├── LICENSE
├── README.md
├── book.tex
├── book
├── ccs.tex
├── content
│ ├── Preface.tex
│ ├── acknowledgments.tex
│ ├── contributors.tex
│ ├── part1
│ │ ├── chapter1
│ │ │ ├── 0.tex
│ │ │ ├── 1.tex
│ │ │ ├── 2.tex
│ │ │ ├── 3.tex
│ │ │ ├── 4.tex
│ │ │ ├── 5.tex
│ │ │ ├── 6.tex
│ │ │ └── images
│ │ │ │ ├── 1.png
│ │ │ │ ├── 2.png
│ │ │ │ ├── 3.png
│ │ │ │ ├── 4.png
│ │ │ │ ├── 5.png
│ │ │ │ ├── 6.png
│ │ │ │ ├── 7.png
│ │ │ │ ├── 8.png
│ │ │ │ └── 9.png
│ │ ├── chapter2
│ │ │ ├── 0.tex
│ │ │ ├── 1.tex
│ │ │ ├── 2.tex
│ │ │ ├── 3.tex
│ │ │ ├── 4.tex
│ │ │ ├── 5.tex
│ │ │ ├── 6.tex
│ │ │ ├── 7.tex
│ │ │ └── 8.tex
│ │ └── part.tex
│ ├── part2
│ │ ├── chapter3
│ │ │ ├── 0.tex
│ │ │ ├── 1.tex
│ │ │ ├── 2.tex
│ │ │ ├── 3.tex
│ │ │ ├── 4.tex
│ │ │ ├── 5.tex
│ │ │ ├── 6.tex
│ │ │ └── 7.tex
│ │ ├── chapter4
│ │ │ ├── 0.tex
│ │ │ ├── 1.tex
│ │ │ ├── 10.tex
│ │ │ ├── 11.tex
│ │ │ ├── 2.tex
│ │ │ ├── 3.tex
│ │ │ ├── 4.tex
│ │ │ ├── 5.tex
│ │ │ ├── 6.tex
│ │ │ ├── 7.tex
│ │ │ ├── 8.tex
│ │ │ └── 9.tex
│ │ ├── chapter5
│ │ │ ├── 0.tex
│ │ │ ├── 1.tex
│ │ │ ├── 2.tex
│ │ │ ├── 3.tex
│ │ │ ├── 4.tex
│ │ │ ├── 5.tex
│ │ │ ├── 6.tex
│ │ │ ├── 7.tex
│ │ │ └── 8.tex
│ │ └── part.tex
│ ├── part3
│ │ ├── chapter6
│ │ │ ├── 0.tex
│ │ │ ├── 1.tex
│ │ │ ├── 2.tex
│ │ │ ├── 3.tex
│ │ │ ├── 4.tex
│ │ │ ├── 5.tex
│ │ │ ├── 6.tex
│ │ │ └── images
│ │ │ │ ├── 1.png
│ │ │ │ ├── 2.png
│ │ │ │ └── 3.png
│ │ ├── chapter7
│ │ │ ├── 0.tex
│ │ │ ├── 1.tex
│ │ │ ├── 10.tex
│ │ │ ├── 2.tex
│ │ │ ├── 3.tex
│ │ │ ├── 4.tex
│ │ │ ├── 5.tex
│ │ │ ├── 6.tex
│ │ │ ├── 7.tex
│ │ │ ├── 8.tex
│ │ │ ├── 9.tex
│ │ │ └── images
│ │ │ │ ├── 1.png
│ │ │ │ └── 2.png
│ │ ├── chapter8
│ │ │ ├── 0.tex
│ │ │ ├── 1.tex
│ │ │ ├── 2.tex
│ │ │ ├── 3.tex
│ │ │ ├── 4.tex
│ │ │ ├── 5.tex
│ │ │ ├── 6.tex
│ │ │ ├── 7.tex
│ │ │ ├── 8.tex
│ │ │ └── 9.tex
│ │ └── part.tex
│ ├── part4
│ │ ├── chapter10
│ │ │ ├── 0.tex
│ │ │ ├── 1.tex
│ │ │ ├── 2.tex
│ │ │ ├── 3.tex
│ │ │ ├── 4.tex
│ │ │ ├── 5.tex
│ │ │ ├── 6.tex
│ │ │ ├── 7.tex
│ │ │ └── 8.tex
│ │ ├── chapter9
│ │ │ ├── 0.tex
│ │ │ ├── 1.tex
│ │ │ ├── 10.tex
│ │ │ ├── 11.tex
│ │ │ ├── 12.tex
│ │ │ ├── 13.tex
│ │ │ ├── 2.tex
│ │ │ ├── 3.tex
│ │ │ ├── 4.tex
│ │ │ ├── 5.tex
│ │ │ ├── 6.tex
│ │ │ ├── 7.tex
│ │ │ ├── 8.tex
│ │ │ ├── 9.tex
│ │ │ └── images
│ │ │ │ ├── 1.png
│ │ │ │ ├── 2.png
│ │ │ │ └── 3.png
│ │ └── part.tex
│ ├── part5
│ │ ├── chapter11
│ │ │ ├── 0.tex
│ │ │ ├── 1.tex
│ │ │ ├── 2.tex
│ │ │ ├── 3.tex
│ │ │ ├── 4.tex
│ │ │ └── 5.tex
│ │ ├── chapter12
│ │ │ ├── 0.tex
│ │ │ ├── 1.tex
│ │ │ ├── 2.tex
│ │ │ ├── 3.tex
│ │ │ ├── 4.tex
│ │ │ └── 5.tex
│ │ ├── chapter13
│ │ │ ├── 0.tex
│ │ │ ├── 1.tex
│ │ │ ├── 2.tex
│ │ │ ├── 3.tex
│ │ │ ├── 4.tex
│ │ │ ├── 5.tex
│ │ │ ├── 6.tex
│ │ │ ├── 7.tex
│ │ │ └── images
│ │ │ │ └── 1.png
│ │ └── part.tex
│ └── reviewers.tex
└── index.tex
└── cover.png
/README.md:
--------------------------------------------------------------------------------
1 | # Asynchronous Programming with C++
2 | *1st Edition*
3 |
4 | *通过多线程和异步编程打造高速运行的软件*
5 |
6 | * 作者:Javier Reguera-Salgado / Juan Antonio Rufes
7 | * 译者:陈晓伟
8 | * Packt Publishing Ltd. (出版于: 2024年11月29日)
9 |
10 | > [!IMPORTANT]
11 | > 翻译是译者用自己的思想,换一种语言,对原作者想法的重新阐释。鉴于我的学识所限,误解和错译在所难免。如果你能买到本书的原版,且有能力阅读英文,请直接去读原文。因为与之相较,我的译文可能根本不值得一读。
12 | >
13 | > — 云风,程序员修炼之道第2版译者
14 |
15 | ## 本书概述
16 |
17 | 随着硬件技术的不断进步,带来了更大的内存容量和更多的CPU核心,软件必须进化以高效利用所有可用资源并减少闲置的CPU周期。在本书中,两位拥有合计约五十年经验的资深软件工程师将教你如何在C++中实现并发和异步解决方案。
18 |
19 | 你将获得对并行编程范式的全面理解——涵盖并发、异步、并行、多线程、反应式和事件驱动编程,以及数据流——并了解线程、进程和服务之间的关系。深入到并发的核心部分,作者会指导你创建和管理线程,并探索C++中的线程安全机制,包括互斥锁、原子操作、信号量、条件变量、门闩和屏障。有了这个坚实的基础,你会专注于纯粹的异步编程,使用future、promise、async函数和协程。书中逐步引导你使用Boost.Asio和Boost.Cobalt开发网络和低级I/O解决方案,证明性能优化技巧,并测试和调试异步软件。
20 |
21 | 读完这本关于C++的书后,你将能够使用现代异步C++技术实现高性能软件
22 |
23 |
24 | ## 重点内容
25 |
26 | * 学习如何使用现代C++特性,包括future、promise、async和协程来构建异步解决方案
27 |
28 | * 使用Boost.Asio开发跨平台的网络和低级I/O项目
29 | * 通过理解软件如何适应机器硬件来掌握优化技巧
30 | * 购买印刷版或Kindle版图书即包含免费的PDF电子书
31 |
32 | ## 作者简介
33 |
34 | **Javier Reguera-Salgado** 是一位拥有超过19年经验的资深软件工程师,专精于高性能计算、实时数据处理和通信协议。他熟练掌握 C++、Python 以及多种其他编程语言和技术,工作范围涵盖低延迟分布式系统、移动应用程序、网络解决方案和企业产品。他在西班牙和英国的研究中心、初创公司、蓝筹公司和量化投资公司都做出过贡献。Javier 以优异的成绩从西班牙维戈大学获得高性能计算博士学位。
35 |
36 | **Juan Antonio Rufes** 是一位拥有30年经验的软件工程师,专精于低级和系统编程,主要使用 C、C++、0x86 汇编语言和 Python。他的专业领域包括 Windows 和 Linux 的优化、用于防病毒和加密的 Windows 内核驱动程序、TCP/IP 协议分析,以及如智能订单路由和基于 FPGA 的交易系统等低延迟金融系统。他曾与软件公司、投资银行和对冲基金合作。Juan 毕业于西班牙瓦伦西亚理工大学,获得电子工程硕士学位。
37 |
38 |
39 |
40 | ## 本书相关
41 |
42 | * github翻译地址:https://github.com/xiaoweiChen/Asynchronous-Programming-with-Cpp
43 |
44 | * 译文的LaTeX 环境配置:https://www.cnblogs.com/1625--H/p/11524968.html
45 |
46 | * 禁用拼写检查:https://blog.csdn.net/weixin_39278265/article/details/87931348
47 |
48 | * 使用xelatex编译时需要添加`-shell-escape`和`-8bit`选项,例如:
49 |
50 | `xelatex -synctex=1 -interaction=nonstopmode -shell-escape -8bit "book".tex`
51 |
52 | * 为了内容中表格和目录索引能正常生成,至少需要连续编译两次
53 |
54 | * Latex中的中文字体([思源宋体](https://github.com/notofonts/noto-cjk/releases))和英文字体([Hack](https://github.com/source-foundry/Hack-windows-installer/releases/tag/v1.6.0)),需要安装后自行配置。如何配置请参考主book/css.tex顶部关于字体的信息。
55 |
56 | * vscode中配置LaTeX:https://blog.csdn.net/Ruins_LEE/article/details/123555016
57 |
58 |
--------------------------------------------------------------------------------
/book.tex:
--------------------------------------------------------------------------------
1 |
2 | %\special{dvipdfmx:config z 0} %取消PDF压缩,加快速度,最终版本生成的时候最好把这句话注释掉
3 |
4 | %实用的工具宏包 syntonly。加载这个宏包后,在导言区使用 \syntaxonly 命令,可令 LATEX 编译后不生成 DVI 或者 PDF 文档,只排查错误,编译速度会快不少:
5 | %\usepackage{syntonly}
6 | %\syntaxonly
7 |
8 | \include{book/ccs.tex}
9 |
10 | \begin{document}
11 | \begin{sloppypar} %latex中一行文字出现溢出问题的解决方法
12 | %\maketitle
13 |
14 | \subfile{book/index.tex}
15 |
16 | \end{sloppypar}
17 | \end{document}
18 |
19 |
--------------------------------------------------------------------------------
/book/content/Preface.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | 异步编程是构建高效、响应迅速且高性能软件的必备,尤其是在多核处理器和实时数据处理领域。本书探讨了掌握 C++ 中异步编程的原理和技巧,提供处理从线程管理到性能优化等事务所需的知识。
4 |
5 | 开发异步软件有几个关键点:
6 |
7 | \begin{itemize}
8 | \item
9 | 线程管理和同步
10 |
11 | \item
12 | 异步编程概念、模型和库
13 |
14 | \item
15 | 调试、测试和优化多线程和异步软件
16 | \end{itemize}
17 |
18 | 虽然许多资料都侧重于并行编程或通用软件开发的基础知识,但本书旨在全面探索这些要点。涵盖了管理并发、调试复杂系统和优化软件性能的基本技术,同时将这些概念应用于实际。
19 |
20 | 随着多核处理器和并行计算架构成为现代应用程序不可或缺的一部分,对异步编程的需求也正在迅速增长。掌握本书中的技术,不仅可以帮助应对当今复杂的软件开发挑战,还可以为性能关键型软件的未来发展做好准备。
21 |
22 | 无论是正在使用低延迟金融系统、开发高吞吐量应用程序,还是想提高编程技能,本书都会为提供相应的工具和知识。
23 |
24 | \mySubsectionNoFile{}{适读人群}
25 |
26 | 本书面向会使用最新 C++ 版本加深对异步编程的理解,并优化软件性能的软件工程师、开发人员和技术主管。主要目标受众包括:
27 |
28 | \begin{itemize}
29 | \item
30 | 软件工程师:希望提高 C++ 技能,并获得多线程和异步编程、调试和性能优化方面的实用见解。
31 |
32 | \item
33 | 技术领导:旨在实施高效异步系统的领导者,将发现管理复杂软件开发和提高团队生产力的策略和最佳实践。
34 |
35 | \item
36 | 学生和爱好者:渴望了解高性能计算和异步编程的个人将受益于详尽的解释和示例,助其在技术职业生涯中取得进步。
37 | \end{itemize}
38 |
39 | 本书将帮助读者应对实际挑战,并在技术面试中脱颖而出,并提供在当今软件领域蓬勃发展的知识。
40 |
41 | \mySubsectionNoFile{}{关于本书}
42 |
43 | 第 1 章,\textit{并行编程范式},探讨了构建并行系统的不同架构和模型,以及各种并行编程范式及其性能指标。
44 |
45 | 第 2 章,\textit{进程、线程和服务},深入研究操作系统中的进程,了解其生命周期、进程间通信以及线程的作用,包括守护进程和多线程。
46 |
47 | 第 3 章,\textit{如何在 C++ 中创建和管理线程},了解如何创建和管理线程、传递参数、检索结果,以及处理异常以确保在多线程环境中高效执行。
48 |
49 | 第 4 章,\textit{使用锁进行线程同步},解释了 C++ 标准库同步原语(包括互斥锁和条件变量)的使用,并解决了竞争条件、死锁和活锁等问题。
50 |
51 | 第 5 章,\textit{原子操作},介绍了 C++ 原子类型、内存模型,以及如何实现基本的 SPSC 无锁队列,为未来的性能增强做好准备。
52 |
53 | 第 6 章,\textit{Promise和Future},介绍了异步编程概念,包括promise、future和打包任务,并展示了如何使用这些工具解决实际问题。
54 |
55 | 第 7 章,\textit{异步函数},介绍了 std::async 用于执行异步任务、定义启动策略、处理异常和优化性能的功能。
56 |
57 | 第 8 章,\textit{使用协程},介绍了 C++ 协程、其基本要求以及如何实现生成器和解析器,并且要如何处理协程内的异常。
58 |
59 | 第 9 章,\textit{使用 Boost.Asio 进行异步编程},介绍了如何使用 Boost.Asio 管理与外部资源相关的异步任务,重点关注 I/O 对象、执行上下文和事件处理。
60 |
61 | 第 10 章,\textit{使用 Boost.Cobalt 实现协程},探索使用 Boost.Cobalt 库轻松实现协程,避免低级复杂性并专注于函数式编程需求。
62 |
63 | 第 11 章,\textit{记录和调试异步软件},介绍了如何有效地使用日志和调试工具来识别和解决异步应用程序中的问题,包括死锁和竞争条件。
64 |
65 | 第 12 章,\textit{消杀和测试异步软件},介绍了如何使用清理器对多线程代码进行清理,并探讨了使用 GoogleTest 库为异步软件定制的测试技术。
66 |
67 | 第 13 章,\textit{提高异步软件性能},研究性能测量工具和技术,包括高分辨率计时器、缓存优化,以及避免虚和实共享的策略。
68 |
69 |
70 | \mySubsectionNoFile{}{编译环境}
71 |
72 | 需要具有使用 C++ 进行编程的经验,以及如何使用调试器查找错误。由于使用 C++20 功能,并且在某些示例中使用 C++23,因此需要安装 GCC 14 和 Clang 18。所有源代码示例均已在 Ubuntu 和 macOS 中进行了测试,由于它们与平台无关,因此应该可以在任何平台上编译和运行。
73 |
74 | % Please add the following required packages to your document preamble:
75 | % \usepackage{longtable}
76 | % Note: It may be necessary to compile the document several times to get a multi-page table to line up properly
77 | \begin{longtable}{|l|l|}
78 | \hline
79 | \textbf{书中涉及的软件/硬件} & \textbf{操作系统} \\ \hline
80 | \endfirsthead
81 | %
82 | \endhead
83 | %
84 | C++20 和 C++23 & Linux (测试过Ubuntu 24.04) \\ \hline
85 | GCC 14.2 & macOS (测试过macOS Sonoma 14.x) \\ \hline
86 | Clang 18 & Windows 11 \\ \hline
87 | Boost 1.86 & \\ \hline
88 | GDB 15.1 & \\ \hline
89 | \end{longtable}
90 |
91 | 每章都包含一个\textit{技术要求}部分,重点介绍如何安装编译本章示例所需的工具和库的相关信息。
92 |
93 | \textbf{如果正在使用这本书的数字版本,我们建议自己输入代码或通过 GitHub 库访问代码 (链接在下一节中提供)。这样做将帮助您避免与复制和粘贴代码相关的任何潜在错误。}
94 |
95 | \mySubsectionNoFile{}{源码下载}
96 |
97 | 本书的代码托管在 GitHub 上,地址为 \url{https://github.com/PacktPublishing/Asynchronous-Programming-with-CPP}。此外,还可以在 \url{https://github.com/PacktPublishing/} 浏览图书和视频目录中的其他代码包。欢迎查看!
98 |
--------------------------------------------------------------------------------
/book/content/acknowledgments.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | 感谢我的妻子 Raquel,我们共同的人生旅程中,她一直是我深爱的伴侣,也是爱、力量和欢乐的源泉。感谢我的女儿 Julia,她在让我心中充满了希望和惊喜。
4 |
5 | 感谢我的父母,玛丽娜 (Marina) 和埃斯塔尼斯劳 (Estanislao),感谢他们的牺牲、爱、支持和启发。
6 |
7 | \begin{flushright}
8 | \textit{Javier Reguera-Salgado}
9 | \end{flushright}
10 |
11 | 献给我的妹妹,伊娃玛丽亚 (Eva María),并深情怀念我的父母。
12 |
13 | \begin{flushright}
14 | \textit{Juan Antonio Rufes}
15 | \end{flushright}
--------------------------------------------------------------------------------
/book/content/contributors.tex:
--------------------------------------------------------------------------------
1 | \textbf{Javier Reguera-Salgado}具有西班牙维哥大学高性能计算博士学位,是一位资深软件工程师,并拥有19年多的工作经验,专门从事高性能计算、实时数据处理和通信协议。他精通 C++、 Python 以及其他编程语言和技术,工作涉及低延迟分布式系统、移动应用、 Web 解决方案和企业产品。他为西班牙和英国的研究中心、初创公司、蓝筹公司和量化投资公司工作过。
2 |
3 | \hspace*{\fill}
4 |
5 | \textit{
6 | 首先,感谢我的妻子 Raquel 和女儿 Julia,感谢她们对我的爱和鼓励,让我每天都深受鼓舞。感谢我的父母 Marina 和 Estanislao,教会了我勤奋和坚持不懈。我还要感谢亲戚朋友对我的爱和支持。此外,还要感谢 Juan 与我共同撰写了这本书,以及 Packt Publishing 团队和审稿人。
7 | }
8 |
9 | \hspace*{\fill}
10 |
11 | \textbf{Juan Antonio Rufes} 具有拥有西班牙瓦伦西亚理工大学电气工程硕士学位,是一位拥有 30 年经验的软件工程师,专门从事底层和系统编程,主要使用 C、 C++、 0x86 汇编和 Python。其专长包括 Windows 和 Linux 优化、用于预防病毒和加密的 Windows 内核驱动程序、 TCP/ IP 协议分析,以及低延迟金融系统(例如:智能订单路由和基于 FPGA 的交易系统)。他在软件公司、投资银行和对冲基金中工作过。
12 |
13 | \hspace*{\fill}
14 |
15 | \textit{
16 | 衷心感谢父母对我的教导和支持。同时,感谢 Javier 与我共同创作了本书。非常感谢 Packt Publishing 团队和技术审阅人员的建议和对细节的关注。
17 | }
--------------------------------------------------------------------------------
/book/content/part1/chapter1/0.tex:
--------------------------------------------------------------------------------
1 | 深入研究使用 C++ 进行并行编程之前,我们将重点了解构建并行软件的不同方法,以及软件如何与机器硬件交互的一些基础知识。
2 |
3 | 本章中,将介绍并行编程,以及在开发高效、响应迅速、可扩展并发和异步软件时,可以使用的不同范式和模型。
4 |
5 | 对开发并行软件的不同方法进行分类时,有很多种方法可以对概念和方法进行分组。由于本书重点介绍使用 C++ 构建的软件,因此可以将不同的并行编程范例划分为以下几种:并发、异步编程、并行编程、反应式编程、数据流、多线程编程和事件驱动编程。
6 |
7 | 特定范式可能比其他范式更适合解决给定场景,了解不同的范式将有助于分析问题并缩小搜索最佳解决方案的范围。
8 |
9 | 本章中,将讨论以下主题:
10 |
11 | \begin{itemize}
12 | \item
13 | 什么是并行编程?为什么重要?
14 |
15 | \item
16 | 有哪些不同的并行编程范式?为什么需要了解它们?
17 |
18 | \item
19 | 将在本书中学到什么?
20 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part1/chapter1/1.tex:
--------------------------------------------------------------------------------
1 |
2 | 整本书中,将使用 C++20 开发不同的解决方案,在某些示例中,还会使用 C++23,因此需要安装 GCC 14 和 Clang 8。
3 |
4 | 本书中展示的所有代码块都可以在以下 GitHub 库中找到: \url{https://github.com/PacktPublishing/Asynchronous-Programming-with-CPP}。
--------------------------------------------------------------------------------
/book/content/part1/chapter1/2.tex:
--------------------------------------------------------------------------------
1 | 当任务或计算同时完成时,就会发生并行计算,其中任务是软件应用程序中的执行或工作单元。由于实现并行性的方法有很多,因此了解不同的方法将有助于编写高效的并行算法。这些方法可以通过范例和模型进行描述。
2 |
3 | 但首先,先对不同的并行计算系统进行分类。
4 |
5 | \mySubsubsection{1.2.1.}{系统分类和技术}
6 |
7 | 并行计算系统最早的分类之一是由 Michael J. Flynn 于 1966 年提出的。 弗林(Flynn)根据并行计算架构可以处理的数据流和指令数量进行了以下分类:
8 |
9 | \begin{itemize}
10 | \item
11 | 单指令单数据 (SISD) 系统:定义顺序执行程序
12 |
13 | \item
14 | 单指令多数据 (SIMD) 系统:对大型数据集进行操作,例如:GPU 计算的信号处理数据
15 |
16 | \item
17 | 多指令单数据 (MISD) 系统:很少使用
18 |
19 | \item
20 | 多指令多数据 (MIMD) 系统:基于多核和多处理器计算机的(最常见)并行架构
21 | \end{itemize}
22 |
23 | \myGraphic{0.5}{content/part1/chapter1/images/1.png}{图 1.1:弗林分类法}
24 |
25 | 本书不仅介绍了如何使用 C++ 构建软件,还介绍了其如何与底层硬件交互。软件层面上,我们可以进行更有趣的划分或分类,并定义技术。这些,将在后续章节中进行介绍。
26 |
27 | \mySamllsection{数据并行}
28 |
29 | 许多不同的数据单元由在不同处理单元(例如:CPU 或 GPU)中,运行的同一程序或指令序列并行处理。
30 |
31 | 数据并行性通过相同操作,同时处理多少个不相交的数据集来实现。利用并行性,可以将大型数据集划分为更小且独立的数据块。
32 |
33 | 因为更多的处理单元可以处理更多的数据,所以该技术还具有高度的可扩展性。
34 |
35 | 在这个子集中,可以包含 SIMD 指令集,例如 SSE、 AVX、 VMX 或 NEON,这些指令集可通过 C++ 中的内部函数访问。此外,还有用于 NVIDIA GPU 的 OpenMP 和 CUDA 等库。在机器学习训练和图像处理中可以找到它的一些使用示例,该技术与弗林定义的 SIMD 分类有关。
36 |
37 | 这种分类方式也存在一些缺点——数据必须能够轻松划分为独立的块。这种数据划分和后验合并也会带来一些开销,从而降低并行化的优势。
38 |
39 | \mySamllsection{任务并行}
40 |
41 | 每个CPU核心使用进程或线程运行不同的任务,当这些任务同时接收数据、处理数据并通过消息传递发回它们生成的结果时,就可以实现任务并行。
42 |
43 | 任务并行的优势在于能够设计异构、细粒度的任务,从而更好地利用处理资源,在设计具有潜在更高加速的解决方案时更加灵活。
44 |
45 | 根据数据创建的任务之间可能存在依赖关系,并且每个任务的性质不同,因此调度和协调比数据并行更复杂,所以任务创建会增加一些开销。
46 |
47 | 这里可以引入弗林的 MISD 和 MIMD 分类法,可以在 Web 服务器请求处理系统或用户界面事件处理程序中找到一些示例。
48 |
49 | \mySamllsection{流并行}
50 |
51 | 可将计算分为处理数据子集的各个阶段,来同时处理连续的数据元素序列(也称为数据流)。
52 |
53 | 阶段可以同时运行。一些阶段生成其他阶段的输入,根据阶段依赖关系构建管道。处理阶段可以将结果发送到下一个阶段,而无需等待整个流数据。
54 |
55 | 流并行技术在处理连续数据时非常有效。还具有高度可扩展性,可以通过添加更多处理单元来处理更多的输入数据。由于流数据在到达时进行处理,所以无需等待整个数据流发送,内存使用量也减少了。
56 |
57 | 然而,这些系统也存在一些缺点。由于逻辑处理、错误处理和恢复,这些系统的实现更加复杂。由于还需要实时处理数据流,因此硬件也可能是瓶颈之一。
58 |
59 | 这些系统的一些示例包括监控系统、传感器数据处理,以及音频和视频流。
60 |
61 | \mySamllsection{隐式并行}
62 |
63 | 编译器、运行时或硬件会并行执行指令。这使得编写并行程序变得更容易,但限制了开发者对所用策略的控制,甚至使分析性能或调试变得更加困难。
64 |
65 | \hspace*{\fill}
66 |
67 | 现在,我们对不同的并行系统和技术有了更好的了解,是时候了解在设计并行程序时可以使用的模型了。
68 |
69 | \mySubsubsection{1.2.2.}{并行编程模型}
70 |
71 | 并行编程模型是一种并行计算机架构,用于表达算法和构建程序。模型越通用,其价值就越大,可以用于更广泛的场景。从这个意义上讲,C++ 通过标准模板库 (STL) 中的库实现了并行模型,可用于实现顺序应用程序中程序的并行执行。
72 |
73 | 这些模型描述了程序生命周期内,不同任务如何交互以从输入数据中获取结果。其主要区别在于,任务如何交互以及如何处理传入数据。
74 |
75 | \mySamllsection{阶段并行}
76 |
77 | 阶段并行(也称为议程或自由同步范式)中,多个作业或任务并行执行独立计算。在某个时刻,程序需要使用栅栏执行同步交互操作来同步不同的进程。栅栏是一种同步机制,可确保一组任务在其执行过程中到达特定点执行完后,才能继续进行下一个步骤。接下来的步骤将执行其他异步操作,依此类推。
78 |
79 | \myGraphic{0.9}{content/part1/chapter1/images/2.png}{图1.2:阶段并行}
80 |
81 | 这种模型的优点是任务间的交互不会与计算重叠,但各个处理单元之间的工作量和吞吐量很难达到均衡。
82 |
83 | \mySamllsection{分而治之}
84 |
85 | 使用此模型的应用程序使用主任务或作业,将工作量分配给其子任务。子任务并行计算结果将其返回给父任务,父任务将结果合并为最终结果。子任务还可以将分配的任务细分为更小的任务,并创建自己的子任务。
86 |
87 | 该模型具有与相并联模型相同的缺点,很难实现良好的负载平衡。
88 |
89 | \myGraphic{0.5}{content/part1/chapter1/images/3.png}{图 1.3:分而治之模型}
90 |
91 | 图 1.3 中,可以看到主作业如何将工作划分给几个子任务,以及子任务 2 如何将其分配的工作细分为两个任务。
92 |
93 | \mySamllsection{管道模型}
94 |
95 | 多个任务相互连接,构建虚拟管道。此管道中,各个阶段可以同时运行,并在输入数据时重叠执行。
96 |
97 | \myGraphic{1.0}{content/part1/chapter1/images/4.png}{图 1.4:管道模型}
98 |
99 | 上图中三个任务在由五个阶段组成的流水线中交互,每个阶段都有一些任务在运行,并产生输出结果,供下一个阶段的任务使用。
100 |
101 | \mySamllsection{主从模型}
102 |
103 | 使用主从模型(也称为进程农场),主执行者执行算法的顺序部分,并生成和协调在工作负载中执行并行操作的从属任务。当从属任务完成计算时,将结果通知主执行者,然后主执行者可能会将更多数据发送给从属任务进行处理。
104 |
105 | \myGraphic{0.5}{content/part1/chapter1/images/5.png}{图 1.5:主从模型}
106 |
107 | 缺点是,如果主服务器需要处理太多从服务器或任务太小,主服务器可能会成为瓶颈。在选择由主服务器执行的工作量时,需要权衡每个任务,这也称为任务粒度。当任务较小时,称为细粒度,当任务较大时,称为粗粒度。
108 |
109 | \mySamllsection{工作池}
110 |
111 | 工作池模型中,全局结构保存着要执行的工作项池。主程序会创建作业,从池中获取工作项并执行。
112 |
113 | 这些作业可以生成更多工作,并将其插入到工作池中。当所有工作都完成后,清空工作池,并行程序便会结束执行。
114 |
115 | \myGraphic{0.5}{content/part1/chapter1/images/6.png}{图 1.6:工作池模型}
116 |
117 | 该机制有利于实现空闲处理单元之间的负载平衡。
118 |
119 | 在 C++ 中,这个池通常使用无序集合、队列或优先级队列来实现。我们将在本书中后续内容中进行实现。
120 |
121 | \hspace*{\fill}
122 |
123 | 了解了可用于构建并行系统的各种模型,可以继续探索用于开发高效并行任务的并行编程范例了。
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
--------------------------------------------------------------------------------
/book/content/part1/chapter1/3.tex:
--------------------------------------------------------------------------------
1 | 现在,是时候转向更抽象的分类,通过探索并行编程语言范式,来了解编写并行程序的风格和原则。
2 |
3 | \mySubsubsection{1.3.1.}{同步编程}
4 |
5 | 同步编程语言用于构建按顺序执行代码的程序。执行一条指令时,程序将保持阻塞状态,直到该指令完成,没有多任务处理。这使得代码更易于理解和调试。
6 |
7 | 这种行为使得程序在运行指令时阻塞,无法响应外部事件,并且难以扩展。这是大多数编程语言(例如 C、 Python 或 Java)使用的传统范式。
8 |
9 | 这种模式对于需要实时、有序地响应输入事件的反应式或嵌入式系统尤其有用。处理速度必须与环境所施加的速度相匹配,并有严格的时间限制。
10 |
11 | \myGraphic{0.8}{content/part1/chapter1/images/7.png}{图 1.7:异步与同步执行时间}
12 |
13 | 图 1.7 展示了系统中正在运行的两个任务。同步系统中,任务 A 被任务 B 中断,只有在任务 B 完成其工作后才会恢复执行。异步系统中,任务 A 和 B 可以同时运行,从而在更短的时间内完成工作。
14 |
15 | \mySubsubsection{1.3.2.}{并发编程}
16 |
17 | 通过并发编程,可以同时运行多个任务。任务可以独立运行,无需等待其他任务完成,还可以共享资源并相互通信。其指令可以异步运行,可以按任意顺序执行,而不会影响结果,增加了并行处理的潜力。不过,这使得程序难以理解和调试。
18 |
19 | 因为在一定时间间隔内完成的任务数量会随着并发性而增加(古斯塔夫森定律公式),所以并发性提高了程序的吞吐量。
20 |
21 | 此外,因为可以在等待期间执行其他任务,程序有更好的输入和输出响应能力。
22 |
23 | 并发软件的问题是实现正确的并发控制。协调对共享资源的访问并确保不同计算执行之间发生正确的交互顺序时,必须格外小心。错误的决策可能导致竞争条件、死锁或资源短缺。这些问题大多可以通过一致性或内存模型来解决,该模型定义了在访问共享内存时按何种顺序执行操作的规则。
24 |
25 | 设计高效并发算法是通过寻找协调任务执行、数据交换、内存分配和调度的技术,来最小化响应时间并最大化吞吐量。
26 |
27 | 第一篇介绍并发的学术论文《并发编程控制问题的解决方案》由 Dijkstra 于 1965 年发表,论文中也发现并解决了互斥问题。
28 |
29 | 并发的抢占会发生在操作系统级,即调度程序无需与任务交互即可切换上下文( 从一个任务切换到另一个任务)。也可以以非抢占或协作方式发生,即任务将控制权交给调度程序,调度程序选择另一个任务继续。
30 |
31 | 调度程序通过保存程序的状态(内存和寄存器内容)来中断正在运行的程序,然后加载已保存的状态并将控制权移交给该程序,这称为上下文切换。根据任务的优先级,调度程序会允许高优先级任务比低优先级任务使用更多的 CPU 时间。
32 |
33 | 此外,一些特殊的操作软件(例如内存保护)会使用特殊硬件来保证监控软件不受用户模式程序错误的损坏。
34 |
35 | 该机制不仅用于单核计算机,也用于多核计算机,允许执行比可用核心数量多的任务。抢占式多任务处理还允许提前安排重要任务,以便快速处理重要的外部事件。当操作系统向这些任务发送触发中断的信号时,这些任务就会唤醒并处理重要工作。
36 |
37 | 旧版 Mac 和 Windows 操作系统使用非抢占式多任务处理。如今, RISC 操作系统仍在使用这种处理方式。 Unix 系统于 1969 年开始使用抢占式多任务处理,这是所有类 Unix 系统、Windows NT 3.1 和 Windows 95 以后 Windows 系统的核心功能。
38 |
39 | 早期的 CPU 每次只能运行一条指令路径。并行通过在指令流之间切换来实现,通过看似重叠的执行,给人一种并行的假象。
40 |
41 | 2005 年,英特尔推出了多核处理器,允许在硬件层面同时执行多个指令流。这给编写软件带来了一些挑战,需要解决和利用硬件层面的并发性。
42 |
43 | C++11通过 std::thread 库支持并发编程。C++早期版本不包含该功能,因此开发者依赖于 Unix 系统中基于 POSIX 线程模型的平台特定库或 Windows 系统中的专有 Microsoft 库。
44 |
45 | 为了更好地理解了并发是什么,需要区分并发和并行。当许多执行路径可以在重叠的时间段内交错执行时,就会发生\textbf{并发};而这些任务由不同的 CPU 单元同时执行,并利用可用的多核资源时,就会发生\textbf{并行}。
46 |
47 | \myGraphic{0.8}{content/part1/chapter1/images/8.png}{图 1.8:并发与并行}
48 |
49 | 并发编程会比并行编程更通用,后者具有预定的通信模式,而前者可以涉及任务之间的通信和交互模式。
50 |
51 | 并行可以存在于没有并发性(没有交错的时间段)的情况,也可以存在于没有并行性(通过单核 CPU 上的分时多任务处理)的情况。
52 |
53 | \mySubsubsection{1.3.3.}{异步编程}
54 |
55 | 异步编程可以安排一些任务在后台运行,同时继续执行当前作业,而无需等待计划的任务完成。当这些任务完成后,它们会将其结果报告给主作业或调度程序。
56 |
57 | 同步应用程序的一个关键问题是,长时间操作可能会导致程序无法响应接下来的输入或处理。异步程序解决了这个问题,在执行某些操作时可以接收新的输入,同时创建非阻塞任务,并且系统可以一次执行多个任务,提高资源的利用率。
58 |
59 | 由于异步执行任务,并且在完成后会报告结果,所以该范例特别适合事件驱动程序。此外,它还是一种通常用于用户界面、 Web 服务器、网络通信或长时间运行的后台处理的范例。随着硬件发展到单个处理器芯片上的多个处理核心,可以使用异步编程来通过在不同核心上并行运行任务,充分利用所有可用的计算能力。
60 |
61 | 异步编程也有其挑战,例如:增加了复杂性,以及竞争条件。此外,错误处理和测试对于确保程序稳定性和避免出现问题也至关重要。
62 |
63 | 现代 C++ 还提供了异步机制,例如协程(可以暂停并稍后恢复的程序),或者future和promise(作为异步程序中未知结果的代理,用于同步程序执行) 。
64 |
65 | \mySubsubsection{1.3.4.}{并行编程}
66 |
67 | 通过并行编程,多个计算任务可以在多个处理单元上同时完成,这些处理单元可以全部位于同一台计算机(多核)上,也可以位于多台计算机(集群)。
68 |
69 | 主要有两种方法:
70 |
71 | \begin{itemize}
72 | \item
73 | 共享内存并行:任务可以通过共享内存(所有处理器都可以访问的内存空间)进行通信
74 |
75 | \item
76 | 消息传递并行:每个任务都有自己的内存空间,并使用消息传递技术与其他任务进行通信
77 | \end{itemize}
78 |
79 | 与之前的范例一样,为了充分发挥潜力并避免错误或问题,并行计算需要同步机制以避免任务相互干扰。还需要平衡工作负载以充分发挥其潜力,以及减少创建和管理任务时的开销。这些需求增加了设计、实施和调试的复杂性。
80 |
81 | \mySubsubsection{1.3.5.}{多线程编程}
82 |
83 | 多线程编程是并行编程的一个子集,其中程序可分成多个线程,在同一进程内执行独立单元。进程、内存空间和资源在线程之间共享。
84 |
85 | 前面我们已经提到,共享内存需要同步机制。另一方面,由于不需要进程间通信,资源共享变得简单。例如,多线程编程通常用于实现具有流畅动画的图形用户界面 (GUI) 响应能力、在 Web 服务器中处理多个客户端的请求或数据处理。
86 |
87 | \mySubsubsection{1.3.6.}{事件驱动式编程}
88 |
89 | 事件驱动编程中,控制流由外部事件驱动。应用程序实时检测事件,并通过调用适当的事件处理方法或回调来响应这些事件。
90 |
91 | 事件表示需要采取的操作。事件循环会监听此事件,并不断监听传入的事件,并将其分派给相应的回调,从而执行所需的操作。由于代码仅在发生操作时执行,因此这种模式提高了资源使用效率和可扩展性。
92 |
93 | 事件驱动编程对于处理用户界面、实时应用程序和网络连接监听器中发生的动作很有用。与许多其他范式一样,增加的复杂性、同步和调试使得该范式的实现和应用变得复杂。由于 C++ 是一种低级语言,需要使用回调或函子等技术来编写事件处理程序。
94 |
95 | \mySubsubsection{1.3.7.}{响应式编程}
96 |
97 | 反应式编程处理数据流,即随时间连续的数据或值流。程序通常使用声明式或函数式编程构建,定义应用于流的运算符和转换的管道。这些操作使用调度程序和背压处理机制异步进行。
98 |
99 | 当数据量超出消费者承受能力,消费者无法处理所有数据时,就会发生背压。为了避免系统崩溃,反应式系统需要使用背压策略来防止系统故障。
100 |
101 | 其中一些策略包括:
102 |
103 | \begin{itemize}
104 | \item
105 | 通过请求发布者降低发布事件的速率来控制输入吞吐量。这可以通过遵循拉取策略来实现,即发布者仅在消费者请求时发送事件,或者通过限制发送的事件数量来实现,从而创建有限且受控的推送策略。
106 |
107 | \item
108 | 缓冲多余的数据,这在短时间内出现数据突发或高带宽传输时尤其有用。
109 |
110 | \item
111 | 删除一些事件或延迟其发布,直到消费者从背压状态中恢复为止。
112 | \end{itemize}
113 |
114 | 反应式程序可以基于拉取,也可以基于推送。基于拉取的程序实现了从数据源主动拉取事件的经典情况,基于推送的程序通过信号网络推送事件以到达订阅者。订阅者对变化做出反应而不会阻塞程序,这使得这些系统非常适合响应性敏感的用户界面环境。
115 |
116 | 反应式编程就像一个事件驱动模型,其中来自各种来源的事件流可以转换、过滤、处理等。两者都增加了代码的模块化,适用于实时应用程序。但二者也存在一些差异:
117 |
118 | \begin{itemize}
119 | \item
120 | 反应式编程会对事件流做出反应,而事件驱动编程处理离散事件。
121 |
122 | \item
123 | 事件驱动编程中,事件会触发回调或事件处理程序。通过反应式编程,可以创建具有不同转换运算符的管道,让数据流流动并修改事件。
124 | \end{itemize}
125 |
126 | 使用反应式编程的系统和软件的示例包括 X Windows 系统和 Qt、 WxWidgets 和 Gtk+ 等库。反应式编程还用于实时传感器数据处理和仪表板,还适用于处理网络或文件 I/O 流量和数据处理。
127 |
128 | 要充分发挥反应式编程的潜力,在使用反应式编程时需要解决一些挑战。例如,调试分布式数据流和异步进程或通过微调调度程序来优化性能。此外,使用声明式或函数式编程,会让使用反应式编程技术开发软件变得更难理解和学习。
129 |
130 | \mySubsubsection{1.3.8.}{数据流编程}
131 |
132 | 使用数据流编程,程序设计为有向图,其中节点表示计算单元,边表示数据流,节点仅在有可用数据时执行。这种范式是由麻省理工学院的杰克·丹尼斯于 20 世纪 60 年代发明的。
133 |
134 | 数据流编程使代码和设计更具可读性和清晰度,提供了不同计算单元及其交互方式的可视化表示。此外,独立节点可以与数据流编程并行运行,从而提高并行性和吞吐量。
135 |
136 | 类似于反应式编程,但为建模系统提供了基于图形的方法和视觉辅助。
137 |
138 | 要实现数据流程序,可以使用哈希表。键标识一组输入,值描述要运行的任务。当给定键的所有输入都可用时,将执行与该键相关联的任务,从而生成触发哈希表中其他键的任务的其他输入值。在这些系统中,调度程序可以通过对图形数据结构进行拓扑排序来寻找并行机会,按相互依赖性对不同的任务进行排序。
139 |
140 | 这种范式通常用于机器学习的大规模数据处理管道、传感器或金融市场数据的实时分析,以及音频、视频和图像处理系统。使用数据流范式的软件库的示例有 Apache Spark 和 TensorFlow。在硬件方面,可以找到数字信号处理、网络路由、 GPU 架构、遥测和人工智能等示例。
141 |
142 | 数据流编程的一种变体是增量计算,即只重新计算依赖于更改的输入数据的输出。这就像当单元格值发生变化时重新计算 Excel 电子表格中受影响的单元格。
143 |
144 | \hspace*{\fill}
145 |
146 | 了解了不同的并行编程系统、模型和范例,现在是时候介绍一些有助于衡量并行系统性能的指标了。
147 |
148 |
149 |
--------------------------------------------------------------------------------
/book/content/part1/chapter1/4.tex:
--------------------------------------------------------------------------------
1 |
2 | 指标是一种测量方法,可以帮助我们了解系统的运行情况并比较不同的改进方法。以下是一些常用于评估系统并行性的指标和公式。
3 |
4 | \mySubsubsection{1.4.1.}{并行度}
5 |
6 | 并行度 (DOP) 是衡量计算机同时执行的操作数量的指标,可用于描述并行程序和多处理器系统的性能。
7 |
8 | 计算 DOP 时,可以使用同时执行的最大操作数,测量没有瓶颈或依赖性的理想情况。或者,可以使用给定时间点的平均操作数或同时执行的操作数,反映系统实现的实际 DOP。可以使用分析器和性能分析工具来测量特定时间段内的线程数,从而进行近似计算。
9 |
10 | DOP 不是一个常数,它是一个在应用程序执行过程中发生变化的动态指标。
11 |
12 | 例如,考虑一个处理多个文件的脚本工具。这些文件可以按顺序或同时处理,从而提高效率。如果有一台有 N 个核的机器,并且想要处理 N 个文件,可以为每个核分配一个文件。
13 |
14 | 按顺序处理所有文件的时间如下:
15 |
16 | \begin{center}
17 | $t_{total} = t_{file1} + t_{file2} + t_{file3} + ... + t_{fileN} \widetilde{=} N · avg(t_{file}) $
18 | \end{center}
19 |
20 | 并且,并行处理的时间为:
21 |
22 | \begin{center}
23 | $t_{total} = max(t_{file1}, t_{file2}, t_{file3}, ..., t_{fileN}) $
24 | \end{center}
25 |
26 | DOP为N,即主动处理单独文件的核数。
27 |
28 | 并行化所能实现的加速比有一个理论上限,由阿姆达尔定律给出。
29 |
30 | \mySubsubsection{1.4.2.}{阿姆达尔定律}
31 |
32 | 并行系统中,可以认为将 CPU 核心数量增加一倍可以使程序运行速度提高一倍,从而将运行时间减半,但并行化带来的加速并不是线性的。在一定数量的核心之后,由于上下文切换、内存分页等不同情况,运行时间不再减少。
33 |
34 | 阿姆达尔定律公式计算了任务并行化后理论上的最大加速比:
35 |
36 | \begin{center}
37 | $S_{max}(s) = \frac{s}{s + p(1 - s)} = \frac{1}{1 - p + \frac{p}{s}}$
38 | \end{center}
39 |
40 | s 是改进部分的加速因子, p 是可并行化部分占整个流程的比例。因此, 1-p 表示不可并行化任务(瓶颈或顺序部分)的比例,而 p/s 表示可并行化部分实现的加速,最大加速受任务的顺序部分限制。可并行化任务的比例越大(p 接近 1),最大加速就越高,直至达到加速因子 (s)。另一方面,当顺序部分变大(p 接近 0)时, $S_{max}$ 趋向于 1,则不可能有任何改进。
41 |
42 | \myGraphic{0.8}{content/part1/chapter1/images/9.png}{图1.9:处理器数量和可并行部件百分比的加速限制}
43 |
44 | 并行系统中的关键路径由最长的依赖计算链定义。由于关键路径几乎不可并行,因此它定义了顺序部分,从而决定了程序可以实现的更快运行时间。例如,如果一个进程的顺序部分占运行时间的 10\%,那么可并行化部分的比例为 p=0.9。在这种情况下,无论有多少个处理器可用,潜在的加速都不会超过 10 倍。
45 |
46 | \mySubsubsection{1.4.3.}{古斯塔夫森定律}
47 |
48 | 阿姆达尔定律公式仅适用于固定规模的问题和不断增加的资源。当使用较大的数据集时,可并行化部分所花费的时间增长速度比顺序部分要快得多。而古斯塔夫森定律公式没那么悲观,也更准确,它考虑了固定的执行时间和使用资源不断增加的问题规模。
49 |
50 | 古斯塔夫森定律公式计算使用 p 个处理器所获得的加速比如下:
51 |
52 | \begin{center}
53 | $S_p = p + (1 - f) · p$
54 | \end{center}
55 |
56 | p 是处理器的数量, f 是保持连续的任务比例。因此, (1-f)*p 表示将 (1-f) 任务分布在p 个处理器上进行并行化所实现的加速, p 表示增加资源时所做的额外工作。古斯塔夫森定律公式表明,降低 f 时,加速比受并行化影响,而增加 p 时,加速比受可扩展性影响。
57 |
58 | 与阿姆达尔定律一样,古斯塔夫森定律公式也是一种近似值,在衡量并行系统的改进时提供了有价值的视角。其他因素也会降低效率,例如:处理器之间的开销通信或内存和存储限制。
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
--------------------------------------------------------------------------------
/book/content/part1/chapter1/5.tex:
--------------------------------------------------------------------------------
1 | 在本章中,了解了可用于构建并行系统的不同架构和模型。然后,探讨了可用于开发并行软件的各种并行编程范例的细节,并了解了其行为和细微差别。最后,定义了一些有用的指标来衡量并行程序的性能。
2 |
3 | 下一章中,将探讨硬件和软件之间的关系,以及软件如何映射和与底层硬件交互。还将了解什么是线程、进程和服务,线程如何调度,以及它们如何相互通信等内容。
--------------------------------------------------------------------------------
/book/content/part1/chapter1/6.tex:
--------------------------------------------------------------------------------
1 | \begin{itemize}
2 | \item
3 | 拓扑排序:\url{https://en.wikipedia.org/wiki/Topological_sorting}
4 |
5 | \item
6 | C++ 编译器的支持: \url{https://en.cppreference.com/w/cpp/compiler_support}
7 |
8 | \item
9 | C++20 编译器的支持: \url{https://en.cppreference.com/w/cpp/compiler_support/20}
10 |
11 | \item
12 | C++23 编译器的支持: \url{https://en.cppreference.com/w/cpp/compiler_support/23}
13 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part1/chapter1/images/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part1/chapter1/images/1.png
--------------------------------------------------------------------------------
/book/content/part1/chapter1/images/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part1/chapter1/images/2.png
--------------------------------------------------------------------------------
/book/content/part1/chapter1/images/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part1/chapter1/images/3.png
--------------------------------------------------------------------------------
/book/content/part1/chapter1/images/4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part1/chapter1/images/4.png
--------------------------------------------------------------------------------
/book/content/part1/chapter1/images/5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part1/chapter1/images/5.png
--------------------------------------------------------------------------------
/book/content/part1/chapter1/images/6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part1/chapter1/images/6.png
--------------------------------------------------------------------------------
/book/content/part1/chapter1/images/7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part1/chapter1/images/7.png
--------------------------------------------------------------------------------
/book/content/part1/chapter1/images/8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part1/chapter1/images/8.png
--------------------------------------------------------------------------------
/book/content/part1/chapter1/images/9.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part1/chapter1/images/9.png
--------------------------------------------------------------------------------
/book/content/part1/chapter2/0.tex:
--------------------------------------------------------------------------------
1 | 异步编程涉及启动操作而不等待任务完成再继续执行下一个任务,这种非阻塞行为允许开发高响应性和高效率的应用程序,能够同时处理大量操作,而不会出现延迟或浪费计算资源的等待。
2 |
3 | 异步编程非常重要,尤其是在网络应用程序、用户界面和系统编程的开发中。开发人员能够创建能够管理大量请求、执行输入/输出 (I/O) 操作或高效执行并发任务的应用程序,从而显著增强用户体验和应用程序性能。
4 |
5 | Linux 操作系统(本书中,将重点介绍在代码无法独立于平台的情况下在 Linux 操作系统上进行开发)具有强大的进程管理、对线程的本机支持和高级 I/O 功能,是开发高性能异步应用程序的理想环境。这些系统提供了一组丰富的功能,例如:用于进程和线程管理的强大 API、非阻塞 I/O 和进程间通信 (IPC) 机制。
6 |
7 | 本章介绍了 Linux 环境中异步编程所必需的基本概念和组件。
8 |
9 | 我们将探讨以下主题:
10 |
11 | \begin{itemize}
12 | \item
13 | Linux 中的进程
14 |
15 | \item
16 | 服务和守护进程
17 |
18 | \item
19 | 线程和并发
20 | \end{itemize}
21 |
22 | 本章结束时,将对 Linux 中的异步编程环境有一个基本的了解,为后续章节的更深入探索和实际应用奠定基础。
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/book/content/part1/chapter2/1.tex:
--------------------------------------------------------------------------------
1 |
2 | 进程可以定义为正在运行的程序的一个实例,包括程序代码、属于此进程的所有线程( 由程序计数器表示)、堆栈(包含临时数据(如函数参数、返回地址和局部变量)的内存区域)、堆(用于动态分配的内存)及其包含全局变量和初始化变量的数据部分。每个进程都在自己的虚拟地址空间内运行,并与其他进程隔离,确保其操作不会直接干扰其他进程的操作。
3 |
4 | \mySubsubsection{2.1.1.}{进程生命周期——创建、执行和终止}
5 |
6 | 进程的生命周期可以分为三个阶段:
7 |
8 | \begin{itemize}
9 | \item
10 | \textbf{创建}:使用 fork() 系统调用创建新进程,该系统调用通过复制现有进程来创建新进程。调用 fork() 的进程为父进程,新创建的进程为子进程。此机制对于在系统内执行新程序至关重要,并且是同时执行不同任务的前提。
11 |
12 | \item
13 | \textbf{执行}:创建后,子进程可能会执行与父进程相同的代码,或者使用 exec() 系列系统调用来加载和运行不同的程序。如果父进程有多个执行线程,则只有调用 fork() 的线程会在子进程中重复,所以子进程只包含一个线程:执行 fork() 系统调用的线程。
14 |
15 | 由于只有调用 fork() 的线程才会复制到子线程,在 fork 时其他线程持有的任何互斥 (mutexes)、条件变量或其他同步原语在父线程中仍保持其当时的状态,但不会转移到子线程中。这可能会导致复杂的同步问题,因为其他线程锁定的互斥 (子线程中不存在) 可能会保持锁定状态,如果子线程尝试解锁或等待这些原语,则可能导致死锁。
16 |
17 | 在此阶段,进程执行其指定的操作,例如:读取或写入文件,以及与其他进程通信。
18 |
19 | \item
20 | \textbf{终止}:进程要么主动终止(通过调用 exit() 系统调用),要么非主动终止(由于收到另一个进程发出的终止信号)。终止时,进程会向其父进程返回退出状态,并将其资源释放回系统。
21 | \end{itemize}
22 |
23 | 因为支持多个任务的并发执行,所以进程生命周期对于异步操作来说是不可或缺的一部分。
24 |
25 | 每个进程都由一个进程 ID (PID) 唯一标识,这是一个整数,内核使用它来管理进程。 PID 用于控制和监视进程。父进程还使用 PID 与子进程通信或控制子进程的执行,例如:等待子进程终止或发送信号。
26 |
27 | Linux 提供了进程控制和信号机制,允许异步管理和通信进程。信号是 IPC 的主要方式之一,使进程能够中断或接收事件通知。例如, kill 命令可以发送信号来停止进程,或提示其重新加载配置文件。
28 |
29 | 进程调度是 Linux 内核为进程分配 CPU 时间的方式。调度程序根据优化响应能力和效率等因素的调度算法和策略,来确定在给定时间运行哪个进程。进程可以处于各种状态,例如:正在运行、等待或停止。调度程序会在这些状态之间转换,以有效地管理执行进程。
30 |
31 | \mySubsubsection{2.1.2.}{探索 IPC}
32 |
33 | Linux 操作系统中,进程独立运行,无法直接访问其他进程的内存空间。当多个进程需要通信和同步其操作时,进程的独立性会带来挑战。为了应对这些挑战, Linux 内核提供了一套多功能的 IPC 机制。每种 IPC 机制都经过量身定制,以适应不同的场景和需求,使开发人员能够构建复杂、高性能的应用程序,并有效利用异步处理。
34 |
35 | 对于旨在创建可扩展且高效的应用程序的开发人员来说,了解这些 IPC 技术至关重要。 IPC 允许进程交换数据、共享资源并协调其活动,从而促进软件系统不同组件之间顺畅可靠的通信。通过利用适当的 IPC 机制,开发人员可以在应用程序中实现更高的吞吐量、更低的延迟和更高的并发性,从而实现更好的性能和用户体验。
36 |
37 | 多任务环境中,多个进程同时运行,IPC 在实现任务的高效和协调执行方面起着至关重要的作用。例如,一个处理来自客户端的多个并发请求的 Web 服务器应用程序。 Web 服务器进程可能使用 IPC 与负责处理每个请求的子进程进行通信。这种方法允许 Web 服务器同时处理多个请求,从而提高应用程序的整体性能和可扩展性。
38 |
39 | IPC 必不可少的另一个常见场景是分布式系统或微服务架构,多个独立进程或服务需要进行通信和协作以实现共同目标。消息队列和套接字或远程过程调用 (RPC) 等 IPC 机制使这些进程能够交换消息、调用远程对象上的方法并同步其操作。
40 |
41 | 通过利用 Linux 内核提供的 IPC 机制,开发人员可以设计多个进程可以和谐协作的系统。这样就可以创建复杂、高性能的应用程序,这些应用程序可以高效利用系统资源、有效处理并发任务,并轻松扩展以满足日益增长的需求。
42 |
43 | \mySamllsection{Linux中的IPC机制}
44 |
45 | Linux 支持多种 IPC 机制,每种机制都有其特点和用例。
46 |
47 | Linux 操作系统支持的基本 IPC 机制包括共享内存(通常用于单个服务器上的进程通信)和套接字(方便服务器间通信)。还有其他机制(本文将简要介绍),但最常用的是共享内存和套接字:
48 |
49 | \begin{itemize}
50 | \item
51 | \textbf{管道}:管道是 IPC 的最简单形式之一,允许进程之间进行单向通信。命名管道或先进先出 (FIFO) 扩展了此概念,通过提供可通过文件系统中的名称访问的管道,允许不相关的进程进行通信。
52 |
53 | \item
54 | \textbf{信号}:信号是一种软件中断,可以发送给进程以通知其事件。虽然信号不是传输数据的方法,但对于控制进程行为和触发进程内的操作非常有用。
55 |
56 | \item
57 | \textbf{消息队列}:消息队列允许进程以先进先出的方式交换消息。与管道不同,消息队列支持异步通信,即消息存储在队列中,接收进程可以在方便时检索消息。
58 |
59 | \item
60 | \textbf{信号量}:信号量用于同步,帮助进程管理对共享资源的访问。通过确保只有指定数量的进程,可以在给定时间访问资源来防止竞争条件。
61 |
62 | \item
63 | \textbf{共享内存}:共享内存是 IPC 中的一个基本概念,可使多个进程能够访问和操作同一段物理内存。提供了一种在不同进程之间交换数据的超快方法,减少了耗时的数据复制操作,这种技术在处理大型数据集或需要高速通信时特别有利。共享内存的机制涉及创建共享内存段,这是多个进程可访问的专用物理内存部分。此共享内存段可视为公共工作区,允许进程读取、写入和协作修改数据。为了确保数据完整性并避免冲突,共享内存需要同步机制,例如:信号量或互斥锁。这些机制规范对共享内存段的访问,防止多个进程同时修改同一数据。这种协调对于保持数据一致性和避免覆盖或损坏至关重要。
64 |
65 | 在性能至关重要的单服务器环境中,共享内存通常是首选的 IPC 机制,其主要优势在于速度。由于数据直接在物理内存中共享,无需中间复制或上下文切换,因此显著降低了通信开销,并最大限度地减少了延迟。
66 |
67 | 然而,共享内存也有一些注意事项,需要仔细管理以防止条件竞争和内存泄漏。访问共享内存的进程必须遵守明确定义的协议,以确保数据完整性并避免死锁。此外,共享内存通常作为系统级功能实现,需要特定的操作系统支持,并可能引入特定于平台的依赖关系。
68 |
69 | 尽管存在这些考虑,共享内存仍然是一种强大且广泛使用的 IPC 技术,特别是在速度和性能是关键因素的应用程序中。
70 |
71 | \item
72 | \textbf{套接字}:套接字是操作系统中 IPC 的基本机制,为进程提供了一种相互通信的方式,无论是在同一台机器内还是跨网络。套接字用于建立和维护进程之间的连接,支持面向连接和无连接通信。
73 |
74 | 面向连接通信是一种在传输数据之前,在两个进程之间建立可靠连接的通信类型。因为需要确保所有数据都以可靠的方式按正确的顺序传输非常重要,这种类型的通信通常用于文件传输和远程登录等应用程序。
75 |
76 | 无连接通信是一种在传输数据之前,在两个进程间不建立可靠连接的通信类型。因为需要低延迟比保证所有数据的可靠传输更重要,所以这种类型的通信通常用于流媒体和实时游戏等应用程序。
77 |
78 | 套接字是网络应用程序的支柱。会在各种各样的应用程序中使用,包括 Web 浏览器、电子邮件客户端和文件共享应用程序。套接字还用于许多操作系统服务使用,例如网络文件系统 (NFS) 和域名系统 (DNS)。
79 |
80 | 以下是使用套接字的一些主要优点:
81 |
82 | \begin{itemize}
83 | \item
84 | \textbf{可靠性}:套接字提供了一种可靠的进程间通信方式,即使这些进程位于不同的机器上。
85 |
86 | \item
87 | \textbf{可扩展性}:套接字可用于支持大量并发连接,使其成为需要处理大量流量的应用程序的理想选择。
88 |
89 | \item
90 | \textbf{灵活性}:套接字可用于实现多种通信协议,适用于各种应用程序。
91 |
92 | \item
93 | \textbf{使用IPC}:套接字是 IPC 的强大工具。可在各种各样的应用程序中使用,对于构建可扩展、可靠且灵活的网络应用程序至关重要。
94 | \end{itemize}
95 |
96 | \end{itemize}
97 |
98 | 基于微服务的应用程序是异步编程的一个示例,使用不同的进程以异步方式在它们之间进行通信,一个简单的例子是日志处理器。不同的进程生成日志条目并将其发送到另一个进程进行进一步处理,例如:特殊格式、重复数据删除和统计。生产者只需发送日志行,而无需等待接收日志进程的回复。
99 |
100 | 本节中,了解了 Linux 中的进程、生命周期以及操作系统如何实现 IPC。下一节中,将介绍一种特殊的 Linux 进程,称为守护进程。
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
--------------------------------------------------------------------------------
/book/content/part1/chapter2/2.tex:
--------------------------------------------------------------------------------
1 | Linux 操作系统领域,守护进程是一个基本组件,在后台运行,默默地执行基本任务。这些进程传统上用以字母 d 结尾的名称来标识,例如 ssh\textbf{d} 表示安全 Shell (SSH) 守护进程, http\textbf{d} 表示 Web 服务器守护进程。处理对操作系统上运行的系统级任务着至关重要。
2 |
3 | 守护进程可用于多种用途,包括文件服务、 Web 服务和网络通信,以及日志记录和监控服务。守护进程具有自主性和弹性,从系统启动时开始运行持续运行,直到系统关闭。与用户启动和控制的普通进程不同,守护进程具有以下特点:
4 |
5 | \begin{itemize}
6 | \item
7 | 后台操作:
8 | \begin{itemize}
9 | \item
10 | 守护进程在后台运行
11 |
12 | \item
13 | 无用于直接用户交互的控制终端
14 |
15 | \item
16 | 不需要用户界面或手动干预执行
17 | \end{itemize}
18 |
19 | \item
20 | 用户独立性:
21 | \begin{itemize}
22 | \item
23 | 守护进程独立于用户运行
24 |
25 | \item
26 | 自主运行,无需用户参与
27 |
28 | \item
29 | 待系统事件或特定请求来触发
30 | \end{itemize}
31 |
32 | \item
33 | 以任务为导向:
34 | \begin{itemize}
35 | \item
36 | 每个守护进程都经过定制,以执行特定任务或一组任务
37 |
38 | \item
39 | 旨在处理特定功能或监听特定事件或请求
40 |
41 | \item
42 | 可确保高效执行任务
43 | \end{itemize}
44 | \end{itemize}
45 |
46 | 创建守护进程不仅涉及在后台运行进程。为了确保守护进程有效运行,开发人员必须考虑几个关键:
47 |
48 | \begin{enumerate}
49 | \item
50 | 终端分离:使用 fork() 系统调用将守护进程从终端分离。父进程在 fork 之后退出,子进程则在后台运行。
51 |
52 | \item
53 | 会话创建: setsid() 系统调用创建新会话,并将调用进程指定为会话和进程组的领导者。此步骤对于完全从终端分离至关重要。
54 |
55 | \item
56 | 更改目录:防止阻塞文件系统的卸载,守护进程通常将其工作目录更改为根目录。
57 |
58 | \item
59 | 处理文件描述符:守护进程关闭继承的文件描述符,stdin、stdout 和 stderr 通常会重定向到 /dev/null。
60 |
61 | \item
62 | 信号处理:正确处理信号(例如:用于重新加载配置的 SIGHUP 或用于正常关闭的 SIGTERM )对于有效的守护进程管理至关重要。
63 | \end{enumerate}
64 |
65 | 守护进程通过各种 IPC 机制与其他进程或守护进程进行通信,是许多异步系统架构不可或缺的一部分,提供基本服务无需直接与用户交互。
66 |
67 | 守护进程的一些经典用例:
68 |
69 | \begin{itemize}
70 | \item
71 | Web 服务器: httpd 和 nginx 等守护进程响应客户端请求提供网页,同时处理多个请求并确保无缝的网页浏览。
72 |
73 | \item
74 | 数据库服务器: mysqld 和 postgresql 等守护进程管理数据库服务,允许各种应用程序异步访问和操作数据库。
75 |
76 | \item
77 | 文件服务器: smbd、 nfsd 等守护进程提供网络文件服务,实现不同系统之间的异步文件共享和访问。
78 |
79 | \item
80 | 日志记录和监控: syslogd 和 snmpd 等守护进程收集和记录系统事件,提供系统健康和性能的异步监控。
81 | \end{itemize}
82 |
83 | 总而言之,守护进程是 Linux 系统必不可少的组件,其自主性和弹性维护着系统稳定性,并为用户和应用程序提供基本服务。
84 |
85 | 我们已经了解了进程和守护进程(一种特殊的进程)。一个进程可以有一个或多个执行线程。在下一节中,我们将介绍线程。
86 |
87 |
88 |
89 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/book/content/part1/chapter2/3.tex:
--------------------------------------------------------------------------------
1 |
2 | 进程和线程代表并发执行的两种基本方式,但二者在操作和资源管理方面存在很大差异。进程是正在运行的程序的实例,拥有一系列私有资源,包括内存、文件描述符和执行上下文。进程彼此隔离,单个进程的故障通常不会影响其他进程,从而可为整个系统提供了强大的稳定性。
3 |
4 | 线程是计算机科学中的一个基本概念,代表在单个进程内执行多个任务的一种轻量且高效的方式。与具有私有内存空间和资源的独立进程不同,线程与其所属的进程交织在一起。这种密切的关系允许线程共享相同的内存空间和资源,包括文件描述符、 堆内存,以及进程分配的其他全局数据。
5 |
6 | 线程的主要优势是能够高效地通信和共享数据。由于进程内的所有线程共享相同的内存空间,因此可以直接访问和修改公共变量,而无需通过 IPC 机制进行消息的传递。这种共享环境可实现快速数据交换,并有助于实现并发算法和数据结构。
7 |
8 | 然而,共享同一内存空间也对管理共享资源的访问带来了不小的挑战。为了避免数据损坏并确保共享数据的完整性,线程必须采用同步机制,例如:锁、信号量或互斥锁。这些机制遵循了执行访问共享资源的规则和协议,确保在限定时间内只有一个线程可以访问特定资源。
9 |
10 | 同步在多线程编程中至关重要,以避免竞争条件、死锁和其他与并发相关的问题。为了应对这些挑战,计算机科学家们开发了各种同步原语和技术。其中包括互斥锁(对共享资源的独占访问)、信号量(对有限数量的资源进行受控访问)和条件变量(使线程能够等待特定条件得到满足后再继续执行)。
11 |
12 | 通过管理同步并采用适当的并发模式,开发人员可以利用线程的强大功能,在应用程序中实现高性能和可扩展性。线程特别适合可并行化的任务,例如:图像处理、科学模拟和 Web 服务器,可以同时执行多个独立计算。
13 |
14 | 如前所述,线程是系统线程,所以它们由内核创建和管理。但有些场景中需要大量线程,系统可能没有足够的资源来创建大量系统线程,解决这个问题的方法是使用用户线程。实现用户线程的一种方法是通过协程,协程自 C++20 起已被 C++ 标准支持。
15 |
16 | 协程是 C++ 中一个较新的功能。协程定义为可在特定点暂停和恢复的函数,可在单个线程内进行协作式多任务处理。与不间断运行的标准函数不同,协程可以暂停执行并将控制权交还给调用者,后者稍后可以从暂停点恢复协程的执行。
17 |
18 | 协程比系统线程轻量得多,所以可以更快地创建和销毁,并且所需的开销更少。并且协程有协作性,必须明确地将控制权移交给调用者才能切换执行上下文,能使用户程序能够更好地控制协程的执行。
19 |
20 | 协程可用于创建各种不同的并发模式。例如,协程可用于实现任务,这些任务是可以调度并同时运行的轻量级工作单元。协程还可用于实现通道,属于协程间传递数据的通信通道。
21 |
22 | 协程可以分为有栈和无栈两类,C++20 协程是无栈的。我们将在第 8 章深入了解这些概念。
23 |
24 | 总体而言,协程是 C++ 中创建并发程序的强大工具。它们轻量级、协作性强,可用于实现各种不同的并发模式。协程不能完全用于实现并行性,因为其仍然需要 CPU 执行上下文,而这只能由线程提供。
25 |
26 | \mySubsubsection{2.3.1.}{线程的生命周期}
27 |
28 | 系统线程(通常称为轻量级进程)的生命周期包括从创建到终止的各个阶段,每个阶段在并发编程环境中管理和利用线程方面都发挥着至关重要的作用:
29 |
30 | \begin{enumerate}
31 | \item
32 | 创建:此阶段始于系统中创建新线程时。创建过程涉及使用函数,该函数需要几个参数。一个关键参数是线程的属性,例如:调度策略、堆栈大小和优先级。另一个重要参数是线程将执行的函数,成功创建后,线程将分配堆栈和其他资源。
33 |
34 | \item
35 | 执行:创建后,线程开始执行其指定的启动函数。在执行期间,线程可以独立执行各种任务,或在必要时与其他线程进行交互。线程还可以创建和管理自己的局部变量和数据结构,使其自成一体并能够同时执行特定任务。
36 |
37 | \item
38 | 同步:为了确保有序访问共享资源避免数据损坏,线程采用同步机制。常见的同步原语包括锁、信号量和栅栏。适当的同步允许线程协调其活动状态,避免并发编程中可能出现的竞争条件、死锁和其他问题。
39 |
40 | \item
41 | 终止:线程可以通过多种方式终止,可以显式调用函数来终止自身,也可以通过从其启动函数返回来终止运行。某些情况下,线程可以被另一个使用该函数的线程取消。终止后,系统将回收分配给该线程的资源,并释放该线程持有的待处理操作或锁。
42 | \end{enumerate}
43 |
44 | 了解系统线程的生命周期,对于设计和实现并发程序至关重要。通过仔细管理线程的创建、执行、同步和终止,开发人员可以创建高效且可扩展的应用程序,以充分利用并发的优势。
45 |
46 | \mySubsubsection{2.3.2.}{线程调度}
47 |
48 | 系统线程由操作系统内核的调度程序管理,具有抢占式调度。调度程序根据线程优先级、分配时间或互斥阻塞等因素,决定何时在线程之间切换执行。这种由内核控制的上下文切换可能会产生大量开销,上下文切换的成本非常高,加上每个线程的资源使用量(例如:其私有的堆栈),使得协程成为更高效替代方案,可以在单个线程中运行多个协程。
49 |
50 | 协程具有多种优势,减少了与上下文切换相关的开销。由于协程的 Yield 或 Await 对上下文的切换在用户空间代码进行(非内核处理),该过程更加轻量和高效。这可以显著提高性能,尤其是在上下文切换频繁的情况下。
51 |
52 | 协程还提供了对线程调度的控制,开发人员可以根据其应用程序的需要定义调度策略。这种灵活性可以微调线程管理、优化资源利用率,并实现所需的性能特征。
53 |
54 | 协程的另一个重要特性是与系统线程相比,协程通常更轻量。协程不维护自己的堆栈,这是一个很大的资源消耗优势,使其适配资源受限的环境。
55 |
56 | 总体而言,协程提供了一种更高效、更灵活的线程管理方法,尤其是在需要频繁切换上下文或对线程调度进行细粒度控制的情况下。线程可以访问内存进程,并且该内存由所有线程共享,因此需要小心并控制内存访问。这种控制可通过“同步原语”机制实现。
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
--------------------------------------------------------------------------------
/book/content/part1/chapter2/4.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | 同步原语是管理多线程编程并发访问共享资源的重要工具。有几种同步原语,每种都有各自的特定用途和特征:
4 |
5 | \begin{itemize}
6 | \item
7 | 互斥锁:用于强制对代码的关键部分进行独占访问。线程可以锁定互斥锁,从而阻止其他线程进入受保护的部分,直到互斥锁解锁。互斥锁保证在给定时间内只有一个线程可以执行关键部分,从而确保数据完整性并避免竞争条件。
8 |
9 | \item
10 | 信号量:比互斥量更通用,可用于更广泛的同步任务,包括线程之间的信号发送。信号量维护一个整数计数器,线程可以将其递增(信号发送)或递减(等待)。信号量允许更复杂的协调模式,例如:计数信号量(用于资源分配)和二进制信号量(类似于互斥量)。
11 |
12 | \item
13 | 条件变量:条件变量用于根据特定条件进行线程同步。线程可以阻塞(等待)条件变量,直到特定条件为真。其他线程可以向条件变量发送信号,从而唤醒等待的线程并继续执行。条件变量通常与互斥锁一起使用,以实现更细粒度的同步,并避免忙等待。
14 |
15 | \item
16 | 附加同步原语:除了前面讨论的核心同步原语之外,还有其他几种同步机制:
17 |
18 | \begin{itemize}
19 | \item
20 | 栅栏:允许一组线程同步执行,确保所有线程在继续执行之前都达到某个点
21 |
22 | \item
23 | 读写锁:读写锁提供了一种控制共享数据并发访问的方法,允许多个读取器但一次只能有一个写入器
24 |
25 | \item
26 | 自旋锁:自旋锁是一种互斥锁,涉及忙等待,持续检查内存位置,直到可用
27 | \end{itemize}
28 | \end{itemize}
29 |
30 | 在第 4 章和第 5 章中,将深入了解 C++ 标准模板库 (STL) 中实现的同步原语,以及使用它们的示例。
31 |
32 | \mySubsubsection{2.4.1.}{选择正确的同步原语}
33 |
34 | 选择适当的同步原语取决于应用程序的具体要求,以及所访问的共享资源的性质。以下是一些准则:
35 |
36 | \begin{itemize}
37 | \item
38 | 互斥锁:需要对关键部分进行独占访问以确保数据完整性并防止竞争条件时,请使用互斥锁
39 |
40 | \item
41 | 信号量:需要更复杂的协调模式时使用信号量,例如:资源分配或线程之间的信号传递
42 |
43 | \item
44 | 条件变量:线程需要等待特定条件变为真才能继续执行时,请使用条件变量
45 | \end{itemize}
46 |
47 | 有效使用同步原语,对于开发安全高效的多线程程序至关重要。通过了解不同同步机制的用途和特性,开发人员可以根据其特定需求选择最合适的原语,实现可靠且和可预测的并发程序。
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/book/content/part1/chapter2/5.tex:
--------------------------------------------------------------------------------
1 | 线程化带来了一些挑战,必须加以管理才能确保应用程序的正确性和性能。这些挑战源于多线程编程固有的并发性和不确定性。
2 |
3 | \begin{itemize}
4 | \item
5 | 当多个线程同时访问和修改共享数据时,就会发生竞争条件。结果取决于线程操作的非确定性顺序,这可能导致不可预测和不一致的结果。例如,有两个线程正在更新共享计数器,如果线程同时增加计数器,则由于竞争条件,最终值可能不正确。
6 |
7 | \item
8 | 当两个或多个线程无限期地等待彼此持有的资源时,就会发生死锁。这会形成无法解决的依赖关系循环,导致线程永久阻塞。例如,有两个线程正在等待彼此释放对共享资源的锁定,如果两个线程都没有释放其持有的锁,就会发生死锁。
9 |
10 | \item
11 | 当线程不断被拒绝访问其需要的资源时,就会发生饥饿。当其他线程不断获取并持有资源,导致饥饿线程无法执行时,就会发生这种情况。
12 |
13 | \item
14 | 活锁类似于死锁,但不会永久阻塞,线程仍然保持活动状态并反复尝试获取资源。
15 | \end{itemize}
16 |
17 | 有多种技术可用于解决线程难题,其中包括:
18 |
19 | \begin{itemize}
20 | \item
21 | 同步机制:可以使用锁和互斥锁等同步原语来控制对共享数据的访问,并确保一次只有一个线程可以访问数据。
22 |
23 | \item
24 | 死锁预防和检测:死锁预防算法可用于避免死锁,而死锁检测算法可用于在发生死锁时识别和解决死锁。
25 |
26 | \item
27 | 线程调度:线程调度算法可用于确定哪个线程应在给定时间运行,以及哪个线程可避免资源匮乏并提高应用程序性能。
28 | \end{itemize}
29 |
30 | 我们将更详细地了解多线程问题的不同解决方案。
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/book/content/part1/chapter2/6.tex:
--------------------------------------------------------------------------------
1 |
2 | 有多种处理线程的方法,可以避免多线程问题。以下是一些最常见的处理方法:
3 |
4 | \begin{itemize}
5 | \item
6 | 最小化共享状态:将线程设计为尽可能多地操作私有数据可显著减少同步需求。通过使用线程本地存储为线程特定数据分配内存,消除了对全局变量的需求,进一步降低了数据争用的可能性。通过同步原语仔细管理共享数据访问对于确保数据完整性至关重要。这种方法通过最小化同步需求,并确保以受控且一致的方式访问共享数据,提高了多线程应用程序的效率和正确性。
7 |
8 | \item
9 | 锁的层次结构:建立明确定义锁的层次结构,对于防止多线程编程中的死锁至关重要。锁的层次结构规定了获取和释放锁的顺序,从而确保跨线程的锁定模式一致。通过以从最粗到最细的粒度分层方式获取锁,可以显著降低死锁的概率。
10 |
11 | 粗粒度的锁用于控制对大部分共享资源的访问,而细粒度的锁用于对资源的特定细粒度部分进行访问。首先获取粗粒度锁,线程可以获得对大部分资源的独占访问,从而降低与试图访问同一资源的其他线程发生冲突的可能性。当获取了粗粒度锁,就可以获取更细粒度的锁来控制对特定资源部分的访问,从而提供更精细的控制并减少其他线程的等待时间。
12 |
13 | 某些情况下,可以使用无锁数据结构来完全消除对锁的需求。无锁数据结构旨在提供对共享资源的并发访问,而无需显式锁定。相反,它们依靠原子操作和非阻塞算法来确保数据完整性和一致性。通过无锁数据结构,可以消除与锁获取和释放相关的开销,从而提高多线程应用程序的性能和可扩展性:
14 |
15 | \item
16 | 超时:避免线程在尝试获取锁时无限期等待,设置获取锁的超时非常重要。确保线程无法在指定的超时期限内获取锁时,将自动放弃并稍后重试。这有助于防止死锁并确保没有线程无限期等待。
17 |
18 | \item
19 | 线程池:管理可重用线程池是优化多线程应用程序性能的关键技术。通过动态创建和销毁线程,可以显著减少线程创建和终止的开销。线程池的大小应根据应用程序的工作负载和资源限制进行调整,太小的线程池可能会导致任务等待可用线程,而太大的线程池可能会浪费资源。工作队列用于管理任务并将其分配给池中的可用线程。任务也会添加到队列中,并由线程按照 FIFO 顺序进行处理。这确保了公平性并避免了任务匮乏。使用工作队列还可以实现负载平衡,任务可以均匀分布在可用线程中。
20 |
21 | \item
22 | 同步原语:了解不同类型的同步原语,例如:互斥锁、信号量和条件变量。根据特定场景的同步要求选择合适的原语。正确使用同步原语,避免竞争条件和死锁。
23 |
24 | \item
25 | 测试和调试:全面测试多线程应用程序以识别和修复线程问题。使用线程清理器和分析器等工具来检测数据竞争和性能瓶颈。采用逐步执行和线程转储等调试技术,来分析和解决线程问题。
26 |
27 | \item
28 | 可扩展性和性能考量:设计线程安全的数据结构和算法以确保可扩展性和性能。平衡线程数量和可用资源以避免超额分配,监控 CPU 利用率和线程争用等系统指标以识别潜在的性能瓶颈。
29 |
30 | \item
31 | 沟通与协作:促进多线程代码开发人员之间的协作,以确保一致性和正确性。建立线程管理的编码指南和最佳实践,以保持代码质量和可读性。随着应用程序的发展,定期审查和更新线程策略
32 | \end{itemize}
33 |
34 | 线程是一种强大的工具,可用于提高应用程序的性能和可扩展性。但是,了解线程的挑战并使用适当的技术来应对这些挑战非常重要。这样,开发人员就可以创建正确、高效且可靠的多线程应用程序。
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/book/content/part1/chapter2/7.tex:
--------------------------------------------------------------------------------
1 | 本章中,探讨了操作系统中的进程概念。进程是执行程序和管理计算机上资源的基本实体。深入研究了进程生命周期,研究了进程从创建到终止所经历的各个阶段。此外,还讨论了 IPC,其对于进程之间的交互和信息交换至关重要。
2 |
3 | 此外,在 Linux 操作系统中介绍了守护进程。守护进程是一种特殊类型的进程,作为服务在后台运行,并执行特定任务,例如:管理系统资源、处理网络连接或为系统提供其他基本服务。还探讨了系统线程和用户线程的概念,是与父进程共享相同地址空间的轻量级进程。并讨论了多线程应用程序的优势,包括改进的性能和响应能力,以及在单个进程内管理和同步多个线程所面临的挑战。
4 |
5 | 了解多线程产生的不同问题是了解如何解决这些问题的基础。在下一章中,将介绍如何创建线程,然后在第 4 章和第 5 章中,将深入研究标准 C++ 提供的不同同步原语及其不同的应用。
--------------------------------------------------------------------------------
/book/content/part1/chapter2/8.tex:
--------------------------------------------------------------------------------
1 |
2 | \begin{itemize}
3 | \item
4 | \verb|[Butenhof, 1997]| David R. Butenhof, Programming with POSIX Threads, Addison Wesley, 1997.
5 |
6 | \item
7 | \verb|[Kerrisk, 2010]| Michael Kerrisk, The Linux Programming Interface, No Starch Press, 2010.
8 |
9 | \item
10 | \verb|[Stallings, 2018]| William Stallings, Operating Systems Internals and Design Principles, Ninth Edition, Pearson Education 2018.
11 |
12 | \item
13 | \verb|[Williams, 2019]| Anthony Williams, C++ Concurrency in Action, Second Edition, Manning 2019.
14 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part1/part.tex:
--------------------------------------------------------------------------------
1 | 第一部分中,将深入探讨构成并行编程和进程管理基础的基本概念和范例。将介绍用于构建并行系统的架构,并探索可用于开发高效并行、多线程和异步软件的各种编程范例。此外,还将介绍与进程、线程和服务相关的关键概念,强调其在操作系统中进程生命周期、性能和资源管理方面的重要性。
2 |
3 | 本部分包含以下章节:
4 |
5 | \begin{itemize}
6 | \item
7 | 第 1 章,并行编程范式
8 |
9 | \item
10 | 第 2 章,进程、线程和服务
11 | \end{itemize}
12 |
--------------------------------------------------------------------------------
/book/content/part2/chapter3/0.tex:
--------------------------------------------------------------------------------
1 | 正如前两章中了解到的,线程是程序中最小、最轻量的执行单元。每个线程负责处理由操作系统调度程序在分配的 CPU 资源上运行的一系列指令定义的独特任务。管理程序中的并发性以最大限度地提高 CPU 资源的整体利用率时,线程起着至关重要的作用。
2 |
3 | 程序启动过程中,内核将执行权交给进程后,C++ 运行时会创建主线程并执行 main() 函数。此后,可以创建其他线程,将程序拆分为可并发运行并共享资源的不同任务。这样,程序就可以处理多个任务,从而提高效率和响应能力。
4 |
5 | 本章中,将介绍如何使用现代 C++ 功能创建和管理线程的基础知识。后续章节中,将介绍 C++ 锁同步原语(互斥锁、信号量、栅栏和自旋锁)、无锁同步原语(原子变量)、协调同步原语(条件变量),以及使用 C++ 解决或避免使用并发或多线程时的潜在问题(竞争条件或数据竞争、死锁、活锁、饥饿、超额分配、负载平衡和线程耗尽)的方法。
6 |
7 | 本章中,将讨论以下主要主题:
8 |
9 | \begin{itemize}
10 | \item
11 | 如何在 C++ 中创建、管理和取消线程
12 |
13 | \item
14 | 如何向线程传递参数并从线程获取结果
15 |
16 | \item
17 | 如何让线程休眠或让其他线程执行
18 |
19 | \item
20 | jthread 对象是什么
21 | \end{itemize}
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/book/content/part2/chapter3/1.tex:
--------------------------------------------------------------------------------
1 |
2 | 本章中,将使用 C++11 和 C++20 开发不同的解决方案。因此,需要安装 GNU 编译器集合 (GCC),特别是 GCC 13,以及 Clang 8(有关 C++ 编译器支持的更多详细信息,请参阅\url{https://en.cppreference.com/w/cpp/compiler_support})。
3 |
4 | 可以在 \url{https://gcc.gnu.org} 上找到有关 GCC 的更多信息,可以在此处找到有关如何安装GCC 的信息: \url{https://gcc.gnu.org/install/index.html}。
5 |
6 | 有关 Clang(支持多种语言(包括 C++)的编译器前端)的更多信息,请访问 \url{https://clang.llvm.org}。 Clang 是 LLVM 编译器基础架构项目 (\url{https://llvm.org}) 的一部分。 Clang 中的 C++ 支持记录在此处: \url{https://clang.llvm.org/cxx_status.html}。
7 |
8 | 本书中的一些代码未显示所包含的库,一些函数(甚至是主要函数)可能会简化,仅显示相关指令。可以在以下 GitHub 库中找到所有完整代码: \url{https://github.com/PacktPublishing/Asynchronous-Programming-with-CPP}。
9 |
10 | GitHub 库根目录中的 scripts 文件夹下,可以找到一个名为 install\_compilers.sh 的脚本,该脚本可能有助于在基于 Debian 的 Linux 系统中安装所需的编译器。该脚本已在Ubuntu 22.04 和 24.04 中进行了测试。
11 |
12 | 本章的示例位于 Chapter\_03 文件夹下。所有源代码文件都可以使用 C++20 和 CMake 进行编译:
13 |
14 | \begin{shell}
15 | cmake . && cmake —build .
16 | \end{shell}
17 |
18 | 可执行文件将在 bin 目录下生成。
19 |
20 |
21 |
--------------------------------------------------------------------------------
/book/content/part2/chapter3/2.tex:
--------------------------------------------------------------------------------
1 |
2 | C++ 中创建和管理线程的主要库是线程库,让我们回顾一下线程,然后再深入了解线程库提供的功能。
3 |
4 | \mySubsubsection{3.2.1.}{什么是线程?回顾一下}
5 |
6 | 线程的目的是在一个进程中同时执行多个任务。
7 |
8 | 线程有自己的堆栈、本地数据和 CPU 寄存器,例如指令指针(IP)和堆栈指针(SP),但共享其父进程的地址空间和虚拟内存。
9 |
10 | 用户空间中,可以区分本机线程和轻量级或虚拟线程。本机线程是操作系统在使用某些内核 API 时创建的线程。 C++ 线程对象创建和管理这些类型的线程。另一方面,轻量级线程类似于本机线程,只由运行时或库模拟。在 C++ 中,协程属于这一组。轻量级线程比本机线程具有更快的上下文切换速度。此外,多个轻量级线程可以在同一个本机线程中运行,并且比本机线程小得多。
11 |
12 | 本章将介绍原生线程。第 8 章将介绍以协程形式出现的轻量级线程。
13 |
14 | \mySubsubsection{3.2.2.}{C++ 线程库}
15 |
16 | 在 C++ 中,线程允许多个函数同时运行。线程类定义了一个类型安全的本机线程接口。在标准模板库 (STL) 中的 头文件中的 std::thread 库中定义,自 C++11 起可用。
17 |
18 | C++ STL 中包含线程库之前,开发人员使用特定于平台的库,例如: Unix 或 Linux 操作系统中的 POSIX 线程 (pthread) 库、 Windows NT 和 CE 系统的 C 运行时 (CRT) 和 Win32 库或第三方库(例如:Boost.Threads)。本书中,将仅使用现代 C++ 功能。由于 可用并在特定于平台的机制之上提供了可移植的抽象,因此不会使用或解释这些库中的任何一个。第 9 章中,将介绍 Boost.Asio。在第 10 章中,将介绍 Boost.Cobalt。这两个库都提供了处理异步 I/O 操作和协程的高级框架。
19 |
20 | 现在是时候了解不同的线程操作了。
21 |
--------------------------------------------------------------------------------
/book/content/part2/chapter3/4.tex:
--------------------------------------------------------------------------------
1 |
2 | 线程本地存储 (TLS) 是一种内存管理技术,允许每个线程拥有私有变量实例。因为此技术消除了访问这些变量的同步机制开销,允许线程存储其他线程无法访问的线程特定数据,从而避免竞争条件并提高性能。
3 |
4 | TLS 由操作系统实现,可使用自 C++11 起可用的 \verb|thread_local| 关键字访问。 \verb|thread_local| 提供了一种统一的方法来使用许多操作系统的 TLS 功能,并避免使用特定于编译器的语言扩展来访问 TLS 功能(此类扩展的一些示例是 TLS Windows API、 \verb|__declspec(thread)| MSVC 编译器语言扩展或 \verb|__thread| GCC 编译器语言扩展)。
5 |
6 | 要将 TLS 与不支持 C++11 或更新版本的编译器一起使用,请使用 Boost.Library。它提供了\verb|boost::thread_specific_ptr| 容器,该容器实现了可移植的 TLS。
7 |
8 | 线程局部变量可以声明如下:
9 |
10 | \begin{itemize}
11 | \item
12 | 全局
13 |
14 | \item
15 | 在命名空间中
16 |
17 | \item
18 | 作为类静态成员变量
19 |
20 | \item
21 | 在函数内部;与使用 static 关键字分配的变量具有相同的效果,变量在程序的整个生命周期内分配,并且其值会在下一次函数调用中保留
22 | \end{itemize}
23 |
24 | 以下示例显示三个线程使用不同的参数调用 multiplyByTwo 函数。此函数将 val 线程局部变量的值设置为参数值,将其乘以 2,然后输出到控制台:
25 |
26 | \begin{cpp}
27 | #include
28 | #include
29 | #include
30 |
31 | #define sync_cout std::osyncstream(std::cout)
32 |
33 | thread_local int val = 0;
34 |
35 | void setValue(int newval) { val = newval; }
36 |
37 | void printValue() { sync_cout << val << ' '; }
38 | void multiplyByTwo(int arg) {
39 | setValue(arg);
40 | val *= 2;
41 | printValue();
42 | }
43 |
44 | int main() {
45 | val = 1; // Value in main thread
46 | std::thread t1(multiplyByTwo, 1);
47 | std::thread t2(multiplyByTwo, 2);
48 | std::thread t3(multiplyByTwo, 3);
49 | t1.join();
50 | t2.join();
51 | t3.join();
52 | std::cout << val << std::endl;
53 | }
54 | \end{cpp}
55 |
56 | 运行此代码片将显示以下输出:
57 |
58 | \begin{shell}
59 | 2 4 6 1
60 | \end{shell}
61 |
62 | 这里,可以看到每个线程都对其输入参数进行操作,导致 t1 打印 2、 t2 输出 4 并且 t 3 打印 6。运行主函数的主线程还可以访问其线程局部变量 val,该变量的值在程序启动时设置为 1,但仅在退出程序之前在主函数末尾输出到控制台时使用。
63 |
64 | TLS 也存在一些缺点。 TLS 会增加内存使用量,每个线程都会创建一个变量,在资源受限的环境中可能会出现问题。与常规变量相比,访问 TLS 变量可能会产生一些开销。这对于性能至关重要的软件来说可能有风险。
65 |
66 | 接下来,利用迄今为止学到的诸多技术,来构建一个计时器。
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/book/content/part2/chapter3/5.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | 实现一个接受间隔和回调函数的计时器。计时器将在每个间隔执行回调函数,用户可以通过调用其 stop() 函数来停止计时器。
4 |
5 | 以下代码片段展示了计时器的实现
6 |
7 | \begin{cpp}
8 | #include
9 | #include
10 | #include
11 | #include
12 | #include
13 |
14 | #define sync_cout std::osyncstream(std::cout)
15 |
16 | using namespace std::chrono_literals;
17 | using namespace std::chrono;
18 |
19 | template
20 | class Timer {
21 | public:
22 | typedef std::function Callback;
23 | Timer(const Duration interval,
24 | const Callback& callback) {
25 | auto value = duration_cast(interval);
26 | sync_cout << "Timer: Starting with interval of "
27 | << value << std::endl;
28 | t = std::jthread([&](std::stop_token stop_token) {
29 | while (!stop_token.stop_requested()) {
30 | sync_cout << "Timer: Running callback "
31 | << val.load() << std::endl;
32 | val++;
33 | callback();
34 | sync_cout << "Timer: Sleeping...\n";
35 | std::this_thread::sleep_for(interval);
36 | }
37 | sync_cout << "Timer: Exit\n";
38 | });
39 | }
40 | void stop() {
41 | t.request_stop();
42 | }
43 | private:
44 | std::jthread t;
45 | std::atomic_int32_t val{0};
46 | };
47 | \end{cpp}
48 |
49 | Timer 构造函数接受一个回调函数(一个 std::function 对象)和一个 std::chrono: :duration 对象,定义执行回调的时间段或间隔。
50 |
51 | 使用 lambda 表达式创建一个 std::jthread 对象,循环以时间间隔调用回调。此循环检查是否已通过 \verb|stop_token| 请求停止,该请求可通过 stop() Timer API 函数启用。如果是,则循环退出,线程终止。
52 |
53 | 使用方法如下:
54 |
55 | \begin{cpp}
56 | int main(void) {
57 | sync_cout << "Main: Create timer\n";
58 | Timer timer(1s, [&]() {
59 | sync_cout << "Callback: Running...\n";
60 | });
61 |
62 | std::this_thread::sleep_for(3s);
63 | sync_cout << "Main thread: Stop timer\n";
64 | timer.stop();
65 |
66 | std::this_thread::sleep_for(500ms);
67 | sync_cout << "Main thread: Exit\n";
68 | return 0;
69 | }
70 | \end{cpp}
71 |
72 | 此示例中,启动了计时器,该计时器每秒将打印一次 Callback: Running 消息。三秒后,主线程将调用 timer.stop() 函数,终止计时器线程,主线程等待 500 毫秒后退出。
73 |
74 | 这是输出:
75 |
76 | \begin{shell}
77 | Main: Create timer
78 | Timer: Starting with interval of 1000ms
79 | Timer: Running callback 0
80 | Callback: Running...
81 | Timer: Sleeping...
82 | Timer: Running callback 1
83 | Callback: Running...
84 | Timer: Sleeping...
85 | Timer: Running callback 2
86 | Callback: Running...
87 | Timer: Sleeping...
88 | Main thread: Stop timer
89 | Timer: Exit
90 | Main thread: Exit
91 | \end{shell}
92 |
93 | 作为练习,可以稍微修改此示例以实现超时类,如果在给定的超时间隔内没有输入事件,则该类会调用回调函数。这是处理网络通信时的一种常见模式,如果一段时间内没有收到数据包,则会发送数据包重放请求。
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
--------------------------------------------------------------------------------
/book/content/part2/chapter3/6.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | 本章中,介绍了如何创建和管理线程、如何传递参数或检索结果、 TLS 的工作原理以及如何等待线程完成。还介绍了如何让线程将控制权让给其他线程或取消其执行。如果出现问题并引发异常,了解如何在线程之间传递异常并避免程序意外终止。最后,实现了一个定期运行回调函数的计时器类。
4 |
5 | 下一章中,将介绍线程安全、互斥和原子操作。其中包括互斥、锁定和无锁算法以及内存同步排序等主题。这些知识将有助于开发线程安全的数据结构和算法。
--------------------------------------------------------------------------------
/book/content/part2/chapter3/7.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | \begin{itemize}
4 | \item
5 | 编译器支持情况: \url{https://en.cppreference.com/w/cpp/compiler_support}
6 |
7 | \item
8 | GCC 发布版本: \url{https://gcc.gnu.org/releases.html}
9 |
10 | \item
11 | Clang: \url{https://clang.llvm.org}
12 |
13 | \item
14 | Clang 8 文档: \url{https://releases.llvm.org/8.0.0/tools/clang/docs/index.html}
15 |
16 | \item
17 | LLVM 项目: \url{https://llvm.org}
18 |
19 | \item
20 | Boost.Threads: \url{https://www.boost.org/doc/libs/1_78_0/doc/html/thread.html}
21 |
22 | \item
23 | P0024 – 并行性技术规范: \url{https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/p0024r0.html}
24 |
25 | \item
26 | TLS 提案: \url{https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2008/n2659.htm}
27 |
28 | \item
29 | C++0X的线程启动: \url{https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2007/n2184.html}
30 |
31 | \item
32 | IBM 的 TLS: \url{https://docs.oracle.com/cd/E19683-01/817-3677/chapter8-1/index.html}
33 |
34 | \item
35 | 线程私有的数据: \url{https://www.ibm.com/docs/en/i/7.5?topic=techniques-data-that-is-private-thread}
36 |
37 | \item
38 | 资源获取即初始化(RAII): \url{https://en.cppreference.com/w/cpp/language/raii}
39 |
40 | \item
41 | Bjarne Stroustrup, 《C++ 之旅》,第三版, 18.2 和 18.7
42 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part2/chapter4/0.tex:
--------------------------------------------------------------------------------
1 | 第 2 章中,介绍了线程可以读取和写入它们所属进程共享的内存。操作系统实现了进程内存访问保护,但对于访问同一进程中共享内存的线程,并没有这样的保护。多个线程对同一内存地址的并发内存写入操作,需要同步机制来避免数据争用并确保数据完整性。
2 |
3 | 本章中,将详细介绍多个线程并发访问共享内存,所产生的问题以及如何解决这些问题。我们将详细介绍以下主题:
4 |
5 | \begin{itemize}
6 | \item
7 | 条件竞争 – 是什么以及如何发生
8 |
9 | \item
10 | 互斥作为一种同步机制,如何通过 std::mutex 在 C++ 中实现
11 |
12 | \item
13 | 通用的锁管理
14 |
15 | \item
16 | 什么是条件变量,以及如何将它们与互斥锁一起使用
17 |
18 | \item
19 | 使用 std::mutex 和 \verb|std::condition_variable|实现完全同步队列
20 |
21 | \item
22 | C++20 引入的新同步原语 - 信号量、栅栏和门闩
23 | \end{itemize}
24 |
25 | 这些都是基于锁的同步机制。无锁技术是下一章的主题。
26 |
--------------------------------------------------------------------------------
/book/content/part2/chapter4/1.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | 本章的技术要求与上一章中解释的概念相同,要编译和运行示例,需要支持 C++20 的 C++ 编译器(用于信号量、门闩和栅栏示例),大多数示例只需要 C++11。示例已在 Linux Ubuntu LTS 24.04 上进行了测试。
4 |
5 | 本章的代码可以在GitHub上找到: \url{https://github.com/PacktPublishing/Asynchronous-Programming-with-CPP}
--------------------------------------------------------------------------------
/book/content/part2/chapter4/10.tex:
--------------------------------------------------------------------------------
1 | 本章中,介绍了如何使用 C++ 标准库提供的基于锁的同步原语。
2 |
3 | 首先解释了条件竞争和互斥的必要性,然后研究了 std::mutex 以及如何使用它来解决条件竞争。还介绍了使用锁进行同步时的主要问题:死锁和活锁。
4 |
5 | 之后,研究了条件变量,并使用互斥锁和条件变量实现了同步队列。最后,了解了 C++20 中引入的新同步原语:信号量、门闩和栅栏。
6 |
7 | 最后,研究了 C++ 标准库提供的仅运行一次函数的机制。
8 |
9 | 本章中,了解了线程同步的基本构成要素以及多线程异步编程的基础,基于锁的线程同步是同步线程最常用的方法。
10 |
11 | 下一章,将介绍无锁线程同步。首先回顾 C++20 标准库提供的原子性、原子操作和原子类型。展示无锁绑定单生产者单消费者队列的实现,还将介绍 C++ 内存模型。
--------------------------------------------------------------------------------
/book/content/part2/chapter4/11.tex:
--------------------------------------------------------------------------------
1 | \begin{itemize}
2 | \item
3 | David R. Butenhof, Programming with POSIX Threads, Addison Wesley, 1997.
4 |
5 | \item
6 | Anthony Williams, C++ Concurrency in Action, Second Edition, Manning, 2019.
7 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part2/chapter4/2.tex:
--------------------------------------------------------------------------------
1 | 当程序运行的结果取决于其指令的执行顺序时,就会发生条件竞争。举一个简单的例子,展示条件竞争是如何发生的,然后了解如何解决这个问题。
2 |
3 | 下面的代码中,计数器全局变量由两个同时运行的线程增加:
4 |
5 | \begin{cpp}
6 | #include
7 | #include
8 |
9 | int counter = 0;
10 |
11 | int main() {
12 | auto func = [] {
13 | for (int i = 0; i < 1000000; ++i) {
14 | counter++;
15 | }
16 | };
17 |
18 | std::thread t1(func);
19 | std::thread t2(func);
20 |
21 | t1.join();
22 | t2.join();
23 |
24 | std::cout << counter << std::endl;
25 | return 0;
26 | }
27 | \end{cpp}
28 |
29 | 运行上述代码三次后,我们得到以下计数器值:
30 |
31 | \begin{shell}
32 | 1056205
33 | 1217311
34 | 1167474
35 | \end{shell}
36 |
37 | 这里看到两个问题:首先,计数器的值不正确;其次,程序每次执行都会以不同的计数器值结束。结果不确定,而且大多数情况下都不正确。
38 |
39 | 这个场景涉及两个线程 t1 和 t2,同时运行并修改同一个变量,该变量本质上是某个内存区域。这应该可以正常工作,因为只有一行代码会增加计数器值,从而修改内存内容(顺便说一句,使用像 counter++ 中的后增量运算符或像 ++counter 中的前增量运算符都没有关系;结果同样是错误的)。
40 |
41 | 仔细查看上面的代码,仔细研究以下行:
42 |
43 | \begin{cpp}
44 | counter++;
45 | \end{cpp}
46 |
47 | 分三步增加计数器:
48 |
49 | \begin{itemize}
50 | \item
51 | 计数器变量存储的内存地址的内容被加载到 CPU 寄存器中。本例中, int 数据类型从内存加载到 CPU 寄存器中。
52 |
53 | \item
54 | 寄存器中的值增加一。
55 |
56 | \item
57 | 寄存器中的值保存在计数器变量的内存地址中。
58 | \end{itemize}
59 |
60 | 现在,考虑两个线程尝试同时增加计数器的可能情况,来看看表 4.1:
61 |
62 | % Please add the following required packages to your document preamble:
63 | % \usepackage{longtable}
64 | % Note: It may be necessary to compile the document several times to get a multi-page table to line up properly
65 | \begin{longtable}{|l|l|}
66 | \hline
67 | 线程 1 & 线程 2 \\ \hline
68 | \endfirsthead
69 | %
70 | \endhead
71 | %
72 | {[}1{]} 将计数器值装入寄存器 & {[}3{]} 将计数器值装入寄存器 \\ \hline
73 | {[}2{]} 增加寄存器的值 & {[}5{]} 增加寄存器的值 \\ \hline
74 | {[}4{]} 寄存器计数器 & {[}6{]} 寄存器计数器 \\ \hline
75 | \end{longtable}
76 |
77 | \begin{center}
78 | 表 4.1:两个线程同时增加计数器
79 | \end{center}
80 |
81 | 线程 1 执行 [1] 并将计数器的当前值(假设为 1)加载到 CPU 寄存器中。然后,它将寄存器中的值加1 [2](现在,寄存器值为 2)。
82 |
83 | 线程 2 被安排执行,并且 [3] 将计数器的当前值(记住 - 它还没有被修改,所以它仍然为 1 )加载到 CPU 寄存器中。
84 |
85 | 现在,线程 1 再次执行,并且 [4] 将更新的值存储到内存中。计数器的值现在等于 2 。
86 | 最后,线程 2 再次执行 [5] 和 [6]。寄存器值增加1,然后将2存储在内存中。计数器变量只增加了一次,而它应该增加两次,其值应该是三。
87 |
88 | 发生上述问题是,计数器上的递增操作不是原子的。如果每个线程都可以在不中断的情况下,执行递增计数器变量所需的三条指令,则计数器将按预期递增两次。但根据操作执行的顺序,结果可能会有所不同,这称为条件竞争。
89 |
90 | 为了避免条件竞争,需要确保以受控的方式访问和修改共享资源。实现此目的的一种方法是使用锁。锁是一种同步原语,一次只允许一个线程访问共享资源。当线程想要访问共享资源时,必须首先获取锁。线程获取了锁后,就可以访问共享资源而不会受到其他线程的干扰。当线程访问完共享资源后,必须释放锁,以便其他线程可以访问相应的共享资源。
91 |
92 | 避免条件竞争的另一种方法是使用原子操作,可保证在单个不可分割的步骤中执行的操作。在执行原子操作时,没有其他线程可以干扰该操作。原子操作通常使用设计为不可分割的硬件指令来实现。
93 |
94 | 本节中,介绍了多线程代码产生的最常见和最重要的问题:条件竞争。根据执行操作的顺序,结果可能会有所不同。考虑到这个问题,将在下一节中研究如何解决它。
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
--------------------------------------------------------------------------------
/book/content/part2/chapter4/4.tex:
--------------------------------------------------------------------------------
1 |
2 | 上一节中,C++ 标准库提供的不同类型的互斥锁。本节中,将介绍提供的类,以便更轻松地使用互斥锁。这是通过使用不同的包装器类来实现的。下表总结了锁管理类及其主要功能:
3 |
4 | % Please add the following required packages to your document preamble:
5 | % \usepackage{longtable}
6 | % Note: It may be necessary to compile the document several times to get a multi-page table to line up properly
7 | \begin{longtable}{|l|l|l|}
8 | \hline
9 | \textbf{互斥管理器类} & \textbf{支持的互斥锁类型} & \textbf{互斥对象管理} \\ \hline
10 | \endfirsthead
11 | %
12 | \endhead
13 | %
14 | std::lock\_guard & 所有类型 & 1 \\ \hline
15 | std::scoped\_lock & 所有类型 & 零或更多 \\ \hline
16 | std::unique\_lock & 所有类型 & 1 \\ \hline
17 | std::shared\_lock & \begin{tabular}[c]{@{}l@{}}std::shared\_mutex \\ std::shared\_timed\_mutex\end{tabular} & 1 \\ \hline
18 | \end{longtable}
19 |
20 | \begin{center}
21 | 表 4.3:锁管理类别及其特性
22 | \end{center}
23 |
24 | \mySubsubsection{4.4.1.}{std::lock\_guard}
25 |
26 | std::lock\_guard 类是一个资源获取即初始化 (RAII) 类,使使用互斥锁更加容易,并保证在调用 lock\_guard 析构函数时释放互斥锁。这在处理异常等情况下非常有用。
27 |
28 | 以下代码展示了 std::lock\_guard 的用法,以及当已获取锁时如何更容易地处理异常:
29 |
30 | \begin{cpp}
31 | #include
32 | #include
33 | #include
34 | #include
35 |
36 | std::mutex mtx;
37 | uint32_t counter{};
38 |
39 | void function_throws() { throw std::runtime_error("Error"); }
40 |
41 | int main() {
42 | auto worker = [] {
43 | for (int i = 0; i < 1000000; ++i) {
44 | mtx.lock();
45 | counter++;
46 | mtx.unlock();
47 | }
48 | };
49 |
50 | auto worker_exceptions = [] {
51 | for (int i = 0; i < 1000000; ++i) {
52 | try {
53 | std::lock_guard lock(mtx);
54 | counter++;
55 | function_throws();
56 | } catch (std::system_error& e) {
57 | std::cout << e.what() << std::endl;
58 | return;
59 | } catch (...) {
60 | return;
61 | }
62 | }
63 | };
64 |
65 | std::thread t1(worker_exceptions);
66 | std::thread t2(worker);
67 |
68 | t1.join();
69 | t2.join();
70 |
71 | std::cout << "Final counter value: " << counter << std::endl;
72 | }
73 | \end{cpp}
74 |
75 | function\_throws() 函数只是一个会引发异常的实用函数。
76 |
77 | 代码示例中, worker\_exceptions() 函数由 t1 执行。这种情况下,处理异常以打印有意义的消息。未显式获取/释放锁。这委托给 lock,一个 std::lock\_guard 对象。构造锁时,会包装互斥锁并调用 mtx.lock(),获取锁。当锁销毁时,互斥锁会自动释放。如果发生异常,互斥锁也将被释放,因为已退出定义锁的范围。
78 |
79 | 为 std::lock\_guard 实现了另一个构造函数,接收 std::adopt\_lock\_t 类型的参数。基本上,这个构造函数可以包装已经获取的非共享互斥锁,将在 std::lock\_guard 析构函数中自动释放。
80 |
81 | \mySubsubsection{4.4.2.}{std::unique\_lock}
82 |
83 | std::lock\_guard 类只是一个简单的 std::mutex 包装器,自动在其构造函数中获取互斥锁(线程将阻塞,等待另一个线程释放互斥锁)并在其析构函数中释放互斥锁。这非常有用,但有时需要更多控制。例如, std::lock\_guard 将在互斥锁上调用 lock() 或假定互斥锁已被获取。可能更倾向于或确实需要调用 try\_lock,可能还希望 std::mutex 包装器不要在其构造函数中获取锁;所以希望将锁定推迟到稍后。所有这些功能均能由 std::unique\_lock 实现。
84 |
85 | std::unique\_lock 构造函数接受一个标签作为其第二个参数,以指示要对底层互斥锁执行的操作。这里有三个选项
86 |
87 | \begin{itemize}
88 | \item
89 | std::defer\_lock:不获取互斥锁的所有权。互斥锁在构造函数中未锁定,如果从未获取过,则不会在析构函数中解锁。
90 |
91 | \item
92 | std::adopt\_lock:假设调用线程已获取互斥锁。将在析构函数中释放。此选项也适用于std::lock\_guard。
93 |
94 | \item
95 | std::try\_to\_lock:尝试在不阻塞的情况下获取互斥锁。
96 | \end{itemize}
97 |
98 | 如果仅将互斥锁作为唯一参数传递给 std::unique\_lock 构造函数,其行为与 std::lock\_guard 中的行为相同:其将阻塞直到互斥锁可用,然后获取锁,并将在析构函数中释放锁。
99 |
100 | std::unique\_lock 类与 std::lock\_guard 不同,它允许调用 lock() 和 unlock() 来分别获取和释放互斥锁。
101 |
102 | \mySubsubsection{4.4.3.}{std::scoped\_lock}
103 |
104 | std::scoped\_lock 类与 std::unique\_lock 一样,是实现 RAII 机制的 std::mutex 包装器(如果获取了互斥锁,则会在析构函数中释放)。主要区别在于,std::unique\_lock 只包装一个互斥锁,而 std::scoped\_lock 包装零个或多个互斥锁。此外,互斥锁的获取顺序与传递给 std::scoped\_lock 构造函数的顺序相同,可以避免死锁。
105 |
106 | 来看看下面的代码:
107 |
108 | \begin{cpp}
109 | std::mutex mtx1;
110 | std::mutex mtx2;
111 |
112 | // Acquire both mutexes avoiding deadlock
113 | std::scoped_lock lock(mtx1, mtx2);
114 |
115 | // Same as doing this
116 | // std::lock(mtx1, mtx2);
117 | // std::lock_guard lock1(mtx1, std::adopt_lock);
118 | // std::lock_guard lock2(mtx2, std::adopt_lock);
119 | \end{cpp}
120 |
121 | 前面的代码片段展示了如何使用两个互斥锁。
122 |
123 | \mySubsubsection{4.4.4.}{std::shared\_lock}
124 |
125 | std::shared\_lock 类是另一个通用互斥锁所有权包装器。与 std::unique\_lock 和 std::scoped\_lock 一样,允许延迟锁定和转移锁所有权。 std::unique\_lock 和 std::shared\_lock 之间的主要区别在于,后者用于在共享模式下获取/释放包装的互斥锁,而前者用于在独占模式下执行相同操作。
126 |
127 | 本节中,介绍了互斥包装类及其主要功能。接下来,将介绍另一种同步机制: 条件变量。
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
--------------------------------------------------------------------------------
/book/content/part2/chapter4/5.tex:
--------------------------------------------------------------------------------
1 | 条件变量是 C++ 标准库提供的另一个同步原语,允许多个线程相互通信,还允许多个线程等待来自另一个线程的通知。条件变量始终与互斥锁相关联。
2 |
3 | 以下示例中,线程必须等待计数器等于某个值:
4 |
5 | \begin{cpp}
6 | #include
7 | #include
8 | #include
9 | #include
10 | #include
11 | #include
12 |
13 | int counter = 0;
14 |
15 | int main() {
16 | using namespace std::chrono_literals;
17 |
18 | std::mutex mtx;
19 | std::mutex cout_mtx;
20 | std::condition_variable cv;
21 |
22 | auto increment_counter = [&] {
23 | for (int i = 0; i < 20; ++i) {
24 | std::this_thread::sleep_for(100ms);
25 | mtx.lock();
26 | ++counter;
27 | mtx.unlock();
28 | cv.notify_one();
29 | }
30 | };
31 |
32 | auto wait_for_counter_non_zero_mtx = [&] {
33 | mtx.lock();
34 | while (counter == 0) {
35 | mtx.unlock();
36 | std::this_thread::sleep_for(10ms);
37 | mtx.lock();
38 | }
39 | mtx.unlock();
40 | std::lock_guard cout_lck(cout_mtx);
41 | std::cout << "Counter is non-zero" << std::endl;
42 | };
43 |
44 | auto wait_for_counter_10_cv = [&] {
45 | std::unique_lock lck(mtx);
46 | cv.wait(lck, [] { return counter == 10; });
47 |
48 | std::lock_guard cout_lck(cout_mtx);
49 | std::cout << "Counter is: " << counter << std::endl;
50 | };
51 |
52 | std::thread t1(wait_for_counter_non_zero_mtx);
53 | std::thread t2(wait_for_counter_10_cv);
54 | std::thread t3(increment_counter);
55 |
56 | t1.join();
57 | t2.join();
58 | t3.join();
59 |
60 | return 0;
61 | }
62 | \end{cpp}
63 |
64 | 等待某个条件有两种方式:一种是循环等待,并使用互斥锁作为同步机制,这在 wait\_for\_counter\_non\_zero\_mtx 中实现。该函数获取锁,读取 counter 中的值,然后释放锁。然后,休眠 10 毫秒,然后再次获取锁。这在 while 循环中完成,直到 counter 非零。
65 |
66 | 条件变量简化了前面的代码。 wait\_for\_counter\_10\_cv 函数会等待直到计数器等于 10。线程将等待 cv 条件变量,直到它被 t1 通知,线程会循环增加计数器。
67 |
68 | wait\_for\_counter\_10\_cv 函数的工作方式如下:条件变量 cv 等待互斥锁 mtx。调用 wait() 后,条件变量锁定互斥锁并等待,直到条件为真(条件在作为第二个参数传递给 wait 函数的 l ambda 中实现)。如果条件不为真,条件变量将保持等待状态,直到收到信号并释放互斥锁。一旦满足条件,条件变量将结束其等待状态,并再次锁定互斥锁以同步其对计数器的访问。
69 |
70 | 一个重要问题是,条件变量可能由不相关的线程发出信号,这称为伪唤醒。为了避免因伪唤醒而导致错误,在等待时会检查条件。当条件变量发出信号时,会再次检查条件。
71 |
72 | 如果发生伪唤醒且计数器为零(条件检查返回 false),则等待将恢复。
73 |
74 | 另一个线程通过运行increment\_counter来增加计数器。当计数器具有所需的值(在本例中,该值为10),就会向等待线程条件变量发出信号。
75 |
76 | 有两个函数用于向条件变量发送信号:
77 |
78 | \begin{itemize}
79 | \item
80 | cv.notify\_one():仅向其中一个等待线程发送信号
81 |
82 | \item
83 | cv.notify\_all():向所有等待线程发出信号
84 | \end{itemize}
85 |
86 | 本节中,介绍了条件变量,并看到了使用条件变量进行同步的简单示例,以及在某些情况下如何简化同步/等待代码。现在,将注意力转向使用互斥锁和两个条件变量实现同步队列。
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
--------------------------------------------------------------------------------
/book/content/part2/chapter4/7.tex:
--------------------------------------------------------------------------------
1 |
2 | C++20 引入了新的同步原语来编写多线程应用程序。本节中,将介绍信号量。
3 |
4 | 信号量是一种计数器,用于管理可用于访问共享资源的许可证数量。
5 |
6 | 信号量可分为两种主要类型:
7 |
8 | \begin{itemize}
9 | \item
10 | 二进制信号量类似于互斥锁,只有两种状态: 0 和 1。尽管二进制信号量在概念上类似于互斥锁,但二进制信号量与互斥锁之间存在一些差异。
11 |
12 | \item
13 | 计数信号量可以具有大于 1 的值,用于控制对具有有限数量实例的资源的访问。
14 | \end{itemize}
15 |
16 | C++20 实现了二进制和计数信号量。
17 |
18 | \mySubsubsection{4.7.1.}{二进制信号量}
19 |
20 | 二进制信号量是一种同步原语,可用于控制对共享资源的访问。它有两种状态: 0 和 1。值为 0 的信号量表示资源不可用,值为 1 的信号量表示资源可用。
21 |
22 | 二进制信号量可用于实现互斥,通过使用二进制信号量来控制对资源的访问来实现。当一个线程想要访问资源时,首先检查信号量。如果信号量为 1,则该线程可以访问该资源。如果信号量为 0,则线程必须等到信号量为 1 后才能访问该资源。
23 |
24 | 互斥锁和信号量之间最显著的区别在于,互斥锁具有独占所有权,而二进制信号量则没有。线程都可以向信号量发出信号,只有获取互斥锁的线程才能释放。互斥锁是临界区的锁定机制,而信号量更像是一种信号机制。这方面,信号量比互斥锁更接近条件变量。因此,信号量通常用于发出信号,而不是用于互斥。
25 |
26 | C++20中, std::binary\_semaphore 是 std::counting\_semaphore 特化的别名,其 LeastMaxValue 为 1。
27 |
28 | 二进制信号量必须用 1 或 0 初始化:
29 |
30 | \begin{cpp}
31 | std::binary_semaphore sm1{ 0 };
32 | std::binary_semaphore sm2{ 1 };
33 | \end{cpp}
34 |
35 | 如果初始值为 0,则获取信号量将阻塞尝试获取线程,并且在获取之前,必须由另一个线程释放它。获取信号量会减少计数器,而释放它会增加计数器。如果计数器为 0 并且线程尝试获取锁(信号量),则将阻塞该线程,直到信号量计数器大于 0。
36 |
37 | \mySubsubsection{4.7.2.}{计数信号量}
38 |
39 | 计数信号量允许多个线程访问共享资源。计数器可以初始化为任意数字,并且每次线程获取信号量时,计数器都会减少。作为如何使用计数信号量的一个例子,修改上一节中实现的多线程安全队列,并使用信号量而不是条件变量来同步对队列的访问。
40 |
41 | 新类的成员变量如下:
42 |
43 | \begin{cpp}
44 | template
45 | class queue {
46 | // public methods and private helper methods
47 |
48 | private:
49 | std::counting_semaphore<> sem_empty_;
50 | std::counting_semaphore<> sem_full_;
51 | std::size_t head_{ 0 };
52 | std::size_t tail_{ 0 };
53 | std::size_t capacity_;
54 | std::vector buffer_;
55 | };
56 | \end{cpp}
57 |
58 | 仍然需要 head\_ 和 tail\_ 来知道在哪里读取和写入元素,需要 capacity\_ 来表示索引的环绕,还需要 buffer\_(一个std::vector)。但目前,不使用互斥锁,将使用计数信号量而不是条件变量。将使用其中两个: sem\_empty\_ 来计算缓冲区中的空槽(初始设置为 capacity\_), sem\_full\_ 来计算缓冲区中的非空槽,初始设置为 0。
59 |
60 | 现在,来看看如何实现 push,即用于在队列中插入项目的函数。
61 |
62 | 在 [1] 中,获取 sem\_empty\_,减少信号量计数器。如果队列已满,则线程将阻塞,直到另一个线程释放(发出信号) sem\_empty\_。如果队列未满,则将项目复制到缓冲区,并在 [2] 和 [3] 更新 tail\_。最后,[4]释放 sem\_full\_,向另一个线程发出信号,表明队列不为空,并且缓冲区中至少有一个元素:
63 |
64 | \begin{cpp}
65 | void push(const T& item) {
66 | sem_empty_.acquire(); // [1]
67 |
68 | buffer_[tail_] = item; // [2]
69 | tail_ = next(tail_); // [3]
70 |
71 | sem_full_.release(); // [4]
72 | }
73 | \end{cpp}
74 |
75 | pop 函数用于从队列中获取元素:
76 |
77 | \begin{cpp}
78 | void pop(T& item) {
79 | sem_full_.acquire(); // [1]
80 |
81 | item = buffer_[head_]; // [2]
82 | head_ = next(head_); // [3]
83 |
84 | sem_empty_.release(); // [4]
85 | }
86 | \end{cpp}
87 |
88 | 这里,在 [1] 中,如果队列不为空,我们成功获取了 sem\_full\_。然后,在 [2] 和 [3] 中读取项目并更新 head\_。最后,向消费者线程发出信号,告知队列未满,释放 sem\_empty。
89 |
90 | 第一个版本的 push 存在几个问题,最重要的问题是 sem\_empty\_ 允许多个线程访问队列中的临界区([2] 和 [3])。需要同步这个临界区并使用互斥锁。
91 |
92 | 这是使用互斥锁进行同步新版本的推送。
93 |
94 | [2]获取了锁(使用 std::unique\_lock)。[4]释放了锁。使用锁将同步临界区,防止多个线程同时访问它并在没有任何同步的情况下并发更新队列:
95 |
96 | \begin{cpp}
97 | void push(const T& item)
98 | {
99 | sem_empty_.acquire();
100 |
101 | std::unique_lock lock(mtx_);
102 | buffer_[tail_] = item;
103 | tail_ = next(tail_);
104 | lock.unlock();
105 |
106 | sem_full_.release();
107 | }
108 | \end{cpp}
109 |
110 | 第二个问题是获取信号量是阻塞的,有时调用者线程可以做一些处理,而不仅仅是等待。 try\_push 函数(及其对应的 try\_pop 函数)实现了此功能。看一下 try\_push 的代码, try\_push 可能仍会在互斥锁上阻塞:
111 |
112 | \begin{cpp}
113 | bool try_push(const T& item) {
114 | if (!sem_empty_.try acquire()) {
115 | return false;
116 | }
117 |
118 | std::unique_lock lock(mtx_);
119 |
120 | buffer_[tail_] = item;
121 | tail_ = next(tail_);
122 |
123 | lock.unlock();
124 |
125 | sem_full_.release();
126 |
127 | return true;
128 | }
129 | \end{cpp}
130 |
131 | 获取信号量时不再阻塞,而是尝试获取,如果失败,则返回 false 。即使可以获取信号量(计数不为零), try\_acquire 函数也可能虚假失败并返回 false。
132 |
133 | 以下是使用信号量同步的队列的完整代码:
134 |
135 | \begin{cpp}
136 | #pragma once
137 |
138 | #include
139 | #include
140 | #include
141 |
142 | namespace async_prog {
143 | template
144 | class semaphore_queue {
145 | public:
146 | semaphore_queue(std::size_t capacity)
147 | : sem_empty_(capacity), sem_full_(0), capacity_{capacity},
148 | buffer_(capacity)
149 | {}
150 |
151 | void push(const T& item) {
152 | sem_empty_.acquire();
153 |
154 | std::unique_lock lock(mtx_);
155 |
156 | buffer_[tail_] = item;
157 | tail_ = next(tail_);
158 |
159 | lock.unlock();
160 |
161 | sem_full_.release();
162 | }
163 |
164 | bool try_push(const T& item) {
165 | if (!sem_empty_.try_acquire()) {
166 | return false;
167 | }
168 |
169 | std::unique_lock lock(mtx_);
170 |
171 | buffer_[tail_] = item;
172 | tail_ = next(tail_);
173 |
174 | lock.unlock();
175 | sem_full_.release();
176 | return true;
177 | }
178 |
179 | void pop(T& item) {
180 | sem_full_.acquire();
181 |
182 | std::unique_lock lock(mtx_);
183 |
184 | item = buffer_[head_];
185 | head_ = next(head_);
186 |
187 | lock.unlock();
188 | sem_empty_.release();
189 | }
190 |
191 | bool try_pop(T& item) {
192 | if (!sem_full_.try_acquire()) {
193 | return false;
194 | }
195 |
196 | std::unique_lock lock(mtx_);
197 |
198 | item = buffer_[head_];
199 | head_ = next(head_);
200 |
201 | lock.unlock();
202 | sem_empty_.release();
203 |
204 | return true;
205 | }
206 | private:
207 | [[nodiscard]] std::size_t next(std::size_t idx) const noexcept {
208 | return ((idx + 1) % capacity_);
209 | }
210 | private:
211 | std::mutex mtx_;
212 | std::counting_semaphore<> sem_empty_;
213 | std::counting_semaphore<> sem_full_;
214 |
215 | std::size_t head_{0};
216 | std::size_t tail_{0};
217 | std::size_t capacity_;
218 | std::vector buffer_;
219 | };
220 | \end{cpp}
221 |
222 | 本节中,介绍了信号量,这是自 C++20 以来包含在 C++ 标准库中的一种新同步原语。介绍了如何使用它们来实现之前实现的相同队列,但使用信号量作为同步原语。
223 |
224 | 下一节中,将介绍栅栏和门闩,这是自 C++20 以来 C++ 标准库中包含的两种新同步机制。
225 |
226 |
227 |
228 |
229 |
230 |
--------------------------------------------------------------------------------
/book/content/part2/chapter4/8.tex:
--------------------------------------------------------------------------------
1 | 本节中,将介绍栅栏门闩,这是 C++20 中引入的两个新同步原语。这些机制允许线程相互等待,从而协调并发任务的执行。
2 |
3 | \mySubsubsection{4.8.1.}{std::latch}
4 |
5 | std::latch 门闩是一种同步原语,允许一个或多个线程阻塞,直到指定数量的操作完成。它是一个一次性对象,当计数达到零,就无法重置。
6 |
7 | 以下示例简单说明了门闩在多线程应用程序中的使用。要编写一个函数,将向量数组的每个元素乘以二,然后将向量数组的所有元素相加。这里,使用三个线程将向量数组中的元素乘以二,然后使用一个线程将向量数组中的所有元素相加并得出结果。
8 |
9 | 这里需要两个门闩。第一个门闩将由三个线程中的每一个线程乘以两个向量元素而减少。添加线程将等待此门闩为零。然后,主线程将等待第二个门闩以同步打印添加所有向量数组中元素的结果。也可以等待执行添加的线程在其上调用 join,这也可以使用门闩来完成。
10 |
11 | 现在,分析功能块中的代码:
12 |
13 | \begin{cpp}
14 | std::latch map_latch{ 3 };
15 | auto map_thread = [&](std::vector& numbers, int start, int end) {
16 | for (int i = start; i < end; ++i) {
17 | numbers[i] *= 2;
18 | }
19 | map_latch.count_down();
20 | };
21 | \end{cpp}
22 |
23 | 每个乘法线程都会运行此 lambda 函数,将向量数组中某个范围(从开始到结束)的两个元素相乘。线程完成后,会将 map\_latch 计数器减一。所有线程完成任务后,门闩计数器将为零,而阻塞等待 map\_latch 的线程将能够继续将向量的所有元素相加。注意,线程访问向量数组的不同元素,因此不需要同步对向量数组本身的访问,但在完成所有乘法之前,不能立即进行累加。
24 |
25 | 累加线程的代码如下:
26 |
27 | \begin{cpp}
28 | std::latch reduce_latch{ 1 };
29 | auto reduce_thread = [&](const std::vector& numbers, int& sum) {
30 | map_latch.wait();
31 |
32 | sum = std::accumulate(numbers.begin(), numbers.end(), 0);
33 |
34 | reduce_latch.count_down();
35 | };
36 | \end{cpp}
37 |
38 | 该线程等待 map\_latch 计数器降至零,然后添加向量的所有元素,最后减少 reduce\_latch 计数器(将降至零)以便主线程能够输出最终结果:
39 |
40 | \begin{cpp}
41 | reduce_latch.wait();
42 | std::cout << "All threads finished. The sum is: " << sum << '\n';
43 | \end{cpp}
44 |
45 | 了解了门闩的基本应用之后,接下来让介绍一下栅栏。
46 |
47 | \mySubsubsection{4.8.2.}{std::barrier}
48 |
49 | std::barrier 栅栏是另一个用于同步一组线程的同步原语。 std::barrier 栅栏是可重复使用的。每个线程到达栅栏并等待,直到所有参与线程都到达相同的栅栏点(与使用门闩的情况一样)。
50 |
51 | std::barrier 和 std::latch 之间的主要区别在于重置功能。 std::latch 门闩是一次性栅栏,具有无法重置的倒计时机制。一旦达到零,就会保持在零。相比之下, std::barrier 是可重复使用的。会在所有线程到达栅栏后重置,从而允许同一组线程多次在同一个栅栏处同步。
52 |
53 | 何时使用门闩,何时使用栅栏?当有线程的一次性聚集点时,请使用 std::latch,例如:等待多个初始化完成后再继续。当需要在任务的多个阶段或迭代计算中重复同步线程时,请使用 std::barrier。
54 |
55 | 现在将重写前面的示例,这次使用栅栏,而非门闩。每个线程将将其对应的向量数组元素范围乘以两倍,然后将其相加。此示例中,主线程将使用 join() 等待处理完成,然后将每个线程获得的结果相加。
56 |
57 | 工作线程的代码:
58 |
59 | \begin{cpp}
60 | std::barrier map_barrier{ 3 };
61 | auto worker_thread = [&](std::vector& numbers, int start, int
62 | end, int id) {
63 | std::cout << std::format("Thread {0} is starting...\n", id);
64 |
65 | for (int i = start; i < end; ++i) {
66 | numbers[i] *= 2;
67 | }
68 |
69 | map_barrier.arrive_and_wait();
70 |
71 | for (int i = start; i < end; ++i) {
72 | sum[id] += numbers[i];
73 | }
74 |
75 | map_barrier.arrive();
76 | };
77 | \end{cpp}
78 |
79 | 代码与栅栏同步。当工作线程完成乘法运算时,会减少 map\_barrier 计数器并等待栅栏计数器为零。一旦降为零,线程就会结束等待并开始执行加法。重置栅栏计数器,其值再次等于三。当完成加法运算时,栅栏计数器就会再次减少,但这一次,线程不会等待,因为其任务已经完成。
80 |
81 | 当然——每个线程都可以先完成加法,然后乘以 2。它们不需要互相等待,线程完成的工作都独立于其他线程的工作,但这是一个用简单示例解释栅栏如何工作的好方法。
82 |
83 | 主线程只需使用 join 等待工作线程完成,然后输出结果:
84 |
85 | \begin{cpp}
86 | for (auto& t : workers) {
87 | t.join();
88 | }
89 | std::cout << std::format("The total sum is {0}\n",
90 | std::accumulate(sum.begin(), sum. End(), 0));
91 | \end{cpp}
92 |
93 | 以下是门闩和栅栏示例的完整代码:
94 |
95 | \begin{cpp}
96 | #include
97 | #include
98 | #include
99 | #include
100 | #include
101 | #include
102 | #include
103 | #include
104 |
105 | void multiply_add_latch() {
106 | const int NUM_THREADS{3};
107 |
108 | std::latch map_latch{NUM_THREADS};
109 | std::latch reduce_latch{1};
110 |
111 | std::vector numbers(3000);
112 | int sum{};
113 | std::iota(numbers.begin(), numbers.end(), 0);
114 |
115 | auto map_thread = [&](std::vector& numbers, int start, int
116 | end) {
117 | for (int i = start; i < end; ++i) {
118 | numbers[i] *= 2;
119 | }
120 | map_latch.count_down();
121 | };
122 |
123 | auto reduce_thread = [&](const std::vector& numbers, int&
124 | sum) {
125 | map_latch.wait();
126 |
127 | sum = std::accumulate(numbers.begin(), numbers.end(), 0);
128 |
129 | reduce_latch.count_down();
130 | };
131 |
132 | for (int i = 0; i < NUM_THREADS; ++i) {
133 | std::jthread t(map_thread, std::ref(numbers), 1000 * i, 1000 *
134 | (i + 1));
135 | }
136 |
137 | std::jthread t(reduce_thread, numbers, std::ref(sum));
138 |
139 | reduce_latch.wait();
140 |
141 | std::cout << "All threads finished. The total sum is: " << sum << '\n';
142 | }
143 |
144 | void multiply_add_barrier() {
145 | const int NUM_THREADS{3};
146 |
147 | std::vector sum(3, 0);
148 | std::vector numbers(3000);
149 | std::iota(numbers.begin(), numbers.end(), 0);
150 |
151 | std::barrier map_barrier{NUM_THREADS};
152 |
153 | auto worker_thread = [&](std::vector& numbers, int start, int
154 | end, int id) {
155 | std::cout << std::format("Thread {0} is starting...\n", id);
156 |
157 | for (int i = start; i < end; ++i) {
158 | numbers[i] *= 2;
159 | }
160 |
161 | map_barrier.arrive_and_wait();
162 |
163 | for (int i = start; i < end; ++i) {
164 | sum[id] += numbers[i];
165 | }
166 |
167 | map_barrier.arrive();
168 | };
169 |
170 | std::vector workers;
171 | for (int i = 0; i < NUM_THREADS; ++i) {
172 | workers.emplace_back(worker_thread, std::ref(numbers), 1000 *
173 | i, 1000 * (i + 1), i);
174 | }
175 |
176 | for (auto& t : workers) {
177 | t.join();
178 | }
179 |
180 | std::cout << std::format("All threads finished. The total sum is: {0}\n",
181 | std::accumulate(sum.begin(), sum.end(), 0));
182 | }
183 |
184 | int main() {
185 | std::cout << "Multiplying and reducing vector using barriers..." << std::endl;
186 | multiply_add_barrier();
187 |
188 | std::cout << "Multiplying and reducing vector using latches..." << std::endl;
189 | multiply_add_latch();
190 | return 0;
191 | }
192 | \end{cpp}
193 |
194 | 本节中,介绍了栅栏和门闩。虽然不像互斥锁、条件变量和信号量那样常用,但了解一下总是好的。这里介绍的简单示例说明了栅栏和门闩的常见用途:同步在不同阶段执行处理的线程。
195 |
196 | 最后,将看到一种只执行一次代码的机制,该代码会在不同的线程中多次调用。
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
--------------------------------------------------------------------------------
/book/content/part2/chapter4/9.tex:
--------------------------------------------------------------------------------
1 | 有时,只需要执行一次某项任务。例如,多线程应用程序中,多个线程可能会运行相同的函数来初始化变量。正在运行的线程都可以执行此操作,但希望初始化只执行一次。
2 |
3 | C++ 标准库提供了 std::once\_flag 和 std::call\_once 来实现该功能。
4 |
5 | 下面的例子将有助于理解如何使用 std::once\_flag 和 std::call\_once,来实现当多个线程尝试执行某项任务时只执行一次的目标:
6 |
7 | \begin{cpp}
8 | #include
9 | #include
10 | #include
11 | #include
12 |
13 | int main() {
14 | std::once_flag run_once_flag;
15 | std::once_flag run_once_exceptions_flag;
16 |
17 | auto thread_function = [&] {
18 | std::call_once(run_once_flag, []{
19 | std::cout << "This must run just once\n";
20 | });
21 | };
22 |
23 | std::jthread t1(thread_function);
24 | std::jthread t2(thread_function);
25 | std::jthread t3(thread_function);
26 |
27 | auto function_throws = [&](bool throw_exception) {
28 | if (throw_exception) {
29 | std::cout << "Throwing exception\n";
30 | throw std::runtime_error("runtime error");
31 | }
32 | std::cout << "No exception was thrown\n";
33 | };
34 |
35 | auto thread_function_1 = [&](bool throw_exception) {
36 | try {
37 | std::call_once(run_once_exceptions_flag,
38 | function_throws,
39 | throw_exception);
40 | }
41 | catch (...) {
42 | }
43 | };
44 |
45 | std::jthread t4(thread_function_1, true);
46 | std::jthread t5(thread_function_1, true);
47 | std::jthread t6(thread_function_1, false);
48 |
49 | return 0;
50 | }
51 | \end{cpp}
52 |
53 | 示例的第一部分中,三个线程 t1、 t2 和 t3 运行 thread\_function 函数。此函数从 std::call\_once 调用 lambda。如果运行示例,将看到消息 "This must run just once" 仅输出一次,正如预期的那样。
54 |
55 | 示例的第二部分中,三个线程 t4、 t5 和 t6 运行 thread\_function\_1 函数。此函数调用 function\_throws,根据参数的不同,可能会抛出或不抛出异常。此代码表明,从 std:: call\_once 调用的函数未成功终止,则不算完成,应再次调用 std::call\_once,只有成功的函数才算运行函数。
56 |
57 | 最后一节展示了一个简单的机制,可以使用它来确保一个函数只执行一次,即使在多个线程中调用多次。
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/book/content/part2/chapter5/0.tex:
--------------------------------------------------------------------------------
1 | 第 4 章中,介绍了基于锁的线程同步,了解了互斥锁、条件变量和其他线程同步原语,它们都基于获取和释放锁。这些同步机制建立在本章主题原子类型和操作之上。
2 |
3 | 接下来,将介绍原子操作是什么,以及它们与基于锁的同步原语有何不同。读完本章后,将对原子操作及其一些应用有基本的了解。基于原子操作的无锁(不使用锁)同步是一个非常复杂的主题,但希望为读者们提供有关该主题的入门介绍。
4 |
5 | 本章中,将讨论以下主要主题:
6 |
7 | \begin{itemize}
8 | \item
9 | 什么是原子操作?
10 |
11 | \item
12 | C++ 内存模型简介
13 |
14 | \item
15 | C++ 标准库提供了哪些原子类型和操作?
16 |
17 | \item
18 | 原子操作的示例,从用于收集统计数据的简单计数器和基本互斥锁到完整的单生产者单消费者 (SPSC) 无锁有界队列
19 | \end{itemize}
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/book/content/part2/chapter5/1.tex:
--------------------------------------------------------------------------------
1 | 需要一个支持 C++20 的最新 C++ 编译器,一些简短的代码示例将作为非常有用的 godbolt 网站 (\url{https://godbolt.org}) 的链接提供。对于完整的代码示例,将使用本书的代码库,该存储库位于 \url{https://github.com/PacktPublishing/Asynchronous-Programming-with-CPP}。
2 |
3 | 这些示例可以在本地编译和运行。我们在运行 Linux(Ubuntu 24.04 LTS)的 Intel CPU 计算机上测试了代码。对于原子操作,尤其是内存排序(本章后面将详细介绍), Intel CPU 与Arm CPU 有所不同。
4 |
5 | 注意,代码性能和分析将是第 13 章的主题,所以在本章中对性能做一些简单的评论。
6 |
7 |
--------------------------------------------------------------------------------
/book/content/part2/chapter5/2.tex:
--------------------------------------------------------------------------------
1 |
2 | 原子操作是不可分割的(“原子”这个词源自希腊语ἄτομος, atomos,不可分割)。在本节中,将介绍原子操作、其是什么,以及使用(和不使用)原子操作的原因。
3 |
4 | \mySubsubsection{5.2.1.}{原子操作与非原子操作——示例}
5 |
6 | 如果还记得第 4 章中的简单计数器示例,其中需要使用同步机制(我们使用了互斥锁) 来从不同线程修改计数器变量以避免条件竞争。条件竞争的原因是增加计数器需要三个操作:读取计数器值、增加计数器值,以及将修改后的计数器值写回内存。如果能一次性完成这些操作,就不会出现条件竞争。
7 |
8 | 这正是原子操作可以实现的:如果有某种原子增量操作,每个线程都可以在一条指令中读取、增量和写入计数器,从而避免条件竞争,那么计数器增量操作都会完全完成。所谓完全完成,是每个线程要么增加计数器,要么什么都不做,这样就不可能在计数器增量操作的中间中断。
9 |
10 | 以下两个示例仅用于说明目的,并非多线程。在此仅关注操作,无论是原子操作还是非原子操作。
11 |
12 | 有关以下示例中显示的 C++ 代码和生成的汇编语言,请参阅\url{https://godbolt.org/z/f4dTacsKW}:
13 |
14 | \begin{cpp}
15 | int counter {0};
16 | int main() {
17 | counter++;
18 | return 0;
19 | }
20 | \end{cpp}
21 |
22 | 代码增加了一个全局计数器。现在让我们看看编译器生成的汇编代码以及 CPU 执行了哪些指令(完整汇编代码可以在前面的链接中找到):
23 |
24 | \begin{cpp}
25 | Mov eax, DWORD PTR counter[rip] // [1]
26 | Add eax, 1 // [2]
27 | Move DWORD PTR counter[rip], eax // [3]
28 | \end{cpp}
29 |
30 | [1] 将存储在 counter 中的值复制到 eax 寄存器, [2] 将存储在 eax 中的值加 1,最后, [3] 将eax 寄存器的内容复制回 counter 变量。因此,线程可以执行 [1],然后调度出去,然后另一个线程执行所有三个指令。当第一个线程完成结果递增时,计数器将仅递增一次,因此结果不正确。
31 |
32 | 以下代码的作用相同:增加一个全局计数器。不过,这次使用了原子类型和操作。要获取以下示例中的代码和生成的程序集,请参阅 \url{https://godbolt.org/z/9hrbo31vx}:
33 |
34 | \begin{cpp}
35 | #include
36 | std::atomic counter {0};
37 | int main() {
38 | counter++;
39 | return 0;
40 | }
41 | \end{cpp}
42 |
43 | 稍后会解释 std::atomic 类型和原子增量操作。生成的汇编代码如下:
44 |
45 | \begin{cpp}
46 | lock add DWORD PTR counter[rip], 1
47 | \end{cpp}
48 |
49 | 只生成了一条指令,用于将存储在计数器变量中的值加 1。此处的 lock 前缀表示以下指令(本例中为 add)将以原子方式执行。第二个示例中,线程在增加计数器的过程中不能中断。顺便提一下,一些 Intel x64 指令以原子方式执行,不使用 lock 前缀。
50 |
51 | 原子操作允许线程不可分割地读取、修改(例如,增加一个值)和写入,也可以用作同步原语(类似于我们在第 4 章中看到的互斥锁)。目前为止,本书中看到的所有基于锁的同步原语都是使用原子操作实现的,原子操作必须由 CPU 提供(如 lock add 指令)。
52 |
53 | 本节中,介绍了原子操作,定义了原子操作,并通过查看编译器生成的汇编指令研究了一个非常简单的原子操作实现示例。在下一节中,将介绍原子操作的一些优点和缺点。
54 |
55 | \mySubsubsection{5.2.2.}{何时使用(何时不使用)原子操作}
56 |
57 | 使用原子操作是一个复杂的主题,需要大量的经验,我们参加了一些关于这个主题的课程,有人建议我们不要这样做!无论如何,总是可以学习基础知识并在学习过程中进行实验。希望这本书能帮助各位在学习之旅中取得进步。
58 |
59 | 原子操作可以在以下情况下使用:
60 |
61 | \begin{itemize}
62 | \item
63 | 如果多个线程共享一个可变状态:需要同步线程是最常见的情况。当然,可以使用互斥锁之类的锁,但有时原子操作会提供更好的性能,但使用原子操作并不能保证更好的性能。
64 |
65 | \item
66 | 如果对共享状态的同步访问是细粒度的:如果必须同步的数据是整数、指针或其他 C++ 内在类型的变量,使用原子操作可能比使用锁更好。
67 |
68 | \item
69 | 提高性能:如果想要实现最大性能,原子操作可以帮助减少线程上下文切换(参见第 2 章)并减少锁带来的开销,从而降低延迟。请记住,分析代码以确保性能得到改善( 将在第 13 章中深入了解这一点)。
70 | \end{itemize}
71 |
72 | 锁可以在下列情况下使用:
73 |
74 | \begin{itemize}
75 | \item
76 | 如果受保护的数据不是细粒度的:例如,正在同步对大于 8 个字节的数据结构或对象的访问(现代 CPU 中)。
77 |
78 | \item
79 | 如果性能不是问题:锁的使用和推理就简单得多(某些情况下,使用锁比使用原子操作具有更好的性能)。
80 |
81 | \item
82 | 避免获取底层知识:要从原子操作中获得最大性能,需要大量的底层知识。我们将在 C++ 内存模型部分介绍其中的一些内容。
83 | \end{itemize}
84 |
85 | 刚刚介绍了何时使用和何时不使用原子操作,某些应用程序(例如低延迟/高频交易系统)需要最大性能并使用原子操作来实现尽可能低的延迟。大多数应用程序都可以很好地与锁同步。
86 |
87 | 下一节将研究阻塞和非阻塞数据结构的区别,以及一些相关的概念定义。
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
--------------------------------------------------------------------------------
/book/content/part2/chapter5/3.tex:
--------------------------------------------------------------------------------
1 | 第 4 章中,介绍了同步队列的实现,并使用互斥锁和条件变量作为同步原语。因为线程被(操作系统)阻塞,所以与锁同步的数据结构称为阻塞数据结构。
2 |
3 | 不使用锁的数据结构称为非阻塞数据结构,大多数(但不是全部)非阻塞数据结构都是无锁的。
4 |
5 | 如果每个同步操作在有限的步骤内完成,而不允许无限期地等待条件变为真或假,则数据结构或算法可认为是无锁的。
6 |
7 | 无锁数据结构的类型如下:
8 |
9 | \begin{itemize}
10 | \item
11 | 无阻塞:如果所有其他线程都暂停,则线程将在有限的步骤内完成其操作
12 |
13 | \item
14 | 无锁:当多个线程同时处理数据结构时,一个线程将在有限的步骤内完成其操作
15 |
16 | \item
17 | 无等待:当多个线程同时处理数据结构时,所有线程将在有限的步骤内完成其操作
18 | \end{itemize}
19 |
20 | 实现无锁数据结构非常复杂,需要确保它是必要的。
21 |
22 | 使用无锁数据结构的原因如下:
23 |
24 | \begin{itemize}
25 | \item
26 | 实现最大并发性:如前所述,当数据访问同步涉及细粒度数据(如本机类型变量)时,原子操作是一个不错的选择。根据前面的定义,无锁数据结构允许至少一个访问数据结构的线程,在有限数量的步骤中取得一些进展。无等待结构将允许所有访问数据结构的线程取得一些进展。但当我们使用锁时,一个线程拥有锁,而其余线程只是等待锁可用,使用无锁数据结构可以实现的并发性会好得多。
27 |
28 | \item
29 | 无死锁:不涉及锁,所以代码中不可能出现死锁。
30 |
31 | \item
32 | 性能:某些应用程序必须实现尽可能低的延迟,因此等待锁定是不可接受的。当线程尝试获取锁定但无法获取时,操作系统会阻止该线程。当线程阻塞时,调度程序会进行上下文切换,以便能够调度另一个线程执行。这些上下文切换需要时间,而对于低延迟应用程序(例如:高性能网络数据包接收器/处理器),这段时间可能太多了。
33 | \end{itemize}
34 |
35 | 已经介绍了什么是阻塞和非阻塞数据结构,以及什么是无锁代码。我们将在下一节介绍 C++ 内存模型。
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/book/content/part2/chapter5/6.tex:
--------------------------------------------------------------------------------
1 |
2 | 已经了解了 C++ 标准库的原子特性,例如:原子类型和操作以及内存模型和排序。现在,将看到一个使用原子实现 SPSC 无锁队列的完整示例。
3 |
4 | 这个队列的主要特点如下:
5 |
6 | \begin{itemize}
7 | \item
8 | SPSC:此队列设计为使用两个线程工作,一个线程将元素推送到队列,另一个线程从队列中获取元素。
9 |
10 | \item
11 | 有界:此队列具有固定大小。需要一种方法来检查队列何时达到其容量,以及何时没有元素。
12 |
13 | \item
14 | 无锁:此队列使用在现代 Intel x64 CPU 上始终无锁的原子类型。
15 | \end{itemize}
16 |
17 | 开始实现队列之前,请记住无锁与无等待不同(还请记住无等待并不能完全消除等待;只是确保每个队列推送/弹出所需的步骤数有限制)。本章中,将构建一个正确且性能良好的 SPSC 无锁队列——将在后面展示如何提高其性能。
18 |
19 | 第 4 章中使用互斥锁和条件变量创建了一个 SPSC 队列,消费者线程和生产者线程可以安全访问该队列。本章将使用原子操作来实现相同的目标。我们将使用相同的数据结构 std::vector 来存储队列中的项目,其大小固定,即 2 的幂。
20 |
21 | 这样,可以提高性能并快速找到下一个头和尾索引,而无需使用需要除法指令的模数运算符。当使用无锁原子类型来获得更好的性能时,需要注意影响性能的一切。
22 |
23 | \mySubsubsection{5.6.1.}{为什么使用2的幂的缓冲区大小?}
24 |
25 | 将使用一个向量数组来保存队列项。该向量数组将具有固定大小,例如 N。使向量数组的行为类似于环形缓冲区,所以访问向量数组中元素的索引将在结束后循环回到开头。第一个元素将跟在最后一个元素后面。正如在第 4 章中所了解到的,可以使用模运算符来做到这一点:
26 |
27 | \begin{cpp}
28 | size_t next_index = (curr_index + 1) % N;
29 | \end{cpp}
30 |
31 | 例如,大小为 4 个元素,则下一个元素的索引将按照上述代码计算。对于最后一个索引,有以下代码:
32 |
33 | \begin{cpp}
34 | next_index = (3 + 1) % 4 = 4 % 4 = 0;
35 | \end{cpp}
36 |
37 | 正如我们所说,向量数组将是一个环形缓冲区,因为在最后一个元素之后,将返回到第一个元素,然后是第二个元素,依此类推。
38 |
39 | 可以使用此方法获取任何缓冲区大小 N 的下一个索引。但为什么只使用 2 的幂的大小?答案很简单:性能。模数 (\%) 运算符需要除法指令,这很昂贵。当大小 N 是 2 的幂时,可以执行以下操作:
40 |
41 | \begin{cpp}
42 | size_t next_index = curr_index & (N - 1);
43 | \end{cpp}
44 |
45 | 这比使用模运算符要快得多。
46 |
47 | \mySubsubsection{5.6.2.}{缓冲区访问同步}
48 |
49 | 要访问队列缓冲区,我们需要两个索引:
50 |
51 | \begin{itemize}
52 | \item
53 | head:当前要读取的元素的索引
54 |
55 | \item
56 | tail:下一个要写入的元素的索引
57 | \end{itemize}
58 |
59 | 消费者线程将使用头部索引进行读写,生产者线程将使用尾部索引进行读写。需要同步对这些变量的访问,因为:
60 |
61 | \begin{itemize}
62 | \item
63 | 只有一个线程(消费者)写入 head,因为它始终看到自己的更改,所以可以以宽松的内存顺序读取。读取 tail 由读取器线程完成,它需要与生产者对 tail 的写入同步,因此需要获取内存顺序。可以使用顺序一致性,但由想要最好的性能。当消费者线程写入 head 时,需要与生产者对它的读取同步,因此需要释放内存顺序。
64 |
65 | \item
66 | 对于 tail,只有生产者线程会写入它,可以使用宽松的内存顺序来读取,但需要释放内存顺序来写入,并将其与消费者线程的读取同步。为了与消费者线程的写入同步,需要获取内存顺序来读取 head。
67 | \end{itemize}
68 |
69 | 队列类的成员变量如下:
70 |
71 | \begin{cpp}
72 | const std::size_t capacity_; // power of two buffer size
73 | std::vector buffer_; // buffer to store queue items handled like a
74 | ring buffer
75 | std::atomic head_{ 0 };
76 | std::atomic tail_{ 0 };
77 | \end{cpp}
78 |
79 | 本节中,介绍了如何同步对队列缓冲区的访问。
80 |
81 | \mySubsubsection{5.6.3.}{将元素推送到队列}
82 |
83 | 决定了队列的数据表示以及如何同步对其元素的访问,现在来实现将元素推送到队列的函数:
84 |
85 | \begin{cpp}
86 | bool push(const T& item) {
87 | std::size_t tail =
88 | tail_.load(std::memory_order_relaxed); // [1]
89 |
90 | std::size_t next_tail =
91 | (tail + 1) & (capacity_ - 1); // [2]
92 |
93 | if (next_tail != head_.load(std::memory_order_acquire)) { // [3]
94 | buffer_[tail] = item; // [4]
95 | tail_.store(next_tail, std::memory_order_release); // [5]
96 | return true;
97 | }
98 |
99 | return false;
100 | }
101 | \end{cpp}
102 |
103 | 当前尾部索引,即数据项(如果可能)被推送到队列的缓冲区槽,在行 [1] 中原子读取。
104 | 如前所述,此读取可以使用 std::memory\_order\_relaxed,因为只有生产者线程会更改此变量,并且它是唯一调用推送的线程。
105 |
106 | 行 [2] 计算下一个索引模容量(记住缓冲区是一个环),需要这样做来检查队列是否已满。
107 |
108 | 行 [3] 中执行检查,首先使用 std::memory\_order\_acquire 原子地读取 head 的当前值,希望生产者线程观察消费者线程对此变量所做的修改。然后,将其值与下一个 head 索引进行比较。
109 |
110 | 如果下一个尾部值等于当前头值,那么(按照我们的惯例)队列已满,返回 false。如果队列未满,行 [4] 将数据项复制到队列缓冲区。这里值得一提的是,数据复制不是原子的。行 [5] 原子地将新的尾部索引值写入 tail\_。然后,使用 std::memory\_order\_release 使更改对使用 std::memory\_order\_acquire 原子地读取此变量的消费者线程可见。
111 |
112 | \mySubsubsection{5.6.4.}{从队列中弹出元素}
113 |
114 | 现在,看看pop函数如何实现:
115 |
116 | \begin{cpp}
117 | bool pop(T& item) {
118 | std::size_t head =
119 | head_.load(std::memory_order_relaxed); // [1]
120 |
121 | if (head == tail_.load(std::memory_order_acquire)) { // [2]
122 | return false;
123 | }
124 |
125 | item = buffer_[head]; // [3]
126 |
127 | head_.store((head + 1) & (capacity_ - 1), std::memory_order_release); // [4]
128 |
129 | return true;
130 | }
131 | \end{cpp}
132 |
133 | 行 [1] 原子地读取 head\_ 的当前值(要读取的下一个项的索引),使用 std::memory\_order\_relaxed,因为 head\_ 变量仅由消费者线程修改,因此不需要强制执行顺序,而消费者线程是唯一调用 pop 的线程。
134 |
135 | 行 [2] 检查队列是否为空。如果 head\_ 的当前值与 tail\_ 的当前值相同,则队列为空,函数返回 false。使用 std::memory\_order\_acquire 原子读取 tail\_ 的值,以查看生产者线程对 tail\_ 所做的最新更改。
136 |
137 | 行 [3] 将数据从队列复制到作为 pop 参数传递的项目引用,此复制并非原子操作。
138 |
139 | 最后,行 [4] 更新 head\_ 的值,使用 std::memory\_order\_release 原子地写入值,以便消费者线程查看消费者线程对 head\_ 所做的更改。
140 |
141 | SPSC无锁队列实现的代码如下
142 |
143 | \begin{cpp}
144 | #include
145 | #include
146 | #include
147 | #include
148 | #include
149 |
150 | template
151 | class spsc_lock_free_queue {
152 | public:
153 | // capacity must be power of two to avoid using modulo operator
154 | when calculating the index
155 | explicit spsc_lock_free_queue(size_t capacity) : capacity_(capacity), buffer_(capacity) {
156 | assert((capacity & (capacity - 1)) == 0 && "capacity must be a
157 | power of 2");
158 | }
159 |
160 | spsc_lock_free_queue(const spsc_lock_free_queue &) = delete;
161 |
162 | spsc_lock_free_queue &operator=(const spsc_lock_free_queue &) = delete;
163 |
164 | bool push(const T &item) {
165 | std::size_t tail = tail_.load(std::memory_order_relaxed);
166 | std::size_t next_tail = (tail + 1) & (capacity_ - 1);
167 | if (next_tail != head_.load(std::memory_order_acquire)) {
168 | buffer_[tail] = item;
169 | tail_.store(next_tail, std::memory_order_release);
170 | return true;
171 | }
172 |
173 | return false;
174 | }
175 |
176 | bool pop(T &item) {
177 | std::size_t head = head_.load(std::memory_order_relaxed);
178 | if (head == tail_.load(std::memory_order_acquire)) {
179 | return false;
180 | }
181 |
182 | item = buffer_[head];
183 | head_.store((head + 1) & (capacity_ - 1), std::memory_order_release);
184 | return true;
185 | }
186 | private:
187 | const std::size_t capacity_;
188 | std::vector buffer_;
189 | std::atomic head_{0};
190 | std::atomic tail_{0};
191 | };
192 | \end{cpp}
193 |
194 | 完整示例的代码可以在以下书籍存储库中找到: \url{https://github.com/PacktPublishing/Asynchronous-Programming-in-CPP/blob/main/Chapter_05/5x09-SPSC_lock_free_queue.cpp}
195 |
196 | 本节中,实现了 SPSC 无锁队列作为原子类型和操作的应用。第 13 章中,将重新讨论此实现并改进其性能。
197 |
198 |
199 |
200 |
201 |
--------------------------------------------------------------------------------
/book/content/part2/chapter5/7.tex:
--------------------------------------------------------------------------------
1 | 本章介绍了原子类型和操作、 C++内存模型以及 SPSC 无锁队列的基本实现。
2 |
3 | 以下是我们所研究内容的总结:
4 |
5 | \begin{itemize}
6 | \item
7 | C++ 标准库原子类型和操作、它们是什么,以及如何通过一些示例展示如何使用。
8 |
9 | \item
10 | C++ 内存模型,尤其是它定义的不同内存顺序。请记住,这是一个非常复杂的主题,本节只是对它的基本介绍。
11 |
12 | \item
13 | 如何实现基本的 SPSC 无锁队列。如前所述,我们将在第 13 章中演示如何提高其性能。性能改进操作的示例包括消除错误共享(当两个变量位于同一缓存行中并且每个变量仅由一个线程修改时会发生这种情况)和减少真共享。如果现在不了解其中何内容,请不要担心,我们将在稍后介绍它并演示如何运行性能测试。
14 | \end{itemize}
15 |
16 | 这是对原子操作的基本介绍,用于同步来自不同线程的内存访问。某些情况下,原子操作的使用相当容易,类似于收集统计数据和简单的计数器。更复杂的应用程序(例如:SPSC 无锁队列的实现),需要对原子操作有更深入的了解。本章中看到的内容有助于理解基础知识,并为进一步研究这个复杂的主题奠定基础。
17 |
18 | 下一章中,将介绍 C++ 中异步编程的两个基本构建块,即 promise 和 future。
--------------------------------------------------------------------------------
/book/content/part2/chapter5/8.tex:
--------------------------------------------------------------------------------
1 | \begin{itemize}
2 | \item
3 | {[}Butenhof, 1997] David R. Butenhof, Programming with POSIX Threads, Addison Wesley, 1997.
4 |
5 | \item
6 | {[}Williams, 2019] Anthony Williams, C++ Concurrency in Action, Second Edition, Manning, 2019.
7 |
8 | \item
9 | Memory Model: Get Your Shared Data Under Control, Jana Machutová, \url{https://www.youtube.com/watch?v=L5RCGDAan2Y}.
10 |
11 | \item
12 | C++ Atomics: From Basic To Advanced, Fedor Pikus, \url{https://www.youtube.com/ watch?v=ZQFzMfHIxng}.
13 |
14 | \item
15 | Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3A: System Programming Guide, Part 1, Intel Corporation, \url{https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-softwaredeveloper-vol-3a-part-1-manual.pdf}.
16 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part2/part.tex:
--------------------------------------------------------------------------------
1 | 第二部分中,将以并行编程的基础知识为基础,深入研究管理线程和同步并发操作的高级技术。探索线程创建和管理、跨线程异常处理和高效线程协调等基本概念,深入了解关键同步原语,包括互斥锁、信号量、条件变量和原子操作。所有这些知识将提供实现基于锁和无锁的多线程解决方案所需的工具,一窥高性能并发系统,并提供管理多线程系统时避免常见陷阱(如竞争条件、死锁和活锁)所需的技能。
2 |
3 | 本部分包含以下章节:
4 |
5 | \begin{itemize}
6 | \item
7 | 第 3 章,如何在 C++ 中创建和管理线程
8 |
9 | \item
10 | 第 4 章,使用锁进行线程同步
11 |
12 | \item
13 | 第 5 章,原子操作
14 | \end{itemize}
15 |
--------------------------------------------------------------------------------
/book/content/part3/chapter6/0.tex:
--------------------------------------------------------------------------------
1 | 前面的章节中,介绍了使用 C++ 管理和同步线程执行的基础知识。还在第 3 章中提到,要从线程返回值,可以使用Future和Promise。现在,是时候学习如何在 C++ 中使用这些功能来做到这些以及做更多事情了。
2 |
3 | Future 和 Promise 是实现异步编程必不可少的块,其定义了一种管理将来完成的任务结果的方法,通常在单独的线程中完成。
4 |
5 | 本章中,将讨论以下主要主题:
6 |
7 | \begin{itemize}
8 | \item
9 | 什么是Future和Promise?
10 |
11 | \item
12 | 什么是共享Future?与普通Future有何不同?
13 |
14 | \item
15 | 什么是打包任务以及何时使用?
16 |
17 | \item
18 | 如何检查未来的状态和错误?
19 |
20 | \item
21 | 使用Future和Promise有哪些优点和缺点?
22 |
23 | \item
24 | 现实场景和解决方案的示例
25 | \end{itemize}
26 |
27 | 那么,让开始吧!
--------------------------------------------------------------------------------
/book/content/part3/chapter6/1.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | 自 C++11 以来,Promise和Future就已经可用,但本章中实现的一些示例使用了 C++20 中的功能,例如 std::jthread,因此本章中显示的代码可以由支持 C++20 的编译器进行编译。
4 |
5 | 请查看第 3 章中的技术要求部分,以获取有关如何安装 GCC 13 和 Clang 8 编译器的指导。
6 |
7 | 可以在以下 GitHub 库中找到所有完整的代码 理论: \url{https://github.com/PacktPublishing/Asynchronous-Programming-with-CPP}
8 |
9 | 本章的示例位于 Chapter\_06 文件夹下。所有源代码文件都可以使用 CMake 进行编译:
10 |
11 | \begin{shell}
12 | cmake . && cmake build .
13 | \end{shell}
14 |
15 | 可执行二进制文件将在 bin 目录下生成。
16 |
17 |
--------------------------------------------------------------------------------
/book/content/part3/chapter6/3.tex:
--------------------------------------------------------------------------------
1 | 使用 Promise 和 Future 既有优点,也有缺点:
2 |
3 | \mySubsubsection{6.3.1.}{优点}
4 |
5 | 作为管理异步操作的高级抽象,使用Promise和Future来编写和推理并发代码变得更加简单且不容易出错。
6 |
7 | Future 和 Promise 支持并发执行任务,让程序能够高效使用多个 CPU 核心。这可以提高计算密集型任务的性能并缩短执行时间。
8 |
9 | 此外,通过将操作的启动与完成分离来促进异步编程。正如稍后将看到的,这对于 I/O 密集型任务(例如:网络请求或文件操作)特别有用。在这些任务中,程序可以在等待异步操作完成的同时继续执行其他任务。因此,可以返回一个值,也可以返回一个异常,从而允许异常从异步任务传播到等待其完成的调用者代码部分,从而为错误处理和恢复提供了一种更清晰的方式。
10 |
11 | 它们还提供了一种同步任务完成和检索其结果的机制,这有助于协调并行任务并管理它们之间的依赖关系。
12 |
13 | \mySubsubsection{6.3.2.}{缺点}
14 |
15 | 不幸的是,并非所有都是好消息。
16 |
17 | 例如,使用 Future 和 Promise 进行异步编程可能会对处理任务之间的依赖关系,或管理异步操作的生命周期时增加复杂性。此外,如果存在循环依赖关系,则可能会发生死锁。
18 |
19 | 同样,使用Future 和 Promise可能会带来一些性能开销,因为在幕后发生的同步机制涉及协调异步任务和管理共享状态。
20 |
21 | 与其他并发或异步解决方案一样,使用 Future 和 Promise 的代码调试与同步代码相比更具挑战性,执行流程可能是非线性的并且涉及多个线程。
22 |
23 | 现在是时候通过一些示例来解决现实问题了。
--------------------------------------------------------------------------------
/book/content/part3/chapter6/5.tex:
--------------------------------------------------------------------------------
1 | 本章中,介绍了 Promise 和 Future,如何使用它们在单独的线程中执行异步代码,以及如何使用打包的任务运行可调用函数。这些对象和机制构成并实现了许多编程语言(包括 C++)使用的异步编程的关键概念。
2 |
3 | 还了解了为什么Promise、Future和打包任务不能复制,以及如何通过使用共享Future对象来共享Future。
4 |
5 | 最后,展示了如何使用Future、Promise和打包任务来解决实际问题。
6 |
7 | 如果想更深入地探索 Promise 和 Future,值得一提的是一些第三方开源库,尤其是 Boost.Thread 和 Facebook Folly。这些库包含很多功能,包括回调、执行器和组合器。
8 |
9 | 下一章中,将学习一种使用 std::async 异步调用可调用函数的更简单的方法。
10 |
11 |
12 |
--------------------------------------------------------------------------------
/book/content/part3/chapter6/6.tex:
--------------------------------------------------------------------------------
1 |
2 | \begin{itemize}
3 | \item
4 | Boost futures and promises: \url{https://theboostcpplibraries.com/boost.thread-futures-and-promises}
5 |
6 | \item
7 | Facebook Folly open source library: \url{https://github.com/facebook/folly}
8 |
9 | \item
10 | Futures for C++11 at Facebook: \url{https://engineering.fb.com/2015/06/19/developer-tools/futuresfor-c-11-at-facebook}
11 |
12 | \item
13 | ‘Futures and Promises’——Instagram 如何利用它来提高资源利用率: \url{https://scaleyourapp.com/futures-and-promises-and-how-instagram- leverages-it/}
14 |
15 | \item
16 | SeaStar:面向高性能服务器应用程序的开源 C++ 框架: \url{https://seastar.io}
17 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part3/chapter6/images/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part3/chapter6/images/1.png
--------------------------------------------------------------------------------
/book/content/part3/chapter6/images/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part3/chapter6/images/2.png
--------------------------------------------------------------------------------
/book/content/part3/chapter6/images/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part3/chapter6/images/3.png
--------------------------------------------------------------------------------
/book/content/part3/chapter7/0.tex:
--------------------------------------------------------------------------------
1 | 上一章中,介绍了Promise、 Future和打包任务。在介绍打包任务时,提到std::async提供了一种更简单的方式来实现同样的结果,代码更少,更干净、更简洁。
2 |
3 | 异步函数 (std::async) 是一个异步运行可调用对象的函数模板,还可以通过传递一些定义启动策略的标志来选择执行方法。它是处理异步操作的强大工具,但其自动管理和对执行线程缺乏控制等,使其不适合某些需要细粒度控制或取消的任务。
4 |
5 | 在本章中,我们将讨论以下主要主题:
6 |
7 | \begin{itemize}
8 | \item
9 | 什么是异步函数以及如何使用?
10 |
11 | \item
12 | 有哪些不同的启动政策?
13 |
14 | \item
15 | 与以前的方法,特别是打包任务有什么不同?
16 |
17 | \item
18 | 使用 std::async 有哪些优点和缺点?
19 |
20 | \item
21 | 实际场景和示例
22 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part3/chapter7/1.tex:
--------------------------------------------------------------------------------
1 | async 函数自 C++11 起可用,但一些示例使用了 C++14 中的功能,例如: chrono\_literals 和 C++20 中的功能(例如 counting\_semaphore),因此本章中展示的代码可以由支持 C++20 的编译器进行编译。
2 |
3 | 请查看第 3 章中的技术要求部分,以获取有关如何安装 GCC 13 和 Clang 8 编译器的指导。
4 |
5 | 可以在以下 GitHub 库中找到所有完整的代码: \url{https://github.com/PacktPublishing/Asynchronous-Programming-with-CPP}
6 |
7 | 本章的示例位于 Chapter\_07 文件夹下。所有源代码文件都可以使用 CMake 进行编译:
8 |
9 | \begin{shell}
10 | cmake . && cmake —build .
11 | \end{shell}
12 |
13 | 可执行二进制文件将在 bin 目录下生成。
--------------------------------------------------------------------------------
/book/content/part3/chapter7/10.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | \begin{itemize}
4 | \item
5 | Effective Modern C++: 42 Specific Ways to Improve Your Use of C++11 and C++14, Scott Meyers, O’Reilly Media, Inc., 1st Edition – Chapter 7, Item 35 and Item 36
6 |
7 | \item
8 | std::async: \url{https://en.cppreference.com/w/cpp/thread/async}
9 |
10 | \item
11 | std::launch: \url{https://en.cppreference.com/w/cpp/thread/launch}
12 |
13 | \item
14 | Strassen algorithm: \url{https://en.wikipedia.org/wiki/Strassen_algorithm}
15 |
16 | \item
17 | Karatsuba algorithm: \url{https://en.wikipedia.org/wiki/Karatsuba_algorithm}
18 |
19 | \item
20 | OpenBLAS: \url{https://www.openblas.net}
21 |
22 | \item
23 | BLIS library: \url{https://github.com/flame/blis}
24 |
25 | \item
26 | MapReduce: \url{https://hadoop.apache.org/docs/r1.2.1/mapred_tutorial.html}
27 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part3/chapter7/2.tex:
--------------------------------------------------------------------------------
1 |
2 | std::async 是 C++ 中的一个函数模板,由 C++ 标准在 标头中引入,作为 C++11 线程支持库的一部分,用于异步运行函数,允许主线程(或其他线程)继续并发运行。
3 |
4 | std::async 是 C++ 中用于异步编程的强大工具,可以更轻松地并行运行任务并有效地管理其结果。
5 |
6 | \mySubsubsection{7.2.1.}{启动异步任务}
7 |
8 | 要使用 std::async 异步执行函数,我们可以使用与第 3 章启动线程时相同的方法,但使用不同的可调用对象。
9 |
10 | 一种方法是使用函数指针:
11 |
12 | \begin{cpp}
13 | void func() {
14 | std::cout << "Using function pointer\n";
15 | }
16 | auto fut1 = std::async(func);
17 | \end{cpp}
18 |
19 | 另一种方法是使用 lambda 函数:
20 |
21 | \begin{cpp}
22 | auto lambda_func = []() {
23 | std::cout << "Using lambda function\n";
24 | };
25 | auto fut2 = std::async(lambda_func);
26 | \end{cpp}
27 |
28 | 还可以使用嵌入的 lambda 函数:
29 |
30 | \begin{cpp}
31 | auto fut3 = std::async([]() {
32 | std::cout << "Using embedded lambda function\n";
33 | });
34 | \end{cpp}
35 |
36 | 可以使用 operator() 重载的函数对象:
37 |
38 | \begin{cpp}
39 | class FuncObjectClass {
40 | public:
41 | void operator()() {
42 | std::cout << "Using function object class\n";
43 | }
44 | };
45 | auto fut4 = std::async(FuncObjectClass());
46 | \end{cpp}
47 |
48 | 可以使用非静态成员函数,通过传递成员函数的地址和对象的地址来调用成员函数:
49 |
50 | \begin{cpp}
51 | class Obj {
52 | public:
53 | void func() {
54 | std::cout << "Using a non-static member function"
55 | << std::endl;
56 | }
57 | };
58 | Obj obj;
59 | auto fut5 = std::async(&Obj::func, &obj);
60 | \end{cpp}
61 |
62 | 还可以使用静态成员函数,由于方法是静态的,只需要成员函数的地址:
63 |
64 | \begin{cpp}
65 | class Obj {
66 | public:
67 | static void static_func() {
68 | std::cout << "Using a static member function"
69 | << std::endl;
70 | }
71 | };
72 | auto fut6 = std::async(&Obj::static_func);
73 | \end{cpp}
74 |
75 | 当调用 std::async 时,会返回一个未来,其中会存储函数的结果。
76 |
77 | \mySubsubsection{7.2.2.}{传递值}
78 |
79 | 同样,与创建线程时传递参数类似,参数可以通过值、引用或指针传递给线程。
80 |
81 | 这里,可以看到如何通过值传递参数:
82 |
83 | \begin{cpp}
84 | void funcByValue(const std::string& str, int val) {
85 | std::cout << "str: " << str << ", val: " << val
86 | << std::endl;
87 | }
88 | std::string str{"Passing by value"};
89 | auto fut1 = async(funcByValue, str, 1);
90 | \end{cpp}
91 |
92 | 按值传递表明可创建一个临时对象并将参数值复制到其中。这可以避免数据竞争,但成本更高。
93 |
94 | 下一个示例显示如何通过引用传递值:
95 |
96 | \begin{cpp}
97 | void modifyValues(std::string& str) {
98 | str += " (Thread)";
99 | }
100 | std::string str{"Passing by reference"};
101 | auto fut2 = std::async(modifyValues, std::ref(str));
102 | \end{cpp}
103 |
104 | 还可以将值作为 const 引用传递:
105 |
106 | \begin{cpp}
107 | void printVector(const std::vector& v) {
108 | std::cout << "Vector: ";
109 | for (int num : v) {
110 | std::cout << num << " ";
111 | }
112 | std::cout << std::endl;
113 | }
114 | std::vector v{1, 2, 3, 4, 5};
115 | auto fut3 = std::async(printVector, std::cref(v));
116 | \end{cpp}
117 |
118 | 引用传递通过使用 std::ref()(非常量引用)或 std::cref()(常量引用)来实现,这两个函数均定义在 头文件中,让定义线程构造函数的可变参数模板(支持任意数量参数的类或函数模板)将参数视为引用。
119 |
120 | 还可以将对象移动到由 std::async 创建的线程中:
121 |
122 | \begin{cpp}
123 | auto fut4 = std::async(printVector, std::move(v));
124 | \end{cpp}
125 |
126 | 注意,向量数组v在移动后,处于有效的空状态。
127 |
128 | 最后,还可以通过 lambda 捕获传递值:
129 |
130 | \begin{cpp}
131 | std::string str5{"Hello"};
132 | auto fut5 = std::async([&]() {
133 | std::cout << "str: " << str5 << std::endl;
134 | });
135 | \end{cpp}
136 |
137 | 此示例中,std::async 执行的 lambda 函数作为引用访问了str 变量。
138 |
139 | \mySubsubsection{7.2.3.}{返回值}
140 |
141 | 当调用 std:async 时,会立即返回一个Future,将保存函数或可调用对象将计算的值。
142 |
143 | 前面的例子中,没有使用 std::async 返回的对象。这里重写第 6 章打包任务部分的最后一个示例,使用 std::packaged\_task 对象来计算两个值的幂。本例中,将使用 std::async 生成几个异步任务来计算这些值,等待任务完成存储结果,最后在控制台中显示:
144 |
145 | \begin{cpp}
146 | #include
147 | #include
148 | #include
149 | #include
150 | #include
151 | #include
152 | #include
153 |
154 | #define sync_cout std::osyncstream(std::cout)
155 |
156 | using namespace std::chrono_literals;
157 |
158 | int compute(unsigned taskId, int x, int y) {
159 | std::this_thread::sleep_for(std::chrono::milliseconds(
160 | rand() % 200));
161 | sync_cout << "Running task " << taskId << '\n';
162 | return std::pow(x, y);
163 | }
164 |
165 | int main() {
166 | std::vector> futVec;
167 | for (int i = 0; i <= 10; i++)
168 | futVec.emplace_back(std::async(compute,
169 | i+1, 2, i));
170 |
171 | sync_cout << "Waiting in main thread\n";
172 | std::this_thread::sleep_for(1s);
173 |
174 | std::vector results;
175 | for (auto& fut : futVec)
176 | results.push_back(fut.get());
177 |
178 | for (auto& res : results)
179 | std::cout << res << ' ';
180 | std::cout << std::endl;
181 | return 0;
182 | }
183 | \end{cpp}
184 |
185 | compute() 函数仅获取两个数字 $x$ 和 $y$,并计算 $x^y$ 。需要获取一个代表任务标识符的数字,并等待最多两秒钟,然后在控制台中打印消息并计算结果。
186 |
187 | 在 main() 函数中,主线程启动几个任务来计算一系列 2 的幂值。调用 std::async 返回的 Future 存储在 futVec 向量中。主线程等待一秒钟后,模拟一些工作。最后,遍历 futVec 并在每个 Future 元素中调用 get() 函数,从而等待该特定任务完成并返回一个值,然后将返回的值存储在另一个名为 results 的向量数组中。然后,在退出程序之前输出 results 向量数组的内容。
188 |
189 | 这是运行该程序时的输出:
190 |
191 | \begin{shell}
192 | Waiting in main thread
193 | Running task 11
194 | Running task 9
195 | Running task 2
196 | Running task 8
197 | Running task 4
198 | Running task 6
199 | Running task 10
200 | Running task 3
201 | Running task 1
202 | Running task 7
203 | Running task 5
204 | 1 2 4 8 16 32 64 128 256 512 1024
205 | \end{shell}
206 |
207 | 每个任务完成所需的时间不同,输出不是按任务标识符排序的。但当按顺序遍历 futVec 向量数组获取结果时,这些结果会按顺序显示。
208 |
209 | 现在,已经了解了如何启动异步任务并传递参数和返回值。接下来,介绍如何使用启动策略来控制执行方法。
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
--------------------------------------------------------------------------------
/book/content/part3/chapter7/3.tex:
--------------------------------------------------------------------------------
1 | 除了在使用 std::async 函数时指定函数或可调用对象作为参数外,还可以指定启动策略。启动策略控制 std::async 如何安排异步任务的执行。这些在 头文件中定义。
2 |
3 | 调用 std::async 时,必须将启动策略指定为第一个参数。此参数的类型为 std::launch,是一个位掩码值,其中的位控制允许的执行方法,可以是以下一个或多个枚举常量:
4 |
5 | \begin{itemize}
6 | \item
7 | std::launch::async:任务在单独的线程中执行。
8 |
9 | \item
10 | std::launch::deferred:通过在首次通过 Future get() 或 wait() 方法请求结果时在调用线程中执行任务来启用惰性求值。对同一 std::future 的所有后续访问都将立即返回结果,所以只有在明确请求结果时才会执行任务,这可能会导致意外延迟。
11 | \end{itemize}
12 |
13 | 如果未定义,则默认启动策略为 std::launch::async | std::launch::deferred。此外,实现可以提供其他启动策略。
14 |
15 | 默认情况下, C++ 标准规定 std::async 可以在异步或延迟模式下运行。
16 |
17 | 当指定多个标志时,行为实现定义,这取决于使用的编译器。如果指定了默认启动策略,标准建议使用可用的并发并推迟任务。
18 |
19 | 实现以下示例来测试不同的启动策略行为。首先,定义 square() 函数用作异步任务:
20 |
21 | \begin{cpp}
22 | #include
23 | #include
24 | #include
25 | #include
26 | #include
27 |
28 | #define sync_cout std::osyncstream(std::cout)
29 |
30 | using namespace std::chrono_literals;
31 |
32 | int square(const std::string& task_name, int x) {
33 | sync_cout << "Launching " << task_name
34 | << " task...\n";
35 | return x * x;
36 | }
37 | \end{cpp}
38 |
39 | main() 函数中,程序通过启动三个不同的异步任务开始,一个使用 std::launch::async 启动策略,另一个使用 std::launch::deferred 启动策略,第三个任务使用默认启动策略:
40 |
41 | \begin{cpp}
42 | sync_cout << "Starting main thread...\n";
43 | auto fut_async = std::async(std::launch::async,
44 | square, "async_policy", 2);
45 | auto fut_deferred = std::async(std::launch::deferred,
46 | square, "deferred_policy", 3);
47 | auto fut_default = std::async(square,
48 | "default_policy", 4);
49 | \end{cpp}
50 |
51 | 如上一章所述, wait\_for() 返回一个 std::future\_status 对象,指示Future是否已准备就绪、已延迟或已超时。因此,可以使用该函数来检查返回的Future是否已延迟。通过 lambda 函数 is\_deferred() 来做到这一点,该函数返回 true。预计至少一个Future对象 fut\_deferred 将返回 true:
52 |
53 | \begin{cpp}
54 | auto is_deferred = [](std::future& fut) {
55 | return (fut.wait_for(0s) ==
56 | std::future_status::deferred);
57 | };
58 |
59 | sync_cout << "Checking if deferred:\n";
60 | sync_cout << " fut_async: " << std::boolalpha
61 | << is_deferred(fut_async) << '\n';
62 | sync_cout << " fut_deferred: " << std::boolalpha
63 | << is_deferred(fut_deferred) << '\n';
64 | sync_cout << " fut_default: " << std::boolalpha
65 | << is_deferred(fut_default) << '\n';
66 | \end{cpp}
67 |
68 | 然后,主程序等待一秒钟,模拟一些处理,最后从异步任务中检索结果并输出其值:
69 |
70 | \begin{cpp}
71 | sync_cout << "Waiting in main thread...\n";
72 | std::this_thread::sleep_for(1s);
73 |
74 | sync_cout << "Wait in main thread finished.\n";
75 | sync_cout << "Getting result from "
76 | << "async policy task...\n";
77 | int val_async = fut_async.get();
78 | sync_cout << "Result from async policy task: "
79 | << val_async << '\n';
80 |
81 | sync_cout << "Getting result from "
82 | << "deferred policy task...\n";
83 | int val_deferred = fut_deferred.get();
84 | sync_cout << "Result from deferred policy task: "
85 | << val_deferred << '\n';
86 | sync_cout << "Getting result from "
87 | << "default policy task...\n";
88 | int val_default = fut_default.get();
89 | sync_cout << "Result from default policy task: "
90 | << val_default << '\n';
91 | \end{cpp}
92 |
93 | 这是运行上述代码的输出:
94 |
95 | \begin{shell}
96 | Starting main thread...
97 | Launching async_policy task...
98 | Launching default_policy task...
99 | Checking if deferred:
100 | fut_async: false
101 | fut_deferred: true
102 | fut_default: false
103 | Waiting in main thread...
104 | Wait in main thread finished.
105 | Getting result from async policy task...
106 | Result from async policy task: 4
107 | Getting result from deferred policy task...
108 | Launching deferred_policy task...
109 | Result from deferred policy task: 9
110 | Getting result from default policy task...
111 | Result from default policy task: 16
112 | \end{shell}
113 |
114 | 注意,使用默认和 std::launch::async 启动策略的任务是在主线程休眠时执行的,任务会在可以安排时立即启动;使用 std::launch::deferred 启动策略的延迟任务在请求值后开始执行。
115 |
116 | 接下来介绍如何处理异步任务中发生的异常。
117 |
--------------------------------------------------------------------------------
/book/content/part3/chapter7/4.tex:
--------------------------------------------------------------------------------
1 |
2 | 使用 std::async 时不支持从异步任务到主线程的异常传播。为了启用异常传播,可能需要一个Promise对象来存储异常,稍后可以通过调用 std::async 返回的 Future 来访问该异常。但std::async 无法访问或提供该Promise对象。
3 |
4 | 实现此目的的一种可行方法是使用 std::packaged\_task 对象包装异步任务,应该直接使用上一章中描述的打包任务。
5 |
6 | 还可以使用自 C++11 起可用的嵌套异常,方法是使用 std::nested\_exception,这是一个可以捕获和存储当前异常的多态混合类,允许任意类型的嵌套异常。从 std::nested\_exception 对象中,可以使用 nested\_ptr() 方法检索存储的异常,或通过调用 rethrow\_nested() 重新抛出。
7 |
8 | 要创建嵌套异常,可以使用 std::throw\_with\_nested() 方法抛出异常。如果只想在异常嵌套时重新抛出异常,可以使用 std::rethrow\_if\_nested()。所有这些函数都在 头文件中定义。
9 |
10 | 使用所有这些函数,可以实现以下示例,其中异步任务抛出 std::runtime\_error 异常,该异常在异步任务的主体中捕获并作为嵌套异常重新抛出。然后,此嵌套异常对象在主函数中再次被捕获,并输出出异常序列:
11 |
12 | \begin{cpp}
13 | #include
14 | #include
15 | #include
16 | #include
17 | #include
18 |
19 | void print_exceptions(const std::exception& e,
20 | int level = 1) {
21 | auto indent = std::string(2 * level, ' ');
22 | std::cerr << indent << e.what() << '\n';
23 | try {
24 | std::rethrow_if_nested(e);
25 | } catch (const std::exception& nestedException) {
26 | print_exceptions(nestedException, level + 1);
27 | } catch (...) { }
28 | }
29 |
30 | void func_throwing() {
31 | throw std::runtime_error(
32 | "Exception in func_throwing");
33 | }
34 |
35 | int main() {
36 | auto fut = std::async([]() {
37 | try {
38 | func_throwing();
39 | } catch (...) {
40 | std::throw_with_nested(
41 | std::runtime_error(
42 | "Exception in async task."));
43 | }
44 | });
45 |
46 | try {
47 | fut.get();
48 | } catch (const std::exception& e) {
49 | std::cerr << "Caught exceptions:\n";
50 | print_exceptions(e);
51 | }
52 | return 0;
53 | }
54 | \end{cpp}
55 |
56 | 正如示例中看到的,创建了一个异步任务,该任务在 try-catch 块内执行 func\_throwing() 函数。此函数仅抛出一个 std::runtime\_error 异常,捕获该异常,然后通过 std::throw\_with\_nested() 函数作为 std::nested\_exception 类的一部分重新抛出。稍后,在主线程中,尝试通过调用其 get() 方法从 fut future 对象中检索结果时,嵌套异常抛出并在主 try-catch 块中再次捕获,其中调用 print\_exceptions(),并使用捕获的嵌套异常作为参数。
57 |
58 | print\_exceptions() 函数打印当前异常(e.what())的原因,如果嵌套则重新抛出异常,再次捕获它并按嵌套级别缩进递归输出异常原因。
59 |
60 | 由于每个异步任务都有自己的Future,程序可以分别处理来自多个任务的异常。
61 |
62 | \mySubsubsection{7.4.1.}{调用 std::async 时发生异常}
63 |
64 | 除了异步任务中发生的异常之外,还存在 std::async 可能抛出异常的情况:
65 |
66 | \begin{itemize}
67 | \item
68 | std::bad\_alloc:如果没有足够的内存来存储 std::async 所需的内部数据结构。
69 |
70 | \item
71 | std:system\_error:如果在使用 std::launch::async 作为启动策略时无法启动新线程,错误条件将是 std::errc::resource\_unavailable\_try\_again。根据实现,如果策略是默认策略,可能会回退到延迟调用或实现定义的策略。
72 | \end{itemize}
73 |
74 | 大多数情况下,这些异常是由于资源耗尽而引发的。一种解决方案是,当当前正在运行的一些异步任务完成并释放其资源后,稍后重试。另一种更可靠的解决方案是限制在给定时间内运行的异步任务数量。
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
--------------------------------------------------------------------------------
/book/content/part3/chapter7/5.tex:
--------------------------------------------------------------------------------
1 | std::async 返回的 Future 在调用其析构函数时的行为与从 Promise 获得的 Future 不同。当这些 Future 销毁时,会调用\~{}future 析构函数,会执行 wait() 函数,使得创建时生成的线程汇入主线程。
2 |
3 | 如果 std::async 使用的线程尚未汇入,则会增加一些开销,从而影响程序性能,因此需要了解Future对象何时超出范围,从而调用其析构函数。
4 |
5 | 通过几个简短的例子来了解这些Future是如何表现的,以及如何使用的一些建议。
6 |
7 | 首先定义一个任务, func只是将其输入值乘以 2,并等待一段时间,模拟一个昂贵的操作:
8 |
9 | \begin{cpp}
10 | #include
11 | #include
12 | #include
13 | #include
14 | #include
15 |
16 | #define sync_cout std::osyncstream(std::cout)
17 |
18 | using namespace std::chrono_literals;
19 |
20 | unsigned func(unsigned x) {
21 | std::this_thread::sleep_for(10ms);
22 | return 2 * x;
23 | }
24 | \end{cpp}
25 |
26 | 为了测量代码块的性能,异步运行多个任务(在此示例中为 NUM\_TASKS = 32),并使用 库中的稳定时钟测量运行时间。为此,只需使用以下命令记录表示任务启动当前时间点的时间点:
27 |
28 | \begin{cpp}
29 | auto start = std::chrono::high_resolution_clock::now();
30 | \end{cpp}
31 |
32 | 可以在 main() 函数中定义以下 lambda 函数,任务完成时调用该函数来获取持续时间(以毫秒为单位):
33 |
34 | \begin{cpp}
35 | auto duration_from = [](auto start) {
36 | auto dur = std::chrono::high_resolution_clock::now()
37 | - start;
38 | return std::chrono::duration_cast
39 | (dur).count();
40 | };
41 | \end{cpp}
42 |
43 | 这个代码,就可以开始衡量未来的不同使用方法。
44 |
45 | 首先运行几个异步任务,但丢弃 std::async 返回的Future:
46 |
47 | \begin{cpp}
48 | constexpr unsigned NUM_TASKS = 32;
49 |
50 | auto start = std::chrono::high_resolution_clock::now();
51 |
52 | for (unsigned i = 0; i < NUM_TASKS; i++) {
53 | std::async(std::launch::async, func, i);
54 | }
55 |
56 | std::cout << "Discarding futures: "
57 | << duration_from(start) << '\n';
58 | \end{cpp}
59 |
60 | 在我的测试PC 上,此项测试的持续时间为 334 毫秒,我的测试 PC 是 Pentium i7 4790K, 4 GHz,四核八线程。
61 |
62 | 对于下一个测试,存储返回的Future,但不要等待结果准备好。显然,这不是通过产生异步任务来消耗资源,而非处理结果来使用计算机能力的正确方法。需要强调的是,这样做是为了测试和学习:
63 |
64 | \begin{cpp}
65 | start = std::chrono::high_resolution_clock::now();
66 |
67 | for (unsigned i = 0; i < NUM_TASKS; i++) {
68 | auto fut = std::async(std::launch::async, func, i);
69 | }
70 |
71 | std::cout << "In-place futures: "
72 | << duration_from(start) << '\n';
73 | \end{cpp}
74 |
75 | 持续时间仍为 334 毫秒。这两种情况下,会创建一个 Future,当在每次循环迭代结束时超出范围时,必须等待 std::async 生成的线程完成并加入。
76 |
77 | 这里启动了 32 个任务,每个任务至少耗时 10 毫秒。总计 320 毫秒,相当于这些测试中获得的 334 毫秒。其余性能成本来自启动线程、检查 for 循环变量、存储使用稳定时钟时的时间点等。
78 |
79 | 为了避免每次调用 std::async 时都创建一个新的 Future 对象,并等待其析构函数被调用。重用 Future 对象,如下面的代码所示。同样,这不是正确的方法,放弃了对先前任务结果的访问:
80 |
81 | \begin{cpp}
82 | std::future fut;
83 | start = std::chrono::high_resolution_clock::now();
84 | for (unsigned i = 0; i < NUM_TASKS; i++) {
85 | fut = std::async(std::launch::async, func, i);
86 | }
87 |
88 | std::cout << "Reusing future: "
89 | << duration_from(start) << '\n';
90 | \end{cpp}
91 |
92 | 现在持续时间为 166 毫秒,时间的减少是因为不必等待每个Future,所以这些Future也不会销毁。
93 |
94 | 但这并不理想,我们可能想知道异步任务的结果。因此,需要将结果存储在一个向量数组中。修改前面的示例,使用 res 向量数组来存储每个任务的结果:
95 |
96 | \begin{cpp}
97 | std::vector res;
98 |
99 | start = std::chrono::high_resolution_clock::now();
100 |
101 | for (unsigned i = 0; i < NUM_TASKS; i++) {
102 | auto fut = std::async(std::launch::async, func, i);
103 | res.push_back(fut.get());
104 | }
105 |
106 | std::cout << "Reused future and storing results: "
107 | << duration_from(start) << '\n';
108 | \end{cpp}
109 |
110 | 持续时间仍为 334 毫秒。这两种情况下,都会创建一个 Future,当在每次循环迭代结束时超出范围时,必须等待 std::async 生成的线程完成并加入。
111 |
112 | 这里启动了 32 个任务,每个任务至少耗时 10 毫秒。总计 320 毫秒,相当于这些测试中获得的 334 毫秒。其余性能成本来自启动线程、检查 for 循环变量、存储使用稳定时钟时的时间点等。
113 |
114 | 为了避免每次调用 std::async 时都创建一个新的 Future 对象,并等待调用其析构函数。这里重用 Future 对象,如下面的代码所示。同样,这不是正确的方法,放弃了对先前任务结果的访问:
115 |
116 | \begin{cpp}
117 | std::vector res;
118 | std::vector> futsVec;
119 |
120 | start = std::chrono::high_resolution_clock::now();
121 |
122 | for (unsigned i = 0; i < NUM_TASKS; i++) {
123 | futsVec.emplace_back(std::async(std::launch::async,
124 | func, i));
125 | }
126 |
127 | for (unsigned i = 0; i < NUM_TASKS; i++) {
128 | res.push_back( futsVec[i].get() );
129 | }
130 |
131 | std::cout << "Futures vector and storing results: "
132 | << duration_from(start) << '\n';
133 | \end{cpp}
134 |
135 | 现在持续时间只有 22 毫秒!但这是为什么呢?
136 |
137 | 现在,所有任务都真正异步运行。第一个循环启动所有任务并将 Future 存储在 futsVec 向量数组中。由于 调用Future 析构函数,因此不再会有等待期。
138 |
139 | 第二个循环遍历 futsVec,检索每个结果,并将其存储在结果向量 res 中。执行第二个循环的时间大约是遍历 res 向量所需的时间加上最慢任务的调度和执行时间。
140 |
141 | 如果测试运行的系统有足够的线程来同时运行所有异步任务,则运行时间可以减半。有些系统可以通过让调度程序决定运行哪些任务来自动管理后台的多个异步任务。其他系统中,当尝试同时启动多个线程时,可能会通过引发异常来发出抱怨。下一节中,将使用信号量实现线程限制器。
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
--------------------------------------------------------------------------------
/book/content/part3/chapter7/6.tex:
--------------------------------------------------------------------------------
1 | 如果没有足够的线程来运行多个 std::async 调用,则会引发 std::runtime\_system异常并指示资源耗尽。
2 |
3 | 可以通过使用计数信号量(std::counting\_semaphore)创建线程限制器来实现一个简单的解决方案,这是第 4 章中解释的多线程同步机制。
4 |
5 | 这里使用一个 std::counting\_semaphore 对象,将其初始值设置为系统允许的最大并发任务数,可以通过调用 std::thread::hardware\_concurrency() 函数来检索,然后在任务函数中使用该信号量来阻止异步任务的总数超过最大并发任务数。
6 |
7 | 以下代码实现了这个想法:
8 |
9 | \begin{cpp}
10 | #include
11 | #include
12 | #include
13 | #include
14 | #include
15 | #include
16 |
17 | #define sync_cout std::osyncstream(std::cout)
18 |
19 | using namespace std::chrono_literals;
20 |
21 | void task(int id, std::counting_semaphore<>& sem) {
22 | sem.acquire();
23 |
24 | sync_cout << "Running task " << id << "...\n";
25 | std::this_thread::sleep_for(1s);
26 |
27 | sem.release();
28 | }
29 |
30 | int main() {
31 | const int total_tasks = 20;
32 | const int max_concurrent_tasks =
33 | std::thread::hardware_concurrency();
34 |
35 | std::counting_semaphore<> sem(max_concurrent_tasks);
36 |
37 | sync_cout << "Allowing only "
38 | << max_concurrent_tasks
39 | << " concurrent tasks to run "
40 | << total_tasks << " tasks.\n";
41 |
42 | std::vector> futures;
43 | for (int i = 0; i < total_tasks; ++i) {
44 | futures.push_back(
45 | std::async(std::launch::async,
46 | task, i, std::ref(sem)));
47 | }
48 |
49 | for (auto& fut : futures) {
50 | fut.get();
51 | }
52 | std::cout << "All tasks completed." << std::endl;
53 | return 0;
54 | }
55 | \end{cpp}
56 |
57 | 该程序首先设置将要启动的任务总数。然后,创建一个计数信号量 sem,并将其初始值设置为硬件并发值。最后,启动所有任务并等待其Future准备就绪。
58 |
59 | 本例的关键点是,每个任务在执行其工作之前都会获取信号量,从而减少内部计数器或阻塞直到计数器可以减少。当工作完成后,释放信号量,这会增加内部计数器并解除阻塞,此时尝试获取信号量的其他任务。所以,只有当存在是用于该任务的空闲硬件线程。否则,线程将阻塞,直到另一个任务释放信号量。
60 |
61 | 探讨一些实际场景之前,先了解一下使用 std::async 的缺点。
62 |
63 |
64 |
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/book/content/part3/chapter7/7.tex:
--------------------------------------------------------------------------------
1 | std::async 不提供对所用线程数或对线程对象本身的访问的直接控制。现在,了解了如何通过使用计数信号量来限制异步任务的数量,但在某些应用程序中,这可能不是最佳解决方案,需要细粒度的控制。
2 |
3 | 此外,线程的自动管理可能会引入开销,从而降低性能,尤其是在启动许多小任务时,会导致过多的上下文切换和资源争用。
4 |
5 | 该实现对可使用的并发线程数施加了一些限制,这可能会降低性能甚至引发异常。由于 std: :async 和可用的 std::launch 策略依赖于实现,因此不同编译器和平台之间的性能并不一致。
6 |
7 | 最后,没有提到如何取消由 std::async 启动的异步任务,因为在完成之前没有标准的方法。
--------------------------------------------------------------------------------
/book/content/part3/chapter7/9.tex:
--------------------------------------------------------------------------------
1 | 本章中,介绍了 std::async,如何使用该函数执行异步任务,如何使用启动策略定义其行为,以及如何处理异常。
2 |
3 | 现在还了解了异步函数返回的 Future 如何影响性能以及如何明智地使用它们,还了解了如何使用计数信号量通过系统中可用的线程数来限制异步任务的数量。
4 |
5 | 还提到了一些场景,其中 std::async 可能不是完成这项工作的最佳工具。
6 |
7 | 最后,实现了几个涵盖实际场景的示例,这对于并行化许多常见任务很有用。
8 |
9 | 通过本章获得的所有知识,现在了解了何时(以及何时不)使用 std::async 函数并行运行异步任务,从而提高应用程序的整体性能,实现更好的计算机资源利用率,并减少资源耗尽。
10 |
11 | 下一章中,将介绍如何使用自 C++20 以来就可用的协程来实现异步执行。
--------------------------------------------------------------------------------
/book/content/part3/chapter7/images/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part3/chapter7/images/1.png
--------------------------------------------------------------------------------
/book/content/part3/chapter7/images/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part3/chapter7/images/2.png
--------------------------------------------------------------------------------
/book/content/part3/chapter8/0.tex:
--------------------------------------------------------------------------------
1 | 前面的章节中,了解了在 C++ 中编写异步代码的不同方法。使用了线程(基本执行单元)和一些更高级的异步代码机制,例如 Future、 Promise 和 std::async。将在下一章中介绍 Boost.Asio 库。所有这些方法通常使用由内核创建和管理的多个系统线程。
2 |
3 | 例如,程序的主线程可能需要访问数据库。这种访问可能很慢,因此会在另一个线程中读取数据,以便主线程可以继续执行其他任务。另一个示例是“生产者-消费者”模型,其中一个或多个线程生成要处理的数据项,并且一个或多个线程以完全异步的方式处理这些项。
4 |
5 | 上述两个示例都使用了线程(也称为系统(内核)线程),并且需要不同的执行单元,每个线程一个。
6 |
7 | 本章中,将研究一种编写异步代码的另一种方式——协程。协程是 20 世纪 50 年代末的一个古老概念,直到最近才添加到 C++(C++20) 中。协程不需要单独的线程( 当然,可以让不同的线程运行协程),而是一种机制,允许在单个线程中执行多项任务。
8 |
9 | 本章中,将讨论以下主要主题:
10 |
11 | \begin{itemize}
12 | \item
13 | 什么是协同程序以及 C++ 如何实现和支持情况?
14 |
15 | \item
16 | 实现基本协程,了解 C++ 协程的要求
17 |
18 | \item
19 | 生成器协程和新的 C++23 std::generator
20 |
21 | \item
22 | 用于解析整数的字符串解析器
23 |
24 | \item
25 | 协程中的异常
26 | \end{itemize}
27 |
28 | 本章介绍不使用任何第三方库实现的 C++ 协程。这种编写协程的方式相当底层,需要编写代码来支持。
29 |
30 |
--------------------------------------------------------------------------------
/book/content/part3/chapter8/1.tex:
--------------------------------------------------------------------------------
1 | 对于本章,需要一个 C++20 编译器。对于生成器示例,需要一个 C++23 编译器。
2 |
3 | 已经使用 GCC 14.1 测试了这些示例。代码与平台无关,因此即使本书以 Linux 为重点,所有示例也应该可以在 macOS 和 Windows 上运行。请注意, Visual Studio 17.11 尚不支持 C++23 std::generator。
4 |
5 | 本章的代码可以在本书的 GitHub 库中找到: \url{https://github.com/PacktPublishing/Asynchr onous-Programming-with-CPP}。
--------------------------------------------------------------------------------
/book/content/part3/chapter8/2.tex:
--------------------------------------------------------------------------------
1 | 开始 C++ 中实现协程之前,将从概念上介绍协程,并了解其在程序中有何用处。
2 |
3 | 从定义开始。协程是一种可以自行暂停的函数,在等待输入值时(暂停时不会执行)或在产生值(例如计算结果)后自行暂停。当输入值可用或调用者请求另一个值,协程就会恢复执行。我们很快会回到 C++ 中的协程,通过一个实际例子来了解协程的工作原理。
4 |
5 | 想象一下有人在做助理,一天的开始就是阅读电子邮件。其中一封电子邮件是一份报告请求。阅读完电子邮件后,开始撰写所要求的文档。写完介绍段落后,注意到需要同事的另一份报告来获取上一季度的一些会计结果。停止撰写报告,给同事写一封电子邮件请求所需的信息,然后阅读下一封电子邮件,这是一份预订下午重要会议会议室的请求。打开公司开发的用于自动预订会议室的特殊应用程序,以优化会议室的使用并预订会议室。
6 |
7 | 过了一会儿,又从同事那里收到所需的会计数据并继续撰写报告。
8 |
9 | 助理总是忙于完成自己的任务。编写报告就是协程的一个很好的例子:开始编写报告,然后在等待所需信息时暂停写作,当信息到达,他们就会继续写作。当然,助理不想浪费时间,在等待期间,也会执行其他任务。如果同事等待请求然后发送适当的响应,则可以将其视为另一个协程。
10 |
11 | 现在回到软件上。假设需要编写一个函数,在处理一些输入信息后将数据存储在数据库中。
12 |
13 | 如果数据一次性全部到达,可以只实现一个函数。该函数将读取输入,对其进行所需的处理,最后将结果写入数据库。但如果数据要处理的数据以块的形式到达,并且处理每个块都需要前一个块处理的结果(为了这个例子,假设第一个块处理只需要一些默认值)?解决问题的一个可能方法是让函数等待每个数据块,处理它后将结果存储在数据库中,然后等待下一个数据块,依此类推。但如果这样做,可能会在等待每个数据块到达时浪费大量时间。
14 |
15 | 阅读完前面的章节后,可能会考虑不同的潜在解决方案:可以创建一个线程来读取数据,将块复制到队列,然后第二个线程(可能是主线程)将处理数据。这是一个可接受的解决方案,但使用多个线程可能有点过头了。
16 |
17 | 另一个解决方案可能是实现一个函数来仅处理一个块。调用者将等待输入传递给函数,并保留处理每个数据块所需的前一个块处理的结果。这个解决方案中,必须将数据处理函数所需的状态保存在另一个函数中。对于一个简单的示例来说,这可能是可以接受的,但是当处理变得更加复杂(例如,需要保留具有不同中间结果的多个步骤),代码可能很难理解和维护。
18 |
19 | 可以使用协程来解决这个问题。来看一些可能的协程伪代码,以块的形式处理数据并保留中间结果:
20 |
21 | \begin{cpp}
22 | processing_result process_data(data_block data) {
23 | while (do_processing == true) {
24 | result_type result{ 0 };
25 | result = process_data_block(previous_result);
26 | update_database();
27 | yield result;
28 | }
29 | }
30 | \end{cpp}
31 |
32 | 上述协程从调用者处接收一个数据块,执行所有处理,更新数据库,并保存处理下一个块所需的结果。将结果交给调用者后(稍后将详细介绍如何交出结果),其会自行暂停。当调用者再次调用协程并请求处理新数据块时,协程将恢复执行。
33 |
34 | 这样的协同程序简化了状态管理,可以在调用之间保持状态。
35 |
36 | 对协程进行概念性介绍之后,将开始在 C++20 中实现它们。
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/book/content/part3/chapter8/3.tex:
--------------------------------------------------------------------------------
1 |
2 | 协程只是函数,但与我们习惯的函数不同。其具有特殊的属性,我们将在本章中研究这些属性。本节中,将重点介绍 C++ 中的协程。
3 |
4 | 函数在调用时开始执行,并且通常以返回语句或刚到达函数末尾时终止。
5 |
6 | 函数从头到尾运行,可能会调用另一个函数(如果是递归函数,则甚至会调用自身),也可能会抛出异常或有不同的返回点,但它总是从头到尾运行。
7 |
8 | 协程则不同。协程是一个可以暂停自身的函数。协程的流程可能类似于以下伪代码:
9 |
10 | \begin{cpp}
11 | void coroutine() {
12 | do_something();
13 | co_yield;
14 | do_something_else();
15 | co_yield;
16 | do_more_work();
17 | co_return;
18 | }
19 | \end{cpp}
20 |
21 | 我们很快就会看到带有 co\_ 前缀的术语的含义。
22 |
23 | 对于协程,需要一种机制来保持执行状态,以便能够暂停/恢复协程。这是由编译器完成的,但必须编写一些辅助代码,让编译器协助实现。
24 |
25 | C++ 中的协程是无堆栈的,所以需要存储的状态才能暂停/恢复协程,存储在调用new/delete 来分配/释放动态内存的堆中。这些调用由编译器创建。
26 |
27 | \mySubsubsection{8.3.1.}{新关键字}
28 |
29 | 因为协程本质上是一个函数(具有一些特殊属性,但仍然是一个函数),所以编译器需要某种方法来知道给定函数是否是协程。 C++20 引入了三个新关键字: co\_yield、 co\_await 和co\_return。如果一个函数至少使用这三个关键字中的一个,那么编译器就知道它是一个协程。
30 |
31 | 下表总结了新关键字的功能:
32 |
33 | % Please add the following required packages to your document preamble:
34 | % \usepackage{longtable}
35 | % Note: It may be necessary to compile the document several times to get a multi-page table to line up properly
36 | \begin{longtable}{|l|l|l|}
37 | \hline
38 | \textbf{关键字} & \textbf{输入/输出} & \textbf{协程状态} \\ \hline
39 | \endfirsthead
40 | %
41 | \endhead
42 | %
43 | co\_yield & 输出 & 暂停 \\ \hline
44 | co\_await & 输入 & 暂停 \\ \hline
45 | co\_return & 输出 & 终止 \\ \hline
46 | \end{longtable}
47 |
48 | \begin{center}
49 | 表 8.1:新的协程关键字
50 | \end{center}
51 |
52 | 上表中,在 co\_yield 和 co\_await 之后,协程会自行挂起,而在 co\_return 之后,协程会终止(co\_return 相当于 C++ 函数中的 return 语句)。协程不能有 return 语句,必须始终使用 co\_return。如果协程不返回任何值并且使用了其他两个协程关键字中的任何一个,则可以省略 co\_return 语句。
53 |
54 | \mySubsubsection{8.3.2.}{协程的限制}
55 |
56 | 使用新的 coroutines 关键字声明的函数是协程,但是协程有以下限制:
57 |
58 | \begin{itemize}
59 | \item
60 | 使用可变参数的可变数量参数的函数不能是协同程序(可变参数函数模板可以是协同程序)
61 |
62 | \item
63 | 类构造函数或析构函数不能是协同程序
64 |
65 | \item
66 | constexpr 和 consteval 函数不能是协程
67 |
68 | \item
69 | 返回 auto 的函数不能是协程,但带有尾随返回类型的 auto 可以
70 |
71 | \item
72 | main() 函数不能是协程
73 |
74 | \item
75 | Lambda 可以是协程
76 | \end{itemize}
77 |
78 | 研究了协程的限制(基本上就是什么样的C++函数不可以成为协程),下一节就要开始实现协程了。
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
--------------------------------------------------------------------------------
/book/content/part3/chapter8/5.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | 生成器是一个协同程序,通过从暂停点反复恢复自身来生成一系列元素。
4 |
5 | 生成器可以看作是一个无限序列,可以生成任意数量的元素。调用函数可以根据需要从生成器中,获取任意数量的新元素。
6 |
7 | 当我们说无限时,指的是理论上。生成器协同程序将产生没有明确最后一个元素的元素(可以实现具有有限范围的生成器)。但在实践中,必须处理诸如数字序列溢出之类的问题。
8 |
9 | 让我们从头开始实现一个生成器,应用在本章前面章节中获得的知识。
10 |
11 | \mySubsubsection{8.5.1.}{斐波那契数列生成器}
12 |
13 | 假设正在实现一个应用程序,需要使用斐波那契数列。斐波那契数列是一个序列,其中每个数字都是前两个数字的总和。第一个元素是 0,第二个元素是1,然后应用定义并生成一个又一个元素。
14 |
15 | 斐波那契数列: $F (n) = F (n − 2) + F (n − 1) ; F (0) = 0, F (1) = 1$
16 |
17 | 可以用 for 循环生成这些数字,但需要在程序的不同点生成相应的值,所以需要实现一种方法来存储序列的状态,还需要在程序的某个地方保存我们生成的最后一个元素。
18 |
19 | 协程是解决此问题的一个非常好的方法,将自行保持所需的状态并且将暂停直到请求序列中的下一个数字。
20 |
21 | 以下是使用生成器协程的代码:
22 |
23 | \begin{cpp}
24 | int main() {
25 | sequence_generator fib = fibonacci();
26 |
27 | std::cout << "Generate ten Fibonacci numbers\n"s;
28 |
29 | for (int i = 0; i < 10; ++i) {
30 | fib.next();
31 | std::cout << fib.value() << " ";
32 | }
33 | std::cout << std::endl;
34 |
35 | std::cout << "Generate ten more\n"s;
36 |
37 | for (int i = 0; i < 10; ++i) {
38 | fib.next();
39 | std::cout << fib.value() << " ";
40 | }
41 | std::cout << std::endl;
42 |
43 | std::cout << "Let's do five more\n"s;
44 |
45 | for (int i = 0; i < 5; ++i) {
46 | fib.next();
47 | std::cout << fib.value() << " ";
48 | }
49 | std::cout << std::endl;
50 |
51 | return 0;
52 | }
53 | \end{cpp}
54 |
55 | 正如上面的代码所示,生成了所需的数字,而不必担心最后一个元素是什么。序列由协程生成。请注意,尽管理论上该序列是无限的,但程序必须意识到非常大的斐波那契数的潜在溢出。为了实现生成器协程,遵循本章前面解释的原则。
56 |
57 | 首先实现协程函数:
58 |
59 | \begin{cpp}
60 | sequence_generator fibonacci() {
61 | int64_t a{ 0 };
62 | int64_t b{ 1 };
63 | int64_t c{ 0 };
64 |
65 | while (true) {
66 | co_yield a;
67 | c = a + b;
68 | a = b;
69 | b = c;
70 | }
71 | }
72 | \end{cpp}
73 |
74 | 协程只是通过应用公式来生成斐波那契数列中的下一个元素。元素是在无限循环中生成的,但协程会在 co\_yield 之后自行暂停。
75 |
76 | 返回类型是sequence\_generator结构(我们使用模板来使用32位或64位整数),包含一个承诺类型,非常类似于我们在上一节中看到的协程让步中的promise类型。
77 |
78 | 在sequence\_generator结构中,添加了两个在实现序列生成器时很有用的函数。
79 |
80 | \begin{cpp}
81 | void next() {
82 | if (!handle.done()) {
83 | handle.resume();
84 | }
85 | }
86 | \end{cpp}
87 |
88 | next()函数为将要生成的序列中的新斐波那契数恢复协程。
89 |
90 | \begin{cpp}
91 | int64_t value() {
92 | return handle.promise().output_data;
93 | }
94 | \end{cpp}
95 |
96 | value() 函数返回最后生成的斐波那契数。
97 |
98 | 这样,将元素生成与其检索 Qvalue 分离。
99 |
100 | 请在本书附带的 GitHub 库中查找本示例的完整代码。
101 |
102 | \mySamllsubsection{C++23的std::generator}
103 |
104 | C++ 中实现即使是最基本的协程也需要一定数量的代码。这种情况可能会在 C++26 中发生变化, C++ 标准库将对协程提供更多支持,这将使我们能够更轻松地编写协程。
105 |
106 | C++23 引入了 std::generator 模板类,可以编写基于协程的生成器,而无需编写任何必需的代码,例如:promise类型、返回类型及其所有函数。要运行此示例,需要一个 C++23 编译器,这里使用了 GCC 14.1。 std::generator 在 Clang 中不可用。
107 |
108 | 来看看使用新的 C++23 标准库功能的斐波那契数列生成器:
109 |
110 | \begin{cpp}
111 | #include
112 | #include
113 |
114 | std::generator fibonacci_generator() {
115 | int a{ };
116 | int b{ 1 };
117 | while (true) {
118 | co_yield a;
119 | int c = a + b;
120 | a = b;
121 | b = c;
122 | }
123 | }
124 | auto fib = fibonacci_generator();
125 |
126 | int main() {
127 | int i = 0;
128 | for (auto f = fib.begin(); f != fib.end(); ++f) {
129 | if (i == 10) {
130 | break;
131 | }
132 | std::cout << *f << " ";
133 | ++i;
134 | }
135 | std::cout << std::endl;
136 | }
137 | \end{cpp}
138 |
139 | 第一步是包含 头文件。然后,只需编写协程,所有代码都已为编写完成。前面的代码中,使用迭代器(由 C++ 标准库提供)访问生成的元素,能够使用 range-for 循环、算法和范围。
140 |
141 | 还可以编写斐波那契生成器的一个版本,来生成一定数量的元素而不是无限数列:
142 |
143 | \begin{cpp}
144 | std::generator fibonacci_generator(int limit) {
145 | int a{ };
146 | int b{ 1 };
147 | while (limit--) {
148 | co_yield a;
149 | int c = a + b;
150 | a = b;
151 | b = c;
152 | }
153 | }
154 | \end{cpp}
155 |
156 | 代码的变化非常简单:只需传递希望生成器生成的元素数量,并将其用作 while 循环中的终止条件。
157 |
158 | 本节中,实现了最常见的协程类型之一——生成器。我们从头开始以及使用 C++23的std::generator 类模板实现了生成器。
159 |
160 | 我们将在下一节中实现一个简单的字符串解析器协程。
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
--------------------------------------------------------------------------------
/book/content/part3/chapter8/6.tex:
--------------------------------------------------------------------------------
1 |
2 | 本节中,将实现最后一个例子:一个简单的字符串解析器。协程将等待输入(一个 std::string 对象),并在解析输入字符串后产生输出(一个数字)。为了简化示例,假设数字的字符串表示没有错误,并且数字的结尾由井号 \# 表示;还假设数字类型为 i nt64\_t,并且字符串不包含该整数类型范围之外的值。
3 |
4 | \mySubsubsection{8.6.1.}{解析算法}
5 |
6 | 看看如何将表示整数的字符串转换为数字。例如,字符串“-12321\#”表示数字 -1232 1。要将字符串转换为数字,可以编写如下函数:
7 |
8 | \begin{cpp}
9 | int64_t parse_string(const std::string& str) {
10 | int64_t num{ 0 };
11 | int64_t sign { 1 };
12 |
13 | std::size_t c = 0;
14 | while (c < str.size()) {
15 | if (str[c] == '-') {
16 | sign = -1;
17 | }
18 | else if (std::isdigit(str[c])) {
19 | num = num * 10 + (str[c] - '0');
20 | }
21 | else if (str[c] == '#') {
22 | break;
23 | }
24 | ++c;
25 | }
26 | return num * sign;
27 | }
28 | \end{cpp}
29 |
30 | 由于假设字符串格式正确,因此代码非常简单。如果读取减号 -,则将其更改为 -1(默认情况下,假设为正数,如果有 + 符号,则将其忽略)。然后,逐个读取数字,并按如下方式计算数值。
31 |
32 | num 的初始值是 0。读取第一位数字,并将其数值添加到当前 num 值乘以 10 之后。这就是读取数字的方式:最左边的数字将乘以 10,次数与其右边的数字数量相同。
33 |
34 | 使用字符来表示数字时,可根据 ASCII 表示具有一些值(我们假设不使用宽字符或任何其他字符类型)。字符 0 到 9 具有连续的 ASCII 码,因此只需减去 0 即可将它们转换为数字。
35 |
36 | 即使对于上述代码,最后一个字符检查不是必需的,但还是在此处包含了它。当解析器例程找到 \# 字符时,它会终止解析循环并返回最终的数字值。
37 |
38 | 我们可以使用此函数来解析任何字符串并获取数字值,但需要完整的字符串才能将其转换为数字。
39 |
40 | 考虑一下这个场景:从网络连接接收到字符串,需要解析它并将其转换为数字。可以将字符保存到临时字符串中,然后调用前面的函数。
41 |
42 | 但还有另一个问题:如果字符到达速度很慢,比如每隔几秒才到达一次,因为这就是字符的传输方式,那该怎么办?希望让 CPU 保持忙碌,如果可能的话,在等待每个字符到达时执行一些其他任务(或任务)。
43 |
44 | 解决这个问题的方法有很多种。可以创建一个线程并同时处理字符串,但对于这样一个简单的任务来说,这会浪费大量的计算机时间,也可以使用 std::async。
45 |
46 | \mySubsubsection{8.6.2.}{解析协程}
47 |
48 | 本章将使用协程,因此将使用 C++ 协程实现字符串解析器。不需要多余的线程,而且由于协程的异步特性,在字符到达时执行其他处理都非常容易。
49 |
50 | 解析协程所需的样板代码,与前面的示例中已经看到的代码几乎相同。解析器本身则完全不同。请参阅以下代码:
51 |
52 | \begin{cpp}
53 | async_parse parse_string() {
54 | while (true) {
55 | char c = co_await char{ };
56 | int64_t number { };
57 | int64_t sign { 1 };
58 |
59 | if (c != '-' && c != '+' && !std::isdigit(c)) {
60 | continue;
61 | }
62 | if (c == '-') {
63 | sign = -1;
64 | }
65 | else if (std::isdigit(c)) {
66 | number = number * 10 + c - '0';
67 | }
68 |
69 | while (true) {
70 | c = co_await char{};
71 | if (std::isdigit(c)) {
72 | number = number * 10 + c - '0';
73 | }
74 | else {
75 | break;
76 | }
77 | }
78 | co_yield number * sign;
79 | }
80 | }
81 | \end{cpp}
82 |
83 | 现在可以轻松识别返回类型(async\_parse),并且解析器协程会暂停自身以等待输入字符。解析完成后,协程将在产生数字后暂停自身。
84 |
85 | 但也可以看到,前面的代码并不像第一次尝试将字符串解析为数字那么简单。
86 |
87 | 首先,解析器协程会逐个解析字符,不会获取完整的字符串进行解析,因此会无限循环while (true)。不知道完整字符串中有多少个字符,因此需要继续接收和解析。
88 |
89 | 外循环意味着协程将随着字符的到来而一个接一个地解析数字——永远如此。但它会暂停自身以等待字符,这样就不会浪费 CPU 时间。
90 |
91 | 现在,一个字符到达。首先,检查它是否是我们数字的有效字符。如果该字符不是减号 -、加号 + 或数字,则解析器等待下一个字符。
92 |
93 | 如果下一个字符是有效字符,则适用以下情况:
94 |
95 | \begin{itemize}
96 | \item
97 | 如果是减号,将符号值改为 -1
98 |
99 | \item
100 | 如果是加号,忽略
101 |
102 | \item
103 | 如果是数字,将其解析为数字,并使用与解析器的第一个版本中看到的相同方法更新当前数字值
104 | \end{itemize}
105 |
106 | 在第一个有效字符之后,进入一个新的循环来接收其余的字符,无论是数字还是分隔符 (\#)。注意,当说有效字符时,指的是适合数字转换。仍然假设输入的字符形成一个正确终止的有效数字。
107 |
108 | 一旦数字转换,协程就会将其返回,然后再次执行外循环。这里需要终止字符,输入字符流理论上是无穷无尽的,并且可以包含许多数字。
109 |
110 | 协程其余部分的代码可以在 GitHub 中找到,遵循与其他协程相同的约定。首先,定义返回类型:
111 |
112 | \begin{cpp}
113 | template
114 | struct async_parse {
115 | // …
116 | };
117 | \end{cpp}
118 |
119 | 使用模板来提高灵活性,其允许参数化输入和输出数据类型。本例中,这些类型分别是 int64\_t 和 char。
120 |
121 | 输入和输出数据项如下:
122 |
123 | \begin{cpp}
124 | std::optional input_data { };
125 | Out output_data { };
126 | \end{cpp}
127 |
128 | 对于输入,可使用 std::optional,因为需要一种方法来知道我们是否收到了一个字符。这里,使用 put() 函数将字符发送到解析器:
129 |
130 | \begin{cpp}
131 | void put(char c) {
132 | handle.promise().input_data = c;
133 | if (!handle.done()) {
134 | handle.resume();
135 | }
136 | }
137 | \end{cpp}
138 |
139 | 此函数仅将值赋给 std::optional input\_data 变量。为了管理字符的等待,实现了以下 awaiter 类型:
140 |
141 | \begin{cpp}
142 | auto await_transform(char) noexcept {
143 | struct awaiter {
144 | promise_type& promise;
145 |
146 | [[nodiscard]] bool await_ready() const noexcept {
147 | return promise.input_data.has_value();
148 | }
149 |
150 | [[nodiscard]] char await_resume() const noexcept {
151 | assert (promise.input_data.has_value());
152 | return *std::exchange(
153 | promise.input_data,
154 | std::nullopt);
155 | }
156 |
157 | void await_suspend(std::coroutine_handle<
158 | promise_type>) const noexcept {
159 | }
160 | };
161 | return awaiter(*this);
162 | }
163 | \end{cpp}
164 |
165 | awaiter 结构实现了两个函数来处理输入数据:
166 |
167 | \begin{itemize}
168 | \item
169 | await\_ready():如果可选的 input\_data 变量包含有效值,则返回 true。否则返回 false。
170 |
171 | \item
172 | await\_resume():返回存储在可选 input\_data 变量中的值并将其清空,并将其分配给 std::nullopt。
173 | \end{itemize}
174 |
175 | 本节中,了解了如何使用 C++ 协程实现一个简单的解析器。这是最后一个例子,说明了使用协程实现的一个非常基本的流处理功能。下一节中,将介绍协程中的异常。
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
--------------------------------------------------------------------------------
/book/content/part3/chapter8/7.tex:
--------------------------------------------------------------------------------
1 | 前面的部分中,实现了几个基本示例来学习主要的 C++ 协程概念。首先实现了一个非常基本的协程,以了解编译器对我们的要求:返回类型(有时称为包装器类型,包装了promise类型)和promise类型。
2 |
3 | 即使对于这样一个简单的协程,也必须实现我们在编写示例时解释过的一些函数。但有一个函数尚未解释:
4 |
5 | \begin{cpp}
6 | void unhandled_exception() noexcept {}
7 | \end{cpp}
8 |
9 | 当时假设协程不会抛出异常,但事实是它们会抛出异常。我们可以在 unhandled\_exception() 函数体中添加处理异常的功能。
10 |
11 | 协程中的异常可能发生在创建返回类型或promiase类型对象时,以及执行协程时(与在正常函数中一样,协程可能会引发异常)。
12 |
13 | 不同之处在于,如果异常是在协程执行前引发的,则创建协程的代码必须处理该异常,而如果异常是在协程执行时引发的,则会调用 unhandled\_exception()。
14 |
15 | 第一种情况只是普通的异常处理,没有调用任何特殊函数。可以将协程创建放在 try-catch 块中,并像在代码中通常做的那样处理可能的异常。
16 |
17 | 另一方面,如果调用 unhandled\_exception()(在 promise 类型内部),必须在该函数内部实现异常处理功能。
18 |
19 | 有不同的策略来处理此类异常。其中包括:
20 |
21 | \begin{itemize}
22 | \item
23 | 重新抛出异常,以便可以在promise类型之外(即在我们的代码中)处理。
24 |
25 | \item
26 | 终止程序(例如,调用 std::terminate)。
27 |
28 | \item
29 | 函数为空。协程将崩溃,很可能会使程序崩溃。
30 | \end{itemize}
31 |
32 | 因为实现的协程非常简单,所以将该函数置为空。
33 |
34 | 最后一节中,介绍了协程的异常处理机制。正确处理异常非常重要。例如,知道协程内部发生异常后,将无法恢复;那么,最好让协程崩溃并从程序的另一部分(通常是从调用者函数)处理异常。
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/book/content/part3/chapter8/8.tex:
--------------------------------------------------------------------------------
1 | 本章中,了解了协程,这是 C++ 中最近引入的一项新功能,允许编写异步代码而无需创建新线程。我们实现了几个简单的协程来解释 C++ 协程的基本要求。此外,还介绍了如何实现生成器和字符串解析器。最后,了解了协程中的异常。
2 |
3 | 协程在异步编程中非常重要,允许程序在特定点暂停执行并稍后恢复,同时允许其他任务运行,所有任务都在同一个线程中运行。它们可以更好地利用资源,减少等待时间,并提高应用程序的可扩展性。
4 |
5 | 下一章中,将介绍Boost.Asio——一个用于在C++中编写异步代码的非常强大的库。
--------------------------------------------------------------------------------
/book/content/part3/chapter8/9.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | \begin{itemize}
4 | \item
5 | C++ Coroutines for Beginners, Andreas Fertig, Meeting C++ Online, 2024
6 |
7 | \item
8 | Deciphering Coroutines, Andreas Weiss, CppCon 2022
9 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part3/part.tex:
--------------------------------------------------------------------------------
1 | 在这一部分中,我们将重点转移到本书的核心主题——异步编程,这是构建响应式高性能应用程序的关键方面。将介绍如何利用诸如Promise、Future、打包任务、 std::async 函数和协程等工具并发执行任务,而不阻塞主执行流程,协程是一项革命性的功能,无需创建线程即可实现异步编程。这里还将介绍共享未来的高级技术,并研究这些概念必不可少的真实场景。这些强大的机制使我们能够开发现代软件系统所需的高效、可扩展且可维护的异步软件。
2 |
3 | 本部分包含以下章节:
4 |
5 | \begin{itemize}
6 | \item
7 | 第6章 Promise和Future
8 |
9 | \item
10 | 第7章,异步函数
11 |
12 | \item
13 | 第8章,使用协程进行异步编程
14 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part4/chapter10/0.tex:
--------------------------------------------------------------------------------
1 | 前面的章节介绍了 C++20 协程和 Boost.Asio 库,这是使用 Boost 编写异步输入/输出 (I/O) 操作的基础。本章中,将探索 Boost.Cobalt,这是一个基于 Boost.Asio 的高级抽象,可简化使用协程的异步编程。
2 |
3 | Boost.Cobalt 可编写清晰、可维护的异步代码,同时避免在 C++ 中手动实现协程的复杂性(如第 8 章所述)。 Boost.Cobalt 与 Boost.Asio 完全兼容,可在项目中无缝结合这两个库。通过使用 Boost.Cobalt,可专注于构建应用程序,而不必担心协程的底层细节。
4 |
5 | 本章中,将介绍以下内容:
6 |
7 | \begin{itemize}
8 | \item
9 | Boost.Cobalt 库简介
10 |
11 | \item
12 | Boost.Cobalt 生成器
13 |
14 | \item
15 | Boost.Cobalt 任务和promise
16 |
17 | \item
18 | Boost.Cobalt 通道
19 |
20 | \item
21 | Boost.Cobalt 同步函数
22 | \end{itemize}
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/book/content/part4/chapter10/1.tex:
--------------------------------------------------------------------------------
1 | 要构建和执行本章中的代码示例,需要支持 C++20 的编译器。我们使用了 Clang 18 和 GCC 14.2。
2 |
3 | 确保使用 Boost 1.84 或更新版本,并且 Boost 库是在 C++20 支持下编译的。撰写本书时, Cobalt 支持在 Boost 中还很新,并非所有预编译发行版都提供此组件。阅读本书时,情况通常会有所改善。如果由于某些原因,系统中的 Boost 库不满足这些要求,则必须从其源代码构建。使用早期版本(例如 C++17)进行编译将不会包含 Boost.Cobalt(严重依赖 C++20 协程)。
4 |
5 | 可以在以下 GitHub 存储库中找到完整的代码:
6 | \url{https://github.com/PacktPublishing/Asynchronous-Programming-with-CPP}
7 |
8 | 本章的示例位于 Chapter\_10 文件夹下。
--------------------------------------------------------------------------------
/book/content/part4/chapter10/2.tex:
--------------------------------------------------------------------------------
1 |
2 | 第 8 章中介绍了 C++20 如何支持协程。显然,编写协程并不是一件容易的事,主要有两个原因:
3 |
4 | \begin{itemize}
5 | \item
6 | 在 C++ 中编写协程需要一定数量的代码才能使协程工作,但与想要实现的功能无关。例如,编写的用于生成斐波那契数列的协程非常简单,但必须实现包装器类型、promise,以及使其可用所需的所有函数。
7 |
8 | \item
9 | 开发普通的 C++20 协程需要很好地了解协程在 C++ 中实现的底层方面,编译器如何转换代码以实现保持协程状态所需的所有机制,以及如何调用和何时调用,函数必须详尽的实现。
10 | \end{itemize}
11 |
12 | 即使没有那么多细节,异步编程也已经够难了。如果能专注于我们的程序,远离底层概念和代码,那就好了。我们看到了 C++23 如何引入 std::generator 来实现这一点,只编写生成器代码,让 C++ 标准库和编译器处理其余部分。预计这种协程支持将在下一个 C++ 版本中得到改进。
13 |
14 | Boost.Cobalt 是 Boost C++ 库中包含的库之一,可做到这一点 - 避免实现协程细节。 Boost.Cobalt 是在 Boost 1.84 中引入的,并且需要 C++20。基于Boost.Asio,可以在程序中使用这两个库。
15 |
16 | Boost.Cobalt 的目标是使用协程编写简单的单线程异步代码 - 可以在单个线程中同时执行多项操作的应用程序。当然,这里所说的同时是指并发,而不是并行,因为只有一个线程。通过使用 Boost.Asio 多线程功能,可以在不同的线程中执行协程,但在本章中,将重点介绍单线程应用程序。
17 |
18 | \mySubsubsection{10.2.1.}{立即和惰性协程}
19 |
20 | 介绍Boost.Cobalt实现的协程类型之前,需要定义两种协程:
21 |
22 | \begin{itemize}
23 | \item
24 | 立即协程:立即协程在调用后立即开始执行。协程逻辑立即开始运行,并按照其序列进行,直到到达暂停点(例如 co\_await 或 co\_yield)。协程的创建实际上启动了其处理,并且其主体中的任何副作用都将立即生效。
25 |
26 | 当希望协程在创建后立即启动其工作(例如启动异步网络操作或准备数据)时,立即协程非常有用。
27 |
28 | \item
29 | 惰性协程:惰性协程会推迟执行,直到明确等待或使用。可以在不运行主体的情况下创建协程对象,直到调用者决定与其交互(通常通过使用 co\_await 等待它)。
30 |
31 | 当想要设置一个协程但延迟其执行直到满足某个条件或需要将其执行与其他任务协调时,惰性协程很有用。
32 | \end{itemize}
33 |
34 | 定义了立即协程和惰性协程之后,我们将介绍在 Boost.Cobalt 中实现的不同类型的协程。
35 |
36 | \mySubsubsection{10.2.2.}{Boost.Cobalt 协程类型}
37 |
38 | Boost.Cobalt 实现了四种类型的协程。我们将在本节中介绍它们,然后在本章后面查看一些示例:
39 |
40 | \begin{itemize}
41 | \item
42 | Promise:这是 Boost.Cobalt 中的主要协程类型,用于实现返回单个值的异步操作(调用 co\_return)。它是一个立即协程,支持 co\_await,允许异步暂停和继续。例如,promise 可用于执行网络调用,完成后将返回其结果而不会阻止其他操作。
43 |
44 | \item
45 | 任务:任务是 Promise 的惰性版本,直到明确等待时才会开始执行。提供了更大的灵活性来控制协程的运行时间和方式。等待时,任务开始执行,允许延迟处理异步操作。
46 |
47 | \item
48 | 生成器:在 Boost.Cobalt 中,生成器是唯一可以产生值的协程类型。每个值都使用 co\_yield 单独产生。其功能类似于 C++23 中的 std::generator,但允许使用 co\_await 进行等待(std::generator 不允许)。
49 |
50 | \item
51 | 分离:这是一个可以使用 co\_await 但不能使用 co\_return 值的立即协程。它无法恢复,通常也不会等待。
52 | \end{itemize}
53 |
54 | 目前为止,我们介绍了 Boost.Cobalt。定义了什么是急切协程和惰性协程,然后定义了库中的四种主要协程类型。
55 |
56 | 下一节中,将深入探讨与 Boost.Cobalt 相关的最重要的主题之一 - 生成器;还将实现一些简单的生成器示例。
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
--------------------------------------------------------------------------------
/book/content/part4/chapter10/3.tex:
--------------------------------------------------------------------------------
1 |
2 | 如第 8 章所述,生成器协程是专门设计用于增量产生值的协程。产生每个值后,协程会自行挂起,直到调用者请求下一个值。在 Boost.Cobalt 中,生成器的工作方式相同,是唯一可以产生值的协程类型。当需要协程随时间产生多个值时,生成器就必不可少了。
3 |
4 | Boost.Cobalt 生成器的一个关键特性是默认即时执行,所以在调用后立即开始执行。此外,这些生成器为异步,允许使用 co\_await,这与 C++23 中引入的 std::generator 有一个重要区别,后者是惰性执行的,不支持 co\_await。
5 |
6 | \mySubsubsection{10.3.1.}{一个基本的例子}
7 |
8 | 从最简单的 Boost.Cobalt 程序开始。此示例不是生成器,但将借助其解释一些重要细节:
9 |
10 | \begin{cpp}
11 | #include
12 |
13 | #include
14 |
15 | boost::cobalt::main co_main(int argc, char* argv[]) {
16 | std::cout << "Hello Boost.Cobalt\n";
17 | co_return 0;
18 | }
19 | \end{cpp}
20 |
21 | 上面的代码中,可观察到以下情况:
22 |
23 | \begin{itemize}
24 | \item
25 | 要使用 Boost.Cobalt,必须包含 头文件。
26 |
27 | \item
28 | 还必须将 Boost.Cobalt 库链接到应用程序。这里提供了一个 CMakeLists.txt 文件来执行此操作,不仅针对 Boost.Cobalt,还针对所有必需的 Boost 库。要明确链接 Boost.Cobalt(即并非所有必需的 Boost 库),只需将以下行添加到您的 CMakeLists.txt 文件:
29 |
30 | \begin{cmake}
31 | target_link_libraries(${EXEC_NAME} Boost::cobalt)
32 | \end{cmake}
33 |
34 | \item
35 | 使用 co\_main 函数。 Boost.Cobalt 引入了一个名为 co\_main 的基于协程的入口点,而不是通常的 main 函数。此函数可以使用协程特定的关键字,例如 co\_return。 Boost.Cobalt 在内部实现了所需的 main 函数。
36 |
37 | 使用 co\_main 可以将程序的主函数(入口点)实现为协程,从而能够调用 co\_await 和co\_return。请记住第 8 章中的内容,主函数不能是协程。
38 |
39 | 如果无法更改当前的主函数,则可以使用 Boost.Cobalt。只需从 main 调用一个函数,该函数将成为使用 Boost.Cobalt 的异步代码的顶层函数。
40 |
41 | Cobalt 正在做的事情:实现了一个 main 函数,是程序的入口点,并且该(对您隐藏的) main 函数调用 co\_main。
42 |
43 | 使用自己的主函数的最简单方法:
44 |
45 | \begin{cpp}
46 | cobalt::task async_task() {
47 | // your code here
48 | // …
49 | return 0;
50 | }
51 |
52 | int main() {
53 | // main function code
54 | // …
55 | return cobalt::run(async_code();
56 | }
57 | \end{cpp}
58 | \end{itemize}
59 |
60 | 该示例只是输出了一条问候消息,然后调用 co\_await 返回 0。在所有后续示例中,将遵循此模式:包括 头文件并使用 co\_main 而不是 main。
61 |
62 | \mySubsubsection{10.3.2.}{Boost.Cobalt 简单生成器}
63 |
64 | 利用前面的基本示例所掌握的知识,可实现一个非常简单的生成器协程:
65 |
66 | \begin{cpp}
67 | #include
68 | #include
69 |
70 | #include
71 |
72 | using namespace std::chrono_literals;
73 |
74 | using namespace boost;
75 |
76 | cobalt::generator basic_generator()
77 | {
78 | std::this_thread::sleep_for(1s);
79 | co_yield 1;
80 | std::this_thread::sleep_for(1s);
81 | co_return 0;
82 | }
83 |
84 | cobalt::main co_main(int argc, char* argv[]) {
85 | auto g = basic_generator();
86 | std::cout << co_await g << std::endl;
87 | std::cout << co_await g << std::endl;
88 | co_return 0;
89 | }
90 | \end{cpp}
91 |
92 | 上面的代码展示了一个简单的生成器,产生一个整数值(使用 co\_yield)并返回另一个整数值(使用 co\_return)。
93 |
94 | cobalt::generator 是一个结构模板:
95 |
96 | \begin{cpp}
97 | template
98 | struct generator
99 | \end{cpp}
100 |
101 | 两个参数类型如下:
102 |
103 | \begin{itemize}
104 | \item
105 | Yield:生成的对象类型
106 |
107 | \item
108 | Push:输入参数类型(默认为void)
109 | \end{itemize}
110 |
111 | co\_main 函数在使用 co\_await(调用者等待值可用)获取这两个数字后,会输出这两个数字。我们引入了一些延迟来模拟生成器生成数字必须进行的处理。
112 |
113 | 第二个生成器将产生一个整数的平方:
114 |
115 | \begin{cpp}
116 | #include
117 | #include
118 |
119 | #include
120 |
121 | using namespace std::chrono_literals;
122 |
123 | using namespace boost;
124 |
125 | cobalt::generator square_generator(int x){
126 | while (x != 0) {
127 | x = co_yield x * x;
128 | }
129 |
130 | co_return 0;
131 | }
132 |
133 | cobalt::main co_main(int argc, char* argv[]){
134 | auto g = square_generator(10);
135 |
136 | std::cout << co_await g(4) << std::endl;
137 | std::cout << co_await g(12) << std::endl;
138 | std::cout << co_await g(0) << std::endl;
139 |
140 | co_return 0;
141 | }
142 | \end{cpp}
143 |
144 | square\_generator 得出 x 参数的平方。这显示了如何将值推送到 Boost.Cob alt 生成器。在 Boost.Cobalt 中,将值推送到生成器则为传递参数(上例中,传递的参数是整数)。
145 |
146 | 本例中的生成器虽然正确,但可能会令人困惑。请看以下代码行:
147 |
148 | \begin{cpp}
149 | auto g = square_generator(10);
150 | \end{cpp}
151 |
152 | 这将创建以 10 作为初始值的生成器对象。然后,查看以下代码行:
153 |
154 | \begin{cpp}
155 | std::cout << co_await g(4) << std::endl;
156 | \end{cpp}
157 |
158 | 这将打印 10 的平方并将 4 推送到生成器,输出的值不是传递给生成器的值的平方。这是因为生成器用一个值(在此示例中为 10)初始化,并且当调用者调用 co\_await 传递另一个值时,会生成平方值。生成器在收到新值 4 时将产生 100,然后在收到值 12 时将产生 16,依此类推。
159 |
160 | Boost.Cobalt 生成器是立即的,但也可以让它在开始执行时立即等待(co\_await)。以下示例显示了如何执行此操作:
161 |
162 | \begin{cpp}
163 | #include
164 | #include
165 |
166 | boost::cobalt::generator square_generator() {
167 | auto x = co_await boost::cobalt::this_coro::initial;
168 | while (x != 0) {
169 | x = co_yield x * x;
170 | }
171 | co_return 0;
172 | }
173 |
174 | boost::cobalt::main co_main(int, char*[]) {
175 | auto g = square_generator();
176 |
177 | std::cout << co_await g(4) << std::endl;
178 | std::cout << co_await g(10) << std::endl;
179 | std::cout << co_await g(12) << std::endl;
180 | std::cout << co_await g(0) << std::endl;
181 |
182 | co_return 0;
183 | }
184 | \end{cpp}
185 |
186 | 该代码与前面的示例非常相似,但也有一些区别:
187 |
188 | \begin{itemize}
189 | \item
190 | 不传递参数的情况下创建生成器:
191 |
192 | \begin{cpp}
193 | auto g = square_generator();
194 | \end{cpp}
195 |
196 | \item
197 | 看一下生成器代码的第一行:
198 |
199 | \begin{cpp}
200 | auto x = co_await boost::cobalt::this_coro::initial;
201 | \end{cpp}
202 |
203 | 这使得生成器等待第一个压入的整数。这表现为一个惰性生成器(实际上,立即开始执行,生成器立即执行,但它做的第一件事是等待一个整数)。
204 |
205 | \item
206 | 生成的值期望从代码中得到:
207 |
208 | \begin{cpp}
209 | std::cout << co_await g(10) << std::endl;
210 | \end{cpp}
211 |
212 | 这将打印100,而不是之前输入的整数的平方。
213 | \end{itemize}
214 |
215 | 这里总结一下这个示例的作用: co\_main 函数调用 square\_generator 协程来生成整数值的平方。生成器协程在开始时暂停自身,等待第一个整数,并在产生每个平方后暂停自身。这个示例故意很简单,只是为了说明如何使用 Boost.Cobalt 编写生成器。
216 |
217 | 上述程序的一个重要特点是它在单线程中运行,所以 co\_main 和生成器协程会相继运行。
218 |
219 | \mySubsubsection{10.3.3.}{斐波那契数列生成器}
220 |
221 | 本节中,将实现一个类似于第 8 章中实现的斐波那契数列生成器。体验到使用 Boost.Cobalt 编写生成器协程,比使用纯 C++20 容易得多。
222 |
223 | 我们编写了两个版本的生成器。第一个版本计算斐波那契数列的任意项。推送想要生成的项,然后就得到了它。此生成器使用 lambda 作为斐波那契计算器:
224 |
225 | \begin{cpp}
226 | boost::cobalt::generator fibonacci_term() {
227 | auto fibonacci = [](int n) {
228 | if (n < 2) {
229 | return n;
230 | }
231 |
232 | int f0 = 0;
233 | int f1 = 1;
234 | int f;
235 |
236 | for (int i = 2; i <= n; ++i) {
237 | f = f0 + f1;
238 | f0 = f1;
239 | f1 = f;
240 | }
241 |
242 | return f;
243 | };
244 |
245 | auto x = co_await boost::cobalt::this_coro::initial;
246 | while (x != -1) {
247 | x = co_yield fibonacci(x);
248 | }
249 |
250 | co_return 0;
251 | }
252 | \end{cpp}
253 |
254 | 前面的代码中,了解到这个生成器与我们在上一节中实现的用于计算数字平方的生成器非常相似。在协程的开头,有以下内容:
255 |
256 | \begin{cpp}
257 | auto x = co_await boost::cobalt::this_coro::initial;
258 | \end{cpp}
259 |
260 | 这行代码暂停协程以等待第一个输入值。
261 |
262 | 然后有以下内容:
263 |
264 | \begin{cpp}
265 | while (x != -1) {
266 | x = co_yield fibonacci(x);
267 | }
268 | \end{cpp}
269 |
270 | 这将生成请求的斐波那契数列项,并暂停自身,直到请求下一个项。当请求的项不等于 -1 时,可以继续请求更多值,直到按下 -1 终止协程。
271 |
272 | 下一个版本的斐波那契生成器将根据要求生成无限数量的项,可以将此生成器视为始终准备生成另一个斐波那契数列:
273 |
274 | \begin{cpp}
275 | boost::cobalt::generator fibonacci_sequence() {
276 | int f0 = 0;
277 | int f1 = 1;
278 | int f = 0;
279 |
280 | while (true) {
281 | co_yield f0;
282 |
283 | f = f0 + f1;
284 | f0 = f1;
285 | f1 = f;
286 | }
287 | }
288 | \end{cpp}
289 |
290 | 上述代码很容易理解:协程产生一个值并将自身挂起,直到请求另一个值,协程计算出新值并产生它并再次陷入无限循环中。
291 |
292 | 可以看到协程的优势:可以在需要时逐个生成斐波那契数列的项。不需要保留任何状态来生成下一个项,因为状态保存在协程中。
293 |
294 | 需要注意的是,即使函数执行了无限循环,由于它是协程,会一次又一次地暂停和恢复,避免阻塞当前线程。
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
--------------------------------------------------------------------------------
/book/content/part4/chapter10/4.tex:
--------------------------------------------------------------------------------
1 | 正如本章中已经看到的, Boost.Cobalt promise是返回一个值的立即协程,而 Boost.Cobalt 任务是惰性版本的promise。
2 |
3 | 我们可以将它们视为不会像生成器那样产生多个值的函数,可以反复调用 Promise 来获取多个值,但调用之间不会保留状态(就像生成器一样)。基本上, Promise 是一个可以使用 co\_await 的协程(也可以使用 co\_return)。
4 |
5 | Promise 的不同用例包括套接字侦听器接收网络数据包、处理数据包、查询数据库,然后从数据中生成一些结果。一般来说,其功能需要异步等待某个结果,然后对该结果执行某些处理(或者可能只是将其返回给调用者) 。
6 |
7 | 第一个例子是一个简单的promise,可生成一个随机数(也可以用生成器来完成):
8 |
9 | \begin{cpp}
10 | #include
11 | #include
12 |
13 | #include
14 |
15 | boost::cobalt::promise random_number(int min, int max) {
16 | std::random_device rd;
17 | std::mt19937 gen(rd());
18 |
19 | std::uniform_int_distribution<> dist(min, max);
20 | co_return dist(gen);
21 | }
22 |
23 | boost::cobalt::promise random(int min, int max) {
24 | int res = co_await random_number(min, max);
25 | co_return res;
26 | }
27 |
28 | boost::cobalt::main co_main(int, char*[]) {
29 | for (int i = 0; i < 10; ++i) {
30 | auto r = random(1, 100);
31 | std::cout << "random number between 1 and 100: "
32 | << co_await r << std::endl;
33 | }
34 | co_return 0;
35 | }
36 | \end{cpp}
37 |
38 | 上面的代码中,编写了三个协程:
39 |
40 | \begin{itemize}
41 | \item
42 | co\_main:Boost.Cobalt 中, co\_main 是一个协同程序,调用 co\_return 来返回一个值。
43 |
44 | \item
45 | random():此协程向调用者返回一个随机数。使用 co\_await 调用 random() 来生成随机数,异步等待随机数生成。
46 |
47 | \item
48 | random\_number():该协程在两个值(最小值和最大值)之间生成一个均匀分布的随机数,并将其返回给调用者。 random\_number() 也是一个promise。
49 | \end{itemize}
50 |
51 | 以下协程返回一个随机数的 std::vector。循环调用 co\_await random\_number() 来生成一个包含 n 个随机数的向量:
52 |
53 | \begin{cpp}
54 | boost::cobalt::promise> random_vector(int min, int
55 | max, int n) {
56 | std::vector rv(n);
57 | for (int i = 0; i < n; ++i) {
58 | rv[i] = co_await random_number(min, max);
59 | }
60 | co_return rv;
61 | }
62 | \end{cpp}
63 |
64 | 上述函数返回 std::vector 的promise。要访问该向量,需要调用 get():
65 |
66 | \begin{cpp}
67 | auto v = random_vector(1, 100, 20);
68 | for (int n : v.get()) {
69 | std::cout << n << " ";
70 | }
71 | std::cout << std::endl;
72 | \end{cpp}
73 |
74 | 上述代码打印了 v 向量的元素。要访问该向量,需要调用 v.get()。
75 |
76 | 实现第二个示例来说明promise和任务的执行有何不同:
77 |
78 | \begin{cpp}
79 | #include
80 | #include
81 | #include
82 |
83 | #include
84 |
85 | void sleep(){
86 | std::this_thread::sleep_for(std::chrono::seconds(2));
87 | }
88 |
89 | boost::cobalt::promise eager_promise(){
90 | std::cout << "Eager promise started\n";
91 | sleep();
92 | std::cout << "Eager promise done\n";
93 | co_return 1;
94 | }
95 |
96 | boost::cobalt::task lazy_task(){
97 | std::cout << "Lazy task started\n";
98 | sleep();
99 | std::cout << "Lazy task done\n";
100 | co_return 2;
101 | }
102 |
103 | boost::cobalt::main co_main(int, char*[]){
104 | std::cout << "Calling eager_promise...\n";
105 | auto promise_result = eager_promise();
106 | std::cout << "Promise called, but not yet awaited.\n";
107 |
108 | std::cout << "Calling lazy_task...\n";
109 | auto task_result = lazy_task();
110 | std::cout << "Task called, but not yet awaited.\n";
111 |
112 | std::cout << "Awaiting both results...\n";
113 | int promise_value = co_await promise_result;
114 | std::cout << "Promise value: " << promise_value
115 | << std::endl;
116 |
117 | int task_value = co_await task_result;
118 | std::cout << "Task value: " << task_value
119 | << std::endl;
120 |
121 | co_return 0;
122 | }
123 | \end{cpp}
124 |
125 | 这个例子中,实现了两个协程:一个 Promise 和一个 Task。Promise 是 Eager 类型的,它在调用时立即开始执行。而 Task 是 Lazy 类型的,在调用后就会暂停。
126 |
127 | 运行程序时,会输出所有消息,可确切地知道协程是如何执行的。
128 |
129 | co\_main()前三行执行完成后,输出如下:
130 |
131 | \begin{shell}
132 | Calling eager_promise...
133 | Eager promise started
134 | Eager promise done
135 | Promise called, but not yet awaited.
136 | \end{shell}
137 |
138 | 从这些消息中,知道promise已经执行了,直到调用co\_return。
139 |
140 | 执行 co\_main() 的接下来三行之后,输出有以下新消息:
141 |
142 | \begin{shell}
143 | Calling lazy_task...
144 | Task called, but not yet awaited.
145 | \end{shell}
146 |
147 | 这里,我们看到任务尚未执行。这是一个惰性协程,在调用后会立即挂起,并且此协程尚未输出任何消息。
148 |
149 | 再执行三行 co\_main(),程序输出的新消息如下:
150 |
151 | \begin{shell}
152 | Awaiting both results...
153 | Promise value: 1
154 | \end{shell}
155 |
156 | 对promise的 co\_await 调用给出了其结果(在本例中设置为 1)并且其执行结束。
157 |
158 | 最后,在任务上调用 co\_await,然后它执行并返回其值(在本例中设置为 2)。输出如下:
159 |
160 | \begin{shell}
161 | Lazy task started
162 | Lazy task done
163 | Task value: 2
164 | \end{shell}
165 |
166 | 此示例显示了任务如何惰性化并开始暂停,并且仅当调用者对其调用 co\_await 时才恢复执行。
167 |
168 | 本节中,与生成器的情况一样,使用 Boost.Cobalt 编写promise和任务协程比仅使用纯 C++ 要容易得多,不需要编写 C++ 实现协程所需的所有支持代码。还了解了任务和promise的区别。
169 |
170 | 下一节中,将研究通道的示例,它是生产者/消费者模型中两个协程之间的通信机制。
171 |
172 |
--------------------------------------------------------------------------------
/book/content/part4/chapter10/5.tex:
--------------------------------------------------------------------------------
1 | Boost.Cobalt 中,通道为协程提供了一种异步通信方式,允许以安全高效的方式在生产者和消费者协程之间传输数据。其受到 Golang 通道的启发,允许通过消息传递进行通信,从而促进通过通信共享内存的范式。
2 |
3 | 通道是一种机制,通过该机制,值从一个协程(生产者)异步传递到另一个协程(消费者)。这种通信是非阻塞的,所以协程可以在等待通道上的数据可用时,或将数据写入容量有限的通道时暂停执行。澄清一下:如果阻塞是指协程暂停,则读取和写入操作都可能阻塞,具体取决于缓冲区大小,但另一方面,从线程的角度来看,这些操作不会阻塞线程。
4 |
5 | 如果缓冲区大小为零,则读取和写入需要同时发生并充当会合点(同步通信)。如果通道大小大于零且缓冲区未满,则写入操作不会暂停协程。同样,如果缓冲区不为空,则读取操作也不会暂停。
6 |
7 | 与 Golang 通道类似, Boost.Cobalt 通道是强类型的。通道是为特定类型定义的,并且只能通过该类型发送数据。例如, int 类型的通道(boost::cobalt::channel)只能传输整数。
8 |
9 | 来看一个通道的示例:
10 |
11 | \begin{cpp}
12 | #include
13 | #include
14 | #include
15 | boost::cobalt::promise producer(boost::cobalt::channel& ch)
16 | {
17 | for (int i = 1; i <= 10; ++i) {
18 | std::cout << "Producer waiting for request\n";
19 | co_await ch.write(i);
20 | std::cout << "Producing value " << i << std::endl;
21 | }
22 | std::cout << "Producer end\n";
23 | ch.close();
24 | co_return;
25 | }
26 | boost::cobalt::main co_main(int, char*[]) {
27 | boost::cobalt::channel ch;
28 | auto p = producer(ch);
29 | while (ch.is_open()) {
30 | std::cout << "Consumer waiting for next number \n";
31 | std::this_thread::sleep_for(std::chrono::seconds(5));
32 | auto n = co_await ch.read();
33 | std::cout << "Consuming value " << n << std::endl;
34 | std::cout << n * n << std::endl;
35 | }
36 | co_await p;
37 | co_return 0;
38 | }
39 | \end{cpp}
40 |
41 | 这个例子中,创建一个大小为 0 的通道和两个协程:生产者promise和充当消费者的 co\_main()。生产者将整数写入通道,消费者将其读回并输出它们的平方。
42 |
43 | 我们添加了 std::this\_thread::sleep 来延迟程序执行,因此能够看到程序运行时发生的情况。来看一下示例输出的摘录,看看它是如何工作的:
44 |
45 | \begin{shell}
46 | Producer waiting for request
47 | Consumer waiting for next number
48 | Producing value 1
49 | Producer waiting for request
50 | Consuming value 1
51 | 1
52 | Consumer waiting for next number
53 | Producing value 2
54 | Producer waiting for request
55 | Consuming value 2
56 | 4
57 | Consumer waiting for next number
58 | Producing value 3
59 | Producer waiting for request
60 | Consuming value 3
61 | 9
62 | Consumer waiting for next number
63 | \end{shell}
64 |
65 | 消费者和生产者都等待下一个操作发生,生产者将始终等待消费者请求下一个项目。这基本上就是生成器的工作方式,也是使用协程的异步代码中的常见模式。
66 |
67 | 消费者执行以下代码行:
68 |
69 | \begin{cpp}
70 | auto n = co_await ch.read();
71 | \end{cpp}
72 |
73 | 然后,生产者将下一个数字写入通道并等待下一个请求。这在以下代码行中完成:
74 |
75 | \begin{cpp}
76 | co_await ch.write(i);
77 | \end{cpp}
78 |
79 | 可以在前面的输出摘录的第四行中看到生产者如何返回等待下一个请求。Boost.Cobalt 通道使编写这种异步代码非常干净且易于理解。该示例显示两个协程通过通道进行通信。
80 |
81 | 下一节将介绍同步函数 - 等待多个协程的机制。
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/book/content/part4/chapter10/6.tex:
--------------------------------------------------------------------------------
1 | 以前,实现了协程,并且在每次调用 co\_await 时,只对一个协程进行调用,所以只等待一个协程的结果。 Boost.Cobalt 具有允许等待多个协程的机制,这些机制称为同步函数。
2 |
3 | Boost.Cobalt 中实现了四个同步函数:
4 |
5 | \begin{itemize}
6 | \item
7 | race: 等待一组协程中的一个完成,但其以伪随机方式执行。此机制有助于避免协程的匮乏,确保一个协程不会主导其他协程的执行流程。当有多个异步操作并且希望第一个完成以确定流程时, race 将允许准备好的协程以不确定的顺序继续执行。
8 |
9 | 当有多个任务(一般意义上的任务,而不是 Boost.Cobalt 任务)并且有兴趣首先完成一个任务,而不优先考虑哪一个,但想防止一个协程在同时准备就绪的情况下总是获胜时将使用竞争。
10 |
11 | \item
12 | join: 等待给定集合中的所有协程完成并将其结果作为值返回。如果协程抛出异常, join 会将异常传播给调用者。这是一种从多个异步操作中收集结果的方法,这些操作必须全部完成才能继续。
13 |
14 | 当需要多个异步操作的结果在一起,并且希望在其中任何一个失败时抛出错误时,可使用 join。
15 |
16 | \item
17 | gather: 与 join 类似,会等待一组协程完成,但其处理异常的方式不同。 gather 不会在其中一个协程失败时立即抛出异常,而是会单独捕获每个协程的结果,所以可以单独检查每个协程的结果(成功或失败)。
18 |
19 | 当需要所有异步操作完成,但想要单独捕获所有结果和异常以分别处理它们时,可使用 gather。
20 |
21 | \item
22 | left\_race: 与 race 类似,但具有确定性行为。它从左到右评估协程,并等待第一个协程准备就绪。当协程完成的顺序很重要,并且希望根据提供协程的顺序确保可预测的结果时,这会很有用。
23 |
24 | 当有多个潜在结果并且需要按照提供的顺序支持第一个可用的协同程序时,会使行为比 race 更可预测,可使用 left\_race。
25 | \end{itemize}
26 |
27 | 本节中,将探讨 join 和 gather 函数的示例,这两个函数都会等待一组协程完成。它们之间的区别在于,如果协程抛出异常, join 就会抛出异常,而 gather 始终会返回所有等待的协程的结果。对于 gather 函数,每个协程的结果要么是错误(缺失值),要么是值。 join 返回一个值元组或抛出异常; gather 返回一个可选值元组,如果发生异常(可选变量未初始化),则该元组没有值。
28 |
29 | 以下示例的完整代码位于 GitHub库中,书中将重点介绍主要部分。
30 |
31 | 我们定义了一个简单的函数来模拟数据处理,这只是一个延迟。如果传递大于 5,000 毫秒的延迟,该函数将引发异常:
32 |
33 | \begin{cpp}
34 | boost::cobalt::promise
35 | process(std::chrono::milliseconds ms) {
36 | if (ms > std::chrono::milliseconds(5000)) {
37 | throw std::runtime_error("delay throw");
38 | }
39 |
40 | boost::asio::steady_timer tmr{ co_await boost::cobalt::this_coro::executor, ms };
41 | co_await tmr.async_wait(boost::cobalt::use_op);
42 | co_return ms.count();
43 | }
44 | \end{cpp}
45 |
46 | 该功能是 Boost.Cobalt 的promise。
47 |
48 | 现在,在代码的下一部分中,将等待这个promise的三个实例运行:
49 |
50 | \begin{cpp}
51 | auto result = co_await boost::cobalt::join(process(100ms),
52 | process(200ms),
53 | process(300ms));
54 |
55 | std::cout << "First coroutine finished in: "
56 | << std::get<0>(result) << "ms\n";
57 | std::cout << "Second coroutine took finished in: "
58 | << std::get<1>(result) << "ms\n";
59 | std::cout << "Third coroutine took finished in: "
60 | << std::get<2>(result) << "ms\n";
61 | \end{cpp}
62 |
63 | 上述代码调用 join 等待三个协程执行完毕,然后打印出执行时间。结果是一个元组,为了代码尽可能简洁,只对每个元素调用 std::get(result)。这样,所有执行时间都在有效范围内,也没有抛出异常,因此可以得到所有执行协程的结果。
64 |
65 | 如果抛出异常,那么将不会得到任何值:
66 |
67 | \begin{cpp}
68 | try {
69 | auto result throw = co_await
70 | boost::cobalt::join(process(100ms),
71 | process(20000ms),
72 | process(300ms));
73 | }
74 | catch (...) {
75 | std::cout << "An exception was thrown\n";
76 | }
77 | \end{cpp}
78 |
79 | 上述代码将抛出异常,因为第二个协程收到的处理时间超出了有效范围,将输出一条错误消息。当调用 join 函数时,希望所有协程都被视为处理的一部分,并且如果发生异常,则整个处理失败。
80 |
81 | 如果需要获取每个协程的所有结果,将使用 gather 函数:
82 |
83 | \begin{cpp}
84 | try{
85 | auto result throw =
86 | boost::cobalt::co_await lt::gather(process(100ms),
87 | process(20000ms),
88 | process(300ms));
89 |
90 | if (std::get<0>(result throw).has value()) {
91 | std::cout << "First coroutine took: "
92 | << *std::get<0>(result throw)
93 | << "msec\n";
94 | }
95 | else {
96 | std::cout << "First coroutine threw an exception\n";
97 | }
98 | if (std::get<1>(result throw).has value()) {
99 | std::cout << "Second coroutine took: "
100 | << *std::get<1>(result throw)
101 | << "msec\n";
102 | }
103 | else {
104 | std::cout << "Second coroutine threw an exception\n";
105 | }
106 | if (std::get<2>(result throw).has value()) {
107 | std::cout << "Third coroutine took: "
108 | << *std::get<2>(result throw)
109 | << "msec\n";
110 | }
111 | else {
112 | std::cout << "Third coroutine threw an exception\n";
113 | }
114 | }
115 | catch (...) {
116 | // this is never reached because gather doesn't throw exceptions
117 | std::cout << "An exception was thrown\n";
118 | }
119 | \end{cpp}
120 |
121 | 我们把代码放在了 try-catch 块中,但没有抛出任何异常。 gather 函数返回一个可选值的元组,需要检查每个协程是否返回了一个值(可选值是否有值)。
122 |
123 | 当希望协程在成功执行时返回一个值时,则使用 gather。
124 |
125 | 这些join 和gather函数的示例结束了对 Boost.Cobalt 同步函数的介绍。
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
--------------------------------------------------------------------------------
/book/content/part4/chapter10/7.tex:
--------------------------------------------------------------------------------
1 | 本章中,了解了如何使用 Boost.Cobalt 库实现协程,是最近才添加到 Boost 中的,关于它的信息并不多。它简化了使用协程开发异步代码的过程,避免了编写 C++20 协程所需的底层代码。
2 |
3 | 我们研究了主要的图书馆概念并开发了一些简单的示例来理解它们。
4 |
5 | 使用 Boost.Cobalt,使用协程编写异步代码变得简单。在 C++ 中编写协程的所有低级细节都由库实现,可以只关注我们想要在程序中实现的功能。
6 |
7 | 下一章中,将介绍如何调试异步代码。
--------------------------------------------------------------------------------
/book/content/part4/chapter10/8.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | \begin{itemize}
4 | \item
5 | Boost.Cobalt reference: Boost.Cobalt reference guide (\url{https://www.boost.org/doc/libs/1_86_0/libs/cobalt/doc/html/index.html#overview})
6 |
7 | \item
8 | A YouTube video on Boost.Cobalt: Using coroutines with Boost.Cobalt (\url{https://www.youtube.com/watch?v=yElSdUqEvME})
9 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part4/chapter9/0.tex:
--------------------------------------------------------------------------------
1 | Boost.Asio 是著名的 Boost 库系列中的一个 C++ 库,简化了处理由操作系统(OS)管理的异步输入/输出(I/O)任务的解决方案的开发,从而更容易开发处理内部和外部资源的异步软件,例如:网络通信服务或文件操作。
2 |
3 | 为此, Boost.Asio 定义了 OS 服务(属于 OS 并由 OS 管理的服务)、 I/O 对象(提供 OS 服务的接口)和 I/O 执行上下文对象(充当服务注册表和代理的对象)。
4 |
5 | 在接下来的内容中,将介绍 Boost.Asio,介绍它的主要构建块,并解释一些使用该库开发异步软件的常见模式,这些模式在业界中广泛使用。
6 |
7 | 本章中,将讨论以下主要主题:
8 |
9 | \begin{itemize}
10 | \item
11 | Boost.Asio 是什么,以及它如何利用外部资源简化异步编程
12 |
13 | \item
14 | 什么是 I/O 对象和 I/O 执行上下文,如何与 OS 服务交互,以及相互之间如何交互
15 |
16 | \item
17 | Proactor 和 Reactor 设计模式是什么,以及与 Boost.Asio 有何关系
18 |
19 | \item
20 | 如何保证程序线程安全,以及如何使用 strand 序列化任务
21 |
22 | \item
23 | 如何使用缓冲区高效地将数据传递给异步任务
24 |
25 | \item
26 | 如何取消异步操作
27 |
28 | \item
29 | 计时器和网络应用程序的实践示例
30 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part4/chapter9/1.tex:
--------------------------------------------------------------------------------
1 | 对于本章,需要安装 Boost C++ 库。撰写本书时最新版本是 Boost 1.85.0。以下是发行说明:
2 |
3 | \url{https://www.boost.org/users/history/version_1_85_0.html}
4 |
5 | 有关 Unix 变体系统(Linux、 macOS)中的安装说明,请查看以下链接:
6 |
7 | \url{https://www.boost.org/doc/libs/1_85_0/more/getting_started/unix-variants.html}
8 |
9 | 对于 Windows 系统,请查看此链接:
10 |
11 | \url{https://www.boost.org/doc/libs/1_85_0/more/getting_started/windows.html}
12 |
13 | 此外,根据要开发的项目,可能需要配置 Boost.Asio 或安装依赖项:
14 |
15 | \url{https://www.boost.org/doc/libs/1_85_0/doc/html/boost_asio/using.html}
16 |
17 | 本章中显示的所有代码均受 C++20 版本的支持。请查看第 3 章中的技术要求部分,其中提供了有关如何安装 GCC 13 和 Clang 8 编译器的一些指导。
18 |
19 | 可以在以下 GitHub 库中找到完整的代码:
20 |
21 | \url{https://github.com/PacktPublishing/Asynchronous-Programming-with-CPP}
22 |
23 | 本章的示例位于 Chapter\_09 文件夹下。所有源代码文件都可以使用 CMake 进行编译:
24 |
25 | \begin{shell}
26 | cmake . && cmake —build .
27 | \end{shell}
28 |
29 | 可执行二进制文件将在 bin 目录下生成。
30 |
31 |
--------------------------------------------------------------------------------
/book/content/part4/chapter9/11.tex:
--------------------------------------------------------------------------------
1 | Boost.Asio 自 1.56.0 版本起还包含对协程的支持,并从 1.75.0 版本起支持本机协程。
2 |
3 | 使用协程可以简化程序的编写方式,因为无需添加完成处理程序并将程序流程拆分为不同的异步函数和回调。相反,使用协程时,程序遵循顺序结构,其中异步操作调用会暂停协程的执行。当异步操作完成时,协程将恢复,让程序从之前暂停的位置继续执行。
4 |
5 | 在较新的版本(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 协程,而且代码也更现代、更干净、更易读,将在本节中重点介绍这种方法。
6 |
7 | 再次实现回显服务器,但这次使用 Boost.Asio 的可等待接口和协程。还将添加一些改进,例如:支持在发送 QUIT 命令时从客户端关闭连接,显示如何在服务器端处理数据或命令,以及在抛出异常时停止处理连接并退出。
8 |
9 | 从实现 main() 函数开始,程序首先使用 boost::asio::co\_spawn 创建一个基于协程的新线程。此函数接受执行上下文(io\_context,也可以使用 strand)、具有 boost::asio::awaitable 返回类型的函数(将用作协程的入口点)(将在下文实现和解释的 listener() 函数),以及将在线程完成时调用的完成令牌作为参数。如果想在不收到完成通知的情况下运行协程,可以传递 boost::asio::detached 令牌。
10 |
11 | 最后,通过调用io\_context.run()开始处理异步事件。
12 |
13 | 如果发生异常,将被 try-catch 块捕获,并通过调用 io\_context.stop() 停止事件处理循环:
14 |
15 | \begin{cpp}
16 | #include
17 | #include
18 | #include
19 | #include
20 |
21 | using boost::asio::ip::tcp;
22 |
23 | int main() {
24 | boost::asio::io_context io_context;
25 | try {
26 | boost::asio::co_spawn(io_context,
27 | listener(io_context, 12345),
28 | boost::asio::detached);
29 | io_context.run();
30 | } catch (std::exception& e) {
31 | std::cerr << "Error: " << e.what() << std::endl;
32 | io_context.stop();
33 | }
34 | return 0;
35 | }
36 | \end{cpp}
37 |
38 | listener() 函数接收一个 io\_context 对象和侦听器将接受连接的端口号作为参数,使用前面解释过的接受器对象。它还必须具有 boost::asio::awaitable 的返回类型,其中 R 是协程的返回类型, E 是可能抛出的异常类型。E 设置为默认值,因此未明确指定。
39 |
40 | 通过调用 async\_accept 接受函数来接受连接。由于现在使用协程,需要为异步函数指定 boost::asio::use\_awaitable,并使用 co\_await 停止协程执行,直到异步任务完成后才恢复。
41 |
42 | 当侦听器协程任务恢复时,acceptor.async\_accept() 返回一个套接字对象。协程继续生成一个新线程,使用 boost::asio::co\_spawn 函数,执行 echo() 函数,并将套接字对象传递给它:
43 |
44 | \begin{cpp}
45 | boost::asio::awaitable listener(boost::asio::io_context& io_context, unsigned short port) {
46 | tcp::acceptor acceptor(io_context,
47 | tcp::endpoint(tcp::v4(), port));
48 | while (true) {
49 | std::cout << "Accepting connections...\n";
50 | tcp::socket socket = co_await
51 | acceptor.async_accept(
52 | boost::asio::use_awaitable);
53 |
54 | std::cout << "Starting an Echo "
55 | << "connection handler...\n";
56 | boost::asio::co_spawn(io_context,
57 | echo(std::move(socket)),
58 | boost::asio::detached);
59 | }
60 | }
61 | \end{cpp}
62 |
63 | echo() 函数负责处理单个客户端连接。必须遵循与 listener() 函数类似的签名,需要返回boost::asio::awaitable 类型。如前所述,套接字对象从侦听器移入此函数。
64 |
65 | 该函数以异步方式从套接字读取内容并将其写回无限循环中,只有在收到 QUIT 命令或引发异常时才会完成。
66 |
67 | 异步读取是通过使用 socket.async\_read\_some() 函数完成的,该函数使用 boost::asio::buffer 将数据读入数据缓冲区并返回读取的字节数 (bytes\_read)。由于异步任务由协程管理,因此 bo ost::asio::use\_awaitable 可传递给异步操作。然后, co\_wait 只是指示协程引擎暂停执行,直到异步操作完成。
68 |
69 | 一旦接收到一些数据,协程就会恢复执行,检查是否真的有一些数据需要处理;否则,可通过退出循环来结束连接,从而退出 echo() 函数。
70 |
71 | 如果读取了数据,会将其转换为 std::string 以便于操作。会删除 \verb|\r\n| 结尾(如果存在),并将字符串与 QUIT 进行比较。
72 |
73 | 如果存在 QUIT,将执行异步写入,发送 "Good bye!" 消息,然后退出循环。否则,将接收到的数据发送回客户端。在这两种情况下,异步写入操作都是通过使用 boost::asio::async\_write() 函数执行的,传递套接字、 boost:asio::buffer 包装要发送的数据缓冲区,以及 boost::asio::use\_awaitable,就像异步读取操作一样。
74 |
75 | 然后,再次使用 co\_await 在执行操作时暂停协程的执行。完成后,协程将恢复并在新的循环迭代中重复以下步骤:
76 |
77 | \begin{cpp}
78 | boost::asio::awaitable echo(tcp::socket socket) {
79 | char data[1024];
80 |
81 | while (true) {
82 | std::cout << "Reading data from socket...\n";
83 | std::size_t bytes_read = co_await
84 | socket.async_read_some(
85 | boost::asio::buffer(data),
86 | boost::asio::use_awaitable);
87 |
88 | if (bytes_read == 0) {
89 | std::cout << "No data. Exiting loop...\n";
90 | break;
91 | }
92 |
93 | std::string str(data, bytes_read);
94 | if (!str.empty() && str.back() == '\n') {
95 | str.pop_back();
96 | }
97 | if (!str.empty() && str.back() == '\r') {
98 | str.pop_back();
99 | }
100 |
101 | if (str == "QUIT") {
102 | std::string bye("Good bye!\n");
103 | co_await boost::asio::async_write(socket,
104 | boost::asio::buffer(bye),
105 | boost::asio::use_awaitable);
106 | break;
107 | }
108 |
109 | std::cout << "Writing '" << str
110 | << "' back into the socket...\n";
111 | co_await boost::asio::async_write(socket,
112 | boost::asio::buffer(data,
113 | bytes_read),
114 | boost::asio::use_awaitable);
115 | }
116 | }
117 | \end{cpp}
118 |
119 | 协程循环直到没有读取到数据,当客户端关闭连接、收到QUIT命令,或者发生异常时都会发生这种情况。
120 |
121 | 始终使用异步操作来确保服务器保持响应,即使同时处理多个客户端。
122 |
123 |
124 |
--------------------------------------------------------------------------------
/book/content/part4/chapter9/12.tex:
--------------------------------------------------------------------------------
1 | 本章中,介绍了 Boost.Asio ,以及如何使用该库来管理处理操作系统管理的外部资源的异步任务。
2 |
3 | 为此,介绍了 I/O 对象和 I/O 执行上下文对象,并深入解释了它们如何工作和交互、如何访问和与 OS 服务通信、它们背后的设计原则,以及如何在单线程和多线程应用程序中正确使用它们。
4 |
5 | 还展示了 Boost.Asio 中可用的不同技术,使用 strands 序列化工作,管理异步操作所使用的对象的生命周期,如何启动、中断或取消任务,如何管理库使用的事件处理循环,以及如何处理操作系统发送的信号。
6 |
7 | 还介绍了与网络和协同程序相关的其他概念,并且还使用这个强大的库实现了一些有用的示例。
8 |
9 | 所有这些概念和示例更深入地了解如何管理 C++ 中的异步任务,以及广泛使用的库如何在后台工作以实现这一目标。
10 |
11 | 下一章中,将介绍另一个 Boost 库 Boost.Cobalt,它提供了丰富的高级接口来开发基于协程的异步软件。
--------------------------------------------------------------------------------
/book/content/part4/chapter9/13.tex:
--------------------------------------------------------------------------------
1 | \begin{itemize}
2 | \item
3 | Boost.Asio official site: \url{https://www.boost.org/doc/libs/1_85_0/doc/html/boost_asio.html}
4 |
5 | \item
6 | Boost.Asio reference: \url{https://www.boost.org/doc/libs/1_85_0/doc/html/boost_asio/reference.html}
7 |
8 | \item
9 | Boost.Asio revision history: \url{https://www.boost.org/doc/libs/1_85_0/doc/html/boost_asio/history.html}
10 |
11 | \item
12 | Boost.Asio BSD socket API: \url{https://www.boost.org/doc/libs/1_85_0/doc/html/boost_asio/overview/networking/bsd_sockets.html}
13 |
14 | \item
15 | BSD socket API: \url{https://web.mit.edu/macdev/Development/MITSupportLib/SocketsLib/Documentation/sockets.html}
16 |
17 | \item
18 | The Boost C++ Libraries, Boris Schälig: \url{https://theboostcpplibraries.com/boost.asio}
19 |
20 | \item
21 | Thinking Asynchronously: Designing Applications with Boost.Asio, Christopher Kohlhoff: \url{https://www.youtube.com/watch?v=D-lTwGJRx0o}
22 |
23 | \item
24 | CppCon 2016: Asynchronous IO with Boost.Asio, Michael Caisse: \url{https://www.youtube.com/watch?v=rwOv_tw2eA4}
25 |
26 | \item
27 | Pattern-Oriented Software Architecture – Patterns for Concurrent and Networked Objects, Volume 2, D. Schmidt et al, Wiley, 2000
28 |
29 | \item
30 | Boost.Asio C++ Network Programming Cookbook, Dmytro Radchuk, Packt Publishing, 2016
31 |
32 | \item
33 | Proactor: An Object Behavioral Pattern for Demultiplexing and Dispatching handlers for Asynchronous events, Irfan Pyarali, Tim Harrison, Douglas C Schmidt, Thomas D Jordan. 1997
34 |
35 | \item
36 | Reactor: An Object Behavioral Pattern for Demultiplexing and Dispatching Handlers for Synchronous events, Douglas C Schmidt, 1995
37 |
38 | \item
39 | Input/Output Completion Port: \url{https://en.wikipedia.org/wiki/Input/output_completion_port}
40 |
41 | \item
42 | kqueue: \url{https://en.wikipedia.org/wiki/Kqueue}
43 |
44 | \item
45 | epoll: \url{https://en.wikipedia.org/wiki/Epoll}
46 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part4/chapter9/3.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | Boost.Asio 可以使用同步和异步操作与 I/O 服务交互,来了解它们的行为方式以及主要区别是什么。
4 |
5 | \mySubsubsection{9.3.1.}{同步操作}
6 |
7 | 如果程序想要以同步方式使用 I/O 服务,通常它会创建一个 I/O 对象并使用其同步操作方法:
8 |
9 | \begin{cpp}
10 | boost::asio::io_context io_context;
11 | boost::asio::steady_timer timer(io_context, 3s);
12 | timer.wait();
13 | \end{cpp}
14 |
15 | 调用 timer.wait() 时,请求将发送到 I/O 执行上下文对象 (io\_context),该对象调用 OS 执行操作。 OS 完成任务后,将结果返回给 io\_context,然后 io\_context 将结果(如果出现问题,则为错误)转换回 I/O 对象(定时器)。错误类型为 boost::system::error\_code。如果发生错误,则会引发异常。
16 |
17 | 如果不想抛出异常,可以通过引用同步方法传递一个错误对象来捕获操作的状态并在之后进行检查:
18 |
19 | \begin{cpp}
20 | boost::system::error_code ec;
21 | Timer.wait(server_endpoint, ec);
22 | \end{cpp}
23 |
24 | \mySubsubsection{9.3.2.}{异步操作}
25 |
26 | 对于异步操作,还需要将完成处理程序传递给异步方法。此完成处理程序是一个可调用对象,当异步操作完成时,将由 I/O 上下文对象调用,通知程序有关结果或操作错误。
27 |
28 | 其签名如下:
29 |
30 | \begin{cpp}
31 | void completion_handler(const boost::system::error_code& ec);
32 | \end{cpp}
33 |
34 | 继续计时器示例,现在需要调用异步操作:
35 |
36 | \begin{cpp}
37 | socket.async_wait(completion_handler);
38 | \end{cpp}
39 |
40 | 再次, I/O 对象(计时器)将请求转发到 I/O 执行上下文对象(io\_context)。 io\_context 请求操作系统启动异步操作。
41 |
42 | 当操作完成后,操作系统将结果放入队列中, io\_context 正在监听该队列。然后, io\_context 将结果从队列中取出,将错误转换为错误代码对象,并触发完成处理程序以通知任务和结果的完成情况。
43 |
44 | 为了允许 io\_context 执行这些步骤,程序必须执行 boost::asio::io\_context::run()(或前面介绍的管理事件处理循环的类似函数),并在处理任何未完成的异步操作时阻塞当前线程。如前所述,如果没有待处理的异步操作, boost::asio::io\_context::run() 将退出。
45 |
46 | 完成处理程序必须是可复制构造的,所以必须有一个可用的复制构造函数。如果需要临时资源(例如:内存、线程或文件描述符),则会在调用完成处理程序之前释放此资源。这能够调用相同的操作而不会重叠资源使用,从而避免增加系统中的峰值资源使用率。
47 |
48 | \mySamllsubsection{错误处理}
49 |
50 | Boost.Asio 允许用户以两种不同的方式处理错误:使用错误代码或抛出异常。如果在调用 I/O 对象方法时传递对 boost::system::error\_code 对象的引用,则实现将通过该变量传递错误;否则,将抛出异常。
51 |
52 | 已经按照第一种方法通过检查错误代码实现了几个示例,现在来看看如何捕获异常。
53 |
54 | 以下示例创建一个有效期为三秒的计时器,io\_context 对象从后台线程 io\_thread 运行。当计时器通过调用其 async\_wait() 函数启动异步任务时,会传递 boost::asio::use\_future 参数,因此该函数返回一个Future对象 fut,稍后在 try-catch 块中使用该对象来调用其 get() 函数并检索存储的结果或异常,正如第 6 章中学到的那样。启动后异步操作,主线程等待一秒钟,计时器通过调用其 cancel() 函数取消操作。由于这发生在到期时间(三秒)之前,因此会引发异常:
55 |
56 | \begin{cpp}
57 | #include
58 | #include
59 | #include
60 | #include
61 | #include
62 |
63 | using namespace std::chrono_literals;
64 |
65 | int main() {
66 | boost::asio::io_context io_context;
67 | boost::asio::steady_timer timer(io_context, 1s);
68 |
69 | auto fut = timer.async_wait(
70 | boost::asio::use_future);
71 |
72 | std::thread io_thread([&io_context]() {
73 | io_context.run();
74 | });
75 |
76 | std::this_thread::sleep_for(3s);
77 |
78 | timer.cancel();
79 |
80 | try {
81 | fut.get();
82 | std::cout << "Timer expired successfully!\n";
83 | } catch (const boost::system::system_error& e) {
84 | std::cout << "Timer failed: "
85 | << e.code().message() << '\n';
86 | }
87 | io_thread.join();
88 | return 0;
89 | }
90 | \end{cpp}
91 |
92 | 捕获类型为boost::system::system\_error的异常,并输出其消息。如果计时器在异步操作完成后取消其操作(在本例中,通过使主线程休眠超过三秒),计时器将成功过期,并且不会抛出异常。
93 |
94 | 现在,了解了 Boost.Asio 的主要构建块以及如何相互作用,再来了解其实现背后的设计模式。
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
--------------------------------------------------------------------------------
/book/content/part4/chapter9/4.tex:
--------------------------------------------------------------------------------
1 | 使用事件处理应用程序时,可以遵循两种方法来设计并发解决方案: Reactor(反应堆) 和 Proactor(前摄器)设计模式。这些模式描述了处理事件所遵循的机制,表明如何发起、接收、解复用和分派这些事件。当系统收集并排队来自不同资源的 I/O 事件时,解复用这些事件,将它们分离并分派给正确的处理程序。
2 |
3 | Reactor 模式以同步、串行的方式对服务请求进行多路分解和分派。通常遵循非阻塞同步I/O 策略,如果操作可以执行则返回结果,如果系统没有资源来完成操作则返回错误。
4 |
5 | 而Proactor模式则允许以高效的异步方式解复用和调度服务请求,立即将控制权返回给调用者,表明操作已启动。然后,当操作完成时,调用系统将通知调用者。因此,Proactor模式将责任分配给两个任务:异步执行的长时间操作和处理结果并通常调用其他异步操作的完成处理程序。
6 |
7 | Boost.Asio通过使用以下元素实现了Proactor设计模式:
8 |
9 | \begin{itemize}
10 | \item
11 | 发起者:发起异步操作的 I/O 对象。
12 |
13 | \item
14 | 异步操作:由操作系统异步运行的任务。
15 |
16 | \item
17 | 异步操作处理器:执行异步操作并将结果排队到完成事件队列中。
18 |
19 | \item
20 | 完成事件队列:异步操作处理器推送事件,异步事件出队的事件队列。
21 |
22 | \item
23 | 异步事件多路分解器:这会阻止 I/O 上下文,等待事件,并将完成的事件返回给调用者。
24 |
25 | \item
26 | 完成处理程序:处理异步操作结果的可调用对象。
27 |
28 | \item
29 | Proactor:调用异步事件多路分解器来将事件从队列中取出,并将它们分派给完成处理程序。这就是 I/O 执行上下文所做的工作。
30 | \end{itemize}
31 |
32 | 图 9.3 清楚地显示了所有这些元素之间的关系:
33 |
34 | \myGraphic{1.0}{content/part4/chapter9/images/3.png}{图 9.3 – {} 前摄器设计模式}
35 |
36 | Proactor模式在封装并发机制、简化应用程序同步、提高性能的同时,分离了关注点。
37 |
38 | 另一方面,无法控制异步操作的调度方式和时间,也无法控制操作系统执行这些操作的效率。此外,由于完成事件队列的存在,内存使用量也会增加,调试和测试的复杂性也会增加。
39 |
40 | Boost.Asio 设计的另一个方面是执行上下文对象的线程安全性。现在,来深入研究 Boost.Asio 的线程工作原理。
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/book/content/part4/chapter9/5.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | I/O 执行上下文对象是线程安全的,其方法可以从不同的线程安全地调用。所以可以使用单独的线程来运行阻塞的 io\_context.run() 方法,并使主线程保持畅通,以继续执行其他不相关的任务。
4 |
5 | 现在,从如何使用线程的角度解释一下配置异步应用程序的不同方法。
6 |
7 | \mySubsubsection{9.5.1.}{单线程方法}
8 |
9 | Boost.Asio 应用程序的起点和首选解决方案都应遵循单线程方法,其中 I/O 执行上下文对象在处理完成处理程序的同一线程中运行,这些处理程序必须简短且无阻塞。以下是与 I/ O 上下文在同一线程(即主线程)中运行的稳定计时器完成处理程序的示例:
10 |
11 | \begin{cpp}
12 | #include
13 | #include
14 | #include
15 |
16 | using namespace std::chrono_literals;
17 |
18 | void handle_timer_expiry(
19 | const boost::system::error_code& ec) {
20 | if (!ec) {
21 | std::cout << "Timer expired!\n";
22 | } else {
23 | std::cerr << "Error in timer: "
24 | << ec.message() << std::endl;
25 | }
26 | }
27 |
28 | int main() {
29 | boost::asio::io_context io_context;
30 | boost::asio::steady_timer timer(io_context,
31 | std::chrono::seconds(1));
32 | timer.async_wait(&handle_timer_expiry);
33 | io_context.run();
34 | return 0;
35 | }
36 | \end{cpp}
37 |
38 | steady\_timer 计时器调用异步 async\_wait() 函数,设置 handle\_timer\_expiry() 完成处理程序,该函数在执行 io\_context.run() 函数的同一线程中。当异步函数完成时,其完成处理程序将在同一线程中运行。
39 |
40 | 由于完成处理程序在主线程中运行,其执行应该很快,以避免冻结主线程和程序应执行的其他相关任务。下一节中,将介绍如何处理长时间运行的任务,或完成处理程序并保持主线程响应。
41 |
42 | \mySubsubsection{9.5.2.}{线程化长时间运行的任务}
43 |
44 | 对于长时间运行的任务,可以将逻辑保留在主线程中,但使用其他线程传递工作并将结果返回到主线程:
45 |
46 | \begin{cpp}
47 | #include
48 | #include
49 | #include
50 |
51 | void long_running_task(boost::asio::io_context& io_context,
52 | int task_duration) {
53 | std::cout << "Background task started: Duration = "
54 | << task_duration << " seconds.\n";
55 | std::this_thread::sleep_for(
56 | std::chrono::seconds(task_duration));
57 | io_context.post([&io_context]() {
58 | std::cout << "Background task completed.\n";
59 | io_context.stop();
60 | });
61 | }
62 |
63 | int main() {
64 | boost::asio::io_context io_context;
65 |
66 | auto work_guard = boost::asio::make_work_guard
67 | (io_context);
68 |
69 | io_context.post([&io_context]() {
70 | std::thread t(long_running_task,
71 | std::ref(io_context), 2);
72 | std::cout << "Detaching thread" << std::endl;
73 | t.detach();
74 | });
75 |
76 | std::cout << "Running io_context...\n";
77 | io_context.run();
78 | std::cout << "io_context exit.\n";
79 | return 0;
80 | }
81 | \end{cpp}
82 |
83 | 这个例子中,创建 io\_context 之后,使用工作保护来避免 io\_context.run() 函数在发布工作之前立即返回。
84 |
85 | 已发布的工作包括创建 t 线程以在后台运行 long\_running\_task() 函数。该 t 线程在 lambda 函数退出之前分离;否则,程序将终止。
86 |
87 | 后台任务函数中,当前线程休眠一段时间,然后将另一个任务发布到 io\_context 对象中以输出消息并停止 io\_context 本身。如果不调用 io\_context.stop(),事件处理循环将永远继续运行,程序将无法完成, io\_context.run() 将继续因工作保护而阻塞。
88 |
89 | \mySubsubsection{9.5.3.}{每线程一个 I/O 执行上下文对象}
90 |
91 | 这种方法类似于单线程方法,其中每个线程都有自己的 io\_context 对象,并处理简短且非阻塞的完成处理程序:
92 |
93 | \begin{cpp}
94 | #include
95 | #include
96 | #include
97 | #include
98 | #include
99 |
100 | #define sync_cout std::osyncstream(std::cout)
101 |
102 | using namespace std::chrono_literals;
103 |
104 | void background_task(int i) {
105 | sync_cout << "Thread " << i << ": Starting...\n";
106 | boost::asio::io_context io_context;
107 | auto work_guard =
108 | boost::asio::make_work_guard(io_context);
109 |
110 | sync_cout << "Thread " << i << ": Setup timer...\n";
111 | boost::asio::steady_timer timer(io_context, 1s);
112 | timer.async_wait(
113 | [&](const boost::system::error_code& ec) {
114 | if (!ec) {
115 | sync_cout << "Timer expired successfully!"
116 | << std::endl;
117 | } else {
118 | sync_cout << "Timer error: "
119 | << ec.message() << '\n';
120 | }
121 | work_guard.reset();
122 | });
123 |
124 | sync_cout << "Thread " << i << ": Running io_context...\n";
125 | io_context.run();
126 | }
127 |
128 | int main() {
129 | const int num_threads = 4;
130 | std::vector threads;
131 |
132 | for (auto i = 0; i < num_threads; ++i) {
133 | threads.emplace_back(background_task, i);
134 | }
135 | return 0;
136 | }
137 | \end{cpp}
138 |
139 | 此示例中,创建了四个线程,每个线程运行 background\_task() 函数,其中创建一个 io\_context 对象,并设置一个计时器,与其完成处理程序一起在一秒后超时。
140 |
141 | \mySubsubsection{9.5.4.}{多线程使用一个 I/O 执行上下文对象}
142 |
143 | 现在,只有一个 io\_context 对象,正在从不同线程的不同 I/O 对象启动异步任务。这种情况下,可以从任何这些线程调用完成处理程序。以下是一个例子:
144 |
145 | \begin{cpp}
146 | #include
147 | #include
148 | #include
149 | #include
150 | #include
151 | #include
152 |
153 | #define sync_cout std::osyncstream(std::cout)
154 |
155 | using namespace std::chrono_literals;
156 |
157 | void background_task(int task_id) {
158 | boost::asio::post([task_id]() {
159 | sync_cout << "Task " << task_id
160 | << " is being handled in thread "
161 | << std::this_thread::get_id()
162 | << std::endl;
163 | std::this_thread::sleep_for(2s);
164 | sync_cout << "Task " << task_id
165 | << " complete.\n";
166 | });
167 | }
168 |
169 | int main() {
170 | boost::asio::io_context io_context;
171 | auto work_guard = boost::asio::make_work_guard(
172 | io_context);
173 | std::jthread io_context_thread([&io_context]() {
174 | io_context.run();
175 | });
176 |
177 | const int num_threads = 4;
178 | std::vector threads;
179 | for (int i = 0; i < num_threads; ++i) {
180 | background_task(i);
181 | }
182 |
183 | std::this_thread::sleep_for(5s);
184 | work_guard.reset();
185 |
186 | return 0;
187 | }
188 | \end{cpp}
189 |
190 | 在此示例中,仅创建了一个 io\_context 对象,并在单独的线程 io\_context\_thread 中运行。然后,创建另外四个后台线程,将工作发布到 io\_context 对象中。最后,主线程等待五秒钟,让所有线程完成工作并重置工作保护。如果没有更多待处理工作,则让 io\_context.run() 函数返回。当程序退出时,所有线程都会自动汇入(是 std::jthread 的实例)。
191 |
192 | \mySubsubsection{9.5.5.}{单个 I/O 执行上下文并行完成工作}
193 |
194 | 上例中,使用了一个唯一的 I/O 执行上下文对象,其 run() 函数从不同的线程调用。然后,每个线程在完成时发布完成处理程序在可用线程中执行的一些工作。
195 |
196 | 这是并行化一个 I/O 执行上下文所做工作的常用方法,即从多个线程调用其 run() 函数,将异步操作的处理分布在这些线程中。因为 io\_context 对象提供了一个线程安全的事件调度系统,所以可能。
197 |
198 | 下面是另一个示例,其中创建了一个线程池,每个线程都运行 io\_context.run(),使这些线程竞争从队列中拉取任务并执行。这种情况下,使用两秒后到期的计时器仅创建一个异步任务。其中一个线程将拾取任务并执行:
199 |
200 | \begin{cpp}
201 | #include
202 | #include
203 | #include
204 | #include
205 |
206 | using namespace std::chrono_literals;
207 |
208 | int main() {
209 | boost::asio::io_context io_context;
210 |
211 | boost::asio::steady_timer timer(io_context, 2s);
212 | timer.async_wait(
213 | [](const boost::system::error_code& /*ec*/) {
214 | std::cout << "Timer expired!\n";
215 | });
216 |
217 | const std::size_t num_threads =
218 | std::thread::hardware_concurrency();
219 | std::vector threads;
220 | for (std::size_t i = 0;
221 | i < std::thread::hardware_concurrency(); ++i) {
222 | threads.emplace_back([&io_context]() {
223 | io_context.run();
224 | });
225 | }
226 | for (auto& t : threads) {
227 | t.join();
228 | }
229 | return 0;
230 | }
231 | \end{cpp}
232 |
233 | 这种技术提高了可扩展性,应用程序可以更好地利用多个内核,并通过同时处理异步任务来减少延迟。此外,通过减少单线程代码处理许多同时 I/O 操作时产生的瓶颈,可以减少争用并提高吞吐量。
234 |
235 | 注意,完成处理程序在不同线程之间共享或修改共享资源,则也必须使用同步原语并且是线程安全的。
236 |
237 | 此外,无法保证完成处理程序的执行顺序。由于许多线程可以同时运行,所以其中一个线程都可以先完成并调用其关联的完成处理程序。
238 |
239 | 当线程竞争从队列中提取任务时,如果线程池大小不是最佳的(理想情况下与硬件线程数匹配,如本例所示),则可能存在潜在的锁争用或上下文切换开销。
240 |
241 | 现在,是时候了解对象的生命周期如何影响使用 Boost.Asio 开发的异步程序的稳定性了。
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
--------------------------------------------------------------------------------
/book/content/part4/chapter9/6.tex:
--------------------------------------------------------------------------------
1 |
2 | 异步操作可能发生的主要灾难性问题之一是:当操作发生时,一些所需的对象已销毁。
3 |
4 | 因此,管理对象的生命周期至关重要。在 C++ 中,对象的生命周期从构造函数结束时开始,到析构函数开始时结束。保持对象活动的常见模式是让对象创建指向自身的共享指针实例,确保只要有指向它的共享指针,该对象就保持有效。
5 |
6 | 这种技术称为 shared-from-this,并使用自 C++11 起可用的 std::enable\_shared\_from\_this 模板基类,提供了对象用来获取指向自身的共享指针的 shared\_from\_this() 方法。
7 |
8 | \mySubsubsection{9.6.1.}{实现回显服务器——示例}
9 |
10 | 通过创建一个回显服务器来了解它的工作原理。同时,将讨论这项技术,还将介绍如何使用 Boost.Asio 进行联网。
11 |
12 | 通过网络传输数据可能需要很长时间才能完成,并且可能会出现多个错误。这使得网络 I/O 服务成为 Boost.Asio 处理的一个特殊情况。网络 I/O 服务是第一个包含在库中的服务。
13 |
14 | Boost.Asio 在业界的主要常见用途是开发网络应用程序,因为支持互联网协议 TCP、 UDP 和 ICMP。该库还提供了基于 Berkeley Software Distribution (BSD) 套接字 API 的套接字接口,允许使用底层接口开发高效且可扩展的应用程序。
15 |
16 | 然而,由于在本书对异步编程感兴趣,所以将重点介绍如何使用高级接口实现回显服务器。
17 |
18 | 回显服务器是一个监听特定地址和端口,并写回从该端口读取的所有内容的程序,将创建一个 TCP 服务器。
19 |
20 | 主程序将简单地创建一个 io\_context 对象,通过传递 io\_context 对象和要监听的端口号来设置 EchoServer 对象,然后调用 io\_context.run() 来启动事件处理循环:
21 |
22 | \begin{cpp}
23 | #include
24 | #include
25 |
26 | constexpr int port = 1234;
27 |
28 | int main() {
29 | try {
30 | boost::asio::io_context io_context;
31 | EchoServer server(io_context, port);
32 | io_context.run();
33 | } catch (std::exception& e) {
34 | std::cerr << "Exception: " << e.what() << "\n";
35 | }
36 | return 0;
37 | }
38 | \end{cpp}
39 |
40 | 当 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 为 in6 addr\_any)。接下来,实现 EchoServer 构造函数的代码如下:
41 |
42 | \begin{cpp}
43 | using boost::asio::ip::tcp;
44 | class EchoServer {
45 | public:
46 | EchoServer(boost::asio::io_context& io_context,
47 | short port)
48 | : acceptor_(io_context,
49 | tcp::endpoint(tcp::v4(),
50 | port)) {
51 | do_accept();
52 | }
53 |
54 | private:
55 | void do_accept() {
56 | acceptor_.async_accept([this](
57 | boost::system::error_code ec,
58 | tcp::socket socket) {
59 | if (!ec) {
60 | std::make_shared(
61 | std::move(socket))->start();
62 | }
63 | do_accept();
64 | });
65 | }
66 |
67 | tcp::acceptor acceptor_;
68 | };
69 | \end{cpp}
70 |
71 | EchoServer 构造函数在设置接受器对象后调用 do\_accept() 函数,其中调用 async\_accept() 函数等待传入连接。当客户端连接到服务器时,操作系统通过 io\_context 对象返回连接的套接字 (boost::asio::tcp::socket) 或错误。
72 |
73 | 如果没有错误并且建立了连接,则创建 Session 对象的共享指针,将套接字移动到 Session 对象中, Session 对象运行 start() 函数。
74 |
75 | Session 对象封装了特定连接的状态,本例中为 socket\_ 对象和 data\_ 缓冲区。还使用 do\_read() 和 do\_write() 管理对该缓冲区的异步读取和写入,稍后将实现这两个函数。Session 继承自 std::enable\_shared\_from\_this,所以 Session 对象创建指向自身的共享指针,确保只要至少有一个共享指针指向管理该连接的 Session 实例,会话对象就会在需要异步操作的整个生命周期内保持活动状态。此共享指针是在建立连接时,在 EchoServer 对象中的 do\_accept() 函数中创建的。
76 |
77 | 以下是 Session 类的实现:
78 |
79 | \begin{cpp}
80 | class Session
81 | : public std::enable_shared_from_this
82 | {
83 | public:
84 | Session(tcp::socket socket)
85 | : socket_(std::move(socket)) {}
86 |
87 | void start() { do_read(); }
88 |
89 | private:
90 | static const size_t max_length = 1024;
91 |
92 | void do_read();
93 | void do_write(std::size_t length);
94 |
95 | tcp::socket socket_;
96 | char data_[max_length];
97 | };
98 | \end{cpp}
99 |
100 | 使用 Session 类,可以将管理连接的逻辑与管理服务器的逻辑分开。 EchoServer 只需接受连接并为每个连接创建一个 Session 对象。这样,一个服务器就可以管理多个客户端,保持连接独立并异步管理。
101 |
102 | Session 使用 do\_read() 和 do\_write() 函数来管理该连接的行为。当 Session 启动时,其 start() 函数会调用 do\_read() 函数:
103 |
104 | \begin{cpp}
105 | void Session::do_read() {
106 | auto self(shared_from_this());
107 | socket_.async_read_some(boost::asio::buffer(data_,
108 | max_length),
109 | [this, self](boost::system::error_code ec,
110 | std::size_t length) {
111 | if (!ec) {
112 | do_write(length);
113 | }
114 | });
115 | }
116 | \end{cpp}
117 |
118 | do\_read() 函数创建指向当前会话对象 (self) 的共享指针,并使用套接字的 async\_read\_some() 异步函数将一些数据读入 data\_buffer。如果成功,此操作将返回复制到 data\_buffer 中的数据以及长度变量中读取的字节数。
119 |
120 | 然后,使用该长度变量调用 do\_write(),使用 async\_write() 函数将 data\_ 缓冲区的内容异步写入套接字。当此异步操作成功时,通过再次调用 do\_read() 函数重新启动循环:
121 |
122 | \begin{cpp}
123 | void Session::do_write(std::size_t length) {
124 | auto self(shared_from_this());
125 | boost::asio::async_write(socket_,
126 | boost::asio::buffer(data_,
127 | length),
128 | [this, self](boost::system::error_code ec,
129 | std::size_t length) {
130 | if (!ec) {
131 | do_read();
132 | }
133 | });
134 | }
135 | \end{cpp}
136 |
137 | 想知道为什么定义了 self 却没有使用?看起来 self 是多余的,但是当 lambda 函数按值捕获它时,会创建一个副本,从而增加指向 this 对象的共享指针的引用计数,确保在 lambda 处于活动状态时会话不会破坏。捕获 this 对象是为了在 lambda 函数中提供对其成员的访问。
138 |
139 | 作为练习,尝试实现一个 stop() 函数来中断 do\_read() 和 do\_write() 之间的循环。当所有异步操作完成并且 lambda 函数退出, self 对象将销毁,并且将不再有其他共享指针指向Session 对象,因此会话将销毁。
140 |
141 | 这种模式确保在异步操作期间对对象的生命周期进行稳健和安全的管理,避免悬垂指针或早期破坏,从而导致不良行为或崩溃。
142 |
143 | 要测试此服务器,只需启动服务器,打开一个新终端,然后使用 telnet 命令连接到服务器并向其发送数据。作为参数,可以传递本地主机地址,表示我们正在连接到在同一台机器上运行的服务器(IP 地址为 127.0.0.1)和端口(在本例中为 1234)。
144 |
145 | telnet 命令将启动并显示有关连接的一些信息,并表明我们需要按 Ctrl + \} 键来关闭连接。
146 |
147 | 输入任何内容并按下 Enter 键将会把输入的行发送到回显服务器,该服务器将监听并发回相同的内容;在这个例子中,是 “Hello world!”。
148 |
149 | 只需关闭连接并使用 quit 命令退出 telnet 即可退出返回终端:
150 |
151 | \begin{shell}
152 | $ telnet localhost 1234
153 | Trying 127.0.0.1...
154 | Connected to localhost.
155 | Escape character is '^]'.
156 | Hello world!
157 | Hello world!
158 | telnet> quit
159 | Connection closed.
160 | \end{shell}
161 |
162 | 此示例中,已经使用了缓冲区。让我们在下一节中进一步进行了解。
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
--------------------------------------------------------------------------------
/book/content/part4/chapter9/7.tex:
--------------------------------------------------------------------------------
1 |
2 | 缓冲区是 I/O 操作期间用于传输数据的连续内存区域。
3 |
4 | Boost.Asio 定义了两种类型的缓冲区:可变缓冲区 (boost::asio::mutable\_buffer),其中可以写入数据;常量缓冲区 (boost::asio::const\_buffers),用于创建只读缓冲区。可变缓冲区可以转换为常量缓冲区,但不能转换为常量缓冲区。这两种类型的缓冲区都提供溢出保护。
5 |
6 | 还有 boost::buffer 函数来帮助从不同数据类型(指向原始内存和大小的指针、字符串 (std::string) 或普通旧数据 (POD) 结构数组或向量(意味着类型、结构或类没有用户定义的复制赋值运算符或析构函数,也没有私有或受保护的非静态数据成员)创建可变或常量缓冲区。例如,要从字符数组创建缓冲区。
7 |
8 | 可以使用以下方式创建:
9 |
10 | \begin{cpp}
11 | char data[1024];
12 | mutable_buffer buffer = buffer(data, sizeof(data));
13 | \end{cpp}
14 |
15 | 另请注意,缓冲区的所有权和生存期是程序的责任,而不是 Boost.Asio 库的责任。
16 |
17 | \mySubsubsection{9.7.1.}{分散-聚集操作}
18 |
19 | 可以通过使用分散-集中操作来高效使用缓冲区,其中多个缓冲区一起用于接收数据(分散- 读取)或发送数据(集中-写入)。
20 |
21 | 分散-读取:是将数据从唯一源读取到不同的不连续内存缓冲区的过程。
22 |
23 | 聚集-写入:是逆向的过程;数据从不同的不连续的内存缓冲区聚集并写入单个目的地。
24 |
25 | 这些技术可以减少系统调用或数据复制的次数,从而提高效率和性能。不仅用于 I/O 操作,还可用于其他用例,例如:数据处理、机器学习或并行算法(例如排序或矩阵乘法)。
26 |
27 | 为了允许分散-集中操作,可以将多个缓冲区一起传递到容器(std::vector、 std::list、 std::array 或 boost::array)内的异步操作。
28 |
29 | 下面是一个散射读取的例子,其中套接字将一些数据异步读入 buf1 和 buf2 缓冲区:
30 |
31 | \begin{cpp}
32 | std::array buf1, buf2;
33 | std::vector buffers = {
34 | boost::asio::buffer(buf1),
35 | boost::asio::buffer(buf2)
36 | };
37 | socket.async_read_some(buffers, handler);
38 | \end{cpp}
39 |
40 | 以下是如何实现聚集读取:
41 |
42 | \begin{cpp}
43 | std::array buf1, buf2;
44 | std::vector buffers = {
45 | boost::asio::buffer(buf1),
46 | boost::asio::buffer(buf2)
47 | };
48 | socket.async_write_some(buffers, handler);
49 | \end{cpp}
50 |
51 | 套接字执行相反的操作,将两个缓冲区中的一些数据写入套接字缓冲区以进行异步发送。
52 |
53 | \mySubsubsection{9.7.2.}{流缓冲区}
54 |
55 | 还可以使用流缓冲区来管理数据。流缓冲区由 boost::asio::basic\_streambuf 类定义,该类基于 std::basic\_streambuf类,并在 头文件中定义。允许动态缓冲区,其大小可以适应正在传输的数据量。
56 |
57 | 以下示例中看看流缓冲区如何与分散-集中操作协同工作。本例中,实现了一个 TCP 服务器,该服务器监听并接受来自给定端口的客户端连接,将客户端发送的消息读入两个流缓冲区,并将其内容打印到控制台。由于这里有兴趣了解流缓冲区和分散-集中操作,可通过使用同步操作来简化示例。
58 |
59 | 与上例一样,在 main() 函数中,使用 boost::asio::ip::tcp::acceptor 对象来设置 TCP 服务器用于接受连接的协议和端口。然后,在无限循环中,服务器使用该 acceptor 对象连接 TCP 套接字 (boost::asio::ip::tcp::socket) 并调用 handle\_client() 函数:
60 |
61 | \begin{cpp}
62 | #include
63 | #include
64 | #include
65 | #include
66 |
67 | using boost::asio::ip::tcp;
68 |
69 | constexpr int port = 1234;
70 |
71 | int main() {
72 | try {
73 | boost::asio::io_context io_context;
74 | tcp::acceptor acceptor(io_context,
75 | tcp::endpoint(tcp::v4(), port));
76 | std::cout << "Server is running on port "
77 | << port << "...\n";
78 |
79 | while (true) {
80 | tcp::socket socket(io_context);
81 | acceptor.accept(socket);
82 | std::cout << "Client connected...\n";
83 |
84 | handle_client(socket);
85 | std::cout << "Client disconnected...\n";
86 | }
87 | } catch (std::exception& e) {
88 | std::cerr << "Exception: " << e.what() << '\n';
89 | }
90 | return 0;
91 | }
92 | \end{cpp}
93 |
94 | handle\_client() 函数创建两个流缓冲区: buf1 和 buf2,并将它们添加到容器(在本例中为 std ::array)中,以用于分散-聚集操作。
95 |
96 | 然后,调用套接字的同步 read\_some() 函数。此函数返回从套接字读取的字节数并将它们复制到缓冲区中。如果套接字连接出现问题,则会在错误代码对象中返回错误,服务器将输出错误消息并退出。
97 |
98 | 实现如下:
99 |
100 | \begin{cpp}
101 | void handle_client(tcp::socket& socket) {
102 | const size_t size_buffer = 5;
103 | boost::asio::streambuf buf1, buf2;
104 |
105 | std::array buffers = {
106 | buf1.prepare(size_buffer),
107 | buf2.prepare(size_buffer)
108 | };
109 |
110 | boost::system::error_code ec;
111 | size_t bytes_recv = socket.read_some(buffers, ec);
112 | if (ec) {
113 | std::cerr << "Error on receive: "
114 | << ec.message() << '\n';
115 |
116 | return;
117 | }
118 |
119 | std::cout << "Received " << bytes_recv << " bytes\n";
120 |
121 | buf1.commit(5);
122 | buf2.commit(5);
123 |
124 | std::istream is1(&buf1);
125 | std::istream is2(&buf2);
126 | std::string data1, data2;
127 | is1 >> data1;
128 | is2 >> data2;
129 |
130 | std::cout << "Buffer 1: " << data1 << std::endl;
131 | std::cout << "Buffer 2: " << data2 << std::endl;
132 | }
133 | \end{cpp}
134 |
135 | 如果没有错误,则使用流缓冲区的 commit() 函数将五个字节传输到流缓冲区 buf1 和 buf2 中。使用 std::istream 对象提取这些缓冲区的内容并将其输出到控制台。
136 |
137 | 要执行此示例,需要打开两个终端。在一个终端中,执行服务器,在另一个终端中,执行 telnet 命令,如前所示。在 telnet 终端中,可以输入一条消息(例如, "Hello World")。此消息将发送到服务器。然后服务器终端将显示以下内容:
138 |
139 | \begin{shell}
140 | Server is running on port 1234...
141 | Client connected...
142 | Received 10 bytes
143 | Buffer 1: Hello
144 | Buffer 2: Worl
145 | Client disconnected...
146 | \end{shell}
147 |
148 | 可以看到,只有 10 个字节被处理并分配到两个缓冲区中。两个单词之间的空格字符被处理但在 iostream 对象解析输入时丢弃。
149 |
150 | 当传入数据的大小可变且事先未知时,流缓冲区非常有用。这些类型的缓冲区可以与固定大小的缓冲区一起使用。
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
--------------------------------------------------------------------------------
/book/content/part4/chapter9/8.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | 信号处理能够捕获操作系统发送的信号,并在操作系统决定终止应用程序的进程之前正常关闭该应用程序。
4 |
5 | Boost.Asio 为此目的提供了 boost::asio::signal\_set 类,启动异步等待一个或多个信号的发生。
6 |
7 | 这是如何处理 SIGINT 和 SIGTERM 信号的示例:
8 |
9 | \begin{cpp}
10 | #include
11 | #include
12 |
13 | int main() {
14 | try {
15 | boost::asio::io_context io_context;
16 | boost::asio::signal_set signals(io_context,
17 | SIGINT, SIGTERM);
18 |
19 | auto handle_signal = [&](
20 | const boost::system::error_code& ec,
21 | int signal) {
22 | if (!ec) {
23 | std::cout << "Signal received: "
24 | << signal << std::endl;
25 |
26 | // Code to perform cleanup or shutdown.
27 | io_context.stop();
28 | }
29 | };
30 |
31 | signals.async_wait(handle_signal);
32 | std::cout << "Application is running. "
33 | << "Press Ctrl+C to stop...\n";
34 |
35 | io_context.run();
36 | std::cout << "Application has exited cleanly.\n";
37 | } catch (std::exception& e) {
38 | std::cerr << "Exception: " << e.what() << '\n';
39 | }
40 | return 0;
41 | }
42 | \end{cpp}
43 |
44 | 信号对象是 signal\_set,列出了程序等待的信号, SIGINT 和 SIGTERM。此对象有一个 async\_wait() 方法,该方法异步等待这些信号的发生,并触发完成处理程序 handle\_signal()。
45 |
46 | 与完成处理程序中通常的情况一样, handle\_signal() 检查错误代码 ec,如果没有错误,则可能会执行一些清理代码以干净利落地退出程序。此示例中,只需调用 io\_context.stop( ) 即可停止事件处理循环。
47 |
48 | 还可以使用 signals.wait() 方法同步等待信号。如果应用程序是多线程的,则信号事件处理程序必须与 io\_context 对象在同一个线程中运行,通常是主线程。
49 |
50 | 下一节中,将介绍如何取消操作。
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/book/content/part4/chapter9/9.tex:
--------------------------------------------------------------------------------
1 |
2 | 某些 I/O 对象(例如套接字或计时器)通过调用 close() 或 cancel() 方法在对象范围内取消未完成的异步操作。如果异步操作被取消,完成处理程序将收到带有 boost::asio::error::operation\_aborted 代码的错误。
3 |
4 | 以下示例中,创建了一个计时器,并将其超时时间设置为 5 秒。但在主线程仅休眠两秒后,通过调用其 cancel() 方法取消了计时器,从而使用 boost::asio::error::operation\_aborted 错误代码调用完成处理程序:
5 |
6 | \begin{cpp}
7 | #include
8 | #include
9 | #include
10 | #include
11 |
12 | using namespace std::chrono_literals;
13 |
14 | void handle_timeout(const boost::system::error_code& ec) {
15 | if (ec == boost::asio::error::operation_aborted) {
16 | std::cout << "Timer canceled.\n";
17 | } else if (!ec) {
18 | std::cout << "Timer expired.\n";
19 | } else {
20 | std::cout << "Error: " << ec.message()
21 | << std::endl;
22 | }
23 | }
24 | int main() {
25 | boost::asio::io_context io_context;
26 | boost::asio::steady_timer timer(io_context, 5s);
27 |
28 | timer.async_wait(handle_timeout);
29 |
30 | std::this_thread::sleep_for(2s);
31 | timer.cancel();
32 |
33 | io_context.run();
34 | return 0;
35 | }
36 | \end{cpp}
37 |
38 | 但若想要每个操作都取消,需要设置一个取消槽,当发出取消信号时,该槽将触发。这个取消信号/槽对组成了一个轻量级通道来传达取消操作。取消框架自 1.75 版起在 Boost.Asio 中可用。
39 |
40 | 这种方法实现了更灵活的取消机制,可以使用同一信号取消多个操作,并与 Boost.Asio 的异步操作无缝集成。同步操作只能通过使用前面描述的 cancel() 或 close() 方法取消;取消槽机制不支持。
41 |
42 | 修改前面的示例,并使用取消信号/槽来取消计时器。只需要修改在 main() 函数中取消计时器的方式,当执行异步 async\_wait() 操作时,通过使用 boost::asio::bind\_cancellation\_slot() 函数将取消信号和完成处理程序中的槽绑定在一起,即可创建取消槽。
43 |
44 | 与之前一样,定时器的有效期为五秒。同样,主线程仅休眠两秒。这次,通过调用 cancel\_signal.emit() 函数发出取消信号。该信号将触发对应的取消槽并使用 boost::asio::error::operation\_aborted 错误代码执行完成处理程序,在控制台中输出"Timer expired."的消息;参见以下内容:
45 |
46 | \begin{cpp}
47 | int main() {
48 | boost::asio::io_context io_context;
49 | boost::asio::steady_timer timer(io_context, 5s);
50 |
51 | boost::asio::cancellation_signal cancel_signal;
52 |
53 | timer.async_wait(boost::asio::bind_cancellation_slot(
54 | cancel_signal.slot(),
55 | handle_timeout
56 | ));
57 |
58 | std::this_thread::sleep_for(2s);
59 | cancel_signal.emit(
60 | boost::asio::cancellation_type::all);
61 |
62 | io_context.run();
63 | return 0;
64 | }
65 | \end{cpp}
66 |
67 | 发出信号时,必须指定取消类型,让目标操作知道应用程序需要什么,以及操作保证什么,从而控制取消的范围和行为。
68 |
69 | 各种取消类别如下:
70 |
71 | \begin{itemize}
72 | \item
73 | None:不执行取消。如果想测试是否应该发生取消,这会很有用。
74 |
75 | \item
76 | Terminal:该操作具有未指定的副作用,因此取消操作的唯一安全方法是关闭或销毁 I/O 对象,其结果是最终的,例如完成任务或交易。
77 |
78 | \item
79 | Partial:操作具有明确定义的副作用,完成处理程序可以采取所需的操作来解决问题,所以操作已部分完成并可以恢复或重试。
80 |
81 | \item
82 | Total或All:操作没有副作用。取消终端操作和部分操作,可通过停止所有正在进行的异步操作实现全面取消。
83 | \end{itemize}
84 |
85 | 如果异步操作不支持取消类型,则取消请求将被丢弃。例如,计时器操作支持所有取消类别,但套接字仅支持 Total 和 All,所以如果尝试使用 Partial 取消来取消套接字异步操作,则将忽略此取消。如果 I/O 系统尝试处理不支持的取消请求,这可以防止出现未定义的行为。
86 |
87 | 此外,操作发起之后,但在操作开始之前或操作完成后,发出的取消请求均无效。
88 |
89 | 有时,需要按顺序运行一些工作。接下来我们将介绍如何使用 strand 来进行实现。
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
--------------------------------------------------------------------------------
/book/content/part4/chapter9/images/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part4/chapter9/images/1.png
--------------------------------------------------------------------------------
/book/content/part4/chapter9/images/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part4/chapter9/images/2.png
--------------------------------------------------------------------------------
/book/content/part4/chapter9/images/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiaoweiChen/Asynchronous-Programming-with-Cpp/c56cac1181d19f78a2c5501879bb8de8b7091358/book/content/part4/chapter9/images/3.png
--------------------------------------------------------------------------------
/book/content/part4/part.tex:
--------------------------------------------------------------------------------
1 | 在本部分中,将使用强大的 Boost 库来学习高级异步编程技术,能够高效地管理与外部资源和系统级服务交互的任务。这里将介绍 Boost.Asio 和 Boost.Cobalt 库,了解它们如何简化异步应用程序的开发,同时提供对复杂流程(如任务管理和协程执行)的细粒度控制。通过实际操作示例,将了解 Boost.Asio 如何在单线程和多线程环境中处理异步 I/ O 操作,以及 Boost.Cobalt 如何抽象出 C++20 协程的复杂性,使开发者能够专注于功能,而非底层协程管理。
2 |
3 | 本部分包含以下章节:
4 |
5 | \begin{itemize}
6 | \item
7 | 第 9 章,使用 Boost.Asio 进行异步编程
8 |
9 | \item
10 | 第 10 章,使用 Boost.Cobalt 实现协程
11 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part5/chapter11/0.tex:
--------------------------------------------------------------------------------
1 | 无法确保软件产品没有错误,因此时不时就会出现错误。这时日志记录和调试就必不可少。
2 |
3 | 日志记录和调试对于识别和诊断软件系统中的问题至关重要。它们提供了对代码运行时行为的可见性,帮助开发人员跟踪错误、监控性能并了解执行流程。通过有效地使用日志记录和调试,开发人员可以检测错误、解决意外行为并提高整体系统稳定性和可维护性。
4 |
5 | 撰写本章时,假设读者们已经熟悉使用调试器调试 C++ 程序,并且了解一些基本的调试器命令和术语,例如断点、观察器、框架或堆栈跟踪。要复习这些知识,可以参考本章末尾的“扩展阅读”部分提供的参考资料。
6 |
7 | 本章中,将讨论以下主要主题:
8 |
9 | \begin{itemize}
10 | \item
11 | 如何使用日志记录来发现错误
12 |
13 | \item
14 | 如何调试异步软件
15 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part5/chapter11/1.tex:
--------------------------------------------------------------------------------
1 | 本章中,需要安装第三方库来编译示例。
2 |
3 | 需要安装 spdlog 和 \{fmt\} 库才能编译日志记录部分中的示例。请查看它们的文档(spdlog 的文档位于 \url{https://github.com/gabime/spdlog}, \{fmt\} 的文档位于 \url{https://github.com/fmtlib/fmt}) ,并按照适合您平台的安装步骤进行操作。
4 |
5 | 一些示例需要支持 C++20 的编译器,请查看第 3 章中的技术要求部分,其中提供了有关如何安装 GCC 13 和 Clang 8 编译器的一些指导。
6 |
7 | 可以在以下 GitHub 库中找到所有完整的代码:
8 |
9 | \url{https://github.com/PacktPublishing/Asynchronous-Programming-with-CPP}
10 |
11 | 本章的示例位于 Chapter\_11 文件夹下。所有源代码文件都可以使用 CMake 进行编译:
12 |
13 | \begin{shell}
14 | $ cmake . && cmake --build .
15 | \end{shell}
16 |
17 | 可执行二进制文件将在 bin 目录下生成。
--------------------------------------------------------------------------------
/book/content/part5/chapter11/4.tex:
--------------------------------------------------------------------------------
1 | 本章中,介绍如何使用日志记录和调试异步程序。
2 |
3 | 首先使用日志来发现正在运行的软件中的问题,并展示了使用 spdlog 日志库检测死锁的实用性。还讨论了许多其他库,并描述了可能适合特定场景的相关功能。
4 |
5 | 但是,并非所有错误都可以通过使用日志来发现,有些错误可能只能在软件开发生命周期的后期才被发现,当生产中出现一些问题时,甚至在处理程序崩溃和事故时也是如此。调试器是检查正在运行或崩溃的程序、了解其代码路径和查找错误的有用工具。引入了几个示例和调试器命令来处理通用代码,但也特别适用于多线程和异步软件、条件竞争和协程。此外,还引入了 rr 调试器,展示了将反向调试纳入开发人员工具箱的潜力。
6 |
7 | 下一章中,将介绍使用消杀器和测试技术,来提高异步程序的运行时间和资源使用率的性能和优化技术。
--------------------------------------------------------------------------------
/book/content/part5/chapter11/5.tex:
--------------------------------------------------------------------------------
1 |
2 | \begin{itemize}
3 | \item
4 | Logging: \url{https://en.wikipedia.org/wiki/Logging_(computing)}
5 |
6 | \item
7 | Syslog: \url{https://en.wikipedia.org/wiki/Syslog}
8 |
9 | \item
10 | Google Logging Library: \url{https://github.com/google/glog}
11 |
12 | \item
13 | Apache Log4cxx: \url{https://logging.apache.org/log4cxx}
14 |
15 | \item
16 | spdlog: \url{https://github.com/gabime/spdlog}
17 |
18 | \item
19 | Quill: \url{https://github.com/odygrd/quill}
20 |
21 | \item
22 | xtr: \url{https://github.com/choll/xtr}
23 |
24 | \item
25 | lwlog: \url{https://github.com/ChristianPanov/lwlog}
26 |
27 | \item
28 | uberlog: \url{https://github.com/IMQS/uberlog}
29 |
30 | \item
31 | Easylogging++: \url{https://github.com/abumq/easyloggingpp}
32 |
33 | \item
34 | NanoLog: \url{https://github.com/PlatformLab/NanoLogReckless}
35 |
36 | \item
37 | Logging Library: \url{https://github.com/mattiasflodin/reckless}
38 |
39 | \item
40 | tracetool: \url{https://github.com/froglogic/tracetool}
41 |
42 | \item
43 | Logback Project: \url{https://logback.qos.ch}
44 |
45 | \item
46 | Sentry: \url{https://sentry.io}
47 |
48 | \item
49 | Graylog: \url{https://graylog.org}
50 |
51 | \item
52 | Logstash: \url{https://www.elastic.co/logstash}
53 |
54 | \item
55 | Debugging with GDB: \url{https://sourceware.org/gdb/current/onlinedocs/gdb.html}
56 |
57 | \item
58 | LLDB tutorial: \url{https://lldb.llvm.org/use/tutorial.html}
59 |
60 | \item
61 | Clang Compiler User’s Manual: \url{https://clang.llvm.org/docs/UsersManual.html}
62 |
63 | \item
64 | GDB: Running programs backward: \url{https://www.zeuthen.desy.de/dv/documentation/unixguide/infohtml/gdb/Reverse-Execution.html#Reverse-Execution}
65 |
66 | \item
67 | Reverse Debugging with GDB: \url{https://sourceware.org/gdb/wiki/ReverseDebug}
68 |
69 | \item
70 | Debugging C++ Coroutines: \url{https://clang.llvm.org/docs/DebuggingCoroutines.html}
71 |
72 | \item
73 | SID Simulator User’s Guide: \url{https://sourceware.org/sid/sid-guide/book1.html}
74 |
75 | \item
76 | Intel Simics Simulator for Intel FPGAs: User Guide: \url{https://www.intel.com/content/www/us/en/docs/programmable/784383/24-1/about-this-document.html}
77 |
78 | \item
79 | IBM Support: How do I enable core dumps: \url{https://www.ibm.com/support/pages/how-do-i-enable-core-dumps}
80 |
81 | \item
82 | Core Dumps – How to enable them?: \url{https://medium.com/@sourabhedake/coredumps-how-to-enable-them-73856a437711}
83 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part5/chapter12/0.tex:
--------------------------------------------------------------------------------
1 | 测试是评估和验证软件解决方案是否按预期完成任务的过程,验证其质量并确保满足用户要求。通过适当的测试,可以避免出现错误并提高性能。
2 |
3 | 本章中,将探讨几种测试异步软件的技术,主要使用 GoogleTest 库以及 GNU 编译器集合 (GCC) 和 Clang 编译器提供的清理器。需要一些单元测试方面的知识。本章末尾的“扩展阅读”部分中,可以找到一些参考资料,它们可能有助于更新和扩展在这些领域的知识。
4 |
5 | 本章中,将讨论以下主要主题:
6 |
7 | \begin{itemize}
8 | \item
9 | 消杀代码以分析软件并发现潜在问题
10 |
11 | \item
12 | 测试异步代码
13 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part5/chapter12/1.tex:
--------------------------------------------------------------------------------
1 | 对于本章,需要安装 GoogleTest (\url{https://google.github.io/googletest}) 来编译一些示例。
2 |
3 | 一些示例需要支持 C++20 的编译器,请查看第 3 章中的技术要求部分,包含有关如何安装 GCC 13 和 Clang 8 编译器的一些指导。
4 |
5 | 可以在以下 GitHub 库中找到所有完整的代码: \url{https://github.com/PacktPublishing/Asynchronous-Programming-with-CPP}
6 |
7 | 本章的示例位于 Chapter\_12 文件夹下。所有源代码文件都可以使用 CMake 进行编译:
8 |
9 | \begin{shell}
10 | $ cmake . && cmake --build .
11 | \end{shell}
12 |
13 | 可执行二进制文件将在 bin 目录下生成。
14 |
--------------------------------------------------------------------------------
/book/content/part5/chapter12/4.tex:
--------------------------------------------------------------------------------
1 | 本章中,介绍了如何清理和测试异步程序。
2 |
3 | 首先接扫如何使用清理器清理代码,以帮助查找多线程和异步问题,例如:竞争条件、 内存泄漏、使用范围后错误,以及许多其他问题。
4 |
5 | 然后,描述了一些针对异步软件的测试技术,使用GoogleTest作为测试库。
6 |
7 | 使用这些工具和技术有助于检测和预防未定义行为、内存错误和安全漏洞,同时确保并发操作正确执行、时序问题得到妥善处理以及代码在各种条件下按预期执行。这提高了整个程序的可靠性和稳定性。
8 |
9 | 下一章中,将介绍可用于改善异步程序运行时间和资源使用情况的性能和优化技术。
--------------------------------------------------------------------------------
/book/content/part5/chapter12/5.tex:
--------------------------------------------------------------------------------
1 |
2 |
3 | \begin{itemize}
4 | \item
5 | Sanitizers: \url{https://github.com/google/sanitizers}
6 |
7 | \item
8 | Clang 20.0 ASan: \url{https://clang.llvm.org/docs/AddressSanitizer.html}
9 |
10 | \item
11 | Clang 20.0 hardware-assisted ASan: \url{https://clang.llvm.org/docs/HardwareAssistedAddressSanitizerDesign.html}
12 |
13 | \item
14 | Clang 20.0 TSan: \url{https://clang.llvm.org/docs/ThreadSanitizer.html}
15 |
16 | \item
17 | Clang 20.0 MSan: \url{https://clang.llvm.org/docs/MemorySanitizer.html}
18 |
19 | \item
20 | Clang 20.0 UBSan: \url{http://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html}
21 |
22 | \item
23 | Clang 20.0 DataFlowSanitizer: \url{https://clang.llvm.org/docs/DataFlowSanitizer.html}
24 |
25 | \item
26 | Clang 20.0 LSan: \url{https://clang.llvm.org/docs/LeakSanitizer.html}
27 |
28 | \item
29 | Clang 20.0 RealtimeSanitizer: \url{https://clang.llvm.org/docs/RealtimeSanitizer.html}
30 |
31 | \item
32 | Clang 20.0 SanitizerCoverage: \url{https://clang.llvm.org/docs/SanitizerCoverage.html}
33 |
34 | \item
35 | Clang 20.0 SanitizerStats: \url{https://clang.llvm.org/docs/SanitizerStats.html}
36 |
37 | \item
38 | GCC: Program Instrumentation Options: \url{https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html}
39 |
40 | \item
41 | Apple Developer: Diagnosing memory, thread, and crash issues early: \url{https://developer.apple.com/documentation/xcode/diagnosing-memory-thread-andcrash-issues-early}
42 |
43 | \item
44 | GCC: Options for Debugging Your Program: \url{https://gcc.gnu.org/onlinedocs/gcc/Debugging-Options.html}
45 |
46 | \item
47 | OpenSSL: Compiler Options Hardening Guide for C and C++: \url{https://best.openssf.org/Compiler-Hardening-Guides/Compiler-Options-Hardening-Guidefor-C-and-C++.html}
48 |
49 | \item
50 | Memory error checking in C and C++: Comparing Sanitizers and Valgrind: \url{https://developers.redhat.com/blog/2021/05/05/memory-error-checkingin-c-and-c-comparing-sanitizers-and-valgrind}
51 |
52 | \item
53 | The GNU C Library: \url{https://www.gnu.org/software/libc}
54 |
55 | \item
56 | Sanitizers: Common flags: \url{https://github.com/google/sanitizers/wiki/SanitizerCommonFlags}
57 |
58 | \item
59 | AddressSanitizer flags: \url{https://github.com/google/sanitizers/wiki/AddressSanitizerFlags}
60 |
61 | \item
62 | AddressSanitizer: A Fast Address Sanity Checker: \url{https://www.usenix.org/system/files/conference/atc12/atc12-final39.pdf}
63 |
64 | \item
65 | MemorySanitizer: Fast detector of uninitialized memory use in C++: \url{https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/43308.pdf}
66 |
67 | \item
68 | Linux Kernel Sanitizers: \url{https://github.com/google/kernel-sanitizers}
69 |
70 | \item
71 | TSan flags: \url{https://github.com/google/sanitizers/wiki/ThreadSanitizerFlags}
72 |
73 | \item
74 | TSan: Popular data races: \url{https://github.com/google/sanitizers/wiki/ThreadSanitizerPopularDataRaces}
75 |
76 | \item
77 | TSan report format: \url{https://github.com/google/sanitizers/wiki/ThreadSanitizerReportFormat}
78 |
79 | \item
80 | TSan algorithm: \url{https://github.com/google/sanitizers/wiki/ThreadSanitizerAlgorithm}
81 |
82 | \item
83 | Address space layout randomization: \url{https://en.wikipedia.org/wiki/Address_space_layout_randomization}
84 |
85 | \item
86 | GoogleTest User’s Guide: \url{https://google.github.io/googletest}
87 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part5/chapter13/0.tex:
--------------------------------------------------------------------------------
1 | 本章中,将介绍异步代码的性能方面。代码性能和优化是一个深奥而复杂的主题,无法在一章中涵盖所有内容。我们的目标是通过一些如何衡量性能和优化代码的示例为读者提供有关该主题的介绍。
2 |
3 | 本章将涵盖以下关键主题:
4 |
5 | \begin{itemize}
6 | \item
7 | 专注于多线程应用程序的性能测量工具
8 |
9 | \item
10 | 什么是伪共享,如何发现它,以及如何修复/改进代码
11 |
12 | \item
13 | 现代 CPU 内存缓存架构简介
14 |
15 | \item
16 | 回顾一下实现的单生产者单消费者 (SPSC) 无锁队列(第 5 章)
17 | \end{itemize}
--------------------------------------------------------------------------------
/book/content/part5/chapter13/1.tex:
--------------------------------------------------------------------------------
1 | 与前几章一样,需要一个支持 C++20 的现代 C++ 编译器。我们将使用 GCC 13 和 Clang 1 8。还需要一台运行 Linux 的 Intel/AMD 多核 CPU 的 PC。在本章中,我们使用了在 CPU AMD Ryzen Threadripper Pro 5975WX(32 核)的工作站上运行的 Ubuntu 24.04 LTS。具有8 个内核的 CPU 是理想的,但 4 个内核足以运行示例。
2 |
3 | 还将使用 Linux perf 工具,将在本书后面解释如何获取和安装这些工具。
4 |
5 | 本章的示例可以在本书的 GitHub 库中找到: \url{https://github.com/PacktPublishing/Asynchronous-Programming-with-CPP}。
--------------------------------------------------------------------------------
/book/content/part5/chapter13/3.tex:
--------------------------------------------------------------------------------
1 | 本节中,将研究多线程应用程序中的一个常见问题,称为伪共享。
2 |
3 | 多线程应用程序的理想实现是最小化不同线程之间共享的数据。理想情况下,应该只为读取访问而共享数据,在这种情况下不需要同步线程来访问共享数据,因此不需要支付运行时成本并处理死锁和活锁等问题。
4 |
5 | 现在,考虑一个简单的示例:四个线程并行运行,生成随机数并计算其总和。
6 |
7 | 每个线程独立工作,生成随机数并计算存储在自己编写的变量中的总和。这是理想的(对于这个例子来说,有点做作)应用程序,线程独立工作,没有共享数据。
8 |
9 | 以下代码是本节将要分析的示例的完整源代码:
10 |
11 | \begin{cpp}
12 | #include
13 | #include
14 | #include
15 | #include
16 | #include
17 |
18 | struct result_data {
19 | unsigned long result { 0 };
20 | };
21 |
22 | struct alignas(64) aligned_result_data {
23 | unsigned long result { 0 };
24 | };
25 |
26 | void set_affinity(int core) {
27 | if (core < 0) {
28 | return;
29 | }
30 |
31 | cpu_set_t cpuset;
32 | CPU_ZERO(&cpuset);
33 | CPU_SET(core, &cpuset);
34 | if (pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t),
35 | &cpuset) != 0) {
36 | perror("pthread_setaffinity_np");
37 | exit(EXIT_FAILURE);
38 | }
39 | }
40 |
41 | template
42 | auto random_sum(T& data, const std::size_t seed, const unsigned long
43 | iterations, const int core) {
44 | set_affinity(core);
45 | std::mt19937 gen(seed);
46 | std::uniform_int_distribution dist(1, 5);
47 | for (unsigned long i = 0; i < iterations; ++i) {
48 | data.result += dist(gen);
49 | }
50 | }
51 |
52 | using namespace std::chrono;
53 | void sum_random_unaligned(int num_threads, uint32_t iterations) {
54 | auto* data = new(static_cast(64)) result_data[num_threads];
55 |
56 | auto start = high_resolution_clock::now();
57 | std::vector threads;
58 | for (std::size_t i = 0; i < num_threads; ++i) {
59 | set_affinity(i);
60 | threads.emplace_back(random_sum, std::ref(data[i]), i, iterations, i);
61 | }
62 | for (auto& thread : threads) {
63 | thread.join();
64 | }
65 | auto end = high_resolution_clock::now();
66 | auto duration = std::chrono::duration_cast(end - start);
67 | std::cout << "Non-aligned data: " << duration.count() << " milliseconds" << std::endl;
68 |
69 | operator delete[] (data, static_cast(64));
70 | }
71 |
72 | void sum_random_aligned(int num_threads, uint32_t iterations) {
73 | auto* aligned_data = new(static_cast(64))
74 | aligned_result_data[num_threads];
75 | auto start = high_resolution_clock::now();
76 | std::vector