XiaoboTalk

多线程二 线程同步


译者:Xiaobo 原文链接:introduction-thread-synchronization 推荐:之前读《程序员的自我修养》一书中有关于多线程的讲解非常好;但最近读到这几篇文章对于多线程的讲解,个人认为比前者(书)更加的清晰、易懂、全面。每篇文章的内容安排也很合理,非常感谢作者TRIANGLES和他优秀的文章,这里是他的个人站点

相关系列译文


  1. 「译文」带你慢慢的了解多线程
  1. 「译文」线程同步 (本篇)
  1. 「译文」原子操作-无锁多线程编程
  1. 「译文」内存重排对无锁多线程的影响

译文正文部分


前一篇文章里提到,写并发代码,最重要的是解决数据竞争竞争条件两个问题。本篇介绍一种常见的解决并发问题的方法:同步

什么是同步


同步是一种确保多线程正常工作的打包方案。更具体的说,同步主要提供了两个重要功能:
1、原子性
如果你的代码在多线程中存在数据共享,不受限制的并发访问有可能造成数据竞争。多线程共同访问共享数据的代码就称为 “临界区”(critical section)。为了数据安全,就需要确保临界区代码的执行具有原子性:前一篇提到,原子操作是不能被分割的最小操作单元,也就是当一个线程在执行一个原子操作的时,其他线程无法介入。
2、有序性
有时,你想让多线程按照可预测的顺序执行,或者限制访问特定资源的线程数量。一般来讲,由于多线程“条件竞争”的因素,执行顺序是不可预测的。但同步可以帮助你按照预期的规划执行多线程任务。
支持多线程的操作系统或者编程语言都会提供实现了同步的特定对象,这些特定对象被称为“同步原语”(synchronization primitives)。使用这些同步原语,可以避免多线程环境下的“数据竞争”和“竞争条件”发生。
同步既可以发生在硬件层面,也可以发生在软件层面。同样,也可以发生在线程和系统进程之间。本文将主要讨论软件层面的多线程同步。

常见的同步原语


最重要的同步原语有三种:
1、互斥锁 (mutexes)
2、信号量 (semaphores)
3、条件变量 (condition variables)
这些术语并没有官方的定义,所以针对每种原语在不同的语境和实现中都会略有不同。操作系统对这些都有天然的支持。比如 Linux 和 macOS 都提供的 POSIX threads,还有广为人知的 pthreads,这些工具都有一系列的函数来帮助开发者写出多线程安全的代码。Windows 上也有它自己的同步工具库:CRT(C Run-Time Libraries) ,概念上和 POSIX 类似。
除非你要实现非常底层代码,通常你应该选择编程语言提供的函数库。每种编程语言都有一套自己的工具库来处理多线程。JAVA 的java.util.concurrent包,C++ 也有自己的库,C# 的 System.Threading等等。当然这些实现都是基于底层操作系统的原语。
还有许多其他的同步工具。本文主要讨论上文提到的三个工具。

互斥锁 mutexs


为了防止发生的“数据竞争”,互斥锁对临界区做了限制。互斥锁通过确保一次只能有一个线程访问临界区,来实现原子性。
技术手段上,互斥锁在应用中是一个全局对象,多个线程共用这同一个全局对象,互斥锁通常会提供两个方法lockunlock。当一个线程进入临界区,就调用lock来锁住互斥量,操作完临界区后,同一个线程再调用 unlock 解锁。其中的重点是:lockunlock 必须由同一个线程来操作。
如果另一个线程进来,试图去操作一个已经 lock 的互斥锁,操作系统会让该线程进入“睡眠状态”,直到前边一个线程完成了操作,并调用 unlock 释放了 互斥锁。也就是一次只能有一个线程操作临界区。因为这个原因,互斥量也被称为“锁机制”。
当发生一个共享变量被并发读写的时候,你可以用互斥锁(mutex)简单的保护一下。

递归互斥锁 (Recursive mutexes)


