【linux kernel 入门及渐进1 -- 常用同步机制】

内核常用锁介绍

1.1 信号量

信号量(信号灯)本质是一个计数器,是描述临界资源中资源数目的计数器,信号量能够更细粒度的对临界资源进行管理。每个执行流在进入临界区之前都应该先申请信号量,申请成功就有了操作特点的临界资源的权限,当操作完毕后就应该释放信号量。

1.1.1 信号量种类

在这里插入图片描述

1.1.2 信号量特点

  • 信号量允许多个进程(计数值>1)同时进入临界区;
  • 如果信号量的计数值为1,一次只允许一个进程进入临界区,这种信号量也就是上面说的二值信号量;
  • 信号量可能会引起进程睡眠,开销较大,适用于保护较长的临界区;

1.1.3 信号量之优先级反转

信号量优先级反转

1.1.4 互斥量与信号量的区别

互斥量用于线程的互斥,信号量用于线程的同步
这是互斥量和信号量的根本区别,也就是互斥和同步之间的区别。

  • 互斥 是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。

  • 同步 是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源

  • 互斥量值只能为0/1,信号量值可以为非负整数
    也就是说,一个互斥量只能用于一个资源的互斥访问,它不能实现多个资源的多线程互斥问题。信号量可以实现多个同类资源的多线程互斥和同步。当信号量为单值信号量是,也可以完成一个资源的互斥访问;

  • 互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到。

1.2 自旋锁

自旋锁spin lock是busy-waiting。就是说当没有可用的锁时,就一直忙等待并不停的进行锁请求,直到得到这个锁为止。这个过程中本cpu始终处于忙状态,不能做别的任务。

需要注意的是持有自旋锁的进程(线程)不允许睡眠,不然会造成死锁——因为睡眠可能造成持有锁的进程被重新调度,因而再次申请自己已持有的锁

typedef struct spinlock {
    union {
        struct raw_spinlock rlock;
    };
} spinlock_t;
typedef struct raw_spinlock {
    arch_spinlock_t raw_lock;
} raw_spinlock_t;
  • Linux 2.6.25 之前:spinlock 数据结构是一个简单无符号类型变量,slock为1表示锁未被持有,为0或者负数表示锁被持有。
  • 在Linux 2.6.25之后:spinlock实现了一套名为 “FIFO ticket-based” 算法的 spinlock机制。

ticket-based 算法的spinlock 仍然使用原来数据结构,但sloclk 被拆分为两部分:

  • owner: 表示锁持有者的等号牌,
  • next: 表示外面排队队列中末尾者的等号牌。
typedef struct {
    union {
        u32 slock;
        struct __raw_tickets {
            u16 owner;
            u16 next;
        } tickets;
    };
} arch_spinlock_t;

加锁:当任务申请自旋锁时,原子地将next域加1,并返回next原来的值,与owner比较,若是相等则说明轮到自己获取锁,否者自旋等待。
解锁:当需要退出临界区域时,将owner加1即可。
结论:通过保存执行线程申请锁顺序信息解决了传统自旋锁的“不公平”问题。

例如:
在一个双核的机器上有两个线程(线程A和线程B),它们分别运行在core0 和Core1上。 用spin-lock, coer0上的线程就会始终占用CPU。

另外一个值得注意的细节是spin lock耗费了更多的user time。这就是因为两个线程分别运行在两个核上,大部分时间只有一个线程能拿到锁,所以另一个线程就一直在它运行的core上进行忙等待,CPU占用率一直是100%;而mutex则不同,当对锁的请求失败后上下文切换就会发生,这样就能空出一个核来进行别的运算任务了。(其实这种上下文切换对已经拿着锁的那个线程性能也是有影响的,因为当该线程释放该锁时它需要通知操作系统去唤醒那些被阻塞的线程,这也是额外的开销)

1.2.1 spink_lock与抢占及中断的关系

spin_lock() 函数最终调用__raw_spin_lock()函数来实现。首先关闭内核抢占,然后调用架构相关的arch_spin_lock()

spin_lock(spinlock_t *lock)
    -->raw_spin_lock(lock)
        -->_raw_spin_lock(lock)
            -->__raw_spin_lock(lock);
                1->preempt_disable();
                2->spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
                3->LOCK_CONTENDED(lock,do_raw_spin_trylock,...)

操作流程:

  • 禁止抢占,这里受CONFIG_PREEMPT_COUNT和CONFIG_PREEMPT开关而不同
  • 如果相关调试选项没有打开,是一个空函数。
  • 在没有定义CONFIG_LOCK_STAT的情况下,就是直接调用do_raw_spin_lock()
