并发控制之线程同步

这是多线程、并发控制系列文章第二篇,本文内容主要来自Introduction to thread synchronization,并做了部分补充。

  1. 多线程简述
  2. 并发控制之线程同步
  3. 并发控制之无锁编程

上一篇多线程简述提到编写并发代码很棘手,可能出现以下两个问题:

  • 数据争用 Data Race:一个线程修改数据时,另一个线程正在读取数据。如果写入还没有完成,就会读取到损坏的数据。
  • 竞争条件 Race Condition:系统或进程的输出依赖不受控制的事件出现顺序或出现时机,而实际上应按照一定顺序执行,这一点比 data race 更微妙。即使已经避免了 data race,也可能触发 race condition。

有多种方案应对以上问题,这篇文章介绍常用方案之一:同步(synchronization)。

1. 什么是同步 synchronization

Synchronization 是一整套技巧,用以确保多个线程按照预定规则执行。更具体的说,synchronization 将帮助在多线程程序中实现以下功能:

  • 原子性 atomicity:如果代码包含对多个线程共享数据操作的指令,则对该共享数据不加限制的并发操作会造成数据争用,包含这些指令的代码段称为临界区块(critical section)。需要确保 critical section 代码是原子执行,atomic operation 不能分解成更小的操作,因此在线程执行时其他线程无法插入。
  • 排序:有时希望多个线程按照指定顺序执行,或限制同时访问资源的线程数量。通常,无法控制这些行为,这也是 race condition 的根本原因。通过 synchronization,可以协调线程按照计划执行。

同步是通过操作系统、支持线程的语言提供的同步原语(synchronization primitive)实现的。使用 synchronization primitive 可以解决多线程中的 data race、race condition 问题。

Synchronization 发生在硬件和软件中,也发生在线程与操作系统进程之间。这篇文章只涉及软件线程间同步。

2. 常见 synchronization primitive

最常用的 synchronization primitive 包括互斥锁(mutex)、信号量(semaphore)和条件变量(condition variable)。这些术语没有官方定义,不同的实现会形成稍有不同的特征。

操作系统原生的提供了这些工具。例如,Linux 和 macOS 支持 POSIX Threads,也称为 pthreads。pthreads 是 POSIX 的线程标准,定义了创建和操作线程的一套 API,为编写线程安全的多线程程序提供支持。Windows 在 C Run-Time Libraries(CRT)有一套 synchronization 工具,在概念上类似于 POSIX 线程函数,只是名称不同。

除非编写的代码很底层,否则一般选用编程语言的 synchronization primitive。每种语言都有自己的 synchronization primitive 工具箱,以及其他管理线程的函数。例如,Java 提供 java.util.concurrent 包,现代 C++ 有自己的线程库,C# 提供 System.Threading 名称空间等。当然,这些函数和对象都基于操作系统 primitive。

此外,还有其他 synchronization 工具,但这篇文章只涉及上面提到的互斥锁、信号量和条件变量。因为它们通常用于构建更为复杂的实体。

2.1 互斥锁 Mutex

互斥锁(mutual exclusion,简写 mutex)是一种同步原语,它在临界区域周围添加限制,以防止 data race。互斥锁限制一次仅有一个线程访问 critical section,以此保证原子性。

互斥锁是 app 中的全局对象,在线程间共享。它提供lockunlock功能,线程即将进入临界区域时调调用lock锁定互斥锁,执行完毕后同一线程调用unlock解除锁定。mutex 的重要特点就是只有锁定 mutex 的线程可以解锁。

如果另一个线程执行到了临界区域,尝试锁定已锁定的互斥锁,操作系统会将其置于睡眠状态,直到之前线程执行完毕并解除该互斥锁。因此,只有一个线程可以访问 critical section,其他线程必须等待。mutex 也称为锁定机制(locking mechanism)。

使用 mutex 可以保护一些简单操作,如并发线程读写共享数据,以及一次必须由一个线程执行的更大、更复杂的操作。例如,写入日志文件、修改数据库等。mutex 的 lock、unlock 始终与临界区域的边界匹配。

2.1.1 递归互斥锁 Recursive Mutex

