共享资源之所以要防止并发访问,是因为如果多个执行线程同时访问和操作数据,就有可能发生各线程之间相互覆盖共享数据的情况,从而造成被访问的数据不一致状态。
临界区和竞争条件
- 临界区:访问和操作共享数据的代码段。
- 原子操作:对资源的操作必须保证在结束之前不可被打断。
- 竞争条件:两个执行线程对同一个临界区操作。
- 同步:避免并发和防止竞争条件。
加锁
现在有一个队列,有两个函数,一个是在尾部添加元素,另一个是删除尾部元素。这两个函数在内核的各个部分都可以调用,因此现在假设程序 A 要增加一个元素,当增加完了后程序 A 被程序 B 抢占,此时程序 B 上 CPU 执行,程序 B 要删除尾部元素。
这样可以明显的看到,队列是共享资源,A 和 B 冲突了,B 删除了 A 需要的数据。
给程序加锁可以解决问题。
锁有多种多样的形式,而且加锁的粒度范围也各不相同。
各种锁机制之间的区别在于,当锁已经被其它线程所持有,因而不可用时的行为表现:
- 一些锁被争用时,会简单地执行忙等待(反复处于一个循环中,不断检测锁的状态,等待锁变为可用)
- 一些锁会使当前任务睡眠直到锁可用为止。
锁是采用原子操作实现的,而原子操作不存在竞争。
造成并发执行的原因
用户空间之所以需要同步,是因为用户程序会被调度程序抢占和重新调度。由于用户进行任何时刻都可能会抢占,因此如果抢占后的那个进程也处于同一个临界区时,前后两个进程相互之间就会产生竞争。
另外,因为型号处理是异步发生的,所以,即使是单线程的多个进程共享文件,或者在一个程序内部处理信号,也有可能产生竞争条件。这种类型的并发操作,其实两者并不是真的同时发生,但它们相互交叉进行,所以也可称作伪并发执行。
在对称多处理器中,两个进程就可以真正地在临界区中同时执行,这种称为真并发。
内核中可能造成并发执行的原因:
- 中断
- 软中断和 tasklet
- 内核抢占
- 睡眠以及用户空间的同步
- 对称多处理
在编写代码的开始阶段就要设计恰当的锁。
了解要保护什么
- 如果有其它执行线程可以访问这些数据,那么就给这些数据加上某种所。
- 如果任何其它睡眠东西都能看到它,那么就要锁住它。
要上锁的是数据,而不是代码。
在编写内核代码时,要清楚:
- 这个数据是不是全局的?除了当前线程外,其它线程能不能访问它。
- 这个数据会不会在进程上下文和中断上下文中共享?它是不是要在两个不同的中断处理程序中共享?
- 进程在访问数据时是否可能被抢占?被调度的新程序会不会访问同一数据?
- 当前进程是否会休眠(阻塞)在某些资源上,若是,它会让共享数据处于何种状态?
- 怎么防止数据失控?
- 如果这个函数又在另一个处理器上被调度将会发生什么?
- 如何确保代码远离并发威胁?
几乎访问所有的内核全局变量和共享数据都需要某种形式的同步。
死锁
当有一个或多个执行线程和一个或多个资源,每个线程都在等待其中的一个资源,但所有的资源都已经被占用了。所有线程都在相互等待,但它们永远都不会释放已经占用的资源。于是任何线程都无法继续,这便是死锁。
自死锁: 一个执行线程试图去获得一个自己已经持有的锁,它将不得不等待锁被释放,但因为它正在忙着等待这个锁,所以自己永远也不会有机会释放锁,最终形成死锁。
ABBA死锁:n 个线程和 n 个锁,若每个线程都持有一把其它进程需要得到的锁,那么所有的线程都将阻塞地等待它们希望得到的锁重新可用。
写代码中,避免死锁的一些规则:
- 按顺序加锁。使用嵌套的锁时必须保证以相同的顺序获得锁。
- 防止发生饥饿。程序是否一定会执行结束?若A进程不结束,那么B进程还需要等下去吗?
- 不要重复请求同一个锁。
- 设计锁要力求简单,越复杂的加锁方案越可能造成死锁。
如果有两个或多个锁曾在同一时间内被请求,那么以后其它函数请求它们也必须按照前次的加锁顺序进行。
现在有个函数(线程1)以 cat、dog 最后以 fox 的顺序获得锁,那么其它函数也都必须要以这个顺序获得锁。现假设另一个函数并没有以这个顺序得到锁,而是先得到 fox 锁,然后获得 dog 锁,因为 dog 总是先于 fox 被获得(dog 被线程1拿走了),因此造成死锁。
可以看到,线程2等到 dog 锁,而线程1在等到 fox 锁,两边都不肯释放自己所占用的锁,因此造成死锁。
在代码中使用锁的地方,对锁的获取顺序加上注释是个良好习惯,例如:
/*
* cat_lock —— 用于保护访问 cat 数据结构的锁,总是要在获得锁 dog 前先获得
*/
争用和扩展性
略…