C++并发模式与协程应用
1. 协程概述
协程是C++20引入的新特性,目前主要作为构建库和框架的基础,而非直接应用于应用程序代码的特性。协程是一种可以暂停和恢复自身执行的函数,它不会被强制暂停,而是自行决定何时暂停。协程用于实现协作式多任务,多个执行流自愿地相互让出控制权,而非由操作系统强制抢占。
2. 协程生成器模式
在处理复杂循环的计算时,将其重写为迭代器是一种常见需求。以遍历3D数组为例,使用传统循环实现如下:
size_t*** a; // 3D array
for (size_t i = 0; i < N1; ++i) {
for (size_t j = 0; j < N2; ++j) {
for (size_t k = 0; k < N3; ++k) {
... do work with a[i][j][k] ...
}
}
}
但这种方式难以编写可复用的代码,若要定制对每个数组元素的操作,需修改内层循环。实现迭代器则需要将循环逻辑反转,代码变得复杂:
// Example 28
class Iterator {
const size_t N1, N2, N3;
size_t*** const a;
size_t i = 0, j = 0, k = 0;
bool done = false;
public:
Iterator(size_t*** a, size_t N1, size_t N2, size_t N3) :
N1(N1), N2(N2), N3(N3), a(a) {}
bool next(size_t& x) {
if (done) return false;
x = a[i][j][k];
if (++k == N3) {
k = 0;
if (++j == N2) {
j = 0;
if (++i == N1) return (done = true);
}
}
return true;
}
};
使用该迭代器的代码如下:
// Example 28
Iterator it(a, N1, N2, N3);
size_t val;
while (it.next(val)) {
... val is the current array element ...
}
而使用协程实现相同功能则简单自然:
// Example 28
generator<size_t>
coro(size_t*** a, size_t N1, size_t N2, size_t N3) {
for (size_t i = 0; i < N1; ++i) {
for (size_t j = 0; j < N2; ++j) {
for (size_t k = 0; k < N3; ++k) {
co_yield a[i][j][k];
}
}
}
}
co_yield 是C++20的关键字,它会暂停协程并将值返回给调用者,与 return 不同的是, co_yield 不会永久退出协程,调用者可以恢复协程,执行从 co_yield 的下一行继续。使用该协程的代码如下:
// Example 28
auto gen = coro(a, N1, N2, N3);
while (true) {
const size_t val = gen();
if (!gen) break;
... val is the current array element ...
}
3. 协程性能分析
虽然使用协程编写代码比迭代器更简单,但也有一定代价。协程通常比普通函数需要更多的工作,其性能很大程度上取决于编译器,并且代码的微小变化可能会导致性能差异。目前编译器对协程的优化还不完善,但协程的性能可以与手工编写的迭代器相媲美。以Clang 17和GCC 13为例,对遍历3D数组的迭代器和协程生成器进行性能测试,结果如下:
| 编译器 | 迭代器时间(s/迭代) | 生成器时间(s/迭代) |
| ---- | ---- | ---- |
| Clang 17 | 9.20286e-10 | 6.39555e-10 |
| GCC 13 | 6.46543e-10 | 1.99748e-09 |
4. 懒生成器
当需要生成的序列长度不确定,且只在需要时生成新元素时,协程生成器的另一种变体——懒生成器就很有用。以下是一个简单的随机数生成器的协程实现:
// Example 29
generator<size_t> coro(size_t i) {
while (true) {
constexpr size_t m = 1234567890, k = 987654321;
for (size_t j = 0; j != 11; ++j) {
if (1) i = (i + k) % m; else ++i;
}
co_yield i;
}
}
使用该生成器的代码如下:
// Example 29
auto gen = coro(42);
size_t random_number = gen();
每次调用 gen() 都会得到一个新的随机数。
5. 并发相关概念
- 并发 :程序允许多个任务同时或部分重叠执行的特性,通常通过多线程实现。
- C++对并发的支持 :C++11引入了线程、互斥锁和条件变量等基本并发支持,C++14和C++17添加了一些便利类和实用工具,C++20则引入了新的同步原语和协程。
- 同步模式 :解决访问共享数据基本问题的常见方案,通常提供对多线程修改或部分线程在修改时其他线程访问的数据进行独占访问的方法。
- 执行模式 :使用一个或多个线程安排某些计算异步执行的标准方式,提供启动代码执行并接收执行结果的方法,而调用者无需负责执行本身。
- 并发设计准则 :并发设计最重要的准则是模块化,即使用满足并发程序行为限制的组件构建并发软件,其中最重要的限制是线程安全保证。
- 事务接口 :为了在并发程序中有用,任何数据结构或组件必须提供事务接口。一个操作是事务,当它执行一个定义明确的完整计算并使系统处于定义明确的状态。
6. 其他C++设计模式
除了协程相关模式,C++还有许多其他设计模式,以下是部分模式的简要介绍:
- 继承与多态
- 公共继承表示对象之间的“is-a”关系,私有继承表示“has-a”或“通过…实现”的关系。
- 多态对象的行为取决于其类型,在C++中通过虚函数实现。
- 动态类型转换在运行时验证转换的目标类型是否有效。
- 类和函数模板
- 模板是生成具有相似结构的多种类型的工厂。
- 有类模板、函数模板和变量模板,每种模板生成相应的实体。
- 模板可以有类型和非类型参数。
- 模板实例化是模板生成的代码,有隐式实例化和显式实例化,显式特化是为特定类型提供的替代代码生成方式。
- 通常使用递归遍历参数包,C++17中也可以不使用递归操作整个参数包。
- lambda表达式是声明局部类的紧凑方式,用于将代码片段与变量关联。
- 概念对模板参数施加限制,可避免模板实例化错误和消除重载歧义。
- 内存和所有权
- 清晰的内存和资源所有权是良好设计的关键属性。
- 常见的资源问题包括资源泄漏、悬空句柄、多次释放同一资源和多次构造同一资源。
- 有非所有权、独占所有权、共享所有权,以及不同类型所有权之间的转换和转移。
- 所有权无关的函数和类应通过原始指针和引用引用对象,根据情况选择合适的方式。
- 独占内存所有权更易于理解和控制程序流程,效率也更高。
- 共享所有权通过共享指针表示,但在大型系统中管理困难,有性能开销,在并发程序中需要谨慎实现。
- 视图是非拥有的富指针,包含与相应拥有对象相同的信息。
- 交换操作
- 交换函数交换两个对象的状态,交换后对象除访问名称外保持不变。
- 交换通常用于提供提交或回滚语义的程序中。
- 使用交换提供提交或回滚语义假设交换操作本身不会抛出异常或失败。
- 应始终提供非成员交换函数,也可提供成员交换函数。
- STL容器和一些标准库类提供成员交换函数, std::swap() 函数模板有标准重载。
- 使用 std:: 限定符会禁用参数依赖查找,建议提供 std::swap 模板的显式实例化。
- RAII(资源获取即初始化)
- 资源可以是任何程序操作的虚拟或物理量,最常见的是内存。
- 资源不应泄漏,访问资源的句柄不应悬空,资源不再需要时应正确释放。
- RAII是C++主导的资源管理方法,资源由对象拥有,在构造函数中获取,在析构函数中释放。
- RAII对象应在栈上或作为其他对象的数据成员创建,程序离开包含RAII对象的作用域时,其析构函数会执行。
- 如果每个资源由RAII对象拥有且不给出原始句柄,只要对象存在,资源就不会释放。
- 常用的RAII对象有 std::unique_ptr 用于内存管理, std::lock_guard 用于管理互斥锁。
- 通常RAII对象不可复制,大多数不可移动,RAII处理释放失败有困难,因为析构函数不能抛出异常。
- 类型擦除
- 类型擦除是一种编程技术,程序不显示依赖所使用的某些类型,用于分离抽象行为和具体实现。
- 实现涉及多态对象和虚函数调用,或为擦除类型实现的函数并通过函数指针调用,通常与泛型编程结合。
- 程序可以避免显式提及大多数类型,类型由模板函数推导,但擦除的类型不会被对象类型捕获。
- 类型通过为该类型生成的函数具体化,通常第一步是将通用指针转换为擦除类型的指针。
- 类型擦除会带来一些性能开销,主要来自额外的间接调用和内存分配。
- SFINAE、概念和重载解析管理
- 重载集是从调用位置可访问的具有指定名称的所有函数的集合。
- 重载解析是根据参数和类型选择要调用的函数的过程。
- 模板函数和成员函数的类型推导根据函数参数类型确定模板参数类型。
- 类型替换可能导致无效类型,替换失败不会产生编译错误,而是将失败的重载从重载集中移除。
- 替换失败仅在函数声明中有效,函数体中的替换失败是硬错误。
- 如果每个重载返回不同类型,可以在编译时检查这些类型。
- 通过故意导致替换失败,可以引导重载解析选择特定的重载。
- C++20约束提供更自然、易理解的语法,错误消息更清晰,且不限于函数模板的替换参数。
- 标准不仅定义了概念和约束,还提供了思考模板限制的方式,限制SFINAE的使用可以使代码更易读和维护。
- CRTP(奇异递归模板模式)
- 虚函数调用比非虚函数调用开销大,因为虚函数通过函数指针调用,编译时不知道实际函数。
- 如果编译器知道要调用的具体函数,可以优化掉间接调用并内联函数。
- 静态多态调用也需要通过指向基类的指针或引用进行,在CRTP中,基类类型是由基类模板为每个派生类生成的一组类型。
- 当直接调用派生类时,CRTP成为一种实现技术,为多个派生类提供通用功能,每个派生类扩展和定制基类模板的接口。
- 使用多个CRTP基类时,将派生类声明为可变模板并继承整个参数包更方便。
- 命名参数、方法链和构建器模式
- 传统函数参数容易出错,添加新参数需要修改所有函数签名。
- 命名参数的聚合值有显式名称,添加新值不需要修改函数签名。
- 命名参数习惯允许使用临时聚合对象,通过方法设置参数值并进行链式调用。
- 方法级联将多个方法应用于同一对象,方法链通常每个方法返回新对象,链式方法通常返回原始对象的引用。
- 构建器模式使用单独的构建器对象构建复杂对象,当构造函数不足以或不易使用时使用。
- 流畅接口使用方法链呈现可在对象上执行的多个指令、命令或操作,流畅构建器用于将复杂对象构建拆分为多个小步骤。
- 局部缓冲区优化
- 微基准测试可单独测量小代码片段的性能,在程序上下文中测量需要使用性能分析器。
- 处理少量数据时,内存分配会增加与数据大小不成比例的常量开销,且可能使用全局锁。
- 局部缓冲区优化用对象本身的缓冲区代替外部内存分配,避免额外内存分配的成本和开销。
- 对象的构造和内存分配有一定成本,局部缓冲区优化增加对象大小,但通常不显著影响分配成本。
- 短字符串优化将字符串字符存储在字符串对象内部的局部缓冲区中,小向量优化将向量的一些元素存储在向量对象的局部缓冲区中。
- ScopeGuard
- 错误安全的程序在遇到错误时保持定义明确的状态,异常安全是一种特定的错误安全,假设错误通过抛出表达式信号。
- 如果多个操作需要保持一致状态,后续操作失败时需要撤销先前操作,最终提交操作和回滚操作不能失败。
- RAII类确保程序离开作用域时执行特定操作,关闭操作不会被跳过或绕过。
- 经典RAII每个操作需要一个特殊类,ScopeGuard可以从任意代码片段自动生成RAII类。
- 如果通过错误码返回状态,无法检测异常;如果所有错误通过异常信号且函数返回表示成功,可以在运行时检测是否抛出异常,但需要考虑栈展开的情况。
- ScopeGuard类通常是模板实例化,具体类型难以明确指定,类型擦除的ScopeGuard是具体类型,但需要运行时多态和内存分配。
- 友元工厂
- 非成员友元函数对类的成员有与成员函数相同的访问权限。
- 授予模板友元关系会使该模板的每个实例化都是友元。
- 作为成员函数实现的二元运算符总是在运算符的左侧操作数上调用,左侧对象不允许转换,右侧对象允许根据成员运算符的参数类型进行转换。
- 插入器的第一个操作数总是流,成员函数必须在流上,但标准库的流不能由用户扩展以包含用户定义类型。
- 调用非模板函数时考虑用户定义的转换,但模板函数的参数类型必须与参数类型几乎完全匹配,不允许用户定义的转换。
- 在类模板中定义原位友元函数会使该模板的每个实例化在包含作用域中生成一个具有给定名称和参数类型的非模板、非成员函数。
- 虚拟构造函数和工厂
- 内存必须按 sizeof(T) 分配,因为 sizeof() 运算符是编译时常量。
- 工厂模式是一种创建对象而无需显式指定对象类型的创建模式。
- 工厂模式允许将对象的构造点与程序决定构造什么对象的点分离,使用替代标识符识别类型。
- 虚拟复制构造函数是一种特殊的工厂,通过已有对象的类型识别要构造的对象,通常通过虚 clone() 方法实现。
- 模板模式描述了一种设计,其中整体控制流由基类决定,派生类在预定义点提供定制。
- 构建器模式用于将对象的构建工作委托给另一个类,当构造函数不足以或不易使用时使用,根据运行时信息使用工厂方法构建不同类型对象的对象也是构建器。
- 模板方法模式和非虚函数惯用法
- 行为模式描述了通过特定方法在不同对象之间通信解决常见问题的方式。
- 模板方法模式是实现具有固定骨架但允许特定问题定制点的算法的标准方式。
- 模板方法允许派生类实现通用算法的特定行为,关键在于基类和派生类的交互方式。
- 与常见的分层设计方法不同,模板模式中低级代码控制算法,决定何时调用高级代码调整执行的特定方面。
- 非虚函数惯用法中,类层次结构的公共接口由基类的非虚公共方法实现,派生类只包含虚私有方法。
- 公共虚函数既提供接口又修改实现,更好的做法是使用虚函数仅定制实现,使用基类的非虚函数指定公共接口。
- 使用非虚函数惯用法后,虚函数通常可以设为私有,派生类需要调用基类虚函数时设为受保护。
- 析构函数按嵌套顺序调用,基类析构函数不能调用派生类的虚函数。
- 脆弱基类问题表现为基类的更改意外破坏派生类,为避免此问题,不应更改现有的定制点。
- 基于策略的设计
- 策略模式是一种行为模式,允许用户通过从一组提供的算法中选择或提供新实现来定制类的某些行为。
- C++将泛型编程与策略模式结合,形成基于策略的设计,主要类模板将某些行为委托给用户指定的策略类型。
- 策略类型通常几乎没有限制,但声明和使用方式会施加一些约定限制,如作为函数调用的策略可以是任何可调用类型,调用特定成员函数的策略必须是类并提供所需成员函数,模板策略必须与指定的模板参数数量完全匹配。
- 主要的组合方式是组合和继承,组合通常更受青睐,但许多策略是无数据成员的空类,可以受益于空基类优化,除非策略需要修改主要类的公共接口,否则应优先使用私有继承,需要操作主要策略类本身的策略通常使用CRTP,策略对象不依赖主要模板构造中使用的类型时,策略行为可以通过静态成员函数暴露。
- 一般来说,只包含常量并用于约束公共接口的策略更易于编写和维护,但在需要添加成员变量或公共函数集难以维护或会导致冲突时,通过基类策略注入公共成员函数更可取。
- 基于策略的设计的主要缺点是复杂性,不同策略的基于策略的类型通常不同,可能导致大量代码需要模板化,长策略列表难以维护和正确使用,应避免创建不必要或难以证明合理的策略,有时具有两组足够不相关策略的类型最好拆分为两个单独的类型。
- 适配器和装饰器
- 适配器是一种非常通用的模式,修改类或函数(在C++中是模板)的接口,使其能在需要不同接口但底层行为相似的上下文中使用。
- 装饰器模式是一种更窄的模式,通过添加或删除行为修改现有接口,但不将接口转换为完全不同的接口。
- 在经典的面向对象编程实现中,被装饰类和装饰器类继承自公共基类,存在两个限制,即被装饰对象保留被装饰类的多态行为但不能保留具体(派生)被装饰类中添加的接口,装饰器特定于特定层次结构,使用C++的泛型编程工具可以消除这两个限制。
- 一般来说,装饰器尽可能保留被装饰类的接口,未修改行为的函数保持不变,因此通常使用公共继承,如果装饰器需要显式转发大多数调用给被装饰类,则继承方面不太重要,可以使用组合或私有继承。
- 与装饰器不同,适配器通常呈现与原始类非常不同的接口,因此通常首选组合,但编译时适配器修改模板参数但本质上是相同的类模板(类似于模板别名),这些适配器必须使用公共继承。
- 适配器模式的主要限制是不能应用于模板函数,也不能用包含参数的表达式替换函数参数。
- 模板别名在函数模板实例化时不被参数类型推导考虑,适配器和策略模式都可用于添加或修改类的公共接口。
- 适配器易于堆叠(组合)以逐个函数构建复杂接口,未启用的功能无需特殊处理,如果不使用相应的适配器,则该功能未启用,传统策略模式需要为每个模式预定义插槽,除最后显式指定的默认参数外,所有策略(包括默认策略)都必须显式指定,另一方面,栈中间的适配器无法访问对象的最终类型,这使实现复杂化,基于策略的类始终是最终类型,使用CRTP可以将该类型传播到需要它的策略中。
- 访问者模式和多分派
- 访问者模式提供了一种将算法的实现与操作的对象分离的方法,即通过编写新的成员函数来扩展类的功能而不修改类。
- 访问者模式允许扩展类层次结构的功能,当类的源代码不可修改或修改难以维护时使用。
- 双分派是根据两个因素调度函数调用(选择要运行的算法)的过程,可以在运行时使用访问者模式(虚函数提供单分派)或在编译时使用模板或编译时访问者实现。
- 经典访问者模式在访问者类层次结构和可访问类层次结构之间存在循环依赖,可访问类在添加新访问者时无需编辑,但访问者层次结构更改时需要重新编译,每次添加新的可访问类时都会发生这种情况,因此存在依赖循环,无环访问者模式通过交叉类型转换和多重继承打破了这个循环。
- 自然的将访问者接受进由较小对象组成的对象的方式是逐个访问这些对象,这种模式递归实现最终会以固定的预定顺序访问对象中包含的每个内置数据成员,因此该模式自然地映射到序列化和反序列化的要求,即必须将对象解构为一组内置类型,然后恢复它。
C++并发模式与协程应用(续)
7. 设计模式的应用与选择
在实际的C++编程中,选择合适的设计模式至关重要。以下是一些选择设计模式的考虑因素和应用场景:
- 根据问题类型选择
- 并发问题 :当处理并发编程时,如多线程访问共享数据,应优先考虑同步模式和执行模式。例如,使用互斥锁和信号量等同步原语来保证数据的一致性,使用线程池等执行模式来提高并发性能。
- 对象创建问题 :如果需要创建对象而不明确指定对象类型,工厂模式和构建器模式是不错的选择。工厂模式适用于根据不同条件创建不同类型的对象,而构建器模式适用于创建复杂对象,将对象的构建过程和表示分离。
- 行为定制问题 :策略模式和模板方法模式可用于定制类的行为。策略模式允许在运行时选择不同的算法,而模板方法模式则在编译时确定算法的骨架,由派生类定制具体步骤。
- 根据性能要求选择
- 性能敏感场景 :在对性能要求较高的场景中,应避免使用开销较大的模式。例如,虚函数调用会带来一定的性能开销,在性能敏感的代码中可以考虑使用CRTP等静态多态模式来替代虚函数。
- 资源管理场景 :对于资源管理,RAII是首选模式。它可以确保资源在对象生命周期结束时自动释放,避免资源泄漏。
- 根据代码复用性选择
- 提高代码复用性 :模板模式和策略模式可以提高代码的复用性。模板模式通过基类定义算法骨架,派生类实现具体步骤,减少了代码的重复。策略模式允许将不同的算法封装成不同的策略类,方便复用。
8. 设计模式的组合使用
在实际项目中,往往需要组合使用多种设计模式来解决复杂的问题。以下是一些常见的设计模式组合:
- 工厂模式和策略模式 :工厂模式用于创建对象,策略模式用于定制对象的行为。例如,一个图形绘制程序可以使用工厂模式创建不同类型的图形对象,然后使用策略模式为每个图形对象选择不同的绘制算法。
- 访问者模式和组合模式 :组合模式用于表示对象的层次结构,访问者模式用于对这些对象进行操作。例如,一个文件系统可以使用组合模式表示文件和文件夹的层次结构,然后使用访问者模式对文件系统进行遍历和操作。
- 装饰器模式和适配器模式 :装饰器模式用于动态地添加对象的功能,适配器模式用于将一个类的接口转换为另一个类的接口。例如,一个日志系统可以使用装饰器模式为日志对象添加不同的功能,如日志加密、日志压缩等,然后使用适配器模式将日志对象的接口转换为不同的输出格式。
9. 设计模式的未来发展
随着C++语言的不断发展,设计模式也在不断演变和发展。以下是一些可能的发展趋势:
- 协程相关模式的发展 :随着协程在C++中的应用越来越广泛,未来可能会出现更多基于协程的设计模式。例如,协程池、协程调度器等模式可能会成为常见的并发编程模式。
- 与现代C++特性的结合 :C++不断引入新的特性,如概念、模块等,设计模式也会与这些特性相结合。例如,使用概念来约束模板参数,提高代码的安全性和可读性。
- 跨平台和跨语言的设计模式 :随着软件系统的复杂性不断增加,跨平台和跨语言的开发变得越来越常见。未来可能会出现一些通用的设计模式,适用于不同的平台和语言。
10. 总结
C++设计模式是解决软件开发中各种问题的有效方法。通过合理使用设计模式,可以提高代码的可维护性、可扩展性和性能。本文介绍了C++中常见的设计模式,包括协程相关模式、继承与多态、类和函数模板等。同时,还讨论了设计模式的应用、选择和组合使用,以及未来的发展趋势。
在实际编程中,应根据具体问题选择合适的设计模式,并灵活组合使用。同时,要不断学习和掌握新的设计模式和C++特性,以适应不断变化的软件开发需求。
以下是一个简单的mermaid流程图,展示了选择设计模式的基本流程:
graph TD;
A[明确问题类型] --> B{是否是并发问题};
B -- 是 --> C[考虑同步和执行模式];
B -- 否 --> D{是否是对象创建问题};
D -- 是 --> E[考虑工厂和构建器模式];
D -- 否 --> F{是否是行为定制问题};
F -- 是 --> G[考虑策略和模板方法模式];
F -- 否 --> H[根据其他因素选择];
C --> I[评估性能和复用性];
E --> I;
G --> I;
H --> I;
I --> J[选择合适的模式];
总之,设计模式是C++编程中的重要工具,合理运用设计模式可以让我们的代码更加健壮、高效和易于维护。希望本文能对读者在C++设计模式的学习和应用中有所帮助。
超级会员免费看

390

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



