内核同步控制机制
在讲述内核的各种进程间通信(interprocess communication, IPC)和数据同步机制之前, 我们简
单讨论一下相互通信的进程彼此干扰的可能情况, 以及如何防止. 我们的讨论只限于基本和核心的方面. 对于经典问题的详细解释和大量例子, 请参考市面上操作系统方面的通用教科书.
#1 临界区与竞态条件
所谓临界区(也称为临界段)就是访问和操作共享数据段的代码的段. 多个执行线程并发访问同一资源通常是不安全的, 为了避免在临界区中并发访问, 编程者必须保证这些代码原子地执行----也就是说, 操作在执行结束之前不可被打断, 就如同整个临界区是一个不可分割的指令一样. 如果两个执行现成有可能处于同一临界区中同时执行. 那么这就是程序中包含的一个bug. 如果这种情况确实发生了, 我们就称之为竞态条件(race conditions), 这样命名是因为这里会存在线程竞争. 这种情况出现的机会往往非常小----就是因为竞争引起的错误非常不易重视, 所以调试这种错误才会非常困难. 避免并发和防止竞争条件称之为同步(synchronization)
##1.1 竞争条件
我们考虑系统通过两种接口从外部设备读取数据的情况. 独立的数据包以不定间隔通过两个接口到达, 存在不同文件中. 录数据包到达的次序, 文件名之后添加了一个号码, 明数据包的
序号. 通常的一系列文件名是act1.fil、act2.fil、act3.fil, 等等. 可使用一个独立的变量来简化两个进程的工作. 该变量保存在由两个进程共享的内存页中, 且指定了下一个未使用的序号(为简
明起见,在下文我称该变量为 counter).
在一个数据包到达时, 进程必须执行一些操作, 才能正确地保存数据.
-
从接口读取数据
-
用序号counter构造文件名,打开一个文件
-
将序号加1
-
将数据写入文件,然后关闭文件。
上述的软件系统会发生错误吗? 如果每个进程都严格遵守上述过程, 并在适当的位置对状态变量加1, 那么上述过程不仅适用于两个进程, 也可用于多个进程.
事实上, 大多数情况下上述过程都会正确运作, 但在某些情况下会出错, 而这也是分布式程序设计的真正困难所在. 我们设个陷阱, 分别将从接口读取数据的进程称作进程1和进程2.
我们给出的场景中, 已经保存了若干文件, 比如说总共有12文件. 因此counter的值是13. 显然是个"凶兆"......
进程1从接口接收一个刚到达的新数据块. 它忠实地用序号13构造文件名并打开一个文件, 而同时调度器被激活并确认该进程已经消耗了足够的CPU时间, 必须由另一个进程替换, 并且假定是进程2. 要注意, 此时进程1读取了counter的值,但尚未对counter加1.
在进程2开始运行后, 同样从其对应的接口读取数据, 并开始执行必要的操作以保存这些数据.
它会读取counter的值, 用序号13构造文件名打开文件, 将 counter加1, counter从13变为14. 接下来它将数据写入文件, 最后结束.
不久, 又轮到进程1再次运行. 它从上次暂停处恢复执行, 并将counter加1, counter从14变为15. 接下来它将数据写入到用序号13打开的文件, 当然, 在这样做的时候, 会覆盖进程2已经保存的数据.
这简直是祸不单行, 丢失了一个数据记录, 而且序号14也变得不可用了.
修改程序接收数据之后的处理步骤, 可以防止该错误. 举例来说,进程可以在读取counter的值之后立即将counter加1, 然后再去打开文件. 但再想想, 问题远远不会这么简单. 因为我们总是可以设计出一些导致致命错误的情形. 因此, 我们很快就意识到了 : 如果在读取counter的值和对其加1
之间发生调度, 则仍然会产生不一致的情况.
几个进程在访问资源时彼此干扰的情况通常称之为竞态条件(race condition). 在对分布式应用编程时, 这种情况是一个主要的问题, 因为竞态条件无法通过系统的试错法检测. 相反, 只有彻底研究源代码(深入了解各种可能发生的代码路径)并通过敏锐的直觉, 才能找到并消除竞态条件.
由于导致竞态条件的情况非常罕见, 因此需要提出一个问题 : 是否值得做一些(有时候是大量的)工作来保护代码避免竞态条件.
在某些环境中(飞机的控制系统、重要机械的监控、危险装备), 竞态条件是致命问题. 即使在日常软件项目中, 避免潜在的竞态条件也能大大提高程序的质量以及用户的满意度. 为改进Linux内核对多处理器的支持, 我们需要精确定位内核中暗藏竞态条件的范围,并提供适当的防护. 由于缺乏保护而导致的出乎意料的系统崩溃和莫名其妙的错误, 这些都是不可接受的.
##1.2 临界区
这个问题的本质是 : 进程的执行在不应该的地方被中断, 从而导致进程工作得不正确. 显然, 一种可能的解决方案是标记出相关的代码段, 使之无法被调度器中断. 尽管这种方法原则上是可行的, 但有几个内在问题. 在某种情况下, 有问题的程序可能迷失在标记的代码段中无法退出, 因而无法放弃CPU, 进而导致计算机不可用. 因此我们必须立即放弃这种解决方案.
问题的解决方案不一定要求临界区是不能中断的. 只要没有其他的进程进入临界区, 那么在临界区中执行的进程完全是可以中断的. 这种严格的禁止条件, 可以确保几个进程不能同时改变共享的值, 我们称为互斥(mutual exclusion). 也就是说, 在给定时刻, 只有一个进程可以进入临界区代码.
有许多方法可以设计这种类别的互斥方法(不考虑技术实现问题). 但所有的设计都必须保证, 无论在何种情况下都要确保排他原则(exclusion principle). 这种保证决不能依赖于所涉及处理器的数目或速度. 如果存在这样的依赖(以至于解决方案只适用于特定硬件配置下的给定计算机系统), 那
么该方案将是不切实际的. 因为它无法提供通用的保护机制, 而这正是我们所需要的. 进程不应该允许彼此阻塞或永久停止. 尽管这里描述了一个可取的目标, 但它并不总是能够用技术手段实现, 读者从下文可以看到这一点. 经常需要程序员未雨绸缪,以避免问题的发生.
应用何种原理来支持互斥方法? 在多任务和多用户系统的历史上, 人们提出了许多不同的解决方案, 但都各有利弊. 一些解决方案只是纯理论的, 而另一些则已经在各种操作系统中付诸实践了.