摘要
本文介绍Linux内核中的一些同步机制,通过本文,希望读者能够明白以下几点:
- 什么是同步
- 为什么要同步
- 同步的几种手段
1.什么是同步?
与其解释什么是同步,倒不如告诉读者同步的由来。在Linux内核中,同步技术是为了解决问题而产生的。 说起这个问题,不得不提起可重入内核。
- 可重入内核:可重入内核即多个进程可以同时在内核态下执行,多个进程的执行事实上说明了进程可以交替执行。
- 内核态:如果不清楚什么是内核态,简单的说,Linux系统空间分为用户空间和内核空间两大部分,更直白的可以理解为Linux系统将内存分成两块(事实上不止两部分),一块叫做内核空间,一块叫做用户空间,我们经常所说进程运行在用户空间,可以理解为进程所用的内存堆栈空间是位于用户空间中的,而进程在用户空间运行时发生中断或者进程执行某个系统调用(事实上也是一种中断,0x80中断)时,会引起堆栈切换,原来使用的是用户空间堆栈,现在要切换成系统堆栈,这个系统堆栈位于内核空间,因此进程这时就进入(陷入)内核态了。至于这个系统堆栈,是每个进程都必备的资源,在进程结构体tast_struct的thread中保存。
可重入内核运行多个进程交替执行,而进程的切换就发生在内核态下。进程的切换就意味着A进程还未执行结束,就要换B进程执行,如果存在全局变量G,一旦进程切换,这意味着A进程失去对G的控制,其他进程可能对G进行修改等操作,当A进程再次运行时,A进程根本无法知道此时的全局变量G的值还是否是当初进程切换前的那个G。这时候再贸然使用G可能导致致命的后果。
如果还不清楚,举个例子。小明房间里有一双滑板鞋,早上起床上学时看到还在,于是在学校里和同学约好下课一块玩滑板,结果小明房间的门没有锁,在小明离开房间期间,他的弟弟进入房间将滑板拿走了,等小明回去一看,滑板鞋已经不在了。造成的后果就是小明将失信于同学,解决办法就是将房间锁住。
因此,无论是因为并发(多个进程交替执行)还是因此并行(多CPU)而产生的多个进程访问同一资源时,就会产生资源访问竞争,或者说资源访问的顺序及何时获取及释放资源。
如果上面A进程或B进程读全局变量G后又修改了G的值,那么读和修改操作应该是一个单独的、不可中断的操作。否则,一旦进程切换,G的值则就变得不明确了。我们将这些不可中断的操作放到临界区来保证不可中断的特性。那么所谓的临界区就是这样一段代码,进入这段代码的进程必须完整的执行完这段代码,否则,即使进程发生切换,另一个进程也不可能进入这段代码。
同步技术就是为了解决资源访问竞争产生问题的一种方法。
2.同步的几种手段
Linux内核同步有如下几种方式:
- 每CPU变量
- 原子操作
- 内存屏障
- 自旋锁
- 顺序锁
- 读-拷贝-更新(RCU)
- 信号量
- 禁止本地中断
- 等等
针对资源访问的不同需求而使用不同的同步方式,有些同步方法可以相互适用,但是所依据的法则是:把系统中的并发度保持在尽可能高的程度。
2.1每CPU变量
正如其名,每cpu变量即为每个CPU都有自己的变量,各个CPU仅访问自己的每CPU变量,可以想象每CPU变量一般的数据结构是一个数组。
type name[CPU_COUNT];
因此每CPU变量解决的是多CPU之间可能发生的竞争条件,而因内核抢占而产生了进程切换时,则很可能使每CPU变量产生竞争条件,就像之前举的那个例子一样。
2.2原子操作
想必读者对这个概念比较熟悉。原子操作即在执行原子操作时,不可能被在执行的时候拆分成几条原子操作。
Linux内核通过一些手段来实现某些操作的原子性,例如
- 操作码前缀为lock的汇编指令,即使多cpu下也能保证其后汇编指令的原子性,lock会锁定内存总线,保证在执行汇编指令时没有其他CPU同时读写内存。
- 多处理器中,Linux内核通过提供atomic_t类型封装了一系列原子操作,如atomic_inc(v)表示把1加到v。
2.3优化和内存屏障
简单的说,优化编译器会将原本的代码进行重排,已达到最优的处理方式,这样产生的问题是程序在执行的时候访问内存的顺序可能并不像我们程序中原本写的那样。当同步发生时(竞争条件),我们必须避免指令重排。
而优化屏障的意思就是在指令之间插入一道屏障,让这道屏障之前和之后的指令不可能因重排而跨越这道屏障,很形象吧。
2.4自旋锁
自旋锁是一种广泛的同步技术,它锁住的是一块临界区,进入临界区时需要先获取自旋锁,在离开临界区时需要释放自旋锁。如果已经有其他进程获取了该锁,那么当前想要获取该锁的进程只能在临界区门口来回溜达(自旋),直到获取该锁。自旋锁类似于现实生活中的给房间的门上锁,进入房间访问资源时需要锁住门,防止其他人同时进入房间,退出房间时,再打开锁。
2.5读写自旋锁
这种自旋锁的特点是允许多个进程同时对同一数据结构进行读,但不允许多个进程同时修改同一数据结构,因此在实现上必须分为读锁和写锁。读锁要实现多个进程能同时读同一数据结构,但是读的过程中不允许写,写锁要实现仅能有一个进程获取写锁进入临界区,获取写锁时同时保证没有进程已经获取读锁。
实现:至于读写锁的实现,事实上用一个变量即可实现,读者可以思考下如何设计。
2.6顺序锁
顺序锁和读写锁的区别就是顺序锁中,写锁可以在进程还未释放读锁的情况下获取写锁,这样写锁不会因为读锁而等待,但是读进程在读的时候必须考虑是否有写进程正在进行写操作。
2.7信号量
信号量和自旋锁类似,也是为了控制进程进入临界区,但是信号量和自旋锁的重大区别是:
- 自旋锁获取锁的过程,不会主动调用schedule()进行进程切换,而是占用cpu,拼命的自旋,类似于while耗时操作
- 信号量中存在一个进程等待队列,未获取锁的进程将挂到该队列中,然后主动调用schedule()切换进程,让出cpu
结束
文本介绍了Linux内核中几种同步方式,关键在于理解同步发生的原因,才能想办法去解决同步竞争条件,剩下的则是根据具体问题,设计具体的同步机制,文中并没有详细介绍各种锁的实现机制,感兴趣的读者可以参考相关书籍资料。
参考资料