George Cao于2020年02月23日 互斥锁 信号量 同步 多线程 条件变量 线程 并发

本文讨论的是多线程应用中最常见的并发控制方法之一。

就像我前一篇文章所阐述的,开发并发代码需要技巧的。 会遇到两个大问题:数据竞争,当一个写线程在修改内存数据的同时一个读线程正在从中读取数据和竞争条件,当2个或以上的线程以不可预知的顺序执行任务的时候会发生。幸运的是我们有一些办法来避免这类错误:这篇文章我们就来看一个最常用的办法:同步(synchronization)

本系列中的其他文章

什么是同步

同步是让2个或以上线程和平共处的技巧合集。更确切的说,同步能够帮你实现多线程程序中至少2个重要的特性:

  1. 原子性 - 如果你的代码包含多个线程操作共享数据的指令,不受控制的并发访问共享数据可触发数据竞争。包含此类指令的代码块称为关键区块。你要确保关键区块要原子执行:如前文所定义的,一个原子操作不能在细分为更小的操作了,因此当一个线程在执行原子代码块的时候,就不会受到其他线程的干扰;

  2. 有序性 - 有时候你需要2个或以上的线程按照可预测的顺序执行任务,或者限制访问某个资源的线程数。正常情况下你是不能控制这个的,这也可能是竞争条件发生的根本原因。有了同步之后,你就可以根据计划来编排多个线程的执行了。

同步是通过支持多线程的操作系统或者编程语言提供的同步原语(synchronization primitives)来实现的。然后你就可以在代码中使用同步原语来保证多线程不会触发数据竞争、竞争条件或者全部。

同步可以发生在硬件和软件,以及线程与操作系统进程之间。 这篇文章是关于软件线程同步:对应的硬件同步部分非常有趣,将会在后续的文章中介绍。

常见同步原语

最重要的同步原语是互斥锁,信号量和条件变量。这些关键字还没有官方的定义,所以在不同的书本或者实现中会有轻微的不同特征。

这3个同步原语是操作系统是原生支持的。例如Linux和macOS支持POSIX线程,也就是pthreads,能够让你可以用这一组函数开发安全的多线程应用。Windows则用C运行时代码库(CRT)提供自己的同步工具:概念上和POSIX多线程功能相似但是不同的命名。

除非你正在写非常底层的代码,通常你只要使用编程语言提供的同步原语就可以了。每个支持多线程的编程语言都提供了自己的同步原语工具箱,以及一些额外的线程处理功能。例如Java提供了java.util.concurrent包,现代C++有自己的线程库,C#提供System.Threading命名空间等等。当然所有这些功能和对象都是基于底层操作系统同步原语的。

除此之外还有其他同步工具,但是本文只关注上面提到的3个,因为他们是构建复杂系统的基础。让我们进一步分析。

互斥锁

互斥锁(mutualexclusion)是一个同步原语,是为了避免数据竞争而给关键区增加限制的保护机制。 互斥锁通过同时只允许一个线程访问关键区来保证原子性

严格来讲,互斥锁是应用中的一个全局对象,在多个线程之间共享,并且提供通常叫做加锁解锁的2个功能函数。一个即将要进入关键区域的线程通过加锁操作锁定这个互斥锁,结束后,也就是关键区域结束之后,同样的线程调用解锁操作来释放这个互斥锁。互斥锁非常重要的特性是:只有锁定这个互斥锁的线程才能解锁。

如果一个线程正在关键区,而另一个线程尝试锁定这个互斥锁,操作系统就让后面这个线程休眠,直到第一个线程任务结束并且释放了这个互斥锁。这样就只有1个线程可以访问关键区,任何其他线程都不能访问而且必须等待互斥锁的释放。基于这个原因,互斥锁也叫做锁机制。

你可以用互斥锁保护比如一个共享变量的并发读和写操作,也可以保护更大、更复杂的操作,同时只允许一个线程执行的,比如写日志文件或者修改数据库。无论如何,互斥锁的加锁/解锁操作总是和关键区的边界是匹配的。