do_raw_spin_lock()
    -->arch_spin_lock(&lock->raw_lock)
static inline void arch_spin_lock(arch_spinlock_t *lock)
{
    unsigned long tmp;
    u32 newval;
    arch_spinlock_t lockval;

    prefetchw(&lock->slock);
    __asm__ __volatile__(
    "1:    ldrex    %0, [%3]\n"
    "    add    %1, %0, %4\n"
    "    strex    %2, %1, [%3]\n"
    "    teq    %2, #0\n"
    "    bne    1b"
    : "=&r" (lockval), "=&r" (newval), "=&r" (tmp)
    : "r" (&lock->slock), "I" (1 << TICKET_SHIFT) //1
    : "cc");
    while (lockval.tickets.next != lockval.tickets.owner) { //2
        wfe();
        lockval.tickets.owner = ACCESS_ONCE(lock->tickets.owner); //3
    }
    smp_mb();
}
  1. TICKET_SHIFT为16,也即lock->tickets.next++。
  2. next 是当前进程对应锁的等号牌,判断owner和next是否相等。如相等表示CPU成功获取了spinlock锁,arch_spin_lock()返回;如不等,则调用 wfe 让CPU进入等待状态。
  3. 更新整个系统锁持有者的等号牌, lockval.tickets.owner 一直在变,而lockval.tickets.next是当前位置持锁等号牌,不变。
    4.内存屏障

arch_spin_unlock()更新owner域的值,并且发送指令唤醒CPU。

static inline void arch_spin_unlock(arch_spinlock_t *lock)
{
    smp_mb();
    lock->tickets.owner++;
    //armv8没有调用到dsb_sev,staddlh指令经过exclusive会产生sev的功
    dsb_sev();}

更新 owner,表示所得持有者转到下一个临界区。

1.2.2 WFE 和 WFI 对比

wfi 和 wfe 指令都是让ARM核进入standby睡眠模式。wfi是直到有wfi唤醒事件发生才会唤醒CPU,wfe是直到wfe唤醒事件发生,这两类事件大部分相同。唯一不同之处在于wfe可以被其他CPU上的sev指令唤醒,sec指令用于修改event寄存器的指令。

1.2.3 smp_mb 和 dsb_sev

smp_mb 调用dmb指令来保证把调用函数之前所有的访问内存指令都执行完成。
dsb_sev() 包含两个指令dsb 和 sev:
dsb 指令保证之前的owner与已经写入内存中;
sev指令来唤醒通过wfe指令进入睡眠状态的CPU。
此处arch_spin_lock()的wfe()等待处被唤醒,进行owner更新以及和next比较。

在kernel中,一个线程获得了spinlock,那么这个线程可以被interrupt吗?
答案是可以的,spin_lock 并没有关中断,只关闭了抢占。如果spinlock临界区中允许抢占,那么如果临界区内发生中断,中断返回时会去检查抢占调度

这里有两个问题:
一是抢占调度相当于持有锁的进程睡眠,违背了spinlock锁不能睡眠和快速执行完成的设计;
二是抢占调度进程也有可能会去申请spinlock锁,那么会导致发生死锁。
调用下面这些接口才关中断,这些是在spin_lock可能会被中断上下文拥有时使用的模式。

spin_lock_irqsave,
spin_lock_irq,
spin_lock_bh

关闭抢占是在所有spin_lock中都会做的,下面阐述禁止抢占的原因
如果不禁止内核抢断(或者不禁止中断),可能会有以下的情况发生(假设进程B比进程A具有更高的优先级):假设进程A先获得spinlock lock,由于没有关掉抢占,调度器会调度优先级高的进程B来运行(即发生了进程A被抢占);然后进程B准备去获取spinlock lock,然后进程A还在拿着lock没有释放,于是就产生了死锁(单核)。

spin_lock_irq 函数,即在自旋锁中关闭中断的这类函数,既然已经关闭了本地中断,再禁止抢占有没有多余呢?
既然本地中断已经禁止了,进程(线程)在本处理器上是无法被打断(无法被抢占),因此本core的调度器也无法运行,也就不可以被本地调度程序调度出去。

内核的设计者总是试图将其不能控的代码(所谓的外部因素)可能给内核带来的损失降到最低,这个表现在内核对中断处理框架的设计时尤其明显,所以在UP系统下先后使用local_disable_irqpreempt_disable,只是尽量让可能在spin lock/unlock的临界区中某些混了头的代码不至于给系统带来灾难,因为难保某些人不会在spin lock的临界区中,比如去wake_up_interruptible()一个进程,而被唤醒的进程在可抢占的系统里就是一个打开的潘多拉盒子。

