C---异步编程-全-

C++ 异步编程(全)

原文:zh.annas-archive.org/md5/f1e7911ac27c9e84fe6c2390cd2daf23

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

异步编程是构建高效、响应迅速且高性能软件的必要实践,尤其是在当今多核处理器和实时数据处理的世界。本书深入探讨了掌握 C++异步编程的原则和实践技术,为你提供处理从线程管理到性能优化所需的知识。

开发异步软件有几个关键支柱:

  • 线程管理和同步

  • 异步编程的概念、模型和库

  • 调试、测试和优化多线程和异步软件

尽管许多资源专注于并行编程的基础或通用软件开发,但本书旨在对这些支柱进行全面探索。它涵盖了管理并发、调试复杂系统和优化软件性能的基本技术,同时将这些概念建立在现实世界场景的基础上。

我们将通过以下基于实际案例的各个方面来引导你了解异步编程:

  • 我们在开发高性能软件方面的丰富经验

  • 从金融到研究中心等不同行业的工作中学到的最佳实践

随着多核处理器和并行计算架构在现代应用中变得越来越重要,对异步编程专家的需求正在迅速增长。掌握本书中涵盖的技术将帮助你不仅应对当今复杂的软件开发挑战,而且为性能关键型软件的未来进步做好准备。

无论你是处理低延迟金融系统、开发高吞吐量应用,还是仅仅想提高你的编程技能,本书都将为你提供成功所需的工具和知识。

本书面向的对象

本书是为寻求深化使用最新 C++版本理解异步编程并优化软件性能的软件工程师、开发人员和技术负责人而设计的。主要目标受众包括:

  • 软件工程师 : 希望提高 C++技能并获得多线程、异步编程、调试和性能优化实用见解的人。

  • 技术负责人 : 致力于实施高效异步系统的领导者将找到管理复杂软件开发和提升团队生产力的策略和最佳实践 .

  • 学生和爱好者 : 渴望了解高性能计算和异步编程的个人将受益于全面解释和示例,帮助他们提升在技术领域的职业生涯。

本书将赋予读者应对现实世界挑战和出色完成技术面试的能力,为他们提供在当今快速发展的软件领域中茁壮成长的知识。

本书涵盖的内容

第一章并行编程范式,探讨了构建并行系统的不同架构和模型,以及各种并行编程范式及其性能指标。

第二章进程、线程和服务,深入探讨了操作系统中的进程,考察了它们的生命周期、进程间通信以及线程的作用,包括守护进程和多线程。

第三章如何在 C++中创建和管理线程,指导如何创建和管理线程,传递参数,检索结果,并处理异常以确保在多线程环境中的高效执行。

第四章使用锁进行线程同步,解释了 C++标准库同步原语的使用,包括互斥锁和条件变量,同时解决竞态条件、死锁和活锁问题。

第五章原子操作,探讨了 C++原子类型、内存模型以及如何实现基本的 SPSC 无锁队列,为未来的性能提升做准备。

第六章承诺和未来,介绍了异步编程概念,包括承诺、未来和包装任务,并展示了如何使用这些工具解决现实生活中的问题。

第七章异步函数,探讨了std::async执行异步任务的功能,定义启动策略,处理异常,并优化性能。

第八章使用协程进行异步编程,描述了 C++协程、它们的基本要求以及如何实现生成器和解析器,同时处理协程内的异常。

第九章使用 Boost.Asio 进行异步编程,解释了如何使用 Boost.Asio 管理与外部资源相关的异步任务,重点关注 I/O 对象、执行上下文和事件处理。

第十章使用 Boost.Cobalt 实现协程,探讨了如何轻松地使用 Boost.Cobalt 库实现协程,避免低级复杂性,并专注于函数式编程需求。

第十一章异步软件的日志记录和调试,解释了如何有效地使用日志和调试工具来识别和解决异步应用程序中的问题,包括死锁和竞态条件。

第十二章异步软件的清理和测试,探讨了如何使用清理器对多线程代码进行清理,并探讨了使用 GoogleTest 库针对异步软件的定制测试技术。

第十三章提高异步软件性能,检查性能测量工具和技术,包括高分辨率计时器、缓存优化以及避免虚假和真实共享的策略。

要充分利用这本书

您需要具备使用 C++进行编程的先前经验以及如何使用调试器查找错误。由于我们使用 C++20 功能,在某些示例中使用 C++23,您需要安装 GCC 14 和 Clang 18。所有源代码示例已在 Ubuntu 和 macOS 上测试,但由于它们是平台无关的,它们应该可以在任何平台上编译和运行。

本书涵盖的软件/硬件 操作系统要求
C++20 和 C++23 Linux(在 Ubuntu 24.04 上测试)
GCC 14.2 macOS(在 macOS Sonoma 14.x 上测试)
Clang 18 Windows 11
Boost 1.86
GDB 15.1

每章都包含一个技术要求部分,突出显示安装编译章节示例所需工具和库的相关信息。

如果您正在使用这本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Asynchronous-Programming-with-CPP)下载这本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包可供在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“上面的代码生成一个正态分布的随机数向量,然后使用std::sort()std::stable_sort()对向量进行排序。”

代码块设置如下:

#include <iostream>
#include <thread>
int main() {
    std::thread t1([]() {
        for (int i = 0; i < 100; ++i) {
            std::cout << "1 " << "2 " << "3 " << "4 "
                      << std::endl;
        }
    });

任何命令行输入或输出都应如下编写:

$ clang++ -O0 -g -fsanitize=address -fno-omit-frame-pointer test.cpp –o test
$ ASAN_OPTIONS=suppressions=myasan.supp ./test

提示或重要注意事项

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们非常感谢你向我们报告。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果你在互联网上以任何形式遇到我们作品的非法副本,我们非常感谢你提供位置地址或网站名称。请通过版权@packt.com 与我们联系,并提供材料的链接。

如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com

分享你的想法

一旦你阅读了《使用 C++进行异步编程》,我们非常乐意听到你的想法!请点击此处直接访问此书的亚马逊评论页面并分享你的反馈。

你的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢购买此书!

你喜欢在旅途中阅读,但无法随身携带你的印刷书籍吗?

你的电子书购买是否与你的选择设备不兼容?

不要担心,现在,随着每本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何时间、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。

优惠不会就此停止,你还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。

按照以下简单步骤获取好处:

  1. 扫描下面的二维码或访问以下链接

img

packt.link/free-ebook/9781835884249

  1. 提交你的购买证明

  2. 就这些!我们将直接将你的免费 PDF 和其他好处发送到你的电子邮件。

第一部分:并行编程和进程管理基础

在这部分,我们深入探讨构成并行编程和进程管理的基石性的基本概念和范式。你将深入理解用于构建并行系统的架构,并探索用于开发高效并行、多线程和异步软件的各种编程范式。此外,我们还将涵盖与进程、线程和服务相关的关键概念,强调它们在操作系统中的重要性,特别是在进程生命周期、性能和资源管理方面。

本部分包含以下章节:

  • 第一章并行编程范式

  • 第二章进程、线程和服务

第一章:并行编程范式

在我们深入使用 C++进行并行编程之前,在前两章中,我们将专注于获取有关构建并行软件的不同方法以及软件如何与机器硬件交互的基础知识。

在本章中,我们将介绍并行编程以及我们在开发高效、响应和可扩展的并发和异步软件时可以使用的不同范式和模型。

在对我们可以采取的不同方法进行分类时,有许多方法可以分组概念和方法。由于我们在这本书中关注使用 C++构建的软件,我们可以将不同的并行编程范式分为以下几类:并发、异步编程、并行编程、响应式编程、数据流、多线程编程和事件驱动编程。

根据手头的问题,一个特定的范式可能比其他范式更适合解决特定场景。了解不同的范式将帮助我们分析问题并缩小最佳解决方案的范围。

在本章中,我们将涵盖以下主要主题:

  • 什么是并行编程,为什么它很重要?

  • 不同的并行编程范式有哪些,为什么我们需要了解它们?

  • 在这本书中你将学到什么?

技术要求

本章没有技术要求。

在整本书中,我们将使用 C++20 开发不同的解决方案,在某些示例中,我们将使用 C++23。因此,我们需要安装 GCC 14 和 Clang 8。

本书展示的所有代码块都可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Asynchronous-Programming-with-CPP

了解分类、技术和模型

当任务或计算同时进行时,发生并行计算,其中任务是一个软件应用程序中的执行单元或工作单元。由于有许多实现并行化的方法,了解不同的方法将有助于编写高效的并行算法。这些方法通过范式和模型进行描述。

但首先,让我们从分类不同的并行计算系统开始。

系统分类和技术

1966 年,迈克尔·J·弗林对并行计算系统进行了最早的分类。弗林的分类法根据并行计算架构可以处理的数据流和指令数量定义了以下分类:

  • 单指令单数据(SISD)系统:定义一个顺序程序

  • 单指令多数据(SIMD)系统:在大型数据集上执行操作,例如在信号处理或 GPU 计算中

  • 多指令单数据(MISD)系统:很少使用

  • 多指令多数据 (MIMD) 系统:基于多核和多处理器计算机的最常见的并行架构

图 1.1:弗林分类法

图 1.1:弗林分类法

这本书不仅关于用 C++ 构建软件,还关注它如何与底层硬件交互。在软件层面可能进行更有趣的划分或分类,在那里我们可以定义技术。我们将在后续章节中学习这些内容。

数据并行

许多不同的数据单元由同一程序或指令序列在 CPU 或 GPU 核心等不同的处理单元中并行处理。

数据并行是通过同一操作可以同时处理多少个不相交的数据集来实现的。大型数据集可以通过并行性被分割成更小且独立的块。

这种技术也具有高度的可扩展性,因为增加更多的处理单元可以处理更大的数据量。

在这个子集中,我们可以包括如 SSE、AVX、VMX 或 NEON 这样的 SIMD 指令集,这些指令集可以通过 C++ 的内建函数访问。还包括如 OpenMP 和 CUDA 这样的库,用于 NVIDIA GPU。一些使用示例可以在机器学习训练和图像处理中找到。这种技术与弗林定义的 SIMD 分类法相关。

如同往常,也有一些缺点。数据必须容易分割成独立的块。这种数据分割和后续合并也引入了一些开销,可能会降低并行化的好处。

任务并行

在每个 CPU 核心使用进程或线程运行不同任务的计算机中,当这些任务同时接收数据、处理数据并将通过消息传递生成的结果发送回来时,可以实现任务并行

任务并行的优势在于能够设计出异构和细粒度的任务,这样可以更好地利用处理资源,在设计和解决方案时更加灵活,可能实现更高的加速比。

由于数据可能产生的任务之间的依赖性,以及每个任务的不同性质,调度和协调比数据并行更复杂。此外,任务创建还会增加一些处理开销。

在这里,我们可以包括弗林的 MISD 和 MIMD 分类法。一些示例可以在网络服务器请求处理系统或用户界面事件处理器中找到。

流并行

通过将计算分割成处理数据子集的各个阶段,可以并行处理数据元素连续序列,也称为数据流

阶段可以并行运行。一些阶段生成其他阶段所需的输入,通过阶段依赖关系构建管道。处理阶段可以在不等待接收整个数据流的情况下,将结果发送到下一阶段。

流并行技术在处理连续数据时非常有效。它们也非常可扩展,因为可以通过添加更多处理单元来扩展,以处理额外的输入数据。由于流数据在到达时即被处理,这意味着不需要等待整个数据流发送完毕,这也意味着内存使用也得到了减少。

然而,像往常一样,也有一些缺点。由于它们的处理逻辑、错误处理和恢复,这些系统更难实现。我们可能还需要实时处理数据流,因此硬件也可能成为限制因素。

这些系统的例子包括监控系统、传感器数据处理以及音频和视频流。

隐式并行性

在这种情况下,编译器、运行时或硬件负责为程序员透明地并行化指令的执行。

这使得编写并行程序变得更容易,但限制了程序员对所使用策略的控制,甚至使得分析性能或调试变得更加困难。

现在我们已经更好地理解了不同的并行系统和技术,是时候学习在设计并行程序时可以使用的不同模型了。

并行编程模型

并行编程模型是用于表达算法和构建程序的并行计算机架构。模型越通用,其价值就越大,因为它可以在更广泛的场景中使用。在这方面,C++通过标准模板库STL)中的库实现并行模型,可以用于从顺序应用程序中实现程序的并行执行。

这些模型描述了在程序的生命周期中,不同的任务如何相互作用以从输入数据中获得结果。它们的主要区别在于任务如何相互交互以及它们如何处理传入的数据。

阶段并行

阶段并行,也称为议程或松散同步范式,多个作业或任务并行执行独立的计算。在某个时刻,程序需要使用屏障执行同步交互操作,以同步不同的进程。屏障是一种同步机制,确保在任一任务进一步执行之前,所有任务都达到它们执行中的特定点。接下来的步骤执行其他异步操作,依此类推。

图 1.2:阶段并行模型

图 1.2:阶段并行模型

此模型的优势在于任务之间的交互不会与计算重叠。另一方面,很难在所有处理单元之间达到平衡的工作负载和吞吐量。

分而治之

使用此模型的应用程序使用一个主要任务或作业,将工作负载分配给其子任务,将它们分配给更小的任务。

子任务并行计算结果并将其返回给父任务,在那里部分结果合并成最终结果。子任务还可以将分配的任务进一步细分,并创建自己的子任务。

这个模型与相位并行模型具有相同的缺点;难以实现良好的负载均衡。

图 1.3:分而治之模型

图 1.3:分而治之模型

图 1.3中,我们可以看到主工作如何将工作分配给几个子任务,以及子任务 2如何将其分配的工作进一步细分为两个额外的任务。

流水线

几个任务相互连接,构建一个虚拟流水线。在这个流水线中,各个阶段可以同时运行,在提供数据时重叠执行。

图 1.4:流水线模型

图 1.4:流水线模型

在前面的图中,三个任务在由五个阶段组成的流水线中交互。在每个阶段,一些任务正在运行,生成输出结果,这些结果被下一阶段的其他任务使用。

主从

使用主从模型,也称为进程农场,主工作执行算法的顺序部分,并产生和协调执行工作负载中的并行操作的从任务。当从任务完成其计算时,它会向主工作报告结果,主工作可能会随后发送更多数据给从任务进行处理。

图 1.5:主从模型

图 1.5:主从模型

主要缺点是,如果主工作需要处理太多的从任务或任务太小,它可能会成为瓶颈。在确定每个任务要执行的工作量时存在权衡,这被称为粒度。当任务较小时,它们被称为细粒度,当任务较大时,它们被称为粗粒度。

工作池

在工作池模型中,一个全局结构持有待完成的工作项池。然后,主程序创建工作,从池中获取工作片段以执行。

这些工作可以生成更多的工作单元,并将它们插入工作池。当所有工作单元都完成且池为空时,并行程序完成其执行。

图 1.6:工作池模型

图 1.6:工作池模型

该机制有助于在空闲处理单元之间实现负载均衡。

在 C++中,这个池通常通过使用无序集合、队列或优先队列来实现。我们将在本书中实现一些示例。

既然我们已经了解了可以用来构建并行系统的各种模型,让我们探索可用的不同并行编程范式,以开发能够高效并行运行任务的软件。

理解各种并行编程范式

现在我们已经探讨了构建并行程序所使用的不同模型,是时候转向更抽象的分类,并通过探索不同的并行编程语言范式来了解如何编写并行程序的基本风格或原则。

同步编程

同步编程语言用于构建代码以严格顺序执行的程序。当一条指令正在执行时,程序会保持阻塞,直到指令完成。换句话说,没有多任务处理。这使得代码更容易理解和调试。

然而,这种行为使得程序在执行指令时对外部事件无响应,并且难以扩展。

这是最多编程语言(如 C、Python 或 Java)使用的传统范式。

这种范式特别适用于需要实时且有序响应输入事件的反应式或嵌入式系统。处理速度必须与环境施加的严格时间限制相匹配。

图 1.7:异步与同步执行时间

图 1.7:异步与同步执行时间

图 1.7 显示了系统中运行的两个任务。在同步系统中,任务 A 被任务 B 中断,只有在任务 B 完成其工作后才会继续执行。在异步系统中,任务 A 和 B 可以同时运行,因此可以在更短的时间内完成它们的工作。

并发编程

并发编程中,可以同时运行多个任务。

任务可以独立运行,无需等待其他任务完成指令。它们还可以共享资源并相互通信。它们的指令可以异步运行,这意味着它们可以按任何顺序执行而不影响结果,增加了并行处理的可能性。另一方面,这也使得这类程序更难以理解和调试。

并发提高了程序的吞吐量,因为随着并发的增加,在时间间隔内完成的任务数量也增加(参见本章末尾“探索评估并行性的指标”部分中 Gustafson 定律的公式)。此外,它还实现了更好的输入和输出响应性,因为程序可以在等待期间执行其他任务。

并发软件中的主要问题是在正确地实现并发控制。在协调对共享资源的访问并确保不同计算执行之间发生正确的交互顺序时必须格外小心。错误的决定可能导致竞争条件、死锁或资源饥饿,这些内容在第三章第四章中进行了深入解释。大多数这些问题通过遵循一致性或内存模型来解决,该模型定义了在访问共享内存时操作应该如何以及按何种顺序执行。

设计高效的并发算法是通过找到协调任务执行、数据交换、内存分配和调度的技术,以最小化响应时间并最大化吞吐量。

介绍并发的第一篇学术论文《解决并发程序控制问题》由迪杰斯特拉于 1965 年发表。互斥性也在此处被识别并解决。

并发可以在操作系统级别以抢占式的方式发生,其中调度器切换上下文(从一个任务切换到另一个任务)而不与任务交互。它也可以以非抢占式或协作式的方式发生,其中任务将控制权交给调度器,调度器选择另一个任务继续工作。

调度器通过保存运行程序的状态(内存和寄存器内容)来中断正在运行的程序,然后加载已恢复程序保存的状态并将控制权转交给它。这被称为上下文切换。根据任务的优先级,调度器可能允许高优先级任务比低优先级任务使用更多的 CPU 时间。

此外,一些特殊的操作系统软件,如内存保护,可能使用特殊的硬件来防止用户模式程序错误损坏监督软件。

这种机制不仅用于单核计算机,也用于多核计算机,允许执行比可用核心数更多的任务。

抢占式多任务处理还允许将重要任务提前调度以快速处理重要外部事件。当操作系统向这些任务发送触发中断的信号时,这些任务会唤醒并处理重要工作。

早期的 Mac 和 Windows 操作系统使用非抢占式多任务处理。这种技术至今仍在 RISC 操作系统上使用。Unix 系统从 1969 年开始使用抢占式多任务处理,成为所有 Unix-like 系统和从 Windows NT 3.1和 Windows 95 开始的现代 Windows 版本的核心功能。

早期的 CPU 只能同时运行一条指令路径。通过在指令流之间切换,通过看似重叠的执行来在软件级别产生并行性的错觉,从而实现了并行性。

然而,在 2005 年,英特尔® 推出了多核处理器,这使得在硬件级别上可以同时执行多个指令流。这给编写软件时带来了一些挑战,因为硬件级别的并发现在需要被处理和利用。

C++ 从 C++11 开始支持并发编程,通过 std::thread 库。早期版本没有包括任何特定功能,因此程序员依赖于基于 Unix 系统中 POSIX 线程模型的平台特定库,或在 Windows 系统上依赖于专有的微软库。

现在我们更好地理解了并发是什么,我们需要区分并发和并行。并发发生在许多执行路径可以在重叠的时间段内交错执行时,而并行发生在这些任务由不同的 CPU 单元同时执行时,利用可用的多核资源。

图 1.8:并发与并行

图 1.8:并发与并行

并发编程被认为比并行编程更通用,因为后者有一个预定义的通信模式,而前者可以涉及任务之间任意和动态的通信和交互模式。

并行可以存在于没有并发(没有交错的时间段)的情况下,也可以在没有并行的情况下存在(通过在单核 CPU 上通过时间共享进行多任务处理)。

异步编程

异步编程允许一些任务在后台调度和运行,同时继续当前工作,无需等待计划中的任务完成。当这些任务完成后,它们将结果返回给主任务或调度器。

同步应用程序的一个关键问题是长时间操作可能会使程序对进一步的输入或处理无响应。异步程序通过接受新的输入来解决此问题,同时一些操作正在通过非阻塞任务执行,系统可以同时执行多个任务。这也允许更好的资源利用。

由于任务异步执行并在完成时返回结果,这种范式特别适合事件驱动程序。此外,它通常用于用户界面、Web 服务器、网络通信或长时间运行的后台处理。

随着硬件向单个处理器芯片上的多个处理核心发展,使用异步编程来利用所有可用的计算能力,通过在不同核心上并行运行任务,已经成为强制性的要求。

然而,异步编程有其挑战,正如我们将在本书中探讨的那样。例如,它增加了复杂性,因为代码不是按顺序解释的。这可能导致竞争条件。此外,错误处理和测试对于确保程序稳定性和防止问题至关重要。

正如我们将在本书中学到的,现代 C++还提供了异步机制,如协程,这些是可以在稍后挂起和恢复的程序,或者将futurepromise作为异步程序中未知结果的代理,以同步程序执行。

并行编程

使用并行编程,可以在多个处理单元上同时执行多个计算任务,这些处理单元可以是同一台计算机上的所有处理单元(多核)或多台计算机(集群)。

主要有两种方法:

  • 共享内存并行性:任务可以通过共享内存进行通信,这是一个所有处理器都可以访问的内存空间。

  • 消息传递并行性:每个任务都有自己的内存空间,并使用消息传递技术与其他任务通信。

就像之前的范式一样,为了充分发挥潜力并避免错误或问题,并行计算需要同步机制来避免任务相互干扰。它还要求平衡负载以达到其全部潜力,以及在创建和管理任务时减少开销。这些需求增加了设计、实现和调试的复杂性。

多线程编程

多线程编程是并行编程的一个子集,其中程序被划分为多个线程,这些线程在同一个进程中执行独立的单元。线程之间共享进程、内存空间和资源。

正如我们之前提到的,共享内存需要同步机制。另一方面,由于不需要进程间通信,资源共享简化了。

例如,多线程编程通常用于实现图形用户界面GUI)的响应性,流畅的动画,在 Web 服务器上处理多个客户端请求,或在数据处理中。

事件驱动编程

在事件驱动编程中,控制流由外部事件驱动。应用程序实时检测事件,并通过调用适当的事件处理方法或回调来对这些事件做出响应。

事件表示需要采取行动的动作。这个事件由事件循环监听,它持续监听传入的事件并将它们调度到适当的回调,该回调将执行所需操作。由于代码仅在发生动作时执行,因此这种范式通过资源使用和可扩展性提高了效率。

事件驱动编程对于处理用户界面、实时应用程序和网络连接监听器中的动作非常有用。

就像许多其他范式一样,增加的复杂性、同步和调试使得这种范式在实现和应用上变得复杂。

由于 C++是一种底层语言,因此使用回调或函数对象等技术来编写事件处理程序。

响应式编程

反应式编程处理数据流,这些是随时间连续的数据或值流。程序通常使用声明式或函数式编程构建,定义应用于流的操作符和转换的管道。这些操作通过调度器和背压处理机制异步发生。

当数据量超过消费者处理能力,他们无法处理所有数据时,就会发生背压现象。为了避免系统崩溃,反应式系统需要使用背压策略来防止系统故障。

这些策略包括以下内容:

  • 通过请求发布者降低发布事件的速度来控制输入吞吐量。这可以通过遵循拉取策略实现,即发布者仅在消费者请求时发送事件,或者通过限制发送的事件数量,创建一个有限且可控的推送策略。

  • 缓存额外的数据,这在数据突发或短时间内高带宽传输时特别有用。

  • 通过丢弃一些事件或延迟它们的发布,直到消费者从背压状态中恢复。

因此,反应式程序可以是基于拉取基于推送的。基于拉取的程序实现了从数据源主动拉取事件的经典案例。另一方面,基于推送的程序通过信号网络推送事件以到达订阅者。订阅者对变化做出反应而不阻塞程序,这使得这些系统非常适合对响应性至关重要的丰富用户界面环境。

反应式编程类似于事件驱动模型,其中来自各种来源的事件流可以进行转换、过滤、处理等。两者都增加了代码模块化,并适合实时应用。然而,也有一些不同之处,如下所示:

  • 反应式编程对事件流做出反应,而事件驱动编程处理离散事件。

  • 在事件驱动编程中,事件触发回调或事件处理器。在反应式编程中,可以创建一个包含不同转换操作符的管道,其中数据流将流动并修改事件。

使用反应式编程的系统软件的例子包括 X 窗口系统和 Qt、WxWidgets、Gtk+等库。反应式编程也用于实时传感器数据处理和仪表板。此外,它还应用于处理网络或文件 I/O 流量和数据处理。

在使用反应式编程时,要充分发挥其潜力,需要解决一些挑战。例如,调试分布式数据流和异步过程或通过微调调度器来优化性能是很重要的。此外,使用声明式或函数式编程使得通过反应式编程技术开发软件理解和学习起来更具挑战性。

数据流编程

使用数据流编程,程序被设计为一个有向图,节点代表计算单元,边代表数据流。节点仅在有可用数据时执行。这种范式是在 20 世纪 60 年代由麻省理工学院的 Jack Dennis 发明的。

数据流编程使代码和设计更易于阅读和清晰,因为它提供了不同计算单元及其交互的视觉表示。此外,独立节点可以在数据流编程中并行运行,增加并行性和吞吐量。因此,它类似于响应式编程,但提供了一种基于图的方法和视觉辅助来建模系统。

要实现数据流程序,我们可以使用哈希表。键标识一组输入,值描述要运行的任务。当给定键的所有输入都可用时,与该键关联的任务将被执行,生成可能触发哈希表中其他键的任务的额外输入值。在这些系统中,调度器可以通过对图数据结构进行拓扑排序来找到并行机会,按任务之间的相互依赖关系对不同的任务进行排序。

这种范式通常用于机器学习的大规模数据处理管道、来自传感器或金融市场数据的实时分析,以及音频、视频和图像处理系统。使用数据流范式的软件库示例包括 Apache Spark 和 TensorFlow。在硬件方面,我们可以找到数字信号处理、网络路由、GPU 架构、遥测和人工智能等方面的示例。

数据流编程的一种变体是增量计算,其中只有依赖于变化输入数据的输出被重新计算。这就像在 Excel 电子表格中某个单元格值改变时重新计算受影响的单元格一样。

现在我们已经了解了不同的并行编程系统、模型和范式,是时候介绍一些指标,这些指标有助于衡量并行系统的性能。

探索评估并行性的指标

指标是帮助我们了解系统性能并进行不同改进方法比较的测量。

这里有一些常用的指标和公式,用于评估系统中的并行性。

并行度

并行度DOP)是一个指标,表示计算机同时执行的操作数量。它有助于描述并行程序和多处理器系统的性能。

当计算 DOP 时,我们可以使用可以同时进行的最大操作数,衡量没有瓶颈或依赖的理想情况。或者,我们可以使用平均操作数或特定时间点的并发操作数,反映系统实际实现的 DOP。可以通过使用分析器和性能分析工具来测量特定时间段内的线程数来进行近似。

这意味着 DOP 不是一个常数;它是一个动态指标,在应用程序执行过程中会发生变化。

例如,考虑一个处理多个文件的脚本工具。这些文件可以依次或同时处理,从而提高效率。如果我们有一台拥有N个核心的机器,并且我们想要处理N个文件,我们可以将一个文件分配给每个核心。

依次处理所有文件所需的时间如下:

<mml:math   display="block">mml:msubmml:mrowmml:mit</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi>mml:mio</mml:mi>mml:mit</mml:mi>mml:mia</mml:mi>mml:mil</mml:mi></mml:mrow></mml:msub>mml:mo=</mml:mo>mml:msubmml:mrowmml:mit</mml:mi></mml:mrow>mml:mrowmml:mif</mml:mi>mml:mii</mml:mi>mml:mil</mml:mi>mml:mie</mml:mi>mml:mn1</mml:mn></mml:mrow></mml:msub>mml:mo+</mml:mo>mml:msubmml:mrowmml:mit</mml:mi></mml:mrow>mml:mrowmml:mif</mml:mi>mml:mii</mml:mi>mml:mil</mml:mi>mml:mie</mml:mi>mml:mn2</mml:mn></mml:mrow></mml:msub>mml:mo+</mml:mo>mml:msubmml:mrowmml:mit</mml:mi></mml:mrow>mml:mrowmml:mif</mml:mi>mml:mii</mml:mi>mml:mil</mml:mi>mml:mie</mml:mi>mml:mn3</mml:mn></mml:mrow></mml:msub>mml:mo+</mml:mo>mml:mo⋯</mml:mo>mml:mo+</mml:mo>mml:msubmml:mrowmml:mit</mml:mi></mml:mrow>mml:mrowmml:mif</mml:mi>mml:mii</mml:mi>mml:mil</mml:mi>mml:mie</mml:mi>mml:miN</mml:mi></mml:mrow></mml:msub>mml:mo≅</mml:mo>mml:miN</mml:mi>mml:mo⋅</mml:mo>mml:mia</mml:mi>mml:miv</mml:mi>mml:mig</mml:mi><mml:mfenced separators="|">mml:mrowmml:msubmml:mrowmml:mit</mml:mi></mml:mrow>mml:mrowmml:mif</mml:mi>mml:mii</mml:mi>mml:mil</mml:mi>mml:mie</mml:mi></mml:mrow></mml:msub></mml:mrow></mml:mfenced></mml:math>

处理这些文件并行所需的时间如下:

<mml:math   display="block">mml:msubmml:mrowmml:mit</mml:mi></mml:mrow>mml:mrowmml:mit</mml:mi>mml:mio</mml:mi>mml:mit</mml:mi>mml:mia</mml:mi>mml:mil</mml:mi></mml:mrow></mml:msub>mml:mo=</mml:mo>mml:mim</mml:mi>mml:mia</mml:mi>mml:mix</mml:mi><mml:mfenced separators="|">mml:mrowmml:msubmml:mrowmml:mit</mml:mi></mml:mrow>mml:mrowmml:mif</mml:mi>mml:mii</mml:mi>mml:mil</mml:mi>mml:mie</mml:mi>mml:mn1</mml:mn></mml:mrow></mml:msub>mml:mo,</mml:mo>mml:msubmml:mrowmml:mit</mml:mi></mml:mrow>mml:mrowmml:mif</mml:mi>mml:mii</mml:mi>mml:mil</mml:mi>mml:mie</mml:mi>mml:mn2</mml:mn></mml:mrow></mml:msub>mml:mo,</mml:mo>mml:msubmml:mrowmml:mit</mml:mi></mml:mrow>mml:mrowmml:mif</mml:mi>mml:mii</mml:mi>mml:mil</mml:mi>mml:mie</mml:mi>mml:mn3</mml:mn></mml:mrow></mml:msub>mml:mo,</mml:mo>mml:mo⋯</mml:mo>mml:mo,</mml:mo>mml:msubmml:mrowmml:mit</mml:mi></mml:mrow>mml:mrowmml:mif</mml:mi>mml:mii</mml:mi>mml:mil</mml:mi>mml:mie</mml:mi>mml:miN</mml:mi></mml:mrow></mml:msub></mml:mrow></mml:mfenced></mml:math>

因此,DOP 是N,即积极处理单独文件的活跃核心数。

并行化能够达到的速度提升有一个理论上的上限,这由阿姆达尔定律给出。

阿姆达尔定律

在一个并行系统中,我们可能会认为增加 CPU 核心的数量可以使程序运行速度加倍,从而将运行时间减半。然而,并行化的速度提升并不是线性的。在达到一定数量的核心后,由于上下文切换、内存分页等因素,运行时间不再减少。

阿姆达尔定律公式计算了并行化后任务可以实现的最高理论速度提升,如下所示:

Smaxs=ss+p1−s=11−p+ps

在这里,s是改进部分的速度提升因子,p是可并行部分相对于整个过程的比率。因此,1-p代表任务不可并行(瓶颈或顺序部分)的比率,而p/s代表可并行部分实现的速度提升。

这意味着最大速度提升受限于任务的顺序部分。可并行化任务的比例越大(p接近1),最大速度提升增加得越多,直到达到速度提升因子(s)。另一方面,当顺序部分变得更大(p接近0)时,Smax趋向于1,这意味着无法实现改进。

Figure 1.9:处理器数量和并行化部分百分比的速度提升限制

图 1.9:处理器数量和并行化部分百分比的速度提升限制

并行系统中的关键路径由依赖计算的最长链定义。由于关键路径几乎无法并行化,它定义了顺序部分,从而定义了程序可以实现的更快的运行时间。

例如,如果一个过程的顺序部分代表运行时间的 10%,那么并行化部分的比例是p=0.9。在这种情况下,潜在的速度提升不会超过速度提升因子的 10 倍,无论有多少处理器可用。

Gustafson 定律

Amdahl 定律公式只能用于固定大小的问题和增加资源。当使用更大的数据集时,并行化部分花费的时间增长速度远快于顺序部分。在这些情况下,Gustafson 定律公式更为乐观且更准确,因为它考虑了固定执行时间和随着额外资源增加的问题规模。

Gustafson 定律公式计算使用p个处理器获得的速度提升如下:

<mml:math   display="block">mml:msubmml:mrowmml:miS</mml:mi></mml:mrow>mml:mrowmml:mip</mml:mi></mml:mrow></mml:msub>mml:mo=</mml:mo>mml:mip</mml:mi>mml:mo+</mml:mo><mml:mfenced separators="|">mml:mrowmml:mn1</mml:mn>mml:mo-</mml:mo>mml:mif</mml:mi></mml:mrow></mml:mfenced>mml:mo⋅</mml:mo>mml:mip</mml:mi></mml:math>

在这里,p是处理器的数量,f是剩余顺序任务的比例。因此,(1-f)*p表示通过将(1-f)任务分布在p个处理器上实现的加速,而p表示增加资源时所做的额外工作。

Gustafson 定律公式表明,当降低f时,速度提升受并行化影响,当增加p时,受可扩展性影响。

与 Amdahl 定律一样,Gustafson 定律公式是一个近似值,当测量并行系统改进时提供了有价值的视角。其他因素可能会降低效率,例如处理器或内存与存储之间的开销通信。

摘要

在本章中,我们学习了我们可以用来构建并行系统的不同架构和模型。然后我们探讨了可用来开发并行软件的各种并行编程范式的细节,并了解了它们的行为和细微差别。最后,我们定义了一些有用的指标来衡量并行程序的性能。

在下一章中,我们将探讨硬件和软件之间的关系,以及软件如何映射和与底层硬件交互。我们还将学习线程、进程和服务是什么,线程是如何调度的,以及它们如何相互通信。此外,我们还将涵盖进程间通信等内容。

进一步阅读

第二章:进程、线程和服务

异步编程涉及在不等待操作完成的情况下启动操作,然后再进行下一项任务。这种非阻塞行为允许开发出高度响应和高效的应用程序,能够同时处理大量操作,而无需不必要的延迟或浪费计算资源等待任务完成。

异步编程非常重要,尤其是在网络应用、用户界面和系统编程的开发中。它使开发者能够创建能够管理大量请求、执行输入/输出I/O)操作或高效执行并发任务的应用程序,从而显著提升用户体验和应用性能。

Linux 操作系统(在本书中,当代码无法实现平台无关性时,我们将专注于 Linux 操作系统的开发),凭借其强大的进程管理、对线程的原生支持以及高级 I/O 能力,是开发高性能异步应用的理想环境。这些系统提供了一组丰富的功能,例如强大的进程和线程管理 API、非阻塞 I/O 以及进程间通信IPC)机制。

本章是介绍 Linux 环境中异步编程的基本概念和组件的入门。

我们将探讨以下主题:

  • Linux 中的进程

  • 服务和守护进程

  • 线程和并发

在本章结束时,你将具备 Linux 异步编程领域的坚实基础理解,为后续章节的深入探索和实际应用奠定基础。

Linux 中的进程

进程可以被定义为正在运行的程序的一个实例。它包括程序的代码、属于此进程的所有线程(由程序计数器表示)、堆栈(堆栈是一个包含临时数据如函数参数、返回地址和局部变量的内存区域)、堆(用于动态分配的内存),以及包含全局变量和初始化变量的数据段。每个进程在其自己的虚拟地址空间中运行,并且与其他进程隔离,确保其操作不会直接干扰其他进程。

进程生命周期——创建、执行和终止

进程的生命周期可以分为三个主要阶段:创建、执行和终止:

  • 创建:使用fork()系统调用创建一个新的进程,该调用通过复制现有进程来创建新进程。调用fork()的进程是父进程,而新创建的进程是子进程。这种机制对于在系统中执行新程序至关重要,并且是并发执行不同任务的先决条件。

  • 执行:在创建后,子进程可以执行与父进程相同的代码,或者使用exec()系列系统调用来加载并运行不同的程序。

    如果父进程有多个执行线程,则只有调用fork()的线程在子进程中复制。因此,子进程包含一个线程:执行fork()系统调用的那个线程。

    由于只有调用fork()的线程被复制到子进程中,因此在fork()发生时由其他线程持有的任何互斥锁mutexes)、条件变量或其他同步原语将保持在其父进程中的当前状态,但不会传递到子进程中。这可能导致复杂的同步问题,因为被其他线程(在子进程中不存在)锁定的互斥锁可能保持锁定状态,如果子进程尝试解锁或等待这些原语,可能会造成死锁。

    在这个阶段,进程执行其指定的操作,如从文件中读取或写入文件以及与其他进程通信。

  • 终止:进程可以通过调用exit()系统调用来自愿终止,或者由于收到来自另一个进程的信号而被迫终止。在终止时,进程向其父进程返回退出状态并将其资源释放回系统。

进程生命周期对于异步操作至关重要,因为它使得多个任务可以并发执行。

每个进程都由一个唯一的进程 IDPID)标识,这是一个内核用来管理进程的整数。PID 用于控制和监控进程。父进程也使用 PID 与子进程通信或控制其执行,例如等待其终止或发送信号。

Linux 提供了进程控制和信号机制,允许进程异步地进行管理和通信。信号是 IPC 的主要手段之一,使得进程能够中断或被通知事件。例如,kill命令可以向进程发送信号以停止其运行或提示其重新加载配置文件。

进程调度是 Linux 内核如何分配 CPU 时间给进程的方式。调度程序根据旨在优化响应性和效率等因素的调度算法和政策,确定在任何给定时间运行哪个进程。进程可以处于各种状态,如运行、等待或停止,调度程序在它们之间转换状态以有效地管理执行。

探索 IPC

在 Linux 操作系统中,进程是独立运行的,这意味着它们不能直接访问其他进程的内存空间。当多个进程需要通信和同步它们的行为时,这种进程的隔离性质会带来挑战。为了解决这些挑战,Linux 内核提供了一套灵活的 IPC 机制。每种 IPC 机制都针对不同的场景和需求进行了定制,使开发者能够构建复杂、高性能的应用程序,并有效地利用异步处理。

理解这些 IPC 技术对于旨在创建可扩展和高效应用的开发者至关重要。IPC 允许进程交换数据、共享资源并协调它们的活动,从而促进软件系统不同组件之间顺畅且可靠的通信。通过利用适当的 IPC 机制,开发者可以在其应用程序中实现提高吞吐量、降低延迟和增强并发性,从而带来更好的性能和用户体验。

在多任务环境中,当多个进程并发运行时,IPC 在实现任务的高效和协调执行中起着至关重要的作用。例如,考虑一个处理来自客户端的多个并发请求的 Web 服务器应用程序。Web 服务器进程可能会使用 IPC 与负责处理每个请求的子进程进行通信。这种方法允许 Web 服务器同时处理多个请求,从而提高应用程序的整体性能和可扩展性。

IPC 在分布式系统或微服务架构中也是一个至关重要的场景。在这样的环境中,多个独立的进程或服务需要通信和协作以实现共同的目标。如消息队列、套接字或远程过程调用RPCs)等 IPC 机制使这些进程能够交换消息、在远程对象上调用方法并同步它们的行为,确保 IPC 的无缝和可靠。

通过利用 Linux 内核提供的 IPC 机制,开发者可以设计出多个进程能够和谐协作的系统。这使创建复杂、高性能的应用程序成为可能,这些应用程序能够高效地利用系统资源,有效地处理并发任务,并且能够轻松扩展以满足不断增长的需求。

Linux 中的 IPC 机制

Linux 支持多种 IPC 机制,每种机制都有其独特的特性和用例。

Linux 操作系统支持的 IPC 基本机制包括共享内存,它通常用于单个服务器上的进程通信,以及套接字,它促进了服务器间的通信。还有其他机制(在此简要描述),但共享内存和套接字是最常用的:

  • 管道和命名管道:管道是 IPC 中最简单的一种形式,允许进程之间进行单向通信。命名管道,或称为先进先出FIFO),通过提供一种可通过文件系统中的名称访问的管道来扩展这一概念,允许无关的进程进行通信。

  • 信号:信号是一种软件中断,可以向进程发送以通知其事件。虽然它们不是传输数据的方法,但信号对于控制进程行为和触发进程内的操作非常有用。

  • 消息队列:消息队列允许进程以先进先出(FIFO)的方式交换消息。与管道不同,消息队列支持异步通信,其中消息被存储在队列中,接收进程可以在方便的时候检索。

  • 信号量:信号量用于同步,帮助进程管理对共享资源的访问。它们通过确保在任何给定时间只有指定数量的进程可以访问资源来防止竞争条件。

  • 共享内存:共享内存是 IPC 中的一个基本概念,它使多个进程能够访问和操作同一物理内存段。它提供了一种快速交换不同进程间数据的方法,减少了耗时数据复制操作的需求。当处理大型数据集或需要高速通信时,这种技术特别有利。共享内存的机制涉及创建一个共享内存段,这是多个进程可访问的物理内存的专用部分。这个共享内存段被视为一个公共工作区,允许进程读取、写入和协作修改数据。为确保数据完整性和防止冲突,共享内存需要同步机制,如信号量或互斥锁。这些机制调节对共享内存段的访问,防止多个进程同时修改相同的数据。这种协调对于维护数据一致性、避免覆盖或损坏至关重要。

    在性能至关重要的单服务器环境中,共享内存通常是首选的进程间通信(IPC)机制。其主要优势在于其速度。由于数据直接在物理内存中共享,无需中间复制或上下文切换,这显著减少了通信开销并最小化了延迟。

    然而,共享内存也带来了一些考虑因素。它需要谨慎管理以防止竞争条件和内存泄漏。访问共享内存的进程必须遵守定义良好的协议,以确保数据完整性和避免死锁。此外,共享内存通常作为系统级功能实现,需要特定的操作系统支持,并可能引入平台特定的依赖。

    尽管有这些考虑,共享内存仍然是一种强大且广泛使用的 IPC 技术,尤其是在速度和性能是关键因素的应用程序中。

  • 套接字:套接字是操作系统中进行 IPC 的基本机制。它们为进程之间相互通信提供了一种方式,无论是同一台机器内部还是跨网络。套接字用于建立和维护进程之间的连接,并支持 面向连接无连接通信

    面向连接的通信是一种通信类型,在传输任何数据之前,在两个进程之间建立了一个可靠的连接。这种类型的通信通常用于文件传输和远程登录等应用程序,在这些应用程序中,确保所有数据可靠且按正确顺序交付非常重要。无连接通信是一种通信类型,在传输数据之前,在两个进程之间不建立可靠的连接。这种类型的通信通常用于流媒体和实时游戏等应用程序,在这些应用程序中,低延迟比保证所有数据的可靠交付更重要。

    套接字是网络应用程序的骨干。它们被各种应用程序使用,包括网页浏览器、电子邮件客户端和文件共享应用程序。套接字也被许多操作系统服务使用,例如 网络文件系统NFS)和 域名系统DNS)。

    这里是使用套接字的一些关键好处:

    • 可靠性:套接字提供了一种可靠的方式来在进程之间进行通信,即使这些进程位于不同的机器上。

    • 可扩展性:套接字可以用来支持大量的并发连接,这使得它们非常适合需要处理大量流量的应用程序。

    • 灵活性:套接字可以用来实现各种通信协议,这使得它们适用于广泛的用途。

    • 在 IPC 中的应用:套接字是 IPC 的强大工具。它们被广泛的应用程序使用,对于构建可扩展、可靠和灵活的网络应用程序至关重要。

基于微服务应用程序是异步编程的一个例子,使用不同的进程以异步方式相互通信。一个简单的例子就是一个日志处理器。不同的进程生成日志条目并将它们发送到另一个进程进行进一步处理,例如特殊格式化、去重和统计。生产者只需发送日志行,而不需要等待它们发送到日志的进程的任何回复。

在本节中,我们了解了 Linux 中的进程、它们的生命周期以及操作系统如何实现进程间通信(IPC)。在下一节中,我们将介绍一种特殊的 Linux 进程,称为 守护进程

Linux 中的服务和守护进程

在 Linux 操作系统领域,守护进程是运行在后台的基本组件,默默地执行关键任务,而不需要交互式用户的直接参与。这些进程传统上以其以字母d结尾的名称来识别,例如sshd代表Secure ShellSSH)守护进程和httpd代表Web 服务器守护进程。它们在处理对操作系统及其上运行的应用程序都至关重要的系统级任务中发挥着至关重要的作用。

守护进程服务于一系列目的,从文件服务、Web 服务和网络通信到日志和监控服务。它们被设计成自主和弹性,在系统启动时启动,并持续运行直到系统关闭。与由用户启动和控制的常规进程不同,守护进程具有独特的特征:

  • 后台操作

    • 守护进程在后台运行

    • 它们缺乏控制终端以进行直接用户交互

    • 它们不需要用户界面或手动干预来执行任务

  • 用户独立性

    • 守护进程独立于用户会话运行

    • 它们无需直接用户参与即可自主运行

    • 它们等待系统事件或特定请求来触发它们的操作

  • 面向任务的焦点

    • 每个守护进程都针对执行特定任务或一系列任务而定制

    • 它们被设计来处理特定功能或监听特定事件或请求

    • 这确保了任务执行的效率

创建守护进程过程不仅仅是运行一个后台进程。为了确保作为守护进程的有效运行,开发者必须考虑几个关键步骤:

  1. 脱离终端fork()系统调用用于使守护进程脱离终端。在 fork 之后,父进程退出,留下子进程在后台运行。

  2. 会话创建setsid()系统调用创建一个新的会话,并将调用进程指定为会话和进程组的领导者。这一步对于完全脱离终端至关重要。

  3. 工作目录更改:为了防止阻止文件系统的卸载,守护进程通常将其工作目录更改为根目录。

  4. 文件描述符处理:守护进程关闭继承的文件描述符,并且通常将stdinstdoutstderr重定向到/dev/null

  5. 信号处理:正确处理信号,例如用于配置重新加载的SIGHUP或用于优雅关闭的SIGTERM,对于有效的守护进程管理至关重要。

守护进程通过各种 IPC 机制与其他进程或守护进程进行通信。

守护进程是许多异步系统架构的组成部分,提供基本服务而不需要直接用户交互。以下是一些守护进程的突出用例:

  • Web 服务器:如httpd和 nginx 之类的守护进程响应客户端请求提供网页服务,处理多个并发请求并确保无缝的网页浏览。

  • 数据库服务器:如 mysqld 和 postgresql 之类的守护进程管理数据库服务,允许各种应用程序异步访问和操作数据库。

  • 文件服务器:如smbdnfsd之类的守护进程提供网络化文件服务,使不同系统之间的异步文件共享和访问成为可能。

  • 日志和监控:如syslogdsnmpd之类的守护进程收集和记录系统事件,提供对系统健康和性能的异步监控。

总结来说,守护进程是 Linux 系统中的关键组件,在后台默默执行关键任务,以确保系统平稳运行和应用程序高效执行。它们的自主性和弹性使它们对于维护系统稳定性和向用户和应用程序提供基本服务至关重要。

我们已经看到了进程和守护进程,这是一种特殊的进程类型。一个进程可以有一个或多个执行线程。在下一节中,我们将介绍线程。

线程

进程和线程代表了两种基本的并发执行代码的方式,但它们在操作和资源管理方面存在显著差异。进程是运行程序的实例,拥有自己的私有资源集合,包括内存、文件描述符和执行上下文。进程之间是隔离的,这为系统提供了强大的稳定性,因为一个进程的失败通常不会影响其他进程。

线程是计算机科学中的一个基本概念,代表了在单个进程中执行多个任务的轻量级和高效方式。与拥有自己私有内存空间和资源的独立实体进程不同,线程与其所属的进程紧密相连。这种亲密关系使得线程可以共享相同的内存空间和资源,包括文件描述符、堆内存以及进程分配的任何其他全局数据结构。

线程的一个关键优势是它们能够有效地进行通信和共享数据。由于进程内的所有线程共享相同的内存空间,它们可以直接访问和修改公共变量,而无需复杂的 IPC 机制。这种共享环境使得数据交换迅速,并促进了并发算法和数据结构的实现。

然而,共享相同的内存空间也引入了管理对共享资源访问的挑战。为了防止数据损坏并确保共享数据的一致性,线程必须采用同步机制,例如锁、信号量或互斥锁。这些机制强制执行访问共享资源的规则和协议,确保在任何给定时间只有一个线程可以访问特定的资源。

在多线程编程中,有效的同步至关重要,以避免竞争条件、死锁和其他并发相关的问题。

为了应对这些挑战,已经开发出各种同步原语和技术。这些包括互斥锁,它提供对共享资源的独占访问,信号量,它允许对有限数量的资源进行受控访问,以及条件变量,它使线程能够在满足特定条件之前等待。

通过仔细管理同步并采用适当的并发模式,开发者可以利用线程的强大功能,在他们的应用程序中实现高性能和可伸缩性。线程特别适合于可以并行化的任务,如图像处理、科学模拟和 Web 服务器,在这些任务中,可以并发执行多个独立的计算。

如前所述,线程是系统线程。这意味着它们是由内核创建和管理的。然而,存在一些场景,我们将在第八章中深入探讨,在这些场景中,我们需要大量的线程。在这种情况下,系统可能没有足够的资源来创建大量的系统线程。解决这个问题的方法是使用用户线程。实现用户线程的一种方法是通过协程,自 C++20 以来,协程已被纳入 C++标准。

协程是 C++中相对较新的特性。协程可以被定义为可以在特定点暂停和恢复的函数,允许在单个线程内进行协作式多任务处理。与从开始到结束不间断运行的常规函数不同,协程可以挂起其执行并将控制权交还给调用者,调用者可以在稍后从暂停点恢复协程。

协程比系统线程轻量得多。这意味着它们可以更快地创建和销毁,并且需要更少的开销。

协程是协作式的,这意味着它们必须显式地将控制权交还给调用者,以便切换执行上下文。在某些情况下,这可能是一个缺点,但也可以是一个优点,因为它使用户程序对协程的执行有更多的控制。

协程可以用来创建各种不同的并发模式。例如,协程可以用来实现任务,这些是轻量级的工作单元,可以调度和并发运行。协程还可以用来实现通道,这些是可以在它们之间传递数据的通信通道。

协程可以分为有栈和无栈两类。C++20 的协程是无栈的。我们将在第八章中深入探讨这些概念。

总体而言,协程是创建 C++ 中并发程序的有力工具。它们轻量级、协作式,可以用来实现各种不同的并发模式。它们不能完全用于实现并行性,因为协程仍然需要 CPU 执行上下文,这只能由线程提供。

线程生命周期

系统线程的生命周期,通常被称为轻量级进程,包括从其创建到终止的阶段。每个阶段在管理并发编程环境中的线程方面都发挥着至关重要的作用:

  1. 创建:此阶段始于在系统中创建新线程时。创建过程涉及使用函数,该函数需要几个参数。一个关键参数是线程的属性,例如其调度策略、堆栈大小和优先级。另一个重要参数是线程将要执行的函数,称为起始例程。成功创建后,线程将分配其自己的堆栈和其他资源。

  2. 执行:线程创建后,开始执行其分配的起始例程。在执行过程中,线程可以独立执行各种任务,或者在必要时与其他线程交互。线程还可以创建和管理自己的局部变量和数据结构,使其成为自包含的,能够并行执行特定任务。

  3. 同步:为确保有序访问共享资源并防止数据损坏,线程使用同步机制。常见的同步原语包括锁、信号量和屏障。适当的同步允许线程协调其活动,避免竞争条件、死锁和其他在并发编程中可能出现的问题。

  4. 终止:线程可以通过多种方式终止。它可以显式调用函数来终止自身。它也可以通过从其起始例程返回来终止。在某些情况下,线程可以通过另一个线程使用函数来取消。终止后,系统回收分配给线程的资源,并释放线程持有的任何挂起操作或锁。

理解系统线程的生命周期对于设计和实现并发程序至关重要。通过仔细管理线程的创建、执行、同步和终止,开发者可以创建高效且可扩展的应用程序,从而利用并发的优势。

线程调度

系统线程由操作系统内核的调度器管理,是抢占式调度的。调度器根据线程优先级、分配的时间或互斥锁阻塞等因素决定何时在线程之间切换执行。这种由内核控制的上下文切换可能会产生显著的开销。上下文切换的高成本,加上每个线程的资源使用(如其自己的堆栈),使得协程在某些应用程序中成为一种更有效的替代方案,因为我们可以在单个线程中运行多个协程。

协程提供了几个优点。首先,它们减少了与上下文切换相关的开销。由于协程的 yield 或 await 上的上下文切换由用户空间代码而不是内核处理,因此过程更加轻量级和高效。这导致了显著的性能提升,尤其是在频繁发生上下文切换的场景中。

协程还提供了对线程调度的更多控制。开发者可以根据其应用程序的具体要求定义自定义调度策略。这种灵活性允许进行精细的线程管理、资源利用优化以及达到期望的性能特性。

协程的另一个重要特性是,与系统线程相比,它们通常更轻量级。协程不维护自己的堆栈,这是一个巨大的资源消耗优势,使它们适合资源受限的环境。

总体而言,协程提供了一种更高效、更灵活的线程管理方法,尤其是在需要频繁上下文切换或需要精细控制线程调度的情况中。线程可以访问内存进程,并且这种内存被所有线程共享,因此我们需要小心并控制内存访问。这种控制是通过称为同步原语的不同机制实现的。

同步原语

同步原语是管理多线程编程中共享资源并发访问的必要工具。存在几种同步原语,每种都有其特定的用途和特性:

  • 互斥锁:互斥锁用于强制对代码关键部分的独占访问。一个线程可以锁定互斥锁,防止其他线程进入受保护的区域,直到互斥锁被解锁。互斥锁确保在任何给定时间只有一个线程可以执行关键部分,从而确保数据完整性和防止竞态条件。

  • 信号量:信号量比互斥锁更灵活,可以用于更广泛的同步任务,包括线程之间的信号。信号量维护一个整数计数器,可以被线程递增(信号)或递减(等待)。信号量允许更复杂的协调模式,例如计数信号量(用于资源分配)和二进制信号量(类似于互斥锁)。

  • 条件变量:条件变量用于基于特定条件的线程同步。线程可以阻塞(在条件变量上等待)直到特定条件变为真。其他线程可以通知条件变量,导致等待的线程唤醒并继续执行。条件变量通常与互斥锁结合使用,以实现更细粒度的同步并避免忙等待。

  • 其他同步原语:除了之前讨论的核心同步原语之外,还有几种其他同步机制:

    • 屏障:屏障允许一组线程同步它们的执行,确保在进一步执行之前所有线程都达到某个点

    • 读写锁:读写锁提供了一种控制对共享数据并发访问的方法,允许多个读者但一次只有一个写者

    • 自旋锁:自旋锁是一种互斥锁,它涉及忙等待,持续检查一个内存位置,直到它变得可用

在第四章和第五章中,我们将深入探讨 C++ 标准模板库STL)中实现的同步原语及其使用示例。

选择合适的同步原语

选择适当的同步原语取决于应用程序的具体要求和访问的共享资源的性质。以下是一些一般性指南:

  • 互斥锁:当需要独占访问临界区以确保数据完整性和防止竞态条件时,请使用互斥锁

  • 信号量:当需要更复杂的协调模式时,例如资源分配或线程间的信号,请使用信号量

  • 条件变量:当线程需要在继续之前等待特定条件变为真时,请使用条件变量

有效使用同步原语对于开发安全且高效的并发程序至关重要。通过了解不同同步机制的目的和特性,开发者可以选择最适合其特定需求的原语,并实现可靠和可预测的并发执行。

使用多个线程时常见的问题

线程引入了几个挑战,必须管理这些挑战以确保应用程序的正确性和性能。这些挑战源于多线程编程固有的并发性和非确定性。

  • 竞态条件发生在多个线程并发访问和修改共享数据时。竞态条件的结局取决于线程操作的不可确定顺序,这可能导致不可预测和不一致的结果。例如,考虑两个更新共享计数器的线程。如果线程并发地增加计数器,最终值可能会由于竞态条件而不正确。

  • 死锁发生在两个或更多线程无限期地等待彼此持有的资源时。这会形成一个无法解决的依赖循环,导致线程永久性地被阻塞。例如,考虑两个正在等待对方释放共享资源锁的线程。如果两个线程都不释放它们持有的锁,就会发生死锁。

  • 饥饿发生在线程始终无法访问它需要以取得进展的资源时。这可能会发生在其他线程持续获取并持有资源的情况下,使得饥饿的线程无法执行。

  • 活锁就像死锁一样,但线程不是永久性地被阻塞,而是保持活跃状态并反复尝试获取资源,只是没有任何进展。

可以使用几种技术来管理线程的挑战,包括以下内容:

  • 同步机制:如前所述,同步原语,如锁和互斥量,可以用来控制对共享数据的访问,并确保一次只有一个线程可以访问数据。

  • 死锁预防和检测:死锁预防算法可以用来避免死锁,而死锁检测算法可以用来在发生死锁时识别和解决死锁。

  • 线程调度:线程调度算法可以用来确定在任何给定时间应该运行哪个线程,以及哪些可以帮助防止饥饿并提高应用程序性能。我们将更详细地了解多线程问题的不同解决方案。

有效线程管理策略

有不同的方法来处理线程以避免多线程问题。以下是一些处理线程的最常见方法:

  • 最小化共享状态:尽可能设计线程在私有数据上操作,这可以显著减少同步的需求。通过使用线程局部存储为线程特定的数据分配内存,可以消除全局变量的需求,进一步减少数据竞争的可能性。通过同步原语仔细管理共享数据访问对于确保数据完整性至关重要。这种方法通过最小化同步需求并确保共享数据以受控和一致的方式访问,提高了多线程应用程序的效率和正确性。

  • 锁层次结构:在多线程编程中,建立良好的锁层次结构对于防止死锁至关重要。锁层次结构规定了锁获取和释放的顺序,确保线程之间有一个一致的锁定模式。通过以层次化的方式获取锁,从最粗粒度到最细粒度,可以显著减少死锁的可能性。

    粗粒度级别指的是控制对共享资源大部分访问的锁,而细粒度锁用于资源的特定、细粒度部分。通过首先获取粗粒度锁,线程可以获取对资源较大部分的独占访问,从而降低与其他线程尝试访问同一资源发生冲突的可能性。一旦获取了粗粒度锁,就可以获取细粒度锁来控制对资源特定部分的访问,提供更细粒度的控制并减少其他线程的等待时间。

    在某些情况下,可以使用无锁数据结构来完全消除对锁的需求。无锁数据结构旨在提供对共享资源的并发访问,而不需要显式的锁。相反,它们依赖于原子操作和非阻塞算法来确保数据完整性和一致性。通过利用无锁数据结构,消除了与锁获取和释放相关的开销,从而在多线程应用程序中提高了性能和可伸缩性:

  • 超时:为了防止线程在尝试获取锁时无限期等待,为锁获取设置超时非常重要。这确保了如果线程在指定的超时期间无法获取锁,它将自动放弃并稍后再次尝试。这有助于防止死锁并确保没有线程被无限期地留下等待。

  • 线程池:管理一组可重用线程是优化多线程应用程序性能的关键技术。通过动态创建和销毁线程,可以显著减少线程创建和终止的开销。线程池的大小应根据应用程序的工作负载和资源约束进行调整。池太小可能导致任务等待可用的线程,而池太大可能浪费资源。工作队列用于管理任务并将它们分配给池中的可用线程。任务被添加到队列中,并由线程按 FIFO 顺序处理。这确保了公平性并防止了任务的饥饿。使用工作队列还可以实现负载均衡,因为任务可以均匀地分配到可用的线程中。

  • 同步原语:理解不同类型的同步原语,例如互斥锁、信号量和条件变量。根据特定场景的同步需求选择合适的原语。正确使用同步原语以避免竞态条件和死锁。

  • 测试与调试:彻底测试多线程应用程序,以识别和修复线程问题。使用线程清理器和性能分析器等工具来检测数据竞争和性能瓶颈。采用逐步执行和线程转储等调试技术来分析和解决线程问题。我们将在第十一章和第十二章中看到测试和调试。

  • 可扩展性和性能考虑:设计线程安全的数据结构和算法,以确保可扩展性和性能。平衡线程数量与可用资源,以避免过度订阅。监控系统指标,如 CPU 利用率和线程竞争,以识别潜在的性能瓶颈。

  • 沟通与协作:促进在多线程代码上工作的开发者之间的协作,以确保一致性和正确性。建立线程管理的编码规范和最佳实践,以维护代码质量和可读性。随着应用程序的发展,定期审查和更新线程策略。

线程是一种强大的工具,可以用来提高应用程序的性能和可扩展性。然而,了解线程的挑战并使用适当的技巧来管理这些挑战是非常重要的。通过这样做,开发者可以创建正确、高效和可靠的多线程应用程序。

摘要

在本章中,我们探讨了操作系统中的进程概念。进程是执行程序并管理计算机资源的根本实体。我们深入研究了进程生命周期,检查了进程从创建到终止所经历的各个阶段。此外,我们还讨论了进程间通信(IPC),这对于进程之间相互交互和交换信息至关重要。

此外,我们在 Linux 操作系统的背景下介绍了守护进程。守护进程是作为服务在后台运行的特定类型的进程,执行诸如管理系统资源、处理网络连接或为系统提供其他基本服务等特定任务。我们还探讨了系统和用户线程的概念,它们是与父进程共享相同地址空间的轻量级进程。我们讨论了多线程应用程序的优势,包括改进的性能和响应性,以及管理并同步单个进程内多个线程的挑战。

了解由多线程引起的不同问题是理解如何修复它们的基础。在下一章中,我们将看到如何创建线程,然后在第四章第五章中,我们将深入研究标准 C++提供的不同同步原语及其不同应用。

进一步阅读

  • [Butenhof, 1997] David R. Butenhof, 《POSIX 线程编程》 , Addison Wesley, 1997。

  • [Kerrisk, 2010] Michael Kerrisk, 《Linux 编程接口》 , No Starch Press, 2010.

  • [Stallings, 2018] William Stallings, 《操作系统内部机制与设计原则》 , 第九版,Pearson Education 2018.

  • [Williams, 2019] Anthony Williams, 《C++并发实战》 , 第二版,Manning 2019.

第二部分:高级线程管理和同步技术

在这部分,我们基于并行编程的基础知识,深入探讨管理线程和同步并发操作的高级技术。我们将探讨诸如线程创建和管理、线程间的异常处理以及高效的线程协调等基本概念,从而对包括互斥锁、信号量、条件变量和原子操作在内的关键同步原语有一个扎实的理解。所有这些知识将使我们具备实现基于锁和无锁多线程解决方案的工具,为高性能并发系统提供一瞥,并提供在管理多线程系统时避免常见陷阱(如竞态条件、死锁和活锁)所需的技能。

本部分包含以下章节:

  • 第三章 , 如何在 C++中创建和管理线程

  • 第四章 , 使用锁进行线程同步

  • 第五章 , 原子操作

第三章:如何在 C++中创建和管理线程

正如我们在前两章所学,线程是程序中执行的最小且最轻量级的单元。每个线程负责由操作系统调度器在分配的 CPU 资源上运行的指令序列定义的唯一任务。当管理程序中的并发性以最大化 CPU 资源利用率时,线程发挥着关键作用。

在程序的启动过程中,在内核将执行权传递给进程之后,C++运行时创建主线程并执行main()函数。之后,可以创建额外的线程来将程序分割成不同的任务,这些任务可以并发运行并共享资源。这样,程序可以处理多个任务,提高效率和响应速度。

在本章中,我们将学习如何使用现代 C++特性创建和管理线程的基础知识。在随后的章节中,我们将遇到关于 C++锁同步原语(互斥锁、信号量、屏障和自旋锁)、无锁同步原语(原子变量)、协调同步原语(条件变量)以及使用 C++解决或避免并发或多线程使用时潜在问题的方法的解释(竞争条件或数据竞争、死锁、活锁、饥饿、过载订阅、负载均衡和线程耗尽)。

在本章中,我们将介绍以下主要主题:

  • 如何在 C++中创建、管理和取消线程

  • 如何向线程传递参数并从线程获取结果

  • 如何让线程休眠或让其他线程执行

  • jthread 对象是什么以及为什么它们有用

技术要求

在本章中,我们将使用 C++11 和 C++20 开发不同的解决方案。因此,我们需要安装GNU 编译器集合GCC),特别是 GCC 13,以及 Clang 8(有关 C++编译器支持的详细信息,请参阅en.cppreference.com/w/cpp/compiler_support)。

你可以在gcc.gnu.org找到更多关于 GCC 的信息。你可以在gcc.gnu.org/install/index.html找到有关如何安装 GCC 的信息。

想了解更多关于支持包括 C++在内的多种语言的编译器前端 Clang 的信息,请访问clang.llvm.org。Clang 是 LLVM 编译器基础设施项目的一部分(llvm.org)。Clang 中的 C++支持在此处文档化:clang.llvm.org/cxx_status.html

在这本书中,一些代码片段没有显示包含的库。此外,一些函数,即使是主要的函数,也可能被简化,只显示相关的指令。你可以在以下 GitHub 仓库中找到所有完整的代码:github.com/PacktPublishing/Asynchronous-Programming-with-CPP

在前一个 GitHub 仓库的根目录下的 scripts 文件夹中,你可以找到一个名为 install_compilers.sh 的脚本,这个脚本可能有助于在基于 Debian 的 Linux 系统中安装所需的编译器。该脚本已在 Ubuntu 22.04 和 24.04 上进行了测试。

本章的示例位于 Chapter_03 文件夹下。所有源代码文件都可以使用 C++20 和 CMake 编译,如下所示:

cmake . && cmake —build .

可执行文件将在 bin 目录下生成。

线程库——简介

在 C++ 中创建和管理线程的主要库是线程库。首先,让我们回顾一下线程。然后我们将深入了解线程库提供了什么。

什么是线程?让我们回顾一下

线程的目的是在一个进程中执行多个同时任务。

正如我们在前一章中看到的,线程有自己的堆栈、局部数据和 CPU 寄存器,如 指令指针 ( IP ) 和 堆栈指针 ( SP ),但与父进程共享地址空间和虚拟内存。

在用户空间中,我们可以区分 原生线程轻量级或虚拟线程。原生线程是在使用某些内核 API 时由操作系统创建的。C++ 线程对象创建和管理这些类型的线程。另一方面,轻量级线程类似于原生线程,但它们是由运行时或库模拟的。如前一章所述,轻量级线程比原生线程具有更快的上下文切换。此外,多个轻量级线程可以在同一个原生线程中运行,并且可以比原生线程小得多。

在本章中,我们将开始学习原生线程。在 第八章 中,我们将学习以协程形式存在的轻量级线程。

C++ 线程库

在 C++ 中,线程允许多个函数并发运行。线程类定义了一个对原生线程的类型安全接口。这个类在 std::thread 库中定义,位于 Standard Template Library ( STL ) 的 头文件中。从 C++11 开始,它就可用。

在 C++ STL 中包含线程库之前,开发者使用特定平台的库,例如 Unix 或 Linux 操作系统中的 POSIX 线程(pthread)库,Windows NT 和 CE 系统的 C 运行时CRT)和 Win32 库,或者第三方库如 Boost.Threads。在这本书中,我们将只使用现代 C++ 功能。由于 可用并提供在特定平台机制之上的可移植抽象,因此不会使用或解释这些库。在 第九章 中,我们将介绍 Boost.Asio,在 第十章 中,Boost.Cobalt。这两个库都提供了处理异步 I/O 操作和协程的高级框架。

现在是时候学习不同的线程操作了。

线程操作

在本节中,我们将学习如何创建线程,在它们的构造过程中传递参数,从线程返回值,取消线程执行,捕获异常,以及更多。

线程创建

当创建一个线程时,它将立即执行。它只是被操作系统调度过程所延迟。如果没有足够的资源并行运行父线程和子线程,它们运行的顺序是不确定的。

构造函数参数定义了线程将要执行的功能或函数对象。这个可调用对象不应该返回任何内容,因为它的返回值将被忽略。如果由于某种原因线程执行以异常结束,除非捕获到异常,否则将调用 std::terminate,正如我们将在本章后面看到的那样。

在以下示例中,我们使用不同的可调用对象创建了六个线程。

t1 使用函数指针:

void func() {
    std::cout << "Using function pointer\n";
}
std::thread t1(func);

t2 使用 lambda 函数:

auto lambda_func = []() {
    std::cout << "Using lambda function\n";
};
std::thread t2(lambda_func);

t3 使用内嵌的 lambda 函数:

std::thread t3([]() {
    std::cout << "Using embedded lambda function\n";
});

t4 使用函数对象,其中operator()被重载:

class FuncObjectClass {
   public:
    void operator()() {
        std::cout << "Using function object class\n";
    }
};
std::thread t4{FuncObjectClass()};

t5 通过传递成员函数的地址和对象的地址来使用非静态成员函数调用成员函数:

class Obj {
  public:
    void func() {
        std::cout << "Using a non-static member function"
                  << std::endl;
    }
};
Obj obj;
std::thread t5(&Obj::func, &obj);

t6 使用静态成员函数,其中只需要成员函数的地址,因为方法是静态的:

class Obj {
  public:
    static void static_func() {
        std::cout << "Using a static member function\n";
    }
};
std::thread t6(&Obj::static_func);

线程创建会产生一些开销,可以通过使用线程池来减少,正如我们将在 第四章 中探讨的那样。

检查硬件并发

有效线程管理的一种策略,这与可扩展性和性能相关,并在上一章中进行了评论,是平衡线程数与可用资源以避免过度订阅。

要检索操作系统支持的并发线程数,我们可以使用 std:🧵:hardware_concurrency() 函数:

const auto processor_count = std::thread::hardware_concurrency();

此函数返回的值必须被视为仅提供有关将要并发运行的线程数的提示。它有时也不太明确,因此返回值为 0

同步流写入

当我们使用来自两个或更多线程的std::cout向控制台打印消息时,输出结果可能会很混乱。这是由于输出流中发生的竞态条件造成的。

如前一章所述,竞态条件是并发和多线程程序中的软件错误,其行为取决于在共享资源上发生的事件序列,其中至少有一个操作不是原子的。我们将在第四章中了解更多如何避免它们。此外,我们还将学习如何使用 Clang 的 sanitizers 在第十二章中调试竞态条件。

以下代码片段显示了两个线程打印一系列数字。t1线程应打印包含1 2 3 4序列的行。t2线程应打印5 6 7 8序列。每个线程打印其序列 100 次。在主线程退出之前,它使用join()等待t1t2完成。

本章后面将详细介绍如何加入线程。

#include <iostream>
#include <thread>
int main() {
    std::thread t1([]() {
        for (int i = 0; i < 100; ++i) {
            std::cout << "1 " << "2 " << "3 " << "4 "
                      << std::endl;
        }
    });
    std::thread t2([]() {
        for (int i = 0; i < 100; ++i) {
            std::cout << "5 " << "6 " << "7 " << "8 "
                      << std::endl;
        }
    });
    t1.join();
    t2.join();
    return 0;
}

然而,运行前面的示例显示了一些包含以下内容的行:

6 1 2 3 4
1 5 2 6 3 4 7 8
1 2 3 5 6 7 8

为了避免这些问题,我们可以简单地从一个特定的线程写入,或者使用一个std::ostringstream对象,该对象对std::cout对象进行原子调用:

std::ostringstream oss;
oss << "1 " << "2 " << "3 " << "4 " << "\n";
std::cout << oss.str();

从 C++20 开始,我们还可以使用std::osyncstream对象。它们的行为类似于std::cout,但在访问同一流的线程之间具有写入同步。然而,由于只有从其内部缓冲区到输出流的传输步骤是同步的,因此每个线程都需要自己的std::osyncstream实例。

当流被销毁时,内部缓冲区会被转移,这是在显式调用emit()时发生的。

以下是一个简单的解决方案,允许在每行打印上进行同步:

#include <iostream>
#include <syncstream>
#include <thread>
#define sync_cout std::osyncstream(std::cout)
int main() {
    std::thread t1([]() {
        for (int i = 0; i < 100; ++i) {
            sync_cout << "1 " << "2 " << "3 " << "4 "
                      << std::endl;
        }
    });
    std::thread t2([]() {
        for (int i = 0; i < 100; ++i) {
            sync_cout << "5 " << "6 " << "7 " << "8 "
                      << std::endl;
        }
    });
    t1.join();
    t2.join();
    return 0;
}

这两种解决方案都将输出序列,而不进行交错。

1 2 3 4
1 2 3 4
5 6 7 8

由于这种方法现在是官方 C++20 避免输出内容时发生竞态条件的官方方法,因此我们将使用std::osyncstream作为本书其余部分默认的方法。

使当前线程休眠

std::this_thread是一个命名空间。它提供了从当前线程访问函数以将执行权交予另一个线程或阻塞当前任务的执行并等待一段时间的功能。

std::this_thread::sleep_forstd::this_thread::sleep_until函数会阻塞线程的执行给定的时间长度。

std::this_thread::sleep_for至少休眠给定的时间长度。阻塞时间可能更长,这取决于操作系统调度器如何决定运行任务,或者由于某些资源争用延迟。

资源争用

当对某个共享资源的需求超过供应时,就会发生资源争用,导致性能下降。

std::this_thread::sleep_untilstd::this_thread::sleep_for类似。然而,它不是睡眠一段时间,而是睡眠直到达到特定的时间点。计算时间点所使用的时钟必须满足Clock要求(你可以在这里找到更多信息:en.cppreference.com/w/cpp/named_req/Clock)。标准建议使用稳定的时钟而不是系统时钟来设置持续时间。

线程识别

在调试多线程解决方案时,知道哪个线程正在执行给定的函数是有用的。每个线程都可以通过一个标识符来识别,这使得可以记录其值以进行跟踪和调试。

std:🧵:id是一个轻量级类,它定义了线程对象(std::threadstd::jthread,我们将在本章后面介绍)的唯一标识符。该标识符通过使用get_id()函数检索。

线程标识符对象可以通过输出流进行比较、序列化和打印。它们也可以用作映射容器中的键,因为它们支持std::hash函数。

以下示例打印了t线程的标识符。在本章后面,我们将学习如何创建线程并睡眠一段时间:

#include <chrono>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
void func() {
    std::this_thread::sleep_for(1s);
}
int main() {
    std::thread t(func);
    std::cout << "Thread ID: " << t.get_id() << std::endl;
    t.join();
    return 0;
}

记住,当一个线程完成时,其标识符可以被未来的线程重用。

传递参数

可以通过值、引用或指针将参数传递给线程。

在这里我们可以看到如何通过值传递参数:

void funcByValue(const std::string& str, int val) {
    sync_cout << «str: « << str << «, val: « << val
              << std::endl;
}
std::string str{"Passing by value"};
std::thread t(funcByValue, str, 1);

通过值传递可以避免数据竞争。然而,它的成本要高得多,因为数据需要复制。

下一个示例显示了如何通过引用传递值:

void modifyValues(std::string& str, int& val) {
    str += " (Thread)";
    val++;
}
std::string str{"Passing by reference"};
int val = 1;
std::thread t(modifyValues, std::ref(str), std::ref(val));

或者作为const-reference

void printVector(const std::vector<int>& v) {
    sync_cout << "Vector: ";
    for (int num : v) {
        sync_cout << num << " ";
    }
    sync_cout << std::endl;
}
std::vector<int> v{1, 2, 3, 4, 5};
std::thread t(printVector, std::cref(v));

通过引用传递是通过使用ref()(非 const 引用)或cref()(const 引用)实现的。这两个都在头文件中定义。这允许变长模板定义线程构造函数,将参数作为引用处理。

这些辅助函数用于生成std::reference_wrapper对象,这些对象将引用包装在可复制和可赋值的对象中。在传递参数时缺少这些函数会使参数以值的方式传递。

你也可以按照以下方式将对象移动到线程中:

std::thread t(printVector, std::move(v));

然而,请注意,在将v向量移动到t线程后,在主线程中尝试访问它会导致未定义的行为。

最后,我们还可以允许线程通过 lambda 捕获访问变量:

std::string str{"Hello"};
std::thread t([&]() {
    sync_cout << "str: " << str << std::endl;
});

在这个例子中,str变量是通过嵌入的 lambda 函数捕获的引用被t线程访问的。

返回值

要返回线程中计算出的值,我们可以使用带有同步机制(如互斥锁、锁或原子变量)的共享变量。

在下面的代码片段中,我们可以看到如何通过使用非 const 引用传递的参数来返回线程计算出的值(使用ref())。在func函数中,result变量在t线程中被计算。从主线程中可以看到结果值。正如我们将在下一节中学习的,join()函数只是等待t线程完成,然后让主线程继续运行,并在之后检查result变量:

#include <chrono>
#include <iostream>
#include <random>
#include <syncstream>
#include <thread>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
namespace {
int result = 0;
};
void func(int& result) {
    std::this_thread::sleep_for(1s);
    result = 1 + (rand () % 10);
}
Int main() {
    std::thread t(func, std::ref(result));
    t.join();
    sync_cout << "Result: " << result << std::endl;
}

reference 参数可以是输入对象的引用,或者是我们想要存储结果的另一个变量,就像在这个示例中用result变量所做的那样。

我们也可以使用 lambda 捕获来返回值,如下面的示例所示:

std::thread t([&]() { func(result); });
t.join();
sync_cout << "Result: " << result << std::endl;

我们也可以通过写入由互斥锁保护的共享变量来实现这一点,在执行写入操作之前锁定互斥锁(例如使用std::lock_guard)。然而,我们将在第四章中更深入地探讨这些机制:

#include <chrono>
#include <iostream>
#include <mutex>
#include <random>
#include <syncstream>
#include <thread>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
namespace {
int result = 0;
std::mutex mtx;
};
void funcWithMutex() {
    std::this_thread::sleep_for(1s);
    int localVar = 1 + (rand() % 10);
    std::lock_guard<std::mutex> lock(mtx);
    result = localVar;
}
Int main() {
    std::thread t(funcWithMutex);
    t.join();
    sync_cout << "Result: " << result << std::endl;
}

从线程返回值有更优雅的方法。这涉及到使用 future 和 promise,我们将在第六章中学习。

移动线程

线程可以移动但不能复制。这是为了避免有两个不同的线程对象来表示相同的硬件线程。

在下面的示例中,t1使用std::move移动到t2。因此,t2继承了t1移动之前的相同标识符,而t1不再可连接,因为它不再包含任何有效的线程:

#include <chrono>
#include <thread>
using namespace std::chrono_literals;
void func() {
    for (auto i=0; i<10; ++i) {
        std::this_thread::sleep_for(500ms);
    }
}
int main() {
    std::thread t1(func);
    std::thread t2 = std::move(t1);
    t2.join();
    return 0;
}

当一个std::thread对象被移动到另一个std::thread对象时,移动线程对象将达到一个不再表示真实线程的状态。这种情况也发生在分离或连接后由默认构造函数生成的线程对象上。我们将在下一节中介绍这些操作。

等待线程完成

有一些用例需要线程等待另一个线程完成,以便它可以使用后者线程计算出的结果。其他用例包括在后台运行线程,将其分离,并继续执行主线程。

连接线程

join() 函数在等待由调用join()函数的线程对象指定的连接线程完成时阻塞当前线程。这确保了在join()返回后连接线程已经终止(有关更多详细信息,请参阅第二章中的线程生命周期部分)。

很容易忘记使用join()函数。Joining Threadjthread)解决了这个问题。它从 C++20 开始可用。我们将在下一节中介绍它。

检查线程是否可连接

如果在某个线程中没有调用 join() 函数,则该线程被认为是可连接的并且是活跃的。即使线程已经执行了代码但尚未连接,这也是正确的。另一方面,默认构造的线程或已经连接的线程是不可连接的。

要检查线程是否可连接,只需使用 std:🧵:joinable() 函数。

让我们看看以下示例中 std:🧵:join()std:🧵:joinable() 的用法:

#include <chrono>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
void func() {
    std::this_thread::sleep_for(100ms);
}
int main() {
    std::thread t1;
    std::cout << "Is t1 joinable? " << t1.joinable()
              << std::endl;
    std::thread t2(func);
    t1.swap(t2);
    std::cout << "Is t1 joinable? " << t1.joinable()
              << std::endl;
    std::cout << "Is t2 joinable? " << t2.joinable()
              << std::endl;
    t1.join();
    std::cout << "Is t1 joinable? " << t1.joinable()
              << std::endl;
}

使用默认构造函数(未指定可调用对象)构造 t1 后,该线程将不可连接。由于 t2 构造时指定了函数,t2 在构造后是可连接的。然而,当 t1t2 交换时,t1 再次变为可连接的,而 t2 则不再可连接。然后主线程等待 t1 连接,因此它不再可连接。尝试连接一个不可连接的线程 t2 将导致未定义的行为。最后,不连接一个可连接的线程将导致资源泄漏或由于共享资源的意外使用而可能引发程序崩溃。

通过分离实现守护线程

如果我们希望一个线程作为守护线程在后台继续运行,但完成当前线程的执行,我们可以使用 std:🧵:detach() 函数。守护线程是在后台执行一些不需要运行到完成的任务的线程。如果主程序退出,所有守护线程都将被终止。如前所述,线程必须在主线程终止之前连接或分离,否则程序将中止执行。

在调用 detach 之后,分离的线程无法通过 std::thread 对象进行控制或连接(因为它正在等待其完成),因为这个对象不再代表分离的线程。

以下示例展示了一个名为 t 的守护线程,它在构造后立即分离,在后台运行 daemonThread() 函数。这个函数执行三秒钟后退出,完成线程执行。同时,主线程在退出前比线程执行时间多睡一秒钟:

#include <chrono>
#include <iostream>
#include <syncstream>
#include <thread>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
namespace {
int timeout = 3;
}
void daemonThread() {
    sync_cout << "Daemon thread starting...\n";
    while (timeout-- > 0) {
        sync_cout << "Daemon thread is running...\n";
        std::this_thread::sleep_for(1s);
    }
    sync_cout << "Daemon thread exiting...\n";
}
int main() {
    std::thread t(daemonThread);
    t.detach();
    std::this_thread::sleep_for(
              std::chrono::seconds(timeout + 1));
    sync_cout << "Main thread exiting...\n";
    Return 0;
}

线程连接 – jthread 类

从 C++20 开始,有一个新的类:std::jthread。这个类类似于 std::thread,但增加了额外的功能,即线程在析构时自动重新连接,遵循 资源获取即初始化RAII)技术。在某些情况下,它可以被取消或停止。

如以下示例所示,jthread 线程具有与 std::thread 相同的接口。唯一的区别是我们不需要调用 join() 函数来确保主线程等待 t 线程连接:

#include <chrono>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
void func() {
    std::this_thread::sleep_for(1s);
}
int main() {
    std::jthread t(func);
    sync_cout << "Thread ID: " << t.get_id() << std::endl;
    return 0;
}

当两个std::jthread被销毁时,它们的析构函数按照从构造函数相反的顺序被调用。为了演示这种行为,让我们实现一个线程包装类,当包装的线程被创建和销毁时打印一些消息:

#include <chrono>
#include <functional>
#include <iostream>
#include <syncstream>
#include <thread>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
class JthreadWrapper {
   public:
    JthreadWrapper(
       const std::function<void(const std::string&)>& func,
       const std::string& str)
        : t(func, str), name(str) {
        sync_cout << "Thread " << name
                  << " being created" << std::endl;
    }
    ~JthreadWrapper() {
        sync_cout << "Thread " << name
                  << " being destroyed" << std::endl;
    }
   private:
    std::jthread t;
    std::string name;
};

使用这个JthreadWrapper包装类,我们启动了三个线程来执行func函数。每个线程将在退出前等待一秒钟:

void func(const std::string& name) {
    sync_cout << "Thread " << name << " starting...\n";
    std::this_thread::sleep_for(1s);
    sync_cout << "Thread " << name << " finishing...\n";
}
int main() {
    JthreadWrapper t1(func, «t1»);
    JthreadWrapper t2(func, "t2");
    JthreadWrapper t3(func, "t3");
    std::this_thread::sleep_for(2s);
    sync_cout << "Main thread exiting..." << std::endl;
    return 0;
}

此程序将显示以下输出:

Thread t1 being created
Thread t1 starting...
Thread t2 being created
Thread t2 starting...
Thread t3 being created
Thread t3 starting...
Thread t1 finishing...
Thread t2 finishing...
Thread t3 finishing...
Main thread exiting...
Thread t3 being destroyed
Thread t2 being destroyed
Thread t1 being destroyed

如我们所见,t1首先被创建,然后是t2,最后是t3。析构函数遵循相反的顺序,t3首先被销毁,然后是t2,最后是t1

由于 jthreads 在忘记在线程中使用join时可以避免陷阱,我们更倾向于使用std::jthread而不是std::thread。可能会有一些情况,我们需要显式调用join()来确保在移动到另一个任务之前线程已经连接并且资源已经适当释放。

让出线程执行

线程也可以决定暂停其执行,让实现重新调度线程的执行,并给其他线程运行的机会。

std::this_thread::yield方法向操作系统提供提示以重新调度另一个线程。其行为依赖于实现,取决于操作系统调度程序和系统的当前状态。

一些 Linux 实现会挂起当前线程并将其移回一个线程队列以调度具有相同优先级的所有线程。如果这个队列是空的,则让出(yield)没有效果。

以下示例显示了两个线程,t1t2,执行相同的工作函数。它们随机选择要么做一些工作(锁定互斥锁,我们将在下一章中了解)或让出执行权给另一个线程:

#include <iostream>
#include <random>
#include <string>
#include <syncstream>
#include <thread>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono;
namespace {
int val = 0;
std::mutex mtx;
}
int main() {
    auto work = & {
        while (true) {
            bool work_to_do = rand() % 2;
            if (work_to_do) {
                sync_cout << name << ": working\n";
                std::lock_guard<std::mutex> lock(mtx);
                for (auto start = steady_clock::now(),
                          now = start;
                          now < start + 3s;
                          now = steady_clock::now()) {
                }
            } else {
                sync_cout << name << ": yielding\n";
                std::this_thread::yield();
            }
        }
    };
    std::jthread t1(work, "t1");
    std::jthread t2(work, "t2");
    return 0;
}

当运行此示例时,当执行达到让出(yield)命令时,我们可以看到当前运行的线程如何停止并允许其他线程重新启动其执行。

线程取消

如果我们不再对线程正在计算的结果感兴趣,我们希望取消该线程并避免更多的计算成本。

杀死线程可能是一个解决方案。然而,这会留下属于线程处理者的资源,例如从该线程启动的其他线程、锁、连接等。这可能导致程序以未定义的行为结束,在互斥锁下锁定关键部分,或任何其他意外问题。

为了避免这些问题,我们需要一个无数据竞争的机制,让线程知道停止执行(请求停止)的意图,以便线程可以采取所有必要的具体步骤来取消其工作并优雅地终止。

实现这一目标的一种可能方式是使用原子变量,该变量由线程定期检查。我们将在下一章详细探讨原子变量。现在,让我们将原子变量定义为一个变量,许多线程可以无任何锁定机制或由于原子事务操作和内存模型导致的数据竞争来读写它。

作为示例,让我们创建一个Counter类,该类每秒调用一次回调。这是无限期进行的,直到调用者使用stop()函数将running原子变量设置为false

#include <chrono>
#include <functional>
#include <iostream>
#include <syncstream>
#include <thread>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
class Counter {
    using Callback = std::function<void(void)>;
   public:
    Counter(const Callback &callback) {
        t = std::jthread([&]() {
            while (running.load() == true) {
                callback ();
                std::this_thread::sleep_for(1s);
            }
        });
    }
    void stop() { running.store(false); }
   private:
    std::jthread t;
    std::atomic_bool running{true};
};

在调用函数中,我们将如下实例化Counter。然后,在需要的时候(这里是在三秒后),我们将调用stop()函数,让Counter退出循环并终止线程执行:

int main() {
    Counter counter([&]() {
        sync_cout << "Callback: Running...\n";
    });
    std::this_thread::sleep_for(3s);
    counter.stop();
}

自 C++20 以来,出现了一种新的线程协作中断机制。这可以通过std::stop_token来实现。

线程通过调用std::stop_token::stop_requested()函数的结果来检查是否请求了停止。

要生成stop_token,我们将通过stop_source对象使用std::stop_source::get_token()函数。

这种线程取消机制是通过std::jthead对象中std::stop_source类型的内部成员实现的,其中存储了共享的停止状态。jthread构造函数接受std::stop_token作为其第一个参数。这用于在执行期间请求停止。

因此,与std::thread对象相比,std::jthread暴露了一些额外的函数来管理停止令牌。这些函数是get_stop_source()get_stop_token()request_stop()

当调用request_stop()时,它向内部停止状态发出停止请求,该状态原子更新以避免竞争条件(你将在第四章中了解更多关于原子变量的内容)。

让我们检查以下示例中所有这些函数是如何工作的。

首先,我们将定义一个模板函数来展示停止项对象(stop_tokenstop_source)的属性:

#include <chrono>
#include <iostream>
#include <string_view>
#include <syncstream>
#include <thread>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
template <typename T>
void show_stop_props(std::string_view name,
                     const T& stop_item) {
    sync_cout << std::boolalpha
              << name
              << ": stop_possible = "
              << stop_item.stop_possible()
              << ", stop_requested = "
              << stop_item.stop_requested()
              << '\n';
};

现在,在main()函数中,我们将启动一个工作线程,获取其停止令牌对象,并展示其属性:

auto worker1 = std::jthread(func_with_stop_token);
std::stop_token stop_token = worker1.get_stop_token();
show_stop_props("stop_token", stop_token);

Worker1正在运行定义在下述代码块中的func_with_stop_token()函数。在这个函数中,通过使用stop_requested()函数来检查停止令牌。如果这个函数返回true,则表示请求了停止,因此函数简单地返回,终止线程执行。否则,它将运行下一个循环迭代,使当前线程休眠 300 毫秒,直到下一次停止请求检查:

void func_with_stop_token(std::stop_token stop_token) {
    for (int i = 0; i < 10; ++i) {
        std::this_thread::sleep_for(300ms);
        if (stop_token.stop_requested()) {
            sync_cout << "stop_worker: "
                      << "Stopping as requested\n";
            return;
        }
        sync_cout << "stop_worker: Going back to sleep\n";
    }
}

我们可以通过使用线程对象返回的停止令牌来从主线程请求停止,如下所示:

worker1.request_stop();
worker1.join();
show_stop_props("stop_token after request", stop_token);

此外,我们还可以从不同的线程请求停止。为此,我们需要传递一个stop_source对象。在下面的代码片段中,我们可以看到如何使用从worker2工作线程获取的stop_source对象创建一个线程停止器:

auto worker2 = std::jthread(func_with_stop_token);
std::stop_source stop_source = worker2.get_stop_source();
show_stop_props("stop_source", stop_source);
auto stopper = std::thread( [](std::stop_source source) {
        std::this_thread::sleep_for(500ms);
        sync_cout << "Request stop for worker2 "
                  << "via source\n";
        source.request_stop();
    }, stop_source);
stopper.join();
std::this_thread::sleep_for(200ms);
show_stop_props("stop_source after request", stop_source);

stopper线程等待 0.5 秒,然后从stop_source对象请求停止。然后worker2意识到这个请求,并终止其执行,如前所述。

我们还可以注册一个回调函数,当通过停止令牌或停止源请求停止时,将调用该函数。这可以通过使用std::stop_callback对象来实现,如下面的代码块所示:

std::stop_callback callback(worker1.get_stop_token(), []{
    sync_cout << "stop_callback for worker1 "
              << "executed by thread "
              << std::this_thread::get_id() << '\n';
});
sync_cout << "main_thread: "
          << std::this_thread::get_id() << '\n';
std::stop_callback callback_after_stop(
    worker2.get_stop_token(),[] {
        sync_cout << "stop_callback for worker2 "
                  << "executed by thread "
                  << std::this_thread::get_id() << '\n';
});

如果销毁了std::stop_callback对象,将阻止其执行。例如,这个作用域内的停止回调将不会执行,因为回调对象在超出作用域时被销毁:

{
    std::stop_callback scoped_callback(
        worker2.get_stop_token(), []{
          sync_cout << "Scoped stop callback "
                    << "will not execute\n";
      }
    );
}

在已经请求停止之后,新的停止回调对象将立即执行。在以下示例中,如果已经为worker2请求了停止,callback_after_stop将在构造后立即执行 lambda 函数:

sync_cout << "main_thread: "
          << std::this_thread::get_id() << '\n';
std::stop_callback callback_after_stop(
    worker2.get_stop_token(), []{
        sync_cout << "stop_callback for worker2 "
                  << "executed by thread "
                  << std::this_thread::get_id() << '\n';
    }
);

捕获异常

在线程内部抛出的任何未处理的异常都需要在该线程内部捕获。否则,C++运行时会调用std::terminate,导致程序突然终止。这会导致意外的行为、数据丢失,甚至程序崩溃。

一种解决方案是在线程内部使用 try-catch 块来捕获异常。然而,只有在该线程内部抛出的异常才会被捕获。异常不会传播到其他线程。

要将异常传播到另一个线程,一个线程可以捕获它并将其存储到std::exception_ptr对象中,然后使用共享内存技术将其传递到另一个线程,在那里将检查std::exception_ptr对象并在需要时重新抛出异常。

以下示例展示了这种方法:

#include <atomic>
#include <chrono>
#include <exception>
#include <iostream>
#include <mutex>
#include <thread>
using namespace std::chrono_literals;
std::exception_ptr captured_exception;
std::mutex mtx;
void func() {
    try {
        std::this_thread::sleep_for(1s);
        throw std::runtime_error(
                  "Error in func used within thread");
    } catch (...) {
        std::lock_guard<std::mutex> lock(mtx);
        captured_exception = std::current_exception();
    }
}
int main() {
    std::thread t(func);
    while (!captured_exception) {
        std::this_thread::sleep_for(250ms);
        std::cout << „In main thread\n";
    }
    try {
        std::rethrow_exception(captured_exception);
    } catch (const std::exception& e) {
        std::cerr << "Exception caught in main thread: "
                  << e.what() << std::endl;
    }
    t.join();
}

在这里,我们可以看到当t线程执行func函数时抛出的std::runtime_error异常。异常被捕获并存储在captured_exception中,这是一个由互斥锁保护的std::exception_ptr共享对象。抛出异常的类型和值是通过调用std::current_exception()函数确定的。

在主线程中,while循环会一直运行,直到捕获到异常。通过调用std::rethrow_exception(captured_exception)在主线程中重新抛出异常。异常再次被主线程捕获,在catch块中执行,通过std::cerr错误流向控制台打印消息。

我们将在第六章中学习一个更好的解决方案,通过使用 future 和 promise。

线程局部存储

线程局部存储TLS)是一种内存管理技术,允许每个线程拥有自己的变量实例。这种技术允许线程存储其他线程无法访问的线程特定数据,避免竞态条件并提高性能。这是因为访问这些变量的同步机制的开销被消除了。

TLS 由操作系统实现,可以通过使用thread_local关键字访问,该关键字自 C++11 以来一直可用。thread_local提供了一种统一的方式来使用许多操作系统的 TLS 功能,并避免使用特定编译器的语言扩展来访问 TLS 功能(此类扩展的示例包括 TLS Windows API、__declspec(thread) MSVC 编译器语言扩展或__thread GCC 编译器语言扩展)。

要使用不支持 C++11 或更高版本的编译器的 TLS,请使用Boost.Library。这提供了boost::thread_specific_ptr容器,它实现了可移植的 TLS。

线程局部变量可以声明如下:

  • 全局范围内

  • 在命名空间中

  • 作为类的静态成员变量

  • 在函数内部;它具有与使用static关键字分配的变量相同的效果,这意味着变量在程序的生命周期内分配,其值在下一个函数调用中传递

以下示例展示了三个线程使用不同参数调用multiplyByTwo函数。此函数将val线程局部变量的值设置为参数值,将其乘以 2,并打印到控制台:

#include <iostream>
#include <syncstream>
#include <thread>
#define sync_cout std::osyncstream(std::cout)
thread_local int val = 0;
void setValue(int newval) { val = newval; }
void printValue() { sync_cout << val << ' '; }
void multiplyByTwo(int arg) {
    setValue(arg);
    val *= 2;
    printValue();
}
int main() {
    val = 1;  // Value in main thread
    std::thread t1(multiplyByTwo, 1);
    std::thread t2(multiplyByTwo, 2);
    std::thread t3(multiplyByTwo, 3);
    t1.join();
    t2.join();
    t3.join();
    std::cout << val << std::endl;
}

运行此代码片段将显示以下输出:

2 4 6 1

在这里,我们可以看到每个线程都操作其输入参数,导致t1打印2t2打印4t3打印6。运行主函数的主线程也可以访问其线程局部变量val,该变量在程序开始时设置为1,但在主函数结束时打印到控制台之前仅用于打印,然后退出程序。

与任何技术一样,也有一些缺点。TLS 会增加内存使用量,因为每个线程都会创建一个变量,所以在资源受限的环境中可能会出现问题。此外,访问 TLS 变量可能比常规变量有一些开销。这在性能关键型软件中可能是一个问题。

使用我们迄今为止学到的许多技术,让我们构建一个计时器。

实现计时器

让我们实现一个接受间隔和回调函数的计时器。计时器将在每个间隔执行回调函数。此外,用户可以通过调用其stop()函数来停止计时器。

以下代码片段展示了计时器的实现:

#include <chrono>
#include <functional>
#include <iostream>
#include <syncstream>
#include <thread>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
using namespace std::chrono;
template<typename Duration>
class Timer {
   public:
    typedef std::function<void(void)> Callback;
    Timer(const Duration interval,
          const Callback& callback) {
        auto value = duration_cast<milliseconds>(interval);
        sync_cout << "Timer: Starting with interval of "
                  << value << std::endl;
        t = std::jthread(& {
            while (!stop_token.stop_requested()) {
                sync_cout << "Timer: Running callback "
                          << val.load() << std::endl;
                val++;
                callback();
                sync_cout << "Timer: Sleeping...\n";
                std::this_thread::sleep_for(interval);
            }
            sync_cout << „Timer: Exit\n";
        });
    }
    void stop() {
        t.request_stop();
    }
   private:
    std::jthread t;
    std::atomic_int32_t val{0};
};

Timer构造函数接受一个Callback函数(一个std::function<void(void)>对象)和一个定义回调将执行的周期或间隔的std::chrono::duration对象。

然后使用 lambda 表达式创建一个 std::jthread 对象,其中循环以时间间隔调用回调函数。这个循环检查是否通过 stop_token 请求停止,这是通过使用 stop() 计时器 API 函数来实现的。当这种情况发生时,循环退出,线程终止。

这里是如何使用它的:

int main(void) {
    sync_cout << "Main: Create timer\n";
    Timer timer(1s, [&]() {
        sync_cout << "Callback: Running...\n";
    });
    std::this_thread::sleep_for(3s);
    sync_cout << "Main thread: Stop timer\n";
    timer.stop();
    std::this_thread::sleep_for(500ms);
    sync_cout << "Main thread: Exit\n";
    return 0;
}

在这个示例中,我们启动了计时器,每秒将打印回调:运行消息。三秒后,主线程将调用timer.stop()函数,终止计时器线程。然后主线程等待 500 毫秒后退出。

这是输出结果:

Main: Create timer
Timer: Starting with interval of 1000ms
Timer: Running callback 0
Callback: Running...
Timer: Sleeping...
Timer: Running callback 1
Callback: Running...
Timer: Sleeping...
Timer: Running callback 2
Callback: Running...
Timer: Sleeping...
Main thread: Stop timer
Timer: Exit
Main thread: Exit

作为练习,你可以稍微修改这个示例来实现一个超时类,如果给定超时间隔内没有输入事件,它会调用回调函数。在处理网络通信时,这是一个常见的模式,如果在一段时间内没有接收到数据包,则会发送数据包重放请求。

摘要

在这一章中,我们学习了如何创建和管理线程,如何传递参数或检索结果,如何工作 TLS,以及如何等待线程完成。我们还学习了如何使线程将控制权交给其他线程或取消其执行。如果出现问题并抛出异常,我们现在知道如何在线程之间传递异常并避免意外的程序终止。最后,我们实现了一个 计时器 类,该类定期运行回调函数。

在下一章中,我们将学习线程安全、互斥和原子操作。这包括互斥锁、锁定和无锁算法,以及内存同步排序,以及其他主题。这些知识将帮助我们开发线程安全的数组和算法。

进一步阅读

第四章:使用锁进行线程同步

第二章 中,我们了解到线程可以读取和写入它们所属进程共享的内存。虽然操作系统实现了进程内存访问保护,但同一进程中对共享内存的线程访问没有这种保护。来自多个线程对同一内存地址的并发内存写操作需要同步机制来避免数据竞争并确保数据完整性。

在本章中,我们将详细描述由多个线程对共享内存并发访问所引起的问题以及如何解决这些问题。我们将详细研究以下主题:

  • 竞态条件 – 它们是什么以及如何发生

  • 互斥作为同步机制及其在 C++中通过 std::mutex 的实现

  • 泛型锁管理

  • 条件变量是什么以及如何与互斥锁一起使用

  • 使用 std::mutexstd::condition_variable 实现一个完全同步的队列

  • C++20 引入的新同步原语 – 信号量、屏障和闩锁

这些都是基于锁的同步机制。无锁技术是下一章的主题。

技术要求

本章的技术要求与上一章中解释的概念相同,要编译和运行示例,需要一个支持 C++20 的 C++编译器(用于信号量、闩锁和屏障示例)。大多数示例只需要 C++11。示例已在 Linux Ubuntu LTS 24.04 上测试。

本章中的代码可以在 GitHub 上找到:

github.com/PacktPublishing/Asynchronous-Programming-with-CPP

理解竞态条件

当程序运行的输出结果取决于其指令执行的顺序时,就会发生竞态条件。我们将从一个非常简单的例子开始,展示竞态条件是如何发生的,然后在本章的后面部分,我们将学习如何解决这个问题。

在以下代码中,counter 全局变量由两个并发运行的线程递增:

#include <iostream>
#include <thread>
int counter = 0;
int main() {
    auto func = [] {
        for (int i = 0; i < 1000000; ++i) {
            counter++;
        }
    };
    std::thread t1(func);
    std::thread t2(func);
    t1.join();
    t2.join();
    std::cout << counter << std::endl;
    return 0;
}

运行前面的代码三次后,我们得到以下 counter 值:

1056205
1217311
1167474

在这里,我们看到了两个主要问题:首先,counter 的值是不正确的;其次,每次程序执行都以不同的 counter 值结束。结果是不可预测的,并且大多数情况下是错误的。如果你非常幸运,可能会得到正确的值,但这非常不可能。

这种情况涉及两个线程,t1t2,它们并发运行并修改相同的变量,本质上是一些内存区域。看起来它应该可以正常工作,因为只有一行代码增加了计数器的值,从而修改了内存内容(顺便说一句,我们使用后增量运算符如counter++或前增量运算符如++counter并不重要;结果都会同样错误)。

仔细观察前面的代码,让我们仔细研究以下这一行:

        counter++;

它通过三个步骤增加计数器

  • 存储在计数器变量内存地址中的内容被加载到一个 CPU 寄存器中。在这种情况下,从内存中加载一个int数据类型到 CPU 寄存器中。

  • 寄存器中的值增加一。

  • 寄存器中的值存储在计数器变量内存地址中。

现在,让我们考虑一个可能的情况,即当两个线程尝试并发地增加计数器时。让我们看看表 4.1

线程 1 线程 2
[1] 将计数器值加载到寄存器 [3] 将计数器值加载到寄存器
[2] 增加寄存器值 [5] 增加寄存器值
[4] 将寄存器存储到计数器 [6] 将寄存器存储到计数器

表 4.1:两个线程并发增加计数器

线程 1 执行[1],并将计数器的当前值(假设是 1)加载到一个 CPU 寄存器中。然后,它通过[2]将寄存器中的值增加一(现在,寄存器中的值是 2)。

线程 2 被调度执行,[3]将计数器的当前值(记住——它尚未被修改,所以仍然是 1)加载到一个 CPU 寄存器中。

现在,线程 1 再次被调度执行,[4]将更新后的值存储到内存中。此时,计数器的值现在是二。

最后,线程 2 再次被调度,并执行[5]和[6]。寄存器值增加一,然后将值二存储在内存中。计数器变量只增加了一次,而它应该增加两次,其值应该是三。

之前的问题发生是因为对计数器的增量操作不是原子的。如果每个线程都能在没有被中断的情况下执行增加计数器变量所需的三个指令,那么计数器就会像预期的那样增加两次。然而,根据操作执行的顺序,结果可能会有所不同。这被称为竞态条件

为了避免竞态条件,我们需要确保共享资源以受控的方式被访问和修改。实现这一目标的一种方法是通过使用锁。是一种同步原语,它允许一次只有一个线程访问共享资源。当线程想要访问共享资源时,它必须首先获取锁。一旦线程获取了锁,它就可以在没有其他线程干扰的情况下访问共享资源。当线程完成对共享资源的访问后,它必须释放锁,以便其他线程可以访问它。

另一种避免竞态条件的方法是使用原子操作。原子操作是一种保证在单个、不可分割的步骤中执行的操作。这意味着在操作执行期间,没有其他线程可以干扰原子操作。原子操作通常使用设计为不可分割的硬件指令来实现。原子操作将在第五章中解释。

在本节中,我们看到了由多线程代码引起的最常见和重要的问题:竞态条件。我们看到了根据操作执行的顺序,结果可能会有所不同。带着这个问题,我们将研究如何在下一节中解决它。

我们为什么需要互斥锁?

互斥锁是并发编程中的一个基本概念,它确保多个线程或进程不会同时访问共享资源,例如共享变量、代码的关键部分或文件或网络连接。互斥锁对于防止如前节所见到的竞态条件至关重要。

想象一家小咖啡馆,只有一台意式浓缩咖啡机。这台机器一次只能制作一杯浓缩咖啡。这意味着这台机器是一个所有咖啡师都必须共享的关键资源。

这家咖啡馆由三位咖啡师:Alice、Bob 和 Carol 负责。他们并发使用咖啡机,但不能同时使用,因为这可能会造成问题:Bob 将适量的新鲜研磨咖啡放入机器中,开始制作浓缩咖啡。然后,Alice 也这样做,但首先从机器中取出咖啡,认为 Bob 忘记做了。Bob 然后从机器中取出浓缩咖啡,之后,Alice 发现没有浓缩咖啡了!这是一场灾难——我们计数程序的现实版本。

为了解决咖啡馆的问题,他们可能会任命 Carol 为机器管理员。在使用机器之前,Alice 和 Bob 都会问她是否可以开始制作新的浓缩咖啡。这样就能解决问题。

回到我们的计数器程序,如果我们能允许一次只有一个线程访问counter(就像 Carol 在咖啡馆里做的那样),我们的软件问题也会得到解决。互斥是一种可以用来控制并发线程访问内存的机制。C++标准库提供了std::mutex类,这是一个同步原语,用于保护共享数据不被两个或更多线程同时访问。

我们在上节中看到的这个新版本的代码实现了两种并发增加counter的方式:自由访问,如前节所述,以及使用互斥同步的访问:

#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx;
int counter = 0;
int main() {
    auto funcWithoutLocks = [] {
        for (int i = 0; i < 1000000; ++i) {
            ++counter;
        };
    };
    auto funcWithLocks = [] {
        for (int i = 0; i < 1000000; ++i) {
            mtx.lock();
            ++counter;
            mtx.unlock();
        };
    };
    {
        counter = 0;
        std::thread t1(funcWithoutLocks);
        std::thread t2(funcWithoutLocks);
        t1.join();
        t2.join();
        std::cout << "Counter without using locks: " << counter << std::endl;
    }
    {
        counter = 0;
        std::thread t1(funcWithLocks);
        std::thread t2(funcWithLocks);
        t1.join();
        t2.join();
        std::cout << "Counter using locks: " << counter << std::endl;
    }
    return 0;
}

当一个线程运行funcWithLocks时,它在增加counter之前使用mtx.lock()获取锁。一旦counter被增加,线程将释放锁(mtx.unlock())。

锁只能被一个线程拥有。例如,如果t1获取了锁,然后t2也尝试获取它,t2将被阻塞并等待直到锁可用。因为任何时刻只有一个线程可以拥有锁,所以这种同步原语被称为互斥锁(来自“互斥”)。如果你运行这个程序几次,你总是会得到正确的结果:2000000

在本节中,我们介绍了互斥的概念,并了解到 C++标准库提供了std::mutex类作为线程同步的原语。在下一节中,我们将详细研究std::mutex

C++标准库互斥实现

在上一节中,我们介绍了互斥和互斥锁的概念,以及为什么需要它们来同步并发内存访问。在本节中,我们将看到 C++标准库提供的用于实现互斥的类。我们还将看到 C++标准库提供的一些辅助类,使互斥锁的使用更加容易。

下表总结了 C++标准库提供的互斥锁类及其主要特性:

Mutex Type Access Recursive Timeout
std::mutex EXCLUSIVE NO NO
std::recursive_mutex EXCLUSIVE YES NO
std::shared_mutex 1 - EXCLUSIVEN - SHARED NO NO
std::timed_mutex EXCLUSIVE NO YES
std::recursive_timed_mutex EXCLUSIVE YES YES
std::shared_timed_mutex 1 - EXCLUSIVEN - SHARED NO YES

表 4.2:C++标准库中的互斥锁类

让我们逐一探索这些类。

std::mutex

std::mutex类是在 C++11 中引入的,它是 C++标准库提供的最重要的、最常使用的同步原语之一。

如我们在本章前面所见,std::mutex是一个同步原语,可以用来保护共享数据不被多个线程同时访问。

std::mutex类提供了独占、非递归的所有权语义。

std::mutex的主要特性如下:

  • 从调用线程成功调用lock()try_lock()到调用unlock(),调用线程拥有互斥锁。

  • 在调用lock()try_lock()之前,调用线程不得拥有互斥锁。这是std::mutex的非递归所有权语义属性。

  • 当一个线程拥有互斥锁时,所有其他线程将阻塞(在调用lock()时)或接收一个false返回值(在调用try_lock()时)。这是std::mutex的独占所有权语义。

如果一个拥有互斥锁的线程尝试再次获取它,其行为是未定义的。通常,在这种情况下会抛出一个异常,但这是由实现定义的。

如果一个线程在释放互斥锁之后,再次尝试释放它,这同样是不确定的行为(就像前一个情况一样)。

当一个线程持有互斥锁时,互斥锁被销毁,或者线程在未释放锁的情况下终止,这些都是不确定行为的原因。

std::mutex类有三个方法:

  • lock():调用lock()会获取互斥锁。如果互斥锁已被锁定,则调用线程将被阻塞,直到互斥锁被解锁。从应用程序的角度来看,这就像调用线程在等待互斥锁可用一样。

  • try_lock():当调用此函数时,它返回true,表示互斥锁已被成功锁定,或者在互斥锁已被锁定的情况下返回false。请注意,try_lock是非阻塞的,调用线程要么获取互斥锁,要么不获取,但它不会像调用lock()时那样被阻塞。try_lock()方法通常在我们不希望线程等待互斥锁可用时使用。当我们希望线程继续进行一些处理并稍后尝试获取互斥锁时,我们将调用try_lock()

  • unlock():调用unlock()会释放互斥锁。

std::recursive_mutex

std::mutex类提供了独占、非递归的所有权语义。虽然对于至少一个线程来说,独占所有权语义总是需要的(毕竞它是一个互斥机制),但在某些情况下,我们可能需要递归地获取互斥锁。例如,一个递归函数可能需要获取一个互斥锁。我们也可能需要在从另一个函数f()中调用的函数g()中获取互斥锁。

std::recursive_mutex类提供了独占、递归语义。其主要特性如下:

  • 调用线程可能多次获取相同的互斥锁。它将持有互斥锁,直到它释放互斥锁的次数与它获取的次数相同。例如,如果一个线程递归地获取一个互斥锁三次,它将持有互斥锁,直到它第三次释放它。

  • 递归互斥锁可以递归获取的最大次数是不确定的,因此是实现定义的。一旦互斥锁已被获取最大次数,调用 lock() 将会抛出 std::system_error,而调用 try_lock() 将返回 false

  • 所有权与 std::mutex 相同:如果一个线程拥有 std::recursive_mutex 类,那么任何其他线程在尝试通过调用 lock() 获取它时都会阻塞,或者在调用 try_lock() 时返回 false

std::recursive_mutex 的接口与 std::mutex 完全相同。

std::shared_mutex

std::mutexstd::shared_mutex 都具有独占所有权的语义,在任何给定时间只有一个线程可以是互斥锁的所有者。尽管如此,也有一些情况下,我们可能需要让多个线程同时访问受保护的数据,并只给一个线程提供独占访问权限。

所需的计数器反例要求每个线程对单个变量具有独占访问权限,因为它们都在更新counter值。现在,如果我们有只要求读取counter当前值的线程,并且只有一个线程需要增加其值,那么让读取线程并发访问counter并将写入线程的独占访问权限会更好。

此功能是通过所谓的读者-写者锁实现的。C++ 标准库实现了具有类似(但不完全相同)功能的 std::shared_mutex 类。

std::shared_mutex 与其他互斥锁类型的主要区别在于它有两个访问级别:

  • 共享:多个线程可以共享同一个互斥锁的所有权。共享所有权通过调用 lock_shared()try_lock_shared() / unlock_shared() 来获取/释放。当至少有一个线程已经获取了对锁的共享访问权限时,没有其他线程可以获取独占访问权限,但它可以获取共享访问权限。

  • 独占:只有一个线程可以拥有互斥锁。独占所有权通过调用lock()try_lock() / unlock() 来获取/释放。当一个线程已经获取了对锁的独占访问权限时,没有其他线程可以获取共享或独占访问权限。

让我们通过一个简单的例子来看看如何使用 std::shared_mutex

#include <algorithm>
#include <chrono>
#include <iostream>
#include <shared_mutex>
#include <thread>
int counter = 0;
int main() {
    using namespace std::chrono_literals;
    std::shared_mutex mutex;
    auto reader = [&] {
        for (int i = 0; i < 10; ++i) {
            mutex.lock_shared();
            // Read the counter and do something
            mutex.unlock_shared();
        }
    };
    auto writer = [&] {
        for (int i = 0; i < 10; ++i) {
            mutex.lock();
            ++counter;
            std::cout << "Counter: " << counter << std::endl;
            mutex.unlock();
            std::this_thread::sleep_for(10ms);
        }
    };
    std::thread t1(reader);
    std::thread t2(reader);
    std::thread t3(writer);
    std::thread t4(reader);
    std::thread t5(reader);
    std::thread t6(writer);
    t1.join();
    t2.join();
    t3.join();
    t4.join();
    t5.join();
    t6.join();
    return 0;
}

示例使用 std::shared_mutex 来同步六个线程:其中两个线程是写入者,它们增加counter的值并需要独占访问。其余四个线程只读取counter,只需要共享访问。此外,请注意,为了使用 std::shared_mutex,我们需要包含 <shared_mutex> 头文件。

定时互斥锁类型

我们至今所见到的互斥锁类型,当我们想要获取锁以进行独占使用时,表现方式相同:

  • std::lock() : 调用线程会阻塞,直到锁可用

  • std::try_lock():如果锁不可用,则返回 false

std::lock()的情况下,调用线程可能需要等待很长时间,我们可能只需要等待一段时间,然后如果线程还没有能够获取到锁,就让它继续进行一些处理。

为了实现这个目标,我们可以使用 C++标准库提供的定时互斥锁:std::timed_mutexstd::recursive_timed_mutexstd::shared_time_mutex

它们与它们的非定时对应物类似,并实现了以下附加功能,以允许等待锁在特定时间段内可用:

  • try_lock_for():尝试锁定互斥锁,并在指定的时间段内(超时)阻塞线程。如果在指定的时间段之前互斥锁被锁定,则返回true;否则,返回false

    如果指定的时间段小于或等于零(timeout_duration.zero()),则该函数的行为与try_lock()完全相同。

    由于调度或竞争延迟,此函数可能会阻塞超过指定的时间段。

  • try_lock_until():尝试锁定互斥锁,直到指定的超时时间或互斥锁被锁定,以先到者为准。在这种情况下,我们指定一个未来的实例作为等待的限制。

以下示例展示了如何使用std::try_lock_for()

#include <algorithm>
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
constexpr int NUM_THREADS = 8;
int counter = 0;
int failed = 0;
int main() {
    using namespace std::chrono_literals;
    std::timed_mutex tm;
    std::mutex m;
    auto worker = [&] {
        for (int i = 0; i < 10; ++i) {
            if (tm.try_lock_for(10ms)) {
                ++counter;
                std::cout << "Counter: " << counter << std::endl;
                std::this_thread::sleep_for(10ms);
                m.unlock();
            }
            else {
                m.lock();
                ++failed;
                std::cout << "Thread " << std::this_thread::get_id() << " failed to lock" << std::endl;
                m.unlock();
            }
            std::this_thread::sleep_for(12ms);
        }
    };
    std::vector<std::thread> threads;
    for (int i = 0; i < NUM_THREADS; ++i) {
        threads.emplace_back(worker);
    }
    for (auto& t : threads) {
        t.join();
    }
    std::cout << "Counter: " << counter << std::endl;
    std::cout << "Failed: " << failed << std::endl;
    return 0;
}

上述代码使用了两个锁:tm,一个定时互斥锁,用于同步对counter的访问以及在成功获取tm的情况下向屏幕写入,以及m,一个非定时互斥锁,用于在未成功获取tm的情况下同步对failed的访问以及向屏幕写入。

使用锁时可能出现的问题

我们已经看到了仅使用互斥锁(锁)的示例。如果我们只需要一个互斥锁并且正确地获取和释放它,通常编写正确的多线程代码并不困难。一旦我们需要多个锁,代码复杂性就会增加。使用多个锁时常见的两个问题是死锁活锁

死锁

让我们考虑以下场景:为了执行某个任务,一个线程需要访问两个资源,并且两个或更多线程不能同时访问这些资源(我们需要互斥来正确同步对所需资源的访问)。每个资源都由不同的std::mutex类进行同步。在这种情况下,一个线程必须先获取第一个资源互斥锁,然后获取第二个资源互斥锁,最后处理资源并释放两个互斥锁。

当两个线程尝试执行上述处理时,可能会发生类似以下情况:

线程 1线程 2 需要获取两个互斥锁来执行所需的处理。线程 1 获取第一个互斥锁,线程 2 获取第二个互斥锁。然后,线程 1 将永远阻塞等待第二个互斥锁可用,而 线程 2 将永远阻塞等待第一个互斥锁可用。这被称为死锁,因为两个线程都将永远阻塞等待对方释放所需的互斥锁。

这是在多线程代码中最常见的问题之一。在第十一章中,关于调试,我们将学习如何通过检查运行(死锁)程序来发现这个问题。

活锁

解决死锁的一个可能方案是:当线程尝试获取锁时,它将仅阻塞有限的时间,如果仍然不成功,它将释放它可能已经获得的任何锁。

例如,线程 1 获得了第一个锁,线程 2 获得了第二个锁。经过一段时间后,线程 1 仍然没有获得第二个锁,因此它释放了第一个锁。线程 2 也可能完成等待并释放它所获得的锁(在这个例子中,是第二个锁)。

这种解决方案有时可能有效,但并不正确。想象一下这个场景:线程 1 获得了第一个锁和第二个锁。过了一段时间后,两个线程都释放了它们已经获得的锁,然后再次获取相同的锁。然后,线程释放锁,再次获取,如此循环。

线程无法做任何事情,除了获取锁、等待、释放锁,然后再重复同样的操作。这种情况被称为活锁,因为线程不仅仅是永远等待(如死锁情况),它们似乎是活跃的,不断地获取和释放锁。

对于死锁和活锁的情况,最常用的解决方案是按照一致的顺序获取锁。例如,如果一个线程需要获取两个锁,它将始终先获取第一个锁,然后获取第二个锁。锁的释放将按照相反的顺序进行(首先释放第二个锁,然后是第一个)。如果第二个线程尝试获取第一个锁,它将不得不等待直到第一个线程释放了两个锁,这样就不会发生死锁。

在本节中,我们看到了 C++ 标准库提供的互斥类。我们研究了它们的主要特性和在使用多个锁时可能遇到的问题。在下一节中,我们将看到 C++ 标准库提供的机制,以使获取和释放互斥锁更加容易。

通用锁管理

在上一节中,我们看到了 C++ 标准库提供的不同类型的互斥量。在本节中,我们将看到提供的类,这些类使得使用互斥量更加容易。这是通过使用不同的包装器类来实现的。以下表格总结了锁管理类及其主要特性:

互斥量管理类 支持的互斥量类型 管理的互斥量
std::lock_guard 所有 1
std::scoped_lock 所有 零个或多个
std::unique_lock 所有 1
std::shared_lock std::shared_mutex std::shared_timed_mutex

表 4.3:锁管理类及其特性

让我们看看每个互斥量管理类及其主要特性。

std::lock_guard

std::lock_guard 类是一个 资源获取即初始化 ( RAII ) 类,它使得使用互斥量更加容易,并保证当调用 lock_guard 析构函数时,互斥量将被释放。这在处理异常时非常有用,例如。

以下代码展示了 std::lock_guard 的使用以及它是如何使在已经获取锁的情况下处理异常变得更容易的:

#include <format>
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx;
uint32_t counter{};
void function_throws() { throw std::runtime_error("Error"); }
int main() {
    auto worker = [] {
        for (int i = 0; i < 1000000; ++i) {
            mtx.lock();
            counter++;
            mtx.unlock();
        }
    };
    auto worker_exceptions = [] {
        for (int i = 0; i < 1000000; ++i) {
            try {
                std::lock_guard<std::mutex> lock(mtx);
                counter++;
                function_throws();
            } catch (std::system_error& e) {
                std::cout << e.what() << std::endl;
                return;
            } catch (...) {
                return;
            }
        }
    };
    std::thread t1(worker_exceptions);
    std::thread t2(worker);
    t1.join();
    t2.join();
    std::cout << "Final counter value: " << counter << std::endl;
}

function_throws() 函数只是一个实用函数,它将抛出一个异常。

在之前的代码示例中,worker_exceptions() 函数由 t1 执行。在这种情况下,异常被处理以打印有意义的消息。锁不是显式地获取/释放。这被委托给 lock,一个 std::lock_guard 对象。当 lock 被构造时,它会包装互斥量并调用 mtx.lock(),获取锁。当 lock 被销毁时,互斥量将自动释放。在发生异常的情况下,互斥量也将被释放,因为 lock 被定义的作用域已经退出。

std::lock_guard 实现了另一个构造函数,接收一个类型为 std::adopt_lock_t 的参数。基本上,这个构造函数使得能够包装一个已经获取的非共享互斥量,该互斥量将在 std::lock_guard 析构函数中自动释放。

std::unique_lock

std::lock_guard 类只是一个简单的 std::mutex 包装器,它在构造函数中自动获取互斥量(线程将被阻塞,等待另一个线程释放互斥量)并在析构函数中释放互斥量。这非常有用,但有时我们需要更多的控制。例如,std::lock_guard 将会在互斥量上调用 lock() 或者假设互斥量已经被获取。我们可能更喜欢或者确实需要调用 try_lock 。我们可能还希望 std::mutex 包装器在其构造函数中不获取锁;也就是说,我们可能希望在稍后的某个时刻再进行锁定。所有这些功能都是由 std::unique_lock 实现的。

std::unique_lock 构造函数接受一个标签作为其第二个参数,以指示我们想要如何处理底层的互斥量。这里有三种选项:

  • std::defer_lock:不获取互斥锁的所有权。构造函数中不会锁定互斥锁,如果从未获取,则析构函数中也不会解锁。

  • std::adopt_lock:假设互斥锁已被调用线程获取。它将在析构函数中释放。此选项也适用于std::lock_guard

  • std::try_to_lock:尝试获取互斥锁而不阻塞。

如果我们只是将互斥锁作为唯一参数传递给std::unique_lock构造函数,其行为与std::lock_guard相同:它会阻塞直到互斥锁可用,然后获取它。它将在析构函数中释放互斥锁。

std::lock_guard不同,std::unique_lock类允许你分别调用lock()unlock()来获取和释放互斥锁。

std::scoped_lock

std::scoped_lock类,与std::unique_lock一样,是一个实现 RAII 机制(记住——如果获取了互斥锁,它们将在析构函数中释放)的std::mutex包装器。主要区别在于,std::unique_lock,正如其名称所暗示的,仅包装一个互斥锁,而std::scoped_lock可以包装零个或多个互斥锁。此外,互斥锁的获取顺序与传递给std::scoped_lock构造函数的顺序相同,从而避免了死锁。

让我们看看以下代码:

std::mutex mtx1;
std::mutex mtx2;
// Acquire both mutexes avoiding deadlock
std::scoped_lock lock(mtx1, mtx2);
// Same as doing this
// std::lock(mtx1, mtx2);
// std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
// std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);

上述代码片段显示了我们可以非常容易地处理两个互斥锁。

std::shared_lock

std::shared_lock类是另一种通用互斥锁所有权包装器。与std::unique_lockstd::scoped_lock一样,它允许延迟锁定和转移锁所有权。std::unique_lockstd::shared_lock之间的主要区别在于,后者用于以共享模式获取/释放包装的互斥锁,而前者用于以独占模式执行相同的操作。

在本节中,我们看到了互斥锁包装类及其主要功能。接下来,我们将介绍另一种同步机制:条件变量。

条件变量

条件变量是 C++标准库提供的另一种同步原语。它们允许多个线程相互通信。它们还允许多个线程等待另一个线程的通知。条件变量始终与一个互斥锁相关联。

在以下示例中,一个线程必须等待计数器等于某个特定值:

#include <chrono>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
int counter = 0;
int main() {
    using namespace std::chrono_literals;
    std::mutex mtx;
    std::mutex cout_mtx;
    std::condition_variable cv;
    auto increment_counter = [&] {
        for (int i = 0; i < 20; ++i) {
            std::this_thread::sleep_for(100ms);
            mtx.lock();
            ++counter;
            mtx.unlock();
            cv.notify_one();
        }
    };
    auto wait_for_counter_non_zero_mtx = [&] {
        mtx.lock();
        while (counter == 0) {
            mtx.unlock();
            std::this_thread::sleep_for(10ms);
            mtx.lock();
        }
        mtx.unlock();
        std::lock_guard<std::mutex> cout_lck(cout_mtx);
        std::cout << "Counter is non-zero" << std::endl;
    };
    auto wait_for_counter_10_cv = [&] {
        std::unique_lock<std::mutex> lck(mtx);
        cv.wait(lck, [] { return counter == 10; });
        std::lock_guard<std::mutex> cout_lck(cout_mtx);
        std::cout << "Counter is: " << counter << std::endl;
    };
    std::thread t1(wait_for_counter_non_zero_mtx);
    std::thread t2(wait_for_counter_10_cv);
    std::thread t3(increment_counter);
    t1.join();
    t2.join();
    t3.join();
    return 0;
}

有两种等待特定条件的方法:一种是在循环中等待并使用互斥锁作为同步机制。这在wait_for_counter_non_zero_mtx函数中实现。该函数获取锁,读取counter中的值,然后释放锁。然后,它睡眠 10 毫秒,再次获取锁。这是在while循环中完成的,直到counter不为零。

条件变量帮助我们简化了之前的代码。wait_for_counter_10_cv 函数等待直到 counter 等于 10。线程将在 cv 条件变量上等待,直到它被 t1(在循环中增加 counter 的线程)通知。

wait_for_counter_10_cv 函数是这样工作的:一个条件变量,cv,在互斥锁,mtx 上等待。在调用 wait() 之后,条件变量锁定互斥锁并等待直到条件为 true(条件是在传递给 wait 函数作为第二个参数的 lambda 表达式中实现的)。如果条件不是 true,条件变量将保持 等待 状态,直到它被发出信号并释放互斥锁。一旦条件满足,条件变量结束其等待状态并再次锁定互斥锁以同步其对 counter 的访问。

一个重要的问题是条件变量可能被一个无关的线程发出信号。这被称为 虚假唤醒。为了避免由于虚假唤醒而引起的错误,条件在 wait 中被检查。当条件变量被发出信号时,条件再次被检查。在发生虚假唤醒且计数器为零(条件检查返回 false )的情况下,等待将重新开始。

一个不同的线程通过运行 increment_counter 来增加计数器的值。一旦 counter 达到期望的值(在示例中,这个值是 10),它就会向等待的线程的条件变量发出信号。

提供了两个函数来发出条件变量信号:

  • cv.notify_one() : 仅向等待的线程中的一个发出信号

  • cv.notify_all() : 向所有等待的线程发出信号

在本节中,我们介绍了条件变量,并看到了一个使用条件变量进行同步的简单示例,以及在某些情况下它如何简化同步/等待代码。现在,让我们将注意力转向使用互斥锁和两个条件变量来实现一个同步队列。

实现一个线程安全的队列

在本节中,我们将看到如何实现一个简单的 线程安全的队列。队列将由多个线程访问,其中一些线程向其中添加元素( 生产者线程),而另一些线程从中移除元素( 消费者线程)。为了开始,我们将假设只有两个线程:一个生产者和一个消费者。

队列或先进先出FIFOs)是线程之间通信的标准方式。例如,如果我们需要尽可能快地接收包含来自网络连接的数据的包,我们可能没有足够的时间仅在一个线程中接收所有包并处理它们。在这种情况下,我们使用第二个线程来处理第一个线程读取的包。仅使用一个消费者线程更容易同步(我们将在第五章中看到这一点),并且我们有保证包将被按照它们到达和被生产者线程复制到队列中的顺序进行处理。确实,包将真正按照它们被复制到队列中的顺序被读取,无论我们有多少消费者线程,但消费者线程可能被操作系统调度进和出,处理过的包的完整序列可能以不同的顺序出现。

通常,最简单的问题是一个单生产者单消费者SPSC)队列。如果每个项目的处理成本太高,以至于仅一个线程无法处理,那么可能需要多个消费者,我们可能还有不同的数据源需要处理,并且需要多个生产者线程。本节中描述的队列将适用于所有情况。

设计队列的第一步是决定我们将使用什么数据结构来存储队列中的项目。我们希望队列包含任何类型 T 的元素,因此我们将它实现为一个模板类。此外,我们将限制队列的容量,以便我们可以在队列中存储的最大元素数量是固定的,并在类构造函数中设置。例如,可以使用链表并使队列无界,或者甚至使用标准模板库STL)队列,std::queue,并让队列增长到任意大小。在本章中,我们将实现一个固定大小的队列。我们将在第五章中重新审视实现,并以非常不同的方式实现它(我们不会使用任何互斥锁或等待条件变量)。对于我们的当前实现,我们将使用 STL 向量,std::vector,来存储队列中的项目。向量将在队列类构造函数中为所有元素分配内存,因此之后将不会有内存分配。当队列被销毁时,向量将自行销毁并释放分配的内存。这是方便的,并且简化了实现。

我们将使用向量作为环形缓冲区。这意味着,一旦我们在向量的末尾存储了一个元素,下一个元素将被存储在开头,因此我们将在读写元素时对这两个位置进行循环

这是队列类的第一个版本,相当简单,但还没有什么用处:

template <typename T>
class synchronized_queue {
public:
    explicit synchronized_queue(size_t size) :
        capacity_{ size }, buffer_(capacity_)
        {}
private:
    std::size_t head_{ 0 };
    std::size_t tail_{ 0 };
    std::size_t capacity_;
    std::vector<T> buffer_;
};

headtail 变量用于指示分别读取或写入下一个元素的位置。我们还需要知道队列何时为空或满。如果队列为空,消费者线程将无法从队列中获取任何项目。如果队列已满,生产者线程将无法将任何项目放入队列中。

有不同的方式来指示队列何时为空和何时已满。在这个例子中,我们遵循以下约定:

  • 如果 tail_ == head_,则队列是空的

  • 如果 (tail_ + 1) % capacity_ == head_,则队列已满

另一种实现方式只需检查 tail_ == head_ 并使用一个额外的标志来指示队列是否已满(或者使用计数器来知道队列中有多少项)。在这个例子中,我们避免使用任何额外的标志或计数器,因为标志将由消费者和生产者线程同时读写,并且我们旨在尽可能减少线程间的数据共享。此外,减少数据共享将是我们在第五章重新实现队列时的唯一选项。

这里有一个小问题。由于我们检查队列是否已满的方式,我们丢失了一个缓冲区槽位,因此实际容量是 capacity_ - 1。我们将认为队列已满,当只有一个空槽位时。由于这个原因,我们丢失了一个队列槽位(请注意,槽位将被使用,但当项目数量为 capacity_ - 1 时,队列仍然会显示为满)。通常情况下,这不是一个问题。

我们将要实现的队列是一个有界队列(固定大小),实现为一个环形缓冲区。

这里还有一个需要考虑的细节:head_ + 1 必须考虑到我们将索引回绕到缓冲区(它是一个环形缓冲区)。因此,我们必须做 (head_ + 1) % capacity_。模运算符计算索引值除以队列容量的余数。

以下代码展示了作为同步队列中的辅助函数实现的基本实用函数:

template <typename T>
class synchronized_queue {
public:
    explicit synchronized_queue(size_t size) :
        capacity_{ size }, buffer_(capacity_) {
    }
private:
    std::size_t next(std::size_t index) {
        return (index + 1)% capacity_;
    }
    bool is_full() const {
        return next(tail_) == head_;
    }
    bool is_empty() const {
        return tail_ == head_;
    }
    std::size_t head_{ 0 };
    std::size_t tail_{ 0 };
    std::size_t capacity_;
    std::vector<T> buffer_;
};

我们实现了一些有用的函数来更新环形缓冲区的头和尾,并检查缓冲区是否已满或为空。现在,我们可以开始实现队列功能。

完整队列实现的代码位于本书的配套 GitHub 仓库中。在这里,我们只展示重要的部分,以简化内容并专注于队列实现的同步方面

队列的接口具有以下两个功能:

void push(const T& item);
void pop(T& item);

push 函数用于在队列中插入一个元素,而 pop 函数用于从队列中获取一个元素。

让我们从 push 开始。它将一个项目插入到队列中。如果队列已满,push 将等待直到队列至少有一个空槽(消费者从队列中移除了一个元素)。这样,生产者线程将阻塞,直到队列至少有一个空槽(满足非满条件)。

在本章前面我们已经看到,存在一种称为条件变量的同步机制,它正是这样做的。push函数将检查条件是否满足,如果满足,它将在队列中插入一个项目。如果条件不满足,与条件变量关联的锁将被释放,线程将等待在条件变量上,直到条件得到满足。

条件变量可能只是等待直到锁被释放。我们仍然需要检查队列是否已满,因为条件变量可能因为虚假唤醒而结束等待。这种情况发生在条件变量接收到一个通知,而这个通知并非由任何其他线程明确发送时。

我们向队列类添加以下三个成员变量:

std::mutex mtx_;
std::condition_variable not_full_;
Std::condition_variable not_empty_;

我们需要两个条件变量——一个用于通知消费者队列不为满(not_full_),另一个用于通知生产者队列不为空(not_empty_)。

这是实现push的代码:

void push(const T& item) {
    std::unique_lock<std::mutex> lock(mtx_);
    not_full_.wait(lock, [this]{ return !is_full(); });
    buffer_[tail_] = T;
    tail_ = increment(tail_);
    lock.unlock();
    not_empty_.notify_one();
}

让我们考虑一个只有一个生产者和一个消费者的场景。我们稍后会看到pop函数,但作为提前了解,它也同步于互斥锁/条件变量。两个线程同时尝试访问队列——生产者在插入元素时,消费者在移除元素时。

假设消费者首先获取锁。这发生在[1]处。条件变量需要std::unique_lock的使用来使用互斥锁。在[2]中,我们等待在条件变量上,直到wait函数谓词中的条件得到满足。如果条件不满足,锁将被释放,以便消费者线程能够访问队列。

一旦条件满足,锁再次被获取,队列在[3]处更新。更新队列后,[4]释放锁,然后[5]通知一个可能正在等待not_empty的消费者线程,队列现在实际上不为空。

std::unique_lock类可以在其析构函数中释放互斥锁,但我们需要在[4]处释放它,因为我们不希望在通知条件变量后释放锁。

pop()函数遵循类似的逻辑,如下面的代码所示:

void pop(T& item)
{
    std::unique_lock<std::mutex> lock(mtx_);
    not_empty_.wait(lock, [this]{return !is_empty()});
    item = buffer_[head_];
    head_ = increment(head_);
    lock.unlock();
    not_full_.notify_one();
}

代码与push函数中的代码非常相似。[1]创建了使用not_empty_条件变量所需的std::unique_lock类。[2]not_empty_上等待,直到它被通知队列不为空。[3]从队列中读取项目,将其分配给item变量,然后在[4]中释放锁。最后,在[5]中,通知not_full_条件变量,向消费者指示队列不为满。

pushpop 函数都是阻塞的,分别等待队列不满或不满。我们可能需要在无法插入或从队列中获取/发送消息的情况下让线程继续运行——例如,让它执行一些独立处理——然后再次尝试访问队列。

try_push 函数正是如此。如果互斥锁可以获取并且队列未满,那么功能与 push 函数相同,但在此情况下,try_push 不需要使用任何条件变量进行同步(但必须通知消费者)。这是 try_push 的代码:

bool try_push(const T& item) {
    std::unique_lock<std::mutex> lock(mtx_, std::try_to_lock);
    if (!lock || is_full()) {
        return false;
    }
    buffer_[tail_] = item;
    tail_ = next(tail_);
    lock.unlock();
    not_empty_.notify_one();
    return true;
}

代码是这样工作的:[1] 尝试获取锁并返回,而不阻塞调用线程。如果锁已经被获取,那么它将评估为 false。在 [2] 中,如果锁尚未获取或队列已满,try_push 返回 false 以指示调用者没有在队列中插入任何项,并将等待/阻塞委托给调用者。请注意,[3] 返回 false 并且函数终止。如果锁已被获取,它将在函数退出和 std::unique_lock 析构函数被调用时释放。

在获取锁并检查队列未满之后,然后将项插入队列,并更新 tail_。在 [5] 中,释放锁,在 [6] 中,通知消费者队列不再为空。这种通知是必需的,因为消费者可能会调用 pop 而不是 try_pop

最后,函数返回 true 以指示调用者项已成功插入队列。

下面的代码显示了相应的 try_pop 函数。作为一个练习,尝试理解它是如何工作的:

bool try_pop(T& item) {
     std::unique_lock<std::mutex> lock(mtx_, std::try_to_lock);
     if (!lock || is_empty()) {
         return false;
     }
     item = buffer_[head_];
     head_ = next(head_);
     lock.unlock();
     not_empty_.notify_one();
     return true;
 }

这是本节中实现队列的完整代码:

#pragma once
#include <condition_variable>
#include <mutex>
#include <vector>
namespace async_prog {
template <typename T>
class queue {
public:
    queue(std::size_t capacity) : capacity_{capacity}, buffer_(capacity) {}
    void push(const T& item) {
        std::unique_lock<std::mutex> lock(mtx_);
        not_full_.wait(lock, [this] { return !is_full(); });
        buffer_[tail_] = item;
        tail_ = next(tail_);
        lock.unlock();
        not_empty_.notify_one();
    }
    bool try_push(const T& item) {
        std::unique_lock<std::mutex> lock(mtx_, std::try_to_lock);
        if (!lock || is_full()) {
            return false;
        }
        buffer_[tail_] = item;
        tail_ = next(tail_);
        lock.unlock();
        not_empty_.notify_one();
        return true;
    }
    void pop(T& item) {
        std::unique_lock<std::mutex> lock(mtx_);
        not_empty_.wait(lock, [this] { return !is_empty(); });
        item = buffer_[head_];
        head_ = next(head_);
        lock.unlock();
        not_full_.notify_one();
    }
    bool try_pop(T& item) {
        std::unique_lock<std::mutex> lock(mtx_, std::try_to_lock);
        if (!lock || is_empty()) {
            return false;
        }
        item = buffer_[head_];
        head_ = next(head_);
        lock.unlock();
        not_empty_.notify_one();
        return true;
    }
private:
    [[nodiscard]] std::size_t next(std::size_t idx) const noexcept {
        return ((idx + 1) % capacity_);
    }
    [[nodiscard]] bool is_empty() const noexcept { return (head_ == tail_); }
    [[nodiscard]] bool is_full() const noexcept { return (next(tail_) == head_); }
   private:
    std::mutex mtx_;
    std::condition_variable not_empty_;
    std::condition_variable not_full_;
    std::size_t head_{0};
    std::size_t tail_{0};
    std::size_t capacity_;
    std::vector<T> buffer_;
};
}

在本节中,我们介绍了条件变量,并实现了一个与互斥锁和两个条件变量同步的基本队列,这是自 C++11 以来 C++ 标准库提供的两种基本同步原语。

队列示例展示了如何使用这些同步原语实现同步,并且可以用作更复杂工具(例如线程池)的基本构建块。

信号量

C++20 引入了新的同步原语来编写多线程应用程序。在本节中,我们将查看信号量。

信号量是一个管理可用于访问共享资源许可数的计数器。信号量可以分为两大类:

  • 二进制信号量就像互斥锁。它只有两种状态:0 和 1。尽管二进制信号量在概念上类似于互斥锁,但二进制信号量和互斥锁之间有一些差异,我们将在本节后面看到。

  • 计数信号量可以具有大于 1 的值,并用于控制对具有有限实例数的资源的访问。

C++20 实现了二进制和计数信号量。

二进制信号量

二进制信号量是一种同步原语,可用于控制对共享资源的访问。它有两个状态:0 和 1。值为 0 的信号量表示资源不可用,而值为 1 的信号量表示资源可用。

二进制信号量可用于实现互斥。这是通过使用二进制信号量来控制对资源的访问来实现的。当线程想要访问资源时,它首先检查信号量。如果信号量为 1,则线程可以访问资源。如果信号量为 0,则线程必须等待信号量变为 1,然后才能访问资源。

锁和信号量之间最显著的区别是,锁具有独占所有权,而二进制信号量则没有。只有拥有锁的线程可以释放它。信号量可以被任何线程发出信号。锁是一个临界区的锁定机制,而信号量更像是一个信号机制。在这方面,信号量比锁更接近条件变量。因此,信号量通常用于信号而不是互斥。

在 C++20 中,std::binary_semaphorestd::counting_semaphore 特化的别名,其 LeastMaxValue 为 1。

二进制信号量必须初始化为 1 或 0,例如:

std::binary_semaphore sm1{ 0 };
std::binary_semaphore sm2{ 1 };

如果初始值为 0,获取信号量将阻塞尝试获取它的线程,并且必须在另一个线程释放它之后才能获取。获取信号量会减少计数器,而释放信号量会增加计数器。如前所述,如果计数器为 0,并且一个线程尝试获取锁(信号量),则该线程将被阻塞,直到信号量计数器大于 0

计数信号量

计数信号量允许多个线程访问共享资源。计数器可以初始化为任意数值,每次线程获取信号量时,计数器将减少。作为使用计数信号量的示例,我们将修改上一节中实现的线程安全队列,并使用信号量而不是条件变量来同步对队列的访问。

新类的成员变量如下:

template <typename T>
class queue {
 // public methods and private helper methods
private:
    std::counting_semaphore<> sem_empty_;
    std::counting_semaphore<> sem_full_;
    std::size_t head_{ 0 };
    std::size_t tail_{ 0 };
    std::size_t capacity_;
    std::vector<T> buffer_;
};

我们仍然需要 head_tail_ 来确定读取和写入元素的位置,capacity_ 用于索引的回绕,以及 buffer_ ,一个 std::vector 向量。但到目前为止,我们并没有使用互斥锁,而是将使用计数信号量代替条件变量。我们将使用两个信号量:sem_empty_ 用于计算缓冲区中的空槽位(初始设置为 capacity_),而 sem_full_ 用于计算缓冲区中的非空槽位,初始设置为 0。

现在,让我们看看如何实现 push 函数,它是用于在队列中插入项目的函数。

[1] 中,sem_empty_ 被获取,减少了信号量计数器。如果队列已满,则线程将阻塞,直到另一个线程通过释放(信号)sem_empty_ 来解除阻塞。如果队列未满,则项目将被复制到缓冲区,并在 [2][3] 中更新 tail_ 。最后,在 [4] 中释放 sem_full_,向另一个线程发出信号,表明队列不为空,且缓冲区中至少有一个项目:

void push(const T& item) {
    sem_empty_.acquire();
    buffer_[tail_] = item;
    tail_ = next(tail_);
    sem_full_.release();
}

pop 函数用于从队列中获取元素:

void pop(T& item) {
    sem_full_.acquire();
    item = buffer_[head_];
    head_ = next(head_);
    sem_empty_.release();
}

在这里,在 [1] 中,如果队列不为空,我们成功获取了 sem_full_。然后,读取项目并在 [2][3] 中分别更新 head_。最后,我们向消费者线程发出信号,表明队列不为空,释放 sem_empty

在我们的 push 的第一个版本中存在几个问题。第一个也是最重要的问题是 sem_empty_ 允许多个线程访问队列中的临界区([2][3])。我们需要同步这个临界区并使用互斥锁。

这里是使用互斥锁进行同步的 push 的新版本。

[2] 中,获取了锁(使用 std::unique_lock),在 [5] 中释放了锁。使用锁将同步临界区,防止多个线程同时访问它,并更新队列而没有任何同步:

void push(const T& item)
{
    sem_empty_.acquire();
    std::unique_lock<std::mutex> lock(mtx_);
    buffer_[tail_] = item;
    tail_ = next(tail_);
    lock.unlock();
    sem_full_.release();
}

第二个问题是获取信号量是阻塞的,正如我们之前所看到的,有时调用线程可以做一些处理,而不仅仅是等待。try_push 函数(及其对应的 try_pop 函数)实现了这一功能。让我们研究一下 try_push 的代码。请注意,try_push 可能仍然会在互斥锁上阻塞:

bool try_push(const T& item) {
    if (!sem_empty_.try acquire()) {
        return false;
    }
    std::unique_lock<std::mutex> lock(mtx_);
    buffer_[tail_] = item;
    tail_ = next(tail_);
    lock.unlock();
    sem_full_.release();
    return true;
}

唯一的变化是 [1][2]。在获取信号量时,我们只是尝试获取它,如果失败,则返回 falsetry_acquire 函数可能会意外失败并返回 false,即使信号量可以被获取(计数不是零)。

这里是使用信号量同步的队列的完整代码。

#pragma once
#include <mutex>
#include <semaphore>
#include <vector>
namespace async_prog {
template <typename T>
class semaphore_queue {
   public:
    semaphore_queue(std::size_t capacity)
        : sem_empty_(capacity), sem_full_(0), capacity_{capacity}, buffer_(capacity)
    {}
    void push(const T& item) {
        sem_empty_.acquire();
        std::unique_lock<std::mutex> lock(mtx_);
        buffer_[tail_] = item;
        tail_ = next(tail_);
        lock.unlock();
        sem_full_.release();
    }
    bool try_push(const T& item) {
        if (!sem_empty_.try_acquire()) {
            return false;
        }
        std::unique_lock<std::mutex> lock(mtx_);
        buffer_[tail_] = item;
        tail_ = next(tail_);
        lock.unlock();
        sem_full_.release();
        return true;
    }
    void pop(T& item) {
        sem_full_.acquire();
        std::unique_lock<std::mutex> lock(mtx_);
        item = buffer_[head_];
        head_ = next(head_);
        lock.unlock();
        sem_empty_.release();
    }
    bool try_pop(T& item) {
        if (!sem_full_.try_acquire()) {
            return false;
        }
        std::unique_lock<std::mutex> lock(mtx_);
        item = buffer_[head_];
        head_ = next(head_);
        lock.unlock();
        sem_empty_.release();
        return true;
    }
private:
    [[nodiscard]] std::size_t next(std::size_t idx) const noexcept {
        return ((idx + 1) % capacity_);
    }
private:
    std::mutex mtx_;
    std::counting_semaphore<> sem_empty_;
    std::counting_semaphore<> sem_full_;
    std::size_t head_{0};
    std::size_t tail_{0};
    std::size_t capacity_;
    std::vector<T> buffer_;
};

在本节中,我们看到了信号量,这是自 C++20 以来包含在 C++ 标准库中的一个新的同步原语。我们学习了如何使用它们来实现我们之前实现的相同队列,但使用信号量作为同步原语。

在下一节中,我们将介绍 屏障闩锁,这是自 C++20 以来包含在 C++ 标准库中的两个新的同步机制。

屏障和闩锁

在本节中,我们将介绍屏障和闩锁,这是 C++20 中引入的两个新的同步原语。这些机制允许线程相互等待,从而协调并发任务的执行。

std::latch

std::latch 闩锁是一种同步原语,允许一个或多个线程阻塞,直到指定的操作数量完成。它是一个一次性对象,一旦计数达到零,就不能重置。

以下示例是 latch 在多线程应用程序中使用的简单说明。我们想要编写一个函数,将向量的每个元素乘以二,然后添加向量的所有元素。我们将使用三个线程将向量元素乘以二,然后使用一个线程添加向量的所有元素并获取结果。

我们需要两个闩锁。第一个闩锁将由每个乘以两个向量元素的三个线程递减。添加线程将等待此闩锁为零。然后,主线程将在第二个闩锁上等待以同步打印添加所有向量元素的结果。我们也可以等待执行加法操作的线程调用 join,但这也可以使用闩锁来完成。

现在,让我们分析代码的功能块。我们将在本节后面包含闩锁和屏障示例的完整代码:

std::latch map_latch{ 3 };
auto map_thread = & {
    for (int i = start; i < end; ++i) {
        numbers[i] *= 2;
    }
    map_latch.count_down();
};

每个乘法线程将运行此 lambda 函数,乘以向量中一定范围内的两个元素(从 startend)。一旦线程完成,它将递减 map_latch 计数器一次。一旦所有线程完成其任务,闩锁计数器将为零,等待在 map_latch 上的线程将能够继续并添加向量的所有元素。请注意,线程访问向量的不同元素,因此我们不需要同步对向量本身的访问,但我们不能开始添加数字,直到所有乘法完成。

添加线程的代码如下:

std::latch reduce_latch{ 1 };
auto reduce_thread = & {
    map_latch.wait();
    sum = std::accumulate(numbers.begin(), numbers.end(), 0);
    reduce_latch.count_down();
};

此线程将等待直到 map_latch 计数器降至零,然后添加向量的所有元素,并最终递减 reduce_latch 计数器(它将降至零),以便主线程能够打印最终结果:

reduce_latch.wait();
std::cout << "All threads finished. The sum is: " << sum << '\n';

在了解了闩锁的基本应用之后,接下来,让我们学习关于屏障的内容。

std::barrier

std::barrier 屏障是另一种用于同步一组线程的同步原语。std::barrier 屏障是可重用的。每个线程达到屏障并等待,直到所有参与线程达到相同的屏障点(就像我们使用闩锁时发生的情况)。

std::barrierstd::latch之间的主要区别是重置能力。std::latch是一个单次使用的 barrier,具有计数器机制,不能重置。一旦它达到零,它就会保持在零。相比之下,std::barrier是可重用的。所有线程都达到 barrier 后,它会重置,允许同一组线程在同一个 barrier 上多次同步。

何时使用 latches 和何时使用 barriers?当您有一个线程的一次性聚集点时,使用std::latch,例如在等待多个初始化完成后再继续之前。当您需要通过任务的多个阶段或迭代计算反复同步线程时,使用std::barrier

我们现在将重写之前的示例,这次使用 barriers 而不是 latches。每个线程将乘以二其对应的向量元素的范围,然后将其相加。在这个例子中,主线程将使用join()等待处理完成,然后添加每个线程获得的结果。

工作线程的代码如下:

std::barrier map_barrier{ 3 };
auto worker_thread = & {
    std::cout << std::format("Thread {0} is starting...\n", id);
    for (int i = start; i < end; ++i) {
        numbers[i] *= 2;
    }
    map_barrier.arrive_and_wait();
    for (int i = start; i < end; ++i) {
        sum[id] += numbers[i];
    }
    map_barrier.arrive();
};

代码通过一个 barrier 进行同步。当一个工作线程完成乘法运算后,它会减少map_barrier计数器,并等待 barrier 计数器变为零。一旦它降到零,线程结束等待并开始进行加法运算。barrier 计数器被重置,其值再次等于三。一旦加法完成,barrier 计数器再次减少,但这次线程不会等待,因为他们的任务已经完成。

当然——每个线程都可以先进行加法运算,然后再乘以二。它们不需要互相等待,因为任何线程完成的工作都不依赖于其他线程完成的工作,但这是一个很好的方法来解释 barriers 是如何通过一个简单的例子来工作的。

主线程只是通过join等待工作线程完成,然后打印结果:

for (auto& t : workers) {
    t.join();
}
std::cout << std::format("The total sum is {0}\n",
                         std::accumulate(sum.begin(), sum. End(), 0));

这里是 latches 和 barriers 示例的完整代码:

#include <algorithm>
#include <barrier>
#include <format>
#include <iostream>
#include <latch>
#include <numeric>
#include <thread>
#include <vector>
void multiply_add_latch() {
    const int NUM_THREADS{3};
    std::latch map_latch{NUM_THREADS};
    std::latch reduce_latch{1};
    std::vector<int> numbers(3000);
    int sum{};
    std::iota(numbers.begin(), numbers.end(), 0);
    auto map_thread = & {
        for (int i = start; i < end; ++i) {
            numbers[i] *= 2;
        }
        map_latch.count_down();
    };
    auto reduce_thread = & {
        map_latch.wait();
        sum = std::accumulate(numbers.begin(), numbers.end(), 0);
        reduce_latch.count_down();
    };
    for (int i = 0; i < NUM_THREADS; ++i) {
        std::jthread t(map_thread, std::ref(numbers), 1000 * i, 1000 * (i + 1));
    }
    std::jthread t(reduce_thread, numbers, std::ref(sum));
    reduce_latch.wait();
    std::cout << "All threads finished. The total sum is: " << sum << '\n';
}
void multiply_add_barrier() {
    const int NUM_THREADS{3};
    std::vector<int> sum(3, 0);
    std::vector<int> numbers(3000);
    std::iota(numbers.begin(), numbers.end(), 0);
    std::barrier map_barrier{NUM_THREADS};
    auto worker_thread = & {
        std::cout << std::format("Thread {0} is starting...\n", id);
        for (int i = start; i < end; ++i) {
            numbers[i] *= 2;
        }
        map_barrier.arrive_and_wait();
        for (int i = start; i < end; ++i) {
            sum[id] += numbers[i];
        }
        map_barrier.arrive();
    };
    std::vector<std::jthread> workers;
    for (int i = 0; i < NUM_THREADS; ++i) {
        workers.emplace_back(worker_thread, std::ref(numbers), 1000 * i,
                             1000 * (i + 1), i);
    }
    for (auto& t : workers) {
        t.join();
    }
    std::cout << std::format("All threads finished. The total sum is: {0}\n",
     std::accumulate(sum.begin(), sum.end(), 0));
}
int main() {
    std::cout << "Multiplying and reducing vector using barriers..." << std::endl;
    multiply_add_barrier();
    std::cout << "Multiplying and reducing vector using latches..." << std::endl;
    multiply_add_latch();
    return 0;
}

在本节中,我们看到了 barriers 和 latches。虽然它们不像 mutexes、condition variables 和 semaphores 那样常用,但了解它们总是有用的。这里提供的简单示例展示了 barriers 和 latches 的常见用法:同步在不同阶段执行处理的线程。

最后,我们将看到一个机制,即使代码从不同的线程中被多次调用,也能只执行一次。

只执行一次任务

有时候,我们只需要执行某个任务一次。例如,在一个多线程应用程序中,几个线程可能运行相同的函数来初始化一个变量。任何正在运行的线程都可以这样做,但我们希望初始化恰好只进行一次。

C++标准库提供了std::once_flagstd::call_once来实现这一功能。我们将在下一章中看到如何使用原子操作来实现这一功能。

以下示例将帮助我们理解如何使用std::once_flagstd::call_once在多个线程尝试执行同一任务时仅执行一次任务:

#include <exception>
#include <iostream>
#include <mutex>
#include <thread>
int main() {
    std::once_flag run_once_flag;
    std::once_flag run_once_exceptions_flag;
    auto thread_function = [&] {
        std::call_once(run_once_flag, []{
            std::cout << "This must run just once\n";
        });
    };
    std::jthread t1(thread_function);
    std::jthread t2(thread_function);
    std::jthread t3(thread_function);
    auto function_throws = & {
        if (throw_exception) {
            std::cout << "Throwing exception\n";
            throw std::runtime_error("runtime error");
        }
        std::cout << "No exception was thrown\n";
    };
    auto thread_function_1 = & {
        try {
            std::call_once(run_once_exceptions_flag,
                           function_throws,
                           throw_exception);
        }
        catch (...) {
        }
    };
    std::jthread t4(thread_function_1, true);
    std::jthread t5(thread_function_1, true);
    std::jthread t6(thread_function_1, false);
    return 0;
}

在示例的第一部分,三个线程t1t2t3运行thread_function函数。这个函数从std::call_once调用一个 lambda 表达式。如果您运行此示例,您将看到预期的消息This must run just once只打印一次。

在示例的第二部分,再次,三个线程t4t5t6运行thread_function_1函数。这个函数调用function_throws,该函数根据一个参数可能抛出异常或不抛出异常。此代码表明,如果从std::call_once调用的函数没有成功终止,则它不算作完成,并且应该再次调用std::call_once。只有成功的函数才算作运行函数。

本节最后展示了一种简单的机制,我们可以用它来确保即使函数被从同一线程或不同线程多次调用,该函数也只被执行一次。

摘要

在本章中,我们学习了如何使用 C++标准库提供的基于锁的同步原语。

我们从对竞争条件和互斥需求进行解释开始。然后,我们研究了std::mutex及其如何用于解决竞争条件。我们还了解了使用锁进行同步时出现的主要问题:死锁和活锁。

在学习了解锁之后,我们研究了条件变量,并使用互斥锁和条件变量实现了一个同步队列。最后,我们看到了 C++20 中引入的新同步原语:信号量、闩锁和屏障。

最后,我们研究了 C++标准库提供的机制,以运行一个函数仅一次。

在本章中,我们学习了线程同步的基本构建块以及多线程异步编程的基础。基于锁的线程同步是同步线程最常用的方法。

在下一章中,我们将研究无锁线程同步。我们将从回顾 C++20 标准库提供的原子性、原子操作和原子类型开始。我们将展示一个无锁的单生产者单消费者队列的实现。我们还将介绍 C++内存模型。

进一步阅读

  • 大卫·R·布滕霍夫,《使用 POSIX 线程编程》,Addison Wesley,1997。

  • 安东尼·威廉姆斯,《C++并发实战》,第二版,Manning,2019。

第五章:原子操作

在第四章中,我们学习了基于锁的线程同步。我们学习了互斥锁、条件变量以及其他基于锁的线程同步原语,这些都是基于获取和释放锁的。这些同步机制建立在原子类型和操作之上,这是本章的主题。

我们将研究原子操作是什么,以及它们与基于锁的同步原语有何不同。阅读完本章后,你将具备原子操作的基本知识以及它们的一些应用。基于原子操作的锁免费(不使用锁)同步是一个非常复杂的话题,需要多年的时间来掌握,但我们将为你提供一个我们希望对主题有良好介绍的入门。

在本章中,我们将涵盖以下主要内容:

  • 什么是原子操作?

  • C++内存模型简介

  • C++标准库提供了哪些原子类型和操作?

  • 一些原子操作的示例,从用于收集统计信息的简单计数器到一个基本的类似互斥锁的全单生产者单消费者SPSC)无锁有界队列

技术要求

你需要一个支持 C++20 的较新版本的 C++编译器。一些简短的代码示例将通过链接到非常有用的 godbolt 网站(godbolt.org)提供。对于完整的代码示例,我们将使用书籍仓库,该仓库可在github.com/PacktPublishing/Asynchronous-Programming-with-CPP找到。

示例可以在本地编译和运行。我们已经在运行 Linux(Ubuntu 24.04 LTS)的 Intel CPU 计算机上测试了代码。对于原子操作,尤其是内存排序(关于这一点将在本章后面详细说明),Intel CPU 与 Arm CPU 不同。

请注意,此处代码性能和性能分析将是第十三章的主题。我们将在本章中仅对性能做一些简要说明,以避免使内容过于冗长。

原子操作简介

原子操作是不可分割的(因此得名原子,源自希腊语ἄτομοςatomos,不可分割)。

在本节中,我们将介绍原子操作,它们是什么,以及使用(以及不使用!)它们的一些原因。

原子操作与非原子操作——示例

如果你还记得第四章中的简单计数器示例,我们需要使用同步机制(我们使用了互斥锁)来修改计数器变量,以避免竞态条件。竞态条件的原因是增加计数器需要三个操作:读取计数器值,增加它,并将修改后的计数器值写回内存。如果我们能一次性完成这些操作,就不会有竞态条件。

这正是原子操作所能实现的效果:如果我们有一种atomic_increment操作,每个线程都会在一个指令中读取、增加并写入计数器,从而避免竞争条件,因为在任何时刻,增加计数器都会被完全完成。当我们说完全完成时,意味着每个线程要么增加计数器,要么什么都不做,使得在计数器增加操作中途的中断成为不可能。

以下两个示例仅用于说明目的,并且不是多线程的。我们在这里只关注操作,无论是原子的还是非原子的。

让我们在代码中看看这个。对于以下示例中的 C++代码和生成的汇编语言,请参考godbolt.org/z/f4dTacsKW

int counter {0};
int main() {
    counter++;
    return 0;
}

代码增加了一个全局计数器。现在让我们看看编译器生成的汇编代码以及 CPU 执行了哪些指令(完整的汇编代码可以在之前的链接中找到):

    Mov    eax, DWORD PTR counter[rip]
    Add    eax, 1
    Move    DWORD PTR counter[rip], eax

[1] 将存储在counter中的值复制到eax寄存器,[2] 将存储在eax中的值增加1,最后,[3]eax寄存器的内容复制回counter变量。因此,一个线程可以执行[1]然后被调度出去,而另一个线程在之后执行所有三个指令。当第一个线程完成增加结果后,计数器只会增加一次,因此结果将是错误的。

以下代码执行相同的操作:它增加了一个全局计数器。不过,这次它使用了原子类型和操作。要获取以下示例中的代码和生成的汇编代码,请参考godbolt.org/z/9hrbo31vx

#include <atomic>
std::atomic<int> counter {0};
int main() {
    counter++;
    return 0;
}

我们将在后面解释std::atomic类型和原子增加操作。

生成的汇编代码如下:

    lock add    DWORD PTR counter[rip], 1

只生成了一条指令来将counter变量中的值增加1。这里的lock前缀意味着接下来的指令(在这种情况下是add)将被原子执行。因此,在这个第二个示例中,一个线程在增加计数器过程中不能被中断。作为旁注,一些 Intel x64 指令是原子执行的,并且不使用lock前缀。

原子操作允许线程以不可分割的方式读取、修改(例如,增加一个值)和写入,也可以用作同步原语(类似于我们在第四章中看到的互斥锁)。实际上,我们在这本书中看到的所有基于锁的同步原语都是使用原子操作实现的。原子操作必须由 CPU 提供(如lock add指令)。

在本节中,我们介绍了原子操作,定义了它们是什么,并研究了通过查看编译器生成的汇编指令来实现的非常简单的例子。在下一节中,我们将探讨原子操作的一些优缺点。

何时使用(以及何时不使用)原子操作

使用原子操作是一个复杂的话题,它可能非常困难(或者至少相当棘手)要掌握。这需要大量的经验,我们参加了一些关于这个主题的课程,并被建议不要这样做!无论如何,您总是可以学习基础知识并在实践中进行实验。我们希望这本书能帮助您在学习之旅中取得进步。

原子操作可以在以下情况下使用:

  • 如果多个线程共享可变状态:需要同步线程的情况最为常见。当然,可以使用互斥锁等锁,但在某些情况下,原子操作将提供更好的性能。请注意,然而,使用原子操作并不保证更好的性能。

  • 如果对共享状态的同步访问是细粒度的:如果我们必须同步的数据是一个整数、指针或任何其他 C++内建类型的变量,那么使用原子操作可能比使用锁更好。

  • 为了提高性能:如果您想达到最大性能,那么原子操作可以帮助减少线程上下文切换(参见第二章)并减少锁引入的开销,从而降低延迟。请记住,始终对代码进行性能分析以确保性能得到提升(我们将在第十三章中深入探讨)。

锁可以在以下情况下使用:

  • 如果受保护的数据不是细粒度的:例如,我们正在同步访问一个大于 8 字节(在现代 CPU 上)的数据结构或对象。

  • 如果性能不是问题:锁的使用和推理要简单得多(在某些情况下,使用锁比使用原子操作性能更好)。

  • 为了避免需要获取底层知识:要从原子操作中获得最大性能,需要大量的底层知识。我们将在“C++内存模型”部分介绍其中的一些内容。

我们刚刚学习了何时使用原子操作以及何时不使用。一些应用程序,如低延迟/高频交易系统,需要最大性能并使用原子操作以实现最低的延迟。大多数应用程序通过锁同步将正常工作。

在下一节中,我们将研究阻塞和非阻塞数据结构之间的差异以及一些相关概念的定义。

非阻塞数据结构

第四章中,我们研究了同步队列的实现。我们使用了互斥锁和条件变量作为同步原语。与锁同步的数据结构被称为阻塞数据结构,因为线程会被操作系统阻塞(等待锁变为可用)。

不使用锁的数据结构被称为非阻塞数据结构。大多数(但并非所有)都是无锁的。

如果每个同步操作都在有限步骤内完成,不允许无限期等待条件变为真或假,则数据结构或算法被认为是无锁的。

无锁数据结构的类型如下:

  • 无阻塞:如果所有其他线程都处于挂起状态,则线程将在有限步骤内完成其操作。

  • 无锁:在多个线程同时工作在数据结构上时,线程将在有限步骤内完成其操作。

  • 无等待:在多个线程同时工作在数据结构上时,所有线程将在有限步骤内完成其操作。

实现无锁数据结构非常复杂,在实施之前,我们需要确保这是必要的。使用无锁数据结构的原因如下:

  • 实现最大并发性:如我们之前所看到的,当数据访问同步涉及细粒度数据(如原生类型变量)时,原子操作是一个很好的选择。根据前面的定义,无锁数据结构将允许至少一个访问数据结构的线程在有限步骤内取得一些进展。无等待结构将允许所有访问数据结构的线程在有限步骤内取得一些进展。

    然而,当我们使用锁时,一个线程会拥有锁,而其他线程则只是在等待锁变为可用,因此无锁数据结构可实现的并发性可以更好。

  • 无死锁:因为没有涉及锁,所以我们的代码中不可能有任何死锁。

  • 性能:某些应用程序必须实现尽可能低的延迟,因此等待锁可能是不可以接受的。当线程尝试获取锁,而锁不可用时,操作系统会阻塞该线程。在线程被阻塞期间,调度器需要进行上下文切换以能够调度另一个线程进行执行。这些上下文切换需要时间,而在低延迟应用程序(如高性能网络数据包接收/处理器)中,这些时间可能太多。

我们现在已经了解了阻塞和非阻塞数据结构是什么,以及无锁代码是什么。在下一节中,我们将介绍 C++内存模型。

C++内存模型

本节解释了 C++内存模型及其如何处理并发。C++内存模型从 C++11 开始引入,并定义了 C++内存的两大主要特性:

  • 对象在内存中的布局(即结构方面)。这个主题不会在本书中介绍,因为本书是关于异步编程的。

  • 内存修改顺序(即并发方面)。我们将看到内存模型中指定的不同内存修改顺序。

内存访问顺序

在我们解释 C++内存模型及其支持的不同的内存排序之前,让我们明确我们所说的内存排序是什么。内存排序指的是内存(即程序中的变量)被访问的顺序。内存访问可以是读取或写入(加载和存储)。但是,程序变量的实际访问顺序是什么?对于以下代码,有三个观点:所写的代码顺序、编译器生成的指令顺序,最后是 CPU 执行指令的顺序。这三个排序都可以相同,或者(更可能)不同。

第一种和最明显的排序是代码中的排序。以下代码片段是一个例子:

void func_a(int& a, int& b) {
    a += 1;
    b += 10;
    a += 2;
}

func_a函数首先将 1 加到变量a上,然后加 10 到变量b上,最后将 2 加到变量a上。这是我们想要的方式,也是我们定义要执行语句的顺序。

编译器将前面的代码转换为汇编指令。如果代码执行的结果不变,编译器可以改变我们语句的顺序,以使生成的代码更高效。例如,对于前面的代码,编译器可以首先对变量a执行两个加法操作,然后对变量b执行加法操作,或者它可以直接将 3 加到a上,然后加 10 到b上。正如我们之前提到的,如果结果是相同的,编译器可以执行任何操作来优化代码。

现在让我们考虑以下代码:

void func_a(int& a, int& b) {
    a += 1;
    b += 10 + a;
    a += 2;
}

在这种情况下,对b的操作依赖于对a的先前操作,因此编译器不能重新排序语句,生成的代码将与我们所写的代码(操作顺序相同)一样。

CPU(本书中使用的 CPU 是现代的 Intel x64 CPU)将运行生成的代码。它可以以不同的顺序执行编译器生成的指令。这被称为乱序执行。如果结果是正确的,CPU 可以再次这样做。

有关前例中显示的生成代码的链接:godbolt.org/z/Mhrcnsr9e

首先,为func_1生成的指令显示了优化:编译器通过在一个指令中将 3 加到变量a上,将两个加法操作合并为一个。其次,为func_2生成的指令与我们所写的 C++语句的顺序相同。在这种情况下,CPU 可以执行指令的乱序执行,因为操作之间没有依赖关系。

总结来说,我们可以这样说,CPU 将要运行的代码可能与我们所写的代码不同(再次强调,前提是执行结果与我们在程序中预期的相同)。

我们所展示的所有示例都适用于单线程运行的代码。代码指令的执行顺序可能因编译器优化和 CPU 的乱序执行而不同,但结果仍然正确。

以下代码展示了乱序执行的示例:

    mov    eax, [var1]  ; load variable var1 into reg eax
    inc    eax          ; eax += 1
    mov    [var1], eax  ; store reg eax into var1
    xor    ecx, ecx     ; ecx = 0
    inc    ecx          ; ecx += 1
    add    eax, ecx     ; eax = eax + ecx

CPU 可能会按照前面代码中显示的顺序执行指令,即load var1 [1]。然后,在变量被读取的同时,它可能会执行一些后续指令,例如[4][5],然后,一旦var1被读取,执行[2],然后[3],最后,[6]。指令的执行顺序不同,但结果仍然是相同的。这是一个典型的乱序执行示例:CPU 发出一个加载指令,而不是等待数据可用,它会执行一些其他指令,如果可能的话,以避免空闲并最大化性能。

我们所提到的所有优化(包括编译器和 CPU)都是在不考虑线程间交互的情况下进行的。编译器和 CPU 都不知道不同的线程。在这些情况下,我们需要告诉编译器它可以做什么,不可以做什么。原子操作和锁是实现这一点的途径。

当例如我们使用原子变量时,我们可能不仅需要操作是原子的,还需要在多线程运行时遵循一定的顺序以确保代码能够正确工作。这不能仅仅通过编译器或 CPU 来完成,因为它们都没有涉及多个线程的信息。为了指定我们想要使用的顺序,C++内存模型提供了不同的选项:

  • 宽松 排序 : std::memory_order_relaxed

  • 获取和释放排序 : std::memory_order_acquire , std::memory_order_release , std::memory_order_acq_rel , 和 std::memory_order_consume

  • 顺序一致性 排序 : std::memory_order_seq_cst

C++内存模型定义了一个抽象机以实现与任何特定 CPU 的独立性。然而,CPU 仍然存在,内存模型中可用的功能可能不会适用于特定的 CPU。例如,Intel x64 架构相当限制性,并强制执行相当强的内存顺序。

Intel x64 架构使用一个处理器排序的内存排序模型,可以定义为写入排序并带有存储缓冲区转发。在单处理器系统中,内存排序模型遵循以下原则:

  • 读取不会与任何读取操作重排

  • 写入不会与任何写入操作重排

  • 写入不会与较旧的读取操作重排

  • 读取可能与较旧的写入操作重排(如果要重排的读取和写入操作涉及不同的内存位置)

  • 读取和写入操作不会与锁定(原子)指令重新排序

更多详细信息请参阅英特尔手册(见本章末尾的参考文献),但前面的原则是最相关的。

在多处理器系统中,以下原则适用:

  • 每个单独的处理器使用与单处理器系统相同的序原则

  • 单个处理器的写入操作被所有处理器以相同的顺序观察到

  • 来自单个处理器的写入操作不会与其他处理器的写入操作进行排序

  • 内存序遵循因果关系

  • 除了执行写入操作的处理器之外,任何两个存储操作都以一致的顺序被其他处理器观察到

  • 锁定(原子)指令具有总序

英特尔架构是强序的;每个处理器的存储操作(写指令)按照它们执行时的顺序被其他处理器观察到,并且每个处理器按照程序中出现的顺序执行存储操作。这被称为总存储序TSO)。

ARM 架构支持弱序WO)。以下是主要原则:

  • 读取和写入可以无序执行。与 TSO 不同,正如我们所看到的,除了写入不同地址后的读取外,没有局部重新排序,ARM 架构允许局部重新排序(除非使用特殊指令另行指定)。

  • 写入操作不一定能像在英特尔架构中那样同时被所有线程看到。

  • 通常,这种相对非限制性的内存序允许核心更自由地重新排序指令,从而可能提高多核性能。

我们必须在这里说明,内存序越宽松,对执行代码的推理就越困难,正确同步多个线程使用原子操作就变得更加具有挑战性。此外,您应该记住,无论内存序如何,原子性总是得到保证。

在本节中,我们已经了解了访问内存时序的含义以及我们在代码中指定的序可能与 CPU 执行代码的序不同。在下一节中,我们将看到如何使用原子类型和操作强制某些序。

强制序

我们已经在第四章以及本章前面的内容中看到,来自不同线程的同一内存地址上的非原子操作可能会导致数据竞争和未定义的行为。为了强制线程间操作的序,我们将使用原子类型及其操作。本节将探讨原子在多线程代码中的使用所达到的效果。

以下简单的示例将帮助我们了解可以使用原子操作做什么:

#include <atomic>
#include <chrono>
#include <iostream>
#include <string>
#include <thread>
std::string message;
std::atomic<bool> ready{false};
void reader() {
    using namespace std::chrono::literals;
    while (!ready.load()) {
        std::this_thread::sleep_for(1ms);
    }
    std::cout << "Message received = " << message << std::endl;
}
void writer() {
    message = "Hello, World!";
    ready.store(true);
}
int main() {
    std::thread t1(reader);
    std::thread t2(writer);
    t1.join();
    t2.join();
    return 0;
}

在这个例子中,reader() 等待直到 ready 变量变为 true,然后打印由 writer() 设置的消息。writer() 函数设置消息并将 store 变量设置为 true

原子操作为我们提供了两个特性,用于在多线程代码中强制执行特定的执行顺序:

  • 发生之前:在先前的代码中,[1](设置message变量)发生在[2](将原子ready变量设置为true)之前。同样,[3](在循环中读取ready变量直到其为true)发生在[4](打印消息)之前。在这种情况下,我们使用顺序一致性内存顺序(默认内存顺序)。

  • 同步于:这仅在原子操作之间发生。在先前的例子中,这意味着当ready[1]设置时,其值将对后续不同线程中的读取(或写入)可见(当然,它对当前线程也是可见的),当ready[3]读取时,更改后的值将可见。

现在我们已经看到了原子操作如何强制从不同线程执行内存访问顺序,让我们详细看看 C++内存模型提供的每个内存顺序选项。

在我们开始之前,让我们在这里记住,英特尔 x64 架构(英特尔和 AMD 的桌面处理器)在内存顺序方面相当严格,不需要任何额外的 acquire/release 指令,并且顺序一致性在性能成本方面是低廉的。

顺序一致性

顺序一致性保证了程序按你编写的方式执行。在 1979 年,莱斯利·兰波特将顺序一致性定义为“执行的结果与读取和写入发生某种顺序的结果相同,并且每个处理器的操作以 其程序指定的顺序 出现在这个序列中。

在 C++中,顺序一致性通过std::memory_order_seq_cst选项指定。这是最严格的内存顺序,也是默认的。如果没有指定顺序选项,则将使用顺序一致性。

C++的内存模型默认确保在代码中不存在竞态条件时保持顺序一致性。将其视为一种协议:如果我们正确同步我们的程序以防止竞态条件,C++将保持程序按编写顺序执行的表象。

在此模型中,所有线程必须看到相同的操作顺序。只要计算的可见结果与无序代码的结果相同,操作仍然可以重新排序。如果读取和写入的顺序与编译代码中的顺序相同,则指令和操作可以重新排序。如果满足依赖关系,CPU 可以在读取和写入之间自由重新排序任何其他指令。由于它定义了一致的顺序,顺序一致性是最直观的排序形式。为了说明顺序一致性,让我们考虑以下示例:

#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
std::atomic<bool> x{ false };
std::atomic<bool> y{ false };
std::atomic<int> z{ 0 };
void write_x() {
    x.store(true, std::memory_order_seq_cst);
}
void write_y() {
    y.store(true, std::memory_order_seq_cst);
}
void read_x_then_y() {
    while (!x.load(std::memory_order_seq_cst)) {}
    if (y.load(std::memory_order_seq_cst)) {
        ++z;
    }
}
void read_y_then_x()
{
    while (!y.load(std::memory_order_seq_cst)) {}
    if (x.load(std::memory_order_seq_cst)) {
        ++z;
    }
}
int main() {
    std::thread t1(write_x);
    std::thread t2(write_y);
    std::thread t3(read_x_then_y);
    std::thread t4(read_y_then_x);
    t1.join();
    t2.join();
    t3.join();
    t4.join();
    if (z.load() == 0) {
        std::cout << "This will never happen\n";
    }
    {
        std::cout << "This will always happen and z = " << z << "\n";
    }
    return 0;
}

由于我们在运行代码时使用 std::memory_order_seq_cst,我们应该注意以下事项:

  • 每个线程中的操作按给定顺序执行(不重新排序原子操作)。

  • t1t2 按顺序更新 xy,而 t3t4 看到相同的顺序。如果没有这个属性,t3 可能会看到 xy 的顺序变化,但 t4 可能会看到相反的顺序。

  • 任何其他排序都可能打印 This will never happen,因为 t3t4 可能会看到 xy 的变化顺序相反。我们将在下一节中看到这个示例。

此例中的顺序一致性意味着以下两个事情将会发生:

  • 每个存储操作都被所有线程看到;也就是说,每个存储操作与每个变量的所有加载操作同步,所有线程以相同的顺序看到这些变化。

  • 每个线程的操作顺序相同(操作顺序与代码中的顺序相同)

请注意,不同线程中操作的顺序没有保证,并且来自不同线程的指令可能以任何顺序执行,因为线程可能被调度。

获取-释放排序

获取-释放排序 比顺序一致性排序更宽松。我们不会得到与顺序一致性排序相同的操作总顺序,但仍然可以进行一些同步。一般来说,随着我们增加内存排序的自由度,我们可能会看到性能提升,但推理代码的执行顺序将变得更加困难。

在此排序模型中,原子加载操作是 std::memory_order_acquire 操作,原子存储操作是 std::memory_order_release 操作,原子读-改-写操作可能是 std::memory_order_acquirestd::memory_order_releasestd::memory_order_acq_rel 操作。

获取语义(与 std::memory_order_acquire 一起使用)确保源代码中出现在获取操作之后的线程中的所有读取或写入操作都在获取操作之后发生。这防止内存重新排序获取操作之后的读取和写入。

释放语义(与std::memory_order_release一起使用)确保在源代码中的释放操作之前完成的读取或写入操作在释放操作之前完成。这防止了释放操作之后的读取和写入的内存重排。

以下示例显示了与上一节关于顺序一致性的示例相同的代码,但在此情况下,我们使用原子操作的获取-释放内存顺序:

#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
std::atomic<bool> x{ false };
std::atomic<bool> y{ false };
std::atomic<int> z{ 0 };
void write_x() {
    x.store(true, std::memory_order_release);
}
void write_y() {
    y.store(true, std::memory_order_release);
}
void read_x_then_y() {
    while (!x.load(std::memory_order_acquire)) {}
    if (y.load(std::memory_order_acquire)) {
        ++z;
    }
}
void read_y_then_x() {
    while (!y.load(std::memory_order_acquire)) {}
    if (x.load(std::memory_order_acquire)) {
        ++z;
    }
}
int main() {
    std::thread t1(write_x);
    std::thread t2(write_y);
    std::thread t3(read_x_then_y);
    std::thread t4(read_y_then_x);
    t1.join();
    t2.join();
    t3.join();
    t4.join();
    if (z.load() == 0) {
        std::cout << "This will never happen\n";
    }
    {
        std::cout << "This will always happen and z = " << z << "\n";
    }
    return 0;
}

在这种情况下,z的值可能是 0。因为我们不再具有顺序一致性,在t1x设置为truet2y设置为true之后,t3t4可能对内存访问的不同看法。由于使用了获取-释放内存排序,t3可能看到xtrueyfalse(记住,没有强制排序),而t4可能看到xfalseytrue。当这种情况发生时,z的值将是 0。

除了std::memory_order_acquirestd::memory_order_releasestd::memory_order_acq_rel之外,获取-释放内存排序还包括std::memory_order_consume选项。我们不会对其进行描述,因为根据在线 C++参考,“释放-消费排序的规范正在修订,std::memory_order_consume 的使用 暂时不鼓励。”

松弛内存排序

要执行具有松弛内存排序的原子操作,我们将std::memory_order_relaxed指定为内存顺序选项。

松弛内存排序是最弱形式的同步。它提供两个保证:

  • 操作的原子性。

  • 单个线程中同一原子变量的原子操作不会被重排。这被称为修改顺序一致性。然而,没有保证其他线程将以相同的顺序看到这些操作。

让我们考虑以下场景:一个线程(th1)将值存储到一个原子变量中。在一定的随机时间间隔后,该变量将被新的随机值覆盖。为了这个示例的目的,我们应该假设写入的顺序是 2、12、23、4、6。另一个线程,th2,定期读取相同的变量。第一次读取变量时,th2得到值 23。记住,该变量是原子的,并且加载和存储操作都是使用松弛内存顺序完成的。

如果th2再次读取该变量,它可以获取与之前读取的相同值或任何在之前读取值之后写入的值。它不能读取任何在之前写入的值,因为这会违反修改顺序一致性属性。在当前示例中,第二次读取可能得到 23、4 或 6,但不能得到 2 或 12。如果我们得到 4,th1 将继续写入 8、19 和 7。现在 th2 可能得到 4、6、8、19 或 7,但不能得到 4 之前的任何数字等等。

在两个或多个线程之间,没有保证任何顺序,但一旦读取了一个值,就不能再读取之前写入的值。

松弛模型不能用于线程同步,因为没有可见性顺序保证,但在操作不需要在线程之间紧密协调的场景中很有用,这可以提高性能。

当执行顺序不影响程序的正确性时,通常可以安全使用,例如用于统计的计数器或引用计数器,其中增量顺序的精确性并不重要。

在本节中,我们学习了 C++ 内存模型以及它是如何允许具有不同内存顺序约束的原子操作进行顺序和同步的。在下一节中,我们将看到 C++ 标准库提供的原子类型和操作。

C++ 标准库原子类型和操作

现在我们将介绍 C++ 标准库提供的支持原子类型和操作的数据类型和函数。正如我们已经看到的,原子操作是一个不可分割的操作。要在 C++ 中执行原子操作,我们需要使用 C++ 标准库提供的原子类型。

C++ 标准库原子类型

C++ 标准库提供的原子类型定义在 头文件中。

你可以在在线 C++ 参考中查看定义在 头文件中的所有原子类型的文档,你可以通过 en.cppreference.com/w/cpp/atomic/atomic 访问。我们不会在这里包含所有内容(这就是参考的作用!),但我们将介绍主要概念和使用示例,以进一步阐述我们的解释。

C++ 标准库提供的原子类型如下:

  • std::atomic_flag:原子布尔类型(但与 std::atomic 不同)。它是唯一保证无锁的原子类型。它不提供加载或存储操作。它是所有原子类型中最基本的。我们将用它来实现一个非常简单的类似互斥锁的功能。

  • std::atomic:这是一个用于定义原子类型的模板。所有内建类型都使用此模板定义了自己的原子类型。以下是一些这些类型的示例:

    • std::atomic(及其别名 atomic_bool):我们将使用此原子类型来实现从多个线程中懒加载变量的一次性初始化。

    • std::atomic(及其别名 atomic_int):我们已经在简单的计数器示例中看到了这个原子类型。我们将在另一个示例中使用它来收集统计数据(与计数器示例非常相似)。

    • std::atomic<intptr_t>(及其别名 atomic_intptr_t)。

    • C++20 引入了原子智能指针:std::atomic<std::shared_ptr>std::atomic<std::weak_ptr>

  • 自从 C++20 发布以来,出现了一种新的原子类型,std::atomic_ref

在本章中,我们将重点关注 std::atomic_flag 和一些 std::atomic 类型。对于这里提到的其他原子类型,您可以使用之前的链接访问在线 C++ 参考。

在进一步解释这些类型之前,有一个非常重要的澄清需要做出:仅仅因为一个类型是 原子 的,并不能保证它是 无锁 的。在这里,我们所说的原子意味着不可分割的操作,而所说的无锁意味着有特殊的 CPU 原子指令支持。如果没有硬件支持某些原子操作,C++ 标准库将使用锁来实现这些操作。

要检查原子类型是否无锁,我们可以使用任何 std::atomic 类型下的以下成员函数:

  • bool is_lock_free() const noexcept:如果此类型的所有原子操作都是无锁的,则返回 true,否则返回 false(除了 std::atomic_flag,它保证始终是无锁的)。其余的原子类型可以使用锁(如互斥量)来实现以保证操作的原子性。此外,某些原子类型可能只在某些情况下是无锁的。如果某个 CPU 只能无锁地访问对齐的内存,那么该原子类型的未对齐对象将使用锁来实现。

也有一个常量用来指示原子类型是否始终无锁:

  • 静态常量 bool is_always_lock_free = / 实现定义 */*:如果原子类型始终是无锁的(例如,即使是未对齐的对象),则此常量的值将为 true

重要的是要意识到这一点:原子类型不保证是无锁的。std::atomic 模板不是一个可以将所有原子类型转换为无锁原子类型的魔法机制。

C++ 标准库原子操作

原子操作主要有两种类型:

  • 原子类型的成员函数:例如,std::atomic 有一个 load() 成员函数用于原子地读取其值

  • 自由函数const std::atomic_load(const std::atomic* obj) 函数与之前的函数完全相同

您可以访问以下代码(如果您感兴趣,还可以访问生成的汇编代码)在 godbolt.org/z/Yhdr3Y1Y8 。此代码展示了成员函数和自由函数的使用:

#include <atomic>
#include <iostream>
std::atomic<int> counter {0};
int main() {
    // Using member functions
    int count = counter.load();
    std::cout << count << std::endl;
    count++;
    counter.store(count);
    // Using free functions
    count = std::atomic_load(&counter);
    std::cout << count << std::endl;
    count++;
    std::atomic_store(&counter, count);
    return 0;
}

大多数原子操作函数都有一个参数来指示内存顺序。我们已经在关于 C++ 内存模型的章节中解释了内存顺序是什么,以及 C++ 提供了哪些内存排序类型。

示例 - 使用 C++ 原子标志实现的简单自旋锁

std::atomic_flag 原子类型是最基本的标准原子类型。它只有两种状态:设置和未设置(我们也可以称之为 true 和 false)。它总是无锁的,与任何其他标准原子类型形成对比。因为它如此简单,所以主要用作构建块。

这是原子标志示例的代码:

#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>
class spin_lock {
public:
    spin_lock() = default;
    spin_lock(const spin_lock &) = delete;
    spin_lock &operator=(const spin_lock &) = delete;
    void lock() {
        while  (flag.test_and_set(std::memory_order_acquire)) {
        }
    }
    void unlock() {
        flag.clear(std::memory_order_release);
    }
private:
    std::atomic_flag flag = ATOMIC_FLAG_INIT;
};

在使用之前,我们需要初始化 std::atomic_flag。以下代码展示了如何进行初始化:

std::atomic_flag flag = ATOMIC_FLAG_INIT;

这是初始化 std::atomic_flag 为确定值的唯一方法。ATOMIC_FLAG_INIT 的值是实现定义的。

一旦标志被初始化,我们就可以对其执行两个原子操作:

  • clear:这个操作原子地将标志设置为 false

  • test_and_set:这个操作原子地将标志设置为 true 并获取其前一个值

clear 函数只能使用松散、释放或顺序一致性内存顺序调用。test_and_set 函数只能使用松散、获取或顺序一致性调用。使用任何其他内存顺序将导致未定义行为。

现在让我们看看如何使用 std::atomic_flag 实现一个简单的自旋锁。首先,我们知道操作是原子的,所以线程要么清除标志,要么不清除,如果一个线程清除了标志,它就会被完全清除。线程不可能只 半清除 标志(记住,对于某些非原子标志这是可能的)。test_and_set 函数也是原子的,所以标志被设置为 true,并且我们一次性获得其前一个状态。

要实现基本的自旋锁,我们需要一个原子标志来原子地处理锁状态,以及两个函数:lock() 用于获取锁(就像我们为互斥量所做的那样)和 unlock() 用于释放锁。

简单自旋锁 unlock() 函数

我们将从 unlock() 开始,这是最简单的函数。它只会重置标志(通过将其设置为 false)而不再做其他操作:

void unlock()
{
    flag.clear(std::memory_order_release);
}

代码很简单。如果我们省略了 std::memory_order_seq_cst 参数,将应用最严格的内存顺序选项,即顺序一致性。

简单的自旋锁 lock() 函数

锁函数有更多步骤。首先,让我们解释一下它做什么:lock() 必须检查原子标志是否开启。如果它是关闭的,那么就将其开启并完成。如果标志是开启的,那么就持续检查,直到另一个线程将其关闭。我们将使用 test_and_set() 使这个函数工作:

void lock()
{
    while (flag.test_and_set(std::memory_order_acquire)) {}
}

上述代码的工作方式如下:在一个 while 循环中,test_and_set 将标志设置为 true 并返回前一个值。如果标志已经设置,再次设置它不会改变任何东西,函数返回 true,所以循环会持续设置标志。当最终 test_and_set 返回 false 时,这意味着标志已被清除,我们可以退出循环。

简单自旋锁问题

简单的自旋锁实现已包含在本章中,以介绍原子类型(std::atomic_flag,最简单的标准原子类型)和操作(cleartest_and_set)的使用,但它存在一些严重问题:

  • 其中第一个问题是其性能不佳。仓库中的代码将让您进行实验。预期自旋锁的性能将远低于互斥锁。

  • 线程一直在自旋等待标志被清除。这种忙等待是应该避免的,尤其是在存在线程竞争的情况下。

您可以尝试运行此示例的前述代码。当我们运行它时,我们得到了这些结果,如表 5.1所示。每个线程将计数器加 1 2 亿次。

std::mutex 自旋锁 原子计数器
一个线程 1.03 s 1.33 s 0.82 s
两个线程 10.15 s 39.14 s 4.52 s
四个线程 24.61 s 128.84 s 9.13 s

表 5.1:同步原语分析结果

从上述表中,我们可以看到简单的自旋锁工作得有多差,以及它如何随着线程的增加而恶化。请注意,这个简单的示例只是为了学习,简单的std::mutex自旋锁和原子计数器都可以得到改进,以便原子类型表现更好。

在本节中,我们探讨了 C++标准库提供的最基本原子类型std::atomic_flag。有关此类型和 C++20 中添加的新功能的信息,请参阅在线 C++参考,可在en.cppreference.com/w/cpp/atomic/atomic_flag找到。

在下一节中,我们将探讨如何创建一种简单的方法,让线程告诉主线程它已处理了多少个项目。

示例 - 线程进度报告

有时我们想检查线程的进度或在其完成时收到通知。这可以通过不同的方式完成,例如,使用互斥锁和条件变量,或者使用由互斥锁同步的共享变量,正如我们在第四章中看到的。我们还在本章中看到了如何使用原子操作同步计数器。在以下示例中,我们将使用类似的计数器:

#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
constexpr int NUM_ITEMS{100000};
int main() {
    std::atomic<int> progress{0};
    std::thread worker([&progress] {
        for (int i = 1; i <= NUM_ITEMS; ++i) {
            progress.store(i, std::memory_order_relaxed);
            std::this_thread::sleep_for(std::chrono::milliseconds(1));
        }
    });
    while (true) {
        int processed_items = progress.load(std::memory_order_relaxed);
        std::cout << "Progress: "
                  << processed_items << " / " << NUM_ITEMS
                  << std::endl;
        if (processed_items == NUM_ITEMS) {
            break;
        }
        std::this_thread::sleep_for(std::chrono::seconds(10));
    }
    worker.join();
    return 0;
}

上述代码实现了一个线程(工作线程),它处理一定数量的项目(在这里,处理是通过使线程休眠来模拟的)。每当线程处理一个项目时,它都会增加进度变量。主线程执行一个while循环,并在每次迭代中访问进度变量并写入进度报告(处理的项目数量)。一旦所有项目都处理完毕,循环结束。

在本例中,我们使用了std::atomic原子类型(一个原子整数)和两个原子操作:

  • load() : 该原子操作检索进度变量的值

  • store():这个原子操作修改 progress 变量的值

处理 progressworker 线程以原子方式读取和写入,因此当两个线程访问 progress 变量时不会发生竞争条件。

load()store() 原子操作有一个额外的参数来指示内存顺序。在这个例子中,我们使用了 std::memory_order_relaxed。这是一个使用松散内存顺序的典型例子:一个线程增加一个计数器,另一个线程读取它。我们需要的唯一顺序是读取递增的值,而对于这一点,松散内存顺序就足够了。

在介绍了 load()store() 原子操作用于原子地读写变量之后,让我们看看另一个简单的统计收集应用的例子。

示例 - 简单统计

这个例子与上一个例子有相同的思想:一个线程可以使用原子操作将进度(例如,处理的项目数量)传递给另一个线程。在这个新的例子中,一个线程将生成一些数据,另一个线程将读取这些数据。我们需要同步内存访问,因为我们有两个线程共享相同的内存,并且至少有一个线程正在更改内存。与上一个例子一样,我们将使用原子操作来实现这一点。

以下代码声明了我们将要使用的原子变量,用于收集统计信息——一个用于处理的项目数量,另外两个(分别用于总处理时间和每个项目的平均处理时间):

std::atomic<int> processed_items{0};
std::atomic<float> total_time{0.0f};
std::atomic<double> average_time{0.0};

我们使用原子浮点数和双精度浮点数来表示总时间和平均时间。在完整的示例代码中,我们确保这两种类型都是无锁的,这意味着它们使用 CPU 的原子指令(所有现代 CPU 都应该有这些)。

现在我们来看看工作线程如何使用这些变量:

processed_items.fetch_add(1, std::memory_order_relaxed);
total_time.fetch_add(elapsed_s, std::memory_order_relaxed);
average_time.store(total_time.load() / processed_items.load(), std::memory_order_relaxed);

第一行以原子方式将处理的项目数增加 1。fetch_add 函数将 1 添加到变量值,并返回旧值(我们在这个例子中没有使用它)。

第二行将 elapsed_s(处理一个项目所需的时间,以秒为单位)加到 total_time 变量上,我们使用这个变量来跟踪处理所有项目所需的时间。

然后,第三行通过原子地读取 total_timeprocessed_items 并将结果原子地写入 average_time 来计算每个项目的平均时间。或者,我们也可以使用 fetch_add() 调用的值来计算平均时间,但它们不包括最后处理的项目。我们也可以在主线程中计算 average_time,但在这里我们选择在工作线程中这样做,仅作为一个示例并练习使用原子操作。记住,我们的目标(至少在本章中)并不是速度,而是学习如何使用原子操作。

下面的代码是统计示例的完整代码:

#include <atomic>
#include <chrono>
#include <iostream>
#include <random>
#include <thread>
constexpr int NUM_ITEMS{10000};
void process() {
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> dis(1, 20);
    int sleep_duration = dis(gen);
        std::this_thread::sleep_for(std::chrono::milliseconds(sleep_duration));
}
int main() {
    std::atomic<int> processed_items{0};
    std::atomic<float> total_time{0.0f};
    std::atomic<double> average_time{0.0};
    std::thread worker([&] {
        for (int i = 1; i <= NUM_ITEMS; ++i) {
            auto now = std::chrono::high_resolution_clock::now();
            process();
            auto elapsed = 
                std::chrono::high_resolution_clock::now() - now;
            float elapsed_s =
                std::chrono::duration<float>(elapsed).count();
            processed_items.fetch_add(1, std::memory_order_relaxed);
            total_time.fetch_add(elapsed_s, std::memory_order_relaxed);
            average_time.store(total_time.load() / processed_items.load(), std::memory_order_relaxed);
        }
    });
    while (true) {
        int items = processed_items.load(std::memory_order_relaxed);
        std::cout << "Progress: " << items << " / " << NUM_ITEMS << std::endl;
        float time = total_time.load(std::memory_order_relaxed);
        std::cout << "Total time: " << time << " sec" << std::endl;
        double average = average_time.load(std::memory_order_relaxed);
        std::cout << "Average time: " << average * 1000 << " ms" << std::endl;
        if (items == NUM_ITEMS) {
            break;
        }
        std::this_thread::sleep_for(std::chrono::seconds(5));
    }
    worker.join();
    return 0;
}

让我们总结一下在本节中到目前为止我们所看到的内容:

  • C++标准原子类型:我们使用 std::atomic_flag 实现了一个简单的自旋锁,并且我们已经使用了一些 std::atomic 类型来实现线程间简单数据的通信。我们看到的所有原子类型都是无锁的。

  • load() 原子操作用于原子地读取原子变量的值。

  • store() 原子操作用于原子地将新值写入原子变量。

  • clear()test_and_set() 是由 std::atomic_flag 提供的特殊原子操作。

  • fetch_add(),用于原子地将某个值添加到原子变量中并获取其之前的值。整数和浮点类型还实现了fetch_sub(),用于从原子变量中减去一定值并返回其之前的值。一些用于执行位逻辑操作的函数仅针对整数类型实现:fetch_and()fetch_or(),和fetch_xor()

以下表格总结了原子类型和操作。对于详尽的描述,请参考在线 C++参考:en.cppreference.com/w/cpp/atomic/atomic

表格显示了三种新的操作:exchangecompare_exchange_weak,和compare_exchange_strong。我们将在稍后的示例中解释它们。大多数操作(即函数,而不是运算符)都有一个用于内存顺序的另一个参数。

操作 atomic_flag atomic atomic atomic atomic
test_and_set YES
Clear YES
Load YES YES YES YES
Store YES YES YES YES
fetch_add, += YES YES
fetch_sub, -= YES YES
fetch_and, &= YES
**fetch_or, =** YES
fetch_xor, ^= YES
++, -- YES
Exchange YES YES YES YES
compare_exchange_weak, compare_exchange_strong YES YES YES YES

表 5.2:原子类型和操作

让我们回顾一下 is_lock_free() 函数和 is_always_lock_free 常量。我们看到了如果 is_lock_free() 为真,则原子类型具有具有特殊 CPU 指令的无锁操作。原子类型可能只在某些时候是无锁的,因此 is_always_lock_free 常量告诉我们类型是否始终无锁。到目前为止,我们看到的所有类型都是无锁的。让我们看看当原子类型非无锁时会发生什么。

以下展示了非无锁原子类型的代码:

#include <atomic>
#include <iostream>
struct no_lock_free {
    int a[128];
    no_lock_free() {
        for (int i = 0; i < 128; ++i) {
            a[i] = i;
        }
    }
};
int main() {
    std::atomic<no_lock_free> s;
    std::cout << "Size of no_lock_free: " << sizeof(no_lock_free) << " bytes\n";
    std::cout << "Size of std::atomic<no_lock_free>: " << sizeof(s) << " bytes\n";
    std::cout << "Is std::atomic<no_lock_free> always lock-free: " << std::boolalpha
              << std::atomic<no_lock_free>::is_always_lock_free << std::endl;
    std::cout << "Is std::atomic<no_lock_free> lock-free: " << std::boolalpha << s.is_lock_free() << std::endl;
    no_lock_free s1;
    s.store(s1);
    return 0;
}

当你执行代码时,你会注意到std::atomic<no_lock_free>类型不是无锁的。它的大小,512 字节,是导致这种情况的原因。当我们向原子变量赋值时,该值是原子地写入的,但这个操作没有使用 CPU 原子指令,也就是说它不是无锁的。这个操作的实现取决于编译器,但一般来说,它使用互斥锁或特殊的自旋锁(例如 Microsoft Visual C++)。

这里的教训是,所有原子类型都有原子操作,但它们并不都是无锁的。如果一个原子类型不是无锁的,那么最好使用锁来实现它。

我们了解到一些原子类型不是无锁的。现在我们将看看另一个例子,展示我们尚未覆盖的原子操作:exchangecompare_exchange操作。

示例 – 延迟一次性初始化

有时初始化一个对象可能会很昂贵。例如,一个特定的对象可能需要连接到数据库或服务器,建立这种连接可能需要很长时间。在这些情况下,我们应该在对象使用之前而不是在程序中定义它时初始化对象。这被称为延迟初始化。现在假设多个线程需要首次使用该对象。如果有多个线程初始化对象,那么将创建不同的连接,这是错误的,因为对象只打开和关闭一个连接。因此,必须避免多次初始化。为了确保对象只初始化一次,我们将利用一种称为延迟一次性初始化的方法。

下面的代码展示了延迟一次性初始化的示例:

#include <atomic>
#include <iostream>
#include <random>
#include <thread>
#include <vector>
constexpr int NUM_THREADS{8};
void process() {
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> dis(1, 1000000);
    int sleep_duration = dis(gen);
    std::this_thread::sleep_for(std::chrono::microseconds(sleep_duration));
}
int main() {
    std::atomic<int> init_thread{0};
    auto worker = &init_thread {
        process();
        int init_value = init_thread.load(std::memory_order::seq_cst);
        if (init_value == 0) {
            int expected = 0;
            if (init_thread.compare_exchange_strong(expected, i, std::memory_order::seq_cst)) {
                std::cout << "Previous value of init_thread: " << expected << "\n";
                std::cout << "Thread " << i << " initialized\n";
            } else {
                // init_thread was already initialized
            }
        } else {
            // init_thread was already initialized
        }
    };
    std::vector<std::thread> threads;
    for (int i = 1; i <= NUM_THREADS; ++i) {
        threads.emplace_back(worker, i);
    }
    for (auto &t: threads) {
        t.join();
    }
    std::cout << "Thread: " << init_thread.load() << " initialized\n";
    return 0;
}

在本章前面我们看到的原子类型操作表中,有一些操作我们还没有讨论。现在我们将通过一个例子来解释compare_exchange_strong。在例子中,我们有一个初始值为 0 的变量。有多个线程正在运行,每个线程都有一个唯一的整数 ID(1、2、3 等等)。我们希望将变量的值设置为第一个设置它的线程的 ID,并且只初始化变量一次。在第四章中,我们学习了std::once_flagstd::call_once,我们可以使用它们来实现这种一次性初始化,但本章是关于原子类型和操作的,所以我们将使用这些来实现我们的目标。

为了确保init_thread变量的初始化只进行一次,并且避免由于多个线程的写访问导致的竞态条件,我们使用了一个原子的int。第[1]行原子地读取了init_thread的内容。如果值不是 0,那么这意味着它已经被初始化,并且工作线程不再做其他操作。

init_thread 的当前值存储在 expected 变量中,它代表当我们尝试初始化它时,我们期望 init_thread 将具有的值。现在行 [2] 执行以下步骤:

  1. init_thread 的当前值与 expected 值(再次强调,等于 0)进行比较。

  2. 如果比较不成功,将 init_thread 的当前值复制到 expected 中,然后返回 false

  3. 如果比较成功,将 init_thread 的当前值复制到 expected 中,然后将 init_thread 的当前值设置为 i 并返回 true

只有当 compare_exchange_strong 返回 true 时,当前线程才会初始化 init_thread。此外,请注意,我们需要再次执行比较(即使行 [1] 返回 0 作为 init_thread 的当前值)因为有可能另一个线程已经初始化了该变量。

非常重要的是要注意,如果 compare_exchange_strong 返回 false,则比较失败;如果它返回 true,则比较成功。这对于 compare_exchange_strong 总是成立的。另一方面,compare_exchange_weak 即使比较成功也可能失败(即返回 false)。使用它的原因是在某些平台上,当它在循环内部调用时,它提供了更好的性能。

关于这两个函数的更多信息,请参考在线 C++ 参考文档:en.cppreference.com/w/cpp/atomic/atomic/compare_exchange

在本节关于 C++ 标准库原子类型和操作的讨论中,我们看到了以下内容:

  • 最常用的标准原子类型,例如 std::atomic_flagstd::atomic

  • 最常用的原子操作:load()store()exchange_compare_strong()/ exchange_compare_weak()

  • 包含这些原子类型和操作的基本示例,包括懒加载一次性初始化和线程进度通信

我们已经多次提到,大多数原子操作(函数)允许我们选择我们想要使用的内存顺序。在下一节中,我们将实现一个无锁编程示例:一个 SPSC 无锁队列。

SPSC 无锁队列

我们已经探讨了 C++ 标准库的原子特性,例如原子类型和操作以及内存模型和排序。现在我们将看到一个使用原子实现 SPSC 无锁队列的完整示例。

此队列的主要特性如下:

  • SPSC:此队列设计用于与两个线程一起工作,一个线程将元素推入队列,另一个线程从队列中获取元素。

  • 有界:此队列具有固定大小。我们需要一种方法来检查队列何时达到其容量以及何时没有元素)。

  • 无锁:此队列使用在现代 Intel x64 CPU 上始终无锁的原子类型。

在你开始开发队列之前,请记住,无锁不等于无等待(也要记住,无等待并不完全消除等待;它只是确保每个队列 push/pop 所需的步骤数有一个限制)。一些主要影响性能的方面将在第十三章中讨论。在第十三章中,我们还将优化队列的性能。现在,在本章中,我们将构建一个正确且性能良好的 SPSC 无锁队列——我们将在稍后展示如何提高其性能。

我们在第四章中使用了互斥锁和条件变量来创建一个 SPSC 队列,消费者和生产线程可以安全地访问。本章将使用原子操作达到相同的目标。

我们将在队列中使用相同的数据结构来存储项目:std::vector,具有固定大小,即 2 的幂。这样,我们可以提高性能并快速找到下一个头和尾索引,而无需使用需要除法指令的模运算符。当使用无锁原子类型以获得更好的性能时,我们需要注意影响性能的每一件事。

为什么我们使用 2 的幂作为缓冲区大小?

我们将使用一个向量来保存队列项目。该向量将具有固定大小,比如说N。我们将使向量表现得像环形缓冲区,这意味着在向量中访问元素的索引将在到达末尾后循环回起点。第一个元素将跟随最后一个元素。正如我们在第四章中学到的,我们可以用模运算符做到这一点:

size_t next_index = (curr_index + 1) % N;

如果大小是,例如,四个元素,下一个元素的索引将按照前面的代码计算。对于最后一个索引,我们有以下代码:

next_index = (3 + 1) % 4 = 4 % 4 = 0;

因此,正如我们所说的,向量将是一个环形缓冲区,因为,在最后一个元素之后,我们将回到第一个,然后是第二个,依此类推。

我们可以使用这种方法为任何缓冲区大小N获取下一个索引。但我们为什么只使用 2 的幂的大小?答案是简单的:性能。模(%)运算符需要除法指令,这是昂贵的。当N是 2 的幂时,我们只需做以下操作:

size_t next_index = curr_index & (N – 1);

这比使用模运算符要快得多。

缓冲区访问同步

要访问队列缓冲区,我们需要两个索引:

  • head:当前要读取的元素的索引

  • tail:下一个要写入的元素的索引

消费者线程将使用头索引进行读写。生产线程将使用尾索引进行读写。由于这个原因,我们需要同步对这些变量的访问:

  • 只有一个线程(消费者)写入 head,这意味着它可以以松散的内存顺序读取它,因为它总是看到自己的更改。读取 tail 由读取器线程完成,并且它需要与生产者写入 tail 进行同步,因此它需要获取内存顺序。我们可以为一切使用顺序一致性,但我们希望获得最佳性能。当消费者线程写入 head 时,它需要与生产者读取它的操作同步,因此它需要释放内存顺序。

  • 对于 tail,只有生产者线程写入它,因此我们可以使用松散的内存顺序来读取它,但我们需要释放内存顺序来写入它并与消费者线程的读取同步。为了与消费者线程的写入同步,我们需要获取内存顺序来读取 head

队列类的成员变量如下:

const std::size_t capacity_; // power of two buffer size
std::vector<T> buffer_; // buffer to store queue items handled like a ring buffer
std::atomic<std::size_t> head_{ 0 };
std::atomic<std::size_t> tail_{ 0 };

在本节中,我们看到了如何同步对队列缓冲区的访问。

将元素推入队列

一旦我们决定了队列的数据表示以及如何同步对其元素的访问,让我们实现将元素推入队列的函数:

bool push(const T& item) {
    std::size_t tail =
        tail_.load(std::memory_order_relaxed);
    std::size_t next_tail =
       (tail + 1) & (capacity_ - 1);
    if (next_tail != head_.load(std::memory_order_acquire)) {
        buffer_[tail] = item;
        tail_.store(next_tail, std::memory_order_release);
        return true;
    }
    return false;
}

当前尾索引,即数据项(如果可能)要推入队列的缓冲区槽位,在行 [1] 中原子地读取。正如我们之前提到的,这个读取可以使用 std::memory_order_relaxed,因为只有生产者线程更改此变量,并且它是唯一调用 push 的线程。

[2] 计算下一个索引对容量取模(记住缓冲区是一个环形)。我们需要这样做来检查队列是否已满。

我们在行 [3] 中执行检查。我们首先使用 std::memory_order_acquire 原子地读取当前头值,因为我们希望生产者线程观察到消费者线程对此变量所做的修改。然后我们将其值与下一个头索引进行比较。

如果下一个尾值等于当前头值,那么(根据我们的约定)队列已满,我们返回 false

如果队列未满,行 [4] 将数据项复制到队列缓冲区。这里值得指出的是,数据复制不是原子的。

[5] 原子地将新的尾索引值写入 tail_。然后,使用 std::memory_order_release 使更改对使用 std::memory_order_acquire 原子读取此变量的消费者线程可见。

从队列中弹出元素

现在我们来看一下 pop 函数是如何实现的:

bool pop(T& item) {
    std::size_t head =
        head_.load(std::memory_order_relaxed);
    if (head == tail_.load(std::memory_order_acquire)) {
        return false;
    }
    item = buffer_[head];
    head_.store((head + 1) & (capacity_ - 1), std::memory_order_release);
    return true;
}

[1] 原子地读取 head_(下一个要读取的项目索引)的当前值。我们使用 std::memory_order_relaxed,因为不需要执行顺序强制,因为 head_ 变量只由消费者线程修改,它是唯一调用 pop 的线程。

[2] 检查队列是否为空。如果当前 head_ 的值与当前 tail_ 的值相同,则队列为空,函数仅返回 false。我们使用 std::memory_order_acquire 原子地读取 tail_ 的值,以查看生产者线程对 tail_ 的最新更改。

[3] 将队列中的数据复制到作为 pop 参数传递的项目引用中。再次强调,这个复制不是原子的。

最后,行 [4] 更新 head_ 的值。同样,我们使用 std::memory_order_release 原子地写入值,以便消费者线程可以看到消费者线程对 head_ 的更改。

SPSC 无锁队列实现的代码如下:

#include <atomic>
#include <cassert>
#include <iostream>
#include <vector>
#include <thread>
template<typename T>
class spsc_lock_free_queue {
public:
    // capacity must be power of two to avoid using modulo operator when calculating the index
    explicit spsc_lock_free_queue(size_t capacity) : capacity_(capacity), buffer_(capacity) {
        assert((capacity & (capacity - 1)) == 0 && "capacity must be a power of 2");
    }
    spsc_lock_free_queue(const spsc_lock_free_queue &) = delete;
    spsc_lock_free_queue &operator=(const spsc_lock_free_queue &) = delete;
    bool push(const T &item) {
        std::size_t tail = tail_.load(std::memory_order_relaxed);
        std::size_t next_tail = (tail + 1) & (capacity_ - 1);
        if (next_tail != head_.load(std::memory_order_acquire)) {
            buffer_[tail] = item;
            tail_.store(next_tail, std::memory_order_release);
            return true;
        }
        return false;
    }
    bool pop(T &item) {
        std::size_t head = head_.load(std::memory_order_relaxed);
        if (head == tail_.load(std::memory_order_acquire)) {
            return false;
        }
        item = buffer_[head];
        head_.store((head + 1) & (capacity_ - 1), std::memory_order_release);
        return true;
    }
private:
    const std::size_t capacity_;
    std::vector<T> buffer_;
    std::atomic<std::size_t> head_{0};
    std::atomic<std::size_t> tail_{0};
};

完整示例的代码可以在以下书籍仓库中找到:github.com/PacktPublishing/Asynchronous-Programming-in-CPP/blob/main/Chapter_05/5x09-SPSC_lock_free_queue.cpp

在本节中,我们将 SPSC 无锁队列作为原子类型和操作的示例实现。在第 第十三章中,我们将重新审视这个实现并提高其性能。

摘要

本章介绍了原子类型和操作、C++ 内存模型以及 SPSC 无锁队列的基本实现。

以下是我们所查看内容的摘要:

  • C++ 标准库原子类型和操作,它们是什么,以及如何使用一些示例。

  • C++ 内存模型,特别是它定义的不同内存排序。请记住,这是一个非常复杂的话题,本节只是对其进行了基本介绍。

  • 如何实现一个基本的 SPSC 无锁队列。正如我们之前提到的,我们将在第十三章中展示如何提高其性能。性能提升的措施包括消除虚假共享(当两个变量位于同一缓存行中,并且每个变量仅被一个线程修改时发生的情况)和减少真实共享。如果你现在不理解这些内容,请不要担心。我们将在稍后进行讲解,并演示如何运行性能测试。

这是对原子操作的基本介绍,用于同步不同线程之间的内存访问。在某些情况下,使用原子操作相当简单,类似于收集统计数据和简单的计数器。更复杂的应用,如 SPSC 无锁队列的实现,需要更深入地了解原子操作。本章我们所看到的内容有助于理解基础知识,并为进一步研究这个复杂主题打下基础。

在下一章中,我们将探讨承诺和未来,这是 C++ 异步编程的两个基本构建块。

进一步阅读

第三部分:使用承诺(Promises)、未来(Futures)和协程进行异步编程

在这部分,我们将焦点转向本书的核心主题,即异步编程,这是构建响应式、高性能应用程序的关键方面。我们将学习如何通过使用诸如承诺(promises)、未来(futures)、打包任务(packaged tasks)、std::async函数和协程(coroutines)等工具来并发执行任务,而不会阻塞主执行流程。协程是一种革命性的特性,它允许在不创建线程的开销下进行异步编程。我们还将介绍高级技术,用于共享未来(futures),并探讨这些概念在现实世界场景中的必要性。这些强大的机制使我们能够开发出适用于现代软件系统的有效、可扩展和可维护的异步软件。

本部分包含以下章节:

  • 第六章承诺(Promises)和未来(Futures)

  • 第七章异步函数

  • 第八章使用协程进行异步编程

第六章:承诺和未来

在前面的章节中,我们学习了使用 C++管理和同步线程执行的基础知识。我们还在第三章中提到,要从线程返回值,我们可以使用承诺和未来。现在,是时候学习如何使用这些 C++特性来完成这一操作以及更多内容了。

Futurespromises是实现异步编程的基本块。它们定义了一种管理未来将完成的任务结果的方式,通常是在一个单独的线程中。

在本章中,我们将涵盖以下主要主题:

  • 承诺和未来是什么?

  • 共享未来是什么?它们与常规未来有何不同?

  • 包裹任务是什么?我们何时使用它们?

  • 我们如何检查未来的状态和错误?

  • 使用承诺和未来的好处和缺点是什么?

  • 真实场景和解决方案的示例

那么,让我们开始吧!

技术要求

承诺和未来自 C++11 以来就可用,但本章中的一些示例使用了 C++20 的特性,例如std::jthread,因此本章中显示的代码可以由支持 C++20 的编译器编译。

请参阅第三章中的技术要求部分,了解如何安装 GCC 13 和 Clang 8 编译器。

你可以在以下 GitHub 仓库中找到所有完整的代码:

github.com/PacktPublishing/Asynchronous-Programming-with-CPP

本章的示例位于Chapter_06文件夹下。所有源代码文件都可以使用以下方式使用 CMake 编译:

cmake . && cmake build .

可执行二进制文件将在bin目录下生成。

探索承诺和未来

future是一个表示未来某个不确定结果的物体,该结果将在未来某个时间完成。promise是提供该结果的一方。

承诺和未来自 C++11 版本起就是 C++标准的一部分,可以通过包含头文件来使用,承诺通过std::promise类,未来通过std::future类。

std::promisestd::future对实现了一个单次生产者-消费者通道,其中承诺是生产者,未来是消费者。消费者(std::future)可以阻塞,直到生产者(std::promise)的结果可用。

图 6.1 – 承诺-未来通信通道

图 6.1 – 承诺-未来通信通道

许多现代编程语言提供了类似的异步方法,例如 Python(使用asyncio库)、Scala(在scala.concurrent库中)、JavaScript(在其核心库中)、Rust(在其标准库std)或如promising_future这样的 crate 中)、Swift(在 Combine 框架中),以及 Kotlin 等。

使用承诺(promises)和未来(futures)实现异步执行的基本原理是,我们想要运行的函数在后台执行,使用新线程或当前线程,而初始线程使用未来对象来检索函数计算出的结果。这个结果值将在函数完成后存储,因此同时,未来对象将用作占位符。异步函数将使用承诺对象将结果存储在未来的对象中,无需在初始线程和后台线程之间进行显式的同步机制。当初始线程需要该值时,它将从未来对象中检索。如果该值尚未准备好,初始线程的执行将被阻塞,直到未来对象准备好。

使用这个想法,使函数异步运行变得容易。我们只需要意识到该函数可以在单独的线程上运行,因此我们需要避免数据竞争,但结果通信和线程间的同步由承诺-未来对管理。

使用承诺和未来通过卸载计算提高了响应性,并且与线程和回调相比,提供了一种处理异步操作的结构化方法,我们将在本章中探讨。

让我们现在了解这两个对象。

承诺(Promises)

承诺在头文件中定义为std::promise

使用承诺,我们得到一个协议,即结果将在未来的某个时间点可用。这样,我们可以让后台任务执行其工作并计算结果。同时,主线程也将继续其任务,当需要结果时,请求它。那时,结果可能已经准备好了。

此外,如果抛出异常而不是返回有效值,承诺可以通信,并且它们将确保其生命周期持续到线程完成并将结果写入它。

因此,承诺是一种存储结果(值或异常)的设施,该结果稍后可以通过未来异步获取。承诺对象仅打算使用一次,之后不能修改。

除了结果外,每个承诺还持有共享状态。共享状态是一个内存区域,用于存储完成状态、同步机制以及指向结果的指针。它通过允许承诺存储结果或异常、在完成时发出信号以及允许未来对象访问结果(如果承诺尚未准备好则阻塞)来确保承诺和未来之间的适当通信和同步。承诺可以使用以下操作更新其共享状态:

  • 准备就绪:承诺将结果存储在共享状态中,并将承诺的状态变为就绪,从而解除任何等待与承诺相关的未来对象的线程的阻塞。记住,结果可以是值(甚至可以是空值)或异常。

  • 释放:承诺释放其对共享状态的引用,如果这是最后一个引用,则该引用将被销毁。这种内存释放机制类似于共享指针及其控制块所使用的机制。除非共享状态是由std::async创建且尚未处于就绪状态(我们将在下一章中学习这一点),否则此操作不会阻塞。

  • 放弃:承诺存储了一个类型为std::future_error的异常,错误代码为std::future_errc::broken_promise,使共享状态准备好然后释放它。

可以使用其默认构造函数或使用自定义分配器来构造std::promise对象。在这两种情况下,都会创建一个新的承诺,其共享状态为空。承诺也可以使用移动构造函数来构造;因此,新的承诺将拥有其他承诺的共享状态。初始承诺将保持没有共享状态。

移动承诺在资源管理、通过避免额外复制进行优化以及保持正确的所有权语义的场景中很有用;例如,当承诺需要在另一个线程中完成、存储在容器中、返回到 API 调用的调用者或发送到回调处理程序时很有用。

承诺不能被复制(它们的复制构造函数或复制赋值运算符被删除),避免了两个承诺对象共享相同的共享状态,并在存储在共享状态中的结果时存在数据竞争的风险。

由于承诺可以被移动,它们也可以被交换。来自标准模板库STL)的std::swap函数为承诺提供了模板特化。

当删除承诺对象时,相关的 future 仍然可以访问共享状态。如果删除发生在承诺设置值之后,共享状态将处于释放模式,因此 future 可以访问结果并使用它。然而,如果承诺在设置结果值之前被删除,共享状态将移动到放弃状态,并且 future 在尝试获取结果时将获得std::future_errc::broken_promise

可以通过使用std::promise函数的set_value()来设置一个值,通过使用set_exception()函数来设置一个异常。结果以原子方式存储在承诺的共享状态中,使其状态准备好。让我们看一个例子:

auto threadFunc = [](std::promise<int> prom) {
    try {
        int result = func();
        prom.set_value(result);
    } catch (...) {
        prom.set_exception(std::current_exception());
    }
};
std::promise<int> prom;
std::jthread t(threadFunc, std::move(prom));

在前面的例子中,创建了prom承诺并将其移动到threadFunc lambda 函数作为参数。由于承诺是不可复制的,我们需要使用按值传递并移动承诺到参数中,以避免复制。

在 lambda 函数内部,调用func()函数,并将结果存储在承诺中使用set_value()。如果func()抛出异常,它将被捕获并使用set_exception()存储到承诺中。正如我们稍后将要学习的,这个结果(值或异常)可以通过使用 future 在调用线程中提取。

在 C++14 中,我们还可以使用泛化 lambda 捕获将承诺传递到 lambda 捕获中:

using namespace std::literals;
std::promise<std::string> prom;
auto t = std::jthread([prm = std::move(prom)] mutable {
    std::this_thread::sleep_for(100ms);
    prm.set_value("Value successfully set");
});

因此,prm = std::move(prom) 是将外部承诺 prom 移动到 lambda 的内部承诺 prm 中。默认情况下,参数被捕获为常量,所以我们需要将 lambda 声明为可变的,以便允许 prm 被修改。

set_value() 如果承诺没有共享状态(错误代码设置为 no_state )或者共享状态已经存储了结果(错误代码设置为 promise_already_satisfied ),则可以抛出 std::future_error 异常。

set_value() 也可以不指定值使用。在这种情况下,它只是使状态准备好。这可以用作屏障,正如我们将在介绍 future 后在本章后面看到的那样。

图 6.2 显示了一个表示不同共享状态转换的图。

图 6.2 – Promise 共享状态转换图

图 6.2 – Promise 共享状态转换图

有另外两个函数可以设置承诺的值,set_value_at_thread_exitset_exception_at_thread_exit 。与之前一样,结果立即被存储,但使用这些新函数,状态不会立即准备好。状态在所有线程局部变量被销毁后,线程退出时才变为准备好。这在我们需要线程管理在退出前需要清理的资源时很有用,即使发生异常,或者如果我们想在线程退出时提供准确的日志活动或监控。

在抛出异常或同步机制以避免数据竞争方面,这两个函数的行为与 set_value()set_exception() 相同。

现在我们已经了解了如何在承诺中存储结果,让我们来了解这对中的另一个成员,future。

Futures

Future 定义在 头文件中,作为 std::future

如我们之前所看到的,future 是通信通道的消费者端。它提供了访问承诺存储的结果的权限。

一个 std::future 对象必须通过调用 get_future()std::promise 对象创建,或者通过 std::packaged_task 对象(本章后面将详细介绍)或调用 std::async 函数(在 第七章 ):

std::promise<int> prom;
std::future<int> fut = prom.get_future();

与承诺一样,future 可以移动但不能复制,原因相同。为了从多个 future 中引用相同的共享状态,我们需要使用共享 future(在下一节 共享 future 中解释)。

get() 方法可以用来检索结果。如果共享状态仍然没有准备好,这个调用将通过内部调用 wait() 来阻塞。当共享状态准备好时,返回结果值。如果共享状态中存储了异常,那么这个异常将被重新抛出:

try {
    int result = fut.get();
    std::cout << "Result from thread: " << result << '\n';
} catch (const std::exception& e) {
    std::cerr << "Exception: " << e.what() << '\n';
}

在前面的例子中,结果是通过使用get()函数从fut未来检索的。如果结果是值,它将以以Result from thread开头的行打印出来。另一方面,如果抛出了异常并将其存储到承诺中,它将被重新抛出并在调用线程中被捕获,并打印出以Exception开头的行。

调用get()方法后,valid()将返回false。如果由于某种原因在valid()false时调用get(),行为是未定义的,但 C++标准建议抛出一个带有std::future_errc::no_state错误代码的std::future_error异常。当valid()函数返回false时,未来仍然可以被移动。

当一个未来被销毁时,它释放其共享状态引用。如果那是最后一个引用,共享状态将被销毁。除非在特定情况下使用std::async,否则这些操作不会阻塞,我们将在第七章中学习到这一点。

未来错误和错误代码

正如我们在前面的例子中看到的那样,一些处理异步执行和共享状态的功能可以抛出std::future_error异常。

这个异常类是从std::logic_error继承的,它反过来又是从std::exception继承的,分别定义在头文件中。

与 STL 中定义的任何其他异常一样,可以通过其code()函数或通过其what()函数使用解释性字符串来检查错误代码。

未来报告的错误代码由std::future_errorc定义,这是一个范围枚举(enum class)。C++标准定义了以下错误代码,但实现可能定义额外的代码:

  • broken_promise:当在设置结果之前删除承诺时报告,因此共享状态在有效之前被释放。

  • future_already_retrieved:当std::promise::get_future()被多次调用时发生。

  • promise_already_satisfied:当std::promise::set_value()报告共享状态已经有一个存储的结果时。

  • no_state:当使用某些方法但没有共享状态(因为是通过使用默认构造函数或移动创建的承诺)时报告。正如我们将在本章后面看到的那样,当调用某些包装任务(std::packaged_task)方法,例如get_future()make_ready_at_thread_exit()reset(),并且它们的共享状态尚未创建,或者当使用std::future::get()与尚未准备好的未来(std::future::valid()返回false)时,这种情况会发生。

等待结果

std::future 还提供了阻塞线程并等待结果可用的函数。这些函数是 wait()wait_for()wait_until()wait() 函数将在结果准备好之前无限期地阻塞,wait_for() 在一段时间后,wait_until() 在达到特定时间后。所有这些函数都会在等待期间内结果可用时立即返回。

这些函数必须在 valid()true 时调用。否则,行为是未定义的,但根据 C++ 标准建议抛出一个带有 std::future_errc::no_state 错误代码的 std::future_error 异常。

如前所述,使用 std::promise::set_value() 而不指定值将共享状态设置为就绪。这可以与 std::future::wait() 一起使用来实现一个屏障,并阻止线程在接收到信号之前继续执行。以下示例展示了这一机制的实际应用。

让我们先添加所需的头文件:

#include <algorithm>
#include <cctype>
#include <chrono>
#include <future>
#include <iostream>
#include <iterator>
#include <sstream>
#include <thread>
#include <vector>
#include <set>
using namespace std::chrono_literals;

main() 函数内部,程序将首先创建两个承诺,numbers_promiseletters_promise,以及它们对应的未来,numbers_readyletters_ready

std::promise<void> numbers_promise, letters_promise;
auto numbers_ready = numbers_promise.get_future();
auto letter_ready = letters_promise.get_future();

然后,input_data_thread 模拟了两个按顺序运行的 I/O 线程操作,一个将数字复制到一个向量中,另一个将字母插入到一个集合中:

std::istringstream iss_numbers{"10 5 2 6 4 1 3 9 7 8"};
std::istringstream iss_letters{"A b 53 C,d 83D 4B ca"};
std::vector<int> numbers;
std::set<char> letters;
std::jthread input_data_thread([&] {
    // Step 1: Emulating I/O operations.
    std::copy(std::istream_iterator<int>{iss_numbers},
              std::istream_iterator<int>{},
              std::back_inserter(numbers));
    // Notify completion of Step 1.
    numbers_promise.set_value();
    // Step 2: Emulating further I/O operations.
    std::copy_if(std::istreambuf_iterator<char>
                               {iss_letters},
                   std::istreambuf_iterator<char>{},
                   std::inserter(letters,
                               letters.end()),
                               ::isalpha);
    // Notify completion of Step 2.
    letters_promise.set_value();
});
// Wait for numbers vector to be filled.
numbers_ready.wait();

当这个操作正在进行时,主线程通过使用 numbers_ready.wait() 停止其执行,等待对应的承诺 numbers_promise 准备就绪。一旦所有数字都被读取,input_data_thread 将调用 numbers_promise.set_value(),唤醒主线程并继续其执行。

如果字母尚未通过使用 letters_ready 未来的 wait_for() 函数读取,并且检查是否超时,那么数字将被排序并打印:

std::sort(numbers.begin(), numbers.end());
if (letter_ready.wait_for(1s) == std::future_status::timeout) {
    for (int num : numbers) std::cout << num << ' ';
    numbers.clear();
}
// Wait for letters vector to be filled.
letter_ready.wait();

这段代码展示了主线程可以执行一些工作。同时,input_data_thread 继续处理传入的数据。然后,主线程将再次通过调用 letters_ready.wait() 等待。

最后,当所有字母都添加到集合中时,主线程将通过再次使用 letters_promise.set_value() 被信号唤醒,并且数字(如果尚未打印)和字母将按顺序打印:

for (int num : numbers) std::cout << num << ' ';
std::cout << std::endl;
for (char let : letters) std::cout << let << ' ';
std::cout << std::endl;

正如我们在前面的例子中看到的,等待函数会返回一个未来状态对象。接下来,让我们学习这些对象是什么。

未来状态

wait_for()wait_until() 返回一个 std::future_status 对象。

未来可以处于以下任何一种状态:

  • Ready:共享状态是 Ready,表示结果可以被检索。

  • Deferred:共享状态包含一个 deferred 函数,这意味着结果只有在明确请求时才会被计算。我们将在介绍 std::async 时学习更多关于延迟函数的内容。

  • 超时:在共享状态准备好之前,指定的 超时周期 已经过去。

接下来,我们将学习如何通过使用共享未来在多个未来之间共享承诺结果。

共享未来

如我们之前看到的,std::future 只能移动,因此只有一个未来对象可以引用特定的异步结果。另一方面,std::shared_future 是可复制的,因此多个共享未来对象可以引用相同的共享状态。

因此,std::shared_future 允许从不同的线程安全访问相同的共享状态。共享未来对于在多个消费者或感兴趣方之间共享计算密集型任务的结果非常有用,可以减少冗余计算。此外,它们可以用作通知事件或作为同步机制,其中多个线程必须等待单个任务的完成。在本章的后面部分,我们将学习如何使用共享未来链式异步操作。

std::shared_object 的接口与 std::future 的接口相同,因此关于等待和获取函数的所有解释都适用于此处。

可以通过使用 std::future::share() 函数创建共享对象:

std::shared_future<int> shared_fut = fut.share();

这将使原始未来失效(其 valid() 函数将返回 false)。

以下示例显示了如何同时将相同的结果发送到多个线程:

#define sync_cout std::osyncstream(std::cout)
int main() {
    std::promise<int> prom;
    std::future<int> fut = prom.get_future();
    std::shared_future<int> shared_fut = fut.share();
    std::vector<std::jthread> threads;
    for (int i = 1; i <= 5; ++i) {
        threads.emplace_back([shared_fut, i]() {
            sync_cout << "Thread " << i << ": Result = "
                      << shared_fut.get() << std::endl;
        });
    }
    prom.set_value(5);
    return 0;
}

我们首先创建一个承诺 prom,从它获取未来 fut,最后通过调用 share() 获取共享未来 shared_fut

然后,创建了五个线程并将它们添加到一个向量中,每个线程都有一个共享未来实例和一个索引。所有这些线程都将通过调用 shared_future.get() 等待承诺 prom 准备就绪。当在共享状态中设置值时,该值将可以通过所有线程访问。运行前一个程序的输出如下:

Thread 5: Result = 5
Thread 3: Result = 5
Thread 4: Result = 5
Thread 2: Result = 5
Thread 1: Result = 5

因此,共享未来也可以用来同时向多个线程发出信号。

打包的任务

打包的任务,或 std::packaged_task,也在 头文件中定义,是一个模板类,它封装了一个将被异步调用的可调用对象。其结果存储在共享状态中,可以通过 std::future 对象访问。要创建 std::packaged_task 对象,我们需要定义模板参数,该参数表示将要调用的任务的功能签名,并将所需的函数作为其构造函数参数传递。以下是一些示例:

// Using a thread.
std::packaged_task<int(int, int)> task1(
                      std::pow<int, int>);
std::jthread t(std::move(task1), 2, 10);
// Using a lambda function.
std::packaged_task<int(int, int)> task2([](int a, int b)
{
    return std::pow(a, b);
});
task2(2, 10);
// Binding to a function.
std::packaged_task<int()> task3(std::bind(std::pow<int, int>, 2, 10));
task3();

在前面的例子中,task1 是通过使用函数创建并通过使用线程执行。另一方面,task2 是通过使用 lambda 函数创建并通过调用其方法 operator() 执行。最后,task3 是通过使用 std::bind 的转发调用包装器创建。

要检索与任务关联的未来,只需从其 packaged_task 对象中调用 get_future()

std::future<int> result = task1.get_future();

与承诺和未来一样,可以通过使用默认构造函数、移动构造函数或分配器来使用没有共享状态的包装任务。因此,包装任务是移动-only 和不可复制的。此外,赋值运算符和交换函数的行为与承诺和未来类似。

包装任务的析构函数的行为类似于承诺的析构函数;如果在有效之前释放了共享状态,将抛出一个带有 std::future_errc::broken_promise 错误代码的 std::future_error 异常。与未来一样,包装任务定义了一个 valid() 函数,如果 std::packaged_task 对象有一个共享状态,该函数返回 true

与承诺一样,get_future() 只能调用一次。如果这个函数被多次调用,将会抛出一个带有 future_already_retrieved 代码的 std::future_error 异常。如果包装的任务是从默认构造函数创建的,因此没有共享状态,错误代码将是 no_state

如前一个示例所示,存储的可调用对象可以通过使用 operator() 来调用:

task1(2, 10);

有时候,只有在运行包装任务的线程退出并且所有其 thread-local 对象都被销毁时才使结果就绪是很有趣的。这是通过使用 make_ready_at_thread_exit() 函数实现的。即使结果直到线程退出才就绪,它也会立即计算,因此其计算不会被延迟。

例如,让我们定义以下函数:

void task_func(std::future<void>& output) {
    std::packaged_task<void(bool&)> task{[](bool& done){
        done = true;
    }};
    auto result = task.get_future();
    bool done = false;
    task.make_ready_at_thread_exit(done);
    std::cout << "task_func: done = "
              << std::boolalpha << done << std::endl;
    auto status = result.wait_for(0s);
    if (status == std::future_status::timeout)
        std::cout << "task_func: result not ready\n";
    output = std::move(result);
}

这个函数创建了一个名为 task 的包装任务,将其布尔参数设置为 true。从这个任务中也创建了一个名为 result 的未来对象。当通过调用 make_ready_at_thread_exit() 执行任务时,其 done 参数被设置为 true,但未来结果仍然没有被标记为就绪。当 task_func 函数退出时,result 未来对象被移动到通过引用传递的位置。此时,线程退出,result 未来对象将被设置为就绪。

因此,如果我们使用以下代码从主线程调用此任务:

std::future<void> result;
std::thread t{task_func, std::ref(result)};
t.join();
auto status = result.wait_for(0s);
if (status == std::future_status::ready)
    std::cout << «main: result ready\n»;

程序将显示以下输出:

task_func: done = true
task_func: result not ready
main: result ready

make_ready_at_thread_exit() 如果没有共享状态(no_state 错误代码)或任务已经被调用(promise_already_satisfied 错误代码),将抛出 std::future_error 异常。

包装任务的状态也可以通过调用 reset() 来重置。这个函数将放弃当前状态并构建一个新的共享状态。显然,如果在调用 reset() 时没有状态,将抛出一个带有 no_state 错误代码的异常。重置后,必须通过调用 get_future() 来获取一个新的未来对象。

以下示例打印了前 10 个 2 的幂次方数。每个数字都是通过调用相同的 packaged_task 对象来计算的。在每次循环迭代中,packaged_task 被重置,并检索一个新的未来对象:

std::packaged_task<int(int, int)> task([](int a, int b){
    return std::pow(a, b);
});
for (int i=1; i<=10; ++i) {
    std::future<int> result = task.get_future();
    task(2, i);
    std::cout << "2^" << i << " = "
              << result.get() << std::endl;
    task.reset();
}

执行前述代码的输出如下:

2¹ = 2
2² = 4
2³ = 8
2⁴ = 16
2⁵ = 32
2⁶ = 64
2⁷ = 128
2⁸ = 256
2⁹ = 512
2¹⁰ = 1024

正如我们将在下一章中学习的,std::async提供了一种更简单的方法来实现相同的结果。std::packaged_task的唯一优点是能够指定任务将在哪个线程上运行。

现在我们已经了解了如何使用承诺、未来和打包任务,是时候了解这种方法的优点以及可能出现的缺点了。

承诺和未来的利弊

使用承诺和未来的优点以及一些缺点。以下是主要观点。

优点

作为管理异步操作的高级抽象,通过使用承诺(promises)和未来(futures)来编写和推理并发代码被简化了,并且错误更少。

未来和承诺允许任务并发执行,使程序能够有效地使用多个 CPU 核心。这可以提高计算密集型任务的性能并减少执行时间。

此外,它们通过将操作的启动与其完成解耦,简化了异步编程。正如我们稍后将会看到的,这对于 I/O 密集型任务特别有用,例如网络请求或文件操作,在这些任务中,程序可以在等待异步操作完成的同时继续执行其他任务。因此,它们不仅可以返回一个值,还可以返回一个异常,允许异常从异步任务传播到等待其完成的调用代码部分,这允许更干净的错误处理和恢复方式。

正如我们之前提到的,它们还提供了一种同步任务完成和检索其结果的方法。这有助于协调并行任务并管理它们之间的依赖关系。

缺点

不幸的是,并非所有消息都是积极的;也有一些领域受到了影响。

例如,使用未来和承诺进行异步编程在处理任务之间的依赖关系或管理异步操作的生命周期时可能会引入复杂性。如果存在循环依赖关系,还可能发生潜在的死锁。

同样,由于底层的同步机制,使用未来和承诺可能会引入一些性能开销,这些机制涉及协调异步任务和管理共享状态。

与其他并发或异步解决方案一样,与同步代码相比,使用未来和承诺的代码调试可能更具挑战性,因为执行流程可能是非线性的,并涉及多个线程。

现在是时候通过实现一些示例来解决真实场景了。

真实场景和解决方案的示例

现在我们已经了解了创建异步程序的一些新构建块,让我们为一些真实场景构建解决方案。在本节中,我们将学习如何执行以下操作:

  • 取消异步操作

  • 返回组合结果

  • 链接异步操作并创建管道

  • 创建一个线程安全的 单生产者单消费者SPSC)任务队列

取消异步操作

如我们之前所见,未来(futures)提供了在等待结果之前检查完成或超时的能力。这可以通过检查 std::futurewait_for()wait_until() 函数返回的 std::future_status 对象来实现。

通过结合使用未来(futures)和机制,如取消标志(通过 std::atomic_bool 实现)或超时,我们可以在必要时优雅地终止长时间运行的任务。超时取消可以通过简单地使用 wait_for()wait_until() 函数来实现。

通过传递定义为 std::atomic_bool 的取消标志或令牌的引用,可以使用取消标志或令牌来取消任务。调用线程将此值设置为 true 以请求任务取消,而工作线程将定期检查此标志是否已设置。如果已设置,它将优雅地退出并执行任何需要完成的清理工作。

让我们先定义一个长时间运行的任务函数:

const int CHECK_PERIOD_MS = 100;
bool long_running_task(int ms,
            const std::atomic_bool& cancellation_token) {
    while (ms > 0 && !cancellation_token) {
        ms -= CHECK_PERIOD_MS;
        std::this_thrsead::sleep_for(100ms);
    }
    return cancellation_token;
}

long_running_task 函数接受两个参数:一个表示运行任务的毫秒数(ms)和一个表示取消令牌的原子布尔值引用(cancellation_token)。该函数将定期检查取消令牌是否设置为 true。当运行周期结束或取消令牌设置为 true 时,线程将退出。

在主线程中,我们可以使用两个 包装任务对象来执行此函数,即 task1 持续时间为 500 毫秒,在线程 t1 中运行,而 task2 在线程 t2 中运行一秒钟。它们共享相同的取消令牌:

std::atomic_bool cancellation_token{false};
std::cout << "Starting long running tasks...\n";
std::packaged_task<bool(int, const std::atomic_bool&)>
                task1(long_running_task);
std::future<bool> result1 = task1.get_future();
std::jthread t1(std::move(task1), 500,
                std::ref(cancellation_token));
std::packaged_task<bool(int, const std::atomic_bool&)>
                task2(long_running_task);
std::future<bool> result2 = task2.get_future();
std::jthread t2(std::move(task2), 1000,
                std::ref(cancellation_token));
std::cout << "Cancelling tasks after 600 ms...\n";
this_thread::sleep_for(600ms);
cancellation_token = true;
std::cout << "Task1, waiting for 500 ms. Cancelled = "
          << std::boolalpha << result1.get() << "\n";
std::cout << "Task2, waiting for 1 second. Cancelled = "
          << std::boolalpha << result2.get() << "\n";

在两个任务启动后,主线程休眠 600 毫秒。当它醒来时,它将取消令牌设置为 true。那时,task1 已经完成,但 task2 仍在运行。因此,task2 被取消。

这种解释与获得的输出一致:

Starting long running tasks...
Cancelling tasks after 600 ms...
Task1, waiting for 500 ms. Cancelled = false
Task2, waiting for 1 second. Cancelled = true

接下来,让我们看看如何将几个异步计算结果组合成一个单独的未来(future)。

返回组合结果

异步编程中的另一种常见方法是使用多个承诺(promises)和未来(futures)将复杂任务分解为更小的独立子任务。每个子任务可以在单独的线程中启动,其结果存储在相应的承诺中。然后,主线程可以使用未来(futures)等待所有子任务完成,并将它们的结果组合起来以获得结果。

这种方法对于实现多个独立任务的并行处理非常有用,允许高效地利用多个核心以实现更快的计算。

让我们看看一个模拟值计算和 I/O 操作的任务示例。我们希望这个任务返回一个包含两个结果的元组,即作为 int 值的计算值,以及从文件中读取的信息作为 string 对象。因此,我们定义了 combineFunc 函数,它接受一个 combineProm 承诺作为参数,该承诺包含一个包含结果类型的元组。

此函数将创建两个线程,computeThreadfetchData,以及它们各自的承诺 computePromfetchProm,以及未来 computeFutfetchFut

void combineFunc(std::promise<std::tuple<int,
                 std::string>> combineProm) {
    try {
        // Thread to simulate computing a value.
        std::cout << "Starting computeThread...\n";
        auto computeVal = [](std::promise<int> prom)
                            mutable {
            std::this_thread::sleep_for(1s);
            prom.set_value(42);
        };
        std::promise<int> computeProm;
        auto computeFut = computeProm.get_future();
        std::jthread computeThread(computeVal,
                              std::move(computeProm));
        // Thread to simulate downloading a file.
        std::cout << "Starting dataThread...\n";
        auto fetchData = [](
                 std::promise<std::string> prom) mutable {
            std::this_thread::sleep_for(2s);
            prom.set_value("data.txt");
        };
        std::promise<std::string> fetchProm;
        auto fetchFut = fetchProm.get_future();
        std::jthread dataThread(fetchData,
                                std::move(fetchProm));
        combineProm.set_value({
                    computeFut.get(),
                    fetchFut.get()
        });
    } catch (...) {
        combineProm.set_exception(
                    std::current_exception());
    }
}

如我们所见,两个线程将异步且独立地执行,生成一个结果并将其存储在它们各自的承诺中。

通过在各个未来上调用 get() 函数并将它们的结果组合成一个元组,然后通过调用其 set_value() 函数来设置组合承诺的值,从而设置组合承诺:

combineProm.set_value({computeFut.get(), fetchFut.get()});

combineFunc 任务可以通过使用线程并设置 combineProm 承诺及其 combineFut 未来来像往常一样调用。在这个未来上调用 get() 函数将返回一个元组:

std::promise<std::tuple<int, std::string>> combineProm;
auto combineFuture = combineProm.get_future();
std::jthread combineThread(combineFunc,
                           std::move(combineProm));
auto [data, file] = combineFuture.get();
std::cout << "Value [ " << data
          << " ]  File [ « << file << « ]\n»;

运行此示例将显示以下结果:

Creating combined promise...
Starting computeThread...
Starting dataThread...
Value [ 42 ]  File [ data.txt ]

现在,让我们继续学习如何使用承诺和未来创建管道。

连接异步操作

承诺和未来可以连接在一起以顺序执行多个异步操作。我们可以创建一个管道,其中一个未来的结果成为下一个操作承诺的输入。这允许我们组合复杂的异步工作流程,其中一个任务的输出馈入下一个任务。

此外,我们还可以在管道中允许分支,并在需要之前保持一些任务处于关闭状态。这可以通过使用延迟执行的未来来实现,这在计算成本高但结果不一定总是需要的情况下很有用。因此,我们可以使用未来异步启动计算,并在需要时才检索结果。由于只有使用 std::async 才能创建具有延迟状态的未来,我们将将其留到下一章。

在本节中,我们将专注于创建以下任务图:

图 6.3 – 管道示例

图 6.3 – 管道示例

我们首先定义一个名为 Task 的模板类,它接受一个可调用对象作为模板参数,用于定义要执行的功能。这个类还将允许我们创建与依赖任务共享未来的任务。这些任务将使用共享的未来来等待前驱任务通过在关联的承诺中调用 set_value() 来通知它们完成,然后再运行它们自己的任务:

#define sync_cout std::osyncstream(std::cout)
template <typename Func>
class Task {
   public:
    Task(int id, Func& func)
        : id_(id), func_(func), has_dependency_(false) {
        sync_cout << "Task " << id
                 << " constructed without dependencies.\n";
        fut_ = prom_.get_future().share();
    }
    template <typename... Futures>
    Task(int id, Func& func, Futures&&... futures)
        : id_(id), func_(func), has_dependency_(true) {
        sync_cout << "Task " << id
                  << « constructed with dependencies.\n";
        fut_ = prom_.get_future().share();
        add_dependencies(
                  std::forward<Futures>(futures)...);
    }
    std::shared_future<void> get_dependency() {
        return fut_;
    }
    void operator()() {
        sync_cout << "Running task " << id_ << '\n';
        wait_completion();
        func_();
        sync_cout << "Signaling completion of task "
                  << id_ << ‹\n';
        prom_.set_value();
    }
   private:
    template <typename... Futures>
    void add_dependencies(Futures&&... futures) {
        (deps_.push_back(futures), ...);
    }
    void wait_completion() {
        sync_cout << "Waiting completion for task "
                  << id_ << ‹\n';
        if (!deps_.empty()) {
            for (auto& fut : deps_) {
                if (fut.valid()) {
                    sync_cout << "Fut valid so getting "
                              << "value in task "
                              << id_ << ‹\n';
                    fut.get();
                }
            }
        }
    }
   private:
    int id_;
    Func& func_;
    std::promise<void> prom_;
    std::shared_future<void> fut_;
    std::vector<std::shared_future<void>> deps_;
    bool has_dependency_;
};

让我们一步一步地描述这个 Task 类是如何实现的。

有两个构造函数:一个用于初始化一个没有与其他任务依赖关系的Task类型对象,另一个是模板化的构造函数,用于初始化一个具有可变数量依赖任务的任务。两者都初始化了一个标识符(id_),调用以执行任务的函数(func_),一个表示任务是否有依赖的布尔变量(has_dependency_),以及一个与将依赖于此任务的共享未来,fut_,共享的共享未来。这个fut_未来是从用于表示任务完成的prom_承诺中检索的。模板化构造函数还会调用add_dependencies函数,将作为参数传递的未来转发,这些未来将被存储在deps_向量中。

get_dependency()函数仅返回依赖任务使用的共享未来,以等待当前任务的完成。

最后,operator()通过调用wait_completion()等待先前任务的完成,该函数检查存储在deps_向量中的每个共享未来是否有效并等待通过调用get()结果就绪,一旦所有共享未来都就绪,意味着所有先前任务都已完成,func_函数被调用,运行任务,然后通过调用set_value()prom_承诺设置为就绪,从而触发依赖任务。

在主线程中,我们定义管道如下,创建一个类似于图 6 .3 中所示的图:

auto sleep1s = []() { std::this_thread::sleep_for(1s); };
auto sleep2s = []() { std::this_thread::sleep_for(2s); };
Task task1(1, sleep1s);
Task task2(2, sleep2s, task1.get_dependency());
Task task3(3, sleep1s, task2.get_dependency());
Task task4(4, sleep2s, task2.get_dependency());
Task task5(5, sleep2s, task3.get_dependency(),
                       task4.get_dependency());

然后,我们需要通过触发所有任务并调用它们的operator()来启动管道。由于task1没有依赖,它将立即开始运行。所有其他任务都将等待其前驱任务完成工作:

sync_cout << "Starting the pipeline..." << std::endl;
task1();
task2();
task3();
task4();
task5();

最后,我们需要等待管道完成所有任务的执行。我们可以通过简单地等待最后一个任务,task5,返回的共享未来就绪来实现这一点:

sync_cout << "Waiting for the pipeline to finish...\n";
auto finish_pipeline_fut = task5.get_dependency();
finish_pipeline_fut.get();
sync_cout << "All done!" << std::endl;

这里是运行此示例的输出:

Task 1 constructed without dependencies.
Getting future from task 1
Task 2 constructed with dependencies.
Getting future from task 2
Task 3 constructed with dependencies.
Getting future from task 2
Task 4 constructed with dependencies.
Getting future from task 4
Getting future from task 3
Task 5 constructed with dependencies.
Starting the pipeline...
Running task 1
Waiting completion for task 1
Signaling completion of task 1
Running task 2
Waiting completion for task 2
Fut valid so getting value in task 2
Signaling completion of task 2
Running task 3
Waiting completion for task 3
Fut valid so getting value in task 3
Signaling completion of task 3
Running task 4
Waiting completion for task 4
Fut valid so getting value in task 4
Signaling completion of task 4
Running task 5
Waiting completion for task 5
Fut valid so getting value in task 5
Fut valid so getting value in task 5
Signaling completion of task 5
Waiting for the pipeline to finish...
Getting future from task 5
All done!

使用这种方法可能会出现一些问题,我们必须注意。首先,依赖图必须是一个有向无环图DAG),因此没有依赖任务之间的循环或循环。否则,可能会发生死锁,因为一个任务可能会等待一个未来发生但尚未启动的任务。此外,我们需要足够的线程来并发运行所有任务或有序启动任务;否则,也可能导致线程等待尚未启动的任务的完成,从而导致死锁。

这种方法的一个常见用例可以在MapReduce算法中找到,其中大型数据集在多个节点上并行处理,可以使用未来和线程来并发执行映射和归约任务,从而实现高效的分布式数据处理。

线程安全的 SPSC 任务队列

在这个最后一个例子中,我们将展示如何使用承诺和未来创建一个 SPSC 队列。

生产者线程为要添加到队列中的每个项目创建一个承诺。消费者线程等待从空队列槽中获取的未来对象。一旦生产者完成添加项目,它就在相应的承诺上设置值,通知等待的消费者。这允许在保持线程安全的同时在线程之间进行高效的数据交换。

让我们先定义线程安全的队列类:

template <typename T>
class ThreadSafeQueue {
   public:
    void push(T value) {
        std::lock_guard<std::mutex> lock(mutex_);
        queue_.push(std::move(value));
        cond_var_.notify_one();
    }
    T pop() {
        std::unique_lock<std::mutex> lock(mutex_);
        cond_var_.wait(lock, [&]{
            return !queue_.empty();
        });
        T value = std::move(queue_.front());
        queue_.pop();
        return value;
    }
   private:
    std::queue<T> queue_;
    std::mutex mutex_;
    std::condition_variable cond_var_;
};

在这个例子中,我们简单地使用互斥锁来在推送或弹出元素时对所有队列数据结构进行互斥访问。我们希望保持这个例子简单,并专注于承诺和未来对象的交互。更好的方法可能是使用向量或循环数组,并使用互斥锁控制队列中各个元素的访问。

队列还使用一个条件变量,cond_var_,在尝试弹出元素时如果队列为空则等待,并在元素被推入时通知一个等待的线程。元素通过移动来在队列中进出。这是必需的,因为队列将存储未来对象,正如我们之前所学的,未来对象是可移动的但不可复制的。

将使用线程安全的队列来定义一个任务队列,该队列将存储未来对象,如下所示:

using TaskQueue = ThreadSafeQueue<std::future<int>>;

然后,我们定义一个函数,producer,它接受队列的引用和一个将要生产的值,val。这个函数只是创建一个承诺,从承诺中检索一个未来对象,并将该未来对象推入队列。然后,我们通过使线程等待一个随机的毫秒数来模拟一个正在运行的任务并产生值,val。最后,该值存储在承诺中:

void producer(TaskQueue& queue, int val) {
    std::promise<int> prom;
    auto fut = prom.get_future();
    queue.push(std::move(fut));
    std::this_thread::sleep_for(
            std::chrono::milliseconds(rand() % MAX_WAIT));
    prom.set_value(val);
}

在通信通道的另一端,消费者函数接受对同一队列的引用。同样,我们通过等待一个随机的毫秒数来模拟消费者端正在运行的任务。然后,从队列中弹出一个未来对象并检索其结果,该结果为值,val,或者如果发生错误则为异常:

void consumer(TaskQueue& queue) {
    std::this_thread::sleep_for(
            std::chrono::milliseconds(rand() % MAX_WAIT));
    std::future<int> fut = queue.pop();
    try {
        int result = fut.get();
        std::cout << "Result: " << result << "\n";
    } catch (const std::exception& e) {
        std::cerr << "Exception: " << e.what() << '\n';
    }
}

对于这个例子,我们将使用以下常量:

const unsigned VALUE_RANGE = 1000;
const unsigned RESULTS_TO_PRODUCE = 10; // Numbers of items to produce.
const unsigned MAX_WAIT = 500; // Maximum waiting time (ms) when producing items.

在主线程中,启动了两个线程;第一个运行生产者函数producerFunc,它将一些未来对象推入队列,而第二个线程运行消费者函数consumerFunc,它从队列中消费元素:

TaskQueue queue;
auto producerFunc = [](TaskQueue& queue) {
    auto n = RESULTS_TO_PRODUCE;
    while (n-- > 0) {
        int val = rand() % VALUE_RANGE;
        std::cout << "Producer: Sending value " << val
                  << std::endl;
        producer(queue, val);
    }
};
auto consumerFunc = [](TaskQueue& queue) {
    auto n = RESULTS_TO_PRODUCE;
    while (n-- > 0) {
        std::cout << "Consumer: Receiving value"
                  << std::endl;
        consumer(queue);
    }
};
std::jthread producerThread(producerFunc, std::ref(queue));
std::jthread consumerThread(consumerFunc, std::ref(queue));

下面是执行此代码的示例输出:

Producer: Sending value 383
Consumer: Receiving value
Producer: Sending value 915
Result: 383
Consumer: Receiving value
Producer: Sending value 386
Result: 915
Consumer: Receiving value
Producer: Sending value 421
Result: 386
Consumer: Receiving value
Producer: Sending value 690
Result: 421
Consumer: Receiving value
Producer: Sending value 926
Producer: Sending value 426
Result: 690
Consumer: Receiving value
Producer: Sending value 211
Result: 926
Consumer: Receiving value
Result: 426
Consumer: Receiving value
Producer: Sending value 782
Producer: Sending value 862
Result: 211
Consumer: Receiving value
Result: 782
Consumer: Receiving value
Result: 862

使用这样的生产者-消费者队列,消费者和生产者是解耦的,它们的线程以异步方式通信,允许生产者和消费者在另一边生成或处理值时执行额外的工作。

摘要

在这一章中,我们学习了关于承诺和未来的知识,如何使用它们在单独的线程中执行异步代码,以及如何使用包装任务运行可调用对象。这些对象和机制构成了许多编程语言(包括 C++)使用的异步编程的关键概念。

我们现在也理解了为什么承诺、未来和包装任务不能被复制,以及如何通过使用共享未来对象来共享未来。

最后,我们展示了如何使用未来、承诺和包装任务来解决现实生活中的问题。

如果你想更深入地探索承诺和未来,值得提一下一些第三方开源库,特别是Boost.ThreadFacebook Folly。这些库包括额外的功能,包括回调、执行器和组合器。

在下一章中,我们将学习一种更简单的方法,通过使用std::async来异步调用可调用对象。

进一步阅读

第七章:异步函数

在上一章中,我们学习了关于承诺、未来和封装任务的内容。当我们介绍封装任务时,我们提到 std::async 提供了一种更简单的方法来实现相同的结果,代码更少,因此更简洁。

异步函数std::async)是一个函数模板,它异步运行可调用对象,我们还可以通过传递定义启动策略的标志来选择执行方法。它是处理异步操作的有力工具,但它的自动管理和对执行线程缺乏控制,以及其他方面,也可能使其不适合需要精细控制或取消的任务。

在本章中,我们将涵盖以下主要主题:

  • 异步函数是什么,以及我们如何使用它?

  • 有哪些不同的启动策略?

  • 与之前的方法相比,有哪些不同,尤其是封装任务?

  • 使用 std::async 的优缺点是什么?

  • 实际场景和示例

技术要求

异步函数自 C++11 以来一直可用,但一些示例使用了 C++14 的功能,例如 chrono_literals,以及 C++20 的功能,例如 counting_semaphore,因此本章中显示的代码可以由支持 C++20 的编译器编译。

请查阅 第三章 中的 技术要求 部分,以获取如何安装 GCC 13 和 Clang 8 编译器的指导。

你可以在以下 GitHub 仓库中找到所有完整的代码:

github.com/PacktPublishing/Asynchronous-Programming-with-CPP

本章的示例位于 Chapter_07 文件夹下。所有源代码文件都可以使用 CMake 编译,如下所示:

cmake . && cmake —build .

可执行二进制文件将在 bin 目录下生成。

std::async 是什么?

std::async 是 C++ 中由 C++ 标准在 头文件中引入的函数模板,作为 C++11 的线程支持库的一部分。它用于异步运行函数,允许主线程(或其他线程)继续并发运行。

总结来说,std::async 是 C++ 中异步编程的强大工具,使并行运行任务和管理其结果更加高效。

启动异步任务

要使用 std::async 异步执行函数,我们可以使用与在 第三章 中启动线程时相同的方法,但使用不同的可调用对象。

一种方法是使用函数指针:

void func() {
    std::cout << "Using function pointer\n";
}
auto fut1 = std::async(func);

另一种方法是使用 lambda 函数:

auto lambda_func = []() {
    std::cout << "Using lambda function\n";
};
auto fut2 = std::async(lambda_func);

我们还可以使用内嵌的 lambda 函数:

auto fut3 = std::async([]() {
    std::cout << "Using embedded lambda function\n";
});

我们可以使用一个重载了 operator() 的函数对象:

class FuncObjectClass {
   public:
    void operator()() {
        std::cout << "Using function object class\n";
    }
};
auto fut4 = std::async(FuncObjectClass());

我们可以通过传递成员函数的地址和对象的地址来调用成员函数的非静态成员函数:

class Obj {
  public:
    void func() {
        std::cout << "Using a non-static member function"
                  << std::endl;
    }
};
Obj obj;
auto fut5 = std::async(&Obj::func, &obj);

我们也可以使用静态成员函数,其中只需要成员函数的地址,因为方法是静态的:

class Obj {
  public:
    static void static_func() {
        std::cout << "Using a static member function"
                  << std::endl;
    }
};
auto fut6 = std::async(&Obj::static_func);

当调用std::async时,它返回一个 future,其中将存储函数的结果,正如我们在上一章所学到的。

传递值

同样,类似于我们在创建线程时传递参数的方式,参数可以通过值、引用或指针传递给线程。

这里,我们可以看到如何通过值传递参数:

void funcByValue(const std::string& str, int val) {
    std::cout << «str: « << str << «, val: « << val
              << std::endl;
}
std::string str{"Passing by value"};
auto fut1 = async(funcByValue, str, 1);

通过值传递意味着创建一个临时对象并复制参数值到其中。这避免了数据竞争,但成本较高。

下一个例子展示了如何通过引用传递值:

void modifyValues(std::string& str) {
    str += " (Thread)";
}
std::string str{"Passing by reference"};
auto fut2 = std::async(modifyValues, std::ref(str));

我们还可以将值作为const 引用传递:

void printVector(const std::vector<int>& v) {
    std::cout << "Vector: ";
    for (int num : v) {
        std::cout << num << " ";
    }
    std::cout << std::endl;
}
std::vector<int> v{1, 2, 3, 4, 5};
auto fut3 = std::async(printVector, std::cref(v));

通过引用传递是通过使用std::ref()(非常量引用)或std::cref()(常量引用)实现的,这两个都在头文件中定义,让定义线程构造函数的变长模板(一个支持任意数量参数的类或函数模板)将参数作为引用处理。在传递参数时缺少这些函数意味着通过值传递参数,如前所述,这会使函数调用成本更高。

你也可以将对象移动到由std::async创建的线程中,如下所示:

auto fut4 = std::async(printVector, std::move(v));

注意,在内容被移动后,向量v处于一个有效但为空的状态。

最后,我们还可以通过 lambda 捕获传递值:

std::string str5{"Hello"};
auto fut5 = std::async([&]() {
    std::cout << "str: " << str5 << std::endl;
});

在这个例子中,str变量被std::async执行的 lambda 函数通过引用访问。

返回值

当调用std:async时,它立即返回一个 future,该 future 将包含函数或可调用对象将计算出的值,正如我们在上一章使用 promises 和 futures 时所见。

在之前的例子中,我们没有使用std::async返回的对象。让我们重写包装任务部分中的最后一个例子,在第六章中,我们使用了一个std::packaged_task对象来计算两个值的幂。但在这个例子中,我们将使用std::async来生成几个异步任务来计算这些值,等待任务完成,存储结果,并最终在控制台显示它们:

#include <chrono>
#include <cmath>
#include <future>
#include <iostream>
#include <thread>
#include <vector>
#include <syncstream>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
int compute(unsigned taskId, int x, int y) {
    std::this_thread::sleep_for(std::chrono::milliseconds(
                               rand() % 200));
    sync_cout << "Running task " << taskId << '\n';
    return std::pow(x, y);
}
int main() {
    std::vector<std::future<int>> futVec;
    for (int i = 0; i <= 10; i++)
        futVec.emplace_back(std::async(compute,
                            i+1, 2, i));
    sync_cout << "Waiting in main thread\n";
    std::this_thread::sleep_for(1s);
    std::vector<int> results;
    for (auto& fut : futVec)
        results.push_back(fut.get());
    for (auto& res : results)
        std::cout << res << ' ';
    std::cout << std::endl;
    return 0;
}

compute()函数简单地获取两个数字,xy,并计算<mml:math  >mml:msupmml:mrowmml:mix</mml:mi></mml:mrow>mml:mrowmml:miy</mml:mi></mml:mrow></mml:msup></mml:math> . 它还获取一个表示任务标识符的数字,并在控制台打印消息并计算结果之前等待最多两秒钟。

main() 函数中,主线程启动了几个计算 2 的幂次序列值的任务。通过调用 std::async 返回的未来存储在 futVec 向量中。然后,主线程等待一秒钟,模拟一些工作。最后,我们遍历 futVec 向量,并在每个未来元素中调用 get() 函数,从而等待该特定任务完成并返回一个值,我们将返回的值存储在另一个名为 results 的向量中。然后,在退出程序之前,我们打印 results 向量的内容。

这是运行该程序时的输出:

Waiting in main thread
Running task 11
Running task 9
Running task 2
Running task 8
Running task 4
Running task 6
Running task 10
Running task 3
Running task 1
Running task 7
Running task 5
1 2 4 8 16 32 64 128 256 512 1024

如我们所见,每个任务完成所需的时间不同,因此输出不是按任务标识符排序的。但是,当我们按顺序遍历在获取结果时 futVec 向量,这些结果被显示为有序。

现在我们已经看到了如何启动异步任务并传递参数和返回值,让我们学习如何使用启动策略来控制执行方法。

启动策略

除了在调用 std::async 函数时将函数或可调用对象作为参数外,我们还可以指定 launch policy。启动策略控制 std::async 如何调度异步任务的执行。这些定义在 库中。

在调用 std::async 时,必须将启动策略指定为第一个参数。此参数的类型为 std::launch,它是一个位掩码值,其中其位控制允许的执行方法,可以是以下枚举常量之一:

  • std::launch::async:任务将在一个单独的线程中执行。

  • std::launch::deferred:通过在第一次通过未来 get()wait() 方法请求结果时在调用线程中执行任务,启用延迟评估。所有对同一 std::future 的进一步访问都将立即返回结果。这意味着任务只有在显式请求结果时才会执行,这可能导致意外的延迟。

如果未定义,默认的启动策略将是 std::launch::async | std::launch::deferred。此外,实现可以提供额外的启动策略。

因此,默认情况下,C++标准声明 std::async 可以在异步或延迟模式下运行。

注意,当指定多个标志时,行为是实现定义的,因此取决于我们使用的编译器。标准建议使用可用的并发性和在指定默认启动策略时延迟任务。

让我们实现以下示例来测试不同的启动策略行为。首先,我们定义 square() 函数,它将作为异步任务:

#include <chrono>
#include <future>
#include <iostream>
#include <string>
#include <syncstream>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
int square(const std::string& task_name, int x) {
    sync_cout << "Launching " << task_name
              << « task...\n»;
    return x * x;
}

然后,在main()函数中,程序首先启动三个不同的异步任务,一个使用std::launch::async启动策略,另一个任务使用std::launch::deferred启动策略,第三个任务使用默认启动策略:

sync_cout << "Starting main thread...\n";
auto fut_async = std::async(std::launch::async,
                           square, «async_policy", 2);
auto fut_deferred = std::async(std::launch::deferred,
                            square, «deferred_policy", 3);
auto fut_default = std::async(square,
                            «default_policy", 4);

如前一章所述,wait_for()返回一个std::future_status对象,指示未来是否就绪、延迟还是超时。因此,我们可以使用该函数来检查返回的任何未来对象是否延迟。我们通过使用返回true的 lambda 函数is_deferred()来实现这一点。至少有一个未来对象fut_deferred预期会返回true

auto is_deferred = [](std::future<int>& fut) {
    return (fut.wait_for(0s) ==
            std::future_status::deferred);
};
sync_cout << "Checking if deferred:\n";
sync_cout << "  fut_async: " << std::boolalpha
          << is_deferred(fut_async) << '\n';
sync_cout << "  fut_deferred: " << std::boolalpha
          << is_deferred(fut_deferred) << '\n';
sync_cout << "  fut_default: " << std::boolalpha
          << is_deferred(fut_default) << '\n';

然后,主程序等待一秒钟,模拟一些处理,最后从异步任务中检索结果并打印它们的值:

sync_cout << "Waiting in main thread...\n";
std::this_thread::sleep_for(1s);
sync_cout << "Wait in main thread finished.\n";
sync_cout << "Getting result from "
          << "async policy task...\n";
int val_async = fut_async.get();
sync_cout << "Result from async policy task: "
          << val_async << '\n';
sync_cout << "Getting result from "
          << "deferred policy task...\n";
int val_deferred = fut_deferred.get();
sync_cout << "Result from deferred policy task: "
          << val_deferred << '\n';
sync_cout << "Getting result from "
          << "default policy task...\n";
int val_default = fut_default.get();
sync_cout << "Result from default policy task: "
          << val_default << '\n';

这是运行前面代码的输出:

Starting main thread...
Launching async_policy task...
Launching default_policy task...
Checking if deferred:
  fut_async: false
  fut_deferred: true
  fut_default: false
Waiting in main thread...
Wait in main thread finished.
Getting result from async policy task...
Result from async policy task: 4
Getting result from deferred policy task...
Launching deferred_policy task...
Result from deferred policy task: 9
Getting result from default policy task...
Result from default policy task: 16

注意,具有默认和std::launch::async启动策略的任务在主线程睡眠时执行。因此,任务一旦可以调度就会立即启动。同时注意,使用std::launch::deferred启动策略的延迟任务在请求值时开始执行。

接下来,让我们学习如何处理异步任务中发生的异常。

处理异常

当使用std::async时,不支持从异步任务到主线程的异常传播。为了启用异常传播,我们可能需要一个承诺对象来存储异常,稍后可以通过调用std::async时返回的未来来访问该异常。但该承诺对象不是由std::async提供的或可访问的。

实现这一点的可行方法之一是使用一个包装异步任务的std::packaged_task对象。但如果那样做,我们应该直接使用前一章中描述的包装任务。

我们还可以使用自 C++11 以来可用的嵌套异常,通过使用std::nested_exception,这是一个可以捕获和存储当前异常的多态混合类,允许任意类型的嵌套异常。从std::nested_exception对象中,我们可以使用nested_ptr()方法检索存储的异常或通过调用rethrow_nested()重新抛出它。

要创建一个嵌套异常,我们可以使用std::throw_with_nested()方法抛出异常。如果我们只想在异常嵌套的情况下重新抛出异常,我们可以使用std::rethrow_if_nested()。所有这些函数都在头文件中定义。

使用所有这些函数,我们可以实现以下示例,其中异步任务抛出一个std::runtime_error异常,该异常在异步任务的主体中被捕获并作为嵌套异常重新抛出。然后,在主函数中再次捕获该嵌套异常对象,并打印出异常序列,如下面的代码所示:

#include <exception>
#include <future>
#include <iostream>
#include <stdexcept>
#include <string>
void print_exceptions(const std::exception& e,
                      int level = 1) {
    auto indent = std::string(2 * level, ‹ ‹);
    std::cerr << indent << e.what() << '\n';
    try {
        std::rethrow_if_nested(e);
    } catch (const std::exception& nestedException) {
        print_exceptions(nestedException, level + 1);
    } catch (...) { }
}
void func_throwing() {
    throw std::runtime_error(
               «Exception in func_throwing");
}
int main() {
    auto fut = std::async([]() {
        try {
            func_throwing();
        } catch (...) {
            std::throw_with_nested(
                 std::runtime_error(
                      "Exception in async task."));
        }
    });
    try {
        fut.get();
    } catch (const std::exception& e) {
        std::cerr << "Caught exceptions:\n";
        print_exceptions(e);
    }
    return 0;
}

如示例所示,创建了一个异步任务,该任务在try-catch块中执行func_throwing()函数。这个函数简单地抛出一个std::runtime_error异常,该异常被捕获,然后通过使用std::throw_with_nested()函数作为std::nested_exception类的一部分重新抛出。稍后,在主线程中,当我们尝试通过调用其get()方法从fut未来对象检索结果时,嵌套异常被抛出并再次在主try-catch块中捕获,其中调用print_exceptions()函数,并将捕获的嵌套异常作为参数。

print_exceptions()函数打印当前异常的原因(e.what()),如果异常嵌套,则重新抛出异常,从而再次捕获它并以缩进的形式递归打印异常原因。

由于每个异步任务都有自己的未来对象,程序可以单独处理多个任务的异常。

调用 std::async 时的异常

除了异步任务中发生的异常之外,还有std::async可能会抛出异常的情况。这些异常如下:

  • std::bad_alloc:如果存储std::async所需内部数据结构的内存不足。

  • std::system_error:当使用std::launch::async作为启动策略时,无法启动新线程。在这种情况下,错误条件将是std::errc::resource_unavailable_try_again。根据实现,如果策略是默认的,它可能会回退到延迟调用或实现定义的策略。

大多数情况下,这些异常是由于资源耗尽而抛出的。一种解决方案是在一些异步任务当前正在运行并释放其资源后稍后重试。另一种更可靠的解决方案是限制给定时间内运行的异步任务数量。我们将很快实现这个解决方案,但首先,让我们了解std::async返回的未来对象以及如何在使用它们时实现更好的性能。

异步未来与性能

当调用其析构函数时,std::async返回的未来对象的行为与从承诺中获得的行为不同。当这些未来对象被销毁时,它们的~future析构函数被调用,其中执行wait()函数,导致在创建时产生的线程加入。

如果std::async使用的线程尚未加入,这将通过添加一些开销影响程序性能,因此我们需要了解未来对象何时将超出作用域,从而其析构函数将被调用。

让我们通过几个简短的示例来看看这些未来对象的行为,以及一些关于如何使用它们的建议。

我们首先定义一个任务func,该任务简单地将其输入值乘以 2,并等待一段时间,模拟一个昂贵的操作:

#include <chrono>
#include <functional>
#include <future>
#include <iostream>
#include <thread>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
unsigned func(unsigned x) {
    std::this_thread::sleep_for(10ms);
    return 2 * x;
}

为了测量代码块的性能,我们将异步运行几个任务(在这个例子中,NUM_TASKS = 32),并使用来自 库的稳定时钟来测量运行时间。为此,我们只需使用以下命令记录表示任务开始时当前时间点的时刻:

auto start = std::chrono::high_resolution_clock::now();

我们可以在 main() 函数中定义以下 lambda 函数,以便在任务完成时调用以获取以毫秒为单位的时间:

auto duration_from = [](auto start) {
    auto dur = std::chrono::high_resolution_clock::now()
               - start;
    return std::chrono::duration_cast
               <std::chrono::milliseconds>(dur).count();
};

在放置该代码后,我们可以开始测量如何使用未来的不同方法。

让我们从运行几个异步任务开始,但丢弃由 std::async 返回的未来:

constexpr unsigned NUM_TASKS = 32;
auto start = std::chrono::high_resolution_clock::now();
for (unsigned i = 0; i < NUM_TASKS; i++) {
    std::async(std::launch::async, func, i);
}
std::cout << "Discarding futures: "
          << duration_from(start) << '\n';

这个测试在我的电脑上持续了 334 毫秒,这是一台 4 GHz 的 Pentium i7 4790K,有四个核心和八个线程。

对于下一个测试,让我们存储返回的未来,但不要等待结果准备好。显然,这不是通过产生异步任务来使用计算机功率的正确方式,因为我们消耗资源而不处理结果,但我们这样做是为了测试和学习目的:

start = std::chrono::high_resolution_clock::now();
for (unsigned i = 0; i < NUM_TASKS; i++) {
    auto fut = std::async(std::launch::async, func, i);
}
std::cout << "In-place futures: "
          << duration_from(start) << '\n';

在这种情况下,持续时间仍然是 334 毫秒。在两种情况下,都会创建一个未来,当每个循环迭代的末尾超出作用域时,它必须等待由 std::async 生成的线程完成并加入。

如您所见,我们正在启动 32 个任务,每个任务至少消耗 10 毫秒。总共是 320 毫秒,这是一个与这些测试中获得的 334 毫秒等效的值。剩余的性能成本来自启动线程、检查 for 循环变量、使用稳定时钟存储时间点等。

为了避免每次调用 std::async 时都创建一个新的未来对象,并等待其析构函数被调用,让我们像以下代码所示重用未来对象。同样,这并不是正确的方式,因为我们正在丢弃对先前任务结果的处理:

std::future<unsigned> fut;
start = std::chrono::high_resolution_clock::now();
for (unsigned i = 0; i < NUM_TASKS; i++) {
    fut = std::async(std::launch::async, func, i);
}
std::cout << "Reusing future: "
          << duration_from(start) << '\n';

现在的持续时间是 166 毫秒。这种减少是由于我们没有等待每个未来,因为它们没有被销毁。

但这并不理想,因为我们可能对异步任务的结果感兴趣。因此,我们需要将结果存储在一个向量中。让我们通过使用 res 向量来存储每个任务的结果来修改之前的示例:

std::vector<unsigned> res;
start = std::chrono::high_resolution_clock::now();
for (unsigned i = 0; i < NUM_TASKS; i++) {
    auto fut = std::async(std::launch::async, func, i);
    res.push_back(fut.get());
}
std::cout << "Reused future and storing results: "
          << duration_from(start) << '\n';

在这种情况下,持续时间回到了 334 毫秒。这是因为我们再次在启动另一个异步任务之前通过调用 fut.get() 等待结果,我们在序列化任务的执行。

一种解决方案是将 std::async 返回的未来存储在一个向量中,然后遍历该向量并获取结果。以下代码说明了如何做到这一点:

std::vector<unsigned> res;
std::vector<std::future<unsigned>> futsVec;
start = std::chrono::high_resolution_clock::now();
for (unsigned i = 0; i < NUM_TASKS; i++) {
    futsVec.emplace_back(std::async(std::launch::async,
                         func, i));
}
for (unsigned i = 0; i < NUM_TASKS; i++) {
    res.push_back( futsVec[i].get() );
}
std::cout << "Futures vector and storing results: "
          << duration_from(start) << '\n';

现在持续时间仅为 22 毫秒!但为什么会这样?

现在所有任务都在真正地异步运行。第一个循环启动所有任务并将未来存储在 futsVec 向量中。由于未来析构函数被调用,不再有任何等待期。

第二个循环遍历futsVec,检索每个结果,并将它们存储在结果向量res中。执行第二个循环的时间将大约等于遍历res向量所需的时间加上调度和执行最慢任务所用的时间。

如果测试运行的系统有足够的线程一次性运行所有异步任务,运行时间可以减半。有些系统可以在底层自动管理多个异步任务,让调度器决定运行哪些任务。在其他系统中,当尝试一次性启动许多线程时,它们可能会通过抛出异常来抱怨。在下一节中,我们将通过使用信号量实现线程限制器。

限制线程数量

如我们之前所见,如果线程不足以运行多个std::async调用,可能会抛出std::runtime_system异常,并指示资源耗尽。

我们可以通过创建一个使用计数信号量(std::counting_semaphore)的线程限制器来实现一个简单的解决方案,这是一种在第四章中解释的多线程同步机制。

策略是使用一个std::counting_semaphore对象,将其初始值设置为系统允许的最大并发任务数,这可以通过调用std:🧵:hardware_concurrency()函数来检索,正如我们在第二章中学到的,然后在任务函数中使用该信号量来阻塞,如果总异步任务数超过最大并发任务数。

以下代码片段实现了这个想法:

#include <chrono>
#include <future>
#include <iostream>
#include <semaphore>
#include <syncstream>
#include <vector>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
void task(int id, std::counting_semaphore<>& sem) {
    sem.acquire();
    sync_cout << "Running task " << id << "...\n";
    std::this_thread::sleep_for(1s);
    sem.release();
}
int main() {
    const int total_tasks = 20;
    const int max_concurrent_tasks =
              std::thread::hardware_concurrency();
    std::counting_semaphore<> sem(max_concurrent_tasks);
    sync_cout << "Allowing only "
              << max_concurrent_tasks
              << " concurrent tasks to run "
              << total_tasks << " tasks.\n";
    std::vector<std::future<void>> futures;
    for (int i = 0; i < total_tasks; ++i) {
        futures.push_back(
                std::async(std::launch::async,
                           task, i, std::ref(sem)));
    }
    for (auto& fut : futures) {
        fut.get();
    }
    std::cout << "All tasks completed." << std::endl;
    return 0;
}

程序首先设置将要启动的总任务数。然后,它创建一个计数信号量sem,将其初始值设置为前面解释的硬件并发值。最后,它只是启动所有任务,并等待它们的未来准备好,就像通常一样。

本例中的关键点是,每个任务在执行其工作之前,都会获取信号量,从而减少内部计数器或阻塞,直到计数器可以减少。当工作完成时,信号量被释放,这会增加内部计数器并解除其他任务在那时尝试获取信号量的阻塞。这意味着,只有在有可用于该任务的空闲硬件线程时,任务才会启动。否则,它将被阻塞,直到另一个任务释放信号量。

在探索一些实际场景之前,让我们首先了解使用std::async的一些缺点。

不使用 std::async 的情况

正如我们在本章中看到的,std::async不提供对线程数量的直接控制或对线程对象的直接访问。我们现在知道如何通过使用计数信号量来限制异步任务的数量,但可能有一些应用程序,这种方法不是最佳解决方案,需要更精细的控制。

此外,线程的自动管理可能会通过引入开销来降低性能,尤其是在启动许多小任务时,会导致过多的上下文切换和资源竞争。

实现对可用的并发线程数施加了一些限制,这可能会降低性能甚至抛出异常。由于 std::async 和可用的 std::launch 策略是依赖于实现的,因此性能在不同编译器和平台之间并不一致。

最后,在本章中,我们没有提到如何取消由 std::async 启动的异步任务,因为在任务完成之前没有标准的方法来做这件事。

实际示例

现在是时候实现一些示例,使用 std::async 来解决现实生活中的场景了。我们将学习如何做以下事情:

  • 执行并行计算和聚合

  • 异步搜索不同的容器或大型数据集

  • 异步乘以两个矩阵

  • 连接异步操作

  • 改进上一章的管道示例

并行计算和聚合

数据聚合 是从多个来源收集原始数据并组织、处理以及提供数据摘要以便于消费的过程。这个过程在许多领域都很有用,例如商业报告、金融服务、医疗保健、社交媒体监控、研究和学术界。

作为一个简单的例子,让我们计算 1n 之间所有数的平方值并获取它们的平均值。我们知道使用以下公式来计算平方值的总和会更快,并且需要更少的计算机资源。此外,这个任务可能更有意义,但这个示例的目的是理解任务之间的关系,而不是任务本身。

∑i=1ni2=nn+1nn+12n+16(2n+1)6

以下示例中的 average_squares() 函数为 1n 之间的每个值启动一个异步任务来计算平方值。结果的未来对象存储在 futsVec 向量中,稍后由 sum_results() 函数用来计算平方值的总和。然后将结果除以 n 以获得平均值:

#include <future>
#include <iomanip>
#include <iostream>
#include <vector>
int square(int x) {
    return x * x;
}
int sum_results(std::vector<std::future<int>>& futsVec) {
    int sum = 0;
    for (auto& fut : futsVec) {
        sum += fut.get();
    }
    return sum;
}
int average_squares(int n) {
    std::vector<std::future<int>> futsVec;
    for (int i = 1; i <= n; ++i) {
        futsVec.push_back(std::async(
                std::launch::async, square, i));
    }
    return double(sum_results(futures)) / n;
}
int main() {
    int N = 100;
    std::cout << std::fixed << std::setprecision(2);
    std::cout << "Sum of squares for N = " << N
              << « is « << average_squares(N) << '\n';
    return 0;
}

例如,对于 <mml:math  >mml:min</mml:mi>mml:mo=</mml:mo>mml:mn100</mml:mn></mml:math> ,我们可以检查该值将与函数返回值除以 * n * 、* 3,383.50 * 相同。

此示例可以轻松修改以实现使用 MapReduce 编程模型来高效处理大数据集的解决方案。MapReduce 通过将数据处理分为两个阶段来工作;Map 阶段,在该阶段独立的数据块被过滤、排序并在多台计算机上并行处理,以及 Reduce 阶段,在该阶段聚合 Map 阶段的结果,总结数据。这就像我们刚刚实现的那样,使用 Map 阶段中的 square() 函数,以及 Reduce 阶段中的 average_squares()sum_results() 函数。

异步搜索

加快在大容器中搜索目标值的一种方法是将搜索并行化。接下来,我们将展示两个示例。第一个示例涉及通过每个容器使用一个任务来跨不同容器进行搜索,而第二个示例涉及跨一个大容器进行搜索,将其分割成更小的段,并使用每个段一个任务:

在不同的容器中进行搜索

在这个例子中,我们需要在包含动物名称的不同类型容器(vectorlistforward_listset)中搜索一个目标值:

#include <algorithm>
#include <forward_list>
#include <future>
#include <iostream>
#include <list>
#include <set>
#include <string>
#include <vector>
int main() {
    std::vector<std::string> africanAnimals =
              {"elephant", "giraffe", "lion", "zebra"};
    std::list<std::string> americanAnimals =
              {"alligator", "bear", "eagle", "puma"};
    std::forward_list<std::string> asianAnimals =
              {"orangutan", "panda", "tapir", "tiger"};
    std::set<std::string> europeanAnimals =
              {«deer», «hedgehog», «linx", "wolf"};
    std::string target = «elephant»;
    /* .... */
}

要搜索目标值,我们为每个容器启动一个异步任务,使用 search() 模板函数,该函数简单地在一个容器中调用 std::find 函数,如果找到目标值则返回 true,否则返回 false

template <typename C>
bool search(const C& container, const std::string& target) {
    return std::find(container.begin(), container.end(),
                     target) != container.end();
}

这些异步任务使用带有 std::launch::async 启动策略的 std::async 函数启动:

int main() {
    /* .... */
    auto fut1 = std::async(std::launch::async,
                   search<std::vector<std::string>>,
                   africanAnimals, target);
    auto fut2 = std::async(std::launch::async,
                   search<std::list<std::string>>,
                   americanAnimals, target);
    auto fut3 = std::async(std::launch::async,
                   search<std::forward_list<std::string>>,
                   asianAnimals, target);
    auto fut4 = std::async(std::launch::async,
                search<std::set<std::string>>,
                europeanAnimals, target);
    /* .... */

最后,我们简单地从使用 std::async 创建的 futures 中检索所有返回值,并通过位或操作它们:

int main() {
    /* .... */
    bool found = fut1.get() || fut2.get() ||
                 fut3.get() || fut4.get();
    if (found) {
        std::cout << target
                  << " found in one of the containers.\n";
    } else {
        std::cout << target
                  << " not found in any of "
                  << "the containers.\n";
    }
    return 0;
}

此示例还展示了 标准模板库STL)的强大功能,因为它提供了通用的可重用算法,可以应用于不同的容器和数据类型。

在大容器中进行搜索

在下一个示例中,我们将实现一个解决方案,以在包含 500 万个整数值的大型向量中查找目标值。

要生成向量,我们使用具有均匀整数分布的随机数生成器:

#include <cmath>
#include <iostream>
#include <vector>
#include <future>
#include <algorithm>
#include <random>
// Generate a large vector of random integers using a uniform distribution
std::vector<int> generate_vector(size_t size) {
    std::vector<int> vec(size);
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> dist(1, size);
    std::generate(vec.begin(), vec.end(), [&]() {
        return dist(gen);
    });
    return vec;
}

要在向量的一个段中搜索目标值,我们可以使用带有指向段限制的 beginend 迭代器的 std::find 函数:

bool search_segment(const std::vector<int>& vec, int target, size_t begin, size_t end) {
    auto begin_it = vec.begin() + begin;
    auto end_it = vec.begin() + end;
    return std::find(begin_it, end_it, target) != end_it;
}

main() 函数中,我们首先使用 generate_vector() 函数生成大型向量,然后定义要查找的 目标 值和向量将被分割进行并行搜索的段数(num_segments):

const int target = 100;
std::vector<int> vec = generate_vector(5000000);
auto vec_size = vec.size();
size_t num_segments = 16;
size_t segment_size = vec.size() / num_segments;

然后,对于每个段,我们定义其 beginend 迭代器,并启动一个异步任务在该段中搜索目标值。因此,我们通过使用带有 std::launch::async 启动策略的 std::async 在单独的线程中异步执行 search_segment。为了避免在将大向量作为 search_segment 的输入参数传递时复制它,我们使用常量引用,std::cref。由 std::async 返回的 futures 存储在 futs 向量中:

std::vector<std::future<bool>> futs;
for (size_t i = 0; i < num_segments; ++i) {
    auto begin = std::min(i * segment_size, vec_size);
    auto end = std::min((i + 1) * segment_size, vec_size);
    futs.push_back( std::async(std::launch::async,
                               search_segment,
                               std::cref(vec),
                               target, begin, end) );
}

注意,向量的大小并不总是段大小的倍数,因此最后一个段可能比其他段短。为了处理这种情况并避免在检查最后一个段时访问越界内存的问题,我们需要为每个段适当地设置 beginend 索引。为此,我们使用 std::min 来获取向量大小和当前段中最后一个元素的假设索引之间的最小值。

最后,我们通过在每个 future 上调用 get() 来检查所有结果,并在控制台打印一条消息,如果任何段中找到了目标值:

bool found = false;
for (auto& fut : futs) {
    if (fut.get()) {
        found = true;
        break;
    }
}
if (found) {
    std::cout << "Target " << target
              << " found in the large vector.\n";
} else {
    std::cout << "Target " << target
              << " not found in the large vector.\n";
}

此解决方案可以作为处理分布式系统中大数据集的更高级解决方案的基础,其中每个异步任务都试图在特定的机器或集群中找到目标值。

异步矩阵乘法

矩阵乘法 是计算机科学中最相关的操作之一,在许多领域中使用,例如计算机图形学、计算机视觉、机器学习和科学计算。

在下面的示例中,我们将通过在多个线程之间分配计算来实现并行计算解决方案。

首先,我们定义一个矩阵类型,matrix_t,作为一个包含整数值的向量向量:

#include <cmath>
#include <exception>
#include <future>
#include <iostream>
#include <vector>
using matrix_t = std::vector<std::vector<int>>;

然后,我们实现 matrix_multiply 函数,该函数接受两个矩阵 AB,将它们作为常量引用传递,并返回它们的乘积。我们知道如果 A 是一个矩阵 <mml:math  >mml:mim</mml:mi>mml:mix</mml:mi>mml:min</mml:mi></mml:math>(其中 m 代表行,n 代表列)并且 B 是一个矩阵 <mml:math  >mml:mip</mml:mi>mml:mix</mml:mi>mml:miq</mml:mi></mml:math>,当 <mml:math  >mml:min</mml:mi>mml:mo=</mml:mo>mml:mip</mml:mi></mml:math> 时,我们可以乘以 AB,结果矩阵的维度将是 <mml:math  >mml:mim</mml:mi>mml:mix</mml:mi>mml:miq</mml:mi></mml:math>m 行和 q 列)。

matrix_multiply 函数首先为结果矩阵 res 预留一些空间,然后通过从 B 中提取列 j 并将其与 A 中的行 i 相乘来遍历矩阵:

matrix_t matrix_multiply(const matrix_t& A,
                         const matrix_t& B) {
    if (A[0].size() != B.size()) {
        throw new std::runtime_error(
                  «Wrong matrices dimmensions.");
    }
    size_t rows = A.size();
    size_t cols = B[0].size();
    size_t inner_dim = B.size();
    matrix_t res(rows, std::vector<int>(cols, 0));
    std::vector<std::future<int>> futs;
    for (auto i = 0; i < rows; ++i) {
        for (auto j = 0; j < cols; ++j) {
            std::vector<int> column(inner_dim);
            for (size_t k = 0; k < inner_dim; ++k) {
                column[k] = B[k][j];
            }
            futs.push_back(std::async(std::launch::async,
                                      dot_product,
                                      A[i], column));
        }
    }
    for (auto i = 0; i < rows; ++i) {
        for (auto j = 0; j < cols; ++j) {
            res[i][j] = futs[i * cols + j].get();
        }
    }
    return res;
}

乘法是通过使用std::asyncstd::launch::async启动策略异步进行的,运行dot_product函数。std::async返回的每个 future 都存储在futs向量中。dot_product函数通过逐元素相乘并返回这些乘积的总和来计算向量ab的点积,代表A中的一行和B中的一列:

int dot_product(const std::vector<int>& a,
                const std::vector<int>& b) {
    int sum = 0;
    for (size_t i = 0; i < a.size(); ++i) {
        sum += a[i] * b[i];
    }
    return sum;
}

由于dot_product函数期望两个向量,我们需要在启动每个异步任务之前从B中提取每一列。这也提高了整体性能,因为向量可能存储在连续的内存块中,因此在计算时更缓存友好。

main()函数中,我们只定义了两个矩阵AB,并使用matrix_multipy函数来计算它们的乘积。所有矩阵都使用show_matrixlambda 函数打印到控制台:

int main() {
    auto show_matrix = [](const std::string& name,
                          matrix_t& mtx) {
        std::cout << name << '\n';
        for (const auto& row : mtx) {
            for (const auto& elem : row) {
                std::cout << elem << " ";
            }
            std::cout << '\n';
        }
        std::cout << std::endl;
    };
    matrix_t A = {{1, 2, 3},
                  {4, 5, 6}};
    matrix_t B = {{7, 8, 9},
                  {10, 11, 12},
                  {13, 14, 15}};
    auto res = matrix_multiply(A, B);
    show_matrix("A", A);
    show_matrix("B", B);
    show_matrix("Result", res);
    return 0;
}

这是运行此示例的输出:

A
1 2 3
4 5 6
B
7 8 9
10 11 12
13 14 15
Result
66 72 78
156 171 186

使用连续内存块在遍历向量时可以提高性能,因为许多元素可以一次读入缓存。使用std::vector时,不保证连续内存分配,因此可能最好使用newmalloc

链接异步操作

在这个例子中,我们将实现一个由三个阶段组成的简单管道,每个阶段都从前一个阶段的结果中获取并计算一个值。

图 7.1 – 简单管道示例

图 7.1 – 简单管道示例

第一阶段仅接受正整数作为输入,否则会抛出异常,并在返回结果之前将 10 加到该值上。第二阶段将其输入乘以 2,第三阶段从其输入中减去 5:

#include <future>
#include <iostream>
#include <stdexcept>
int stage1(int x) {
    if (x < 0) throw std::runtime_error(
                        "Negative input not allowed");
    return x + 10;
}
int stage2(int x) {
    return x * 2;
}
int stage3(int x) {
    return x - 5;
}

main()函数中,对于中间和最终阶段,我们通过使用前一个阶段生成的 futures 作为输入来定义管道。这些 futures 通过引用传递给运行异步代码的 lambda 表达式,其中使用它们的get()函数来获取它们的结果。

要从管道中检索结果,我们只需调用最后阶段返回的 future 的get()函数。如果发生异常,例如,当input_value为负时,它会被 try-catch 块捕获:

int main() {
    int input_value = 5;
    try {
        auto fut1 = std::async(std::launch::async,
                         stage1, input_value);
        auto fut2 = std::async(std::launch::async,
                         [&fut1]() {
                            return stage2(fut1.get()); });
        auto fut3 = std::async(std::launch::async,
                         [&fut2]() {
                            return stage3(fut2.get()); });
        int final_result = fut3.get();
        std::cout << "Final Result: "
                  << final_result << std::endl;
    } catch (const std::exception &ex) {
        std::cerr << "Exception caught: "
                  << ex.what() << std::endl;
    }
    return 0;
}

在这个例子中定义的管道是一个简单的管道,其中每个阶段都使用前一个阶段的 future 来获取输入值并产生其结果。在下一个例子中,我们将使用std:async和延迟启动策略重写上一章中实现的管道,以仅执行所需的阶段。

异步管道

正如承诺的那样,在前一章中,当我们实现管道时,我们提到可以使用具有延迟执行的 futures 来保持不同的任务关闭,直到需要时。正如也提到的,这在计算成本高但结果不一定总是需要的情况下很有用。由于只有使用 std::async 才能创建具有延迟状态的 futures,现在我们来看看如何做到这一点。

我们将实现前一章中描述的相同管道,它遵循以下任务图:

图 7.2 – 管道示例

图 7.2 – 管道示例

我们首先定义 Task 类。这个类类似于前一章示例中实现的类,但使用 std::async 函数并存储返回的 future 而不是之前使用的 promise。在这里,我们只将评论与示例中的相关代码更改相关联,因此请查看该示例以获取 Task 类的完整解释或检查 GitHub 仓库。

Task 构造函数存储任务标识符(id_)、要启动的函数(func_)以及任务是否有依赖关系(has_dependency_)。它还通过使用具有 std::launch::deferred 启动策略的 std::async 以延迟启动模式启动异步任务。这意味着任务被创建但直到需要时才启动。返回的 future 存储在 fut_ 变量中:

template <typename Func>
class Task {
   public:
    Task(int id, Func& func)
        : id_(id), func_(func), has_dependency_(false) {
        sync_cout << "Task " << id
                 << " constructed without dependencies.\n";
        fut_ = std::async(std::launch::deferred,
                         [this](){ (*this)(); });
    }
    template <typename... Futures>
    Task(int id, Func& func, Futures&&... futures)
        : id_(id), func_(func), has_dependency_(true) {
        sync_cout << "Task " << id
                  << " constructed with dependencies.\n";
        fut_ = std::async(std::launch::deferred,
                         [this](){ (*this)(); });
        add_dependencies(std::forward<Futures>
                                     (futures)...);
    }
   private:
    int id_;
    Func& func_;
    std::future<void> fut_;
    std::vector<std::shared_future<void>> deps_;
    bool has_dependency_;
};

std::async 启动的异步任务调用它们自己的实例(即 this 对象)的 operator()。当发生这种情况时,会调用 wait_completion(),通过调用存储依赖任务的共享 future 向量 deps_valid() 函数来检查所有 futures 是否有效,如果是,则通过调用 get() 函数等待它们完成。当所有依赖任务完成时,会调用 func_ 函数:

public:
void operator()() {
    sync_cout << "Starting task " << id_ << std::endl;
    wait_completion();
    sync_cout << "Running task " << id_ << std::endl;
    func_();
}
private:
void wait_completion() {
    sync_cout << "Waiting completion for task "
              << id_ << std::endl;
    if (!deps_.empty()) {
        for (auto& fut : deps_) {
            if (fut.valid()) {
                sync_cout << "Fut valid so getting "
                          << "value in task " << id_
                          << std::endl;
                fut.get();
            }
        }
    }
}

还有一个新的成员函数,start(),它在调用 std::async 期间任务构造时等待创建的 fut_ future。这将用于通过请求最后一个任务的结果来触发管道:

public:
void start() {
    fut_.get();
}

如前一章中的示例所示,我们也定义了一个名为 get_dependency() 的成员函数,它返回由 fut_ 构造的共享 future:

std::shared_future<void> get_dependency() {
    sync_cout << "Getting future from task "
              << id_ << std::endl;
    return fut_;
}

main() 函数中,我们通过链式任务对象并设置它们的依赖关系以及要运行的 lambda 函数(sleep1ssleep2s),根据图 7.2 中所示的图来定义管道:

int main() {
    auto sleep1s = [](){
        std::this_thread::sleep_for(1s);
    };
    auto sleep2s = [](){
        std::this_thread::sleep_for(2s);
    };
    Task task1(1, sleep1s);
    Task task2(2, sleep2s, task1.get_dependency());
    Task task3(3, sleep1s, task2.get_dependency());
    Task task4(4, sleep2s, task2.get_dependency());
    Task task5(5, sleep2s, task3.get_dependency(),
               task4.get_dependency());
    sync_cout << "Starting the pipeline..." << std::endl;
    task5.start();
    sync_cout << "All done!" << std::endl;
    return 0;
}

启动管道就像从最后一个任务的 future 获取结果一样简单。我们可以通过调用 task5start() 方法来实现这一点。这将递归地通过依赖关系向量调用它们的依赖任务并启动延迟异步任务。

这是执行前述代码的输出:

Task 1 constructed without dependencies.
Getting future from task 1
Task 2 constructed with dependencies.
Getting future from task 2
Task 3 constructed with dependencies.
Getting future from task 2
Task 4 constructed with dependencies.
Getting future from task 4
Getting future from task 3
Task 5 constructed with dependencies.
Starting the pipeline...
Starting task 5
Waiting completion for task 5
Fut valid so getting value in task 5
Starting task 3
Waiting completion for task 3
Fut valid so getting value in task 3
Starting task 2
Waiting completion for task 2
Fut valid so getting value in task 2
Starting task 1
Waiting completion for task 1
Running task 1
Running task 2
Running task 3
Fut valid so getting value in task 5
Starting task 4
Waiting completion for task 4
Running task 4
Running task 5
All done!

我们可以通过调用每个任务的构造函数并从先前依赖任务获取 futures 来看到管道是如何创建的。

然后,当管道被触发时,task5 开始执行,递归地启动 task3task2task1。由于 task1 没有依赖项,它不需要等待其他任何任务运行其工作,因此它完成,允许 task2 完成,然后是 task3

接下来,task5 继续检查其依赖任务,因此现在是 task4 的运行时间。由于 task4 的所有依赖任务都已完成,task4 只需执行,允许 task5 在之后运行,从而完成管道。

此示例可以通过执行实际计算并在任务之间传输结果来改进。此外,我们也可以考虑具有几个并行步骤的阶段,这些步骤可以在单独的线程中计算。请随意将这些改进作为附加练习实现。

摘要

在本章中,我们学习了 std::async,如何使用此函数执行异步任务,如何通过使用启动策略来定义其行为,以及如何处理异常。

我们现在还了解异步函数返回的未来如何影响性能,以及如何明智地使用它们。我们还看到了如何通过使用计数信号量来限制系统可用线程数来限制异步任务的数量。

我们还提到了一些场景,在这些场景中,std::async 可能不是最佳工具。

最后,我们实现了几个涵盖现实场景的示例,这对于并行化许多常见任务很有用。

通过本章获得的所有知识,现在我们知道了何时(以及何时不)使用 std::async 函数来并行运行异步任务,从而提高应用程序的整体性能,实现更好的计算机资源使用,并减少资源耗尽。

在下一章中,我们将学习如何通过使用自 C++20 起可用的协程来实现异步执行。

进一步阅读

第八章:使用协程进行异步编程

在前面的章节中,我们看到了在 C++中编写异步代码的不同方法。我们使用了线程,这是执行的基本单元,以及一些高级异步代码机制,如 futures、promises 和std::async。我们将在下一章中查看 Boost.Asio 库。所有这些方法通常使用多个系统线程,由内核创建和管理。

例如,我们程序的主线程可能需要访问数据库。这种访问可能很慢,所以我们将在不同的线程中读取数据,以便主线程可以继续执行其他任务。另一个例子是生产者-消费者模型,其中一个或多个线程生成要处理的数据项,一个或多个线程以完全异步的方式处理这些项。

上述两个示例都使用了线程,也称为系统(内核)线程,并需要不同的执行单元,每个线程一个。

在本章中,我们将研究一种不同的异步代码编写方式——协程。协程是一个来自 20 世纪 50 年代末的老概念,直到 C++20 才被添加到 C++中。它们不需要单独的线程(当然,我们可以在不同的线程中运行协程)。协程是一种机制,它使我们能够在单线程中执行多个任务。

在本章中,我们将涵盖以下主要主题:

  • 协程是什么?它们是如何被 C++实现和支持的?

  • 实现基本协程以了解 C++协程的要求

  • 生成器协程和新的 C++23 std::generator

  • 用于解析整数的字符串解析器

  • 协程中的异常

本章介绍的是不使用任何第三方库实现的 C++协程。这种方式编写协程相当底层,我们需要编写代码来支持编译器。

技术要求

对于本章,你需要一个 C++20 编译器。对于生成器示例,你需要一个 C++23 编译器。我们已经测试了这些示例与 GCC 14.1 兼容。代码是平台无关的,因此尽管本书关注 Linux,但所有示例都应在 macOS 和 Windows 上运行。请注意,Visual Studio 17.11 还不支持 C++23 std::generator

本章的代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Asynchronous-Programming-with-CPP

协程

在我们开始用 C++实现协程之前,我们将从概念上介绍协程,并看看它们在我们的程序中如何有用。

让我们从定义开始。协程是一个可以暂停自己的函数。协程在等待输入值(在它们暂停时,它们不执行)或产生一个值,如计算的输出后暂停自己。一旦输入值可用或调用者请求另一个值,协程将恢复执行。我们很快将回到 C++中的协程,但让我们通过一个现实生活中的例子来看看协程是如何工作的。

想象一下有人在当助手。他们开始一天的工作是阅读电子邮件。

其中一封电子邮件是要求一份报告。在阅读电子邮件后,他们开始撰写所需的文档。一旦他们写完了引言段落,他们注意到他们需要从同事那里获取一份报告,以获取上一季度的会计结果。他们停止撰写报告,给同事写了一封电子邮件,请求所需的信息,然后阅读下一封电子邮件,这是一封要求预订下午重要会议的会议室的请求。他们打开公司开发的一个专门用于自动预订会议室以优化其使用的应用程序来预订会议室。

过了一段时间,他们从同事那里收到了所需的会计数据,然后继续撰写报告。

助手总是忙于处理他们的任务。撰写报告是协程的一个好例子:他们开始撰写报告,然后在等待所需信息时暂停写作,一旦信息到达,他们继续写作。当然,助手不想浪费时间,在等待时,他们会继续做其他任务。如果他们等待请求并发出适当的响应,他们的同事可以被视为另一个协程。

现在,让我们回到软件。假设我们需要编写一个函数,在处理一些输入信息后,将数据存储到数据库中。

如果数据一次性到达,我们只需实现一个函数。该函数将读取输入,对数据进行必要的处理,最后将结果写入数据库。但如果要处理的数据以块的形式到达,并且处理每个块都需要前一个块处理的结果(为了这个例子,我们可以假设第一个块的处理只需要一些默认值)呢?

解决我们问题的可能方法是在每个数据块到达时让函数等待,处理它,将结果存储在数据库中,然后等待下一个,依此类推。但如果我们这样做,我们可能会在等待每个数据块到达时浪费很多时间。

在阅读了前面的章节后,你可能正在考虑不同的潜在解决方案:我们可以创建一个线程来读取数据,将块复制到队列中,然后第二个线程(可能是主线程)将处理数据。这是一个可接受的解决方案,但使用多个线程可能有些过度。

另一种解决方案可能是实现一个只处理一个数据块的函数。调用者将等待输入传递给函数,并保留处理每个数据块所需的上一块处理的结果。在这个解决方案中,我们必须在另一个函数中保留数据处理函数所需的状态。对于简单的示例可能是可接受的,但一旦处理变得更为复杂(例如,需要保留不同中间结果的多步处理),代码可能难以理解和维护。

我们可以用协程解决这个问题。让我们看看处理数据块并保留中间结果的协程的一些可能的伪代码:

processing_result process_data(data_block data) {
    while (do_processing == true) {
        result_type result{ 0 };
        result = process_data_block(previous_result);
        update_database();
        yield result;
    }
}

前面的协程从调用者那里接收一个数据块,执行所有处理,更新数据库,并保留处理下一个数据块所需的结果。在将结果传回调用者(关于传回的更多内容稍后讨论)之后,它将自己暂停。当调用者再次调用协程请求处理新的数据块时,其执行将恢复。

这样的协程简化了状态管理,因为它可以在调用之间保持状态。

在对协程进行概念介绍之后,我们将开始使用 C++20 实现它们。

C++协程

正如我们所见,协程只是函数,但它们并不像我们习惯的函数。它们具有我们将在本章中学习的特殊属性。在本节中,我们将专注于 C++中的协程。

函数在调用时开始执行,并通常通过返回语句或当函数的末尾到达时正常终止。

函数从开始到结束运行。它可能调用另一个函数(或者如果是递归的,甚至可以调用自己),它可能抛出异常或具有不同的返回点。但它总是从开始到结束运行。

协程是不同的。协程是一个可以暂停自己的函数。协程的流程可能如下伪代码所示:

 void coroutine() {
    do_something();
    co_yield;
    do_something_else();
    co_yield;
    do_more_work();
    co_return;
}

我们很快就会看到那些带有co_前缀的术语的含义。

对于协程,我们需要一个机制来保持执行状态,以便能够暂停/恢复协程。这是由编译器为我们完成的,但我们必须编写一些辅助代码,以便让编译器帮助我们。

C++中的协程是无堆栈的。这意味着我们需要存储以能够暂停/恢复协程的状态存储在堆中,通过调用new/delete来分配/释放动态内存。这些调用是由编译器创建的。

新关键字

因为协程本质上是一个函数(具有一些特殊属性,但仍然是一个函数),编译器需要某种方式来确定给定的函数是否是协程。C++20 引入了三个新的关键字:co_yieldco_awaitco_return。如果一个函数使用了这三个关键字中的至少一个,那么编译器就知道它是一个协程。

下表总结了新关键字的函数:

关键字 输入/输出 协程状态
co_yield 输出 暂停
co_await 输入 暂停
co_return 输出 终止

表 8.1:新的协程关键字

在前面的表中,我们看到在 co_yieldco_await 之后,协程会暂停,而在 co_return 之后,它会终止(co_return 在 C++函数中相当于 return 语句)。协程不能有 return 语句;它必须始终使用 co_return。如果协程不返回任何值,并且使用了其他两个协程关键字之一,则可以省略 co_return 语句。

协程限制

我们已经说过,协程是使用新协程关键字的函数。但协程有以下限制:

  • 使用 varargs 的具有可变数量参数的函数不能是协程(一个变长函数模板可以是协程)

  • 类构造函数或析构函数不能是协程

  • constexprconsteval 函数不能是协程

  • 返回 auto 的函数不能是协程,但带有尾随返回类型的 auto 可以是

  • main() 函数不能是协程

  • Lambda 可以是协程

在学习了协程的限制(基本上是哪些 C++函数不能是协程)之后,我们将在下一节开始实现协程。

实现基本协程

在上一节中,我们学习了协程的基本知识,包括它们是什么以及一些用例。

在本节中,我们将实现三个简单的协程来展示实现和使用它们的基本方法:

  • 只返回的最简单协程

  • 协程向调用者发送值

  • 从调用者获取值的协程

最简单的协程

我们知道协程是一个可以暂停自己的函数,并且可以被调用者恢复。我们还知道,如果函数至少使用了一个 co_yieldco_awaitco_return 表达式,编译器会将该函数识别为协程。

编译器将转换协程源代码,并创建一些数据结构和函数,使协程能够正常工作,并能够暂停和恢复。这是为了保持协程状态并能够与协程进行通信。

编译器将处理所有这些细节,但请注意,C++对协程的支持相当底层。有一些库可以帮助我们在 C++中更轻松地处理协程。其中一些是 Lewis Baker 的 cppcoroBoost.CobaltBoost.Asio 库也支持协程。这些库是下一章的主题。

让我们从零开始。这里的“从零开始”是指绝对的零起点。我们将编写一些代码,并通过编译器错误和 C++参考来编写一个基本但功能齐全的协程。

以下代码是协程的最简单实现:

void coro_func() {
    co_return;
}
int main() {
    coro_func();
}

简单,不是吗?我们的第一个协程将只返回空值。它不会做任何其他事情。遗憾的是,前面的代码对于功能协程来说太简单了,无法编译。当使用 GCC 14.1 编译时,我们得到以下错误:

error: coroutines require a traits template; cannot find 'std::coroutine_traits'

我们还得到了以下提示:

note: perhaps '#include <coroutine>' is missing

编译器给我们一个提示:我们可能遗漏了包含一个必需的文件。让我们包含头文件。我们将在一会儿处理关于 traits 模板的错误:

#include <coroutine>
void coro_func() {
    co_return;
}
int main() {
    coro_func();
}

在编译前面的代码时,我们遇到了以下错误:

 error: unable to find the promise type for this coroutine

我们协程的第一个版本给我们带来了一个编译错误,说找不到类型std::coroutine_traits模板。现在我们得到了一个与所谓的promise 类型有关的错误。

查看 C++参考,我们看到std::coroutine_traits模板决定了协程的返回类型和参数类型。参考还指出,协程的返回类型必须定义一个名为promise_type的类型。遵循参考建议,我们可以编写我们协程的新版本:

#include <coroutine>
struct return_type {
    struct promise_type {
    };
};
template<>
struct std::coroutine_traits<return_type> {
    using promise_type = return_type::promise_type;
};
return_type coro_func() {
    co_return;
}
int main() {
    coro_func();
}

请注意,协程的返回类型可以有任何名称(我们在这里将其称为return_type,因为这在这个简单示例中很方便)。

再次编译前面的代码时,我们遇到了一些错误(为了清晰起见,错误已被编辑)。所有错误都与promise_type结构中缺少的函数有关:

error: no member named 'return_void' in 'std::__n4861::coroutine_traits<return_type>::promise_type'
error: no member named 'initial_suspend' in 'std::__n4861::coroutine_traits<return_type>::promise_type'
error: no member named 'unhandled_exception' in 'std::__n4861::coroutine_traits<return_type>::promise_type'
error: no member named 'final_suspend' in 'std::__n4861::coroutine_traits<return_type>::promise_type'
error: no member named 'get_return_object' in 'std::__n4861::coroutine_traits<return_type>::promise_type'

我们到目前为止看到的所有编译错误都与我们的代码中缺少的功能有关。在 C++中编写协程需要遵循一些规则,并帮助编译器生成有效的代码。

以下是最简单的协程的最终版本:

#include <coroutine>
struct return_type {
    struct promise_type {
        return_type get_return_object() noexcept {
            return return_type{ *this };
        }
        void return_void() noexcept {}
        std::suspend_always initial_suspend() noexcept {
            return {};
        }
        std::suspend_always final_suspend() noexcept {
            return {};
        }
        void unhandled_exception() noexcept {}
    };
    explicit return_type(promise_type&) {
    }
    ~return_type() noexcept {
    }
};
return_type coro_func() {
    co_return;
}
int main() {
    coro_func();
}

你可能已经注意到我们已经移除了std::coroutine_traits模板。实现返回和 promise 类型就足够了。

前面的代码编译没有任何错误,你可以运行它。它确实...什么也不做!但这是我们第一个协程,我们已经了解到我们需要提供一些编译器所需的代码来创建协程。

promise 类型

promise 类型是编译器所要求的。我们需要始终定义此类型(它可以是类或结构体),它必须命名为promise_type,并且必须实现 C++参考中指定的某些函数。我们已经看到,如果我们不这样做,编译器会抱怨并给出错误。

promise 类型必须在协程返回的类型内部定义,否则代码将无法编译。返回的类型(有时也称为wrapper 类型,因为它封装了promise_type)可以任意命名。

一个产生结果的协程

一个什么也不做的协程对于说明一些基本概念很有用。我们现在将实现另一个可以将数据发送回调用者的协程。

在这个第二个例子中,我们将实现一个产生消息的协程。它将是协程的“hello world”。协程将说你好,调用函数将打印从协程接收到的消息。

为了实现该功能,我们需要从协程到调用者建立一个通信通道。这个通道是允许协程向调用者传递值并从它那里接收信息的机制。这个通道是通过协程的 承诺类型句柄 建立的,它们管理协程的状态。

通信通道按以下方式工作:

  • 协程帧 : 当协程被调用时,它创建一个 协程帧 ,其中包含暂停和恢复其执行所需的所有状态信息。这包括局部变量、承诺类型以及任何内部状态。

  • 承诺类型 : 每个协程都有一个相关的 承诺类型 ,它负责管理协程与调用函数之间的交互。承诺是存储协程返回值的地方,它提供了控制协程行为的函数。我们将在本章的示例中看到这些函数。承诺是调用者与协程交互的接口。

  • 协程句柄 : 协程句柄是一种类型,它提供了对协程帧(协程的内部状态)的访问权限,并允许调用者恢复或销毁协程。句柄是调用者可以在协程被挂起后(例如,在 co_awaitco_yield 之后)恢复协程的东西。句柄还可以用来检查协程是否完成或清理其资源。

  • 挂起和恢复机制 : 当协程 yield 一个值( co_yield )或等待异步操作( co_await )时,它挂起其执行,将其状态保存在协程帧中。然后调用者可以在稍后恢复协程,通过协程句柄检索 yielded 或 awaited 的值并继续执行。

我们将在以下示例中看到,这个通信通道需要我们在自己的这一侧编写相当数量的代码,以帮助编译器生成协程功能所需的全部代码。

以下代码是调用函数和协程的新版本:

return_type coro_func() {
    co_yield "Hello from the coroutine\n"s;
    co_return;
}
int main() {
    auto rt = coro_func();
    std::cout << rt.get() << std::endl;
    return 0;
}

变更如下:

  • [1] : 协程 yield 并向调用者发送一些数据(在这种情况下,一个 std::string 对象)

  • [2] : 调用者读取那些数据并将其打印出来

所需的通信机制在承诺类型和返回类型(这是一个承诺类型包装器)中实现。

当编译器读取 co_yield 表达式时,它将生成对在承诺类型中定义的 yield_value 函数的调用。

以下代码是我们版本的该函数的实现,该函数生成(或 yield)一个 std::string 对象:

std::suspend_always yield_value(std::string msg) noexcept {
    output_data = std::move(msg);
    return {};
}

函数获取一个 std::string 对象并将其移动到承诺类型的 output_data 成员变量中。但这只是将数据保留在承诺类型内部。我们需要一种机制来将那个字符串从协程中取出。

句柄类型

一旦我们需要一个协程的通信通道,我们需要一种方式来引用一个挂起或正在执行的协程。C++标准库在所谓的协程句柄中实现了这样的机制。它的类型是std::coroutine_handle,它是返回类型的成员变量。这个结构也负责句柄的完整生命周期,包括创建和销毁它。

以下代码片段是我们添加到返回类型中以管理协程句柄的功能:

std::coroutine_handle<promise_type> handle{};
explicit return_type(promise_type& promise) : handle{ std::coroutine_handle<promise_type>::from_promise(promise)} {
}
~return_type() noexcept {
    if (handle) {
        handle.destroy();
    }
}

前面的代码声明了一个类型为std::coroutine_handle<promise_type>的协程句柄,并在返回类型构造函数中创建句柄。句柄在返回类型析构函数中被销毁。

现在,回到我们的产生值的协程。唯一缺少的部分是调用函数的get()函数,以便能够访问协程生成的字符串:

std::string get() {
    if (!handle.done()) {
        handle.resume();
    }
    return std::move(handle.promise().output_data);
}

get()函数在协程未终止的情况下恢复协程,然后返回字符串对象。

以下是我们第二个协程的完整代码:

#include <coroutine>
#include <iostream>
#include <string>
using namespace std::string_literals;
struct return_type {
    struct promise_type {
        std::string output_data { };
        return_type get_return_object() noexcept {
            std::cout << "get_return_object\n";
            return return_type{ *this };
        }
        void return_void() noexcept {
            std::cout << "return_void\n";
        }
        std::suspend_always yield_value(
                         std::string msg) noexcept {
            std::cout << "yield_value\n";
            output_data = std::move(msg);
            return {};
        }
        std::suspend_always initial_suspend() noexcept {
            std::cout << "initial_suspend\n";
            return {};
        }
        std::suspend_always final_suspend() noexcept {
            std::cout << "final_suspend\n";
            return {};
        }
        void unhandled_exception() noexcept {
            std::cout << "unhandled_exception\n";
        }
    };
    std::coroutine_handle<promise_type> handle{};
    explicit return_type(promise_type& promise)
       : handle{ std::coroutine_handle<
                 promise_type>::from_promise(promise)}{
        std::cout << "return_type()\n";
    }
    ~return_type() noexcept {
        if (handle) {
            handle.destroy();
        }
        std::cout << "~return_type()\n";
    }
    std::string get() {
        std::cout << "get()\n";
        if (!handle.done()) {
            handle.resume();
        }
        return std::move(handle.promise().output_data);
    }
};
return_type coro_func() {
    co_yield "Hello from the coroutine\n"s;
    co_return;
}
int main() {
    auto rt = coro_func();
    std::cout << rt.get() << std::endl;
    return 0;
}

运行前面的代码会打印以下消息:

get_return_object
return_type()
initial_suspend
get()
yield_value
Hello from the coroutine
~return_type()

这个输出显示了协程执行期间发生的情况:

  1. return_type对象在调用get_return_object之后创建

  2. 协程最初是挂起的

  3. 调用者想要从协程中获取消息,因此调用get()

  4. yield_value被调用,协程被恢复,并且消息被复制到承诺的成员变量中

  5. 最后,调用函数打印消息,协程返回

注意,承诺(以及承诺类型)与在第六章中解释的 C++标准库std::promise类型无关。

等待中的协程

在前面的例子中,我们看到了如何实现一个可以通过发送std::string对象来回调者通信的协程。现在,我们将实现一个可以等待调用者发送的输入数据的协程。在我们的例子中,协程将等待直到它接收到一个std::string对象,然后打印它。当我们说协程“等待”时,我们的意思是它是挂起的(即,没有执行)直到数据接收。

让我们从协程和调用函数的更改开始:

return_type coro_func() {
    std::cout << co_await std::string{ };
    co_return;
}
int main() {
    auto rt = coro_func();
    rt.put("Hello from main\n"s);
    return 0;
}

在前面的代码中,调用函数调用put()函数(返回类型结构中的方法)和协程调用co_await等待从调用者那里来的std::string对象。

返回类型的更改很简单,即只是添加put()函数:

void put(std::string msg) {
    handle.promise().input_data = std::move(msg);
    if (!handle.done()) {
        handle.resume();
    }
}

我们需要将input_data变量添加到承诺结构中。但是,仅仅通过对我们第一个示例所做的更改(我们将它作为本章其余示例的起点,因为它是最少的代码来实现协程)以及上一个示例中的协程句柄,代码无法编译。编译器给我们以下错误:

error: no member named 'await_ready' in 'std::string' {aka 'std::__cxx11::basic_string<char>'}

回到 C++参考,我们看到当协程调用co_await时,编译器将生成代码来调用承诺对象中的函数await_transform,该函数的参数类型与协程等待的数据类型相同。正如其名所示,await_transform是一个将任何对象(在我们的例子中,std::string)转换为可等待对象的函数。std::string是不可等待的,因此之前的编译器错误。

await_transform必须返回一个awaiter对象。这只是一个简单的结构,实现了使编译器能够使用 awaiter 所需的基本接口。

以下代码展示了我们实现的await_transform函数和awaiter结构:

auto await_transform(std::string) noexcept {
    struct awaiter {
        promise_type& promise;
        bool await_ready() const noexcept {
            return true;
        }
        std::string await_resume() const noexcept {
            return std::move(promise.input_data);
        }
        void await_suspend(std::coroutine_handle<
                           promise_type>) const noexcept {
        }
   };
   return awaiter(*this);
}

编译器需要promise_type函数await_transform。我们不能为这个函数使用不同的标识符。参数类型必须与协程等待的对象类型相同。awaiter结构可以命名为任何名称。我们在这里使用awaiter是因为它具有描述性。awaiter结构必须实现三个函数:

  • await_ready:这个函数用于检查协程是否被挂起。如果是这种情况,它返回false。在我们的例子中,它总是返回true,表示协程没有被挂起。

  • await_resume:这个函数恢复协程并生成co_await表达式的结果。

  • await_suspend:在我们的简单 awaiter 中,这个函数返回void,意味着控制权传递给调用者,协程被挂起。await_suspend也可以返回一个布尔值。在这种情况下返回true就像返回void一样。返回false意味着协程被恢复。

这是等待协程完整示例的代码:

#include <coroutine>
#include <iostream>
#include <string>
using namespace std::string_literals;
struct return_type {
    struct promise_type {
        std::string input_data { };
        return_type get_return_object() noexcept {
            return return_type{ *this };
        }
        void return_void() noexcept {
        }
        std::suspend_always initial_suspend() noexcept {
            return {};
        }
        std::suspend_always final_suspend() noexcept {
            return {};
        }
        void unhandled_exception() noexcept {
        }
        auto await_transform(std::string) noexcept {
            struct awaiter {
                promise_type& promise;
                bool await_ready() const noexcept {
                    return true;
                }
                std::string await_resume() const noexcept {
                    return std::move(promise.input_data);
                }
                void await_suspend(std::coroutine_handle<
                                  promise_type>) const noexcept {
                }
            };
            return awaiter(*this);
        }
    };
    std::coroutine_handle<promise_type> handle{};
    explicit return_type(promise_type& promise)
      : handle{ std::coroutine_handle<
                         promise_type>::from_promise(promise)} {
    }
    ~return_type() noexcept {
        if (handle) {
            handle.destroy();
        }
    }
    void put(std::string msg) {
        handle.promise().input_data = std::move(msg);
        if (!handle.done()) {
            handle.resume();
        }
    }
};
return_type coro_func() {
    std::cout << co_await std::string{ };
    co_return;
}
int main() {
    auto rt = coro_func();
    rt.put("Hello from main\n"s);
    return 0;
}

在本节中,我们看到了协程的三个基本示例。我们实现了最简单的协程,然后是具有通信通道的协程,这些协程既为调用者生成数据(co_yield),又从调用者那里等待数据(co_await)。

在下一节中,我们将实现一种称为生成器的协程类型,并生成数字序列。

协程生成器

生成器是一个协程,通过反复从它被挂起的位置恢复自身来生成一系列元素。

生成器可以被视为一个无限序列,因为它可以生成任意数量的元素。调用函数可以从生成器获取它所需的所有新元素。

当我们说无限时,我们指的是理论上。生成器协程将产生元素,没有明确的最后一个元素(可以实现具有有限范围的生成器),但在实践中,我们必须处理诸如数值序列中的溢出等问题。

让我们从零开始实现一个生成器,应用我们在本章前几节学到的知识。

斐波那契序列生成器

想象我们正在实现一个应用程序,并且需要使用斐波那契序列。您可能已经知道,斐波那契序列是一个序列,其中每个数字都是前两个数字的和。第一个元素是 0,第二个元素是 1,然后我们应用定义并逐个生成元素。

Fibonaccionequence:Fn=Fn−2+Fn−1;F0=0,F1=1

我们总是可以用一个 for 循环生成这些数字。但如果我们需要在程序的不同点生成它们,我们需要实现一种存储序列状态的方法。我们需要在我们的程序中某个地方保留我们生成的最后一个元素是什么。是第五个还是可能是第十个?

协程是解决这个问题的非常好的解决方案;它会自己保持所需的状态,并且它会在我们请求序列中的下一个数字时暂停。

下面是使用生成器协程的代码:

int main() {
    sequence_generator<int64_t> fib = fibonacci();
    std::cout << "Generate ten Fibonacci numbers\n"s;
    for (int i = 0; i < 10; ++i) {
        fib.next();
        std::cout << fib.value() << " ";
    }
    std::cout << std::endl;
    std::cout << "Generate ten more\n"s;
    for (int i = 0; i < 10; ++i) {
        fib.next();
        std::cout << fib.value() << " ";
    }
    std::cout << std::endl;
    std::cout << "Let's do five more\n"s;
    for (int i = 0; i < 5; ++i) {
        fib.next();
        std::cout << fib.value() << " ";
    }
    std::cout << std::endl;
    return 0;
}

如您在前面的代码中看到的,我们生成所需的数字时无需担心最后一个元素是什么。序列是由协程生成的。

注意,尽管在理论上序列是无限的,但我们的程序必须意识到非常大的斐波那契数可能存在溢出的潜在风险。

要实现生成器协程,我们遵循本章之前解释的原则。

首先,我们实现协程函数:

sequence_generator<int64_t> fibonacci() {
    int64_t a{ 0 };
    int64_t b{ 1 };
    int64_t c{ 0 };
    while (true) {
        co_yield a;
        c = a + b;
        a = b;
        b = c;
    }
}

协程通过应用公式生成斐波那契序列的下一个元素。元素在无限循环中生成,但协程在 co_yield 后会暂停自己。

返回类型是 sequence_generator 结构体(我们使用模板以便能够使用 32 位或 64 位整数)。它包含一个承诺类型,与我们在前一个部分中看到的产生式协程中的承诺类型非常相似。

sequence_generator 结构体中,我们添加了两个在实现序列生成器时有用的函数。

void next() {
    if (!handle.done()) {
        handle.resume();
    }
}

next() 函数用于恢复协程以生成序列中要生成的下一个斐波那契数。

int64_t value() {
    return handle.promise().output_data;
}

value() 函数返回最后一个生成的斐波那契数。

这样,我们就解耦了元素生成和其检索 Q 值。

请在本书的配套 GitHub 仓库中找到此示例的完整代码。

C++23 std::generator

我们已经看到,即使在 C++ 中实现最基础的协程也需要一定量的代码。这可能在 C++26 中改变,因为 C++ 标准库对协程的支持将更多,这将使我们能够更容易地编写协程。

C++23 引入了 std::generator 模板类。通过使用它,我们可以编写基于协程的生成器,而无需编写任何所需的代码,例如承诺类型、返回类型及其所有函数。要运行此示例,您需要一个 C++23 编译器。我们使用了 GCC 14.1。std::generator 在 Clang 中不可用。

让我们看看使用新的 C++23 标准库特性的斐波那契数列生成器:

#include <generator>
#include <iostream>
std::generator<int> fibonacci_generator() {
    int a{ };
    int b{ 1 };
    while (true) {
        co_yield a;
        int c = a + b;
        a = b;
        b = c;
    }
}
auto fib = fibonacci_generator();
int main() {
    int i = 0;
    for (auto f = fib.begin(); f != fib.end(); ++f) {
        if (i == 10) {
            break;
        }
        std::cout << *f << " ";
        ++i;
    }
    std::cout << std::endl;
}

第一步是包含 头文件。然后,我们只需编写协程,因为所有其他所需的代码都已经为我们编写好了。在前面的代码中,我们使用迭代器(由 C++ 标准库提供)访问生成的元素。这允许我们使用范围-for 循环、算法和范围。

还可以编写一个斐波那契生成器的版本,生成一定数量的元素而不是无限序列:

std::generator<int> fibonacci_generator(int limit) {
    int a{ };
    int b{ 1 };
    while (limit--) {
        co_yield a;
        int c = a + b;
        a = b;
        b = c;
    }
}

代码更改非常简单:只需传递我们希望生成器生成的元素数量,并在 while 循环中将其用作终止条件。

在本节中,我们实现了最常见的协程类型之一——生成器。我们从头开始实现了生成器,也使用了 C++23 的 std::generator 类模板。

在下一节中,我们将实现一个简单的字符串解析器协程。

简单的协程字符串解析器

在本节中,我们将实现我们的最后一个示例:一个简单的字符串解析器。协程将等待输入,一个 std::string 对象,并在解析输入字符串后产生输出,即一个数字。为了简化示例,我们将假设数字的字符串表示没有错误,并且数字的结尾由哈希字符,# 表示。我们还将假设数字类型是 int64_t,并且字符串不会包含该整数类型范围之外的任何值。

解析算法

让我们看看如何将表示整数的字符串转换为数字。例如,字符串 "-12321#" 表示数字 -12321。要将字符串转换为数字,我们可以编写一个像这样的函数:

int64_t parse_string(const std::string& str) {
    int64_t num{ 0 };
    int64_t sign { 1 };
    std::size_t c = 0;
    while (c < str.size()) {
        if (str[c] == '-') {
            sign = -1;
        }
        else if (std::isdigit(str[c])) {
            num = num * 10 + (str[c] - '0');
        }
        else if (str[c] == '#') {
            break;
        }
        ++c;
    }
    return num * sign;
}

由于假设字符串是良好形成的,代码相当简单。如果我们读取负号,-,则将符号更改为 -1(默认情况下,我们假设正数,如果有 + 符号,则简单地忽略它)。然后,逐个读取数字,并按以下方式计算数字值。

num 的初始值是 0。我们读取第一个数字,并将其数值加到当前 num 值乘以 10 上。这就是我们读取数字的方式:最左边的数字将乘以 10,次数等于其右侧数字的数量。

当我们使用字符来表示数字时,它们根据 ASCII 表示法有一定的值(我们假设没有使用宽字符或其他任何字符类型)。字符09具有连续的 ASCII 码,因此我们可以通过简单地减去0来轻松地将它们转换为数字。

即使对于前面的代码,最后的字符检查可能不是必要的,但我们还是在这里包含了它。当解析器例程找到#字符时,它将终止解析循环并返回最终的数值。

我们可以使用这个函数解析任何字符串并获取数值,但我们需要完整的字符串来将其转换为数字。

让我们考虑这个场景:字符串正在从网络连接接收,我们需要解析它并将其转换为数字。我们可能将字符保存到一个临时字符串中,然后调用前面的函数。

但还有一个问题:如果字符以每几秒一次的速度缓慢到达,那会怎样?因为这就是它们传输的方式?我们希望保持 CPU 忙碌,并在可能的情况下,在等待每个字符到达时执行其他任务(或多个任务)。

解决这个问题有不同的方法。我们可以创建一个线程并发处理字符串,但这对于这样一个简单的任务来说可能会在计算机时间上代价高昂。我们也可以使用std::async

解析协程

在本章中,我们正在使用协程,因此我们将使用 C++协程实现字符串解析。我们不需要额外的线程,并且由于协程的异步性质,在字符到达时执行任何其他处理将非常容易。

我们需要的解析协程的样板代码与我们在前面的示例中已经看到的代码几乎相同。解析器本身则相当不同。请看以下代码:

async_parse<int64_t, char> parse_string() {
    while (true) {
        char c = co_await char{ };
        int64_t number { };
        int64_t sign { 1 };
        if (c != '-' && c != '+' && !std::isdigit(c)) {
            continue;
        }
        if (c == '-') {
            sign = -1;
        }
        else if (std::isdigit(c)) {
            number = number * 10 + c - '0';
        }
        while (true) {
            c = co_await char{};
            if (std::isdigit(c)) {
                number = number * 10 + c - '0';
            }
            else {
                break;
            }
        }
        co_yield number * sign;
    }
}

我认为你现在可以轻松地识别返回类型(async_parse<int64_t, char>),并且知道解析协程会在等待输入字符时挂起。一旦解析完成,协程会在返回数字后挂起自己。

但你也会看到,前面的代码并不像我们第一次尝试将字符串解析为数字那样简单。

首先,解析协程逐个解析字符。它不获取完整的字符串来解析,因此有无限循环while (true)。我们不知道完整字符串中有多少个字符,因此我们需要继续接收和解析它们。

外层循环意味着协程将解析数字,一个接一个,随着字符的到达——永远。但请记住,它会挂起自己以等待字符,所以我们不会浪费 CPU 时间。

现在,一个字符到达。首先检查这个字符是否是我们数字的有效字符。如果字符既不是负号-,也不是正号+,也不是一个数字,那么解析器将等待下一个字符。

如果下一个字符是有效的,那么以下适用:

  • 如果是减号,我们将符号值更改为-1

  • 如果是加号,我们忽略它

  • 如果是数字,我们将其解析到数字中,使用与我们在解析器的第一个版本中看到的方法更新当前数字值。

在第一个有效字符之后,我们进入一个新的循环来接收其余的字符,无论是数字还是分隔符字符(#)。注意,当我们说有效字符时,我们是指对数值转换好的。我们仍然假设输入字符形成一个有效的数字,并且正确终止。

一旦数字被转换,它就会被协程产生,外层循环再次执行。这里需要一个终止字符,因为输入字符流在理论上是无尽的,它可以包含许多数字。

协程其余部分的代码可以在 GitHub 仓库中找到。它遵循任何其他协程相同的约定。首先,我们定义返回类型:

template <typename Out, typename In>
struct async_parse {
// …
};

我们使用模板以提高灵活性,因为它允许我们参数化输入和输出数据类型。在这种情况下,这些类型分别是int64_tchar

输入和输出数据项如下:

std::optional<In> input_data { };
Out output_data { };

对于输入,我们使用std::optional,因为我们需要一种方式来知道我们是否收到了一个字符。我们使用put()函数将字符发送到解析器:

 void put(char c) {
    handle.promise().input_data = c;
    if (!handle.done()) {
        handle.resume();
    }
}

这个函数只是将值赋给std::optional input_data变量。为了管理等待字符,我们实现以下 awaiter 类型:

auto await_transform(char) noexcept {
    struct awaiter {
        promise_type& promise;
        [[nodiscard]] bool await_ready() const noexcept {
            return promise.input_data.has_value();
        }
        [[nodiscard]] char await_resume() const noexcept {
            assert (promise.input_data.has_value());
            return *std::exchange(
                            promise.input_data,
                            std::nullopt);
        }
        void await_suspend(std::coroutine_handle<
                           promise_type>) const noexcept {
        }
    };
    return awaiter(*this);
}

awaiter结构体实现了两个函数来处理输入数据:

  • await_ready():如果可选的input_data变量包含有效值,则返回true。否则返回false

  • await_resume():返回存储在可选input_data变量中的值,并将其清空,赋值为std::nullopt

在本节中,我们看到了如何使用 C++协程实现一个简单的解析器。这是我们最后的示例,展示了使用协程的一个非常基本的流处理函数。在下一节中,我们将看到协程中的异常。

协程和异常

在前面的章节中,我们实现了一些基本示例来学习主要的 C++协程概念。我们首先实现了一个非常基本的协程,以了解编译器对我们有什么要求:返回类型(有时称为包装类型,因为它包装了承诺类型)和承诺类型。

即使对于这样一个简单的协程,我们也必须实现我们在编写示例时解释的一些函数。但有一个函数尚未解释:

void unhandled_exception() noexcept {}

我们当时假设协程不能抛出异常,但事实是它们可以。我们可以在unhandled_exception()函数体中添加处理异常的功能。

协程中的异常可能在创建返回类型或承诺类型对象时发生,也可能在协程执行时发生(就像正常函数一样,协程可以抛出异常)。

差别在于,如果在协程执行之前抛出异常,创建协程的代码必须处理该异常,而如果在协程执行时抛出异常,则调用unhandled_exception()

第一种情况只是通常的异常处理,没有调用特殊函数。我们可以在try-catch块中放置协程创建,并像我们通常在代码中那样处理可能的异常。

如果另一方面,调用了unhandled_exception()(在 promise 类型内部),我们必须在该函数内部实现异常处理功能。

处理此类异常有不同的策略。其中之一如下:

  • 重新抛出异常,这样我们就可以在 promise 类型之外(即在我们的代码中)处理它。

  • 终止程序(例如,调用std::terminate)。

  • 留下函数为空。在这种情况下,协程将崩溃,并且它很可能导致程序崩溃。

因为我们实现了非常简单的协程,所以我们留下了函数为空。

在本节的最后,我们介绍了协程的异常处理机制。正确处理异常非常重要。例如,如果你知道协程内部发生异常后无法恢复;那么,可能更好的做法是让协程崩溃,并在程序的另一部分(通常是从调用函数)处理异常。

概述

在本章中,我们介绍了协程,这是 C++中最近引入的一个特性,允许我们编写不需要创建新线程的异步代码。我们实现了一些简单的协程来解释 C++协程的基本要求。此外,我们还学习了如何实现生成器和字符串解析器。最后,我们看到了协程中的异常。

协程在异步编程中很重要,因为它们允许程序在特定点挂起执行并在稍后恢复,同时允许在此期间运行其他任务,所有这些都在同一个线程中运行。它们允许更好的资源利用,减少等待时间,并提高应用程序的可扩展性。

在下一章中,我们将介绍 Boost.Asio – 一个用于在 C++中编写异步代码的非常强大的库。

进一步阅读

  • C++协程入门 ,Andreas Fertig,Meeting C++在线,2024

  • 解码协程 ,Andreas Weiss,CppCon 2022

第四部分:使用 Boost 库的高级异步编程

在这部分,我们将学习使用强大的 Boost 库进行高级异步编程技术,使我们能够高效地管理与外部资源和系统级服务交互的任务。我们将探索Boost.AsioBoost.Cobalt库,了解它们如何简化异步应用程序的开发,同时提供对复杂过程(如任务管理和协程执行)的精细控制。通过实际示例,我们将看到 Boost.Asio 如何在单线程和多线程环境中处理异步 I/O 操作,以及 Boost.Cobalt 如何抽象出 C++20 协程的复杂性,使我们能够专注于功能而不是低级协程管理。

本部分包含以下章节:

  • 第九章使用 Boost.Asio 进行异步编程

  • 第十章使用 Boost.Cobalt 的协程

第九章:使用 Boost.Asio 进行异步编程

Boost.Asio 是 Boost 库家族中包含的一个 C++ 库,它简化了处理由操作系统(OS)管理的异步 输入/输出I/O)任务解决方案的开发,使得开发处理内部和外部资源(如网络通信服务或文件操作)的异步软件变得更加容易。

为了这个目的,Boost.Asio 定义了操作系统服务(属于并受操作系统管理的服务)、I/O 对象(提供对操作系统服务的接口)以及 I/O 执行上下文对象(一个充当服务注册表和代理的对象)。

在以下页面中,我们将介绍 Boost.Asio,描述其主要构建块,并解释一些在工业界广泛使用的开发异步软件的常见模式。

在本章中,我们将涵盖以下主要主题:

  • Boost.Asio 是什么,以及它是如何简化使用外部资源的异步编程的

  • I/O 对象和 I/O 执行上下文是什么,以及它们如何与操作系统服务以及彼此交互

  • Proactor 和 Reactor 设计模式是什么,以及它们与 Boost.Asio 的关系

  • 如何保持程序线程安全以及如何使用 strands 序列化任务

  • 如何使用缓冲区有效地将数据传递给异步任务

  • 如何取消异步操作

  • 使用计时器和网络应用程序的常见实践示例

技术要求

对于本章,我们需要安装 Boost C++ 库。本书编写时的最新版本是 Boost 1.85.0。以下是发布说明:

www.boost.org/users/history/version_1_85_0.html

对于 Unix 变体系统(Linux、macOS)的安装说明,请查看以下链接:

www.boost.org/doc/libs/1_85_0/more/getting_started/unix-variants.html

对于 Windows 系统,请查看以下链接:

www.boost.org/doc/libs/1_85_0/more/getting_started/windows.html

此外,根据我们想要开发的项目,我们可能需要配置 Boost.Asio 或安装依赖项:

www.boost.org/doc/libs/1_85_0/doc/html/boost_asio/using.html

本章中展示的所有代码都将由 C++20 版本支持。请查阅第三章中的技术要求部分,其中包含有关如何安装 GCC 13 和 Clang 8 编译器的指导。

您可以在以下 GitHub 仓库中找到完整的代码:

github.com/PacktPublishing/Asynchronous-Programming-with-CPP

本章的示例位于Chapter_09文件夹下。所有源代码文件都可以使用 CMake 编译,如下所示:

cmake . && cmake —build .

可执行二进制文件将在bin目录下生成。

什么是 Boost.Asio?

Boost.Asio是由 Chris Kohlhoff 创建的跨平台 C++库,它提供了一个可移植的网络和低级 I/O 编程,包括套接字、定时器、主机名解析、套接字 iostreams、串行端口、文件描述符和 Windows HANDLEs,提供了一个一致的异步模型。它还提供了协程支持,但正如我们在上一章所学,它们现在在 C++20 中可用,所以我们将只在本章中简要介绍。

Boost.Asio 允许程序在不显式使用线程和锁的情况下管理长时间运行的操作。此外,因为它在操作系统服务之上实现了一层,所以它允许可移植性、效率、易用性和可扩展性,使用最合适的底层操作系统机制来实现这些目标,例如,分散-聚集 I/O 操作或在移动数据的同时最小化昂贵的复制。

让我们从学习 Boost.Asio 的基本块、I/O 对象和 I/O 执行上下文对象开始。

I/O 对象

有时,应用程序需要访问操作系统服务,在这些服务上运行异步任务,并收集结果或错误。Boost.Asio提供了一个由 I/O 对象和 I/O 执行上下文对象组成的机制,以允许这种功能。

I/O 对象是表示执行 I/O 操作的实体任务的面向任务的 I/O 对象。正如我们在图 9.1中可以看到,Boost.Asio 提供了核心类来管理并发、流、缓冲区或其他核心功能,并为库提供通过传输控制协议/互联网协议TCP/IP)、用户数据报协议UDP)或互联网控制消息协议ICMP)进行网络通信的可移植网络类,还包括定义安全层、传输协议和串行端口等任务的类,以及针对特定平台设置的特定类,以处理底层操作系统。

图 9.1 – I/O 对象

图 9.1 – I/O 对象

I/O 对象不会直接在操作系统中执行其任务。它们需要通过 I/O 执行上下文对象与操作系统进行通信。上下文对象的一个实例作为 I/O 对象构造函数的第一个参数传递。在这里,我们定义了一个 I/O 对象(一个三秒后到期的定时器)并通过其构造函数传递一个 I/O 执行上下文对象(io_context):

#include <boost/asio.hpp>
#include <chrono>
using namespace std::chrono_literals;
boost::asio::io_context io_context;
boost::asio::steady_timer timer(io_context, 3s);

大多数 I/O 对象都有以 async_ 开头的方法名。这些方法触发异步操作,当操作完成时将调用完成处理程序,这是一个作为方法参数传递的可调用对象。这些方法立即返回,不会阻塞程序流程。当前线程可以在任务未完成时继续执行其他任务。一旦完成,完成处理程序将被调用并执行,处理异步任务的结果或错误。

I/O 对象还提供了阻塞的对应方法,这些方法将阻塞直到完成。这些方法不需要作为参数接收处理程序。

如前所述,请注意,I/O 对象不直接与操作系统交互;它们需要一个 I/O 执行上下文对象。让我们来了解这类对象。

I/O 执行上下文对象

要访问 I/O 服务,程序至少使用一个表示操作系统 I/O 服务的网关的 I/O 执行上下文对象。它使用 boost::asio::io_context 类实现,为 I/O 对象提供操作系统服务的核心 I/O 功能。在 Windows 上,boost::asio::io_context 基于 I/O completion ports ( IOCP ),在 Linux 上,它基于 epoll,在 FreeBSD/macOS 上,它基于 kqueue

图 9.2 – Boost.Asio 架构

图 9.2 – Boost.Asio 架构

boost::asio::io_contextboost::asio::execution_context 的子类,它是函数对象执行的基础类,也被其他执行上下文对象继承,例如 boost::asio::thread_poolboost::asio::system_context。在本章中,我们将使用 boost::asio::io_context 作为我们的执行上下文对象。

自 1.66.0 版本以来,boost::asio::io_context 类已经取代了 boost::asio::io_service 类,采用了更多来自 C++ 的现代特性和实践。boost::asio::io_service 仍然可用于向后兼容。

如前所述,Boost.Asio 对象可以使用以 async_ 开头的方法来调度异步操作。当所有异步任务都调度完毕后,程序需要调用 boost::asio::io_context::run() 函数来执行事件处理循环,允许操作系统处理任务并将结果传递给程序,并触发处理程序。

回到我们之前的例子,我们现在将设置完成处理程序,on_timeout(),这是一个可调用对象(在这种情况下是一个函数),我们在调用异步的 async_wait() 函数时将其作为参数传递。以下是代码示例:

#include <boost/asio.hpp>
#include <iostream>
void on_timeout(const boost::system::error_code& ec) {
    if (!ec) {
        std::cout << "Timer expired.\n" << std::endl;
    } else {
        std::cerr << "Error: " << ec.message() << '\n';
    }
}
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context,
                              std::chrono::seconds(3));
    timer.async_wait(&on_timeout);
    io_context.run();
    return 0;
}

运行此代码后,我们应该在三个秒后在控制台看到消息 Timer expired.,或者在异步调用因任何原因失败时显示错误消息。

boost::io_context::run()是一个阻塞调用。这是为了保持事件循环运行,允许异步操作运行,并防止程序退出。显然,这个函数可以在新线程中调用,并让主线程保持未阻塞以继续其他任务,正如我们在前面的章节中看到的。

当没有挂起的异步操作时,boost::io_context::run()将返回。有一个模板类,boost::asio::executor_work_guard,可以在需要时保持io_context忙碌并避免其退出。让我们通过一个示例看看它是如何工作的。

让我们先定义一个后台任务,该任务将在等待两秒钟后通过io_context使用boost::asio::io_context::post()函数发布一些工作:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
void background_task(boost::asio::io_context& io_context) {
    std::this_thread::sleep_for(2s);
    std::cout << "Posting a background task.\n";
    io_context.post([]() {
        std::cout << "Background task completed!\n";
    });
}

main()函数中,创建了io_context对象,并使用该io_context对象构造了一个work_guard对象。

然后,创建了两个线程,io_thread,其中io_context运行,和worker,其中background_task()将运行。我们还像之前解释的那样,将io_context作为引用传递给后台任务以发布工作。

在此基础上,主线程进行了一些工作(等待五秒钟),然后通过调用其reset()函数移除工作保护,让io_context退出其run()函数,并在退出之前加入两个线程,如所示:

int main() {
    boost::asio::io_context io_context;
    auto work_guard = boost::asio::make_work_guard(
                      io_context);
    std::thread io_thread([&io_context]() {
        std::cout << "Running io_context.\n";
        io_context.run();
        std::cout << "io_context stopped.\n";
    });
    std::thread worker(background_task,
                       std::ref(io_context));
    // Main thread doing some work.
    std::this_thread::sleep_for(5s);
    std::cout << "Removing work_guard." << std::endl;
    work_guard.reset();
    worker.join();
    io_thread.join();
    return 0;
}

如果我们运行前面的代码,这是输出:

Running io_context.
Posting a background task.
Background task completed!
Removing work_guard.
io_context stopped.

我们可以看到后台线程如何正确地发布后台任务,并且这个任务在移除工作保护并停止 I/O 上下文对象的执行之前完成。

另一种保持io_context对象活跃并处理请求的方法是通过不断调用async_函数或从完成处理程序发布工作。这在读取或写入套接字或流时是一个常见的模式:

#include <boost/asio.hpp>
#include <chrono>
#include <functional>
#include <iostream>
using namespace std::chrono_literals;
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, 3s);
    std::function<void(const boost::system::error_code&)>
                  timer_handler;
    timer_handler = &timer, &timer_handler {
        if (!ec) {
            std::cout << "Handler: Timer expired.\n";
            timer.expires_after(1s);
            timer.async_wait(timer_handler);
        } else {
            std::cerr << "Handler error: "
                      << ec.message() << std::endl;
        }
    };
    timer.async_wait(timer_handler);
    io_context.run();
    return 0;
}

在这种情况下,timer_handler是一个作为 lambda 函数定义的完成处理程序,它捕获了计时器和自身。每秒钟,当计时器到期时,它打印处理程序:计时器已过期的消息,并通过将新的异步任务(使用async_wait()函数)入队到io_context对象中通过计时器对象来重启自己。

如我们所见,io_context对象可以从任何线程运行。默认情况下,此对象是线程安全的,但在某些场景中,如果我们想要更好的性能,我们可能想要避免这种安全性。这可以在其构造过程中进行调整,正如我们将在下一节中看到的。

并发提示

io_context构造函数接受一个并发提示作为参数,建议实现使用多少个活动线程来运行完成处理程序。

默认情况下,此值为BOOST_ASIO_CONCURRENCY_HINT_SAFE(值1),表示io_context对象将从一个线程运行,由于这个事实,可以启用一些优化。但这并不意味着io_context只能从单个线程使用;它仍然提供线程安全,并且可以使用来自多个线程的 I/O 对象。

可以指定的其他值如下:

  • BOOST_ASIO_CONCURRENCY_HINT_UNSAFE:禁用锁定,因此对io_context或 I/O 对象的操作必须在同一线程中发生。

  • BOOST_ASIO_CONCURRENCY_HINT_UNSAFE_IO:在反应器中禁用锁定,但在调度器中保持锁定,因此io_context对象中的所有操作都可以使用除run()函数和其他与执行事件处理循环相关的方法之外的不同线程。我们将在解释库背后的设计原则时了解调度器和反应器。

现在我们来了解事件处理循环是什么以及如何管理它。

事件处理循环

使用boost::asio::io_context::run()方法,io_context会阻塞并持续处理 I/O 异步任务,直到所有任务都已完成并且通知了完成处理程序。这个 I/O 请求处理是在内部事件处理循环中完成的。

有其他方法可以控制事件循环并避免在所有异步事件处理完毕之前阻塞。这些方法如下:

  • poll:运行事件处理循环以执行就绪处理程序

  • poll_one:运行事件处理循环以执行一个就绪处理程序

  • run_for:运行事件处理循环以指定的时间段

  • run_until:与上一个相同,但仅限于指定的时间

  • run_one:运行事件处理循环以执行最多一个处理程序

  • run_one_for:与上一个相同,但仅限于指定的时间段

  • run_one_until:与上一个相同,但仅限于指定的时间

事件循环也可以通过调用boost::asio::io_context::stop()方法或通过调用boost:asio::io_context::stopped()来检查其状态是否已停止来停止。

当事件循环没有运行时,已经安排的任务将继续执行。其他任务将保持挂起。可以通过再次使用前面提到的方法之一启动事件循环来恢复挂起的任务并收集挂起的结果。

在之前的示例中,应用程序通过调用异步方法或使用post()函数将一些工作发送到io_context。现在让我们了解dispatch()及其与post()的区别。

向 io_context 分配一些工作

除了通过来自不同 I/O 对象的异步方法或使用executor_work_guard(下面将解释)将工作发送到io_context之外,我们还可以使用boost::asio::post()boost::asio::dispatch()模板方法。这两个函数都用于将一些工作调度到io_context对象中。

post() 函数保证任务将被执行。它将完成处理程序放入执行队列,最终将被执行:

boost::asio::io_context io_context;
io_context.post([] {
    std::cout << "This will always run asynchronously.\n";
});

另一方面,如果 io_context 或 strand(本章后面将详细介绍 strand)与任务被派发的同一线程相同,则 dispatch() 可能会立即执行任务,否则将其放入队列以异步执行:

boost::asio::io_context io_context;
io_context.dispatch([] {
    std::cout << "This might run immediately or be queued.\n";
});

因此,使用 dispatch(),我们可以通过减少上下文切换或队列延迟来优化性能。

已派发的事件可以直接从当前工作线程执行,即使队列中还有其他挂起的事件。必须始终由 I/O 执行上下文管理已发布的事件,等待其他处理程序完成,然后才能执行。

现在我们已经学习了某些基本概念,让我们了解同步和异步操作在底层是如何工作的。

与操作系统交互

Boost.Asio 可以使用同步和异步操作与 I/O 服务交互。让我们了解它们的行为以及主要区别是什么。

同步操作

如果程序想以同步方式使用 I/O 服务,通常,它将创建一个 I/O 对象并使用其同步操作方法:

boost::asio::io_context io_context;
boost::asio::steady_timer timer(io_context, 3s);
timer.wait();

当调用 timer.wait() 时,请求被发送到 I/O 执行上下文对象(io_context),该对象调用操作系统执行操作。一旦操作系统完成任务,它将结果返回给 io_context,然后 io_context 将结果或错误(如果有任何问题)转换回 I/O 对象(定时器)。错误类型为 boost::system::error_code。如果发生错误,将抛出异常。

如果我们不希望抛出异常,我们可以通过引用将错误对象传递给同步方法以捕获操作状态并在之后进行检查:

boost::system::error_code ec;
Timer.wait(server_endpoint, ec);

异步操作

在异步操作的情况下,我们还需要向异步方法传递一个完成处理程序。这个完成处理程序是一个可调用对象,当异步操作完成时,I/O 上下文对象将调用它,通知程序结果或操作错误。其签名如下:

void completion_handler(
     const boost::system::error_code& ec);

继续以定时器为例,现在,我们需要调用异步操作:

socket.async_wait(completion_handler);

再次强调,I/O 对象(定时器)将请求转发到 I/O 执行上下文对象(io_context)。io_context 向操作系统请求启动异步操作。

当操作完成时,操作系统将结果放入队列,其中 io_context 正在监听。然后,io_context 取出结果,将错误转换为错误代码对象,并触发完成处理程序以通知程序任务完成和结果。

为了允许 io_context 跟进这些步骤,程序必须执行 boost::asio::io_context::run()(或之前介绍过的类似函数,这些函数管理事件处理循环)并阻塞当前线程,以处理任何未完成的异步操作。如前所述,如果没有挂起的异步操作,boost::asio::io_context::run() 将退出。

完成处理器(Completion handlers)需要是可复制的,这意味着必须有一个复制构造函数可用。如果需要临时资源(如内存、线程或文件描述符),则在调用完成处理器之前释放该资源。这允许我们在不重叠资源使用的情况下调用相同的操作,从而避免增加系统的峰值资源使用。

错误处理

如前所述,Boost.Asio 允许用户以两种不同的方式处理错误:使用错误代码或抛出异常。如果我们调用 I/O 对象方法时传递一个对 boost::system::error_code 对象的引用,则实现将通过该变量传递错误;否则,将抛出异常。

我们已经通过检查错误代码实现了第一个方法的一些示例。现在让我们看看如何捕获异常。

以下示例创建了一个持续三秒钟的计时器。io_context 对象由后台线程 io_thread 运行。当计时器通过调用其 async_wait() 函数启动异步任务时,它传递了 boost::asio::use_future 参数,因此函数返回一个未来对象 fut,稍后在该 try-catch 块内部调用其 get() 函数以检索存储的结果或异常,正如我们在 第六章 中所学。在启动异步操作后,主线程等待一秒钟,然后计时器通过调用其 cancel() 函数取消操作。由于这发生在其到期时间(三秒钟)之前,因此会抛出异常:

#include <boost/asio.hpp>
#include <chrono>
#include <future>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, 1s);
    auto fut = timer.async_wait(
                     boost::asio::use_future);
    std::thread io_thread([&io_context]() {
                        io_context.run();
    });
    std::this_thread::sleep_for(3s);
    timer.cancel();
    try {
        fut.get();
        std::cout << "Timer expired successfully!\n";
    } catch (const boost::system::system_error& e) {
        std::cout << "Timer failed: "
                  << e.code().message() << '\n';
    }
    io_thread.join();
    return 0;
}

类型为 boost::system::system_error 的异常被捕获,并打印出其消息。如果在异步操作完成后(在这个例子中,通过让主线程休眠超过三秒钟),计时器取消其操作,计时器将成功到期,不会抛出异常。

现在我们已经看到了 Boost.Asio 的主要构建块以及它们是如何相互作用的,让我们回顾一下并理解其实现背后的设计模式。

反应器(Reactor)和执行者(Proactor)设计模式

当使用事件处理应用程序时,我们可以遵循两种方法来设计并发解决方案:反应器(Reactor)和执行者(Proactor)设计模式。

这些模式描述了处理事件所遵循的机制,表明了这些事件是如何被发起、接收、解多路复用和分派的。当系统收集和排队来自不同资源的 I/O 事件时,解多路复用这些事件意味着将它们分离以分派到正确的处理程序。

Reactor 模式同步和串行地解多路复用和调度服务请求。它通常遵循非阻塞同步 I/O 策略,如果操作可以执行,则返回结果;如果系统没有资源完成操作,则返回错误。

另一方面,Proactor 模式允许通过立即将控制权返回给调用者,以高效异步的方式解多路复用和调度服务请求,表明操作已启动。然后,被调用的系统将在操作完成时通知调用者。

因此,Proactor 模式在两个任务之间分配责任:执行异步的长时操作和完成处理程序,处理结果并通常调用其他异步操作。

Boost.Asio 通过以下元素实现 Proactor 设计模式:

  • 发起者:一个 I/O 对象,用于启动异步操作。

  • 异步操作:由操作系统异步运行的任务。

  • 异步操作处理器:这执行异步操作,并将结果排队在完成事件队列中。

  • 完成事件队列:一个事件队列,异步操作处理器将事件推入其中,而异步事件从队列中取出。

  • 异步事件解多路复用器:这会阻塞 I/O 上下文,等待事件,并将完成的事件返回给调用者。

  • 完成处理程序:一个可调用的对象,将处理异步操作的结果。

  • Proactor:这调用异步事件解多路复用器来出队事件并将它们分派给完成处理程序。这正是 I/O 执行上下文所做的事情。

图 9 .3 清楚地显示了所有这些元素之间的关系:

图 9.3 – Proactor 设计模式

图 9.3 – Proactor 设计模式

Proactor 模式在封装并发机制的同时,增加了关注点的分离,简化了应用程序的同步,并提高了性能。

另一方面,我们无法控制异步操作是如何或何时被调度,以及操作系统将如何高效地执行这些操作。此外,由于完成事件队列和调试和测试的复杂性增加,内存使用量也有所增加。

Boost.Asio 设计的另一个方面是执行上下文对象的线程安全性。现在让我们深入了解 Boost.Asio 中的线程是如何工作的。

使用 Boost.Asio 的多线程

I/O 执行上下文对象是线程安全的;它们的方法可以从不同的线程安全调用。这意味着我们可以使用单独的线程来运行阻塞的 io_context.run() 方法,并让主线程保持未阻塞状态,以便继续执行其他无关任务。

现在我们来解释如何根据使用线程的方式配置异步应用程序的不同方法。

单线程方法

任何 Boost.Asio 应用程序的起点和首选解决方案都应遵循单线程方法,其中 I/O 执行上下文对象在处理完成处理程序的同一线程中运行。这些处理程序必须是短小且非阻塞的。以下是一个在 I/O 上下文和主线程中运行的稳定定时器完成处理程序的示例:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
using namespace std::chrono_literals;
void handle_timer_expiry(
            const boost::system::error_code& ec) {
    if (!ec) {
        std::cout << "Timer expired!\n";
    } else {
        std::cerr << "Error in timer: "
                  << ec.message() << std::endl;
    }
}
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context,
                              std::chrono::seconds(1));
    timer.async_wait(&handle_timer_expiry);
    io_context.run();
    return 0;
}

如我们所见,steady_timer 定时器在执行 io_context.run() 函数的同一线程中调用异步的 async_wait() 函数,设置 handle_timer_expiry() 完成处理程序。当异步函数完成后,其完成处理程序将在同一线程中运行。

由于完成处理程序在主线程中运行,其执行应该快速,以避免冻结主线程和其他程序应执行的相关任务。在下一节中,我们将学习如何处理长时间运行的任务或完成处理程序,并保持主线程的响应性。

线程化长时间运行的任务

对于长时间运行的任务,我们可以保留主线程中的逻辑,但使用其他线程传递工作和将结果返回到主线程:

#include <boost/asio.hpp>
#include <iostream>
#include <thread>
void long_running_task(boost::asio::io_context& io_context,
                       int task_duration) {
    std::cout << "Background task started: Duration = "
              << task_duration << " seconds.\n";
    std::this_thread::sleep_for(
                      std::chrono::seconds(task_duration));
    io_context.post([&io_context]() {
        std::cout << "Background task completed.\n";
        io_context.stop();
    });
}
int main() {
    boost::asio::io_context io_context;
    auto work_guard = boost::asio::make_work_guard
                                        (io_context);
    io_context.post([&io_context]() {
        std::thread t(long_running_task,
                      std::ref(io_context), 2);
        std::cout << "Detaching thread" << std::endl;
        t.detach();
    });
    std::cout << "Running io_context...\n";
    io_context.run();
    std::cout << "io_context exit.\n";
    return 0;
}

在此示例中,在创建 io_context 之后,使用工作保护来避免在发布任何工作之前立即返回 io_context.run() 函数。

发布的工作包括创建一个 t 线程以在后台运行 long_running_task() 函数。在 lambda 函数退出之前,该 t 线程被分离;否则,程序将终止。

在后台任务函数中,当前线程会暂停给定的时间,然后向 io_context 对象中发布另一个任务以打印消息并停止 io_context 本身。如果我们不调用 io_context.stop(),事件处理循环将无限期地继续运行,程序将无法结束,因为 io_context.run() 将由于工作保护而继续阻塞。

每个线程一个 I/O 执行上下文对象

这种方法类似于单线程方法,其中每个线程都有自己的 io_context 对象,并处理短小且非阻塞的完成处理程序:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <syncstream>
#include <thread>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
void background_task(int i) {
    sync_cout << "Thread " << i << ": Starting...\n";
    boost::asio::io_context io_context;
    auto work_guard =
              boost::asio::make_work_guard(io_context);
    sync_cout << "Thread " << i << ": Setup timer...\n";
    boost::asio::steady_timer timer(io_context, 1s);
    timer.async_wait(
        & {
            if (!ec) {
                sync_cout << "Timer expired successfully!"
                          << std::endl;
            } else {
                sync_cout << "Timer error: "
                          << ec.message() << ‚\n';
        }
        work_guard.reset();
    });
    sync_cout << "Thread " << i << ": Running
                      io_context...\n";
    io_context.run();
}
int main() {
    const int num_threads = 4;
    std::vector<std::jthread> threads;
    for (auto i = 0; i < num_threads; ++i) {
        threads.emplace_back(background_task, i);
    }
    return 0;
}

在此示例中,创建了四个线程,每个线程运行 background_task() 函数,其中创建了一个 io_context 对象,并设置了一个定时器,在经过一秒后超时,并与其完成处理程序一起停止。

单个 I/O 执行上下文对象的多线程

现在,只有一个io_context对象,但它从不同的线程启动不同的 I/O 对象异步任务。在这种情况下,完成处理程序可以从这些线程中的任何一个被调用。以下是一个例子:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <syncstream>
#include <thread>
#include <vector>
#define sync_cout std::osyncstream(std::cout)
using namespace std::chrono_literals;
void background_task(int task_id) {
    boost::asio::post([task_id]() {
        sync_cout << "Task " << task_id
                  << " is being handled in thread "
                  << std::this_thread::get_id()
                  << std::endl;
        std::this_thread::sleep_for(2s);
        sync_cout << "Task " << task_id
                  << " complete.\n";
    });
}
int main() {
    boost::asio::io_context io_context;
    auto work_guard = boost::asio::make_work_guard(
                                   io_context);
    std::jthread io_context_thread([&io_context]() {
        io_context.run();
    });
    const int num_threads = 4;
    std::vector<std::jthread> threads;
    for (int i = 0; i < num_threads; ++i) {
        background_task(i);
    }
    std::this_thread::sleep_for(5s);
    work_guard.reset();
    return 0;
}

在这个例子中,只创建并运行了一个io_context对象,并在一个单独的线程io_context_thread中执行。然后,创建了另外四个后台线程,工作被提交到io_context对象中。最后,主线程等待五秒钟,让所有线程完成它们的工作,并重置工作保护器,如果没有任何待处理的工作,则让io_context.run()函数返回。当程序退出时,所有线程自动合并,因为它们是std::jthread的实例。

并行化一个 I/O 执行上下文所做的工作

在上一个例子中,使用了一个独特的 I/O 执行上下文对象,其run()函数从不同的线程中被调用。然后,每个线程提交了一些工作,这些工作由完成处理程序在完成时在可用的线程中执行。

这是一种常见的并行化一个 I/O 执行上下文所做工作的方法,通过从多个线程调用其run()函数,将异步操作的处理分配给这些线程。这是可能的,因为io_context对象提供了一个线程安全的事件分发系统。

这里还有一个例子,其中创建了一个线程池,每个线程运行io_context.run(),使这些线程竞争从队列中拉取任务并执行它们。在这种情况下,仅使用一个在两秒后到期的计时器创建了一个异步任务。其中一个线程将拾取该任务并执行它:

#include <boost/asio.hpp>
#include <iostream>
#include <thread>
#include <vector>
using namespace std::chrono_literals;
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, 2s);
    timer.async_wait(
        [](const boost::system::error_code& /*ec*/) {
            std::cout << "Timer expired!\n";
    });
    const std::size_t num_threads =
                std::thread::hardware_concurrency();
    std::vector<std::thread> threads;
    for (std::size_t i = 0;
         i < std::thread::hardware_concurrency(); ++i) {
            threads.emplace_back([&io_context]() {
                io_context.run();
            });
    }
    for (auto& t : threads) {
        t.join();
    }
    return 0;
}

这种技术提高了可伸缩性,因为应用程序更好地利用了多个核心,并通过并发处理异步任务来降低延迟。此外,通过减少单线程代码处理许多同时进行的 I/O 操作时产生的瓶颈,可以减少竞争并提高吞吐量。

注意,完成处理程序也必须使用同步原语,并且如果它们在不同的线程之间共享或修改共享资源,则必须是线程安全的。

此外,不能保证完成处理程序执行的顺序。由于可以同时运行许多线程,任何一个线程都可能先完成并调用其相关的完成处理程序。

由于线程正在竞争从队列中拉取任务,如果线程池的大小不是最优的,可能会出现潜在的锁竞争或上下文切换开销,理想情况下应与硬件线程的数量相匹配,就像在这个例子中所做的那样。

现在,是时候了解对象的生存期如何影响我们使用 Boost.Asio 开发的异步程序稳定性了。

管理对象的生存期

异步操作可能引发的主要灾难性问题之一是,当操作进行时,一些必需的对象已经被销毁。因此,管理对象的生命周期至关重要。

在 C++中,一个对象的生命周期从构造函数结束开始,到析构函数开始结束。

保持对象存活的一个常用模式是让对象为自己创建一个指向自身的共享指针实例,确保只要存在指向该对象的共享指针,对象就保持有效,这意味着有持续进行的异步操作需要该对象。

这种技术被称为shared-from-this,它使用 C++11 以来可用的std::enable_shared_from_this模板基类,该基类提供了对象用来获取自身共享指针的shared_from_this()方法。

实现回声服务器 – 示例

让我们通过创建一个回声服务器来看看它是如何工作的。同时,我们将讨论这项技术,我们还将学习如何使用 Boost.Asio 进行网络编程。

在网络中传输数据可能需要很长时间才能完成,并且可能会发生几个错误。这使得网络 I/O 服务成为 Boost.Asio 处理的一个特殊良好案例。网络 I/O 服务是库中最早包含的服务之一。

在工业界,Boost.Asio 的主要常见用途是开发网络应用程序,因为它支持互联网协议 TCP、UDP 和 ICMP。该库还提供了一个基于伯克利软件发行版BSD)套接字 API 的套接字接口,以允许使用低级接口开发高效和可扩展的应用程序。

然而,由于在这本书中我们关注的是异步编程,让我们专注于使用高级接口实现回声服务器。

回声服务器是一个监听特定地址和端口的程序,并将从该端口读取的所有内容写回。为此,我们将创建一个 TCP 服务器。

主程序将简单地创建一个io_context对象,通过传递io_context对象和一个要监听的端口号来设置EchoServer对象,并调用io_context.run()来启动事件处理循环:

#include <boost/asio.hpp>
#include <memory>
constexpr int port = 1234;
int main() {
    try {
        boost::asio::io_context io_context;
        EchoServer server(io_context, port);
        io_context.run();
    } catch (std::exception& e) {
        std::cerr << "Exception: " << e.what() << "\n";
    }
    return 0;
}

EchoServer初始化时,它将开始监听传入的连接。它是通过使用一个boost::asio::tcp::acceptor对象来做到这一点的。这个对象通过其构造函数接受一个io_context对象(对于 I/O 对象来说通常是这样的)和一个boost::asio::tcp::endpoint对象,该对象指示用于监听的连接协议和端口号。由于使用了boost::asio::tcp::v4()对象来初始化端点对象,因此EchoServer将使用的协议是 IPv4。没有指定给端点构造函数的 IP 地址,因此端点 IP 地址将是任何地址(IPv4 的INADDR_ANY或 IPv6 的in6addr_any)。接下来,实现EchoServer构造函数的代码如下:

using boost::asio::ip::tcp;
class EchoServer {
   public:
    EchoServer(boost::asio::io_context& io_context,
               short port)
        : acceptor_(io_context,
                    tcp::endpoint(tcp::v4(),
                    port)) {
        do_accept();
    }
   private:
    void do_accept() {
        acceptor_.async_accept(this {
            if (!ec) {
                std::make_shared<Session>(
                    std::move(socket))->start();
            }
            do_accept();
        });
    }
    tcp::acceptor acceptor_;
};

EchoServer 构造函数在设置好接受者对象后调用 do_accept() 函数。do_accept() 函数调用 async_accept() 函数等待传入的连接。当客户端连接到服务器时,操作系统通过 io_context 对象返回连接的套接字(boost::asio::tcp::socket)或错误。

如果没有错误并且建立了连接,就创建一个 Session 对象的共享指针,将套接字移动到 Session 对象中。然后,Session 对象运行 start() 函数。

Session 对象封装了特定连接的状态,在本例中是 socket_ 对象和 data_ 缓冲区。它还通过使用 do_read()do_write() 管理对该缓冲区的异步读取和写入,我们将在稍后实现它们。但在那之前,注释说明 Session 继承自 std::enable_shared_from_this,允许 Session 对象创建指向自身的共享指针,确保会话对象在整个异步操作的生命周期中保持活跃,只要至少有一个共享指针指向管理该连接的 Session 实例。这个共享指针是 EchoServer 对象中的 do_accept() 函数在建立连接时创建的。以下是 Session 类的实现:

class Session
    : public std::enable_shared_from_this<Session>
{
   public:
    Session(tcp::socket socket)
        : socket_(std::move(socket)) {}
    void start() { do_read(); }
   private:
    static const size_t max_length = 1024;
    void do_read();
    void do_write(std::size_t length);
    tcp::socket socket_;
    char data_[max_length];
};

使用 Session 类允许我们将管理连接的逻辑与管理服务器的逻辑分开。EchoServer 只需要接受连接并为每个连接创建一个 Session 对象。这样,服务器可以管理多个客户端,保持它们的连接独立并异步管理。

Session 是使用 do_read()do_write() 函数管理该连接行为的对象。当 Session 开始时,它的 start() 函数调用 do_read() 函数,如下所示:

void Session::do_read() {
    auto self(shared_from_this());
    socket_.async_read_some(boost::asio::buffer(data_,
                                          max_length),
        this, self {
            if (!ec) {
                do_write(length);
            }
        });
}

do_read() 函数创建当前会话对象(self)的共享指针,并使用套接字的 async_read_some() 异步函数将一些数据读取到 data_ 缓冲区。如果操作成功,此操作将返回复制到 data_ 缓冲区的数据以及读取的字节数存储在 length 变量中。

然后,使用那个 length 变量调用 do_write(),通过使用 async_write() 函数异步地将 data_ 缓冲区的内容写入套接字。当这个异步操作成功时,它通过再次调用 do_read() 函数来重启循环,如下所示:

void Session::do_write(std::size_t length) {
    auto self(shared_from_this());
    boost::asio::async_write(socket_,
                             boost::asio::buffer(data_,
                                                length),
        this, self {
            if (!ec) {
                do_read();
            }
        });
}

你可能会想知道为什么定义了self但没有使用它。它看起来self是多余的,但作为 lambda 函数按值捕获它,会创建一个副本,增加对this对象的共享指针的引用计数,确保如果 lambda 是活跃的,会话不会被销毁。this对象被捕获以在 lambda 函数中提供对其成员的访问。

作为练习,尝试实现一个stop()函数,以中断do_read()do_write()之间的循环。一旦所有异步操作完成并且 lambda 函数退出,self对象将被销毁,并且没有其他共享指针指向Session对象,因此会话将被销毁。

这种模式确保在异步操作期间对对象生命周期的健壮和安全管理,避免了悬垂指针或过早销毁,这可能导致不期望的行为或崩溃。

要测试此服务器,只需启动服务器,打开一个新的终端,并使用telnet命令连接到服务器并向其发送数据。作为参数,我们可以传递localhost地址,表示我们正在连接到同一台机器上运行的服务器(IP 地址为127.0.0.1)和端口号,在这种情况下,1234

telnet命令将启动并显示一些关于连接的信息,并指示我们需要按下Ctrl + }键来关闭连接。

输入任何内容并按Enter键将发送输入的行到回显服务器,服务器将监听并发送回相同的内容;在这个例子中,将是Hello world!

只需关闭连接并使用quit命令退出telnet回到终端:

$ telnet localhost 1234
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hello world!
Hello world!
telnet> quit
Connection closed.

在这个例子中,我们已经使用了一个缓冲区。让我们在下一节中了解更多关于它们的信息。

使用缓冲区传输数据

缓冲区是在 I/O 操作期间用于传输数据的连续内存区域。

Boost.Asio 定义了两种类型的缓冲区:可变缓冲区boost::asio::mutable_buffer),其中可以写入数据,和常量缓冲区boost::asio::const_buffers),用于创建只读缓冲区。可变缓冲区可以转换为常量缓冲区,但不能反向转换。这两种类型的缓冲区都提供了防止越界的保护。

此外,还有boost::buffer函数,用于从不同数据类型(原始内存的指针和大小、字符串(std::string)、或原始数据POD)结构(意味着一个没有用户定义的复制赋值运算符或析构函数的类型、结构或类,并且没有私有或受保护的非静态数据成员)的数组或向量)创建可变或常量缓冲区。例如,要从字符数组创建缓冲区,我们可以使用以下代码:

char data[1024];
mutable_buffer buffer = buffer(data, sizeof(data));

此外,请注意,缓冲区的所有权和生命周期是程序的责任,而不是 Boost.Asio 库的责任。

Scatter-gather 操作

通过使用 scatter-gather 操作,可以有效地使用缓冲区,其中多个缓冲区一起用于接收数据(scatter-read)或发送数据(gather-write)。

Scatter-read是从唯一源读取数据到不同的非连续内存缓冲区的过程。

Gather-write是相反的过程;数据从不同的非连续内存缓冲区中收集并写入单个目标。

这些技术通过减少系统调用或数据复制的次数来提高效率和性能。它们不仅用于 I/O 操作,还用于其他用例,例如数据处理、机器学习或并行算法,如排序或矩阵乘法。

为了允许 scatter-gather 操作,可以将多个缓冲区一起传递到容器中的异步操作内部(std::vectorstd::liststd::arrayboost::array)。

这里是一个 scatter-read 的示例,其中套接字异步地将一些数据读取到buf1buf2缓冲区中:

std::array<char, 128> buf1, buf2;
std::vector<boost::asio::mutable_buffer> buffers = {
    boost::asio::buffer(buf1),
    boost::asio::buffer(buf2)
};
socket.async_read_some(buffers, handler);

这里是如何实现 gather-read 的:

std::array<char, 128> buf1, buf2;
std::vector<boost::asio::const_buffer> buffers = {
    boost::asio::buffer(buf1),
    boost::asio::buffer(buf2)
};
socket.async_write_some(buffers, handler);

现在,套接字执行相反的操作,将两个缓冲区中的数据写入套接字缓冲区以进行异步发送。

流缓冲区

我们还可以使用流缓冲区来管理数据。Stream buffersboost::asio::basic_streambuf类定义,基于std::basic_streambuf C++类,并在头文件中定义。它允许一个动态缓冲区,其大小可以适应传输的数据量。

让我们看看以下示例中流缓冲区如何与 scatter-gather 操作一起工作。在这种情况下,我们正在实现一个 TCP 服务器,该服务器监听并接受来自给定端口的客户端连接,将客户端发送的消息读取到两个流缓冲区中,并将它们的内容打印到控制台。由于我们感兴趣的是理解流缓冲区和 scatter-gather 操作,让我们通过使用同步操作来简化示例。

如前一个示例所示,在main()函数中,我们使用一个boost::asio::ip::tcp::acceptor对象来设置 TCP 服务器将用于接受连接的协议和端口。然后,在一个无限循环中,服务器使用该 acceptor 对象附加一个 TCP 套接字(boost::asio::ip::tcp::socket)并调用handle_client()函数:

#include <array>
#include <iostream>
#include <boost/asio.hpp>
#include <boost/asio/streambuf.hpp>
using boost::asio::ip::tcp;
constexpr int port = 1234;
int main() {
    try {
        boost::asio::io_context io_context;
        tcp::acceptor acceptor(io_context,
                      tcp::endpoint(tcp::v4(), port));
        std::cout << "Server is running on port "
                  << port << "...\n";
        while (true) {
            tcp::socket socket(io_context);
            acceptor.accept(socket);
            std::cout << "Client connected...\n";
            handle_client(socket);
            std::cout << "Client disconnected...\n";
        }
    } catch (std::exception& e) {
        std::cerr << "Exception: " << e.what() << '\n';
    }
    return 0;
}

handle_client()函数创建了两个流缓冲区:buf1buf2,并将它们添加到一个容器中,在这种情况下是std::array,用于 scatter-gather 操作。

然后,调用套接字的同步read_some()函数。此函数返回从套接字读取的字节数并将它们复制到缓冲区中。如果套接字连接出现任何问题,错误将返回到错误代码对象ec中。在这种情况下,服务器将打印错误消息并退出。

这里是实现方式:

void handle_client(tcp::socket& socket) {
    const size_t size_buffer = 5;
    boost::asio::streambuf buf1, buf2;
    std::array<boost::asio::mutable_buffer, 2> buffers = {
        buf1.prepare(size_buffer),
        buf2.prepare(size_buffer)
    };
    boost::system::error_code ec;
    size_t bytes_recv = socket.read_some(buffers, ec);
    if (ec) {
        std::cerr << "Error on receive: "
                  << ec.message() << '\n';
        return;
    }
    std::cout << "Received " << bytes_recv << " bytes\n";
    buf1.commit(5);
    buf2.commit(5);
    std::istream is1(&buf1);
    std::istream is2(&buf2);
    std::string data1, data2;
    is1 >> data1;
    is2 >> data2;
    std::cout << "Buffer 1: " << data1 << std::endl;
    std::cout << "Buffer 2: " << data2 << std::endl;
}

如果没有错误,流缓冲区的 commit() 函数用于将五个字节传输到每个流缓冲区,即 buf1buf2。这些缓冲区的内容通过使用 std::istream 对象提取并打印到控制台。

要执行此示例,我们需要打开两个终端。在一个终端中,我们执行服务器,在另一个终端中执行 telnet 命令,如前所述。在 telnet 终端中,我们可以输入一条消息(例如,Hello World)。这条消息将被发送到服务器。然后服务器终端将显示以下内容:

Server is running on port 1234...
Client connected...
Received 10 bytes
Buffer 1: Hello
Buffer 2: Worl
Client disconnected...

如我们所见,只有 10 个字节被处理并分配到两个缓冲区中。两个单词之间的空格在通过 iostream 对象解析输入时被处理但被丢弃。

当接收到的数据大小可变且事先未知时,流缓冲区非常有用。这些类型的缓冲区可以与固定大小的缓冲区一起使用。

信号处理

信号处理允许我们捕获操作系统发送的信号,并在操作系统决定杀死应用程序进程之前优雅地关闭应用程序。

Boost.Asio 提供了 boost::asio::signal_set 类来实现此目的,该类启动对一个或多个信号发生的异步等待。

这是如何处理 SIGINTSIGTERM 信号的示例:

#include <boost/asio.hpp>
#include <iostream>
int main() {
    try {
        boost::asio::io_context io_context;
        boost::asio::signal_set signals(io_context,
                                  SIGINT, SIGTERM);
        auto handle_signal = & {
            if (!ec) {
                std::cout << "Signal received: "
                          << signal << std::endl;
                // Code to perform cleanup or shutdown.
                io_context.stop();
            }
        };
        signals.async_wait(handle_signal);
        std::cout << "Application is running. "
                  << "Press Ctrl+C to stop...\n";
        io_context.run();
        std::cout << "Application has exited cleanly.\n";
    } catch (std::exception& e) {
        std::cerr << "Exception: " << e.what() << '\n';
    }
    return 0;
}

signals 对象是 signal_set,列出了程序等待的信号,SIGINTSIGTERM。此对象有一个 async_wait() 方法,它异步等待这些信号中的任何一个发生,并触发完成处理程序,handle_signal()

如同在完成处理程序中通常所做的那样,handle_signal() 检查错误代码,ec,如果没有错误,可能会执行一些清理代码以干净和优雅地退出程序。在这个例子中,我们只是通过调用 io_context.stop() 来停止事件处理循环。

我们也可以通过使用 signals.wait() 方法同步等待信号。

如果应用程序是多线程的,信号事件处理程序必须在与 io_context 对象相同的线程中运行,通常是主线程。

在下一节中,我们将学习如何取消操作。

取消操作

一些 I/O 对象,如套接字或定时器,可以通过调用它们的 close()cancel() 方法来取消未完成的异步操作。如果异步操作被取消,完成处理程序将接收到一个带有 boost::asio::error::operation_aborted 代码的错误。

在以下示例中,创建了一个定时器,并将其超时时间设置为五秒。但是,在主线程仅休眠两秒后,通过调用其 cancel() 方法取消定时器,使得完成处理程序以 boost::asio::error::operation_aborted 错误代码被调用:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
void handle_timeout(const boost::system::error_code& ec) {
    if (ec == boost::asio::error::operation_aborted) {
        std::cout << "Timer canceled.\n";
    } else if (!ec) {
        std::cout << "Timer expired.\n";
    } else {
        std::cout << "Error: " << ec.message()
                  << std::endl;
    }
}
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, 5s);
    timer.async_wait(handle_timeout);
    std::this_thread::sleep_for(2s);
    timer.cancel();
    io_context.run();
    return 0;
}

但如果我们想实现按操作取消,我们需要设置一个在取消信号发出时被触发的取消槽。这个取消信号/槽对构成了一个轻量级通道,用于通信取消操作,就像在第六章中解释的承诺和未来之间创建的那样。取消框架自 Boost.Asio 1.75 版本以来就可用。

这种方法实现了一种更灵活的取消机制,其中可以使用相同的信号取消多个操作,并且它与 Boost.Asio 的异步操作无缝集成。同步操作只能通过使用前面描述的cancel()close()方法来取消;它们不受取消槽机制的支持。

让我们修改之前的示例,并使用取消信号/槽来取消计时器。我们只需要修改main()函数中取消计时器的方式。现在,当执行异步async_wait()操作时,通过使用boost::asio::bind_cancellation_slot()函数将取消信号的槽绑定到完成处理程序,将创建一个取消槽。

与之前一样,计时器的到期时间为五秒,再次强调,主线程只睡眠两秒。这次,通过调用cancel_signal.emit()函数发出取消信号。该信号将触发对应的取消槽,并使用boost::asio::error::operation_aborted错误代码执行完成处理程序,在控制台打印Timer canceled.消息;请参见以下内容:

int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, 5s);
    boost::asio::cancellation_signal cancel_signal;
    timer.async_wait(boost::asio::bind_cancellation_slot(
        cancel_signal.slot(),
        handle_timeout
    ));
    std::this_thread::sleep_for(2s);
    cancel_signal.emit(
        boost::asio::cancellation_type::all);
    io_context.run();
    return 0;
}

当发出信号时,必须指定取消类型,让目标操作知道应用程序的要求和操作保证,从而控制取消的范围和行为。

取消的各类别如下:

  • :不执行取消。如果我们想测试是否应该发生取消,这可能很有用。

  • 终止:操作具有未指定的副作用,因此取消操作的唯一安全方法是关闭或销毁 I/O 对象,因为其结果是最终的,例如,完成任务或事务。

  • 部分:操作具有定义良好的副作用,因此完成处理程序可以采取必要的行动来解决该问题,这意味着操作已部分完成,可以恢复或重试。

  • 全部全部:操作没有副作用。取消终止和部分操作,通过停止所有正在进行的异步操作实现全面取消。

如果异步操作不支持取消类型,则取消请求将被丢弃。例如,计时器操作支持所有取消类别,但套接字只支持 TotalAll,这意味着如果我们尝试使用 Partial 取消来取消套接字异步操作,则此取消请求将被忽略。这防止了如果 I/O 系统尝试处理不受支持的取消请求时出现未定义的行为。

此外,在操作开始之前或完成之后提出的取消请求没有任何效果。

有时,我们需要按顺序运行一些工作。接下来,我们将介绍如何通过使用线程来实现这一点。

使用线程序列化工作负载

线程是一种严格的顺序和非并发调用完成处理器的机制。使用线程,异步操作可以在不使用互斥锁或其他之前在本书中看到的同步机制的情况下进行排序。线程可以是隐式的或显式的。

如本章前面所示,如果我们只从一个线程执行 boost::asio::io_context::run(),所有的事件处理器都将在一个隐式线程中执行,因为它们将一个接一个地按顺序排队并从 I/O 执行上下文中触发。

当存在链式异步操作时,其中一个异步操作安排下一个异步操作,依此类推,会发生另一个隐式线程。本章中的一些先前示例已经使用了这种技术,但这里还有一个例子。

在这种情况下,如果没有错误,计时器将通过在 handle_timer_expiry() 事件处理器中递归地设置过期时间并调用 async_wait() 方法来不断重启自己:

#include <boost/asio.hpp>
#include <chrono>
#include <iostream>
using namespace std::chrono_literals;
void handle_timer_expiry(boost::asio::steady_timer& timer,
                        int count) {
    std::cout << "Timer expired. Count: " << count
              << std::endl;
    timer.expires_after(1s);
    timer.async_wait(&timer, count {
        if (!ec) {
            handle_timer_expiry(timer, count + 1);
        } else {
            std::cerr << „Error: „ << ec.message()
                      << std::endl;
        }
    });
}
int main() {
    boost::asio::io_context io_context;
    boost::asio::steady_timer timer(io_context, 1s);
    int count = 0;
    timer.async_wait(& {
        if (!ec) {
            handle_timer_expiry(timer, count);
        } else {
            std::cerr << "Error: " << ec.message()
                      << std::endl;
        }
    });
    io_context.run();
    return 0;
}

运行此示例将每秒打印一次 Timer expired. Count: 行,计数器在每一行上递增。

如果某些工作需要序列化,但这些方法不适用,我们可以通过使用 boost::asio::strand 或其针对 I/O 上下文执行对象的特化,boost::asio::io_context::strand 来使用显式线程。使用这些线程对象发布的作业将按它们进入 I/O 执行上下文队列的顺序序列化其处理器的执行。

在以下示例中,我们将创建一个记录器,它将多个线程中的写入操作序列化到一个单个日志文件中。我们将从四个线程中记录消息,每个线程写入五条消息。我们期望输出是正确的,但这次不使用任何互斥锁或其他同步机制。

让我们先定义 Logger 类:

#include <boost/asio.hpp>
#include <chrono>
#include <fstream>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
#include <vector>
using namespace std::chrono_literals;
class Logger {
   public:
    Logger(boost::asio::io_context& io_context,
           const std::string& filename)
        : strand_(io_context), file_(filename
        , std::ios::out | std::ios::app)
    {
        if (!file_.is_open()) {
            throw std::runtime_error(
                      "Failed to open log file");
        }
    }
    void log(const std::string message) {
        strand_.post([this, message](){
            do_log(message);
        });
    }
   private:
    void do_log(const std::string message) {
        file_ << message << std::endl;
    }
    boost::asio::io_context::strand strand_;
    std::ofstream file_;
};

Logger构造函数接受一个 I/O 上下文对象,用于创建一个线程对象(boost::asio::io_context::strand),以及std::string,指定用于打开日志文件或创建它的日志文件名。日志文件用于追加新内容。如果构造函数完成前文件未打开,意味着在访问或创建文件时出现问题,构造函数将抛出异常。

记录器还提供了一个公共的log()函数,该函数接受std::string,指定一个消息作为参数。此函数使用线程对象将新工作提交到io_context对象。它是通过使用 lambda 函数实现的,通过值捕获记录器实例(对象this)和消息,并调用私有的do_log()函数,在该函数中使用std::fstream对象将消息写入输出文件。

程序中只有一个Logger类的实例,由所有线程共享。这样,线程将写入同一个文件。

让我们定义一个worker()函数,每个线程将运行此函数以将num_messages_per_thread条消息写入输出文件:

void worker(std::shared_ptr<Logger> logger, int id) {
    for (unsigned i=0; i < num_messages_per_thread; ++i) {
        std::ostringstream oss;
        oss << "Thread " << id << " logging message " << i;
        logger->log(oss.str());
        std::this_thread::sleep_for(100ms);
    }
}

此函数接受对Logger对象的共享指针和一个线程标识符。它使用前面解释的Logger的公共log()函数打印所有消息。

为了交错线程执行并严格测试线程的工作方式,每个线程在写入每条消息后都将睡眠 100 毫秒。

最后,在main()函数中,我们启动一个io_context对象和一个工作保护器,以避免从io_context中提前退出。然后,创建一个指向Logger实例的共享指针,传递前面解释的必要参数。

通过使用worker()函数并传递对记录器的共享指针以及每个线程的唯一标识符,创建了一个线程池(std::jthread对象向量)。此外,还添加了一个运行io_context.run()函数的线程到线程池中。

在以下示例中,由于我们知道所有消息将在两秒内打印出来,我们使io_context只运行那个时间段,使用io_context.run_for(2s)

run_for()函数退出时,程序将打印Done!到控制台并结束:

const std::string log_filename = "log.txt";
const unsigned num_threads = 4;
const unsigned num_messages_per_thread = 5;
int main() {
    try {
        boost::asio::io_context io_context;
        auto work_guard = boost::asio::make_work_guard(
                                 io_context);
        std::shared_ptr<Logger> logger =
             std::make_shared<Logger>(
                  io_context, log_filename);
        std::cout << "Logging "
                  << num_messages_per_thread
                  << " messages from " << num_threads
                  << " threads\n";
        std::vector<std::jthread> threads;
        for (unsigned i = 0; i < num_threads; ++i) {
            threads.emplace_back(worker, logger, i);
        }
        threads.emplace_back([&]() {
            io_context.run_for(2s);
        });
    } catch (std::exception& e) {
        std::cerr << "Exception: " << e.what() << '\n';
    }
    std::cout << "Done!" << std::endl;
    return 0;
}

运行此示例将显示以下输出:

Logging 5 messages from 4 threads
Done!

这是生成的log.txt日志文件的内容。由于每个线程的睡眠时间相同,所有线程和消息都是顺序排列的:

Thread 0 logging message 0
Thread 1 logging message 0
Thread 2 logging message 0
Thread 3 logging message 0
Thread 0 logging message 1
Thread 1 logging message 1
Thread 2 logging message 1
Thread 3 logging message 1
Thread 0 logging message 2
Thread 1 logging message 2
Thread 2 logging message 2
Thread 3 logging message 2
Thread 0 logging message 3
Thread 1 logging message 3
Thread 2 logging message 3
Thread 3 logging message 3
Thread 0 logging message 4
Thread 1 logging message 4
Thread 2 logging message 4
Thread 3 logging message 4

如果我们移除工作保护器,日志文件只包含以下内容:

Thread 0 logging message 0
Thread 1 logging message 0
Thread 2 logging message 0
Thread 3 logging message 0

这是因为第一个工作批次被及时提交并排队到每个线程的io_object中,但在第二个消息批次提交之前,io_object在完成工作保护器的调度和通知完成处理程序后退出。

如果我们也在工作线程中移除sleep_for()指令,现在,日志文件的内容如下:

Thread 0 logging message 0
Thread 0 logging message 1
Thread 0 logging message 2
Thread 0 logging message 3
Thread 0 logging message 4
Thread 1 logging message 0
Thread 1 logging message 1
Thread 1 logging message 2
Thread 1 logging message 3
Thread 1 logging message 4
Thread 2 logging message 0
Thread 2 logging message 1
Thread 2 logging message 2
Thread 2 logging message 3
Thread 2 logging message 4
Thread 3 logging message 0
Thread 3 logging message 1
Thread 3 logging message 2
Thread 3 logging message 3
Thread 3 logging message 4

之前,内容是按消息标识符排序的,现在则是按线程标识符排序。这是因为现在,当一个线程开始运行 worker() 函数时,它会一次性发布所有消息,没有任何延迟。因此,第一个线程(线程 0)在第二个线程有机会这样做之前,就将其所有工作入队,依此类推。

在进行进一步实验时,当我们向 strand 中发布内容时,我们通过以下指令捕获了日志记录器实例和消息的值:

strand_.post([this, message]() { do_log(message); });

通过值捕获允许 lambda 函数运行 do_log() 时使用所需对象的副本,保持它们存活,正如在本章前面讨论对象生命周期时注释的那样。

假设,由于某种原因,我们决定使用以下指令通过引用捕获:

strand_.post([&]() { do_log(message); });

然后,生成的日志文件将包含不完整的日志消息,甚至可能包含错误的字符,因为日志记录器是从属于不再存在的消息对象的内存区域打印的,当 do_log() 函数执行时。

因此,始终假设异步更改;操作系统可能会执行一些我们无法控制的变化,所以始终要知道我们控制的是什么,最重要的是,什么不是。

最后,我们不仅可以使用 lambda 表达式并通过值捕获 thismessage 对象,还可以像下面这样使用 std::bind

strand_.post(std::bind(&Logger::do_log, this, message));

让我们学习如何通过使用协程简化我们之前实现的 echo 服务器,并通过添加一个命令从客户端退出连接来改进它。

协程

自 1.56.0 版本以来,Boost.Asio 也包括了协程的支持,并从 1.75.0 版本开始支持原生协程。

正如我们在上一章中学到的,使用协程简化了程序的编写方式,因为不需要添加完成处理程序并将程序的流程分割成不同的异步函数和回调。相反,使用协程,程序遵循顺序结构,异步操作调用会暂停协程的执行。当异步操作完成时,协程会恢复,让程序从之前暂停的地方继续执行。

在新版本(1.75.0 之后的版本)中,我们可以通过 co_await 使用原生 C++ 协程,在协程中等待异步操作,使用 boost::asio::co_spawn 来启动协程,以及使用 boost::asio::use_awaitable 来让 Boost.Asio 知道异步操作将使用协程。在早期版本(从 1.56.0 开始),可以通过 boost::asio::spawn()yield 上下文使用协程。由于新方法更受欢迎,不仅因为它支持原生 C++20 协程,而且代码也更现代、简洁、易读,我们将在这个部分专注于这种方法。

让我们再次实现 echo 服务器,但这次使用 Boost.Asio 的 awaitable 接口和协程。我们还将添加一些改进,例如支持在发送 QUIT 命令时从客户端关闭连接,展示如何在服务器端处理数据或命令,以及在抛出任何异常时停止处理连接并退出。

让我们先实现 main() 函数。程序开始时使用 boost::asio::co_spawn 创建一个新的基于协程的线程。这个函数接受一个执行上下文(io_context,也可以使用 strand),一个返回类型为 boost::asio::awaitable<R,E> 的函数,该函数将用作协程的入口点(我们将实现并解释的 listener() 函数),以及一个完成令牌,当线程完成时将被调用。如果我们想在不通知其完成的情况下运行协程,我们可以传递 boost::asio::detached 令牌。

最后,我们通过调用 io_context.run() 开始处理异步事件。

如果有任何异常发生,它将被 try-catch 块捕获,并且通过调用 io_context.stop() 来停止事件处理循环:

#include <boost/asio.hpp>
#include <iostream>
#include <sstream>
#include <string>
using boost::asio::ip::tcp;
int main() {
    boost::asio::io_context io_context;
    try {
        boost::asio::co_spawn(io_context,
                    listener(io_context, 12345),
                    boost::asio::detached);
        io_context.run();
    } catch (std::exception& e) {
        std::cerr << "Error: " << e.what() << std::endl;
        io_context.stop();
    }
    return 0;
}

listener() 函数接收一个 io_context 对象和监听器将接受的端口号作为参数,使用前面解释的 acceptor 对象。它还必须有一个返回类型为 boost::asio::awaitable<R,E> ,其中 R 是协程的返回类型,E 是可能抛出的异常类型。在这个例子中,E 被设置为默认值,因此没有明确指定。

通过调用 async_accept 接受器函数来接受连接。由于我们现在使用协程,我们需要将 boost::asio::use_awaitable 指定给异步函数,并使用 co_await 来停止协程执行,直到异步任务完成时恢复。

当监听协程任务恢复时,acceptor.async_accept() 返回一个套接字对象。协程继续通过使用 boost::asio::co_spawn 函数创建一个新的线程,执行 echo() 函数,并将 socket 对象传递给它:

boost::asio::awaitable<void> listener(boost::asio::io_context& io_context, unsigned short port) {
    tcp::acceptor acceptor(io_context,
                           tcp::endpoint(tcp::v4(), port));
    while (true) {
        std::cout << "Accepting connections...\n";
        tcp::socket socket = co_await
                acceptor.async_accept(
                    boost::asio::use_awaitable);
        std::cout << "Starting an Echo "
                  << "connection handler...\n";
        boost::asio::co_spawn(io_context,
                              echo(std::move(socket)),
                              boost::asio::detached);
    }
}

echo() 函数负责处理单个客户端连接。它必须遵循与 listener() 函数相似的签名;它需要一个返回类型为 boost::asio::awaitable<R,E> 。如前所述,socket 对象是从监听器移动到这个函数中的。

函数异步地从套接字读取内容,并在一个无限循环中将其写回,只有当它接收到 QUIT 命令或抛出异常时循环才会结束。

异步读取是通过使用socket.async_read_some()函数完成的,该函数使用boost::asio::buffer将数据读入数据缓冲区,并返回读取的字节数(bytes_read)。由于异步任务由协程管理,因此将boost::asio::use_awaitable传递给异步操作。然后,co_wait只是指示协程引擎暂停执行,直到异步操作完成。

一旦接收到一些数据,协程的执行就会继续,检查是否真的有数据需要处理,如果没有,它将通过退出循环来结束连接,从而结束echo()函数。

如果读取到数据,它将其转换为std::string以便于操作。如果存在,它会移除\r\n结束符,并将字符串与QUIT进行比较。

如果存在QUIT,它将执行异步写入,发送Good bye!消息,并退出循环。否则,它将发送接收到的数据回客户端。在这两种情况下,都使用boost::asio::async_write()函数执行异步写入操作,传递套接字、boost:asio::buffer包装要发送的数据缓冲区,以及与异步读取操作相同的boost::asio::use_awaitable

然后,再次使用co_await来挂起协程的执行,同时进行操作。一旦完成,协程将恢复,并在新的循环迭代中重复这些步骤:

boost::asio::awaitable<void> echo(tcp::socket socket) {
    char data[1024];
    while (true) {
        std::cout << "Reading data from socket...\n";
        std::size_t bytes_read = co_await
                 socket.async_read_some(
                        boost::asio::buffer(data),
                        boost::asio::use_awaitable);
        if (bytes_read == 0) {
            std::cout << "No data. Exiting loop...\n";
            break;
        }
        std::string str(data, bytes_read);
        if (!str.empty() && str.back() == '\n') {
            str.pop_back();
        }
        if (!str.empty() && str.back() == '\r') {
            str.pop_back();
        }
        if (str == "QUIT") {
            std::string bye("Good bye!\n");
            co_await boost::asio::async_write(socket,
                         boost::asio::buffer(bye),
                         boost::asio::use_awaitable);
            break;
        }
        std::cout << "Writing '" << str
                  << "' back into the socket...\n";
        co_await boost::asio::async_write(socket,
                     boost::asio::buffer(data,
                                         bytes_read),
                     boost::asio::use_awaitable);
    }
}

协程循环直到没有读取到数据,这发生在客户端关闭连接、接收到QUIT命令或发生异常时。

异步操作被广泛应用于确保服务器在同时处理多个客户端时仍保持响应。

摘要

在本章中,我们学习了 Boost.Asio 以及如何使用这个库来管理由操作系统管理的资源的外部资源的异步任务。

为了这个目的,我们介绍了 I/O 对象和 I/O 执行上下文对象,并深入解释了它们是如何工作以及如何相互交互的,它们如何访问和与操作系统服务进行通信,它们背后的设计原则是什么,以及如何在单线程和多线程应用程序中正确使用它们。

我们还展示了 Boost.Asio 中可用于使用 strands 序列化工作、管理异步操作使用的对象的生命周期、如何启动、中断或取消任务、如何管理库使用的处理事件循环,以及如何处理操作系统发送的信号的不同技术。

还介绍了与网络和协程相关的其他概念,并使用这个强大的库实现了一些有用的示例。

所有这些概念和示例都使我们能够更深入地了解如何在 C++中管理异步任务,以及一个广泛使用的库是如何在底层实现这一目标的。

在下一章中,我们将学习另一个 Boost 库,Boost.Cobalt,它提供了一个丰富且高级的接口,用于基于协程开发异步软件。

进一步阅读

第十章:使用 Boost.Cobalt 的协程

前几章介绍了 C++20 协程和 Boost.Asio 库,后者是使用 Boost 编写异步 输入/输出 ( I/O ) 操作的基础。在本章中,我们将探讨 Boost.Cobalt,这是一个基于 Boost.Asio 的高级抽象,它简化了使用协程的异步编程。

Boost.Cobalt 允许你编写清晰、可维护的异步代码,同时避免在 C++ 中手动实现协程的复杂性(如第 第八章 中所述)。Boost.Cobalt 与 Boost.Asio 完全兼容,允许你在项目中无缝结合这两个库。通过使用 Boost.Cobalt,你可以专注于构建你的应用程序,而无需担心协程的低级细节。

在本章中,我们将涵盖以下 Boost.Cobalt 主题:

  • 介绍 Boost.Cobalt 库

  • Boost.Cobalt 生成器

  • Boost.Cobalt 任务和承诺

  • Boost.Cobalt 通道

  • Boost.Cobalt 同步函数

技术要求

要构建和执行本章的代码示例,需要一个支持 C++20 的编译器。我们使用了 Clang 18 和 GCC 14.2

确保你使用的是 Boost 版本 1.84 或更高版本,并且你的 Boost 库是用 C++20 支持编译的。在撰写本书时,Cobalt 在 Boost 中的支持相对较新,并非所有预编译的分发版都可能提供此组件。在阅读本书时,情况通常会得到改善。如果由于任何原因,你系统中的 Boost 库不满足这些要求,你必须从其源代码构建它。使用更早的版本,如 C++17,编译将不会包含 Boost.Cobalt,因为它严重依赖于 C++20 协程。

你可以在以下 GitHub 仓库中找到完整的代码:

github.com/PacktPublishing/Asynchronous-Programming-with-CPP

本章的示例位于 Chapter_10 文件夹下。

介绍 Boost.Cobalt 库

我们在 第八章 中介绍了 C++20 对协程的支持。很明显,由于两个主要原因,编写协程并不是一件容易的事情:

  • 在 C++ 中编写协程需要一定量的代码才能使协程工作,但这与我们要实现的功能无关。例如,我们编写的用于生成斐波那契序列的协程相当简单,但我们必须实现包装类型、承诺以及所有使其可用的函数。

  • 开发 plain C++20 协程需要了解 C++ 中协程实现的底层细节,包括编译器如何将我们的代码转换为实现保持协程状态所需的所有机制,以及我们必须实现的功能的调用方式和时机。

异步编程本身就足够复杂,无需那么多细节。如果我们能专注于我们的程序,并从底层概念和代码中隔离出来,那就更好了。我们看到了 C++23 如何引入 std::generator 来实现这一点。让我们只写生成器代码,让 C++ 标准库和编译器处理其余部分。预计在下一个 C++ 版本中,这种协程支持将得到改进。

Boost.Cobalt 是 Boost C++ 库中包含的库之一,它允许我们做到这一点——避免协程的细节。Boost.Cobalt 在 Boost 1.84 中引入,并需要 C++20,因为它依赖于语言协程功能。它基于 Boost.Asio,我们可以在程序中使用这两个库。

Boost.Cobalt 的目标是让我们能够使用协程编写简单的单线程异步代码——可以在单个线程中同时执行多项任务的应用程序。当然,当我们说“同时”时,我们是指并发,而不是并行,因为只有一个线程。通过使用 Boost.Asio 的多线程功能,我们可以在不同的线程中执行协程,但在这个章节中,我们将专注于单线程应用程序。

急切协程和懒协程

在介绍 Boost.Cobalt 实现的协程类型之前,我们需要定义两种协程类型:

  • 急切协程:急切协程在调用时立即开始执行。这意味着协程逻辑会立即开始运行,并一直运行到遇到挂起点(例如 co_awaitco_yield)。协程的创建实际上启动了其处理过程,并且其主体中的任何副作用都会立即执行。

    当你希望协程在创建时立即开始其工作,急切协程是有益的,例如启动异步网络操作或准备数据。

  • 懒协程:懒协程会延迟其执行,直到被显式地等待或使用。协程对象可以在其主体中的任何代码运行之前被创建,直到调用者决定与之交互(通常是通过使用co_await来等待它)。

    当你需要设置一个协程但希望延迟其执行,直到满足某个条件,或者需要与其他任务协调其执行时,懒协程非常有用。

在定义了急切协程和懒协程之后,我们将描述 Boost.Cobalt 中实现的不同类型的协程。

Boost.Cobalt 协程类型

Boost.Cobalt 实现了四种类型的协程。我们将在本节中介绍它们,并在本章后面的部分给出一些示例:

  • 承诺:这是 Boost.Cobalt 中的主要协程类型。它用于实现返回单个值的异步操作(调用 co_return)。它是一个急切协程。它支持 co_await,允许异步挂起和继续。例如,承诺可以用来执行网络调用,当完成时,将返回其结果而不会阻塞其他操作。

  • 任务:任务是对承诺的懒实现。它将不会开始执行,直到被显式等待。它提供了更多的灵活性来控制协程何时以及如何运行。当被等待时,任务开始执行,允许延迟处理异步操作。

  • 生成器:在 Boost.Cobalt 中,生成器是唯一可以产生值的协程类型。每个值都是通过 co_yield 单独产生的。它的功能类似于 C++23 中的 std::generator,但它允许使用 co_await 等待(std::generator 不支持)。

  • 分离的:这是一个急切协程,可以使用 co_await 但不能返回 co_return 值。它不能被恢复,通常也不被等待。

到目前为止,我们介绍了 Boost.Cobalt。我们定义了急切和懒协程是什么,然后我们定义了库中的四种主要协程类型。

在下一节中,我们将深入探讨与 Boost.Cobalt 相关的最重要的主题之一——生成器。我们还将实现一些简单的生成器示例。

Boost.Cobalt 生成器

如在第 第八章 中所述,生成器协程是专门设计的协程,用于逐步产生值。在产生每个值之后,协程会暂停自身,直到调用者请求下一个值。在 Boost.Cobalt 中,生成器以相同的方式工作。它们是唯一可以产生值的协程类型。这使得生成器在您需要协程在一段时间内产生多个值时变得至关重要。

Boost.Cobalt 生成器的一个关键特性是它们默认是急切的,这意味着它们在被调用后立即开始执行。此外,这些生成器是异步的,允许它们使用 co_await,这是与 C++23 中引入的 std::generator 的重要区别,后者是懒的且不支持 co_await

查看基本示例

让我们从最简单的 Boost.Cobalt 程序开始。这个例子不是生成器的例子,但我们将借助它解释一些重要细节:

#include <iostream>
#include <boost/cobalt.hpp>
boost::cobalt::main co_main(int argc, char* argv[]) {
    std::cout << "Hello Boost.Cobalt\n";
    co_return 0;
}

在前面的代码中,我们观察到以下内容:

  • 要使用 Boost.Cobalt,必须包含 <boost/cobalt.hpp> 头文件。

  • 您还必须将 Boost.Cobalt 库链接到您的应用程序。我们提供了一个 CMakeLists.txt 文件来完成这项工作,不仅适用于 Boost.Cobalt,还适用于所有必需的 Boost 库。要显式地链接 Boost.Cobalt(即不是所有必需的 Boost 库),只需将以下行添加到您的 CMakeLists.txt 文件中:

    target_link_libraries(${EXEC_NAME} Boost::cobalt)
    
  • 使用co_main函数。与常规的main函数不同,Boost.Cobalt 引入了一个基于协程的入口点,称为co_main。这个函数可以使用协程特定的关键字,如co_return。Boost.Cobalt 内部实现了所需的main函数。

    使用co_main将允许您将程序的main函数(入口点)实现为协程,从而能够调用co_awaitco_return。记住,从第八章中,main函数不能是协程。

    如果您无法更改当前的函数,可以使用 Boost.Cobalt。您只需从main函数中调用一个函数,这个函数将成为您使用 Boost.Cobalt 的异步代码的最高级函数。实际上,这正是 Boost.Cobalt 所做的事情:它实现了一个函数,这是程序的入口点,并且(对您隐藏的)这个函数调用了co_main

    使用您自己的main函数的最简单方法可能如下所示:

    cobalt::task<int> async_task() {
        // your code here
        // …
        return 0;
    }
    int main() {
        // main function code
        // …
        return cobalt::run(async_code();
    }
    

示例简单地打印一条问候消息,然后通过调用co_await返回 0。在所有未来的例子中,我们将遵循这个模式:包含<boost/cobalt.hpp>头文件,并使用co_main而不是main

Boost.Cobalt 简单生成器

在我们之前的基本例子中获得的知识的基础上,我们将实现一个非常简单的生成器协程:

#include <chrono>
#include <iostream>
#include <boost/cobalt.hpp>
using namespace std::chrono_literals;
using namespace boost;
cobalt::generator<int> basic_generator()
{
    std::this_thread::sleep_for(1s);
    co_yield 1;
    std::this_thread::sleep_for(1s);
    co_return 0;
}
cobalt::main co_main(int argc, char* argv[]) {
    auto g = basic_generator();
    std::cout << co_await g << std::endl;
    std::cout << co_await g << std::endl;
    co_return 0;
}

上述代码展示了一个简单的生成器,它产生一个整数值(使用co_yield)并返回另一个值(使用co_return)。

cobalt::generator是一个struct模板:

template<typename Yield, typename Push = void>
struct generator

两个参数类型如下:

  • Yield:生成的对象类型

  • Push:输入参数类型(默认为void

co_main函数在通过co_await获取数值后打印这两个数(调用者等待数值可用)。我们已经引入了一些延迟来模拟生成器必须执行的处理以生成这些数字。

我们的第二个生成器将产生一个整数的平方:

#include <chrono>
#include <iostream>
#include <boost/cobalt.hpp>
using namespace std::chrono_literals;
using namespace boost;
cobalt::generator<int, int> square_generator(int x){
    while (x != 0) {
        x = co_yield x * x;
    }
    co_return 0;
}
cobalt::main co_main(int argc, char* argv[]){
    auto g = square_generator(10);
    std::cout << co_await g(4) << std::endl;
    std::cout << co_await g(12) << std::endl;
    std::cout << co_await g(0) << std::endl;
    co_return 0;
}

在这个例子中,square_generator产生x参数的平方。这展示了我们如何将值推送到 Boost.Cobalt 生成器。在 Boost.Cobalt 中,将值推送到生成器意味着传递参数(在先前的例子中,传递的参数是整数)。

在这个例子中,尽管生成器是正确的,但可能会令人困惑。请看以下代码行:

auto g = square_generator(10);

这创建了一个初始值为10的生成器对象。然后,看看以下代码行:

std::cout << co_await g(4) << std::endl;

这将打印10的平方并将4推送到生成器。正如你所看到的,打印的值不是传递给生成器的值的平方。这是因为生成器初始化时有一个值(在这个例子中,10),当调用者调用co_await传递另一个值时,它将生成平方值。当接收到新值4时,生成器将产生100,然后当接收到12的值时,它将产生16,依此类推。

我们说过,Boost.Cobalt 生成器是急切的,但它们在开始执行时可以等待(co_await)。以下示例展示了如何做到这一点:

#include <iostream>
#include <boost/cobalt.hpp>
boost::cobalt::generator<int, int> square_generator() {
    auto x = co_await boost::cobalt::this_coro::initial;
    while (x != 0) {
        x = co_yield x * x;
    }
    co_return 0;
}
boost::cobalt::main co_main(int, char*[]) {
    auto g = square_generator();
    std::cout << co_await g(4) << std::endl;
    std::cout << co_await g(10) << std::endl;
    std::cout << co_await g(12) << std::endl;
    std::cout << co_await g(0) << std::endl;
    co_return 0;
}

代码与上一个示例非常相似,但有一些不同:

  • 我们创建生成器时没有传递任何参数给它:

    auto g = square_generator();
    
  • 看一下生成器代码的第一行:

    auto x = co_await boost::cobalt::this_coro::initial;
    

    这使得生成器等待第一个推入的整数。这表现得像一个惰性生成器(实际上,它立即开始执行,因为生成器是急切的,但它首先做的事情是等待一个整数)。

  • 产生的值是我们从代码中期望得到的:

    std::cout << co_await g(10) << std::endl;
    

    这将打印100而不是之前推入整数的平方。

让我们在这里总结一下示例做了什么:co_main函数调用square_generator协程生成整数的平方。生成器协程在开始时挂起等待第一个整数,并在产生每个平方后再次挂起。这个例子故意简单,只是为了说明如何使用 Boost.Cobalt 编写生成器。

前一个程序的一个重要特性是它在单个线程中运行。这意味着co_main和生成器协程一个接一个地运行。

一个斐波那契数列生成器

在本节中,我们将实现一个类似于我们在第八章中实现的斐波那契数列生成器。这将让我们看到使用 Boost.Cobalt 编写生成器协程比使用纯 C++20(不使用任何协程库)要容易多少。

我们编写了两个版本的生成器。第一个计算斐波那契数列的任意项。我们推入我们想要生成的项,然后我们得到它。这个生成器使用 lambda 作为斐波那契计算器:

boost::cobalt::generator<int, int> fibonacci_term() {
    auto fibonacci = [](int n) {
        if (n < 2) {
            return n;
        }
        int f0 = 0;
        int f1 = 1;
        int f;
        for (int i = 2; i <= n; ++i) {
            f = f0 + f1;
            f0 = f1;
            f1 = f;
        }
        return f;
    };
    auto x = co_await boost::cobalt::this_coro::initial;
    while (x != -1) {
        x = co_yield fibonacci(x);
    }
    co_return 0;
 }

在前面的代码中,我们看到这个生成器与我们之前章节中用于计算数字平方的生成器非常相似。在协程的开始,我们有以下内容:

auto x = co_await boost::cobalt::this_coro::initial;

这行代码使协程挂起以等待第一个输入值。

然后我们有以下内容:

while (x != -1) {
        x = co_yield fibonacci(x);
    }

这生成所需的斐波那契数列项,并在请求下一个项之前挂起。当请求的项不等于-1时,我们可以继续请求更多值,直到推入-1终止协程。

下一个版本的斐波那契生成器将在需要时产生无限多个项。当我们说“无限”时,我们是指“潜在无限”。将这个生成器想象成总是准备好产生下一个斐波那契数列的数字:

boost::cobalt::generator<int> fibonacci_sequence() {
    int f0 = 0;
    int f1 = 1;
    int f = 0;
    while (true) {
        co_yield f0;
        f = f0 + f1;
        f0 = f1;
        f1 = f;
    }
}

前面的代码很容易理解:协程产生一个值并暂停自己,直到另一个值被请求,然后协程计算新值并产生它,再次在无限循环中暂停自己。

在这种情况下,我们可以看到协程的优势:我们可以在需要时逐个生成斐波那契数列的项。我们不需要保持任何状态来生成下一个项,因为状态被保存在协程中。

还要注意,即使函数执行了无限循环,因为它是一个协程,它会暂停并再次恢复,从而避免阻塞当前线程。

Boost.Cobalt 任务和承诺

正如我们在本章中已经看到的,Boost.Cobalt 的承诺是急切协程,它们返回一个值,而 Boost.Cobalt 的任务是承诺的懒版本。

我们可以将其视为只是函数,不像生成器那样产生多个值。我们可以多次调用承诺以获取多个值,但调用之间不会保持状态(就像生成器中那样)。基本上,承诺是一个可以使用co_await(它也可以使用co_return)的协程。

承诺的不同用法可能是一个套接字监听器,用于接收网络数据包,处理它们,对数据库进行查询,然后从数据中生成一些结果。一般来说,它们的功能需要异步等待某个结果,然后对该结果进行一些处理(或者可能只是将其返回给调用者)。

我们的第一个例子是一个简单的承诺,它生成一个随机数(这也可以用生成器来完成):

#include <iostream>
#include <random>
#include <boost/cobalt.hpp>
boost::cobalt::promise<int> random_number(int min, int max) {
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> dist(min, max);
    co_return dist(gen);
}
boost::cobalt::promise<int> random(int min, int max) {
    int res = co_await random_number(min, max);
    co_return res;
}
boost::cobalt::main co_main(int, char*[]) {
    for (int i = 0; i < 10; ++i) {
        auto r = random(1, 100);
        std::cout << "random number between 1 and 100: "
                  << co_await r << std::endl;
    }
    co_return 0;
}

在前面的代码中,我们已经编写了三个协程:

  • co_main:记住在 Boost.Cobalt 中,co_main是一个协程,它调用co_return来返回一个值。

  • random():这个协程返回一个随机数给调用者。它使用co_await调用random()来生成随机数。它异步等待随机数的生成。

  • random_number():这个协程生成两个值minmax之间的均匀分布随机数,并将其返回给调用者。random_number()也是一个承诺。

下面的协程返回一个包含随机数的std::vector。在循环中调用co_await random_number()来生成一个包含n个随机数的向量:

boost::cobalt::promise<std::vector<int>> random_vector(int min, int max, int n) {
    std::vector<int> rv(n);
    for (int i = 0; i < n; ++i) {
        rv[i] = co_await random_number(min, max);
    }
    co_return rv;
}

前面的函数返回一个std::vector的承诺。要访问这个向量,我们需要调用get()

auto v = random_vector(1, 100, 20);
for (int n : v.get()) {
    std::cout << n << " ";
}
std::cout << std::endl;

之前的代码打印了v向量的元素。要访问这个向量,我们需要调用v.get()

我们将实现第二个示例来展示承诺和任务的执行有何不同:

#include <chrono>
#include <iostream>
#include <thread>
#include <boost/cobalt.hpp>
void sleep(){
    std::this_thread::sleep_for(std::chrono::seconds(2));
}
boost::cobalt::promise<int> eager_promise(){
    std::cout << "Eager promise started\n";
    sleep();
    std::cout << "Eager promise done\n";
    co_return 1;
}
boost::cobalt::task<int> lazy_task(){
    std::cout << "Lazy task started\n";
    sleep();
    std::cout << "Lazy task done\n";
    co_return 2;
}
boost::cobalt::main co_main(int, char*[]){
    std::cout << "Calling eager_promise...\n";
    auto promise_result = eager_promise();
    std::cout << "Promise called, but not yet awaited.\n";
    std::cout << "Calling lazy_task...\n";
    auto task_result = lazy_task();
    std::cout << "Task called, but not yet awaited.\n";
    std::cout << "Awaiting both results...\n";
    int promise_value = co_await promise_result;
    std::cout << "Promise value: " << promise_value
              << std::endl;
    int task_value = co_await task_result;
    std::cout << "Task value: " << task_value
              << std::endl;
    co_return 0;
}

在这个例子中,我们实现了两个协程:一个承诺(promise)和一个任务(task)。正如我们之前所说的,承诺是急切的,它一旦被调用就开始执行。任务则是懒加载的,在被调用后会被挂起。

当我们运行程序时,它会打印出所有消息,这让我们确切地知道协程是如何执行的。

执行完 co_main() 的前三行后,打印的输出如下:

Calling eager_promise...
Eager promise started
Eager promise done
Promise called, but not yet awaited.

从这些消息中,我们知道承诺已经执行到调用 co_return 的位置。

执行完 co_main() 的下一三行后,打印的输出有这些新消息:

Calling lazy_task...
Task called, but not yet awaited.

在这里,我们看到任务尚未执行。它是一个懒加载的协程,因此,在被调用后立即挂起,并且这个协程还没有打印任何消息。

执行了三行更多的 co_main() 代码,这些是新消息,程序输出的内容如下:

Awaiting both results...
Promise value: 1

在承诺上调用 co_await 会给我们其结果(在这个例子中,设置为 1)并且执行结束。

最后,我们在任务上调用 co_await,然后它执行并返回其值(在这个例子中,设置为 2)。输出如下:

Lazy task started
Lazy task done
Task value: 2

这个例子展示了任务是如何懒加载的,开始时是挂起的,并且只有在调用者对它们调用 co_await 时才会恢复执行。

在本节中,我们看到了,就像生成器的情况一样,使用 Boost.Cobalt 比仅仅使用纯 C++ 更容易编写承诺和任务协程。我们不需要编写 C++ 实现协程所需的所有支持代码。我们也看到了任务和承诺之间的主要区别。

在下一节中,我们将研究一个通道的例子,这是一个在生产者/消费者模型中两个协程之间的通信机制。

Boost.Cobalt 通道

在 Boost.Cobalt 中,通道为协程提供了异步通信的方式,允许生产者和消费者协程之间以安全且高效的方式进行数据传输。它们受到了 Golang 通道的启发,并允许通过消息传递进行通信,促进了一种“通过通信共享内存”的范式。

通道是一种机制,通过它,值可以从一个协程(生产者)异步地传递到另一个协程(消费者)。这种通信是非阻塞的,这意味着协程在等待通道上有可用数据时可以挂起它们的执行,或者在向具有有限容量的通道写入数据时也可以挂起。让我们澄清一下:如果“阻塞”意味着协程被挂起,那么读取和写入操作可能会根据缓冲区大小而阻塞,但另一方面,从线程的角度来看,这些操作不会阻塞线程。

如果缓冲区大小为零,读取和写入将需要同时发生并作为 rendezvous(同步通信)。如果通道大小大于零且缓冲区未满,写入操作不会挂起协程。同样,如果缓冲区不为空,读取操作也不会挂起。

类似于 Golang 的通道,Boost.Cobalt 的通道是强类型的。通道为特定类型定义,并且只能通过它发送该类型的数据。例如,int 类型的通道(boost::cobalt::channel)只能传输整数。

现在让我们看看一个通道的示例:

#include <iostream>
#include <boost/cobalt.hpp>
#include <boost/asio.hpp>
boost::cobalt::promise<void> producer(boost::cobalt::channel<int>& ch) {
    for (int i = 1; i <= 10; ++i) {
        std::cout << "Producer waiting for request\n";
        co_await ch.write(i);
        std::cout << "Producing value " << i << std::endl;
    }
    std::cout << "Producer end\n";
    ch.close();
    co_return;
}
boost::cobalt::main co_main(int, char*[]) {
    boost::cobalt::channel<int> ch;
    auto p = producer(ch);
    while (ch.is_open()) {
        std::cout << "Consumer waiting for next number \n";
        std::this_thread::sleep_for(std::chrono::seconds(5));
        auto n = co_await ch.read();
        std::cout << "Consuming value " << n << std::endl;
        std::cout << n * n << std::endl;
    }
    co_await p;
    co_return 0;
}

在这个示例中,我们创建了一个大小为 0 的通道和两个协程:生产者承诺和作为消费者的 co_main()。生产者将整数写入通道,消费者读取它们并将它们平方后打印出来。

我们添加了 **std::this_thread::sleep** 来延迟程序执行,从而能够看到程序运行时的状态。让我们看看示例输出的摘录,看看它是如何工作的:

Producer waiting for request
Consumer waiting for next number
Producing value 1
Producer waiting for request
Consuming value 1
1
Consumer waiting for next number
Producing value 2
Producer waiting for request
Consuming value 2
4
Consumer waiting for next number
Producing value 3
Producer waiting for request
Consuming value 3
9
Consumer waiting for next number

消费者和生产者都等待下一个动作发生。生产者将始终等待消费者请求下一个项目。这基本上是生成器的工作方式,并且在使用协程的异步代码中是一个非常常见的模式。

消费者执行以下代码行:

auto n = co_await ch.read();

然后,生产者将下一个数字写入通道并等待下一个请求。这是在以下代码行中完成的:

co_await ch.write(i);

你可以在上一段输出摘录的第四行中看到生产者如何返回等待下一个请求。

Boost.Cobalt 通道使得编写这种异步代码非常清晰且易于理解。

示例显示了两个协程通过通道进行通信。

这部分内容就到这里。下一部分将介绍同步函数——等待多个协程的机制。

Boost.Cobalt 同步函数

之前,我们实现了协程,并且在每次调用 **co_await** 的时候,我们只为一个协程调用。这意味着我们只等待一个协程的结果。Boost.Cobalt 有机制允许我们等待多个协程。这些机制被称为 同步函数

Boost.Cobalt 实现了四个同步函数:

  • racerace 函数等待一组协程中的一个完成,但它以伪随机的方式进行。这种机制有助于避免协程的饥饿,确保一个协程不会在执行流程上主导其他协程。当你有多个异步操作,并且想要第一个完成以确定流程时,race 将允许任何准备就绪的协程以非确定性的顺序继续执行。

    当你有多个任务(在通用意义上,不是 Boost.Cobalt 任务)并且对完成其中一个感兴趣,没有偏好哪个,但想要防止在准备就绪同时发生的情况下一个协程总是获胜时,你会使用race

  • joinjoin函数等待给定集合中的所有协程完成,并返回它们的值。如果任何一个协程抛出异常,join将把异常传播给调用者。这是一种从多个异步操作中收集结果的方法,这些操作必须在继续之前全部完成。

    当你需要多个异步操作的结果一起,并且如果任何一个操作失败则想要抛出错误时,你会使用join

  • gather:与join类似,gather函数等待一组协程完成,但它处理异常的方式不同。当其中一个协程失败时,gather不会立即抛出异常,而是单独捕获每个协程的结果。这意味着你可以独立检查每个协程的输出(成功或失败)。

    当你需要所有异步操作都完成,但想要单独捕获所有结果和异常以分别处理时,你会使用gather

  • left_raceleft_race函数类似于race,但具有确定性行为。它从左到右评估协程,并等待第一个协程准备好。当协程完成的顺序很重要,并且你想要基于它们提供的顺序确保可预测的结果时,这可能很有用。

    当你有多个潜在的结果,并且需要优先考虑提供的顺序中的第一个可用的协程,使行为比race更可预测时,你会使用left_race

在本节中,我们将探讨joingather函数的示例。正如我们所见,这两个函数都等待一组协程完成。它们之间的区别在于,如果任何一个协程抛出异常,join将抛出一个异常,而gather总是返回所有等待的协程的结果。在gather函数的情况下,每个协程的结果将要么是一个错误(缺失值),要么是一个值。join返回一个值元组或抛出一个异常;gather返回一个可选值元组,在发生异常的情况下没有值(可选变量未初始化)。

以下示例的完整代码在 GitHub 仓库中。在这里,我们将关注主要部分。

我们定义了一个简单的函数来模拟数据处理,它仅仅是一个延迟。如果传递的延迟大于 5,000 毫秒,该函数将抛出一个异常:

boost::cobalt::promise<std::chrono::milliseconds::rep> process(std::chrono::milliseconds ms) {
    if (ms > std::chrono::milliseconds(5000)) {
        throw std::runtime_error("delay throw");
    }
    boost::asio::steady_timer tmr{ co_await boost::cobalt::this_coro::executor, ms };
    co_await tmr.async_wait(boost::cobalt::use_op);
    co_return ms.count();
}

该函数是一个 Boost.Cobalt 承诺。

现在,在代码的下一节中,我们将等待这个承诺的三个实例运行:

auto result = co_await boost::cobalt::join(process(100ms),
                                           process(200ms),
                                           process(300ms));
std::cout << "First coroutine finished in: "
          <<  std::get<0>(result) << "ms\n";
std::cout << "Second coroutine took finished in: "
          <<  std::get<1>(result) << "ms\n";
std::cout << "Third coroutine took finished in: "
         <<  std::get<2>(result) << "ms\n";

前面的代码调用join等待三个协程完成,然后打印它们所花费的时间。正如你所看到的,结果是元组,为了使代码尽可能简单,我们只为每个元素调用std::get(result)。在这种情况下,所有处理时间都在有效范围内,没有抛出异常,因此我们可以获取所有已执行协程的结果。

如果抛出异常,则我们不会得到任何值:

try {
    auto result throw = co_await
    boost::cobalt::join(process(100ms),
                        process(20000ms),
                        process(300ms));
}
catch (...) {
    std::cout << "An exception was thrown\n";
}

前面的代码将抛出异常,因为第二个协程接收到的处理时间超出了有效范围。它将打印一条错误信息。

当调用join函数时,我们希望所有协程都被视为处理的一部分,并且在发生异常的情况下,整个处理失败。

如果我们需要获取每个协程的所有结果,我们将使用gather函数:

try
    auto result throw =
    boost::cobalt::co_await lt::gather(process(100ms),
                                       process(20000ms),
                                       process(300ms));
    if (std::get<0>(result throw).has value()) {
        std::cout << "First coroutine took: "
                  <<  *std::get<0>(result throw)
                  << "msec\n";
    }
    else {
        std::cout << "First coroutine threw an exception\n";
    }
    if (std::get<1>(result throw).has value()) {
        std::cout << "Second coroutine took: "
                  <<  *std::get<1>(result throw)
                  << "msec\n";
    }
    else {
        std::cout << "Second coroutine threw an exception\n";
    }
    if (std::get<2>(result throw).has value()) {
        std::cout << "Third coroutine took: "
                  <<  *std::get<2>(result throw)
                  << "msec\n";
    }
    else {
        std::cout << "Third coroutine threw an exception\n";
    }
}
catch (...) {
    // this is never reached because gather doesn't throw exceptions
    std::cout << "An exception was thrown\n";
}

我们将代码放在了try-catch块中,但没有抛出异常。gather函数返回一个可选值的元组,我们需要检查每个协程是否返回了值(可选值是否有值)。

当我们希望协程在成功执行时返回一个值时,我们使用gather

这些joingather函数的例子结束了我们对 Boost.Cobalt 同步函数的介绍。

摘要

在本章中,我们看到了如何使用 Boost.Cobalt 库实现协程。它最近才被添加到 Boost 中,关于它的信息并不多。它简化了使用协程异步代码的开发,避免了编写 C++20 协程所需的底层代码。

我们研究了主要库概念,并开发了一些简单的示例来理解它们。

使用 Boost.Cobalt,使用协程编写异步代码变得简单。C++中编写协程的所有底层细节都由库实现,我们可以专注于我们想要在程序中实现的功能。

在下一章中,我们将看到如何调试异步代码。

进一步阅读

第五部分:异步编程中的调试、测试和性能优化

在本最终部分,我们专注于调试、测试和优化多线程和异步程序性能的基本实践。我们将首先使用日志记录和高级调试工具和技术,包括反向调试和代码清理器,来识别和解决异步应用程序中的微妙错误,例如崩溃、死锁、竞态条件、内存泄漏和线程安全问题,随后使用 GoogleTest 框架针对异步代码制定测试策略。最后,我们将深入性能优化,理解诸如缓存共享、伪共享以及如何缓解性能瓶颈等关键概念。掌握这些技术将为我们提供一套全面的工具集,用于识别、诊断和改进异步应用程序的质量和性能。

本部分包含以下章节:

  • 第十一章异步软件的日志记录和调试

  • 第十二章清理和测试异步软件

  • 第十三章提高异步软件性能

第十一章:异步软件的日志和调试

没有办法确保软件产品完全没有错误,所以有时会出现错误。这时,日志和调试是必不可少的。

日志和调试对于识别和诊断软件系统中的问题至关重要。它们提供了对代码运行时行为的可见性,帮助开发者追踪错误、监控性能以及理解执行流程。通过有效地使用日志和调试,开发者可以检测到错误、解决意外行为,并提高整体系统的稳定性和可维护性。

在编写本章时,我们假设你已经熟悉使用调试器调试 C++程序,并了解一些基本的调试器命令和术语,例如断点、监视器、帧或堆栈跟踪。为了复习这些知识,你可以参考章节末尾的进一步阅读部分提供的参考资料。

在本章中,我们将涵盖以下主要主题:

  • 如何使用日志来查找错误

  • 如何调试异步软件

技术要求

对于本章,我们需要安装第三方库来编译示例。

要编译日志部分中的示例,需要安装spdlog{fmt}库。请检查它们的文档(spdlog的文档可在github.com/gabime/spdlog找到,{fmt}的文档可在github.com/fmtlib/fmt找到),并按照适合您平台的安装步骤进行操作。

一些示例需要支持 C++20 的编译器。因此,请检查第三章中的技术要求部分,其中提供了一些关于如何安装 GCC 13 和 Clang 8 编译器的指导。

你可以在以下 GitHub 仓库中找到所有完整的代码:

github.com/PacktPublishing/Asynchronous-Programming-with-CPP

本章的示例位于Chapter_11文件夹下。所有源代码文件都可以使用以下命令使用 CMake 编译:

$ cmake . && cmake —build .

可执行二进制文件将在bin目录下生成。

如何使用日志来查找错误

让我们从理解软件程序在执行时做了什么的简单但有用方法开始——日志。

日志是记录程序中发生的事件的过程,通过使用消息记录程序如何执行,跟踪其流程,并帮助识别问题和错误。

大多数基于 Unix 的日志系统使用由 Eric Altman 在 1980 年作为 Sendmail 项目一部分创建的标准协议syslog。这个标准协议定义了生成日志消息的软件、存储它们的系统和报告和分析这些日志事件的软件之间的边界。

每条日志消息都包含一个设施代码和严重级别。设施代码标识了产生特定日志消息的系统类型(用户级、内核、系统、网络等),严重级别描述了系统的状态,表明处理特定问题的紧迫性,严重级别包括紧急、警报、关键、错误、警告、通知、信息和调试。

大多数日志系统或日志记录器都提供了各种日志消息的目的地或接收器:控制台、可以稍后打开和分析的文件、远程 syslog 服务器或中继,以及其他目的地。

在调试器无法使用的地方,日志非常有用,正如我们稍后将看到的,特别是在分布式、多线程、实时、科学或以事件为中心的应用程序中,使用调试器检查数据或跟踪程序流程可能变得是一项繁琐的任务。

日志库通常还提供一个线程安全的单例类,允许多线程和异步写入日志文件,有助于日志轮转,通过动态创建新文件而不丢失日志事件来避免大型日志文件,并添加时间戳,以便更好地跟踪日志事件发生的时间。

而不是实现我们自己的多线程日志系统,更好的方法是使用一些经过良好测试和文档化的生产就绪库。

如何选择第三方库

在将日志库(或任何其他库)集成到我们的软件之前,我们需要调查以下问题,以避免未来出现的问题:

  • 支持:库是否定期更新和升级?是否有社区或活跃的生态系统围绕该库,可以帮助解决可能出现的任何问题?社区是否对使用该库感到满意?

  • 质量:是否存在公开的缺陷报告系统?缺陷报告是否得到及时处理,提供解决方案并修复库中的缺陷?它是否支持最近的编译器版本并支持最新的 C++特性?

  • 安全性:库或其任何依赖库是否有已报告的漏洞?

  • 许可证:库的许可证是否与我们的开发和产品需求一致?成本是否可承受?

对于复杂系统,考虑集中式系统来收集和生成日志报告或仪表板可能是值得的,例如 Sentry (sentry.io) 或 Logstash (www.elastic.co/logstash),它们可以收集、解析和转换日志,并且可以与其他工具集成,如 Graylog (graylog.org)、Grafana (grafana.com) 或 Kibana (www.elastic.co/kibana)。

下一个部分将描述一些有趣的日志库。

一些相关的日志库

市场上有许多日志库,每个库都覆盖了一些特定的软件需求。根据程序约束和需求,以下库中的一些可能比其他库更适合。

第九章 中,我们探讨了 Boost.Asio。Boost 还提供了另一个库,Boost.Loggithub.com/boostorg/log),这是一个强大且可配置的日志库。

Google 也提供了许多开源库,包括 glog,Google 日志库(github.com/google/glog),这是一个 C++14 库,提供了 C++ 风格的流 API 和辅助宏。

如果开发者熟悉 Java,一个不错的选择可能是基于 Log4jlogging.apache.org/log4j)的 Apache Log4cxxlogging.apache.org/log4cxx),这是一个多才多艺、工业级、Java 日志框架。

值得考虑的其他日志库如下:

作为指导,根据要开发的系统,我们可能会选择以下库之一:

  • 对于低延迟或实时系统:Quill、XTR 或 Reckless

  • 对于纳秒级性能的日志:NanoLog

  • 对于异步日志:Quill 或 lwlog

  • 对于跨平台、多进程 应用程序uberlog**

  • 对于简单灵活的日志:Easylogging++ 或 glog

  • **对于熟悉 Java 日志:Log4cxx

所有库都有优点,但也存在需要在使用前调查的缺点。以下表格总结了这些要点:

优点 缺点
spdlog 简单集成,性能导向,可定制 缺乏一些针对极低延迟需求的高级功能
Quill 在低延迟系统中性能高 相比于更简单的同步日志记录器,设置更复杂
NanoLog 在速度上表现最佳,针对性能优化 功能有限;适用于专用用例
lwlog 轻量级,适合快速集成 相比于其他替代方案,成熟度和功能较少
XTR 非常高效,用户界面友好 更适合特定的实时应用
Reckless 高度优化吞吐量和低延迟 相比于更通用的日志记录器,灵活性有限
uberlog 适用于多进程和分布式系统 不如专门的低延迟日志记录器快
Easylogging++ 使用简单,可自定义输出目标 性能优化不如一些其他库
tracetool 将日志和跟踪结合在一个库中 不专注于低延迟或高吞吐量
Boost.Log 通用性强,与 Boost 库集成良好 复杂度较高;对于简单的日志需求可能过于复杂
glog 使用简单,适合需要简单 API 的项目 对于高级定制功能不如其他库丰富
Log4cxx 稳定,经过时间考验,工业级日志 设置较为复杂,特别是对于小型项目

表 11.1:各种库的优点和缺点

请访问日志库的网站以更好地了解它们提供的功能,并比较它们之间的性能。

由于 spdlog 是 GitHub 上被分叉和星标最多的 C++ 日志库仓库,在下一节中,我们将实现一个使用此库来捕获竞态条件的示例。

记录死锁 - 示例

在实现此示例之前,我们需要安装 spdlog{fmt} 库。{fmt} (https://github.com/fmtlib/fmt) 是一个开源格式化库,提供了一种快速且安全的 C++ IOStreams 替代方案。

请检查它们的文档,并根据您的平台遵循安装步骤。

让我们实现一个发生死锁的示例。正如我们在 第四章 中所学,当两个或更多线程需要获取多个互斥锁以执行其工作时会发生死锁。如果互斥锁不是以相同的顺序获取,一个线程可以获取一个互斥锁并永远等待另一个线程获取的互斥锁。

在这个例子中,两个线程需要获取两个互斥锁,mtx1mtx2,以增加 counter1counter2 计数器的值并交换它们。由于线程以不同的顺序获取互斥锁,可能会发生死锁。

让我们先包含所需的库:

#include <fmt/core.h>
#include <spdlog/sinks/basic_file_sink.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/spdlog.h>
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
using namespace std::chrono_literals;

main() 函数中,我们定义了计数器和互斥锁:

uint32_t counter1{};
std::mutex mtx1;
uint32_t counter2{};
std::mutex mtx2;

在生成线程之前,让我们设置一个多目标记录器,这是一种可以将日志消息写入控制台和日志文件的记录器。我们还将设置其日志级别为调试,使记录器发布所有严重性级别大于调试的日志消息,每行日志的格式包括时间戳、线程标识符、日志级别和日志消息:

auto console_sink = std::make_shared<
         spdlog::sinks::stdout_color_sink_mt>();
console_sink->set_level(spdlog::level::debug);
auto file_sink = std::make_shared<
         spdlog::sinks::basic_file_sink_mt>("logging.log",
                                            true);
file_sink->set_level(spdlog::level::info);
spdlog::logger logger("multi_sink",
         {console_sink, file_sink});
logger.set_pattern(
         "%Y-%m-%d %H:%M:%S.%f - Thread %t [%l] : %v");
logger.set_level(spdlog::level::debug);

我们还声明了一个increase_and_swap lambda 函数,该函数增加两个计数器的值并交换它们:

auto increase_and_swap = [&]() {
    logger.info("Incrementing both counters...");
    counter1++;
    counter2++;
    logger.info("Swapping counters...");
    std::swap(counter1, counter2);
};

两个工作 lambda 函数worker1worker2获取两个互斥锁,并在退出前调用increase_and_swap()。由于使用了锁保护(std::lock_guard)对象,因此在销毁工作 lambda 函数时释放互斥锁:

auto worker1 = [&]() {
    logger.debug("Entering worker1");
    logger.info("Locking mtx1...");
    std::lock_guard<std::mutex> lock1(mtx1);
    logger.info("Mutex mtx1 locked");
    std::this_thread::sleep_for(100ms);
    logger.info("Locking mtx2...");
    std::lock_guard<std::mutex> lock2(mtx2);
    logger.info("Mutex mtx2 locked");
    increase_and_swap();
    logger.debug("Leaving worker1");
};
auto worker2 = [&]() {
    logger.debug("Entering worker2");
    logger.info("Locking mtx2...");
    std::lock_guard<std::mutex> lock2(mtx2);
    logger.info("Mutex mtx2 locked");
    std::this_thread::sleep_for(100ms);
    logger.info("Locking mtx1...");
    std::lock_guard<std::mutex> lock1(mtx1);
    logger.info("Mutex mtx1 locked");
    increase_and_swap();
    logger.debug("Leaving worker2");
};
logger.debug("Starting main function...");
std::thread t1(worker1);
std::thread t2(worker2);
t1.join();
t2.join();

两个工作 lambda 函数worker1worker2相似,但有一个小差异:worker1先获取mutex1然后获取mutex2,而worker2则相反,先获取mutex2然后获取mutex1。在获取两个互斥锁之间有一个睡眠期,以便其他线程获取其互斥锁,因此,这会导致死锁,因为worker1将获取mutex1worker2将获取mutex2

然后,在睡眠之后,worker1将尝试获取mutex2,而worker2将尝试获取mutex1,但它们都不会成功,永远在死锁中阻塞。

以下是在运行此代码时的输出:

2024-09-04 23:39:54.484005 - Thread 38984 [debug] : Starting main function...
2024-09-04 23:39:54.484106 - Thread 38985 [debug] : Entering worker1
2024-09-04 23:39:54.484116 - Thread 38985 [info] : Locking mtx1...
2024-09-04 23:39:54.484136 - Thread 38986 [debug] : Entering worker2
2024-09-04 23:39:54.484151 - Thread 38986 [info] : Locking mtx2...
2024-09-04 23:39:54.484160 - Thread 38986 [info] : Mutex mtx2 locked
2024-09-04 23:39:54.484146 - Thread 38985 [info] : Mutex mtx1 locked
2024-09-04 23:39:54.584250 - Thread 38986 [info] : Locking mtx1...
2024-09-04 23:39:54.584255 - Thread 38985 [info] : Locking mtx2...

在检查日志时,首先要注意的症状是程序从未完成,因此很可能处于死锁状态。

从记录器输出中,我们可以看到t1(线程38985)正在运行worker1,而t2(线程38986)正在运行worker2。一旦t1进入worker1,它就获取mtx1。然而,mtx2互斥锁是由t2获取的,因为worker2一启动就获取了。然后,两个线程等待 100 毫秒并尝试获取另一个互斥锁,但都没有成功,程序保持阻塞。

记录在生产系统中是必不可少的,但如果过度使用,则会对性能造成一些惩罚,并且大多数时候需要人工干预来调查问题。作为日志详细程度和性能惩罚之间的折衷方案,一个人可能会选择实现不同的日志级别,在正常操作期间仅记录主要事件,同时仍然保留在需要时提供极其详细日志的能力。在开发周期早期自动检测代码中的错误的一种更自动化的方法是使用测试和代码清理器,我们将在下一章中学习这些内容。

并非所有错误都可以检测到,因此通常使用调试器是跟踪和修复软件中的错误的方法。让我们接下来学习如何调试多线程和异步代码。

如何调试异步软件

调试是查找和修复计算机程序中的错误的过程。

在本节中,我们将探讨几种调试多线程和异步软件的技术。您必须具备一些使用调试器的先验知识,例如 GDB(GNU 项目调试器)或 LLDB(LLVM 低位调试器),以及调试过程的术语,如断点、观察者、回溯、帧和崩溃报告。

GDB 和 LLDB 都是优秀的调试器,它们的大多数命令都是相同的,只有少数命令不同。如果程序是在 macOS 上调试或针对大型代码库,LLDB 可能更受欢迎。另一方面,GDB 拥有稳定的传统,许多开发者都熟悉它,并支持更广泛的架构和平台。在本节中,我们将使用 GDB 15.1,因为它属于 GNU 框架,并且被设计为与 g++ 编译器协同工作,但随后显示的大多数命令也可以在用 clang++ 编译的程序上使用 LLDB 进行调试。

由于一些处理多线程和异步代码的调试器功能仍在开发中,请始终更新调试器到最新版本,以包括最新的功能和修复。

一些有用的 GDB 命令

让我们从一些在调试任何类型程序时都很有用的 GDB 命令开始,并为下一节打下基础。

在调试程序时,我们可以启动调试器并将程序作为参数传递。程序可能需要的额外参数可以通过 -- args 选项传递:

$ gdb <program> --args <args>

或者,我们可以通过使用其 进程 标识符PID)来将调试器附加到正在运行的程序:

$ gdb –p <PID>

一旦进入调试器,我们可以运行程序(使用 run 命令)或启动它(使用 start 命令)。运行意味着程序执行直到达到断点或完成。start 仅在 main() 函数的开始处放置一个临时断点并运行程序,在程序开始处停止执行。

例如,如果我们想调试已经崩溃的程序,我们可以使用由崩溃生成的 core dump 文件,该文件可能存储在系统中的特定位置(通常在 Linux 系统上是 /var/lib/apport/coredump/,但请通过访问官方文档来检查您系统中的确切位置)。此外,请注意,通常 core dump 默认是禁用的,需要运行 ulimit -c unlimited 命令,在程序崩溃之前和同一 shell 中执行。如果处理的是特别大的程序或系统磁盘空间不足,可以将 unlimited 参数更改为某个任意限制。

在生成 coredump 文件后,只需将其复制到程序二进制文件所在的目录,并使用以下命令:

$ gdb <program> <coredump>

注意,所有二进制文件都必须有调试符号,因此必须使用–g选项编译。在生产系统中,发布二进制文件通常移除了符号并存储在单独的文件中。有 GDB 命令可以包含这些符号,以及命令行工具可以检查它们,但这个主题超出了本书的范围。

一旦调试器开始运行,我们可以使用 GDB 命令在代码中导航或检查变量。一些有用的命令如下:

  • info args:这会显示用于调用当前函数的参数信息。

  • info locals:这会显示当前作用域中的局部变量。

  • whatis:这会显示给定变量或表达式的类型。

  • return:这会从当前函数返回,而不执行其余的指令。可以指定返回值。

  • backtrace:这会列出当前调用栈中的所有栈帧。

  • frame:这允许你切换到特定的栈帧。

  • updown:这会在调用栈中移动,向当前函数的调用者(up)或被调用者(down)移动。

  • print:这会评估并显示一个表达式的值,该表达式可以是变量名、类成员、指向内存区域的指针或直接是内存地址。我们还可以定义漂亮的打印器来显示我们自己的类。

让我们以调试程序最基本但也是最常用的技术之一来结束本节。这种技术被称为printf。每个开发者都使用过printf或替代命令来打印变量内容,以便在代码路径上的战略位置显示其内容。在 GDB 中,dprintf命令有助于设置在遇到断点时打印信息的printf样式断点,而不会停止程序执行。这样,我们可以在调试程序时使用打印语句,而无需修改代码、重新编译和重启程序。

其语法如下:

$ dprintf <location>, <format>, <args>

例如,如果我们想在第 25 行设置一个printf语句来打印x变量的内容,但只有当其值大于5时,这是命令:

$ dprintf 25, "x = %d\n", x if x > 5

现在我们已经建立了一些基础,让我们从调试一个多线程程序开始。

调试多线程程序

这里显示的示例永远不会结束,因为会发生死锁,因为不同的线程以不同的顺序锁定两个互斥锁,正如在本章介绍日志时已经解释过的:

#include <chrono>
#include <mutex>
#include <thread>
using namespace std::chrono_literals;
int main() {
    std::mutex mtx1, mtx2;
    std::thread t1([&]() {
        std::lock_guard lock1(mtx1);
        std::this_thread::sleep_for(100ms);
        std::lock_guard lock2(mtx2);
    });
    std::thread t2([&]() {
        std::lock_guard lock2(mtx2);
        std::this_thread::sleep_for(100ms);
        std::lock_guard lock1(mtx1);
    });
    t1.join();
    t2.join();
    return 0;
}

首先,让我们使用g++编译这个示例,并添加调试符号(–g选项)以及不允许代码优化(–O0选项),防止编译器重构二进制代码,使调试器更难通过使用--fno-omit-frame-pointer选项找到并显示相关信息。

以下命令编译test.cpp源文件并生成test二进制文件。我们还可以使用clang++以相同的选项:

$ g++ -o test –g -O0 --fno-omit-frame-pointer test.cpp

如果我们运行生成的程序,它将永远不会结束:

$ ./test

要调试一个正在运行的程序,我们首先使用 ps Unix 命令检索其 PID:

$ ps aux | grep test

然后,通过提供 pid 来附加调试器并开始调试程序:

$ gdb –p <pid>

假设调试器以以下消息开始:

ptrace: Operation not permitted.

然后,只需运行以下命令:

$ sudo sysctl -w kernel.yama.ptrace_scope=0

一旦 GDB 正确启动,你将能够在其提示符中输入命令。

我们可以执行的第一条命令是下一个,以检查正在运行的线程:

(gdb) info threads
  Id   Target Id                                Frame
* 1    Thread 0x79d1f3883740 (LWP 14428) "test" 0x000079d1f3298d61 in __futex_abstimed_wait_common64 (private=128, cancel=true, abstime=0x0, op=265, expected=14429, futex_word=0x79d1f3000990)
    at ./nptl/futex-internal.c:57
  2    Thread 0x79d1f26006c0 (LWP 14430) "test" futex_wait (private=0, expected=2, futex_word=0x7fff5e406b00) at ../sysdeps/nptl/futex-internal.h:146
  3    Thread 0x79d1f30006c0 (LWP 14429) "test" futex_wait (private=0, expected=2, futex_word=0x7fff5e406b30) at ../sysdeps/nptl/futex-internal.h:146

输出显示,具有 GDB 标识符 10x79d1f3883740 线程是当前线程。如果有许多线程,而我们只对特定的子集感兴趣,比如说线程 1 和 3,我们可以使用以下命令仅显示那些线程的信息:

(gdb) info thread 1 3

运行一个 GDB 命令将影响当前线程。例如,运行 bt 命令将显示线程 1 的回溯(输出已简化):

(gdb) bt
#0  0x000079d1f3298d61 in __futex_abstimed_wait_common64 (private=128, cancel=true, abstime=0x0, op=265, expected=14429, futex_word=0x79d1f3000990) at ./nptl/futex-internal.c:57
#5  0x000061cbaf1174fd in main () at 11x18-debug_deadlock.cpp:22

要切换到另一个线程,例如线程 2,我们可以使用 thread 命令:

(gdb) thread 2
[Switching to thread 2 (Thread 0x79d1f26006c0 (LWP 14430))]

现在,bt 命令将显示线程 2 的回溯(输出已简化):

(gdb) bt
#0  futex_wait (private=0, expected=2, futex_word=0x7fff5e406b00) at ../sysdeps/nptl/futex-internal.h:146
#2  0x000079d1f32a00f1 in lll_mutex_lock_optimized (mutex=0x7fff5e406b00) at ./nptl/pthread_mutex_lock.c:48
#7  0x000061cbaf1173fa in operator() (__closure=0x61cbafd64418) at 11x18-debug_deadlock.cpp:19

要在不同的线程中执行命令,只需使用 thread apply 命令,在这种情况下,在线程 1 和 3 上执行 bt 命令:

(gdb) thread apply 1 3 bt

要在所有线程中执行命令,只需使用 thread apply all

注意,当多线程程序中的断点被达到时,所有执行线程都会停止运行,从而允许检查程序的整体状态。当通过 continuestepnext 等命令重新启动执行时,所有线程将恢复。当前线程将向前移动一个语句,但其他线程向前移动几个语句或甚至在语句中间停止是不确定的。

当执行停止时,调试器将跳转并显示当前线程的执行上下文。为了避免调试器通过锁定调度器在线程之间跳转,我们可以使用以下命令:

(gdb) set scheduler-locking <on/off>

我们还可以使用以下命令来检查调度器锁定状态:

(gdb) show scheduler-locking

现在我们已经学习了一些用于多线程调试的新命令,让我们检查一下我们附加到调试器中的应用程序发生了什么。

如果我们检索线程 2 和 3 的回溯,我们可以看到以下内容(仅输出简化版,仅显示相关部分):

(gdb) thread apply all bt
Thread 3 (Thread 0x79d1f30006c0 (LWP 14429) "test"):
#0  futex_wait (private=0, expected=2, futex_word=0x7fff5e406b30) at ../sysdeps/nptl/futex-internal.h:146
#5  0x000061cbaf117e20 in std::mutex::lock (this=0x7fff5e406b30) at /usr/include/c++/14/bits/std_mutex.h:113
#7  0x000061cbaf117334 in operator() (__closure=0x61cbafd642b8) at 11x18-debug_deadlock.cpp:13
Thread 2 (Thread 0x79d1f26006c0 (LWP 14430) "test"):
#0  futex_wait (private=0, expected=2, futex_word=0x7fff5e406b00) at ../sysdeps/nptl/futex-internal.h:146
#5  0x000061cbaf117e20 in std::mutex::lock (this=0x7fff5e406b00) at /usr/include/c++/14/bits/std_mutex.h:113
#7  0x000061cbaf1173fa in operator() (__closure=0x61cbafd64418) at 11x18-debug_deadlock.cpp:19

注意,在运行 std::mutex::lock() 之后,两个线程都在第 13 行等待线程 3,在第 19 行等待线程 2,这与 std::thread t1 中的 std::lock_guard lock2std::thread t2 中的 std::lock_guard lock1 相匹配。

因此,我们在这些代码位置检测到了这些线程中发生的死锁。

现在我们来学习更多关于通过捕获竞态条件来调试多线程软件的知识。

调试竞态条件

竞态条件是最难检测和调试的 bug 之一,因为它们通常以间歇性的方式发生,每次发生时都有不同的效果,有时在程序达到失败点之前会发生一些昂贵的计算。

这种不稳定的行为不仅由竞态条件引起。与不正确的内存分配相关的其他问题也可能导致类似症状,因此,在调查并达到根本原因诊断之前,无法将 bug 分类为竞态条件。

调试竞态条件的一种方法是通过 watchpoints 手动检查变量是否在没有当前线程中执行的任何语句修改它的情况下更改其值,或者放置在特定线程触发的策略位置上的断点,如下所示:

(gdb) break <linespec> thread <id> if <condition>

例如,参见以下内容:

(gdb) break test.cpp:11 thread 2

或者,甚至可以使用断言并检查任何由不同线程访问的变量的当前值是否具有预期的值。这种方法在下一个示例中得到了应用:

#include <cassert>
#include <chrono>
#include <cmath>
#include <iostream>
#include <mutex>
#include <thread>
using namespace std::chrono_literals;
static int g_value = 0;
static std::mutex g_mutex;
void func1() {
    const std::lock_guard<std::mutex> lock(g_mutex);
    for (int i = 0; i < 10; ++i) {
        int old_value = g_value;
        int incr = (rand() % 10);
        g_value += incr;
        assert(g_value == old_value + incr);
        std::this_thread::sleep_for(10ms);
    }
}
void func2() {
    for (int i = 0; i < 10; ++i) {
        int old_value = g_value;
        int incr = (rand() % 10);
        g_value += (rand() % 10);
        assert(g_value == old_value + incr);
        std::this_thread::sleep_for(10ms);
    }
}
int main() {
    std::thread t1(func1);
    std::thread t2(func2);
    t1.join();
    t2.join();
    return 0;
}

在这里,两个线程t1t2正在运行增加g_value全局变量随机值的函数。每次增加时,都会将g_value与预期值进行比较,如果不相等,断言指令将停止程序。

按照以下方式编译此程序并运行调试器:

$ g++ -o test -g -O0 test
$ gdb ./test

调试器启动后,使用运行命令来运行程序。程序将运行,并在某个时刻由于收到SIGABRT信号而终止,表明断言未满足。

test: test.cpp:29: void func2(): Assertion `g_value == old_value + incr' failed.
Thread 3 "test" received signal SIGABRT, Aborted.

程序停止后,我们可以使用backtrace命令检查该点的回溯,并将该点失败处的源代码更改为特定的列表

这个例子相当简单,所以通过检查断言输出,可以清楚地看出g_value变量出了问题,这很可能是竞态条件。

但是,对于更复杂的程序,手动调试问题的这个过程相当困难,所以让我们关注另一种称为反向调试的技术,它可以帮助我们解决这个问题。

反向调试

反向调试,也称为时间旅行调试,允许调试器在程序失败后停止程序,并回溯到程序执行的记录中,以调查失败的原因。此功能通过记录(记录)正在调试的程序中的每个机器指令以及内存和寄存器值的每次更改来实现,之后,使用这些记录随意回放和重放程序。

在 Linux 上,我们可以使用 GDB(自 7.0 版本起)、rr(最初由 Mozilla 开发,rr-project.org)或Undo 的时光旅行调试器UDB)(docs.undo.io)。在 Windows 上,我们可以使用时光旅行调试learn.microsoft.com/en-us/windows-hardware/drivers/debuggercmds/time-travel-debugging-overview)。

反向调试仅由有限数量的 GDB 目标支持,例如远程目标 Simics、系统集成和设计SID)模拟器或原生 Linux 的进程记录和回放目标(仅适用于i386amd64moxie-elfarm)。在撰写本书时,Clang 的反向调试功能仍在开发中。

因此,由于这些限制,我们决定通过使用rr进行一个小型展示。请按照项目网站上的说明构建和安装rr调试工具:github.com/rr-debugger/rr/wiki/Building-And-Installing

安装后,要记录和回放程序,请使用以下命令:

$ rr record <program> --args <args>
$ rr replay

例如,如果我们有一个名为test的程序,命令序列将如下所示:

$ rr record test
rr: Saving execution to trace directory `/home/user/.local/share/rr/test-1'.

如果显示以下致命错误:

[FATAL src/PerfCounters.cc:349:start_counter()] rr needs /proc/sys/kernel/perf_event_paranoid <= 3, but it is 4.
Change it to <= 3.
Consider putting 'kernel.perf_event_paranoid = 3' in /etc/sysctl.d/10-rr.conf.

然后,使用以下命令调整内核变量,kernel.perf_event_paranoid

$ sudo sysctl kernel.perf_event_paranoid=1

一旦有记录可用,请使用replay命令开始调试程序:

$ rr replay

或者,如果程序崩溃并且你只想在记录的末尾开始调试,请使用 e选项:

$ rr replay -e

在这一点上,rr将使用 GDB 调试器启动程序并加载其调试符号。然后,你可以使用以下任何命令进行反向调试:

  • reverse-continue:以反向方式开始执行程序。执行将在达到断点或由于同步异常而停止。

  • reverse-next:反向运行到当前栈帧中之前执行的上一行的开始。

  • reverse-nexti:这会反向执行一条指令,跳转到内部栈帧。

  • reverse-step:运行程序直到控制达到新源行的开始。

  • reverse-stepi:反向执行一条机器指令。

  • reverse-finish:这会执行到当前函数调用,即当前函数的开始处。

我们也可以通过使用以下命令来反转调试方向,并使用正向调试的常规命令(如nextstepcontinue等)在相反方向进行:

(rr) set exec-direction reverse

要将执行方向恢复到正向,请使用以下命令:

(rr) set exec-direction forward

作为练习,安装rr调试器并尝试使用反向调试来调试前面的示例。

现在让我们继续探讨如何调试协程,由于协程的异步特性,这是一个具有挑战性的任务。

调试协程

如我们所见,异步代码可以通过在战略位置使用断点、使用观察点检查变量、进入或跳过代码来像同步代码一样进行调试。此外,使用前面描述的技术选择特定线程并锁定调度器有助于在调试时避免不必要的干扰。

如我们所已了解,异步代码中存在复杂性,例如异步代码执行时将使用哪个线程,这使得调试更加困难。对于 C++ 协程,由于它们的挂起/恢复特性,调试甚至更难掌握。

Clang 使用两步编译使用协程的程序:语义分析由 Clang 执行,协程帧在 LLVM 中间端构建和优化。由于调试信息是在 Clang 前端生成的,因此在编译过程中较晚生成协程帧时,将会有不足的调试信息。GCC 采用类似的方法。

此外,如果执行在协程内部中断,当前帧将只有一个变量,frame_ptr。在协程中,没有指针或函数参数。协程在挂起之前将它们的状态存储在堆中,并且在执行期间只使用栈。frame_ptr 用于访问协程正常运行所需的所有必要信息。

让我们调试在 第九章 中实现的 Boost.Asio 协程示例。在这里,我们只展示相关的指令。请访问 第九章 中的 协程 部分,以检查完整的源代码:

boost::asio::awaitable<void> echo(tcp::socket socket) {
    char data[1024];
    while (true) {
        std::cout << "Reading data from socket...\n";//L12
        std::size_t bytes_read = co_await
            socket.async_read_some(
                boost::asio::buffer(data),
                             boost::asio::use_awaitable);
        /* .... */
        co_await boost::asio::async_write(socket,
                boost::asio::buffer(data, bytes_read),
                boost::asio::use_awaitable);
    }
}
boost::asio::awaitable<void>
listener(boost::asio::io_context& io_context,
         unsigned short port) {
    tcp::acceptor acceptor(io_context,
                           tcp::endpoint(tcp::v4(), port));
    while (true) {
        std::cout << "Accepting connections...\n";  // L45
        tcp::socket socket = co_await
            acceptor.async_accept(
                boost::asio::use_awaitable);
        boost::asio::co_spawn(io_context,
            echo(std::move(socket)),
            boost::asio::detached);
    }
}
/* main function */

由于我们使用 Boost,让我们在编译源代码时包含 Boost.System 库,以添加更多符号以进行调试:

$ g++ --std=c++20 -ggdb -O0 --fno-omit-frame-pointer -lboost_system  test.cpp -o test

然后,我们使用生成的程序启动调试器,并在第 12 行和第 45 行设置断点,这些是每个协程中 while 循环内第一条指令的位置:

$ gdb –q ./test
(gdb) b 12
(gdb) b 45

我们还启用了 GDB 内置的格式化打印器,以显示标准模板库容器的可读输出:

(gdb) set print pretty on

如果现在运行程序(运行命令),它将在接受连接之前到达协程监听器内的第 42 行的断点。使用 info locals 命令,我们可以检查局部变量。

协程创建了一个具有多个内部字段的状态机,例如带有线程的承诺对象、调用对象的地址、挂起的异常等。它们还存储 resumedestroy 回调。这些结构是编译器依赖的,与编译器的实现相关联,并且如果我们使用 Clang,可以通过 frame_ptr 访问。

如果我们继续运行程序(使用继续命令),服务器将等待客户端连接。要退出等待状态,我们使用telnet,如第九章所示,将客户端连接到服务器。此时,执行将停止,因为达到echo()协程内部第 12 行的断点,并且info locals显示了每个echo连接使用的变量。

使用回溯命令将显示一个调用栈,由于协程的挂起特性,可能存在一些复杂性。

在纯 C++例程中,如第八章所述,有两个设置断点可能有趣的表达式:

  • co_await:执行将在等待的操作完成后挂起。可以通过检查底层的await_suspendawait_resume或自定义可等待代码来在协程恢复的点设置断点。

  • co_yield:挂起执行并返回一个值。在调试期间,进入co_yield以观察控制流如何在协程及其调用函数之间进行。

由于协程在 C++世界中相当新颖,并且编译器持续发展,我们希望不久的将来调试协程将更加直接。

一旦我们找到并调试了一些错误,并且可以重现导致这些特定错误的场景,设计一些涵盖这些情况的测试将很方便,以避免未来代码更改可能导致类似问题或事件。让我们在下一章学习如何测试多线程和异步代码。

摘要

在本章中,我们学习了如何使用日志和调试异步程序。

我们从使用日志来发现运行软件中的问题开始,展示了使用spdlog日志库检测死锁的有用性。还讨论了许多其他库,描述了它们可能适合特定场景的相关功能。

然而,并非所有错误都可以通过使用日志来发现,有些错误可能只能在软件开发生命周期后期,当生产中出现问题时才会被发现,即使在处理程序崩溃和事件时也是如此。调试器是检查运行或崩溃程序的有用工具,了解其代码路径,并找到错误。介绍了几个示例和调试器命令来处理通用代码,但也特别针对多线程和异步软件、竞态条件和协程。还介绍了rr调试器,展示了将反向调试纳入我们的开发者工具箱的潜力。

在下一章中,我们将学习使用 sanitizers 和测试技术来性能和优化技术,这些技术可以用来改善异步程序的运行时间和资源使用。

进一步阅读

第十二章:清理和测试异步软件

测试是评估和验证软件解决方案是否按预期工作,验证其质量并确保满足用户需求的过程。通过适当的测试,我们可以预防错误的发生并提高性能。

在本章中,我们将探讨几种测试异步软件的技术,主要使用GoogleTest库以及来自GNU 编译器集合GCC)和Clang编译器的清理器。需要一些单元测试的先验知识。在本章末尾的进一步阅读部分,您可以找到一些可能有助于刷新和扩展这些领域知识的参考资料。

在本章中,我们将涵盖以下主要主题:

  • 清理代码以分析软件并查找潜在问题

  • 测试异步代码

技术要求

对于本章,我们需要安装GoogleTestgoogle.github.io/googletest)来编译一些示例。

一些示例需要支持 C++20 的编译器。因此,请参阅第三章中的技术要求部分,因为它包含有关如何安装 GCC 13 和 Clang 8 编译器的指导。

您可以在以下 GitHub 仓库中找到所有完整代码:

github.com/PacktPublishing/Asynchronous-Programming-with-CPP

本章的示例位于Chapter_12文件夹下。所有源代码文件都可以使用以下 CMake 编译:

$ cmake . && cmake —build .

可执行二进制文件将在bin目录下生成。

清理代码以分析软件并查找潜在问题

清理器是工具,最初由 Google 开发,用于检测和预防代码中各种类型的问题或安全漏洞,帮助开发者尽早在开发过程中捕捉到错误,减少后期修复问题的成本,并提高软件的稳定性和安全性。

清理器通常集成到开发环境中,并在手动测试或运行单元测试、持续集成CI)管道或代码审查管道时启用。

C++ 编译器,如 GCC 和 Clang,在构建程序时提供编译器选项以生成代码,以跟踪运行时的执行并报告错误和漏洞。这些功能从 Clang 3.1 版本和 GCC 4.8 版本开始实现。

由于向程序的二进制代码中注入了额外的指令,根据清理器类型,性能惩罚约为 1.5 倍到 4 倍减慢。此外,总体内存开销为 2 倍到 4 倍,堆栈大小增加最多 3 倍。但请注意,减慢程度远低于使用其他仪器框架或动态分析工具(如Valgrind valgrind.org)时遇到的减慢,后者比生产二进制文件慢高达 50 倍。另一方面,使用 Valgrind 的好处是不需要重新编译。两种方法都仅在程序运行时检测问题,并且仅在执行遍历的代码路径上检测。因此,我们需要确保足够的覆盖率。

此外,还有静态分析工具和代码检查器,它们在编译期间检测问题并检查程序中包含的所有代码,非常有用。例如,通过启用–Werror–Wall–pedantic选项,编译器如 GCC 和 Clang 可以执行额外的检查并提供有用的信息。

此外,还有开源替代方案,如CppcheckFlawfinder,或免费提供给开源项目的商业解决方案,如PVS-StudioCoverity Scan。其他解决方案,如SonarQubeCodeSonarOCLint,可用于持续集成/持续交付CI/CD)管道中的持续质量跟踪。

在本节中,我们将重点关注可以通过向编译器传递一些特殊选项来启用的清理器。

编译器选项

要启用清理器,我们需要在编译程序时传递一些编译器选项。

主要选项是--fsanitize=sanitizer_name,其中sanitizer_name是以下选项之一:

  • 地址:这是针对AddressSanitizerASan),用于检测内存错误,如缓冲区溢出和使用后释放错误

  • 线程:这是针对ThreadSanitizerTSan),通过监控线程交互来识别多线程程序中的数据竞争和其他线程同步问题

  • 泄露:这是针对LeakSanitizerLSan),通过跟踪内存分配并确保所有分配的内存都得到适当释放来发现内存泄露

  • 内存:这是针对MemorySanitizerMSan),用于揭示未初始化内存的使用

  • 未定义:这是针对UndefinedBehaviorSanitizerUBSan),用于检测未定义行为,例如整数溢出、无效类型转换和其他错误操作

Clang 还包括dataflowcfi(控制流完整性)、safe_stackrealtime

GCC 增加了kernel-addresshwaddresskernel-hwaddresspointer-comparepointer-subtractshadow-call-stack

由于此列表和标志行为可能会随时间而变化,建议检查编译器的官方文档。

可能需要额外的标志:

  • -fno-omit-frame-pointer:帧指针是编译器用来跟踪当前堆栈帧的寄存器,其中包含其他信息,如当前函数的基址。省略帧指针可能会提高程序的性能,但代价是使调试变得非常困难;它使得定位局部变量和重建堆栈跟踪更加困难。

  • -g:包含调试信息,并在警告消息中显示文件名和行号。如果使用调试器 GDB,则可能希望使用–ggdb选项,因为编译器可以生成更易于调试的符号。还可以通过使用–g[level]指定一个级别,其中[level]是一个从03的值,每次级别增加都会添加更多的调试信息。默认级别是2

  • –fsanitize-recover:这些选项会导致清理器尝试继续运行程序,就像没有检测到错误一样。

  • –fno-sanitize-recover:清理器将仅检测到第一个错误,并且程序将以非零退出码退出。

为了保持合理的性能,我们可能需要通过指定–O[num]选项来调整优化级别。不同的清理器在一定的优化级别上表现最佳。最好从–O0开始,如果减速显著,尝试增加到–O1–O2等。此外,由于不同的清理器和编译器推荐特定的优化级别,请检查它们的文档。

当使用 Clang 时,为了使堆栈跟踪易于理解,并让清理器将地址转换为源代码位置,除了使用前面提到的标志外,我们还可以将特定的环境变量[X]SAN_SYMBOLIZER_PATH设置为llvm-symbolizer的位置(其中[X]A表示 AddressSanitizer,L表示 LSan,M表示 MSan 等)。我们还可以将此位置包含在PATH环境变量中。以下是在使用AddressSanitizer时设置PATH变量的示例:

export ASAN_SYMBOLIZER_PATH=`which llvm-symbolizer`
export PATH=$ASAN_SYMBOLIZER_PATH:$PATH

注意,启用–Werror与某些清理器一起可能会导致误报。此外,可能还需要其他编译器标志,但执行期间的警告消息将显示正在发生问题,并且将明显表明需要某个标志。请检查清理器和编译器的文档,以找到在那些情况下应使用的标志。

避免对代码部分进行清理

有时,我们可能希望静音某些清理器警告,并跳过某些函数的清理,原因如下:这是一个已知问题,该函数是正确的,这是一个误报,该函数需要加速,或者这是一个第三方库的问题。在这些情况下,我们可以使用抑制文件或通过使用某些宏指令排除代码区域。还有一个黑名单机制,但由于它已被抑制文件取代,我们在此不做评论。

使用抑制文件,我们只需要创建一个文本文件,列出我们不希望清理器运行的代码区域。每一行都包含一个模式,该模式根据清理器的不同而有所不同,但通常结构如下:

type:location_pattern

在这里,type 表示抑制的类型,例如,leakrace 值,而 location_pattern 是匹配要抑制的函数或库名的正则表达式。下面是一个 ASan 的抑制文件示例,将在下一节中解释:

# Suppress known memory leaks in third-party function Func1 in library Lib1
leak:Lib1::Func1
# Ignore false-positive from function Func2 in library Lib2
race:Lib2::Func2
# Suppress issue from libc
leak:/usr/lib/libc.so.*

让我们称这个文件为 myasan.supp。然后,编译并使用以下命令将抑制文件传递给清理器通过 [X]SAN_OPTIONS

$ clang++ -O0 -g -fsanitize=address -fno-omit-frame-pointer test.cpp –o test
$ ASAN_OPTIONS=suppressions=myasan.supp ./test

我们还可以在源代码中使用宏来排除特定的函数,使其不被清理器清理,如下所示使用 attribute((no_sanitize("<sanitizer_name>")))

#if defined(__clang__) || defined (__GNUC__)
# define ATTRIBUTE_NO_SANITIZE_ADDRESS __attribute__((no_sanitize_address))
#else
# define ATTRIBUTE_NO_SANITIZE_ADDRESS
#endif
...
ATTRIBUTE_NO_SANITIZE_ADDRESS
void ThisFunctionWillNotBeInstrumented() {...}

这种技术提供了对清理器应该对什么进行插装的细粒度编译时控制。

现在,让我们探索最常见的代码清理器类型,从与检查地址误用最相关的一种开始。

AddressSanitizer

ASan 的目的是检测由于数组越界访问、使用释放的内存块(堆、栈和全局)以及其他内存泄漏而发生的内存相关错误。

除了设置 -fsanitize=address 和之前推荐的其他标志外,我们还可以使用 –fsanitize-address-use-after-scope 来检测移出作用域后使用的内存,或者设置环境变量 ASAN_OPTIONS=option detect_stack_use_after_return=1 来检测返回后使用。

ASAN_OPTIONS 也可以用来指示 ASan 打印堆栈跟踪或设置日志文件,如下所示:

ASAN_OPTIONS=detect_stack_use_after_return=1,print_stacktrace=1,log_path=asan.log

Linux 上的 Clang 完全支持 ASan,其次是 Linux 上的 GCC。默认情况下,ASan 是禁用的,因为它会增加额外的运行时开销。

此外,ASan 处理所有对 glibc 的调用——这是为 GNU 系统提供核心库的 GNU C 库。然而,其他库的情况并非如此,因此建议使用 –fsanitize=address 选项重新编译此类库。如前所述,使用 Valgrind 不需要重新编译。

ASan 可以与 UBSan 结合使用,我们将在后面看到,但这会降低性能约 50%。

如果我们想要更激进的诊断清理,可以使用以下标志组合:

ASAN_OPTIONS=strict_string_checks=1:detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1

让我们看看使用 ASan 检测常见软件问题的两个示例,包括释放内存后继续使用和检测缓冲区溢出。

释放内存后的内存使用

软件中常见的一个问题是释放内存后继续使用。在这个例子中,堆中分配的内存被删除后仍在使用:

#include <iostream>
#include <memory>
int main() {
  auto arr = new int[100];
  delete[] arr;
  std::cout << "arr[0] = " << arr[0] << '\n';
  return 0;
}

假设之前的源代码在一个名为 test.cpp 的文件中。要启用 ASan,我们只需使用以下命令编译文件:

$ clang++ -fsanitize=address -fno-omit-frame-pointer -g -O0 –o test test.cpp

然后,执行生成的输出 test 程序,我们得到以下输出(注意,输出已简化,仅显示相关内容,可能因不同的编译器版本和执行环境而有所不同):

ERROR: AddressSanitizer: heap-use-after-free on address 0x514000000040 at pc 0x63acc82a0bec bp 0x7fff2d096c60 sp 0x7fff2d096c58
READ of size 4 at 0x514000000040 thread T0
    #0 0x63acc82a0beb in main test.cpp:7:31
0x514000000040 is located 0 bytes inside of 400-byte region 0x514000000040,0x5140000001d0)
freed by thread T0 here:
    #0 0x63acc829f161 in operator delete[ (/mnt/StorePCIE/Projects/Books/Packt/Book/Code/build/bin/Chapter_11/11x02-ASAN_heap_use_after_free+0x106161) (BuildId: 7bf8fe6b1f86a8b587fbee39ae3a5ced3e866931)
previously allocated by thread T0 here:
    #0 0x63acc829e901 in operator new[](unsigned long) (/mnt/StorePCIE/Projects/Books/Packt/Book/Code/build/bin/Chapter_11/11x02-ASAN_heap_use_after_free+0x105901) (BuildId: 7bf8fe6b1f86a8b587fbee39ae3a5ced3e866931)
SUMMARY: AddressSanitizer: heap-use-after-free test.cpp:7:31 in main

输出显示 ASan 已应用并检测到一个堆使用后释放错误。这个错误发生在 T0 线程(主线程)。输出还指向了分配该内存区域的代码,稍后释放,以及其大小(400 字节区域)。

这类错误不仅发生在堆内存中,也发生在栈或全局区域分配的内存区域中。ASan 可以用来检测这类问题,例如内存溢出。

内存溢出

内存溢出,也称为缓冲区溢出或越界,发生在将某些数据写入超出缓冲区分配内存的地址时。

以下示例显示了一个堆内存溢出:

#include <iostream>
int main() {
  auto arr = new int[100];
  arr[0] = 0;
  int res = arr[100];
  std::cout << "res = " << res << '\n';
  delete[] arr;
  return 0;
}

编译并运行生成的程序后,这是输出:

ERROR: AddressSanitizer: heap-buffer-overflow on address 0x5140000001d0 at pc 0x582953d2ac07 bp 0x7ffde9d58910 sp 0x7ffde9d58908
READ of size 4 at 0x5140000001d0 thread T0
    #0 0x582953d2ac06 in main test.cpp:6:13
0x5140000001d0 is located 0 bytes after 400-byte region 0x514000000040,0x5140000001d0)
allocated by thread T0 here:
    #0 0x582953d28901 in operator new[ (test+0x105901) (BuildId: 82a16fc86e01bc81f6392d4cbcad0fe8f78422c0)
    #1 0x582953d2ab78 in main test.cpp:4:14
(test+0x2c374) (BuildId: 82a16fc86e01bc81f6392d4cbcad0fe8f78422c0)
SUMMARY: AddressSanitizer: heap-buffer-overflow test.cpp:6:13 in main

从输出中我们可以看到,现在 ASan 报告了主线程(T0)在访问超过 400 字节区域(arr 变量)的内存地址时的堆缓冲区溢出错误。

集成到 ASan 中的清理器是 LSan。现在让我们学习如何使用这个清理器来检测内存泄漏。

LeakSanitizer

LSan 用于检测内存泄漏,当内存已分配但未正确释放时发生。

LSan 集成到 ASan 中,并在 Linux 系统上默认启用。在 macOS 上可以通过使用 ASAN_OPTIONS=detect_leaks=1 来启用它。要禁用它,只需设置 detect_leaks=0

如果使用 –fsanitize=leak 选项,程序将链接到支持 LSan 的 ASan 的子集,禁用编译时仪器并减少 ASan 的减速。请注意,此模式不如默认模式经过充分测试。

让我们看看一个内存泄漏的例子:

#include <string.h>
#include <iostream>
#include <memory>
int main() {
    auto arr = new char[100];
    strcpy(arr, "Hello world!");
    std::cout << "String = " << arr << '\n';
    return 0;
}

在这个例子中,分配了 100 字节(arr 变量),但从未释放。

要启用 LSan,我们只需使用以下命令编译文件:

$ clang++ -fsanitize=leak -fno-omit-frame-pointer -g -O2 –o test test.cpp

运行生成的测试二进制文件,我们得到以下结果:

ERROR: LeakSanitizer: detected memory leaks
Direct leak of 100 byte(s) in 1 object(s) allocated from:
    #0 0x5560ba9a017c in operator new[](unsigned long) (test+0x3417c) (BuildId: 2cc47a28bb898b4305d90c048c66fdeec440b621)
    #1 0x5560ba9a2564 in main test.cpp:6:16
SUMMARY: LeakSanitizer: 100 byte(s) leaked in 1 allocation(s).

LSan 正确报告了一个 100 字节大小的内存区域是通过使用操作符 new 分配的,但从未被删除。

由于本书探讨了多线程和异步编程,现在让我们了解一个用于检测数据竞争和其他线程问题的清理器:TSan。

ThreadSanitizer

TSan 用于检测线程问题,特别是数据竞争和同步问题。它不能与 ASan 或 LSan 结合使用。TSan 是与本书内容最一致的清理器。

通过指定 –fsanitize=thread 编译器选项启用此清理器,可以通过使用 TSAN_OPTIONS 环境变量来修改其行为。例如,如果我们想在第一次错误后停止,只需使用以下命令:

TSAN_OPTIONS=halt_on_error=1

此外,为了合理的性能,使用编译器的 O2 选项。

TSan 只报告在运行时发生的竞争条件,因此它不会在未在运行时执行的代码路径中存在的竞争条件上发出警报。因此,我们需要设计提供良好覆盖率和使用真实工作负载的测试。

让我们看看 TSan 检测数据竞争的一些示例。在下一个示例中,我们将通过使用一个全局变量而不使用互斥锁来保护其访问来实现这一点:

#include <thread>
int globalVar{0};
void increase() {
  globalVar++;
}
void decrease() {
  globalVar--;
}
int main() {
  std::thread t1(increase);
  std::thread t2(decrease);
  t1.join();
  t2.join();
  return 0;
}

编译程序后,使用以下命令启用 TSan:

$ clang++ -fsanitize=thread -fno-omit-frame-pointer -g -O2 –o test test.cpp

运行生成的程序会生成以下输出:

WARNING: ThreadSanitizer: data race (pid=31692)
  Write of size 4 at 0x5932b0585ae8 by thread T2:
    #0 decrease() test.cpp:10:12 (test+0xe0b32) (BuildId: 895b75ef540c7b44daa517a874d99d06bd27c8f7)
  Previous write of size 4 at 0x5932b0585ae8 by thread T1:
    #0 increase() test.cpp:6:12 (test+0xe0af2) (BuildId: 895b75ef540c7b44daa517a874d99d06bd27c8f7)
  Thread T2 (tid=31695, running) created by main thread at:
    #0 pthread_create <null> (test+0x6062f) (BuildId: 895b75ef540c7b44daa517a874d99d06bd27c8f7)
  Thread T1 (tid=31694, finished) created by main thread at:
    #0 pthread_create <null> (test+0x6062f) (BuildId: 895b75ef540c7b44daa517a874d99d06bd27c8f7)
SUMMARY: ThreadSanitizer: data race test.cpp:10:12 in decrease()
ThreadSanitizer: reported 1 warnings

从输出中可以看出,在increase()decrease()函数访问globalVar时存在数据竞争。

如果我们决定使用 GCC 而不是 Clang,在运行生成的程序时可能会报告以下错误:

FATAL: ThreadSanitizer: unexpected memory mapping 0x603709d10000-0x603709d11000

这种内存映射问题是由称为地址空间布局随机化ASLR)的安全功能引起的,这是一种操作系统使用的内存保护技术,通过随机化进程的地址空间来防止缓冲区溢出攻击。

一种解决方案是使用以下命令减少 ASLR:

$ sudo sysctl vm.mmap_rnd_bits=30

如果错误仍然发生,传递给vm.mmap_rnd_bits(在先前的命令中为30)的值可以进一步降低。为了检查该值是否正确设置,只需运行以下命令:

$ sudo sysctl vm.mmap_rnd_bits
vm.mmap_rnd_bits = 30

注意,此更改不是永久的。因此,当机器重新启动时,其值将设置为默认值。要持久化此更改,请将m.mmap_rnd_bits=30添加到/etc/sysctl.conf

但这降低了系统的安全性,因此可能更倾向于使用以下命令临时禁用特定程序的 ASLR:

$ setarch `uname -m` -R ./test

运行上述命令将显示与使用 Clang 编译时显示的类似输出。

让我们转到另一个示例,其中std::map对象在没有互斥锁的情况下被访问。即使映射被用于不同的键值,因为写入std::map会使其迭代器无效,这也可能导致数据竞争:

#include <map>
#include <thread>
std::map<int,int> m;
void Thread1() {
  m[123] = 1;
}
void Thread2() {
  m[345] = 0;
}
int main() {
  std::jthread t1(Thread1);
  std::jthread t2(Thread1);
  return 0;
}

编译并运行生成的二进制文件会生成大量输出,包含三个警告。在这里,我们只显示第一个警告中最相关的行(其他警告类似):

WARNING: ThreadSanitizer: data race (pid=8907)
  Read of size 4 at 0x720c00000020 by thread T2:
  Previous write of size 8 at 0x720c00000020 by thread T1:
  Location is heap block of size 40 at 0x720c00000000 allocated by thread T1:
  Thread T2 (tid=8910, running) created by main thread at:
  Thread T1 (tid=8909, finished) created by main thread at:
SUMMARY: ThreadSanitizer: data race test.cpp:11:3 in Thread2()

t1t2线程都在向映射,m写入时,TSan 警告会标记。

在下一个示例中,只有一个辅助线程通过指针访问映射,但此线程与主线程竞争以访问和使用映射。t线程访问映射,m,以更改foo键的值;同时,主线程将其值打印到控制台:

#include <iostream>
#include <thread>
#include <map>
#include <string>
typedef std::map<std::string, std::string> map_t;
void *func(void *p) {
  map_t& m = *static_cast<map_t*>(p);
  m["foo"] = "bar";
  return 0;
}
int main() {
  map_t m;
  std::thread t(func, &m);
  std::cout << "foo = " << m["foo"] << '\n';
  t.join();
  return 0;
}

编译并运行此示例会生成大量输出,包含七个 TSan 警告。在这里,我们只显示第一个警告。您可以自由地通过在 GitHub 存储库中编译和运行示例来检查完整的报告:

WARNING: ThreadSanitizer: data race (pid=10505)
  Read of size 8 at 0x721800003028 by main thread:
    #8 main test.cpp:17:28 (test+0xe1d75) (BuildId: 8eef80df1b5c81ce996f7ef2c44a6c8a11a9304f)
  Previous write of size 8 at 0x721800003028 by thread T1:
    #0 operator new(unsigned long) <null> (test+0xe0c3b) (BuildId: 8eef80df1b5c81ce996f7ef2c44a6c8a11a9304f)
    #9 func(void*) test.cpp:10:3 (test+0xe1bb7) (BuildId: 8eef80df1b5c81ce996f7ef2c44a6c8a11a9304f)
  Location is heap block of size 96 at 0x721800003000 allocated by thread T1:
    #0 operator new(unsigned long) <null> (test+0xe0c3b) (BuildId: 8eef80df1b5c81ce996f7ef2c44a6c8a11a9304f)
    #9 func(void*) test.cpp:10:3 (test+0xe1bb7) (BuildId: 8eef80df1b5c81ce996f7ef2c44a6c8a11a9304f)
  Thread T1 (tid=10507, finished) created by main thread at:
    #0 pthread_create <null> (test+0x616bf) (BuildId: 8eef80df1b5c81ce996f7ef2c44a6c8a11a9304f)
SUMMARY: ThreadSanitizer: data race test.cpp:17:28 in main
ThreadSanitizer: reported 7 warnings

从输出中,TSan 正在警告访问在堆中分配的std::map对象时存在数据竞争。该对象是映射m

然而,TSan 不仅可以通过缺少互斥锁来检测数据竞争,还可以报告何时变量必须是原子的。

下一个示例展示了这种情况。RefCountedObject 类定义了可以保持该类已创建对象数量的引用计数的对象。智能指针遵循这个想法,当计数器达到值 0 时,在销毁时删除底层分配的内存。在这个例子中,我们只展示了 Ref()Unref() 函数,它们增加和减少引用计数变量 ref_。为了避免多线程环境中的问题,ref_ 必须是一个原子变量。正如这里所示,这并不是这种情况,t1t2 线程正在修改 ref_,可能发生数据竞争:

#include <iostream>
#include <thread>
class RefCountedObject {
   public:
    void Ref() {
        ++ref_;
    }
    void Unref() {
        --ref_;
    }
   private:
    // ref_ should be atomic to avoid synchronization issues
    int ref_{0};
};
int main() {
  RefCountedObject obj;
  std::jthread t1(&RefCountedObject::Ref, &obj);
  std::jthread t2(&RefCountedObject::Unref, &obj);
  return 0;
}

编译并运行此示例会产生以下输出:

WARNING: ThreadSanitizer: data race (pid=32574)
  Write of size 4 at 0x7fffffffcc04 by thread T2:
    #0 RefCountedObject::Unref() test.cpp:12:9 (test+0xe1dd0) (BuildId: 448eb3f3d1602e21efa9b653e4760efe46b621e6)
  Previous write of size 4 at 0x7fffffffcc04 by thread T1:
    #0 RefCountedObject::Ref() test.cpp:8:9 (test+0xe1c00) (BuildId: 448eb3f3d1602e21efa9b653e4760efe46b621e6)
  Location is stack of main thread.
  Location is global '??' at 0x7ffffffdd000 ([stack]+0x1fc04)
  Thread T2 (tid=32577, running) created by main thread at:
    #0 pthread_create <null> (test+0x6164f) (BuildId: 448eb3f3d1602e21efa9b653e4760efe46b621e6)
    #2 main test.cpp:23:16 (test+0xe1b94) (BuildId: 448eb3f3d1602e21efa9b653e4760efe46b621e6)
  Thread T1 (tid=32576, finished) created by main thread at:
    #0 pthread_create <null> (test+0x6164f) (BuildId: 448eb3f3d1602e21efa9b653e4760efe46b621e6)
    #2 main test.cpp:22:16 (test+0xe1b56) (BuildId: 448eb3f3d1602e21efa9b653e4760efe46b621e6)
SUMMARY: ThreadSanitizer: data race test.cpp:12:9 in RefCountedObject::Unref()
ThreadSanitizer: reported 1 warnings

TSan 输出显示,当访问之前由 Ref() 函数修改的内存位置时,Unref() 函数中发生了数据竞争条件。

数据竞争也可能发生在没有同步机制的情况下从多个线程初始化的对象中。在以下示例中,MyObj 类型的对象在 init_object() 函数中被创建,全局静态指针 obj 被分配其地址。由于此指针没有由互斥锁保护,当 t1t2 线程分别从 func1()func2() 函数尝试创建对象并更新 obj 指针时,会发生数据竞争:

#include <iostream>
#include <thread>
class MyObj {};
static MyObj *obj = nullptr;
void init_object() {
  if (!obj) {
    obj = new MyObj();
  }
}
void func1() {
  init_object();
}
void func2() {
  init_object();
}
int main() {
  std::thread t1(func1);
  std::thread t2(func2);
  t1.join();
  t2.join();
  return 0;
}

这是编译并运行此示例后的输出:

WARNING: ThreadSanitizer: data race (pid=32826)
  Read of size 1 at 0x5663912cbae8 by thread T2:
    #0 func2() test.cpp (test+0xe0b68) (BuildId: 12f32c1505033f9839d17802d271fc869b7a3e38)
  Previous write of size 1 at 0x5663912cbae8 by thread T1:
    #0 func1() test.cpp (test+0xe0b3d) (BuildId: 12f32c1505033f9839d17802d271fc869b7a3e38)
  Location is global 'obj (.init)' of size 1 at 0x5663912cbae8 (test+0x150cae8)
  Thread T2 (tid=32829, running) created by main thread at:
    #0 pthread_create <null> (test+0x6062f) (BuildId: 12f32c1505033f9839d17802d271fc869b7a3e38)
  Thread T1 (tid=32828, finished) created by main thread at:
    #0 pthread_create <null> (test+0x6062f) (BuildId: 12f32c1505033f9839d17802d271fc869b7a3e38)
SUMMARY: ThreadSanitizer: data race test.cpp in func2()
ThreadSanitizer: reported 1 warnings

输出显示了我们之前描述的情况,由于从 func1()func2() 访问 obj 全局变量而导致的数据竞争。

由于 C++11 标准已正式将数据竞争视为未定义行为,现在让我们看看如何使用 UBSan 来检测程序中的未定义行为问题。

UndefinedBehaviorSanitizer

UBSan 可以检测代码中的未定义行为,例如,当通过过多的位移操作、整数溢出或误用空指针时。可以通过指定 –fsanitize=undefined 选项来启用它。其行为可以通过设置 UBSAN_OPTIONS 变量在运行时进行修改。

许多 UBSan 可以检测到的错误也可以在编译期间由编译器检测到。

让我们看看一个简单的例子:

int main() {
  int val = 0x7fffffff;
  val += 1;
  return 0;
}

要编译程序并启用 UBSan,请使用以下命令:

$ clang++ -fsanitize=undefined -fno-omit-frame-pointer -g -O2 –o test test.cpp

运行生成的程序会产生以下输出:

test.cpp:3:7: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior test.cpp:3:7

输出非常简单且易于理解;存在一个有符号整数溢出操作。

现在,让我们了解另一个有用的 C++ 检查器,用于检测未初始化的内存和其他内存使用问题:MSan。

MemorySanitizer

MSan 可以检测未初始化的内存使用,例如,在使用变量或指针之前没有分配值或地址时。它还可以跟踪位域中的未初始化位。

要启用 MSan,请使用以下编译器标志:

-fsanitize=memory -fPIE -pie -fno-omit-frame-pointer

它还可以通过指定- fsanitize-memory-track-origins选项将每个未初始化的值追踪到其创建的内存分配。

GCC 不支持 MSan,因此当使用此编译器时,-fsanitize=memory标志是无效的。

在以下示例中,创建了arr整数数组,但只初始化了其位置5。在向控制台打印消息时使用位置0的值,但此值仍然是未初始化的:

#include <iostream>
int main() {
  auto arr = new int[10];
  arr[5] = 0;
  std::cout << "Value at position 0 = " << arr[0] << '\n';
  return 0;
}

要编译程序并启用 MSan,请使用以下命令:

$ clang++ -fsanitize=memory -fno-omit-frame-pointer -g -O2 –o test test.cpp

运行生成的程序将生成以下输出:

==20932==WARNING: MemorySanitizer: use-of-uninitialized-value
    #0 0x5b9fa2bed38f in main test.cpp:6:41
    #3 0x5b9fa2b53324 in _start (test+0x32324) (BuildId: c0a0d31f01272c3ed59d4ac66b8700e9f457629f)
SUMMARY: MemorySanitizer: use-of-uninitialized-value test.cpp:6:41 in main

再次,输出清楚地显示,在读取arr数组中位置0的值时,在第 6 行使用了未初始化的值。

最后,让我们在下一节总结其他检查器。

其他检查器

在为某些系统(如内核或实时开发)开发时,还有其他有用的检查器:

  • 硬件辅助地址检查器 (HWASan):ASan 的一个新变体,通过使用硬件能力忽略指针的最高字节来消耗更少的内存。可以通过指定 fsanitize=hwaddress选项来启用。

  • 实时检查器 (RTSan):实时测试工具,用于检测在调用具有确定运行时要求的函数中不安全的函数时发生的实时违规。

  • Fuzzer 检查器:一种检查器,通过向程序输入大量随机数据来检测潜在漏洞,检查程序是否崩溃,并寻找内存损坏或其他安全漏洞。

  • 内核相关检查器:还有其他检查器可用于通过内核开发者跟踪问题。出于好奇,以下是一些例子:

    • 内核地址 检查器 ( KASAN )

    • 内核并发 检查器 ( KCSAN )

    • 内核 电栅栏 ( KFENCE )

    • 内核内存 检查器 ( KMSAN )

    • 内核线程 检查器 ( KTSAN )

检查器可以自动在我们的代码中找到许多问题。一旦我们找到并调试了一些错误,并且可以重现导致这些特定错误的场景,设计一些涵盖这些情况的测试将非常方便,以避免未来代码中的更改可能导致类似问题或事件。

让我们在下一节学习如何测试多线程和异步代码。

测试异步代码

最后,让我们探索一些测试异步代码的技术。本节中显示的示例需要GoogleTestGoogleTest Mock ( gMock )库来编译。如果您不熟悉这些库,请查阅官方文档了解如何安装和使用它们。

正如我们所知,单元测试是一种编写小型且独立的测试的实践,用于验证单个代码单元的功能和行为。单元测试有助于发现和修复错误,重构和改进代码质量,记录和传达底层代码设计,并促进协作和集成。

本节不会涵盖将测试分组到逻辑和描述性套件的最佳方式,或者何时应该使用断言或期望来验证不同变量和测试方法结果的值。本节的目的在于提供一些关于如何创建单元测试以测试异步代码的指南。因此,对单元测试或测试驱动开发TDD)有一些先前的知识是可取的。

处理异步代码的主要困难在于它可能在另一个线程中执行,通常不知道何时会发生,或何时完成。

测试异步代码时,主要遵循的方法是将功能与多线程分离,这意味着我们可能希望以同步方式测试异步代码,尝试在一个特定的线程中执行它,移除上下文切换、线程创建和销毁以及其他可能影响测试结果和时序的活动。有时,也会使用计时器,在超时前等待回调被调用。

测试一个简单的异步函数

让我们从测试一个异步操作的小例子开始。此示例展示了asyncFunc()函数,它通过使用std::async异步运行来测试,如第七章中所示:

#include <gtest/gtest.h>
#include <chrono>
#include <future>
using namespace std::chrono_literals;
int asyncFunc() {
    std::this_thread::sleep_for(100ms);
    return 42;
}
TEST(AsyncTests, TestHandleAsyncOperation) {
    std::future<int> result = std::async(
                         std::launch::async,
                         asyncFunc);
    EXPECT_EQ(result.get(), 42);
}
int main(int argc, char **argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

std::async返回一个 future,用于检索计算值。在这种情况下,asyncFunc只是等待100ms然后返回值42。如果异步任务运行正常,测试将通过,因为有一个期望指令检查返回的值确实是42

只定义了一个测试,使用TEST()宏,其中第一个参数是测试套件名称(在这个例子中,AsyncTests),第二个参数是测试名称(TestHandleAsyncOperation)。

main()函数中,通过调用::testing::InitGoogleTest()初始化 GoogleTest 库。此函数解析命令行以获取 GoogleTest 识别的标志。然后调用RUN_ALL_TESTS(),该函数收集并运行所有测试,如果所有测试都成功则返回0,否则返回1。这个函数最初是一个宏,这就是为什么它的名字是大写的。

通过使用超时限制测试时长

这种方法可能出现的一个问题是,异步任务可能由于任何原因而未能被调度,完成时间超过预期,或者由于任何原因未能完成。为了处理这种情况,可以使用计时器,将其超时时间设置为合理的值,以便给测试足够的时间成功完成。因此,如果计时器超时,测试将失败。以下示例通过在 std::async 返回的 future 上使用定时等待来展示这种方法:

#include <gtest/gtest.h>
#include <chrono>
#include <future>
using namespace std::chrono;
using namespace std::chrono_literals;
int asyncFunc() {
    std::this_thread::sleep_for(100ms);
    return 42;
}
TEST(AsyncTest, TestTimeOut) {
    auto start = steady_clock::now();
    std::future<int> result = std::async(
                         std::launch::async,
                         asyncFunc);
    if (result.wait_for(200ms) ==
               std::future_status::timeout) {
        FAIL() << "Test timed out!";
    }
    EXPECT_EQ(result.get(), 42);
    auto end = steady_clock::now();
    auto elapsed = duration_cast<milliseconds>(
                                end - start);
    EXPECT_LT(elapsed.count(), 200);
}
int main(int argc, char** argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

现在,调用 future 对象 resultwait_for() 函数,等待 200 毫秒以完成异步任务。由于任务将在 100 毫秒内完成,超时不会过期。如果由于任何原因,wait_for() 被调用时的时间值低于 100 毫秒,它将超时,并调用 FAIL() 宏,使测试失败。

测试继续运行并检查返回的值是否为 42,正如前一个示例中所示,并且还检查执行异步任务所花费的时间是否少于使用的超时时间。

测试回调

测试回调是一个相关任务,尤其是在实现库和 应用程序编程接口 ( API ) 时。以下示例展示了如何测试回调已被调用及其结果:

#include <gtest/gtest.h>
#include <chrono>
#include <functional>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
void asyncFunc(std::function<void(int)> callback) {
    std::thread([callback]() {
        std::this_thread::sleep_for(1s);
        callback(42);
    }).detach();
}
TEST(AsyncTest, TestCallback) {
    int result = 0;
    bool callback_called = false;
    auto callback = & {
        callback_called = true;
        result = value;
    };
    asyncFunc(callback);
    std::this_thread::sleep_for(2s);
    EXPECT_TRUE(callback_called);
    EXPECT_EQ(result, 42);
}
int main(int argc, char** argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

TestCallback 测试仅定义了一个作为 lambda 函数的回调,该 lambda 函数接受一个参数。这个 lambda 函数通过引用捕获存储 value 参数的 result 变量,以及默认为 false 并在回调被调用时设置为 truecallback_called 布尔变量。

然后,测试调用 asyncFunc() 函数,该函数启动一个线程,该线程在调用回调并传递值 42 之前等待一秒钟。测试在等待两秒钟后使用 EXPECT_TRUE 宏检查是否调用了回调,并检查 callback_called 的值,以及 result 是否具有预期的值 42

测试事件驱动软件

我们在 第九章 中看到了如何使用 Boost.Asio 和其事件队列来调度异步任务。在事件驱动编程中,通常还需要测试回调,如前一个示例所示。我们可以设置测试以注入回调并在它们被调用后验证结果。以下示例展示了如何在 Boost.Asio 程序中测试异步任务:

#include <gtest/gtest.h>
#include <boost/asio.hpp>
#include <chrono>
#include <thread>
using namespace std::chrono_literals;
void asyncFunc(boost::asio::io_context& io_context,
               std::function<void(int)> callback) {
    io_context.post([callback]() {
        std::this_thread::sleep_for(100ms);
        callback(42);
    });
}
TEST(AsyncTest, BoostAsio) {
    boost::asio::io_context io_context;
    int result = 0;
    asyncFunc(io_context, &result {
        result = value;
    });
    std::jthread io_thread([&io_context]() {
        io_context.run();
    });
    std::this_thread::sleep_for(150ms);
    EXPECT_EQ(result, 42);
}
int main(int argc, char** argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

BoostAsio 测试首先创建一个 I/O 执行上下文对象 io_context,并将其传递给 asyncFunc() 函数,同时传递一个 lambda 函数,该 lambda 函数实现一个在后台运行的任务或回调。这个回调简单地设置由 lambda 函数捕获的 result 变量的值,将其设置为传递给它的值。

asyncFunc() 函数仅使用 io_context 来发布一个任务,该任务由一个 lambda 函数组成,该函数在等待 100 毫秒后调用回调并传递值 42

然后,测试只是等待 150 毫秒,直到后台任务完成,并检查结果值是否为 42,以标记测试通过。

模拟外部资源

如果异步代码还依赖于外部资源,例如文件访问、网络服务器、计时器或其他模块,我们可能需要模拟它们,以避免由于任何资源问题导致的测试失败。模拟和存根是用于在测试目的下用假或简化的对象或函数替换或修改真实对象或函数行为的技巧。这样,我们可以控制异步代码的输入和输出,并避免副作用或其他因素的干扰。

例如,如果测试的代码依赖于服务器,服务器可能无法连接或执行其任务,导致测试失败。在这些情况下,失败是由于资源问题,而不是由于测试的异步代码,导致了一个错误,通常是一个短暂的错误。我们可以通过使用我们自己的模拟类来模拟外部资源,这些模拟类模仿它们的接口。让我们看看如何使用模拟类和使用依赖注入来测试该类的示例。

在这个例子中,有一个外部资源 AsyncTaskScheduler,其 runTask() 方法用于执行异步任务。因为我们只想测试异步任务并消除异步任务调度器可能产生的任何不期望的副作用,我们可以使用模拟类模仿 AsyncScheduler 接口。这个类是 MockTaskScheduler,它继承自 AsyncTaskScheduler 并实现了其 runTask() 基类方法,其中任务是同步运行的:

#include <gtest/gtest.h>
#include <functional>
class AsyncTaskScheduler {
   public:
    virtual int runTask(std::function<int()> task) = 0;
};
class MockTaskScheduler : public AsyncTaskScheduler {
   public:
    int runTask(std::function<int()> task) override {
        return task();
    }
};
TEST(AsyncTests, TestDependencyInjection) {
    MockTaskScheduler scheduler;
    auto task = []() -> int {
        return 42;
    };
    int result = scheduler.runTask(task);
    EXPECT_EQ(result, 42);
}
int main(int argc, char** argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

TestDependencyInjection 测试仅创建一个 MockTaskScheduler 对象和一个 lambda 函数形式的任务,并使用模拟对象通过运行 runTask() 函数来执行任务。一旦任务运行,result 将具有值 42

我们不仅可以用 gMock 库完全定义模拟类,还可以只模拟所需的方法。以下示例展示了 gMock 的应用:

#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <functional>
class AsyncTaskScheduler {
   public:
    virtual int runTask(std::function<int()> task) = 0;
};
class MockTaskScheduler : public AsyncTaskScheduler {
   public:
    MOCK_METHOD(int, runTask, (std::function<int()> task), (override));
};
TEST(AsyncTests, TestDependencyInjection) {
    using namespace testing;
    MockTaskScheduler scheduler;
    auto task = []() -> int {
        return 42;
    };
    EXPECT_CALL(scheduler, runTask(_)).WillOnce(
        Invoke(task)
    );
    auto result = scheduler.runTask(task);
    EXPECT_EQ(result, 42);
}
int main(int argc, char** argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

现在,MockTaskScheduler 也继承自 AsyncTaskScheduler,其中定义了接口,但不是通过重写其方法,而是使用 MOCK_METHOD 宏,其中传递了返回类型、模拟方法名称及其参数。

然后,TestMockMethod 测试使用 EXPECT_CALL 宏来定义对 MockTaskSchedulerrunTask() 模拟方法的预期调用,该调用只会发生一次,并调用 lambda 函数任务,该任务返回值 42

该调用仅在下一个指令中发生,其中调用 scheduler.runTask(),并将返回值存储在结果中。测试通过检查 result 是否是预期的 42 值来完成。

测试异常和失败

异步任务并不总是成功并生成有效的结果。有时可能会出错(网络故障、超时、异常等),返回错误或抛出异常是通知用户这种情况的方式。我们应该模拟失败以确保代码能够优雅地处理这些情况。

测试错误或异常可以像通常那样进行,通过使用 try-catch 块和使用断言或期望来检查是否抛出了错误,并使测试成功或失败。GoogleTest 还提供了 EXPECT_ANY_THROW() 宏,它简化了检查是否发生了异常。以下示例展示了这两种方法:

#include <gtest/gtest.h>
#include <chrono>
#include <future>
#include <iostream>
#include <stdexcept>
using namespace std::chrono_literals;
int asyncFunc(bool should_fail) {
    std::this_thread::sleep_for(100ms);
    if (should_fail) {
        throw std::runtime_error("Simulated failure");
    }
    return 42;
}
TEST(AsyncTest, TestAsyncFailure1) {
    try {
        std::future<int> result = std::async(
                             std::launch::async,
                             asyncFunc, true);
        result.get();
        FAIL() << "No expected exception thrown";
    } catch (const std::exception& e) {
        SUCCEED();
    }
}
TEST(AsyncTest, TestAsyncFailure2) {
    std::future<int> result = std::async(
                         std::launch::async,
                         asyncFunc, true);
    EXPECT_ANY_THROW(result.get());
}
int main(int argc, char** argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

TestAsyncFailure1TestAsyncFailure2 这两个测试非常相似。它们都异步执行了 asyncFunc() 函数,该函数现在接受一个 should_fail 布尔参数,指示任务是否应该成功并返回值 42,或者失败并抛出异常。两个测试都使任务失败,区别在于 TestAsyncFailure1 在没有抛出异常的情况下使用 FAIL() 宏使测试失败,或者在 try-catch 块捕获到异常时使用 SUCCEED(),而 TestAsyncFailure2 使用 EXPECT_ANY_THROW() 宏来检查在尝试通过调用其 get() 方法从 future result 获取结果时是否发生了异常。

测试多个线程

在 C++ 中测试涉及多个线程的异步软件时,一个常见且有效的技术是使用条件变量来同步线程。正如我们在 第四章 中所看到的,条件变量允许线程在满足某些条件之前等待,这使得它们对于管理线程间的通信和协调至关重要。

接下来是一个示例,其中多个线程执行一些任务,而主线程等待所有其他线程完成。

让我们先定义一些必要的全局变量,例如线程总数( num_threads ),counter 作为每次异步任务被调用时都会增加的原子变量,以及条件变量 cv 和其关联的互斥锁 mtx,这将有助于在所有异步任务完成后解锁主线程:

#include <gtest/gtest.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <syncstream>
#include <thread>
#include <vector>
using namespace std::chrono_literals;
#define sync_cout std::osyncstream(std::cout)
std::condition_variable cv;
std::mutex mtx;
bool ready = false;
std::atomic<unsigned> counter = 0;
const std::size_t num_threads = 5;

asyncTask() 函数将在增加 counter 原子变量并通过 cv 条件变量通知主线程其工作已完成之前执行异步任务(在这个例子中简单等待 100 毫秒):

void asyncTask(int id) {
    sync_cout << "Thread " << id << ": Starting work..."
              << std::endl;
    std::this_thread::sleep_for(100ms);
    sync_cout << "Thread " << id << ": Work finished."
              << std::endl;
    ++counter;
    cv.notify_one();
}

TestMultipleThreads 测试将首先启动多个线程,每个线程将异步运行 asyncTask() 任务。然后,它将等待,使用一个条件变量,其中 counter 的值与线程数相同,这意味着所有后台任务都已完成工作。条件变量使用 wait_for() 函数设置 150 毫秒的超时时间,以限制测试可以运行的时间,但为所有后台任务成功完成留出一些空间:

TEST(AsyncTest, TestMultipleThreads) {
    std::vector<std::jthread> threads;
    for (int i = 0; i < num_threads; ++i) {
        threads.emplace_back(asyncTask, i + 1);
    }
    {
        std::unique_lock<std::mutex> lock(mtx);
        cv.wait_for(lock, 150ms, [] {
            return counter == num_threads;
        });
        sync_cout << "All threads have finished."
                  << std::endl;
    }
    EXPECT_EQ(counter, num_threads);
}

测试通过检查确实 counter 的值与 num_threads 相同来结束。

最后,实现 main() 函数:

int main(int argc, char** argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

如前所述,程序通过调用 ::testing::InitGoogleTest() 来初始化 GoogleTest 库,然后调用 RUN_ALL_TESTS() 来收集和运行所有测试。

测试协程

随着 C++20 的推出,协程提供了一种编写和管理异步代码的新方法。基于协程的代码可以通过使用与其他异步代码类似的方法进行测试,但有一个细微的区别,即协程可以挂起和恢复。

让我们用一个简单的协程示例来看看。

我们在 第八章 中看到,协程有一些样板代码来定义它们的承诺类型和可等待方法。让我们先实现定义协程的 Task 结构。请重新阅读 第八章 以全面理解这段代码。

让我们先定义 Task 结构:

#include <gtest/gtest.h>
#include <coroutine>
#include <exception>
#include <iostream>
struct Task {
    struct promise_type;
    using handle_type =
              std::coroutine_handle<promise_type>;
    handle_type handle_;
    Task(handle_type h) : handle_(h) {}
    ~Task() {
        if (handle_) handle_.destroy();
    }
    // struct promise_type definition
    // and await methods
};

Task 中,我们定义 promise_type,它描述了协程是如何管理的。此类型提供了一些预定义的方法(钩子),用于控制值的返回方式、协程的挂起方式以及协程完成后资源的管理方式:

struct Task {
    // ...
    struct promise_type {
        int result_;
        std::exception_ptr exception_;
        Task get_return_object() {
            return Task(handle_type::from_promise(*this));
        }
        std::suspend_always initial_suspend() {
            return {};
        }
        std::suspend_always final_suspend() noexcept {
            return {};
        }
        void return_value(int value) {
            result_ = value;
        }
        void unhandled_exception() {
            exception_ = std::current_exception();
        }
    };
    // ....
};

然后,实现用于控制协程挂起和恢复的方法:

struct Task {
    // ...
    bool await_ready() const noexcept {
        return handle_.done();
    }
    void await_suspend(std::coroutine_handle<>
                           awaiting_handle) {
        handle_.resume();
        awaiting_handle.resume();
    }
    int await_resume() {
        if (handle_.promise().exception_) {
            std::rethrow_exception(
                handle_.promise().exception_);
        }
        return handle_.promise().result_;
    }
    int result() {
        if (handle_.promise().exception_) {
            std::rethrow_exception(
                    handle_.promise().exception_);
        }
        return handle_.promise().result_;
    }
    // ....
};

在有了 Task 结构之后,让我们定义两个协程,一个用于计算有效值,另一个用于抛出异常:

Task asyncFunc(int x) {
    co_return 2 * x;
}
Task asyncFuncWithException() {
    throw std::runtime_error("Exception from coroutine");
    co_return 0;
}

由于 GoogleTest 中的 TEST() 宏内的测试函数不能直接是协程,因为它们没有与它们关联的 promise_type 结构,我们需要定义一些辅助函数:

Task testCoroutineHelper(int value) {
    co_return co_await asyncFunc(value);
}
Task testCoroutineWithExceptionHelper() {
    co_return co_await asyncFuncWithException();
}

在此基础上,我们现在可以实施测试:

TEST(AsyncTest, TestCoroutine) {
    auto task = testCoroutineHelper(5);
    task.handle_.resume();
    EXPECT_EQ(task.result(), 10);
}
TEST(AsyncTest, TestCoroutineWithException) {
    auto task = testCoroutineWithExceptionHelper();
    EXPECT_THROW({
            task.handle_.resume();
            task.result();
        },
        std::runtime_error);
}
int main(int argc, char **argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

TestCoroutine 测试使用 testCoroutineHelper() 辅助函数定义了一个任务,并传递了值 5。在恢复协程时,预期它将返回双倍值,即 10,这通过 EXPECT_EQ() 进行测试。

TestCoroutineWithException 测试使用类似的方法,但现在使用 testCoroutineWithExceptionHelper() 辅助函数,当协程恢复时将抛出异常。这正是 EXPECT_THROW() 断言宏在检查确实异常是 std::runtime_error 类型之前所发生的事情。

压力测试

通过执行压力测试可以实现竞态条件检测。对于高度并发或多线程异步代码,压力测试至关重要。我们可以通过多个异步任务来模拟高负载,以检查系统在压力下的行为是否正确。此外,使用随机延迟、线程交错或压力测试工具也很重要,以减少确定性条件,增加测试覆盖率。

下一个示例展示了实现一个压力测试,该测试启动 100(total_nums)个线程执行异步任务,其中原子变量计数器在每个运行后随机等待增加:

#include <gtest/gtest.h>
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>
std::atomic<int> counter(0);
const std::size_t total_runs = 100;
void asyncIncrement() {
    std::this_thread::sleep_for(std::chrono::milliseconds(rand() % 100));
    counter.fetch_add(1);
}
TEST(AsyncTest, StressTest) {
    std::vector<std::thread> threads;
    for (std::size_t i = 0; i < total_runs; ++i) {
        threads.emplace_back(asyncIncrement);
    }
    for (auto& thread : threads) {
        thread.join();
    }
    EXPECT_EQ(counter, total_runs);
}
int main(int argc, char** argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

如果计数器的值与线程总数相同,则测试成功。

并行化测试

为了更快地运行测试套件,我们可以并行化在不同线程中运行的测试,但测试必须是独立的,每个测试都在特定的线程中作为一个同步的单线程解决方案运行。此外,它们还需要设置和拆除任何必要的对象,而不会保留之前测试运行的状态。

当使用 CMake 与 GoogleTest 一起时,我们可以通过指定以下命令来并行运行所有检测到的测试:

$ ctest –j <num_jobs>

本节中展示的所有示例只是测试异步代码可以进行的很小一部分。我们希望这些技术能提供足够的洞察力和知识,以开发进一步的处理特定场景的测试技术。

摘要

在本章中,我们学习了如何清理和测试异步程序。

我们首先学习了如何使用 sanitizers 来清理代码,以帮助找到多线程和异步问题,例如竞态条件、内存泄漏和作用域后使用错误等问题。

然后,描述了一些旨在处理异步软件的测试技术,使用 GoogleTest 作为测试库。

使用这些工具和技术有助于检测和预防未定义行为、内存错误和安全漏洞,同时确保并发操作正确执行,正确处理时序问题,并在各种条件下代码按预期执行。这提高了整个程序的整体可靠性和稳定性。

在下一章中,我们将学习可以用来提高异步程序运行时间和资源使用的性能和优化技术。

进一步阅读

第十三章:提高异步软件性能

在本章中,我们将介绍异步代码的性能方面。代码性能和优化是一个深奥且复杂的话题,我们无法在一章中涵盖所有内容。我们的目标是给你一个关于这个主题的良好介绍,并提供一些如何测量性能和优化代码的示例。

本章将涵盖以下关键主题:

  • 专注于多线程应用程序的性能测量工具

  • 什么是伪共享,如何识别它,以及如何修复/改进我们的代码

  • 现代 CPU 内存缓存架构简介

  • 对我们在第五章中实现的单生产者单消费者SPSC)无锁队列的回顾

技术要求

如前几章所述,你需要一个支持 C++20 的现代 C++编译器。我们将使用 GCC 13 和 Clang 18。你还需要一台运行 Linux 的 Intel/AMD 多核 CPU 的 PC。对于本章,我们使用了在具有 CPU AMD Ryzen Threadripper Pro 5975WX(32 核心)的工作站上运行的 Ubuntu 24.04 LTS。8 核心的 CPU 是理想的,但 4 核心足以运行示例。

我们还将使用 Linux perf工具。我们将在本书的后面部分解释如何获取和安装这些工具。

本章的示例可以在本书的 GitHub 存储库中找到:github.com/PacktPublishing/Asynchronous-Programming-with-CPP

性能测量工具

要了解我们应用程序的性能,我们需要能够对其进行测量。如果从这个章节中有一个关键要点,那就是永远不要估计或猜测你的代码性能。要知道你的程序是否满足其性能要求(无论是延迟还是吞吐量),你需要测量,测量,然后再测量。

一旦你从性能测试中获得数据,你就会知道代码中的热点。也许它们与内存访问模式或线程竞争(例如,当多个线程必须等待获取锁以访问资源时)有关。这就是第二个最重要的要点发挥作用的地方:在优化应用程序时设定目标。不要试图达到可能的最优性能,因为总有改进的空间。正确的方法是设定一个明确的规范,包括目标,例如事务的最大处理时间或每秒处理的网络数据包数量。

在考虑这两个主要想法的同时,让我们从我们可以用来测量代码性能的不同方法开始。

代码内分析

理解代码性能的一个非常简单但实用的方法是 代码内分析,它包括添加一些额外的代码来测量某些代码段的执行时间。这种方法在编写代码时作为一个工具使用是很好的(当然,我们需要访问源代码)。这将使我们能够找到代码中的某些性能问题,正如我们将在本章后面看到的。

我们将使用 std::chrono 作为我们分析代码的初始方法。

以下代码片段展示了我们如何使用 std::chrono 对代码进行一些基本的性能分析:

auto start = std::chrono::high_resolution_clock::now();
// processing to profile
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout < duration.count() << " milliseconds\n";

在这里,我们获取了两个时间样本,它们调用了 high_resolution_clock::now() 并打印了转换成毫秒的时间间隔。根据我们估计的处理时间,我们可以使用微秒或秒,例如。使用这种简单技术,我们可以轻松地了解处理所需的时间,并且可以轻松地比较不同的选项。

在这里,std::chrono::high_resolution_clock 是提供最高精度(实现提供的最小滴答周期)的时钟类型。C++ 标准库允许它成为 std::chrono::system_clockstd::chrono::steady_clock 的别名。libstdc++ 将其别名为 std::chrono::system_clock,而 libc++ 使用 std::chrono::steady_clock。在本章的示例中,我们使用了 GCC 和 libstdc++。时钟分辨率为 1 纳秒:

/**
 *  @brief Highest-resolution clock
 *
 *  This is the clock "with the shortest tick period." Alias to
 *  std::system_clock until higher-than-nanosecond definitions
 *  become feasible.
 *  @ingroup chrono
*/
using high_resolution_clock = system_clock;

现在,让我们看看一个完整的示例,用于分析 C++ 标准库中的两个排序算法——std::sortstd::stable_sort 的性能:

#include <algorithm>
#include <chrono>
#include <iostream>
#include <random>
#include <utility>
int uniform_random_number(int min, int max) {
    static std::random_device rd;
    static std::mt19937 gen(rd());
    std::uniform_int_distribution dis(min, max);
    return dis(gen);
}
std::vector<int> random_vector(std::size_t n, int32_t min_val, int32_t max_val) {
    std::vector<int> rv(n);
    std::ranges::generate(rv, [&] {
            return uniform_random_number(min_val, max_val);
        });
    return rv;
}
using namespace std::chrono;
int main() {
    constexpr uint32_t elements = 100000000;
    int32_t minval = 1;
    int32_t maxval = 1000000000;
    auto rv1 = random_vector(elements, minval, maxval);
    auto rv2 = rv1;
    auto start = high_resolution_clock::now();
    std::ranges::sort(rv1);
    auto end = high_resolution_clock::now();
    auto duration = duration_cast<milliseconds>(end - start);
    std::cout << "Time to std::sort "
              << elements << " elements with values in ["
              << minval << "," << maxval << "] "
              << duration.count() << " milliseconds\n";
    start = high_resolution_clock::now();
    std::ranges::stable_sort(rv2);
    end = high_resolution_clock::now();
    duration = duration_cast<milliseconds>(end - start);
    std::cout << "Time to std::stable_sort "
              << elements << " elements with values in ["
              << minval << "," << maxval << "] "
              << duration.count() << " milliseconds\n";
    return 0;
}

上一段代码生成一个正态分布的随机数向量,然后使用 std::sort()std::stable_sort() 对向量进行排序。这两个函数都排序向量,但 std::sort() 使用了称为 introsort 的快速排序和插入排序算法的组合,而 std::stable_sort() 使用了归并排序。排序是 稳定的,因为在原始和排序后的向量中,等效键具有相同的顺序。对于整数向量来说,这并不重要,但如果向量有三个具有相同值的元素,在排序向量后,数字将保持相同的顺序。

运行代码后,我们得到以下输出:

Time to std::sort 100000000 elements with values in [1,1000000000] 6019 milliseconds
Time to std::stable_sort 100000000 elements with values in [1,1000000000] 7342 milliseconds

在这个例子中,std::stable_sort() 的速度比 std::sort() 慢。

在本节中,我们了解了一种简单的方法来测量代码段运行时间。这种方法是侵入性的,需要我们修改代码;它主要在我们开发应用程序时使用。在下一节中,我们将介绍另一种测量执行时间的方法,称为微基准测试。

代码微基准测试

有时候,我们只想独立分析一小段代码。我们可能需要多次运行它,然后获取平均运行时间或使用不同的输入数据运行它。在这些情况下,我们可以使用基准测试库(也称为 微基准测试)来完成这项工作——在不同的条件下执行我们代码的小部分。

微基准测试必须作为指导。请注意,代码在隔离状态下运行,当我们将所有代码一起运行时,由于代码不同部分之间复杂的交互,这可能会给我们带来非常不同的结果。请谨慎使用,并意识到微基准测试可能会误导。

我们可以使用许多库来基准测试我们的代码。我们将使用 Google Benchmark,这是一个非常好且广为人知的库。

让我们从获取代码和编译库开始。要获取代码,请运行以下命令:

git clone https://github.com/google/benchmark.git
cd benchmark
git clone https://github.com/google/googletest.git

一旦我们有了基准测试和 Google Test 库的代码(后者是编译前者所必需的),我们就会构建它。

为构建创建一个目录:

mkdir build
cd build

有了这些,我们在基准测试目录内创建了构建目录。

接下来,我们将使用 CMake 来配置构建并创建 make 所需的所有必要信息:

cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBRARIES=ON -DCMAKE_INSTALL_PREFIX=/usr/lib/x86_64-linux-gnu/

最后,运行 make 来构建和安装库:

make -j16
sudo make install

您还需要将库添加到 CmakeLists.txt 文件中。我们已经在本书的代码中为您完成了这项工作。

一旦安装了 Google Benchmark,我们就可以通过一些基准函数的示例来学习如何使用该库进行一些基本的基准测试。

注意,std::chrono 和 Google Benchmark 都不是专门用于处理异步/多线程代码的工具,它们更像是通用工具。

这是使用 Google Benchmark 的第一个示例:

#include <benchmark/benchmark.h>
#include <algorithm>
#include <chrono>
#include <iostream>
#include <random>
#include <thread>
void BM_vector_push_back(benchmark::State& state) {
    for (auto _ : state) {
        std::vector<int> vec;
        for (int i = 0; i < state.range(0); i++) {
            vec.push_back(i);
        }
    }
}
void BM_vector_emplace_back(benchmark::State& state) {
    for (auto _ : state) {
        std::vector<int> vec;
        for (int i = 0; i < state.range(0); i++) {
            vec.emplace_back(i);
        }
    }
}
void BM_vector_insert(benchmark::State& state) {
    for (auto _ : state) {
        std::vector<int> vec;
        for (int i = 0; i < state.range(0); i++) {
            vec.insert(vec.begin(), i);
        }
    }
}
BENCHMARK(BM_vector_push_back)->Range(1, 1000);
BENCHMARK(BM_vector_emplace_back)->Range(1, 1000);
BENCHMARK(BM_vector_insert)->Range(1, 1000);
int main(int argc, char** argv) {
    benchmark::Initialize(&argc, argv);
    benchmark::RunSpecifiedBenchmarks();
    return 0;
}

我们需要包含库头文件:

#include <benchmark/benchmark.h>

所有基准测试函数都具有以下签名:

void benchmark_function(benchmark::State& state);

这是一个具有一个参数的函数,benchmark::State& state,它返回 voidbenchmark::State 参数具有双重用途:

  • 控制迭代循环benchmark::State 对象用于控制被基准测试的函数或代码应该执行多少次。通过重复测试足够多次以最小化变异性并收集有意义的数据,这有助于准确测量性能。

  • 测量时间和统计信息state 对象跟踪基准测试代码的运行时间,并提供报告指标(如经过时间、迭代次数和自定义计数器)的机制。

我们实现了三个函数来以不同的方式基准测试向 std::vector 序列添加元素:第一个函数使用 std::vector::push_back,第二个使用 std::vector::emplace_back,第三个使用 std::vector::insert。前两个函数在向量的末尾添加元素,而第三个函数在向量的开头添加元素。

一旦我们实现了基准测试函数,我们需要告诉库它们必须作为基准测试运行:

BENCHMARK(BM_vector_push_back)->Range(1, 1000);

我们使用 BENCHMARK 宏来完成这项工作。对于本例中的基准测试,我们设置了每次迭代要插入向量中的元素数量。范围从 11000,每次迭代将插入前一次迭代元素数量的八倍,直到达到最大值。在这种情况下,它将插入 1、8、64、512 和 1000 个元素。

当我们运行第一个基准测试程序时,我们得到以下输出:

2024-10-17T05:02:37+01:00
Running ./13x02-benchmark_vector
Run on (64 X 3600 MHz CPU s)
CPU Caches:
  L1 Data 32 KiB (x32)
  L1 Instruction 32 KiB (x32)
  L2 Unified 512 KiB (x32)
  L3 Unified 32768 KiB (x4)
Load Average: 0.00, 0.02, 0.16
----------------------------------------------------------------------
Benchmark                            Time             CPU   Iterations
----------------------------------------------------------------------
BM_vector_push_back/1             10.5 ns         10.5 ns     63107997
BM_vector_push_back/8             52.0 ns         52.0 ns     13450361
BM_vector_push_back/64             116 ns          116 ns      6021740
BM_vector_push_back/512            385 ns          385 ns      1819732
BM_vector_push_back/1000           641 ns          641 ns      1093474
BM_vector_emplace_back/1          10.8 ns         10.8 ns     64570848
BM_vector_emplace_back/8          53.3 ns         53.3 ns     13139191
BM_vector_emplace_back/64          108 ns          108 ns      6469997
BM_vector_emplace_back/512         364 ns          364 ns      1924992
BM_vector_emplace_back/1000        616 ns          616 ns      1138392
BM_vector_insert/1                10.6 ns         10.6 ns     65966159
BM_vector_insert/8                58.6 ns         58.6 ns     11933446
BM_vector_insert/64                461 ns          461 ns      1485319
BM_vector_insert/512              7249 ns         7249 ns        96756
BM_vector_insert/1000            23352 ns        23348 ns        29742

首先,程序打印出基准测试执行的信息:日期和时间、可执行文件名称以及它所运行的 CPU 信息。

看看以下这一行:

Load Average: 0.00, 0.02, 0.16

这一行给出了 CPU 负载的估计:从 0.0(完全没有负载或非常低的负载)到 1.0(完全加载)。这三个数字分别对应于过去 5、10 和 15 分钟的 CPU 负载。

在打印 CPU 负载信息后,基准测试会打印出每次迭代的成果。以下是一个示例:

BM_vector_push_back/64             116 ns          116 ns      6021740

这意味着 BM_vector_push_back 被调用了 6,021,740 次(迭代次数),在向量的插入过程中插入了 64 个元素。

时间CPU 列给出了每次迭代的平均时间:

  • 时间:这是从基准测试开始到结束所经过的真正时间。它包括基准测试期间发生的所有事情:CPU 计算、I/O 操作、上下文切换等。

  • CPU 时间:这是 CPU 处理基准测试指令所花费的时间。它可以小于或等于 时间

在我们的基准测试中,因为操作很简单,我们可以看到 时间CPU 大多数情况下是相同的。

通过查看结果,我们可以得出以下结论:

  • 对于简单的对象,例如 32 位整数,push_backemplace_back 花费的时间相同。

  • 在这里,对于少量元素,insertpush_back / emplace_back 花费的时间相同,但从 64 个元素开始,它需要更多的时间。这是因为每次插入后,insert 必须复制向量中所有后续的元素(我们在向量的开头插入元素)。

以下示例也排序了一个 std::vector 序列,但这次,我们将使用微基准测试来测量执行时间:

#include <benchmark/benchmark.h>
#include <algorithm>
#include <chrono>
#include <iostream>
#include <random>
#include <thread>
std::vector<int> rv1, rv2;
int uniform_random_number(int min, int max) {
    static std::random_device rd;
    static std::mt19937 gen(rd());
    std::uniform_int_distribution dis(min, max);
    return dis(gen);
}
std::vector<int> random_vector(std::size_t n, int32_t min_val, int32_t max_val) {
    std::vector<int> rv(n);
    std::ranges::generate(rv, [&] {
        return uniform_random_number(min_val, max_val);
    });
    return rv;
}
static void BM_vector_sort(benchmark::State& state, std::vector<int>& vec) {
    for (auto _ : state) {
        std::ranges::sort(vec);
    }
}
static void BM_vector_stable_sort(benchmark::State& state, std::vector<int>& vec) {
    for (auto _ : state) {
        std::ranges::stable_sort(vec);
    }
}
BENCHMARK_CAPTURE(BM_vector_sort, vector, rv1)->Iterations(1)->Unit(benchmark::kMillisecond);
BENCHMARK_CAPTURE(BM_vector_stable_sort, vector, rv2)->Iterations(1)->Unit(benchmark::kMillisecond);
int main(int argc, char** argv) {
    constexpr uint32_t elements = 100000000;
    int32_t minval = 1;
    int32_t maxval = 1000000000;
    rv1 = random_vector(elements, minval, maxval);
    rv2 = rv1;
    benchmark::Initialize(&argc, argv);
    benchmark::RunSpecifiedBenchmarks();
    return 0;
}

上述代码生成一个随机数字的向量。在这里,我们运行两个基准测试函数来排序向量:一个使用 std::sort,另一个使用 std::stable_sort。请注意,我们使用了同一个向量的两个副本,所以两个函数的输入是相同的。

以下代码行使用了 BENCHMARK_CAPTURE 宏。这个宏允许我们将参数传递给我们的基准测试函数——在这种情况下,一个对 std::vector 的引用(我们通过引用传递以避免复制向量并影响基准测试结果)。

我们指定结果以毫秒为单位而不是纳秒:

BENCHMARK_CAPTURE(BM_vector_sort, vector, rv1)->Iterations(1)->Unit(benchmark::kMillisecond);

下面是基准测试的结果:

-------------------------------------------------------------------------
Benchmark                          Time         CPU   Iterations
-------------------------------------------------------------------------
BM_vector_sort                     5877 ms      5876 ms            1
BM_vector_stable_sort.             7172 ms      7171 ms            1

结果与我们使用 std::chrono 测量时间得到的结果一致。

对于我们最后的 Google Benchmark 示例,我们将创建一个线程(std::thread):

#include <benchmark/benchmark.h>
#include <algorithm>
#include <chrono>
#include <iostream>
#include <random>
#include <thread>
static void BM_create_terminate_thread(benchmark::State& state) {
    for (auto _ : state) {
        std::thread thread([]{ return -1; });
        thread.join();
    }
}
BENCHMARK(BM_create_terminate_thread)->Iterations(2000);
int main(int argc, char** argv) {
    benchmark::Initialize(&argc, argv);
    benchmark::RunSpecifiedBenchmarks();
    return 0;
}

这个例子很简单:BM_create_terminate_thread 创建一个线程(什么都不做,只是返回 0)并等待它结束(thread.join())。我们运行 2000 次迭代以估计创建线程所需的时间。

结果如下:

---------------------------------------------------------------
----------
Benchmark                        Time             CPU
Iterations
---------------------------------------------------------------
----------
BM_create_terminate_thread.       32424 ns        21216 ns     2000

在本节中,我们学习了如何使用 Google Benchmark 库创建微基准来测量某些函数的执行时间。再次强调,微基准只是一个近似值,由于被基准测试的代码的隔离性质,它们可能会有误导性。请谨慎使用。

Linux 的 perf 工具

在我们的代码中使用 std::chrono 或像 Google Benchmark 这样的微基准库需要获取要分析代码的访问权限,并且能够通过添加额外的调用来测量代码段的执行时间或运行小的代码片段作为微基准函数来修改它。

使用 Linux 的 perf 工具,我们可以分析程序的执行,而不需要更改其任何代码。

Linux 的 perf 工具是一个强大、灵活且广泛使用的性能分析和分析工具,适用于 Linux 系统。它提供了对内核和用户空间级别的系统性能的详细洞察。

让我们考虑 perf 的主要用途。

首先,我们有 CPU 分析perf 工具允许你捕获进程的执行配置文件,测量哪些函数消耗了最多的 CPU 时间。这可以帮助识别代码中 CPU 密集的部分和瓶颈。

以下命令行将在我们编写的用于说明工具基本原理的小型 13x07-thread_contention 程序上运行 perf。此应用程序的代码可以在本书的 GitHub 仓库中找到:

perf record --call-graph dwarf ./13x07-thread_contention

--call-graph 选项将函数调用层次结构的数据记录在名为 perf.data 的文件中,而 dwarf 选项指示 perf 使用 dwarf 文件格式来调试符号(以获取函数名称)。

在之前的命令之后,我们必须运行以下命令:

 perf script > out.perf

这将把记录的数据(包括调用栈)输出到名为 out.perf 的文本文件中。

现在,我们需要将文本文件转换为带有调用图的图片。为此,我们可以运行以下命令:

gprof2dot -f perf out.perf -o callgraph.dot

这将生成一个名为 callgraph.dot 的文件,可以使用 Graphviz 进行可视化。

你可能需要安装 gprof2dot。为此,你需要在你的电脑上安装 Python。运行以下命令来安装 gprof2dot

pip install gprof2dot

还需要安装 Graphviz。在 Ubuntu 上,你可以这样做:

sudo apt-get install graphviz

最后,你可以通过运行以下命令生成 callgraph.png 图片:

dot -Tpng callgraph.dot -o callgraph.png

另一种非常常见的可视化程序调用图的方法是使用火焰图。

要生成火焰图,请克隆FlameGraph仓库:

git clone https://github.com/brendangregg/FlameGraph.git

FlameGraph文件夹中,您将找到生成火焰图的脚本。

运行以下命令:

FlameGraph/stackcollapse-perf.pl out.perf > out.folded

此命令将堆栈跟踪折叠成火焰图工具可以使用的形式。现在,运行以下命令:

Flamegraph/flamegraph.pl out.folded > flamegraph.svg

您可以使用网页浏览器可视化火焰图:

图 13.1:火焰图的概述

图 13.1:火焰图的概述

现在,让我们学习如何收集程序的性能统计数据。

以下命令将显示在13x05-sort_perf执行期间执行的指令数量和使用的 CPU 周期。每周期指令数是 CPU 在每个时钟周期中执行的指令的平均数。此指标仅在微基准测试或测量代码的短部分时才有用。对于此示例,我们可以看到 CPU 每个周期执行一条指令,这对于现代 CPU 来说是平均的。在多线程代码中,由于执行的并行性,我们可以得到一个更大的数字,但此指标通常用于测量和优化在单个 CPU 核心中执行的代码。此数字必须解释为我们如何保持 CPU 忙碌,因为它取决于许多因素,例如内存读取/写入的数量、内存访问模式(线性连续/非线性)、代码中的分支级别等:

perf stat -e instructions,cycles ./13x05-sort_perf

运行前面的命令后,我们得到了以下结果:

Performance counter stats for './13x05-sort_perf':
    30,993,024,309      instructions                     #     1.03   
             insn per cycle
    30,197,863,655      cycles
       6.657835162 seconds time elapsed
       6.502372000 seconds user
       0.155008000 seconds sys

运行以下命令,您可以获取所有预定义事件的列表,您可以使用perf分析这些事件:

perf list

让我们再进行几个操作:

perf stat -e branches ./13x05-sort_perf

之前的命令测量了已执行的分支指令的数量。我们得到了以下结果:

Performance counter stats for './13x05-sort_perf':
     5,246,138,882      branches
       6.712285274 seconds time elapsed
       6.551799000 seconds user
       0.159970000 seconds sys

在这里,我们可以看到,执行指令中有六分之一是分支指令,这在排序大型向量的程序中是预期的。

如前所述,测量代码中的分支级别很重要,尤其是在代码的短部分(以避免可能影响测量的交互)。如果没有分支或只有很少的分支,CPU 将运行指令的速度会更快。分支的主要问题是 CPU 可能需要重建流水线,这可能会很昂贵,尤其是如果分支在内部/关键循环中。

以下命令将报告 L1 缓存数据访问的数量(我们将在下一节中看到 CPU 缓存):

perf stat -e all_data_cache_accesses ./13x05-sort_perf

我们得到了以下结果:

Performance counter stats for './13x05-sort_perf':
    21,286,061,764      all_data_cache_accesses
       6.718844368 seconds time elapsed
       6.561416000 seconds user
       0.157009000 seconds sys

让我们回到我们的锁竞争示例,并使用perf收集一些有用的统计数据。

使用perf的另一个好处是CPU 迁移——也就是说,线程从一个 CPU 核心移动到另一个核心的次数。线程在核心之间的迁移可能会降低缓存性能,因为当线程移动到新的核心时,会失去缓存数据的优势(关于缓存的更多内容将在下一节中介绍)。

让我们运行以下命令:

perf stat -e cpu-migrations ./13x07-thread_contention

这导致了以下输出:

Performance counter stats for './13x08-thread_contention':
                45      cpu-migrations
      50.476706194 seconds time elapsed
      57.333880000 seconds user
     262.123060000 seconds sys

让我们看看使用 perf 的另一个优点:上下文切换。它计算执行过程中的上下文切换次数(线程被交换出去和另一个线程被调度的次数)。高上下文切换可能表明太多线程正在竞争 CPU 时间,从而导致性能下降。

让我们运行以下命令:

perf stat -e context-switches ./13x07-thread_contention

这导致以下输出:

Performance counter stats for './13x08-thread_contention':
        13,867,866      cs
      47.618283562 seconds time elapsed
      52.931213000 seconds user
     247.033479000 seconds sys

这一节的内容到此结束。在这里,我们介绍了 Linux perf 工具及其一些应用。我们将在下一节研究 CPU 内存缓存和假共享。

假共享

在本节中,我们将研究多线程应用程序中一个常见的问题,称为 假共享

我们已经知道,多线程应用程序的理想实现是尽量减少不同线程之间共享的数据。理想情况下,我们应该只为读取访问共享数据,因为在这种情况下,我们不需要同步线程来访问共享数据,因此我们不需要支付运行时成本,也不需要处理死锁和活锁等问题。

现在,让我们考虑一个简单的例子:四个线程并行运行,生成随机数,并计算它们的总和。每个线程独立工作,生成随机数并计算存储在它刚刚写入的变量中的总和。这是一个理想的应用(尽管对于这个例子来说有点牵强),线程独立工作,没有任何共享数据。

以下代码是我们将在本节中分析的示例的完整源代码。在阅读解释时,你可以参考它:

#include <chrono>
#include <iostream>
#include <random>
#include <thread>
#include <vector>
struct result_data {
    unsigned long result { 0 };
};
struct alignas(64) aligned_result_data {
    unsigned long result { 0 };
};
void set_affinity(int core) {
    if (core < 0) {
        return;
    }
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(core, &cpuset);
    if (pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset) != 0) {
        perror("pthread_setaffinity_np");
        exit(EXIT_FAILURE);
    }
}
template <typename T>
auto random_sum(T& data, const std::size_t seed, const unsigned long iterations, const int core) {
    set_affinity(core);
    std::mt19937 gen(seed);
    std::uniform_int_distribution dist(1, 5);
    for (unsigned long i = 0; i < iterations; ++i) {
        data.result += dist(gen);
    }
}
using namespace std::chrono;
void sum_random_unaligned(int num_threads, uint32_t iterations) {
    auto* data = new(static_cast<std::align_val_t>(64)) result_data[num_threads];
    auto start = high_resolution_clock::now();
    std::vector<std::thread> threads;
    for (std::size_t i = 0; i < num_threads; ++i) {
        set_affinity(i);
        threads.emplace_back(random_sum<result_data>, std::ref(data[i]), i, iterations, i);
    }
    for (auto& thread : threads) {
        thread.join();
    }
    auto end = high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<milliseconds>(end - start);
    std::cout << "Non-aligned data: " << duration.count() << " milliseconds" << std::endl;
    operator delete[] (data, static_cast<std::align_val_t>(64));
}
void sum_random_aligned(int num_threads, uint32_t iterations) {
    auto* aligned_data = new(static_cast<std::align_val_t>(64)) aligned_result_data[num_threads];
    auto start = high_resolution_clock::now();
    std::vector<std::thread> threads;
    for (std::size_t i = 0; i < num_threads; ++i) {
        set_affinity(i);
        threads.emplace_back(random_sum<aligned_result_data>, std::ref(aligned_data[i]), i, iterations, i);
    }
    for (auto& thread : threads) {
        thread.join();
    }
    auto end = high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<milliseconds>(end - start);
    std::cout << "Aligned data: " << duration.count() << " milliseconds" << std::endl;
    operator delete[] (aligned_data, static_cast<std::align_val_t>(64));
}
int main() {
    constexpr unsigned long iterations{ 100000000 };
    constexpr unsigned int num_threads = 8;
    sum_random_unaligned(8, iterations);
    sum_random_aligned(8, iterations);
    return 0;
}

如果你编译并运行前面的代码,你会得到类似于以下输出的结果:

Non-aligned data: 4403 milliseconds
Aligned data: 160 milliseconds

程序仅调用两个函数:sum_random_unalignedsum_random_aligned。这两个函数做的是同一件事:它们创建八个线程,每个线程生成随机数并计算它们的总和。线程之间没有共享数据。你可以看到这两个函数几乎相同,主要区别在于 sum_random_unaligned 使用以下数据结构来存储生成的随机数的总和:

struct result_data {
    unsigned long result { 0 };
};

sum_random_aligned 函数使用了一个稍微不同的方法:

struct alignas(64) aligned_result_data {
    unsigned long result { 0 };
};

唯一的区别是使用了 alignas(64) 来通知编译器,数据结构实例必须在 64 字节边界上对齐。

我们可以看到,性能差异非常明显,因为线程正在执行相同的任务。只需将每个线程写入的变量对齐到 64 字节边界,就可以大大提高性能。

要理解为什么会发生这种情况,我们需要考虑现代 CPU 的一个特性——内存缓存。

CPU 内存缓存

现代 CPU 在计算方面非常快,当我们想要达到最大性能时,内存访问是主要的瓶颈。内存访问的良好估计约为 150 纳秒。在这段时间内,我们的 3.6 GHz CPU 已经通过了 540 个时钟周期。作为一个粗略估计,如果 CPU 每两个周期执行一条指令,那么就是 270 条指令。对于一个普通应用程序,内存访问是一个问题,即使编译器可能会重新排序它生成的指令,CPU 也可能重新排序指令以优化内存访问并尽可能多地运行指令。

因此,为了提高现代 CPU 的性能,我们有了所谓的CPU 缓存内存缓存,这是芯片中的内存,用于存储数据和指令。这种内存比 RAM 快得多,允许 CPU 更快地检索数据,从而显著提高整体性能。

作为现实生活中的缓存示例,想想一个厨师。他们需要一些原料来为他们的餐厅客户准备午餐。现在,想象一下,他们只有在客户来到餐厅并点餐时才购买这些原料。这将非常慢。他们也可以去超市购买一天的原料,比如。现在,他们可以为所有客户烹饪,并在更短的时间内为他们提供餐点。

CPU 缓存遵循相同的概念:当 CPU 需要访问一个变量时,比如一个 4 字节的整数,它会读取 64 字节(这个大小可能因 CPU 而异,但大多数现代 CPU 使用这个大小)的连续内存,以防万一它可能需要访问更多的连续数据。

线性内存数据结构,如std::vector,在内存访问方面将表现得更好,因为这些情况下,缓存可以大幅提高性能。对于其他类型的数据结构,如std::list,则不会是这样。当然,这仅仅是关于优化缓存使用。

你可能想知道,如果 CPU 缓存内存如此之好,为什么所有内存都像那样?答案是成本。缓存内存非常快(比 RAM 快得多),但它也非常昂贵。

现代 CPU 采用分层缓存结构,通常由三个级别组成,称为 L1、L2 和 L3:

  • L1 缓存是最小和最快的。它也是最接近 CPU 的,同时也是最昂贵的。它通常分为两部分:一个指令缓存用于存储指令,一个数据缓存用于存储数据。典型的大小是 64 Kb,分为 32 Kb 用于指令和 32 Kb 用于数据。L1 缓存的典型访问时间在 1 到 3 纳秒之间。

  • L2 缓存比 L1 大,速度略慢,但仍然比 RAM 快得多。典型的 L2 缓存大小在 128 Kb 到 512 Kb 之间(本章中使用的 CPU 每个核心有 512 Kb 的 L2 缓存)。典型的 L2 缓存访问时间约为 3 到 5 纳秒。

  • L3 缓存是三者中最大且速度最慢的。L1 和 L2 缓存是每个核心的(每个核心都有自己的 L1 和 L2 缓存),但 L3 是多个核心共享的。我们的 CPU 每组八个核心共享 32 Mb 的 L3 缓存。典型的访问时间大约是 10 到 15 纳秒。

有了这些,让我们将注意力转向与内存缓存相关的一个重要概念。

缓存一致性

CPU 不直接访问 RAM。这种访问总是通过缓存进行的,只有当 CPU 在缓存中找不到所需的数据时才会访问 RAM。在多核系统中,每个核心都有自己的缓存意味着同一块 RAM 可能同时存在于多个核心的缓存中。这些副本需要始终同步;否则,计算结果可能会不正确。

到目前为止,我们已经看到每个核心都有自己的 L1 缓存。让我们回到我们的例子,思考一下当我们使用非对齐内存运行函数时会发生什么。

在这种情况下,每个 result_data 实例是 8 字节。我们创建了一个包含 8 个 result_data 实例的数组,每个线程一个。总共占用的内存将是 64 字节,所有实例在内存中都是连续的。每次线程更新随机数的总和时,它都会改变存储在缓存中的值。记住,CPU 总是会一次读取和写入 64 字节(这被称为缓存行——你可以将其视为最小的内存访问单元)。所有变量都在同一个缓存行中,即使线程没有共享它们(每个线程都有自己的变量——sum),CPU 也不知道这一点,需要使更改对所有核心可见。

在这里,我们有 8 个核心,每个核心都在运行一个线程。每个核心已经从 RAM 中加载了 64 字节的内存到 L1 缓存中。由于线程只读取变量,所以一切正常,但一旦某个线程修改了它的变量,缓存行的内容就会被无效化。

现在,由于剩余的 7 个核心中的缓存行无效,CPU 需要将更改传播到所有核心。如前所述,即使线程没有共享变量,CPU 也不可能知道这一点,并且需要更新所有核心的所有缓存行以保持值的一致性。这被称为缓存一致性。如果线程共享变量,那么不将更改传播到所有核心是不正确的。

在我们的例子中,缓存一致性协议在 CPU 内部产生了相当多的流量,因为所有线程都共享变量所在的内存区域,尽管从程序的角度来看它们并不共享。这就是我们称之为伪共享的原因:变量之所以共享,是因为缓存和缓存一致性协议的工作方式。

当我们将数据对齐到 64 字节边界时,每个实例占用 64 字节。这保证了它们位于自己的缓存行中,并且不需要缓存一致性流量,因为在这种情况下,没有数据共享。在这种情况下,性能要好得多。

让我们使用perf来确认这一点是否真的发生了。

首先,我们在执行sum_random_unaligned时运行perf。我们想看看程序访问缓存的次数和缓存未命中的次数。每次缓存需要更新,因为它包含的数据也在另一个核心的缓存行中,都算作一次缓存未命中:

perf stat -e cache-references,cache-misses ./13x07-false_sharing

我们得到以下结果:

Performance counter stats for './13x07-false_sharing':
       251,277,877      cache-references
       242,797,999      cache-misses
                        # 96.63% of all cache refs

大多数缓存引用都是缓存未命中。这是预期的,因为伪共享。

现在,如果我们运行sum_random_aligned,结果会有很大不同:

Performance counter stats for './13x07-false_sharing':
           851,506      cache-references
           231,703      cache-misses
                        # 27.21% of all cache refs

缓存引用和缓存未命中的数量都小得多。这是因为不需要不断更新所有核心的缓存以保持缓存一致性。

在本节中,我们看到了多线程代码中最常见的性能问题之一:伪共享。我们看到了一个带有和没有伪共享的函数示例,以及伪共享对性能的负面影响。

在下一节中,我们将回到我们在第五章中实现的 SPSC 无锁队列,并提高其性能。

SPSC 无锁队列

第五章中,我们实现了一个 SPSC 无锁队列,作为如何从两个线程同步访问数据结构的示例,而不使用锁。这个队列仅由两个线程访问:一个生产者将数据推送到队列,一个消费者从队列中弹数据。这是最容易同步的队列。

我们使用了两个原子变量来表示队列的头部(读取缓冲区索引)和尾部(写入缓冲区索引):

std::atomic<std::size_t> head_ { 0 };
std::atomic<std::size_t> tail_ { 0 };

为了避免伪共享,我们可以将代码更改为以下内容:

alignas(64) std::atomic<std::size_t> head_ { 0 };
alignas(64) std::atomic<std::size_t> tail_ { 0 };

在这次更改之后,我们可以运行我们实现的代码来测量生产者和消费者线程每秒执行的操作数(推/弹)。代码可以在本书的 GitHub 仓库中找到。

现在,我们可以运行perf

perf stat -e cache-references,cache-misses ./13x09-spsc_lock_free_queue

我们将得到以下结果:

101559149 ops/sec
 Performance counter stats for ‹./13x09-spsp_lock_free_queue›:
       532,295,487      cache-references
       219,861,054      cache-misses                     #   41.30% of all cache refs
       9.848523651 seconds time elapsed

在这里,我们可以看到队列每秒能够处理大约 1 亿次操作。此外,大约有 41%的缓存未命中。

让我们回顾一下队列的工作原理。在这里,生产者是唯一写入tail_的线程,消费者是唯一写入head_的线程。尽管如此,两个线程都需要读取tail_head_。我们已经将这两个原子变量声明为aligned(64),以确保它们保证位于不同的缓存行中,从而没有伪共享。然而,存在真正的共享。真正的共享也会生成缓存一致性流量。

真正的共享意味着两个线程都共享对两个变量的访问权限,即使每个变量只是由一个线程(并且总是同一个线程)写入。在这种情况下,为了提高性能,我们必须减少共享,尽可能避免每个线程对两个变量的读访问。我们无法避免数据共享,但我们可以减少它。

让我们关注生产者(对于消费者也是同样的机制):

bool push(const T &item) {
    std::size_t tail = tail_.load(std::memory_order_relaxed);
    std::size_t next_tail = (tail + 1) & (capacity_ - 1);
    if (next_tail == cache_head_) {
        cache_head_ = head_.load(std::memory_order_acquire);
        if (next_tail == cache_head_) {
            return false;
        }
    }
    buffer_[tail] = item;
    tail_.store(next_tail, std::memory_order_release);
    return true;
}

push() 函数只由生产者调用。

让我们分析一下该函数的功能:

  • 它原子地读取环形缓冲区中存储最后一个项目的索引:

    std::size_t tail = tail_.load(std::memory_order_relaxed);
    
  • 它计算项目将在环形缓冲区中存储的索引:

    std::size_t next_tail = (tail + 1) & (capacity_ - 1);
    
  • 它检查环形缓冲区是否已满。然而,它不是读取 head_ ,而是读取缓存的头部值:

        if (next_tail == cache_head_) {
    

    初始时,cache_head_cache_tail_ 都被设置为零。如前所述,使用这两个变量的目的是最小化核心之间的缓存更新。缓存变量技术是这样的:每次调用 push(或 pop )时,我们原子地读取 tail_(由同一线程写入,因此不需要缓存更新)并生成下一个存储传递给 push 函数的项目索引。现在,我们不是使用 head_ 来检查队列是否已满,而是使用 cache_head_,它只被一个线程(生产者线程)访问,避免了任何缓存一致性流量。如果队列“已满”,则通过原子加载 head_ 来更新 cache_head_。在此更新之后,我们再次检查。如果第二次检查的结果是队列已满,则返回 false

    使用这些局部变量(生产者使用 cache_head_ ,消费者使用 cache_tail_ )的优势在于它们减少了真正的共享——也就是说,访问可能在不同核心的缓存中更新的变量。当生产者在消费者尝试获取它们之前在队列中推送多个项目时(消费者也是如此),这将表现得更好。比如说生产者在队列中插入 10 个项目,而消费者尝试获取一个项目。在这种情况下,使用缓存变量进行的第一次检查将告诉我们队列是空的,但在更新为实际值之后,它将正常工作。消费者只需通过检查队列是否为空(只读取 cache_tail_ 变量)就可以获取另外九个项目。

  • 如果环形缓冲区已满,则更新 cache_head_

    head_.load(std::memory_order_acquire);
            if (next_tail == cache_head_) {
                return false;
            }
    
  • 如果缓冲区已满(不仅仅是 cache_head_ 需要更新),则返回 false 。生产者无法将新项目推送到队列中。

  • 如果缓冲区未满,将项目添加到环形缓冲区并返回 true

    buffer_[tail] = item;
        tail_.store(next_tail, std::memory_order_release);
        return true;
    

我们可能减少了生产者线程访问 tail_ 的次数,从而减少了缓存一致性流量。考虑以下情况:生产者和消费者使用队列,生产者调用 push()。当 push() 更新 cache_head_ 时,它可能比 tail_ 前面多一个槽位,这意味着我们不需要读取 tail_

同样的原则也适用于消费者和 pop()

在修改代码以减少缓存一致性流量后,让我们再次运行 perf

162493489 ops/sec
 Performance counter stats for ‹./13x09-spsp_lock_free_queue›:
       474,296,947      cache-references
       148,898,301      cache-misses                     #   31.39% of all cache refs
       6.156437788 seconds time elapsed
      12.309295000 seconds user
       0.000999000 seconds sys

在这里,我们可以看到性能提高了大约 60%,并且缓存引用和缓存缺失的数量更少。

通过这样,我们学习了如何通过减少两个线程之间共享数据的访问来提高性能。

摘要

在本章中,我们介绍了你可以用来分析代码的三个方法:std::chrono,使用 Google Benchmark 库进行微基准测试,以及 Linux 的 perf 工具。

我们还看到了如何通过减少/消除伪共享和减少真实共享来提高多线程程序的性能,从而减少缓存一致性流量。

本章提供了一些基本的分析技术介绍,这些技术将作为进一步研究的起点非常有用。正如我们在本章开头所说,性能是一个复杂的话题,值得有它自己的书籍。

进一步阅读

  • Fedor G. Pikus,编写高效程序的艺术,第一版,Packt Publishing,2021。

  • Ulrich Drepper,程序员应了解的内存知识,2007。

  • Shivam Kunwar,优化多线程性能www.youtube.com/watch?v=yN7C3SO4Uj8)。

posted @ 2025-10-07 17:57  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报