1.临界区和竞争条件
临界区:就是访问和操作共享数据的代码段。
如果两个执行线程有可能处于同一个临界区中同时执行,如果这个情况发生了,就叫做竞争条件。避免并发和防止竞争条件称为同步。
我们必须在某些操作期间对数据加锁,确保每个事务相对其他操作是原子性的,这样的事务必须完整地发生,要么干脆不发生,但是决不能打断。
对于单个变量的访问,也有可能发生竞争;因为把变量从内存拷贝到寄存器,再修改寄存器值,然后重新写回内存,这个过程是可以并发执行的,多数处理器提供原子指令来操作变量,这样也可以解决并发问题。
2.加锁
当共享资源是一个复杂的数据结构时,竞争条件往往会使该数据结构遭到破坏。锁就是一种保护共享数据的机制,任何线程先持有锁,才能操作数据,这保护了数据的安全,操作完成后也必须释放锁。
锁是采用原子操作实现的,而原子操作不存在竞争。
(1) 造成并发的原因
①中断:中断几乎可以在任意时刻异步发生,可能随时打断正在执行的代码;
②软中断和tasklet:内核能在任意时刻唤醒或调度软中断和tasklet, 打断正在执行的代码;
③内核抢占:因为内核具有抢占性,所以内核中任务可能会被另一任务抢占;
④睡眠及用户空间同步:在内核执行的进程可能会睡眠,这就会唤醒调度程序,导致一个新进程运行。
⑤对称多处理器:两个或多个处理器可以同时执行代码
对内核开发者来说,必须理解上诉并发执行的原因,并且为它们事先做足准备工作。
用锁来保护共享资源并不难,但辨认出真正需要共享的数据和相应的临界区,才是真正有挑战性的地方。最好是在编写代码的开始阶段就要涉及恰当的锁。
(2) 了解要保护些什么
大多数内核数据结构都需要加锁!如果有其他执行线程可以访问这些数据,那么就给这些数据加上某种形式的锁。注意:是给数据而不是给代码加锁。
编写内核代码时,要注意以下问题:
①这个数据是不是全局的?除了当前线程外,其他线程能不能访问他?
②这个数据会不会在进程上下文或中断上下文共享?是不是要在两个不同的中断处理程序中共享?
③进程在访问数据时可不可能被抢占?被调度的新程序会不会访问同一数据?
④当前进程是不是会睡眠(阻塞)在某些资源上,如果是,它会让共享数据处于何种状态?
⑤怎样防止数据失控?
⑥如果这个函数又在另一个处理器上被调度将会发生什么?
⑦如何确保代码远离并发威胁呢?
简而言之,几乎访问所有内核全局变量和共享数据都需要某种形式的同步方法。
3.死锁
死锁的产生需要一定条件:要有一个或多个执行线程和一个或多个资源,每个线程都在等待其中的一个资源,但所有的资源都已经被占用了。所有线程在互相等待,但它们永远不会释放已经占有的资源。
最简单的死锁例子是自死锁:一个执行线程视图是获得一个自己已经持有的锁。(Linux没有提供递归锁)
预防死锁的一些简单规则:
①按顺序加锁。使用嵌套锁时,所有线程都按系统的顺序获取锁。
②防止发生饥饿,这个代码的执行是否一定会结束?
③不要重复请求同一个锁。
④涉及应力求简单—越复杂的加锁方案越有可能造成死锁。
尽管释放锁的顺序与死锁无关,但最好还是以获取锁的相反顺序来释放锁。
防止死锁很重要,所以Linux提供了一些简单易用的调试工具,可以在运行时检测死锁。
4.争用和扩展性
锁的争用(lock contention):是指当锁正在被占用时,有其他线程试图获得该锁。锁一个锁处于高度争用状态,就是指有多个其他线程在等待获取该锁。由于锁的作用是使线程以串行方式对资源进行访问,所以使用锁无疑会降低系统性能。
扩展性(scalability):是对系统可扩展性的一个量度。对于操作系统,处理器,内存等可以被计量的计算机组件都可以涉及可扩展性。
Linux2.6内核中,内核加的锁是非常细的力度,可扩展性很好。
当锁太大,锁争用问题变得严重时,设计就向更加精细的加锁方向进化。
一般来说,提高可扩展性是好事,因为可以提高Linux在更大型、处理能力更强大的系统上的性能,但是一味“提高”可扩展性,却会导致Linux在小型SMP和UP机器上的性能降低。因为小型机器可能用不到特别精细的锁,锁得过细致会增加复杂度,并加大开销。
如果在双处理器机器上锁争用表现的并不明显,那么多余的锁会加大系统开销,造成很大浪费。
锁加的过粗或过细,差别往往只在一线之间,当锁争用严重时,加锁太粗会降低可扩展性;而锁争用不明显时,加锁过细会加大系统开销,带来浪费,这两种情况都会造成系统性能下降。
设计初期加锁方案应该力求简单,仅当需要时再进一步细化加锁方案,精髓在于力求简单。