并发访问共享数据是造成系统不稳定的一类隐患。
随着Linux内核2.6的出现,内核已经发展成抢占式内核,意味着在不加保护的情况下,调度程序可以在任何时刻抢占正在运行的内核代码,重新调度其他的进程执行。
内核代码中有不少部分都能够同步执行,这样就必须将它们妥善的保护起来。
1.临界区和竞争条件
Q:何为临界区?
A:访问和操作共享数据的代码段。
多个执行线程并发访问同一个资源通常是不安全的,为了避免在临界区中并发访问,必须保证这些代码原子地执行,即操作在执行结束之前不可被打断,就如同整个临界区是一个不可分割的指令一样。
如果两个执行线程(这里指任何正在执行的代码实例)有可能处于同一个临界区中同时执行,那么称之为竞争条件(race conditions)。
避免并发和防止竞争条件称之为同步(syncchronization)。
Q:为什么我们需要保护?
A:明白临界区无处不在。
2.加锁
当共享资源是一个复杂的数据结构时,竞争条件往往会使该数据结构遭到破坏。
如:允许并发访问队列,就会产生危害。
Q:如何允许并发访问队列,而不产生危害?
A:加锁–我们需要一种方法确保一次有且只有一个线程对数据结构进程操作,或者当另一个线程在对临界区标记时,就禁止(锁定)其他访问。 这样,线程持有锁,而锁保护了数据。
锁机制可以防止并发执行,并且保护队列不受竞争条件的影响。
任何访问队列的代码首先都要占住相应的锁,这样该锁就能阻止来自其他执行线程的并发访问,如:
2.1 造成并发执行的原因
用户空间之所以需要同步,是因为用户程序会被调度程序抢占和重新调度。
由于用户进程可能在任何时刻被抢占,而调度程序完成可能选择另一个高优先级的进程到处理器上执行,所以就会使得一个程序正处于临界区时,被非自愿的抢占了。
内核中有类似可能造成并发执行的原因。如下:
- 中断–中断几乎可以在任何时刻异步发生,也就是可能随时打断当前正在执行代码;
- 软中断和tasklet–内核能在任何时刻唤醒或调度软中断和tasklet,打断当前正在执行的代码;
- 内核抢占–因为内核具有抢占性,所以内核中的任务可能会被另一任务抢占;
- 睡眠以及用户空间的同步–在内核执行的进程可能会睡眠。这样就会唤醒调度程序,从而导致调度一个新的用户进程执行;
- 对称多处理–两个或多个处理器可以同时执行代码。
ps:辨认出真正需要共享的数据和相应的临界区,才是真正具有挑战性的地方,要记住,最开始设计代码的时候就要考虑加入锁,而不是事后才想到。
2.2 了解要保护些什么
找出哪些数据需要保护是关键所在。
由于任何可能被并发访问的代码几乎无例外地需要保护,所以寻找哪些代码不需要保护反而相对更容易些。
如:执行线程的局部数据仅仅被它本身访问,显然不需要保护,比如局部变量(还有动态分配的数据结构,其地址仅存放在堆栈中)不需要任何形式的锁,因为它们独立存在于执行线程的栈中。如果数据只会被特定的进程访问,那么也不需要加锁(因为进程一次只在一个处理器上执行)。
在编写内核代码时,要问自己如下问题:
- 这个数据是不是全局的?除了当前线程外,其他线程能不能访问它?
- 这个数据会不会在进程上下文和中断上下文中共享?它是不是要在两个不同的中断处理程序中共享?
- 进程在访问数据时可不可能被抢占?被调度的新程序会不会访问同一数据?
- 当前进程是不是会睡眠(阻塞)在某些资源上,如果是,它会让共享数据处于何种状态?
- 怎么样防止数据失控?
- 如果这个函数又在另一个处理器上被调度会发生什么呢?
- 如何确保代码远离并发威胁呢?
3.死锁
Q:死锁产生的条件(什么是死锁)?
A:死锁的产生需要一定的条件:要有一个或多个执行线程和一个或多个资源,每个线程都在等待其中的一个资源,但所有的资源都已经被占用了。所有的线程都在相互等待,但它们永远不会释放已经占有的资源。-- 于是任何线程都无法继续,这便意味着死锁的发生。
自死锁:如果一个执行线程试图去获得一个自己已经持有的锁,它将不得不等待锁被释放,但因为它正在忙着等待这个锁,所以自己永远也不会有机会释放锁,最终造成自死锁。
ABBA死锁:考虑有n个线程和n个锁,如果每个线程都持有一把其他进程需要得到的锁,那么所有的线程都将阻塞地等待它们希望得到的锁重新可用。
如何预防死锁的发生,即使很难证明代码不会发生死锁,但是可以避免,如下规则对避免死锁大有帮助:
- 按顺序加锁。使用嵌套的锁时必须保证以相同的顺序获取锁,这样可以阻止致命拥抱类型的死锁。最好能记录下锁的顺序,以便其他人也能照此顺序使用;
- 防止发生饥饿。即这个代码的执行是否一定会结束?-- 如果“张”不发生?“王”要一直等待下去吗?(即超时机制)
- 不要重复请求同一个锁;
- 设计应力求简单–越复杂的加锁方案越有可能造成死锁。
ps:获取锁–只要嵌套的使用多个锁,就必须按照相同的顺序去获取它们。尽管释放锁的顺序和死锁是无关的,但最好还是以获得锁的相反顺序来释放锁。
4.争用和扩展性
锁的争用(lock contention)简称争用。
争用是指正在被占用时,有其他线程试图获得该锁。
说一个锁处于高度争用状态,就是指有多个线程在等待获得该锁。
由于锁的作用是使程序以串行方式对资源进行访问,所以使用锁无疑会降低系统的性能。被高度争用(频繁被持有,或者长时间持有–两者都有就更糟糕)的锁会成为系统的瓶颈,严重降低系统性能。
但,相比于被几个相互抢夺共享资源的线程撕成碎片,引起内核崩溃,还是这种同步保护来得更好一点。
扩展性(scalability)是对系统可扩展程序的一个量度。
对于操作系统,在谈及可扩展性时就会和大量进程、大量处理器或是大量内存等联系起来,其实任何可以被计量的计算机组建都可以涉及可扩展性。
**加锁粒度用来描述加锁保护的数据规模。**粗锁保护大块数据,细锁保护小块数据。
许多锁的设计在开始阶段都很粗,但是当锁的争用问题变得严重时,设计就向更加精细的加锁方向进化。
一般来说,提高可扩展性是件好事,因为它可以提高Linux在更大型的、处理能力更强大的系统上的性能。
但一味的“提高“可扩展性,却会导致Linux在小型SMP和UP机器上的性能降低,因为小型机器可能用不到特别精细的锁,锁得过细只会增加复杂度,并加大开销。
因此,可扩展性是很重要的,需要慎重考虑。关键在于,在设计锁的开始阶段就应该考虑到保证良好的扩展性。因为即使在小型机器上,如果对重要资源锁的太粗,也容易造成系统性能瓶颈。
当锁争用严重时,加锁太粗会降低可扩展性;而锁争用不明显时,加锁过细会加大系统开销,带来浪费,都会造成系统性能下降。