九、进阶 | Linux 内核中的同步原语(6)
Linux内核中的同步原语。第6部分。
引言
这是描述Linux内核中同步原语的章节的第六部分,在前面的部分中,我们讨论了不同的读写锁同步原语。在这一部分,我们将继续学习同步原语,并开始考虑一种类似的同步原语,它可以用于避免“写者饥饿”问题。这种同步原语的名称是seqlock
或“顺序锁”。
从之前的部分我们知道,读写锁是一种特殊的锁定机制,它允许对只读操作进行并发访问,但写入或修改数据需要独占锁。正如我们可能猜到的,这可能导致所谓的“写者饥饿”问题。换句话说,只要至少有一个持有锁的读取进程,写者进程就无法获得锁。因此,在争用率高的情况下,这将导致想要获取锁的写者进程长时间等待。
seqlock
同步原语可以帮助解决这个问题。
就像这本书的所有前面部分一样,我们将尝试从理论角度考虑这种同步原语,然后我们将考虑Linux内核提供的用于操作seqlocks
的API。
那么,让我们开始吧。
顺序锁
那么,什么是seqlock
同步原语,它是如何工作的?让我们在这一段中尝试回答这些问题。实际上,顺序锁
是在Linux内核2.6.x中引入的。这种同步原语的主要目的是为共享资源提供快速且无锁的访问。由于顺序锁
同步原语的核心是自旋锁同步原语,所以顺序锁
适用于受保护资源小且简单的情况。此外,写入访问必须很少,而且应该很快。
这种同步原语的工作基于事件计数器的序列。实际上,顺序锁
允许读者自由访问资源,但每个读者必须检查与写者的冲突。这种同步原语引入了一个特殊的计数器。顺序锁
的主要算法很简单:每个获取顺序锁的写者都会增加这个计数器,并额外获取一个自旋锁。当这个写者完成时,它将释放获取的自旋锁,以便其他写者访问,并再次增加顺序锁的计数器。
只读访问的工作原则是,在进入临界区之前获取顺序锁
计数器的值,并在退出临界区时与同一个顺序锁
计数器的值进行比较。如果它们的值相等,这意味着在这个时期没有写者。如果它们的值不相等,这意味着一个写者在临界区期间增加了计数器。这种冲突意味着必须重复读取受保护的数据。
就这些。正如我们所看到的,顺序锁
的工作原理很简单。
unsigned int seq_counter_value;
do {
seq_counter_value = get_seq_counter_val(&the_lock);
//
// 在这里做我们想做的
//
} while (__retry__);
实际上,Linux内核并没有提供get_seq_counter_val()
函数。这里它只是一个桩。像__retry__
一样。正如我上面已经写的,我们将在这部分的下一段中看到实际的API。
好的,现在我们知道了seqlock
同步原语是什么,以及它在Linux内核中的表示。在这种情况下,我们可以继续并开始查看Linux内核为这种类型的同步原语提供的API。
顺序锁API
现在我们已经从理论上了解了顺序锁
同步原语,让我们看看它在Linux内核中的实现。所有的顺序锁
API都位于include/linux/seqlock.h头文件中。
首先我们可以看到,顺序锁
机制由以下类型表示:
typedef struct {
struct seqcount seqcount;
spinlock_t lock;
} seqlock_t;
正如我们所看到的,seqlock_t
提供了两个字段。这些字段代表我们上面看到的顺序锁计数器,以及将保护数据免受其他写者影响的自旋锁。注意seqcount
计数器表示为seqcount
类型。seqcount
是结构:
typedef struct seqcount {
unsigned sequence;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
} seqcount_t;
它持有顺序锁的计数器和锁验证器相关字段。
像这个章节的前面部分一样,在考虑Linux内核中顺序锁
机制的API之前,我们需要知道如何初始化seqlock_t
的实例。
我们在前面的部分中看到,Linux内核通常提供两种方法来初始化给定的同步原语。seqlock_t
结构的情况也是如此。这些方法允许以以下两种方式初始化seqlock_t
:
静态地
;动态地
。
让我们看看第一种方法。我们可以使用DEFINE_SEQLOCK
宏静态初始化seqlock_t
:
#define DEFINE_SEQLOCK(x) \
seqlock_t x = __SEQLOCK_UNLOCKED(x)
它在include/linux/seqlock.h头文件中定义。正如我们所看到的,DEFINE_SEQLOCK
宏接受一个参数,并展开为seqlock_t
结构的定义和初始化。初始化是通过__SEQLOCK_UNLOCKED
宏的帮助完成的,它在同一个源代码文件中定义。让我们看看这个宏的实现:
#define __SEQLOCK_UNLOCKED(lockname) \
{ \
.seqcount = SEQCNT_ZERO(lockname), \
.lock = __SPIN_LOCK_UNLOCKED(lockname) \
}
正如我们所看到的,__SEQLOCK_UNLOCKED
宏执行给定seqlock_t
结构的字段初始化。第一个字段是seqcount
,它通过SEQCNT_ZERO
宏初始化,它展开为:
#define SEQCNT_ZERO(lockname) { .sequence = 0, SEQCOUNT_DEP_MAP_INIT(lockname)}
所以我们只是将给定顺序锁的计数器初始化为零,另外我们还可以看到锁验证器相关的初始化,这取决于CONFIG_DEBUG_LOCK_ALLOC
内核配置选项的状态:
#ifdef CONFIG_DEBUG_LOCK_ALLOC
# define SEQCOUNT_DEP_MAP_INIT(lockname) \
.dep_map = { .name = #lockname } \
...
...
...
#else
# define SEQCOUNT_DEP_MAP_INIT(lockname)
...
...
...
#endif
正如我在本章节的前面部分中已经写的,我们不会在这部分考虑调试和锁验证器相关的内容。所以现在我们只是跳过SEQCOUNT_DEP_MAP_INIT
宏。给定seqlock_t
的第二个字段是lock
,它通过__SPIN_LOCK_UNLOCKED
宏初始化,它在include/linux/spinlock_types.h头文件中定义。我们不会在这里考虑这个宏的实现,因为它只是用体系结构特定的方法初始化rawspinlock(你可以在本章节的第一部分中阅读更多关于自旋锁的内容)。
我们已经考虑了初始化顺序锁的第一种方法。让我们考虑第二种方法,但以动态方式进行。我们可以使用在同一个include/linux/seqlock.h头文件中定义的seqlock_init
宏来初始化顺序锁。
让我们看看这个宏的实现:
#define seqlock_init(x) \
do { \
seqcount_init(&(x)->seqcount);
spin_lock_init(&(x)->lock); \
} while (0)
正如我们所看到的,seqlock_init
展开为两个宏。第一个宏seqcount_init
接受给定顺序锁的计数器,并展开为调用__seqcount_init
函数:
# define seqcount_init(s) \
do { \
static struct lock_class_key __key; \
__seqcount_init((s), #s, &__key); \
} while (0)
来自同一个头文件。这个函数
static inline void __seqcount_init(seqcount_t *s, const char *name,
struct lock_class_key *key)
{
lockdep_init_map(&s->dep_map, name, key, 0);
s->sequence = 0;
}
只是将给定的seqcount_t
的计数器初始化为零。seqlock_init
宏的第二个调用是spin_lock_init
宏的调用,我们在本章的第一部分中看到了。
所以,现在我们知道如何初始化一个顺序锁
,让我们看看如何使用它。Linux内核提供了以下API来操作顺序锁
:
static inline unsigned read_seqbegin(const seqlock_t *sl);
static inline unsigned read_seqretry(const seqlock_t *sl, unsigned start);
static inline void write_seqlock(seqlock_t *sl);
static inline void write_sequnlock(seqlock_t *sl);
static inline void write_seqlock_irq(seqlock_t *sl);
static inline void write_sequnlock_irq(seqlock_t *sl);
static inline void read_seqlock_excl(seqlock_t *sl)
static inline void read_sequnlock_excl(seqlock_t *sl)
等等。在我们继续考虑这个API的实现之前,我们必须知道实际上有两种类型的读者。第一种类型的读者永远不会阻止写者进程。在这种情况下,写者不会等待读者。第二种类型的读者可以锁定。在这种情况下,锁定的读者将阻止写者,因为它会等待读者不释放其锁。
首先让我们考虑第一种类型的读者。read_seqbegin
函数开始一个seq-read 临界区。
正如我们所看到的,这个函数只是返回read_seqcount_begin
函数的值:
static inline unsigned read_seqbegin(const seqlock_t *sl)
{
return read_seqcount_begin(&sl->seqcount);
}
反过来,read_seqcount_begin
函数调用raw_read_seqcount_begin
函数:
static inline unsigned read_seqcount_begin(const seqcount_t *s)
{
return raw_read_seqcount_begin(s);
}
它只是返回顺序锁
计数器的值:
static inline unsigned raw_read_seqcount(const seqcount_t *s)
{
unsigned ret = READ_ONCE(s->sequence);
smp_rmb();
return ret;
}
在我们有了给定顺序锁
计数器的初始值并做了一些事情之后,我们知道从这个函数的前一段,我们需要在退出临界区之前将其与同一个顺序锁
计数器的当前值进行比较。我们可以通过调用read_seqretry
函数来实现这一点。这个函数接受一个顺序锁
,计数器的起始值,并通过一系列函数:
static inline unsigned read_seqretry(const seqlock_t *sl, unsigned start)
{
return read_seqcount_retry(&sl->seqcount, start);
}
static inline int read_seqcount_retry(const seqcount_t *s, unsigned start)
{
smp_rmb();
return __read_seqcount_retry(s, start);
}
它调用__read_seqcount_retry
函数:
static inline int __read_seqcount_retry(const seqcount_t *s, unsigned start)
{
return unlikely(s->sequence != start);
}
它只是将给定顺序锁
的计数器值与这个计数器的初始值进行比较。如果从read_seqbegin()
函数获得的计数器的初始值是奇数,这意味着当读者开始行动时,写者正在更新数据的过程中。在这种情况下,数据值可能处于不一致的状态,所以我们需要再次尝试读取它。
这是Linux内核中常见的模式。例如,你可能还记得第一部分中的jiffies
概念。顺序锁用于在x86_64体系结构中获取jiffies
的值:
u64 get_jiffies_64(void)
{
unsigned long seq;
u64 ret;
do {
seq = read_seqbegin(&jiffies_lock);
ret = jiffies_64;
} while (read_seqretry(&jiffies_lock, seq));
return ret;
}
在这里,我们只是读取jiffies_lock
顺序锁的计数器值,然后将jiffies_64
系统变量的值写入ret
。由于这里我们可以看到do/while
循环,循环体至少执行一次。所以,当循环体被执行时,我们读取并比较jiffies_lock
的计数器的当前值与初始值。如果这些值不相等,循环的执行将被重复,否则get_jiffies_64
将在ret
中返回其值。
我们刚刚看到了第一种类型的读者,它们不会阻止写者和其他读者。让我们考虑第二种类型。它不更新顺序锁
计数器的值,只是锁定自旋锁
:
static inline void read_seqlock_excl(seqlock_t *sl)
{
spin_lock(&sl->lock);
}
所以,没有读者或写者可以访问受保护的数据。当读者完成时,必须使用以下方法解锁:
static inline void read_sequnlock_excl(seqlock_t *sl)
{
spin_unlock(&sl->lock);
}
函数。
现在我们知道了顺序锁
对读者的工作方式。让我们考虑一下当写者想要获取顺序锁
以修改数据时的行为。要获取顺序锁
,写者应该使用write_seqlock
函数。如果我们看看这个函数的实现:
static inline void write_seqlock(seqlock_t *sl)
{
spin_lock(&sl->lock);
write_seqcount_begin(&sl->seqcount);
}
我们将看到它获取自旋锁
以防止其他写者的访问,并调用write_seqcount_begin
函数。这个函数只是增加顺序锁
计数器的值:
static inline void raw_write_seqcount_begin(seqcount_t *s)
{
s->sequence++;
smp_wmb();
}
当写者进程完成修改数据时,必须调用write_sequnlock
函数来释放锁并允许其他写者或读者访问。让我们考虑write_sequnlock
函数的实现。它看起来相当简单:
static inline void write_sequnlock(seqlock_t *sl)
{
write_seqcount_end(&sl->seqcount);
spin_unlock(&sl->lock);
}
首先它只是调用write_seqcount_end
函数再次增加顺序
锁的计数器值:
static inline void raw_write_seqcount_end(seqcount_t *s)
{
smp_wmb();
s->sequence++;
}
最后我们只是调用spin_unlock
宏来允许其他读者或写者访问。
这就是Linux内核中顺序锁
机制的全部内容。当然,我们在这一部分没有考虑这个机制的全部API。但所有其他函数都基于我们在这里描述的这些。例如,Linux内核还提供了一些安全的宏/函数,用于在软IRQ的中断处理程序中使用顺序锁
机制:write_seqclock_irq
和write_sequnlock_irq
:
static inline void write_seqlock_irq(seqlock_t *sl)
{
spin_lock_irq(&sl->lock);
write_seqcount_begin(&sl->seqcount);
}
static inline void write_sequnlock_irq(seqlock_t *sl)
{
write_seqcount_end(&sl->seqcount);
spin_unlock_irq(&sl->lock);
}
正如我们所看到的,这些函数只在自旋锁的初始化上有所不同。它们调用spin_lock_irq
和spin_unlock_irq
而不是spin_lock
和spin_unlock
。
或者例如write_seqlock_irqsave
和write_sequnlock_irqrestore
函数,它们与上述函数相同,但使用spin_lock_irqsave
和spin_unlock_irqrestore
宏在IRQ处理程序中使用。
就这些。
结论
这是Linux内核中同步原语章节的第六部分的结束。在这部分中,我们遇到了一种名为顺序锁
的新同步原语。从理论上讲,这种同步原语与读写锁同步原语非常相似,但允许避免“写者饥饿”问题。
如果您有任何问题或建议,请随时在Twitter上联系我0xAX,给我发电子邮件,或者在这里创建一个问题。
请注意,英语不是我的母语,如果给您带来任何不便,我深表歉意。如果您发现任何错误,请向我发送PR到linux-insides。
链接
"《Linux嵌入式必考必会》专栏,专为嵌入式开发者量身打造,聚焦Linux环境下嵌入式系统面试高频考点。涵盖基础架构、内核原理、驱动开发、系统优化等核心技能,实战案例与理论解析并重。助你快速掌握面试关键,提升竞争力。