一、临界区与竞争条件
临界区就是访问和操作共享数据的代码段。
如果两个执行线程有可能处于同一临界区中同时执行,那么我们就称它们为竞争条件(race conditions)
避免并发和防止竞争条件称为同步(synchronization)
二、加锁
2.1 锁的介绍
我们需要一种方法确保一次有且只有一个线程对数据结构进行操作,或者当一个线程在对临界区标记时,就禁止其他访问。线程持有锁,而锁保护了数据。如请求队列的例子,可以使用一个单独的锁保护队列,每当有新的请求到来时,线程会首先占住锁,然后就可以安全的将请求加入队列中,结束操作后再释放该锁。
锁具有多种多样的形式,而且加锁的粒度范围也各不相同,各种锁机制之间的区别主要在于:当锁已经被其他线程持有,不可用时的行为表现——一些锁会执行忙等待,另外一些锁则可能会使当前任务睡眠直到锁可用为止。
2.2 为什么加锁?
用户空间之所以要同步是因为用户程序会被调度程序抢占和调度。用户进程可能在任何时刻被调度程序选择另一个高优先级的进程到处理器上执行,所以就会使得当前程序正处于临界区时,被非自愿的抢占了。
内核中造成并发执行的原因有:
1.中断,异步时刻发生,可能随时打断当前正在执行的代码。
2.软中断和tasklet—内核可在任意时刻唤醒或调度软中断和tasklet
3.内核抢占
4.睡眠及用户空间的同步
5.对称多处理(两个或多个处理器可以同时执行代码)
辨别出真正需要共享的数据和相应的临界区,才是最具有挑战的地方,要记住,最开始设计代码的时候,就需要加入锁,而不是事后才想到。
在中断处理程序中能避免并发访问的代码称为中断安全代码(interrupt-safe),在对称多处理的机器中能避免并发访问的代码称为SMP安全代码(SMP-safe),在内核抢占时能够避免并发访问的代码称为抢占安全代码(preempt-safe)
2.3 如何找到需要保护的数据?
找到需要保护的数据是困难的,换个思路,我们可以找到哪些数据不需要保护。
1.执行线程的局部数据仅仅被它本身访问,显然不需要保护。比如局部自动变量(还有动态分配的数据结构,其地址仅存在堆栈中),独立存放于执行线程的堆栈中,不需要加锁。
2.若数据只会被特定进程访问,那么它也不需要加锁(进程一次只在一个处理器上执行)
所以剩下的是:大多数内核数据结构需要加锁,如果有其他执行线程可以访问这些数据,那么就给这些数据加锁。是给数据加锁而不是给代码加锁。
三、死锁
3.1 死锁的产生条件
要有一个或多个执行线程和一个或多个资源,每个线程都在等待其中的一个资源,但所有的资源都已经被占用了。所有线程都在相互等待,但它们永远不会释放已占有的资源,于是任何线程都无法继续,这意味着死锁发生。
死锁举例:
1.四路交通堵塞,每个停止的车都在等待其它的车开动后自己再启动。
2.自死锁:某个线程试图访问一个自己已经持有的锁,它将不得不等待锁被释放,但因为它正在忙着等待这个锁,所以自己永远也不会有机会释放锁,最终结果就是死锁。
3.ABBA死锁
线程1 线程2
获得锁A 获得锁B
试图获得锁B 试图获得锁A
等待锁B 等待锁A
3.2 避免死锁的规则
1.按顺序加锁 :使用嵌套锁时必须保证以相同的顺序获取锁,这样可以阻止致命拥抱类型的死锁,最好记录下加锁的顺序,以便其他人也能够使用。
2.防止发生“饥饿”: 即这个代码是否一定会执行结束,如果“张”不发生,“王”会一直等待下去吗?
3.不要重复请求同一个锁。
4.大道至简——越复杂的加锁方案,越可能造成死锁。
注释:在加锁时,加上获取锁的顺序的注释极为方便,如下所示:
//cat_lock //用于保护访问cat数据结构的锁,总是要在获得锁dog前先获得。
四、争用和扩展性
锁的争用是指锁正在被占用时,其他线程试图获得该锁,也就是说一个锁处于高度争用状态。由于锁的作用是使程序以串行的方式对资源进行访问,所以使用锁无疑会降低系统性能。
被高度争用的锁会成为系统瓶颈、
扩展性:对系统可扩展程度的一个度量。
加锁粒度用来描述加锁保护的数据规模:一个过粗的锁保护较大块数据——比如一个子系统用到的所有数据结构。一个过于精细的锁保护很小的一块数据结构——比如大数据结构中的一个元素。在使用过程种,锁的设置位于上述两种极端之间。
许多锁的设计在开始阶段都很粗,但是当锁的争用问题变得严重时,设计就向更加精细的加锁方向进化。
当锁争用时,加锁太粗会降低可扩展性;而锁争用不明显时,加锁过细又会加大系统开销。