一个常规的互斥锁的实现中都有个机制,一个线程如果连续两次调用了 lock 就会引发错误。但递归互斥锁不会,相反,它允许一个线程多次递归的调用 lock,每次 lock 前,不调用 unlock 也可以。但也要求必须是同一个线程。也就是说,其他线程不可以 lock 一个已经被递归 lock 多次的递归锁,直到前一个线程调用了同等次数的 unlock 。这种同步原语也被称为可可重入互斥锁可重入指不需要等待前一次的函数调用完成,就能再次调用该函数的能力。
递归互斥锁比较难掌握,并且容易出错。你必须时刻关注每个线程调用了多少次 lock,以确保同一个线程调用相同次数的 unlock,否则就会出错。通常来讲,使用常规的互斥锁就足够了。

读写互斥锁


我们知道,多线程可以并发的读取共享资源,而不会带来任何问题,只要不修改共享资源即可。所以如果你的线程都是 “只读” 操作,为什么还要用互斥锁呢?考虑这样一种情况,如果一个数据库频繁的被多线程读,只有很少的情况被另一个线程写入并更新。这时候你依然需要互斥锁来处理这种 “读/写” 访问。但是大多数情况你锁住它只是在做 “读取” 操作,这样是安全了,但同时也造成了其他的 “读取” 线程无法工作。
一个 “读/写” 互斥锁 会允许多线程同时读取,而写入线程则是独占式工作。它能够根据读或写的模式进行加锁。想要修改资源的时候,线程必须先获得专有的写入锁,写入锁只有当其他所有的读取锁都被释后,才被允许访问资源。

信号量


信号量也是编排线程的一种同步原语:它可以限制同时访问资源的线程数量。就像交通信号灯🚥一样,用信号规范多线程。所以信号量也被称为 “信号机制”。也可以被认为是互斥锁的进化版本,因为它也能保证原子性和有序性。然而,接下来几段我将告诉你,使用信号量保证原子性不是一个好主意。
技术实现上,信号量应用程序中的一个全局对象,被多个线程共享。它包含了一个计数器,和两个函数:一个是增加计数的函数,另一个是减少计数的函数。历史上,这两个函数分别被称为PV,现在的普遍的实现这两个函数分别叫 acquirerelease
计数大小决定了同时访问共享资源的线程数量。程序开始,你根据自己的需要设定信号量的计数大小。然后如果有线程想要访问共享资源就需要调用 acquire 函数:
  • 如果计数 > 0,那么可以新来的线程可以继续申请访问,当计数被用正确的方式减少(通常是调用 acquire 让计数 -1),那么当前的线程开始它的工作。线程工作完,调用 release 让计数 +1
  • 如果计数 = 0,表示信号量设定的可用线程数被占用完,那么任何线程都不能继续申请访问资源。当前的申请的线程被操作系统调整为睡眠模式,直到信号量的计数 > 0 的时候,再次唤醒睡眠线程(也就是有线程调用了 release 来完成它的工作)。
和互斥锁不同的是,任何线程都可以释放信号量(调用 release),不仅仅是最开始调用 acquire 的线程。也就是说一个线程可以在没有调用过 acquire 的时候,就直接调用 release。
信号量通常被用来限制访问共享资源的线程数量:比如设置连接数据库的最大线程数,每个线程可能是被连接服务器的用户触发。
通过将多个信号量组合在一起,可以解决线程排序问题:例如,在浏览器中呈现网页的线程必须在从Internet 下载 HTML 文件的线程之后启动。线程A完成后将通知线程B,以便B可以醒来并继续其工作:这也被称为著名的生产者-消费者问题。

二元信号量


