查看原文
其他

线程同步:多线程应用程序中最流行的并发控制方式之一

尤慕 背井 2019-11-27

文章译自 Introduction to thread synchronization, https://www.internalpointers.com/post/introduction-thread-synchronization点击阅读原文可查看英文原文。如果喜欢,还请点个「在看」并帮忙扩散!




系列其它文章:


  • 多线程简述 —— 一步一步走进并发世界(已翻译)。

  • 原子化且lock-free的多线程操作 —— 更底层的线程同步(待翻译)。


正如我之前对多线程的介绍所描述的那样,编写并发代码很棘手。可能会出现两个大的问题:数据竞争,当一个写线程修改内存,而一个读线程正在读取内存时;竞态条件,当两个或更多线程以不可预知的顺序执行任务时。幸运的是,有几种方法可以防止这些错误:本文将介绍最常见的方法 —— 同步(synchronization)


什么是synchronization?


synchronization 是一套确保两个或多个线程正常运行的技巧。更具体地说,synchronization 将帮助你在多线程程序中实现至少两个重要功能:


  • atomicity(原子性) —— 如果代码包含对跨多个线程共享的数据进行操作的指令,则对该数据的不受控制的并发访问可能会触发数据竞争。包含这些指令的代码段称为 critical section(关键部分)。你要确保关键部分是原子操作的:正如前一篇所定义的,原子操作不能被分解成更小的部分,这样,当一个线程执行它时,其他线程就不能通过;

  • ordering(顺序) —— 有时你希望两个或多个线程以可预测的顺序执行其任务,或者限制可以访问特定资源的线程数。一般情况下你无法控制这些事情,这也是造成竞态条件的根本原因。通过synchronization,您可以编排线程,让它们按计划执行。


synchronization是通过操作系统或任何支持线程的编程语言所提供的称为 synchronization primitives(同步原语) 的特殊对象来实现的。你在代码中使用这些同步原语,以确保线程不会触发数据竞争、竞态条件或同时触发。


synchronization可以发生在硬件和软件,以及线程和操作系统进程之间。本文是关于软件线程的同步:物理硬件和进程同步是很有趣的主题,在以后的文章中会论述。


通用的同步原语


最重要的同步原语是 mutexes(互斥锁)semaphores(信号量) 以及 condition variables(条件变量)。这些术语没有官方定义,因此不同的文章和实现给这些原语夹带上了稍微不同的特征。


操作系统原生提供了这些工具。例如,Linux 和 macOS 支持POSIX线程(也称为pthreads),这是一组允许你编写安全的多线程应用程序的函数。Windows在C运行时库(CRT)中有自己的同步工具:概念上类似于POSIX线程函数,但名称不同。


除非你正在编写非常底层的代码,否则你通常会希望使用所选编程语言附带的同步原语。每种处理多线程的编程语言都有自己的同步原语工具箱,以及其他处理线程的函数。例如JAVA提供了 java.util.concurrent 并发包,现代C++有自己的 thread 线程库,C#提供了 System.Threading 名称空间等等。当然,所有这些函数和对象都基于底层操作系统原语。


Mutexes 互斥锁


互斥锁(mutual exclusion)是一种同步原语,它在关键部分周围设置限制,以防止数据竞争。互斥锁通过确保一次只有一个线程访问关键部分来保证原子性。


从技术上讲,互斥锁是应用程序中的一个全局对象,跨多个线程共享,它提供两个通常称为 lockunlock 的函数。即将进入关键部分的线程调用 lock 来锁定互斥锁;当它完成时,即当关键部分结束时,相同的线程调用 unlock 来解锁互斥锁。互斥锁的重要特性是:只有锁定互斥锁的线程才允许在以后解锁互斥锁。


如果另一个线程跳入并试图锁定已经锁定的互斥锁,则操作系统会将其置于睡眠状态,直到第一个线程完成其任务并解锁互斥锁。这样,只有一个线程可以访问关键部分;任何其他线程都被排除在外,并且必须等待解锁。因此互斥锁也称为 locking mechanism(锁定机制)


你可以使用互斥锁来保护简单的操作,如对共享变量的并发读写;也可以处理一次只能由一个线程执行的更大、更复杂的操作,如写入日志文件或修改数据库。无论如何,互斥锁的 lock/unlock 操作总是匹配关键部分的边界。


