背景
- 如今多CPU操作系统环境下,有多个进程或线程可能会同时执行一段代码,如果不对这段加以保护和隔离,可能会导致预期之外的错误。这种避免并发导致和防止竞争条件就叫做同步。
- 因此,这些代码需要以原子的方式执行,即要么一次执行完,要么就都不执行。
同步
原子操作
- 最简单最轻量化的实现同步的方法就是原子操作。顾名思义,原子是物理学中不可分割的单位,用在计算机领域表示不可分割的命令,一条原子命令决不允许被打断,要么执行完成,要么不执行。
- Linux中对原子类型的定义是
atomic_t
,实际就是用volatile
修饰的整形变量,每次读取该变量都从内存中读取。
- 内核提供了一系列原子操作:
原子位操作:
内核提供了原子化的位操作接口:
插入表10-3
看一个内核运用原子位操作的实例:
Linux内核用一个map保存进程号的使用情况,每一位对应一个进程号,在map初始化阶段,将0号进程置为已使用:set_bit(0, pidmap_array->page)
。
另外,该函数也用到了atomic_dec
函数。
然而,不是每一段竞争条件的代码都是简单的自增自减、或者位操作,也许是比较复杂的数据结构读取、解析和写入,甚至可能跨越函数,这种情况下原子操作就无能为力了,这就要用到下文介绍的锁机制。
锁
- 解决同步的一个另办法是加锁,即访问临界区代码需要获取锁,执行前先获取锁,执行完后释放锁。好比几个人去参加面试,只有一个面试房间,有人进房间面试后需要上锁,避免其他人进来,然后面试完离开房间时需要解锁,给后面的人进入。
- 然而,锁机制只是把一段临界区代码缩小到了加锁和解锁这一个单独的动作,但是加锁和解锁本身也可能存在竞争访问,因此需要保证加锁和解锁操作是原子操作,幸运的是,一般的体系结构都提供了原子访问加锁和解锁的机制,如此就可以把一段可能比较复杂的临界区代码用一把轻量级的锁保护起来,然后锁本身是原子化的操作,就可以实现临界区代码的同步了。
- 给数据加锁,而不是给代码加锁。那么该给什么数据加锁呢?只有单个进程访问的局部堆栈数据不需要加锁,多个进程都会访问的共享变量、内核数据需要加锁。
- 锁机制这么好,是不是没有缺点呢?不是的,锁带来的明显缺点就是会降低程序的性能。如果很多的进程频繁的争用同一把锁,会导致CPU资源大量的消耗在加锁、解锁和等待锁的释放上。一种好的解决办法就是减小锁的范围,提升锁的精度。比如从锁一个大的结构体变成锁其中的一个字段,这种适用于其他字段无需加锁的情况下,从只要访问这个结构体都要争用变成只有访问该字段才会争用,这就减少了争用的概率。
自旋锁
- 顾名思义,当一个进程在试图获得锁时,如果锁已被占用,则进程自旋等待,直到锁被释放才能获得,自旋等待期间也不能做其他事,CPU处于忙等状态。以上文面试的例子,如果有人在面试中,其他人想要面试,发现门被锁了,就在门口一直等待,不能离开去干其他事,直到里面的人出来才能进去。
- 因此,自旋锁适用于占用锁时间很短的场景,否则对程序性能影响很大。一般使用自旋锁的临界区运行时间要小于两次进程上下文切换的时间,因为这样用自旋锁比较划算,否则可以让等待进程睡眠,等锁释放后再调度回来。
- 自旋锁的使用方法:
spinlock_t my_lock; DEFINE_SPINLOCK(my_lock); spin_lock(&my_lock); /*临界区代码*/ spin_unlock(&my_lock);
- Linux中的自旋锁是不可递归的,即不允许在持有某个自旋锁时请求同一个自旋锁,这样会导致请求不到锁导致自旋,然后占用的锁也不释放,被自己锁死。
- 自旋锁可以用在中断处理程序中,但是在使用锁之前要禁止本地中断(仅限于当前处理器上,其他处理器不需要),因为如果不禁用,另一个中断处理程序打断原有内核代码,也争用原内核代码已占用的自旋锁,会导致双重请求死锁,中断处理程序2得不到锁而处于自旋,中断处理程序1等待中断处理程序2返回才能继续执行,二者都不能继续往下执行。
- 在单CPU系统上,自旋锁就是禁止或开启内核抢占。
读写锁(共享/排斥锁)
- 顾名思义,只读操作占用读者锁,写操作占用写者锁。读者锁可以多个读者同时占有,写者锁同一时刻只能有一个写者持有,且在有读者时写者要等待,直到读者释放读者锁。以上文面试的例子,把读者类比成向面试官递交材料,可以多个读者同时获得锁,把写者类比成和面试官交流,一次只能有一个人获得锁。
- 读写锁是特殊的自旋锁,使用方法如下:
rwlock_t my_rwlock; DEFINE_RWLOCK(my_rwlock); read_lock(&my_rwlock); /*读者临界区*/ read_unlock(&my_rwlock); write_lock(&my_rwlock); /*写者临界区*/ write_unlock(&my_rwlock);
- 读写锁比较照顾读者,如果有大量读者,那么写者需要一直等待,甚至可能饿死。
信号量
- 与锁机制相比,信号量同样能够让多个进程互斥访问资源,即进程在访问资源前申请信号量。有进程占有信号量的时候,其他进程无法访问,与自旋锁让其等待不同,信号量让其睡眠,等信号量被释放后再去唤醒进程。因此,信号量是一种睡眠锁。
- 以上文面试的例子,一个人进房间前需要获取信号量,信号量空闲则直接占有,然后进房间面试,反之信号量被占用(即有人在面试中),他把自己的联系方式写在门口的登记表上,然后去休息室睡觉去了,不用一直等在门口。等前面的人面试完,查看登记表,给第一个人打电话叫醒他来面试。
- 可以看出信号量具有一些特性:
① 信号量不会使争用进程一直等待,CPU可以处理其他事情
② 信号量带来了一定的上下文开销,进程睡眠、唤醒、调度执行需要上下文切换
③ 信号量比较适合占用资源较久的场景,因为进程可以睡眠,影响不大。而且如果占用资源时间很短,用信号量也得不偿失,可能上下文切换的代价就大于等待时间了
④ 因为进程争用会导致睡眠,所以在进程上下文中才能使用信号量,中断处理程序中不能使用,因为中断处理程序不允许睡眠
⑤ 进程已占用信号量时是允许睡眠的,因为其他争用进程也在睡眠,影响不大
⑥ 占用信号量时不允许申请自旋锁,因为第五条说了占用信号量时可以睡眠,但占用自旋锁不允许睡眠 - 自旋锁和信号量如何选择?一般来说,占用资源时间较长、或者可能会睡眠时,只能选择信号量。占用资源时间很短选择自旋锁。
- 信号量有两种:二值信号量(互斥信号量)和计数信号量。二值信号量像锁一样,一次只能有一个进程占用。计数信号量可以指定最多有几个进程同时占用,超出计数后的进程不允许占用。好比一个房间里有N个面试官,同时允许N个人面试。
- Linux2.6对信号量的类型定义
struct semaphore
:
可以看到,struct __wait_queue_head { spinlock_t lock; struct list_head task_list; } typedef struct __wait_queue_head wait_queue_head_t; struct semaphore { atomic_t count; int sleepers; wait_queue_head_t wait; }
struct semaphore
是由计数值、睡眠进程数和等待队列组成,等待队列是由自旋锁和链表头组成。可见内核对信号量的实现还用到了自旋锁。 - 与读写锁类似,信号量也有读写信号量。没有写者时,读者可以同时获取读信号量。没有读者时,写者互斥获取写信号量。读写信号量在睡眠时不会被其他信号打断。写者可以使用
downgrade_write
动态降级为读者。
互斥体(mutex)
- 回顾自旋锁和信号量的特点,自旋锁是不可睡眠、互斥访问,信号量是可以睡眠、可以指定同时访问个数。一般来说,在用户态常用的是互斥访问的、可睡眠的同步机制。因此内核提供了互斥体的概念,实际上就是可睡眠的自旋锁。
- Linux内核为互斥体的实现就是计数为1的信号量:
初始化信号量:
初始化互斥体:
完成变量
- 如果一个任务等待另一个任务完成某个事件后才继续执行,可以使用完成变量。当这个任务完成后,借助完成变量唤醒等待的任务。
- 完成变量的类型定义和使用方法:
/* 完成变量结构体 */ struct completion { unsigned int done; wait_queue_head_t wait; } /* 初始化完成变量 */ void init_completion(struct completion *x); /* 等待完成变量接收信号 */ void wait_for_completion(struct completion *x); /* 发信号唤醒等待的任务 */ void complete(struct completion *x);
顺序锁
- 之前说到读写锁,与之类似的还有个顺序锁。两者相同点是:都具有读者、写者两类用户,读者可以同时访问,写者必须互斥访问。不同点是:读写锁对读者有利,对写者不利,因为只要有读者存在,写者就要循环等待;然而顺序锁反之,对写者有利,有读者不影响写者获取锁,写者拿到锁后读者只能循环等待,直到写者释放锁。
- 顺序锁是通过对一个序列值进行校验奇偶来同步读者和写者的。序列值初始值为0,写者在写数据时会把序列值+1,变为奇数,写完后会把值再+1,变为偶数。读者在读取数据前后都要读取序列值,如果序列值没有改变,说明没有被写者打断过(上一条说了,写者可以在有读者时占有锁,写数据)。
- 看一个内核的实例:
这是内核的do_gettimeofday
函数,可以获取当前计算机微妙级的时间。jiffies
变量记录着从计算机开机到现在总共的时钟中断次数,时钟中断和CPU的主频相关,因此通过该变量可以计算出从开机到现在的时间。每一次时钟中断都会使jiffies
+1,读者在读取该变量时,可能会被时钟中断程序改写该变量的值。因此读者需要同步读取。
do ... while ...
框架是顺序锁读者的统一代码框架。在读取前,先通过read_seqbegin
函数获取最新的序列值,读完jiffies
变量后判断read_seqretry
函数是否返回真,如果为真说明有写者在写,读者需要循环等待,读者重新执行do
的代码,直到读前后都没有写者在写,说明读到的jiffies
是最新的数据。
看一下上述函数的实现:
read_seqbegin
函数实际上就是获取最新的顺序锁的序列值。
read_seqretry
函数检查2个条件:1、iv是否为奇数;2、读之后的序列值和读之前的序列值是否相同。如果这2点都不满足,函数返回0,表示读前后没有被写者写过(包含写者正在写或写完释放),否则返回1。
那写者怎么操作序列值呢?看一下实现便知:
可以看到,write_seqlock
和write_sequnlock
都是对序列值原子化+1。写者在写前调用write_seqlock
加锁,写完后调用write_sequnlock
解锁:
由于序列值初始值是0,如此就可以达到写者在写时序列值是奇数,写完后又变为偶数,但是值不同。结合刚刚读者的操作,正好就可以完成读写同步了,且达到了写者优先的策略。(不得不说,Linux的内核思想真的很精妙!)/* 时钟中断函数 */ irqreturn_t timer_interrupt(int irq, void *dev, struct pt_regs *regs) { ... write_seqlock(&xtime_lock); /* jiffies+1 */ ... write_sequnlock(&xtime_lock); }
每CPU变量
- 在多CPU系统中,为每个CPU定义一个变量,每个CPU只能访问属于自己的变量,这是最简单的在多CPU之间同步的方式。
- 然而,每CPU变量只能解决多个CPU之间同步的问题,不能解决每个CPU上的内核抢占导致的同步问题。
DEFINE_PER_CPU(type, name)
静态分配一个每CPU数组,get_cpu_var(name)
关闭内核抢占,获取本CPU的元素,put_cpu_var(name)
开启内核抢占,也可以通过alloc_percpu(type)
动态分配内存,free_percpu(ptr)
释放内存,其实是调用kmalloc
和kfree
完成的。
内存屏障
- 处理器执行指令分为取指、译码、执行、访存、写回等步骤。在C代码中前后两条语句不代表在CPU上能够保持相同的执行顺序,因为现代CPU具有指令派发功能,CPU会自己选择性能最高的指令执行顺序。
- 内核提供了内存屏障功能,
rmb()
是读内存屏障,确保在此之前的代码不会被重排序到之后,在此之后的代码也不会被重排序到之前。wmb()
是写内存屏障,功能和rmb()
类似,只是针对的是写内存操作。mb()
是读写内存屏障,是前面两者的功能之和。 - 为了便于理解内存屏障,可以想象有一扇门,屏障就限制了门外门内的人不能随便跨越这道门。读、写操作相当于两类人,门外的人不能晚与门内的人执行某个操作,门内的人不能早与门外的人执行某个操作,但是门外或门内的那些人可能是打乱顺序的。
- 不是每个体系结构都有读写内存指令重排序的特点,因此每个体系结构的
rmb()
和wmb()
实现也可能不同,具体可以看下Linux内核的实现。
内核抢占
- 为了提升用户使用计算机的响应体验,内核分为可抢占和不可抢占两种类型。
- 所谓内核抢占,就是进程A在内核态运行时(比如在执行异常处理程序),有进程B处于可调度状态且进程优先级高于A,或者进程A时间片用完,让进程B取代进程A运行。进程A的异常处理程序被暂停,直到再次调度到进程A才会继续执行。如果不支持内核抢占,那么进程A在内核态运行期间是不会被进程B打断的,除非进程A主动放弃了CPU。
- 是否支持内核抢占是通过进程描述符的
thread_info->preempt_count
字段决定的,如果该值>0,说明不可抢占,=0说明允许抢占。 - 内核抢占会引起进程切换开销,因此Linux2.6可以在编译内核时指定是否支持内核抢占。
- 可以通过内核提供的
preempt_disable()
和preempt_enable()
函数禁止或开启内核抢占。