如果把一个信号量的计数限定在 0 和 1 之间,那么这个信号量就被称为二元信号量:同一时间只有一个线程可以访问共享资源。你可以能觉得这很像互斥锁保护临界区的工作方式。更有可能,你想完全通过二元信号量代替互斥锁。然后有两个重要的点需要考虑:
1、互斥锁只能被调用了 lock 的线程解锁。而信号量则不是,信号量可以被任何线程释放,如果你想通过二元信号量实现锁机制,很有可能导致一个难以发觉的微妙 bug。
2、信号量通过信号机制编排线程,而互斥锁是通过锁机制来保护共享资源。所以你不应该用信号量来保护共享资源,也不应该用互斥锁来实现信号机制,这样能让你的意图会更加清晰,代码更加易读。

条件变量


条件变量是另一种同步机制,被设计用来确保线程执行的有序性。常被用在不同的线程间互相唤醒。条件变量总是与互斥锁一起使用;单独使用它没有意义。
技术实现上,条件量变量是应用程序中的一个全局对象,被多个线程共享。通常提供三个函数:waitnotify_one, notify_all,外加一种机制:传递变量到与它一起工作的已经存在的互斥锁上。
线程调用条件变量的 wait 函数后,系统会让该线程进入睡眠状态。然后另一个线程通过调用 notify_one 或者 notify_all 来唤醒它。notify_one 只唤醒一个线程,notify_all 会唤醒所有在条件变量上等待的线程。睡眠和唤醒机制是由内部的互斥锁提供的。
条件变量具有能够在线程间发送信号的强大机制,结合互斥锁,能完成一些单独使用其中之一(条件变量或者互斥锁)无法完成的工作。比如消费者-生产者问题,通过条件变量和互斥锁结合使用也可以解决。线程 A 发送任务完成的信号,然后线程 B 开始工作。

同步的常见问题


本文中提到的所有同步原语有个共同点:它们都会让线程进入睡眠状态。所以它们也都被称为阻塞机制。阻塞机制可以在并发状态下很好的保护共享资源,从而避免数据竞争和线程竞争。睡眠状态的线程不会带来什么危害。但它有可能触发一些副作用:

死锁 Deadlock

当一个线程处于等待的另一个线程释放共享资源时,刚好另一个线程也在等待当前线程释放共享资源,此时就会发生死锁。这种情况大多发生在多个互斥锁一起工作的时候:两个线程都在一个无线循环中永久等待,A 等 B,B 等 A,A 等 B ……

线程饿死 Starvation

线程饿死表示,该线程卡在访问共享资源的无限等待中,该线程不能得到操作系统足够的喜爱,操作系统总是把共享资源分配给其他线程。例如一个设计糟糕的信号机制算法,可能忘记了去唤醒多个等待线程中的某个线程,优先权只分配给其他线程。处于饥饿状态的线程将永远等待下去,而不做任何事情。

伪唤醒 Spurious wake-ups

这个微妙的问题来自某些操作系统上对条件变量的具体实现。指的是,一个线程在没有条件变量信号发出的情况下被唤醒。这也是为什么大多数同步原语都要有一种检查机制:检查线程是否是被它所等待的条件变量唤醒。

优先级反转 Priority inversion

优先级反转发生在:一个执行高优先级任务的线程,被一个低优先级的线程阻塞,高优先级的线程不得不等待低优先级线程释放资源后再执行。例如一个给声卡输出音频的线程(高优先级)被一个显示界面的线程(低优先级)阻塞,从而导致严重的扬声器故障。
本篇正文完

下篇预告


上述所有同步机制引发的问题已经被研究了很多年,有很多解决方案。用心设计和一些经验能帮助预防这些问题。同时,由于多线程应用本来的不确定性,人们发明了很多有趣的工具,去检测并发代码中的错误和潜在的陷阱。例如 Google’s TSan 或者 Helgrind
然而,有时你需要通过其他路径来避免在多线程中使用阻塞机制。这就需要进入非阻塞的领域:非常底层的环境,线程在这里将永远不会进入睡眠状态,通过原子原语无锁数据结构处理并发。这些是很有挑战性的技术,不总是必须使用,这些技术有可能极大的加快你的软件运行速度,(使用不当)也可能摧毁你的软件,这是下一节要讲的故事……
(译文完)