Recursive mutexes 递归互斥锁


在任何常规互斥锁的实现中,一个互斥锁被一个线程同时锁定2次都将导致错误。但递归互斥锁允许这样做:线程可以多次锁定递归互斥锁,而不必先解除锁定。但是,在第一个线程持有的所有锁都被释放之前,没有其他线程可以锁定该递归互斥锁。此同步原语也称为 reentrant mutex(可重入互斥锁),其中可重入是指在前一次调用结束之前多次调用函数(即再次调用函数)的能力。


递归互斥锁很难使用,而且容易出错。你必须跟踪哪个线程锁定了互斥锁多少次,并确保同一个线程完全解锁互斥锁。如果不这样做,就会留下锁着的互斥锁,带来恶劣的后果。大多数时候,一个普通的互斥锁就已经足够了。


Reader/Writer Mutexes 读写锁


正如我们在上一篇中所知道的,只要不修改共享资源,多个线程是可以同时从共享资源中读取数据的,它不会造成危害。所以,如果你的一些线程是在“只读”模式下运行的,那么为什么还要锁互斥锁呢?例如,考虑一个并发数据库,该数据库经常被许多线程读取,而另一个线程只是偶尔写入更新。你当然需要一个互斥锁来保护读/写访问,但大多数情况下,你的锁行为仅用于读操作,结果却阻止了其他读线程执行工作。


读写锁允许多个线程进行并发读取,并允许单个线程对共享资源进行独占写入。它有读模式的锁和写模式的锁。要修改资源,线程必须首先获取独占的写入锁(write lock)。在释放所有读锁(read lock)之前,不允许使用独占的写入锁。


Semaphores 信号量


信号量是用于编排线程的同步原语:它决定哪个线程先启动、多少个线程可以访问特定的资源,等等。就像街道信号量控制交通流量一样,编程信号量控制多线程流向:因此,信号量也被称为 signaling mechanism(信号机制)。它可以被视为互斥锁的一种进化,因为它同时保证了有序性和原子性。不过,在下面几个段落中,我将向你展示为什么将信号量仅用于保证原子性不是一个好主意。


从技术上讲,信号量是应用程序中的一个全局对象,在多个线程之间共享,它包含一个由两个函数管理的 numeric counter(数值计数器):一个用于增加计数,另一个减少计数。历史上它们被称为P和V,现代实现则用了更友好的名字(如acquirerelease)。


信号量控制对共享资源的访问:计数器确定可以同时访问它的最大线程数。在程序开始时,当信号量初始化时,你可以根据需要选择该数值。然后,要访问共享资源的线程调用 acquire:


  • 如果计数器大于零,则线程可以继续。计数器立即减少一个,然后当前线程开始执行其任务。完成后,它调用release,然后将计数器增加一个。

  • 如果计数器等于零,则线程无法继续:其他线程已填满可用空间。操作系统将当前线程置于睡眠状态,并在信号量计数器再次大于零时(即任何其他线程在完成其任务后调用release)唤醒休眠线程。


与互斥锁不同,任何线程都可以释放一个信号量,而不仅仅是最初获取它的线程。


单个信号量用于限制访问共享资源的线程数:例如,用于限制多线程数据库连接数,其中每个线程由连接到服务器的人触发。


通过将多个信号量组合在一起,可以解决线程顺序问题:例如,在浏览器中呈现网页的线程必须在从Internet下载HTML文件的线程之后启动。线程A完成后会通知线程B,以便B能够醒来并继续其工作:这也被称为著名的生产者-消费者问题。


Binary semaphores 二元信号量


计数器的值限制为0和1的信号量称为二元信号量:一次只有一个线程可以访问共享资源。等等:这基本上可以充当保护关键部分的互斥锁了!实际上,你可以使用二元信号量模拟互斥锁行为。不过,有两点需要牢记:


  • 互斥锁只能由先前锁定它的线程解锁,而信号量可以从任何其他线程释放。如果你想要的只是一个锁定机制,这可能会导致混乱和难以发现的错误;

  • 信号量是协调线程的信号机制,而互斥锁是保护共享资源的锁定机制。你不应该使用信号量来保护共享资源,也不应该使用互斥锁来发出信号:正确地使用它们,你和任何阅读你的代码的人才能清晰地理解你的意图。


