为什么用锁
临界区
访问和操作共享数据的代码段
竞态条件
两个执行线程处于同一个临界区中同时执行
竞态条件可能导致操作数据后得到不正确的数据
同步
避免并发和防止竞态条件
锁
锁的实现
采用硬件提供的原子操作和原子位操作实现,比如操作atomic_t的原子整数。大部分处理器都提供了test-set指令,这个指令用来测试一个整数的值,如果为0就设置一个新的值,0意味着开锁。x86 体系中使用compare和exchange指令
到底什么需要上锁
如果有其他执行线程可以访问这些数据,那么就给这些数据加上某种形式的锁;注意:是给数据上锁而不是给代码上锁
死锁
自死锁
一个执行线程试图去获得一个自己已经持有的锁,它将等待这个锁的释放,但是因为它正在忙着等待,所以自己永远也不会有机会释放锁
可以使用递归锁解决,递归锁可以被一个执行线程多次请求,但是很容易导致加锁逻辑变复杂
如何解决死锁
- 按相同的顺序获取锁
- 不要重复请求一个锁
- 设计尽可能简单,越复杂越容易死锁
上锁带来的性能影响
- 锁的作用是让程序串行的对资源进行访问,所以使用锁会减低系统性能,被高度争用的锁,或者频繁或者长时持有的锁会成为系统瓶颈,减低系统性能
锁的粒度
- 加锁粒度用来描述加锁保护的数据规模。比如保护整个系统所有用到的数据结构,那么这个锁粒度就过粗。给每个数据结构中的每个元素上锁,那么这个锁的粒度就过于精细
- 过粗可能导致争用问题严重降低可扩展性,过细意味着增加设计难度,和系统开销。初期设计要力求简单,后期再具体细化
递归锁
- 可以被一个执行线程多次请求
自旋锁
- 短期内进行轻量级加锁
- 最多只能被一个执行线程持有。如果一个线程试图获取一个已经被持有的(可以是自己,也可以是别的线程)自旋锁,那么会一直循环-旋转-等待锁可以重用,注意它不会休眠,会一直占用CPU。
- 所以自旋锁在等待期间的自旋会让处理器时间,所以自旋锁不应该被长时间持有
- 如果等待时间过长,应该考虑使用其他的方式,比如让请求线程睡眠知道重新可用时再唤醒,这样就可以不比循环等待的,但是这里有2次明显的上下文切换,被阻塞进程要换进换出,所以自旋锁时间最好要小于两次上下文切换耗时
- 自旋锁不可递归,如果你试图得到一个你正持有的自旋锁,那么你必须自旋,等待自己释放这个锁,但是你正处于自旋,所以没机会释放锁,所以会产生死锁。
- 双重请求死锁,中断处理程序可以使用自旋锁(不能使用条件变量会导致睡眠),但获取锁之前要禁止本地中断(当前处理器的中断请求),否则中断处理程序就会打断 正持有锁的内核代码,有可能会去征用这个已经持有的锁。那么中断处理程序就会自旋等待锁重新可用,但是锁在这个中断处理程序结束前不可能运行,导致了双重请求死锁。
读-写自旋锁
- 多个读者可以安全的获得同一个读自旋锁
- 写者只能独享一个写自旋锁
- linux读-写自旋锁比较照顾读。读锁被持有时候,写操作为了互斥访问,只能等待,但是新的读者可以继续占用这个锁,如果有大量读者,将导致写者饥饿
信号量
- linux信号量是一种睡眠锁,当一个任务试图获取一个不可用的信号量,信号量就会将其推进一个等待队列,然后让其睡眠。处理器这时可以重获自由去运行其他代码。当信号量可用时,等待队列里的那个任务会被唤醒,并获得该信号量。
- 有两中申请信号量的方法,一种可以在睡眠时不被信号唤醒(将进程状态置为TASK_UNINITERRUPTIBLE),一种可以(将进程转态置为TASK_INTERRUPTIBLE)。
读-写信号量
- 所有读写信号量都是互斥信号量,引用计数为1,只对写者互斥,不对读者数限制,读写锁睡眠不会被信号打断。
- 读-写机制是有条件的,只有在代码中可以自然地界定出读-写时才有价值
seq锁:顺序锁-用于读写共享数据-写者有利
- 依靠序列计数器,但有疑义的数据写入是,会得到一个锁,并且序列值会增加。读操作前后,序列号都会被读取,如果读取序列号相同说明没有被写操作打断过,如果读取的序列号是偶数,表面写操作没有发生。(锁初值为0,写锁会变成奇数,释放后会变成偶数)
- 只要没有别的写者,写者总能或者写锁,读者不会影响写者,挂起的写者会不断的让读操作循环,知道没有任何写者为止
适用场景 - 读者多,写者少
- 希望写者优先级高,不允许写者饥饿
- 数据简单,某些场合不能用原子量,比如LINUX启动时间的读取和更新
互斥体MUTEX
- 为什么需要MUTEX: 信号量虽然可以睡眠,但是不支持自动调试
- 互斥体:实现互斥的特定睡眠锁,也是一种互斥信号,使用起来类似引用计数为1的信号量,不管理引用计数
缺点
- 给mutex上锁的要负责给mutex解锁
- 不允许递归的上锁解锁
- 持有一个mutex时进程不可以退出
- 不可以在中断或者下半部使用
- 不可拷贝手动初始化,或者重复初始化,只能使用官方API管理
优点
- 接口简单实现高效
- 通过一种特殊的调试方式检查和警告任何践踏其约束条件的不老实行为,内核可以通过打开配置选项CONFIG_DEFBUG_MUTEXES检测和确保这些约束。
- 这些约束可以确保mutex使用者可以规范式的,简单会的使用模式对其使用。
完成变量
- 内核中一个任务需要发出信号通知另一个任务发送了某个特定事件,从而实现两个任务 的同步,思想类似信号量。
- 比如内核会把完成变量作为数据结构中的一项动态创建,完成数据结构初始化的内核代码调用wait_for_completion()等待,初始化完成后调用completion唤醒等待的内核任务
总结
- 中断上下文只能用自旋锁,任务睡眠只能用互斥体
- 进程等待信号量可用时会睡眠,因此信号量使用于锁会被长期持有
- 如果锁被短期持有,那么就不适合使用信号量,因为睡眠,维护等待队列,唤醒的开销可能要比锁的占用还有长。
- 由于争用信号量会睡眠,所以只能在中断上下文获取信号量锁,因为在中断上下文是不能进行调度的。
- 占有信号量的同时不能占用自旋锁,因为在你等待信号量时可能会睡眠,这样将影响自旋锁性能。
禁止强占
- 即使单处理器,因为内核是强占性的,那么存在一个任务运行到临界区被另一个任务强占了,这个任务也在这个临界区内运行,导致伪并发错误
- 可以使用自旋锁作为非强占区域标记。
- 也可以调用preempt_disable()禁止内核强占,该函数可以嵌套调用,每次调用都要有对应的preempt_enable()调用
- 强占计数器:存放被持有的锁的数量和preempt_disable()调用次数,根据是否为0判断是否可以抢占
顺序和屏障
- 编译器编译代码是静态的不会打乱原有顺序,但是处理器会重新动态排序,把表面上看似无关的指令按自己自认为最好是的顺序排序,比如读写逆序,导致一些问题产生,这也是peterson算法失效的原因
- 使用读屏障写屏障解决,保证对读操作或写操作不会排序到屏障的另一边