并发编程中的模式与准则
在并发编程中,我们常常会遇到各种复杂的问题,尤其是在处理共享数据的访问控制时。本文将深入探讨并发设计模式和准则,帮助我们更好地设计和实现并发软件。
并发编程基础与线程安全保障
在并发编程中,消费者在遇到未准备好的数据元素时可能会停止操作,但我们也可以继续扫描数组,因为后续元素可能已由其他生产者线程填充。不过,这就需要我们记住回头处理错过的元素,具体方法取决于要解决的问题。
锁自由编程的文献众多,且示例通常非常复杂。我们所展示的并发模式只是构建更复杂数据结构和数据同步协议的基本模块。
为了简化并发程序的编写,编程社区提出了一些准则,其中核心概念是线程安全保障。一个并发程序的每个类、函数、模块或组件都应明确其提供的线程安全保障,以及对所使用组件的要求。
一般来说,软件组件可提供三种级别的线程安全保障:
-
强线程安全保障
:任意数量的线程可以无限制地访问该组件,且不会遇到未定义行为。对于函数,意味着任意数量的线程可以同时调用;对于类,任意数量的线程可以并发调用其成员函数;对于更大的组件,任意数量的线程可以操作其接口(可能有一些参数限制)。这类组件、类和数据结构有时被称为线程安全的。
-
弱线程安全保障
:任意数量的线程可以对指定不改变组件状态的操作进行访问(对于类,通常是 const 成员函数)。任何时候只有一个线程可以修改组件的状态,调用者有责任使用锁或其他方式确保这种独占访问。这类组件、类和数据结构有时被称为线程兼容的,因为可以使用适当的同步机制构建并发程序。所有 STL 容器都提供这种级别的保障。
-
无线程安全保障
:这类组件根本不能在并发程序中使用,有时被称为线程敌对的。这些类和函数通常有隐藏的全局状态,无法以线程安全的方式访问。
通过为每个组件设计特定的线程安全保障,我们可以将使整个程序线程安全的难题分解为一系列设计挑战,让更复杂的组件利用简单组件提供的保障。其中,事务性接口的概念至关重要。
事务性接口设计
事务性接口设计的理念很简单:每个组件的接口应使每个操作都是一个原子事务。从程序的其他部分来看,操作要么尚未发生,要么已经完成。在操作过程中,其他线程无法观察到组件的状态。这可以通过互斥锁或其他合适的同步方案来实现,具体实现可能影响性能,但只要接口保证事务处理,对正确性就不是关键的。
这个准则在为并发程序设计数据结构时非常有用。通常认为,不提供事务性接口就无法设计出线程安全的数据结构(至少不是有用的数据结构)。以队列为例,C++ 标准库提供了
std::queue
模板,它和其他 STL 容器一样,提供弱保障:只要没有线程调用非 const 方法,任意数量的线程可以调用队列的 const 方法;或者任意一个线程可以调用非 const 方法。为了确保后者,我们需要用外部互斥锁锁定对队列的所有访问。
以下是一个结合队列和互斥锁的新类示例:
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()
函数获取队列的第一个元素。由于
front()
是 const 成员函数,我们通常也会锁定它的调用:
template <typename T> class ts_queue {
public:
T& front() const {
std::lock_guard l(m_);
return q_.front();
}
};
我们还需要检查队列是否为空,以避免调用
pop()
或
front()
时出现未定义行为:
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()
调用也是未定义的。
为了避免这些问题,我们需要将
pop()
操作的三个步骤(
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()
操作的事务性接口有很大不同。为了提供有意义的线程安全保障,我们需要提供一个操作,原子地返回队首元素并将其从队列中移除。我们还需要考虑队列为空的情况,使用
std::optional
来返回可能不存在的值。
现在,
pop()
和
push()
都是原子和事务性的,我们可以从任意数量的线程调用这两个方法,结果总是明确定义的。
你可能会想,为什么
std::queue
一开始不提供这种事务性接口。一方面,STL 在线程成为标准之前就设计好了;另一方面,队列接口受异常安全的需求影响。异常安全是指在抛出异常时,对象仍保持明确定义的状态。原始的队列接口在这方面表现很好,但我们的线程安全队列存在异常安全问题。如果在复制队首元素返回给调用者时抛出异常,可能会导致数据丢失。
在设计并发程序的线程安全数据结构时,线程安全和异常安全之间的矛盾往往不可避免,需要仔细考虑。但必须重申,设计线程安全数据结构或更大模块的唯一方法是确保每个接口调用都是一个完整的事务,所有条件定义的步骤都应与确保条件满足的操作一起封装在一个事务性调用中,然后用互斥锁或其他方式确保无竞争的独占访问。
设计线程安全的数据结构通常非常困难,尤其是在追求高性能时。因此,利用任何使用限制或特殊要求来简化数据结构的使用非常重要。接下来,我们将看到一种常见的限制情况。
具有访问限制的数据结构
设计线程安全的数据结构非常困难,我们应该寻找机会简化需求和实现。如果有当前不需要的场景,可以考虑不支持该场景是否能使代码更简单。
一种明显的情况是,数据结构由单个线程构建(不需要线程安全保障),然后变为不可变的,由多个作为读者的线程访问(弱保障就足够)。例如,任何 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_
决定。
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_
的当前值。如果要覆盖的元素也是队首元素,那么队列已满,
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_
,以确保不会再次读取相同的元素,并使这个数组槽可供生产者线程使用。
综上所述,在并发编程中,我们可以通过合理运用线程安全保障、事务性接口设计以及具有访问限制的数据结构等方法,来应对并发编程中的各种挑战,提高程序的性能和稳定性。
并发编程中的模式与准则
并发编程的性能考量与未来展望
设计线程安全的数据结构不仅要考虑正确性,性能也是至关重要的因素。在前面提到的单生产者单消费者队列中,无锁设计避免了互斥锁带来的开销,从而提高了性能。但在实际应用中,性能的提升还受到多种因素的影响,如内存访问模式、缓存命中率等。
为了更直观地理解不同并发模式的性能差异,我们可以通过一个简单的表格进行对比:
| 并发模式 | 锁使用情况 | 性能特点 | 适用场景 |
| — | — | — | — |
| 强线程安全保障(使用互斥锁) | 频繁使用互斥锁 | 性能受锁竞争影响,可能存在阻塞 | 多线程对共享资源频繁读写,需要严格的线程安全保障 |
| 单生产者单消费者队列(无锁设计) | 无锁 | 避免锁竞争,性能较高 | 数据处理流程明确,只有一个生产者和一个消费者的场景 |
在实际开发中,我们需要根据具体的业务需求和性能要求选择合适的并发模式。例如,在一个实时数据处理系统中,如果数据的生产和消费流程相对固定,使用单生产者单消费者队列可以显著提高系统的吞吐量。
下面是一个简单的 mermaid 流程图,展示了单生产者单消费者队列的工作流程:
graph LR
classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px;
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
classDef decision fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;
A([开始]):::startend --> B{队列是否已满}:::decision
B -- 否 --> C(生产者写入数据):::process
C --> D(更新 back_ 索引):::process
D --> E{队列是否为空}:::decision
E -- 否 --> F(消费者读取数据):::process
F --> G(更新 front_ 索引):::process
G --> B
B -- 是 --> H(等待队列有空间):::process
H --> B
E -- 是 --> I(等待队列有数据):::process
I --> E
并发编程中的错误处理与调试
在并发编程中,错误处理和调试是非常具有挑战性的任务。由于多线程环境的复杂性,一些错误可能很难复现和定位。例如,前面提到的数据丢失问题,可能在某些特定的线程调度情况下才会出现。
为了更好地处理并发编程中的错误,我们可以采取以下几个步骤:
1.
日志记录
:在关键的代码位置添加详细的日志记录,记录线程的状态、共享资源的变化等信息。这样在出现问题时,可以通过查看日志来了解程序的执行流程。
2.
断言检查
:在代码中使用断言来检查一些关键的条件是否满足。例如,在
push()
和
pop()
操作中,可以使用断言来检查队列的状态是否符合预期。
3.
使用调试工具
:利用调试工具,如线程分析器、内存分析器等,来帮助定位问题。这些工具可以提供线程的执行时间、内存使用情况等信息,有助于发现潜在的问题。
并发编程的未来趋势
随着计算机硬件的不断发展,多核处理器已经成为主流,并发编程的重要性也日益凸显。未来,并发编程可能会朝着以下几个方向发展:
-
更高级的并发原语
:编程语言和操作系统可能会提供更高级的并发原语,如事务内存、软件事务性内存(STM)等,以简化并发编程的复杂性。
-
人工智能与并发编程的结合
:人工智能算法通常需要处理大量的数据和复杂的计算,并发编程可以提高人工智能算法的执行效率。未来,可能会出现更多将人工智能与并发编程相结合的应用。
-
分布式并发编程
:随着云计算和分布式系统的发展,分布式并发编程将变得更加重要。如何在分布式环境中实现高效的并发控制和数据同步将是一个重要的研究方向。
总结
并发编程是一个充满挑战和机遇的领域。通过合理运用线程安全保障、事务性接口设计以及具有访问限制的数据结构等方法,我们可以设计出高效、稳定的并发程序。同时,我们也需要关注并发编程中的错误处理和调试,以及未来的发展趋势。在实际开发中,我们应该根据具体的业务需求和性能要求选择合适的并发模式,不断学习和探索新的技术和方法,以应对不断变化的挑战。
希望本文能够帮助你更好地理解并发编程中的模式与准则,在实际项目中发挥更大的作用。如果你在并发编程中遇到任何问题,欢迎在评论区留言讨论。
超级会员免费看
595

被折叠的 条评论
为什么被折叠?



