思维导图:
共享数据的一致性:
包含多个线程的程序(以下简称多线程程序)多以共享数据作为线程之间传递数据的手段.由于一个进程所拥有的相当一部分虚拟内存地址都可以被该进程中的所有线程共享.所以这些共享数据大多数是以内存空间为载体的.如果两个线程同时读取同一块共享内存但获取到的数据却不同.程序很可能出现某种错误.共享数据的一致性往往代表着某种约定.只有在该约定成立的前提下.多线程程序的各个线程才能执行正确.如果操作共享数据的实际结果总是与我们约定的操作结果相符.就可以说该数据一致性得到了保证.而共享数据的一致性保证则是多线程程序中的各个控制流得以正确执行的前提.
保证共享数据一致性最简单和最彻底的方法就是使该数据称为一个一成不变的常量.例如.常量就是一个绝对不变的量.它不可能改变.也就不可能出现不一致的情况.因此.无论当前程序有多少个可能访问该常量的线程.都不需要采取任何措施.但是.把计数器变成常量是不现实的.一个可以并需要改变的计数器只能看作变量.需要通过额外的手段来保证被多个线程共享变量的一致性.才有了临界区的概念.
临界区只能被串行化访问或执行某个资源或代码段.也常称为串行区域.保证临界区最有效最佳的方式就是利用同步机制.在针对多线程程序同步机制中包含了很多同步方法.包括之前提到的原子操作和互斥量.还包括条件变量.
互斥量:
在同一时刻.只允许一个线程处于临界区之内的约束称为互斥.每个线程进入临界区之前.都必须先锁定某个对象.只有成功锁定对象的线程才会允许进入临界区.否则就会阻塞.这个对象称为互斥对象或互斥量.
由此可知.互斥量有两种可能的状态.即已锁定状态和未锁定状态.互斥量每次只能锁定一次.也就是说.处于锁定状态的互斥量不能再次锁定.除非它已解锁.否则任何线程都不能对它进行二次加锁.如果对一个已锁定的互斥量进行加锁.那么这个操作必定失败.成功锁定互斥量的线程会成为该互斥量的所有者.只有互斥量的所有者才能进行解锁.从这个角度讲.多个线程对同一个互斥量的争相锁定也可以看作是对互斥量的所有权的争夺.锁定可以看作是对互斥量的获取.解锁可以作是对互斥量的释放.
线程在离开临界区的时候.必须要对相应的互斥量进行解锁.如此一来.其他因想进入临界区而被阻塞的线程才会被唤醒并有机会再次尝试锁定该互斥量.在这些线程中.只有一个线程能够成功锁定该互斥量.注意.对同一个互斥量的锁定和解锁操作应该成对出现.
示例:
上图展示了两个线程共同使用一个计数器的场景.在这一过程中.使用互斥量作为线程间同步工具.
互斥量和计数器一样.也属于共享资源.互斥量必须能够被所有相关线程访问到.因此.代表互斥量的变量或常量一般不是局部的.还有.初始化互斥量的操作总是在任何 线程真正使用它之前.经过初始化的互斥量会处于未锁定状态.注意.如果多个线程的代码都包含了对同一个互斥量的初始化操作.那么必须保证互斥量只会初始化一次.
在初始化创建计数器以后.线程A开始先使用计数器.它会获取并更新计数器的值.线程A欲锁定互斥量.互斥量又处于未锁定状态.因此锁定操作会成功.线程A获取计数器值但未更新它的时候.线程B得到运行时机并准备获取计数器.使用之前.会先尝试锁定互斥量.互斥量已处于锁定状态.不能重复锁定.会失败.线程B进入阻塞睡眠状态.直到A解锁互斥量.线程B才会唤醒然后退出阻塞.
上面的示例忽略了一个细节.线程执行完筛选数据之后要把每次筛选的数据写入集合文件.并在写入以后关闭文件.因此.这里有必要对写入文件操作也进行同步.避免文件内容发生混乱.
从上面可以看到互斥量1和互斥量2所起到的作用.线程B欲进入临界区2时.由于线程A在临界区2中.所以线程B进入睡眠状态.等线程A离开后.线程B被唤醒进入临界区2.
一般情况下.应该尽量少用互斥量.每个互斥量保护的临界区应该在合理的范围内并尽量大.但是.如果发现多个线程会频繁出入某个较大的临界区.并且它们之间经常发生访问冲突.就应该把这个较大的临界区分成若干个较小的临界区.并且使用不同的互斥量进行保护.此举的意义是让等待进入同一个临界区的线程数变少.从而降低被阻塞的概率.并减少它们被迫进入睡眠状态的时间.从一定程度上提高了程序的整体性能.
还需要特别注意.尽量不要让不同的互斥量所保护的临界区重叠.这样回大大增加死锁发生的概率.如下图所示.
线程A和线程B在一开始就分别锁定了一个互斥量1和互斥量2.在未释放自己所持的互斥量的情况下.就想去锁定另一个互斥量.导致了死锁.
在这种情况下.有两种通用的解决方法.其中一个方法需要用到操作系统提供的线程库功能.叫做"试退定-回退".核心思想是.如果在执行一个代码块的时候.需要先后(顺序不定)锁定两个互斥量.那么在成功锁定其中一个互斥量后应该使用试锁定的方法来锁定另一个互斥量.如果试锁定第二个互斥量失败.就把已锁定的第一个互斥量解锁.并重新对这两个互斥量进行锁定和试锁定.如果需要锁定的互斥量多于两个.那么总是先锁定其中一个.然后按照上面的方式试锁定其他互斥量.必要时进行回退..这里的试锁定代表的是操作系统的线程库提供的一个函数.它会尝试对一个互斥量进行锁定和试锁定.若锁定失败.则函数会返回一个错误码.而不是阻塞在那里.
另一种通用方式更加廉价.它不需要用到更多的线程库函数.也不会像第一种方式那样的复杂.称作固定顺序锁定.顾名思义.它的思路是在需要先后对多个互斥量进行锁定的场景下.总以固定不变的顺序锁定它们.
条件变量:
与互斥变量不同.条件变量的作用并不是保证在同一时刻仅有一个线程访问某一个共享数据.而是在对应的共享数据状态发生变化时.通知其他因此阻塞的线程.条件变量总是与互斥量组合使用.当线程成功锁定互斥量并访问到共享数据时.共享数据的状态不一定满足它的要求.
假设有一个容量有限的数据块队列和若干个会操作该队列的线程.可以把数据块想象成具有明显边界的字节序列.其中一些线程会产生数据库并把它们添加到数据库队列中.而另一些线程会消费数据块.并把它们从数据块队列中删除.这是一个经典的生产者者和消费者问题.
因为有多个线程并发访问这个数据块队列.会把向数据块队列添加数据块的操作(简称添加操作)和从数据块队列中获取数据块并从队列删除的操作(简称获取操作)都置于临界区中.并用同一个互斥量保护.执行添加操作的线程(简称为生产者线程)未完成添加操作之前.其他生产者线程和执行获取操作的线程(简称消费者线程)都无法进行相应的操作.同时也能保证.一个数据块只会被某一个消费者线程取走.即使有了互斥量保护.也会发生以下两种情况.
第一种情况是生产者线程获取互斥量.却发现数据块队列已满.无法添加新的数据块.这时.生产者线程可能会在临界区内等待.直到数据块队列有空余空间以容纳新的数据块.这里的等待行为往往是通过循环判断数据队列的已满状态来实现的.一旦判断结果为假.循环就会立即结束并执行数据块的添加操作.
把锁定和解锁互斥量的操作与数据块添加操作一起放到了循环体里面.前面造成的死锁问题解决了.无论队列是否已满 添加操作是否可以进行.互斥量都会被解锁.这个改进流程还是存在缺陷.如果队列长时间处于已满状态.这里的循环会执行很多次.其中包括对互斥量的那两个操作.这样的循环无疑会造成CPU的浪费资源.
第二种情况是消费者线程得到互斥量.但发现数据块队列为空.这时.消费者不得不也循环检查队列状态.并在队列中存在数据块时再尝试获取它.流程如下.
与互斥量相同.使用条件变量前.必须创建和初始化它.同样.条件变量的初始化必须要保证唯一性.另外.条件变量真正使用前.还必须要与某个互斥量进行绑定.操作有如下三种.
等待通知:
它的意思是阻塞当前线程.直至收到该条件变量发来的通知.
单发通知:
它的意思是让该条件变量向至少一个正在等待它的通知的线程发送通知.以表示某个共享数据状态已经改变.
广播通知:
它的意思是让条件变量给正在等待它通知的所有线程发送通知.以表示某个共享数据的状态已改变.
实际上等待通知的操作并不是简单的阻塞线程.在执行该操作的时.会先解锁与该条件变量绑定在一起的那个互斥量.然后再使当前线程阻塞.这里隐藏两个细节.
第一个细节是.只有在当前的共享数据状态不满足的条件下.才执行等待通知操作.而检查共享数据的状态也需要受到互斥量的保护.换句话说.检查共享数据状态和等待通知操作都需要在相应的临界区内进行.因此.等待通知操作中包含的解锁互斥量的那个步骤不会造成任何问题.它没有违反互斥量的基本使用原则.
第二个细节与等待通知操作中的包含解锁互斥量步骤的原因有关.如果等待通知操作在阻塞当前线程之前不对互斥量进行解锁.那么其他线程无法进入相应的临界区.如果当前线程因等待数据状态的改变而阻塞.而其他线程也因互斥量的阻挡而无法改变共享数据状态.那么会形成死锁.等待通知操作所包含的解锁互斥量和阻塞当前线程的步骤共同形成了一个原子操作.在等待通知操作使当前线程阻塞之前.任何线程都无法锁定相应的互斥量.如下图.
上面的情况如果有多个生产线程接收到该变量的通知.如果只有一个生产线程被唤醒并直接执行添加操作.那么就有可能使这个操作失败.因为有可能其他生产者线程抢先向队列添加了数据块.致使该队列又处于已满的状态.如此一来.那个生产者线程可能需要再次尝试添加数据块.如果再次尝试.为了保证成功率.就必然还需要检查队列状态.这样做的效率很低.因为线程始终会执行成功率低下的添加操作.如果不再次尝试.就等于甘愿承受数据块添加操作失败.无论是否再次尝试.都不是正确且高效的方案.本质在于.这里错过了再次检查队列状态的最佳时机.如果在执行添加操作之前再次检查队列的状态.并保证仅在条件满足时才执行添加操作.就可以保证添加操作成功.流程图如下.
这样的话.即使被其他线程抢先了一步.当前线程仍然可以再次利用等待通知操作重新等待操作时机.
注:在有些多CPU计算机系统中.即使没有接受到变量通知.线程也有可能被唤醒.
条件变量的单发通知和广播通知这两个操作的作用都是向因执行相同条件变量的等待通知操作而被阻塞的线程(下面简称等待线程)发送通知.不同的是.前者只保证至少唤醒一个等待线程.而后者则必然会唤醒所有等待线程.这就决定了两项操作的适用场景.如果明知道等待线程都在等待共享数据的同一个状态.并且在某个等待线程被唤醒并执行相应操作之后.共享数据的状态就不满足等待线程的条件了.广播操作是低效的.因此只要通知一个生产者就可以.修改后流程图如下.
在确认获取操作执行完成后.添加了执行执行条件变量的通知操作.意味着每当队列中的一个数据块消费后.相应条件变量的单发通知操作都会执行一次.
注:条件变量的通知具有即时性.通知只负责向等待线程发送一个信号已告知共享数据的状态发生了某种变化.而不会存储相关信息.再通知被发送的时候.如果没有任何线程正在等待此条件变量的通知.那么该通知就会被直接丢弃.
等待通知操作在执行的时候会先解锁互斥量在阻塞当前线程.但是由于这两个步骤组成了一个原子操作.所以当前线程阻塞之前.其它线程无法锁定该互斥量.这里依然以生产者线程中执行的等待通知操作为例来介绍.在生产者线程执行等待通知操作的过程中.消费者线程无法从队列里获取数据块.并且也无法执行之后的单发通知操作.如果双方的操作同时发起.那么消费者的线程执行操作的时间势必会推后一点.如果两个步骤不共同形成原子操作.那么在生产者执行等待通知操作的过程中(已解锁互斥量变量.但还未阻塞当前线程).虽然同一个条件变量发送通知可能会传递给它,但是它无法做出任何响应.原因是生产者线程还没有为接收通知做好准备.它此时还未进入睡眠状态并等待开始接受通知.
还存在另外一种对称情况.那就是消费者线程需要利用条件变量等待空队列的非空状态的出现并从队列那里获取新的数据块.同时发送者线程也应该在每次数据块添加操作完成后.让该条件变量发送通知给正在等待的消费者线程.
为了向特定的等待线程告知已处于非满状态或非空状态.需要分别创建两个条件变量.为了加以区分..用来关注队列非满状态的的条件变量称为条件变量1.用来关注队列非空状态的条件变量称为条件变量2.由于在生产者线程和消费者线程中对数据队列的操作都由同一个互斥量保护.所以条件变量1和条件变量2也必须与这个互斥量进行绑定.
语雀地址www.yuque.com/itbosunmian…?
《Go.》 密码:xbkk 欢迎大家访问.提意见.
避不开思念的锋芒.
如果大家喜欢我的分享的话.可以关注我的微信公众号 念何架构之路