互斥锁保护的是关键区域,也就是操作,而不是数据本身。如果在关键区域之外的代码也能读写数据,互斥锁就有失效的风险。所以所有对数据有读写的代码都需要作为关键区用互斥锁保护起来才能避免数据竞争。

递归互斥锁

许多常规的互斥锁实现中,一个线程两次加锁同一个互斥锁会引起错误。但是递归互斥锁(recursive mutex)允许此类操作:一个线程可以锁定一个递归互斥锁许多次而不需要先释放。尽管如此,其他线程只有等到第一个线程释放所有的递归互斥锁之后才能锁定这个锁。这个同步原语也叫做可重入互斥锁(reentrant mutex),这里的可重入性(reentrancy)是指在前一次调用结束之前可以多次调用同一个函数的能力。

递归互斥锁很难用而且容易出错。你必须记录哪个线程锁定了哪个互斥锁多少次,而且要保证一个线程完全释放这个互斥锁。不然的话就会导致互斥锁没能释放而引起讨厌的后果。大多数时候正常的互斥锁就够用了。

读写锁

正如我们从前一篇文章中知道的,多个线程可以安全的并发读一个共享资源,只要没有线程修改该共享资源。所以如果一些线程是“只读”模式的,还有必要锁定一个互斥锁?例如一个并发数据库被多个线程频繁读取,同时另一个线程偶尔写入更新。你当然需要一个互斥锁来保护读/写访问,但是大多数情况下仅仅为了读操作而锁定一个互斥锁,也同时阻碍了其他读线程正常执行。

读/写互斥锁 允许多线程并发读和单线程排他写共享资源。这个互斥锁可以被锁定为读模式或者写模式。为了修改资源,一个线程必须先获得排他写入锁。排他写入锁直到所有的读取锁全部释放之后才能获取。

信号量

信号量 是用来编排线程的同步原语:那个线程先启动,多少个线程可以访问一个资源等等。就像“红绿灯”调节交通一样,程序信号量规范多线程交互流程:基于这个原因,信号量也称为信号机制。他可以被看做互斥锁的进化,因为他能同时保证有序性原子性。尽管如此,接下来的几段中我讲告诉你为什么仅仅为了原子性而使用信号量不是一个好选择。

严格来讲,信号量是应用中的全局变量,多个线程间共享,还包含了一个计数器,通过2个函数管理:一个增加计数器,另一个减少计数器。历史上,这两个操作分别叫做P操作和V操作,现代信号量的实现使用更友好的名字比如获取释放

信号量控制共享资源的访问:计数器决定了并行访问共享资源的最大线程数。程序启动的时候,也就是信号量被初始化的时候,你根据自己的需要选择这个最大线程数。然后一个想访问共享资源的线程调用获取函数:

  • 如果计数器大于0就继续进行。计数器被立即减少1,然后当前线程可以开始操作了。结束后,当前线程调用释放函数,同时计数器加1.

  • 如果计数器等于0则此线程不能继续进行:其他线程已经占用了可以空间。当前线程被操作系统休眠,只有等到信号量的计数器再次大于0(也就是有线程完成任务后调用了释放函数)的时候才会被唤醒。

不像互斥锁,任何线程可以释放信号量,不仅仅是最先获取信号量的线程。

单个信号量可以用来限制同时访问共享资源的线程数:例如为了控制多线程数据库的连接数,这其中的每个线程是连接到你的服务器的用户触发的。

结合多个信号量一起,你就可以解决线程的有序性问题了:比如在浏览器中渲染网页的线程必须在通过互联网下载HTML文件的线程之后启动。线程A结束的时候会通知线程B,因此线程B可以被唤醒继续执行任务:这个也常被称为著名的生产者-消费者问题

二元信号量

