九、进阶 | Linux 内核中的同步原语(6)

Linux内核中的同步原语。第6部分。

引言

这是描述Linux内核中同步原语的章节的第六部分,在前面的部分中,我们讨论了不同的读写锁同步原语。在这一部分,我们将继续学习同步原语,并开始考虑一种类似的同步原语,它可以用于避免“写者饥饿”问题。这种同步原语的名称是seqlock或“顺序锁”。

从之前的部分我们知道,读写锁是一种特殊的锁定机制,它允许对只读操作进行并发访问,但写入或修改数据需要独占锁。正如我们可能猜到的,这可能导致所谓的“写者饥饿”问题。换句话说,只要至少有一个持有锁的读取进程,写者进程就无法获得锁。因此,在争用率高的情况下,这将导致想要获取锁的写者进程长时间等待。

seqlock同步原语可以帮助解决这个问题。

就像这本书的所有前面部分一样,我们将尝试从理论角度考虑这种同步原语,然后我们将考虑Linux内核提供的用于操作seqlocksAPI

那么,让我们开始吧。

顺序锁

那么,什么是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_irqwrite_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_irqspin_unlock_irq而不是spin_lockspin_unlock

或者例如write_seqlock_irqsavewrite_sequnlock_irqrestore函数,它们与上述函数相同,但使用spin_lock_irqsavespin_unlock_irqrestore宏在IRQ处理程序中使用。

就这些。

结论

这是Linux内核中同步原语章节的第六部分的结束。在这部分中,我们遇到了一种名为顺序锁的新同步原语。从理论上讲,这种同步原语与读写锁同步原语非常相似,但允许避免“写者饥饿”问题。

如果您有任何问题或建议,请随时在Twitter上联系我0xAX,给我发电子邮件,或者在这里创建一个问题。

请注意,英语不是我的母语,如果给您带来任何不便,我深表歉意。如果您发现任何错误,请向我发送PR到linux-insides

链接

Linux嵌入式必考必会 文章被收录于专栏

"《Linux嵌入式必考必会》专栏,专为嵌入式开发者量身打造,聚焦Linux环境下嵌入式系统面试高频考点。涵盖基础架构、内核原理、驱动开发、系统优化等核心技能,实战案例与理论解析并重。助你快速掌握面试关键,提升竞争力。

全部评论

相关推荐

点赞 评论 收藏
分享
点赞 收藏 评论
分享
牛客网
牛客企业服务