临界区和竞争条件: 临界区:访问和操作共享数据的代码段。 竞争条件:多个线程同时在临界区同时运行,就构成了竞争条件。 所谓同步,就是防止在临界区形成竞争条件。
内核出现竞争条件的情况:
- 中断:中断几乎在任何时候异步发生,在中断服务程序中在临界区同时访问同个共享资源
- 软中断和tasklet:内核在任何时刻唤醒或调度软中断和tasklet
- 内核抢占:内核具有抢占性,内核中的任务可以被另一个进程抢占
- 睡眠及用户空间同步:当进程睡眠时,调度程序会唤醒另一个用户进程进行调度执行
- 对称多处理:两个及多个处理器同时执行代码 编码前就要想好临界区在哪里,以及怎么加锁
死锁就是所有的线程都在相互等待释放资源,导致谁也无法继续执行下去。 防止死锁发生的一些规则:
- 如果有多个锁的话,尽量确保每个线程都是按相同的顺序加锁,按加锁相反的顺序解锁。(即加锁a->b->c,解锁c->b->a)
- 防止发生饥饿。即设置一个超时时间,防止一直等待下去。
- 不要重复请求同一个锁。
- 设计应力求简单。加锁的方案越复杂就越容易出现死锁。
粒度过粗的锁,过导致频繁争用,造成系统性能瓶颈;粒度过细的锁,会导致系统开销越大,程序越复杂。
Linux内核提供了一组相当完备的同步方法:原子操作、自旋锁、读写自旋锁、信号量、读写信号量、互斥体、完成变量、大内核锁、顺序锁、禁止抢占、顺序和屏障。
原子操作是由编译器保证,指令以原子的方式执行——执行过程不会被打断,从而保证保证一个线程对数据的操作不会被其他线程打断。 原子操作有2类:
- 原子正数操作。分为32位和64位。针对整数的原子操作只能针对atomic_t类型(64位为atomic64_t)的数据进行处理,接口举例:atomic_inc 原子地自增1,atomic_dec 原子地自减1。
- 原子位操作。针对普通指针进行操作,不像原子整型对应atomic_t。接口举例:set_bit 原子地设置第n位,clear_bit 原子地清除第n位
原子操作只能用于临界区只有一个变量的情况。实际应用中,临界区的情况会复杂很多。 Linux内核中最常见的锁就是自旋锁。自旋锁的特点是当一个线程获取锁后,其他试图获取这个争用锁的线程会一直循环等待获取这个锁,直到锁重新可用。 由于线程是在一直循环的获取这个锁,所以会造成CPU处理时间的浪费,因此最好将自旋锁用于能很快处理完的临界区。 自旋锁的方法: spin_lock(), spin_unlock() 自旋锁在使用时有2点要注意:
- 自旋锁是不可递归的,递归请求同一个自旋锁会导致死锁;
- 线程获取自旋锁之前,要禁止当前处理器的中断。接口:spin_lock_irqsave()、spin_lock_irqrestore()。其中irq全称为interupt request中断请求 自旋锁方法列表如下: spin_lock: 获取指定的自旋锁 spin_unlock: 释放指定的锁 spin_lock_irqsave:保存当前中断状态,禁止本地中断,获取自旋锁 spin_lock_irqrestore:释放指定的锁,恢复中断到加锁前的状态 spin_trylock:试图获取指定的锁,如果未获取,则返回非0 spin_is_locked():如果指定的锁当前正在被获取,则返回非0,否则返回0
读写自旋锁除了具有普通自旋锁的自旋的特性外,还具有以下特点:
- 读锁之间是共享的(可递归) 即一个线程持有读锁后,其他线程可继续持有读锁
- 写锁之间是互斥的(不可递归) 即一个线程持有写锁后,其他线程争用该锁时会不断循环等待(自旋)
- 读与写之间是互斥的 即一个线程持有读锁时,其他线程不能以写的方式持有这个锁
读写自旋锁的方法列表如下: read_lock():以读的方式持有锁 read_unlock():释放指定的读锁 write_lock():获取指定的写锁 write_unlock() :释放指定的写锁
信号量的结构如下:
/* Please don't access any members of this structure directly */
struct semaphore {
spinlock_t lock;
unsigned int count;
struct list_head wait_list;
};
可以看到信号量内部持有了一个自旋锁、一个计数量和一个等待队列。信号量是一种睡眠锁。顾名思义,当线程在争用一个已被占用的信号量时,与自旋锁不同,信号量会将线程推进等待队列中睡眠。直到信号量可用(释放)时,才会唤醒睡眠的线程,进入临界区执行。 由于使用信号量时,线程会睡眠,等待的过程不会占用cpu时间,因此适合于等待时间较长的临界区。 信号量消耗cpu时间在于:使线程睡眠 和 唤醒线程。 如果使线程睡眠 和 唤醒线程 的cpu时间 > 线程自旋等待的时间,那么建议还是用自旋锁。
信号量的分类:计数信号量和互斥信号量(二值信号量)。 计数信号量:允许count个线程持有信号量,访问临界区。比如count=5,允许5个线程同时进入临界区。 二值信号量:只允许一个线程持有信号量,访问临界区。即计数信号量count=1的情况。 信号量的方法: sema_init(struct semaphore *, int):以指定的计数值初始化动态创建的信号量 init_MUTEX(struct semaphore *):以计数值1初始化动态创建的信号量 down_interruptible(struct semaphore *):试图获取指定信号量,若信号量已被争用,则进入可中断睡眠状态 down(struct semaphore *):图获取指定信号量,若信号量已被争用,则进入不可中断睡眠状态 up(struct semaphore *):释放指定信号量,若等待队列不为空,则唤醒其中的一个线程
对于 TASK_INTERRUPTIBLE 和 TASK_UNINTERRUPTIBLE 补充说明一下: TASK_INTERRUPTIBLE - 可打断睡眠,可以接受信号并被唤醒,也可以在等待条件全部达成后被显式唤醒(比如wake_up()函数)。 TASK_UNINTERRUPTIBLE - 不可打断睡眠,只能在等待条件全部达成后被显式唤醒(比如wake_up()函数)。
读写信号量和信号量之间的关系 与 读写自旋锁和普通自旋锁之间的关系 差不多。 读写信号量是二值信号量,即计数最大值为1,增加读者时,计数不变,增加写者时,计数才减1。因此读写信号量保护的临界区,只允许有一个写者,但可以有多个读者。 读写信号量的方法: down_read:试图获取信号量用于读 up_read:释放读信号量 down_write:试图获取信号量用于写 up_write:释放写信号量
互斥体也是一种可以睡眠的锁,相当于二值信号量,只是提供的API更加简单,不再需要管理任何计数,使用的场景也更严格一些,建议优先使用互斥体。如下所示:
- mutex的计数值只能为1,也就是最多只允许一个线程访问临界区
- 在同一个上下文中上锁和解锁
- 不能递归的上锁和解锁
- 持有个mutex时,进程不能退出
- mutex不能在中断或者下半部中使用,也就是mutex只能在进程上下文中使用
- mutex只能通过官方API来管理,不能自己写代码操作它
互斥体的方法: DEFINE_MUTEX(name): 定义mutex mutex_lock:互斥体锁定,若锁不可用,则进入睡眠 mutex_unlock:互斥体解锁
使用场景与建议的加锁方法: 低开销加锁: 优先使用自旋锁 短期锁定:优先使用自旋锁 长期锁定:优先使用互斥体 中断上下文中加锁:优先使用自旋锁 持有锁需要睡眠:优先使用互斥体
完成变量的机制类似于信号量。 比如当线程A进入临界区时,线程B在完成变量上等待,线程A完成任务退出临界区时,完成变量会唤醒等待的线程B。 完成变量的方法: init_completion(struct completion *):初始化指定的动态创建的完成变量 wait_for_completion(struct completion *):等待指定的完成变量接收信号,等待特定事件 complete(struct completion *):发信号唤醒任何等待的任务
一般在2个任务需要简单同步的情况下,可以考虑使用完成变量。
大内核锁已经不再使用,只存在与一些遗留的代码中。
顺序锁为读写共享数据提供了一种简单的实现机制。 之前提到的读写自旋锁和读写信号量,读写锁之间是互斥的,在读锁被获取后,写锁是无法被获取的。也就是说只有等所有的读锁释放后,才能对临界区进行写入。 顺序锁与之不同,在读锁被获取时,写锁也可以被获取,对临界区进行写入。 当顺序锁执行读操作时,读之前和读之后都会读取顺序锁的序列号值,当两者一致时,说明这之间没有写入操作,否则说明有写入发生,需要再读取一次,直到读前后的序列号值一样。
do
{
/* 读之前获取 顺序锁foo 的序列值 */
seq = read_seqbegin(&foo);
...
} while(read_seqretry(&foo, seq)); /* 顺序锁foo此时的序列值!=seq 时返回true,反之返回false */
顺序锁优先保证写锁的可用,因此适用于很多读者,写者很少,但写更优先的场景。 顺序锁的方法: DEFINE_SEQLOCK():定义一个seq锁 write_seqlock():获取写锁 write_sequnlock():释放写锁
其实自旋锁已经可以防止内核抢占了,但有时候只需要防止内核抢占,而不需要像自旋锁一样连中断都禁止。 这时候就需要禁止内核抢占的方法: preempt_disable:增加抢占计数值,从而禁止内核抢占 preempt_enable():减少抢占计算,并当该值降为0时检查和执行被挂起的需调度的任务 preempt_enable_no_resched():激活内核抢占但不再检查任何被挂起的需调度的任务 preempt_count():返回抢占计数
编译器和处理器为了提高效率,可能会重排指令,对读和写进行重排。在并发情况下可能会出现不符合预期的情况。这时候需要确保指令的执行顺序,指示编译器不要对指令进行重新排序,这些确保顺序的指令称之为屏障。 内存和编译器屏障方法: rmb() :阻止跨越屏障的载入动作发生重排序 read_barrier_depends() : 阻止跨越屏障的具有数据依赖关系的载入动作重排序 wmb() |:阻止跨越屏障的存储动作发生重排序 mb() : 阻止跨越屏障的载入和存储动作重新排序 smp_rmb() :在SMP上提供rmb()功能,在UP上提供barrier()功能 smp_read_barrier_depends() :在SMP上提供read_barrier_depends()功能,在UP上提供barrier()功能 smp_wmb() :在SMP上提供wmb()功能,在UP上提供barrier()功能 smp_mb() : 在SMP上提供mb()功能,在UP上提供barrier()功能 barrier() : 阻止编译器跨越屏障对载入或存储操作进行优化
本节讨论了大约11种内核同步方法,除了大内核锁已经不再推荐使用之外,其他各种锁都有其适用的场景。 了解了各种同步方法的适用场景,才能正确的使用它们,使我们的代码在安全的保障下达到最优的性能。 同步的目的就是为了保障数据的安全,其实就是保障各个线程之间共享资源的安全,下面根据共享资源的情况来讨论一下10种同步方法的选择。 10种同步方法在图中分别用蓝色框标出。