C++设计模式:访问者模式与并发编程模式解析
1. 访问者模式概述
访问者模式是一种在C++中非常实用的设计模式。经典的面向对象访问者模式允许我们在不改变类的源代码的情况下,为整个类层次结构有效地添加新的虚函数。要使用该模式,类层次结构必须是可访问的,之后可以添加任意数量的操作,并且这些操作的实现与对象本身是分离的。
在经典访问者模式的实现中,包含被访问层次结构的源代码不需要更改,但当向层次结构中添加新类时,需要重新编译。而无环访问者模式解决了这个问题,但代价是需要额外的动态类型转换。此外,无环访问者模式还支持部分访问,即可以忽略某些访问者/可访问对象的组合,而经典访问者模式要求所有组合至少要被声明。
对于所有的访问者变体,为了实现可扩展性,需要牺牲一定的封装性,并且通常需要允许外部访问者类访问本应是私有数据成员的内容。
访问者模式经常与其他设计模式结合使用,特别是组合模式,以创建复杂的可访问对象。组合对象将访问委托给其包含的对象。这种组合模式在需要将对象分解为最小构建块时特别有用,例如在序列化时。
经典访问者模式在运行时实现双重分派,即在执行过程中,程序根据访问者和可访问对象的类型这两个因素来选择要运行的代码。该模式也可以在编译时使用,提供有限的反射能力。在C++17中,
std::visit
可以将访问者模式扩展到不属于同一层次结构的类型,甚至可以实现多重分派。
以下是一个简单的示例代码,展示了如何使用访问者模式:
// 假设存在 Cat 和 Dog 类
class Cat;
class Dog;
struct Visitor {
void operator()(const Cat& c) {
std::cout << " cat" << std::endl;
}
void operator()(const Cat& c, const Dog& d) {
(*this)(d, c);
}
void operator()(const Dog& d1, const Dog& d2) {
std::cout << "Take the " << d1.color() << " and the "
<< d2.color() << " dogs for a walk"
<< std::endl;
}
};
2. 并发编程概述
C++与并发编程有着复杂的关系。一方面,C++是一种注重性能的语言,而并发编程几乎总是用于提高性能,因此两者是天然的搭配。实际上,从C++语言诞生之初就开始用于开发并发程序。另一方面,对于一种经常用于编写并发程序的语言来说,C++直接满足并发编程需求的构造和特性却出奇地少。这些需求大多通过社区开发的各种库以及特定于应用程序的解决方案来满足。
在开发并发程序时,大致会遇到三种类型的挑战:
- 如何确保即使多个线程并发操作相同数据时,程序仍然正确?
- 如何在多个线程上执行程序的工作以提高整体性能?
- 如何设计软件,使其在并发的复杂性下,仍然能够进行推理、理解其功能并进行维护?
其中,第一个挑战主要涉及数据共享和同步。我们首先来探讨相关的模式,因为程序首先必须是正确的,一个崩溃或产生不可信结果的程序,即使性能再好也是无用的。
3. 同步模式
同步模式的主要目的是确保多个线程对共享数据的正确操作。对于绝大多数并发程序来说,这些模式至关重要。只有那些执行几个完全独立任务,不涉及任何公共数据(可能除了读取共享且不可变的输入)并产生独立结果的程序,才不需要同步。对于其他程序,都需要管理一些共享状态,这就使我们面临可怕的数据竞争风险。
从形式上讲,C++标准规定,如果多个线程并发访问同一个对象(同一内存位置),而没有适当的同步机制来保证每个线程的独占访问,就会导致未定义行为。准确地说,如果至少有一个线程可以修改共享数据,那么行为就是未定义的;如果数据从未被任何线程更改,那么就不存在数据竞争的可能性。有一些设计模式利用了这个漏洞,但我们先从最广为人知的同步模式开始。
4. 互斥锁和锁定模式
如果说编写并发程序有一个必备工具,那就是互斥锁(mutex)。互斥锁用于保证多个线程对共享数据的独占访问:
std::mutex m;
MyData data;
// On several threads:
m.lock();
transmogrify(data);
m.unlock();
数据修改操作
transmogrify()
必须保证对共享数据的独占访问,即任何时候只能有一个线程执行该操作。程序员使用互斥锁(互斥的缩写)来确保这一点:任何时候只有一个线程可以锁定互斥锁并进入临界区(
lock()
和
unlock()
之间的代码)。
使用互斥锁足以确保对共享数据的正确访问,但这并不是一个好的设计。首先,它容易出错:如果
transmogrify()
抛出异常,或者程序员添加了返回值检查并提前退出临界区,那么最终的
unlock()
永远不会执行,互斥锁将永远保持锁定状态,从而阻止其他线程访问数据。
这个问题可以通过应用我们在之前已经见过的C++通用模式——RAII(资源获取即初始化)来轻松解决。我们只需要一个对象来锁定和解锁互斥锁,而C++标准库已经提供了这样一个对象
std::lock_guard
:
// Example 01
std::mutex m;
int i = 0;
void add() {
std::lock_guard<std::mutex> l(m);
++i;
}
//...
std::thread t1(add);
std::thread t2(add);
t1.join();
t2.join();
std::cout << i << std::endl;
函数
add()
修改共享变量
i
,因此需要独占访问,这通过使用互斥锁
m
来提供。需要注意的是,如果不使用互斥锁运行这个示例,很可能仍然会得到正确的结果,因为其中一个线程可能会在另一个线程之前执行。但有时程序会失败,而且更常见的是不容易调试。可以使用线程消毒剂(TSAN)来查看竞争条件。如果使用GCC或Clang,添加
--sanitize=address
来启用它。从
add()
中移除互斥锁(示例02),使用TSAN编译并运行程序,会看到如下警告:
WARNING: ThreadSanitizer: data race
...
Location is global 'i' of size 4 at <address>
这比等待程序失败来测试数据竞争要可靠得多。
在C++17中,使用
std::lock_guard
稍微简单一些,因为编译器会从构造函数中推断出模板参数:
// Example 03
std::lock_guard l(m);
在C++20中,可以使用
std::jthread
而不是显式调用
join()
:
// Example 03
{
std::jthread t1(add);
std::jthread t2(add);
}
std::cout << i << std::endl;
需要注意的是,在使用计算结果之前,必须小心地销毁线程,因为析构函数现在会加入线程并等待计算完成。否则,会出现另一个数据竞争:主线程在
i
被递增时读取其值(TSAN也会发现这个竞争)。
使用RAII可以确保每次锁定互斥锁时都会解锁,但这并不能避免使用互斥锁时可能出现的其他错误。最常见的错误是一开始就忘记使用互斥锁。同步保证仅在每个线程使用相同的机制来确保对数据的独占访问时才适用。如果即使只有一个线程不使用互斥锁,即使只是读取数据,整个程序也是不正确的。
为了防止对共享数据的不同步访问,开发了一种模式,通常称为“互斥锁保护”模式。该模式有两个关键要素:首先,需要保护的数据和用于保护它的互斥锁组合在同一个对象中;其次,设计确保对数据的每次访问都受到互斥锁的保护。以下是基本的互斥锁保护类模板:
// Example 04
template <typename T> class MutexGuarded {
std::mutex m_;
T data_ {};
public:
MutexGuarded() = default;
template <typename... Args>
explicit MutexGuarded(Args&&... args) :
data_(std::forward<Args>(args)...) {}
template <typename F> decltype(auto) operator()(F f) {
std::lock_guard<std::mutex> l(m_);
return f(data_);
}
};
可以看到,这个模板将互斥锁和受其保护的数据组合在一起,并且只提供一种访问数据的方式:通过使用任意可调用对象调用
MutexGuarded
对象。这确保了所有数据访问都是同步的:
// Example 04
MutexGuarded<int> i_guarded(0);
void add() {
i_guarded([](int& i) { ++i; });
}
//...
// On many threads:
std::thread t1(add);
std::thread t2(add);
t1.join();
t2.join();
i_guarded([](int i) { std::cout << i << std::endl; });
这些是正确和可靠使用互斥锁的最基本模式。在实践中,需求通常更复杂,解决方案也更复杂。例如,有比
std::mutex
更高效的锁(如用于保护短计算的自旋锁),还有更复杂的锁,如用于高效读写访问的共享和独占锁。此外,通常需要同时操作多个共享对象,这会导致安全锁定多个互斥锁的问题。
以下是一个简单的流程图,展示了使用互斥锁的基本流程:
graph TD;
A[线程开始] --> B[锁定互斥锁];
B --> C[执行临界区代码];
C --> D[解锁互斥锁];
D --> E[线程结束];
5. 线程特定数据模式
虽然使用互斥锁保护共享数据看起来并不复杂,但实际上,数据竞争是任何并发程序中最常见的错误。虽然说不共享数据就不会有数据竞争似乎是一个无用的常识,但不共享数据是一种经常被忽视的替代方案。换句话说,通常可以重新设计程序,避免共享某些变量,或者将对共享数据的访问限制在代码的较小部分。
这种想法是线程特定数据模式的基础,该模式也称为“线程本地数据”,但这个名称容易与C++的
thread_local
关键字混淆。为了说明这个想法,我们考虑一个例子:需要统计可能在多个线程中同时发生的某些事件(在这个示例中,统计的具体内容并不重要)。我们需要整个程序中这些事件的总计数,所以直接的方法是使用一个共享计数,并在线程检测到事件时递增它(在示例中,我们统计能被10整除的随机数):
// Example 05
MutexGuarded<size_t> count;
void events(unsigned int s) {
for (size_t i = 1; i != 100; ++i) {
if ((rand_r(&s) % 10) == 0) { // Event!
count([](size_t& i) { ++i; });
}
}
}
这是一个直接的设计,但不是最好的。注意,虽然每个线程都在统计事件,但它不需要知道其他线程统计了多少事件。这与在我们的实现中每个线程需要知道当前计数的值以便正确递增它这一事实不同。这种区别很微妙但很重要,它暗示了一种替代方案:每个线程可以使用自己的线程特定计数来统计事件。
以下是几种不同的实现方式:
-
使用局部计数并在退出线程前更新共享计数
:
// Example 06
MutexGuarded<size_t> count;
void events(unsigned int s) {
size_t n = 0;
for (size_t i = 1; i != 100; ++i) {
if ((rand_r(&s) % 10) == 0) { // Event!
++n;
}
}
if (n > 0) count([n](size_t& i) { i += n; });
}
在函数中声明的任何局部(栈分配)变量对于每个线程都是特定的,即每个线程在其栈上都有该变量的唯一副本,当它们都引用相同的名称
n
时,每个线程访问的是自己的变量。
-
为每个线程提供唯一的计数变量并在主线程中汇总
:
// Example 07
void events(unsigned int s, size_t& n) {
for (size_t i = 1; i != 100; ++i) {
if ((rand_r(&s) % 10) == 0) ++n;
}
}
alignas(64) size_t n1 = 0;
alignas(64) size_t n2 = 0;
std::thread t1(events, 1, std::ref(n1));
std::thread t2(events, 2, std::ref(n2));
t1.join();
t2.join();
size_t count = n1 + n2;
alignas
属性确保每个计数变量按64字节对齐,从而确保
n1
和
n2
的地址之间至少有64字节的差异(64是大多数现代CPU(包括X86和ARM)的缓存行大小)。注意,
std::thread
调用使用引用参数的函数时需要
std::ref
包装器。
前一个示例将每个线程对共享数据的访问需求减少到一次,而最后一个示例根本没有共享数据,首选的解决方案取决于何时需要总计数的值。
我们还可以从另一个角度来看待最后一个示例,将线程特定的计数视为同一数据结构的一部分,而不是为每个线程创建的独立变量。这种思维方式引出了线程特定数据模式的另一个变体:有时,多个线程必须操作相同的数据,但可以对数据进行分区,为每个线程分配自己的子集进行处理。
例如,我们需要对向量中的每个元素进行钳位操作(如果元素超过最大值,则将其替换为该值,因此结果始终在零和最大值之间)。计算由以下模板算法实现:
// Example 09
template <typename IT, typename T>
void clamp(IT from, IT to, T value) {
for (IT it = from; it != to; ++it) {
if (*it > value) *it = value;
}
}
clamp()
函数可以应用于任何序列,有时我们可能会幸运地有独立的不相关数据结构,可以在多个线程上独立处理。但假设我们只有一个向量需要钳位,仍然可以在多个线程上处理其不重叠的部分,而不会有数据竞争的风险:
// Example 09
std::vector<int> data = ... data ...;
std::thread t1([&](){
clamp(data.begin(), data.begin() + data.size()/2, 42);
});
std::thread t2([&](){
clamp(data.begin() + data.size()/2, data.end(), 42);
});
//...
t1.join();
t2.join();
尽管程序中的数据结构在两个线程之间共享,并且两个线程都对其进行修改,但这个程序是正确的,因为对于向量的每个元素,只有一个线程可以修改它。需要注意的是,向量对象本身虽然被所有线程共享,但所有线程只是读取向量的大小和其他数据成员,而不会更改它们,这属于允许的无同步数据共享情况(只要没有线程修改共享数据)。
应用线程特定数据模式必须仔细考虑,通常需要对数据结构有很好的理解。必须确保没有线程尝试修改它们共享的变量,例如向量对象本身的大小和数据指针。例如,如果其中一个线程可以调整向量的大小,即使没有两个线程访问相同的元素,也会发生数据竞争,因为向量的大小是一个被一个或多个线程修改而没有锁定的变量。
6. 线程特定数据副本模式
当多个线程需要修改整个数据集(因此无法进行分区),但线程不需要看到其他线程所做的修改时,有时最好的方法是为每个线程创建数据的线程特定副本。这种模式在副本是“一次性”对象时效果最佳,即每个线程需要修改其副本,但修改结果不需要提交回原始数据结构。
例如,我们使用一个算法来统计向量中唯一元素的数量,该算法会对向量进行原地排序:
// Example 10
void count_unique(std::vector<int> data, size_t& count) {
std::sort(data.begin(), data.end());
count = std::unique(data.begin(),
data.end()) - data.begin();
}
当我们只需要统计满足某个谓词的元素时,首先会擦除所有其他元素(
std::erase_if
是C++20的新增功能,但在早期版本的C++中也很容易实现):
// Example 10
void count_unique_even(std::vector<int> data, size_t& count) {
std::erase_if(data, [](int i) { return i & 1; });
std::sort(data.begin(), data.end());
count = std::unique(data.begin(),
data.end()) - data.begin();
}
这两个操作都会对向量进行破坏性修改,但它们只是达到目的的手段,一旦得到计数,修改后的向量就可以丢弃。在多个线程上同时计算这些计数的最简单、通常也是最有效的方法是为每个线程创建向量的线程特定副本。实际上,我们已经这样做了,因为两个计数函数都按值接受向量参数,因此会创建副本。通常,这可能是一个错误,但在我们的例子中,这是有意为之,允许两个函数同时对同一个向量进行操作:
// Example 10
std::vector<int> data = ...;
size_t unique_count = 0;
size_t unique_even_count = 0;
{
std::jthread t1(count_unique, data,
std::ref(unique_count));
std::jthread t2(count_unique_even, data,
std::ref(unique_even_count));
}
当然,仍然存在对原始数据的并发访问,并且没有使用锁,但这属于只读并发访问的例外情况,是安全的。
综上所述,在C++并发编程中,我们可以根据不同的场景选择合适的同步模式和数据处理方式,以避免数据竞争,提高程序的正确性和性能。同时,访问者模式在处理类层次结构的操作扩展方面也有着独特的优势,可以与并发编程模式结合使用,构建更加复杂和高效的系统。
C++设计模式:访问者模式与并发编程模式解析
7. 不同同步模式对比
为了更清晰地了解各种同步模式的特点和适用场景,我们可以通过一个表格进行对比:
| 同步模式 | 优点 | 缺点 | 适用场景 |
| — | — | — | — |
| 互斥锁和锁定模式 | 确保对共享数据的独占访问,实现相对简单 | 容易出错,如忘记解锁;性能开销较大 | 多个线程需要频繁访问共享数据,且需要严格的独占访问 |
| 线程特定数据模式 | 避免数据竞争,减少同步开销 | 需要重新设计程序,对数据结构理解要求高 | 可以对数据进行分区,线程不需要知道其他线程操作结果的场景 |
| 线程特定数据副本模式 | 避免线程间干扰,可并行处理 | 需要额外的内存来存储副本 | 线程需要修改整个数据集,但不需要看到其他线程修改结果的场景 |
8. 并发编程模式的综合应用
在实际的并发编程中,往往需要综合运用多种模式来解决复杂的问题。例如,在一个大型的数据分析系统中,可能会同时涉及到数据的共享、处理和汇总。
以下是一个简化的示例,展示如何综合运用上述模式:
#include <iostream>
#include <vector>
#include <thread>
#include <mutex>
#include <algorithm>
// 互斥锁保护类模板
template <typename T> class MutexGuarded {
std::mutex m_;
T data_ {};
public:
MutexGuarded() = default;
template <typename... Args>
explicit MutexGuarded(Args&&... args) :
data_(std::forward<Args>(args)...) {}
template <typename F> decltype(auto) operator()(F f) {
std::lock_guard<std::mutex> l(m_);
return f(data_);
}
};
// 线程特定数据模式示例:统计事件
MutexGuarded<size_t> total_count;
void events(unsigned int s) {
size_t local_count = 0;
for (size_t i = 1; i != 100; ++i) {
if ((rand_r(&s) % 10) == 0) { // Event!
++local_count;
}
}
total_count([local_count](size_t& i) { i += local_count; });
}
// 线程特定数据副本模式示例:统计唯一元素
void count_unique(std::vector<int> data, size_t& count) {
std::sort(data.begin(), data.end());
count = std::unique(data.begin(),
data.end()) - data.begin();
}
int main() {
// 线程特定数据模式
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(events, i);
}
for (auto& t : threads) {
t.join();
}
total_count([](size_t i) { std::cout << "Total events: " << i << std::endl; });
// 线程特定数据副本模式
std::vector<int> data = {1, 2, 3, 2, 4, 3, 5};
size_t unique_count = 0;
std::thread t(count_unique, data, std::ref(unique_count));
t.join();
std::cout << "Unique elements: " << unique_count << std::endl;
return 0;
}
在这个示例中,我们使用了互斥锁保护类模板来保护共享的总计数,同时使用线程特定数据模式来统计每个线程的局部事件计数,最后将局部计数汇总到总计数中。另外,使用线程特定数据副本模式来统计向量中唯一元素的数量,避免了线程间的干扰。
9. 访问者模式与并发编程的结合
访问者模式可以与并发编程模式结合使用,以实现更复杂的功能。例如,在一个并发的文件系统中,我们可以使用访问者模式来处理不同类型的文件,同时使用并发编程模式来提高处理效率。
以下是一个简单的示例,展示了如何结合访问者模式和并发编程:
#include <iostream>
#include <vector>
#include <thread>
#include <variant>
// 定义文件类型
struct TextFile {};
struct ImageFile {};
struct AudioFile {};
// 访问者类
struct FileVisitor {
void operator()(const TextFile&) {
std::cout << "Processing text file" << std::endl;
}
void operator()(const ImageFile&) {
std::cout << "Processing image file" << std::endl;
}
void operator()(const AudioFile&) {
std::cout << "Processing audio file" << std::endl;
}
};
// 处理文件的函数
void process_file(std::variant<TextFile, ImageFile, AudioFile> file) {
FileVisitor visitor;
std::visit(visitor, file);
}
int main() {
std::vector<std::variant<TextFile, ImageFile, AudioFile>> files = {
TextFile(), ImageFile(), AudioFile()
};
std::vector<std::thread> threads;
for (auto& file : files) {
threads.emplace_back(process_file, file);
}
for (auto& t : threads) {
t.join();
}
return 0;
}
在这个示例中,我们使用
std::variant
来表示不同类型的文件,使用访问者模式来处理这些文件。同时,使用多线程并发处理这些文件,提高了处理效率。
10. 总结与展望
通过对访问者模式和并发编程模式的学习,我们了解到这些模式在C++编程中的重要性和实用性。访问者模式可以帮助我们在不修改类层次结构的情况下扩展操作,而并发编程模式可以帮助我们提高程序的性能和处理能力。
在未来的编程中,随着计算机硬件的不断发展,多核处理器的普及,并发编程将变得越来越重要。同时,新的设计模式也将不断涌现,以应对更加复杂的编程挑战。我们需要不断学习和掌握这些模式,以开发出更加高效、可靠的软件系统。
以下是一个简单的流程图,展示了综合运用并发编程模式的基本流程:
graph TD;
A[程序开始] --> B[数据初始化];
B --> C[创建线程];
C --> D{线程操作类型};
D -- 线程特定数据模式 --> E[处理局部数据];
D -- 线程特定数据副本模式 --> F[处理数据副本];
D -- 互斥锁和锁定模式 --> G[访问共享数据];
E --> H[汇总局部数据];
F --> I[汇总处理结果];
G --> J[更新共享数据];
H --> K[检查是否完成];
I --> K;
J --> K;
K -- 是 --> L[输出结果];
K -- 否 --> C;
L --> M[程序结束];
通过合理运用这些模式,我们可以更好地应对并发编程中的挑战,提高程序的性能和可靠性。同时,不断探索和创新,将有助于我们发现更多有效的编程模式和方法。
超级会员免费看

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



