设计基于锁的并发数据结构——并发设计纲要

前言

 
选择与设计正确的数据结构是解决方案设计的重中之重,并发编程亦不例外。若某种数据结构存在被多线程访问的可能性,除非其具备只读属性,否则必然需要保证数据变化在线程间被正确同步,对于此,一般有两种设计方案:

  1. 使用独立的互斥锁以及外部加锁来保护数据
  2. 设计自身支持并发访问的数据结构

本章将重点论述第二点。


何谓并发设计

 
线程安全(thread-safe):若存在某种数据结构,支持多线程并发访问,所有线程均可观察到该结构前后一致的视图,施加于该数据结构的任意操作均保证不会造成数据丢失或损坏,且不会造成数据竞争,则称该数据结构线程安全。通常场景下,数据结构可能仅需要保证特定的并发访问“安全”即可,例如“保证在多线程读与单线程写的情景下安全”,或者“多线程执行不同操作时安全”。

前文曾提及的互斥锁可以为数据结构提供保护(一次仅能有一个线程获得互斥锁),究其本质,即显式阻止对被保护数据的真正并发访问。这被称为串行化(serialization)——各线程轮流,以串行而非并行的方式访问保护数据。

使用互斥锁包裹全部数据显然不是并发设计——真正的设计意味着需要为多线程提供并发访问保护数据的机会。尽管不同场景,不同数据结构所需要的并发范围大小各有差异,但基本设计思想是一致的:保护区越小,串行化操作就越少,并发的潜力就越大。


并发设计纲要

 

如上文所说,并发设计主要需要考虑2点:

  1. 确保访问安全
  2. 允许真正的并发访问

访问安全

线程间共享数据一章中曾对保证数据结构线程安全的基础要点有所介绍:

  • 除修改线程外,没有线程能看到数据被修改时的中间状态。
  • 提供完整操作(而非一个个操作步骤)的函数以避免接口固有的竞争条件。
  • 避免异常行为暴露数据的中间状态。
  • 限制锁的范围,避免嵌套锁,尽力避免死锁。

同时,我们也需要考虑对数据结构的使用者施加何种约束:若当前存在线程A通过一个特定函数对数据结构进行访问,那么其他线程能安全使用哪些函数?
通常,构造函数和析构函数需要互斥地访问数据结构,但需要用户保证不会在构造函数完成前/析构函数完成后访问数据结构。若该数据结构支持copy assignmentswapcopy ctor,则我们必须决定这些操作与其他操作并发调用时使用安全,或者它们是否必须要求用户保证互斥访问。

并发访问

在设计数据结构之前,我们必须扪心自问

  • 是否可以限制锁的作用范围,以允许操作的某些部分在锁外执行?
  • 数据结构不同部分能否被不同的互斥锁保护?
  • 所有的操作需要同一级别的保护吗?
  • 是否可以在不影响操作语义的前提下修改数据结构,增加其得以被并发访问的可能?

上述问题的核心是——如何最小化必须的串行操作,使真实的并发最大化?
就实际使用场景而言,只读线程并发访问,修改线程互斥访问的案例较为常见(可以通过使用std::shared_thread实现)。后文将提及另一种常见场景:串行执行执行同一操作,并发执行不同操作。