注:本文含个人的理解和认知
1. 造成内核资源竞争的原因
其实根本原因是,内核允许交叉执行的内核控制路径(细节可以参考我之前Linux内核学习之内核控制路径文章),场景可以归纳成4种,都有可能对内核数据结构存在竞争。
- 系统调用
- 异常
- 中断
- 抢占
2. 不必要的同步
有一些场景,考虑到架构的特点,是没有必要做同步的。
- 中断不会被同优先级(或一下)的中断打断,因此在分析中断的同步场景时,没有必要考虑会被同优先级甚至低优先级的中断打断。
- 内核在设计阶段,就充分考虑了异常,保证除了少数几个被用作特殊功能的异常(如,缺页),在内核态是不会产生异常的,因此,没必要考虑这种场景的同步。
- 同一个tasklet不可能同时在几个CPU上执行。
3. 同步技术
- 原子操作
- 内核信号量
- 内核自旋锁
- 关中断
- 关抢占
- 大内核锁
3.1 原子操作
- 这是最小的,也是代价最小的同步机制,它利用CPU指令集中部分指令的原子性,即,有些指令,它在执行完成前是不会被中断打断的,也就是说,在该CPU上(注意,这里强调是单核CPU),执行该指令时,是不会发生内核控制路径切换的,因为内核控制路径的切换需要中断的加持(不考虑主动切换,因为主动切换前,肯定自行保证了数据的一致性),进而保证了数据操作的一致性。这类指令包含一些读、改、写一体的指令。注意不是所有指令都支持原子操作,如有些微控器芯片,为了加快中断响应的及时性,有些指令是可以被打断的。
- 现在说说原子操作的局限性,第一,因为很小,因此能做的事情也很有限,比如修改数据结构涉及多个字段,无法通过一条指令实现。第二,在多核系统上,原子操作仍无法保证数据的一致性,虽然总线一次只能有一个cpu访问,但是,CPU1上的指令执行完读时,总线可能先响应了CPU2的读,然后CPU1将结果通过总线写回内存,最后CPU2将结果写回内存,假设CPU1和CPU2执行的都是对变量a的加1操作,a最初值为0,那么经过这一番操作,a的值等于1,这显然不符合一致性要求,正确结果应为2。
- 为了解决上面问题,在使用原子操作时,往往会结合总线锁使用,lock指令能够保证,在接下来的指令执行完成前,总线都被当前CPU独占。
3.2 内核信号量
- 与用户级信号量(IPC信号量)相似,内核信号量也会使进程挂起,部分会阻塞的系统调用就是通过内核信号量实现的,与用户级型号不同的地方在,内核信号量运行在内核态,用户级信号量则运行在用户态。
- 内核信号量有一个子类是读写信号量,与用户态读写锁的作用类似。
PS:这里说一下读写锁一个有意思的地方,读锁可以重复获取,即,读操作允许多次进入临界区,但是写锁只有一个人获取,且必须等待所有读锁释放(即读锁的引用计数归零),才能进入临界区,为了避免读操作频繁,导致写操作永远无法进行(写饥渴),当有进程在等待写锁时,则禁止后续读锁的获取(不影响已经获取的读锁),但是频繁的写操作仍然会导致读饥渴(这种情况就不应该使用读写锁,本末倒置的用法错误)。
3.3 内核自旋锁
- 与用户级自旋锁作用一样,不同的是这个运行在内核态。
- 自旋锁也分几个子类,普通自旋锁,读写自旋锁,顺序自旋锁,前面两种比较熟悉,重点说下顺序锁,它与读写锁非常相似,不过赋予了写更高的权限,就算当前有读正在执行,仍然可以执行写操作,写只与写互斥。这个锁的缺点是,读操作需要重复操作几次以确认读到了有效的数据。
- 顺序锁有个计数器,每次读操作前后两次检查该计数器,如果不相同,则说明数据有更新,需要重新读;计数器起始值为0,每次写操作,获取锁,会对计数器进行原子加1,释放锁,会再次原子加1,因此每次获取写锁,计数器是计数,以计数器奇数值表锁定状态,偶数值表释放状态。
3.4 关中断
- 关中断,这种同步方式,只针对单核CPU有效,除非关全局中断(即关闭中断仲裁器),因为关一个CPU的中断,并不能影响其他CPU访问内核数据。当然对于每CPU变量(只有这个CPU自己会修改的全局变量),可以采用这种关本地中断的方式来实现保护。x86架构CPU的中断由名为eflags的cpu寄存器控制,关闭 eflags 的 IF 标志位,即可关本地中断
3.5 关抢占
- 这里指关内核抢占,这中方法只能作用于两个处在内核控制路径的进程,对内核资源造成竞争的场景,这是代价较小的同步方法。
3.6 大内核锁
这个就大了,它保证一次只有一个进程进入内核态。