临界区和竞争条件
临界区:指的是访问和操作共享数据的代码段。
竞争条件:两个或多个执行线程处于同一个临界区中同时执行,可能会发生各线程互相覆盖共享数据的情况,造成被访问数据处于不一致的状态。
原子操作:为了避免对临界区的并发访问,必须保证临界区代码原子地执行——操作在执行结束前不可被打断,就像整个临界区是一个不可分割地指令一样。
同步:避免并发和防止竞争条件
避免竞争条件:
对临界区操作前,需要加锁,保证操作是原子性的,临界区代码必须完整地执行,决不能被打断。
对简单的单个变量的读写:
内核提供了指令实现原子的读变量,增加变量,写回变量。
加锁
锁提供的一种机制:
在同一时刻只有一个线程持有锁,第二个线程必须等待第一个线程释放锁,才能继续运行。
各种锁机制的区别:
当锁被其他线程持有,因而不可用时的行为表现——有的锁会简单执行忙等待,即不断循环检测锁的状态,等待锁变为可用;有的锁会使当前任务睡眠直到锁可用为止。
本质:
将临界区缩小到加锁和开锁之间。锁是采用原子操作实现的。
应用:(生产者消费者模型)
有一个队列,一个函数将新请求添加到队列尾部,一个函数从队列首部删除请求并处理它。我们需要确保一次只有一个线程对数据结构操作,避免并发访问队列。
此时,可以使用锁对队列进行保护。当有一个新请求加入队列时,先占住锁,然后安全地将请求加入队列,再释放锁;当有线程想要删除请求时,也要先占住锁,然后读取和删除请求,最后释放锁。
造成并发执行的原因
用户空间之所以需要同步,是因为用户程序会被调度程序抢占和重新调度。
当一个进程处于临界区时,可能会被调度程序选择的另一个优先级高的进程抢占,如果新调度的进程也随后进入此临界区,那么前后两个进程就会产生竞争。
内核中可能造成并发执行的原因:
- 中断:可以在任何异步时刻发生,随时打断当前的进程
- 软中断和tasklet:内核可以在任何时刻唤醒软中断和tasklet,打断当前的进程
- 内核抢占:内核中的任务被另一个任务抢占
- 睡眠及用户空间的同步:在内核执行的进程可能会睡眠,会唤醒调度程序,从而导致调度一个新的用户程序运行
- 对称多处理:两个或多个处理器同时执行代码
对应的能避免并发访问的安全代码:
- 中断安全代码:在中断处理程序中能避免并发访问的安全代码
- SMP安全代码:可在多处理器避免并发访问的安全代码
- 抢占安全代码:在内核抢占时能避免并发访问的安全代码
需要加锁的数据:大部分内核数据
不需要保护的数据:局部数据
死锁
产生条件:
有一个或多个执行线程和一个或多个资源,每个线程都在等待其中的一个资源,但是所有的资源都被占用了。所有线程都在互相等待,但是永远不会释放已经占有的资源,于是任何线程都无法继续,因而产生了死锁。
常见的死锁例子:
- 自死锁:一个线程试图获取自己已经持有的锁,它将一直等待锁被释放,而由于它在等待这个锁,永远不会有机会释放锁,最终结果是死锁。
- 两个线程两个锁ABBA锁:线程1获得锁A,,等待锁B;线程2获得锁B,等待锁A。
- n个线程n个锁:每个线程都有一把其他线程等待的锁,而没有一个线程会释放已得到的锁。
避免死锁
- 按顺序加锁:嵌套使用锁时,必须以相同的顺序获得锁。
- 防止发生饥饿
- 不要重复请求锁
- 设计力求简单
争用和可扩展性
锁的争用:当锁被占用时,有其他线程试图获取该锁。
高度争用状态:有多个其他线程等待获得该锁。
加锁粒度用来形容加锁保护的数据规模。一个过粗的锁保护大块数据,一个过于精细的锁保护很小的一块数据。
提高扩展性,可以提高Linux在大型的、处理能力更强的系统上的性能;但是一味提高可扩展性,会导致Linux在小型机器上性能降低。
当锁争用严重时,加锁太粗会降低可扩展性;当锁争用不严重时,加锁太细会增大系统开销,带来浪费,这两种情况都会是系统性能下降。