Linux-系统的-C---编程指南-全-

Linux 系统的 C++ 编程指南(全)

原文:zh.annas-archive.org/md5/0061a465ddd209c15f32a1ff7a4ea5cd

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

亲爱的读者,您好!您已经置身于朋友之中。欢迎您踏上高级技能、意外惊喜、巧妙知识和新颖编程工具的旅程。假设您是一位经验丰富的软件工程师,知道如何编写高质量的代码,并对一些构建和操作系统有所了解。您也遇到过几种计算机架构,并修复了一个或两个错误。那么以下情况如何:您是一位刚开始学习如何成为一名软件工程师的学生。您希望有一天成为一名优秀的专业人士。您希望成为别人在软件行为不可预测时寻求的专家。或者您只是出于最初的好奇心拿起这本书,还不知道能期待什么。那就太完美了!

我们挑战您回忆一下在您的实践中,至今仍无法解释发生了什么的情况。不,我们不是指超自然现象——尽管这个话题相当晦涩。我们谈论的是系统及其行为,以及我们作为专业工程师如何进行其行为。我们的代码只是告诉机器做什么的工具。所以,假设您已经记起了一个困扰您一段时间的问题——您将如何处理?如果它阻碍了您晋升的道路呢?或者当它让一个对您来说重要的客户失望时呢?或者您只是想给您的老师留下深刻印象。我们理解您!我们也在那里。

尽管如此,不要被误导。我们通过一些基本要点为您提供机会,丰富您作为工程师的方式,但我们并非拥有所有答案。我们坚信,改变您看待代码工作方式的方法将使您成为一个更加稳健的专家,无论您的专业领域是什么。您应该关心这一点,因为技术世界正在快速发展。跟上每一个创新、算法、语言、操作系统和架构是不可能的。但您可以在正确的时间提出正确的问题。您有机会了解如何进一步优化、更好地设计、验证您的环境,并激励自己彻底理解您的工作。

我们再次向您发起挑战。这次,通过我们的经验和专业知识,让我们帮助您更加自我意识和高效。有一些复杂的现实世界挑战,我们急切地想要与您分享。请记住,这需要您花费一些时间。作为朋友,我们希望您喜欢这本书,并将其中激动人心的部分与他人分享。快快……让我们出发吧!

本书面向的对象

本书面向希望提升在基于 Linux 和 Unix 操作系统中 C++编程知识的程序员和开发者。无论您是希望学习如何在这样的环境中使用 C++的初学者,还是希望探索适用于系统编程的最新 C++20 特性的经验丰富的程序员,您都会发现这本书很有帮助。

本书涵盖的内容

第一章, 开始使用 Linux 系统和 POSIX 标准,向读者介绍了不同操作系统存在的理由。讨论了 Linux 的特定内容,读者接着学习基于 Unix 的操作系统编程的基础。提到了内核空间和用户空间,因为系统调用接口被彻底解释。之后,我们利用这个机会介绍 POSIX 和一些标准函数调用,以便让读者了解系统编程的好处。

第二章, 关于进程管理的更多学习,扩展了前一章的学习内容,并指出如果操作系统是主要资源管理器,那么进程就是主要资源使用者。这是通过一个可能变得复杂且需要良好分析的过程来实现的。因此,本章介绍了主要进程的生命周期——其启动、运行和最终状态。还介绍了线程的本质。我们探讨了操作系统的调度算法。引入了一个示例 C++应用程序,并讨论了其main()函数作为入口点。此外,还介绍了启动进程的不同方法:fork()vfork()exec()。还讨论了其他基本函数,如wait()exit()pthread_create()pthread_join()

第三章, 文件系统导航,展示了文件在 Linux 中作为基本资源表示的作用——无论是数据还是对 I/O 设备的访问。这种抽象允许用户以相同的方式通过相同的系统接口操作流或存储数据。我们讨论了文件系统结构——元数据和 inode。向读者展示了 C++文件系统操作的示例。我们利用这个机会介绍了管道作为进程间通信的初始工具。还提供了 C++20 的string_view对象。最后,我们提到了信号处理,因为它将在后续章节中需要。

第四章, 深入 C++对象,引导读者了解一些核心的 C++特性,如对象的创建过程及其初始化。我们讨论了生命周期对象问题、临时对象、RVO、RAII 模式以及 C++20。我们还一起介绍了函数对象和 lambda 表达式,以及如何使用它们的详细说明。接下来,我们将更深入地探讨 lambda 表达式。最后,我们将关注一些如何在 STL 和多线程中使用 lambda 表达式的具体示例。

第五章使用 C++处理错误,探讨了基于 Unix 操作系统的 C++编程中不同类型的错误报告,如错误代码、异常和断言。我们将讨论异常处理和异常操作的最佳实践以及未捕获异常在系统中的情况。我们将讨论异常规范以及为什么我们更喜欢noexcept关键字。我们将探讨使用异常时的性能影响及其背后的机制。接下来,我们将讨论如何使用std::optional来处理错误。最后,我们将讨论std::uncaught_exceptions功能提供了什么。

第六章使用 C++进行并发系统编程,讨论了基于 Unix 的操作系统中的进程和线程的基本原理和理论。我们将探讨 C++内存模型的变化,以便原生地支持并发。我们将熟悉 C++原语,这些原语使多线程支持成为可能——线程、jthread 和任务。接下来,我们将学习如何使用 C++同步原语来同步并行代码的执行。我们还将研究 STL 在并行算法方向上提供了什么。最后,我们将学习如何编写无锁代码。

第七章进程间通信的进行,引导读者了解 Linux 环境中的基本 IPC 机制(因为他们已经对多线程的挑战有所了解)。进程之间能够轻松地相互通信是很重要的,因此,我们快速地浏览了消息队列。它们允许在不阻塞进程的情况下交换数据。我们将花一些时间讨论同步机制——信号量和互斥量——然后继续讨论共享内存。它提供了对某些数据的快速访问,同时允许异构系统有一个数据交换的共同点。最后,套接字经常被使用,但主要是为了它们允许网络上的计算机系统之间进行通信的可能性。

第八章在 Linux 中使用时钟、定时器和信号,介绍了基于 Unix 的操作系统中的信号和定时器。我们最初将介绍信号系统是如何工作的,以及用户如何有效地管理操作时间。我们将介绍 C++语言提供的功能来处理时钟和定时器。我们将介绍标准时间 API,std::chrono,预定义的时钟和时间。接下来,我们将介绍如何正确使用它们以及可以期待什么。然后,我们将关注标准提供的持续时间功能以及用户定义的时钟。最终,我们将介绍 C++20 中引入的日历和时间库。

第九章理解 C++内存模型,探讨了 C++20 的一些新特性。它指导读者了解如何以及为什么管理动态资源的一些关键评论。接着讨论了条件变量和互斥锁的使用,以及懒初始化和缓存友好性。在讨论如何从不同的同步机制中选择方法时,介绍了 C++内存顺序。还展示了自旋锁/票选锁技术。

第十章在系统编程中使用 C++协程,讨论了协程,这是一个在有些编程语言中已有实现但现在是 C++20 中引入的概念。它们被描述为在执行期间挂起并在稍后恢复的栈函数。本章讨论了系统编程领域中的这些确切的有价值特性。还讨论了它们的缺点,例如,在堆上保持挂起的协程状态。还展示了某些实际用法。

为了充分利用本书

在开始本书之前,需要熟悉 C++语言的基础和 C 语言中的 POSIX 编程。对 Linux 和 Unix 的基本知识将有所帮助,但不是必需的。

本书涵盖的软件 操作系统要求
C++20 Linux Mint 21
GCC12.2
godbolt.org

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免复制粘贴代码时可能出现的任何潜在错误。

每一章都有其各自的技术要求。所有示例都经过这些要求运行。代码依赖于系统,因此可能无法直接在您的环境中运行。

下载示例代码文件

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

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

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“该示例使用open()close() POSIX 函数,这些函数尝试从我们的 Linux 测试环境的文件系统中打开和关闭文件。”

代码块设置如下:

if (ecode.value() == EEXIST)

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

std::for_each(v1.begin(), v1.end(),
                  &mean, sum{0.0}, count{0}, text mutable

任何命令行输入或输出都按以下方式编写:

$ ./test

小贴士或重要注意事项

看起来像这样。

联系我们

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

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

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

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

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

分享你的想法

一旦你阅读了《Linux 系统编程的 C++编程》,我们很乐意听到你的想法!请点击此处直接转到该书的亚马逊评论页面并分享你的反馈。

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

下载本书的免费 PDF 副本

感谢你购买本书!

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

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

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

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

优惠远不止这些,你还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取优惠:

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

packt.link/free-ebook/9781805129004

  1. 提交你的购买证明

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

第一部分:巩固基础

本书的这一部分将为你提供进入系统编程高级主题所需的基本工具。经验丰富的读者也会发现这部分内容很有帮助,因为 C++20 的一些特性在系统编程领域得到了实际的应用。本部分确保读者与所讨论的主题保持一致,并提供从技术示例中提取最佳内容的机会。同时,它还注意到了 Linux 系统开发的重要方面。

本部分包含以下章节:

  • 第一章**,从 Linux 系统和 POSIX 标准开始学习

  • 第二章**,学习更多关于进程管理

  • 第三章**,在文件系统中导航

  • 第四章**,深入探索 C++对象

  • 第五章**,使用 C++处理错误

第一章:Linux 系统和 POSIX 标准入门

本书是关于 Linux 以及我们如何在 Linux 环境中使用 C++ 来管理关键资源。C++ 语言在不断发展,你将在接下来的章节中了解到这一点。在进入那个话题之前,我们想在本章中花些时间来建立对 操作系统OSs)的基本理解。你将了解更多关于某些特定技术的起源,包括 系统调用接口可移植操作系统 接口POSIX)。

你选择的操作系统很重要。尽管操作系统最初是为了单一目的而创建的,但如今它们的角色各不相同。人们对它们也有很高的期望。每个操作系统都有其自身的优势和劣势,我们将简要讨论。Linux 在多个技术领域得到广泛应用,拥有庞大的全球社区,因此非常适合我们的实际目的。根据我们的经验,在 Linux 或其他基于 Unix 的操作系统环境中编程相当普遍。无论你的专长在哪里——从 物联网IoT) 设备和嵌入式软件开发到移动设备、超级计算或航天器——你都有很大机会在某个时刻遇到 Linux 发行版。

将本章用作系统编程的入门。即使你对这个主题已经很熟悉,也请花时间回顾一下术语和细节。其中大部分内容都包含在大学课程中,或者被视为常识,但在此处解释一些基本概念仍然很重要,以确保我们在接下来的章节中保持一致。

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

  • 熟悉操作系统(OSs)的概念

  • 了解 Linux 内核

  • 介绍系统调用接口和系统编程

  • 在文件、进程和线程中导航

  • 使用 initsystemd 运行服务

  • 可移植操作系统 接口POSIX

技术要求

为了熟悉编程环境,读者必须准备以下内容:

  • 能够编译和执行 C++20 的基于 Linux 的系统(例如,Linux Mint 21)

熟悉操作系统(OSs)的概念

那么,什么是操作系统?你可能至少能提供一个答案,但让我们简要讨论一下,因为这很重要,我们需要了解我们的计算机系统真正是什么以及我们如何操作它。尽管你可能熟悉这里提供的大部分信息,但我们使用本章来与你就操作系统及其用途达成一致。有些人可能会说,操作系统是为了使硬件作为一个整体工作而创建的。其他人可能会争论说,它是一系列程序的总和,致力于管理整体系统资源。有效地利用这些资源,如 CPU 和内存,至关重要。还有操作系统作为抽象和硬件扩展的概念。最终,我们可以安全地说,现代操作系统是一个复杂的实体。它还具有其他功能,例如收集统计数据、多媒体处理、系统安全性和安全性、整体稳定性、可靠的错误处理等。

虽然操作系统有义务执行所有这些任务,但程序员仍然需要关注系统的具体要求和细节。从更高的抽象层次工作,例如通过虚拟机,并不意味着可以忽视理解我们的代码如何影响系统行为的需求。而且,更接近操作系统层的程序员需要高效地管理系统的资源。这就是操作系统提供应用程序编程接口(APIs)的原因之一。了解如何使用这些 API 以及它们提供的哪些好处是一种宝贵的专业知识。

我们认为,能够与操作系统紧密合作是一种不太常见的技能。了解操作系统和计算机架构的行为属于软件工程的专家级别。我们将讨论一些操作系统类型,只是为了给你一个大致的了解,但本书的重点是专门针对POSIX 兼容的操作系统。话虽如此,让我们熟悉一下我们主要的工具集之一。

操作系统类型

如果我们在网上进行一些快速研究,我们会发现许多类型的操作系统,类型定义将严格基于搜索的标准。一个例子是操作系统的目的:它是一个通用操作系统,如 macOS 和 Windows,还是更具体,如嵌入式 LinuxFreeRTOS?另一个例子是针对 PC 的操作系统与针对移动设备的操作系统。同样,许可协议可能将操作系统描述为开源、企业或企业开源。根据同一时间活跃用户数量,Windows 可能被视为单用户操作系统,因为它只为当前的用户会话构建一个Win32 API。另一方面,类 Unix 操作系统被认为是多用户的,因为多个用户可以同时在该系统上工作,其中每个shell终端实例被视为一个单独的用户会话。

因此,系统的应用及其限制是基本的。因此,需要了解的一个关键区别是系统行为的限制级别。通用操作系统GPOSs)最初作为分时操作系统开始。从历史上看,还有一种类型的操作系统,与分时操作系统起源于同一时期——实时操作系统RTOSs)。预期系统程序员了解GPOSsRTOSs的特定内容。在接下来的章节中,我们将讨论诸如任务优先级、计时器值、外围设备速度、中断和信号处理程序、多线程和动态内存分配等属性如何导致系统行为的变化。有时这些变化是不可预测的。这就是为什么我们认识到两种类型的RTOSs:硬RTOSs和软RTOSs。硬RTOSs通常与特定的硬件紧密相关。系统开发者熟悉最终设备的要求。任务执行时间可以预先评估和编程,尽管设备的输入仍然被视为异步和不可预测的。因此,本书的重点仍然是GPOS编程,附带一些软RTOS功能。

让我们这样设定场景:用户以循环方式接收系统资源如此频繁,以至于给人一种用户是唯一依赖这些资源的印象。用户的工作不应被打断,并且操作系统应提供快速响应时间;理论上,程序越小,响应时间越短。我们将在第二章中进一步讨论这个问题,因为它并不完全正确。

重要提示

GPOS中,用户是系统功能的主要驱动器。操作系统的主要任务是保持与用户和操作的高可用性之间的活跃对话。

在这里,每个任务和对操作系统的每个请求都必须在严格的时间间隔内快速处理。RTOS仅在异常情况、错误和不可预测的行为期间期望用户输入。

重要提示

RTOS中,异步工作的设备和额外的外围电子设备是系统功能的主要驱动器。操作系统的主要任务仍然是进程管理和任务调度。

正如我们所说的,有两种类型的RTOS硬 RTOS软 RTOS。在硬RTOS中,实时任务保证能够按时执行。系统的反应截止时间通常预先定义,而关键任务数据存储在 ROM 中,因此不能在运行时更新。通常移除虚拟内存等功能。一些现代 CPU 核心提供了所谓的紧密耦合内存TCM),在系统启动时,频繁使用的数据和代码行从非易失性存储器NVM)加载到其中。系统的行为是预先脚本化的。这些操作系统的作用与机器控制相关,其中禁止用户的输入。

一个软RTOS(实时操作系统)在任务完成之前为关键任务提供最高优先级,并且不会受到中断。然而,实时任务仍需及时完成,不应无限期地等待。显然,这种类型的操作系统不能用于关键任务:例如工厂机器机器人、车辆等。但它可以用来控制整个系统的行为,因此这种类型的操作系统在多媒体和研究项目中、人工智能、计算机图形学、虚拟现实设备等领域都有应用。由于这些RTOSGPOS(通用操作系统)不冲突,它们可以与之集成。它们的功能也可以在某些 Linux 发行版中找到。这种实现的有趣例子是QNX

简而言之,Linux

这里有一些误解,让我们简要地澄清一下。Linux 是一个类 Unix 操作系统,这意味着它提供了类似于 Unix(有时甚至是相同的)接口——它的功能,尤其是 API,被设计成与 Unix 匹配。但它不是一个基于 Unix的操作系统。它们的实现方式并不相同。在 FreeBSD-macOS 关系的理解中存在类似的误解。尽管两者共享大量的代码,但它们的处理方式完全不同,包括它们内核的结构。

在这本书中,我们需要记住这些事实,因为并非所有我们将要使用的功能都存在于所有类 Unix 操作系统上。我们专注于 Linux,并且只要满足每一章的技术要求,我们的示例就能正常工作。

这个决定有几个原因。首先,Linux 是开源的,您可以轻松地检查其内核代码:github.com/torvalds/linux。由于它是用 C 编写的,您应该能够轻松阅读它。尽管 C 不是面向对象的语言,但 Linux 内核遵循了许多面向对象编程OOP)范式。操作系统本身由许多独立的设计块组成,称为模块。您可以轻松地配置、集成和应用它们,以满足您系统的特定需求。Linux 赋予我们与实时系统(本章后面将描述)一起工作的能力,并执行并行代码执行(在第第六章中讨论)。简而言之——Linux 易于适应、扩展和配置;我们可以轻松地利用这一点。但在哪里,确切地说?

好吧,我们可以开发接近操作系统的应用程序,甚至可以自己制作一些模块,这些模块可以在运行时加载或卸载。这样的例子包括文件系统或设备驱动程序。我们将在第二章中重新探讨这个话题,届时我们将深入探讨进程实体。现在,让我们说这些模块基本上看起来像面向对象的设计:它们是可构建和可销毁的;有时,根据内核的需求,通用代码可以概括成一个模块,并且这些模块具有层次依赖性。尽管如此,Linux 内核被认为是单核的;例如,它具有复杂的功能,但整个操作系统都在内核空间中运行。相比之下,存在微内核(如 QNX、MINIX 或 L4),它们构成了运行操作系统的最小必要条件。在这种情况下,额外的功能是通过在内核本身之外工作的模块提供的。这导致了一个略微混乱但总体清晰的 Linux 内核可能性图景。

了解 Linux 内核

图 1.1展示了 Linux 内核的一个示例。根据您的需求,系统架构可能看起来不同,但您可以看到我们期望在任何给定的 Linux 系统中看到的三个主要层。

这些是用户空间(运行中的进程及其线程)、内核空间(正在运行的内核本身,通常是一个自己的进程)和计算机——这可以是任何类型的计算设备,如 PC、平板电脑、智能手机、超级计算机、物联网设备等等。随着我们在以下章节中逐一解释它们,图中观察到的所有术语都将逐一到位,所以如果你现在不熟悉所有这些术语,请不要担心。

图 1.1 – Linux 内核及其相邻层的概述

图 1.1 – Linux 内核及其相邻层的概述

在前面的图中,一些相互依赖关系可能已经给您留下了印象。例如,看看设备驱动程序相应设备中断是如何相关的。设备驱动程序是字符设备驱动程序块设备驱动程序网络设备驱动程序的泛化。注意中断是如何与任务的调度相关的。这是一个简单但基本的机制,在驱动程序的实现中被广泛使用。它是操作系统和硬件的初始通信控制机制。

只举一个例子:假设您想要从磁盘恢复并读取一个文件(在底层将执行read()调用,然后转换为文件系统操作。文件系统调用设备驱动程序来查找并检索给定文件描述符背后的内容,该内容与文件系统已知的一个地址相关联。这将在第三章中进一步讨论。所需的设备(NVM)开始搜索数据块——一个文件。直到操作完成,如果调用进程是一个单线程进程且没有其他事情可做,它将被停止。另一个进程将开始工作,直到设备找到返回指向文件地址的指针。然后触发一个中断,这有助于操作系统调用调度器。我们的初始进程将使用新加载的数据重新启动,而第二个进程现在将被停止。

这个任务示例展示了您如何仅通过一个微小且微不足道的操作来影响系统的行为——这正是您在第一门编程课程中学到的编程技能。当然,在大多数情况下,不会发生任何坏事。在您的系统生命周期内,许多进程将不断重新安排。这是操作系统的职责,确保这一过程不会出现中断。

但中断是一个重量级的操作,可能会导致不必要的内存访问和无用的应用程序状态转换。我们将在第二章中讨论这一点。现在,只需考虑如果系统过载会发生什么——CPU 使用率高达 99%,或者磁盘收到了许多请求但无法及时处理。如果该系统是飞机嵌入式设备的一部分呢?当然,在现实中这种情况非常不可能,因为飞机有严格的技术要求和高质量标准需要满足。但只是为了辩论,考虑一下您如何防止类似情况发生,或者您如何保证在任何用户场景中代码的成功执行。

介绍系统调用接口和系统编程

当然,我们刚才看到的例子是简化的,但它给了我们一些关于操作系统需要执行的工作的想法——本质上,它负责管理和提供资源,但同时也保持对其他进程请求的可用性。在现代操作系统中,这是一项繁杂的工作。我们很少能对此有所作为。因此,为了更好地控制和预测系统行为,程序员可能会直接使用操作系统的 API,称为系统调用接口

重要注意事项

NVM 数据请求是一个受益于 glibc 的过程,并且不是直接调用的。

换句话说,系统调用定义了程序员通过它来获取所有内核服务的接口。操作系统可以被视为在内核服务和硬件之间的更多中介。除非你喜欢玩硬件引脚和低级平台指令,或者你自己就是模块架构师,否则你应该勇敢地将细节留给操作系统。处理特定的计算机物理接口操作是操作系统的责任。使用正确的系统调用是应用程序的责任。而了解它们对系统整体行为的影响是软件工程师的任务。请记住,使用系统调用是有代价的。

如示例所示,在检索文件时,操作系统会做很多事情。当动态分配内存或单个内存块被多个线程访问时,将做更多的事情。我们将在接下来的章节中进一步讨论这个问题,并将强调尽可能谨慎、有意识地使用系统调用,无论是自愿还是不自愿。简单来说,系统调用不是简单的函数调用,因为它们不在用户空间执行。系统调用不会像你的程序堆栈中的下一个程序一样执行,而是触发一个模式切换,导致跳转到内核内存堆栈中的一个例程。从文件中读取可以可视化如下:

图 1.2 – 从文件中读取的系统调用接口表示

图 1.2 – 从文件中读取的系统调用接口表示

那么我们应该在什么时候使用系统调用呢?简单来说,当我们想要对一些操作系统任务非常精确时,这些任务通常与设备管理文件管理进程控制通信基础设施有关。我们将在后面的章节中展示这些角色的许多例子,但简要来说,欢迎您阅读更多内容,并自己熟悉以下内容:

syscall()
fork()
exec()
exit()
wait()
kill()

重要链接

正确的起点是Linux man-pages 项目,链接如下:www.kernel.org/doc/man-pages/

在以下链接中可以找到有用系统调用的一览表:man7.org/linux/man-pages/man2/syscalls.2.xhtml

我们强烈建议你对自己的项目中进行更多关于系统调用的研究。有没有,以及它们执行什么任务?在你的实现中有没有替代方案?

你可能已经猜到了,使用系统调用接口对系统本身也存在安全风险。如此接近内核和设备控制,为恶意软件渗透你的软件提供了极大的机会。当你的软件影响系统行为时,另一个程序可能会四处嗅探并收集有价值的数据。至少,你应该以这种方式设计你的代码,即使用户界面与关键程序(特别是系统调用)隔离良好。要达到 100%的安全是不可能的,尽管有许多关于安全问题的全面书籍,但确保系统安全本身是一个不断发展的过程。

说到进程,让我们继续下一个主题:Linux 系统的基本实体。

在文件、进程和线程之间导航

如果你已经到达这里——做得好!我们将在第二章中彻底介绍进程和线程,在第三章中介绍文件系统。在此期间,我们将在这里稍作偏离,只是为了通过定义三个重要术语:文件进程线程,为你描绘一幅更清晰的画面。你可能已经在前面的内核概述中注意到了其中两个,所以现在我们将简要解释它们,以防你不熟悉它们。

文件

简而言之,我们需要文件来表示我们系统上的多种资源。我们编写的程序也是文件。例如,编译后的代码,可执行二进制文件(.bin.exe),以及库都是文件(.o.so.lib.dll等等)。此外,我们还需要它们用于通信机制和存储管理。你知道 Linux 上可识别哪些类型的文件吗?让我们快速了解一下:

  • 普通或常规文件:几乎系统上存储数据的所有文件都被视为常规文件:文本、媒体、代码等等。

  • 目录:用于构建文件系统的层次结构。它们不存储数据,而是存储其他文件的位置。

  • /dev目录,代表你所有的硬件设备。

  • 链接:我们使用这些来允许访问不同位置的另一个文件。实际上,它们是真实文件的替代品,通过它们可以直接访问这些文件。这与 Windows 的快捷方式不同。它们是特定的文件类型,需要应用程序支持它们——首先处理快捷方式元数据,然后指向资源,这样文件就不会一次性被访问。

  • 套接字:这是进程交换数据的通信端点,包括与其他系统交换数据。

  • 命名管道:我们使用命名管道在系统上当前运行的两个进程之间交换双向数据。

第三章中,我们将通过一些实际例子来探讨这些内容。你将看到那里每个文件类型的用法,除了套接字,它将在本书后面的部分详细解释。我们现在需要的是一个可以运行的程序。

进程和线程

进程是一个程序的实例,更确切地说,是一个正在执行的实例。它有自己的地址空间,并且与其他进程保持隔离。这意味着每个进程都有一个范围(通常是虚拟的)地址,操作系统将其分配给它。Linux 将其视为任务。它们对普通用户是不可观察的。这正是内核工作的方式。每个任务都通过task_struct实体来描述,该实体定义在include/linux/sched.h中。系统管理员和系统程序员通过进程表来观察进程,通过每个进程特定的进程标识符——pid进行散列。这种方法用于快速查找进程——在终端中使用ps命令查看系统上的进程状态,然后输入以下命令以查看单个进程的详细信息:

ps -p <required pid>

例如,让我们启动一个名为test的程序,并让它运行:

$ ./test

你可以打开一个单独的终端,查看运行进程列表中的test,如下所示:

$ ps
PID TTY           TIME CMD
...
56693 ttys001    0:00.00 test

如果你已经知道了PID,那么只需执行以下操作:

$ ps –p 56693
56693 ttys001    0:00.00 test

通过当前进程属性的副本创建一个新的进程,并将属于一个进程组。一个或多个组创建一个会话。每个会话都与一个终端相关联。组和会话都有进程领导者。属性的克隆主要用于资源共享。如果两个进程共享相同的虚拟内存空间,它们将被视为单个进程中的两个线程来处理和管理,但它们并不像进程那样重量级。那么线程是什么呢?

重要提示

总体来说,我们有四个实体需要关注:首先是可执行文件,因为它是将要执行指令的单位载体。其次是进程——执行这些指令的工作单元。第三——我们需要这些指令作为处理和管理系统资源的工具。第四是线程——由操作系统独立管理的最小指令序列,是进程的一部分。记住,进程和线程的实现对于每个操作系统都是不同的,所以在使用它们之前请做好研究。

从内核的角度来看,进程的主线程是任务组领导者,在代码中标识为group_leader。所有由组领导者产生的线程都可以通过thread_node迭代。实际上,它们存储在一个单链表中,thread_node是它的头。产生的线程携带一个指向group_leader工具的指针。进程创建者task_struct对象就是通过它指向的。你可能已经正确猜到了,它与组领导者的task_struct相同。

重要提示

如果一个进程通过fork()等创建另一个进程,那么新创建的进程(称为子进程)通过parent指针了解它们的创建者。它们也通过sibling指针了解它们的兄弟姐妹,这是一个指向父进程其他子进程的列表节点。每个父进程通过children了解其子进程——这是一个指向列表头部的指针,存储子进程并提供对它们的访问。

如以下图所示,线程不定义任何其他数据结构:

图 1.3 – 通过 task_structs 查看进程和线程的结构

图 1.3 – 通过 task_structs 查看进程和线程的结构

我们已经提到了fork()几次,但它是什么?简单地说,它是一个创建进程调用者进程副本的系统函数。它向父进程提供新进程的 ID 并启动子进程的执行。我们将在下一章提供一些代码示例,你可以查看那里以获取更多详细信息。现在,由于我们正在讨论 Linux 环境,我们应该提到一些重要的事情。

在幕后,fork()被替换为clone()。通过flags提供了不同的选项,但如果所有选项都设置为零,clone()的行为就像fork()。我们建议你在这里了解更多:man7.org/linux/man-pages/man2/clone.2.xhtml

你可能正在问自己为什么这种实现更可取。可以这样想:当内核在进程之间进行切换时,它会检查虚拟内存中当前进程的地址,确切地说是页目录。如果它与新执行的进程相同,那么它们共享相同的地址空间。然后,切换只是一个简单的指针跳转指令,通常指向程序的入口点。这意味着可以期待更快的重新调度。小心——进程可能共享相同的地址空间,但不是相同的程序栈。clone()负责为每个进程创建不同的栈。

现在进程已经创建,我们必须查看其运行模式。请注意,这与进程状态不同。

基于运行模式的进程类型

一些进程需要用户交互来启动或与之交互。它们被称为前台进程。但正如您可能已经发现的,还有一些进程独立于我们的活动或任何其他用户的操作运行。这类进程被称为后台进程。除非另有说明,否则默认将终端输入作为程序执行调用或用户命令处理为前台进程。要在一个后台运行进程,只需在您用于启动进程的命令行末尾放置&即可。例如,让我们调用已知的test,完成后,我们在终端看到以下内容:

$ ./test &
[1] 62934
[1]  + done       ./test

您可以使用其pid轻松地停止它,当调用kill命令时:

$ ./test &
[1] 63388
$ kill 63388
[1]  + terminated./test

正如您所看到的,终止一个进程并让它自行终止是两回事,终止进程可能会导致不可预测的系统行为或无法访问某些资源,例如未关闭的文件或套接字。这个话题将在本书的后面再次讨论。

其他进程是无人照料的。它们被称为守护进程,并且始终在后台持续运行。它们预期始终可用。守护进程通常通过系统的启动脚本启动,并在关闭之前运行。它们通常提供系统服务,并且多个用户依赖于它们。因此,启动时的守护进程通常由 ID 为 0 的用户(通常是root)启动,并且可能以root权限运行。

重要提示

在 Linux 系统中,拥有最高权限的用户被称为 root 用户,或简单地称为 root。这个权限级别允许执行与安全相关的任务。这个角色对系统的完整性有直接影响,因此,直到需要更高的权限级别之前,所有其他用户都必须设置尽可能低的权限级别。

僵尸进程是一个已经终止但仍然通过其pid被识别的进程。它没有地址空间。僵尸进程会一直存在,直到其父进程运行。这意味着,直到我们退出主进程、关闭系统或重启系统,僵尸进程在ps列表中仍然会显示为<defunct>

$ ps
  PID TTY           TIME CMD
…
64690 ttys000    0:00.00 <defunct>

您也可以通过top查看僵尸进程:

$ top
t–p - 07:58:26 up 100 days,  2:34, 2 users,  load average: 1.20, 1.12, 1.68
Tasks: 200 total,   1 running, 197 sleeping,   1 stopped,   1 zombie

回到后台进程的讨论,还有另一种方法可以执行特定的程序,而无需显式启动后台进程。甚至更好——我们可以管理在系统启动或不同系统事件上运行的此类进程。让我们在下一节中看看这个。

使用 init 和 systemd 运行服务

让我们利用这个机会来讨论initsystemd进程守护进程。还有其他一些,但我们已经决定保持对这两个的关注。第一个是在 Linux 系统上由内核执行的初始进程,其pid始终为1

$ ps -p 1
PID TTY          TIME CMD
1 ?        04:53:20 systemd

它被称为系统上所有进程的父进程,因为它用于初始化、管理和跟踪其他服务和守护进程。Linux 的第一个 init 守护进程被称为 Init,它定义了六个系统状态。所有系统服务都映射到这些状态。它的脚本用于以预定义的顺序启动进程,这偶尔会被系统程序员使用。使用它的一个可能的原因是减少系统的启动时间。要创建服务或编辑脚本,你可以修改 /etc/init.d。由于这是一个目录,我们可以使用 ls 命令列出它,并查看所有可以通过 init 运行的服务。

这是我们机器上的内容:

$ ls /etc/init.d/
acpid
alsa-utils
anacron
...
ufw
unidd
x11-common

每个这些脚本都遵循相同的代码模板进行执行和维护:

图 1.4 – init.d 脚本,表示可能的服务操作

图 1.4 – init.d 脚本,表示可能的服务操作

你可以自己生成相同的模板,并通过以下命令了解更多关于 init 脚本源代码的信息:

$ man init-d-script

你可以通过以下命令列出可用服务的状态:

$ service --status-all
 [ + ]  acpid
 [ - ]  alsa-utils
 [ - ]  anacron
...
 [ + ]  ufw
 [ - ]  uuidd
 [ - ]  x11-common

我们可以停止防火墙服务 – ufw

$ service ufw stop

现在,让我们检查其状态:

$ service ufw status
● ufw.service - Uncomplicated firewall
Loaded: loaded (/lib/systemd/system/ufw.service; enabled; vendor preset: enabled)
Active: inactive (dead) since Thu 2023-04-06 14:33:31 EEST; 46s ago
Docs: man:ufw(8)
Process: 404 ExecStart=/lib/ufw/ufw-init start quiet (code=exited, status=0/SUCCESS)
Process: 3679 ExecStop=/lib/ufw/ufw-init stop (code=exited, status=0/SUCCESS)
Main PID: 404 (code=exited, status=0/SUCCESS)
Apr 06 14:33:30 oem-virtual-machine systemd[1]: Stopping Uncomplicated firewall...
Apr 06 14:33:31 oem-virtual-machine ufw-init[3679]: Skip stopping firewall: ufw (not enabled)
Apr 06 14:33:31 oem-virtual-machine systemd[1]: ufw.service: Succeeded.
Apr 06 14:33:31 oem-virtual-machine systemd[1]: Stopped Uncomplicated firewall.

现在,让我们再次启动它并再次检查其状态:

$ service ufw start
$ service ufw status
● ufw.service - Uncomplicated firewall
Loaded: loaded (/lib/systemd/system/ufw.service; enabled; vendor preset: enabled)
Active: active (exited) since Thu 2023-04-06 14:34:56 EEST; 7s ago
Docs: man:ufw(8)
Process: 3736 ExecStart=/lib/ufw/ufw-init start quiet (code=exited, status=0/SUCCESS)
Main PID: 3736 (code=exited, status=0/SUCCESS)
Apr 06 14:34:56 oem-virtual-machine systemd[1]: Starting Uncomplicated firewall...
Apr 06 14:34:56 oem-virtual-machine systemd[1]: Finished Uncomplicated firewall.

以类似的方式,你可以创建自己的服务并使用 service 命令来启动它。一个重要的说明是,init 在现代的完整规模的 Linux 系统上被认为是一种过时的方法。然而,它可以在每个基于 Unix 的操作系统上找到,与 systemd 不同,因此系统程序员会预期它作为服务的一个常见接口。因此,我们更多地将其用作一个简单的示例和解释服务从何而来。如果我们想使用最新的方法,我们必须转向 systemd

/lib/systemd/system/etc/systemd/system 目录下的 .service 文件。在 /lib 中找到的服务是系统启动服务的定义,而在 /etc 中的是在系统运行期间启动的服务。让我们列出它们:

$ ls /lib/systemd/system
accounts-daemon.service
acpid.path
acpid.service
...
sys-kernel-config.mount
sys-kernel-debug.mount
sys-kernel-tracing.mount
syslog.socket
$ ls /etc/systemd/system
bluetooth.target.wants
display-manager.service
…
timers.target.wants
vmtoolsd.service

在我们继续示例之前,让我们在这里提出一个免责声明 – systemd 的接口比 init 复杂得多。我们鼓励你花时间单独检查它,因为我们无法在这里简要总结。但如果你列出你的 systemd 目录,你可能会观察到许多类型的文件。在守护进程的上下文中,它们被称为 units。每个都提供了不同的接口,因为它们都与 systemd 管理的某个实体相关。每个文件中的脚本描述了设置了哪些选项以及给定服务做什么。units 的名称是优雅的。.timer 用于定时管理,.service 用于给定服务的启动方式和依赖关系,.path 描述了基于路径的给定服务的激活,等等。

让我们创建一个简单的 systemd 服务,其目的是监控一个给定的文件是否被修改。一个例子是监控一些配置:我们不想限制文件更新的权限,但我们仍然想知道是否有人更改了它。

首先,让我们通过一个简单的文本编辑器创建一些虚拟文件。让我们想象它是一个真实的配置。打印出来如下所示:

$ cat /etc/test_config/config
test test

让我们准备一个脚本,描述当文件更改时需要执行的程序。再次强调,仅为了这个示例,让我们通过一个简单的文本编辑器创建它——它看起来像这样:

$ cat ~/sniff_printer.sh
echo "File /etc/test_config/config changed!"

当脚本被调用时,将会有一个消息表明文件已更改。当然,你可以在这里放置任何程序。让我们称它为 sniff_printer,因为我们通过服务嗅探文件更改,并将打印一些数据。

那么,这是怎么发生的呢?首先,我们通过所需的 unit 定义我们的新服务——myservice_test.service——实现以下脚本:

[Unit]
Description=This service is triggered through a file change
[Service]
Type=oneshot
ExecStart=bash /home/oem/sniff_printer.sh
[Install]
WantedBy=multi-user.target

第二,我们描述我们通过另一个名为 myservice_test.pathunit 监控的文件路径,该 unit 通过以下代码实现:

[Unit]
Description=Path unit for watching for changes in "config"
[Path]
PathModified=/etc/test_config/config
Unit=myservice_test.service
[Install]
WantedBy=multi-user.target

将所有这些部分组合在一起,我们得到一个会打印简单消息的服务。它将在提供的文件更新时被触发。让我们看看效果如何。当我们向服务目录添加新文件时,我们必须执行一个重新加载:

$ systemctl daemon-reload

现在,让我们启用并启动服务:

$ systemctl enable myservice_test
$ systemctl start myservice_test

我们需要通过一些文本编辑器,如以下内容,更新文件:

$ vim /etc/test_config/config

为了看到我们触发的影响,我们必须查看服务状态:

$ systemctl status myservice_test
● myservice_test.service - This service is for printing the "config".
Loaded: loaded (/etc/systemd/system/myservice_test.service; enabled; vendor preset: enabled)
Active: inactive (dead) since Thu 2023-04-06 15:37:12 EEST; 31s ago
Process: 5340 ExecStart=/bin/bash /home/oem/sniff_printer.sh (code=exited, status=0/SUCCESS)
Main PID: 5340 (code=exited, status=0/SUCCESS)
Apr 06 15:37:12 oem-virtual-machine systemd[1]: Starting This service is for printing the "config"....
Apr 06 15:37:12 oem-virtual-machine bash[5340]: File /etc/test_config/config changed!
Apr 06 15:37:12 oem-virtual-machine systemd[1]: myservice_test.service: Succeeded.
Apr 06 15:37:12 oem-virtual-machine systemd[1]: Finished This service is for printing the "config"..

你可以通过我们的消息存在来验证服务已被触发:

Apr 06 15:37:12 oem-virtual-machine bash[5340]: File /etc/test_config/config changed!

我们还看到了执行过的代码及其成功状态:

Process: 5340 ExecStart=/bin/bash /home/oem/sniff_printer.sh (code=exited, status=0/SUCCESS)
Main PID: 5340 (code=exited, status=0/SUCCESS)

但由于服务 unit 的类型是 oneshot,因此进程不再活跃,只有另一个文件更新才能重新触发它。我们相信这个例子提供了一个简单的解释,说明如何在系统运行时创建和启动守护程序。请随意实验,尝试不同的 unit 类型或选项。

进程守护程序和启动程序是系统管理、编程、监控和获取执行流程信息的一个大型专业领域。这些主题以及下一节的内容都值得有自己的一本书。

可移植操作系统接口 (POSIX)

POSIX 标准的主要任务是维护不同操作系统之间的兼容性。因此,POSIX 经常被标准应用程序软件开发者和系统程序员使用。如今,它不仅可以在类 Unix 操作系统上找到,还可以在 Windows 环境中找到——例如,CygwinMinGWWindows Subsystem for LinuxWSL)。POSIX 定义了系统级和用户级 API,有一点需要注意:使用 POSIX,程序员不需要区分系统调用和库函数。

POSIX API 经常在 C 编程语言中使用。因此,它可以与 C++编译。在系统编程的一些重要领域,为系统调用接口提供了额外的功能:文件操作内存管理进程和线程控制网络和通信以及正则表达式——正如你所见,它几乎涵盖了现有系统调用所做的一切。只是不要混淆,认为这总是这种情况。

与每个标准一样,POSIX 也有多个版本,你必须了解你系统中存在的是哪一个。它也可能是某些环境子系统的一部分,例如 Windows 的Microsoft POSIX 子系统。这是一个关键点,因为环境本身可能不会向你完全暴露整个接口。一个可能的原因是系统的安全评估。

随着 POSIX 的发展,代码质量规则已经建立。其中一些与多线程内存访问同步机制并发执行安全访问限制以及类型安全有关。POSIX 软件要求中的一个著名概念是一次编写,到处采用

该标准定义并针对其应用的四个主要领域,称为卷:

  • 基本定义:规范的主要定义:语法、概念、术语和服务操作

  • 系统接口:接口描述和定义的可用性

  • 实用工具:Shell、命令和实用工具描述

  • 理由:版本信息和历史数据

如此一来,在这本书中,我们的主要关注点是 POSIX 作为一种不同的系统调用方法。在接下来的章节中,我们将看到使用对象如消息队列、信号量、共享内存或线程的一般模式的益处。一个显著改进是函数调用及其命名约定的简单性。例如,shm_open()mq_open()sem_open()分别用于创建和打开共享内存对象、消息队列和信号量。它们的相似性显而易见。POSIX 中的类似思想受到系统程序员的欢迎。API 也是公开的,并且有大量的社区贡献。此外,POSIX 还为诸如互斥锁这样的对象提供了接口,这在 Unix 上并不容易找到和使用。然而,在后面的章节中,我们将建议读者更多地关注 C++20 的特性,这是有充分理由的,所以请耐心等待。

使用 POSIX 允许软件工程师将他们的操作系统相关代码进行泛化,并声明为非操作系统特定。这使得软件的重新集成更加容易和快速,从而缩短了上市时间。系统程序员也可以在编写相同类型代码的同时轻松地在不同的系统之间切换。

摘要

在本章中,我们介绍了与操作系统相关的基本概念的定义。你学习了 Linux 的主要内核结构和其对软件设计的期望。实时操作系统被简要介绍,我们还涵盖了系统调用、系统调用接口以及 POSIX 的定义。我们还为多进程和多线程奠定了基础。在下一章中,我们将讨论进程作为主要资源的使用者和管理者。我们将从一些 C++20 代码开始。通过这种方式,你将了解 Linux 的进程内存布局、操作系统的进程调度机制以及多进程的运作方式及其带来的挑战。你还将了解一些关于原子操作的有趣事实。

第二章:深入了解进程管理

你在上一章中已经熟悉了进程的概念。现在,是时候深入了解细节了。了解进程管理如何与系统的整体行为相关联是很重要的。在本章中,我们将强调用于进程控制和资源访问管理的基本操作系统机制。我们将利用这个机会向你展示如何使用一些 C++ 功能。

一旦我们调查了程序及其相应的进程作为系统实体,我们将讨论一个进程在其生命周期中经历的状态。你将学习如何创建新的进程和线程。你还将看到这些活动背后的问题。稍后,我们将逐步引入多线程代码的示例。通过这样做,你将有机会学习一些与异步执行相关的 POSIX 和 C++ 技术的基础。

无论你的 C++ 经验如何,本章都将帮助你了解你可能在系统级别陷入的一些陷阱。你可以使用你对各种语言特性的了解来增强你的执行控制和进程可预测性。

本章将涵盖以下主要主题:

  • 调查进程的本质

  • 继续探讨进程状态和一些调度机制

  • 深入了解进程创建

  • 介绍 C++ 中线程操作的系统调用

技术要求

要运行本章中的代码示例,你必须准备以下内容:

拆解进程创建

正如我们在上一章中提到的,进程是程序的运行实例,包含其相应的元数据、占用的内存、打开的文件等。它是操作系统中的主要作业执行者。回想一下,编程的整体目标是把一种类型的数据转换成另一种类型的数据,或者进行计数。我们通过编程语言所做的就是向硬件提供指令。通常,我们 告诉 CPU 要做什么,包括在不同内存部分移动数据片段。换句话说,计算机必须 计算,我们必须告诉它如何做。这种理解至关重要,并且与所使用的编程语言或操作系统无关。

通过这样,我们回到了系统编程和了解系统行为的话题。让我们立即声明,进程创建和执行既不简单也不快。进程切换也是如此。这通常无法用肉眼观察到,但如果你必须设计一个高度可扩展的系统或对系统执行期间的事件有严格的截止日期,那么你迟早会进行进程交互分析。再次,这就是计算机的工作方式,当你进入资源优化时,这种知识是有用的。

谈到资源,让我们再次提醒自己,我们的进程最初只是一个程序。它通常存储在非易失性存储器NVM)中。根据系统不同,这可能是硬盘驱动器、SSD、ROM、EEPROM、Flash 等等。我们提到这些设备是因为它们具有不同的物理特性,例如速度、存储空间、写入访问和碎片化。这些因素中的每一个都是系统耐用性的重要因素,但在这个章节中,我们主要关心的是速度。

再次,正如我们在上一章中提到的,程序,就像所有其他操作系统资源一样,是一个文件。C++程序是一个可执行目标文件,其中包含必须提供给 CPU 的代码——例如,指令。这个文件是编译的结果。编译器是另一个程序,它将 C++代码转换为机器指令。了解我们的系统支持哪些指令至关重要。操作系统和编译器是集成标准、库、语言特性等的先决条件,并且有很大可能性,编译后的目标文件无法在另一个与我们不完全匹配的系统上运行。此外,相同的代码,在另一个系统或通过另一个编译器编译,很可能会生成不同大小的可执行目标文件。文件越大,从NVM加载程序到主存储器随机存取存储器RAM)是最常用的)所需的时间就越长。为了分析我们代码的速度并尽可能地为给定系统优化它,我们将查看一个关于我们的数据或指令沿着完整路径流动的通用图。这稍微有些离题,所以请耐心等待:

图 2.1 – 加载程序及其指令执行序列

图 2.1 – 加载程序及其指令执行序列

这里提供了一个通用的 CPU 概述,因为不同的架构会有不同的布局。L1 和 L2 缓存是静态 RAMSRAM)元素,这使得它们非常快,但也很昂贵。因此,我们必须保持它们体积小。我们还保持它们体积小以实现小的 CPU 延迟。L2 缓存具有更大的容量,以便在算术逻辑单元ALUs)之间创建共享空间——一个常见的例子是在单个核心中的两个硬件线程,其中 L2 缓存扮演共享内存的角色。L3 缓存并不总是存在,但它通常基于动态 RAMDRAM)元素。它比 L1 和 L2 缓存慢,但允许 CPU 有一个额外的缓存级别,仅用于加速目的。一个例子就是指导 CPU 猜测并从 RAM 中预取数据,从而节省在 RAM 到 CPU 加载中的时间。现代 C++特性可以大量使用这种机制,从而在进程执行中实现显著的加速。

此外,根据它们的作用,识别出三种类型的缓存:指令缓存数据缓存转换后备缓冲器TLB)。前两种是显而易见的,而TLB与 CPU 缓存没有直接关系——它是一个独立的单元。它用于数据和指令的地址,但其作用是加速虚拟到物理地址的转换,我们将在本章后面讨论这一点。

RAM 经常被使用,主要涉及双数据速率同步动态 RAMDDR SDRAM)内存电路。这是一个非常重要的点,因为不同的 DDR 总线配置有不同的速度。而且不管速度如何,它仍然不如 CPU 内部传输快。即使 CPU 满载,DDR 也很少被完全利用,因此成为我们的第一个重大瓶颈。正如在第一章中提到的,NVM 比 DDR 慢得多,这是它的第二个重大瓶颈。我们鼓励您分析您的系统并查看速度差异。

重要提示

您的程序大小很重要。优化执行程序指令或加载数据的事件序列的过程是一个永久和持续的平衡行为。在考虑代码优化之前,您必须了解您的系统硬件和操作系统!

如果您仍然不确信,那么考虑以下情况:如果我们有一个程序用于在某个屏幕上可视化一些数据,对于桌面 PC 用户来说,如果 1 秒后或 10 秒后出现,可能不是问题。但如果这是一个飞机上的飞行员,那么在严格的时间窗口内显示数据是一个安全合规特性。我们程序的大小也很重要。我们相信接下来的几节将为您提供分析您环境所需的工具。那么,我们的程序在执行过程中会发生什么?让我们来看看。

内存段

内存段也被称为内存布局内存部分。这些只是内存区域,不应与分段内存架构混淆。一些专家在讨论编译时操作时更喜欢使用部分,而在运行时使用布局。选择你喜欢的,只要它能描述同一件事。主要段包括文本(或代码)、数据BSS,其中BSS代表由符号开始的块由符号开始的块。让我们更详细地看看:

  • 也有const变量。

  • 数据:这个段也是在编译时创建的,由初始化的全局、静态或全局和静态数据组成。它用于初步分配的存储,当你不希望依赖于运行时分配时使用。

  • 0,理论上按照语言标准,但实际上在进程启动时由操作系统的程序加载器设置为0

  • :程序栈是一个表示运行程序例程的内存段——它保存它们的局部变量,并在被调用的函数返回时跟踪继续的位置。它是在运行时构建的,并遵循后进先出LIFO)策略。我们希望保持它小而快。

  • :这是另一个在运行时创建的段,用于动态内存分配。对于许多嵌入式系统来说,它被认为是禁止的,但我们将在此书稍后进一步探讨它。有许多有趣的教训可以学习,而且有时很难避免它。

图 2.1中,你可以观察到两个正在运行相同可执行文件并正在运行时加载到主内存中的进程。我们可以看到,对于 Linux,文本段只复制一次,因为它应该对两个进程都是相同的。缺失,因为我们现在不关注它。正如你所见,不是无限的。当然,其大小取决于许多因素,但我们猜测你已经在实践中看到过几次栈溢出消息。这是一个不愉快的运行时事件,因为程序流程被不优雅地破坏,并且有可能在系统级别引起问题:

图 2.2 – 两个进程的内存段

图 2.2 – 两个进程的内存段

图 2.2中,主内存的顶部代表虚拟地址空间,其中操作系统使用一个称为页表的数据结构来将进程的内存布局映射到物理内存地址。这是一种重要的技术,可以概括操作系统管理内存资源的方式。这样,我们就不必考虑设备的特定特性或接口。在抽象层面上,它很像我们在第一章中访问文件的方式。我们将在本章稍后回到这个讨论。

让我们使用以下代码示例进行分析:

void test_func(){}
int main(){
     test_func(); return 0;
}

这是一个非常简单的程序,其中函数在入口点之后立即被调用。这里没有什么特别之处。让我们为 C++20 编译它,不进行任何优化:

$ g++ mem_layout_example.cpp -std=c++2a -O0 -o test

生成的二进制对象称为 test。让我们通过 size 命令分析它:

$ size test
 text       data        bss        dec        hex    filename
 2040        640          8       2688        a80    test

总大小为 2,688 字节,其中 2,040 字节是指令,640 字节是 数据,8 字节用于 BSS。正如你所见,我们没有全局或静态数据,但仍然有 648 字节被占用。记住,编译器仍在工作,所以那里有一些分配的符号,当需要时我们可以进一步分析:

$ readelf -s test

现在,让我们专注于其他事情并按照以下方式编辑代码:

void test_func(){
    static uint32_t test_var;
}

一个未初始化的静态变量必须导致 BSS 增长:

$ size test
text       data        bss        dec        hex    filename
2040        640         16       2696        a88    test

因此,BSS 变得更大——不是 4 字节,而是 8 字节。让我们检查一下新变量的大小:

$ nm -S test | grep test_var
0000000000004018 0000000000000004 b _ZZ9test_funcvE8test_var

一切正常——无符号 32 位整数是 4 字节,正如预期的那样,但编译器在那里放了一些额外的符号。我们还可以看到它在符号前面的 b。现在,让我们再次更改代码:

void test_func(){
    static uint32_t test_var = 10;}

我们已经初始化了变量。现在,我们期望它在 数据 段中:

$ size test
text       data        bss        dec        hex    filename
2040        644          4       2688        a80    test
$ nm -S test | grep test_var
0000000000004010 0000000000000004 d _ZZ9test_funcvE8test_var

如预期的那样,符号前面的 d)。你还可以看到编译器已经将 2688 字节缩小了。

让我们进行最后的更改:

void test_func(){
    const static uint32_t test_var = 10;}

由于 const 在程序执行期间不能更改,它必须被标记为只读。为此,它可以放入 文本 段。请注意,这取决于系统实现。让我们检查一下:

$ size test
 text       data        bss        dec        hex    filename
 2044        640          8       2692        a84    test
$ nm -S test | grep test_var
0000000000002004 0000000000000004 r _ZZ9test_funcvE8test_var

正确!我们可以看到符号前面的字母 r,以及 2044 而不是之前提到的 2040。编译器从定义中生成一个 8 字节的 static 看起来相当有趣?我们鼓励你尝试一下。

到目前为止,你可能已经建立了联系,即编译时较大的部分通常意味着可执行文件更大。而可执行文件更大意味着程序启动所需的时间更长,因为从 NVM 到主内存的数据复制速度明显慢于从主内存到 CPU 缓存的复制速度。我们将在讨论上下文切换时回到这个话题。如果我们想保持启动速度快,那么我们应该考虑较小的编译时部分,但较大的运行时部分。这是一个通常由软件架构师或对系统有良好概述和知识的人进行的权衡。必须考虑的先决条件包括 NVM 的读写速度、DDR 配置、系统启动、正常工作和关闭时的 CPU 和 RAM 负载、活动进程数量等。

我们将在本书的后面重新讨论这个话题。现在,让我们专注于新进程创建时内存段的意义。它们的意义将在本章后面讨论。

继续讨论进程状态和一些调度机制

在上一节中,我们讨论了如何启动一个新的进程。但在底层发生了什么?如第一章中所述,在 Linux 的调度器中,进程和线程被视为任务。它们的状态是通用的,对它们的理解对于正确的程序规划非常重要。当任务期望一个资源时,可能需要等待甚至停止。我们也可以通过同步机制来影响这种行为,例如信号量和互斥锁,我们将在本章后面讨论。我们相信,理解这些基础知识对于系统程序员来说至关重要,因为不良的任务状态管理可能导致不可预测性和整体系统退化。这在大型系统中表现得尤为明显。

现在,让我们暂时放慢脚步,尝试简化代码的目标——它需要指导 CPU 执行操作并修改数据。我们的任务是思考正确的指令应该是什么,这样我们就可以在重新调度或通过阻塞资源什么都不做时节省时间。让我们看看我们的进程可能处于的状态:

图 2.3 – Linux 任务状态及其依赖关系

图 2.3 – Linux 任务状态及其依赖关系

前面的图中的状态是详细的,但 Linux 以四个一般字母表示法向用户展示它们:

  • 执行(R – 运行和可运行):处理器(核心或线程)为进程的指令提供服务——任务正在运行。调度算法可能迫使它执行。然后,任务变为可运行状态,并被添加到可运行队列中,等待轮到它们。这两种状态都是不同的,但都被表示为“正在执行”的进程。

  • 睡眠(D – 不可中断和 S – 可中断):还记得上一章中关于文件读写操作的例子吗?那是一种由等待外部资源引起的不可中断睡眠形式。在资源可用且进程可以再次执行之前,睡眠状态不能通过信号中断。可中断睡眠不仅依赖于资源可用性,还允许进程通过信号进行控制。

  • 停止(T):你有没有使用 Ctrl + Z 来停止一个进程?这是将进程置于停止状态的信号,但根据信号请求,它可能被忽略,进程将继续。或者,进程可以被停止,直到它被信号通知继续。我们将在本书后面讨论信号。

  • 僵尸(Z):我们在第一章中看到了这种状态——进程已终止,但在操作系统的任务向量中仍然可见。

使用 top 命令,你将在进程信息列的顶部行看到字母 S

$ top
. . .
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND

它将显示每个进程状态的字母表示。另一个选项是ps命令,其中STAT列将给出当前状态:

$ ps a
PID TTY STAT TIME COMMAND

通过这样,我们知道了任务最终处于哪些状态,但不知道它们是如何以及为什么在这些状态之间切换的。我们将在下一节继续这个讨论。

调度机制

现代 Linux 发行版提供了许多调度机制。它们唯一的目的就是帮助操作系统以优化的方式决定下一个必须执行的任务。是优先级最高的任务,还是完成最快的任务,或者两者兼而有之?还有其他标准,所以不要陷入一个错误的假设,认为一个可以解决你所有的问题。当系统中R状态下的进程数量多于可用处理器时,调度算法尤为重要。为了管理这项任务,操作系统有一个调度器——每个操作系统都以某种形式实现的根本模块。它通常是一个独立的内核进程,充当负载均衡器,这意味着它使计算机资源保持忙碌,并为多个用户提供服务。它可以配置为追求低延迟、公平执行、最大吞吐量或最短等待时间。在实时操作系统中,它必须保证满足截止日期。这些因素显然是相互冲突的,调度器必须通过适当的折衷来解决这个问题。系统程序员可以根据用户的需求配置系统的偏好。但这是如何发生的呢?

高级调度

我们请求操作系统启动一个程序。首先,我们必须从 NVM 中加载它。这个调度级别考虑的是程序加载器的执行。程序的地址由操作系统提供。文本数据段被加载到主内存中。大多数现代操作系统都会按需加载程序。这使进程启动更快,并且意味着在给定时刻只提供所需的代码。BSS数据也在那里分配和初始化。然后,虚拟地址空间被映射。携带指令的新进程被创建,并初始化所需的字段,如进程 ID、用户 ID、组 ID 等。程序计数器被设置为程序的入口点,控制权传递给加载的代码。由于 NVM 的硬件限制,这种开销在进程的生命周期中相当重要。让我们看看程序达到 RAM 后会发生什么。

低级调度

这是一系列尝试提供最佳任务执行顺序的技术。尽管我们在这本书中很少提到“调度”这个词,但请确保我们做的每一个操作都会导致任务状态切换,这意味着我们促使调度器行动。这种动作被称为上下文切换。切换也需要时间,因为调度算法可能需要重新排序任务队列,并且新启动的任务指令必须从 RAM 复制到 CPU 缓存。

重要提示

多个运行的任务,无论是并行还是非并行,可能会导致在重新调度上花费时间而不是在程序执行上。这是另一种平衡行为,这取决于系统程序员的 设计。

这里是一个基本的概述:

图 2.4 – 准备/阻塞任务队列

图 2.4 – 准备/阻塞任务队列

算法必须从队列中选择一个任务并将其放置以执行。在系统级别,基本层次结构如下(从最高优先级到最低):调度器 -> 块设备 -> 文件管理 -> 字符设备 -> 用户进程。

根据队列的数据结构实现和调度器的配置,我们可以执行不同的算法。以下是一些例子:

  • 先来先服务(First-come-first-serve,FCFS):如今,这种方法很少使用,因为较长的任务可能会阻碍系统的性能,而重要的进程可能永远不会被执行。

  • 最短作业优先(Shortest job first,SJF):这比 FCFS 提供了更短的等待时间,但较长的任务可能永远不会被调用。它缺乏可预测性。

  • 最高优先级先执行(Highest priority first,HPF):在这里,任务有优先级,最高优先级的任务将被执行。但谁设置优先级值,谁决定新到达的进程是否会导致重新调度?Kleinrock 规则就是这样一种纪律,其中优先级线性增加,而任务保持在队列中。根据运行-保持比率,不同的顺序被执行——FCFS、Last-CFS、SJF 等等。关于这个问题的有趣文章可以在这里找到:dl.acm.org/doi/10.1145/322261.322266

  • 轮询(Round-robin):这是一个无资源饥饿和抢占式算法,每个任务在相等的时间量子中获得。任务按环形顺序执行。每个任务都获得一个 CPU 时间槽,等于时间量子。当时间量子到期时,任务被推回到队列的末尾。正如你可能推断的那样,队列的长度和时间量子(通常在 10 到 300 毫秒之间)非常重要。为了保持公平性,现代操作系统调度器中还会采用一些额外的技术来丰富这个算法。

  • 完全公平调度(Completely fair scheduling,CFS):这是当前的 Linux 调度机制。它根据系统的状态应用上述算法的组合:

    $ chrt -m
    SCHED_OTHER   the standard round-robin time-sharing policy
    SCHED_BATCH   for "batch" style execution of processes
    SCHED_IDLE    for running very low priority background jobs.
    SCHED_FIFO    a first-in, first-out policy
    SCHED_RR      a round-robin policy
    

这种方法很复杂,值得单独一本书来讨论。

我们关心的是以下内容:

  • 优先级:其值是实际任务优先级,用于调度。介于 0 到 99 之间的值专用于实时进程,而介于 100 到 139 之间的值用于用户进程。

  • Nice:其值在用户空间级别上有意义,并在运行时调整进程的优先级。root 用户可以从 -20 到 +19 设置它,而普通用户可以从 0 到 +19 设置它,其中较高的 nice 值意味着较低的优先级。默认值为 0。

它们的依赖关系是,对于用户进程,优先级 = nice + 20,对于实时进程,优先级 = -1 – real_time_priority。优先级值越高,调度优先级越低。我们无法更改进程的基本优先级,但我们可以以不同的 ps 和新的优先级启动它:

$ nice -5 ps

在这里,-5 表示 5。将其改为 5 需要使用 sudo 权限:

$ sudo nice -5 ps

使用 renice 命令和 pid 可以在进程运行时更改进程的优先级:

$ sudo renice -n -10 -p 9610

这将设置 nice 值为 -10

要启动实时进程或设置和检索 pid 的实时属性,您必须使用 chrt 命令。例如,让我们用它来启动一个优先级为 99 的实时进程:

$ sudo chrt --rr 99 ./test

我们鼓励您查看其他算法,例如 反馈自适应分区调度APS)、最短剩余时间SRT)、最高响应比 下一个HRRN)。

调度算法的主题很广泛,不仅涉及操作系统任务的执行,还涉及其他领域,如网络数据管理。我们无法在这里全面介绍,但重要的是要说明如何最初处理它并了解您系统的优势。话虽如此,让我们继续通过查看进程管理来继续讨论。

了解更多关于进程创建的知识

在系统编程中,遵循严格的进程创建和执行时间表是一种常见做法。程序员使用守护进程,如 systemd 和其他内部开发的解决方案,或者启动脚本。我们也可以使用终端,但这主要是当我们修复系统状态并恢复它,或者测试某个特定功能时。从我们的代码中启动进程的另一种方式是通过系统调用。您可能知道其中的一些,例如 fork()vfork()

介绍 fork()

让我们来看一个例子;我们稍后会讨论它:

#include <iostream>
#include <unistd.h>
using namespace std;
void process_creator() {
    if (fork() == 0) // {1}
        cout << "Child with pid: " << getpid() << endl;
    else
        cout << "Parent with pid: " << getpid() << endl;
}
int main() {
    process_creator();
    return 0;
}

是的,我们知道您可能之前已经见过类似的例子,并且很清楚应该输出什么 – 通过 fork() 1 启动了一个新的进程,并且打印出了两个 pid 值:

Parent with pid: 92745
Child with pid: 92746

Parent 中,fork() 将返回新创建进程的 ID;这样,父进程就能知道其子进程。在 Child 中,将返回 0。这种机制对于进程管理非常重要,因为 fork() 会创建调用进程的副本。理论上,编译时段(textdataBSS)在主内存中重新创建。新的 从程序的相同入口点开始展开,但在 fork 调用处分支。然后,父进程遵循一条逻辑路径,子进程遵循另一条。每个都使用自己的 dataBSSheap

你可能认为大型的编译时段和栈会因为重复而造成不必要的内存使用,尤其是在我们不做修改的情况下。你是对的!幸运的是,我们正在使用虚拟地址空间。这允许操作系统对内存有额外的管理和抽象。在前一节中,我们讨论了无限 fork() 的进程将导致所谓的 exec

exec 和 clone()

exec 函数调用实际上不是一个系统调用,而是一组具有 execXX(<args>) 模式的系统调用。每个都有其特定的作用,但最重要的是,它们通过其文件系统路径创建一个新的进程,称为 NULL。这段代码与之前的例子类似,但做了一些修改:

. . .
void process_creator() {
    if (execv("./test_fork", NULL) == -1) // {1}
        cout << "Process creation failed!" << endl;
    else
        cout << "Process called!" << endl;
}
. . .

结果如下:

Parent with pid: 12191
Child with pid: 12192

你可能已经注意到打印输出中缺少了一些内容。哪里去了 "Process called!" 消息?如果出了问题,比如可执行文件找不到,那么我们将观察到 "Process creation failed!"。但在这个例子中,我们知道它已经运行,因为有了父进程和子进程的输出。这个答案可以在本代码示例之前的段落中找到——内存段被 test_fork 中的段所替换。

exec 类似,clone() 是对真实 clone() 系统调用的包装函数。它创建一个新的进程,如 fork(),但允许你精确地管理新进程的实例化方式。一些例子包括虚拟地址空间共享、信号处理、文件描述符等。前面提到的 vfork()clone() 的一个特殊变体。我们鼓励你花些时间看看一些例子,尽管我们相信大多数时候,fork()execXX() 就足够了。

如你所见,我们为给定的例子选择了 execv() 函数 {1}。我们使用这个函数是为了简单,也因为它与 图 2**.5 有关。但在我们查看这个图之前,我们还可以使用其他函数:execl()execle()execip()execve()execvp()。遵循 execXX() 模式,我们需要遵守给定的要求:

  • e 要求函数使用指向系统环境变量的指针数组,这些变量传递给新创建的进程。

  • l 需要将命令行参数存储在一个临时数组中,并将它们传递给函数调用。这只是为了在处理数组大小时方便。

  • p 需要将路径的环境变量(在 Unix 中表示为 PATH)传递给新加载的进程。

  • v 在本书中之前被使用过——它要求将命令行参数提供给函数调用,但它们是以指针数组的形式传递的。在我们的例子中,我们将其设置为 NULL 以简化。

让我们看看现在这个样子:

int execl(const char* path, const char* arg, …)
int execlp(const char* file, const char* arg, …)
int execle(const char* path, const char* arg, …, char*
  const envp[])
int execv(const char* path, const char* argv[])
int execvp(const char* file, const char* argv[])
int execvpe(const char* file, const char* argv[], char
  *const envp[])

简而言之,当涉及到我们创建新进程的方式时,它们的实现是相同的。是否使用它们严格取决于您的需求和软件设计。在接下来的几章中,我们将多次回顾进程创建的主题,特别是当涉及到共享资源时,所以这不会是我们最后一次提到它。

让我们看看一个简单的例子:假设我们有一个通过命令行终端——&——启动的进程系统命令。这可以通过以下图表表示:

图 2.5 – 从 shell 执行命令

图 2.5 – 从 shell 执行命令

我们使用这张图来强调 Linux 中进程之间父-子关系的不可见系统调用。在后台,是 exec()。内核接管控制权并转到应用程序的入口点,在那里调用 main()。可执行文件完成其工作,当 main() 返回时,进程结束。结束例程是特定于实现的,但您可以通过 exit()_exit() 系统调用以受控的方式触发它。同时,shell 被置于等待状态。现在,我们将介绍如何终止一个进程。

终止进程

通常,exit() 被视为一个库函数,它是在 _exit() 之上实现的。它做一些额外的工作,例如缓冲区清理和关闭流。在 main() 中使用 return 可以被认为是调用 exit() 的等效操作。_exit() 将通过释放数据和栈段、销毁内核对象(共享内存、信号量等)、关闭文件以及通知父进程其状态变化(将触发 SIGCHLD 信号)来处理进程终止。它们的接口如下:

  • void _exit(int status)

  • void exit(int status)

人们普遍认为,当 status 值设置为 0 时,表示正常进程终止,而其他值表示由内部进程问题引起的终止。因此,在 stdlib.h 中定义了 EXIT_SUCCESSEXIT_FAILURE 符号。为了演示这一点,我们可以修改之前提到的 fork 示例,如下所示:

...
#include <stdlib.h>
...
    if (fork() == 0) {
        cout << "Child process id: " << getpid() << endl;
        exit(EXIT_SUCCESS); // {1}
    }
    else {
        cout << "Parent process id: " << getpid() << endl;
    }
...

因此,子进程将按预期继续进行,因为没有发生任何特别的事情,但我们使它能够更好地管理其终止策略。输出将与上一个例子相同。我们将在下一节中进一步丰富这一点,通过一个代码片段。

但在我们这样做之前,让我们注意,这两个函数通常都与一种受控的过程终止方式相关。abort()将以类似的方式导致进程终止,但会触发SIGABRT信号。正如下一章所讨论的,一些信号应该被处理而不是被忽略——这是一个优雅地处理进程退出例程的好例子。同时,父进程会做什么,它会不会受到子进程退出代码的影响?让我们看看。

阻塞调用进程

如您在图 2.5中可能已经注意到的,一个进程可能被设置为等待。使用wait()waitid()waitpid()系统调用将导致调用进程被阻塞,直到它收到一个信号或其子进程改变状态:它被终止、被信号停止或被信号恢复。我们使用wait()来指示系统释放与子进程相关的资源;否则,它将成为一个僵尸进程,如前一章所述。这三种方法几乎相同,但后两种符合 POSIX 标准,并提供了对监控子进程的更精确控制。这三个接口如下:

  • pid_t wait(int *status);

  • pid_t waitpid(pid_t pid, int *status, int options);

  • int waitid(idtype_t idtype, id_t id, siginfo_t * infop, int options);

status参数对前两个函数具有相同的作用。wait()可以表示为waitpid(-1, &status, 0),这意味着进程调用者必须等待任何终止的子进程并接收其状态。让我们直接通过waitpid()来看一个例子:

#include <sys/wait.h>
...
void process_creator() {
    pid_t pids[2] = {0};
    if ((pids[0] = fork()) == 0) {
        cout << "Child process id: " << getpid() << endl;
        exit(EXIT_SUCCESS); // {1}
    }
    if ((pids[1] = fork()) == 0) {
        cout << "Child process id: " << getpid() << endl;
        exit(EXIT_FAILURE); // {2}
    }
    int status = 0;
    waitpid(pids[0], &status, 0); // {3}
    if (WIFEXITED(status)) // {4}
        cout << "Child " << pids[0]
             << " terminated with: "
             << status << endl;
    waitpid(pids[1], &status, 0); // {5}
    if (WIFEXITED(status)) // {6}
        cout << "Child " << pids[1]
             << " terminated with: "
             << status << endl;
...

此执行的输出结果如下:

Child process id: 33987
Child process id: 33988
Child 33987 terminated with: 0
Child 33988 terminated with: 256

如您所见,我们正在创建两个子进程,并将其中一个设置为成功退出,另一个设置为失败([1]和[2])。我们将父进程设置为等待它们的退出状态([1]和[5])。当子进程退出时,父进程会通过信号得到通知,正如前面所描述的,并打印出退出状态([4]和[6])。

此外,idtypewaitid()系统调用使我们能够等待一个特定的进程,也可以等待一组进程。其状态参数提供了关于实际状态更新的详细信息。让我们再次修改这个例子:

...
void process_creator() {
...
    if ((pids[1] = fork()) == 0) {
        cout << "Child process id: " << getpid() << endl;
        abort(); // {1}
    }
    siginfo_t status = {0}; // {2}
    waitid(P_PID, pids[1], &status, WEXITED); // {3}
    if (WIFSIGNALED(status)) // {4}
        cout << "Child " << pids[1]
             << " aborted: "
             << "\nStatus update with SIGCHLD: "
             << status.si_signo
             << "\nTermination signal - SIGABRT: "
             << status.si_status
             << "\nTermination code - _exit(2): "
             << status.si_code << endl;
}...

输出如下:

Child process id: 48368
Child process id: 48369
Child 48369 aborted:
Status update with SIGCHLD: 20
Termination signal - SIGABRT: 6
Termination code - _exit(2): 2

我们将exit()改为abort()([1]),这导致子进程接收SIGABRT信号并以默认处理方式退出(这并不完全是我们之前建议的)。我们使用struct状态([2])来收集更有意义的状态变化信息。waitid()系统调用用于监控单个进程,并设置为等待其退出([3])。如果子进程发出退出信号,那么我们将打印出有意义的信息([4]),在我们的例子中,这证明了我们得到了SIGABRT(值为6),更新伴随着SIGCHLD(值为20),并且退出代码为2,正如文档中所述。

waitid()系统调用有多种选项,通过它,你可以实时监控你派生的进程。我们在这里不会深入探讨,但如果需要,你可以在手册页面上找到更多信息:linux.die.net/man/2/waitid

一个重要的注意事项是,根据我们之前讨论的 POSIX 和 Linux 的线程管理策略,默认情况下,一个线程将等待同一线程组中其他线程的子线程。话虽如此,我们将在下一节中探讨一些线程管理。

介绍 C++中线程操作的系统调用

如同在第一章中讨论的那样,我们使用线程来并行执行不同的程序。它们只存在于进程的作用域内,它们的创建开销比线程的大,所以我们认为它们是轻量级的,尽管它们有自己的栈和task_struct。它们几乎可以自给自足,除了它们依赖于父进程的存在。这个进程也被称为主线程。所有由它创建的其他线程都需要加入它才能启动。你可以在系统上同时创建成千上万的线程,但它们不会并行运行。你可以运行的并行任务数量为n,其中n是系统并发 ALU 的数量(偶尔,这些是硬件的并发线程)。其他的将根据操作系统的任务调度机制进行调度。让我们看看 POSIX 线程接口的最简单例子:

pthread_t new_thread;
pthread_create(&new_thread, <attributes>,
               <procedure to execute>,
               <procedure arguments>);
pthread_join(new_thread, NULL);

当然,我们还可以使用其他系统调用来进一步管理 POSIX 线程,例如退出线程、接收被调用过程的返回值、从主线程分离等。让我们看看 C++的线程实现:

std::thread new_thread(<procedure to execute>);
new.join();

这看起来更简单,但它提供了与 POSIX 线程相同的操作。为了与语言保持一致,我们建议您使用 C++线程对象。现在,让我们看看这些任务是如何执行的。由于我们将在第六章中介绍新添加的 C++20 jthreads功能,因此我们将在接下来的几节中提供一个系统编程概述。

线程的连接和分离

无论您是通过 POSIX 系统调用还是 C++ 加入线程,您都需要执行此操作来通过给定的线程执行例程并等待其终止。有一点需要注意——在 Linux 上,pthread_join() 的线程对象必须是可连接的,而 C++ 线程对象默认情况下不可连接。将线程分别连接是一个好习惯,因为同时连接它们会导致未定义的行为。它与 wait() 系统调用的工作方式相同,只是它涉及线程而不是进程。

同样,进程可以作为守护进程运行,线程也可以通过分离来成为守护进程——POSIX 的 pthread_detach() 或 C++ 中的 thread::detach()。我们将在下面的示例中看到这一点,但我们还将分析线程的可连接设置:

#include <iostream>
#include <chrono>
#include <thread>
using namespace std;
using namespace std::chrono;
void detached_routine() {
    cout << "Starting detached_routine thread.\n";
    this_thread::sleep_for(seconds(2));
    cout << "Exiting detached_routine thread.\n";
}
void joined_routine() {
    cout << "Starting joined_routine thread.\n";
    this_thread::sleep_for(seconds(2));
    cout << "Exiting joined_routine thread.\n";
}
void thread_creator() {
    cout << "Starting thread_creator.\n";
    thread t1(detached_routine);
    cout << "Before - Is the detached thread joinable: "
         << t1.joinable() << endl;
    t1.detach();
    cout << "After - Is the detached thread joinable: "
         << t1.joinable() << endl;
    thread t2(joined_routine);
    cout << "Before - Is the joined thread joinable: "
         << t2.joinable() << endl;
    t2.join();
    cout << "After - Is the joined thread joinable: "
         << t2.joinable() << endl;
    this_thread::sleep_for(chrono::seconds(1));
    cout << "Exiting thread_creator.\n";
}
int main() {
    thread_creator();
}

相应的输出如下:

Starting thread_creator.
Before - Is the detached thread joinable: 1
After - Is the detached thread joinable: 0
Before - Is the joined thread joinable: 1
Starting joined_routine thread.
Starting detached_routine thread.
Exiting joined_routine thread.
Exiting detached_routine thread.
After - Is the joined thread joinable: 0
Exiting thread_creator.

上述示例相当简单——我们创建了两个线程对象:一个是要从主线程句柄中分离出来(detached_routine()),而另一个(joined_thread())将在退出后连接到主线程。我们在创建时以及设置它们工作后检查它们的可连接状态。正如预期的那样,线程到达它们的例程后,它们就不再可连接,直到它们被终止。

线程终止

Linux (POSIX) 提供了两种从线程内部以受控方式结束线程例程的方法:pthread_cancel()pthread_exit()。正如您可能从它们的名字中猜到的,第二个会终止调用线程,并且预期总是成功。与 exit() 系统调用不同,在执行此操作期间,不会释放进程共享资源,例如信号量、文件描述符、互斥锁等,因此请确保在线程退出之前管理它们。取消线程是一种更灵活的方法来做这件事,但它最终会调用 pthread_exit()。由于线程取消请求是发送到线程对象的,因此它有机会执行取消清理并调用线程特定的数据析构函数。

由于 C++ 是系统调用接口的抽象,它使用线程对象的范围来管理其生命周期,并且做得很好。当然,背景中发生的事情是特定于实现、系统和编译器的。我们将在本书的后续章节中再次探讨这个主题,所以请利用这个机会熟悉这些接口。

摘要

在本章中,我们探讨了在进程或线程创建和操作期间发生的低级事件。我们讨论了进程的内存布局及其重要性。您还了解了一些关于操作系统任务调度方式的重要观点以及进程和线程状态更新期间在后台发生的事情。我们将在本书的后续章节中使用这些基础知识。下一章将涵盖文件系统管理,并将提供一些该领域的有趣 C++ 工具。

第三章:在文件系统中导航

在本章中,我们将回顾在第一章中简要讨论的文件概念。你将详细了解 Linux 中的文件系统FS)及其具体细节。我们不会深入到某些文件系统实现中,因为你会看到有很多,但我们将建立与之工作的基础。你将了解更多关于 Linux 的 FS 层次结构——它的分区、对象类型和一些常用操作。

你将熟悉string_views。在这里你将学习的一些操作将在第五章中再次回顾,届时我们将讨论错误处理。

最后但同样重要的是,你将亲身体验到被称为管道的基本进程间通信IPC)机制。我们还将讨论作为系统实体的信号及其对通信的影响。如果你对进程间的数据传输不熟悉,那么你应该从这里开始。如果你有经验,你可能会注意到代码可能更加复杂——例如,使用管道实现服务器-客户端应用程序。我们了解这一点,但我们相信这些示例是一个很好的起点——这种机制的额外可扩展性会产生不希望的影响。我们将在第七章中进一步讨论这一点。

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

  • 探索 Linux 的文件系统基础

  • 使用 C++执行文件系统操作

  • 通过匿名管道和命名管道进行 IPC

  • 简要观察信号处理

技术要求

为了运行代码示例,读者必须准备以下内容:

探索 Linux 的文件系统基础

我们在第一章中介绍了一些 Unix(和 Linux)文件系统的定义。让我们看看它们在系统编程的更大图景中真正意味着什么。你可能还记得 Linux 系统中有哪些类型的文件——常规文件、目录、特殊文件、链接、套接字和命名管道。我们将在本章中处理其中大部分,并了解它们各自的作用。关于 Unix 中的文件,包括 Linux,可以这样简单思考:

“在 UNIX 系统中,一切皆文件;如果某物不是文件,那么它就是一个进程。”

因此,所有不是进程的东西都有一个 API,这包括文件操作系统调用。让我们同意文件是数据逻辑组织的主要工具。那么,必然存在某种是文件组织的主要工具。好吧,这就是文件管理系统,或者简单地说是文件系统,发挥作用的地方。它负责物理介质上的文件布局——例如,open()write()等。

文件系统还允许用户暂时忘记硬件的细节,专注于数据操作,以及像有序目录一样使用文件系统。它帮助在用户界面或命令行界面上的文件结构和数据可视化,访问权限,以及资源的有效使用。当用户有机会专注于文件创建、删除、修改和共享时,文件系统更关注数据准确性、设备驱动程序错误处理、多用户访问等问题。这是一个重要的观点,因为我们将在本书后面的某些地方观察到一些错误状态——例如,在第五章,其中文件系统是创建异常情况的实体。它还影响任务调度,正如我们之前提到的。让我们看看 Linux 中的文件系统结构和其具体细节。

Linux 的文件系统(FS)

我们必须提到,存在许多种类的文件系统。每种文件系统都适合其特定的用途,正如用户体验所暗示的,存在多种偏好,并不是所有文件系统都同时存在。Linux 具有支持超过 100 种文件系统的能力。它们可以组合运行在单个系统上。这为用户提供了最佳操作的机会,并使他们能够从中受益。如果文件系统只需要组织文件结构,那么一个普通的文件系统就可以做到这一点——例如,ext2FAT。如果我们想要文件一致性和更少出错的操作,那么可以选择ext4ReiserFSXFS。对于在线数据存储,NFSCIFS可能很有用。对于大文件和大量小文件,也需要特定的管理,因此ZFSbtrfs很有用。最后但同样重要的是,还有一些文件系统不是基于物理存储,而是在procsysramtmp中代表实体。然而,在抽象层面上,文件操作似乎是一样的。因此,我们可以有一个统一的接口。这不仅允许系统程序员以相同的方式使用不同的文件系统,而且还允许操作系统的用户界面可视化同一文件树下的所有文件和目录。Linux 通过虚拟文件系统VFS)实现这一点。它也被称为虚拟文件系统切换器——一个位于内核中的层,为程序提供通用接口。在详细探讨之前,让我们从设计角度看看它是什么样的。

图 3.1 – Linux 内核中的 VFS 软件层

图 3.1 – Linux 内核中的 VFS 软件层

此外,VFS 是面向对象的。不幸的是,这不会对我们使用 C++代码有很大帮助。不过,它是一个 C 语言中面向对象编程的好例子,因为对象实际上是struct类型,包含文件数据和指向文件操作的函数指针。我们将在本章稍后讨论这些对象。现在让我们看看目录结构和标准化分区。

目录结构和分区

Linux 的目录结构在#!中得到了很好的展示。你可以通过执行此命令了解更多信息:

$ man magic

回到文件系统结构——它从root目录开始,用/表示。在系统引导序列的早期阶段,root文件系统被挂载到该目录上。其他所有文件系统都在操作系统启动期间或甚至在正常操作期间挂载。你可以按照以下方式检查自己的配置:

$ cat /etc/fstab
# /etc/fstab: static file system information.
...
# <file system> <mount point>   <type>  <options>       <dump>  <pass>
# / was on /dev/sda5 during installation
UUID=618800a5-57e8-43c1-9856-0a0a14ebf344 /               ext4    errors=remount-ro 0       1
# /boot/efi was on /dev/sda1 during installation
UUID=D388-FA76  /boot/efi       vfat    umask=0077      0       1
/swapfile                                 none            swap    sw              0       0

它提供了有关挂载点和相应文件系统类型的详细信息。在此文件之外,文件系统将以具有确切路径的单独目录的形式在系统中可见。每个目录都可以通过root目录访问。一个重要点是//root是不同的目录,因为前者是root目录,而后者是root 用户的主目录。其他一些重要的分区和目录如下:

  • /bin: 包括常用用户可执行文件。

  • /boot: 包括 Linux 系统启动文件、内核的静态部分和引导加载程序配置。

  • /dev: 包括对所有外围硬件的引用,这些硬件通过具有特殊文件类型'c''b'的文件表示,并提供对真实设备的访问。我们曾在第一章中提到过这些特殊文件类型。

  • /etc: 包括系统配置文件。

  • /home: 这是顶级目录,可供用户文件使用,所有用户都有各自的公共子目录。

  • /lib: 这包括启动系统所需的共享库文件。

  • /mnt: 外部文件系统的临时挂载点。它与/media结合得很好,其中媒体设备(如 USB 闪存驱动器)被挂载。

  • /opt: 这包括可选文件和第三方软件应用程序。

  • /proc: 这包含有关系统资源的信息。

  • /tmp: 这是一个由操作系统和多个程序用于临时存储的临时目录——重启后会进行清理。

  • /sbin: 这包括系统二进制文件,通常由系统管理员使用。

  • /usr: 这通常包括只读文件,但也有一些例外。它是用于程序、库和二进制文件、man文件和文档的。

  • /var: 这包括可变数据文件——通常是日志文件、数据库文件、存档电子邮件等。

让我们回到挂载点文件系统分区。由于很多人对这些不太熟悉,我们将借此机会简要解释它们。一个很好的理由是,正如已经提到的,系统程序员一次要处理许多文件系统(FSes),其中一些与网络驱动器或不同设备相关。

Linux 不像 Windows 那样给分区分配字母;因此,你很容易将一个单独的设备与一个简单的目录混淆。大多数时候,这不会成为大问题,但如果你关心资源管理、弹性和安全性,可能会成为问题。例如,车辆对硬件耐用性有严格的要求,这延伸到 10-15 年的可服务性。考虑到这一点,你必须了解设备的特性,尤其是如果你经常在其上写入或无意义地填满其整个空间。文件系统管理数据的方式对于及时耗尽外围设备的内存也非常关键,因此这个选择很重要。

fstab显示了文件系统挂载的位置,但它还描述了其他内容。首先,让我们记住文件系统分区的目的是将单个设备——例如硬盘驱动器——分成多个分区。这主要用于具有安全要求的嵌入式系统。然而,Linux 还提供了逻辑卷管理器LVM),它允许灵活的设置。换句话说,文件系统可以很容易地缩小或扩大,这在更大规模系统中更受欢迎。

创建多个文件系统不仅作为用户数据分组工具,还允许在某个分区因故障而损坏时保持其他分区完整。另一种用法是当设备的存储不可用时——通常,它只是充满了数据。整个系统可能会停止工作,因为它也依赖于存储空间。因此,最好只完全填充一个文件系统并引发错误。其他文件系统将保持完整,系统将继续工作。从这个角度来看,这是一个安全且稳健的解决方案。但请记住,它不能保护你免受整体设备故障的影响。因此,许多网络存储设备依赖于廉价磁盘冗余阵列RAID)。我们在这里不会处理它,但我们鼓励你阅读更多关于它的内容。

现在,你可能已经注意到了之前fstab输出中的一些额外数据。除了根分区外,我们实际上将分区类型分为数据交换分区:

  • 数据分区:这包括根分区,以及系统启动和正常运行所需的所有必要信息。它还包括 Linux 的标准数据。

  • fstab 中的 swap,它为系统提供了在内存溢出时将数据从主内存移动到 NVM 的选项。这仅对系统本身可见。这并不意味着你应该溢出你的 RAM,但只是为了保持额外的灵活性,以免影响系统的可用性。记住,NVM 比主存储芯片慢得多!

重要提示

系统管理员通常配置分区布局。有时,一个分区会跨越多个 NVM 设备。这种设计严格与系统的用途相关。一旦分区对您作为用户可用,您就只能添加更多。我们强烈建议您除非您非常清楚自己在做什么以及为什么这么做,否则不要更改它们的属性。

关于 df 命令,在我们的例子中,它是这样的:

$ df -h
Filesystem      Size  Used Avail Use% Mounted on
udev            5,9G     0  5,9G   0% /dev
tmpfs           1,2G  1,3M  1,2G   1% /run
/dev/sda5        39G   24G   14G  64% /
tmpfs           6,0G     0  6,0G   0% /dev/shm
tmpfs           5,0M  4,0K  5,0M   1% /run/lock
tmpfs           6,0G     0  6,0G   0% /sys/fs/cgroup
/dev/sda1       511M  4,0K  511M   1% /boot/efi
tmpfs           1,2G   16K  1,2G   1% /run/user/29999

很容易看出文件系统类型和挂载点之间的关系,例如,文件系统挂载在 列。我们不会对此进行更详细的说明,但我们鼓励您阅读更多关于 parted 工具的信息,该工具正是用于创建和编辑分区的。

Linux 文件系统对象

正如我们在上一节中提到的,文件系统是通过对象实现的,我们关注的主要有四种类型:

  • 超级块:这代表挂载的文件系统元数据——相应的设备、修改标志、相应的文件系统类型、文件系统访问权限、修改的文件等等。

  • open()create()lookup()mkdir()。常规文件、特殊文件、目录和 stat 命令:

    $ stat test
      File: test
      Size: 53248         Blocks: 104        IO Block: 4096   regular file
    Device: 805h/2053d    Inode: 696116      Links: 1
    Access: (0775/-rwxrwxr-x)  Uid: (29999/     oem)   Gid: (29999/     oem)
    ...
    

    现在,看看权限位——0775/-rwxrwxr-x。数字和符号标志具有相同的意义,但表示方式不同。- 表示标志未设置。r 表示文件可以被当前用户、组或所有人读取(从左到右读取)。w 表示 x 代表 p 在前面,它将此文件标记为 1。在操作过程中可能会看到的其他符号包括 d 代表 b 代表 c 代表 s 代表 套接字

  • 目录项(dentry):为了便于使用,我们不会像 inode 那样用数字来引用物理文件,而是使用名称和位置。因此,我们需要一个转换表,将符号名称(用于用户)映射到 inode 编号(用于内核)。最简单的方法是通过路径名来表示,如下所示:

    $ ls -li test
    696116 -rwxrwxr-x 1 oem oem 53248 Jul 30 08:29 test
    

    如您所见,inode 与上一个例子相同——696116,符号名称是 test

  • open()close() 时被销毁。这个对象包含的一些成员是,当为特定的文件系统实现调用 open() 方法时,文件被放置在调用进程的文件描述符表中。在用户空间中,文件描述符用于应用程序的文件操作。

下面的图表提供了通过多个进程访问单个文件的概述:

图 3.2 – 文件访问组织

图 3.2 – 文件访问组织

在这里我们可以看到一些有趣的事情。尽管进程打开的是同一个文件,但在到达真实数据之前,它们会经过不同的执行路径。首先,进程有自己的fork(),子进程获得相同的打开文件表。独立进程指向一个单独的表。然后,假设我们有两个指向同一文件的dentry,我们的文件对象指向它。这种情况发生在我们通过不同的路径名到达同一物理文件时。当我们处理同一个文件时,条目将指向单个 inode 和超级块实例。从那时起,确切地讲,文件所在的文件系统将接管其特定的功能。

虽然如此,但有一个免责声明——操作系统不是多个进程同时更新文件的仲裁者。它将按照我们在上一章中讨论的规则调度这些操作。如果您想为这样的操作制定特定的策略,那么这必须被明确设计和应用。尽管文件系统提供了文件锁定作为sudo rm -rf,但你可能会删除当前正在使用的文件。这可能导致不可逆转的系统问题。我们使用文件锁定来确保对文件内容的并发访问安全。它允许一次只有一个进程访问文件,从而避免可能的竞争条件,你将在第六章中了解到这一点。Linux 支持两种类型的文件锁定——建议性锁定和强制锁定,你可以在这里了解更多信息:www.kernel.org/doc/html/next/filesystems/locking.xhtml

重要提示

通过各自的 inode 进行物理文件唯一标识的数字不是无限的。VFS 可能包含如此多的微小文件,以至于它耗尽了创建新文件的能力,而 NVM 上仍有空闲空间。这种情况在高规模系统中比你想的更常见。

你可能也想知道如何通过不同的路径名访问相同的文件。嗯,你还记得我们在第一章中关于链接文件的讨论吗?我们谈到了硬链接符号链接。对于给定的文件,硬链接总是可用的——例如,当至少有一个与数据相关的硬链接时,相应的文件被认为是存在于文件系统中的。通过它,路径名直接与文件所在的 NVM 上的点相关联,并且可以从那里打开。指向设备上同一点的多个路径名会导致多个硬链接构造。让我们来看看。首先,我们将列出一些文件的详细信息:

$ ls -li some_data
695571 -rw-rw-r-- 1 oem 5 May 28 18:13 some_data

然后,我们将通过ln命令为同一文件创建一个硬链接,并列出这两个文件:

$ ln some_data some_data_hl
$ ls -li some_data some_data_hl
695571 -rw-rw-r-- 2 oem oem 5 May 28 18:13 some_data
695571 -rw-rw-r-- 2 oem oem 5 May 28 18:13 some_data_hl

如您所见,它们都具有相同的 inode,因为它们具有不同的字符名称,但它们是同一文件。文件的唯一真实表示是695571。这意味着它们真正指向硬盘上的同一块。然后,我们看到硬链接计数已从1增加到2(在访问权限和uid列之间)。

再次使用ln命令,但这次我们将添加-s选项。我们将列出迄今为止的所有文件:

$ ln -s some_data some_data_sl
$ ls -li some_data some_data_hl some_data_sl
695571 -rw-rw-r-- 2 oem oem 5 May 28 18:13 some_data
695571 -rw-rw-r-- 2 oem oem 5 May 28 18:13 some_data_hl
694653 lrwxrwxrwx 1 oem oem 9 May 28 18:16 some_data_sl -> some_data

你可以很容易地看到新文件 – some_data_sl – 与原始文件及其硬链接有不同的 inode。它指向 NVM 中的新位置,并有自己的访问权限。此外,它还直观地显示了它真正指向的路径名。即使存在指向符号链接的符号链接,ls -li也会显示符号链接设置的指向文件,如下所示:

696063 -rw-rw-r--  1 oem oem  4247 Jul  2 13:25 test.dat
696043 lrwxrwxrwx  1 oem oem     8 Aug  6 10:07 testdat_sl -> test.dat
696024 lrwxrwxrwx  1 oem oem    10 Aug  6 10:07 testdat_sl2 -> testdat_sl

然后查看字节大小 – 原始文件的大小仅为4247字节,而符号链接的大小为8字节,下一个为10。实际上,原始文件的大小对符号链接的大小没有影响,但其他因素确实如此 – 您可以通过计算引用文件路径名中的字符数来找出它。

所有的前缀文件名都将为您提供访问和修改文件的能力。它们还为您提供从多个访问点获取数据的灵活性,而无需重复使用和无效地使用额外的存储空间。许多系统程序员使用符号链接来重新排序文件系统,仅为了便于某些专用用户进程的数据管理。Linux 系统本身也这样做,只是为了重新排序文件系统层次结构,出于同样的原因。让我们通过以下图表创建这个示例的概述:

图 3.3 – 硬链接和符号链接概述

图 3.3 – 硬链接和符号链接概述

重要提示

即使原始文件被移动或删除,符号链接也会继续指向其路径名作为目标,而硬链接必须指向一个现有文件。符号链接可以在分区间工作,但硬链接不能在不同卷或文件系统上的路径之间建立链接。

在下一节中,我们将继续操作文件,但这次是通过 C++代码。

使用 C++执行文件系统操作

在 C++17 中,更接近系统编程的文件系统操作得到了简化。FS 库允许 C++开发者区分 Linux 的文件系统类型,并对其执行某些操作。让我们看看一个示例接口:

bool is_directory(const std::filesystem::path& p)

此方法检查给定的路径名是否是is_fifo()is_regular_file()is_socket()is_symlink()。你能告诉我为什么我们没有is_hardlink()方法吗?没错——如果两个具有不同字符名称的文件指向同一个 inode,那么它们都提供了对相同内容的访问。尽管我们可以通过hard_link_count()方法获取 inode 的硬链接计数器,但这并不重要。

由于 C++语言可以在多个操作系统上编译,FS 函数也依赖于那些特定系统的相应 FS。例如,FAT 不支持符号链接;因此,与之相关的函数将失败,错误处理留给系统程序员。你可以使用std::filesystem::filesystem_error异常对象来获取当前错误 FS 错误状态的相关细节。这类讨论可以在第五章中找到。

我们之前提到,并发文件访问必须由软件工程师管理,否则操作系统将根据其看法调度操作。这个库也是如此。不要期望它能够自行处理竞争条件或修改冲突。现在,让我们看看一些操作如何使用。不过有一个免责声明——正如之前提到的,错误条件将在稍后讨论,所以这里不会关注它们。

我们将在以下代码段中的标记 {1}处创建一个新的目录:

#include <iostream>
#include <filesystem>
using namespace std;
using namespace std::filesystem;
int main() {
    auto result = create_directory("test_dir"); // {1}
    if (result)
        cout << "Directory created successfully!\n";
    else
        cout << "Directory creation failed!\n";
    return 0;
}

现在,让我们看看 FS 上发生了什么:

$ ./create_dir
Directory created successfully!

如果你再次调用程序,它将失败,因为目录已经存在:

.$ /create_dir
Directory creation failed!

我们按照之前的示例(见图 3**.3)描述的方式填充新目录,但这次使用 C++代码(以下代码中的标记 {1}{2}):

...
int main() {
    if (exists("some_data")) {
       create_hard_link("some_data", "some_data_hl");// {1}
       create_symlink("some_data", "some_data_sl"); // {2}
    }
...

当然,从some_data所在的目录调用程序,或者相应地提供其路径名——通过some_data,所以它的大小是9字节。尽管如此,画面几乎相同——当然,inode 是不同的:

79105062 rw-rw-r-- 2 oem oem 9 May 29 16:33 some_data
79105062 rw-rw-r-- 2 oem oem 9 May 29 16:33 some_data_hl
79112163 lrwxrwxrwx 1 oem oem 9 May 29 17:04 some_data_sl  -> some_data

我们还手动创建了一个新的内部目录,称为inner_test_dir,并添加了一个新文件,称为inner_some_data。让我们迭代目录,既非递归(以下代码中的标记 {1}),也递归(以下代码中的标记 {2}),并打印出目录内容:

...
int main() {
    const path path_to_iterate{"test_dir"};
    for (auto const& dir_entry :
        directory_iterator{path_to_iterate}) { // {1}
        cout << dir_entry.path() << endl;
    }
    cout << endl;
    for (auto const& dir_entry :
        recursive_directory_iterator{path_to_iterate}) {
        cout << dir_entry.path() << endl; // {2}
    }
    return 0;
}

输出并不令人惊讶:

"test_dir/inner_test_dir"
"test_dir/some_data"
"test_dir/some_data_sl"
"test_dir/some_data_hl"
"test_dir/inner_test_dir"
"test_dir/inner_test_dir/inner_some_data"
"test_dir/some_data"
"test_dir/some_data_sl"
"test_dir/some_data_hl"

现在,我们想要检查一些文件是否是符号链接(以下代码中的标记 {1}),如果是,就打印出它们的目标:

...
int main() {
    const path path_to_iterate{"test_dir"};
    for (auto const& dir_entry :
        recursive_directory_iterator{path_to_iterate}) {
        auto result = is_symlink(dir_entry.path()); // {1}
        if (result) cout << read_symlink(dir_entry.path());
    }
}

再次,输出符合预期——目标是初始源文件:

$ ./sym_link_check
"some_data"

在我们继续进行其他修改之前,让我们尝试重命名符号链接文件(以下代码段中的标记 {1}),:

...
int main() {
    if (exists("some_data_sl")) {
        rename("some_data_sl", "some_data_sl_rndm"); // {1}
    }
...

我们看到重命名是成功的:

79112163 lrwxrwxrwx 1 oem oem 9 May 29 17:04 some_data_sl_rndm -> some_data

让我们删除初始文件——some_data(以下代码中的标记 {2}),并观察系统上的空闲空间变化(以下代码中的标记 {1}{3}):

...
int main() {
    if (exists("some_data")) {
        std::filesystem::space_info space_obj =
            space(current_path());// {1}
        cout << "Capacity: "
            << space_obj.capacity << endl;
        cout << "Free: "
            << space_obj.free << endl;
        cout << "Available: "
            << space_obj.available << endl;
        remove("some_data"); // {2}
        space_obj = space(current_path()); // {3}
        cout << "Capacity: "
            << space_obj.capacity << endl;
        cout << "Free: "
            << space_obj.free << endl;
        cout << "Available: "
            << space_obj.available << endl;
    }
...

下面是输出:

Capacity: 41678012416
Free: 16555171840
Available: 14689452032
Capacity: 41678012416
Free: 16555175936
Available: 14689456128

如你所见,已经释放了 4096 字节,尽管文件的大小只有 9 字节。这是因为我们实际使用的最小值是一个 NVM 块的大小——操作系统可以写入或从文件中读取的最小数据单元。在这种情况下,它是 4 KB。如果你对细节不感兴趣,但只想检查空间值是否已更新,那么在 C++ 20 中,你也有 == 操作符重载;因此,你可以直接比较两个 space_info 对象,这些对象实际上是 space() 返回的值(标记 {1}{3})。

我们使用这些代码示例快速浏览了 C++ 文件系统库。我们希望这对您来说是一个很好的概述,尽管我们从函数跳到了函数。它应该对您的工作有所帮助。下一节将处理一个非常重要的话题——多进程通信的基础。正如您从本章的开头就已经知道的那样,Linux 将一切不是进程的东西都视为文件。通信资源也是如此,我们将带着我们的 C++ 知识深入探讨。会有一些更多的理论,所以请继续关注我们!

通过匿名管道和命名管道进行 IPC

在我们开始研究这个主题之前,让我们问你这个问题。你曾经做过以下事情吗?

$ cat some_data | grep data
some data

如果是的话,那么你可能把 | 称为带有 | 符号的管道,和它们一样?不!那是一个匿名管道。系统程序员在所谓的 pipefs 之间进行区分,而用户执行标准的 VFS 系统调用。我们将使用管道作为例子来可视化一些关于文件系统的观察。那么,让我们开始吧!

匿名或未命名的管道

| 符号,你可以很容易地得出结论,这种实现更多地与短期通信相关,并且它在时间上不是持久的。匿名管道有两个端点——一个读端和一个写端。这两个端点都由一个文件描述符表示。一旦两个端点都关闭,管道就会被销毁,因为没有更多的方式可以通过打开的文件描述符来引用它。此外,这种类型的通信被称为单工 FIFO 通信——例如,它创建了一个单向数据传输——通常是从父进程到子进程。让我们看一个例子,它使用系统调用创建一个匿名管道和一个简单的数据传输:

#include <iostream>
#include <unistd.h>
#include <string.h>
using namespace std;
constexpr auto BUFF_LEN = 64;
constexpr auto pipeIn   = 0;
constexpr auto pipeOut  = 1;

我们需要一个整数数组来保存文件描述符,表示管道的 inout 端点——a_pipe。然后,这个数组被传递给 pipe() 系统调用,如果发生错误,它将返回 -1,如果成功,则返回 0(见标记 {1}):

int main() {
   int a_pipe[2]{};
   char buff[BUFF_LEN + 1]{};
   if (pipe(a_pipe) == -1) {  // {1}
       perror("Pipe creation failed");
       exit(EXIT_FAILURE);
   }
   else {
      if (int pid = fork(); pid == -1) {
         perror("Process creation failed");
         exit(EXIT_FAILURE);
      }
      else if (pid == 0) {
         // Child: will be the reader!
         sleep(1); // Just to give some extra time!
         close(a_pipe[pipeOut]); // {2}
         read(a_pipe[pipeIn], buff, BUFF_LEN); // {3}
         cout << "Child: " << buff << endl;
     }

我们通过 fork() 创建一个新的进程,就像我们在 第二章 中做的那样。了解这一点后,你能告诉我最后创建了多少个管道吗?没错——创建了一个管道,文件描述符在进程之间共享。

由于数据传输是单向的,我们需要为每个进程关闭未使用的端点 – 标记 {2}{4}。如果进程在其自己的管道中写入和读取文件描述符,它将只能获得之前写入那里的信息:

      else {
         // Parent: will be the writer!
         close(a_pipe[pipeIn]); // {4}
         const char *msg = {"Sending message to child!"};
         write(a_pipe[pipeOut], msg, strlen(msg) + 1);
         // {5}
      }
   }
   return 0;
}

换句话说,我们禁止孩子回嘴父母,而父母只能向孩子发送数据。数据通过将其写入文件并从中读取(见标记 {3}{5})来发送。这是一段非常简单的代码,通常,通过匿名管道的通信就是那么简单。然而,请注意 – write()read() 是阻塞调用;如果没有从管道中读取的内容(管道缓冲区为空),相应的进程读取器将被阻塞。如果管道容量耗尽(管道缓冲区已满),进程写入器将被阻塞。如果没有读取器来消耗数据,将触发 SIGPIPE。我们将在本章的最后部分提供一个这样的例子。在第六章中,我们将展示的方式不会有竞态条件风险,但数据创建和消费的同步仍然掌握在程序员手中。下面的图表提供了当我们使用匿名管道时发生的一些额外信息:

图 3.4 – 匿名管道通信机制

图 3.4 – 匿名管道通信机制

在后台,在内核级别,还有一些其他操作在进行:

图 3.5 – 匿名管道创建

图 3.5 – 匿名管道创建

可以使用 fcntl(fd, F_GETPIPE_SZ)F_SETPIPE_SZ 操作分别检查和设置管道的容量。您可以看到,管道默认有 16 个 页面页面是虚拟内存可以管理的最小数据单位。如果一个页面是 4,096 KB,那么在溢出之前它可以传输 65,536 字节的数据。我们将在本章后面讨论这个问题。然而,请记住,一些系统可能会有所不同,图 3**.5 中的信息可能对您是错误的。以类似的方式,我们可以在 read()write() 操作的较低级别表示发生的事情。

在下面的图中,使用文件系统作为共享(全局)内存的问题出现了。请注意,尽管文件系统通过互斥锁有自己的保护机制,但这在用户级别上并不能帮助我们正确同步数据。通过多个进程简单地修改常规文件将导致问题,如前所述。使用管道这样做会带来较少的问题,但我们仍然不处于安全状态。正如您所看到的,调度器参与了其中,我们可能会陷入不断等待的进程的死锁。与命名管道相比,使用匿名管道更容易避免这种情况。

图 3.6 – 管道读写操作

图 3.6 – 管道读写操作

现在我们已经建立了通信,为什么还需要像命名管道这样的额外文件类型呢?我们将在下一节中讨论这个问题。

命名管道

命名管道比匿名管道要复杂一些,因为它们有更多的可编程上下文。例如,它们有字符名称,并且可以在文件系统(FS)中被用户观察到。它们在进程完成与它们的工作后不会被销毁,而是在执行特定系统调用以删除文件时——unlink()——才会被销毁。因此,我们可以说它们提供了 持久性。与匿名管道类似,我们可以在以下 CLI 命令中演示命名管道,结果创建 fifo_example

$ ./test > fifo_example
$ cat fifo_example
$ Child: Sending message to child!

此外,通信是双向的——例如,数据传输可以双向工作。尽管如此,你的工作可能会推动你使用 C++ 代码封装系统调用。下一个示例提供了一个示例概述,免责声明是它是例示性的,并且随着 C++ 上下文被添加到代码中,程序的大小会变大。让我们从一个早期的 管道 示例中获取一个例子,我们可以用 C++ 代码对其进行修改,但行为保持不变:(注意:由于原文中未提供具体的代码示例,因此译文中的“Let’s get an example from the pipe from earlier, which we can modify with C++ code, but the behavior remains the same:”部分仅作为示例,实际翻译时需要根据具体代码内容进行。)

#include <sys/stat.h>
#include <unistd.h>
#include <array>
#include <iostream>
#include <filesystem>
#include <string_view>
using namespace std;
using namespace std::filesystem;
static string_view fifo_name     = "example_fifo"; // {1}
static constexpr size_t buf_size = 64;
void write(int out_fd,
           string_view message) { // {2}
    write(out_fd,
          message.data(),
          message.size());
}

在标记 {1} 处,我们引入了 string_view 对象。它代表一个指向字符串或数组的指针对及其相应的尺寸。由于它是一个 view-handle 类类型,我们最好以值的方式传递它(见标记 {2}),同时提供预期的子字符串操作接口。它始终是 const,因此你不需要将其声明为 const。所以,它是一个对象,它的大小更大,但它有一个好处,即无条件地安全——处理典型的 C 字符串错误情况,如 NULL-termination。任何问题都会在编译时得到处理。在我们的情况下,我们可以简单地将其用作 const char*const string 的替代品。让我们继续进行读取操作:

string read(int in_fd) { // {3}
    array <char, buf_size> buffer;
    size_t bytes = read(in_fd,
                        buffer.data(),
                        buffer.size());
    if (bytes > 0) {
        return {buffer.data(), bytes}; // {4}
    }
    return {};
}
int main() {
    if (!exists(fifo_name))
        mkfifo(fifo_name.data(), 0666); // {5}
    if (pid_t childId = fork(); childId == -1) {
        perror("Process creation failed");
        exit(EXIT_FAILURE);
    }

标记 {2}{3} 显示了 write()read() 的 C++ 封装。你可以看到,我们不是做 strlen()sizeof() 的杂技,而是分别使用 string_viewdata()arraysize(),因为它们通过相应的对象打包在一起。一个重要点是,我们使用 array<char, buf_size> 来具体指定缓冲区大小和类型。同样,我们可以使用 string 而不是 array,因为它定义为 basic_string<char>,并且我们可以使用 reserve(buf_size) 来限制其大小。选择实际上取决于你在函数后面的需求。在我们的情况下,我们将使用 array 作为从管道直接读取固定大小 char 缓冲区的直接表示。之后我们构建结果 string 或者让它为空(见标记 {4})。

现在,我们将使用已知的 exists() 函数来丢弃第二个 mkfifo() 调用,该调用由第二个到达的进程执行。然后,我们检查文件是否真正是 FIFO(见标记 {6}):

    else {
        if(is_fifo(fifo_name)) { // {6}
            if (childId == 0) {
                if (int named_pipe_fd =
                        open(fifo_name.data(), O_RDWR);
                    named_pipe_fd >= 0) { // {7}
                    string message;
                    message.reserve(buf_size);
                    sleep(1);
                    message = read(named_pipe_fd); // {8}
                    string_view response_msg
                        = "Child printed the message!";
                    cout << "Child: " << message << endl;
                    write(named_pipe_fd,
                          response_msg); // {9}
                    close(named_pipe_fd);
                }

现在,看看标记 {7}{10}。你看到我们打开管道、保留这个结果以及检查其值的地方了吗?正确——我们将这些操作打包在一起放在 if 语句中,从而将我们的作用域集中在同一个逻辑位置。然后,我们通过新添加的功能包装器(标记 {8}{12})从管道中读取。然后我们通过 write() 包装器(标记 {9}{11})向管道写入。请注意,在标记 {9} 时,我们向函数传递 string_view,而在标记 {11} 时,我们传递一个 string。这在两种情况下都有效,从而进一步证明了我们使用 string_views 而不是 const stringconst char * 等来处理此类接口的观点:

                else {
                    cout << "Child cannot open the pipe!"
                         << endl;
                }
            }
            else if (childId > 0) {
                if (int named_pipe_fd =
                        open(fifo_name.data(), O_RDWR);
                    named_pipe_fd >= 0) { // {10}
                    string message
                    = "Sending some message to the child!";
                    write(named_pipe_fd,
                          message); // {11}
                    sleep(1);
                    message = read(named_pipe_fd); // {12}
                    cout << "Parent: " << message << endl;
                    close(named_pipe_fd);
                }
            }
            else {
                cout << "Fork failed!";
      }

管道在标记 {13} 处被移除,但我们将保留它进行实验。例如,我们可以列出命名管道:

$ ls -la example_fifo
prw-r--r-- 1 oem oem 0 May 30 13:45 example_fifo

请注意,其大小为 0。这意味着写入的内容已被全部消耗。在 close() 时,内核将刷新文件描述符,并在主内存中销毁 FIFO 对象,就像匿名管道那样。有时可能会发生 reader 没有完全消耗数据的情况。如您所记得,它可以存储 16 页的数据。这就是我们鼓励您使用 read()write() 函数返回的字节数来决定是否需要终止进程的原因。现在,看看权限位——你注意到什么有趣的地方了吗?是的——它们前面有一个额外的 p,这标志着这个文件是一个管道。你之前在章节中观察到这一点了吗?如果没有,你可以回过头去检查 inode 的权限位。

让我们继续看最后的代码片段:

            remove(fifo_name); // {13}
        }
    }
    return 0;
}

这是一个简单的单次 ping-pong 应用程序,其输出如下:

Child: Sending some message to the child!
Parent: Child printed the message!

你仍然可以使用 IO 操作来发送消息,但那时 string_view 就不适用了。在下一节中,我们将简要概述当通过管道进行通信被干扰时会发生什么。为了保持对系统调用的关注,我们暂时将 C++ 放在一边。

现在让我们回到 C++ 文件系统库。我们可以通过库操作检查当前文件是否真的是 FIFO 文件。如果是,我们可以使用 remove() 函数将其删除。这和 unlink() 一样,尽管它比系统调用本身多了一层抽象。同样,这将给我们一些平台无关性:

...
int main() {
    if (exists("example_fifo") && is_fifo("example_fifo")){
        remove("example_fifo");
        cout << "FIFO is removed";
    } ...

正如你所见,我们使用了本章前面解释过的已知方法。现在让我们看看在 VFS 和内核级别会发生什么:

图 3.7 – 命名管道创建系统操作

图 3.7 – 命名管道创建系统操作

这个图以及下一个图给你提供了一个例子,说明了为什么匿名管道被认为稍微轻量一些。看看从进程调用者的初始系统调用到实际 FSinode 操作执行之间有多少个函数调用。话虽如此,再加上关闭和删除文件所做的额外努力,很容易得出结论,即使是相关的代码也更大。尽管如此,命名管道用于持久性和不同进程之间的通信,包括那些没有父子关系的进程。想想看——你在 FS 中有通信资源端点,你知道它的字符名称,然后你只需要从两个独立的过程中打开它,并开始数据传输。其他 IPC 机制也使用了类似的方法,我们将在后面的第七章中讨论。在此之前,查看以下图表,看看在简单的open()函数和内核中创建 FIFO 缓冲区之间有多少个操作:

图 3.8 – 命名管道的打开和转换为管道

图 3.8 – 命名管道的打开和转换为管道

文件系统库不允许你直接与文件描述符一起工作。同时,系统调用期望它们。将来,在 C++标准中可能会有所不同。

注意

已知有一种非标准的将文件描述符与iostream关联的方法。你可以在这里参考:www.josuttis.com/cppcode/fdstream.xhtml

我们将在下一节中简要概述当通过管道进行通信被干扰时会发生什么。

简要观察信号处理

在 Linux 中,信号是一种强大且简单的方式,通过向它们发送软件中断来同步进程,表明已发生重要事件。它们具有不同的性质,取决于其角色。其中一些是可以忽略的,而另一些则不行,会导致进程被阻塞、解除阻塞或终止。我们在上一章讨论了这些行为,但我们可以做些什么来优雅地处理它们呢?我们将使用匿名管道示例来触发SIGPIPE信号。

让我们看看以下示例:

...
void handle_sigpipe(int sig) { // {1}
   printf("SIGPIPE handled!\n");
}
int main() {
   int an_pipe[2] = {0};
   char buff[BUFF_LEN + 1] = {0};
   if (pipe(an_pipe) == 0) {
      int pid = fork();
      if (pid == 0) {
         close(an_pipe[pipeOut]); // {2}
         close(an_pipe[pipeIn]);
      }

我们定义了一个SIGPIPE处理程序(标记{1}),如果这个信号被触发,我们可以提供额外的功能。我们故意关闭子进程的管道端点,这样就没有进程会从它那里读取。然后,我们声明一个信号动作,它将信号处理程序映射到动作本身(标记{3}{4})。我们给子进程一些时间来关闭文件描述符,然后我们尝试在管道中写入:

      else {
         struct sigaction act = {0};
         sigemptyset(&act.sa_mask);
         act.sa_handler = handle_sigpipe; // {3}
         if(sigaction(SIGPIPE, &act, 0) == -1) {// {4}
            perror("sigaction"); return (1);
         }
         close(an_pipe[pipeIn]);
         sleep(1);
         const char *msg = {"Sending message to child!"};
         write(an_pipe[pipeOut], msg, strlen(msg) + 1);
// {5} ...

内核将触发 SIGPIPE 信号,其目的是阻塞父进程,直到有读取者出现。在这种情况下,我们会打印一条消息,告知用户已收到信号,父进程将被终止。实际上,这是处理此类信号默认的行为。我们使用处理程序来相应地通知用户:

$ ./sighandler_test
SIGPIPE handled!

然而,我们也可以通过在标记 {3} 上进行以下简单更改来忽略信号:

act.sa_handler = SIG_IGN; // {3}

再次调用程序不会触发处理程序,这意味着信号被忽略,进程将按照其工作流程继续执行。你可以在代码中使用这两种方法,但请注意——某些信号不能被忽略。我们将在本书的后续内容中使用这一知识。

摘要

在本章中,我们没有展示通过 C++ 修改文件数据的任何示例。我们的目标主要是解释不同的 Linux 文件系统实体。我们使用 C++ 文件系统库来丰富这方面的知识——例如,提高系统编程意识。你了解了不同文件系统对象的作用及其具体细节。你还拥有了 C++ 工具来管理文件资源并提升你的抽象能力。还有一些关于如何通过匿名和命名管道在进程间通信的实践示例。我们还讨论了它们在操作系统层面的实现,并简要探讨了 Linux 中的信号处理。

在下一章中,我们将更深入地探讨 C++ 语言,根据最新的标准为其安全使用奠定基础。在本书的后续部分,我们将重新审视本章中展示的一些代码片段。我们将通过使用新的 C++ 特性来不断改进它们。

第四章:深入探索 C++ 对象

在本章中,我们将特别关注 C++ 语言中的对象。但是什么让 C++ 中的对象如此特别,以至于我们应该如此关注它呢?好吧,考虑到 C++ 支持面向对象编程范式,我们可以假设对象本身在语言结构中占据中心位置。你将看到围绕 C++ 中的对象有很多具体细节。

在本章中,我们将深入研究 C++ 中对象的基本方面。我们将首先检查 C++ 标准如何指定对象的定义。在此基础上,我们将更详细地研究不同类型的对象初始化,例如聚合、直接和复制初始化,以及它们的使用场景。

我们还将探讨对象的存储持续时间概念。此外,我们将查看 C++ 中对象的范围和生存期。我们还将了解引用是什么以及它们如何与对象相关联。

随着我们进一步学习,我们将了解临时对象及其为何需要小心处理的原因,以及 C++ 中函数对象和 lambda 表达式的概念。我们将探讨如何使用 lambda 表达式与 标准模板库STL)算法结合的示例,这将帮助我们全面理解如何利用这些强大的功能来创建更高效和优化的代码。

到本章结束时,你将清楚地理解 C++ 中对象的基本概念,并且将熟悉一些你可以用来创建更健壮和高效代码的技术。

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

  • C++ 对象模型

  • 范围、存储持续时间和生存期

  • C++ 中的函数式对象和 lambda 表达式

好的,现在是开始的时候了!

技术要求

本章中的所有示例都在以下配置的环境中进行了测试:

理解 C++ 对象模型

C++ 程序涉及创建、操作和销毁各种称为 对象 的实体。C++ 中的对象具有多个属性,如 类型大小存储持续时间生存期对齐要求。对象的 名称可选的

命名对象的生存期受其存储持续时间的限制,如果没有名称,则被视为临时对象。然而,C++ 中的并非所有实体都被视为对象。例如,引用就是这样一种非对象。

首先,让我们简要地了解一下术语,因为了解它们很重要,因为这将有助于我们在日常使用 C++ 语言的工作中。

声明与定义

在 C++ 中,变量、函数或类的术语声明定义经常用来指代变量、函数或类的不同方面。以下是每个术语的含义:

  • 声明:声明将名称引入程序并指定变量的类型、函数或类,例如以下内容:

    extern int x;
    void foo(int arg);
    struct Point;
    

    在前面的例子中,xfooPoint 都被声明但没有定义。变量声明中的 extern 关键字表示 x 在程序的另一部分被定义。在声明中,不分配内存。

  • 定义:定义提供了已声明的名称的实际实现。为变量保留内存,为函数分配代码空间,并定义类的布局,例如以下内容:

    int x;
    void foo(int arg) {
       // function body
    }
    struct Point {
       // struct members and methods
    };
    

    在前面的例子中,xfooPoint 都被定义

因此,声明引入了一个名称并指定了其类型,而定义提供了实际的实现并为对象分配内存。

现在我们已经熟悉了这些术语,让我们深入探讨 C++ 中对象的具体细节。

范围、存储持续时间和生存期

C++ 程序中的每个对象或引用都有一个特定的程序区域,在该区域中它是可见和可访问的,有一个特定的生存期,以及它占据的特定类型的内存。让我们更详细地看看它们中的每一个。

范围

在 C++ 中,变量、函数或类的作用域指的是程序中实体名称可见并可无限制访问的区域。作用域规则决定了在不同程序部分中哪些标识符是可见和可访问的。标准定义了 C++ 中的几种作用域类型。其中一些如下:

  • 全局:在函数或类外部声明的变量、函数和类具有全局作用域。它们可以从程序的任何部分访问,例如以下内容:

    int x = 1; // global variable
    void foo() {
        std::cout << x << std::endl; // access global
          variable
    }
    
  • 函数:在函数内部声明的变量具有函数作用域。它们只能在声明它们的函数内部访问,例如以下内容:

    void foo() {
        int x = 1; // local variable
        std::cout << x << std::endl; // access local
          variable
    }
    
  • {}),具有块作用域。它们只能在声明它们的块内部访问,或者在存在内部块的情况下,例如以下内容:

    void foo() {
        int x = 2; // local variable with function scope
        {
            int y = 4; // local variable with block scope
        }
    }
    

这些是我们使用的 C++ 中的一些作用域。现在,让我们看看在 C++ 中存储持续时间意味着什么。

存储持续时间

在 C++ 中,存储持续时间指的是对象的生存期,即它在内存中存在的时间。有四种存储持续时间类型:

  • static 关键字和函数参数。

  • 函数内的 static 关键字。

  • 使用 new 操作符创建,并使用 delete 操作符销毁。它们存在于堆上,并且可以被程序的多部分访问。

  • 线程局部:这些对象在创建线程时创建,在线程终止时销毁。它们类似于具有静态存储期的对象,但它们特定于某个特定的线程。

下面是一个说明不同存储期的类型的例子:

#include <iostream>
int global_var = 1; // Static storage duration
void foo() {
    int automatic_var = 2;
    static int static_var = 3;
    int* dynamic_var = new int(4);
    std::cout << "Automatic var: " << automatic_var <<
      '\n';
    std::cout << "Static var: " << static_var << '\n';
    std::cout << "Dynamic var: " << *dynamic_var << '\n';
    delete dynamic_var;
}
int main() {
    foo();
    std::cout << "Global var: " << global_var << '\n';
    return 0;
}

在这个例子中,global_var 具有静态存储期,因为它是一个全局变量。automatic_var 具有自动存储期,因为它是在 foo 函数内部声明的。static_var 也具有静态存储期,但由于 static 关键字的存在,它在 foo 函数调用之间保留其值。dynamic_var 本身具有自动存储期,但它指向的分配的内存具有动态存储期,因为它是用 new 操作符分配的。当 foo 返回时,automatic_var 会自动销毁,dynamic_var 会通过 delete 操作符销毁,而 static_varglobal_var 则在整个程序的生命周期内持续存在。

生命周期

术语 生命周期 指的是对象或引用在程序中存在的时间长度。在 C++ 中,每个对象和引用都有一个特定的生命周期。对象的生命周期从为其分配内存并初始化时开始。如果对象的类型具有构造函数,则生命周期从构造函数成功完成时开始。对象的生命周期在调用其析构函数时结束,如果没有析构函数,则在销毁时结束。因此,对象的生命周期等同于或小于其存储的持续时间。同样,引用的生命周期从其初始化完成时开始,最终像标量对象一样结束。

对象

每个对象都是由一个定义语句创建的,该语句引入、创建并可选地初始化一个 变量。变量是一个 对象引用,它不是非静态数据成员,并且通过声明引入(对象 - cppreference.com)。

让我们定义一个简单的变量并从中创建一个对象:

void foo() {
    int x;
}

我们定义了一个对象,并且同时从foo()函数的栈上实例化了一个整型对象。在 C++中,每个对象在特定的内存区域中占用一定量的内存。由于它位于栈上,这个对象具有自动存储持续时间。在我们的例子中,这意味着对象将在函数开始时创建,并在函数结束时自动销毁。当它被实例化时,它会使用一些内存。这个数量是一个编译时已知的值,可以使用sizeof运算符来获取。请注意,某些类型的大小可能会根据程序运行的底层硬件而变化,所以如果您需要确保大小,请始终使用运算符来计算它。这样的例子是基本的int类型。标准规定int类型的大小不能小于 16 位。对于运行本章示例的 Linux Mint 21 和 GCC 12.2,使用的底层数据模型是 LP64。这意味着int是 4 字节,long和指针是 8 字节。在下一个示例中,我们将演示前面提到的类型的大小。为了编译和运行此代码,您必须将其传递到一个函数中:

int i;
long l;
char* p;
std::cout << "sizeof(int) = " << sizeof(int) << "; sizeof(i) = " << sizeof(i) << '\n';
std::cout << "sizeof(long) = " << sizeof(long) << "; sizeof(l) = " << sizeof(l) << '\n';
std::cout << "sizeof(char*) = " << sizeof(char*) << "; sizeof(p) = " << sizeof(p) << '\n';

下面是示例的输出:

sizeof(int) = 4; sizeof(i) = 4
sizeof(long) = 8; sizeof(l) = 8
sizeof(char*) = 8; sizeof(p) = 8

到目前为止,没有什么令人惊讶的。int类型是 4 字节,但指针,无论它指向哪种类型,都是 8 字节。

现在,让我们定义几个结构并检查它们的内存占用:

struct Empty {};
struct Padding {
    long test;
    char m;
};
struct Virt {
    virtual char GetChar() const { return ch; }
    char ch;
};
void foo() {
    std::cout << "Empty: " << sizeof(Empty) << '\n';
    std::cout << "Padding: " << sizeof(Padding) << '\n';
    std::cout << "Virt: " << sizeof(Virt) << '\n';
}

我们定义了三个结构——EmptyPaddingVirt。正如其名所示,Empty结构只是一个没有任何成员的空结构。Padding结构包含两个成员——longchar。正如我们从上一个示例中看到的,在我的测试环境中,long是 8 字节,char是 1 字节。最后,Virt结构只有一个char类型的成员和一个虚方法。结构和类方法不是对象本身的一部分。它们位于文本段而不是对象占用的内存中。让我们执行前面的代码并查看结果:

Empty: 1
Padding: 16
Virt: 16

我们可以看到所有对象都占用内存。即使是空的!这是由标准保证的,因为系统中的任何对象都必须有一个地址,它位于该地址上。如果没有占用任何内存,则无法为其分配地址。因此,程序中的每个对象至少保留 1 字节。

Padding结构占用的内存比其成员内存总和还要多。这是因为编译器可以自由地将对象放置在地址上,这样可以减少指令运算,以便更快地访问。因此,如果需要,它们会在类型的大小上添加填充字节。

最后,Virt 结构体只包含一个成员,其类型为 char。然而,这个结构体占用的内存量与 Padding 结构体相同。这是由于 C++ 中实现多态机制的方式所导致的。该结构体包含一个虚拟方法,它通知编译器这个用户定义的类型将被多态地使用。因此,编译器在从该类型实例化的每个对象中注入一个指向表的指针,其中包含类中所有虚拟方法的地址。

由于所有这些示例,我们可以得出结论:一旦对象被实例化,它就会占用内存,而内存的大小可能取决于底层系统和类型的定义。

接下来,我们将熟悉 C++ 中的引用以及它们与语言中的对象有何不同。

引用

在上一节中,我们发现我们可以从对象声明一个变量,也可以从引用声明。但就 C++ 而言,引用 是什么?根据标准,引用变量是已存在对象或函数的别名。这意味着我们可以使用别名来处理对象,而不需要在语法上有差异,而不是处理对象的指针,其语法相当不同。让我们看一下以下示例。为了编译和运行它,你需要从函数中调用它:

char c;
char& r_c{c};
char* p_c;
std::cout << "sizeof(char) = " << sizeof(char) << "; sizeof(c) = " << sizeof(c) << '\n';
std::cout << "sizeof(char&) = " << sizeof(char&) << "; sizeof(r_c) = " << sizeof(r_c) << '\n';
std::cout << "sizeof(char*) = " << sizeof(char*) << "; sizeof(p_c) = " << sizeof(p_c) << '\n';

在这个例子中,我们声明了三个变量——一个字符、一个字符的引用和一个字符的指针。在处理引用变量时,一个重要的细节是在其声明点,我们必须用它将引用到的对象初始化。从这一刻起,对引用变量的每个操作实际上都是在别名对象上执行的。但别名究竟是什么?它是否像指针一样占用内存?嗯,这是一个灰色地带。标准指出,与对象不同,引用并不总是占用存储。然而,如果需要实现预期的语义,编译器可能会分配存储。因此,你不能使用 sizeof 运算符来获取引用的大小:

sizeof(char) = 1; sizeof(c) = 1
sizeof(char&) = 1; sizeof(r_c) = 1
sizeof(char*) = 8; sizeof(p_c) = 8

你可以看到,指针的大小与预期相符,而不是引用类型的大小,它与其别名类型的大小相匹配。

理解初始化的重要性

初始化 是在对象构造期间设置其初始值的过程。在 C++ 中,根据以下内容,存在多种初始化类型:

  • 对象所属的存储持续时间

  • 对象的定义

了解不同类型的初始化以及它们确切发生的时间,无疑会增强你在编写可预测代码时的信心。

让我们看看 C++ 语言支持的几种不同类型的初始化的例子。这将使初始化发生的时间更加清晰。

默认初始化

在下一个例子中,你可以看到一个 默认初始化。为了运行和测试这段代码,你必须调用 foo() 方法:

struct Point {
    double x;
    double y;
};
void foo() {
    long a; // {1}
    Point p1; // {2}
    std::cout << "{1}: " << a << '\n';
    std::cout << "{2}: " << p1.x << ", " << p1.y << '\n';
}

在标记 {1} 中,我们声明了一个 long 类型的栈变量。对象将应用哪种初始化类型主要取决于以下因素:

  • 它占用的存储持续时间:这意味着不同的初始化策略可能适用,这取决于对象是位于栈上、全局空间中等。

  • init 值,我们是如何传递那个 init 值的,等等。

在我们的例子中,long a; 变量具有自动存储持续时间,这意味着它位于函数的栈上。在其声明中,我们没有指定任何初始化值。对于这样的对象,我们将应用 默认初始化。当一个对象被默认初始化时,C++ 编译器将生成调用对象类型默认构造函数的代码(如果存在)。然而,由于 long 是一个缺乏默认构造函数的基本 C++ 类型,C++ 运行时 不会对其进行任何初始化,结果得到的是一个 不可预测的值。这意味着用于初始化的值没有指定,可能是任何值。这也适用于 Point p1; 对象,它是一个用户定义的类型,但我们没有为它指定默认构造函数。Point 结构是一个所谓的 原始数据 (POD) 类型,因为它与 C 语言的结构的完全兼容。对于这样的类型,编译器将为你生成一个 平凡的默认构造函数,当被调用时实际上什么也不做。

早期例子的输出将看起来像这样:

{1}: 1
{2}: 4.19164e-318, 4.3211e-320

在我的环境中,ap1 对象都有不确定的值。如果你运行你自己的例子,你可能会得到不同的值。

直接初始化

在我们的下一个例子中,我们将学习 C++ 的 直接初始化。为了运行和测试这段代码,你必须再次调用 foo() 方法。请注意,为了成功编译,这个例子中的 int c_warn{2.2}; // {4.2} 语句应该被注释掉:

void foo() {
    int b(1);         // {3.1}
    int b_trunc(1.2); // {3.2}
    int c{2};         // {4.1}
    int c_warn{2.2};  // {4.2}
    std::cout << "{3.1}: " << b << '\n';
    std::cout << "{3.2}: " << b_trunc << '\n';
    std::cout << "{4.1}: " << c << '\n';
}

在例子中的第一个语句 int b(1);,我们定义了一个 int 类型的变量,并且我们显式地用值 1 初始化了它。这是我们自 C++ 语言诞生以来所知道的 直接初始化。为了调用它,你必须指定括号中的初始化值,并且该值必须与对象的类型的一些转换构造函数相匹配。这些转换构造函数可以是编译器生成的。在我们的例子中,我们使用 int,这是一个基本的 C++ 类型,支持使用整数值进行直接初始化。因此,b 对象将被初始化为 1,到目前为止没有什么新的。

在下一个语句中,我们声明了一个int b_trunc(1.2);变量,但这次我们用浮点值1.2来初始化它。这个语句运行正常,并声明了一个int类型的变量,并用一个值初始化它……1!是的,根据 C++标准,它试图尽可能与 C 语言兼容,对于两种语言都存在的特性,值会被截断到其尾数部分。在某些情况下,用浮点值初始化整数对象可能是有用的,但在其他情况下,这可能是无意中的错误。在这种情况下,我们期望编译器警告我们我们可能正在做错误的事情。因此,C++11 引入了所谓的统一初始化

在示例中的下一个语句int c{2};中,我们再次声明了一个int类型的变量,但这次我们用花括号而不是括号来初始化它。这通知编译器调用直接列表初始化,这是一种统一初始化。它是一个命名列表初始化,因为它可以用作不同类型值的初始化列表,以初始化复杂对象。

在可能的情况下优先使用统一初始化的一个原因是体现在示例中的下一个语句:

int c_warn{2.2};  // {4.2}

正如我们刚才看到的,使用直接初始化将一个特定类型的对象用更宽类型的值初始化会导致初始化值被静默截断。在某些情况下,这可能会导致错误。避免这种潜在副作用的一种方法是用统一初始化代替。在我们的例子中,我们定义了一个int类型的变量,并且再次用浮点值初始化它。然而,这次编译器不会静默地将c_warn初始化为2,而是会生成一个类似于下面的错误:

error: narrowing conversion of '2.2000000000000002e+0' from 'double' to 'int' [-Wnarrowing]

错误产生是因为我们试图在用double值初始化int变量时执行缩窄转换。因此,使用统一初始化而不是直接初始化更安全,因为它在初始化过程中保护你免受缩窄转换。

零和聚合初始化

让我们再看另一个初始化的例子。我们将初始化一个包含Person个人数据和几个整数对象的对象:

struct Person {
    std::string name;
    int age;
};
void init() {
    int zero1{}; // {1}
    int zero2 = int(); // {2}
    int zero3 = int{}; // {3}
    Person nick{"Nick L.", 42}; // {4}
    Person john{.name{"John M."}, .age{24}}; // {5}
}

正如我们之前解释的,具有自动存储持续时间且没有显式初始化的对象会得到随机初始化值。在这个例子中,从标记{1}{3},我们使用零初始化来初始化对象,这实际上将它们的值设置为 0。对于非类、内置类型以及没有构造函数的用户定义类型的成员,都会发生零初始化。当你需要将对象零初始化时,最好使用花括号表示法和统一初始化,例如标记{1},而不是复制零初始化,例如标记{2}{3}

语句{4}展示了另一种初始化方法,称为聚合初始化。它允许我们使用统一初始化符号初始化聚合对象。聚合被认为是指任何数组或没有用户声明的或继承的构造函数的类类型对象;它的所有非静态成员都是公开可见的,并且它没有虚拟基类和虚拟方法。语句{5}执行了另一种聚合初始化的方式,但使用了设计符。设计符明确指定了正在初始化的成员,并且初始化中设计符的顺序应该遵循结构中成员声明的顺序。

复制初始化

复制初始化发生在特定类型的对象被同类型的另一个对象初始化时。让我们看看以下触发复制初始化的语法示例。为了运行和测试此代码,你必须调用foo()方法:

void foo() {
    int c{2};
    int d(c);     // {1}
    int e{d};     // {2}
    int f = e;    // {3}
    int f1 = {d}; // {4}
}

此示例中的标记{1}{3}展示了即使在 C++11 之前,语言中也存在的知名复制初始化。一个int类型的对象被同类型的另一个对象初始化。正如我们之前看到的,这种初始化不会提供任何针对类型缩窄的保护。这意味着我们的int对象可以被double对象无声地初始化,这会导致缩窄。幸运的是,标记{2}{4}的情况并非如此。它们使用统一的复制初始化,这迫使编译器验证初始化对象与被初始化的对象类型相同。

现在,让我们看看用户定义类型的一些复制初始化场景。我们定义了两个类——PersonEmployeePerson类有一个用户定义的构造函数,它接收一个指向std::string参数的引用,用于初始化人的名字。构造函数被标记为explicit。这意味着它只能作为非转换构造函数使用。转换构造函数是一种将它的参数类型隐式转换为它的类类型的构造函数。

另一个类Employee有两个构造函数,其中一个获取一个Person对象的引用,而另一个是复制构造函数。复制构造函数也被标记为explicit

class Person {
public:
    explicit Person(const std::string&  the_name) : name{
      the_name} {}
private:
    std::string name;
};
class Employee {
public:
    Employee(const Person& p) : p{p} {}
    explicit Employee(const Employee& e) : p{e.p} {}
private:
    Person p;
};

让我们使用这两个类在不同的初始化场景中。为了运行和测试此代码,你必须重新修改并再次调用foo()方法:

void foo() {
    Person john{"John M."};
    Employee staff1{john};          // {1}
    // Employee staff2{std::string{"George"}};   // {2}
    Employee staff3{staff1};        // {3}
    // Employee staff4 = staff1;    // {4}
    // Employee staff5 = {staff1};  // {5}
}

我们首先定义了一个名为johnPerson对象,并在标记 {1} 中使用john初始化了一个Employee对象。这实际上是有效的,因为Employee类有一个接受Person对象的构造函数。下一个语句,标记 {2},被注释掉了,它接受一个std::string类型的对象,但编译器会生成一个错误。这是因为Employee类没有接受字符串对象的构造函数。它有一个从Person对象转换而来的构造函数。然而,Person构造函数被标记为explicit,不允许在隐式类型转换中使用,因此编译会失败。

下一个语句,标记 {3},将成功编译,因为Employee是通过另一个Employee对象复制构造和初始化的,没有进行任何隐式类型转换。

示例中的最后两个语句 – 标记 {4}{5} – 也被注释掉了,以避免编译错误。编译错误的原因是Employee类的复制构造函数也被标记为explicit。这意味着不允许使用等号 "=" 进行复制构造和初始化,对于显式复制构造函数来说,只有直接复制初始化是被允许的。

现在我们已经熟悉了对象的作用域、存储持续时间和生命周期,我们可以看看一些稍微不同类型的对象,它们的行为更像是函数而不是对象 – 函子和 lambda 表达式。

函子和 lambda 表达式

本节将深入探讨函数对象 – 它们的定义、有用性和正确使用。我们将从检查一个与 STL 算法一起使用的函数对象示例开始,并讨论潜在问题,如临时对象的创建和悬垂引用。之后,我们将继续探讨 lambda 表达式 – 它是什么,如何使用它们,以及它们在特定情况下可以特别有利的情况。

探索函数对象

作用域、存储持续时间和生命周期部分,我们探讨了 C++中各种类型的对象初始化,但我们的重点主要是在表示数据的对象上,例如整数或坐标。在本节中,我们将把注意力转向另一种类型的对象 – 那些被设计为可调用的对象,例如函数,但有一个关键的区别:它们可以在不同的函数调用之间保持状态。这些对象被称为函数对象函子。我们将首先定义一个函子,然后使用它来计算包含浮点数的向量的平均值:

#include <iostream>
#include <vector>
#include <algorithm>
#include <cmath>
#include <source_location>
struct Mean {
    Mean() = default;
    void operator()(const double& val) {
        std::cout <<  std::source_location::current()
          .function_name() << " of " << this << '\n';
        sum += val;
        ++count;
    }
private:
    double sum{};
    int count{};
    friend std::ostream& operator<<(std::ostream& os, const
      Mean& a);
};
std::ostream& operator<<(std::ostream& os, const Mean& a) {
    double mean{std::nan("")};
    if (a.count > 0) {
        mean = a.sum / a.count;
    }
    os << mean;
    return os;
}
int main() {
    Mean calc_mean;
    std::vector v1{1.0, 2.5, 4.0, 5.5};
    std::for_each(v1.begin(), v1.end(), calc_mean);
    std::cout << "The mean value is: " << calc_mean <<
      '\n';
    return 0;
}

函子是一个像其他任何对象一样的对象。它有一个类型、存储持续时间和作用域。为了定义一个函子,你必须定义一个用户自定义类型的结构体或类,并且这个类型必须实现了函数 调用操作符

operator()

在我们的示例中,我们定义了一个包含两个成员的 struct Mean,这两个成员都是零初始化的。第一个成员 sum 将用于在函数调用操作符调用期间累积该对象接收到的输入数据,并在不同的调用之间保持它。另一个成员 count 将用于计算函数调用操作符的调用次数。

函数调用操作符的定义接受一个 double 类型的参数,然后该方法打印其名称并将输入值添加到之前调用中已经累积的值。最后,它增加调用计数器。

函数调用操作符不返回任何类型,并且没有定义为 const 方法,因为它会改变 Mean 对象的状态。我们还重载了流提取操作符,它将用于将计算出的平均值报告到标准输出。如果没有累积值,则打印 nan(“不是一个数字”):

std::ostream& operator<<(std::ostream& os, const Mean& a)

请记住,操作符在 Mean 结构外部重载,并且它被声明为该结构的 friend 方法。这是因为它需要将 std::ostream 作为左操作数,将 Mean 参数作为右操作数,因此不能实现为成员方法。它被定义为 friend 是因为它必须能够访问 Mean 结构的 private 成员。

为了计算平均值,我们的算法使用 std::for_each STL 算法遍历向量中的所有值。std::for_each 预期接收一个容器来操作,以及一个函数,该函数将使用容器中的每个元素调用;因此,此函数必须接受一个参数作为输入参数。

在主方法中,我们定义了一个类型为 Mean calc_mean; 的对象,该对象将用于计算 std::vector v1{1.0, 2.5, 4.0, 5.5}; 的平均值。正如您所看到的,我们不需要显式指定 std::vector 类的模板参数类型,因为它会根据其初始化列表值的类型自动推导。在我们的例子中,这些是 double 类型的值。

重要提示

请注意,自 C++17 以来,基于其初始化的类型,已经支持自动类模板参数推导。

我们期望程序将为向量中的每个元素调用 Mean 对象的函数操作符。函数操作符将累积所有值,当结果打印出来时,它将是 3.25。让我们看看程序的输出:

void Mean::operator()(const double&) of 0x7ffc571a64e0
void Mean::operator()(const double&) of 0x7ffc571a64e0
void Mean::operator()(const double&) of 0x7ffc571a64e0
void Mean::operator()(const double&) of 0x7ffc571a64e0
The mean value is: nan

如我们所预期,操作符函数调用为向量中的每个元素调用一次,但令人惊讶的是,没有计算出的平均值。为了更好地理解计算中出了什么问题,我们需要查看 calc_mean 对象的情况,该对象已被 std::for_each 算法使用。

小心临时变量

为了进行调查,在Mean结构中,我们需要定义copymove构造函数、移动操作符和一个析构函数,它们的唯一目标将是打印它们是否被调用以及它们所属的对象的地址。我们还需要添加计算开始和结束时的时间标记。让我们看看修改后的示例:

struct Mean {
    Mean() noexcept {
        std::cout <<  std::source_location::current()
         .function_name() << " of " << this << '\n';
    }
    Mean(Mean&& a) noexcept : sum{a.sum}, count{a.count} {
        std::cout <<  std::source_location::current()
          .function_name() << " from: " << &a << " to: " <<
             this << '\n';
        a.sum = 0;
        a.count = -1;
    }
    Mean& operator=(Mean&& a) noexcept {
        std::cout <<  std::source_location::current()
          .function_name() << " from: " << &a << " to: " <<
            this << '\n';
        sum = a.sum;
        count = a.count;
        return *this;
    }
    Mean(const Mean& a) noexcept : sum{a.sum},
      count{a.count} {
        std::cout <<  std::source_location::current()
          .function_name() << " from: " << &a << " to: " <<
            this << '\n';
    }
    ~Mean() noexcept {
        std::cout <<  std::source_location::current()
          .function_name() << " of " << this << '\n';
    }
    void operator()(const double& val) {
        std::cout <<  std::source_location::current()
          .function_name() << " of " << this << '\n';
        sum += val;
        ++count;
    }
private:
    double sum{};
    int count{};
    friend std::ostream& operator<<(std::ostream& os, const
      Mean& a);
};

我们还需要稍微修改main()方法的实现:

int main() {
    Mean calc_mean;
    std::vector v1{1.0, 2.5, 4.0, 5.5};
    std::cout << "Start calculation\n";
    std::for_each(v1.begin(), v1.end(), calc_mean);
    std::cout << "Finish calculation\n";
    std::cout << "The mean value is: " << calc_mean <<
      '\n';
    return 0;
}

当我们重新执行已经修改过的程序时,我们得到以下输出:

Mean::Mean() of 0x7ffef7956c50
Start calculation
Mean::Mean(const Mean&) from: 0x7ffef7956c50 to: 0x7ffef7956ca0
void Mean::operator()(const double&) of 0x7ffef7956ca0
void Mean::operator()(const double&) of 0x7ffef7956ca0
void Mean::operator()(const double&) of 0x7ffef7956ca0
void Mean::operator()(const double&) of 0x7ffef7956ca0
Mean::Mean(Mean&&) from: 0x7ffef7956ca0 to: 0x7ffef7956c90
Mean::~Mean() of 0x7ffef7956c90
Mean::~Mean() of 0x7ffef7956ca0
Finish calculation
The mean value is: nan
Mean::~Mean() of 0x7ffef7956c50

如我们所预期,程序从创建地址为0x7ffef7956c50的对象开始,然后开始计算,我们可以看到调用了复制构造函数。这是因为std::for_each,就像标准库中的许多其他算法一样,是一个模板方法,它通过值获取其函数对象。以下是标准关于其原型的说明:

template< class InputIt, class UnaryFunction >
constexpr UnaryFunction for_each( InputIt first, InputIt
  last, UnaryFunction f );

这意味着无论它执行什么计算,所有累积的值都将存储在复制的对象中,而不是原始对象中。实际上,由这个复制构造函数创建的对象只是一个临时对象。临时对象是没有名称的对象,它们由编译器自动创建和销毁。它们经常导致开发者难以识别的副作用。临时对象通常是在参数和函数返回值的隐式转换的结果中创建的。如果它们没有绑定到某个命名引用,它们通常具有有限的生存期,直到它们被创建的语句结束。因此,要小心它们,因为它们可能会影响程序的性能,但更重要的是,它们可能导致意外的行为,就像我们的例子中那样。

从前面的代码中,我们可以看到所有的累积都是在新创建的临时对象中完成的。一旦std::for_each方法完成执行,就会调用一个新的临时对象的移动构造函数。这是因为根据std::for_each的定义,通过值传递的输入函数对象作为操作的结果返回。因此,如果我们需要将累积的值返回到原始对象,我们需要将std::for_each的返回值赋给原始对象calc_mean

calc_mean = std::for_each(v1.begin(), v1.end(), calc_mean);

最后,结果是我们所预期的,但代价是创建了几个临时对象:

Finish calculation
The mean value is: 3.25

在我们的例子中,这并不是问题,但对于涉及昂贵且可能缓慢的操作的复杂对象,例如资源获取,这可能会成为问题。

接下来,让我们看看我们如何通过避免不必要的复制操作来改进我们的示例。

通过引用传递

改进早期示例的一种方法是将函数对象不是通过值而是通过引用传递。这将避免创建不必要的临时对象:

using VecCIter = std::vector<double>::const_iterator;
std::for_each<VecCIter, Mean&>(v1.begin(), v1.end(),
  calc_mean);

为了通过引用传递Mean对象,你必须显式地告诉编译器Mean模板参数是引用类型。否则,自动模板参数推导将推导出你是通过值传递。结果,这迫使你避免使用自动类模板参数推导,使得你的代码更难阅读。幸运的是,标准为此提供了一个解决方案:

std::for_each(v1.begin(), v1.end(), std::ref(calc_mean));

我们需要使用工厂方法,std::ref,来创建std::reference_wrapper对象。std::reference_wrapper是一个模板类,它将一个引用封装在一个可赋值、可复制的对象中。它通常用于存储标准容器中通常无法容纳的引用。在我们的例子中,std::ref的使用消除了显式指定std::for_each的函数模板参数是引用类型而不是值的必要性。以下是我们的重构结果:

Mean::Mean() of 0x7ffe7415a180
Start calculation
void Mean::operator()(const double&) of 0x7ffe7415a180
void Mean::operator()(const double&) of 0x7ffe7415a180
void Mean::operator()(const double&) of 0x7ffe7415a180
void Mean::operator()(const double&) of 0x7ffe7415a180
Finish calculation
The mean value is: 3.25
Mean::~Mean() of 0x7ffe7415a180

如你所见,没有额外的临时对象的创建和销毁,因为算法直接与calc_mean对象的引用一起工作。

小心悬垂引用

总是要确保你传递的程序中的引用将指向活动对象,直到它们被使用!

函数对象只是我们可以在例子中使用的一个选项。这里还有另一种方法可以使我们的代码更具表现力。这些是 lambda 表达式。让我们看看它们。

Lambda 表达式

在 C++中,lambda 表达式或简称为lambda,是一种简洁的方式来定义一个匿名函数函数对象,它可以立即使用或赋值给变量以供以后使用。它允许程序员在不定义命名函数或functor类的情况下,即时编写小型、一次性函数。Lambdas 通常与标准库中的算法和容器一起使用,从而允许编写更简洁、更具表现力的代码。

让我们定义一个简单的 lambda,它只是打印到标准输出:

auto min_lambda = [](const auto& name) -> void {
    std::cout << name << " lambda.\n";
};
min_lambda("Simple");

每个 lambda 表达式都是一个对象,这意味着它有一个生命周期并占用内存。每个定义的 lambda 实际上是一个隐式 functor 类定义,因此它有一个唯一类型。一个程序中不能有两个或更多具有相同类型的 lambda。这个类型名称是平台特定的,因此,如果你需要将 lambda 赋值给变量,你必须使用auto指定符定义这个变量。

Lambda 的语法由[ ]符号组成,其后跟一个可选的捕获列表、一个可选的参数列表、一个可选的返回类型、一个可选的mutable指定符和一个函数体。Lambdas 可以通过值或引用从外部作用域捕获变量,并且它们还可以有返回类型推导或显式返回类型,我们将在下面看到。

捕获外部作用域

Lambda 可以通过使用 捕获列表 来访问它们定义的作用域中的其他对象。如果捕获列表为空,则不会捕获任何对象。全局对象在 lambda 中始终可见,无需显式捕获。在定义捕获列表时,你可以选择通过 或通过 引用 来捕获对象,甚至两者混合使用。

在 lambda 表达式中通过值捕获变量时,变量会在其 定义时刻 被复制到 lambda 对象中。在 lambda 定义之后对原始变量所做的任何修改都不会影响其内部的副本。默认情况下,所有捕获的对象都是 只读的,要修改它们,你必须显式指定 lambda 为 可变的

另一种捕获变量的方法是通过引用,这会在 lambda 内部创建每个捕获对象的引用。这允许 lambda 与外部作用域进行通信,但确保所有通过引用捕获的对象的生存期都超过 lambda 的生存期,以防止 悬垂引用

现在,让我们重构上一节中的示例,使用 lambda 而不是泛型来计算包含浮点数的向量的平均值。为了运行以下代码,你必须从你的程序中调用 foo() 方法:

void foo() {
    double mean{};
    std::vector v1{1.0, 2.5, 4.0, 5.5};
    std::string_view text{"calculating ..."};
    std::for_each(v1.begin(), v1.end(),
                  &mean, sum{0.0}, count{0}, text mutable {
        std::cout << text << '\n';
        sum += val;
        ++count;
        mean = sum / count;
    });
    std::cout << mean << '\n';
}

与命名函数和泛型相比,lambda 表达式的一个关键优势是它们可以被内联到它们的调用位置。在我们的例子中,我们直接在 std::for_each 调用语句中定义了 lambda。这种方法明确指出,这个 lambda 存在的唯一原因就是为前面的情况提供服务。

让我们更仔细地看看 lambda 原型:

&mean, sum{0.0}, count{0}, text
  mutable { … }

在捕获列表中,我们捕获了四个对象。第一个对象,mean,是通过引用捕获的。在变量名前放置 & 指定它是通过引用捕获的。我们将使用 mean 在 lambda 外部报告计算出的平均值。捕获列表中的下一个两个变量,sumcount,是通过值捕获的。如果 & 不在变量名前,则表示它是通过值捕获的。这个规则的唯一例外是在捕获类的 this 指针时,它将通过值捕获,但访问类成员将通过引用。正如你所看到的,捕获的 sumcount 并不在外部作用域中定义;它们只在 lambda 的作用域内定义,目的是为了我们的示例。就像函数对象示例一样,它们被用来存储累积的总和和迭代的计数。这是一种将状态显式添加到 lambda 中以供进一步计算使用的便捷方式。当然,你需要通过向捕获传递初始化器来初始化它们,原因有两个——为了允许编译器推断它们的类型,并在计算中获得预期的结果。实现逻辑将在其执行过程中更新 sumcount 的值,但如前所述,这些捕获在 lambda 的上下文中是只读的。因此,我们不能在不明确声明我们的意图的情况下修改它们。这是通过在参数列表之后和 lambda 体之前附加 mutable 关键字来完成的。

最后捕获的对象是 text。它也是通过值捕获的,但这次它是从 foo() 方法的外部作用域中捕获的。

一旦程序执行完毕,我们将得到以下输出:

calculating ...
calculating ...
calculating ...
calculating ...
3.25

正如我们所预期的,我们的 lambda 被调用了四次,计算出的平均值与上一节中函数对象计算出的值完全相同。

在捕获列表中捕获对象有许多方法。以下列表显示了一些适用的规则:

图 4.1 – 在捕获列表中捕获对象的方法

图 4.1 – 在捕获列表中捕获对象的方法

现在我们已经知道了如何正确捕获外部作用域,让我们熟悉一下 lambda 的参数列表。

参数列表

Lambda 的参数列表就像任何其他函数参数列表一样。这是因为 lambda 的参数列表实际上是函数调用操作符在函数对象类中的参数列表。你可以定义你的 lambda 接受任意数量的参数,具体取决于你的用例。

使用 auto 指示符作为 lambda 参数列表中一个或多个参数的类型,使其成为一个 泛型 lambda。泛型 lambda 行为类似于模板函数调用操作符:

auto sum = [](auto a, auto b) {
    return a*b;
}

这实际上作用如下:

class platform_specific_name {
public:
    template<typename T1, typename T2>
    auto operator()(T1 a, T2 b) const {
        return a*b;
    }
};

随着 C++20 的发布,如果你愿意,你可以显式指定 lambda 可以获取的模板参数。前面的例子可以重写如下:

auto sum = []<typename T1, typename T2>(T1 a, T2 b) {
    return a*b;
}

lambda 的另一个重要特性是返回类型。让我们看看它的具体细节。

返回类型

指定 lambda 的返回类型是可选的。如果你没有显式指定它,编译器会尝试为你推断它。如果它没有成功,那么将生成一个类型推断的编译器错误。然后,你必须要么更改你的代码以允许自动返回类型推断,要么显式指定 lambda 的返回类型。

这里是一个关于返回类型推断的编译器错误:

auto div = [](double x, double y) {
    if (y < 0) { return 0; }
    return x / y;
};

这段代码无法编译,因为编译器将无法自动推断 lambda 的返回类型。它的实现逻辑有两个执行分支。第一个分支返回一个整型字面量,0,但另一个分支返回除法的结果,即商,它是一个双精度浮点数。

为了修复这个问题,我们需要显式指定 lambda 的返回类型为 double

这里是一个显式指定的返回类型:

auto div = [](double x, double y) -> double {
    if (y < 0) { return 0; }
    return x / y;
};

现在,对于编译器来说,很清楚返回结果总是被转换为 double

摘要

在本章中,我们探讨了 C++ 中对象的各种方面,包括存储持续时间、作用域和生命周期。我们区分了对象和引用,并讨论了初始化对象的不同方式以及这些初始化何时发生。此外,我们还深入了解了函数对象的世界,了解了它们是什么以及如何有效地使用它们。基于这些知识,我们还学习了 lambda 表达式及其相对于函数对象的优点。我们介绍了如何正确使用 lambda 和函数对象与 STL 算法。掌握了这些关于对象特性的知识后,我们接下来可以讨论 C++ 中的错误处理。

第五章:使用 C++ 处理错误

本章将重点介绍 C++ 中的错误处理。作为一名程序员,您不可避免地会遇到需要确定最佳方法来传播程序错误的情况。无论您使用错误代码还是异常,我们都会深入研究它们,以更好地了解如何有效地使用它们。

在本章中,我们将探讨如何使用 C++ 处理 POSIX API 报告的错误。我们将从介绍 errno 线程局部变量和 strerror 函数开始。之后,我们将介绍 std::error_codestd::error_condition,并演示它们如何帮助封装来自 POSIX API 的 POSIX 错误。我们还将研究自定义错误类别,这允许我们比较来自不同来源的错误,并开发平台无关的错误处理代码。

随着我们不断深入,我们将了解 C++ 中的异常以及如何将 std::error_code 转换为 std::system_error 异常。我们还将探讨一些与异常一起工作的最佳实践,例如通过值抛出异常并通过引用捕获它们。此外,我们将熟悉对象切片,这是一种在通过值而不是通过引用捕获异常时可能发生的副作用。最后,我们将深入研究 C++ 中的 RAII 技术,该技术消除了语言中 finally 构造的需求。

到本章结束时,您将彻底了解在 C++ 中处理错误的多种方式,并且您将熟悉创建容错代码的几种技术。

总结来说,我们将涵盖以下主题:

  • 使用 C++ 处理 POSIX API 的错误

  • 从错误代码到异常

好的,现在是时候开始了!

技术要求

本章中所有示例都在以下配置的环境中进行了测试:

使用 C++ 处理 POSIX API 的错误

在符合 POSIX 标准的系统(如 Unix 和 Linux)中,错误处理基于使用错误代码和错误消息在函数和应用程序之间通信错误。

通常情况下,当一个函数遇到错误时,它会返回一个非零错误代码并将全局变量 errno 设置为特定的错误值,以指示错误的性质。然后应用程序可以使用 errno 变量来确定错误的起因并采取适当的行动。

除了错误代码之外,POSIX 兼容的函数通常还提供描述错误性质的错误消息。这些错误消息通常使用 strerror 函数访问,该函数接受一个错误代码作为输入,并返回一个指向以空字符终止的字符序列的指针,包含相应的错误消息。

POSIX 错误处理风格要求开发者在每次可能失败的系统调用或函数调用之后检查错误,并以一致且有意义的方式处理错误。这可能包括记录错误消息、重试失败的操作或在发生关键错误时终止程序。

让我们看看以下示例,其中我们演示了如何使用 errno 变量和 strerror() 函数来处理 C++ 中 POSIX 函数的错误。

该示例使用 open()close() POSIX 函数,这些函数试图从我们的 Linux 测试环境的文件系统中打开和关闭文件:

#include <iostream>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
int main() {
    const int fd{open("no-such-file.txt", O_RDONLY)}; //
      {1}
    if (fd == -1) {
        std::cerr << "Error opening file: " <<
          strerror(errno) << '\n';
        std::cerr << "Error code: " << errno << '\n';
        return  EXIT_FAILURE;
    }
    // Do something with the file...
    if (close(fd) == -1) {
        std::cerr << "Error closing file: " <<
          strerror(errno) << '\n';
        std::cerr << "Error code: " << errno << '\n';
        return  EXIT_FAILURE;
    }
    return 0;
}

在这个例子中,我们尝试使用 open() 函数打开一个名为 no-such-file.txt 的文件进行读取;见标记 {1}。如果成功,open() 返回一个非负整数,这对应于成功打开的文件的文件描述符 ID。如果 open() 返回 -1,我们知道发生了错误,因此我们使用 strerror(errno) 打印错误信息,并返回 errno 的值,其中包含相应的错误代码。

如果 open() 成功,我们对文件进行一些操作,然后使用 close() 函数关闭它。如果 close() 返回 -1,我们再次使用 strerror(errno) 打印错误信息,并返回 errno 的值。

这是一种常见的 POSIX 函数错误处理技术。在发生错误的情况下,它们返回 -1 并将 errno 变量设置为相应的错误代码。errno 变量是一个 int 类型的 线程局部 可修改变量。这意味着在多线程环境中使用它是安全的。每个线程将有自己的副本,并且由该线程调用的 POSIX 方法将使用此实例来报告错误。

为了在发生错误的情况下打印有意义的消息,我们使用 strerror() 函数,该函数接受一个整数并尝试将其值与一组已知的系统特定错误代码描述相匹配。open() 函数可以报告多个错误,并根据发生的错误类型将不同的值设置为 errno。让我们看看示例的输出:

Error opening file: No such file or directory
Error code: 2

如我们所见,open() 方法未能打开文件,因为它不存在。在这种情况下,它将 errno 设置为 2 的值,这对应于函数文档中指定的 ENOENT 值。在系统调用之前显式将 errno 设置为 0 是一种良好的做法,以确保调用之后可以读取其实际响应。

使用 std::error_code 和 std::error_condition

C++标准库为处理来自 POSIX 接口等低级 API 的错误提供了几个类。这些类是用于处理特定系统错误的std::error_code和用于处理可移植错误代码的std::error_condition。让我们更详细地探讨这两种风格。

std::error_code

让我们重新整理之前的例子,以便提供一个用于创建具有特定目录路径的目录的函数:

#include <iostream>
#include <sys/stat.h>
std::error_code CreateDirectory(const std::string& dirPath) {
    std::error_code ecode{};
    if (mkdir(dirPath.c_str(), 0777) != 0) {
        ecode = std::error_code{errno,
          std::generic_category()}; // {1}
    }
    return ecode;
}
int main() {
    auto ecode{CreateDirectory("/tmp/test")};
    if (ecode){ // {2}
        std::cerr << "Error 1: " << ecode.message() <<
          '\n';
    }
    ecode = CreateDirectory("/tmp/test"); // {3}
    if (ecode){
        std::cerr << "Error 2: " << ecode.message() <<
          '\n';
    }
    if (ecode.value() == EEXIST) {
        std::cout << "This is platform specific and not
          portable.\n";
    }
    return 0;
}

与我们新函数的客户端CreateDirectory直接使用errno变量来确定操作是否成功不同,我们将利用标准库提供的实用类std::error_codestd::error_code用于存储和传输由库或系统调用生成的错误代码。它是一种包装类,其中预定义了用于处理错误的错误类别。POSIX 函数返回的错误大多是标准的,因此它们在标准库中是预定义的。因此,从errno值创建一个std::error_code实例并指定该值对应于std::generic_category(),就像在先前的例子中的标记{1}所做的那样,是直截了当的。errno值实际上被转换成了std::errc枚举器的一个常量。

创建的std::error_code对象有两个方法可以提供有关底层错误的信息。std::error_code::message()方法返回一个有意义的字符串,可用于日志记录。在我们的例子中,std::error_code::value()方法返回最初存储在errno变量中的值。但用户可能最值得注意的是std::error_code对象中预定义的operator bool()。如果对象中存储了错误,它返回true;否则返回false

如前例所示,CreateCategory()方法的调用者检查是否发生了错误,如果是,则获取存储在此错误中的消息;请参阅标记{2}。在这里,您可以找到在我们测试环境中运行的程序输出:

Error 2: File exists
This is platform specific and not portable.

如程序输出所示,第一次CreateDirectory()调用成功,但第二次调用失败;请参阅标记{3}。这是因为CreateDirectory()的实现首先检查是否存在这样的目录,如果不存在,则为我们创建它。但如果目录已存在,mkdir()系统调用返回-1并将errno设置为EEXIST

关于std::error_code类的一个重要事实是它是平台特定的。这意味着存储在其中的错误值强烈依赖于底层操作系统。在类似 POSIX 的系统(如 Linux)的情况下,我们有的错误值是EEXIST。但这对其他操作系统不一定成立。

因此,如果我们设计我们的代码尽可能不依赖于平台,我们需要避免以下比较:

if (ecode.value() == EEXIST)

但我们还需要一种方法来确保已存在的目录不会破坏我们的程序逻辑。是的,从 POSIX 的角度来看这是一个错误,但就我们的特定业务逻辑而言,这并不是程序执行继续的问题。

std::error_condition

解决这个问题的正确方法是有助于另一个标准库类——std::error_condition。正如其名所示,它的主要目的是提供条件程序逻辑。让我们对前面示例中的CreateDirectory()方法进行轻微的重构:

std::error_code CreateDirectory(const std::string& dirPath) {
    std::error_code ecode{};
    if (mkdir(dirPath.c_str(), 0777) != 0) {
        std::errc cond{errno}; // {1}
        ecode = std::make_error_code(cond); // {2}
    }
    return ecode;
}

如您所见,与上一个示例的不同之处在于我们构建error_code对象的方式。在重构的代码中,我们首先创建一个std::errc类型的对象,并用 POSIX errno的值初始化它;参见标记 {1}std::errc是一个作用域枚举类。它定义了与特定 POSIX 错误码相对应的可移植错误条件。这意味着我们不再依赖于与特定 POSIX 错误码相对应的平台特定宏,例如EEXIST,而是切换到一个无论来自哪个平台都将具有相同错误条件的错误。

重要提示

你可以在这里找到std::errc作用域枚举器预定义的可移植错误条件,它们对应于它们的等效 POSIX 错误码:en.cppreference.com/w/cpp/error/errc

一旦我们创建了std::errc的一个实例,我们就将其传递给创建错误码的工厂方法——std::make_error_code()(参见标记 {2})——它为我们生成一个通用类别的std::error_code

现在,让我们看看main()方法是如何被修改以实现平台无关性的:

int main() {
    auto ecode{CreateDirectory("/tmp/test")};
    if (ecode){
        std::cerr << "Error 1: " << ecode.message() <<
          '\n';
    }
    ecode = CreateDirectory("/tmp/test");
    if (ecode){
        std::cerr << "Error 2: " << ecode.message() <<
          '\n';
    }
    if (ecode == std::errc::file_exists) { // {3}
        std::cout << "This is platform agnostic and is
          portable.\n";
    }
    return 0;
}

我们仍然调用了两次CreateDirectory()方法,第二次调用仍然返回了一个error_code。但主要的不同之处在于我们比较ecode对象的方式;参见标记 {3}。我们不是将其与 POSIX 错误码的整数值进行比较,而是将其与一个包含可移植错误条件的对象进行比较——std::errc::file_exists。它具有相同的语义,表示文件已存在,但它具有平台无关性。在下一节中,我们将看到这有多么有用。

使用自定义错误类别

每个软件开发者都应该尽可能努力编写可移植的代码。编写可移植的代码提供了可重用性,这可以显著降低开发成本。当然,这并不总是可能的。有些情况下,你编写的代码是针对特定系统定制的。但对于所有其他情况,将你的代码从底层系统中抽象出来,可以让你轻松地将它迁移到其他系统,而无需进行大量重构以使其工作。这更安全,也更经济。

让我们回到我们之前的例子,我们试图抽象从 POSIX 系统调用接收到的错误代码。它应该可以与可移植的错误条件,如 std::errc::file_exists 进行比较。我们将通过以下用例扩展这一点。想象一下,我们有一个自定义库,它也处理文件。让我们称它为 MyFileLibrary。但这个库不支持 POSIX 错误代码。它提供了一种不同的 类别 的自定义错误代码,这些代码在语义上对应于一些 POSIX 代码,但具有不同的错误值。

该库支持以下错误及其相应的错误代码:

enum class MyFileLibraryError {
    FileNotFound = 1000,
    FileAlreadyExists = 2000,
    FileBusy = 3000,
    FileTooBig = 4000
};

如您所见,我们的库可以返回 FileAlreadyExists 枚举常量,就像 mkdir() 系统调用一样,但具有不同的错误值 – 1000。因此,消耗 MyFileLibrarymkdir() 的主要逻辑应该能够以相同的方式处理这些错误,因为它们在语义上是相等的。让我们看看如何做到这一点。

在我们之前的例子中,我们创建了 POSIX API 返回的错误代码:

ecode = std::error_code{errno, std::generic_category()};

我们使用了 std::generic_category,它是从基类别类 – std::error_category 派生出来的。它在我们标准库中是预定义的,这样它就 知道 POSIX 错误代码。这实际上是 API 返回的实际错误代码与 std::error_condition 之间进行转换的地方。因此,为了使 MyFileLibrary 具有相同的特性,我们需要定义一个新的 std::error_category 派生类。我们将它命名为 MyFileLibraryCategory

class MyFileLibraryCategory : public std::error_category {
public:
    const char* name() const noexcept override { // {1}
        return "MyFileLibrary";
    }
    std::string message(int ev) const override { // {2}
        switch (static_cast<MyFileLibraryError>(ev)) {
        case MyFileLibraryError::FileAlreadyExists:
            return "The file already exists";
        default:
            return "Unsupported error";
        }
    }
    bool equivalent(int code,
                    const std::error_condition& condition)
                      const noexcept override { // {3}
        switch (static_cast<MyFileLibraryError>(code)) {
        case MyFileLibraryError::FileAlreadyExists:
            return condition == std::errc::file_exists; //
                {4}
        default:
            return false;
        }
    }
};

std::error_category 基类有几个 方法,如果派生类中重写了这些方法,则允许自定义行为。在我们的例子中,我们重写了以下方法:

  • name() 方法,用于报告该错误属于哪个类别;参见标记 {1}

  • message() 方法,用于报告与特定错误值相对应的消息字符串;参见标记 {2}

  • equivalent() 方法,用于将我们的库生成的自定义错误代码与预定义的 std::error_condition 值进行比较

equivalent() 方法获取自定义错误代码,将其转换为 MyFileLibraryError 类型的值,并为每个特定情况决定它匹配的 condition;参见标记 {3}

现在,既然我们有了我们新的、闪亮的自定义错误类别 – MyFileLibraryCategory – 让我们看看如何使用它:

const MyFileLibraryCategory my_file_lib_category{}; // {1}
int main() {
    std::error_code file_exists{static_cast<int>
      (MyFileLibraryError::FileAlreadyExists),
       my_file_lib_category}; // {2}
    if (file_exists == std::errc::file_exists) { // {3}
        std::cout << "Msg: " << file_exists.message() <<
          '\n'; // {4}
        std::cout << "Category: " << file_exists
          .default_error_condition().category().name() <<
             '\n'; // {5}
    }
    return 0;
}

我们需要采取的第一步是实例化我们自定义类别的对象;参见标记 {1}。然后,我们创建一个 error_code 实例,将其初始化为 FileAlreadyExists 错误值,并指定它属于 MyFileLibraryCategory 类别;参见标记 {2}。由于我们有一个有效的错误代码实例 – file_exists – 我们现在可以将其与平台无关的 std::errc::file_exists 错误条件进行比较。

以下为程序的输出:

Msg: The file already exists
Category: MyFileLibrary

正如您所看到的,借助我们定义的自定义错误类别MyFileLibraryCategory,现在可以比较由MyFileLibrary生成的错误和通用的std::errc::file_exists。相应的错误消息会显示出来(见标记 {3}),以及类别本身(见标记 {4})。

重要提示

在这里,您可以找到std::error_category基类公开的所有虚拟方法的完整描述:en.cppreference.com/w/cpp/error/error_category

现在我们已经熟悉了错误代码和错误条件的使用,让我们看看如何使用 C++异常的强大机制来传播错误。

从错误代码到异常

异常处理是编程的一个重要方面,尤其是在处理可能破坏程序正常流程的错误时。虽然代码库中处理错误的方法有多种,但异常提供了一种强大的机制,以分离错误流程和正常程序流程的方式来处理错误。

当处理错误代码时,确保所有错误情况都得到适当处理并且代码保持可维护性可能具有挑战性。通过将错误代码封装在异常中,我们可以创建一种更实用的错误处理方法,这使得推理代码和集中捕获错误变得更加容易。

在处理代码库中的错误处理时,很难说哪种方法更好,而使用异常的决定应基于实用考虑。虽然异常可以在代码组织和可维护性方面提供显著的好处,但它们可能带来性能上的惩罚,这在某些系统中可能不可接受。

在本质上,异常是一种将正常程序流程与错误流程分离的方法。与可以忽略的错误代码不同,异常不容易被忽视,这使得它们成为确保以一致和集中方式处理错误的一种更可靠的方法。

虽然异常可能不是每个代码库的最佳选择,但它们提供了一种强大的错误处理方式,可以使代码更容易维护和推理。通过了解如何正确使用异常,程序员可以就如何在代码中处理错误做出明智的决定。让我们更深入地探讨这一点。

std::system_error

在上一节中,我们创建了一个程序,该程序正确处理了 POSIX 系统调用mkdir()报告的错误。现在,让我们看看如何使用异常而不是错误代码来改进这个程序中的错误处理。以下是重访的CreateDirectory()方法:

void CreateDirectory(const std::string& dirPath) { // {1}
    using namespace std;
    if (mkdir(dirPath.c_str(), 0777) != 0) {
        const auto ecode{make_error_code(errc{errno})}; //
           {2}
        cout << "CreateDirectory reports error: " <<
          ecode.message() << '\n';
        system_error exception{ecode}; // {3}
        throw exception; // {4}
    }
}

CreateDirectory() 方法中,我们使用 mkdir() API 进行系统调用,如果失败,则返回非零结果并将 POSIX 错误代码存储在 errno 变量中。到目前为止没有什么新的。就像我们之前的例子一样,我们从一个值创建一个 std::error_code(参见标记 {2})来向我们的 CreateDirectory() 方法的调用者报告它。但与直接返回函数的结果错误不同,我们更愿意使用异常来做这件事,并使我们的函数 {1}

由于我们已经有了一个错误代码对象被创建,我们将使用它来从它创建一个异常。为了做到这一点,我们将使用标准库中的一个预定义的异常类,该类明确定义为封装 std::error_code 对象 – std::system_error

std::system_error 是从 C++ 标准库的 std::exception 接口类派生出来的派生类型。它被各种库函数使用,这些函数通常与 OS 功能接口,可以通过生成 std::error_codestd::error_condition 来报告错误。

图 5.1 – std::system_error 异常的继承图

图 5.1 – std::system_error 异常的继承图

在我们的例子中,为了创建一个 std::system_error 对象,我们必须将其构造函数传递给 std::error_code ecode 的实例,这是我们之前创建的;参见标记 {3}

与从标准库的基异常类 std::exception 派生出来的任何其他异常一样,std::system_error 有一个 what() 方法。它的目的是报告一个有意义的字符串,解释异常背后的错误细节。更具体地说,它调用封装的 std::error_code 对象的 message() 方法,并返回其结果。

由于我们已经有了一个新的、闪亮的异常对象被创建,我们现在需要抛出它回到我们的 API 的调用者那里。这是通过 throw 关键字完成的;参见标记 {4}。一个重要的提示是,我们通过抛出异常对象;我们不抛出对它的引用或指针。

重要提示

通常情况下,尽可能通过值传递抛出异常。

异常相对于错误代码的一个关键优点是它们不能被调用者省略。当一个函数返回一个错误代码时,决定是否检查返回值的是函数的调用者。有些情况下,返回值没有检查可能是由于错误,这会导致程序中的错误。当使用异常作为错误处理机制时,没有这样的可能性。一旦抛出异常,它就会沿着调用栈向上传播,直到被适当的程序异常处理逻辑捕获或达到函数栈的顶部。如果在传播路径的任何地方都没有捕获到异常,也称为栈展开,那么它将通过调用 std::terminate 函数来终止程序。

重要提示

查看以下 std::system_error 参考页面:en.cppreference.com/w/cpp/error/system_error.

现在,让我们回到我们的示例,看看 main() 方法应该如何重新设计以处理 CreateDirectory() 方法抛出的异常:

int main() {
    try {
        CreateDirectory("/tmp/test"); // First try succeeds
        CreateDirectory("/tmp/test"); // Second try throws
    } catch (const std::system_error& se) { // {5}
        const auto econd{se.code()
          .default_error_condition()}; // {6}
        if (econd != std::errc::file_exists) { // {7}
            std::cerr << "Unexpected system error: " <<
              se.what() << '\n';
            throw; // {8}
        }
        std::cout << "Nothing unexpected, safe to
          continue.\n";
    }
    return 0;
}

与错误代码不同,一旦函数返回,就需要分配和检查,异常需要被捕获,并应采取适当的行动。在 C++ 中,通过语言中的 try-catch 构造来捕获异常。在前面的示例中,你可以看到我们调用了两次 CreateDirectory() 方法,因为第二次调用将生成一个错误,该错误将作为异常向上传播。这个异常将被标记 {5} 中的 catch 子句捕获。正如你所看到的,catch 子句期望一个参数,指定应该捕获什么;参见标记 {5}。其语法类似于函数的参数列表,其中你可以通过值或引用传递对象。

在我们的示例中,我们通过常量引用捕获了 CreateDirectory() 方法抛出的异常。我们不通过值捕获的原因是避免不必要的对象复制,更重要的是,避免对象切片。我们很快将深入了解 C++ 中异常捕获技术的具体细节,但现在,让我们专注于我们的当前示例。一旦捕获到异常,我们就可以从中提取 error_condition 对象;参见标记 {6}。这是可能的,因为 system_error 类支持错误代码和错误条件,并使我们能够获取它们。当我们有 error_condition 时,我们可以成功地对已知的 errc 代码进行检查,以确定这个异常是否是我们程序的真实问题,或者它可以被忽略;参见标记 {7}

重要提示

在可能的情况下,通过引用(优先使用常量引用)而不是通过值来捕获异常,以避免潜在的切片对象和由于对象复制而产生的额外开销。

我们的业务程序逻辑预期,报告文件已存在的错误是正常的,不应该中断程序执行。最终,它表示我们尝试创建一个已存在的目录,这是可以接受的,我们可以继续。但如果错误是其他我们不知道如何处理的情况,那么我们必须报告这个错误并将其重新抛出到调用堆栈中的上层方法,这些方法可能更知道如何处理这种类型的错误。这是通过语言中的 throw 子句完成的;参见标记 {8}。这里的一个重要细节是,为了重新抛出现有的异常而不是抛出一个新的异常,你必须只使用带有无参数throw;

重要提示

使用不带参数的 throw; 子句来重新抛出现有的异常。

当然,如果错误是我们预期的,比如std::errc::file_exists,那么我们可以安全地继续程序执行,而不需要重新抛出这个异常。你可以按照以下方式找到程序的输出:

CreateDirectory reports error: File exists
Nothing unexpected, safe to continue.

我们可以看到异常是由CreateDirectory()方法抛出的,并在main()方法的catch子句中被捕获。在这个例子中,我们看到使用异常而不是错误码可以清楚地分离正常程序执行路径和错误路径,并使得重新抛出我们无法正确处理的错误变得更容易。

按值抛出,按引用捕获

在 C++中,我们实际上可以抛出每一个对象。你可以这样成功做到:

throw 42;

前面的语句抛出了一个值为42的整数对象。但仅仅因为你能够做某事,并不意味着这样做是个好主意。异常的目标是提供错误发生的上下文。抛出42的值并没有提供太多上下文,对吧?对于你的异常接收者来说,42意味着什么?并不多!

这个说法得到了由 C++标准委员会的一些关键成员开发的核心指南项目的充分证实。无论你的专业水平如何,C++核心指南都是每个 C++开发者的一个非常有用的指南。它汇集了关于 C++不同特性的建议和最佳实践。

重要提示

确保熟悉 C++核心指南,你可以在isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#c-core-guidelines找到它。

C++核心指南指出,我们必须确保抛出有意义的异常。如果你没有为你的情况定义一个标准的异常,你可以抛出一个从某些标准异常派生的用户定义类型:

isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#e14-use-purpose-designed-user-defined-types-as-exceptions-not-built-in-types

C++核心指南还建议我们按值抛出异常,并按引用捕获它们。当然,如果能按常量引用捕获那就更好了。按值抛出确保抛出的对象的生存期将由你的系统运行时管理。否则,如果你抛出一个指向你已在堆上分配的对象的指针,而这个对象的责任是在不再需要时删除它,那么你最终可能会遇到内存泄漏:

isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#e15-throw-by-value-catch-exceptions-from-a-hierarchy-by-reference

让我们通过一个例子来分析。我们将定义一个方法——Throw()——它通过值抛出一个带有错误代码——bad_file_descriptorstd::system_error 异常:

void Throw() {
    using namespace std;
    throw system_error{make_error_code
      (errc::bad_file_descriptor)};
}

此方法将由 main() 方法调用,我们将在此捕获抛出的异常:

int main() {
    using namespace std;
    try {
        try {
            Throw(); // {1}
        } catch (runtime_error e) { // {2}
            throw e; // {3}
        }
    } catch (const exception& e) { // {4}
        const system_error& se{dynamic_cast<const
          system_error&>(e)}; // {5}
        const auto econd{se.code()
          .default_error_condition()};
        std::cerr << econd.message() << '\n';
    }
    return 0;
}

如前例所示,我们定义了两个 try-catch 块——一个内层和一个外层。这样做的原因是,在同一个 try-catch 块中抛出的异常不能被另一个 catch 子句捕获。它们会被传播出去,因此,为了捕获它们,我们需要一个外部的 try-catch 块。

在标记 {1} 中,我们调用了 Throw() 方法,它抛出了一个异常。但在标记 {2} 中,我们捕获了这个抛出的异常。实际上,我们没有直接捕获 std::system_error,而是捕获了它的父类——std::runtime_error。你也可以看到,我们通过 runtime_error e 的方式通过值捕获了这个异常。

一旦捕获到 runtime_error 异常,我们采取的唯一行动就是通过以下语句将其从内层 try-catch 块中抛出:

throw e;

在重新抛出现有异常时,一定要小心。上面的语句没有重新抛出catch 子句中捕获的异常,而是抛出了一个新的 runtime_error 异常实例,它是捕获到的异常的副本。

一旦抛出了新的异常,它就会被标记 {4} 中的外部 catch 子句捕获。正如你所见,我们遵循了 C++ 核心指南的建议,捕获了一个常量引用而不是标准库的基类异常类——std::exception——它也是 std::runtime_error 的基类。

catch 子句中,我们尝试将其向下转换为原始类型——std::system_error——并打印其 std::error_condition 中的消息。让我们看看程序的输出:

terminate called after throwing an instance of 'std::bad_cast'
  what():  std::bad_cast

但令人惊讶的是,我们没有得到预期的结果。向下转换失败了,当它失败时,会生成一个标准异常——std::bad_cast——这个异常从外部 catch 子句中被抛出。但这个异常没有被另一个 try-catch 块保护,因此它传播出了 main() 方法,这在事实上是程序函数栈的顶部。正如我们之前解释的,如果在函数栈向上传播过程中没有捕获到异常,那么将会调用 std::terminate 函数。

但为什么当我们尝试向下转换为 std::system_error 时,转换失败了?原因是 Throw() 方法抛出了 std::system_error,理论上一切应该正常工作。嗯,应该是这样的,但实际上并没有。让我们深入探讨这个问题。

Throw() 方法实际上是通过值抛出一个 std::system_error 实例。但内层 catch 子句通过值捕获了一个基类异常,并抛出了它的一个副本:

catch (runtime_error e) {
    throw e;
}

这导致了一个问题,因为我们重新抛出的对象不再是std::system_error的实例。它已经被裁剪到其基类——std::runtime_error。所有原本属于原始 std::system_error对象的信息现在不再是新创建的副本std::runtime_error——e类型的一部分。

因此,向下转换为std::system_error失败,我们的程序终止。

总结来说,我们可以通过遵循通过值抛出异常、通过引用捕获异常以及尽可能重新抛出现有异常而不是它们的副本的规则来成功防止这类错误。

try/catch … finally

你可能已经注意到,在 C++语言中,我们有try-catch块,但没有finally构造。如果你有 C#或 Java 等语言的经验,你将习惯于使用finally子句来释放你使用过的资源。但这种方法只适用于异常情况,其中try子句在finally的使用之前。

但在没有finally的情况下,我们如何在 C++中实现呢?让我们回顾一下使用open()close() POSIX 函数打开和关闭文件的初始示例:

int main() {
    try {
        const int fd{open("/tmp/cpp-test-file", O_RDONLY)};
          // {1}
        if (fd == -1) { return errno; }
        // Do something with the file and suddenly
          something throws {2}
        if (close(fd) == -1) { return errno; } // {3}
    } catch (...) {
        std::cerr << "Something somewhere went terribly
          wrong!\n";
        return -1;
    }
    return 0;
}

如同我们在本章前面讨论的那样,使用open() POSIX 方法打开文件会返回文件描述符的 ID,如果函数成功打开文件;否则,就像许多 POSIX 函数一样,它返回-1;见标记 {1}

一旦你的文件被打开,确保它在最终关闭时的责任就落在了你身上。因此,我们在main()方法的末尾调用close()方法来确保文件在离开main()之前被关闭(见标记 {3})。但是,你怎么能确保在关闭文件之前不会发生某些异常情况,不会抛出异常呢?实际上,唯一可以确保这种情况不会发生的情况是如果你的系统中不支持异常。但在我们的测试 Linux 环境中,情况并非如此。更糟糕的是,在实际的代码库中工作,很难确保你在正常业务逻辑执行过程中调用的某些方法不会抛出异常。

想象一下,如果你的程序在关闭文件之前抛出异常会发生什么;见标记 {2}。实际上,你将泄露资源。作为一个经验法则,我们永远不应该泄露资源,无论这会不会导致问题。

但如果没有语言中的finally子句,我们如何保护自己不泄露资源呢?让我们来看看最典型的 C++编程技术之一:

void Throw() {
    cout << "Ops, I need to throw ...\n";
    throw system_error{make_error_code
      (errc::bad_file_descriptor)};
}
int main() {
    const string_view myFileName{"/tmp/cpp-test-file"}; //
      {1}
    ofstream theFile(myFileName.data()); // {2}
    try {
        file_guard guard(myFileName, O_RDONLY); // {3}
        const auto fd = guard.getFileDescriptor();
        Throw(); // {4}
    } catch (const exception& e) {
        cout << e.what();
        return -1;
    }
    return 0;
}

我们已经重新设计了main()方法,使其仅创建一个文件(见标记 {2}),并将文件名(见标记 {1})传递给一个新的file_guard类型对象(见标记 {3}),我们稍后将对其进行探讨。file_guard对象负责以特定名称打开和关闭文件:

using namespace std;
class file_guard final {
public:
    file_guard(string_view file, mode_t mode) : // {5}
        fd{open(file.data(), mode)}
    {
        if (fd == -1) {
            throw system_error
              {make_error_code(errc{errno})};
        }
        cout << "File '" << file <<
        "' with file descriptor '" <<
        fd << "' is opened.\n";
    }
    explicit file_guard(const file_guard&) = delete; // {6}
    file_guard& operator=(const file_guard&) = delete;
    explicit file_guard(file_guard&& other) noexcept : //
      {7}
        fd{move(other.fd)} { other.fd = -1; }
    file_guard& operator=(file_guard&& other) noexcept
    {
        fd = move(other.fd);
        other.fd = -1;
        return *this;
    }
    int getFileDescriptor() const noexcept { // {8}
        return fd;
    }
    ~file_guard() noexcept { // {9}
        if (fd != -1) {
            close(fd);
            cout << "File with file descriptor '" << fd <<
              "' is closed.\n";
        }
    }
private:
    int fd;
};

类在其构造函数中获取文件路径和文件应打开的模式;见标记 {5}。在构造函数的初始化列表中,调用 POSIX 的open()方法。结果是文件描述符 ID,被分配给类的_fd成员。如果open()失败,异常将从file_guard构造函数抛出。在这种情况下,我们不需要担心关闭文件,因为我们没有成功打开它。

在类的析构函数中,我们有相反的操作;见标记 {9}。如果文件描述符不是-1,这意味着在该之前文件已被成功打开,我们将关闭它。

这种 C++编程技术被称为资源获取即初始化,或简称RAII。它是一种资源管理技术,在 RAII 对象的构造过程中获取资源,并在该对象的销毁过程中释放资源。与使用自动垃圾回收且资源释放时机对用户来说并不完全清晰的 Java 和 C#等语言不同,C++对象具有精确定义的存储持续时间和生命周期。因此,我们可以依赖这一特性,并利用 RAII 对象来管理我们的资源。

回到我们的main()方法,如果文件已打开(见标记 {3}),但在显式关闭之前发生错误,我们可以确信一旦file_guard对象超出作用域,它将被自动关闭。

这种技术无论系统是否有异常处理机制都广泛使用。您可以使用 RAII(Resource Acquisition Is Initialization,资源获取即初始化)封装资源,并确保它们将在离开 RAII 对象的作用域时自动释放。

在我们的file_guard示例中,我们移除了拷贝构造函数和拷贝赋值运算符,只留下了移动构造函数和移动运算符,声称这个 RAII 对象是不可拷贝的。

C++经常被质疑没有finally构造。然而,C++的发明者 Bjarne Stroustrup 解释说,RAII 是一个更好的替代品:www.stroustrup.com/bs_faq2.xhtml#finally

Stroustrup 认为,在实际的代码库中,资源获取和释放的操作要多得多,使用 RAII 而不是finally会导致代码更少。此外,它更不容易出错,因为 RAII 包装器只需要编写一次,而且不需要手动释放资源。

标准库中有许多 RAII 对象的示例,例如std::unique_ptrstd::lock_guardstd::fstreams

摘要

本章介绍了在 C++中使用 POSIX API 进行错误处理的各种技术。我们讨论了errno的使用,这是一个线程局部变量,以及strerror函数。我们还探讨了std::error_codestd::error_condition如何封装 POSIX 错误,以及自定义错误类别如何使我们能够比较不同来源生成的错误,并开发平台无关的错误处理代码。此外,我们还深入探讨了 C++中的异常,以及如何将std::error_code转换为std::system_error类型的异常。

我们还探讨了处理异常的最佳实践,例如通过值抛出异常并通过引用捕获异常,以避免对象切片等问题。最后,我们学习了 C++中的 RAII 技术,它消除了在语言中需要finally结构的需要。

在下一章中,我们将探讨使用 C++的并发主题。

第二部分:系统编程的高级技术

在这部分,你将学习关于 C++20 高级特性的专业知识,这将进一步提高你的操作系统和 C++开发技能。虽然示例仍然实用,但变得更加复杂,需要一些关于系统编程主题的初步了解。

本部分包含以下章节:

  • 第六章**,使用 C++进行并发系统编程

  • 第七章**,进行进程间通信

  • 第八章**,在 Linux 中使用时钟、定时器和信号

  • 第九章**,理解 C++内存模型

  • 第十章**,在系统编程中使用 C++协程

第六章:使用 C++ 进行并发系统编程

在本章中,我们将探讨并发意味着什么,以及它与并行性的区别。我们将了解进程和线程的基本原理和理论。我们将探讨 C++ 内存模型的变化,这些变化强制语言支持原生并发。我们还将熟悉什么是竞态条件,它如何导致数据竞争,以及如何防止数据竞争。接下来,我们将熟悉 C++20 的 std::jthread 原语,它使多线程支持成为可能。我们将了解 std::jthread 类的细节,以及如何使用 std::stop_source 原语停止已运行的 std::jthread 实例。最后,我们将学习如何同步并发代码的执行,以及如何从执行的任务中报告计算结果。我们将学习如何使用 C++ 同步原语,如 屏障闩锁 来同步并发任务的执行,以及如何使用 承诺未来 正确报告这些任务的结果。

总结来说,在本章中,我们将涵盖以下主题:

  • 什么是并发?

  • 线程与进程

  • 使用 C++ 进行并发

  • 揭秘竞态条件和数据竞争

  • 实践多线程

  • 并行执行期间共享数据

那么,让我们开始吧!

技术要求

本章中的所有示例都在以下配置的环境中进行了测试:

什么是并发?

现代汽车已经成为高度复杂的机器,不仅提供交通功能,还提供各种其他功能。这些功能包括信息娱乐系统,允许用户播放音乐和视频,以及加热和空调系统,调节乘客的气温。考虑一个这样的场景,这些功能不能同时工作。在这种情况下,驾驶员必须在开车、听音乐或保持舒适气候之间做出选择。这并不是我们对汽车的期望,对吧?我们期望所有这些功能都能同时可用,增强我们的驾驶体验,提供舒适的旅程。为了实现这一点,这些功能必须并行运行。

但是它们真的并行运行,还是只是并发运行?有什么区别吗?

在计算机系统中,并发并行在某些方面是相似的,但它们并不相同。想象一下,你有一些工作要做,但这些工作可以分成几个独立的小部分。并发指的是多个工作部分在重叠的时间间隔内开始、执行和完成,没有保证特定的执行顺序。另一方面,并行是一种执行策略,这些部分在具有多个计算资源的硬件上同时执行,例如多核处理器。

当多个工作部分,我们称之为任务,在一定时间内以未指定的顺序执行时,就会发生并发。操作系统可能会运行一些任务,并强制其他任务等待。在并发执行中,任务持续争取执行槽位,因为操作系统不保证它们会一次性执行所有任务。此外,在任务执行过程中,它可能会突然被暂停,另一个任务开始执行。这被称为抢占。很明显,在并发任务执行中,任务的执行顺序是没有保证的。

让我们回到我们的汽车例子。在现代汽车中,信息娱乐系统负责同时执行许多活动。例如,它可以在运行导航部分的同时让你听音乐。这是可能的,因为系统可以并发地运行这些任务。它在处理音乐内容的同时运行与路线计算相关的任务。如果硬件系统只有一个核心,那么这些任务应该并发运行:

图 6.1 – 并发任务执行

图 6.1 – 并发任务执行

从前面的图中,你可以看到每个任务以不可预测的顺序获得非确定性的执行时间。此外,没有保证你的任务会在下一个任务开始之前完成。这就是抢占发生的地方。当你的任务正在运行时,它突然被暂停,另一个任务被安排执行。请记住,任务切换不是一个便宜的过程。系统消耗处理器的计算资源来执行这个动作——进行上下文切换。结论应该是这样的:我们必须设计我们的系统来尊重这些限制。

另一方面,并行是一种并发形式,涉及在独立的处理单元上同时执行多个操作。例如,具有多个 CPU 的计算机可以并行执行多个任务,这可以导致性能的显著提升。你不必担心上下文切换和抢占。尽管如此,它也有其缺点,我们将详细讨论。

图 6.2 – 并行任务执行

图 6.2 – 并行任务执行

回到我们的汽车例子,如果信息娱乐系统的 CPU 是多核的,那么导航系统的相关任务可以在一个核心上执行,而音乐处理任务可以在其他一些核心上执行。因此,你不需要采取任何行动来设计你的代码以支持抢占。当然,这只有在你能确定你的代码将在这样的环境中执行时才成立。

并发与并行之间的基本联系在于,并行性可以应用于并发计算,而不会影响结果的准确性,但仅仅并发并不保证并行性。

总结来说,并发是计算中的一个重要概念,它允许同时执行多个任务,尽管这并不保证。这可能会导致性能提升和资源利用效率的提高,但代价是代码更加复杂,需要尊重并发带来的陷阱。另一方面,从软件角度来看,真正并行执行代码更容易处理,但必须由底层系统支持。

在下一节中,我们将熟悉 Linux 中执行线程和进程之间的区别。

线程与进程

在 Linux 中,进程是正在执行程序的实例。一个进程可以有一个或多个执行线程。线程是可以在同一进程内独立于其他线程执行的指令序列。

每个进程都有自己的内存空间、系统资源和执行上下文。进程之间是隔离的,默认情况下不共享内存。它们只能通过文件和进程间通信IPC)机制,如管道、队列、套接字、共享内存等来通信。

另一方面,线程是进程内的轻量级执行单元。从非易失性内存中加载指令到 RAM 或甚至缓存的开销已经由创建线程的进程——父进程——承担。每个线程都有自己的堆栈和寄存器值,但共享父进程的内存空间和系统资源。因为线程在进程内共享内存,所以它们可以轻松地相互通信并同步自己的执行。一般来说,这使得它们在并发执行方面比进程更有效率。

图 6.3 – IPC

图 6.3 – IPC

进程和线程之间的主要区别如下:

  • 资源分配:进程是独立的实体,拥有自己的内存空间、系统资源和调度优先级。另一方面,线程与它们所属的进程共享相同的内存空间和系统资源。

  • 创造与销毁:进程是由操作系统创建和销毁的,而线程是由它们所属的进程创建和管理的。

  • 上下文切换:当发生上下文切换时,操作系统会切换整个进程上下文,包括其所有线程。相比之下,线程上下文切换只需要切换当前线程的状态,这在一般情况下更快,资源消耗也更少。

  • 通信和同步:使用管道、队列、套接字和共享内存等 IPC 机制来启用进程间的通信。另一方面,线程可以通过在同一个进程内共享内存来直接通信。这也使得线程之间的同步变得高效,因为它们可以使用锁和其他同步原语来协调对共享资源的访问。

重要提示

Linux 在内核中调度任务,这些任务要么是线程,要么是单线程进程。每个任务都通过内核线程来表示;因此,调度器不会区分线程和进程。

进程和线程在现实生活中有它们的类比。假设你正在与一组人合作完成一个项目,项目被划分为不同的任务。每个任务代表需要完成的工作单元。你可以将项目视为进程,每个任务视为线程。

在这个类比中,进程(项目)是一系列需要完成以实现共同目标的关联任务集合。每个任务(线程)是一个独立的工作单元,可以被分配给特定的人来完成。

当你将一项任务分配给某个人时,你就在项目中(进程)创建了一个新的线程。被分配任务(线程)的人可以独立地工作,而不会干扰到他人的工作。他们还可以与其他团队成员(线程)沟通,协调他们的工作,就像进程内的线程可以相互沟通一样。他们还需要使用共同的资源来完成他们的任务。

相反,如果你将项目划分为不同的项目,你将创建多个进程。每个进程都有自己的资源、团队成员和目标。确保这两个进程共享项目完成所需的资源变得更加困难。

因此,计算机中的进程和线程分别类似于现实生活中的项目和任务。进程代表需要完成以实现共同目标的关联任务集合,而线程是独立的工作单元,可以被分配给特定的人来完成。

在 Linux 中,进程是具有自己内存和资源的程序实例,而线程是进程内的轻量级执行单元,它们共享相同的内存和资源。线程可以更有效地进行通信,更适合需要并行执行的任务,而进程提供了更好的隔离和容错能力。

考虑到所有这些,让我们看看如何在 C++中编写并发代码。

C++的并发

C++ 语言自 C++11 起就内置了对管理并发线程的支持。但它没有为管理并发进程提供任何原生支持。C++ 标准库提供了各种用于线程管理、线程间同步和通信、保护共享数据、原子操作和并行算法的类。C++ 内存模型也是考虑到线程意识而设计的。这使得它成为开发并发应用程序的一个很好的选择。

使用 C++ 进行多线程是指在一个程序中同时运行多个执行线程的能力。这允许程序利用多个 CPU 核心,并行执行任务,从而加快任务完成速度并提高整体性能。

C++ 标准库引入了 std::thread 线程管理类。一旦实例化,用户就有责任处理线程的目标。用户必须选择是连接线程还是将其从父线程中分离。如果他们不加以管理,程序将终止。

随着 C++20 的发布,引入了一个全新的线程管理类 std::jthread。它使得创建和管理线程相对容易。要创建一个新线程,你可以创建 std::jthread 类的实例,传递你想要在单独线程中运行的函数或可调用对象。与 std::thread 相比,std::jthread 的一个关键优势是,你不必显式地担心将其连接。它将在 std::jthread 销毁时自动完成。在本章的后面部分,我们将更深入地探讨 std::jthread 以及如何使用它。

请记住,多线程也会使程序变得更加复杂,因为它需要仔细管理共享资源和线程同步。如果管理不当,多线程可能导致死锁和竞态条件等问题,这些问题可能导致程序挂起或产生意外的结果。

此外,多线程要求开发者确保代码是线程安全的,这可能是一项具有挑战性的任务。并非所有任务都适合多线程;有些任务如果尝试并行化,实际上可能会运行得更慢。

总体而言,使用 C++ 进行多线程可以在性能和资源利用方面提供显著的好处,但它也要求仔细考虑潜在挑战和陷阱。

现在,让我们熟悉一下编写并发代码最常见的陷阱。

揭秘竞态条件和数据竞争

在 C++中,多线程支持首次在 C++11 语言版本中引入。C++11 标准提供的关键元素之一是内存模型,它有助于促进多线程。内存模型解决两个问题:对象在内存中的布局以及对这些对象的并发访问。在 C++中,所有数据都由对象表示,这些对象是具有各种属性(如类型、大小、对齐、生命周期、值和可选名称)的内存块。每个对象在内存中保持一段时间,并存储在一个或多个内存位置中,这取决于它是一个简单的标量对象还是一个更复杂的数据类型。

在 C++的多线程编程中,考虑如何处理多个线程对共享对象的并发访问至关重要。如果两个或更多线程尝试访问不同的内存位置,通常不会有问题。然而,当线程尝试同时写入同一内存位置时,可能会导致数据竞争,这可能导致程序中出现意外的行为和错误。

重要提示

数据竞争发生在多个线程尝试访问数据,并且至少有一个线程尝试修改它,而没有采取预防措施来同步内存访问时。数据竞争可能导致程序中出现未定义的行为,并成为问题的来源。

但你的程序是如何出现数据竞争的?这发生在存在未妥善处理的竞态条件时。让我们来看看数据竞争和竞态条件的区别:

  • 竞态条件:一种代码的正确性取决于特定时间或严格操作顺序的情况

  • 数据竞争:当两个或更多线程访问一个对象,并且至少有一个线程修改它时

基于这些定义,我们可以推断出,你程序中发生的每一个数据竞争都是由于没有正确处理竞态条件的结果。但反之并不总是成立:并非每个竞态条件都会导致数据竞争。

通过查看示例来理解竞态条件和数据竞争是最佳方式。让我们想象一个原始的银行系统,一个非常原始的系统,我们希望它不存在于任何地方。

比尔和约翰在一家银行有账户。比尔账户中有 100 美元,约翰账户中有 50 美元。比尔总共欠约翰 30 美元。为了还清债务,比尔决定向约翰的账户进行两次转账。第一次转账 10 美元,第二次转账 20 美元。所以实际上,比尔将偿还约翰。两次转账完成后,比尔账户中剩下 70 美元,而约翰账户累计达到 80 美元。

让我们定义一个包含账户所有者姓名和他们在某一时刻的账户余额的Account结构:

struct Account {
    Account(std::string_view the_owner, unsigned
      the_amount) noexcept :
        balance{the_amount}, owner{the_owner} {}
    std::string GetBalance() const {
        return "Current account balance of " + owner +
                " is " + std::to_string(balance) + '\n';
    }
private:
    unsigned balance;
    std::string owner;
};

Account 结构中,我们还将添加 +=-= 重载操作符方法。这些方法分别负责向相应的账户存入或提取特定金额的钱。在每个操作之前和之后,都会打印出账户的当前余额。以下是这些操作符的定义,它们是 Account 结构的一部分:

Account& operator+=(unsigned amount) noexcept {
        Print(" balance before depositing: ", balance,
          owner);
        auto temp{balance}; // {1}
        std::this_thread::sleep_for(1ms);
        balance = temp + amount; // {2}
        Print(" balance after depositing: ", balance,
          owner);
        return *this;
    }
    Account& operator-=(unsigned amount) noexcept {
        Print(" balance before withdrawing: ", balance,
          owner);
        auto temp{balance}; // {1}
        balance = temp - amount; // {2}
        Print(" balance after withdrawing: ", balance,
          owner);
        return *this;
    }

查看操作符函数的实现过程可以看出,它们首先读取账户的当前余额,然后将其存储在一个局部对象中(标记为 {1}),最后,使用局部对象的值,根据指定的金额进行增加或减少。

简单到不能再简单了!

账户新余额的结果被写回到 Account 结构的 balance 成员中(标记为 {2})。

我们还需要定义一个负责实际钱款转账的方法:

void TransferMoney(unsigned amount, Account& from, Account& to) {
    from -= amount; // {1}
    to += amount; // {2}
}

它所做的唯一事情是从一个账户(标记为 {1})中提取所需金额,并将其存入另一个账户(标记为 {2}),这正是我们成功在账户之间转账所需要做的。

现在,让我们看看我们的 main 程序方法,它将执行我们的示例:

int main() {
    Account bill_account{"Bill", 100}; // {1}
    Account john_account{"John", 50}; // {2}
    std::jthread first_transfer{[&](){ TransferMoney(10,
      bill_account, john_account); }}; // {3}
    std::jthread second_transfer{[&](){ TransferMoney(20,
      bill_account, john_account); }}; // {4}
    std::this_thread::sleep_for(100ms); // {5}
    std::cout << bill_account.GetBalance(); // {6}
    std::cout << john_account.GetBalance(); // {7}
    return 0;
}

首先,我们需要为比尔和约翰创建账户,并分别存入 $100 和 $70(标记为 {1}{2})。然后,我们必须进行实际的钱款转账:一次转账 $10,另一次转账 $20(标记为 {3}{4})。我知道这段代码可能对你来说看起来不熟悉,但别担心,我们将在本章中很快深入探讨 std::jthread

目前你必须知道的重要细节是,我们试图通过 C++ 多线程库的帮助,使两次转账 并发 进行。在过程结束时,我们为两个执行线程设置了一些时间来完成钱款转账(标记为 {5})并打印结果(标记为 {6}{7})。正如我们之前讨论的,转账完成后,比尔应该在他的账户中有 $70,而约翰应该有 $80。

让我们看看程序输出:

140278035490560 Bill balance before withdrawing: 100
140278027097856 Bill balance before withdrawing: 100
140278027097856 Bill balance after withdrawing: 80
140278035490560 Bill balance after withdrawing: 90
140278027097856 John balance before depositing: 50
140278035490560 John balance before depositing: 50
140278027097856 John balance after depositing: 70
140278035490560 John balance after depositing: 60
Current account balance of Bill is 80
Current account balance of John is 60

等等,比尔有 $80,而约翰有 $60!这是怎么可能的?

这是因为我们创建了一个导致 竞争条件数据竞争!让我们来解释一下。更深入地查看 operator+= 方法的实现,我们可以发现问题。顺便说一下,其他操作符方法的情况也是完全相同的:

Account& operator+=(unsigned amount) noexcept {
    Print(" balance before withdrawing: ", balance, owner);
    auto temp{balance}; // {1}
    std::this_thread::sleep_for(1ms); // {2}
    balance = temp + amount; // {3}
    Print(" balance after withdrawing: ", balance, owner);
    return *this;
}

在标记 {1} 处,我们将账户的当前余额缓存到一个位于栈上的局部对象中。

重要提示

C++ 内存模型保证每个线程都有其自己的所有具有自动存储期的对象的副本——栈对象。

接下来,我们给当前执行线程至少1ms的休息时间(标记 {2})。通过这个语句,我们将线程置于休眠状态,允许其他线程(如果有)获取处理器时间并开始执行。到目前为止,没有什么可担心的,对吧?一旦线程重新开始执行,它将使用账户余额的缓存值,并增加新的金额。最后,它将新计算出的值存储回Account结构的balance成员。

仔细观察程序的输出,我们注意到以下内容:

140278035490560 Bill balance before withdrawing: 100
140278027097856 Bill balance before withdrawing: 100
140278027097856 Bill balance after withdrawing: 80
140278035490560 Bill balance after withdrawing: 90

第一次转账开始执行。它作为具有140278035490560标识符的线程的一部分运行。我们看到在第一次提款完成之前,第二次提款也开始执行。其标识符为140278027097856。第二次提款首先完成提款,使得比尔的银行账户余额变为 80 美元。然后,第一次提款重新开始。但接下来发生了什么?它并没有从比尔的账户中再取出 10 美元,而是实际上退还了 10 美元!这是因为第一个线程在已经缓存了初始账户余额 100 美元时被挂起。这创建了一个竞态条件。同时,第二次转账已经改变了账户余额,现在当第一个转账重新开始执行时,它已经使用过时的缓存值。这导致盲目地用过时的值覆盖了更新的账户余额。发生了数据竞态

我们如何避免它们?

幸运的是,C++编程语言提供了各种并发控制机制来解决这些挑战,例如原子操作、锁、信号量、条件变量、屏障等。这些机制有助于确保共享资源以可预测和安全的方式被访问,并且线程能够有效地协调以避免数据竞态。在接下来的章节中,我们将深入了解一些这些同步原语。

实践中的多线程

在计算机科学中,一个执行线程是一系列可以被操作系统调度器独立管理的代码指令序列。在 Linux 系统中,线程始终是进程的一部分。C++线程可以通过标准提供的多线程能力相互并发执行。在执行过程中,线程共享公共内存空间,与每个进程都有自己的内存空间不同。具体来说,进程的线程共享其可执行代码、动态和全局分配的对象,这些对象没有被定义为thread_local

Hello C++ jthread

每个 C++程序都至少包含一个线程,这就是运行int main()方法的线程。多线程程序在主线程执行的某个点上启动额外的线程。让我们看看一个简单的 C++程序,它使用多个线程将输出打印到标准输出:

#include <iostream>
#include <thread>
#include <syncstream>
#include <array>
int main() {
    std::array<std::jthread, 5> my_threads; // Just an
      array of 5 jthread objects which do nothing.
    const auto worker{[]{
        const auto thread_id = std::
           this_thread::get_id();  // 3
        std::osyncstream sync_cout{std::cout};
        sync_cout << "Hello from new jthread with id:"
                  << thread_id << '\n';
    }};
    for (auto& thread : my_threads) {
        thread = std::jthread{worker}; // This moves the
          new jthread on the place of the placeholder
    }
    std::osyncstream{std::cout} << "Hello Main program
      thread with id:" << std::this_thread::get_id() <<
        '\n';
    return 0; // jthread dtors join them here.
}

当程序启动时,进入 int main() 方法。到目前为止没有什么令人惊讶的。在执行开始时,我们在方法栈上创建了一个变量,称为 my_threads。它是一种 std::array 类型,其中包含五个元素。std::array 类型代表标准库中的一个容器,封装了 C 风格的固定大小数组。它具有标准容器的优点,例如知道自己的大小,支持赋值,随机访问迭代器等。与 C++ 中的任何其他数组类型一样,我们需要指定它包含什么类型的元素。在我们的例子中,my_threads 包含五个 std::jthread 对象。std::jthread 类是在 C++20 标准发布时引入到 C++ 标准库中的。它代表一个执行线程,就像 std::thread,它在 C++11 的发布中引入。与 std::thread 相比,std::jthread 的一些优点是它在销毁时会自动重新连接,并且在某些特定情况下可以被取消或停止。它定义在 <thread> 头文件中;因此,我们必须包含它才能成功编译。

是的,你问的是正确的问题!如果我们已经定义了一个 jthread 对象的数组,它们实际上执行什么工作呢?预期是每个线程都与一些需要完成的工作相关联。但在这里,简单的答案是 没有。我们的数组包含五个 jthread 对象,实际上并不代表一个执行线程。它们更像是一个占位符,因为当 std::array 被实例化时,如果没有传递其他参数,它也会使用它们的默认构造函数创建包含的对象。

现在我们定义一些线程可以与之关联的工作者。std::jthread 类接受任何 可调用 类型作为工作者。这类类型提供单个可调用的操作。这类类型的广泛例子包括函数对象和 lambda 表达式,我们已经在 第四章 中详细介绍了。在我们的例子中,我们将使用 lambda 表达式,因为它们提供了一种创建匿名函数对象(functors)的方法,这些对象可以内联使用或作为参数传递。C++11 中 lambda 表达式的引入简化了创建匿名函数对象的过程,使其更加高效和直接。以下代码展示了我们定义的工作者方法作为一个 lambda 表达式:

const auto worker{[]{
    const auto thread_id = std::this_thread::get_id();
    std::osyncstream sync_cout{std::cout};
    sync_cout << "Hello from new jthread with id:" <<
      thread_id << '\n';
}};

定义好的 lambda 表达式const auto worker{…};相当简单。它在函数栈上实例化。它没有输入参数,也不捕获任何外部状态。它所做的唯一工作是向标准输出打印jthread对象的 ID。C++标准并发支持库提供的每个线程都有一个与之关联的唯一标识符。std::this_thread::get_id()方法返回被调用的特定线程的 ID。这意味着如果这个 lambda 表达式被传递到几个不同的线程,它应该打印出不同的线程 ID。

由许多并发线程向std::cout打印可能会产生意想不到的结果。std::cout对象被定义为全局的、线程安全的对象,这确保了写入它的每个字符都是原子性的。然而,对于字符串等字符序列没有提供任何保证,并且当多个线程同时向std::cout写入字符串时,输出很可能是这些字符串的混合。嗯,这并不是我们真正想要的。我们期望每个线程都能完全打印出它的消息。因此,我们需要一个同步机制,确保将字符串写入std::cout是完全原子的。幸运的是,C++20 在<syncstream>标准库头文件中引入了一整套新的类模板,它提供了同步线程向同一个流写入的机制。其中之一是std::osyncstream。你可以像使用常规流一样使用它。只需通过传递std::cout作为参数来创建它的一个实例。然后,借助其std::basic_ostream& operator<<(...)类方法,你可以插入数据,就像常规流一样。保证一旦std::osyncstream对象超出作用域并被销毁,所有插入的数据都将被原子性地刷新到输出。在我们的例子中,sync_cout对象将在 lambda 即将完成其执行并离开作用域时被销毁。这正是我们想要的行为。

最后,我们准备给我们的线程分配一些工作去做。这意味着我们需要将工作 lambda 函数与my_threads数组中的五个线程关联起来。但是std::jthread类型只支持在其构造过程中添加工作方法。这就是为什么我们需要创建其他的jthread对象,并用它们替换my_threads数组中的占位符:

for (auto& thread : my_threads) {
    thread = jthread{worker}; // This moves the new jthread
      on the place of the placeholder
}

作为标准容器,std::array 本地支持基于范围的 for 循环。因此,我们可以轻松地遍历 my_threads 中的所有元素,并用已经具有相关工作者的新 jthread 对象替换它们。首先,我们创建具有自动存储期的新的 jthread 对象,并分配一个工作对象。在我们的情况下,对于每个新创建的线程,我们分配同一个工作对象。这是可能的,因为在当前情况下,jthread 类在 jthread 对象中复制了工作实例,因此每个 jthread 对象都得到了工作 lambda 的副本。当构造这些对象时,该过程是在调用者的上下文中执行的。这意味着在评估、复制或移动参数期间发生的任何异常都将抛出在当前的 main 线程中。

一个重要的细节是,新创建的 jthread 对象不会被复制到数组的现有元素中,而是被移动。因此,std::jthread 类隐式地删除了其复制构造函数和赋值运算符,因为将一个线程复制到已存在的线程中并没有太多意义。在我们的情况下,新创建的 jthread 对象将在现有数组元素的存储中创建。

当一个 jthread 对象被构造时,相关的线程立即开始执行,尽管可能由于 Linux 调度特性而有一些延迟。线程从构造函数参数指定的函数开始执行。在我们的例子中,这是与每个线程关联的工作 lambda。如果工作返回一个结果,它将被忽略,如果它通过抛出异常结束,则执行 std::terminate 函数。因此,我们需要确保我们的工作代码不抛出异常,或者我们捕获所有可抛出的异常。

当一个线程启动时,它开始执行其专用的工作。每个线程都有自己的函数栈空间,这保证了在工作者中定义的任何局部变量在每个线程中都有一个单独的实例。因此,const auto thread_id 在工作者中被初始化为不同的 ID,这取决于它是由哪个线程运行的。我们不需要采取任何预防措施来确保存储在 thread_id 中的数据的一致性。标准保证具有自动存储期的数据在线程之间不共享。

一旦所有 jthread 对象都创建完成,main 线程将并发地打印其 ID 以及其他线程的 ID。每个线程的执行顺序没有保证,一个线程可能被另一个线程中断。因此,确保代码能够处理潜在的抢占,并在所有情况下保持健壮性是很重要的:

std::osyncstream{std::cout} << "Hello Main program thread
  with id:" << std::this_thread::get_id() << '\n';

所有线程现在都与应用程序的主线程并发运行。我们需要确保主线程以线程安全的方式向标准输出打印。我们再次使用 std::osyncstream 的一个实例,但这次我们不创建一个命名变量——相反,我们创建一个临时变量。这种做法因其易用性而受到青睐,类似于使用 std::cout 对象。标准保证在每个语句结束时刷新输出,因为临时变量会持续到语句结束,并且它们的析构函数会被调用,从而导致输出刷新。

这里是程序的一个示例输出:

Hello from new jthread with id:1567180544
Hello from new jthread with id:1476392704
Hello from new jthread with id:1468000000
Hello Main program thread with id:1567184704
Hello from new jthread with id:1558787840
Hello from new jthread with id:1459607296

std::jthread 名称指的是一个连接线程。与 std::thread 不同,std::jthread 还具有自动连接它所启动的线程的能力。std::thread 的行为有时可能会令人困惑。如果 std::thread 没有被连接或分离,并且仍然被认为是可连接的,那么在其销毁时将会调用 std::terminate 函数。一个线程被认为是可连接的,如果既没有调用 join() 方法,也没有调用 detach() 方法。在我们的例子中,所有的 jthread 对象在销毁时会自动连接,并不会导致程序终止。

取消线程——这真的可能吗?

在 C++ 20 发布之前,这并不完全可能。不能保证 std::thread 是可停止的,因为没有一个标准的工具可以停止线程的执行。相反,使用了不同的机制。停止 std::thread 需要主线程和工作线程之间的协作,通常使用标志或原子变量或某种消息系统。

随着 C++20 的发布,现在有一个标准化的工具可以请求 std::jthread 对象停止它们的执行。停止令牌出现了。查看关于 std::jthread 定义的 C++ 标准参考页面(en.cppreference.com/w/cpp/thread/jthread),我们发现以下内容:

jthread 类代表一个单独的执行线程。它具有与 std::thread 相同的一般行为,除了在销毁时会自动重新连接,并且在某些情况下可以被取消/停止。”

我们已经看到 jthread 对象在销毁时会自动连接,但关于取消/停止以及“某些情况”是什么意思呢?让我们更深入地探讨这个问题。

首先,不要期望 std::jthread 揭示某种神奇机制,某种按下时可以停止正在运行的线程的红色按钮。这始终是实现的问题,你的工作函数是如何实现的。如果你想使你的线程可取消,你必须确保你以正确的方式实现了它,以便允许取消:

#include <iostream>
#include <syncstream>
#include <thread>
#include <array>
using namespace std::literals::chrono_literals;
int main() {
    const auto worker{[](std::stop_token token, int num){
      // {1}
        while (!token.stop_requested()) { // {2}
            std::osyncstream{std::cout} << "Thread with id
              " << num << " is currently working.\n";
            std::this_thread::sleep_for(200ms);
        }
        std::osyncstream{std::cout} << "Thread with id " <<
          num << " is now stopped!\n";
    }};
    std::array<std::jthread, 3> my_threads{
        std::jthread{worker, 0},
        std::jthread{worker, 1},
        std::jthread{worker, 2}
    };
    // Give some time to the other threads to start
      executing …
    std::this_thread::sleep_for(1s);
    // 'Let's stop them
    for (auto& thread : my_threads) {
        thread.request_stop(); // {3} - this is not a
          blocking call, it is just a request.
    }
    std::osyncstream{std::cout} < "Main thread just
      requested stop!\n";
    return 0; // jthread dtors join them here.
}

观察我们之前的工作线程 lambda 函数的定义,我们可以看到它现在略有修改(标记 {1})。它接受两个新的参数——std::stop_token tokenint num。停止令牌反映了 jthread 对象共享的停止状态。如果工作方法接受许多参数,那么停止令牌必须始终是第一个传递的参数。

确保工作方法能够处理取消操作是至关重要的。这就是停止令牌的作用所在。我们的逻辑应该以这种方式实现,以便定期检查是否收到了停止请求。这是通过调用 std::stop_token 对象的 stop_requested() 方法来完成的。每个具体的实现都决定在哪里以及何时进行这些检查。如果代码不尊重停止令牌的状态,那么线程就不能优雅地取消。因此,正确设计你的代码取决于你。

幸运的是,我们的工作线程 lambda 尊重线程停止令牌的状态。它持续检查是否收到停止请求(标记 {2})。如果没有,它将打印线程的 ID 并进入休眠状态 200ms。这个循环会一直持续到父线程决定向其工作线程发送停止请求(标记 {3})。这是通过调用 std::jthread 对象的 request_stop() 方法来完成的。

这是程序的输出:

Thread with id 0 is currently working.
Thread with id 1 is currently working.
Thread with id 2 is currently working.
Thread with id 1 is currently working.
Thread with id 2 is currently working.
Thread with id 0 is currently working.
Thread with id 1 is currently working.
Thread with id 2 is currently working.
Thread with id 0 is currently working.
Thread with id 2 is currently working.
Thread with id 1 is currently working.
Thread with id 0 is currently working.
Thread with id 1 is currently working.
Thread with id 0 is currently working.
Thread with id 2 is currently working.
Main thread just requested stop!
Thread with id 1 is now stopped!
Thread with id 0 is now stopped!
Thread with id 2 is now stopped!

既然我们已经知道了如何使用 std::stop_token 来停止特定 std::jthread 的执行,那么让我们看看如何使用单个停止源来停止多个 std::jthread 对象的执行。

std::stop_source

std::stop_source 类允许你为 std::jthread 发出取消请求。当通过 stop_source 对象发出停止请求时,它对所有与相同停止状态关联的其他 stop_sourcestd::stop_token 对象都是可见的。你只需要发出信号,任何消费它的线程工作器都会收到通知。

通过利用 std::stop_tokenstd::stop_source,线程可以异步地发出或检查停止执行请求。停止请求是通过 std::stop_source 来发出的,它影响所有相关的 std::stop_token 对象。这些令牌可以被传递给工作函数并用于监控停止请求。std::stop_sourcestd::stop_token 都共享停止状态的所有权。std::stop_source 类的方法 request_stop() 以及 std::stop_token 中的方法 stop_requested()stop_possible() 都是原子操作,以确保不会发生数据竞争。

让我们看看如何使用停止令牌来重新设计我们之前的示例:

#include <iostream>
#include <syncstream>
#include <thread>
#include <array>
using namespace std::literals::chrono_literals;
int main() {
    std::stop_source source;
    const auto worker{[](std::stop_source sr, int num){
        std::stop_token token = sr.get_token();
        while (!token.stop_requested()) {
            std::osyncstream{std::cout} << "Thread with id
              " << num << " is currently working.\n";
            std::this_thread::sleep_for(200ms);
        }
        std::osyncstream{std::cout} << "Thread with id " <<
          num << " is now stopped!\n";
    }};
    std::array<std::jthread, 3> my_threads{
        std::jthread{worker, source, 0},
        std::jthread{worker, source, 1},
        std::jthread{worker, source, 2}
    };
    std::this_thread::sleep_for(1s);
    source.request_stop(); // this is not a blocking call,
      it is just a request. {1}
    Std::osyncstream{std::cout} << "Main thread just
      requested stop!\n";
    return 0; // jthread dtors join them here.
}

main 方法从声明 std::stop_source 源开始,它将被 main 线程用来向所有子工作者线程发出信号并请求它们停止。工作者 lambda 被稍微修改,以便接受 std::stop_source sr 作为输入。这实际上是工作者被通知停止请求的通信通道。std::stop_source 对象被复制到所有与已启动线程相关的工作者中。

而不是遍历所有线程并对每个线程调用停止请求,我们需要的唯一操作是在 main 线程的源实例上直接调用 request_stop()(标记 {1})。这将向所有消费它的工作者广播停止请求。

如其名所示,在停止源对象上调用 request_stop() 方法只是一个请求,而不是阻塞调用。因此,不要期望你的线程在调用完成后立即停止。

下面是程序的示例输出:

Thread with id 0 is currently working.
Thread with id 1 is currently working.
Thread with id 2 is currently working.
Thread with id 1 is currently working.
Thread with id 2 is currently working.
Thread with id 0 is currently working.
Thread with id 1 is currently working.
Thread with id 2 is currently working.
Thread with id 0 is currently working.
Thread with id 1 is currently working.
Thread with id 0 is currently working.
Thread with id 2 is currently working.
Thread with id 1 is currently working.
Thread with id 0 is currently working.
Thread with id 2 is currently working.
Main thread just requested stop!
Thread with id 1 is now stopped!
Thread with id 0 is now stopped!
Thread with id 2 is now stopped!

现在,我们已经熟悉了在 C++ 中停止线程执行的两种机制。现在是时候看看我们如何能够在多个线程之间共享数据了。

并行执行期间的数据共享

以任务而非线程为思考方式 (isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#cp4-think-in-terms-of-tasks-rather-than-threads)。

回顾到 C++ 核心指南,它们建议我们最好坚持使用任务而不是线程。线程是一种技术实现理念,是对机器工作方式的一种看法。另一方面,任务是你想要执行的工作的实用概念,理想情况下与其他任务并行。一般来说,实用概念更容易理解,并且提供更好的抽象,我们更倾向于使用它们。

但在 C++ 中,任务是什么?它是另一个标准库原语吗?还是其他什么?让我们看看!

在 C++ 中,除了线程,还可以使用任务来异步执行工作。一个任务由一个工作器和两个相关组件组成:一个 promise 和一个 future。这些组件通过共享状态连接,这是一种数据通道。promise 执行工作并将结果放入共享状态,而 future 获取结果。promise 和 future 都可以在单独的线程中运行。future 的一个独特之处在于它可以在稍后时间检索结果,使得 promise 计算结果与相关 future 检索结果的操作独立。

图 6.4 – 线程间通信

图 6.4 – 线程间通信

标准库中定义的<future>头文件对于利用任务来说是必要的。它提供了获取在单独线程中执行的功能的结果的能力,也称为std::promise类,这些结果通过共享状态进行通信,异步任务可以在其中存储其返回值或异常。然后可以使用std::future访问这个共享状态以检索返回值或存储的异常。

让我们看看一个简单的例子,其中线程将其结果作为字符串报告给父线程:

#include <future>
#include <thread>
#include <iostream>
using namespace std::literals::chrono_literals;
int main() {
    std::promise<std::string> promise; // {1}
    std::future<std::string> future{promise.get_future()};
      // {2} – Get the future from the promise.
    std::jthread th1{[p{std::move(promise)}]() mutable { //
      {3} – Move the promise inside the worker thread.
        std::this_thread::sleep_for(20ms);
        p.set_value_at_thread_exit("I promised to call you
          back once I am ready!\n"); // {4}
    }};
    std::cout << "Main thread is ready.\n";
    std::cout << future.get(); // {5} – This is a blocking
      call!
    return 0;
}

正如我们之前讨论的,线程通过共享状态相互通信。在int main()方法中,我们声明std::promise<std::string> promise,这是我们事实上的数据源(标记 {1})。std::promise类是一个模板类,一旦实例化就需要进行参数化。在我们的例子中,我们希望工作线程std::thread th1返回一个字符串作为结果。因此,我们使用std::string类型实例化std::promise。我们还需要一种方式让main线程能够获取工作线程将要设置的结果。为了做到这一点,我们需要从已经实例化的承诺中获取一个std::future对象。这是可能的,因为std::promise类型有一个返回其相关未来的方法——std::future<...> get_future()。在我们的例子中,我们实例化了一个未来对象future,它通过承诺的get_future()方法初始化(标记 {2})。

由于我们已经有一个承诺及其相关的未来,我们现在可以准备将承诺作为工作线程的一部分进行移动。我们这样做是为了确保它不会被main线程再使用(标记 {3})。我们的工作线程相当简单,它只是休眠20ms并在承诺中设置结果(标记 {4})。std::promise类型提供了几种设置结果的方法。结果可以是承诺参数化的类型值,也可以是工作执行期间抛出的异常。值是通过set_value()set_value_at_thread_exit()方法设置的。这两种方法之间的主要区别在于,set_value()立即通知共享状态值已准备好,而set_value_at_thread_exit()则在线程执行完成后这样做。

同时,main线程的执行被阻塞,等待工作线程的结果。这是在调用future.get()方法时完成的。这是一个阻塞调用,等待线程在共享状态通知未来结果已设置之前被阻塞。在我们的例子中,这发生在工作线程完成后,因为只有当工作完成时共享状态才会被通知(标记 {5})。

程序的预期输出如下:

Main thread is ready.
I promised to call you back once I am ready!

障碍和锁

C++20 标准引入了新的线程同步原语。屏障和 latch 是简单的线程同步原语,它们会阻塞线程,直到计数器达到零。这些原语以 std::latchstd::barrier 类的形式由标准库提供。

这两种同步机制有什么区别?关键区别是 std::latch 只能使用一次,而 std::barrier 可以被多个线程多次使用。

屏障和 latch 相比 C++ 标准提供的其他同步原语(如条件变量和锁)有什么优势?屏障和 latch 更容易使用,更直观,在某些情况下可能提供更好的性能。

让我们看看以下示例:

#include <thread>
#include <iostream>
#include <array>
#include <latch>
#include <syncstream>
using namespace std::literals::chrono_literals;
int main() {
    std::latch progress{2}; // {1}
    std::array<std::jthread, 2> threads {
        std::jthread{&{
            std::osyncstream{std::cout} << "Starting thread
              " << num << " and go to sleep.\n";
            std::this_thread::sleep_for(100ms);
            std::osyncstream{std::cout} << "Decrementing
              the latch for thread " << num << '\n';
            progress.count_down(); // {2}
            std::osyncstream{std::cout} << "Thread " << num
              << " finished!\n";
        }, 0},
        std::jthread{&{
            std::osyncstream{std::cout} << "Starting thread
              " << num << ". Arrive on latch and wait to
                 become zero.\n";
            progress.arrive_and_wait(); // {3}
            std::osyncstream{std::cout} << "Thread " << num
              << " finished!\n";
        }, 1}
    };
    std::osyncstream{std::cout} << "Main thread waiting
      workers to finish.\n";
    progress.wait(); // {4} wait for all threads to finish.
    std::cout << "Main thread finished!\n";
    return 0;
}

我们有两个线程的数组,它们在 latch 上同步。这意味着每个线程开始执行并完成其工作,直到达到 latch。

std::latch 类是一种同步机制,它使用向下计数的计数器来协调线程。计数器在初始化时设置,并作为参数传递给构造函数。然后线程可以等待直到计数器达到零。一旦初始化,计数器就不能增加或重置。从多个线程并发访问 std::latch 的成员函数保证是线程安全的,并且没有数据竞争。

在我们的例子(标记 {1})中,我们使用 2 的值初始化了 latch,因为我们有两个工作线程需要与主线程同步。一旦工作线程达到 latch,它有三个选择:

  • 减少它并继续(标记 {2})。这是通过 std::latch 类的成员函数 – void count_down(n = 1) 来实现的。这个调用是非阻塞的,并自动将 latch 的内部计数器值减去 n。如果尝试使用负值或大于内部计数器当前值的值来减少,则行为是未定义的。在我们的例子中,这是一个 ID 为 0 的工作线程,一旦它准备好,就会减少 latch 计数器并完成。

  • 减少它并等待直到 latch 变为零(标记 {3})。为了做到这一点,你必须使用 std::latch 类的另一个方法 – void arrive_and_wait(n = 1)。这个方法一旦被调用,就会减少 latch 的值 n 并阻塞它,直到 latch 的内部计数器达到 0。在我们的例子中,这是一个 ID 为 1 的工作线程,一旦它准备好,就会开始等待,直到另一个工作线程完成。

  • 只需阻塞并等待闩锁的内部计数器变为零(标记 {4})。这是可能的,因为 std::latch 提供了一个方法——void wait() const。这是一个阻塞调用,调用线程会在闩锁的内部计数器达到零之前被阻塞。在我们的例子中,main 线程会阻塞并开始等待工作线程完成它们的执行。

我们程序的结果是 main 线程的执行被挂起,直到工作线程完成它们的任务。std::latch 类提供了一个方便的方式来同步多个线程的执行:

Main thread waiting workers to finish.
Starting thread 1\. Arrive on latch and wait to become zero.
Starting thread 0 and go to sleep.
Decrementing the latch for thread 0
Thread 0 finished!
Main thread finished!
Thread 1 finished!

std::latch 非常相似的同步原语是 std::barrier。屏障是线程同步原语,允许一组线程等待直到它们都达到一个特定的同步点。与闩锁不同,屏障可以被多次使用。一旦线程从同步点释放,它们可以重用屏障。同步点是一个特定时刻,线程可以暂停其执行,直到满足特定条件。这使得屏障非常适合同步重复任务或由多个线程执行同一更大任务的不同阶段。

为了更好地理解什么是屏障,让我们用一个例子来说明。想象一下,你在家里安装了一个温度传感器的网络。在每一个房间中,都安装了一个传感器。每个传感器在特定的时间段内进行温度测量,并将结果缓冲在其内存中。当传感器完成 10 次测量后,它会将它们作为一个数据块发送到服务器。这个服务器负责收集家中所有传感器的所有测量数据,并计算温度平均值——每个房间的平均温度和整个家的平均温度。

让我们讨论一下算法。为了计算你整个家的平均温度,我们首先需要处理传感器在某个特定时间段发送到服务器的温度测量数据。这意味着我们需要处理接收到的特定房间的所有温度样本,以计算该房间的平均温度,并且我们需要为家中的所有房间都这样做。最后,有了每个房间的计算出的平均温度,我们可以计算整个家的平均温度。

听起来我们需要处理大量的数据。尽可能地在数据处理中尝试并行化是有意义的。是的,你说得对:并非所有的数据处理都可以并行化!我们需要遵守一系列严格的行为顺序。首先,我们需要计算每个房间的平均温度。房间之间没有依赖关系,因此我们可以并行执行这些计算。一旦我们计算出所有房间的温度,我们就可以继续计算整个家的平均温度。这正是 std::barrier 会提供帮助的地方。

std::barrier 同步原语在特定的同步点(屏障)阻塞线程,直到所有线程到达。然后,它允许调用回调并执行特定操作。在我们的例子中,我们需要等待所有房间计算完成——等待在屏障上。然后,将执行回调,我们将计算整个家庭的平均温度:

using Temperature =
    std::tuple<std::string, // The name of the room
               std::vector<double>, // Temperature
                 measurements
               double>; // Calculated mean temperature
                        // value for a specific room
std::vector<Temperature> room_temperatures {
    {"living_room",{}, 0.0},
    {"bedroom", {}, 0.0},
    {"kitchen", {}, 0.0},
    {"closet", {}, 0.0}
};

让我们从定义我们的数据容器开始,我们将在此容器中存储每个房间进行的温度测量,以及工作线程计算出的平均值。我们将使用一个房间温度向量 room_temperature,在其中我们将存储房间名称、测量值向量以及平均值。

现在,我们需要定义将并行计算每个房间平均值的工人:

std::stop_source message;
std::barrier measurementBarrier{ // {1}
    static_cast<int>(room_temperatures.size()), // {2}
    [&message]() noexcept { // {3}
        // 1\. Compute the mean temperature of the entire
          home.
        // 2\. Push new temperature data
        // 3\. After 5 measurement cycles request stop.
    }
};
std::vector<std::jthread> measurementSensors;
for (auto& temp : room_temperatures) {
    measurementSensors.emplace_back([&measurementBarrier,
      &message, &temp](){
        const auto& token = message.get_token();
        while(!token.stop_requested()) {
            ProcessMeasurement(temp);
            measurementBarrier.arrive_and_wait(); // {4}
        }
    });
}

我们创建了与房间数量相同的 jthread 实例。每个 jthread 实例被创建,并分配了一个工作 lambda。正如你所看到的,工作 lambda 捕获了一个 std::stop_source 对象,该对象将用于通知它没有其他工作待处理,线程执行应该完成。lambda 还捕获了 std::barrier measurementBarrier,它将被用于阻塞每个已经准备好其计算的线程,直到所有其他线程也准备好(标记 {1})。

std::barrier 实例需要使用同步点的数量(标记 {2})进行初始化。这意味着当达到屏障的线程数量等于初始化值时,屏障将被提升。在我们的例子中,我们使用将要并发计算每个房间平均温度的工作线程数量来初始化屏障。屏障可以接受一个可选的初始化参数,即回调函数(标记 {3})。此函数不得抛出异常,因此我们将其标记为 noexcept。它将在所有线程到达屏障并提升屏障之前被调用。请注意,标准并未指定哪个线程将执行此回调。我们将使用此回调来完成以下操作:

  • 遍历所有已计算房间的平均温度,并计算整个家庭的平均温度。这是我们期望程序提供的结果。

  • 为工人线程提供下一次计算周期的新温度数据。与 std::latch 不同,std::barrier 允许我们根据需要多次使用同一个屏障。

  • 检查我们是否已经计算了整个家庭平均温度的五次,如果是这样,则通知工人他们需要优雅地停止并退出程序。

当一个线程开始工作并且它准备好进行计算时,它会遇到屏障(标记 {4})。这是可能的,因为 std::barrier 提供了一个方法:void arrive_and_wait()。这个调用实际上减少了屏障的内部计数器,通知它线程已经到达,并阻塞线程,直到计数器达到零并且触发屏障的回调。

在以下代码中,你可以找到负责生成示例温度值和计算平均温度值的函数:

void GetTemperatures(Temperature& temp) {
    std::mt19937 gen{std::random_device{}()};
    // Add normal distribution with mean = 20
    // and standard deviation of 8
    std::normal_distribution<> d{20, 8};
    auto& input_data{std::get<1>(temp)};
    input_data.clear();
    for (auto n{0}; n < 10; ++n) {
        // Add input data
        input_data.emplace_back(d(gen));
    }
}
void ProcessMeasurement(Temperature& temp){
    const auto& values{std::get<1>(temp)};
    auto& mean{std::get<2>(temp)};
    mean = std::reduce(values.begin(), values.end()) /
      values.size();
}

一旦我们有了所有代码片段,让我们看看我们程序的 main 方法实现:

int main() {
    // Init data
    std::ranges::for_each(room_temperatures,
      GetTemperatures);
    std::stop_source message;
    std::barrier measurementBarrier{
        static_cast<int>(room_temperatures.size()),
        [&message]() noexcept {
            // Get all results
            double mean{0.0};
            for (const auto& room_t : room_temperatures) {
                std::cout << "Mean temperature in "
                          << std::get<0>(room_t)
                          << " is " << std::get<2>(room_t)
                            << ".\n";
                mean += std::get<2>(room_t);
            }
            mean /= room_temperatures.size();
            std::cout << "Mean temperature in your home is
              " << mean << " degrees Celsius.\n";
            std::cout << "=======================
              ======================\n";
            // Add new input data
            std::ranges::for_each(room_temperatures,
              GetTemperatures);
            // Make 4 measurements and request stop.
            static unsigned timer{0};
            if (timer >= 3) {
                message.request_stop();
            }
            ++timer;
        }
    };
    std::vector<std::jthread> measurementSensors;
    for (auto& temp : room_temperatures) {
        measurementSensors.emplace_back
          ([&measurementBarrier, &message, &temp](){
            const auto& token = message.get_token();
            while(!token.stop_requested()) {
                ProcessMeasurement(temp);
                measurementBarrier.arrive_and_wait();
            }
        });
    }
    return 0;
}

对于我们示例中的输入温度数据,我们使用随机数生成器,它产生具有正态分布的数据。因此,我们得到以下输出:

Mean temperature in living_room is 18.7834.
Mean temperature in bedroom is 16.9559.
Mean temperature in kitchen is 22.6351.
Mean temperature in closet is 20.0296.
Mean temperature in your home is 19.601 degrees Celsius.
=============================================
Mean temperature in living_room is 19.8014.
Mean temperature in bedroom is 20.4068.
Mean temperature in kitchen is 19.3223.
Mean temperature in closet is 21.2223.
Mean temperature in your home is 20.1882 degrees Celsius.
=============================================
Mean temperature in living_room is 17.9305.
Mean temperature in bedroom is 22.6204.
Mean temperature in kitchen is 17.439.
Mean temperature in closet is 20.3107.
Mean temperature in your home is 19.5752 degrees Celsius.
=============================================
Mean temperature in living_room is 19.4584.
Mean temperature in bedroom is 19.0377.
Mean temperature in kitchen is 16.3529.
Mean temperature in closet is 20.1057.
Mean temperature in your home is 18.7387 degrees Celsius.
=============================================

在前面的示例中,我们展示了如何使用同步原语与 std::jthread 提供程序中的线程间同步。

概述

在本章中,我们探讨了与 C++ 中并发和并行相关的多个主题。我们首先讨论了术语和并发与并行之间的区别,包括抢占。然后,我们深入探讨了程序如何在单个和多个处理单元上执行,区分了进程和执行线程,并简要探讨了管道、套接字和共享内存等通信机制。

在 C++ 的背景下,我们考察了语言如何支持并发,特别是通过 std::thread 类和 C++20 中引入的新 std::jthread 原语。我们还讨论了与竞争条件和数据竞争相关的风险,包括一个货币转账操作的示例。为了避免这些问题,我们考察了诸如锁、原子操作和内存屏障等机制。

接下来,我们仔细研究了 std::jthread 类,探讨了其功能和正确用法。此外,我们还了解了 C++20 中引入的新同步流包装器,用于并发环境中的打印。我们还介绍了如何使用 std::stop_token 取消正在运行的线程,以及如何使用 std::stop_source 请求多个线程停止。

然后,我们将重点转向使用 std::futurestd::promise 从线程返回结果。此外,我们还讨论了 std::latchstd::barrier 的使用,通过温度站的示例演示了后者如何用于同步线程。

总体而言,我们探讨了与 C++ 中并发和并行相关的多个主题,从基本术语和概念到更高级的技术和机制,用于避免数据竞争和同步线程。但请保持关注,因为在下一章中,你将熟悉一些在软件编程中广泛使用的 IPC 机制。

第七章:继续进行进程间通信

上一章介绍了 C++20 的许多功能,允许你并行执行任务。除了全局变量外,它没有涵盖进程或线程之间的通信方式。在系统级别,大多数异步调用都源于进程和不同计算机系统之间的持续通信。

在本章中,你将了解 Linux 提供的进程间通信IPC)接口。通过它们,你将全面了解满足系统和软件需求的可能性。你将从学习消息队列MQs)作为对第三章(B20833_03.xhtml#_idTextAnchor047)中管道讨论的延续。此外,我们将详细分析信号量互斥锁同步技术的工作。我们将介绍一些易于使用的 C++20 新特性,你将不再需要自己实现这些特性。

这使我们能够继续使用共享内存技术,这将为你提供快速传输大量数据的选择。最后,如果你对网络中计算机系统的通信感兴趣,你将了解套接字和网络通信协议。通过这些,我们为你提供了一些实际命令来管理自己的网络系统。

我们将在本章开始的讨论基础上,在第九章(B20833_09.xhtml#_idTextAnchor129)中进行扩展。

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

  • 介绍 MQs 和发布/订阅机制

  • 通过信号量和互斥锁保证原子操作

  • 使用共享内存

  • 通过套接字进行网络通信

技术要求

要运行代码示例,你必须准备以下内容:

介绍 MQs 和发布/订阅机制

我们很高兴再次回到 IPC 主题。上次我们讨论它是在第三章,在那里我们解释了管道并使用了一些代码示例。你学习了进程间交换数据的基本机制,但正如你所记得的,有一些阻塞点。与任何编程工具一样,管道有特定的用途 – 它们速度快,可以帮助你从相关(派生)进程(通过匿名管道)和不相关进程(通过命名管道)发送和接收数据。

以类似的方式,我们可以使用 MQ 来传输数据,这些数据对相关和不相关的进程都是可用的。它们提供了向多个接收进程发送单个消息的能力。但正如你所见,管道在发送和接收二进制数据方面是原始的,而 MQ 则引入了 消息 的概念。传输策略仍然在调用过程中配置 – 队列名称、大小、信号处理、优先级等 – 但其策略和数据序列化的能力现在掌握在 MQ 的实现手中。这为程序员提供了一种相对简单灵活的方式来准备和处理数据消息。根据我们的软件设计,我们可以轻松实现异步发送-接收数据传输或 发布/订阅pub/sub)机制。Linux 为 MQ 提供了两个不同的接口 – 一个是为本地服务器应用程序设计的(来自 System V),另一个是为实时应用程序设计的(来自 POSIX)。出于本书的目的,我们更喜欢使用 POSIX 接口,因为它在配置上更丰富、更简洁。它也是一个基于文件的机制,如第一章中所述,你可以通过以下方式找到挂载的队列:

$ ls /dev/mqueue

此接口可通过操作系统实时函数库 librt 获取,因此在编译时需要链接它。MQ 本身可以表示如下:

图 7.1 – 通过 MQ 表示 IPC

图 7.1 – 通过 MQ 表示 IPC

让我们看看一个例子,其中我们从一个进程向另一个进程发送数据。示例数据已经存储在文件中,并加载到通过 MQ 发送。完整的示例可以在github.com/PacktPublishing/C-Programming-for-Linux-Systems/tree/main/Chapter%207找到:

constexpr auto MAX_SIZE = 1024;
string_view QUEUE_NAME  = "/test_queue";

我们将初始配置与队列名称一起作为路径名设置:

void readFromQueue() {
...
    mqd_t          mq   = { 0 };
    struct mq_attr attr = { 0 };
    array<char, MAX_SIZE> buffer{};
    attr.mq_flags = 0;
    attr.mq_maxmsg = 10;
    attr.mq_msgsize = MAX_SIZE;
    attr.mq_curmsgs = 0;
    if (mq = mq_open(QUEUE_NAME.data(), O_CREAT | O_RDONLY,
                     0700, &attr); mq > -1) { // {1}
        for (;;) {
            if (auto bytes_read = mq_receive(mq,
                                             buffer.data(),
                                             buffer.size(),
                                             NULL);
                                  bytes_read > 0) { // {2}
                buffer[bytes_read] = '\0';
                cout << "Received: "
                     << buffer.data()
                     << endl; // {3}
            }
            else if (bytes_read == -1) {
                cerr << "Receive message failed!";
            }

额外的配置应用于消息队列,并且接收端已准备就绪。通过调用 mq_open() 函数,在文件系统中创建消息队列并打开其读取端。通过一个无限循环,数据在从二进制文件中读取的同时被接收并打印出来(前述代码中的标记 {2}{3})。直到文件完全被消耗。然后,关闭接收端和读取端(以下代码中的标记 {4})。如果没有其他事情要做,可以通过 mq_unlink() 从文件系统中删除消息队列:

            else {
                cout << "\n\n\n***Receiving ends***"
                     << endl;
                mq_close(mq); // {4}
                break;
            }
        }
    }
    else {
        cerr << "Receiver: Failed to load queue: "
             << strerror(errno);
    }
    mq_unlink(QUEUE_NAME.data());
}

这个例子使用两个线程实现,但也可以用两个进程以同样的方式完成。消息队列的功能将保持不变。我们再次调用 mq_open() 并为写入打开消息队列(以下代码中的标记 {5})。创建的队列可以容纳多达 10 条消息,每条消息的大小为 1,024 字节——这是通过之前代码片段中的消息队列属性定义的。如果您不希望消息队列操作阻塞,可以在属性中使用 O_NONBLOCK 标志,或者在 mq_receive() 调用之前使用 mq_notify()。这样,如果消息队列为空,读取器将被阻塞,但 mq_notify() 会在消息到达时触发一个信号,进程将被恢复。

然后,使用测试数据打开本地存储的文件,并从中读取(以下代码中的标记 {6}{7})。在读取的同时(您也可以使用 std::ofstream),我们通过消息队列发送其内容(以下代码中的标记 {8})。消息具有可能的最小优先级,这意味着 0。在一个队列中有更多消息的系统,我们可以设置更高的优先级,并且它们将按降序处理。最大值可以从 sysconf(_SC_MQ_PRIO_MAX) 中看到,对于 Linux 来说,这是 32768,但 POSIX 强制从 0 到 31 的范围,以便与其他操作系统兼容。让我们检查以下代码片段:

void writeToQueue() {
...
   if (mq = mq_open(QUEUE_NAME.data(), O_WRONLY,
                     0700, NULL); mq > -1) { // {5}
        int fd = open("test.dat", O_RDONLY); // {6}
        if (fd > 0) {
            for (;;) {
                // This could be taken from cin.
                array<char, MAX_SIZE> buffer{};
                if (auto bytes_to_send =
                        read(fd,
                             buffer.data(),
                        buffer.size());
                             bytes_to_send > 0) { // {7}
                    if (auto b_sent =
                            mq_send(mq,
                                    buffer.data(),
                                    buffer.size(),
                                    0);
                                    b_sent == -1) {// {8}
                        cerr << "Sent failed!"
                             << strerror(errno);
                    }

然后,我们发送一个零大小的消息来指示通信的结束(标记 {9}):

...
                else if (bytes_to_send == 0) {
                    cout << "Sending ends...." << endl;
                    if (auto b_sent =
                            mq_send(mq,
                                    buffer.data(),
                                    0,
                                    0); b_sent == -1) {
                                    // {9}
                        cerr << "Sent failed!"
                             << strerror(errno);

结果如下(为了可读性,文件中的打印数据已缩减):

Thread READER starting...
Thread WRITER starting...
Sending ends....
Received: This is a testing file...
Received: ing fileThis is a testing file...
***Receiving ends***
Main: program completed. Exiting.

考虑到我们只有两个工作者——readFromQueue()writeToQueue(),这是一个非常简单的例子。消息队列允许我们扩展并执行多对多的通信。这种方法可以在许多嵌入式系统中找到,因为它也符合实时性,并且不需要使用任何同步原语。许多微服务架构和无服务器应用程序都依赖于它。在下一节中,我们将讨论基于消息队列的最流行的模式之一。

发布/订阅机制

你可能已经意识到,在扩展时,一个 MQ 可能会成为瓶颈。正如你在前面的例子中观察到的,存在消息计数和大小限制。另一个问题是,消息被消费后,它将从队列中移除——同一时间只能有一个消费者消费特定的消息。数据提供者(生产者)还必须管理正确的消息地址,这意味着添加额外的数据以帮助消费者识别消息是发送给谁的,每个消费者都必须遵循这一策略。

一种推荐的方法是为每个消费者创建一个单独的消息队列(MQ)。生产者事先知道这些 MQ,无论是在编译时(所有 MQ 都由系统程序员在数据段中列出)还是在运行时(每个消费者在启动时都会发送其 MQ 路径名,生产者将处理这些信息)。这样,消费者就订阅接收来自特定生产者的数据,而生产者则将其数据发布到它所知道的全部 MQ。因此,我们称这种机制为发布-订阅机制。

当然,具体的实现可能会有所不同,这取决于软件设计,但基本思想将保持不变。此外,可能有多个生产者向多个消费者发送数据,我们称这种情况为多对多实现。请看下面的图示:

图 7.2 – pub/sub 机制的 MQ 实现表示

图 7.2 – pub/sub 机制的 MQ 实现表示

随着我们向进程解耦的方向发展,我们的系统变得更加灵活。由于订阅者不需要花费计算时间来识别消息是否是针对他们的,因此更容易进行扩展。添加新的生产者或消费者而不打扰他人也很容易。MQ 是在操作系统级别实现的,因此我们可以将其视为一个健壮的进程间通信(IPC)机制。然而,一个可能的缺点是,生产者通常不会从订阅者那里收到任何健康信息。这导致 MQ 中充满了未消费的数据,生产者被阻塞。因此,在更抽象的级别上实现了额外的实现框架,以处理此类用例。我们鼓励你进一步研究观察者消息代理设计模式。内部开发的 pub/sub 机制通常建立在它们之上,而不一定通过 MQ。尽管如此,正如你可能已经猜到的,通过这样的机制发送大量数据将是一个缓慢的操作。因此,我们需要一个工具来快速获取大量数据。不幸的是,这需要额外的同步管理来避免数据竞争,类似于第六章。下一节将介绍同步原语。

通过信号量和互斥保证原子操作

让我们尝试放大共享资源,看看在 CPU 中会发生什么。我们将提供一个简单而有效的方法来解释数据竞争究竟从哪里开始。它们已经在第六章中进行了充分讨论。我们在这里学到的所有内容都应该被视为一种补充,但并发和并行处理的分析方法与之前相同。但现在,我们关注具体低级问题。

让我们仔细看看以下片段:

int shrd_res = 0; //Some shared resource.
void thread_func(){
    shrd_res ++;
    std::cout << shrd_res;
}

这是一段非常简单的代码,其中变量被增加并打印出来。根据 C++标准,这种修改在多线程环境中是未定义的行为。让我们看看它是如何做到的——而不是在这里通过进程的内存布局来分析,我们将并行分析其伪汇编代码:

...
int shrd_res = 0;      store 0
shrd_res++;            load value
                       add 1
                       store value
std::cout << shrd_res; load value
...

假设这个增加过程在一个线程函数中,并且有多个线程在执行它。加 1指令是在加载的值上执行的,而不是在shrd_res的实际内存位置上。前面的代码片段将被多次执行,并且很可能是并行的。如果我们注意到线程是一组指令,那么直观的想法是这些指令将以单一的方式执行。换句话说,每个线程例程应该在没有中断的情况下运行,这通常是情况。然而,我们应该记住一个小细节——CPU 被设计成保持较小的延迟。它不是为了数据并行而构建的。因此,从字面上讲,它的主要目标是加载大量的小型任务。我们的每个线程都在一个单独的处理器上执行;这可能是单独的 CPU、CPU 线程或 CPU 核心——这完全取决于系统。如果处理器的数量(CPU、核心或线程)小于N,那么剩余的线程预计会排队等待,直到有处理器空闲。

现在,初始线程的指令已经加载到那里并按原样执行。即使 CPU 核心在架构上相同,它们的目的是尽可能快地执行。这意味着由于多个硬件波动,它们在速度上并不期望相等。但shared_resource是一个变量,它……是一个共享资源。这意味着第一个到达并增加它的将会这样做,其他人会跟随。即使我们不在乎std::cout的结果(例如,打印顺序不再顺序进行),我们仍然有东西要担心。你可能已经猜到了!我们不知道我们将要增加的值是什么——是shared_resource的最后一个存储值,还是新增加的一个?这怎么可能发生?

让我们看看:

Thread 1: shrd_res++; T1: load value
                      T1: add 1
Thread 2: shrd_res++; T2: load value
                      T2: add 1
                      T2: store value
                      T1: store value

你是否理解了刚才发生的事情?Thread 1 的指令序列因为 Thread 2 的执行而被中断。现在,我们能预测将要打印的内容吗?这被称为 Thread 2 永远没有被执行,因为存储在 shared_resource 中的最后一个值将是:

T1: add 1

换句话说,我们丢失了一个增加。没有指令告诉 CPU 这两个过程必须分别调用并连续执行。应该很清楚,可能存在有限数量的指令组合,所有这些都会导致意外的行为,因为这取决于硬件的状态。这种操作被称为 非原子。为了正确处理并行性,我们需要依赖于 原子 操作!这是软件开发者的工作,要考虑这一点,并通知 CPU 这样的指令集。例如,互斥锁和信号量用于管理 原子 范围。我们将在下一节中详细分析它们的作用。

信号量

如果你做一个问卷调查,询问多个职业的人什么是 信号量,你会得到不同的答案。一个来自机场的人会告诉你这是一个通过使用旗帜来向某人发出信号的系统。一个警察可能会告诉你这只是一个交通灯。询问一个火车司机可能会得到类似的回答。有趣的是,这就是 我们的 信号量的来源。总的来说,这些答案应该向你暗示这是一个 信号 机制。

重要提示

编程信号量是由 Edsger Dijkstra 发明的,主要用于防止竞态条件。它们帮助我们指示资源是否可用,并计算给定类型的共享资源单元的数量。

与之前提到的信号机制一样,信号量不能保证代码无错误,因为它们不能阻止进程或线程获取资源单元——它们只是通知。就像火车可能会忽略信号并驶向被占用的轨道一样,或者汽车可能会在繁忙的十字路口继续行驶,这可能会造成灾难!再次强调,软件工程师的任务是找出如何使用信号量来确保系统的健康。因此,让我们开始使用它们。

Dijkstra 提供了我们围绕临界区的主要两个函数:P(S)V(S)。正如你可能知道的,他是荷兰人,所以这些函数的名称来自荷兰语中的 尝试增加 (probeervrhoog,分别),其中 S 是信号量变量。仅从它们的名称中,你就可以得到它们将要做什么的线索。让我们用伪代码来看看它们:

unsigned int S = 0;
V(S):
    S=S+1;
P(S):
    while(S==0):
        // Do nothing.
    S = S – 1;

因此,P(S)会无限期地检查信号量是否已发出资源可用的信号——信号量被增加。一旦S被增加,循环就会停止,信号量的值会减少,以便其他代码可以执行。根据增加的值,我们可以识别两种类型的信号量:二进制计数。二进制信号量常被误认为是互斥mutex)机制。逻辑是相同的——例如,资源是否可以自由访问和修改——但技术的本质是不同的,正如我们之前解释的,没有任何东西阻止一些不良的并发设计忽略信号量。我们将在稍后讨论这个问题,但现在,让我们关注信号量做了什么。在我们开始编写代码之前,让我们声明一下,在类 Unix 操作系统上存在一些信号量接口。使用的选择取决于抽象级别和标准。例如,并非每个系统都有 POSIX,或者它并没有完全公开。由于我们将专注于 C++20 的使用,我们将使用以下示例仅作为参考。以下示例的完整源代码可以在github.com/PacktPublishing/C-Programming-for-Linux-Systems/tree/main/Chapter%207找到。

让我们来看看 Linux 上的两个常见信号量接口。第一个是无名信号量——我们可以通过以下接口来展示它:

sem_t sem;
sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);
int sem_post(sem_t *sem);
int sem_wait(sem_t *sem);

sem变量是信号量,它分别通过sem_init()sem_destroy()进行初始化和销毁。P(S)函数由sem_wait()表示,而V(S)函数由sem_post()表示。还有sem_trywait(),如果你想在减少不立即发生时报告错误,以及sem_timedwait(),它是一个在减少可能发生的特定时间窗口内的阻塞调用。这看起来很清楚,除了初始化部分。你可能已经注意到了valuepshared参数。第一个显示了信号量的初始值。例如,二进制信号量可以是01。第二个更有趣。

如你所回忆,在第二章中,我们讨论了内存段。想象一下,如果我们创建的信号量在pshared上被用于这个目的。如果它被设置为0,那么信号量是进程本地的,但如果它被设置为非零值,那么它是进程间共享的。关键是创建一个在全局可见内存区域上的信号量,例如共享内存(shmem),包括文件系统作为一个共享资源池。以下是命名信号量的概述:

  • /dev/shm。我们将其视为文件。例如,以下代码将创建一个名为 /sem 且权限为 0644 的信号量——它只能由所有者读写,但其他人只能读取,并且它将在文件系统中可见,直到稍后通过代码删除:

    sem_t *global_sem = sem_open("/sem", O_CREAT, 0644,
      0);
    
  • P(S)V(S) 调用保持不变。完成之后,我们必须关闭文件,如果不再需要,则删除它:

    sem_close(global_sem);
    sem_unlink("/sem");
    

第一章所述,您可以看到 POSIX 调用遵循相同的模式,通过 <object>_open<object>_close<object>_unlink<object>_<specific function> 后缀。这使得它们的使用对每个 POSIX 对象都是通用的,正如您可能已经在本章早期观察到的那样。

简要说明一下,存在低级信号量,其中系统调用与 OS 类型紧密相关或基于直接 OS 信号操作。这种方法的实现和维护比较复杂,因为它们是特定的,被认为是微调。请随意研究您自己的系统。

C++ 信号量入门

有了这个想法,我们希望继续提升抽象级别,因此我们将讨论 C++ 的信号量对象。这是 C++20 中的一个新特性,当您想要使代码更加系统通用时非常有用。让我们通过 atomic<uint16_t> shared_resource 来查看它。如本节开头所述,信号量有助于任务同步,但我们需要一个数据竞争保护。atomic 类型确保我们遵循 C++ 内存模型,编译器将按照 std::memory_order 保持 CPU 指令的顺序。您可以回顾第六章以了解数据竞争的解释。

我们将继续创建两个全局 binary_semaphore 对象,以适当地同步访问(如乒乓球)。binary_semaphore 对象是 counting_semaphore 对象的别名,其最大值为 1。我们需要一个程序结束规则,因此我们将定义迭代次数的限制。我们将要求编译器通过 constexpr 关键字将其定义为常量。最后,但同样重要的是,我们将创建两个线程,它们将作为生产者(增加共享资源)和消费者(减少它)。让我们看看代码示例:

...
uint32_t shared_resource = 0;
binary_semaphore sem_to_produce(0);
binary_semaphore sem_to_consume(0);
constexpr uint32_t limit = 65536;

信号量被构建和初始化。我们继续处理线程。release() 函数增加一个内部计数器,这会向其他人发出信号(以下代码中的 {2} 标记,类似于 sem_post())。我们使用 osyncstream(cout) 来构建非交错输出。以下是生产者线程:

void producer() {
    for (auto i = 0; i <= limit; i++) {
        sem_to_produce.acquire(); // {1}
        ++shared_resource;
        osyncstream(cout) << "Before: "
                          << shared_resource << endl;
        sem_to_consume.release(); // {2}
        osyncstream(cout) << "Producer finished!" << endl;
    }
}

这是消费者线程:

void consumer() {
    for (auto i = 0; i <= limit; i++) {
        osyncstream(cout)  << "Waiting for data..."
                           << endl;
        sem_to_consume.acquire();
        --shared_resource;
        osyncstream(cout)  << "After: "
                           << shared_resource << endl;
        sem_to_produce.release();
        osyncstream(cout)  << "Consumer finished!" << endl;
    } }
int main() {
    sem_to_produce.release();
    jthread t1(producer); jthread t2(consumer);
    t1.join(); t2.join();}

在迭代过程中,我们根据 limit 会看到多次此输出:

Waiting for data...
Before: 1
Producer finished!
After: 0
Consumer finished!
...

回到代码的逻辑,我们必须强调,C++ 信号量被认为是轻量级的,并允许对共享资源进行多个并发访问。但请注意:提供的代码使用了 acquire()(标记 {1},类似于 sem_wait()),这是一个阻塞调用——例如,你的任务将会被阻塞,直到信号量被释放。你可以使用 try_acquire() 来实现非阻塞。我们依赖于这两个信号量来创建可预测的操作序列。我们通过释放生产者信号量(例如,主线程)来启动这个过程,这样生产者就会收到信号,首先开始工作。

代码可以通过移除 C++ 原语并添加上述系统调用到代码中的相同位置来修改,以使用 POSIX 信号量。此外,我们鼓励你使用一个信号量实现相同的效果。考虑使用辅助变量或条件变量。请记住,这样的操作会使同步变得异构,并且在大规模上难以管理。

当前的代码显然无法同步多个进程,与命名信号量不同,因此它实际上不是那里的替代品。我们还可以希望对共享资源的访问更加严格——例如,在并发环境中只有一个访问时刻。那么,我们就需要下一节中描述的互斥锁的帮助。

互斥(mutex)

互斥锁是一种来自操作系统操作的机制。共享资源也被称为临界区,需要无风险地访问,以避免竞态条件。一种允许在给定时刻只允许一个任务修改临界区,并排除其他任务相同请求的机制,称为互斥互斥锁。互斥锁由操作系统内部实现,对用户空间是隐藏的。它们提供了一种锁定-解锁的访问功能,并且被认为比信号量更严格,尽管它们被作为二进制信号量来控制。

重要提示

调用线程锁定资源,并被迫解锁它。没有保证系统中的更高实体能够覆盖锁定并解除并行功能的阻塞。建议尽快释放每个锁定,以允许系统线程扩展并节省空闲时间。

POSIX 互斥锁的创建和使用与未命名的信号量几乎相同:

pthread_mutex_t global_lock;
pthread_mutex_init(&global_lock, NULL);
pthread_mutex_destroy(&global_lock);
pthread_mutex_lock(&global_lock);
pthread_mutex_unlock(&global_lock);

函数名的模式再次被遵循,因此让我们关注 pthread_mutex_lock()pthread_mutex_unlock()。我们使用它们来锁定和解锁一个临界区以进行操作,但它们不能帮助我们处理事件序列。锁定资源只能保证没有竞态条件。如果需要,正确的事件序列是由系统程序员设计的。错误的事件序列可能会导致死锁活锁

  • 死锁:一个或多个线程被阻塞并且无法改变它们的状态,因为它们正在等待一个永远不会发生的事件。一个常见的错误是两个(或更多)线程相互循环——例如,一个线程正在等待共享资源 A,同时持有共享资源 B 的锁,而第二个线程持有 A 的锁,但会在 B 解锁时释放它。两者都将保持阻塞,因为它们都不会是第一个放弃资源的。即使没有互斥锁,这种行为也可能发生。另一个错误是两次锁定互斥锁,在 Linux 的情况下,这可以通过操作系统检测到。存在死锁解决算法,其中锁定多个互斥锁可能由于死锁而最初无法成功,但在有限次数的尝试后可以保证成功。

    NULL, but we could use them to decide on the mutex kind. The default one, known as a fast mutex, is not deadlock-safe. The recursive mutex type will not cause a deadlock; it will count the number of lock requests by the same thread. The error-checking mutex will detect and mark a double lock. We encourage you to give them a try.
    
  • 活锁:线程没有被阻塞,但同样,它们无法改变它们的状态,因为它们需要共享资源来继续前进。一个很好的现实世界例子是两个人面对面站在入口处。双方都会出于礼貌让开,但他们很可能会朝着对方的方向移动。如果发生这种情况,并且他们一直这样做,那么没有人会被阻塞,但与此同时,他们无法继续前进。

这两类错误都很常见,可以用信号量复制,因为它们也是阻塞的,并且很少出现在小规模系统中,那里它们很容易调试。只有几个线程时,代码的逻辑很容易跟踪,并且进程是可管理的。具有数千个线程的大规模系统同时执行了大量的锁。错误复制通常是一个坏时机和模糊的任务序列的问题。因此,它们很难捕捉和调试,我们建议你在锁定关键部分时要小心。

C++提供了一个灵活的锁接口。它不断升级,我们现在有几种行为可供选择。让我们对变量进行并行增加。为了清晰起见,我们使用increment()线程过程,类似于之前的代码,但我们用互斥锁替换了信号量。你可能已经猜到了,代码将防止竞争条件,但线程执行的顺序是未定义的。我们可以通过额外的标志、条件变量或简单的睡眠来安排这个顺序,但让我们保持这种方式进行实验。更新的代码片段如下:

...
uint32_t shared_resource = 0;
mutex shres_guard;
constexpr uint32_t limit = INT_MAX;

我们定义了我们的共享资源和互斥锁。让我们看看增加是如何发生的:

void increment() {
    for (auto i = 0; i < limit; i++) {
        lock_guard<mutex> lock(shres_guard); // {1}
        ++shared_resource;
    }
    cout << "\nIncrement finished!" << endl;
}
...

观察到的输出如下:

$ time ./test
Increment finished!
Increment finished!
real    3m34,169s
user    4m21,676s
sys     2m43,331s

很明显,在不使用多线程的情况下增加变量会比这个结果快得多。你甚至可以尝试运行它直到UINT_MAX

因此,前面的代码创建了一个全局可见的互斥锁,并使用一个unique_lock对象(标记 {1})来包装它。这类似于pthread_mutex_init(),它允许我们延迟锁定,执行递归锁定,转移锁定所有权,并在特定时间约束内执行解锁尝试。锁在其作用域块内有效——在当前示例中,它是线程过程的范围。锁拥有互斥锁的所有权。当它达到作用域的末尾时,锁被销毁,互斥锁被释放。你应该已经知道这种做法作为scoped_lock对象来锁定多个互斥锁,同时通过其设计避免死锁。

在使用互斥锁时,你还需要考虑其他一些事情。互斥锁达到内核级别。任务状态直接受到影响,多个锁将导致多个上下文切换。正如你之前回忆的那样,我们可能会在重新调度中浪费时间。这意味着操作系统需要从 RAM 中的一个内存区域跳转到另一个内存区域,只是为了加载另一个任务的指令。你必须考虑对你有利的事情:许多具有小范围的锁导致许多切换,或者少数具有较大作用域块的锁在更长的时间段内保持资源。

最终,我们的目标只是向 CPU 指示一个原子区域。如果你还记得,我们在信号量示例中使用了atomic模板。我们可以用atomic变量更新我们的代码,并移除互斥锁的锁定:

atomic<uint32_t> shared_resource = 0;

结果如下:

$ time ./test
Increment finished!
Increment finished!
real    0m0,003s
user    0m0,002s
sys     0m0,000s

如你所见,仅仅通过移除互斥锁就能显著提高时间效率。为了辩论的目的,你可以重新添加信号量,你仍然会观察到比互斥锁更快的执行速度。我们建议你查看代码的汇编代码,针对三种情况——只有atomic变量、有互斥锁和有信号量。你会发现atomic对象在指令层面上非常简单,并且在用户级别执行。由于它是真正的原子操作,CPU(或其核心)将在增加时保持忙碌。记住,任何解决数据竞争的技术都会固有地带来性能成本。最佳性能可以通过最小化需要同步原语的位置及其作用域来实现。

重要提示

C++20 为并发执行提供了令人兴奋的特性,例如jthread协程更新的原子类型合作取消。除了第一个之外,我们将在本书的后面部分查看其他特性。除了这些之外,Linux 还有用于使用 IPC 实体的系统调用,这些实体是为多进程数据交换而构建的。话虽如此,我们在尝试组合互斥锁、信号量、标志和条件变量之前,建议你考虑使用现有的异步工作机制。所有这些 C++和 Linux 特性都是为了以稳定的方式扩展并节省你设计解决方案的时间。

我们到目前为止所做的一切都是为了确保我们能够原子性地访问临界区。原子操作、互斥锁和信号量会给你这个——一种指示 CPU 指令作用域的方法。但还有两个问题:我们能做得更快、更轻量吗?原子操作意味着我们保持指令的顺序吗?第一个问题的答案是可能。第二个问题的答案是!现在我们有动力去深入了解 C++的内存模型内存顺序。如果你对此感兴趣,我们邀请你跳转到第九章,在那里我们讨论更多有趣的并发任务。现在,我们将继续通过shmem IPC机制讨论共享资源的话题。

使用共享内存

与管道一样,一旦消耗了 MQ 数据,数据就会丢失。双工消息数据复制会增加用户空间和内核空间之间的调用,因此预期会有开销。shmem机制是快速的。正如你在上一章和上一节中学到的,数据访问的同步是一个必须由系统程序员解决的问题,尤其是在出现竞态条件时。

重要说明:术语共享内存本身是模糊的。它是两个线程可以同时访问的全局变量吗?或者是一个多个 CPU 核心用作在彼此之间传输数据的共同基础的共享 RAM 区域?或者是一个文件系统中的文件,许多进程对其进行修改?很好的问题——感谢提问!一般来说,所有这些都是共享资源的一种,但当我们谈论术语内存时,我们应该真正考虑一个在主内存中可见的区域,其中多个任务可以使用它来交换和修改数据。不仅限于任务,还包括不同的处理器核心和核心复杂(如 ARM),如果它们可以访问相同的预定义内存区域。这些技术需要特定的配置文件——一个内存映射,它严格依赖于处理器,并且具有特定的实现。它提供了使用,例如,紧密耦合内存TCM)来加速频繁使用的代码和数据部分,或者将 RAM 的一部分用作核心之间的数据交换的 shmem 的机会。由于这太依赖于处理器,我们不会继续讨论它。相反,我们将继续讨论 Linux 的shmem IPC机制。

重要注意事项

进程将它们的部分虚拟内存分配为一个共享段。传统上,操作系统禁止进程访问彼此的内存区域,但 shmem 是一种机制,允许进程在 shmem 的边界内请求移除这种限制。我们使用它通过简单的读写操作或 POSIX 中已提供的函数快速摄取和修改大量数据,而通过 MQ 或管道是无法实现这种功能的。

与 MQs 相比,这里没有序列化或同步。系统程序员负责管理 IPC 的数据传输策略(再次)。但是,由于共享区域位于 RAM 中,我们减少了上下文切换,从而降低了开销。我们可以通过以下图来可视化它:

图 7.3 – 通过进程的内存段展示 Shmem

图 7.3 – 通过进程的内存段展示 Shmem

shmem 区域通常被描绘在两个进程的地址空间之间。目的是强调这个空间在进程之间是如何真正共享的。实际上,这取决于具体实现,我们将其留给内核 – 我们关心的是映射到 shmem 段本身。它允许两个进程同时观察相同的内容。那么,让我们开始吧。

了解 mmap()和 shm_open()

创建 shmem 映射的初始系统调用是shmget()。这适用于任何基于 Unix 的操作系统,但对于符合 POSIX 标准的系统,有更舒适的途径。如果我们想象我们在进程的地址空间和文件之间进行映射,那么mmap()函数将基本上完成这项工作。它是符合 POSIX 标准的,并且按需执行读取操作。你可以简单地使用mmap()来指向一个常规文件,但数据将在进程完成工作后仍然保留在那里。你还记得第三章中的管道吗?这里的情况类似。有与fork()一起使用的mmap()系统调用。

如果你有多独立进程,那么它们知道如何访问共享区域的唯一方式是通过其路径名。shm_open()函数将为你提供一个带有名称的文件,就像mq_open()所做的那样 – 你可以在/dev/shm中观察到它。这同样需要librt库。了解这一点后,你会直觉地认为我们通过文件系统操作限制了 I/O 开销和上下文切换,因为该文件位于 RAM 中。最后但同样重要的是,这种共享内存的大小是灵活的,在需要时可以扩大到几 GB。其限制取决于系统。以下示例的完整版本可以在 https://github.com/PacktPublishing/C-Programming-for-Linux-Systems/tree/main/Chapter 7 找到:

...
string_view SHM_ID      = "/test_shm";
string_view SEM_PROD_ID = "/test_sem_prod";
string_view SEM_CONS_ID = "/test_sem_cons";
constexpr auto SHM_SIZE = 1024;
sem_t *sem_prod; sem_t *sem_cons;
void process_creator() {
...
    if (int pid = fork(); pid == 0) {
        // Child - used for consuming data.
        if (fd = shm_open(SHM_ID.data(),
                          O_RDONLY,
                          0700); // {1}
            fd == -1) {
....

这个例子非常具体,因为我们故意使用了进程而不是线程。这使我们能够展示shm_open()(标记{1})的使用,因为不同的进程使用 shmem 的路径名(在编译时已知)来访问它。让我们继续读取数据:

        shm_addr = mmap(NULL, SHM_SIZE,
                        PROT_READ, MAP_SHARED,
                        fd, 0); // {2}
        if (shm_addr == MAP_FAILED) {
...
        }
        array<char, SHM_SIZE> buffer{};

我们可以使用互斥锁,但当前我们只需要一个进程向另一个进程发出信号,表明其工作已完成,所以我们应用了信号量(在之前的代码块中标记为{3}和{7})如下:

        sem_wait(sem_cons);
        memcpy(buffer.data(),
               shm_addr,
               buffer.size()); // {3}
        if(strlen(buffer.data()) != 0) {
            cout << "PID : " << getpid()
                 << "consumed: " << buffer.data();
        }
        sem_post(sem_prod); exit(EXIT_SUCCESS);

要使内存区域共享,我们使用带有MAP_SHARED选项的mmap()函数,并通过以下页面设置相应地标记读取器和写入器凭据:PROT_READPROT_WRITE(标记为{2}{6})。我们还使用ftruncate()函数设置区域的大小(标记为{5})。在给定的示例中,信息被写入 shmem,而有人必须读取它。这是一种单次生产者-消费者模式,因为写入完成后,写入者会给读取者时间(标记为{8}),然后 shmem 被设置为零(标记为{9})并删除(标记为{10})。现在,让我们继续讨论父进程的代码——数据的生产者:

    else if (pid > 0) {
        // Parent - used for producing data.
        fd = shm_open(SHM_ID.data(),
                      O_CREAT | O_RDWR,
                      0700); // {4}
        if (fd == -1) {
...
        res = ftruncate(fd, SHM_SIZE); // {5}

再次,shmem 区域被映射:

        if (res == -1) {
...
        shm_addr = mmap(NULL, SHM_SIZE,
                        PROT_WRITE, MAP_SHARED,
                        fd, 0); // {6}
        if (shm_addr == MAP_FAILED) {
...
        sem_wait(sem_prod);
        string_view produced_data
            {"Some test data, coming!"};
        memcpy(shm_addr,
               produced_data.data(),
               produced_data.size());
        sem_post(sem_cons);    // {7}
        waitpid(pid, NULL, 0); // {8}
        res = munmap(shm_addr, SHM_SIZE); // {9}
        if (res == -1) {
...
        fd = shm_unlink(SHM_ID.data()); //{10}
        if (fd == -1) {

如前所述,我们使用sem_open()命名的信号量(标记为{11})允许两个进程进行同步。我们无法通过本章前面讨论的信号量来完成,因为它们没有名称,只在单个进程的上下文中知名。最后,我们还将信号量从文件系统中删除(标记为{12}),如下所示:

...
}
int main() {
    sem_prod = sem_open(SEM_PROD_ID.data(),
                        O_CREAT, 0644, 0); // {11}
...
    sem_post(sem_prod);
    process_creator();
    sem_close(sem_prod); // {12}
    sem_close(sem_cons);
    sem_unlink(SEM_PROD_ID.data());
    sem_unlink(SEM_CONS_ID.data());
    return 0;
}

程序的结果如下:

PID 3530: consumed: "Some test data, coming!"

Shmem 是一个有趣的话题,我们将在第九章中再次回到这个话题。其中一个原因是 C++允许我们适当地封装 POSIX 代码,并使代码更安全。类似于第三章,在 C++代码中混合系统调用应该经过深思熟虑。但同样值得探讨memory_order的使用案例。如果jthreadscoroutines不适用于您的用例,那么目前讨论的同步机制,连同智能指针,为您提供了设计最适合您系统的最佳解决方案的灵活性。但在我们达到那里之前,我们首先需要讨论其他事情。让我们继续讨论计算机系统之间的通信。

通过套接字进行网络通信

如果管道、MQ 和 shmem 能够共同克服它们的问题,那么为什么我们还需要套接字?这是一个简单答案的伟大问题——我们需要它们在网络上不同系统之间进行通信。有了这个,我们就拥有了交换数据的完整工具集。在我们理解套接字之前,我们需要快速了解网络通信。无论网络类型或其介质如何,我们都必须遵循由开放系统互连OSI基本参考模型确立的设计。如今,几乎所有的操作系统都支持互联网协议IP)家族。与其他计算机系统建立通信的最简单方法就是使用这些协议。它们遵循分层,正如ISO-OSI模型所描述的,现在我们将快速看一下这一点。

OSI 模型的概述

OSI 模型通常表示如下表所示。系统程序员通常需要它来分析他们的通信在哪里受到干扰。尽管套接字旨在执行网络数据传输,但它们也适用于本地进程间通信(IPC)。一个原因是通信层,尤其是在大型系统中,是独立实用程序或抽象层,位于应用程序之上。因为我们希望使它们与环境无关,这意味着我们不在乎数据是在本地传输还是通过互联网传输,那么套接字就非常适合。话虽如此,我们必须意识到我们使用的通道以及我们的数据是如何传输的。让我们看看:

图 7.4 – 以表格形式表示的 OSI 模型

图 7.4 – 以表格形式表示的 OSI 模型

全球网络通信,特别是互联网,是一个广泛且复杂的话题,我们无法在本书的单个章节中掌握它。但思考你的系统是有价值的——它有什么样的网络通信硬件;也许你应该考虑检查物理数据链路层。一个简单的练习是配置你自己的家庭网络——连接的设备、路由器等等。系统是否可以安全且安全地被外部(如果需要)访问?然后检查网络表示应用层。尝试一些端口转发并创建一个具有数据交换加密的应用程序。软件是否能够足够快地扩展,以适应当前的带宽和速度?让我们看看会话传输层能提供什么——我们将在下一段中探讨它们。它是否健壮,在受到攻击时是否仍然可用?然后重新审视所有层。当然,这些都是简单且单方面的观察,但它们允许你双重检查你的需求。

因此,如果我们忽略硬件的作用,只关注建立连接,我们就可以回到套接字和相应的会话层。你可能已经注意到,一些网站会在一段时间后自动注销你。你有没有想过为什么会这样?好吧,会话是在设备或端点之间建立的信息交换的双向链接。强烈建议为会话的销毁应用时间限制和需求。打开的连接不仅意味着攻击者可以嗅探的开放通道,而且意味着服务器端使用的一种资源。这需要计算能力,而这可能被重新分配到其他地方。服务器通常保留当前状态和会话历史,因此我们将此类通信标记为有状态的——至少一个设备保持状态。但如果我们能够处理请求而无需知道并保留以前的数据,我们就可以进行无状态的通信。然而,我们仍然需要会话来建立面向连接的数据交换。为此,在传输层找到了一个已知的协议——传输控制协议TCP)。如果我们不想建立双向信息传输通道,而只想实现广播应用程序,那么我们可以通过用户数据报协议UDP)提供的无连接通信来进行。让我们在接下来的章节中检查它们。

通过 UDP 熟悉网络

正如我们所说,这个协议可以实现无连接通信,但这并不意味着端点之间没有连接。这意味着它们不需要持续连接来维护数据传输并在它们自己的端进行解释。换句话说,丢失一些数据包(例如,在在线会议中通话时听不到某人,可能)对于系统的行为本身可能不是至关重要的。这可能对你来说很重要,但让我们说实话,我们打赌你更需要高速,而这是有代价的。像域名系统DNS)、动态主机配置协议DHCP)、音视频流平台等网络应用程序都使用 UDP。差异和数据包丢失通常通过数据重传来处理,但这是在应用层实现的,并取决于程序员的实现。从示意图上看,建立此类连接的系统调用如下:

图 7.5 – UDP 系统调用实现

图 7.5 – UDP 系统调用实现

如您所见,这确实很简单——通信双方(或更多)的应用程序只需遵循该顺序即可。该协议并不强制您遵守消息顺序或传输质量,它只是快速。让我们看看以下示例,请求从套接字进行 N 次骰子投掷。代码的完整版本可以在 github.com/PacktPublishing/C-Programming-for-Linux-Systems/tree/main/Chapter%207 找到:

...
constexpr auto PORT     = 8080;
constexpr auto BUF_SIZE = 16;
auto die_roll() {
...
void process_creator() {
    auto sockfd = 0;
    array<char, BUF_SIZE> buffer{};
    string_view stop{ "No more requests!" };
    string_view request{ "Throw dice!" };
    struct sockaddr_in servaddr {};
    struct sockaddr_in cliaddr {};

如您所见,通信配置相当简单——一方必须绑定到一个地址,以便知道从哪里接收数据(标记 {3}),而另一方只需直接将数据写入套接字。套接字配置在标记 {1} 中描述:

    servaddr.sin_family = AF_INET; // {1}
    servaddr.sin_addr.s_addr = INADDR_ANY;
    servaddr.sin_port = htons(PORT);
    if (int pid = fork(); pid == 0) {
        // Child
        if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0))
                < 0) {
            const auto ecode
                { make_error_code(errc{errno}) };
            cerr << "Error opening socket!";
            system_error exception{ ecode };
            throw exception;
        } // {2}
        if (bind(sockfd,
            (const struct sockaddr*)&servaddr,
            sizeof(servaddr)) < 0) {
            const auto ecode
                { make_error_code(errc{errno}) };
            cerr << "Bind failed!";
            system_error exception{ ecode };
            throw exception;
        } // {3}

地址族被定义为 AF_INET,这意味着我们将依赖于 IPv4 兼容的地址。我们可以使用 AF_INET6 来表示 IPv6,或者使用 AF_BLUETOOTH 来表示蓝牙。我们通过套接字的 SOCK_DGRAM 设置使用 UDP(标记 {2}{10})。通过这种方式,我们将一个进程中的数字传输到另一个进程。您可以想象它们是一个服务器和一个客户端:

        socklen_t len = sizeof(cliaddr);
        for (;;) {
            if (auto bytes_received =
                recvfrom(sockfd, buffer.data(),
                    buffer.size(),
                    MSG_WAITALL,
                    (struct sockaddr*)&cliaddr,
                    &len);
                bytes_received >= 0) { // {4}
                buffer.data()[bytes_received] = '\0';
                cout << "Request received: "
                     << buffer.data() << endl;
                if (request.compare(0,
                                    bytes_received,
                                    buffer.data()) == 0) {
                                                    // {5}
                    string_view res_data
                        { to_string(die_roll()) };

收到新的骰子投掷请求(标记 {4})并打印出请求数据。然后,将请求字符串与一个不可变的字符串进行比较,这样我们就知道这个请求只是为了骰子投掷(标记 {5})。如您所见,我们使用了 MSG_WAITALL 设置,这意味着套接字操作将阻塞调用进程——通常在没有传入数据时。此外,这是一个 UDP 通信,因此数据包顺序可能不会遵循,并且通过 recvfrom() 接收 0 字节是一个有效的用例。话虽如此,我们使用额外的消息来标记通信的结束(标记 {6}{14})。为了简单起见,如果 request.compare() 的结果不是 0,则通信结束。可以添加对多个选项的额外检查。我们可以使用类似的手势来开始通信——这取决于系统程序员的决策和应用需求。继续进行客户端的功能:

                    sendto(sockfd, res_data.data(),
                           res_data.size(),
                           MSG_WAITALL,
                           (struct sockaddr*)&cliaddr,
                           len);
                }
                else break; // {6}
...
        }
        if (auto res = close(sockfd); res == -1) { // {8}
            const auto ecode
                { make_error_code(errc{errno}) };
            cerr << "Error closing socket!";
            system_error exception{ ecode };
            throw exception;
        }
        exit(EXIT_SUCCESS);

die_roll() 函数被调用 dice_rolls 次数(标记 {10}{11}),结果通过套接字(标记 {12})发送。在收到结果后(标记 {13}),发送结束消息(标记 {14})。我们在这个例子中主要使用了 MSG_CONFIRM,但必须小心使用这个标志。它应该在您期望从发送给同一对等方收到响应时使用。它告诉 OSI 模型的数据链路层有一个成功的回复。我们可以将 recvfrom() 设置更改为 MSG_DONTWAIT,就像标记 {12} 一样,但实现自己的重试机制或切换到 TCP 会是一个好主意:

       for (auto i = 1; i <= dice_rolls; i++) { // {11}
            if (auto b_sent = sendto(sockfd,
                                     request.data(),
                                     request.size(),
                                     MSG_DONTWAIT,
                                     (const struct
                                      sockaddr*)&servaddr,
                                     sizeof(servaddr));
                                     b_sent >= 0) { // {12}
...
            if (auto b_recv =
                    recvfrom(sockfd,
                             buffer.data(),
                             buffer.size(),
                             MSG_WAITALL,
...                             { // {13}
                buffer.data()[b_recv] = '\0';
                cout << "Dice roll result for throw number"
                     << i << " is "
                     << buffer.data() << endl;
            }

在关闭语句之后关闭通信(标记 {8}{15}):

       sendto(sockfd,
              stop.data(),
              stop.size(),
              MSG_CONFIRM,
              (const struct sockaddr*)&servaddr,
              sizeof(servaddr)); // {14}
       if (auto res = close(sockfd); res == -1) {
            const auto ecode
                { make_error_code(errc{errno}) };
            cerr << "Error closing socket!";
            system_error exception{ ecode };
            throw exception; // {15}
        }
...

输出的简短版本如下:

Choose a number of dice throws between 1 and 256.
5
Request received: Throw dice!
Dice roll result for throw number 1 is 2
....
Dice roll result for throw number 5 is 6
Request received: No more requests

我们必须设置服务器可以被访问的地址和端口。通常,服务器计算机上会持续运行许多应用程序,其中一些为顾客执行服务。这些服务与服务器端口绑定,用户可以调用它们来完成一些工作——获取在线商店的内容、查看天气、获取一些银行详情、可视化图形网站等等。一次只有一个应用程序(服务)可以与给定的端口一起工作。如果您在第一个应用程序活动时尝试使用它,您将得到一个Address already in use错误(或类似错误)。目前,我们正在使用端口8080,这是为 TCP/UDP(和 HTTP)通常打开的。您也可以尝试使用80,但在 Linux 上,非 root 用户没有这个能力——您需要更高的用户权限来使用小于1000的端口。最后但同样重要的是,IP 地址设置为INADDR_ANY。这通常在我们在一个系统上进行通信时使用,我们不在乎它的地址。不过,如果我们想使用它,我们可以在执行以下命令后使用它:

$ ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: ens32: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 00:0c:29:94:a5:25 brd ff:ff:ff:ff:ff:ff
    inet 192.168.136.128/24 brd 192.168.136.255 scope global dynamic noprefixroute ens32
       valid_lft 1345sec preferred_lft 1345sec
    inet6 fe80::b11f:c011:ba44:35e5/64 scope link noprefixroute
       valid_lft forever preferred_lft forever...

在我们的案例中,这是192.168.136.128。我们可以在标记{1}处更新代码如下:

servaddr.sin_addr.s_addr = inet_addr("192.168.136.128");

另一个选项是使用 localhost 地址——127.0.0.1——与回环设备地址:INADDR_LOOPBACK一起使用。我们用它来运行本地服务器,通常用于测试目的。但如果我们使用一个确切的 IP 地址,那么这是在我们需要非常具体地指定应用程序端点时进行的,如果 IP 地址是静态的,我们期望本地网络上的其他人能够调用它。如果我们想将其暴露给外界,以便我们的服务对其他人可用(比如说我们拥有一个在线商店,我们希望向世界提供我们的购物服务),那么我们必须考虑端口转发

重要注意事项

现在,仅仅暴露端口被认为是不安全的,因为任何人都可以访问该设备。相反,服务不仅由防火墙、加密机制等保护,还被部署在虚拟机上。这增加了一层额外的安全防护,因为攻击者永远无法访问真实设备,而只能访问其一个非常有限的版本。这样的决定也提供了更高的可用性,因为受攻击的表面可以立即移除,系统管理员可以从健康快照中启动一个新的虚拟机,使服务再次可用。根据实现方式,这也可以自动化。

最后一点——如果我们传输大量数据,文件内容可能会被放置不当。这同样可以从之前提到的 UDP 中预期,因为数据包的顺序。如果这不符合您的需求,并且您需要一个更健壮的实现,那么您应该检查下一节中的 TCP 描述。

通过 TCP 考虑健壮性

UDP 的替代方案是 TCP。它被认为是可靠的——消息是有序的,它是面向连接的,并且具有延长的延迟。像万维网(WWW)、电子邮件、远程管理应用程序等应用程序都是基于此协议的。你可能已经注意到了(你将在图 7.6中观察到),相应的系统调用在顺序上与其他编程语言中的名称相似。这有助于不同领域专家在设计网络应用程序时有一个共同的基础,并轻松理解事件序列。这是一个非常简单的方法,帮助他们遵循 OSI 模型中的协议,使用这些名称作为提示,了解当前通信的位置。正如我们在上一节中提到的,套接字用于环境无关的解决方案,其中系统具有不同的操作系统,通信的应用程序使用不同的编程语言。例如,它们是用 C、C++、Java 或 Python 实现的,而它们的客户端可以是 PHP、JavaScript 等等。

TCP 通信的系统调用在以下图中表示:

**图 7.6 – TCP 系统调用实现**

图 7.6 – TCP 系统调用实现

如你所见,它比 UDP 更复杂,正如预期的那样。为什么会这样?好吧,我们需要保持一个已建立的连接,并且内核确认数据包传输。如果你记得,在第一章[B20833_01.xhtml#_idTextAnchor014]和第二章[B20833_02.xhtml#_idTextAnchor029]中,我们讨论了套接字也是文件,我们可以这样对待它们。而不是执行send()recv()调用,你可以简单地执行write()read()调用。前者专门用于网络通信的角色,而后者通常是所有文件通用的。使用read()write()调用将类似于通过管道进行通信,但这是在计算机系统之间,因此它再次取决于你的需求。

让我们看看以下示例——一个简单的请求-响应交换,我们将在本地网络上的不同机器上执行这个交换,因为之前提到的 IP 地址仅适用于我们的内部网络。首先,让我们看看我们是否可以 ping 通服务器:

$ ping 192.168.136.128
Pinging 192.168.136.128 with 32 bytes of data:
Reply from 192.168.136.128: bytes=32 time<1ms TTL=64
Reply from 192.168.136.128: bytes=32 time<1ms TTL=64
Reply from 192.168.136.128: bytes=32 time<1ms TTL=64

因此,我们已经可以访问这台机器。现在,让我们将服务器作为一个独立的应用程序运行(完整的代码可以在 https://github.com/PacktPublishing/C-Programming-for-Linux-Systems/tree/main/Chapter 7 中找到)。配置几乎相同,所以我们省略了代码片段中的这些部分:

...
constexpr auto PORT     = 8080;
constexpr auto BUF_SIZE = 256;
constexpr auto BACKLOG  = 5;
constexpr auto SIG_MAX  = 128;
void exitHandler(int sig) {
    cerr << "Exit command called - terminating server!"
         << endl;
    exit(SIG_MAX + sig);
}
int main() {
    signal(SIGINT, exitHandler);
    constexpr auto ip = "192.168.136.128";
...

我们打开套接字:

    if (auto server_sock =
            socket(AF_INET, SOCK_STREAM, 0);
            server_sock < 0) {

我们使用SOCK_STREAM来表示这是一个 TCP 连接。我们还使用了硬编码的 IP 地址。在绑定到地址后,我们需要监听一个BACKLOG数量的活动连接。如果连接数小于BACKLOG值,通常可以接受每个新的连接:

...
        server_addr.sin_addr.s_addr = inet_addr(ip);
        result = bind(server_sock,
            (struct sockaddr*)&server_addr,
            sizeof(server_addr));
...
        result = listen(server_sock, BACKLOG);
        if (result != 0) {
            cerr << "Cannot accept connection";
        }
        cout << "Listening..." << endl;
        for (;;) {
            addr_size = sizeof(client_addr);
            client_sock =
                accept(server_sock,
                       (struct sockaddr*)&client_addr,
                       &addr_size);

到目前为止,我们只有以下内容:

$ ./server
Listening...

现在,让我们准备接受客户端并处理其请求。我们使用MSG_PEEK标志来检查传入的消息,并使用MSG_DONTWAIT发送消息。为了简单和可读性,我们省略了sendto()的结果检查:

            if (client_sock > 0) {
                cout << "Client connected." << endl;
                array<char, BUF_SIZE> buffer{};
                if (auto b_recv = recv(client_sock,
                                       buffer.data(),
                                       buffer.size(),
                                       MSG_PEEK);
                                  b_recv > 0) {
                    buffer.data()[b_recv] = '\0';
                    cout << "Client request: "
                         << buffer.data() << endl;
                    string_view response =
                        { to_string(getpid()) };
                    cout << "Server response: "
                         << response << endl;
                    send(client_sock,
                         response.data(),
                         response.size(),
                         MSG_DONTWAIT);
                }

并且在结束时关闭套接字:

...
           if (auto res =
                        close(client_sock); res == -1) {
...

现在,让我们从另一个系统连接一个客户端。它的实现与 UDP 类似,除了必须调用connect()并且必须成功:

...
       if (auto res =
                connect(serv_sock,
                        (struct sockaddr*)&addr,
                        sizeof(addr)); res == -1) {
            const auto ecode
                { make_error_code(errc{errno}) };
            cerr << "Error connecting to socket!";
            system_error exception{ ecode };
            throw exception;
        }
        string_view req = { to_string(getpid()) };
        cout << "Client request: " << req << endl;

服务器的输出如下变化:

$ ./server
Listening...
Client connected.
Client request: 12502
Server response: 12501

让我们继续通信,发送信息回来:

        if (auto res =
                send(serv_sock,
                     req.data(),
                     req.size(),
                     MSG_DONTWAIT);
                res >= 0) {
            array<char, BUF_SIZE> buffer{};
            if (auto b_recv =
                    recv(serv_sock,
                         buffer.data(),
                         buffer.size(),
                         MSG_PEEK);
                    res > 0) {
                buffer.data()[b_recv] = '\0';
                cout << "Server response: "
                     << buffer.data();
...
       if (auto res = close(serv_sock); res == -1) {
...
      cout << "\nJob done! Disconnecting." << endl;

我们正在关闭客户端端的通信,包括套接字。客户端的输出如下:

$ ./client
Client request: 12502
Server response: 12501
Job done! Disconnecting.

当客户端的工作完成时,进程终止并关闭其套接字,但服务器仍然为其他客户端保持活跃,因此如果我们从不同的 shell 多次调用客户端,服务器将会有以下输出:

Listening...
Client connected.
Client request: 12502
Server response: 12501
Client connected.
Client request: 12503
Server response: 12501

服务器将在其队列中处理最多五个客户端会话。如果客户端没有关闭它们的套接字,或者服务器在超时后没有强制终止它们的连接,它将无法接受新的客户端,并且会观察到“客户端连接失败”的消息。在下一章中,我们将讨论不同的基于时间的技巧,所以请考虑将它们与您的实现相结合,以提供有意义的会话超时。

如果我们想要优雅地处理服务器终止,可以简单地实现一个信号处理程序,就像我们在第三章中所做的那样。这次,我们将处理Ctrl + C键组合,导致以下输出:

...
Client request: 12503
Server response: 12501
^CExit command called - terminating server!

如前所述,服务器和客户端的不当终止可能导致挂起的套接字和打开的端口。这将对系统造成问题,因为简单的应用程序重启将失败并显示Address already in use。如果发生这种情况,请通过ps命令检查剩余的进程。您可以通过kill命令终止正在运行的过程,就像您在第一章第二章中学到的那样。有时,这还不够,服务器也不应该那么容易终止。因此,您可以在检查了哪些端口已打开后更改端口。您可以通过以下命令完成此操作:

$ ss -tnlp
State Recv-Q Send-Q Local Address:Port Peer Address:Port  Process
LISTEN 0           5            192.168.136.128:8080 0.0.0.0:*      users:(("server",pid=9965,fd=3))
LISTEN   0         4096         127.0.0.53%lo:53         0.0.0.0:*
LISTEN   0         5            127.0.0.1:631            0.0.0.0:*
LISTEN   0         5            [::1]:631                [::]:*

您可以看到服务器正在指定的地址和端口上运行:192.168.136.128:8080。我们也可以使用以下方法检查到某个端口的连接:

$ lsof -P -i:8080
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
server  10116  oem    3u  IPv4  94617      0t0  TCP oem-virtual-machine:8080 (LISTEN)

随着现在在线服务的增多,我们无法避免网络编程。我们鼓励您将这些示例作为简单的应用程序开始。了解更多的套接字设置也很重要,因为这将帮助您满足特定的需求。

摘要

在本章中,你学习了各种执行 IPC 的方法。你熟悉了消息队列(MQs)作为发送小块数据的简单、实时和可靠的工具。我们还深入探讨了基本同步机制,如信号量和互斥锁,以及它们的 C++20 接口。结合共享内存(shmem),你观察到我们如何快速交换大量数据。最后,通过主要的协议 UDP 和 TCP,介绍了通过套接字进行网络通信。

复杂的应用通常依赖于多种进程间通信(IPC)技术来实现其目标。了解它们——包括它们的优点和缺点——非常重要。这将帮助你决定你特定的实现方案。大多数情况下,我们会在 IPC 解决方案之上构建层,以确保应用程序的健壮性——例如,通过重试机制、轮询、事件驱动设计等。我们将在第九章中重新探讨这些主题。下一章将为你提供通过不同计时器自我监控可用性和性能的工具。

第八章:在 Linux 中使用时钟、定时器和信号

在本章中,我们将首先探索 Linux 环境中可用的各种定时器。随后,我们将深入研究时钟纪元的重要性,并探讨 UNIX 时间的概念。接着,我们将揭示在 Linux 中使用 POSIX 精确测量时间间隔的方法。进一步地,我们将揭示 std::chrono 的领域,并检查 C++ 为有效的时间相关操作提供的功能。我们的旅程随后进展到对 std::chrono 框架中定义的持续时间、时间点和时钟的全面审查。继续前进,我们将熟悉 std::chrono 中可用的各种时钟。在我们导航的过程中,我们将迈出第一步,利用 std::chrono 提供的日历功能。在探索的最后阶段,我们将熟悉时区,并利用 std::chrono 的强大工具执行无缝的时间转换。

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

  • 探索 Linux 中的定时器

  • 在 C++ 中处理时间

  • 使用时钟、定时器和比率

  • 使用日历和时区功能

那么,让我们开始吧!

技术要求

本章中的所有示例都在以下配置的环境中进行了测试:

在 Linux 中处理时间

时间是任何计算机系统的一个基本方面,Linux 也不例外。在 Linux 中,有不同类型的定时器可供使用,每个定时器都设计用于处理特定的任务和需求。

这些定时器可以用来测量程序的执行时间、安排任务、触发事件等等。在本节中,我们将探讨 Linux 中可用的不同类型的定时器以及如何有效地使用它们。

这里列出了 Linux 系统中使用的不同类型的定时器:

  • 系统定时器:Linux 内核使用系统定时器来跟踪时间和安排各种任务。系统定时器用于测量系统运行时间、延迟和超时。Linux 中最重要的系统定时器是 Jiffies 定时器,它在系统时钟的每次滴答中增加 1。Jiffies 定时器用于跟踪自系统启动以来经过的时间,并且它经常被各种内核模块和驱动程序使用。

  • /dev/rtc 设备文件或 hwclock 命令行工具。RTC 用于在启动时同步系统时间,并维护系统事件的准确时间戳。

  • 高分辨率计时器(HRTs):HRTs 提供纳秒级分辨率,这使得它们适合需要精确计时的实时应用程序。HRTs 可用于测量代码段的执行时间、以高精度安排事件或驱动高速硬件。

  • timer_create()timer_settime()timer_delete() 系统调用。

  • 计时器队列:计时器队列是 Linux 内核提供的一种调度事件和超时的机制。计时器队列作为事件优先队列实现,其中每个事件都与一个计时器相关联。计时器队列可用于安排周期性任务、实现超时或在特定间隔触发事件。计时器队列在各个内核模块和设备驱动程序中被广泛使用。

但说到计时器,我们首先需要了解在计算机系统中时间意味着什么。让我们看看。

Linux 基准点

在计算机科学中,基准点 指的是在特定系统或上下文中用作测量时间的参考的具体时间点。它作为其他时间值计算或表示的起点。换句话说,这是计算机测量系统时间的时间起点。

基准点通常定义为特定的时间点,通常表示为自特定基准时间点以来经过的秒数、毫秒数或其他小于毫秒的时间间隔。基准点的选择取决于系统和上下文。例如,在 Linux 类似系统中,Linux 就是这样的系统,基准点被定义为 1970 年 1 月 1 日 00:00:00 UTC(协调世界时)。这个基准时间通常被称为 UNIX 基准点UNIX 时间。基于 UNIX 的系统中的时间值通常表示为自 UNIX 基准点以来经过的秒数。

现在,我们已经更好地理解了 UNIX 基准点,让我们看看如何在实践中使用这些计时器的例子。

在 Linux 中使用计时器

既然我们已经了解了 Linux 中可用的不同类型的计时器,让我们探索如何在我们的应用程序中使用它们。我们将查看一个启动 POSIX 计时器并等待其被信号的通知的例子:

#include <iostream>
#include <csignal>
#include <unistd.h>
#include <sys/time.h>
#include <atomic>
static std::atomic_bool continue_execution{true};
int main() {
    struct sigaction sa{};
    sa.sa_handler = [](int signum) {
        // Timer triggered, stop the loop.
        std::cout << "Timer expired. Stopping the
          task...\n";
        continue_execution = false;
    };
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    sigaction(SIGALRM, &sa, nullptr);
    // Configure the timer to trigger every 1 seconds
    struct itimerval timer{
        .it_interval{.tv_sec{1}, .tv_usec{0}},
        .it_value{.tv_sec{1}, .tv_usec{0}}
    };
    // Start the timer
    setitimer(ITIMER_REAL, &timer, nullptr);
    std::cout << "Timer started. Waiting for timer
      expiration...\n";
    // Keep the program running to allow the timer to
      trigger
    while (continue_execution) {
        sleep(1);
    }
    return 0;
}

在这个例子中,我们定义了一个 lambda 处理器,该处理器将在计时器到期时被调用。在处理器内部,我们打印一条消息,表明计时器已到期,并设置繁忙循环的退出条件。

我们使用sigaction函数设置信号处理程序。然后,我们使用it_intervalit_value成员配置计时器。配置计时器后,我们通过调用带有ITIMER_REAL选项的setitimer POSIX 函数来启动它,该选项设置一个实时计时器,在到期时发送SIGALRM信号。我们进入一个循环以无限期地运行程序。循环中的sleep(1)调用确保程序不会立即退出,并允许计时器触发。

程序的输出如下:

Program returned: 0
Timer started. Waiting for timer expiration...
Timer expired. Stopping the task...

在软件开发中,另一个常见的任务是测量代码段的执行时间。这也可以通过使用 POSIX 时间功能来实现。要测量代码段的执行时间,我们可以在 POSIX 中使用高分辨率计时器(HRT)。

要在 POSIX 中使用 HRT,我们将使用clock_gettime()函数以及CLOCK_MONOTONIC时钟 ID。以下是一个演示在 POSIX 中使用 HRT 的示例:

#include <iostream>
#include <ctime>
static const auto LIMIT{10000};
void just_busy_wait_f() {
    for (auto i{0}; i < LIMIT; ++i) {
        for (auto j{0}; j < LIMIT; ++j);
    }
}
int main() {
    timespec start, end;
    // Start the timer
    clock_gettime(CLOCK_MONOTONIC, &start);
    // Measured code segment
    just_busy_wait_f();
    // Stop the timer
    clock_gettime(CLOCK_MONOTONIC, &end);
    // Calculate the elapsed time
    const auto elapsed{(end.tv_sec - start.tv_sec) +
      (end.tv_nsec - start.tv_nsec) / 1e9};
    std::cout << "Elapsed time: " << elapsed << "
      seconds\n";
    return 0;
}

在这个例子中,我们声明了两个timespec结构,startend,以保存计时器的开始和结束时间戳。我们使用clock_gettime()函数使用高分辨率时钟获取当前时间。

我们调用clock_gettime()两次:一次在任务开始时(记录开始时间)和一次在结束时(记录结束时间)。使用CLOCK_MONOTONIC时钟 ID,它代表一个不受系统时间调整影响的单调时钟。

在捕获开始和结束时间戳后,我们通过减去时间戳的相应秒和纳秒组件来计算经过的时间。然后将结果打印为经过的时间(以秒为单位)。

我们测试实验室中的示例输出如下:

Program returned: 0
Elapsed time: 0.169825 seconds

请记住,在您的环境中,结果可能会有所不同。

注意,这个例子演示了使用计时器测量执行时间的一种方法。根据您的需求,您可以选择不同的计时器机制。

POSIX 计时器特性

让我们看看 POSIX 计时器的一些特性:

  • 强大且灵活:POSIX 计时器提供了一组丰富的功能,包括不同的计时器类型(例如,间隔计时器和一次性计时器)、各种时钟源以及精确控制计时器行为。

  • 低级控制:POSIX 计时器提供了对计时器设置的精细控制,例如信号处理和计时器到期行为。

  • 向后兼容性支持:POSIX 计时器是 POSIX API 的一部分,在类 UNIX 系统上已经可用很长时间了,这使得它们在需要与旧代码或特定 POSIX 要求保持兼容性时非常合适。

  • 平台特定:POSIX 计时器并非在所有平台上都可用,因此如果可移植性是一个问题,最好切换到更合适的选择。

但在 C++中我们有什么更好的替代方案呢?我们将在下一节中看到。

C++中的时间处理

虽然 POSIX 计时器有其优点,但在 C++ 中存在提供更高层次和更便携的定时和时间相关操作解决方案的库。

这样的库的一个好例子是 std::chrono。这是一个提供与时间相关操作和测量的一组实用工具的 C++ 库。它是标准库的一部分,包含在 <chrono> 头文件中。std::chrono 库提供了一种灵活且类型安全的机制来表示和操作时间间隔、时间点、时钟以及与时间相关的操作。通过使用 std::chrono,您将受益于 C++ 标准库带来的标准化、类型安全、灵活性和集成。与传统的 POSIX 方法相比,std::chrono 的一些优势如下:

  • std::chrono 是 C++ 标准库的一部分,使其成为一个跨平台解决方案,可以在不同的操作系统和编译器上保持一致性。另一方面,POSIX 是特定于类 UNIX 系统的,可能在所有平台上不可用或表现不一致。

  • std::chrono 提供了类型安全的时间间隔和时间点的表示。它提供了一套丰富的持续时间时钟类型,可以无缝地一起使用,从而实现更安全、更具表现力的代码。POSIX 计时器虽然功能强大,但通常依赖于低级类型,如 timespec 结构体,这可能导致错误并需要手动转换。

  • std::chrono 为时间相关操作提供了一个灵活且具有表现力的接口。它提供了方便的方式来对持续时间执行算术运算、在不同时间单位之间进行转换以及格式化时间值。POSIX 计时器虽然适用于特定的定时要求,但缺乏 std::chrono 提供的高级抽象和实用工具。

  • std::chrono 与 C++ 标准库的其他部分无缝集成。它可以与算法、容器和并发设施一起使用,从而实现更紧密和高效的代码。POSIX 计时器作为一个低级接口,可能需要额外的工作才能与其他 C++ 标准库组件集成。

  • std::chrono 得益于现代 C++ 中引入的进步和特性。它支持用户定义文字、lambda 函数和类型推导等特性,使得编写简洁和具有表现力的代码变得更加容易。POSIX 计时器作为 POSIX API 的一部分,可能无法充分利用现代 C++ 语言特性。

<chrono> 库为处理与时间相关的操作提供了一套全面的特性,例如测量时间间隔、表示时间点以及执行各种时间计算和转换。以下是 std::chrono 的关键组件和特性:

  • <chrono> 定义了几个时钟类型,它们代表不同的时间来源和不同的纪元。std::chrono::system_clock 代表可调整的全局 RTC,std::chrono::steady_clock 代表不受系统时间调整影响的一致单调时钟,std::chrono::high_resolution_clock 代表具有最高可用分辨率的时钟(如果系统支持)。

  • std::chrono::duration 模板类表示一个时间间隔,即指定的时间段。持续时间是使用特定时间单位计数的滴答数;例如,五个小时是五个单位的 小时 滴答。可以定义不同类型的持续时间,从年到纳秒。示例持续时间包括 std::chrono::secondsstd::chrono::millisecondsstd::chrono::months

  • std::chrono::time_point 模板类由一个时钟和持续时间类型参数化。

  • std::chrono 允许在持续时间和时间点之间进行转换,以及涉及持续时间的算术运算。它提供了将不同持续时间之间进行转换的函数,如 std::chrono::duration_cast,以及将不同时间点之间进行转换的函数 std::chrono::time_point_cast

  • std::chrono 提供了查询当前时间的实用工具,例如 std::chrono::system_clock::now(),它返回当前的系统时间点。

  • std::chronostd::literals::chrono_literals 命名空间中提供用户定义的时间相关字面量。它们允许您使用带时间单位的字面量创建 std::chrono::duration 对象。这使得处理与时间相关的计算时代码更易于阅读和方便。

  • std::chrono 提供了日历功能,例如处理天、月和年。它还提供了闰年和闰秒的表示法。

  • std::chrono 根据地理位置提供关于全球不同时区的信息。

通过使用 std::chrono,您可以执行精确且可移植的时间测量,处理超时,计算时间差,并以类型安全的方式处理与时间相关的操作。

重要提示

以下是一个指向 C++ 参考文档中 <chrono> 头文件的链接:en.cppreference.com/w/cpp/header/chrono

下面是一个如何使用 std::chrono 来测量代码片段执行时间的示例:

#include <iostream>
#include <chrono>
using namespace std::chrono;
int main() {
    const auto start{steady_clock::now()}; // {1}
    just_busy_wait_f(); // {2}
    const auto end{steady_clock::now()}; // {3}
    const auto dur{duration_cast<milliseconds>(end -
      start)}; // {4}
    std::cout << "Execution time: " << dur.count() << "
      milliseconds\n"; // {5}
    return 0;
}

在前面的示例中,使用 std::chrono::steady_clock 来测量与上一个示例中相同函数的执行时间(参见标记 {2})。startend 变量代表使用 steady_clocknow() 静态函数在代码执行前后获取的 时间点(参见标记 {1}{3})。std::chrono::duration_cast 用于将时间点之间的计算持续时间转换为毫秒(参见标记 {4})。

程序的输出应类似于以下内容:

Program returned: 0
Execution time: 179 milliseconds

正如你所见,std::chrono::duration类有一个count()方法,它返回特定持续时间中的单位数;参见标记 {5}

但让我们更深入地了解它是如何真正工作的。

使用时钟、计时器和比率

在进入更多关于时钟和计时器的例子之前,我们首先需要更好地理解 chrono 库是如何定义持续时间的。

正如我们在前面的例子中所见,持续时间是两个时间点之间的距离,称为时间点。在我们的前一个例子中,这些是startend时间点。

图 8.1 – 时间点和持续时间

图 8.1 – 时间点和持续时间

持续时间本身是滴答计数和表示从一个滴答到下一个滴答的秒数的分数的组合。这个分数由std::ratio类表示。以下是一些示例:

using namespace std::chrono;
constexpr std::chrono::duration<int, std::ratio<1>>
  six_minutes_1{360};
constexpr std::chrono::duration<double, std::ratio<3600>>
  six_minutes_2{0.1};
constexpr std::chrono::minutes six_minutes_3{6};
constexpr auto six_minutes_4{6min};
std::cout << six_minutes_1 << '\n';
std::cout << six_minutes_2 << '\n';
std::cout << six_minutes_3 << '\n';
std::cout << six_minutes_4 << '\n';
static_assert(six_minutes_1 == six_minutes_2);
static_assert(six_minutes_2 == six_minutes_3);
static_assert(six_minutes_3 == six_minutes_4);

在前面的例子中,我们以几种方式定义了六分钟的持续时间。在six_minutes_1变量中,我们指定了这个持续时间为 360 秒。相同的持续时间也可以表示为 1/10 小时 – six_minutes_2变量。最后两个持续时间 – six_minutes_3six_minutes_4 – 使用std::chrono预定义的持续时间类型和字面量表示相同的六分钟持续时间。以下是前面代码块的输出:

360s
0.1h
6min
6min

正如你所见,std::duration还提供了相当强大的格式化功能,因此一旦持续时间传递给字符串或流操作符,它将添加相应的后缀,这样我们就可以看到持续时间类型。

为了确保前面的持续时间确实对应六分钟,我们已经用static_assert测试了它们,如果不匹配,程序将失败。

重要提示

以下是一个链接到 C++参考文档中的std::duration类:en.cppreference.com/w/cpp/chrono/duration

让我们回到我们之前的一个例子,稍作修改,并更仔细地看看一个timepoint对象:

using namespace std::chrono;
const time_point start{steady_clock::now()}; // {1}
const duration epoch_to_start{start.time_since_epoch()}; //
  {2}
std::cout << "Time since clock epoch: " << epoch_to_start
  << '\n'; // {3}

正如你所见,我们再次构造了一个timepoint对象start,在这个对象中,我们从 Linux 系统的steady_clock实例中获取其实例化时刻的时间;参见标记 {1}std::chrono::time_point类存储一个std::chrono::duration值,这个值实际上表示从时钟纪元开始的时间间隔。为了允许获取这个值,std::chrono::duration类公开了一个返回持续时间的time_since_epoch()方法,单位为纳秒;参见标记 {2}

这是前面代码在我们测试环境中执行的结果。请注意,如果你执行此代码,结果可能会有所不同:

Time since clock epoch: 2080809926594ns

在某些用例中,如我们计算代码块执行时间的例子,以纳秒为单位的时间持续时间可能不方便。然而,将持续时间从高精度类型转换为低精度类型会导致精度损失。因此,如果我们需要以分钟和纳秒为单位查看持续时间,我们不能只是这样做:

using namespace std::chrono;
const minutes
  dur_minutes{steady_clock::now().time_since_epoch()};

这是因为前面的代码无法编译。背后的原因是time_since_epoch()方法返回的持续时间具有纳秒的精度。如果我们把数据存储在分钟里,我们肯定会失去精度。为了确保这不是一个错误,编译器阻止了我们。

但我们如何有意地将持续时间值从一种精度转换为另一种精度呢?正如我们在第一个例子中所看到的,我们可以使用库提供的std::chrono::duration_cast函数。它使我们能够将具有更高精度的持续时间类型转换为具有更低精度的持续时间类型。让我们重新处理前面的例子,看看它是如何工作的:

using namespace std::chrono;
auto dur_from_epoch{steady_clock::now()
  .time_since_epoch()}; // {1}
minutes dur_minutes{duration_cast<minutes>
  (dur_from_epoch)}; // {2}
std::cout << "Duration in nanoseconds: " << dur_from_epoch
  << '\n'; //{3}
std::cout << "Duration in minutes: " << dur_minutes <<
  '\n'; //{4}

如您在标记 {1}中所见,我们再次从时钟的纪元得到纳秒的持续时间。在标记 {2}中,我们初始化另一个持续时间变量,但这次是以分钟为单位。为了做到这一点,我们使用std::chrono::duration_cast<minutes>,它将源分辨率转换为目标分辨率,并将其截断到最接近的整数值。在我们的测试环境中,前面代码块的结果如下:

Duration in nanoseconds: 35206835643934ns
Duration in minutes: 586min

我们可以看到,以纳秒为单位的测量持续时间相当于大约 586.78 分钟,但它被截断到 586 分钟。

当然,我们也可能需要向上舍入而不是简单地向下截断值。幸运的是,chrono库通过std::chrono::round方法提供了这种能力,它正好做到了这一点。以下是一个例子:

using namespace std::chrono;
seconds dur_sec_1{55s}; //{1}
seconds dur_sec_2{65s}; //{2}
minutes dur_min_1{round<minutes>(dur_sec_1)}; //{3}
minutes dur_min_2{round<minutes>(dur_sec_2)}; //{4}
std::cout << "Rounding up to " << dur_min_1 << '\n';
std::cout << "Rounding down to " << dur_min_2 << '\n';

在这个例子中,我们定义了两个持续时间变量,dur_sec_1dur_sec_2dur_sec_1被初始化为 55 秒(见标记 {1}),而dur_sec_2被初始化为 65 秒(见标记 {2})。然后,使用std::chrono::round函数,我们初始化另外两个持续时间变量,但这次以分钟为分辨率(见标记 {3}{4})。这两个持续时间变量都被四舍五入到一分钟:

Rounding up to 1min
Rounding down to 1min

chrono库还提供了ceilfloor持续时间的方法。所有这些都可以在官方文档中找到。

重要提示

roundfloorceil方法对于持续时间值的文档可以在以下链接中找到:en.cppreference.com/w/cpp/chrono/duration/roundhttps://en.cppreference.com/w/cpp/chrono/duration/floor,以及en.cppreference.com/w/cpp/chrono/duration/ceil

由于我们对时间操作有了更好的理解,让我们更仔细地看看std::chrono为我们提供的不同类型的时钟。

关于 C++20 中的时钟的更多信息

我们已经在之前的例子中使用了 std::chrono::steady_clock。这只是 C++ chrono 库中可用的预定义时钟之一。正如其名称所暗示的,std::chrono::steady_clock 是一个稳定的时钟。这意味着它是一个单调时钟,其中时间只向前移动,并且其时间点值总是增加。当我们想要测量时间间隔时,它很适用。它的纪元可以变化。

另一个常用的时钟是 std::chrono::system_clock。在 Linux 中,它代表系统测量的时间。这意味着它不保证是单调的,并且可以在任何时候进行调整。在 Linux 中,它的纪元与 UNIX 纪元相匹配。让我们看一个例子:

using namespace std::chrono;
time_point<system_clock> systemClockEpoch;
std::cout << std::format("system_clock epoch:
  {0:%F}T{0:%R%z}.", systemClockEpoch) << '\n';

前面的例子打印了 Linux 系统时钟纪元,它对应于 UNIX 纪元——197011日的00:00:00: UTC

system_clock epoch: 1970-01-01T00:00+0000.

请记住,std::chrono::system_clock 并不考虑 闰秒,这些秒可以从测量时间中添加或减去。一般来说,闰秒是对协调世界时(UTC)的一次一秒调整,每年可能发生两次,以反映地球绕太阳旋转的精度。

重要提示

关于闰秒的更多信息,请参阅此处:en.wikipedia.org/wiki/Leap_second

C++20 引入了几种更多的预定义时钟。其中一些是 std::chrono::utc_clock,它测量 UTC,以及 std::chrono::tai_clock,它测量国际原子时TAI)。

重要提示

关于 UTC 和 TAI 的更多信息,请参阅此处:en.wikipedia.org/wiki/Coordinated_Universal_Timeen.wikipedia.org/wiki/International_Atomic_Time

TAI 时钟和 UTC 时钟之间的一个关键区别是,UTC 时钟保证会考虑自时钟纪元以来进行的闰秒修正,但 TAI 时钟不考虑这些修正。让我们看一个例子:

using namespace std::chrono;
tai_time tai{tai_clock::now()};
utc_time utc{utc_clock::now()};
std::cout << "International atomic time (TAI): " << tai <<
  '\n';
std::cout << "Coordinated universal time (UTC): " << utc <<
  '\n';

在前面的例子中,我们从两个时钟——utctai——获取了当前时间。以下是结果:

International atomic time (TAI): 2023-08-04 14:02:57.95506
Coordinated universal time (UTC): 2023-08-04 14:02:20.95506

如您所见,无论两个时钟是否同时调用,它们显示的时间都不同。并且它们的差异正好是 37 秒。这种差异来自自 1972 年引入以来进行的闰秒调整。

std::chrono::utc_clock 应用闰秒调整。通过使用 chrono 的 UTC 时钟,这些闰秒调整将自动为您完成,您不需要采取任何特殊行动。因此,chrono 库提供了一个在时钟类型之间进行转换的方法——std::chrono::clock_cast,它可以将 std::chrono::time_point 值从一个时钟转换为另一个时钟。让我们再看一个例子:

using namespace std::chrono;
tai_time tai{tai_clock::now()};
std::cout << "International atomic time (TAI): " << tai <<
  '\n';
utc_time utc{clock_cast<utc_clock>(tai)};
std::cout << "Coordinated universal time (UTC): " << utc <<
  '\n';

如您所见,由 chrono 的 TAI 时钟生成的time_point tai对象被转换为 UTC 时钟的时间点。结果如下:

International atomic time (TAI): 2023-08-04 14:16:22.72521
Coordinated universal time (UTC): 2023-08-04 14:15:45.72521

如我们所预期,TAI 时钟比 UTC 时钟快 37 秒。因此,UTC 不能正确地测量时间差,因为可能会添加或删除闰秒。

重要提示

您可以在 C++ chrono 库中找到所有预定义的时钟:en.cppreference.com/w/cpp/chrono#Clocks

现在,由于我们已经对时间和时钟有了很好的理解,让我们看看 C++ chrono 库为日历和时间区域提供了哪些功能。

使用日历和时间区域功能

C++20 向标准引入了对日历和时间区域操作的新支持。当我们谈论日历操作时,这意味着在日、月和年中的操作。它们与时间区域概念一起,允许在不同时区之间进行时间转换,同时考虑时区调整,如夏令时。

让我们定义一个日期并使用chrono库打印它:

using namespace std::chrono;
year theYear{2023};
month theMonth{8};
day theDay{4};
std::cout << "Year: " << theYear;
std::cout << ", Month: " << theMonth;
std::cout << ", Day: " << theDay << '\n';

如您所见,std::chrono命名空间提供了yearmonthday类,这使得处理日期变得容易。这些类的优点是它们提供了严格的类型和边界检查,一些用于加法和减法的运算符,以及格式化功能。前面代码的结果如下:

Year: 2023, Month: Aug, Day: 04

如您所见,将Month变量传递给operator<<会应用格式化,以便月份的值打印为Aug。此外,这些类提供了对应用值的验证和边界检查:

using namespace std::chrono;
std::cout << "Year: " << year{2023} ;
std::cout << ", Month: " << month{13};
std::cout << ", Day: " << day{32} << '\n';

在前面的例子中,我们应用了一个无效的月份和月份中的日期。结果如下:

Year: 2023, Month: 13 is not a valid month, Day: 32 is not a valid day

如您所见,monthday值已通过验证,当它们传递给operator<<时,它会打印出这些值无效。

year类表示公历中的一个年份,这使得我们可以询问该年份是否为闰年:

using namespace std::chrono;
sys_time now{system_clock::now()};
year_month_day today{floor<days>(now)};
std::cout << "Today is: " << today << '\n';
year thisYear{today.year()};
std::cout << "Year " << thisYear;
if (thisYear.is_leap()) {
    std::cout << " is a leap year\n";
} else {
    std::cout << " is not a leap year\n";
}

在这个例子中,我们首先获取当前系统时间now,然后将其转换为year_month_day类型的对象。该对象表示一个方便的基于字段的时点。它包含yearmonthday对象,并允许直接访问它们。它还支持从std::chrono::sys_days实例化,这实际上是一个系统时钟的时点。因此,我们传递now时点并创建today对象。然后,我们获取year对象thisYear,并使用year类的is_leap()方法检查它是否是闰年:

Today is: 2023-08-05
Year 2023 is not a leap year

如预期,2023 年不是闰年。

chrono库在日期创建中大量使用operator/。C++20 为该运算符的参数提供了大约 40 个重载。让我们看一个例子:

using namespace std::chrono;
year_month_day date1{July/5d/2023y};
year_month_day date2{1d/October/2023y};
year_month_day date3{2023y/January/27d};
std::cout << date1 << '\n';
std::cout << date2 << '\n';
std::cout << date3 << '\n';

如您所见,我们通过传递新引入的 chrono 文字面量(月份、日期和年份)以及 operator/ 来创建 year_month_day 对象。chrono 为创建日期提供了方便的文字面量;您只需在日期值后附加 d。对于年份也是如此,您需要附加 y 并构建一个 year 对象。对于月份,chrono 库为每年的所有月份定义了命名常量。

重要提示

以下是一个链接,列出了 chrono 库中的月份常量:en.cppreference.com/w/cpp/chrono/month

在实例化 year_month_day 对象时,我们使用 operator/ 来传递日期值。从前面的示例中可以看出,chrono 支持许多日期、月份和年份值的组合。所有这些都可以在标准文档中找到。

重要提示

以下是一个链接,列出了日期管理中 operator/ 的所有重载:en.cppreference.com/w/cpp/chrono/operator_slash

我们示例中使用的所有重载都旨在创建有效的 year_month_date 对象。让我们看看输出结果:

2023-07-05
2023-10-01
2023-01-27

如我们所见,我们已成功使用 chrono 文字面量和 operator/ 创建了三个不同的有效日期。

在 C++ 中处理时区

C++20 的 chrono 库提供了处理时区的能力。它集成了 IANA 时区数据库,该数据库包含全球许多地理位置的本地时间信息。

重要提示

在此处查找有关 IANA 时区数据库的更多信息:www.iana.org/time-zones

使用 chrono,您可以获取 IANA 数据库的副本,并浏览特定地理位置的信息:

using namespace std::chrono;
const tzdb& tzdb{get_tzdb()};
const std::vector<time_zone>& tzs{tzdb.zones};
for (const time_zone& tz : tzs) {
    std::cout << tz.name() << '\n';
}

从示例中我们可以看出,在 std::chrono 命名空间中有一个方法——get_tzdb(),它返回对 IANA 数据库的引用。在数据库中,您可以找到有关其版本的信息,还可以获取所有可用的 std::chrono::time_zone 对象的排序列表。

std::chrono::time_zone 类存储有关特定地理区域和名称之间时区转换的信息。前一个示例的输出如下:

Africa/Abidjan
Africa/Accra
Africa/Addis_Ababa
Africa/Algiers
Africa/Asmara
Africa/Bamako
...

现在,一旦我们有了所有可用的时间区,让我们尝试根据地理位置找到一个特定的时间区,并看看那里的时间是什么:

using namespace std::chrono;
const tzdb& tzdb{get_tzdb()};
const std::vector<time_zone>& tzs{tzdb.zones};
const auto& res{std::find_if(tzs.begin(), tzs.end(), []
  (const time_zone& tz){
    std::string name{tz.name()};
    return name.ends_with("Sofia");
})};
if (res != tzs.end()) {
    try {
        const std::string_view myLocation{res->name()};
        const std::string_view london{"Europe/London"};
        const time_point now{system_clock::now()};
        const zoned_time zt_1{myLocation, now};
        const zoned_time zt_2{london, now};
        std::cout << myLocation << ": " << zt_1 << '\n';
        std::cout << london << ": " << zt_2 << '\n';
    } catch (const std::runtime_error& e) {
        std::cout << e.what() << '\n';
    }
}

在这个例子中,我们再次获取可用的时区列表,并尝试找到城市索非亚的时区。然后,我们使用找到的时区的全名创建另一个对象,该对象使用特定的地理位置和系统时间值——std::chrono::zoned_time。这个类代表了一个时区和时间点之间的逻辑对。我们还创建了另一个zoned_time zt_2对象,但针对的是城市伦敦,它代表与zt_1相同的时间点,但位于另一个地理位置。上述代码的结果如下:

Europe/Sofia: 2023-08-05 13:43:53.503184619 EEST
Europe/London: 2023-08-05 11:43:53.503184619 BST

如您所见,这两个对象都显示了一个有效的时间,但相对于它们的地理位置而言。这就是我们如何安全地获取特定地理位置的当前时间,同时考虑夏令时的情况。

摘要

在本章中,我们探讨了 Linux 环境中可用的不同计时器。随后,我们了解了时钟纪元和 UNIX 时间概念的重要性。接着,我们深入研究了 Linux 中 POSIX 的实际实现,以实现准确的时间测量。此外,我们还研究了std::chrono领域,并检查了 C++为有效的时间相关操作提供的各种功能。我们的探索随后带我们详细了解了std::chrono框架中定义的持续时间、时间点和时钟。向前推进,我们熟悉了std::chrono中可用的各种时钟类型。随着我们的旅程继续,我们开始探索std::chrono提供的日历功能。最后,我们熟悉了时区,并提高了使用std::chrono提供的工具执行无缝时间转换的熟练度。现在,我们已准备好进入下一章,我们将更深入地探讨 C++内存模型的细节。

第九章:理解 C++ 内存模型

本章是延续第第七章的讨论,在第七章中,我们讨论了一些多进程和多线程技术;本章将增强它们的用法。我们将引导您了解各种技术,同时缩小到本章的主要焦点——C++内存模型。但为了讨论这一点,您将首先通过智能指针和可选对象对内存健壮性进行简要检查。我们将在稍后使用它们来实现延迟初始化并安全地处理共享内存区域。接下来是针对缓存友好代码的改进内存访问分析。您将了解为什么即使在软件设计中做得一切正确,使用多线程执行也可能成为陷阱。

本章为您提供了扩展对同步原语理解的机会。在学习条件变量的同时,您还将了解读写锁的好处。我们将使用 C++20 的范围来以不同的方式可视化相同共享数据。逐一结合这些机制,我们将以最大的主题——指令排序来最终完成我们的分析。通过 C++ 的内存顺序,您将了解正确原子例程设置的重要性。最后,我们将使用自旋锁实现来总结所有技术。

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

  • 了解 C++ 中的智能指针和可选

  • 了解 C++ 中的条件变量、读写锁和范围

  • 讨论多处理器系统——C++ 中的缓存局部性和缓存友好性

  • 通过自旋锁实现回顾 C++ 内存模型中的共享资源

技术要求

为了运行代码示例,读者必须准备以下内容:

了解 C++ 中的智能指针和可选

第四章中,我们回顾了 C++基础知识,以便在语言方面保持一致。另一个也被认为是必需品的工具是智能指针。通过这些工具,我们能够提高程序的安全性,并更有效地管理我们的资源。正如前面章节所讨论的,这是我们作为系统程序员的主要目标之一。还记得RAII原则吗?智能指针基于这一点,帮助 C++开发者减少甚至消除内存泄漏。它们还可以帮助管理共享内存,正如你将在本章后面看到的那样。

内存泄漏发生在我们分配内存但未能释放它的时候。这种情况不仅可能是因为我们忘记了调用对象的析构函数,还可能是因为我们失去了对该内存地址的指针。除此之外,还需要考虑野指针悬挂指针。第一种情况是当指针存在于上,但它从未与实际对象(或地址)相关联。第二种情况是当我们释放了对象使用的内存,但指针的值仍然悬挂在周围,我们引用了一个已经被删除的对象。总的来说,这些错误不仅可能导致内存碎片化,还可能导致缓冲区溢出漏洞。

这些问题很难捕捉和重现,尤其是在大型系统中。系统程序员和软件集成工程师使用诸如地址清理器、静态和动态代码分析器以及性能分析器等工具,依赖它们来预测未来的缺陷。但是,这样的工具成本高昂,消耗大量的计算能力,因此我们不能始终依赖它们来保证更高的代码质量。话虽如此,我们还能做什么呢?答案是简单的——使用智能指针。

注意

你可以在标准中了解更多关于智能指针的信息,或者参考en.cppreference.com/w/cpp/memory

通过智能指针重审 RAII

即使是有经验的 C++开发者,在处理内存释放的正确时机时也会犯错误。其他语言使用垃圾回收技术来处理内存管理,但重要的是要提到,那里也会发生内存泄漏。代码中实现了多种算法来检测此类情况,但并不总是成功。例如,对象之间的循环依赖有时很难解决——指向彼此的两个对象应该被删除,还是应该保持分配?如果它们保持分配,这构成泄漏吗?因此,我们必须谨慎对待内存使用。此外,垃圾回收器努力释放内存,但不管理打开的文件、网络连接、锁等。为此,C++实现了自己的控制工具——指针的包装类,帮助我们正确地释放内存,通常是在对象超出作用域时(对象生命周期已在第四章中讨论)。智能指针在内存和性能方面效率高,这意味着它们(几乎)不会比原始指针多花费(多少)成本。同时,它们在内存管理方面提供了稳健性。C++标准中有三种类型的智能指针:

  • unique_ptr: 这是一个只能有一个所有者的指针。它不能被复制或共享,但所有权可以被转让。它的大小与单个原始指针相同。当它超出作用域时,它将被销毁,对象将被释放。

  • shared_ptr: 它可以有多个所有者,当所有所有者都放弃对该对象的所有权或所有所有者都超出作用域时,它将被销毁。它使用一个指向对象的引用计数器。它的大小是两个原始指针——一个用于分配的对象,一个用于包含引用计数的共享控制块。

  • weak_ptr: 这提供了对一个或多个共享指针拥有的对象的访问,但不计算引用。它用于观察一个对象,但不用于管理其生命周期。它由两个指针组成——一个用于控制块,一个用于指向它从中构建的共享指针。通过weak_ptr你可以了解底层的shared_ptr是否仍然有效——只需调用expired()方法。

让我们通过以下示例演示它们的初始作用:

struct Book {
   string_view title;
   Book(string_view p_title) : title(p_title) {
        cout << "Constructor for: " << title << endl; }
   ~Book() {cout << "Destructor for: " << title << endl;}};
int main() {
    unique_ptr<Book> book1 =
        make_unique<Book>("Jaws");
    unique_ptr<Book> book1_new;
    book1_new = move(book1); // {1}
    cout << book1_new->title << endl;
    shared_ptr<Book> book2 =
        make_unique<Book>("Dune");
    shared_ptr<Book> book2_new;
    book2_new = book2; // {2}
    cout << book2->title <<" "<< book2_new->title << endl;
    cout << book2.use_count() << endl;

正如你所见,我们在创建Book对象时使用堆,因为我们调用new。但是,由于智能指针处理内存管理,我们不需要显式调用析构函数:

Constructor for: Jaws
Jaws
Constructor for: Dune
Dune Dune
2
Destructor for: Dune
Destructor for: Jaws

首先,我们将book1对象的拥有权移动到另一个unique_ptrbook1_new(标记{1})。我们通过第二个unique_ptr打印其title,因为第一个已经无效。我们对另一个Book对象执行相同的操作,但通过一个shared_ptr对象(标记{2})。这次title变量可以从两个指针访问。我们还打印了引用计数,我们看到有两个对该对象的引用。

weak_ptr在系统编程中也有有用的优势。你可以使用weak_ptr来检查指针的有效性。weak_ptr还可以解决对象之间的循环依赖问题。让我们考虑一个双链表列表节点的例子。下一个例子将说明weak_ptr的好处。现在是时候建议你不要自己实现这样的数据结构了,尤其是当它们已经是 C++标准的一部分时。

现在,让我们将Book对象用作ListNode struct的内容:

struct ListNode {
    Book data;
    ListNode(string_view p_title) {
        data.title = p_title;
        cout << "Node created: " << data.title << endl;
    }

我们还添加了两个成员变量用于前一个和后一个节点,但其中一个将是weak_ptr。一个需要注意的是,weak_ptr引用在shared_ptr控制块中不被计为引用。现在,我们既有了对对象的访问,又有机会在每次分配时将引用计数归零:

    ~ListNode() {
        cout << "Node destroyed: " << data.title
             << endl;
    }
    shared_ptr<ListNode> next;
    weak_ptr<ListNode> prev;
};
int main() {
    shared_ptr<ListNode> head =
        make_shared<ListNode>("Dune");
    head->next = make_shared<ListNode>("Jaws");
    if (!head->next->prev.expired())
        head->next->prev = head;

从输出中可以看出,所有对象都已成功删除:

Node created: Dune
Node created: Jaws
Node destroyed: Dune
Node destroyed: Jaws

weak_ptr在缓存实现中也非常有用。想想看 – 如果你失去了对一个对象的全部引用,你将失去该对象本身;但是,使用智能指针,它肯定会销毁。所以,想象一下,最近访问的对象或重要性较高的对象通过shared_ptr在当前代码作用域中保持。但是weak_ptr允许我们在需要在该作用域中稍后引用该对象时,在该作用域中保持对对象的引用。在这种情况下,我们会为它创建一个weak_ptr对象。但是想象一下,与此同时,其他代码作用域通过shared_ptr持有对该对象的引用,从而保持其分配。换句话说,我们知道关于该对象的信息,但不需要担心其管理。因此,如果以后还需要,该对象仍然是可访问的,但如果不再需要,它将被删除。以下图表显示了shared_ptr在左侧可能的不正确使用,以及右侧描述的实现:

图 9.1 – 通过 shared_ptr 产生的循环依赖和通过 weak_ptr 解决

图 9.1 – 通过 shared_ptr 产生的循环依赖和通过 weak_ptr 解决

在本节中,我们不会深入探讨其他可能需要智能指针的设计解决方案,但稍后在本章的系统编程领域,我们将回到这些解决方案。在下一节中,我们将讨论一种与weak_ptr相反的技术,其中我们保留了对尚未在内存中创建的对象的意识。

在 C++中进行懒初始化

你玩电子游戏吗?在玩游戏的时候,你是否曾在图形中看到过缺失的纹理?当你用角色靠近时,是否有图形资源突然出现?你在其他 UI 中也观察到这种行为吗?如果你的回答大多是肯定的,那么你可能已经遇到了延迟初始化。很容易理解它的目的是将对象的构建推迟到真正需要的时候。通过这样做,我们只允许系统分配所需的资源。我们还用它来加速我们的代码,尤其是在高 CPU 负载期间运行时,比如在系统启动时。我们不会浪费 CPU 周期去创建那些(在很久以后)才需要的大的对象,而是让 CPU 腾出来处理其他请求。从负面来看,我们可能会遇到无法及时加载对象的情况,正如你在游戏中可能观察到的。正如我们在第二章中讨论的那样,这也用于程序加载时,内核以延迟方式分配虚拟内存——直到被引用,可执行代码的页面才被加载。

就像其他任何模式一样,延迟初始化不能解决所有问题。因此,系统程序员必须选择是否应该将其应用于给定应用程序的功能。通常,图形和网络存储资源的一部分保持延迟初始化是首选的,因为它们无论如何都是按需加载的。换句话说,用户并不总是看到 UI 的全部内容。因此,事先存储在内存中不是必需的。C++有特性允许我们轻松实现这种方法。以下是我们展示延迟初始化的示例:

#include <iostream>
#include <chrono>
#include <optional>
#include <string_view>
#include <thread>
using namespace std;
using namespace std::literals::chrono_literals;
struct Settings {
    Settings(string_view fileName) {
        cout << "Loading settings: " << fileName << endl;
    }
    ~Settings() {
        cout << "Removing settings" << endl;
    }

我们提出一个Settings类,它将帮助我们模拟从磁盘加载和更新设置列表的过程。请注意,我们是通过值传递而不是引用传递:

    void setSetting(string_view setting,
                    string_view value) {
        cout << "Set setting: " << setting
             << " to: " << value << endl;
    }
};

这种技术由于减少了从内存中的加载而节省了时间。在 C++中,除了数组之外,按值传递(或按拷贝传递)是默认的参数传递技术,对于小型类型,如int来说,这是便宜且最优的。按引用传递是按值传递的替代方案,string_view对象的处理方式与int相同,使用比其他标准对象(如string)更便宜的拷贝构造函数。回到我们的例子,我们正在创建一个配置对象Config,它将包含设置文件(在现实场景中可能不止一个文件)并允许在配置中更改设置。我们的main()方法模拟应用程序的启动。Config对象将被构建,但设置文件只有在启动完成后、进程资源可用时才会被加载:

struct Config {
    optional<Settings> settings{};
    Config() {
        cout << "Config loaded..." << endl;
    }
    void changeSetting(string_view setting,
                       string_view value) {
        if (!settings)
            settings.emplace("settings.cfg");
        settings->setSetting(setting, value);
    }
};
int main() {
    Config cfg;
    cout << "Application startup..." << endl;
    this_thread::sleep_for(10s);
    cfg.changeSetting("Drive mode", "Sport");
    cfg.changeSetting("Gear label", "PRNDL");

我们观察到文件是在启动完成后加载的,正如我们所预期的:

Config loaded...
Application startup...
Loading settings: settings.cfg
Set setting: Drive mode to: Sport
Set setting: Gear label to: PRNDL
Removing settings

optional 类模板的设计是为了让函数在失败时可以返回 nothing,或者在成功时返回一个有效结果。我们也可以用它来处理构建成本高昂的对象。它还管理一个在特定时间可能存在也可能不存在的值。它易于阅读,意图明确。如果一个 optional 对象包含一个值,那么这个值保证是作为 optional 对象的一部分分配的,并且不会发生动态内存分配。因此,optional 对象模拟了一个对对象的 reservation,而不是一个指针。这是 optional 和智能指针之间的一个关键区别。尽管使用智能指针来处理大型和复杂对象可能是一个更好的主意,但 optional 给你提供了一个在所有参数都已知时(如果它们在执行早期未知)稍后构建对象的机会。两者在实现 延迟初始化 方面都能很好地工作——这取决于你的偏好。

在本章的后面部分,我们将回到智能指针及其在管理共享内存方面的可用性。不过,首先,我们将使用下一节来介绍一些有用的同步机制。

了解 C++ 中的条件变量、读写锁和范围

现在我们开始讨论同步原语,其中一个是 条件变量。它的目的是允许多个线程在事件发生(即条件满足)之前保持阻塞。条件变量 的实现需要一个额外的布尔变量来指示条件是否满足,一个 互斥锁 来序列化对布尔变量的访问,以及条件变量本身。

POSIX 为多个用例提供了一个接口。你还记得在 第七章 中关于使用共享内存的 生产者-消费者 示例吗?所以,pthread_cond_timedwait() 用于在给定时间内阻塞一个线程。或者简单地通过 pthread_cond_wait() 等待一个条件,并通过 pthread_cond_signal() 向一个线程或 pthread_cond_broadcast() 向所有线程发出信号。通常,条件是在互斥锁的作用域内定期检查的:

...
pthread_cond_t  condition_variable;
pthread_mutex_t condition_lock;
...
pthread_cond_init(&condition_variable, NULL);
...
void consume() {
    pthread_mutex_lock(&condition_lock);
    while (shared_res == 0)
        pthread_cond_wait(&condition_variable,
                          &condition_lock);
    // Consume from shared_res;
    pthread_mutex_unlock(&condition_lock);
}
void produce() {
    pthread_mutex_lock(&condition_lock);
    if (shared_res == 0)
        pthread_cond_signal(&condition_variable);
    // Produce for shared_res;
    pthread_mutex_unlock(&condition_lock);
}
pthread_mutex_unlock(&condition_lock);
...
pthread_cond_destroy(&condition_variable);
...

如果我们将抽象级别提升到与我们在 第七章 中所做的一样,C++ 会给我们提供相同的技巧,但使用起来更简单、更安全——我们受到 RAII 原则的保护。让我们检查以下 C++ 代码片段:

...
#include <condition_variable>
mutex cv_mutex;
condition_variable cond_var;
...
void waiting() {
    cout << "Waiting for work..." << endl;
    unique_lock<mutex> lock(cv_mutex);
    cond_var.wait(lock);
    processing();
    cout << "Work done." << endl;
}
void done() {
    cout << "Shared resource ready."  << endl;
    cond_var.notify_one();
}
int main () {
    jthread t1(waiting); jthread t2(done);
    t1.join(); t2.join();
    return 0;
}

输出如下:

Waiting for work...
Shared resource ready.
Processing shared resource.
Work done.

在这种形式中,代码是不正确的。没有要检查的条件,共享资源本身也缺失。我们只是在为以下示例做准备,这些示例是我们之前在第七章中讨论的内容的延续。但请注意,使用了一个{4}),而第一个是在等待时(标记{2})。正如你所看到的,我们依赖于一个互斥锁来锁定作用域内的共享资源(标记{1}),并通过它触发条件变量以继续工作(标记{2}{3})。因此,CPU 不会忙于等待,因为没有无限循环等待条件,从而为其他进程和线程释放了对 CPU 的访问。但是线程仍然保持阻塞,因为条件变量wait()方法会解锁互斥锁,线程被原子性地置于睡眠状态。当线程被信号时,它将被恢复并重新获取互斥锁。这并不总是有用的,你将在下一节中看到。

通过条件变量进行协作取消

一个重要的注意事项是,条件变量应该只通过条件和谓词来等待。如果不这样做,等待该条件的线程将保持阻塞状态。你还记得来自第六章的线程取消示例吗?我们使用了jthread并在线程之间通过stop_token类和stop_requested方法发送停止通知。这种机制被称为jthread技术,被认为是安全且易于应用的,但它可能不是你的软件设计选项,或者可能不足以满足需求。取消线程可能与等待事件直接相关。在这种情况下,条件变量可能会派上用场,因为不需要无限循环或轮询。回顾来自第六章的线程取消示例“取消线程,这真的可能吗?”,我们有以下内容:

while (!token.stop_requested())

我们正在执行轮询,因为线程工作线程在同时做其他事情时定期检查是否已发送取消信号。但如果取消是我们唯一关心的事情,那么我们就可以使用stop_requested函数简单地订阅取消事件。C++20 允许我们定义一个stop_callback函数,因此,结合条件变量和get_stop_token(),我们可以进行协作取消,而不需要无限循环:

#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>
#include <syncstream>
using namespace std;
int main() {
    osyncstream{cout} << "Main thread id: "
                      << this_thread::get_id()
                      << endl;

因此,让我们完成上一节中的示例工作,并在工作线程中的条件变量添加一个谓词:

    jthread worker{[](stop_token token) {
        mutex mutex;
        unique_lock lock(mutex);
        condition_variable_any().wait(lock, token,
            [&token] { return token.stop_requested(); });
        osyncstream{cout} << "Thread with id "
                          << this_thread::get_id()
                          << " is currently working."
                          << endl;
    }};
    stop_callback callback(worker.get_stop_token(), [] {
    osyncstream{cout} <<"Stop callback executed by thread:"
                      << this_thread::get_id()
                      << endl;
    });
    auto stopper_func = [&worker] {
        if (worker.request_stop())
            osyncstream{cout} << "Stop request executed by
              thread: "
                              << this_thread::get_id()
                              << endl;
    };
    jthread stopper(stopper_func);
    stopper.join(); }

输出如下:

Main thread id: 140323902175040
Stop callback executed by thread: 140323893778176
Stop request executed by thread: 140323893778176
Thread with id 140323902170880 is currently working.

因此,工作线程仍然在执行,但stopper线程在stop_callback函数中获得了停止令牌。当通过停止函数请求停止时,条件变量将通过令牌被信号。

既然我们有了除了信号量之外的另一种线程间通信的机制,我们就可以让共享内存重新回到游戏中。让我们看看它是如何与条件变量和智能指针一起工作的。

结合智能指针、条件变量和共享内存

我们已经在第七章**,使用共享内存中探讨了共享内存的概念。现在,让我们利用本章前面部分的知识,通过一些 C++技术来增强代码的安全性。我们稍微简化了场景。完整的示例可以在github.com/PacktPublishing/C-Programming-for-Linux-Systems/tree/main/Chapter%209找到。

我们使用unique_ptr参数提供一个特定的析构函数:

template<typename T>
struct mmap_deallocator {
    size_t m_size;
    mmap_deallocator(size_t size) : m_size{size} {}
    void operator()(T *ptr) const {
       munmap(ptr, m_size);
    }
};

我们依赖于以下:

unique_ptr<T, mmap_deallocator<T>>(obj, del);

正如你所见,我们也在使用模板来提供在共享内存中存储任何类型对象的可能性。在堆中保持具有大型层次结构和成员的复杂对象很容易,但存储和访问它们的数据并不简单。多个进程将能够访问共享内存中的这些对象,但这些进程能否引用指针后面的内存?如果引用的内存不在那里或者共享虚拟地址空间中,那么将会抛出一个内存访问违规异常。因此,要小心处理。

我们继续进行下一个示例。使用已知的条件变量技术,但这次我们添加了一个真实的谓词来等待:

mutex cv_mutex;
condition_variable cond_var;
bool work_done = false;

我们的producer()方法创建并映射了{1}。这种技术被称为new运算符同时执行这两个操作。此外,对象本身被一个带有相应析构函数的unique_ptr对象包装。一旦离开作用域,该内存部分将通过munmap()方法重置。使用条件变量向消费者发出信号,表明数据已准备就绪:

template<typename T, typename N>
auto producer(T buffer, N size) {
    unique_lock<mutex> lock(cv_mutex);
    cond_var.wait(lock, [] { return work_done == false; });
    if (int fd =
            shm_open(SHM_ID, O_CREAT | O_RDWR, 0644);
                     fd != -1) {
        ftruncate(fd, size);

创建并调整了shm区域的大小。现在,让我们使用它来存储数据:

        if (auto ptr =
                mmap(0, size,
                     PROT_RW, MAP_SHARED,
                     fd, 0); ptr != MAP_FAILED) {
            auto obj = new (ptr) T(buffer);
            auto del = mmap_deallocator<T>(size);
            work_done = true;
            lock.unlock();
            cond_var.notify_one();
            return unique_ptr<T,
                mmap_deallocator<T>>(obj, del);
        }
        else {
          const auto ecode{ make_error_code(errc{errno}) };
…
        }
    }
    else {
        const auto ecode{ make_error_code(errc{errno}) };
...
        throw exception;
    }
    // Some shm function failed.
    throw bad_alloc();
}

消费者的实现方式类似,只是等待以下情况:

cond_var.wait(lock, []{ return work_done == true; });

最后,启动并连接两个线程作为生产者和消费者,以提供以下输出:

Sending: This is a testing message!
Receiving: This is a testing message!

当然,示例可以更加复杂,添加周期性的生产和消费。我们鼓励你尝试一下,只需使用另一种类型的缓冲区——如你所记得的,string_view对象是常量。确保析构函数被正确实现和调用。它用于使代码更安全并排除内存泄漏的可能性。

正如你可能观察到的,在我们这本书的工作中,我们经常只想访问一个对象来读取它,而不修改其数据。在这种情况下,我们不需要全面的锁定,但需要某种方法来区分仅仅是读取数据还是修改它。这种技术是读写锁,我们将在下一节中介绍。

使用 C++实现读写锁和 ranges

POSIX 直接提供了读写锁机制,而 C++则将其隐藏在不同的名称下——shared_mutexshared_timed_mutex。让我们看看在 POSIX 中它是如何传统上工作的。我们有读写锁对象(rwlock),它具有预期的 POSIX 接口,其中线程可以对其持有多个并发读锁。目标是允许多个读取器访问数据,直到一个线程决定修改它。那个线程通过写入锁来锁定资源。大多数实现更倾向于写入锁而不是读锁,以避免写入饥饿。当涉及到数据竞争时,这种行为不是必要的,但它确实会导致应用程序执行的最小瓶颈。

这尤其适用于处理大规模系统的数据读取器——例如,多个只读 UI。C++的特性再次为我们提供了这个任务的简单而强大的工具。因此,我们不会花时间研究 POSIX 的示例。如果你感兴趣,我们建议你自己查看,从 https://linux.die.net/man/3/pthread_rwlock_rdlock 开始。

在继续 C++示例之前,让我们考虑以下场景——少数线程想要修改一个共享资源——一个数字向量,而更多的线程想要可视化数据。我们在这里想要使用的是shared_timed_mutex。它允许两种级别的访问:独占,其中只有一个线程可以拥有互斥锁;和共享,其中多个线程共享互斥锁的所有权。

重要提示

请记住,shared_timed_mutexshared_mutex类型比简单的mutex更重,尽管在某些平台上shared_mutex被认为比shared_timed_mutex更高效。当你确实需要大量的读取操作时,你应该使用它们。对于短时间的操作爆发,坚持使用互斥锁会更好。你需要具体测量你系统的资源使用情况,以便确定选择哪一个。

以下示例说明了shared_mutex的使用方法。我们还将利用这个机会介绍 C++中的ranges库。这个特性是 C++20 的一部分,并且与string_views一起提供了一种灵活的方式来可视化、过滤、转换和切片 C++容器,以及其他功能。通过这个示例,你将了解一些关于ranges库的有用技术,这些技术将伴随着代码进行解释。完整的示例可以在 https://github.com/PacktPublishing/C-Programming-for-Linux-Systems/tree/main/Chapter 9 找到。

让我们有一个带有共享资源——书籍 vectorBook 结构体。我们将使用 shared_mutex 来处理读写锁:

struct Book {
    string_view title;
    string_view author;
    uint32_t    year;
};
shared_mutex shresMutex;
vector<Book> shared_data =  {{"Harry Potter", ...

我们使用 wr_ 前缀实现向共享资源添加书籍的方法,以区分其与其他方法的角色。我们还在资源上执行写锁(标记 {1}):

void wr_addNewBook(string_view title,
                   string_view author,
                   uint32_t year) {
    lock_guard<shared_mutex> writerLock(shresMutex); // {1}
    osyncstream{cout} << "Add new book: " << title << endl;
    shared_data.emplace_back(Book {title, author, year});
    this_thread::sleep_for(500ms);
}

现在,我们开始实现多个读取例程。它们以 rd_ 前缀标记,并且每个例程执行一个读取锁,这意味着资源将同时可供多个读取者使用:

void rd_applyYearFilter(uint32_t yearKey) {
    auto year_filter =
        yearKey
       { return book.year < yearKey; };
    shared_lock<shared_mutex> readerLock(shresMutex);
    osyncstream{cout}
   << "Apply year filter: " << endl; // {2}
    for (const auto &book : shared_data |
                            views::filter(year_filter))
        osyncstream{cout} << book.title << endl;
}

观察标记 {2} 之后的 for 循环。它不仅遍历共享资源,而且通过管道(|)字符过滤掉其部分内容,这与在第三章中介绍的管道和 grep 类似,但在这里,它不是一个管道。我们通过管道操作符创建了一个范围视图,从而为迭代提供了额外的逻辑。换句话说,我们操作了容器的视图。这种方法不仅适用于 vectors,也适用于其他 C++ 可迭代对象。为什么?范围用于通过迭代器扩展和泛化算法,使代码更加紧凑且错误率更低。

很容易看出这里的范围意图。此外,范围视图是一个轻量级对象,类似于 string_view。它表示一个可迭代的序列——范围本身,是在容器的迭代器之上创建的。它基于Curiously Recurring Template Pattern。通过范围接口,我们可以改变容器的表示,以某种方式转换其值,过滤掉值,分割和组合序列,展示唯一元素,打乱元素,滑动窗口通过值,等等。所有这些操作都是通过已实现的简单范围适配器语法完成的。在我们的示例中,rd_applyYearFilter 有一个 for 循环,其中过滤掉了比 yearKey 更早的书籍。我们也可以以逆序打印共享资源的元素:

void rd_Reversed() {
    for (const auto &book : views::reverse(shared_data))
        osyncstream{cout} << book.title << endl; ...

我们甚至可以将视图组合起来,如下所示:

for (const auto &book :
         views::reverse(shared_data) |
         views::filter(nameSizeKey
              {return book.author.size() < nameSizeKey;}))}

之前的代码片段以逆序遍历元素,但它还过滤掉了作者姓名长度超过给定值的书籍。在下一个代码片段中,我们展示了如何在迭代过程中简单地丢弃容器的一部分:

for (const auto &book :
   ranges::drop_view(shared_data, dropKey))
        osyncstream{cout} << book.title << endl;

如果这太通用,你可以使用特定的子范围,这将创建一个 range 对象。range 对象可以像任何其他对象一样使用,如下所示:

auto const sub_res =
    ranges::subrange(shared_data.begin(),
                     shared_data.begin()+5);
    for (const auto& book: sub_res){
        osyncstream{cout}
        << book.title << " " << book.author
             <<  " " << book.year << endl;

完成所有这些操作后,我们创建线程以并发方式执行所有这些操作,并观察读写锁如何管理它们。运行示例将根据线程的调度产生不同的输出顺序:

    thread yearFilter1(
        []{ rd_applyYearFilter(1990); });
    thread reversed(
        []{ rd_Reversed(); });
    thread reversed_and_filtered(
        []{ rd_ReversedFilteredByAuthorNameSize(8); });
    thread addBook1(
        []{ wr_addNewBook("Dune", "Herbert", 1965); });
    thread dropFirstElements(
        []{ rd_dropFirstN(1); });
    thread addBook2(
        []{ wr_addNewBook("Jaws", "Benchley", 1974); });
    thread yearFilter2(
        []{ rd_applyYearFilter(1970); });

输出按照描述的范围视图(以下内容略有调整以便于阅读):

Apply reversed order:
It
East of Eden
Harry Potter
Drop first N elements:
East of Eden
It
Apply reversed order and filter by author name size:
It
Harry Potter
Apply year filter:
East of Eden
It
Add new book: Dune
Apply year filter:
East of Eden
Dune
Add new book: Jaws
Print subranged books in main thread:
East of Eden Steinbeck 1952
It King 1986

你现在已经了解了另一种技术组合,你可以使用它来扩展一个由多个线程处理展示任务的系统。现在让我们退一步,讨论一下与数据竞争无关的并发执行可能出现的陷阱。我们继续讨论缓存友好的代码。

讨论多处理器系统——C++中的缓存局部性和缓存友好性

你可能还记得此时此刻的第二章,在那里我们讨论了多线程和多核处理器。相应的计算单元被表示为处理器。我们还可视化了指令从NVM(磁盘)传输到处理器的过程,通过这个过程我们解释了进程和软件线程的创建。

我们希望我们的代码能够满足性能要求。让代码表现良好的最重要的方面是选择合适的算法和数据结构。经过一些思考,你可以尝试从每个 CPU 周期中榨取最大价值。算法误用的最常见例子之一就是使用冒泡排序对大型无序数组进行排序。所以,确保学习你的算法和数据结构——结合本节及以后的知识,这将使你成为一个真正强大的开发者。

正如你所知,我们离 RAM 越远,接近处理器寄存器的程度越高,操作就越快,内存容量就越小。每次处理器从 RAM 中加载数据到缓存时,它要么只是坐着等待数据出现,要么执行其他非相关任务。因此,从当前任务的角度来看,CPU 周期被浪费了。当然,达到 100%的 CPU 利用率可能是不可能的,但我们应该至少意识到它在做无谓的工作。所有这些可能在你现在看来似乎没有意义,但如果我们粗心大意,并发系统将会受到影响。

C++ 语言提供了多种工具来进一步提高性能,包括通过硬件指令的预取机制分支预测优化。即使不做任何特别的事情,现代编译器和 CPU 也会使用这些技术做得很好。然而,通过提供正确的提示、选项和指令,我们还可以进一步提高性能。了解缓存中的数据也有助于减少访问它所需的时间。请记住,缓存只是数据和指令的一种快速、临时的存储类型。因此,当我们以良好的方式处理缓存时,即所谓的缓存友好代码,我们可以利用 C++ 的特性。需要注意的是,这个陈述的反面——误用 C++ 特性会导致缓存性能不佳,或者至少不是最佳性能。您可能已经猜到这与系统的规模和快速数据访问的需求有关。让我们在下一节中进一步讨论这个问题。

通过缓存友好代码考虑缓存局部性

我们已经提到了缓存友好代码的概念,但它究竟意味着什么呢?首先,您需要了解 int 或甚至无符号的 char

因此,缓存已经成为几乎所有系统的主要方面。在本书的前面,我们提到较慢的硬件,如磁盘,有时有自己的缓存内存来减少访问频繁打开的文件所需的时间。操作系统可以缓存频繁使用的数据,例如文件,作为虚拟地址空间的部分,从而进一步提高性能。这也被称为时间局部性

考虑以下场景:第一次尝试在缓存中找不到数据,这被称为缓存未命中。然后它在 RAM 中查找,找到后,作为一个或多个缓存块缓存行被加载到缓存中。之后,如果此数据被多次请求并且仍然在缓存中找到,这被称为缓存命中,它将保留在缓存中并保证更快的访问,或者至少比第一次缓存未命中更快。您可以在以下图中观察到这一点:

图 9.2 – 硬件级别上时间局部性的表示

图 9.2 – 硬件级别上时间局部性的表示

正如我们之前提到的预取机制,有一个已知的事实,即具有多个缓存命中的对象意味着其周围的数据也可能很快被引用。这导致处理器请求预取从 RAM 中额外的附近数据,并提前将其加载到缓存中,以便在最终需要时它将在缓存中。这导致了空间局部性,意味着访问附近的内存并从缓存以块的形式(称为缓存行)进行的事实中受益,从而只需支付一次传输费用并使用几个字节的内存。预取技术假设代码已经具有空间局部性,以提高性能。

这两个局部性原则都是基于假设的。但代码分支需要良好的设计。分支树越简单,预测就越简单。再次强调,你需要仔细考虑要使用的数据结构和算法。你还需要旨在连续内存访问,将代码简化为简单的循环和小函数;例如,从使用链表切换到数组或矩阵。对于小型对象,std::vector容器仍然是最佳选择。此外,我们理想地寻求一个可以适应一个缓存行的数据结构对象——但有时这因为应用程序的要求而根本不可能。

我们的过程应该以连续块的形式访问数据,其中每个块的大小与缓存行的大小相同(通常是 64 字节,但取决于系统)。但如果我们要进行并行评估,那么每个 CPU 核心(处理器)处理的数据最好与其他核心的数据在不同的缓存行中。如果不是这样,缓存硬件将不得不在核心和 CPU 之间来回移动数据,CPU 将再次浪费在无意义的工作上的时间,性能将下降,而不是提高。这个术语被称为伪共享,我们将在下一节中对其进行探讨。

简要了解伪共享

通常情况下,除非程序员另有指示,否则小数据块将组合在一个单独的缓存行中,正如我们将在以下示例中看到的那样。这是处理器为了保持低延迟而工作的方式——一次处理每个核心的一个缓存行。即使它不是满的,缓存行的大小也将被分配为 CPU 可以处理的最小块。如前所述,如果两个或更多线程独立地请求该缓存行中的数据,那么这将减慢多线程执行的效率。

处理伪共享的影响意味着获得可预测性。就像代码分支可以被预测一样,系统程序员也可以预测一个对象是否是缓存行的大小,因此每个单独的对象可以驻留在自己的内存块中。此外,所有计算都可以在局部作用域内进行,共享数据修改发生在给定过程的末尾。当然,这种活动最终会导致资源的浪费,但这是一个设计和偏好的问题。如今,我们可以使用编译器优化来提高这种可预测性和性能,但我们不应该总是依赖于此。让我们首先检查我们的缓存行大小:

#include <iostream>
#include <new>
using std::hardware_destructive_interference_size;
int main() {
    std::cout << "L1 Cache Line size: "
        << hardware_destructive_interference_size
        << " bytes";
    return 0;
}

预期的输出如下:

L1 Cache Line size: 64 bytes

现在我们知道了如何使用std::atomic来保证对共享资源的单一修改,但我们同时也强调了这并不是全部。让我们用三个原子变量丰富之前的例子:

    cout << "L1 Cache Line size: "
         << hardware_constructive_interference_size
         << " bytes" << endl;
    atomic<uint32_t> a_var1;
    atomic<uint32_t> a_var2;
    atomic<uint32_t> a_var3;

打印地址给出以下结果:

       cout << "The atomic var size is: " << sizeof(a_var1)
            << " and its address are: \n"
            << &a_var1 << endl
            << &a_var2 << endl
            << &a_var3 << endl;
        ...

输出如下:

L1 Cache Line size: 64 bytes
The atomic var size is: 4 and the addresses are:
0x7ffeb0a11c7c
0x7ffeb0a11c78
0x7ffeb0a11c74

这意味着即使我们有原子变量,它们也可以适应单个atomic_ref<T>::required_alignment,这允许程序员根据当前的缓存行大小对齐原子,从而保持它们之间的良好距离。让我们如下应用它到所有原子变量:

    alignas(hardware_destructive_interference_size)
        atomic<uint32_t> a_var1;

输出如下:

L1 Cache Line size: 64 bytes
The atomic var size is: 4 and the addresses are:
0x7ffc3ac0af40
0x7ffc3ac0af00
0x7ffc3ac0aec0

在前面的代码片段中,你可以看到地址的差异正如预期的那样,变量对齐良好,这始终是系统程序员的职责。现在,让我们应用你可能从第七章中记得的increment()方法:

void increment(std::atomic<uint32_t>& shared_res) {
    for(int I = 0; i < 100000; ++i) {shared_res++;}
}

我们增加一个原子资源,正如在第八章中所述,我们知道如何测量过程的持续时间。因此,我们可以分析以下四种场景的性能。有一点需要注意——如果你有兴趣,可以调整编译器的优化级别来观察以下值的差异,因为我们没有使用任何优化标志。完整的代码示例可以在github.com/PacktPublishing/C-Programming-for-Linux-Systems/tree/main/Chapter%209找到。我们的场景如下:

  • 单线程应用程序,调用increment() 3 次,对一个原子变量进行 300,000 次增加,耗时 2,744 微秒

  • 直接与一个原子变量进行共享,由 3 个线程并行地各自增加 100,000 次,耗时 5,796 微秒

  • 与三个未对齐的原子变量发生伪共享,由 3 个线程并行地各自增加 100,000 次,耗时 3,545 微秒

  • 不与三个对齐的原子变量共享,由 3 个线程并行地各自增加 100,000 次,耗时 1,044 微秒

由于我们没有使用基准测试工具,我们无法测量缓存未命中或命中的次数。我们只是做以下操作:

    ...
    auto start = chrono::steady_clock::now();
    alignas(hardware_destructive_interference_size)
        atomic<uint32_t> a_var1 = 0;
    alignas(hardware_destructive_interference_size)
        atomic<uint32_t> a_var2 = 0;
    alignas(hardware_destructive_interference_size)
        atomic<uint32_t> a_var3 = 0;
    jthread t1([&]() {increment(a_var1);});
    jthread t2([&]() {increment(a_var2);});
    jthread t3([&]() {increment(a_var3);});
    t1.join();
    t2.join();
    t3.join();
    auto end = chrono::steady_clock::now();
    ...

无共享工作在以下图中展示:

图 9.3 – 多核心/线程上无共享(正确共享)数据的表示

图 9.3 – 多核心/线程上无共享(正确共享)数据的表示

重要提示

显然,我们或者必须在并行修改它们之前对原子资源进行对齐,或者为小过程使用单线程应用程序。时间度量可能因系统和编译器优化标志而异。记住,当你从硬件中获得最佳性能时,这些加速是非常好的,但深入这么多细节也可能导致代码复杂、调试困难以及维护上的时间浪费。这是一个平衡行为。

在多线程期间发生伪共享,如果共享对象适合一个缓存行,则可以修复。但如果对象的大小超过一个缓存行呢?

在 C++中共享大于缓存行的资源

这里的分析相对简单,因为它并不那么依赖于语言。代表大型数据结构的大对象只是...很大。它们无法适应单个缓存行,因此它们在本质上不是缓存友好的。面向数据的设计处理这个问题。例如,你可以考虑使用更小的对象,或者只为并行工作共享它们的小部分。此外,考虑算法的优化也是好的。使它们线性化会导致更好的分支预测。这意味着使条件语句依赖于可预测的而不是随机的数据。复杂的条件语句可以用算术解决方案和模板替换,或者以不同的方式链接,这样 CPU 就更容易预测哪个分支有更高的发生概率。这些操作,再次强调,可能会导致难以阅读的代码和复杂的调试,因此只有在代码不够快以满足你的要求时才进行这些操作。

由于分支预测错误可能代价高昂且隐藏得很好,因此另一个建议是所谓的条件移动。它不是基于预测,而是基于数据。数据依赖包括条件为真条件为假的情况。在执行将数据从寄存器之一移动到另一个寄存器的条件指令之后,第二个寄存器的内容取决于它们的先前值和第一个寄存器的值。正如提到的,良好的分支设计允许更好的性能。但是数据依赖需要一到两个 CPU 周期才能到达,有时使它们成为一个更安全的赌注。一个可能的陷阱是当条件是这样的,从内存中取出的值没有被分配给寄存器时——那么它就只是无意义的等待。幸运的是,对于系统程序员来说,指令集中的条件移动指令通常在寄存器上很接近。

std::arraystd::vector。是的,向量可以调整大小,但它仍然对缓存友好,因为元素在内存中是相邻的。当然,如果你因为频繁调整大小而需要重新分配向量,那么可能这不是你需要的数据结构。你可以考虑使用 std::deque 容器,它在集合中间的修改中效率很高,或者作为替代的 std::list,它是一个链表,并且完全不友好缓存。

重要提示

根据系统不同,许多连续内存块的重新分配(构造和析构)可能会导致内存碎片化。这种情况可能由于内存管理的软件算法、语言标准、操作系统、驱动程序、设备等原因造成。直到它发生之前很难预测。内存分配开始失败可能需要大量的连续执行时间。在 RAM 中,所有空闲内存块的总和可能有足够的空闲空间,但可能没有足够大的单个块来容纳当前重新分配或创建的连续块。过度的碎片化可能导致性能下降,甚至服务拒绝。

关于这个主题的最后一句话是,有许多文章讨论了高效使用 C++ 算法和容器的最佳方法。这值得一本单独的书,而且大多数情况下都是非常 CPU 特定的——或者至少当你达到绝对性能时是这样。例如,条件移动会直接导致汇编代码,而这在这里我们没有机会去探索。话虽如此,当涉及到算法和数据结构时,针对不同实际问题的解决方案种类繁多。

通过自旋锁实现重新审视 C++ 内存模型中的共享资源

我们在第七章中学习了原子操作。在本章中,你了解到原子变量在缓存中的放置同样至关重要。最初,原子和锁的引入是为了在多个线程想要进入相同的临界区时保证正确性。现在,我们的研究将深入一点。原子操作的最后一块拼图。请检查以下代码片段:

Thread 1: shrd_res++; T1: load value
                      T1: add 1
Thread 2: shrd_res++; T2: load value
                      T2: add 1
                      T2: store value
                      T1: store value

这是一个非原子操作的例子。即使我们将其变为原子操作,我们仍然没有关于指令顺序的说明。直到现在,我们使用同步原语来指示 CPU 哪些指令部分必须作为一个统一的上下文来处理。我们现在需要指示处理器这些指令的顺序。我们通过 C++ 的 memory_order 来实现,它是 C++ 标准内存模型的一部分。

在 C++ 中引入 memory_order 类型

使用memory_order类型,我们指定原子和非原子内存访问在原子操作周围的排序方式。前一小节中的代码片段的原子实现以及本章早期使用读写锁的示例都可能存在相同的问题:两个原子操作作为一个整体并不是原子的。原子作用域内的指令顺序将被保持,但不是在它周围。这通常是在 CPU 和编译器的优化技术之后完成的。因此,如果有许多读取线程,我们(和线程)期望观察到的变化顺序可能会变化。这种效果甚至可能在单线程执行期间出现,因为编译器可能会根据内存模型重新排列指令。

注意

我们鼓励您在此处查看有关memory_order的完整信息:https://en.cppreference.com/w/cpp/atomic/memory_order。

一个重要的说明是,C++中所有原子操作默认的行为是应用顺序一致性排序。C++20 中定义的内存排序如下:

  • 松弛排序,标记如下:

    memory_order_relaxed = memory_order::relaxed;
    

    这种排序是最基本的。这是最便宜的选择,除了当前操作的原子性外,不提供任何保证。一个例子是shared_ptr引用计数器的增加,因为它需要是原子的,但不需要排序。

  • 发布-获取排序,标记如下:

    memory_order_acquire = memory_order::acquire;
    memory_order_release = memory_order::release;
    memory_order_acq_rel = memory_order::acq_rel;
    

    当发布操作生效时,防止读取和写入在原子区域之后重新排序。acquire操作类似,但在原子区域之前禁止重新排序。第三种模型acq_rel是两者的组合。这种模型在创建读写锁时可能非常有帮助,但因为没有进行锁定操作。shared_ptr引用计数的递减是通过这种技术完成的,因为它需要与析构函数同步。

  • 发布-消费排序,标记如下:

    memory_order_consume = memory_order::consume;
    

    consume操作的要求至今仍在修订中。它被设计成像acquire操作一样工作,但仅针对特定数据。这样,编译器在优化代码时比acquire操作更灵活。显然,正确处理数据依赖会使代码更复杂,因此这种模型并不广泛使用。您可以在访问很少写入的并发数据结构时看到它——配置和设置、安全策略、防火墙规则,或者通过指针介导发布的发布-订阅应用程序;生产者通过一个指针发布,消费者可以通过该指针访问信息。

  • 顺序一致性排序,标记如下:

    memory_order_seq_cst = memory_order::seq_cst;
    

    这与松散顺序正好相反。原子区域及其周围的所有操作都遵循严格顺序。没有任何指令可以跨越原子操作强加的障碍。它被认为是最昂贵的模型,因为所有优化机会都丢失了。顺序一致排序对多生产者-多消费者应用程序很有帮助,其中所有消费者都必须按精确顺序观察所有生产者的操作。

一个直接受益于内存顺序的著名例子是自旋锁机制。我们将在下一节中继续探讨这个问题。

在 C++中为多处理器系统设计自旋锁

操作系统经常使用这种技术,因为它对短期的操作非常有效,包括逃避重新调度和上下文切换的能力。但是,长时间持有的锁可能会被操作系统调度器中断。自旋锁意味着一个给定的线程要么获取锁,要么会等待自旋(在一个循环中)——检查锁的可用性。我们在本章介绍协作取消时讨论了类似的忙等待例子。这里的危险是,长时间保持锁将使系统进入活锁状态,正如在第二章中描述的那样。持有锁的线程通过释放它不会进一步进展,而其他线程将保持自旋状态,试图获取锁。C++非常适合实现自旋锁,因为原子操作可以详细配置。在底层编程中,这种方法也被称为测试-设置。以下是一个例子:

struct SpinLock {
    atomic_bool state = false;
    void lock() {
        while (state.exchange(true,
                              std::memory_order_acquire){
            while (state.load(std::memory_order_relaxed))
           // Consider this_thread::yield()
                // for excessive iterations, which
                // go over a given threshold.
}
    void unlock() noexcept {
        state.store(false, std::memory_order_release); };

你可能想知道为什么我们不使用已经知道的同步技术。好吧,记住,这里所有的内存顺序设置只花费一个 CPU 指令。它们既快又简单,从软件和硬件的角度来看。尽管如此,你应该限制它们的使用时间非常短,因为 CPU 被阻止为另一个进程做有用的工作。

使用原子布尔值来标记SpinLock的状态是锁定还是解锁。unlock()方法很简单——当关键部分释放时,通过释放顺序将false值(store()是原子的)设置到state成员。所有后续的读写操作都必须以原子方式排序。lock()方法首先运行一个循环,尝试访问关键部分。exchange()方法将state设置为true,并返回先前的值false,从而中断循环。从逻辑上讲,这与信号量P(S)V(S)函数非常相似。内循环将执行无序限制的忙等待场景,并且不会产生缓存未命中

重要提示

store()load()exchange()操作有memory_order要求,以及支持的一组顺序。使用额外的和意外的顺序会导致未定义的行为,并使 CPU 忙碌而不做有用的工作。

自旋锁的高级版本是票据锁算法。与队列类似,票据以先进先出的方式提供给线程。这样,它们进入临界区的顺序就可以公平地管理。与自旋锁相比,这里避免了饥饿。然而,这种机制的可扩展性并不好。首先,需要读取、测试和获取锁的指令更多,因为管理顺序的指令也更多。其次,一旦临界区空闲,所有线程的上下文都必须加载到缓存中,以确定它们是否被允许获取锁并进入临界区。

由于其低延迟,C++在这里具有优势。完整的示例可在github.com/PacktPublishing/C-Programming-for-Linux-Systems/tree/main/Chapter%209找到。

首先,我们实现TicketLock机制,提供必要的lock()unlock()方法。我们使用两个辅助成员变量,servingnext。正如你所见,它们被对齐以在单独的lock()unlock()方法中实现。此外,通过fetch_add()执行原子递增,允许锁生成票据。没有读写操作发生在其周围,因此它以宽松的顺序执行。与SpinLock不同,unlock()方法以宽松的方式加载一个票据号值,并将其存储为当前服务的线程:

struct TicketLock {
    alignas(hardware_destructive_interference_size)
        atomic_size_t serving;
    alignas(hardware_destructive_interference_size)
        atomic_size_t next;

TicketLock算法的锁定和解锁方法如下:

    void lock() {
        const auto ticket = next.fetch_add(1,
                                memory_order_relaxed);
        while (serving.load(memory_order_acquire) !=
               ticket);
    }
    void unlock() {
        serving.fetch_add(1, memory_order_release);
    }
};

现在,创建了一个全局的spinlock对象,其类型为TicketLock。我们还创建了一个vector,它充当共享资源。producer()consumer()例程如预期的那样——前者将创建数据,后者将消费它,包括清除共享资源。由于这两个操作都将并行执行,它们的执行顺序是随机的。如果你想创建类似乒乓的行为,可以使用条件变量信号量作为信号机制。当前的实现仅限于票据锁的目的:

TicketLock spinlock = {0};
vector<string> shared_res {};
void producer() {
    for(int i = 0; i < 100; i ++) {
        osyncstream{cout} << "Producing: " << endl;
        spinlock.lock();
        shared_res.emplace_back("test1");
        shared_res.emplace_back("test2");
        for (const auto& el : shared_res)
            osyncstream{cout} << "p:" << el << endl;
        spinlock.unlock();
        this_thread::sleep_for(100ms);
    }
}

消费者与你已经学过的类似:

void consumer() {
    for (int i = 0; i < 100; i ++) {
         this_thread::sleep_for(100ms);
         osyncstream{cout} << "Consuming: " << endl;
         spinlock.lock();
         for (const auto& el : shared_res)
             osyncstream{cout} << "c:" << el << endl;

移除向量中的内容:

         shared_res.clear();
         spinlock.unlock();
         if (shared_res.empty())
             osyncstream{cout} << "Consumed" << endl;
     }
}

输出如下:

Producing:
p:test1
p:test2
Consuming:
c:test1
c:test2
...

输出显示,生产和消费例程被视为一个整体,尽管它们没有被调用相同次数,这是预期的。如前所述,除了暂停线程100ms外,你也可以通过添加一个条件变量来修改代码:

void producer() {
    for(int i = 0; i < 100; i ++) {
        cout <<"Producing:" << endl;
        unique_lock<mutex> mtx(cv_mutex);
        cond_var.wait(mtx, []{ return work_done ==
                                      !work_done; });

继续进行预期的关键部分:

        spinlock.lock();
        shared_res.emplace_back"test1");
        shared_res.emplace_back"test2");
        for (const auto& el : shared_res)
            cout <<"p" << el << endl;
        spinlock.unlock();
        work_done = !work_done;
    }
}

结合所有这些技术——内存健壮性、同步原语、缓存友好性和指令排序意识——你拥有了真正提升代码性能和调整以获得特定系统最佳性能的工具。我们想借此机会提醒你,这样的详细优化可能会导致代码难以阅读和调试困难,因此只有在需要时才使用它们。

摘要

在本章中,我们汇集了实现 C++代码最佳性能所需的所有工具。你学习了在不同系统和软件层面上的技术,因此你现在想要休息一下是可以理解的。确实,花更多时间在一些我们覆盖的内容上会更好,例如分支预测缓存友好性,或者通过条件变量和内存顺序实现更多算法。我们强烈建议你将本章作为系统改进和更高效工作的一个步骤。

下一章将专注于 C++特性中的一个更多重要改进——协程。你会发现它们要轻得多,对于这里讨论的一些机制,如事件等待,它们要更受欢迎得多。

第十章:使用 C++进行系统编程的协程

我们几乎到了本书的结尾。最后一章致力于一个对于系统编程非常有用但相对较新的 C++标准特性。协程对象迅速找到了它们的应用,成为了一等状态机对象。它们的强大之处在于隐藏在协程帧背后的逻辑。请注意,这是一个高级主题,C++的协程接口既不简单也不舒适使用。它经过深思熟虑,但与其他编程语言相比,绝对不是最用户友好的。

在本章中,您将学习使用此功能的基本知识。如果您是初学者,那么您将花费一些时间来理解其要求。如果您在其他编程语言中有协程的先前经验,那么您将更容易使用协程。尽管如此,我们仍将利用本章来探讨其在系统编程中的应用。

我们将展示与网络共享内存相关的先前示例的两个实际解决方案。您将立即看到例程的可预测性和清晰的执行路径。我们希望您对无需使用同步原语而执行的并发方式印象深刻。在现实世界环境中的直接重用是可能的;只需确保您有所需的编译器,因为这个特性仍然很新。不再拖延,让我们进入我们的最后一个主题。

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

  • 介绍协程

  • C++中的网络编程和协程

  • 通过 C++中的协程重新审视共享内存问题

  • 对协程及其在 C++中的实现的最终思考

技术要求

为了运行代码示例,您必须准备以下内容:

介绍协程

在你的旅程结束时,我们想提醒你关于你在第一章第二章中学到的关于进程线程的知识。如果你记得很好,进程只是一个程序的运行实例。它有自己的地址空间,这个地址空间不与其他共享,除非通过共享内存。线程存在于进程内,并且它们不能独立于进程存在,尽管在 Linux 中,进程和线程都被视为任务。它们以相同的方式进行调度,并且在内核级别上有相同的控制结构。然而,线程被认为是轻量级的,因为程序初始加载的大开销由父进程承担。

但这并不是全部。还有纤程和协程。如果进程和线程真正是并发的,并且并行地在共享资源上工作,纤程就像线程一样,但不是并发兼容的。虽然线程通常依赖于任务调度器的抢占式时间切片,但纤程使用协作式多任务处理。也就是说,在执行时,它们会自己让出运行权给另一个纤程。它们也被称为堆栈式协程。同时,C++中的协程被称为无堆栈协程,并且不是由操作系统管理的。换句话说,堆栈式协程可以在嵌套的堆栈帧中被挂起,而无堆栈协程只能通过顶层例程进行嵌套。

这两个设施被认为是隐式同步的,因此所有来自前几章的同步原语和原子构造都是不必要的。但你可以想象早期的例子,即从文件系统中读取——操作系统等待文件打开,并通知进程调用者继续其工作。想象一下,纤程和协程正是为了这种反应式访问而有用,这种访问不需要额外的 CPU 处理。实际上,网络和文件系统是纤程和协程被认为最有价值的领域。当一个请求被提出时,纤程将控制权交给主线程,而当 I/O 操作完成时,纤程继续其暂停的地方。

协程技术相当古老。C++最近引入了它,这对于网络编程、I/O 操作、事件管理等非常有用。协程也被认为是具有暂停能力的执行。然而,它们以协作的方式提供多任务处理,并不并行工作。这意味着任务不能同时执行。同时,它们对实时系统友好,允许快速在协程之间切换上下文,并且不需要系统调用。实际上,它们对实时操作系统非常友好,因为执行顺序和调度是由系统程序员控制的,正如你将在本章后面看到的那样。C++中的协程对于实现任务图和状态机也非常有用。

你们中的一些人可能想知道协程和标准单线程函数式编程之间的区别是什么。好吧,后者被认为是一种同步方法,而前者是一种具有同步可读性的异步方法。但协程实际上是为了减少不必要的(忙碌的)等待,并在准备所需资源或调用时做些有用的事情。下面的图示简单但提醒我们同步和异步执行之间的相应差异。

图 10.1 – 同步与异步应用程序执行对比

图 10.1 – 同步与异步应用程序执行对比

一个常规的单线程执行在某些方面也是有限的。首先,在程序内部调用、挂起或恢复一个函数是不可追踪的,或者至少不是通过引用来追踪。换句话说,控制流在后台发生,是隐式的。此外,控制流有一个严格的方向——一个函数要么返回给调用者,要么向内调用另一个函数。每次函数调用都会在栈上创建一个新的记录,并且立即发生,一旦调用,方法就不能延迟。一旦该函数返回,其栈的部分就会被清除,无法恢复。换句话说,激活是不可追踪的。

另一方面,协程有自己的生命周期。协程是一个对象,可以显式引用。如果协程应该比调用者存活得更久,或者应该转移到另一个协程,那么它可以存储在int func(int arg)原型中,这意味着一个名为func的函数,接收一个整型类型的参数arg,返回一个整型。类似的协程可能永远不会返回给调用者,而调用者期望的值可能由另一个协程产生。让我们看看在 C++中这是如何发生的。

C++中的协程功能

最初,你可以把它们想象成Task exCoroutine()任务(任务与 Linux 中对任务的定义不同)——如果它使用以下三个操作符之一:co_awaitco_yieldco_return,它就被解释为协程。以下是一个例子:

#include <coroutine>
...
Task exCoroutine() {
    co_return;
}
int main() { Task async_task = exCoroutine(); }

包装类型目前是Task。它在调用者级别上是已知的。协程对象通过co_return运算符被标识为exCoroutine()函数。创建Task类是系统程序员的职责。它不是标准库的一部分。那么Task类是什么呢?

struct Task {
    struct promise_type {
        Task get_return_object()
            { return {}; }
        std::suspend_never initial_suspend()
            { return {}; }
        std::suspend_never final_suspend() noexcept
            { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
};

重要提示

这是一个在几乎所有协程示例中都使用的非常通用的模式。您最初应该参考en.cppreference.com/w/cpp/language/coroutines

我们称执行给定例程但不返回值的任务为协程。此外,协程与一个promise对象相关联——我们曾在第六章中讨论过这一点。promise对象在协程级别上进行操作。协程通过这个对象返回操作结果或抛出异常。这个功能也要求使用promise。它还包括传递的参数——按值复制,当前调用引用的表示;挂起点,以便协程可以相应地恢复;以及该点之外的作用域内的局部变量。那么,我们的代码做了什么?从用户的角度来看,它什么也没做,但在后台有很多事情发生。让我们观察以下图表:

图 10.2 – 协程启动的简单演示

图 10.2 – 协程启动的简单演示

记住,按值参数在协程的作用域内被复制或移动,而按引用参数保持为引用。这意味着程序员应该考虑它们在任务调用者中的生命周期,以避免出现悬垂指针。之后,构造promise并调用get_return_object()。当协程首次挂起时,结果将返回给任务调用者。

图 10.2演示了promise返回suspend_always并且我们懒加载启动协程的情况。initial_suspend()操作恢复,但没有继续的知道或上下文,协程将永远不会恢复并且会泄漏。为了处理这种情况,我们需要...一个handle对象。你可以把handle对象看作是一个视图。类似于string_view对象和string对象之间的关系,或者vector对象和具有range view对象的range对象之间的关系,handle对象用于提供对*this的间接访问。通过handle对象,我们可以调用resume()来继续协程的工作。它必须首先挂起,否则行为将是未定义的:

图 10.3 – 图形演示协程的创建和恢复

图 10.3 – 图形演示了协程的创建和恢复

调用initial_suspend()操作,并通过co_await处理结果。这是通过编译器在suspend_never可等待对象周围生成额外的代码来实现的——协程不是以懒加载的方式创建,而是立即启动。这两个都在 C++标准库中定义。

当前协程执行co_return关键字(在exCoroutine()中)。但这样,协程主体就退出了。如果我们想用它来产生不断的新或下一个生成的值,那么我们需要co_yield运算符。我们称这样的协程为co_yield运算符,即co_await promise.yield_value(<some expression>)。否则,如果它只是调用co_await,那么它就像之前提到的任务一样。现在,如果我们再次看看图 10**.3,使用co_yield运算符将箭头从控制中的线程调用者重定向到协程执行,从而为协程继续工作提供机会。换句话说,co_return关键字将导致执行完成,而co_yield关键字只是暂时挂起协程。

让我们退一步,看看co_await调用。它们的工作在以下图中展示:

图 10.4 – 表示在 co_await 调用后生成的调用的图形

图 10.4 – 表示在 co_await 调用后生成的调用的图形

现在,使用Handle类型的私有变量来调用真正的resume()函数。让我们检查一下代码:

using namespace std;
struct Task {
    struct promise_type {
        using Handle = coroutine_handle<promise_type>;
        Task get_return_object() {
            return Task { Handle::from_promise(*this) };
        }
...

我们将使用explicit指定符。在 C++ 20 中,它允许你对构造函数调用更加严格。也就是说,它不能用于复制初始化或隐式转换。此外,我们保持我们的handle对象私有。现在,让我们看看这可能会派上什么用场(标记{1}和{2},同时提供一个包装器给调用者——标记{1}和{3}):

    explicit Task (promise_type::Handle crtHdnl) :
                                 crtHandle(crtHdnl) {}
    void resume() { crtHandle.resume(); } // {1}
private:
        promise_type::Handle crtHandle;   // {2}
...
    auto async_task = exCoroutine();
    async_task.resume();  // {3}

让我们使用这种代码结构来构建一个完全功能性的示例。我们将重命名Task结构为Generator,并实现一个具有生成器功能的协程。完整的代码可以在以下位置找到:github.com/PacktPublishing/C-Programming-for-Linux-Systems/tree/main/Chapter%2010

我们将通过协程增加变量 N 的次数。这就是为什么它需要能够产生,所以我们向Generator添加以下内容:

...
   suspend_always yield_value(auto value) {
            currValue = value;
            return {};
        }
...
        uint32_t currValue;
    };

然后,获取下一个元素的过程如下:

    int next() {
        crtHndl.resume();
        return crtHndl.promise().currValue; } ...

在主线程中继续协程主体及其创建。增量将发生 100,000 次。这个例子允许程序员以懒加载的方式生成数据,而不使用大量 RAM。同时,没有使用单独的线程,因此执行保持在用户空间,没有进行大量的上下文切换:

Generator exCoroutine() {
    auto idx = 0;
    for (;;) {
        co_yield idx++;
    }
}
int main() {
    auto crt = exCoroutine();
    for (auto idx = 1; (idx = crt.next()) <= 100000; )
        cout << idx << " ";
    cout << endl;
    return 0;
}

输出的简短版本如下:

1 2 3 4 ... 100000

很遗憾,你可能已经理解了为什么在 C++ 中创建一个简单的协程应用程序并不那么简单。作为一个新特性,这个功能仍在不断改进,预计在即将到来的 C++ 版本中会有新的接口,这将简化协程的使用。但这不应该阻止你继续使用它们。这个例子可以很容易地扩展到其他功能,你可以逐步建立你的知识。在接下来的章节中,我们将做 exactly this,并将讨论带回系统编程领域。

C++ 中的网络编程和协程

第七章 中,你学习了如何使用 Generator 定义来匹配 协程 的类型,正如之前讨论的那样。传统上,该对象被制成仅可移动的 – 这允许我们限制协程包装器的使用,但在一般情况下,协程对象是不可复制的且不可移动的,因为 协程帧 是它们的一部分,并且一些局部变量可以是其他局部变量的引用或指针。因此,让我们相应地扩展结构:

重要提示

这,再次,是一个非常通用的模式,几乎在每一个协程示例中都使用。你应该最初参考en.cppreference.com/w/cpp/language/coroutines

template<typename T> struct Generator {
    Generator(const Generator&)              = delete;
    Generator& operator = (const Generator&) = delete;
    Generator(Generator&& other) noexcept :
        c_routine(other.c_routine) {
        other.c_routine = {};
    }

你会注意到,struct 对象被定义为 template 以使其通用。我们重载 () 操作符,以便能够适当地将控制权交还给调用者:

    Generator& operator = (Generator&& other) noexcept {
        if (this == &other)
            return *this;
        if (c_routine)
            c_routine.destroy();
        c_routine = other.c_routine;
        other.c_routine = {};
        return *this;
    }
    optional<T> operator()() {
        c_routine.resume();
        if (c_routine.done()) {
            return nullopt;
        }
        return c_routine.promise().currValue;
    }

我们还在异常期间添加了行为 – 应用程序将被终止:

        void unhandled_exception() {
            exit(EXIT_FAILURE);
   }

在主线程中,我们创建并连接两个线程 – 一个服务器和一个客户端。每个线程都将执行相应域的协程。我们提供了一个 socket(以下代码中的标记 {9}):

   auto sockfd = 0;
    if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
        const auto ecode{ make_error_code(errc{errno}) };
        cerr << "Error opening shm region";
        system_error exception{ ecode };
        throw exception;
    }
    auto server = jthread([&sockfd] {
        struct sockaddr_in servaddr = { 0 };
        servaddr.sin_family = AF_INET;
        servaddr.sin_addr.s_addr = INADDR_ANY;
        servaddr.sin_port = htons(PORT);
        if (bind(sockfd,
            (const struct sockaddr*)&servaddr,
            sizeof(struct sockaddr_in)) < 0) {
            perror("Bind failed");
            exit(EXIT_FAILURE);
        }
        cout << "\nsend_to():\n";
        string_view message{ "This is a test!" };
        auto sender = send_to(sockfd, message,
           servaddr);
                                                   // {9}

sendto() 方法内部。我们使用 string_view 对象,就像我们在 第三章 中做的那样 – 主要原因是代码的安全性以及数据及其大小的紧凑性。在循环结束时,我们使用 co_yield value,从而向主线程提供发送的字节数。无限循环允许协程在真正被外部逻辑取消之前运行 – 在这种情况下,它被调用 10 次,因为主线程中的 for 循环(以下代码中的标记 {10}):

    for (int i = 1; i <= 10; i++) {
            auto sentData = sender();
            cout << i << " Bytes sent: "
                 << *sentData << endl;     // {10}
        }
    });

客户端线程以类似的方式实现:

    auto client = jthread([&sockfd] {
        cout << "\nrecv_from():\n" << endl;
        struct sockaddr_in clntaddr = { 0 };
        auto receiver = recv_from(sockfd, clntaddr);
        for (auto i = 1; i <= 10; i++) {
            auto recvData = receiver();
            cout << i << " Message received: "
                 << *recvData << endl;   // {11}
        }
    });
    server.join(); client.join();
    close(sockfd); return 0;
}

服务器端协程具有以下主体:

Generator<size_t> send_to(int sockfd,
                          string_view buffer,
                          auto servaddr) noexcept {
    for (;;) {
        auto value = sendto(sockfd,
                            buffer.data(),
                            buffer.size(),
                            MSG_DONTWAIT,
                            (const struct sockaddr*)
                                &servaddr,
                            sizeof(servaddr));
        co_yield value;
    }
}

客户端协程以类似的方式实现:

Generator<string> recv_from(int sockfd,
                                 auto clntaddr,
                                 size_t buf_size =
                                       BUF_SIZE) noexcept {
    socklen_t len = sizeof(struct sockaddr_in);
    array<char, BUF_SIZE> tmp_buf = {};

协程函数调用 recvfrom() 系统调用。在结束时,不是存储接收到的字节,而是将来自套接字的消息存储在 currValue 成员变量中。然后,在主线程中打印出来。我们还使用了 MSG_DONTWAIT 标志。由于代码是异步的,每次相应的输出将以不同的方式打印出来。最后一部分符合预期:

    for (;;) {
         recvfrom(sockfd,
                  tmp_buf.data(),
                  tmp_buf.size(),
                  MSG_DONTWAIT,
                  (struct sockaddr*)&clntaddr,
                  &len);
         co_yield tmp_buf.data();
    }

文本的合并或错位是预期之中的,但这证明了协程的可使用性。输出的简短版本如下:

send_to():
1 Bytes sent: 15
...
10 Bytes sent: 15
recv_from():
1 Message received: This is a test!
...
10 Message received: This is a test!

完整示例可以在 https://github.com/PacktPublishing/C-Programming-for-Linux-Systems/tree/main/Chapter 10 找到。

在上一章中,我们也遇到了同步并行线程的问题,但代码并不总是真正并行。例如,等待“资源可访问”这样的事件是并发的问题,而不是并行执行。话虽如此,协程在共享内存问题中也是一个强大的工具——让我们在下一节中看看它。

通过 C++ 协程重新审视共享内存问题

我们与 条件变量 有关的一个问题是进程启动时的同步。换句话说,对于生产者-消费者示例,我们不知道哪些线程将首先启动。我们通过条件变量——它的 互斥锁 以及一个谓词来同步代码,以处理事件的正确顺序。否则,我们可能会丢失信息或陷入 死锁。在这本书的大部分示例准备过程中,我们遇到了这种情况,这使得写作体验更加丰富。但协程提供了另一种方法,有时可能更高效且更易于使用(在你习惯了协程的接口之后,因为它并不容易掌握)。

下一示例是由 awaitable-awaiter 模式激发的。它与条件变量类似,但它不使用这样的同步原语。然而,通知信号依赖于一个原子变量。我们将回到 Task 协程。它将用于处理接收端。完整示例在此:github.com/PacktPublishing/C-Programming-for-Linux-Systems/tree/main/Chapter%2010

重要提示

该示例受到了 www.modernescpp.com/index.php/c-20-thread-synchronization-with-coroutines/ 的启发。

我们重用了来自 第九章共享内存 示例中的代码:

template<typename T, typename N>
Task receiver(Event& event, int fd, N size) {
    co_await event;
    ftruncate(fd, size);

我们首先对共享内存进行对齐并设置其大小,然后继续将指针映射到它上面:

    if (const auto ptr = mmap(0, size,
                           PROT_RW, MAP_SHARED,
                           fd, 0); ptr != MAP_FAILED) {
        auto* obj = static_cast<T*>(ptr);
        auto del = mmap_deallocator<T>(size);
        auto res =
            unique_ptr<T, mmap_deallocator<T>>(obj, del);
        if (res != nullptr)
            cout << "Receiver: " << *res << endl;
    }
    else {
        cerr << "Error mapping shm region";
    } }

确保对 res 的地址可访问以在协程内部进行解引用非常重要。否则,代码将因 Segmentation fault 而崩溃,这比悬挂指针更可取。另一个需要注意的是,不同的编译器(或环境)将为这段代码提供不同的行为。在我们到达 Event 结构体之前,让我们看看发送者做了什么——再次,我们回到了之前的代码:

template<typename T, typename N>
void Event::notify(T buffer, int fd, N size) noexcept {
    notified = false;
    auto* waiter =
        static_cast<Awaiter*>(suspended.load());
    if (waiter != nullptr) {
        ftruncate(fd, size);

再次,我们确保共享内存的大小正确,并将指针映射到它上面:

        if (const auto ptr = mmap(0, size,
                                  PROT_RW, MAP_SHARED,
                                  fd, 0);
                              ptr != MAP_FAILED) {
            auto* obj = new (ptr) T(buffer);
            auto del = mmap_deallocator<T>(size);
            auto res =
                unique_ptr<T, mmap_deallocator<T>>
                                                (obj, del);
        }
        else {
            cerr << "Error mapping shm region";
        }
        waiter->coroutineHandle.resume();
    }
}

初始时,通知标志被设置为false,这意味着协程将不会像常规函数那样执行,而是将被挂起。然后,加载waiter对象,它是nullptr,因为它之前没有被设置。相应的resume()操作没有被调用。随后执行的await_suspend()函数将waiter的状态存储在suspended成员变量中。稍后,notify()被触发并完全执行:

bool
Event::Awaiter::await_suspend(coroutine_handle<> handle)
  noexcept {
    coroutineHandle = handle;
    if (event.notified) return false;
    event.suspended.store(this);
    return true;
}

在主线程中,需要一个Event对象来同步工作流程。同时定义了一个共享内存区域。如果在每个协程中调用shm_open(),它实际上不会是共享虚拟内存,因为文件描述符将访问每个协程的私有区域。因此,我们最终会遇到Segmentation fault。有两个线程,分别代表发送者和接收者端。上述协程分别在线程合并后分别被调用:

    Event event{};
    int fd = shm_open(SHM_ID, O_CREAT | O_RDWR, 0644);
    auto senderT = jthread([&event, &fd]{
         event.notify<const char*, size_t>(message.data(),
                                           fd,
                                           message.size());
    });

接收者的代码类似,但将event对象作为参数传递:

    auto receiverT = jthread([&event, &fd]{
         receiver<char*, size_t>(ref(event),
                                 fd, (message.size())); });

输出如下:

This is a testing message!

此示例为您提供了以并发方式管理共享资源的灵活性。awaiter-awaitable 的通知机制将完成工作,无需同步原语。我们鼓励您亲自尝试。同时,我们将继续讨论系统编程中协程的使用的一些最终注意事项。

关于协程及其在 C++中的实现的最终思考

之前的例子虽然实用,但并不简单。它们有助于理解协程执行可能遵循的顺序。可视化协程的状态图是很有帮助的,尽管我们仍然认为对于经验不足的开发者来说可能会有些混乱。

如前所述,图 10.2图 10.3图 10.4基本上涵盖了通过代码示例我们已经解释的内容。了解围绕协程及其成员生成的额外逻辑量是有用的。大部分逻辑都是在后台发生的,系统程序员只需安排调度。在本章的示例中,我们通过promise对象和可等待对象来实现这一点。上述图示部分表示协程的执行作为一个有限状态机,这应该会提示你,这也是协程有用处的另一个应用场景。它们将状态机转换为一等对象。一旦协程框架被定义,大部分逻辑都保留在那里,并且对调用者隐藏。这为系统程序员提供了暂时放下并发逻辑的机会,只通过简短的代码片段调用协程,就像我们这样做。系统行为代码和任务调度将更简单、更明显。因此,管理算法、解析器、数据结构遍历、轮询等许多功能的大部分能力都可以通过这种技术来解释。不幸的是,我们无法在此涵盖所有内容,但我们相信检查这些内容是值得的。

最后但同样重要的是,我们想强调协程对这个语言来说相对较新。由于 C++中的协程接口仍然缺乏舒适性和简单性,你可以在互联网上找到许多定制的协程库。我们建议您只依赖可信赖的库,或者等待这个设施的下一次标准特性。应用这些特性比重新实现它们更有意义。正如你所看到的,这是一个相当复杂的概念,并且在这方面有很多研究正在进行。对于好奇的读者,我们鼓励您花些时间了解 C++中协程的演变,尤其是在最近几年。C++标准中讨论了三种技术——协程 TS、核心协程和可恢复表达式。尽管目前标准中只使用其中一种,但三者都值得注意。Geoffrey Romer、Gor Nishanov、Lewis Baker 和 Mihail Mihailov 在这里进行了很好的总结和分析:www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1493r0.pdf

随意查看。本章中我们提供的许多澄清都在文档中以优秀的视觉对比形式呈现,对比了常规函数和协程。同时,我们继续完成剩余部分。

摘要

通过这种方式,我们已经涵盖了这本书的所有主题。随着 C++23 即将到来的改进,协程及其发展将得到越来越多的分析,尤其是在系统编程领域——当然,也会在那里应用。虽然一开始可能难以理解,但协程允许您继续磨练 C++的使用,并为您提供另一个增强代码的工具。

在本章中,您学习了如何在并发应用程序中应用它们,但它们的作用远不止于此。我们对接下来会发生什么感到兴奋。我们预计,编译器将完全覆盖我们在这本书中——故意地——没有涵盖的modules语言特性,并将其广泛应用。另一个有趣的功能是std::generator——C++23 中用于同步创建协程的视图。std::stacktrace可以从抛出的异常中获取,这将帮助您进行代码调试。为了便于打印,您还将能够使用std::printstd::expected的单调接口将允许您存储两个值中的任意一个。除此之外,文件将通过#embed在编译时作为数组加载。

我们想借此机会向您——读者——表达我们的感激之情!我们希望您觉得这本书有用,并将其中的部分内容应用到您的日常工作中。我们也希望您享受阅读这本书的过程,就像我们享受写作这本书一样。对我们来说,这是一次巨大的旅程,我们很高兴能与您分享未来的旅程。在此,我们祝愿您在所有项目中好运连连!

posted @ 2025-10-09 13:23  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报