原文:
zh.annas-archive.org/md5/f1e7911ac27c9e84fe6c2390cd2daf23译者:飞龙
第十一章:异步软件的日志和调试
没有办法确保软件产品完全没有错误,所以有时会出现错误。这时,日志和调试是必不可少的。
日志和调试对于识别和诊断软件系统中的问题至关重要。它们提供了对代码运行时行为的可见性,帮助开发者追踪错误、监控性能以及理解执行流程。通过有效地使用日志和调试,开发者可以检测到错误、解决意外行为,并提高整体系统的稳定性和可维护性。
在编写本章时,我们假设你已经熟悉使用调试器调试 C++程序,并了解一些基本的调试器命令和术语,例如断点、监视器、帧或堆栈跟踪。为了复习这些知识,你可以参考章节末尾的进一步阅读部分提供的参考资料。
在本章中,我们将涵盖以下主要主题:
-
如何使用日志来查找错误
-
如何调试异步软件
技术要求
对于本章,我们需要安装第三方库来编译示例。
要编译日志部分中的示例,需要安装spdlog和**{fmt}库。请检查它们的文档(spdlog的文档可在github.com/gabime/spdlog找到,{fmt}**的文档可在github.com/fmtlib/fmt找到),并按照适合您平台的安装步骤进行操作。
一些示例需要支持 C++20 的编译器。因此,请检查第三章中的技术要求部分,其中提供了一些关于如何安装 GCC 13 和 Clang 8 编译器的指导。
你可以在以下 GitHub 仓库中找到所有完整的代码:
github.com/PacktPublishing/Asynchronous-Programming-with-CPP
本章的示例位于Chapter_11文件夹下。所有源代码文件都可以使用以下命令使用 CMake 编译:
$ cmake . && cmake —build .
可执行二进制文件将在bin目录下生成。
如何使用日志来查找错误
让我们从理解软件程序在执行时做了什么的简单但有用方法开始——日志。
日志是记录程序中发生的事件的过程,通过使用消息记录程序如何执行,跟踪其流程,并帮助识别问题和错误。
大多数基于 Unix 的日志系统使用由 Eric Altman 在 1980 年作为 Sendmail 项目一部分创建的标准协议syslog。这个标准协议定义了生成日志消息的软件、存储它们的系统和报告和分析这些日志事件的软件之间的边界。
每条日志消息都包含一个设施代码和严重级别。设施代码标识了产生特定日志消息的系统类型(用户级、内核、系统、网络等),严重级别描述了系统的状态,表明处理特定问题的紧迫性,严重级别包括紧急、警报、关键、错误、警告、通知、信息和调试。
大多数日志系统或日志记录器都提供了各种日志消息的目的地或接收器:控制台、可以稍后打开和分析的文件、远程 syslog 服务器或中继,以及其他目的地。
在调试器无法使用的地方,日志非常有用,正如我们稍后将看到的,特别是在分布式、多线程、实时、科学或以事件为中心的应用程序中,使用调试器检查数据或跟踪程序流程可能变得是一项繁琐的任务。
日志库通常还提供一个线程安全的单例类,允许多线程和异步写入日志文件,有助于日志轮转,通过动态创建新文件而不丢失日志事件来避免大型日志文件,并添加时间戳,以便更好地跟踪日志事件发生的时间。
而不是实现我们自己的多线程日志系统,更好的方法是使用一些经过良好测试和文档化的生产就绪库。
如何选择第三方库
在将日志库(或任何其他库)集成到我们的软件之前,我们需要调查以下问题,以避免未来出现的问题:
-
支持:库是否定期更新和升级?是否有社区或活跃的生态系统围绕该库,可以帮助解决可能出现的任何问题?社区是否对使用该库感到满意?
-
质量:是否存在公开的缺陷报告系统?缺陷报告是否得到及时处理,提供解决方案并修复库中的缺陷?它是否支持最近的编译器版本并支持最新的 C++特性?
-
安全性:库或其任何依赖库是否有已报告的漏洞?
-
许可证:库的许可证是否与我们的开发和产品需求一致?成本是否可承受?
对于复杂系统,考虑集中式系统来收集和生成日志报告或仪表板可能是值得的,例如 Sentry (sentry.io) 或 Logstash (www.elastic.co/logstash),它们可以收集、解析和转换日志,并且可以与其他工具集成,如 Graylog (graylog.org)、Grafana (grafana.com) 或 Kibana (www.elastic.co/kibana)。
下一个部分将描述一些有趣的日志库。
一些相关的日志库
市场上有许多日志库,每个库都覆盖了一些特定的软件需求。根据程序约束和需求,以下库中的一些可能比其他库更适合。
在 第九章 中,我们探讨了 Boost.Asio。Boost 还提供了另一个库,Boost.Log(github.com/boostorg/log),这是一个强大且可配置的日志库。
Google 也提供了许多开源库,包括 glog,Google 日志库(github.com/google/glog),这是一个 C++14 库,提供了 C++ 风格的流 API 和辅助宏。
如果开发者熟悉 Java,一个不错的选择可能是基于 Log4j(logging.apache.org/log4j)的 Apache Log4cxx(logging.apache.org/log4cxx),这是一个多才多艺、工业级、Java 日志框架。
值得考虑的其他日志库如下:
-
spdlog(
github.com/gabime/spdlog)是一个有趣的日志库,我们可以与 {fmt} 库一起使用。此外,程序可以从启动时开始记录消息并将它们排队,甚至在指定日志输出文件名之前。 -
Quill(
github.com/odygrd/quill)是一个异步低延迟的 C++ 日志库。 -
NanoLog(
github.com/PlatformLab/NanoLog)是一个具有类似 printf API 的纳秒级日志系统。 -
lwlog(
github.com/ChristianPanov/lwlog)是一个惊人的快速异步 C++17 日志库。 -
XTR(
github.com/choll/xtr)是一个适用于低延迟和实时环境的快速便捷的 C++ 日志库。 -
Reckless(
github.com/mattiasflodin/reckless)是一个低延迟和高吞吐量的日志库。 -
uberlog(
github.com/IMQS/uberlog)是一个跨平台和多进程的 C++ 日志系统。 -
Easylogging++(
github.com/abumq/easyloggingpp)是一个单头文件 C++ 日志库,具有编写自定义存储和跟踪性能的能力。 -
tracetool(
github.com/froglogic/tracetool)是一个日志和跟踪共享库。
作为指导,根据要开发的系统,我们可能会选择以下库之一:
-
对于低延迟或实时系统:Quill、XTR 或 Reckless
-
对于纳秒级性能的日志:NanoLog
-
对于异步日志:Quill 或 lwlog
-
**对于跨平台、多进程 应用程序:uberlog
-
对于简单灵活的日志:Easylogging++ 或 glog
-
**对于熟悉 Java 日志:Log4cxx
所有库都有优点,但也存在需要在使用前调查的缺点。以下表格总结了这些要点:
| 库 | 优点 | 缺点 |
|---|---|---|
| spdlog | 简单集成,性能导向,可定制 | 缺乏一些针对极低延迟需求的高级功能 |
| Quill | 在低延迟系统中性能高 | 相比于更简单的同步日志记录器,设置更复杂 |
| NanoLog | 在速度上表现最佳,针对性能优化 | 功能有限;适用于专用用例 |
| lwlog | 轻量级,适合快速集成 | 相比于其他替代方案,成熟度和功能较少 |
| XTR | 非常高效,用户界面友好 | 更适合特定的实时应用 |
| Reckless | 高度优化吞吐量和低延迟 | 相比于更通用的日志记录器,灵活性有限 |
| uberlog | 适用于多进程和分布式系统 | 不如专门的低延迟日志记录器快 |
| Easylogging++ | 使用简单,可自定义输出目标 | 性能优化不如一些其他库 |
| tracetool | 将日志和跟踪结合在一个库中 | 不专注于低延迟或高吞吐量 |
| Boost.Log | 通用性强,与 Boost 库集成良好 | 复杂度较高;对于简单的日志需求可能过于复杂 |
| glog | 使用简单,适合需要简单 API 的项目 | 对于高级定制功能不如其他库丰富 |
| Log4cxx | 稳定,经过时间考验,工业级日志 | 设置较为复杂,特别是对于小型项目 |
表 11.1:各种库的优点和缺点
请访问日志库的网站以更好地了解它们提供的功能,并比较它们之间的性能。
由于 spdlog 是 GitHub 上被分叉和星标最多的 C++ 日志库仓库,在下一节中,我们将实现一个使用此库来捕获竞态条件的示例。
记录死锁 - 示例
在实现此示例之前,我们需要安装 spdlog 和 {fmt} 库。{fmt} (https://github.com/fmtlib/fmt) 是一个开源格式化库,提供了一种快速且安全的 C++ IOStreams 替代方案。
请检查它们的文档,并根据您的平台遵循安装步骤。
让我们实现一个发生死锁的示例。正如我们在 第四章 中所学,当两个或更多线程需要获取多个互斥锁以执行其工作时会发生死锁。如果互斥锁不是以相同的顺序获取,一个线程可以获取一个互斥锁并永远等待另一个线程获取的互斥锁。
在这个例子中,两个线程需要获取两个互斥锁,mtx1 和 mtx2,以增加 counter1 和 counter2 计数器的值并交换它们。由于线程以不同的顺序获取互斥锁,可能会发生死锁。
让我们先包含所需的库:
#include <fmt/core.h>
#include <spdlog/sinks/basic_file_sink.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/spdlog.h>
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
using namespace std::chrono_literals;
在 main() 函数中,我们定义了计数器和互斥锁:
uint32_t counter1{};
std::mutex mtx1;
uint32_t counter2{};
std::mutex mtx2;
在生成线程之前,让我们设置一个多目标记录器,这是一种可以将日志消息写入控制台和日志文件的记录器。我们还将设置其日志级别为调试,使记录器发布所有严重性级别大于调试的日志消息,每行日志的格式包括时间戳、线程标识符、日志级别和日志消息:
auto console_sink = std::make_shared<
spdlog::sinks::stdout_color_sink_mt>();
console_sink->set_level(spdlog::level::debug);
auto file_sink = std::make_shared<
spdlog::sinks::basic_file_sink_mt>("logging.log",
true);
file_sink->set_level(spdlog::level::info);
spdlog::logger logger("multi_sink",
{console_sink, file_sink});
logger.set_pattern(
"%Y-%m-%d %H:%M:%S.%f - Thread %t [%l] : %v");
logger.set_level(spdlog::level::debug);
我们还声明了一个increase_and_swap lambda 函数,该函数增加两个计数器的值并交换它们:
auto increase_and_swap = [&]() {
logger.info("Incrementing both counters...");
counter1++;
counter2++;
logger.info("Swapping counters...");
std::swap(counter1, counter2);
};
两个工作 lambda 函数worker1和worker2获取两个互斥锁,并在退出前调用increase_and_swap()。由于使用了锁保护(std::lock_guard)对象,因此在销毁工作 lambda 函数时释放互斥锁:
auto worker1 = [&]() {
logger.debug("Entering worker1");
logger.info("Locking mtx1...");
std::lock_guard<std::mutex> lock1(mtx1);
logger.info("Mutex mtx1 locked");
std::this_thread::sleep_for(100ms);
logger.info("Locking mtx2...");
std::lock_guard<std::mutex> lock2(mtx2);
logger.info("Mutex mtx2 locked");
increase_and_swap();
logger.debug("Leaving worker1");
};
auto worker2 = [&]() {
logger.debug("Entering worker2");
logger.info("Locking mtx2...");
std::lock_guard<std::mutex> lock2(mtx2);
logger.info("Mutex mtx2 locked");
std::this_thread::sleep_for(100ms);
logger.info("Locking mtx1...");
std::lock_guard<std::mutex> lock1(mtx1);
logger.info("Mutex mtx1 locked");
increase_and_swap();
logger.debug("Leaving worker2");
};
logger.debug("Starting main function...");
std::thread t1(worker1);
std::thread t2(worker2);
t1.join();
t2.join();
两个工作 lambda 函数worker1和worker2相似,但有一个小差异:worker1先获取mutex1然后获取mutex2,而worker2则相反,先获取mutex2然后获取mutex1。在获取两个互斥锁之间有一个睡眠期,以便其他线程获取其互斥锁,因此,这会导致死锁,因为worker1将获取mutex1而worker2将获取mutex2。
然后,在睡眠之后,worker1将尝试获取mutex2,而worker2将尝试获取mutex1,但它们都不会成功,永远在死锁中阻塞。
以下是在运行此代码时的输出:
2024-09-04 23:39:54.484005 - Thread 38984 [debug] : Starting main function...
2024-09-04 23:39:54.484106 - Thread 38985 [debug] : Entering worker1
2024-09-04 23:39:54.484116 - Thread 38985 [info] : Locking mtx1...
2024-09-04 23:39:54.484136 - Thread 38986 [debug] : Entering worker2
2024-09-04 23:39:54.484151 - Thread 38986 [info] : Locking mtx2...
2024-09-04 23:39:54.484160 - Thread 38986 [info] : Mutex mtx2 locked
2024-09-04 23:39:54.484146 - Thread 38985 [info] : Mutex mtx1 locked
2024-09-04 23:39:54.584250 - Thread 38986 [info] : Locking mtx1...
2024-09-04 23:39:54.584255 - Thread 38985 [info] : Locking mtx2...
在检查日志时,首先要注意的症状是程序从未完成,因此很可能处于死锁状态。
从记录器输出中,我们可以看到t1(线程38985)正在运行worker1,而t2(线程38986)正在运行worker2。一旦t1进入worker1,它就获取mtx1。然而,mtx2互斥锁是由t2获取的,因为worker2一启动就获取了。然后,两个线程等待 100 毫秒并尝试获取另一个互斥锁,但都没有成功,程序保持阻塞。
记录在生产系统中是必不可少的,但如果过度使用,则会对性能造成一些惩罚,并且大多数时候需要人工干预来调查问题。作为日志详细程度和性能惩罚之间的折衷方案,一个人可能会选择实现不同的日志级别,在正常操作期间仅记录主要事件,同时仍然保留在需要时提供极其详细日志的能力。在开发周期早期自动检测代码中的错误的一种更自动化的方法是使用测试和代码清理器,我们将在下一章中学习这些内容。
并非所有错误都可以检测到,因此通常使用调试器是跟踪和修复软件中的错误的方法。让我们接下来学习如何调试多线程和异步代码。
如何调试异步软件
调试是查找和修复计算机程序中的错误的过程。
在本节中,我们将探讨几种调试多线程和异步软件的技术。您必须具备一些使用调试器的先验知识,例如 GDB(GNU 项目调试器)或 LLDB(LLVM 低位调试器),以及调试过程的术语,如断点、观察者、回溯、帧和崩溃报告。
GDB 和 LLDB 都是优秀的调试器,它们的大多数命令都是相同的,只有少数命令不同。如果程序是在 macOS 上调试或针对大型代码库,LLDB 可能更受欢迎。另一方面,GDB 拥有稳定的传统,许多开发者都熟悉它,并支持更广泛的架构和平台。在本节中,我们将使用 GDB 15.1,因为它属于 GNU 框架,并且被设计为与 g++ 编译器协同工作,但随后显示的大多数命令也可以在用 clang++ 编译的程序上使用 LLDB 进行调试。
由于一些处理多线程和异步代码的调试器功能仍在开发中,请始终更新调试器到最新版本,以包括最新的功能和修复。
一些有用的 GDB 命令
让我们从一些在调试任何类型程序时都很有用的 GDB 命令开始,并为下一节打下基础。
在调试程序时,我们可以启动调试器并将程序作为参数传递。程序可能需要的额外参数可以通过 – args 选项传递:
$ gdb <program> --args <args>
或者,我们可以通过使用其 进程 标识符(PID)来将调试器附加到正在运行的程序:
$ gdb –p <PID>
一旦进入调试器,我们可以运行程序(使用 run 命令)或启动它(使用 start 命令)。运行意味着程序执行直到达到断点或完成。start 仅在 main() 函数的开始处放置一个临时断点并运行程序,在程序开始处停止执行。
例如,如果我们想调试已经崩溃的程序,我们可以使用由崩溃生成的 core dump 文件,该文件可能存储在系统中的特定位置(通常在 Linux 系统上是 /var/lib/apport/coredump/,但请通过访问官方文档来检查您系统中的确切位置)。此外,请注意,通常 core dump 默认是禁用的,需要运行 ulimit -c unlimited 命令,在程序崩溃之前和同一 shell 中执行。如果处理的是特别大的程序或系统磁盘空间不足,可以将 unlimited 参数更改为某个任意限制。
在生成 coredump 文件后,只需将其复制到程序二进制文件所在的目录,并使用以下命令:
$ gdb <program> <coredump>
注意,所有二进制文件都必须有调试符号,因此必须使用**–g**选项编译。在生产系统中,发布二进制文件通常移除了符号并存储在单独的文件中。有 GDB 命令可以包含这些符号,以及命令行工具可以检查它们,但这个主题超出了本书的范围。
一旦调试器开始运行,我们可以使用 GDB 命令在代码中导航或检查变量。一些有用的命令如下:
-
info args:这会显示用于调用当前函数的参数信息。
-
info locals:这会显示当前作用域中的局部变量。
-
whatis:这会显示给定变量或表达式的类型。
-
return:这会从当前函数返回,而不执行其余的指令。可以指定返回值。
-
backtrace:这会列出当前调用栈中的所有栈帧。
-
frame:这允许你切换到特定的栈帧。
-
up,down:这会在调用栈中移动,向当前函数的调用者(up)或被调用者(down)移动。
-
print:这会评估并显示一个表达式的值,该表达式可以是变量名、类成员、指向内存区域的指针或直接是内存地址。我们还可以定义漂亮的打印器来显示我们自己的类。
让我们以调试程序最基本但也是最常用的技术之一来结束本节。这种技术被称为printf。每个开发者都使用过printf或替代命令来打印变量内容,以便在代码路径上的战略位置显示其内容。在 GDB 中,dprintf命令有助于设置在遇到断点时打印信息的printf样式断点,而不会停止程序执行。这样,我们可以在调试程序时使用打印语句,而无需修改代码、重新编译和重启程序。
其语法如下:
$ dprintf <location>, <format>, <args>
例如,如果我们想在第 25 行设置一个printf语句来打印x变量的内容,但只有当其值大于5时,这是命令:
$ dprintf 25, "x = %d\n", x if x > 5
现在我们已经建立了一些基础,让我们从调试一个多线程程序开始。
调试多线程程序
这里显示的示例永远不会结束,因为会发生死锁,因为不同的线程以不同的顺序锁定两个互斥锁,正如在本章介绍日志时已经解释过的:
#include <chrono>
#include <mutex>
#include <thread>
using namespace std::chrono_literals;
int main() {
std::mutex mtx1, mtx2;
std::thread t1([&]() {
std::lock_guard lock1(mtx1);
std::this_thread::sleep_for(100ms);
std::lock_guard lock2(mtx2);
});
std::thread t2([&]() {
std::lock_guard lock2(mtx2);
std::this_thread::sleep_for(100ms);
std::lock_guard lock1(mtx1);
});
t1.join();
t2.join();
return 0;
}
首先,让我们使用g++编译这个示例,并添加调试符号(–g选项)以及不允许代码优化(–O0选项),防止编译器重构二进制代码,使调试器更难通过使用**–fno-omit-frame-pointer**选项找到并显示相关信息。
以下命令编译test.cpp源文件并生成test二进制文件。我们还可以使用**clang++**以相同的选项:
$ g++ -o test –g -O0 --fno-omit-frame-pointer test.cpp
如果我们运行生成的程序,它将永远不会结束:
$ ./test
要调试一个正在运行的程序,我们首先使用 ps Unix 命令检索其 PID:
$ ps aux | grep test
然后,通过提供 pid 来附加调试器并开始调试程序:
$ gdb –p <pid>
假设调试器以以下消息开始:
ptrace: Operation not permitted.
然后,只需运行以下命令:
$ sudo sysctl -w kernel.yama.ptrace_scope=0
一旦 GDB 正确启动,你将能够在其提示符中输入命令。
我们可以执行的第一条命令是下一个,以检查正在运行的线程:
(gdb) info threads
Id Target Id Frame
* 1 Thread 0x79d1f3883740 (LWP 14428) "test" 0x000079d1f3298d61 in __futex_abstimed_wait_common64 (private=128, cancel=true, abstime=0x0, op=265, expected=14429, futex_word=0x79d1f3000990)
at ./nptl/futex-internal.c:57
2 Thread 0x79d1f26006c0 (LWP 14430) "test" futex_wait (private=0, expected=2, futex_word=0x7fff5e406b00) at ../sysdeps/nptl/futex-internal.h:146
3 Thread 0x79d1f30006c0 (LWP 14429) "test" futex_wait (private=0, expected=2, futex_word=0x7fff5e406b30) at ../sysdeps/nptl/futex-internal.h:146
输出显示,具有 GDB 标识符 1 的 0x79d1f3883740 线程是当前线程。如果有许多线程,而我们只对特定的子集感兴趣,比如说线程 1 和 3,我们可以使用以下命令仅显示那些线程的信息:
(gdb) info thread 1 3
运行一个 GDB 命令将影响当前线程。例如,运行 bt 命令将显示线程 1 的回溯(输出已简化):
(gdb) bt
#0 0x000079d1f3298d61 in __futex_abstimed_wait_common64 (private=128, cancel=true, abstime=0x0, op=265, expected=14429, futex_word=0x79d1f3000990) at ./nptl/futex-internal.c:57
#5 0x000061cbaf1174fd in main () at 11x18-debug_deadlock.cpp:22
要切换到另一个线程,例如线程 2,我们可以使用 thread 命令:
(gdb) thread 2
[Switching to thread 2 (Thread 0x79d1f26006c0 (LWP 14430))]
现在,bt 命令将显示线程 2 的回溯(输出已简化):
(gdb) bt
#0 futex_wait (private=0, expected=2, futex_word=0x7fff5e406b00) at ../sysdeps/nptl/futex-internal.h:146
#2 0x000079d1f32a00f1 in lll_mutex_lock_optimized (mutex=0x7fff5e406b00) at ./nptl/pthread_mutex_lock.c:48
#7 0x000061cbaf1173fa in operator() (__closure=0x61cbafd64418) at 11x18-debug_deadlock.cpp:19
要在不同的线程中执行命令,只需使用 thread apply 命令,在这种情况下,在线程 1 和 3 上执行 bt 命令:
(gdb) thread apply 1 3 bt
要在所有线程中执行命令,只需使用 thread apply all 。
注意,当多线程程序中的断点被达到时,所有执行线程都会停止运行,从而允许检查程序的整体状态。当通过 continue、step 或 next 等命令重新启动执行时,所有线程将恢复。当前线程将向前移动一个语句,但其他线程向前移动几个语句或甚至在语句中间停止是不确定的。
当执行停止时,调试器将跳转并显示当前线程的执行上下文。为了避免调试器通过锁定调度器在线程之间跳转,我们可以使用以下命令:
(gdb) set scheduler-locking <on/off>
我们还可以使用以下命令来检查调度器锁定状态:
(gdb) show scheduler-locking
现在我们已经学习了一些用于多线程调试的新命令,让我们检查一下我们附加到调试器中的应用程序发生了什么。
如果我们检索线程 2 和 3 的回溯,我们可以看到以下内容(仅输出简化版,仅显示相关部分):
(gdb) thread apply all bt
Thread 3 (Thread 0x79d1f30006c0 (LWP 14429) "test"):
#0 futex_wait (private=0, expected=2, futex_word=0x7fff5e406b30) at ../sysdeps/nptl/futex-internal.h:146
#5 0x000061cbaf117e20 in std::mutex::lock (this=0x7fff5e406b30) at /usr/include/c++/14/bits/std_mutex.h:113
#7 0x000061cbaf117334 in operator() (__closure=0x61cbafd642b8) at 11x18-debug_deadlock.cpp:13
Thread 2 (Thread 0x79d1f26006c0 (LWP 14430) "test"):
#0 futex_wait (private=0, expected=2, futex_word=0x7fff5e406b00) at ../sysdeps/nptl/futex-internal.h:146
#5 0x000061cbaf117e20 in std::mutex::lock (this=0x7fff5e406b00) at /usr/include/c++/14/bits/std_mutex.h:113
#7 0x000061cbaf1173fa in operator() (__closure=0x61cbafd64418) at 11x18-debug_deadlock.cpp:19
注意,在运行 std::mutex::lock() 之后,两个线程都在第 13 行等待线程 3,在第 19 行等待线程 2,这与 std::thread t1 中的 std::lock_guard lock2 和 std::thread t2 中的 std::lock_guard lock1 相匹配。
因此,我们在这些代码位置检测到了这些线程中发生的死锁。
现在我们来学习更多关于通过捕获竞态条件来调试多线程软件的知识。
调试竞态条件
竞态条件是最难检测和调试的 bug 之一,因为它们通常以间歇性的方式发生,每次发生时都有不同的效果,有时在程序达到失败点之前会发生一些昂贵的计算。
这种不稳定的行为不仅由竞态条件引起。与不正确的内存分配相关的其他问题也可能导致类似症状,因此,在调查并达到根本原因诊断之前,无法将 bug 分类为竞态条件。
调试竞态条件的一种方法是通过 watchpoints 手动检查变量是否在没有当前线程中执行的任何语句修改它的情况下更改其值,或者放置在特定线程触发的策略位置上的断点,如下所示:
(gdb) break <linespec> thread <id> if <condition>
例如,参见以下内容:
(gdb) break test.cpp:11 thread 2
或者,甚至可以使用断言并检查任何由不同线程访问的变量的当前值是否具有预期的值。这种方法在下一个示例中得到了应用:
#include <cassert>
#include <chrono>
#include <cmath>
#include <iostream>
#include <mutex>
#include <thread>
using namespace std::chrono_literals;
static int g_value = 0;
static std::mutex g_mutex;
void func1() {
const std::lock_guard<std::mutex> lock(g_mutex);
for (int i = 0; i < 10; ++i) {
int old_value = g_value;
int incr = (rand() % 10);
g_value += incr;
assert(g_value == old_value + incr);
std::this_thread::sleep_for(10ms);
}
}
void func2() {
for (int i = 0; i < 10; ++i) {
int old_value = g_value;
int incr = (rand() % 10);
g_value += (rand() % 10);
assert(g_value == old_value + incr);
std::this_thread::sleep_for(10ms);
}
}
int main() {
std::thread t1(func1);
std::thread t2(func2);
t1.join();
t2.join();
return 0;
}
在这里,两个线程t1和t2正在运行增加g_value全局变量随机值的函数。每次增加时,都会将g_value与预期值进行比较,如果不相等,断言指令将停止程序。
按照以下方式编译此程序并运行调试器:
$ g++ -o test -g -O0 test
$ gdb ./test
调试器启动后,使用运行命令来运行程序。程序将运行,并在某个时刻由于收到SIGABRT信号而终止,表明断言未满足。
test: test.cpp:29: void func2(): Assertion `g_value == old_value + incr' failed.
Thread 3 "test" received signal SIGABRT, Aborted.
程序停止后,我们可以使用backtrace命令检查该点的回溯,并将该点失败处的源代码更改为特定的帧或列表。
这个例子相当简单,所以通过检查断言输出,可以清楚地看出g_value变量出了问题,这很可能是竞态条件。
但是,对于更复杂的程序,手动调试问题的这个过程相当困难,所以让我们关注另一种称为反向调试的技术,它可以帮助我们解决这个问题。
反向调试
反向调试,也称为时间旅行调试,允许调试器在程序失败后停止程序,并回溯到程序执行的记录中,以调查失败的原因。此功能通过记录(记录)正在调试的程序中的每个机器指令以及内存和寄存器值的每次更改来实现,之后,使用这些记录随意回放和重放程序。
在 Linux 上,我们可以使用 GDB(自 7.0 版本起)、rr(最初由 Mozilla 开发,rr-project.org)或Undo 的时光旅行调试器(UDB)(docs.undo.io)。在 Windows 上,我们可以使用时光旅行调试(learn.microsoft.com/en-us/windows-hardware/drivers/debuggercmds/time-travel-debugging-overview)。
反向调试仅由有限数量的 GDB 目标支持,例如远程目标 Simics、系统集成和设计(SID)模拟器或原生 Linux 的进程记录和回放目标(仅适用于i386、amd64、moxie-elf和arm)。在撰写本书时,Clang 的反向调试功能仍在开发中。
因此,由于这些限制,我们决定通过使用rr进行一个小型展示。请按照项目网站上的说明构建和安装rr调试工具:github.com/rr-debugger/rr/wiki/Building-And-Installing。
安装后,要记录和回放程序,请使用以下命令:
$ rr record <program> --args <args>
$ rr replay
例如,如果我们有一个名为test的程序,命令序列将如下所示:
$ rr record test
rr: Saving execution to trace directory `/home/user/.local/share/rr/test-1'.
如果显示以下致命错误:
[FATAL src/PerfCounters.cc:349:start_counter()] rr needs /proc/sys/kernel/perf_event_paranoid <= 3, but it is 4.
Change it to <= 3.
Consider putting 'kernel.perf_event_paranoid = 3' in /etc/sysctl.d/10-rr.conf.
然后,使用以下命令调整内核变量,kernel.perf_event_paranoid:
$ sudo sysctl kernel.perf_event_paranoid=1
一旦有记录可用,请使用replay命令开始调试程序:
$ rr replay
或者,如果程序崩溃并且你只想在记录的末尾开始调试,请使用**–** e选项:
$ rr replay -e
在这一点上,rr将使用 GDB 调试器启动程序并加载其调试符号。然后,你可以使用以下任何命令进行反向调试:
-
reverse-continue:以反向方式开始执行程序。执行将在达到断点或由于同步异常而停止。
-
reverse-next:反向运行到当前栈帧中之前执行的上一行的开始。
-
reverse-nexti:这会反向执行一条指令,跳转到内部栈帧。
-
reverse-step:运行程序直到控制达到新源行的开始。
-
reverse-stepi:反向执行一条机器指令。
-
reverse-finish:这会执行到当前函数调用,即当前函数的开始处。
我们也可以通过使用以下命令来反转调试方向,并使用正向调试的常规命令(如next、step、continue等)在相反方向进行:
(rr) set exec-direction reverse
要将执行方向恢复到正向,请使用以下命令:
(rr) set exec-direction forward
作为练习,安装rr调试器并尝试使用反向调试来调试前面的示例。
现在让我们继续探讨如何调试协程,由于协程的异步特性,这是一个具有挑战性的任务。
调试协程
如我们所见,异步代码可以通过在战略位置使用断点、使用观察点检查变量、进入或跳过代码来像同步代码一样进行调试。此外,使用前面描述的技术选择特定线程并锁定调度器有助于在调试时避免不必要的干扰。
如我们所已了解,异步代码中存在复杂性,例如异步代码执行时将使用哪个线程,这使得调试更加困难。对于 C++ 协程,由于它们的挂起/恢复特性,调试甚至更难掌握。
Clang 使用两步编译使用协程的程序:语义分析由 Clang 执行,协程帧在 LLVM 中间端构建和优化。由于调试信息是在 Clang 前端生成的,因此在编译过程中较晚生成协程帧时,将会有不足的调试信息。GCC 采用类似的方法。
此外,如果执行在协程内部中断,当前帧将只有一个变量,frame_ptr。在协程中,没有指针或函数参数。协程在挂起之前将它们的状态存储在堆中,并且在执行期间只使用栈。frame_ptr 用于访问协程正常运行所需的所有必要信息。
让我们调试在 第九章 中实现的 Boost.Asio 协程示例。在这里,我们只展示相关的指令。请访问 第九章 中的 协程 部分,以检查完整的源代码:
boost::asio::awaitable<void> echo(tcp::socket socket) {
char data[1024];
while (true) {
std::cout << "Reading data from socket...\n";//L12
std::size_t bytes_read = co_await
socket.async_read_some(
boost::asio::buffer(data),
boost::asio::use_awaitable);
/* .... */
co_await boost::asio::async_write(socket,
boost::asio::buffer(data, bytes_read),
boost::asio::use_awaitable);
}
}
boost::asio::awaitable<void>
listener(boost::asio::io_context& io_context,
unsigned short port) {
tcp::acceptor acceptor(io_context,
tcp::endpoint(tcp::v4(), port));
while (true) {
std::cout << "Accepting connections...\n"; // L45
tcp::socket socket = co_await
acceptor.async_accept(
boost::asio::use_awaitable);
boost::asio::co_spawn(io_context,
echo(std::move(socket)),
boost::asio::detached);
}
}
/* main function */
由于我们使用 Boost,让我们在编译源代码时包含 Boost.System 库,以添加更多符号以进行调试:
$ g++ --std=c++20 -ggdb -O0 --fno-omit-frame-pointer -lboost_system test.cpp -o test
然后,我们使用生成的程序启动调试器,并在第 12 行和第 45 行设置断点,这些是每个协程中 while 循环内第一条指令的位置:
$ gdb –q ./test
(gdb) b 12
(gdb) b 45
我们还启用了 GDB 内置的格式化打印器,以显示标准模板库容器的可读输出:
(gdb) set print pretty on
如果现在运行程序(运行命令),它将在接受连接之前到达协程监听器内的第 42 行的断点。使用 info locals 命令,我们可以检查局部变量。
协程创建了一个具有多个内部字段的状态机,例如带有线程的承诺对象、调用对象的地址、挂起的异常等。它们还存储 resume 和 destroy 回调。这些结构是编译器依赖的,与编译器的实现相关联,并且如果我们使用 Clang,可以通过 frame_ptr 访问。
如果我们继续运行程序(使用继续命令),服务器将等待客户端连接。要退出等待状态,我们使用telnet,如第九章所示,将客户端连接到服务器。此时,执行将停止,因为达到echo()协程内部第 12 行的断点,并且info locals显示了每个echo连接使用的变量。
使用回溯命令将显示一个调用栈,由于协程的挂起特性,可能存在一些复杂性。
在纯 C++例程中,如第八章所述,有两个设置断点可能有趣的表达式:
-
co_await:执行将在等待的操作完成后挂起。可以通过检查底层的await_suspend、await_resume或自定义可等待代码来在协程恢复的点设置断点。
-
co_yield:挂起执行并返回一个值。在调试期间,进入co_yield以观察控制流如何在协程及其调用函数之间进行。
由于协程在 C++世界中相当新颖,并且编译器持续发展,我们希望不久的将来调试协程将更加直接。
一旦我们找到并调试了一些错误,并且可以重现导致这些特定错误的场景,设计一些涵盖这些情况的测试将很方便,以避免未来代码更改可能导致类似问题或事件。让我们在下一章学习如何测试多线程和异步代码。
摘要
在本章中,我们学习了如何使用日志和调试异步程序。
我们从使用日志来发现运行软件中的问题开始,展示了使用spdlog日志库检测死锁的有用性。还讨论了许多其他库,描述了它们可能适合特定场景的相关功能。
然而,并非所有错误都可以通过使用日志来发现,有些错误可能只能在软件开发生命周期后期,当生产中出现问题时才会被发现,即使在处理程序崩溃和事件时也是如此。调试器是检查运行或崩溃程序的有用工具,了解其代码路径,并找到错误。介绍了几个示例和调试器命令来处理通用代码,但也特别针对多线程和异步软件、竞态条件和协程。还介绍了rr调试器,展示了将反向调试纳入我们的开发者工具箱的潜力。
在下一章中,我们将学习使用 sanitizers 和测试技术来性能和优化技术,这些技术可以用来改善异步程序的运行时间和资源使用。
进一步阅读
-
日志:https://en.wikipedia.org/wiki/Logging_(computing)
-
Syslog:https://en.wikipedia.org/wiki/Syslog
-
Google 日志库 : https://github.com/google/glog
-
Apache Log4cxx : https://logging.apache.org/log4cxx
-
spdlog : https://github.com/gabime/spdlog
-
Quill : https://github.com/odygrd/quill
-
xtr : https://github.com/choll/xtr
-
lwlog : https://github.com/ChristianPanov/lwlog
-
uberlog : https://github.com/IMQS/uberlog
-
Easylogging++ : https://github.com/abumq/easyloggingpp
-
NanoLog : https://github.com/PlatformLab/NanoLog
-
Reckless 日志库 : https://github.com/mattiasflodin/reckless
-
tracetool :
github.com/froglogic/tracetool -
Logback 项目 :
logback.qos.ch -
Sentry :
sentry.io -
Graylog :
graylog.org -
Logstash :
www.elastic.co/logstash -
使用 GDB 调试 :
sourceware.org/gdb/current/onlinedocs/gdb.html -
LLDB 教程 :
lldb.llvm.org/use/tutorial.html -
Clang 编译器用户手册 :
clang.llvm.org/docs/UsersManual.html -
GDB : 运行程序反向执行 :
www.zeuthen.desy.de/dv/documentation/unixguide/infohtml/gdb/Reverse-Execution.html#Reverse-Execution -
使用 GDB 进行反向调试 :
sourceware.org/gdb/wiki/ReverseDebug -
调试 C++ 协程 :
clang.llvm.org/docs/DebuggingCoroutines.html -
SID 模拟器用户手册 :
sourceware.org/sid/sid-guide/book1.html -
Intel Simics 模拟器用于 Intel FPGAs: 用户手册 :
www.intel.com/content/www/us/en/docs/programmable/784383/24-1/about-this-document.html -
IBM 支持 : 如何启用核心转储 :
www.ibm.com/support/pages/how-do-i-enable-core-dumps -
核心转储 – 如何启用它们? :
medium.com/@sourabhedake/core-dumps-how-to-enable-them-73856a437711
第十二章:清理和测试异步软件
测试是评估和验证软件解决方案是否按预期工作,验证其质量并确保满足用户需求的过程。通过适当的测试,我们可以预防错误的发生并提高性能。
在本章中,我们将探讨几种测试异步软件的技术,主要使用GoogleTest库以及来自GNU 编译器集合(GCC)和Clang编译器的清理器。需要一些单元测试的先验知识。在本章末尾的进一步阅读部分,您可以找到一些可能有助于刷新和扩展这些领域知识的参考资料。
在本章中,我们将涵盖以下主要主题:
-
清理代码以分析软件并查找潜在问题
-
测试异步代码
技术要求
对于本章,我们需要安装GoogleTest(google.github.io/googletest)来编译一些示例。
一些示例需要支持 C++20 的编译器。因此,请参阅第三章中的技术要求部分,因为它包含有关如何安装 GCC 13 和 Clang 8 编译器的指导。
您可以在以下 GitHub 仓库中找到所有完整代码:
github.com/PacktPublishing/Asynchronous-Programming-with-CPP
本章的示例位于Chapter_12文件夹下。所有源代码文件都可以使用以下 CMake 编译:
$ cmake . && cmake —build .
可执行二进制文件将在bin目录下生成。
清理代码以分析软件并查找潜在问题
清理器是工具,最初由 Google 开发,用于检测和预防代码中各种类型的问题或安全漏洞,帮助开发者尽早在开发过程中捕捉到错误,减少后期修复问题的成本,并提高软件的稳定性和安全性。
清理器通常集成到开发环境中,并在手动测试或运行单元测试、持续集成(CI)管道或代码审查管道时启用。
C++ 编译器,如 GCC 和 Clang,在构建程序时提供编译器选项以生成代码,以跟踪运行时的执行并报告错误和漏洞。这些功能从 Clang 3.1 版本和 GCC 4.8 版本开始实现。
由于向程序的二进制代码中注入了额外的指令,根据清理器类型,性能惩罚约为 1.5 倍到 4 倍减慢。此外,总体内存开销为 2 倍到 4 倍,堆栈大小增加最多 3 倍。但请注意,减慢程度远低于使用其他仪器框架或动态分析工具(如Valgrind valgrind.org)时遇到的减慢,后者比生产二进制文件慢高达 50 倍。另一方面,使用 Valgrind 的好处是不需要重新编译。两种方法都仅在程序运行时检测问题,并且仅在执行遍历的代码路径上检测。因此,我们需要确保足够的覆盖率。
此外,还有静态分析工具和代码检查器,它们在编译期间检测问题并检查程序中包含的所有代码,非常有用。例如,通过启用**–Werror**、–Wall和**–pedantic**选项,编译器如 GCC 和 Clang 可以执行额外的检查并提供有用的信息。
此外,还有开源替代方案,如Cppcheck或Flawfinder,或免费提供给开源项目的商业解决方案,如PVS-Studio或Coverity Scan。其他解决方案,如SonarQube、CodeSonar或OCLint,可用于持续集成/持续交付(CI/CD)管道中的持续质量跟踪。
在本节中,我们将重点关注可以通过向编译器传递一些特殊选项来启用的清理器。
编译器选项
要启用清理器,我们需要在编译程序时传递一些编译器选项。
主要选项是**–fsanitize=sanitizer_name**,其中sanitizer_name是以下选项之一:
-
地址:这是针对AddressSanitizer(ASan),用于检测内存错误,如缓冲区溢出和使用后释放错误
-
线程:这是针对ThreadSanitizer(TSan),通过监控线程交互来识别多线程程序中的数据竞争和其他线程同步问题
-
泄露:这是针对LeakSanitizer(LSan),通过跟踪内存分配并确保所有分配的内存都得到适当释放来发现内存泄露
-
内存:这是针对MemorySanitizer(MSan),用于揭示未初始化内存的使用
-
未定义:这是针对UndefinedBehaviorSanitizer(UBSan),用于检测未定义行为,例如整数溢出、无效类型转换和其他错误操作
Clang 还包括dataflow、cfi(控制流完整性)、safe_stack和realtime。
GCC 增加了kernel-address、hwaddress、kernel-hwaddress、pointer-compare、pointer-subtract和shadow-call-stack。
由于此列表和标志行为可能会随时间而变化,建议检查编译器的官方文档。
可能需要额外的标志:
-
-fno-omit-frame-pointer:帧指针是编译器用来跟踪当前堆栈帧的寄存器,其中包含其他信息,如当前函数的基址。省略帧指针可能会提高程序的性能,但代价是使调试变得非常困难;它使得定位局部变量和重建堆栈跟踪更加困难。
-
-g:包含调试信息,并在警告消息中显示文件名和行号。如果使用调试器 GDB,则可能希望使用**–ggdb选项,因为编译器可以生成更易于调试的符号。还可以通过使用–g[level]指定一个级别,其中[level]是一个从0到3的值,每次级别增加都会添加更多的调试信息。默认级别是2**。
-
–fsanitize-recover:这些选项会导致清理器尝试继续运行程序,就像没有检测到错误一样。
-
–fno-sanitize-recover:清理器将仅检测到第一个错误,并且程序将以非零退出码退出。
为了保持合理的性能,我们可能需要通过指定**–O[num]选项来调整优化级别。不同的清理器在一定的优化级别上表现最佳。最好从–O0开始,如果减速显著,尝试增加到–O1**、–O2等。此外,由于不同的清理器和编译器推荐特定的优化级别,请检查它们的文档。
当使用 Clang 时,为了使堆栈跟踪易于理解,并让清理器将地址转换为源代码位置,除了使用前面提到的标志外,我们还可以将特定的环境变量**[X]SAN_SYMBOLIZER_PATH设置为llvm-symbolizer的位置(其中[X]为A表示 AddressSanitizer,L表示 LSan,M表示 MSan 等)。我们还可以将此位置包含在PATH环境变量中。以下是在使用AddressSanitizer时设置PATH**变量的示例:
export ASAN_SYMBOLIZER_PATH=`which llvm-symbolizer`
export PATH=$ASAN_SYMBOLIZER_PATH:$PATH
注意,启用**–Werror**与某些清理器一起可能会导致误报。此外,可能还需要其他编译器标志,但执行期间的警告消息将显示正在发生问题,并且将明显表明需要某个标志。请检查清理器和编译器的文档,以找到在那些情况下应使用的标志。
避免对代码部分进行清理
有时,我们可能希望静音某些清理器警告,并跳过某些函数的清理,原因如下:这是一个已知问题,该函数是正确的,这是一个误报,该函数需要加速,或者这是一个第三方库的问题。在这些情况下,我们可以使用抑制文件或通过使用某些宏指令排除代码区域。还有一个黑名单机制,但由于它已被抑制文件取代,我们在此不做评论。
使用抑制文件,我们只需要创建一个文本文件,列出我们不希望清理器运行的代码区域。每一行都包含一个模式,该模式根据清理器的不同而有所不同,但通常结构如下:
type:location_pattern
在这里,type 表示抑制的类型,例如,leak 和 race 值,而 location_pattern 是匹配要抑制的函数或库名的正则表达式。下面是一个 ASan 的抑制文件示例,将在下一节中解释:
# Suppress known memory leaks in third-party function Func1 in library Lib1
leak:Lib1::Func1
# Ignore false-positive from function Func2 in library Lib2
race:Lib2::Func2
# Suppress issue from libc
leak:/usr/lib/libc.so.*
让我们称这个文件为 myasan.supp。然后,编译并使用以下命令将抑制文件传递给清理器通过 [X]SAN_OPTIONS:
$ clang++ -O0 -g -fsanitize=address -fno-omit-frame-pointer test.cpp –o test
$ ASAN_OPTIONS=suppressions=myasan.supp ./test
我们还可以在源代码中使用宏来排除特定的函数,使其不被清理器清理,如下所示使用 attribute((no_sanitize(“<sanitizer_name>”))):
#if defined(__clang__) || defined (__GNUC__)
# define ATTRIBUTE_NO_SANITIZE_ADDRESS __attribute__((no_sanitize_address))
#else
# define ATTRIBUTE_NO_SANITIZE_ADDRESS
#endif
...
ATTRIBUTE_NO_SANITIZE_ADDRESS
void ThisFunctionWillNotBeInstrumented() {...}
这种技术提供了对清理器应该对什么进行插装的细粒度编译时控制。
现在,让我们探索最常见的代码清理器类型,从与检查地址误用最相关的一种开始。
AddressSanitizer
ASan 的目的是检测由于数组越界访问、使用释放的内存块(堆、栈和全局)以及其他内存泄漏而发生的内存相关错误。
除了设置 -fsanitize=address 和之前推荐的其他标志外,我们还可以使用 –fsanitize-address-use-after-scope 来检测移出作用域后使用的内存,或者设置环境变量 ASAN_OPTIONS=option detect_stack_use_after_return=1 来检测返回后使用。
ASAN_OPTIONS 也可以用来指示 ASan 打印堆栈跟踪或设置日志文件,如下所示:
ASAN_OPTIONS=detect_stack_use_after_return=1,print_stacktrace=1,log_path=asan.log
Linux 上的 Clang 完全支持 ASan,其次是 Linux 上的 GCC。默认情况下,ASan 是禁用的,因为它会增加额外的运行时开销。
此外,ASan 处理所有对 glibc 的调用——这是为 GNU 系统提供核心库的 GNU C 库。然而,其他库的情况并非如此,因此建议使用 –fsanitize=address 选项重新编译此类库。如前所述,使用 Valgrind 不需要重新编译。
ASan 可以与 UBSan 结合使用,我们将在后面看到,但这会降低性能约 50%。
如果我们想要更激进的诊断清理,可以使用以下标志组合:
ASAN_OPTIONS=strict_string_checks=1:detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1
让我们看看使用 ASan 检测常见软件问题的两个示例,包括释放内存后继续使用和检测缓冲区溢出。
释放内存后的内存使用
软件中常见的一个问题是释放内存后继续使用。在这个例子中,堆中分配的内存被删除后仍在使用:
#include <iostream>
#include <memory>
int main() {
auto arr = new int[100];
delete[] arr;
std::cout << "arr[0] = " << arr[0] << '\n';
return 0;
}
假设之前的源代码在一个名为 test.cpp 的文件中。要启用 ASan,我们只需使用以下命令编译文件:
$ clang++ -fsanitize=address -fno-omit-frame-pointer -g -O0 –o test test.cpp
然后,执行生成的输出 test 程序,我们得到以下输出(注意,输出已简化,仅显示相关内容,可能因不同的编译器版本和执行环境而有所不同):
ERROR: AddressSanitizer: heap-use-after-free on address 0x514000000040 at pc 0x63acc82a0bec bp 0x7fff2d096c60 sp 0x7fff2d096c58
READ of size 4 at 0x514000000040 thread T0
#0 0x63acc82a0beb in main test.cpp:7:31
0x514000000040 is located 0 bytes inside of 400-byte region 0x514000000040,0x5140000001d0)
freed by thread T0 here:
#0 0x63acc829f161 in operator delete[ (/mnt/StorePCIE/Projects/Books/Packt/Book/Code/build/bin/Chapter_11/11x02-ASAN_heap_use_after_free+0x106161) (BuildId: 7bf8fe6b1f86a8b587fbee39ae3a5ced3e866931)
previously allocated by thread T0 here:
#0 0x63acc829e901 in operator new[](unsigned long) (/mnt/StorePCIE/Projects/Books/Packt/Book/Code/build/bin/Chapter_11/11x02-ASAN_heap_use_after_free+0x105901) (BuildId: 7bf8fe6b1f86a8b587fbee39ae3a5ced3e866931)
SUMMARY: AddressSanitizer: heap-use-after-free test.cpp:7:31 in main
输出显示 ASan 已应用并检测到一个堆使用后释放错误。这个错误发生在 T0 线程(主线程)。输出还指向了分配该内存区域的代码,稍后释放,以及其大小(400 字节区域)。
这类错误不仅发生在堆内存中,也发生在栈或全局区域分配的内存区域中。ASan 可以用来检测这类问题,例如内存溢出。
内存溢出
内存溢出,也称为缓冲区溢出或越界,发生在将某些数据写入超出缓冲区分配内存的地址时。
以下示例显示了一个堆内存溢出:
#include <iostream>
int main() {
auto arr = new int[100];
arr[0] = 0;
int res = arr[100];
std::cout << "res = " << res << '\n';
delete[] arr;
return 0;
}
编译并运行生成的程序后,这是输出:
ERROR: AddressSanitizer: heap-buffer-overflow on address 0x5140000001d0 at pc 0x582953d2ac07 bp 0x7ffde9d58910 sp 0x7ffde9d58908
READ of size 4 at 0x5140000001d0 thread T0
#0 0x582953d2ac06 in main test.cpp:6:13
0x5140000001d0 is located 0 bytes after 400-byte region 0x514000000040,0x5140000001d0)
allocated by thread T0 here:
#0 0x582953d28901 in operator new[ (test+0x105901) (BuildId: 82a16fc86e01bc81f6392d4cbcad0fe8f78422c0)
#1 0x582953d2ab78 in main test.cpp:4:14
(test+0x2c374) (BuildId: 82a16fc86e01bc81f6392d4cbcad0fe8f78422c0)
SUMMARY: AddressSanitizer: heap-buffer-overflow test.cpp:6:13 in main
从输出中我们可以看到,现在 ASan 报告了主线程(T0)在访问超过 400 字节区域(arr 变量)的内存地址时的堆缓冲区溢出错误。
集成到 ASan 中的清理器是 LSan。现在让我们学习如何使用这个清理器来检测内存泄漏。
LeakSanitizer
LSan 用于检测内存泄漏,当内存已分配但未正确释放时发生。
LSan 集成到 ASan 中,并在 Linux 系统上默认启用。在 macOS 上可以通过使用 ASAN_OPTIONS=detect_leaks=1 来启用它。要禁用它,只需设置 detect_leaks=0 。
如果使用 –fsanitize=leak 选项,程序将链接到支持 LSan 的 ASan 的子集,禁用编译时仪器并减少 ASan 的减速。请注意,此模式不如默认模式经过充分测试。
让我们看看一个内存泄漏的例子:
#include <string.h>
#include <iostream>
#include <memory>
int main() {
auto arr = new char[100];
strcpy(arr, "Hello world!");
std::cout << "String = " << arr << '\n';
return 0;
}
在这个例子中,分配了 100 字节(arr 变量),但从未释放。
要启用 LSan,我们只需使用以下命令编译文件:
$ clang++ -fsanitize=leak -fno-omit-frame-pointer -g -O2 –o test test.cpp
运行生成的测试二进制文件,我们得到以下结果:
ERROR: LeakSanitizer: detected memory leaks
Direct leak of 100 byte(s) in 1 object(s) allocated from:
#0 0x5560ba9a017c in operator new[](unsigned long) (test+0x3417c) (BuildId: 2cc47a28bb898b4305d90c048c66fdeec440b621)
#1 0x5560ba9a2564 in main test.cpp:6:16
SUMMARY: LeakSanitizer: 100 byte(s) leaked in 1 allocation(s).
LSan 正确报告了一个 100 字节大小的内存区域是通过使用操作符 new 分配的,但从未被删除。
由于本书探讨了多线程和异步编程,现在让我们了解一个用于检测数据竞争和其他线程问题的清理器:TSan。
ThreadSanitizer
TSan 用于检测线程问题,特别是数据竞争和同步问题。它不能与 ASan 或 LSan 结合使用。TSan 是与本书内容最一致的清理器。
通过指定 –fsanitize=thread 编译器选项启用此清理器,可以通过使用 TSAN_OPTIONS 环境变量来修改其行为。例如,如果我们想在第一次错误后停止,只需使用以下命令:
TSAN_OPTIONS=halt_on_error=1
此外,为了合理的性能,使用编译器的 – O2 选项。
TSan 只报告在运行时发生的竞争条件,因此它不会在未在运行时执行的代码路径中存在的竞争条件上发出警报。因此,我们需要设计提供良好覆盖率和使用真实工作负载的测试。
让我们看看 TSan 检测数据竞争的一些示例。在下一个示例中,我们将通过使用一个全局变量而不使用互斥锁来保护其访问来实现这一点:
#include <thread>
int globalVar{0};
void increase() {
globalVar++;
}
void decrease() {
globalVar--;
}
int main() {
std::thread t1(increase);
std::thread t2(decrease);
t1.join();
t2.join();
return 0;
}
编译程序后,使用以下命令启用 TSan:
$ clang++ -fsanitize=thread -fno-omit-frame-pointer -g -O2 –o test test.cpp
运行生成的程序会生成以下输出:
WARNING: ThreadSanitizer: data race (pid=31692)
Write of size 4 at 0x5932b0585ae8 by thread T2:
#0 decrease() test.cpp:10:12 (test+0xe0b32) (BuildId: 895b75ef540c7b44daa517a874d99d06bd27c8f7)
Previous write of size 4 at 0x5932b0585ae8 by thread T1:
#0 increase() test.cpp:6:12 (test+0xe0af2) (BuildId: 895b75ef540c7b44daa517a874d99d06bd27c8f7)
Thread T2 (tid=31695, running) created by main thread at:
#0 pthread_create <null> (test+0x6062f) (BuildId: 895b75ef540c7b44daa517a874d99d06bd27c8f7)
Thread T1 (tid=31694, finished) created by main thread at:
#0 pthread_create <null> (test+0x6062f) (BuildId: 895b75ef540c7b44daa517a874d99d06bd27c8f7)
SUMMARY: ThreadSanitizer: data race test.cpp:10:12 in decrease()
ThreadSanitizer: reported 1 warnings
从输出中可以看出,在increase()和decrease()函数访问globalVar时存在数据竞争。
如果我们决定使用 GCC 而不是 Clang,在运行生成的程序时可能会报告以下错误:
FATAL: ThreadSanitizer: unexpected memory mapping 0x603709d10000-0x603709d11000
这种内存映射问题是由称为地址空间布局随机化(ASLR)的安全功能引起的,这是一种操作系统使用的内存保护技术,通过随机化进程的地址空间来防止缓冲区溢出攻击。
一种解决方案是使用以下命令减少 ASLR:
$ sudo sysctl vm.mmap_rnd_bits=30
如果错误仍然发生,传递给vm.mmap_rnd_bits(在先前的命令中为30)的值可以进一步降低。为了检查该值是否正确设置,只需运行以下命令:
$ sudo sysctl vm.mmap_rnd_bits
vm.mmap_rnd_bits = 30
注意,此更改不是永久的。因此,当机器重新启动时,其值将设置为默认值。要持久化此更改,请将m.mmap_rnd_bits=30添加到**/etc/sysctl.conf**。
但这降低了系统的安全性,因此可能更倾向于使用以下命令临时禁用特定程序的 ASLR:
$ setarch `uname -m` -R ./test
运行上述命令将显示与使用 Clang 编译时显示的类似输出。
让我们转到另一个示例,其中std::map对象在没有互斥锁的情况下被访问。即使映射被用于不同的键值,因为写入std::map会使其迭代器无效,这也可能导致数据竞争:
#include <map>
#include <thread>
std::map<int,int> m;
void Thread1() {
m[123] = 1;
}
void Thread2() {
m[345] = 0;
}
int main() {
std::jthread t1(Thread1);
std::jthread t2(Thread1);
return 0;
}
编译并运行生成的二进制文件会生成大量输出,包含三个警告。在这里,我们只显示第一个警告中最相关的行(其他警告类似):
WARNING: ThreadSanitizer: data race (pid=8907)
Read of size 4 at 0x720c00000020 by thread T2:
Previous write of size 8 at 0x720c00000020 by thread T1:
Location is heap block of size 40 at 0x720c00000000 allocated by thread T1:
Thread T2 (tid=8910, running) created by main thread at:
Thread T1 (tid=8909, finished) created by main thread at:
SUMMARY: ThreadSanitizer: data race test.cpp:11:3 in Thread2()
当t1和t2线程都在向映射,m写入时,TSan 警告会标记。
在下一个示例中,只有一个辅助线程通过指针访问映射,但此线程与主线程竞争以访问和使用映射。t线程访问映射,m,以更改foo键的值;同时,主线程将其值打印到控制台:
#include <iostream>
#include <thread>
#include <map>
#include <string>
typedef std::map<std::string, std::string> map_t;
void *func(void *p) {
map_t& m = *static_cast<map_t*>(p);
m["foo"] = "bar";
return 0;
}
int main() {
map_t m;
std::thread t(func, &m);
std::cout << "foo = " << m["foo"] << '\n';
t.join();
return 0;
}
编译并运行此示例会生成大量输出,包含七个 TSan 警告。在这里,我们只显示第一个警告。您可以自由地通过在 GitHub 存储库中编译和运行示例来检查完整的报告:
WARNING: ThreadSanitizer: data race (pid=10505)
Read of size 8 at 0x721800003028 by main thread:
#8 main test.cpp:17:28 (test+0xe1d75) (BuildId: 8eef80df1b5c81ce996f7ef2c44a6c8a11a9304f)
Previous write of size 8 at 0x721800003028 by thread T1:
#0 operator new(unsigned long) <null> (test+0xe0c3b) (BuildId: 8eef80df1b5c81ce996f7ef2c44a6c8a11a9304f)
#9 func(void*) test.cpp:10:3 (test+0xe1bb7) (BuildId: 8eef80df1b5c81ce996f7ef2c44a6c8a11a9304f)
Location is heap block of size 96 at 0x721800003000 allocated by thread T1:
#0 operator new(unsigned long) <null> (test+0xe0c3b) (BuildId: 8eef80df1b5c81ce996f7ef2c44a6c8a11a9304f)
#9 func(void*) test.cpp:10:3 (test+0xe1bb7) (BuildId: 8eef80df1b5c81ce996f7ef2c44a6c8a11a9304f)
Thread T1 (tid=10507, finished) created by main thread at:
#0 pthread_create <null> (test+0x616bf) (BuildId: 8eef80df1b5c81ce996f7ef2c44a6c8a11a9304f)
SUMMARY: ThreadSanitizer: data race test.cpp:17:28 in main
ThreadSanitizer: reported 7 warnings
从输出中,TSan 正在警告访问在堆中分配的std::map对象时存在数据竞争。该对象是映射m。
然而,TSan 不仅可以通过缺少互斥锁来检测数据竞争,还可以报告何时变量必须是原子的。
下一个示例展示了这种情况。RefCountedObject 类定义了可以保持该类已创建对象数量的引用计数的对象。智能指针遵循这个想法,当计数器达到值 0 时,在销毁时删除底层分配的内存。在这个例子中,我们只展示了 Ref() 和 Unref() 函数,它们增加和减少引用计数变量 ref_。为了避免多线程环境中的问题,ref_ 必须是一个原子变量。正如这里所示,这并不是这种情况,t1 和 t2 线程正在修改 ref_,可能发生数据竞争:
#include <iostream>
#include <thread>
class RefCountedObject {
public:
void Ref() {
++ref_;
}
void Unref() {
--ref_;
}
private:
// ref_ should be atomic to avoid synchronization issues
int ref_{0};
};
int main() {
RefCountedObject obj;
std::jthread t1(&RefCountedObject::Ref, &obj);
std::jthread t2(&RefCountedObject::Unref, &obj);
return 0;
}
编译并运行此示例会产生以下输出:
WARNING: ThreadSanitizer: data race (pid=32574)
Write of size 4 at 0x7fffffffcc04 by thread T2:
#0 RefCountedObject::Unref() test.cpp:12:9 (test+0xe1dd0) (BuildId: 448eb3f3d1602e21efa9b653e4760efe46b621e6)
Previous write of size 4 at 0x7fffffffcc04 by thread T1:
#0 RefCountedObject::Ref() test.cpp:8:9 (test+0xe1c00) (BuildId: 448eb3f3d1602e21efa9b653e4760efe46b621e6)
Location is stack of main thread.
Location is global '??' at 0x7ffffffdd000 ([stack]+0x1fc04)
Thread T2 (tid=32577, running) created by main thread at:
#0 pthread_create <null> (test+0x6164f) (BuildId: 448eb3f3d1602e21efa9b653e4760efe46b621e6)
#2 main test.cpp:23:16 (test+0xe1b94) (BuildId: 448eb3f3d1602e21efa9b653e4760efe46b621e6)
Thread T1 (tid=32576, finished) created by main thread at:
#0 pthread_create <null> (test+0x6164f) (BuildId: 448eb3f3d1602e21efa9b653e4760efe46b621e6)
#2 main test.cpp:22:16 (test+0xe1b56) (BuildId: 448eb3f3d1602e21efa9b653e4760efe46b621e6)
SUMMARY: ThreadSanitizer: data race test.cpp:12:9 in RefCountedObject::Unref()
ThreadSanitizer: reported 1 warnings
TSan 输出显示,当访问之前由 Ref() 函数修改的内存位置时,Unref() 函数中发生了数据竞争条件。
数据竞争也可能发生在没有同步机制的情况下从多个线程初始化的对象中。在以下示例中,MyObj 类型的对象在 init_object() 函数中被创建,全局静态指针 obj 被分配其地址。由于此指针没有由互斥锁保护,当 t1 和 t2 线程分别从 func1() 和 func2() 函数尝试创建对象并更新 obj 指针时,会发生数据竞争:
#include <iostream>
#include <thread>
class MyObj {};
static MyObj *obj = nullptr;
void init_object() {
if (!obj) {
obj = new MyObj();
}
}
void func1() {
init_object();
}
void func2() {
init_object();
}
int main() {
std::thread t1(func1);
std::thread t2(func2);
t1.join();
t2.join();
return 0;
}
这是编译并运行此示例后的输出:
WARNING: ThreadSanitizer: data race (pid=32826)
Read of size 1 at 0x5663912cbae8 by thread T2:
#0 func2() test.cpp (test+0xe0b68) (BuildId: 12f32c1505033f9839d17802d271fc869b7a3e38)
Previous write of size 1 at 0x5663912cbae8 by thread T1:
#0 func1() test.cpp (test+0xe0b3d) (BuildId: 12f32c1505033f9839d17802d271fc869b7a3e38)
Location is global 'obj (.init)' of size 1 at 0x5663912cbae8 (test+0x150cae8)
Thread T2 (tid=32829, running) created by main thread at:
#0 pthread_create <null> (test+0x6062f) (BuildId: 12f32c1505033f9839d17802d271fc869b7a3e38)
Thread T1 (tid=32828, finished) created by main thread at:
#0 pthread_create <null> (test+0x6062f) (BuildId: 12f32c1505033f9839d17802d271fc869b7a3e38)
SUMMARY: ThreadSanitizer: data race test.cpp in func2()
ThreadSanitizer: reported 1 warnings
输出显示了我们之前描述的情况,由于从 func1() 和 func2() 访问 obj 全局变量而导致的数据竞争。
由于 C++11 标准已正式将数据竞争视为未定义行为,现在让我们看看如何使用 UBSan 来检测程序中的未定义行为问题。
UndefinedBehaviorSanitizer
UBSan 可以检测代码中的未定义行为,例如,当通过过多的位移操作、整数溢出或误用空指针时。可以通过指定 –fsanitize=undefined 选项来启用它。其行为可以通过设置 UBSAN_OPTIONS 变量在运行时进行修改。
许多 UBSan 可以检测到的错误也可以在编译期间由编译器检测到。
让我们看看一个简单的例子:
int main() {
int val = 0x7fffffff;
val += 1;
return 0;
}
要编译程序并启用 UBSan,请使用以下命令:
$ clang++ -fsanitize=undefined -fno-omit-frame-pointer -g -O2 –o test test.cpp
运行生成的程序会产生以下输出:
test.cpp:3:7: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior test.cpp:3:7
输出非常简单且易于理解;存在一个有符号整数溢出操作。
现在,让我们了解另一个有用的 C++ 检查器,用于检测未初始化的内存和其他内存使用问题:MSan。
MemorySanitizer
MSan 可以检测未初始化的内存使用,例如,在使用变量或指针之前没有分配值或地址时。它还可以跟踪位域中的未初始化位。
要启用 MSan,请使用以下编译器标志:
-fsanitize=memory -fPIE -pie -fno-omit-frame-pointer
它还可以通过指定**-** fsanitize-memory-track-origins选项将每个未初始化的值追踪到其创建的内存分配。
GCC 不支持 MSan,因此当使用此编译器时,-fsanitize=memory标志是无效的。
在以下示例中,创建了arr整数数组,但只初始化了其位置5。在向控制台打印消息时使用位置0的值,但此值仍然是未初始化的:
#include <iostream>
int main() {
auto arr = new int[10];
arr[5] = 0;
std::cout << "Value at position 0 = " << arr[0] << '\n';
return 0;
}
要编译程序并启用 MSan,请使用以下命令:
$ clang++ -fsanitize=memory -fno-omit-frame-pointer -g -O2 –o test test.cpp
运行生成的程序将生成以下输出:
==20932==WARNING: MemorySanitizer: use-of-uninitialized-value
#0 0x5b9fa2bed38f in main test.cpp:6:41
#3 0x5b9fa2b53324 in _start (test+0x32324) (BuildId: c0a0d31f01272c3ed59d4ac66b8700e9f457629f)
SUMMARY: MemorySanitizer: use-of-uninitialized-value test.cpp:6:41 in main
再次,输出清楚地显示,在读取arr数组中位置0的值时,在第 6 行使用了未初始化的值。
最后,让我们在下一节总结其他检查器。
其他检查器
在为某些系统(如内核或实时开发)开发时,还有其他有用的检查器:
-
硬件辅助地址检查器 (HWASan):ASan 的一个新变体,通过使用硬件能力忽略指针的最高字节来消耗更少的内存。可以通过指定**–** fsanitize=hwaddress选项来启用。
-
实时检查器 (RTSan):实时测试工具,用于检测在调用具有确定运行时要求的函数中不安全的函数时发生的实时违规。
-
Fuzzer 检查器:一种检查器,通过向程序输入大量随机数据来检测潜在漏洞,检查程序是否崩溃,并寻找内存损坏或其他安全漏洞。
-
内核相关检查器:还有其他检查器可用于通过内核开发者跟踪问题。出于好奇,以下是一些例子:
-
内核地址 检查器 ( KASAN )
-
内核并发 检查器 ( KCSAN )
-
内核 电栅栏 ( KFENCE )
-
内核内存 检查器 ( KMSAN )
-
内核线程 检查器 ( KTSAN )
-
检查器可以自动在我们的代码中找到许多问题。一旦我们找到并调试了一些错误,并且可以重现导致这些特定错误的场景,设计一些涵盖这些情况的测试将非常方便,以避免未来代码中的更改可能导致类似问题或事件。
让我们在下一节学习如何测试多线程和异步代码。
测试异步代码
最后,让我们探索一些测试异步代码的技术。本节中显示的示例需要GoogleTest和GoogleTest Mock ( gMock )库来编译。如果您不熟悉这些库,请查阅官方文档了解如何安装和使用它们。
正如我们所知,单元测试是一种编写小型且独立的测试的实践,用于验证单个代码单元的功能和行为。单元测试有助于发现和修复错误,重构和改进代码质量,记录和传达底层代码设计,并促进协作和集成。
本节不会涵盖将测试分组到逻辑和描述性套件的最佳方式,或者何时应该使用断言或期望来验证不同变量和测试方法结果的值。本节的目的在于提供一些关于如何创建单元测试以测试异步代码的指南。因此,对单元测试或测试驱动开发(TDD)有一些先前的知识是可取的。
处理异步代码的主要困难在于它可能在另一个线程中执行,通常不知道何时会发生,或何时完成。
测试异步代码时,主要遵循的方法是将功能与多线程分离,这意味着我们可能希望以同步方式测试异步代码,尝试在一个特定的线程中执行它,移除上下文切换、线程创建和销毁以及其他可能影响测试结果和时序的活动。有时,也会使用计时器,在超时前等待回调被调用。
测试一个简单的异步函数
让我们从测试一个异步操作的小例子开始。此示例展示了asyncFunc()函数,它通过使用std::async异步运行来测试,如第七章中所示:
#include <gtest/gtest.h>
#include <chrono>
#include <future>
using namespace std::chrono_literals;
int asyncFunc() {
std::this_thread::sleep_for(100ms);
return 42;
}
TEST(AsyncTests, TestHandleAsyncOperation) {
std::future<int> result = std::async(
std::launch::async,
asyncFunc);
EXPECT_EQ(result.get(), 42);
}
int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
std::async返回一个 future,用于检索计算值。在这种情况下,asyncFunc只是等待100ms然后返回值42。如果异步任务运行正常,测试将通过,因为有一个期望指令检查返回的值确实是42。
只定义了一个测试,使用**TEST()**宏,其中第一个参数是测试套件名称(在这个例子中,AsyncTests),第二个参数是测试名称(TestHandleAsyncOperation)。
在main()函数中,通过调用::testing::InitGoogleTest()初始化 GoogleTest 库。此函数解析命令行以获取 GoogleTest 识别的标志。然后调用RUN_ALL_TESTS(),该函数收集并运行所有测试,如果所有测试都成功则返回0,否则返回1。这个函数最初是一个宏,这就是为什么它的名字是大写的。
通过使用超时限制测试时长
这种方法可能出现的一个问题是,异步任务可能由于任何原因而未能被调度,完成时间超过预期,或者由于任何原因未能完成。为了处理这种情况,可以使用计时器,将其超时时间设置为合理的值,以便给测试足够的时间成功完成。因此,如果计时器超时,测试将失败。以下示例通过在 std::async 返回的 future 上使用定时等待来展示这种方法:
#include <gtest/gtest.h>
#include <chrono>
#include <future>
using namespace std::chrono;
using namespace std::chrono_literals;
int asyncFunc() {
std::this_thread::sleep_for(100ms);
return 42;
}
TEST(AsyncTest, TestTimeOut) {
auto start = steady_clock::now();
std::future<int> result = std::async(
std::launch::async,
asyncFunc);
if (result.wait_for(200ms) ==
std::future_status::timeout) {
FAIL() << "Test timed out!";
}
EXPECT_EQ(result.get(), 42);
auto end = steady_clock::now();
auto elapsed = duration_cast<milliseconds>(
end - start);
EXPECT_LT(elapsed.count(), 200);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
现在,调用 future 对象 result 的 wait_for() 函数,等待 200 毫秒以完成异步任务。由于任务将在 100 毫秒内完成,超时不会过期。如果由于任何原因,wait_for() 被调用时的时间值低于 100 毫秒,它将超时,并调用 FAIL() 宏,使测试失败。
测试继续运行并检查返回的值是否为 42,正如前一个示例中所示,并且还检查执行异步任务所花费的时间是否少于使用的超时时间。
测试回调
测试回调是一个相关任务,尤其是在实现库和 应用程序编程接口 ( API ) 时。以下示例展示了如何测试回调已被调用及其结果:
#include <gtest/gtest.h>
#include <chrono>
#include <functional>
#include <iostream>
#include <thread>
using namespace std::chrono_literals;
void asyncFunc(std::function<void(int)> callback) {
std::thread([callback]() {
std::this_thread::sleep_for(1s);
callback(42);
}).detach();
}
TEST(AsyncTest, TestCallback) {
int result = 0;
bool callback_called = false;
auto callback = & {
callback_called = true;
result = value;
};
asyncFunc(callback);
std::this_thread::sleep_for(2s);
EXPECT_TRUE(callback_called);
EXPECT_EQ(result, 42);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
TestCallback 测试仅定义了一个作为 lambda 函数的回调,该 lambda 函数接受一个参数。这个 lambda 函数通过引用捕获存储 value 参数的 result 变量,以及默认为 false 并在回调被调用时设置为 true 的 callback_called 布尔变量。
然后,测试调用 asyncFunc() 函数,该函数启动一个线程,该线程在调用回调并传递值 42 之前等待一秒钟。测试在等待两秒钟后使用 EXPECT_TRUE 宏检查是否调用了回调,并检查 callback_called 的值,以及 result 是否具有预期的值 42。
测试事件驱动软件
我们在 第九章 中看到了如何使用 Boost.Asio 和其事件队列来调度异步任务。在事件驱动编程中,通常还需要测试回调,如前一个示例所示。我们可以设置测试以注入回调并在它们被调用后验证结果。以下示例展示了如何在 Boost.Asio 程序中测试异步任务:
#include <gtest/gtest.h>
#include <boost/asio.hpp>
#include <chrono>
#include <thread>
using namespace std::chrono_literals;
void asyncFunc(boost::asio::io_context& io_context,
std::function<void(int)> callback) {
io_context.post([callback]() {
std::this_thread::sleep_for(100ms);
callback(42);
});
}
TEST(AsyncTest, BoostAsio) {
boost::asio::io_context io_context;
int result = 0;
asyncFunc(io_context, &result {
result = value;
});
std::jthread io_thread([&io_context]() {
io_context.run();
});
std::this_thread::sleep_for(150ms);
EXPECT_EQ(result, 42);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
BoostAsio 测试首先创建一个 I/O 执行上下文对象 io_context,并将其传递给 asyncFunc() 函数,同时传递一个 lambda 函数,该 lambda 函数实现一个在后台运行的任务或回调。这个回调简单地设置由 lambda 函数捕获的 result 变量的值,将其设置为传递给它的值。
asyncFunc() 函数仅使用 io_context 来发布一个任务,该任务由一个 lambda 函数组成,该函数在等待 100 毫秒后调用回调并传递值 42。
然后,测试只是等待 150 毫秒,直到后台任务完成,并检查结果值是否为 42,以标记测试通过。
模拟外部资源
如果异步代码还依赖于外部资源,例如文件访问、网络服务器、计时器或其他模块,我们可能需要模拟它们,以避免由于任何资源问题导致的测试失败。模拟和存根是用于在测试目的下用假或简化的对象或函数替换或修改真实对象或函数行为的技巧。这样,我们可以控制异步代码的输入和输出,并避免副作用或其他因素的干扰。
例如,如果测试的代码依赖于服务器,服务器可能无法连接或执行其任务,导致测试失败。在这些情况下,失败是由于资源问题,而不是由于测试的异步代码,导致了一个错误,通常是一个短暂的错误。我们可以通过使用我们自己的模拟类来模拟外部资源,这些模拟类模仿它们的接口。让我们看看如何使用模拟类和使用依赖注入来测试该类的示例。
在这个例子中,有一个外部资源 AsyncTaskScheduler,其 runTask() 方法用于执行异步任务。因为我们只想测试异步任务并消除异步任务调度器可能产生的任何不期望的副作用,我们可以使用模拟类模仿 AsyncScheduler 接口。这个类是 MockTaskScheduler,它继承自 AsyncTaskScheduler 并实现了其 runTask() 基类方法,其中任务是同步运行的:
#include <gtest/gtest.h>
#include <functional>
class AsyncTaskScheduler {
public:
virtual int runTask(std::function<int()> task) = 0;
};
class MockTaskScheduler : public AsyncTaskScheduler {
public:
int runTask(std::function<int()> task) override {
return task();
}
};
TEST(AsyncTests, TestDependencyInjection) {
MockTaskScheduler scheduler;
auto task = []() -> int {
return 42;
};
int result = scheduler.runTask(task);
EXPECT_EQ(result, 42);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
TestDependencyInjection 测试仅创建一个 MockTaskScheduler 对象和一个 lambda 函数形式的任务,并使用模拟对象通过运行 runTask() 函数来执行任务。一旦任务运行,result 将具有值 42。
我们不仅可以用 gMock 库完全定义模拟类,还可以只模拟所需的方法。以下示例展示了 gMock 的应用:
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <functional>
class AsyncTaskScheduler {
public:
virtual int runTask(std::function<int()> task) = 0;
};
class MockTaskScheduler : public AsyncTaskScheduler {
public:
MOCK_METHOD(int, runTask, (std::function<int()> task), (override));
};
TEST(AsyncTests, TestDependencyInjection) {
using namespace testing;
MockTaskScheduler scheduler;
auto task = []() -> int {
return 42;
};
EXPECT_CALL(scheduler, runTask(_)).WillOnce(
Invoke(task)
);
auto result = scheduler.runTask(task);
EXPECT_EQ(result, 42);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
现在,MockTaskScheduler 也继承自 AsyncTaskScheduler,其中定义了接口,但不是通过重写其方法,而是使用 MOCK_METHOD 宏,其中传递了返回类型、模拟方法名称及其参数。
然后,TestMockMethod 测试使用 EXPECT_CALL 宏来定义对 MockTaskScheduler 中 runTask() 模拟方法的预期调用,该调用只会发生一次,并调用 lambda 函数任务,该任务返回值 42。
该调用仅在下一个指令中发生,其中调用 scheduler.runTask(),并将返回值存储在结果中。测试通过检查 result 是否是预期的 42 值来完成。
测试异常和失败
异步任务并不总是成功并生成有效的结果。有时可能会出错(网络故障、超时、异常等),返回错误或抛出异常是通知用户这种情况的方式。我们应该模拟失败以确保代码能够优雅地处理这些情况。
测试错误或异常可以像通常那样进行,通过使用 try-catch 块和使用断言或期望来检查是否抛出了错误,并使测试成功或失败。GoogleTest 还提供了 EXPECT_ANY_THROW() 宏,它简化了检查是否发生了异常。以下示例展示了这两种方法:
#include <gtest/gtest.h>
#include <chrono>
#include <future>
#include <iostream>
#include <stdexcept>
using namespace std::chrono_literals;
int asyncFunc(bool should_fail) {
std::this_thread::sleep_for(100ms);
if (should_fail) {
throw std::runtime_error("Simulated failure");
}
return 42;
}
TEST(AsyncTest, TestAsyncFailure1) {
try {
std::future<int> result = std::async(
std::launch::async,
asyncFunc, true);
result.get();
FAIL() << "No expected exception thrown";
} catch (const std::exception& e) {
SUCCEED();
}
}
TEST(AsyncTest, TestAsyncFailure2) {
std::future<int> result = std::async(
std::launch::async,
asyncFunc, true);
EXPECT_ANY_THROW(result.get());
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
TestAsyncFailure1 和 TestAsyncFailure2 这两个测试非常相似。它们都异步执行了 asyncFunc() 函数,该函数现在接受一个 should_fail 布尔参数,指示任务是否应该成功并返回值 42,或者失败并抛出异常。两个测试都使任务失败,区别在于 TestAsyncFailure1 在没有抛出异常的情况下使用 FAIL() 宏使测试失败,或者在 try-catch 块捕获到异常时使用 SUCCEED(),而 TestAsyncFailure2 使用 EXPECT_ANY_THROW() 宏来检查在尝试通过调用其 get() 方法从 future result 获取结果时是否发生了异常。
测试多个线程
在 C++ 中测试涉及多个线程的异步软件时,一个常见且有效的技术是使用条件变量来同步线程。正如我们在 第四章 中所看到的,条件变量允许线程在满足某些条件之前等待,这使得它们对于管理线程间的通信和协调至关重要。
接下来是一个示例,其中多个线程执行一些任务,而主线程等待所有其他线程完成。
让我们先定义一些必要的全局变量,例如线程总数( num_threads ),counter 作为每次异步任务被调用时都会增加的原子变量,以及条件变量 cv 和其关联的互斥锁 mtx,这将有助于在所有异步任务完成后解锁主线程:
#include <gtest/gtest.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <syncstream>
#include <thread>
#include <vector>
using namespace std::chrono_literals;
#define sync_cout std::osyncstream(std::cout)
std::condition_variable cv;
std::mutex mtx;
bool ready = false;
std::atomic<unsigned> counter = 0;
const std::size_t num_threads = 5;
asyncTask() 函数将在增加 counter 原子变量并通过 cv 条件变量通知主线程其工作已完成之前执行异步任务(在这个例子中简单等待 100 毫秒):
void asyncTask(int id) {
sync_cout << "Thread " << id << ": Starting work..."
<< std::endl;
std::this_thread::sleep_for(100ms);
sync_cout << "Thread " << id << ": Work finished."
<< std::endl;
++counter;
cv.notify_one();
}
TestMultipleThreads 测试将首先启动多个线程,每个线程将异步运行 asyncTask() 任务。然后,它将等待,使用一个条件变量,其中 counter 的值与线程数相同,这意味着所有后台任务都已完成工作。条件变量使用 wait_for() 函数设置 150 毫秒的超时时间,以限制测试可以运行的时间,但为所有后台任务成功完成留出一些空间:
TEST(AsyncTest, TestMultipleThreads) {
std::vector<std::jthread> threads;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back(asyncTask, i + 1);
}
{
std::unique_lock<std::mutex> lock(mtx);
cv.wait_for(lock, 150ms, [] {
return counter == num_threads;
});
sync_cout << "All threads have finished."
<< std::endl;
}
EXPECT_EQ(counter, num_threads);
}
测试通过检查确实 counter 的值与 num_threads 相同来结束。
最后,实现 main() 函数:
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
如前所述,程序通过调用 ::testing::InitGoogleTest() 来初始化 GoogleTest 库,然后调用 RUN_ALL_TESTS() 来收集和运行所有测试。
测试协程
随着 C++20 的推出,协程提供了一种编写和管理异步代码的新方法。基于协程的代码可以通过使用与其他异步代码类似的方法进行测试,但有一个细微的区别,即协程可以挂起和恢复。
让我们用一个简单的协程示例来看看。
我们在 第八章 中看到,协程有一些样板代码来定义它们的承诺类型和可等待方法。让我们先实现定义协程的 Task 结构。请重新阅读 第八章 以全面理解这段代码。
让我们先定义 Task 结构:
#include <gtest/gtest.h>
#include <coroutine>
#include <exception>
#include <iostream>
struct Task {
struct promise_type;
using handle_type =
std::coroutine_handle<promise_type>;
handle_type handle_;
Task(handle_type h) : handle_(h) {}
~Task() {
if (handle_) handle_.destroy();
}
// struct promise_type definition
// and await methods
};
在 Task 中,我们定义 promise_type,它描述了协程是如何管理的。此类型提供了一些预定义的方法(钩子),用于控制值的返回方式、协程的挂起方式以及协程完成后资源的管理方式:
struct Task {
// ...
struct promise_type {
int result_;
std::exception_ptr exception_;
Task get_return_object() {
return Task(handle_type::from_promise(*this));
}
std::suspend_always initial_suspend() {
return {};
}
std::suspend_always final_suspend() noexcept {
return {};
}
void return_value(int value) {
result_ = value;
}
void unhandled_exception() {
exception_ = std::current_exception();
}
};
// ....
};
然后,实现用于控制协程挂起和恢复的方法:
struct Task {
// ...
bool await_ready() const noexcept {
return handle_.done();
}
void await_suspend(std::coroutine_handle<>
awaiting_handle) {
handle_.resume();
awaiting_handle.resume();
}
int await_resume() {
if (handle_.promise().exception_) {
std::rethrow_exception(
handle_.promise().exception_);
}
return handle_.promise().result_;
}
int result() {
if (handle_.promise().exception_) {
std::rethrow_exception(
handle_.promise().exception_);
}
return handle_.promise().result_;
}
// ....
};
在有了 Task 结构之后,让我们定义两个协程,一个用于计算有效值,另一个用于抛出异常:
Task asyncFunc(int x) {
co_return 2 * x;
}
Task asyncFuncWithException() {
throw std::runtime_error("Exception from coroutine");
co_return 0;
}
由于 GoogleTest 中的 TEST() 宏内的测试函数不能直接是协程,因为它们没有与它们关联的 promise_type 结构,我们需要定义一些辅助函数:
Task testCoroutineHelper(int value) {
co_return co_await asyncFunc(value);
}
Task testCoroutineWithExceptionHelper() {
co_return co_await asyncFuncWithException();
}
在此基础上,我们现在可以实施测试:
TEST(AsyncTest, TestCoroutine) {
auto task = testCoroutineHelper(5);
task.handle_.resume();
EXPECT_EQ(task.result(), 10);
}
TEST(AsyncTest, TestCoroutineWithException) {
auto task = testCoroutineWithExceptionHelper();
EXPECT_THROW({
task.handle_.resume();
task.result();
},
std::runtime_error);
}
int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
TestCoroutine 测试使用 testCoroutineHelper() 辅助函数定义了一个任务,并传递了值 5。在恢复协程时,预期它将返回双倍值,即 10,这通过 EXPECT_EQ() 进行测试。
TestCoroutineWithException 测试使用类似的方法,但现在使用 testCoroutineWithExceptionHelper() 辅助函数,当协程恢复时将抛出异常。这正是 EXPECT_THROW() 断言宏在检查确实异常是 std::runtime_error 类型之前所发生的事情。
压力测试
通过执行压力测试可以实现竞态条件检测。对于高度并发或多线程异步代码,压力测试至关重要。我们可以通过多个异步任务来模拟高负载,以检查系统在压力下的行为是否正确。此外,使用随机延迟、线程交错或压力测试工具也很重要,以减少确定性条件,增加测试覆盖率。
下一个示例展示了实现一个压力测试,该测试启动 100(total_nums)个线程执行异步任务,其中原子变量计数器在每个运行后随机等待增加:
#include <gtest/gtest.h>
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>
std::atomic<int> counter(0);
const std::size_t total_runs = 100;
void asyncIncrement() {
std::this_thread::sleep_for(std::chrono::milliseconds(rand() % 100));
counter.fetch_add(1);
}
TEST(AsyncTest, StressTest) {
std::vector<std::thread> threads;
for (std::size_t i = 0; i < total_runs; ++i) {
threads.emplace_back(asyncIncrement);
}
for (auto& thread : threads) {
thread.join();
}
EXPECT_EQ(counter, total_runs);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
如果计数器的值与线程总数相同,则测试成功。
并行化测试
为了更快地运行测试套件,我们可以并行化在不同线程中运行的测试,但测试必须是独立的,每个测试都在特定的线程中作为一个同步的单线程解决方案运行。此外,它们还需要设置和拆除任何必要的对象,而不会保留之前测试运行的状态。
当使用 CMake 与 GoogleTest 一起时,我们可以通过指定以下命令来并行运行所有检测到的测试:
$ ctest –j <num_jobs>
本节中展示的所有示例只是测试异步代码可以进行的很小一部分。我们希望这些技术能提供足够的洞察力和知识,以开发进一步的处理特定场景的测试技术。
摘要
在本章中,我们学习了如何清理和测试异步程序。
我们首先学习了如何使用 sanitizers 来清理代码,以帮助找到多线程和异步问题,例如竞态条件、内存泄漏和作用域后使用错误等问题。
然后,描述了一些旨在处理异步软件的测试技术,使用 GoogleTest 作为测试库。
使用这些工具和技术有助于检测和预防未定义行为、内存错误和安全漏洞,同时确保并发操作正确执行,正确处理时序问题,并在各种条件下代码按预期执行。这提高了整个程序的整体可靠性和稳定性。
在下一章中,我们将学习可以用来提高异步程序运行时间和资源使用的性能和优化技术。
进一步阅读
-
Sanitizers:
github.com/google/sanitizers -
Clang 20.0 ASan:
clang.llvm.org/docs/AddressSanitizer.html -
Clang 20.0 硬件辅助 ASan:
clang.llvm.org/docs/HardwareAssistedAddressSanitizerDesign.html -
Clang 20.0 TSan:
clang.llvm.org/docs/ThreadSanitizer.html -
Clang 20.0 MSan:
clang.llvm.org/docs/MemorySanitizer.html -
Clang 20.0 UBSan:
clang.llvm.org/docs/UndefinedBehaviorSanitizer.html -
Clang 20.0 DataFlowSanitizer:
clang.llvm.org/docs/DataFlowSanitizer.html -
Clang 20.0 LSan:
clang.llvm.org/docs/LeakSanitizer.html -
Clang 20.0 RealtimeSanitizer:
clang.llvm.org/docs/RealtimeSanitizer.html -
Clang 20.0 SanitizerCoverage:
clang.llvm.org/docs/SanitizerCoverage.html -
Clang 20.0 SanitizerStats:
clang.llvm.org/docs/SanitizerStats.html -
GCC: 程序仪器 选项:
gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html -
Apple 开发者: 早期诊断内存、线程和崩溃问题:
developer.apple.com/documentation/xcode/diagnosing-memory-thread-and-crash-issues-early -
GCC: 调试你的 程序 的选项:
gcc.gnu.org/onlinedocs/gcc/Debugging-Options.html -
OpenSSL: C 和 C++编译器选项加固指南:
best.openssf.org/Compiler-Hardening-Guides/Compiler-Options-Hardening-Guide-for-C-and-C++.html -
C 和 C++中的内存错误检查:比较 Sanitizers 和 Valgrind:
developers.redhat.com/blog/2021/05/05/memory-error-checking-in-c-and-c-comparing-sanitizers-and-valgrind -
GNU C 库:
www.gnu.org/software/libc -
Sanitizers: 常见标志:
github.com/google/sanitizers/wiki/SanitizerCommonFlags -
AddressSanitizer 标志:
github.com/google/sanitizers/wiki/AddressSanitizerFlags -
AddressSanitizer: 快速地址检查器:
www.usenix.org/system/files/conference/atc12/atc12-final39.pdf -
MemorySanitizer: C++中未初始化内存使用的快速检测器:
static.googleusercontent.com/media/research.google.com/en//pubs/archive/43308.pdf -
Linux 内核 Sanitizers:
github.com/google/kernel-sanitizers -
TSan 标志:
github.com/google/sanitizers/wiki/ThreadSanitizerFlags -
TSan:常见的数据竞争:
github.com/google/sanitizers/wiki/ThreadSanitizerPopularDataRaces -
TSan 报告格式:
github.com/google/sanitizers/wiki/ThreadSanitizerReportFormat -
TSan 算法:
github.com/google/sanitizers/wiki/ThreadSanitizerAlgorithm -
地址空间布局随机化:
en.wikipedia.org/wiki/Address_space_layout_randomization -
GoogleTest 用户指南:
google.github.io/googletest
第十三章:提高异步软件性能
在本章中,我们将介绍异步代码的性能方面。代码性能和优化是一个深奥且复杂的话题,我们无法在一章中涵盖所有内容。我们的目标是给你一个关于这个主题的良好介绍,并提供一些如何测量性能和优化代码的示例。
本章将涵盖以下关键主题:
-
专注于多线程应用程序的性能测量工具
-
什么是伪共享,如何识别它,以及如何修复/改进我们的代码
-
现代 CPU 内存缓存架构简介
-
对我们在第五章中实现的单生产者单消费者(SPSC)无锁队列的回顾
技术要求
如前几章所述,你需要一个支持 C++20 的现代 C++编译器。我们将使用 GCC 13 和 Clang 18。你还需要一台运行 Linux 的 Intel/AMD 多核 CPU 的 PC。对于本章,我们使用了在具有 CPU AMD Ryzen Threadripper Pro 5975WX(32 核心)的工作站上运行的 Ubuntu 24.04 LTS。8 核心的 CPU 是理想的,但 4 核心足以运行示例。
我们还将使用 Linux perf工具。我们将在本书的后面部分解释如何获取和安装这些工具。
本章的示例可以在本书的 GitHub 存储库中找到:github.com/PacktPublishing/Asynchronous-Programming-with-CPP。
性能测量工具
要了解我们应用程序的性能,我们需要能够对其进行测量。如果从这个章节中有一个关键要点,那就是永远不要估计或猜测你的代码性能。要知道你的程序是否满足其性能要求(无论是延迟还是吞吐量),你需要测量,测量,然后再测量。
一旦你从性能测试中获得数据,你就会知道代码中的热点。也许它们与内存访问模式或线程竞争(例如,当多个线程必须等待获取锁以访问资源时)有关。这就是第二个最重要的要点发挥作用的地方:在优化应用程序时设定目标。不要试图达到可能的最优性能,因为总有改进的空间。正确的方法是设定一个明确的规范,包括目标,例如事务的最大处理时间或每秒处理的网络数据包数量。
在考虑这两个主要想法的同时,让我们从我们可以用来测量代码性能的不同方法开始。
代码内分析
理解代码性能的一个非常简单但实用的方法是 代码内分析,它包括添加一些额外的代码来测量某些代码段的执行时间。这种方法在编写代码时作为一个工具使用是很好的(当然,我们需要访问源代码)。这将使我们能够找到代码中的某些性能问题,正如我们将在本章后面看到的。
我们将使用 std::chrono 作为我们分析代码的初始方法。
以下代码片段展示了我们如何使用 std::chrono 对代码进行一些基本的性能分析:
auto start = std::chrono::high_resolution_clock::now();
// processing to profile
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout < duration.count() << " milliseconds\n";
在这里,我们获取了两个时间样本,它们调用了 high_resolution_clock::now() 并打印了转换成毫秒的时间间隔。根据我们估计的处理时间,我们可以使用微秒或秒,例如。使用这种简单技术,我们可以轻松地了解处理所需的时间,并且可以轻松地比较不同的选项。
在这里,std::chrono::high_resolution_clock 是提供最高精度(实现提供的最小滴答周期)的时钟类型。C++ 标准库允许它成为 std::chrono::system_clock 或 std::chrono::steady_clock 的别名。libstdc++ 将其别名为 std::chrono::system_clock,而 libc++ 使用 std::chrono::steady_clock。在本章的示例中,我们使用了 GCC 和 libstdc++。时钟分辨率为 1 纳秒:
/**
* @brief Highest-resolution clock
*
* This is the clock "with the shortest tick period." Alias to
* std::system_clock until higher-than-nanosecond definitions
* become feasible.
* @ingroup chrono
*/
using high_resolution_clock = system_clock;
现在,让我们看看一个完整的示例,用于分析 C++ 标准库中的两个排序算法——std::sort 和 std::stable_sort 的性能:
#include <algorithm>
#include <chrono>
#include <iostream>
#include <random>
#include <utility>
int uniform_random_number(int min, int max) {
static std::random_device rd;
static std::mt19937 gen(rd());
std::uniform_int_distribution dis(min, max);
return dis(gen);
}
std::vector<int> random_vector(std::size_t n, int32_t min_val, int32_t max_val) {
std::vector<int> rv(n);
std::ranges::generate(rv, [&] {
return uniform_random_number(min_val, max_val);
});
return rv;
}
using namespace std::chrono;
int main() {
constexpr uint32_t elements = 100000000;
int32_t minval = 1;
int32_t maxval = 1000000000;
auto rv1 = random_vector(elements, minval, maxval);
auto rv2 = rv1;
auto start = high_resolution_clock::now();
std::ranges::sort(rv1);
auto end = high_resolution_clock::now();
auto duration = duration_cast<milliseconds>(end - start);
std::cout << "Time to std::sort "
<< elements << " elements with values in ["
<< minval << "," << maxval << "] "
<< duration.count() << " milliseconds\n";
start = high_resolution_clock::now();
std::ranges::stable_sort(rv2);
end = high_resolution_clock::now();
duration = duration_cast<milliseconds>(end - start);
std::cout << "Time to std::stable_sort "
<< elements << " elements with values in ["
<< minval << "," << maxval << "] "
<< duration.count() << " milliseconds\n";
return 0;
}
上一段代码生成一个正态分布的随机数向量,然后使用 std::sort() 和 std::stable_sort() 对向量进行排序。这两个函数都排序向量,但 std::sort() 使用了称为 introsort 的快速排序和插入排序算法的组合,而 std::stable_sort() 使用了归并排序。排序是 稳定的,因为在原始和排序后的向量中,等效键具有相同的顺序。对于整数向量来说,这并不重要,但如果向量有三个具有相同值的元素,在排序向量后,数字将保持相同的顺序。
运行代码后,我们得到以下输出:
Time to std::sort 100000000 elements with values in [1,1000000000] 6019 milliseconds
Time to std::stable_sort 100000000 elements with values in [1,1000000000] 7342 milliseconds
在这个例子中,std::stable_sort() 的速度比 std::sort() 慢。
在本节中,我们了解了一种简单的方法来测量代码段运行时间。这种方法是侵入性的,需要我们修改代码;它主要在我们开发应用程序时使用。在下一节中,我们将介绍另一种测量执行时间的方法,称为微基准测试。
代码微基准测试
有时候,我们只想独立分析一小段代码。我们可能需要多次运行它,然后获取平均运行时间或使用不同的输入数据运行它。在这些情况下,我们可以使用基准测试库(也称为 微基准测试)来完成这项工作——在不同的条件下执行我们代码的小部分。
微基准测试必须作为指导。请注意,代码在隔离状态下运行,当我们将所有代码一起运行时,由于代码不同部分之间复杂的交互,这可能会给我们带来非常不同的结果。请谨慎使用,并意识到微基准测试可能会误导。
我们可以使用许多库来基准测试我们的代码。我们将使用 Google Benchmark,这是一个非常好且广为人知的库。
让我们从获取代码和编译库开始。要获取代码,请运行以下命令:
git clone https://github.com/google/benchmark.git
cd benchmark
git clone https://github.com/google/googletest.git
一旦我们有了基准测试和 Google Test 库的代码(后者是编译前者所必需的),我们就会构建它。
为构建创建一个目录:
mkdir build
cd build
有了这些,我们在基准测试目录内创建了构建目录。
接下来,我们将使用 CMake 来配置构建并创建 make 所需的所有必要信息:
cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBRARIES=ON -DCMAKE_INSTALL_PREFIX=/usr/lib/x86_64-linux-gnu/
最后,运行 make 来构建和安装库:
make -j16
sudo make install
您还需要将库添加到 CmakeLists.txt 文件中。我们已经在本书的代码中为您完成了这项工作。
一旦安装了 Google Benchmark,我们就可以通过一些基准函数的示例来学习如何使用该库进行一些基本的基准测试。
注意,std::chrono 和 Google Benchmark 都不是专门用于处理异步/多线程代码的工具,它们更像是通用工具。
这是使用 Google Benchmark 的第一个示例:
#include <benchmark/benchmark.h>
#include <algorithm>
#include <chrono>
#include <iostream>
#include <random>
#include <thread>
void BM_vector_push_back(benchmark::State& state) {
for (auto _ : state) {
std::vector<int> vec;
for (int i = 0; i < state.range(0); i++) {
vec.push_back(i);
}
}
}
void BM_vector_emplace_back(benchmark::State& state) {
for (auto _ : state) {
std::vector<int> vec;
for (int i = 0; i < state.range(0); i++) {
vec.emplace_back(i);
}
}
}
void BM_vector_insert(benchmark::State& state) {
for (auto _ : state) {
std::vector<int> vec;
for (int i = 0; i < state.range(0); i++) {
vec.insert(vec.begin(), i);
}
}
}
BENCHMARK(BM_vector_push_back)->Range(1, 1000);
BENCHMARK(BM_vector_emplace_back)->Range(1, 1000);
BENCHMARK(BM_vector_insert)->Range(1, 1000);
int main(int argc, char** argv) {
benchmark::Initialize(&argc, argv);
benchmark::RunSpecifiedBenchmarks();
return 0;
}
我们需要包含库头文件:
#include <benchmark/benchmark.h>
所有基准测试函数都具有以下签名:
void benchmark_function(benchmark::State& state);
这是一个具有一个参数的函数,benchmark::State& state,它返回 void。benchmark::State 参数具有双重用途:
-
控制迭代循环:benchmark::State 对象用于控制被基准测试的函数或代码应该执行多少次。通过重复测试足够多次以最小化变异性并收集有意义的数据,这有助于准确测量性能。
-
测量时间和统计信息:state 对象跟踪基准测试代码的运行时间,并提供报告指标(如经过时间、迭代次数和自定义计数器)的机制。
我们实现了三个函数来以不同的方式基准测试向 std::vector 序列添加元素:第一个函数使用 std::vector::push_back,第二个使用 std::vector::emplace_back,第三个使用 std::vector::insert。前两个函数在向量的末尾添加元素,而第三个函数在向量的开头添加元素。
一旦我们实现了基准测试函数,我们需要告诉库它们必须作为基准测试运行:
BENCHMARK(BM_vector_push_back)->Range(1, 1000);
我们使用 BENCHMARK 宏来完成这项工作。对于本例中的基准测试,我们设置了每次迭代要插入向量中的元素数量。范围从 1 到 1000,每次迭代将插入前一次迭代元素数量的八倍,直到达到最大值。在这种情况下,它将插入 1、8、64、512 和 1000 个元素。
当我们运行第一个基准测试程序时,我们得到以下输出:
2024-10-17T05:02:37+01:00
Running ./13x02-benchmark_vector
Run on (64 X 3600 MHz CPU s)
CPU Caches:
L1 Data 32 KiB (x32)
L1 Instruction 32 KiB (x32)
L2 Unified 512 KiB (x32)
L3 Unified 32768 KiB (x4)
Load Average: 0.00, 0.02, 0.16
----------------------------------------------------------------------
Benchmark Time CPU Iterations
----------------------------------------------------------------------
BM_vector_push_back/1 10.5 ns 10.5 ns 63107997
BM_vector_push_back/8 52.0 ns 52.0 ns 13450361
BM_vector_push_back/64 116 ns 116 ns 6021740
BM_vector_push_back/512 385 ns 385 ns 1819732
BM_vector_push_back/1000 641 ns 641 ns 1093474
BM_vector_emplace_back/1 10.8 ns 10.8 ns 64570848
BM_vector_emplace_back/8 53.3 ns 53.3 ns 13139191
BM_vector_emplace_back/64 108 ns 108 ns 6469997
BM_vector_emplace_back/512 364 ns 364 ns 1924992
BM_vector_emplace_back/1000 616 ns 616 ns 1138392
BM_vector_insert/1 10.6 ns 10.6 ns 65966159
BM_vector_insert/8 58.6 ns 58.6 ns 11933446
BM_vector_insert/64 461 ns 461 ns 1485319
BM_vector_insert/512 7249 ns 7249 ns 96756
BM_vector_insert/1000 23352 ns 23348 ns 29742
首先,程序打印出基准测试执行的信息:日期和时间、可执行文件名称以及它所运行的 CPU 信息。
看看以下这一行:
Load Average: 0.00, 0.02, 0.16
这一行给出了 CPU 负载的估计:从 0.0(完全没有负载或非常低的负载)到 1.0(完全加载)。这三个数字分别对应于过去 5、10 和 15 分钟的 CPU 负载。
在打印 CPU 负载信息后,基准测试会打印出每次迭代的成果。以下是一个示例:
BM_vector_push_back/64 116 ns 116 ns 6021740
这意味着 BM_vector_push_back 被调用了 6,021,740 次(迭代次数),在向量的插入过程中插入了 64 个元素。
时间 和 CPU 列给出了每次迭代的平均时间:
-
时间:这是从基准测试开始到结束所经过的真正时间。它包括基准测试期间发生的所有事情:CPU 计算、I/O 操作、上下文切换等。
-
CPU 时间:这是 CPU 处理基准测试指令所花费的时间。它可以小于或等于 时间。
在我们的基准测试中,因为操作很简单,我们可以看到 时间 和 CPU 大多数情况下是相同的。
通过查看结果,我们可以得出以下结论:
-
对于简单的对象,例如 32 位整数,push_back 和 emplace_back 花费的时间相同。
-
在这里,对于少量元素,insert 与 push_back / emplace_back 花费的时间相同,但从 64 个元素开始,它需要更多的时间。这是因为每次插入后,insert 必须复制向量中所有后续的元素(我们在向量的开头插入元素)。
以下示例也排序了一个 std::vector 序列,但这次,我们将使用微基准测试来测量执行时间:
#include <benchmark/benchmark.h>
#include <algorithm>
#include <chrono>
#include <iostream>
#include <random>
#include <thread>
std::vector<int> rv1, rv2;
int uniform_random_number(int min, int max) {
static std::random_device rd;
static std::mt19937 gen(rd());
std::uniform_int_distribution dis(min, max);
return dis(gen);
}
std::vector<int> random_vector(std::size_t n, int32_t min_val, int32_t max_val) {
std::vector<int> rv(n);
std::ranges::generate(rv, [&] {
return uniform_random_number(min_val, max_val);
});
return rv;
}
static void BM_vector_sort(benchmark::State& state, std::vector<int>& vec) {
for (auto _ : state) {
std::ranges::sort(vec);
}
}
static void BM_vector_stable_sort(benchmark::State& state, std::vector<int>& vec) {
for (auto _ : state) {
std::ranges::stable_sort(vec);
}
}
BENCHMARK_CAPTURE(BM_vector_sort, vector, rv1)->Iterations(1)->Unit(benchmark::kMillisecond);
BENCHMARK_CAPTURE(BM_vector_stable_sort, vector, rv2)->Iterations(1)->Unit(benchmark::kMillisecond);
int main(int argc, char** argv) {
constexpr uint32_t elements = 100000000;
int32_t minval = 1;
int32_t maxval = 1000000000;
rv1 = random_vector(elements, minval, maxval);
rv2 = rv1;
benchmark::Initialize(&argc, argv);
benchmark::RunSpecifiedBenchmarks();
return 0;
}
上述代码生成一个随机数字的向量。在这里,我们运行两个基准测试函数来排序向量:一个使用 std::sort,另一个使用 std::stable_sort。请注意,我们使用了同一个向量的两个副本,所以两个函数的输入是相同的。
以下代码行使用了 BENCHMARK_CAPTURE 宏。这个宏允许我们将参数传递给我们的基准测试函数——在这种情况下,一个对 std::vector 的引用(我们通过引用传递以避免复制向量并影响基准测试结果)。
我们指定结果以毫秒为单位而不是纳秒:
BENCHMARK_CAPTURE(BM_vector_sort, vector, rv1)->Iterations(1)->Unit(benchmark::kMillisecond);
下面是基准测试的结果:
-------------------------------------------------------------------------
Benchmark Time CPU Iterations
-------------------------------------------------------------------------
BM_vector_sort 5877 ms 5876 ms 1
BM_vector_stable_sort. 7172 ms 7171 ms 1
结果与我们使用 std::chrono 测量时间得到的结果一致。
对于我们最后的 Google Benchmark 示例,我们将创建一个线程(std::thread):
#include <benchmark/benchmark.h>
#include <algorithm>
#include <chrono>
#include <iostream>
#include <random>
#include <thread>
static void BM_create_terminate_thread(benchmark::State& state) {
for (auto _ : state) {
std::thread thread([]{ return -1; });
thread.join();
}
}
BENCHMARK(BM_create_terminate_thread)->Iterations(2000);
int main(int argc, char** argv) {
benchmark::Initialize(&argc, argv);
benchmark::RunSpecifiedBenchmarks();
return 0;
}
这个例子很简单:BM_create_terminate_thread 创建一个线程(什么都不做,只是返回 0)并等待它结束(thread.join())。我们运行 2000 次迭代以估计创建线程所需的时间。
结果如下:
---------------------------------------------------------------
----------
Benchmark Time CPU
Iterations
---------------------------------------------------------------
----------
BM_create_terminate_thread. 32424 ns 21216 ns 2000
在本节中,我们学习了如何使用 Google Benchmark 库创建微基准来测量某些函数的执行时间。再次强调,微基准只是一个近似值,由于被基准测试的代码的隔离性质,它们可能会有误导性。请谨慎使用。
Linux 的 perf 工具
在我们的代码中使用 std::chrono 或像 Google Benchmark 这样的微基准库需要获取要分析代码的访问权限,并且能够通过添加额外的调用来测量代码段的执行时间或运行小的代码片段作为微基准函数来修改它。
使用 Linux 的 perf 工具,我们可以分析程序的执行,而不需要更改其任何代码。
Linux 的 perf 工具是一个强大、灵活且广泛使用的性能分析和分析工具,适用于 Linux 系统。它提供了对内核和用户空间级别的系统性能的详细洞察。
让我们考虑 perf 的主要用途。
首先,我们有 CPU 分析。perf 工具允许你捕获进程的执行配置文件,测量哪些函数消耗了最多的 CPU 时间。这可以帮助识别代码中 CPU 密集的部分和瓶颈。
以下命令行将在我们编写的用于说明工具基本原理的小型 13x07-thread_contention 程序上运行 perf。此应用程序的代码可以在本书的 GitHub 仓库中找到:
perf record --call-graph dwarf ./13x07-thread_contention
–call-graph 选项将函数调用层次结构的数据记录在名为 perf.data 的文件中,而 dwarf 选项指示 perf 使用 dwarf 文件格式来调试符号(以获取函数名称)。
在之前的命令之后,我们必须运行以下命令:
perf script > out.perf
这将把记录的数据(包括调用栈)输出到名为 out.perf 的文本文件中。
现在,我们需要将文本文件转换为带有调用图的图片。为此,我们可以运行以下命令:
gprof2dot -f perf out.perf -o callgraph.dot
这将生成一个名为 callgraph.dot 的文件,可以使用 Graphviz 进行可视化。
你可能需要安装 gprof2dot。为此,你需要在你的电脑上安装 Python。运行以下命令来安装 gprof2dot:
pip install gprof2dot
还需要安装 Graphviz。在 Ubuntu 上,你可以这样做:
sudo apt-get install graphviz
最后,你可以通过运行以下命令生成 callgraph.png 图片:
dot -Tpng callgraph.dot -o callgraph.png
另一种非常常见的可视化程序调用图的方法是使用火焰图。
要生成火焰图,请克隆FlameGraph仓库:
git clone https://github.com/brendangregg/FlameGraph.git
在FlameGraph文件夹中,您将找到生成火焰图的脚本。
运行以下命令:
FlameGraph/stackcollapse-perf.pl out.perf > out.folded
此命令将堆栈跟踪折叠成火焰图工具可以使用的形式。现在,运行以下命令:
Flamegraph/flamegraph.pl out.folded > flamegraph.svg
您可以使用网页浏览器可视化火焰图:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/async-prog-cpp/img/B22219_13_1.jpg
图 13.1:火焰图的概述
现在,让我们学习如何收集程序的性能统计数据。
以下命令将显示在13x05-sort_perf执行期间执行的指令数量和使用的 CPU 周期。每周期指令数是 CPU 在每个时钟周期中执行的指令的平均数。此指标仅在微基准测试或测量代码的短部分时才有用。对于此示例,我们可以看到 CPU 每个周期执行一条指令,这对于现代 CPU 来说是平均的。在多线程代码中,由于执行的并行性,我们可以得到一个更大的数字,但此指标通常用于测量和优化在单个 CPU 核心中执行的代码。此数字必须解释为我们如何保持 CPU 忙碌,因为它取决于许多因素,例如内存读取/写入的数量、内存访问模式(线性连续/非线性)、代码中的分支级别等:
perf stat -e instructions,cycles ./13x05-sort_perf
运行前面的命令后,我们得到了以下结果:
Performance counter stats for './13x05-sort_perf':
30,993,024,309 instructions # 1.03
insn per cycle
30,197,863,655 cycles
6.657835162 seconds time elapsed
6.502372000 seconds user
0.155008000 seconds sys
运行以下命令,您可以获取所有预定义事件的列表,您可以使用perf分析这些事件:
perf list
让我们再进行几个操作:
perf stat -e branches ./13x05-sort_perf
之前的命令测量了已执行的分支指令的数量。我们得到了以下结果:
Performance counter stats for './13x05-sort_perf':
5,246,138,882 branches
6.712285274 seconds time elapsed
6.551799000 seconds user
0.159970000 seconds sys
在这里,我们可以看到,执行指令中有六分之一是分支指令,这在排序大型向量的程序中是预期的。
如前所述,测量代码中的分支级别很重要,尤其是在代码的短部分(以避免可能影响测量的交互)。如果没有分支或只有很少的分支,CPU 将运行指令的速度会更快。分支的主要问题是 CPU 可能需要重建流水线,这可能会很昂贵,尤其是如果分支在内部/关键循环中。
以下命令将报告 L1 缓存数据访问的数量(我们将在下一节中看到 CPU 缓存):
perf stat -e all_data_cache_accesses ./13x05-sort_perf
我们得到了以下结果:
Performance counter stats for './13x05-sort_perf':
21,286,061,764 all_data_cache_accesses
6.718844368 seconds time elapsed
6.561416000 seconds user
0.157009000 seconds sys
让我们回到我们的锁竞争示例,并使用perf收集一些有用的统计数据。
使用perf的另一个好处是CPU 迁移——也就是说,线程从一个 CPU 核心移动到另一个核心的次数。线程在核心之间的迁移可能会降低缓存性能,因为当线程移动到新的核心时,会失去缓存数据的优势(关于缓存的更多内容将在下一节中介绍)。
让我们运行以下命令:
perf stat -e cpu-migrations ./13x07-thread_contention
这导致了以下输出:
Performance counter stats for './13x08-thread_contention':
45 cpu-migrations
50.476706194 seconds time elapsed
57.333880000 seconds user
262.123060000 seconds sys
让我们看看使用 perf 的另一个优点:上下文切换。它计算执行过程中的上下文切换次数(线程被交换出去和另一个线程被调度的次数)。高上下文切换可能表明太多线程正在竞争 CPU 时间,从而导致性能下降。
让我们运行以下命令:
perf stat -e context-switches ./13x07-thread_contention
这导致以下输出:
Performance counter stats for './13x08-thread_contention':
13,867,866 cs
47.618283562 seconds time elapsed
52.931213000 seconds user
247.033479000 seconds sys
这一节的内容到此结束。在这里,我们介绍了 Linux perf 工具及其一些应用。我们将在下一节研究 CPU 内存缓存和假共享。
假共享
在本节中,我们将研究多线程应用程序中一个常见的问题,称为 假共享。
我们已经知道,多线程应用程序的理想实现是尽量减少不同线程之间共享的数据。理想情况下,我们应该只为读取访问共享数据,因为在这种情况下,我们不需要同步线程来访问共享数据,因此我们不需要支付运行时成本,也不需要处理死锁和活锁等问题。
现在,让我们考虑一个简单的例子:四个线程并行运行,生成随机数,并计算它们的总和。每个线程独立工作,生成随机数并计算存储在它刚刚写入的变量中的总和。这是一个理想的应用(尽管对于这个例子来说有点牵强),线程独立工作,没有任何共享数据。
以下代码是我们将在本节中分析的示例的完整源代码。在阅读解释时,你可以参考它:
#include <chrono>
#include <iostream>
#include <random>
#include <thread>
#include <vector>
struct result_data {
unsigned long result { 0 };
};
struct alignas(64) aligned_result_data {
unsigned long result { 0 };
};
void set_affinity(int core) {
if (core < 0) {
return;
}
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(core, &cpuset);
if (pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset) != 0) {
perror("pthread_setaffinity_np");
exit(EXIT_FAILURE);
}
}
template <typename T>
auto random_sum(T& data, const std::size_t seed, const unsigned long iterations, const int core) {
set_affinity(core);
std::mt19937 gen(seed);
std::uniform_int_distribution dist(1, 5);
for (unsigned long i = 0; i < iterations; ++i) {
data.result += dist(gen);
}
}
using namespace std::chrono;
void sum_random_unaligned(int num_threads, uint32_t iterations) {
auto* data = new(static_cast<std::align_val_t>(64)) result_data[num_threads];
auto start = high_resolution_clock::now();
std::vector<std::thread> threads;
for (std::size_t i = 0; i < num_threads; ++i) {
set_affinity(i);
threads.emplace_back(random_sum<result_data>, std::ref(data[i]), i, iterations, i);
}
for (auto& thread : threads) {
thread.join();
}
auto end = high_resolution_clock::now();
auto duration = std::chrono::duration_cast<milliseconds>(end - start);
std::cout << "Non-aligned data: " << duration.count() << " milliseconds" << std::endl;
operator delete[] (data, static_cast<std::align_val_t>(64));
}
void sum_random_aligned(int num_threads, uint32_t iterations) {
auto* aligned_data = new(static_cast<std::align_val_t>(64)) aligned_result_data[num_threads];
auto start = high_resolution_clock::now();
std::vector<std::thread> threads;
for (std::size_t i = 0; i < num_threads; ++i) {
set_affinity(i);
threads.emplace_back(random_sum<aligned_result_data>, std::ref(aligned_data[i]), i, iterations, i);
}
for (auto& thread : threads) {
thread.join();
}
auto end = high_resolution_clock::now();
auto duration = std::chrono::duration_cast<milliseconds>(end - start);
std::cout << "Aligned data: " << duration.count() << " milliseconds" << std::endl;
operator delete[] (aligned_data, static_cast<std::align_val_t>(64));
}
int main() {
constexpr unsigned long iterations{ 100000000 };
constexpr unsigned int num_threads = 8;
sum_random_unaligned(8, iterations);
sum_random_aligned(8, iterations);
return 0;
}
如果你编译并运行前面的代码,你会得到类似于以下输出的结果:
Non-aligned data: 4403 milliseconds
Aligned data: 160 milliseconds
程序仅调用两个函数:sum_random_unaligned 和 sum_random_aligned。这两个函数做的是同一件事:它们创建八个线程,每个线程生成随机数并计算它们的总和。线程之间没有共享数据。你可以看到这两个函数几乎相同,主要区别在于 sum_random_unaligned 使用以下数据结构来存储生成的随机数的总和:
struct result_data {
unsigned long result { 0 };
};
sum_random_aligned 函数使用了一个稍微不同的方法:
struct alignas(64) aligned_result_data {
unsigned long result { 0 };
};
唯一的区别是使用了 alignas(64) 来通知编译器,数据结构实例必须在 64 字节边界上对齐。
我们可以看到,性能差异非常明显,因为线程正在执行相同的任务。只需将每个线程写入的变量对齐到 64 字节边界,就可以大大提高性能。
要理解为什么会发生这种情况,我们需要考虑现代 CPU 的一个特性——内存缓存。
CPU 内存缓存
现代 CPU 在计算方面非常快,当我们想要达到最大性能时,内存访问是主要的瓶颈。内存访问的良好估计约为 150 纳秒。在这段时间内,我们的 3.6 GHz CPU 已经通过了 540 个时钟周期。作为一个粗略估计,如果 CPU 每两个周期执行一条指令,那么就是 270 条指令。对于一个普通应用程序,内存访问是一个问题,即使编译器可能会重新排序它生成的指令,CPU 也可能重新排序指令以优化内存访问并尽可能多地运行指令。
因此,为了提高现代 CPU 的性能,我们有了所谓的CPU 缓存或内存缓存,这是芯片中的内存,用于存储数据和指令。这种内存比 RAM 快得多,允许 CPU 更快地检索数据,从而显著提高整体性能。
作为现实生活中的缓存示例,想想一个厨师。他们需要一些原料来为他们的餐厅客户准备午餐。现在,想象一下,他们只有在客户来到餐厅并点餐时才购买这些原料。这将非常慢。他们也可以去超市购买一天的原料,比如。现在,他们可以为所有客户烹饪,并在更短的时间内为他们提供餐点。
CPU 缓存遵循相同的概念:当 CPU 需要访问一个变量时,比如一个 4 字节的整数,它会读取 64 字节(这个大小可能因 CPU 而异,但大多数现代 CPU 使用这个大小)的连续内存,以防万一它可能需要访问更多的连续数据。
线性内存数据结构,如std::vector,在内存访问方面将表现得更好,因为这些情况下,缓存可以大幅提高性能。对于其他类型的数据结构,如std::list,则不会是这样。当然,这仅仅是关于优化缓存使用。
你可能想知道,如果 CPU 缓存内存如此之好,为什么所有内存都像那样?答案是成本。缓存内存非常快(比 RAM 快得多),但它也非常昂贵。
现代 CPU 采用分层缓存结构,通常由三个级别组成,称为 L1、L2 和 L3:
-
L1 缓存是最小和最快的。它也是最接近 CPU 的,同时也是最昂贵的。它通常分为两部分:一个指令缓存用于存储指令,一个数据缓存用于存储数据。典型的大小是 64 Kb,分为 32 Kb 用于指令和 32 Kb 用于数据。L1 缓存的典型访问时间在 1 到 3 纳秒之间。
-
L2 缓存比 L1 大,速度略慢,但仍然比 RAM 快得多。典型的 L2 缓存大小在 128 Kb 到 512 Kb 之间(本章中使用的 CPU 每个核心有 512 Kb 的 L2 缓存)。典型的 L2 缓存访问时间约为 3 到 5 纳秒。
-
L3 缓存是三者中最大且速度最慢的。L1 和 L2 缓存是每个核心的(每个核心都有自己的 L1 和 L2 缓存),但 L3 是多个核心共享的。我们的 CPU 每组八个核心共享 32 Mb 的 L3 缓存。典型的访问时间大约是 10 到 15 纳秒。
有了这些,让我们将注意力转向与内存缓存相关的一个重要概念。
缓存一致性
CPU 不直接访问 RAM。这种访问总是通过缓存进行的,只有当 CPU 在缓存中找不到所需的数据时才会访问 RAM。在多核系统中,每个核心都有自己的缓存意味着同一块 RAM 可能同时存在于多个核心的缓存中。这些副本需要始终同步;否则,计算结果可能会不正确。
到目前为止,我们已经看到每个核心都有自己的 L1 缓存。让我们回到我们的例子,思考一下当我们使用非对齐内存运行函数时会发生什么。
在这种情况下,每个 result_data 实例是 8 字节。我们创建了一个包含 8 个 result_data 实例的数组,每个线程一个。总共占用的内存将是 64 字节,所有实例在内存中都是连续的。每次线程更新随机数的总和时,它都会改变存储在缓存中的值。记住,CPU 总是会一次读取和写入 64 字节(这被称为缓存行——你可以将其视为最小的内存访问单元)。所有变量都在同一个缓存行中,即使线程没有共享它们(每个线程都有自己的变量——sum),CPU 也不知道这一点,需要使更改对所有核心可见。
在这里,我们有 8 个核心,每个核心都在运行一个线程。每个核心已经从 RAM 中加载了 64 字节的内存到 L1 缓存中。由于线程只读取变量,所以一切正常,但一旦某个线程修改了它的变量,缓存行的内容就会被无效化。
现在,由于剩余的 7 个核心中的缓存行无效,CPU 需要将更改传播到所有核心。如前所述,即使线程没有共享变量,CPU 也不可能知道这一点,并且需要更新所有核心的所有缓存行以保持值的一致性。这被称为缓存一致性。如果线程共享变量,那么不将更改传播到所有核心是不正确的。
在我们的例子中,缓存一致性协议在 CPU 内部产生了相当多的流量,因为所有线程都共享变量所在的内存区域,尽管从程序的角度来看它们并不共享。这就是我们称之为伪共享的原因:变量之所以共享,是因为缓存和缓存一致性协议的工作方式。
当我们将数据对齐到 64 字节边界时,每个实例占用 64 字节。这保证了它们位于自己的缓存行中,并且不需要缓存一致性流量,因为在这种情况下,没有数据共享。在这种情况下,性能要好得多。
让我们使用perf来确认这一点是否真的发生了。
首先,我们在执行sum_random_unaligned时运行perf。我们想看看程序访问缓存的次数和缓存未命中的次数。每次缓存需要更新,因为它包含的数据也在另一个核心的缓存行中,都算作一次缓存未命中:
perf stat -e cache-references,cache-misses ./13x07-false_sharing
我们得到以下结果:
Performance counter stats for './13x07-false_sharing':
251,277,877 cache-references
242,797,999 cache-misses
# 96.63% of all cache refs
大多数缓存引用都是缓存未命中。这是预期的,因为伪共享。
现在,如果我们运行sum_random_aligned,结果会有很大不同:
Performance counter stats for './13x07-false_sharing':
851,506 cache-references
231,703 cache-misses
# 27.21% of all cache refs
缓存引用和缓存未命中的数量都小得多。这是因为不需要不断更新所有核心的缓存以保持缓存一致性。
在本节中,我们看到了多线程代码中最常见的性能问题之一:伪共享。我们看到了一个带有和没有伪共享的函数示例,以及伪共享对性能的负面影响。
在下一节中,我们将回到我们在第五章中实现的 SPSC 无锁队列,并提高其性能。
SPSC 无锁队列
在第五章中,我们实现了一个 SPSC 无锁队列,作为如何从两个线程同步访问数据结构的示例,而不使用锁。这个队列仅由两个线程访问:一个生产者将数据推送到队列,一个消费者从队列中弹数据。这是最容易同步的队列。
我们使用了两个原子变量来表示队列的头部(读取缓冲区索引)和尾部(写入缓冲区索引):
std::atomic<std::size_t> head_ { 0 };
std::atomic<std::size_t> tail_ { 0 };
为了避免伪共享,我们可以将代码更改为以下内容:
alignas(64) std::atomic<std::size_t> head_ { 0 };
alignas(64) std::atomic<std::size_t> tail_ { 0 };
在这次更改之后,我们可以运行我们实现的代码来测量生产者和消费者线程每秒执行的操作数(推/弹)。代码可以在本书的 GitHub 仓库中找到。
现在,我们可以运行perf:
perf stat -e cache-references,cache-misses ./13x09-spsc_lock_free_queue
我们将得到以下结果:
101559149 ops/sec
Performance counter stats for ‹./13x09-spsp_lock_free_queue›:
532,295,487 cache-references
219,861,054 cache-misses # 41.30% of all cache refs
9.848523651 seconds time elapsed
在这里,我们可以看到队列每秒能够处理大约 1 亿次操作。此外,大约有 41%的缓存未命中。
让我们回顾一下队列的工作原理。在这里,生产者是唯一写入tail_的线程,消费者是唯一写入head_的线程。尽管如此,两个线程都需要读取tail_和head_。我们已经将这两个原子变量声明为aligned(64),以确保它们保证位于不同的缓存行中,从而没有伪共享。然而,存在真正的共享。真正的共享也会生成缓存一致性流量。
真正的共享意味着两个线程都共享对两个变量的访问权限,即使每个变量只是由一个线程(并且总是同一个线程)写入。在这种情况下,为了提高性能,我们必须减少共享,尽可能避免每个线程对两个变量的读访问。我们无法避免数据共享,但我们可以减少它。
让我们关注生产者(对于消费者也是同样的机制):
bool push(const T &item) {
std::size_t tail = tail_.load(std::memory_order_relaxed);
std::size_t next_tail = (tail + 1) & (capacity_ - 1);
if (next_tail == cache_head_) {
cache_head_ = head_.load(std::memory_order_acquire);
if (next_tail == cache_head_) {
return false;
}
}
buffer_[tail] = item;
tail_.store(next_tail, std::memory_order_release);
return true;
}
push() 函数只由生产者调用。
让我们分析一下该函数的功能:
-
它原子地读取环形缓冲区中存储最后一个项目的索引:
std::size_t tail = tail_.load(std::memory_order_relaxed); -
它计算项目将在环形缓冲区中存储的索引:
std::size_t next_tail = (tail + 1) & (capacity_ - 1); -
它检查环形缓冲区是否已满。然而,它不是读取 head_ ,而是读取缓存的头部值:
if (next_tail == cache_head_) {初始时,cache_head_ 和 cache_tail_ 都被设置为零。如前所述,使用这两个变量的目的是最小化核心之间的缓存更新。缓存变量技术是这样的:每次调用 push(或 pop )时,我们原子地读取 tail_(由同一线程写入,因此不需要缓存更新)并生成下一个存储传递给 push 函数的项目索引。现在,我们不是使用 head_ 来检查队列是否已满,而是使用 cache_head_,它只被一个线程(生产者线程)访问,避免了任何缓存一致性流量。如果队列“已满”,则通过原子加载 head_ 来更新 cache_head_。在此更新之后,我们再次检查。如果第二次检查的结果是队列已满,则返回 false。
使用这些局部变量(生产者使用 cache_head_ ,消费者使用 cache_tail_ )的优势在于它们减少了真正的共享——也就是说,访问可能在不同核心的缓存中更新的变量。当生产者在消费者尝试获取它们之前在队列中推送多个项目时(消费者也是如此),这将表现得更好。比如说生产者在队列中插入 10 个项目,而消费者尝试获取一个项目。在这种情况下,使用缓存变量进行的第一次检查将告诉我们队列是空的,但在更新为实际值之后,它将正常工作。消费者只需通过检查队列是否为空(只读取 cache_tail_ 变量)就可以获取另外九个项目。
-
如果环形缓冲区已满,则更新 cache_head_ :
head_.load(std::memory_order_acquire); if (next_tail == cache_head_) { return false; } -
如果缓冲区已满(不仅仅是 cache_head_ 需要更新),则返回 false 。生产者无法将新项目推送到队列中。
-
如果缓冲区未满,将项目添加到环形缓冲区并返回 true:
buffer_[tail] = item; tail_.store(next_tail, std::memory_order_release); return true;
我们可能减少了生产者线程访问 tail_ 的次数,从而减少了缓存一致性流量。考虑以下情况:生产者和消费者使用队列,生产者调用 push()。当 push() 更新 cache_head_ 时,它可能比 tail_ 前面多一个槽位,这意味着我们不需要读取 tail_。
同样的原则也适用于消费者和 pop()。
在修改代码以减少缓存一致性流量后,让我们再次运行 perf:
162493489 ops/sec
Performance counter stats for ‹./13x09-spsp_lock_free_queue›:
474,296,947 cache-references
148,898,301 cache-misses # 31.39% of all cache refs
6.156437788 seconds time elapsed
12.309295000 seconds user
0.000999000 seconds sys
在这里,我们可以看到性能提高了大约 60%,并且缓存引用和缓存缺失的数量更少。
通过这样,我们学习了如何通过减少两个线程之间共享数据的访问来提高性能。
摘要
在本章中,我们介绍了你可以用来分析代码的三个方法:std::chrono,使用 Google Benchmark 库进行微基准测试,以及 Linux 的 perf 工具。
我们还看到了如何通过减少/消除伪共享和减少真实共享来提高多线程程序的性能,从而减少缓存一致性流量。
本章提供了一些基本的分析技术介绍,这些技术将作为进一步研究的起点非常有用。正如我们在本章开头所说,性能是一个复杂的话题,值得有它自己的书籍。
进一步阅读
-
Fedor G. Pikus,编写高效程序的艺术,第一版,Packt Publishing,2021。
-
Ulrich Drepper,程序员应了解的内存知识,2007。
-
Shivam Kunwar,优化多线程性能(
www.youtube.com/watch?v=yN7C3SO4Uj8)。
1532

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