Condition variables 条件变量


条件变量是另一个为排序而设计的同步原语。它们用于跨不同线程发送唤醒信号。条件变量总是与互斥锁一起使用;单独使用它是没有意义的。


从技术上讲,条件变量是应用程序中的一个全局对象,它在多个线程之间共享,它提供了通常称为 waitnotify_onenotify_all 的三个函数,再加上一个传递现有互斥锁的机制(确切的方法取决于具体实现)。


线程调用条件变量的 wait 方法后,会被操作系统置于睡眠状态。然后,另一个想要唤醒它的线程调用 notify_one 或 notify_all 。不同之处在于 notify_one 只解冻一个线程,而 notify_all 将唤醒调用条件变量 wait 之后休眠的所有线程。互斥锁在内部用于提供睡眠/唤醒机制。


条件变量是一种强大的机制,可以在单独使用互斥锁无法实现的线程之间发送信号。例如,你同样可以使用它们来解决生产者-消费者问题,即线程A完成后发出信号,以便线程B可以开始其工作。


Common problems in synchronization 同步化引发的常见问题


本文中描述的所有同步原语都有一些共同点:它们将线程置于休眠状态。因此,它们也被称为blocking mechanisms(阻塞机制)。如果要避免数据竞争或竞态条件,阻塞机制是防止并发访问共享资源的好方法:毕竟休眠的线程不会造成损害。但它也会引起一些副作用。让我们快速看一下。


Deadlock 死锁


当一个线程正在等待另一个线程持有的共享变量,而第二个线程也在等待第一个线程持有的共享变量时,就会发生死锁。这通常发生在处理多个互斥锁时:这两个线程永远在无限循环中等待:线程A等待线程B等待线程A等待线程B...


Starvation 饥饿


当一个线程得不到足够的"爱"时,它会进入饥饿模式:它会无限期地停留在睡眠状态,同时等待对共享资源的访问权,而共享资源却不断地被给予其他线程。例如,一个设计糟糕的基于信号量的算法可能会忘记唤醒处于等待中的多个线程中的某一个,因为它只将优先级给了其它的线程。饥饿线程将永远等待,而没法做任何有用的工作。


Spurious wake-ups 虚假唤醒


这是一个微妙的问题,它来自于条件变量在某些操作系统中的实际实现方式。在虚假唤醒中,即使没有通过条件变量发出信号,线程也会被唤醒。这就是为什么大多数同步原语还包括一种检查唤醒信号是否真的来自线程正在等待的条件变量的方法。


Priority inversion 优先级倒置


当执行高优先级任务的线程,因为等待低优先级线程释放资源(如互斥锁)而发生阻塞时,会发生优先级倒置。例如,当向声卡输出音频的线程(高优先级)被显示界面的线程(低优先级)阻塞时,会让人以为扬声器出现了故障。


What's next 下一篇写什么


所有这些同步问题都已经被研究了多年,有很多解决方案,包括技术上和架构上。认真的设计和既有的经验对预防这些问题有很大帮助。此外,考虑到多线程应用程序的不确定性(即,极其困难)特质,人们也开发出了有趣的工具来检测并发代码中的错误和潜在陷阱。像谷歌的 TSan 或 Helgrind 这样的项目就是其中的一小部分。


但是,有时你会希望采用不同的路子,想在多线程应用程序中消除任何阻塞机制。这意味着进入非阻塞(non-blocking)领域:一个非常底层的领域,在那里,操作系统永远不会让线程进入睡眠状态,并发性通过 atomic primitives(原子原语)lock-free(无锁)数据结构进行调节。这是一个具有挑战性的领域,它并非总是必要的。它既可以提升你的软件性能,也可能对它造成严重破坏。但这是下一篇的故事了...





参考:

Wikipedia - Synchronization (computer science)

Wikipedia - Reentrant mutex

Wikipedia - Reentrancy (computing)

Wikipedia - Semaphore (programming)

Wikipedia - Spurious Wakeup

.. 文章参考了很多内容,点击阅读原文查看全部


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存