linux内核同步机制实现

 

个人公众号:linux进击之路

​锁是多CPU运行时保证功能正常必备的同步机制,本文先总结内核常用锁的实现原理,然后借鉴内核实现,开发用户态直接操作的锁同步机制。


 

自旋锁

实现机制

自旋锁不会导致进程睡眠和调度,因此对于不可打断的场景,如中断上下文中,通常会使用自旋锁来保护数据。老版内核的自旋锁维护一个计数值count并初始为1,第一个进程加持锁时,使用CAS原子操作更新count值,CAS语义实现如下:

int CAS(int *ptr,int old, int new){    int actual = *ptr;    if (actual == old) {        *ptr = new;    }    return actual;}void spin_lock(spinlock *lock){    while (CAS(*lock->count, 1, 1) == 1) {        //spin;    }}void spin_unlock(spinlock *lock){    lock->count = 0;}

这种简单的CAS实现,虽然能够满足互斥的功能,但是资源释放后,等待在自旋锁上的多个进程是去竞争自旋锁的,而非先等待的先获取锁。为了解决这个问题,linux更新了自旋锁实现机制:类似于银行取票排队等待的逻辑,先到先得,叫号到谁,谁就上,避免无序竞争。内核实现自旋锁的结构体简化如下:

struct tickets {  int owner; // 当前允许办理业务(加持锁)的序号  int next; // 下一个要办理业务的序号}struct raw_spinlock {  struct tickets ticket;}struct spinlock {  struct raw_spinlock rlock;}

排队逻辑要求owner等于next时,进程才会成功持有自选锁,否则自选。每个进程加锁时,都会记录当前的next值,表示自己的排队号,并且next递增,保证下一个进程加锁时,获取的next值是稍大的,从而达到排序的目的。自旋锁加锁解锁实现逻辑简化如下:

void spin_lock(spinlock *lock){    int tmp = lock->rlock.ticket.next;    lock->rlock.ticket.next++;    while (tmp != lock->rlock.ticket.owner) {        // spin;    }}void spin_unlock(spinlock *lock){    lock->rlock.ticket.owner++;}

上述自旋锁结构的设计逻辑可以很好的保证先到先得的思想,但是在多cpu场景会存在一个性能问题:自旋锁变量是存放在主存上的,每个cpu有自己的L1/L2缓存机制,这就要求主存到cpu缓存之间存在同步机制。当cpu0运行的进程加持锁之后,会更新主存上的next值,导致其他cpu的缓存值失效,这就比较浪费性能了。

linux内核针对上述性能问题,重新设计了自旋锁结构,使得不同cpu在主存上各自拥有排队结构,然后等待在各自的排队结构体变量上。想要深入了解新机制的,请移驾搜索引擎或者查阅新版Linux内核。

初学者看到这里的时候,一定会有个疑惑:为什么会有raw_spinlock和spinlock两种自旋锁结构?其实还有第三种arch_spinlock,arch_spinlock是用来兼容不同架构的,不同的架构其arch_spinlock实现机制大同小异,内核也会遵循"存在即合理"的真谛。回归正题,部分内核支持PREEMPT_RT选项,该选项会导致spinlock依旧会被抢占。而raw_spinlock加锁时,会禁用抢占机制,确保进程会自旋等待,而不是休眠。所以内核开发时,对于不能抢占的场景,可以直接使用底层的raw_spinlock来实现同步互斥的目的。

 

自旋锁使用场景

  • 多个进程上下文使用自选锁保护共享自选

  1. 同一个cpu上运行的多个进程竞争

    假使进程A使用共享资源前,使用spinlock进入临界区。在使用资源的过程中,被中断打断,中断处理函数中调度进程B,进程B获取自选锁时,会自选等待A退出临界区,这就导致了死锁。针对这种场景,获取锁时,还必须禁用中断(如果是中断上半部即硬中断,则调用spin_lock_irq内核原生接口禁用硬中断并加锁,如果是中断下半部bottom_half即软中断,则调用spin_lock_bh接口关闭软中断并加锁)

  2. 不同cpu运行的多进程竞争

    cpu0的A进程加锁使用资源时,cpu1的B进程加持自旋锁,B会原地等待。由于不同cpu的调度是独立的,A正常使用完资源退出临界区时,B就会退出自旋状态

    小结:上述场景虽然是在多个进程上下文中竞争资源,但是穿插了中断后,才会引入死锁的问题。因此可以归结为中断上下文+进程上下文同时使用资源的场景,这类场景需要自旋锁+禁用中断或者自旋锁+禁用抢占功能(raw_spinlock属于此类型)。

 

  • 多中断上下文竞争资源

    对于同一种硬中断来说,linux内核不会并发执行,无需保护。对于同一种软中断来说,多cpu并发时,存在同时运行的可能,这种场景只需要加自旋锁保护即可,无需禁用中断。


 

信号量

信号量内部是基于自选锁来实现的,并且用的是底层raw_spinlock。信号量的结构设计如下:

struct semaphore {    struct raw_spinlock lock; // 确保信号量计数器的操作是互斥的    int count; // 计数器    struct list_head wait_list; // 等待队列头节点}struct semaphore_wait {    struct list_head list; // 挂接在等待队列上    struct task_struct *task; // 记录等待的进程}

理解信号量结构设计思想后,对应的down、up接口实现逻辑也就不难了。实现逻辑简化如下:

// down接口void down(struct semaphore *sem){    unsigned long flag;    struct semaphore_wait waiter;        /* 保存硬中断寄存器值,关闭硬中断,加锁 */    raw_spin_lock_irqsave(flag);    if (sem->count > 0)        sem->count--;    else {        list_add_tail(&waiter.list, &sem->wait_list);        waiter.task = current;        /* 设置进程状态为不可打断模式 */        __set_task_state(waiter.task, UNINPTERRUPTIBLE);        schedule();    }    /* 恢复硬中断寄存器值,打开硬中断,解锁 */    raw_spin_lock_irqrestore(flag);}void up(struct semaphore *sem){    unsigned long flag;    struct semaphore_wait *waiter = NULL;    raw_spin_lock_irqsave(flag);    if (list_empty(&sem->wait_list))        sem->count++;    else {        waiter = list_first_entry(&sem->wait_list, struct semaphore_wait, list);        list_del(&waiter->list);        wake_up(waiter->task);    }    raw_spin_lock_irqrestore(flag);}

 

互斥量mutex

mutex同步机制和semaphore机制类似,信号量的计数值可以为多个,mutex的计数值只能为1,0,负数。二者的差异点允许多个进程同时使用资源,mutex只允许同一时刻只有一个进程能持有锁使用资源。自旋锁也是基于自选锁实现的。mutex结构体设计简化如下:

struct mutex {    atomic_t count; // 不知道为啥有自旋锁还用原子类型的计数    spinlock wait_lock;    struct list_head wait_list;}

其实现逻辑类似于信号量,不再赘述。


 

读写锁

上述讲解的同步机制不区分读取数据和更新数据的情况,锁的粒度相关粗一些。而读写锁将读场景和写数据的场景分割开,读锁允许多个进程同时进入临界区;写锁具有排他性,写锁会阻塞,但是后续新加持的读锁依旧能够进入临界区(这种机制会导致加持写锁的进程饿死的情况,正常应该是加写锁后,后续新增的读锁也阻塞才对,但是还未见到这种实现)。加持写锁的进程必须等待读锁都退出临界区后,才会进入临界区。因此,读写锁需要统计读锁的数量以及是否存在写锁。内核采用一个volatile类型的整数计数器counter来记录:最高位表示是否存在写锁,低31位记录当前存在的读锁。volatile类型保证counter是修改对别的进程来说是立刻可见的(同自选锁的计票系统一样,存在cpu缓存和主存间同步的问题,volatile可以使得cpu对主存上counter的修改,快速让别的进程感知),但是volatile是无法保证原子性的,所以对counter的操作必须是原子的。读写锁设计实现简化如下:

typedef struct {    volatile unsigned int counter;} arch_rwlock_t;/* 内核使用汇编语言实现加解锁,对应C逻辑如下 */void read_lock(arch_rwlock_t *lock){    unsigned int tmp;    selv; // 汇编指令,保证第一次执行wfe时,不会睡眠    do {        wfe; // 汇编指令,使进程睡眠        tmp = lock->counter;        tmp++;    } while (tmp & (1 << 32)); // 没有写锁处于临界区时,退出循环    lock->counter = tmp;}void read_unlock(arch_rwlock_t *lock){    lock->counter--;    sev; // 唤醒睡眠的CPU}void write_lock(arch_rwlock_t *lock){    unsigned int tmp;    sel;    do {        wfe;        tmp = lock->counter;    } while(tmp);    lock->counter = 1 << 32;}void write_unlock(arch_rwlock_t *lock){    lock->counter = 0; // 写锁存在时,不可能有读锁,所以直接置0    sev;}

 

   RCU同步机制

看过内核网络协议栈的人,对RCU不会陌生,很多函数中都用到了RCU机制,比如内核接收报文后判断是否需要送入桥模块处理时,获取skb->dev->rx_handler指针前,调用了rcu_dereference接口将该指针转换成受RCU保护的指针。RCU类似于读写锁,将读写场景分开,RCU机制允许多个读者随意读和一个写者更新数据同时发生,但是不允许多个写者同时发生。存在多个写者时,需要采用其他同步机制来实现互斥,RCU机制并不提供写者间的互斥功能。

RCU展开了说就是读-拷贝更新,当受RCU保护的指针需要更新时,先申请一块内存,将数据拷贝出来(和COW一样,写时拷贝),在副本上做需改,然后调用synchronize_rcu等待读者都退出临界区,或者调用call_rcu注册回调函数,等待宽限期到来后,将原数据地址引用修改为新申请的内存地址,并释放原有数据占据的内存空间,避免内存泄漏。因此RCU同步机制受限于以下场景:

  • RCU只能保护动态生成的数据结构,并且必须通过指针来访问数据接口,常见于链表

  • 受RCU保护的临界区不能睡眠,因此RCU通常用自旋锁来保证写者间互斥

  • 对写场景的性能要求不高,读场景要求很高

  • 读者对新旧数据不敏感


 

自实现用户态同步机制

几种常用同步机制的内核实现就先总结到这里。总体来看,锁机制的是实现逻辑是很好理解的,而且也来源于生活中存在的场景,比如自旋锁的银行排队机制。上述锁都是内核中实现的,如果在用户态直接执行相应的系统调用是会耗费性能(系统调用会使进程限于内核态,涉及用户态堆栈、cpu寄存器值的保存与恢复)。理解了锁的实现后,可以认为同步即原子操作,只要在用户态能实现原子操作,就可以实现用户态使用的同步机制了——atomic_t类型为实现用户态同步机制提供了方便。

atomic_t类型声明的变量具有原子性,CPU执行该类型变量对应的汇编执行时原子性的。借鉴内核的锁实现思想,在用户态声明一个atomic_t类型的整型计数器lockval,用户态进程直接原子操作lockval实现互斥。lockval原子递减后,如果不为0,说明不存在竞争关系,可以在用户态直接使用资源;反之在执行系统调用wait让进程睡眠,等待资源释放。

上述思路抽象成下述代码逻辑:

// 用户态加锁接口void lock(int lockval){    /* trylock尝试加锁:原子性递减locval */    while (trylock(lockval) == 0) {        wait();    }}

上述逻辑和内核中信号量的加锁接口类似。区别点是信号量操作lockval前,使用自旋锁保护了临界区,而上述实现逻辑是没有的,所以上述逻辑是存在缺陷的:trylock和wait之间存在时间窗口,如果trylock执行后,持有锁的进程释放了锁,而本进程不感知,会继续调用wait睡眠,且后续不会被唤醒。对于这种情况,我们也可以像内核一样,在trylock原子操作计数器前加锁保护,但是这违背了我们避免直接陷入内核的初衷。

感恩内核开发者,内核提供了futex同步机制(快速用户态互斥体),可以很好的解决这个问题。futex机制提供了futex_wait/futex_wake来替代上述实现中的wait。futex_wait接口要求用户进程调用时,除了传递lockval外,还必须把存储lockval的地址uaddr传递下去,futex_wait内部判断当前uaddr记录的值和lockval一致时,才会睡眠,否则直接返回。

futex在内核态维护了futex_hash_bucket类型的大数组,来记录不同uaddr记录的锁变量上等待的队列,每个队列都会采用spinlock来同步,原型如下:

struct futex_hash_bucket {    spinlock lock; // 保证多进程操作chain时互斥    struct plist_head chain; // 等待队列的头结点}static futex_hash_bucket futex_queue[N]; // hash桶struct futex_q {    struct plist_head list; // 该节点挂接到hash桶的chain上    struct task_struct *task; // 准备睡眠的进程    spinlock *lock_ptr; // 指向futex_hash_bucket中的lock,确保判断uaddr记录的值等于lockval的比较逻辑和进程睡眠逻辑处于同一临界区中    union futex_key key; // 用于计算hash值,找到对应的hash桶}

每次调用futex_wait都会创建futex_q结构变量,用于表征*uaddr对应的锁。futex_wait内部实现简化如下:

/* 只有当*uaddr == lockval时才会睡眠 */void futex_wait(u32 *uaddr, u32 lockval){    struct futex_q q;    struct futex_hash_bucket *bh;        init(&q);    bh = queue_lock(&q);    u32 uval = get_phyaddr_val(uaddr); //找到对应物理地址上存储的数值    if (uval != lockval) {        queue_unlock(&q);    }    set_current_state(TASK_INTERRUPTIBLE);    queue_me(&q); // 计算q对应的hash值,插入到对应hash桶的等待队列上,并解锁    schedule();}/* 用户态加锁接口实现更新如下 */void lock(int lockval){    /* trylock尝试加锁:原子性递减locval */    while (trylock(lockval) == 0) {        futex_wait(&lockval, lockval);    }}

the end.

本文理解如果有误,请大神指点修正。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值