在以前的文章中咱们讲到过引发多线程 bug 的三大源头问题(可见性,原子性,有序性问题),java 的内存模型(Java Memory Model)能够解决可见性和有序性问题,可是原子性问题如何解决呢?java
public class Counter {
volatile int count; public void add10K() { for(int i = 0; i < 10000; i++) { count++; } } public int get() { return count; } public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Thread t1 = new Thread(() -> counter.add10K()); Thread t2 = new Thread(() -> counter.add10K()); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(counter.count); } } 复制代码
上面的代码咱们的指望结果是 20000,能够运行结果确实小于 20000 的。是由于 count 这个共享变量同时被两个线程在修改,而 count++这条语句在 cpu 中实际对应三条指令程序员
而线程切换就可能发生在执行完任意一个指令的时候,好比 如今 count 的值为 100, 线程 1 读取到了以后加 1 完成,可是尚未将新的值赋值给 count,此时让出 cpu,而后线程 2 执行,线程 2 读取到的值也为 100,执行后 count++以后,才让出 cpu,此时线程 1 获取到执行资格,将 count 设置为 101。web
相似上面的状况,两个或多个线程读写某些共享数据,而最后的结果取决于线程运行的精准时序,称为竞争条件。 那么如何避免竞争条件呢?实际上凡是涉及共享内存、共享文件以及共享任何资源的状况都会引起与前面相似的错误,要避免这种错误,关键是要找出某种途径来阻止多个线程同时读写共享的数据。算法
换言之,咱们须要的是互斥,即以某种手段确保当一个进程在使用另外一个共享变量或文件时,其余进程不能作一样的操做。编程
上面例子的症结在于,线程 1 对共享变量的使用未结束以前线程 2 就使用它了。缓存
线程的存在的问题,进程也一样存在。因此咱们接下来看看操做系统是如何处理的。数据结构
一个进程的一部分时间作内部计算或另一些不会引起竞争条件的操做。在某些时候进程可能须要访问共享内存或文件,或执行另一些会致使竞争的操做。多线程
咱们把对共享内存进行访问的程序片断称做临界区域或临界区。若是咱们可以使得两个进程不一样时处于临界区中,就能避免竞争。并发
为了保证使用共享数据的并发进程可以正确和高效地进行写做,一个好的解决方案须要知足如下 4 个条件。编程语言
比较指望的进程行为如上图所示。进程 A 在 T1 时刻进入临界区。稍后,在 T2 时刻进程 B 试图进入临界区,可是失败了,由于另外一个进程已经在临界区内,而一个时刻只容许一个进程在临界区内。因此 B 被暂时挂起知道 T3 时刻 A 离开临界区为止,从而容许 B 当即进入。最后,在 B 离开(在时刻 T4),回到了在临界区中没有进程的原始状态。
为了保证同一时刻只能有一个进程进入临界区,咱们须要让进程在临界区保持互斥,这样当一个进程在临界区更新共享内存时,其余进程将不会进入临界区,以避免带来不可预见的问题。
接下来会讨论下关于互斥的几种方案。
单处理器系统中比较简单的作法就是在进入临界区以后当即屏蔽全部中断,并在要离开以前在打开中断。屏蔽中断后,时钟中断也被屏蔽,因为 cpu 只有发生时钟中断或其余中断时才会进行进程切换,这样在屏蔽中断后 CPU 不会被切换到其余进程,检查和修改共享内存的时候就不用担忧其余进程介入。
中断信号致使 CPU 中止当前正在作的工做而且开始作其余的事情
但是若是把屏蔽中断的能力交给进程,若是某个进程屏蔽以后再也不打开中断怎么办? 整个系统可能会所以终止。多核 CPU 可能要好点,可能只有其中一个 cpu 收到影响,其余 cpu 仍然能正常工做。
锁变量实际是一种软件解决方案,设想存在一个共享锁变量,初始值为 0,当进程想要进入临界区的时候,会执行下面代码相似的判断
if( lock == 0) { lock = 1; // 临界区 critical_region(); } else { wait lock == 0; } 复制代码
看上去很好,但是会出现和文章开头演示代码一样的问题。进程 A 读取锁变量 lock 的值发现为 0,而在刚好设置为 1 以前,另外一个进程被调度运行,将锁变量设置为 1。 当第一个进程再次运行时,一样也将该锁设置为 1,则此时同时有两个进程进入临界区中。
一样的即便在改变值以前再检查一次也是无济于事,若是第二个进程刚好在第一个进程完成第二次检查以后修改了锁变量的值,则一样会发生竞争条件。
这种方法设计到两个线程执行不一样的方法
线程 1:
while(true) { while(turn != 0) { // 空等 } // 临界区 critical_region(); turn = 1; // 非临界区 noncritical_region(); } 复制代码
线程 2:
while(true) {
while(turn != 1) { // 空等 } // 临界区 critical_region(); turn = 0; noncritical_region(); } 复制代码
turn 初始值为 0,用于记录轮到哪一个线程进入临界区。
开始时,进程 1 检查 turn,发现其值为 0,因而进入临界区。进程 2 也发现值为 0,因而一直在一个等待循环中不停的测试 turn 的值。
连续测试一个变量直到某个值出现为止,称为忙等待。因为这种方式浪费 CPU 时间,因此一般应该避免。只有在有理由认为等待时间是很是短的状况下,才使用忙等待。用于忙等待的锁,称为自旋锁。
看上去是否是很完美,其实否则。
当进程 2 执行得比较慢还在执行非临界区代码,此时 turn = 1,而进程 1 又回到了循环的开始,可是它不能进入临界区,它只能在 while 循环中一直忙等待。
这种状况违反了前面叙述的条件 3:进程不能被一个临界区外的进程阻塞。这也说明了在一个进程比另外一个慢了许多的状况下,轮流进入临界区并非一个好办法。
1981 年发明的互斥算法其基本原理以下
int turn;
boolean[] interested = new boolean[2]; // 进程号是0或者1 void enterRegion(int process) { // 其余进程 int other = 1 - process; // 记录哪一个进程有兴趣进入临界区 interested[process] = true; // 设置标志 turn = process; // 若是不知足进入条件,则空等待 while(turn == process && interested[other] == true) { } } void leaveRegion(int process) { interested[process] = false; } 复制代码
当进程想要进入临界区的时候就会调用 enterRegion 方法,该方法在须要时将使得进程等待,在完成对共享变量的操做后,就会调用 leaveRegion 离开临界区。
这种算法虽然不会严格要求进程的执行顺序,可是仍然会形成忙等待。
忙等待的缺点如何克服呢?
忙等待的本质是因为当一个进程想要进入临界区时,先检查是否容许进入,如不容许,则该进程在原地一直等待,知道容许为止
是否存在一个命令当没法进入临界区的时候就阻塞,而不是忙等待呢?
进程通讯有两个原语 sleep 和 wakeup 就知足这个需求,sleep 是一个将引发调用进程阻塞的系统调用,它会使得进程被挂起直到另一个进程将其唤醒。
而 wakeup 有一个参数,即被须要唤醒的线程。这里咱们来考虑一个生产者消费者的例子。
生产者往缓冲区中写入数据,消费者从缓冲区中取出数据。可是这里有两个隐藏的条件,当缓冲区为空的时候消费者不能取出数据, 当缓冲区满了的时候生产者不能生产数据。
int MAX = 100;
int count = 0; void producer() { int item; while(true) { iterm = produce_item(); // 队列满了就要睡眠 if(count == MAX) { sleep(); } insert_item(item); count = count + 1; if(count == 1) { wakeup(consumer); } } } void consumer() { int item; while(true) { if(count == 0) { sleep(); } item = remove_item(); count = count -1; // 队列不满,则唤醒producer if(count == N -1) { wakeup(producer); } consume_item(item); } } 复制代码
乍看之下没有问题,其实仍是存在问题的,那就是对 count(队列当前数据个数)的访问未加限制。
队列为空,当 consumer 在执行到 if(count == 0)的时候,若是恰好发生了进程切换,producer 执行,此时插入一个数据,而后发现 count == 1,就会唤醒 consumer(可是实际上 consumer 并无睡眠),consumer 接着刚才的代码执行会发现 count = 0,因而睡眠。生产者早晚会填满整个队列,从而使得两个进程都陷入睡眠状态。
快速的弥补方法就是加一个唤醒等待位,当一个 wakeup 信号发送给一个清醒的进程时,将该标志位置为 1,随后,当该进程要睡眠时,若是唤醒等待位为 1,则将该标志位清除,同时进程保持为清醒状态。
可是这个办法仍是治标不治本,若是有三个进程甚至更多的进程那么就会须要更多的标志位。
信号量是 E.W.Dijkstra 在 1965 年提出的一种方法,它使用一个整型变量来累计唤醒次数,供之后使用。 在他的建议中引入了一个新的变量类型,称做信号量(semaphore),一个信号量的取值能够为 0(表示没有唤醒操做)或者为正值(表示有一个或多个唤醒操做)。 Dijkstra 建议设立两种操做:down 和 up(分别为通常化后的 sleep 和 wakeup)
void down() { if(semaphore > 0) { semaphore = semaphore -1; } else { sleep(); } } 复制代码
检査数值、修改变量值以及可能发生的睡眠操做均做为一个单一的、不可分割的原子操做完成,保证一旦一个信号量操做开始,则在该操做完成或阻塞以前,其余进程均不容许访问该信号量。
所谓原子操做,是指一组相关联的操做要么都不间断地执行,要么都不执行。
void up() {
semaphore = semaphore + 1; wakeup(); } 复制代码
up 操做对信号量的操做增长 1,一样信号量的值增长 1 和唤醒一个进程一样是不可分割的一个操做。
那么是如何保证 down()和 up()的操做是原子性的呢?其实是经过硬件提供的 TSL 或 XCHG 指令来完成的。
执行 TSL 或者 XCHG 指令的 CPU 将锁住内存总线,以禁止其余 CPU 在本指令结束以前访问内存。
来看看如何使用信号量的方式来解决生产者消费者问题。
在下面的实现方案中使用了 3 个信号量
#define N 100 // 缓冲区中槽数目 typedef int semaphore; // 信号量是一种特殊的整型数据 semaphore mutex = 1; // 控制对临界区的访问 semaphore empty = N; //缓冲区的空槽数目 semaphore full = 0; // 缓冲区的满槽数目 void producer(void) { int item; while (TRUE) { // 产生放在缓冲区中的一些数据 item = produce_item(); // 将空槽数目減1 down(&empty); // 进入临界区 down(&mutex); // 将新数据项放到缓冲区中 insert_item(item); // 离开临界区 up(&mutex); // 将满槽的数目加1 up(&full); } } void consumer(void) { int item; while (TRUE) { // 将满槽数目減1 down(&full); // 进入临界区 down(&mutex); // 从缓冲区中取出数据项 item = remove_item(); // 离开临界区 up(&mutex); // 将空槽数目加1 up(&empty); // 处理数据项 consume_item(item); } } 复制代码
empty 这个信号量保证了缓冲区满(empty=0)的时候生产者中止运行,full 这个信号量保证了缓冲区空的时候消费者中止运行。
互斥量是信号量的简化版本,互斥量仅仅适用于管理共享资源或一小段代码,因为互斥量在实现时即容易又有效,这使得互斥量在实现用户空间线程包时很是有用。
互斥量是一个能够处于两态之一的变量:解锁和加锁。这样,只须要一个二进制位表示它,不过实际上,经常使用一个整型量,0 表示解锁,而其余全部的值则表示加锁。
当一个线程(或进程)须要访问临界区时,它调用 mutex_lock,若是该互斥量当前是解锁的(即临界区可用),此调用成功,调用线程能够自由进入该临界区。
另外一方面,若是该互斥量已经加锁,调用线程被阻塞,直到在临界区中的线程完成并调用 mutex_unlock。若是多个线程被阻塞在该互斥量上,将随机选择一个线程并容许它得到锁。 因为互斥量很是简单,因此若是有可用的 TSL 或 XCHG 指令,就能够很容易地在用户空间中实现它们。
mutex_lock:
// 将互斥信号量复制到寄存器,而且将互斥信号量置为 1 TSL REGISTER,MUTEX // 互斥信号是0吗 CMP REGISTER,#O // 若是互斥信号量为0,它被解锁,因此返回 JZE ok // 互斥信号量忙,调度另外一个线程 CALL thread_yield // 稍后再试 JMP mutex_lock ok: RET // 返回调用者,进入临界区 mutex_unlock: // 将mutex置为0 MOVE MUTEX,#0 // 返回调用者 RET 1 复制代码
mutex_lock 的方法和 enter_region 的方法有点相似,可是有一个很关键的区别,enter_region 进入临界区失败,会忙等待(实际上因为 CPU 时钟超时,会调度其余进程运行)。
因为 thread_yield 只是在用户空间中对线程调度程序的一个调用,因此它的运行很是快。这样,mutex_lock 利 mutex_unlock 都不须要任何内核调用。
为实现可移植的线程程序,IEEE 标准 1003.1c 中定义了线程的标准,它定义的线程包叫作 Pthread,大部分 UNIX 系统都支持这个标准。
Pthread 提供许多能够用来同步线程的函数。其基本机制是使用一个能够被锁定和解锁的互斥量来保护每一个临界区。
与互斥量相关的主要函数调用以下所示:
线程调用 | 描 述 |
---|---|
pthread_mutex_init | 建立一个互斥量 |
pthread_mutex destroy | 撤销一个已存在的互斥量 |
pthread_mutex_lock | 得到一个锁或阻塞 |
pthread_mutex_trylock | 尝试得到一个锁或失败 |
pthread_mutex_unlock | 释放一个锁 |
除互斥量以外,pthread 提供了另外一种同步机制:条件变量。
**互斥量在容许或阻塞对临界区的访问上是颇有用的,条件变量则容许线程因为一些未达到的条件而阻塞,绝大都分情況下这两种方法是一块儿使用的。**如今让咱们进一步地研究线程、互斥量、条件变量之间的关联。
再考虑一下生产者一消费者问题:一个线程将产品放在一个缓冲区内,而另外一个线程将它们取出。若是生产者发现缓冲区中没有空槽可使用了,它不得不阻塞起来直到有一个空槽可使用。生产者使用互斥量能够进行原子性检查,而不受其余线程干扰。可是当发现缓存区已经满了之后,生产者须要一种方法来阻塞本身并在之后被唤醒,这即是条件变量作的事了.
与条件变量相关的 pthread 调用以下:
线程调用 | 描 述 |
---|---|
pthread_condinit | 建立一个条件变量 |
pthread_cond_destroy | 撤销一个条件变量 |
pthread_cond_wait | 阻塞以等待一个信号 |
pthread_cond_signal | 向另外一个线程发信号来唤醒它 |
pthread_cond_broadcast | 向多个线程发信号来让它们所有唤醒 |
与条件变量相关的最重要的两个操做是 pthread_cond_wait 和 pthread_cond_signal,前者阻塞调用线程直到其余线程给它发信号(pthread_cond_signal 通知其余进程)。当有多个线程被阻塞在同一个信号时,可使用 pthread_cond_broadcast 调用。
条件变量与互斥量常常一块儿使用,这种模式用于让一个线程锁住一个互斥量,而后当它不能得到它期待的结果时等待一个条件变量。最后另外一个线程会向它发信号,使它能够继续执行。 pthread_cond_wait 原子性地调用并解锁它持有的互斥量,因为这个缘由,互斥量是参数之一。
#include <stdio.h>
#include <pthread.h> #define MAX 1000000000 pthread_mutex_t the_mutex; pthread_cond_t condc, condp; // 缓冲区 int buffer = 0; void producer(void *ptr){ int i; for (i =1; i <= MAX; i++) { // 互斥使用缓冲区 pthread_mutex_lck(&the_mutex); while (buffer != 0) { pthread_cond_wait(&condp, &the_mutex); } // 将数据放入缓冲区 buffer = i; // 唤醒消费者 pthread_cond_signal(&condc); // 释放缓冲区 pthread_mutex_unlock(&the_mutex); } pthread _exit(O); } void consumer(void *ptr) { int i; for (i = 1; i <= MAX; i++) { // 互斥使用缓冲区 pthread_mutex_lock(&the_mutex); while (buffer == 0) { pthread_cond_wait(&condc, &the_mutex); } // 从缓冲区中取出数据 buffer = 0; // 唤醒生产者 pthread_cond_signal(&condp); // 释放缓冲区 pthread_.mutex_unlock(&the_mutex); } pthread_exit(O); } int main(int argc, char** argv) { // 初始化 pthread_t pro, con; pthread_mutex_init(&the_mutex, 0); pthread_cond _init(&condc, 0); pthread_cond_init(&condp, 0); // 建立一个线程(pthread提供的方法) pthread_create(&con, 0, consumer, 0); pthread_create(&pro, 0, producer, 0); // 等待线程执行完成 pthread_join(pro, 0); pthread_join(con, 0); pthread_cond_destroy(&condc); pthread_cond_destroy(&condp); pthread_mutex_destroy(&the_mutex); } 复制代码
这个例子虽然简单,可是却说明了基本的机制。 同时使一个线程睡眠的语句应该总要检查这个条件,以保证线程在继续执行前知足条件,由于线程可能已经由于一个 UNIX 信号或其余缘由被唤醒。
有了信号量和互斥量以后,进程间通讯看起来就容易了,是这样吗?
固然不是,能够再看看信号量中的 producer(),若是将代码中的两个 down 操做交换下次序,这使得 mutex 的值在 empty 以前减 1,若是缓冲区彻底满了,生产者将阻塞,此时 mutex 为 0,
当消费者下一次访问缓冲区时,消费者也就被阻塞,这种状况叫作死锁。
void producer(void) {
int item; while (TRUE) { // 产生放在缓冲区中的一些数据 item = produce_item(); // 下面的代码交换了顺序 // 进入临界区 down(&mutex); // 将空槽数目減1 down(&empty); // 将新数据项放到缓冲区中 insert_item(item); // 离开临界区 up(&mutex); // 将满槽的数目加1 up(&full); } } 复制代码
这个问题告诉咱们使用信号量要很当心。
为了更易于编写正确的程序,Brinch Hansen(1973)和 Hoare(1974)提出了一种高级同步原语,称为管程(monitor)。 在下面的介绍中咱们会发现,他们两人提出的方案略有不一样。
一个管程是一个由过程、变量及数据结构等组成的一个集合,它们组成一个特殊的模块或软件包。
下面的代码是类 Pascal 语法,procedure 你能够认为是一个函数。
monitor example: interger i; condition c; procedure producer(); begin ... end; procedure consumer(); begin ... end; end monitor; 复制代码
管程有一个很重要的特性,即任一时刻管程中只能有一个活跃进程,这一特性使管程能有效地完成互斥。
管程是一个语言概念
管程是编程语言的组成部分,编译器知道它们的特殊性,所以能够采用与其余过程调用不一样的方法来处理对管程的调用。
当一个进程调用管程过程时,该过程当中的前几条指令将检査在管程中是否有其余的活跃进程。若是有,调用进程将被挂起,直到另外一个进程离开管程将其唤醒。若是没有活跃进程在使用管程,则该调用进程能够进入。
进入管程时的互斥由编译器负责,但一般的作法是用一个互斥量或信号量。由于是由编译器而非程序员来安排互斥,因此出错的可能性要小得多。在任一时刻,写管程的人无须关心编译器是如何实现互斥的。他只需知道将全部的临界区转换成管程过程便可,决不会有两个进程同时执行临界区中的代码。
管程仍然须要一种方法使得进程在没法继续运行时被阻塞,其解决办法就是条件变量,以及相关的两个操做:wait 和 signal.
当一个管程发现他没法运行时(好比生产者没法缓冲区满了),它会在某个条件变量执行 wait 操做,该操做致使调用进程自身阻塞,而且还将另外一个之前在管程外的进程调入管程,另外一个进程好比消费者能够唤醒生产者,经过对生产者正在等待的条件变量执行 signal。
为了不管程中同时有两个活跃进程,咱们须要一个规则来经过在 signal 以后怎么作。
Hoare 建议让唤起的新线程执行,而挂起另外一个线程。
Brinch Hansen 则建议执行 signal 的进程必须当即退出管程,即 signal 语句只能做为一个管程过程的最后一个语句。
第三种方法是让发信号者继续运行,而且只有在发信号者退出管程后,才容许等待的进程开始运行。而 java 使用的就是该种方案。
须要注意的是不管是信号量仍是管程在分布式系统中都是不起做用的,至于为何我相信你们内心确定有了答案。