1.2.4 锁的使用选择

  • mutex适合对锁操作非常频繁的场景,并且具有更好的适应性。尽管相比spin lock它会花费更多的开销(主要是上下文切换),但是它能适合实际开发中复杂的应用场景,在保证一定性能的前提下提供更大的灵活度。
  • spin lock的lock/unlock性能更好(花费更少的cpu指令),但是它只适应用于临界区运行时间很短的场景。而在实际软件开发中,除非程序员对自己的程序的锁操作行为非常的了解,否则使用spin lock不是一个好主意(通常一个多线程程序中对锁的操作有数以万次,如果失败的锁操作(contended lock requests)过多的话就会浪费很多的时间进行空等待)。
  • 更保险的方法或许是先(保守的)使用 Mutex,然后如果对性能还有进一步的需求,可以尝试使用spin lock进行调优。毕竟我们的程序不像Linux kernel那样对性能需求那么高(Linux Kernel最常用的锁操作是spin lock和rw lock)。

在Linux内核中何时使用spin_lock,何时使用spin_lock_irqsave很容易混淆。可以看出来他们两者只有一个差别:是否调用local_irq_disable()函数, 即是否禁止本地中断。在任何情况下使用spin_lock_irq都是安全的。因为它既禁止本地中断,又禁止内核抢占。spin_lockspin_lock_irq速度快,但是它并不是任何情况下都是安全的。

举个例子:
进程A中调用了spin_lock(&lock)然后进入临界区,此时来了一个中断(interrupt),该中断也运行在和进程A相同的CPU上,并且在该中断处理程序中恰巧也会spin_lock(&lock)试图获取同一个锁。由于是在同一个CPU上被中断,进程A会被设置为TASK_INTERRUPT状态,中断处理程序无法获得锁, 因为此时锁被进程A拿着,但是中断处理函数会不停的忙等待。此外,由于进程A被设置为中断状态(TASK_INTERRUPT状态),schedule() 进程调度就无法再调度进程A运行(因为A进程不是TASK_RUNNING状态),这样就导致进程A无法释放锁,然后就导致了死锁!

1.2.5 内核可抢占, 单CPU

process1通过系统调用进入内核态,如果其需要访问临界区,则在进入临界区前获得锁,上锁,V=1,然后进入临界区如果process1在内核态执行临界区代码的过程中发生了一个外部中断,当中断处理函数返回时,因为内核的可抢占性,此时将会出现一个调度点,如果CPU的运行队列中出现了一个比当前被中断进程process1优先级更高的进程process2,那么被中断的进程将会被换出处理器,即便此时它正运行于内核态。如果process2也通过系统调用进入内核态,且要访问相同的临界区,则会形成死锁(因为拥有锁的Process1永没有机会再运行从而释放锁)

1.2.6 内核可抢占SMP

CPU1上的process1通过系统调用进入内核态,如果其需要访问临界区,则在进入临界区前获得锁,上锁,V=1,然后进入临界区如果process1在内核态执行临界区代码的过程中发生了一个外部中断,当中断处理函数返回时,因为内核的可抢占性,此时将会出现一个调度点,如果CPU1的运行队列中出现了一个比当前被中断进程process1优先级更高的进程process2,那么被中断的进程process1将会被换出处理器,即便此时它正运行于内核态。如果CPU2上的process3也通过系统调用进入内核态,且要访问相同的临界区,也一样形成死锁。

1.4 RCU 锁

RCU锁是linux2.6引入的,非常高效的,适合读多写少的情况; 全称是(Read-Copy Update)读-拷贝修改。原理就是:
读操作的时候:不需要任何锁,直接进行读取,
写操作的时候:先拷贝一个副本,然后对副本进行修改,最后使用回调(callback)在适当的时候把指向原来数据的指针指向新的被修改的数据。

这里的rcu_head就存储了对这个结构上rcu锁所需要的回调信息。

struct callback_head {
    struct callback_head *next;
    void (*func)(struct callback_head *head);
} __attribute__((aligned(sizeof(void *))));
#define rcu_head callback_head

可以多个线程同时占用读模式的读写锁,但是只能一个线程占用写模式的读写锁,即
(1)当有人拿到写锁的时候,任何人都不能再拿到读锁或 写锁
(2)当有人拿到读锁的时候,其他人也可以拿到读锁,但不能有人拿到写锁去修改临界区
(3)已经加了读锁时,如有人尝试拿写锁,需要尽快得到满足,避免写锁饥饿

推荐阅读:
https://www.ibm.com/developerworks/cn/linux/l-rcu/
https://www.cnblogs.com/arnoldlu/p/9236304.html#arch_spin_lock

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

主公CodingCos

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值