设计和实现并发软件非常困难。即使是我们上一节看到的那些用于控制对共享数据访问的基本模式,也十分复杂且充满了微妙的细节。如果忽略了其中一个细节,通常都会导致难以调试的数据竞争问题。为了简化编写并发程序的任务,编程社区提出了若干指导原则。这些原则都源于早期的惨痛教训,因此请务必认真对待。这些指导原则的核心是线程安全保证的概念。
虽然这并非一种模式,但它是一个范围更广的概念,也是并发软件的关键设计原则。并发程序中的每个类、函数、模块或组件,都应明确说明其提供的线程安全保证,以及对其所使用组件的线程安全要求。
通常,一个软件组件可以提供三个级别的线程安全保证:
强线程安全保证:任意数量的线程可以无限制地访问该组件,而不会遇到未定义行为。对于函数而言,任意数量的线程可以同时调用该函数(可能对参数存在某些限制)。对于类而言,任意数量的线程可以同时调用该类的成员函数。对于更大的组件,任意数量的线程可以同时操作其接口(同样,可能有某些限制)。此类组件、类和数据结构有时称为“线程安全”。
弱线程安全保证:任意数量的线程可以同时访问该组件,执行那些指定为不改变组件状态的操作(对于类而言,这通常是 const 成员函数)。但在时刻,只能有一个线程修改组件的状态,并且确保这种独占访问的加锁或其他机制由调用者负责。此类组件、类和数据结构有时称为“线程兼容”,因为可以使用适当的同步机制基于它们构建并发程序。所有 STL 容器都提供这一级别的保证。
无线程安全保证:此类组件完全不能用于并发程序中,有时称为“线程敌对”。这些类和函数通常包含,无法以线程安全方式访问的隐藏全局状态。
通过设计每个组件以提供特定的线程安全保证,们可以将确保整个程序线程安全这一棘手问题,分解为一系列分层的设计挑战,其中更复杂的组件可以利用更简单组件所提供的保证。实现这一过程的核心概念是“事务性接口”。
事务性接口设计的思想非常简单:每个组件都应拥有一个接口,使得其每个操作都是一个原子事务。从程序其他部分的角度来看,该操作要么尚未发生,要么已经完成。在操作执行期间,没有其他线程能够观察到该组件的中间状态。这可以通过互斥锁或其他适合需求的同步机制来实现 —— 具体的实现方式可能影响性能,但只要接口能保证事务处理,其具体实现对于正确性而言并非关键。
这一原则在设计并发程序的数据结构时尤为有用。它的重要性如此之高,以至于业界普遍认为,无法设计出一个不提供事务性接口的线程安全数据结构(至少无法设计出一个真正有用的数据结构)。例如,一个队列。C++ 标准库提供了 std::queue 模板。与其他 STL 容器一样,提供的是弱保证:只要没有线程调用非常量方法,任意数量的线程都可以调用队列的常量方法;或者,只有一个线程可以调用非常量方法。
为了确保线程安全,必须使用一个外部互斥锁来锁定对队列的所有访问。如果采用这种方法,就应该将队列和互斥锁封装在一个新的类中:
template <typename T> class ts_queue {
std::mutex m_;
std::queue<T> q_;
public:
ts_queue() = default;
...
};
要向队列中添加另一个元素,需要先锁定互斥锁,因为 push() 成员函数会修改队列:
template <typename T> class ts_queue {
public:
void push(const T& t) {
std::lock_guard l(m_);
q_.push(t);
}
};
这样做完全达到了我们的预期效果:任意数量的线程都可以调用 push(),并且每个元素都将恰好一次地添加到队列中(如果多个调用同时发生,元素的入队顺序将是任意的,但这正是并发的本质)。我们成功地提供了强线程安全保证!
然而,这份成功将是短暂的。来看看从队列中弹出一个元素需要什么操作。有一个成员函数 pop() 可以移除队列中的元素,可以用同样的互斥锁来保护:
template <typename T> class ts_queue {
public:
void pop() {
std::lock_guard l(m_);
q_.pop();
}
};
这个函数不返回值:它会移除队列中最老的元素并将其销毁,但这并不是我们想要的,因为通常需要知道移除的元素是什么(或曾经是什么)。为此,需要使用 front() 函数,返回一个指向最老元素的引用,但不会修改队列。它是一个 const 成员函数,只有在同时调用非常量函数时才需要加锁;现在先忽略这种优化的可能性,而是一律对这个调用也进行加锁:
template <typename T> class ts_queue {
public:
T& front() const {
std::lock_guard l(m_);
return q_.front();
}
};
如果从多个线程调用 front() 且不调用其他函数,这种实现方式虽然不是最优的,但并没有错误。
如果队列为空,不应该调用 pop() 或 front() —— 根据标准,这样做会导致未定义行为。那么,如何知道从队列中弹出元素是否安全呢?可以检查队列是否为空。这是另一个 const 成员函数,同样将采取过度保护的策略,对每次调用都加锁:
template <typename T> class ts_queue {
public:
bool empty() const {
std::lock_guard l(m_);
return q_.empty();
}
};
现在,底层 std::queue 的每个成员函数都受到互斥锁的保护。可以从任意数量的线程中调用,并确保在时刻只有一个线程能访问队列。从技术上讲,已经实现了强保证,但它并不太实用。
为了理解原因,让我们考虑一下从队列中移除一个元素的过程:
ts_queue<int> q;
int i = 0;
if (!q.empty()) {
i = q.front();
q.pop();
}
在单个线程上运行时,这完全没问题,但并不需要互斥锁。当有两个线程时,一个向队列推送新元素,另一个从队列取出元素,这种情况也(大部分)能正常工作。考虑两个线程都试图各自弹出一个元素的情况。
首先,都调用 empty()。假设队列非空,两次调用都返回 true。接着,都调用 front()。由于此时还没有线程调用 pop(),两个线程都得到了相同的队首元素。而这并非我们期望的结果 —— 本希望每个线程都能从队列中弹出一个元素。最后,两个线程都调用 pop(),于是队列中的两个元素移除。其中有一个元素从未获取过,也永远不会再看到,因此丢失了部分入队的数据。
但这还不是唯一可能出错的方式。如果队列中仅有一个元素会发生什么?两次对 empty() 的调用仍然返回 true —— 包含一个元素的队列并非空队列。两次对 front() 的调用仍然返回(相同的)队首元素。第一次 pop() 调用成功,但第二次调用则导致未定义行为,因为此时队列已经为空。还有一种可能:一个线程在另一个线程调用 front() 之前、但调用 empty() 之后调用了 pop()。在这种情况下,第二次调用 front() 同样会导致未定义行为。
我们得到了一个绝对安全但完全无用的数据结构,仅仅有线程安全保证是不够的。还需要一个不会产生未定义行为的接口,而实现的唯一方法是将弹出操作的三个步骤(empty()、front() 和 pop())放在一个单一的临界区内完成,即在这些调用之间不释放互斥锁。除非我们希望调用者自己提供互斥锁,否则唯一的方法就是改变 ts_queue 类的接口:
// Example 20
template <typename T> class ts_queue {
std::queue<T> q_;
std::mutex m_;
public:
ts_queue() = default;
template <typename U> void push(U&& u) {
std::lock_guard l(m_);
q_.push(std::forward<U>(u));
}
std::optional<T> pop() {
std::lock_guard l(m_);
if (q_.empty()) return {};
std::optional<T> res(std::move(q_.front()));
q_.pop();
return res;
}
};
push() 函数与之前相同(使参数类型更加灵活,但这与线程安全无关)。之所以不需要修改 push 操作,因为它本身已经是事务性的:操作结束后,队列比操作开始时多了一个元素,而队列的其他状态保持不变。只是通过互斥锁使其变为原子操作(其他正确使用同一互斥锁的线程,都无法观察到队列在转换过程中的非不变状态)。
pop() 操作则不同,体现了事务性接口的设计。为了提供有意义的线程安全保证,必须提供一个操作,该操作能原子性地将队首元素返回给调用者,并将其从队列中移除:其他线程不应再看到同一个队首元素,因此必须使用同一个互斥锁来锁定原始队列的 front() 和 pop() 操作。还必须考虑队列为空的情况,此时没有队首元素可以返回给调用者。这种情况下,应该返回什么?如果决定按值返回队首元素,就必须默认构造一个该类型的值(或返回某个约定好的、表示“无元素”的值)。在 C++17 中,更好的方法是返回一个 std::optional,当队列中有元素时,包含该元素,否则为空。
现在,pop() 和 push() 都是原子且事务性的:可以从任意数量的线程中调用这两个方法,结果始终是明确定义的。
有读者可能会疑惑,为什么 std::queue 一开始没有提供这种事务性接口呢?首先,STL 是在多线程引入标准之前很久设计的。但另一个非常重要的原因是,队列接口的设计受到了提供异常安全保证的需求影响。异常安全是指当抛出异常时,对象仍能保持在明确定义的状态。在这方面,原始的队列接口表现得非常好:empty() 只是返回大小,不会抛出异常;front() 返回对队首元素的引用,通常也不会抛出异常;最后,pop() 调用队首元素的析构函数,这通常也不会抛出异常。当然,当调用者访问队首元素时,其代码可能会抛出异常(如果调用者需要将队首元素复制到另一个对象中),但调用者有责任处理这种情况。无论如何,队列本身始终保持在明确定义的状态。
然而,我们的线程安全队列却存在一个异常安全问题:现在将队首元素复制出来以返回给调用者的代码位于 pop() 内部。如果在构造局部 std::optional 变量 res 时复制构造函数抛出异常,我们可能还好(此时队列状态未变)。但如果在将结果返回给调用者时抛出异常(这可能发生在移动或复制过程中),那么 pop() 操作已经完成,所以刚刚从队列中弹出的元素将丢失。这种线程安全与异常安全之间的矛盾常常不可避免,在设计并发程序的线程安全数据结构时必须加以考虑。
尽管如此,必须重申的是,设计线程安全的数据结构或更大模块的唯一方法是确保每次接口调用都是一个完整的事务:条件性定义的步骤,都必须与确保这些条件成立所需的操作一起,打包成单一的事务性调用。然后,整个调用应该由互斥锁或其他机制保护,以确保无竞争的独占访问。
设计线程安全的数据结构通常非常困难,尤其是当追求良好性能时(如果不能获得良好的性能,并发的意义何在?)。,充分利用使用限制或特殊要求,以便对这些数据结构的使用方式施加限制,非常重要。在下一节中,我们将看到一种此类限制的常见情况。
设计线程安全的数据结构如此困难,以至于人们应当寻找一切机会来简化需求和实现。对于当前不需要的场景,思考一下如果不支持该场景,是否能让代码变得更简单。
一个明显的例子是:数据结构由单个线程构建(此时无需线程安全保证),之后变为不可变状态,再由多个线程作为读取者进行访问(此时弱线程安全保证已足够)。例如, STL 容器都可以直接在这种模式下工作。仍然需要确保在容器填充数据期间,没有读取者可以访问它,但这可以通过栅栏或条件变量实现。这是一个非常有用但相对简单的例子。
那么,还有没有其他我们可以利用的限制条件呢?
在本节中,将探讨一种频繁出现且能实现更简单数据结构的特定使用场景。具体来说,我们考察一种数据结构仅由两个线程访问的情况:一个线程是生产者,负责向数据结构中添加数据;另一个线程是消费者,负责移除数据。这两个线程都会修改数据结构,但方式不同。
这种情况相当常见,通常允许我们实现非常专门化且高效的特定数据结构。它或许值得认可为一种并发设计模式,并且它已经有一个广为人知的名称:“单生产者单消费者数据结构”。
本节中,我们将以一个单生产者单消费者队列为例。这种数据结构常用于一个生产者和一个消费者线程的场景,但这里探讨的思想也可用于设计其他类型的数据结构。这种队列最主要的特点是它是无锁的:其中完全不使用互斥锁,因此可以预期其性能会高得多。
该队列基于一个固定大小的数组构建,因此与普通队列不同,不能无限增长(这是为了简化无锁数据结构而采用的另一个常见限制):
// Example 21
template <typename T, size_t N> class ts_queue {
T buffer_[N];
std::atomic<size_t> back_{0};
std::atomic<size_t> front_{N - 1};
...
};
示例中,对数组中的元素进行默认构造。如果这不可取,也可以使用一个正确对齐的未初始化缓冲区。
队列的所有访问都由两个原子变量 back_ 和 front_ 决定。前者是当向队列推入新元素时将要写入的数组元素的索引;后者是当需要从队列弹出元素时将要读取的数组元素的索引。位于区间 [front_, back_) 内的所有数组元素都填充了当前在队列中的元素。(注意:这个范围可以环绕缓冲区的末尾:在使用了 buffer_[N-1] 这个元素后,队列不会耗尽空间,而是从 buffer_[0] 重新开始。这种结构称为循环缓冲区)。
如何使用这些索引来管理队列呢?先从 push 操作开始:
// Example 21
template <typename T, size_t N> class ts_queue {
public:
template <typename U> bool push(U&& u) {
const size_t front =
front_.load(std::memory_order_acquire);
size_t back = back_.load(std::memory_order_relaxed);
if (back == front) return false;
buffer_[back] = std::forward<U>(u);
back_.store((back + 1) % N, std::memory_order_release);
return true;
}
};
当然,需要读取 back_ 的当前值:这是将要写入的数组元素的索引。只支持一个生产者,并且只有生产者线程可以递增 back_,因此不需要特别的防护措施。但是,必须小心避免覆盖队列中已有的元素。为此,必须检查 front_ 的当前值(可以在读取 back_ 之前或之后读取它,这没有区别)。如果覆盖的元素 buffer_[back] 同时也是队首元素,则队列已满,push() 操作失败(注意:这个问题还有另一种常用解决方案,常用于实时系统中:如果队列已满,则静默地覆盖并丢失最旧的元素)。
在新元素存储之后,原子性地递增 back_ 的值,以向消费者线程表明该槽位现在可供读取。由于正在“发布”这个内存位置的内容,必须使用释放栅栏。另外请注意模运算:在到达数组元素 N-1 之后,我们循环回到元素 0。
接下来,来看看 pop() 的操作:
// Example 21
template <typename T, size_t N> class ts_queue {
public:
std::optional<T> pop() {
const size_t back =
back_.load(std::memory_order_acquire);
const size_t front =
(front_.load(std::memory_order_relaxed) + 1) % N;
if (front == back) return {};
std::optional<T> res(std::move(buffer_[front]));
front_.store(front, std::memory_order_release);
return res;
}
};
同样,需要读取 front_ 和 back_:front_ 是即将读取的元素的索引,并且只有消费者线程可以推进这个索引。另一方面,需要 back_ 来确保确实有元素可读:如果 front 和 back 相同,则队列为空;同样,使用 std::optional 来返回一个可能不存在的值。
读取 back_ 时,必须使用获取栅栏,以确保能看到生产者线程写入数组的元素值。最后,推进 front_,以确保不会再次读取同一个元素,并使该数组槽位可供生产者线程使用。
这里有几个微妙的细节必须指出。
首先,读取 back_ 和 front_ 并不是在一个单一的事务中完成的(即并非原子的)。特别是,如果生产者先读取了 front_,在它读取 back_ 并进行比较时,消费者可能已经推进了 front_。尽管如此,这并不会使我们的数据结构不正确。最坏的情况是,生产者报告队列已满,而实际上队列已经不再满了。可以原子性地读取这两个值,但这只会降低性能,而且调用者仍然必须处理队列已满的情况。同样地,当 pop() 报告队列为空时,等到调用完成时,队列可能已经不再为空了。再次强调,这些是并发不可避免的复杂性:每个操作反映的是数据在某一时刻的状态。等到调用者获得返回值并能对其进行分析时,数据可能已经改变了。
另一个值得注意的细节是对队列元素生命周期的谨慎管理,对数组中的所有元素进行了默认构造,在 push() 期间将数据从调用者转移到队列的正确方式是通过复制或移动赋值(std::forward 可以同时处理这两种情况)。另一方面,当 pop() 将值返回给调用者,就不再需要该值了,这里的正确操作是移动(move),先移动到 optional,再移动到调用者的返回值对象。移动一个对象并不等同于销毁它;实际上,移动过的数组元素直到队列本身销毁时才销毁。如果一个数组元素重用,它会赋予一个新的值(通过复制或移动赋值),而赋值操作正是可以在移动过的对象上安全执行的三种操作之一(另外两种是调用析构函数,我们最终也会调用)。
单生产者单消费者模式是一种常见的模式,它允许开发者极大地简化他们的并发数据结构。还有其他类似的模式,可以在专门讨论并发数据结构的书籍和论文中找到。所有这些模式最终都是为了帮助你编写在多线程访问时,能正确且高效运行的数据结构。然而,我们必须继续前进,最终解决使用这些线程来完成一些工作中的实际问题。