在常规互斥锁中,线程锁定互斥锁两次会导致错误,但递归互斥锁允许锁定多次。线程可以连续锁定 recursive mutex 多次,而无需先解锁。但只有第一个锁定递归互斥锁的线程解除所有锁定后,其他线程才可以锁定该递归互斥锁。递归互斥锁也称为可重入互斥锁(reentrant mutex)。可重入即在先前调用结束前,多次调用同一个函数。

Recursive mutex 很难使用,并且容易出错。必须记录哪个线程已锁定互斥锁多少次,并使用同一线程解锁对应次数。没有完全解除递归互斥锁,会导致其他问题。通常,普通互斥锁能够解决大部分问题。

2.1.2 读写互斥锁 Reader/Writer Mutex

只要不修改共享资源,并发的读取资源不会产生任何问题。如果读取概率远远大于写入,为何要使用互斥锁?例如,一个经常被很多线程访问的并发数据库,通过另一线程偶尔写入,很多线程经常读取。如果使用互斥锁保护读写操作,大部分情况下只会锁定读取操作,从而阻止其他线程进行读取。

Reader/Writer mutex 允许从多个线程并发读取,写入时锁定资源,禁止其他线程进入,这样可以将资源锁定为读取或写入模式。要修改资源,线程必须先获取 exclusive write lock,而 exclusive write lock 必须等所有 read lock 释放后才可以进入。

2.2 信号量 Semaphore

信号量(semaphore)是用于编排线程的 synchronization primitive。例如,哪个线程先运行,最多多少线程可以同时访问资源。就像信号灯控制交通,编程中的信号量控制多线程流。因此,semaphore 也称为信号机制(signaling mechanism)。因为其同时控制顺序和原子性,可以将其视为互斥锁的进化。稍后会说明将信号量仅用于原子性不是一个好主意。

信号量是 app 中的全局对象,在线程间共享。它包含一个由两个函数管理的计数器,一个增加计数器、一个减少计数器,曾被称为PV,现在使用更易于理解的名称:acquirerelease

信号量控制对共享资源的访问,计数器确定同时访问共享资源的线程数。初始化信号量时设定允许的最大线程数,随后需要访问共享资源的线程调用acquire

  • 如果计数器大于零,线程可以继续。计数器立即减一,随后线程执行任务。执行完毕后,调用release,计数器加一。
  • 如果计数器等于零,线程不可以继续,表示其他线程已经占完最大可进入线程数。操作系统将当前线程设置为休眠,并在计数器大于零时(即其他线程调用release)唤醒该线程。

与互斥锁不同,任何线程都可以release信号量,不仅仅是第一个acquire信号量的线程。

单个信号量可以限制访问共享资源最大线程数。例如,限制多线程数据库连接数量,其中每个线程都是由连接到服务器的客户端触发的。

多个信号量组合在一起,可以解决线程排序问题。例如,浏览器中的渲染线程必须在下载 HTML 文件的线程完成后才开始。即线程A完成工作后通知线程B,线程B苏醒后执行工作,这也就是著名的生产者消费者问题(Producer-Consumer problem)。

生产者消费者问题也称为有限缓冲问题(Bounded-buffer problem),是多进程同步问题的经典案例。该问题描述了共享固定大小缓冲区的两个进程(即所谓的生产者和消费者)在实际运行时会发生的问题。生产者的作用是生产一定量的数据放到缓冲区,然后重复此过程。与此同时,消费者也在缓冲区消耗这些数据。问题的关键在于要保证生产者在缓冲区满时不会继续加入数据,消费者在缓冲区空时不会消耗数据。

要解决该问题,必须让生产者在缓冲区满时休眠,等下次消费者消耗缓冲区数据时才唤醒;在缓冲区空时让消费者进入休眠,等下次生产者向缓冲区添加数据后唤醒消费者。常用进程间通信解决该问题,具体方法有信号量等。如果解决方案有问题容易导致死锁,两个线程均休眠等待对方唤醒自己。

2.2.1 二进制信号量 Binary Semaphore

计数器限制为0和1的信号量称为二进制信号量(binary semaphore)。binary semaphore 一次只能有一个线程访问共享资源,这一点与互斥锁相同。事实上,可以使用 binary semaphore 实现类似互斥锁的行为,但需注意以下两点:

  • 在互斥锁中,只有加锁的线程可以解锁。在信号量中任何线程都可以release。如果你想要的只是锁定机制,使用信号量可能会产生微妙的 bug。
  • Semaphore 是协调线程的信号机制,mutex 是保护共享资源的锁定机制。不要使用 semaphore 保护共享资源,也不要使用 mutex 协调线程,否则代码会难以维护。

