原书《操作系统精髓与设计原理——富兰克林》第五章。
不论是进程还是线程,不论是单处理器的多道程序设计还是多处理器甚至是分布式系统,因为程序能并发或者真正的并行执行,所以都面临着一系列的并发问题。比如一个进程正在访问打印机,另一个进程也要使用打印机,如果没有一些手段来处理这些并发问题,两个进程输出给打印机的内容就会被乱序打印。
进程的交互
进程间存在三种交互关系
进程之间相互不知道对方的存在
这种情况下一般是进程之间对一些外部设备存在竞争,比如它们共同访问一个打印机、磁盘、文件。
这种情况必然发生,并且由于竞争的进程之间并不知道对方的存在,所以不能够指望它们自己来使用某些机制消除竞争。消除它们之间的竞争得由操作系统来完成。
我们把这些进程需要共同访问的资源,也就是操作系统需要提供某种保护手段的资源称作临界资源,访问临界资源的那部分程序称作临界区。操作系统需要在这些临界资源的访问时提供一种互斥机制,即如进程A正在访问一个临界资源,进程B也想访问这个临界资源,那么B需要等待,等A完成了对临界资源的访问,B再访问。
互斥机制虽让临界资源的访问变得有序,但也引出了一些问题,比如进程A正在访问临界资源1,进程B正在访问临界资源2,这时,进程B想访问临界资源1,它需要等待进程A释放,而进程A又想访问临界资源2,它也需要等待进程B释放,它们在对方释放资源前谁都没法释放自己手里的资源,导致它们就一致僵持下去,这种情况称作死锁。还有一个问题就是进程A、B和C都想以一个频率来访问临界资源1,A先得到了机会,BC等待,A访问完毕,释放,B得到了机会,B执行完毕释放,A又得到了机会,在最极端的情况下,C永远得不到执行的机会,这种情况称作饥饿。
显然,死锁能够引起饥饿,比如A和B因为两个临界资源产生了死锁,C想访问其中的一个,在死锁被某些外力解除前,它永远也得不到机会。
此类竞争条件往往通过操作系统进行协调处理,但也允许进程通过某种方式表达它将要访问一个临界资源的诉求,比如操作系统可能提供锁机制,一个进程想要访问某些资源时,先请求锁定这个资源,此时其他进程对该资源的访问统统挂起,当它处理完毕后,它要释放对该资源的锁定。如下图,entercritical
用于锁定临界资源,进入临界区,exitcritical
用于释放临界资源,退出临界区,它们的参数就是要锁定或释放的临界资源。
进程之间通过共享合作
此状态下,进程可能共享出去一些公共的资源,比如变量、共享文件或数据库。此时的进程也不知道其他进程的细节,但是它知道它正在共享的资源可能被其它进程使用或读写,所以这些进程必须要主动使用某些手段进行合作。例如上面的entercritical
和exitcritical
。
注意,该状态下的进程虽不知道彼此的细节,但它们之间是一种合作关系,而上面所说的是竞争关系。
进程之间通过通信合作
此时的进程之间清楚的知道彼此的存在,并且它们通过某种通信手段进行通信,它们之间通过通信来协调和同步各种活动。
互斥:硬件的支持
首先互斥是可以使用软件来完成的,不过好像因为有种种的缺陷所以不常用,在本书的附录中应该有介绍。下面主要介绍的是硬件实现的互斥。
中断禁用
在单处理器多道程序操作系统中,并发进程不能真正的重叠执行,它们只能够交替执行,所以只需要保证一个进程在进入临界区时不会被打断即可。一个进程只有在调用系统服务或者被中断时才会被打断。所以只需要操作系统提供禁用中断的原语即可在进程间实现互斥。使用这种办法,进程在临界区可以这样实现互斥:
该方法不能在多处理器的情况下使用,因为多处理器操作系统可以让进程真正的并行重叠执行。
同时我个人的看法,程序设计语言与操作系统底层原理不在一个层面上,我觉得不应该让程序设计语言卷入类似“中断”这样的操作系统概念,否则程序员就必须要去了解什么是“中断”。别忘了操作系统的目的是向外界屏蔽细节,提供简单一致的抽象,又怎么能让程序员卷入这些细节中呢?
专用机器指令
一种能在多处理器共享内存的操作系统中用于实现对内存单元的互斥访问的指令。注意,只是对一个内存单元的互斥访问,稍后我们会看到如何使用这一个内存单元来进行多个进程之间的互斥操作。
一个指令是CAS,即比较后交换,调用者在写入内存单元时,先给定一个测试值,还有一个要写入的新值,然后这个指令会用测试值和内存单元中的旧值进行测试,如果它们相等,就将新值设置给该内存单元。
无论如何,该函数返回oldval
,在调用者对内存单元的写入成功的情况下,oldval
等于测试值,否则,它等于内存中的旧值。调用者只需要在调用结束后判断返回值是否等于testval
即可。
用它如何实现互斥呢?我们可以使用这个内存单元作为临界资源是否正在被访问的标记:
上面的代码首先将bolt
初始化为0,然后它创建了n个进程,比较幸运首先发现bolt
是0的那个进程将bolt
置为1,此时它进入临界区,其它进程都会在内循环上自旋等待(或称为忙等待),因为它们的cas操作迟迟没有成功。当在临界区的进程执行完毕要退出临界区时,它将bolt
置回0,这时继续重复之前的动作,即所有进程竞争着发现bolt
为0。
还有一个指令是exchage
,它交换一个寄存器和一个存储单元之中的内容:
我们可以这样用它实现互斥:
首先还是bolt
为0,所有进程初始化一个keyi
为1,此时,等待某一个进程P先执行到exchange
,这时,进程P的keyi
为0,而bolt
被置1,该进程进入临界区。
后续再来的进程再执行exchange
时,进程中的keyi
和共享内存中的bolt
都是1,这种交换不会产生任何实质性的作用,什么值都不会改变,所以其它线程一直在内循环中自选,当进程P从临界区退出,它把bolt
置0。
它能够在几乎所有情况下提供互斥,但是它也有缺点:
- 使用了自旋操作:自旋操作也在占用CPU,这样会让CPU陷入没用的繁忙状态
- 可能饥饿
- 可能死锁
还有就是我感觉对于程序来说还是过于麻烦,卷入底层细节。我们需要一种能够屏蔽实现细节的机制,比如:
int p() {
lock();
// 临界区
release(); // 退出
}
信号量
从现在开始,讨论的不是硬件层面对互斥的支持了,是操作系统或程序设计语言对互斥的支持。它们通常能对开发者屏蔽具体的底层细节和更清晰明了的抽象。
二元信号量
先说二元信号量,我们从它的用法开始,假设这个信号量是s。
用户可以做的操作很有限,这也是为了屏蔽细节:
- 初始化信号量
s
为0或1 -
semWait(s)
先检查信号量的值,若为0,则阻塞。当semWait(s)
执行完毕,信号量s
的值置0。 -
semSignal(s)
将唤醒一个正在等待(在semWait(s)
处阻塞的)的进程,并将信号量s
的值置1。
经过以上三点,二元信号量的值被限制在0或1,不可能出现别的值。并且,除了以上三点,你没有任何手段可以检查一个信号量的值,或对信号量做任何操作。
不同进程之间如果访问同一个临界资源,可以使用一个信号量来保护这个临界资源:
semaphore s = 1;
int p() {
semWait(s);
// 进入临界区...
semSignal(s); // 退出临界区
}
semWait
和semSignal
被定义为原子操作。任意时候,只要二元信号量为0,说明临界区中有进程访问,若二元信号量为1,说明临界区中无进程。
多元信号量
有时我们的需求是允许在临界区内有一定数量的进程并发执行,而不是让它们在临界区完全串行执行。非二元的信号量可以做到这点。
- 一个信号量
s
可以初始化成非负数 -
semWait(s)
使得s
减1,若s
为负数,调用者进程阻塞,否则继续执行 -
semSignal(s)
使得s
加1,若此时值小于等于0(加1之前小于0),则解除一个正在阻塞的进程
关键是,理解当信号量为非负数时,调用semWait
的进程可以不用阻塞,立即执行,也就是说此时信号量s就是当前系统中剩余的可并发执行的进程;而当信号量为负数时,就代表当前有多少个进程正在阻塞。
强信号量和弱信号量
区别在于semSignal
在解除一个阻塞进程时使用的策略,若遵循队列的先进先出顺序(FIFO),则是强信号量,若未规定顺序,则是弱信号量。
不清楚的点:如果按这个所说,那么比如有个信号量系统使用后进先出的顺序,那么它是强信号量还是弱信号量?它有规定的顺序,但它不符合FIFO。
对于这点,我想书上的意思可能是不按FIFO的都是弱信号量,或有一种顺序可循的都是强信号量
使用信号量解决互斥问题
一般情况下,如果是针对临界区的互斥访问,我们都把信号量初始化为1,以便第一个进程可以直接进入临界区,然后稍后退出临界区时将信号量加1,以便其他进程进入临界区。
生产者消费者问题
现在考虑有一个无限长度的产品生产线,一个或多个生产者可以向其中生产产品,只有一个消费者可以从中消费产品。
我们要解决的互斥有两个部分,一是我们不能让生产者和消费者同时操作队列,这样可能会造成数据混乱,二是当生产线中没有产品时,消费者无法消费。
二元信号量
下面的设计中比较值得一说的部分是,生产者produce
和消费者consume
的部分没有在临界区内处理,实际在临界区内的只有向缓冲区中添加或取得。
在并发程序设计中,一个指导原则就是尽量在临界区内保留最少的代码,如非必要不要在其中进行耗时操作。
int n; // 当前生产线中产品个数
b_semaphore wr = 1, p = 0;
void producer() {
while(true) {
produce(); // 生产产品
semWait(wr);
append();
n++;
if (n == 1) semSignal(p);
semSignal(wr);
}
}
void consumer() {
semWait(p);
while(true) {
semWait(wr);
take();
n--;
semSignal(wr);
consume();
if(n == 0) semWait(p);
}
}
void main() {
n = 0;
parbegin(producer, consumer);
}
信号量
semaphore wr = 1, n = 0;
void producer() {
while(true) {
produce();
semWait(wr);
append();
semSignal(wr); // 这行可以与下面的行交换位置
semSignal(n);
}
}
void consumer() {
while(true) {
semWait(n); // 这行不可以与下面的互换
semWait(wr);
take();
semSignal(wr);
consume();
}
}
void main() {
n = 0;
parbegin(producer, consumer);
}
考虑不能够互换那两行,如果互换了,消费者先使用semWait
将wr
的值-1,那么任何生产者都无法在消费者调用semSignal(wr)
之前向生产线中添加产品,而这时如果生产线中恰巧没产品,semWait(n)
也会阻塞。结果就是消费者等待生产者生产数据,生产者等待消费者释放生产线的使用权,这就出现了死锁。
有限生产线的生产者消费者问题
如果将生产者消费者问题的生产线队列设为有容量限制的,比如它最多容纳20个产品,那么该咋办。
我们需要再添加一个信号量e来表示当前生产线中的空闲空间。
semaphore wr = 1, n = 0, e = 20;
void producer() {
while(true) {
produce();
semWait(e);
semWait(wr);
append();
semSignal(n);
semSignal(wr);
}
}
void consumer() {
while(true) {
semWait(n);
semWait(wr);
take();
semSignal(wr);
semSignal(e);
consume();
}
}
void main() {
n = 0;
parbegin(producer, consumer);
}
错误例子,只用了一个信号量,这种情况下,消费者不受制约,消费者可以无限的take尽管并没有啥东西让它take。
semaphore wr = 1, n = 20;
void producer() {
while(true) {
produce();
semWait(n); // 等待一个空闲位置
semWait(wr);
append();
semSignal(wr);
}
}
void consumer() {
while(true) {
semWait(wr);
take();
semSignal(wr);
semSignal(n);
consume();
}
}
void main() {
n = 0;
parbegin(producer, consumer);
}
信号量的实现
可能是硬件实现、软件实现或者基于忙等待实现。在单处理器情况下,还可能是屏蔽中断。
管程
对于管程是什么还是有点懵,这里只说下自己的看法,如果有错误,欢迎纠正,感谢。
管程是在编程语言级别对互斥进行的更高级的抽象,是在任意时刻只能由一个进程执行的一段代码。使用管程,我们不再需要一个什么信号量或者原子CAS操作来小心翼翼的自己实现临界区,因为在任意时刻,管程只能被同一个进程执行,其它的只能阻塞,等待管程中的当前进程执行完毕。
管程由一个锁和零到若干个条件变量构成,锁用来提供“管程一次只能被一个进程调用”的功能,条件变量用来控制管程中的操作逻辑,下面是我用我理解的管程来编写的一段生产者消费者的伪代码:
queue q;
lock lock;
condition notfull, notempty;
void append(product p) {
aquire(lock);
// 如果队列满了,等待队列不满的条件
if (is_full(q)) wait(notfull);
q.enqueue(p);
// 因为刚将一个产品入队, 所以激活队列非空的条件
signal(notempty);
release(lock);
}
product take() {
aquire(lock);
// 如果队列为空,等待队列不为空的条件
if (is_empty(q)) wait(notempty);
product p = q.dequeue();
// 因为刚弹出一个所以一定不满了,激活条件
signal(notfull);
release(lock);
return p;
}
void producer() {
while(true) {
append(
produce()
);
}
}
void consumer() {
while(true) {
consume(
take()
);
}
}
首先,锁对象给一个函数提供了“只能供一个进程进入”的互斥能力,简而言之,我们使用加锁和解锁操作实现“管程”。一个进程如果使用aquire
对一个锁对象加锁,那么其它进程都没办法再向它加锁,其它进程只能等待持有锁对象的进程通过release
对该锁对象解锁。
其次,条件变量代表了一种状态,比如notempty
待表当且队列是非空的。当条件满足时,进程需要使用signal
函数来激活这个条件变量,激活后,该条件的状态转为满足。某些进程需要等待条件满足,使用wait
函数来等待。
你可以按照信号量的思路来理解,比如wait
函数在某个条件上挂起,signal
函数激活在某个条件上挂起的一个进程。
Java中的管程
Java中的管程和我们上面定义的还不太一样,最开始我想用Java来做上面的演示的,但是后来发现行不通。
下面是不使用并发库情况下Java管程和上面的区别:
- Java中没有特定的锁对象,每个对象都可以充当锁
- Java中没有条件变量,锁就充当条件变量,同时你对锁进行
wait
和signal
操作的时候,必须持有这把锁 - Java中没有
signal
,取而代之的是notify
- Java使用synchronized关键字来获取锁,它是一个代码块,当代码块执行完毕,自动释放锁。synchronized关键字作用在方法上时,该方法就被当成它的代码块,锁对象就是该方法所在的实例对象(只有当该方法是实例方法时)。
注意,下面我们用线程来演示,而非进程,Java的并发模型是建立在线程上的。
Java中你可以这样理解,wait
方法在一个对象上挂起,等待唤醒,notify
唤醒该对象上挂起的一个线程,还有notifyAll
唤醒所有该对象上挂起的线程。
下面是Java使用这些来解决生产者消费者问题的代码,我将一些东西精简掉了,比如异常处理。同时Java有比这个更先进的并发库,它们的可读性更强,且效率要比下面的代码高
synchronized void append(int x) throws InterruptedException {
// 如果产品列表已经满了,挂起当前线程,准备接受唤醒
// 由于没有了条件变量,所以唤醒我们的可能不一定是生产者
// 所以我们还要不停的轮询这个条件
while (products.size() == MAX_SIZE) wait();
products.add(x);
notifyAll(); // 发生状态改变,唤醒全部挂起线程
}
synchronized int take() throws InterruptedException {
int result;
// 如果产品列表为空,挂起,等待接收唤醒
while (products.size() == 0) wait();
result = products.remove(0);
notifyAll();
return result;
}
void producer() {
while (true) {
append(produce());
}
}
void consumer() {
while (true) {
consume(take());
}
}
public static void main(String[] args) {
MointerTest test = new MointerTest();
new Thread(test::consumer).start();
new Thread(test::consumer).start();
new Thread(test::consumer).start();
new Thread(test::consumer).start();
new Thread(test::producer).start();
new Thread(test::producer).start();
new Thread(test::producer).start();
new Thread(test::producer).start();
}
后续内容
后面的内容是进程间使用通信机制来协同工作,我感觉这个对我没啥帮助,还有一个是另一个经典的并发问题,我不打算记了,如果有需要可以看书。或者我可能明天会来记完。