在共享内存的应用中,开发者必须确保共享资源在并发访问时的安全性,内核也不例外。因为如果多线程操作同时的访问以及修改数据,则一个线程可能就会覆盖或访问一个处于不一致状态的数据资源。因此保护很重要。并发访问共享数据很容易引起不稳定,让调试以及跟踪问题变得困难,所以正确的访问共享数据是很重要的。
访问和操作共享数据的代码路径称为关键区域(critical regions)。通常多线程同时访问同一资源是不安全的。要阻止在关键区域内的并发访问,程序员必须保证代码是原子执行的(即操作不可被打断,整个区域运作起来就如一个单条指令一样)。如果让两个线程同时在一个关键区域内执行,这是一个bug。当这个发生时候,我们称之为竞争条件(race condition),之所以这样命名,是因为线程都在竞争去首先进入关键区域。要知道,有多少你的代码会标明是race condition?调试race condition总是很难的,因为他们不容易复现。保证阻止出现非安全的并发以及保证race condition不出现的行为,称之为同步(synchronization).
为了更好地明白同步的需要,先看一个普遍的race condition例子。先看ATM取款机的例子。一个人走到取款机那里,插入卡,输密码,选择取钱。输入了金钱数目,按OK,取钱。在用户要求指定数额的钱之后,提款机需要保证钱是存在用户账号那里。如果钱存在,则会把要取的钱的数额从总数扣除。实现的代码如下:
int total = get_total_from_account();
int withdrawal = get_withdrawal_amount();
if(total < withdrawal)
{
return -1;
}
total -= withdrawal;
update_total_funds(total);
spit_out_money(withdrawal);
现在假设,另外一处同时从用户帐户中扣款。同时扣款怎样发生这个不管,假设是用户的配偶在另外一个地方取钱。两个系统同时执行取钱的动作有类似上面的代码。先检查扣款是可以的,然后计算剩余总数,最后执行物理扣款。现在先编一些数字。假设第一笔扣款是100元,第二笔是10员。假设用户总共才有105元。很明显,其中一笔交易是不可能完成的。但如果遇上race condition,会有很有趣的发生。假设两笔交易是大致同时发起。两个交易都检查帐户总额105,发现100或10都比105小。此时第一笔接着处理,最后计算剩余总数为5元,并更新银行帐户。接着第二笔因为前面检查到105是足够的,故此也会继续交易下去,此时得到剩余款为95,又去更新银行帐户,结果银行帐户最后变成95元了。看到吧,钱多了。很明显,金融行业必须确保此类事情不能发生,他们必须对帐户在操作的时候上锁,保证每一笔交易都是原子的(atomic),不能被打断的。
lock 提供了一种机制,很像一个门的锁。想象一下门后的空间是一个关键区域,在关键区域内,仅仅只能一个执行的线程在给定的时间内出现。当进程进入空间,会锁住门。当进程完成操作后,会离开空间,打开房门。如果另一个进程过来发现房门是锁住的,它必须等待到里面的进程退出空间并解锁。这样进程拥有锁,锁又保护数据。
上锁是自愿的以及是被建议的。上锁是一种完全的编程结构,是程序员应该利用的机制。从没有任何股则强迫用户使用锁,但不对共享资源使用锁,往往会导致race condition以及程序的奔溃。锁以各种形式以及各种大小出现。Linux实现了各种有用的锁机制,各种机制中最大的区别在于其在锁被别的线程拥有时候的表现:是busy wait(循环的等待)还是休眠的等待。
在用户态空间里,同步的需求来源于调度会依据其愿望来抢占式的调度程序。因为一个进程可以被在任意时刻别的进程抢占。因此一个进程可以被强迫的在其处理关键区域的紧要关头被抢占。如果别的进程也能进入相同的关键区域,则race condition就产生了。同样的问题也会发生在多个单线程进程分享文件时候,或者是带有信号处理的单进程程序,因为信号可以随时发生(signal)。这种并行并不是真正的同时发生,而是交互着发生的,只是表现的跟并发一样,所以称为伪并行。
如果有一个对称处理器的机器,两个进程就可以同时的运行。这个叫做真并行。虽然伪并行与真并行的本质不一样,但都会引起同样的race condition,需要同样的保护。
内核也有类似的并发过程:
1. 中断 可以随时发生,中断当前运行的程序
2. softirq 和 tasklet 内核可以几乎在任何时刻引发softirq或tasklet,中断当前运行的代码。
3. 内核抢占 因为内核是抢占式的,因此一个内核任务可以抢占别的
4. 用户空间的休眠和同步 内核中的任务可以休眠,从而可以调度并让给新的进程。
5. 对称多处理器 两个或更多的处理器可以在内核中同时执行。
内核开发者必须懂得这些并发的原因。在操作共享数据途中被打断且能被中断处理操作同样的共享数据,是一种bug。同样,内核在访问共享资源时候可被抢占,也是一种bug。最后,两个处理器永远不能同时的去访问同样的数据空间。如果对要保护的数据有清晰的了解,那么提供锁让系统稳定就不是一件难事。最难的部分就是辨认出这些情况,然后提供适当的保护。
辨别出什么数据需要特殊的保护是很重要的。因为大部分需要并发访问的数据都需要保护,因此要辨析出那些数据不需要保护就简单了。显然,线程都有的数据是不需要保护的,因为只有它所属线程才能访问。任何别的线程能访问的数据,都需要某种保护。记住,要保护数据,而不是代码。
当你写内核代码时候,要问清自己以下问题:
1. 数据是全局的么?非当前的别的线程可以访问么?
2. 数据可以在进程上下文以及中断上下文中共享么?可以在两个中断函数中共享么?
3. 访问这个数据时候,进程是否可被抢占的,新的被调度的进程可以访问这个数据么?
4. 当前的进程会在任何事情上休眠么?进程会在什么状态下,在访问的数据中休眠?
5. 什么事情会阻止我释放资源?
6. 如果函数在别的进程被调用会发生什么?
7. 在给定的处理点,我如何确保我的数据对于并发来说是安全的?
死锁是一种一个或多个线程执行体,以及一个或多个资源满足如下条件的一种状况:每一个线程都等待一个资源,但所有资源都已经被占用。这些线程都等待对方释放资源,但总是等不到。因此没有一个线程能继续,这就导致死锁。
锁的争夺描述了一个锁在使用的时候,另外一个线程尝试去获取它。当锁是高度争夺的话,它总是有一个线程在等待它。锁的主要工作是确保数据的顺序化访问,因此毫无疑问的会降低系统效率。一个高度争夺的锁会称为一个系统的瓶颈,会限制系统的性能。锁的粒度是针对被保护数据的大小或数量而定。一种粗糙的锁,用来保护大数据量的数据,如保护整个数据结构。一种微调的锁,用来保护小数据量的数据,如某个大结构的项。实际上,大部分锁都是介于这两种之间。一般都是先从粗糙的锁机制入手,直接锁定一个结构,后期,随着系统的性能出现瓶颈,就开始考虑如何细调锁机制,降低系统负担。