2.3 条件变量 Condition Variable

条件变量(conditional variable)是另一个用于排序的 synchronization primitive,用于在不同线程间发送唤醒信号。条件变量总是与互斥锁并存,单独使用没有意义。

Conditional variable 是 app 中的全局对象,线程之间共享。它提供三个函数:waitnotify_onenotify_all,以及为其传递互斥锁的机制。

对条件变量调用wait的线程被操作系统置于休眠状态,其他线程想要唤醒它时调用notify_onenotify_allnotify_one唤醒一个线程,notify_all唤醒所有因wait条件变量而休眠的线程。互斥锁在内部提供休眠、唤醒机制。

条件变量可以在线程之间发送信号,这是单独使用互斥锁无法实现的。使用 conditional variable 可以解决 Producer-Consumer 问题。

3. Synchronization 常见问题

这篇文章中介绍的所有 synchronization primitive 有一个共同点:使线程进入休眠,因此也称为阻止机制(blocking mechanism)。Blocking mechanism 可以很好的防止并发线程同时访问共享资源带来的 data race、race condition 问题。睡眠的线程不会产生危害,但可能产生其他副作用。

3.1 死锁 Deadlock

线程A正在等待线程B持有的变量,线程B正在等待线程A持有的变量,这时就会产生死锁(deadlock)。通常在使用多个互斥锁时发生死锁。

3.2 资源匮乏 Starvation

在计算机科学中,资源匮乏(resource starvation,也称线程饥饿)是并发计算中的问题。在该问题中,永久性地拒绝了一个线程访问其工作所必须的资源,导致一直处于休眠状态。Starvation 可能是调度算法、互斥算法的问题,也可能是资源泄漏引起的。

3.3 虚假唤醒 Spurious Wake-up

虚假唤醒(Spurious Wake-up)是一个很微妙的问题,源于操作系统如何实现 conditional variable。在 spurious wake-up 中,即使没有通过条件变量发出信号,线程也会唤醒。这也就是为何大多数 synchronization primitive 提供了检查唤醒信号是否来自于线程正在等待的条件变量。

3.4 优先级倒置 Priority Inversion

优先级倒置(priority inversion)又称为优先级反转、优先级逆转、优先级翻转,是一种不希望发生的任务调度状态。在该种状态下,一个高优先级的任务被一个低优先级任务所抢先,两个任务相对优先级倒置。

这种情况往往出现在一个高优先级任务等待访问一个低优先级任务正在使用的临界资源,从而堵塞了高优先级任务;同时,该低优先级任务被一个次高优先级任务抢先,从而无法及时释放该临界资源。例如,将音频输出到声卡的线程(高优先级)被显示界面的线程(低优先级)堵塞时,会导致扬声器出现故障。Priority inversion 并不一定产生危害,有时高优先级任务延迟运行并不会被察觉。

总结

所有这些 synchronization 问题都已经研究了多年,并提供了多种解决方案,包括技术上的和架构上的。精心设计结合过往经验可以避免出现这些问题。鉴于多线程程序的不确定性,人们开发了一些工具检测并发代码中的错误和潜在陷进,如HelgrindTSan等。

有时想要避免堵塞机制,采取其他模式。这意味着进入非堵塞领域:一个非常底层的领域。在该领域中,线程永远不会被操作系统休眠,并发通过原子性和不可变数据结构来控制。这是一个充满挑战的领域,并非总是必要的。无锁编程可以提高软件速度,也可以造成严重破坏。下一篇文章并发控制之无锁编程将介绍相关部分内容。

上一篇:多线程简述

下一篇:并发控制之无锁编程

欢迎更多指正:https://github.com/pro648/tips

本文地址:https://github.com/pro648/tips/blob/master/sources/%E5%B9%B6%E5%8F%91%E6%8E%A7%E5%88%B6%E4%B9%8B%E7%BA%BF%E7%A8%8B%E5%90%8C%E6%AD%A5.md