如果信号量的计数器只允许取值0和1,则称之为二元信号量:也就是同时只允许一个线程访问共享资源。 等一下,这基本上就是互斥锁保护关键区的作用。你确实可以用二元信号量来实现互斥锁的行为。但是要时刻牢记以下2点:

  1. 互斥锁只能被加锁的线程解锁,但是信号量可以被任意线程释放。如果你仅仅需要一个锁机制的话,这会导致困惑和微妙的问题;

  2. 信号量是用来编排线程的信号机制,但是互斥锁是保护共享资源的锁机制。你不应改使用信号量来保护共享资源,也不应该将互斥锁用于信号机制:这样你的意图对你和你的代码读者会更明确。

条件变量

条件变量是另一个用来保证有序性的同步原语。他们是用来在不同线程之间发送唤醒信号的。条件变量往往配合互斥锁一起使用,单独使用条件变量没有意义。

严格来讲,条件变量是应用中的全局对象,多个线程之间共享,提供3个函数,分别叫做:waitnotify_onenotify_all, 外加一个传递已知互斥锁给他配合工作的机制(具体方法依实现而定)。

线程调用一个条件变量的wait操作会被操作系统休眠。然后其他的线程想要唤醒休眠线程的话就调用notify_one或者notify_allnotify_onenotify_all的不同之处是notify_one仅仅唤醒一个休眠线程,但是notify_all会唤醒所有因为调用了条件变量的等待操作而休眠的线程。条件变量内部使用互斥锁提供休眠/唤醒机制。

条件变量是仅靠互斥锁实现不了的在线程之间发送信号的强大机制。例如你也可以使用它解决生产者-消费者问题,线程A完成任务后产生一个信号,接着线程B就可以开始执行任务了。

常见的同步问题

本文所述的所有同步原语有共同之处:都会让线程休眠。基于这个原因,他们也被叫做阻塞机制。如果你想避免数据竞争或者竞争条件,阻塞机制是防止并发访问共享资源的好办法:休眠线程不会有任何害处。但是他能够触发不幸的副作用,我们来看看都有哪些。

死锁

死锁 发生在一个线程等待另一个线程持有的共享变量,而第二个线程在等待第一个线程持有的共享变量。这种情况通常在使用多个互斥锁的时候发生:2个线程在死循环中永久等待:线程A在等待线程B,线程B在等待线程A,而线程A又在等待线程B,如此往复。。。

饥饿线程

当线程没有得到足够的爱就进入饥饿模式:它永远卡在休眠模式等待访问共享资源,但是这个共享资源持续的给了其他线程。例如一个基于信号量的糟糕实现可能会忘记唤醒等待队列中的一些线程,这个可以通过给部分线程高优先级的方式实现。饥饿线程会永久等待而不能做任何有效的工作。

无效唤醒

这是一些操作系统中条件变量的具体实现方式带来的微妙问题。一个无效唤醒可能是线程没有收到条件变量的信号而被唤醒了。这也是多数同步原语中包含了检查唤醒信号是否真的来自线程正在等待的条件变量的方法的原因。

优先级反转

优先级反转 是一个执行高优先级任务的线程阻塞等待一个低优先级的线程释放资源,如互斥锁。例如输出音频到声卡的线程(高优先级)被显示界面的线程(低优先级)阻塞了,会导致扬声器严重的卡顿。

下一步

这些同步问题被研究很多年了,也有很多技术和架构方面的解决方法。严谨的设计和一些实际经验能很大程度上预防问题的发生。鉴于多线程程序的不确定性(非常难的)性质,也有人开发出来在并发代码中检测错误和潜在缺陷的有趣工具。就像Google的TSan或者Helgrind一样。

尽管如此,有时候你可能在多线程应用中采用不同的方法,完全去掉阻塞机制。这意味着进入非阻塞领域:这是一个非常底层的领域,线程不会被操作系统休眠,并发是通过原子操作无锁数据结构规范的。这是一个充满挑战的领域,并不总是有必要,但是它能够加速你的软件或者对他造成严重的破坏。不过这是下一篇文章的内容。

参考

本文译自https://www.internalpointers.com/post/introduction-thread-synchronization,英文读者可直接阅读原文。