C---系统编程实用指南-全-

C++ 系统编程实用指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

通过本书,我们旨在为您提供对 Linux/Unix 系统编程的理解,对 Linux 系统调用的参考手册,以及使用 C++编写更智能、更快速代码的内幕指南。本书将解释 POSIX 标准函数与现代 C++提供的特殊服务之间的区别。

本书还将教读者有关基本 I/O 操作,如从文件中读取和写入,高级 I/O 接口,内存映射,优化技术,线程概念,多线程编程,POSIX 线程,分配内存和优化内存访问的接口,基本和高级信号接口以及它们在系统中的作用。本书还将解释时钟管理,包括 POSIX 时钟和高分辨率定时器。最后,本书使用现代示例和参考资料,为 C++和更广泛的社区提供最新的相关性,包括指导支持库及其在系统编程中的作用。

这本书适合谁

这本书适用于初学者到高级 Linux 和一般 UNIX 程序员,他们使用 C++,或者任何寻求 Linux、C++17 和/或使用 POSIX、C 和 C++进行系统编程的人。尽管本书涵盖了许多关于现代 C++的主题,但它的重点是系统编程。预期读者已经对 C 和 C++有一般的了解,因为本书将在整个过程中利用它们。

这本书涵盖了什么

第一章《开始系统编程》为本书奠定了基础,通过提供一些基本示例并解释使用 C++进行系统编程的好处来定义系统编程是什么。

第二章《学习 C、C++17 和 POSIX 标准》回顾了 C、C++和 POSIX 标准,提供了每个标准提供的设施的概述,以及对本书中将讨论的主题的一般概述。

第三章《C 和 C++的系统类型》提供了 C 和 C++提供的系统类型的全面概述,以及在进行系统编程时如何使用它们。本章还将讨论许多与本机类型相关的缺陷以及如何克服它们。

第四章《C++,RAII 和 GSL 复习》提供了 C++17 提供的增强功能的概述。本章还将讨论资源获取即初始化RAII)的好处,以及在进行系统编程时如何利用它。本章将以对指导支持库的概述结束,该库在本书中用于帮助维护 C++核心指导方针的合规性。

第五章《Linux/Unix 系统编程》提供了对基于 Linux/UNIX 系统的编程的全面概述,包括 System V 规范的概述,编程 Linux 进程和基于 Linux 的信号。

第六章《学习编程控制台输入/输出》提供了如何利用 C++来进行控制台输入和输出的完整概述,包括std::coutstd::cin。还将讨论更高级的主题,如如何处理自定义类型。

第七章《内存管理的全面视角》提供了对 C 和 C++提供的内存管理设施的完整审查。在本章中,我们将回顾 C 的不足之处,以及现代 C++如何克服其中许多不足之处。

第八章,学习使用文件输入/输出,回顾了如何使用 C++17 读取和写入文件,并将这些功能与 C 提供的功能进行比较。此外,我们还将深入研究 C++17 提供的用于处理磁盘上的文件和目录的std::filesystem附加功能。

第九章,分配器的实践方法,介绍了 C++分配器以及如何利用它们进行系统编程。与大多数其他描述 C++分配器的尝试不同,我们将指导您如何创建多个真实世界的有状态分配器示例,包括内存池分配器,并演示其潜在的性能优势。

第十章,使用 C++编程 POSIX 套接字,概述了如何使用 C++编程 POSIX 套接字(即网络编程)并提供了一系列示例。在本章中,我们还将讨论与 POSIX 套接字相关的一些问题以及如何克服这些问题。

第十一章,Unix 中的时间接口,全面介绍了 C 和 C++提供的时间接口,以及如何在系统编程中一起使用它们来处理时间,包括如何使用接口进行基准测试。

第十二章,学习使用 POSIX 和 C++线程,讨论了 POSIX 和 C++提供的线程编程和同步功能,以及它们之间的关系。我们还将提供一系列示例,演示如何利用这些功能。

第十三章,异常处理,涵盖了 C 和 C++的错误处理,包括 C 和 C++的异常。在本章中,我们还将演示一系列示例,展示利用 C++异常而不是传统的 C 错误处理的好处。

为了充分利用本书

读者应该对 C 和 C++有一般的了解,并且能够在 Linux 上编写、编译和执行 C 和 C++应用程序。为了执行本书中的示例,读者还应该能够访问一台运行 Ubuntu Linux 17.10 或更高版本的基于英特尔的计算机。读者还应该确保使用以下方法安装了 GCC 7.0 或更高版本:

sudo apt-get install build-essential

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packtpub.com

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用以下最新版本的解压缩或提取文件夹:

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

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

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“例如,看看使用std::array{}还是std::vector{}命令的区别。”

代码块设置如下:

int array[10];

auto r1 = array + 1;
auto r2 = *(array + 1);
auto r3 = array[1];

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

int main()
{
    auto ptr1 = mmap_unique_server<int>(42);
    auto ptr2 = mmap_unique_client<int>();
    std::cout << *ptr1 << '\n';
    std::cout << *ptr2 << '\n';
}

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

> cmake -DCMAKE_BUILD_TYPE=Release ..
> make

粗体:表示新术语,重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“从管理面板中选择系统信息。”

警告或重要说明会以这种方式出现。

技巧和窍门会以这种方式出现。

第一章:开始系统编程

在本章中,我们将讨论系统编程是什么(即,向操作系统发出系统调用以代表您执行操作),并深入探讨系统编程和使用 C++进行系统编程的利弊。

在本章中,我们将回顾以下内容:

  • 系统调用,包括它们是什么,如何执行它们以及与它们相关的潜在安全风险

  • 在系统编程时使用 C++的好处

技术要求

为了遵循本章的示例,读者必须具备:

  • 能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

理解系统调用

操作系统是一种旨在同时执行一个或多个应用程序的软件,同时还提供这些应用程序执行所需的资源。为了实现这一点,操作系统必须能够在同一时间将硬件资源分配给系统上执行的所有应用程序。

例如,大多数个人电脑(PC)都有一个存储所有文件的硬盘,这些文件是 PC 所有者正在使用的。在现代 PC 上,用户可能希望同时执行几个应用程序,例如网络浏览器和办公套件。

这两个应用程序都需要在执行时不同的时间独占访问硬盘。对于网络浏览器,这可能是将网站缓存到磁盘中,而对于办公套件,这可能是存储文档。

操作系统有责任管理应用程序及其对硬盘的访问,以确保网络浏览器和办公套件都能正常执行。

为了实现这一点,操作系统提供了应用程序编程接口(API),应用程序可以利用这些接口来完成其任务。访问硬盘就是其中一个任务的例子。read()write()函数是 POSIX 兼容操作系统提供的 API 的例子,用于从文件描述符读取和写入数据。

在底层,这些 API 使用称为系统调用的应用程序二进制接口(ABI)向操作系统发出调用。执行系统调用以完成操作系统提供的任务的行为称为系统编程,这是本书的主要重点。

系统调用的解剖学

在本节中,我们将重点关注英特尔 x86 架构的示例,尽管这些示例适用于大多数其他 CPU 架构。

原始的 x86 架构利用中断提供系统调用 ABI。操作系统提供的 API 将在 CPU 上编程特定寄存器,并使用中断调用操作系统。

例如,使用 BIOS,应用程序可以使用int 0x13从硬盘中读取数据,其寄存器布局如下:

  • AH = 2

  • AL:要读取的扇区

  • CH:柱面

  • CL:扇区

  • DH:磁头

  • DL:驱动器

  • ES:BX:缓冲区地址

应用程序作者将使用read()API 命令来读取这些数据,而在底层,read()将使用前面的 ABI 执行系统调用。当int 0x13执行时,应用程序将被硬件暂停,操作系统(在本例中为 BIOS)将代表应用程序执行从磁盘中读取数据,并将结果返回到应用程序提供的缓冲区中。

完成后,BIOS 将执行iret(中断返回)以返回到应用程序,然后应用程序将从磁盘中读取的数据等待在其缓冲区中以供使用。

采用这种方法,应用程序不需要知道如何在特定计算机上与硬盘进行物理接口,以便读取数据;这是操作系统及其设备驱动程序应该处理的任务。

应用程序也不必担心可能正在执行的其他应用程序。它只需利用提供的 API(或 ABI,取决于操作系统),其余繁琐的细节由操作系统处理。

换句话说,系统调用提供了应用程序之间的清晰界限,以帮助用户完成特定任务,并帮助操作系统管理这些应用程序和它们所需的硬件资源。

然而,中断是缓慢的。硬件不会对操作系统的编写方式或操作系统正在执行的应用程序的编写或组织方式做任何假设。因此,中断必须在执行中断处理程序之前保存 CPU 状态,并在执行iret命令时恢复此状态,导致性能不佳。

正如将要展示的那样,应用程序在尝试执行其任务时会进行大量的系统调用,这种性能不佳成为 x86 架构(以及其他 CPU 架构)的瓶颈。

为了解决这个问题,现代版本的 Intel x86 CPU 提供了快速系统调用指令。这些指令专门设计用于解决基于中断的系统调用的性能瓶颈。然而,它们需要 CPU、操作系统和在该操作系统上执行的应用程序之间的协调,以减少开销。

具体来说,操作系统必须以 CPU 指定的特定方式构造自身和正在运行的应用程序的内存布局。通过预定义操作系统及其相关应用程序的内存布局,CPU 在执行系统调用时不再需要保存和恢复太多 CPU 状态,从而减少开销。如何实现这一点取决于您是在 Intel 还是 AMD x86 CPU 上执行。

关于系统调用的执行方式最重要的一点是,系统调用并不廉价。即使有快速系统调用支持,系统调用也必须执行大量的工作。在通过read()API 从硬盘读取数据的情况下,必须设置 CPU 寄存器状态并执行系统调用指令。CPU 控制权被移交给操作系统,以从硬盘中读取数据。

由于可能有多个应用程序正在执行,并且尝试同时从硬盘中读取数据,因此操作系统可能必须暂停应用程序,以便为另一个应用程序提供服务。

一旦操作系统准备好为应用程序提供服务,它必须首先弄清楚应用程序试图读取的数据,这最终决定了它需要与哪个物理设备进行交互。在我们的例子中,这是一个硬盘,但在符合 POSIX 标准的系统中,它可以是任何类型的块设备。

接下来,操作系统必须利用其设备驱动程序之一从这个硬盘中读取数据。这需要时间,因为操作系统必须在硬盘上物理编程,要求从特定位置请求数据,通过一个几乎肯定不以与 CPU 本身相同的速度执行的硬件总线。

一旦硬盘最终向操作系统提供所请求的数据,操作系统可以将这些信息提供给应用程序,并返回控制权,将 CPU 状态恢复给应用程序。所有这些繁琐的工作都被一个read()调用所隐藏。

因此,系统调用应该谨慎执行,只在绝对需要时执行,以防止导致应用程序性能不佳。

值得注意的是,这种优化类型需要对应用程序利用的 API 有深入的了解,因为更高级的 API 会代表 API 自己进行系统调用。例如,分配内存,稍后将讨论,也是另一种系统调用。

例如,看看使用std::array{}std::vector{}命令之间的区别。std::vector{}支持在底层管理的数组大小调整,这需要内存分配。这不仅可能导致内存碎片化(这本书后面将讨论的一个主题),还可能导致性能不佳,因为内存分配可能需要向操作系统请求更多的系统 RAM。

了解不同类型的系统调用

几乎在符合 POSIX 标准的操作系统上执行的每个应用程序都必须进行一些系统调用。在这里,我们概述了本书中将探讨的一些系统调用类型。

控制台输入/输出

如果您曾经执行过命令行应用程序,您将会熟悉基于控制台的输入/输出的概念。这在符合 POSIX 标准的操作系统中尤其如此。在向控制台输出时,您可以将输出发送到stdout(通常用于正常输出)或stderr(通常用于输出错误消息)。

通过应用程序执行系统调用来输出到stdoutstderr。 (值得注意的是,在本书中,我们通常说我们输出到stdout,而不是打印到控制台。)

这是因为,在符合 POSIX 标准的系统上,您的应用程序实际上并不知道将文本发送到哪里。应用程序利用 API 输出到stdout。这可以通过以下方式实现:

  • 写入专用文件句柄(即stdout

  • 使用 C API,如printf

  • 使用 C++ API,如std::cout

  • 为您输出到stdout的应用程序分叉(例如,使用echo

大多数情况下,这些示例在说到底都会向操作系统发出系统调用,将字符缓冲区传输到管理stdoutstderr的设备。在某些情况下,这会导致操作系统将生成的字符缓冲区传递给父进程(可能是您的 shell),最终父进程会再次进行系统调用,将字符缓冲区显示在屏幕上。

无论您的操作系统如何处理,操作系统中存在一个设备驱动程序,用于管理用于显示文本的物理监视器,应用程序调用的简单 API(例如printfstd::cout)最终会向该设备驱动程序提供所请求的字符缓冲区。

尽管在大多数系统上,输出到stdout的文本通常会提供给您的 shell,并最终显示在屏幕上,但这并非一定如此。由于应用程序正在进行系统调用以输出字符缓冲区,因此操作系统可以自由地将这些数据转发到串行设备、日志文件、作为另一个应用程序的输入等。

这种灵活性是符合 POSIX 标准的操作系统如此强大的原因之一,也是学习如何正确进行系统调用如此重要的原因。

内存分配

内存是应用程序必须使用系统调用请求的另一种资源。当应用程序首次执行时,大多数应用程序会获得全局和堆栈内存资源,以及应用程序在调用诸如malloc()free()等函数时可以使用的一小块堆内存。

如果应用程序只使用最初在堆中给定的内存,那么应用程序不需要请求额外的内存。然而,如果堆内存用尽,应用程序的malloc()free()引擎将不得不向操作系统(通过系统调用)请求更多内存。

为了做到这一点,操作系统将通过向应用程序添加更多的物理内存来扩展应用程序的末端。然后,malloc()free()引擎能够利用这些额外的内存,直到需要更多内存。

在内存有限的系统上,当请求额外的内存时,操作系统必须从当前未执行的其他应用程序中获取内存。它通过将这些应用程序交换到磁盘上来实现这一点,这是一种昂贵的操作。

因此,在资源受限的系统上,不应该在时间关键的代码中调用malloc()free(),因为执行这些函数所需的时间可能会有很大的变化。

我们将在第七章中更详细地介绍内存管理,全面了解内存管理

文件输入/输出

读写文件是大多数应用程序的另一个常见用例,需要进行系统调用。

值得注意的是,在符合 POSIX 的系统上,读取和写入文件描述符并不总是意味着读取和写入存储设备上的文件。相反,您所做的系统调用会写入字符设备。这可能是一个存储设备,但也可能是一个控制台设备,甚至是一个虚拟设备,比如/dev/random,在读取时提供随机数据。

在第八章中,学习文件输入/输出编程,我们将提供有关文件输入/输出系统编程的更多信息。

网络

网络是另一个常见的用例,需要进行系统调用。在符合 POSIX 的系统上,我们通过使用 POSIX 套接字进行基于网络的系统编程。套接字提供了与操作系统中的网络接口控制器NIC)和支持逻辑(例如 TCP/IP 协议栈)进行编程的 API。

网络本身是一个非常复杂的主题,值得有一本专门的书来讨论,但幸运的是,执行这种类型的编程所需的系统调用是简单的,大部分繁琐的细节由操作系统处理。

在第十章中,使用 C++编程 POSIX 套接字,我们将更详细地介绍如何使用套接字 API 进行这些类型的系统调用。

时间

一些读者可能会感到惊讶,甚至执行简单的任务,如获取当前日期和时间,都需要系统调用来向操作系统请求这些信息。直到今天,系统上都提供了一个专用芯片(带有电池,以防断电)来维护当前的日期和时间。

如果需要这些信息,必须进行系统调用来请求。当这种情况发生时,操作系统将询问负责管理芯片的设备驱动程序当前存储的日期和时间,然后将此信息返回给应用程序。

值得注意的是,并非所有时间接口都需要系统调用。例如,大多数高分辨率定时器,它们旨在在操作发生前后比较高分辨率数字,不需要操作系统执行此操作。这是因为这些高分辨率定时器通常直接存在于 CPU 中,并且它们的值可以使用简单的指令提取。

这些类型的定时器的缺点是它们的值本身通常是没有意义的(也就是说,返回的值之间的差异才提供了意义,而不是值本身)。基本上,这些定时器通常只是一个计数器,每次 CPU 滴答(也就是执行一条指令)时递增。

由于现代 CPU 可以动态改变其频率,这些计数器存储的值取决于 CPU 自上次上电以来执行的时间长短,以及 CPU 在执行时设置的频率。

甚至不能保证一个计数器中的值与另一个物理核上的另一个计数器中读取的值相同,因为每个物理核都能够独立于多核 CPU 上的其他核改变自己的频率。

高分辨率定时器的好处是它们可以非常快地执行(因为您只是执行一个读取 CPU 中计数器的指令)。两个测量值之间的差异可以用来执行诸如测量执行小函数所需时间的任务,这通常无法使用标准定时器完成,因为它们没有足够的粒度。

在第十一章中,《Unix 中的时间接口》,我们将详细介绍这些细节,甚至提供如何自己实现的示例。

线程和进程创建

同时执行多个任务可以通过请求操作系统创建额外的线程(甚至新进程)来实现。这是系统编程中的常见任务,有许多系统调用可以完成这项工作。

进程是一个执行单元,为其分配了一组资源(例如内存、文件描述符等)。每个应用程序至少由一个进程组成,但它们可以包含多个进程(例如,shell 是一个专门设计用于运行多个子进程的应用程序)。

每个进程由操作系统安排执行一定的时间,然后下一个进程获得 CPU 的访问权限,这个循环根据需要继续进行。

线程类似于进程,但它们与同一进程的其他线程共享相同的资源。线程为应用程序提供了一个机会,可以创建能够并行执行的任务,而无需使用进程间通信方法。在第十二章中,《学习使用 POSIX 和 C++线程编程》,我们将学习如何使用 POSIX 和 C++ API 编程线程。

系统调用安全风险

系统调用并非没有安全风险。即使在现代硬件上,使用英特尔以外的 CPU 架构,在操作系统中执行多个进程并实现进程之间的完全隔离几乎是不可能的。

尽管现代硬件和现代操作系统都在努力提供最佳的隔离和安全性,但应始终假定与您同时执行的其他恶意进程可能能够窥探您的操作,包括解密用户数据等敏感任务。

这是另一个值得一本专门书籍的主题,但在这里,我们将简要讨论影响系统编程的两种不同的最近的安全漏洞。

SYSRET

英特尔和 AMD 提供的快速系统调用接口并非没有问题。如前所述,为了使快速系统调用正常工作,硬件、操作系统和应用程序必须协调。这是为了确保 ABI 信息得到正确处理,以允许操作系统在执行系统调用之前无需硬件保存整个 CPU 状态。

当系统调用完成并且必须将控制权交还给应用程序时也是如此。为了实现这一点,操作系统必须加载应用程序的堆栈,然后执行SYSRET指令,将控制权返回给应用程序。

这种方法的问题在于不可屏蔽中断(NMI)可能会在操作系统加载应用程序的堆栈和执行SYSRET之间触发。这种竞争条件的结果是,NMI(以根权限执行的代码)将使用应用程序的堆栈而不是内核的堆栈执行,从而可能导致安全漏洞或损坏。

值得庆幸的是,现代操作系统有办法防止这种类型的攻击,大多数操作系统,如 Linux,都可以并且确实利用这些方法。

Meltdown 和 Spectre

熔断和幽灵攻击是系统调用实现的复杂性的现代例子。为了支持系统调用的快速执行,内核的内存被映射到每个应用程序中,使用一种称为 3:1 分割的内存布局技术,指的是应用程序内存与内核内存的三比一的比例。

为了防止应用程序读取/写入内核内存,这些内核内存可能包含高度敏感的信息,如加密密钥和密码,现代 CPU 架构提供了一种机制来锁定内核内存的部分,以便只有内核能够看到所有内容。应用程序只能看到其部分特权内存。

为了提高这些现代 CPU 的性能,包括英特尔、AMD 和 ARM 在内的大多数架构都采用了一种称为推测执行的技术。例如,看下面的代码:

if (x) {
    do_y();
}

do_z();

CPU 在执行这条指令之前不知道x是真还是假。如果 CPU 假设x是真,它可以通过节省一些 CPU 周期来提高性能。如果x实际上是真的,CPU 就能节省周期,而如果x实际上是假的,惩罚通常是值得冒的风险,特别是如果 CPU 能够对x是真还是假进行合理猜测(例如,如果 CPU 在过去执行过这个语句并且x是真的)。

这种优化称为推测执行。CPU 正在执行代码,即使可能以后代码可能被证明是无效的并需要撤销。

像熔断和幽灵这样的推测执行攻击利用这一过程,绕过保护系统调用接口的内存保护,这个接口位于应用程序和其内核之间。这是通过说服 CPU 进行推测执行一个通常会导致安全违规的指令(例如,尝试从内核内存中读取密码)来完成的。

如果 CPU 推测执行这种类型的指令,CPU 将在 CPU 加载密码到 CPU 缓存和 CPU 发现发生安全违规之间存在一个间隙。如果 CPU 在这个间隙期间被中断(使用所谓的瞬态指令),密码将留在 CPU 缓存中,即使指令实际上并没有完成执行。

为了从缓存中恢复密码,攻击者利用了对 CPU 的额外攻击,称为侧信道攻击,这些攻击专门设计用来读取 CPU 缓存的内容,而不执行直接的内存操作。

最终结果是,攻击者能够设置一系列复杂的条件,最终允许他们使用一个非特权应用程序(可能是你在寻找猫视频时点击的网站)恢复存储在内核中的敏感信息。

如果这看起来很复杂,那是因为它确实很复杂。这些类型的攻击非常复杂。这些例子的目标是提供关于系统调用并非没有问题的简要概述。根据你所执行的 CPU 和操作系统,你在处理敏感信息时可能需要特别小心。

在系统编程时使用 C++的好处

尽管本书的重点是系统编程而不是 C++,我们确实提供了很多 C++的例子,但与标准 C 相比,C++在系统编程中有几个好处。

请注意,本节假定读者对 C++有一些基本知识。有关 C++标准的更完整解释将在第二章中提供,学习 C、C++17 和 POSIX 标准

C++中的类型安全

标准 C 不是一种类型安全的语言。类型安全是指为防止一种类型与另一种类型混淆而采取的保护措施。一些语言,如 ADA,非常类型安全,提供了许多保护措施,以至于有时使用该语言可能会令人沮丧。

相反,像 C 这样的语言是如此不安全,以至于很难找到类型错误,而且经常导致不稳定性。

C++在这两种方法之间提供了一个折衷方案,鼓励默认情况下合理的类型安全性,同时在需要时提供规避这一点的机制。

例如,考虑以下代码:

/* Example: C */
int *p = malloc(sizeof(int));

// Example: C++
auto p = new int;

在 C 中在堆上分配整数需要使用malloc(),它返回void *。这段代码存在几个问题,在 C++中得到了解决:

  • C 自动将void *类型转换为int *,这意味着即使用户声明的类型与返回的类型之间没有连接,隐式类型转换仍然发生了。用户可以轻松地分配short(这与int不同,这是我们将在第三章中讨论的一个主题,C 和 C++的系统类型)。类型转换仍然会被应用,这意味着编译器无法正确地检测到分配的空间对于用户尝试分配的类型来说不够大。

  • 程序员必须声明分配的大小。与 C++不同,C 不了解正在分配的类型。因此,它不知道类型的大小,因此程序员必须明确声明这一点。这种方法的问题在于可能引入难以发现的分配错误。通常,提供给sizeof()的类型是不正确的(例如,程序员可能提供指针而不是类型本身,或者程序员可能稍后更改代码,但忘记更改提供给sizeof()的值)。如前所述,malloc()分配和返回的内容与用户尝试分配的类型之间没有关联,这提供了引入难以发现的逻辑错误的机会。

  • 类型必须明确声明两次。malloc()返回void *,但 C 隐式转换为用户声明的任何指针类型,这意味着类型已经声明了两次(在这种情况下,void *int *)。在 C++中,使用auto意味着类型只声明一次(在这种情况下,int表示类型是int *),并且auto将采用返回的任何类型。使用auto和去除隐式类型转换意味着分配中声明的任何类型都是p变量将采用的类型。如果在此分配后的代码期望p采用不同的类型,编译器将在 C++中在编译时知道这一点,而在 C 中,这样的错误可能直到运行时才会被捕获,当程序崩溃时(我们希望这段代码不控制飞机!)。

除了隐式类型转换的危险示例之外,C++还提供了运行时类型信息RTTI)。这些信息有许多用途,但最重要的用例涉及dynamic_cast<>运算符,它执行运行时类型检查。

具体来说,可以在运行时检查从一种类型转换为另一种类型,以确保不会发生类型错误。这在执行以下操作时经常看到:

  • 多态类型转换:在 C 中,多态是可能的,但必须手动完成,这是内核编程中经常见到的模式。然而,C 无法确定指针是否为基本类型分配,从而导致可能出现类型错误的可能性。相反,C++能够在运行时确定提供的指针是否被转换为正确的类型,包括在使用多态性时。

  • 异常支持:在捕获异常时,C++ 使用 RTTI(本质上是 dynamic_cast<>)来确保被抛出的异常被适当的处理程序捕获。

C++ 对象

尽管 C++ 支持使用内置构造进行面向对象编程,但面向对象编程也经常在 C 中以及 POSIX 中使用。看下面的例子:

/* Example: C */

struct point 
{
    int x;
    int y;
};

void translate(point *p; int val)
{
    if (p == NULL) {
        return;
    }

    p->x += val;
    p->y += val;
}

在前面的例子中,我们有一个存储 point{} 的结构体,其中包含 xy 位置。然后我们提供一个函数,能够使用给定的值(即对角线平移)来翻译这个 point{}xy 位置。

关于这个例子,有几点需要注意:

  • 人们经常声称不喜欢面向对象的编程,但是在他们的代码中却会看到这种情况,实际上这是一种面向对象的设计。使用类并不是创建面向对象设计的唯一方式。C++ 的不同之处在于语言提供了额外的构造来清晰、安全地处理对象,而在 C 中,这个功能必须手动完成,这个过程容易出错。

  • translate() 函数只与 point{} 对象相关,因为它将 point{} 作为参数。因此,编译器没有上下文信息来理解如何操作 point{} 结构,没有给 translate() 提供指针作为参数。这意味着每个公共函数都必须以指针作为第一个参数来操作 point{} 结构,并验证指针是否有效。这不仅是一个笨拙的接口,而且速度慢。

在 C++ 中,前面的例子可以写成如下形式:

// Example: C++

struct point 
{
    int x;
    int y;

    void translate(int val)
    {
        p->x += val;
        p->y += val;
    }
};

在这个例子中,仍然使用了一个 struct。C++ 中类和结构体的唯一区别是,结构体中的所有变量和函数默认都是公共的,而类中默认是私有的。

不同之处在于 translate() 函数是 point{} 的成员,这意味着它可以访问其结构的内容,因此不需要指针来执行翻译。因此,这段代码更安全、更确定,并且更容易理解,因为永远不会出现空指针解引用的情况。

最后,C++ 中的对象提供了构造和销毁例程,有助于防止对象未被正确初始化或正确销毁。看下面的例子:

// Example: C++

struct myfile 
{
    int fd{0};

    ~myfile() {
        close(fd);
    }
};

在前面的例子中,我们创建了一个自定义文件对象,它保存了一个文件描述符,在使用 POSIX API 进行系统编程时经常看到和使用。

在 C 中,程序员需要记住在初始化时手动将文件描述符设置为 0,并在不再使用时关闭文件描述符。在 C++ 中,使用前面的例子,每次使用 myfile 时都会为您执行这两个操作。

这是一个使用资源获取即初始化RAII)的例子,这个主题将在 第四章 中详细讨论,C++,RAII 和 GSL 刷新,因为这种模式在 C++ 中经常被使用。在系统编程时,我们将利用这种技术来避免许多常见的 POSIX 风格陷阱。

C++ 中使用的模板

模板编程经常被低估和误解,它是 C++ 中一个没有得到足够赞扬的补充。大多数程序员只需要尝试创建一个通用链表就能理解为什么。

C++ 模板使您能够在不提前定义类型信息的情况下定义代码。

在 C 中创建链表的一种方式是使用指针和动态内存分配,就像在这个简单的例子中看到的那样:

struct node 
{
    void *data;
    node next;
};

void add_data(node *n, void *val);

在前面的例子中,我们使用 void * 存储链表中的数据。使用方法如下:

node head;
add_data(&head, malloc(sizeof(int)));
*(int*)head.data = 42;

这种方法存在一些问题:

  • 这种类型的链表显然不是类型安全的。数据的使用和数据的分配完全无关,需要使用这个链表的程序员在没有错误的情况下管理所有这些。

  • 节点和数据都需要动态内存分配。正如前面讨论的,内存分配很慢,因为它们需要系统调用。

  • 总的来说,这段代码很难阅读,而且笨拙。

创建通用链表的另一种方法是使用宏。在互联网上有几种这些类型的链表(和其他数据结构)的实现,它们提供了一个通用的链表实现,无需动态分配数据。这些宏为用户提供了一种在编译时定义链表将管理的数据类型的方法。

除了可靠性之外,这些实现使用宏来实现模板编程的方式远不如优雅。换句话说,向 C 添加通用数据结构的解决方案是使用 C 的宏语言手动实现模板编程。程序员最好只使用 C++模板。

在 C++中,可以创建像链表这样的数据结构,而无需在声明之前声明链表管理的类型,如下所示:

template<typename T>
class mylinked_list
{
    struct node 
    {
        T data;
        node *next;
    };

public:

    ...

private:

    node m_head;
};

在上面的例子中,我们不仅能够创建一个不需要宏或动态分配(以及使用void *指针带来的所有问题)的链表,而且还能够封装功能,提供更清晰的实现和用户 API。

关于模板编程经常提出的一个抱怨是它生成的代码量。模板的大部分代码膨胀通常源于编程错误。例如,程序员可能没有意识到整数和无符号整数不是相同的类型,导致在使用模板时出现代码膨胀(因为为每种类型创建了一个定义)。

即使不考虑这个问题,使用宏也会产生相同的代码膨胀。没有免费的午餐。如果你想避免使用动态分配和类型转换,同时仍然提供通用算法,你必须为你计划使用的每种类型创建你的算法的实例。如果可靠性是你的目标,允许编译器生成确保程序正确执行所需的代码,将会超过这些缺点。

与 C++相关的函数式编程

函数式编程是 C++的另一个补充,它以 lambda 函数的形式为用户提供编译器的帮助。目前,这在 C 中必须手动完成。

在 C 中,可以使用回调来实现函数式编程构造。例如,考虑以下代码:

void
guard(void (*ptr)(int *val), int *val)
{
    lock();
    ptr(val);
    unlock();
}

void 
inc(int *val)
{
    *val++;
}

void 
dec(int *val)
{
    *val--;
}

void
foo() 
{
    int count = 0;
    guard(inc, &count);
    guard(dec, &count);
}

在上面的代码示例中,我们创建了一个guard函数,它锁定互斥锁,调用一个操作值的函数,然后在退出时解锁互斥锁。然后,我们创建了两个函数,一个增加给定的值,一个减少给定的值。最后,我们创建一个函数,实例化一个计数,然后使用guard函数递增计数和递减计数。

这段代码存在一些问题:

  • 第一个问题是需要指针逻辑来确保我们可以操作所需操作的变量。我们还需要手动传递这个指针来跟踪它。这使得 API 笨拙,因为我们必须为这样一个简单的例子手动编写大量额外的代码。

  • 辅助函数的函数签名是静态的。guard函数是一个简单的函数。它锁定互斥锁,调用一个函数,然后解锁它。问题在于,由于在编写代码时必须知道函数的参数,而不是在编译时,我们无法将此函数重用于其他任务。我们需要手动为计划支持的每种函数签名类型编写相同的函数。

可以使用以下 C++编写相同的示例:

template<typename FUNC>
guard(FUNC f)
{
    lock();
    f();
    unlock();
}

void
foo() 
{
    int count = 0;
    guard(inc, [&]{ count++ });
    guard(inc, [&]{ count-- });
}

在前面的例子中,提供了相同的功能,但不需要指针。此外,守卫函数是通用的,可以用于多种情况。这是通过利用模板编程和函数式编程实现的。

Lambda 提供了回调,但是回调的参数被编码到 lambda 的函数签名中,这由模板函数的使用吸收。编译器能够生成一个用于接受参数(在本例中是对count变量的引用)并将其存储在代码本身中以供使用的守卫函数的版本,从而消除了用户手动执行此操作的需要。

在本书中,前面的例子将被大量使用,特别是在创建基准测试示例时,因为这种模式使您能够将功能包装在旨在计时回调执行的代码中。

C++中的错误处理机制

错误处理是 C 的另一个问题。问题是,至少在添加了 set jump 异常之前,从函数获取错误代码的唯一方法是:

  • 限制函数的输出,以便将函数的某些输出值视为错误

  • 获取函数返回一个结构,然后手动解析该结构

例如,考虑以下代码:

struct myoutput 
{
    int val;
    int error_code;
}

struct myoutput myfunc(int val)
{
    struct myoutput = {0};

    if (val == 42) {
        myoutput.error_code = -1;
    }

    myoutput.val = val;
    return myoutput;
}

void 
foo(void)
{
    struct myoutput = myfunc(42);

    if (myoutput.error_code == -1) {
        printf("yikes\n");
        return;
    }
}

前面的例子提供了一个简单的机制,用于从函数输出错误,而无需限制函数的输出(例如,假设-1始终是一个错误)。

在 C++中,可以使用以下 C++17 逻辑来实现:

std::pair<int, int>
myfunc(int val)
{
    if (val == 42) {
        return {0, -1};
    }

    return {val, 0};
}

void 
foo(void)
{
    if (auto [val, error_code] = myfunc(42); error_code == -1) {
        printf("yikes\n");
        return;
    }
}

在前面的例子中,我们能够通过利用std::pair{}来消除对专用结构的需求,并且通过利用initializer_list{}和 C++17 结构化绑定来消除对std::pair{}的需求。

然而,还有一种更简单的处理错误的方法,而无需检查您执行的每个函数的输出,那就是使用异常。C 通过 set jump API 提供异常,而 C++提供 C++异常支持。这两者将在第十三章中详细讨论,即使用异常处理错误

API 和 C++容器在 C++中

除了 C++提供的语言原语外,它还带有标准模板库(STL)及相关 API,这些 API 极大地帮助系统编程。本书的很大一部分将专注于这些 API 以及它们如何支持系统编程。

应该注意,本书的重点是系统编程而不是 C++,因此我们不会详细介绍 C++容器,而是假设读者对它们是什么以及它们如何工作有一些基本知识。话虽如此,C++容器通过防止用户手动重写它们来支持系统编程。

我们教导学生如何编写自己的数据结构,不是为了当他们需要数据结构时知道如何编写一个,而是为了当他们需要一个时,知道使用哪种数据结构以及为什么。C++已经提供了大部分,如果不是全部,您在系统编程时可能需要的数据结构。

总结

在本章中,我们了解了什么是系统编程。我们涵盖了系统调用的一般解剖,不同类型的系统调用以及一些最近与系统调用相关的安全问题。

此外,我们讨论了使用 C++进行系统编程的优势,而不仅仅是严格使用标准 C。在下一章中,我们将详细介绍 C、C++和 POSIX 标准以及它们与系统编程的关系。

问题

  1. 什么是系统编程?

  2. 快速系统调用之前,系统调用是如何执行的?

  3. 支持快速系统调用所做的关键更改是什么?

  4. 分配内存是否总是导致系统调用?

  5. Meltdown 和 Spectre 攻击利用了什么类型的执行?

  6. 什么是类型安全?

  7. 在 C++中模板编程至少提供一个好处是什么?

进一步阅读

第二章:学习 C、C++17 和 POSIX 标准

如第一章所述,开始系统编程,系统编程是通过进行系统调用与底层操作系统协调来执行各种操作的行为。每个操作系统都有自己的一套系统调用,以及这些系统调用的执行方式也各不相同。

为了防止系统程序员不得不为每个不同的操作系统重新编写他们的程序,已经制定了几个标准,这些标准用一个明确定义的 API 包装了操作系统的 ABI。

在本章中,我们将讨论三个标准——C 标准、C++标准和 POSIX 标准。C 和 POSIX 标准提供了包装操作系统 ABI 的基本语言语法和 API。具体来说,C 标准定义了程序链接和执行,标准 C 语法(许多高级语言,如 C++,都是基于此),以及提供 ABI 到 API 包装的 C 库。

C 库可以被视为更大的 POSIX 标准的子集,后者定义了更大的 API 子集,包括但不限于文件系统、网络和线程库。

最后,C++标准定义了 C++语法、程序链接和执行,以及提供 C 和 POSIX 标准更高级抽象的 C++库。本书的大部分内容将围绕这些标准 API 以及如何在 C++17 中使用它们。

本章有以下目标:

  • 学习 C、C++和 POSIX 标准

  • 理解程序链接和执行,以及 C 和 C++之间的区别

  • 简要概述这些标准提供的功能,每个功能将在本书的后面更详细地讨论

技术要求

为了跟随本章的示例,读者必须具备:

  • 一个能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请转到以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter02

从 C 标准语言开始

C 编程语言是最古老的语言之一。与其他高级语言不同,C 足够类似汇编语言编程,同时又提供了一些高级编程抽象,因此成为系统、嵌入式和内核级程序员的首选。

几乎每个主要的操作系统都源自 C。此外,大多数高级语言,包括 C++,都是基于 C 构建其高级构造,因此仍然需要 C 标准的一些组件。

C 标准是由国际标准化组织ISO)管理的一个庞大标准。我们假设读者对 C 标准和如何编写 C 代码有一些基本知识:www.open-std.org/jtc1/sc22/wg14/www/docs/n1256.pdf

因此,本节的目标是讨论一些在其他书中讨论得较少的主题,以及本书和系统编程相关的 C 标准的部分,但在其他章节中缺失。

有关 C 编程语言和如何编写 C 程序的更多信息,请参阅本章的进一步阅读部分。

标准的组织方式

该规范分为三个部分:

  • 环境

  • 语言

让我们简要讨论每个部分的目的。之后,我们将讨论 C 标准的特定部分,这些部分与系统编程相关,但在本书的其他地方没有讨论。

环境

标准的环境部分提供了主要由编译器编写者需要的信息,以更好地理解如何为 C 创建编译器。

它描述了编译器必须遵守的最低限制(例如必须支持的最小嵌套if()语句数量),以及程序是如何链接和启动的。

在本章中,我们将讨论程序链接和执行,以更好地理解创建 C 程序所需的内容。

语言

标准的语言部分提供了与 C 语法相关的所有细节,包括变量是什么,如何编写函数,for()循环和while()循环之间的区别,以及支持的所有运算符以及它们的工作原理。

这本书假设读者对标准的这一部分有一般的了解,并且只涉及标准 C 语法的系统编程特定细微差别,读者可能会遇到的问题(比如与指针相关的问题)。

标准的库部分描述了标准 C 语言提供的所有库设施。这包括向stdout输出字符串、分配内存和处理时间等设施。

系统编程主要围绕这些库设施展开,本书的大部分内容将集中在这些库设施上,它们提供了什么以及如何使用它们。

C 程序的启动方式

标准中与系统编程相关但在文献中没有被广泛讨论的一部分是 C 程序如何启动。一个常见的误解是 C 程序从以下两个入口点开始:

int main(void) {}
int main(int argc, char *argv[]) {}

虽然这实际上是 C 程序员提供的第一个函数调用,但它并不是 C 程序启动时调用的第一个函数。它也不是执行的第一段代码,也不是用户提供的第一段执行的代码。

main()函数执行之前,操作系统和标准 C 环境以及用户都进行了大量的工作。

让我们看看编译器如何创建一个简单的Hello World\n示例:

#include <stdio.h>

int main(void) 
{
    printf("Hello World\n");
}

为了更好地理解 C 程序的启动过程,让我们看看这个简单程序是如何编译的:

> gcc -v scratchpad.c; ./a.out

Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-linux-gnu/7/lto-wrapper
OFFLOAD_TARGET_NAMES=nvptx-none
OFFLOAD_TARGET_DEFAULT=1
Target: x86_64-linux-gnu
Configured with: ...
...

通过向 GCC 添加-v选项,我们可以看到编译器编译我们的简单的Hello World\n程序所采取的每一步。

首先,编译器将程序转换为可以由gnu-as处理的格式:

/usr/lib/gcc/x86_64-linux-gnu/7/cc1 -quiet -v -imultiarch x86_64-linux-gnu scratchpad.c -quiet -dumpbase scratchpad.c -mtune=generic -march=x86-64 -auxbase scratchpad -version -fstack-protector-strong -Wformat -Wformat-security -o /tmp/ccMSWHgC.s

你不仅可以看到初始编译是如何执行的,还可以看到操作系统提供的默认标志。

接下来,编译器将输出转换为一个目标文件,如下所示:

/usr/bin/x86_64-linux-gnu-as -v --64 -o /tmp/cc9oaJWV.o /tmp/ccMSWHgC.s

最后,最后一步使用collect2实用程序将生成的目标文件链接成一个单独的可执行文件,这是一个围绕链接器的包装器:

/usr/lib/gcc/x86_64-linux-gnu/7/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/7/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/7/lto-wrapper -plugin-opt=-fresolution=/tmp/ccWQB2Gf.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --sysroot=/ --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/7 -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/7/../../.. /tmp/cc9oaJWV.o -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/x86_64-linux-gnu/7/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crtn.o

在这里有几个重要的事情需要注意关于程序是如何链接的:

  • -lc:使用此标志告诉链接器链接libc。像这里讨论的其他库一样,我们没有告诉编译器链接libc。默认情况下,GCC 会为我们链接libc

  • -lgcc_s:这是一个静态库,由 GCC 自动链接,用于支持特定于编译器的操作,包括在 32 位 CPU 上进行 64 位操作,以及诸如异常展开(这是一个将在第十三章中讨论的主题,异常处理)。

  • Scrt1.ocrti.ocrtbeginS.ocrtendS.ocrtn.o:这些库提供了启动和停止应用程序所需的代码。

具体来说,C 运行时 CRT)库是这里感兴趣的库。这些库提供了引导应用程序所需的代码,包括:

  • 执行全局构造函数和析构函数(尽管这不是标准 C 的功能,GCC 支持 C 中的构造函数和析构函数)。

  • 设置展开以支持异常支持。虽然这主要是为了 C++异常,对于仅需要标准 C 的应用程序来说是不需要的,但它们仍然需要链接到 set jump 异常逻辑中,这个话题将在第十三章中进行解释,异常处理

  • 提供_start函数,这是使用默认 GCC 编译器的任何基于 C 的应用程序的实际入口点。

最后,所有这些库都负责为main()函数提供传递给它的参数,并拦截main()函数的返回值,并在需要时代表您执行exit()函数。

这里最重要的一点是,在您的程序中执行的第一段代码不是main()函数,如果您注册了全局构造函数,它也不是您提供的第一段代码。在系统编程中,如果您遇到程序初始化的问题,这是首先要查看的地方。

关于链接的一切

链接是一个非常复杂的主题,因操作系统而异。例如,Windows 和 Linux 链接程序的方式完全不同。因此,我们将把讨论限制在 Linux 上。

当 C 源文件被编译时,它被编译成所谓的目标文件,其中包含以二进制格式定义程序中每个函数的编译源代码,如下所示:

> gcc -c scratchpad.c; objdump -d scratchpad.o

...

0000000000000000 <main>:
   0: 55 push %rbp
   1: 48 89 e5 mov %rsp,%rbp
   4: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # b <main+0xb>
   b: e8 00 00 00 00 callq 10 <main+0x10>
  10: b8 00 00 00 00 mov $0x0,%eax
  15: 5d pop %rbp
  16: c3 retq

如此所示,编译器创建了一个目标文件,其中包含源代码的编译(即二进制)版本。这里的一个重要说明是,main()函数被标记为main,以纯文本形式。

让我们扩展这个例子来包括另一个函数:

int test(void)
{
    return 0;
}

int main(void)
{
    return test();
}

编译这个源文件,我们得到以下结果:

> gcc -c scratchpad.c; objdump -d scratchpad.o

...

0000000000000000 <test>:
   0: 55 push %rbp
   1: 48 89 e5 mov %rsp,%rbp
   4: b8 00 00 00 00 mov $0x0,%eax
   9: 5d pop %rbp
   a: c3 retq

000000000000000b <main>:
   b: 55 push %rbp
   c: 48 89 e5 mov %rsp,%rbp
   f: e8 00 00 00 00 callq 14 <main+0x9>
  14: 5d pop %rbp
  15: c3 retq

如此所示,每个编译的函数都使用与函数相同的名称标记。也就是说,每个函数的名称都不是混编的(不像 C++)。名称混编将在下一节中进一步详细解释,以及为什么这在链接方面很重要。

超越简单的源文件,C 程序被分成编译和链接在一起的源文件组。具体来说,可执行文件是目标文件和库的组合。库是额外目标文件的组合,分为两种不同的类型:

  • 静态库:在编译时链接的库

  • 动态库:在加载时链接的库

静态库

静态库是一组在编译时链接的目标文件。在 Linux(和大多数基于 UNIX 的系统中),静态库只是一组目标文件的存档。您可以轻松地获取现有的静态库并使用AR工具来提取原始的目标文件。

与作为程序一部分链接的目标文件不同,作为静态库一部分链接的目标文件只包括静态库所需的源代码,提供了优化,从程序中删除未使用的代码,最终减少了程序的总大小。

这种方法的缺点是,使用静态库链接程序的顺序很重要。如果在提供需要该库的代码之前(即在命令行上)链接库,将会发生链接错误,因为静态库中的代码将被优化掉。

操作系统提供的库通常也不支持静态链接,并且通常不需要静态链接操作系统库,因为这些库可能已经被操作系统加载到内存中。

动态库

动态库是在加载时链接的库。动态库更像是没有入口点的可执行文件。它们包含程序所需的代码,加载时链接器负责为程序提供每个所需函数的位置。

程序也可以在运行时链接自身作为优化,只链接需要的函数(这个过程称为延迟加载)。

操作系统提供的大多数库都是动态库。要查看程序需要哪些动态库,可以使用 LDD 工具,如下所示:

> ldd a.out
  linux-vdso.so.1 (0x00007ffdc5bfd000)
  libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f92878a0000)
  /lib64/ld-linux-x86-64.so.2 (0x00007f9287e93000)

在这个例子中,我们使用 LDD 工具列出了我们简单的Hello World\n示例所需的动态库。如下所示,需要以下库:

  • vdso:操作系统提供的库,用于加速系统调用的过程

  • libc:标准 C 库

  • ld-linux-x86-64:动态链接器本身,负责延迟加载

作用域

C 语言的一个特点是它使用作用域,这使它与汇编语言编程有了明显的区别。在汇编中,函数的前缀和后缀必须手动编码,而这个过程完全取决于 CPU 提供的指令集架构(ISA)和程序员决定使用的 ABI。

在 C 中,函数的作用域会自动使用{}语法为您定义。例如:

#include <stdio.h>

int main(void) 
{
    printf("Hello World\n");
}

在我们简单的Hello World\n示例中,作用域用于定义main()函数的开始和结束。其他基本类型的作用域也可以使用{}语法来定义。例如:

#include <stdio.h>

int main(void)
{
    int i;

    for (i = 0; i < 10; i++) {
        printf("Hello World: %d\n", i);
    }
}

在上一个例子中,我们定义了main()函数和for()循环的作用域。

{}语法也可以用于为任何内容创建作用域。例如:

#include <stdio.h>

int main(void)
{
    {
        int i;
        ...
    }

    {
        int i;
        ...
    }
}

在上一个例子中,我们能够在不小心重新定义它的情况下两次使用i变量,因为我们将i的定义包裹在{}中。这不仅告诉编译器i的作用域,还告诉编译器如果需要的话自动为我们创建前缀和后缀(因为优化可能消除了前缀和后缀的需要)。

作用域还用于定义编译器在链接方面公开的内容。在标准 C 中,static关键字告诉编译器变量只对正在编译的目标文件可见(即具有作用域),这不仅为链接器提供了优化,还防止两个全局变量或函数相互冲突。

因此,如果一个函数不打算被另一个源文件(或库)调用,它应该被标记为静态。

在系统编程的上下文中,作用域很重要,因为系统编程通常需要获取系统级资源。正如将在第四章中看到的那样,C++,RAII 和 GSL 刷新器,C++提供了使用标准 C{}语法创建生命周期可作用域对象的能力,为资源获取和释放提供了安全机制。

指针和数组

在学校里,我有一位老师曾经告诉过我:

“无论你有多有经验,没有人完全理解指针。”

没有比这更真实的陈述了。在标准 C 中,指针是一个值指向内存中的位置的变量。标准 C 的问题在于,这个内存位置与特定类型无关。相反,指针类型本身定义了指针指向的内存类型,如下例所示:

int main(void)
{
    int i;
    int *p = &i;
}

// > gcc scratchpad.c; ./a.out

在上一个例子中,我们创建了一个整数,然后创建了一个指针,并将其指向先前定义的整数。但是,我们可以这样做:

int main(void)
{
    int i;
    void *p = &i;

    int *int_p = p;
    float *float_p = p;
}

// > gcc scratchpad.c; ./a.out

在这个程序中,我们创建了一个指向整数的指针,但我们将指针类型定义为void *,这告诉编译器我们正在创建一个没有类型的指针。然后我们创建了另外两个指针——一个指向整数,一个指向浮点数。这两个额外的指针都是使用我们之前创建的void *指针进行初始化的。

这个例子的问题在于标准 C 编译器执行自动类型转换,将void *转换为整数指针和浮点数指针。如果同时使用这两个指针,将会发生一些损坏:

  • 根据架构的不同,缓冲区溢出可能会发生,因为整数可能比浮点数大,反之亦然。这取决于所使用的 CPU;这是一个将在第三章中更详细讨论的话题,C 和 C++的系统类型

  • 在内部,整数和浮点数在同一内存中以不同的方式存储,这意味着任何尝试设置一个值都会破坏另一个值。

值得庆幸的是,现代 C 编译器具有能够检测这种类型转换错误的标志,但是这些警告必须启用,因为它们默认情况下是关闭的,如前所示。

指针的明显问题不仅在于它们可以指向内存中的任何内容并重新定义该内存的含义,而且它们还可以取空值。换句话说,指针被认为是可选的。它们可以选择包含有效值并指向内存,或者它们可以是空的。

因此,在确定其值有效之前,不应使用指针,如下所示:

#include <stdio.h>

int main(void)
{
    int i = 42;
    int *p = &i;

    if (p) {
        printf("The answer is: %d\n", *p);
    }
}

// > gcc scratchpad.c; ./a.out
// The answer is: 42

在前面的例子中,我们创建了一个指向整数的指针,它被初始化为先前定义的一个初始值为42的整数的位置。我们检查确保p不是一个空指针,然后将其值输出到stdout

if()语句的添加不仅麻烦,而且性能不佳。因此,大多数程序员会省略if()语句,因为在这个例子中,p永远不会是一个空指针。

这个问题在于,程序员可能会在这个简单的例子中添加与这个假设相矛盾的代码,同时忘记添加if()语句,导致潜在生成难以发现的分段错误的代码。

正如将在下一节中所示,C++标准通过引入引用的概念来解决这个问题,它是一个非可选指针,这意味着它必须始终指向一个有效的、有类型的内存位置。为了解决这个问题,在标准 C 中,通常(虽然不总是)通过公共 API 来检查空指针。私有 API 通常不会检查空指针以提高性能,这样做的假设是,只要公共 API 不能接受空指针,私有 API 很可能永远不会看到无效的指针。

标准 C 数组类似于指针。唯一的区别在于 C 数组利用了一种能够对指针指向的内存进行索引的语法,就像下面的例子中所示:

#include <stdio.h>

int main(void)
{
    int i[2] = {42, 43};
    int *p = i;

    if (p) {
        // method #1
        printf("The answer is: %d and %d\n", i[0], p[0]);
        printf("The answer is: %d and %d\n", i[1], p[1]);

        // method #2
        printf("The answer is: %d and %d\n", *(i + 0), *(p + 0));
        printf("The answer is: %d and %d\n", *(i + 1), *(p + 1));
    }
}

// > gcc scratchpad.c; ./a.out
// The answer is: 42 and 42
// The answer is: 43 and 43
// The answer is: 42 and 42
// The answer is: 43 and 43

在前面的例子中,我们创建了一个包含 2 个元素的整数数组,初始化为值4243。然后我们创建一个指向该数组的指针。请注意,不再需要&。这是因为数组本身就是一个指针,因此我们只是将一个指针设置为另一个指针的值(而不是必须从现有内存位置提取指针)。

最后,我们使用指针算术来打印数组中每个元素的值,既使用数组本身,又使用指向数组的指针。

正如将在第四章中讨论的那样,数组和指针之间几乎没有区别。当尝试访问数组中的元素时,两者都执行所谓的指针算术

在系统编程方面,指针被广泛使用。例如:

  • 由于标准 C 不像 C++那样包含引用的概念,必须通过引用传递的系统 API(因为它们太大而无法通过值传递,或者必须由 API 修改)必须通过指针传递,因此在进行系统调用时会大量使用指针。

  • 系统编程通常涉及与内存中的位置指针交互,旨在定义该内存的布局。指针提供了一种方便的方法来实现这一点。

标准 C 不仅定义了语法、环境和程序链接方式,还提供了一组库,程序员可以利用这些库来进行系统编程。其中一些库如下:

  • errno.h:提供处理错误所需的代码。这个库将在第十三章中进一步讨论,异常处理

  • inttypes.h:提供类型信息,将在第三章中讨论,C 和 C++的系统类型

  • limits.h:提供每种类型的限制信息,将在第三章中讨论,C 和 C++的系统类型

  • setjump.h:提供 C 风格的异常处理的 API,将在第十三章中讨论,异常处理

  • signal.h:提供处理系统发送到程序的信号的 API,将在第五章中讨论,Linux/Unix 系统编程

  • stdbool.h:提供类型信息,将在第三章中讨论,C 和 C++的系统类型

  • stddef.h:提供类型信息,将在第三章中讨论,C 和 C++的系统类型

  • stdint.h:提供类型信息,将在第三章中讨论,C 和 C++的系统类型

  • stdio.h:提供在系统编程中处理输入和输出的函数,将在第六章和第八章中讨论,学习控制台输入/输出学习文件输入/输出

  • stdlib.h:提供各种实用程序,包括动态内存分配 API,将在第七章中讨论,全面了解内存管理

  • time.h:提供处理时钟的功能,将在第十一章中讨论,Unix 中的时间接口

正如前面所述,本书的大部分内容将集中在这些功能上,以及它们如何支持系统编程。

学习 C++标准

C++编程语言(最初称为带类的 C)专门设计为提供比 C 更高级的功能,包括更好的类型安全性和面向对象编程,同时考虑了系统编程。具体来说,C++旨在提供 C 程序的性能和效率,同时仍提供更高级语言的特性。

如今,C++是世界上最流行的编程语言之一,应用于从航空电子学到银行业的各个领域。

与 C 标准一样,C++标准也很庞大,并由 ISO 管理。我们假设读者对 C++标准和如何编写 C 代码有一些基本的了解:www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4713.pdf

因此,本节的目标是讨论一些在其他书中没有详细讨论的主题,以及与本书和系统编程相关的 C++标准的部分,但在其他章节中缺失。有关 C++编程语言以及如何编写 C++程序的更多信息,请参阅本章的进一步阅读部分。

标准的组织方式

与 C 标准规范一样,C++规范分为三大组部分:

  • 一般约定和概念

  • 语言语法

应该注意到,C++标准比 C 标准要大得多。

一般约定和概念

标准中的前四个部分专门讨论约定和概念。它们定义了类型、程序的启动和关闭、内存和链接。它们还概述了理解规范其余部分所需的所有定义和关键字。

与标准 C 规范一样,在这些部分中定义了许多对系统程序员很重要的东西,因为它们定义了编译器在编译程序时将输出什么,以及程序将如何执行。

语言语法

规范中的接下来 12 个部分定义了 C++语言的语法本身。这包括 C++的特性,如类、重载、模板和异常处理。有整本书只是针对规范的这些部分写的。

我们假设读者对 C++有一般的了解,我们在书中不再讨论这部分规范,除了第四章中关于 C++17 的修改,C++,RAII 和 GSL 刷新

规范中剩下的 14 个部分定义了 C++作为规范一部分提供的库。应该注意到,本书的大部分内容都围绕着规范的这一部分。

具体来说,我们详细讨论了 C++为系统程序员提供的设施,以及如何在实践中使用这些设施。

链接 C++应用程序

与 C 一样,C++应用程序通常从一个具有与 C 相同签名的main()函数开始。同样,与 C 程序一样,代码的实际入口点实际上是_start函数。

然而,与 C 不同,C++要复杂得多,包括了更多的代码来演示一个简单的例子。为了证明这一点,让我们看一个简单的Hello World\n示例:

#include <iostream>

int main(void)
{
    std::cout << "Hello World\n";
}

// > g++ scratchpad.cpp; ./a.out
// Hello World

首先,C++应用程序示例比上一节中等价的 C 示例略长:

> gcc scratchpad.c -o c_example
> g++ scratchpad.cpp -o cpp_example
> stat -c "%s %n" *
8352 c_example
8768 cpp_example

如果我们看一下我们的示例中的符号,我们得到以下内容:

> nm -gC cpp_example
                 U __cxa_atexit@@GLIBC_2.2.5
                 w __cxa_finalize@@GLIBC_2.2.5
00000000000008f4 T _fini
0000000000000688 T _init
00000000000007fa T main
00000000000006f0 T _start
                 U std::ios_base::Init::Init()@@GLIBCXX_3.4
                 U std::ios_base::Init::~Init()@@GLIBCXX_3.4
0000000000201020 B std::cout@@GLIBCXX_3.4
                 U std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)@@GLIBCXX_3.4

...

如前所述,我们的程序包含一个main()函数和一个_start()函数。_start()函数是应用程序的实际入口点,而main()函数在初始化完成后由_start()函数调用。

_init()_fini()函数负责全局构造和销毁。在我们的示例中,_init()函数创建了 C++库支持std::cout所需的代码,而_fini()函数负责销毁这些全局对象。为此,全局对象使用__cxa_atexit()函数注册,并最终使用__cxa_finalize()函数销毁。

其余的符号构成了std::cout的代码,包括对ios_base{}basic_ostream{}的引用。

这里需要注意的重要事情是,与 C 语言一样,有很多代码在main()函数之前和之后执行,并且在 C++中使用全局对象只会增加启动和停止应用程序的复杂性。

在前面的例子中,我们使用_C选项来解开我们的函数名。让我们看看使用这个选项的相同输出:

> nm -gC cpp_example
                 U __cxa_atexit@@GLIBC_2.2.5
                 w __cxa_finalize@@GLIBC_2.2.5
00000000000008f4 T _fini
0000000000000688 T _init
00000000000007fa T main
00000000000006f0 T _start
                 U _ZNSt8ios_base4InitC1Ev@@GLIBCXX_3.4
                 U _ZNSt8ios_base4InitD1Ev@@GLIBCXX_3.4
0000000000201020 B _ZSt4cout@@GLIBCXX_3.4
                 U _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@@GLIBCXX_3.4

...

如上所示,一些函数仍然可读,而另一些则不可读。具体来说,C++规范规定某些支持函数使用 C 链接进行链接,防止名称编码。在我们的例子中,这包括__cxa_xxx()函数、_init()_fini()main()_start()

然而,支持std::cout的 C++库函数的语法几乎无法阅读。在大多数符合 POSIX 标准的系统上,可以使用C++filt命令来解开这些编码的名称,如下所示:

> c++filt _ZSt4cout
std::cout

这些名称被编码是因为它们的名称中包含了整个函数签名,包括参数和特化(例如,noexcept关键字)。为了证明这一点,让我们创建两个函数重载:

void test(void) {}
void test(bool b) {}

int main(void)
{
    test();
    test(false);
}

// > g++ scratchpad.cpp; ./a.out

在前面的例子中,我们创建了两个具有相同名称但不同函数签名的函数,这个过程称为函数重载,这是 C++特有的。

现在让我们看看我们测试应用程序中的符号:

> nm -g a.out
...

0000000000000601 T _Z4testb
00000000000005fa T _Z4testv

函数名在 C++中被编码的原因有几个:

  • 在函数名中编码函数参数意味着函数可以重载,并且编译器和链接器将知道哪个函数做什么。没有名称编码,具有相同名称但不同参数的两个函数对于链接器来说看起来是相同的,会导致错误。

  • 通过在函数名中编码这种类型的信息,链接器能够识别库函数是否使用了不同的签名进行编译。没有这些信息,链接器可能会将使用不同签名(因此不同实现)编译的库链接到相同的函数名,这将导致难以发现的错误,很可能会导致损坏。

C++名称编码的最大问题是对公共 API 进行微小更改会导致库无法再与已经存在的代码链接。

有许多方法可以克服这个问题,但总的来说,重要的是要理解 C++在函数名中编码了关于你如何编写代码的大量信息,这使得公共 API 不改变除非期望进行版本更改至关重要。

作用域

C 和 C++之间的一个主要区别是对象的构造和销毁是如何处理的。让我们看下面的例子:

#include <iostream>

struct mystruct {
    int data1{42};
    int data2{42};
};

int main(void)
{
    mystruct s;
    std::cout << s.data1 << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// 42

与 C 语言不同,在 C++中,我们可以使用{}运算符来定义结构的数据值应该如何初始化。这是可能的,因为在 C++中,对象(包括结构和类)包含构造函数和析构函数,定义了对象在构造时如何初始化和在销毁时如何销毁。

在系统编程时,这种方案将被广泛使用,并且在处理系统资源时,构造和销毁对象的概念将贯穿本书。具体来说,将利用作用域来定义对象的生命周期,从而定义对象拥有的系统资源,使用一种称为资源获取即初始化RAII)的概念。

指针与引用

在前一节中,我们详细讨论了指针,包括指针可以取两个值——有效或空(假设损坏不是方程式的一部分)。

问题在于用户必须检查指针是否有效。当使用指针来定义内存的内容(例如,使用数据结构布局内存)时,通常不会出现问题,但在 C 中,指针经常必须简单地用于减少将大型对象传递给函数的开销,就像以下示例中一样:

struct mystruct {
    int data1{};
    int data2{};
    int data3{};
    int data4{};
    int data5{};
    int data6{};
    int data7{};
    int data8{};
};

void test(mystruct *s)
{
}

int main(void)
{
    mystruct s;
    test(&s);
}

// > g++ scratchpad.cpp; ./a.out

在前面的例子中,我们创建了一个包含八个变量的结构。以值传递这种类型的结构将导致使用堆栈(即,多次内存访问)。在 C 中,通过指针传递这种结构以减少将结构传递给单个寄存器的成本更加高效,很可能完全消除所有内存访问。

问题在于,现在,test 函数必须在使用指针之前检查指针是否有效。因此,该函数将一组内存访问交换为分支语句和可能导致 CPU 流水线刷新的操作,而我们所要做的只是减少将大型对象传递给函数的成本。

如前一节所述,解决方案就是简单地不验证指针的有效性。然而,在 C++中,我们还有另一个选择,那就是通过引用传递结构,如下所示:

struct mystruct {
    int data1{};
    int data2{};
    int data3{};
    int data4{};
    int data5{};
    int data6{};
    int data7{};
    int data8{};
};

void test(mystruct &s)
{
}

int main(void)
{
    mystruct s;
    test(s);
}

// > g++ scratchpad.cpp; ./a.out

在前面的例子中,我们的test()函数接受了mystruct{}的引用,而不是指针。当我们调用test()函数时,无需获取结构的地址,因为我们没有使用指针。

C++引用将在本书中大量使用,因为它们极大地提高了程序的性能和稳定性,特别是在系统编程中,资源、性能和稳定性至关重要。

C++不仅定义了基本的环境和语言语法,还提供了一组库,程序员可以利用这些库进行系统编程。这些包括以下内容:

  • 控制台输入/输出库:这些包括iostreamiomanipstring库,它们提供了处理字符串、格式化字符串和输出字符串(或从用户那里获取输入)的能力。我们将在第六章中讨论大多数这些库,学习编程控制台输入/输出

  • 内存管理库:这些包括内存库,其中包含有助于防止悬空指针的内存管理实用程序。它们将在第七章中讨论全面了解内存管理

  • 文件输入/输出库:这些包括fstreamfilesystem(C++17 中新增)库,在第八章中将讨论学习文件输入/输出

  • 时间库:这些包括chrono库,在第十一章中将讨论Unix 中的时间接口

  • 线程库:这些包括threadmutexconditional_variable库,在第十二章中将讨论学习编程 POSIX 和 C++线程

  • 错误处理库:这些包括异常支持库,在第十三章中将讨论使用异常进行错误处理

从 POSIX 标准开始

POSIX 标准定义了符合 POSIX 的操作系统必须实现的所有功能。在系统编程方面,POSIX 标准定义了操作系统必须支持的系统调用接口(即 API,而不是 ABI)。

在底层,C 和 C ++提供的大多数系统级 API 实际上执行 POSIX 函数,或者它们本身就是 POSIX 函数(就像很多 C 库 API 一样)。事实上,libc通常被认为是更大的 POSIX 标准的子集,而 C ++利用libc和 POSIX 来实现其更高级的 API,如线程,内存管理,错误处理,文件操作和输入/输出。有关更多信息,请参阅ieeexplore.ieee.org/document/8277153/

在本节中,我们将讨论与系统编程相关的 POSIX 标准的一些组件。所有这些主题将在后面的章节中进一步详细讨论。

内存管理

libc提供的所有内存管理函数也被视为 POSIX API。此外,还有一些libc不提供的 POSIX 特定内存管理函数,如对齐内存。

例如,以下演示了如何使用 POSIX 分配对齐的动态(堆)内存:

#include <iostream>

int main()
{
    void *ptr;

    if (posix_memalign(&ptr, 0x1000, 42 * sizeof(int))) {
        std::clog << "ERROR: unable to allocate aligned memory\n";
        ::exit(EXIT_FAILURE);
    }

    std::cout << ptr << '\n';
    free(ptr);
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55c5d31d1000

在这个例子中,我们使用posix_memalign()函数来分配一个对齐到页面的42个整数的数组。这是一个 POSIX 特定的函数。

此外,我们还利用std::clog()函数将错误输出到stderr,在底层利用了 POSIX 特定函数将字符串输出到stderr。我们还使用了::exit(),这是一个用于退出应用程序的libc和 POSIX 函数。

最后,我们利用了std::cout()free()函数。std::cout()使用 POSIX 函数将字符串输出到stdout,而free()是用于释放内存的libc和 POSIX 特定函数。

在这个简单的例子中,我们利用了几个 C、C++和 POSIX 特定的功能来执行系统编程。在本书中,我们将讨论如何大量利用 POSIX 来编程系统以完成特定任务。

文件系统

POSIX 不仅定义了如何从符合 POSIX 的操作系统中读取和写入文件,还定义了文件应该位于文件系统上的位置。在第八章中,《学习使用 C、C++和 POSIX 进行文件输入/输出编程》,我们将详细介绍如何使用 C、C++和 POSIX 读取和写入文件系统。

关于文件系统的布局,POSIX 定义了文件应该位于的位置,包括以下常见文件夹:

  • /bin:所有用户使用的二进制文件

  • /boot:启动操作系统所需的文件

  • /dev:物理和虚拟设备

  • /etc:操作系统需要的配置文件

  • /home:用户特定的文件

  • /lib:可执行文件需要的库

  • /mnt 和/media:用作临时挂载点

  • /sbin:系统特定的二进制文件

  • /tmp:在重启时删除的文件

  • /usr:前述文件夹的用户特定版本

套接字

要在符合 POSIX 的操作系统上进行网络编程,您需要利用 POSIX 套接字 API。POSIX 提供的套接字编程接口是一个很好的例子,它既不是由 C 也不是由 C++提供的一组 API,但在符合 POSIX 的操作系统上进行网络编程时是必需的。

在第十章中,《使用 C++编程 POSIX 套接字》,我们将讨论如何使用 POSIX 套接字 API 执行网络编程,同时利用 C ++。具体来说,我们将展示如何利用 C ++简化基于套接字的网络编程的实现,并提供如何执行网络编程的几个示例。

线程

线程为系统程序员提供了执行并行执行的手段。具体来说,线程是操作系统在适当时安排的执行单元。C++和 POSIX 都提供了用于处理线程的 API,其中 C++的 API 可能更容易使用。

应该注意,在幕后,C++利用了 POSIX 线程库(pthreads)-因此,即使 C++提供了一组用于处理线程的 API,最终,POSIX 线程负责所有情况下的线程。

这是因为原因很简单。 POSIX 定义了程序与操作系统交流的接口。在这种情况下,如果您希望告诉操作系统创建一个线程,您必须通过利用操作系统定义的 API 来实现。如果操作系统符合 POSIX 标准,那么这些接口就是 POSIX,而不管可能会被放置在那里以使 API 更容易使用的任何抽象。

总结

在本章中,我们了解了三种不同的标准:C、C++和 POSIX。 C 标准定义了流行的 C 语法,C 风格的程序链接和执行,以及提供跨平台 API 的标准 C 库,以包装操作系统的 ABI。

我们还了解了 C++标准,以及它如何定义 C++语法,程序链接和执行,以及高级 C++ API,以包装底层的 C 和 POSIX API 到 C++。

最后,我们看到 POSIX 标准提供了超出 C 的额外 API。这些 API 包括(但不限于)内存管理、网络和线程。一般来说,POSIX 标准定义了应用程序在任何符合 POSIX 标准的操作系统上以跨平台方式执行其功能所需的所有标准。

本书的其余部分将重点关注这些标准中定义的 API,以及它们如何用于在 C++17 中进行系统编程。在下一章中,我们将专门介绍由 C、C++和 POSIX 提供的系统类型,以及它们如何影响系统编程。

问题

  1. C 标准是否是 POSIX 标准的一部分?如果是,列举一个在两个标准中都常见的 API。

  2. _start()main()函数之间有什么区别?

  3. 列举 C 运行时的一个职责。

  4. 全局构造函数是在main()函数之前还是之后执行的?

  5. C++名称修饰是什么,为什么需要?

  6. 列举 C 和 C++程序链接之间的一个区别。

  7. 指针和引用之间有什么区别?

进一步阅读

第三章:C 和 C++的系统类型

通过系统程序,诸如整数类型之类的简单事物变得复杂。整个章节都致力于解决在进行系统编程时出现的常见问题,特别是在为多个 CPU 架构、操作系统和用户空间/内核通信(如系统调用)进行系统编程时出现的问题。

本章包括以下主题:

  • 解释 C 和 C++提供的默认类型,包括大多数程序员熟悉的类型,如charint

  • 回顾stdint.h提供的一些标准整数类型,以解决默认类型的限制

  • 结构打包和与优化和类型转换相关的复杂性

技术要求

要编译和执行本章中的示例,读者必须具备以下条件:

  • 一个能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请访问以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter03

探索 C 和 C++的默认类型

C 和 C++语言提供了几种内置类型,无需额外的头文件或语言特性。在本节中,我们将讨论以下内容:

  • charwchar_t

  • short intintlong int

  • floatdoublelong double

  • bool(仅限 C++)

字符类型

C 和 C++中最基本的类型是以下字符类型:

#include <iostream>

int main(void)
{
    char c = 0x42;
    std::cout << c << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// B

char是一个整数类型,在大多数平台上,它的大小为 8 位,必须能够接受无符号的值范围为[0255],有符号的值范围为[-127127]。char与其他整数类型的区别在于,char具有特殊含义,对应着美国信息交换标准代码ASCII)。在前面的示例中,大写字母B由 8 位值0x42表示。需要注意的是,虽然char可以用来简单表示 8 位整数类型,但它的默认含义是字符类型;这就是为什么它具有特殊含义。例如,考虑以下代码:

#include <iostream>

int main(void)
{
    int i = 0x42;
    char c = 0x42;

    std::cout << i << '\n';
    std::cout << c << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// 66
// B

在前面的示例中,我们使用int(稍后将解释)和char来表示相同的整数类型0x42。然而,这两个值以两种不同的方式输出到stdout。整数以整数形式输出,而使用相同的 API,char以其 ASCII 表示形式输出。此外,char类型的数组在 C 和 C++中被认为是 ASCII 字符串类型,这也具有特殊含义。以下代码显示了这一点:

#include <iostream>

int main(void)
{
    const char *str = "Hello World\n";
    std::cout << str;
}

// > g++ scratchpad.cpp; ./a.out
// Hello World

从前面的示例中,我们了解到以下内容。我们使用char指针(在这种情况下,无界数组类型也可以)定义了一个 ASCII 字符串;std::cout默认情况下知道如何处理这种类型,而char数组具有特殊含义。将数组类型更改为int将无法编译,因为编译器不知道如何将字符串转换为整数数组,而std::cout默认情况下也不知道如何处理整数数组,尽管在某些平台上,intchar实际上可能是相同的类型。

boolshort int一样,字符类型在表示 8 位整数时并不总是最有效的类型,正如前面的代码所暗示的,在某些平台上,char实际上可能比 8 位更大,这是我们在讨论整数时将进一步详细讨论的一个主题。

为了进一步研究char类型,以及本节讨论的其他类型,让我们利用std::numeric_limits{}类。这个类提供了一个简单的包装器,围绕着limits.h,它为我们提供了一种查询在给定平台上如何实现类型的方法,使用一组静态成员函数实时地。

例如,考虑下面的代码:

#include <iostream>

int main(void)
{
    auto num_bytes_signed = sizeof(signed char);
    auto min_signed = std::numeric_limits<signed char>().min();
    auto max_signed = std::numeric_limits<signed char>().max();

    auto num_bytes_unsigned = sizeof(unsigned char);
    auto min_unsigned = std::numeric_limits<unsigned char>().min();
    auto max_unsigned = std::numeric_limits<unsigned char>().max();

    std::cout << "num bytes (signed): " << num_bytes_signed << '\n';
    std::cout << "min value (signed): " << +min_signed << '\n';
    std::cout << "max value (signed): " << +max_signed << '\n';

    std::cout << '\n';

    std::cout << "num bytes (unsigned): " << num_bytes_unsigned << '\n';
    std::cout << "min value (unsigned): " << +min_unsigned << '\n';
    std::cout << "max value (unsigned): " << +max_unsigned << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes (signed): 1
// min value (signed): -128
// max value (signed): 127

// num bytes (unsigned): 1
// min value (unsigned): 0
// max value (unsigned): 255

在前面的例子中,我们利用std::numeric_limits{}来告诉我们有符号和无符号char的最小和最大值(应该注意的是,本书中的所有示例都是在标准的英特尔 64 位 CPU 上执行的,假设这些相同的示例实际上可以在不同的平台上执行,返回的值可能是不同的)。std::numeric_limits{}类可以提供关于类型的实时信息,包括以下内容:

  • 有符号或无符号

  • 转换限制,如四舍五入和表示类型所需的总位数

  • 最小值和最大值信息

在前面的例子中,64 位英特尔 CPU 上的char大小为 1 字节(即 8 位),对于无符号char取值范围为[0,255],有符号char取值范围为[-127,127],这是规范规定的。让我们来看一下宽字符charwchar_t

#include <iostream>

int main(void)
{
    auto num_bytes_signed = sizeof(signed wchar_t);
    auto min_signed = std::numeric_limits<signed wchar_t>().min();
    auto max_signed = std::numeric_limits<signed wchar_t>().max();

    auto num_bytes_unsigned = sizeof(unsigned wchar_t);
    auto min_unsigned = std::numeric_limits<unsigned wchar_t>().min();
    auto max_unsigned = std::numeric_limits<unsigned wchar_t>().max();

    std::cout << "num bytes (signed): " << num_bytes_signed << '\n';
    std::cout << "min value (signed): " << +min_signed << '\n';
    std::cout << "max value (signed): " << +max_signed << '\n';

    std::cout << '\n';

    std::cout << "num bytes (unsigned): " << num_bytes_unsigned << '\n';
    std::cout << "min value (unsigned): " << +min_unsigned << '\n';
    std::cout << "max value (unsigned): " << +max_unsigned << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes (signed): 4
// min value (signed): -2147483648
// max value (signed): 2147483647

// num bytes (unsigned): 4
// min value (unsigned): 0
// max value (unsigned): 4294967295

wchar_t表示 Unicode 字符,其大小取决于操作系统。在大多数基于 Unix 的系统上,wchar_t为 4 字节,可以表示 UTF-32 字符类型,如前面的例子所示,而在 Windows 上,wchar_t为 2 字节,可以表示 UTF-16 字符类型。在这两种操作系统上执行前面的例子将得到不同的输出。

这是非常重要的,这个问题定义了整个章节的基本主题;C 和 C++提供的默认类型取决于 CPU 架构、操作系统,有时还取决于应用程序是在用户空间还是内核中运行(例如,当 32 位应用程序在 64 位内核上执行时)。在系统编程时,永远不要假设在与系统调用进行接口时,你的应用程序对特定类型的定义与 API 所假定的类型相同。这种假设往往是无效的。

整数类型

为了进一步解释默认的 C 和 C++类型是由它们的环境定义的,而不是由它们的大小定义的,让我们来看一下整数类型。有三种主要的整数类型——short intintlong int(不包括long long int,在 Windows 上实际上是long int)。

short int通常比int小,在大多数平台上表示为 2 字节。例如,看下面的代码:

#include <iostream>

int main(void)
{
    auto num_bytes_signed = sizeof(signed short int);
    auto min_signed = std::numeric_limits<signed short int>().min();
    auto max_signed = std::numeric_limits<signed short int>().max();

    auto num_bytes_unsigned = sizeof(unsigned short int);
    auto min_unsigned = std::numeric_limits<unsigned short int>().min();
    auto max_unsigned = std::numeric_limits<unsigned short int>().max();

    std::cout << "num bytes (signed): " << num_bytes_signed << '\n';
    std::cout << "min value (signed): " << min_signed << '\n';
    std::cout << "max value (signed): " << max_signed << '\n';

    std::cout << '\n';

    std::cout << "num bytes (unsigned): " << num_bytes_unsigned << '\n';
    std::cout << "min value (unsigned): " << min_unsigned << '\n';
    std::cout << "max value (unsigned): " << max_unsigned << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes (signed): 2
// min value (signed): -32768
// max value (signed): 32767

// num bytes (unsigned): 2
// min value (unsigned): 0
// max value (unsigned): 65535

如前面的例子所示,代码获取了有符号short int和无符号short int的最小值、最大值和大小。这段代码的结果表明,在运行 Ubuntu 的英特尔 64 位 CPU 上,short int,无论是有符号还是无符号,都返回 2 字节的表示。

英特尔 CPU 相对于其他 CPU 架构提供了一个有趣的优势,因为英特尔 CPU 被称为复杂指令集计算机CISC),这意味着英特尔指令集架构ISA)提供了一长串复杂的指令,旨在为英特尔汇编的编译器和手动作者提供高级功能。其中的一个特性是英特尔处理器能够在字节级别执行算术逻辑单元ALU)操作(包括基于内存的操作),尽管大多数英特尔 CPU 都是 32 位或 64 位。并非所有的 CPU 架构都提供相同级别的细粒度。

为了更好地解释这一点,让我们看一个涉及short int的例子:

#include <iostream>

int main(void)
{
    short int s = 42;

    std::cout << s << '\n';
    s++;
    std::cout << s << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// 42
// 43

在前面的例子中,我们取一个short int,将其设置为值42,使用std::cout将这个值输出到stdout,然后将short int增加1,再次使用std::cout将结果输出到stdout。这是一个简单的例子,但在底层,发生了很多事情。在这种情况下,一个 2 字节的值,在包含 8 字节寄存器的系统上执行(即 64 位),必须初始化为42,存储在内存中,递增,然后再次存储在内存中以输出到stdout。所有这些操作都必须涉及 CPU 寄存器来执行这些操作。

在基于英特尔的 CPU 上(32 位或 64 位),这些操作可能涉及使用 CPU 寄存器的 2 字节版本。具体来说,英特尔的 CPU 可能是 32 位或 64 位,但它们提供的寄存器大小为 1、2、4 和 8 字节(特别是在 64 位 CPU 上)。在前面的例子中,这意味着 CPU 加载一个 2 字节寄存器,存储这个值到内存(使用 2 字节的内存操作),将这个 2 字节寄存器增加 1,然后再次将这个 2 字节寄存器存储回内存中。

精简指令集计算机RISC)上,这个相同的操作可能会更加复杂,因为 2 字节寄存器不存在。要加载、存储、递增和再次存储只有 2 字节的数据将需要使用额外的指令。具体来说,在 32 位 CPU 上,必须将 32 位值加载到寄存器中,当这个值存储在内存中时,必须保存和恢复上 32 位(或下 32 位,取决于对齐)以确保实际上只影响了 2 字节的内存。如果进行了大量的操作,额外的对齐检查,即内存读取、掩码和存储,将导致显著的性能影响。

因此,C 和 C++提供了默认的int类型,通常表示 CPU 寄存器。也就是说,如果架构是 32 位,那么int就是 32 位,反之亦然(64 位除外,稍后将解释)。应该注意的是,像英特尔这样的 CISC 架构可以自由地以比 CPU 寄存器大小更小的粒度实现 ALU 操作,这意味着在底层,仍然可能进行相同的对齐检查和掩码操作。重点是,除非你有非常特定的原因要使用short int(对此有一些原因;我们将在本章末讨论这个话题),而不是int,在大多数情况下,使用更小的类型,即使你不需要完整的 4 或 8 字节,仍然更有效率。

让我们看一下int类型:

#include <iostream>

int main(void)
{
    auto num_bytes_signed = sizeof(signed int);
    auto min_signed = std::numeric_limits<signed int>().min();
    auto max_signed = std::numeric_limits<signed int>().max();

    auto num_bytes_unsigned = sizeof(unsigned int);
    auto min_unsigned = std::numeric_limits<unsigned int>().min();
    auto max_unsigned = std::numeric_limits<unsigned int>().max();

    std::cout << "num bytes (signed): " << num_bytes_signed << '\n';
    std::cout << "min value (signed): " << min_signed << '\n';
    std::cout << "max value (signed): " << max_signed << '\n';

    std::cout << '\n';

    std::cout << "num bytes (unsigned): " << num_bytes_unsigned << '\n';
    std::cout << "min value (unsigned): " << min_unsigned << '\n';
    std::cout << "max value (unsigned): " << max_unsigned << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes (signed): 4
// min value (signed): -2147483648
// max value (signed): 2147483647

// num bytes (unsigned): 4
// min value (unsigned): 0
// max value (unsigned): 4294967295

在前面的例子中,int在 64 位英特尔 CPU 上显示为 4 字节。这是因为向后兼容性,这意味着在一些 RISC 架构上,默认的寄存器大小,导致最有效的处理,可能不是int,而是long int。问题在于实时确定这一点是痛苦的(因为使用的指令是在编译时完成的)。让我们看一下long int来进一步解释这一点:

#include <iostream>

int main(void)
{
    auto num_bytes_signed = sizeof(signed long int);
    auto min_signed = std::numeric_limits<signed long int>().min();
    auto max_signed = std::numeric_limits<signed long int>().max();

    auto num_bytes_unsigned = sizeof(unsigned long int);
    auto min_unsigned = std::numeric_limits<unsigned long int>().min();
    auto max_unsigned = std::numeric_limits<unsigned long int>().max();

    std::cout << "num bytes (signed): " << num_bytes_signed << '\n';
    std::cout << "min value (signed): " << min_signed << '\n';
    std::cout << "max value (signed): " << max_signed << '\n';

    std::cout << '\n';

    std::cout << "num bytes (unsigned): " << num_bytes_unsigned << '\n';
    std::cout << "min value (unsigned): " << min_unsigned << '\n';
    std::cout << "max value (unsigned): " << max_unsigned << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes (signed): 8
// min value (signed): -9223372036854775808
// max value (signed): 9223372036854775807

// num bytes (unsigned): 8
// min value (unsigned): 0
// max value (unsigned): 18446744073709551615

如前面的代码所示,在运行 Ubuntu 的 64 位英特尔 CPU 上,long int是一个 8 字节的值。这在 Windows 上并不成立,它将long int表示为 32 位,而long long int为 64 位(再次是为了向后兼容)。

在系统编程中,您正在处理的数据大小通常非常重要,正如本节所示,除非您确切知道应用程序将在哪种 CPU、操作系统和模式上运行,否则几乎不可能知道在使用 C 和 C++提供的默认类型时您的整数类型的大小。大多数这些类型在系统编程中不应该使用,除了int之外,它几乎总是表示与 CPU 寄存器相同位宽的数据类型,或者至少是一个不需要额外对齐检查和掩码来执行简单算术操作的数据类型。在下一节中,我们将讨论克服这些大小问题的其他类型,并讨论它们的优缺点。

浮点数

在系统编程中,浮点数很少使用,但我们在这里简要讨论一下以供参考。浮点数通过减少精度来增加可以存储的值的大小。例如,使用浮点数可以存储代表1.79769e+308的数字,这是使用整数值甚至long long int都不可能实现的。然而,无法将这个值减去1并看到数字值的差异,浮点数也无法在保持与整数值相同的粒度的同时表示如此大的值。浮点数的另一个好处是它们能够表示次整数数值,在处理更复杂的数学计算时非常有用(这在系统编程中很少需要,因为大多数内核不使用浮点数来防止内核中发生浮点错误,最终导致没有接受浮点值的系统调用)。

主要有三种不同类型的浮点数——floatdoublelong double。例如,考虑以下代码:

#include <iostream>

int main(void)
{
    auto num_bytes = sizeof(float);
    auto min = std::numeric_limits<float>().min();
    auto max = std::numeric_limits<float>().max();

    std::cout << "num bytes: " << num_bytes << '\n';
    std::cout << "min value: " << min << '\n';
    std::cout << "max value: " << max << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes: 4
// min value: 1.17549e-38
// max value: 3.40282e+38

在前面的例子中,我们利用std::numeric_limits来检查float类型,在英特尔 64 位 CPU 上是 4 字节大小。double如下:

#include <iostream>

int main(void)
{
    auto num_bytes = sizeof(double);
    auto min = std::numeric_limits<double>().min();
    auto max = std::numeric_limits<double>().max();

    std::cout << "num bytes: " << num_bytes << '\n';
    std::cout << "min value: " << min << '\n';
    std::cout << "max value: " << max << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes: 8
// min value: 2.22507e-308
// max value: 1.79769e+308

对于long double,代码如下:

#include <iostream>

int main(void)
{
    auto num_bytes = sizeof(long double);
    auto min = std::numeric_limits<long double>().min();
    auto max = std::numeric_limits<long double>().max();

    std::cout << "num bytes: " << num_bytes << '\n';
    std::cout << "min value: " << min << '\n';
    std::cout << "max value: " << max << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes: 16
// min value: 3.3621e-4932
// max value: 1.18973e+4932

如前面的代码所示,在英特尔 64 位 CPU 上,long double是 16 字节大小(或 128 位),可以存储绝对庞大的数字。

布尔值

标准 C 语言没有本地定义布尔类型。然而,C++有,并使用bool关键字定义。在 C 中,布尔值可以用任何整数类型表示,通常false表示0true表示1。有趣的是,一些 CPU 能够比较寄存器或内存位置与0更快,这意味着在某些 CPU 上,布尔算术和分支实际上更快地导致典型情况下的false

让我们看一下使用以下代码的bool

#include <iostream>

int main(void)
{
    auto num_bytes = sizeof(bool);
    auto min = std::numeric_limits<bool>().min();
    auto max = std::numeric_limits<bool>().max();

    std::cout << "num bytes: " << num_bytes << '\n';
    std::cout << "min value: " << min << '\n';
    std::cout << "max value: " << max << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// num bytes: 1
// min value: 0
// max value: 1

在前面的代码中,使用 C++在 64 位英特尔 CPU 上的布尔值大小为 1 字节,可以取值为01。值得注意的是,出于相同的原因,布尔值可以是 32 位或者 64 位,取决于 CPU 架构。在英特尔 CPU 上,支持 8 位寄存器大小(即 1 字节),布尔值只需要 1 字节大小。

布尔值的总大小很重要,特别是在磁盘上存储布尔值时。从技术上讲,布尔值只需要一个位来存储其值,但很少(如果有的话)CPU 架构支持位式寄存器和内存访问,这意味着布尔值通常占用多于一个位,有些情况下甚至可能占用多达 64 位。如果您的结果文件的大小很重要,使用内置的布尔类型存储布尔值可能不是首选(最终需要位掩码)。

学习标准整数类型

为了解决 C 和 C++提供的默认类型的不确定性,它们都提供了标准整数类型,可以从stdint.h头文件中访问。此头文件定义了以下类型:

  • int8_tuint8_t

  • int16_tuint16_t

  • int32_tuint32_t

  • int64_tuint64_t

此外,stdint.h提供了上述类型的最小最快版本,以及最大类型和整数指针类型,这些都超出了本书的范围。前面的类型正是您所期望的;它们定义了具有特定位数的整数类型的宽度。例如,int8_t是一个有符号的 8 位整数。无论 CPU 架构、操作系统或模式如何,这些类型始终相同(唯一未定义的是它们的字节顺序,通常仅在处理网络和外部设备时才需要)。

一般来说,如果您正在处理的数据类型的大小很重要,应使用标准整数类型,而不是语言提供的默认类型。尽管标准类型确实解决了许多已经确定的问题,但它们也有自己的问题。具体来说,stdint.h是一个由编译器提供的头文件,对于可能的每个 CPU 架构和操作系统组合,都定义了不同的头文件。此文件中定义的类型通常在底层使用默认类型表示。这是因为编译器知道int32_tint还是long int。为了证明这一点,让我们创建一个能够比较整数类型的应用程序。

我们将从以下头文件开始:

#include <typeinfo>
#include <iostream>

#include <string>
#include <cstdint>
#include <cstdlib>
#include <cxxabi.h>

typeinfo头文件将为我们提供 C++支持的类型信息,最终为我们提供特定整数类型的根类型。问题在于typeinfo为我们提供了这些类型信息的编码版本。为了解码这些信息,我们需要cxxabi.h头文件,它提供了对 C++本身内置的解码器的访问:

template<typename T>
std::string type_name()
{
    int status;
    std::string name = typeid(T).name();

    auto demangled_name =
        abi::__cxa_demangle(name.c_str(), nullptr, nullptr, &status);

    if (status == 0) {
        name = demangled_name;
        std::free(demangled_name);
    }

    return name;
}

前一个函数返回提供的类型T的根名称。首先从 C++中获取类型的名称,然后使用解码器将编码的类型信息转换为人类可读的形式。最后,返回结果名称:

template<typename T1, typename T2>
void
are_equal()
{
    #define red "\0331;31m"
    #define reset "\033[0m"

    std::cout << type_name<T1>() << " vs "
              << type_name<T2>() << '\n';

    if (sizeof(T1) == sizeof(T2)) {
        std::cout << " - size: both == " << sizeof(T1) << '\n';
    }
    else {
        std::cout << red " - size: "
                  << sizeof(T1)
                  << " != "
                  << sizeof(T2)
                  << reset "\n";
    }

    if (type_name<T1>() == type_name<T2>()) {
        std::cout << " - name: both == " << type_name<T1>() << '\n';
    }
    else {
        std::cout << red " - name: "
                  << type_name<T1>()
                  << " != "
                  << type_name<T2>()
                  << reset "\n";
    }
}

前一个函数检查类型的名称和大小是否相同,因为它们不需要相同(例如,大小可能相同,但类型的根可能不同)。应该注意的是,我们向此函数的输出(输出到stdout)添加了一些奇怪的字符。这些奇怪的字符告诉控制台在找不到匹配项时以红色输出,提供了一种简单的方法来查看哪些类型是相同的,哪些类型是不同的:

int main()
{
    are_equal<uint8_t, int8_t>();
    are_equal<uint8_t, uint32_t>();

    are_equal<signed char, int8_t>();
    are_equal<unsigned char, uint8_t>();

    are_equal<signed short int, int16_t>();
    are_equal<unsigned short int, uint16_t>();
    are_equal<signed int, int32_t>();
    are_equal<unsigned int, uint32_t>();
    are_equal<signed long int, int64_t>();
    are_equal<unsigned long int, uint64_t>();
    are_equal<signed long long int, int64_t>();
    are_equal<unsigned long long int, uint64_t>();
}

最后,我们将比较每种标准整数类型与预期(更恰当地说是典型)默认类型,以查看在任何给定架构上这些类型是否实际相同。可以在任何架构上运行此示例,以查看默认类型和标准整数类型之间的差异,以便在需要系统编程时查找不一致之处。

对于在 Ubuntu 上的基于英特尔 64 位 CPU 的uint8_t,结果如下:

are_equal<uint8_t, int8_t>();
are_equal<uint8_t, uint32_t>();

// unsigned char vs signed char
// - size: both == 1
// - name: unsigned char != signed char

// unsigned char vs unsigned int
// - size: 1 != 4
// - name: unsigned char != unsigned int

以下显示了char的结果:


are_equal<signed char, int8_t>();
are_equal<unsigned char, uint8_t>();

// signed char vs signed char
// - size: both == 1
// - name: both == signed char

// unsigned char vs unsigned char
// - size: both == 1
// - name: both == unsigned char

最后,以下代码显示了剩余的int类型的结果:

are_equal<signed short int, int16_t>();
are_equal<unsigned short int, uint16_t>();
are_equal<signed int, int32_t>();
are_equal<unsigned int, uint32_t>();
are_equal<signed long int, int64_t>();
are_equal<unsigned long int, uint64_t>();
are_equal<signed long long int, int64_t>();
are_equal<unsigned long long int, uint64_t>();

// short vs short
// - size: both == 2
// - name: both == short

// unsigned short vs unsigned short
// - size: both == 2
// - name: both == unsigned short

// int vs int
// - size: both == 4
// - name: both == int

// unsigned int vs unsigned int
// - size: both == 4
// - name: both == unsigned int

// long vs long
// - size: both == 8
// - name: both == long

// unsigned long vs unsigned long
// - size: both == 8
// - name: both == unsigned long

// long long vs long
// - size: both == 8
// - name: long long != long

// unsigned long long vs unsigned long
// - size: both == 8
// - name: unsigned long long != unsigned long

所有类型都相同,但有一些显著的例外:

  • 前两个测试是特意提供的,以确保实际上会检测到错误。

  • 在 Ubuntu 上,int64_t是使用long实现的,而不是long long,这意味着在 Ubuntu 上,longlong long是相同的。但在 Windows 上不是这样。

这个演示最重要的是要认识到输出中不包括标准整数类型名称,而只包含默认类型名称。这是因为,如前所示,编译器在 Ubuntu 上的 Intel 64 位 CPU 上使用int实现int32_t,对编译器来说,这些类型是一样的。不同之处在于,在另一个 CPU 架构和操作系统上,int32_t可能是使用long int实现的。

如果您关心整数类型的大小,请使用标准整数类型,并让头文件为您选择默认类型。如果您不关心整数类型的大小,或者 API 规定了类型,请使用默认类型。在下一节中,我们将向您展示,即使标准整数类型也不能保证特定大小,并且刚刚描述的规则在使用常见的系统编程模式时可能会出现问题。

结构打包

标准整数提供了一个编译器支持的方法,用于在编译时指定整数类型的大小。具体来说,它们将位宽映射到默认类型,这样编码人员就不必手动执行此操作。然而,标准类型并不总是保证类型的宽度,结构是一个很好的例子。为了更好地理解这个问题,让我们看一个简单的结构示例:

#include <iostream>

struct mystruct {
    uint64_t data1;
    uint64_t data2;
};

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 16

在前面的例子中,我们创建了一个包含两个 64 位整数的结构。然后,使用sizeof()函数,我们输出了结构的大小到stdout,使用std::cout。如预期的那样,结构的总大小,以字节为单位,是16。值得注意的是,和本书的其余部分一样,本节中的例子都是在 64 位 Intel CPU 上执行的。

现在,让我们看看相同的例子,但其中一个数据类型被更改为 16 位整数,而不是 64 位整数,如下所示:

#include <iostream>

struct mystruct {
    uint64_t data1;
    uint16_t data2;
};

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 16

在前面的例子中,我们有一个结构,其中有两种数据类型,但它们不匹配。然后,我们使用std::cout输出数据结构的大小到stdout,报告的大小是 16 字节。问题在于,我们期望是 10 字节,因为我们将结构定义为 64 位(8 字节)和 16 位(2 字节)整数的组合。

在幕后,编译器正在用 64 位整数替换 16 位整数。这是因为 C 和 C++的基本类型是int,编译器允许将小于int的类型更改为int,即使我们明确声明第二个整数为 16 位整数。换句话说,使用unit16_t并不要求使用 16 位整数,而是在 64 位 Intel CPU 上的 Ubuntu 上是short inttypedef,根据 C 和 C++规范,编译器可以随意将short int更改为int

我们指定整数的顺序也不重要:

#include <iostream>

struct mystruct {
    uint16_t data1;
    uint64_t data2;
};

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 16

如前面的例子所示,编译器再次声明结构的总大小为 16 字节,而我们期望是 10。在这个例子中,编译器更有可能进行这种类型的替换,因为它能够识别到存在对齐问题。具体来说,这段代码编译的 CPU 是 64 位 CPU,这意味着用unit64_t替换uint16_t可能会改善内存缓存,并且将data2对齐到 64 位边界,而不是 16 位边界,如果结构在内存中正确对齐,它将跨越两个 64 位内存位置。

结构并不是唯一可以重现这种类型替换的方法。让我们来看看以下例子:

#include <iostream>

int main()
{
    int16_t s = 42;
    auto result = s + 42;
    std::cout << "size: " << sizeof(result) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 4

在前面的例子中,我们创建了一个 16 位整数,并将其设置为42。然后我们创建了另一个整数,并将其设置为我们的 16 位整数加上42。值42可以表示为 8 位整数,但实际上并没有。相反,编译器将42表示为int,在这种情况下,这意味着这段代码编译的系统大小为 4 字节。

编译器将42表示为int,加上int16_t,结果为int,因为这是更高宽度类型。在前面的例子中,我们使用auto定义了result变量,这确保了结果类型反映了编译器由于这种算术操作而创建的类型。我们也可以将result定义为另一个int16_t,这样也可以工作,除非我们打开整数类型转换警告。这样做会导致一个转换警告,因为编译器构造了一个int,作为加上s加上42的结果,然后必须自动将结果的int转换回int16_t,这将执行一个缩小转换,可能导致溢出(因此会有警告)。

所有这些问题都是编译器能够执行类型转换的结果,从较小宽度类型转换为更高宽度类型,以优化性能,减少溢出的可能性。在这种情况下,一个数字值总是一个int,除非该值需要更多的存储空间(例如,用0xFFFFFFFF00000000替换42)。

这种类型的转换并不总是保证的。考虑以下例子:

#include <iostream>

struct mystruct {
    uint16_t data1;
    uint16_t data2;
};

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 4

在前面的例子中,我们有一个包含两个 16 位整数的结构。结构的总大小报告为 4 字节,这正是我们所期望的。在这种情况下,编译器并没有看到改变整数大小的好处,因此保持了它们不变。

位域也不会改变编译器执行这种类型转换的能力,如下例所示:

#include <iostream>

struct mystruct {
    uint16_t data1 : 2, data2 : 14;
    uint64_t data3;
};

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 16

在前面的例子中,我们创建了一个包含两个整数(一个 16 位整数和一个 64 位整数)的结构,但我们不仅定义了 16 位整数,还定义了位域,使我们可以直接访问整数中的特定位(这种做法在系统编程中应该避免,即将要解释的原因)。定义这些位域并不能阻止编译器将第一个整数的总大小从 16 位改为 64 位。

前面例子的问题在于,位域经常是系统程序员在直接与硬件接口时使用的一种模式。在前面的例子中,第二个 64 位整数预计应该距离结构顶部 2 字节。然而,在这种情况下,第二个 64 位整数实际上距离结构顶部 8 字节。如果我们使用这个结构直接与硬件接口,将会导致一个难以发现的逻辑错误。

克服这个问题的方法是对结构进行打包。以下例子演示了如何做到这一点:

#include <iostream>

#pragma pack(push, 1)
struct mystruct {
    uint64_t data1;
    uint16_t data2;
};
#pragma pack(pop)

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 10

前面的例子类似于本节中的第一个例子。创建了一个包含 64 位整数和 16 位整数的结构。在前面的例子中,结构的大小为 16 字节,因为编译器用 64 位整数替换了 16 位整数。为了解决这个问题,在前面的例子中,我们用#pragma pack#pragma pop宏包装了结构。这些宏告诉编译器(因为我们向宏传递了1,表示一个字节)使用字节粒度对结构进行打包,告诉编译器不允许进行替换优化。

使用这种方法,将变量的顺序更改为编译器尝试执行这种类型优化的更可能情况,仍然会导致结构不被转换,如下例所示:

#include <iostream>

#pragma pack(push, 1)
struct mystruct {
    uint16_t data1;
    uint64_t data2;
};
#pragma pack(pop)

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 10

如前面的例子所示,结构的大小仍然是 10 字节,无论整数的顺序如何。

将结构打包与标准整数类型结合使用足以(假设字节顺序不是问题)直接与硬件进行接口,但是这种模式仍然不鼓励,而是更倾向于构建访问器和利用位掩码,为用户提供一种方式来确保以受控的方式进行直接访问硬件寄存器,而不受编译器的干扰,或者优化产生不希望的结果。

为了解释为什么应该避免打包结构和位字段,让我们看一个与对齐问题相关的例子:

#include <iostream>

#pragma pack(push, 1)
struct mystruct {
    uint16_t data1;
    uint64_t data2;
};
#pragma pack(pop)

int main()
{
    mystruct s;
    std::cout << "addr: " << &s << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// addr: 0x7fffd11069cf

在上一个例子中,我们创建了一个包含 16 位整数和 64 位整数的结构,然后对结构进行了打包,以确保结构的总大小为 10 字节,并且每个数据字段都正确对齐。然而,结构的总对齐方式并不是缓存对齐,这在上一个例子中得到了证明,方法是在堆栈上创建结构的一个实例,然后使用std::cout将结构的地址输出到stdout。如图所示,地址是字节对齐的,而不是缓存对齐的。

为了对结构进行缓存对齐,我们将利用alignas()函数,这将在[第七章中进行解释,内存管理的全面视图

#include <iostream>

#pragma pack(push, 1)
struct alignas(16) mystruct {
    uint16_t data1;
    uint64_t data2;
};
#pragma pack(pop)

int main()
{
    mystruct s;
    std::cout << "addr: " << &s << '\n';
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// addr: 0x7fff44ee3f40
// size: 16

在上一个例子中,我们在结构的定义中添加了alignas()函数,它在堆栈上对结构进行了缓存对齐。我们还输出了结构的总大小,就像以前的例子一样,如图所示,结构不再是紧凑的。换句话说,使用#pragma pack#并不能保证结构实际上会被打包。在所有情况下,编译器都可以根据需要进行更改,即使#pragma pack宏也只是一个提示,而不是要求。

在前面的情况下,应该注意编译器实际上在结构的末尾添加了额外的内存,这意味着结构中的数据成员仍然在它们的正确位置,如下所示:

#include <iostream>

#pragma pack(push, 1)
struct alignas(16) mystruct {
    uint16_t data1;
    uint64_t data2;
};
#pragma pack(pop)

int main()
{
    mystruct s;
    std::cout << "addr data1: " << &s.data1 << '\n';
    std::cout << "addr data2: " << &s.data2 << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// addr data1: 0x7ffc45dd8c90
// addr data2: 0x7ffc45dd8c92

在上一个例子中,每个数据成员的地址都输出到stdout,并且如预期的那样,第一个数据成员对齐到0,第二个数据成员距离结构顶部 2 字节,即使结构的总大小是 16 字节,这意味着编译器通过在结构底部添加额外的整数来获得额外的 6 字节。虽然这可能看起来无害,如果创建了这些结构的数组,并且假定由于使用了#pragma pack,结构的大小为 10 字节,那么将引入一个难以发现的逻辑错误。

为了结束本章,应该提供一个关于指针大小的注释。具体来说,指针的大小完全取决于 CPU 架构、操作系统和应用程序运行的模式。让我们来看下面的例子:

#include <iostream>

#pragma pack(push, 1)
struct mystruct {
    uint16_t *data1;
    uint64_t data2;
};
#pragma pack(pop)

int main()
{
    std::cout << "size: " << sizeof(mystruct) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// size: 16

在上一个例子中,我们存储了一个指针和一个整数,并使用std::cout将结构的总大小输出到stdout。在运行 Ubuntu 的 64 位英特尔 CPU 上,这个结构的总大小是 16 字节。在运行 Ubuntu 的 32 位英特尔 CPU 上,这个结构的总大小将是 12 字节,因为指针只有 4 字节大小。更糟糕的是,如果应用程序被编译为 32 位应用程序,但在 64 位内核上执行,应用程序将看到这个结构为 12 字节,而内核将看到这个结构为 16 字节。尝试将这个结构传递给内核将导致错误,因为应用程序和内核会以不同的方式看待这个结构。

总结

在本章中,我们回顾了 C 和 C++为系统编程提供的不同整数类型(并简要回顾了浮点类型)。我们从讨论 C 和 C++提供的默认类型以及与这些类型相关的利弊开始,包括常见的int类型,解释了它是什么以及如何使用它。接下来,我们讨论了由stdint.h提供的标准整数类型以及它们如何解决默认类型的一些问题。最后,我们结束了本章,讨论了结构打包以及编译器在不同情况下可以进行的类型转换和优化的问题。

在下一章中,我们将介绍 C++17 所做的更改,一种 C++特定的技术称为资源获取即初始化RAII),并概述指导支持库GSL)。

问题

  1. short intint之间有什么区别?

  2. int的大小是多少?

  3. signed intunsigned int的大小不同吗?

  4. int32_tint之间有什么区别?

  5. int16_t保证是 16 位吗?

  6. #pragma pack是做什么的?

  7. 是否可能保证在所有情况下进行结构打包?

进一步阅读

第四章:C++,RAII 和 GSL 复习

在本章中,我们将概述本书中利用的 C++的一些最新进展。我们将首先概述 C++17 规范中对 C++所做的更改。然后我们将简要介绍一种名为资源获取即初始化RAII)的 C++设计模式,以及它在 C++中的使用方式以及为什么它对 C++以及许多其他利用相同设计模式的语言如此重要。本章将以介绍指导支持库GSL)并讨论它如何通过帮助遵守 C++核心指南来增加系统编程的可靠性和稳定性而结束。

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

  • 讨论 C++17 中的进展

  • 概述 RAII

  • 介绍 GSL

技术要求

为了编译和执行本章中的示例,读者必须具备以下条件:

  • 能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请转到以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter04

C++17 的简要概述

本节的目标是简要概述 C++17 和添加到 C++的功能。要了解更全面和深入的 C++17,请参阅本章的进一步阅读部分,其中列出了 Packt Publishing 关于该主题的其他书籍。

语言变化

C++17 语言和语法进行了几处更改。以下是一些示例。

if/switch 语句中的初始化器

在 C++17 中,现在可以在ifswitch语句的定义中定义变量并初始化,如下所示:

#include <iostream>

int main(void)
{
    if (auto i = 42; i > 0) {
        std::cout << "Hello World\n";
    }
}

// > g++ scratchpad.cpp; ./a.out
// Hello World

在前面的示例中,i变量在if语句内部使用分号(;)进行定义和初始化。这对于返回错误代码的 C 和 POSIX 风格函数特别有用,因为存储错误代码的变量可以在适当的上下文中定义。

这个特性如此重要和有用的原因在于只有在条件满足时才定义变量。也就是说,在前面的示例中,只有当i大于0时,i才存在。

这对确保变量在有效时可用非常有帮助,有助于减少使用无效变量的可能性。

switch语句可以发生相同类型的初始化,如下所示:

#include <iostream>

int main(void)
{
    switch(auto i = 42) {
        case 42:
            std::cout << "Hello World\n";
            break;

        default:
            break;
    }
}

// > g++ scratchpad.cpp; ./a.out
// Hello World

在前面的示例中,i变量仅在switch语句的上下文中创建。与if语句不同,i变量存在于所有情况下,这意味着i变量在default状态中可用,这可能代表无效状态。

增加编译时设施

在 C++11 中,constexpr被添加为一种声明,告诉编译器变量、函数等可以在编译时进行评估和优化,从而减少运行时代码的复杂性并提高整体性能。在某些情况下,编译器足够聪明,可以将constexpr语句扩展到其他组件,包括分支语句,例如:

#include <iostream>

constexpr const auto val = true;

int main(void)
{
    if (val) {
        std::cout << "Hello World\n";
    }
}

在这个例子中,我们创建了一个constexpr变量,并且只有在constexprtrue时才将Hello World输出到stdout。由于在这个例子中它总是为真,编译器将完全从代码中删除该分支,如下所示:

push %rbp
mov %rsp,%rbp
lea 0x100(%rip),%rsi
lea 0x200814(%rip),%rdi
callq 6c0 <...cout...>
mov $0x0,%eax
pop %rbp
retq

正如你所看到的,代码加载了一些寄存器并调用std::cout,而没有检查val是否为真,因为编译器完全从生成的二进制代码中删除了该代码。C++11 的问题在于作者可能会假设这种类型的优化正在进行,而实际上可能并没有。

为了防止这种类型的错误,C++17 添加了constexpr if语句,告诉编译器在编译时特别优化分支。如果编译器无法优化if语句,将会发生显式的编译时错误,告诉用户无法进行优化,为用户提供修复问题的机会(而不是假设优化正在进行,实际上可能并没有进行),例如:

#include <iostream>

int main(void)
{
    if constexpr (constexpr const auto i = 42; i > 0) {
        std::cout << "Hello World\n";
    }
}

// > g++ scratchpad.cpp; ./a.out
// Hello World

在前面的例子中,我们有一个更复杂的if语句,它利用了编译时的constexpr优化以及if语句的初始化器。生成的二进制代码如下:

push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
movl $0x2a,-0x4(%rbp)
lea 0x104(%rip),%rsi 
lea 0x200809(%rip),%rdi 
callq 6c0 <...cout...>
mov $0x0,%eax
leaveq
retq

可以看到,结果的二进制代码中已经移除了分支,更具体地说,如果表达式不是常量,编译器会抛出一个错误,说明这段代码无法按照所述进行编译。

应该注意到,这个结果并不是之前的相同二进制代码,可能会有人期望的那样。似乎 GCC 7.3 在其优化引擎中还有一些额外的改进,因为在这段代码中定义和初始化的constexpr i变量没有被移除(当代码中并不需要为i分配栈空间时)。

另一个编译时的变化是static_assert编译时函数的不同版本。在 C++11 中,添加了以下内容:

#include <iostream>

int main(void)
{
    static_assert(42 == 42, "the answer");
}

// > g++ scratchpad.cpp; ./a.out
// 

static_assert函数的目标是确保某些编译时的假设是正确的。当编写系统时,这是特别有帮助的,比如确保一个结构体的大小是特定的字节数,或者根据你正在编译的系统来确保某个代码路径被执行。这个断言的问题在于它需要添加一个在编译时输出的描述,这个描述可能只是用英语描述了断言而没有提供任何额外的信息。在 C++17 中,添加了另一个版本的这个断言,它去掉了对描述的需求,如下所示:

#include <iostream>

int main(void)
{
    static_assert(42 == 42);
}

// > g++ scratchpad.cpp; ./a.out
//

命名空间

C++17 中一个受欢迎的变化是添加了嵌套命名空间。在 C++17 之前,嵌套命名空间必须在不同的行上定义,如下所示:

#include <iostream>

namespace X 
{
namespace Y
{
namespace Z 
{
    auto msg = "Hello World\n";
}
}
}

int main(void)
{
    std::cout << X::Y::Z::msg;
}

// > g++ scratchpad.cpp; ./a.out
// Hello World

在前面的例子中,我们定义了一个在嵌套命名空间中输出到stdout的消息。这种语法的问题是显而易见的——它占用了大量的空间。在 C++17 中,通过在同一行上声明嵌套命名空间来消除了这个限制,如下所示:

#include <iostream>

namespace X::Y::Z 
{
    auto msg = "Hello World\n";
}

int main(void)
{
    std::cout << X::Y::Z::msg;
}

// > g++ scratchpad.cpp; ./a.out
// Hello World

在前面的例子中,我们能够定义一个嵌套的命名空间,而不需要单独的行。

结构化绑定

我对 C++17 的一个最喜欢的新增功能是结构化绑定。在 C++17 之前,复杂的结构,比如结构体或std::pair,可以用来作为函数输出的多个值,但语法很繁琐,例如:

#include <utility>
#include <iostream>

std::pair<const char *, int>
give_me_a_pair()
{
    return {"The answer is: ", 42};
}

int main(void)
{
    auto p = give_me_a_pair();
    std::cout << std::get<0>(p) << std::get<1>(p) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// The answer is: 42

在前面的例子中,give_me_a_pair()函数返回一个带有The answer is:字符串和整数42std::pair。这个函数的结果存储在main函数中的一个名为p的变量中,需要使用std::get()来获取std::pair的第一部分和第二部分。这段代码在没有进行积极的优化时既笨拙又低效,因为需要额外的函数调用来获取give_me_a_pair()的结果。

在 C++17 中,结构化绑定为我们提供了一种检索结构体或std::pair的各个字段的方法,如下所示:

#include <iostream>

std::pair<const char *, int>
give_me_a_pair()
{
    return {"The answer is: ", 42};
}

int main(void)
{
    auto [msg, answer] = give_me_a_pair();
    std::cout << msg << answer << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// The answer is: 42

在前面的例子中,give_me_a_pair()函数返回与之前相同的std::pair,但这次我们使用了结构化绑定来获取give_me_a_pair()的结果。msganswer变量被初始化为std::pair的结果,为我们提供了直接访问结果的方式,而不需要使用std::get()

同样的也适用于结构体,如下所示:

#include <iostream>

struct mystruct
{
    const char *msg;
    int answer;
};

mystruct
give_me_a_struct()
{
    return {"The answer is: ", 42};
}

int main(void)
{
    auto [msg, answer] = give_me_a_struct();
    std::cout << msg << answer << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// The answer is: 42

在前面的示例中,我们创建了一个由give_me_a_struct()返回的结构。使用结构化绑定获取此函数的结果,而不是使用std::get()

内联变量

C++17 中更具争议的一个新增功能是内联变量的包含。随着时间的推移,越来越多的仅头文件库由 C++社区的各个成员开发。这些库提供了在 C++中提供复杂功能的能力,而无需安装和链接到库(只需包含库即可)。这些类型的库的问题在于它们必须在库本身中使用花哨的技巧来包含全局变量。

内联变量解决了这个问题,如下所示:

#include <iostream>

inline auto msg = "Hello World\n";

int main(void)
{
    std::cout << msg;
}

// > g++ scratchpad.cpp; ./a.out
// Hello World

在前面的示例中,msg变量被声明为inline。这种类型的变量可以在头文件(即.h文件)中定义,并且可以多次包含而不会在链接期间定义多个定义。值得注意的是,内联变量还消除了对以下内容的需求:

extern const char *msg;

通常,多个源文件需要一个全局变量,并且使用前述模式将变量暴露给所有这些源文件。前面的代码添加到一个由所有源文件包含的头文件中,然后一个源文件实际上定义变量,例如:

const char *msg = "Hello World\n";

尽管这种方法有效,但它很麻烦,而且并不总是清楚哪个源文件实际上应该定义变量。使用内联变量可以解决这个问题,因为头文件既定义了变量,又将符号暴露给所有需要它的源文件,消除了歧义。

库的更改

除了对语言语法的更改,还对库进行了一些更改。以下是一些显著的更改。

字符串视图

正如本章的GSL部分将讨论的那样,C++社区内部正在推动消除对指针和数组的直接访问。在应用程序中发现的大多数段错误和漏洞都可以归因于对指针和数组的处理不当。随着程序变得越来越复杂,并由多人修改而没有完整了解应用程序及其如何使用每个指针和/或数组的情况,引入错误的可能性也会增加。

为了解决这个问题,C++社区已经采纳了 C++核心指南:github.com/isocpp/CppCoreGuidelines

C++核心指南的目标是定义一组最佳实践,以帮助防止在使用 C++编程时出现的常见错误,以限制引入程序的总错误数量。 C++已经存在多年了,尽管它有很多设施来防止错误,但它仍然保持向后兼容性,允许旧程序与新程序共存。 C++核心指南帮助新用户和专家用户浏览可用的许多功能,以帮助创建更安全和更健壮的应用程序。

C++17 中为支持这一努力添加的一个功能是std::string_view{}类。std::string_view是字符数组的包装器,类似于std::array,有助于使使用基本 C 字符串更安全和更容易,例如:

#include <iostream>
#include <string_view>

int main(void)
{
    std::string_view str("Hello World\n");
    std::cout << str;
}

// > g++ scratchpad.cpp; ./a.out
// Hello World

在前面的示例中,我们创建了std::string_view{}并将其初始化为 ASCII C 字符串。然后使用std::cout将字符串输出到stdout。与std::array一样,std::string_view{}提供了对基础数组的访问器,如下所示:

#include <iostream>
#include <string_view>

int main(void)
{
    std::string_view str("Hello World");

    std::cout << str.front() << '\n';
    std::cout << str.back() << '\n';
    std::cout << str.at(1) << '\n';
    std::cout << str.data() << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// H
// d
// e
// Hello World

在上面的例子中,front()back()函数可用于获取字符串中的第一个和最后一个字符,而at()函数可用于获取字符串中的任何字符;如果索引超出范围(即,提供给at()的索引比字符串本身还长),则会抛出std::out_of_range{}异常。最后,data()函数可用于直接访问底层数组。不过,应谨慎使用此函数,因为其使用会抵消std::string_view{}的安全性好处。

除了访问器之外,std::string_view{}类还提供了有关字符串大小的信息:

#include <iostream>
#include <string_view>

int main(void)
{
    std::string_view str("Hello World");

    std::cout << str.size() << '\n';
    std::cout << str.max_size() << '\n';
    std::cout << str.empty() << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// 11
// 4611686018427387899
// 0

在上面的例子中,size()函数返回字符串中的字符总数,而empty()函数在size() == 0时返回true,否则返回falsemax_size()函数定义了std::string_view{}可以容纳的最大大小,在大多数情况下是无法实现或现实的。在上面的例子中,最大字符串大小超过一百万兆字节。

std::array不同,std::string_view{}提供了通过从字符串的前面或后面删除字符来减小字符串视图的能力,如下所示:

#include <iostream>
#include <string_view>

int main(void)
{
    std::string_view str("Hello World");

    str.remove_prefix(1);
    str.remove_suffix(1);
    std::cout << str << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// ello Worl

在上面的例子中,remove_prefix()remove_suffix()函数用于从字符串的前面和后面各删除一个字符,结果是将ello Worl输出到stdout。需要注意的是,这只是改变了起始字符并重新定位了结束的空字符指针,而无需重新分配内存。对于更高级的功能,应该使用std::string{},但这会带来额外的内存分配性能损失。

也可以按如下方式访问子字符串:

#include <iostream>
#include <string_view>

int main(void)
{
    std::string_view str("Hello World");
    std::cout << str.substr(0, 5) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// Hello

在上面的例子中,我们使用substr()函数访问Hello子字符串。

也可以比较字符串:

#if SNIPPET13

#include <iostream>
#include <string_view>

int main(void)
{
    std::string_view str("Hello World");

    if (str.compare("Hello World") == 0) {
        std::cout << "Hello World\n";
    }

    std::cout << str.compare("Hello") << '\n';
    std::cout << str.compare("World") << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// Hello World
// 6
// -1

strcmp()函数类似,比较函数在比较两个字符串时返回0,而它们不同时返回差异。

最后,搜索函数如下所示:

#include <iostream>

int main(void)
{
    std::string_view str("Hello this is a test of Hello World");

    std::cout << str.find("Hello") << '\n';
    std::cout << str.rfind("Hello") << '\n';
    std::cout << str.find_first_of("Hello") << '\n';
    std::cout << str.find_last_of("Hello") << '\n';
    std::cout << str.find_first_not_of("Hello") << '\n';
    std::cout << str.find_last_not_of("Hello") << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// 0
// 24
// 0
// 33
// 5
// 34

这个例子的结果如下:

  • find()函数返回字符串中第一次出现Hello的位置,这种情况下是0

  • rfind()返回提供的字符串的最后出现位置,在这种情况下是24

  • find_first_of()find_last_of()找到提供的任何字符的第一个和最后一个出现位置(而不是整个字符串)。在这种情况下,H在提供的字符串中,而Hmsg中的第一个字符,这意味着find_first_of()返回0,因为0是字符串中的第一个索引。

  • find_last_of()中,l是最后出现的字母,位置在33

  • find_first_not_of()find_last_not_of()find_first_of()find_last_of()的相反,返回提供的字符串中任何字符的第一个和最后一个出现位置。

std::any,std::variant 和 std::optional

C++17 中的其他受欢迎的新增功能是std::any{}std::variant{}std::optional{}类。std::any{}能够随时存储任何值。需要特殊的访问器来检索std::any{}中的数据,但它们能够以类型安全的方式保存任何值。为了实现这一点,std::any{}利用了内部指针,并且每次更改类型时都必须分配内存,例如:

#include <iostream>
#include <any>

struct mystruct {
    int data;
};

int main(void)
{
    auto myany = std::make_any<int>(42);
    std::cout << std::any_cast<int>(myany) << '\n';

    myany = 4.2;
    std::cout << std::any_cast<double>(myany) << '\n';

    myany = mystruct{42};
    std::cout << std::any_cast<mystruct>(myany).data << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// 42
// 4.2
// 42

在上面的例子中,我们创建了std::any{}并将其设置为具有值42int,具有值4.2double,以及具有值42struct

std::variant更像是一个类型安全的联合。联合在编译时为联合中存储的所有类型保留存储空间(因此不需要分配,但是所有可能的类型必须在编译时已知)。标准 C 联合的问题在于无法知道任何给定时间存储的是什么类型。同时存储 int 和double是有问题的,因为同时使用两者会导致损坏。使用std::variant可以避免这种问题,因为std::variant知道它当前存储的是什么类型,并且不允许尝试以不同类型访问数据(因此,std::variant是类型安全的),例如:

#include <iostream>
#include <variant>

int main(void)
{
    std::variant<int, double> v = 42;
    std::cout << std::get<int>(v) << '\n';

    v = 4.2;
    std::cout << std::get<double>(v) << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// 42
// 4.2

在前面的例子中,std::variant被用来存储integerdouble,我们可以安全地从std::variant中检索数据而不会损坏。

std::optional是一个可空的值类型。指针是一个可空的引用类型,其中指针要么无效,要么有效并存储一个值。要创建一个指针值,必须分配内存(或者至少指向内存)。std::optional是一个值类型,这意味着不需要为std::optional分配内存,并且在底层,只有在可选项有效时才执行构造,消除了在实际未设置时构造默认值类型的开销。对于复杂对象,这不仅提供了确定对象是否有效的能力,还允许我们在无效情况下跳过构造,从而提高性能,例如:

#include <iostream>
#include <optional>

class myclass
{
public:
    int val;

    myclass(int v) :
        val{v}
    {
        std::cout << "constructed\n";
    }
};

int main(void)
{
    std::optional<myclass> o;
    std::cout << "created, but not constructed\n";

    if (o) {
        std::cout << "Attempt #1: " << o->val << '\n';
    }

    o = myclass{42};

    if (o) {
        std::cout << "Attempt #2: " << o->val << '\n';
    }
}

// > g++ scratchpad.cpp; ./a.out
// created, but not constructed
// constructed
// Attempt #2: 42

在前面的例子中,我们创建了一个简单的类,用于存储一个integer。在这个类中,当类被构造时,我们向 stdout 输出一个字符串。然后我们使用std::optional创建了这个类的一个实例。我们尝试在实际设置类为有效值之前和之后访问这个std::optional。如所示,只有在我们实际设置类为有效值之后,类才被构造。由于sts::unique_ptr曾经是创建 optionals 的常用方法,因此std::optional共享一个常用的接口并不奇怪。

资源获取即初始化(RAII)

RAII 可以说是 C 和 C++之间最显著的区别之一。RAII 为整个 C++库奠定了基础和设计模式,并且已经成为无数其他语言的灵感之源。这个简单的概念为 C++提供了无与伦比的安全性,与 C 相比,这个概念将在本书中被充分利用,当 C 和 POSIX 必须用于替代 C++时(例如,当 C++的替代方案要么不存在,要么不完整时)。

RAII 的理念很简单。如果分配了资源,它是在对象构造期间分配的,当对象被销毁时,资源被释放。为了实现这一点,RAII 利用了 C++的构造和销毁特性,例如:

#include <iostream>

class myclass
{
public:
    myclass()
    {
        std::cout << "Hello from constructor\n";
    }

    ~myclass()
    {
        std::cout << "Hello from destructor\n";
    }
};

int main(void)
{
    myclass c;
}

// > g++ scratchpad.cpp; ./a.out
// Hello from constructor
// Hello from destructor

在前面的例子中,我们创建了一个在构造和销毁时向stdout输出的类。如所示,当类被实例化时,类被构造,当类失去焦点时,类被销毁。

这个简单的概念可以用来保护资源,如下所示:

#include <iostream>

class myclass
{
    int *ptr;

public:
    myclass() :
        ptr{new int(42)}
    { }

    ~myclass()
    {
        delete ptr;
    }

    int get()
    {
        return *ptr;
    }
};

int main(void)
{
    myclass c;
    std::cout << "The answer is: " << c.get() << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// The answer is: 42

在前面的例子中,当myclass{}被构造时,分配了一个指针,并且当myclass{}被销毁时,指针被释放。这种模式提供了许多优势:

  • 只要myclass{}的实例可见(即可访问),指针就是有效的。因此,任何尝试访问类中的内存都是安全的,因为只有在类的范围丢失时才会释放内存,这将导致无法访问类(假设没有使用指向类的指针和引用)。

  • 不会发生内存泄漏。如果类可见,类分配的内存将是有效的。一旦类不再可见(即失去范围),内存就会被释放,不会发生泄漏。

具体来说,RAII 确保在对象初始化时获取资源,并在不再需要对象时释放资源。正如稍后将在第七章中展示的那样,std::unique_ptr[]std::shared_ptr{}利用了这种精确的设计模式(尽管,这些类不仅仅是上面的例子,还要求在获取资源的同时确保所有权)。

RAII 不仅适用于指针;它可以用于必须获取然后释放的任何资源,例如:

#include <iostream>

class myclass
{
    FILE *m_file;

public:
    myclass(const char *filename) :
        m_file{fopen(filename, "rb")}
    {
        if (m_file == 0) {
            throw std::runtime_error("unable to open file");
        }
    }

    ~myclass()
    {
        fclose(m_file);
        std::clog << "Hello from destructor\n";
    }
};

int main(void)
{
    myclass c1("test.txt");

    try {
        myclass c2("does_not_exist.txt");
    }
    catch(const std::exception &e) {
        std::cout << "exception: " << e.what() << '\n';
    }
}

// > g++ scratchpad.cpp; touch test.txt; ./a.out
// exception: unable to open file
// Hello from destructor

在前面的例子中,我们创建了一个在构造时打开文件并存储其句柄,然后在销毁时关闭文件并释放句柄的类。在主函数中,我们创建了一个类的实例,它既被构造又被正常销毁,利用 RAII 来防止文件泄漏。

除了正常情况外,我们创建了第二个类,试图打开一个不存在的文件。在这种情况下,会抛出异常。这里需要注意的重要一点是,对于这个第二个实例,析构函数不会被调用。这是因为构造失败并抛出了异常。因此,没有获取资源,因此也不需要销毁。也就是说,资源的获取直接与类本身的初始化相关联,而安全地构造类可以防止销毁从未分配的资源。

RAII 是 C++的一个简单而强大的特性,在 C++中被广泛利用,这种设计模式将在本书中进行扩展。

指导支持库(GSL)

如前所述,C++核心指南的目标是提供与 C++编程相关的最佳实践。GSL 是一个旨在帮助遵守这些指南的库。总的来说,GSL 有一些整体主题:

  • 指针所有权:定义谁拥有指针是防止内存泄漏和指针损坏的简单方法。一般来说,定义所有权的最佳方法是通过使用std::unique_ptr{}std::shared_ptr{},这将在第七章中深入解释,但在某些情况下,这些不能使用,GSL 有助于处理这些边缘情况。

  • 期望管理:GSL 还有助于定义函数对输入的期望和对输出的保证,目标是将这些概念转换为 C++合同。

  • 没有指针算术:指针算术是程序不稳定和易受攻击的主要原因之一。消除指针算术(或者至少将指针算术限制在经过充分测试的支持库中)是消除这些问题的简单方法。

指针所有权

经典的 C++不区分谁拥有指针(即负责释放与指针关联的内存的代码或对象)和谁只是使用指针访问内存,例如:

void init(int *p)
{
    *p = 0;
}

int main(void)
{
    auto p = new int;
    init(p);
    delete p;
}

// > g++ scratchpad.cpp; ./a.out
//

在前面的例子中,我们分配了一个指向整数的指针,然后将该指针传递给一个名为init()的函数,该函数初始化指针。最后,在init()函数使用完指针后,我们删除了指针。如果init()函数位于另一个文件中,就不清楚init()函数是否应该删除指针。尽管在这个简单的例子中,这可能是显而易见的,但在有大量代码的复杂项目中,这种意图可能会丢失。对这样的代码进行未来修改可能会导致使用未定义所有权的指针。

为了克服这一点,GSL 提供了一个gsl::owner<>修饰,用于记录给定变量是否是指针的所有者,例如:

#include <gsl/gsl>

void init(int *p)
{
    *p = 0;
}

int main(void)
{
    gsl::owner<int *> p = new int;
    init(p);
    delete p;
}

// > g++ scratchpad.cpp; ./a.out
//

在前面的例子中,我们记录了main函数中的p是指针的所有者,这意味着一旦p不再需要,指针应该被释放。前面例子中的另一个问题是init()函数期望指针不为空。如果指针为空,将发生空指针解引用。

有两种常见的方法可以克服空指针解引用的可能性。第一种选择是检查nullptr并抛出异常。这种方法的问题在于你必须在每个函数上执行这个空指针检查。这些类型的检查成本高,而且会使代码混乱。另一个选择是使用gsl::not_null<>{}类。像gsl::owner<>{}一样,gsl::not_null<>{}是一个装饰,可以在不使用调试时从代码中编译出来。然而,如果启用了调试,gsl::not_null<>{}将抛出异常,abort(),或者在某些情况下,如果变量设置为 null,拒绝编译。使用gsl::not_null<>{},函数可以明确说明是否允许和安全处理空指针,例如:

#include <gsl/gsl>

gsl::not_null<int *>
test(gsl::not_null<int *> p)
{
    return p;
}

int main(void)
{
    auto p1 = std::make_unique<int>();
    auto p2 = test(gsl::not_null(p1.get()));
}

// > g++ scratchpad.cpp; ./a.out
//

在前面的例子中,我们使用std::unique_ptr{}创建了一个指针,然后将得到的指针传递给一个名为test()的函数。test()函数不支持空指针,因此使用gsl::not_null<>{}来表示这一点。反过来,test()函数返回gsl::not_null<>{},告诉用户test()函数确保函数的结果不为空(这也是为什么test函数一开始不支持空指针的原因)。

指针算术

指针算术是导致不稳定和易受攻击的常见错误源。因此,C++核心指南不鼓励使用这种类型的算术。以下是一些指针算术的例子:

int array[10];

auto r1 = array + 1;
auto r2 = *(array + 1);
auto r3 = array[1];

最后一个例子可能是最令人惊讶的。下标运算符实际上是指针算术,其使用可能导致越界错误。为了克服这一点,GSL 提供了gsl::span{}类,为我们提供了一个安全的接口,用于处理指针,包括数组,例如:

#define GSL_THROW_ON_CONTRACT_VIOLATION
#include <gsl/gsl>
#include <iostream>

int main(void)
{
    int array[5] = {1, 2, 3, 4, 5};
    auto span = gsl::span(array);

    for (const auto &elem : span) {
        std::clog << elem << '\n';
    }

    for (auto i = 0; i < 5; i++) {
        std::clog << span[i] << '\n';
    }

    try {
        std::clog << span[5] << '\n';
    }
    catch(const gsl::fail_fast &e) {
        std::cout << "exception: " << e.what() << '\n';
    }
}

// > g++ scratchpad.cpp; ./a.out
// 1
// 2
// 3
// 4
// 5
// 1
// 2
// 3
// 4
// 5
// exception: GSL: Precondition failure at ...

让我们看看前面的例子是如何工作的:

  1. 我们创建一个数组,并用一组整数初始化它。

  2. 我们创建一个 span,以便可以安全地与数组交互。我们使用基于范围的for循环(因为 span 包括一个迭代器接口)将数组输出到stdout

  3. 我们使用传统的索引和下标运算符(即[]运算符)将数组第二次输出到stdout。这个下标运算符的不同之处在于每个数组访问都会检查是否越界。为了证明这一点,我们尝试访问数组越界,gsl::span{}抛出了一个gsl::fail_fast{}异常。应该注意的是,GSL_THROW_ON_CONTRACT_VIOLATION用于告诉 GSL 抛出异常,而不是执行std::terminate或完全忽略边界检查。

除了gsl::span{}之外,GSL 还包含gsl::span{}的特殊化,这些特殊化在处理常见类型的数组时对我们有所帮助。例如,GSL 提供了gsl::cstring_span{},如下所示:

#include <gsl/gsl>
#include <iostream>

int main(void)
{
    gsl::cstring_span<> str = gsl::ensure_z("Hello World\n");
    std::cout << str.data();

    for (const auto &elem : str) {
        std::clog << elem;
    }
}

// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World

gsl::cstring_span{}是一个包含标准 C 风格字符串的gsl::span{}。在前面的例子中,我们使用gsl::ensure_z()函数将gsl::cstring_span{}加载到标准 C 风格字符串中,以确保字符串在继续之前以空字符结尾。然后我们使用常规的std::cout调用和使用基于范围的循环输出标准 C 风格字符串。

合同

C++合同为用户提供了一种说明函数期望的输入以及函数确保的输出的方法。具体来说,C++合同记录了 API 的作者和 API 的用户之间的合同,并提供了对该合同的编译时和运行时验证。

未来的 C++版本将内置支持合同,但在此之前,GSL 通过提供expects()ensures()宏的库实现了 C++合同,例如:

#define GSL_THROW_ON_CONTRACT_VIOLATION
#include <gsl/gsl>
#include <iostream>

int main(void)
{
    try {
        Expects(false);
    }
    catch(const gsl::fail_fast &e) {
        std::cout << "exception: " << e.what() << '\n';
    }
}

// > g++ scratchpad.cpp; ./a.out
// exception: GSL: Precondition failure at ...

在前面的例子中,我们使用Expects()宏并将其传递为false。与标准 C 库提供的assert()函数不同,Expects()宏在false时失败。与assert()不同,即使在禁用调试时,如果传递给Expects()的表达式求值为falseExpects()也将执行std::terminate()。在前面的例子中,我们声明Expects()应该抛出gsl::fail_fast{}异常,而不是执行std::terminate()

Ensures()宏与Expects()相同,唯一的区别是名称,用于记录合同的输出而不是输入,例如:

#define GSL_THROW_ON_CONTRACT_VIOLATION
#include <gsl/gsl>
#include <iostream>

int
test(int i)
{
    Expects(i >= 0 && i < 41);
    i++;

    Ensures(i < 42);
    return i;
}

int main(void)
{
    test(0);

    try {
        test(42);
    }
    catch(const gsl::fail_fast &e) {
        std::cout << "exception: " << e.what() << '\n';
    }
}

// > g++ scratchpad.cpp; ./a.out
// exception: GSL: Precondition failure at ...

在前面的例子中,我们创建了一个函数,该函数期望输入大于或等于0且小于41。然后函数对输入进行操作,并确保结果输出始终小于42。一个正确编写的函数将定义其期望,以便Ensures()宏永远不会触发。相反,如果输入导致输出违反合同,则Expects()检查可能会触发。

实用程序

GSL 还提供了一些有用的辅助工具,有助于创建更可靠和可读的代码。其中一个例子是gsl::finally{}API,如下:

#define concat1(a,b) a ## b
#define concat2(a,b) concat1(a,b)
#define ___ concat2(dont_care, __COUNTER__)

#include <gsl/gsl>
#include <iostream>

int main(void)
{
    auto ___ = gsl::finally([]{
        std::cout << "Hello World\n";
    });
}

// > g++ scratchpad.cpp; ./a.out
// Hello World

gsl::finally{}提供了一种简单的方法,在函数退出之前执行代码,利用 C++析构函数。当函数必须在退出之前执行清理时,这是有帮助的。应该注意,gsl::finally{}在存在异常时最有用。通常,当触发异常时,清理代码被遗忘,导致清理逻辑永远不会执行。gsl::finally{} API 将始终执行,即使发生异常,只要它在执行可能生成异常的操作之前定义。

在前面的代码中,我们还包括了一个有用的宏,允许使用___来定义要使用的gsl::finally{}的名称。具体来说,gsl::finally{}的用户必须存储gsl::finally{}对象的实例,以便在退出函数时销毁该对象,但是必须命名gsl::finally{}对象是繁琐且无意义的,因为没有 API 与gsl::finally{}对象交互(它的唯一目的是在exit时执行)。这个宏提供了一种简单的方式来表达,“我不在乎变量的名称是什么”。

GSL 提供的其他实用程序包括gsl::narrow<>()gsl::narrow_cast<>(),例如:

#include <gsl/gsl>
#include <iostream>

int main(void)
{
    uint64_t val = 42;

    auto val1 = gsl::narrow<uint32_t>(val);
    auto val2 = gsl::narrow_cast<uint32_t>(val);
}

// > g++ scratchpad.cpp; ./a.out
//

这两个 API 与常规的static_cast<>()相同,唯一的区别是gsl::narrow<>()执行溢出检查,而gsl::narrow_cast<>()只是static_cast<>()的同义词,用于记录整数的缩小(即将具有更多位的整数转换为具有较少位的整数)。

#endif

#if SNIPPET30

#define GSL_THROW_ON_CONTRACT_VIOLATION
#include <gsl/gsl>
#include <iostream>

int main(void)
{
    uint64_t val = 0xFFFFFFFFFFFFFFFF;

    try {
        gsl::narrow<uint32_t>(val);
    }
    catch(...) {
        std::cout << "narrow failed\n";
    }
}

// > g++ scratchpad.cpp; ./a.out
// narrow failed

在前面的例子中,我们尝试使用gsl::narrow<>()函数将 64 位整数转换为 32 位整数,该函数执行溢出检查。由于发生了溢出,抛出了异常。

总结

在本章中,我们概述了本书中使用的 C++的一些最新进展。我们从 C++17 规范中对 C++所做的更改开始。然后我们简要介绍了一个称为 RAII 的 C++设计模式,以及它如何被 C++使用。最后,我们介绍了 GSL 以及它如何通过帮助遵守 C++核心指南来增加系统编程的可靠性和稳定性。

在下一章中,我们将介绍 UNIX 特定的主题,如 UNIX 进程和信号,以及 System V 规范的全面概述,该规范用于定义如何在 Intel CPU 上为 UNIX 编写程序。

问题

  1. 什么是结构化绑定?

  2. C++17 对嵌套命名空间做了哪些改变?

  3. C++17 对static_assert()函数做了哪些改变?

  4. 什么是if语句的初始化器?

  5. RAII 代表什么?

  6. RAII 用于什么?

  7. gsl::owner<>{}有什么作用?

  8. Expects()Ensures()的目的是什么?

进一步阅读

第五章:编程 Linux/Unix 系统

本章的目标是解释在 Linux/Unix 系统上编程的基础知识。这将提供一个更完整的图景,说明程序在 Unix/Linux 系统上如何执行,如何编写更高效的代码,以及在出现难以找到的错误时应该去哪里寻找。

为此,本章首先全面审视 Linux ABI,或者更具体地说,System V ABI。在本节中,我们将从寄存器和栈布局到 System V 调用约定和 ELF 二进制对象规范进行全面审查。

下一节将简要介绍 Linux 文件系统,包括标准布局和权限。然后,我们将全面审查 Unix 进程以及如何对它们进行编程,包括考虑到创建新进程和进程间通信等方面。

最后,本章将简要概述基于 Unix 的信号以及如何处理它们(发送和接收)。

在本章中,我们将讨论以下内容:

  • Linux ABI

  • Unix 文件系统

  • Unix 进程 API

  • Unix 信号 API

技术要求

为了跟随本章中的示例,您必须具备以下条件:

  • 一个能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请转到以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter05

Linux ABI

在本节中,我们将讨论 Linux ABI(实际上称为System V ABI),以及 ELF 标准及其在 Linux/Unix 中的使用。

我们还将深入探讨与 ELF 文件相关的一些细节,如何读取和解释它们,以及 ELF 文件中特定组件的一些含义。

System V ABI

Unix System V 是最早可用的 Unix 版本之一,并在很大程度上定义了多年的 Unix。在内部,System V 利用了 System V ABI。随着 Linux 和 BSD(类 Unix 操作系统)的广泛使用,System V 的流行度下降了。然而,System V ABI 仍然很受欢迎,因为诸如 Linux 之类的操作系统采用了这一规范用于基于 Intel 的个人电脑。

在本章中,我们将重点关注 Linux 操作系统上 Intel 平台的 System V ABI。然而,需要注意的是,其他架构和操作系统可能使用不同的 ABI。例如,ARM 有自己的 ABI,它在很大程度上基于 System V(奇怪的是,还有 Itanium 64 规范),但有一些关键的区别。

本节的目标是揭示单个 Unix ABI 的内部工作原理,从而使必要时学习其他 ABI 更容易。

本章讨论的大部分规范可以在以下链接找到:refspecs.linuxfoundation.org/

System V ABI 定义了程序的大部分低级细节(从而定义了系统编程的接口),包括:

  • 寄存器布局

  • 栈帧

  • 函数前言和尾声

  • 调用约定(即参数传递)

  • 异常处理

  • 虚拟内存布局

  • 调试

  • 二进制对象格式(在本例中为 ELF)

  • 程序加载和链接

在第二章中,学习 C、C++17 和 POSIX 标准,我们讨论了程序链接和动态加载的细节,并专门讨论了二进制对象格式(ELF)。

以下是关于 Intel 64 位架构的 System V 规范的其余细节的简要描述。

寄存器布局

为了简化这个话题,我们将专注于英特尔 64 位。每个 ABI、操作系统和架构组合的不同寄存器布局都可以写成一本书。

英特尔 64 位架构(通常称为 AMD64,因为 AMD 实际上编写了它)定义了几个寄存器,其中一些在指令集中有定义的含义。

指令指针rip定义了程序在可执行内存中的当前位置。具体来说,当程序执行时,它从rip中存储的位置执行,并且每次指令执行完毕,rip都会前进到下一条指令。

堆栈指针和基指针(分别为rsprbp)用于定义堆栈中的当前位置,以及堆栈帧的开始位置(我们将在后面提供更多信息)。

以下是剩余的通用寄存器。它们有不同的含义,将在本节的其余部分中讨论:raxrbxrcxrdxrdirsir8r9r10r11r12r13r14r15

在继续之前,应该指出系统上还定义了几个具有非常具体目的的寄存器,包括浮点寄存器和宽寄存器(这些寄存器由专门设计用于加速某些类型计算的特殊指令使用;例如,SSE 和 AVX)。这些超出了本讨论的范围。

最后,一些寄存器以字母结尾,而另一些以数字结尾,因为英特尔的 x86 处理器版本只有基于字母的寄存器,真正的通用寄存器只有 AX、BX、CX 和 DX。

当 AMD 引入 64 位时,通用寄存器的数量翻了一番,为了保持简单,寄存器名称被赋予了数字。

堆栈帧

堆栈帧用于存储每个函数的返回地址,并存储函数参数和基于堆栈的变量。它是所有程序都大量使用的资源,它采用以下形式:

high |----------| <- top of stack
     |          |
     |   Used   |
     |          |
     |----------| <- Current frame (rbp)
     |          | <- Stack pointer (rsp)
     |----------|
     |          |
     |  Unused  |
     |          |
 low |----------|

堆栈帧只不过是一个从顶部向底部增长的内存数组。也就是说,在英特尔 PC 上,向堆栈推送会从堆栈指针中减去,而从堆栈弹出会向堆栈指针中加上,这意味着内存实际上是向下增长的(假设您的观点是随着地址增加,内存向上增长,就像前面的图表中一样)。

System V ABI 规定堆栈由堆栈组成。每个帧看起来像下面这样:

high |----------| 
     |   ....   |
     |----------| 
     |   arg8   | 
     |----------| 
     |   arg7   | 
     |----------| 
     | ret addr | 
     |----------| <- Stack pointer (rbp)
     |          |
 low |----------|

每个帧代表一个函数调用,并以超过前六个参数的任何参数开始调用函数(前六个参数作为寄存器传递,这将在后面更详细地讨论)。最后,返回地址被推送到堆栈中,然后调用函数。

返回地址后的内存属于函数本身范围内的变量。这就是为什么我们称在函数中定义的变量为基于堆栈的变量。剩下的堆栈将被未来将要调用的函数使用。每当一个函数调用另一个函数时,堆栈就会增长,而每当一个函数返回时,堆栈就会缩小。

操作系统的工作是管理堆栈的大小,确保它始终有足够的内存。例如,如果应用程序尝试使用太多内存,操作系统将终止该程序。

最后,应该指出,在大多数 CPU 架构上,提供了特殊指令,用于从函数调用返回并自动弹出堆栈的返回地址。在英特尔的情况下,call指令将跳转到一个函数并将当前的rip推送到堆栈作为返回地址,然后ret将从堆栈中弹出返回地址并跳转到被弹出的地址。

函数前言和尾声

每个函数都带有一个堆栈帧,如前所述,存储函数参数、函数变量和返回地址。管理这些资源的代码称为函数的前导(开始)和结尾(结束)。

为了更好地解释这一点,让我们创建一个简单的例子并检查生成的二进制文件:

int test()
{
    int i = 1;
    int j = 2;

    return i + j;
}

int main(void)
{
    test();
}

// > g++ scratchpad.cpp; ./a.out
// 

如果我们反汇编生成的二进制文件,我们得到以下结果:

...
00000000000005fa <_Z4testv>:
 push %rbp
 mov %rsp,%rbp
 movl $0x1,-0x8(%rbp)
 movl $0x2,-0x4(%rbp)
 mov -0x8(%rbp),%edx
 mov -0x4(%rbp),%eax
 add %edx,%eax
 pop %rbp
 retq
...

在我们的测试函数中,前两条指令是函数的前导。前导是推送当前堆栈帧(即前一个函数的堆栈帧),然后将当前堆栈指针设置为rbp,从而创建一个新的堆栈帧。

接下来的两条指令使用堆栈的未使用部分来创建变量ij。最后,将结果加载到寄存器中,并在rax中添加并返回结果(这是为英特尔定义的大多数 ABI 的返回寄存器)。

该函数的结尾是这个例子中的最后两条指令。具体来说,先前的堆栈帧的位置(在前导中推送到堆栈中)从堆栈中弹出并存储在rbp中,有效地切换到先前的堆栈帧,然后使用ret指令返回到先前的函数(就在函数调用之后)。

敏锐的眼睛可能已经注意到,通过移动rsp来为变量ij保留了堆栈上的空间。这是因为 System V ABI 的 64 位版本定义了所谓的红区。红区仅适用于叶函数(在我们的例子中,测试函数是一个叶函数,意味着它不调用任何其他函数)。

叶函数永远不会进一步增加堆栈,这意味着剩余的堆栈可以被函数使用,而无需推进堆栈指针,因为所有剩余的内存都是公平竞争的。

在系统编程时,如果在内核中编程,有时可能会出现问题。具体来说,如果中断触发(使用当前堆栈指针作为其堆栈),如果堆栈没有正确保留,可能会导致损坏,因此中断会破坏基于堆栈的叶函数的变量。

为了克服这一点,必须使用 GCC 的-mno-red-zone标志关闭红区。例如,如果我们使用这个标志编译前面的例子,我们得到以下二进制输出:

...
00000000000005fa <_Z4testv>:
 push %rbp
 mov %rsp,%rbp
 sub $0x10,%rsp
 movl $0x1,-0x8(%rbp)
 movl $0x2,-0x4(%rbp)
 mov -0x8(%rbp),%edx
 mov -0x4(%rbp),%eax
 add %edx,%eax
 leaveq
 retq
...

如所示,生成的二进制文件与原始文件非常相似。然而,有两个主要区别。第一个是sub指令,用于移动堆栈指针,从而保留堆栈空间,而不是使用红区。

第二个区别是使用leave指令。这条指令像前面的例子一样弹出rbp,但也恢复了堆栈指针,这个指针已经移动以为基于堆栈的变量腾出空间。在这个例子中,leaveret指令是新的结尾。

调用约定

调用约定规定了哪些寄存器是易失性的,哪些寄存器是非易失性的,哪些寄存器用于参数传递以及顺序,以及哪个寄存器用于返回函数的结果。

非易失性寄存器是在函数结束之前恢复到其原始值的寄存器(即在其结尾)。System V ABI 将rbxrbpr12r13r14r15定义为非易失性寄存器。相比之下,易失性寄存器是被调用函数可以随意更改的寄存器,无需在返回时恢复其值。

为了证明这一点,让我们看下面的例子:

0000000000000630 <__libc_csu_init>:
push %r15
push %r14
mov %rdx,%r15
push %r13
push %r12

如前面的例子所示,__libc_csu_init()函数(被libc用于初始化)会触及r12r13r14r15。因此,在执行初始化过程之前,它必须将这些寄存器的原始值推送到堆栈中。

此外,在这段代码的中间,编译器将rdx存储在r15中。稍后将会展示,编译器正在保留函数的第三个参数。仅仅根据这段代码,我们知道这个函数至少需要三个参数。

快速的谷歌搜索会显示这个函数有以下签名:

__libc_csu_init (int argc, char **argv, char **envp)

由于这个函数触及了非易失性寄存器,它必须在离开之前将这些寄存器恢复到它们的原始值。让我们看一下函数的尾声:

pop %rbx
pop %rbp
pop %r12
pop %r13
pop %r14
pop %r15
retq

如前所示,__libc_csu_init()函数在离开之前恢复所有非易失性寄存器。这意味着,在函数的中间某处,rbx也被破坏了(原始值先被推送到堆栈上)。

除了定义易失性和非易失性寄存器之外,System V 的调用约定还定义了用于传递函数参数的寄存器。具体来说,寄存器rdirsirdxrcxr8r9用于传递参数(按照提供的顺序)。

为了证明这一点,让我们看下面的例子:

int test(int val1, int val2)
{
    return val1 + val2;
}

int main(void)
{
    auto ret = test(42, 42);
}

// > g++ scratchpad.cpp; ./a.out
//

在前面的例子中,我们创建了一个接受两个参数,将它们相加并返回结果的测试函数。现在让我们看一下main()函数的生成二进制文件:

000000000000060e <main>:
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
mov $0x2a,%esi
mov $0x2a,%edi
callq 5fa <_Z4testii>
mov %eax,-0x4(%rbp)
mov $0x0,%eax
leaveq
retq

main()函数的第一件事是提供其前言(如前几章所述,main()函数不是第一个执行的函数,因此,就像任何其他函数一样,需要前言和尾声)。

然后,main()函数在堆栈上为test()函数的返回值保留空间,并在调用test()之前用传递给test()的参数填充esiedi

如前所述,call指令将返回地址推送到堆栈上,然后跳转到test()函数。test()函数的结果存储在堆栈上(如果启用了优化,这个操作将被移除),然后在返回之前将eax中放入0

正如我们所看到的,我们没有为main函数提供返回值。这是因为,如果没有提供返回值,编译器将自动为我们插入返回0,这就是我们在这段代码中看到的,因为rax是 System V 的返回寄存器。

现在让我们看一下测试函数的二进制文件:

00000000000005fa <_Z4testii>:
push %rbp
mov %rsp,%rbp
mov %edi,-0x4(%rbp)
mov %esi,-0x8(%rbp)
mov -0x4(%rbp),%edx
mov -0x8(%rbp),%eax
add %edx,%eax
pop %rbp
retq

test函数设置前言,然后将函数的参数存储在堆栈上(如果启用了优化,这个操作将被移除)。然后将堆栈变量放入易失性寄存器中(以防止它们需要被保存和恢复),然后将寄存器相加,并将结果存储在eax中。最后,函数通过尾声返回。

如前所述,System V 的返回寄存器是rax,这意味着每个返回值的函数都将使用rax来返回。要返回多个值,也可以使用rdx。例如,看下面的例子:

#include <cstdint>

struct mystruct
{
    uint64_t data1;
    uint64_t data2;
};

mystruct test()
{
    return {1, 2};
}

int main(void)
{
    auto ret = test();
}

// > g++ scratchpad.cpp; ./a.out
//

在前面的例子中,我们创建了一个返回包含两个 64 位整数的结构的test函数。我们选择了两个 64 位整数,因为如果我们使用常规的 int,编译器将尝试将结构的内容存储在一个 64 位寄存器中。

test()函数的生成二进制文件如下:

00000000000005fa <_Z4testv>:
push %rbp
mov %rsp,%rbp
mov $0x1,%eax
mov $0x2,%edx
pop %rbp
retq

如前所示,test函数在返回之前将结果存储在raxrdx中。如果返回的数据超过 128 位,那么main()函数和test()函数都会变得更加复杂。这是因为main()函数必须保留堆栈空间,然后test()函数必须利用这个堆栈空间来返回函数的结果。

这是如何工作的具体细节超出了本书的范围,但简而言之,为返回值保留的堆栈空间的地址实际上成为函数的第一个参数,所有这些都是由 System V ABI 定义的。

应该注意,示例大量使用以e为前缀而不是r的寄存器。这是因为e表示 32 位寄存器,而r表示 64 位寄存器。之所以这么多使用e版本,是因为我们利用基于整数的文字,如1242。这些都是int类型,根据 C 和 C++规范(如前几章所述),在 Intel 64 位 CPU 上默认是 32 位值。

异常处理和调试

C++异常提供了一种在调用堆栈的某个地方返回错误到catch处理程序的方法。我们将在第十三章中详细介绍 C++异常,异常处理

现在,我们将使用以下简单的例子:

#include <iostream>
#include <exception>

void test(int i)
{
    if (i == 42) {
        throw 42;
    }
}

int main(void)
{
    try {
        test(1);
        std::cout << "attempt #1: passed\n";

        test(21);
        std::cout << "attempt #2: passed\n";
    }
    catch(...) {
        std::cout << "exception catch\n";
    }
}

// > g++ scratchpad.cpp; ./a.out
// attempt #1: passed
// exception catch

在前面的例子中,我们创建了一个简单的test()函数,它接受一个输入。如果输入等于42,我们会抛出一个异常。这将导致函数返回(并且每个调用函数继续返回),直到遇到trycatch块。在块的try部分执行的任何代码都将在抛出异常时执行块的catch部分。

应该注意,被调用函数的返回值不被考虑或使用。这提供了一种在调用函数调用堆栈的任何点抛出错误,并在任何点捕获可能的错误的方法(最有可能在错误可以安全处理或程序可以安全中止时)。

如前面的例子所示,第一次尝试执行test()函数成功,并且将attempt #1: passed字符串输出到stdout。第二次尝试执行test()函数失败,因为函数抛出异常,结果,attempt #2: passed字符串不会输出到stdout,因为这段代码永远不会执行。相反,将执行catch块,该块处理错误(忽略它)。

异常处理(和调试)的细节非常困难(故意的双关语),因此本节的目标是解释 System V 规范如何规定与异常(和调试)支持相关的 ABI。

我在以下视频中提供了有关 C++异常内部工作原理的更多细节,该视频是在 CppCon 上录制的:www.youtube.com/watch?v=uQSQy-7lveQ

在本节结束时,以下内容应该是清楚的:

  • C++异常执行起来很昂贵,因此不应该用于控制流(仅用于错误处理)。

  • C++异常在可执行文件中占用大量空间,如果不使用,应该传递-fno-exceptions标志给 GCC,以减少生成代码的总体大小。这也意味着不应该使用可能引发异常的库设施。

为了支持前面的例子,堆栈必须被展开。也就是说,为了程序跳转到catch块,非易失性寄存器需要被设置,以使得test()函数看起来从未执行过。为了做到这一点,我们以某种方式以编译器提供的一组指令的方式,以相反的方式执行test()函数。

在我们深入了解这些信息之前,让我们先看一下与我们之前的例子相关的汇编代码:

0000000000000c11 <main>:
push %rbp
mov %rsp,%rbp
push %rbx
sub $0x8,%rsp
mov $0x1,%edi
callq b9a <test>
...
callq a30 <std::cout>
...
mov $0x0,%eax
jmp c90
...
callq 9f0 <__cxa_begin_catch@plt>
...
callq a70 <_Unwind_Resume@plt>
add $0x8,%rsp     
pop %rbx
pop %rbp
retq

为了保持易于理解,上述代码已经简化。让我们从头开始。这个函数的第一件事是设置函数前言(即堆栈帧),然后在堆栈上保留一些空间。完成这些操作后,代码将0x1移动到edi中,这将传递1test()函数。

接下来,调用test()函数。然后发生一些事情(细节不重要),然后调用std::cout(尝试将attempt #1: passed字符串输出到stdout)。这个过程对test(42)也是一样的。

接下来的代码是main()函数变得有趣的地方。mov $0x0,%eaxeax设置为0,正如我们所知,这是返回寄存器。这段代码设置了main()函数的返回值,但有趣的是,下一条指令相对跳转到main()函数中的c90,也就是add $0x8,%rsp代码。这是函数的结尾的开始,它清理堆栈并恢复非易失性寄存器。

中间的代码是我们的catch块。这是在抛出异常时执行的代码。如果没有抛出异常,就会执行jmp c90代码,跳过catch块。

test函数要简单得多:

0000000000000a6a <_Z4testi>:
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
mov %edi,-0x4(%rbp)
cmpl $0x2a,-0x4(%rbp)
jne a9f
mov $0x4,%edi
callq 8e0 <__cxa_allocate_exception@plt>
...
callq 930 <__cxa_throw@plt>
nop
leaveq
retq

test函数中,函数的前言被设置,堆栈空间被保留(如果启用了优化,这可能会被移除)。然后将输入与42进行比较,如果它们不相等(通过使用jne来表示),函数就会跳转到结尾并返回。如果它们相等,就会分配并抛出一个 C++异常。

这里需要注意的重要一点是,__cxa_throw()函数不会返回,这意味着函数的结尾部分永远不会被执行。原因是,当抛出异常时,程序员表示函数的剩余部分无法执行,而是需要__cxa_throw()跳转到调用堆栈中的catch块(在这种情况下是在main()函数中),或者如果找不到catch块,则终止程序。

由于函数的结尾从未被执行,非易失性寄存器需要以某种方式恢复到它们的原始状态。这就引出了 DWARF 规范和嵌入在应用程序中的.eh_frame表。

正如本章后面将要展示的,大多数基于 Unix 的应用程序都是编译成一种名为ELF的二进制格式。任何使用 C++异常支持编译的 ELF 应用程序都包含一个特殊的表,叫做.eh_frame表(这代表异常处理框架)。

例如,如果你在之前的应用程序上运行readelf,你会看到.eh_frame表,如下所示:

> readelf -SW a.out
There are 31 section headers, starting at offset 0x2d18:

Section Headers:
...
  [18] .eh_frame PROGBITS 0000000000000ca8 000ca8 000190 00 A 0 0 8
...

DWARF 规范(官方上没有特定的含义)提供了调试应用程序所需的所有信息。当 GCC 启用调试时,会向应用程序添加几个调试表,以帮助 GDB。

DWARF 规范也用于定义反转堆栈所需的指令;换句话说,以相对于非易失性寄存器的内容执行函数的反向操作。

让我们使用readelf来查看.eh_frame表的内容,如下所示:

> readelf --debug-dump=frames a.out
...
00000088 000000000000001c 0000005c FDE ...
  DW_CFA_advance_loc: 1 to 0000000000000a6b
  DW_CFA_def_cfa_offset: 16
  DW_CFA_offset: r6 (rbp) at cfa-16
  DW_CFA_advance_loc: 3 to 0000000000000a6e
  DW_CFA_def_cfa_register: r6 (rbp)
  DW_CFA_advance_loc: 51 to 0000000000000aa1
  DW_CFA_def_cfa: r7 (rsp) ofs 8
  DW_CFA_nop
  DW_CFA_nop
  DW_CFA_nop
...

关于这段代码的功能可以写一整本书,但这里的目标是保持简单。对于程序中的每个函数(对于代码量很大的程序可能有数十万个函数),.eh_frame中都提供了类似上面的一个块。

前面的代码块(通过使用objdump找到的地址匹配)是我们test()函数的Frame Description Entry(FDE)。这个 FDE 描述了如何使用 DWARF 指令反转堆栈,这些指令是为了尽可能小(以减小.eh_frame表的大小)而压缩的指令。

FDE 根据抛出的位置提供了堆栈反转指令。也就是说,当一个函数执行时,它会继续触及堆栈。如果一个函数中存在多个抛出,那么在每次抛出之间可能会触及更多的堆栈,这意味着需要更多的堆栈反转指令来正确地将堆栈恢复到正常状态。

一旦函数的堆栈被反转,调用堆栈中的下一个函数也需要被反转。这个过程会一直持续,直到找到一个catch块。问题在于.eh_frame表是这些 FDE 的列表,这意味着反转堆栈是一个O(N²)的操作。

已经进行了优化,包括使用哈希表,但仍然有两个事实是真实的:

  • 反转堆栈是一个缓慢的过程。

  • 使用 C++异常会占用大量空间。这是因为代码中定义的每个函数不仅必须包含该函数的代码,还必须包含一个 FDE,告诉代码如何在触发异常时展开堆栈。

虚拟内存布局

虚拟内存布局也是由 System V 规范提供的。在下一节中,我们将讨论 ELF 格式的细节,这将提供关于虚拟内存布局以及如何更改它的更多信息。

可执行和可链接格式(ELF)

可执行和可链接格式ELF)是大多数基于 Unix 的操作系统中使用的主要格式,包括 Linux。每个 ELF 文件以十六进制数0x7F开头,然后是ELF字符串。

例如,让我们看一下以下程序:

int main(void)
{
}

// > g++ scratchpad.cpp; ./a.out
//

如果我们查看生成的a.out ELF 文件的hexdump,我们会看到以下内容:

> hexdump -C a.out
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010 03 00 3e 00 01 00 00 00 f0 04 00 00 00 00 00 00 |..>.............|
00000020 40 00 00 00 00 00 00 00 e8 18 00 00 00 00 00 00 |@...............|
00000030 00 00 00 00 40 00 38 00 09 00 40 00 1c 00 1b 00 |....@.8...@.....|

如图所示,ELF字符串位于开头。

每个 ELF 文件都包含一个 ELF 头,描述了 ELF 文件本身的一些关键组件。以下命令可用于查看 ELF 文件的头部:

> readelf -hW a.out
ELF Header:
  Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class: ELF64
  Data: 2's complement, little endian
  Version: 1 (current)
  OS/ABI: UNIX - System V
  ABI Version: 0
  Type: DYN (Shared object file)
  Machine: Advanced Micro Devices X86-64
  Version: 0x1
  Entry point address: 0x4f0
  Start of program headers: 64 (bytes into file)
  Start of section headers: 6376 (bytes into file)
  Flags: 0x0
  Size of this header: 64 (bytes)
  Size of program headers: 56 (bytes)
  Number of program headers: 9
  Size of section headers: 64 (bytes)
  Number of section headers: 28
  Section header string table index: 27

如图所示,我们编译的 ELF 文件链接到了一个符合 Unix System V ABI for Intel 64-bit 的 ELF-64 文件。在头部的底部附近,您可能会注意到程序头和部分头的提及。

每个 ELF 文件都可以从其段或其部分的角度来查看。为了可视化这一点,让我们从两个角度来看一个 ELF 文件,如下所示:

   Segments       Sections
|------------| |------------|
|   Header   | |   Header   |
|------------| |------------|
|            | |            |
|            | |------------|
|            | |            |
|            | |            |
|------------| |------------|
|            | |            |
|            | |------------|
|            | |            |
|------------| |------------|

如前所示,每个 ELF 文件由部分组成。然后将这些部分分组成段,用于定义需要加载哪些部分,以及如何加载(例如,一些部分需要以读写方式加载,其他部分需要以读取-执行方式加载,或者在一些次优化的情况下,以读写-执行方式加载)。

ELF 部分

要查看所有部分的列表,请使用以下命令:

> readelf -SW a.out

这将导致以下输出:

如图所示,即使在一个简单的例子中,也有几个部分。其中一些部分包含了在前几章中已经讨论过的信息:

  • eh_frame/.eh_frame_hdr:这些包含了在处理异常时反转堆栈的 FDE 信息,正如刚才讨论的。eh_frame_hdr部分包含了用于改进 C++异常性能的其他信息,包括一个哈希表,用于定位 FDE,而不是循环遍历 FDE 列表(否则将是一个O(n²)操作)。

  • .init_array/.fini_array/.init/.fini:这些包含了代码执行的构造函数和析构函数,包括任何链接到您的代码的库(如前所述,在底层可能链接到您的应用程序的库很多)。还应该注意,这些部分包含能够执行运行时重定位的代码,必须在任何应用程序的开头执行,以确保代码被正确链接和重定位。

  • .dynsym:这包含了用于动态链接的所有符号。如前所述,如果使用 GCC,这些符号将全部包含 C 运行时链接名称,而如果使用 G++,它们还将包含有缠结的名称。我们将很快更详细地探讨这一部分。

readelf的部分输出中可以学到很多东西。例如,所有地址都以0开头,而不是一些更高内存中的地址。这意味着应用程序在链接期间使用了-pie标志进行编译,这意味着应用程序是可重定位的。具体来说,位置无关可执行文件PIE)(因此 ELF 文件)包含了.plt.got部分,用于在内存中重定位可执行文件。

这也可以从.rela.xxx部分的包含中看出,其中包含了 ELF 加载器用于在内存中重新定位可执行文件的实际重定位命令。为了证明这个应用程序是使用-pie标志编译的,让我们看看应用程序的编译标志:

> g++ scratchpad.cpp -v
...
/usr/lib/gcc/x86_64-linux-gnu/7/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/7/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/7/lto-wrapper -plugin-opt=-fresolution=/tmp/ccmBVeIh.res -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lgcc --sysroot=/ --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/7 -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/7/../../.. /tmp/ccZU6K8e.o -lstdc++ -lm -lgcc_s -lgcc -lc -lgcc_s -lgcc /usr/lib/gcc/x86_64-linux-gnu/7/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crtn.o
...

如前所示,提供了-pie标志。

还要注意的一点是,部分从地址0开始并继续,但是在某个时候,地址跳到0x200000并从那里继续。这意味着应用程序是 2MB 对齐的,这对于 64 位应用程序来说是典型的,因为它们有更大的地址空间可供使用。

正如将要展示的,跳转到0x200000的点是 ELF 文件中一个新程序段的开始,并且表示正在加载的部分权限的改变。

还有一些值得指出的显著部分:

  • .text:这包含了与程序相关的大部分(如果不是全部)代码。这个部分通常位于标记为读取-执行的段中,并且理想情况下不具有写权限。

  • .data:这包含了初始化为非0值的全局变量。如前所示,这个部分存在于 ELF 文件本身中,因此这些类型的变量应该谨慎使用,因为它们会增加生成的 ELF 文件的大小(这会减少应用程序的加载时间,并在磁盘上占用额外的空间)。还应该注意,一些编译器会将未初始化的变量放在这个部分中,所以如果一个变量应该是0,就要初始化为0

  • .bss:这个部分包含所有应该初始化为0的全局变量(假设使用 C 和 C++)。这个部分总是最后一个被加载的部分(也就是说,它是由段标记的最后一个部分),并且实际上并不存在于 ELF 文件本身。相反,当一个 ELF 文件被加载到内存中时,ELF 加载器(或 C 运行时)会扩展 ELF 文件的大小以包括这个部分的总大小,并且额外的内存会被初始化为0

  • .dynstr/.strtab:这些表包含用于符号名称(即变量和函数名称)的字符串。.dynstr表包含在动态链接期间需要的所有字符串,而.strtab部分包含程序中的所有符号。关键点在于这些字符串出现了两次。在变量或函数前使用static可以防止变量的符号出现在.dynsym部分中,这意呈现它不会出现在.dynstr部分中。这样做的缺点是,变量在动态链接期间无法被看到,这意味着如果另一个库尝试在该变量上使用extern,它将失败。默认情况下,所有变量和函数都应该被标记为static,除非你打算让它们在外部可访问,这样可以减少磁盘和内存上文件的总大小。这也加快了链接时间,因为它减少了.dynsym部分的大小,该部分用于动态链接。

为了进一步研究字符串在 ELF 文件中的存储方式,让我们创建一个简单的示例,其中包含一个易于查找的字符串。

#include <iostream>

int main(void)
{
    std::cout << "The answer is: 42\n";
}

// > g++ scratchpad.cpp; ./a.out
// The answer is: 42

如前所示,这个示例将The answer is: 42输出到stdout

现在让我们在 ELF 文件本身中查找这个字符串,使用以下内容:

> hexdump -C a.out | grep "The" -B1 -A1
000008f0 f3 c3 00 00 48 83 ec 08 48 83 c4 08 c3 00 00 00 |....H...H.......|
00000900 01 00 02 00 00 54 68 65 20 61 6e 73 77 65 72 20 |.....The answer |
00000910 69 73 3a 20 34 32 0a 00 01 1b 03 3b 4c 00 00 00 |is: 42.....;L...|

如前所示,字符串存在于我们的程序中,并位于0x905。现在让我们看看这个应用程序的 ELF 部分:

如果我们查看部分内的地址,我们可以看到字符串存在于一个名为.rodata的部分中,其中包含常量数据。

现在让我们使用objdump查看这个应用程序的汇编,它会反汇编.text部分中的代码,如下所示:

如前所示,代码在调用 std::cout 之前将 rsi 加载为字符串的地址(在 0x905 处),这是第二个参数。需要注意的是,与之前一样,这个应用是使用 -pie 命令编译的,这意味着应用本身将被重定位。这最终意味着字符串的地址不会在 0x905 处,而是在 # + 0x905 处。

为了避免需要重定位条目(即全局偏移表(GOT)中的条目),程序使用了指令指针相对偏移。在这种情况下,加载 rsi 的指令位于 0x805 处,使用了偏移量 0x100,从而返回 0x905 + rip。这意味着无论应用程序在内存中的哪个位置,代码都可以找到字符串,而无需需要重定位条目。

ELF 段

正如之前所述,ELF 段将各个部分分组为可加载的组件,并描述了如何在内存中加载 ELF 文件的位置和方式。理想的 ELF 加载器只需要读取 ELF 段来加载 ELF 文件,并且(对于可重定位的 ELF 文件)还需要加载动态部分和重定位部分。

要查看 ELF 的段,请使用以下代码:

如前所示,简单示例有几个程序段。第一个段描述了程序头(定义了段),在很大程度上可以忽略。

第二个段告诉 ELF 加载器它期望使用哪个重定位器。具体来说,这个段中描述的程序用于延迟重定位。当程序动态链接时,GOT 和过程链接表(PLT)中的符号包含每个符号在内存中的实际地址,代码引用这个表中的条目,而不是直接引用一个符号。

这是必要的,因为编译器无法知道另一个库中符号的位置,因此 ELF 加载器通过加载其他库中存在的符号的 GOT 和 PLT 来填充每个符号的位置(或者没有标记为静态的符号)。

问题在于一个大型程序可能有数百甚至数千个这些 GOT 或 PLT 条目,因此加载一个程序可能需要很长时间。更糟糕的是,许多外部库的符号可能永远不会被调用,这意味着 ELF 加载器需要填充一个不需要的符号位置的 GOT 或 PLT 条目。

为了克服这些问题,ELF 加载器将 GOT 和 PLT 加载为懒加载器的位置,而不是符号本身的位置。懒加载器(就是你在第二个段中看到的程序)在第一次使用符号时加载符号的位置,从而减少程序加载时间。

第三个段标记为 LOAD,告诉 ELF 加载器将 ELF 文件的下一部分加载到内存中。如前面的输出所示,这个段包含几个部分,所有这些部分都标记为读取-执行。例如,.text 部分存在于这个段中。

ELF 加载器所需做的就是按照段标记的指示将 ELF 文件的部分加载到提供的虚拟地址(以及提供的内存大小)中。

第四个段与第三个相同,但标记的不是读取-执行部分,而是标记的读取-写入部分,包括 .data 等部分。

需要注意的是,加载第四个段的内存偏移增加了 0x200000。正如之前所述,这是因为程序是 2 MB 对齐的。更具体地说,英特尔 64 位 CPU 支持 4 KB、2 MB 和 1 GB 页面。

由于第一个可加载段标记为读-执行,第二个可加载段不能在同一页上(否则,它也必须标记为读-执行)。因此,第二个可加载段被设计为从下一个可用页面开始,这种情况下是内存中的 2MB。这允许操作系统将第一个可加载段标记为读-执行,第二个可加载段标记为读-写,并且 CPU 可以强制执行这些权限。

下一部分定义了动态部分的位置,ELF 加载器用于执行动态重定位。这是必要的,因为可执行文件是使用-pie编译的。需要注意的是,ELF 加载器可以扫描 ELF 部分以找到这些数据,但程序段的目标是定义加载 ELF 文件所需的所有信息,而无需扫描部分。不幸的是,在实践中,这并不总是正确的,但理想情况下应该是这样。

notes部分可以安全地忽略。以下部分为 ELF 加载器提供了异常信息的位置(如描述);可执行文件期望的堆栈权限,理想情况下应该始终是读写而不是读写执行;以及只读部分的位置,加载后可以更改其权限为只读。

Unix 文件系统

Unix 文件系统被大多数基于 Unix 的操作系统使用,包括 Linux,它由一个虚拟文件系统树组成,这是用户和应用程序的前端。树从根目录(即/)开始,所有文件、设备和其他资源都位于这个单一的根目录中。

从那里,物理文件系统通常映射到虚拟文件系统,提供了一种存储和检索文件的机制。需要注意的是,这个物理文件系统不一定是一个磁盘;它也可以是 RAM 或其他类型的存储设备。

为了执行这种映射,操作系统有一个机制指示 OS 执行这种映射。在 Linux 上,这是通过/etc/fstab完成的,如下所示:

> cat /etc/fstab
UUID=... / ext4 ...
UUID=... /boot/efi vfat ...

如本例所示,根文件系统映射到一个特定的物理设备(用 UUID 表示),其中包含一个ext4文件系统。此外,在这个根文件系统中,另一个物理分区映射到/boot/efi,包含一个 VFAT 文件系统。

这意味着对虚拟文件系统的所有访问都默认为ext4分区,而对/boot/efi下的任何内容的访问都会被重定向到一个包含特定于 UEFI 的文件的单独的 VFAT 分区(这是用于编写本书的文本框中使用的特定 BIOS)。

虚拟文件系统中的任何节点都可以重新映射到任何设备或资源。这种设计的精妙之处在于,应用程序不需要关心虚拟文件系统当前映射的设备类型,只要应用程序对它正在尝试访问的文件系统部分有权限,并且有能力打开文件并读写它。

例如,让我们看看以下内容:

> ls /dev/null
/dev/null

在大多数基于 Linux 的系统上,存在一个名为/dev/null的文件。这个文件实际上并不映射到一个真实的文件。相反,虚拟文件系统将这个文件映射到一个忽略所有写入并在读取时返回空的设备驱动程序。例如,参见以下内容:

> echo "Hello World" > /dev/null
> hexdump -n16 /dev/null
<nothing>

大多数基于 Linux 的系统还提供了/dev/zero,当读取时返回所有的零,如下所示:

> hexdump -n16 /dev/zero
0000000 0000 0000 0000 0000 0000 0000 0000 0000
0000010

还有/dev/random,当读取时返回一个随机数,如下所示:

> hexdump -n16 /dev/random
0000000 3ed9 25c2 ad88 bf62 d3b3 0f72 b32a 32b3
0000010

如前所述,在第二章中,学习 C、C++17 和 POSIX 标准,POSIX 定义的文件系统布局如下:

  • /bin:用于所有用户使用的二进制文件

  • /boot:用于引导操作系统所需的文件

  • /dev:用于物理和虚拟设备

  • /etc:操作系统需要的配置文件

  • /home:用于特定用户文件

  • /lib:用于可执行文件所需的库

  • /mnt/media:用作临时挂载点

  • /sbin:用于系统特定的二进制文件

  • /tmp:用于在重启时删除的文件

  • /usr:用于前述文件夹的特定用户版本

通常,/boot下的文件指向与根分区不同的物理分区,/dev文件夹包含映射到设备的文件(而不是存储和检索在磁盘上的文件),/mnt/media用于挂载临时设备,如 USB 存储设备和 CD-ROM。

在一些系统上,/home可能被映射到一个完全独立的硬盘驱动器,允许用户完全格式化和重新安装根文件系统(即重新安装操作系统),而不会丢失任何个人文件或配置。

Unix 文件系统还维护了一整套权限,定义了谁被允许读取、写入和执行文件。参见以下示例:

> ls -al
total 40
drwxrwxr-x 3  user user ... .
drwxrwxr-x 16 user user ... ..
-rwxrwxr-x 1  user user ... a.out
drwxrwxr-x 3  user user ... build
-rw-rw-r-- 1  user user ... CMakeLists.txt
-rw-rw-r-- 1  user user ... scratchpad.cpp

文件系统定义了文件所有者、文件组和其他人(既不是所有者也不是文件组成员)的权限。

前面示例中的第一列定义了文件的权限。d定义了节点是目录还是文件。前三个字符定义了文件所有者的读/写/执行权限,第二个定义了文件组的权限,最后一个定义了其他用户的权限。

前面示例中的第三列定义了所有者的名称,而第二列定义了组的名称(在大多数情况下也是所有者)。

使用这种权限模型,Unix 文件系统可以控制任何给定用户、一组用户和其他所有人对任何文件或目录的访问。

Unix 进程

Unix 系统上的进程是由操作系统执行和调度的用户空间应用程序。在本书中,我们将进程和用户空间应用程序互换使用。

正如将要展示的,大多数在任何给定时间运行的基于 Unix 的进程都是某些其他父进程的子进程,每个内核在底层实现进程的方式可能不同,但所有 Unix 操作系统都提供了相同的基本命令来创建和管理进程。

在本节中,我们将讨论如何使用常见的 POSIX 接口创建和管理基于 Unix 的进程。

fork()函数

在基于 Unix 的系统上,fork()函数用于创建进程。fork()函数是操作系统提供的一个相对简单的系统调用,它接受当前进程,并创建进程的一个重复子版本。父进程和子进程的一切都是相同的,包括打开的文件句柄、内存等,唯一的区别是子进程有一个新的进程 ID。

在第十二章中,《学习编程 POSIX 和 C++线程》,我们将讨论线程(在系统编程中比进程更常用)。线程和进程都由操作系统调度;线程和进程的主要区别在于子进程和父进程无法访问彼此的内存,而线程可以。

即使fork()创建了一个具有相同资源和内存布局的新进程,父进程和子进程之间共享的内存被标记为写时复制。这意味着,当父进程和子进程执行时,任何尝试写入可能已经共享的内存的操作会导致子进程创建自己的内存副本,只有它自己可以写入。结果是,父进程无法看到子进程对内存所做的修改。

对于线程来说并不是这样,因为线程保持相同的内存布局,不会被标记为写时复制。因此,一个线程能够看到另一个线程(或父进程)对内存所做的更改。

让我们看下面的例子:

#include <unistd.h>
#include <iostream>

int main(void)
{
    fork();
    std::cout << "Hello World\n";
}

// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World

在这个例子中,我们使用fork()系统调用创建一个重复的进程。重复的子进程使用std::coutstdout输出Hello World。如示例所示,这个例子的结果是Hello World被输出两次。

fork()系统调用在父进程中返回子进程的进程 ID,在子进程中返回0。如果发生错误,将返回-1并将errno设置为适当的错误代码。看下面的例子:

#include <unistd.h>
#include <iostream>

int main(void)
{
    if (fork() != 0) {
        std::cout << "Hello\n";
    }
    else {
        std::cout << "World\n";
    }
}

// > g++ scratchpad.cpp; ./a.out
// Hello
// World

在这个例子中,父进程输出Hello,而子进程输出World

为了检查父进程和子进程之间如何处理共享内存,让我们看下面的例子:

#include <unistd.h>
#include <iostream>

int data = 0;

int main(void)
{
    if (fork() != 0)
    {
        data = 42;
    }

    std::cout << "The answer is: " << data << '\n';
}

// > g++ scratchpad.cpp; ./a.out
// The answer is: 42
// The answer is: 0

在这个例子中,我们为父进程和子进程输出The answer is:字符串。两个进程都可以访问一个名为data的全局变量,它被初始化为0。不同之处在于父进程将data变量设置为42,而子进程没有。

父进程在操作系统调度子进程之前完成了它的工作,因此The answer is: 42首先被输出到stdout

一旦子进程有机会执行,它也输出这个字符串,但答案是0而不是42。这是因为对于子进程来说,数据变量从未被设置过。父进程和子进程都可以访问自己的内存(至少是写入的内存),因此42是在父进程的内存中设置的,而不是子进程的。

在大多数基于 Unix 的操作系统中,第一个执行的进程是init,它使用fork()启动系统上的其他进程。这意味着init进程是用户空间应用程序的根级父进程(有时被称为祖父)。因此,fork()系统调用可以用来创建复杂的进程树。

看下面的例子:

#include <unistd.h>
#include <iostream>

int main(void)
{
    fork();
    fork();
    std::cout << "Hello World\n";
}

// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World
// Hello World
// Hello World

在前面的例子中,我们两次执行fork()系统调用,生成了三个额外的进程。为了理解为什么会创建三个进程而不是两个,让我们对例子进行简单修改,以突出所创建的树结构,如下所示:

#include <unistd.h>
#include <iostream>

int main(void)
{
    auto id1 = fork();
    std::cout << "id1: " << id1 << '\n';

    auto id2 = fork();
    std::cout << "id2: " << id2 << '\n';
    std::cout << "-----------\n";
}

// > g++ scratchpad.cpp; ./a.out
// id1: 14181
// id2: 14182
// -----------
// id1: 0
// id2: 14183
// -----------
// id2: 0
// -----------
// id2: 0
// -----------

在这个例子中,我们像之前一样两次执行fork(),唯一的区别是我们输出每个创建的进程的 ID。父进程执行fork(),输出 ID,再次执行fork(),然后再次输出 ID 后执行。

由于 ID 不是0(实际上是1418114182),我们知道这是父进程,并且如预期的那样,它创建了两个子进程。接下来显示的 ID 是014183。这是第一个子进程(14181),它出现在父进程第一次调用fork()时。

然后这个子进程继续创建它自己的子进程(ID 为14183)。当第二次执行fork()时,父进程和子进程分别创建了一个额外的进程(1418214183),它们都为id2输出了0。这解释了最后两个输出。

需要注意的是,这个例子可能需要执行多次才能得到一个干净的结果,因为每个额外的子进程增加了一个子进程与其他子进程同时执行的机会,从而破坏了输出。由于进程不共享内存,在这样的例子中实现同步输出的方法并不简单。

使用fork()创建n² 个进程,其中n是调用fork()的总次数。例如,如果fork()被调用三次而不是两次,就像在简化的前面的例子中一样,我们期望Hello World输出的次数是八次而不是四次,如下所示:

#include <unistd.h>
#include <iostream>

int main(void)
{
    fork();
    fork();
    fork();
    std::cout << "Hello World\n";
}

// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World
// Hello World
// Hello World
// Hello World
// Hello World
// Hello World
// Hello World

除了显示的进程呈指数增长外,一些进程可能选择创建子进程,而另一些可能不会,导致复杂的进程树结构。

请看以下示例:

#include <unistd.h>
#include <iostream>

int main(void)
{
    if (fork() != 0) {
        std::cout << "The\n";
    }
    else {
        if (fork() != 0) {
            std::cout << "answer\n";
        }
        else {
            if (fork() != 0) {
                std::cout << "is\n";
            }
            else {
                std::cout << 42 << '\n';
            }
        }
    }
}

// > g++ scratchpad.cpp; ./a.out
// The
// answer
// is
// 42

在这个例子中,父进程创建子进程,而每个子进程都不做任何事情。这导致The answer is 42字符串仅由父进程输出到stdout

wait()函数

正如所述,操作系统以操作系统选择的任何顺序执行每个进程。因此,父进程在子进程完成之前可能会完成执行。在某些操作系统上,这可能会导致损坏,因为某些操作系统要求父进程在子进程成功完成之前保持活动状态。

为了处理这个问题,POSIX 提供了wait()函数:

#include <unistd.h>
#include <iostream>
#include <sys/wait.h>

int main(void)
{
    if (fork() != 0) {
        std::cout << "parent\n";
        wait(nullptr);
    }
    else {
        std::cout << "child\n";
    }
}

// > g++ scratchpad.cpp; ./a.out
// parent
// child

在这个例子中,我们创建了一个子进程,将child输出到stdout。与此同时,父进程将parent输出到stdout,然后执行wait()函数,告诉父进程等待子进程完成执行。

我们将nullptr传递给wait()函数,因为这告诉wait()函数我们对错误代码不感兴趣。

wait()函数等待任何子进程完成。它不等待特定子进程完成。因此,如果创建了多个子进程,必须多次执行wait()

请看以下示例:

#include <unistd.h>
#include <iostream>
#include <sys/wait.h>

int main(void)
{
    int id;

    auto id1 = fork();
    auto id2 = fork();
    auto id3 = fork();

    while(1)
    {
        id = wait(nullptr);

        if (id == -1)
            break;

        if (id == id1)
            std::cout << "child #1 finished\n";

        if (id == id2)
            std::cout << "child #2 finished\n";

        if (id == id3)
            std::cout << "child #3 finished\n";
    }

    if (id1 != 0 && id2 != 0 && id3 != 0)
        std::cout << "parent done\n";
}

// > g++ scratchpad.cpp; ./a.out
// child #3 finished
// child #3 finished
// child #3 finished
// child #3 finished
// child #2 finished
// child #2 finished
// child #1 finished
// parent done

在上面的示例中,我们创建了八个子进程。如前所述,创建的进程总数是 2^(调用fork的次数)。然而,在这个例子中,我们希望确保祖父进程,也就是根父进程,是最后一个完成执行的进程。

请记住,当我们像这样调用fork()时,第一次调用创建第一个子进程。第二次调用fork()创建另一个子进程,但第一个子进程现在成为父进程,因为它调用fork()。当我们第三次调用fork()时,同样的事情发生了(甚至更多)。祖父进程是根父进程。

无论哪个进程是祖父进程,我们都希望确保所有子进程在其父进程之前完成。为了实现这一点,我们记录每次执行fork()时的进程 ID。对于子进程,此 ID 设置为0

接下来,我们进入一个while(1)循环,然后调用wait()wait()函数将在子进程完成时退出。进程完成后,我们输出退出到stdout的子进程。如果我们从wait()获取的进程 ID 是-1,我们知道没有更多的子进程存在,我们可以退出while(1)循环。

最后,如果没有一个进程 ID 等于0,我们知道该进程是祖父,我们输出它何时退出,只是为了显示它是最后一个退出的进程。

由于wait()函数不会返回0,我们知道当子进程退出时,我们只会在我们的while(1)循环中输出退出的子进程。如所示,我们看到一个带有id1的子进程退出,两个带有id2的子进程退出,以及四个带有id3的子进程退出。这与我们之前执行的数学计算一致。

还应该注意,这个例子确保所有子进程在父进程之前完成。这意味着祖父必须等待其子进程完成。由于祖父的子进程也创建自己的进程,因此祖父必须首先等待父进程完成,而父进程必须依次等待其子进程完成。

这导致了子进程在其父进程之前完成的级联效应,一直到最终完成祖父进程。

最后,还应该注意,尽管父进程必须等待子进程完成,但这并不意味着所有带有id3的子进程将在带有id2的子进程之前退出。这是因为子树的一半可能在另一半完成之前或以任何顺序完成而没有问题。因此,可能会得到这样的输出:

child #3 finished
child #3 finished
child #3 finished
child #2 finished
child #2 finished
child #3 finished
child #1 finished
parent done

在这个例子中,最后完成的child #3是由祖父进程最后一次调用fork()创建的进程。

进程间通信(IPC)

在我们之前的一个例子中,我们演示了如何使用fork()从父进程创建一个子进程,如下所示:

#include <unistd.h>
#include <iostream>
#include <sys/wait.h>

int main(void)
{
    if (fork() != 0) {
        std::cout << "parent\n";
        wait(nullptr);
    }
    else {
        std::cout << "child\n";
    }
}

// > g++ scratchpad.cpp; ./a.out
// parent
// child

在这个例子中,我们看到parentchild之前输出仅仅是因为操作系统启动子进程的时间比从子进程输出的时间长。如果父进程需要更长的时间,child将首先输出。

请参见以下示例:

#include <unistd.h>
#include <iostream>
#include <sys/wait.h>

int main(void)
{
    if (fork() != 0) {
        sleep(1);
        std::cout << "parent\n";
        wait(nullptr);
    }
    else {
        std::cout << "child\n";
    }
}

// > g++ scratchpad.cpp; ./a.out
// child
// parent

这与之前的例子相同,唯一的区别是在父进程中添加了一个sleep()命令,告诉操作系统暂停父进程的执行一秒钟。结果,子进程有足够的时间来执行,导致child首先输出。

为了防止子进程先执行,我们需要在父进程和子进程之间建立一个通信通道,以便子进程知道在父进程完成向stdout输出之前等待。这被称为同步

有关同步、如何处理同步以及同步引起的死锁和竞争条件等问题的更多信息,请参见本章的进一步阅读部分。

在本节中,我们将用来同步父进程和子进程的机制称为进程间通信IPC)。在继续之前,应该注意到,创建多个进程并使用 IPC 来同步它们是在操作系统上创建和协调多个任务的一种笨重的方式。除非绝对需要单独的进程,更好的方法是使用线程,这是我们在第十二章中详细介绍的一个主题,学习编程 POSIX 和 C++线程

在 Unix 系统中有几种不同类型的 IPC 可以利用。在这里,我们将介绍两种最流行的方法:

  • Unix 管道

  • Unix 共享内存

Unix 管道

管道是一种从一个进程向另一个进程发送信息的机制。在其最简单的形式中,管道是一个文件(在 RAM 中),一个进程可以向其写入,另一个进程可以从中读取。文件最初为空,直到有字节写入它,才能从管道中读取字节。

让我们看下面的例子:

#include <string.h>
#include <unistd.h>
#include <sys/wait.h>

#include <array>
#include <iostream>
#include <string_view>

class mypipe
{
    std::array<int, 2> m_handles;

public:
    mypipe()
    {
        if (pipe(m_handles.data()) < 0) {
            exit(1);
        }
    }

    ~mypipe()
    {
        close(m_handles.at(0));
        close(m_handles.at(1));
    }

    std::string
    read()
    {
        std::array<char, 256> buf;
        std::size_t bytes = ::read(m_handles.at(0), buf.data(), buf.size());

        if (bytes > 0) {
            return {buf.data(), bytes};
        }

        return {};
    }

    void
    write(const std::string &msg)
    {
        ::write(m_handles.at(1), msg.data(), msg.size());
    }
};

int main(void)
{
    mypipe p;

    if (fork() != 0) {
        sleep(1);
        std::cout << "parent\n";

        p.write("done");
        wait(nullptr);
    }
    else {
        auto msg = p.read();

        std::cout << "child\n";
        std::cout << "msg: " << msg << '\n';
    }
}

// > g++ scratchpad.cpp -std=c++17; ./a.out
// parent
// child
// msg: done

这个例子与之前的例子类似,增加了一个 Unix 管道。这是为了确保即使父进程需要一段时间来执行,父进程在子进程执行之前输出到stdout。为了实现这一点,我们创建了一个类,利用资源获取即初始化RAII)来封装 Unix 管道,确保正确抽象 C API 的细节,并在mypipe类失去作用域时关闭支持 Unix 管道的句柄。

我们在课上做的第一件事是打开管道,如下所示:

mypipe()
{
    if (pipe(m_handles.data()) < 0) {
        exit(1);
    }
}

管道本身是两个文件句柄的数组。第一个句柄用于从管道中读取,而第二个句柄用于向管道中写入。如果发生错误,pipe()函数将返回-1

需要注意的是,如果pipe()函数成功,结果是两个文件句柄,当不再使用时应该关闭。为了支持这一点,我们在类的析构函数中关闭打开的文件句柄,这样当管道失去作用域时,管道就关闭了,如下所示:

~mypipe()
{
    close(m_handles.at(0));
    close(m_handles.at(1));
}

然后我们提供一个read()函数,如下所示:

std::string
read()
{
    std::array<char, 256> buf;
    std::size_t bytes = ::read(m_handles.at(0), buf.data(), buf.size());

    if (bytes > 0) {
        return {buf.data(), bytes};
    }

    return {};
}

read()函数创建一个可以读取的缓冲区,我们从管道中读取并将结果放入缓冲区。注意我们从第一个文件句柄中读取,如所述。

需要注意的是,我们在这里利用的read()write()函数将在第八章中详细介绍,学习文件输入/输出编程。现在,重要的是要注意,read()函数在这种情况下是一个阻塞函数,直到从管道中读取数据才会返回。如果发生错误(例如,管道关闭了),将返回-1

为了解决这个问题,我们只在从管道中读取实际字节时返回数据;否则,我们返回一个空字符串,用户可以用这个类来检测错误(或者我们可以使用 C++异常,如第十三章中所述的异常处理)。

最后,我们还添加了一个write()函数到管道,如下所示:

void
write(const std::string &msg)
{
    ::write(m_handles.at(1), msg.data(), msg.size());
}

write()函数更简单,使用write() Unix 函数写入管道的写端。

在父进程中我们做以下事情:

sleep(1);
std::cout << "parent\n";

p.write("done");
wait(nullptr);

首先,我们要做的是睡一秒钟,这样可以确保父进程需要很长时间才能执行。如果不使用同步,子进程会在父进程之前输出到stdout,这是由于使用了sleep()函数的结果。

接下来我们要做的是输出到stdout,然后将done消息写入管道。最后,我们等待子进程完成后再退出。

子进程做以下事情:

auto msg = p.read();

std::cout << "child\n";
std::cout << "msg: " << msg << '\n';

正如所述,read()函数是一个阻塞函数,这意味着它在从文件句柄中读取数据(或发生错误)之前不会返回。我们假设不会发生错误,并将结果字符串存储在一个名为msg的变量中。

由于read()函数是阻塞的,子进程会等待父进程输出到stdout,然后写入管道。无论父进程在写入管道之前做了什么,子进程都会等待。

一旦read()调用返回,我们输出到stdout child 和父进程发送的消息,然后退出。

通过这个简单的例子,我们能够从一个进程发送信息到另一个进程。在这种情况下,我们使用这种通信来同步父进程和子进程。

Unix 共享内存

Unix 共享内存是另一种流行的 IPC 形式。与 Unix 管道不同,Unix 共享内存提供了一个可以被两个进程读写的缓冲区。

让我们来看下面的例子:

#include <string.h>
#include <unistd.h>
#include <sys/shm.h>
#include <sys/wait.h>

#include <iostream>

char *
get_shared_memory()
{
    auto key = ftok("myfile", 42);
    auto shm = shmget(key, 0x1000, 0666 | IPC_CREAT);

    return static_cast<char *>(shmat(shm, nullptr, 0));
}

int main(void)
{
    if (fork() != 0) {
        sleep(1);
        std::cout << "parent\n";

        auto msg = get_shared_memory();
        msg[0] = 42;

        wait(nullptr);
    }
    else {
 auto msg = get_shared_memory();
 while(msg[0] != 42);

 std::cout << "child\n";
    }
}

// > g++ scratchpad.cpp; ./a.out
// parent
// child

在前面的例子中,我们创建了以下函数,负责在父进程和子进程之间打开共享内存:

char *
get_shared_memory()
{
    auto key = ftok("myfile", 42);
    auto shm = shmget(key, 0x1000, 0666 | IPC_CREAT);

    return static_cast<char *>(shmat(shm, nullptr, 0));
}

这个函数首先创建一个唯一的键,操作系统用它来关联父进程和子进程之间的共享内存。一旦生成了这个键,就使用shmget()来打开共享内存。

0x1000告诉shmget()我们想要打开 4KB 的内存,0666 | IPC_CREATE用于告诉shmget()我们想要以读写权限打开内存,并在不存在时创建共享内存文件。

shmget()的结果是一个句柄,可以被shmat()使用来返回指向共享内存的指针。

需要注意的是,一个更完整的例子会将这个共享内存封装在一个类中,这样 RAII 也可以被使用,并且利用 GSL 来正确保护两个进程之间共享的缓冲区。

在父进程中,我们做以下事情:

sleep(1);
std::cout << "parent\n";

auto msg = get_shared_memory();
msg[0] = 42;

wait(nullptr);

与前面的例子一样,父进程在输出到stdout之前睡眠一秒。接下来,父进程获取共享内存区域,并向缓冲区写入42。最后,父进程在退出之前等待子进程完成。

子进程执行以下操作:

auto msg = get_shared_memory();
while(msg[0] != 42);

std::cout << "child\n";

如前所示,子进程获取共享内存缓冲区,并等待缓冲区的值为42。一旦这样做了,也就是说父进程已经完成了对stdout的输出,子进程就会输出到stdout并退出。

exec()函数

直到这一点,我们创建的所有子进程都是父进程的副本,具有相同的代码和内存结构。虽然这是可以做到的,但这种情况不太可能发生,因为 POSIX 线程提供了相同的功能,而且没有共享内存和 IPC 的问题。POSIX 线程将在第十二章中更详细地讨论,学习编程 POSIX 和 C++线程

相反,更有可能的是对fork()的调用后面跟着对exec()的调用。exec()系统调用用全新的进程覆盖现有的进程。看下面的例子:

#include <unistd.h>
#include <iostream>

int main(void)
{
    execl("/bin/ls", "ls", nullptr);
    std::cout << "Hello World\n";
}

// > g++ scratchpad.cpp; ./a.out
// <output of ls>

在前面的例子中,我们调用了execl(),这是exec()系统调用系列的一个特定版本。execl()系统调用执行函数的第一个参数,并将剩余的参数作为argv[]传递给进程。最后一个参数总是必须是nullptr,就像argv[]中的最后一个参数总是nullptr一样。

exec()(和相关函数)的调用会用新执行的进程替换当前进程。因此,对stdout输出Hello World的调用不会被执行。这是因为这个调用是a.out程序的一部分,而不是ls程序的一部分,而且由于exec()用新的可执行文件替换了当前进程,输出就永远不会发生。

这就是为什么fork()exec()通常一起调用的原因。fork()的调用创建了一个新的进程,而exec()的调用将新的进程作为新的进程执行所需的程序。

这就是system()系统调用的工作原理:

#include <unistd.h>
#include <iostream>

int main(void)
{
    system("ls");
    std::cout << "Hello World\n";
}

// > g++ scratchpad.cpp; ./a.out
// <output of ls -al>
// Hello World

调用system()时,ls可执行文件被运行,system()函数会等待直到可执行文件完成。一旦完成,执行就会继续,对stdout输出Hello World的调用就会被执行。

这是因为system()调用会 fork 一个新的进程,并从新的进程中运行exec()。父进程运行wait(),并在子进程完成时返回。

为了演示这一点,我们可以制作我们自己的系统调用版本,如下所示:

#include <unistd.h>
#include <iostream>
#include <sys/wait.h>

void
mysystem(const char *command)
{
    if (fork() == 0) {
        execlp(command, command, nullptr);
    }
    else {
        wait(nullptr);
    }
}

int main(void)
{
    mysystem("ls");
    std::cout << "Hello World\n";
}

// > g++ scratchpad.cpp; ./a.out
// <output of ls>
// Hello World

mysystem()函数中,我们执行fork()来创建一个新的子进程,然后执行execlp()来执行ls。(对execlp()的调用将在后面解释。)

父进程调用wait(),并等待新创建的子进程完成。一旦完成,对mysystem()的调用就结束了,允许Hello World的输出执行。

应该注意的是,有一些改进可以使这个函数更完整。实际的system()函数会将参数传递给exec()调用,而我们的版本没有。

wait()调用不会检查已完成的子进程是否是被 fork 的进程。相反,wait()的调用应该循环,直到被 fork 的子进程实际完成。

为了向子进程传递参数,我们可以使用execl()进行以下操作:

#include <unistd.h>
#include <iostream>

int main(void)
{
    execl("/bin/ls", "ls", "-al", nullptr);
}

// > g++ scratchpad.cpp; ./a.out
// <output of ls -al>

在这个例子中,我们执行/bin/ls并将-al传递给进程。

第二个参数lsargv[0]相同,它总是进程的名称。就像argv[argc] == nullptr一样,我们的最后一个参数也是nullptr

如前所述,exec()有不同的版本。看下面的例子:

#include <unistd.h>
#include <iostream>

int main(void)
{
    const char *envp[] = {"ENV1=1", "ENV2=2", nullptr};
    execle("/bin/ls", "ls", nullptr, envp);
}

// > g++ scratchpad.cpp; ./a.out
// <output of ls>

execle()版本与execl()执行相同的操作,但还提供了传递环境变量的能力。在这种情况下,我们为ls提供了进程特定的环境变量ENV1ENV2

到目前为止,execl()函数已经采用了ls的绝对路径。可以使用PATH环境变量来定位可执行文件,而不是使用绝对路径,如下所示:

#include <unistd.h>
#include <iostream>

int main(void)
{
    execlp("ls", "ls", nullptr);
}

// > g++ scratchpad.cpp; ./a.out
// <output of ls>

execlp()调用使用PATH来定位ls,而不是使用绝对路径。

另外,exec()系列还提供了使用变量详细说明argv[]参数的能力,而不是直接作为exec()的函数参数,如下所示:

#include <unistd.h>
#include <iostream>

int main(void)
{
    const char *argv[] = {"ls", nullptr};
    execv("/bin/ls", const_cast<char **>(argv));
}

// > g++ scratchpad.cpp; ./a.out
// <output of ls>

如此所示,execv()调用允许你将argv[]定义为一个单独的变量。

execv()系列调用的一个问题是argv[]在技术上是指向 C 风格字符串的指针数组,在 C++中采用const char *的形式。然而,execv()和相关函数的调用需要char**而不是const char**,这意味着需要使用const_cast来转换参数。

execv()系列还提供了像execl()一样传递环境变量的能力,如下所示:

#include <unistd.h>
#include <iostream>

int main(void)
{
    const char *argv[] = {"ls", nullptr};
    const char *envp[] = {"ENV1=1", "ENV2=2", nullptr};

    execve(
        "/bin/ls",
        const_cast<char **>(argv),
        const_cast<char **>(envp)
    );
}

// > g++ scratchpad.cpp; ./a.out
// <output of ls>

在前面的例子中,我们使用execve()传递了argv[]参数和环境变量。

最后,也可以使用路径来定位可执行文件,而不是使用绝对值,如下所示:

\#include <unistd.h>
#include <iostream>

int main(void)
{
    const char *argv[] = {"ls", nullptr};
    execvp("ls", const_cast<char **>(argv));
}

// > g++ scratchpad.cpp; ./a.out
// <output of ls>

在这个例子中,PATH环境变量用于定位ls

输出重定向

在本章中,我们已经概述了编写自己的 shell 所需的所有系统调用。现在你可以创建自己的进程,加载任意可执行文件,并等待进程完成。

创建一个完整的 shell 还需要一些东西。其中之一是 Unix 信号,这将很快讨论;另一个是捕获子进程的输出。

为此,我们将利用 Unix 管道进行 IPC,并告诉子进程将其输出重定向到此管道,以便父进程可以接收它。

请参阅以下示例:

#include <string.h>
#include <unistd.h>
#include <sys/wait.h>

#include <array>
#include <iostream>
#include <string_view>

class mypipe
{
    std::array<int, 2> m_handles;

public:
    mypipe()
    {
        if (pipe(m_handles.data()) < 0) {
            exit(1);
        }
    }

    ~mypipe()
    {
        close(m_handles.at(0));
        close(m_handles.at(1));
    }

    std::string
    read()
    {
        std::array<char, 256> buf;
        std::size_t bytes = ::read(m_handles.at(0), buf.data(), buf.size());

        if (bytes > 0) {
            return {buf.data(), bytes};
        }

        return {};
    }

    void
    redirect()
    {
        dup2(m_handles.at(1), STDOUT_FILENO);
        close(m_handles.at(0));
        close(m_handles.at(1));
    }
};

int main(void)
{
    mypipe p;

    if(fork() == 0) {
        p.redirect();
        execlp("ls", "ls", nullptr);
    }
    else {
        wait(nullptr);
        std::cout << p.read() << '\n';
    }
}

// > g++ scratchpad.cpp; ./a.out
// <output of ls>

在前面的例子中,我们使用了与前一个例子中创建的相同的 Unix 管道类。然而,不同之处在于子进程不会写入 Unix 管道,而是输出到stdout。因此,我们需要将stdout的输出重定向到我们的 Unix 管道。

为此,我们将write()函数替换为redirect(),如下所示:

void
redirect()
{
    dup2(m_handles.at(1), STDOUT_FILENO);
    close(m_handles.at(0));
    close(m_handles.at(1));
}

在这个redirect()函数中,我们告诉操作系统将所有写入我们的管道的stdout的写入重定向(管道的写入端)。因此,当子进程写入stdout时,写入被重定向到父进程的读取端的管道。

因此,不再需要子进程的管道句柄(并且在执行子进程之前关闭)。

示例的其余部分与我们对自定义mysystem()调用的调用类似,如下所示:

if(fork() == 0) {
    p.redirect();
    execlp("ls", "ls", nullptr);
}
else {
    wait(nullptr);
    std::cout << p.read() << '\n';
}

创建了一个子进程。在执行ls命令之前,我们重定向了子进程的输出。父进程,就像mysystem()一样,等待子进程完成,然后读取管道的内容。

要创建自己的完整 shell,需要更多的功能,包括为stdoutstderr提供对子进程输出的异步访问的能力,执行前台和后台的进程,解析参数等。然而,这里提供了所需概念的大部分。

在下一节中,我们将讨论 Unix 信号的工作原理。

Unix 信号

Unix 信号提供了中断给定进程的能力,并允许子进程接收此中断并以任何希望的方式处理它。

具体来说,Unix 信号提供了处理特定类型的控制流和可能发生的错误的能力,比如终端试图关闭你的程序,或者可能是可恢复的分段错误。

请参阅以下示例:

#include <unistd.h>
#include <iostream>

int main(void)
{
    while(true) {
        std::cout << "Hello World\n";
        sleep(1);
    }
}

// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World
// Hello World
// ...
// ^C

在前面的例子中,我们创建了一个永远执行的进程,每秒输出Hello World。要停止这个应用程序,我们必须使用CTRL+C命令,这告诉 shell 终止进程。这是使用 Unix 信号完成的。

我们可以这样捕获这个信号:

#include <signal.h>
#include <unistd.h>
#include <iostream>

void handler(int sig)
{
    if (sig == SIGINT)
    {
        std::cout << "handler called\n";
    }
}

int main(void)
{
    signal(SIGINT, handler);

    for (auto i = 0; i < 10; i++)
    {
        std::cout << "Hello World\n";
        sleep(1);
    }
}

// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World
// ^Chandler called
// Hello World
// ^Chandler called
// Hello World
// ^Chandler called
// Hello World
// ^Chandler called
// Hello World
// Hello World
// Hello World
// Hello World
// Hello World

在这个例子中,我们创建了一个循环,每秒向stdout输出Hello World,并且这样做了 10 次。然后我们使用signal()函数安装了一个信号处理程序。这个信号处理程序告诉操作系统,我们希望在调用SIGINT时调用handler()函数。

因此,现在,如果我们使用CTRL+C,信号处理程序将被调用,我们会在stdout上看到handler called的输出。

应该注意的是,由于我们成功处理了SIGINT,使用CTRL+C不再会终止进程,这就是为什么我们使用for()循环而不是while(1)循环。您也可以使用CTRL+/来发送SIGSTOP,而不是SIGINT,这也会终止前面例子中的应用程序。

另一种克服这个问题的方法是使用一个能够停止循环的全局变量,如下所示:

#include <signal.h>
#include <unistd.h>
#include <iostream>

auto loop = true;

void handler(int sig)
{
    if (sig == SIGINT)
    {
        std::cout << "handler called\n";
        loop = false;
    }
}

int main(void)
{
    signal(SIGINT, handler);

    while(loop) {
        std::cout << "Hello World\n";
        sleep(1);
    }
}

// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World
// ^Chandler called

这个例子与我们之前的例子相同,只是我们使用了一个while()循环,该循环一直循环,直到loop变量为false为止。在我们的信号处理程序中,我们将loop变量设置为true,这会停止循环。这是因为信号处理程序不会在与while()循环相同的线程中执行。

这一点很重要,因为如果不解决这些问题,使用信号处理程序时可能会出现死锁、损坏和竞争条件。有关线程的更多信息,请参见第十二章,学习编程 POSIX 和 C++线程

最后,在我们结束之前,kill()函数可以用来向子进程发送信号,如下所示:

#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>

#include <iostream>

void
mysystem(const char *command)
{
    if(auto id = fork(); id > 0) {
        sleep(2);
        kill(id, SIGINT);
    }
    else {
        execlp(command, command, nullptr);
    }
}

int main(void)
{
    mysystem("b.out");
}

// > g++ scratchpad.cpp -std=c++17; ./a.out
// 

在这个例子中,我们再次创建了我们的mysystem()函数调用,但这次在父进程中,我们在两秒后杀死了子进程,而不是等待它完成。然后我们编译了我们的while(1)例子,并将其重命名为b.out

然后我们执行了子进程,它将永远执行,或者直到父进程发送kill命令。

总结

在本章中,我们全面介绍了 Linux(System V)ABI。我们讨论了寄存器和堆栈布局,System V 调用约定以及 ELF 规范。

然后,我们回顾了 Unix 文件系统,包括标准文件系统布局和权限。

接下来,我们将介绍如何处理 Unix 进程,包括常见函数,如fork()exec()wait(),以及 IPC。

最后,本章简要概述了基于 Unix 的信号以及如何处理它们。

在下一章中,我们将全面介绍使用 C++进行控制台输入和输出。

问题

  1. System V 架构(64 位)在 Intel 上的第一个返回寄存器是什么?

  2. System V 架构(64 位)在 Intel 上的第一个参数寄存器是什么?

  3. 在 Intel 上将数据推送到堆栈时,您是添加还是减去堆栈指针?

  4. ELF 中段和节之间有什么区别?

  5. 在 ELF 文件的.eh_frame部分中存储了什么?

  6. fork()exec()之间有什么区别?

  7. 在创建 Unix 管道时,写文件句柄是第一个还是第二个?

  8. wait()系统调用的返回值是什么?

进一步阅读

第六章:学习编程控制台输入/输出

控制台 IO 对于任何程序都是必不可少的。它可以用于获取用户输入,提供输出,并支持调试和诊断。程序不稳定的常见原因通常源于 IO 编写不佳,这只会加剧标准 C printf()/scanf() IO 函数的滥用。在本章中,我们将讨论使用 C++ IO 的利弊,通常称为基于流的 IO,与标准 C 风格的替代方法相比。此外,我们将提供一个关于 C++操作器的高级介绍,以及它们如何可以用来替代标准 C 风格的格式字符串。我们将以一组示例结束本章,旨在引导读者如何使用std::coutstd::cin

本章有以下目标:

  • 学习基于流的 IO

  • 用户定义的类型操作器

  • 回声的例子

  • 串行回声服务器示例

技术要求

为了编译和执行本章中的示例,读者必须具备:

  • 能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请参阅以下 GitHub 链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter06

学习基于流的 IO

在本节中,我们将学习基于流的 IO 的基础知识以及一些优缺点。

流的基础知识

与 C 风格的printf()scanf()函数不同,C++ IO 使用流(std::ostream用于输出,std::istream用于输入),利用<<>>操作符。例如,以下代码使用basic_ostream的非成员<<重载将Hello World输出到stdout

#include <iostream>

int main()
{
    std::cout << "Hello World\n";
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
Hello World

默认情况下,std::coutstd::wcout对象是std::ostream的实例,将数据输出到标准 C stdout,唯一的区别是std::wcout支持 Unicode,而std::cout支持 ASCII。除了几个非成员重载外,C++还提供了以下算术风格的成员重载:

basic_ostream &operator<<(short value);
basic_ostream &operator<<(unsigned short value);
basic_ostream &operator<<(int value);
basic_ostream &operator<<(unsigned int value);
basic_ostream &operator<<(long value);
basic_ostream &operator<<(unsigned long value);
basic_ostream &operator<<(long long value);
basic_ostream &operator<<(unsigned long long value);
basic_ostream &operator<<(float value);
basic_ostream &operator<<(double value);
basic_ostream &operator<<(long double value);
basic_ostream &operator<<(bool value);
basic_ostream &operator<<(const void* value);

这些重载可以用于将各种类型的数字流到stdoutstderr。考虑以下例子:

#include <iostream>

int main()
{
    std::cout << "The answer is: " << 42 << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 42

默认情况下,使用stdin进行输入,通过std::cinstd::wcin执行输入。与std::cout不同,std::cin使用>>流操作符,而不是<<流操作符。以下接受来自stdin的输入并将结果输出到stdout

#include <iostream>

int main()
{
    auto n = 0;

    std::cin >> n; 
    std::cout << "input: " << n << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
42 ↵
input: 42

C++基于流的 IO 的优缺点

使用 C++进行 IO 而不是标准 C 函数有许多优缺点。

C++基于流的 IO 的优点

通常情况下,C++流优于使用格式说明符的标准 C 函数,因为 C++流具有以下特点:

  • 能够处理用户定义的类型,提供更清晰、类型安全的 IO

  • 更安全,可以防止更多的意外缓冲区溢出漏洞,因为并非所有格式说明符错误都可以被编译器检测到或使用 C11 添加的_s C 函数变体来预防

  • 能够提供隐式内存管理,不需要可变参数函数

因此,C++核心指南不鼓励使用格式说明符,包括printf()scanf()等函数。尽管使用 C++流有许多优点,但也有一些缺点。

C++基于流的 IO 的缺点

关于 C++流的两个最常见的抱怨如下:

  • 标准 C 函数(特别是printf())通常优于 C++流(这在很大程度上取决于您的操作系统和 C++实现)

  • 格式说明符通常比#include <iomanip>更灵活

尽管这些通常是有效的抱怨,但有方法可以解决这些问题,而不必牺牲 C++流的优势,我们将在接下来的部分中解释。

从用户定义的类型开始

C++流提供了为用户定义的类型重载<<>>运算符的能力。这提供了为任何数据类型创建自定义、类型安全的 IO 的能力,包括系统级数据类型、结构,甚至更复杂的类型,如类。例如,以下提供了对<<流运算符的重载,以打印由 POSIX 风格函数提供的错误代码:

#include <fcntl.h>
#include <string.h>
#include <iostream>

class custom_errno
{ };

std::ostream &operator<<(std::ostream &os, const custom_errno &e)
{ return os << strerror(errno); }

int main()
{
    if (open("filename.txt", O_RDWR) == -1) {
        std::cout << custom_errno{} << '\n';
    }
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
No such file or directory

在这个例子中,我们创建了一个空类,为我们提供了一个自定义类型,并重载了这个自定义类型的<<运算符。然后我们使用strerror()来输出errno的错误字符串到提供的输出流。虽然可以通过直接将strerror()的结果输出到流中来实现这一点,但它演示了如何创建并使用流的用户定义类型。

除了更复杂的类型,用户定义的类型也可以通过输入流进行利用。考虑以下例子:

#include <iostream>

struct object_t
{
    int data1;
    int data2;
};

std::ostream &operator<<(std::ostream &os, const object_t &obj)
{
    os << "data1: " << obj.data1 << '\n';
    os << "data2: " << obj.data2 << '\n';
    return os;
}

std::istream &operator>>(std::istream &is, object_t &obj)
{
    is >> obj.data1;
    is >> obj.data2;
    return is;
}

int main()
{
    object_t obj;

    std::cin >> obj;
    std::cout << obj;
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
42 ↵
43 ↵
data1: 42
data2: 43

在这个例子中,我们创建了一个存储两个整数的结构。然后我们为这个用户定义的类型重载了<<>>运算符,通过读取数据到我们类型的实例来练习这些重载,然后输出结果。通过我们的重载,我们已经指示了std::cinstd::cout如何处理我们用户定义的类型的输入和输出。

安全和隐式内存管理

尽管 C++流仍然可能存在漏洞,但与它们的标准 C 对应物相比,这种可能性较小。使用标准 C 的scanf()函数进行缓冲区溢出的经典示例如下:

#include <stdio.h>

int main()
{
    char buf[2];
    scanf("%s", buf);
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is 42 ↵
*** stack smashing detected ***: <unknown> terminated
Aborted (core dumped)

用户输入的缓冲区大于为该缓冲区分配的空间,导致缓冲区溢出的情况。在这个例子中增加buf的大小不会解决问题,因为用户总是可以输入一个比提供的缓冲区更大的字符串。这个问题可以通过在scanf()上指定长度限制来解决:

#include <stdio.h>

int main()
{
    char buf[2];
    scanf("%2s", buf);
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is 42 ↵

在这里,我们向scanf()函数提供了buf的大小,防止了缓冲区溢出。这种方法的问题是buf的大小声明了两次。如果这两者中的一个改变了,就可能重新引入缓冲区溢出。可以使用 C 风格的宏来解决这个问题,但缓冲区和其大小的解耦仍然存在。

虽然还有其他方法可以用 C 来解决这个问题,但解决 C++中前面提到的问题的一种方法如下:

#include <iomanip>
#include <iostream>

template<std::size_t N>
class buf_t
{
    char m_buf[N];

public:

    constexpr auto size()
    { return N; }

    constexpr auto data()
    { return m_buf; }
};

template<std::size_t N>
std::istream &operator>>(std::istream &is, buf_t<N> &b)
{
    is >> std::setw(b.size()) >> b.data();
    return is;
}

int main()
{
    buf_t<2> buf;
    std::cin >> buf;
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is 42 ↵

我们不是使用* char,而是创建一个封装* char 及其长度的用户定义类型。缓冲区的总大小与缓冲区本身耦合在一起,防止意外的缓冲区溢出。然而,如果允许内存分配(在编程系统中并非总是如此),我们可以做得更好:

#include <string>
#include <iostream>

int main()
{
    std::string buf;
    std::cin >> buf;
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is 42 ↵

在这个例子中,我们使用std::string来存储从std::cin输入的内容。这里的区别在于std::string根据需要动态分配内存来存储输入,防止可能的缓冲区溢出。如果需要更多的内存,就分配更多的内存,或者抛出std::bad_alloc并中止程序。C++流的用户定义类型提供了更安全的处理 IO 的机制。

常见的调试模式

在编程系统中,控制台输出的主要用途之一是调试。C++流提供了两个不同的全局对象——std::coutstd::cerr。第一个选项std::cout通常是缓冲的,发送到stdout,并且只有在发送到流的std::flushstd::endl时才会刷新。第二个选项std::cerr提供了与std::cout相同的功能,但是发送到stderr而不是stdout,并且在每次调用全局对象时都会刷新。看一下以下的例子:

#include <iostream>

int main()
{
    std::cout << "buffered" << '\n';
    std::cout << "buffer flushed" << std::endl;
    std::cerr << "buffer flushed" << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
buffer
buffer flushed
buffer flushed

因此,错误逻辑通常使用std::cerr发送到stderr,以确保在发生灾难性问题时接收所有错误控制台输出。同样,一般输出,包括调试逻辑,使用std::cout发送到stdout,以利用缓冲加快控制台输出速度,并且使用'\n'发送换行而不是std::endl,除非需要显式刷新。

以下是 C 语言中调试的典型模式:

#include <iostream>

#ifndef NDEBUG
#define DEBUG(...) fprintf(stdout, __VA_ARGS__);
#else
#define DEBUG(...)
#endif

int main()
{
    DEBUG("The answer is: %d\n", 42);
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 42

如果启用了调试,通常意味着定义了NDEBUG,则可以使用DEBUG宏将调试语句发送到控制台。使用NDEBUG是因为这是大多数编译器设置为发布模式时定义的宏,禁用了标准 C 中的assert()。另一个常见的调试模式是为调试宏提供调试级别,允许开发人员在调试时调整程序的详细程度:

#include <iostream>

#ifndef DEBUG_LEVEL
#define DEBUG_LEVEL 0
#endif

#ifndef NDEBUG
#define DEBUG(level,...) \
    if(level <= DEBUG_LEVEL) fprintf(stdout, __VA_ARGS__);
#else
#define DEBUG(...)
#endif

int main()
{
    DEBUG(0, "The answer is: %d\n", 42);
    DEBUG(1, "The answer no is: %d\n", 43);
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 42

这种逻辑的问题在于过度使用宏来实现调试,这是 C++核心指南不赞成的模式。使用 C++17 进行调试的简单方法如下:

#include <iostream>

#ifdef NDEBUG
constexpr auto g_ndebug = true;
#else
constexpr auto g_ndebug = false;
#endif

int main()
{
    if constexpr (!g_ndebug) {
        std::cout << "The answer is: " << 42 << '\n';
    }
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 42

即使使用了 C++17,仍然需要一些宏逻辑来处理启用调试时编译器提供的NDEBUG宏。在这个例子中,NDEBUG宏被转换为constexpr,然后在源代码中用于处理调试。调试级别也可以使用以下方式实现:

#include <iostream>

#ifdef DEBUG_LEVEL
constexpr auto g_debug_level = DEBUG_LEVEL;
#else
constexpr auto g_debug_level = 0;
#endif

#ifdef NDEBUG
constexpr auto g_ndebug = true;
#else
constexpr auto g_ndebug = false;
#endif

int main()
{
    if constexpr (!g_ndebug && (0 <= g_debug_level)) {
        std::cout << "The answer is: " << 42 << '\n';
    }

    if constexpr (!g_ndebug && (1 <= g_debug_level)) {
        std::cout << "The answer is not: " << 43 << '\n';
    }
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 42

由于在这个例子中调试级别是一个编译时特性,它将使用-DDEBUG_LEVEL=xxx传递给编译器,因此仍然需要宏逻辑将 C 宏转换为 C++的constexpr。正如在这个例子中所看到的,C++实现比利用fprintf()和其他函数的简单DEBUG宏要复杂得多。为了克服这种复杂性,我们将利用封装,而不会牺牲编译时优化:

#include <iostream>

#ifdef DEBUG_LEVEL
constexpr auto g_debug_level = DEBUG_LEVEL;
#else
constexpr auto g_debug_level = 0;
#endif

#ifdef NDEBUG
constexpr auto g_ndebug = true;
#else
constexpr auto g_ndebug = false;
#endif

template <std::size_t LEVEL>
constexpr void debug(void(*func)()) {
    if constexpr (!g_ndebug && (LEVEL <= g_debug_level)) {
        func();
    };
}

int main()
{
    debug<0>([] {
        std::cout << "The answer is: " << 42 << '\n';
    });

    debug<1>([] {
        std::cout << "The answer is not: " << 43 << '\n';
    });
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 42

在这个例子中,调试逻辑被封装为一个接受 Lambda 的constexpr函数。调试级别使用模板参数来保持常数。与典型的标准 C 调试模式不同,这个实现将接受任何适合于void(*func)()函数或 lambda 的调试逻辑,并且与标准 C 版本一样,在编译器设置为发布模式时将被编译和移除(即定义了NDEBUG并且通常启用了优化)。为了证明这一点,当启用发布模式时,GCC 7.3 输出如下内容:

> g++ -std=c++17 -O3 -DNDEBUG scratchpad.cpp; ./a.out
> ls -al a.out
-rwxr-xr-x 1 user users 8600 Apr 13 18:23 a.out

> readelf -s a.out | grep cout

当在源代码中添加#undef NDEBUG时,GCC 7.3 输出如下内容(确保唯一的区别是调试逻辑被禁用,但编译标志保持不变):

> g++ -std=c++17 scratchpad.cpp; ./a.out
> ls -al a.out
-rwxr-xr-x 1 user users 8888 Apr 13 18:24 a.out

> readelf -s a.out | grep cout
    23: 0000000000201060 272 OBJECT GLOBAL DEFAULT 24 _ZSt4cout@GLIBCXX_3.4 (5)
    59: 0000000000201060 272 OBJECT GLOBAL DEFAULT 24 _ZSt4cout@@GLIBCXX_3.4

额外的 288 字节来自于调试逻辑,这些逻辑完全被编译器移除,这要归功于 C++17 中constexpr的常数性,提供了一种更清晰的调试方法,而无需大量使用宏。

另一个常见的调试模式是在调试语句中包含当前行号和文件名,以提供额外的上下文。__LINE____FILE__宏用于提供这些信息。遗憾的是,在 C++17 中没有包含源位置 TS,因此没有办法在没有这些宏的情况下提供这些信息,也没有类似以下模式的包含:

#include <iostream>

#ifndef NDEBUG
#define DEBUG(fmt, args...) \
    fprintf(stdout, "%s [%d]: " fmt, __FILE__, __LINE__, args);
#else
#define DEBUG(...)
#endif

int main()
{
    DEBUG("The answer is: %d\n", 42);
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
scratchpad.cpp [11]: The answer is: 42

在这个例子中,DEBUG宏会自动将文件名和行号插入标准 C 风格的fprintf()函数中。这是因为无论编译器在哪里看到DEBUG宏,它都会插入fprintf(stdout, "%s [%d]: " fmt, __FILE__, __LINE__, args);,然后必须评估行和文件宏,从而产生预期的输出。将这种模式转换为我们现有的 C++示例的一个例子如下:

#include <iostream>

#ifdef DEBUG_LEVEL
constexpr auto g_debug_level = DEBUG_LEVEL;
#else
constexpr auto g_debug_level = 0;
#endif

#ifdef NDEBUG
constexpr auto g_ndebug = true;
#else
constexpr auto g_ndebug = false;
#endif

#define console std::cout << __FILE__ << " [" << __LINE__ << "]: "

template <std::size_t LEVEL>
constexpr void debug(void(*func)()) {
    if constexpr (!g_ndebug && (LEVEL <= g_debug_level)) {
        func();
    };
}

int main()
{
    debug<0>([] {
        console << "The answer is: " << 42 << '\n';
    });
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
scratchpad.cpp [27]: The answer is: 42

在我们的调试 lambda 中,我们不再使用std::cout,而是添加一个使用std::cout的控制台宏,但也将文件名和行号添加到调试语句中,以提供与标准 C 版本相同的功能。与标准 C 版本不同的是,不需要额外的 C 宏函数,因为控制台宏将正确提供使用的文件名和行号。

最后,为了完成我们的 C++17 调试模式,我们添加了一个带颜色的调试、警告和致命错误版本的前面示例,并为fatal函数添加了一个默认退出错误码为-1的重载版本。

首先,我们利用与前面代码片段中相同的标准 C 宏:

#ifdef DEBUG_LEVEL
constexpr auto g_debug_level = DEBUG_LEVEL;
#else
constexpr auto g_debug_level = 0;
#endif

#ifdef NDEBUG
constexpr auto g_ndebug = true;
#else
constexpr auto g_ndebug = false;
#endif

这些宏将标准 C 风格的宏转换为 C++风格的常量表达式,这些宏在命令行兼容性中是必需的。接下来,我们创建一个名为debug的模板函数,能够接受一个 lambda 函数。这个debug函数首先将绿色的debug输出到stdout,然后执行 lambda 函数,只有在调试被启用并且调试级别与提供给debug函数本身的级别匹配时才执行。如果调试未启用,debug函数将在不影响程序大小或性能的情况下编译。

template <std::size_t LEVEL>
constexpr void debug(void(*func)()) {
    if constexpr (!g_ndebug && (LEVEL <= g_debug_level)) {
        std::cout << "\033[1;32mDEBUG\033[0m ";
        func();
    };
}

这个相同的debug函数被重复使用来提供警告和致命错误版本的函数,唯一的区别是颜色(这是特定于平台的,在这种情况下是为 UNIX 操作系统设计的),而fatal函数在执行 lambda 函数后退出程序,退出时使用用户定义的错误码或-1

template <std::size_t LEVEL>
constexpr void warning(void(*func)()) {
    if constexpr (!g_ndebug && (LEVEL <= g_debug_level)) {
        std::cout << "\033[1;33mWARNING\033[0m ";
        func();
    };
}

template <std::size_t LEVEL>
constexpr void fatal(void(*func)()) {
    if constexpr (!g_ndebug && (LEVEL <= g_debug_level)) {
        std::cout << "\033[1;31mFATAL ERROR\033[0m ";
        func();
        ::exit(-1);
    };
}

template <std::size_t LEVEL>
constexpr void fatal(int error_code, void(*func)()) {
    if constexpr (!g_ndebug && (LEVEL <= g_debug_level)) {
        std::cout << "\033[1;31mFATAL ERROR\033[0m ";
        func();
        ::exit(error_code);
    };
}

最后,这些调试模式在main()函数中得到了应用,以演示它们的使用方法。

int main()
{
    debug<0>([] {
        console << "The answer is: " << 42 << '\n';
    });

    warning<0>([] {
        console << "The answer might be: " << 42 << '\n';
    });

    fatal<0>([] {
        console << "The answer was not: " << 42 << '\n';
    });
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
DEBUG scratchpad.cpp [54]: The answer is: 42
WARNING scratchpad.cpp [58]: The answer might be: 42
FATAL ERROR scratchpad.cpp [62]: The answer was not: 42

C++流的性能

关于 C++流的一个常见抱怨是性能问题,这个问题在多年来已经得到了很大的缓解。为了确保 C++流的最佳性能,可以应用一些优化:

  • 禁用 std::ios::sync_with_stdio:C++流默认会与标准 C 函数(如printf()等)同步。如果不使用这些函数,应该禁用这个同步功能,因为这将显著提高性能。

  • 避免刷新:在可能的情况下,避免刷新 C++流,让libc++和操作系统来处理刷新。这包括不使用std::flush,而是使用'\n'代替std::endl,后者在输出换行后会刷新。避免刷新时,所有输出都会被缓冲,减少了向操作系统传递输出的次数。

  • 使用 std::cout 和 std::clog 而不是 std::cerr:出于同样的原因,std::cerr在销毁时会刷新,增加了操作系统传递输出的次数。在可能的情况下,应该使用std::cout,只有在出现致命错误后才使用std::cerr,例如退出、异常、断言和可能的崩溃。

对于问题“哪个更快printf() 还是 std::cout”,不可能提供一个一般性的答案。但实际上,如果使用了前面的优化,std::cout通常可以优于标准 C 的printf(),但这高度依赖于您的环境和用例。

除了前面的例子,避免不必要的刷新以提高性能的一种方法是使用std::stringstream而不是std::cout

#include <sstream>
#include <iostream>

int main()
{
    std::stringstream stream;
    stream << "The answer is: " << 42 << '\n';

    std::cout << stream.str() << std::flush;
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 42

通过使用std::stringstream,所有输出都被定向到您控制的缓冲区,直到您准备通过std::cout和手动刷新将输出发送到操作系统。这也可以用于缓冲输出到std::cerr,减少总刷新次数。避免刷新的另一种方法是使用std::clog

#include <iostream>

int main()
{
    std::clog << "The answer is: " << 42 << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 42

std::clog的操作方式类似于std::cout,但是不是将输出发送到stdout,而是将输出发送到stderr

学习操纵器

C++流有几种不同的操纵器,可以用来控制输入和输出,其中一些已经讨论过。最常见的操纵器是std::endl,它输出一个换行符,然后刷新输出流:

#include <iostream>

int main()
{
    std::cout << "Hello World" << std::endl;
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
Hello World

编写相同逻辑的另一种方法是使用std::flush操纵器:

#include <iostream>

int main()
{
    std::cout << "Hello World\n" << std::flush;
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
Hello World

两者是相同的,尽管除非明确需要刷新,否则应始终使用'\n'。例如,如果需要多行,应首选以下方式:

#include <iostream>

int main()
{
    std::cout << "Hello World\n";
    std::cout << "Hello World\n";
    std::cout << "Hello World\n";
    std::cout << "Hello World" << std::endl;
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
Hello World
Hello World
Hello World
Hello World

与前面的代码相比,以下代码不是首选:

#include <iostream>

int main()
{
    std::cout << "Hello World" << std::endl;
    std::cout << "Hello World" << std::endl;
    std::cout << "Hello World" << std::endl;
    std::cout << "Hello World" << std::endl;
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
Hello World
Hello World
Hello World
Hello World

应该注意,不需要尾随刷新,因为::exit()main完成时会为您刷新stdout

在任何程序开始时设置的常见操纵器是std::boolalpha,它导致布尔值输出为truefalse,而不是10std::noboolalpha提供相反的效果,这也是默认值):

#include <iostream>

int main()
{
    std::cout << std::boolalpha;
    std::cout << "The answer is: " << true << '\n';
    std::cout << "The answer is: " << false << '\n';

    std::cout << std::noboolalpha;
    std::cout << "The answer is: " << true << '\n';
    std::cout << "The answer is: " << false << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: true
The answer is: false
The answer is: 1
The answer is: 0

另一组常见的操纵器是数字基操纵器——std::hexstd::decstd::oct。这些操纵器类似于标准 C 格式说明符(例如printf()使用的%d%x%o)。与标准 C 版本不同,这些操纵器是全局的,因此在库中使用时应谨慎使用。要使用这些操纵器,只需在添加所需基数的数字之前将它们添加到流中:

#include <iostream>

int main()
{
    std::cout << "The answer is: " << 42 << '\n' << std::hex 
              << "The answer is: " << 42 << '\n';
    std::cout << "The answer is: " << 42 << '\n' << std::dec 
              << "The answer is: " << 42 << '\n';
    std::cout << "The answer is: " << 42 << '\n' << std::oct 
              << "The answer is: " << 42 << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 42
The answer is: 2a
The answer is: 2a
The answer is: 42
The answer is: 42
The answer is: 52

第一个数字42打印为42,因为尚未使用任何数字基操纵器。第二个数字打印为2a,因为使用了std::hex操纵器,导致2a42的十六进制值。打印的第三个数字也是2a,因为数字基操纵器是全局的,因此,即使第二次调用std::cout,流仍然被告知使用十六进制值而不是十进制值。这种模式对于std::dec(例如,十进制数)和std::oct(例如,八进制数)都是一样的,结果是422a2a4242,最后是52

还可以使用std::hex的大写版本,而不是前面示例中看到的默认小写版本。要实现这一点,使用std::uppercasestd::nouppercasestd::uppercase显示大写字母数字字符,而std::nouppercase不显示,这是默认值):

#include <iostream>

int main()
{
    std::cout << std::hex << std::uppercase << "The answer is: " 
              << 42 << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 2A

在这个例子中,42不再输出为2a,而是输出为2A,其中字母数字字符是大写的。

通常,特别是在编程系统方面,十六进制和八进制数以它们的基数标识符(例如0x0)打印。要实现这一点,使用std::showbasestd::noshowbase操纵器(std::showbase显示基数,std::noshowbase不显示,这是默认值):

#include <iostream>

int main()
{
    std::cout << std::showbase;
    std::cout << std::hex << "The answer is: " << 42 << '\n';
    std::cout << std::dec << "The answer is: " << 42 << '\n';
    std::cout << std::oct << "The answer is: " << 42 << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 0x2a
The answer is: 42
The answer is: 052

从这个例子中可以看出,std::hex现在输出0x2a,而不是2astd::oct输出052,而不是52,而std::dec继续按预期输出42(因为十进制数没有基数标识符)。与数字不同,指针始终以十六进制、小写形式输出,并显示它们的基数,std::uppercasestd::noshowbasestd::decstd::oct不会影响输出。解决这个问题的一个方法是将指针转换为数字,然后可以使用前面的操纵器,如下例所示,但是 C++核心指南不鼓励这种逻辑,因为需要使用reinterpret_cast

#include <iostream>

int main()
{
    int i = 0;
    std::cout << &i << '\n';
    std::cout << std::hex << std::showbase << std::uppercase 
              << reinterpret_cast<uintptr_t>(&i) << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
0x7fff51d370b4
0X7FFF51D370B4

输出指针的一个问题是它们的总长度(即字符的总数)会从一个指针变化到另一个指针。当同时输出多个指针时,这通常会分散注意力,因为它们的基本修改器可能不匹配。为了克服这一点,可以使用std::setwstd::setfillstd::setw设置下一个输出的总宽度(即字符的总数)。如果下一个输出的大小不至少是传递给std::setw的值的大小,流将自动向流中添加空格:

#include <iomanip>
#include <iostream>

int main()
{
    std::cout << "The answer is: " << std::setw(18) << 42 << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is:                 42

在这个例子中,宽度设置为18。由于流的下一个添加是两个字符(来自数字42),在将42添加到流之前添加了16个空格。要更改由std::setw添加到流中的字符,请使用std::setfill

#include <iomanip>
#include <iostream>

int main()
{
    std::cout << "The answer is: " << std::setw(18) << std::setfill('0') 
              << 42 << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 000000000000000042

可以看到,流中添加的不是空格(默认情况下),而是添加到流中的'0'字符。可以使用std::leftstd::rightstd::internal来控制添加到流中的字符的方向:

#include <iomanip>
#include <iostream>

int main()
{
    std::cout << "The answer is: "
              << std::setw(18) << std::left << std::setfill('0')
              << 42 << '\n';

    std::cout << "The answer is: "
              << std::setw(18) << std::right << std::setfill('0')
              << 42 << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 420000000000000000
The answer is: 000000000000000042

std::left首先输出到流中,然后用剩余的字符填充流,而std::right用未使用的字符填充流,然后输出到流中。std::internal特定于使用基本标识符(如std::hexstd::oct)的文本以及使用std::showbase或自动显示基本标识符的指针。

#include <iomanip>
#include <iostream>

int main()
{
    int i = 0;

    std::cout << std::hex
              << std::showbase;

    std::cout << "The answer is: "
              << std::setw(18) << std::internal << std::setfill('0')
              << 42 << '\n';

    std::cout << "The answer is: "
              << std::setw(18) << std::internal << std::setfill('0')
              << &i << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 0x000000000000002a
The answer is: 0x00007ffc074c9be4

通常,特别是在库中,设置一些操纵器然后将流恢复到其原始状态是有用的。例如,如果您正在编写一个库,并且想要以hex输出一个数字,您需要使用std::hex操纵器,但这样做会导致从那时起用户输出的所有数字也以hex输出。问题是,您不能简单地使用std::dec将流设置回十进制,因为用户可能实际上是首先使用std::hex。解决这个问题的一种方法是使用std::cout.flags()函数,它允许您获取和设置流的内部标志:

#include <iostream>

int main()
{
    auto flags = std::cout.flags();
    std::cout.flags(flags);
}

> g++ -std=c++17 scratchpad.cpp; ./a.out

总的来说,所有已经讨论过的操纵器以及其他一些操纵器都可以使用std::cout.flags()函数启用/禁用,所讨论的操纵器只是这个函数的包装器,以减少冗长。虽然这个函数可以用来配置操纵器(应该避免),std::cout.flags()函数是在流被更改后恢复操纵器的便捷方法。还应该注意,前面的方法适用于所有流,而不仅仅是std::cout。简化恢复操纵器的一种方法是使用一些函数式编程,并用保存/恢复逻辑包装用户函数,如下所示:

#include <iomanip>
#include <iostream>

template<typename FUNC>
void cout_transaction(FUNC f)
{
    auto flags = std::cout.flags();
    f();
    std::cout.flags(flags);
}

int main()
{
    cout_transaction([]{
        std::cout << std::hex << std::showbase;
        std::cout << "The answer is: " << 42 << '\n';
    });

    std::cout << "The answer is: " << 42 << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 0x2a
The answer is: 42

在这个例子中,我们将std::cout的使用包装在cout_transation中。这个包装器存储操纵器的当前状态,调用用户提供的函数(改变操纵器),然后在完成之前恢复操纵器。结果是,事务完成后操纵器不受影响,这意味着这个例子中的第二个std::cout输出42而不是0x2a

最后,为了简化操纵器的使用,有时创建自定义的用户定义操纵器可以封装自定义逻辑是很有用的:

#include <iomanip>
#include <iostream>

namespace usr
{
    class hex_t { } hex;
}

std::ostream &
operator<<(std::ostream &os, const usr::hex_t &obj)
{
    os << std::hex << std::showbase << std::internal
        << std::setfill('0') << std::setw(18);

    return os;
}

int main()
{
    std::cout << "The answer is: " << usr::hex << 42 << '\n';
}

> g++ -std=c++17 scratchpad.cpp; ./a.out
The answer is: 0x000000000000002a

从这个例子可以看出,只需使用usr::hex而不是std::hex,就可以使用std::hexstd::showbasestd::internalstd::setfill('0')std::setw(18)输出42,减少冗长并简化对相同逻辑的多次使用。

重新创建 echo 程序

在这个实际例子中,我们将重新创建几乎所有POSIX系统上都可以找到的流行的 echo 程序。echo 程序接受程序提供的所有输入并将其回显到stdout。这个程序非常简单,具有以下程序选项:

  • -n:防止 echo 在退出时输出换行符

  • --help:打印帮助菜单

  • --version:打印一些版本信息

还有两个选项,-e-E;我们在这里省略了它们,以保持简单,但如果需要,可以作为读者的一个独特练习。

要查看此示例的完整源代码,请参见以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter06/example1.cpp

此处呈现的main函数是一个有用的模式,与原始的 echo 程序略有不同,因为异常(在本例中极不可能)可能会生成原始 echo 程序中看不到的错误消息;但是,它仍然很有用:

int
main(int argc, char **argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

此逻辑的目标是在程序退出之前捕获任何异常,并在退出之前将异常描述输出到stderr

考虑以下示例:

catch (const std::exception &e) {
    std::cerr << "Caught unhandled exception:\n";
    std::cerr << " - what(): " << e.what() << '\n';
}

前面的代码捕获所有std::exceptions并将捕获的异常描述(即e.what())输出到stderr。请注意,这里使用的是std::cerr(而不是std::clog),以防异常的使用会导致不稳定性,确保发生刷新。在使用错误处理逻辑时,最好始终保持谨慎,并确保所有调试输出都以性能为次要考虑因素进行。

考虑以下示例:

catch (...) {
    std::cerr << "Caught unknown exception\n";
}

前面的代码捕获所有未知异常,在本程序中几乎肯定永远不会发生,并且纯粹是为了完整性而添加:

try {
    return protected_main(argc, argv);
}

try块尝试执行protected_main()函数,如果出现异常,则执行先前描述的catch块;否则,从main函数返回,最终退出程序。

protected_main()函数的目标是解析程序提供的参数,并按预期处理每个参数:

int
protected_main(int argc, char **argv)
{
    using namespace gsl;

    auto endl = true;
    auto args = make_span(argv, argc);

    for (int i = 1, num = 0; i < argc; i++) {
        cstring_span<> span_arg = ensure_z(args.at(i));

        if (span_arg == "-n") {
            endl = false;
            continue;
        }

        if (span_arg == "--help") {
            handle_help();
        }

        if (span_arg == "--version") {
            handle_version();
        }

        if (num++ > 0) {
            std::cout << " ";
        }

        std::cout << span_arg.data();
    }

    if (endl) {
        std::cout << '\n';
    }

    return EXIT_SUCCESS;
}

以下是第一行:

auto endl = true;

它用于控制是否在退出时向stdout添加换行符,就像原始的 echo 程序一样,并由-n程序参数控制。以下是下一行:

auto args = make_span(argv, argc);

前面的代码将标准 C argvargc参数转换为 C++ GSL span,使我们能够以符合 C++核心指南的方式安全地处理程序参数。该 span 只不过是一个列表(具体来说,它与std::array非常相似),每次访问列表时都会检查此列表的边界(不像std::array)。如果我们的代码尝试访问不存在的参数,将抛出异常,并且程序将以错误代码安全退出,通过stderr告诉我们尝试访问不存在的列表元素(通过main函数中的try/catch逻辑)。

以下是下一部分:

for (int i = 1, num = 0; i < argc; i++) {
    cstring_span<> span_arg = ensure_z(args.at(i));

它循环遍历列表中的每个参数。通常,我们会使用范围for语法循环遍历列表中的每个元素:

for (const auto &arg : args) {
    ...
}

但是,不能使用此语法,因为参数列表中的第一个参数始终是程序名称,在我们的情况下应该被忽略。因此,我们从1开始(而不是0),如前所述,然后循环遍历列表中的其余元素。此片段中的第二行从列表中的每个程序参数创建cstring_span{}cstring_span{}只不过是一个标准的 C 风格字符串,包装在 GSL span 中,以保护对字符串的任何访问,使 C 风格字符串访问符合 C++核心指南。稍后将使用此包装器来比较字符串,以安全和符合规范的方式查找我们的程序选项,例如-n--help--versionensure_z()函数确保字符串完整,防止可能的意外损坏。

下一步是将每个参数与我们计划支持的参数列表进行比较:

if (span_arg == "-n") {
    endl = false;
    continue;
}

由于我们使用cstring_span{}而不是标准的 C 风格字符串,我们可以安全地直接将参数与"-n"字面字符串进行比较,而无需使用不安全的函数(如strcmp())或直接字符比较,这是原始 echo 实现所做的(由于我们只支持一个单个字符选项,性能是相同的)。如果参数是-n,我们指示我们的实现在程序退出时不应向stdout添加换行符,通过将endl设置为false,然后我们继续循环处理参数,直到它们全部被处理。

以下是接下来的两个代码块:

if (span_arg == "--help") {
    handle_help();
}

if (span_arg == "--version") {
    handle_version();
}

它们检查参数是否为--help--version。如果用户提供了其中任何一个,将执行特殊的handle_help()handle_version()函数。需要注意的是,handle_xxx()函数在完成时退出程序,因此不需要进一步的逻辑,并且应该假定这些函数永远不会返回(因为程序退出)。

此时,所有可选参数都已处理。所有其他参数应该像原始的 echo 程序一样输出到stdout。问题在于用户可能提供多个希望输出到stdout的参数。考虑以下例子:

> echo Hello World
Hello World

在这个例子中,用户提供了两个参数——HelloWorld。预期输出是Hello World(有一个空格),而不是HelloWorld(没有空格),需要一些额外的逻辑来确保根据需要将空格输出到stdout

以下是下一个代码块:

if (num++ > 0) {
    std::cout << " ";
}

这在第一个参数已经输出后向stdout输出一个空格,但在下一个参数即将输出之前(以及所有剩余的参数)。这是因为num开始为00等于0,而不是大于0,因此在第一个参数上不会输出空格),然后num被递增。当处理下一个参数时,num1(或更大),大于0,因此空格被添加到stdout

最后,通过向std::cout提供参数的数据,将参数添加到stdout,这只是std::cout可以安全处理的参数的不安全的标准 C 版本:

std::cout << span_arg.data();

protected_main()函数中的最后一个代码块是:

if (endl) {
    std::cout << '\n';
}

return EXIT_SUCCESS;

默认情况下,endltrue,因此在程序退出之前会向stdout添加一个换行符。然而,如果用户提供了-n,那么endl将被设置为false

if (span_arg == "-n") {
    endl = false;
    continue;
}

在上述代码中,如果用户提供了--help,则会执行handle_help()函数如下:

void
handle_help()
{
    std::cout
            << "Usage: echo [SHORT-OPTION]... [STRING]...\n"
            << " or: echo LONG-OPTION\n"
            << "Echo the STRING(s) to standard output.\n"
            << "\n"
            << " -n do not output the trailing newline\n"
            << " --help display this help and exit\n"
            << " --version output version information and exit\n";

    ::exit(EXIT_SUCCESS);
}

该函数使用std::cout将帮助菜单输出到stdout,然后成功退出程序。如果用户提供了--versionhandle_version()函数也会执行相同的操作:

void
handle_version()
{
    std::cout
            << "echo (example) 1.0\n"
            << "Copyright (C) ???\n"
            << "\n"
            << "Written by Rian Quinn.\n";

    ::exit(EXIT_SUCCESS);
}

要编译这个例子,我们使用 CMake:

# ------------------------------------------------------------------------------
# Header
# ------------------------------------------------------------------------------

cmake_minimum_required(VERSION 3.6)
project(chapter6)

include(ExternalProject)
find_package(Git REQUIRED)

set(CMAKE_CXX_STANDARD 17)

# ------------------------------------------------------------------------------
# Guideline Support Library
# ------------------------------------------------------------------------------

list(APPEND GSL_CMAKE_ARGS
    -DGSL_TEST=OFF
    -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}
)

ExternalProject_Add(
    gsl
    GIT_REPOSITORY https://github.com/Microsoft/GSL.git
    GIT_SHALLOW 1
    CMAKE_ARGS ${GSL_CMAKE_ARGS}
    PREFIX ${CMAKE_BINARY_DIR}/external/gsl/prefix
    TMP_DIR ${CMAKE_BINARY_DIR}/external/gsl/tmp
    STAMP_DIR ${CMAKE_BINARY_DIR}/external/gsl/stamp
    DOWNLOAD_DIR ${CMAKE_BINARY_DIR}/external/gsl/download
    SOURCE_DIR ${CMAKE_BINARY_DIR}/external/gsl/src
    BINARY_DIR ${CMAKE_BINARY_DIR}/external/gsl/build
)

# ------------------------------------------------------------------------------
# Executable
# ------------------------------------------------------------------------------

include_directories(${CMAKE_BINARY_DIR}/include)
add_executable(example1 example1.cpp)
add_dependencies(example1 gsl)

这是CMakeLists.txt文件的头部分:

cmake_minimum_required(VERSION 3.6)
project(chapter6)

include(ExternalProject)
find_package(Git REQUIRED)

set(CMAKE_CXX_STANDARD 17)

这设置了 CMake 要求版本为 3.6(因为我们使用GIT_SHALLOW),为项目命名,包括ExternalProject模块(提供了ExternalProject_Add),并将 C++标准设置为 C++17。

以下是下一部分:

# ------------------------------------------------------------------------------
# Guideline Support Library
# ------------------------------------------------------------------------------

list(APPEND GSL_CMAKE_ARGS
    -DGSL_TEST=OFF
    -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}
)

ExternalProject_Add(
    gsl
    GIT_REPOSITORY https://github.com/Microsoft/GSL.git
    GIT_SHALLOW 1
    CMAKE_ARGS ${GSL_CMAKE_ARGS}
    PREFIX ${CMAKE_BINARY_DIR}/external/gsl/prefix
    TMP_DIR ${CMAKE_BINARY_DIR}/external/gsl/tmp
    STAMP_DIR ${CMAKE_BINARY_DIR}/external/gsl/stamp
    DOWNLOAD_DIR ${CMAKE_BINARY_DIR}/external/gsl/download
    SOURCE_DIR ${CMAKE_BINARY_DIR}/external/gsl/src
    BINARY_DIR ${CMAKE_BINARY_DIR}/external/gsl/build
)

它使用 CMake 的ExternalProject_Add从 GitHub 上的 Git 存储库下载并安装 GSL,使用深度为 1(即GIT_SHALLOW 1)来加快下载过程。提供给ExternalProject_Add的参数(即GSL_CMAKE_ARGS)告诉 GSL 的构建系统关闭单元测试(我们的项目不需要)并将生成的头文件安装到我们的构建目录中(将它们放在我们的build目录中的include文件夹中)。提供给ExternalProject_Add的其余参数是可选的,只是用来清理ExternalProject_Add的输出,并且可以被忽略,甚至在需要时删除。

最后,这是最后一个代码块:

include_directories(${CMAKE_BINARY_DIR}/include)
add_executable(example1 example1.cpp)

它告诉构建系统在哪里找到我们新安装的 GSL 头文件,然后从example1.cpp源代码创建一个名为example1的可执行文件。要编译和运行此示例,只需执行:

> mkdir build; cd build
> cmake ..; make
...
> ./example1 Hello World
Hello World

理解串行回显服务器示例

在这个实际示例中,我们将创建一个基于串行的回显服务器。回显服务器(无论类型如何)都会接收输入并将输入回显到程序的输出(类似于第一个示例,但在这种情况下使用串行端口上的服务器式应用程序)。

要查看此示例的完整源代码,请参阅以下内容:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter06/example2.cpp

#include <fstream>
#include <iostream>

#include <gsl/gsl>
using namespace gsl;

void
redirect_output(
    const std::ifstream &is,
    const std::ofstream &os,
    std::function<void()> f)
{
    auto cinrdbuf = std::cin.rdbuf();
    auto coutrdbuf = std::cout.rdbuf();

    std::cin.rdbuf(is.rdbuf());
    std::cout.rdbuf(os.rdbuf());

    f();

    std::cin.rdbuf(cinrdbuf);
    std::cout.rdbuf(coutrdbuf);
}

auto
open_streams(cstring_span<> port)
{
    std::ifstream is(port.data());
    std::ofstream os(port.data());

    if (!is || !os) {
        std::clog << "ERROR: unable to open serial port:" << port.data() << '\n';
        ::exit(EXIT_FAILURE);
    }

    return std::make_pair(std::move(is), std::move(os));
}

int
protected_main(int argc, char** argv)
{
    auto args = make_span(argv, argc);

    if (argc != 2) {
        std::clog << "ERROR: unsupported number of arguments\n";
        ::exit(EXIT_FAILURE);
    }

    auto [is, os] = open_streams(
        ensure_z(args.at(1))
    );

    redirect_output(is, os, []{
        std::string buf;

        std::cin >> buf;
        std::cout << buf << std::flush;
    });

    return EXIT_SUCCESS;
}

main函数与第一个示例相同。它的唯一目的是捕获可能触发的任何异常,将异常的描述输出到stderr,并以失败状态安全地退出程序。有关其工作原理的更多信息,请参见第一个示例。protected_main()函数的目的是打开串行端口,读取输入,并将输入回显到输出:

int
protected_main(int argc, char** argv)
{
    auto args = make_span(argv, argc);

    if (argc != 2) {
        std::clog << "ERROR: unsupported number of arguments\n";
        ::exit(EXIT_FAILURE);
    }

    auto [is, os] = open_streams(
        ensure_z(args.at(1))
    );

    redirect_output(is, os, []{
        std::string buf;

        std::cin >> buf;
        std::cout << buf << std::flush;
    });

    return EXIT_SUCCESS;
}

这是第一行:

auto args = make_span(argv, argc);

它做的事情和第一个示例一样,将argcargv参数参数包装在 GSL span 中,为解析用户提供的参数提供了安全机制。

这是第二个块:

if (argc != 2) {
    std::clog << "ERROR: unsupported number of arguments\n";
    ::exit(EXIT_FAILURE);
}

它检查确保用户提供了一个且仅一个参数。argc的总数为2而不是1的原因是因为第一个参数总是程序的名称,在这种情况下应该被忽略,因此用户提供的1个参数实际上等于argc2。此外,我们使用std::clog而不是std::cerr,因为在这种情况下不太可能不稳定,并且当调用::exit()时,libc将为我们执行刷新。

这是第二个块:

auto [is, os] = open_streams(
    ensure_z(args.at(1))
);

它打开串行端口并返回输入和输出流,std::coutstd::cin可以使用串行端口而不是stdoutstdin。为此,使用了open_streams()函数:

auto
open_streams(cstring_span<> port)
{
    std::ifstream is(port.data());
    std::ofstream os(port.data());

    if (!is || !os) {
        std::clog << "ERROR: unable to open serial port:" << port.data() << '\n';
        ::exit(EXIT_FAILURE);
    }

    return std::make_pair(std::move(is), std::move(os));
}

此函数接受一个cstring_span{},用于存储要打开的串行端口(例如/dev/ttyS0)。

接下来我们转到以下流:

std::ifstream is(port.data());
std::ofstream os(port.data());

前面的代码打开了一个输入和输出流,绑定到这个串行端口。ifstream{}ofstream{}都是文件流,超出了本章的范围(它们将在以后的章节中解释),但简而言之,这些打开了串行设备并提供了一个流对象,std::coutstd::cin可以使用它们,就好像它们在使用stdoutstdin(这在POSIX系统上也是技术上的文件流)。

这是下一个块:

if (!is || !os) {
    std::clog << "ERROR: unable to open serial port:" << port.data() << '\n';
    ::exit(EXIT_FAILURE);
}

它验证了输入流和输出流是否成功打开,这很重要,因为这种类型的错误可能发生(例如,提供了无效的串行端口,或者用户无法访问串行端口)。如果发生错误,用户将通过输出到std::clog的消息得到通知,并且程序以失败状态退出。

最后,如果输入流和输出流成功打开,它们将作为一对返回,protected_main()函数将使用结构化绑定语法(C++17 中添加的功能)读取它们。

这是protected_main()函数中的下一个块:

redirect_output(is, os, []{
    std::string buf;

    std::cin >> buf;
    std::cout << buf << std::flush;
});

它将std::coutstd::cin重定向到串行端口,然后将输入回显到程序的输出,实际上回显了用户提供的串行端口。为了执行重定向,使用了redirect_output()函数:

void
redirect_output(
    const std::ifstream &is,
    const std::ofstream &os,
    std::function<void()> f)
{
    auto cinrdbuf = std::cin.rdbuf();
    auto coutrdbuf = std::cout.rdbuf();

    std::cin.rdbuf(is.rdbuf());
    std::cout.rdbuf(os.rdbuf());

    f();

    std::cin.rdbuf(cinrdbuf);
    std::cout.rdbuf(coutrdbuf);
}

redirect_output()函数将输入和输出流作为参数,以及要执行的函数和最终参数。redirect_function()的第一件事是保存std::cinstd::cout的当前缓冲区:

auto cinrdbuf = std::cin.rdbuf();
auto coutrdbuf = std::cout.rdbuf();

接下来我们看到:

std::cin.rdbuf(is.rdbuf());
std::cout.rdbuf(os.rdbuf());

std::cinstd::cout都重定向到提供的输入和输出流。完成此操作后,将执行提供的函数。任何对std::cinstd::cout的使用都将重定向到提供的串行端口,而不是标准的stdoutstdin。当f()函数完成时,std::cinstd::cout将恢复到它们的原始缓冲区,将它们重定向回stdoutstdin

std::cin.rdbuf(cinrdbuf);
std::cout.rdbuf(coutrdbuf);

最后,程序成功退出。要编译此示例,我们使用 CMake:

# ------------------------------------------------------------------------------
# Header
# ------------------------------------------------------------------------------

cmake_minimum_required(VERSION 3.6)
project(chapter6)

include(ExternalProject)
find_package(Git REQUIRED)

set(CMAKE_CXX_STANDARD 17)

# ------------------------------------------------------------------------------
# Guideline Support Library
# ------------------------------------------------------------------------------

list(APPEND GSL_CMAKE_ARGS
    -DGSL_TEST=OFF
    -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}
)

ExternalProject_Add(
    gsl
    GIT_REPOSITORY https://github.com/Microsoft/GSL.git
    GIT_SHALLOW 1
    CMAKE_ARGS ${GSL_CMAKE_ARGS}
    PREFIX ${CMAKE_BINARY_DIR}/external/gsl/prefix
    TMP_DIR ${CMAKE_BINARY_DIR}/external/gsl/tmp
    STAMP_DIR ${CMAKE_BINARY_DIR}/external/gsl/stamp
    DOWNLOAD_DIR ${CMAKE_BINARY_DIR}/external/gsl/download
    SOURCE_DIR ${CMAKE_BINARY_DIR}/external/gsl/src
    BINARY_DIR ${CMAKE_BINARY_DIR}/external/gsl/build
)

# ------------------------------------------------------------------------------
# Executable
# ------------------------------------------------------------------------------

include_directories(${CMAKE_BINARY_DIR}/include)
add_executable(example2 example2.cpp)
add_dependencies(example2 gsl)

这个CMakeLists.txt与第一个例子中的CMakeLists.txt相同(减去了使用example1而不是example2)。有关此操作原理的完整解释,请参阅本章中的第一个例子。

要编译和使用此示例,需要两台计算机,一台用作 echo 服务器,另一台用作客户端,两台计算机的串行端口连接在一起。在 echo 服务器计算机上,使用以下命令:

> mkdir build; cd build
> cmake ..; make
...
> ./example2 /dev/ttyS0

请注意,您的串行端口设备可能不同。在客户计算机上,打开两个终端。在第一个终端中,运行以下命令:

> cat < /dev/ttyS0

这段代码等待串行设备输出数据。在第二个终端中运行:

> echo "Hello World" > /dev/ttyS0

这将通过串行端口将数据发送到 echo 服务器。当您按下Enter时,您将看到我们在 echo 服务器上成功关闭的example2程序,并且客户端的第一个终端将显示Hello World

> cat < /dev/ttyS0
Hello World

摘要

在本章中,我们学习了如何使用 C++17 执行基于控制台的 IO,这是一种常见的系统编程需求。与printf()scanf()等标准 C 风格的 IO 函数不同,C++使用基于流的 IO 函数,如std::coutstd::cin。使用基于流的 IO 有许多优点和一些缺点。例如,基于流的 IO 提供了一种类型安全的机制来执行 IO,而原始的 POSIX 风格的write()函数通常由于不调用malloc()free()而能够优于基于流的 IO。

此外,我们还研究了基于流的操作符,它们为基于流的 IO 提供了与标准 C 风格格式字符串类似的功能集,但没有 C 等效项中常见的不稳定性问题。除了操纵数字和布尔值的格式之外,我们还探讨了字段属性,包括宽度和对齐。

最后,我们用两个不同的例子结束了本章。第一个例子展示了如何在 C++中实现流行的 POSIX echo程序,而不是在 C 中。第二个例子创建了一个echo服务器,用于串行端口,它使用std::cin从串行端口接收输入,并使用std::cout将该输入作为输出发送回串行端口。

在下一章中,我们将全面介绍 C、C++和 POSIX 提供的内存管理设施,包括对齐内存和 C++智能指针。

问题

  1. 相比标准 C 的scanfstd::cin如何帮助防止缓冲区溢出?

  2. 至少列举一个使用 C++流相对于标准 C 风格的printf/scanf的优点。

  3. 至少列举一个使用 C++流相对于标准 C 风格的printf/scanf的缺点。

  4. 何时应该使用std::endl而不是\n

  5. std::cerrstd::clog之间有什么区别,何时应该使用std::cerr

  6. 如何在基数标识符和十六进制值之间输出额外字符?

  7. 如何输出八进制和大写字母?

  8. 如何使用 C++和 GSL 安全地解析标准 C 风格的程序参数?

  9. 如何保存/恢复std::cin的读取缓冲区?

进一步阅读

第七章:内存管理的全面视角

在本章中,我们将逐步指导读者如何正确和安全地执行 C++风格的内存管理,同时尽可能遵守 C++核心指南,利用 C++11、C++14 和 C++17 对 C++标准模板库的增强,以增加读者系统程序的安全性、可靠性和稳定性。我们将首先介绍new()delete()函数,以及它们如何用于分配类型安全的内存,包括对齐内存。接下来,本章将讨论使用new()delete()直接的安全问题,以及如何使用智能指针来处理这些安全问题,包括它们对 C++核心指南合规性的影响。还将讨论如何执行内存映射和权限,并在章节结束时简要讨论碎片化问题。

技术要求

为了编译和执行本章中的示例,读者必须具备以下条件:

  • 一个能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请访问:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter07

学习关于 new 和 delete 函数

在本节中,读者将学习如何使用 C++17 分配和释放内存。您将学习如何使用new()delete()而不是malloc()/free()来增加分配和释放的类型安全性。将解释这些函数的各个版本,包括数组、对齐和放置式分配。

编写程序的基础知识

在编写程序时,包括系统编程,作者可以利用几种不同类型的内存:

  • 全局内存

  • 堆栈内存

  • 堆内存

全局内存存在于程序本身中,由操作系统的加载器分配,并且通常存在于两个不同的位置(假设是 ELF 二进制文件):

  • .bss: 零初始化(或未初始化)内存

  • .data: value-initialized memory

考虑以下示例:

#include <iostream>

int bss_mem = 0;
int data_mem = 42;

int main()
{
    std::cout << bss_mem << '\n';
    std::cout << data_mem << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0
// 42

尽管在系统编程中经常使用,但全局内存通常不鼓励使用,而是推荐使用堆栈内存和动态内存。在使用值初始化的全局内存时需要特别小心,因为这种内存使用会增加程序在磁盘上的大小,导致更大的存储影响,以及长时间的加载时间,而零初始化的内存是由操作系统加载器在链接期间提供的。

堆栈内存是在堆栈上分配的内存:

#include <iostream>

int main()
{
    int stack_mem = 42;
    std::cout << stack_mem << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 42

如本例所示,stack_mem是在堆栈上分配而不是全局分配,因为它存在于main()函数中。堆栈内存绑定到创建它的作用域——在这种情况下是main()函数。除了有作用域之外,堆栈内存的另一个优点是,当内存的作用域完成时,内存将自动释放。在使用堆栈内存时需要小心,因为这种内存的大小是有限的。

应注意,堆栈的总大小完全取决于系统,并且可能差异很大。除非知道堆栈的大小,否则应假定它很小,并小心使用,因为没有简单的方法来确定堆栈何时耗尽。与通常在内存不可用时返回某种错误的动态内存分配不同,在大多数系统上,当堆栈耗尽时,程序将简单崩溃。

例如,在我们的测试系统上,尝试在堆栈上分配一个整数数组268435456,如下所示的代码:

#include <iostream>

int main()
{
    int stack_mem[268435456];
    std::cout << stack_mem[0] << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// Segmentation fault (core dumped)

这导致分段错误,因为stack_mem变量超出了堆栈的总大小。

内存的第三种形式,也是本章的主题,是动态内存(也称为堆内存)。与堆栈一样,每个程序都会被操作系统分配一块堆内存池,这个池通常可以根据需求增长。与堆栈甚至全局内存不同,堆内存分配可以非常大,如果物理系统和操作系统支持的话。此外,与堆栈和全局内存不同,堆内存的分配速度较慢,用户按需分配的任何内存在完成时也必须由用户释放回堆。在 C++中,分配堆内存的基本方法是通过使用new()delete()运算符函数,如下所示:

#include <iostream>

int main()
{
    auto ptr = new int;
    std::cout << ptr << '\n';
    delete ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x5639c77e4e70

在这个简单的例子中,使用 new 运算符在堆上分配了一个整数(其大小取决于体系结构,但在这里假定为4字节)。新分配的内存的地址被输出到stdout,然后使用delete()运算符将内存释放回堆。除了单个对象,也可以使用new()/delete()运算符分配/释放数组,如下所示:

#include <iostream>

int main()
{
    auto ptr = new int[42];
    std::cout << ptr << '\n';
    delete [] ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x5594a7d47e70

在这个例子中,分配了一个大小为42的整数数组。请注意,与标准 C 中的malloc()不同,new 运算符会自动计算对象或对象数组所需的总字节数。假设一个整数是4字节,在这个例子中,new 运算符分配了42 * sizeof(int) == 42 * 4 == 11088字节。除了使用new[]()来分配数组外,还使用了delete[]()运算符,而不是delete运算符。delete 运算符调用单个对象的析构函数,而delete[]()运算符调用数组中每个对象的析构函数。

#include <iostream>

class myclass
{
public:
    ~myclass()
    {
        std::cout << "my delete\n";
    }
};

int main()
{
    auto ptr = new myclass[2];
    std::cout << ptr << '\n';
    delete [] ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x56171064ae78
// my delete
// my delete

重要的是要注意,一些系统可能使用不同的池来分配单个对象、对象数组、对齐对象等。需要注意确保释放内存的例程与分配内存的例程匹配。例如,如果使用new[](),应该始终使用delete[]()而不是delete()。如果发生不匹配,共享相同池的系统将正常运行,但在不共享这些池的系统上可能会崩溃,因为您会尝试释放内存到原本不属于的池中。预防这些类型的错误的最简单方法是使用std::unique_ptr{}std::shared_ptr{},这将在理解智能指针和所有权部分讨论。

对齐内存

在编程系统时,通常需要分配对齐内存(即,可以被特定对齐方式整除的内存)。具体来说,当分配内存时,指向所分配内存的地址可以是任何值。然而,在编程系统时,这通常会有问题,因为一些 API 和物理设备要求内存以一定的最小粒度进行分配。考虑以下例子:

0x0ABCDEF123456789 // Unaligned
0x0ABCDEF12345F000 // 4 Kb aligned

可以使用所有三种内存类型来分配对齐内存:

  • 全局

  • 在堆栈上

  • 动态地

要在 C++中全局分配对齐内存,使用alignas()说明符:

#include <iostream>

alignas(0x1000) int ptr[42];

int main()
{
    std::cout << ptr << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x560809897000

在这个例子中,全局分配了一个大小为42的整数数组,并使用alignas()说明符将数组对齐到 4k 页边界。然后输出数组的地址,如所示,该地址可以被 4k 页整除(即,前 12 位为零)。要在堆栈上分配对齐内存,也可以使用alignas()说明符:

#include <iostream>

int main()
{
    alignas(0x1000) int ptr[42];
    std::cout << ptr << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x560809897000

数组不是全局分配的,而是移动到main函数的作用域中,因此在main函数执行时使用堆栈分配,并在main函数完成时自动释放。这种类型的分配应该谨慎使用,因为编译器必须向程序的可执行文件中添加代码,以移动堆栈指针以对齐内存。因此,堆栈上的对齐分配间接分配了额外的不可用内存,以确保指针对齐(在 Intel 的 x86_64 上使用 GCC 7.3 显示):

> objdump -d | grep main
...
00000000000008da <main>:
 8da: 4c 8d 54 24 08 lea 0x8(%rsp),%r10
 8df: 48 81 e4 00 f0 ff ff and $0xfffffffffffff000,%rsp
 8e6: 41 ff 72 f8 pushq -0x8(%r10)

可以看到,堆栈指针(即本例中的 RSP 寄存器)被移动以对齐整数数组。如果这种类型的分配频繁进行,或者对齐要求很高(比如 2MB 对齐),堆栈空间可能很快用完。无论类型如何,另一种分配对齐内存的方法是在现有字符缓冲区内手动计算对齐位置:

#include <iostream>

int main()
{
    char buffer[0x2000];
    auto ptr1 = reinterpret_cast<uintptr_t>(buffer);
    auto ptr2 = ptr1 - (ptr1 % 0x1000) + 0x1000;

    std::cout << std::hex << std::showbase;
    std::cout << ptr1 << '\n';
    std::cout << ptr2 << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x7ffd160dec20
// 0x7ffd160df000

在这个例子中,堆栈上分配了一个足够大的字符缓冲区。然后将字符缓冲区的地址转换为无符号整数指针类型,这是 C++核心指南所不鼓励的操作,然后对字符缓冲区的指针进行算术运算,以定位缓冲区内的页面对齐地址,这也是 C++核心指南所不鼓励的操作,因为应该避免指针算术。原始指针和结果指针都输出到stdout,如所示,计算出的指针在字符缓冲区内对齐到 4k 页面边界。要了解这个算法是如何工作的,请参见以下内容:

// ptr1 = 0x7ffd160dec20
// ptr1 % 0x1000 = 0xc20
// ptr1 - (ptr1 % 0x1000) = 0x7ffd160de000   
// ptr1 - (ptr1 % 0x1000) + 0x1000 = 0x7ffd160df000 

这种类型的处理方式有效,并且已经使用了多年,但应该避免使用,因为有更好的方法可以使用alignas()来完成相同的任务,而无需进行类型转换和指针算术,这种方法容易出错,并且被 C++核心指南所不鼓励。

最后,分配对齐内存的第三种方法是使用动态分配。在 C++17 之前,可以使用posix_memalign()或更新的 C11 aligned_alloc()来实现,如下所示:

#include <iostream>

int main()
{
    int *ptr;

    if (posix_memalign(reinterpret_cast<void **>(&ptr), 0x1000, 42 * sizeof(int))) {
        std::clog << "ERROR: unable to allocate aligned memory\n";
        ::exit(EXIT_FAILURE);
    }

    std::cout << ptr << '\n';
    free(ptr);
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55c5d31d1000

posix_memalign() API 有点笨拙。首先,必须声明一个指针,然后提供对齐和大小(必须手动计算),最后,函数在成功时返回 0。最后,需要使用reinterpret_cast()来告诉posix_memalign()函数提供的指针是void **而不是int**。由于posix_memalign()函数是 C 风格的函数,所以使用free()来释放内存。

另一种分配对齐内存的方法是使用相对较新的aligned_alloc()函数,它提供了一个更简洁、更便携的实现方式:

#include <iostream>

int main()
{
    if (auto ptr = aligned_alloc(0x1000, 42 * sizeof(int))) {
        std::cout << ptr << '\n';
        free(ptr);
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55c5d31d1000

如所示,aligned_alloc()的功能类似于常规的malloc(),但具有额外的对齐参数。这个 API 仍然存在与malloc()posix_memalign()相同的大小问题,其中数组的总大小必须手动计算。

为了解决这些问题,C++17 添加了new()delete()运算符的对齐分配版本,利用了alignas(),如下所示:

#include <iostream>

using aligned_int alignas(0x1000) = int;

int main()
{
    auto ptr = new aligned_int;
    std::cout << ptr << '\n';
    delete ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55e32ece1000

在这个例子中,我们使用alignas()new()delete()运算符来分配一个单个整数。为了实现这一点,我们创建了一个新类型,称为aligned_int,它在类型定义中利用了alignas()。以下内容也可以用来分配一个对齐的数组:

#include <iostream>

using aligned_int alignas(0x1000) = int;

int main()
{
    auto ptr = new aligned_int[42];
    std::cout << ptr << '\n';
    delete [] ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x5649c0597000

使用相同的对齐整数类型,唯一的区别是使用new []()delete []()而不是new()delete()。与前面代码中显示的 C API 不同,new()delete(),包括 C++17 中添加的对齐版本,会自动计算需要分配的总字节数,从而消除了潜在的错误。

nothrow

new()delete()运算符允许抛出异常。实际上,如果分配失败,默认的 new 运算符会抛出std::bad_alloc,而不是返回nullptr。在某些情况下,通常在编程系统中经常见到,不希望在无效的分配上抛出异常,因此提供了nothrow版本。

#include <iostream>

int main()
{
    auto ptr = new (std::nothrow) int;
    std::cout << ptr << '\n';
    delete ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55893e230e70

具体来说,使用new (std::nothrow)代替new(),告诉 C++在无效分配时希望返回nullptr,而不是new()抛出std::bad_alloc。数组版本也提供如下:

#include <iostream>

int main()
{
    auto ptr = new (std::nothrow) int[42];
    std::cout << ptr << '\n';
    delete [] ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x5623076e9e70

正如人们所期望的那样,这些函数的对齐分配版本也适用于单个对象的分配:

#include <iostream>

using aligned_int alignas(0x1000) = int;

int main()
{
    auto ptr = new (std::nothrow) aligned_int;
    std::cout << ptr << '\n';
    delete ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55e36201a000

还有数组样式的分配:

#include <iostream>

using aligned_int alignas(0x1000) = int;

int main()
{
    auto ptr = new (std::nothrow) aligned_int[42];
    std::cout << ptr << '\n';
    delete [] ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x557222103000

应该注意,nullptr仅对 C++提供的类型返回。对于用户定义的类型,如果在构造过程中抛出异常,标记为nothrownew()版本将调用std::terminate并中止:

#include <iostream>

class myclass
{
public:
    myclass()
    {
        throw std::runtime_error("the answer was not 42");
    }
};

int main()
{
    auto ptr = new (std::nothrow) myclass;
    std::cout << ptr << '\n';
    delete ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// terminate called after throwing an instance of 'std::runtime_error'
// what(): the answer was not 42
// Aborted (core dumped)

为了解决这个问题,可以使用特定于类的newdelete运算符(在重载部分进行解释)。

放置 new

除了对齐分配和nothrow指定符,C++还提供了从现有的、用户控制的缓冲区分配内存的能力,这种情况在编程系统中经常见到。例如,假设您已经从物理设备映射了一个缓冲区。现在假设您希望从这个缓冲区分配一个整数,可以使用new()放置运算符来实现:

#include <iostream>

char buf[0x1000];

int main()
{
    auto ptr = new (buf) int;
    std::cout << ptr << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x5567b8884000

在这个例子中,我们利用new()放置运算符从现有的用户控制的缓冲区分配内存。new()放置运算符提供了要分配的对象的地址,然后像往常一样调用对象的构造函数。应该注意,在这种情况下不需要delete()运算符,因为分配给对象的内存是用户定义的,因此在完成时没有堆内存需要返回到堆中。此外,new()放置运算符不管理提供给一组对象的内存,这是用户必须执行的任务。为了证明这一点,可以参考以下内容:

#include <iostream>

char buf[0x1000];

int main()
{
    auto ptr1 = new (buf) int;
    auto ptr2 = new (buf) int;
    std::cout << ptr1 << '\n';
    std::cout << ptr2 << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x558044c66180
// 0x558044c66180

在这个例子中,new()放置被使用了两次。如图所示,提供的地址是相同的,因为我们没有手动提前提供给new()放置的地址,这表明当使用new()放置时,C++不会自动管理用户定义的内存。通常,这种类型的例子如果执行会导致未定义的行为(在这种情况下并不会,因为我们实际上并没有使用新分配的内存)。因此,new()放置应该特别小心使用。除了单个分配外,还提供了数组分配:

#include <iostream>

char buf[0x1000];

int main()
{
    auto ptr = new (buf) int[42];
    std::cout << ptr << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55594aff0000

由于 C++不管理new()放置分配,用户还必须提供对齐分配。在前面的代码中提供的对齐算法可以用于从用户定义的缓冲区提供对齐分配,也可以使用已经对齐的内存(例如,通过mmap()与物理设备进行接口),或者也可以使用alignas(),如下所示:

#include <iostream>

alignas(0x1000) char buf[0x1000];

int main()
{
    auto ptr = new (buf) int;
    std::cout << ptr << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x5567b8884000

在这个例子中,由于使用alignas()对缓冲区进行了对齐,因此当提供该缓冲区时,得到的新的放置分配也是对齐的。这种类型的分配对于数组分配也是适用的:

#include <iostream>

alignas(0x1000) char buf[0x1000];

int main()
{
    auto ptr = new (buf) int[42];
    std::cout << ptr << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55594aff0000

重载

在编程系统时,C++提供的默认分配方案通常是不理想的。例如(但不限于):

  • 自定义内存布局

  • 碎片化

  • 性能优化

  • 调试和统计

克服这些问题的一种方法是利用 C++分配器,这是一个复杂的话题,将在第九章中讨论,分配器的实践方法。另一种更严厉的方法是利用new()delete()运算符的用户定义重载:

#include <iostream>

void *operator new (std::size_t count)
{
    // WARNING: Do not use std::cout here
    return malloc(count);
}

void operator delete (void *ptr)
{
    // WARNING: Do not use std::cout here
    return free(ptr);
}

int main()
{
    auto ptr = new int;
    std::cout << ptr << '\n';
    delete ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55f204617e70

在这个例子中,提供了自定义的new()delete()运算符重载。你的程序将使用new()delete()函数提供的默认分配方案,而是使用你定义的版本。

这些重载会影响所有的分配,包括 C++库使用的分配,因此在利用这些重载时需要小心,因为如果在这些函数内执行分配,可能会发生无限循环递归。例如,像std::vectorstd::list这样的数据结构,或者像std::coutstd::cerr这样的调试函数都不能使用,因为这些设施使用new()delete()运算符来分配内存。

除了单个对象的new()delete()运算符外,所有其他运算符也可以进行重载,包括数组分配版本:

#include <iostream>

void *operator new[](std::size_t count)
{
    // WARNING: Do not use std::cout here
    return malloc(count);
}

void operator delete[](void *ptr)
{
    // WARNING: Do not use std::cout here
    return free(ptr);
}

int main()
{
    auto ptr = new int[42];
    std::cout << ptr << '\n';
    delete [] ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55e5e2c62e70

调试和统计是重载new()delete()运算符的常见原因,提供有关正在发生的分配类型的有用信息。例如,假设你希望记录大于或等于一页的总分配数:

#include <iostream>

std::size_t allocations = 0;

void *operator new (std::size_t count)
{
    if (count >= 0x1000) {
        allocations++;
    }

    return malloc(count);
}

void operator delete (void *ptr)
{
    return free(ptr);
}

int main()
{
    auto ptr = new int;
    std::cout << allocations << '\n';
    delete ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0

如图所示,我们的程序没有执行大于一页的分配,包括由 C++库进行的分配。让我们看看如果我们按照这里所示的方式分配一页会发生什么:

#include <iostream>

std::size_t allocations = 0;

void *operator new (std::size_t count)
{
    if (count >= 0x1000) {
        allocations++;
    }

    return malloc(count);
}

void operator delete (void *ptr)
{
    return free(ptr);
}

struct mystruct
{
    char buf[0x1000];
};

int main()
{
    auto ptr = new mystruct;
    std::cout << allocations << '\n';
    delete ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 1

如预期的那样,我们得到了一个大于或等于一页的单个分配。这种使用重载的new()delete()的方式对于调试内存泄漏、定位分配优化等非常有用。然而,需要注意的是,在编写这些类型的重载时需要小心。如果你意外地分配内存(例如,在使用 C++数据结构如std::vector{}时,或者在使用std::cout时),你可能会陷入无限循环,或者增加你可能正在记录的统计数据。

除了全局运算符newdelete运算符重载外,还提供了特定于类的版本:

#include <iostream>

class myclass
{
public:
    void *operator new (std::size_t count)
    {
        std::cout << "my new\n";
        return ::operator new (count);
    }

    void operator delete (void *ptr)
    {
        std::cout << "my delete\n";
        return ::operator delete (ptr);
    }
};

int main()
{
    auto ptr = new myclass;
    std::cout << ptr << '\n';
    delete ptr;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// my new
// 0x5561cac52280
// my delete

当使用特定于类的运算符时,只有为特定类或类提供重载的分配才会被指向你的重载。如前面的例子所示,std::cout所做的分配不会指向我们特定于类的重载,从而防止无限递归。唯一使用重载的分配和释放是myclass的分配和释放。

如预期的那样,所有全局运算符也存在于特定于类的运算符中,包括对齐分配的版本:

#include <iostream>

class myclass
{
public:
    void *operator new[](std::size_t count, std::align_val_t al)
    {
        std::cout << "my new\n";
        return ::operator new (count, al);
    }

    void operator delete[](void *ptr, std::align_val_t al)
    {
        std::cout << "my delete\n";
        return ::operator delete (ptr, al);
    }
};

using aligned_myclass alignas(0x1000) = myclass;

int main()
{
    auto ptr1 = new aligned_myclass;
    auto ptr2 = new aligned_myclass[42];
    std::cout << ptr1 << '\n';
    std::cout << ptr2 << '\n';
    delete ptr1;
    delete [] ptr2;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// my new
// 0x563b49b74000
// 0x563b49b76000
// my delete

理解智能指针和所有权

在本节中,读者将学习如何使用智能指针来增加程序的安全性、可靠性和稳定性,同时也遵循 C++核心准则。

std::unique_ptr{}指针

现在应该清楚了,C++提供了一套广泛的 API 来分配和释放动态内存。同时也应该清楚,无论你使用malloc()/free()还是new()/delete(),在大型应用程序中错误不仅可能而且很可能发生。例如,你可能会忘记将内存释放回堆:

#include <iostream>

int main()
{
    auto ptr = new int;
    std::cout << ptr << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; valgrind ./a.out
// ==8627== LEAK SUMMARY:
// ==8627== definitely lost: 4 bytes in 1 blocks
// ==8627== indirectly lost: 0 bytes in 0 blocks
// ==8627== possibly lost: 0 bytes in 0 blocks
// ==8627== still reachable: 0 bytes in 0 blocks
// ==8627== suppressed: 0 bytes in 0 blocks
// ==8627== Rerun with --leak-check=full to see details of leaked memory

或者在分配数组时,你可以使用delete而不是delete []

#include <iostream>

int main()
{
    auto ptr = new int[42];
    std::cout << ptr << '\n';
    delete ptr;
}

// > g++ -std=c++17 scratchpad.cpp; valgrind ./a.out
// ==8656== Mismatched free() / delete / delete []
// ==8656== at 0x4C2E60B: operator delete(void*) (vg_replace_malloc.c:576)
// ==8656== by 0x108960: main (in /home/user/examples/chapter_7/a.out)
// ==8656== Address 0x5aebc80 is 0 bytes inside a block of size 168 alloc'd
// ==8656== at 0x4C2DC6F: operator new[](unsigned long) (vg_replace_malloc.c:423)
// ==8656== by 0x10892B: main (in /home/user/examples/chapter_7/a.out)

为了克服这一点,C++11 引入了指针所有权的概念,使用了两个类:

  • std::unique_ptr{}:定义了一个由单个实体独有拥有的指针。不允许复制该指针,并且由 C++自动处理内存释放。

  • std::shared_ptr{}: 定义一个可能由一个或多个实体拥有的指针。允许复制此指针,并且只有在所有所有者释放所有权时才会释放内存。

总的来说,C++核心指南不鼓励不是由这两个类执行的任何动态分配。在大多数情况下,通常会使用newdelete的地方,应该改用std::unique_ptr{}。考虑以下例子:

#include <memory>
#include <iostream>

int main()
{
    auto ptr = std::make_unique<int>(42);
    std::cout << *ptr << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 42

为了创建std::unique_ptr{}std::shared_ptr,C++提供了以下内容:

  • std::make_unique(): 创建std::unique_ptr{}

  • std::make_shared(): 创建std::shared_ptr{}

如果您计划遵守 C++核心指南,请熟悉这些函数。如上所示,要创建std::unique_ptr{},必须提供要分配的对象类型以及对象的初始值作为模板参数。此外,如上所示,无需手动调用delete()运算符,因为这是由系统自动完成的。为了证明这一点,看下面的例子:

#include <memory>
#include <iostream>

class myclass
{
public:
    ~myclass()
    {
        std::cout << "my delete\n";
    }
};

int main()
{
    auto ptr = std::make_unique<myclass>();
    std::cout << ptr.get() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x5621eb029e70
// my delete

在这个例子中使用std::unique_ptr{},防止了内存泄漏和内存 API 不匹配。此外,这种智能分配和释放是有范围的。考虑以下例子:

#include <memory>
#include <iostream>

class myclass1
{
public:
    ~myclass1()
    {
        std::cout << "my delete\n";
    }
};

class myclass2
{
    std::unique_ptr<myclass1> m_data;

public:
    myclass2() :
        m_data{std::make_unique<myclass1>()}
    { }
};

int main()
{
    myclass2();
    std::cout << "complete\n";
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// my delete
// complete

myclass1作为myclass2的成员变量存储。在main函数中,创建并立即销毁myclass2,结果是当销毁myclass2时,myclass1也会被释放回堆。

std::unique_ptr{}接受指向先前分配的内存的指针(例如通过new()运算符),然后在销毁时默认释放通过delete()运算符给出的内存。如果提供给std::unique_ptr{}的内存是使用new[]()而不是new()分配的,则应该使用[]版本的std::unique_ptr{},以确保它使用delete[]()而不是delete()释放分配的内存:

#include <memory>
#include <iostream>

class myclass1
{
public:
    ~myclass1()
    {
        std::cout << "my delete\n";
    }
};

int main()
{
    std::unique_ptr<myclass1[]>(new myclass1[2]);
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// my delete
// my delete

使用std::unique_ptr{}分配和释放数组的更符合 C++核心指南的方法是使用std::make_unique()的数组版本:

#include <memory>
#include <iostream>

int main()
{
    auto ptr = std::make_unique<int[]>(42);
    std::cout << ptr.get() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55b25f224e70
// my delete

std::make_unique()代替手动分配数组。使用std::make_unique()进行单个对象分配和数组分配的区别如下:

  • std::make_unique<type>(args): 要执行单个对象分配,需要将类型作为模板参数提供,并将对象的构造函数参数作为参数提供给std::make_unique()

  • std::make_unique<type[]>(size): 要执行数组分配,需要将数组类型作为模板参数提供,并将数组的大小作为参数提供给std::make_unique()

在某些情况下,提供给std::unique_ptr{}的内存无法使用delete()delete[]()释放(例如mmap()缓冲区,放置new()等)。为支持这些类型的情况,std::unique_ptr{}接受自定义删除器:

#include <memory>
#include <iostream>

class int_deleter
{
public:
    void operator()(int *ptr) const
    {
        std::cout << "my delete\n";
        delete ptr;
    };
};

int main()
{
    auto ptr = std::unique_ptr<int, int_deleter>(new int, int_deleter());
    std::cout << ptr.get() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x5615be977e70
// my delete

在上面的例子中,创建了一个deleter类,并提供了一个函数对象(即operator ()),用于执行自定义删除。当需要释放分配的内存时,std::unique_ptr{}会调用这个函数对象。

C++17 中std::unique_ptr{}的一个缺点是,newdelete运算符的对齐版本没有扩展到std::unique_ptr{}(或std::shared_pointer{})。由于std::unique_ptr{}没有对齐版本,如果需要对齐内存,必须手动分配(希望这个问题在未来的 C++版本中得到解决,因为这种分配方式通常是 C++核心指南所不鼓励的):

#include <memory>
#include <iostream>

using aligned_int alignas(0x1000) = int;

int main()
{
    auto ptr = std::unique_ptr<int>(new aligned_int);
    std::cout << ptr.get() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x560eb6a0a000

与普通的 C++风格指针一样,*->可以用于解引用std::unique_ptr{}

#include <memory>
#include <iostream>

struct mystruct {
    int data{42};
};

int main()
{
    auto ptr1 = std::make_unique<int>(42);
    auto ptr2 = std::make_unique<mystruct>();
    std::cout << *ptr1 << '\n';
    std::cout << ptr2->data << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 42
// 42

要使std::unique_ptr{}释放其分配,指针需要失去作用域,导致调用std::unique_ptr{}的析构函数,从而将分配释放回堆。std::unique_ptr{}还提供了reset()函数,它明确告诉指针在需要时释放其内存,而无需失去作用域:

#include <memory>
#include <iostream>

int main()
{
    auto ptr = std::make_unique<int>();
    std::cout << ptr.get() << '\n';
    ptr.reset();
    std::cout << ptr.get() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55bcfa2b1e70
// 0

在此示例中,std::unique_ptr{}被重置,因此它存储的指针等同于nullptrstd::unique_ptr{}在使用->*等运算符对其进行解引用时不会检查指针是否有效。因此,应谨慎使用reset()函数,并且仅在需要时使用(例如,释放分配的顺序很重要时)。

以下是std::unique_ptr{}可能无效的几种方式(但这不是详尽列表):

  • 最初是使用nullptr创建的

  • 调用了reset()release()

为了检查std::unique_ptr{}是否有效,以确保不会意外发生空指针解引用,可以使用布尔运算符:

#include <memory>
#include <iostream>

int main()
{
    auto ptr = std::make_unique<int>(42);
    if (ptr) {
        std::cout << *ptr << '\n';
    }
    ptr.reset();
    if (ptr) {
        std::cout << *ptr << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 42

如本例所示,一旦在std::unique_ptr{}上调用reset(),它就变得无效(即等于nullptr),布尔运算符返回false,防止nullptr解引用。

如果使用数组语法创建std::unique_ptr{},则可以使用下标运算符来访问数组中的特定元素,类似于使用下标运算符访问标准 C 数组或std::array{}

#include <memory>
#include <iostream>

int main()
{
    auto ptr = std::make_unique<int[]>(42);
    std::cout << ptr[0] << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0

在上面的示例中,分配了大小为42的整数数组,并将数组中的第一个元素输出到stdout,其中包含值0,因为std::make_unique()使用值初始化来对所有分配进行零初始化。

应该注意,尽管 C++核心指南鼓励使用std::unique_ptr{}而不是手动分配和释放 C 风格数组,但指南不鼓励使用下标运算符来访问数组,因为这样做会执行不安全的指针算术,并可能导致nullptr解引用。相反,应该在访问之前将使用std::unique_ptr{}新分配的数组提供给gsl::span

关于std::unique_ptr{},C++17 的一个限制是无法直接将其添加到诸如std::cout之类的 IO 流。在 C++17 中,输出std::unique_ptr{}的地址的最佳方法是使用get()函数,该函数返回指针的地址。另一种实现这一点的方法是创建用户定义的重载:

#include <memory>
#include <iostream>

template<typename T> std::ostream &
operator<<(std::ostream &os, const std::unique_ptr<T> &ptr)
{
    os << ptr.get();
    return os;
}

int main()
{
    auto ptr = std::make_unique<int>();
    std::cout << ptr << '\n';
    std::cout << ptr.get() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x55ed70997e70

std::shared_ptr 指针

在大多数情况下,应该使用std::unique_ptr{}来分配动态内存。然而,在某些用例中,std::unique_ptr{}无法正确表示指针所有权。指针所有权指的是谁拥有指针,或者换句话说,谁负责分配,更重要的是,释放指针。在大多数情况下,程序中的单个实体负责此任务。然而,有一些用例需要多个实体来声明释放指针的责任。

最常见的情况是多个实体必须声明对变量的所有权,涉及线程。假设您有两个线程:

  • 线程#1 创建指针(因此拥有它)

  • 线程#2 使用来自线程#1 的指针

在此示例中,第二个线程拥有指针,就像创建指针并在第一次提供它的第一个线程一样。以下示例演示了这种情况:

#include <thread>
#include <iostream>

class myclass
{
    int m_data{0};

public:

    ~myclass()
    {
        std::cout << "myclass deleted\n";
    }

    void inc()
    { m_data++; }
};

std::thread t1;
std::thread t2;

void
thread2(myclass *ptr)
{
    for (auto i = 0; i < 100000; i++) {
        ptr->inc();
    }

    std::cout << "thread2: complete\n";
}

void
thread1()
{
    auto ptr = std::make_unique<myclass>();
    t2 = std::thread(thread2, ptr.get());

    for (auto i = 0; i < 10; i++) {
        ptr->inc();
    }

    std::cout << "thread1: complete\n";
}

int main()
{
    t1 = std::thread(thread1);

    t1.join();
    t2.join();
}

// > g++ -std=c++17 -lpthread scratchpad.cpp; ./a.out
// thread1: complete
// myclass deleted
// thread2: complete

在这个例子中,首先创建了第一个线程,它创建了一个指向myclass的指针。然后创建第二个线程,并将新创建的指针传递给这个第二个线程。两个线程对指针执行一系列操作,然后完成。问题在于,第一个线程没有第二个线程那么多的工作要做,所以它很快就完成了,释放了指针,而第二个线程还没有完成的机会,因为在这种情况下,我们明确声明thread1是指针的所有者,而thread2只是指针的使用者。

为了解决这个问题,C++提供了第二个智能指针,称为std::shared_ptr{},它能够将所有权分配给多个实体。std::shared_ptr{}的语法几乎与std::unique_ptr{}相同。

#include <memory>
#include <iostream>

int main()
{
    auto ptr = std::make_shared<int>();
    std::cout << ptr.get() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x562e6ba9ce80

在内部,std::shared_ptr{}将托管对象保存在一个单独的对象中,该对象在所有原始std::shared_ptr{}的副本之间共享。这个托管对象存储了std::shared_ptr{}副本的总数。每次创建一个副本,托管对象内的计数就会增加。当std::shared_ptr{}需要访问指针本身时,它必须使用指向托管对象的指针来请求指针(也就是说,std::shared_ptr{}并不存储指针本身,而是存储指向存储指针的托管对象的指针)。每次销毁std::shared_ptr{}时,托管对象的计数都会减少,当计数达到 0 时,指针最终会被释放回堆。

使用这种模式,std::shared_ptr{}能够将单个指针的所有权提供给多个实体。以下是使用std::shared_ptr{}而不是std::unique_ptr{}重写前面的示例:

#include <thread>
#include <iostream>

class myclass
{
    int m_data{0};

public:

    ~myclass()
    {
        std::cout << "myclass deleted\n";
    }

    void inc()
    { m_data++; }
};

std::thread t1;
std::thread t2;

void
thread2(const std::shared_ptr<myclass> ptr)
{
    for (auto i = 0; i < 100000; i++) {
        ptr->inc();
    }

    std::cout << "thread2: complete\n";
}

void
thread1()
{
    auto ptr = std::make_shared<myclass>();
    t2 = std::thread(thread2, ptr);

    for (auto i = 0; i < 10; i++) {
        ptr->inc();
    }

    std::cout << "thread1: complete\n";
}

int main()
{
    t1 = std::thread(thread1);

    t1.join();
    t2.join();
}

// > g++ -std=c++17 -lpthread scratchpad.cpp; ./a.out
// thread1: complete
// thread2: complete
// myclass deleted

正如这个例子所示,thread2得到了原始std::shared_ptr{}的一个副本,实际上创建了指向单个托管对象的两个副本。当thread1完成时,thread2仍然保持对托管对象的引用,因此指针保持完好。直到第二个线程完成,托管对象的引用计数达到 0,指针才会被释放回堆。

需要注意的是,std::shared_ptr{}也存在一些缺点:

  • 内存占用:由于std::shared_ptr{}保持对托管对象的指针,std::shared_ptr{}可能会导致两次 malloc 而不是一次(一些实现能够分配单个更大的内存块,并将其用于指针和托管对象)。无论实现方式如何,std::shared_ptr{}所需的内存量都大于std::unique_ptr{},通常与常规 C 风格指针的大小相同。

  • 性能:所有对指针的访问都必须首先重定向到托管对象,因为std::shared_ptr{}实际上并没有指针本身的副本(只有指向托管对象的指针)。因此,需要额外的函数调用(即指针解引用)。

  • 内存泄漏:在管理内存的方式上,std::unique_ptr{}std::shared_ptr{}之间存在权衡,两者都不能提供完美的解决方案,既能防止可能的nullptr解引用,又能防止内存泄漏。正如所示,在某些情况下使用std::unique_ptr{}可能会导致nullptr解引用。另一方面,std::shared_ptr{}可能会导致内存泄漏,如果std::shared_ptr{}的副本数量从未达到 0。尽管存在这些智能指针的问题,手动使用new()/delete()并不能解决这些问题(几乎肯定会使问题变得更糟),通常情况下,如果在正确的场景中使用正确的智能指针类型,这些问题可以得到缓解。

  • 循环引用:使用std::shared_ptr{}可以创建循环引用。

std::unique_ptr{}一样,std::shared_ptr{}提供了一个reset()函数:

#include <memory>
#include <iostream>

int main()
{
    auto ptr1 = std::make_shared<int>();
    auto ptr2 = ptr1;
    std::cout << ptr1.get() << '\n';
    std::cout << ptr2.get() << '\n';
    ptr2.reset();
    std::cout << ptr1.get() << '\n';
    std::cout << ptr2.get() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x555b99574e80
// 0x555b99574e80
// 0x555b99574e80
// 0

在这个例子中,创建了两个std::shared_ptr{}的副本。我们首先将这些指针的地址输出到stdout,如预期的那样,地址是有效的,它们是相同的(因为它们都指向同一个托管对象)。接下来,使用reset()函数释放第二个指针,然后再次输出指针的地址。第二次,第一个std::shared_ptr{}仍然指向有效指针,而第二个指向nullptr,因为它不再引用原始托管对象。当main()函数完成时,指针最终将被释放到堆上。

C++17 版本的std::shared_ptr{}的一个问题是缺乏类似std::unique_ptr{}的数组版本。也就是说,没有std::shared_ptr<type[]>版本的std::shared_ptr{},类似于std::unique_ptr<type[]>{}的 API。因此,无法使用std::make_shared()来分配数组,并且没有下标运算符来访问数组中的每个元素。相反,必须执行以下操作:

#include <memory>
#include <iostream>

int main()
{
    auto ptr = std::shared_ptr<int>(new int[42]());
    std::cout << ptr.get()[0] << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0

C++还提供了一种确定std::shared_ptr{}存在多少个副本的方法(实质上只是询问托管对象的引用计数):

#include <memory>
#include <iostream>

int main()
{
    auto ptr1 = std::make_shared<int>();
    auto ptr2 = ptr1;
    std::cout << ptr1.get() << '\n';
    std::cout << ptr2.get() << '\n';
    std::cout << ptr1.use_count() << '\n';
    ptr2.reset();
    std::cout << ptr1.get() << '\n';
    std::cout << ptr2.get() << '\n';
    std::cout << ptr1.use_count() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x5644edde7e80
// 0x5644edde7e80
// 2
// 0x5644edde7e80
// 0
// 1

这个示例与前面的reset()示例类似,但增加了对use_count()函数的调用,该函数报告std::shared_ptr{}的总副本数。如示例所示,当创建两个std::shared_ptr{}的副本时,use_count()报告2。当运行reset()时,use_count()减少为1,最终当main()完成时,此计数将减少为0,指针将被释放到堆上。应该注意,在多线程环境中应谨慎使用此函数,因为可能会发生关于报告的计数的竞争。

std::unique_ptr{}类似,std::shared_ptr{}也提供了一个布尔运算符来检查指针是否有效。与std::unique_ptr{}不同,布尔运算符不确定托管对象是否已被释放(因为可能有一个std::shared_ptr{}的副本在某个地方)。相反,布尔运算符报告std::shared_ptr{}是否在维护对托管对象的引用。如果std::shared_ptr{}有效,则它引用托管对象(因此可以访问分配的指针),布尔运算符报告true。如果std::shared_ptr{}无效,则不再维护对托管对象的引用(因此无法访问分配的指针),调用get()时返回nullptr,布尔运算符报告false

#include <memory>
#include <iostream>

int main()
{
    auto ptr = std::make_shared<int>();
    if (ptr) {
        std::cout << "before: " << ptr.get() << '\n';
    }
    ptr.reset();
    if (ptr) {
        std::cout << "after: "<< ptr.get() << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// before: 0x55ac226b5e80

如前面的示例所示,当调用reset()函数时,指针将不再有效,因为智能指针内部管理的对象现在指向nullptr,因此布尔运算符返回false。由于没有其他std::shared_ptr{}的副本(即,托管对象的计数为0),分配的指针也将被释放到堆上。

std::unique_ptr{}一样,std::shared_ptr{}提供了*->运算符来取消引用std::shared_ptr{}(但不提供下标运算符,因为不支持数组):


#include <memory>
#include <iostream>

struct mystruct {
    int data;
};

int main()
{
    auto ptr = std::make_shared<mystruct>();
    std::cout << ptr->data << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0

最后,std::shared_ptr{}的一个问题是循环引用。以下示例最好地描述了这个问题:

#include <memory>
#include <iostream>

class myclass2;

class myclass1
{
public:

    ~myclass1()
    {
        std::cout << "delete myclass1\n";
    }

    std::shared_ptr<myclass2> m;
};

class myclass2
{
public:

    ~myclass2()
    {
        std::cout << "delete myclass2\n";
    }

    std::shared_ptr<myclass1> m;
};

int main()
{
    auto ptr1 = std::make_shared<myclass1>();
    auto ptr2 = std::make_shared<myclass2>();
    ptr1->m = ptr2;
    ptr2->m = ptr1;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out

在这个例子中,创建了两个类——myclass1myclass2myclass1myclass2都维护对彼此的std::shared_ptr{}引用(也就是说,出于某种原因,两个类都声称拥有对另一个类的所有权)。当指针被销毁时,没有内存被释放到堆上,因为没有一个析构函数被调用。要理解原因,我们需要分解所做的副本数量以及它们存在的位置。

ptr1ptr2的原始std::shared_ptr{}都是在main()函数中创建的,这意味着#1#2管理的对象在创建时都有use_count()1。接下来,ptr1得到了ptr2std::shared_ptr{}的副本,反之亦然,这意味着#1#2管理的对象现在都有use_count()2。当main()完成时,main()函数中的ptr2std::shared_ptr{}被销毁(而不是ptr1中的std::shared_ptr{}),但由于ptr1中仍然有ptr2std::shared_ptr{}的副本,指针本身并没有被释放。接下来,main()中的ptr1被销毁,但由于ptr1的副本仍然存在于ptr1的一个副本中,ptr1本身也没有被释放,因此,我们创建了一个指向彼此的ptr1ptr2的副本,但代码本身没有剩余的这些指针的副本来释放这个内存,因此内存被永久删除。

为了解决这个问题,std::shared_ptr{}提供了一个称为std::weak_ptr{}的版本。它具有std::shared_ptr{}的所有属性,但不会增加托管对象的引用计数。虽然get()函数可以用来存储原始指针,但std::weak_ptr{}仍然与托管对象保持连接,提供了一种确定托管对象是否已被销毁的方法,这是使用原始指针无法做到的。为了证明这一点,前面的例子已经被转换为在myclass1myclass2中使用std::weak_ptr{}而不是std::shared_ptr{}

#include <memory>
#include <iostream>

class myclass2;

class myclass1
{
public:

    ~myclass1()
    {
        std::cout << "delete myclass1\n";
    }

    std::weak_ptr<myclass2> m;
};

class myclass2
{
public:

    ~myclass2()
    {
        std::cout << "delete myclass2\n";
    }

    std::weak_ptr<myclass1> m;
};

int main()
{
    auto ptr1 = std::make_shared<myclass1>();
    auto ptr2 = std::make_shared<myclass2>();
    ptr1->m = ptr2;
    ptr2->m = ptr1;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// delete myclass2
// delete myclass1

正如本例所示,即使存在循环引用,当main()完成时,分配的指针也会被释放回堆。最后,应该注意,可以使用以下语法将std::unique_ptr转换为std::shared_ptr

auto ptr = std::make_unique<int>();
std::shared_ptr<int> shared = std::move(ptr);

由于std::unique_ptr被移动,它不再拥有指针,而是std::shared_ptr现在拥有指针。从std::shared_ptr移动到std::unqiue_ptr是不允许的。

学习映射和权限

在本节中,读者将学习如何使用 C++模式映射内存。您将学习如何映射内存(一种常见的系统编程技术),同时使用 C++模式进行操作。

基础知识

malloc()/free()new()/delete()std::unique_ptr{}/std::shared_ptr{}并不是在 POSIX 系统上分配内存的唯一方法。C++风格的分配器是另一种更复杂的分配内存的方法,将在第九章中更详细地讨论,分配器的实践方法。一种更直接的、POSIX 风格的分配内存的方法是使用mmap()

#include <iostream>
#include <sys/mman.h>

constexpr auto PROT_RW = PROT_READ | PROT_WRITE;
constexpr auto MAP_ALLOC = MAP_PRIVATE | MAP_ANONYMOUS;

int main()
{
    auto ptr = mmap(0, 0x1000, PROT_RW, MAP_ALLOC, -1, 0);
    std::cout << ptr << '\n';

    munmap(ptr, 0x1000);
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x7feb41ab6000

mmap()函数可以用来将来自不同来源的内存映射到程序中。例如,如果要将设备内存映射到应用程序中,可以使用mmap()。如果将MAP_ANONYMOUS传递给mmap(),它可以用来分配内存,就像使用malloc()free()分配内存一样。在前面的例子中,mmap()用于分配一个标记为读/写的 4k 页面的内存。使用MAP_PRIVATE告诉mmap()您不打算与其他应用程序共享此内存(例如,用于进程间通信)。与使用malloc()/free()分配内存相比,以这种方式映射内存有一些优点和缺点。

优点

  • 碎片化:使用MAP_ANONYMOUS分配内存通常会将内存映射为页面大小的倍数,或者在最坏的情况下,是 2 的幂。这是因为mmap()正在向操作系统内核请求一个内存块,而该内存必须映射到应用程序中,这只能以不小于一个页面的块来完成。因此,与通常使用malloc()进行多次随机内存分配相比,这种内存的碎片化可能性要小得多。

  • 权限:在使用mmap()时,您可以指定要应用于新分配内存的权限。如果您需要具有特殊权限的内存,例如读/执行内存,这将非常有用。

  • 共享内存:使用mmap()分配的内存也可以被另一个应用程序共享,而不是为特定应用程序私有分配,就像使用malloc()一样。

缺点

  • 性能malloc()/free()分配和释放由应用程序内部的 C 库管理的内存块。如果需要更多内存,C 库将调用操作系统,使用诸如brk()甚至mmap()的函数,从操作系统获取更多内存。调用 free 时,释放的内存将返回到由 C 库管理的内存中,并且在许多情况下实际上从未返回到操作系统。因此,malloc()/free()可以快速为应用程序分配内存,因为不会进行任何特定于操作系统的调用(除非当然 C 库耗尽内存)。另一方面,mmap()必须在每次分配时调用操作系统。因此,它的性能不如malloc()/free(),因为操作系统调用可能很昂贵。

  • 粒度:与mmap()减少碎片化的原因相同,它也减少了粒度。mmap()进行的每次分配至少是一个页面大小,即使请求的内存只有一个字节。

为了演示mmap()的潜在浪费,请参阅以下内容:

#include <iostream>
#include <sys/mman.h>

constexpr auto PROT_RW = PROT_READ | PROT_WRITE;
constexpr auto MAP_ALLOC = MAP_PRIVATE | MAP_ANONYMOUS;

int main()
{
    auto ptr1 = mmap(0, 42, PROT_RW, MAP_ALLOC, -1, 0);
    auto ptr2 = mmap(0, 42, PROT_RW, MAP_ALLOC, -1, 0);

    std::cout << ptr1 << '\n';
    std::cout << ptr2 << '\n';

    munmap(ptr1, 42);
    munmap(ptr2, 42);
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x7fc1637ad000
// 0x7fc1637ac000

在此示例中,分配了 42 字节两次,但生成的地址相隔 4k 页。这是因为由mmap()进行的分配必须至少是一个页面大小,即使请求的数量仅为 42 字节。malloc()/free()没有这种浪费的原因是这些函数一次从操作系统请求大块内存,然后在 C 库内部使用各种不同的分配方案管理这些内存。有关如何执行此操作的更多信息,在newlib中有一个关于此主题的非常好的解释:sourceware.org/git/?p=newlib-cygwin.git;a=blob;f=newlib/libc/stdlib/malloc.c.

权限

mmap()可用于使用特殊参数分配内存。例如,假设您需要分配具有读/执行权限而不是通常与malloc()/free()相关联的读/写权限的内存:

#include <iostream>
#include <sys/mman.h>

constexpr auto PROT_RE = PROT_READ | PROT_EXEC;
constexpr auto MAP_ALLOC = MAP_PRIVATE | MAP_ANONYMOUS;

int main()
{
    auto ptr = mmap(0, 0x1000, PROT_RE, MAP_ALLOC, -1, 0);
    std::cout << ptr << '\n';

    munmap(ptr, 0x1000);
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x7feb41ab6000

如所示,使用读/执行权限分配内存与使用读/写权限分配内存相同,将PROT_WRITE替换为PROT_EXEC

在支持读/写或读/执行(也称为 W^E,表示写与执行互斥)的系统上,不应同时使用写和执行权限。特别是在程序被恶意使用的情况下,防止可执行内存同时具有写权限可以防止许多已知的网络攻击。

将内存分配为只读/执行而不是读/写/执行的问题在于,没有简单的方法将可执行代码放入新分配的缓冲区中,因为内存被标记为只读/执行。如果您希望分配只读内存也是如此。再次,由于从未添加写权限,因此无法向只读内存添加数据,因为它没有写权限。

为了解决这个问题,一些操作系统阻止应用程序分配读/写/执行内存,因为它们试图强制执行 W^E 权限。为了克服这个问题,同时仍然提供设置所需权限的手段,POSIX 提供了mprotect(),它允许您更改已经分配的内存的权限。尽管这可能与由malloc()/free()管理的内存一起使用,但它应该与mmap()一起使用,因为大多数体系结构上的页面级别上只能强制执行内存权限。malloc()/free()从一个大缓冲区中分配,该缓冲区在程序的所有分配之间共享,而mmap()只分配页面粒度的内存,因此不会被其他分配共享。

以下是如何使用mprotect的示例:

#include <iostream>
#include <sys/mman.h>

constexpr auto PROT_RW = PROT_READ | PROT_WRITE;
constexpr auto MAP_ALLOC = MAP_PRIVATE | MAP_ANONYMOUS;

int main()
{
    auto ptr = mmap(0, 0x1000, PROT_RW, MAP_ALLOC, -1, 0);
    std::cout << ptr << '\n';

    if (mprotect(ptr, 0x1000, PROT_READ) == -1) {
        std::clog << "ERROR: Failed to change memory permissions\n";
        ::exit(EXIT_FAILURE);
    }

    munmap(ptr, 0x1000);
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 0x7fb05b4b6000

在这个例子中,mmap()用于分配一个大小为 4k 页面的缓冲区,并具有读/写权限。分配内存后,mprotect()用于将内存的权限更改为只读。最后,munmap()用于将内存释放回操作系统。

智能指针和 mmap()

就 C++而言,mmap()munmap()的最大问题是它们遭受了与malloc()/free()相同的许多缺点:

  • 内存泄漏:由于mmap()munmap()必须手动执行,用户可能会忘记在不再需要内存时调用munmap(),或者复杂的逻辑错误可能导致在正确的时间不调用munmap()

  • 内存不匹配mmap()的用户可能会错误地调用free()而不是munmap(),这几乎肯定会导致不稳定,因为来自mmap()的内存来自操作系统内核,而free()期望来自应用程序堆的内存。

为了克服这个问题,mmap()应该用std::unique_ptr{}包装:

#include <memory>
#include <iostream>

#include <string.h>
#include <sys/mman.h>

constexpr auto PROT_RW = PROT_READ | PROT_WRITE;
constexpr auto MAP_ALLOC = MAP_PRIVATE | MAP_ANONYMOUS;

class mmap_deleter
{
    std::size_t m_size;

public:
    mmap_deleter(std::size_t size) :
        m_size{size}
    { }

    void operator()(int *ptr) const
    {
        munmap(ptr, m_size);
    }
};

template<typename T, typename... Args>
auto mmap_unique(Args&&... args)
{
    if (auto ptr = mmap(0, sizeof(T), PROT_RW, MAP_ALLOC, -1, 0)) {

        auto obj = new (ptr) T(args...);
        auto del = mmap_deleter(sizeof(T));

        return std::unique_ptr<T, mmap_deleter>(obj, del);
    }

    throw std::bad_alloc();
}

int main()
{
    auto ptr = mmap_unique<int>(42);
    std::cout << *ptr << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// 42

在这个例子中,主函数调用mmap_unique()而不是std::make_unqiue(),因为std::make_unique()使用new()/delete()分配内存,而我们希望使用mmap()/munmap()mmap_unique()函数的第一部分使用mmap()分配内存的方式与我们之前的例子相同。在这种情况下,权限被设置为读/写,但也可以使用mprotect()进行更改,以提供只读或读/执行权限。如果mmap()调用失败,就像 C++库一样,会抛出std::bad_alloc()

在这个例子中的下一行使用了new()放置运算符,如前面在放置 new部分中讨论的。这个调用的目标是创建一个对象,其构造函数已被调用以初始化所需的T类型。在这个例子中,这是将一个整数设置为42,但如果使用的是类而不是整数,类的构造函数将被调用,并传递给mmap_unique()的任何参数。

下一步是为我们的std::unqiue_ptr{}创建自定义删除器。这是因为默认情况下,std::unqiue_ptr{}将调用delete()运算符而不是munmap()。自定义删除器接受一个参数,即原始分配的大小。这是因为munmap()需要知道原始分配的大小,而delete()free()只需要一个指针。

最后,使用新创建的对象和自定义删除器创建了std::unique_ptr{}。从这一点开始,使用mmap()分配的所有内存都可以使用标准的std::unique_ptr{}接口访问,并被视为正常分配。当指针不再需要,并且std::unique_ptr{}超出范围时,将调用munmap()将指针释放回操作系统内核。

共享内存

除了分配内存外,mmap()还可用于分配共享内存,通常用于进程间通信。为了演示这一点,我们首先定义一个共享内存名称"/shm",以及我们的读取、写入和执行权限:

#include <memory>
#include <iostream>

#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <sys/mman.h>

constexpr auto PROT_RW = PROT_READ | PROT_WRITE;

auto name = "/shm";

接下来,我们必须定义我们的自定义删除器,它使用munmap()而不是free()

class mmap_deleter
{
    std::size_t m_size;

public:
    mmap_deleter(std::size_t size) :
        m_size{size}
    { }

    void operator()(int *ptr) const
    {
        munmap(ptr, m_size);
    }
};

在这个例子中,我们基于之前的例子,但现在不再只有一个mmap_unique()函数,而是有一个服务器版本和一个客户端版本。尽管通常共享内存会用于进程间通信,在这个例子中,我们在同一个应用程序中共享内存,以保持简单。

main函数创建了一个服务器和一个客户端共享指针。服务器版本使用以下方式创建共享内存:

template<typename T, typename... Args>
auto mmap_unique_server(Args&&... args)
{
  if(int fd = shm_open(name, O_CREAT | O_RDWR, 0644); fd != -1) {
      ftruncate(fd, sizeof(T));

        if (auto ptr = mmap(0, sizeof(T), PROT_RW, MAP_SHARED, fd, 0)) {

            auto obj = new (ptr) T(args...);
            auto del = mmap_deleter(sizeof(T));

            return std::unique_ptr<T, mmap_deleter>(obj, del);
        }
    }

    throw std::bad_alloc();
}

这个函数类似于之前例子中的mmap_unique()函数,但是打开了一个共享内存文件的句柄,而不是使用MAP*_*ANONYMOUS来分配内存。为了打开共享内存文件,我们使用POSIX shm_open()函数。这个函数类似于open()函数。第一个参数是共享内存文件的名称。第二个参数定义了文件的打开方式,而第三个参数提供了模式。shm_open()用于打开共享内存文件,并检查文件描述符以确保分配成功(即文件描述符不是-1)。

接下来,文件描述符被截断。这确保了共享内存文件的大小等于我们希望共享的内存大小。在这种情况下,我们希望共享一个单一的T类型,所以我们需要获取T的大小。一旦共享内存文件的大小被正确调整,我们需要使用mmap()映射共享内存。对mmap()的调用与我们之前的示例相同,唯一的区别是使用了MAP_SHARED

最后,就像之前的例子一样,我们利用new()放置运算符在共享内存中创建新分配的类型,我们创建自定义删除器,然后最后,我们返回std::unique_ptr{}以用于这个共享内存。

连接到这个共享内存(可以从另一个应用程序中完成),我们需要使用mmap_unique()函数的客户端版本:

template<typename T>
auto mmap_unique_client()
{
  if(int fd = shm_open(name, O_RDWR, 0644); fd != -1) {
      ftruncate(fd, sizeof(T));

        if (auto ptr = mmap(0, sizeof(T), PROT_RW, MAP_SHARED, fd, 0)) {

            auto obj = static_cast<T*>(ptr);
            auto del = mmap_deleter(sizeof(T));

            return std::unique_ptr<T, mmap_deleter>(obj, del);
        }
    }

    throw std::bad_alloc();
}

这些函数的服务器和客户端版本看起来相似,但也有区别。首先,共享内存文件是在没有O_CREAT的情况下打开的。这是因为服务器创建共享内存文件,而客户端连接到共享内存文件,因此在客户端版本中不需要传递O_CREAT。最后,这个函数的客户端版本的签名不像服务器版本那样带有任何参数。这是因为服务器版本使用new()放置来初始化共享内存,不需要再次执行。而不是使用新的放置,static_cast()被用来将void *转换为适当的类型,然后将指针传递给新创建的std::unique_ptr{}

int main()
{
    auto ptr1 = mmap_unique_server<int>(42);
    auto ptr2 = mmap_unique_client<int>();
    std::cout << *ptr1 << '\n';
    std::cout << *ptr2 << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lrt; ./a.out
// 42
// 42

这个例子的结果是,内存在服务器和客户端之间共享,将共享内存包装在std::unique_ptr{}中。此外,正如例子中所示,内存被正确共享,可以看到服务器和客户端版本的指针都打印出42。尽管我们用于整数类型,但这种类型的共享内存可以根据需要与任何复杂类型一起使用(尽管在尝试共享类时应该小心,特别是那些利用继承并包含vTable的类)。

学习内存碎片化的重要性

没有关于内存管理的章节是完整的,而没有对碎片的简要讨论。内存碎片指的是一种将内存分割成块的过程,通常是分散的,几乎总是导致分配器无法为应用程序分配内存,最终导致在 C++中抛出std::bad_alloc()。在编程系统时,碎片应该始终是一个关注点,因为它可能会极大地影响程序的稳定性和可靠性,特别是在资源受限的系统上,比如嵌入式和移动应用程序。在本节中,读者将简要介绍碎片,以及它如何影响他们创建的程序。

有两种类型的碎片——外部碎片和内部碎片。

外部碎片

外部碎片指的是内存分配和释放的过程,以不同大小的块进行,最终导致大量不可用的未分配内存。为了证明这一点,假设我们有五次分配:

所有五次分配都成功了,所有内存都被分配了。现在,假设第二次和第四次分配被释放回堆:

通过将内存释放回堆,内存现在可以再次用于分配。问题在于,由于最初的 1、3 和 5 次分配,这些内存是分散的。现在假设我们想进行最后一次分配:

最终的分配失败了,即使有足够的空闲内存进行分配,因为空闲内存是分散的——换句话说,空闲内存是碎片化的。

在一般情况下,外部碎片是一个极其难以解决的问题,这个问题已经研究了多年,操作系统随着时间的推移实施了各种不同的方法。在第九章中,《分配器的实践方法》,我们将讨论如何使用 C++分配器来解决程序中一些外部碎片问题,使用各种不同的自定义分配器模式。

内部碎片

内部碎片指的是在分配过程中浪费的内存。例如,当我们使用mmap()分配一个整数时,就像我们在前面的例子中所做的那样,mmap()为整数分配了整个页面,从而在过程中浪费了将近 4k 的内存。这就是所谓的内部碎片:

与外部碎片一样,内部碎片的丢失内存也不能用于其他分配。事实上,从高层次上看,内存的视图看起来就像外部碎片一样。不同之处在于,外部碎片不断地将大块的空闲未分配内存分割成越来越小的碎片内存,最终变得太小而无法在将来分配。内部碎片看起来也是一样的,但在某些情况下,甚至更大的不可用内存块会出现在整个内存中。这些不可用的内存不是因为它对于给定的分配来说不够大,而是因为不可用的内存已经被较小的先前分配所占用,而这些分配根本没有使用它所获得的所有内存。

应该注意的是,在解决碎片问题时,通常的解决方案是优化一种类型的碎片而不是另一种,每种选择都有其优点和缺点。

内部碎片和外部碎片

malloc()free()使用的分配器通常更倾向于优化内部碎片而不是外部碎片。目标是提供一个尽可能少浪费的分配器,然后利用各种不同的分配模式来尽可能减少外部碎片的可能性。这些类型的分配器被应用程序所青睐,因为它们最小化了在任何给定操作系统上单个应用程序的内存需求,为其他应用程序留下了额外的内存。此外,如果外部碎片化阻止了分配的发生,应用程序总是向操作系统请求更多的内存(直到操作系统用尽)。

外部碎片优于内部碎片

操作系统倾向于优化外部碎片而不是内部碎片。这是因为操作系统通常只能以页面粒度分配内存,这意味着在许多情况下内部碎片是不可避免的。此外,如果允许外部碎片随时间发生,最终会导致操作系统崩溃。因此,操作系统使用分配模式,如伙伴分配器模式,它优化外部碎片,即使以牺牲大量内部碎片为代价。

摘要

在本章中,我们学习了使用new()delete()malloc()free()来分配内存的各种方法,包括对齐内存和 C 风格数组。我们研究了全局内存(全局空间中的内存)、堆栈内存(或作用域内存)和动态分配内存(使用new()delete()分配的内存)之间的区别。还讨论了new()delete()的安全性问题,并演示了 C++智能指针,包括std::shared_ptr{}std::unique_ptr{},如何防止程序中常见的不稳定性问题,以及它们如何提供 C++核心指导支持。我们通过快速回顾碎片化以及它如何影响系统程序来结束本章。

在下一章中,我们将涵盖文件输入和输出,包括读写文件以及 C++17 添加的文件系统 API。

问题

  1. new()new[]()之间有什么区别?

  2. delete()可以安全地用于释放使用new[]()分配的内存吗?

  3. 全局内存和静态内存之间有什么区别?

  4. 如何使用new()分配对齐内存?

  5. std::make_shared()可以用来分配数组吗?

  6. 在什么情况下应该使用std::shared_ptr{}而不是std::unique_ptr{}

  7. mmap()可以用来分配读/执行内存吗?

  8. 内部碎片和外部碎片之间有什么区别?

进一步阅读

第八章:学习编程文件输入/输出

文件输入/输出(I/O)是大多数系统级程序的重要部分。它可以用于调试、保存程序状态、处理特定于用户的数据,甚至与物理设备进行交互(由于 POSIX 块和字符设备)。

在 C++17 之前,处理文件 I/O 是困难的,因为文件系统管理必须使用非 C++ API 来处理,这些 API 通常不安全、特定于平台,甚至不完整。

在本章中,我们将提供一个实际操作的回顾,介绍如何打开、读取和写入文件,以及处理路径、目录和文件系统。最后,我们将提供三个不同的示例,演示如何记录到文件、追踪现有文件和对 C++文件输入/输出 API 进行基准测试。

本章将涵盖以下主题:

  • 打开文件的方式

  • 读取和写入文件

  • 文件工具

技术要求

为了编译和执行本章中的示例,读者必须具备以下条件:

  • 一个能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请参见以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter08

打开文件

打开文件的多种方式。我们将在以下部分讨论其中一些,并介绍如何使用std::fstream C++ API 来实现。

打开文件的不同方式

在 C++中打开文件就像提供一个std::fstream对象和你想要打开的对象的文件名和路径一样简单。示例如下:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        std::cout << "success\n";
    }
    else {
        std::cout << "failure\n";
    }
}

// > g++ -std=c++17 scratchpad.cpp; touch test.txt; ./a.out
// success

在这个例子中,我们打开一个名为test.txt的文件,之前使用 POSIX 的touch命令创建过。这个文件以读/写权限打开(因为这是默认模式)。

文件存储在名为file的变量中,并使用std::fstream提供的 bool 运算符重载来确保它已经正确打开。如果文件成功打开,我们将success输出到stdout

前面的例子利用了std::fstream对象具有重载的bool运算符,当文件成功打开时返回 true。更明确地执行此操作的另一种方法是使用is_open()函数,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt"); file.is_open()) {
        std::cout << "success\n";
    }
}

// > g++ -std=c++17 scratchpad.cpp; touch test.txt; ./a.out
// success

在前面的例子中,我们不是依赖于bool运算符重载,而是利用 C++17 在if语句中使用is_open()来检查文件是否打开。前面的例子通过使用构造函数初始化std::fstream来进一步简化,而不是显式调用open(),如下所示:

#include <fstream>
#include <iostream>

int main()
{
    auto file = std::fstream();
    if (file.open("test.txt"); file.is_open()) {
        std::cout << "success\n";
    }
}

// > g++ -std=c++17 scratchpad.cpp; touch test.txt; ./a.out
// success

在这个例子中,std::fstream对象是使用默认构造函数创建的,这意味着还没有打开文件,允许我们在准备好时再打开文件。然后我们使用open()函数打开文件,然后,类似于前面的例子,我们利用 C++17 来检查文件是否打开,然后将success输出到stdout

在所有前面的例子中,不需要在文件上调用close()。这是因为,像其他 C++类(如利用 RAII 的std::unique_ptr)一样,std::fstream对象在销毁时会自动关闭文件。

然而,如果需要的话,可以显式关闭文件,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    std::cout << std::boolalpha;

    if (auto file = std::fstream("test.txt")) {
        std::cout << file.is_open() << '\n';
        file.close();
        std::cout << file.is_open() << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; touch test.txt; ./a.out
// true
// false

在这个例子中,我们打开一个文本文件并使用is_open()来检查文件是否打开。第一次使用is_open()返回 true,因为文件成功打开。然后我们使用close()显式关闭文件,然后再次使用is_open()检查文件是否打开,现在返回 false。

打开文件的模式

到目前为止,我们一直使用默认模式打开文件。有两种模式可以用来打开文件:

  • std::ios::in:打开文件以供读取

  • std::ios::out:打开文件以供写入

此外,还有几种其他模式可以与这两种模式结合使用,以修改文件的打开方式:

  • std::ios::binary:以二进制方式打开文件。默认情况下,std::fstream处于文本模式,该模式适用于使用换行符格式化文件以及可以读取/写入文件的字符类型的特定规则。这些规则通常适用于文本文件,但在尝试向文件读取/写入二进制数据时会导致问题。在这种情况下,应将std::ios::binary添加到模式说明符中。

  • std::ios::app:当此模式与std::ios::out一起使用时,对文件的所有写入都会追加到文件的末尾。

  • std::ios::ate:当此模式与std::ios::instd::ios::out一起使用时,文件在成功打开后定位在文件的末尾。也就是说,对文件的读取和写入发生在文件的末尾,即使在文件打开后立即进行。

  • std::ios::trunc:当此模式与std::ios::instd::ios::out一起使用时,打开文件之前会删除文件的内容。

为了演示这些模式,第一个示例以二进制模式打开文件进行读取:

#include <fstream>
#include <iostream>

int main()
{
    constexpr auto mode = std::ios::in | std::ios::binary;
    if (auto file = std::fstream("test.txt", mode)) {
        std::cout << "success\n";
    }
}

// > g++ -std=c++17 scratchpad.cpp; touch test.txt; ./a.out
// success

所有模式都是常量值,因此在前面的示例中,使用constexpr创建了一个名为mode的新常量,表示以只读、二进制模式打开文件。要以文本模式而不是二进制模式打开文件进行只读,请简单地删除std::ios::binary模式,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    constexpr auto mode = std::ios::in;
    if (auto file = std::fstream("test.txt", mode)) {
        std::cout << "success\n";
    }
}

// > g++ -std=c++17 scratchpad.cpp; touch test.txt; ./a.out
// success

在前面的示例中,我们以只读、文本模式打开文件。相同的逻辑也可以用于只写,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    constexpr auto mode = std::ios::out | std::ios::binary;
    if (auto file = std::fstream("test.txt", mode)) {
        std::cout << "success\n";
    }
}

// > g++ -std=c++17 scratchpad.cpp; touch test.txt; ./a.out
// success

在这里,我们以只写、二进制模式打开文件。要以只写、文本模式打开文件,请使用以下方法:

#include <fstream>
#include <iostream>

int main()
{
    constexpr auto mode = std::ios::out;
    if (auto file = std::fstream("test.txt", mode)) {
        std::cout << "success\n";
    }
}

// > g++ -std=c++17 scratchpad.cpp; touch test.txt; ./a.out
// success

再次,由于省略了std::ios::binary,这段代码以只写、文本模式打开文件。

要以只写、二进制模式在文件末尾(而不是默认的文件开头)打开文件,请使用以下方法:

#include <fstream>
#include <iostream>

int main()
{
    constexpr auto mode = std::ios::out | std::ios::binary | std::ios::ate;
    if (auto file = std::fstream("test.txt", mode)) {
        std::cout << "success\n";
    }
}

// > g++ -std=c++17 scratchpad.cpp; touch test.txt; ./a.out
// success

在此示例中,我们通过将std::ios::ate添加到模式变量中,在只写、二进制模式下打开文件,将文件移动到文件末尾。这将文件中的输出指针移动到文件的末尾,但允许在文件中的任何位置进行写入。

为了确保文件始终追加到文件的末尾,使用std::ios::app而不是std::ios::ate来打开文件,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    constexpr auto mode = std::ios::out | std::ios::binary | std::ios::app;
    if (auto file = std::fstream("test.txt", mode)) {
        std::cout << "success\n";
    }
}

// > g++ -std=c++17 scratchpad.cpp; touch test.txt; ./a.out
// success

在前面的示例中,由于使用了std::ios::app,文件的写入和添加总是追加到文件中。

应该注意,在所有先前使用std::ios::out的示例中,文件都是使用std::ios::trunc打开的。这是因为截断模式是在使用std::ios::out时的默认值,除非使用了std::ios::atestd::ios::app。这样做的问题在于,没有办法在文件开头以只写模式打开文件而不截断文件。

为了解决这个问题,可以使用以下方法:

#include <fstream>
#include <iostream>

int main()
{
    constexpr auto mode = std::ios::out | std::ios::binary | std::ios::ate;
    if (auto file = std::fstream("test.txt", mode); file.seekp(0)) {
        std::cout << "success\n";
    }
}

// > g++ -std=c++17 scratchpad.cpp; touch test.txt; ./a.out
// success

在此示例中,我们以只写、二进制模式在文件末尾打开文件,然后我们使用seekp()(稍后将解释的函数)将文件中的输出位置移动到文件的开头。

尽管std::ios::trunc是在使用std::ios::out时的默认值,但如果还使用了std::ios::in(即读/写模式),则必须显式添加std::ios::trunc,如果您希望在打开文件之前清除文件的内容,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    constexpr auto mode = std::ios::in | std::ios::out | std::ios::trunc;
    if (auto file = std::fstream("test.txt", mode)) {
        std::cout << "success\n";
    }
}

// > g++ -std=c++17 scratchpad.cpp; touch test.txt; ./a.out
// success

在这里,文件以读/写模式打开,并且在打开文件之前删除了文件的内容。

读取和写入文件

以下部分将帮助您了解如何使用std::fstream C++ API 读取和写入文件。

从文件中读取

C++提供了几种不同的方法来读取文件,包括按字段、按行和按字节数。

按字段读取

从文件中读取的最安全的方法是按字段,代码如下:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        std::string hello, world;
        file >> hello >> world;
        std::cout << hello << " " << world << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "Hello World" > test.txt; ./a.out
// Hello World

在这个例子中,我们打开一个文件进行读写(因为这是默认模式)。如果文件成功打开,我们将两个字符串分别读入两个变量——helloworld。要读取这两个字符串,我们使用>> operator(),它的行为就像第六章中讨论的std::cin一样。

对于字符串,流会读取字符,直到发现第一个空格或换行符。与std::cin一样,也可以读取数值变量,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        int answer;
        file >> answer;
        std::cout << "The answer is: " << answer << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "42" > test.txt; ./a.out
// The answer is: 42

在这个例子中,我们读取的是一个整数而不是一个字符串,就像读取字符串一样,流会读取字节,直到发现空格或换行符,然后将输入解释为一个数字。当然,如果被读取的字段不是一个数字,就会读取0,如下所示:

// > g++ -std=c++17 scratchpad.cpp; echo "not_a_number" > test.txt; ./a.out
// The answer is: 0

值得注意的是,当发生这种情况时会设置一个错误标志,我们将在本章后面讨论。

与其他 C++流一样,std::fstream可以被重载以支持用户定义的类型,如下所示:

#include <fstream>
#include <iostream>

struct myclass
{
    std::string hello;
    std::string world;
};

std::fstream &operator >>(std::fstream &is, myclass &obj)
{
    is >> obj.hello;
    is >> obj.world;

    return is;
}

std::ostream &operator<<(std::ostream &os, const myclass &obj)
{
    os << obj.hello;
    os << ' ';
    os << obj.world;

    return os;
}

int main()
{
    if (auto file = std::fstream("test.txt")) {
        myclass obj;
        file >> obj;
        std::cout << obj << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "Hello World" > test.txt; ./a.out
// Hello World

在这个例子中,我们创建了一个名为myclass的用户定义类型。在main()函数中,我们打开一个文件,如果文件成功打开,我们创建一个myclass{}对象,将文件读入myclass{}对象,然后将myclass{}对象的结果输出到stdout

为了将文件读入myclass{}对象中,我们重载了std::fstream{}>> operator(),它读取两个字符串,并将结果存储在myclass{}对象中。要将myclass{}对象输出到stdout,我们将在第六章中学到的内容进行扩展,即关于用户定义重载std::ostream的内容,并为我们的myclass{}对象提供用户定义的重载。

结果是从文件中读取Hello World并输出到stdout

读取字节

除了从文件中读取字段外,C++还提供了直接从文件中读取字节的支持。要从流中读取一个字节,使用get()函数,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        char c = file.get();
        std::cout << c << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "Hello World" > test.txt; ./a.out
// H

在 C++17 中读取多个字节仍然是一种不安全的操作,因为没有能力直接将x个字节读入std::string。这意味着必须使用标准的 C 风格缓冲区,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        char buf[25] = {};
        file.read(buf, 11);
        std::cout << buf << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "Hello World" > test.txt; ./a.out
// Hello World

在前面的例子中,我们创建了一个名为buf的标准 C 风格字符缓冲区,然后从文件中读取了 11 个字节到这个字符缓冲区中。最后,我们将结果输出到stdout

我们需要确保被读取的字节数不超过缓冲区本身的总大小——这种操作通常会导致编码错误,产生难以调试的缓冲区溢出。

解决这个问题的简单方法是使用一个包装器来包围read()函数,以确保请求的字节数不超过缓冲区的总大小,如下所示:

#include <fstream>
#include <iostream>

template<typename T, std::size_t N>
void myread(std::fstream &file, T (&str)[N], std::size_t count)
{
    if (count >= N) {
        throw std::out_of_range("file.read out of bounds");
    }

    file.read(static_cast<char *>(str), count);
}

int main()
{
    if (auto file = std::fstream("test.txt")) {
        char buf[25] = {};
        myread(file, buf, 11);
        std::cout << buf << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "Hello World" > test.txt; ./a.out
// Hello World

在这个例子中,我们创建了一个名为myread()的模板函数,在编译期间将缓冲区的总大小编码到函数本身中。在读取发生之前,可以检查缓冲区的大小,以确保不会发生缓冲区溢出。

值得注意的是,这对于数组来说效果很好,但对于动态分配的数组来说存在问题,因为缓冲区的总大小也必须传递给我们的包装器函数,可能会导致难以调试的逻辑错误(即未提供正确的缓冲区大小,交换要读取的字节数和缓冲区大小等)。

为了克服这些问题,应该使用gsl::span

当读取字节而不是字段时,了解当前正在读取文件的位置可能会有所帮助。当您从文件流中读取时,流内部会维护一个读指针和一个写指针。要获取当前的读位置,使用tellg()函数,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        std::cout << file.tellg() << '\n';
        char c = file.get();
        std::cout << file.tellg() << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "Hello World" > test.txt; ./a.out
// 0
// 1

在这里,我们像往常一样打开一个文件并输出当前的读指针,预期的是0。然后我们从文件中读取一个字符,并再次输出读指针。这次,指针是1,表示我们已成功读取了一个字节。

另一种读取单个字节的方法是使用peek函数,它的功能类似于get(),只是内部读指针不会增加,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        std::cout << file.tellg() << '\n';
        char c = file.peek();
        std::cout << file.tellg() << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "Hello World" > test.txt; ./a.out
// 0
// 0

这个例子与前一个例子相同,只是使用了peek()而不是get()。如所示,在使用peek()从缓冲区中读取一个字节之前和之后,读指针都是0,表明peek()不会增加流中的读指针。

C++也提供了相反的操作。除了从文件中读取一个字节而不移动读指针之外,还可以使用ignore()函数移动读指针而不从流中读取字节,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        std::cout << file.tellg() << '\n';
        file.ignore(1);
        std::cout << file.tellg() << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "Hello World" > test.txt; ./a.out
// 0
// 1

在这个例子中,我们通过一个字节移动文件流中的读指针,并使用tellg()来验证读指针是否实际上已经移动。ignore()函数相对于当前读指针增加读指针。

C++还提供了seekg()函数,它将读指针设置为绝对位置,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        std::string hello, world;

        file >> hello >> world;
        std::cout << hello << " " << world << '\n';

        file.seekg(1);

        file >> hello >> world;
        std::cout << hello << " " << world << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "Hello World" > test.txt; ./a.out
// Hello World
// ello World

在前面的例子中,seekg()函数用于在读取后将读指针设置为文件中的第 1 个字节,有效地倒带,使我们可以再次读取文件。

按行读取

最后,文件读取的最后一种类型是按行读取,这意味着您每次从文件中读取一行,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        char buf[25] = {};
        file.getline(buf, 25, '\n');
        std::cout << buf << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "Hello World" > test.txt; ./a.out
// Hello World

在这个例子中,我们创建了一个标准的 C 字符缓冲区,从文件中读取一行,并将该行输出到stdout。与read()函数不同,getline()会一直读取,直到达到缓冲区的大小(第二个参数),或者看到一个分隔符。

由于行的定义取决于您使用的操作系统(尽管在这种情况下,我们将坚持使用 Unix),getline()函数接受一个分隔符参数,允许您定义行的结束位置。

read()函数一样,这个操作是不安全的,因为它要求用户确保传递给getline()的总缓冲区大小实际上是缓冲区的总大小,从而提供了一个方便的机制来引入难以调试的缓冲区溢出。

read()函数不同,C++提供了getline()的非成员版本,它接受任何流类型(包括std::cin)和std::string,而不是标准的 C 风格字符串,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        std::string str;
        std::getline(file, str);
        std::cout << str << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "Hello World" > test.txt; ./a.out
// Hello World

在前面的例子中,我们没有调用file.getline(),而是调用了std::getline(),并提供了std::string,它可以根据需要读取的字节数动态更改其大小,从而防止可能的缓冲区溢出。

应该注意的是,为了实现这一点,std::string将自动为您执行new()/delete()操作,这可能会引入不可接受的低效率(特别是在系统编程方面)。在这种情况下,应该使用file.getline()版本,使用一个包装类,类似于我们在read()函数中所做的。

最后,如果对已经打开的文件进行了更改,以下操作将使当前流与这些更改同步:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        file.sync();
    }
}

如前面的代码所示,sync()函数可以用于将已经打开的文件与文件的更改重新同步。

写入文件

std::cin和文件读取一样,C++还提供了文件写入,其行为类似于std::cout。与读取不同,文件写入只有两种不同的模式——按字段和按字节。

按字段写入

要按字段写入文件,使用<< operator(),类似于std::cout,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        std::string hello{"Hello"}, world{"World"};
        file << hello << " " << world << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "" > test.txt; ./a.out; cat test.txt
// Hello World

在前面的例子中,我们像往常一样打开一个文件,然后创建了两个std::string对象,分别向这些字符串中添加了helloworld。最后,这些字符串被写入文件。请注意,不需要关闭或刷新文件,因为这在文件流对象销毁时会为我们完成。

std::cout一样,C++本身支持标准 C 字符缓冲区和数字类型,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        file << "The answer is: " << 42 << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "" > test.txt; ./a.out; cat test.txt
// The answer is: 42

在前面的例子中,我们直接向文件写入了一个标准 C 字符缓冲区和一个整数。对于写入,也支持用户定义的类型,如下所示:

#include <fstream>
#include <iostream>

struct myclass
{
    std::string hello{"Hello"};
    std::string world{"World"};
};

std::fstream &operator <<(std::fstream &os, const myclass &obj)
{
    os << obj.hello;
    os << ' ';
    os << obj.world;

    return os;
}

int main()
{
    if (auto file = std::fstream("test.txt")) {
        file << myclass{} << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "" > test.txt; ./a.out; cat test.txt
// Hello World

在这个例子中,我们打开一个文件,并向文件写入一个myclass{}对象。myclass{}对象是一个包含两个成员变量的结构体,这两个成员变量被初始化为HelloWorld。然后提供了一个用户定义的<< operator()重载,用于向提供的文件流写入myclass{}对象的内容,结果是将Hello World写入文件。

写入字节

除了按字段写入,还支持写入一系列字节。在下面的例子中,我们使用put()函数向文件写入一个字节(以及一个换行符),该函数类似于get(),但用于写入而不是读取:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        file.put('H');
        file.put('\n');
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "" > test.txt; ./a.out; cat test.txt
// H

多个字节也可以使用write()函数进行写入,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        file.write("Hello World\n", 12);
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "" > test.txt; ./a.out; cat test.txt
// Hello World

在前面的例子中,我们向文件写入了12字节(字符串Hello World的 11 个字符和一个额外的换行符)。

read()函数一样,write()函数是不安全的,应该进行包装,以确保写入文件的总字节数不超过缓冲区的总大小(否则会发生缓冲区溢出)。为了演示即使标准 C 风格的const字符缓冲区也是不安全的,可以参考以下内容:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        file.write("Hello World\n", 100);
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "" > test.txt; ./a.out; cat test.txt
// Hello World
// ;�����D���d)��������$=���DR����d���d�����[

正如本例所示,尝试从大小仅为13字节的标准 C const字符缓冲区中写入 100 个字节(Hello World的 11 个字节,1个换行符,1\0空终止符),会导致缓冲区溢出。在这种情况下,缓冲区溢出会导致损坏的字节被写入文件,最好的情况下会泄漏程序的部分内容,但也可能导致不稳定性,包括难以调试的分段错误。

为了克服这个问题,无论何时使用这些不安全的函数,都应该使用一个包装器,如下所示:

#include <string.h>

#include <fstream>
#include <iostream>

void
mywrite(std::fstream &file, const char *str, std::size_t count)
{
    if (count > strlen(str)) {
        throw std::out_of_range("file.write out of bounds");
    }

    file.write(str, count);
}

int main()
{
    if (auto file = std::fstream("test.txt")) {
        mywrite(file, "Hello World\n", 100);
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "" > test.txt; ./a.out; cat test.txt
// terminate called after throwing an instance of 'std::out_of_range'
// what(): file.write out of bounds
// Aborted (core dumped)

在前面的例子中,我们创建了一个write()函数的包装器,类似于之前创建的read()函数包装器。当我们尝试写入的字节数超过了标准 C const字符缓冲区的总大小时,我们会生成一个异常,该异常可用于跟踪错误,以确定我们尝试写入 100 个字节。

应该注意的是,这个包装器只适用于编译器生成的标准 C const字符缓冲区。可以手动声明这种类型的缓冲区,这种类型的函数将失败,如下所示:

#include <string.h>

#include <fstream>
#include <iostream>

void
mywrite(std::fstream &file, const char *str, std::size_t count)
{
    if (count > strlen(str)) {
    std::cerr << count << " " << strlen(str) << '\n';
        throw std::out_of_range("file.write out of bounds");
    }

    file.write(str, count);
}

int main()
{
    if (auto file = std::fstream("test.txt")) {
        const char str1[6] = {'H','e','l','l','o','\n'};
        const char str2[6] = {'#','#','#','#','#','\n'};
        mywrite(file, str1, 12);
        mywrite(file, str2, 6);
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "" > test.txt; ./a.out; cat test.txt
// Hello
// World
// World

在这个例子中,我们创建了两个标准的 C const 字符缓冲区。第一个缓冲区由单词Hello和一个换行符组成,第二个缓冲区由单词World和一个换行符组成。然后我们向文件写入Hello,但是我们写入的不是6个字符,而是12个字符。最后,我们向文件写入World,并提供了正确的字节数,即6

结果输出为Hello WorldWorld被写入文件两次。这是因为精心设计的缓冲区溢出。向文件的第一次写入将Hello写入缓冲区,但是提供给write()函数的是12个字节,而不是6个字节。在这种情况下,我们的包装器正在寻找空终止符,但这个终止符不存在(因为我们手动定义了标准 C const字符缓冲区,删除了空终止符)。

因此,mywrite()函数无法检测到溢出,并写入了两个缓冲区。

没有安全的方法可以克服这种问题(read()函数存在类似的问题),除非使用指导支持库、勤勉和能够检测到这些类型的缓冲区不安全使用的静态分析器(这对于静态分析器来说并不是一件微不足道的事情)。因此,通常情况下,应尽可能避免使用read()write()等函数,而应使用按字段和按行的替代方法。

tellg()类似,写流也可以使用tellp()函数获取当前写指针位置,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        std::cout << file.tellp() << '\n';
        file << "Hello";
        std::cout << file.tellp() << '\n';
        file << ' ';
        std::cout << file.tellp() << '\n';
        file << "World";
        std::cout << file.tellp() << '\n';
        file << '\n';
        std::cout << file.tellp() << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "" > test.txt; ./a.out; cat test.txt
// 0
// 5
// 6
// 11
// 12
// Hello World

在上述示例中,Hello World被写入文件,并且使用tellp()函数输出写指针位置,结果为0561112

还可以使用seekp()函数将写指针移动到文件中的绝对位置,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        std::cout << file.tellp() << '\n';
        file << "Hello World\n";
        std::cout << file.tellp() << '\n';
        file.seekp(0);
        std::cout << file.tellp() << '\n';
        file << "The answer is: " << 42 << '\n';
        std::cout << file.tellp() << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "" > test.txt; ./a.out; cat test.txt
// 0
// 12
// 0
// 18
// The answer is: 42

在此示例中,我们将Hello World写入文件,然后将流中的写指针移回文件的开头。然后我们将“答案是:42”写入文件。在此过程中,我们使用tellp()输出写指针的位置,显示了在执行这些操作时写指针的移动情况。

因此,文件包含“答案是:42”,而不是Hello World,因为Hello World被覆盖。

最后,与sync()函数一样,可以使用以下方法将文件的写入刷新到文件系统:

#include <fstream>
#include <iostream>

int main()
{
    if (auto file = std::fstream("test.txt")) {
        file.flush();
    }
}

应该注意的是,尽管可以手动刷新文件(例如,如果知道更改必须传输到文件系统),但是当std::fstream对象失去作用域并被销毁时,文件将自动关闭并刷新到文件系统。

在读取和写入时,可能会发生不同类型的错误。std::fstream提供了四个不同的函数来确定流的状态,如下所示:

  • good(): 如果此函数返回true,则没有发生错误,流也没有到达文件的末尾。

  • eof(): 如果此函数返回true,则已到达文件的末尾。内部错误不会影响此函数的结果。

  • fail(): 如果此函数返回true,则发生了内部错误,但流仍然可用,例如,如果发生数字转换错误。

  • bad(): 如果此函数返回true,则发生了错误,流不再可用,例如,如果流无法打开文件。

当正常的文件操作发生时,good()应该返回true,而其他三个状态函数应该返回false,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    std::cout << std::boolalpha;

    if (auto file = std::fstream("test.txt")) {
        std::string hello{"Hello"}, world{"World"};
        file << hello << " " << world << '\n';
        std::cout << "good: " << file.good() << '\n';
        std::cout << "fail: " << file.fail() << '\n';
        std::cout << "bad: " << file.bad() << '\n';
        std::cout << "eof: " << file.eof() << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "" > test.txt; ./a.out; cat test.txt
// good: true
// fail: false
// bad: false
// eof: false
// Hello World

在上述示例中,Hello World成功写入文件,导致good()返回true

除了使用good()函数外,可以使用! operator()来检测是否发生了错误,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    std::cout << std::boolalpha;

    if (auto file = std::fstream("test.txt")) {
        std::string hello{"Hello"}, world{"World"};
        file << hello << " " << world << '\n';
        if (!file) {
            std::cout << "failed\n";
        }
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "" > test.txt; ./a.out; cat test.txt
// Hello World

在这里,Hello World成功写入文件,因此good()函数返回true,这意味着! operator()返回false,导致failed字符串从未输出到stdout

类似地,可以使用bool运算符,其返回与good()相同的结果,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    std::cout << std::boolalpha;

    if (auto file = std::fstream("test.txt")) {
        std::string hello{"Hello"}, world{"World"};
        file << hello << " " << world << '\n';
        if (file) {
            std::cout << "success\n";
        }
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "" > test.txt; ./a.out; cat test.txt
// success
// Hello World

在上述代码中,Hello World成功写入文件,导致bool运算符返回true;这意味着good()函数也会返回true,因为它们返回相同的结果。

如果发生错误,错误状态将保持触发状态,直到流关闭,或者使用clear()函数告诉流已处理错误,如下所示:

#include <fstream>
#include <iostream>

int main()
{
    std::cout << std::boolalpha;

    if (auto file = std::fstream("test.txt")) {
        int answer;
        std::cout << file.good() << '\n';
        file >> answer;
        std::cout << file.good() << '\n';
        file.clear();
        std::cout << file.good() << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; echo "not_a_number" > test.txt; ./a.out
// true
// false
// true

在上述示例中,将一个字符串写入文本文件。此测试文件被打开以进行读取,并读取一个整数。问题在于写入文件的值实际上不是一个数字,导致文件流报告错误。

然后使用clear函数清除错误,之后good()函数继续报告true

理解文件实用程序

到目前为止,在 C++17 之前添加的所有 C++ API 都提供了描述的功能。尽管 C++提供了读写文件的能力,但它并没有提供管理文件系统所需的所有其他文件操作,包括文件路径、目录管理等。

本节将重点介绍 C++17 中的std::filesystem增强功能,以解决这些缺陷中的大部分。

路径

路径只不过是表示文件系统中节点的字符串。在 UNIX 系统上,这通常是一个由一系列目录名、/和文件名组成的字符串,通常带有扩展名。路径的目的是表示文件的名称和位置,然后可以用来对文件执行操作,如打开文件进行读写、更改文件的权限,甚至从文件系统中删除文件。

应该注意,路径可以表示文件系统中许多不同类型的节点,包括文件、目录、链接、设备等。更完整的列表将在本章后面呈现。考虑以下例子:

/home/user/

这是一个指向名为user的目录的路径,位于名为home的根目录中。现在考虑以下内容:

/home/user/test.txt

这指的是在同一目录中名为test.txt的文件。文件的主干是test,而文件的扩展名是.txt。此外,文件的根目录是/(这在大多数 UNIX 系统上都是这样)。

在 UNIX 系统上,路径可以采用不同的形式,包括以下内容:

  • 块设备:路径指向 POSIX 风格的块设备,如/dev/sda

  • 字符设备:路径指向 POSIX 风格的字符设备,如/dev/random

  • 目录:路径指向常规目录

  • Fifo:路径指向管道或其他形式的 IPC

  • 套接字:路径指向 POSIX 套接字

  • 符号链接:路径指向 POSIX 符号链接

  • 文件:路径指向常规文件

要确定路径的类型,C++17 提供了以下测试函数:

#include <iostream>
#include <filesystem>

int main()
{
    using namespace std::filesystem;

    std::cout << std::boolalpha;
    std::cout << is_block_file("/dev/sda1") << '\n';
    std::cout << is_character_file("/dev/random") << '\n';
    std::cout << is_directory("/dev") << '\n';
    std::cout << is_empty("/dev") << '\n';
    std::cout << is_fifo("scratchpad.cpp") << '\n';
    std::cout << is_other("scratchpad.cpp") << '\n';
    std::cout << is_regular_file("scratchpad.cpp") << '\n';
    std::cout << is_socket("scratchpad.cpp") << '\n';
    std::cout << is_symlink("scratchpad.cpp") << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// true
// true
// true
// false
// false
// false
// true
// false
// false

如前面的例子所示,/dev/sda是一个块设备,/dev/random是一个字符设备,/dev是一个非空目录,scratchpad.cpp文件用于编译本章中的所有示例,是一个常规文件。

要确定路径是否存在,C++17 提供了exists()函数,如下所示:

#include <iostream>
#include <filesystem>

int main()
{
 std::cout << std::boolalpha;
 std::cout << std::filesystem::exists("/dev") << '\n';
 std::cout << std::filesystem::exists("/dev/random") << '\n';
 std::cout << std::filesystem::exists("scratchpad.cpp") << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// true
// true
// true

这里的目录/dev存在,字符设备/dev/random和常规文件scratchpad.cpp也存在。

每个执行的程序都必须从给定的目录执行。要确定这个目录,C++17 提供了current_path()函数,如下所示:

#include <iostream>
#include <filesystem>

int main()
{
    std::cout << std::filesystem::current_path() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08"

在这个例子中,current_path()用于获取a.out正在执行的当前目录。current_path()提供的路径是绝对路径。要将绝对路径转换为相对路径,可以使用relative()函数,如下所示:

#include <iostream>
#include <filesystem>

int main()
{
    auto path = std::filesystem::current_path();
    std::cout << std::filesystem::relative(path) << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// "."

如本例所示,当前路径的相对路径只是(.)。

同样,要将相对路径转换为绝对路径,C++17 提供了canonical()函数:

#include <iostream>
#include <filesystem>

int main()
{
    std::cout << std::filesystem::canonical(".") << '\n';
    std::cout << std::filesystem::canonical("../Chapter08") << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08"
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08"

在这个例子中,我们使用canonical()函数将相对路径转换为绝对路径。值得注意的是,获取.的绝对路径是返回current_path()相同结果的另一种方法。

还要注意,canonical()函数返回带有所有对.././的引用解析的绝对路径,将绝对路径减少到其最小形式。如果不需要这种类型的路径,可以使用absolute()函数,如下所示:

#include <iostream>
#include <filesystem>

int main()
{
    std::cout << std::filesystem::absolute("../Chapter08") << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08/../Chapter08"

如本例所示,../不会被“absolute()”函数移除。

由于有不同的表示相同路径的方式(即相对、规范和绝对),C++17 提供了equivalent()函数,如下所示:

#include <iostream>
#include <filesystem>

int main()
{
 auto path1 = std::filesystem::path{"."};
 auto path2 = std::filesystem::path{"../Chapter08"};
 auto path3 = std::filesystem::path{"../Chapter08/../Chapter08"};
 auto path4 = std::filesystem::current_path();
 auto path5 = std::filesystem::current_path() / "../Chapter08/";

 std::cout << std::boolalpha;
 std::cout << std::filesystem::equivalent(path1, path2) << '\n';
 std::cout << std::filesystem::equivalent(path1, path3) << '\n';
 std::cout << std::filesystem::equivalent(path1, path4) << '\n';
 std::cout << std::filesystem::equivalent(path1, path5) << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// true
// true
// true
// true

在本例中引用的所有路径都指向相同的目录,无论它们是相对的、规范的还是绝对的。

如果要确定两个路径在词法上是否相等(包含完全相同的字符),请使用== operator(),如下所示:

#include <iostream>
#include <filesystem>

int main()
{
    auto path1 = std::filesystem::path{"."};
    auto path2 = std::filesystem::path{"../Chapter08"};
    auto path3 = std::filesystem::path{"../Chapter08/../Chapter08"};
    auto path4 = std::filesystem::current_path();
    auto path5 = std::filesystem::current_path() / "../Chapter08/";

    std::cout << std::boolalpha;
    std::cout << (path1 == path2) << '\n';
    std::cout << (path1 == path3) << '\n';
    std::cout << (path1 == path4) << '\n';
    std::cout << (path1 == path5) << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// false
// false
// false
// false

这里的代码与前面的代码相同,只是使用了== operator()而不是equivalent()函数。前一个示例对所有路径返回true,因为它们都指向相同的路径,而前面的示例返回false,因为相同的路径在词法上不相等,即使它们在技术上是相同的路径。

请注意这些示例中的/ operator()的使用。C++17 为路径提供了不同的连接函数,方便地提供了一种清晰易读的方式来添加到现有路径中://=+=/ operator()(以及自修改版本/= operator())将两个路径连接在一起,并为您添加/,如下所示:

#include <iostream>
#include <filesystem>

int main()
{
    auto path = std::filesystem::current_path();
    path /= "scratchpad.cpp";

    std::cout << path << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08/scratchpad.cpp"

在这个例子中,使用/= operator()scratchpad.cpp添加到路径中,并为我们添加了/。如果您希望自己添加/,或者根本不希望添加/,可以使用+= operator(),如下所示:

#include <iostream>
#include <filesystem>

int main()
{
    auto path = std::filesystem::current_path();
    path += "/scratchpad.cpp";

    std::cout << path << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08/scratchpad.cpp"

这里的结果与前一个示例中的结果相同,不同之处在于使用+= operator()而不是/= operator(),因此需要手动添加/

除了连接,C++17 还提供了一些额外的路径修改器。其中一个函数是remove_filename(),它从路径中删除文件名,如下所示:

#include <iostream>
#include <filesystem>

int main()
{
    auto path = std::filesystem::current_path();
    path /= "scratchpad.cpp";

    std::cout << path << '\n';
    path.remove_filename();
    std::cout << path << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08/scratchpad.cpp"
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08/"

如图所示,remove_filename()函数从路径中删除了文件名。

也可以用其他东西替换文件名,而不是删除它,如下所示:

#include <iostream>
#include <filesystem>

int main()
{
    auto path = std::filesystem::current_path();
    path /= "scratchpad.cpp";

    std::cout << path << '\n';
    path.replace_filename("test.cpp");
    std::cout << path << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08/scratchpad.cpp"
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08/test.cpp"

如图所示,文件名scratchpad.cpp被替换为test.cpp

除了替换文件名,还可以替换扩展名,如下所示:

#include <iostream>
#include <filesystem>

int main()
{
    auto path = std::filesystem::current_path();
    path /= "scratchpad.cpp";

    std::cout << path << '\n';
    path.replace_extension("txt");
    std::cout << path << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08/scratchpad.cpp"
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08/scratchpad.txt"

如图所示,scratchpad.cpp的扩展名已更改为.txt

最后,如果需要,可以使用clear()函数清除路径,如下所示:

#include <iostream>
#include <filesystem>

int main()
{
    auto path = std::filesystem::current_path();
    path /= "scratchpad.cpp";

    std::cout << path << '\n';
    path.clear();
    std::cout << path << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08/scratchpad.cpp"
// ""

如前面的代码所示,clear()函数删除了路径的内容(就像它是默认构造的一样)。

如前所述,路径由不同部分组成,包括根名称、目录、词干和扩展名。为了将路径分解为这些不同的组件,C++17 提供了一些辅助函数,如下所示:

#include <iostream>
#include <filesystem>

int main()
{
    auto path = std::filesystem::current_path();
    path /= "scratchpad.cpp";

    std::cout << std::boolalpha;
    std::cout << path.root_name() << '\n';
    std::cout << path.root_directory() << '\n';
    std::cout << path.root_path() << '\n';
    std::cout << path.relative_path() << '\n';
    std::cout << path.parent_path() << '\n';
    std::cout << path.filename() << '\n';
    std::cout << path.stem() << '\n';
    std::cout << path.extension() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// ""
// "/"
// "/"
// "home/user/Hands-On-System-Programming-with-CPP/Chapter08/scratchpad.cpp"
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08"
// "scratchpad.cpp"
// "scratchpad"
// ".cpp"

在这个例子中,我们将scratchpad.cpp文件的路径分解为不同的部分。父路径是/home/user/Hands-On-System-Programming-with-CPP/Chapter08,文件名是scratchpad.cpp,词干是scratchpad,扩展名是.cpp

并非所有路径都包含路径可能包含的所有部分。当路径指向目录或格式不正确时,可能会发生这种情况。

要找出路径包含的部分,使用以下辅助函数:

#include <iostream>
#include <filesystem>

int main()
{
    auto path = std::filesystem::current_path();
    path /= "scratchpad.cpp";

    std::cout << std::boolalpha;
    std::cout << path.empty() << '\n';
    std::cout << path.has_root_path() << '\n';
    std::cout << path.has_root_name() << '\n';
    std::cout << path.has_root_directory() << '\n';
    std::cout << path.has_relative_path() << '\n';
    std::cout << path.has_parent_path() << '\n';
    std::cout << path.has_filename() << '\n';
    std::cout << path.has_stem() << '\n';
    std::cout << path.has_extension() << '\n';
    std::cout << path.is_absolute() << '\n';
    std::cout << path.is_relative() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// false
// true
// false
// true
// true
// true
// true
// true
// true
// true
// false

如图所示,您可以确定路径是否具有根路径、根名称、根目录、相对路径、父路径、文件名、词干和扩展名。您还可以确定路径是绝对路径还是相对路径。

最后,C++17 提供了不同的机制来管理文件系统上的路径,具体取决于您使用的路径类型。例如,如果要创建目录或删除路径(无论其类型如何),可以分别使用create_directory()remove()函数,如下所示:

#include <iostream>
#include <filesystem>

int main()
{
    auto path = std::filesystem::current_path();
    path /= "test";

    std::cout << std::boolalpha;
    std::cout << std::filesystem::create_directory(path) << '\n';
    std::cout << std::filesystem::remove(path) << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// true
// true

在前面的示例中,我们使用create_directory()函数创建一个目录,然后使用remove()函数删除它。

我们还可以使用rename()函数重命名路径,如下所示:

#include <iostream>
#include <filesystem>

int main()
{
    auto path1 = std::filesystem::current_path();
    auto path2 = std::filesystem::current_path();
    path1 /= "test1";
    path2 /= "test2";

    std::cout << std::boolalpha;
    std::cout << std::filesystem::create_directory(path1) << '\n';
    std::filesystem::rename(path1, path2);
    std::cout << std::filesystem::remove(path1) << '\n';
    std::cout << std::filesystem::remove(path2) << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// true
// false
// true

在这个例子中,我们使用create_directory()函数创建一个目录。然后我们使用rename()函数重命名目录,然后删除旧目录路径和新目录路径。如图所示,尝试删除已重命名的目录失败,因为该路径不再存在,而尝试删除新目录成功,因为该路径确实存在。

remove()函数将删除任何路径(假设程序具有适当的权限),除非路径指向一个非空的目录,在这种情况下它将失败。要删除一个非空的目录,请使用remove_all()函数,如下所示:

#include <fstream>
#include <iostream>
#include <filesystem>

int main()
{
    auto path = std::filesystem::current_path();
    path /= "test";

    std::cout << std::boolalpha;
    std::cout << std::filesystem::create_directory(path) << '\n';

    std::fstream(path / "test1.txt", std::ios::app);
    std::fstream(path / "test2.txt", std::ios::app);
    std::fstream(path / "test3.txt", std::ios::app);

    std::cout << std::filesystem::remove_all(path) << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// true
// 4

如此所示,我们创建一个目录并使用std::fstream向目录添加一些文件。然后,我们使用remove_all()而不是remove()来删除新创建的目录。如果我们使用remove()函数,程序将抛出异常,如下所示:

terminate called after throwing an instance of 'std::filesystem::__cxx11::filesystem_error'
 what(): filesystem error: cannot remove: Directory not empty [/home/user/Hands-On-System-Programming-with-CPP/Chapter08/test]
Aborted (core dumped)

在文件系统上执行的另一个常见操作是遍历目录中的所有文件。为此,C++17 提供了一个目录迭代器,如下所示:

#include <fstream>
#include <iostream>
#include <filesystem>

int main()
{
    auto path = std::filesystem::current_path();
    path /= "test";

    std::cout << std::boolalpha;
    std::cout << std::filesystem::create_directory(path) << '\n';

    std::fstream(path / "test1.txt", std::ios::app);
    std::fstream(path / "test2.txt", std::ios::app);
    std::fstream(path / "test3.txt", std::ios::app);

    for(const auto &p: std::filesystem::directory_iterator(path)) {
        std::cout << p << '\n';
    }

    std::cout << std::filesystem::remove_all(path) << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// true
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08/test/test1.txt"
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08/test/test3.txt"
// "/home/user/Hands-On-System-Programming-with-CPP/Chapter08/test/test2.txt"
// 4

在前面的示例中,我们使用create_directory()函数创建一个目录,向目录添加一些文件,然后使用目录迭代器来遍历所有文件。

目录迭代器的功能与 C++中的任何其他迭代器一样,这意味着,如前面的示例所示,我们可以利用范围 for 语法。

最后,C++17 提供了一个方便的函数来确定临时目录的路径,可以用于根据需要为程序创建临时目录,如下所示:

#include <fstream>
#include <iostream>
#include <filesystem>

int main()
{
    std::cout << std::filesystem::temp_directory_path() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lstdc++fs; ./a.out
// "/tmp"

#endif

应该注意,在 POSIX 系统上,临时目录通常是/tmp,如此所示。然而,最好还是使用temp_directory_path()而不是硬编码这个路径。

理解记录器示例

在本节中,我们将扩展第六章中的调试示例,学习编程控制台输入/输出,以包括一个基本的记录器。这个记录器的目标是将对std::clog流的添加重定向到控制台之外的日志文件中。

就像第六章中的调试函数一样,学习编程控制台输入/输出,如果调试级别不够,或者调试已被禁用,我们希望日志函数被编译出。

为了实现这一点,请参阅以下代码:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter08/example1.cpp

首先,我们需要创建两个常量表达式——一个用于调试级别,一个用于启用或禁用调试,如下所示:

#ifdef DEBUG_LEVEL
constexpr auto g_debug_level = DEBUG_LEVEL;
#else
constexpr auto g_debug_level = 0;
#endif

#ifdef NDEBUG
constexpr auto g_ndebug = true;
#else
constexpr auto g_ndebug = false;
#endif

接下来,我们需要创建一个全局变量,如下所示:

std::fstream g_log{"log.txt", std::ios::out | std::ios::app};

全局变量是日志文件流。这将用于将对std::clog流的添加写入日志文件。由于这是一个日志文件,我们将其以只写、追加的方式打开,这意味着我们只能向日志文件写入,并且所有写入都必须追加到文件的末尾。

接下来,我们需要定义log函数本身。这个函数需要能够输出到std::clog和我们的日志文件流,而不会执行调试逻辑超过一次(因为这可能导致意外行为)。

以下实现了具有这一目标的log函数:

template <std::size_t LEVEL>
constexpr void log(void(*func)()) {
    if constexpr (!g_ndebug && (LEVEL <= g_debug_level)) {
        std::stringstream buf;

        auto g_buf = std::clog.rdbuf();
        std::clog.rdbuf(buf.rdbuf());

        func();

        std::clog.rdbuf(g_buf);

        std::clog << "\0331;32mDEBUG\033[0m: ";
        std::clog << buf.str();

        g_log << "\033[1;32mDEBUG\033[0m: ";
        g_log << buf.str();
    };
}

与[第六章中的调试函数一样,学习编程控制台输入/输出,这个log函数首先通过constexpr if语句(C++17 的新特性)包装函数的业务逻辑,为编译器提供了一种在调试被禁用或者提供的调试级别大于当前调试级别时编译出代码的方法。

如果需要进行调试,第一步是创建一个字符串流,它的行为就像std::clog和日志文件流一样,但是将流的任何添加结果保存到std::string中。

然后保存std::clog的读取缓冲区,并将字符串流的读取缓冲区提供给std::clog。对std::clog流的任何添加都将重定向到我们的字符串流,而不是stderr

接下来,我们执行用户提供的debug函数,收集调试字符串并将其存储在字符串流中。最后,将std::clogread()缓冲区恢复为stderr,并将字符串流输出到std::clog和日志文件流。

最后一步是创建我们的protected_main()函数,记录Hello World。请注意,为了演示,我们还手动将Hello World添加到std::clog中,而不使用log函数,以演示std::clog在使用log函数时仍然正常工作,并且只在我们的日志文件中记录。下面的代码显示了这一点:

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    log<0>([]{
        std::clog << "Hello World\n";
    });

    std::clog << "Hello World\n";

    return EXIT_SUCCESS;
}

要编译此代码,我们将利用我们一直在使用的相同的CMakeLists.txt文件:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter08/CMakeLists.txt

有了这段代码,我们可以使用以下方法编译和执行这段代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter08/
> mkdir build
> cd build

> cmake ..
> make
> ./example1
DEBUG: Hello World
Hello World

> cat log.txt
DEBUG: Hello World

请注意,debug语句都输出到stderrlog函数中的语句和手动执行的没有log函数的语句)。然而,日志文件中只有一个语句,演示了log函数负责将对std::clog的添加重定向到日志文件和stderr,同时保持std::clog完好无损以供将来使用。

学习关于 tail 文件的例子

在这个例子中,我们将创建一个简单的程序来 tail 一个文件。这个例子的目标是模仿tail -f -n0的行为,它输出文件的新添加。-f参数告诉 tail 跟踪文件,-n0告诉 tail 只将新添加输出到stdout

第一步是定义我们打算在打开要 tail 的文件时使用的模式,如下所示:

constexpr auto mode = std::ios::in | std::ios::ate;

在这种情况下,我们将以只读方式打开文件,并在打开时将读取指针移动到文件的末尾。

下一步是创建一个tail函数,用于监视文件的更改并将更改输出到stdout,如下所示:

[[noreturn]] void
tail(std::fstream &file)
{
    while (true) {
        file.peek();
        while(!file.eof()) {
            auto pos = file.tellg();

            std::string buf;
            std::getline(file, buf, '\n');

            if (file.eof() && !file.good()) {
                file.seekg(pos);
                break;
            }

            std::cout << buf << '\n';
        }

        sleep(1);

        file.clear();
        file.sync();
    }
}

这个tail函数开始时告诉编译器这个函数不会返回,因为该函数包装在一个永不结束的while(true)循环中。

接下来,函数首先通过查看文件末尾来检查文件是否已到达末尾,然后使用eof()检查文件结束位。如果是,程序将休眠一秒钟,清除所有状态位,重新同步文件系统以查看是否有新的更改,然后再次循环。

如果读取指针不在文件末尾,则需要读取其当前位置,以便在需要时恢复其在文件中的位置。然后读取文件中的下一行并将其存储在缓冲区中。

尝试使用getline读取下一行可能会失败(例如,当文件中的最后一个字符不是换行符时)。如果发生这种情况,应忽略缓冲区的内容(因为它不是完整的一行),并且需要将读取指针恢复到其原始位置。

如果成功读取了下一行,它将输出到stdout,然后我们再次循环以查看是否需要读取更多行。

这个例子中的最后一个函数必须解析提供给我们程序的参数,以获取要 tail 的文件名,打开文件,然后使用新打开的文件调用tail函数,如下所示:

int
protected_main(int argc, char **argv)
{
    std::string filename;
    auto args = make_span(argv, argc);

    if (args.size() < 2) {
        std::cin >> filename;
    }
    else {
        filename = ensure_z(args[1]).data();
    }

    if (auto file = std::fstream(filename, mode)) {
        tail(file);
    }

    throw std::runtime_error("failed to open file");
}

与以前的例子一样,我们使用gsl::span解析参数,以确保安全并符合 C++核心指南。如果没有为程序提供参数,我们将等待用户提供要 tail 的文件名。

如果提供了文件名,我们将打开文件并调用tail()。如果文件无法打开,我们会抛出异常。

为了编译这段代码,我们利用了同样的CMakeLists.txt文件,这是我们在其他示例中一直在使用的:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter08/CMakeLists.txt

有了这段代码,我们可以使用以下方式编译和执行这段代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter08/
> mkdir build
> cd build

> cmake ..
> make
> touch test.txt
> ./example2 test.txt

从另一个终端,我们可以对文件进行更改,如下所示:

> cd Hands-On-System-Programming-with-CPP/Chapter08/build
> echo "Hello World" > test.txt

这将导致示例程序将以下内容输出到stdout

Hello World

为了确保程序忽略不完整的行,我们可以向文件中添加一个不完整的行,如下所示:

> echo -n "Hello World" > test.txt

这导致示例程序没有输出。

比较 C++与 mmap 基准测试

在这个例子中,我们将对使用std::fstreammmap()读取文件内容的差异进行基准测试。

值得注意的是,mmap()函数利用系统调用直接将文件映射到程序中,我们期望mmap()比本章中突出的 C++ API 更快。这是因为 C++ API 需要执行额外的内存复制,显然更慢。

我们将从定义我们打算读取的文件的大小开始,如下所示:

constexpr auto size = 0x1000;

接下来,我们必须定义一个benchmark函数来记录执行操作所需的时间:

template<typename FUNC>
auto benchmark(FUNC func) {
    auto stime = std::chrono::high_resolution_clock::now();
    func();
    auto etime = std::chrono::high_resolution_clock::now();

    return etime - stime;
}

在前面的函数中,我们利用高分辨率计时器来记录执行用户提供的函数所需的时间。值得注意的是,这个基准测试程序相对通用,可以用于许多非平凡的函数(因为即使使用高分辨率计时器,通常也很难对平凡函数进行基准测试)。

最后,我们需要创建一个文件读取,然后我们需要使用std::fstreammmap()来读取文件,如下所示:

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    using namespace std::chrono;

    {
        char buf[size] = {};
        if (auto file = std::fstream("test.txt", std::ios::out)) {
            file.write(buf, size);
        }
    }

    {
        char buf[size];
        if (auto file = std::fstream("test.txt", std::ios::in)) {
            auto time = benchmark([&file, &buf]{
                file.read(buf, size);
            });

            std::cout << "c++ time: "
                      << duration_cast<microseconds>(time).count()
                      << '\n';
        }
    }

    {
        void *buf;
        if (int fd = open("test.txt", O_RDONLY); fd != 0) {
            auto time = benchmark([&fd, &buf]{
                buf = mmap(NULL, size, PROT_READ, 0, fd, 0);
            });

            munmap(buf, size);

            std::cout << "mmap time: "
                      << duration_cast<microseconds>(time).count()
                      << '\n';
        }
    }

    return EXIT_SUCCESS;
}

protected_main()函数中的第一步是创建我们打算读取的文件,如下所示:

char buf[size] = {};
if (auto file = std::fstream("test.txt", std::ios::out)) {
    file.write(buf, size);
}

为了做到这一点,我们以只写方式打开我们打算使用的文件,这也默认使用std::ios::trunc打开文件,以便在必要时为我们擦除文件的内容。最后,我们向文件写入size个零。

下一步是使用std::fstream读取文件,如下所示:

char buf[size];
if (auto file = std::fstream("test.txt", std::ios::in)) {
    auto time = benchmark([&file, &buf]{
        file.read(buf, size);
    });

    std::cout << "c++ time: "
                << duration_cast<microseconds>(time).count()
                << '\n';
}

在使用std::fstream读取文件之前,我们首先以只读方式打开文件,这将文件打开到文件的开头。我们的文件读取然后封装在我们的基准函数中。基准测试的结果输出到stdout

最后,最后一步是对mmap()做同样的操作,如下所示:

void *buf;
if (int fd = open("test.txt", O_RDONLY); fd != 0) {
    auto time = benchmark([&fd, &buf]{
        buf = mmap(NULL, size, PROT_READ, 0, fd, 0);
    });

    munmap(buf, size);

    std::cout << "mmap time: "
                << duration_cast<microseconds>(time).count()
                << '\n';
}

std::fstream一样,首先打开文件,然后在我们的基准函数中封装mmap()的使用。

为了编译这段代码,我们利用了同样的CMakeLists.txt文件,这是我们在其他示例中一直在使用的:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter08/CMakeLists.txt

有了这段代码,我们可以使用以下方式编译和执行这段代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter08/
> mkdir build
> cd build

> cmake ..
> make
> ./example3
c++ time: 16
mmap time: 3

如所示,mmap()的执行速度比std::fstream快。

摘要

在本章中,我们学习了如何以不同的方式打开文件,取决于我们打算如何使用文件本身。一旦打开,我们学习了如何使用std::fstream C++ API 读取和写入文件。

我们学习了字段和字节之间的区别,以及读写两种方法的优缺点,以及常见的不安全实践。此外,我们学习了支持函数,这些函数提供了在std::fstreamAPI 中移动指针的能力,以操纵文件的读写方式。

此外,在本章中,我们对 C++17 新增的新文件系统 API 进行了广泛的概述,包括路径及其支持函数,用于操作文件和目录。

我们用三个示例结束了本章。在第一个示例中,我们编写了一个记录器,将std::clog的输出重定向到日志文件和stdout。第二个示例演示了如何使用 C++重写 tail POSIX 命令。

最后,在第三个示例中,我们编写了一些基准代码,以比较 POSIX、C 和 C++性能的差异。在下一章中,我们将介绍 C++分配器,包括如何创建有状态的分配器,例如在系统编程时可以提高内存性能和效率的内存池。

问题

  1. 用于查看文件是否成功打开的函数的名称是什么?

  2. 打开文件的默认模式是什么?

  3. 如果您尝试从文件中读取非数字值到数字变量中会发生什么?

  4. 使用read()write()函数时可能会发生什么类型的错误?

  5. / = operator()是否会自动为您的路径添加/

  6. 以下路径的 stem 是什么—/home/user/test.txt

  7. 以下路径的父目录是什么—/home/user/test.txt

进一步阅读

第九章:分配器的实践方法

在第七章中,全面了解内存管理,我们学习了如何使用 C++特定的技术来分配和释放内存,包括使用std::unique_ptrstd::shared_ptr。此外,我们还了解了碎片化以及根据内存分配和后续释放的方式可能浪费大量内存。系统程序员经常需要从不同的池中分配内存(有时来自不同的来源),并处理碎片以防止系统在运行过程中耗尽内存。这对于嵌入式程序员来说尤其如此。可以使用放置new()来解决这些问题,但基于放置 new 的实现通常很难创建,甚至更难维护。放置new()也只能从用户定义的代码中访问,无法控制源自 C++标准库 API(如std::liststd::map)的分配。

为了解决这些问题,C++提供了一个称为分配器的概念。C++分配器定义了如何为特定类型 T 分配和释放内存。在本章中,您将学习如何创建自己的分配器,同时涵盖 C++分配器概念的复杂细节。本章将以两个不同的示例结束;第一个示例将演示如何创建一个简单的、缓存对齐的无状态分配器,而第二个示例将提供一个有状态对象分配器的功能示例,该分配器维护一个用于快速分配的空闲池。

本章的目标如下:

  • 介绍 C++分配器

  • 研究无状态的、缓存对齐的分配器的示例

  • 研究有状态的、内存池分配器的示例

技术要求

为了编译和执行本章中的示例,读者必须具备以下条件:

  • 一个能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请参阅以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter09

介绍 C++分配器

C++分配器定义了一个模板类,为特定类型 T 分配内存,并由分配器概念定义。有两种不同类型的分配器:

  • 相等的分配器

  • 不相等的分配器

相等的分配器是指可以从一个分配器中分配内存并从另一个分配器中释放内存的分配器,例如:

myallocator<myclass> myalloc1;
myallocator<myclass> myalloc2;

auto ptr = myalloc1.allocate(1);
myalloc2.deallocate(ptr, 1);

在前面的例子中,我们创建了两个myallocator{}的实例。我们从一个分配器中分配内存,然后从另一个分配器中释放内存。为了使这有效,分配器必须是相等的:

myalloc1 == myalloc2; // true

如果这不成立,分配器被认为是不相等的,这极大地复杂了分配器的使用方式。不相等的分配器通常是有状态的分配器,这意味着它在自身内部存储了一个状态,阻止了一个分配器从另一个相同分配器的实例中释放内存(因为状态不同)。

学习基本分配器

在我们深入研究有状态的、不相等的分配器的细节之前,让我们回顾一下最基本的分配器,即无状态的、相等的分配器。这个最基本的分配器采用以下形式:

template<typename T>
class myallocator
{
public:

 using value_type = T;
 using pointer = T *;
 using size_type = std::size_t;

public:

 myallocator() = default;

 template <typename U>
 myallocator(const myallocator<U> &other) noexcept
 { (void) other; }

 pointer allocate(size_type n)
 {
 if (auto ptr = static_cast<pointer>(malloc(sizeof(T) * n))) {
 return ptr;
 }

 throw std::bad_alloc();
 }

 void deallocate(pointer p, size_type n)
 { (void) n; return free(p); }
};

template <typename T1, typename T2>
bool operator==(const myallocator<T1> &, const myallocator<T2> &)
{ return true; }

template <typename T1, typename T2>
bool operator!=(const myallocator<T1> &, const myallocator<T2> &)
{ return false; }

首先,所有分配器都是模板类,如下所示:

template<typename T>
class myallocator

应该注意,分配器可以具有任意数量的模板参数,但至少需要一个来定义分配器将分配和释放的类型。在我们的示例中,我们使用以下别名:

using value_type = T;
using pointer = T *;
using size_type = std::size_t;

从技术上讲,唯一需要的别名是以下内容:

using value_type = T;

然而,由于需要T*std::size_t来创建最小的分配器,这些别名也可以添加以提供更完整的实现。可选的别名包括以下内容:

using value_type = T;
using pointer = T *;
using const_pointer = const T *;
using void_pointer = void *;
using const_void_pointer = const void *;
using size_type = std::size_t;
using difference_type = std::ptrdiff_t;

如果自定义分配器没有提供这些内容,将为您提供前面的默认值。

如所示,所有分配器必须提供默认构造函数。这是因为 C++容器将自行创建分配器,在某些情况下可能会多次创建,并且它们将使用默认构造函数来执行此操作,这意味着必须能够在不需要额外参数的情况下构造分配器。

我们示例中的allocate()函数如下:

pointer allocate(size_type n)
{
    if (auto ptr = static_cast<pointer>(malloc(sizeof(T) * n))) {
        return ptr;
    }

    throw std::bad_alloc();
}

与本示例中解释的所有函数一样,allocate()函数的函数签名由分配器概念定义,这意味着分配器中的每个函数必须采用特定的签名;否则,在现有容器使用时,分配器将无法正确编译。

在前面的示例中,使用malloc()来分配一些内存,如果malloc没有返回nullptr,则返回结果指针。由于分配器分配T*类型的指针,而不是void *,我们必须在返回指针之前对malloc()的结果进行静态转换。提供给malloc()的字节数等于sizeof(T) * n。这是因为n参数定义了分配器必须分配的对象总数——因为一些容器将一次分配多个对象,并且期望被分配的对象在内存中是连续的。这包括std::dequestd::vector的示例,分配器必须确保这些规则在内存中成立。最后,如果malloc()返回nullptr,表示无法分配请求的内存,我们会抛出std::bad_alloc()

应该注意的是,在我们的示例中,我们使用malloc()而不是new()。在这里,应该使用malloc()而不是new(),因为容器将为您构造被分配的对象。因此,我们不希望使用new(),因为它也会构造对象,这意味着对象将被构造两次,这将导致损坏和未定义的行为。因此,new()delete()不应该在分配器中使用。

deallocate函数执行与allocate函数相反的操作,释放内存并将其释放回操作系统:

void deallocate(pointer p, size_type n)
{ (void) n; free(p); }

在前面的示例中,要释放内存,我们只需要调用free()。请注意,我们创建了一个相等的分配器,这意味着ptr不需要来自执行解除分配的相同分配器。然而,分配的数量n必须与原始分配相匹配,在我们的情况下可能可以安全地忽略,因为我们使用的是malloc()free(),它们会自动为我们跟踪原始分配的大小。并非所有的分配器都具有这个属性。

在我们的简单示例中,有两个额外的要求,以符合 C++分配器,这些要求在其目的方面远不那么明显。第一个是使用模板类型U的复制构造函数,如下所示:

template <typename U>
myallocator(const myallocator<U> &other) noexcept
{ (void) other; }

这是因为当您在容器的定义中使用分配器时,您会指定容器中的类型,例如:

std::list<myclass, myallocator<myclass>> mylist;

在前面的示例中,我们创建了一个myclass{}类型的std::list,使用一个分配器来分配和释放myclass{}对象。问题是,std::list有自己的内部数据结构,也必须进行分配。具体来说,std::list实现了一个链表,因此std::list必须能够分配和释放链表节点。在前面的定义中,我们定义了一个分配器,用于分配和释放myclass{}对象,但std::list实际上将分配和释放节点,这两种类型并不相同。为了解决这个问题,std::list将使用复制构造函数的模板版本创建myclass{}分配器的副本,从而使std::list能够使用最初提供的分配器来创建自己的节点分配器。因此,完全功能的分配器需要模板版本的复制构造函数。

前面示例中前面的奇怪添加是使用相等运算符,如下所示:

template <typename T1, typename T2>
bool operator==(const myallocator<T1> &, const myallocator<T2> &)
{ return true; }

template <typename T1, typename T2>
bool operator!=(const myallocator<T1> &, const myallocator<T2> &)
{ return false; }

相等运算符定义了分配器是相等还是不相等。在前面的示例中,我们创建了一个无状态的分配器,这意味着以下是有效的:

myallocator<int> myalloc1;
myallocator<int> myalloc2;

auto ptr = myalloc1.allocate(1);
myalloc2.deallocate(ptr, 1);

如果前面的属性成立,那么分配器是相等的。由于在我们的示例中,myalloc1{}在分配时调用malloc(),在释放时调用free(),我们知道它们是可以互换的,这意味着前面的属性成立,我们的示例实现了一个相等的分配器。前面的相等运算符只是正式陈述了这种相等关系,为 C++容器等提供了根据需要创建新分配器的 API。

了解分配器的属性和选项

我们刚刚讨论的基本分配器仅提供了使用现有 C++数据结构(以及利用对象分配的其他用户定义类型)的分配器所需的功能。除了我们讨论的可选别名之外,还有几个其他选项和属性构成了 C++分配器。

学习属性

C++分配器必须遵守一定的属性集,其中大多数要么是显而易见的,要么很容易遵守。

值指针类型

第一组属性确保分配器返回的指针类型实际上是一个指针:

myallocator<myclass> myalloc;

myclass *ptr = myalloc.allocate(1);
const myclass *cptr = myalloc.allocate(1);

std::cout << (*ptr).data1 << '\n';
std::cout << (*cptr).data2 << '\n';

std::cout << ptr->data1 << '\n';
std::cout << cptr->data2 << '\n';

// 0
// 32644
// 0
// 32644

如果分配器返回的指针确实是一个指针,就可以对指针进行解引用以访问其指向的内存,如前面的示例所示。还应该注意,在这个例子中,当尝试将分配的内存输出到stdout时,返回的值是相对随机的。这是因为分配器没有要求将内存清零,因为使用这个内存的容器会为我们执行此操作,这样更高效。

相等性

如前所述,如果比较时分配器相等,则返回true,如下所示:

myallocator<myclass> myalloc1;
myallocator<myclass> myalloc2;

std::cout << std::boolalpha;
std::cout << (myalloc1 == myalloc2) << '\n';
std::cout << (myalloc1 != myalloc2) << '\n';

// true
// false

如果同一类型的两个分配器返回true,这意味着使用此分配器的容器可以自由地使用不同实例的相同分配器来分配和释放内存,从而最终实现了某些优化的使用。例如,容器可以从不实际存储分配器的内部引用,而是只在需要分配内存时创建一个分配器。从那时起,容器在内部管理内存,并且只在销毁时释放内存,此时容器将再次创建另一个分配器来执行释放操作,再次假设分配器相等。

正如我们所讨论的,分配器的相等通常与状态有关。通常,有状态的分配器不相等,而无状态的分配器相等;但这个规则并不总是成立,特别是当对有状态的分配器进行复制时,规范要求提供相等性(或者至少能够释放从副本分配的先前分配的内存)。当我们涉及有状态的分配器时,我们将提供更多细节。

在 C++17 之前,分配器存在一个问题,即容器没有简单的方法来确定分配器是否相等,而不是在初始化时首先创建两个相同分配器的实例,进行比较,然后根据结果设置内部状态。由于 C++分配器概念的这种限制,容器要么假定是无状态的分配器(这是旧版本 C++库的情况),要么假定所有分配器都是有状态的,从而消除了优化的可能性。

为了克服这一问题,C++17 引入了以下内容:

using is_always_equal = std::true_type;

如果您的分配器没有提供这个功能,就像前面的例子一样,默认值是std::empty,告诉容器需要使用旧式比较来确定相等性。如果提供了这个别名,容器将知道如何对自身进行优化。

不同的分配类型

容器如何分配内存完全取决于容器的类型,因此,分配器必须能够支持不同的分配类型,例如以下内容:

  • 分配器的所有分配必须在内存中是连续的。不要求一个分配在内存中与另一个分配是连续的,但每个单独的分配必须是连续的。

  • 分配器必须能够在单个分配中分配多个元素。这有时可能会有问题,这取决于分配器。

为了探讨这些属性,让我们使用以下示例:

template<typename T>
class myallocator
{
public:

    using value_type = T;
    using pointer = T *;
    using size_type = std::size_t;
    using is_always_equal = std::true_type;

public:

    myallocator()
    {
        std::cout << this << " constructor, sizeof(T): "
                  << sizeof(T) << '\n';
    }

    template <typename U>
    myallocator(const myallocator<U> &other) noexcept
    { (void) other; }

    pointer allocate(size_type n)
    {
        if (auto ptr = static_cast<pointer>(malloc(sizeof(T) * n))) {
            std::cout << this << " A [" << n << "]: " << ptr << '\n';
            return ptr;
        }

        throw std::bad_alloc();
    }

    void deallocate(pointer p, size_type n)
    {
        (void) n;

        std::cout << this << " D [" << n << "]: " << p << '\n';
        free(p);
    }
};

template <typename T1, typename T2>
bool operator==(const myallocator<T1> &, const myallocator<T2> &)
{ return true; }

template <typename T1, typename T2>
bool operator!=(const myallocator<T1> &, const myallocator<T2> &)
{ return false; }

前面的分配器与第一个分配器相同,唯一的区别是在构造函数和分配和释放函数中添加了调试语句,这样我们就可以看到容器是如何分配内存的。

让我们来看一个简单的std::list的例子:

std::list<int, myallocator<int>> mylist;
mylist.emplace_back(42);

// 0x7ffe97b0e8e0 constructor, sizeof(T): 24
// 0x7ffe97b0e8e0 A [1]: 0x55c0793e8580
// 0x7ffe97b0e8e0 D [1]: 0x55c0793e8580

正如我们所看到的,分配器只进行了一次分配和释放。尽管提供的类型是 4 字节的 int,但分配器分配了 24 字节的内存。这是因为std::list分配了链表节点,这种情况下是 24 字节。分配器位于0x7ffe97b0e8e0,分配位于0x55c0793e8580。此外,如所示,每次调用分配函数时分配的元素数量为 1。这是因为std::list实现了一个链表,对于添加到列表中的每个元素都进行了动态分配。尽管在使用自定义分配器时这似乎非常浪费,但在进行系统编程时,这可能非常有用,因为有时候一次只分配一个元素(而不是多个)时更容易处理内存。

现在让我们来看一下std::vector,如下所示:

std::vector<int, myallocator<int>> myvector;
myvector.emplace_back(42);
myvector.emplace_back(42);
myvector.emplace_back(42);

// 0x7ffe1db8e2d0 constructor, sizeof(T): 4
// 0x7ffe1db8e2d0 A [1]: 0x55bf9dbdd550
// 0x7ffe1db8e2d0 A [2]: 0x55bf9dbebe90
// 0x7ffe1db8e2d0 D [1]: 0x55bf9dbdd550
// 0x7ffe1db8e2d0 A [4]: 0x55bf9dbdd550
// 0x7ffe1db8e2d0 D [2]: 0x55bf9dbebe90
// 0x7ffe1db8e2d0 D [4]: 0x55bf9dbdd550

在前面的例子中,我们使用我们的客户分配器创建了std::vector,然后,与之前的例子不同,我们向向量中添加了三个整数,而不是一个。这是因为std::vector必须维护连续的内存,而不管向量中的元素数量如何(这是std::vector的主要属性之一)。因此,如果std::vector填满(即,内存用完了),std::vector必须为std::vector中的所有元素分配一个全新的连续内存块,将std::vector从旧内存复制到新内存,然后释放先前的内存块,因为它不再足够大。

为了演示这是如何工作的,我们向std::vector添加了三个元素:

  • 第一个元素分配了一个四个字节大小的内存块(n == 1sizeof(T) == 4)。

  • 第二次向std::vector添加数据时,当前的内存块已满(因为第一次只分配了四个字节),所以std::vector必须释放先前分配的内存,分配一个新的内存块,然后复制std::vector的旧内容。然而,这一次分配设置了n == 2,所以分配了八个字节。

  • 第三次添加元素时,std::vector再次用完内存,重复这个过程,但是n == 4,这意味着分配了 16 个字节。

顺便说一句,第一次分配从0x55bf9dbdd550开始,这也恰好是第三次分配的位置。这是因为malloc()分配的内存是按 16 字节对齐的,这意味着第一次分配,虽然只有 4 个字节,实际上分配了 16 个字节,这对于第一次就足够了(也就是说,由 GCC 提供的std::vector的实现可以使用优化)。由于第一次分配在第二次向std::vector添加内存时被释放,所以这块内存可以在第三次使用元素时被释放,因为原始分配仍然足够请求的数量。

显然,看到分配器的使用方式,除非你真的需要连续的内存,否则std::vector不是存储列表的好选择,因为它很慢。然而,std::list占用了大量额外的内存,因为每个元素是 24 个字节,而不是 4 个字节。接下来要观察的下一个和最后一个容器是std::deque,它在std::vectorstd::list之间找到了一个合适的平衡点:

std::deque<int, myallocator<int>> mydeque;
mydeque.emplace_back(42);
mydeque.emplace_back(42);
mydeque.emplace_back(42);

// constructor, sizeof(T): 4
// 0x7ffdea986e67 A [8]: 0x55d6822b0da0
// 0x7ffdea986f30 A [128]: 0x55d6822afaf0
// 0x7ffdea986f30 D [128]: 0x55d6822afaf0
// 0x7ffdea986e67 D [8]: 0x55d6822b0da0

std::deque创建了一个内存块的链表,可以用来存储多个元素。换句话说,std::dequestd::vectorsstd::list。像std::list一样,内存不是连续的,但像std::vector一样,每个元素只占用四个字节,并且不需要为每个添加的元素进行动态内存分配。如所示,sizeof(T) == 4字节,在创建std::deque时,分配了一个大的内存缓冲区来存储多个元素(具体来说是128个元素)。第二个较小的分配用于内部记录。

为了进一步探索std::deque,让我们向std::deque添加大量元素:

std::deque<int, myallocator<int>> mydeque;

for (auto i = 0; i < 127; i++)
    mydeque.emplace_back(42);

for (auto i = 0; i < 127; i++)
    mydeque.emplace_back(42);

for (auto i = 0; i < 127; i++)
    mydeque.emplace_back(42);

// constructor, sizeof(T): 4
// 0x7ffc5926b1b7 A [8]: 0x560285cc0da0
// 0x7ffc5926b280 A [128]: 0x560285cbfaf0
// 0x7ffc5926b280 A [128]: 0x560285cc1660
// 0x7ffc5926b280 A [128]: 0x560285cc1bc0
// 0x7ffc5926b280 D [128]: 0x560285cbfaf0
// 0x7ffc5926b280 D [128]: 0x560285cc1660
// 0x7ffc5926b280 D [128]: 0x560285cc1bc0
// 0x7ffc5926b1b7 D [8]: 0x560285cc0da0

在上面的例子中,我们三次添加了127个元素。这是因为每次分配都足够存储128个元素,其中一个元素用于记录。如所示,std::deque分配了三个内存块。

复制相等的分配器

具有相等分配器的容器的复制是直接的,因为分配器是可互换的。为了探索这一点,让我们在先前的分配器中添加以下重载,以便我们可以观察到额外的操作:

myallocator(myallocator &&other) noexcept
{
    (void) other;
    std::cout << this << " move constructor, sizeof(T): "
                << sizeof(T) << '\n';
}

myallocator &operator=(myallocator &&other) noexcept
{
    (void) other;
    std::cout << this << " move assignment, sizeof(T): "
                << sizeof(T) << '\n';
    return *this;
}

myallocator(const myallocator &other) noexcept
{
    (void) other;
    std::cout << this << " copy constructor, sizeof(T): "
                << sizeof(T) << '\n';
}

myallocator &operator=(const myallocator &other) noexcept
{
    (void) other;
    std::cout << this << " copy assignment, sizeof(T): "
                << sizeof(T) << '\n';
    return *this;
}

前面的代码添加了一个复制构造函数、复制赋值运算符、移动构造函数和一个移动赋值运算符,所有这些都有调试语句,以便我们可以看到容器在做什么。通过前面的添加,我们将能够看到分配器的复制是何时进行的。现在让我们在一个被复制的容器中使用这个分配器:

std::list<int, myallocator<int>> mylist1;
std::list<int, myallocator<int>> mylist2;

mylist1.emplace_back(42);
mylist1.emplace_back(42);

std::cout << "----------------------------------------\n";
mylist2 = mylist1;
std::cout << "----------------------------------------\n";

mylist2.emplace_back(42);
mylist2.emplace_back(42);

在上面的例子中,我们创建了两个列表。在第一个std::list中,我们向列表添加了两个元素,然后将列表复制到第二个std::list。最后,我们向第二个std::list添加了两个元素。输出如下:

0x7fff866d1e50 constructor, sizeof(T): 24
0x7fff866d1e70 constructor, sizeof(T): 24
0x7fff866d1e50 A [1]: 0x557c430ec550
0x7fff866d1e50 A [1]: 0x557c430fae90
----------------------------------------
0x7fff866d1d40 copy constructor, sizeof(T): 24
0x7fff866d1d40 A [1]: 0x557c430e39a0
0x7fff866d1d40 A [1]: 0x557c430f14a0
----------------------------------------
0x7fff866d1e70 A [1]: 0x557c430f3b30
0x7fff866d1e70 A [1]: 0x557c430ec4d0
0x7fff866d1e70 D [1]: 0x557c430e39a0
0x7fff866d1e70 D [1]: 0x557c430f14a0
0x7fff866d1e70 D [1]: 0x557c430f3b30
0x7fff866d1e70 D [1]: 0x557c430ec4d0
0x7fff866d1e50 D [1]: 0x557c430ec550
0x7fff866d1e50 D [1]: 0x557c430fae90

正如预期的那样,每个列表都创建了它打算使用的分配器,分配器创建了 24 字节的std::list节点。然后我们看到第一个分配器为添加到第一个列表中的两个元素分配内存。第二个列表在复制第一个列表之前仍然是空的,因此第二个容器创建了第三个临时分配器,它可以专门用于复制列表。完成这些操作后,我们将最后两个元素添加到第二个列表,我们可以看到第二个列表使用其原始分配器执行分配。

std::list可以自由地从一个分配器分配内存,然后从另一个分配器释放内存,这在释放内存时可以看到,这就是为什么std::list在复制期间创建临时分配器的原因。容器是否应该创建临时分配器并不是重点(尽管这可能是一个值得讨论的优化)。

移动相等的分配器

移动容器与复制容器类似,如果分配器相等。这是因为容器没有规则要做什么,因为容器可以使用其原始分配器来处理任何内存,如果需要,它可以创建一个新的分配器,如下所示:

std::list<int, myallocator<int>> mylist1;
std::list<int, myallocator<int>> mylist2;

mylist1.emplace_back(42);
mylist1.emplace_back(42);

std::cout << "----------------------------------------\n";
mylist2 = std::move(mylist1);
std::cout << "----------------------------------------\n";

mylist2.emplace_back(42);
mylist2.emplace_back(42);

在前面的例子中,我们不是复制第一个容器,而是移动它。因此,移动后的第一个容器不再有效,第二个容器现在拥有来自第一个容器的内存。

这个例子的输出如下:

0x7ffe582e2850 constructor, sizeof(T): 24
0x7ffe582e2870 constructor, sizeof(T): 24
0x7ffe582e2850 A [1]: 0x56229562d550
0x7ffe582e2850 A [1]: 0x56229563be90
----------------------------------------
----------------------------------------
0x7ffe582e2870 A [1]: 0x5622956249a0
0x7ffe582e2870 A [1]: 0x5622956324a0
0x7ffe582e2870 D [1]: 0x56229562d550
0x7ffe582e2870 D [1]: 0x56229563be90
0x7ffe582e2870 D [1]: 0x5622956249a0
0x7ffe582e2870 D [1]: 0x5622956324a0

与复制示例类似,两个列表被创建,每个std::list创建一个管理 24 字节的std::list节点的分配器。两个元素被添加到第一个列表,然后第一个列表被移动到第二个列表。因此,属于第一个列表的内存现在由第二个容器拥有,并且不执行任何副本。第二个列表的第二个分配是由它自己的分配器执行的,所有的释放也是如此,因为可以使用第二个分配器来释放从第一个分配器分配的内存。

探索一些可选属性

C++分配器提供了一些额外的属性,这些属性超出了is_always_equal。具体来说,C++分配器的作者可以选择定义以下内容:

    • propagate_on_container_copy_assignment
  • propagate_on_container_move_assignment

  • propagate_on_container_swap

可选属性告诉容器在特定操作(即复制、移动和交换)期间应如何处理分配器。具体来说,当容器被复制、移动或交换时,分配器不会被触及,这可能导致低效。传播属性告诉容器将操作传播到分配器。例如,如果propagate_on_container_copy_assignment设置为std::true_type并且正在复制容器,则在通常情况下不会复制分配器时,也必须复制分配器。

为了更好地探索这些属性,让我们创建我们的第一个不相等的分配器(即,相同分配器的两个不同实例可能不相等)。正如所述,大多数不相等的分配器是有状态的。在这个例子中,我们将创建一个无状态的不相等分配器,以保持简单。本章的最后一个例子将创建一个不相等的、有状态的分配器。

要开始我们的示例,我们首先需要为我们的分配器类创建一个托管对象,如下所示:

class myallocator_object
{
public:

    using size_type = std::size_t;

public:

    void *allocate(size_type size)
    {
        if (auto ptr = malloc(size)) {
            std::cout << this << " A " << ptr << '\n';
            return ptr;
        }

        throw std::bad_alloc();
    }

    void deallocate(void *ptr)
    {
        std::cout << this << " D " << ptr << '\n';
        free(ptr);
    }
};

不相等的分配器必须遵守以下属性:

  • 所有分配器的副本必须相等。这意味着即使我们创建了一个不相等的分配器,分配器的副本仍必须相等。当使用重新绑定复制构造函数时,这会变得棘手,因为这个属性仍然成立(即使两个分配器可能不具有相同的类型,如果一个是另一个的副本,它们仍可能相等)。

  • 所有相等的分配器必须能够释放彼此的内存。再次,当使用重新绑定复制构造函数时,这变得棘手。具体来说,这意味着管理int对象的分配器可能必须从管理std::list节点的分配器中释放内存。

为了支持这两条规则,大多数不相等的分配器最终都成为受控对象的包装器。也就是说,创建了一个可以分配和释放内存的对象,并且每个分配器都存储指向此对象的指针。在前面的示例中,myallocator_object{}是能够分配和释放内存的受控对象。要创建此对象,我们所做的就是将malloc()free()从分配器本身移动到此myallocator_object{}中;代码是相同的。添加到myallocator_object{}的唯一附加逻辑是以下内容:

  • 构造函数接受一个大小。这是因为我们无法将受控对象创建为模板类。具体来说,受控对象需要能够更改其管理的内存类型(根据所述规则)。不久将介绍此特定需求。

  • 添加了一个rebind()函数,专门用于更改受控对象管理的内存大小。再次,这使我们能够更改myallocator_object{}执行的分配大小。

接下来,我们需要定义分配器本身,如下所示:

template<typename T>
class myallocator
{

分配器的第一部分与其他分配器相同,需要使用为某个T类型分配内存的模板类:

public:

    using value_type = T;
    using pointer = T *;
    using size_type = std::size_t;
    using is_always_equal = std::false_type;

我们分配器的下一部分定义了我们的类型别名和可选属性。如图所示,所有三个传播函数都未定义,这告诉使用此分配器的任何容器,当容器发生复制、移动或交换时,分配器也不会被复制、移动或交换(容器应继续使用在构造时给定的相同分配器)。

接下来的一组函数定义了我们的构造函数和运算符。让我们从默认构造函数开始:

myallocator() :
    m_object{std::make_shared<myallocator_object>()}
{
    std::cout << this << " constructor, sizeof(T): "
                << sizeof(T) << '\n';
}

与所有构造函数和运算符一样,我们输出stdout一些调试信息,以便观察容器对分配器的操作。如图所示,默认构造函数分配myallocator_object{}并将其存储为std::shared_ptr。我们利用std::shared_ptr,因为每个分配器的副本都必须相等,因此每个副本必须共享相同的受控对象(以便可以从一个分配器分配的内存可以从副本中释放)。由于任何分配器都可能在任何时间被销毁,因此拥有受控对象,因此std::shared_ptr是更合适的智能指针。

接下来的两个函数是移动构造函数和赋值运算符:

myallocator(myallocator &&other) noexcept :
    m_object{std::move(other.m_object)}
{
    std::cout << this << " move constructor, sizeof(T): "
                << sizeof(T) << '\n';
}

myallocator &operator=(myallocator &&other) noexcept
{
    std::cout << this << " move assignment, sizeof(T): "
                << sizeof(T) << '\n';

    m_object = std::move(other.m_object);
    return *this;
}

在这两种情况下,由于移动操作的结果,我们需要std::move()我们的受控对象。对于复制也是一样的:

myallocator(const myallocator &other) noexcept :
    m_object{other.m_object}
{
    std::cout << this << " copy constructor, sizeof(T): "
                << sizeof(T) << '\n';
}

myallocator &operator=(const myallocator &other) noexcept
{
    std::cout << this << " copy assignment, sizeof(T): "
                << sizeof(T) << '\n';

    m_object = other.m_object;
    return *this;
}

如图所示,如果对分配器进行复制,我们也必须复制受控对象。因此,分配器的副本利用相同的受控对象,这意味着副本可以从原始对象中释放内存。

下一个函数是使不相等的分配器如此困难的原因:

template <typename U>
myallocator(const myallocator<U> &other) noexcept :
    m_object{other.m_object}
{
    std::cout << this << " copy constructor (U), sizeof(T): "
                << sizeof(T) << '\n';
}

前面的函数是重新绑定复制构造函数。此构造函数的目的是创建不同类型的另一个分配器的副本。例如,std::listmyallocator<int>{}开始,但实际上需要的是myallocator<std::list::node>{}类型的分配器,而不是myallocator<int>{}。为了克服这一点,前面的函数允许容器执行以下操作:

myallocator<int> alloc1;
myallocator<std::list::node> alloc2(alloc1);

在上面的例子中,alloc2alloc1的副本,即使alloc1alloc2T类型不相同。问题是,一个int是四个字节,而在我们的例子中,std::list::node有 24 个字节,这意味着前面的函数不仅能够创建一个相等的不同类型的分配器的副本,还必须能够创建一个能够释放不同类型内存的副本(特别是在这种情况下,alloc2必须能够释放int,即使它管理std::list::node元素)。在我们的例子中,这不是问题,因为我们使用malloc()free(),但正如我们将在最后的例子中展示的那样,一些有状态的分配器,比如内存池,不太符合这个要求。

allocatedeallocate函数定义如下:

pointer allocate(size_type n)
{
    auto ptr = m_object->allocate(sizeof(T) * n);
    return static_cast<pointer>(ptr);
}

void deallocate(pointer p, size_type n)
{
    (void) n;
    return m_object->deallocate(p);
}

由于我们的托管对象只调用malloc()free(),我们可以将对象的allocate()deallocate()函数视为malloc()free(),因此,实现很简单。

我们allocator类中的私有逻辑如下:

std::shared_ptr<myallocator_object> m_object;

template <typename T1, typename T2>
friend bool operator==(const myallocator<T1> &lhs, const myallocator<T2> &rhs);

template <typename T1, typename T2>
friend bool operator!=(const myallocator<T1> &lhs, const myallocator<T2> &rhs);

如前所述,我们存储了一个指向托管对象的智能指针,这允许我们创建分配器的副本。我们还声明我们的平等函数是友元的,尽管我们将这些友元函数放在类的私有部分,但我们可以将它们放在任何地方,因为友元声明不受公共/受保护/私有声明的影响。

最后,平等函数如下:

template <typename T1, typename T2>
bool operator==(const myallocator<T1> &lhs, const myallocator<T2> &rhs)
{ return lhs.m_object.get() == rhs.m_object.get(); }

template <typename T1, typename T2>
bool operator!=(const myallocator<T1> &lhs, const myallocator<T2> &rhs)
{ return lhs.m_object.get() != rhs.m_object.get(); }

我们的equal分配器示例只是对operator==返回 true,对operator!=返回 false,这表明分配器是相等的(除了使用is_always_equal)。在这个例子中,is_always_equal设置为false,在我们的相等运算符中,我们比较了托管对象。每次创建一个新的分配器,都会创建一个新的托管对象,因此,分配器不相等(也就是说,它们是不相等的分配器)。问题是,我们不能简单地总是对operator==返回false,因为根据规范,分配器的副本必须始终等于原始分配器,这就是我们使用std::shared_ptr的原因。每个分配器的副本都创建了一个std::shared_ptr的副本,因此,如果复制了分配器,我们比较托管对象的地址,复制和原始对象有相同的托管对象,因此返回true(也就是说,它们是相等的)。虽然可能不使用std::shared_ptr,但大多数不相等的分配器都是这样实现的,因为它提供了一种简单的处理相等和不相等分配器之间差异的方法,根据分配器是否已被复制。

现在我们有了一个分配器,让我们来测试一下:

std::list<int, myallocator<int>> mylist;
mylist.emplace_back(42);

// 0x7ffce60fbd10 constructor, sizeof(T): 24
// 0x561feb431590 A [1]: 0x561feb43fec0
// 0x561feb431590 D [1]: 0x561feb43fec0

如您所见,我们的分配器能够分配和释放内存。在上面的例子中,分配器位于0x561feb431590,而由std::list容器分配的元素位于0x561feb43fec0

复制一个具有传播属性设置为false的不相等容器很简单,如下所示:

std::list<int, myallocator<int>> mylist1;
std::list<int, myallocator<int>> mylist2;

mylist1.emplace_back(42);
mylist1.emplace_back(42);

mylist2.emplace_back(42);
mylist2.emplace_back(42);

std::cout << "----------------------------------------\n";
mylist2 = mylist1;
std::cout << "----------------------------------------\n";

mylist2.emplace_back(42);
mylist2.emplace_back(42);

如前面的例子所示,我们创建了两个列表,并将两个列表都填充了两个元素。一旦列表填充完毕,我们就将第一个容器复制到第二个容器中,并输出到stdout,以便我们可以看到容器如何处理这个复制。最后,我们向刚刚复制的容器添加了两个元素。

这个例子的输出如下:

// 0x7ffd65a15cb0 constructor, sizeof(T): 24
// 0x7ffd65a15ce0 constructor, sizeof(T): 24
// 0x55c4867c3a80 A [1]: 0x55c4867b9210  <--- add to list #1
// 0x55c4867c3a80 A [1]: 0x55c4867baec0  <--- add to list #1
// 0x55c4867d23c0 A [1]: 0x55c4867c89c0  <--- add to list #2
// 0x55c4867d23c0 A [1]: 0x55c4867cb050  <--- add to list #2
// ----------------------------------------
// ----------------------------------------
// 0x55c4867d23c0 A [1]: 0x55c4867c39f0  <--- add to list #2 after copy
// 0x55c4867d23c0 A [1]: 0x55c4867c3a10  <--- add to list #2 after copy
// 0x55c4867d23c0 D [1]: 0x55c4867c89c0  <--- deallocate list #2
// 0x55c4867d23c0 D [1]: 0x55c4867cb050  <--- deallocate list #2
// 0x55c4867d23c0 D [1]: 0x55c4867c39f0  <--- deallocate list #2
// 0x55c4867d23c0 D [1]: 0x55c4867c3a10  <--- deallocate list #2
// 0x55c4867c3a80 D [1]: 0x55c4867b9210  <--- deallocate list #1
// 0x55c4867c3a80 D [1]: 0x55c4867baec0  <--- deallocate list #1

如图所示,复制容器不涉及分配器。当发生复制时,列表 2 保留它已经拥有的两个分配,覆盖前两个元素的值。由于传播属性为false,第二个容器保留了它最初给定的分配器,并在复制后使用分配器来分配另外两个元素,但在列表失去作用域时也释放了之前分配的所有元素。

这种方法的问题在于容器需要循环遍历每个元素并执行手动复制。对于整数来说,这种类型的复制是可以的,但是我们可能已经在列表中存储了大型结构,因此复制容器将导致复制容器中的每个元素,这是浪费和昂贵的。由于传播属性为false,容器没有选择,因为它不能使用第一个列表的分配器,也不能使用自己的分配器来复制在第一个列表中分配的元素(因为分配器不相等)。尽管这是浪费的,但如将会展示的,这种方法可能仍然是最快的方法。

移动列表存在类似的问题:

std::list<int, myallocator<int>> mylist1;
std::list<int, myallocator<int>> mylist2;

mylist1.emplace_back(42);
mylist1.emplace_back(42);

mylist2.emplace_back(42);
mylist2.emplace_back(42);

std::cout << "----------------------------------------\n";
mylist2 = std::move(mylist1);
std::cout << "----------------------------------------\n";

mylist2.emplace_back(42);
mylist2.emplace_back(42);

在前面的示例中,我们做了与之前示例中相同的事情。我们创建了两个列表,并在将一个列表移动到另一个列表之前向每个列表添加了两个元素。

这个示例的结果如下:

// 0x7ffd65a15cb0 constructor, sizeof(T): 24
// 0x7ffd65a15ce0 constructor, sizeof(T): 24
// 0x55c4867c3a80 A [1]: 0x55c4867c3a10  <--- add to list #1
// 0x55c4867c3a80 A [1]: 0x55c4867c39f0  <--- add to list #1
// 0x55c4867d23c0 A [1]: 0x55c4867c0170  <--- add to list #2
// 0x55c4867d23c0 A [1]: 0x55c4867c0190  <--- add to list #2
// ----------------------------------------
// ----------------------------------------
// 0x55c4867d23c0 A [1]: 0x55c4867b9c90  <--- add to list #2 after move
// 0x55c4867d23c0 A [1]: 0x55c4867b9cb0  <--- add to list #2 after move
// 0x55c4867d23c0 D [1]: 0x55c4867c0170  <--- deallocate list #2
// 0x55c4867d23c0 D [1]: 0x55c4867c0190  <--- deallocate list #2
// 0x55c4867d23c0 D [1]: 0x55c4867b9c90  <--- deallocate list #2
// 0x55c4867d23c0 D [1]: 0x55c4867b9cb0  <--- deallocate list #2
// 0x55c4867c3a80 D [1]: 0x55c4867c3a10  <--- deallocate list #1
// 0x55c4867c3a80 D [1]: 0x55c4867c39f0  <--- deallocate list #1

在前面的示例中,我们可以看到相同的低效性。由于传播属性为false,容器不能使用第一个列表的分配器,而必须继续使用它已经拥有的分配器。因此,移动操作不能简单地将内部容器从一个列表移动到另一个列表,而必须循环遍历整个容器,在每个单独的元素上执行std::move(),以便与列表中的每个节点相关联的内存仍然由第二个列表的原始分配器管理。

为了克服这些问题,我们将向我们的分配器添加以下内容:

using propagate_on_container_copy_assignment = std::true_type;
using propagate_on_container_move_assignment = std::true_type;
using propagate_on_container_swap = std::true_type;

这些属性告诉使用这个分配器的任何容器,如果容器发生复制、移动或交换,分配器也应该执行相同的操作。例如,如果我们复制std::list,容器不仅必须复制元素,还必须复制分配器。

让我们看一下以下复制示例:

std::list<int, myallocator<int>> mylist1;
std::list<int, myallocator<int>> mylist2;

mylist1.emplace_back(42);
mylist1.emplace_back(42);

mylist2.emplace_back(42);
mylist2.emplace_back(42);

std::cout << "----------------------------------------\n";
mylist2 = mylist1;
std::cout << "----------------------------------------\n";

mylist2.emplace_back(42);
mylist2.emplace_back(42);

这个复制示例与我们之前的复制示例相同。我们创建两个列表,并向每个列表添加两个元素。然后我们将第一个列表复制到第二个列表,然后在完成之前向第二个列表添加两个额外的元素(最终将释放列表)。

这个示例的结果如下。应该注意,这个输出有点复杂,所以我们将一步一步地进行:

// 0x7ffc766ec580 constructor, sizeof(T): 24
// 0x7ffc766ec5b0 constructor, sizeof(T): 24
// 0x5638419d9720 A [1]: 0x5638419d0b60  <--- add to list #1
// 0x5638419d9720 A [1]: 0x5638419de660  <--- add to list #1
// 0x5638419e8060 A [1]: 0x5638419e0cf0  <--- add to list #2
// 0x5638419e8060 A [1]: 0x5638419d9690  <--- add to list #2

在前面的输出中,两个列表都被创建,并且向每个容器添加了两个元素。接下来,输出将展示当我们将第二个容器复制到第一个容器时会发生什么:

// 0x5638419e8060 D [1]: 0x5638419e0cf0
// 0x5638419e8060 D [1]: 0x5638419d9690
// 0x7ffc766ec5b0 copy assignment, sizeof(T): 24
// 0x7ffc766ec450 copy constructor (U), sizeof(T): 4
// 0x7ffc766ec3f0 copy constructor (U), sizeof(T): 24
// 0x7ffc766ec460 copy constructor, sizeof(T): 24
// 0x5638419d9720 A [1]: 0x5638419e8050
// 0x5638419d9720 A [1]: 0x5638419d9690

由于我们将传播属性设置为false,容器现在可以选择保留第一个容器使用的内存(例如,实现写时复制)。这是因为容器应该创建分配器的副本,任何两个分配器的副本都是相等的(即,它们可以释放彼此的内存)。glibc 的这种实现并不这样做。相反,它试图创建一个干净的内存视图。两个列表的分配器不相等,这意味着一旦复制发生,容器将不再能够释放自己先前分配的内存(因为它可能不再能够访问其原始分配器)。因此,容器首先删除它先前分配的所有内存。然后,它使用第一个列表分配器的重新绑定副本创建一个临时分配器(这似乎是未使用的),然后创建第一个列表分配器的直接副本,并使用它来为将要复制的元素分配新的内存。

最后,现在复制完成,最后两个元素可以添加到第二个列表中,每个列表在失去作用域时都可以被销毁:

// 0x5638419d9720 A [1]: 0x5638419d96b0  <--- add to list #2 after copy
// 0x5638419d9720 A [1]: 0x5638419d5e10  <--- add to list #2 after copy
// 0x5638419d9720 D [1]: 0x5638419e8050  <--- deallocate list #2
// 0x5638419d9720 D [1]: 0x5638419d9690  <--- deallocate list #2
// 0x5638419d9720 D [1]: 0x5638419d96b0  <--- deallocate list #2
// 0x5638419d9720 D [1]: 0x5638419d5e10  <--- deallocate list #2
// 0x5638419d9720 D [1]: 0x5638419d0b60  <--- deallocate list #1
// 0x5638419d9720 D [1]: 0x5638419de660  <--- deallocate list #1

正如所示,由于分配器被传播,因此相同的分配器用于从两个列表中释放元素。这是因为一旦复制完成,两个列表现在都使用相同的分配器(因为任何两个分配器的副本必须相等,我们选择实现的方式是在发生复制时创建相同基本分配器对象的副本)。还应该注意,glibc 实现没有选择实现写时复制方案,这意味着实现不仅未能利用传播属性提供的可能优化,而且复制的实现实际上更慢,因为复制不仅必须逐个元素复制,还必须为复制分配新的内存。

现在让我们看一个移动示例:

std::list<int, myallocator<int>> mylist1;
std::list<int, myallocator<int>> mylist2;

mylist1.emplace_back(42);
mylist1.emplace_back(42);

mylist2.emplace_back(42);
mylist2.emplace_back(42);

std::cout << "----------------------------------------\n";
mylist2 = std::move(mylist1);
std::cout << "----------------------------------------\n";

mylist2.emplace_back(42);
mylist2.emplace_back(42);

就像我们之前的移动示例一样,这创建了两个列表,并在将第一个列表移动到第二个列表之前向每个列表添加了两个元素。最后,我们的示例在第二个列表(现在是第一个列表)中添加了两个元素,然后在失去作用域时完成并释放了两个列表。

这个示例的输出结果如下:

// 0x7ffc766ec580 constructor, sizeof(T): 24
// 0x7ffc766ec5b0 constructor, sizeof(T): 24
// 0x5638419d9720 A [1]: 0x5638419d96b0  <--- add to list #1
// 0x5638419d9720 A [1]: 0x5638419d9690  <--- add to list #1
// 0x5638419d5e20 A [1]: 0x5638419e8050  <--- add to list #2
// 0x5638419d5e20 A [1]: 0x5638419d5e30  <--- add to list #2
// ----------------------------------------
// 0x5638419d5e20 D [1]: 0x5638419e8050  <--- deallocate list #2
// 0x5638419d5e20 D [1]: 0x5638419d5e30  <--- deallocate list #2
// 0x7ffc766ec5b0 move assignment, sizeof(T): 24
// ----------------------------------------
// 0x5638419d9720 A [1]: 0x5638419d5e10
// 0x5638419d9720 A [1]: 0x5638419e8050
// 0x5638419d9720 D [1]: 0x5638419d96b0  <--- deallocate list #1
// 0x5638419d9720 D [1]: 0x5638419d9690  <--- deallocate list #1
// 0x5638419d9720 D [1]: 0x5638419d5e10  <--- deallocate list #2
// 0x5638419d9720 D [1]: 0x5638419e8050  <--- deallocate list #2

就像之前的示例一样,你可以看到列表被创建,并且第一个元素被添加到每个列表中。一旦移动发生,第二个列表将删除与其先前添加的元素相关联的内存。这是因为一旦移动发生,与第二个列表相关联的内存就不再需要了(因为它将被第一个列表分配的内存替换)。这是可能的,因为第一个列表的分配器将被移动到第二个列表(因为传播属性被设置为true),因此第二个列表现在将拥有第一个列表的所有内存。

最后,两个元素被添加到列表中,列表失去作用域并释放所有内存。正如所示,这是最优化的实现。不需要额外的内存分配,也不需要逐个元素的移动。移动操作只是将内存和分配器从一个容器移动到另一个容器。此外,由于没有复制分配器,这对于任何分配器来说都是一个简单的操作,因此,这个属性应该始终设置为 true。

可选函数

除了属性之外,还有几个可选的函数,可以为容器提供有关所提供的分配器类型的附加信息。一个可选的函数如下:

size_type myallocator::max_size();

max_size() 函数告诉容器分配器可以分配的最大大小“n”。在 C++17 中,此函数已被弃用。max_size() 函数返回分配器可以执行的最大可能分配。耐人寻味的是,在 C++17 中,这默认为 std::numeric_limits<size_type>::max() / sizeof(value_type),在大多数情况下可能不是一个有效的答案,因为大多数系统根本没有这么多可用的 RAM,这表明这个函数在实践中提供的价值很小。相反,就像 C++中的其他分配方案一样,如果分配失败,将抛出std::bad_alloc,表示容器尝试执行的分配是不可能的。

C++中的另一组可选函数如下:

template<typename T, typename... Args>
static void myallocator::construct(T* ptr, Args&&... args);

template<typename T>
static void myallocator::destroy(T* ptr);

就像max_size()函数一样,构造和析构函数在 C++17 中已被弃用。在 C++17 之前,这些函数可以用于构造和析构与ptr提供的对象相关联的对象。应该注意的是,这就是为什么在构造函数中分配内存时我们不使用 new 和 delete,而是使用malloc()free()。如果我们使用new()delete(),我们会意外地调用对象的构造函数和/或析构函数两次,这将导致未定义的行为。

研究一个无状态、缓存对齐的分配器的示例

在这个例子中,我们将创建一个无状态的、相等的分配器,旨在分配对齐缓存的内存。这个分配器的目标是展示一个可以利用的 C++17 分配器,以增加容器存储的对象(例如链表)的效率,因为缓存抖动不太可能发生。

首先,我们将定义分配器如下:

template<typename T, std::size_t Alignment = 0x40>
class myallocator
{
public:

    using value_type = T;
    using pointer = T *;
    using size_type = std::size_t;
    using is_always_equal = std::true_type;

    template<typename U> struct rebind {
        using other = myallocator<U, Alignment>;
    };

public:

    myallocator()
    { }

    template <typename U>
    myallocator(const myallocator<U, Alignment> &other) noexcept
    { (void) other; }

    pointer allocate(size_type n)
    {
        if (auto ptr = aligned_alloc(Alignment, sizeof(T) * n)) {
            return static_cast<pointer>(ptr);
        }

        throw std::bad_alloc();
    }

    void deallocate(pointer p, size_type n)
    {
        (void) n;
        free(p);
    }
};

前面的分配器类似于本章中创建的其他相等分配器。有一些显著的不同之处:

  • 分配器的模板签名不同。我们不仅定义了分配器类型T,还添加了一个Alignment参数,并将默认值设置为0x40(即,分配将是 64 字节对齐的,这是 Intel CPU 上典型的缓存行大小)。

  • 我们还提供了自己的重新绑定结构。通常,这个结构是为我们提供的,但由于我们的分配器有多个模板参数,我们必须提供我们自己版本的重新绑定结构。这个结构被容器使用,比如std::list,来创建容器需要的任何分配器,而不必创建一个副本(相反,它可以在初始化期间直接创建一个分配器)。在我们的这个重新绑定结构版本中,我们传递了原始分配器提供的Alignment参数。

  • 重新绑定复制构造函数还必须定义Alignment变量。在这种情况下,如果要进行重新绑定,我们强制Alignment保持相同,这将是情况,因为重新绑定结构提供了Alignment(也是相同的)。

为了测试我们的例子,让我们创建分配器并输出一个分配的地址,以确保内存对齐:

myallocator<int> myalloc;

auto ptr = myalloc.allocate(1);
std::cout << ptr << '\n';
myalloc.deallocate(ptr, 1);

// 0x561d512b6500

如图所示,分配的内存至少是 64 字节对齐的。多次分配也是如此:

myallocator<int> myalloc;

auto ptr = myalloc.allocate(42);
std::cout << ptr << '\n';
myalloc.deallocate(ptr, 42);

// 0x55dcdcb41500

如图所示,分配的内存也至少是 64 字节对齐的。我们还可以将这个分配器与一个容器一起使用:

std::vector<int, myallocator<int>> myvector;
myvector.emplace_back(42);

std::cout << myvector.data() << '\n';

// 0x55f875a0f500

而且,内存仍然是正确对齐的。

编译和测试

要编译这段代码,我们利用了与其他示例相同的CMakeLists.txt文件:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter09/CMakeLists.txt

有了这段代码,我们可以使用以下方法编译这段代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter09/
> mkdir build
> cd build

> cmake ..
> make

要执行这个例子,运行以下命令:

> ./example6

输出应该类似于以下内容:

0x55aec04dbd00
0x55aec04e8f40
0x55aec04d5d00
===============================================================================
test cases: 3 | 3 passed
assertions: - none -

如前面的片段所示,我们能够分配不同类型的内存,以及释放这些内存,所有的地址都是 64 字节对齐的。

研究一个有状态的、内存池分配器的例子

在这个例子中,我们将创建一个更复杂的分配器,称为内存池分配器。内存池分配器的目标是快速为固定大小的类型分配内存,同时(更重要的是)减少内存的内部碎片(即,每个分配浪费的内存量,即使分配大小不是二的倍数或其他优化的分配大小)。

内存池分配器是如此有用,以至于一些 C++的实现已经包含了内存池分配器。此外,C++17 在技术上支持一种称为多态分配器的内存池分配器(本书未涵盖,因为在撰写时,没有主要的 C++17 实现支持多态分配器),大多数操作系统在内核中利用内存池分配器来减少内部碎片。

内存池分配器的主要优点如下:

  • 使用malloc()是慢的。有时free()也很慢,但对于一些实现,free()就像翻转一个位一样简单,这样它可以实现非常快的速度。

  • 大多数池分配器利用 deque 结构,这意味着池分配器分配了一个大的内存,然后将这个内存分割为分配。每个内存都使用链表链接,以便根据需要向池中添加更多内存。

池分配器还具有一个有趣的特性,即块大小越大,内部碎片的减少就越大。这种优化的代价是,如果池没有完全利用,那么随着块大小的增加,浪费的内存量也会增加,因此池分配器应该根据应用程序的需求进行定制。

为了开始我们的示例,我们首先创建一个管理列表并从中分配内存的pool类。列表将存储在一个永远增长的堆栈中(也就是说,在这个示例中,我们将尝试对中的内存进行碎片整理,或者如果中的所有内存都已被释放,则从堆栈中移除)。每次我们向池中添加一个内存块时,我们将将内存块分割为sizeof(T)大小的块,并将每个块的地址添加到称为地址堆栈的第二个堆栈中。当分配内存时,我们将从地址堆栈中弹出一个地址,当释放内存时,我们将地址推回堆栈。

我们池的开始如下:

class pool
{
public:

    using size_type = std::size_t;

public:

    explicit pool(size_type size) :
        m_size{size}
    { }

池将充当我们不均匀分配器的托管对象,就像我们以前的不均匀分配器示例一样。因此,池不是一个模板类,因为如果使用重新绑定复制构造函数,我们将需要更改池的大小(更多关于这个特定主题的内容即将到来)。如图所示,在我们的构造函数中,我们存储了池的大小,但我们并没有尝试预加载池。

要分配,我们从地址堆栈中弹出一个地址并返回它。如果地址堆栈为空,我们通过分配另一个内存块并将其添加到块堆栈中,将内存分割成块,并将分割的块添加到地址堆栈中,如下所示:

    void *allocate()
    {
        if (m_addrs.empty()) 
        {
            this->add_addrs();
        }

        auto ptr = m_addrs.top();
        m_addrs.pop();

        return ptr;
    }

为了释放内存,我们将提供的地址推送到地址堆栈中,以便以后可以重新分配。使用这种方法,为容器分配和释放内存就像从单个堆栈中弹出和推送地址一样简单:

    void deallocate(void *ptr)
    { 
        m_addrs.push(ptr); 
    }

如果使用重新绑定复制构造函数,则需要更改池的大小。这种类型的复制只有在尝试将int类型的分配器创建为std::list::node类型的分配器时才会发生,这意味着要复制的分配器尚未被使用,这意味着可以调整大小。如果分配器已经被使用,这意味着分配器已经分配了不同大小的内存,因此在这种实现中重新绑定是不可能的。考虑以下代码:

    void rebind(size_type size)
    {
        if (!m_addrs.empty() || !m_blocks.empty()) 
        {
            std::cerr << "rebind after alloc unsupported\n";
            abort();
        }

        m_size = size;
    }

应该指出,还有其他处理这个特定问题的方法。例如,可以创建一个不尝试使用重新绑定复制构造函数的std::list。还可以创建一个能够管理多个内存池的分配器,每个池都能够分配和释放特定类型的内存(当然,这将导致性能下降)。

在我们的私有部分,我们有add_addrs()函数,这个函数在allocate函数中看到过。this函数的目标是重新填充地址堆栈。为此,this函数分配另一个内存块,将内存分割,并将其添加到地址堆栈中:

    void add_addrs()
    {
        constexpr const auto block_size = 0x1000;
        auto block = std::make_unique<uint8_t[]>(block_size);

        auto v = gsl::span<uint8_t>(
            block.get(), block_size
        );

        auto total_size =
            v.size() % m_size == 0 ? v.size() : v.size() - m_size;

        for (auto i = 0; i < total_size; i += m_size) 
        {
            m_addrs.push(&v.at(i));
        }

        m_blocks.push(std::move(block));
    }

最后,我们有私有成员变量,其中包括池的大小、地址堆栈和块堆栈。请注意,我们使用std::stackstd::stack使用std::deque来实现堆栈,尽管可以编写一个不使用迭代器的更有效的堆栈,但在测试中,std::stack的性能几乎一样好:

    size_type m_size;

    std::stack<void *> m_addrs{};
    std::stack<std::unique_ptr<uint8_t[]>> m_blocks{};

分配器本身与我们已经定义的先前的不平等分配器几乎完全相同:

template<typename T>
class myallocator
{
public:

    using value_type = T;
    using pointer = T *;
    using size_type = std::size_t;
    using is_always_equal = std::false_type;
    using propagate_on_container_copy_assignment = std::false_type;
    using propagate_on_container_move_assignment = std::true_type;
    using propagate_on_container_swap = std::true_type;

一个区别是我们将propagate_on_container_copy_assignment定义为false,特意防止分配器尽可能少地被复制。这个选择也得到了支持,因为我们已经确定 glibc 在使用不平等分配器时并不会提供很大的好处。

构造函数与先前定义的相同:

    myallocator() :
        m_pool{std::make_shared<pool>(sizeof(T))}
    {
        std::cout << this << " constructor, sizeof(T): "
                  << sizeof(T) << '\n';
    }

    template <typename U>
    myallocator(const myallocator<U> &other) noexcept :
        m_pool{other.m_pool}
    {
        std::cout << this << " copy constructor (U), sizeof(T): "
                  << sizeof(T) << '\n';

        m_pool->rebind(sizeof(T));
    }

    myallocator(myallocator &&other) noexcept :
        m_pool{std::move(other.m_pool)}
    {
        std::cout << this << " move constructor, sizeof(T): "
                  << sizeof(T) << '\n';
    }

    myallocator &operator=(myallocator &&other) noexcept
    {
        std::cout << this << " move assignment, sizeof(T): "
                  << sizeof(T) << '\n';

        m_pool = std::move(other.m_pool);
        return *this;
    }

    myallocator(const myallocator &other) noexcept :
        m_pool{other.m_pool}
    {
        std::cout << this << " copy constructor, sizeof(T): "
                  << sizeof(T) << '\n';
    }

    myallocator &operator=(const myallocator &other) noexcept
    {
        std::cout << this << " copy assignment, sizeof(T): "
                  << sizeof(T) << '\n';

        m_pool = other.m_pool;
        return *this;
    }

allocatedeallocate函数与先前定义的相同,调用池的分配函数。一个区别是我们的池只能分配单个块的内存(也就是说,池分配器不能分配多个地址同时保持连续性)。因此,如果n不是1(也就是说,容器不是std::liststd::map),我们将退回到malloc()/free()实现,这通常是默认实现:

    pointer allocate(size_type n)
    {
        if (n != 1) {
            return static_cast<pointer>(malloc(sizeof(T) * n));
        }

        return static_cast<pointer>(m_pool->allocate());
    }

    void deallocate(pointer ptr, size_type n)
    {
        if (n != 1) {
            free(ptr);
        }

        m_pool->deallocate(ptr);
    }

分配器的其余部分与先前定义的相同:

private:

    std::shared_ptr<pool> m_pool;

    template <typename T1, typename T2>
    friend bool operator==(const myallocator<T1> &lhs, const myallocator<T2> &rhs);

    template <typename T1, typename T2>
    friend bool operator!=(const myallocator<T1> &lhs, const myallocator<T2> &rhs);

    template <typename U>
    friend class myallocator;
};

template <typename T1, typename T2>
bool operator==(const myallocator<T1> &lhs, const myallocator<T2> &rhs)
{ return lhs.m_pool.get() == rhs.m_pool.get(); }

template <typename T1, typename T2>
bool operator!=(const myallocator<T1> &lhs, const myallocator<T2> &rhs)
{ return lhs.m_pool.get() != rhs.m_pool.get(); }

最后,在测试我们的分配器之前,我们需要定义一个基准测试函数,能够给我们一个特定操作所需时间的指示。这个函数将在第十一章中更详细地定义,Unix 中的时间接口。目前,最重要的是要理解这个函数将一个回调函数作为输入(在我们的情况下是 Lambda),并返回一个数字。返回的数字越高,回调函数执行的时间越长:

template<typename FUNC>
auto benchmark(FUNC func) {
    auto stime = std::chrono::high_resolution_clock::now();
    func();
    auto etime = std::chrono::high_resolution_clock::now();

    return (etime - stime).count();
}

我们将进行的第一个测试是创建两个列表,并向每个列表添加元素,同时计算添加所有元素到列表所需的时间。由于每次添加到列表都需要分配,执行此测试将使我们大致比较我们的分配器在分配内存方面与 glibc 提供的默认分配器相比有多好。

constexpr const auto num = 100000;

std::list<int> mylist1;
std::list<int, myallocator<int>> mylist2;

auto time1 = benchmark([&]{
    for (auto i = 0; i < num; i++) {
        mylist1.emplace_back(42);
    }
});

auto time2 = benchmark([&]{
    for (auto i = 0; i < num; i++) {
        mylist2.emplace_back(42);
    }
});

std::cout << "[TEST] add many:\n";
std::cout << " - time1: " << time1 << '\n';
std::cout << " - time2: " << time2 << '\n';

如上所述,对于每个列表,我们向列表中添加100000个整数,并计算所需的时间,从而使我们能够比较分配器。结果如下:

0x7ffca71d7a00 constructor, sizeof(T): 24
[TEST] add many:
  - time1: 3921793
  - time2: 1787499

如图所示,我们的分配器在分配内存方面比默认分配器快 219%。

在我们的下一个测试中,我们将比较我们的分配器与默认分配器在释放内存方面的表现。为了执行此测试,我们将做与之前相同的事情,但是不是计时我们的分配,而是计时从每个列表中删除元素所需的时间:

constexpr const auto num = 100000;

std::list<int> mylist1;
std::list<int, myallocator<int>> mylist2;

for (auto i = 0; i < num; i++) {
    mylist1.emplace_back(42);
    mylist2.emplace_back(42);
}

auto time1 = benchmark([&]{
    for (auto i = 0; i < num; i++) {
        mylist1.pop_front();
    }
});

auto time2 = benchmark([&]{
    for (auto i = 0; i < num; i++) {
        mylist2.pop_front();
    }
});

std::cout << "[TEST] remove many:\n";
std::cout << " - time1: " << time1 << '\n';
std::cout << " - time2: " << time2 << '\n';

this函数的结果如下:

0x7fff14709720 constructor, sizeof(T): 24
[TEST] remove many:
  - time1: 1046463
  - time2: 1285248

如图所示,我们的分配器只有默认分配器的 81%那么快。这可能是因为free()函数更有效率,这并不奇怪,因为理论上推送到堆栈可能比某些free()的实现更慢。即使我们的free()函数较慢,与分配和碎片化改进相比,差异微不足道。还要注意的是,这种实现的分配和释放速度几乎相同,这是我们所期望的。

为了确保我们正确编写了分配器,以下将再次运行我们的测试,但是不是计算向列表添加元素所需的时间,而是计算列表中每个值的总和。如果我们的总和符合预期,我们将知道分配和释放已正确执行:

constexpr const auto num = 100000;

std::list<int, myallocator<int>> mylist;

for (auto i = 0; i < num; i++) {
    mylist.emplace_back(i);
}

uint64_t total1{};
uint64_t total2{};

for (auto i = 0; i < num; i++) {
    total1 += i;
    total2 += mylist.back();
    mylist.pop_back();
}

std::cout << "[TEST] verify: ";
if (total1 == total2) {
    std::cout << "success\n";
}
else {
    std::cout << "failure\n";
    std::cout << " - total1: " << total1 << '\n';
    std::cout << " - total2: " << total2 << '\n';
}

正如预期的那样,我们的测试输出是“成功”。

编译和测试

要编译这段代码,我们利用了与其他示例相同的CMakeLists.txt文件:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter09/CMakeLists.txt

有了这段代码,我们可以使用以下方式编译这段代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter09/
> mkdir build
> cd build

> cmake -DCMAKE_BUILD_TYPE=Release ..
> make

要执行示例,请运行以下命令:

> ./example7

输出应该类似于以下内容:

0x7ffca71d7a00 constructor, sizeof(T): 24
[TEST] add many:
  - time1: 3921793
  - time2: 1787499
0x7fff14709720 constructor, sizeof(T): 24
[TEST] remove many:
  - time1: 1046463
  - time2: 1285248
0x7fff5d8ad040 constructor, sizeof(T): 24
[TEST] verify: success
===============================================================================
test cases: 5 | 5 passed
assertions: - none -

正如你所看到的,我们的示例输出与我们之前提供的输出相匹配。需要注意的是,你的结果可能会根据硬件或已在系统上运行的内容等因素而有所不同。

总结

在本章中,我们看了如何创建自己的分配器,并涵盖了 C++分配器概念的复杂细节。主题包括相等和不相等分配器之间的区别,容器传播的处理方式,重新绑定以及有状态分配器可能出现的问题。最后,我们用两个不同的例子总结了。第一个例子演示了如何创建一个简单的、缓存对齐的无状态分配器,而第二个例子提供了一个有状态对象分配器的功能示例,该分配器维护一个用于快速分配的空闲池。

在下一章中,我们将使用几个示例来演示如何使用 C++编程 POSIX 套接字(即网络编程)。

问题

  1. is_always_equal是什么意思?

  2. 什么决定了分配器是相等还是不相等?

  3. 一个有状态的分配器可以是相等的吗?

  4. 一个无状态的分配器可以是相等的吗?

  5. propagate_on_container_copy_assignment是做什么的?

  6. 对于容器,rebind 复制构造函数的作用是什么?

  7. 关于传递给 allocate 函数的n变量,std::liststd::vector有什么区别?

进一步阅读

第十章:使用 C++编程 POSIX 套接字

在本章中,您将学习如何使用 C++17 编程 POSIX 套接字,包括更常见的 C++范例,如资源获取即初始化RAII)。首先,本章将讨论套接字是什么,以及 UDP 和 TCP 之间的区别。在向您介绍五个不同的示例之前,将详细解释 POSIX API。第一个示例将引导您通过使用 POSIX 套接字创建 UDP 回显服务器示例。第二个示例将使用 TCP 而不是 UDP 创建相同的示例,并解释其中的区别。第三个示例将扩展我们在以前章节中创建的现有调试记录器,而第四和第五个示例将解释如何安全地处理数据包。

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

  • POSIX 套接字

  • 利用 C++和 RAII 进行套接字编程

  • TCP vs UDP

技术要求

为了编译和执行本章中的示例,读者必须具备以下条件:

  • 能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请参见以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter10

从 POSIX 套接字开始

不幸的是,C++不包含本地网络库(希望 C++20 能够解决这个问题)。因此,需要使用 POSIX 套接字来执行 C++网络编程。POSIX 套接字 API 定义了使用标准 Unix 文件描述符范式发送和接收网络数据包的 API。在使用套接字进行编程时,必须创建服务器和客户端。服务器负责将特定端口绑定到套接字协议,该协议由套接字库的用户开发。客户端是连接到先前绑定端口的任何其他应用程序。服务器和客户端都有自己的 IP 地址。

在编程套接字时,除了选择地址类型(例如 IPv4 与 IPv6),通常程序员还必须在 UDP 与 TCP 之间进行选择。UDP 是一种无连接协议,不保证可靠发送数据包,其优势在于速度和简单性。UDP 通常用于不需要 100%接收的数据,例如在视频游戏中的位置。另一方面,TCP 是一种基于连接的协议,确保所有数据包按发送顺序接收,并且是其可靠性的典型协议。

从 API 开始

以下部分将详细解释不同的套接字 API。

socket() API

所有 POSIX 套接字编程都始于使用socket() API 创建套接字文件描述符,其形式如下:

int socket(int domain, int type, int protocol);

域定义了创建套接字时使用的地址类型。在大多数情况下,这将是 IPv4 的AF_INET或 IPv6 的AF_INET6。在本章的示例中,我们将使用AF_INET。类型字段通常采用SOCK_STREAM用于 TCP 连接或SOCK_DGRAM用于 UDP 连接,这两者都将在本章中进行演示。最后,此 API 中的协议字段将在所有示例中设置为0,告诉 API 使用指定套接字类型的默认协议。

完成此 API 后,将返回套接字文件描述符,这将是剩余 POSIX API 所需的。如果此 API 失败,则返回-1,并将errno设置为适当的错误代码。应注意errno不是线程安全的,其使用应谨慎处理。处理这些类型的错误的一个很好的方法是立即将errno转换为 C++异常,可以使用以下方法完成:

if (m_fd = ::socket(AF_INET, SOCK_STREAM, 0); m_fd == -1) {
    throw std::runtime_error(strerror(errno));
}

在前面的示例中,创建了一个 IPv4 TCP 套接字。生成的文件描述符保存在内存变量m_fd中。使用 C++17 语法,检查文件描述符的有效性,如果报告错误(即-1),则抛出异常。为了提供错误的人类可读版本,errno被转换为字符串使用strerror()。这不仅提供了errno的字符串版本,还确保记录错误的过程不会在过程中更改errno,如果使用更复杂的方法可能会发生这种情况。

最后,当套接字不再需要时,应像使用 POSIXclose()函数关闭任何其他文件描述符一样关闭。应该注意,大多数 POSIX 操作系统在应用程序关闭时仍然打开的套接字将自动关闭。

为了防止可能的描述符泄漏,套接字文件描述符可以封装在一个类中,如下所示:

class mytcpsocket
{
public:
    explicit mytcpsocket(uint16_t port)
    {
        if (m_fd = ::socket(AF_INET, SOCK_STREAM, 0); m_fd == -1) {
            throw std::runtime_error(strerror(errno));
        }
    }

    ~mytcpsocket()
    {
        close(m_fd);
    }

    auto descriptor() const
    { return m_fd; }

private:

    int m_fd{};
};

在前面的示例中,我们使用先前示例中的逻辑打开了一个 IPv4 TCP 套接字,确保检测到任何错误并正确报告。不同之处在于我们将文件描述符存储为成员变量,并且当mytcpsocket{}失去作用域时,我们会自动确保文件描述符被正确释放回操作系统。每当需要文件描述符时,可以使用descriptor()访问器。

bind()和 connect() API

创建套接字文件描述符后,套接字必须绑定或连接,具体取决于套接字是创建连接(服务器)还是连接到现有绑定套接字(客户端)。通过 TCP 或 UDP 进行通信时,绑定套接字会为套接字分配一个端口。端口0-1024保留用于特定服务,并且通常由操作系统管理(需要特殊权限进行绑定)。其余端口是用户定义的,并且通常可以在没有特权的情况下绑定。确定要使用的端口取决于实现。某些端口预先为特定应用程序确定,或者应用程序可以向操作系统请求一个可用的端口,还可以将这个新分配的端口通知给潜在的客户端应用程序,这增加了通信的复杂性。

bind() API 采用以下形式:

int bind(int socket, const struct sockaddr *address, socklen_t address_len);

socket整数参数是先前由socket() API 提供的套接字文件描述符。address参数告诉操作系统要绑定到哪个端口,并且要接受来自哪个 IP 地址的传入连接,通常是INADDR_ANY,告诉操作系统可以接受来自任何 IP 地址的传入连接。最后,address_len参数告诉 API 地址结构的总大小是多少。

地址结构需要总大小(以字节为单位),因为根据您使用的套接字类型,支持不同的结构。例如,IPv6 套接字的 IP 地址比 IPv4 套接字大。在本章中,我们将讨论使用sockaddr_in{}结构的 IPv4,该结构定义以下字段:

  • sin_family:这与套接字域相同,在 IPv4 的情况下是AF_INET

  • sin_port:这定义了要绑定到的端口,必须使用htons()转换为网络字节顺序。

  • sin_address:这定义了要接受传入连接的 IP 地址,也必须使用htonl()转换为网络字节顺序。通常,这被设置为htonl(INADDR_ANY),表示可以接受来自任何 IP 地址的连接。

由于地址结构的长度是可变的,bind()API 接受一个指向不透明结构类型的指针,并使用长度字段来确保提供了正确的信息。应该注意,C++核心指南不鼓励这种类型的 API,因为没有类型安全的实现方式。事实上,为了使用这个 API,需要使用reinterpret_cast()sockaddr_in{}转换为不透明的sockaddr{}结构。尽管 C++核心指南不支持使用reinterpret_cast(),但没有其他选择,因此如果需要套接字,必须违反这个规则。

服务器使用bind()为套接字专用端口,客户端使用connect()连接到已绑定的端口。connect()API 的形式如下:

int connect(int socket, const struct sockaddr *address, socklen_t address_len);

应该注意,connect()的参数与bind()相同。与bind()一样,必须提供socket()调用返回的文件描述符,并且在 IPv4 的情况下,必须提供指向sockaddr_in{}结构的指针以及sockaddr_in{}结构的大小。在填写sockaddr_in{}结构时,可以使用以下内容:

  • sin_family:与套接字域相同,在 IPv4 的情况下为AF_INET

  • sin_port:定义要连接的端口,必须使用htons()转换为网络字节顺序。

  • sin_address:定义要连接的 IP 地址,也必须使用htonl()转换为网络字节顺序。对于环回连接,这将设置为htonl(INADDR_LOOPBACK)

最后,bind()connect()在成功时返回0,失败时返回-1,并在发生错误时设置errno

listen()accept()API

对于 TCP 服务器,还存在两个额外的 API,提供了服务器监听和接受传入 TCP 连接的方法——listen()accept()

listen()API 的形式如下:

int listen(int socket, int backlog); 

套接字参数是socket()API 返回的文件描述符,backlog 参数限制可以建立的未决连接的总数。在本章的示例中,我们将使用0的 backlog,这告诉 API 使用实现特定的值作为 backlog。

如果listen()成功,返回0,否则返回-1,并设置errno为适当的错误代码。

一旦应用程序设置好监听传入连接的准备,accept()API 可以用来接受连接。accept()API 的形式如下:

int accept(int socket, struct sockaddr *address, socklen_t *address_len);

与其他 API 一样,socket参数是socket()API 返回的文件描述符和地址,address_len参数返回连接的信息。如果不需要连接信息,也可以为地址和address_len提供nullptr。成功完成accept()API 后,将返回客户端连接的套接字文件描述符,可用于与客户端发送和接收数据。

如果 accept 执行失败,返回的不是有效的套接字文件描述符,而是返回-1,并且适当地设置了errno

应该注意,listen()accept()仅适用于 TCP 连接。对于 TCP 连接,服务器创建两个或多个套接字描述符;第一个用于绑定到端口并监听连接,而第二个是客户端的套接字文件描述符,用于发送和接收数据。另一方面,UDP 是一种无连接的协议,因此用于绑定到端口的套接字也用于与客户端发送和接收数据。

send()recv()sendto()recvfrom()API

在打开套接字后向服务器或客户端发送信息,POSIX 提供了send()sendto()API。send()API 的形式如下:

ssize_t send(int socket, const void *buffer, size_t length, int flags);

第一个参数是要发送数据的服务器或客户端的套接字文件描述符。应该注意的是,套接字必须连接到特定的客户端或服务器才能工作(例如,与服务器进行通信,或者使用 TCP 打开的客户端)。buffer参数指向要发送的缓冲区,length定义了要发送的缓冲区的长度,flags提供了各种不同的设置,用于指定发送缓冲区的方式,在大多数情况下只需设置为0。还应该注意,当flags设置为0时,write()函数和send()函数通常没有区别,两者都可以使用。

如果服务器尝试使用 UDP 与客户端通信,服务器将不知道如何将信息发送给客户端,因为服务器绑定到特定端口,而不是特定客户端。同样,如果使用 UDP 的客户端不连接到特定服务器,它将不知道如何将信息发送给服务器。因此,POSIX 提供了sendto(),它添加了sockaddr{}结构,用于定义要发送缓冲区的对象和方式。sendto()的形式如下:

ssize_t sendto(int socket, const void *buffer, size_t length, int flags, const struct sockaddr *dest_addr, socklen_t dest_len);

send()sendto()之间唯一的区别是sendto()还提供了目标addresslen参数,这为用户提供了一种定义缓冲区发送对象的方式。

要从客户端或服务器接收数据,POSIX 提供了recv()API,其形式如下:

ssize_t recv(int socket, void *buffer, size_t length, int flags);

recv()API 与send()API 具有相同的参数,不同之处在于当接收到数据时,将写入缓冲区(这就是为什么它没有标记为const),并且长度字段描述了缓冲区的总大小,而不是接收到的字节数。

同样,POSIX 提供了recvfrom()API,类似于sendto()API,其形式如下:

ssize_t recvfrom(int socket, void *restrict buffer, size_t length, int flags, struct sockaddr *restrict address, socklen_t *restrict address_len);

send()sendto()函数都返回发送的总字节数,而recv()recvfrom()函数返回接收到的总字节数。所有这些函数在发生错误时都返回-1并将errno设置为适当的值。

学习 UDP 回显服务器的示例

在本例中,我们将通过一个简单的 UDP 回显服务器示例来引导您。回显服务器(与我们之前的章节相同)会将任何输入回显到其输出。在这个 UDP 示例中,服务器将从客户端接收到的数据回显回客户端。为了保持示例简单,将回显字符缓冲区。如何正确处理结构化数据包将在接下来的示例中介绍。

服务器

首先,我们必须定义从客户端发送到服务器和返回的最大缓冲区大小,并且我们还必须定义要使用的端口:

#define PORT 22000
#define MAX_SIZE 0x10

应该注意,只要端口号在1024以上,任何端口号都可以,以避免需要特权。在本例中,服务器需要以下包括:

#include <array>
#include <iostream>
#include <stdexcept>

#include <unistd.h>
#include <string.h>

#include <sys/socket.h>
#include <netinet/in.h>

服务器将使用一个类来定义,以利用 RAII,提供一个在不再需要时关闭服务器打开的套接字的清理方法。我们还定义了三个私有成员变量。第一个变量将存储服务器在整个示例中将使用的套接字文件描述符。第二个变量存储服务器的地址信息,将提供给bind()函数,而第三个参数存储客户端的地址信息,将被recvfrom()sendto()函数使用。

class myserver
{
    int m_fd{};
    struct sockaddr_in m_addr{};
    struct sockaddr_in m_client{};

public:

服务器的构造函数将打开套接字并将提供的端口绑定到套接字,如下所示:

    explicit myserver(uint16_t port)
    {
        if (m_fd = ::socket(AF_INET, SOCK_DGRAM, 0); m_fd == -1) {
            throw std::runtime_error(strerror(errno));
        }

        m_addr.sin_family = AF_INET;
        m_addr.sin_port = htons(port);
        m_addr.sin_addr.s_addr = htonl(INADDR_ANY);

        if (this->bind() == -1) {
            throw std::runtime_error(strerror(errno));
        }
    }

套接字使用AF_INET打开,这告诉套接字 API 需要 IPv4。此外,提供了SOCK_DGRAM,这告诉套接字 API 需要 UDP 而不是 TCP。对::socket()的调用结果保存在m_fd变量中,该变量存储服务器的套接字文件描述符。利用 C++17,如果结果文件描述符为-1,则发生错误,我们会抛出错误,稍后会恢复。

接下来,我们填写一个sockaddr_in{}结构:

  • sin_family被设置为AF_INET以匹配套接字,告诉套接字 API 我们希望使用 IPv4。

  • sin_port被设置为端口号,htons用于将主机字节顺序转换为短网络字节顺序。

  • sin_addr 被设置为 INADDR_ANY,这告诉套接字 API 服务器将接受来自任何客户端的数据。由于 UDP 是一种无连接的协议,这意味着我们可以从任何客户端接收数据。

最后,调用一个名为bind()的成员函数,并检查结果是否有错误。如果发生错误,就会抛出异常。

绑定函数实际上只是::bind()套接字 API 的包装器,如下所示:

    int bind()
    {
        return ::bind(
            m_fd,
            reinterpret_cast<struct sockaddr *>(&m_addr),
            sizeof(m_addr)
        );
    }

在前面的代码片段中,我们使用在服务器类的构造函数中打开的套接字文件描述符调用bind,并在调用此函数之前提供了在构造函数中初始化的端口和地址给bind API,这告诉套接字绑定到端口22000和任何 IP 地址。

一旦套接字被绑定,服务器就准备好从客户端接收数据。由于我们将套接字绑定到任何 IP 地址,任何客户端都可以向我们发送信息。我们可以使用recv() POSIX API 来实现这一点,但这种方法的问题在于一旦我们接收到数据,我们就不知道是谁发送给我们信息。如果我们不需要向该客户端发送任何信息,或者我们将客户端信息嵌入接收到的数据中,这是可以接受的,但在简单的回显服务器的情况下,我们需要知道要将数据回显给谁。为了解决这个问题,我们使用recvfrom()而不是recv(),如下所示:

   ssize_t recv(std::array<char, MAX_SIZE> &buf)
   {
        socklen_t client_len = sizeof(m_client);

        return ::recvfrom(
            m_fd,
            buf.data(),
            buf.size(),
            0,
            (struct sockaddr *) &m_client,
            &client_len
        );
    }

第一个参数是在构造过程中创建的套接字文件描述符,而第二个和第三个参数是缓冲区及其最大大小。请注意,我们的recv()成员函数使用std::array而不是指针和大小,因为使用指针和大小参数不符合 C++核心规范,因为这样做会提供报告数组实际大小的错误机会。最后两个参数是指向sockaddr_in{}结构和其大小的指针。

值得注意的是,在我们的示例中,我们向recvfrom()提供了一个sockaddr_in{}结构,因为我们知道将要连接的客户端将使用 IPv4 地址。如果不是这种情况,recvfrom()函数将失败,因为我们提供了一个太小的结构,无法提供例如 IPv6 地址(如果使用)的结构。为了解决这个问题,可以使用sockaddr_storage{}而不是sockaddr_in{}sockaddr_storage{}结构足够大,可以存储传入的地址类型。要确定收到的地址类型,可以使用所有结构中都需要的sin_family字段。

最后,我们返回对recvfrom()的调用结果,这可能是接收到的字节数,或者在发生错误时为-1

要将缓冲区发送给连接到 UDP 服务器的客户端,我们使用sendto() API,如下所示:

    ssize_t send(std::array<char, MAX_SIZE> &buf, ssize_t len)
    {
        if (len >= buf.size()) {
            throw std::out_of_range("len >= buf.size()");
        }

        return ::sendto(
            m_fd,
            buf.data(),
            buf.size(),
            0,
            (struct sockaddr *) &m_client,
            sizeof(m_client)
        );
    }

与其他 API 一样,第一个参数是在构造函数中打开的套接字文件描述符。然后提供缓冲区。在这种情况下,“recvfrom()”和“sendto()”之间的区别在于提供要发送的字节数,而不是缓冲区的总大小。这不会违反 C++核心指导,因为缓冲区的总大小仍然附加到缓冲区本身,而要发送的字节数是用于确定我们计划寻址数组的位置的第二个值。但是,我们需要确保长度字段不超出范围。这可以使用“Expects()”调用来完成,如下所示:

Expects(len < buf.size())

在这个例子中,我们明确检查了是否超出范围的错误,并在发生这种情况时抛出了更详细的错误。任何一种方法都可以。

与“recvfrom()”调用一样,我们向“sendto()”API 提供了指向sockaddr_in{}结构的指针,告诉套接字要向哪个客户端发送数据。在这种情况下,由于 API 不修改地址结构(因此结构的大小不会改变),因此不需要指向长度字段的指针。

下一步是将所有这些组合在一起,创建回显服务器本身,如下所示:

    void echo()
    {
        while(true)
        {
            std::array<char, MAX_SIZE> buf{};

            if (auto len = recv(buf); len != 0) {
                send(buf, len);
            }
            else {
                break;
            }
        }
    }

回显服务器设计用于从客户端接收数据缓冲区,将其发送回同一客户端,并重复。首先,我们创建一个无限循环,能够从任何客户端回显数据,直到我们被告知客户端已断开连接。下一步是定义一个缓冲区,该缓冲区将用于向客户端发送和接收数据。然后调用“recv()”成员函数,并向其提供我们希望接收函数用来填充来自客户端的数据的缓冲区,并检查来自客户端返回的字节数是否大于0。如果来自客户端返回的字节数大于0,我们使用send成员函数将缓冲区发送(或回显)回客户端。如果字节数为0,我们假设客户端已断开连接,因此停止无限循环,从而完成回显过程。

客户端信息结构(即m_client)提供给“recvfrom()”和“sendto()”POSIX API。这是故意的。我们唯一假设的是所有连接的客户端都将使用 IPv4。当从客户端接收到数据时,“recvfrom()”函数将为我们填充m_client结构,告诉我们发送给我们信息的客户端是谁。然后我们将相同的结构提供回“sendto()”函数,告诉 API 要将数据回显给谁。

如前所述,当服务器类被销毁时,我们关闭套接字,如下所示:

    ~myserver()
    {
        close(m_fd);
    }

最后,我们通过在“protected_main()”函数中实例化服务器来完成服务器,并开始回显:

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    myserver server{PORT};
    server.echo();

    return EXIT_SUCCESS;
}

int
main(int argc, char** argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

如所示,main函数受到可能异常的保护,在“protected_main()”函数中,我们实例化服务器并调用其“echo()”成员函数,这将启动用于回显客户端数据的无限循环。

客户端逻辑

在这个例子中,客户端需要以下包含:

#include <array>
#include <string>
#include <iostream>
#include <stdexcept>

#include <unistd.h>
#include <string.h>

#include <sys/socket.h>
#include <netinet/in.h>

与服务器一样,客户端是使用类创建的,以利用 RAII:

class myclient
{
    int m_fd{};
    struct sockaddr_in m_addr{};

public:

除了类定义之外,还定义了两个私有成员变量。第一个,像服务器一样,是客户端将使用的套接字文件描述符。第二个定义了客户端希望与之通信的服务器的地址信息。

客户端的构造函数与服务器的类似,有一些细微的差异:

    explicit myclient(uint16_t port)
    {
        if (m_fd = ::socket(AF_INET, SOCK_DGRAM, 0); m_fd == -1) {
            throw std::runtime_error(strerror(errno));
        }

        m_addr.sin_family = AF_INET;
        m_addr.sin_port = htons(port);
        m_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);

        if (connect() == -1) {
            throw std::runtime_error(strerror(errno));
        }
    }

像服务器一样,客户端使用AF_INET创建 IPv4 的套接字文件描述符,并且使用SOCK_DGRAM将协议类型设置为 UDP。如果socket()API 返回错误,则会抛出异常。设置的sockaddr_in{}结构与服务器不同。服务器的sockaddr_in{}结构定义了服务器将如何绑定套接字,而客户端的sockaddr_in{}结构定义了客户端将连接到哪个服务器。在这个例子中,我们将地址设置为INADDR_LOOPBACK,因为服务器将在同一台计算机上运行。最后,调用connect()成员函数,连接到服务器,如果发生错误,则抛出异常。

连接到服务器,使用以下connect()成员函数:

    int connect()
    {
        return ::connect(
            m_fd,
            reinterpret_cast<struct sockaddr *>(&m_addr),
            sizeof(m_addr)
        );
    }

应该注意,使用 UDP 连接到服务器是可选的,因为 UDP 是一种无连接的协议。在这种情况下,connect函数告诉操作系统您计划与哪个服务器通信,以便在客户端使用send()recv(),而不是sendto()recvfrom()。像服务器的bind()成员函数一样,connect()函数利用构造函数填充的sockaddr_in{}结构。

要发送数据到服务器进行回显,使用以下send()成员变量:

    ssize_t send(const std::string &buf)
    {
        return ::send(
            m_fd,
            buf.data(),
            buf.size(),
            0
        );
    }

由于我们计划向服务器发送一个字符串,所以我们将send()成员函数传递一个字符串引用。然后send() POSIX API 被赋予在构造函数中创建的套接字文件描述符,要发送到服务器进行回显的缓冲区以及要发送的缓冲区的总长度。由于我们不使用flags字段,send()成员函数也可以使用write()函数编写如下:

    ssize_t send(const std::string &buf)
    {
        return ::write(
            m_fd,
            buf.data(),
            buf.size()
        );
    }

要在服务器回显数据后从服务器接收数据,我们使用以下recv()成员函数:

    ssize_t recv(std::array<char, MAX_SIZE> &buf)
    {
        return ::recv(
            m_fd,
            buf.data(),
            buf.size() - 1,
            0
        );
    }

有许多方法可以实现recv()成员函数。由于我们知道要发送到服务器的字符串的总大小,并且我们知道服务器将向我们回显相同大小的字符串,我们可以始终创建一个与第一个字符串大小相同的第二个字符串(或者如果您信任回显实际上正在发生,可以简单地重用原始字符串)。在这个例子中,我们创建一个具有特定最大大小的接收缓冲区,以演示更有可能的情况。因此,在这个例子中,我们可以发送任意大小的字符串,但是服务器有自己的内部最大缓冲区大小可以接受。然后服务器将数据回显到客户端。客户端本身有自己的最大接收缓冲区大小,这最终限制了可能被回显的总字节数。由于客户端正在回显字符串,我们必须为尾随的'\0'保留一个字节,以便终止由客户端接收到的填满整个接收缓冲区的任何字符串。

要向服务器发送和接收数据,我们创建一个echo函数,如下所示:

    void echo()
    {
        while(true) {
            std::string sendbuf{};
            std::array<char, MAX_SIZE> recvbuf{};

            std::cin >> sendbuf;
            if (sendbuf == "exit") {
                send({});
                break;
            }

            send(sendbuf);
            recv(recvbuf);

            std::cout << recvbuf.data() << '\n';
        }
    }

echo函数,就像服务器一样,首先创建一个无限循环,以便可以向服务器发送多个字符串进行回显。在无限循环内,创建了两个缓冲区。第一个是将接收用户输入的字符串。第二个定义了要使用的接收缓冲区。一旦定义了缓冲区,我们使用std::cin从用户那里获取要发送到服务器的字符串(最终将被回显)。

如果字符串是单词exit,我们向服务器发送 0 字节并退出无限循环。由于 UDP 是一种无连接的协议,服务器无法知道客户端是否已断开连接,因为没有这样的构造存在。因此,如果不向服务器发送停止的信号(在这种情况下我们发送 0 字节),服务器将保持在无限循环中,因为它无法知道何时停止。在这个例子中,这带来了一个有趣的问题,因为如果客户端崩溃或被杀死(例如,使用Ctrl + C),服务器将永远不会收到 0 字节的信号,因此仍然保持在无限循环中。有许多方法可以解决这个问题(即发送保持活动的信号),但一旦你开始尝试解决这个问题,你很快就会得到一个与 TCP 如此相似的协议,你可能会选择使用 TCP。

最后,用户输入的缓冲区使用send()成员函数发送到服务器,服务器回显字符串,然后客户端使用recv()成员函数接收字符串。一旦接收到字符串,数据将使用std::cout输出到stdout

与服务器一样,当客户端类被销毁时,套接字文件描述符将被关闭,关闭套接字:

    ~myclient()
    {
        close(m_fd);
    }
};

最后,客户端是使用与服务器和我们先前的示例相同的protected_main()函数创建的:

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    myclient client{PORT};
    client.echo();

    return EXIT_SUCCESS;
}

int
main(int argc, char** argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

在上面的代码中,客户端是在protected_main()函数中实例化的,并调用了echo函数,该函数接受用户输入,将输入发送到服务器,并将任何回显的数据输出到stdout

编译和测试

要编译此代码,我们利用了我们一直在使用的相同的CMakeLists.txt文件:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter10/CMakeLists.txt

有了这个代码,我们可以使用以下命令编译这个代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter10/
> mkdir build
> cd build

> cmake ..
> make

要执行服务器,请运行以下命令:

> ./example1_server

要执行客户端,请打开一个新的终端并运行以下命令:

> cd Hands-On-System-Programming-with-CPP/Chapter10/build
> ./example1_client
Hello ↵
Hello
World
World ↵
exit ↵

如前面的片段所示,当客户端执行并输入时,输入将回显到终端。完成后,输入单词exit,客户端退出。服务器也将在客户端完成时退出。为了演示 UDP 的连接问题,而不是输入exit,在客户端上按*Ctrl *+ C,客户端将退出,但服务器将继续执行,等待来自客户端的更多输入,因为它不知道客户端已完成。为了解决这个问题,我们的下一个示例将创建相同的回声服务器,但使用 TCP。

学习 TCP 回声服务器的示例

在这个例子中,我们将引导读者创建一个回声服务器,但是使用 TCP 而不是 UDP。就像之前的例子一样,回声服务器会将任何输入回显到其输出。与 UDP 示例不同,TCP 是一种基于连接的协议,因此在这个例子中建立连接和发送/接收数据的一些具体细节是不同的。

服务器

首先,我们必须定义从客户端发送到服务器和返回的最大缓冲区大小,并且我们还必须定义要使用的端口:

#define PORT 22000
#define MAX_SIZE 0x10

对于服务器,我们将需要以下包含:

#include <array>
#include <iostream>

#include <unistd.h>
#include <string.h>

#include <sys/socket.h>
#include <netinet/in.h>

与之前的例子一样,我们将使用一个类来创建服务器,以便利用 RAII:

class myserver
{
    int m_fd{};
    int m_client{};
    struct sockaddr_in m_addr{};

public:

与 UDP 一样,将使用三个成员变量。第一个成员变量m_fd存储与服务器关联的套接字文件描述符。与 UDP 不同,此描述符将不用于与客户端发送/接收数据。相反,m_client表示将用于与客户端发送/接收数据的第二个套接字文件描述符。与 UDP 一样,sockaddr_in{}结构m_addr将填充服务器地址类型,该类型将被绑定。

服务器的构造函数与 UDP 示例类似:

    explicit myserver(uint16_t port)
    {
        if (m_fd = ::socket(AF_INET, SOCK_STREAM, 0); m_fd == -1) {
            throw std::runtime_error(strerror(errno));
        }

        m_addr.sin_family = AF_INET;
        m_addr.sin_port = htons(port);
        m_addr.sin_addr.s_addr = htonl(INADDR_ANY);

        if (this->bind() == -1) {
            throw std::runtime_error(strerror(errno));
        }
    }

与 UDP 示例类似,创建了服务器的套接字文件描述符,但是使用的不是SOCK_DGRAM,而是使用SOCK_STREAMsockaddr_in{}结构与 UDP 示例相同,使用了 IPv4(即AF_INET),端口和任何 IP 地址用于表示将接受来自任何 IP 地址的连接。

与 UDP 示例类似,sockaddr_in{}结构然后使用以下成员函数进行绑定:

    int bind()
    {
        return ::bind(
            m_fd,
            reinterpret_cast<struct sockaddr *>(&m_addr),
            sizeof(m_addr)
        );
    }

前面的bind()函数与 UDP 示例中使用的bind()函数相同。

与 UDP 不同,创建了第二个特定于客户端的套接字描述符,并为该套接字类型设置了 IP 地址、端口和地址类型,这意味着与客户端通信不需要sendto()recvfrom(),因为我们已经有了一个特定的套接字文件描述符,其中已经绑定了这些额外的信息。因此,可以使用send()recv()而不是sendto()recvfrom()

要从客户端接收数据,将使用以下成员函数:

    ssize_t recv(std::array<char, MAX_SIZE> &buf)
    {
        return ::recv(
            m_client,
            buf.data(),
            buf.size(),
            0
        );
    }

UDP 示例和这个示例之间唯一的区别是使用recv()而不是recvfrom(),这省略了额外的sockaddr_in{}结构。如果你还记得之前的 UDP 示例,m_fd是与recvfrom()一起使用的,而不是m_clientrecv()一起使用的。不同之处在于 UDP 示例中的m_client是一个sockaddr_in{}结构,用于定义从哪里接收数据。而在 TCP 中,m_client实际上是一个套接字描述符,从描述符绑定接收数据,这就是为什么不需要额外的sockaddr_in{}结构。

send()成员函数也是如此:

    ssize_t send(std::array<char, MAX_SIZE> &buf, ssize_t len)
    {
        if (len >= buf.size()) {
            throw std::out_of_range("len >= buf.size()");
        }

        return ::send(
            m_client,
            buf.data(),
            len,
            0
        );
    }

与 UDP 示例不同,前面的send()函数可能使用send() POSIX API 而不是sendto(),因为关于如何向客户端发送数据的地址信息已经绑定到描述符上,因此可以省略额外的sockaddr_in{}信息。send()函数的其余部分与 UDP 示例相同。

echo函数与其 UDP 对应函数有很大不同:

    void echo()
    {
        if (::listen(m_fd, 0) == -1) {
            throw std::runtime_error(strerror(errno));
        }

        if (m_client = ::accept(m_fd, nullptr, nullptr); m_client == -1) {
            throw std::runtime_error(strerror(errno));
        }

        while(true)
        {
            std::array<char, MAX_SIZE> buf{};

            if (auto len = recv(buf); len != 0) {
                send(buf, len);
            }
            else {
                break;
            }
        }

        close(m_client);
    }

由于 TCP 需要连接,服务器echo函数的第一步是告诉 POSIX API 您希望开始监听传入连接。在我们的示例中,通过将 backlog 设置为0来告诉 API 使用默认连接 backlog,这是特定于实现的。下一步是使用accept() POSIX API 等待来自客户端的传入连接。默认情况下,此函数是一个阻塞函数。accept()函数返回一个带有地址信息绑定到描述符的套接字文件描述符,因此在accept() POSIX API 的地址字段中传递nullptr,因为在我们的示例中不需要这些信息(但是如果需要过滤某些传入客户端,可能需要这些信息)。

下一步是等待客户端接收数据,然后使用send()成员函数将数据回传给客户端。这个逻辑与 UDP 示例相同。值得注意的是,如果我们从客户端接收到0字节,我们将停止处理来自客户端的数据,类似于 UDP。不同之处在于,如将会展示的,客户端端不需要显式地向服务器发送 0 字节以发生这种情况。

echo函数中的最后一步是在客户端完成后关闭客户端套接字文件描述符:

    ~myserver()
    {
        close(m_fd);
    }
};

与其他示例一样,当服务器类被销毁时,关闭服务器的套接字文件描述符。最后,在protected_main()函数中实例化服务器,如下所示:

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    myserver server{PORT};
    server.echo();
}

int
main(int argc, char** argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

与 UDP 示例类似,实例化了服务器,并执行了echo()函数。

客户端逻辑

客户端逻辑与 UDP 客户端逻辑类似,有一些细微的例外。需要以下包含:

#include <array>
#include <string>
#include <iostream>

#include <unistd.h>
#include <string.h>

#include <sys/socket.h>
#include <netinet/in.h>

与 UDP 示例一样,创建了一个客户端类来利用 RAII,并定义了m_fdm_addr私有成员变量,用于存储客户端的套接字文件描述符和客户端希望连接到的服务器的地址信息:

class myclient
{
    int m_fd{};
    struct sockaddr_in m_addr{};

public:

与 UDP 示例不同,但与 TCP 服务器逻辑相同,构造函数创建了一个用于 IPv4 和 TCP 的套接字,使用了AF_INETSOCK_STREAM

    explicit myclient(uint16_t port)
    {
        if (m_fd = ::socket(AF_INET, SOCK_STREAM, 0); m_fd == -1) {
            throw std::runtime_error(strerror(errno));
        }

        m_addr.sin_family = AF_INET;
        m_addr.sin_port = htons(port);
        m_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);

        if (connect() == -1) {
            throw std::runtime_error(strerror(errno));
        }
    }

构造函数的其余部分与 UDP 示例相同,connect()send()recv()函数也是如此:

     int connect()
    {
        return ::connect(
            m_fd,
            reinterpret_cast<struct sockaddr *>(&m_addr),
            sizeof(m_addr)
        );
    }

    ssize_t send(const std::string &buf)
    {
        return ::send(
            m_fd,
            buf.data(),
            buf.size(),
            0
        );
    }

    ssize_t recv(std::array<char, MAX_SIZE> &buf)
    {
        return ::recv(
            m_fd,
            buf.data(),
            buf.size() - 1,
            0
        );
    }

如前面的代码片段所示,客户端的功能几乎与 UDP 客户端完全相同。UDP 客户端和 TCP 客户端之间的区别,除了使用SOCK_STREAM之外,还在于echo函数的实现:

    void echo()
    {
        while(true) {
            std::string sendbuf{};
            std::array<char, MAX_SIZE> recvbuf{};

            std::cin >> sendbuf;

            send(sendbuf);
            recv(recvbuf);

            std::cout << recvbuf.data() << '\n';
        }
    }

与 UDP 示例不同,TCP 客户端不需要检查exit字符串。这是因为如果客户端断开连接(例如,使用Ctrl+C杀死客户端),服务器端会接收到 0 字节,告诉服务器逻辑客户端已断开连接。这是可能的,因为 TCP 是一种基于连接的协议,因此操作系统正在维护一个开放的连接,包括服务器和客户端之间的保持活动信号,以便 API 的用户不必显式地执行此操作。因此,在大多数情况下,这是期望的套接字类型,因为它可以防止许多与连接状态相关的常见问题:

    ~myclient()
    {
        close(m_fd);
    }
};

如前面的代码所示,与所有其他示例一样,当客户端被销毁时,套接字文件描述符将被关闭,如下所示:

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    myclient client{PORT};
    client.echo();
}

int
main(int argc, char** argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

最后,客户端在protected_main()函数中实例化,并调用echo函数。

编译和测试

要编译此代码,我们利用了与本章其他示例相同的CMakeLists.txt文件:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter10/CMakeLists.txt

有了这些代码,我们可以使用以下命令编译此代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter10/
> mkdir build
> cd build

> cmake ..
> make

要执行服务器,请运行以下命令:

> ./example2_server

要执行客户端,请打开一个新的终端并运行以下命令:

> cd Hands-On-System-Programming-with-CPP/Chapter10/build
> ./example2_client
Hello ↵
Hello
World
World ↵
<ctrl+c>

如前面的代码片段所示,当客户端被执行并输入时,输入将被回显到终端。完成后,输入Ctrl+C,客户端退出。如您所见,服务器将在客户端完成时退出。上面的示例演示了 TCP 的易用性及其优于 UDP 的优势。下一个示例将演示如何使用 TCP 进行更有用的操作。

探索 TCP 记录器示例

为了演示更有用的功能,以下示例实现了我们在整本书中一直在开发的相同记录器,但作为远程记录设施。

服务器

与本章前面的示例一样,此示例也需要相同的宏和包含文件。要启动服务器,我们必须定义日志文件:

std::fstream g_log{"server_log.txt", std::ios::out | std::ios::app};

由于记录器将在同一台计算机上执行,为了保持示例简单,我们将命名服务器正在记录的文件为server_log.txt

服务器与前面示例中的 TCP 服务器相同,唯一的区别是只需要一个recv()成员函数(即不需要send()函数,因为服务器只会接收日志数据):

class myserver
{
    int m_fd{};
    int m_client{};
    struct sockaddr_in m_addr{};

public:
    explicit myserver(uint16_t port)
    {
        if (m_fd = ::socket(AF_INET, SOCK_STREAM, 0); m_fd == -1) {
            throw std::runtime_error(strerror(errno));
        }

        m_addr.sin_family = AF_INET;
        m_addr.sin_port = htons(port);
        m_addr.sin_addr.s_addr = htonl(INADDR_ANY);

        if (this->bind() == -1) {
            throw std::runtime_error(strerror(errno));
        }
    }

    int bind()
    {
        return ::bind(
            m_fd,
            reinterpret_cast<struct sockaddr *>(&m_addr),
            sizeof(m_addr)
        );
    }

    ssize_t recv(std::array<char, MAX_SIZE> &buf)
    {
        return ::recv(
            m_client, buf.data(), buf.size(), 0
        );
    }

前一个 TCP 示例和此示例之间的区别在于使用log()函数而不是echo函数。这两个函数都类似,它们监听传入的连接,然后无限循环,直到服务器接收到数据:

    void log()
    {
        if (::listen(m_fd, 0) == -1) {
            throw std::runtime_error(strerror(errno));
        }

        if (m_client = ::accept(m_fd, nullptr, nullptr); m_client == -1) {
            throw std::runtime_error(strerror(errno));
        }

        while(true)
        {
            std::array<char, MAX_SIZE> buf{};

            if (auto len = recv(buf); len != 0) {
                g_log.write(buf.data(), len);
                std::clog.write(buf.data(), len);
            }
            else {
                break;
            }
        }

        close(m_client);
    }

log函数的不同之处在于,当客户端接收到数据时,不会将数据回显到服务器,而是将数据输出到stdout并写入server_log.txt日志文件。

如此所示,服务器逻辑的其余部分与前面的示例相同:

    ~myserver()
    {
        close(m_fd);
    }
};

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    myserver server{PORT};
    server.log();

    return EXIT_SUCCESS;
}

int
main(int argc, char** argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

当服务器对象被销毁时,套接字文件描述符被关闭,在protected_main()函数中实例化服务器,然后执行log()函数。

客户端逻辑

本示例的客户端逻辑是前几章中的调试示例(我们一直在构建)和之前的 TCP 示例的组合。

我们首先定义调试级别并启用宏,与之前的示例一样:

#ifdef DEBUG_LEVEL
constexpr auto g_debug_level = DEBUG_LEVEL;
#else
constexpr auto g_debug_level = 0;
#endif

#ifdef NDEBUG
constexpr auto g_ndebug = true;
#else
constexpr auto g_ndebug = false;
#endif

客户端类与之前的 TCP 示例中的客户端类相同:

class myclient
{
    int m_fd{};
    struct sockaddr_in m_addr{};

public:
    explicit myclient(uint16_t port)
    {
        if (m_fd = ::socket(AF_INET, SOCK_STREAM, 0); m_fd == -1) {
            throw std::runtime_error(strerror(errno));
        }

        m_addr.sin_family = AF_INET;
        m_addr.sin_port = htons(port);
        m_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);

        if (connect() == -1) {
            throw std::runtime_error(strerror(errno));
        }
    }

    int connect()
    {
        return ::connect(
            m_fd,
            reinterpret_cast<struct sockaddr *>(&m_addr),
            sizeof(m_addr)
        );
    }

    ssize_t send(const std::string &buf)
    {
        return ::send(
            m_fd,
            buf.data(),
            buf.size(),
            0
        );
    }

    ~myclient()
    {
        close(m_fd);
    }
};

本示例中的客户端与上一个示例中的客户端唯一的区别在于,在本示例中不需要recv()函数(因为不会从服务器接收数据),也不需要echo()函数(或类似的东西),因为客户端将直接用于根据需要向服务器发送数据。

与之前的调试示例一样,需要为客户端创建一个日志文件,在本示例中,我们还将全局实例化客户端,如下所示:

myclient g_client{PORT};
std::fstream g_log{"client_log.txt", std::ios::out | std::ios::app};

如所示,客户端日志文件将被命名为client_log.txt,以防止与服务器日志文件发生冲突,因为两者将在同一台计算机上运行,以简化示例。

log函数与第八章中定义的log函数相同,学习编程文件输入/输出,唯一的区别是除了记录到stderr和客户端日志文件外,调试字符串还将记录到服务器上:

template <std::size_t LEVEL>
constexpr void log(void(*func)()) {
    if constexpr (!g_ndebug && (LEVEL <= g_debug_level)) {
        std::stringstream buf;

        auto g_buf = std::clog.rdbuf();
        std::clog.rdbuf(buf.rdbuf());

        func();

        std::clog.rdbuf(g_buf);

        std::clog << "\033[1;32mDEBUG\033[0m: ";
        std::clog << buf.str();

        g_log << "\033[1;32mDEBUG\033[0m: ";
        g_log << buf.str();

        g_client.send("\033[1;32mDEBUG\033[0m: ");
        g_client.send(buf.str());
    };
}

如前面的代码所示,log函数封装了对std::clog的任何输出,并将结果字符串重定向到stderr,日志文件,并且为了本示例的目的,发送字符串到服务器的客户端对象上,以便在服务器端记录。

示例的其余部分与之前的示例相同:

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    log<0>([]{
        std::clog << "Hello World\n";
    });

    std::clog << "Hello World\n";

    return EXIT_SUCCESS;
}

int
main(int argc, char** argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

protected_main()函数将Hello World\n输出到stderr,它被重定向到包括stderr,日志文件,并最终发送到服务器。另外调用std::clog用于显示只有封装在log()函数中的std:clog调用才会被重定向。

编译和测试

要编译此代码,我们利用了与其他示例相同的CMakeLists.txt文件:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter10/CMakeLists.txt

有了这段代码,我们可以使用以下命令编译这段代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter10/
> mkdir build
> cd build

> cmake ..
> make

要执行服务器,请运行以下命令:

> ./example3_server

要执行客户端,请打开一个新的终端并运行以下命令:

> cd Hands-On-System-Programming-with-CPP/Chapter10/build
> ./example3_client
Debug: Hello World
Hello World

> cat client_log.txt
Debug: Hello World

> cat server_log.txt
Debug: Hello World

如前面的片段所示,当客户端执行时,客户端和服务器端都将在stderr输出DEBUG: Hello World。此外,客户端还将Hello World输出到stderr,因为第二次对std::clog的调用没有被重定向。最后,两个日志文件都包含重定向的DEBUG: Hello World

到目前为止,在所有示例中,忽略的一件事是如果多个客户端尝试连接到服务器会发生什么。在本章的示例中,只支持一个客户端。要支持额外的客户端,需要使用线程,这将在第十二章中介绍,学习编程 POSIC 和 C++线程,在那里我们将扩展此示例以创建一个能够记录多个应用程序的调试输出的日志服务器。本章的最后两个示例将演示如何使用 TCP 处理非字符串数据包。

尝试处理数据包的示例

在本示例中,我们将讨论如何处理从客户端到服务器的以下数据包:

struct packet
{
    uint64_t len;
    char buf[MAX_SIZE];

    uint64_t data1;
    uint64_t data2;
};

数据包由一些固定宽度的整数数据和一个字符串组成(网络中的字段必须始终是固定宽度,因为您可能无法控制应用程序运行的计算机类型,非固定宽度类型,如intlong,可能会根据计算机而变化)。

这种类型的数据包在许多程序中很常见,但正如将要演示的那样,这种类型的数据包在安全解析方面存在挑战。

服务器与之前的 TCP 示例相同,减去了recv_packet()函数(recv()函数处理数据包而不是std::arrays):

class myserver
{
...

    void recv_packet()
    {
        if (::listen(m_fd, 0) == -1) {
            throw std::runtime_error(strerror(errno));
        }

        if (m_client = ::accept(m_fd, nullptr, nullptr); m_client == -1) {
            throw std::runtime_error(strerror(errno));
        }

        packet p{};

        if (auto len = recv(p); len != 0) {
            auto msg = std::string(p.buf, p.len);

            std::cout << "data1: " << p.data1 << '\n';
            std::cout << "data2: " << p.data2 << '\n';
            std::cout << "msg: \"" << msg << "\"\n";
            std::cout << "len: " << len << '\n';
        }

        close(m_client);
    }

...
};

recv_packet()函数中,我们等待从客户端接收数据。一旦从客户端接收到数据包,我们就解析接收到的数据包。与数据包相关的整数数据被读取并输出到stdout而没有问题。然而,字符串数据更加棘手。由于我们不知道接收到的字符串数据的总大小,我们必须考虑整个缓冲区来安全地处理字符串,并在某种程度上保持类型安全。当然,在我们的示例中,为了减小数据包的总大小,我们可以先将整数数据放在数据包中,然后创建一个可变长度的数据包,但这既不安全,也难以在更复杂的情况下控制或实现。大多数解决这个问题的尝试(需要发送和接收比实际需要的更多数据)都会导致长度可变的操作,因此是不安全的。

服务器的其余部分与之前的示例相同:

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    myserver server{PORT};
    server.recv_packet();
}

int
main(int argc, char** argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

如前面的代码所示,服务器在protected_main()函数中实例化,并调用recv_packet()函数。

客户端逻辑

客户端的大部分部分也与之前的示例相同:

class myclient
{
...

    void send_packet()
    {
        auto msg = std::string("Hello World");

        packet p = {
            42,
            43,
            msg.size(),
            {}
        };

        memcpy(p.buf, msg.data(), msg.size());

        send(p);
    }

...
};

send_packet()函数是与之前的示例唯一不同的部分(减去send()函数发送的是数据包而不是std::array())。在send_packet()函数中,我们创建一个不包含"Hello World"字符串的数据包。值得注意的是,为了创建这个数据包,我们仍然需要一些处理,包括内存复制。一旦数据包创建完成,我们就将其发送到服务器进行处理。

客户端的其余部分与之前的示例相同:

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    myclient client{PORT};
    client.send_packet();
}

int
main(int argc, char** argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

客户端在proceted_main()函数中实例化,并执行send_packet()函数。

编译和测试

要编译此代码,我们利用了与其他示例相同的CMakeLists.txt文件:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter10/CMakeLists.txt

有了这段代码,我们可以使用以下命令编译这段代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter10/
> mkdir build
> cd build

> cmake ..
> make

要执行服务器,运行以下命令:

> ./example4_server

要执行客户端,打开一个新的终端并运行以下命令:

> cd Hands-On-System-Programming-with-CPP/Chapter10/build
> ./example4_client

在服务器端,以下内容输出到stdout

data1: 42
data2: 43
msg: "Hello World"
len: 280

如前面的片段所示,客户端发送数据包,服务器接收。服务器接收到的数据包总大小为 280 字节,尽管字符串的总大小要小得多。在下一个示例中,我们将演示如何通过数据包编组安全地减小数据包的总大小,尽管这会增加一些额外的处理(尽管根据您的用例可能是可以忽略的)。

处理 JSON 处理的示例

在最后一个示例中,我们将演示如何使用 JSON 对数据包进行编组,以安全地减小网络数据包的大小,尽管这会增加一些额外的处理。为支持此示例,将使用以下 C++ JSON 库:github.com/nlohmann/json

要将此 JSON 库纳入我们的示例中,需要将以下内容添加到我们的CMakeLists.txt中,该文件将下载这个仅包含头文件的库并将其安装到我们的构建文件夹中以供使用:

list(APPEND JSON_CMAKE_ARGS
    -DBUILD_TESTING=OFF
    -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}
)

ExternalProject_Add(
    json
    GIT_REPOSITORY https://github.com/nlohmann/json.git
    GIT_SHALLOW 1
    CMAKE_ARGS ${JSON_CMAKE_ARGS}
    PREFIX ${CMAKE_BINARY_DIR}/external/json/prefix
    TMP_DIR ${CMAKE_BINARY_DIR}/external/json/tmp
    STAMP_DIR ${CMAKE_BINARY_DIR}/external/json/stamp
    DOWNLOAD_DIR ${CMAKE_BINARY_DIR}/external/json/download
    SOURCE_DIR ${CMAKE_BINARY_DIR}/external/json/src
    BINARY_DIR ${CMAKE_BINARY_DIR}/external/json/build
    UPDATE_DISCONNECTED 1
)

服务器

服务器包括和宏是一样的,唯一的区别是必须添加 JSON,如下所示:

#include <nlohmann/json.hpp>
using json = nlohmann::json;

在本示例中,服务器与之前的示例相同,唯一的区别是recv_packet()函数:

class myserver
{
...

    void recv_packet()
    {
        std::array<char, MAX_SIZE> buf{};

        if (::listen(m_fd, 0) == -1) {
            throw std::runtime_error(strerror(errno));
        }

        if (m_client = ::accept(m_fd, nullptr, nullptr); m_client == -1) {
            throw std::runtime_error(strerror(errno));
        }

        if (auto len = recv(buf); len != 0) {
            auto j = json::parse(buf.data(), buf.data() + len);

            std::cout << "data1: " << j["data1"] << '\n';
            std::cout << "data2: " << j["data2"] << '\n';
            std::cout << "msg: " << j["msg"] << '\n';
            std::cout << "len: " << len << '\n';
        }

        close(m_client);
    }

...
};

recv_packet()函数中,我们需要分配一个具有一定最大大小的缓冲区;这个缓冲区不需要完全接收,而是作为我们的 JSON 缓冲区的占位符,其大小可以达到我们的最大值。解析 JSON 数据很简单。整数数据和字符串数据都被安全地解析为它们的整数和std::string类型,都遵循 C++核心指南。代码易于阅读和理解,未来可以更改数据包而无需更改任何其他逻辑。

服务器的其余部分是相同的:

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    myserver server{PORT};
    server.recv_packet();
}

int
main(int argc, char** argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

服务器在protected_main()函数中实例化,然后调用recv_packet()函数。

客户端逻辑

与服务器一样,客户端也必须包括 JSON 头:

#include <nlohmann/json.hpp>
using json = nlohmann::json;

与服务器一样,客户端与之前的示例相同,只是没有send_packet()函数:

class myclient
{
...

    void send_packet()
    {
        json j;

        j["data1"] = 42;
        j["data2"] = 43;
        j["msg"] = "Hello World";

        send(j.dump());
    }

...
};

send_packet()函数同样简单。构造一个 JSON 数据包并发送到服务器。不同之处在于,在发送之前将数据包编组成 JSON 字符串(使用dump()函数)。这将把所有数据转换为一个字符串,其中包含特殊语法来定义每个字段的开始和结束,以防止不安全的解析,以一种经过良好建立和测试的方式。此外,如将很快展示的那样,发送的字节数总量大大减少。

客户端的其余部分是相同的:

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    myclient client{PORT};
    client.send_packet();
}

int
main(int argc, char** argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

客户端在protected_main()函数中实例化,并调用send_packet()函数。

编译和测试

要编译这些代码,我们利用了与其他示例相同的CMakeLists.txt文件:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter10/CMakeLists.txt

有了这些代码,我们可以使用以下命令编译这些代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter10/
> mkdir build
> cd build

> cmake ..
> make

要执行服务器,请运行以下命令:

> ./example5_server

要执行客户端,请打开一个新的终端并运行以下命令:

> cd Hands-On-System-Programming-with-CPP/Chapter10/build
> ./example5_client

在服务器端,将以下内容输出到stdout

data1: 42
data2: 43
msg: "Hello World"
len: 43

如前面的片段所示,客户端发送数据包,服务器接收数据包。服务器接收的数据包总大小为 43 字节,与之前的示例相比,效率提高了 6.5 倍。除了提供更小的数据包外,创建和解析数据包的逻辑相似,未来更改也更容易阅读和修改。此外,使用 JSON Schema 等内容,甚至可以在处理之前验证数据包,这是本书范围之外的主题。

总结

在本章中,我们学习了如何使用 C++17 编程 POSIX 套接字。具体来说,我们学习了与 POSIX 套接字相关的常见 API,并学习了如何使用它们。我们用五个不同的示例结束了本章。第一个示例创建了一个 UDP 回显服务器,而第二个示例创建了一个类似的回显服务器,但使用的是 TCP 而不是 UDP,概述了不同方法之间的区别。第三个示例通过向我们的调试器添加服务器组件来扩展了我们的调试示例。第四和第五个示例演示了如何处理简单的网络数据包,以及使用编组来简化该过程的好处。

在下一章中,我们将讨论可用于获取挂钟时间、测量经过的时间和执行基准测试的 C 和 C++时间接口。

问题

  1. UDP 和 TCP 之间的主要区别是什么?

  2. UDP 使用什么协议类型?

  3. TCP 使用什么协议类型?

  4. AF_INET代表什么地址类型?

  5. bind()connect()之间有什么区别?

  6. sendto()send()之间有什么区别?

  7. UDP 服务器如何检测 UDP 客户端何时断开或崩溃?

  8. 使用数据包编组的好处是什么?

进一步阅读

第十一章:Unix 中的时间接口

在本章中,读者将学习如何使用 C++17 编程 POSIX 和 C++时间接口。首先,本章将介绍 UNIX 纪元和 POSIX time.h API 以及如何使用它们。接下来,将简要解释 C++ Chrono API,它们与time.h的关系,并提供一些示例。最后,本章将以两个简单的示例结束,演示如何使用时间接口。第一个示例将演示如何读取系统时钟并在间隔上将结果输出到控制台,第二个示例将演示如何使用 C++高分辨率计时器对软件进行基准测试。

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

  • 学习 POSIX time.h API

  • C++ Chrono API

  • 通过示例了解读取系统时钟

  • 涉及高分辨率计时器的示例

技术要求

为了编译和执行本章中的示例,读者必须具备以下条件:

  • 能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请访问以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter11

学习 POSIX time.h API

我们将从讨论 POSIX time.h API 开始,该 API 提供了用于读取各种时钟并对这些时钟时间进行计算的 API。尽管这些 API 特定于标准 C,但如下一节所示,当使用 C++时仍然需要 C 时间接口,这是 C++20 正在解决的问题。

学习有关 API 类型

UNIX 纪元定义了从 1970 年 1 月 1 日起的秒数。本章描述的接口利用 UNIX 纪元来定义时间的概念。本章中描述的 POSIX time.h API 定义了三种不同的不透明类型:

  • tm:一个不透明的结构,保存日期和时间。

  • time_t:一个typedef,通常使用存储从 UNIX 纪元起的秒数的整数来实现。

  • clock_t:一个typedef,用于存储应用程序执行的处理器时间量。

这些 API 提供了各种函数来创建这些类型并对其进行操作。应该注意,有不同类型的时钟:

  • 系统时钟:系统时钟读取操作系统维护的时钟,并存储向用户呈现的日期和时间(例如,任务栏上显示的时钟)。这个时钟可以在任何时间改变,因此通常不建议在应用程序中使用它进行计时,因为所使用的时钟可能以意想不到的方式向后/向前移动。

  • 稳定时钟:稳定时钟是程序执行时会滴答作响的时钟。程序执行得越多,这个时钟就会变得越大。应该注意,这个时钟不会与系统时钟的结果匹配,通常只有两个这些时钟之间的差异才有真正的价值。

  • 高分辨率时钟:这与稳定时钟相同,唯一的区别是返回的结果具有更高的分辨率。这些类型的时钟通常用于基准测试。

time() API

time() API 返回当前系统时钟,并采用以下形式:

time_t time(time_t *arg);

您可以使用预先定义的time_t变量提供time()函数,或者它将为您返回一个(如果您将nullptr作为参数传递),如下所示:

#include <ctime>
#include <iostream>

int main()
{
    auto t = time(nullptr);
    std::cout << "time: " << t << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: 1531603643

在前面的例子中,我们使用time() API 创建一个名为t的变量,以获取从 UNIX 纪元开始的当前秒数。然后将这个值输出到stdout。应该注意,time_t typedef 通常使用整数值实现,这就是为什么我们可以直接将其值输出到stdout的原因,就像前面的例子中所示的那样。

正如所述,也可以像下面这样使用time()提供自己之前定义的变量:

#include <ctime>
#include <iostream>

int main()
{
    time_t t;
    time(&t);
    std::cout << "time: " << t << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: 1531603652

前面的例子与第一个例子相同,但是不是存储time()的返回值,而是将我们的time_t变量作为参数传递给函数。虽然这种语法是支持的,但前者更受青睐。time()在出现错误时会返回-1,可以根据需要进行检查和处理。

ctime() typedef

time_t typedef 是特定于实现的,尽管它通常使用存储从 Unix 纪元开始的秒数的整数实现,但不能保证这种情况,这意味着前面的例子可能不会编译。相反,要以支持的方式输出time_t变量的值,使用ctime() API,形式如下:

char* ctime(const time_t* time);

ctime() API 接受一个指向time_t变量的指针,并输出一个标准的 C 字符串。返回的字符串所使用的内存由time.h API 维护(因此不需要被释放),因此不是线程安全的。可以如下使用这个 API:

#include <ctime>
#include <iostream>

int main()
{
    auto t = time(nullptr);
    std::cout << "time: " << ctime(&t);
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: Sat Jul 14 15:27:44 2018

从前面的例子可以看出,返回的不是从 Unix 纪元开始的秒数,而是当前时间和日期的可读版本。还应该注意的是,除了ctime()函数不是线程安全的之外,它也没有提供调整输出格式的机制。因此,通常不鼓励使用这个函数,而是使用其他time.h函数。

localtime()gmtime() API

time() API 返回一个存储从 Unix 纪元开始的秒数的time_t值,正如前面所述。这个值可以进一步处理以暴露日期和时间信息,使我们能够将日期和时间转换为本地时间或格林尼治标准时间GMT)。为此,POSIX API 提供了localtime()gmtime()函数,形式如下:

struct tm *localtime( const time_t *time );
struct tm *gmtime( const time_t *time );

这两个函数都接受一个指向time_t变量的指针,并返回一个指向tm不透明结构的指针。应该注意,返回值指向的结构像ctime()一样由time.h实现管理,因此不会被用户释放,这意味着这个函数的结果不是线程安全的。

asctime()函数

要将不透明的tm结构输出到stdout(或者一般来说,只是将结构转换为标准的 C 字符串),POSIX API 提供了asctime()函数,形式如下:

char* asctime( const struct tm* time_ptr );

asctime()函数的形式与ctime()相同,唯一的区别是主要参数是指向tm结构的指针,而不是time_t变量,如下所示:

#include <ctime>
#include <iostream>

int main()
{
    auto t = time(nullptr);
    std::cout << "time: " << asctime(localtime(&t));
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: Sat Jul 14 15:28:59 2018

如前面的例子所示,ctime()asctime(localtime())的输出没有区别。要输出 GMT 时间而不是本地时间,使用以下方式:

#include <ctime>
#include <iostream>

int main()
{
    auto t = time(nullptr);
    std::cout << "time: " << asctime(gmtime(&t));
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: Sat Jul 14 21:46:12 2018

如前面的例子所示,gmtime()localtime()执行相同,唯一的区别是时区的改变。

strftime()函数

到目前为止,ctime()asctime()的输出是由 POSIX API 预先确定的。也就是说,没有办法控制输出格式。此外,这些函数返回内部内存,阻止了它们的线程安全性。为了解决这些问题,POSIX API 添加了strftime()函数,这是将不透明的tm结构转换为字符串的推荐 API,形式如下:

size_t strftime(char * str, size_t count, const char *format, const struct tm *time);

str参数接受预分配的标准 C 字符串,而count参数定义第一个参数的大小。format参数接受一个以空字符结尾的标准 C 字符串,定义要将日期和时间转换为的格式,而最终的time参数接受不透明的tm结构以转换为字符串。提供给此函数的格式字符串类似于提供给其他 POSIX 函数的格式字符串,例如printf()。接下来的几个示例将演示一些这些格式说明符。

为了演示strftime()函数,以下将当前日期输出到stdout

#include <ctime>
#include <iostream>

int main()
{
    auto t = time(nullptr);

    char buf[256]{};
    strftime(buf, sizeof(buf), "%m/%d/%Y", localtime(&t));

    std::cout << "time: " << buf << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: 07/14/2018

如前面的例子所示,time() API 用于获取当前日期和时间。localtime()函数用于将time()的结果(即time_t)转换为表示本地日期和时间的不透明tm结构。得到的tm结构传递给strftime(),格式字符串为"%m/%d/%Y",将月/日/年输出到提供的标准 C 字符串。最后,将此字符串输出到stdout,结果为07/14/2018

同样,此函数可用于输出当前时间:

#include <ctime>
#include <iostream>

int main()
{
    auto t = time(nullptr);

    char buf[256]{};
    strftime(buf, sizeof buf, "%H:%M", localtime(&t));

    std::cout << "time: " << buf << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: 15:41

前面的例子与上一个例子相同,唯一的区别是格式说明符是%H:%M,表示小时:分钟,结果为15:41

最后,要输出与ctime()asctime()相同的字符串,请使用以下示例:

#include <ctime>
#include <iostream>

int main()
{
    auto t = time(nullptr);

    char buf[256]{};
    strftime(buf, sizeof buf, "%a %b %d %H:%M:%S %Y", localtime(&t));

    std::cout << "time: " << buf << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: Sat Jul 14 15:44:57 2018

前面的例子与前两个例子相同,唯一的区别是格式说明符为"%a %b %d %H:%M:%S %Y",输出与ctime()asctime()相同的结果。

difftime()函数

从技术上讲,time_t typedef 被认为是不透明的(尽管在 Unix 系统上几乎总是一个带符号的 32 位整数)。因此,为了确定两个time_t值之间的差异,提供了difftime()函数,如下所示:

double difftime(time_t time_end, time_t time_beg);

difftime()函数接受两个time_t值,并将差异作为双精度返回(因为非 POSIX 函数可能支持分数时间):

#include <ctime>
#include <iostream>

#include <unistd.h>

int main()
{
    auto t1 = time(nullptr);
    sleep(2);
    auto t2 = time(nullptr);

    std::cout << "diff: " << difftime(t2, t1) << '\n';
    std::cout << "diff: " << t2 - t1 << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// diff: 2

如前面的例子所示,difftime()函数返回两个时间之间的差异。应该注意的是,尽管前面的代码在大多数系统上都可以编译,但应该使用difftime()而不是直接减去两个值的第二个示例。

mktime()函数

如果您有两个不透明的tm结构,并希望计算它们的差异怎么办?问题在于difftime()函数只接受time_t而不是tm结构。为了支持localtime()gmtime()函数的反向操作,它们将time_t转换为tm结构,mktime()函数将tm结构转换回time_t值,如下所示:

time_t mktime(struct tm *time);

mktime()函数接受一个参数,即您希望将其转换为time_t值的不透明tm结构:

#include <ctime>
#include <iostream>

int main()
{
    auto t1 = time(nullptr);
    auto lt = localtime(&t1);
    auto t2 = mktime(lt);

    std::cout << "time: " << ctime(&t2);
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: Sat Jul 14 16:00:13 2018

前面的例子使用time() API 获取当前时间和日期,并使用localtime() API 将结果转换为tm结构。然后将得到的tm结构转换回time_t值,使用mktime()输出结果到stdout使用ctime()

clock()函数

到目前为止,time()已用于获取当前系统日期和时间。这种类型的时钟的问题在于它返回操作系统管理的与当前日期和时间相关的值,这可以在任何时间点发生变化(例如,用户可能在不同时区之间飞行)。例如,如果您使用时间 API 来跟踪某个操作执行了多长时间,这可能是一个问题。在这种情况下,当时区发生变化时,使用time()的应用程序可能会记录经过的时间为负数。

为了解决这个问题,POSIX 提供了clock()函数,如下所示:

clock_t clock(void);

clock() API 返回一个clock_t值,它类似于time_t值。time()clock()之间的区别在于,time()返回当前系统时间,而clock()返回一个代表自应用程序启动以来经过的总时间的值,例如:

#include <ctime>
#include <iostream>

int main()
{
    std::cout << "clock: " << clock() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// clock: 2002

在上面的例子中,clock()的结果输出到stdout。如图所示,该值是特定于实现的,只有两个clock_t值之间的差异才有意义。要将clock_t转换为秒,POSIX 提供了CLOCKS_PER_SEC宏,它提供了必要的转换,如下例所示:

#include <ctime>
#include <iostream>

#include <unistd.h>

int main()
{
    auto c1 = clock();
    sleep(2);
    auto c2 = clock();

    std::cout << "clock: " <<
        static_cast<double>(c2 - c1) / CLOCKS_PER_SEC << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// clock: 3.2e-05

在上面的例子中,使用clock()API 获取第一个时钟值,然后应用程序睡眠两秒。一旦操作系统再次执行应用程序,就会再次读取时钟值,并将差异转换为毫秒,使用CLOCKS_PER_SEC(然后乘以 1,000)。请注意,该值不等于 2,000 毫秒。这是因为应用程序在睡眠时不记录执行时间,因此clock()只能看到应用程序的执行时间。

为了更好地展示时间的差异,以下示例演示了clock()time()的一对一比较:

#include <ctime>
#include <iostream>

#include <unistd.h>

int main()
{
    auto c1 = clock();

    auto t1 = time(nullptr);
    while(time(nullptr) - t1 <= 2);

    auto c2 = clock();

    std::cout << "clock: " <<
        static_cast<double>(c2 - c1) / CLOCKS_PER_SEC << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// clock: 2.05336

上面的例子与前面的例子相同,唯一的区别是我们使用time()旋转两秒,而不是睡眠两秒,导致clock()返回两秒。

探索 C++ Chrono API

C++包括 Chrono API,大多数情况下提供了对 POSIX time.h API 的 C++包装。因此,仍然需要一些 time.h 函数来提供完整的功能,包括转换为标准 C 字符串。值得注意的是,尽管在 C++17 中进行了一些添加(特别是floor()ceil()round()),但随着 C++20 的引入,Chrono API 预计会进行相当大的改进,这超出了本书的范围。因此,本节简要解释了 C++ Chrono API,以提供当前 API 的概述。

system_clock() API

std::chrono::system_clock{} API 类似于time(),它能够获取系统时钟。system_clock{}也是唯一能够转换为time_t的时钟(因为它很可能是使用time()实现的),如下例所示:

#include <chrono>
#include <iostream>

int main()
{
    auto t = std::chrono::system_clock::now();
    std::cout << "time: " << std::chrono::system_clock::to_time_t(t) << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: 1531606644

在上面的例子中,使用system_clock::now()API 读取当前系统时钟,并使用system_clock::to_time_t()API 将结果转换为time_t值。与前面的例子一样,结果是从 Unix 纪元开始的秒数。

time_point API

system_clock::now() API 的结果是一个time_point{}。C++没有提供将time_point{}转换为字符串的函数(直到 C++20 才会提供),因此仍然需要使用前面讨论过的 POSIX 函数来执行这种转换,如下所示:

#include <chrono>
#include <iostream>

template<typename C, typename D>
std::ostream &
operator<<(std::ostream &os, std::chrono::time_point<C,D> &obj)
{
    auto t = std::chrono::system_clock::to_time_t(obj);
    return os << ctime(&t);
}

int main()
{
    auto now = std::chrono::system_clock::now();
    std::cout << "time: " << now;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: Sat Jul 14 19:01:55 2018

在上面的例子中,我们首先为std::chrono::system_clock::now()API 的结果time_point{}定义了一个用户定义的重载。这个用户定义的重载使用 C++的std::chrono::system_clock::to_time_t()API 将time_point{}转换为time_t值,然后使用ctime()time_t转换为标准 C 字符串,并将结果流式输出到stdout

与 POSIX time.h API 不同,Chrono 库提供了各种函数来使用 C++运算符重载对time_point{}进行递增、递减和比较,如下所示:

#include <chrono>
#include <iostream>

template<typename C, typename D>
std::ostream &
operator<<(std::ostream &os, const std::chrono::time_point<C,D> &obj)
{
    auto t = std::chrono::system_clock::to_time_t(obj);
    return os << ctime(&t);
}

int main()
{
    using namespace std::chrono;

    auto now = std::chrono::system_clock::now();

    std::cout << "time: " << now;

    now += 1h;
    std::cout << "time: " << now;

    now -= 1h;
    std::cout << "time: " << now;
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: 1531606644

在上面的例子中,提供了time_point{}的用户定义重载,与前面的例子一样。使用std::chrono::system_clock::now()读取当前日期和时间,并将结果输出到stdout。最后,将得到的time_point{}增加一个小时,然后减少一个小时(使用小时字面量),并将结果也输出到stdout

此外,还支持算术比较,如下所示:

#include <chrono>
#include <iostream>

int main()
{
    auto now1 = std::chrono::system_clock::now();
    auto now2 = std::chrono::system_clock::now();

    std::cout << std::boolalpha;
    std::cout << "compare: " << (now1 < now2) << '\n';
    std::cout << "compare: " << (now1 > now2) << '\n';
    std::cout << "compare: " << (now1 <= now2) << '\n';
    std::cout << "compare: " << (now1 >= now2) << '\n';
    std::cout << "compare: " << (now1 == now2) << '\n';
    std::cout << "compare: " << (now1 != now2) << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// compare: true
// compare: false
// compare: true
// compare: false
// compare: false
// compare: true

在上面的例子中,系统时钟被读取两次,然后使用支持的比较运算符比较得到的time_point{}值。应该注意,这个例子的结果可能因执行代码的系统不同而不同,因为时间的分辨率可能不同。

持续时间

time_point{}类型提供了增加、减少、执行加法和减法的算术运算。所有这些算术运算都是使用 C++ Chrono duration{}完成的,它定义了一段时间。另一种看待duration{}的方式是它将是 POSIX difftime()调用的结果抽象。事实上,两个time_point{}类型的减法结果是一个duration{}

在前面的例子中,time_point{}使用小时持续时间字面量增加和减少了一个小时。与小时字面量类似,C++还为时间持续时间提供了以下字面量,可用于此类算术运算:

  • 小时h

  • 分钟min

  • s

  • 毫秒ms

  • 微秒us

  • 纳秒ns

持续时间具有相对复杂的模板结构,超出了本书的范围,用于定义它们的分辨率(即持续时间是以秒、毫秒还是小时为单位),并且在技术上可以以几乎任何分辨率进行。尽管存在这种功能,但 C++提供了一些预定义的辅助程序,用于将一种持续时间转换为另一种,从而避免您需要了解duration{}的内部工作方式:

  • std::chrono::nanoseconds

  • std::chrono::microseconds

  • std::chrono::milliseconds

  • std::chrono::seconds

  • std::chrono::minutes

  • std::chrono::hours 

例如,下面我们将使用这些预定义的辅助程序将系统时钟转换为秒和毫秒:

#include <chrono>
#include <iostream>

#include <unistd.h>

int main()
{
    using namespace std::chrono;

    auto now1 = system_clock::now();
    sleep(2);
    auto now2 = system_clock::now();

    std::cout << "time: " <<
        duration_cast<seconds>(now2 - now1).count() << '\n';

    std::cout << "time: " <<
        duration_cast<milliseconds>(now2 - now1).count() << '\n';

    std::cout << "time: " <<
        duration_cast<nanoseconds>(now2 - now1).count() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: 2
// time: 2001
// time: 2001415132

在上面的例子中,系统时钟被读取两次,每次读取之间间隔两秒的睡眠。然后将得到的time_point{}值相减以创建一个duration{},并将得到的duration{}转换为秒、毫秒和纳秒,结果使用count()成员函数输出到stdout,该函数简单地返回duration{}的值。

time_point{}一样,持续时间也可以使用算术运算进行操作,如下所示:

#include <chrono>
#include <iostream>

int main()
{
    using namespace std::chrono;

    seconds t(42);

    t++;
    std::cout << "time: " << t.count() << '\n';

    t--;
    std::cout << "time: " << t.count() << '\n';

    t += 1s;
    std::cout << "time: " << t.count() << '\n';

    t -= 1s;
    std::cout << "time: " << t.count() << '\n';

    t %= 2s;
    std::cout << "time: " << t.count() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: 43
// time: 42
// time: 43
// time: 42
// time: 0

在上面的例子中,创建了两个代表一秒的duration{}变量,一个值为0秒,另一个值为42秒。然后对第一个持续时间进行算术运算,并将结果输出到stdout

此外,还支持比较:

#include <chrono>
#include <iostream>

int main()
{
    using namespace std::chrono;

    auto t1 = 0s;
    auto t2 = 42s;

    std::cout << std::boolalpha;
    std::cout << "compare: " << (t1 < t2) << '\n';
    std::cout << "compare: " << (t1 > t2) << '\n';
    std::cout << "compare: " << (t1 <= t2) << '\n';
    std::cout << "compare: " << (t1 >= t2) << '\n';
    std::cout << "compare: " << (t1 == t2) << '\n';
    std::cout << "compare: " << (t1 != t2) << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// compare: true
// compare: false
// compare: true
// compare: false
// compare: false
// compare: true

在上面的例子中,创建了两个分别代表0秒和42秒的持续时间,并使用比较运算符进行比较。

大多数对 Chrono 库的修改可能会在 C++20 中进行,大量的 API 将被添加以解决现有 API 的明显缺陷。然而,在 C++17 中,floor()ceil()round()abs() API 被添加到了 Chrono API 中,它们返回持续时间的 floor、ceil、round 或绝对值,如下例所示(类似的 API 也被添加到了time_point{}类型中):

#include <chrono>
#include <iostream>

int main()
{
    using namespace std::chrono;

    auto s1 = -42001ms;

    std::cout << "floor: " << floor<seconds>(s1).count() << '\n';
    std::cout << "ceil: " << ceil<seconds>(s1).count() << '\n';
    std::cout << "round: " << round<seconds>(s1).count() << '\n';
    std::cout << "abs: " << abs(s1).count() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// floor: -43
// ceil: -42
// round: -42
// abs: 42001

稳定时钟函数

system_clock{}类似于time(),而steady_clock{}类似于clock(),并且执行相同的目标——提供一个代表应用程序执行时间的时钟,而不考虑当前系统日期和时间(这可能会根据系统用户而改变);例如:

#include <chrono>
#include <iostream>

#include <unistd.h>

int main()
{
    using namespace std::chrono;

    auto now1 = steady_clock::now();
    sleep(2);
    auto now2 = steady_clock::now();

    std::cout << "time: " <<
        duration_cast<seconds>(now2 - now1).count() << '\n';

    std::cout << "time: " <<
        duration_cast<milliseconds>(now2 - now1).count() << '\n';

    std::cout << "time: " <<
        duration_cast<nanoseconds>(now2 - now1).count() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: 2
// time: 2001
// time: 2001447628

在上面的示例中,steady_clock::now()函数被调用两次,两次调用之间有一个睡眠。然后将得到的值相减,转换为秒、毫秒和纳秒,并将结果输出到stdout。需要注意的是,与clock()不同,得到的稳定时钟考虑了应用程序休眠的时间。

高分辨率时钟函数

在大多数系统上,high_resolution_clock{}steady_clock{}是相同的。一般来说,high_resolution_clock{}代表最高分辨率的稳定时钟,并且如下例所示,与stead_clock{}的结果相同:

#include <chrono>
#include <iostream>

#include <unistd.h>

int main()
{
    using namespace std::chrono;

    auto now1 = high_resolution_clock::now();
    sleep(2);
    auto now2 = high_resolution_clock::now();

    std::cout << "time: " <<
        duration_cast<seconds>(now2 - now1).count() << '\n';

    std::cout << "time: " <<
        duration_cast<milliseconds>(now2 - now1).count() << '\n';

    std::cout << "time: " <<
        duration_cast<nanoseconds>(now2 - now1).count() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// time: 2
// time: 2000
// time: 2002297281

在上面的示例中,high_resolution_clock::now()函数被调用两次,两次调用之间有一个睡眠。然后将得到的值相减,转换为秒、毫秒和纳秒,并将结果输出到stdout

研究读取系统时钟的示例

在这个示例中,我们将把本章学到的所有内容融入到一个简单的演示中,该演示按用户指定的间隔读取系统时钟。为了实现这一点,需要以下包含和命名空间:

#include <chrono>
#include <iostream>

#include <gsl/gsl>

#include <unistd.h>

using namespace std::chrono;

与本章中的其他示例一样,提供了一个用户定义的std::ostream{}重载,将time_point{}转换为标准 C 字符串,然后将结果流式输出到stdout

template<typename C, typename D>
std::ostream &
operator<<(std::ostream &os, std::chrono::time_point<C,D> &obj)
{
    auto t = std::chrono::system_clock::to_time_t(obj);
    return os << ctime(&t);
}

在我们的protected_main()函数中(这是本书中使用的一种模式),我们按用户提供的间隔输出当前系统时间,如下所示:

int
protected_main(int argc, char **argv)
{
    using namespace std::chrono;
    auto args = gsl::make_span(argv, argc);

    if (args.size() != 2) {
        std::cerr << "wrong number of arguments\n";
        ::exit(1);
    }

    gsl::cstring_span<> arg = gsl::ensure_z(args.at(1));

    while(true) {
        auto now = std::chrono::system_clock::now();
        std::cout << "time: " << now;

        sleep(std::stoi(arg.data()));
    }
}

在上面的代码中,我们将参数列表转换为gsl::span{},然后确保我们提供了一个参数。如果没有提供参数,我们就退出程序。然后将参数转换为cstring_span{},并启动一个无限循环。在循环中,读取系统时钟并将其输出到stdout,然后程序休眠用户提供的时间:

int
main(int argc, char **argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

与我们所有的示例一样,protected_main()函数由main()函数执行,如果发生异常,main()函数会捕获异常。

编译和测试

要编译这段代码,我们利用了与其他示例相同的CMakeLists.txt文件:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter11/CMakeLists.txt

有了这段代码,我们可以使用以下命令编译这段代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter10/
> mkdir build
> cd build

> cmake ..
> make

要执行这个示例,运行以下命令:

> ./example1 2
time: Sun Jul 15 15:04:41 2018
time: Sun Jul 15 15:04:43 2018
time: Sun Jul 15 15:04:45 2018
time: Sun Jul 15 15:04:47 2018
time: Sun Jul 15 15:04:49 2018

如前面的片段所示,示例以两秒的间隔运行,并且应用程序每两秒将系统时钟输出到控制台。

研究高分辨率定时器的示例

在这个示例中,我们将使用high_resolution_clock{}创建一个简单的基准测试。为了实现这一点,需要以下包含和命名空间:

#include <chrono>
#include <iostream>

#include <gsl/gsl>

要创建一个benchmark函数,我们使用以下内容:

template<typename FUNC>
auto benchmark(FUNC func) {
    auto stime = std::chrono::high_resolution_clock::now();
    func();
    auto etime = std::chrono::high_resolution_clock::now();

    return etime - stime;
}

这个函数在第八章中已经见过,学习文件输入/输出编程,日志示例。这段代码利用函数式编程将一个函数调用(可能是一个 lambda)包装在两次高分辨率时钟调用之间。然后相减并返回结果。正如我们在本章中学到的,high_resolution_clock{}返回一个time_point{},它们的差值创建一个duration{}

protected_main()函数的实现如下:

int
protected_main(int argc, char **argv)
{
    using namespace std::chrono;

    auto args = gsl::make_span(argv, argc);

    if (args.size() != 2) {
        std::cerr << "wrong number of arguments\n";
        ::exit(1);
    }

    gsl::cstring_span<> arg = gsl::ensure_z(args.at(1));

    auto d = benchmark([&arg]{
        for (uint64_t i = 0; i < std::stoi(arg.data()); i++);
    });

    std::cout << "time: " <<
        duration_cast<seconds>(d).count() << '\n';

    std::cout << "time: " <<
        duration_cast<milliseconds>(d).count() << '\n';

    std::cout << "time: " <<
        duration_cast<nanoseconds>(d).count() << '\n';
}

在上述代码中,我们将参数列表转换为gsl::span{},然后检查确保我们得到了一个参数。如果没有提供参数,我们就退出程序。然后将参数转换为cstring_span{},并对用户希望运行的时间进行基准测试。基准测试的结果然后转换为秒、毫秒和纳秒,并输出到stdout

int
main(int argc, char **argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

与我们所有的示例一样,protected_main()函数由main()函数执行,如果发生异常,main()函数会捕获异常。

编译和测试

为了编译这段代码,我们利用了与其他示例相同的CMakeLists.txt文件:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter11/CMakeLists.txt

有了这段代码,我们可以使用以下方法编译这段代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter10/
> mkdir build
> cd build

> cmake ..
> make

要执行这个示例,运行以下命令:

> ./example2 1000000
time: 0
time: 167
time: 167455690

如前面的片段所示,示例是通过一个循环运行的,循环次数为1000000,并且执行该循环所需的时间被输出到控制台。

总结

在本章中,我们学习了如何使用 POSIX 和 C++时间接口来读取系统时钟,以及使用稳定时钟进行更精确的计时。本章以两个示例结束;第一个示例演示了如何读取系统时钟并在用户定义的间隔内将结果输出到控制台,第二个示例演示了如何使用 C++高分辨率计时器对软件进行基准测试。在下一章中,我们将学习如何使用 POSIX 和 C++线程,并且会通过本章所学的知识构建示例。

在下一章中,我们将讨论 C++线程、互斥锁等同步原语,以及如何对它们进行编程。

问题

  1. Unix 纪元是什么?

  2. time_t通常表示什么类型?

  3. time()clock()之间有什么区别?

  4. 为什么difftime()返回一个 double?

  5. C++ duration{}是什么?

  6. steady_clock{}high_resolution_clock{}之间有什么区别?

进一步阅读

第十二章:学习编程 POSIX 和 C++ 线程

在本章中,读者将学习如何编程使用 POSIX 和 C++ 线程。我们将首先讨论如何使用 POSIX 线程编程,然后转向 C++ 线程,提供每个 API 的比较。

然后我们将呈现三个示例。第一个示例将演示如何使用线程执行并行计算。第二个示例将演示如何使用线程创建自己的高分辨率计时器以进行基准测试(尽管该计时器可能不太准确)。

第三个和最后一个示例将在现有的调试示例基础上构建,以提供对多个客户端的支持。

应注意,本章假定读者已经基本了解线程、线程同步以及与竞争条件和死锁相关的挑战。在这里,我们将只关注 POSIX 和 C++ 提供的用于处理线程的 API。

本章将涵盖以下内容:

  • POSIX 线程

  • C++ 线程

  • 并行计算

  • 使用线程进行基准测试

  • 线程日志记录

技术要求

为了遵循本章的示例,读者必须具备以下知识:

  • 能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请转到以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter12

理解 POSIX 线程

线程类似于进程,主要区别如下:

  • 线程包含在进程内

  • 线程本质上与同一进程的其他线程共享内存空间,而进程不共享资源,除非明确告知(使用进程间通信机制)。

与进程一样,线程也可以被操作系统随时调度执行。如果正确使用,这可能意味着与其他线程并行执行,从而导致性能优化,但代价是引入特定于线程的逻辑错误,如竞争条件和死锁。

本节的目标是简要回顾 POSIX 线程。这些在很大程度上影响了 C++ 线程的设计,稍后将进行讨论。

POSIX 线程的基础知识

线程的最基本用法是创建它,然后加入线程,这实际上是在线程完成工作之前等待线程完成。

#include <iostream>
#include <pthread.h>

void *mythread(void *ptr)
{
    std::cout << "Hello World\n";
    return nullptr;
}

int main()
{
    pthread_t thread1;
    pthread_t thread2;

    pthread_create(&thread1, nullptr, mythread, nullptr);
    pthread_create(&thread2, nullptr, mythread, nullptr);

    pthread_join(thread1, nullptr);
    pthread_join(thread2, nullptr);
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// Hello World
// Hello World

在前面的示例中,创建了一个具有 (void *)(*)(void *) 签名的 mythread() 函数,这是 POSIX 线程所需的。在此示例中,线程只是简单地输出到 stdout 并返回。

main() 函数中,使用 pthread_create() 函数创建了两个线程,其形式如下:

int pthread_create(
    pthread_t *thread, 
    const pthread_attr_t *attr, 
    void *(*start_routine)(void*), 
    void *arg
);

在此示例中,创建了一个 pthread_t 类型,并传递给第一个参数。使用 nullptr 忽略了属性参数,线程本身的参数也是如此(因为它没有被使用)。我们向 pthread_create 函数提供的唯一其他内容是线程本身,它是指向我们的 mythread() 函数的函数指针。

要等待线程完成,我们使用 pthread_join() 函数,其形式如下:

int pthread_join(pthread_t thread, void **value_ptr);

先前创建的 pthread 作为此函数的第一个参数提供,而 pthread 的返回值使用 nullptr 忽略(因为线程不返回值)。

此示例的结果是Hello World被输出到stdout两次(因为创建了两个线程)。

应注意,此示例存在几个问题,我们将在本章中简要讨论(因为整本书都可以写关于并行计算的主题):

  • 类型安全:线程的参数和返回值都作为void *传递,完全消除了与线程本身相关的任何形式的类型安全。因此,pthread接口不符合 C++核心指南,并鼓励创建难以发现的逻辑错误。正如将要演示的,C++在很大程度上解决了这些问题,尽管有时可能难以遵循接口。

  • 竞争条件:前面的例子并没有尝试解决两个线程同时输出到stdout可能出现的竞争条件。因此,如果这个例子被执行足够多次,很可能会导致输出方面的损坏。

  • 没有输入/输出:通常,线程在不需要输入或输出的情况下操作全局定义的数据,但完全有可能在不同的情况下需要输入和/或输出。这个例子没有解决如何实现这一点。

线程的实现方式因操作系统而异,跨平台软件需要考虑这一点。一些操作系统将线程实现为单独的进程,而另一些将线程实现为进程内的单独的可调度任务。

无论如何,POSIX 规范规定线程是可识别的,而不管底层实现如何。

要识别线程,可以使用以下方法:

#include <iostream>
#include <pthread.h>

void *mythread(void *ptr)
{
    std::cout << "thread id: " 
              << pthread_self() << '\n';

    return nullptr;
}

main()
{
    pthread_t thread1;
    pthread_t thread2;

    pthread_create(&thread1, nullptr, mythread, nullptr);
    pthread_create(&thread2, nullptr, mythread, nullptr);

    pthread_join(thread1, nullptr);
    pthread_join(thread2, nullptr);
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// thread id: 140232513570560
// thread id: 140232505177856

前面的例子与第一个例子相同,唯一的区别是,我们使用pthread_self()函数而不是将Hello World输出到stdout来输出线程的标识符。pthread_self()函数采用以下形式:

pthread_t pthread_self(void);

由于pthread_t类型通常使用整数类型实现,在我们前面的例子中,我们可以使用std::cout将这种类型的值输出到stdout

为了支持输入和输出,pthread API 为线程函数的输入和输出都提供了void *。以下示例演示了如何做到这一点:

#include <iostream>
#include <pthread.h>

void *mythread(void *ptr)
{
    (*reinterpret_cast<int *>(ptr))++;
    return ptr;
}

main()
{
    int in_value = 42;
    void *out_value = nullptr;

    pthread_t thread1;
    pthread_t thread2;

    pthread_create(&thread1, nullptr, mythread, &in_value);
    pthread_create(&thread2, nullptr, mythread, &in_value);

    pthread_join(thread1, &out_value);
    pthread_join(thread2, &out_value);

    std::cout << "value: " 
              << *reinterpret_cast<int *>(out_value) << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// 44

在这个例子中,线程函数假设它传递的参数是一个整数的指针。它获取提供的值,递增它,然后将其返回给调用者(在这种情况下是main()函数)。

main()函数中,我们创建了一个输入值和一个输出值,其中输入被初始化为42。在线程创建时提供了指向输入值的指针,并在加入线程时提供了指向输出值的指针。

最后,结果值被输出到stdout。这是44,因为创建了两个线程,每个线程递增了提供的输入一次。

由于两个线程都在同一个整数上操作,如果它们恰好同时执行,可能会出现竞争条件,这可能会破坏这些线程的结果;这个问题将在以后解决。

产量

使用线程的一个优点是它们可以长时间执行而不会阻止主线程/应用程序的执行。缺点是,没有结束的线程可能会消耗太多的 CPU。

例如,考虑以下代码:

#include <iostream>
#include <pthread.h>

void *mythread(void *ptr)
{
    while(true) {
        std::clog << static_cast<char *>(ptr) << '\n';
        pthread_yield();
    }
}

main()
{
    char name1[9] = "thread 1";
    char name2[9] = "thread 2";

    pthread_t thread1;
    pthread_t thread2;

    pthread_create(&thread1, nullptr, mythread, name1);
    pthread_create(&thread2, nullptr, mythread, name2);

    pthread_join(thread1, nullptr);
    pthread_join(thread2, nullptr);
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// thread 2
// thread 2
// thread 2
// thread 1
// thread 2
// thread 2
// thread 1
// thread 1
// thread 1

在前面的例子中,我们创建了一个使用while(true)语句的线程,它尽可能快地永远执行。这样的线程将执行,直到操作系统决定抢占线程以调度另一个线程或进程,导致线程的输出以阻塞的几乎串行的方式发生。

然而,在某些情况下,用户可能需要线程执行一个动作,然后释放其对 CPU 的访问权,以允许另一个线程执行其任务。为了实现这一点,我们使用pthread_yield()API,它采用以下形式:

int pthread_yield(void)

在前面的例子中,使用yield函数为每个线程提供了执行的机会,导致线程 1线程 2的输出更好地混合在一起。

尽管提供了这个函数,但应该注意操作系统在处理必须执行大量工作的线程时非常出色,pthread_yield()只有在用户明确了解它如何在特定用例中提供优化时才应该使用(因为过度使用pthread_yield()函数实际上可能会导致性能下降)。

还应该注意到pthread_yield()并不是所有 Unix 系统都可用。

除了pthread_yield(),POSIX API 还提供了一些函数,如果没有要执行的任务,可以使线程休眠(从而提高性能和电池寿命),如下所示:

#include <iostream>

#include <unistd.h>
#include <pthread.h>

void *mythread(void *ptr)
{
    while (true) {
        sleep(1);
        std::cout << "hello world\n";
    }
}

main()
{
    pthread_t thread;
    pthread_create(&thread, nullptr, mythread, nullptr);
    pthread_join(thread, nullptr);
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// hello world
// hello world
// hello world

在前面的示例中,我们创建了一个线程,每秒输出一次Hello World,方法是创建一个输出到stdout的单个线程,然后使用sleep()函数使线程休眠一秒钟。

应该注意,对sleep()的使用应该谨慎,因为操作系统可能在调用sleep()之前就已经执行了sleep()调用。

同步

竞争条件在使用线程时是一个常见问题,解决竞争条件而不引入死锁(由于线程同步逻辑的逻辑错误而无法执行的线程)是一个复杂的主题,值得有专门的书籍来讨论。

以下示例试图演示潜在竞争条件的问题:

#include <array>
#include <iostream>
#include <pthread.h>

int count = 0;

void *mythread(void *ptr)
{
    count++;
}

main()
{
    while (true) {
        count = 0;
        for (auto i = 0; i < 1000; i++) {
            std::array<pthread_t, 8> threads;

            for (auto &t : threads) {
                pthread_create(&t, nullptr, mythread, nullptr);
            }

            for (auto &t : threads) {
                pthread_join(t, nullptr);
            }
        }

        std::cout << "count: " << count << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// count: 7992
// count: 7996
// count: 7998
// count: 8000
// count: 8000

要产生竞争条件,我们必须以足够快的速度执行线程,并且足够长的时间(特别是在现代硬件上),以便一个线程在另一个线程在同一共享资源上执行操作时,另一个线程正在完成自己对该共享资源的操作。

有很多种方法可以做到这一点。在前面的示例中,我们有一个递增计数器的线程,然后我们创建了8000个这样的线程,增加了竞争条件发生的机会。在执行过程中的某个时刻,两个线程同时读取计数器的当前值,增加该值并同时存储增加后的值。这导致计数器只增加了一次,尽管有两个线程在执行。

因此,从示例的输出中可以看出,在某些情况下,计数小于8000。在这些情况下,发生了竞争条件,导致了数据损坏。

为了解决这个问题,我们必须保护关键区域,这在这种情况下是使用共享资源的线程部分。以下示例演示了使用互斥锁(确保对关键区域的互斥访问)的一种方法:

#include <array>
#include <iostream>
#include <pthread.h>

int count = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *mythread(void *ptr)
{
    pthread_mutex_lock(&lock);
    count++;
    pthread_mutex_unlock(&lock);
}

main()
{
    while (true) {
        count = 0;
        for (auto i = 0; i < 1000; i++) {
            std::array<pthread_t, 8> threads;

            for (auto &t : threads) {
                pthread_create(&t, nullptr, mythread, nullptr);
            }

            for (auto &t : threads) {
                pthread_join(t, nullptr);
            }
        }

        std::cout << "count: " << count << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// count: 8000
// count: 8000
// count: 8000
// count: 8000
// count: 8000

在前面的示例中,我们用互斥锁包装了关键区域。互斥锁利用原子操作(由硬件保证的操作,可以在不损坏的情况下操作共享资源)来一次让一个线程访问关键区域。

如果一个线程在另一个线程正在使用关键区域时尝试访问关键区域,它将等待直到该线程完成。一旦线程完成,所有等待的线程都会竞争访问关键区域,获胜的线程获得访问权限,而其余线程继续等待。(每个操作系统都有自己的实现方式,以防止饥饿的可能性;这是本书范围之外的另一个主题。)

从前面示例的输出中可以看出,使用互斥锁包围关键区域(在这种情况下是递增count变量)可以防止竞争条件的发生,每次输出都是8000

互斥锁的问题在于每次锁定互斥锁时,线程必须等待直到解锁才能继续。这样可以保护关键区域免受其他线程的干扰,但如果同一线程尝试多次锁定同一互斥锁(例如在使用递归时),或者以错误的顺序锁定互斥锁,就会导致死锁。

为了解决这个问题,POSIX API 提供了将互斥锁转换为递归互斥锁的能力,如下所示:

#include <iostream>
#include <pthread.h>

int count = 0;
pthread_mutex_t lock;
pthread_mutexattr_t attr;

void *mythread(void *ptr)
{
    pthread_mutex_lock(&lock);
    pthread_mutex_lock(&lock);
    pthread_mutex_lock(&lock);
    count++;
    pthread_mutex_unlock(&lock);
    pthread_mutex_unlock(&lock);
    pthread_mutex_unlock(&lock);
}

int main()
{
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
    pthread_mutex_init(&lock, &attr);

    pthread_t thread1;
    pthread_t thread2;

    pthread_create(&thread1, nullptr, mythread, nullptr);
    pthread_create(&thread2, nullptr, mythread, nullptr);

    pthread_join(thread1, nullptr);
    pthread_join(thread2, nullptr);

    std::cout << "count: " << count << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// count: 2

在前面的例子中,我们能够多次锁定互斥锁,而不会因此造成死锁,首先使用互斥锁属性将互斥锁设置为递归模式。应该注意,这种额外的灵活性通常伴随着额外的开销。

我们将在本章讨论的最后一个 POSIX API 是条件变量。正如之前所演示的,互斥锁可以用来同步对代码的关键区域的访问。线程同步的另一种形式是确保线程按正确的顺序执行,这就是条件变量所允许的。

在下面的例子中,线程 1 和 2 可以随时执行:

#include <iostream>
#include <pthread.h>

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *mythread1(void *ptr)
{
    pthread_mutex_lock(&lock);
    std::cout << "Hello World: 1\n";
    pthread_mutex_unlock(&lock);

    return nullptr;
}

void *mythread2(void *ptr)
{
    pthread_mutex_lock(&lock);
    std::cout << "Hello World: 2\n";
    pthread_mutex_unlock(&lock);

    return nullptr;
}

main()
{
    pthread_t thread1;
    pthread_t thread2;

    pthread_create(&thread2, nullptr, mythread2, nullptr);
    pthread_create(&thread1, nullptr, mythread1, nullptr);

    pthread_join(thread1, nullptr);
    pthread_join(thread2, nullptr);
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// Hello World: 2
// Hello World: 1

在这个例子中,我们创建了两个线程,每个线程都在使用互斥锁保护的关键区域中输出到stdout。示例的其余部分与本章中之前的示例相同。如所示,线程 2首先执行,然后是线程 1(这主要是因为线程 2先创建)。然而,仍然有可能线程 1先执行,因为没有控制线程执行顺序的东西。

为了解决这个问题,POSIX API 提供了一个条件变量,可以用来同步线程的顺序,如下所示:

#include <iostream>
#include <pthread.h>

bool predicate = false;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *mythread1(void *ptr)
{
    pthread_mutex_lock(&lock);
    std::cout << "Hello World: 1\n";
    predicate = true;
    pthread_mutex_unlock(&lock);
    pthread_cond_signal(&cond);

    return nullptr;
}

void *mythread2(void *ptr)
{
    pthread_mutex_lock(&lock);
    while(!predicate) {
        pthread_cond_wait(&cond, &lock);
    }
    std::cout << "Hello World: 2\n";
    pthread_mutex_unlock(&lock);

    return nullptr;
}

main()
{
    pthread_t thread1;
    pthread_t thread2;

    pthread_create(&thread2, nullptr, mythread2, nullptr);
    pthread_create(&thread1, nullptr, mythread1, nullptr);

    pthread_join(thread1, nullptr);
    pthread_join(thread2, nullptr);
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// Hello World: 1
// Hello World: 2

正如我们所看到的,线程 1首先执行,然后是线程 2,尽管线程 2是先创建的。为了实现这一点,我们使用pthread_cond_wait()pthread_cond_signal()函数,如下所示:

bool predicate = false;
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);

pthread_cond_wait()函数接受一个指向条件变量和互斥锁的指针。当它被执行时,它会解锁互斥锁并等待pthread_cond_signal()的调用。一旦发送了信号,pthread_cond_wait()再次锁定互斥锁并继续执行。

使用predicate变量,它也受到互斥锁的保护,用于确保处理任何虚假唤醒。具体来说,pthread_cond_wait()函数可能会在条件变量尚未被发出信号的情况下唤醒。因此,您必须始终将pthread_cond_wait()函数与predicate配对使用。

探索 C++线程

在前一节中,我们学习了 POSIX 如何支持线程。在本节中,我们将讨论 C++线程,它们在很大程度上受到了 POSIX 线程的启发。它们提供了类似的功能,同时在某些方面简化了 API,并提供了类型安全性。

C++线程的基础知识

为了展示 C++线程的简单性,下面的例子,就像本章中的第一个例子一样,创建了两个线程,然后等待它们执行完毕:

#include <thread>
#include <iostream>

void mythread()
{
    std::cout << "Hello World\n";
}

main()
{
    std::thread t1{mythread};
    std::thread t2{mythread};

    t1.join();
    t2.join();
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// Hello World
// Hello World

与此示例的 POSIX 版本相比,有一些显著的区别:

  • 线程函数本身可能具有多种不同的函数签名,并不限于(void *)(*)(void *)。在这个例子中,线程函数使用了void(*)()签名。

  • 线程类型的构造函数也创建了线程(无需定义类型,然后显式创建线程)。

应该注意,在 Linux 中,仍然需要将pthread库链接到示例中。这是因为在底层,C++使用pthread实例来提供线程支持。

与 POSIX 版本相似,C++也提供了获取线程 ID 的能力,如下所示:

#include <thread>
#include <iostream>

void mythread()
{
    std::cout << "thread id: " 
              << std::this_thread::get_id() << '\n';
}

main()
{
    std::thread t1{mythread};
    std::thread t2{mythread};

    std::cout << "thread1 id: " << t1.get_id() << '\n';
    std::cout << "thread2 id: " << t2.get_id() << '\n';

    t1.join();
    t2.join();
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// thread1 id: 139960486229760
// thread2 id: 139960477837056
// thread id: 139960477837056
// thread id: 139960486229760

在前面的例子中,我们同时使用了this_thread命名空间和线程本身来获取 ID,演示了查询线程 ID 的两种不同方式(取决于调用者的观点)。

C++线程的输入和输出是 C++线程在某些方面比 POSIX 线程更复杂的一个很好的例子。正如所述,关于输入和输出,POSIX 线程最大的问题是明显缺乏类型安全性。

为了解决这个问题,C++提供了一个叫做 C++ futures 的概念,它本身可能值得有自己的章节。我们将在这里简要描述它们,以便让读者对它们的工作原理有一些一般性的了解。

在下面的例子中,我们创建了一个mythread()函数,它的签名是int(*)(int),它接受一个值,加一,然后返回结果(与前面的 POSIX 例子的输入和输出非常相似):

#include <thread>
#include <future>
#include <iostream>

int mythread(int value)
{
    return ++value;
}

int main()
{
    std::packaged_task<int(int)> task1(mythread);
    std::packaged_task<int(int)> task2(mythread);

    auto f1 = task1.get_future();
    auto f2 = task2.get_future();

    std::thread t1(std::move(task1), 42);
    std::thread t2(std::move(task2), 42);

    t1.join();
    t2.join();

    std::cout << "value1: " << f1.get() << '\n';
    std::cout << "value2: " << f2.get() << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// Hello World
// Hello World

使用 C++ futures,我们需要首先告诉 C++我们的线程的签名类型,以确保类型安全。为了在我们的例子中实现这一点(利用 future 的 API 有很多种方式,这只是其中一种),我们创建了一个std::packaged_task{},并为它提供了我们的线程函数签名。

这做了一些事情。首先,它告诉 API 调用哪个线程,另外,它为线程的结果设置了存储,以便稍后使用std::future{}检索。一旦创建了std::packaged_task{},我们就可以使用get_future()函数从packaged_task{}中获取std::future{}

最后,我们通过创建一个线程对象并将之前创建的std::packaged_task{}对象传递给它来启动线程。

我们可以在线程的构造函数中为线程提供初始输入,将所有的线程参数作为额外的基于模板的参数。要检索线程的结果,我们使用来自 future 的get(),这在线程完成并加入后是有效的(因此称为future)。

尽管 futures 在某些方面比简单地传递void *更复杂,但接口是优雅的,允许线程采用任何所需的签名类型,同时也提供类型安全。(在这个例子中不需要reinterpret_casts(),确保了核心指导方针的合规性,减少了难以发现的逻辑错误的可能性。)

让出

与 POSIX 线程类似,C++线程提供了让出线程的能力,让出 CPU,以便其他需要执行任务的线程可以这样做。表达如下:

#include <thread>
#include <iostream>

void mythread(const char *str)
{
    while(true) {
        std::clog << str << '\n';
        std::this_thread::yield();
    }
}

main()
{
    std::thread t1{mythread, "thread 1"};
    std::thread t2{mythread, "thread 2"};

    t1.join();
    t2.join();
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// thread 2
// thread 2
// thread 1
// thread 1
// thread 1
// thread 1
// thread 1
// thread 2
// thread 1

在前面的例子中,我们利用了this_thread命名空间提供的yield()函数,它让出调用线程。因此,它更能够在两个线程之间重新排列线程的输出,正如之前所演示的那样。

除了让出,线程可能需要停止执行一段时间。类似于 POSIX 中的sleep(),C++提供了让当前执行线程休眠的能力。C++的不同之处在于提供了更精细的 API,允许用户轻松地决定他们喜欢哪种类型的粒度(包括纳秒和秒的分辨率),如下所示:

#include <thread>
#include <chrono>
#include <iostream>

using namespace std::chrono_literals;

void mythread()
{
    while (true) {
        std::this_thread::sleep_for(1s);
        std::cout << "hello world\n";
    }
}

main()
{
    std::thread t{mythread};
    t.join();
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// hello world
// hello world
// hello world

在前面的例子中,我们创建了一个线程,将Hello World输出到stdout。在输出到stdout之前,线程通过调用this_thread命名空间提供的sleep_for()来休眠一秒,并使用秒字面量来定义1秒,结果是每秒将Hello World输出到stdout

同步

POSIX 线程和 C++线程之间的另一个显著区别是线程同步的简单性。与 POSIX API 类似,C++提供了创建互斥锁的能力,如下所示:

#include <mutex>
#include <thread>
#include <iostream>

int count = 0;
std::mutex mutex;

void mythread()
{
    mutex.lock();
    count++;
    mutex.unlock();
}

main()
{
    std::thread t1{mythread};
    std::thread t2{mythread};

    t1.join();
    t2.join();

    std::cout << "count: " << count << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// count: 2

在前面的例子中,我们创建了一个线程,它会增加一个共享的计数器,这个计数器被 C++的std::mutex{}包围,实际上创建了一个受保护的临界区。然后我们创建了两个线程,等待它们完成,然后将结果输出到stdout,结果是2,因为我们执行了两个线程。

POSIX 线程和前面的 C++例子的问题在于当一个线程不得不在多个地方离开临界区时,会出现问题:

void mythread()
{
    mutex.lock();

    if (count == 1) {
        mutex.unlock();
        return;
    }

    count++;
    mutex.unlock();
}

在之前的例子中,临界区在多个地方退出,因此必须在多个地方解锁互斥锁,以防止死锁。尽管这似乎是一个简单的例子,但由于简单地忘记在从临界区返回之前解锁互斥锁,导致了无数的死锁错误。

为了防止这个问题,C++提供了std::lock_guard{},它提供了一个使用资源获取即初始化RAII)的简单机制来解锁互斥锁。

#include <mutex>
#include <thread>
#include <iostream>

int count = 0;
std::mutex mutex;

void mythread()
{
    std::lock_guard lock(mutex);

    if (count == 1) {
        return;
    }

    count++;
}

main()
{
    std::thread t1{mythread};
    std::thread t2{mythread};

    t1.join();
    t2.join();

    std::cout << "count: " << count << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// count: 1

在之前的例子中,我们在线程中创建了一个基于 RAII 的锁保护,而不是手动锁定和解锁互斥锁。因此,在这个例子中,整个线程都处于临界区,因为当创建保护时互斥锁被锁定,当锁超出范围时(即线程返回时)被解锁。

正如在之前的例子中所演示的,不可能意外忘记解锁互斥锁,因为解锁互斥锁是由锁保护处理的。

在某些情况下,用户可能希望线程在等待访问临界区时执行其他有用的工作。为了实现这一点,std::mutex{}提供了try_lock()作为lock()的替代方法,如果无法获得锁,则返回false

#include <mutex>
#include <thread>
#include <iostream>

int count = 0;
std::mutex mutex;

void mythread()
{
    while(!mutex.try_lock());
    count++;
    mutex.unlock();
}

main()
{
    std::thread t1{mythread};
    std::thread t2{mythread};

    t1.join();
    t2.join();

    std::cout << "count: " << count << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// count: 2

在之前的例子中,我们继续在无限的while循环中尝试锁定互斥锁。然而,如果try_lock()返回false,我们可以执行一些额外的工作,或者在再次尝试之前睡眠一段时间,从而减轻操作系统和电池的压力。

如果希望使用try_lock与锁保护一起,以防止手动解锁互斥锁的需要,可以使用以下方法:

#include <mutex>
#include <thread>
#include <chrono>
#include <iostream>

int count = 0;
std::mutex mutex;

using namespace std::chrono_literals;

void mythread()
{
    std::unique_lock lock(mutex, std::defer_lock);

    while(!lock.try_lock()) {
        std::this_thread::sleep_for(1s);
    }

    count++;
}

main()
{
    std::thread t1{mythread};
    std::thread t2{mythread};

    t1.join();
    t2.join();

    std::cout << "count: " << count << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// count: 2

在这个例子中,我们介绍了 C++线程的两个新特性。第一个是std::unique_lock{},它类似于std::lock_guard{}

std::lock_guard{}是一个简单的 RAII 包装器,而std::unique_lock提供了类似于std::unique_ptr{}的功能,即生成的锁是可移动的(不可复制的),并提供了超出简单 RAII 包装器的额外 API。

作为一个副作用,关于所有这些锁保护,不要忘记定义保护的变量,否则锁将立即被锁定和解锁,导致难以发现的错误。

std::unique_lock提供的另一个额外 API 是延迟锁定互斥锁的能力(即在锁本身的构造时不锁定)。这使用户能够更好地控制锁定发生的时间,使用诸如lock()try_lock()try_lock_for()try_lock_until()之类的许多锁函数。

在我们之前的例子中,我们尝试锁定临界区,如果失败,就在再次尝试之前睡眠一秒。其他修饰符包括std::adopt_lock{}std::try_lock{}修饰符,它们要么假设互斥锁已经被锁定,要么构造函数尝试锁定而不阻塞。

除了常规的互斥锁,C++还提供了像 POSIX 一样的递归互斥锁,如下所示:

#include <mutex>
#include <thread>
#include <iostream>

int count = 0;
std::recursive_mutex mutex;

void mythread()
{
    std::lock_guard lock1(mutex);
    std::lock_guard lock2(mutex);
    count++;
}

main()
{
    std::thread t1{mythread};
    std::thread t2{mythread};

    t1.join();
    t2.join();

    std::cout << "count: " << count << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// count: 2

在这个例子中,我们能够在同一个递归锁上创建两个锁保护,而不会创建死锁(因为析构函数的执行顺序与构造相反,确保锁以正确的顺序被解锁)。

互斥锁的另一个常见问题与同时锁定多个互斥锁有关;也就是说,如果存在多个临界区,并且特定操作必须同时在两个临界区上操作。为了实现这一点,C++17 添加了std::scoped_lock{},它类似于std::lock_guard{},但接受多个锁,如下所示:

#include <mutex>
#include <thread>
#include <iostream>

int count = 0;
std::mutex mutex1;
std::mutex mutex2;

void mythread()
{
    std::scoped_lock lock(mutex1, mutex2);
    count++;
}

main()
{
    std::thread t1{mythread};
    std::thread t2{mythread};

    t1.join();
    t2.join();

    std::cout << "count: " << count << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// count: 2

在这个例子中,使用std::scoped_lock{}类锁定和解锁了不止一个互斥锁。

std::unique_lock{}类似于std::unique_ptr{},它保护资源并防止复制。与std::shared_ptr{}类似,互斥量 API 还提供std::shared_lock{},它提供了多个线程访问同一互斥量的能力。以下代码演示了这一点:

#include <shared_mutex>
#include <thread>
#include <iostream>

int count = 0;
std::shared_mutex mutex;

void mythread1()
{
    while(true) {
        std::unique_lock lock(mutex);
        count++;
    }
}

void mythread2()
{
    while(true) {
        std::shared_lock lock(mutex);
        std::cout << "count: " << count << '\n';
    }
}

main()
{
    std::thread t1{mythread1};
    std::thread t2{mythread2};
    std::thread t3{mythread2};

    t1.join();
    t2.join();
    t3.join();
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// count: 999
// count: 1000
// count: 1000
// count: 1000
// count: 1000
// count: 1000
// count: count: 1000
// count: 1000

在前面的例子中,我们有两个线程——一个生产者和一个消费者。生产者(mythread1)增加计数器,而消费者(mythread2)将计数输出到stdout。在main()函数中,我们创建三个线程——一个生产者和两个消费者。

我们可以使用常规的std::mutex来实现这种情况;但是,这样的实现将是次优的,因为两个消费者都没有修改计数器,这意味着多个消费者可以在不损坏结果的情况下同时执行,如果它们碰巧发生冲突(因为没有进行修改)。

然而,如果使用常规的std::muted,消费者将不得不互相等待,这也是次优的(显然忽略了stdout也是一个共享资源,应该被视为自己的临界区,以防止stdout本身的损坏)。

为了解决这个问题,我们利用std::shared_mutex代替常规的std::mutex。在生产者中,我们使用std::unique_lock{}锁定互斥量,这确保了对临界区的独占访问。然而,在消费者中,我们利用std::shared_lock{},它只等待使用std::unique_lock{}的先前锁定。如果使用std::shared_lock{}获取了互斥量,线程将继续执行而不等待,共享对临界区的访问。

最后,在 C++17 之前,通过添加std::scoped_lock{},锁定多个互斥量的唯一方法是使用std::lock()(和 friends)函数,如下所示:

#include <mutex>
#include <thread>
#include <iostream>

int count = 0;
std::mutex mutex1;
std::mutex mutex2;

void mythread()
{
    std::unique_lock lock1(mutex1, std::defer_lock);
    std::unique_lock lock2(mutex2, std::defer_lock);

    std::lock(lock1, lock2);

    count++;
}

main()
{
    std::thread t1{mythread};
    std::thread t2{mythread};

    t1.join();
    t2.join();

    std::cout << "count: " << count << '\n';
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// count: 2

与 POSIX 一样,C++也提供了使用条件变量控制线程执行顺序的能力。在下面的例子中,我们创建两个线程,并使用条件变量来同步它们的执行顺序,类似于 POSIX 的条件变量示例:

#include <mutex>
#include <condition_variable>
#include <thread>
#include <iostream>

std::mutex mutex;
std::condition_variable cond;

void mythread1()
{
    std::cout << "Hello World: 1\n";
    cond.notify_one();
}

void mythread2()
{
    std::unique_lock lock(mutex);
    cond.wait(lock);
    std::cout << "Hello World: 2\n";
}

main()
{
    std::thread t2{mythread2};
    std::thread t1{mythread1};

    t1.join();
    t2.join();
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// Hello World: 1
// Hello World: 2

如前面的例子所示,尽管第二个线程是先创建的,但它最后执行。这是通过创建一个 C++条件变量实现的。在第二个线程中,我们使用std::unique_lock{}保护临界区,然后等待第一个线程通过调用notify_one()来发出已完成的信号。

一旦第一个线程完成并通知第二个线程,第二个线程就完成了它的执行。

这种方法也适用于使用 C++线程进行广播模式的多个线程,如下所示:

#include <mutex>
#include <condition_variable>
#include <thread>
#include <iostream>

std::mutex mutex;
std::condition_variable cond;

void mythread1()
{
    std::cout << "Hello World: 1\n";
    cond.notify_all();
}

void mythread2()
{
    std::unique_lock lock(mutex);
    cond.wait(lock);
    std::cout << "Hello World: 2\n";
    cond.notify_one();
}

main()
{
    std::thread t2{mythread2};
    std::thread t3{mythread2};
    std::thread t1{mythread1};

    t1.join();
    t2.join();
    t3.join();
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// Hello World: 1
// Hello World: 2
// Hello World: 2

在这个例子中,第一个线程完成它的工作,然后通知所有剩余的线程完成。第二个线程用互斥量保护临界区,并等待第一个线程的信号。

问题在于一旦第一个线程执行并发出完成的信号,剩余的线程将尝试执行,但只有一个线程可以获取临界区,导致第三个线程等待临界区被解锁并收到通知。因此,当第二个线程完成时,它必须再次通知条件变量以解锁剩余的线程,从而允许所有三个线程完成。

为了解决这个问题,我们将结合本节学到的所有内容,如下所示:

#include <shared_mutex>
#include <condition_variable>
#include <thread>
#include <iostream>

std::shared_mutex mutex;
std::condition_variable_any cond;

void mythread1()
{
    std::unique_lock lock(mutex);
    std::cout << "Hello World: 1\n";

    cond.notify_all();
}

void mythread2()
{
    std::shared_lock lock(mutex);
    cond.wait(lock);

    std::cout << "Hello World: 2\n";
}

main()
{
    std::thread t2{mythread2};
    std::thread t3{mythread2};
    std::thread t1{mythread1};

    t1.join();
    t2.join();
    t3.join();
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// Hello World: 1
// Hello World: 2
// Hello World: 2

这个例子与前一个例子相同,只有一个简单的改变。我们使用std::shared_mutex{}而不是std::mutex{},并且使用std::shared_lock{}来锁定互斥量。

为了能够使用共享互斥量代替常规互斥量,必须使用std::condition_variable_any{}而不是std::condition_variable{}。通过使用std::shared_mutex{}而不是std::mutex{},当第一个线程发出已完成的信号时,剩余的线程可以自由完成它们的工作并同时处理临界区。

最后,C++提供了一个方便的机制,如果需要多个线程,但只允许一个执行初始化逻辑(这也是 POSIX 提供的功能,但本书未涵盖),如下所示:

#include <mutex>
#include <thread>
#include <iostream>

std::once_flag flag;

void mythread()
{
    std::call_once(flag, [] {
        std::cout << "Hello World\n";
    });
}

main()
{
    std::thread t1{mythread};
    std::thread t2{mythread};

    t1.join();
    t2.join();
}

// > g++ -std=c++17 scratchpad.cpp -lpthread; ./a.out
// Hello World

在这个示例中,创建了多个线程,但是使用std::call_once{}包装器只执行了一次Hello World。值得注意的是,尽管这看起来很简单,但std::call_once{}确保了标志的原子翻转,以确定包装逻辑是否已经执行,从而防止可能的竞争条件,尽管它们可能是不太可能发生的。

研究并行计算的示例

在这个示例中,我们将演示如何使用线程执行并行计算任务,计算质数。在这个示例中,需要以下包含文件和命名空间:

#include <list>
#include <mutex>
#include <thread>
#include <iostream>
#include <algorithm>

#include <gsl/gsl>
using namespace gsl;

using namespace std::string_literals;

对于大数来说,计算质数值是一项昂贵的操作,但幸运的是,它们可以并行计算。值得注意的是,在我们的示例中,我们并没有尝试优化我们的搜索算法,因为我们的目标是提供一个可读的线程示例。有许多方法,一些简单的方法,可以改进此示例中代码的性能。

为了存储我们的程序找到的质数,我们将定义以下类:


class primes
{
    std::list<int> m_primes;
    mutable std::mutex m_mutex;

public:

    void add(int prime)
    {
        std::unique_lock lock(m_mutex);
        m_primes.push_back(prime);
    }

    void print()
    {
        std::unique_lock lock(m_mutex);
        m_primes.sort();

        for (const auto prime : m_primes) {
            std::cout << prime << ' ';
        }

        std::cout << '\n';
    }
};

primes g_primes;

这个类提供了一个地方,让我们使用add()函数存储每个质数。一旦找到我们计划搜索的所有质数,我们提供一个print()函数,能够按排序顺序打印已识别的质数。

我们将用来检查一个数字是否为质数的线程如下:

void check_prime(int num)
{
    for (auto i = 2; i < num; i++) {
        if (num % i == 0) {
            return;
        }
    }

    g_primes.add(num);
}

在这个线程中,我们循环遍历用户提供的数字的每个可能的倍数,并检查模是否为0。如果是0,则该数字不是质数。如果没有找到任何倍数,则该数字是质数,并且将其添加到我们的列表中。

最后,在我们的protected_main()函数中,我们搜索一组质数。我们首先将所有参数转换,以便对其进行处理:

int
protected_main(int argc, char** argv)
{
    auto args = make_span(argv, argc);

    if (args.size() != 4) {
        std::cerr << "wrong number of arguments\n";
        ::exit(1);
    }

我们期望有三个参数。第一个参数将提供我们希望检查是否为质数的最大可能数字;第二个参数是我们希望创建的用于搜索质数的线程总数;第三个参数将确定我们是否要打印结果。

下一个任务是获取要搜索的最大可能质数,以及获取要创建的线程总数。考虑以下代码:

    int max_prime = std::stoi(args.at(1));
    int max_threads = std::stoi(args.at(2));

    if (max_prime < 3) {
        std::cerr << "max_prime must be 2 or more\n";
        ::exit(1);
    }

    if (max_threads < 1) {
        std::cerr << "max_threads must be 1 or more\n";
        ::exit(1);
    }

一旦我们知道要搜索多少个质数,以及要创建多少个线程,我们就按照以下方式搜索我们的质数:

    for (auto i = 2; i < max_prime; i += max_threads) {

        std::list<std::thread> threads;
        for (auto t = 0; t < max_threads; t++) {
            threads.push_back(std::thread{check_prime, i + t});
        }

        for (auto &thread : threads) {
            thread.join();
        }
    }

在这段代码中,我们搜索用户提供的数字范围内的所有质数,逐个增加用户提供的线程总数。然后,我们创建一个线程列表,为每个线程提供它应该从哪个数字开始寻找质数。

一旦所有线程都创建好了,我们就等待这些线程完成。值得注意的是,有许多方法可以进一步优化这个逻辑,包括防止线程的重新创建,从而防止过度使用malloc(),但这个示例提供了一个简单的机制来演示这个示例的要点。

protected_main()函数中,我们做的最后一件事是检查用户是否想要查看结果,并在需要时打印它们:


    if (args.at(3) == "print"s) {
        g_primes.print();
    }

    return EXIT_SUCCESS;
}

最后,我们使用我们的main()执行protected_main()函数,并捕获可能出现的任何异常,如下所示:

int
main(int argc, char** argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

编译和测试

要编译这段代码,我们利用了与其他示例相同的CMakeLists.txt文件,可以在以下链接找到:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter12/CMakeLists.txt

有了这段代码,我们可以使用以下方式编译这段代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter12/
> mkdir build
> cd build

> cmake ..
> make

要执行此示例,请运行以下命令:

> time ./example1 20 4 print
2 3 5 7 11 13 17 19

如本片段所示,找到了最多20个素数。为了演示线程的有效性,请执行以下操作:

> time ./example1 50000 4 no
real 0m2.180s
user 0m0.908s
sys 0m3.280s

> time ./example1 50000 2 no
real 0m2.900s
user 0m1.073s
sys 0m3.230s

> time ./example1 50000 1 no
real 0m4.546s
user 0m0.910s
sys 0m3.615s

可以看到,随着线程总数的减少,应用程序查找素数所需的总时间也会增加。

研究使用线程进行基准测试的示例

在之前的章节中,我们讨论了如何使用各种不同的机制对软件进行基准测试。在本章中,我们将探讨使用线程创建自己的高分辨率计时器,而不是使用 C++ chrono API 提供的高分辨率计时器。

为了实现这一点,我们将创建一个线程,其唯一工作是尽可能快地计数。值得注意的是,尽管这将提供一个非常敏感的高分辨率计时器,但与英特尔等计算机架构相比,它有很多缺点。这些提供了比这里可能的更高分辨率的硬件指令,同时对 CPU 频率缩放的影响较小。

在这个示例中,需要以下包含和命名空间:

#include <thread>
#include <mutex>
#include <condition_variable>
#include <iostream>

#include <gsl/gsl>
using namespace gsl;

我们将把高分辨率计时器存储在count变量中,如下所示:

int count = 0;
bool enable_counter = true;

std::mutex mutex;
std::condition_variable cond;

enable_counter布尔值将用于关闭计时器,而互斥锁和条件变量将用于在正确的时间打开计时器。

我们的高分辨率计时器将包括以下内容:

void tick()
{
    cond.notify_one();

    while (enable_counter) {
        count++;
    }
}

计时器将在启动后通知条件变量它正在运行,并将继续计数,直到enable_counter标志被设置为false。为了计时一个操作,我们将使用以下命令:

template<typename FUNC>
auto timer(FUNC func) {
    std::thread timer{tick};

    std::unique_lock lock(mutex);
    cond.wait(lock);

    func();

    enable_counter = false;
    timer.join();

    return count;
}

这个逻辑创建了计时器线程,然后使用条件变量等待它启动。一旦计时器启动,它将执行测试函数,然后禁用计时器并等待线程完成,返回结果的总计时数。

在我们的protected_main()函数中,我们要求用户输入在for循环中循环的总次数,然后计算执行for循环所需的时间,并在完成后将结果输出到stdout,如下所示:

int
protected_main(int argc, char** argv)
{
    auto args = make_span(argv, argc);

    if (args.size() != 2) {
        std::cerr << "wrong number of arguments\n";
        ::exit(1);
    }

    auto ticks = timer([&] {
        for (auto i = 0; i < std::stoi(args.at(1)); i++) {
        }
    });

    std::cout << "ticks: " << ticks << '\n';

    return EXIT_SUCCESS;
}

最后,我们使用我们的main()执行protected_main()函数,并捕获可能出现的任何异常,如下所示:

int
main(int argc, char** argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

编译和测试

要编译此代码,我们将利用我们一直在使用的相同CMakeLists.txt文件:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter12/CMakeLists.txt

有了这段代码,我们可以使用以下命令编译此代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter12/
> mkdir build
> cd build

> cmake ..
> make

要执行代码,请运行以下命令:

> ./example2 1000000
ticks: 103749316

如本片段所示,示例将循环1000000次,并将执行循环所需的时钟周期数输出到控制台。

研究线程日志示例

本章的最后一个示例将在现有的调试器示例基础上构建,以支持多个客户端。在第十章中,使用 C++编程 POSIX 套接字,我们为示例调试器添加了对网络的支持,除了本地系统外,还提供了将调试日志卸载到服务器的功能。

问题在于服务器在关闭之前只能接受一个连接,因为它没有处理多个客户端的逻辑。在这个示例中,我们将解决这个问题。

首先,我们需要定义我们的端口和最大调试字符串长度,如下所示:

#define PORT 22000
#define MAX_SIZE 0x1000

服务器将需要以下包含语句:

#include <array>
#include <unordered_map>

#include <sstream>
#include <fstream>
#include <iostream>

#include <mutex>
#include <thread>

#include <unistd.h>
#include <string.h>

#include <sys/socket.h>
#include <netinet/in.h>

与之前的示例一样,日志文件将被定义为全局,并且将添加互斥锁以同步对日志的访问:

std::mutex log_mutex;
std::fstream g_log{"server_log.txt", std::ios::out | std::ios::app};

我们将全局定义recv()函数,而不是在服务器中定义它,以便为客户端线程提供方便访问(每个客户端将生成一个新线程):

ssize_t
recv(int handle, std::array<char, MAX_SIZE> &buf)
{
    return ::recv(
        handle,
        buf.data(),
        buf.size(),
        0
    );
}

recv()函数一样,log()函数也将从服务器中移出,并将创建我们的客户端线程。每当客户端建立连接时,服务器将生成一个新线程(log()函数),其实现如下:

void
log(int handle)
{
    while(true)
    {
        std::array<char, MAX_SIZE> buf{};

        if (auto len = recv(handle, buf); len != 0) {

            std::unique_lock lock(log_mutex);

            g_log.write(buf.data(), len);
            std::clog.write(buf.data(), len);

            g_log.flush();
        }
        else {
            break;
        }
    }

    close(handle);
}

与在第十章中的示例相比,使用log()函数的唯一区别是添加了std::unique_lock{}以保护对日志的访问(以防多个客户端同时尝试写入日志)。句柄被传递给日志函数,而不是句柄作为服务器的成员,我们在每次写入后刷新日志文件,以确保所有写入实际写入磁盘,因为我们将通过终止服务器应用程序来关闭它。

最后,服务器被修改为接受传入的连接并生成线程作为结果。服务器从前一个示例中的相同逻辑开始:

class myserver
{
    int m_fd{};
    struct sockaddr_in m_addr{};

public:

    myserver(uint16_t port)
    {
        if (m_fd = ::socket(AF_INET, SOCK_STREAM, 0); m_fd == -1) {
            throw std::runtime_error(strerror(errno));
        }

        m_addr.sin_family = AF_INET;
        m_addr.sin_port = htons(port);
        m_addr.sin_addr.s_addr = htonl(INADDR_ANY);

        if (bind() == -1) {
            throw std::runtime_error(strerror(errno));
        }
    }

    int bind()
    {
        return ::bind(
            m_fd,
            reinterpret_cast<struct sockaddr *>(&m_addr),
            sizeof(m_addr)
        );
    }

服务器的构造函数创建一个套接字,并将套接字绑定到标识的端口。服务器的主要区别在于使用listen()函数,该函数曾经是log()函数。考虑以下代码:

    void listen()
    {
        if (::listen(m_fd, 0) == -1) {
            throw std::runtime_error(strerror(errno));
        }

        while (true) {
            if (int c = ::accept(m_fd, nullptr, nullptr); c != -1) {

                std::thread t{log, c};
                t.detach();

                continue;
            }

            throw std::runtime_error(strerror(errno));
        }
    }

listen()函数在套接字上监听新连接。当建立连接时,它使用log()函数创建一个线程,并向log函数提供新客户端的句柄。

无需确保服务器和/或客户端正确关闭,因为 TCP 将为我们处理这一点,消除了创建每个客户端线程后跟踪每个客户端线程的需要(即,当完成时无需join()线程)。因此,我们使用detach()函数,告诉 C++不会发生join(),线程应该在线程对象被销毁后继续执行。

最后,我们循环等待更多客户端连接。

服务器的剩余逻辑是相同的。我们在protected_main()函数中创建服务器,并在main()函数中执行protected_main()函数,尝试捕获可能发生的任何异常。以下代码显示了这一点:

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    myserver server{PORT};
    server.listen();
}

int
main(int argc, char** argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

最后,此示例的客户端逻辑与第十章中找到的客户端逻辑相同。

编译和测试

要编译此代码,我们利用了与其他示例相同的CMakeLists.txt文件——github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter11/CMakeLists.txt

有了这个,我们可以使用以下命令编译代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter12/
> mkdir build
> cd build

> cmake ..
> make

要执行服务器,请运行以下命令:

> ./example3_server

要执行客户端,请打开一个新的终端并运行以下命令:

> cd Hands-On-System-Programming-with-CPP/Chapter12/build
> ./example3_client
Debug: Hello World
Hello World

> ./example3_client
Debug: Hello World
Hello World

> cat client_log.txt
Debug: Hello World
Debug: Hello World

> cat server_log.txt
Debug: Hello World
Debug: Hello World

如本片段所示,当客户端执行时,客户端和服务器端都会将DEBUG: Hello World输出到stderr。此外,客户端将Hello World输出到stderr,因为第二次调用std::clog时未重定向。

两个日志文件都包含重定向的DEBUG: Hello World。最后,我们可以多次执行客户端,导致服务器记录来自两个客户端的输出,而不仅仅是一个。

总结

在本章中,我们讨论了如何使用 POSIX 和 C++ API 编程线程。然后我们讨论了三个示例。第一个示例演示了如何使用线程执行并行计算,而第二个示例演示了如何使用线程创建自己的高分辨率计时器来进行基准测试。

最后,第三个示例建立在我们现有的调试示例基础上,为多个客户端提供支持。下一章,也是最后一章,将讨论 C 和 C++提供的错误处理功能,包括 C 风格的错误处理和异常。

问题

  1. 如何使用 POSIX 获取线程的 ID?在使用 C++时呢?

  2. POSIX 线程输入和输出的主要问题是什么?

  3. 什么是竞争条件?

  4. 死锁是什么?

  5. C++中的std::future{}是什么,它试图解决什么问题?

  6. 使用std::call_once()的主要原因是什么?

  7. std::shared_mutexstd::mutex之间有什么区别?

  8. 递归互斥锁的目的是什么?

进一步阅读

第十三章:异常处理

在这最后一章中,我们将学习如何在系统编程时执行错误处理。具体来说,将介绍三种不同的方法。第一种方法将演示如何使用 POSIX 风格的错误处理,而第二种方法将演示如何使用标准的 C 风格的 set jump 异常。第三种方法将演示如何使用 C++ 异常,并讨论每种方法的优缺点。最后,本章将以一个示例结束,演示了 C++ 异常如何优于 POSIX 风格的错误处理。

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

  • POSIX 风格的错误处理

  • C++ 中的异常支持

  • 带异常基准的示例

技术要求

为了编译和执行本章中的示例,读者必须具备以下条件:

  • 一个能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)

  • GCC 7+

  • CMake 3.6+

  • 互联网连接

要下载本章中的所有代码,包括示例和代码片段,请参见以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter13

错误处理 POSIX 风格

POSIX 风格的错误处理提供了可能的最基本的错误处理形式,几乎可以在任何系统的几乎任何程序中使用。以标准 C 为基础编写,POSIX 风格的错误处理采用以下形式:

if (foo() != 0) {
    std::cout << errno << '\n';
}

通常,每个调用的函数要么在 success 时返回 0,要么在失败时返回 -1,并将错误代码存储在一个全局(非线程安全)的实现定义的宏中,称为 errno。使用 0 作为 success 的原因是,在大多数 CPU 上,将变量与 0 进行比较比将变量与任何其他值进行比较更快,而 success 情况是预期的情况。以下示例演示了如何使用这种模式:

#include <cstring>
#include <iostream>

int myfunc(int val)
{
    if (val == 42) {
        errno = EINVAL;
        return -1;
    }

    return 0;
}

int main()
{
    if (myfunc(1) == 0) {
        std::cout << "success\n";
    }
    else {
        std::cout << "failure: " << strerror(errno) << '\n';
    }

    if (myfunc(42) == 0) {
        std::cout << "success\n";
    }
    else {
        std::cout << "failure: " << strerror(errno) << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// success
// failure: Invalid argument

在这个例子中,我们创建了一个名为 myfunc() 的函数,它接受一个整数并返回一个整数。该函数接受任何值作为其参数,除了 42。如果将 42 作为输入函数,函数将返回 -1 并将 errno 设置为 EINVAL,表示函数提供了一个无效的参数。

main 函数中,我们调用 myfunc(),分别使用有效输入和无效输入进行测试,以查看是否发生了错误,有效输入返回 success,无效输入返回 failure: Invalid argument。值得注意的是,我们利用了 strerror() 函数,将 POSIX 定义的错误代码转换为它们的字符串等价物。还应该注意的是,这个简单的例子将在本章中被利用,并在此基础上进行改进。

从这个简单的例子中出现的第一个问题是函数的输出被用于错误处理,但如果函数需要输出除错误代码以外的值怎么办?有两种处理方法。处理这个问题的第一种方法是限制函数的有效输出(即,并非所有输出都被认为是有效的)。这通常是 POSIX 处理这个问题的方式。以下示例演示了这一点:

#include <cstring>
#include <iostream>

int myfunc(int val)
{
    if (val == 42) {
        errno = EINVAL;
        return 0;
    }

    return 42;
}

int main()
{
    if (auto handle = myfunc(1); handle != 0) {
        std::cout << "success: " << handle << '\n';
    }
    else {
        std::cout << "failure: " << strerror(errno) << '\n';
    }

    if (auto handle = myfunc(42); handle != 0) {
        std::cout << "success: " << handle << '\n';
    }
    else {
        std::cout << "failure: " << strerror(errno) << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// success: 42
// failure: Invalid argument

在上面的示例中,我们创建了一个 myfunc() 函数,给定有效输入返回一个 handle,给定无效输入返回 0。这类似于很多返回文件句柄的 POSIX 函数。在这种情况下,success 的概念被颠倒了,此外,句柄可能永远不会取值为 0,因为这用于表示错误。另一种同时提供错误处理和函数输出的可能方法是返回多个值,如下所示:

#include <utility>
#include <cstring>
#include <iostream>

std::pair<int, bool>
myfunc(int val)
{
    if (val == 42) {
        errno = EINVAL;
        return {0, false};
    }

    return {42, true};
}

int main()
{
    if (auto [handle, success] = myfunc(1); success) {
        std::cout << "success: " << handle << '\n';
    }
    else {
        std::cout << "failure: " << strerror(errno) << '\n';
    }

    if (auto [handle, success] = myfunc(42); success) {
        std::cout << "success: " << handle << '\n';
    }
    else {
        std::cout << "failure: " << strerror(errno) << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// success: 42
// failure: Invalid argument

在前面的例子中,我们返回了std::pair{}(实际上只是一个具有两个值的结构体)。对中的第一个值是我们的句柄,而对中的第二个值确定了句柄是否有效。使用这种机制,0可能是一个有效的句柄,因为我们有一种方法告诉这个函数的用户它是否有效。另一种方法是为函数提供一个作为输出而不是输入的参数,这种做法是 C++核心指南不推荐的。这通过以下代码表示:

#include <cstring>
#include <iostream>

int myfunc(int val, int &error)
{
    if (val == 42) {
        error = EINVAL;
        return 0;
    }

    return 42;
}

int main()
{
    int error = 0;

    if (auto handle = myfunc(1, error); error == 0) {
        std::cout << "success: " << handle << '\n';
    }
    else {
        std::cout << "failure: " << strerror(error) << '\n';
    }

    if (auto handle = myfunc(42, error); error == 0) {
        std::cout << "success: " << handle << '\n';
    }
    else {
        std::cout << "failure: " << strerror(error) << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// success: 42
// failure: Invalid argument

在这个例子中,myfunc()接受两个参数,第二个参数接受一个整数,用于存储错误。如果错误整数保持为0,则表示没有发生错误。然而,如果错误整数被设置,就表示发生了错误,我们会检测并输出失败的结果。尽管这种方法不被 C++核心指南推荐(主要是因为在 C++中有更好的方法来处理错误),但这种方法的额外好处是错误整数是线程安全的,而不像errno的使用那样不是线程安全的。

除了 POSIX 风格错误处理的冗长和错误值被忽略的倾向之外,最大的问题是必须持续执行大量分支语句,以防错误可能发生的情况。下面的例子演示了这一点:

#include <cstring>
#include <iostream>

int myfunc(int val)
{
    if (val == 42) {
        errno = EINVAL;
        return -1;
    }

    return 0;
}

int nested1(int val)
{
    if (auto ret = myfunc(val); ret != 0) {
        std::cout << "nested1 failure: " << strerror(errno) << '\n';
        return ret;
    }
    else {
        std::cout << "nested1 success\n";
    }

    return 0;
}

int nested2(int val)
{
    if (auto ret = nested1(val); ret != 0) {
        std::cout << "nested2 failure: " << strerror(errno) << '\n';
        return ret;
    }
    else {
        std::cout << "nested2 success\n";
    }

    return 0;
}

int main()
{
    if (nested2(1) == 0) {
        std::cout << "nested2(1) complete\n";
    }
    else {
        std::cout << "nested2(1) failure: " << strerror(errno) << '\n';
    }

    if (nested2(42) == 0) {
        std::cout << "nested2(42) complete\n";
    }
    else {
        std::cout << "nested2(42) complete: " << strerror(errno) << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// nested1 success
// nested2 success
// nested2(1) complete
// nested1 failure: Invalid argument
// nested2 failure: Invalid argument
// nested2(42) failure: Invalid argument

在这个例子中,我们创建了相同的myfunc()函数,如果输入为42,则返回一个错误。然后我们从另一个函数中调用这个函数(也就是说,我们在myfunc()中进行了嵌套调用,这在系统编程中很可能会发生)。由于myfunc()可能返回一个错误,而我们的嵌套函数无法处理错误,它们也必须返回一个错误代码,然后必须对其进行检查。在这个例子中,大部分代码只提供了错误处理逻辑,旨在将错误的结果转发给下一个函数,希望下一个函数能够处理错误。

这种嵌套的错误转发可能被称为“堆栈展开”。每次调用可能返回错误的函数时,我们都会检查是否发生了错误,并将结果返回给堆栈中的下一个函数。这个展开调用堆栈的过程会重复,直到我们到达堆栈中能够处理错误的函数为止。在我们的情况下,这是main()函数。

POSIX 风格的错误处理存在的问题是必须手动执行堆栈展开,因此,在“成功”情况下,这段代码会持续执行,导致性能不佳、代码冗长,正如前面的示例所示,该示例仅在三个嵌套调用中检查了一个简单的整数值。

最后,应该指出,POSIX 风格的错误处理确实支持资源获取即初始化RAII),这意味着在函数范围内定义的对象在函数退出时会被正确销毁,无论是在“成功”情况下还是错误情况下,如下例所示:

#include <cstring>
#include <iostream>

class myclass
{
public:
    ~myclass()
    {
        std::cout << "destructor called\n";
    }
};

int myfunc(int val)
{
    myclass c{};

    if (val == 42) {
        errno = EINVAL;
        return -1;
    }

    return 0;
}

int main()
{
    if (myfunc(1) == 0) {
        std::cout << "success\n";
    }
    else {
        std::cout << "failure: " << strerror(errno) << '\n';
    }

    if (myfunc(42) == 0) {
        std::cout << "success\n";
    }
    else {
        std::cout << "failure: " << strerror(errno) << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// destructor called
// success
// destructor called
// failure: Invalid argument

在前面的例子中,我们创建了一个简单的类,在销毁时向stdout输出一个字符串,并在我们的myfunc()函数中创建了这个类的一个实例。当调用myfunc()时,无论是在“成功”还是失败时,类的析构函数都会在退出时被正确调用。在我们下一个错误处理机制中,称为设置跳转,我们将演示如何解决 POSIX 风格错误处理的许多问题,同时也演示了设置跳转的关键限制是缺乏 RAII 支持,可能导致未定义的行为。

学习关于设置跳转异常

Set jump 异常可以看作是 C 风格的异常。与 C++风格的异常一样,set jump 异常提供了在出现错误时设置返回代码的位置以及执行跳转的异常生成方法。以下代码示例演示了这一点:

#include <cstring>
#include <csetjmp>

#include <iostream>

std::jmp_buf jb;

void myfunc(int val)
{
    if (val == 42) {
        errno = EINVAL;   // Invalid argument
        std::longjmp(jb, -42);
    }
}

int main()
{
    if (setjmp(jb) == -42) {
        std::cout << "failure: " << strerror(errno) << '\n';
        std::exit(EXIT_FAILURE);
    }

    myfunc(1);
    std::cout << "success\n";

    myfunc(42);
    std::cout << "success\n";
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// success
// failure: Invalid argument

在这个例子中,我们创建了myfunc()函数,但是不返回错误代码,而是执行了 long jump,它像goto一样,跳转到调用setjmp()的调用栈中最后一次调用的位置。在我们的main函数中,我们首先调用setjmp()来设置返回点,然后使用有效输入和无效输入调用我们的myfunc()函数。

我们已经解决了 POSIX 风格错误处理的几个问题。如前面的例子所示,代码变得简单得多,不再需要检查错误条件。此外,myfunc()返回一个 void,不再需要返回错误代码,这意味着不再需要限制函数的输出以支持错误情况,如下例所示:

#include <cstring>
#include <csetjmp>

#include <iostream>

std::jmp_buf jb;

int myfunc(int val)
{
    if (val == 42) {
        errno = EINVAL;
        std::longjmp(jb, -1);
    }

    return 42;
}

int main()
{
    if (setjmp(jb) == -1) {
        std::cout << "failure: " << strerror(errno) << '\n';
        std::exit(EXIT_FAILURE);
    }

    auto handle1 = myfunc(1);
    std::cout << "success: " << handle1 << '\n';

    auto handle2 = myfunc(42);
    std::cout << "success: " << handle2 << '\n';
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// success: 42
// failure: Invalid argument

在这个例子中,myfunc()返回一个handle,并且使用 set jump 异常处理错误情况。因此,myfunc()可能返回任何值,函数的使用者根据是否调用了 long jump 来判断 handle 是否有效。

由于不再需要myfunc()的返回值,我们也不再需要检查myfunc()的返回值,这意味着我们的嵌套示例大大简化,如下所示:

#include <cstring>
#include <csetjmp>

#include <iostream>

std::jmp_buf jb;

void myfunc(int val)
{
    if (val == 42) {
        errno = EINVAL;
        std::longjmp(jb, -1);
    }
}

void nested1(int val)
{
    myfunc(val);
    std::cout << "nested1 success\n";
}

void nested2(int val)
{
    nested1(val);
    std::cout << "nested2 success\n";
}

int main()
{
    if (setjmp(jb) == -1) {
        std::cout << "failure: " << strerror(errno) << '\n';
        exit(EXIT_FAILURE);
    }

    nested2(1);
    std::cout << "nested2(1) complete\n";

    nested2(42);
    std::cout << "nested2(42) complete\n";
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// nested1 success
// nested2 success
// nested2(1) complete
// failure: Invalid argument

正如所见,这个例子中唯一的错误逻辑存在于myfunc()中,用于确保输入有效。其余的错误逻辑已经被移除。这不仅使得代码更易于阅读和维护,而且由于不再执行分支语句,而是手动展开调用栈,因此结果代码的性能也更好。

使用 set jump 异常的另一个好处是可以创建线程安全的错误处理。在我们之前的例子中,我们在出现错误时设置了errno,然后在到达能够处理错误的代码时读取它。使用 set jump,不再需要errno,因为我们可以在 long jump 本身中返回错误代码,采用以下方法:

#include <cstring>
#include <csetjmp>

#include <iostream>

void myfunc(int val, jmp_buf &jb)
{
    if (val == 42) {
        std::longjmp(jb, EINVAL);
    }
}

int main()
{
    std::jmp_buf jb;

    if (auto ret = setjmp(jb); ret > 0) {
        std::cout << "failure: " << strerror(ret) << '\n';
        std::exit(EXIT_FAILURE);
    }

    myfunc(1, jb);
    std::cout << "success\n";

    myfunc(42, jb);
    std::cout << "success\n";
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// success
// failure: Invalid argument

在前面的例子中,我们不再在 long jump 中设置errno并返回-1,而是在 long jump 中返回错误代码,并且使用 C++17 语法,在调用 set jump 时存储 long jump 的值,并确保这个值大于0。第一次调用 set jump 时,由于尚未发生错误,它返回0,意味着不会执行分支。然而,如果第二次调用 set jump(当我们的 long jump 被调用时),则返回 long jump 中放置的值,导致执行分支并以线程安全的方式报告错误。

请注意,我们需要对我们的例子进行的唯一修改是必须传递每个函数的跳转缓冲区,这非常不方便,特别是在嵌套函数调用的情况下。在我们之前的例子中,跳转缓冲区是全局存储的,这不是线程安全的,但更方便,代码更清晰。

除了提供线程安全的笨拙机制之外,使用 set jump 进行错误处理的主要缺点是不支持 RAII,这意味着在函数范围内创建的对象在退出时可能不会调用它们的析构函数(这实际上是特定于实现的问题)。析构函数不会被调用的原因是函数从技术上讲从未退出。set jump/long jump 在调用 set jump 时将指令指针和非易失性寄存器存储在跳转缓冲区中。

当执行长跳转时,应用程序会用跳转缓冲区中存储的值覆盖指令指针和 CPU 寄存器的值,然后继续执行,就好像调用setjump()后的代码从未执行过一样。因此,对象的析构函数永远不会被执行,就像下面的例子中所示的那样:

#include <cstring>
#include <csetjmp>

#include <iostream>

jmp_buf jb;

class myclass
{
public:
    ~myclass()
    {
        std::cout << "destructor called\n";
    }
};

void myfunc(int val)
{
    myclass c{};

    if (val == 42) {
        errno = EINVAL;
        std::longjmp(jb, -1);
    }
}

int main()
{
    if (setjmp(jb) == -1) {
        std::cout << "failure: " << strerror(errno) << '\n';
        exit(EXIT_FAILURE);
    }

    myfunc(1);
    std::cout << "success\n";

    myfunc(42);
    std::cout << "success\n";
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// destructor called
// success
// failure: Invalid argument

在这个例子中,我们创建了一个简单的类,在类被销毁时向stdout输出一个字符串。然后我们在myfunc()中创建了这个类的一个实例。在success情况下,当myfunc()退出时,析构函数被调用,导致析构函数被调用。然而,在失败的情况下,myfunc()永远不会退出,导致析构函数不会被调用。

在下一节中,我们将讨论 C++异常,它建立在 set jump 异常的基础上,不仅提供了对 RAII 的支持,还提供了在发生错误时返回复杂数据类型的能力。

理解 C++中的异常支持

C++异常提供了一种在线程安全的方式报告错误的机制,无需手动展开调用堆栈,同时还支持 RAII 和复杂数据类型。要更好地理解这一点,请参考以下例子:

#include <cstring>
#include <iostream>

void myfunc(int val)
{
    if (val == 42) {
        throw EINVAL;
    }
}

int main()
{
    try {
        myfunc(1);
        std::cout << "success\n";

        myfunc(42);
        std::cout << "success\n";
    }
    catch(int ret) {
        std::cout << "failure: " << strerror(ret) << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// success
// failure: Invalid argument

在上面的例子中,我们的myfunc()函数相对于其 POSIX 风格的等效函数大大简化了。就像我们之前的例子一样,如果提供给函数的输入是42,则返回错误(在这种情况下实际上是抛出)。如果提供的输入不是42,则函数成功返回。

与 set jump 一样,调用myfunc()不再需要检查函数的返回值,因为没有提供返回值。为了处理错误情况,我们将对myfunc()的调用包装在try...catch块中。如果try{}块中的任何代码导致抛出异常,将执行catch{}块。与大多数 C++一样,catch块是类型安全的,这意味着你必须声明在抛出异常时要接收的返回数据的类型。在这种情况下,我们抛出EINVAL,它是一个整数,所以我们捕获一个整数并将结果输出到stdout

与 set jump 类似,myfunc()不再需要返回错误代码,这意味着它可以输出任何它想要的值(意味着输出不受限制),就像下一个例子中所示的那样:

#include <cstring>
#include <iostream>

int myfunc(int val)
{
    if (val == 42) {
        throw EINVAL;
    }

    return 42;
}

int main()
{
    try {
       auto handle1 = myfunc(1);
        std::cout << "success: " << handle1 << '\n';

        auto handle2 = myfunc(42);
        std::cout << "success: " << handle2 << '\n';
    }
    catch(int ret) {
        std::cout << "failure: " << strerror(ret) << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// success: 42
// failure: Invalid argument

在上面的例子中,myfunc()返回一个句柄,它可以取任何值,因为如果抛出异常,这个函数的用户将知道句柄是否有效。

与 set jump 不同,我们的嵌套情况大大简化,因为我们不再需要手动展开调用堆栈:

#include <cstring>
#include <iostream>
void myfunc(int val)
{
    if (val == 42) {
        throw EINVAL;
    }
}

void nested1(int val)
{
    myfunc(val);
    std::cout << "nested1 success\n";
}

void nested2(int val)
{
    nested1(val);
    std::cout << "nested2 success\n";
}

main()
{
    try {
        nested2(1);
        std::cout << "nested2(1) complete\n";

        nested2(42);
        std::cout << "nested2(42) complete\n";
    }
    catch(int ret) {
        std::cout << "failure: " << strerror(ret) << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// nested1 success
// nested2 success
// nested2(1) complete
// failure: Invalid argument

上面的例子类似于我们的 set jump 例子,主要区别在于我们抛出异常而不是执行长跳转,并且我们使用try...catch块捕获异常。

与 set jump 不同,C++异常支持 RAII,这意味着在函数范围内定义的对象在函数退出时会被正确销毁:

#include <cstring>
#include <iostream>

class myclass
{
public:
    ~myclass()
    {
        std::cout << "destructor called\n";
    }
};

void myfunc(int val)
{
    myclass c{};

    if (val == 42) {
        throw EINVAL;
    }
}

main()
{
    try {
        myfunc(1);
        std::cout << "success\n";

        myfunc(42);
        std::cout << "success\n";
    }
    catch(int ret) {
        std::cout << "failure: " << strerror(ret) << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// destructor called
// success
// destructor called
// failure: Invalid argument

正如在上面的例子中所看到的,析构函数在success情况和失败情况下都被调用。为了实现这一点,C++包括一个堆栈展开器,它能够自动展开堆栈,类似于我们使用 POSIX 风格的错误处理手动展开调用堆栈,但是自动进行,而不需要通过代码执行分支语句,从而实现最佳性能(就好像没有进行错误检查一样)。这被称为零开销异常处理

自动展开器如何在不产生任何性能开销的情况下自动展开调用堆栈的细节,同时仍以线程安全的方式支持 RAII,这超出了本书的范围,因为这个过程非常复杂。然而,下面是一个简要的解释。

当启用 C++异常并编译代码时,每个函数还会为堆栈解开指令编译一组指令,并将其放置在可执行文件中,以便 C++异常解开器可以找到它们。然后编译器会编译代码,就好像没有进行错误处理一样,代码会按照这样执行。如果抛出异常,将创建一个线程安全的对象来包装被抛出的数据,并将其存储。然后,使用之前保存在可执行文件中的调用堆栈解开指令来逆转函数的执行,最终导致抛出异常的函数退出到其调用者。在函数退出之前,将执行所有析构函数,并且对调用堆栈中调用的每个函数都会继续执行这个过程,直到遇到一个能够处理被抛出的数据的catch{}块。

以下是一些需要记住的关键点:

  • 解开指令存储在可执行文件的表中。每当需要从寄存器的角度逆转函数的执行时,解开器必须在表中查找下一个函数的这些指令。这个操作很慢(尽管已经添加了一些优化,包括使用哈希表)。因此,异常不应该用于控制流,因为它们在错误情况下很慢且低效,而在成功情况下非常高效。C++异常应该只用于错误处理。

  • 程序中的函数越多,或者函数越大(即函数接触 CPU 寄存器越多),就需要在解开指令表中存储更多的信息,从而导致程序更大。如果程序中从未使用 C++异常,这些信息仍然会被编译并存储在应用程序中。因此,如果不使用异常,应该禁用异常。

除了线程安全、高性能和支持 RAII 之外,C++异常还支持复杂的数据类型。C++使用的典型数据类型包括字符串,如下所示:

#include <cstring>
#include <iostream>

void myfunc(int val)
{
    if (val == 42) {
        throw std::runtime_error("invalid val");
    }
}

int main()
{
    try {
        myfunc(1);
        std::cout << "success\n";

        myfunc(42);
        std::cout << "success\n";
    }
    catch(const std::runtime_error &e) {
        std::cout << "failure: " << e.what() << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// success
// failure: invalid val

在前面的例子中,我们抛出了一个std::runtime_error{}异常。这个异常是 C++提供的许多异常之一,它继承了std::exception,支持除异常类型本身之外的字符串存储能力。在前面的例子中,我们存储了invalid val。前面的代码不仅能够检测到提供的字符串,还能检测到抛出了std::runtime_exception{}

在某些情况下,您可能不知道抛出的异常类型是什么。当抛出一个不继承std::exception的异常时,比如原始字符串和整数,通常就会出现这种情况。要捕获任何异常,请使用以下方法:

#include <cstring>
#include <iostream>

void myfunc(int val)
{
    if (val == 42) {
        throw -1;
    }
}

main()
{
    try {
        myfunc(1);
        std::cout << "success\n";

        myfunc(42);
        std::cout << "success\n";
    }
    catch(...) {
        std::cout << "failure\n";
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// success
// failure

在前面的例子中,我们抛出一个整数,并使用...语法来捕获它,表示我们希望捕获所有异常。在代码中至少有这种类型的catch{}语句是一个很好的做法,以确保捕获所有异常。在本书的所有示例中,我们都包含了这种catch语句,就是为了这个原因。这种类型的catch{}块的主要缺点是我们必须使用std::current_exception()来获取异常,例如:

#include <cstring>
#include <iostream>
#include <stdexcept>

void myfunc1(int val)
{
    if (val == 42) {
        throw std::runtime_error("runtime_error");
    }
}

void myfunc2(int val)
{
    try {
        myfunc1(val);
    }
    catch(...) {
        auto e = std::current_exception();
        std::rethrow_exception(e);
    }
}

int main()
{
    try {
        myfunc2(42);
    }
    catch(const std::exception& e) {
        std::cout << "caught: " << e.what() << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// caught: runtime_error

在前面的例子中,我们从myfunc1()抛出std::runtime_error()。在myfunc2()中,我们使用...语法捕获异常,表示我们希望捕获所有异常。要获取异常,我们必须使用std::current_exception(),它返回std::exception_ptr{}std::exception_ptr{}是一个特定于实现的指针类型,可以使用std::rethrow_exception()重新抛出。使用这个函数,我们可以使用前面的标准方法捕获异常并输出消息。值得注意的是,如果您希望捕获异常,std::current_exception()不是推荐的方法,因为您需要重新抛出异常才能从中获取what(),因为std::exception_ptr不提供获取what()的接口。还应该注意,如果抛出的异常不是std::exception{}的子类,std::current_exception()也无济于事。

最后,可以用自定义数据替换subclass std::exception。要做到这一点,请参考以下示例:

#include <cstring>
#include <iostream>
#include <stdexcept>

class myexception : public std::exception
{
    int m_error{0};

public:

    myexception(int error) noexcept :
        m_error{error}
    { }

    const char *
    what() const noexcept
    {
      return "error";
    }

    int error() const noexcept
    {
        return m_error;
    }
};

void myfunc(int val)
{
    if (val == 42) {
        throw myexception(42);
    }
}

int main()
{
    try {
        myfunc(1);
        std::cout << "success\n";

        myfunc(42);
        std::cout << "success\n";
    }
    catch(const myexception &e) {
        std::cout << "failure: " << std::to_string(e.error()) << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// success
// failure: 42

在前面的例子中,我们对std::exception进行子类化,以创建我们自己的异常,该异常能够存储错误编号。与所有std::exception{}的子类一样,what()函数应该被重载,以提供一个能够唯一标识你自定义异常的消息。在我们的情况下,我们还提供了一个函数来检索在创建和抛出异常时存储的错误代码。

另一个常见的任务是为您的异常创建自定义字符串。然而,这可能会导致一个常见的错误,即在what()函数中返回一个构造的字符串:

const char *
what() const noexcept
{
    return ("error: " + std::to_string(m_error)).c_str();
}

前面的代码产生了未定义的行为和难以发现的错误。在前面的代码中,我们存储错误代码,就像在前面的例子中一样,但是我们不是返回错误代码,而是在what()函数中返回一个字符串中的错误代码。为此,我们利用std::to_string()函数将我们的错误代码转换为std::string。然后我们添加error:,并返回生成的标准 C 字符串。

前面例子的问题在于返回了指向标准 C 字符串的指针,然后在what()函数退出时销毁了std::string{}。试图使用此函数返回的字符串的代码最终会读取已删除的内存。这很难发现的原因是在某些情况下,这段代码会按预期执行,只是因为内存的内容可能没有变化得足够快。然而,经过足够长的时间,这段代码很可能会导致损坏。

相反,要创建输出相同消息的字符串,请将生成的错误代码放在现有异常的构造函数中:

#include <cstring>
#include <iostream>

class myexception : public std::runtime_error
{
public:
    myexception(int error) noexcept :
        std::runtime_error("error: " + std::to_string(42))
    { }
};

void myfunc(int val)
{
    if (val == 42) {
        throw myexception(42);
    }
}

int main()
{
    try {
        myfunc(1);
        std::cout << "success\n";

        myfunc(42);
        std::cout << "success\n";
    }
    catch(const std::exception &e) {
        std::cout << "failure: " << e.what() << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// success
// failure: error: 42

在前面的例子中,我们对std::runtime_error{}进行子类化,而不是直接对std::exception进行子类化,并在异常构造期间创建我们的what()消息。这样,当调用what()时,异常信息就可以在没有损坏的情况下使用。

我们将以关于 C++17 唯一真正的异常支持方面的说明结束本章。通常不鼓励在已经抛出异常时抛出异常。要实现这一点,您必须从已标记为except()的类的析构函数中抛出异常,并且在堆栈展开期间销毁。在 C++17 之前,析构函数可以通过利用std::uncaught_exception()函数来检测是否即将发生这种情况,该函数在正在抛出异常时返回 true。为了支持在已经抛出异常时抛出异常,C++17 将此函数更改为返回一个整数,该整数表示当前正在抛出的异常的总数:

#include <cstring>
#include <iostream>

class myclass
{
public:
    ~myclass()
    {
        std::cout << "uncaught_exceptions: "
                  << std::uncaught_exceptions() << '\n';
    }
};

void myfunc(int val)
{
    myclass c{};

    if (val == 42) {
        throw EINVAL;
    }
}

int main()
{
    try {
        myfunc(1);
        std::cout << "success\n";

        myfunc(42);
        std::cout << "success\n";
    }
    catch(int ret) {
        std::cout << "failure: " << strerror(ret) << '\n';
    }
}

// > g++ -std=c++17 scratchpad.cpp; ./a.out
// uncaught_exceptions: 0
// success
// uncaught_exceptions: 1
// failure: Invalid argument

在前面的示例中,我们创建了一个类,输出当前正在抛出的异常总数到stdout。然后在myfunc()中实例化这个类。在成功案例中,当销毁类时,没有异常正在被抛出。在错误案例中,当销毁类时,报告有一个异常被抛出。

研究异常基准测试的示例

在最后一个示例中,我们将演示 C++异常优于 POSIX 风格异常(这一说法在很大程度上取决于您执行的硬件,因为编译器优化和激进的分支预测可以提高 POSIX 风格错误处理的性能)。

POSIX 风格的错误处理要求用户每次执行函数时都要检查结果。当函数嵌套发生时(这几乎肯定会发生),这个问题会进一步恶化。在这个示例中,我们将把这种情况推向极端,创建一个递归函数,检查自身的结果数千次,同时执行测试数十万次。每个测试都将进行基准测试,并比较结果。

有很多因素可能会改变这个测试的结果,包括分支预测、优化和操作系统。这个测试的目标是将示例推向极端,以便大部分这些问题都在噪音中消失,任何方法的性能相关问题都很容易识别。

首先,我们需要以下包含:

#include <csetjmp>

#include <chrono>
#include <iostream>

我们还需要以下全局定义的跳转缓冲区,因为我们将比较 C++异常和 set jump 以及 POSIX 风格的错误处理:

jmp_buf jb;

我们还将使用我们在之前章节中使用过的相同基准测试代码:

template<typename FUNC>
auto benchmark(FUNC func) {
    auto stime = std::chrono::high_resolution_clock::now();
    func();
    auto etime = std::chrono::high_resolution_clock::now();

    return (etime - stime).count();
}

我们的第一个递归函数将使用 POSIX 风格的错误处理返回错误:

int myfunc1(int val)
{
    if (val >= 0x10000000) {
        return -1;
    }

    if (val < 0x1000) {
        if (auto ret = myfunc1(val + 1); ret == -1) {
            return ret;
        }
    }

    return 0;
}

如图所示,函数的返回值与预期相比。第二个函数将使用 set jump 返回错误:

void myfunc2(int val)
{
    if (val >= 0x10000000) {
        std::longjmp(jb, -1);
    }

    if (val < 0x1000) {
        myfunc2(val + 1);
    }
}

正如预期的那样,这个函数不那么复杂,因为不需要返回或比较返回值。最后,第三个函数将使用 C++异常返回错误:

void myfunc3(int val)
{
    if (val >= 0x10000000) {
        throw -1;
    }

    if (val < 0x1000) {
        myfunc3(val + 1);
    }
}

正如预期的那样,这个函数与 set jump 几乎相同,唯一的区别是使用了 C++异常。由于我们不测试 RAII,我们期望 C++异常的执行速度与 set jump 一样快,因为两者都不需要进行比较。

最后,在我们的 protected main函数中,我们将以与之前示例相同的方式执行每个函数,以演示每个函数的执行结果如预期。

void test_func1()
{
    if (auto ret = myfunc1(0); ret == 0) {
        std::cout << "myfunc1: success\n";
    }
    else {
        std::cout << "myfunc1: failure\n";
    }

    if (auto ret = myfunc1(bad); ret == 0) {
        std::cout << "myfunc1: success\n";
    }
    else {
        std::cout << "myfunc1: failure\n";
    }

    uint64_t total = 0;
    for (auto i = 0; i < num_iterations; i++) {
        total += benchmark([&] {
            myfunc1(0);
        });
    }

    std::cout << "time1: " << total << '\n';
}

第一个测试函数测试 C 风格的错误处理逻辑,以确保函数按预期返回成功和失败。然后,我们执行成功案例多次,并计算执行所需的时间,将结果输出到stdout

void test_func2()
{
    if (setjmp(jb) == -1) {
        std::cout << "myfunc2: failure\n";

        uint64_t total = 0;
        for (auto i = 0; i < num_iterations; i++) {
            total += benchmark([&] {
                myfunc2(0);
            });
        }

        std::cout << "time2: " << total << '\n';
        return;
    }

    myfunc2(0);
    std::cout << "myfunc2: success\n";

    myfunc2(bad);
    std::cout << "myfunc2: success\n";
}

如图所示,我们还确保第二个 C 风格异常示例也按预期返回成功和失败。然后,我们执行成功案例多次,以查看执行所需的时间:

void test_func3()
{
    try {
        myfunc3(0);
        std::cout << "myfunc3: success\n";

        myfunc3(bad);
        std::cout << "myfunc3: success\n";
    }
    catch(...) {
        std::cout << "myfunc3: failure\n";
    }

    uint64_t total = 0;
    for (auto i = 0; i < num_iterations; i++) {
        total += benchmark([&] {
            myfunc3(0);
        });
    }

    std::cout << "time3: " << total << '\n';
}

我们对 C++异常示例做同样的事情。我们通过执行每个测试来完成我们的protected_main()函数,如下所示:

int
protected_main(int argc, char** argv)
{
    (void) argc;
    (void) argv;

    test_func1();
    test_func2();
    test_func3();

    return EXIT_SUCCESS;
}

基准测试的结果将输出到stdout

int
main(int argc, char **argv)
{
    try {
        return protected_main(argc, argv);
    }
    catch (const std::exception &e) {
        std::cerr << "Caught unhandled exception:\n";
        std::cerr << " - what(): " << e.what() << '\n';
    }
    catch (...) {
        std::cerr << "Caught unknown exception\n";
    }

    return EXIT_FAILURE;
}

与我们的所有示例一样,protected_main()函数由main()函数执行,如果发生异常,则捕获异常。

编译和测试

要编译这段代码,我们利用了我们之前示例中使用的相同的CMakeLists.txt文件:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/raw/master/Chapter13/CMakeLists.txt

有了这个,我们可以使用以下命令编译这段代码:

> git clone https://github.com/PacktPublishing/Hands-On-System-Programming-with-CPP.git
> cd Hands-On-System-Programming-with-CPP/Chapter13/
> mkdir build
> cd build

> cmake ..
> make

要执行示例,请运行以下代码:

> ./example1
myfunc1: success
myfunc1: failure
time1: 1750637978
myfunc2: success
myfunc2: failure
time2: 1609691756
myfunc3: success
myfunc3: failure
time3: 1593301696

如前面的代码片段所示,C++异常优于 POSIX 风格的错误处理,并且 set jump 异常是可比较的。

总结

在本章中,我们学习了三种不同的方法来进行系统编程时的错误处理。第一种方法是 POSIX 风格的错误处理,它涉及从每个执行的函数返回一个错误代码,并检查每个函数的结果以检测错误。第二种方法涉及使用标准的 C 风格异常(即 set jump),演示了这种形式的异常处理如何解决了 POSIX 风格错误处理的许多问题,但引入了 RAII 支持和线程安全的问题。第三个例子讨论了使用 C++ 异常进行错误处理,以及这种错误处理形式如何解决了本章讨论的大部分问题,唯一的缺点是导致生成的可执行文件大小增加。最后,本章以一个示例结束,演示了 C++ 异常如何优于 POSIX 风格的错误处理。

问题

  1. 为什么 C++ 异常优于 POSIX 风格的错误处理?

  2. 使用 POSIX 风格的错误处理,函数如何返回输出?

  3. 为什么 set jump 不支持 RAII?

  4. 如何使用 catch{} 块捕获任何异常?

  5. 为什么 C++ 异常会增加可执行文件的大小?

  6. 为什么不应该将 C++ 异常用于控制流?

进一步阅读

第十四章:评估

第一章

  1. 通过系统调用来完成操作系统提供的任务的行为称为系统编程

  2. 通过调用操作系统的中断处理程序。

  3. 特殊指令已添加到 CPU 以支持系统调用,无需调用中断处理程序,这在执行之前保存了更多的 CPU 状态。

  4. 不。malloc()/free()的大多数实现会从操作系统请求大量内存,然后在程序执行期间划分该内存。只有在内存用尽并且malloc()/free()必须请求更多内存时才需要系统调用。

  5. 推测执行。

  6. 类型安全是编程语言帮助防止由于类型之间差异而导致错误的程度。强类型语言比弱类型语言更能防止这些类型的错误。

  7. C++模板为用户提供了在不提前定义类型信息的情况下定义代码的能力。

第二章

  1. 是的。C 标准的大部分内容也是 POSIX 标准的一部分。 POSIX 通常会提供特定于 POSIX 操作系统的附加设施。 C 和 POSIX 函数的示例包括read()write()

  2. _start()是应用程序的入口点,通常由 C 运行时设施提供。main()是用户提供的函数,通常是用户代码中要执行的第一个函数,最终由 C 运行时设施在应用程序完全初始化后调用。

  3. 执行全局构造函数和析构函数,并初始化 C++异常。

  4. 之前。

  5. C++名称修饰将函数的整个签名嵌入到函数的符号中。这不仅需要为 C++中的函数重载提供支持,还确保链接器不会意外地动态链接两个具有相同名称但具有不同签名的函数(这在 C 中可能会发生)。

  6. C 符号不会被修饰。C++会。

  7. 指针可以指向任何内存,包括nullptr。引用则不行。

第三章

  1. 这取决于 CPU 架构。在某些 CPU 上,短int为 16 位宽,而int为 32 位宽。但并非所有 CPU 都是这样。

  2. 这取决于 CPU 架构。在大多数 CPU 上,int是 32 位宽,但并非总是如此。

  3. 不。

  4. int32_t始终为 32 位宽。在某些 CPU 上,int可以是 16、32 或 64 位宽。

  5. 是的。这些称为精确宽度类型,将始终是所需的宽度。

  6. 确保结构不会被编译器自动填充以进行优化。

  7. 不。

第四章

  1. 结构化绑定提供了通过手动提供单独变量来检索结构结果的能力,例如,auto [first, second] = std::pair{1, 2}

  2. 现在可以在同一行上列出嵌套的命名空间

  3. 您不再需要提供错误消息

  4. 使您能够在if语句中定义变量

  5. 资源获取即初始化

  6. 在构造时获取和初始化资源,并在销毁时释放资源

  7. 指出谁拥有指针(即负责删除指针的实体)

  8. Expects()定义了函数的输入期望,Ensures()定义了函数的输出

第五章

  1. rax

  2. rdi

  3. 减法。

  4. 段是一组部分。

  5. 处理异常所需的信息。

  6. Fork()创建一个新进程,而exec()用新程序覆盖现有进程。这两者都需要启动新程序。

  7. 第二。

  8. 完成的进程的进程 ID。

第六章

  1. std::cin是类型感知的。

  2. 能够处理用户定义的类型,提供更清晰、类型安全的 IO。

  3. 格式说明符通常比#include <iomanip>更灵活。

  4. 如果必须发生刷新,请使用std::endl

  5. std::cerr在每次写入后会刷新,而std::clog不会。在处理错误时,请使用std::cerr确保在灾难性问题发生之前成功刷新所有调试信息。

  6. std::internal

  7. 通过同时使用std::octstd::uppercase

  8. 通过利用gsl::span

  9. 通过利用rdbuf()成员函数。

第七章

  1. new()分配单个对象,而new()分配对象数组。

  2. 不。

  3. 全局内存对整个程序可见,而静态内存(在全局定义)仅对定义它的源文件可见。

  4. 通过使用alignas()函数的别名,比如using aligned_int alignas(0x1000) = int;

  5. 不适用于 C++17 及以下版本

  6. 只有在多个对象必须拥有内存时才应该使用std::shared_ptr(也就是说,内存需要能够以任何顺序和任何时间被多个对象释放)。

  7. 是的(取决于操作系统和权限)。

  8. 如果分配了 4 个字节并使用了 3 个,那么就会产生内部碎片(浪费内存)。如果以这样的方式分配内存,使得分配器不再具有连续的内存块可供分配(即使它有大量空闲内存),那么就会产生外部碎片。

第八章

  1. is_open()

  2. std::ios_base::in | std::ios_base::out

  3. 读取0并设置标志

  4. 缓冲区溢出错误

  5. 是的

  6. 测试

  7. /home/user

第九章

  1. 这意味着相同分配器的两个实例始终相等,这反过来意味着两个分配器都可以分配和释放彼此的内存。

  2. 如果相同分配器的两个实例可以分配和释放彼此的内存。

  3. 是的。

  4. 是的。

  5. 当容器被复制时,它的分配器也被复制。

  6. 它为容器提供了使用为不同类型创建分配器的副本的能力。

  7. 对于std::listn ==1;对于std::vectorn可以是任何数字。

第十章

  1. UDP 是无连接的。

  2. SOCK_DGRAM

  3. SOCK_STREAM

  4. IPV4。

  5. Bind()分配一个端口,而connect()连接到先前分配的端口。

  6. sendto()以地址作为参数,通常由 UDP 使用,而send()通常由 TCP 使用。

  7. 它不会。

  8. 类型安全。

第十一章

  1. 1970 年 1 月 1 日星期四

  2. 自 UNIX 纪元开始以来的秒数。

  3. clock()相对于程序的执行。

  4. 非 POSIX 操作系统可能支持分数时间。

  5. difftime()的包装器。

  6. 稳定时钟提供实际时间,而高分辨率计时器提供的数字只有在与duration{}一起使用时才提供值。

第十二章

  1. pthread_self()

  2. 它们不是类型安全的。

  3. 当两个线程竞争读/写相同的资源时。

  4. 当线程等待永远不会被释放的同步原语(例如互斥锁)时。

  5. C++ future 提供了一种类型安全的机制,用于返回线程的结果。

  6. 确保函数只执行一次,而不管调用它的线程数是多少。

  7. std::shared_mutex提供了支持多个读取器的能力。

  8. 允许单个线程多次锁定同一互斥锁而不会陷入死锁。

第十三章

  1. C++异常不需要检查每个函数调用的返回结果。

  2. POSIX 风格的函数必须保留函数输出的一部分以传达错误。例如,如果函数必须返回文件句柄,在发生错误时返回0,这意味着文件句柄不能具有值0

  3. 跳转设置不会解开堆栈,这意味着析构函数被跳过。

  4. catch(...)

  5. 必须为每个函数存储如何解开堆栈的指令。这是为了提高性能而进行的权衡。

  6. 它们很慢。

posted @ 2024-05-15 15:27  绝不原创的飞龙  阅读(19)  评论(0编辑  收藏  举报