C++并发模式解析
1. 并发数据结构细节
在并发编程中,处理数据结构时存在一些微妙的细节。例如,读取队列的
back_
和
front_
并非原子操作。如果生产者先读取
front_
,在读取
back_
并比较两者时,消费者可能已经推进了
front_
。这虽不会使数据结构出错,但可能导致生产者误报队列为满。若采用原子操作读取这两个值,反而会降低性能,且调用者仍需处理队列满的情况。同理,当
pop()
报告队列为空时,实际情况可能已改变。这体现了并发编程的复杂性,每个操作仅反映某一时刻的数据状态。
此外,队列元素的生命周期管理也很重要。数组中的元素默认构造,在
push()
操作时,应通过复制或移动赋值将数据从调用者传输到队列中。而在
pop()
操作返回值后,不再需要该值,此时应使用移动操作,先将其移动到
optional
中,再移动到调用者的返回值对象。需注意,移动对象并非销毁对象,被移动的数组元素直到队列本身被销毁才会被销毁。若数组元素被重用,可通过复制或移动赋值赋予新值,赋值操作是对被移动对象安全的三种操作中的两种,第三种是析构函数。
单生产者单消费者模式是一种常见模式,可简化并发数据结构。还有其他模式,它们旨在帮助编写能被多线程正确高效访问的数据结构。
2. 并发执行模式概述
并发执行模式用于组织多线程的计算。这些模式多为低级模式,实际问题的解决方案通常需将它们组合成更复杂的设计。这并非因为 C++ 不适合大型设计,相反,C++ 实现线程池等有多种方式,针对不同应用有不同的理想版本。由于问题常见但解决方案差异大,难以将完整解决方案描述为模式,但可将基本挑战及其通用解决方案描述为设计模式。
3. 主动对象模式
主动对象通常封装要执行的代码、执行所需的数据以及异步执行代码的控制流。控制流可以是对象启动并加入的单独线程,多数情况下,会使用多线程执行器(如线程池)运行代码。从调用者角度看,主动对象是调用者构造、用数据初始化后,可让其异步执行的对象。
3.1 基本主动对象示例
// Example 22
class Job {
... data ...
std::thread t_;
bool done_ {};
public:
Job(... args ...) { ... initialize data ... }
void operator()() {
t_ = std::thread([this](){ ... computations ... });
}
void wait() {
if (done_) return;
t_.join();
done_ = true;
}
~Job() { wait(); }
auto get() { this->wait(); return ... results ...; }
};
Job j(... args ...);
j(); // Execute code on a thread
... do other work ...
std::cout << j.get(); // Wait for results and print them
在这个简单示例中,主动对象包含一个用于异步执行代码的线程。实际应用中,通常使用执行器调度工作。执行从调用
operator()
开始,也可在构造对象时调用
operator()
使其立即执行。需要等待结果时,若使用单独线程,可通过
join()
操作等待线程结束,并避免重复
join()
。
3.2 基于继承实现主动对象框架
// Example 23
class Job {
std::thread t_;
bool done_ {};
virtual void operator()() = 0;
public:
void wait() {
if (done_) return;
t_.join();
done_ = true;
}
void run() {
t_ = std::thread([this](){ (*this)(); });
}
virtual ~Job() { wait(); }
};
class TheJob final : public Job {
... data ...
void operator()() override { ... work ... }
public:
TheJob(... args ...) {
... initialize data ...
this->run();
}
auto get() { this->wait(); return ... results ...; }
};
基类
Job
包含实现异步控制流所需的线程和状态标志,通过调用非虚函数
run()
定义执行代码的方式。派生类需重写
operator()
提供要执行的代码。在派生类构造函数末尾调用
run()
时需注意,若在基类构造函数中启动线程和异步执行,会导致线程执行
operator()
与派生类构造函数中的初始化操作产生竞争。因此,从构造函数开始执行的主动对象不应再被派生,可通过
final
关键字确保。
3.3 基于类型擦除实现主动对象框架
// Example 24
class Job {
bool done_ {};
std::function<void()> f_;
std::thread t_;
public:
template <typename F> explicit Job(F&& f) :
f_(f), t_(f_) {}
void wait() {
if (done_) return;
t_.join();
done_ = true;
}
~Job() { wait(); }
};
class TheJob {
... data ...
public:
TheJob(... args ...) { ... initialize data ... }
void operator()() { // Callable!
... do the work ...
}
};
Job j(TheJob(... args ...));
j.wait();
使用
std::function
实现类型擦除的可调用对象。调用者提供的要在线程上执行的代码来自构造函数参数中的可调用对象
f
。类成员的顺序很重要,异步执行在线程
t_
初始化时开始,因此其他数据成员(特别是可调用对象
f_
)必须在此之前初始化。在这种设计中,若
TheJob
不是作为命名对象创建,访问其数据成员并不容易,结果通常通过传递给构造函数的引用参数返回。
4. 反应器对象模式
反应器模式常用于事件处理或响应服务请求。它解决了多线程发出多个特定操作请求,但部分操作必须在一个线程上执行或同步的问题。反应器对象接收多个线程的请求并执行。
以下是一个反应器示例,可接受执行特定计算的请求,使用调用者提供的输入并存储结果:
// Example 25
class Reactor {
static constexpr size_t N = 1024;
Data data_[N] {};
std::atomic<size_t> size_{0};
public:
bool operator()(... args ...) {
const size_t s =
size_.fetch_add(1, std::memory_order_acq_rel);
if (s >= N) return false; // Array is full
data_[s] = ... result ...;
return true;
}
void print_results() { ... }
};
对
operator()
的调用是线程安全的,多个线程可同时调用该操作符,每个调用会将计算结果添加到数组的下一个插槽中,不会覆盖其他调用产生的数据。要从对象中检索结果,可等待所有请求完成,或实现同步机制(如发布协议)使
operator()
和
print_results()
的调用相互线程安全。通常,反应器对象异步处理请求,可通过组合之前的模式(如添加线程安全队列)构建异步反应器。
5. 前摄器对象模式
前摄器模式用于根据一个或多个线程的请求执行异步任务(通常是长时间运行的任务)。它与反应器模式类似,但区别在于任务完成时的处理。在反应器模式中,只需等待工作完成,而前摄器对象为每个任务关联一个回调,任务完成时异步执行回调。反应器和前摄器是处理并发任务完成问题的同步和异步解决方案。
以下是一个使用线程安全队列的前摄器对象示例:
// Example 26
class Proactor {
using callback_t = std::function<void(size_t, double)>;
struct op_task {
size_t n;
callback_t f;
};
std::atomic<bool> done_{false}; // Must come before t_
ts_queue<op_task> q_; // Must come before t_
std::thread t_;
public:
Proactor() : t_([this]() {
while (true) {
auto task = q_.pop();
if (!task) { // Queue is empty
if (done_.load(std::memory_order_relaxed)) {
return; // Work is done
}
continue; // Wait for more work
}
... do the work ...
double x = ... result ...
task->f(n, x);
} // while (true)
}) {}
template <typename F>
void operator()(size_t n, F&& f) {
q_.push(op_task{n, std::forward<F>(f)});
}
~Proactor() {
done_.store(true, std::memory_order_relaxed);
t_.join();
}
};
Proactor p;
for (size_t n : ... all inputs ...) {
p(n, [](double x) { std::cout << x << std::endl; });
}
队列存储工作请求,包含输入和可调用对象,多个线程可调用
operator()
向队列添加请求。前摄器在单个线程上按顺序执行所有请求,任务完成后,线程调用回调并传递结果。需注意,此示例中的前摄器在一个线程上执行所有回调,主线程不进行输出,否则需用互斥锁保护
std::cout
。
6. 监视器模式
监视器模式用于观察某些条件并响应特定事件。通常,监视器运行在自己的线程上,大部分时间处于睡眠或等待状态,可通过通知或时间流逝唤醒。唤醒后,监视器对象检查要观察的系统状态,若满足指定条件则采取相应行动,然后线程继续等待。
以下是一个使用超时的监视器实现示例:
// Example 27
static constexpr size_t N = 1UL << 16;
struct Data {... data ... };
Data data[N] {};
std::atomic<size_t> index(0);
void produce(std::atomic<size_t>& count) {
for (size_t n = 0; ; ++n) {
const size_t s =
index.fetch_add(1, std::memory_order_acq_rel);
if (s >= N) return;
const int niter = 1 << (8 + data[s].n);
data[s] = ... result ...
count.store(n + 1, std::memory_order_relaxed);
}
}
std::thread t[nthread];
std::atomic<size_t> work_count[nthread] = {};
for (size_t i = 0; i != nthread; ++i) {
t[i] = std::thread(produce, std::ref(work_count[i]));
}
std::atomic<bool> done {false};
std::thread monitor([&]() {
auto print = [&]() { ... print work_count[] ... };
std::cout << "work counts:" << std::endl;
while (!done.load(std::memory_order_relaxed)) {
std::this_thread::sleep_for(
std::chrono::duration<double, std::milli>(500));
print();
}
print();
});
生产者线程进行计算并将结果存储在数组中,同时将计算结果的数量存储在
work_count
变量中。监视器线程会定期唤醒,读取结果计数并报告工作进度。要关闭监视器,需设置
done
标志并加入监视器线程。另一种常见的监视器模式变体是等待条件而非定时器,可结合基本监视器和等待通知模式实现。
并发编程社区还有许多其他解决并发问题的模式,C++ 有特定特性(如原子变量)会影响这些模式的实现和使用。通过这些示例,可将其他并发模式应用到 C++ 中。
并发模式总结与流程梳理
1. 并发模式总结
| 模式名称 | 特点 | 应用场景 |
|---|---|---|
| 单生产者单消费者模式 | 简化并发数据结构,适用于单个生产者和单个消费者的场景 | 数据传输、任务队列等 |
| 主动对象模式 | 封装代码、数据和控制流,实现异步执行 | 多线程任务调度 |
| 反应器对象模式 | 接收多线程请求并执行,解决部分操作需同步的问题 | 事件处理、服务请求响应 |
| 前摄器对象模式 | 为异步任务关联回调,任务完成时异步执行回调 | 长时间运行的异步任务处理 |
| 监视器模式 | 观察条件并响应事件,运行在单独线程上 | 系统状态监控、工作进度报告 |
2. 主动对象模式流程
graph TD;
A[创建主动对象] --> B[初始化数据];
B --> C[调用执行操作符];
C --> D[启动线程执行代码];
D --> E[执行其他工作];
E --> F[等待结果];
F --> G[获取并处理结果];
3. 反应器对象模式流程
graph TD;
A[多个线程发出请求] --> B[反应器接收请求];
B --> C[分配数组插槽];
C --> D[执行计算];
D --> E[存储结果];
E --> F[检索结果];
4. 前摄器对象模式流程
graph TD;
A[线程提交任务和回调] --> B[前摄器接收任务];
B --> C[任务入队];
C --> D[线程从队列取出任务];
D --> E[执行任务];
E --> F[任务完成];
F --> G[执行回调];
5. 监视器模式流程
graph TD;
A[生产者线程执行任务] --> B[更新工作计数];
C[监视器线程启动] --> D[定期唤醒];
D --> E[读取工作计数];
E --> F[报告工作进度];
F --> G[继续等待或结束];
通过这些模式和流程,我们可以更好地理解和应用 C++ 并发编程,提高程序的性能和可靠性。在实际应用中,根据具体需求选择合适的模式,并结合 C++ 的特性进行优化,能够有效地解决并发编程中的各种问题。
C++并发模式解析
7. 不同并发模式的对比与选择
不同的并发模式适用于不同的场景,了解它们的特点和适用范围有助于我们在实际编程中做出正确的选择。以下是对几种常见并发模式的对比分析:
| 模式名称 | 同步方式 | 任务执行 | 适用场景 | 优缺点 |
|---|---|---|---|---|
| 主动对象模式 | 线程或执行器异步执行 | 独立线程或线程池 | 多线程任务调度,需要封装代码和数据 | 优点:封装性好,代码结构清晰;缺点:可能创建过多线程,资源消耗大 |
| 反应器对象模式 | 部分操作同步执行 | 单线程或多线程调度 | 事件处理、服务请求响应,部分操作需同步 | 优点:线程安全,可处理多线程请求;缺点:同步操作可能成为性能瓶颈 |
| 前摄器对象模式 | 异步回调 | 单线程顺序执行 | 长时间运行的异步任务处理,需要任务完成后回调 | 优点:异步处理,提高效率;缺点:回调管理复杂,可能出现竞争条件 |
| 监视器模式 | 定期检查或条件通知 | 独立线程 | 系统状态监控、工作进度报告 | 优点:实时监控系统状态;缺点:资源消耗,可能影响系统性能 |
在选择并发模式时,我们可以按照以下步骤进行:
1.
明确需求
:确定是需要任务调度、事件处理、异步回调还是系统监控等。
2.
分析并发情况
:考虑任务是否需要同步执行,是否有多个线程同时访问共享资源。
3.
评估性能要求
:根据系统的性能要求,选择合适的模式,避免资源浪费或性能瓶颈。
4.
考虑代码复杂度
:不同模式的代码复杂度不同,选择易于实现和维护的模式。
8. 并发模式的组合与扩展
在实际应用中,单一的并发模式可能无法满足复杂的需求,我们需要将多种模式组合使用,或者对现有模式进行扩展。
8.1 组合模式示例
例如,我们可以将主动对象模式和反应器对象模式组合使用。主动对象负责封装任务的代码和数据,反应器对象负责接收多个主动对象的请求并进行调度。以下是一个简单的示例:
// 主动对象类
class ActiveJob {
std::function<void()> task;
public:
ActiveJob(std::function<void()> t) : task(t) {}
void execute() {
task();
}
};
// 反应器类
class Reactor {
std::vector<ActiveJob> jobs;
public:
void addJob(ActiveJob job) {
jobs.push_back(job);
}
void run() {
for (auto& job : jobs) {
job.execute();
}
}
};
// 使用示例
int main() {
Reactor r;
ActiveJob job1([]() { std::cout << "Job 1 executed" << std::endl; });
ActiveJob job2([]() { std::cout << "Job 2 executed" << std::endl; });
r.addJob(job1);
r.addJob(job2);
r.run();
return 0;
}
8.2 扩展模式示例
我们还可以对前摄器对象模式进行扩展,使其支持多个线程并行执行任务。例如,我们可以将任务队列改为多个队列,每个队列由一个线程处理,提高并发性能。
#include <vector>
#include <thread>
#include <atomic>
#include <queue>
#include <functional>
template<typename T>
class ThreadSafeQueue {
std::queue<T> queue_;
std::mutex mutex_;
public:
void push(T value) {
std::lock_guard<std::mutex> lock(mutex_);
queue_.push(value);
}
bool pop(T& value) {
std::lock_guard<std::mutex> lock(mutex_);
if (queue_.empty()) {
return false;
}
value = queue_.front();
queue_.pop();
return true;
}
};
class ExtendedProactor {
using callback_t = std::function<void(size_t, double)>;
struct op_task {
size_t n;
callback_t f;
};
std::atomic<bool> done_{false};
std::vector<ThreadSafeQueue<op_task>> queues_;
std::vector<std::thread> threads_;
public:
ExtendedProactor(size_t numThreads) {
queues_.resize(numThreads);
for (size_t i = 0; i < numThreads; ++i) {
threads_.emplace_back([this, i]() {
while (true) {
op_task task;
if (!queues_[i].pop(task)) {
if (done_.load(std::memory_order_relaxed)) {
return;
}
continue;
}
// 执行任务
double result = task.n * 2.0;
task.f(task.n, result);
}
});
}
}
template <typename F>
void operator()(size_t n, F&& f) {
size_t queueIndex = n % queues_.size();
queues_[queueIndex].push(op_task{n, std::forward<F>(f)});
}
~ExtendedProactor() {
done_.store(true, std::memory_order_relaxed);
for (auto& thread : threads_) {
thread.join();
}
}
};
// 使用示例
int main() {
ExtendedProactor p(2);
for (size_t n = 0; n < 10; ++n) {
p(n, [](size_t n, double x) { std::cout << "Task " << n << " result: " << x << std::endl; });
}
return 0;
}
9. 并发模式的性能优化
在使用并发模式时,性能优化是一个重要的考虑因素。以下是一些常见的性能优化方法:
9.1 减少锁竞争
锁竞争是并发编程中常见的性能瓶颈,我们可以通过以下方法减少锁竞争:
-
使用细粒度锁
:将大锁拆分成多个小锁,减少锁的持有时间。
-
无锁数据结构
:使用原子操作和无锁算法实现数据结构,避免锁的使用。
-
锁粒度调整
:根据实际情况调整锁的粒度,避免过度同步。
9.2 线程池优化
线程池是并发编程中常用的工具,我们可以通过以下方法优化线程池:
-
合理设置线程数量
:根据系统的 CPU 核心数和任务特点,合理设置线程池的线程数量。
-
任务调度优化
:采用合适的任务调度算法,提高线程池的利用率。
-
线程复用
:避免频繁创建和销毁线程,提高线程的复用率。
9.3 异步操作优化
异步操作可以提高程序的并发性能,我们可以通过以下方法优化异步操作:
-
回调管理
:合理管理回调函数,避免回调地狱和竞争条件。
-
异步 I/O
:使用异步 I/O 操作,减少线程的阻塞时间。
-
并发控制
:控制异步操作的并发度,避免资源耗尽。
10. 未来趋势与展望
随着计算机技术的不断发展,并发编程也在不断演进。未来,我们可能会看到以下趋势:
- 更高级的并发库 :C++ 标准库可能会提供更高级的并发库,简化并发编程的复杂度。
- 硬件支持 :硬件厂商可能会提供更多的并发支持,如多核处理器、GPU 等,提高并发性能。
- 人工智能与并发 :人工智能和机器学习的发展需要大量的并发计算,并发模式将在这些领域得到更广泛的应用。
总之,掌握并发模式是 C++ 程序员的必备技能之一。通过了解不同的并发模式,我们可以更好地应对并发编程中的挑战,提高程序的性能和可靠性。在实际应用中,我们需要根据具体需求选择合适的模式,并进行性能优化,以满足不断增长的业务需求。同时,关注并发编程的未来趋势,不断学习和探索新的技术,将有助于我们在这个领域保持领先地位。
超级会员免费看
30

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



