并发同步模式解析
在并发编程中,实现无竞争的数据访问是一个关键目标。虽然避免数据共享并在必要时使用互斥锁是一种可行的方法,但这可能并非最有效的方式。本文将介绍几种并发访问共享数据的模式,这些模式在适用场景下能提供更优的性能。
1. 等待模式
在并发程序中,等待是一个常见问题,有多种表现形式。例如,当两个线程同时尝试进入临界区时,其中一个线程必须等待,这是互斥锁使用时的等待情况,但这只是临界区独占访问的副作用。还有一些情况,等待是主要目的,比如用户界面线程等待输入、线程等待网络套接字响应,或者线程池中的计算线程等待任务执行。
等待模式主要有两种实现方式:通知和轮询。
1.1 通知模式
通知模式的基本形式是条件模式,通常由条件变量和互斥锁组成。一个或多个线程会在条件变量上阻塞等待,此时会有另一个线程锁定互斥锁,完成其他线程等待的工作。工作完成后,该线程释放互斥锁,并通知等待的线程可以继续执行。
以下是一个简单的双线程通知模式示例:
// Example 11
std::mutex m;
std::condition_variable cv;
size_t n = 0; // Zero until work is done
// Main thread
void main_thread() {
std::unique_lock l(m);
std::thread t(produce); // Start the worker
cv.wait(l, []{ return n != 0; });
// ... producer thread is done, we have the lock ...
}
// Worker thread
void produce() {
{
std::lock_guard l(m);
// ... compute results ...
n = ... result count ...
} // Mutex unlocked
cv.notify_one(); // Waiting thread notified
}
在这个示例中,
std::unique_lock
用于锁定互斥锁,
cv.wait()
函数在等待条件时会解锁互斥锁,收到通知后会再次锁定。同时,为了避免虚假唤醒,我们会检查结果计数
n
。
1.2 轮询模式
轮询模式中,等待线程会反复检查某个条件是否满足。在 C++20 中,可以使用
std::atomic_flag
实现简单的轮询等待:
// Example 12
std::atomic_flag flag;
// Worker thread:
void produce() {
// ... produce the results ...
flag.test_and_set(std::memory_order_release);
}
// Waiting thread:
void main_thread() {
flag.clear();
std::thread t(produce);
while (!flag.test(std::memory_order_acquire)) {} // Wait
// ... results are ready ...
}
轮询模式的缺点是,如果等待时间较长,等待线程会一直忙于计算,效率较低。实际实现中可能会在等待循环中加入睡眠,但这也会带来延迟。
轮询和等待的界限并不总是清晰的,例如
wait()
函数可能通过定期轮询条件变量的内部状态来实现。实际上,相同的原子标志也可用于等待通知:
// Example 13
std::atomic_flag flag;
// Worker thread:
void produce() {
// ... produce the results ...
flag.test_and_set(std::memory_order_release);
flag.notify_one();
}
// Waiting thread:
void main_thread() {
flag.clear();
std::thread t(produce);
flag.wait(true, std::memory_order_acquire); // Wait
while (!flag.test(std::memory_order_acquire)) {}
// ... results are ready ...
}
1.3 等待多个事件
在某些情况下,我们需要等待一定数量的事件发生。例如,多个线程产生结果,主线程需要等待所有线程完成工作。在 C++20 中,可以使用
std::latch
来实现:
// Example 14
// Worker threads
void produce(std::latch& latch) {
// ... do the work ...
latch.count_down(); // One more thread is done
}
void main_thread() {
constexpr size_t nthread = 4;
std::jthread t[nthread];
std::latch latch(nthread); // Wait for 4 count_down()
for (size_t i = 0; i != nthread; ++i) {
t[i] = std::jthread(std::ref(latch));
}
latch.wait(); // Wait for producers to finish
// ... results are ready ...
}
2. 无锁同步模式
大多数情况下,安全访问共享数据依赖于互斥锁,但 C++ 也支持另一种同步并发线程的方式:原子操作。
2.1 原子计数
原子操作的一个简单而有用的应用是计数。在并发程序中,如果所有线程都需要知道当前计数,使用互斥锁保护整数的简单递增操作效率很低,而原子计数器是更好的选择:
// Example 15
std::atomic<size_t> count;
void thread_work() {
size_t current_count = 0;
if (... counted even ...) {
current_count =
count.fetch_add(1, std::memory_order_relaxed);
}
}
2.2 原子索引
原子计数还可以用于让多个线程在无需锁定的情况下处理同一数据结构。通过原子索引,每个线程可以获得自己的数组元素进行操作:
// Example 16
static constexpr size_t N = 1024;
struct Data { ... };
Data data[N] {};
std::atomic<size_t> index(0);
// Many producer threads
void produce(size_t& n) {
while (... more work … ) {
const size_t s =
index.fetch_add(1, std::memory_order_relaxed);
if (s >= N) return; // No more space
data[s] = ... results ...
}
}
void main_thread() {
constexpr size_t nthread = 5;
std::thread t[nthread];
for (size_t i = 0; i != nthread; ++i) {
t[i] = std::thread(produce);
}
// Wait for producers. to finish.
for (size_t i = 0; i != nthread; ++i) {
t[i].join();
}
// ... all work is done, data is ready ...
}
2.3 发布协议
发布协议是一种依赖内存屏障的无锁模式,适用于一个线程产生数据并在准备好后让其他线程访问的情况。
// Example 17
std::atomic<Data*> data;
void produce() {
Data* p = new Data;
// ... complete *p object ...
data.store(p, std::memory_order_release);
}
void consume() {
Data* p = nullptr;
while (!(p = data.load(std::memory_order_acquire))) {}
// ... safe to use *p ...
}
在这个模式中,共享变量是指向数据的原子指针,称为“根”指针。生产者线程构建数据,完成后将指针原子地存储在根指针中,消费者线程等待根指针变为非空后访问数据。
在多生产者和多消费者的程序中,可能需要结合多种同步模式。例如,使用原子索引确保每个元素由一个生产者处理,使用发布协议让消费者安全访问数据:
// Example 19
static constexpr size_t N = 1024;
struct Data { ... };
std::atomic<Data*> data[N] {};
std::atomic<size_t> size(0); // Atomic index
void produce() {
Data* p = new Data;
// ... compute *p ...
const size_t s =
size.fetch_add(1, std::memory_order_relaxed);
data[s].store(p, std::memory_order_release);
}
void consumer() {
for (size_t i = 0; i != N; ++i) {
const Data* p =
data[i].load(std::memory_order_acquire);
if (!p) break; // No more data
// ... *p is safe to access ...
}
}
总结
本文介绍了并发编程中的多种同步模式,包括等待模式(通知和轮询)和无锁同步模式(原子计数、原子索引和发布协议)。这些模式在不同场景下各有优劣,在实际应用中需要根据具体需求选择合适的模式。
以下是一个简单的流程图,展示了发布协议的基本流程:
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(生产者构建数据):::process
B --> C(生产者原子存储指针):::process
C --> D{根指针是否非空}:::decision
D -->|否| D
D -->|是| E(消费者访问数据):::process
E --> F([结束]):::startend
同时,为了更清晰地对比不同同步模式的特点,我们可以列出以下表格:
| 同步模式 | 适用场景 | 优点 | 缺点 |
| ---- | ---- | ---- | ---- |
| 通知模式 | 线程等待特定事件完成 | 效率较高,避免不必要的轮询 | 实现相对复杂 |
| 轮询模式 | 简单的条件检查 | 实现简单 | 长时间等待效率低 |
| 原子计数 | 多线程计数 | 避免互斥锁的开销 | 仅适用于简单计数场景 |
| 原子索引 | 多线程处理共享数据结构 | 无需锁定,提高并发性能 | 数据结构需支持原子操作 |
| 发布协议 | 一个生产者多个消费者场景 | 无锁同步,提高性能 | 依赖内存屏障,实现复杂 |
并发同步模式解析(续)
3. 同步模式的综合应用与性能考量
在实际的并发编程中,很少会单纯地使用某一种同步模式,往往需要根据具体的业务需求和系统环境,综合运用多种同步模式来实现高效、安全的并发操作。
3.1 多生产者多消费者场景的复杂应用
在多生产者多消费者的场景中,我们可能会遇到更复杂的情况。例如,生产者不仅要生产数据,还需要对数据进行分类和优先级排序;消费者则需要根据数据的类型和优先级进行不同的处理。此时,我们可以结合原子索引、发布协议和条件变量来实现这一功能。
以下是一个示例代码,展示了如何在多生产者多消费者场景中综合运用这些同步模式:
#include <iostream>
#include <thread>
#include <vector>
#include <atomic>
#include <mutex>
#include <condition_variable>
#include <queue>
// 定义数据结构
struct Data {
int value;
int priority;
Data(int v, int p) : value(v), priority(p) {}
};
// 共享资源
std::atomic<size_t> index(0);
std::vector<std::atomic<Data*>> dataQueue;
std::mutex mtx;
std::condition_variable cv;
bool allProduced = false;
// 生产者线程函数
void producer() {
for (int i = 0; i < 10; ++i) {
Data* newData = new Data(i, rand() % 3); // 随机生成优先级
size_t s = index.fetch_add(1, std::memory_order_relaxed);
if (s >= dataQueue.size()) break;
dataQueue[s].store(newData, std::memory_order_release);
{
std::lock_guard<std::mutex> lock(mtx);
cv.notify_one(); // 通知消费者有新数据
}
}
{
std::lock_guard<std::mutex> lock(mtx);
allProduced = true;
cv.notify_all(); // 通知所有消费者生产结束
}
}
// 消费者线程函数
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return allProduced || anyDataAvailable(); });
if (allProduced && !anyDataAvailable()) break;
for (size_t i = 0; i < dataQueue.size(); ++i) {
Data* p = dataQueue[i].load(std::memory_order_acquire);
if (p) {
// 处理数据
std::cout << "Consumed: " << p->value << " with priority " << p->priority << std::endl;
dataQueue[i].store(nullptr, std::memory_order_release);
delete p;
}
}
}
}
// 检查是否有数据可用
bool anyDataAvailable() {
for (size_t i = 0; i < dataQueue.size(); ++i) {
if (dataQueue[i].load(std::memory_order_acquire)) return true;
}
return false;
}
int main() {
constexpr size_t N = 100;
dataQueue.resize(N);
std::vector<std::thread> producers;
std::vector<std::thread> consumers;
// 创建生产者线程
for (int i = 0; i < 3; ++i) {
producers.emplace_back(producer);
}
// 创建消费者线程
for (int i = 0; i < 2; ++i) {
consumers.emplace_back(consumer);
}
// 等待生产者线程完成
for (auto& t : producers) {
t.join();
}
// 等待消费者线程完成
for (auto& t : consumers) {
t.join();
}
return 0;
}
在这个示例中,生产者线程使用原子索引将数据存储到共享队列中,并使用发布协议将数据发布给消费者。消费者线程通过条件变量等待新数据的到来,并根据数据的优先级进行处理。
3.2 性能考量与优化
不同的同步模式在性能上有不同的表现,因此在选择同步模式时,需要充分考虑性能因素。以下是一些常见的性能考量和优化建议:
- 减少锁的持有时间 :在使用互斥锁时,尽量减少锁的持有时间,避免长时间占用锁导致其他线程等待。例如,在生产者线程中,只在必要时锁定互斥锁,完成操作后尽快释放。
- 避免不必要的轮询 :轮询模式在长时间等待时效率较低,应尽量避免。可以使用通知模式或条件变量来实现线程的等待和唤醒。
- 合理使用原子操作 :原子操作虽然可以避免锁的开销,但也有一定的性能成本。在使用原子操作时,应根据具体情况选择合适的内存顺序,避免过度同步。
4. 同步模式的未来发展趋势
随着计算机硬件的不断发展和并发编程需求的不断增加,同步模式也在不断演进。以下是一些可能的未来发展趋势:
4.1 硬件支持的增强
未来的硬件可能会提供更多的并发支持,例如更高效的原子操作指令、更强大的内存屏障机制等。这将使得无锁同步模式的性能得到进一步提升,并且可能会出现新的同步原语和模式。
4.2 软件层面的优化
在软件层面,编程语言和开发框架可能会提供更高级的并发编程工具和库,使得同步模式的实现更加简单和高效。例如,一些编程语言可能会引入新的关键字或语法糖来简化原子操作和条件变量的使用。
4.3 人工智能与并发编程的结合
随着人工智能技术的发展,人工智能算法可能会被应用于并发编程中,用于自动选择最优的同步模式和参数。例如,通过机器学习算法分析程序的运行状态和性能数据,动态调整同步策略,以提高程序的整体性能。
总结与展望
本文详细介绍了并发编程中的多种同步模式,包括等待模式(通知和轮询)、无锁同步模式(原子计数、原子索引和发布协议),并探讨了这些模式的综合应用和性能考量。同时,对同步模式的未来发展趋势进行了展望。
在实际的并发编程中,我们需要根据具体的业务需求和系统环境,选择合适的同步模式,并综合运用多种模式来实现高效、安全的并发操作。随着技术的不断发展,我们相信会有更多更优秀的同步模式和工具出现,为并发编程带来更多的可能性。
以下是一个流程图,展示了多生产者多消费者场景中综合同步模式的基本流程:
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(生产者生产数据):::process
B --> C(生产者使用原子索引存储数据):::process
C --> D(生产者发布数据):::process
D --> E{是否有新数据}:::decision
E -->|否| E
E -->|是| F(消费者等待通知):::process
F --> G(消费者获取数据):::process
G --> H(消费者处理数据):::process
H --> I{是否所有数据处理完毕}:::decision
I -->|否| E
I -->|是| J([结束]):::startend
为了进一步对比不同同步模式在综合应用中的性能表现,我们可以列出以下表格:
| 综合应用场景 | 适用同步模式组合 | 性能优势 | 潜在问题 |
| ---- | ---- | ---- | ---- |
| 多生产者多消费者简单数据处理 | 原子索引 + 发布协议 | 无锁同步,提高并发性能 | 数据一致性需要严格控制 |
| 多生产者多消费者复杂数据处理 | 原子索引 + 发布协议 + 条件变量 | 灵活控制线程等待和唤醒,提高效率 | 实现复杂度高,调试困难 |
| 高并发计数场景 | 原子计数 + 通知模式 | 高效计数,避免锁竞争 | 计数逻辑复杂时难以扩展 |
超级会员免费看
170万+

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



