Linux-系统编程实用手册-全-
Linux 系统编程实用手册(全)
原文:
zh.annas-archive.org/md5/9713B9F84CB12A4F8624F3E68B0D4320译者:飞龙
前言
Linux 操作系统及其嵌入式和服务器应用程序是当今分散和网络化宇宙中关键软件基础设施的关键组成部分。对熟练的 Linux 开发人员的行业需求不断增加。本书旨在为您提供两方面的内容:扎实的理论基础和实用的、与行业相关的信息——通过代码进行说明——涵盖 Linux 系统编程领域。本书深入探讨了 Linux 系统编程的艺术和科学,包括系统架构、虚拟内存、进程内存和管理、信号、定时器、多线程、调度和文件 I/O。
这本书试图超越使用 API X 来实现 Y 的方法;它着力解释了理解编程接口、设计决策以及有经验的开发人员在使用它们时所做的权衡和背后的原理所需的概念和理论。故障排除技巧和行业最佳实践丰富了本书的内容。通过本书,您将具备与 Linux 系统编程接口一起工作所需的概念知识和实践经验。
这本书适合谁
《Linux 系统编程实践》是为 Linux 专业人士准备的:系统工程师、程序员和测试人员(QA)。它也适用于学生;任何想要超越使用 API 集合理解强大的 Linux 系统编程 API 背后的理论基础和概念的人。您应该熟悉 Linux 用户级别的知识,包括登录、通过命令行界面使用 shell 以及使用 find、grep 和 sort 等工具。需要具备 C 编程语言的工作知识。不需要有 Linux 系统编程的先前经验。
这本书涵盖了什么
《Linux 系统架构》一章涵盖了关键基础知识:Unix 设计理念和 Linux 系统架构。同时,还涉及了其他重要方面——CPU 特权级别、处理器 ABI 以及系统调用的真正含义。
《虚拟内存》一章澄清了关于虚拟内存的常见误解以及为什么它对现代操作系统设计至关重要;还介绍了进程虚拟地址空间的布局。
《资源限制》一章深入探讨了每个进程资源限制以及管理其使用的 API。
《动态内存分配》一章首先介绍了流行的 malloc API 系列的基础知识,然后深入探讨了更高级的方面,如程序断点、malloc 的真正行为、需求分页、内存锁定和保护,以及使用 alloca 函数。
《Linux 内存问题》一章介绍了(不幸地)普遍存在的内存缺陷,这些缺陷由于对内存 API 的正确设计和使用缺乏理解而出现在我们的项目中。涵盖了未定义行为(一般)、溢出和下溢错误、泄漏等缺陷。
《内存问题调试工具》一章展示了如何利用现有工具,包括编译器本身、Valgrind 和 AddressSanitizer,用于检测前一章中出现的内存问题。
《进程凭证》一章是两章中第一章,重点是让您从系统角度思考和理解安全性和特权。在这里,您将了解传统安全模型——一组进程凭证——以及操作它们的 API。重要的是,还深入探讨了 setuid-root 进程及其安全影响。
第八章,进程能力,向你介绍了现代 POSIX 能力模型以及当应用程序开发人员学会使用和利用这一模型而不是传统模型时,安全性可以得到的好处。我们还探讨了能力是什么,如何嵌入它们以及安全性的实际设计。
第九章,进程执行,是处理广泛的进程管理领域(执行、创建和信号)的四章中的第一章。在本章中,你将学习 Unix exec 公理的行为方式以及如何使用 API 集(exec 家族)来利用它。
第十章,进程创建,深入探讨了fork(2)系统调用的行为和使用方法;我们通过七条 fork 规则来描述这一过程。我们还描述了 Unix 的 fork-exec-wait 语义(并深入探讨了等待 API),还涵盖了孤儿进程和僵尸进程。
第十一章,信号-第一部分,涉及了 Linux 平台上信号的重要主题:信号的含义、原因和方式。我们在这里介绍了强大的sigaction(2)系统调用,以及诸如可重入和信号异步安全性、sigaction 标志、信号堆栈等主题。
第十二章,信号-第二部分,继续我们对信号的覆盖,因为这是一个庞大的主题。我们将指导你正确地编写一个处理臭名昭著的致命段错误的信号处理程序,以及处理实时信号、向进程发送信号、使用信号进行进程间通信以及处理信号的其他替代方法。
第十三章,定时器,教会你如何在现实世界的 Linux 应用程序中设置和处理定时器这一重要(和与信号相关的)主题。我们首先介绍传统的定时器 API,然后迅速转向现代的 POSIX 间隔定时器以及如何使用它们。我们还介绍并演示了两个有趣的小项目。
第十四章,使用 Pthreads 进行多线程编程第一部分-基础知识,是关于在 Linux 上使用 pthread 框架进行多线程编程的三部曲中的第一部分。在这里,我们向你介绍了线程究竟是什么,它与进程的区别,以及使用线程的动机(在设计和性能方面)。本章还指导你了解在 Linux 上编写 pthread 应用程序的基础知识,包括线程的创建、终止、加入等。
第十五章,使用 Pthreads 进行多线程编程第二部分-同步,是一个专门讨论同步和竞争预防这一非常重要主题的章节。你将首先了解问题的本质,然后深入探讨原子性、锁定、死锁预防等关键主题。接下来,本章将教你如何使用 pthread 同步 API 来处理互斥锁和条件变量。
第十六章,使用 Pthreads 进行多线程编程第三部分,完成了我们关于多线程的工作;我们阐明了线程安全、线程取消和清理以及在多线程应用程序中处理信号的关键主题。我们在本章中讨论了多线程的利弊,并解答了一些常见问题。
第十七章,Linux 上的 CPU 调度,向您介绍了系统程序员应该了解的与调度相关的主题。我们涵盖了 Linux 进程/线程状态机,实时概念以及 Linux 操作系统提供的三种(最小)POSIX CPU 调度策略。通过利用可用的 API,您将学习如何在 Linux 上编写软实时应用程序。我们最后简要介绍了一个有趣的事实,即 Linux可以被打补丁以作为实时操作系统。
第十八章,高级文件 I/O,完全专注于在 Linux 上执行更高级的 IO 以获得最佳性能(因为 IO 通常是瓶颈)。您将简要了解 Linux IO 堆栈的架构(页面缓存至关重要),以及向操作系统提供文件访问模式建议的 API。编写性能 IO 代码,正如您将了解的那样,涉及使用诸如 SG-I/O、内存映射、DIO 和 AIO 等技术。
第十九章,故障排除和最佳实践,是对 Linux 故障排除关键要点的重要总结。您将了解到使用强大工具,如 perf 和跟踪工具。然后,本章试图总结一般软件工程和特别是 Linux 编程的关键要点,探讨行业最佳实践。我们认为这些对于任何程序员来说都是至关重要的收获。
附录 A,文件 I/O 基础知识,向您介绍了如何在 Linux 平台上执行高效的文件 I/O,通过流式(stdio 库层)API 集以及底层系统调用。在此过程中,还涵盖了有关缓冲及其对性能的影响的重要信息。
请参考本章:www.packtpub.com/sites/default/files/downloads/File_IO_Essentials.pdf。
附录 B,守护进程,以简洁的方式向您介绍了 Linux 上守护进程的世界。您将了解如何编写传统的 SysV 风格守护进程。还简要介绍了构建现代新风格守护进程所涉及的内容。
请参考本章:www.packtpub.com/sites/default/files/downloads/Daemon_Processes.pdf。
为了充分利用本书
正如前面提到的,本书旨在面向 Linux 软件专业人员——无论是开发人员、程序员、架构师还是 QA 人员,以及希望通过 Linux 操作系统的系统编程主题扩展知识和技能的认真学生。
我们假设您熟悉通过命令行界面、shell 使用 Linux 系统。我们还假设您熟悉使用 C 语言进行编程,知道如何使用编辑器和编译器,并熟悉 Makefile 的基础知识。我们不假设您对书中涉及的主题有任何先前的知识。
为了充分利用本书——我们非常明确地指出——您不仅必须阅读材料,还必须积极地动手尝试、修改提供的代码示例,并尝试完成作业!为什么?简单:实践才是真正教会您并内化主题的方法;犯错误并加以修正是学习过程中至关重要的一部分。我们始终主张经验主义方法——不要轻信任何东西。实验,亲自尝试并观察。
因此,我们建议您克隆本书的 GitHub 存储库(请参阅以下部分的说明),浏览文件,并尝试它们。显然,为了进行实验,使用虚拟机(VM)是绝对推荐的(我们已经在 Ubuntu 18.04 LTS 和 Fedora 27/28 上测试了代码)。书的 GitHub 存储库中还提供了在系统上安装的强制和可选软件包的清单;请阅读并安装所有必需的实用程序,以获得最佳体验。
最后,但绝对不是最不重要的,每一章都有一个进一步阅读部分,在这里提到了额外的在线链接和书籍(在某些情况下);我们建议您浏览这些内容。您将在书的 GitHub 存储库上找到每一章的进一步阅读材料。
下载示例代码文件
您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便直接将文件发送到您的邮箱。
您可以按照以下步骤下载代码文件:
-
登录或注册www.packt.com。
-
选择“支持”选项卡。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名并按照屏幕上的说明操作。
文件下载后,请确保使用最新版本的解压缩或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。我们还有其他丰富书籍和视频代码包可供查看,网址为github.com/PacktPublishing/。请查看。
下载彩色图片
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在此处下载:www.packtpub.com/sites/default/files/downloads/9781788998475_ColorImages.pdf
使用的约定
本书中使用了许多文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:“让我们通过我们的membugs.c程序的源代码来检查这些。”
代码块设置如下:
include <pthread.h>
int pthread_mutexattr_gettype(const pthread_mutexattr_t *restrict attr, int *restrict type);
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);
当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体显示:
include <pthread.h>
int pthread_mutexattr_gettype(const pthread_mutexattr_t *restrict attr, int *restrict type);
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);
任何命令行输入或输出都以以下方式编写:
$ ./membugs 3
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:“通过下拉菜单选择 C 作为语言。”
警告或重要说明会以这种方式出现。
提示和技巧会以这种方式出现。
联系我们
我们始终欢迎读者的反馈。
一般反馈:发送电子邮件至customercare@packtpub.com,并在主题中提及书名。如果您对本书的任何方面有疑问,请发送电子邮件至customercare@packtpub.com与我们联系。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误确实会发生。如果您在本书中发现错误,我们将不胜感激地向我们报告。请访问www.packt.com/submit-errata,选择您的书,单击勘误提交表单链接,然后输入详细信息。
盗版:如果您在互联网上发现我们作品的任何形式的非法复制,请您提供给我们地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并附上材料链接。
如果您有兴趣成为作者:如果您在某个专题上有专业知识,并且有兴趣撰写或为一本书做出贡献,请访问authors.packtpub.com。
评论
请留下评论。当您阅读并使用了这本书后,为什么不在购买它的网站上留下评论呢?潜在的读者可以看到并使用您公正的意见来做出购买决定,我们在 Packt 可以了解您对我们产品的看法,我们的作者也可以看到您对他们书籍的反馈。谢谢!
有关 Packt 的更多信息,请访问packt.com。
第一章:Linux 系统架构
本章介绍了 Linux 生态系统的系统架构。首先介绍了优雅的 Unix 哲学和设计基础,然后深入探讨了 Linux 系统架构的细节。将涵盖 ABI 的重要性、CPU 特权级别以及现代操作系统如何利用它们,以及 Linux 系统架构的分层和 Linux 是一个单体架构。还将介绍系统调用 API 的(简化的)流程以及内核代码执行上下文等关键点。
在本章中,读者将学习以下主题:
-
Unix 哲学简介
-
架构初步
-
Linux 架构层
-
Linux——单体操作系统
-
内核执行上下文
在这个过程中,我们将使用简单的例子来阐明关键的哲学和架构观点。
技术要求
需要一台现代台式电脑或笔记本电脑;Ubuntu 桌面版指定以下为安装和使用该发行版的推荐系统要求:
-
2GHz 双核处理器或更好
-
RAM
-
在物理主机上运行:2GB 或更多系统内存
-
作为客户操作系统运行:主机系统应至少具有 4GB RAM(内存越大,体验越好,更加流畅)
-
25GB 的可用硬盘空间
-
安装介质需要 DVD 驱动器或 USB 端口
-
互联网访问肯定是有帮助的
我们建议读者使用以下 Linux 发行版(可以安装为 Windows 或 Linux 主机系统的客户操作系统,如前所述):
-
Ubuntu 18.04 LTS 桌面版(Ubuntu 16.04 LTS 桌面版也是一个不错的选择,因为它也有长期支持,几乎所有功能都应该可以使用)
-
Ubuntu 桌面版下载链接:
www.ubuntu.com/download/desktop -
Fedora 27(工作站)
请注意,这些发行版在默认情况下是开源软件和非专有软件,可以免费使用。
有时书中并未包含完整的代码片段。因此,GitHub 链接可用于参考代码:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
此外,在进一步阅读部分,请参考上述 GitHub 链接。
Linux 和 Unix 操作系统
摩尔定律著名地指出,集成电路中的晶体管数量将每两年(大约)翻一番(并附带成本将以几乎相同的速度减半)。这个定律在很多年内保持相当准确,这是清楚地表明了人们对电子和信息技术行业的创新和技术范式转变的速度的认识和庆祝;这里创新和技术范式转变的速度是无与伦比的。以至于现在,每年,甚至在某些情况下每几个月,新的创新和技术出现,挑战并最终淘汰旧的技术,几乎没有仪式感。
在这个快速变化的背景下,有一个引人入胜的反常现象:一个操作系统的基本设计、哲学和架构在近五十年来几乎没有发生任何变化。是的,我们指的是古老的 Unix 操作系统。
Unix 起源于 AT&T 贝尔实验室的一个注定失败的项目(Multics),大约在 1969 年。Unix 曾一度风靡全球。至少在一段时间内是如此。
但是,你可能会说,这是一本关于 Linux 的书;为什么要提供这么多关于 Unix 的信息?简单地说,因为在本质上,Linux 是古老 Unix 操作系统的最新化身。Linux 是一种类 Unix 操作系统(还有其他几种)。出于法律需要,代码是独特的;然而,Linux 的设计、哲学和架构与 Unix 的几乎完全相同。
Unix 哲学简介
要理解任何人(或任何事物),必须努力首先理解他们(或它的)基本哲学;要开始理解 Linux 就是开始理解 Unix 哲学。在这里,我们不打算深入到每一个细节;相反,我们的目标是对 Unix 哲学的基本要点有一个整体的理解。此外,当我们使用术语 Unix 时,我们也非常指的是 Linux!
软件(特别是工具)在 Unix 上的设计、构建和维护方式慢慢演变成了一种被称为 Unix 设计哲学的模式。在其核心,这里是 Unix 哲学、设计和架构的支柱:
-
一切都是一个进程;如果不是进程,就是一个文件
-
一个工具做一件事
-
三个标准 I/O 通道
-
无缝地组合工具
-
首选纯文本
-
命令行界面,而不是图形界面
-
模块化,设计为供他人重新利用
-
提供机制,而不是策略
让我们更仔细地检查这些支柱,好吗?
一切都是一个进程 - 如果不是进程,就是一个文件
进程是正在执行的程序的一个实例。文件是文件系统上的一个对象;除了具有纯文本或二进制内容的常规文件之外;它还可以是一个目录、一个符号链接、一个设备特殊文件、一个命名管道或者一个(Unix 域)套接字。
Unix 设计哲学将外围设备(如键盘、显示器、鼠标、传感器和触摸屏)抽象为文件 - 它称之为设备文件。通过这样做,Unix 允许应用程序员方便地忽略细节,只是将(外围)设备视为普通的磁盘文件。
内核提供了一个处理这种抽象的层 - 它被称为虚拟文件系统开关(VFS)。因此,有了这个层,应用程序开发人员可以打开设备文件并对其进行 I/O(读取和写入),所有这些都使用提供的通常 API 接口(放心,这些 API 将在后续章节中介绍)。
实际上,每个进程在创建时都会继承三个文件:
-
标准输入(
stdin:fd 0):默认情况下是键盘设备 -
标准输出(
stdout:fd 1):默认情况下是监视器(或终端)设备 -
标准错误(
stderr:fd 2):默认情况下是监视器(或终端)设备
fd是文件描述符的常见缩写,特别是在代码中;它是一个指向所讨论的打开文件的整数值。
另外,注意我们提到默认情况下是某个设备 - 这意味着默认值可以被更改。事实上,这是设计的一个关键部分:改变标准输入、输出或错误通道被称为重定向,通过使用熟悉的<、>和 2> shell 操作符,这些文件通道被重定向到其他文件或设备。
在 Unix 上,存在一类被称为过滤器的程序。
过滤器是一个从其标准输入读取的程序,可能修改输入,并将过滤后的结果写入其标准输出。
Unix 上的过滤器是非常常见的实用工具,比如cat、wc、sort、grep、perl、head和tail。
过滤器允许 Unix 轻松地规避设计和代码复杂性。如何做到的?
让我们以sort过滤器作为一个快速的例子。好的,我们需要一些数据来排序。假设我们运行以下命令:
$ cat fruit.txt
orange
banana
apple
pear
grape
pineapple
lemon
cherry
papaya
mango
$
现在我们考虑使用sort的四种情况;根据我们传递的参数,我们实际上正在执行显式或隐式的输入、输出和/或错误重定向!
场景 1:对文件进行字母排序(一个参数,输入隐式重定向到文件):
$ sort fruit.txt
apple
banana
cherry
grape
lemon
mango
orange
papaya
pear
pineapple
$
好的!
不过,等一下。如果sort是一个过滤器(它是),它应该从其stdin(键盘)读取,并将其写入stdout(终端)。它确实是写入终端设备,但它是从一个文件fruit.txt中读取的。
这是故意的;如果提供了参数,sort 程序会将其视为标准输入,这一点显而易见。
另外,注意sort fruit.txt和sort < fruit.txt是相同的。
情景 2:按字母顺序对任何给定的输入进行排序(无参数,输入和输出从 stdin/stdout 进行):
$ sort
mango
apple
pear
^D
apple
mango
pear
$
一旦输入sort并按下Enter键,排序过程就开始运行并等待。为什么?它在等待你,用户,输入。为什么?回想一下,默认情况下,每个进程都从标准输入或 stdin - 键盘设备读取输入!所以,我们输入一些水果名称。当我们完成时,按下Ctrl + D。这是表示文件结束(EOF)的默认字符序列,或者在这种情况下,表示输入结束。哇!输入已经排序并写入。写到哪里?写到sort进程的 stdout - 终端设备,因此我们可以看到它。
情景 3:按字母顺序对任何给定的输入进行排序,并将输出保存到文件中(显式输出重定向):
$ sort > sorted.fruit.txt
mango
apple
pear
^D
$
与情景 2 类似,我们输入一些水果名称,然后按Ctrl + D告诉 sort 我们已经完成了。不过这次要注意的是,输出是通过>元字符重定向到sorted.fruits.txt文件!
因此,预期的输出如下:
$ cat sorted.fruit.txt
apple
mango
pear
$
情景 4:按字母顺序对文件进行排序,并将输出和错误保存到文件中(显式输入、输出和错误重定向):
$ sort < fruit.txt > sorted.fruit.txt 2> /dev/null
$
有趣的是,最终结果与前一个情景中的结果相同,还有一个额外的优势,即将任何错误输出重定向到错误通道。在这里,我们将错误输出重定向(回想一下,文件描述符 2 总是指向stderr)到/dev/null特殊设备文件;/dev/null是一个设备文件,其作用是充当一个接收器(一个黑洞)。写入空设备的任何内容都将永远消失!(谁说 Unix 上没有魔法?)此外,它的补充是/dev/zero;零设备是一个源 - 一个无限的零源。从中读取将返回零(第一个 ASCII 字符,而不是数字 0);它没有文件结束!
一个工具做一件事
在 Unix 设计中,人们试图避免创建一把瑞士军刀;相反,人们为一个非常具体的指定目的创建一个工具,只为这一个目的。没有如果,没有但是;没有杂物,没有混乱。这就是设计的简单性。
“简单是终极的复杂。”
- 列奥纳多·达·芬奇
举个常见的例子:在 Linux CLI(命令行界面)上工作时,您可能想知道您本地挂载的文件系统中哪个有最多的可用(磁盘)空间。
我们可以通过适当的开关获取本地挂载的文件系统的列表(只需df也可以):
$ df --local
Filesystem 1K-blocks Used Available Use% Mounted on
rootfs 20640636 1155492 18436728 6% /
udev 10240 0 10240 0% /dev
tmpfs 51444 160 51284 1% /run
tmpfs 5120 0 5120 0% /run/lock
tmpfs 102880 0 102880 0% /run/shm
$
要对输出进行排序,首先需要将其保存到一个文件中;可以使用临时文件进行此操作,tmp,然后使用sort实用程序进行排序。最后,我们删除这个临时文件。(是的,有一个更好的方法,管道;请参考无缝组合工具部分)
请注意,可用空间是第四列,因此我们相应地进行排序:
$ df --local > tmp
$ sort -k4nr tmp
rootfs 20640636 1155484 18436736 6% /
tmpfs 102880 0 102880 0% /run/shm
tmpfs 51444 160 51284 1% /run
udev 10240 0 10240 0% /dev
tmpfs 5120 0 5120 0% /run/lock
Filesystem 1K-blocks Used Available Use% Mounted on
$
哎呀!输出包括标题行。让我们首先使用多功能的sed实用程序 - 一个强大的非交互式编辑工具 - 从df的输出中消除第一行,即标题行:
$ df --local > tmp
$ sed --in-place '1d' tmp
$ sort -k4nr tmp
rootfs 20640636 1155484 18436736 6% /
tmpfs 102880 0 102880 0% /run/shm
tmpfs 51444 160 51284 1% /run
udev 10240 0 10240 0% /dev
tmpfs 5120 0 5120 0% /run/lock
$ rm -f tmp
那又怎样?关键是,在 Unix 上,没有一个实用程序可以同时列出挂载的文件系统并按可用空间进行排序。
相反,有一个用于列出挂载的文件系统的实用程序:df。它做得很好,有选择的选项开关。(如何知道哪些选项?学会使用 man 页面,它们非常有用。)
有一个用于对文本进行排序的实用程序:sort。同样,它是对文本进行排序的最后一个单词,有很多选项开关可供选择,几乎可以满足每一个可能需要的排序。
Linux man 页面:man是manual的缩写;在终端窗口上,输入man man以获取有关使用 man 的帮助。请注意,手册分为 9 个部分。例如,要获取有关 stat 系统调用的手册页,请输入man 2 stat,因为所有系统调用都在手册的第二部分。使用的约定是 cmd 或 API;因此,我们称之为stat(2)。
正如预期的那样,我们获得了结果。那么到底是什么意思呢?就是这个:我们使用了三个实用程序,而不是一个。df,用于列出已挂载的文件系统(及其相关的元数据),sed,用于消除标题行,以及sort,以任何可想象的方式对其给定的输入进行排序。
df可以查询和列出已挂载的文件系统,但它不能对它们进行排序。sort可以对文本进行排序;它不能列出已挂载的文件系统。
想一想这一刻。
将它们组合起来,你会得到比其各部分更多的东西! Unix 工具通常只做一项任务,并且他们会把它做到逻辑上的结论;没有人做得比他们更好!
说到这一点,我想有点羞怯地指出,备受推崇的工具 Busybox。 Busybox(http://busybox.net)被宣传为嵌入式 Linux 的瑞士军刀。它确实是一个非常多才多艺的工具;它在嵌入式 Linux 生态系统中有其位置 - 正是因为在嵌入式盒子上为每个实用程序都有单独的二进制可执行文件太昂贵(而且会消耗更多的 RAM)。 Busybox 通过具有单个二进制可执行文件(以及从其每个 applet(如 ls、ps、df 和 sort)到它的符号链接)来解决这个问题。
因此,除了嵌入式场景和它所暗示的所有资源限制之外,确实要遵循一个工具只做一项任务的规则!
三个标准 I/O 通道
再次,一些流行的 Unix 工具(技术上称为过滤器)是故意设计为从称为标准输入(stdin)的标准文件描述符读取它们的输入 - 可能修改它,并将它们的结果输出写入称为标准输出(stdout)的标准文件描述符。任何错误输出都可以写入一个名为标准错误(stderr)的单独错误通道。
与 shell 的重定向操作符(>用于输出重定向和<用于输入重定向,2>用于 stderr 重定向)以及更重要的是管道(参见章节,无缝组合工具),这使得程序设计师能够高度简化。不需要硬编码(或者甚至软编码,无论如何)输入和输出源或接收器。它就像预期的那样工作。
让我们回顾一些快速示例,以说明这一重要观点。
字数统计
我下载的 C netcat.c源文件中有多少行源代码?(在这里,我们使用了流行的开源netcat实用程序代码库的一小部分。)我们使用wc实用程序。在我们进一步之前,wc是什么?word count(wc)是一个过滤器:它从 stdin 读取输入,计算输入流中的行数、单词数和字符数,并将结果写入其 stdout。此外,作为一种便利,可以将文件名作为参数传递给它;传递-l选项开关使 wc 只打印行数:
$ wc -l src/netcat.c
618 src/netcat.c
$
在这里,输入是作为参数传递给wc的文件名。
有趣的是,我们现在应该意识到,如果我们不向它传递任何参数,wc将从 stdin 读取其输入,默认情况下是键盘设备。例如如下所示:
$ wc -l
hey, a small
quick test
of reading from stdin
by wc!
^D
4
$
是的,我们在 stdin 中输入了4行;因此结果是 4,写入 stdout - 默认情况下是终端设备。
这就是它的美丽之处:
$ wc -l < src/netcat.c > num
$ cat num
618
$
正如我们所看到的,wc 是 Unix 过滤器的一个很好的例子。
猫
Unix,当然还有 Linux,用户学会快速熟悉日常使用的cat实用程序。乍一看,cat 所做的就是将文件的内容输出到终端。
例如,假设我们有两个纯文本文件,myfile1.txt和myfile2.txt:
$ cat myfile1.txt
Hello,
Linux System Programming,
World.
$ cat myfile2.txt
Okey dokey,
bye now.
$
好的。现在看看这个:
$ cat myfile1.txt myfile2.txt
Hello,
Linux System Programming,
World.
Okey dokey,
bye now.
$
我们只需要运行cat一次,通过将两个文件名作为参数传递给它。
理论上,可以向 cat 传递任意数量的参数:它将一个接一个地使用它们!
不仅如此,还可以使用 shell 通配符(*和?;实际上,shell 将首先扩展通配符,并将结果路径名作为参数传递给被调用的程序):
$ cat myfile?.txt
Hello,
Linux System Programming,
World.
Okey dokey,
bye now.
$
事实上,这实际上说明了另一个关键点:任何数量的参数或没有参数都被认为是设计程序的正确方式。当然,每个规则都有例外:有些程序要求强制参数。
等等,还有更多。cat也是 Unix 过滤器的一个很好的例子(回想一下:过滤器是一个从其标准输入读取的程序,以某种方式修改其输入,并将结果写入其标准输出的程序)。
那么,快速测验,如果我们只是运行cat而没有参数,会发生什么?
好吧,让我们试一试看看:
$ cat
hello,
hello,
oh cool
oh cool
it reads from stdin,
it reads from stdin,
and echoes whatever it reads to stdout!
and echoes whatever it reads to stdout!
ok bye
ok bye
^D
$
哇,看看:cat在其标准输入处阻塞(等待),用户输入一个字符串并按 Enter 键,cat通过将其标准输入复制到其标准输出来做出响应-毫不奇怪,因为这就是猫的工作要点!
我们意识到以下命令如下所示:
-
cat fname等同于cat < fname -
cat > fname创建或覆盖fname文件
没有理由我们不能使用 cat 将几个文件追加在一起:
$ cat fname1 fname2 fname3 > final_fname
$
这不一定要使用纯文本文件;也可以合并二进制文件。
事实上,这就是这个实用程序所做的-它连接文件。因此它的名字;与 Unix 上的规范一样,高度缩写-从 concatenate 到 cat。再次,干净而优雅-Unix 的方式。
猫将文件内容输出到标准输出,按顺序。如果想要以相反的顺序(最后一行先)显示文件的内容怎么办?使用 Unix 的tac实用程序-是的,就是猫的拼写反过来!
另外,FYI,我们看到 cat 可以用来高效地连接文件。猜猜:split (1)实用程序可以用来将文件分割成多个部分。
无缝地组合工具
我们刚刚看到,常见的 Unix 实用程序通常被设计为过滤器,这使它们能够从它们的标准输入读取,并将结果写入它们的标准输出。这个概念被优雅地扩展到无缝地组合多个实用程序,使用一个叫做管道的 IPC 机制。
此外,我们还记得 Unix 哲学拥抱只做一项任务的设计。如果我们有一个执行任务 A 的程序和另一个执行任务 B 的程序,我们想要将它们组合起来怎么办?啊,这正是管道所做的!参考以下代码:
prg_does_taskA | prg_does_taskB
管道本质上是重定向执行两次:左侧程序的输出成为右侧程序的输入。当然,这意味着左侧的程序必须写入 stdout,右侧的程序必须从 stdin 读取。
例如:按可用空间(以相反顺序)对挂载的文件系统列表进行排序。
正如我们已经在一个工具只做一项任务部分讨论过的例子一样,我们不会重复相同的信息。
选项 1:使用临时文件执行以下代码(参考部分,一个工具只做一项任务):
$ df --local | sed '1d' > tmp
$ sed --in-place '1d' tmp
$ sort -k4nr tmp
rootfs 20640636 1155484 18436736 6% /
tmpfs 102880 0 102880 0% /run/shm
tmpfs 51444 160 51284 1% /run
udev 10240 0 10240 0% /dev
tmpfs 5120 0 5120 0% /run/lock
$ rm -f tmp
选项 2:使用管道-干净而优雅:
$ df --local | sed '1d' | sort -k4nr
rootfs 20640636 1155492 18436728 6% /
tmpfs 102880 0 102880 0% /run/shm
tmpfs 51444 160 51284 1% /run
udev 10240 0 10240 0% /dev
tmpfs 5120 0 5120 0% /run/lock
$
这不仅优雅,而且在性能上也更加出色,因为写入内存(管道是一个内存对象)比写入磁盘要快得多。
一个可以扩展这个概念,并通过多个管道组合多个工具;实际上,可以通过组合它们来构建一个超级工具。
例如:显示占用最多(物理)内存的三个进程;仅显示它们的 PID,虚拟大小(VSZ),驻留集大小(RSS)(RSS 是对物理内存使用的相当准确的度量),以及名称:
$ ps au | sed '1d' | awk '{printf("%6d %10d %10d %-32s\n", $2, $5, $6, $11)}' | sort -k3n | tail -n3
10746 3219556 665252 /usr/lib64/firefox/firefox
10840 3444456 1105088 /usr/lib64/firefox/firefox
1465 5119800 1354280 /usr/bin/gnome-shell
$
在这里,我们通过四个管道组合了五个实用程序,ps,sed,awk,sort和tail。不错!
另一个例子:显示占用最多内存(RSS)的进程,不包括守护进程*:
ps aux | awk '{if ($7 != "?") print $0}' | sort -k6n | tail -n1
守护进程是系统后台进程;我们将在守护进程这里介绍这个概念:www.packtpub.com/sites/default/files/downloads/Daemon_Processes.pdf。
纯文本优先
Unix 程序通常设计为使用文本,因为它是一个通用接口。当然,有一些实用程序确实操作二进制对象(如对象和可执行文件);我们在这里不是指它们。重点是:Unix 程序设计为在文本上工作,因为它简化了程序的设计和架构。
一个常见的例子:一个应用程序在启动时解析配置文件。配置文件可以格式化为二进制数据块。另一方面,将其作为纯文本文件使其易于阅读(无价!),因此更容易理解和维护。有人可能会认为解析二进制会更快。也许在某种程度上是这样,但考虑以下情况:
-
在现代硬件上,差异可能并不显著
-
标准化的纯文本格式(如 XML)将优化代码以解析它,从而产生双重好处
记住,简单是关键!
CLI,而不是 GUI
Unix 操作系统及其所有应用程序、实用程序和工具都是为了从命令行界面(CLI)而构建的,通常是 shell。从 20 世纪 80 年代开始,对图形用户界面(GUI)的需求变得明显。
麻省理工学院的 Robert Scheifler 被认为是 X Window 系统的首席设计架构师,他构建了一个非常干净和优雅的架构,其中的一个关键组成部分是:GUI 形成了 OS 上方的一层(实际上是几层),为 GUI 客户端即应用程序提供库。
GUI 从来不是设计为应用程序或操作系统的固有部分 - 它始终是可选的。
这种架构今天仍然有效。话虽如此,尤其是在嵌入式 Linux 上,出于性能原因,新架构的出现,比如帧缓冲区和 Wayland。此外,尽管使用 Linux 内核的 Android 需要为最终用户提供 GUI,但系统开发人员与 Android 的接口 ADB 是 CLI。
大量的生产嵌入式和服务器 Linux 系统纯粹依靠 CLI 界面运行。GUI 几乎就像是一个附加功能,为最终用户的操作方便。
在适当的地方,设计您的工具以在 CLI 环境中工作;稍后将其适应 GUI 就变得简单了。
清晰而谨慎地将项目或产品的业务逻辑与其 GUI 分离是良好设计的关键。
模块化,设计为他人重新利用
从 Unix 操作系统的早期开始,它就被有意地设计和编码,假定多个程序员将在系统上工作。因此,编写干净、优雅和易于理解的代码的文化,以便其他有能力的程序员阅读和使用,已经根深蒂固。
后来,随着 Unix 战争的出现,专有和法律上的关注超越了这种共享模式。有趣的是,历史表明 Unix 在相关性和行业使用方面逐渐失去了地位,直到及时出现了 Linux 操作系统 - 这是一个开源生态系统的最佳体现!今天,Linux 操作系统被广泛认为是最成功的 GNU 项目。确实讽刺!
提供机制,而不是政策
让我们用一个简单的例子来理解这个原则。
在设计应用程序时,您需要用户输入登录name和password。执行获取和检查密码工作的函数称为,比如说,mygetpass()。它由mylogin()函数调用:mylogin() → mygetpass()。
现在,要遵循的协议是:如果用户连续三次输入错误密码,程序不应允许访问(并应记录该情况)。好吧,但我们在哪里检查这个?
Unix 哲学:如果密码在mygetpass()函数中被错误指定三次,不要实现逻辑,而是让mygetpass()返回一个布尔值(密码正确时为 true,密码错误时为 false),并让调用mylogin()函数实现所需的逻辑。
伪代码
以下是错误的方法:
mygetpass()
{
numtries=1
<get the password>
if (password-is-wrong) {
numtries ++
if (numtries >= 3) {
<write and log failure message>
<abort>
}
}
<password correct, continue>
}
mylogin()
{
mygetpass()
}
现在让我们来看看正确的方法:Unix 的方式!参考以下代码:
mygetpass()
{
<get the password>
if (password-is-wrong)
return false;
return true;
}
mylogin()
{
maxtries = 3
while (maxtries--) {
if (mygetpass() == true)
<move along, call other routines>
}
// If we're here, we've failed to provide the
// correct password
<write and log failure message>
<abort>
}
mygetpass()的工作是从用户那里获取密码并检查它是否正确;它将成功或失败的结果返回给调用者-就是这样。这就是机制。它的工作不是决定如果密码错误该怎么办-这是策略,留给调用者决定。
现在我们已经简要介绍了 Unix 哲学,那么对于你作为 Linux 系统开发者来说,重要的要点是什么呢?
在设计和实现 Linux 操作系统上的应用程序时,从 Unix 哲学中学习并遵循将会带来巨大的回报。你的应用程序将会做到以下几点:
-
成为系统的自然适应部分;这一点非常重要
-
大大减少了复杂性
-
拥有一个干净而优雅的模块化设计
-
更易于维护
Linux 系统架构
为了清楚地理解 Linux 系统架构,首先需要了解一些重要的概念:处理器应用二进制接口(ABI)、CPU 特权级别以及这些如何影响我们编写的代码。因此,在几个代码示例中,我们将在这里深入探讨这些内容,然后再深入了解系统架构的细节。
准备工作
如果有人问“CPU 是用来做什么的?”,答案显而易见:CPU 是机器的核心,它读取、解码和执行机器指令,处理内存和外围设备。它通过各种阶段来实现这一点。
简单来说,在指令获取阶段,它从内存(RAM)或 CPU 缓存中读取机器指令(我们以各种人类可读的方式表示,如十六进制、汇编和高级语言)。然后,在指令解码阶段,它继续解析指令。在此过程中,它利用控制单元、寄存器集、ALU 和内存/外围接口。
ABI
让我们想象一下,我们编写了一个 C 程序,并在机器上运行它。
等一下。C 代码不可能直接被 CPU 解析;它必须被转换成机器语言。因此,我们了解到在现代系统上我们将安装一个工具链 - 这包括编译器、链接器、库对象和各种其他工具。我们编译和链接 C 源代码,将其转换为可在系统上运行的可执行格式。
处理器指令集架构(ISA)- 记录了机器的指令格式、支持的寻址方案和寄存器模型。事实上,CPU原始设备制造商(OEMs)发布了一份描述机器工作原理的文档;这份文档通常被称为 ABI。ABI 不仅描述了 ISA,还描述了机器指令格式、寄存器集细节、调用约定、链接语义和可执行文件格式,比如 ELF。尝试在谷歌上搜索 x86 ABI - 这应该会显示出有趣的结果。
出版商在他们的网站上提供了本书的完整源代码;我们建议读者在以下 URL 上进行快速的 Git 克隆。构建并尝试它:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
让我们试一试。首先,我们编写一个简单的Hello, World类型的 C 程序:
$ cat hello.c /*
* hello.c
*
****************************************************************
* This program is part of the source code released for the book
* "Linux System Programming"
* (c) Kaiwan N Billimoria
* Packt Publishers
*
* From:
* Ch 1 : Linux System Architecture
****************************************************************
* A quick 'Hello, World'-like program to demonstrate using
* objdump to show the corresponding assembly and machine
* language.
*/
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
int a;
printf("Hello, Linux System Programming, World!\n");
a = 5;
exit(0);
} $
我们通过Makefile和make构建应用程序。理想情况下,代码必须在没有警告的情况下编译通过。
$ gcc -Wall -Wextra hello.c -o hello
hello.c: In function ‘main':
hello.c:23:6: warning: variable ‘a' set but not used [-Wunused-but-set-variable]
int a;
^
$
重要!不要忽略生产代码中的编译器警告。努力消除所有警告,即使看似微不足道的警告也是如此;这将对正确性、稳定性和安全性有很大帮助。
在这个简单的示例代码中,我们理解并预期了gcc发出的未使用变量警告,并且只是为了演示目的而忽略它。
您在系统上看到的确切警告和/或错误消息可能与您在此处看到的不同。这是因为我的 Linux 发行版(和版本)、编译器/链接器、库版本,甚至可能是 CPU,可能与您的不同。我在运行 Fedora 27/28 Linux 发行版的 x86_64 框上构建了这个。
同样,我们构建了hello程序的调试版本(暂时忽略警告),并运行它:
$ make hello_dbg
[...]
$ ./hello_dbg
Hello, Linux System Programming, World!
$
我们使用强大的objdump实用程序来查看程序的源代码、汇编语言和机器语言的混合(objdump的--source 选项开关)
-S, --source 将源代码与反汇编混合):
$ objdump --source ./hello_dbg ./hello_dbg: file format elf64-x86-64
Disassembly of section .init:
0000000000400400 <_init>:
400400: 48 83 ec 08 sub $0x8,%rsp
[...]
int main(void)
{
400527: 55 push %rbp
400528: 48 89 e5 mov %rsp,%rbp
40052b: 48 83 ec 10 sub $0x10,%rsp
int a;
printf("Hello, Linux System Programming, World!\n");
40052f: bf e0 05 40 00 mov $0x4005e0,%edi
400534: e8 f7 fe ff ff callq 400430 <puts@plt>
a = 5;
400539: c7 45 fc 05 00 00 00 movl $0x5,-0x4(%rbp)
exit(0);
400540: bf 00 00 00 00 mov $0x0,%edi
400545: e8 f6 fe ff ff callq 400440 <exit@plt>
40054a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)
[...]
$
您在系统上看到的确切汇编和机器代码很可能与您在此处看到的不同;这是因为我的 Linux 发行版(和版本)、编译器/链接器、库版本,甚至可能是 CPU,可能与您的不同。我在运行 Fedora Core 27 的 x86_64 框上构建了这个。
好吧。让我们看一下源代码行a = 5;,objdump显示了相应的机器和汇编语言:
a = 5;
400539: c7 45 fc 05 00 00 00 movl $0x5,-0x4(%rbp)
我们现在可以清楚地看到以下内容:
| C 源代码 | 汇编语言 | 机器指令 |
|---|---|---|
a = 5; |
movl $0x5,-0x4(%rbp) |
c7 45 fc 05 00 00 00 |
因此,当进程运行时,它将在某个时刻获取并执行机器指令,产生期望的结果。确实,这正是可编程计算机的设计目的!
虽然我们已经展示了显示(甚至写了一点)英特尔 CPU 的汇编和机器代码的示例,但是这个讨论背后的概念和原则对其他 CPU 架构,如 ARM、PPC 和 MIPS,也同样适用。涵盖所有这些 CPU 的类似示例超出了本书的范围;然而,我们建议感兴趣的读者研究处理器数据表和 ABI,并尝试一下。
通过内联汇编访问寄存器的内容
现在我们已经编写了一个简单的 C 程序并看到了它的汇编和机器代码,让我们继续进行一些更具挑战性的工作:一个带有内联汇编的 C 程序,以访问 CPU 寄存器的内容。
有关汇编语言编程的详细信息超出了本书的范围;请参阅 GitHub 存储库上的进一步阅读部分。
x86_64 有几个寄存器;让我们就以普通的 RCX 寄存器为例。我们确实使用了一个有趣的技巧:x86 ABI 调用约定规定函数的返回值将是放在累加器中的值,也就是 x86_64 的 RAX。利用这个知识,我们编写一个使用内联汇编将我们想要的寄存器内容放入 RAX 的函数。这确保了这是它将返回给调用者的内容!
汇编微基础包括以下内容:
at&t 语法:
movq <src_reg>, <dest_reg>
寄存器:前缀名称为%
立即值:前缀为$
有关更多信息,请参阅 GitHub 存储库上的进一步阅读部分。
让我们来看一下以下代码:
$ cat getreg_rcx.c
/*
* getreg_rcx.c
*
****************************************************************
* This program is part of the source code released for the book
* "Linux System Programming"
* (c) Kaiwan N Billimoria
* Packt Publishers
*
* From:
* Ch 1 : Linux System Architecture
****************************************************************
* Inline assembly to access the contents of a CPU register.
* NOTE: this program is written to work on x86_64 only.
*/
#include <stdio.h>#include <unistd.h>
#include <stdlib.h>
typedef unsigned long u64;
static u64 get_rcx(void)
{
/* Pro Tip: x86 ABI: query a register's value by moving its value into RAX.
* [RAX] is returned by the function! */
__asm__ __volatile__(
"push %rcx\n\t"
"movq $5, %rcx\n\t"
"movq %rcx, %rax");
/* at&t syntax: movq <src_reg>, <dest_reg> */
__asm__ __volatile__("pop %rcx");
}
int main(void)
{
printf("Hello, inline assembly:\n [RCX] = 0x%lx\n",
get_rcx());
exit(0);}
$ gcc -Wall -Wextra getreg_rcx.c -o getreg_rcx
getreg_rcx.c: In function ‘get_rcx':
getreg_rcx.c:32:1: warning: no return statement in function returning non-void [-Wreturn-type]
}
^
$ ./getreg_rcx Hello, inline assembly:
[RCX] = 0x5
$
在这里;它按预期工作。
通过内联汇编访问控制寄存器的内容
在 x86_64 处理器上有许多引人入胜的寄存器,其中有六个控制寄存器,命名为 CR0 到 CR4 和 CR8。没有必要详细讨论它们;可以说它们对系统控制至关重要。
为了举例说明,让我们暂时考虑一下 CR0 寄存器。英特尔的手册指出:CR0-包含控制处理器操作模式和状态的系统控制标志。
英特尔的手册可以从这里方便地下载为 PDF 文档(包括英特尔® 64 和 IA-32 体系结构软件开发人员手册,第 3 卷(3A、3B 和 3C):系统编程指南):
software.intel.com/en-us/articles/intel-sdm
显然,CR0 是一个重要的寄存器!
我们修改了之前的程序以访问并显示其内容(而不是普通的RCX寄存器)。唯一相关的代码(与之前的程序不同)是查询CR0寄存器值的函数:
static u64 get_cr0(void)
{
/* Pro Tip: x86 ABI: query a register's value by moving it's value into RAX.
* [RAX] is returned by the function! */
__asm__ __volatile__("movq %cr0, %rax");
/* at&t syntax: movq <src_reg>, <dest_reg> */
}
构建并运行它:
$ make getreg_cr0
[...]
$ ./getreg_cr0
Segmentation fault (core dumped)
$
它崩溃了!
嗯,这里发生了什么?继续阅读。
CPU 特权级别
正如本章前面提到的,CPU 的基本工作是从内存中读取机器指令,解释并执行它们。在计算机的早期,这几乎是处理器所做的全部工作。但后来,工程师们更深入地思考了这个问题,意识到其中存在一个关键问题:如果程序员可以向处理器提供任意的机器指令流,而处理器又盲目地、顺从地执行它们,那么就存在损害、黑客攻击机器的可能性!
如何?回想一下前一节中提到的英特尔处理器的 CR0 控制寄存器:包含控制处理器操作模式和状态的系统控制标志。如果有无限(读/写)访问 CR0 寄存器的权限,就可以切换位,从而可以做到以下几点:
-
打开或关闭硬件分页
-
禁用 CPU 缓存
-
更改缓存和对齐属性
-
禁用操作系统标记为只读的内存(技术上是页面)上的 WP(写保护)
哇,黑客确实可以造成严重破坏。至少,只有操作系统应该被允许这种访问。
正是出于安全、健壮性和操作系统及其控制的硬件资源的正确性等原因,所有现代 CPU 都包括特权级别的概念。
现代 CPU 将支持至少两个特权级别或模式,通常称为以下内容:
-
管理员
-
用户
您需要了解的是,即机器指令在 CPU 上以给定的特权级别或模式运行。设计和实现操作系统的人可以利用处理器特权级别。这正是现代操作系统的设计方式。看一下以下表格通用 CPU 特权级别:
| 特权级别或模式名称 | 特权级别 | 目的 | 术语 |
|---|---|---|---|
| 管理员 | 高 | 操作系统代码在这里运行 | 内核空间 |
| 用户 | 低 | 应用程序代码在这里运行 | 用户空间(或用户区) |
表 1:通用 CPU 特权级别
x86 特权级或环
为了更好地理解这个重要概念,让我们以流行的 x86 架构作为一个真实的例子。从 i386 开始,英特尔处理器支持四个特权级别或环:Ring 0、Ring 1、Ring 2 和 Ring 3。在英特尔 CPU 上,这就是这些级别的工作方式:

图 1:CPU 环级别和特权
让我们将图 1以表 2:x86 特权或环级别的形式进行可视化:
| 特权或环级别 | 特权 | 目的 |
|---|---|---|
| 环 0 | 最高 | 操作系统代码在这里运行 |
| 环 1 | <环 0 | <未使用> |
| 环 2 | <环 1 | <未使用> |
| 环 3 | 最低 | 应用程序代码在这里运行(用户空间) |
表 2:x86 特权或环级别
最初,环级别 1 和 2 是为设备驱动程序而设计的,但现代操作系统通常在环 0 中运行驱动程序代码。一些虚拟化程序(例如 VirtualBox)曾经使用环 1 来运行客户机内核代码;在没有硬件虚拟化支持(如 Intel VT-x、AMD SV)时,这是早期的情况。
ARM(32 位)处理器有七种执行模式;其中六种是特权的,只有一种是非特权模式。在 ARM 上,相当于英特尔的 Ring 0 是 Supervisor(SVC)模式,相当于英特尔的 Ring 3 是用户模式。
对于感兴趣的读者,在 GitHub 存储库的进一步阅读部分中有更多链接。
以下图表清楚地显示了所有现代操作系统(Linux、Unix、Windows 和 macOS)在 x86 处理器上利用处理器特权级别:

图 2:用户-内核分离
重要的是,处理器 ISA 为每条机器指令分配了一个特权级别或允许执行的多个特权级别。允许在用户特权级别执行的机器指令自动意味着它也可以在监管特权级别执行。对于寄存器访问,也适用于区分在哪种模式下可以做什么和不能做什么。
用英特尔的术语来说,当前特权级别(CPL)是处理器当前执行代码的特权级别。
例如,在给定的处理器上,如下所示:
-
foo1 机器指令的允许特权级别为监管者(或 x86 的 Ring 0)
-
foo2 机器指令的允许特权级别为用户(或 x86 的 Ring 3)
因此,对于执行这些机器指令的运行应用程序,出现了以下表格:
| 机器指令 | 允许的模式 | CPL(当前特权级别) | 可行? |
|---|---|---|---|
| foo1 | 监管者(0) | 0 | 是 |
| 3 | 否 | ||
| foo2 | 用户(3) | 0 | 是 |
| 3 | 是 |
表 3:特权级别示例
因此,考虑到 foo2 在用户模式下被允许执行,也将被允许以任何 CPL 执行。换句话说,如果 CPL <= 允许的特权级别,则可以执行,否则不行。
当在 Linux 上运行应用程序时,应用程序作为一个进程运行(稍后会详细介绍)。但应用程序代码运行在什么特权级别(或模式或环)下?参考前面的表格:用户模式(x86 上的 Ring 3)。
啊哈!现在我们明白了。前面的代码示例getreg_rcx.c之所以能够工作,是因为它试图访问通用寄存器RCX的内容,在用户模式(Ring 3)下是允许的,当然在其他级别也是允许的!
但getreg_cr0.c的代码失败了;它崩溃了,因为它试图访问CR0控制寄存器的内容,在用户模式(Ring 3)下是不允许的,只有在 Ring 0 特权级别下才允许!只有操作系统或内核代码才能访问控制寄存器。这对其他一些敏感的汇编语言指令也是适用的。这种方法非常有道理。
从技术上讲,它崩溃是因为处理器引发了通用保护故障(GPF)。
Linux 架构
Linux 系统架构是分层的。以一种非常简单的方式来说,但是理想的开始我们理解这些细节的路径,以下图表说明了 Linux 系统架构:

图 3:Linux - 简化的分层架构
层有助于减少复杂性,因为每一层只需要关注它的上一层和下一层。这带来了许多优势:
-
清晰的设计,减少复杂性
-
标准化,互操作性
-
能够在堆栈中轻松地切换层
-
能够根据需要轻松引入新的层
在最后一点上,存在 FTSE。直接引用维基百科的话:
“软件工程的基本定理(FTSE)”是由安德鲁·科尼格创造的术语,用来描述对已故的大卫·J·惠勒所做的评论。
我们可以通过引入额外的间接层来解决任何问题。
现在我们理解了 CPU 模式或特权级别的概念,以及现代操作系统如何利用它们,Linux 系统架构的更好的图表(在前一个图表的基础上扩展)如下所示:

图 4:Linux 系统架构
在上图中,P1、P2、…、Pn 只是用户空间进程(进程 1、进程 2)或者换句话说,正在运行的应用程序。例如,在 Linux 笔记本上,我们可能有 vim 编辑器、一个网页浏览器和终端窗口(gnome-terminal)正在运行。
库
当然,库是代码的存档(集合);正如我们所知,使用库对于代码的模块化、标准化、防止重复发明轮子综合症等方面有很大帮助。Linux 桌面系统可能有数百个库,甚至可能有几千个!
经典的 K&R hello, world C 程序使用printf API 将字符串写入显示器:
printf(“hello, world\n”);
显然,printf的代码不是hello, world源代码的一部分。那它是从哪里来的?它是标准 C 库的一部分;在 Linux 上,由于其 GNU 起源,这个库通常被称为GNU libc(glibc)。
Glibc 是 Linux 盒子上的一个关键和必需的组件。它不仅包含通常的标准 C 库例程(APIs),事实上,它是操作系统的编程接口!如何?通过它的较低层,系统调用。
系统调用
系统调用实际上是可以通过 glibc 存根例程从用户空间调用的内核功能。它们提供了关键功能;它们将用户空间连接到内核空间。如果用户程序想要请求内核的某些东西(从文件中读取,写入网络,更改文件权限),它会通过发出系统调用来实现。因此,系统调用是用户空间进入内核的唯一合法入口。用户空间进程没有其他方法可以调用内核。
有关所有可用 Linux 系统调用的列表,请参阅 man 页面的第二部分(linux.die.net/man/2/)。也可以执行:man 2 syscalls 来查看所有支持的系统调用的 man 页面
另一种思考方式:Linux 内核内部实际上有成千上万的 API(或函数)。其中,只有很小一部分是可见或可用的,也就是暴露给用户空间的;这些暴露的内核 API 就是系统调用!同样,作为一个近似值,现代 Linux glibc 大约有 300 个系统调用。
在运行 4.13.16-302.fc27.x86_64 内核的 x86_64 Fedora 27 盒子上,有接近 53000 个内核 API!
系统调用与所有其他(通常是库)API 非常不同。由于它们最终调用内核(操作系统)代码,它们有能力跨越用户-内核边界;实际上,它们有能力从普通的非特权用户模式切换到完全特权的监督员或内核模式!
如何?不深入了解细节,系统调用基本上是通过调用具有内置能力从用户模式切换到监督员的特殊机器指令来工作的。所有现代 CPU ABI 都将提供至少一条这样的机器指令;在 x86 处理器上,实现系统调用的传统方式是使用特殊的 int 0x80 机器指令。是的,这确实是一个软件中断(或陷阱)。从奔腾 Pro 和 Linux 2.6 开始,使用 sysenter/syscall 机器指令。请参阅 GitHub 存储库上的进一步阅读部分。
从应用程序开发人员的角度来看,关于系统调用的一个关键点是,系统调用似乎是可以被开发人员调用的常规函数(APIs);这种设计是故意的。实际情况:开发人员调用的系统调用 API(如open()、read()、chmod()、dup()和write())只是存根。它们是一种很好的机制,可以访问内核中的实际代码(通过在 x86 上将累加器寄存器填充为系统调用编号,并通过其他通用寄存器传递参数)来执行内核代码路径,并在完成后返回到用户模式。参考以下表格:
| CPU | 用于从用户模式陷入监督员(内核)模式的机器指令 | 用于系统调用编号的分配寄存器 |
|---|---|---|
x86[_64] |
int 0x80 或 syscall |
EAX / RAX |
ARM |
swi / svc |
R0 到 R7 |
Aarch64 |
svc |
X8 |
MIPS |
syscall |
$v0 |
表 4:各种 CPU 架构上的系统调用,以便更好地理解
Linux - 一个单体操作系统
操作系统通常被认为遵循两种主要的架构风格之一:单体或微内核。
Linux 显然是一个单体操作系统。
这是什么意思?
英语单词 monolith 字面上意味着一个大的单立的石块:

图 5:科林斯柱 - 它们是单体的!
在 Linux 操作系统上,应用程序作为独立实体称为进程运行。一个进程可以是单线程(原始 Unix)或多线程。不管怎样,现在,我们将进程视为 Linux 上的执行单元;进程被定义为正在执行的程序的一个实例。
当用户空间进程发出库调用时,库 API 可能会或可能不会发出系统调用。例如,发出atoi(3)API 并不会导致 glibc 发出系统调用,因为它不需要内核支持来实现将字符串转换为整数。<api-name>(n) ; n 是 man 手册部分。
为了帮助澄清这些重要概念,让我们再次看看著名的经典 K&R Hello, World C 程序:
#include <stdio.h>
main()
{
printf(“hello, world\n”);
}
好的,应该可以。确实可以。
但问题是,printf(3)API 究竟如何写入监视器设备?
简短的答案:不是这样的。
事实上,printf(3)只有智能来格式化指定的字符串;就是这样。一旦完成,printf实际上调用了write(2)API - 一个系统调用。写系统调用确实有能力将缓冲区内容写入到一个特殊的设备文件 - 监视器设备,被写入视为标准输出。回到我们关于Unix 哲学的核心的讨论:如果不是一个进程,那就是一个文件!当然,在内核下面它变得非常复杂;长话短说,写的内核代码最终切换到正确的驱动程序代码;设备驱动程序是唯一可以直接与外围硬件交互的组件。它执行实际的写入到监视器,并且返回值一直传播回应用程序。
在下图中,P是hello, world在运行时的进程:

图 6:代码流程:printf 到内核
此外,从图中我们可以看到,glibc 被认为由两部分组成:
-
与架构无关的 glibc:常规的 libc API(如[s|sn|v]printf,memcpy,memcmp,atoi)
-
与架构相关的 glibc:系统调用存根
在这里,arch 指的是 CPU。
另外,省略号(...)代表内核空间中我们没有展示或深入探讨的额外逻辑和处理。
现在hello, world的代码流路径更清晰了,让我们回到单体的东西!
很容易假设它是这样工作的:
-
这个
hello, world应用程序(进程)发出了printf(3)库调用。 -
printf发出了write(2)系统调用。 -
我们从用户模式切换到监管者(内核)模式。
-
内核接管 - 它将
hello, world写入监视器。 -
切换回非特权用户模式。
实际上,情况并非如此。
事实上,在单体设计中,没有内核;换句话说,内核实际上是进程本身的一部分。它的工作方式如下:
-
这个
hello, world应用程序(进程)发出了printf(3)库调用。 -
printf 发出了
write(2)系统调用。 -
发出系统调用的进程现在从用户模式切换到监管者(内核)模式。
-
进程运行底层内核代码,底层设备驱动程序代码,因此,将
hello, world写入监视器! -
然后进程被切换回非特权用户模式。
总之,在单片内核中,当一个进程(或线程)发出系统调用时,它会切换到特权的监督者或内核模式,并运行系统调用的内核代码(处理内核数据)。完成后,它会切换回非特权的用户模式,并继续执行用户空间代码(处理用户数据)。
这一点非常重要要理解:

图 7:进程的特权模式生命周期
前面的图尝试说明 X 轴是时间线,Y 轴代表用户模式(顶部)和监督者(内核)模式(底部):
-
时间 t[0]:一个进程在内核模式下诞生(当然,创建进程的代码在内核中)。一旦完全诞生,它就会切换到用户(非特权)模式,并运行其用户空间代码(同时处理其用户空间数据项)。
-
时间 t[1]:进程直接或间接(可能通过库 API)调用系统调用。现在它陷入内核模式(参考表格CPU 架构上的系统调用显示了根据 CPU 的机器指令)并在特权监督者模式下执行内核代码(处理内核数据项)。
-
时间 t[2]:系统调用完成;进程切换回非特权用户模式并继续执行其用户空间代码。这个过程会一直持续,直到未来的某个时间点。
-
时间 t[n]:进程死亡,要么是通过调用退出 API 故意退出,要么是被信号杀死。现在它切换回监督者模式(因为 exit(3)库 API 调用 _exit(2)系统调用),执行 _exit()的内核代码,并终止。
事实上,大多数现代操作系统都是单片内核的(尤其是类 Unix 的操作系统)。
从技术上讲,Linux 并不被认为是 100%的单片内核。它被认为是大部分单片内核,但也是模块化的,因为 Linux 内核支持模块化(通过一种称为可加载内核模块(LKM)的技术插入和拔出内核代码和数据)。
有趣的是,MS Windows(特别是从 NT 内核开始)遵循了既是单片内核又是微内核的混合架构。
内核内的执行上下文
内核代码总是在两种上下文中的一种中执行:
-
进程
-
中断
在这里很容易混淆。请记住,这个讨论适用于内核代码执行的上下文,而不是用户空间代码。
进程上下文
现在我们明白了可以通过发出系统调用来调用内核服务。当这种情况发生时,调用进程以内核模式运行系统调用的内核代码。这被称为进程上下文 - 内核代码现在在调用系统调用的进程的上下文中运行。
进程上下文代码具有以下属性:
-
总是由进程(或线程)发出系统调用来触发
-
自上而下的方法
-
进程通过同步执行内核代码
中断上下文
乍一看,似乎没有其他方式可以执行内核代码。好吧,想想这种情况:网络接收路径。一个发送到您以太网 MAC 地址的网络数据包到达硬件适配器,硬件检测到它是为它而来的,收集它并缓冲它。现在它必须让操作系统知道;更准确地说,它必须让网络接口卡(NIC)设备驱动程序知道,以便它可以在到达时获取和处理数据包。它通过断言硬件中断来激活 NIC 驱动程序。
回想一下,设备驱动程序驻留在内核空间,因此它们的代码在特权或内核模式下运行。现在(内核特权)驱动程序代码中断服务例程(ISR)执行,获取数据包,并将其发送到操作系统网络协议栈进行处理。
网卡驱动程序的 ISR 代码是内核代码,但它在什么上下文中运行?显然不是在任何特定进程的上下文中。实际上,硬件中断可能中断了某个进程。因此,我们只是称之为中断上下文。
中断上下文代码具有以下属性:
-
始终由硬件中断触发(不是软件中断、故障或异常;那仍然是进程上下文)
-
自下而上的方法
-
通过中断异步执行内核代码
如果在某个时候报告内核错误,指出执行上下文会有所帮助。
从技术上讲,在中断上下文中,我们有进一步的区分,比如硬中断和软中断,底半部分和任务。然而,这个讨论超出了本书的范围。
总结
本章首先解释了 Unix 的设计哲学,包括 Unix 哲学、设计和架构的核心原则或支柱。然后我们描述了 Linux 系统架构,其中涵盖了 CPU-ABI(应用程序二进制接口)、ISA 和工具链的含义(使用objdump来反汇编一个简单的程序,并使用内联汇编访问 CPU 寄存器)。讨论了 CPU 特权级别及其在现代操作系统中的重要性,引出了 Linux 系统架构的层次 - 应用程序、库、系统调用和内核。本章以讨论 Linux 是一个单片操作系统开始,然后探讨了内核执行上下文。
在下一章中,读者将深入探讨虚拟内存的奥秘,并对其有一个扎实的理解 - 它到底意味着什么,为什么它存在于所有现代操作系统中,以及它提供的关键好处。我们将讨论进程虚拟地址空间的相关细节。
第二章:虚拟内存
回到这一章,我们将探讨虚拟内存(VM)的含义和目的,以及为什么它是一个关键概念和必需的概念。我们将涵盖 VM、分页和地址转换的含义和重要性,使用 VM 的好处,进程在执行中的内存布局,以及内核所看到的进程的内部布局。我们还将深入探讨构成进程虚拟地址空间的各个段。在难以调试的情况下,这些知识是不可或缺的。
在本章中,我们将涵盖以下主题:
-
虚拟内存
-
进程虚拟地址空间
技术要求
需要现代台式机或笔记本电脑;Ubuntu 桌面指定以下作为安装和使用发行版的推荐系统要求:
-
2 GHz 双核处理器或更好
-
RAM
-
在物理主机上运行:2 GB 或更多系统内存
-
作为客人运行:主机系统应至少具有 4 GB RAM(越多越好,体验越流畅)
-
25 GB 的空闲硬盘空间
-
安装介质需要 DVD 驱动器或 USB 端口
-
互联网访问肯定是有帮助的
我们建议读者使用以下 Linux 发行版之一(可以安装为 Windows 或 Linux 主机系统上的客户操作系统,如前所述):
-
Ubuntu 18.04 LTS 桌面(Ubuntu 16.04 LTS 桌面也是一个不错的选择,因为它也有长期支持,并且几乎所有功能都应该可以使用)
-
Ubuntu 桌面下载链接:
www.ubuntu.com/download/desktop -
Fedora 27(工作站)
请注意,这些发行版在其默认形式下是开源的,非专有的,并且作为最终用户可以免费使用。
有时整个代码片段并未包含在书中。因此,GitHub URL 可以参考代码:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
另外,关于进一步阅读的部分,请参考前面的 GitHub 链接。
虚拟内存
现代操作系统基于称为 VM 的内存模型。这包括 Linux、Unix、MS Windows 和 macOS。要真正理解现代操作系统在底层是如何工作的,需要对 VM 和内存管理有深入的理解 - 这些并不是我们在本书中深入讨论的主题;然而,对 VM 概念的扎实掌握对于 Linux 系统开发人员至关重要。
没有 VM - 问题
让我们想象一下,如果 VM 以及它携带的所有复杂负担不存在。因此,我们正在使用一个(虚构的)纯平面物理内存平台,比如说,64 MB RAM。这实际上并不那么不寻常 - 大多数旧的操作系统(比如 DOS)甚至现代的实时操作系统(RTOS)都是这样运行的:

图 1:64 MB 的平面物理地址空间
显然,运行在这台机器上的所有东西都必须共享这个物理内存空间:操作系统、设备驱动程序、库和应用程序。我们可以这样想象(当然,这并不是要反映一个实际的系统 - 这只是一个极为简化的例子,帮助你理解事情):一个操作系统,几个设备驱动程序(驱动硬件外围设备),一组库和两个应用程序。这个虚构的(64 MB 系统)平台的物理内存映射(比例不准确)可能是这样的:
| 对象 | 占用空间 | 地址范围 |
|---|---|---|
| 操作系统(OS) | 3 MB | 0x03d0 0000 - 0x0400 0000 |
| 设备驱动程序 | 5 MB | 0x02d0 0000 – 0x0320 0000 |
| 库 | 10 MB | 0x00a0 0000 – 0x0140 0000 |
| 应用程序 2 | 1 MB | 0x0010 0000 – 0x0020 0000 |
| 应用程序 1 | 0.5 MB | 0x0000 0000 – 0x0008 0000 |
| 总体空闲内存 | 44.5 MB | <各种> |
表 1:物理内存映射
同样的虚构系统在下图中表示:

图 2:我们虚构的 64MB 系统的物理内存映射
当然,系统在发布之前会经过严格的测试,并且会按预期运行;除了,我们行业中可能会出现的问题,你可能听说过,叫做 bug。是的,确实。
但是,让我们想象一下,一个危险的 bug 潜入了 Application 1,比如说在使用普遍的memcpy(3) glibc API 时,由于以下原因之一:
-
无意的编程错误
-
故意的恶意意图
作为一个快速提醒,memcpy库 API 的使用如下所示:
void *memcpy(void *dest, const void *src, size_t n).
目标
以下 C 程序片段意图使用通常的memcpy(3) glibc API 复制一些内存,比如说 1,024 字节,从程序中的源位置 300KB 到程序中的目标位置 400KB。由于 Application 1 是物理内存低端的程序(参见前面的内存映射),它从0x0物理偏移开始。
我们知道,在现代操作系统上,没有什么会从地址0x0开始;那是经典的 NULL 内存位置!请记住,这只是一个用于学习目的的虚构示例
首先,让我们看看正确的使用情况。
参考以下伪代码:
phy_offset = 0x0;
src = phy_offset + (300*1024); /* = 0x0004 b000 */
dest = phy_offset + (400*1024); /* = 0x0006 4000 */
n = 1024;
memcpy(dest, src, n);
上述代码的效果如下图所示:

图 3:放大到 App 1:正确的 memcpy()
如前图所示,这是有效的!(大)箭头显示了从源到目的地的复制路径,共 1,024 字节。很好。
现在来看看有 bug 的情况。
一切都一样,只是这一次,由于一个 bug(或者恶意意图),dest指针被修改如下:
phy_offset = 0x0;
src = phy_offset + (300*1024); /* = 0x0004 b000 */
dest = phy_offset + (400*1024*156); /* = 0x03cf 0000 *!*BUG*!* */
n = 1024;
memcpy(dest, src, n);
目标位置现在大约在 64KB(0x03cf0000 - 0x03d00000)进入操作系统!最好的部分是:代码本身并没有失败。 memcpy()完成了它的工作。当然,现在操作系统可能已经损坏,整个系统将(最终)崩溃。
请注意,这里的意图不是为了调试原因(我们知道);这里的意图是要清楚地意识到,尽管有这个 bug,memcpy 仍然成功。
为什么?这是因为我们在用 C 语言编程 - 我们可以自由地读写物理内存,任何意外的错误都是我们的问题,而不是语言的问题!
那现在呢?啊,这就是 VM 系统出现的一个关键原因之一。
虚拟内存
不幸的是,虚拟内存(VM)这个术语经常被工程师大部分人误解或模糊地理解。在本节中,我们试图澄清这个术语及其相关术语(如内存金字塔、寻址和分页)的真正含义;开发人员清楚地理解这一关键领域是很重要的。
首先,什么是进程?
进程是正在执行的程序的一个实例。
程序是一个二进制可执行文件:一个死的、磁盘上的对象。例如,拿cat程序来说:$ ls -l /bin/cat
-rwxr-xr-x 1 root root 36784 Nov 10 23:26 /bin/cat
$
当我们运行cat时,它变成了一个可以运行的实体,我们在 Unix 宇宙中称之为进程。
为了更清楚地理解更深层次的概念,我们从一个小的、简单的、虚构的机器开始。想象一下,它有一个带有 16 个地址线的微处理器。因此,很容易看出,它将可以访问总共潜在的内存空间(或地址空间)为 2¹⁶ = 65,536 字节 = 64KB:

图 4:64KB 的虚拟内存
但是,如果机器上的物理内存(RAM)少得多,比如 32KB 呢?
显然,前图描述的是虚拟内存,而不是物理内存。
同时,物理内存(RAM)如下所示:

图 5:32KB 的物理内存
尽管系统向每个活动的进程做出了承诺:每个进程都将有整个 64KB 的虚拟地址空间可用。听起来很荒谬,对吧?是的,直到人们意识到内存不仅仅是 RAM;事实上,内存被视为一个层次结构 - 通常被称为存储金字塔:

图 6:存储金字塔
就像生活一样,一切都是一种权衡。在金字塔的顶端,我们在速度方面获得了优势,但代价是尺寸;在金字塔的底部,情况正好相反:尺寸以牺牲速度为代价。人们也可以认为 CPU 寄存器位于金字塔的顶端;由于其尺寸几乎微不足道,因此没有显示。
交换是一种文件系统类型 - 系统安装时,原始磁盘分区被格式化为交换。它被操作系统视为第二级 RAM。当操作系统的 RAM 用完时,它使用交换。作为一个粗略的启发式方法,系统管理员有时会将交换分区的大小配置为可用 RAM 的两倍。
为了帮助量化这一点,根据《计算机体系结构,定量方法,第 5 版》(Hennessy & Patterson)提供了相当典型的数字:
| 类型 | CPU 寄存器 | CPU 缓存 | RAM | 交换/存储 |
|---|---|---|---|---|
| L1 | L2 | L3 | ||
| 服务器 | 1000 字节 | 64KB | 256KB | 2-4MB |
| 300ps | 1ns | 3-10ns | 10-20ns | 50-100ns |
| 嵌入式 | 500 字节 | 64KB | 256KB | - |
| 500ps | 2ns | 10-20ns | - | 50-100ns |
表 2:存储器层次结构数字
许多(如果不是大多数)嵌入式 Linux 系统不支持交换分区;原因很简单:嵌入式系统主要使用闪存作为辅助存储介质(而不是像笔记本电脑、台式机和服务器那样使用传统的 SCSI 磁盘)。写入闪存芯片会使其磨损(它有限制的擦写周期);因此,嵌入式系统设计者宁愿牺牲交换,只使用 RAM。(请注意,嵌入式系统仍然可以是基于虚拟内存的,这在 Linux 和 Win-CE 等系统中是常见情况)。
操作系统将尽最大努力将页面的工作集保持在尽可能高的金字塔位置,以优化性能。
读者需要注意,在接下来的章节中,虽然本书试图解释一些高级主题的内部工作,比如虚拟内存和寻址(分页),但我们故意没有描绘完整、真实世界的视图。
原因很简单:深入和血腥的技术细节远远超出了本书的范围。因此,读者应该记住,以下几个领域中的一些是以概念而不是实际情况来解释的。进一步阅读部分提供了对这些问题感兴趣的读者的参考资料。请在 GitHub 存储库上查看。
寻址 1 - 简单的有缺陷的方法
好的,现在来看看存储金字塔;即使我们同意虚拟内存现在是可能的,一个关键且困难的障碍仍然存在。要解释这一点,请注意,每个活动的进程都将占用整个可用的虚拟地址空间(VAS)。因此,每个进程在 VAS 方面与其他每个进程重叠。但这会怎么样?它本身不会。为了使这个复杂的方案工作,系统必须以某种方式将每个进程中的每个虚拟地址映射到物理地址!参考以下虚拟地址到物理地址的映射:
进程 P:虚拟地址(va)→ RAM:物理地址(pa)
因此,现在的情况是这样的:

图 7:包含虚拟地址的进程
进程 P1、P2 和 Pn 在虚拟内存中活跃。它们的虚拟地址空间覆盖 0 到 64KB,并相互重叠。在这个(虚构的)系统上存在 32KB 的物理内存 RAM。
例如,每个进程的两个虚拟地址以以下格式显示:
P'r':va'n';其中r是进程编号,n是 1 和 2。
如前所述,现在的关键是将每个进程的虚拟地址映射到物理地址。因此,我们需要映射以下内容:
P1:va1 → P1:pa1
P1:va2 → P1:pa2
...
P2:va1 → P2:pa1
P2:va2 → P2:pa2
...
[...]
Pn:va1 → Pn:pa1
Pn:va2 → Pn:pa2
...
我们可以让操作系统执行这种映射;然后操作系统将维护每个进程的映射表来执行此操作。从图解和概念上看,它如下所示:

图 8:将虚拟地址直接映射到物理 RAM 地址
那就这样了?实际上似乎相当简单。嗯,不,实际上不会这样:要将每个进程的所有可能的虚拟地址映射到 RAM 中的物理地址,操作系统需要维护每个地址每个进程的 va-to-pa 翻译条目!这太昂贵了,因为每个表可能会超过物理内存的大小,使该方案无用。
快速计算表明,我们有 64KB 的虚拟内存,即 65,536 字节或地址。这些虚拟地址中的每一个都需要映射到一个物理地址。因此,每个进程都需要:
- 65536 * 2 = 131072 = 128 KB,用于每个进程的映射表。
实际情况更糟糕;操作系统需要存储一些元数据以及每个地址转换条目;假设 8 字节的元数据。所以现在,每个进程都需要:
- 65536 * 2 * 8 = 1048576 = 1 MB,用于每个进程的映射表。
哇,每个进程需要 1 兆字节的 RAM!这太多了(想象一下嵌入式系统);而且在我们的虚构系统中,总共只有 32KB 的 RAM。哎呀。
好吧,我们可以通过不映射每个字节而映射每个字来减少这种开销;比如,将 4 个字节映射到一个字。所以现在,每个进程都需要:
- (65536 * 2 * 8)/ 4 = 262144 = 256 KB,用于每个进程的映射表。
更好,但还不够好。如果只有 20 个进程在运行,我们需要 5MB 的物理内存来存储映射元数据。在 32KB 的 RAM 中,我们做不到这一点。
地址 2-简要分页
为了解决这个棘手的问题,计算机科学家提出了一个解决方案:不要试图将单个虚拟字节(甚至单词)映射到它们的物理对应物;这太昂贵了。相反,将物理和虚拟内存空间分割成块并进行映射。
有两种广义的方法来做到这一点:
-
硬件分段
-
硬件分页
硬件分段:将虚拟和物理地址空间分割成称为段的任意大小块。最好的例子是英特尔 32 位处理器。
硬件分页:将虚拟和物理地址空间分割成称为页面的等大小块。大多数现实世界的处理器都支持硬件分页,包括英特尔、ARM、PPC 和 MIPS。
实际上,甚至不是由操作系统开发人员选择使用哪种方案:选择由硬件 MMU 决定。
再次提醒读者:这本书的复杂细节超出了范围。请参阅 GitHub 存储库上的“进一步阅读”部分。
假设我们采用分页技术。关键要点是,我们停止尝试将每个进程的所有可能的虚拟地址映射到 RAM 中的物理地址,而是将虚拟页(称为页)映射到物理页(称为页框)。
常见术语
虚拟地址空间:VAS
进程 VAS 中的虚拟页:页
RAM 中的物理页:页框(pf)
不起作用:虚拟地址(va)→物理地址(pa)
起作用:(虚拟)页→页框
左到右的箭头表示映射。
作为一个经验法则(和通常被接受的规范),页面的大小为 4 千字节(4,096 字节)。再次强调,是处理器的内存管理单元(MMU)决定页面的大小。
那么这种方案如何以及为什么有帮助呢?
想一想;在我们的虚构机器中,我们有:64 KB 的虚拟内存,即 64K/4K = 16 页,和 32 KB 的 RAM,即 32K/4K = 8 页帧。
将 16 页映射到相应的页面帧需要每个进程只有 16 个条目的表;这是可行的!
就像我们之前的计算一样:
16 * 2 * 8 = 256 字节,每个进程的映射表。
非常重要的一点,值得重复:我们将(虚拟)页映射到(物理)页面帧!
这是由操作系统基于每个进程进行的。因此,每个进程都有自己的映射表,用于在运行时将页面转换为页面帧;通常称为分页表(PT):

图 9:将(虚拟)页映射到(物理)页面帧
分页表--简化
同样,在我们的虚构机器中,我们有:64 KB 的虚拟内存,即 64K/4K = 16 页,和 32 KB 的 RAM,即 32K/4K = 8 页帧。
将 16 个(虚拟)页映射到相应的(物理)页面帧只需要每个进程一个只有 16 个条目的表,这使得整个交易可行。
非常简单地说,单个进程的操作系统创建的页表如下所示:
| (虚拟)页 | (物理)页面帧 |
|---|---|
0 |
3 |
1 |
2 |
2 |
5 |
[...] |
[...] |
15 |
6 |
表 3:操作系统创建的页表
当然,敏锐的读者会注意到我们有一个问题:我们有 16 页,只有 8 页帧可以映射到它们中--剩下的八页怎么办?
好吧,考虑一下:
-
实际上,每个进程都不会使用每个可用的页面来存储代码或数据或其他内容;虚拟地址空间的几个区域将保持空白(稀疏),
-
即使我们需要它,我们也有办法:不要忘记内存金字塔。当我们用完 RAM 时,我们使用交换。因此,进程的(概念性)页表可能如下所示(例如,页面 13 和 14 驻留在交换中):
| (虚拟)页 | (物理)页面帧 |
|---|---|
0 |
3 |
1 |
2 |
2 |
5 |
[...] |
[...] |
13 |
<交换地址> |
14 |
<交换地址> |
15 |
6 |
表 4:概念性页表
请注意,这些页表的描述纯粹是概念性的;实际的页表更复杂,且高度依赖于体系结构(CPU/MMU)。
间接
通过引入分页,我们实际上引入了一级间接:我们不再将(虚拟)地址视为从零的绝对偏移,而是作为相对数量:va = (page, offset)。
我们将每个虚拟地址视为与页号和从该页开头的偏移相关联。这被称为使用一级间接。
因此,每当进程引用虚拟地址时(当然,几乎一直在发生),系统必须根据该进程的页表将虚拟地址转换为相应的物理地址。
地址转换
因此,在运行时,进程查找一个虚拟地址,比如说,距离 0 有 9,192 字节,也就是说,它的虚拟地址:va = 9192 = 0x000023E8。如果每页大小为 4,096 字节,这意味着 va 地址在第三页(第 2 页),从该页的开头偏移 1,000 字节。
因此,通过一级间接,我们有:va = (page, offset) = (2, 1000)。
啊哈!现在我们可以看到地址转换是如何工作的:操作系统看到进程想要一个地址在第 2 页。它在该进程的页表上查找,并发现第 2 页映射到第 5 页帧。计算如下所示的物理地址:
pa = (pf * PAGE_SIZE) + offset
= (5 * 4096) + 1000
= 21480 = 0x000053E8
哇!
系统现在将物理地址放在总线上,CPU 像往常一样执行其工作。看起来很简单,但再次强调,这并不现实,请参见下面的信息框。
分页模式带来的另一个优势是,操作系统只需要存储页到页框的映射。这自动让我们能够通过添加偏移量将页面中的任何字节转换为页面框中对应的物理字节,因为页面和页面框之间存在一对一的映射(两者大小相同)。
实际上,执行地址转换的并不是操作系统。这是因为在软件中执行这个操作会太慢(记住,查找虚拟地址是一个几乎一直在进行的活动)。事实是,地址查找和转换是由硅片——CPU 内的硬件内存管理单元(MMU)来完成的!
记住以下几点:
• 操作系统负责为每个进程创建和维护页表。
• MMU 负责执行运行时地址转换(使用操作系统的页表)。
• 此外,现代硬件支持硬件加速器,如 TLB、CPU 缓存的使用和虚拟化扩展,这在很大程度上有助于获得良好的性能。
使用虚拟内存的好处
乍一看,由于虚拟内存和相关的地址转换引入的巨大开销似乎不值得使用它。是的,开销很大,但事实如下:
-
现代硬件加速(通过 TLB/CPU 缓存/预取)减轻了这种开销,并提供了足够的性能。
-
从虚拟内存中获得的好处超过了性能问题。
在基于虚拟内存的系统上,我们获得以下好处:
-
进程隔离
-
程序员不需要担心物理内存
-
内存区域保护
更好地理解这些是很重要的。
进程隔离
有了虚拟内存,每个进程都在一个沙盒中运行,这是它的虚拟地址空间的范围。关键规则:它不能窥视到盒子外面。
因此,想想看,一个进程不可能窥视或篡改任何其他进程的虚拟地址空间的内存。这有助于使系统安全稳定。
例如:我们有两个进程 A 和 B。进程 A 想要写入进程 B 中的虚拟地址0x10ea。它不能,即使它试图写入该地址,它实际上只是写入自己的虚拟地址0x10ea!读取也是一样的。
因此我们得到了进程隔离——每个进程完全与其他进程隔离。
对于进程 A 的虚拟地址 X 来说,它与进程 B 的虚拟地址 X 并不相同;很可能它们会通过它们的页表转换为不同的物理地址。
利用这一特性,Android 系统被设计得非常有意识地使用进程模型来进行 Android 应用程序:当一个 Android 应用程序启动时,它成为一个 Linux 进程,它存在于自己的虚拟地址空间中,被隔离并因此受到保护,不受其他 Android 应用程序(进程)的影响!
-
再次强调,不要误以为给定进程中的每个(虚拟)页面都对该进程本身有效。只有在映射了页面的情况下,页面才是有效的,也就是说,它已经被分配并且操作系统对它有有效的转换(或者有办法获取它)。事实上,特别是对于庞大的 64 位虚拟地址空间,进程的虚拟地址空间被认为是稀疏的。
-
如果进程隔离是如此描述的,那么如果进程 A 需要与进程 B 通信会怎样呢?实际上,这是许多真实的 Linux 应用程序的频繁设计要求——我们需要一些机制来能够读取/写入另一个进程的虚拟地址空间。现代操作系统提供了实现这一点的机制:进程间通信(IPC)机制。(有关 IPC 的简要介绍可以在第十五章中找到,使用 Pthreads 进行多线程编程第二部分-同步。)
程序员不需要担心物理内存
在旧的操作系统甚至现代的实时操作系统中,程序员需要详细了解整个系统的内存布局,并相应地使用内存(回想一下图 1)。显然,这给开发人员带来了很大的负担;他们必须确保他们在系统的物理限制内工作良好。
大多数在现代操作系统上工作的现代开发人员甚至从不这样思考:如果我们想要,比如说,512 Kb 的内存,我们不是只需动态分配它(使用malloc(3),稍后在第四章中详细介绍,动态内存分配),将如何和在哪里完成的精确细节留给库和操作系统层?事实上,我们可以做这种事情数十次而不必担心诸如“是否有足够的物理 RAM?应该使用哪些物理页框?碎片化/浪费怎么办?”之类的问题。
我们得到的额外好处是系统返回给我们的内存是保证连续的;当然,它只是虚拟连续的,不一定是物理上连续的,但这种细节正是虚拟内存层要处理的!
所有这些都由库层和操作系统中的底层内存管理系统高效处理。
内存区域保护
也许 VM 最重要的好处就是:能够在虚拟内存上定义保护,并且这些保护会被操作系统遵守。
Unix 和其它类似系统(包括 Linux),允许在内存页面上有四个保护或权限值:
| 保护或权限类型 | 意义 |
|---|---|
| 无 | 无权限在页面上执行任何操作 |
| 读取 | 页面可以读取 |
| 写入 | 页面可以写入 |
| 执行 | 页面(代码)可以执行 |
表 5:内存页面上的保护或权限值
让我们考虑一个小例子:我们在我们的进程中分配了四个页面的内存(编号为 0 到 3)。默认情况下,页面的默认权限或保护是RW(读-写),这意味着页面既可以读取又可以写入。
有了虚拟内存操作系统级别的支持,操作系统提供了 API(mmap(2)和mprotect(2)系统调用),可以更改默认的页面保护!请看下表:
| 内存页# | 默认保护 | 更改后的保护 |
|---|---|---|
| 0 | RW- | -无- |
| 1 | RW- | 只读(R--) |
| 2 | RW- | 仅写入(-W-) |
| 3 | RW- | 读取-执行(R-X) |
有了这样强大的 API,我们可以将内存保护设置到单个页面的粒度!
应用程序(甚至操作系统)可以利用这些强大的机制;事实上,这正是操作系统在进程地址空间的特定区域所做的(正如我们将在下一节学到的那样,侧边栏::测试 memcpy() 'C'程序)。
好的,很好,我们可以在某些页面上设置某些保护,但是如果应用程序违反了它们怎么办?例如,在设置页面#3(如前表所示)为读取-执行后,如果应用程序(或操作系统)尝试写入该页面会怎样?
这就是虚拟内存(和内存管理)的真正威力所在:事实上,在启用了虚拟内存的系统上,操作系统(更确切地说是 MMU)能够陷入每个内存访问,并确定最终用户进程是否遵守规则。如果是,访问将成功进行;如果不是,MMU 硬件会引发异常(类似但不完全相同于中断)。操作系统现在会跳转到一个称为异常(或故障)处理程序的代码例程。操作系统的异常处理例程确定访问是否确实非法,如果是,操作系统立即终止尝试进行此非法访问的进程。
这就是内存保护吗?事实上,这几乎就是段错误或段错误的定义;在第十二章中会更详细地介绍这一点,信号-第二部分。异常处理例程称为操作系统的故障处理程序。
侧边栏:测试memcpy() C 程序
现在我们更好地理解了虚拟内存系统的什么和为什么,让我们回到本章开头考虑的有 bug 的伪代码示例:我们使用memcpy(3)来复制一些内存,但指定了错误的目标地址(在我们虚构的仅物理内存的系统中,它会覆盖操作系统本身)。
一个在 Linux 上运行的概念上类似的 C 程序,一个完整的虚拟内存启用的操作系统,被展示并在这里尝试。让我们看看这个有 bug 的程序在 Linux 上是如何工作的:
$ cat mem_app1buggy.c /*
* mem_app1buggy.c
*
***************************************************************
* This program is part of the source code released for the book
* "Linux System Programming"
* (c) Kaiwan N Billimoria
* Packt Publishers
*
* From:
* Ch 2 : Virtual Memory
****************************************************************
* A simple demo to show that on Linux - full-fledged Virtual
* Memory enabled OS - even a buggy app will _NOT_ cause system
* failure; rather, the buggy process will be killed by the
* kernel!
* On the other hand, if we had run this or a similar program in a flat purely
* physical address space based OS, this seemingly trivial bug
* can wreak havoc, bringing the entire system down.
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include "../common.h"
int main(int argc, char **argv)
{
void *ptr = NULL;
void *dest, *src = "abcdef0123456789";
void *arbit_addr = (void *)0xffffffffff601000;
int n = strlen(src);
ptr = malloc(256 * 1024);
if (!ptr)
FATAL("malloc(256*1024) failed\n");
if (argc == 1)
dest = ptr; /* correct */
else
dest = arbit_addr; /* bug! */
memcpy(dest, src, n);
free(ptr);
exit(0);
}
malloc(3) API 将在下一章中详细介绍;现在,只需了解它用于为进程动态分配 256 KB 的内存。当然,memcpy(3)也用于将内存从源指针复制到目标指针,共 n 个字节:
void *memcpy(void *dest, const void *src, size_t n);
有趣的是我们有一个名为arbit_addr的变量;它被设置为一个任意的无效(虚拟)地址。从代码中可以看出,当用户向程序传递任何参数时,我们将目标指针设置为arbit_addr,从而使其成为有 bug 的测试用例。让我们尝试运行这个程序,看看正确和错误的情况。
这是正确的情况:
$ ./mem_app1buggy
$
它运行良好,没有错误。
这是错误的情况:
$ ./mem_app1buggy buggy-case pass-params forcing-argc-to-not-be-1
Segmentation fault (core dumped)
$
它崩溃了!正如前面所述,有 bug 的 memcpy 导致 MMU 出现故障;操作系统的故障处理代码意识到这确实是一个 bug,于是杀死了有问题的进程!进程死于自己的过错,而不是系统的过错。这不仅是正确的,而且段错误也提醒开发人员他们的代码有 bug,必须修复。
- 到底什么是核心转储?
核心转储是进程在崩溃时某些动态区域(段)的快照(从技术上讲,至少是数据和堆栈段的快照)。核心转储可以在崩溃后使用诸如 GDB 之类的调试器进行分析。我们在本书中不涵盖这些领域。
- 嘿,它说(核心已转储),但我没有看到任何核心文件?
嗯,为什么没有核心文件呢?这可能有几个原因,细节超出了本书的范围。请参考core(5)的 man 页面获取详细信息:linux.die.net/man/5/core。
稍微详细地考虑一下这里发生了什么:目标指针的值是0xffffffffff601000;在 x86_64 处理器上,这实际上是一个内核虚拟地址。现在我们,一个用户模式进程,试图向这个目标区域写入一些内存,这个区域受到了来自用户空间的访问保护。从技术上讲,它位于内核虚拟地址空间,这对用户模式进程不可用(回想一下我们在第一章中对CPU 特权级别的讨论,Linux 系统架构)。所以当我们——一个用户模式进程——试图写入内核虚拟地址空间时,保护机制会启动并阻止我们这样做,从而导致我们死亡。
高级:系统如何知道这个区域受到保护以及它有什么样的保护?这些细节被编码到进程的分页表项(PTEs)中,并且在每次访问时由 MMU 进行检查!
这种高级内存保护在硬件和软件中都没有支持是不可能的:
-
通过所有现代微处理器中的 MMU 提供的硬件支持
-
通过操作系统的软件支持
虚拟内存提供了许多其他好处,包括(但不限于)使强大的技术成为可能,例如需求分页、写时复制(COW)处理、碎片整理、内存过度承诺、内存压缩、内核同页合并(KSM)和超然内存(TM)。在本书的范围内,我们将在以后的章节中涵盖其中的一些。
进程内存布局
进程是正在执行的程序的实例。它被操作系统视为一个活动的、可调度的实体。换句话说,当我们启动一个程序时,运行的是进程。
操作系统或内核在内核内存中的数据结构中存储有关进程的元数据;在 Linux 上,这个结构通常被称为进程描述符,尽管术语任务结构更准确。进程属性存储在任务结构中;进程PID(进程标识符)- 用于标识进程的唯一整数,进程凭证,打开文件信息,信号信息等等,都驻留在这里。
从之前的讨论中,虚拟内存,我们了解到进程除了许多其他属性外,还有一个 VAS*。VAS 是它可能可用的总空间。就像我们之前的例子,使用 16 个地址线的虚构计算机,每个进程的 VAS 将是 2¹⁶ = 64 KB。
现在,让我们考虑一个更现实的系统:一个具有 32 位寻址的 32 位 CPU。显然,每个进程的 VAS 为 2³²,相当大的 4 GB。
16 进制格式的 4 GB 是0x100000000;所以 VAS 从低地址0x0到高地址4GB - 1 = 0xffff ffff。
然而,我们还需要了解更多细节(参见高级:VM 分割)关于 VAS 高端的确切使用。因此,至少目前,让我们将其称为高地址,而不给它一个特定的数值。
这是它的图示表示:

图 10:进程虚拟地址空间(VAS)
因此,现在要理解的是,在 32 位 Linux 上,每个活动的进程都有这个映像:0x0到 0xffff ffff = 4 GB 的虚拟地址空间.
段或映射
当创建一个新进程(详细信息见第十章,进程创建)时,其 VAS 必须由操作系统设置。所有现代操作系统都将进程 VAS 划分为称为段的同质区域(不要将这些段与硬件分段方法混淆,该方法在地址 2 - 简要分页部分中提到)。
段是进程 VAS 的同质或统一区域;它由虚拟页面组成。段具有属性,如起始和结束地址,保护(RWX/none)和映射类型。现在的关键点是:属于段的所有页面共享相同的属性。
从技术上讲,从操作系统的角度来看,段被称为映射。
从现在开始,当我们使用“segment”这个词时,我们也指的是 mapping,反之亦然。
简而言之,从低到高端,每个 Linux 进程都会有以下段(或映射):
-
文本(代码)
-
数据
-
库(或其他)
-
堆栈

图 11:带有段的进程 VAS 的整体视图
继续阅读有关这些段的更多详细信息。
文本段
文本就是代码:构成馈送给 CPU 消耗的机器指令的实际操作码和操作数。读者可能还记得我们在第一章,Linux 系统架构中所做的objdump --source ./hello_dbg,显示 C 代码转换为汇编和机器语言。这个机器代码驻留在进程 VAS 中,称为文本段。例如,假设一个程序有 32 KB 的文本;当我们运行它时,它变成一个进程,文本段占用 32 KB 的虚拟内存;这是 32K/4K = 8(虚拟)页。
为了优化和保护,操作系统标记,即保护,所有这八页文本都标记为读-执行(r-x)。这是有道理的:代码将从内存中读取并由 CPU 执行,而不是写入它。
在 Linux 上,文本段总是朝着进程 VAS 的低端。请注意,它永远不会从0x0地址开始。
作为一个典型的例子,在 IA-32 上,文本段通常从0x0804 8000开始。尽管这在架构上是非常特定的,而且在 Linux 安全机制如地址空间布局随机化(ASLR)存在时会发生变化。
数据段
文本段的上方是数据段,这是进程保存程序的全局和静态变量(数据)的地方。
实际上,这不是一个映射(段);数据段由三个不同的映射组成。从低地址开始,它包括:初始化数据段,未初始化数据段和堆段。
我们知道,在 C 程序中,未初始化的全局和静态变量会自动初始化为零。那么初始化的全局变量呢?初始化数据段是一个地址空间的区域,用于存储显式初始化的全局和静态变量。
未初始化的数据段是地址空间的一个区域,当然,未初始化的全局和静态变量驻留在这里。关键是:这些变量会被隐式初始化为零(实际上是 memset 为零)。此外,旧的文献经常将这个区域称为 BSS。BSS 是一个旧的汇编指令-由符号开始的块-可以忽略;今天,BSS 区域或段仅仅是进程 VAS 的未初始化数据段。
堆应该是大多数 C 程序员熟悉的一个术语;它指的是为动态内存分配(和随后的释放)保留的内存区域。把堆想象成一个在启动时为进程提供的免费内存页面的礼物。
一个关键点:文本、初始化数据和未初始化数据段的大小是固定的;堆是一个动态段-它可以在运行时增长或缩小。重要的是要注意,堆段向更高的虚拟地址增长。有关堆及其用法的更多细节可以在下一章中找到。
库段
在链接程序时,我们有两个广泛的选择:
-
静态链接
-
动态链接
静态链接意味着任何和所有库文本(代码)和数据都保存在程序的二进制可执行文件中(因此它更大,加载速度更快)。
动态链接意味着任何和所有共享库文本(代码)和数据都不保存在程序的二进制可执行文件中;相反,它被所有进程共享,并在运行时映射到进程 VAS 中(因此二进制可执行文件要小得多,尽管加载速度可能会慢一些)。动态链接始终是默认的。
想想Hello, world C 程序。你调用了printf(3),但你有写它的代码吗?当然没有;我们知道它在 glibc 中,并且会在运行时链接到我们的进程中。这正是动态链接的发生方式:在进程加载时,程序依赖(使用)的所有库文本和数据段都会内存映射到进程 VAS 中。在哪里?在堆的顶部和栈的底部之间的区域:库段(参见前面的图表)。
另一件事:除了库文本和数据之外,其他映射可能会进入这个地址空间的区域。一个典型的情况是开发人员进行的显式内存映射(使用mmap(2)系统调用),隐式映射,比如 IPC 机制所做的映射,比如共享内存映射,以及 malloc 例程(参见第四章,动态内存分配)。
栈段
这一节解释了进程栈:什么,为什么,以及如何。
栈内存是什么?
你可能记得被教过,栈内存只是内存,但具有特殊的推/弹出语义;你最后推入的内存位于栈的顶部,如果执行弹出操作,那个内存就会从栈中弹出-从中移除。
将晚餐盘子堆叠的教学示例是一个很好的例子:你最后放置的盘子在顶部,你从顶部取下盘子给你的晚餐客人(当然,你可以坚持说你从堆栈的中间或底部给他们盘子,但我们认为顶部的盘子最容易取下)。
一些文献还将这种推送/弹出行为称为后进先出(LIFO)。好吧。
进程 VAS 的高端用于堆栈段(参见图 11)。好吧,但它到底是用来做什么的?它如何帮助?
为什么需要进程堆栈?
我们被教导要编写良好的模块化代码:将工作分解为子例程,并将其实现为小型、易读、易维护的 C 函数。这很好。
然而,CPU 实际上并不了解如何调用 C 函数,如何传递参数,存储局部变量,并将结果返回给调用函数。我们的救世主,编译器接管,将 C 代码转换为能够使整个函数工作的汇编语言。
编译器生成汇编代码来调用函数,传递参数,为局部变量分配空间,最后将返回结果发回给调用者。为了做到这一点,它使用堆栈!因此,类似于堆,堆栈也是一个动态段。
每次调用函数时,在堆栈区域(或段或映射)中分配内存来保存具有函数调用、参数传递和函数返回机制的元数据。每个函数的这个元数据区域称为堆栈帧。
堆栈帧保存了实现函数调用-参数使用-返回值机制所需的元数据。堆栈帧的确切布局高度依赖于 CPU(和编译器);这是 CPU ABI 文档涵盖的关键领域之一。
在 IA-32 处理器上,堆栈帧布局基本上如下:
[ <-- 高地址
[ 函数参数 ... ]
[ 返回地址 ]
[ 保存的帧指针 ] (可选)
[ 局部变量 ... ]
] <-- SP: 最低地址
考虑一些伪代码:
bar() { jail();}
foo() { bar();}
main() { foo();}
调用图相当明显:
main --> foo --> bar --> jail
箭头绘制为 --> 表示调用;所以,main 调用 foo,依此类推。
要理解的是:每次函数调用在进程的堆栈中都由一个堆栈帧表示。
如果处理器发出了推送或弹出指令,它将继续执行。但是,想想看,CPU 如何知道确切地在哪里 - 在哪个堆栈内存位置或地址 - 它应该推送或弹出内存?答案是:我们保留一个特殊的 CPU 寄存器,堆栈指针(通常缩写为SP),用于确切地这个目的:SP 中的值始终指向堆栈顶部。
下一个关键点:堆栈段向较低的虚拟地址增长。这通常被称为堆栈向下增长的语义。还要注意,堆栈增长的方向是由该 CPU 的 ABI 规定的 CPU 特定特性;大多数现代 CPU(包括英特尔、ARM、PPC、Alpha 和 Sun SPARC)都遵循堆栈向下增长的语义。
SP 始终指向堆栈顶部;由于我们使用的是向下增长的堆栈,这是堆栈上的最低虚拟地址!
为了清楚起见,让我们看一张图,它展示了在调用main()之后的进程堆栈(main()由一个__libc_start_main() glibc 例程调用):

图 12:调用main()后的进程堆栈
进入jail()函数时的进程堆栈:

图 13:调用jail()后的进程堆栈
窥视堆栈
我们可以以不同的方式窥视进程堆栈(技术上来说,是main()的堆栈)。这里,我们展示了两种可能性:
-
通过
gstack(1)实用程序自动 -
通过 GDB 调试器手动
首先,通过gstack(1)查看用户模式堆栈:
警告!Ubuntu 用户,你们可能会在这里遇到问题。在撰写时(Ubuntu 18.04),gstack似乎对 Ubuntu 不可用(它的替代方法pstack也不太好用!)。请使用第二种方法(通过 GDB),如下。
作为一个快速的例子,我们查看bash的堆栈(参数是进程的 PID):
$ gstack 14654
#0 0x00007f3539ece7ea in waitpid () from /lib64/libc.so.6
#1 0x000056474b4b41d9 in waitchld.isra ()
#2 0x000056474b4b595d in wait_for ()
#3 0x000056474b4a5033 in execute_command_internal ()
#4 0x000056474b4a52c2 in execute_command ()
#5 0x000056474b48f252 in reader_loop ()
#6 0x000056474b48dd32 in main ()
$
堆栈帧编号出现在左边,前面有#符号;请注意,帧#0是堆栈的顶部(最低的帧)。以自下而上的方式读取堆栈,即从帧#6(main()函数的帧)到帧#0(waitpid()函数的帧)。还要注意,如果进程是多线程的,gstack将显示每个线程的堆栈。
接下来,通过 GDB 查看用户模式堆栈。
GNU Debugger (GDB)是一个著名的、非常强大的调试工具(如果你还没有使用它,我们强烈建议你学习一下;请查看进一步阅读部分中的链接)。在这里,我们将使用 GDB 来附加到一个进程,并且一旦附加,就可以查看它的进程堆栈。
一个小的测试 C 程序,进行了几个嵌套的函数调用,将作为一个很好的例子。基本上,调用图将如下所示:
main() --> foo() --> bar() --> bar_is_now_closed() --> pause()
pause(2)系统调用是一个阻塞调用的很好的例子 - 它让调用进程进入睡眠状态,等待(或阻塞)事件的发生;这里它所阻塞的事件是向进程传递任何信号。(耐心点;我们将在第十一章中学到更多,信号 - 第一部分,和第十二章,信号 - 第二部分)。
这里是相关的代码(ch2/stacker.c):
static void bar_is_now_closed(void)
{
printf("In function %s\n"
"\t(bye, pl go '~/' now).\n", __FUNCTION__);
printf("\n Now blocking on pause()...\n"
" Connect via GDB's 'attach' and then issue the 'bt' command"
" to view the process stack\n");
pause(); /*process blocks here until it receives a signal */
}
static void bar(void)
{
printf("In function %s\n", __FUNCTION__);
bar_is_now_closed();
}
static void foo(void)
{
printf("In function %s\n", __FUNCTION__);
bar();
}
int main(int argc, char **argv)
{
printf("In function %s\n", __FUNCTION__);
foo();
exit (EXIT_SUCCESS);
}
请注意,为了让 GDB 看到符号(函数名称、变量、行号),必须使用-g开关编译代码(生成调试信息)。
现在,我们在后台运行进程:
$ ./stacker_dbg &
[2] 28957
In function main
In function foo
In function bar
In function bar_is_now_closed
(bye, pl go '~/' now).
Now blocking on pause()...
Connect via GDB's 'attach' and then issue the 'bt' command to view the process stack
$
接下来,打开 GDB;在 GDB 中,附加到进程(PID 在前面的代码中显示),并使用backtrace(bt)命令查看其堆栈:
$ gdb --quiet
(gdb) attach 28957 *# parameter to 'attach' is the PID of the process to attach to*
Attaching to process 28957
Reading symbols from <...>/Hands-on-System-Programming-with-Linux/ch2/stacker_dbg...done.
Reading symbols from /lib64/libc.so.6...Reading symbols from /usr/lib/debug/usr/lib64/libc-2.26.so.debug...done.
done.
Reading symbols from /lib64/ld-linux-x86-64.so.2...Reading symbols from /usr/lib/debug/usr/lib64/ld-2.26.so.debug...done.
done.
0x00007fce204143b1 in __libc_pause () at ../sysdeps/unix/sysv/linux/pause.c:30
30 return SYSCALL_CANCEL (pause);
(gdb) bt
#0 0x00007fce204143b1 in __libc_pause () at ../sysdeps/unix/sysv/linux/pause.c:30
#1 0x00000000004007ce in bar_is_now_closed () at stacker.c:31
#2 0x00000000004007ee in bar () at stacker.c:36
#3 0x000000000040080e in foo () at stacker.c:41
#4 0x0000000000400839 in main (argc=1, argv=0x7ffca9ac5ff8) at stacker.c:47
(gdb)
在 Ubuntu 上,由于安全原因,GDB 不允许附加到任何进程;可以通过以 root 身份运行 GDB 来克服这一问题;然后它就可以正常工作了。
通过gstack查看相同的进程如何?(在撰写时,Ubuntu 用户,你们没那么幸运)。这是在 Fedora 27 上的情况:
$ gstack 28957
#0 0x00007fce204143b1 in __libc_pause () at ../sysdeps/unix/sysv/linux/pause.c:30
#1 0x00000000004007ce in bar_is_now_closed () at stacker.c:31
#2 0x00000000004007ee in bar () at stacker.c:36
#3 0x000000000040080e in foo () at stacker.c:41
#4 0x0000000000400839 in main (argc=1, argv=0x7ffca9ac5ff8) at stacker.c:47
$
你猜怎么着?原来gstack实际上是一个包装的 shell 脚本,它以非交互方式调用 GDB,并发出了我们刚刚使用的backtrace命令!
作为一个快速的学习练习,查看一下gstack脚本。
高级 - VM 分割
到目前为止,我们所看到的实际上并不是完整的画面;实际上,这个地址空间需要在用户空间和内核空间之间共享。
这一部分被认为是高级的。我们把决定是否深入了解接下来的细节留给读者。虽然它们非常有用,特别是从调试的角度来看,但严格来说并不是跟随本书其余部分所必需的。
回想一下我们在库段部分提到的内容:如果一个Hello, world应用程序要工作,它需要将printf(3) glibc 例程映射到它。这是通过在运行时将动态或共享库内存映射到进程 VAS 中(由加载程序)来实现的。
对于进程发出的任何系统调用,都可以提出类似的论点:我们从第一章中了解到,系统调用代码实际上位于内核地址空间内。因此,如果发出系统调用成功,我们需要将 CPU 的指令指针(IP或 PC 寄存器)重新定位到系统调用代码的地址,这当然是在内核地址空间内。现在,如果进程 VAS 只包括文本、数据、库和栈段,正如我们迄今所暗示的那样,它将如何工作?回想一下虚拟内存的基本规则:你不能看到盒子外面(可用地址空间)。
因此,为了使整个方案成功,即使内核虚拟地址空间 - 是的,请注意,即使内核地址空间也被认为是虚拟的 - 也必须以某种方式映射到进程 VAS 中。
正如我们之前看到的,在 32 位系统上,进程可用的总 VAS 为 4 GB。到目前为止,隐含的假设是 32 位系统上进程 VAS 的顶部是 4 GB。没错。同样,再次暗示的假设是栈段(由栈帧组成)位于这里 - 在顶部的 4 GB 点。嗯,那是不正确的(请参阅图 11)。
事实是:操作系统创建了进程 VAS,并为其中的段进行了安排;但是,它在顶端保留了一定量的虚拟内存供内核或 OS 映射使用(即内核代码、数据结构、堆栈和驱动程序)。顺便说一句,这个包含内核代码和数据的段通常被称为内核段。
内核段保留了多少 VM?啊,这是一个可调整的或可配置的参数,由内核开发人员(或系统管理员)在内核配置时间设置;它被称为VMSPLIT。这是 VAS 中我们在 OS 内核和用户模式内存之间分割地址空间的点 - 文本、数据、库和栈段!
实际上,为了清晰起见,让我们再次重现图 11(作为图 14),但这次明确显示 VM 分割:

图 14:进程 VM 分割
让我们不要在这里深入细节:可以说在 IA-32(Intel x86 32 位)上,分割点通常是 3 GB 点。因此,我们有一个比例:用户空间 VAS:内核 VAS :: 3 GB:1 GB;在 IA-32 上。
请记住,这是可调的。在其他系统上,例如典型的 ARM-32 平台,分割可能是这样的:用户空间 VAS:内核 VAS :: 2 GB:2 GB;在 ARM-32 上。
在具有庞大的2⁶⁴ VAS(这是一个令人难以置信的 16 艾字节!)的 x86_64 上,它将是:用户空间 VAS:内核 VAS :: 128 TB:128 TB;在 x86_64 上。
现在可以清楚地看到为什么我们使用术语“单片式”来描述 Linux OS 架构 - 每个进程确实就像一块单一的大石头!
每个进程都包含以下两者:
-
用户空间映射
-
文本(代码)
-
数据
-
初始化数据
-
未初始化的数据(BSS)
-
堆
-
库映射
-
其他映射
-
堆栈
-
内核段
每个活动进程都映射到内核 VAS(或内核段,通常被称为)的顶端。
这是一个关键点。让我们看一个现实世界的例子:在运行 Linux OS 的 Intel IA-32 上,VMSPLIT的默认值为 3 GB(即0xc0000000)。因此,在这个处理器上,每个进程的 VM 布局如下:
-
0x0到0xbfffffff:用户空间映射,即文本、数据、库和栈。
-
0xc0000000到0xffffffff:内核空间或内核段。
这在下图中清楚地表示出来:

图 15:IA-32 上的完整进程 VAS
注意每个进程的顶部 1GB 的 VAS 都是相同的 - 内核段。还要记住,这种布局在所有系统上都不相同 - VMSPLIT 和用户和内核段的大小因 CPU 架构而异。
自 Linux 3.3 特别是 3.10(当然是内核版本)以来,Linux 支持prctl(2)系统调用。查阅其手册页面会发现各种有趣的,尽管不可移植(仅限于 Linux)的事情。例如,prctl(2)与PR_SET_MM参数一起使用,让一个进程(具有 root 权限)基本上可以指定其 VAS 布局,其段,以文本、数据、堆和栈的起始和结束虚拟地址为单位。这对于普通应用程序当然是不需要的。
总结
本章深入解释了 VM 概念,为什么 VM 很重要,以及对现代操作系统和在其上运行的应用程序的许多好处。然后我们介绍了 Linux OS 上进程虚拟地址空间的布局,包括一些关于文本、(多个)数据和堆栈段的信息。还介绍了堆栈的真正原因及其布局。
在下一章中,读者将了解每个进程的资源限制:为什么需要它们,它们如何工作,当然还有与它们一起工作所需的程序员接口。
第三章:资源限制
在本章中,我们将研究每个进程的资源限制——它们是什么,以及为什么我们需要它们。我们将继续描述资源限制的粒度和类型,区分软限制和硬限制。将介绍用户(或系统管理员)如何使用适当的 CLI 前端(ulimit,prlimit)查询和设置每个进程的资源限制的详细信息。
编程接口(API)——实际上是关键的prlimit(2)系统调用 API——将被详细介绍。两个详细的代码示例,查询限制和设置 CPU 使用限制,将使读者有机会亲自体验资源限制的工作。
在本章中,关于资源限制,我们将涵盖以下主题:
-
必要性
-
粒度
-
类型——软限制和硬限制
-
资源限制 API,带有示例代码
资源限制
一种常见的黑客攻击是(分布式)拒绝服务((D)DoS)攻击。在这种攻击中,恶意攻击者试图消耗、甚至超载目标系统的资源,以至于系统要么崩溃,要么至少变得完全无响应(挂起)。
有趣的是,在一个未调整的系统上,执行这种类型的攻击是非常容易的;例如,让我们想象一下,我们在服务器上有 shell 访问权限(当然不是 root,而是普通用户)。我们可以通过操纵无处不在的dd(1)(磁盘转储)命令很容易地使其耗尽磁盘空间(或至少变得短缺)。dd的一个用途是创建任意长度的文件。
例如,要创建一个填满随机内容的 1GB 文件,我们可以这样做:
$ dd if=/dev/urandom of=tst count=1024 bs=1M
1024+0 records in
1024+0 records out
1073741824 bytes (1.1 GB, 1.0 GiB) copied, 15.2602 s, 70.4 MB/s
$ ls -lh tst
-rw-rw-r-- 1 kai kai 1.0G Jan 4 12:19 tst
$
如果我们将块大小(bs)的值增加到1G,就像这样:
dd if=/dev/urandom of=tst count=1024 bs=1G
dd现在将尝试创建一个大小为 1,024GB—即一太字节—的文件!如果我们在循环中运行这行代码(在脚本中)会发生什么?你懂的。
为了控制资源使用,Unix(包括 Linux)有一个资源限制,即操作系统对资源施加的人为限制。
首先要明确的一点是:这些资源限制是基于每个进程而不是系统范围的全局限制——关于这一点我们将在下一节详细介绍。
在深入更多细节之前,让我们继续我们的黑客示例,耗尽系统的磁盘空间,但这次在之前设置了文件最大大小的资源限制。
查看和设置资源限制的前端命令是一个内置的 shell 命令(这些命令称为bash 内置):ulimit。要查询由 shell 进程(及其子进程)写入的文件的最大可能大小,我们将-f选项开关设置为ulimit:
$ ulimit -f
unlimited
$
好的,它是无限的。真的吗?不,无限只意味着操作系统没有特定的限制。当然它是有限的,受到盒子上实际可用磁盘空间的限制。
让我们通过传递-f选项开关和实际限制来设置最大文件大小的限制。但是大小的单位是什么?字节、KB、MB?让我们查看它的手册页:顺便说一句,ulimit的手册页是bash(1)的手册页。这是合理的,因为ulimit是一个内置的 shell 命令。在bash(1)手册页中,搜索ulimit;手册告诉我们,单位(默认情况下)是 1,024 字节的增量。因此,2意味着1,0242 = 2,048*字节。或者,要在ulimit上获得一些帮助,只需在 shell 上输入help ulimit。
所以,让我们试一试:将文件大小资源限制减少到只有 2,048 字节,然后用dd进行测试:

图 1:使用 ulimit -f 进行简单的测试案例
从前面的截图中可以看出,我们将文件大小资源限制减少到2,意味着 2,048 字节,然后用dd进行测试。只要我们创建的文件大小在或低于 2,048 字节,它就可以工作;一旦我们尝试超过限制,它就会失败。
顺便说一句,注意dd并没有尝试使用一些聪明的逻辑来测试资源限制,如果它尝试创建超出此限制的文件,它会显示错误。不,它只是失败了。回想一下第一章,Linux 系统架构,Unix 哲学原则:提供机制,而不是策略!
资源限制的粒度
在前面的dd(1)的例子中,我们看到我们确实可以对最大文件大小施加限制。一个重要的问题是:资源限制的范围或粒度是什么?它是系统范围的吗?
简短的回答:不,它不是系统范围的,它是进程范围,这意味着资源限制适用于进程的粒度而不是系统。为了澄清这一点,考虑两个 shell——仅仅是bash进程——shell A 和 shell B。我们修改了 shell A 的最大文件大小资源限制(使用通常的ulimit -f <new-limit>命令),但保持了 shell B 的最大文件大小资源限制不变。如果现在它们都使用dd(就像我们做的那样),我们会发现在 shell A 中调用的dd进程可能会因为超出文件大小限制而死亡,并显示'文件大小限制超出(核心已转储)'的失败消息,而在 shell B 中调用的dd进程可能会继续并成功(当然,前提是有足够的磁盘空间)。
这个简单的实验证明了资源限制的粒度是每个进程*。
当我们深入研究多线程的内部细节时,我们将重新讨论资源限制的粒度以及它们如何应用于单个线程。对于急躁的人来说,除了堆栈大小之外,所有资源限制都是由进程内的所有线程共享的
资源类型
到目前为止,我们只检查了最大文件大小资源限制;难道没有其他的吗?是的,确实还有其他几个。
可用资源限制
以下表格列举了典型 Linux 系统上可用的资源限制(按ulimit 选项开关列按字母顺序排列):
| 资源限制 | ulimit 选项开关 | 默认值 | 单位 |
|---|---|---|---|
| 最大核心文件大小 | -c | 无限制 | KB |
| 最大数据段大小 | -d | 无限制 | KB |
| 最大调度优先级(nice) | -e | 0 | 未缩放 |
| 最大文件大小 | -f | 无限制 | KB |
| 最大(实时)挂起信号 | -i | 未缩放 | |
| 最大锁定内存 | -l | KB | |
| 最大内存大小 | -m | 无限制 | KB |
| 最大打开文件数 | -n | 1024 | 未缩放 |
| 最大管道大小 | -p | 8 | 以 512 字节递增 |
| 最大 POSIX 消息队列 | -q | 未缩放 | |
| 最大实时调度优先级 | -r | 0 | 未缩放 |
| 最大堆栈段大小 | -s | 8192 | KB |
| 最大 CPU 时间 | -t | 无限制 | 秒 |
| 最大用户进程数 | -u | 未缩放 | |
| 地址空间限制或最大虚拟内存 | -v | 无限制 | KB |
| 最大文件锁定数 | -x | 无限制 | 未缩放 |
有几点需要注意:
-
乍一看,一些资源限制的含义是相当明显的;有些可能不是。这里没有解释大部分资源限制,一些将在后续章节中涉及。
-
第二列是传递给
ulimit的选项开关,用于显示该行中特定资源限制的当前值;例如,ulimit -s打印出堆栈大小资源限制的当前值(单位:KB)。 -
第三列是默认值。当然,这可能会因 Linux 平台而异。特别是,企业级服务器可能会调整它们的默认值,使其比嵌入式 Linux 系统的默认值高得多。而且,通常默认值是一个计算(基于,比如,安装在盒子上的 RAM 数量);因此,在某些情况下会有
的条目。另外,正如前面提到的, unlimited并不意味着无限——它意味着没有强加的人工上限。 -
关于第四列,单位,(
bash(1))的 man 页面(来源:linux.die.net/man/1/bash)中如下所述:
[...] If limit is given, it is the new value of the specified resource (the -a option is display only). If no option is given, then -f is assumed. Values are in 1024-byte increments, except for -t, which is in seconds, -p, which is in units of 512-byte blocks, and -T, -b, -n, and -u, which are unscaled values. The return status is 0 unless an invalid option or argument is supplied, or an error occurs while setting a new limit. [...]
此外,unscaled 意味着它只是一个数字。
可以通过-a选项开关显示所有资源限制;我们留给您尝试ulimit -a命令。
请注意,ulimit -a按选项开关的字母顺序排列资源限制,就像我们在表中所做的那样。
此外,非常重要的是要理解这些资源限制是针对单个进程的——调用ulimit命令的 shell 进程(Bash)。
硬限制和软限制
Unixes 有一个进一步的区别:实际上(在底层),给定类型的资源限制不是一个数字,而是两个:
-
硬限制的值
-
软限制的值
硬限制是真正的最大值;作为普通用户,不可能超过这个限制。如果一个进程尝试这样做会发生什么?简单:它会被操作系统杀死。
另一方面,软限制可以被突破:在某些资源限制的情况下,进程(超过软限制的进程)将被内核发送一个信号。把这看作是一个警告:你接近限制了。再次强调,不用担心,我们将在第十一章中深入探讨信号处理,信号-第一部分,以及第十二章,信号-第二部分。例如,如果一个进程超过了文件大小的软限制,操作系统会通过传递SIGXFSZ信号—信号:超出文件大小—来响应它!超过 CPU 的软限制又会怎样呢?你将成为SIGXCPU信号的骄傲接收者。
嗯,还有更多:prlimit(2)的 man 页面显示,在 Linux 上,关于 CPU 限制,会在通过SIGXCPU发送多个警告后发送SIGKILL。正确的行为是:应用程序应该在收到第一个SIGXCPU信号时进行清理和终止。我们将在第十一章中查看信号处理,信号-第一部分!
将硬限制视为软限制的上限值是很有启发性的;实际上,给定资源的软限制范围是[0,硬限制]。
要查看 shell 进程的硬限制和软限制,请分别使用ulimit的-S和-H选项开关。以下是我们可靠的 Fedora 28 桌面系统上ulimit -aS的输出:
$ ulimit -aS
core file size (blocks, -c) unlimited
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 63260
max locked memory (kbytes, -l) 64
max memory size (kbytes, -m) unlimited
open files (-n) 1024
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 8192
cpu time (seconds, -t) unlimited
max user processes (-u) 63260
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited
$
当我们同时运行ulimit和以下命令时:
-
-aS: 显示所有软资源限制值 -
-aH: 显示所有硬资源限制值
一个问题出现了:软限制和硬限制(对于 Bash 进程)究竟在哪里不同?我们可以使用一个超级 GUI 前端meld来进行比较(实际上,它不仅仅是一个diff前端):
$ ps
PID TTY TIME CMD
23843 pts/6 00:00:00 bash
29305 pts/6 00:00:00 ps
$ $ ulimit -aS > ulimit-aS.txt $ ulimit -aH > ulimit-aH.txt $ meld ulimit-aS.txt ulimit-aH.txt &
显示 meld 比较软限制和硬限制资源值的屏幕截图如下:

图 2:屏幕截图显示 meld 比较软限制和硬限制资源值
请注意我们运行ps;这是为了重申我们看到的资源限制值是关于它(PID 23843)的。因此,meld 清楚地显示,在典型的 Linux 系统上,默认情况下只有两个资源限制在软限制和硬限制的值上有所不同:最大打开文件数(soft=1024,hard=4096),和最大堆栈大小(soft=8192 KB = 8 MB,hard=无限)。
meld对开发人员非常有价值;我们经常用它来(同行)审查代码并进行更改(通过右箭头和左箭头进行合并)。事实上,强大的 Git SCM 使用meld作为可用工具之一(使用git mergetool命令)。使用适合您的发行版的适当软件包管理器在 Linux 上安装meld并尝试一下。
查询和更改资源限制值
我们现在明白了,是内核(操作系统)为每个进程设置资源限制并跟踪使用情况,甚至在必要时终止进程——如果它试图超过资源的硬限制。这引发了一个问题:有没有办法改变软限制和硬限制的值?实际上我们已经看到了:ulimit。不仅如此,更深层次的问题是:我们被允许设置任何硬/软限制吗?
内核对更改资源限制有一些预设规则。查询或设置进程的资源限制只能由调用进程自身或拥有的进程进行;更准确地说,对于除自身之外的任何其他进程,进程必须设置CAP_SYS_RESOURCE能力位(不用担心,关于进程能力的详细覆盖可以在第八章中找到,进程能力):
-
查询:任何人都可以查询他们拥有的进程的资源限制硬限制和软(当前)值。
-
设置:
-
一旦设置了硬限制,就不能进一步增加(对于该会话)。
-
软限制只能增加到硬限制值,即软限制范围= [0, 硬限制]。
-
当使用
ulimit设置资源限制时,系统在内部设置软限制和硬限制。这具有重要的后果(参见前面的要点)。
设置资源限制的权限如下:
-
特权进程(如
superuser/root/sysadmin,或者具有前述CAP_SYS_RESOURCE能力的进程)可以增加或减少硬限制和软限制。 -
非特权进程(非 root):
-
可以将资源的软限制设置在[0, 硬限制]的范围内。
-
可以不可逆地减少资源的硬限制(一旦减少,就不能再增加,但只能继续减少)。更准确地说,硬限制可以减少到大于或等于当前软限制的值。
每个好的规则都有例外:非特权用户可以减少和/或增加核心文件资源限制。这通常是为了允许开发人员生成核心转储(随后可以通过 GDB 进行分析)。
为了演示这一点,需要一个快速的测试案例;让我们操纵最大打开文件资源限制:
$ ulimit -n
1024
$ ulimit -aS |grep "open files"
open files (-n) 1024
$ ulimit -aH |grep "open files"
open files (-n) 4096
$
$ ulimit -n 3000
$ ulimit -aS |grep "open files"
open files (-n) 3000
$ ulimit -aH |grep "open files"
open files (-n) 3000
$ ulimit -n 3001
bash: ulimit: open files: cannot modify limit: Operation not permitted
$ ulimit -n 2000
$ ulimit -n
2000
$ ulimit -aS |grep "open files"
open files (-n) 2000
$ ulimit -aH |grep "open files"
open files (-n) 2000
$ ulimit -n 3000
bash: ulimit: open files: cannot modify limit: Operation not permitted
$
上述命令的解释如下:
-
当前软限制为 1,024(默认值)
-
软限制为 1,024,硬限制为 4,096
-
使用
ulimit,我们将限制设置为 3,000;这在内部导致软限制和硬限制都设置为 3,000 -
尝试将值设置得更高(至 3,001)失败
-
减小值(至 2,000)成功
-
不过要意识到,软限制和硬限制都已经设置为 2,000
-
尝试返回到先前有效的值失败(3,000);这是因为现在的有效范围是[0, 2,000]
测试 root 访问权限留给读者练习;不过,可以查看下面的注意事项部分。
注意事项
考虑的事项和适用的例外情况:
-
即使可以,增加资源限制可能会带来更多的危害;要考虑你在这里试图实现什么。将自己置于恶意黑客的心态中(回想(DDoS 攻击)。在服务器类和高度资源受限的系统(通常是嵌入式系统)上,适当设置资源限制可以帮助减轻风险。
-
将资源限制设置为更高的值需要 root 权限。例如:我们希望将最大打开文件资源限制从 1,024 增加到 2,000。人们可能会认为使用
sudo应该可以完成任务。然而,最初令人惊讶的是,诸如sudo ulimit -n 2000这样的命令不起作用!为什么?当你运行它时,sudo期望ulimit是一个二进制可执行文件,因此在PATH中搜索它;但当然,事实并非如此:ulimit是一个内置的 shell 命令,因此无法启动。因此,请尝试以下方式:
$ ulimit -n
1024
$ sudo bash -c "ulimit -n 2000 && exec ulimit -n"
[sudo] password for kai: xxx
2000
$
Chapter 9, *Process Execution.*
- 例外情况——似乎无法更改最大管道大小资源限制。
高级:默认的最大管道大小实际上在/proc/sys/fs/pipe-max-size中,默认为 1MB(从 Linux 2.6.35 开始)。如果程序员必须更改管道大小怎么办?为此,可以使用fcntl(2)系统调用,通过F_GETPIPE_SZ和F_SETPIPE_SZ参数。有关详细信息,请参阅fcntl(2)man 页面。
关于 prlimit 实用程序的快速说明
除了使用ulimit,查询和显示资源限制的另一个前端是prlimit实用程序。prlimit与ulimit不同的地方在于:
-
这是一个更新的、现代的接口(从 Linux 内核版本 2.6.36 开始)
-
它可以用于根据需要修改限制并启动另一个将继承新限制的程序(一个有用的功能;请参见以下示例)
-
它本身是一个二进制可执行程序,不像
ulimit那样是内置的
没有任何参数,prlimit会显示调用进程(自身)的资源限制。可以选择传递资源限制<name=value>对来设置相同的资源限制,要查询/设置资源限制的进程的 PID,或者要使用新设置的资源限制启动的命令。以下是它的 man 页面中的概要:
prlimit [options] [--resource[=limits] [--pid PID]
prlimit [options] [--resource[=limits] command [argument...]
请注意,--pid和command选项是互斥的。
使用 prlimit(1) - 示例
示例 1-查询限制:
$ prlimit
上述命令的输出如下:

$ ps
PID TTY TIME CMD
2917 pts/7 00:00:00 bash
3339 pts/7 00:00:00 ps
$ prlimit --pid=2917
RESOURCE DESCRIPTION SOFT HARD UNITS
AS address space limit unlimited unlimited bytes
CORE max core file size unlimited unlimited bytes
CPU CPU time unlimited unlimited seconds
[...]
$
在这里,我们已经缩短了输出以便更好地阅读。
示例 2-设置(前面的)shell 进程的最大文件大小和最大堆栈大小的资源限制:
$ prlimit --pid=2917 --fsize=2048000 --stack=12582912
$ prlimit --pid=2917 | egrep -i "fsize|stack"
FSIZE max file size 2048000 2048000 bytes
STACK max stack size 12582912 12582912 bytes
$
示例 3-一个名为rlimit_primes的程序,用于生成质数;让它生成大量的质数,但只给它两秒的 CPU 时间来完成。
请注意,rlimit_primes程序及其源代码的详细描述在API 接口部分中。
目前,我们只在内置的prlimit程序范围内运行它,确保rlimit_primes进程只获得我们通过prlimit --cpu=选项开关传递的 CPU 带宽(以秒为单位)。在这个示例中,我们确保以下内容:
-
我们给我们的质数生成器进程两秒(通过
prlimit) -
我们将-2 作为第二个参数传递;这将导致
rlimit_primes程序跳过设置 CPU 资源限制 -
我们要求它生成小于 800 万的质数:
$ ./rlimit_primes
Usage: ./rlimit_primes limit-to-generate-primes-upto CPU-time-limit
arg1 : max is 10000000
arg2 : CPU-time-limit:
-2 = don't set
-1 = unlimited
0 = 1s
$ prlimit --cpu=2 ./rlimit_primes 8000000 -2
2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53,
59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131,
[...]
18353, 18367, 18371, 18379, 18397, 18401, 18413, 18427, 18433, 18439,
18443, 18451, 18457, 18461, 18481, 18493, 18503, 18517, 18521, 18523,
18539, 18541, 18553, 18583, 18587, 18593,
Killed
$
请注意,一旦它超出了其新的受限 CPU 时间资源(在前面的示例中为两秒),它就会被内核杀死!(技术上,是通过SIGKILL信号;关于信号的更多内容请参见第十一章,信号-第 I 部分和第十二章,信号-第 II 部分)。请注意Killed这个词的出现,表明操作系统已经杀死了该进程。
有关更多详细信息,请参阅prlimit(1)的 man 页面。
一个实际案例:当运行像 Eclipse 和 Dropbox 这样的相当重的软件时,我发现有必要提高它们的资源限制(如建议的);否则,它们会因资源耗尽而中止。
高级:从 Linux 内核版本 2.6.24 开始,可以通过强大的proc文件系统查找给定进程 PID 的资源限制:/proc/<PID>/limits。
API 接口
可以通过以下 API 来查询和/或以编程方式设置资源限制-系统调用:
-
getrlimit -
setrlimit -
prlimit
其中,我们只关注prlimit(2);[get|set]rlimit(2)是一个较旧的接口,存在一些问题(错误),通常被认为已过时。
要使prlimit(2)正常工作,必须在 Linux 内核版本 2.6.36 或更高版本上运行。
如何确定正在运行的 Linux 内核版本?
简单:使用uname实用程序查询内核版本:
$ uname -r
4.14.11-300.fc27.x86_64
$
让我们回到prlimit(2)系统调用 API:
#include <sys/time.h>
#include <sys/resource.h>
int prlimit(pid_t pid, int resource,
const struct rlimit *new_limit, struct rlimit *old_limit);
prlimit()系统调用可用于查询和设置给定进程的给定资源限制,每次调用只能设置一个资源限制。它接收四个参数;第一个参数pid是要操作的进程的 PID。特殊值0表示它作用于调用进程本身。第二个参数,资源,是我们希望查询或设置的资源限制的名称(请参阅以下表格以获取完整列表)。第三和第四个参数都是指向struct rlimit的指针;如果第三个参数非 NULL,则是我们要设置的新值(这就是为什么它被标记为const);如果第四个参数非 NULL,则是我们将接收到的先前(或旧的)限制的结构。
有经验的 C 程序员会意识到创建错误有多么容易。程序员有责任确保rlimit结构(第三和第四个参数)的内存(如果使用)必须被分配;操作系统肯定不会为这些结构分配内存。
rlimit结构包含两个成员,软限制和硬限制(rlim_cur和rlim_max):
struct rlimit {
rlim_t rlim_cur; /* Soft limit */
rlim_t rlim_max; /* Hard limit (ceiling for rlim_cur) */
};
回到第二个参数,资源,这是我们希望查询或设置的资源限制的编程名称。以下表列出了所有资源限制:
| 资源限制 | 编程名称(在 API 中使用) | 默认值 | 单位 |
|---|---|---|---|
max core file size |
RLIMIT_CORE |
unlimited |
KB |
max data segment size |
RLIMIT_DATA |
unlimited |
KB |
max scheduling priority (*nice*) |
RLIMIT_NICE |
0 |
未缩放 |
max file size |
RLIMIT_FSIZE |
unlimited |
KB |
max (real-time) pending signals |
RLIMIT_SIGPENDING |
<varies> |
未缩放 |
max locked memory |
RLIMIT_MEMLOCK |
<varies> |
KB |
max open files |
RLIMIT_NOFILE |
1024 |
未缩放 |
max POSIX message queues |
RLIMIT_MSGQUEUE |
<varies> |
未缩放 |
max real-time priority |
RLIMIT_RTTIME |
0 |
微秒 |
max stack segment size |
RLIMIT_STACK |
8192 |
KB |
max CPU time |
RLIMIT_CPU |
unlimited |
秒 |
max user processes |
RLIMIT_NPROC |
<varies> |
未缩放 |
address space limit or max virtual memory |
RLIMIT_AS (AS = 地址空间) |
unlimited |
KB |
max file locks held |
RLIMIT_LOCKS |
*unlimited* |
unscaled |
需要注意的要点如下:
-
对于资源值,
RLIM_INFINITY表示没有限制。 -
细心的读者会注意到在上一个表中没有
max pipe size的条目;这是因为这个资源不能通过prlimit(2)API 进行修改。 -
从技术上讲,要修改资源限制值,进程需要
CAP_SYS_RESOURCE能力(能力在第八章中有详细解释,进程能力)。现在,让我们只使用传统方法,并说为了改变进程的资源限制,需要拥有该进程(或者是 root;成为 root 或超级用户基本上是所有规则的捷径)。
代码示例
以下两个 C 程序用于演示prlimit(2) API 的用法:
-
第一个程序
rlimits_show.c查询当前进程或调用进程的所有资源限制,并打印出它们的值。 -
第二个程序给定 CPU 资源限制(以秒为单位),在该限制的影响下运行一个简单的素数生成器。
为了便于阅读,只显示了代码的相关部分。要查看并运行它,整个源代码可在github.com/PacktPublishing/Hands-on-System-Programming-with-Linux上找到。
参考以下代码:
/* From ch3/rlimits_show.c */
#define ARRAY_LEN(arr) (sizeof((arr))/sizeof((arr)[0]))
static void query_rlimits(void)
{
unsigned i;
struct rlimit rlim;
struct rlimpair {
int rlim;
char *name;
};
struct rlimpair rlimpair_arr[] = {
{RLIMIT_CORE, "RLIMIT_CORE"},
{RLIMIT_DATA, "RLIMIT_DATA"},
{RLIMIT_NICE, "RLIMIT_NICE"},
{RLIMIT_FSIZE, "RLIMIT_FSIZE"},
{RLIMIT_SIGPENDING, "RLIMIT_SIGPENDING"},
{RLIMIT_MEMLOCK, "RLIMIT_MEMLOCK"},
{RLIMIT_NOFILE, "RLIMIT_NOFILE"},
{RLIMIT_MSGQUEUE, "RLIMIT_MSGQUEUE"},
{RLIMIT_RTTIME, "RLIMIT_RTTIME"},
{RLIMIT_STACK, "RLIMIT_STACK"},
{RLIMIT_CPU, "RLIMIT_CPU"},
{RLIMIT_NPROC, "RLIMIT_NPROC"},
{RLIMIT_AS, "RLIMIT_AS"},
{RLIMIT_LOCKS, "RLIMIT_LOCKS"},
};
char tmp1[16], tmp2[16];
printf("RESOURCE LIMIT SOFT HARD\n");
for (i = 0; i < ARRAY_LEN(rlimpair_arr); i++) {
if (prlimit(0, rlimpair_arr[i].rlim, 0, &rlim) == -1)
handle_err(EXIT_FAILURE, "%s:%s:%d: prlimit[%d] failed\n",
__FILE__, __FUNCTION__, __LINE__, i);
snprintf(tmp1, 16, "%ld", rlim.rlim_cur);
snprintf(tmp2, 16, "%ld", rlim.rlim_max);
printf("%-18s: %16s %16s\n",
rlimpair_arr[i].name,
(rlim.rlim_cur == -1 ? "unlimited" : tmp1),
(rlim.rlim_max == -1 ? "unlimited" : tmp2)
);
}
}
让我们试一试:
$ make rlimits_show
[...]
$ ./rlimits_show
RESOURCE LIMIT SOFT HARD
RLIMIT_CORE : unlimited unlimited
RLIMIT_DATA : unlimited unlimited
RLIMIT_NICE : 0 0
RLIMIT_FSIZE : unlimited unlimited
RLIMIT_SIGPENDING : 63229 63229
RLIMIT_MEMLOCK : 65536 65536
RLIMIT_NOFILE : 1024 4096
RLIMIT_MSGQUEUE : 819200 819200
RLIMIT_RTTIME : unlimited unlimited
RLIMIT_STACK : 8388608 unlimited
RLIMIT_CPU : unlimited unlimited
RLIMIT_NPROC : 63229 63229
RLIMIT_AS : unlimited unlimited
RLIMIT_LOCKS : unlimited unlimited
$ ulimit -f
unlimited
$ ulimit -f 512000
$ ulimit -f
512000
$ ./rlimits_show | grep FSIZE
RLIMIT_FSIZE : 524288000 524288000
$
我们首先使用程序来转储所有资源限制。然后,我们查询文件大小资源限制,修改它(使用ulimit将其从无限制降低到约 512 KB),然后再次运行程序,这反映了更改。
现在到第二个程序;给定 CPU 资源限制(以秒为单位),我们在该 CPU 资源限制的影响下运行一个简单的质数生成器。
为了便于阅读,源代码的相关部分(相关源文件是ch3/rlimit_primes.c)被展示出来。
这是一个简单的质数生成函数:
#define MAX 10000000 // 10 million
static void simple_primegen(int limit)
{
int i, j, num = 2, isprime;
printf(" 2, 3, ");
for (i = 4; i <= limit; i++) {
isprime = 1;
for (j = 2; j < limit / 2; j++) {
if ((i != j) && (i % j == 0)) {
isprime = 0;
break;
}
}
if (isprime) {
num++;
printf("%6d, ", i);
/* Wrap after WRAP primes are printed on a line;
* this is crude; in production code, one must query
* the terminal window's width and calculate the column
* to wrap at.
*/
#define WRAP 16
if (num % WRAP == 0)
printf("\n");
}
}
printf("\n");
}
这是设置 CPU 资源限制为传递的参数(以秒为单位的时间)的函数:
/*
* Setup the CPU resource limit to 'cpulimit' seconds
*/
static void setup_cpu_rlimit(int cpulimit)
{
struct rlimit rlim_new, rlim_old;
if (cpulimit == -1)
rlim_new.rlim_cur = rlim_new.rlim_max = RLIM_INFINITY;
else
rlim_new.rlim_cur = rlim_new.rlim_max = (rlim_t)cpulimit;
if (prlimit(0, RLIMIT_CPU, &rlim_new, &rlim_old) == -1)
FATAL("prlimit:cpu failed\n");
printf
("CPU rlimit [soft,hard] new: [%ld:%ld]s : old [%ld:%ld]s (-1 = unlimited)\n",
rlim_new.rlim_cur, rlim_new.rlim_max, rlim_old.rlim_cur,
rlim_old.rlim_max);
}
在下面的代码中,我们首先进行了一个快速测试运行——我们打印了前 100 个质数,并且没有改变 CPU 资源限制的值(它通常默认为无限)。然后我们调用它来打印前 90,000 个质数,只允许它使用五秒的 CPU 时间。正如预期的那样(在现代硬件上),两者都成功了:
$ prlimit | grep "CPU time"
CPU CPU time unlimited unlimited seconds
$ ./rlimit_primes
Usage: ./rlimit_primes limit-to-generate-primes-upto CPU-time-limit
arg1 : max is 10000000
arg2 : CPU-time-limit:
-2 = don't set
-1 = unlimited
0 = 1s
$ ./rlimit_primes 100 -2
2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53,
59, 61, 67, 71, 73, 79, 83, 89, 97,
$
$ ./rlimit_primes 90000 5 CPU rlimit [soft,hard] new: [5:5]s : old [-1:-1]s (-1 = unlimited) 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127,
[...]
89753, 89759, 89767, 89779, 89783, 89797, 89809, 89819, 89821, 89833, 89839, 89849, 89867, 89891, 89897, 89899, 89909, 89917, 89923, 89939, 89959, 89963, 89977, 89983, 89989,
$
现在到了有趣的部分:我们调用rlimit_primes来打印前 200,000 个质数,只允许它使用一秒的 CPU 时间;这次它失败了(请注意,我们将标准输出重定向到临时文件,以免被所有输出分散注意力):
$ prlimit | grep "CPU time" CPU CPU time unlimited unlimited seconds
$ ./rlimit_primes 200000 1 > /tmp/prm
Killed
$ tail -n1 /tmp/prm
54727, 54751, 54767, 54773, 54779, 54787, 54799, 54829, 54833, 54851, 54869, 54877, 54881, $
为什么它失败了?显然,CPU 资源限制——只有一秒——对于完成给定任务来说时间太短了;当进程试图超过这个限制时,它被内核终止。
对于高级读者的一点提示:可以使用非常强大和多功能的perf(1) Linux 实用程序来查看这一切:
$ sudo **perf stat** ./rlimit_primes 200000 1 >/tmp/prm
./rlimit_primes: **被终止**
./rlimit_primes 200000 1的性能计数器统计:
1001.917484 任务时钟(毫秒) # 0.999 个 CPU 被利用
17 上下文切换 # 0.017 K/秒
1 cpu 迁移 # 0.001 K/秒
51 页面错误 # 0.051 K/秒
3,018,577,481 周期 # 3.013 GHz
5,568,202,738 指令 # 每个周期 1.84 条指令
982,845,319 分支 # 980.964 百万/秒
88,602 分支失效 # 所有分支的 0.01%
**1.002659905 秒的时间流逝**
$
永久性
我们已经证明,在其操作框架内,确实可以使用前端(如ulimit, prlimit(1))以及通过库和系统调用 API 以编程方式查询和设置每个进程的资源限制。然而,我们所做的更改是临时的——只在该进程的生命周期或会话的生命周期内有效。如何使资源限制值的更改变得永久?
Unix 的方式是使用(ASCII 文本)配置文件,这些文件驻留在文件系统上。特别是在大多数 Linux 发行版上,编辑/etc/security/limits.conf配置文件是答案。我们不会在这里进一步探讨细节;如果感兴趣,请查看limits.conf(5)的 man 页面。
总结
本章首先深入探讨了每个进程资源限制背后的动机以及我们为什么需要它们。我们还解释了资源限制的粒度和类型,区分了软限制和硬限制。然后我们看了用户(或系统管理员)如何使用适当的 CLI 前端(ulimit(1),prlimit(1))查询和设置每个进程的资源限制。
最后,我们详细探讨了编程接口(API)——实际上是prlimit(2)系统调用。两个详细的代码示例,查询限制和设置 CPU 使用限制,为讨论画上了句号。
在下一章中,我们将学习关键的动态内存管理 API 及其正确的使用方法。我们将远远超越使用典型的malloc() API 的基础知识,深入一些微妙而重要的内部细节。
第四章:动态内存分配
在本章中,我们将深入探讨现代操作系统上的系统编程的一个关键方面 – 动态(运行时)内存分配和释放的管理。我们将首先介绍用于动态分配和释放内存的基本 glibc API。然后,我们将超越这些基础,研究 VAS 中的程序中断和malloc(3)在不同情况下的行为。
然后,我们将使读者沉浸在一些高级讨论中:需求分页、内存锁定和保护,以及allocaAPI 的使用。
代码示例为读者提供了一个探索这些主题的机会。
在本章中,我们将涵盖以下主题:
-
基本的 glibc 动态内存管理 API 及其在代码中的正确使用
-
程序中断(及其通过
sbrk(3)API 的管理) -
malloc(3)在分配不同数量的内存时的内部行为 -
高级特性:
-
需求分页概念
-
内存锁定
-
内存区域保护
-
使用
alloca (3)API 的替代方案
glibc malloc(3) API 系列
在第二章中,虚拟内存,我们了解到在虚拟地址空间(VAS)的进程中有用于动态内存分配的区域或段。堆段就是这样一个动态区域 – 一个为进程的运行时消耗提供的免费内存礼物。
开发人员究竟如何利用这份内存的礼物?不仅如此,开发人员还必须非常小心地匹配内存分配和后续内存释放,否则系统将不会喜欢它!
GNU C 库(glibc)提供了一组小而强大的 API,使开发人员能够管理动态内存;它们的使用细节是本节的内容。
正如你将会看到的,内存管理 API 实际上只有几个:malloc(3)、calloc、realloc和free。然而,正确地使用它们仍然是一个挑战!接下来的章节(和章节)将揭示为什么会出现这种情况。继续阅读!
malloc(3) API
也许应用程序开发人员最常用的 API 之一是著名的malloc(3)。
foo(3)的语法表示foo函数在手册(man 页面)的第三部分中 – 一个库 API,而不是系统调用。我们建议您养成阅读 man 页面的习惯。man 页面可以在线获取,您可以在linux.die.net/man/找到它们。
我们使用malloc(3)在运行时动态分配一块内存。这与静态 – 或编译时 – 内存分配相反,我们做出了一个声明,比如:
char buf[256];
在前面的情况下,内存是静态分配的(在编译时)。
那么,究竟如何使用malloc(3)?让我们来看看它的签名:
#include <stdlib.h>
void *malloc(size_t size);
malloc(3)的参数是要分配的字节数。但是size_t数据类型是什么?显然,它不是 C 原始数据类型;它是在典型的 64 位平台上的typedef – long unsigned int(确切的数据类型会随着平台的不同而变化;重要的是它总是无符号的 – 它不能是负数。在 32 位 Linux 上,它将是unsigned int)。确保您的代码与函数签名和数据类型精确匹配对于编写健壮和正确的程序至关重要。顺便说一句,确保您包含 API 签名所显示的头文件。
要在printf中打印size_t类型的变量,请使用%zu格式说明符:
size_t sz = 4 * getpagesize();
[...]
printf("size = %zu bytes\n", sz);
在本书中,我们不会深入探讨malloc(3)和其它内部实现细节,实际上是如何存储、分配和释放内存的(请参阅 GitHub 存储库上的进一步阅读部分)。可以说,内部实现力求尽可能高效;通常认为使用这些 API 是执行内存管理的正确方式。
成功时返回值是指向新分配的内存区域的第一个字节的指针,失败时返回 NULL。
你会遇到,我们可以说是乐观主义者,他们会说诸如“不要费心检查malloc是否失败,它从不失败”。好吧,对这个明智的建议要持保留态度。虽然malloc很少会失败,但事实是(正如你将看到的),它可能会失败。编写防御性代码——立即检查失败情况的代码——是编写坚固、健壮程序的基石。
因此,使用这个 API 非常简单:例如,动态分配 256 字节的内存,并将指向新分配区域的指针存储在ptr变量中:
void *ptr;
ptr = malloc(256);
作为另一个典型的例子,程序员需要为一个数据结构分配内存;我们称之为struct sbar。你可以这样做:
struct sbar {
int a[10], b[10];
char buf[512];
} *psbar;
psbar = malloc(sizeof(struct sbar));
// initialize and work with it
[...]
free(psbar);
嘿,敏锐的读者!那么检查失败情况呢?这是一个关键点,所以我们将像这样重写前面的代码(当然,对于malloc(256)的代码片段也是如此):
struct [...] *psbar;
sbar = malloc(sizeof(struct sbar));
if (!sbar) {
*<... handle the error ...>*
}
让我们使用强大的跟踪工具ltrace来检查这是否按预期工作;ltrace用于显示进程执行路径中的所有库 API(类似地,使用strace来跟踪所有系统调用)。假设我们编译了前面的代码,生成的二进制可执行文件名为tst:
$ ltrace ./tst
malloc(592) = 0xd60260
free(0xd60260) = <void>
exit(0 <no return ...>
+++ exited (status 0) +++
$
我们可以清楚地看到malloc(3)(以及我们使用的示例结构在 x86_64 上占用了 592 字节),以及它的返回值(跟在=符号后面)。接着是free API,然后简单地退出。
重要的是要理解malloc(3)分配的内存块的内容被认为是随机的。因此,程序员有责任在从中读取之前初始化内存;如果未能这样做,将导致一个称为未初始化内存读取(UMR)的错误(在下一章中会更详细介绍)。
malloc(3)总是返回一个按 8 字节边界对齐的内存区域。需要更大的对齐值吗?使用posix_memalign(3) API。像通常一样使用free(3)释放其内存。
有关posix_memalign(3)的详细信息可以在 man 页面上找到linux.die.net/man/3/posix_memalign。使用posix_memalign(3) API 的示例可以在锁定内存和内存保护部分找到。
malloc(3) – 一些常见问题
以下是一些常见问题,这些问题将帮助我们更多地了解malloc(3):
- 常见问题 1:
malloc(3)可以一次分配多少内存?
在实际情况下是一个相当无意义的问题,但经常被问到!
malloc(3)的参数是size_t数据类型的整数值,因此,从逻辑上讲,我们可以作为参数传递给malloc(3)的最大数字是平台上size_t可以取的最大值。从实际上来说,在 64 位 Linux 上,size_t将是 8 字节,当然,以位来说是 8*8 = 64。因此,在单次malloc(3)调用中可以分配的最大内存量是2⁶⁴!
那么,它是多少呢?让我们来实证(在第十九章中阅读很重要,故障排除和最佳实践,以及关于实证方法的简要讨论)。并实际尝试一下(请注意,以下代码片段必须使用-lm开关链接数学库):
int szt = sizeof(size_t);
float max=0;
max = pow(2, szt*8);
printf("sizeof size_t = %u; "
"max value of the param to malloc = %.0f\n",
szt, max);
在 x86_64 上的输出:
sizeof size_t = 8; max param to malloc = 18446744073709551616
啊哈!这是一个非常大的数字;更易读地说,如下所示:
2⁶⁴ = 18,446,744,073,709,551,616 = 0xffffffffffffffff
这是 16EB(EB,即 16384PB,即 1600 万 TB)!
因此,在 64 位操作系统上,malloc(3)可以在一次调用中分配最多 16EB。理论上。
通常情况下,还有更多:请参见常见问题 2;它将揭示这个问题的理论答案是8EB(8EB)。
实际上,这是不可能的,因为这当然是进程本身的整个用户模式 VAS。实际上,可以分配的内存量受堆上连续可用的空闲内存量的限制。实际上,还有更多。正如我们很快将了解的那样(在malloc(3)的真正行为部分),malloc(3)的内存也可以来自 VAS 的其他区域。不要忘记数据段大小有资源限制;默认情况下通常是无限的,这意味着没有操作系统施加的人为限制,正如我们在本章中讨论的那样。
因此,在实践中,最好是明智一点,不要假设任何事情,并检查返回值是否为 NULL。
顺便说一下,在 32 位操作系统上,size_t可以取的最大值是多少?因此,我们通过向编译器传递-m32开关在 x86_64 上编译 32 位:
$ gcc -m32 mallocmax.c -o mallocmax32 -Wall -lm
$ ./mallocmax32
*** max_malloc() ***
sizeof size_t = 4; max value of the param to malloc = 4294967296
[...]
$
显然,这是 4GB(千兆字节)- 再次,32 位进程的整个 VAS。
- 常见问题 2:如果我传递
malloc(3)一个负参数会怎么样?
malloc(3)的参数数据类型size_t是一个无符号整数量-它不能是负数。但是,人是不完美的,整数溢出(IOF)错误确实存在!你可以想象一个程序试图计算要分配的字节数的情况,就像这样:
num = qa * qb;
如果num声明为有符号整数变量,qa和qb足够大,使得乘法操作的结果导致溢出,num的结果将会变成负数!malloc(3)当然应该失败。但是等等:如果num变量声明为size_t(这应该是情况),负数将变成一些正数!
mallocmax 程序有一个针对此的测试用例。
在 x86_64 Linux 系统上运行时的输出如下:
*** negative_malloc() ***
size_t max = 18446744073709551616
ld_num2alloc = -288225969623711744
szt_num2alloc = 18158518104085839872
1\. long int used: malloc(-288225969623711744) returns (nil)
2\. size_t used: malloc(18158518104085839872) returns (nil)
3\. short int used: malloc(6144) returns 0x136b670
4\. short int used: malloc(-4096) returns (nil)
5\. size_t used: malloc(18446744073709547520) returns (nil)
以下是相关的变量声明:
const size_t onePB = 1125899907000000; /* 1 petabyte */
int qa = 28*1000000;
long int ld_num2alloc = qa * onePB;
size_t szt_num2alloc = qa * onePB;
short int sd_num2alloc;
现在,让我们尝试 32 位版本的程序。
请注意,在默认安装的 Ubuntu Linux 系统上,32 位编译可能会失败(出现诸如fatal error: bits/libc-header-start.h: No such file or directory)的错误。不要惊慌:这通常意味着默认情况下没有编译 32 位二进制文件的编译器支持。要获得它(如在硬件-软件列表文档中提到的),安装multilib编译器包:sudo apt-get install gcc-multilib。
为 32 位编译并运行它:
$ ./mallocmax32
*** max_malloc() ***
sizeof size_t = 4; max param to malloc = 4294967296
*** negative_malloc() ***
size_t max = 4294967296
ld_num2alloc = 0
szt_num2alloc = 1106247680
1\. long int used: malloc(-108445696) returns (nil)
2\. size_t used: malloc(4186521600) returns (nil)
3\. short int used: malloc(6144) returns 0x85d1570
4\. short int used: malloc(-4096) returns (nil)
5\. size_t used: malloc(4294963200) returns (nil)
$
公平地说,编译器确实警告我们:
gcc -Wall -c -o mallocmax.o mallocmax.c
mallocmax.c: In function ‘negative_malloc’:
mallocmax.c:87:6: warning: argument 1 value ‘18446744073709551615’ exceeds maximum object size 9223372036854775807 [-Walloc-size-larger-than=]
ptr = malloc(-1UL);
~~~~^~~~~~~~~~~~~~
In file included from mallocmax.c:18:0:
/usr/include/stdlib.h:424:14: note: in a call to allocation function ‘malloc’ declared here
extern void *malloc (size_t __size) __THROW __attribute_malloc__ __wur;
^~~~~~
[...]
有趣!编译器现在回答了我们的常见问题 1:
[...] warning: argument 1 value ‘18446744073709551615’ *exceeds maximum object size* *9223372036854775807* [-Walloc-size-larger-than=] [...]
编译器允许分配的最大值似乎是 9223372036854775807。
哇。简单的计算时间表明这是 8192PB = 8EB!因此,我们必须得出结论:对于上一个问题的正确答案是:malloc一次调用可以分配多少内存?答案:8EB。再次,理论上。
- 常见问题 3:如果我使用
malloc(0)会怎么样?
不多;根据实现的不同,malloc(3)将返回 NULL,或者一个可以传递给 free 的非 NULL 指针。当然,即使指针是非 NULL 的,也没有内存,所以不要尝试使用它。
让我们试一试:
void *ptr;
ptr = malloc(0);
free(ptr);
我们编译然后通过ltrace运行它:
$ ltrace ./a.out
malloc(0) = 0xf50260
free(0xf50260) = <void>
exit(0 <no return ...>
+++ exited (status 0) +++
$
在这里,malloc(0)确实返回了一个非 NULL 指针。
- 常见问题 4:如果我使用
malloc(2048)并尝试读/写超出 2048 字节会怎么样?
当然,这是一个错误-一个越界内存访问错误,进一步定义为读取或写入缓冲区溢出。请稍等,关于内存错误的详细讨论(以及随后如何找到和修复它们)是第五章的主题,Linux 内存问题,以及第六章,内存问题的调试工具。
malloc(3) - 快速总结
因此,让我们总结一下关于malloc(3) API 使用的关键点:
-
malloc(3)在运行时从进程堆中动态分配内存 -
正如我们很快将了解的那样,这并不总是情况
-
malloc(3)的单个参数是一个无符号整数值-要分配的字节数。 -
成功时返回值是指向新分配的内存块开头的指针,失败时返回 NULL:
-
您必须检查失败的情况;不要假设它会成功
-
malloc(3)总是返回一个按 8 字节边界对齐的内存区域 -
新分配的内存区域的内容被认为是随机的
-
在从中读取任何部分之前,您必须对其进行初始化
-
您必须释放您分配的内存
free API
在这个生态系统中开发的黄金规则之一是程序员分配的内存必须被释放。
未能这样做会导致糟糕的情况-一个错误,真的-称为内存泄漏;这在下一章中有比较深入的介绍。仔细匹配您的分配和释放是至关重要的。
然而,在较小的实际项目(实用程序)中,您会遇到只分配一次内存的情况;在这种情况下,释放内存是迂腐的,因为整个虚拟地址空间在进程终止时被销毁。此外,使用alloca(3) API 意味着您不需要释放内存区域(稍后在高级特性部分中看到)。尽管如此,建议您谨慎行事!
使用free(3) API 很简单:
void free(void *ptr);
它接受一个参数:要释放的内存块的指针。ptr必须是malloc(3)系列例程(malloc(3),calloc或realloc[array])返回的指针。
free不返回任何值;甚至不要尝试检查它是否起作用;如果您使用正确,它就起作用了。有关 free 的更多信息,请参阅释放的内存去哪里了?部分。一旦释放了内存块,您显然不能尝试再次使用该内存块的任何部分;这样做将导致错误(或者所谓的UB-未定义行为)。
关于free()的一个常见误解有时会导致其以错误的方式使用;看一下这个伪代码片段:
void *ptr = NULL;
[...]
while(*<some-condition-is-true>*) {
if (!ptr)
ptr = malloc(n);
[...
* <use 'ptr' here>*
...]
free(ptr);
}
这个程序可能会在循环中崩溃(在<use 'ptr' here>代码内)。为什么?因为ptr内存指针被释放并且正在尝试被重用。但是为什么?啊,仔细看:代码片段只有在ptr当前为 NULL 时才会malloc(3)指针,也就是说,其程序员假设一旦我们free()内存,我们刚刚释放的指针就会被设置为 NULL。这并不是事实!
在编写代码时要谨慎并且要有防御性。不要假设任何事情;这是一个错误的丰富来源。重要的是,我们的第十九章,故障排除和最佳实践,涵盖了这些要点)
free - 快速总结
因此,让我们总结一下关于free API 使用的关键点:
-
传递给
free(3)的参数必须是malloc(3)系列 API(malloc(3),calloc或realloc[array])返回的值。 -
free没有返回值。 -
调用
free(ptr)不会将ptr设置为NULL(尽管这样做会很好)。 -
一旦释放,不要尝试使用已释放的内存。
-
不要尝试多次free相同的内存块(这是一个错误-UB)。
-
目前,我们将假设释放的内存返回给系统。
-
天哪,不要忘记释放先前动态分配的内存。被遗忘的内存被称为泄漏,这是一个非常难以捕捉的错误!幸运的是,有一些工具可以帮助我们捕捉这些错误。更多内容请参阅第五章,Linux 内存问题,和第六章,内存问题的调试工具。
calloc API
calloc(3) API 与malloc(3)几乎相同,主要有两个不同之处:
-
它将分配的内存块初始化为零值(即 ASCII 0 或 NULL,而不是数字
0) -
它接受两个参数,而不是一个
calloc(3)函数签名如下:
void *calloc(size_t nmemb, size_t size);
第一个参数nmemb是 n 个成员;第二个参数size是每个成员的大小。实际上,calloc(3)分配了一个大小为(nmemb*size)字节的内存块。因此,如果你想为一个包含 1,000 个整数的数组分配内存,你可以这样做:
int *ptr;
ptr = calloc(1000, sizeof(int));
假设整数的大小为 4 字节,我们将总共分配了(1000*4)= 4000 字节。
每当需要为一组项目分配内存(在应用程序中经常使用的一种情况是结构数组),calloc是一种方便的方式,既可以分配内存,又可以同时初始化内存。
需求分页(本章后面介绍),是程序员使用calloc而不是malloc(3)的另一个原因(在实践中,这对实时应用程序非常有用)。在即将到来的部分中了解更多信息。
realloc API
realloc API 用于调整现有内存块的大小——增大或缩小。这种调整只能在先前使用malloc(3)系列 API 之一(通常的嫌疑犯:malloc(3),calloc或realloc[array])分配的内存块上执行。以下是其签名:
void *realloc(void *ptr, size_t size);
第一个参数ptr是先前使用malloc(3)系列 API 之一分配的内存块的指针;第二个参数size是内存块的新大小——它可以比原来的大或小,从而增大或缩小内存块。
一个快速的示例代码片段将帮助我们理解realloc:
void *ptr, *newptr;
ptr = calloc(100, sizeof(char)); // error checking code not shown here
newptr = realloc(ptr, 150);
if (!newptr) {
fprintf(stderr, "realloc failed!");
free(ptr);
exit(EXIT_FAILURE);
}
*< do your stuff >*
free(newptr);
realloc返回的指针是新调整大小的内存块的指针;它可能与原始ptr的地址相同,也可能不同。实际上,你现在应该完全忽略原始指针ptr,并将realloc返回的newptr指针视为要处理的指针。如果失败,返回值为 NULL(检查它!),原始内存块将保持不变。
一个关键点:realloc(3)返回的指针newptr是随后必须释放的指针,而不是指向(现在调整大小的)内存块的原始指针(ptr)。当然,不要尝试释放两个指针,因为那是一个错误。
刚刚调整大小的内存块的内容呢?它们保持不变,直到MIN(original_size, new_size)。因此,在前面的例子中,MIN(100, 150) = 100,100 字节的内存内容将保持不变。剩下的部分(50 字节)呢?它被视为随机内容(就像malloc(3)一样)。
realloc(3)——边界情况
考虑以下代码片段:
void *ptr, *newptr;
ptr = calloc(100, sizeof(char)); // error checking code not shown here
newptr = realloc(NULL, 150);
realloc传递的指针是NULL?库将其视为等同于新分配的malloc(150);以及malloc(3)的所有含义。就是这样。
现在,考虑以下代码片段:
void *ptr, *newptr;
ptr = calloc(100, sizeof(char)); // error checking code not shown here
newptr = realloc(ptr, 0);
传递给realloc的大小参数是0?库将其视为等同于free(ptr).就是这样。
reallocarray API
一种情况:你使用calloc(3)为一个数组分配内存;后来,你想将其调整大小为更大。我们可以使用realloc(3)来做到;例如:
struct sbar *ptr, *newptr;
ptr = calloc(1000, sizeof(struct sbar)); // array of 1000 struct sbar's
[...]
// now we want 500 more!
newptr = realloc(ptr, 500*sizeof(struct sbar));
好的。不过,有一种更简单的方法——使用reallocarray(3) API。其签名如下:
void *reallocarray(void *ptr, size_t nmemb, size_t size);
有了它,代码变得更简单:
[...]
// now we want 500 more!
newptr = reallocarray(ptr, 500, sizeof(struct sbar));
reallocarray的返回值与realloc API 非常相似:成功时调整大小的新内存块的新指针(可能与原始指针不同),失败时为NULL。如果失败,原始内存块将保持不变。
reallocarray相对于realloc有一个真正的优势——安全性。从realloc(3)的手册页上看到这段代码:
... However, unlike that realloc() call, reallocarray() fails safely in the case where the multiplication would overflow. If such an overflow occurs, reallocarray() returns NULL, sets errno to ENOMEM, and leaves the original block of memory unchanged.
还要意识到,reallocarrayAPI 是 GNU 的扩展;它将在现代 Linux 上工作,但不应被认为在其他操作系统上是可移植的。
最后,请考虑:一些项目对其数据对象有严格的对齐要求;使用calloc(甚至通过malloc(3)分配这些对象)可能导致微妙的错误!在本章后面,我们将使用posix_memalign(3)API——它保证按给定的字节对齐分配内存(您指定字节数)!例如,要求内存分配对齐到页面边界是相当常见的情况(请回忆,malloc 总是返回一个按 8 字节边界对齐的内存区域)。
底线:小心。阅读文档,思考,并决定在特定情况下哪个 API 更合适。在 GitHub 存储库的进一步阅读部分中有更多信息。
基础之外
在本节中,我们将深入探讨malloc(3)API 系列的动态内存管理。了解这些领域,以及第五章的内容,Linux 内存问题,以及第六章,内存问题的调试工具,将有助于开发人员有效地调试常见的内存错误和问题。
程序中断
当进程或线程需要内存时,它调用动态内存例程之一——通常是malloc(3)或calloc(3);这段内存(通常)来自堆段。如前所述,堆是一个动态段——它可以增长(朝着更高的虚拟地址)。显然,但是,在任何给定的时间点,堆都有一个终点或顶部,超过这个顶部就不能再取内存了。这个终点——堆上最后一个合法可引用的位置——称为程序中断。
使用 sbrk() API
那么,你如何知道当前程序中断在哪里?这很容易——当使用参数值为零的sbrk(3)API 时,它会返回当前程序中断!让我们快速查找一下:
#include <unistd.h>
[...]
printf("Current program break: %p\n", sbrk(0));
当前面的代码行运行时,您将看到以下示例输出:
$ ./show_curbrk
Current program break: 0x1bb4000
$ ./show_curbrk
Current program break: 0x1e93000
$ ./show_curbrk
Current program break: 0x1677000
$
它有效,但为什么程序中断值保持改变(看起来是随机的)?嗯,它确实是随机的:出于安全原因,Linux 随机化了进程的虚拟地址空间布局(我们在第二章中介绍了进程 VAS 布局,虚拟内存)。这种技术称为地址空间布局随机化(ASLR)。
让我们再做一点:我们将编写一个程序,如果没有任何参数运行,仅显示当前程序中断并退出(就像我们刚才看到的那样);如果传递一个参数——动态分配内存的字节数,它将这样做(使用malloc(3)),然后打印返回的堆地址以及原始和当前程序中断。在这里,您只能请求少于 128KB 的内存,稍后将会解释原因。
参考ch4/show_curbrk.c:
int main(int argc, char **argv)
{
char *heap_ptr;
size_t num = 2048;
/* No params, just print the current break and exit */
if (argc == 1) {
printf("Current program break: %p\n", sbrk(0));
exit(EXIT_SUCCESS);
}
/* If passed a param - the number of bytes of memory to
* dynamically allocate - perform a dynamic alloc, then
* print the heap address, the current break and exit.
*/
num = strtoul(argv[1], 0, 10);
if ((errno == ERANGE && num == ULONG_MAX)
|| (errno != 0 && num == 0))
handle_err(EXIT_FAILURE, "strtoul(%s) failed!\n", argv[1]);
if (num >= 128 * 1024)
handle_err(EXIT_FAILURE, "%s: pl pass a value < 128 KB\n",
argv[0]);
printf("Original program break: %p ; ", sbrk(0));
heap_ptr = malloc(num);
if (!heap_ptr)
handle_err(EXIT_FAILURE, "malloc failed!");
printf("malloc(%lu) = %16p ; curr break = %16p\n",
num, heap_ptr, sbrk(0));
free(heap_ptr);
exit(EXIT_SUCCESS);
}
让我们试一试:
$ make show_curbrk && ./show_curbrk [...]
Current program break: 0x1247000
$ ./show_curbrk 1024
Original program break: 0x1488000 ; malloc(1024) = 0x1488670 ;
curr break = 0x14a9000
$
有趣(见下图)!使用 1024 字节的分配,返回到该内存块开头的堆指针是0x1488670;这是从原始中断的0x1488670 - 0x1488000 = 0x670 = 1648字节。
还要意识到,新的中断值是0x14a9000,即(0x14a9000 - 0x1488670 = 133520),大约从新分配的块增加了 130KB。为什么堆为了仅仅 1KB 的分配而增长了这么多?耐心等待;这个问题以及更多内容将在下一节malloc(3)的真正行为中进行探讨。同时,请参考下图:

堆和程序中断
关于前面的图表:
Original program break = 0x1488000
heap_ptr = 0x1488670
New program break = 0x14a9000
请注意,sbrk(2)可以用于增加或减少程序断点(通过传递整数参数)。乍一看,这似乎是分配和释放动态内存的一种好方法;实际上,最好使用经过充分记录和可移植的 glibc 实现,即malloc(3)家族的 API。
sbrk是对brk(2)系统调用的一个方便的库包装。
malloc(3)的真正行为
普遍的共识是,malloc(3)(以及calloc(3)和reallocarray)从堆段获取其内存。这确实是事实,但深入挖掘会发现这并非总是如此。现代的 glibc malloc(3)引擎使用一些微妙的策略来最优化地利用可用的内存区域和进程 VAS——尤其是在当今的 32 位系统上,这已经成为一种相当稀缺的资源。
那么,它是如何工作的呢?库使用预定义的MMAP_THRESHOLD变量–其默认值为 128 KB–来确定从哪里分配内存。让我们想象一下,我们正在使用 malloc(n)分配n字节的内存:
-
如果n < MMAP_THRESHOLD,则使用堆段来分配请求的n字节
-
如果n >= MMAP_THRESHOLD,并且堆的空闲列表中没有 n 字节可用,则使用虚拟地址空间的任意空闲区域来满足请求的n字节分配。
第二种情况下内存是如何分配的?啊,malloc(3)在内部调用mmap(2)——内存映射系统调用。mmap系统调用非常灵活。在这种情况下,它被用来保留调用进程的虚拟地址空间中的 n 字节的空闲区域!
为什么使用mmap(2)?关键原因是 mmap 的内存总是可以在需要时以独立的方式释放(归还给系统);这在使用free(3)时并非总是如此。
当然,也有一些缺点:mmap分配可能很昂贵,因为内存是页面对齐的(因此可能是浪费的),而且内核会将内存区域清零(这会影响性能)。
mallopt(3)手册页(截至 2016 年 12 月)还指出,现在的 glibc 使用动态 mmap 阈值;最初的值是通常的 128 KB,但如果在当前阈值和DEFAULT_MMAP_THRESHOLD_MAX之间释放了一个大内存块,阈值就会增加到与释放块的大小相同。
代码示例– malloc(3)和程序断点
我们亲眼看到malloc(3)分配对堆和进程虚拟地址空间的影响是有趣且富有教育意义的。查看以下代码示例的输出(源代码可在本书的 Git 存储库中找到):
$ ./malloc_brk_test -h
Usage: ./malloc_brk_test [option | --help]
option = 0 : show only mem pointers [default]
option = 1 : opt 0 + show malloc stats as well
option = 2 : opt 1 + perform larger alloc's (over MMAP_THRESHOLD)
option = 3 : test segfault 1
option = 4 : test segfault 2
-h | --help : show this help screen
$
在这个应用程序中有几种情景正在运行;现在让我们来检查其中的一些。
情景 1–默认选项
我们以默认方式运行malloc_brk_test程序,即不使用任何参数:
$ ./malloc_brk_test
init_brk = 0x1c97000
#: malloc( n) = heap_ptr cur_brk delta
[cur_brk-init_brk]
0: malloc( 8) = 0x1c97670 0x1cb8000 [135168]
1: malloc( 4083) = 0x1c97690 0x1cb8000 [135168]
2: malloc( 3) = 0x1c98690 0x1cb8000 [135168]
$
进程打印出其初始程序断点值:0x1c97000。然后它只分配了 8 字节(通过malloc(3)API);在幕后,glibc 分配引擎调用了sbrk(2)系统调用来增加堆;新的断点现在是0x1cb8000,比之前的断点增加了 135,168 字节= 132 KB(在前面的代码中的delta列中清楚可见)!
为什么?优化:glibc 预期,将来进程将需要更多的堆空间;而不是每次调用系统调用(sbrk/brk)的开销,它执行一次相当大的堆增长操作。左侧列中的下两个malloc(3)API(编号为 1 和 2)证明了这一点:我们分别分配了 4,083 和 3 字节,你注意到了什么?程序断点没有改变–堆已经足够大,可以容纳这些请求。
情景 2–显示 malloc 统计信息
这一次,我们传递了1参数,要求它也显示malloc(3)的统计信息(使用malloc_stats(3)API 实现):
$ ./malloc_brk_test 1
init_brk = 0x184e000
#: malloc( n) = heap_ptr cur_brk delta
[cur_brk-init_brk]
0: malloc( 8) = 0x184e670 0x186f000 [135168]
Arena 0:
system bytes = 135168
in use bytes = 1664
Total (incl. mmap):
system bytes = 135168
in use bytes = 1664
max mmap regions = 0
max mmap bytes = 0
1: malloc( 4083) = 0x184e690 0x186f000 [135168]
Arena 0:
system bytes = 135168
in use bytes = 5760
Total (incl. mmap):
system bytes = 135168
in use bytes = 5760
max mmap regions = 0
max mmap bytes = 0
2: malloc( 3) = 0x184f690 0x186f000 [135168]
Arena 0:
system bytes = 135168
in use bytes = 5792
Total (incl. mmap):
system bytes = 135168
in use bytes = 5792
max mmap regions = 0
max mmap bytes = 0
输出类似,除了程序调用有用的malloc_stats(3) API,该 API 查询并打印malloc(3)的状态信息到stderr(顺便说一句,arena 是malloc(3)引擎内部维护的分配区域)。从这个输出中,注意到:
-
可用的空闲内存-系统字节-为 132 KB(在执行一个小的 8 字节
malloc(3)之后) -
每次分配时,正在使用的字节都会增加,但系统字节保持不变
-
mmap区域和mmap字节数为零,因为没有发生基于 mmap 的分配。
情景 3-大分配选项
这次,我们传递了2参数,要求程序执行更大的分配(大于MMAP_THRESHOLD):
$ ./malloc_brk_test 2
init_brk = 0x2209000
#: malloc( n) = heap_ptr cur_brk delta
[cur_brk-init_brk]
[...]
3: malloc( 136168) = 0x7f57288cd010 0x222a000 [135168]
Arena 0:
system bytes = 135168
in use bytes = 5792
Total (incl. mmap):
system bytes = 274432
in use bytes = 145056
max mmap regions = 1
max mmap bytes = 139264
4: malloc( 1048576) = 0x7f57287c7010 0x222a000 [135168]
Arena 0:
system bytes = 135168
in use bytes = 5792
Total (incl. mmap):
system bytes = 1327104
in use bytes = 1197728
max mmap regions = 2
max mmap bytes = 1191936
$
(请注意,我们已经剪辑了前两个小分配的输出,并且只显示了相关的大分配)。
现在,我们分配 132 KB(前面输出的第 3 点);需要注意的是:
-
分配(#3 和#4)分别为 132 KB 和 1 MB - 都超过了
MMAP_THRESHOLD(值为 128 KB) -
(arena 0)堆正在使用的字节(5,792)在这两个分配中完全没有改变,表明堆内存没有被使用
-
最大的 mmap 区域和最大的 mmap 字节数已经改变为正值(从零开始),表示使用了 mmap 内存
稍后将检查剩下的几种情况。
释放的内存去哪了?
free(3),当然,是一个库例程,所以可以推断,当我们释放内存,之前由动态分配例程之一分配的内存不会被释放回系统,而是被释放到进程堆(当然,这是虚拟内存)。
然而,至少有两种情况下可能不会发生这种情况:
-
如果分配是通过mmap而不是通过堆段内部满足的,它会立即被释放回系统。
-
在现代的 glibc 上,如果释放的堆内存量非常大,这会触发将至少一些内存块返回给操作系统。
高级功能
现在将介绍一些高级功能:
-
需求分页
-
锁定内存在 RAM 中
-
内存保护
-
使用alloca(3)进行分配
需求分页
我们大多数人都知道,如果一个进程动态分配内存,使用malloc,比如它做了ptr = malloc(8192) ;,然后,假设成功,进程现在分配了 8 KB 的物理 RAM。这可能会让人感到惊讶,但是,在现代的操作系统如 Linux 上,实际上并不是这样。
那么,情况是什么?(在本书中,我们不深入研究内核级细节。另外,正如你可能知道的,操作系统分配器的内存粒度是页面,通常为 4 KB。)
在编写健壮的软件时,假设任何事情都不是一个好主意。那么,如何正确确定操作系统的页面大小?使用sysconf(3) API;例如,printf("page size = %ld\n", **sysconf(_SC_PAGESIZE)**);,输出page size = 4096。
或者,使用getpagesize(2)系统调用来检索系统页面大小。(重要的是,参见第十九章,故障排除和最佳实践,在程序员的清单:7 条规则部分涵盖了类似的观点)。
实际上,所有的 malloc 只是从进程 VAS 中保留虚拟页面的内存。
那么,进程何时获得实际的物理页面呢?啊,当进程实际上窥视或触及页面中的任何字节时,实际上是在任何页面上进行任何访问(尝试读取/写入/执行它)时,进程会陷入操作系统 - 通过称为页面错误的硬件异常 - 并且在操作系统的错误处理程序中,如果一切正常,操作系统会为虚拟页面分配一个物理页帧。这种高度优化的向进程分配物理内存的方式称为需求分页 - 只有在实际需要时才会分配物理页面!这与操作系统人员所称的内存或虚拟内存超额分配功能密切相关;是的,这是一个功能,而不是一个错误。
如果要保证在虚拟分配后分配物理页帧,可以:
-
在所有页面的所有字节上执行
malloc(3),然后执行memset(3) -
只需使用
calloc(3);它会将内存设置为零,从而引发错误
在许多实现中,第二种方法 - 使用calloc(3) - 比第一种方法更快。
正是因为需求分页,我们才能编写一个 malloc 大量内存并且从不释放它的应用程序;只要进程不尝试读取、写入或执行分配区域的任何(虚拟)页面的任何字节,它就可以正常工作。显然,有许多现实世界的应用程序设计得非常糟糕,确实做了这种事情 - 通过malloc(3)分配大量内存,以防万一需要。需求分页是操作系统对浪费大量实际上很少使用的物理内存的一种保护。
当然,聪明的读者会意识到每个优势可能都有一个劣势。在这种情况下,可能会同时进行几个进程执行大内存分配。如果它们都分配了大量的虚拟内存,并且想要在大致相同的时间实际上占用这些页面,这将给操作系统带来巨大的内存压力!而且,操作系统绝对不保证它能成功为每个进程提供服务。事实上,在最坏的情况下,Linux 操作系统将缺乏物理内存,以至于必须调用一个有争议的组件 - Out-of-Memory (OOM) Killer - 其工作是识别占用内存的进程并将其及其后代杀死,从而回收内存并保持系统运行。这让你想起黑手党了,是吧。
再次,malloc(3)的 man 页面清楚地指出了以下内容:
By default, Linux follows an optimistic memory allocation strategy. This means that when malloc() returns non-NULL there is no guarantee that the memory really is available. In case it turns out that the system is out of memory, one or more processes will be killed by the OOM killer.
[...]
如果感兴趣,可以在 GitHub 存储库的进一步阅读部分中查看参考资料。
驻留还是不驻留?
现在我们清楚地了解了由malloc和朋友分配的页面是虚拟的,不能保证由物理页框支持(至少起初是这样),想象一下我们有一个指向(虚拟)内存区域的指针,并且知道它的长度。我们现在想知道相应的页面是否在 RAM 中,也就是说,它们是驻留还是不驻留。
事实证明,有一个系统调用可以提供精确的信息:mincore(2)。
mincore(2)系统调用的发音是 m-in-core,而不是 min-core。Core是一个用来描述物理内存的古老词语。
让我们看一下以下代码:
#include <unistd.h>
#include <sys/mman.h>
int mincore(void *addr, size_t length, unsigned char *vec);
给定起始虚拟地址和长度,mincore(2)会填充第三个参数 - 一个向量数组。调用成功返回后,对于向量数组的每个字节,如果最低有效位(LSB)被设置,那么意味着相应的页面是驻留(在 RAM 中),否则不是(可能未分配或在交换中)。
使用mincore(2) man 页面可以获取使用详细信息:linux.die.net/man/2/mincore。
当然,您应该意识到页面驻留返回的信息只是内存页面状态的一个快照:它可能在我们之下发生变化,也就是说,它(或可能)在性质上非常瞬态。
锁定内存
我们知道,在基于虚拟内存的操作系统(如 Linux)上,用户模式页面可以在任何时候被交换;Linux 内核内存管理代码做出这些决定。对于常规应用程序进程来说,这不应该有关系:每当它尝试访问(读取、写入或执行)页面内容时,内核将其分页回 RAM,并允许其像没有发生任何事情一样使用。这种处理通常称为服务页面错误(还有很多其他内容,但就本讨论的目的而言,这就足够了),对用户模式应用程序进程完全透明。
然而,有一些情况下,内存页面被分页写入 RAM 到交换空间,反之亦然是不希望的:
-
实时应用程序
-
加密(安全)应用程序
在实时应用程序中,关键因素(至少在其关键代码路径中)是确定性——铁一般的保证工作将花费一定的最坏情况时间,不会超过这个时间,无论系统的负载如何。
想象一下,实时进程正在执行关键代码路径,并且在那一刻必须从交换分区中分页数据页面——引入的延迟可能会破坏应用程序的特性,导致惨败(或更糟)。在这些情况下,我们,开发人员,需要一种方法来保证所述内存页面可以保证驻留在 RAM 中,从而避免任何页面错误。
在某些类型的安全应用程序中,它们可能会在内存中存储一些机密信息(密码、密钥);如果包含这些信息的内存页面被写入磁盘(交换空间),则始终存在它在应用程序退出后仍然留在磁盘上的可能性——这就是所谓的信息泄漏,这是攻击者等待发动攻击的一个漏洞!在这里,再次需要保证这些页面不能被交换出去。
输入mlock(2)(以及相关的:mlock2和mlockall)系统调用;这些 API 的明确目的是锁定调用进程虚拟地址空间中的内存页面。让我们来看看如何使用mlock(2)。这是它的签名:
int mlock(const void *addr, size_t len);
第一个参数addr是指向要锁定的(虚拟)内存区域的指针;第二个参数len是要锁定到 RAM 中的字节数。举个简单的例子,看看下面的代码(这里为了保持易读性,我们不显示错误检查代码;在实际应用中,请务必这样做!):
long pgsz = sysconf(_SC_PAGESIZE);
size_t len = 3*pgsz;
void *ptr = malloc(len);
[...] // initialize the memory, etc
// Lock it!
if (mlock(ptr, len) != 0) {
// mlock failed, handle it
return ...;
}
[...] /* use the memory, confident it is resident in RAM & will stay
there until unlocked */
munlock(ptr, len); // it's now unlocked, can be swapped
限制和特权
特权进程,无论是通过以root身份运行,还是更好地通过设置CAP_IPC_LOCK能力位来锁定内存(我们将在它们自己的章节中详细描述进程凭据和能力——第七章,进程凭据,和第八章,进程能力),可以锁定无限量的内存。
从 Linux 2.6.9 开始,对于非特权进程,它受RLIMIT_MEMLOCK软资源限制的限制(通常不设置得很高)。以下是在 x86_64 Fedora 盒子(以及 Ubuntu)上的一个示例:
$ prlimit | grep MEMLOCK
MEMLOCK max locked-in-memory address space 65536 65536 bytes
$
这只是 64 KB(在嵌入式 ARM Linux 上也是如此,默认情况下)。
在撰写本书时,最近在 x86_64 上运行的Fedora 28发行版上,最大锁定内存的资源限制似乎已经提升到 16 MB!以下prlimit(1)输出显示了这一点:
$ prlimit | grep MEMLOCK
MEMLOCK 最大锁定内存地址空间 16777216 16777216 字节
$
然而,当使用 mlock(2)时,POSIX 标准要求addr对齐到页面边界(即,如果你将内存起始地址除以系统页面大小,余数将为零,即,(addr % pgsz) == 0)。你可以使用posix_memalign(3)API 来保证这一点;因此,我们可以稍微改变我们的代码以适应这个对齐要求:
参考以下内容(ch4/mlock_try.c):
[...]
#define CMD_MAX 256
static void disp_locked_mem(void)
{
char *cmd = malloc(CMD_MAX);
if (!cmd)
FATAL("malloc(%zu) failed\n", CMD_MAX);
snprintf(cmd, CMD_MAX-1, "grep Lck /proc/%d/status", getpid());
system(cmd);
free(cmd);
}
static void try_mlock(const char *cpgs)
{
size_t num_pg = atol(cpgs);
const long pgsz = sysconf(_SC_PAGESIZE);
void *ptr= NULL;
size_t len;
len = num_pg * pgsz;
if (len >= LONG_MAX)
FATAL("too many bytes to alloc (%zu), aborting now\n", len);
/* ptr = malloc(len); */
/* Don't use the malloc; POSIX wants page-aligned memory for mlock */
posix_memalign(&ptr, pgsz, len);
if (!ptr)
FATAL("posix_memalign(for %zu bytes) failed\n", len);
/* Lock the memory region! */
if (mlock(ptr, len)) {
free(ptr);
FATAL("mlock failed\n");
}
printf("Locked %zu bytes from address %p\n", len, ptr);
memset(ptr, 'L', len);
disp_locked_mem();
sleep(1);
/* Now unlock it.. */
if (munlock(ptr, len)) {
free(ptr);
FATAL("munlock failed\n");
}
printf("unlocked..\n");
free(ptr);
}
int main(int argc, char **argv)
{
if (argc < 2) {
fprintf(stderr, "Usage: %s pages-to-alloc\n", argv[0]);
exit(EXIT_FAILURE);
}
disp_locked_mem();
try_mlock(argv[1]);
exit (EXIT_SUCCESS);
}
让我们试一试:
$ ./mlock_try Usage: ./mlock_try pages-to-alloc $ ./mlock_try 1 VmLck: 0 kB
Locked 4096 bytes from address 0x1a6e000
VmLck: 4 kB
unlocked.. $ ./mlock_try 32 VmLck: 0 kB mlock_try.c:try_mlock:79: mlock failed
perror says: Cannot allocate memory
$
$ ./mlock_try 15 VmLck: 0 kB
Locked 61440 bytes from address 0x842000
VmLck: 60 kB
unlocked.. $ sudo ./mlock_try 32 [sudo] password for <user>: xxx
VmLck: 0 kB
Locked 131072 bytes from address 0x7f6b478db000
VmLck: 128 kB
unlocked..
$ prlimit | grep MEMLOCK MEMLOCK max locked-in-memory address space 65536 65536 bytes
$
请注意,在成功的情况下,posix_memalign(3)返回的地址;它在页面边界上。我们可以通过查看地址的最后三位数字(从右边开始)来快速判断–如果它们都是零,那么它可以被页面大小整除,因此在页面边界上。这是因为页面大小通常为 4,096 字节,而 4096 十进制=0x1000 十六进制!
我们请求 32 页;分配成功,但mlock失败,因为 32 页=324K=128 KB;锁定内存的资源限制只有 64 KB。然而,当我们使用sudo*(因此以 root 访问运行)时,它可以工作。
锁定所有页面
mlock基本上允许我们告诉操作系统将某个内存范围锁定到 RAM 中。然而,在一些实际情况下,我们无法准确预测我们将提前需要哪些内存页面(实时应用程序可能需要各种或所有内存页面始终驻留)。
为了解决这个棘手的问题,还有另一个系统调用mlockall(2)存在;正如你所猜测的,它允许你锁定所有进程内存页面:
int mlockall(int flags);
如果成功(记住,mlockall和mlock都受到相同的特权限制),进程的所有内存页面–如文本、数据段、库页面、堆栈和共享内存段–都保证保持驻留在 RAM 中,直到解锁。
flags参数为应用程序开发人员提供了进一步的控制;它可以是以下内容的按位或:
-
MCL_CURRENT -
MCL_FUTURE -
MCL_ONFAULT(Linux 4.4 及以上版本)
使用MCL_CURRENT要求操作系统将调用进程 VAS 中的所有当前页面锁定到内存中。
但是如果你在初始化时发出mlockall(2)系统调用,但是实时进程将在 5 分钟后执行一个 200 千字节的malloc呢?我们需要保证这 200 KB 的内存(即 50 页,假设页面大小为 4 KB)始终驻留在 RAM 中(否则,实时应用程序将因可能的未来页面故障而遭受太大的延迟)。这就是MCL_FUTURE标志的目的:它保证成为调用进程 VAS 的一部分的内存页面将始终保持驻留在内存中,直到解锁。
我们在需求分页部分学到,执行malloc只是保留虚拟内存,而不是物理内存。例如,如果一个(非实时)应用程序执行了一个相当大的分配,比如 1 兆字节(即 512 页),我们知道只有 512 个虚拟页面被保留,而物理页面框架实际上并没有被分配–它们将按需故障进入。因此,一个典型的实时应用程序需要以某种方式保证这 512 页一旦故障进入就会保持锁定(驻留)在 RAM 中。使用MCL_ONFAULT标志来实现这一点。
这个标志必须与MCL_CURRENT或MCL_FUTURE标志,或两者一起使用。其思想是,物理内存消耗保持极其高效(因为在malloc时没有进行物理分配),但一旦应用程序开始访问虚拟页面(即读取、写入或执行页面内的数据或代码),物理页面框架就会被故障进入,然后被锁定。换句话说,我们不预先故障内存,因此我们可以兼得两全其美。
另一面是,完成后,应用程序可以通过发出对应的 API:munlockall(2)来解锁所有内存页面。
内存保护
一个应用程序动态分配了四页内存。默认情况下,这块内存既可读又可写;我们称之为页面的内存保护。
如果应用程序开发人员能够动态修改每页的内存保护,那不是很好吗?例如,保持第一页的默认保护,将第二页设置为只读,将第三页设置为读+执行,并且在第四页上不允许任何访问(可能是一个守卫页?)。
这个特性正是mprotect(2)系统调用的设计目的。让我们深入研究如何利用它来实现所有这些。这是它的签名:
#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);
这实际上非常简单:从(虚拟)地址addr开始,对len字节(即从addr到addr+len-1),应用prot位掩码指定的内存保护。由于mprotect的粒度是一页,因此预期第一个参数addr应该是页面对齐的(在页面边界上;回想一下,这正是mlockall所期望的)。
第三个参数prot是您指定实际保护的地方;它是一个位掩码,可以是PROT_NONE位,也可以是其余位的按位或:
| 保护位 | 内存保护的含义 |
|---|---|
PROT_NONE |
页面上不允许任何访问 |
PROT_READ |
页面上允许读取 |
PROT_WRITE |
页面上允许写入 |
PROT_EXEC |
页面上允许执行访问 |
在mprotect(2)的 man 页面中,NOTES部分有几个其他相当神秘的保护位和有用的信息。如果需要(或只是好奇),可以在这里阅读:man7.org/linux/man-pages/man2/mprotect.2.html。
内存保护 - 代码示例
让我们考虑一个示例程序,其中进程动态分配了四页内存,并希望设置它们的内存保护,如下表所示:
| 页码 | 页 0 | 页 1 | 页 2 | 页 3 |
|---|---|---|---|---|
| 保护位 | rw- |
r-- |
rwx |
--- |
代码的相关部分如下所示:
首先,main函数使用posix_memalign(3)API 动态分配页面对齐的内存(四页),然后依次调用内存保护和内存测试函数:
[...]
/* Don't use the malloc; POSIX wants page-aligned memory for mprotect(2) */
posix_memalign(&ptr, gPgsz, 4*gPgsz);
if (!ptr)
FATAL("posix_memalign(for %zu bytes) failed\n", 4*gPgsz);
protect_mem(ptr);
test_mem(ptr, atoi(argv[1]));
[...]
这是内存保护函数:
int okornot[4];
static void protect_mem(void *ptr)
{
int i;
u64 start_off=0;
char str_prots[][128] = {"PROT_READ|PROT_WRITE", "PROT_READ",
"PROT_WRITE|PROT_EXEC", "PROT_NONE"};
int prots[4] = {PROT_READ|PROT_WRITE, PROT_READ,
PROT_WRITE|PROT_EXEC, PROT_NONE};
printf("%s():\n", __FUNCTION__);
memset(okornot, 0, sizeof(okornot));
/* Loop over each page, setting protections as required */
for (i=0; i<4; i++) {
start_off = (u64)ptr+(i*gPgsz);
printf("page %d: protections: %30s: "
"range [0x%llx:0x%llx]\n",
i, str_prots[i], start_off, start_off+gPgsz-1);
if (mprotect((void *)start_off, gPgsz, prots[i]) == -1)
WARN("mprotect(%s) failed\n", str_prots[i]);
else
okornot[i] = 1;
}
}
设置完内存保护后,main()函数调用内存测试函数test_mem。第二个参数确定我们是否尝试在只读内存上进行写入(我们需要这个测试用例来测试第 1 页,因为它是只读保护的):
static void test_mem(void *ptr, int write_on_ro_mem)
{
int byte = random() % gPgsz;
char *start_off;
printf("\n----- %s() -----\n", __FUNCTION__);
/* Page 0 : rw [default] mem protection */
if (okornot[0] == 1) {
start_off = (char *)ptr + 0*gPgsz + byte;
TEST_WRITE(0, start_off, 'a');
TEST_READ(0, start_off);
} else
printf("*** Page 0 : skipping tests as memprot failed...\n");
/* Page 1 : ro mem protection */
if (okornot[1] == 1) {
start_off = (char *)ptr + 1*gPgsz + byte;
TEST_READ(1, start_off);
if (write_on_ro_mem == 1) {
TEST_WRITE(1, start_off, 'b');
}
} else
printf("*** Page 1 : skipping tests as memprot failed...\n");
/* Page 2 : RWX mem protection */
if (okornot[2] == 1) {
start_off = (char *)ptr + 2*gPgsz + byte;
TEST_READ(2, start_off);
TEST_WRITE(2, start_off, 'c');
} else
printf("*** Page 2 : skipping tests as memprot failed...\n");
/* Page 3 : 'NONE' mem protection */
if (okornot[3] == 1) {
start_off = (char *)ptr + 3*gPgsz + byte;
TEST_READ(3, start_off);
TEST_WRITE(3, start_off, 'd');
} else
printf("*** Page 3 : skipping tests as memprot failed...\n");
}
在尝试测试之前,我们检查页面是否确实已经被mprotect调用(通过我们简单的okornot[]数组)。另外,为了便于阅读,我们构建了简单的TEST_READ和TEST_WRITE宏:
#define TEST_READ(pgnum, addr) do { \
printf("page %d: reading: byte @ 0x%llx is ", \
pgnum, (u64)addr); \
fflush(stdout); \
printf(" %x", *addr); \
printf(" [OK]\n"); \
} while (0)
#define TEST_WRITE(pgnum, addr, byte) do { \
printf("page %d: writing: byte '%c' to address 0x%llx now ...", \
pgnum, byte, (u64)addr); \
fflush(stdout); \
*addr = byte; \
printf(" [OK]\n"); \
} while (0)
如果进程违反了任何内存保护,操作系统将通过通常的segfault机制(在第十二章,第二部分信号*中有详细解释)立即终止它。
让我们在memprot程序上进行一些测试运行;首先(出于很快就会变得清楚的原因),我们将在一个通用的 Ubuntu Linux 系统上尝试它,然后在一个 Fedora 系统上,最后在一个(模拟的)ARM-32 平台上!
案例#1.1:在标准 Ubuntu 18.04 LTS 上使用参数 0 运行memprot程序(输出重新格式化以便阅读):
$ cat /etc/issue Ubuntu 18.04 LTS \n \l $ uname -r 4.15.0-23-generic $
$ ./memprot
Usage: ./memprot test-write-to-ro-mem [0|1]
$ ./memprot 0
----- protect_mem() -----
page 0: protections: PROT_READ|PROT_WRITE: range [0x55796ccd5000:0x55796ccd5fff]
page 1: protections: PROT_READ: range [0x55796ccd6000:0x55796ccd6fff]
page 2: protections: PROT_READ|PROT_WRITE|PROT_EXEC: range [0x55796ccd7000:0x55796ccd7fff]
page 3: protections: PROT_NONE: range [0x55796ccd8000:0x55796ccd8fff]
----- test_mem() -----
page 0: writing: byte 'a' to address 0x55796ccd5567 now ... [OK]
page 0: reading: byte @ 0x55796ccd5567 is 61 [OK]
page 1: reading: byte @ 0x55796ccd6567 is 0 [OK]
page 2: reading: byte @ 0x55796ccd7567 is 0 [OK]
page 2: writing: byte 'c' to address 0x55796ccd7567 now ... [OK]
page 3: reading: byte @ 0x55796ccd8567 is Segmentation fault
$
好吧,memprot的参数是0或1;0表示我们不执行写入只读内存的测试,而1表示我们执行。这里,我们使用了0参数来运行它。
在前面的输出中需要注意的一些事情如下:
-
protect_mem()函数按页设置内存保护。我们已经分配了 4 页,因此我们循环 4 次,并在每次循环迭代i上执行mprotect(2)。 -
正如您在代码中清楚地看到的那样,它是以这种方式完成的,每次循环迭代
-
页面
0:rw-:将页面保护设置为PROT_READ | PROT_WRITE -
页面
1:r--:将页面保护设置为PROT_READ -
页面
2:rwx:将页面保护设置为PROT_READ| PROT_WRITE | PROT_EXEC -
页面
3:---:将页面保护设置为PROT_NONE,即使页面无法访问 -
在上面的输出中,mprotect之后显示的输出格式如下:
page <#>: protections: <PROT_xx|[...]> range [<start_addr>:<end_addr>]
-
一切顺利;四个页面都得到了所需的新保护。
-
接下来,调用
test_mem()函数,该函数测试每个页面的保护(页面的内存保护以通常的[rwx]格式显示在方括号内): -
在页面 0 [默认:
rw-]上:它在页面内写入和读取一个随机字节 -
在页面 1 [
r--]上:它在页面内读取一个随机字节,如果用户将参数传递为1(这里不是这种情况,但在下一个案例中会是这种情况),它会尝试在该页面内写入一个随机字节 -
在页面 2 [
rwx]上:如预期的那样,读取和写入一个随机字节成功 -
在页面 3 [
---]上:它尝试在页面内部读取和写入一个随机字节。 -
第一次访问-一个读取-失败了,出现了段错误;这当然是预期的-页面根本没有任何权限(我们为这种情况重现了输出):
**page 3: reading: byte @ 0x55796ccd8567 is Segmentation fault** -
总之,参数为
0时,页面 0、1 和 2 的测试用例都成功了;如预期的那样,对页面 3 的任何访问都会导致操作系统终止进程(通过分段违规信号)。
案例#1.2:在标准 Ubuntu 18.04 LTS 上使用参数 1 运行memprot程序(输出重新格式化以便阅读)。
现在让我们将参数设置为1重新运行程序,因此尝试写入只读页面1:
$ ./memprot 1 ----- protect_mem() -----
page 0: protections: PROT_READ|PROT_WRITE: range [0x564d74f2d000:0x564d74f2dfff]
page 1: protections: PROT_READ: range [0x564d74f2e000:0x564d74f2efff]
page 2: protections: PROT_READ|PROT_WRITE|PROT_EXEC: range [0x564d74f2f000:0x564d74f2ffff]
page 3: protections: PROT_NONE: range [0x564d74f30000:0x564d74f30fff]
----- test_mem() -----
page 0: writing: byte 'a' to address 0x564d74f2d567 now ... [OK]
page 0: reading: byte @ 0x564d74f2d567 is 61 [OK]
page 1: reading: byte @ 0x564d74f2e567 is 0 [OK]
page 1: writing: byte 'b' to address 0x564d74f2e567 now ...Segmentation fault
$
确实,当违反只读页面权限时,它会段错误。
案例#2:在标准* Fedora 28 *系统上的memprot程序。
在撰写本书时,最新和最伟大的* Fedora *工作站发行版是版本 28:
$ lsb_release -a
LSB Version: :core-4.1-amd64:core-4.1-noarch
Distributor ID: Fedora
Description: Fedora release 28 (Twenty Eight)
Release: 28
Codename: TwentyEight
$ uname -r
4.16.13-300.fc28.x86_64
$
我们在标准* Fedora 28 *工作站系统上构建和运行我们的memprot程序(将0作为参数传递-这意味着我们不尝试写入只读内存页):
$ ./memprot 0
----- protect_mem() -----
page 0: protections: PROT_READ|PROT_WRITE: range [0x15d8000:0x15d8fff]
page 1: protections: PROT_READ: range [0x15d9000:0x15d9fff]
page 2: protections: PROT_READ|PROT_WRITE|PROT_EXEC: range [0x15da000:0x15dafff]
!WARNING! memprot.c:protect_mem:112:
mprotect(PROT_READ|PROT_WRITE|PROT_EXEC) failed
perror says: Permission denied
page 3: protections: PROT_NONE: range [0x15db000:0x15dbfff]
----- test_mem() -----
page 0: writing: byte 'a' to address 0x15d8567 now ... [OK]
page 0: reading: byte @ 0x15d8567 is 61 [OK]
page 1: reading: byte @ 0x15d9567 is 0 [OK]
*** Page 2 : skipping tests as memprot failed...
page 3: reading: byte @ 0x15db567 is Segmentation fault (core dumped)
$
我们如何解释上面的输出?以下是相同的解释:
-
页面 0、1 和 3 都很好:mprotect API 成功地设置了页面的保护,就像所示的那样
-
然而,当我们尝试在页面 2 上使用
PROT_READ | PROT_WRITE | PROT_EXEC属性进行mprotect(2)系统调用时,我们会收到失败(和警告消息)。为什么? -
通常的操作系统安全是自主访问控制(DAC)层。许多现代 Linux 发行版,包括 Fedora,都配备了一个强大的安全功能-操作系统内部的额外安全层-强制访问控制(MAC)层。这些在 Linux 上实现为Linux 安全模块(LSM)。流行的 LSM 包括 NSA 的 SELinux(安全增强型 Linux),AppArmor,Smack,TOMOYO 和 Yama。
-
Fedora 使用 SELinux,而 Ubuntu 变体倾向于使用 AppArmor。无论哪种情况,通常这些 LSM 在违反安全策略时可能会失败用户空间发出的系统调用。这正是我们的 mprotect(2)系统调用在第三页上发生的情况(当尝试将页面保护设置为[
rwx]时)! -
作为一个快速的概念验证,并且为了让它现在正常工作,我们暂时禁用 SELinux并重试:
$ getenforce
Enforcing
$ setenforce
usage: setenforce [ Enforcing | Permissive | 1 | 0 ]
$ sudo setenforce 0
[sudo] password for <username>: xxx
$ getenforce
Permissive
$
SELinux现在处于宽容模式;重试应用程序:
$ ./memprot 0
----- protect_mem() -----
page 0: protections: PROT_READ|PROT_WRITE: range [0x118e000:0x118efff]
page 1: protections: PROT_READ: range [0x118f000:0x118ffff]
page 2: protections: PROT_READ|PROT_WRITE|PROT_EXEC: range [0x1190000:0x1190fff]
page 3: protections: PROT_NONE: range [0x1191000:0x1191fff]
----- test_mem() -----
page 0: writing: byte 'a' to address 0x118e567 now ... [OK]
page 0: reading: byte @ 0x118e567 is 61 [OK]
page 1: reading: byte @ 0x118f567 is 0 [OK]
page 2: reading: byte @ 0x1190567 is 0 [OK]
page 2: writing: byte 'c' to address 0x1190567 now ... [OK]
page 3: reading: byte @ 0x1191567 is Segmentation fault (core dumped)
$
现在它按预期工作!不要忘记重新启用 LSM:
$ sudo setenforce 1
$ getenforce
Enforcing
$
一旁-LSM 日志,Ftrace
(如果您对此不感兴趣,请随意跳过此部分)。敏锐的读者可能会想知道:如何意识到是操作系统安全层(LSM)最终导致了系统调用失败?大体上有两种方法:检查给定的 LSM 日志,或使用内核的Ftrace功能。第一种方法更简单,但第二种方法可以让我们在操作系统的层面上获得洞察。
LSM 日志
现代 Linux 系统使用强大的 systemd 框架进行进程初始化、日志记录等。日志记录设施称为 journal,并通过journalctl(1)实用程序访问。我们使用它来验证确实是 SELinux LSM 导致了问题:
$ journalctl --boot | grep memprot
[...]
<timestamp> <host> python3[31861]: SELinux is preventing memprot from using the execheap access on a process.
If you do not think memprot should need to map heap memory that is both writable and executable.
If you believe that memprot should be allowed execheap access on processes labeled unconfined_t by default.
# ausearch -c 'memprot' --raw | audit2allow -M my-memprot
# semodule -X 300 -i my-memprot.pp
它甚至向我们显示了如何允许访问。
Ftrace
Linux 内核有一个非常强大的内置跟踪机制(它是其中之一)- Ftrace。使用ftrace,您可以验证确实是LSM代码,尽管遵守其安全策略,导致用户空间发出的系统调用返回失败。我运行了一个跟踪(使用ftrace):

ftrace 输出片段
SyS_mprotect函数是内核中的mprotect(2)系统调用;security_file_mprotect是导致实际的 SELinux 函数selinux_file_mprotect的 LSM 挂钩函数;显然,它拒绝了访问。
有趣的是,Ubuntu 18.04 LTS 也使用了 LSM-AppArmor。然而,似乎它没有配置来捕获这种write+execute(堆)页面保护情况。
当然,这些主题(LSMs,ftrace)超出了本书的范围。对于好奇的读者(我们喜欢的那种),请在 GitHub 存储库的进一步阅读部分查看更多关于LSMs和Ftrace的内容。
一个实验-在 ARM-32 上运行 memprot 程序
作为一个有趣的实验,我们将为ARM 系统交叉编译我们之前的memprot程序。我使用了一种方便的方法来做到这一点,而不需要真正的硬件:使用强大的自由开源软件(FOSS)Quick Emulator(QEMU)项目,来模拟 ARM Versatile Express Cortex-A9 平台!
交叉编译代码确实很简单:请注意,我们的Makefile中现在有一个CROSS_COMPILE变量;它是交叉编译器的前缀-用于标识工具链的前缀字符串(所有工具通用)。它实际上是添加到CC(对于gcc或CL对于 clang)变量的前缀,这是用于构建目标的编译器。不幸的是,更详细地讨论交叉编译和根文件系统构建超出了本书的范围;有关帮助,请参阅本示例输出后面的提示。此外,为了保持简单,我们将使用直接的方法-在Makefile中为 ARM 版本设置一个单独的目标。让我们来看看Makefile的相关部分:
$ cat Makefile
[...]
CROSS_COMPILE=arm-linux-gnueabihf-
CC=gcc
CCARM=${CROSS_COMPILE}gcc
[...]
common_arm.o: ../common.c ../common.h
${CCARM} ${CFLAGS} -c ../common.c -o common_arm.o
memprot_arm: common_arm.o memprot_arm.o
${CCARM} ${CFLAGS} -o memprot_arm memprot_arm.c common_arm.o
[...]
因此,如图所示,我们交叉编译memprot_arm程序:
$ make clean [...] $ make memprot_arm
arm-linux-gnueabihf-gcc -Wall -c ../common.c -o common_arm.o gcc -Wall -c -o memprot_arm.o memprot_arm.c arm-linux-gnueabihf-gcc -Wall -o memprot_arm memprot_arm.c common_arm.o $ file ./memprot_arm ./memprot_arm: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 3.2.0, BuildID[sha1]=3c720<...>, with debug_info, not stripped $
啊哈,它生成了一个 ARM 可执行文件!我们将其复制到我们的嵌入式根文件系统中,启动(模拟的)ARM 板,并尝试运行它:
$ qemu-system-arm -m 512 -M vexpress-a9 \
-kernel <...>/images/zImage \
-drive file=<...>/images/rfs.img,if=sd,format=raw \
-append \
"console=ttyAMA0 rootfstype=ext4 root=/dev/mmcblk0 init=/sbin/init " \
-nographic -dtb <...>/images/vexpress-v2p-ca9.dtb
[...]
Booting Linux on physical CPU 0x0
Linux version 4.9.1-crk (xxx@yyy) (gcc version 4.8.3 20140320 (prerelease) (Sourcery CodeBench Lite 2014.05-29) ) #16 SMP Wed Jan 24 10:09:17 IST 2018
CPU: ARMv7 Processor [410fc090] revision 0 (ARMv7), cr=10c5387d
CPU: PIPT / VIPT nonaliasing data cache, VIPT nonaliasing instruction cache
[...]
smsc911x 4e000000.ethernet eth0: SMSC911x/921x identified at 0xa1290000, IRQ: 31
/bin/sh: can't access tty; job control turned off
ARM / $
我们在(模拟的)ARM-32 系统提示符上;让我们尝试运行我们的程序:
ARM # ./memprot_arm Usage: ./memprot_arm test-write-to-ro-mem [0|1] ARM # ./memprot_arm 0 ----- protect_mem() -----
page 0: protections: PROT_READ|PROT_WRITE: range [0x24000, 0x24fff]
page 1: protections: PROT_READ: range [0x25000, 0x25fff]
page 2: protections: PROT_READ|PROT_WRITE|PROT_EXEC: range [0x26000, 0x26fff]
page 3: protections: PROT_NONE: range [0x27000, 0x27fff]
----- test_mem() -----
page 0: writing: byte 'a' to address 0x24567 now ... [OK]
page 0: reading: byte @ 0x24567 is 61 [OK]
page 1: reading: byte @ 0x25567 is 0 [OK]
page 2: reading: byte @ 0x26567 is 0 [OK]
page 2: writing: byte 'c' to address 0x26567 now ... [OK]
page 3: reading: byte @ 0x27567 is Segmentation fault (core dumped)
ARM #
读者会注意到,与我们之前在 x86_64 系统上运行的Fedora 28发行版不同,我们尝试将第 2 页的内存保护设置为[rwx]的测试用例(用粗体标出)确实成功了!当然,没有安装 LSM。
如果您想尝试类似的实验,在模拟的 ARM-32 上运行代码,请考虑使用Simple Embedded ARM Linux System(SEALS)项目,再次纯开源,轻松构建一个非常简单但有效的 ARM/Linux 嵌入式系统:github.com/kaiwan/seals。
类似的内存保护-在一段内存上设置保护属性(rwx 或无)-可以通过强大的mmap(2)系统调用实现(我们在第十八章中涵盖了关于文件 I/O 的mmap(2))。
内存保护密钥 - 简要说明
最近的英特尔 64 位处理器引入了一个名为内存保护密钥(MPK)的功能。简而言之,MPK(或在 Linux 上称为pkeys)允许用户空间以页面粒度设置权限。因此,如果它和mprotect或mmap做同样的事情,它带来了什么好处?请看以下内容:
-
这是一个硬件特性,因此将大量页面(比如说,几十亿字节的内存)设置为某些特定的内存权限将比
mprotect(2)快得多;这对某些类型的应用程序很重要 -
应用程序(例如内存数据库)可以通过在绝对需要之前关闭内存区域的写入来受益,减少了错误写入。
如何利用 MPK?首先要知道的是,它目前只在最近的 Linux 内核和 x86_64 处理器架构上实现。要使用它,阅读关于pkeys的 man 页面(第七部分);它有解释说明以及示例代码:man7.org/linux/man-pages/man7/pkeys.7.html。
使用 alloca 来分配自动内存
glibc 库提供了一个动态内存分配的替代方案,使用 malloc(和其他函数);alloca(3) API。
alloca 可以被认为是一种便利程序:它在堆栈上分配内存(在调用它的函数内)。展示特点是不需要free,并且一旦函数返回,内存就会自动释放。实际上,不能调用free(3)。这是有道理的:在堆栈上分配的内存称为自动内存 - 在函数返回时将被释放。
像往常一样,使用alloca(3)有好处和坏处 - 折衷:
以下是alloca(3)的优点:
-
不需要释放;这可以使编程、可读性和可维护性更简单。因此,我们可以避免危险的内存泄漏 bug - 这是一个重大的收获!
-
它被认为非常快,内部碎片为零。
-
使用它的主要原因:有时,程序员使用非局部退出,通常通过
longjmp(3)和siglongjmp(3)的 API。如果程序员使用malloc(3)来分配内存区域,然后突然通过非局部退出离开函数,将会发生内存泄漏。使用alloca将防止这种情况发生,并且代码易于实现和理解。
以下是 alloca 的缺点:
-
alloca 的主要缺点是,当传递一个足够大的值导致堆栈溢出时,不能保证它返回失败;因此,如果在运行时实际发生了这种情况,进程现在处于未定义行为(UB)状态,并且(最终)会崩溃。换句话说,检查
alloca是否返回 NULL,就像你在malloc(3)系列中所做的那样,是没有用的! -
可移植性并非一定存在。
-
通常,alloca 被实现为内联函数;这可以防止它被第三方库覆盖。
看一下以下代码(ch4/alloca_try.c):
[...]
static void try_alloca(const char *csz, int do_the_memset)
{
size_t sz = atol(csz);
void *aptr;
aptr = alloca(sz);
if (!aptr)
FATAL("alloca(%zu) failed\n", sz);
if (1 == do_the_memset)
memset(aptr, 'a', sz);
/* Must _not_ call free(), just return;
* the memory is auto-deallocated!
*/
}
int main(int argc, char **argv)
{
[...]
if (atoi(argv[2]) == 1)
try_alloca(argv[1], 1);
else if (atoi(argv[2]) == 0)
try_alloca(argv[1], 0);
else {
fprintf(stderr, "Usage: %s size-to-alloca do_the_memset[1|0]\n",
argv[0]);
exit(EXIT_FAILURE);
}
exit (EXIT_SUCCESS);
}
让我们构建它并试一试:
$ ./alloca_try
Usage: ./alloca_try size-to-alloca do_the_memset[1|0]
$ ./alloca_try 50000 1
$ ./alloca_try 50000 0
$
alloca_try的第一个参数是要分配的内存量(以字节为单位),而第二个参数,如果为1,则在该内存区域上调用memset;如果为0,则不调用。
在前面的代码片段中,我们尝试了一个 50,000 字节的分配请求 - 对于memset的两种情况都成功了。
现在,我们故意将-1作为第一个参数传递,这将被视为一个无符号数量(因此在 64 位操作系统上变为巨大的值0xffffffffffffffff!),这当然应该导致alloca(3)失败。令人惊讶的是,它并没有报告失败;至少它认为一切都很好:
$ ./alloca_try -1 0
$ echo $?
0
$ ./alloca_try -1 1
Segmentation fault (core dumped)
$
但是,通过进行memset(将第二个参数传递为1)会导致错误出现;没有它,我们将永远不会知道。
为了进一步验证这一点,尝试在库调用跟踪软件ltrace的控制下运行程序;我们将1作为第一个参数传递,强制进程在alloca(3)之后调用memset:
$ ltrace ./alloca_try -1 1
atoi(0x7ffcd6c3e0c9, 0x7ffcd6c3d868, 0x7ffcd6c3d888, 0) = 1
atol(0x7ffcd6c3e0c6, 1, 0, 0x1999999999999999) = -1
memset(0x7ffcd6c3d730, 'a', -1 <no return ...>
--- SIGSEGV (Segmentation fault) ---
+++ killed by SIGSEGV +++
$
啊哈!我们可以看到,在 memset 之后,进程接收到致命信号并死亡。但为什么alloca(3) API 没有出现在ltrace中呢?因为它是一个内联函数-咳咳,它的缺点之一。
但是请看;在这里,我们将0作为第一个参数传递,绕过了alloca(3)之后对 memset 的调用:
$ ltrace ./alloca_try -1 0
atoi(0x7fff9495b0c9, 0x7fff94959728, 0x7fff94959748, 0) = 0
atoi(0x7fff9495b0c9, 0x7fff9495b0c9, 0, 0x1999999999999999) = 0
atol(0x7fff9495b0c6, 0, 0, 0x1999999999999999) = -1
exit(0 <no return ...>
+++ exited (status 0) +++
$
它正常退出,就好像没有错误一样!
此外,你会回忆起第三章中所述,进程的默认堆栈大小为 8 MB。我们可以通过我们的alloca_try程序来测试这个事实:
$ ./alloca_try 8000000 1
$ ./alloca_try 8400000 1
Segmentation fault (core dumped)
$ ulimit -s
8192
$
一旦超过 8 MB,alloca(3)分配了太多的空间,但并不会触发崩溃;相反,memset(3)导致段错误发生。此外,ulimit 验证了堆栈资源限制为 8,192 KB,即 8 MB。
总之,一个非常非常关键的观点:你经常会发现自己编写的软件看起来是正确的,但实际上并不是。唯一获得软件信心的方法是费力地进行 100%的代码覆盖,并对其运行测试用例!这很难做到,但质量很重要。只管去做吧。
总结
本章重点介绍了 Linux 操作系统上 C 应用程序开发人员动态内存管理的简单和更高级的方面。在初始部分,讨论了基本的 glibc 动态内存管理 API 及其在代码中的正确使用。
然后,我们转向更高级的主题,如程序断点(和sbrk(3)API)、malloc(3)在分配不同大小的内存时的内部行为,以及需求分页的关键概念。然后,我们深入研究了执行内存锁定和内存区域保护的 API 以及使用它们的原因。最后,我们看了alloca(3),这个替代 API。使用了几个代码示例来巩固所学的概念。下一章将涵盖一个非常重要的主题-由于内存 API 的不良编程实践而在 Linux 上可能出现的各种内存问题(缺陷)。
第五章:Linux 内存问题
一个简单的真理:内存问题存在。我们使用 C(和 C++)等语言编程的事实本身就隐含着无限类型的问题!在某个时候,人们意识到(或许有点悲观地认识到),在一个受管理的内存安全语言中小心编程最终是避免内存问题的(唯一?)现实方式。
然而,在这里,我们正在使用我们选择的强大工具:卓越而古老的 C 编程语言!因此,我们可以做些什么来减轻,如果不能消除,常见的内存问题,这就是本章的主题。最终,目标是真正的内存安全;好吧,说起来容易做起来难!
尽管如此,我们将尝试通过阐明开发人员可能会遇到的常见内存问题,成功地完成这项任务。在接下来的章节中,我们将探讨一些强大的内存调试工具如何在这方面提供巨大帮助。
在本章中,开发人员将了解到,尽管动态内存管理 API(在第四章,动态内存分配中涵盖)很少,但当使用不慎时,它们可能会引起看似无穷无尽的麻烦和错误!
具体来说,本章将阐明导致现场软件中难以检测的错误的常见内存问题:
-
不正确的内存访问问题(其中有几种类型)
-
内存泄漏
-
未定义行为
常见内存问题
如果要对细粒度的内存错误进行分类(通常是由 C 或 C++编程引起的),将会很困难——存在数百种类型!相反,让我们把讨论控制在可管理的范围内,看看什么被认为是我们这些可怜的 C 程序员经常遭遇的典型或常见内存错误:
-
不正确的内存访问
-
使用未初始化的变量
-
越界内存访问(读/写下溢/溢出错误)
-
释放后使用/返回后使用(超出范围)错误
-
双重释放
-
泄漏
-
未定义行为(UB)
-
数据竞争
-
碎片化(内部实现)问题
-
内部
-
外部
所有这些常见的内存问题(除了碎片化)都被归类为 UB;尽管如此,我们将 UB 作为一个单独的条目,因为我们将更深入地探讨它。此外,虽然人们口头上使用bug这个词,但一个人应该真正(并更正确地)将其视为defect。
我们在本章不涵盖数据竞争(请等到第十五章,使用 Pthreads 的多线程 Part II - 同步)。
为了帮助测试这些内存问题,membugs程序是每个问题的一系列小测试用例。
侧边栏 :: Clang 编译器
LLVM/Clang 是一个用于 C 的开源编译器。我们确实使用 Clang 编译器,特别是在本章和下一章中,特别是在下一章中涵盖的 sanitizer 编译器工具集。它在整本书中都很有用(事实上,在我们的许多 Makefile 中都使用它),因此在 Linux 开发系统上安装 Clang 是一个好主意!再次强调,这并非完全必要,人们也可以继续使用熟悉的 GCC——只要愿意在必要时编辑 Makefile(s)以切换回 GCC!
在 Ubuntu 18.04 LTS 桌面上安装 Clang 很容易:sudo apt install clang
Clang 文档可以在clang.llvm.org/docs/index.html找到。
当编译membugs程序(使用 GCC 进行正常情况以及使用 Clang 编译器进行 sanitizer 变体)时,你会看到大量的编译器警告被发出!这是预期的;毕竟,它的代码充满了错误。放松心情,继续阅读。
此外,我们提醒您,本章的目的是了解(和分类)典型的 Linux 内存问题;使用强大的工具来识别和修复它们是下一章的主题。两者都是必需的,所以请继续阅读。
构建的一些示例输出如下所示(为了可读性,输出被剪切了)。现在,我们不会尝试分析它;这将在我们通过本章时发生(记住,您也需要安装 Clang!):
$ make
gcc -Wall -c ../common.c -o common.o
gcc -Wall -c membugs.c -o membugs.o
membugs.c: In function ‘uar’:
membugs.c:143:9: warning: function returns address of local variable [-Wreturn-local-addr]
return name;
^~~~
[...]
gcc -Wall -o membugs membugs.o common.o
[...]
clang -g -ggdb -gdwarf-4 -O0 -Wall -Wextra -fsanitize=address -c membugs.c -o membugs_dbg_asan.o
membugs.c:143:9: warning: address of stack memory associated with local variable 'name' returned [-Wreturn-stack-address]
return name;
^~~~
gcc -g -ggdb -gdwarf-4 -O0 -Wall -Wextra -o membugs_dbg membugs_dbg.o common_dbg.o
[...]
$
我们还强调,在我们将运行的所有测试案例中,我们使用由 GCC 生成的membugs二进制可执行文件(而不是 Clang;我们将在后面使用 sanitizer 工具时使用 Clang)。
在构建过程中,可以将所有输出捕获到文件中,如下所示:
make >build.txt 2>&1
使用--help开关运行membugs程序以查看所有可用的测试案例:
$ ./membugs --help
Usage: ./membugs test_case [ -h | --help]
test case 1 : uninitialized var test case
test case 2 : out-of-bounds : write overflow [on compile-time memory]
test case 3 : out-of-bounds : write overflow [on dynamic memory]
test case 4 : out-of-bounds : write underflow
test case 5 : out-of-bounds : read overflow [on compile-time memory]
test case 6 : out-of-bounds : read overflow [on dynamic memory]
test case 7 : out-of-bounds : read underflow
test case 8 : UAF (use-after-free) test case
test case 9 : UAR (use-after-return) test case
test case 10 : double-free test case
test case 11 : memory leak test case 1: simple leak
test case 12 : memory leak test case 2: leak more (in a loop)
test case 13 : memory leak test case 3: "lib" API leak
-h | --help : show this help screen
$
您将注意到写入和读取上溢各有两个测试案例:一个是在编译时内存上,一个是在动态分配的内存上。区分这些情况很重要,因为工具在检测哪些类型的缺陷时有所不同。
不正确的内存访问
通常,这个类别中的错误和问题是如此常见,以至于被轻率地忽视!请注意,它们仍然非常危险;请注意找到、理解和修复它们。
所有内存缓冲区上溢和下溢错误的类别都经过仔细记录和跟踪,通过通用漏洞和暴露(CVE)和通用弱点枚举(CWE)网站。与我们讨论的相关的是,CWE-119 是内存缓冲区边界内操作的不当限制(cwe.mitre.org/data/definitions/119.html)。
访问和/或使用未初始化的变量
为了让读者对这些内存问题的严重性有所了解,我们编写了一个测试程序membugs.c。这个测试程序允许用户测试各种常见的内存错误,这将帮助他们更好地理解潜在的问题。
每个内存错误测试案例都被赋予一个测试案例编号。这样读者可以很容易地跟随源代码和解释材料,我们也会指定测试案例如下。
测试案例 1:未初始化内存访问
这些也被称为未初始化内存读取(UMR)错误。一个经典案例:本地(或自动)变量根据定义是未初始化的(不像全局变量,它们总是预设为零):
/* test case 1 : uninitialized var test case */
static void uninit_var()
{
int x; /* static mem */
if (x)
printf("true case: x=%d\n", x);
else
printf("false case\n");
}
在前面的代码中,由于x未初始化并且将具有随机内容,因此在运行时会发生未定义的情况。现在,我们按以下方式运行这个测试案例:
$ ./membugs 1
true case: x=32604
$ ./membugs 1
true case: x=32611
$ ./membugs 1
true case: x=32627
$ ./membugs 1
true case: x=32709
$
值得庆幸的是,现代版本的编译器(gcc和clang)会对这个问题发出警告:
$ make
[...]
gcc -Wall -c membugs.c -o membugs.o
[...]
membugs.c: In function ‘uninit_var’:
membugs.c:272:5: warning: ‘x’ is used uninitialized in this function [-Wuninitialized]
if (x)
^
[...]
clang -g -ggdb -gdwarf-4 -O0 -Wall -Wextra -fsanitize=address -c membugs.c -o membugs_dbg_asan.o
[...]
membugs.c:272:6: warning: variable 'x' is uninitialized when used here [-Wuninitialized]
if (x)
^
membugs.c:270:7: note: initialize the variable 'x' to silence this warning
int x; /* static mem */
^
= 0
[...]
越界内存访问
这个类别再次属于更常见但致命的内存访问错误。它们可以被分类为不同类型的错误:
-
写入上溢:尝试向内存缓冲区的最后一个合法可访问位置之后写入的错误
-
写入下溢:在第一个合法可访问位置之前尝试向内存缓冲区写入
-
读取下溢:在第一个合法可访问位置之前尝试读取内存缓冲区的错误
-
读取上溢:在第一个合法可访问位置之后尝试读取内存缓冲区的错误
让我们通过我们的membugs.c程序的源代码来检查这些。
测试案例 2
编写或缓冲区溢出在编译时分配的内存。请参见以下代码片段:
/* test case 2 : out-of-bounds : write overflow [on compile-time memory] */
static void write_overflow_compilemem(void)
{
int i, arr[5], tmp[8];
for (i=0; i<=5; i++) {
arr[i] = 100; /* Bug: 'arr' overflows on i==5,
overwriting part of the 'tmp' variable
- a stack overflow! */
}
}
这导致了堆栈溢出(也称为堆栈破坏或缓冲区溢出(BOF))错误;这是一类严重的漏洞,攻击者已经成功地多次利用,从 1988 年的 Morris Worm 病毒开始!在 GitHub 存储库的进一步阅读部分中,了解更多关于这个漏洞的信息。
有趣的是,在我们的Fedora 28工作站 Linux 系统上编译和运行代码的这一部分(通过传递适当的参数),显示默认情况下既没有编译时也没有运行时检测到这种(和其他类似的)危险错误(稍后详细介绍!):
$ ./membugs 2
$ ./membugs_dbg 2
$
这些错误有时也被称为一次性错误。
当然还有更多(像往常一样);让我们进行一个快速实验。在membugs.c:write_overflow_compilemem()函数中,将我们循环的次数从 5 更改为 50:
for (i = 0; i <= 50; i++) {
arr[i] = 100;
}
重新构建并重试;现在在Ubuntu 18.04 LTS桌面 Linux 系统上查看输出(在 Fedora 上也是如此,但使用原始内核):
$ ./membugs 2
*** stack smashing detected ***: <unknown> terminated
Aborted
$
事实上,现代编译器使用堆栈保护功能来检测堆栈溢出错误,更重要的是,攻击。当值足够大时,溢出被检测到;但是使用默认值时,错误却未被检测到!我们强调在下一章中使用工具(包括编译器)来检测这些隐藏的错误的重要性。
测试案例 3
在动态分配的内存上写入或 BOF。请参阅以下代码片段:
/* test case 3 : out-of-bounds : write overflow [on dynamic memory] */
static void write_overflow_dynmem(void)
{
char *dest, src[] = "abcd56789";
dest = malloc(8);
if (!dest)
FATAL("malloc failed\n");
strcpy(dest, src); /* Bug: write overflow */
free(dest);
}
同样,没有发生错误的编译或运行时检测:
$ ./membugs 3
$ ./membugs 3 *<< try once more >>*
$
不幸的是,与 BOF 相关的错误和漏洞在行业中往往相当常见。根本原因并不为人所知,因此导致编写不良代码;这就是我们作为开发人员必须提高自己水平的地方!
有关安全漏洞的真实世界示例,请参阅 2017 年 Linux 上 52 个文档化的安全漏洞(由各种 BOF 错误引起)的表格:www.cvedetails.com/vulnerability-list/vendor_id-33/year-2017/opov-1/Linux.html。
测试案例 4
写入下溢。我们使用malloc(3)动态分配一个缓冲区,将指针减小,然后写入该内存位置——写入或缓冲区下溢错误:
/* test case 4 : out-of-bounds : write underflow */
static void write_underflow(void)
{
char *p = malloc(8);
if (!p)
FATAL("malloc failed\n");
p--;
strncpy(p, "abcd5678", 8); /* Bug: write underflow */
free(++p);
}
在这个测试案例中,我们不希望free(3)失败,所以我们确保传递给它的指针是正确的。编译器在这里没有检测到任何错误;尽管在运行时,现代的 glibc 确实会崩溃,检测到错误(在这种情况下是内存损坏):
$ ./membugs 4
double free or corruption (out)
Aborted
$
测试案例 5
读取溢出,编译时分配的内存。我们尝试在编译时分配的内存缓冲区的最后一个合法可访问位置之后进行读取:
/* test case 5 : out-of-bounds : read overflow [on compile-time memory] */
static void read_overflow_compilemem(void)
{
char arr[5], tmp[8];
memset(arr, 'a', 5);
memset(tmp, 't', 8);
tmp[7] = '\0';
printf("arr = %s\n", arr); /* Bug: read buffer overflow */
}
这个测试案例的设计方式是,我们在内存中顺序排列了两个缓冲区。错误在于:我们故意没有对第一个缓冲区进行空终止(但对第二个缓冲区进行了空终止),因此,printf(3)将会继续读取arr中的内容,直到tmp缓冲区。如果tmp缓冲区包含秘密呢?
当然,问题是编译器无法捕捉到这个看似明显的错误。还要意识到,这里我们编写的是小型、简单、易于阅读的测试案例;在一个有几百万行代码的真实项目中,这样的缺陷很容易被忽视。
以下是示例输出:
$ ./membugs 2>&1 | grep -w 5
option = 5 : out-of-bounds : read overflow [on compile-time memory]
$ ./membugs 5
arr = aaaaattttttt
$
嘿,我们读取了tmp的秘密内存。
实际上,诸如 ASan(地址消毒剂,在下一章中介绍)之类的工具将此错误分类为堆栈缓冲区溢出。
顺便说一句,在我们的Fedora 28工作站上,我们在这个测试案例中从第二个缓冲区中只得到了垃圾:
$ ./membugs 5
arr = aaaaa0<5=�
$ ./membugs 5
arr = aaaaa�:��
$
这向我们表明,这些错误可能会因编译器版本、glibc 版本和机器硬件的不同而表现出不同的特征。
一个始终有用的测试技术是尽可能在多种硬件/软件变体上运行测试案例。隐藏的错误可能会暴露出来!考虑到诸如字节序问题、编译器优化(填充、打包)和特定平台的对齐等情况。
测试案例 6
读取溢出,动态分配的内存。再次尝试读取;这次是在动态分配的内存缓冲区的最后一个合法可访问位置之后:
/* test case 6 : out-of-bounds : read overflow [on dynamic memory] */
static void read_overflow_dynmem(void)
{
char *arr;
arr = malloc(5);
if (!arr)
FATAL("malloc failed\n",);
memset(arr, 'a', 5);
/* Bug 1: Steal secrets via a buffer overread.
* Ensure the next few bytes are _not_ NULL.
* Ideally, this should be caught as a bug by the compiler,
* but isn't! (Tools do; seen later).
*/
arr[5] = 'S'; arr[6] = 'e'; arr[7] = 'c';
arr[8] = 'r'; arr[9] = 'e'; arr[10] = 'T';
printf("arr = %s\n", arr);
/* Bug 2, 3: more read buffer overflows */
printf("*(arr+100)=%d\n", *(arr+100));
printf("*(arr+10000)=%d\n", *(arr+10000));
free(arr);
}
测试案例与前一个测试案例(编译时内存的读取溢出)基本相同,只是我们动态分配了内存缓冲区,并且为了好玩插入了一些其他错误:
$ ./membugs 2>&1 |grep -w 6
option = 6 : out-of-bounds : read overflow [on dynamic memory]
$ ./membugs 6
arr = aaaaaSecreT
*(arr+100)=0
*(arr+10000)=0
$
嘿,妈妈,看!我们得到了秘密!
它甚至不会导致崩溃。乍一看,这样的错误可能看起来相当无害——但事实上,这是一个非常危险的错误!
著名的 OpenSSL Heartbleed 安全漏洞(CVE-2014-0160)是利用读取溢出的一个很好的例子,或者通常被称为缓冲区过读取漏洞。
简而言之,这个错误允许一个恶意客户端进程向 OpenSSL 服务器进程发出一个看似正确的请求;实际上,它可以请求并接收比应该允许的更多的内存,因为存在缓冲区过读取漏洞。实际上,这个错误使得攻击者可以轻松地绕过安全性并窃取秘密[heartbleed.com]。
如果感兴趣,在 GitHub 存储库的进一步阅读部分中找到更多信息。
测试案例 7
读取下溢。我们尝试在动态分配的内存缓冲区上进行读取,而在其第一个合法可访问的位置之前:
/* test case 7 : out-of-bounds : read underflow */
static void read_underflow(int cond)
{
char *dest, src[] = "abcd56789", *orig;
printf("%s(): cond %d\n", __FUNCTION__, cond);
dest = malloc(25);
if (!dest)
FATAL("malloc failed\n",);
orig = dest;
strncpy(dest, src, strlen(src));
if (cond) {
*(orig-1) = 'x';
dest --;
}
printf(" dest: %s\n", dest);
free(orig);
}
测试案例设计了一个运行时条件;我们两种方式测试它:
case 7:
read_underflow(0);
read_underflow(1);
break;
如果条件为真,则缓冲区指针会减少,从而导致后续printf的读取缓冲区下溢:
$ ./membugs 7
read_underflow(): cond 0
dest: abcd56789
read_underflow(): cond 1
dest: xabcd56789
double free or corruption (out)
Aborted (core dumped)
$
同样,glibc 通过显示双重释放或损坏来帮助我们——在这种情况下,它是内存损坏。
释放后使用/返回后使用错误
使用- 释放后使用(UAF)和返回后使用(UAR)是危险的、难以发现的错误。查看以下每个测试案例。
测试案例 8
释放后使用(UAF)。在释放内存指针后对其进行操作显然是一个错误,会导致 UB。这个指针有时被称为悬空指针。这里是一个快速测试案例:
/* test case 8 : UAF (use-after-free) test case */
static void uaf(void)
{
char *arr, *next;
char name[]="Hands-on Linux Sys Prg";
int n=512;
arr = malloc(n);
if (!arr)
FATAL("malloc failed\n");
memset(arr, 'a', n);
arr[n-1]='\0';
printf("%s():%d: arr = %p:%.*s\n", __FUNCTION__, __LINE__, arr,
32, arr);
next = malloc(n);
if (!next) {
free(arr);
FATAL("malloc failed\n");
}
free(arr);
strncpy(arr, name, strlen(name)); /* Bug: UAF */
printf("%s():%d: arr = %p:%.*s\n", __FUNCTION__, __LINE__, arr,
32, arr);
free(next);
}
同样,无论在编译时还是在运行时都无法检测到 UAF 错误,也不会导致崩溃:
$ ./membugs 2>&1 |grep -w 8
option = 8 : UAF (use-after-free) test case
$ ./membugs 8
uaf():158: arr = 0x558012280260:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
uaf():166: arr = 0x558012280260:Hands-on Linux Sys Prgaaaaaaaaaa
$
你注意到了巧妙的printf(3)格式说明符%.*s吗?这种格式用于打印特定长度的字符串(不需要终止空字符!)。首先,指定要打印的字节数,然后是字符串的指针。
测试案例 9
返回后使用(UAR)。另一个经典的错误,这个错误涉及将存储项(或指向它的指针)返回给调用函数。问题在于存储是局部的或自动的,因此一旦返回受影响,存储对象现在就超出了范围。
这里显示了一个经典的例子:我们为一个局部变量分配了32字节,初始化它,并将其返回给调用者:
/* test case 9 : UAR (use-after-return) test case */
static void * uar(void)
{
char name[32];
memset(name, 0, 32);
strncpy(name, "Hands-on Linux Sys Prg", 22);
return name;
}
这是调用者调用前面的错误函数的方式:
[...]
case 9:
res = uar();
printf("res: %s\n", (char *)res);
break;
[...]
当然,一旦uar()函数中的return语句生效,name变量就会自动超出范围!因此,指向它的指针是无效的,运行时会失败:
$ ./membugs 2>&1 |grep -w 9
option = 9 : UAR (use-after-return) test case
$ ./membugs 9
res: (null)
$
幸运的是,现代 GCC(我们使用的是 GCC ver 7.3.0)会警告我们这个常见的错误:
$ make membugs
gcc -Wall -c membugs.c -o membugs.o
membugs.c: In function ‘uar’:
membugs.c:143:9: warning: function returns address of local variable [-Wreturn-local-addr]
return name;
^~~~
[...]
如前所述(但值得重申),请注意并修复所有警告!
实际上,有时这个错误会被忽视——看起来它工作正常,没有错误。这是因为没有实际的保证在函数返回时立即销毁堆栈内存帧——内存和编译器优化可能会保留帧(通常是为了重用)。然而,这是一个危险的错误,必须修复!
在下一章中,我们将介绍一些内存调试工具。事实上,Valgrind 和 Sanitizer 工具都无法捕捉到这个可能致命的错误。但是,适当使用 ASan 工具集确实可以捕捉到 UAR!继续阅读。
测试案例 10
双重释放。一旦释放了malloc系列缓冲区,就不允许再使用该指针。尝试再次释放相同的指针(而不是通过malloc系列 API 之一再次分配内存)是一个错误:双重释放。它会导致堆损坏;攻击者经常利用这样的错误来造成拒绝服务(DoS)攻击或更糟糕的情况(权限提升)。
这是一个简单的测试案例:
/* test case 10 : double-free test case */
static void doublefree(int cond)
{
char *ptr;
char name[]="Hands-on Linux Sys Prg";
int n=512;
printf("%s(): cond %d\n", __FUNCTION__, cond);
ptr = malloc(n);
if (!ptr)
FATAL("malloc failed\n");
strncpy(ptr, name, strlen(name));
free(ptr);
if (cond) {
bogus = malloc(-1UL); /* will fail! */
if (!bogus) {
fprintf(stderr, "%s:%s:%d: malloc failed\n",
__FILE__, __FUNCTION__, __LINE__);
free(ptr); /* Bug: double-free */
exit(EXIT_FAILURE);
}
}
}
在前面的测试案例中,我们模拟了一个有趣且相当现实的场景:一个运行时条件(通过cond参数模拟)导致程序执行一个调用,让我们说,失败了——malloc(-1UL)几乎可以保证这种情况发生。
为什么?因为在 64 位操作系统上,-1UL = 0xffffffffffffffff = 18446744073709551615 字节 = 16 EB。这是 64 位虚拟地址空间的全部范围。
回到重点:在我们的 malloc 错误处理代码中,发生了一个错误的双重释放——之前释放的ptr指针——导致了双重释放错误。
真正的问题是,作为开发人员,我们经常不为错误处理代码路径编写(负面的)测试案例;然后一个缺陷就会逃脱检测进入现场:
$ ./membugs 10
doublefree(): cond 0
doublefree(): cond 1
membugs.c:doublefree:56: malloc failed
$
有趣的是,编译器确实警告我们关于错误(有缺陷)的第二次 malloc(但没有关于双重释放的警告!);请参见以下内容:
$ make
[...]
membugs.c: In function ‘doublefree’:
membugs.c:125:9: warning: argument 1 value ‘18446744073709551615’ exceeds maximum object size 9223372036854775807 [-Walloc-size-larger-than=]
bogus = malloc(-1UL); /* will fail! */
~~~~~~^~~~~~~~~~~~~~
In file included from membugs.c:18:0:
/usr/include/stdlib.h:539:14: note: in a call to allocation function ‘malloc’ declared here
extern void *malloc (size_t __size) __THROW __attribute_malloc__ __wur;
^~~~~~
[...]
为了强调检测和修复此类错误的重要性——记住,这只是一个例子——我们展示了国家漏洞数据库(NVD)在过去 3 年内(在此写作时)关于双重释放错误的一些信息:nvd.nist.gov/vuln/search/results?adv_search=false&form_type=basic&results_type=overview&search_type=last3years&query=double+free
在国家漏洞数据库(NVD)上执行的双重释放错误的搜索结果的部分截图(在此写作时)如下:

完整的截图没有在这里显示。
泄漏
动态内存的黄金法则是释放你分配的内存。
内存泄漏是用来描述未能释放内存的情况。程序员认为内存区域确实已经被释放了。但实际上没有——这就是错误。因此,这使得认为已释放的内存区域对进程和系统不可用;实际上,它是不可用的,尽管它本应该是可用的。
据说内存已经泄漏了。那么为什么程序员不能在代码的其他地方通过调用 free 来处理这个内存指针呢?这实际上是问题的关键:在典型情况下,由于代码的实现方式,基本上不可能重新访问泄漏的内存指针。
一个快速的测试案例将证明这一点。
amleaky函数被故意编写成每次调用时泄漏mem字节的内存——它的参数。
测试案例 11
内存泄漏 - 情况 1:(简单的)内存泄漏测试案例。请参见以下代码片段:
static const size_t BLK_1MB = 1024*1024;
[...]
static void amleaky(size_t mem)
{
char *ptr;
ptr = malloc(mem);
if (!ptr)
FATAL("malloc(%zu) failed\n", mem);
/* Do something with the memory region; else, the compiler
* might just optimize the whole thing away!
* ... and we won't 'see' the leak.
*/
memset(ptr, 0, mem);
/* Bug: no free, leakage */
}
[...]
/* test case 11 : memory leak test case 1: simple leak */
static void leakage_case1(size_t size)
{
printf("%s(): will now leak %zu bytes (%ld MB)\n",
__FUNCTION__, size, size/(1024*1024));
amleaky(size);
}
[...]
case 11:
leakage_case1(32);
leakage_case1(BLK_1MB);
break;
[...]
正如大家可以清楚地看到的,在amleaky函数中,ptr内存指针是一个局部变量,因此一旦我们从有缺陷的函数返回,它就会丢失;这使得以后无法释放它。还要注意——注释解释了它——我们需要memset来强制编译器生成代码并使用内存区域。
对前面测试案例的快速构建和执行将显示,再次没有明显的编译时或运行时检测到泄漏的发生:
$ ./membugs 2>&1 | grep "memory leak"
option = 11 : memory leak test case 1: simple leak
option = 12 : memory leak test case 2: leak more (in a loop)
option = 13 : memory leak test case 3: lib API leak
$ ./membugs 11
leakage_case1(): will now leak 32 bytes (0 MB)
leakage_case1(): will now leak 1048576 bytes (1 MB)
$
测试案例 12
内存泄漏情况 2 - 泄漏更多(在循环中)。很多时候,有缺陷的泄漏代码可能只会泄漏少量内存,几个字节。问题是,如果这个有泄漏的函数在进程执行期间被调用了数百次,甚至数千次,现在泄漏就变得显著了,但不幸的是,不会立即显现出来。
为了精确模拟这一点以及更多内容,我们执行两个测试案例(选项 12):
-
我们分配并泄漏了少量内存(32 字节),但在循环中重复了 10 万次(因此,是的,我们最终泄漏了超过 3 MB)。
-
我们在循环中分配并泄漏了大量内存(1 MB),循环了 12 次(因此,我们最终泄漏了 12 MB)。
以下是相关代码:
[...]
/* test case 12 : memory leak test case 2: leak in a loop */
static void leakage_case2(size_t size, unsigned int reps)
{
unsigned int i, threshold = 3*BLK_1MB;
double mem_leaked;
if (reps == 0)
reps = 1;
mem_leaked = size * reps;
printf("%s(): will now leak a total of %.0f bytes (%.2f MB)"
" [%zu bytes * %u loops]\n",
__FUNCTION__, mem_leaked, mem_leaked/(1024*1024),
size, reps);
if (mem_leaked >= threshold)
system("free|grep \"^Mem:\"");
for (i=0; i<reps; i++) {
if (i%10000 == 0)
printf("%s():%6d:malloc(%zu)\n", __FUNCTION__, i, size);
amleaky(size);
}
if (mem_leaked >= threshold)
system("free|grep \"^Mem:\""); printf("\n");
}
[...]
case 12:
leakage_case2(32, 100000);
leakage_case2(BLK_1MB, 12);
break;
[...]
这个逻辑确保在每 10,000 次循环迭代时才显示泄漏循环中的printf(3)。
另外,我们想要看看内存是否确实泄漏了。为了以一种近似的方式来做到这一点,我们使用free实用程序:
$ free
total used free shared buff/cache available
Mem: 16305508 5906672 348744 1171944 10050092 10248116
Swap: 8000508 0 8000508
$
free(1)实用程序以千字节为单位显示系统整体上当前(近似)使用的内存量、空闲内存量和可用内存量。它进一步将已使用的内存分为共享、缓冲/页面缓存;它还显示Swap分区统计信息。我们还应该注意,使用free(1)来检测内存泄漏的方法并不被认为是非常准确的;这最多是一种粗略的方法。操作系统报告的已使用内存、空闲内存、缓存等等可能会有所不同。对于我们的目的来说,这是可以接受的。
我们感兴趣的是Mem行和free列的交集;因此,我们可以看到在总共可用的 16 GB 内存(RAM)中,当前空闲的内存量约为 348744 KB ~= 340 MB。
我们可以快速尝试一个一行脚本,只显示感兴趣的区域——Mem行:
$ free | grep "^Mem:"
Mem: 16305508 5922772 336436 1165960 10046300 10237452
$
在Mem之后的第三列是free内存(有趣的是,它已经从上一次的输出中减少了;这并不重要)。
回到程序;我们使用system(3)库 API 在 C 程序中运行前面的管道化的 shell 命令(我们将在第十章中构建我们自己的system(3)API 的小型模拟,进程创建):
if (mem_leaked >= threshold) system("free|grep \"^Mem:\");
if语句确保只有在泄漏量大于等于 3 MB 时才会出现这个输出。
在执行后,这是输出:
$ ./membugs 12
leakage_case2(): will now leak a total of 3200000 bytes (3.05 MB)
[32 bytes * 100000 loops]
Mem: 16305508 5982408 297708 1149648 10025392 10194628
leakage_case2(): 0:malloc(32)
leakage_case2(): 10000:malloc(32)
leakage_case2(): 20000:malloc(32)
leakage_case2(): 30000:malloc(32)
leakage_case2(): 40000:malloc(32)
leakage_case2(): 50000:malloc(32)
leakage_case2(): 60000:malloc(32)
leakage_case2(): 70000:malloc(32)
leakage_case2(): 80000:malloc(32)
leakage_case2(): 90000:malloc(32)
Mem: 16305508 5986996 293120 1149648 10025392 10190040
leakage_case2(): will now leak a total of 12582912 bytes (12.00 MB)
[1048576 bytes * 12 loops]
Mem: 16305508 5987500 292616 1149648 10025392 10189536
leakage_case2(): 0:malloc(1048576)
Mem: 16305508 5999124 280992 1149648 10025392 10177912
$
我们看到两种情况正在执行;查看free列的值。我们将它们相减以查看泄漏的内存量:
-
我们在循环中分配并泄漏了一小部分内存(32 字节),但是循环了 100,000 次:
泄漏内存 = 297708 - 293120 = 4588 KB ~= 4.5 MB -
我们在循环中分配并泄漏了大量内存(1 MB),共 12 次:
泄漏内存 = 292616 - 280992 = 11624 KB ~= 11.4 MB
当然,要意识到一旦进程死掉,它的所有内存都会被释放回系统。这就是为什么我们在进程还活着的时候执行了这个一行脚本。
测试案例 13
复杂情况——包装器 API。有时,人们会原谅地认为所有程序员都被教导:在调用 malloc(或 calloc、realloc)之后,调用 free。malloc 和 free 是一对!这有多难?如果是这样,为什么会有这么多隐蔽的泄漏错误呢?
泄漏缺陷发生并且难以准确定位的一个关键原因是因为一些 API——通常是第三方库 API——可能在内部执行动态内存分配,并期望调用者释放内存。API(希望)会记录这一重要事实;但是谁(半开玩笑地)会去读文档呢?
这实际上是现实世界软件中的问题的关键所在;它很复杂,我们在大型复杂项目上工作。很容易忽略的一个事实是,底层 API 分配内存,调用者负责释放它。这种情况确实经常发生。
在复杂的代码库(尤其是那些有意大利面代码的代码库)中,深度嵌套的层次结构使代码纠缠在一起,要执行所需的清理工作,包括释放内存,在每种可能的错误情况下都变得特别困难。
Linux 内核社区提供了一种干净但颇具争议的方式来保持清理代码路径的干净和良好运行,即使用本地跳转来执行集中的错误处理!这确实有帮助。想要了解更多吗?查看www.kernel.org/doc/Documentation/process/coding-style.rst中的第七部分,函数的集中退出。
测试案例 13.1
这是一个简单的例子。让我们用以下测试案例代码来模拟这个:
/*
* A demo: this function allocates memory internally; the caller
* is responsible for freeing it!
*/
static void silly_getpath(char **ptr)
{
#include <linux/limits.h>
*ptr = malloc(PATH_MAX);
if (!ptr)
FATAL("malloc failed\n");
strcpy(*ptr, getenv("PATH"));
if (!*ptr)
FATAL("getenv failed\n");
}
/* test case 13 : memory leak test case 3: "lib" API leak */
static void leakage_case3(int cond)
{
char *mypath=NULL;
printf("\n## Leakage test: case 3: \"lib\" API"
": runtime cond = %d\n", cond);
/* Use C's illusory 'pass-by-reference' model */
silly_getpath(&mypath);
printf("mypath = %s\n", mypath);
if (cond) /* Bug: if cond==0 then we have a leak! */
free(mypath);
}
我们这样调用它:
[...]
case 13:
leakage_case3(0);
leakage_case3(1);
break;
和往常一样,没有编译器或运行时警告。这是输出(注意第一次调用是有 bug 的情况,因为cond的值为0,因此不会调用free(3)):
$ ./membugs 13
## Leakage test: case 3: "lib" API: runtime cond = 0
mypath = /usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/sbin:/usr/sbin:/usr/local/sbin:/home/kai/MentorGraphics/Sourcery_CodeBench_Lite_for_ARM_GNU_Linux/bin/:/mnt/big/scratchpad/buildroot-2017.08.1/output/host/bin/:/sbin:/usr/sbin:/usr/local/sbin
## Leakage test: case 3: "lib" API: runtime cond = 1
mypath = /usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/sbin:/usr/sbin:/usr/local/sbin:/home/kai/MentorGraphics/Sourcery_CodeBench_Lite_for_ARM_GNU_Linux/bin/:/mnt/big/scratchpad/buildroot-2017.08.1/output/host/bin/:/sbin:/usr/sbin:/usr/local/sbin
$
通过查看输出看不出有 bug,这也是这些 bug 如此危险的部分原因!
这种情况对开发人员和测试人员来说非常关键;它值得检查一些真实世界的例子。
测试案例 13.2
例子——Motif库。Motif是 X Window 系统的一部分的传统库;它被用于(也许仍在用)为 Unix(和类 Unix)系统开发 GUI。
为了举例说明,我们将专注于其中一个 API:XmStringCreateLocalized(3)。GUI 开发人员使用这个函数来创建 Motif 称之为“复合字符串”的东西——本质上,就是一个以特定区域设置(用于国际化 I18N)的文本为内容的字符串。这是它的签名:
#include <Xm/Xm.h>
XmString XmStringCreateLocalized(char *text);
所以,让我们想象一下,开发人员使用它来生成复合字符串(用于各种目的;很多时候是用于标签或按钮小部件的标签)。
那么问题出在哪里呢?
内存泄漏!怎么回事?从XmStringCreateLocalized(3)的 man 页面(linux.die.net/man/3/xmstringcreatelocalized)上阅读文档:
[...]
The function will allocate space to hold the returned compound string. The application is responsible for managing the allocated space. The application can recover the allocated space by calling XmStringFree.
[...]
显然,开发人员不仅必须调用XmStringCreateLocalized(3),还必须记得通过调用XmStringFree(3)释放由它内部分配的复合字符串的内存!
如果不这样做,就会导致内存泄漏。我有这种情况的亲身经历——一个有 bug 的应用程序调用了XmStringCreateLocalized(3),但没有调用它的对应函数XmStringFree(3)。更糟糕的是,这段代码经常运行,因为它是外部循环的一部分!所以,内存泄漏不断增加。
测试案例 13.3
例子——Nortel 移植项目。有一个关于 Nortel(加拿大一家大型电信和网络设备跨国公司)的开发人员在调试一个内存泄漏问题时遇到了很大困难的故事。问题的关键在于:在将 Unix 应用程序移植到 VxWorks 时,在测试过程中,他们注意到发生了一个小的 18 字节的内存泄漏,最终导致应用程序崩溃。找到内存泄漏的源头是一场噩梦——无休止地审查代码没有提供任何线索。最终,改变游戏规则的是使用了一个内存泄漏检测工具(我们将在接下来的第六章中介绍,内存问题调试工具)。几分钟内,他们发现了内存泄漏的根本原因:一个看似无害的 API,inet_ntoa(3)(参见信息框),在 Unix 上和 VxWorks 上都是正常工作的。问题在于,在 VxWorks 的实现中,它在幕后分配了内存——调用者有责任释放!这个事实是有文档记录的,但这是一个移植项目!一旦意识到这一点,问题很快就解决了。
文章:嵌入式调试的十个秘密,Schneider 和 Fraleigh:www.embedded.com/design/prototyping-and-development/4025015/The-ten-secrets-of-embedded-debugging
inet_ntoa(3)的 man 页面条目指出:inet_ntoa()函数将以网络字节顺序给出的 Internet 主机地址转换为 IPv4 点分十进制表示的字符串。字符串以静态分配的缓冲区返回,后续调用将覆盖它。
关于有内存泄漏 bug 的程序的一些观察:
-
程序在很长一段时间内表现正常;突然之间,比如说,运行一个月后,它突然崩溃了。
-
根源的内存泄漏可能非常小——每次只有几个字节;但可能经常被调用。
-
通过仔细匹配你的
malloc(3)和free(3)的实例来寻找泄漏错误是行不通的;库 API 包装器通常在后台分配内存,并期望调用者释放它。 -
泄漏通常会被忽视,因为它们在大型代码库中很难被发现,一旦进程死掉,泄漏的内存就会被释放回系统。
底线:
-
不要假设任何事情
-
仔细阅读 API 文档
-
使用工具(在即将到来的第六章中涵盖的内存问题调试工具)
不能过分强调使用工具检测内存错误的重要性!
未定义行为
我们已经涵盖了相当多的内容,并看到了一些常见的内存错误,包括:
-
不正确的内存访问
-
使用未初始化的变量
-
越界内存访问(读/写下溢/溢出错误)
-
释放后使用/返回后使用(超出范围)错误
-
双重释放
-
泄漏
-
数据竞争(详细信息将在后面的章节中介绍)
如前所述,所有这些都属于一个通用的分类——UB。正如短语所暗示的,一旦发生这些错误中的任何一个,进程(或线程)的行为就会变得未定义。更糟糕的是,其中许多错误并不显示任何直接可见的副作用;但进程是不稳定的,并且最终会崩溃。特别是泄漏错误在其中是主要的破坏者:泄漏可能在崩溃实际发生之前存在很长时间。不仅如此,留下的痕迹(开发人员将气喘吁吁地追踪)往往可能是一个误导——与错误根本原因无关紧要的事情,没有真正影响错误根本原因的事情。当然,所有这些都使得调试 UB 成为大多数人都愿意避免的经历!
好消息是,只要开发人员了解 UB 的根本原因(我们在前面的章节中已经涵盖了),并且有能力使用强大的工具来发现并修复这些错误,UB 是可以避免的,这也是我们下一个话题领域。
要深入了解许多可能的 UB 错误,请查看:附录 J.2:未定义行为:C 中未定义行为的非规范、非穷尽列表:www.open-std.org/jtc1/sc22/wg14/www/docs/n1548.pdf#page=571。
来自深入的 C 编程语言标准——ISO/IEC 9899:201x 委员会草案,日期为 2010 年 12 月 2 日。
同样,还请参阅CWE VIEW:C 编写的软件中的弱点:cwe.mitre.org/data/definitions/658.html。
碎片
碎片问题通常指的是主要由内存分配引擎的内部实现面临的问题,而不是典型的应用程序开发人员所面临的问题。碎片问题通常有两种类型:内部和外部。
外部碎片通常指的是这样一种情况:在系统运行了几天后,即使系统上的空闲内存为 100MB,物理上连续的空闲内存可能少于 1MB。因此,随着进程获取和释放各种大小的内存块,内存变得碎片化。
内部碎片通常指的是由于使用低效的分配策略而导致的内存浪费;然而,这通常是无法避免的,因为浪费往往是许多基于堆的分配器的副作用。现代的 glibc 引擎使用内存池,大大减少了内部碎片。
我们不打算在本书中深入探讨碎片问题。
可以说,如果在一个大型项目中你怀疑存在碎片问题,你应该尝试使用一个显示进程运行时内存映射的工具(在 Linux 上,可以查看/proc/<PID>/maps作为起点)。通过解释它,你可能可以重新设计你的应用程序以避免这种碎片。
杂项
同时,要意识到,除非已经分配了内存,否则尝试仅使用指针来访问内存是一个错误。记住指针本身没有内存;它们必须分配内存(无论是在编译时静态分配还是在运行时动态分配)。
例如,有人编写了一个使用参数作为返回值的 C 函数——这是一种常见的 C 编程技巧(这些通常被称为值-结果或输入-输出参数):
unsigned long *uptr;
[...]
my_awesome_func(uptr); // bug! value to be returned in 'uptr'
[...]
这是一个错误;uptr变量只是一个指针——它没有内存。修复这个问题的一种方法如下:
unsigned long *uptr;
[...]
uptr = malloc(sizeof(unsigned long));
if (!uptr) {
[...handle the error...]
}
my_awesome_func(uptr); // value returned in 'uptr'
[...]
free(uptr);
或者,更简单地说,为什么不在这种情况下使用编译时内存:
unsigned long uptr; // compile-time allocated memory
[...]
my_awesome_func(&uptr); // value returned in 'uptr'
[...]
总结
在本章中,我们深入研究了一个关键领域:看似简单的动态内存管理 API 在实际应用系统中可能引发深层次且难以检测的错误。
我们讨论了内存错误的常见类别,比如未初始化的内存使用(UMR),越界访问(读取|写入下溢|溢出错误)和双重释放。内存泄漏是一种常见且危险的内存错误——我们看了三种不同的情况。
提供的membugs程序帮助读者通过小型测试案例实际看到并尝试各种内存错误。在下一章中,我们将深入使用工具来帮助识别这些危险的缺陷。
第六章:内存问题调试工具
我们人类(我们假设是人类在阅读这本书,而不是某种形式的人工智能,尽管,谁知道现在)擅长许多复杂的任务;但是,我们也擅长许多平凡的任务。这就是为什么我们发明了计算机——配备了驱动它们的软件!
嗯。我们并不擅长发现深藏在 C(或汇编)代码中的细节——内存错误是我们人类可以使用帮助的典型案例。所以,猜猜看:我们发明了软件工具来帮助我们——它们做乏味的工作,检查我们数以百万计甚至数十亿行代码和二进制代码,并且在捕捉我们的错误方面非常有效。当然,说到底,最好的工具仍然是你的大脑,但是人们可能会问:谁和什么来调试我们用于调试的工具?答案当然是更多的工具,以及你,作为人类程序员。
在本章中,读者将学习如何使用两种最佳的内存调试工具:
-
Valgrind 的 Memcheck
-
Sanitizer 工具(ASan)
提供了有用的表格,总结和比较它们的特性。还可以看到通过mallopt(3)调整 glibc 的 malloc。
这一章没有自己的源代码;相反,我们使用了上一章的源代码,即第五章,Linux 内存问题。我们的membugs程序测试案例将在 Valgrind 和 ASan 下进行测试,以查看它们是否能捕捉到我们的memugs程序的测试案例努力提供的内存错误。因此,我们强烈建议您复习前一章和membugs.c源代码,以重新熟悉我们将要运行的测试案例。
工具类型
总的来说,在这些领域范围内,有两种工具:
-
动态分析工具
-
静态分析工具
动态分析工具基本上通过对运行时进程进行仪器化来工作。因此,为了充分利用它们,必须要花费大量精力来确保工具实际上覆盖了所有可能的代码路径;通过仔细而费力地编写测试案例来确保完整的代码覆盖。这是一个关键点,将在后面提到(重要的是,第十九章,故障排除和最佳实践,涵盖了这些要点)。虽然非常强大,但动态分析工具通常会导致显著的运行时性能损失和更多的内存使用。
另一方面,静态分析工具是针对源代码进行工作的;在这个意义上,它们类似于编译器。它们通常远远超出了典型的编译器,帮助开发人员发现各种潜在的错误。也许最初的 Unix lint程序可以被认为是今天强大的静态分析器的前身。如今,存在着非常强大的商业静态分析器(带有花哨的图形用户界面),并且值得花费在它们上的金钱和时间。缺点是这些工具可能会引发许多错误的警报;更好的工具可以让程序员执行有用的过滤。我们不会在本文中涵盖静态分析器(请参阅 GitHub 存储库上的进一步阅读部分,了解 C/C++的静态分析器列表)。
现在,让我们来看看一些现代内存调试工具;它们都属于动态分析工具类。确实要学会如何有效地使用它们——它们是对各种未定义行为(UB)的必要武器。
Valgrind
Valgrind(发音为val-grinned)是一套强大工具的仪器化框架。它是开源软件(OSS),根据 GNU GPL ver. 2 的条款发布;它最初由 Julian Seward 开发。Valgrind 是一套用于内存调试和性能分析的获奖工具。它已经发展成为创建动态分析工具的框架。事实上,它实际上是一个虚拟机;Valgrind 使用一种称为动态二进制仪器化(DBI)的技术来对代码进行检测。在其主页上阅读更多信息:valgrind.org/。
Valgrind 的巨大优势在于其工具套件,主要是Memory Checker工具(Memcheck)。还有其他几个检查器和性能分析工具,按字母顺序列在下表中:
| Valgrind 工具名称 | 目的 |
|---|---|
| cachegrind | CPU 缓存性能分析器。 |
| callgrind | cachegrind 的扩展;提供更多的调用图信息。KCachegrind 是 cachegrind/callgrind 的良好 GUI 可视化工具。 |
| drd | Pthreads 错误检测器。 |
| helgrind | 多线程应用程序(主要是 Pthreads)的数据竞争检测器。 |
| massif | 堆分析器(堆使用情况图表,最大分配跟踪)。 |
| Memcheck | 内存错误检测器;包括越界(OOB)访问(读取 |
请注意,一些较少使用的工具(如 lackey、nulgrind、none)和一些实验性工具(exp-bbv、exp-dhat、exp-sgcheck)没有在表中显示。
通过--tool=选项选择 Valgrind 要运行的工具(将前述任何一个作为参数)。在本书中,我们只关注 Valgrind 的 Memcheck 工具。
使用 Valgrind 的 Memcheck 工具
Memcheck 是 Valgrind 的默认工具;你不需要显式传递它,但可以使用valgrind --tool=memcheck <要执行的程序及参数>语法来执行。
作为一个简单的例子,让我们在 Ubuntu 上运行 Valgrind 对df(1)实用程序进行检测:
$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 17.10
Release: 17.10
Codename: artful
$ df --version |head -n1
df (GNU coreutils) 8.26
$ valgrind df
==1577== Memcheck, a memory error detector
==1577== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==1577== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==1577== Command: df
==1577==
Filesystem 1K-blocks Used Available Use% Mounted on
udev 479724 0 479724 0% /dev
tmpfs 100940 10776 90164 11% /run
/dev/sda1 31863632 8535972 21686036 29% /
tmpfs 504692 0 504692 0% /dev/shm
tmpfs 5120 0 5120 0% /run/lock
tmpfs 504692 0 504692 0% /sys/fs/cgroup
tmpfs 100936 0 100936 0% /run/user/1000
==1577==
==1577== HEAP SUMMARY:
==1577== in use at exit: 3,577 bytes in 213 blocks
==1577== total heap usage: 447 allocs, 234 frees, 25,483 bytes allocated
==1577==
==1577== LEAK SUMMARY:
==1577== definitely lost: 0 bytes in 0 blocks
==1577== indirectly lost: 0 bytes in 0 blocks
==1577== possibly lost: 0 bytes in 0 blocks
==1577== still reachable: 3,577 bytes in 213 blocks
==1577== suppressed: 0 bytes in 0 blocks
==1577== Rerun with --leak-check=full to see details of leaked memory
==1577==
==1577== For counts of detected and suppressed errors, rerun with: -v
==1577== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
$
Valgrind 实际上接管并在其中运行df进程,对所有动态内存访问进行检测。然后打印出其报告。在前面的代码中,这些行都以==1577==为前缀;那只是df进程的 PID 而已。
没有发现运行时内存错误,因此没有输出(当我们在 Valgrind 的控制下运行我们的membugs程序时,很快你就会看到差异)。就内存泄漏而言,报告指出:
definitely lost: 0 bytes in 0 blocks
所有这些都是零值,所以没问题。如果definitely lost下的值为正数,那么这确实会表明存在必须进一步调查和修复的内存泄漏错误。其他标签——indirectly/possibly lost,still reachable——通常是由于代码库中复杂或间接的内存处理而产生的(实际上,它们通常是可以忽略的假阳性)。
still reachable通常表示在进程退出时,一些内存块未被应用程序显式释放(但在进程死亡时被隐式释放)。以下语句显示了这一点:
-
退出时使用:213 个块中的 3,577 字节
-
总堆使用情况:447 次分配,234 次释放,25,483 字节
在总共的 447 次分配中,只有 234 次释放,剩下了 447 - 234 = 213 个未释放的块。
好了,现在来看有趣的部分:让我们运行我们的membugs程序测试用例(来自前面的第五章,Linux 内存问题)在 Valgrind 下运行,并看看它是否能捕捉到测试用例努力提供的内存错误。
我们强烈建议您回顾前一章和membugs.c源代码,以便重新熟悉我们将要运行的测试用例。
membugs 程序共有 13 个测试用例;我们不打算在书中展示所有测试用例的输出;我们把这留给读者作为一个练习,尝试在 Valgrind 下运行程序并解密其输出报告。
大多数读者可能会对本节末尾的摘要表感兴趣,该表显示了在每个测试用例上运行 Valgrind 的结果。
测试用例#1:未初始化内存访问
$ ./membugs 1
true: x=32568
$
为了便于阅读,我们删除了以下显示的部分并截断了程序路径名。
现在处于 Valgrind 的控制之下:
$ valgrind ./membugs 1
==19549== Memcheck, a memory error detector
==19549== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==19549== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==19549== Command: ./membugs 1
==19549==
==19549== Conditional jump or move depends on uninitialised value(s)
==19549== at 0x40132C: uninit_var (in <...>/ch3/membugs)
==19549== by 0x401451: process_args (in <...>/ch3/membugs)
==19549== by 0x401574: main (in <...>/ch3/membugs)
==19549==
[...]
==19549== Conditional jump or move depends on uninitialised value(s)
==19549== at 0x4E9101C: vfprintf (in /usr/lib64/libc-2.26.so)
==19549== by 0x4E99255: printf (in /usr/lib64/libc-2.26.so)
==19549== by 0x401357: uninit_var (in <...>/ch3/membugs)
==19549== by 0x401451: process_args (in <...>/ch3/membugs)
==19549== by 0x401574: main (in <...>/ch3/membugs)
==19549==
false: x=0
==19549==
==19549== HEAP SUMMARY:
==19549== in use at exit: 0 bytes in 0 blocks
==19549== total heap usage: 1 allocs, 1 frees, 1,024 bytes allocated
==19549==
==19549== All heap blocks were freed -- no leaks are possible
==19549==
==19549== For counts of detected and suppressed errors, rerun with: -v
==19549== Use --track-origins=yes to see where uninitialised values come from
==19549== ERROR SUMMARY: 6 errors from 6 contexts (suppressed: 0 from 0)
$
显然,Valgrind 捕捉到了未初始化的内存访问错误!粗体突出显示的文本清楚地揭示了这种情况。
但是,请注意,尽管 Valgrind 可以向我们显示调用堆栈(包括进程路径名),但似乎无法向我们显示源代码中存在错误的行号。不过,我们可以通过使用程序的启用调试版本来精确地实现这一点:
$ make membugs_dbg
gcc -g -ggdb -gdwarf-4 -O0 -Wall -Wextra -c membugs.c -o membugs_dbg.o
[...]
membugs.c: In function ‘uninit_var’:
membugs.c:283:5: warning: ‘x’ is used uninitialized in this function [-Wuninitialized]
if (x > MAXVAL)
^
[...]
gcc -g -ggdb -gdwarf-4 -O0 -Wall -Wextra -c ../common.c -o common_dbg.o
gcc -o membugs_dbg membugs_dbg.o common_dbg.o
[...]
用于调试的常见 GCC 标志
有关详细信息,请参阅gcc(1)的 man 页面。简而言之:-g:生成足够的调试信息,使得诸如GNU 调试器(GDB)之类的工具必须使用符号信息来进行调试(现代 Linux 通常会使用 DWARF 格式)。
-ggdb:使用操作系统可能的最表达格式。
-gdwarf-4:调试信息以 DWARF-
-O0:优化级别0;用于调试。
在以下代码中,我们重试了使用启用调试版本的二进制可执行文件membugs_dbg运行 Valgrind:
$ valgrind --tool=memcheck ./membugs_dbg 1
==20079== Memcheck, a memory error detector
==20079== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==20079== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==20079== Command: ./membugs_dbg 1
==20079==
==20079== Conditional jump or move depends on uninitialised value(s)
==20079== at 0x40132C: uninit_var (membugs.c:283)
==20079== by 0x401451: process_args (membugs.c:326)
==20079== by 0x401574: main (membugs.c:379)
==20079==
==20079== Conditional jump or move depends on uninitialised value(s)
==20079== at 0x4E90DAA: vfprintf (in /usr/lib64/libc-2.26.so)
==20079== by 0x4E99255: printf (in /usr/lib64/libc-2.26.so)
==20079== by 0x401357: uninit_var (membugs.c:286)
==20079== by 0x401451: process_args (membugs.c:326)
==20079== by 0x401574: main (membugs.c:379)
==20079==
==20079== Use of uninitialised value of size 8
==20079== at 0x4E8CD7B: _itoa_word (in /usr/lib64/libc-2.26.so)
==20079== by 0x4E9043D: vfprintf (in /usr/lib64/libc-2.26.so)
==20079== by 0x4E99255: printf (in /usr/lib64/libc-2.26.so)
==20079== by 0x401357: uninit_var (membugs.c:286)
==20079== by 0x401451: process_args (membugs.c:326)
==20079== by 0x401574: main (membugs.c:379)
[...]
==20079==
false: x=0
==20079==
==20079== HEAP SUMMARY:
==20079== in use at exit: 0 bytes in 0 blocks
==20079== total heap usage: 1 allocs, 1 frees, 1,024 bytes allocated
==20079==
==20079== All heap blocks were freed -- no leaks are possible
==20079==
==20079== For counts of detected and suppressed errors, rerun with: -v
==20079== Use --track-origins=yes to see where uninitialised values come from
==20079== ERROR SUMMARY: 6 errors from 6 contexts (suppressed: 0 from 0)
$
像往常一样,以自下而上的方式阅读调用堆栈,它就会有意义!
重要提示:请注意,不幸的是,输出中显示的精确行号可能与书中 GitHub 存储库中最新版本的源文件中的行号不完全匹配。
以下是源代码(此处使用nl实用程序显示所有行编号的代码):
$ nl --body-numbering=a membugs.c [...]
278 /* option = 1 : uninitialized var test case */
279 static void uninit_var()
280 {
281 int x;
282
283 if (x) 284 printf("true case: x=%d\n", x);
285 else
286 printf("false case\n");
287 }
[...]
325 case 1:
326 uninit_var();
327 break;
[...]
377 int main(int argc, char **argv)
378 {
379 process_args(argc, argv);
380 exit(EXIT_SUCCESS);
381 }
我们现在可以看到 Valgrind 确实完美地捕捉到了错误的情况。
测试用例#5: 编译时内存读取溢出:
$ valgrind ./membugs_dbg 5
==23024== Memcheck, a memory error detector
==23024== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==23024== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==23024== Command: ./membugs_dbg 5
==23024==
arr = aaaaa����
==23024==
==23024== HEAP SUMMARY:
==23024== in use at exit: 0 bytes in 0 blocks
==23024== total heap usage: 1 allocs, 1 frees, 1,024 bytes allocated
==23024==
==23024== All heap blocks were freed -- no leaks are possible
==23024==
==23024== For counts of detected and suppressed errors, rerun with: -v
==23024== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
$
看看!Valgrind 未能捕捉到读取溢出内存错误。为什么?这是一个限制:Valgrind 只能对动态分配的内存进行插装和捕捉 UB(错误)。前面的测试用例使用了静态编译时分配的内存。
因此,让我们尝试相同的测试,但这次使用动态分配的内存;这正是测试用例#6 的设计目的。
测试用例#6: 动态内存上的读取溢出(为了便于阅读,我们截断了部分输出):
$ ./membugs_dbg 2>&1 |grep 6
option = 6 : out-of-bounds : read overflow [on dynamic memory]
$ valgrind ./membugs_dbg 6
[...]
==23274== Command: ./membugs_dbg 6
==23274==
==23274== Invalid write of size 1
==23274== at 0x401127: read_overflow_dynmem (membugs.c:215)
==23274== by 0x401483: process_args (membugs.c:341)
==23274== by 0x401574: main (membugs.c:379)
==23274== Address 0x521f045 is 0 bytes after a block of size 5 alloc'd
==23274== at 0x4C2FB6B: malloc (vg_replace_malloc.c:299)
==23274== by 0x4010D9: read_overflow_dynmem (membugs.c:205)
==23274== by 0x401483: process_args (membugs.c:341)
==23274== by 0x401574: main (membugs.c:379)
[...]
==23274== Invalid write of size 1
==23274== at 0x40115E: read_overflow_dynmem (membugs.c:216)
==23274== by 0x401483: process_args (membugs.c:341)
==23274== by 0x401574: main (membugs.c:379)
==23274== Address 0x521f04a is 5 bytes after a block of size 5 alloc'd
==23274== at 0x4C2FB6B: malloc (vg_replace_malloc.c:299)
==23274== by 0x4010D9: read_overflow_dynmem (membugs.c:205)
==23274== by 0x401483: process_args (membugs.c:341)
==23274== by 0x401574: main (membugs.c:379)
==23274==
==23274== Invalid read of size 1
==23274== at 0x4C32B94: strlen (vg_replace_strmem.c:458)
==23274== by 0x4E91955: vfprintf (in /usr/lib64/libc-2.26.so)
==23274== by 0x4E99255: printf (in /usr/lib64/libc-2.26.so)
==23274== by 0x401176: read_overflow_dynmem (membugs.c:217)
==23274== by 0x401483: process_args (membugs.c:341)
==23274== by 0x401574: main (membugs.c:379)
==23274== Address 0x521f045 is 0 bytes after a block of size 5 alloc'd
==23274== at 0x4C2FB6B: malloc (vg_replace_malloc.c:299)
==23274== by 0x4010D9: read_overflow_dynmem (membugs.c:205)
==23274== by 0x401483: process_args (membugs.c:341)
==23274== by 0x401574: main (membugs.c:379)
[...]
arr = aaaaaSecreT
==23274== Conditional jump or move depends on uninitialised value(s)
==23274== at 0x4E90DAA: vfprintf (in /usr/lib64/libc-2.26.so)
==23274== by 0x4E99255: printf (in /usr/lib64/libc-2.26.so)
==23274== by 0x401195: read_overflow_dynmem (membugs.c:220)
==23274== by 0x401483: process_args (membugs.c:341)
==23274== by 0x401574: main (membugs.c:379)
==23274==
==23274== Use of uninitialised value of size 8
==23274== at 0x4E8CD7B: _itoa_word (in /usr/lib64/libc-2.26.so)
==23274== by 0x4E9043D: vfprintf (in /usr/lib64/libc-2.26.so)
==23274== by 0x4E99255: printf (in /usr/lib64/libc-2.26.so)
==23274== by 0x401195: read_overflow_dynmem (membugs.c:220)
==23274== by 0x401483: process_args (membugs.c:341)
==23274== by 0x401574: main (membugs.c:379)
[...]
==23274== ERROR SUMMARY: 31 errors from 17 contexts (suppressed: 0 from 0)
$
这一次,大量的错误被捕捉到,显示了源代码中的确切位置(因为我们使用了-g进行编译)。
测试用例#8: UAF(释放后使用):
$ ./membugs_dbg 2>&1 |grep 8
option = 8 : UAF (use-after-free) test case
$

当 Valgrind 捕捉到 UAF 错误时的(部分)屏幕截图
Valgrind 确实捕捉到了 UAF!
测试用例#8: UAR(返回后使用):
$ ./membugs_dbg 9
res: (null)
$ valgrind ./membugs_dbg 9
==7594== Memcheck, a memory error detector
==7594== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==7594== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==7594== Command: ./membugs_dbg 9
==7594==
res: (null)
==7594==
==7594== HEAP SUMMARY:
==7594== in use at exit: 0 bytes in 0 blocks
==7594== total heap usage: 1 allocs, 1 frees, 1,024 bytes allocated
==7594==
==7594== All heap blocks were freed -- no leaks are possible
==7594==
==7594== For counts of detected and suppressed errors, rerun with: -v
==7594== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
$
哎呀!Valgrind 没有捕捉到 UAR 错误!
测试用例#13: 内存泄漏案例#3—lib API 泄漏。我们通过选择 13 作为membugs的参数来运行内存泄漏测试用例#3。值得注意的是,只有在使用--leak-check=full选项运行时,Valgrind 才会显示泄漏的来源(通过显示的调用堆栈):
$ valgrind --leak-resolution=high --num-callers=50 --leak-check=full ./membugs_dbg 13
==22849== Memcheck, a memory error detector
==22849== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==22849== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==22849== Command: ./membugs_dbg 13
==22849==
## Leakage test: case 3: "lib" API: runtime cond = 0
mypath = /usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/sbin:/usr/sbin:/usr/local/sbin:/home/kai/MentorGraphics/Sourcery_CodeBench_Lite_for_ARM_GNU_Linux/bin/:/mnt/big/scratchpad/buildroot-2017.08.1/output/host/bin/:/sbin:/usr/sbin:/usr/local/sbin
## Leakage test: case 3: "lib" API: runtime cond = 1
mypath = /usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/sbin:/usr/sbin:/usr/local/sbin:/home/kai/MentorGraphics/Sourcery_CodeBench_Lite_for_ARM_GNU_Linux/bin/:/mnt/big/scratchpad/buildroot-2017.08.1/output/host/bin/:/sbin:/usr/sbin:/usr/local/sbin
==22849==
==22849== HEAP SUMMARY:
==22849== in use at exit: 4,096 bytes in 1 blocks
==22849== total heap usage: 3 allocs, 2 frees, 9,216 bytes allocated
==22849==
==22849== 4,096 bytes in 1 blocks are definitely lost in loss record 1 of 1
==22849== at 0x4C2FB6B: malloc (vg_replace_malloc.c:299)
==22849== by 0x400A0C: silly_getpath (membugs.c:38)
==22849== by 0x400AC6: leakage_case3 (membugs.c:59)
==22849== by 0x40152B: process_args (membugs.c:367)
==22849== by 0x401574: main (membugs.c:379)
==22849==
==22849== LEAK SUMMARY:
==22849== definitely lost: 4,096 bytes in 1 blocks
==22849== indirectly lost: 0 bytes in 0 blocks
==22849== possibly lost: 0 bytes in 0 blocks
==22849== still reachable: 0 bytes in 0 blocks
==22849== suppressed: 0 bytes in 0 blocks
==22849==
==22849== For counts of detected and suppressed errors, rerun with: -v
==22849== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
$
Valgrind 的 man 页面建议设置--leak-resolution=high和--num-callers=为 40 或更高。
valgrind(1)的 man 页面涵盖了它提供的许多选项(如日志和工具(Memcheck)选项);请查看以更深入地了解此工具的用法。
Valgrind 摘要表
关于我们的测试用例(并入我们的membugs程序),以下是 Valgrind 的报告卡和内存错误如下:
| 测试用例# | 测试用例 | Valgrind 检测到了吗? |
|---|---|---|
| 1 | 未初始化内存读取(UMR) | 是 |
| 2 | 越界(OOB):写入溢出[在编译时内存上] | 否 |
| 3 | OOB:写入溢出[在动态内存上] | 是 |
| 4 | OOB:写入下溢[在动态内存上] | 是 |
| 5 | OOB:读取溢出[在编译时内存上] | 否 |
| 6 | OOB:读取溢出[在动态内存上] | 是 |
| 7 | OOB:读取下溢[在动态内存上] | 是 |
| 8 | UAF,也称为悬空指针 | 是 |
| 9 | UAR,也称为use-after-scope(UAS) | 否 |
| 10 | 重复释放 | 是 |
| 11 | 内存泄漏测试案例 1:简单泄漏 | 是 |
| 12 | 内存泄漏测试案例 1:泄漏更多(循环中) | 是 |
| 13 | 内存泄漏测试案例 1:库 API 泄漏 | 是 |
Valgrind 优点和缺点:快速总结
Valgrind 优点:
-
捕获动态分配内存区域上的常见内存错误(UB)
-
使用未初始化的变量
-
越界内存访问(读取/写入下溢/溢出错误)
-
释放后使用/返回后使用(超出范围)错误
-
重复释放
-
泄漏
-
无需修改源代码
-
无需重新编译
-
无需特殊的编译器标志
Valgrind 缺点:
-
性能:在 Valgrind 下运行目标软件可能会慢 10 到 30 倍。
-
内存占用:目标程序中的每个分配都需要 Valgrind 进行内存分配(在高资源约束的嵌入式 Linux 系统上运行 Valgrind 变得困难)。
-
无法捕获静态(编译时)分配的内存区域上的错误。
-
为了查看带有行号信息的调用堆栈,需要使用
-g标志重新编译/构建。
事实上,Valgrind 仍然是对抗错误的有力武器。有许多真实世界的项目使用 Valgrind;在valgrind.org/gallery/users.html上查看长列表.
总是有更多可以学习和探索的:Valgrind 提供了 GDB 监视器模式,允许您通过GNU 调试器(GDB)对程序进行高级调试。这对于在从不终止的程序上使用 Valgrind 特别有用(守护进程是典型案例)。
Valgrind 手册的第三章在这方面非常有帮助:valgrind.org/docs/manual/manual-core-adv.html
Sanitizer 工具
Sanitizer 是来自 Google 的一套开源工具;与其他内存调试工具一样,它们解决了通常的常见内存错误和 UB 问题,包括 OOB(越界访问:读取/写入下溢/溢出)、UAF、UAR、重复释放和内存泄漏。其中一个工具还处理 C/C++代码中的数据竞争。
一个关键区别是,Sanitizer 工具通过编译器向代码引入了插装。它们使用一种称为编译时插装(CTI)的技术以及影子内存技术。截至目前,ASan 是 GCC ver 4.8 和 LLVM(Clang)ver. 3.1 及以上的一部分并支持它。
Sanitizer 工具集
要使用给定的工具,需要使用 Usage 列中显示的标志编译程序:
| Sanitizer 工具(简称) | 目的 | 使用(编译器标志) | Linux 平台[+注释] |
|---|---|---|---|
| AddressSanitizer (ASan) | 检测通用内存错误[堆栈全局缓冲区溢出、UAF、UAR、初始化顺序错误] | -fsanitize=address |
x86、x86_64、ARM、Aarch64、MIPS、MIPS64、PPC64. [不能与 TSan 组合] |
| Kernel AddressSanitizer (KASAN) | 用于 Linux 内核空间的 ASan | -fsanitize=kernel-address |
x86_64 [内核版本>=4.0],Aarch64 [内核版本>= 4.4] |
| MemorySanitizer (MSan) | UMR 检测器 | -fsanitize=memory -fPIE -pie [-fno-omit-frame-pointer] |
仅适用于 Linux x86_64 |
| ThreadSanitizer (TSan) | 数据竞争检测器 | -fsanitize=thread |
仅适用于 Linux x86_64。[不能与 ASan 或 LSan 标志组合] |
| LeakSanitizer (LSan)(ASan 的子集) | 内存泄漏检测器 | -fsanitize=leak |
Linux x86_64 和 OS X [不能与 TSan 组合] |
| UndefinedBehaviorSanitizer (UBSan) | UB 检测器 | -fsanitize=undefined |
x86, x86_64, ARM, Aarch64, PPC64, MIPS, MIPS64 |
额外的文档 Google 维护着一个 GitHub 页面,其中包含有关 sanitizer 工具的文档:
每个工具的个别 wiki(文档)页面都有链接。建议您在使用工具时仔细阅读它们(例如,每个工具可能具有用户可以利用的特定标志和/或环境变量)。
gcc(1)的 man 页面是关于-fsanitize=sanitizer 工具 gcc 选项的复杂信息的丰富来源。有趣的是,大多数 sanitizer 工具也支持 Android(>=4.1)平台。
Clang 文档还记录了使用 sanitizer 工具的方法:clang.llvm.org/docs/index.html。
在本章中,我们专注于使用 ASan 工具。
为 ASan 构建程序
正如前表所示,我们需要使用适当的编译器标志来编译我们的目标应用程序 membugs。此外,建议使用clang而不是gcc作为编译器。
clang被认为是几种编程语言的编译器前端,包括 C 和 C++;后端是 LLVM 编译器基础设施项目。关于 Clang 的更多信息可以在其维基百科页面上找到。
您需要确保在您的 Linux 系统上安装了 Clang 软件包;使用您的发行版的软件包管理器(apt-get,dnf,rpm)是最简单的方法。
我们的 Makefile 片段显示了我们如何使用clang来编译 membugs sanitizer 目标:
CC=${CROSS_COMPILE}gcc
CL=${CROSS_COMPILE}clang
CFLAGS=-Wall -UDEBUG
CFLAGS_DBG=-g -ggdb -gdwarf-4 -O0 -Wall -Wextra -DDEBUG
CFLAGS_DBG_ASAN=${CFLAGS_DBG} -fsanitize=address
CFLAGS_DBG_MSAN=${CFLAGS_DBG} -fsanitize=memory
CFLAGS_DBG_UB=${CFLAGS_DBG} -fsanitize=undefined
[...]
#--- Sanitizers (use clang): <foo>_dbg_[asan|ub|msan]
membugs_dbg_asan.o: membugs.c
${CL} ${CFLAGS_DBG_ASAN} -c membugs.c -o membugs_dbg_asan.o
membugs_dbg_asan: membugs_dbg_asan.o common_dbg_asan.o
${CL} ${CFLAGS_DBG_ASAN} -o membugs_dbg_asan membugs_dbg_asan.o common_dbg_asan.o
membugs_dbg_ub.o: membugs.c
${CL} ${CFLAGS_DBG_UB} -c membugs.c -o membugs_dbg_ub.o
membugs_dbg_ub: membugs_dbg_ub.o common_dbg_ub.o
${CL} ${CFLAGS_DBG_UB} -o membugs_dbg_ub membugs_dbg_ub.o common_dbg_ub.o
membugs_dbg_msan.o: membugs.c
${CL} ${CFLAGS_DBG_MSAN} -c membugs.c -o membugs_dbg_msan.o
membugs_dbg_msan: membugs_dbg_msan.o common_dbg_msan.o
${CL} ${CFLAGS_DBG_MSAN} -o membugs_dbg_msan membugs_dbg_msan.o common_dbg_msan.o
[...]
使用 ASan 运行测试用例
为了提醒我们,这是我们的 membugs 程序的帮助屏幕:
$ ./membugs_dbg_asan
Usage: ./membugs_dbg_asan option [ -h | --help]
option = 1 : uninitialized var test case
option = 2 : out-of-bounds : write overflow [on compile-time memory]
option = 3 : out-of-bounds : write overflow [on dynamic memory]
option = 4 : out-of-bounds : write underflow
option = 5 : out-of-bounds : read overflow [on compile-time memory]
option = 6 : out-of-bounds : read overflow [on dynamic memory]
option = 7 : out-of-bounds : read underflow
option = 8 : UAF (use-after-free) test case
option = 9 : UAR (use-after-return) test case
option = 10 : double-free test case
option = 11 : memory leak test case 1: simple leak
option = 12 : memory leak test case 2: leak more (in a loop)
option = 13 : memory leak test case 3: "lib" API leak
-h | --help : show this help screen
$
membugs 程序共有 13 个测试用例;我们不打算在本书中显示所有这些测试用例的输出;我们把它留给读者来尝试使用 ASan 构建和运行所有测试用例的程序,并解密其输出报告。读者有兴趣看到本节末尾的摘要表,显示在每个测试用例上运行 ASan 的结果。
测试用例#1: UMR
让我们尝试第一个——未初始化变量读取测试用例:
$ ./membugs_dbg_asan 1
false case
$
它没有捕获到错误!是的,我们已经发现了 ASan 的限制:AddressSanitizer 无法捕获静态(编译时)分配的内存上的 UMR。Valgrind 可以。
MSan 工具已经处理了这个问题;它的具体工作是捕获 UMR 错误。文档说明 MSan 只能捕获动态分配的内存上的 UMR。我们发现它甚至捕获了一个在静态分配的内存上的 UMR 错误,而我们的简单测试用例使用了:
$ ./membugs_dbg_msan 1
==3095==WARNING: MemorySanitizer: use-of-uninitialized-value
#0 0x496eb8 (<...>/ch5/membugs_dbg_msan+0x496eb8)
#1 0x494425 (<...>/ch5/membugs_dbg_msan+0x494425)
#2 0x493f2b (<...>/ch5/membugs_dbg_msan+0x493f2b)
#3 0x7fc32f17ab96 (/lib/x86_64-linux-gnu/libc.so.6+0x21b96)
#4 0x41a8c9 (<...>/ch5/membugs_dbg_msan+0x41a8c9)
SUMMARY: MemorySanitizer: use-of-uninitialized-value (<...>/ch5/membugs_dbg_msan+0x496eb8) Exiting $
它已经捕获了错误;然而,这一次,尽管我们使用了带有-g -ggdb标志构建的调试二进制可执行文件,但在堆栈跟踪中缺少通常的filename:line_number信息。实际上,下一个测试用例中演示了一种获得这种信息的方法。
现在,不管怎样:这给了我们一个学习另一种有用的调试技术的机会:objdump(1)是可以极大帮助的工具链实用程序之一(我们可以使用诸如readelf(1)或gdb(1)之类的工具获得类似的结果)。我们将使用objdump(1)(-d开关,并通过-S开关提供源代码),并在其输出中查找 UMR 发生的地址:
SUMMARY: MemorySanitizer: use-of-uninitialized-value (<...>/ch5/membugs_dbg_msan+0x496eb8)
由于objdump的输出非常庞大,我们截断它,只显示相关部分:
$ objdump -d -S ./membugs_dbg_msan > tmp
<< Now examine the tmp file >>
$ cat tmp
./membugs_dbg_msan: file format elf64-x86-64
Disassembly of section .init:
000000000041a5b0 <_init>:
41a5b0: 48 83 ec 08 sub $0x8,%rsp
41a5b4: 48 8b 05 ad a9 2a 00 mov 0x2aa9ad(%rip),%rax # 6c4f68 <__gmon_start__>
41a5bb: 48 85 c0 test %rax,%rax
41a5be: 74 02 je 41a5c2 <_init+0x12>
[...]
0000000000496e60 <uninit_var>:
{
496e60: 55 push %rbp
496e61: 48 89 e5 mov %rsp,%rbp
int x; /* static mem */
496e64: 48 83 ec 10 sub $0x10,%rsp
[...]
if (x)
496e7f: 8b 55 fc mov -0x4(%rbp),%edx
496e82: 8b 31 mov (%rcx),%esi
496e84: 89 f7 mov %esi,%edi
[...]
496eaf: e9 00 00 00 00 jmpq 496eb4 <uninit_var+0x54>
496eb4: e8 a7 56 f8 ff callq 41c560 <__msan_warning_noreturn>
496eb9: 8a 45 fb mov -0x5(%rbp),%al
496ebc: a8 01 test $0x1,%al
[...]
在objdump输出中与 MSan 提供的0x496eb8错误点最接近的是0x496eb4。没问题:只需查看代码的第一行之前的内容;它是以下一行:
if (x)
完美。这正是 UMR 发生的地方!
测试案例#2:写溢出[在编译时内存上]
我们运行membugs程序,同时在 Valgrind 和 ASan 下运行,只调用write_overflow_compilemem()函数来测试编译时分配的内存的越界写溢出错误。
案例 1:使用 Valgrind
请注意,Valgrind 没有捕获越界内存错误:
$ valgrind ./membugs_dbg 2 ==8959== Memcheck, a memory error detector
==8959== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==8959== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==8959== Command: ./membugs_dbg 2
==8959==
==8959==
==8959== HEAP SUMMARY:
==8959== in use at exit: 0 bytes in 0 blocks
==8959== total heap usage: 0 allocs, 0 frees, 0 bytes allocated
==8959==
==8959== All heap blocks were freed -- no leaks are possible
==8959==
==8959== For counts of detected and suppressed errors, rerun with: -v
==8959== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
$
这是因为 Valgrind 仅限于处理动态分配的内存;它无法插装和处理编译时分配的内存。
案例 2:地址消毒剂
ASan 确实捕获了 bug:

AddressSanitizer(ASan)捕获了 OOB 写溢出 bug
以下是一个类似的文本版本:
$ ./membugs_dbg_asan 2
=================================================================
==25662==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7fff17e789f4 at pc 0x00000051271d bp 0x7fff17e789b0 sp 0x7fff17e789a8
WRITE of size 4 at 0x7fff17e789f4 thread T0
#0 0x51271c (<...>/membugs_dbg_asan+0x51271c)
#1 0x51244e (<...>/membugs_dbg_asan+0x51244e)
#2 0x512291 (<...>/membugs_dbg_asan+0x512291)
#3 0x7f7e19b2db96 (/lib/x86_64-linux-gnu/libc.so.6+0x21b96)
#4 0x419ea9 (<...>/membugs_dbg_asan+0x419ea9)
Address 0x7fff17e789f4 is located in stack of thread T0 at offset 52 in frame
#0 0x5125ef (/home/seawolf/0tmp/membugs_dbg_asan+0x5125ef)
[...]
SUMMARY: AddressSanitizer: stack-buffer-overflow (/home/seawolf/0tmp/membugs_dbg_asan+0x51271c)
[...]
==25662==ABORTING
$
然而,请注意,在堆栈回溯中,没有filename:line#信息。这令人失望。我们能获取它吗?
确实—诀窍在于确保几件事情:
-
使用
-g开关编译应用程序(包括调试符号信息;我们对所有*_dbg 版本都这样做)。 -
除了 Clang 编译器,还必须安装一个名为
llvm-symbolizer的工具。安装后,您必须找出它在磁盘上的确切位置,并将该目录添加到路径中。 -
在运行时,必须将
ASAN_OPTIONS环境变量设置为symbolize=1值。
在这里,我们使用llvm-symbolizer重新运行有 bug 的案例:
$ export PATH=$PATH:/usr/lib/llvm-6.0/bin/
$ ASAN_OPTIONS=symbolize=1 ./membugs_dbg_asan 2
=================================================================
==25807==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffd63e80cf4 at pc 0x00000051271d bp 0x7ffd63e80cb0 sp 0x7ffd63e80ca8
WRITE of size 4 at 0x7ffd63e80cf4 thread T0
#0 0x51271c in write_overflow_compilemem <...>/ch5/membugs.c:268:10
#1 0x51244e in process_args <...>/ch5/membugs.c:325:4
#2 0x512291 in main <...>/ch5/membugs.c:375:2
#3 0x7f9823642b96 in __libc_start_main /build/glibc-OTsEL5/glibc-2.27/csu/../csu/libc-start.c:310
#4 0x419ea9 in _start (<...>/membugs_dbg_asan+0x419ea9)
[...]
$
现在filename:line#信息显示出来了!
显然,ASan 可以并且确实插装编译时分配的内存以及动态分配的内存区域,从而捕获内存类型的错误。
另外,正如我们所看到的,它显示了一个调用堆栈(当然是从底部到顶部)。我们可以看到调用链是:
_start --> __libc_start_main --> main --> process_args -->
write_overflow_compilemem
AddressSanitizer 还显示了“在有 bug 的地址周围的影子字节”;在这里,我们不试图解释用于捕获此类错误的内存阴影技术;如果感兴趣,请参阅 GitHub 存储库上的进一步阅读部分。
测试案例#3:写溢出(在动态内存上)
正如预期的那样,ASan 捕获了 bug:
$ ./membugs_dbg_asan 3
=================================================================
==25848==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000018 at pc 0x0000004aaedc bp 0x7ffe64dd2cd0 sp 0x7ffe64dd2480
WRITE of size 10 at 0x602000000018 thread T0
#0 0x4aaedb in __interceptor_strcpy.part.245 (<...>/membugs_dbg_asan+0x4aaedb)
#1 0x5128fd in write_overflow_dynmem <...>/ch5/membugs.c:258:2
#2 0x512458 in process_args <...>/ch5/membugs.c:328:4
#3 0x512291 in main <...>/ch5/membugs.c:375:2
#4 0x7f93abb88b96 in __libc_start_main /build/glibc-OTsEL5/glibc-2.27/csu/../csu/libc-start.c:310
#5 0x419ea9 in _start (<...>/membugs_dbg_asan+0x419ea9)
0x602000000018 is located 0 bytes to the right of 8-byte region [0x602000000010,0x602000000018) allocated by thread T0 here:
#0 0x4d9d60 in malloc (<...>/membugs_dbg_asan+0x4d9d60)
#1 0x512896 in write_overflow_dynmem <...>/ch5/membugs.c:254:9
#2 0x512458 in process_args <...>/ch5/membugs.c:328:4
#3 0x512291 in main <...>/ch5/membugs.c:375:2
#4 0x7f93abb88b96 in __libc_start_main /build/glibc-OTsEL5/glibc-2.27/csu/../csu/libc-start.c:310
[...]
有了llvm-symbolizer在路径中,filename:line#信息再次显示出来。
尝试为消毒剂插装编译(通过-fsanitize=GCC 开关)并尝试在 Valgrind 上运行二进制可执行文件是不受支持的;当我们尝试这样做时,Valgrind 报告如下:
$ valgrind ./membugs_dbg 3
==8917== Memcheck, a memory error detector
==8917== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==8917== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==8917== Command: ./membugs_dbg 3
==8917==
==8917==ASan runtime does not come first in initial library list; you should either link runtime to your application or manually preload it with LD_PRELOAD.
[...]
测试案例#8:UAF(释放后使用)。看看以下代码:
$ ./membugs_dbg_asan 8 uaf():162: arr = 0x615000000080:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
=================================================================
==25883==ERROR: AddressSanitizer: heap-use-after-free on address 0x615000000080 at pc 0x000000444b14 bp 0x7ffde4315390 sp 0x7ffde4314b40
WRITE of size 22 at 0x615000000080 thread T0
#0 0x444b13 in strncpy (<...>/membugs_dbg_asan+0x444b13)
#1 0x513529 in uaf <...>/ch5/membugs.c:172:2
#2 0x512496 in process_args <...>/ch5/membugs.c:344:4
#3 0x512291 in main <...>/ch5/membugs.c:375:2
#4 0x7f4ceea9fb96 in __libc_start_main /build/glibc-OTsEL5/glibc-2.27/csu/../csu/libc-start.c:310
#5 0x419ea9 in _start (<...>/membugs_dbg_asan+0x419ea9)
0x615000000080 is located 0 bytes inside of 512-byte region [0x615000000080,0x615000000280)
freed by thread T0 here:
#0 0x4d9b90 in __interceptor_free.localalias.0 (<...>/membugs_dbg_asan+0x4d9b90)
#1 0x513502 in uaf <...>/ch5/membugs.c:171:2
#2 0x512496 in process_args <...>/ch5/membugs.c:344:4
#3 0x512291 in main <...>/ch5/membugs.c:375:2
#4 0x7f4ceea9fb96 in __libc_start_main /build/glibc-OTsEL5/glibc-2.27/csu/../csu/libc-start.c:310
previously allocated by thread T0 here:
#0 0x4d9d60 in malloc (<...>/membugs_dbg_asan+0x4d9d60)
#1 0x513336 in uaf <...>/ch5/membugs.c:157:8
#2 0x512496 in process_args <...>/ch5/membugs.c:344:4
#3 0x512291 in main <...>/ch5/membugs.c:375:2
#4 0x7f4ceea9fb96 in __libc_start_main /build/glibc-OTsEL5/glibc-2.27/csu/../csu/libc-start.c:310
SUMMARY: AddressSanitizer: heap-use-after-free (<...>/membugs_dbg_asan+0x444b13) in strncpy
[...]
太棒了。ASan 不仅报告了 UAF bug,甚至还报告了缓冲区的确切分配和释放位置!强大的东西。
测试案例#9:UAR
为了举例,假设我们以通常的方式使用gcc编译membugs程序。运行测试案例:
$ ./membugs_dbg 2>&1 | grep -w 9
option = 9 : UAR (use-after-return) test case
$ ./membugs_dbg_asan 9
res: (null)
$
ASan 本身并没有捕获这个危险的 UAR bug!正如我们之前看到的,Valgrind 也没有。但是,编译器确实发出了警告!
不过,消毒剂文档提到,如果:
-
clang(版本从 r191186 开始)用于编译代码(而不是 gcc) -
设置了一个特殊标志
detect_stack_use_after_return为1
因此,我们通过 Clang 重新编译可执行文件(再次,我们假设已安装 Clang 软件包)。实际上,我们的 Makefile 确实对所有membugs_dbg_*构建使用了clang。因此,请确保我们使用 Clang 重新构建编译器并重试:
$ ASAN_OPTIONS=detect_stack_use_after_return=1 ./membugs_dbg_asan 9
=================================================================
==25925==ERROR: AddressSanitizer: stack-use-after-return on address 0x7f7721a00020 at pc 0x000000445b17 bp 0x7ffdb7c3ba10 sp 0x7ffdb7c3b1c0
READ of size 23 at 0x7f7721a00020 thread T0
#0 0x445b16 in printf_common(void*, char const*, __va_list_tag*) (<...>/membugs_dbg_asan+0x445b16)
#1 0x4465db in vprintf (<...>/membugs_dbg_asan+0x4465db)
#2 0x4466ae in __interceptor_printf (<...>/membugs_dbg_asan+0x4466ae)
#3 0x5124b9 in process_args <...>/ch5/membugs.c:348:4
#4 0x512291 in main <...>/ch5/membugs.c:375:2
#5 0x7f7724e80b96 in __libc_start_main /build/glibc-OTsEL5/glibc-2.27/csu/../csu/libc-start.c:310
#6 0x419ea9 in _start (/home/seawolf/0tmp/membugs_dbg_asan+0x419ea9)
Address 0x7f7721a00020 is located in stack of thread T0 at offset 32 in frame
#0 0x5135ef in uar <...>/ch5/membugs.c:141
This frame has 1 object(s):
[32, 64) 'name' (line 142) <== Memory access at offset 32 is inside this variable
[...]
它确实有效。正如我们在测试案例#1:UMR中所展示的,可以进一步利用objdump(1)来找出 bug 发生的确切位置。我们把这留给读者作为一个练习。
有关 ASan 如何检测堆栈 UAR 的更多信息,请访问github.com/google/sanitizers/wiki/AddressSanitizerUseAfterReturn。
测试案例#10:双重释放
这个错误的测试用例有点有趣(参考membugs.c源代码);我们执行malloc,释放指针,然后用一个如此大的值(-1UL,它变成了无符号,因此太大)执行另一个malloc,这是保证会失败的。在错误处理代码中,我们(故意)释放了之前已经释放过的指针,从而生成了双重释放的测试用例。在更简单的伪代码中:
ptr = malloc(n);
strncpy(...);
free(ptr);
bogus = malloc(-1UL); /* will fail */
if (!bogus) {
free(ptr); /* the Bug! */
exit(1);
}
重要的是,这种编码揭示了另一个非常关键的教训:开发人员通常不够重视错误处理代码路径;他们可能或可能不编写负面测试用例来彻底测试它们。这可能导致严重的错误!
通过 ASan 的插装运行,一开始并没有产生预期的效果:你会看到由于明显巨大的malloc失败,ASan 实际上中止了进程执行;因此,它没有检测到我们真正想要的双重释放的真正错误:
$ ./membugs_dbg_asan 10 doublefree(): cond 0
doublefree(): cond 1
==25959==WARNING: AddressSanitizer failed to allocate 0xffffffffffffffff bytes
==25959==AddressSanitizer's allocator is terminating the process instead of returning 0
==25959==If you don't like this behavior set allocator_may_return_null=1
==25959==AddressSanitizer CHECK failed: /build/llvm-toolchain-6.0-QjOn7h/llvm-toolchain-6.0-6.0/projects/compiler-rt/lib/sanitizer_common/sanitizer_allocator.cc:225 "((0)) != (0)" (0x0, 0x0)
#0 0x4e2eb5 in __asan::AsanCheckFailed(char const*, int, char const*, unsigned long long, unsigned long long) (<...>/membugs_dbg_asan+0x4e2eb5)
#1 0x500765 in __sanitizer::CheckFailed(char const*, int, char const*, unsigned long long, unsigned long long) (<...>/membugs_dbg_asan+0x500765)
#2 0x4e92a6 in __sanitizer::ReportAllocatorCannotReturnNull() (<...>/membugs_dbg_asan+0x4e92a6)
#3 0x4e92e6 in __sanitizer::ReturnNullOrDieOnFailure::OnBadRequest() (<...>/membugs_dbg_asan+0x4e92e6)
#4 0x424e66 in __asan::asan_malloc(unsigned long, __sanitizer::BufferedStackTrace*) (<...>/membugs_dbg_asan+0x424e66)
#5 0x4d9d3b in malloc (<...>/membugs_dbg_asan+0x4d9d3b)
#6 0x513938 in doublefree <...>/ch5/membugs.c:129:11
#7 0x5124d2 in process_args <...>/ch5/membugs.c:352:4
#8 0x512291 in main <...>/ch5/membugs.c:375:2
#9 0x7f8a7deccb96 in __libc_start_main /build/glibc-OTsEL5/glibc-2.27/csu/../csu/libc-start.c:310
#10 0x419ea9 in _start (/home/seawolf/0tmp/membugs_dbg_asan+0x419ea9)
$
是的,但是,请注意前一行输出,它说:
[...] If you don't like this behavior set allocator_may_return_null=1 [...]
我们如何告诉 ASan 呢?一个环境变量ASAN_OPTIONS使得可以传递运行时选项;查找它们(回想一下我们已经提供了卫生器工具集的文档链接),我们像这样使用它(可以同时传递多个选项,用:分隔选项;为了好玩,我们还打开了冗长选项,但修剪了输出):
$ ASAN_OPTIONS=verbosity=1:allocator_may_return_null=1 ./membugs_dbg_asan 10
==26026==AddressSanitizer: libc interceptors initialized
[...]
SHADOW_OFFSET: 0x7fff8000
==26026==Installed the sigaction for signal 11
==26026==Installed the sigaction for signal 7
==26026==Installed the sigaction for signal 8
==26026==T0: stack 0x7fffdf206000,0x7fffdfa06000) size 0x800000; local=0x7fffdfa039a8
==26026==AddressSanitizer Init done
doublefree(): cond 0
doublefree(): cond 1
==26026==WARNING: AddressSanitizer failed to allocate 0xffffffffffffffff bytes
membugs.c:doublefree:132: malloc failed
=================================================================
==26026==ERROR: AddressSanitizer: attempting double-free on 0x615000000300 in thread T0:
#0 0x4d9b90 in __interceptor_free.localalias.0 (<...>/membugs_dbg_asan+0x4d9b90)
#1 0x5139b0 in doublefree <...>/membugs.c:133:4
#2 0x5124d2 in process_args <...>/ch5/membugs.c:352:4
#3 0x512291 in main <...>/ch5/membugs.c:375:2
#4 0x7fd41e565b96 in __libc_start_main /build/glibc-OTsEL5/glibc-2.27/csu/../csu/libc-start.c:310
#5 0x419ea9 in _start (/home/seawolf/0tmp/membugs_dbg_asan+0x419ea9)
0x615000000300 is located 0 bytes inside of 512-byte region [0x615000000300,0x615000000500) freed by thread T0 here:
#0 0x4d9b90 in __interceptor_free.localalias.0 (<...>/membugs_dbg_asan+0x4d9b90)
#1 0x51391f in doublefree <...>/ch5/membugs.c:126:2
#2 0x5124d2 in process_args <...>/ch5/membugs.c:352:4
#3 0x512291 in main <...>/ch5/membugs.c:375:2
#4 0x7fd41e565b96 in __libc_start_main /build/glibc-OTsEL5/glibc-2.27/csu/../csu/libc-start.c:310
previously allocated by thread T0 here:
#0 0x4d9d60 in malloc (<...>/membugs_dbg_asan+0x4d9d60)
#1 0x51389d in doublefree <...>/ch5/membugs.c:122:8
#2 0x5124d2 in process_args <...>/ch5/membugs.c:352:4
#3 0x512291 in main <...>/ch5/membugs.c:375:2
#4 0x7fd41e565b96 in __libc_start_main /build/glibc-OTsEL5/glibc-2.27/csu/../csu/libc-start.c:310
SUMMARY: AddressSanitizer: double-free (<...>/membugs_dbg_asan+0x4d9b90) in __interceptor_free.localalias.0
==26026==ABORTING
$
这次,即使遇到分配失败,ASan 也会继续运行,因此找到了真正的错误-双重释放。
测试用例#11:内存泄漏测试用例 1-简单泄漏。参考以下代码:
$ ./membugs_dbg_asan 11
leakage_case1(): will now leak 32 bytes (0 MB)
leakage_case1(): will now leak 1048576 bytes (1 MB)
=================================================================
==26054==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 1048576 byte(s) in 1 object(s) allocated from:
#0 0x4d9d60 in malloc (<...>/membugs_dbg_asan+0x4d9d60)
#1 0x513e34 in amleaky <...>/ch5/membugs.c:66:8
#2 0x513a79 in leakage_case1 <...>/ch5/membugs.c:111:2
#3 0x5124ef in process_args <...>/ch5/membugs.c:356:4
#4 0x512291 in main <...>/ch5/membugs.c:375:2
#5 0x7f2dd5884b96 in __libc_start_main /build/glibc-OTsEL5/glibc-2.27/csu/../csu/libc-start.c:310
Direct leak of 32 byte(s) in 1 object(s) allocated from:
#0 0x4d9d60 in malloc (<...>/membugs_dbg_asan+0x4d9d60)
#1 0x513e34 in amleaky <...>/ch5/membugs.c:66:8
#2 0x513a79 in leakage_case1 <...>/ch5/membugs.c:111:2
#3 0x5124e3 in process_args <...>/ch5/membugs.c:355:4
#4 0x512291 in main <...>/ch5/membugs.c:375:2
#5 0x7f2dd5884b96 in __libc_start_main /build/glibc-OTsEL5/glibc-2.27/csu/../csu/libc-start.c:310
SUMMARY: AddressSanitizer: 1048608 byte(s) leaked in 2 allocation(s).
$
它确实找到了泄漏,并指出了它。还要注意,LeakSanitizer(LSan)实际上是 ASan 的一个子集。
测试用例#13****:内存泄漏测试用例 3- libAPI 泄漏
这是一个截图,展示了 ASan(在幕后,LSan)捕获泄漏时的操作:
![
很好地抓住了!
AddressSanitizer(ASan)摘要表
关于我们的测试用例(并入我们的membugs程序),这是 ASan 的报告卡:
| 测试用例# | 测试用例 | 由 Address Sanitizer 检测到? |
|---|---|---|
| 1 | UMR | 否[1] |
| 2 | OOB(越界):写入溢出[在编译时内存] | 是 |
| 3 | OOB(越界):写入溢出[在动态内存上] | 是 |
| 4 | OOB(越界):写入下溢[在动态内存上] | 是 |
| 5 | OOB(越界):读取溢出[在编译时内存] | 是 |
| 6 | OOB(越界):读取溢出[在动态内存上] | 是 |
| 7 | OOB(越界):读取下溢[在动态内存上] | 是 |
| 8 | UAF(释放后使用)也称为悬空指针 | 是 |
| 9 | UAR 也称为 UAS(返回后使用) | 是[2] |
| 10 | 双重释放 | 是 |
| 11 | 内存泄漏测试用例 1:简单泄漏 | 是 |
| 12 | 内存泄漏测试用例 1:泄漏更多(循环中) | 是 |
| 13 | 内存泄漏测试用例 1:lib API 泄漏 | 是 |
表 4:AddressSanitizer 和内存错误
[1] MemorySanitizer(MSan)正好实现了这个目的-它确实检测到 UMR。但是,有两件事需要注意:
-
UMR 只能由 MSan 在动态分配的内存上检测到
-
成功使用 MSan 需要使用 Clang 编译器(它不能与 GCC 一起工作)
[2]这适用的前提是代码使用 Clang 编译,并通过ASAN_OPTIONS传递detect_stack_use_after_return=1标志。
AddressSanitizer 的优缺点-快速总结
ASan 的优点:
-
捕获常见的内存错误(UB)在静态(编译时)和动态分配的内存区域上
-
越界(OOB)内存访问(读/写下溢/溢出错误)
-
释放后使用(UAF)错误
-
返回后使用(UAR)错误
-
双重释放
-
泄漏
-
性能远远优于其他工具(如 Valgrind);最坏情况下性能下降似乎是 2 倍
-
不需要修改源代码
-
完全支持多线程应用程序
ASan 的缺点:
-
ASan 无法检测到某些类型的错误:
-
UMR(如前所述,带有一些警告,MSan 可以)
-
无法检测所有 UAF 错误
-
IOF(整数下溢/上溢)错误
-
一次只能使用一个特定的工具;不能总是组合多个消毒剂工具(参见前表);这意味着通常必须为 ASan、TSan、LSan 编写单独的测试用例
-
编译器:
-
通常,需要使用 LLVM 前端 Clang 和适当的编译器标志重新编译程序。
-
为了查看带有行号信息的调用堆栈,需要使用
-g标志重新编译/构建。
在这里,我们已经合并了前面的两个表。请参考以下表格,内存错误 - Valgrind 和地址消毒剂之间的快速比较:
| 测试用例# | 测试用例 | Valgrind 检测到? | 地址消毒剂检测到? |
|---|---|---|---|
| 1 | UMR | 是 | 否[1] |
| 2 | OOB(越界):写入溢出[在编译时内存上] | 否 | 是 |
| 3 | OOB(越界):写入溢出[在动态内存上] | 是 | 是 |
| 4 | OOB(越界):写入下溢[在动态内存上] | 是 | 是 |
| 5 | OOB(越界):读取溢出[在编译时内存上] | 否 | 是 |
| 6 | OOB(越界):读取溢出[在动态内存上] | 是 | 是 |
| 7 | OOB(越界):读取下溢[在动态内存上] | 是 | 是 |
| 8 | UAF(释放后使用)也称为悬空指针 | 是 | 是 |
| 9 | UAR(返回后使用)也称为 UAS(作用域后使用) | 否 | 是[2] |
| 10 | 重复释放 | 是 | 是 |
| 11 | 内存泄漏测试用例 1:简单泄漏 | 是 | 是 |
| 12 | 内存泄漏测试用例 1:泄漏更多(循环中) | 是 | 是 |
| 13 | 内存泄漏测试用例 1:lib API 泄漏 | 是 | 是 |
[1]MSan 正好实现了这个目的-它确实检测 UMR(也请参见警告)。
它与警告一起使用,即代码使用 Clang 编译,并通过ASAN_OPTIONS传递了detect_stack_use_after_return=1标志。
Glibc mallopt
对程序员有时很有用,glibc 提供了一种通过传递一些特定参数来更改 malloc 引擎默认值的方法。API 是mallopt(3):
#include <malloc.h>
int mallopt(int param, int value);
请参阅mallopt(3)的 man 页面,了解所有可怕的细节(可在man7.org/linux/man-pages/man3/mallopt.3.html上找到)。
作为一个有趣的例子,可以调整的参数之一是M_MMAP_THRESHOLD;回想一下,在之前的第五章中,Linux 内存问题,我们已经讨论过在现代 glibc 上,malloc 并不总是从堆段获取内存块。如果分配请求的大小大于或等于MMAP_THRESHOLD,则在底层通过强大的mmap(2)系统调用(设置请求大小的任意虚拟地址空间区域)来服务请求。MMAP_THRESHOLD的默认值为 128 KB;可以通过使用mallopt(3)的M_MMAP_THRESHOLD参数进行更改!
再次强调,这并不意味着您应该更改它;只是您可以。默认值经过精心设计,可能最适合大多数应用程序工作负载。
另一个有用的参数是M_CHECK_ACTION;此参数确定在检测到内存错误时 glibc 的反应(例如,写入溢出或重复释放)。还要注意,该实现不检测所有类型的内存错误(例如,泄漏不会被注意到)。
在运行时,glibc 解释参数值的最低三位(LSB)以确定如何做出反应:
- 位 0:如果设置,将在
stderr上打印一行错误消息,提供有关原因的详细信息;错误行格式为:
*** glibc detected *** <program-name>: <function where error was detected> : <error description> : <address>
-
位 1:如果设置了,在打印错误消息后,将调用
abort(3)导致进程终止。根据库的版本,还可能打印堆栈跟踪和进程内存映射的相关部分(通过 proc)。 -
位 2:如果设置,并且设置了位 0,则简化错误消息格式。
从 glibc ver。2.3.4 开始,M_CHECK_ACTION的默认值为 3(意味着二进制 011;之前是 1)。
将M_CHECK_ACTION设置为非零值非常有用,因为它将导致出现错误的进程在命中错误时崩溃,并显示有用的诊断信息。如果值为零,进程可能会进入未定义状态(UB),并在将来的某个任意点崩溃,这将使调试变得更加困难。
作为一个快速的参考者,这里有一些有用的M_CHECK_ACTION值及其含义:
-
1 (001b):打印详细的错误消息,但继续执行(进程现在处于 UB 状态!)。
-
3 (011b):打印详细的错误消息、调用堆栈、内存映射,并中止执行[默认]。
-
5 (101b):打印简单的错误消息并继续执行(进程现在处于 UB 状态!)。
-
7 (111b):打印简单的错误消息、调用堆栈、内存映射,并中止执行。
mallopt(3)的 man 页面提供了一个使用M_CHECK_ACTION的 C 程序示例。
通过环境设置 Malloc 选项
一个有用的功能:系统允许我们通过环境变量方便地调整一些分配参数,而不是通过编程方式使用mallopt(3) API。也许最有用的是,从调试和测试的角度来看,MALLOC_CHECK_变量是与先前描述的M_CHECK_ACTION参数对应的环境变量;因此,我们只需设置值,运行我们的应用程序,然后亲自查看结果!
以下是一些示例,使用我们通常的 membugs 应用程序来检查一些测试用例:
测试用例#10:在设置MALLOC_CHECK_的情况下,使用double free:
$ MALLOC_CHECK_=1 ./membugs_dbg 10
doublefree(): cond 0
doublefree(): cond 1
membugs.c:doublefree:134: malloc failed
*** Error in `./membugs_dbg': free(): invalid pointer: 0x00005565f9f6b420 ***
$ MALLOC_CHECK_=3 ./membugs_dbg 10
doublefree(): cond 0
doublefree(): cond 1
membugs.c:doublefree:134: malloc failed
*** Error in `./membugs_dbg': free(): invalid pointer: 0x0000562f5da95420 ***
Aborted
$ MALLOC_CHECK_=5 ./membugs_dbg 10
doublefree(): cond 0
doublefree(): cond 1
membugs.c:doublefree:134: malloc failed
$ MALLOC_CHECK_=7 ./membugs_dbg 10
doublefree(): cond 0
doublefree(): cond 1
membugs.c:doublefree:134: malloc failed
$
请注意,当MALLOC_CHECK_的值为 1 时,错误消息被打印,但进程没有中止;这就是当环境变量的值设置为3时发生的情况。
测试用例#7:在设置MALLOC_CHECK_的情况下,进行越界(读取下溢):
$ MALLOC_CHECK_=3 ./membugs_dbg 7
read_underflow(): cond 0
dest: abcd56789
read_underflow(): cond 1
dest: xabcd56789
*** Error in `./membugs_dbg': free(): invalid pointer: 0x0000562ce36d9420 ***
Aborted
$
测试用例#11:内存泄漏测试用例 1——在设置MALLOC_CHECK_的情况下,进行简单泄漏:
$ MALLOC_CHECK_=3 ./membugs_dbg 11
leakage_case1(): will now leak 32 bytes (0 MB)
leakage_case1(): will now leak 1048576 bytes (1 MB)
$
注意泄漏错误测试用例未被检测到。
前面的示例是在 Ubuntu 17.10 x86_64 上执行的;由于某种原因,在 Fedora 27 上对MALLOC_CHECK_的解释似乎并不像广告中描述的那样有效。
一些关键点
我们已经介绍了一些强大的内存调试工具和技术,但归根结底,这些工具本身是不够的。今天的开发人员必须保持警惕——还有一些关键点需要简要提及,这将为本章画上一个圆满的句号。
测试时的代码覆盖率
要记住使用动态分析工具(我们介绍了使用 Valgrind 的 Memcheck 工具和 ASan/MSan)的一个关键点是,只有在运行工具时实现了完整的代码覆盖率,它才真正有助于我们的工作!
这一点无法强调得足够。如果代码的错误部分实际上没有运行,那么运行一个奇妙的工具或编译器插装(例如 Sanitizers)有什么用呢!错误仍然潜伏,未被捕获。作为开发人员和测试人员,我们必须自律地编写严格的测试用例,确保实际上执行了完整的代码覆盖,以便通过这些强大的工具测试所有代码,包括库中的项目代码。
这并不容易:记住,任何值得做的事情都值得做好。
现代 C/C++开发人员该怎么办?
面对 C/C++复杂软件项目中潜在的 UB 问题,关注的开发人员可能会问,我们该怎么办?
来源:blog.regehr.org/archives/1520。这是一篇来自优秀博客文章《2017 年的未定义行为》的摘录,作者是 Cuoq 和 Regehr。
现代 C 或 C++开发人员该怎么办?
-
熟悉一些易于使用的 UB 工具——通常可以通过调整 makefile 来启用的工具,例如编译器警告和 ASan 和 UBSan。尽早并经常使用这些工具,并(至关重要)根据它们的发现采取行动。
-
熟悉一些难以使用的 UB 工具——例如 TIS Interpreter 通常需要更多的努力来运行——并在适当的时候使用它们。
-
在进行广泛的测试(跟踪代码覆盖率,使用模糊器)以便充分利用动态 UB 检测工具。
-
进行 UB 意识的代码审查:建立一个文化,我们共同诊断潜在危险的补丁并在其落地之前修复它们。
-
要了解 C 和 C++标准中实际包含的内容,因为这是编译器编写者所遵循的。避免重复的陈词滥调,比如 C 是一种可移植的汇编语言,相信程序员。
提到了 malloc API 辅助程序
有很多mallocAPI 辅助程序。在调试困难的情况下,这些可能会很有用;了解有哪些可用的是个好主意。
在 Ubuntu Linux 系统中,我们通过 man 检查与关键字malloc匹配的内容:
$ man -k malloc
__after_morecore_hook (3) - malloc debugging variables
__free_hook (3) - malloc debugging variables
__malloc_hook (3) - malloc debugging variables
__malloc_initialize_hook (3) - malloc debugging variables
__memalign_hook (3) - malloc debugging variables
__realloc_hook (3) - malloc debugging variables
malloc (3) - allocate and free dynamic memory
malloc_get_state (3) - record and restore state of malloc implementation
malloc_hook (3) - malloc debugging variables
malloc_info (3) - export malloc state to a stream
malloc_set_state (3) - record and restore state of malloc implementation
malloc_stats (3) - print memory allocation statistics
malloc_trim (3) - release free memory from the top of the heap
malloc_usable_size (3) - obtain size of block of memory allocated from heap
mtrace (1) - interpret the malloc trace log
mtrace (3) - malloc tracing
muntrace (3) - malloc tracing
$
这些mallocAPI 中有相当多的(提醒:括号内的数字三(3)表示这是一个库例程)与 malloc 挂钩的概念有关。基本思想是:可以用自己的hook函数替换库的malloc(3)、realloc(3)、memalign(3)和free(3)API,当应用程序调用 API 时将调用该函数。
然而,我们不会进一步深入这个领域;为什么呢?glibc 的最新版本记录了这样一个事实,即这些挂钩函数是:
-
不是 MT-Safe(在第十六章中有介绍,使用 Pthreads 进行多线程编程第三部分)
-
从 glibc ver. 2.24 开始弃用
最后,这可能是显而易见的,但我们更愿意明确指出:必须意识到,使用这些工具只在测试环境中有意义;它们不应该在生产中使用!一些研究已经揭示了在生产中运行 ASan 时可能会被利用的安全漏洞;请参阅 GitHub 存储库上的进一步阅读部分。
总结
在本章中,我们试图向读者展示几个关键点、工具和技术;其中包括:
-
人会犯错误;这在内存未受管理的语言(C、C++)中尤其如此。
-
在非平凡的代码库中,确实需要强大的内存调试工具。
-
我们详细介绍了这两种最佳动态分析工具中的两种:
-
Valgrind 的 Memcheck
-
消毒剂(主要是 ASan)
-
通过
mallopt(3)API 和环境变量,glibc 允许对malloc进行一些调整。 -
在构建测试用例时确保完整的代码覆盖率对项目的成功至关重要。
下一章与文件 I/O 的基本方面有关,这对于组件读者来说是必不可少的。它向您介绍了如何在 Linux 平台上执行高效的文件 I/O。我们请求读者阅读这一章,可在此处找到:www.packtpub.com/sites/default/files/downloads/File_IO_Essentials.pdf。我们强烈建议读者阅读系统调用层的 Open,文件描述符和 I/O - 读/写系统调用,这有助于更容易理解下一章,即第七章,进程凭证。
第七章:进程凭证
在本章和下一章中,读者将学习有关进程凭证和能力的概念和实践。除了在 Linux 应用程序开发中具有实际重要性之外,本章本质上更深入地探讨了一个经常被忽视但极其关键的方面:安全性。本章和下一章的内容非常相关。
我们将这一关键领域的覆盖分为两个主要部分,每个部分都是本书的一个章节:
-
在本章中,详细讨论了传统风格的 Unix 权限模型,并展示了在不需要根密码的情况下以 root 权限运行程序的技术。
-
在第八章 进程能力中,讨论了现代方法,POSIX 能力模型的一些细节。
我们将尝试清楚地向读者表明,虽然重要的是了解传统机制及其运作方式,但了解现代安全性方法也同样重要。无论如何看待它,安全性都是非常重要的,尤其是在当今。Linux 在各种设备上运行——从微小的物联网和嵌入式设备到移动设备、台式机、服务器和超级计算平台——使安全性成为所有利益相关者的关键关注点。因此,在开发软件时应使用现代能力方法。
在本章中,我们将广泛介绍传统的 Unix 权限模型,它究竟是什么,以及它是如何提供安全性和稳健性的。一点黑客攻击总是有趣的!
您将了解以下内容:
-
Unix 权限模型的运行
-
真实和有效的身份证
-
强大的系统调用来查询和设置进程凭证
-
黑客攻击(一点点)
-
sudo(8)实际上是如何工作的 -
保存的身份证
-
关于安全性的重要思考
在这个过程中,几个示例允许您以实际操作的方式尝试概念,以便真正理解它们。
传统的 Unix 权限模型
从 1970 年初开始,Unix 操作系统通常具有一个优雅而强大的系统,用于管理系统上共享对象的安全性。这些对象包括文件和目录——也许是最常考虑的对象。文件、目录和符号链接是文件系统对象;还有其他几个,包括内存对象(任务、管道、共享内存区域、消息队列、信号量、密钥、套接字)和伪文件系统(proc、sysfs、debugfs、cgroupfs 等)及其对象。重点是所有这些对象都以某种方式共享,因此它们需要某种保护机制,以防止滥用;这种机制称为 Unix 权限模型。
您可能不希望其他人读取、写入和删除您的文件;Unix 权限模型使这在各种粒度级别上成为可能;再次,以文件和目录作为常见目标,您可以在目录级别设置权限,或者在该目录中的每个文件(和目录)上设置权限。
为了明确这一点,让我们考虑一个典型的共享对象——磁盘上的文件。让我们创建一个名为myfile的文件:
$ cat > myfile
This is my file.
It has a few lines of not
terribly exciting content.
A blank line too! WOW.
You get it...
Ok fine, a useful line: we shall keep this file in the book's git repo.
Bye.
$ ls -l myfile
-rw-rw-r-- 1 seawolf seawolf 186 Feb 17 13:15 myfile
$
所有显示的输出都来自 Ubuntu 17.10 x86_64 Linux 系统;用户以seawolf登录。
用户级别的权限
之前我们对之前的myfile文件进行了快速的ls -l;第一个字符-当然显示它是一个常规文件;接下来的九个字符rw-rw-r--是文件权限。如果您记得,这些被分成三组——所有者(U)、组(G)和其他人(O)(或公共)权限,每个组包含三个权限位:r、w和x(读取、写入和执行访问)。这张表总结了这些信息:

解释一下,我们可以看到文件的所有者可以读取和写入它,组成员也可以,但其他人(既不是所有者也不属于文件所属的组)只能对myfile执行读操作。这就是安全性!
因此,让我们举个例子:我们尝试使用echo命令写入文件myfile:
echo "I can append this string" >> myfile
它会起作用吗?嗯,答案是,这取决于:如果文件的所有者或组成员(在本例中是 seawolf)正在运行 echo(1)进程,那么访问类别将相应地设置为 U 或 G,是的,它将成功(因为 U|G 对文件具有写访问权限)。但是,如果进程的访问类别是其他或公共,它将失败。
Unix 权限模型是如何工作的
关于这个主题的一个非常重要的理解点是:正在处理的共享对象(这里是myfile文件)和正在对对象执行某些访问(rwx)的进程(这里是 echo 进程)都很重要。更正确地说,它们的权限属性很重要。下一次讨论将有助于澄清这一点。
让我们一步一步地考虑这个问题:
-
使用登录名
seawolf的用户登录到系统。 -
成功后,系统会生成一个 shell;用户现在处于 shell 提示符下。(在这里,我们考虑的是登录到命令行界面(CLI)控制台的传统情况,而不是 GUI 环境。)
每个用户都有一条记录;它存储在/etc/passwd文件中。让我们为这个用户grep文件:
$ grep seawolf /etc/passwd
seawolf:x:1000:1000:Seawolf,,,:/home/seawolf:/bin/bash
$
通常,只需这样做:grep $LOGNAME /etc/passwd
passwd条目是一个有七列的行,它们是以冒号分隔的字段;它们如下:
username:<passwd>:UID:GID:descriptive_name:home_dir:program
有几个字段需要解释一下:
-
第二个字段
<passwd>在现代 Linux 系统上总是显示为x;这是为了安全。即使加密密码也不会显示出来(黑客很可能可以通过暴力算法破解它;它在一个只有 root 用户才能访问的文件/etc/shadow中)。 -
第三和第四个字段是用户的用户标识符(UID)和组标识符(GID)。
-
第七个字段是成功登录时要运行的程序;通常是 shell(如前所述),但也可以是其他任何东西。
要以编程方式查询/etc/passwd,请查看getpwnam_r,getpwent_r库层 API。
最后一点是关键的:系统为登录的用户生成一个 shell。shell 是 CLI 环境中人类用户和系统之间的用户界面(UI)。毕竟,它是一个进程;在 Linux 上,bash 通常是我们使用的 shell。当您登录时收到的 shell 称为您的登录 shell。这很重要,因为它的特权决定了它启动的所有进程的特权——实际上,您在系统上工作时拥有的特权是从您的登录 shell 派生的。
让我们查找我们的 shell 进程:
$ ps
PID TTY TIME CMD
13833 pts/5 00:00:00 bash
30500 pts/5 00:00:00 ps
$
这就是了;我们的 bash 进程有一个进程标识符(PID——一个唯一的整数标识进程)为 13833。现在,进程还有其他与之关联的属性;对于我们当前的目的来说,关键的是进程用户标识符(UID)和进程组标识符(GID)。
可以查找进程的 UID、GID 值吗?让我们尝试使用id(1)命令:
$ id
uid=1000(seawolf) gid=1000(seawolf) groups=1000(seawolf),4(adm),24(cdrom),27(sudo),[...]
$
id(1)命令向我们显示,进程 UID 是 1000,进程 GID 也恰好是 1000。(用户名是seawolf,这个用户属于几个组。)在前面的例子中,我们已经以用户seawolf的身份登录;id命令反映了这一事实。请注意,我们现在从这个 shell 运行的每个进程都将继承这个用户帐户的特权,也就是说,它将以与登录 shell 相同的 UID 和 GID 运行!
您可能会合理地问:进程的 UID 和 GID 值是从哪里获取的?嗯,想想看:我们以用户seawolf的身份登录,这个帐户的/etc/passwd条目的第三个和第四个字段是进程 UID 和 GID 的来源。
因此,每次我们从这个 shell 运行一个进程,该进程将以 UID 1000 和 GID 1000 运行。
我们想要了解操作系统如何准确地检查我们是否可以执行以下操作:
echo "I can append this string" >> myfile
因此,这里的关键问题是:在运行时,当前的 echo 进程尝试写入myfile文件时,内核如何确定写入访问是否被允许。为了做到这一点,操作系统必须确定以下内容:
-
所讨论的文件的所有权和组成员资格是什么?
-
进程尝试访问的访问类别是什么(例如,是 U|G|O)?
-
对于该访问类别,权限掩码是否允许访问?
回答第一个问题:文件的所有权和组成员信息(以及关于文件的更多信息)作为文件系统的关键数据结构的属性进行传递——信息节点(inode)。inode 数据结构是一个每个文件的结构,并且存在于内核中(文件系统;当文件首次被访问时,它被读入内存)。用户空间当然可以通过系统调用访问这些信息。因此,文件所有者 ID 存储在 inode 中——让我们称之为file_UID。类似地,file_GID也将存在于 inode 对象中。
对于好奇的读者:您可以使用强大的stat(2)系统调用自己查询任何文件对象的 inode。(像往常一样,查阅它的手册页)。事实上,我们在附录 A中使用了stat(2),文件 I/O 基础。
确定访问类别
先前提出的第二个问题:它将以哪种访问类别运行?这是很重要的问题。
访问类别将是所有者(U)、组(G)或其他(O)中的一个;它们是互斥的。操作系统用于确定访问类别的算法大致如下:
if process_UID == file_UID
then
access_category = U
else if process_GID == file_GID
then
access_category = G
else
access_category = O
fi
实际上,情况要复杂一些:一个进程可以同时属于多个组。因此,在检查权限时,内核会检查所有组;如果进程属于其中任何一个组,访问类别就设置为 G。
最后,对于该访问类别,检查权限掩码(rwx);如果相关位被设置,进程将被允许进行操作;如果没有,就不会被允许。
让我们看看以下命令:
$ ls -l myfile
-rw-rw-r-- 1 seawolf seawolf 186 Feb 17 13:15 myfile
$
另一种澄清的方法——stat(1)命令(当然是stat(2)系统调用的包装器)显示了文件myfile的 inode 内容,就像这样:
$ stat myfile
File: myfile
Size: 186 Blocks: 8 IO Block: 4096 regular file
Device: 801h/2049d Inode: 1182119 Links: 1
Access: (0664/-rw-rw-r--) Uid: ( 1000/ seawolf) Gid: ( 1000/ seawolf)
Access: 2018-02-17 13:15:52.818556856 +0530
Modify: 2018-02-17 13:15:52.818556856 +0530
Change: 2018-02-17 13:15:52.974558288 +0530
Birth: -
$
显然,我们正在强调file_UID == 1000和file_GID == 1000。
在我们的 echo 示例中,我们发现,根据谁登录,组成员资格和文件权限,可以出现一些情景。
因此,为了正确理解这一点,让我们设想一些情景(从现在开始,我们将只是将进程 UID 称为UID,将进程 GID 值称为GID,而不是process_UID|GID):
-
用户以 seawolf 身份登录:[UID 1000,GID 1000]
-
用户以 mewolf 身份登录:[UID 2000,GID 1000]
-
用户以 cato 身份登录:[UID 3000,GID 3000]
-
用户以 groupy 身份登录:[UID 4000,GID 3000,GID 2000,GID 1000]
一旦登录,用户尝试执行以下操作:
echo "I can append this string" >> <path/to/>myfile
发生了什么?哪个会起作用(权限允许),哪个不会?通过先前的算法运行先前的情景,确定关键的访问类别,你会看到;以下表总结了这些情况:
| 案例# | 登录为 | (进程)UID | (进程)GID | 访问类别 (U|G|O) | Perm bitmask | 允许写入? |
|---|---|---|---|---|---|---|
| 1 | seawolf | 1000 | 1000 | U | r**w**- |
Y |
| 2 | mewolf | 2000 | 1000 | G | r**w**- |
Y |
| 3 | cato | 3000 | 3000 | O | r**-**- |
N |
| 4 | groupy | 4000 | 4000,3000, 2000,1000 | G | r**w**- |
Y |
前面的描述仍然有点太简单了,但是作为一个很好的起点。实际上,在幕后发生了更多的事情;接下来的部分将阐明这一点。
在此之前,我们将稍微偏离一下:chmod(1)命令(当然会变成chmod(2)系统调用)用于设置对象的权限。因此,如果我们这样做:chmod g-w myfile来从组类别中删除写权限,那么之前的表将会改变(获得 G 访问权限的行现在将不允许写入)。
这里有一个有趣的观察:渴望获得 root 访问权限的进程是那些UID = 0的进程;这是一个特殊的值!
接下来,严谨地说,echo 命令实际上可以以两种不同的方式运行:一种是作为一个进程,当二进制可执行文件(通常是/bin/echo)运行时,另一种是作为一个内置的 shell 命令;换句话说,没有新的进程,shell 进程本身——通常是bash——运行它。
真实和有效 ID
我们从前面的部分了解到,正在处理的共享对象(这里是文件 myfile)和执行某些访问操作的进程(这里是 echo 进程)在权限方面都很重要。
让我们更深入地了解与权限模型相关的进程属性。到目前为止,我们已经了解到每个进程都与一个 UID 和一个 GID 相关联,从而允许内核运行其内部算法,并确定是否应该允许对资源(或对象)的访问。
如果我们深入研究,我们会发现每个进程 UID 实际上不是一个单一的整数值,而是两个值:
-
真实用户 ID(RUID)
-
有效用户 ID(EUID)
同样,组信息不是一个整数 GID 值,而是两个整数:
-
真实组 ID(RGID)
-
有效组 ID(EGID)
因此,关于特权,每个进程都有与之关联的四个整数值:
{RUID, EUID, RGID, EGID};这些被称为进程凭证。
严格来说,进程凭证还包括其他几个进程属性——进程 PID、PPID、PGID、会话 ID 以及真实和有效用户和组 ID。在我们的讨论中,为了清晰起见,我们将它们的含义限制在最后一个——真实和有效用户和组 ID。
但它们究竟是什么意思呢?
每个进程都必须在某人的所有权和组成员身份下运行;这个某人当然是登录的用户和组 ID。
真实 ID 是与登录用户关联的原始值;实际上,它们只是来自该用户的/etc/passwd记录的 UID:GID 对。回想一下,id(1)命令恰好显示了这些信息:
$ id
uid=1000(seawolf) gid=1000(seawolf) groups=1000(seawolf),4(adm), [...]
$
显示的uid和gid值是从/etc/passwd记录中的 seawolf 获取的。实际上,uid/gid值分别成为运行进程的 RUID/RGID 值!
真实数字反映了你最初的身份——以整数标识符的登录帐户信息。另一种说法是:真实数字反映了谁拥有该进程。
那么有效值呢?
有效值是为了通知操作系统,当前进程正在以什么样的特权(用户和组)运行。以下是一些关键点:
-
在执行权限检查时,操作系统使用进程的有效值,而不是真实(原始)值。
-
EUID = 0是操作系统实际检查的内容,以确定进程是否具有 root 特权。
默认情况下如下:
-
EUID = RUID
-
EGID = RGID
这意味着,对于前面的例子,以下是正确的:
{RUID, EUID, RGID, EGID} = {1000, 1000, 1000, 1000}
是的。这引发了一个问题(你不觉得吗?):如果真实和有效 ID 是相同的,那么为什么我们需要四个数字呢?两个就够了,对吧?
嗯,事实是:它们通常(默认情况下)是相同的,但它们可以改变。让我们看看这是如何发生的。
再次强调一下:在 Linux 上,文件系统操作的权限检查是基于另一个进程凭证-文件系统 UID(或 fsuid;类似地,fsgid)。然而,总是情况是 fsuid/fsgid 对遮蔽了 EUID/EGID 对的凭证-从而有效地使它们相同。这就是为什么在我们的讨论中我们忽略fs[u|g]id并专注于通常的真实和有效的用户和组 ID。
在那之前,想想这种情况:一个用户登录并在 shell 上;他们有什么特权?好吧,只需运行id(1)程序;输出将显示 UID 和 GID,我们现在知道实际上是{RUID,EUID}和{RGID,EGID}对,具有相同的值。
为了更容易阅读的例子,让我们随便将 GID 值从 1000 更改为 2000。所以,现在,如果值是 UID=1000 和 GID=2000,用户现在运行,我们应该说,vi 编辑器,现在情况是这样的,参考给定的表,进程凭证 - 正常情况:
| 进程凭证 / 进程 | RUID | EUID | RGID | EGID |
|---|---|---|---|---|
| bash | 1000 | 1000 | 2000 | 2000 |
| vi | 1000 | 1000 | 2000 | 2000 |
一个谜题-普通用户如何更改他们的密码?
假设你以seawolf登录。出于安全原因,你想要将你的弱密码(hello123,哎呀!)更新为一个强密码。我们知道密码存储在/etc/passwd文件中。好吧,我们也知道在现代 Unix 系统(包括 Linux)中,为了更好的安全性,密码是shadowed:实际上存储在一个名为/etc/shadow的文件中。让我们来看看:
$ ls -l /etc/shadow
-rw-r----- 1 root shadow 891 Jun 1 2017 /etc/shadow
$
(请记住,我们在 Ubuntu 17.10 x86_64 系统上;我们经常指出这一点,因为在不同的发行版上,确切的输出可能会有所不同,如果安装了诸如 SELinux 之类的内核安全机制。)
正如上面所强调的,你可以看到文件所有者是 root,组成员是 shadow,UGO 的权限掩码为[rw-][r--][---]。这意味着以下内容:
-
所有者(root)可以执行读/写操作
-
组(shadow)可以执行只读操作
-
其他人无法对文件进行任何操作
你可能也知道,你用来更改密码的实用程序叫做passwd(1)(当然,它是一个二进制可执行程序,并且不应与/etc/passwd(5)数据库混淆)。
所以,想一想,我们有一个谜题:要更改你的密码,你需要对/etc/shadow有写访问权限,但是,显然,只有 root 有对/etc/shadow的写访问权限。那么,它是如何工作的呢?(我们知道它是如何工作的。你以普通用户身份登录,而不是 root。你可以使用passwd(1)实用程序来更改你的密码-试一试看。)所以,这是一个很好的问题。
线索就在二进制可执行实用程序本身-passwd。让我们来看看;首先,磁盘上的实用程序在哪里?参考以下代码:
$ which passwd
/usr/bin/passwd
$
让我们深入挖掘-引用前面的命令并进行长列表:

你能发现任何异常吗?
这是所有者执行位:它不是你可能期望的x,而是一个s!(实际上,这就是在前面的长列表中可执行文件名字的漂亮红色背后的原因。)
这是一个特殊的权限位:对于一个二进制可执行文件,当所有者的执行位中有一个s时,它被称为 setuid 二进制文件。这意味着每当执行 setuid 程序时,生成的进程的有效用户 ID(EUID)会改变(从默认值:原始 RUID 值)变为等于二进制可执行文件的所有者;在前面的例子中,EUID 将变为 root(因为/usr/bin/passwd文件的所有者是 root)。
现在,我们根据手头的新信息重新绘制上一个表(进程凭证-正常情况),关于 setuid passwd 可执行文件:
| 进程凭证 / 进程 | RUID | EUID | RGID | EGID |
|---|---|---|---|---|
| bash | 1000 | 1000 | 2000 | 2000 |
| vi | 1000 | 1000 | 2000 | 2000 |
| /usr/bin/passwd | 1000 | 0 | 2000 | 2000 |
表:进程凭据 - setuid-root 情况(第三行)
因此,这回答了它是如何工作的:EUID 是特殊值0(root),操作系统现在将进程视为 root 进程,并允许其写入/etc/shadow数据库。
例如/usr/bin/passwd这样的程序,通过 setuid 位继承了 root 访问权限,并且文件所有者是 root:这些类型的程序称为 setuid root 二进制文件(它们也被称为 set-user-ID-root 程序)。
引用一个受挫的开发人员对所有测试人员的反应:这不是一个 bug;这是一个功能! 好吧,它就是:setuid 功能非常了不起:完全不需要编程,您就能够提高进程的特权级别,持续一段时间。
想想这个。如果没有这个功能,非 root 用户(大多数用户)将无法更改他们的密码。要求系统管理员执行此操作(想象一下拥有几千名员工具有 Linux 账户的大型组织)不仅会让系统管理员考虑自杀,还必须向系统管理员提供您的新密码,这可能并不是一个明智的安全实践。
setuid 和 setgid 特殊权限位
我们可以看到 setuid 程序二进制文件是前面讨论的一个重要内容;让我们再次总结一下:
-
拥有所有者执行位设置为
s的二进制可执行文件称为setuid 二进制文件。 -
如果该可执行文件的所有者是 root,则称为setuid-root 二进制文件。
-
当您执行 setuid 程序时,关键点在于 EUID 设置为二进制可执行文件的所有者:
-
因此,使用 setuid-root 二进制文件,进程将以 root 身份运行!
-
当进程死掉后,您将回到具有常规(默认)进程凭据或特权的 shell。
在概念上类似于 setuid 的是 setgid 特殊权限位的概念:
-
拥有组执行位设置为
s的二进制可执行文件称为 setgid 二进制文件。 -
当您执行 setgid 程序时,关键点在于 EGID 设置为二进制可执行文件的组成员身份。
-
当进程死掉后,您将回到具有常规(默认)进程凭据或特权的 shell。
如前所述,请记住,set[u|g]id特殊权限位只对二进制可执行文件有意义,对于脚本(bash、Perl 等)尝试设置这些位将完全没有效果。
使用chmod设置 setuid 和 setgid 位
也许到现在为止,您已经想到了,但是我到底如何设置这些特殊权限位呢?
这很简单:您可以使用chmod(1)命令(或系统调用);此表显示了如何使用 chmod 设置setuid/setgid权限位:
通过chmod: |
设置 setuid 的符号 | 设置 setgid 的符号 |
|---|---|---|
| 符号表示 | u+s |
g+s |
| 八进制符号 | 4<八进制 #> (例如 4755) |
2<八进制 #> (例如 2755) |
举个简单的例子,拿一个简单的Hello, world C 程序并编译它:
gcc hello.c -o hello
现在我们设置了 setuid 位,然后删除它,并设置了 setgid 位(通过u-s,g+s参数进行一次操作:通过chmod),然后删除了 setgid 位,同时长时间列出二进制可执行文件以便查看权限:
$ ls -l hello
-rwxrwxr-x 1 seawolf seawolf 8336 Feb 17 19:02 hello
$ chmod u+s hello ; ls -l hello
-rwsrwxr-x 1 seawolf seawolf 8336 Feb 17 19:02 hello
$ chmod u-s,g+s hello ; ls -l hello
-rwxrwsr-x 1 seawolf seawolf 8336 Feb 17 19:02 hello
$ chmod g-s hello ; ls -l hello
-rwxrwxr-x 1 seawolf seawolf 8336 Feb 17 19:02 hello
$
(由于这个Hello, world程序只是简单地打印到 stdout,没有其他作用,因此 setuid/setgid 位没有任何感知效果。)
黑客尝试 1
嗯,嗯,对于您这位像黑客一样思考的读者(干得好!),为什么不这样做以获得最终奖励,即 root shell!
-
编写一个生成 shell 的 C 程序(
system(3)库 API 使这变得简单);我们将代码称为rootsh_hack1.c。我们希望得到一个 root shell 作为结果! -
编译它,得到
a.out。如果我们现在运行a.out,没什么大不了的;我们将得到一个具有我们已经拥有的相同特权的 shell。所以尝试这个: -
使用
chmod(1)更改权限以设置setuid位。 -
使用
chown(1)将a.out的所有权更改为 root。 -
运行它:我们现在应该得到一个 root shell。
哇!让我们试试这个!
代码很简单(我们这里不显示头文件的包含):
$ cat rootsh_hack1.c
[...]
int main(int argc, char **argv)
{
/* Just spawn a shell.
* If this process runs as root,
* then, <i>Evil Laugh</i>, we're now root!
*/
system("/bin/bash");
exit (EXIT_SUCCESS);
}
现在编译并运行:
$ gcc rootsh_hack1.c -Wall
$ ls -l a.out
-rwxrwxr-x 1 seawolf seawolf 8344 Feb 20 10:15 a.out
$ ./a.out
seawolf@seawolf-mindev:~/book_src/ch7$ id -u
1000
seawolf@seawolf-mindev:~/book_src/ch7$ exit
exit
$
如预期的那样,当没有特殊的set[u|g]id权限位运行时,a.out 进程以普通特权运行,生成一个与相同所有者(seawolf)的 shell——正是id -u命令证明的。
现在,我们尝试我们的黑客行为:
$ chmod u+s a.out
$ ls -l a.out
-rwsrwxr-x 1 seawolf seawolf 8344 Feb 20 10:15 a.out
$
成功了!好吧,不要太兴奋:我们已经将其变成了一个 setuid 二进制文件,但所有者仍然是seawolf;因此在运行时不会有任何区别:进程 EUID 将变为二进制可执行文件的所有者seawolf本身:
$ ./a.out
seawolf@seawolf-mindev:~/book_src/ch7$ id -u
1000
seawolf@seawolf-mindev:~/book_src/ch7$ exit
exit
$
嗯。是的,所以我们现在需要做的是将所有者更改为 root:
$ chown root a.out
chown: changing ownership of 'a.out': Operation not permitted
$
抱歉要打破你的幻想,新手黑客:这行不通。这就是安全性;使用chown(1),你只能更改你拥有的文件(或对象)的所有权,猜猜?只能更改为你自己的帐户!只有 root 可以使用chown将对象的所有权设置为其他任何人。
从安全性方面来看这是有道理的。它甚至更进一步;看看这个:我们将成为 root 并运行chown(当然只是通过sudo):
$ sudo chown root a.out
[sudo] password for seawolf: xxx
$ ls -l a.out
-rwxrwxr-x 1 root seawolf 8344 Feb 20 10:15 a.out*
$
你注意到了吗?即使chown成功了,setuid 位也被清除了!这就是安全性。
好吧,让我们甚至通过手动在 root-owned a.out 上设置 setuid 位来颠覆这一点(请注意,除非我们已经拥有 root 访问权限或密码,否则这是不可能的):
$ sudo chmod u+s a.out
$ ls -l a.out
-rwsrwxr-x 1 root seawolf 8344 Feb 20 10:15 a.out
$
啊!现在它是一个 setuid-root 二进制可执行文件(确实,你在这里看不到,但 a.out 的颜色变成了红色)。没有人会阻止我们!看看这个:
$ ./a.out
seawolf@seawolf-mindev:~/book_src/ch7$ id -u
1000
seawolf@seawolf-mindev:~/book_src/ch7$ exit
exit
$
生成的 shell 的(R)UID 为 1000,而不是 0。发生了什么?
真是个惊喜!即使拥有 root 所有权和 setuid 位,我们也无法获得 root shell。怎么回事?当然是因为安全性:当通过system(3)运行时,现代版本的 bash 拒绝在启动时以 root 身份运行。这张截图显示了system(3)的 man 页面上相关部分,显示了我们正在讨论的警告(man7.org/linux/man-pages/man3/system.3.html):

第二段总结了这一点:
... as a security measure, bash 2 drops privileges on startup.
系统调用
我们从之前的讨论中了解到,每个活动进程都有一组四个整数值,有效确定其特权,即真实和有效的用户和组 ID;它们被称为进程凭证。
如前所述,我们将它们称为{RUID,EUID,RGID,EGID}。
有效的 ID 以粗体字显示,以重申这样一个事实,即当涉及实际检查权限时,内核使用有效的 ID。
进程凭证存储在哪里?操作系统将这些信息作为相当大的进程属性数据结构的一部分(当然是每个进程)保存在内核内存空间中。
在 Unix 上,这种每个进程的数据结构称为进程控制块(PCB);在 Linux 上,它被称为进程描述符或简单地称为任务结构。
重点是:如果数据在内核地址空间中,那么获取(查询或设置)的唯一方法是通过系统调用。
查询进程凭证
如何在 C 程序中以编程方式查询真实和有效的 UID / GID?以下是用于这样做的系统调用:
#include <unistd.h>
#include <sys/types.h>
uid_t getuid(void);
uid_t geteuid(void);
gid_t getgid(void);
gid_t getegid(void);
这很简单:
-
getuid(2)返回真实 UID;geteuid(2)返回有效 UID -
getgid(2)返回真实 GID;getegid(2)返回有效 GID -
uid_t和gid_t是 glibc 对无符号整数的 typedef
这是一个很好的提示,可以找出任何给定数据类型的 typedef:你需要知道包含定义的头文件。只需这样做:
$ echo | gcc -E -xc -include 'sys/types.h' - | grep uid_t
typedef unsigned int __uid_t;
typedef __uid_t uid_t;
$
来源*:stackoverflow.com/questions/2550774/what-is-size-t-in-c。
一个问题出现了:前面的系统调用没有带任何参数;它们返回真实或有效的[U|G]ID,是的,但是为哪个进程?答案当然是调用进程,发出系统调用的进程。
代码示例
我们编写了一个简单的 C 程序(ch7/query_creds.c);运行时,它会将其进程凭证打印到标准输出(我们展示了相关代码):
#define SHOW_CREDS() do { \
printf("RUID=%d EUID=%d\n" \
"RGID=%d EGID=%d\n", \
getuid(), geteuid(), \
getgid(), getegid()); \
} while (0)
int main(int argc, char **argv)
{
SHOW_CREDS();
if (geteuid() == 0) {
printf("%s now effectively running as root! ...\n", argv[0]);
sleep(1);
}
exit (EXIT_SUCCESS);
}
构建并尝试运行它:
$ ./query_creds
RUID=1000 EUID=1000
RGID=1000 EGID=1000
$ sudo ./query_creds
[sudo] password for seawolf: xxx
RUID=0 EUID=0
RGID=0 EGID=0
./query_creds now effectively running as root! ...
$
注意以下内容:
-
在第一次运行时,四个进程凭证的值是通常的值(在我们的例子中是 1000)。还要注意,默认情况下 EUID = RUID,EGID = RGID。
-
但在第二次运行时我们使用了
sudo:一旦我们输入正确的密码,进程就以 root 身份运行,这当然可以从这里直接看到:四个进程凭证的值现在都是零,反映了 root 权限。
Sudo - 它是如何工作的
sudo(8)实用程序允许您以另一个用户的身份运行程序;如果没有进一步的限定,那么另一个用户就是 root。当然,出于安全考虑,您必须正确输入 root 密码(或者像一些发行版允许桌面计算那样,如果用户属于 sudo 组,可以输入用户自己的密码)。
这带来了一个非常有趣的问题:sudo(8)程序究竟是如何工作的?它比你想象的要简单!参考以下代码:
$ which sudo
/usr/bin/sudo
$ ls -l $(which sudo)
-rwsr-xr-x 1 root root 145040 Jun 13 2017 /usr/bin/sudo
$
我们注意到,可执行文件 sudo 实际上是一个设置了 setuid-root 权限的程序!所以想一想:每当您使用 sudo 运行一个程序时,sudo 进程就会立即以 root 权限运行——不需要密码,也不需要麻烦。但是,出于安全考虑,用户必须输入密码;一旦他们正确输入密码,sudo 就会继续执行并以 root 身份执行您想要的命令。如果用户未能正确输入密码(通常在三次尝试内),sudo 将中止执行。
保存的 ID 是什么?
所谓的保存的 ID 是一个方便的功能;操作系统能够保存进程的初始有效用户 ID(EUID)的值。它有什么作用呢?这允许我们从进程启动时的原始 EUID 值切换到一个非特权的普通值(我们马上就会详细介绍),然后从当前特权状态切换回保存的 EUID 值(通过seteuid(2)系统调用);因此,最初保存的 EUID 被称为保存的 ID。
实际上,我们可以随时在我们的进程之间切换特权和非特权状态!
在我们涵盖了更多的材料之后,一个例子将有助于澄清事情。
设置进程凭证
我们知道,从 shell 中,查看当前运行的用户是谁的一个方便的方法是运行简单的id(1)命令;它会显示真实的 UID 和真实的 GID(以及我们所属的所有附加组)。就像我们之前做的那样,让我们在用户seawolf登录时尝试一下:
$ id
uid=1000(seawolf) gid=1000(seawolf) groups=1000(seawolf),4(adm),24(cdrom),27(sudo), [...]
$
再次考虑sudo(8)实用程序;要以另一个用户而不是 root 身份运行程序,我们可以使用-u或--user=开关来使用sudo。例如,让我们以用户mail的身份运行id(1)程序:
$ sudo -u mail id
[sudo] password for seawolf: xxx
uid=8(mail) gid=8(mail) groups=8(mail)
$
预期的是,一旦我们提供正确的密码,sudo就会以邮件用户的身份运行id程序,id的输出现在显示我们的(真实)用户和组 ID 现在是邮件用户账户的!(而不是 seawolf),这正是预期的效果。
但sudo(8)是如何做到的呢?我们从前一节了解到,当运行sudo(无论带有什么参数),它至少最初总是以 root 身份运行。现在的问题是,它如何以另一个用户账户的凭证运行?
答案是:存在几个系统调用可以改变进程的特权(RUID、EUID、RGID、EGID):setuid(2)、seteuid(2)、setreuid(2)、setresuid(2)以及它们的 GID 对应的函数。
让我们快速看一下 API 签名:
#include <sys/types.h>
#include <unistd.h>
int setuid(uid_t uid);
int setgid(gid_t gid);
int seteuid(uid_t euid);
int setegid(gid_t egid);
int setreuid(uid_t ruid, uid_t euid);
int setregid(gid_t rgid, gid_t egid);
setuid(2)系统调用允许进程将其 EUID 设置为传递的值。如果进程具有 root 权限(稍后在下一章中,当我们了解 POSIX 能力模型时,我们将更好地限定这样的陈述),那么 RUID 和保存的 setuid(稍后解释)也将设置为这个值。
所有的set*gid()调用都类似于它们的 UID 对应物。
在 Linux 操作系统上,seteuid 和 setegid API,虽然被记录为系统调用,实际上是setreuid(2)和setregid(2)系统调用的包装器。
黑客攻击尝试 2
啊,黑客攻击!好吧,至少让我们试一试。
我们知道EUID 0是一个特殊值——它意味着我们拥有 root 权限。想想看——我们有一个setuid(2)系统调用。所以,即使我们没有特权,为什么不快速地做一个
setuid(0);变得特权,并像 root 一样黑客攻击!
嗯,如果上面的黑客攻击真的奏效,Linux 就不会成为一个非常强大和受欢迎的操作系统。它不会奏效,朋友们:上面的系统调用调用将失败返回-1;errno将被设置为EPERM,错误消息(来自perror(3)或strerror(3))将是这样的:操作不允许。
为什么呢?在内核中有一个简单的规则:一个非特权进程可以将其有效 ID 设置为其真实 ID,不允许其他值。换句话说,一个非特权进程可以设置以下内容:
-
它的 EUID 到它的 RUID
-
它的 EGID 到它的 RGID
就是这样。
当然,(root)特权进程可以将其四个凭据设置为任何它选择的值。这并不奇怪——这是作为 root 的权力的一部分。
seteuid(2)将进程的有效用户 ID 设置为传递的值;对于一个非特权进程,它只能将其 EUID 设置为其 RUID,EUID 或保存的 setuid。
setreuid(2)将真实和有效的 UID 分别设置为传递的值;如果传递了-1,则相应的值将保持不变。(这可能间接影响保存的值。)set[r]egid(2)调用在组 ID 方面是相同的。
让我们实际操作一下我们刚刚谈到的内容:
$ cat rootsh_hack2.c
[...]
int main(int argc, char **argv)
{
/* Become root */
if (setuid(0) == -1)
WARN("setuid(0) failed!\n");
/* Now just spawn a shell;
* <i>Evil Laugh</i>, we're now root!
*/
system("/bin/bash");
exit (EXIT_SUCCESS);
}
构建并运行它。这个屏幕截图显示了一个名为 seawolf 的虚拟机,以及右下角的一个ssh连接的终端窗口(我们以用户 seawolf 的身份登录);看到rootsh_hack2程序正在那里运行:

研究前面屏幕截图中ssh终端窗口的输出,我们可以看到以下内容:
-
原始的 bash 进程(shell)的 PID 是 6012。
-
id 命令显示我们正在以(真实的)UID = 1000(即 seawolf 用户)运行。
-
我们运行
rootsh_hack2;显然,setuid(0)失败了;显示了错误消息:操作不允许。 -
尽管如此,这只是一个警告消息;执行继续进行,进程生成另一个 bash 进程,实际上是另一个 shell。
-
它的 PID 是 6726(证明它与原始 shell 不同)。
-
id(1)仍然是 1000,证明我们并没有真正取得什么重大成就。
-
我们退出,回到我们最初的 shell。
但是,如果我们(或者更糟糕的是,一个黑客)能够欺骗这个进程以 root 身份运行呢!?怎么做?当然是将其设置为 setuid-root 可执行文件;然后我们就麻烦了:
$ ls -l rootsh_hack2
-rwxrwxr-x 1 seawolf seawolf 8864 Feb 19 18:03 rootsh_hack2
$ sudo chown root rootsh_hack2
[sudo] password for seawolf:
$ sudo chmod u+s rootsh_hack2
$ ls -l rootsh_hack2
-rwsrwxr-x 1 root seawolf 8864 Feb 19 18:03 rootsh_hack2
$ ./rootsh_hack2
root@seawolf-mindev:~/book_src/ch7# id -u
0
root@seawolf-mindev:~/book_src/ch7# ps
PID TTY TIME CMD
7049 pts/0 00:00:00 rootsh_hack2
7050 pts/0 00:00:00 sh
7051 pts/0 00:00:00 bash
7080 pts/0 00:00:00 ps
root@seawolf-mindev:~/book_src/ch7# exit
exit
$
所以,我们只是模拟被欺骗:在这里我们使用 sudo(8);我们输入密码,从而将二进制可执行文件更改为 setuid-root,一个真正危险的程序。它运行,并生成了一个现在被证明是 root shell 的进程(注意,id(1)命令证明了这一事实);我们执行ps然后exit。
我们也意识到,我们之前的黑客尝试失败了——当 shell 作为运行参数时,系统(3)API 拒绝提升权限,这在安全方面是很好的。但是,这次黑客尝试(#2)证明你可以轻松地颠覆这一点:只需在调用 system(/bin/bash)之前发出setuid(0)的调用,它就成功地提供了一个 root shell——当然,只有在进程首先以 root 身份运行时才会成功:要么通过 setuid-root 方法,要么只是使用 sudo(8)。
一边——一个用于识别 setuid-root 和 setgid 安装程序的脚本
我们现在开始理解,setuid/setgid程序可能很方便,但从安全的角度来看,它们可能是潜在的危险,并且必须仔细审计。这种审计的第一步是找出 Linux 系统上这些二进制文件是否存在以及确切存在的位置。
为此,我们编写一个小的 shell(bash)脚本;它将识别并显示系统上安装的setuid-root和setgid程序(通常情况下,您可以从书的 Git 存储库下载并尝试该脚本)。
脚本基本上执行其工作,如下所示(它实际上循环遍历一个目录数组;为简单起见,我们显示了扫描/bin目录的直接示例):
echo "Scanning /bin ..."
ls -l /bin/ | grep "^-..s" | awk '$3=="root" {print $0}'
ls -l的输出被管道传输到grep(1),它使用一个正则表达式,如果第一个字符是-(一个常规文件),并且所有者执行位是 s——换句话说,是一个 setuid 文件;awk(1)过滤器确保只有所有者是 root 时,我们才将结果字符串打印到 stdout。
我们在两个 Linux 发行版上运行 bash 脚本。
在 x86_64 上的 Ubuntu 17.10 上:
$ ./show_setuidgid.sh
------------------------------------------------------------------
System Information (LSB):
------------------------------------------------------------------
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 17.10
Release: 17.10
Codename: artful
kernel: 4.13.0-32-generic
------------------------------------------------------------------
Scanning various directories for (traditional) SETUID-ROOT binaries ...
------------------------------------------------------------------
Scanning /bin ...
-rwsr-xr-x 1 root root 30800 Aug 11 2016 fusermount
-rwsr-xr-x 1 root root 34888 Aug 14 2017 mount
-rwsr-xr-x 1 root root 146128 Jun 23 2017 ntfs-3g
-rwsr-xr-x 1 root root 64424 Mar 10 2017 ping
-rwsr-xr-x 1 root root 40168 Aug 21 2017 su
-rwsr-xr-x 1 root root 26696 Aug 14 2017 umount
------------------------------------------------------------------
Scanning /usr/bin ...
-rwsr-xr-x 1 root root 71792 Aug 21 2017 chfn
-rwsr-xr-x 1 root root 40400 Aug 21 2017 chsh
-rwsr-xr-x 1 root root 75344 Aug 21 2017 gpasswd
-rwsr-xr-x 1 root root 39944 Aug 21 2017 newgrp
-rwsr-xr-x 1 root root 54224 Aug 21 2017 passwd
-rwsr-xr-x 1 root root 145040 Jun 13 2017 sudo
-rwsr-xr-x 1 root root 18448 Mar 10 2017 traceroute6.iputils
------------------------------------------------------------------
Scanning /sbin ...
------------------------------------------------------------------
Scanning /usr/sbin ...
------------------------------------------------------------------
Scanning /usr/local/bin ...
------------------------------------------------------------------
Scanning /usr/local/sbin ...
------------------------------------------------------------------
Scanning various directories for (traditional) SETGID binaries ...
------------------------------------------------------------------
Scanning /bin ...
------------------------------------------------------------------
Scanning /usr/bin ...
-rwxr-sr-x 1 root tty 14400 Jul 27 2017 bsd-write
-rwxr-sr-x 1 root shadow 62304 Aug 21 2017 chage
-rwxr-sr-x 1 root crontab 39352 Aug 21 2017 crontab
-rwxr-sr-x 1 root shadow 22808 Aug 21 2017 expiry
-rwxr-sr-x 1 root mlocate 38992 Apr 28 2017 mlocate
-rwxr-sr-x 1 root ssh 362640 Jan 16 18:58 ssh-agent
-rwxr-sr-x 1 root tty 30792 Aug 14 2017 wall
------------------------------------------------------------------
Scanning /sbin ...
-rwxr-sr-x 1 root shadow 34816 Apr 22 2017 pam_extrausers_chkpwd
-rwxr-sr-x 1 root shadow 34816 Apr 22 2017 unix_chkpwd
------------------------------------------------------------------
Scanning /usr/sbin ...
------------------------------------------------------------------
Scanning /usr/local/bin ...
------------------------------------------------------------------
Scanning /usr/local/sbin ...
------------------------------------------------------------------
$
显示系统信息横幅(以便我们可以获取系统详细信息,主要是使用lsb_release实用程序获得的)。然后,脚本扫描各种系统目录,打印出它找到的所有setuid-root和setgid二进制文件。熟悉的例子,passwd和sudo被突出显示。
setgid 示例- wall
作为setgid二进制文件的一个很好的例子,看看 wall(1)实用程序,从脚本的输出中复制:
-rwxr-sr-x 1 root tty 30792 Aug 14 2017 wall
wall(1)程序用于向所有用户控制台(tty)设备广播任何消息(通常由系统管理员执行)。现在,要写入tty设备(回想一下,朋友们,第一章,Linux 系统架构,以及如果不是一个进程,它就是一个文件 Unix 哲学),我们需要什么权限?让我们以第二个终端tty2设备为例:
$ ls -l /dev/tty2
crw--w---- 1 root tty 4, 2 Feb 19 18:04 /dev/tty2
$
我们可以看到,要写入前面的设备,我们要么需要 root,要么必须是tty组的成员。再次查看 wall(1)实用程序的长列表;它是一个 setgid 二进制可执行文件,组成员是tty;因此,当任何人运行它时,wall 进程将以tty的有效组 ID(EGID)运行!这解决了问题——没有代码。没有麻烦。
这是一个截图,显示了 wall 的使用:

在前台,有一个连接的ssh(到 Ubuntu VM;您可以在后台看到它)终端窗口。它以常规用户的身份发出wall命令:由于setgid tty,它有效!
现在你可以在 x86_64 上的 Fedora 27 上运行之前的脚本:
$ ./show_setuidgid.sh 1
------------------------------------------------------------------
System Information (LSB):
------------------------------------------------------------------
LSB Version: :core-4.1-amd64:core-4.1-noarch
Distributor ID: Fedora
Description: Fedora release 27 (Twenty Seven)
Release: 27
Codename: TwentySeven
kernel: 4.14.18-300.fc27.x86_64
------------------------------------------------------------------
Scanning various directories for (traditional) SETUID-ROOT binaries ...
------------------------------------------------------------------
Scanning /bin ...
------------------------------------------------------------------
Scanning /usr/bin ...
-rwsr-xr-x. 1 root root 52984 Aug 2 2017 at
-rwsr-xr-x. 1 root root 73864 Aug 14 2017 chage
-rws--x--x. 1 root root 27992 Sep 22 14:07 chfn
-rws--x--x. 1 root root 23736 Sep 22 14:07 chsh
-rwsr-xr-x. 1 root root 57608 Aug 3 2017 crontab
-rwsr-xr-x. 1 root root 32040 Aug 7 2017 fusermount
-rwsr-xr-x. 1 root root 31984 Jan 12 20:36 fusermount-glusterfs
-rwsr-xr-x. 1 root root 78432 Aug 14 2017 gpasswd
-rwsr-xr-x. 1 root root 36056 Sep 22 14:07 mount
-rwsr-xr-x. 1 root root 39000 Aug 14 2017 newgidmap
-rwsr-xr-x. 1 root root 41920 Aug 14 2017 newgrp
-rwsr-xr-x. 1 root root 39000 Aug 14 2017 newuidmap
-rwsr-xr-x. 1 root root 27880 Aug 4 2017 passwd
-rwsr-xr-x. 1 root root 27688 Aug 4 2017 pkexec
-rwsr-xr-x. 1 root root 32136 Sep 22 14:07 su
---s--x--x. 1 root root 151416 Oct 4 18:55 sudo
-rwsr-xr-x. 1 root root 27880 Sep 22 14:07 umount
------------------------------------------------------------------
Scanning /sbin ...
------------------------------------------------------------------
Scanning /usr/sbin ...
-rwsr-xr-x. 1 root root 114840 Jan 19 23:25 mount.nfs
-rwsr-xr-x. 1 root root 89600 Aug 4 2017 mtr
-rwsr-xr-x. 1 root root 11256 Aug 21 2017 pam_timestamp_check
-rwsr-xr-x. 1 root root 36280 Aug 21 2017 unix_chkpwd
-rws--x--x. 1 root root 40352 Aug 5 2017 userhelper
-rwsr-xr-x. 1 root root 11312 Jan 2 21:06 usernetctl
------------------------------------------------------------------
Scanning /usr/local/bin ...
------------------------------------------------------------------
Scanning /usr/local/sbin ...
------------------------------------------------------------------
Scanning various directories for (traditional) SETGID binaries ...
------------------------------------------------------------------
Scanning /bin ...
------------------------------------------------------------------
Scanning /usr/bin ...
-rwxr-sr-x. 1 root cgred 15640 Aug 3 2017 cgclassify
-rwxr-sr-x. 1 root cgred 15600 Aug 3 2017 cgexec
-rwx--s--x. 1 root slocate 40528 Aug 4 2017 locate
-rwxr-sr-x. 1 root tty 19584 Sep 22 14:07 write
------------------------------------------------------------------
Scanning /sbin ...
------------------------------------------------------------------
Scanning /usr/sbin ...
-rwx--s--x. 1 root lock 15544 Aug 4 2017 lockdev
-rwxr-sr-x. 1 root root 7144 Jan 2 21:06 netreport
------------------------------------------------------------------
Scanning /usr/local/bin ...
------------------------------------------------------------------
Scanning /usr/local/sbin ...
------------------------------------------------------------------
$
似乎出现了更多的 setuid-root 二进制文件;此外,在 Fedora 上,write(1)是等效于wall(1)的setgid tty实用程序。
放弃特权
从先前的讨论中,似乎set*id()系统调用(setuid(2),seteuid(2),setreuid(2),setresuid(2))只对 root 有用,因为只有具有 root 权限的进程才能使用这些系统调用来更改进程凭据。嗯,这并不是完全的真相;还有另一个重要的情况,适用于非特权进程。
考虑这种情况:我们的程序规范要求初始化代码以 root 权限运行;其余代码则不需要。显然,我们不希望为了运行我们的程序而给最终用户 root 访问权限。我们该如何解决这个问题呢?
将程序设置为 setuid-root 会很好地解决问题。正如我们所看到的,setuid-root 进程将始终以 root 身份运行;但在初始化工作完成后,我们可以切换回非特权正常状态。我们如何做到这一点?通过setuid(2):回想一下,对于特权进程,setuid 会将 EUID 和 RUID 都设置为传递的值;因此我们将其传递给进程的 RUID,这是通过 getuid 获得的。
setuid(getuid()); // make process unprivileged
这是一个有用的语义(通常,seteuid(getuid())就是我们需要的)。我们使用这个语义来再次成为我们真正的自己——相当哲学,不是吗?
在信息安全(infosec)领域,有一个重要的原则是:减少攻击面。将根特权进程转换为非特权(一旦其作为根完成工作)有助于实现这一目标(至少在某种程度上)。
保存的 UID - 一个快速演示
在前一节中,我们刚刚看到了有用的seteuid(getuid())语义如何用于将 setuid 特权进程切换到常规非特权状态(这是很好的设计,更安全)。但是如果我们有这个要求呢:
Time t0: initialization code: must run as root
Time t1: func1(): must *not* run as root
Time t2: func2(): must run as root
Time t3: func3(): must *not* run as root
[...]
为了实现最初必须以 root 身份运行的语义,我们当然可以创建程序为 setuid-root 程序。然后,在 t1 时,我们发出setuid(getuid())放弃 root 权限。
但是我们如何在 t2 时重新获得 root 权限呢?啊,这就是保存的 setuid 功能变得宝贵的地方。而且,这样做很容易;以下是实现这种情况的伪代码:
t0: we are running with root privilege due to *setuid-root* binary
executable being run
saved_setuid = geteuid() // save it
t1: seteuid(getuid()) // must *not* run as root
t2: seteuid(saved_setuid) // switch back to the saved-set, root
t3: seteuid(getuid()) // must *not* run as root
我们接下来用实际的 C 代码来演示相同的情况。请注意,为了使演示按预期工作,用户必须通过以下方式将二进制可执行文件变成 setuid-root 二进制文件:
make savedset_demo
sudo chown root savedset_demo
sudo chmod u+s savedset_demo
以下代码检查了在开始时,进程确实是以 root 身份运行的;如果不是,它将中止并显示一条消息,要求用户将二进制文件设置为 setuid-root 二进制文件:
int main(int argc, char **argv)
{
uid_t saved_setuid;
printf("t0: Init:\n");
SHOW_CREDS();
if (0 != geteuid())
FATAL("Not a setuid-root executable,"
" aborting now ...\n"
"[TIP: do: sudo chown root %s ;"
" sudo chmod u+s %s\n"
" and rerun].\n"
, argv[0], argv[0], argv[0]);
printf(" Ok, we're effectively running as root! (EUID==0)\n");
/* Save the EUID, in effect the "saved set UID", so that
* we can switch back and forth
*/
saved_setuid = geteuid();
printf("t1: Becoming my original self!\n");
if (seteuid(getuid()) == -1)
FATAL("seteuid() step 2 failed!\n");
SHOW_CREDS();
printf("t2: Switching to privileged state now...\n");
if (seteuid(saved_setuid) == -1)
FATAL("seteuid() step 3 failed!\n");
SHOW_CREDS();
if (0 == geteuid())
printf(" Yup, we're root again!\n");
printf("t3: Switching back to unprivileged state now ...\n");
if (seteuid(getuid()) == -1)
FATAL("seteuid() step 4 failed!\n");
SHOW_CREDS();
exit (EXIT_SUCCESS);
}
这是一个样本运行:
$ make savedset_demo
gcc -Wall -o savedset_demo savedset_demo.c common.o
#sudo chown root savedset_demo
#sudo chmod u+s savedset_demo
$ ls -l savedset_demo
-rwxrwxr-x 1 seawolf seawolf 13144 Feb 20 09:22 savedset_demo*
$ ./savedset_demo
t0: Init:
RUID=1000 EUID=1000
RGID=1000 EGID=1000
FATAL:savedset_demo.c:main:48: Not a setuid-root executable, aborting now ...
[TIP: do: sudo chown root ./savedset_demo ; sudo chmod u+s ./savedset_demo
and rerun].
$
程序失败了,因为它检测到在开始时并没有有效地以 root 身份运行,这意味着它一开始就不是一个 setuid-root 二进制可执行文件。因此,我们必须通过sudo chown ...然后sudo chmod ...来使其成为 setuid-root 二进制可执行文件。(请注意,我们已经将执行此操作的代码放在了 Makefile 中,但已经将其注释掉,这样你作为读者就可以练习一下)。
这个截图显示了一旦我们这样做,它会按预期运行,在特权和非特权状态之间来回切换:

请注意,真正关键的系统调用来回切换,毕竟是 setuid(2);还要注意 EUID 在不同时间点的变化(从 t0 的 0 到 t1 的 1000,再到 t2 的 0,最后在 t3 回到 1000)。
还要注意,为了提供有趣的例子,我们大多数情况下使用的是 setuid-root 二进制文件。你不需要这样做:将文件所有者更改为其他人(比如邮件用户),实际上会使其成为一个 setuid-mail 二进制可执行文件,这意味着当运行时,进程 RUID 将是通常的 1000(seawolf),但 EUID 将是邮件用户的 RUID。
setres[u|g]id(2)系统调用
这里有一对包装调用 - setresuid(2)和setresgid(2);它们的签名:
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <unistd.h>
int setresuid(uid_t ruid, uid_t euid, uid_t suid);
int setresgid(gid_t rgid, gid_t egid, gid_t sgid);
这对系统调用就像是早期的set*id()API 的超集。使用setresuid(2)系统调用,进程可以一次性设置 RUID、EUID 和保存的 set-id,只需一个系统调用(系统调用名称中的res代表real、effective和saved-set-ID)。
非特权(即非 root)进程只能使用此系统调用将三个 ID 之一设置为当前 RUID、当前 EUID 或当前保存的 UID,没有其他选项(通常的安全原则在起作用)。传递-1意味着保持相应的值不变。特权(root)进程当然可以使用调用将三个 ID 设置为任何值。(通常情况下,setresgid(2)系统调用是相同的,只是它设置组凭据)。
一些真实的开源软件项目确实使用了这个系统调用;OpenSSH 项目(Linux 端口称为 OpenSSH-portable)和著名的 sudo(8)实用程序就是很好的例子。
OpenSSH:来自其 git 存储库:github.com/openssh/openssh-portable/:
uidswap.c:permanently_drop_suid():
void permanently_drop_suid(uid_t uid)
[...]
debug("permanently_drop_suid: %u", (u_int)uid);
if (setresuid(uid, uid, uid) < 0)
fatal("setresuid %u: %.100s", (u_int)uid, strerror(errno));
[...]
/* Verify UID drop was successful */
if (getuid() != uid || geteuid() != uid) {
fatal("%s: euid incorrect uid:%u euid:%u (should be %u)",
__func__, (u_int)getuid(), (u_int)geteuid(), (u_int)uid);
}
有趣的是注意到确保 UID 降级成功所付出的努力——接下来会更多地讨论这一点!
对 sudo(8)执行strace(1)(请注意,我们必须以 root 身份跟踪它,因为尝试以普通用户身份跟踪 setuid 程序时不起作用,因为在跟踪时,setuid 位被故意忽略;此输出来自 Ubuntu Linux 系统):
$ id mail uid=8(mail) gid=8(mail) groups=8(mail) $ sudo strace -e trace=setuid,setreuid,setresuid sudo -u mail id
[...]
setresuid(-1, 0, -1) = 0
setresuid(-1, -1, -1) = 0
setresuid(-1, 8, -1) = 0
setresuid(-1, 0, -1) = 0
[...]
显然,sudo 使用setresuid(2)系统调用来设置权限、凭据,确实是适当的(在上面的示例中,进程 EUID 被设置为邮件用户的 EUID,RUID 和保存的 ID 被保持不变)。
重要的安全注意事项
以下是一些关于安全性的关键要点:
-
如果设计不当,使用 setuid 二进制文件是一种安全风险。特别是对于 setuid-root 程序,它们应该被设计和测试,以确保在进程处于提升的特权状态时,它永远不会生成一个 shell 或盲目接受用户命令(然后在内部执行)。
-
您必须检查任何
set*id()系统调用(setuid(2)、seteuid(2)、setreuid(2)、setresuid(2))的失败情况。
考虑这个伪代码:
run setuid-root program; EUID = 0
do required work as root
switch to 'normal' privileges: setuid(getuid())
do remaining work as non-root
[...]
思考一下:如果前面的setuid(getuid())调用失败了(无论什么原因),而我们没有检查呢?剩下的工作将继续以 root 访问权限运行,很可能会招致灾难!(请参阅 OpenSSH-portable Git 存储库中的示例代码,了解仔细检查的真实示例。)让我们看看以下几点:
-
setuid(2)系统调用在某种意义上是有缺陷的:如果真实 UID 是 root,那么保存的 UID 也是 root;因此,您无法放弃权限!显然,这对于 setuid-root 应用程序等来说是危险的。作为替代方案,使用setreuid(2)API 使根进程暂时放弃权限,并稍后重新获得(通过交换它们的 RUID 和 EUID 值)。 -
即使您拥有系统管理员(root)访问权限,也不应该以 root 身份登录!您可能会(非常容易地)被欺骗以 root 身份运行危险程序(黑客经常使用这种技术在系统上安装 rootkit;一旦成功,确实会考虑您的系统已被入侵)。
-
当一个进程创建一个共享对象(比如一个文件)时,它将由谁拥有,组将是什么?换句话说,内核将在文件的 inode 元数据结构中设置什么值作为 UID 和 GID?答案是:文件的 UID 将是创建进程的 EUID,文件的 GID(组成员资格)将是创建进程的 EGID。这将对权限产生后续影响。
我们建议您,读者,一定要阅读第九章,进程执行!在其中,我们展示了传统权限模型在许多方面存在缺陷,以及为什么以及如何使用更优越的 Linux Capabilities 模型。
总结
在本章中,读者已经了解了关于传统 Unix 安全模型设计和实施的许多重要观念。除此之外,我们还涵盖了传统 Unix 权限模型、进程真实和有效 ID 的概念、用于查询和设置它们的 API、sudo(8)、保存的 ID 集。
再次强调:我们强烈建议您也阅读以下内容[第八章],进程能力!在其中,我们展示了传统权限模型存在缺陷,以及您应该使用更优越、现代的 Linux 能力模型。
第八章:进程功能
在两章中,您将学习有关进程凭据和功能的概念和实践。除了在 Linux 应用程序开发中具有实际重要性之外,本章本质上深入探讨了一个经常被忽视但极为重要的方面:安全性。
我们将这一关键领域的覆盖分为两个主要部分,每个部分都是本书的一个章节:
-
在第七章中,进程凭据,传统风格的 Unix 权限模型被详细讨论,并展示了以 root 权限运行程序但不需要 root 密码的技术。
-
在第八章中,进程功能,现代方法,POSIX 功能模型,被详细讨论。
我们将尝试清楚地向读者展示,虽然了解传统机制及其运作方式很重要,但就安全而言,这成为了一个经典的弱点。无论如何看待它,安全性都是至关重要的,尤其是在当今这个时代;Linux 运行在各种设备上——从微型物联网和嵌入式设备到移动设备、台式机、服务器和超级计算平台——使安全成为所有利益相关者的关键关注点。因此,在开发软件时应该使用现代功能方法。
在本章中,我们将详细介绍现代方法——POSIX 功能模型。我们将讨论它究竟是什么,以及它如何提供安全性和健壮性。读者将了解以下内容:
-
现代 POSIX 功能模型究竟是什么
-
为什么它优于旧的(传统的)Unix 权限模型
-
如何在 Linux 上使用功能
-
将功能嵌入到进程或二进制可执行文件中
-
安全提示
在此过程中,我们将使用代码示例,让您尝试其中一些功能,以便更好地理解它们。
现代 POSIX 功能模型
考虑这个(虚构的)情景:Vidya 正在为 Alan 和他的团队开发 Linux 应用程序的项目。她正在开发一个捕获网络数据包并将其保存到文件中的组件(以供以后分析)。该程序名为packcap。然而,为了成功捕获网络数据包,packcap 必须以root权限运行。现在,Vidya 明白以root身份运行应用程序不是一个好的安全实践;不仅如此,她知道客户不会接受这样的说法:哦,它没用?你必须以 root 登录或通过 sudo 运行它。通过 sudo(8)运行它可能听起来合理,但是,当你停下来想一想,这意味着 Alan 的每个团队成员都必须被给予root密码,这是完全不可接受的。
那么,她如何解决这个问题呢?答案突然出现在她脑海中:将packcap二进制文件设置为setuid-root 文件可执行;这样,当它被启动时,进程将以root权限运行,因此不需要 root 登录/密码或 sudo。听起来很棒。
动机
这种 setuid-root 方法——正是传统的解决上面简要描述的问题的方式。那么,今天有什么变化(好吧,现在已经有好几年了)?简而言之:对黑客攻击的安全关注。现实情况是:所有真实世界的非平凡程序都有缺陷(错误)——隐藏的、潜伏的、未发现的,也许,但确实存在。现代真实世界软件项目的广泛范围和复杂性使这成为一个不幸的现实。某些错误导致漏洞“泄漏”到软件产品中;这正是黑客寻求利用的内容。众所周知,但令人畏惧的缓冲区溢出(BoF)攻击是基于几个广泛使用的库 API 中的软件漏洞!(我们强烈建议阅读 David Wheeler 的书安全编程 HOWTO - 创建安全软件——请参阅 GitHub 存储库的进一步阅读部分。)
在代码级别上,安全问题就是错误;一旦修复,问题就消失了。(在 GitHub 存储库的进一步阅读部分中查看 Linux 对此的评论链接。)
那么重点是什么?简而言之,重点就是:您交付给客户的 setuid-root 程序(packcap)可能包含不幸的、目前未知的软件漏洞,黑客可能会发现并利用它们(是的,这有一个专门的工作描述——白帽黑客或渗透测试)。
如果进程被黑客入侵以普通特权—非 root—运行,那么损害至少被限制在该用户帐户中,不会进一步扩散。但是,如果进程以 root 特权运行并且攻击成功,黑客可能最终会在系统上获得root shell。系统现在已经受到损害——任何事情都可能发生(秘密可能被窃取,后门和 rootkit 被安装,DoS 攻击变得微不足道)。
不仅仅是关于安全,通过限制特权,您还会获得损坏控制的好处;错误和崩溃将会造成有限的损害——情况比以前要好得多。
POSIX 功能
那么,回到我们虚构的 packcap 示例应用程序,我们如何运行该进程——似乎需要 root——而不具备 root 特权(不允许 root 登录,setuid-root*或 sudo(8))并且使其正确执行任务?
进入 POSIX 功能模型:在这个模型中,与其像 root(或其他)用户一样给予进程全面访问,不如将特定功能嵌入到进程和/或二进制文件中。 Linux 内核从很早开始就支持 POSIX 功能模型——2.2 Linux 内核(在撰写本文时,我们现在处于 4.x 内核系列)。从实际的角度来看,我们将描述的功能从 Linux 内核版本 2.6.24(2008 年 1 月发布)开始可用。
这就是它的工作原理:每个进程——实际上,每个线程——作为其操作系统元数据的一部分,包含一个位掩码。这些被称为功能位或功能集,因为每个 位代表一个功能。通过仔细设置和清除位,内核(以及用户空间,如果具有该功能)因此可以在每个线程基础上设置细粒度权限(我们将在以后的第十四章中详细介绍多线程,现在,将术语线程视为可互换使用进程)。
更现实的是,正如我们将在接下来看到的,内核保持每个线程活动的多个功能集(capsets);每个 capset 由两个 32 位无符号值的数组组成。
例如,有一个称为CAP_DAC_OVERRIDE的功能位;它通常会被清除(0)。如果设置,那么进程将绕过内核的所有文件权限检查——无论是读取、写入还是执行!(这被称为DAC:自主访问控制。)
在这一点上,查看一些功能位的更多示例将是有用的(完整列表可在这里的功能(7)功能页面上找到:linux.die.net/man/7/capabilities)。以下是一些片段:
[...]
CAP_CHOWN
Make arbitrary changes to file UIDs and GIDs (see chown(2)).
CAP_DAC_OVERRIDE
Bypass file read, write, and execute permission checks. (DAC is an abbreviation of "discretionary access control".)
[...]
CAP_NET_ADMIN
Perform various network-related operations:
* interface configuration;
* administration of IP firewall, masquerading, and accounting;
* modify routing tables;
[...]
CAP_NET_RAW
* Use RAW and PACKET sockets;
* bind to any address for transparent proxying.
[...]
CAP_SETUID
* Make arbitrary manipulations of process UIDs (setuid(2),
setreuid(2), setresuid(2), setfsuid(2));
[...]
CAP_SYS_ADMIN
Note: this capability is overloaded; see Notes to kernel
developers, below.
* Perform a range of system administration operations
including: quotactl(2), mount(2), umount(2), swapon(2),
setdomainname(2);
* perform privileged syslog(2) operations (since Linux 2.6.37,
CAP_SYSLOG should be used to permit such operations);
* perform VM86_REQUEST_IRQ vm86(2) command;
* perform IPC_SET and IPC_RMID operations on arbitrary
System V IPC objects;
* override RLIMIT_NPROC resource limit;
* perform operations on trusted and security Extended
Attributes (see xattr(7));
* use lookup_dcookie(2);
*<< a lot more follows >>*
[...]
实际上,功能模型提供了细粒度的权限;一种将 root 用户的(过度)巨大的权限切割成可管理的独立部分的方法。
因此,在我们虚构的 packcap 示例的背景下理解重要的好处,考虑这一点:使用传统的 Unix 权限模型,最好的情况下,发布的二进制文件将是一个 setuid-root 二进制可执行文件;进程将以 root 权限运行。在最好的情况下,没有错误,没有安全问题(或者如果有,它们没有被发现),一切都会顺利进行-幸运的是。但是,我们不相信运气,对吧?(用李·查德的主角杰克·里彻的话来说,“希望最好,为最坏做准备”)。在最坏的情况下,代码中潜在的漏洞可以被利用,有黑客会不知疲倦地工作,直到他们找到并利用它们。整个系统可能会受到威胁。
另一方面,使用现代 POSIX 功能模型,packcap 二进制可执行文件将不需要设置 setuid,更不用说 setuid-root;进程将以普通权限运行。工作仍然可以完成,因为我们嵌入了能力来精确完成这项工作(在这个例子中,是网络数据包捕获),绝对没有其他东西。即使代码中存在可利用的漏洞,黑客可能也不会有动力去找到并利用它们;这个简单的原因是,即使他们设法获得访问权限(比如,任意代码执行赏金),他们可以利用的只是运行进程的非特权用户的帐户。这对黑客来说是没有动力的(好吧,这是一个玩笑,但其中蕴含着真理)。
想想看:Linux 功能模型是实现一个被广泛接受的安全实践的一种方式:最小特权原则(PoLP):产品(或项目)中的每个模块只能访问其合法工作所需的信息和资源,而不多。
功能-一些血腥的细节
Linux 功能是一个相当复杂的主题。对于本书的目的,我们深入讨论了系统应用开发人员从讨论中获益所需的深度。要获取完整的详细信息,请查看这里的功能手册(7):man7.org/linux/man-pages/man7/capabilities.7.html,以及这里的内核文档:github.com/torvalds/linux/raw/master/Documentation/security/credentials.rst
操作系统支持
功能位掩码(s)通常被称为功能集-我们将这个术语缩写为capset。
要使用 POSIX 功能模型的功能,首先,操作系统本身必须为其提供“生命支持”;完全支持意味着以下内容:
-
每当进程或线程尝试执行某些操作时,内核能够检查线程是否被允许这样做(通过检查线程有效 capset 中设置适当位)-请参见下一节。
-
必须提供系统调用(通常是包装器库 API),以便线程可以查询和设置其 capsets。
-
Linux 内核文件系统代码必须具有一种设施,以便可以将功能嵌入(或附加)到二进制可执行文件中(以便当文件“运行”时,进程会获得这些功能)。
现代 Linux(特别是 2.6.24 版本及以后的内核)支持所有三种,因此完全支持功能模型。
通过 procfs 查看进程功能
为了更详细地了解,我们需要一种快速的方法来“查看”内核并检索信息;Linux 内核的proc 文件系统(通常缩写为procfs)就提供了这个功能(以及更多)。
Procfs 是一个伪文件系统,通常挂载在/proc 上。探索 procfs 以了解更多关于 Linux 的信息是一个好主意;在 GitHub 存储库的进一步阅读部分中查看一些链接。
在这里,我们只关注手头的任务:要了解详细信息,procfs 公开了一个名为/proc/self的目录(它指的是当前进程的上下文,有点类似于 OOP 中的this指针);在它下面,一个名为status的伪文件揭示了有关所讨论的进程(或线程)的有趣细节。进程的 capsets 被视为“Cap”,所以我们只需按照这个模式进行 grep。在下一段代码中,我们对一个常规的非特权进程(grep本身通过self目录)以及一个特权(root)进程(systemd/init PID 1*)执行此操作,以查看差异:
进程/线程 capsets:常规进程(如 grep):
$ grep -i cap /proc/self/status
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 0000003fffffffff
CapAmb: 0000000000000000
进程/线程 capsets:特权(root)进程(如 systemd/init PID 1):
$ grep -i cap /proc/1/status
CapInh: 0000000000000000
CapPrm: 0000003fffffffff
CapEff: 0000003fffffffff
CapBnd: 0000003fffffffff
CapAmb: 0000000000000000
$
在一个表中列举:
| 线程能力集(capset) | 非特权任务的典型值 | 特权任务的典型值 |
|---|---|---|
| CapInh(继承) | 0x0000000000000000 |
0x0000000000000000 |
| CapPrm(允许) | 0x0000000000000000 |
0x0000003fffffffff |
| CapEff(有效) | 0x0000000000000000 |
0x0000003fffffffff |
| CapBnd(有界) | 0x0000003fffffffff |
0x0000003fffffffff |
| CapAmb(环境) | 0x0000000000000000 |
0x0000000000000000 |
(此表描述了 Fedora 27/Ubuntu 17.10 Linux 在 x86_64 上的输出)。
广义上,有两种类型的能力集:
-
线程能力集
-
文件能力集
线程能力集
在线程 capsets 中,实际上有几种类型。
Linux 每个线程的能力集:
-
允许(Prm):线程的有效能力的整体限制超集。如果一个能力被丢弃,它就永远无法重新获得。
-
可继承(Inh):这里的继承是指在exec操作中吸收 capset 属性。当一个进程执行另一个进程时,capsets 会发生什么?(关于 exec 的详细信息将在后面的章节中处理。现在,可以说如果 bash 执行 vi,那么我们称 bash 为前任,vi 为继任)。
继任进程是否会继承前任的 capsets?是的,继承的是可继承的 capset。从前面的表中,我们可以看到对于非特权进程,继承的 capset 都是零,这意味着在执行操作中没有能力被继承。因此,如果一个进程想要执行另一个进程,并且(继任)进程必须以提升的特权运行,它应该使用环境能力。
-
有效(Eff):这些是内核在检查给定线程的权限时实际使用的能力。
-
环境(Amb):(从 Linux 4.3 开始)。这些是在执行操作中继承的能力。位必须同时存在(设置为 1)在允许和可继承的 capsets 中,只有这样它才能是“环境”。换句话说,如果一个能力从 Prm 或 Inh 中清除,它也会在 Amb 中清除。
如果执行了一个set[u|g]id程序或者一个带有文件能力(我们将会看到)的程序,环境集会被清除。通常,在执行期间,环境 capset 会被添加到 Prm 并分配给继任进程的 Eff。
-
边界(Bnd):这个 capset 是在执行期间赋予进程的能力的一种限制方式。它的效果是:
-
当进程执行另一个进程时,允许的集合是原始允许和有界 capset 的 AND 运算:Prm = Prm AND Bnd. 这样,你可以限制继任进程的允许 capset。
-
只有在边界集中的能力才能被添加到可继承的 capset 中。
-
此外,从 Linux 2.6.25 开始,能力边界集是一个每个线程的属性。
执行程序不会对 capsets 产生影响,除非以下情况之一成立:
-
继承者是一个 setuid-root 或 setgid 程序
-
文件能力设置在被执行的二进制可执行文件上
这些线程 capsets 如何以编程方式查询和更改?这正是capget(2)和capset(2)系统调用的用途。然而,我们建议使用库级别的包装 API cap_get_proc(3)和cap_set_proc(3)。
文件能力集
有时,我们需要能力将能力“嵌入”到二进制可执行文件中(关于这一点的讨论在下一节中)。这显然需要内核文件系统支持。在早期的 Linux 中,这个系统是一个内核可配置选项;从 Linux 内核 2.6.33 开始,文件能力总是编译到内核中,因此总是存在。
文件 capsets 是一个强大的安全功能——你可以说它们是旧的set[u|g]id功能的现代等价物。首先,要使用它们,操作系统必须支持它们,并且进程(或线程)需要CAP_FSETCAP能力。这是关键:(之前的)线程 capsets 和(即将到来的)文件 capsets 最终确定了exec操作后线程的能力。
以下是 Linux 文件能力集:
-
允许(Prm):自动允许的能力
-
可继承(Inh)
-
有效(Eff):这是一个单一的位:如果设置,新的 Prm capset 会在 Eff 集中提升;否则,不会。
再次理解上述信息提供的警告:这不是完整的细节。要获取它们,请在这里查看关于 capabilities(7)的 man 页面:linux.die.net/man/7/capabilities。
这是来自该 man 页面的截图片段,显示了exec操作期间确定能力的算法:

将能力嵌入程序二进制文件
我们已经了解到,能力模型的细粒度是与旧式的仅限 root 或 setuid-root 方法相比的一个主要安全优势。因此,回到我们的虚构的 packcap 程序:我们想要使用能力,而不是 setuid-root。因此,经过仔细研究可用的能力,我们得出结论,我们希望将以下能力赋予我们的程序:
-
CAP_NET_ADMIN -
CAP_NET_RAW
查看 credentials(7)的 man 页面会发现,第一个给予进程执行所有必需的网络管理请求的能力;第二个给予使用“原始”套接字的能力。
但是开发人员如何将这些所需的能力嵌入到编译后的二进制可执行文件中呢?啊,这很容易通过getcap(8)和setcap(8)实用程序实现。显然,你使用getcap(8)来查询给定文件的能力,使用setcap(8)在给定文件上设置它们。
“如果尚未安装,请在系统上安装 getcap(8)和 setcap(8)实用程序(本书的 GitHub 存储库提供了必需和可选软件包的列表)”
警惕的读者会注意到这里有些可疑:如果你能够任意设置二进制可执行文件的能力,那么安全在哪里?(我们可以在文件/bin/bash 上设置CAP_SYS_ADMIN,它现在将以 root 身份运行。)因此,事实是,只有在文件上已经具有CAP_FSETCAP能力时,才能在文件上设置能力;从手册中得知:
CAP_SETFCAP (since Linux 2.6.24)
Set file capabilities.
实际上,实际上,你会以 root 身份通过 sudo(8)执行 setcap(8);这是因为只有在以 root 权限运行时才能获得 CAP_SETFCAP 能力。
因此,让我们做一个实验:我们构建一个简单的hello world程序(ch8/hello_pause.c);唯一的区别是这样:我们在printf之后调用pause(2)系统调用;pause会使进程休眠(永远):
int main(void)
{
printf("Hello, Linux System Programming, World!\n");
pause();
exit(EXIT_SUCCESS);
}
然后,我们编写另一个 C 程序来查询任何给定进程上的功能;ch8/query_pcap.c的代码:
[...]
#include <sys/capability.h>
int main(int argc, char **argv)
{
pid_t pid;
cap_t pcaps;
char *caps_text=NULL;
if (argc < 2) {
fprintf(stderr, "Usage: %s PID\n"
" PID: process to query capabilities of\n"
, argv[0]);
exit(EXIT_FAILURE);
}
pid = atoi(argv[1]);
[...]
pcaps = cap_get_pid(pid);
if (!pcaps)
FATAL("cap_get_pid failed; is process %d valid?\n", pid);
caps_text = cap_to_text(pcaps, NULL);
if (!caps_text)
FATAL("caps_to_text failed\n", argv[1]);
printf("\nProcess %6d : capabilities are: %s\n", pid, caps_text);
cap_free(caps_text);
exit (EXIT_SUCCESS);
}
很简单:cap_get_pid(3) API 返回功能状态,基本上是目标进程的capsets。唯一的麻烦是它是通过一个叫做cap_t的内部数据类型表示的;要读取它,我们必须将其转换为人类可读的 ASCII 文本;你猜对了,cap_to_text (3). API 正好有这个功能。我们使用它并打印结果。(嘿,注意我们必须在使用后cap_free(3)释放变量;手册告诉我们这一点。)
这些与功能有关的 API 中的一些(广义上的cap_*)需要在系统上安装libcap库。如果尚未安装,请使用您的软件包管理器进行安装(正确的软件包通常称为libcap-dev[el*])。显然,您必须链接libcap库(我们在 Makefile 中使用-lcap来这样做)。
让我们试一试:
$ ./query_pcap
Usage: ./query_pcap PID
PID: process to query capabilities of
$ ./query_pcap 1
Process 1 : capabilities are: = cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read+ep
$
进程 PID 1,传统上(Sys V)是init,但现在是systemd,以root权限运行;因此,当我们使用我们的程序查询其 capsets(实际上,我们得到的是有效的 capset 返回),我们得到了一个相当长的功能列表!(如预期的那样。)
接下来,我们在后台构建和运行hello_pause进程;然后我们查询它的功能:
$ make hello_pause
gcc -Wall -c -o hello_pause.o hello_pause.c
gcc -Wall -o hello_pause hello_pause.c common.o
$ ./hello_pause &
[1] 14303
Hello, Linux System Programming, World!
$ ./query_pcap 14303
Process 14303 : capabilities are: =
$
我们的hello_pause进程当然是没有特权的,也没有任何功能嵌入其中;因此,如预期的那样,我们看到它没有功能。
现在是有趣的部分:首先,我们使用setcap(8)实用程序将功能嵌入到我们的hello_pause二进制可执行文件中:
$ setcap cap_net_admin,cap_net_raw+ep ./hello_pause
unable to set CAP_SETFCAP effective capability: Operation not permitted
$ sudo setcap cap_net_admin,cap_net_raw+ep ./hello_pause
[sudo] password for <xyz>: xxx
$
这是有道理的:作为root(从技术上讲,现在我们明白了,具有CAP_SYS_ADMIN功能),我们当然具有CAP_SETFCAP功能,因此成功使用setcap(8)。从语法上讲,我们需要指定给setcap(8)一个功能列表,后面跟着一个操作列表;以前,我们已经指定了cap_net_admin,cap_net_raw功能,以及添加到有效和允许作为操作列表(使用+ep语法)。
现在,我们重新尝试我们的小实验:
$ ./hello_pause &
[2] 14821
Hello, Linux System Programming, World!
$ ./query_pcap 14821
Process 14821 : capabilities are: = cap_net_admin,cap_net_raw+ep
$
是的!新的hello_pause进程确实具有我们希望它具有的功能。
如果传统的 setuid-root 和现代(文件)功能都嵌入到一个二进制可执行文件中会发生什么?嗯,在这种情况下,运行时只有文件中嵌入的功能会生效;进程的 EUID 为 0,但不会具有完整的root功能。
功能愚蠢的二进制
不过,注意一下:上面的hello_pause程序实际上并不知道它实际上具有这些功能;换句话说,它在程序上并没有做任何事情来查询或设置自己的 POSIX 功能。然而,通过文件功能模型(和 setcap(8)实用程序),我们已经“注入”了功能。这种类型的二进制因此被称为 功能愚蠢的二进制。
从安全性的角度来看,这仍然远远优于使用笨拙的 setuid-root,但如果应用程序本身在运行时使用 API 来查询和设置功能,它可能会变得更加“智能”。我们可以将这种类型的应用程序视为功能智能二进制***。
通常,在移植传统的 setuid-root(或更糟糕的,只是root)类型的应用程序时,开发人员会剥离它的 setuid-root 位,从二进制文件中删除root所有权,然后通过运行 setcap(8)将其转换为功能愚蠢二进制。这是迈向更好安全性(或“加固”)的第一步。
Getcap 和类似的实用程序
getcap(8)实用程序可用于查找嵌入在(二进制)文件中的功能。作为一个快速的例子,让我们在 shell 程序和 ping 实用程序上运行getcap:
$ getcap /bin/bash
$ getcap /usr/bin/ping
/usr/bin/ping = cap_net_admin,cap_net_raw+p
$
很明显,bash 没有任何文件 capsets——这正是我们所期望的。另一方面,Ping 有,因此它可以在不需要 root 特权的情况下执行其职责。
通过一个 bash 脚本(类似于我们在上一章中看到的)充分演示了getcap实用程序的用法:ch8/show_caps.sh。运行它以查看系统上安装的各种嵌入文件能力的程序(留作读者的一个简单练习)。
与getcap(8)类似的是capsh(1)实用程序——一个capability shell wrapper;查看其手册页以获取详细信息。
与我们编写的query_pcap程序类似的是getpcaps(1)实用程序。
Wireshark——一个典型案例
因此:我们在本主题开头编写的故事并非完全虚构——好吧,它确实是,但它有一个引人注目的现实世界平行:著名的Wireshark(以前称为 Ethereal)网络数据包嗅探器和协议分析器应用程序。
在旧版本中,Wireshark 曾作为setuid-root进程运行,以执行数据包捕获。
现代版本的 Wireshark 将数据包捕获分离到一个名为dumpcap1的程序中。它不作为 setuid-root 进程运行,而是嵌入了所需的能力位,使其具有执行其工作所需的特权——数据包捕获。
现在黑客成功攻击它的潜在回报大大降低了——黑客最多只能获得运行 Wireshark 的用户和 wireshark 组的特权(EUID,EGID)而不是root!我们使用ls(1)和getcap(1)来查看这一点,如下所示:
$ ls -l /bin/dumpcap
-rwxr-x---. 1 root wireshark 107K Jan 19 19:45 /bin/dumpcap
$ getcap /bin/dumpcap
/bin/dumpcap = cap_net_admin,cap_net_raw+ep
$
请注意,在上面的长列表中,其他(O)访问类别没有权限;只有 root 用户和 Wireshark 成员可以执行 dumpcap(1)。(不要以 root 身份执行它;那样你将打败整个安全性的目的)。
FYI,实际的数据包捕获代码在一个名为pcap—packet capture 的库中:
# ldd /bin/dumpcap | grep pcap
libpcap.so.1 => /lib64/libpcap.so.1 (0x00007f9723c66000)
#
供您参考:Red Hat 发布的安全公告详细介绍了 wireshark 的安全问题:access.redhat.com/errata/RHSA-2012:0509。以下摘录证明了一个重要观点:
...在 Wireshark 中发现了几个缺陷。如果 Wireshark 从网络上读取了格式不正确的数据包或打开了恶意的转储文件,它可能会崩溃,甚至可能以运行 Wireshark 的用户的身份执行任意代码。(CVE-2011-1590,CVE-2011-4102,CVE-2012-1595)...
突出显示的文本很关键:即使黑客成功执行任意代码,它也将以运行 Wireshark 的用户的特权而不是 root 特权执行!
关于如何使用 POSIX 功能设置 Wireshark的详细信息在这里(在名为GNU/Linux distributions的部分下):wiki.wireshark.org/CaptureSetup/CapturePrivileges。
现在应该很清楚了:dumpcap是一个capability-dumb二进制文件;Wireshark 进程(或文件)本身没有任何特权。安全性胜出,两全其美。
以编程方式设置能力
我们已经看到了如何构建一个capability-dumb二进制文件;现在让我们弄清楚如何在程序内部在运行时添加或删除进程(线程)能力。
getcap 的另一面当然是 setcap——我们已经在命令行上使用过这个实用程序。现在让我们使用相关的 API。
要理解的是:要使用进程 capsets,我们需要在内存中拥有所谓的“能力状态”。为了获得这个能力状态,我们使用cap_get_proc(3)API(当然,正如前面提到的,所有这些 API 都来自libcap库,我们将将其链接到其中)。一旦我们有了一个工作上下文,即能力状态,我们将使用cap_set_flag(3)API 来设置事务:
#include <sys/capability.h>
int cap_set_flag(cap_t cap_p, cap_flag_t flag, int ncap,
const cap_value_t *caps, cap_flag_value_t value);
第一个参数是我们从cap_get_proc()得到的功能状态;第二个参数是我们希望影响的功能集之一:有效的、允许的或继承的。第三个参数是我们用这个 API 调用操作的功能数量。第四个参数——这是我们如何识别我们希望添加或删除的功能的地方,但是如何?我们传递一个cap_value_t数组的指针。当然,我们必须初始化数组;每个元素都持有一个功能。最后,第五个参数value可以是两个值之一:CAP_SET用于设置功能,CAP_CLEAR用于删除它。
到目前为止,所有的工作都是在内存上下文中进行的——功能状态变量;它实际上并没有影响到进程(或线程)的功能集。为了实际设置进程的功能集,我们使用cap_set_proc(3) API:
int cap_set_proc(cap_t cap_p);
它的参数是我们仔细设置的功能状态变量。现在功能将被设置。
还要意识到,除非我们以root身份运行它(当然我们不会这样做——这确实是整个重点),我们不能只提高我们的功能。因此,在Makefile内部,一旦程序二进制文件构建完成,我们就对二进制可执行文件本身(set_pcap)执行sudo setcap,增强它的功能;我们赋予它的允许和有效功能集中的CAP_SETUID和CAP_SYS_ADMIN功能位。
下一个程序简要演示了一个进程如何添加或删除功能(当然是在它的允许功能集内)。当选项 1 运行时,它添加了CAP_SETUID功能,并通过一个简单的测试函数(test_setuid())“证明”了它。这里有一个有趣的地方:由于二进制文件已经在其中嵌入了两个功能(我们在Makefile中进行了setcap(8)),我们实际上需要删除CAP_SYS_ADMIN功能(从它的有效集中)。
当选项 2 运行时,我们希望有两个功能——CAP_SETUID和CAP_SYS_ADMIN;它会工作,因为这些功能已经嵌入到有效和允许的功能集中。
这是ch8/set_pcap.c的相关代码:
int main(int argc, char **argv)
{
int opt, ncap;
cap_t mycaps;
cap_value_t caps2set[2];
if (argc < 2)
usage(argv, EXIT_FAILURE);
opt = atoi(argv[1]);
if (opt != 1 && opt != 2)
usage(argv, EXIT_FAILURE);
/* Simple signal handling for the pause... */
[...]
//--- Set the required capabilities in the Thread Eff capset
mycaps = cap_get_proc();
if (!mycaps)
FATAL("cap_get_proc() for CAP_SETUID failed, aborting...\n");
if (opt == 1) {
ncap = 1;
caps2set[0] = CAP_SETUID;
} else if (opt == 2) {
ncap = 2;
caps2set[1] = CAP_SYS_ADMIN;
}
if (cap_set_flag(mycaps, CAP_EFFECTIVE, ncap, caps2set,
CAP_SET) == -1) {
cap_free(mycaps);
FATAL("cap_set_flag() failed, aborting...\n");
}
/* For option 1, we need to explicitly CLEAR the CAP_SYS_ADMIN capability; this is because, if we don't, it's still there as it's a file capability embedded into the binary, thus becoming part of the process Eff+Prm capsets. Once cleared, it only shows up in the Prm Not in the Eff capset! */
if (opt == 1) {
caps2set[0] = CAP_SYS_ADMIN;
if (cap_set_flag(mycaps, CAP_EFFECTIVE, 1, caps2set,
CAP_CLEAR) == -1) {
cap_free(mycaps);
FATAL("cap_set_flag(clear CAP_SYS_ADMIN) failed, aborting...\n");
}
}
/* Have the caps take effect on the process.
* Without sudo(8) or file capabilities, it fails - as expected.
* But, we have set the file caps to CAP_SETUID (in the Makefile),
* thus the process gets that capability in it's effective and
* permitted capsets (as we do a '+ep'; see below):"
* sudo setcap cap_setuid,cap_sys_admin+ep ./set_pcap
*/
if (cap_set_proc(mycaps) == -1) {
cap_free(mycaps);
FATAL("cap_set_proc(CAP_SETUID/CAP_SYS_ADMIN) failed, aborting...\n",
(opt==1?"CAP_SETUID":"CAP_SETUID,CAP_SYS_ADMIN"));
}
[...]
printf("Pausing #1 ...\n");
pause();
test_setuid();
cap_free(mycaps);
printf("Now dropping all capabilities and reverting to original self...\n");
drop_caps_be_normal();
test_setuid();
printf("Pausing #2 ...\n");
pause();
printf(".. done, exiting.\n");
exit (EXIT_SUCCESS);
}
让我们构建它:
$ make set_pcap
gcc -Wall -o set_pcap set_pcap.c common.o -lcap
sudo setcap cap_setuid,cap_sys_admin+ep ./set_pcap
$ getcap ./set_pcap
./set_pcap = cap_setuid,cap_sys_admin+ep
$
注意setcap(8)已经将文件功能嵌入到二进制可执行文件set_pcap中(由getcap(8)验证)。
试一下;我们首先用选项2运行它:
$ ./set_pcap 2 &
[1] 3981
PID 3981 now has CAP_SETUID,CAP_SYS_ADMIN capability.
Pausing #1 ...
$
pause(2)系统调用使进程进入睡眠状态;这是故意这样做的,以便我们可以尝试一些东西(见下一个代码)。顺便说一句,为了使用这个,程序已经设置了一些最小的信号处理;然而,这个主题将在后续章节中详细讨论。现在,只要理解暂停(和相关的信号处理)允许我们真正“暂停”进程,检查东西,一旦完成,发送一个信号继续它:
$ ./query_pcap 3981
Process 3981 : capabilities are: = cap_setuid,cap_sys_admin+ep
$ grep -i cap /proc/3981/status
Name: set_pcap
CapInh: 0000000000000000
CapPrm: 0000000000200080
CapEff: 0000000000200080
CapBnd: 0000003fffffffff
CapAmb: 0000000000000000
$
上面,我们通过我们自己的query_pcap程序和 proc 文件系统检查了进程。CAP_SETUID和CAP_SYS_ADMIN功能都存在于允许和有效功能集中。
为了继续这个过程,我们发送一个信号;一个简单的方法是通过kill(1)命令(详细内容见后面的第十一章,信号-第一部分)。现在有很多东西要看:
$ kill %1
*(boing!)*
test_setuid:
RUID = 1000 EUID = 1000
RUID = 1000 EUID = 0
Now dropping all capabilities and reverting to original self...
test_setuid:
RUID = 1000 EUID = 1000
!WARNING! set_pcap.c:test_setuid:55: seteuid(0) failed...
perror says: Operation not permitted
RUID = 1000 EUID = 1000
Pausing #2 ...
$
有趣的(boing!)只是过程通知我们发生了信号处理。(忽略它。)我们调用test_setuid()函数,函数代码:
static void test_setuid(void)
{
printf("%s:\nRUID = %d EUID = %d\n", __FUNCTION__,
getuid(), geteuid());
if (seteuid(0) == -1)
WARN("seteuid(0) failed...\n");
printf("RUID = %d EUID = %d\n", getuid(), geteuid());
}
我们尝试用seteuid(0)代码行成为root(有效)。输出显示我们已经成功做到了,因为 EUID 变成了0。之后,我们调用drop_caps_be_normal()函数,它“删除”了所有功能并使用之前看到的setuid(getuid())语义将我们恢复为“我们的原始自己”;函数代码:
static void drop_caps_be_normal(void)
{
cap_t none;
/* cap_init() guarantees all caps are cleared */
if ((none = cap_init()) == NULL)
FATAL("cap_init() failed, aborting...\n");
if (cap_set_proc(none) == -1) {
cap_free(none);
FATAL("cap_set_proc('none') failed, aborting...\n");
}
cap_free(none);
/* Become your normal true self again! */
if (setuid(getuid()) < 0)
FATAL("setuid to lower privileges failed, aborting..\n");
}
程序输出确实显示我们的 EUID 现在恢复为非零(1000的 RUID),并且seteuid(0)失败,正如预期的那样(现在我们已经删除了功能和 root 权限)。
然后进程再次调用pause(2)(输出中的“暂停#2…”语句),以使进程保持活动状态;现在我们可以看到这个:
$ ./query_pcap 3981
Process 3981 : capabilities are: =
$ grep -i cap /proc/3981/status
Name: set_pcap
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 0000003fffffffff
CapAmb: 0000000000000000
$
确实,所有的能力都已经被放弃了。(我们把用选项1运行程序的测试案例留给读者。)
这里有一个有趣的观点:你可能会遇到这样的说法CAP_SYS_ADMIN是新的 root。真的吗?让我们来测试一下:如果我们只将CAP_SYS_ADMIN能力嵌入到二进制文件中,并修改代码在选项1下运行时不丢弃它会发生什么?乍一看,似乎这并不重要 - 我们仍然能够成功执行seteuid(0),因为我们实际上是以这种能力作为根用户运行的。但是猜猜看?它不起作用!底线是:这教会我们,虽然这个说法听起来不错,但它并不完全正确!我们仍然需要CAP_SETUID能力来执行set*id()系统调用的任意使用。
我们把这个案例的代码编写和测试留给读者作为练习。
杂项
还有一些其他杂项,但仍然有用的观察和提示:
ls 显示不同的二进制文件
Fedora 27(x86_64)的屏幕截图显示了*ls* -l在显示不同的二进制可执行文件类型时显示的漂亮颜色:

这些二进制文件到底是什么?让我们按照上面显示的顺序列出:
-
dumpcap:一个文件功能二进制可执行文件 -
passwd:一个setuid-root二进制可执行文件 -
ping:一个文件功能二进制可执行文件 -
write:一个setgid-tty二进制可执行文件
注意:精确的含义和着色在 Linux 发行版之间肯定会有所不同;所显示的输出来自 Fedora 27 x86_64 系统。
权限模型分层
现在我们已经在上一章中看到了传统的 UNIX 权限和本章中的现代 POSIX 能力模型的细节,我们对其进行了概述。现代 Linux 内核的现实情况是,传统模型实际上是建立在更新的能力模型之上的;以下表格显示了这种“分层”:
| 优缺点 | 模型/属性 |
|---|---|
| 更简单,更不安全 | UNIX 权限进程和带有 UID、GID 值的文件 |
| 进程凭证: | |
| 更复杂,更安全 | POSIX 能力 |
| 线程 Capsets,文件 Capsets | |
| 每个线程:{继承的,允许的,有效的,有界的,环境的} capsets 二进制文件:{继承的,允许的,有效的} capsets |
由于这种分层,有一些观察结果需要注意:
-
在上层:看起来像一个单一的整数,进程 UID 和 GID,实际上在底层是两个整数 - 真实和有效的用户|组 ID。
-
中间层:产生四个进程凭证:{RUID, EUID, RGID, EGID}。
-
底层:这又集成到现代 Linux 内核中 POSIX 能力模型中:
-
所有内核子系统和代码现在都使用能力模型来控制和确定对对象的访问。
-
现在root - 实际上是“新”root - 取决于(过载的)能力位
CAP_SYS_ADMIN的设置。 -
一旦存在
CAP_SETUID能力,set*id()系统调用可以任意用于设置真实/有效 ID: -
因此,您可以使 EUID = 0,依此类推。
安全提示
关于安全性的关键点的快速总结如下:
-
显然,尽可能不再使用过时的 root 模式;这包括(不)使用 setuid-root 程序。相反,您应该使用能力,并且只为进程分配所需的能力:
-
直接或通过
libcap(3)API(“能力智能”二进制文件)进行编程。 -
通过二进制文件的
setcap(8)文件功能间接设置。 -
如果上述是通过 API 路线完成的,那么一旦需要该能力,您应立即考虑放弃该能力(并且只在需要时提高它)。
-
容器:一种“热门”的相当新的技术(本质上,容器在某种意义上是轻量级虚拟机),它们被认为是“安全”的,因为它们有助于隔离运行的代码。然而,现实并不那么乐观:容器部署通常缺乏对安全性的考虑,导致高度不安全的环境。您可以通过明智地使用 POSIX 能力模型在安全方面获得很大的好处。有关如何要求 Docker(一种流行的容器技术产品)放弃能力并从而大大提高安全性的有趣的 RHEL 博客在这里详细介绍:
rhelblog.redhat.com/2016/10/17/secure-your-containers-with-this-one-weird-trick/。
FYI - 在内核层面
(以下段落仅供参考,如果对更深入的细节感兴趣,请查看,或者随意跳过。)
在 Linux 内核中,所有任务(进程和线程)元数据都保存在一个称为task_struct(也称为进程描述符)的数据结构中。关于 Linux 所谓的任务的安全上下文的信息保存在这个任务结构中,嵌入在另一个称为cred(缩写为凭证)的数据结构中。这个结构cred包含了我们讨论过的一切:现代 POSIX 能力位掩码(或能力集)以及传统风格的进程特权:RUID、EUID、RGID、EGID(以及 set[u|g]id 和 fs[u|g]id 位)。
我们之前看到的procfs方法实际上是从这里查找凭据信息。黑客显然对访问凭据结构并能够在运行时修改它感兴趣:在适当的位置填充零可以让他们获得 root 权限!这听起来离谱吗?在 GitHub 存储库的进一步阅读部分中查看(一些) Linux 内核利用。不幸的是,这种情况经常发生。
总结
在本章中,读者已经了解了关于现代 POSIX 能力模型(在 Linux 操作系统上)的设计和实现的重要思想。除其他事项外,我们已经介绍了什么是 POSIX 能力,以及为什么它们很重要,特别是从安全的角度来看。还介绍了将能力嵌入运行时进程或二进制可执行文件。
讨论的整个目的,始于上一章,是让应用程序开发人员认识到在开发代码时出现的关键安全问题。我们希望我们已经让您,读者,感到紧迫,当然还有处理现代安全性的知识和工具。今天的应用程序不仅仅是要工作;它们必须以安全性为考量来编写!否则……
第九章:进程执行
想象这样的情景:作为一个系统程序员(在 Linux 上使用 C 语言)在一个项目上工作时,有一个要求,即在图形用户界面(GUI)前端应用程序中,当最终用户点击某个按钮时,应用程序必须显示系统生成的 PDF 文档的内容。我们可以假设有一个 PDF 阅读器软件应用程序可供我们使用。但是,你要如何在 C 代码中运行它?
本章将教你如何执行这一重要任务。在这里,我们将学习一些核心的 Unix/Linux 系统编程概念:Unix exec模型的工作原理,前身/后继术语,以及如何使用多达七个exec系列 API 来使整个过程在代码中实际运行。当然,在这个过程中,会使用代码示例来清楚地说明这些概念。
简而言之,读者将学习以下关键领域:
-
exec操作的含义及其语义 -
测试
exec操作 -
使用
exec的错误和正确方式 -
使用
exec进行错误处理 -
七个
exec系列 API 及其在代码中的使用方法。
技术要求
本章的一个练习要求安装 Poppler 软件包(PDF 工具);可以按以下方式安装:
在 Ubuntu 上:sudo apt install poppler-utils
在 Fedora 上:sudo dnf install poppler-utils-<version#>
关于 Fedora 案例:要获取版本号,只需输入上述命令,然后在输入poppler-utils-后按两次Tab键;它将自动完成并提供一个选择列表。选择最新版本并按Enter。
进程执行
在这里,我们研究 Unix/Linux 操作系统在系统程序员级别上如何执行程序。首先,我们将教你理解重要的exec语义;一旦这清楚了,你就可以使用exec系列 API 来编程。
将程序转换为进程
如前所述,程序是存储介质上的二进制文件;它本身是一个死对象。要运行它,使其成为一个进程,我们必须执行它。当你从 shell 中运行程序时,它确实会变得活跃并成为一个进程。
这里是一个快速示例:
$ ps
PID TTY TIME CMD
3396 pts/3 00:00:00 bash
21272 pts/3 00:00:00 ps
$
从前面的代码中可以看出,从 shell(本身就是一个进程:bash)中运行或执行ps(1)程序;ps确实运行了;它现在是一个进程;它完成了它的工作(在这里打印出当前在这个终端会话中活动的进程),然后礼貌地死去,让我们回到 shell 的提示符。
稍加思考就会发现,要使ps(1)程序成为ps进程,操作系统可能需要做一些工作。确实如此:操作系统通过一个名为execve(2)的 API,一个系统调用,执行程序并最终使其成为运行中的进程。不过,现在让我们暂时把 API 放在一边,专注于概念。
exec Unix 公理
我们在第二章中学到,即虚拟内存,一个进程可以被视为一个盒子(一个矩形),具有虚拟地址空间(VAS);VAS 由称为段的同质区域(技术上称为映射)组成。基本上,一个进程的 VAS 由几个段组成:文本(代码)段、数据段、库(和其他)映射以及栈。为了方便起见,这里再次呈现了表示进程 VAS 的图表:

图 1:进程虚拟地址空间(VAS)
底端的虚拟地址为0,地址随着向上增加;我们有一个向上增长的堆和一个向下增长的栈。
机器上的每个进程都有这样的进程 VAS;因此,可以推断出,我们之前的小例子中的 shell,bash,也有这样的进程 VAS(以及所有其他属性,如进程标识符(PID)、打开的文件等)。
所以,让我们想象一下,shell 进程 bash 的 PID 是 3,396。现在,当我们从 shell 运行ps时,实际上发生了什么?
显然,作为第一步,shell 会检查ps是否是一个内置命令;如果是,它会运行它;如果不是,也就是我们的情况,它会继续到第二步。现在,shell 解析PATH环境变量,并且在/bin中找到了ps。第三步,有趣的一步!,是 shell 进程现在通过 API 执行/bin/ps。我们将把确切的 API 讨论留到以后;现在,我们只是把可能的 API 称为execAPI。
不要为了树木而忘记了森林;我们现在要谈到的一个关键点是:当exec发生时,调用进程(bash)通过让(除其他设置外)ps覆盖其虚拟地址空间(VAS)来执行被调用的进程(ps)。是的,你没看错——Unix 和因此 Linux 上的进程执行是通过一个进程——“调用者”——被要执行的进程——“被调用者”——覆盖来实现的。
术语
这里有一些重要的术语可以帮助我们:调用exec(在我们的例子中是 bash)的进程被称为“前任”;被调用和执行的进程(在我们的例子中是 ps)被称为“继任”。
exec 操作期间的关键点
以下总结了前任进程执行继任进程时需要注意的重要点:
-
继任进程覆盖(或叠加)了前任的虚拟地址空间。
-
实际上,前任的文本、数据、库和堆栈段现在被继任的替换了。
-
操作系统将负责大小调整。
-
没有创建新进程——继任现在在旧前任的上下文中运行。
-
前任属性(包括但不限于 PID 和打开文件)因此被继任者自动继承。
(敏锐的读者可能会问,为什么在我们之前的例子中,ps的 PID 不是 3,396?请耐心等待,我们将在 GitHub 存储库中得到确切的答案)。
- 在成功的 exec 中,没有可能返回到前任;它已经消失了。口头上说,执行 exec 就像对前任自杀一样:成功执行后,继任就是唯一留下的;返回到前任是不可能的:
图 2:exec 操作
测试 exec 公理
你能测试上面描述的exec公理吗?当然。我们可以用三种不同的方式来尝试。
实验 1 - 在 CLI 上,不花俏
按照以下简单的步骤:
-
启动一个 shell(通常是一个基于 GUI 的 Linux 上的终端窗口)
-
在窗口中,或者更准确地说,在 shell 提示符中,输入这个:
$ exec ps
你注意到了什么?你能解释一下吗?
嘿,请先试一下,然后再继续阅读。
是的,终端窗口进程在这里是前任;在 exec 之后,它被继任进程ps覆盖,完成它的工作并退出(你可能没有看到输出,因为它消失得太快了)。ps是继任进程,当然,我们不能返回到前任(终端窗口)——ps已经完全替换了它的 VAS。因此,终端窗口实际上消失了。
实验 2 - 在 CLI 上,再次
这一次,我们会让你更容易!按照给定的步骤进行:
-
启动一个 shell(通常是一个基于 GUI 的 Linux 上的终端窗口)。
-
在窗口中,或者更准确地说,在 shell 提示符中,先运行
ps,然后是bash——是的,我们在这里生成一个子 shell,然后再次运行ps。(查看下一个截图;注意原始和子 shell Bash 进程的 PID - 3,396 和 13,040)。 -
在子 shell 中,
execps命令;这个ps继任进程覆盖(或叠加)了前任进程——bash 子 shell 的进程镜像。 -
观察输出:在
exec ps命令输出中,ps的 PID 是 bash 子 shell 进程的 PID:13,040!这表明它是在该进程的上下文中运行。 -
还要注意,现在我们又回到了原始的 bash shell 进程 PID 3,396,因为当然,我们无法返回到前身:

第三次实验运行很快就会开始,一旦我们有了一些execAPI 来玩耍。
不归路
对于系统程序员来说,重要的是要理解,一旦exec操作成功,就不会返回到前身进程。为了说明这一点,考虑这里的粗略调用图:
main()
foo()
exec(something)
bar()
main()调用foo(),它调用exec(something);一旦exec成功,bar()就永远不会运行了!
为什么不呢?我们无法在前身的执行路径中到达它,因为整个执行上下文现在已经改变 - 到了后继进程的上下文(某个东西)。PID 仍然保持不变。
只有在exec失败时,函数bar()才会获得控制(当然,我们仍然会处于前身的上下文中)。
作为进一步的细节,注意exec()操作本身可能成功,但被执行的进程something失败。没关系;这不会改变语义;bar()仍然不会执行,因为后继者已经接管了。
家庭时间 - exec 家族 API
现在我们已经理解了exec的语义,是时候看看如何在程序中执行exec操作了。Unix 和 Linux 提供了几个 C API,实际上有七个,最终都是做同样的工作:它们让前身进程exec后继进程。
所以,有七个 API 都做同样的事情?大多数是的;因此它们被称为exec家族 API。
让我们来看看它们:
#include <unistd.h>
extern char **environ;
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,
char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
char *const envp[]);
execvpe(): _GNU_SOURCE
等等,虽然我们说有七个 API,但上面的列表只有六个;确实:第七个在某种意义上是特殊的,没有显示在上面。像往常一样,耐心等待一下;我们会介绍的!
事实上,尽管每个 API 最终都会执行相同的工作,但根据您所处的情况(方便性),使用特定的 API 会有所帮助。让我们不要挑剔,至少现在,忽略它们的差异;相反,让我们专注于理解第一个;其余的将自动而轻松地跟随。
看看第一个 API,execl(3):
int execl(const char *path, const char *arg, ...);
它需要两个、三个还是更多的参数?如果你对此还不熟悉,省略号...表示可变参数列表或varargs,这是编译器支持的一个特性。
第一个参数是您想要执行的应用程序的路径名。
从第二个参数开始,varargs,传递给后继进程的参数包括argv[0]。想想,在上面的简单实验中,我们通过 shell 进程在命令行上传递了参数;实际上,真正传递给后继进程所需参数的是前身,也就是 shell 进程。这是有道理的:除了前身,谁还会传递参数给后继者呢?
编译器如何知道你何时传递参数?简单:你必须用空指针终止参数列表:execl(const char *pathname_to_successor_program, const char *argv0, const char *argv1, ..., const char *argvn, (char *)0);
现在你可以看到为什么它被命名为execl:当然,execl API 执行exec;最后一个字母l表示长格式;后继进程的每个参数都传递给它。
为了澄清这一点,让我们写一个简单的示例 C 程序;它的工作是调用uname进程:
为了可读性,这里只显示了代码的相关部分;要查看和运行它,整个源代码在这里可用:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
int main(int argc, char **argv)
{
if (argc < 2) {
[...]
}
/* Have us, the predecessor, exec the successor! */
if (execl("/bin/uname", "uname", argv[1], (char *)0) == -1)
FATAL("execl failed\n");
printf("This should never get executed!\n");
exit (EXIT_SUCCESS);
}
以下是一些需要注意的要点:
-
execlAPI 的第一个参数是继承者的路径名。 -
第二个参数是程序的名称。小心:一个相当典型的新手错误是漏掉它!
-
在这种简单的情况下,我们只传递用户发送的参数
argv[1]:-a或-r;我们甚至没有进行健壮的错误检查,以确保用户传递了正确的参数(我们把它留给你作为练习)。 -
如果我们只尝试用一个单独的
0来进行空终止,编译器会抱怨,警告如下(这可能取决于你使用的gcc编译器版本):
warning: missing sentinel in function call [-Wformat=]。
为了消除警告,你必须像代码中所示的那样用(char *)对0进行强制转换。
-
最后,我们使用
printf()来演示控制永远不会到达它。为什么呢?嗯,想想看: -
要么
execl成功;因此继承者进程(uname)接管。 -
或者
execl失败;FATAL宏执行错误报告并终止前身。
让我们构建并尝试一下:
$ ./execl_eg
Usage: ./execl_eg {-a|-r}
-a : display all uname info
-r : display only kernel version
$
传递一个参数;我们在这里展示一些例子:
$ ./execl_eg -r
4.13.0-36-generic
$ ./execl_eg -a
Linux seawolf-mindev 4.13.0-36-generic #40-Ubuntu SMP Fri Feb 16 20:07:48 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux
$ ./execl_eg -eww
uname: invalid option -- 'e'
Try 'uname --help' for more information.
$
它确实有效(尽管,正如从最后一个案例中可以看到的那样,execl_eg程序的参数错误检查并不好)。
我们鼓励你自己尝试这个简单的程序;事实上,多做一些实验:例如,将第一个参数更改为一些未知的内容(例如/bin/oname)并看看会发生什么。
错误的方法
有时,为了展示正确的做法,首先看看错误的做法是有用的!
错误处理和 exec
一些程序员炫耀:他们不使用if条件来检查exec API 是否失败;他们只是在exec后写下一行代码作为失败情况!
例如,拿前面的程序,但将代码更改为这样,这是错误的做法:
execl("/bin/uname", "uname", argv[1], (char *)0);
FATAL("execl failed\n");
它有效,是的:控制将永远到达'FATAL()'行的唯一原因是 exec 操作失败。这听起来很酷,但请不要这样编码。要专业一点,遵循规则和良好的编码风格指南;你会成为一个更好的程序员并为此感到高兴!(一个无辜的新手程序员甚至可能没有意识到上面的execl之后是实际的错误处理;谁能怪他呢?他可能会尝试在那里放一些业务逻辑!)
传递零作为参数
假设我们有一个(虚构的)要求:从我们的 C 代码中,我们必须执行程序/projectx/do_this_now并传递三个参数:-1,0和55。就像这样:
/projectx/do_this_now -1 0 55
回想一下exec API 的语法:
execl(const char *pathname_to_successor_program, const char *argv0, const char *argv1, ..., const char *argvn, (char *)0);
所以,这似乎相当琐碎;让我们做吧:
execl("/projectx/do_this_now", "do_this_now", -1, 0, 55, (char *)0);
哎呀!编译器会,或者可能会,将继承者的第二个参数0(在-1之后)解释为NULL终结符,因此不会看到后面的参数55。
修复这很容易;我们只需要记住每个传递给继承者进程的参数都是字符指针类型,而不是整数;NULL终结符本身是一个整数(尽管为了让编译器满意,我们将其强制转换为(char *)),就像这样:
execl("/projectx/do_this_now", "do_this_now", "-1", "0", "55", (char *)0);
指定继承者的名称
不,我们这里不是在讨论如何黑掉谁将继承伊丽莎白二世王位的问题,抱歉。我们所指的是:如何正确指定继承进程的名称;也就是说,我们是否可以以编程方式将其更改为我们喜欢的任何内容?
乍一看,它看起来确实很琐碎:execl的第二个参数是要传递给后继的argv[0]参数;实际上,它看起来像是它的名称!所以,让我们试一试:我们编写了一对 C 程序;第一个程序,前身(ch9/predcs_name.c)从用户那里传递一个名称参数。然后通过execl执行我们的另一个程序successor_setnm,并将用户提供的名称作为第一个参数传递给后继(在 API 中,它将后继的argv[0]参数设置为前身的argv[1]),如下所示:execl("./successor_setnm", argv[1], argv[1], (char *)0);
回想一下execl的语法:execl(pathname_to_successor_program, argv0, argv1, ..., argvn, 0);
因此,这里的想法是:前身已将后继的argv[0]值设置为argv[1],因此后继的名称应该是前身的argv[1]。然而,它并没有成功;请看一次运行的输出:
$ ./predcs_name
Usage: ./predcs_name {successor_name} [do-it-right]
$ ./predcs_name UseThisAsName &
[1] 12571
UseThisAsName:parameters received:
argv[0]=UseThisAsName
argv[1]=UseThisAsName
UseThisAsName: attempt to set name to 1st param "UseThisAsName" [Wrong]
UseThisAsName: pausing now...
$
$ ps
PID TTY TIME CMD
1392 pts/0 00:00:01 Bash
12571 pts/0 00:00:00 successor_setnm
12576 pts/0 00:00:00 ps
$
我们故意让后继进程调用pause(2)系统调用(它只是导致它休眠,直到它收到一个信号)。这样,我们可以在后台运行它,然后运行ps来查找后继 PID 和名称!
有趣的是:我们发现,虽然在ps输出中名称不是我们想要的(上面),但在printf中是正确的;这意味着argv[0]已经正确接收并设置为后继。
好的,我们必须清理一下;现在让我们杀死后台进程:
$ jobs
[1]+ Running ./predcs_name UseThisAsName &
$ kill %1
[1]+ Terminated ./predcs_name UseThisAsName
$
因此,现在显而易见的是,我们之前所做的还不够:为了在操作系统层面反映我们想要的名称,我们需要一种替代的 API;这样的 API 之一是prctl(2)系统调用(甚至是pthread_setname_np(3)线程 API)。在这里不详细介绍,我们使用PR_SET_NAME参数(通常,请参阅prctl(2)的 man 页面以获取完整详情)。因此,使用prctl(2)系统调用的正确代码(仅显示successor_setnm.c中的相关代码片段)如下:
[...]
if (argc == 3) { /* the "do-it-right" case! */
printf("%s: setting name to \"%s\" via prctl(2)"
" [Right]\n", argv[0], argv[2]);
if (prctl(PR_SET_NAME, argv[2], 0, 0, 0) < 0)
FATAL("prctl failed\n");
} else { /* wrong way... */
printf("%s: attempt to implicitly set name to \"%s\""
" via the argv[0] passed to execl [Wrong]\n",
argv[0], argv[1]);
}
[...]
$ ./predcs_name
Usage: ./predcs_name {successor_name} [do-it-right]
$
所以,我们现在以正确的方式运行它(逻辑涉及传递一个可选的第二个参数,该参数将用于“正确”设置后继进程的名称):
$ ./predcs_name NotThis ThisNameIsRight &
[1] 12621
ThisNameIsRight:parameters received:
argv[0]=ThisNameIsRight
argv[1]=NotThis
argv[2]=ThisNameIsRight
ThisNameIsRight: setting name to "ThisNameIsRight" via prctl(2) [Right]
ThisNameIsRight: pausing now...
$ ps
PID TTY TIME CMD
1392 pts/0 00:00:01 Bash
12621 pts/0 00:00:00 ThisNameIsRight
12626 pts/0 00:00:00 ps
$ kill %1
[1]+ Terminated ./predcs_name NotThis ThisNameIsRight
$
这次它的工作完全符合预期。
剩下的 exec 系列 API
很好,我们已经详细介绍了如何正确和不正确地使用exec API 系列中的第一个execl(3)。剩下的呢?让我们来看看它们;为了方便读者,以下是列表:
#include <unistd.h>
extern char **environ;
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,
char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
char *const envp[]);
execvpe(): _GNU_SOURCE
正如多次提到的,execl的语法是这样的:execl(const char *pathname_to_successor_program, const char *argv0, const char *argv1, ..., const char *argvn, (char *)0);
记住,它的名字是execl;l意味着长格式可变参数列表:后继进程的每个参数依次传递给它。
现在让我们看看家族中的其他 API。
execlp API
execlp是execl的一个小变体:
int **execlp**(const char ***file**, const char *arg, ...);
与之前一样,execlp中的l意味着长格式可变参数列表;p意味着环境变量PATH用于搜索要执行的程序。您可能知道,PATH 环境变量由一组以冒号(:)分隔的目录组成,用于搜索要运行的程序文件;第一个匹配项是要执行的程序。
例如,在我们的 Ubuntu VM 上(我们以用户seawolf登录):
$ echo $PATH
/home/seawolf/bin:/home/seawolf/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games
$
因此,如果您通过execlp执行一个进程,您不需要给出绝对或完整的路径名作为第一个参数,而只需要给出程序名;看看以下两个示例的区别:
execl("/bin/uname", "uname", argv[1], (char *)0);
**execlp**("uname", "uname", argv[1], (char *)0);
使用execl,您必须指定uname的完整路径名;使用execlp,您不需要;库例程将执行查找 PATH 和找到uname的匹配的工作!(它会在/bin中找到第一个匹配项)。
使用which工具来定位一个程序,实际上是在路径中找到它的第一个匹配项。例如:
$ which uname
/bin/uname
$
这个execlp自动搜索路径的事实确实很方便;但需要注意的是,这可能会牺牲安全性!
黑客编写称为特洛伊木马的程序——基本上是假装成其他东西的程序;这显然是危险的。如果黑客能够在你的家目录中放置一个uname的特洛伊木马版本,并修改 PATH 环境变量以首先搜索你的家目录,那么当你(以为)运行uname时,他们就可以控制你。
出于安全原因,最好在执行程序时指定完整的pathname(因此,避免使用execlp、execvp和execvpeAPI)。
如果 PATH 环境变量未定义会怎么样?在这种情况下,API 会默认搜索进程的当前工作目录(cwd)以及一个叫做confstr路径,通常默认为目录/bin,然后是/usr/bin。
execle API
现在是关于execle(3)的 API;它的签名是:
int **execle**(const char *path, const char *arg, ...,char * const envp[]);
和之前一样,execle中的l表示长格式可变参数列表;e表示我们可以传递一个环境变量数组给后续进程。
进程环境由一组<name>=<value>变量对组成。环境实际上对每个进程都是唯一的,并存储在进程堆栈段中。你可以通过printenv、env或set命令(set是一个 shell 内置命令)来查看整个列表。在程序中,使用extern char **environ来访问进程的环境。
默认情况下,后继进程将继承前驱进程的环境。如果这不是所需的,该怎么办;例如,我们想要执行一个进程,但更改 PATH 的值(或者引入一个新的环境变量)。为此,前驱进程将复制环境,根据需要修改它(可能添加、编辑、删除变量),然后将指向新环境的指针传递给后继进程。这正是最后一个参数char * const envp[]的用途。
旧的 Unix 程序曾经接受main()的第三个参数:char **arge,表示进程环境。现在这被认为是不推荐的;应该使用extern environ代替。
没有机制只传递一些环境变量给后续进程;整个一堆环境变量——以字符串的二维数组形式(本身是NULL结尾)必须被传递。
execv API
execv(3) API 的签名是:
int **execv**(const char *path, char *const argv[]);
可以看到,第一个参数是后继进程的路径名。第二个参数与上面的环境列表类似,是一个二维字符串数组(每个字符串都以NULL结尾),保存所有要传递给后继进程的参数,从argv[0]开始。想想看,这与我们 C 程序员如此习惯的东西是一样的;这就是 C 中main()函数的签名:
int main(int argc, char *argv[]);
argc,当然,是接收到的参数数量,包括程序名称本身(保存在argv[0]中),而argv是指向一个二维字符串数组的指针(每个字符串都以NULL结尾),保存从argv[0]开始的所有参数。
因此,我们口头上称之为短格式(与之前使用的长格式l风格相对)。当你看到v(代表 argv)时,它代表短格式参数传递风格。
现在,剩下的两个 API 很简单:
-
execvp(3):短格式参数,以及被搜索的路径。 -
execvpe(3):短格式参数,正在搜索的路径,以及显式传递给后继的环境列表。此外,这个 API 要求定义特性测试宏_GNU_SOURCE(顺便说一句,在本书的所有源代码中我们都这样做)。
带有p的exec函数——搜索PATH的函数——execlp、execvp和execvpe具有一个额外的特性:如果它们正在搜索的文件被找到但没有权限打开它,它们不会立即失败(就像其他exec API 会失败并将errno设置为EACCESS一样);相反,它们将继续搜索PATH的其余部分以寻找文件。
在操作系统级别执行
到目前为止,我们已经涵盖了七个exec API 家族中的六个。最后,第七个是execve(2)。你注意到了吗?括号中的2表示它是一个系统调用(回想一下第一章中关于系统调用的细节)。
事实上,所有前面的六个exec API 都在glibc库层内;只有execve(2)是一个系统调用。你会意识到,最终,要使一个进程能够执行另一个程序——从而启动或运行一个后继程序——将需要操作系统级别的支持。所以,是的,事实是,所有上述六个exec API 只是包装器;它们转换它们的参数并调用execve系统调用。
这是execve(2)的签名:
int execve(const char *filename, char *const argv[], char *const envp[]);
看一下 exec API 家族的总结表。
总结表 - exec API 家族
这是一个总结所有七个exec家族 API 的表:
| Exec API | 参数:长格式(l) | 参数:短格式(v) | 搜索路径?(p) | 传递环境?(e) | API 层 |
|---|---|---|---|---|---|
execl |
Y | N | N | N | Lib |
execlp |
Y | N | Y | N | Lib |
execle |
Y | N | N | Y | Lib |
execv |
N | Y | N | N | Lib |
execvp |
N | Y | Y | N | Lib |
execvpe |
N | Y | Y | Y | Lib |
execve |
N | Y | N | Y | SysCall |
exec API 的格式:exec<foo>,其中<foo>是{l,v,p,e}的不同组合。
所有列出的 API,在成功时,正如我们所学的那样,都不会返回。只有在失败时,你才会看到一个返回值;根据通常的规范,全局变量errno将被设置以反映错误的原因,可以方便地通过perror(3)或strerror(3)API 来查找(例如,在本书提供的源代码中,查看common.h头文件中的FATAL宏)。
代码示例
在本章的介绍中,我们提到了一个要求:从 GUI 前端,显示系统生成的 PDF 文档的内容。让我们在这里做这个。
为此,我们需要一个 PDF 阅读器应用程序;我们可以假设我们有一个。事实上,在许多 Linux 发行版中,evince 应用程序是一个很好的 PDF 阅读器应用程序,通常预装(在 Ubuntu 和 Fedora 等发行版上是真的)。
在这里,我们不会使用 GUI 前端应用程序,我们将使用老式的 C 语言编写一个 CLI 应用程序,给定一个 PDF 文档的路径名,执行 evince PDF 阅读器应用程序。我们要显示哪个 PDF 文档?啊,这是一个惊喜!(看一下):
为了可读性,只显示代码的相关部分如下;要查看和运行它,整个源代码在这里可用:
github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
const char *pdf_reader_app="/usr/bin/evince";
static int exec_pdf_reader_app(char *pdfdoc)
{
char * const pdf_argv[] = {"evince", pdfdoc, 0};
if (execv(pdf_reader_app, pdf_argv) < 0) {
WARN("execv failed");
return -1;
}
return 0; /* never reached */
}
我们从main()中调用前面的函数如下:
if (exec_pdf_reader_app(argv[1]) < 0)
FATAL("exec pdf function failed\n");
我们构建它,然后执行一个示例运行:
$ ./pdfrdr_exec
Usage: ./pdfrdr_exec {pathname_of_doc.pdf}
$ ./pdfrdr_exec The_C_Programming_Language_K\&R_2ed.pdf 2>/dev/null
$
这是一个动作的截图!

如果我们只在控制台上运行 Linux(没有 GUI)?那么,当然,前面的应用程序将无法工作(而且 evince 甚至可能没有安装)。这是这种情况的一个例子:
$ ./pdfrdr_exec ~/Seawolf_MinDev_User_Guide.pdf
!WARNING! pdfrdr_exec.c:exec_pdf_reader_app:33: execv failed
perror says: No such file or directory
FATAL:pdfrdr_exec.c:main:48: exec pdf function failed
perror says: No such file or directory
$
在这种情况下,为什么不尝试修改上述应用程序,改用 CLI PDF 工具集呢;其中一个这样的工具集来自 Poppler 项目(见下面的注释)。其中一个有趣的实用工具是pdftohtml。为什么不使用它来从 PDF 文档生成 HTML 呢?我们把这留给读者作为一个练习(请参阅 GitHub 存储库上的问题部分)。
这些有用的 PDF 实用程序是由一个名为 Poppler 的开源项目提供的。您可以在 Ubuntu 上轻松安装这些 PDF 实用程序:sudo apt install poppler-utils
我们可以很容易地跟踪pdfrdr_exec程序中发生的情况;在这里,我们使用ltrace(1)来查看发出的库调用:
$ ltrace ./pdfrdr_exec The_C_Programming_Language_K\&R_2ed.pdf
execv("/usr/bin/evince", 0x7ffcd861fc00 <no return ...>
--- Called exec() ---
g_static_resource_init(0x5575a5aff400, 0x7ffc5970f888, 0x7ffc5970f8a0, 32) = 0
ev_get_locale_dir(2, 0x7ffc5970f888, 0x7ffc5970f8a0, 32) = 0x7fe1ad083ab9
[...]
关键调用:当然可以看到execv;有趣的是,ltrace友好地告诉我们它没有返回值...。然后我们看到了 evince 软件本身的库 API。
如果我们使用strace(1)来查看发出的系统调用呢?
$ strace ./pdfrdr_exec The_C_Programming_Language_K\&R_2ed.pdf
execve("./pdfrdr_exec", ["./pdfrdr_exec", "The_C_Programming_Language_K&R_2"...], 0x7fff7f7720f8 /* 56 vars */) = 0
brk(NULL) = 0x16c0000
access("/etc/ld.so.preload", R_OK) = 0
openat(AT_FDCWD, "/etc/ld.so.preload", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=0, ...}) = 0
[...]
是的,第一个是execve(2),证明了execv(3)库 API 调用了execve(2)系统调用。当然,输出的其余部分是 evince 进程执行时发出的系统调用。
总结
本章介绍了 Unix/Linux 的exec编程模型;前身和后继进程的关键概念,以及后继进程(或多或少地)如何覆盖前身。介绍了七个exec家族 API,以及几个代码示例。还介绍了错误处理、后继名称规范等内容。系统程序员现在将有足够的知识来编写正确执行给定程序的 C 代码。
第十章:进程创建
在上一章中,我们学习了如何处理(虚构的)应用程序设计和实现需求:让我们的 C 程序执行(exec)另一个完全不同的程序。然而,现实情况是讨论仍然不完整;这一章关于进程创建将填补一些空白,并且更多。
在本章中,您将学习一些核心的 Unix/Linux 系统编程概念:正确编程关键的fork(2)系统调用所需的细节。在这个过程中,Unix 爱好者术语如阻塞调用、孤儿和僵尸也将得到澄清。这些材料将仔细呈现出微妙的要点,将普通开发人员变成熟练的开发人员。同时,读者将学会编写 C 代码,以在 Linux 系统应用程序中实现前述关键概念。和往常一样,我们将使用多个代码示例来清楚地说明和巩固所教授的概念。
本章的目的是指导 Linux 系统开发人员进入 Unix 的核心系统编程世界,包括fork-exec-wait语义和相关领域。简而言之,我们将重点关注以下几个方面,帮助读者学习:
-
Unix 进程创建模型
-
其中的原因和方法
-
更深入的细节,包括:
-
fork如何影响内存分配、打开文件等,以及安全性影响 -
waitAPI 的几种形式 -
这些 API 如何实际使用
-
fork的规则 -
孤儿和僵尸进程
进程创建
除非 Unix/Linux 系统程序员一直生活在某个地方的岩石下,他们肯定听说过,如果不是直接使用过fork(2)系统调用。为什么它如此著名和重要?原因很简单:Unix 是一个多任务操作系统;程序员必须利用操作系统的能力。要使应用程序多任务,我们需要创建多个任务或进程;fork是 Unix 创建进程的方式。事实上,对于典型的系统程序员来说,fork是创建进程的唯一可用方式。
还有另一个用于创建进程或线程的系统调用:clone(2)。它也创建一个自定义进程。它通常不被 Linux 应用程序开发人员使用;库(通常是线程库)开发人员更多地使用它。在本书中,我们不探讨clone;首先,它非常特定于 Linux 且不可移植;其次,它更像是一个隐藏的 API。
另一种多任务的方式是通过多线程,当然,这将在后面的章节中详细介绍。
fork的工作原理
理论上,fork(2)系统调用的工作描述可以简化为一个简单的语句:创建一个调用进程的相同副本。我们将反复遇到的术语如下:调用fork的进程称为父进程,而新创建的、新生的进程称为子进程。
请注意,起初,我们将保持对fork工作方式的讨论纯粹概念化和简单;稍后,我们将深入探讨并澄清操作系统执行的几项必要优化。
fork是一个系统调用;因此,进程创建的实际工作是由操作系统在后台完成的。回想一下第二章中的虚拟内存,一个进程的虚拟地址空间(VAS)是由称为段(或映射)的同质区域构建而成。因此,当创建一个子进程时,操作系统将父进程的文本、数据(三个)、库(和其他映射),以及堆栈段复制到子进程中。
然而,不止于此:进程不仅仅是它的虚拟地址空间。这包括打开的文件,进程凭证,调度信息,文件系统结构,分页表,命名空间(PID 等),审计信息,锁,信号处理信息,定时器,警报,资源限制,IPC 结构,性能(perf)信息,安全(LSM)指针,seccomp,线程栈和 TLS,硬件上下文(CPU 和其他寄存器),等等。
许多早期提到的属性远远超出了本书的范围,我们不会尝试深入研究它们。想要表明进程不仅仅是虚拟地址空间。
呼!因此,在 fork 中涉及内核从父进程复制多个东西到子进程。但是,想一想:并非所有属性都直接从父进程继承到子进程(许多是,但肯定不是所有的)。例如,进程 PID 和 PPID(父进程 PID)不会被继承(你能想出原因吗?)。
作为第一级枚举,以下进程属性在 fork(意思是,新生的孩子-获得父进程的属性副本与相同的内容)时被子进程继承:
-
虚拟地址空间(VAS):
-
文本
-
数据:
-
初始化
-
未初始化(bss)
-
堆
-
库段
-
其他映射(例如,共享内存区域,mmap 区域等)
-
栈
-
打开的文件
-
进程凭证
-
调度信息
-
文件系统(VFS)结构
-
分页表
-
命名空间
-
信号处理
-
资源限制
-
IPC 结构
-
性能(perf)信息
-
安全信息:
-
- 安全(LSM)指针
-
Seccomp
-
线程栈和 TLS
-
硬件上下文
父进程的以下属性在 fork 时不会被子进程继承:
-
PID,PPID
-
锁
-
待处理和阻塞信号(为子进程清除)
-
定时器,警报(为子进程清除)
-
审计信息(CPU/时间计数器为子进程重置)
-
通过
semop(2)进行信号量调整 -
异步 IO(AIO)操作和上下文
以图表形式看到这一点很有用:

可以看到,fork(2)确实是一个繁重的操作!
如果感兴趣,您可以在fork(2)的 man 页面中找到更多关于继承/非继承特性的详细信息。
使用 fork 系统调用
fork 的签名本身就是简单的:
pid_t fork(void);
这看起来微不足道,但你知道那句话“魔鬼藏在细节中”!的确,我们将提出几个关于正确使用此系统调用的微妙和不那么微妙的指针。
为了开始理解 fork 的工作原理,让我们编写一个简单的 C 程序(ch10/fork1.c):
int main(int argc, char **argv)
{
fork();
printf("Hello, fork.\n");
exit (EXIT_SUCCESS);
}
构建并运行它:
$ make fork1
gcc -Wall -c ../../common.c -o common.o
gcc -Wall -c -o fork1.o fork1.c
gcc -Wall -o fork1 fork1.c common.o
$ ./fork1
Hello, fork.
Hello, fork.
$
fork 将在成功时创建一个新的子进程。
一个关键的编程规则:永远不要假设 API 成功,总是检查失败的情况!!!
这一点无法过分强调。
好的,让我们修改代码以检查失败的情况;任何系统调用(可能除了大约 380 个系统调用中的两个例外)在失败时返回-1。检查它;这是相关的代码片段(ch10/fork1.c):
if (fork() == -1)
FATAL("fork failed!\n");
printf("Hello, fork.\n");
exit(EXIT_SUCCESS);
输出与之前看到的完全相同(当然,因为 fork 没有失败)。所以,printf似乎被执行了两次。确实是这样:一次是由父进程执行的,一次是由新的子进程执行的。这立即教会我们一些关于 fork 工作方式的东西;在这里,我们将尝试将这些东西编码为 fork 的规则。在本书中,我们将最终将 fork(2)的七条规则编码。
Fork 规则#1
Fork 规则#1:成功 fork 后,父进程和子进程中的执行都将继续在 fork 后的指令处进行。
为什么会这样呢?嗯,想一想:fork的工作是在子进程中创建父进程的(几乎)相同的副本;这包括硬件上下文(前面提到的),当然也包括指令指针(IP)寄存器(有时称为程序计数器(PC))本身!因此,子进程也将在与父进程相同的位置执行用户模式代码。由于fork成功,控制不会转到错误处理代码(FATAL()宏);相反,它将转到printf。关键是:这将在(原始)父进程和(新的)子进程中都发生。因此输出。
为了加强这一点,我们编写了这个简单的 C 程序的第三个版本(ch10/fork3.c)。在这里,我们只显示printf语句,因为这是唯一一行代码发生了变化(从ch10/fork3.c):
printf("PID %d: Hello, fork.\n", getpid());
构建并运行它:
$ ./fork3
PID 25496: Hello, fork.
PID 25497: Hello, fork.
$
啊!现在我们实际上可以看到两个进程都运行了printf!可能(但不确定),PID 25496是父进程,另一个当然是子进程。之后,两个进程都执行exit(3)API,因此都会终止。
Fork 规则#2 - 返回
让我们来看看我们迄今为止使用的代码:
if (fork() == -1)
FATAL("fork failed!\n");
printf("PID %d: Hello, fork.\n", getpid());
exit(EXIT_SUCCESS);
好的,现在我们从第一条规则中了解到printf将被父进程和子进程并行运行两次。
但是,想一想:这真的有用吗?现实世界的应用程序能从中受益吗?不。我们真正追求的,有用的是分工,也就是说,让子进程执行一些任务,父进程执行一些其他任务,以并行方式。这使得fork变得有吸引力和有用。
例如,在fork之后,让子进程运行某个函数foo的代码,父进程运行某个其他函数bar的代码(当然,这些函数也可以内部调用任意数量的其他函数)。那将是有趣和有用的。
为了安排这一点,我们需要一些方法在fork之后区分父进程和子进程。同样,乍一看,似乎查询它们的 PID(通过getpid(2))是这样做的方法。嗯,你可以,但这是一种粗糙的方法。区分进程的正确方法内置在框架本身中:它是——猜猜——基于fork返回的值。
一般来说,您可能会正确地说,如果一个函数被调用一次,它就会返回一次。嗯,fork是特殊的——当您调用fork(3)时,它会返回两次。怎么做?想一想,fork的工作是创建父进程的副本,子进程;一旦完成,两个进程现在都必须从内核模式返回到用户空间;因此fork只被调用一次,但返回两次;一次在父进程中,一次在子进程上下文中。
然而,关键是内核保证父进程和子进程的返回值不同;以下是关于fork返回值的规则:
-
成功时:
-
子进程中的返回值为零(
0) -
父进程中的返回值是一个正整数,新子进程的 PID
-
失败时,返回
-1并相应地设置errno(请检查!)
所以,我们开始吧:
Fork 规则#2:要确定您是在父进程还是子进程中运行,请使用 fork 返回值:在子进程中始终为 0,在父进程中为子进程的 PID。
另一个细节:暂时看一下fork的签名:
pid_t fork(void);
返回值的数据类型是pid_t,肯定是一个typedef。它是什么?让我们找出来:
$ echo | gcc -E -xc -include 'unistd.h' - | grep "typedef.*pid_t"
typedef int __pid_t;
typedef __pid_t pid_t;
$
我们找到了:它只是一个整数。但这不是重点。这里的重点是,在编写代码时,不要假设它是整数;只需根据手册指定的数据类型声明数据类型;在fork的情况下,为pid_t。这样,即使在将来库开发人员将pid_t更改为,比如,long,我们的代码也只需要重新编译。我们未来证明了我们的代码,使其具有可移植性。
现在我们了解了三个 fork 规则,让我们编写一个小巧但更好的基于 fork 的应用程序来演示相同的内容。在我们的演示程序中,我们将编写两个简单的函数foo和bar;它们的代码是相同的,它们将发出打印并使进程休眠传递给它们的秒数作为参数。睡眠是为了模拟真实程序的工作(当然,我们可以做得更好,但现在我们只是保持简单)。
main函数如下(通常情况下,在 GitHub 存储库ch10/fork4.c上找到完整的源代码):
int main(int argc, char **argv)
{
pid_t ret;
if (argc != 3) {
fprintf(stderr,
"Usage: %s {child-alive-sec} {parent-alive-sec}\n",
argv[0]);
exit(EXIT_FAILURE);
}
/* We leave the validation of the two parameters as a small
* exercise to the reader :-)
*/
switch((ret = fork())) {
case -1 : FATAL("fork failed, aborting!\n");
case 0 : /* Child */
printf("Child process, PID %d:\n"
" return %d from fork()\n"
, getpid(), ret);
foo(atoi(argv[1]));
printf("Child process (%d) done, exiting ...\n",
getpid());
exit(EXIT_SUCCESS);
default : /* Parent */
printf("Parent process, PID %d:\n"
" return %d from fork()\n"
, getpid(), ret);
bar(atoi(argv[2]));
}
printf("Parent (%d) will exit now...\n", getpid());
exit(EXIT_SUCCESS);
}
首先,有几点需要注意:
-
返回变量已声明为
pid_t。 -
规则#1-父进程和子进程中的执行都在 fork 后的指令继续进行。在这里,跟在 fork 后的指令不是
switch(通常被误解为),而是变量ret的初始化!想一想:这将保证ret被初始化两次:一次在父进程中,一次在子进程中,但值不同。 -
规则#2-要确定您是在父进程还是子进程中运行,请使用 fork 返回值:在子进程中始终为
0,在父进程中为子进程的 PID。啊,因此我们看到两条规则的效果都是确保ret得到正确初始化,因此我们可以正确地进行切换 -
有点不相关的事情-需要输入验证。看看我们传递给
fork4程序的参数:
$ ./fork4 -1 -2
Parent process, PID 6797 :: calling bar()...
fork4.c:bar :: will take a nap for 4294967294s ...
Child process, PID 6798 :: calling foo()...
fork4.c:foo :: will take a nap for 4294967295s ...
[...]
我们还需要说什么(看输出)?这是一个缺陷(一个错误)。如源代码注释中所述,我们将两个参数的验证留给读者作为一个小练习。
-
我们更喜欢使用
switch-case语法而不是if条件;在作者看来,这使得代码更易读,因此更易维护。 -
正如我们在规则 2 中学到的,fork 在子进程中返回 0,在父进程中返回子进程的 PID;我们在
switch-case中使用这个知识,因此在代码中有效地、非常易读地区分子进程和父进程。 -
当子进程 ID 完成时,我们不让它调用
break;相反,我们让它退出。原因显而易见:清晰。让子进程在其业务逻辑(foo())中做它需要做的事情,然后简单地让它离开。不麻烦;清晰的代码。(如果我们使用break,我们将需要在switch语句之后再使用另一个if条件;这将很难理解,且难看。) -
父进程通过
switch-case,只是发出打印并退出。
因为函数foo和bar是相同的,所以我们只在这里展示foo的代码:
static void foo(unsigned int nsec)
{
printf(" %s:%s :: will take a nap for %us ...\n",
__FILE__, __FUNCTION__, nsec);
sleep(nsec);
}
好的,让我们运行它:
$ ./fork4
Usage: ./fork4 {child-alive-sec} {parent-alive-sec}
$ ./fork4 3 7
Parent process, PID 8228:
return 8229 from fork()
fork4.c:bar :: will take a nap for 7s ...
Child process, PID 8229:
return 0 from fork()
fork4.c:foo :: will take a nap for 3s ...
Child process (8229) done, exiting ...
Parent (8228) will exit now...
$
正如您所看到的,我们选择让子进程保持活动状态三秒,父进程分别保持活动状态七秒。研究输出:fork 的返回值如预期的那样。
现在让我们再次在后台运行它(此外,我们给子进程和父进程分别更多的睡眠时间,10 秒和 20 秒)。回到 shell 上,我们将使用ps(1)来查看父进程和子进程:
$ ./fork4 10 20 &
[1] 308
Parent process, PID 308:
return 312 from fork()
fork4.c:bar :: will take a nap for 20s ...
Child process, PID 312:
return 0 from fork()
fork4.c:foo :: will take a nap for 10s ...
$ ps
PID TTY TIME CMD
308 pts/0 00:00:00 fork4
312 pts/0 00:00:00 fork4
314 pts/0 00:00:00 ps
32106 pts/0 00:00:00 bash
$ ps -l
F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
0 S 1000 308 32106 0 80 0 - 1111 hrtime pts/0 00:00:00 fork4
1 S 1000 312 308 0 80 0 - 1111 hrtime pts/0 00:00:00 fork4
0 R 1000 319 32106 0 80 0 - 8370 - pts/0 00:00:00 ps
0 S 1000 32106 32104 0 80 0 - 6003 wait pts/0 00:00:00 bash
$
$ Child process (312) done, exiting ... *<< after 10s >>*
Parent (308) will exit now... *<< after 20s >>*
<Enter>
[1]+ Done ./fork4 10 20
$
ps -l(l:长列表)显示了每个进程的更多细节。(例如,我们可以看到 PID 和 PPID。)
在前面的输出中,您是否注意到fork4父进程的 PPID(父进程 ID)恰好是值32106,PID 是308。这不奇怪吗?通常您期望 PPID 比 PID 小。这通常是正确的,但并非总是如此!事实是内核从最早可用的值开始回收 PID。
模拟子进程和父进程中的工作的实验。
让我们这样做:我们创建fork4.c程序的副本,将其命名为ch10/fork4_prnum.c。然后,我们稍微修改代码:我们消除了foo和bar函数,而不是只是睡觉,我们让进程通过调用一个简单的宏DELAY_LOOP来模拟一些真正的工作。(代码在头文件common.h中。)这个宏根据输入参数打印给定字符给定次数,我们将这些参数作为输入参数传递给fork4_prnum。这是一个示例运行:
$ ./fork4_prnum
Usage: ./fork4_prnum {child-numbytes-to-write} {parent-numbytes-to-write}
$ ./fork4_prnum 20 100
Parent process, PID 24243:
return 24244 from fork()
pChild process, PID 24244:
return 0 from fork()
ccpcpcpcpcpcpcpcpcpcpcpcpcpcpcpcpcpcpcpChild process (24244) done, exiting ...
ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppParent (24243) will exit now...
$
DELAY_LOOP宏被编码为打印字符p(代表父)和c(代表子);它打印的次数作为参数传递。你可以很清楚地看到调度程序在父进程和子进程之间进行上下文切换!(交错的p和c表明它们各自何时拥有 CPU)。
要严谨一点,我们应该确保两个进程都在同一个 CPU 上运行;这可以通过 Linux 上的taskset(1)实用程序轻松实现。我们运行taskset指定一个 CPU 掩码为0,意味着作业只能在 CPU 0上运行。(再次留给读者一个简单的查找练习:查看taskset(1)的手册页,学习如何使用它:
$ taskset -c 0 ./fork4_prnum 20 100
Parent process, PID 24555:
return 24556 from fork()
pChild process, PID 24556:
return 0 from fork()
ccppccpcppcpcpccpcpcppcpccpcppcpccppccppChild process (24556) done, exiting ...
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppParent (24555) will exit now...
$
我们建议您实际在系统上尝试这些程序,以了解它们的工作方式。
Fork 规则#3
Fork 规则#3:成功 fork 后,父进程和子进程都并行执行代码。
乍一看,这个规则看起来与第一个规则几乎一样。但不,这里强调的是并行性。父进程和子进程的执行路径与彼此并行运行。
你可能会想知道在单处理器系统上,这是怎么可能的?是的,没错:现代处理器的一个基本属性是在任何给定时间只能运行一条机器指令。因此,如果我们在一个单处理器的机器上,这意味着进程将在 CPU 上进行时间切片(或时间共享)。因此,这是伪并行;然而,由于现代 CPU 的速度,人类用户会感知执行是并行的。在多核(SMP)系统上,它们可以真正地并行运行。因此,单处理器的细节只是一个细节。关键点是我们应该将父进程和子进程都视为并行执行代码。
因此,在上一个代码示例中,这个规则告诉我们父进程和子进程的整个代码路径将并行运行;可视化这种并行性对于新手来说确实是 fork 的初始困难!为了帮助准确理解,看下面的图表(尽管我们只显示了 switch-case 的代码以简洁为目的):父进程的代码路径用一种颜色(红色)突出显示,子进程的代码路径用另一种颜色(蓝色)突出显示:

这是关键点:蓝色的代码和红色的代码,子进程和父进程并行运行!

在第二个图表中,蓝色和红色的时间轴箭头再次用来描述这种并行性。
原子执行?
在看到前面的代码流程图时,你可能会误以为一旦进程开始执行其代码,它就会一直不受干扰地执行直到完成。这并不一定会发生;实际上,进程在运行时经常会被上下文切换出 CPU,然后再切换回来。
这带我们来到一个重要的观点:原子执行。如果一段代码总是在没有中断的情况下运行完成,那么这段代码被认为是原子的。特别是在用户空间,原子性是不被保证的:通常,进程(或线程)的执行会被中断或抢占(中断/抢占的来源包括硬件中断、故障或异常,以及调度程序上下文切换)。在内核中保持代码段的原子性是可以安排的。
Fork 规则#4 - 数据
当父进程分叉时,我们知道子进程被创建;它是父进程的副本。这将包括 VAS,因此也包括数据和堆栈段。记住这个事实,看看下面的代码片段(ch10/fork5.c):
static int g=7;
[...]
int main(int argc, char **argv)
[...]
int loc=8;
switch((ret = fork())) {
case -1 : FATAL("fork failed, aborting!\n");
case 0 : /* Child */
printf("Child process, PID %d:\n", getpid());
loc ++;
g --;
printf( " loc=%d g=%d\n", loc, g);
printf("Child (%d) done, exiting ...\n", getpid());
exit(EXIT_SUCCESS);
default : /* Parent */
#if 1
sleep(2); /* let the child run first */
#endif
printf("Parent process, PID %d:\n", getpid());
loc --;
g ++;
printf( " loc=%d g=%d\n", loc, g);
}
printf("Parent (%d) will exit now...\n", getpid());
exit(EXIT_SUCCESS);
前面的程序(ch10/fork5)有一个初始化的全局变量g和一个初始化的局部变量loc。父进程在分叉后睡了两秒,因此更多或更少地保证了子进程先运行(这种同步在生产质量代码中是不正确的;我们将在本章后面详细讨论这一点)。子进程和父进程都在全局和局部变量上工作;这里的关键问题是这样的:数据会被破坏吗?
让我们运行一下看看:
$ ./fork5
Child process, PID 17271:
loc=9 g=6
Child (17271) done, exiting ...
Parent process, PID 17270: *<< after 2 sec >>*
loc=7 g=8
Parent (17270) will exit now...
$
嗯,数据变量没有被破坏。再次强调这里的关键点是:由于子进程有父进程变量的副本,一切都进行得很顺利。它们彼此独立地改变;它们不会互相干扰。所以,请考虑这一点:
分叉规则#4:数据在分叉时被复制,而不是共享。
分叉规则#5-赛车
注意前面代码(ch10/fork5.c)中sleep(2);语句周围的#if 1和#endif?这当然意味着代码将被编译并运行。
如果我们将#if 1改为#if 0?很明显,sleep(2);语句被有效地编译掉了。让我们这样做:重新构建和重新运行fork5程序。现在会发生什么?
想想这个:分叉规则#4 告诉了我们这个故事。在分叉后,我们仍然有子进程和父进程在数据变量的分开副本上工作;因此,我们之前看到的值不会改变。
然而,这一次没有sleep来粗略地同步父进程和子进程;因此,问题出现了,printf对于子进程还是父进程的代码(显示变量值)会先运行?换句话说,我们真正要问的问题是:在没有任何同步原语的情况下,在fork(2)之后,哪个进程会先获得处理器:父进程还是子进程?简短的答案是下一个规则:
分叉规则#5:分叉后,父进程和子进程之间的执行顺序是不确定的。
不确定?嗯,这是一种花哨的说法,意思是我们真的不知道或者它是不可预测的。所以问题就是这样:系统开发人员不应该试图预测执行顺序。现在运行修改后的fork5(没有 sleep(2)语句):
$ ./fork5
Parent process, PID 18620:
loc=7 g=8
Parent (18620) will exit now...
Child process, PID 18621:
loc=9 g=6
Child (18621) done, exiting ...
$
啊,父进程先运行。这并不意味着什么!父进程可能在你尝试了下一次 50,000 次后仍然先运行,但在第 50,001 次试运行时,子进程可能会先运行。别管它:这是不可预测的。
这引出了另一个关键点(在软件中常见):我们这里有一个叫做竞争条件的东西。竞争就是字面上的意思:我们无法确定谁会是赢家。在前面的程序中,我们真的不在乎父进程还是子进程赢得了比赛(先运行):这被称为良性竞争条件。但在软件设计中经常我们确实在乎;在这种情况下,我们需要一种方法来保证赢家。换句话说,打败竞争。这就是所谓的同步。(正如前面提到的,我们将在本章后面详细讨论这一点。)
进程和打开的文件
为了清楚地理解分叉对打开文件的影响,我们需要稍微偏离一下,并简要了解一些背景信息。
实际上,对于那些在 Unix 范式中对文件进行 I/O 非常新手的读者,最好先阅读附录 A,文件 I/O 基础,然后再着手阅读本节。
Unix/Linux 进程在启动时,默认会分配三个打开文件;我们在本书的前面已经讨论过这些基本要点。为了方便起见,这三个打开文件被称为进程的stdin、stdout和stderr;它们自动默认为键盘、显示器和再次显示器,分别用于stdin、stdout和stderr。不仅如此,真实的应用程序在执行任务时肯定会打开其他文件。回想一下分层系统架构;如果 Linux 应用程序使用fopen(3)库 API 打开文件,最终将归结为open(2)系统调用,该调用返回一个称为文件描述符的打开文件句柄。(想一想:考虑一个在 Linux 上运行的 Java 应用程序打开文件:最终,通过 JVM,这次工作将通过相同的open(2)系统调用完成!)
这里的重点是:内核在一个数据结构中存储每个进程的打开文件(在经典的 Unix 术语中,它被称为打开文件描述符表(OFDT)。我们在前面的部分中看到,子进程继承了父进程的特性,其中包括打开的文件。为了便于讨论,考虑以下伪代码片段:
main
...
foo
fd = open("myfile", O_RDWR);
...
fork()
// Child code
*... work_on_file(fd) ...*
// Parent code
*... work_on_file(fd) ...*
...
在这里,文件myfile现在对两个进程都可用,并且可以通过文件描述符fd进行操作!但是要注意:很明显,父进程和子进程同时对同一个文件进行操作肯定会损坏文件;或者如果不是文件内容,至少会损坏应用程序。为了理解这一点,考虑函数work_on_file(伪代码):
work_on_file(int fd)
{ /* perform I/O */
lseek(fd, 0, SEEK_SET);
read(fd, buf, n);
lseek(...);
write(fd, buf2, x);
...
}
Fork 规则#6 - 打开文件
你可以看到,如果没有任何同步,将会造成混乱!因此下一个 fork 规则:
Fork 规则#6:打开文件(松散地)在 fork 中共享。**
所有这些的要点是:系统程序员必须明白,如果父进程打开了一个文件(或文件),在没有同步的情况下进行文件操作(记住 fork 规则#3!)很可能会导致错误。一个关键原因是:尽管进程是不同的,但它们操作的对象,即打开的文件,更确切地说是它的 inode,是一个独立的对象,因此是共享的。事实上,文件的seek position是 inode 的一个属性;在没有同步的情况下盲目地重新定位父进程和子进程的寻位指针几乎肯定会导致问题。
有两种选择可以使事情顺利运行:
-
让其中一个进程关闭文件
-
同步对打开文件的访问
第一个方法保持简单,但在现实应用中的用途有限;它们通常要求文件保持打开。因此,第二种选择:如何确切地同步对打开文件的访问?
再次强调,本书没有涵盖这些细节,但是,非常简单地说,你可以这样在进程之间同步文件 I/O:
-
通过 SysV IPC 或 POSIX 信号量
-
通过文件锁定
第一个方法可以工作,但很粗糙。这不被认为是正确的方法。第二种解决方案,使用文件锁定,绝对是首选。(文件锁定在这里没有详细介绍,请参考进一步阅读部分,链接到 GitHub 存储库上的一篇优秀教程。)
还要意识到,当父进程或子进程关闭打开文件时,它对打开文件的访问就关闭了;文件在另一个进程中仍然是打开的。这就是所谓的“松散共享”的含义。
为了快速演示这个问题,我们编写一个简单的程序ch10/fork_r6_of.c(这里,of代表打开文件)。我们留给读者去阅读源代码;接下来是解释和示例输出。
首先,我们让进程打开一个名为 tst 的常规文件;然后,我们让子进程执行这个操作:定位到偏移量 10,并写入numlines(等于 100)行的c。与此同时,我们让父进程执行这个操作:定位到偏移量 10+(80*100),并写入numlines行的 p。因此,当我们完成并检查文件时,我们期望有 100 行c和 100 行p。但是,嘿,实际上并不是这样发生的。以下是实际运行的情况:
$ ./fork_r6_of
Parent process, PID 5696:
in fork_r6_of.c:work_on_file now...
context: parent process
Child process, PID 5697:
in fork_r6_of.c:work_on_file now...
context: child process
Parent (5696) will exit now...
Child (5697) done, exiting ...
$
这是运行后测试文件的内容:
$ vi tst
^@^@^@^@^@^@^@^@^@^@ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
[...]
:q
$
p和c交错!是的,确实,因为进程在并行运行时没有任何形式的同步。(通过检查文件内容,我们可以清楚地看到内核 CPU 调度程序在父进程和子进程之间进行了上下文切换)。通过不使用同步,我们设置了一个竞争。那么我们如何解决这个问题呢?前面提到过:文件锁定实际上是答案(注意:不要尝试使用我们使用的父进程中的愚蠢的sleep(2)进行同步;那只是为了演示;此外,我们将很快介绍正确的方法来同步子进程和父进程。)
打开文件和安全性
关于安全性的一个关键点,适用于 exec 和 fork 的情况。
当执行exec操作时,前任进程的 VAS 实质上被继任进程的 VAS 覆盖。但是,请意识到,前任进程的打开文件(在先前提到的 OS 中的每个进程结构中称为 OFDT)保持不变,并且实际上被继任进程继承。这可能构成严重的安全威胁。想一想:如果前任正在使用的安全敏感文件没有关闭并执行了exec,那么继任者现在可以通过其文件描述符访问它,无论它是否利用了这种知识。
对于 fork,同样的论点也成立;如果父进程打开了一个安全敏感的文件,然后 fork,子进程也可以访问该文件(fork 规则#6)。
为了对抗这个问题,从 Linux 2.6.23 内核开始,open(2)系统调用包括一个新标志:O_CLOEXEC。当在open(2)中指定了这个标志时,相应的文件将在该进程执行的任何未来exec操作时关闭。(在早期内核中,开发人员必须通过fcntl(2)执行显式的F_SETFD来设置FD_CLOEXEC位)。
在使用 fork 时,程序员必须包含逻辑,以在 fork 之前关闭父进程中的任何安全敏感文件。
Malloc 和 fork
程序员可能会遇到或犯的一个常见错误是:考虑在进程中成功分配内存,比如,p = malloc(2048)。假设变量p是全局的。一段时间后,进程 fork。开发人员现在希望父进程向子进程传递一些信息;所以,她说,让我们只是写入共享缓冲区p,工作就完成了。不,这不起作用!让我们详细说明一下:malloc 的缓冲区对两个进程都是可见的,但不是以他们认为的方式。错误的假设是 malloc 的缓冲区在父进程和子进程之间是共享的;它不是共享的,它被复制到子进程的 VAS。请回忆 fork 规则#4:数据不共享;在 fork 中被复制。
我们必须测试这种情况;看一下以下代码片段(源文件:ch10/fork_malloc_test.c):
为了便于阅读,这里只显示了代码的相关部分;要查看并运行它,整个源代码在这里可用:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
const int memsz=2048;
static char *gptr;
[...]
main(int argc, char **argv)
{
gptr = malloc(memsz);
[...]
printf("Init: malloc gptr=%p\n", gptr);
[...]
switch ((ret = fork())) {
case -1: [...]
case 0: /* Child */
printf("\nChild process, PID %d:\n", getpid());
memset(gptr, 'c', memsz);
disp_few(gptr);
[...]
printf("Child (%d) done, exiting ...\n", getpid());
exit(EXIT_SUCCESS);
default: /* Parent */
#if 1
sleep(2); /* let the child run first */
#endif
printf("\nParent process, PID %d:\n", getpid());
memset(gptr, 'p', memsz);
disp_few(gptr);
[...]
}
free(gptr);
[...]
disp_few函数用于显示内存缓冲区的前几个(16)字节很简单:
static inline void disp_few(char *p)
{
int i;
printf(" malloc gptr=%p\n ", p);
for (i=0; i<16; i++)
printf("%c", *(p+i));
printf("\n");
}
我们构建并运行它:
$ ./fork_malloc_test
Init: malloc gptr=0x1802260
Child process, PID 13782:
malloc gptr=0x1802260
cccccccccccccccc
Child (13782) done, exiting ...
Parent process, PID 13781:
malloc gptr=0x1802260
pppppppppppppppp
Parent (13781) will exit now...
$
立即要注意的第一件事是:父进程和子进程中指向内存缓冲区(0x1802260)的指针是相同的,这导致人们得出结论,它指向的是同一个内存缓冲区。嗯,事实并非如此;这是一个容易犯的错误。查看父进程和子进程中分配的缓冲区的内容;父进程中是p,子进程中是c;如果它们真的是同一个缓冲区,内容应该是相同的。那么,到底发生了什么?
正如现在已经提到了好几次,数据在 fork 时被复制,而不是共享(*我们的 fork 规则#4)。好吧,那么为什么地址是相同的呢?有两个原因:
-
地址是一个虚拟地址(不是物理地址,我们应该从第二章的讨论中知道,这是虚拟内存)
-
实际上,这是相同的虚拟地址;现代操作系统如 Linux 在 fork 时并不会立即复制数据和堆栈段;它们使用一种优化的语义称为写时复制(COW)。
COW 的要点
这需要一点解释。到目前为止,为了保持讨论在概念上的简单,我们说在 fork 时,内核会将所有父进程的 VAS 段(以及所有其他继承的进程属性)复制到新的子进程中。这是夸大其词的;事实上,试图这样做会使fork(2)在实践中不可行,因为这将需要太多的 RAM 和太多的时间。(事实上,即使有几个优化,fork 仍然被认为是重量级的。)
让我们岔开一下:在 fork 时的优化之一是,内核不会将文本(代码)段复制到子进程中;它只是与子进程共享父进程的文本段(虚拟)页面。这很有效,因为文本无论如何只能读取和执行(r-x);因此,它永远不会改变,为什么要复制呢?
但是数据和堆栈段呢?它们的页面毕竟是读写(rw-),所以操作系统怎么能与子进程共享它们呢?啊,这就是 COW 语义派上用场的地方。要理解 COW,考虑一个由操作系统标记为 COW 的单个虚拟页面。这基本上意味着:只要两个进程(父进程和子进程)将页面视为只读,它们可以共享它;不需要复制。但是一旦它们中的一个修改了页面(甚至是一个字节),操作系统就会介入并创建页面的副本,然后将其交给执行写入操作的进程。
因此,如果我们有一个全局变量g=5并且fork(2),包含g的页面由操作系统标记为 COW;父进程和子进程共享它,直到其中一个写入g。在那时,操作系统会创建包含(更新的)变量的页面的副本,并将其交给写入者。因此,COW 的粒度是一个页面。
事实上,Linux 积极地执行 COW 以最大程度地优化。不仅是数据和堆栈段,我们之前讨论的大多数其他可继承的进程属性实际上都没有复制到子进程中,它们是 COW 共享的,有效地使 Linux 的 fork 非常高效。
通过注意到相同的效果,COW 优化也应用在数据变量(全局和局部)上;只需用任何参数运行我们的测试程序,它就会在两个变量上运行一个小的测试用例:一个全局变量和一个局部变量。
$ ./fork_malloc_test anyparameter
Init: malloc gptr=0xabb260
Init: loc=8, g=5
Child process, PID 17285:
malloc gptr=0xabb260
cccccccccccccccc
loc=9, g=4
&loc=0x7ffc8f324014, &g=0x602084
Child (17285) done, exiting ...
Parent process, PID 17284:
malloc gptr=0xabb260
pppppppppppppppp
loc=7, g=6
&loc=0x7ffc8f324014, &g=0x602084
Parent (17284) will exit now...
$
注意父进程和子进程中全局变量g和局部变量loc的地址是相同的。但是为什么呢?COW 在它们被写入时已经执行了。是的,但是要想一想:这都是虚拟寻址;在底层,物理地址实际上是不同的。
有时候你会觉得现代操作系统似乎费尽心思来困惑和迷惑可怜的系统程序员!我们之前提到的两个重要观点似乎相互矛盾:
-
Fork 规则#4:数据在 fork 时被复制,而不是共享
-
数据/堆栈(以及许多其他内容)实际上并没有在 fork 时复制,而是 COW 共享
我们如何解决这种情况?实际上很容易:第一个(我们的 fork 规则#4)是在使用 fork 时正确的思考方式;第二个陈述是在操作系统层面下真正发生的事情。这只是关于优化的问题。
这里有一个建议:当扮演应用程序开发人员的角色时,不要过于关注底层操作系统的 COW 优化细节;更重要的是理解意图而不是优化。因此,就 Linux 应用程序开发人员使用fork(2)而言,仍然保持的关键概念点是 fork 规则#4:数据在 fork 时被复制,而不是共享。
等待和我们的 simpsh 项目
让我们设定一个有趣的学习练习:一个小项目。我们想要使用 C 在 Linux 操作系统上实现一个非常简单的 shell。让我们称它为我们的 simpsh——simple shell——项目。
注意:simpsh 是一个非常小的、最小功能的 shell。它只能处理单词命令。它不支持重定向、管道、shell 内置等功能。它的目的是作为一个学习练习。
目前的规范是:显示一个提示符(比如>>),在提示符下接受用户命令,并执行它。这是停止条件:如果用户输入quit,则终止(类似于在实际 shell 进程上输入logout、exit或Ctrl + D)。
看起来非常简单:在我们的 C 程序中,您进入一个循环,显示所需的提示,接受用户输入(让我们使用fgets(3)来做到这一点)到一个cmd变量中,然后使用exec familyAPI 之一(一个简单的execl(3)听起来很有前途)来执行它。
好吧,是的,除了,你怎么能忘记,前任进程在 exec 操作成功后实际上已经丢失了!我们的 shell 在执行任何东西后都会丢失(就像我们之前的实验 1:在 CLI 上和实验 2 一样)。
例如,如果我们尝试用我们的 shell simpsh 执行ps(1),它会像这样:

Unix 的 fork-exec 语义
所以,这样是行不通的。实际上,我们需要的是让我们的简单 shell simpsh 在 exec 操作之后保持存活和正常运行,但我们如何实现呢?
fork 就是答案!我们要做的是:在用户提供输入(命令)之后,我们的 shell 进行 fork。现在我们有两个相同的 shell 存活:原始父进程(假设它的 PID 为 x)和全新的子 shell(PID 为 y)。子 shell 被用作牺牲品:我们让它执行用户命令。所以,是的,子进程是不可能返回的前任进程;但没关系,因为我们有父 shell 进程存活正常!
这种众所周知的技术被称为fork-exec语义。它将一些其他操作系统称为生成的内容组合成了两个离散的操作:进程创建(fork)和进程执行(exec)。再次展示了 Unix 设计的精彩之处。

在上图中,将时间线想象为(水平)x 轴。此外,我们使用蓝色来显示子进程的执行路径。
一旦父 shell 检测到 exec 的子进程已经完成,它会再次显示 shell 提示符。
等待的需要
fork-exec真的很有趣,但等一下:当子进程对用户命令执行exec时,继任者正在运行(在前面的图表中用点划线表示),父进程应该做什么?显然,它应该等待,但要等多久?我们应该让它睡觉吗?不,因为sleep的参数是要睡觉的秒数。我们事先不知道继任者需要多长时间(可能是毫秒,可能是几个月)。正确的做法是:让父进程等待子进程(现在是继任者)死亡。
这正是wait(2)API 的设计目的。当父进程发出wait(2)API 时,它被置于睡眠状态;在它的子进程死亡时,它被唤醒!
执行等待
wait(2)API 是一个典型的阻塞调用的例子:调用进程被置于睡眠状态,直到它等待(或阻塞)的事件发生。当事件发生时,它被唤醒并继续运行。
所以,想一想:一个进程 fork;然后父进程发出wait(2)API,它阻塞的事件是子进程的死亡!当然,子进程继续运行;当子进程死亡时,内核唤醒或解除阻塞父进程;现在它继续执行它的代码。这是wait(2)的签名:
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *wstatus);
现在,我们将忽略wait(2);我们将只传递 NULL(或0)(当然,我们很快会涵盖它)。
在 fork 后打败竞争
回想一下我们在第十章中看到的示例代码ch10/fork5.c。在这个程序中,我们通过在父进程的代码中引入一个sleep(2);语句来人为地、粗糙地等待子进程:
[...]
default: /* Parent */
#if 1
sleep(2); /* let the child run first */
#endif
printf("Parent process, PID %d:\n", getpid());
[...]
这是不够的:如果子进程花费的时间超过两秒来完成它的工作怎么办?如果只花了几毫秒,那么我们就浪费了时间。
这就是我们解决的竞争:谁会先运行,父进程还是子进程?显然,fork 规则#5告诉我们这是不确定的。但是,在现实世界的代码中,我们需要一种方法来保证其中一个确实首先运行——比如说,子进程。有了wait API,我们现在有了一个合适的解决方案!我们将前面的代码片段更改为这样:
[...]
default: /* Parent */
wait(0); /* ensure the child runs first */
printf("Parent process, PID %d:\n", getpid());
[...]
想想这是如何工作的:在fork之后,这是一场竞赛:如果子进程确实首先运行,那么没有任何伤害。然而,在不久的将来,父进程将获得 CPU;这没问题,因为它所做的就是通过调用wait来阻塞子进程。如果父进程在fork后首先运行,同样的事情发生:它通过调用wait来阻塞子进程。我们有效地打败了竞争!通过在父进程在fork后的第一件事就是发出wait,我们有效地保证了子进程首先运行。
将其整合在一起 - 我们的 simpsh 项目
所以,现在我们已经把所有的部分都放在了一起——即fork-exec语义和waitAPI,我们可以看到我们的简单 shell 应该如何设计。
在 C 程序中,进入循环,显示所需的提示,接受用户输入(让我们使用fgets(3)来做这个——为什么?请阅读即将到来的提示),将用户输入到一个cmd变量中,然后 fork。在子代码中(使用fork 规则#2来区分父进程和子进程),使用许多exec familyAPI 之一(这里简单的execlp(3)听起来很有希望)来执行用户提供的命令。同时(回想fork 规则#3),让父进程调用waitAPI;父进程现在睡眠直到子进程死亡。现在再次循环并重复整个过程,直到用户输入'quit'退出。大家都很高兴!

实际上,我们现在有了一个被利用的fork-exec-wait语义!
fgets(3):出于安全原因,不要使用传统教授的 API,如gets(3)或scanf(3)来接收用户输入;它们实现很差,也不提供任何边界检查功能。fgets(3)提供了;因此,使用它,或者getline(3),从安全性的角度来看要好得多。(再次提到,黑客利用这些常用 API 中的漏洞来执行堆栈破坏或其他类型的攻击。)
当然,我们的 simpsh shell 的范围相当有限:它只能处理单词命令(如ps,ls,vi,w等)。阅读代码,思考为什么会这样。
我们开始吧(源代码:ch10/simpsh_v1.c):
为了可读性,这里只显示了代码的相关部分;要查看和运行它,整个源代码在这里可用:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
static void do_simpsh(void)
{
[...]
while (1) {
if (!getcmd(cmd)) {
free(cmd);
FATAL("getcmd() failed\n");
}
/* Stopping condition */
if(!strncmp(cmd, "quit", 4))
break;
[...]
正如您所看到的,我们进入循环,通过我们编写的getcmd函数接受用户的命令(fgets在其中发出),然后检查用户是否输入了quit,在这种情况下我们退出。
真正的工作,fork-exec-wait语义,发生在这里,在循环内:
[...]
/* Wield the powerful fork-exec-wait semantic ! */
switch ((ret = fork())) {
case -1:
free(cmd);
FATAL("fork failed, aborting!\n");
case 0: /* Child */
VPRINT
(" Child process (%7d) exec-ing cmd \"%s\" now..\n",
getpid(), cmd);
if (execlp(cmd, cmd, (char *)0) == -1) {
WARN("child: execlp failed\n");
free(cmd);
exit(EXIT_FAILURE);
}
/* should never reach here */
exit(EXIT_FAILURE); // just to avoid gcc warnings
default: /* Parent */
VPRINT("Parent process (%7d) issuing the wait...\n",
getpid());
/* sync: child runs first, parent waits for child's death */
if (wait(0) < 0)
FATAL("wait failed, aborting..\n");
} // switch
} // while(1)
(关于参数传递的逻辑——显示帮助屏幕,详细开关,实际的fgets,calloc/free等等,并没有明确显示;请参考源文件simpsh_v1.c)。
让我们试一试:
$ ./simpsh_v1 --help
Usage: ./simpsh_v1 [-v]|[--help]
-v : verbose mode
--help : display this help screen.
$ ./simpsh_v1 -v
>> ps
Parent process ( 1637) issuing the wait...
Child process ( 1638) exec-ing cmd "ps" now..
PID TTY TIME CMD
1078 pts/0 00:00:00 bash
1637 pts/0 00:00:00 simpsh_v1
1638 pts/0 00:00:00 ps
>> uname
Parent process ( 1637) issuing the wait...
Child process ( 1639) exec-ing cmd "uname" now..
Linux
>> uname -a
Parent process ( 1637) issuing the wait...
Child process ( 1640) exec-ing cmd "uname -a" now..
!WARNING! simpsh_v1.c:do_simpsh:90: child: execlp failed
perror says: No such file or directory
>> www
Parent process ( 1648) issuing the wait...
Child process ( 1650) exec-ing cmd "www" now..
!WARNING! simpsh_v1.c:do_simpsh:90: child: execlp failed
perror says: No such file or directory
>> quit
Parent process ( 1637) exiting...
$
我们以详细模式运行程序;您可以看到 shell 提示字符串>>以及每个详细打印;它们都以[v]:为前缀。请注意,它适用于单词命令;一旦我们传递一些未知的或超过一个单词的内容(例如www和uname -a),execlp(3)就会失败;我们捕获失败并发出警告消息;程序会继续,直到用户退出。
这里是另一个快速实验:我们可以使用我们的simpsh_v1程序生成另一个 shell(/bin/sh):
$ ./simpsh_v1 -v
>> sh
[v]: Parent process ( 12945) issuing the wait...
[v]: Child process ( 12950) exec-ing cmd "sh" now..
$ ps
PID TTY TIME CMD
576 pts/3 00:00:00 git-credential-
3127 pts/3 00:00:01 bash
12945 pts/3 00:00:00 simpsh_v1
12950 pts/3 00:00:00 sh *<< the newly spawned sh >>*
12954 pts/3 00:00:00 ps
31896 pts/3 00:00:40 gitg
$ exit
exit
>> ps
[v]: Parent process ( 12945) issuing the wait...
[v]: Child process ( 12960) exec-ing cmd "ps" now..
PID TTY TIME CMD
576 pts/3 00:00:00 git-credential-
3127 pts/3 00:00:01 bash
12945 pts/3 00:00:00 simpsh_v1
12960 pts/3 00:00:00 ps
31896 pts/3 00:00:40 gitg
>>
它的工作正如预期的那样(嘿,你甚至可以尝试生成相同的进程simpsh_v1)。所以,我们有了一个非常简单但功能齐全的 shell。
为什么超过一个单词的命令会失败?答案在于我们如何执行后继操作,使用execlp(3)API。回想一下,对于execlp,我们需要传递程序名称(当然会自动搜索路径),以及从argv[0]开始的所有参数。在我们的简单实现中,我们只传递了第一个参数argv[0]之外的任何内容;这就是为什么。
那么,我们如何使其能够处理任意数量的参数的命令?嗯,这实际上涉及一定量的字符串处理工作:我们将需要将参数标记为单独的字符串,初始化一个指向它们的argv指针数组,并通过execv[pe]API 使用该argv。我们将其留作一个稍微具有挑战性的练习给读者!(提示:C 库提供了用于标记字符串的 API;strtok(3),strtok_r(3);查找它们)。
实际上,我们的 simpsh 项目是system(3)库 API 的简单实现。请注意,从安全的角度来看,始终建议使用经过验证和测试的 API,如system(3),而不是自行编写的fork-exec-wait代码。当然,这里我们编写它是为了学习目的。
等待 API - 详细信息
在我们的 simpsh 程序中,我们确实使用了wait(2)API,但并没有深入研究细节:
pid_t wait(int *wstatus);
要理解的是:wait(2)是一个阻塞调用;它会导致调用进程阻塞,直到子进程死亡。
从技术上讲,wait(2)(以及我们稍后将看到的相关 API)实际上是在子进程经历状态改变时阻塞;嗯,状态改变就是子进程的死亡,对吧?是的,但非常重要的是要理解,不仅仅是这样:可能的状态改变如下:
-
子进程终止如下:
-
正常(通过从
main中退出,或调用[_]exit())。 -
异常(被信号杀死)。
-
子进程收到了一个停止它的信号(通常是
SIGSTOP或SIGTSTP)。 -
被停止后,它收到了一个信号,继续(恢复)了它(通常是
SIGCONT;我们将在下一章详细介绍信号)。
然而,通用的wait(2)系统调用会在子进程死亡(终止)时阻塞,而不是之前提到的任何其他与信号相关的状态更改。(可以吗?是的,确实可以,我们将在本章后面介绍waitpid(2)系统调用)。
wait 的参数是一个指向整数wstatus的指针。实际上,它更像是一个返回值,而不是要传递的参数;这是一种相当常见的 C 编程技术:将参数视为返回值。Linux 上的系统调用经常使用它;这种技术通常被称为值-结果或输入-输出参数。想想这个:我们传递变量的地址;API 在内部,有了地址,就可以更新它(poke it)。
关于参数wstatus的下一件事是:这个整数被视为一个位掩码,而不是一个绝对值。这又是一种常见的 C 优化技巧,程序员们使用:我们可以通过将其视为位掩码来将多个信息存储到一个整数中。那么,如何解释这个返回的位掩码呢?出于可移植性的考虑,C 库提供了预定义的宏来帮助我们解释位掩码(通常在<sys/wait.h>中)。这些宏是成对工作的:第一个宏返回一个布尔值;如果它返回 true,查找第二个宏的结果;如果它返回 false,完全忽略第二个宏。
一个离题:一个进程可以以两种方式死亡:正常或异常。正常终止意味着进程是自愿死亡的;它只是从main()中掉下来,或者调用exit(3)或_exit(2)并将退出状态作为参数传递(退出状态的约定:零表示成功,非零表示失败并被视为失败代码)。另一方面,异常终止意味着进程是非自愿死亡的——它被杀死,通常是通过信号。
以下是 wait 宏对及其含义:
| 第一个宏 | 第二个宏 | 含义 |
|---|
| WIFEXITED | WEXITSTATUS | 子进程正常死亡:WIFEXITED为 true;然后,WEXITSTATUS——子进程的退出状态。子进程异常死亡:WIFEXITED为 false
|
WIFSIGNALED |
WTERMSIG |
子进程因信号而死亡:WIFSIGNALED为 true;然后,WTERMSIG是杀死它的信号。 |
|---|---|---|
WCOREDUMP |
如果在死亡时,子进程产生了核心转储,则为 true。 | |
WIFSTOPPED |
WSTOPSIG |
如果子进程被信号停止,则WIFSTOPPED为 true;然后,WSTOPSIG是停止它的信号。 |
WIFCONTINUED |
- | 如果子进程被停止然后后来恢复(继续)通过信号(SIGCONT)则为 true。 |
(在包含WCOREDUMP的行中,缩进意味着您可以知道WCOREDUMP仅在WIFSIGNALED为 true 时才有意义)。
那么wait(2)的实际返回值是什么?很明显,-1表示失败(当然内核会设置errno以反映失败的原因);否则,在成功时,它是死亡进程的 PID,从而解除了父进程的等待。
为了尝试我们刚刚学到的东西,我们复制了simpsh_v1程序并将其命名为ch10/simpsh_v2.c。再次强调,我们这里只展示相关的片段;完整的源代码文件在书的 GitHub 存储库中。
[...]
default: /* Parent */
VPRINT("Parent process (%7d) issuing the wait...\n",
getpid());
/* sync: child runs first, parent waits for child's death */
if ((cpid = wait(&wstat)) < 0) {
free(cmd);
FATAL("wait failed, aborting..\n");
}
if (gVerbose)
interpret_wait(cpid, wstat);
} // switch
} // while(1)
[...]
正如您所看到的,我们现在捕获了wait(2)的返回值(改变状态的子进程的 PID),如果我们在详细模式下运行,我们将调用我们自己的interpret_wait函数;它将提供详细的输出,说明发生了什么状态变化;这就是它:
static void interpret_wait(pid_t child, int wstatus)
{
VPRINT("Child (%7d) status changed:\n", child);
if (WIFEXITED(wstatus))
VPRINT(" normal termination: exit status: %d\n",
WEXITSTATUS(wstatus));
if (WIFSIGNALED(wstatus)) {
VPRINT(" abnormal termination: killer signal: %d",
WTERMSIG(wstatus));
if (WCOREDUMP(wstatus))
VPRINT(" : core dumped\n");
else
VPRINT("\n");
}
if (WIFSTOPPED(wstatus))
VPRINT(" stopped: stop signal: %d\n",
WSTOPSIG(wstatus));
if (WIFCONTINUED(wstatus))
VPRINT(" (was stopped), resumed (SIGCONT)\n");
}
VPRINT宏很简单;如果进程处于详细模式,则会导致printf(3)。我们尝试运行程序(版本 2):
$ ./simpsh_v2 -v
>> ps
Parent process ( 2095) issuing the wait...
Child process ( 2096) exec-ing cmd "ps" now..
PID TTY TIME CMD
1078 pts/0 00:00:00 bash
2095 pts/0 00:00:00 simpsh_v2
2096 pts/0 00:00:00 ps
Child ( 2096) status changed:
normal termination: exit status: 0
>> quit
Parent process ( 2095) exiting...
$
正如你所看到的,我们以详细模式运行它;我们可以看到子进程ps(1)的状态发生了变化:它以正常的方式死亡,退出状态为零,表示成功。
有趣的是:这就是 bash 如何知道刚刚运行的进程成功与否;它将退出状态(通过类似于wait的 API 获取)插入到变量?中(您可以使用$?访问)。
等待的场景
到目前为止,我们已经涵盖了通用的wait(2)API;但是,我们只讨论了关于wait的一个可能的场景;还有其他几种。让我们来看看它们。
等待场景#1
这是一个简单的情况(我们已经遇到过的):一个进程 fork,创建一个子进程。父进程随后发出waitAPI;现在它在其子进程的状态变化上阻塞;回想一下,子进程可能经历的可能状态变化是这些:
-
从运行状态(R)转换为死亡状态;也就是说,子进程终止(正常/异常)
-
从运行/睡眠状态(R|S|D)到停止状态(T)的状态转换;也就是说,它接收到一个信号导致它被停止
-
从停止状态(T)到准备运行状态(R)的状态转换;也就是说,从停止状态到准备运行状态的状态转换
(关于状态转换和表示进程状态的字母在第十七章中有所涵盖,Linux 上的 CPU 调度,关于调度)。无论发生什么,事实是父进程被解除阻塞并继续执行其代码路径;wait(2)API 返回(以及我们接收到死亡或被信号中断的子进程的 PID),以及详细的状态位掩码。
等待场景#2
考虑这种情况:一个进程 fork(创建)两个子进程;让我们称父进程为 P,子进程为 C1 和 C2。回想一下 fork 规则#3-父进程和子进程将继续并行运行。现在,P 调用wait;会发生什么?
这就是答案:进程 P 将保持阻塞,直到其中一个子进程死亡(或停止),但是哪一个?任何一个;任何一个首先改变状态的。那么系统程序员如何知道哪个进程死亡或停止?很简单:返回值是死亡或停止的进程的 PID。
换句话说,我们得出一个推论:一个wait会阻塞一个子进程;要阻塞 n 个子进程需要 n 个wait。
一个有趣的练习是在代码中构建前面的场景;确保父进程确实等待两个子进程(这个练习在 GitHub 存储库中被称为fork2c)。
要让父进程等待所有可能的子进程,将waitAPI 作为 while 循环的条件调用;只要存在可等待的子进程,它将阻塞并返回正值;一旦没有可等待的子进程,wait返回-1;检查这个条件以跳出循环。但请注意,有些情况需要设置非阻塞等待;我们也将涵盖这些情况。
fork 炸弹和创建多个子进程
假设我们想编写代码来创建三个子进程;下面显示的代码会实现吗?
main()
{
[...]
fork();
fork();
fork();
[...]
}
当然不!(试一下就知道了)。
回想 fork 规则#1:父进程和子进程中的执行都在 fork 后的指令处继续。因此,正如你所看到的,第一个 fork 后,父进程和子进程都运行第二个 fork(所以现在我们总共有四个进程),然后所有四个都将运行第三个 fork(给我们总共八个进程),依此类推(混乱!)。
如果在这种不受控制的方式下调用 fork,它最终会创建2³ = 8个子进程!换句话说,这是指数增长;n 个 fork 意味着将创建2^n个子进程。
想象一下这段代码可能造成的损害:
int main(void)
{
while(1)
fork();
}
这被称为 fork 炸弹!-一种拒绝服务(DoS)攻击。
有趣的是,由于现代 Unix(包括 Linux)具有基于 COW 的复制语义,所以产生的内存开销可能并不那么大。当然,它仍然消耗大量的 CPU;此外,while 循环中的一个简单的 calloc 也会导致内存被耗尽。
顺便说一下,精心调整的资源限制(我们在之前的章节中详细研究过)可以帮助减轻 fork 炸弹(以及类似的)DoS 攻击风险。更好的是,通过 cgroups 进行资源带宽控制的仔细调整。这是 fork 炸弹维基百科链接:en.wikipedia.org/wiki/Fork_bomb。
好吧,fork(); fork();不是创建两个子进程的方法。(在 GitHub 存储库上尝试练习Smallbomb。)
如何正确地做到这一点?很简单:考虑父进程和子进程的执行路径,区分它们(fork 规则#2),并让父进程创建第二个子进程。这段代码片段演示了同样的情况:
static void createChild(int sleep_time)
{
pid_t n;
switch (n = fork()) {
case -1:
perror("fork");
exit(1);
case 0: // Child
printf("Child 2 PID %d sleeping for %ds...\n", getpid(),
sleep_time);
sleep(sleep_time);
exit(0);
default: ; // Parent returns..
}
}
int main(void)
{
[...]
switch (n = fork()) { // create first child
case -1:
perror("fork");
exit(1);
case 0: // Child
printf("Child 1 PID %d sleeping for %ds...\n", getpid(),
c1_slptm);
sleep(c1_slptm);
exit(0);
default: // Parent
createChild(c2_slptm); // create second child
/* Wait until all children die (typically) */
while ((cpid = wait(&stat)) != -1) {
printf("Child %d changed state\n", cpid);
}
}
等待场景#3
如果一个进程没有子进程,从来没有子进程(单身汉),并且发出wait(2)API,会发生什么?乍一看,这似乎是一个问题,因为它可能导致死锁;但是,内核比那更聪明。wait的内核代码检查,并在发现调用进程没有子进程(无论是死的还是活的还是停止的),它就会简单地失败这个等待。(FYI,errno被设置为ECHILD,表示进程没有未等待的子进程)。
再次回想我们的一个黄金法则:永远不要假设任何事情;总是检查失败的情况。重要的是,我们的第十九章,故障排除和最佳实践,涵盖了这些要点。
还有一个wait场景;但是,我们需要先了解更多信息。
等待的变体 - API
还有一些额外的系统调用来执行等待子进程的工作;我们接下来会介绍它们。
waitpid(2)
假设我们有一个有三个子进程的进程;要求父进程等待(阻塞)特定子进程的终止。如果我们使用通用的waitAPI,我们已经看到它会在任何一个子进程的状态改变时解除阻塞。这个难题的答案:waitpid(2)系统调用:
pid_t waitpid(pid_t pid, int *wstatus, int options);
第一个参数pid设置为要等待的子进程的 PID。但是,也可能有其他值;如果传递-1,它会通用地等待任何可等待的子进程。(还有其他更深奥的情况;我们建议您参考 man 页面)。换句话说,发出这个等同于通用的wait(&stat);API 调用:
waitpid(-1, &stat, 0);
第二个参数是我们在waitAPI 中详细看到的通常状态整数位掩码。
第三个参数称为options;之前,我们将其设置为零,表示没有特殊行为。它还可以采用哪些其他值?嗯,你可以传递零或以下位或的按位或(它也是一个位掩码):
| Options 参数值 | 含义 |
|---|---|
0 |
默认,与wait(2)相同 |
WNOHANG |
只在有活的子进程时阻塞;如果没有,立即返回 |
WUNTRACED |
当子进程停止(并不一定终止)时也解除阻塞 |
WCONTINUED |
当一个停止的子进程恢复(通过传递SIGCONT信号)时也解除阻塞 |
起初,WNOHANG选项可能听起来很奇怪;除了活的子进程,你怎么能阻塞?好吧,稍微耐心一点,我们很快就会解决这个奇怪的问题。
为了测试waitpid(2),我们再次复制我们的simpsh_v2.c,并将其命名为ch10/simpsh_v3.c;代码中唯一有意义的区别是我们现在使用waitpid(2)而不是通用的waitAPI,并根据需要传递选项;来自ch10/simpsh_v3.c:
[...] default: /* Parent */
VPRINT("Parent process (%7d) issuing the waitpid...\n",
getpid());
/* sync: child runs first, parent waits
* for child's death.
* This time we use waitpid(2), and will therefore also get
* unblocked on a child stopping or resuming!
*/
if ((cpid = waitpid(-1, &wstat,
WUNTRACED|WCONTINUED)) < 0) {
free(cmd);
FATAL("wait failed, aborting..\n");
}
if (gVerbose)
interpret_wait(cpid, wstat);
[...]
现在我们运行它:
$ ./simpsh_v3 -v
>> read
Parent process ( 15040) issuing the waitpid...
Child process ( 15058) exec-ing cmd "read" now..
我们发出read(一个 bash 内置)命令,因为它本身是一个阻塞调用,所以我们知道子进程read会活着并处于睡眠状态。在另一个终端窗口中,我们查看了我们的simpsh_v3进程和我们从中运行的命令(read)的 PID:
$ pgrep simpsh
15040
$ pstree -A -h 15040 -p
simpsh_v3(15040)---read(15058)
$
(有用的pstree(1)实用程序显示了进程树的父子层次结构。查阅它的 man 手册以获取详细信息)。
现在我们发送SIGTSTP(终端停止信号)给read进程;它被停止了:
$ kill -SIGTSTP 15058
被停止是我们正在寻找的状态变化!回想一下,我们现在的等待代码是这样的:
waitpid(-1, &wstat, WUNTRACED|WCONTINUED))
因此,一旦子进程停止,WUNTRACED选项就会生效,在原始终端窗口中我们会看到这个:
Child ( 15058) status changed:
stopped: stop signal: 20
>>
现在我们通过发送信号SIGCONT来继续子进程:
$ kill -SIGCONT 15058
$
由于我们(父进程)的waitpid(2)也使用了WIFCONTINUED选项,在原始终端窗口中,我们看到了这个(尽管似乎需要用户按下Enter键):
Child ( 15058) status changed:
(was stopped), resumed (SIGCONT)
我们对子进程有更多的控制。(年轻的父母,请注意!)
fork-exec-wait Unix 框架确实很强大。
waitid(2)
为了进一步微调和控制,也有waitid(2)系统调用(从 Linux 2.6.9):
int **waitid**(idtype_t idtype, id_t id, siginfo_t *infop, int options);
前两个参数实际上会指定要等待的子进程:
| waitid(2):第一个参数:idtype | 第二个参数:id |
|---|---|
P_PID |
设置为要等待(阻塞)的子进程的 PID |
P_PGID |
等待任何进程组 ID(PGID)与此数字匹配的子进程 |
P_ALL |
等待任何子进程(此参数将被忽略) |
第四个options参数与waitpid(2)的使用方式类似,但不完全相同;还有一些额外的选项可以传递;同样,它是一个位掩码,而不是绝对值:WNOHANG和WCONTINUED选项的含义与waitpid(2)系统调用相同。
此外,以下选项可以进行按位或操作:
-
WEXITED:阻塞在已经终止的子进程上(我们很快会解释为什么这个选项存在) -
WSTOPPED:阻塞在将进入stopped状态的子进程上(类似于WUNTRACED选项) -
WNOWAIT:阻塞在子进程上,但一旦解除阻塞,将其保持在可等待状态,以便稍后可以使用wait*API 再次等待它们。
第三个参数是一个siginfo_t类型的(大型)数据结构;(我们将在第十一章中详细介绍,信号-第一部分)。在waitid(2)返回时,内核将填充这个数据结构。操作系统会设置各种字段,其中包括改变状态的子进程的 PID(si_pid)、si_signo设置为SIGCHLD、si_status、si_code。我们打算在后面的章节中介绍这些(现在,请参考 man 手册)。
也有 BSD 版本的waitAPI:wait3和wait4。然而,这些现在被认为是过时的;请使用waitpid(2)或waitid(2)API。
实际的系统调用
我们已经看到了几个 API,它们执行了让父进程等待子进程改变状态(死亡、停止或在停止后恢复)的工作:
-
wait -
waitpid -
waitid -
wait3 -
wait4
有趣的是,与exec系列 API 的情况类似,Linux 的实现是大多数前面的 API 都是库(glibc)包装器:事实上,在 Linux 操作系统上,所有前面的 API 中,wait4(2)是实际的系统调用 API。
对使用waitAPI 之一的程序执行strace(1)证明了这一点(我们对调用wait的simpsh_v1程序进行了strace):
$ strace -e trace=process -o strc.txt ./simpsh_v1
>> ps
PID TTY TIME CMD
14874 pts/6 00:00:00 bash
27248 pts/6 00:00:00 strace
27250 pts/6 00:00:00 simpsh_v1
27251 pts/6 00:00:00 ps
>> quit
$
这是strace的输出:
execve("./simpsh_v1", ["./simpsh_v1"], 0x7fff79a424e0 /* 56 vars */) = 0
arch_prctl(ARCH_SET_FS, 0x7f47641fa4c0) = 0
clone(child_stack=NULL,
flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD,
child_tidptr=0x7f47641fa790) = 27251
wait4(-1, NULL, 0, NULL) = 27251
[...]
在讨论执行strace时,另一个有趣的问题出现了:如果你strace一个调用fork(2)的应用程序,那么在forkAPI 之后,strace会跟踪子进程的执行路径吗?默认情况下不会,但只需传递-f选项,它就会跟踪!
strace(1)的 man 手册上说:
-f Trace child processes as they are created by currently traced processes as a result of the fork(2), vfork(2) and clone(2) system calls. ...
类似地,系统程序员可能知道强大的 GNU 调试器-GDB。如果使用gdb(1)调试多进程应用程序,如何请求 GDB 在遇到指令流中的 fork 后跟随哪个进程的执行路径?这个设置称为follow-fork-mode:在gdb中;这里,我们展示了将模式设置为child的示例:
(gdb) show follow-fork-mode
Debugger response to a program call of fork or vfork is "parent".
(gdb) set follow-fork-mode child
(gdb)
关于 GDB:使用 GDB 的attach <PID>命令对多进程应用程序进行调试是有用的,可以attach到另一个进程(比如子进程)。GDB 还提供了一个强大的catch命令;在 GDB 中查看help catch以获取更多详细信息。
关于 vfork 的说明
几十年前,BSD Unix 开发人员提出了一个高效的特殊情况系统调用-vfork(2)*。当时的想法是在子进程中执行一些优化,几乎立即在子进程中执行fork和exec(也就是fork-exec)。正如我们所知,使用fork-exec是一个非常常见和有用的语义(shell 和网络服务器大量使用它)。当调用vfork而不是fork时,内核不会进行通常需要的大量复制操作;它会优化事情。
底线是:当时,vfork(2)在 Unix 上很有用;但今天的 Linuxfork(2)已经优化到了极致,使vfork成为了后门。它仍然存在,可能有两个原因:
-
兼容性-帮助将 BSD 应用程序移植到 Linux
-
在一些奇特的特殊 Linux 系统上显然很有用,这些系统在没有 MMU 的处理器上运行(如 uClinux)
在今天的常规 Linux 平台上,不建议使用vfork(2);只需使用fork(2)。
更多 Unix 怪异现象
从 fork 规则#3,我们了解到父进程和子进程并行运行。如果其中一个终止会怎么样?另一个会死吗?当然不会;它们是独立的实体。但是,会有副作用。
孤儿
考虑这种情况:一个进程分叉,父进程和子进程同时在并行运行它们各自的代码路径。假设父进程的 PID 是 100,子进程的 PID 是 102,这意味着子进程的 PPID 当然是 100。
父进程出于任何原因死亡。子进程继续进行而没有任何问题,除了一个副作用:父进程(PID 100)死亡时,子进程的 PPID(100)现在无效!因此,内核介入,将子进程的 PPID 设置为整体母舰-所有用户空间任务的祖先,进程树的根-init,或者在最近的 Linux 上是systemd进程!根据古老的 Unix 惯例,它的 PID 始终是数字1。
术语:失去其直接父进程的子进程现在被称为由systemd(或 init)重新父化,并且其 PPID 因此为1;这个子进程现在是一个孤儿。
有可能整体祖先进程(init 或 systemd)不具有 PID 1,因此孤立进程的 PPID 可能不是 1;例如,在 Linux 容器或自定义命名空间中可能会发生这种情况。
我们注意到子进程的 PPID 值突然改变了;因此,系统程序员必须确保他们不依赖于 PPID 值相同(可以通过getppid(2)系统调用查询)的任何原因!
僵尸
孤立的进程不会造成任何问题;还有另一种可能性,可能会出现一个严重的问题。
考虑这种情况:一个进程分叉,父进程和子进程同时在并行运行它们各自的代码路径。假设父进程的 PID 是 100,子进程的 PID 是 102,这意味着子进程的 PPID 当然是 100。
现在我们深入到更深层次的细节:父进程应该等待其子进程的终止(当然可以通过任何可用的wait*(2)API 来实现);如果它没有怎么办?啊,这真的是一个糟糕的情况。
想象一下这种情况:子进程终止,但父进程没有等待(阻塞)它;因此它继续执行它的代码。然而,内核并不高兴:Unix 规则是父进程必须阻塞在它的子进程上!因为父进程没有阻塞,内核无法完全清理刚刚死去的子进程;它释放整个 VAS,释放所有内存,刷新和关闭所有打开的文件,以及其他数据结构,但它不清除内核进程表中的子进程条目。因此,死去的子进程仍然有一个完全有效的 PID 和一些杂项信息(它的退出状态,退出位掩码等)。内核保留这些细节是因为这是 Unix 的方式:父进程必须等待它的子进程并收割它们,也就是在它们死后获取它们的终止状态信息。父进程如何收割子进程?简单:通过执行等待!
所以,请想一想:子进程已经死了;父进程没有等待它;内核在某种程度上清理了子进程。但从技术上讲,它仍然存在,因为它是半死半活的;这就是我们所说的僵尸进程。事实上,这是 Unix 上的一个进程状态:僵尸(你可以在ps -l的输出中看到这一点;此外,该进程被标记为defunct)。
那么为什么不干脆杀死僵尸?嗯,他们已经死了;我们不能杀死他们。读者可能会问,那又怎样?让他们待着吧。好吧,有两个原因使得僵尸在生产系统上造成了真正的麻烦:
-
他们占用了宝贵的 PID
-
僵尸占用的内核内存量并不可忽视(基本上是浪费)
因此,问题的关键是:几个僵尸可能还好,但是几十个、几百个,甚至更多,肯定不行。你可能会达到一个程度,系统被僵尸堵塞得无法运行其他进程——fork(2)失败,errno设置为EAGAIN(稍后重试),因为没有可用的 PID!这是一个危险的情况。
Linux 内核开发人员有迅速解决的见解:如果你在系统上发现了僵尸,你可以至少暂时通过杀死它们的父进程来摆脱它们!(一旦父进程死了,留着僵尸有什么用呢?问题是,它们仍然存在,以便父进程可以通过wait来收割它们)。请注意,这只是一个临时措施,而不是一个解决方案;解决方案是修复代码(参见下一个规则)。
这是一个关键点;事实上,我们称之为wait场景#4:wait被已经终止的子进程解除阻塞,实际上就是僵尸。换句话说,你不仅应该,而且必须等待所有子进程;否则,会出现僵尸(请注意,僵尸是 Unix/Linux 操作系统上的一个有效进程状态;每个进程,在“死亡”的过程中都会经过僵尸(Z)状态。对于大多数进程来说,这是短暂的;它不应该在这种状态下停留很长时间)。
fork 规则#7
所有这些都很好地引出了我们的下一个 fork 规则。
Fork 规则#7:父进程必须等待(阻塞)每个子进程的终止(死亡),直接或间接。
事实上,就像malloc-free一样,fork-wait是一起的。在现实项目中会有一些情况,我们可能认为不可能强制父进程在fork之后阻塞在wait上;我们将解决这些看似困难的情况如何轻松解决(这就是为什么我们也提到了一种间接方法;提示:这与信号有关,是下一章的主题)。
fork 的规则-总结
为了方便起见,这个表格总结了我们在本章中编码的 fork 规则:
| 规则 | fork 的规则 |
|---|---|
| 1 | 成功 fork 后,父进程和子进程的执行都在 fork 后的指令处继续进行 |
| 2 | 要确定当前是在父进程还是子进程中运行,使用 fork 的返回值:在子进程中始终为0,在父进程中为子进程的 PID |
| 3 | 成功 fork 后,父进程和子进程同时执行代码 |
| 4 | 数据在 fork 时被复制,而不是共享 |
| 5 | 在 fork 之后,父进程和子进程之间的执行顺序是不确定的 |
| 6 | 打开的文件在 fork 时(松散地)被共享 |
| 7 | 父进程必须等待(阻塞)每个子进程的终止(死亡),直接或间接地 |
总结
Unix/Linux 系统编程的核心领域之一是学习如何正确处理重要的fork(2)系统调用,以在系统上创建一个新进程。正确使用fork(2)需要深刻的见解。本章通过提供几个 fork 的关键规则来帮助系统开发人员。通过几个代码示例揭示了学到的概念——规则、处理数据、打开文件、安全问题等。还讨论了如何正确等待子进程的许多细节。还讨论了孤儿进程和僵尸进程的确切含义,以及为什么以及如何避免僵尸进程。
第十一章:信号-第一部分
信号对于 Linux 系统开发人员来说是一个至关重要的机制,需要理解和利用。我们在本书的两章中涵盖了这个相当大的主题,即本章和下一章。
在这一章中,读者将了解信号是什么,为什么它们对系统开发人员很有用,最重要的当然是开发人员如何处理和利用信号机制。
我们将在下一章中继续探讨这个问题。
在这一章中,读者将学习以下内容:
-
信号到底是什么。
-
为什么它们很有用。
-
可用的信号。
-
如何在应用程序中处理信号,这实际上涉及许多事情——阻塞或解除信号、编写安全处理程序、一劳永逸地摆脱讨厌的僵尸进程、处理信号量很高的应用程序等等。
为什么需要信号?
有时,系统程序员需要操作系统提供异步设施——某种方式让你知道某个事件或条件已经发生。信号在 Unix/Linux 操作系统上提供了这个特性。进程可以捕获或订阅信号;当这发生时,操作系统将异步通知进程,并且运行一个函数的代码作为响应:信号处理程序。
举个例子:
-
一个 CPU 密集型进程正在忙于进行科学或数学计算(为了便于理解,我们假设它正在生成素数);回想一下(来自第三章,资源限制)CPU 使用率有一个上限,并且已经设置为特定值。如果超出了呢?进程将被默认杀死。我们能阻止这种情况发生吗?
-
开发人员想要执行一个常见的任务:设置一个定时器,并在 1.5 秒后让它到期。操作系统将如何通知进程定时器已经到期?
-
在一些 Sys V Unix 系统(通常在企业级服务器上运行),如果突然断电会发生什么?一个事件会广播给所有进程(那些对事件表示兴趣或订阅了事件的进程),通知它们相同的情况:它们可以刷新缓冲区并保存数据。
-
一个进程有一个无意的缺陷(一个错误);它进行了无效的内存访问。内存子系统(技术上来说,是 MMU 和操作系统)决定必须将其杀死。它将如何被杀死?
-
Linux 的异步 IO(AIO)框架,以及许多其他类似的场景。
所有这些示例场景都由同一机制服务:信号。
简要介绍信号机制
信号可以定义为传递给目标进程的异步事件。信号可以由另一个进程或操作系统(内核)本身传递给目标进程。
在代码级别,信号只是一个整数值;更准确地说,它是位掩码中的一位。重要的是要理解,尽管信号可能看起来像是中断,但它并不是中断。中断是硬件特性;信号纯粹是软件机制。
好的,让我们尝试一个简单的练习:运行一个进程,将其放在一个无限循环中,然后通过键盘手动发送一个信号给它。在(ch11/sig1.c)中找到代码:
int main(void)
{
unsigned long int i=1;
while(1) {
printf("Looping, iteration #%02ld ...\n", i++);
(void)sleep(1);
}
exit (EXIT_SUCCESS);
}
为什么sleep(1);代码被强制转换为(void)?这是我们告诉编译器(可能是任何静态分析工具)我们不关心它的返回值的方式。事实上,我们应该关心;稍后会有更多内容。
它的工作是很明显的:让我们构建并运行它,在第三次循环迭代后,我们在键盘上按下Ctrl + C组合键。
$ ./sig1
Looping, iteration #01 ...
Looping, iteration #02 ...
Looping, iteration #03 ...
^C
$
是的,如预期的那样,进程终止了。但这到底是如何发生的呢?
这是简要的答案:信号。更详细地说,这是发生的情况(尽管仍然保持简单):当用户按下Ctrl + C键组合(在输出中显示为^C)时,内核的tty层代码处理此输入,将输入键组合处理成信号,并将其传递给 shell 上的前台进程。
但是,等一下。记住,信号只是一个整数值。那么,是哪个整数?哪个信号?Ctrl + C键组合映射到SIGINT信号,整数值为2,因此导致其传递给进程。(下一节开始解释不同的信号;现在,让我们不要太担心它)。
所以,好吧,SIGINT信号,值为2,已传递给我们的sig1进程。但是接下来呢?这里再次是一个关键点:每个信号都与一个在其传递时运行的函数相关联;这个函数称为信号处理程序。如果我们不改变它,将运行默认的信号函数。那么,这带来了一个问题:由于我们没有编写任何默认(或其他)信号处理代码,那么是谁提供了这个默认信号处理程序函数?简短的答案是:操作系统(内核)处理所有情况,即进程接收到应用程序没有安装任何处理程序的信号;换句话说,对于默认情况。
信号处理函数或底层内核代码执行的操作将决定信号到达目标进程时会发生什么。因此,现在我们可以更好地理解:SIGINT信号的默认信号处理程序(实际上是内核代码)执行的操作是终止进程,实际上导致接收进程死亡。
我们将其以以下图表的形式显示:

通过键盘传递信号,默认处理程序导致进程死亡
从这个图表中,我们可以看到以下步骤:
-
进程P启动并运行其代码。
-
用户按下
^C,实际上导致SIGINT信号被发送到进程。 -
由于我们没有设置任何信号处理程序,因此 OS 的默认信号处理操作将被调用。
-
操作系统中的默认信号处理代码导致进程死亡。
FYI,对于默认情况,也就是所有应用程序开发人员没有安装特定信号处理例程的情况(我们将很快学习如何安装我们自己的信号处理程序),处理这些情况的操作系统代码会做什么?根据正在处理的信号,操作系统将执行以下五种可能的操作之一(有关详细信息,请参见以下表格):
-
忽略信号
-
停止进程
-
继续(先前停止的)进程
-
终止进程
-
终止进程并发出核心转储
真正有趣和强大的是:程序员有能力改变-重新定向信号处理到他们自己的函数!实际上,我们可以使用某些 API 来捕获信号。一旦我们这样做,当信号发生时,控制将不会转到默认的信号处理(操作系统)代码,而是转到我们想要的函数。通过这种方式,程序员可以控制并利用强大的信号机制。
当然,这还有更多:细节确实隐藏在其中!继续阅读。
可用信号
Unix/Linux 操作系统总共提供了 64 个信号。它们大致分为两种类型:标准或 Unix 信号和实时信号。我们将发现,虽然它们共享共同的属性,但也有一些重要的区别;在这里,我们将调查 Unix(或标准)信号,稍后再调查后者。
除了键盘键组合(如Ctrl + C)之外,用户空间的通用通信接口是kill(1)实用程序(因此也是kill(2)系统调用)。
除了 kill,还有几个其他 API 可以传递信号;我们将在本章的后面部分详细介绍这一点。
使用kill(1)实用程序的-l或列表选项在平台上列出可用的信号:
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12
47) SIGRTMIN+13 48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14
51) SIGRTMAX-13 52) SIGRTMAX-12 53) SIGRTMAX-11 54) SIGRTMAX-10
55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7 58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2 63) SIGRTMAX-1 64) SIGRTMAX
$
也许kill(1)这个名字是个误称:kill 实用程序只是向给定的进程(或作业)发送一个信号。因此(至少对于您的作者来说),sendsig这个名字可能是更好的选择。
一个常见问题:信号 32 和 33 的编号在哪里?
它们由 Linux Pthreads 实现(称为 NPTL)内部使用,因此不可用于应用程序开发人员。
标准或 Unix 信号
从 kill 的输出中可以看出,平台上支持的所有信号都显示出来;其中前 31 个(在典型的 Linux 系统上)被称为标准或 Unix 信号。与随后的实时信号不同,每个标准/Unix 信号都有一个非常具体的名称和目的。
(不用担心;我们将在下一章讨论实时信号,即 34 到 64 的数字)。
您很快将看到的表格,基本上是从signal(7)的 man 页面中复制的,总结了标准(Unix)信号,列顺序如下:信号的符号名称,整数值,传递给进程时采取的默认操作,以及描述信号的注释。
默认操作列有以下类型:信号处理程序的默认操作是:
-
终止:终止进程。
-
终止和生成核心转储:终止进程并生成核心转储。(核心转储实质上是进程的动态段,即数据和堆栈段,在传递(致命)信号时的快照)。当内核向进程发送致命信号时,会发生这种终止和核心转储操作。这意味着进程已经做了一些非法的事情(有 bug);一个例外是
SIGQUIT信号:当SIGQUIT传递给进程时,我们会得到一个核心转储。 -
忽略:忽略该信号。
-
停止:进程进入停止(冻结/暂停)状态(在
ps -l输出中用T表示)。 -
继续:继续执行先前停止的进程。
参考标准或 Unix 信号表:
| 信号 | 整数值 | 默认操作 | 注释 |
|---|---|---|---|
SIGHUP |
1 |
终止 | 在控制终端检测到挂机或控制进程死亡 |
SIGINT |
2 |
终止 | 从键盘中断:**^**C |
SIGQUIT |
3 |
终止和生成核心转储 | 从键盘退出:**^\** |
SIGILL |
4 |
终止和生成核心转储 | 非法指令 |
SIGABRT |
6 |
终止和生成核心转储 | 来自 abort(3)的中止信号 |
SIGFPE |
8 |
终止和生成核心转储 | 浮点异常 |
SIGKILL |
9 |
终止 | (强制)终止信号 |
SIGSEGV |
11 |
终止和生成核心转储 | 无效的内存引用 |
SIGPIPE |
13 |
终止 | 管道中断:向没有读取者的管道写入;参见 pipe(7) |
SIGALRM |
14 |
终止 | 来自 alarm(2)的定时器信号 |
SIGTERM |
15 |
终止 | 终止信号(软终止) |
SIGUSR1 |
30,10,16 |
终止 | 用户定义的信号 1 |
SIGUSR2 |
31,12,17 |
终止 | 用户定义的信号 2 |
SIGCHLD |
20,17,18 |
忽略 | 子进程停止或终止 |
SIGCONT |
19,18,25 |
继续 | 如果停止则继续 |
SIGSTOP |
17,19,23 |
停止 | 停止进程 |
SIGTSTP |
18,20,24 |
停止 | 在终端上停止输入:^Z |
SIGTTIN |
21,21,26 |
停止 | 后台进程的终端输入 |
SIGTTOU |
22,22,27 |
停止 | 后台进程的终端输出 |
有时,第二列,信号的整数值,有三个数字。嗯,就是这样:这些数字是与架构(即 CPU)相关的;中间列代表 x86 架构的值。
在代码中始终使用信号的符号名称(如SIGSEGV),包括脚本,而不是数字(如11)。您可以看到数字值随 CPU 而变化,这可能导致不可移植的错误代码!
如果系统管理员需要紧急终止进程怎么办?是的,很可能,在交互式 shell 中,时间非常宝贵,多出来的几秒钟可能会有所不同。在这种情况下,键入 kill -9比 kill -SIGKILL或者 kill -KILL更好。(前面的观点是关于编写源代码)。
将信号编号传递给 kill -l会导致它打印信号的符号名称(尽管是简写形式)。例如:
$ kill -l 11
SEGV
$
前面的表格(实际上后面的表格也是如此)显示,除了两个例外,所有信号都有特殊用途。扫描注释列可以发现这一点。例外是SIGUSR1和SIGUSR2,这些是通用信号;它们的使用完全取决于应用程序设计者的想象力。
此外,手册页告诉我们,以下信号(在此表中显示)是较新的,并包括在SUSv2和POSIX.1-2001标准中:
| 信号 | 整数值 | 默认操作 | 注释 |
|---|---|---|---|
SIGBUS |
10,7,10 |
Term&Core | 总线错误(内存访问错误) |
SIGPOLL |
终止 | 可轮询事件(Sys V)。SIGIO 的同义词 | |
SIGPROF |
27,27,29 |
终止 | 分析计时器已过期 |
SIGSYS |
12,31,12 |
Term&Core | 系统调用错误(SVr4);另请参阅 seccomp(2) |
SIGTRAP |
5 |
Term&Core | 跟踪/断点陷阱 |
SIGURG |
16,23,21 |
忽略 | 套接字上的紧急情况(4.2BSD) |
SIGVTALRM |
26,26,28 |
终止 | 虚拟警报时钟(4.2BSD) |
SIGXCPU |
24,24,30 |
Term&Core | CPU 时间限制超出(4.2BSD);参见 prlimit(2) |
SIGXFSZ |
25,25,31 |
Term&Core | 文件大小限制超出(4.2BSD);参见 prlimit(2) |
较新的标准或 Unix 信号
同一手册页还进一步提到了一些剩余的(不太常见的)信号(signal(7))。如果感兴趣,可以看一下。
重要的是要注意,所有提到的信号中,只有两个信号不能被捕获、忽略或阻塞:SIGKILL和SIGSTOP。这是因为操作系统必须保证一种方法来终止和/或停止进程。
处理信号
在本节中,我们将详细讨论应用程序开发人员如何以编程方式处理信号(当然是使用 C 代码)。
回顾图 1。您可以看到操作系统如何执行默认的信号处理,当未捕获的信号传递给进程时运行。这似乎很好,直到我们意识到,默认操作很常见的是简单地杀死(或终止)进程。如果应用程序要求我们做其他事情怎么办?或者,实际上,应用程序崩溃了,而不仅仅是突然死亡(可能留下重要文件和其他元数据处于不一致的状态)。也许我们可以通过执行一些必要的清理,刷新缓冲区,关闭打开的文件,记录状态/调试信息等,通知用户糟糕的情况(也许是一个漂亮的对话框),然后优雅而平静地让进程死去。
捕获或陷阱信号的能力是实现这些目标的关键。如前所述,重新定向控制流,使其不是默认的信号处理内核代码,而是我们自定义的信号处理代码在信号到达时执行。
那么,我们如何实现这一点呢?通过使用 API 来注册对信号的兴趣,从而处理信号。广义上说,有三种可用的 API 来捕获或陷阱信号:
-
sigaction(2)系统调用 -
signal(2)系统调用 -
sigvec(3)库 API
嗯,这三个 API 中,sigvec现在被认为是已弃用的。此外,除非工作真的很简单,您应该放弃signal(2)API,转而使用sigactionAPI。实际上,处理信号的强大方法是通过sigaction(2)系统调用;这是我们将深入讨论的方法。
使用 sigaction 系统调用来捕获信号
sigaction(2)系统调用是捕获或捕捉信号的正确方法;它功能强大,符合 POSIX,并且可以用来优化应用程序的信号处理。
在高层次上,sigaction系统调用用于为给定信号注册信号处理程序。如果信号的处理函数是foo,我们可以使用sigaction将其信号处理程序更改为bar。通常情况下,我们还可以指定更多内容,这对信号处理产生了强大的影响,我们很快就会讨论到所有这些。这是签名:
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
glibc的特性测试宏要求(参见feature_test_macros(7)):sigaction(): _POSIX_C_SOURCE
siginfo_t: _POSIX_C_SOURCE >= 199309L
sigaction(2)的 man 页面告诉我们(通过特性测试宏要求部分;稍后会有更多细节),使用sigaction需要定义_POSIX_C_SOURCE宏;这在现代 Linux 上几乎总是如此。此外,使用siginfo_t数据结构(稍后在本章中解释)需要您拥有POSIX版本199309L或更高版本。(格式为YYYYMM;因此,这是 1993 年 9 月的POSIX标准草案;同样,在任何相当现代的 Linux 平台上都是如此)。
侧边栏 - 特性测试宏
一个快速的离题:特性测试宏是glibc的一个特性;它允许开发人员在源代码中定义这些宏,从而在编译时指定确切的特性集。手册(man)页面总是指定(如有必要)要求存在的特性测试宏,以支持某个 API 或特性。
关于这些特性测试宏,在 Ubuntu(17.10)和 Fedora(27)Linux 发行版上,我们已经测试了本书的源代码,_POSIX_C_SOURCE的值为200809L。该宏在头文件<features.h>中定义,该头文件本身包含在头文件<unistd.h>中。
书中的 GitHub 源代码树中提供了一个简单的测试程序,用于打印一些特性测试宏:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux/tree/master/misc。为什么不在您的 Linux 平台上试一试呢?
glibc文档中有关特性测试宏的更多信息:www.gnu.org/software/libc/manual/html_node/Feature-Test-Macros.html。
sigaction 结构
sigaction(2)系统调用有三个参数,其中第二个和第三个参数的数据类型相同。
第一个参数int signum是要捕获的信号。这立即揭示了一个重要的观点:信号是要逐个捕获的 - 您只能使用单个sigaction调用捕获一个信号。不要试图过于聪明,做一些诸如一起传递信号的位掩码(按位或)之类的事情;那是一个错误。当然,您可以多次调用sigaction或在循环中调用。
第二个和第三个参数的数据类型是指向一个名为sigaction的结构的指针。sigaction结构的定义如下(来自头文件/usr/include/bits/sigaction.h):
/* Structure describing the action to be taken when a signal arrives. */
struct sigaction
{
/* Signal handler. */
#ifdef __USE_POSIX199309
union
{
/* Used if SA_SIGINFO is not set. */
__sighandler_t sa_handler;
/* Used if SA_SIGINFO is set. */
void (*sa_sigaction) (int, siginfo_t *, void *);
}
__sigaction_handler;
# define sa_handler __sigaction_handler.sa_handler
# define sa_sigaction __sigaction_handler.sa_sigaction
#else
__sighandler_t sa_handler;
#endif
/* Additional set of signals to be blocked. */
__sigset_t sa_mask;
/* Special flags. */
int sa_flags;
/* Restore handler. */
void (*sa_restorer) (void);
};
第一个成员,一个函数指针,指的是信号处理程序函数本身。在现代 Linux 发行版上,__USE_POSIX199309宏确实会被定义;因此,可以看到,信号处理程序值是两个元素的联合,这意味着在运行时,将准确使用其中一个。先前的评论清楚地表明:默认情况下,使用sa_handler原型函数;然而,如果传递了标志SA_SIGINFO(在第三个成员sa_flags中),那么将使用sa_sigaction样式的函数。我们将很快用示例代码来说明这一点。
C 库将__sighandler_t指定为:typedef void (*__sighandler_t) (int);
如前所述,它是一个指向函数的指针,将接收一个参数:一个整数值(是的,你猜对了:传递的信号)。
在深入了解数据结构之前,编写并尝试一个简单的 C 程序来处理一对信号,对于先前提到的sigaction结构的大多数成员使用默认值将是有益的。
ch11/sig2.c的main()函数的源代码:
int main(void)
{
unsigned long int i = 1;
struct sigaction act;
/* Init sigaction to defaults via the memset,
* setup 'siggy' as the signal handler function,
* trap just the SIGINT and SIGQUIT signals.
*/
memset(&act, 0, sizeof(act));
act.sa_handler = siggy;
if (sigaction(SIGINT, &act, 0) < 0)
FATAL("sigaction on SIGINT failed");
if (sigaction(SIGQUIT, &act, 0) < 0)
FATAL("sigaction on SIGQUIT failed");
while (1) {
printf("Looping, iteration #%02ld ...\n", i++);
(void)sleep(1);
} [...]
我们故意将sigaction结构的所有成员都设置为零,以初始化它(在任何情况下初始化都是良好的编码实践!)。然后,我们将信号处理程序初始化为我们自己的信号处理函数siggy。
请注意,为了捕获两个信号,我们需要两个sigaction(2)系统调用。第二个参数,指向结构sigaction的指针,由程序员填充,被认为是信号的新设置。第三个参数再次是指向结构sigaction的指针;然而,它是一个值-结果类型:如果非空且已分配,内核将用信号的先前设置填充它。这是一个有用的特性:如果设计要求您执行一些信号处理的保存和恢复。在这里,作为一个简单的情况,我们只是将第三个参数设置为NULL,意味着我们对先前的信号状态不感兴趣。
然后我们进入与sig1.c相同的无限循环...我们简单的信号处理程序函数siggy如下所示:
static void siggy(int signum)
{
const char *str1 = "*** siggy: handled SIGINT ***\n";
const char *str2 = "*** siggy: handled SIGQUIT ***\n";
switch (signum) {
case SIGINT:
if (write(STDOUT_FILENO, str1, strlen(str1)) < 0)
WARN("write str1 failed!");
return;
case SIGQUIT:
if (write(STDOUT_FILENO, str2, strlen(str2)) < 0)
WARN("write str2 failed!");
return;
}
}
信号处理程序接收一个整数值作为参数:导致控制到达此处的信号。因此,我们可以对多个信号进行多路复用:设置一个公共信号处理程序,并执行一个简单的switch-case来处理每个特定的信号。
信号处理函数的返回类型当然是void。问问自己:它会返回到哪里?这是未知的。记住,信号可以异步到达;我们不知道处理程序何时会运行。
让我们试一试:
$ make sig2
gcc -Wall -c ../common.c -o common.o
gcc -Wall -c -o sig2.o sig2.c
gcc -Wall -o sig2 sig2.c common.o
$ ./sig2
Looping, iteration #01 ...
Looping, iteration #02 ...
Looping, iteration #03 ...
^C*** siggy: handled SIGINT ***
Looping, iteration #04 ...
Looping, iteration #05 ...
^\*** siggy: handled SIGQUIT ***
Looping, iteration #06 ...
Looping, iteration #07 ...
^C*** siggy: handled SIGINT ***
Looping, iteration #08 ...
Looping, iteration #09 ...
^\*** siggy: handled SIGQUIT ***
Looping, iteration #10 ...
Looping, iteration #11 ...
^Z
[1]+ Stopped ./sig2
$ kill %1
[1]+ Terminated ./sig2
$
您可以看到,这次,应用程序正在处理SIGINT(通过键盘^C)和SIGQUIT(通过键盘**^\**组合键)信号。
那么我们如何终止应用程序呢?好吧,一种方法是打开另一个终端窗口,并通过kill实用程序杀死应用程序。不过,现在,我们使用另一种方法:我们向进程发送SIGTSTP信号(通过键盘**^Z**组合键)将其置于停止状态;我们返回 shell。现在,我们只需通过kill(1)杀死它。([1]是进程的作业号;您可以使用jobs命令查看会话中的所有当前作业)。
我们以以下形式的图表显示:

图 2:处理信号
显然,正如我们简单的sig2应用程序和图 2所示,一旦捕获到信号(通过sigaction(2)(或signal)系统调用),当它传递给进程时,控制现在重新定向到新的特定于应用程序的信号处理程序函数,而不是默认的操作系统信号处理代码。
在程序sig2中,一切看起来都很好,除了你,细心的读者,可能已经注意到一个谜题:在信号处理程序函数的代码中,为什么不只使用一个简单的printf(3)来发出消息。为什么要使用write(2)系统调用?实际上,这背后有一个非常好的原因。这还有更多内容即将呈现。
尽可能早地在应用程序初始化时捕获所有必需的信号。这是因为信号可以在任何时刻到达;我们越早准备好处理它们,就越好。
屏蔽信号
当一个进程正在运行时,如果它想要屏蔽(或者屏蔽)某些信号怎么办?通过 API 接口确实是可能的;事实上,sigaction(2)结构的第二个成员就是信号掩码,即在信号处理程序函数运行时要屏蔽的信号的掩码。掩码通常意味着信号的按位或运算:
...
/* Additional set of signals to be blocked. */
__sigset_t sa_mask;
...
请注意前面的评论;它暗示着某个信号已经被屏蔽。是的,确实;假设一个进程通过sigaction系统调用捕获了一个信号n。在稍后的某个时间点,该信号 n 被传递给它;当我们的进程处理该信号时——也就是运行其信号处理程序代码时——该信号 n 被屏蔽,不会传递给进程。它被屏蔽多久?直到我们从信号处理程序返回。换句话说,操作系统自动屏蔽当前正在处理的信号。这通常正是我们想要的,并且对我们有利。
使用 sigprocmask API 进行信号屏蔽
如果我们想要在执行过程中屏蔽(或者屏蔽)一些其他信号。例如,在处理关键代码区域时?sigprocmask(2)系统调用就是为此设计的:int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
信号集本质上是所讨论信号的位掩码。set 是要屏蔽的新信号集,而oldset实际上是一个返回值(参数的值-结果类型),或者是信号掩码的先前(或当前)值。how参数决定了行为,并且可以取这些值:
-
SIG_BLOCK:另外,屏蔽(掩码)信号集 set 中指定的信号(以及已经被屏蔽的信号) -
SIG_UNBLOCK:解除(取消屏蔽)信号集 set 中指定的信号 -
SIG_SETMASK:信号集 set 中指定的信号被屏蔽,覆盖先前的值
查询信号掩码
因此,我们了解到可以在sigaction(2)时(通过sa_mask成员)或通过*s*igprocmask(2)系统调用(如前所述)设置进程的信号掩码。但是在任意时间点上如何查询进程信号掩码的状态呢?
好吧,再次通过sigprocmask(2)系统调用。但是,逻辑上,这个 API 设置一个掩码,对吧?这就是诀窍:如果第一个参数设置为NULL,那么第二个参数就会被有效地忽略,而在第三个参数oldset中,当前的信号掩码值就会被填充,因此我们可以查询信号掩码而不改变它。
ch11/query_mask程序演示了这一点,代码是建立在我们之前的例子sig2.c之上。因此,我们不需要展示整个源代码;我们只展示相关的代码,在main()中:
[...]
/* Init sigaction:
* setup 'my_handler' as the signal handler function,
* trap just the SIGINT and SIGQUIT signals.
*/
memset(&act, 0, sizeof(act));
act.sa_handler = my_handler;
/* This is interesting: we fill the signal mask, implying that
* _all_ signals are masked (blocked) while the signal handler
* runs! */
sigfillset(&act.sa_mask);
if (sigaction(SIGINT, &act, 0) < 0)
FATAL("sigaction on SIGINT failed");
if (sigaction(SIGQUIT, &act, 0) < 0)
FATAL("sigaction on SIGQUIT failed");
[...]
正如你所看到的,这一次我们使用了sigfillset(3)(POSIX信号集操作或sigsetops(3)操作符中有用的一个)来用所有 1 填充信号掩码,这意味着,在信号处理程序代码运行时,所有信号都将被屏蔽。
这是信号处理程序代码的相关部分:
static void my_handler(int signum)
{
const char *str1 = "*** my_handler: handled SIGINT ***\n";
const char *str2 = "*** my_handler: handled SIGQUIT ***\n";
show_blocked_signals();
switch (signum) {
[...]
啊!这里,智能在show_blocked_signals函数中;我们在我们的公共代码源文件../common.c中有这个函数。这是函数:
/*
* Signaling: Prints (to stdout) all signal integer values that are
* currently in the Blocked (masked) state.
*/
int show_blocked_signals(void)
{
sigset_t oldset;
int i, none=1;
/* sigprocmask:
* int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
* if 'set' is NULL, the 'how' is ignored, but the
* 'oldset' sigmask value is populated; thus we can query the
* signal mask without altering it.
*/
sigemptyset(&oldset);
if (sigprocmask(SIG_UNBLOCK, 0, &oldset) < 0)
return -1;
printf("\n[SigBlk: ");
for (i=1; i<=64; i++) {
if (sigismember(&oldset, i)) {
none=0;
printf("%d ", i);
}
}
if (none)
printf("-none-]\n");
else
printf("]\n");
fflush(stdout);
return 0;
}
关键在于:sigprocmask(2)与 NULL 第二个参数(要设置的掩码)一起使用;因此,正如前面所述,how参数被忽略,值-结果第三个参数oldset将保存当前进程的信号掩码。
我们可以再次使用sigsetops: sigismember(3)方便方法查询掩码中的每个信号位。现在剩下的就是迭代掩码中的每个位并打印信号编号,如果该位被设置,则忽略它。
这是一个测试运行的输出:
$ make query_mask
gcc -Wall -c ../common.c -o common.o
gcc -Wall -c -o query_mask.o query_mask.c
gcc -Wall -o query_mask query_mask.c common.o
$ ./query_mask
Looping, iteration #01 ...
Looping, iteration #02 ...
Looping, iteration #03 ...
^C
[SigBlk: 1 2 3 4 5 6 7 8 10 11 12 13 14 15 16 17 18 20 21 22 23 24 25 26 27 28 29 30 31 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 ]
*** my_handler: handled SIGINT ***
Looping, iteration #04 ...
Looping, iteration #05 ...
^\
[SigBlk: 1 2 3 4 5 6 7 8 10 11 12 13 14 15 16 17 18 20 21 22 23 24 25 26 27 28 29 30 31 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 ]
*** my_handler: handled SIGQUIT ***
Looping, iteration #06 ...
Looping, iteration #07 ...
^Z
[2]+ Stopped ./query_mask
$ kill %2
[2]+ Terminated ./query_mask
$
注意打印出的被阻塞的信号。嘿,你能发现缺失的信号吗?
SIGKILL(#9)和SIGSTOP(#19)不能被屏蔽;另外,信号 32 和 33 是内部保留并由Pthreads实现使用的。
侧边栏 - 操作系统内的信号处理 - 轮询而不是中断
在这里,我们不打算深入探讨 Linux 内核信号处理的内部细节;相反,我们想澄清之前提到的一个常见误解:处理信号根本不像处理硬件中断。信号不是中断,也不是故障或异常;所有这些——中断、陷阱、异常、故障——都是由计算机上的 PIC/MMU/CPU 硬件引发的。信号纯粹是软件功能。
向进程发送信号意味着在任务的任务结构(在内核内存中)中设置一些成员,即所谓的TIF_SIGPENDING位,以及任务的sigpending集中表示信号的特定位;这样,内核就知道是否有信号待传递给进程,以及是哪些信号。
事实是,在适当的时间点(定期发生),内核代码会检查是否有信号待传递,如果有,就会传递它,运行或消耗进程的信号处理程序(在用户空间上下文中)。因此,信号处理被认为更像是一种轮询机制,而不是中断机制。
可重入安全和信号传递
在信号处理期间,使用不安全的可重入(也称为异步信号不安全)函数时,存在一个重要的问题。
当然,要理解这个问题,你必须首先了解什么是可重入函数,以及随后什么是可重入安全或异步信号安全函数。
可重入函数
可重入函数是指在仍在运行的调用中可以重新进入的函数。比听起来简单;看看这段伪代码片段:
signal_handler(sig)
{
my_foo();
< ... >
}
my_foo()
{
char mybuf[MAX];
<...>
}
do_the_work_mate()
{
my_foo();
<...>
}
现在想象一下这个活动序列:
-
函数
my_foo()由业务逻辑函数do_the_work_mate()调用;它在本地缓冲区mybuf上操作 -
在这个仍在运行时,一个信号被发送到这个进程
-
信号处理程序代码会抢占发生时正在执行的任何内容并运行
-
重新调用函数
my_foo()
因此,我们看到了:函数my_foo()被重新进入。单独来说,这没问题;这里重要的问题是:它是安全的吗?
回想一下(从我们在第二章中的覆盖中),进程堆栈用于保存函数调用帧和任何局部变量。在这里,可重入函数my_foo()只使用一个局部变量。它被调用了两次;每次调用都会在进程堆栈上创建一个单独的调用帧。关键点:my_foo()的每次调用都在局部变量mybuf的副本上工作;因此,它是安全的。因此,它被记录为可重入安全。在信号处理上下文中,它被称为异步信号安全:在先前的调用仍在运行时从信号处理程序中调用该函数是安全的。
好的,让我们给之前的伪代码加点变化:将函数my_foo()的局部变量mybuf改为全局(或静态)变量。现在想想当它被重新进入时会发生什么;这次,不同的堆栈调用帧不能拯救我们。由于mybuf是全局的,只有一个副本存在,它将处于不一致状态,从第一个函数调用(由do_the_work_mate())开始。当第二次调用my_foo()发生时,我们将在这个不一致的全局mybuf上工作,从而破坏它。因此,显然,这是不安全的。
异步信号安全函数
一般规则是,只使用局部变量的函数是可重入安全的;任何使用全局或静态数据的用法都会使它们变得不安全。这是一个关键点:你只能在信号处理程序中调用那些被记录为可重入安全或信号异步安全的函数。
signal-safety(7)手册页man7.org/linux/man-pages/man7/signal-safety.7.html提供了详细信息。
在 Ubuntu 上,这个名字的 man 手册(signal-safety(7))只在最近的版本中安装;它在 Ubuntu 18.04 上可以使用。
其中,它发布了一个(按字母顺序排列的)函数列表,POSIX.1标准要求实现保证实现为异步信号安全的(参见 2017-03-13 日期的 man 页版本 4.12)
所以底线是:在信号处理程序中,你只能调用以下内容:
-
C 库函数或系统调用在信号安全(7)手册页中(确实查找一下)
-
在第三方库中,明确记录为异步信号安全的函数
-
你自己的库或其他明确编写为异步信号安全的函数
此外,不要忘记你的信号处理程序函数本身必须是可重入安全的。不要在其中访问应用程序的全局或静态变量。
在信号处理程序中保持安全的替代方法
如果我们必须在信号处理程序例程中访问一些全局状态呢?确实存在一些使其信号安全的替代方法:
-
在必须访问这些变量的时候,确保所有信号被阻塞(或屏蔽),一旦完成,恢复信号状态(取消屏蔽)。
-
在访问共享数据时进行某种形式的锁定。
-
在多进程应用程序中(我们在这里讨论的情况),(二进制)信号量可以用作锁定机制,以保护跨进程共享的数据。
-
在多线程应用程序中,使用适当的锁定机制(可能是互斥锁;当然,我们将在后面的章节中详细介绍)。
-
如果你的需求只是对全局整数进行操作(这是信号处理的常见情况!),使用特殊的数据类型(
sig_atomic_t)。稍后再看。
现实是,第一种方法,在需要时阻塞信号,实际上很难在复杂项目中实现(尽管你当然可以通过将信号掩码设置为全 1 来安排在处理信号时屏蔽所有信号,如前一节所示,查询信号掩码)。
第二种方法,锁定,对于多进程和多线程应用程序来说是现实的,尽管对性能敏感。
在这里和现在,讨论信号时,我们将涵盖第三种方法。另一个原因是因为在信号处理程序中处理(查询和/或设置)整数是一个非常常见的情况。
在本书中我们展示的代码中,偶尔会在信号处理程序中使用异步信号不安全的函数(通常是[f|s|v]printf(3)系列中的一个)。我们强调这仅仅是为了演示目的而做的;请不要在生产代码中使用异步信号不安全的函数!
信号安全的原子整数
想象一个多进程应用程序。一个进程 A 必须完成一定量的工作(比如说它必须完成运行函数foo()),并让另一个进程 B 知道它已经这样做了(换句话说,我们希望这两个进程之间实现同步;也请参见下一个信息框)。
实现这一点的一个简单方法是:当进程 A 达到所需点时,让进程 A 发送一个信号(比如SIGUSR1)给进程 B。反过来,进程 B 捕获SIGUSR1,当它到达时,在它的信号处理程序中,它将全局缓冲区设置为适当的消息字符串,以让应用程序的其余部分知道我们已经到达了这一点。
在下面的表格中,将时间线纵向(y轴)向下可视化。
伪代码-错误的方式:
| 进程 A | 进程 B |
|---|---|
| 做工作 | 为SIGUSR1设置信号处理程序 |
对foo()进行处理 |
char gMsg[32]; // 全局 做工作 |
foo()完成;向进程B发送SIGUSR1 |
|
signal_handler()函数异步进入 |
|
strncpy(gMsg, "chkpointA", 32); |
|
| [...] | [...] |
这看起来不错,只是请注意,对消息缓冲区gMsg的全局更新不能保证是原子的。完全有可能尝试这样做会导致竞争-一种我们无法确定全局变量的最终结果的情况。正是这种数据竞争是一类难以发现和解决的竞争性错误的理想滋生地。您必须通过使用适当的编程实践来避免它们。
解决方案:从使用全局缓冲区切换到使用数据类型为sig_atomic_t的全局整数变量,并且重要的是将其标记为volatile(以便编译器在其周围禁用优化)。
伪代码-正确的方式:
| 进程 A | 进程 B |
|---|---|
| 做工作 | 为SIGUSR1设置信号处理程序 |
对foo()进行处理 |
volatile sig_atomic_t gFlag=0; 做工作 |
foo()完成;向进程B发送SIGUSR1 |
|
signal_handler()函数异步进入 |
|
gFlag = 1; |
|
| [...] | [...] |
这次它将正常工作,没有任何竞争。(建议读者将前一个程序的完整工作代码编写为练习)。
重要的是要意识到,使用sig_atomic_t使(整数)变量只能在异步信号中安全,而不是线程安全。(线程安全将在以后的第十四章中详细介绍,使用 Pthreads 的多线程编程第 I 部分-基础知识)。
真正的进程同步应该使用适用于该目的的 IPC 机制来执行。信号确实可以作为原始的 IPC 机制;根据您的项目,其他 IPC 机制(套接字,消息队列,共享内存,管道和信号量)可能更适合这样做。
根据卡内基梅隆大学软件工程研究所(CMU SEI)CERT C 编码标准:
SIG31-C:不要在信号处理程序中访问共享对象(wiki.sei.cmu.edu/confluence/display/c/SIG31-C.+Do+not+access+shared+objects+in+signal+handlers)
类型sig_atomic_t是可以在异步中断的情况下作为原子实体访问的对象的整数类型。
附加说明:
查看最后一个链接中提供的代码示例也是值得的。此外,在相同的上下文中,CMU SEI 的 CERT C 编码标准,关于执行信号处理的正确方式有以下几点需要注意:
-
SIG30-C。在信号处理程序中只调用异步安全函数。 -
SIG31-C:不要在信号处理程序中访问共享对象。 -
SIG34-C。不要在可中断的信号处理程序中调用signal()。 -
SIG35-C。不要从计算异常信号处理程序返回。
最后一个要点可能更好地由POSIX.1委员会来表达:
进程在从未由kill(2),sigqueue(3)或raise(2)生成的SIGBUS,SIGFPE,SIGILL或SIGSEGV信号的信号捕获函数正常返回后的行为是未定义的。
换句话说,一旦您的进程从操作系统接收到任何先前提到的致命信号,它可以在信号处理程序中执行清理,但然后必须终止。(请允许我们开个玩笑:英雄呼喊“今天不是,死神!”在电影中是很好的,但当 SIGBUS,SIGFPE,SIGILL 或 SIGSEGV 来临时,是时候清理并优雅地死去了!)。事实上,我们将在下一章中详细探讨这一方面。
强大的 sigaction 标志
从前一节的sigaction结构中,回想一下sigaction结构的成员之一如下:
/* Special flags. */
int sa_flags;
这些特殊标志非常强大。有了它们,开发人员可以精确指定信号语义,否则很难或不可能获得。零的默认值意味着没有特殊行为。
我们首先将在本表中列举sa_flags的可能值,然后继续使用它们:
sa_flag |
它提供的行为或语义(来自sigaction(2)的 man 页面)。 |
|---|---|
SA_NOCLDSTOP |
如果signum是SIGCHLD,则当子进程停止或停止的子进程继续时不生成SIGCHLD。 |
SA_NOCLDWAIT |
(Linux 2.6 及更高版本)如果signum是SIGCHLD,则当它们终止时不将子进程转换为僵尸。 |
SA_RESTART |
通过使某些系统调用在信号中可重启,提供与 BSD 信号语义兼容的行为。 |
SA_RESETHAND |
在进入信号处理程序时将信号动作恢复为默认值。 |
SA_NODEFER |
不要阻止信号在其自己的信号处理程序中被接收。 |
SA_ONSTACK |
在由sigaltstack(2)提供的备用信号堆栈上调用信号处理程序。如果备用堆栈不可用,则将使用默认(进程)堆栈。 |
SA_SIGINFO |
信号处理程序需要三个参数,而不是一个。在这种情况下,应该设置sa_sigaction而不是sa_handler。 |
请记住,sa_flags是由操作系统解释为位掩码的整数值;将几个标志进行按位或运算以暗示它们的组合行为的做法确实很常见。
不邀请僵尸
让我们从标志SA_NOCLDWAIT开始。首先,一个快速的离题:
正如我们在第十章中学到的进程创建,一个进程可以 fork,导致一个创造的行为:一个新的子进程诞生了!从那一章开始,现在回想起我们的 Fork规则#7:父进程必须等待(阻塞)每个子进程的终止(死亡),直接或间接地。
父进程可以通过wait系统调用 API 集等待(阻塞)子进程的终止。正如我们之前学到的那样,这是必不可少的:如果子进程死亡而父进程没有等待它,子进程就会成为僵尸状态——这是一个不希望出现的状态。最坏的情况下,它可能会严重阻塞系统资源。
然而,通过waitAPI(s)阻塞在子进程(或子进程)的死亡上会导致父进程变成同步;它会阻塞,因此,在某种意义上,它会击败多处理的整个目的,即并行化。我们不能在我们的子进程死亡时异步通知吗?这样,父进程可以继续执行处理,与其子进程并行运行。
啊!信号来拯救:每当其任何子进程终止或进入停止状态时,操作系统将向父进程传递SIGCHLD信号。
注意最后的细节:即使子进程停止(因此并非死亡),SIGCHLD也将被传递。如果我们不想要这样怎么办?换句话说,我们只希望在子进程死亡时向我们发送信号。这正是SA_NOCLDSTOP标志执行的操作:停止时不产生子进程死亡。因此,如果您不希望被子进程的停止所欺骗以为它们已经死亡,请使用此标志。(当通过SIGCONT继续停止的子进程时,也适用)。
没有僵尸!-经典方式
前面的讨论也应该让你意识到,嘿,我们现在有了一个整洁的异步方式来摆脱任何讨厌的僵尸:捕获SIGCHLD,并在其信号处理程序中发出wait调用(使用第九章中涵盖的任何等待 API,最好使用WNOHANG选项参数,这样我们进行非阻塞等待;因此,我们不会在任何活着的子进程上阻塞,只会成功清除任何僵尸。
这是清除僵尸的经典 Unix 方式:
static void child_dies(int signum)
{
while((pid = wait3(0, WNOHANG, 0)) != -1);
}
在现代 Linux 上深入研究这里只会产生学术兴趣(在您的作者看来,现代 Linux 是指 2.6.0 及更高版本的 Linux 内核,顺便说一下,它于 2003 年 12 月 18 日发布)。
没有僵尸进程!- 现代的方式
所以,对于现代 Linux 来说,避免僵尸进程变得更加容易:只需使用sigaction(2)捕获SIGCHLD信号,并在信号标志位掩码中指定SA_NOCLDWAIT位。就是这样:僵尸进程的担忧永远消失了!在 Linux 平台上,SIGCHLD信号仍然会传递给父进程 - 您可以使用它来跟踪子进程,或者您可能想到的任何会计目的。
顺便说一下,POSIX.1标准还指定了另一种摆脱麻烦的僵尸进程的方法:只需忽略SIGCHLD信号(使用SIG_IGN)。好吧,您可以使用这种方法,但要注意,这样您将永远不会知道子进程确实死亡(或停止)。
所以,有用的东西:让我们把我们的新知识付诸实践:我们设置一个小型的多进程应用程序,它生成僵尸进程,但也以现代方式清除它们(ch11/zombies_clear_linux26.c):
为了可读性,只显示了代码的相关部分;要查看和运行它,整个源代码在这里可用:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
int main(int argc, char **argv)
{
struct sigaction act;
int opt=0;
if (argc != 2)
usage(argv[0]);
opt = atoi(argv[1]);
if (opt != 1 && opt != 2)
usage(argv[0]);
memset(&act, 0, sizeof(act));
if (opt == 1) {
act.sa_handler = child_dies;
/* 2.6 Linux: prevent zombie on termination of child(ren)! */
act.sa_flags = SA_NOCLDWAIT;
}
if (opt == 2)
act.sa_handler = SIG_IGN;
act.sa_flags |= SA_RESTART | SA_NOCLDSTOP; /* no SIGCHLD on stop of child(ren) */
if (sigaction(SIGCHLD, &act, 0) == -1)
FATAL("sigaction failed");
printf("parent: %d\n", getpid());
switch (fork()) {
case -1:
FATAL("fork failed");
case 0: // Child
printf("child: %d\n", getpid());
DELAY_LOOP('c', 25);
exit(0);
default: // Parent
while (1)
pause();
}
exit(0);
}
(暂时忽略代码中的SA_RESTART标志;我们很快会解释)。这是SIGCHLD的信号处理程序:
#define DEBUG
//#undef DEBUG
/* SIGCHLD handler */
static void child_dies(int signum)
{
#ifdef DEBUG
printf("\n*** Child dies! ***\n");
#endif
}
请注意,当处于调试模式时,我们只在信号处理程序中发出printf(3)(因为它是异步信号不安全的)。
让我们试一下:
$ ./zombies_clear_linux26
Usage: ./zombies_clear_linux26 {option-to-prevent-zombies}
1 : (2.6 Linux) using the SA_NOCLDWAIT flag with sigaction(2)
2 : just ignore the signal SIGCHLD
$
好的,首先我们尝试使用选项1;也就是使用SA_NOCLDWAIT标志:
$ ./zombies_clear_linux26 1 &
[1] 10239
parent: 10239
child: 10241
c $ cccccccccccccccccccccccc
*** Child dies! ***
$ ps
PID TTY TIME CMD
9490 pts/1 00:00:00 bash
10239 pts/1 00:00:00 zombies_clear_l
10249 pts/1 00:00:00 ps
$
重要的是,使用ps(1)检查后发现没有僵尸进程。
现在使用选项2运行它:
$ ./zombies_clear_linux26 2
parent: 10354
child: 10355
ccccccccccccccccccccccccc
^C
$
请注意,我们在上一次运行中得到的*** Child dies! ***消息没有出现,证明我们从未进入SIGCHLD的信号处理程序。当然不会;我们忽略了信号。虽然这确实可以防止僵尸进程,但也会阻止我们知道子进程已经死亡。
SA_NOCLDSTOP 标志
关于SIGCHLD信号,有一个重要的要意识到的地方:默认行为是,无论进程是死亡还是停止,或者一个已停止的子进程继续执行(通常是通过向其发送SIGCONT信号),内核都会向其父进程发送SIGCHLD信号。
也许这很有用。父进程被通知所有这些事件 - 子进程的死亡、停止或继续。另一方面,也许我们不希望被欺骗,认为我们的子进程已经死亡,而实际上它只是被停止(或继续)。
对于这种情况,使用SA_NOCLDSTOP标志;它的字面意思是子进程停止(或恢复)时不发送SIGCHLD。现在只有在子进程死亡时才会收到SIGCHLD。
中断的系统调用以及如何使用 SA_RESTART 修复它们
传统(较旧的)Unix 操作系统在处理阻塞系统调用时存在一个问题。
阻塞 API
当发出 API 时,调用进程(或线程)处于睡眠状态时,API 被称为阻塞。为什么?这是因为底层的操作系统或设备驱动程序知道调用者需要等待的事件尚未发生;因此,它必须等待。一旦事件(或条件)发生,操作系统或驱动程序唤醒进程;进程现在继续执行其代码路径。
阻塞 API 的例子很常见:read、write、select、wait(及其变体)、accept等等。
花点时间来想象这种情况:
-
一个进程捕获一个信号(比如
SIGCHLD)。 -
稍后,进程发出一个阻塞系统调用(比如
accept(2)系统调用)。 -
当它处于睡眠状态时,信号被传递给它。
以下伪代码说明了相同的情况:
[...]
sigaction(SIGCHLD, &sigact, 0);
[...]
sd = accept( <...> );
[...]
顺便说一下,accept(2)系统调用是网络服务器进程在客户端连接到它时阻塞(等待)的方式。
现在,信号被传递后应该发生什么?正确的行为是:进程应该唤醒,处理信号(运行其信号处理程序的代码),然后再次进入睡眠状态,继续阻塞在它正在等待的事件上。
在旧的 Unix 系统(作者在旧的 SunOS 4.x 上遇到过这种情况),信号被传递,信号处理程序代码运行,但在此之后,阻塞系统调用失败,返回-1。errno变量设置为EINTR,这意味着系统调用被中断。
当然,这被认为是一个 bug。可怜的 Unix 应用程序开发人员不得不求助于一些临时修复措施,通常是在循环中包装每个系统调用(在这个例子中是 foo),如下所示:
while ((foo() == -1) && (errno == EINTR));
这不容易维护。
POSIX委员会随后修复了这个问题,要求实现提供一个信号标志SA_RESTART。当使用此标志时,内核将自动重新启动任何由信号中断的阻塞系统调用。
因此,当注册信号处理程序时,只需在sigaction(2)中使用有用的SA_RESTART标志,这个问题就会消失。
一般来说,在编程sigaction(2)时使用SA_RESTART标志是一个好主意。不过,并不总是;第十三章,定时器,向我们展示了一些情况下我们故意远离这个标志。
一次性的 SA_RESETHAND 标志
SA_RESETHAND信号标志有点奇怪。在旧的 Unix 平台上,存在一个 bug,即捕获信号(通过signal(2)函数),信号被分发,然后进程处理信号。但是,一旦进入信号处理程序,内核立即将信号动作重置为原始的操作系统默认处理代码。因此,第二次信号到达时,默认处理程序代码会运行,通常会导致进程被终止。 (再次,Unix 开发人员有时不得不求助于一些糟糕的竞争性代码来尝试解决这个问题)。
因此,信号实际上只会被传递一次。在今天的现代 Linux 系统中,信号处理程序保持原样;默认情况下不会被重置为原始处理程序。当然,如果你想要这种一次性的行为,可以使用SA_RESETHAND标志(你可能会觉得这并不是非常流行)。SA_ONESHOT也是同一个标志的一个较旧的不推荐使用的名称。
推迟还是不推迟?使用 SA_NODEFER
让我们回顾一下信号的默认处理方式:
-
一个进程捕获了一个信号 n。
-
信号 n 被传递给进程(可以是另一个进程或操作系统)。
-
信号处理程序被调度;也就是说,它是作为对信号的响应而运行的。
-
信号 n 现在被自动屏蔽;也就是说,被阻止传递给进程。
-
信号处理已完成。
-
信号 n 现在被自动解除屏蔽,也就是说,可以传递给进程。
这是合理的:在处理特定信号时,该信号被屏蔽。这是默认行为。
但是,如果你正在编写一个嵌入式实时应用程序,其中信号传递意味着发生了一些真实世界的事件,并且应用程序必须立即(尽快)做出响应。在这种情况下,我们可能希望禁用信号的自动屏蔽,从而允许信号处理程序在到达时被重新进入。通过使用SA_NODEFER信号标志可以实现这一点。
英语单词 defer 的意思是延迟或推迟;推迟到以后。
这是默认行为,当指定了该标志时可以更改。
信号被屏蔽时的行为
为了更好地理解这一点,让我们举一个虚构的例子:假设我们捕获了一个信号 n,并且我们的信号处理程序的执行时间为 55 毫秒。此外,想象一种情况,通过一个定时器(至少一段时间),信号 n 以 10 毫秒的间隔不断地传递给进程。现在让我们来看看在默认情况下会发生什么,以及在使用SA_NODEFER标志的情况下会发生什么。
情况 1:默认:SA_NODEFER 位清除
在这里,我们不使用SA_NODEFER信号标志。因此,当信号 n 的第一个实例到达时,我们的进程会跳转到信号处理代码(需要 55 毫秒才能完成)。然而,第二个信号将在信号处理代码进行 10 毫秒时到达。但是,等一下,它被自动屏蔽了!因此,我们不会处理它。实际上,简单的计算将显示,在 55 毫秒的信号处理时间内,最多会有五个信号 n 的实例到达我们的进程:

图 3:默认行为:SA_NODEFER 位清除:没有队列,一个信号实例待处理,对堆栈没有实质影响
那么,到底会发生什么?这五个信号会在处理程序完成后排队等待传递吗?啊!这是一个重要的观点:标准的或 Unix 信号不会排队。然而,内核确实知道一个或多个信号正在等待传递给进程;因此,一旦信号处理完成,将传递一个待处理信号实例(并且随后清除待处理信号掩码)。
因此,在我们的例子中,即使有五个信号待处理,信号处理程序也只会被调用一次。换句话说,没有信号被排队,但是一个信号实例被处理了。这就是默认情况下信号的工作方式。
图 3显示了这种情况:虚线信号箭头代表进入信号处理程序后传递的信号;因此,只保留一个实例待处理。注意进程堆栈:当调用信号处理程序时,信号 n 的信号实例#1(显然)在堆栈上得到一个调用帧,没有更多。
问题:如果情况如图所示,但另一个信号,信号m,被传递了呢?
回答:如果信号 m 已被捕获并且当前未被屏蔽,它将立即被处理;换句话说,它将抢占一切,并且其处理程序将运行。当然,操作系统保存了上下文,以便稍后可以恢复被抢占的内容。这使我们得出以下结论:
-
信号是对等的;它们没有与之相关的优先级。
-
对于标准信号,如果传递了相同整数值的多个实例,并且该信号当前被屏蔽(阻塞),那么只保留一个实例待处理;不会排队。
情况 2:SA_NODEFER 位设置
现在让我们重新考虑完全相同的情况,只是这次我们使用了SA_NODEFER信号标志。因此,当信号 n 的第一个实例到达时,我们的进程会跳转到信号处理代码(需要 55 毫秒才能完成)。与以前一样,第二个信号将在信号处理代码进行 10 毫秒时到达,但是等一下,这次它没有被屏蔽;它没有被推迟。因此,我们将立即重新进入信号处理程序函数。然后,20 毫秒后(信号 n 实例#1 首次进入信号处理程序后),第三个信号实例到达。同样,我们将重新进入信号处理程序函数。是的,这将发生五次。
图 4 向我们展示了这种情况:

图 4:设置了 SA_NODEFER 位:没有队列;所有信号实例在传递时处理,堆栈密集
这看起来不错,但请意识到以下情况:
-
在这种情况下,信号处理程序代码本身必须编写为可重入安全(不使用全局或静态变量;仅在其中调用异步信号安全函数),因为在这种情况下它将不断地被重新进入。
-
堆栈使用:每次重新进入信号处理程序时,都要意识到已经为进程堆栈分配(推送)了一个额外的调用帧。
第二点值得思考:如果有这么多信号到达(在处理先前的调用时),我们会不会过载,甚至溢出堆栈?嗯,灾难。堆栈溢出是一个严重的错误;实际上几乎不可能进行异常处理(我们无法有信心捕获或陷入堆栈溢出问题)。
接下来是一个有趣的代码示例ch11/defer_or_not.c,用于演示这两种情况:
为了便于阅读,只显示了代码的关键部分;要查看完整的源代码,请构建并运行它;整个树可在书的 GitHub 存储库中克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux
static volatile sig_atomic_t s=0, t=0;
[...]
int main(int argc, char **argv)
{
int flags=0;
struct sigaction act;
[...]
flags = SA_RESTART;
if (atoi(argv[1]) == 2) {
flags |= SA_NODEFER;
printf("Running with the SA_NODEFER signal flag Set\n");
} else {
printf("Running with the SA_NODEFER signal flag Cleared [default]\n");
}
memset(&act, 0, sizeof(act));
act.sa_handler = sighdlr;
act.sa_flags = flags;
if (sigaction(SIGUSR1, &act, 0) == -1)
FATAL("sigaction failed\n");
fprintf(stderr, "\nProcess awaiting signals ...\n");
while (1)
(void)pause();
exit(EXIT_SUCCESS);
}
以下是信号处理程序函数:
/*
* Strictly speaking, should not use fprintf here as it's not
* async-signal safe; indeed, it sometimes does not work well!
*/
static void sighdlr(int signum)
{
int saved;
fprintf(stderr, "\nsighdlr: signal %d,", signum);
switch (signum) {
case SIGUSR1:
s ++; t ++;
if (s >= MAX)
s = 1;
saved = s;
fprintf(stderr, " s=%d ; total=%d; stack %p :", s, t, stack());
DELAY_LOOP(saved+48, 5); /* +48 to get the equivalent ASCII value */
fprintf(stderr, "*");
break;
default:;
}
}
我们故意让信号处理代码花费相当长的时间(通过我们使用的DELAY_LOOP宏),以便我们可以模拟在处理信号时多次传递相同信号的情况。在现实世界的应用中,始终努力使您的信号处理尽可能简短。
内联汇编stack()函数是获取寄存器值的有趣方法。阅读以下评论以了解其工作原理:
/*
* stack(): return the current value of the stack pointer register.
* The trick/hack: on x86 CPU's, the ABI tells us that the return
* value is always in the accumulator (EAX/RAX); so we just initialize
* it to the stack pointer (using inline assembly)!
*/
void *stack(void)
{
if (__WORDSIZE == 32) {
__asm__("movl %esp, %eax");
} else if (__WORDSIZE == 64) {
__asm__("movq %rsp, %rax");
}
/* Accumulator holds the return value */
}
处理器 ABI - 应用程序二进制接口 - 文档是严肃系统开发人员需要熟悉的重要领域;在 GitHub 存储库的进一步阅读部分中查看更多信息。
为了正确测试此应用程序,我们编写了一个名为bombard_sig.sh的小型 shell 脚本,它会向给定进程(相同)信号轰炸(这里我们使用 SIGUSR1)。用户应该传递进程 PID 和要发送的信号实例数作为参数;如果第二个参数给定为-1,则脚本将不断轰炸该进程。以下是脚本的关键代码:
SIG=SIGUSR1
[...]
NUMSIGS=$2
n=1
if [ ${NUMSIGS} -eq -1 ] ; then
echo "Sending signal ${SIG} continually to process ${1} ..."
while [ true ] ; do
kill -${SIG} $1
sleep 10e-03 # 10 ms
done
else
echo "Sending ${NUMSIGS} instances of signal ${SIG} to process ${1} ..."
while [ ${n} -le ${NUMSIGS} ] ; do
kill -${SIG} $1
sleep 10e-03 # 10 ms
let n=n+1
done
fi
运行案例 1 - 清除 SA_NODEFER 位[默认]
接下来,我们执行测试用例,其中SA_NODEFER标志被清除;这是默认行为:
$ ./defer_or_not
Usage: ./defer_or_not {option}
option=1 : don't use (clear) SA_NODEFER flag (default sigaction style)
option=2 : use (set) SA_NODEFER flag (will process signal immd)
$ ./defer_or_not 1
PID 3016: running with the SA_NODEFER signal flag Cleared [default]
Process awaiting signals ...
现在,在另一个终端窗口中,我们运行 shell 脚本:
$ ./bombard_sig.sh $(pgrep defer_or_not) 12
pgrep找出defer_or_not进程的 PID:有用!只需确保以下内容:
(a) 您发送信号的进程只有一个实例,或者pgrep返回多个 PID 并且脚本失败。
(b) 传递给pgrep的名称为 15 个字符或更少。
脚本一运行,向进程发送(12)个信号,就会出现以下输出:
sighdlr: signal 10, s=1 ; total=1; stack 0x7ffc8d021a70 :11111*
sighdlr: signal 10, s=2 ; total=2; stack 0x7ffc8d021a70 :22222*
研究前面的输出,我们注意到如下内容:
-
SIGUSR1被捕获并且其信号处理程序运行;它发出一系列数字(在每个信号实例上递增)。 -
为了正确地执行此操作,我们使用了一对
volatile sig_atomic_t全局变量(一个用于在DELAY_LOOP宏中打印的值,另一个用于跟踪传递给进程的信号总数)。 -
数字末尾的星号
*意味着,当您看到它时,信号处理程序已经执行完成。 -
尽管已传递了 12 个
SIGUSR1信号实例,但在剩余的 11 个信号到达时,进程正在处理第一个信号实例;因此,只有一个信号保持挂起,并在处理程序完成后处理。当然,在不同的系统上,您可能会看到处理多个信号实例。 -
最后,注意我们在每次信号处理程序调用时打印堆栈指针值;当然,这是用户空间虚拟地址(回想我们在第二章中的讨论,虚拟内存);更重要的是,它是相同的,这意味着相同的堆栈帧被重用于信号处理程序函数(这经常发生)。
运行案例 2 - 设置 SA_NODEFER 位
接下来,我们执行测试用例,其中设置了SA_NODEFER标志(首先确保你已经杀死了defer_or_not进程的任何旧实例):
$ ./defer_or_not 2 PID 3215: running with the SA_NODEFER signal flag Set
Process awaiting signals ...
现在,在另一个终端窗口中,我们运行 shell 脚本:
$ ./bombard_sig.sh $(pgrep defer_or_not) 12
脚本运行后,向进程发送(12)个信号,输出如下:
sighdlr: signal 10, s=1 ; total=1; stack 0x7ffe9e17a0b0 :
sighdlr: signal 10, s=2 ; total=2; stack 0x7ffe9e1799b0 :2
sighdlr: signal 10, s=3 ; total=3; stack 0x7ffe9e1792b0 :3
sighdlr: signal 10, s=4 ; total=4; stack 0x7ffe9e178bb0 :4
sighdlr: signal 10, s=5 ; total=5; stack 0x7ffe9e1784b0 :5
sighdlr: signal 10, s=6 ; total=6; stack 0x7ffe9e177db0 :6
sighdlr: signal 10, s=7 ; total=7; stack 0x7ffe9e1776b0 :7
sighdlr: signal 10, s=8 ; total=8; stack 0x7ffe9e176fb0 :8
sighdlr: signal 10, s=9 ; total=9; stack 0x7ffe9e1768b0 :9
sighdlr: signal 10, s=1 ; total=10; stack 0x7ffe9e1761b0 :1
sighdlr: signal 10, s=2 ; total=11; stack 0x7ffe9e175ab0 :22222*1111*9999*8888*7777*6666*5555*4444*3333*2222*11111*
sighdlr: signal 10, s=3 ; total=12; stack 0x7ffe9e17adb0 :33333*
这一次,请注意以下事项:
-
SIGUSR1被捕获并且它的信号处理程序运行;它发出一系列数字(每个信号实例递增)。 -
为了正确地做到这一点,我们使用一个
volatile sig_atomic_t全局变量(一个用于在DELAY_LOOP中打印的值,一个用于跟踪传递给进程的信号总数)。 -
数字末尾的星号
*表示,当你看到它时,信号处理程序已经执行完成;请注意,这一次,*直到很晚才出现。 -
连续传递了 12 个
SIGUSR1信号实例:这一次,每个实例都会抢占前一个实例(在进程堆栈上设置一个新的调用帧;请注意独特的堆栈指针地址)。 -
请注意,在所有信号实例处理完毕后,控制被恢复到原始上下文;我们确实可以看到堆栈展开。
-
最后,仔细观察堆栈指针的值;它们是逐渐减少的。当然,这是因为在
x86[_64]CPU 上(大多数现代 CPU 都是如此),堆栈是向下增长的。
自己试试这个程序。它很有趣而且功能强大,但是请记住,这是以非常大的堆栈为代价的!
它(在堆栈内存使用方面)有多昂贵?我们实际上可以计算每个堆栈(调用)帧的大小;取任意两个不同的实例,从较小的中减去较大的。例如,让我们看看前面的情况s=6和s=5:s=5: 0x7ffe9e1784b0 s=6: 0x7ffe9e177db0
因此,调用帧大小 = 0x7ffe9e1784b0 - 0x7ffe9e177db0 = 0x700 = 1792字节。
在这种特定的应用用例中,每个信号处理调用帧占用了高达 1792 字节的内存。
现在,让我们考虑一个最坏的情况:在嵌入式实时应用中,如果我们在上一个实例运行时非常快地接收到,比如说,5000 个信号(当然设置了SA_NODEFER标志):我们将在进程堆栈上创建 5000 个额外的调用帧,这将花费大约 5000 x 1,792 = 8,960,000 = ~ 8.5 MB!
为什么不实际测试一下这种情况呢?(以实证为价值 - 尝试事物而不是仅仅假设它们,是至关重要的。参见第十九章,故障排除和最佳实践)。我们可以这样做:
$ ./defer_or_not 2
PID 7815: running with the SA_NODEFER signal flag Set
Process awaiting signals ...
在另一个终端窗口中,运行bombard_sig.sh脚本,要求它生成 5000 个信号实例。参考以下命令:
$ ./bombard_sig.sh $(pgrep defer_or_not) 5000
Sending 5000 instances of signal SIGUSR1 to process 7815 ...
这是第一个终端窗口中的输出:
<...>
sighdlr: signal 10, s=1 ; total=1; stack 0x7ffe519b3130 :1
sighdlr: signal 10, s=2 ; total=2; stack 0x7ffe519b2a30 :2
sighdlr: signal 10, s=3 ; total=3; stack 0x7ffe519b2330 :3
sighdlr: signal 10, s=4 ; total=4; stack 0x7ffe519b1c30 :4
sighdlr: signal 10, s=5 ; total=5; stack 0x7ffe519b1530 :5
sighdlr: signal 10, s=6 ; total=6; stack 0x7ffe519b0e30 :6
sighdlr: signal 10, s=7 ; total=7; stack 0x7ffe519b0730 :7
sighdlr: signal 10, s=8 ; total=8; stack 0x7ffe519b0030 :8
sighdlr: signal 10, s=9 ; total=9; stack 0x7ffe519af930 :9
sighdlr: signal 10, s=1 ; total=10; stack 0x7ffe519af230 :1
sighdlr: signal 10, s=2 ; total=11; stack 0x7ffe519aeb30 :2
*--snip--*
sighdlr: signal 10, s=8 ; total=2933; stack 0x7ffe513a2d30 :8
sighdlr: signal 10, s=9 ; total=2934; stack 0x7ffe513a2630 :9
sighdlr: signal 10, s=1 ; total=2935; stack 0x7ffe513a1f30 :1
sighdlr: signal 10, s=2 ; total=2936; stack 0x7ffe513a1830 :2
sighdlr: signal 10, s=3 ; total=2937; stack 0x7ffe513a1130 :Segmentation fault
$
当它耗尽堆栈空间时,它当然会崩溃。(在不同的系统上,结果可能会有所不同;如果你没有经历过堆栈溢出导致的崩溃,尝试增加脚本发送的信号数量并观察...)。
正如我们在第三章中学到的,资源限制,典型的进程堆栈资源限制为 8 MB;因此,我们真的有可能溢出堆栈,这将导致致命的突然崩溃。所以,请小心!如果你打算使用SA_NODEFER标志,请费点功夫在大负载下对你的应用进行压力测试,看看是否使用了比安全更多的堆栈。
使用备用信号堆栈
注意我们之前的测试用例,向设置了SA_NODEFER的defer_or_not应用程序发送了 5,000 个SIGUSR1信号,导致它崩溃并出现段错误(通常缩写为 segfault)。当进程进行无效的内存引用时,操作系统向进程发送了SIGSEGV(段错误)信号;换句话说,这是与内存访问相关的错误。捕获SIGSEGV可能非常有价值;我们可以获得关于应用程序崩溃的原因和方式的信息(实际上,我们将在下一章中做到这一点)。
然而,仔细想一想:在最后一个测试用例中(发送 5,000 个信号...),进程崩溃的原因是它的栈溢出。因此,操作系统发送了SIGSEGV信号;我们希望捕获这个信号并处理它。但是栈上没有空间,那么信号处理程序本身如何被调用?这是一个问题。
有一个有趣的解决方案:我们可以为信号处理分配(虚拟)内存空间,并设置一个单独的备用栈仅用于信号处理。如何做到?通过sigaltstack(2)系统调用。它用于这种情况:你需要处理SIGSEGV,但是你的栈空间不够了。想想我们之前的实时高容量信号处理应用:也许我们可以重新设计它,为单独的信号栈分配更多的空间,这样在实践中就可以工作了。
使用备用信号栈处理高容量信号的实现
这是一个精确的尝试:ch11/altstack.c的代码和运行时测试。此外,我们还添加了一个新功能(对于之前的版本:defer_or_not程序):发送进程SIGUSR2信号将使其打印出第一个和最近的堆栈指针地址。它还将计算并显示增量——实际上是应用程序到目前为止使用的堆栈内存量。
从ch11/defer_or_not.c中的更改:
-
我们也捕获了信号。
-
SIGUSR2:显示第一个和最近的堆栈指针地址以及它们之间的差值。 -
SIGSEGV:这在现实世界的应用中很重要。捕获segfault允许我们在进程崩溃时(这里,可能是由于栈溢出)接管控制,并且也许显示(或在实际应用中,写入日志)相关信息,执行清理,然后调用abort(3)退出。要意识到,毕竟,我们必须退出:一旦这个信号从操作系统到达,进程就处于一个未定义的状态。(请注意,有关处理SIGSEGV的更多细节将在下一章中介绍)。 -
为了避免输出中的噪音过多,我们用一个静默版本的
DELAY_LOOP宏替换它。
为了可读性,只显示了代码的关键部分;要查看完整的源代码,构建并运行它,整个树都可以从 GitHub 克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
在ch11/altstack.c:main()中:
<...>
altstacksz = atoi(argv[1])*1024;
setup_altsigstack(altstacksz);
<...>
setup_altsigstack()函数的代码如下:
static void setup_altsigstack(size_t stack_sz)
{
stack_t ss;
printf("Alt signal stack size = %zu\n", stack_sz);
ss.ss_sp = malloc(stack_sz);
if (!ss.ss_sp)
FATAL("malloc(%zu) for alt sig stack failed\n", stack_sz);
ss.ss_size = stack_sz;
ss.ss_flags = 0;
if (sigaltstack(&ss, NULL) == -1)
FATAL("sigaltstack for size %zu failed!\n", stack_sz);
}
信号处理代码如下:
static volatile sig_atomic_t s=0, t=0;
static volatile unsigned long stk_start=0, stk=0;
static void sighdlr(int signum)
{
if (t == 0)
stk_start = (unsigned long)stack();
switch (signum) {
case SIGUSR1:
stk = (unsigned long)stack();
s ++; t ++;
if (s >= MAX)
s = 1;
fprintf(stderr, " s=%d ; total=%d; stack %p\n", s, t, stack());
/* Spend some time inside the signal handler ... */
DELAY_LOOP_SILENT(5);
break;
case SIGUSR2:
fprintf(stderr, "*** signal %d:: stack@: t0=%lx last=%lx : delta=%ld ***\n", signum, stk_start, stk, (stk_start-stk));
break;
case SIGSEGV:
fprintf(stderr, "*** signal %d:: stack@: t0=%lx last=%lx :
delta=%ld ***\n", signum, stk_start, stk, (stk_start-stk));
abort();
}
}
让我们进行一些测试,并考虑以下情况运行它们。
情况 1 - 非常小(100KB)的备用信号栈
我们故意为备用信号栈分配了非常少的空间——只有 100 千字节。不用说,它很快就溢出并且出现段错误;我们的SIGSEGV处理程序运行,打印出一些统计信息:
$ ./altstack 100
Alt signal stack size = 102400
Running: signal SIGUSR1 flags: SA_NODEFER | SA_ONSTACK | SA_RESTART
Process awaiting signals ...
在另一个终端窗口中,运行 shell 脚本:
$ ./bombard_sig.sh $(pgrep altstack) 120
Sending 120 instances of signal SIGUSR1 to process 12811 ...
现在,在原始窗口中的输出:
<...>
s=1 ; total=1; stack 0xa20ff0
s=2 ; total=2; stack 0xa208f0
s=3 ; total=3; stack 0xa201f0
*--snip--*
s=1 ; total=49; stack 0xa0bff0
s=2 ; total=50; stack 0xa0b8f0
s=3 ; total=51; stack 0xa0b1f0
*** signal 11:: stack@: t0=a20ff0 last=a0aaf0 : delta=91392 ***
Aborted
$
可以看到,根据我们的度量标准,备用信号栈的总使用量为 91,392 字节,接近 100KB,在溢出时。
shell 脚本以预期的方式终止:
<...>
./bombard_sig.sh: line 30: kill: (12811) - No such process
bombard_sig.sh: kill failed, loop count=53
$
情况 2:一个大(16MB)备用信号栈
这一次,我们故意为备用信号栈分配了大量空间——16 兆字节。现在它可以处理几千个连续的信号。但是,当然,在某个时候它也会溢出:
$ ./altstack 16384
Alt signal stack size = 16777216
Running: signal SIGUSR1 flags: SA_NODEFER | SA_ONSTACK | SA_RESTART
Process awaiting signals ...
在另一个终端窗口中运行 shell 脚本:
$ ./bombard_sig.sh $(pgrep altstack) 12000
Sending 12000 instances of signal SIGUSR1 to process 13325 ...
现在原始窗口中的输出:
<...>
s=1 ; total=1; stack 0x7fd7339239b0
s=2 ; total=2; stack 0x7fd7339232b0
s=3 ; total=3; stack 0x7fd733922bb0
*--snip--*
s=2 ; total=9354; stack 0x7fd732927ab0
s=3 ; total=9355; stack 0x7fd7329273b0
*** signal 11:: stack@: t0=7fd7339239b0 last=7fd732926cb0 : delta=16764160 ***
Aborted
$
shell 脚本以预期的方式终止:
./bombard_sig.sh: line 30: kill: (13325) - No such process
bombard_sig.sh: kill failed, loop count=9357
$
这一次,在堆栈耗尽之前,它成功处理了大约九千个信号。备用信号堆栈的总使用量为 16,764,160 字节,或接近 16 MB,在溢出时。
处理高容量信号的不同方法
总之,如果您遇到一个场景,其中大量相同类型的多个信号(以及其他信号)以快速的速度传递给进程,我们使用通常的方法就有丢失(或丢弃)信号的风险。正如我们所看到的,我们可以以几种方式成功处理所有信号,每种方式都有其自己的处理高容量信号的方法 - 优缺点如下表所示:
| 方法 | 优点 | 缺点/限制 |
|---|---|---|
在调用sigaction(2)之前使用sigfillset(3)来确保在处理信号时,所有其他信号都被阻塞。 |
简单直接的方法。 | 可能导致处理和/或丢弃信号出现显著(不可接受的)延迟。 |
设置SA_NODEFER信号标志并在信号到达时处理所有信号。 |
简单直接的方法。 | 在负载下,堆栈使用量大,存在堆栈溢出的危险。 |
使用备用信号堆栈,设置SA_NODEFER信号标志,并在信号到达时处理所有信号。 |
可以根据需要指定备用堆栈大小。 | 设置更多工作;必须在负载下进行仔细测试以确定要使用的(最大)堆栈大小。 |
| 使用实时信号(在下一章中介绍)。 | 操作系统会自动排队待处理的信号,堆栈使用低,可以对信号进行优先级排序。 | 系统范围内对可以排队的最大数量有限制(可以作为 root 进行调整)。 |
总结
在本章中,读者首先介绍了 Linux 操作系统上的信号概念,信号的作用,以及如何在应用程序中有效处理信号的详细内容。
当然,还有更多内容,下一章将继续讨论这一重要内容。到那里见。
第十二章:信号处理 - 第 II 部分
如前一章所述,信号是 Linux 系统开发人员理解和利用的关键机制。前一章涵盖了几个方面:介绍,为什么信号对系统开发人员有用,以及最重要的是,开发人员如何处理和利用信号机制。
本章将继续探讨这一问题。在这里,我们将深入研究使用信号处理进程崩溃的内部细节,如何识别和避免处理信号时的常见问题,处理实时信号,发送信号,以及最后,执行信号处理的替代方法。
在本章中,读者将学到以下内容:
-
优雅地处理进程崩溃,并在那时收集有价值的诊断信息
-
处理与信号相关的常见陷阱——errno 竞争,正确的睡眠方式(是的,你没看错!)
-
处理强大的实时信号
-
向其他进程发送信号,并通过信号执行 IPC
-
替代信号处理技术
优雅地处理进程崩溃
应用程序中导致运行时崩溃的错误?天啊,这怎么可能?
不幸的是,对于经验丰富的软件老手来说,这并不是一个大惊喜。错误存在;它们有时可以很好地隐藏多年;有一天,它们会出现,然后砰!进程崩溃了。
在这里,我们的意图不是讨论调试技术或工具(也许我们可以把这个留到另一本书中吧?);相反,关键是:如果我们的应用程序进程崩溃了,我们能做些什么?当然可以:在上一章中,我们已经详细学习了如何捕获信号。为什么不设计我们的应用程序,使我们捕获典型的致命信号——SIGBUS、SIGFPE、SIGILL 和 SIGSEGV,并在它们的信号处理程序中执行有用的任务,比如:
-
执行关键应用程序清理——例如,释放内存区域,刷新和关闭打开的文件等
-
将相关详细信息写入日志文件(导致崩溃的信号,信号的来源,原因,CPU 寄存器值等)
-
通知最终用户,嘿,太糟糕了,我们崩溃了
-
请允许我们收集崩溃详细信息,我们下次会做得更好,我们保证!
这不仅为我们提供了有价值的信息,可以帮助您调试崩溃的根本原因,而且还可以使应用程序优雅地退出。
使用 SA_SIGINFO 详细信息
让我们回顾一下我们在上一章中看到的sigaction结构的第一个成员,信号处理 - 第 I 部分,sigaction 结构部分;它是一个函数指针,它指定了信号处理程序:
struct sigaction
{
/* Signal handler. */
#ifdef __USE_POSIX199309
union
{
/* Used if SA_SIGINFO is not set. */
__sighandler_t sa_handler;
/* Used if SA_SIGINFO is set. */
void (*sa_sigaction) (int, siginfo_t *, void *);
}
__sigaction_handler;
# define sa_handler __sigaction_handler.sa_handler
# define sa_sigaction __sigaction_handler.sa_sigaction
#else
__sighandler_t sa_handler;
#endif
*--snip--* };
前面突出显示的代码突出显示了,由于它在一个联合中,信号处理程序可以是以下之一:
-
sa_handler:当清除SA_SIGINFO标志时 -
sa_sigaction:当设置了SA_SIGINFO标志时
到目前为止,我们已经使用了sa_handler风格的信号处理程序原型:
void (*sa_handler)(int);
它只接收一个参数:发生的信号的整数值。
如果您设置了SA_SIGINFO标志(当然,在发出sigaction(2)系统调用时),信号处理程序函数原型现在变成了这样:void (*sa_sigaction)(int, siginfo_t *, void *);
参数如下:
-
发生的信号的整数值
-
一个指向
siginfo_t类型的结构的指针(显然是一个 typedef) -
一个仅供内部使用的(未记录的)指针称为ucontext
第二个参数是关键所在!
siginfo_t 结构
当您使用SA_SIGINFO信号标志并发生受困信号时,内核会填充一个数据结构:siginfo_t结构。
下面显示了siginfo_t结构定义(稍微简化;在前几个成员周围有一些#if包装,我们在这里不需要担心)(在 Ubuntu 的头文件/usr/include/x86_64-linux-gnu/bits/types/siginfo_t.h中,Fedora 的头文件为/usr/include/bits/types/siginfo_t.h):
typedef struct {
int si_signo; /* Signal number. */
int si_code;
int si_errno; /* If non-zero, an errno value associated with
this signal, as defined in <errno.h>. */
union
{
int _pad[__SI_PAD_SIZE];
/* kill(). */
struct
{
__pid_t si_pid; /* Sending process ID. */
__uid_t si_uid; /* Real user ID of sending process. */
} _kill;
/* POSIX.1b timers. */
struct
{
int si_tid; /* Timer ID. */
int si_overrun; /* Overrun count. */
__sigval_t si_sigval; /* Signal value. */
} _timer;
/* POSIX.1b signals. */
struct
{
__pid_t si_pid; /* Sending process ID. */
__uid_t si_uid; /* Real user ID of sending process. */
__sigval_t si_sigval; /* Signal value. */
} _rt;
/* SIGCHLD. */
struct
{
__pid_t si_pid; /* Which child. */
__uid_t si_uid; /* Real user ID of sending process. */
int si_status; /* Exit value or signal. */
__SI_CLOCK_T si_utime;
__SI_CLOCK_T si_stime;
} _sigchld;
/* SIGILL, SIGFPE, SIGSEGV, SIGBUS. */
struct
{
void *si_addr; /* Faulting insn/memory ref. */
__SI_SIGFAULT_ADDL
short int si_addr_lsb; /* Valid LSB of the reported address. */
union
{
/* used when si_code=SEGV_BNDERR */
struct
{
void *_lower;
void *_upper;
} _addr_bnd;
/* used when si_code=SEGV_PKUERR */
__uint32_t _pkey;
} _bounds;
} _sigfault;
/* SIGPOLL. */
struct
{
long int si_band; /* Band event for SIGPOLL. */
int si_fd;
} _sigpoll;
/* SIGSYS. */
#if __SI_HAVE_SIGSYS
struct
{
void *_call_addr; /* Calling user insn. */
int _syscall; /* Triggering system call number. */
unsigned int _arch; /* AUDIT_ARCH_* of syscall. */
} _sigsys;
#endif
} _sifields;
} siginfo_t ;
前三个成员是整数:
-
si_signo: 信号编号 - 传递给进程的信号 -
si_code: 信号来源;一个枚举;典型值如下:
SI_QUEUE : 由sigqueue(3)发送
SI_USER : 由kill(2)发送
SI_KERNEL : 由内核发送
SI_SIGIO : 由排队的 SIGIO 发送
SI_ASYNCIO : 由 AIO 完成发送
SI_MESGQ : 由实时消息队列状态更改发送
SI_TIMER : 由定时器到期发送
si_errno: (如果非零)errnovalue
这里真正有趣的部分是:结构的第四个成员是七个结构的union(_sifields)。我们知道union意味着在运行时将实例化任何一个成员:它将是七个结构中的一个,具体取决于接收到哪个信号!
看一下之前显示的siginfo_t结构中的union;union中的注释非常清楚地指出了哪些信号将导致在运行时实例化哪个数据结构。
例如,我们在union中看到,当接收到SIGCHLD信号时,将填充此结构(也就是说,当子进程死亡、停止或继续时):
/* SIGCHLD. */
struct
{
__pid_t si_pid; /* Which child. */
__uid_t si_uid; /* Real user ID of sending process. */
int si_status; /* Exit value or signal. */
__SI_CLOCK_T si_utime;
__SI_CLOCK_T si_stime;
} _sigchld;
信息是关于子进程的;因此,我们接收到了进程的 PID 和真实 UID(除非使用了SA_NOCLDWAIT标志,当然)。此外,我们接收到整数位掩码si_status告诉我们子进程究竟是如何死亡的(等等)。还有一些审计信息,si_utime和si_stime,子进程在用户空间和内核空间中所花费的时间。
回想一下我们在第十章中的详细讨论,进程创建,等待 API - 详细信息部分,我们可以通过(任何)waitAPI 获取子进程终止状态信息。好吧,在这里,我们可以看到,更简单:使用SA_SIGINFO标志,捕获SIGCHLD信号,并且在处理程序函数中,只需从union中查找相关值!
sigaction(2)的手册详细描述了siginfo_t结构的成员,提供了详细信息。务必仔细阅读。
在进程崩溃时获取系统级细节
当进程通过SIGSEGV死亡时,可以从内核中获取大量信息:内存错误或缺陷,这是一个常见情况,正如我们在第四章中讨论的那样,动态内存分配,第五章,Linux 内存问题和第六章,内存问题的调试工具。(本节也适用于致命信号SIGBUS,SIGILL和SIGFPE。顺便说一句,SIGFPE不仅在除以零错误时发生,而且在任何与算术相关的异常中都会发生)。
在sigaction(2)的手册中揭示了以下信息:
...
The following values can be placed in si_code for a SIGSEGV signal:
SEGV_MAPERR
Address not mapped to object.
SEGV_ACCERR
Invalid permissions for mapped object.
SEGV_BNDERR (since Linux 3.19)
Failed address bound checks.
SEGV_PKUERR (since Linux 4.6)
Access was denied by memory protection keys. See pkeys(7). The
protection key which applied to this access is available via si_pkey.
...
SEGV_MAPERR表示进程试图访问的地址(读取、写入或执行)无效;要么没有可用于它的页表项(PTE)条目,要么它拒绝映射到任何有效地址。
SEGV_ACCERR很容易理解:尝试访问(读取、写入或执行)无法执行,因为缺少权限(例如,尝试写入只读内存页)。
奇怪的是,SEGV_BNDERR和SEGV_PKUERR宏无法编译;我们不会在这里尝试使用它们。
glibc 库提供了辅助例程psignal(3)和psiginfo(3);传递一个信息字符串,它们会打印出来,然后是实际发生的信号以及有关信号传递和故障地址的信息(分别从 siginfo_t 结构中查找)。我们在示例代码中使用psiginfo(3)如下。
捕获并提取崩溃信息
接下来,我们将看到一个测试程序ch12/handle_segv.c,其中包含故意的错误,以帮助我们理解可能的用例。所有这些都将导致 OS 生成SIGSEGV信号。应用程序开发人员如何处理这个信号很重要:我们演示了如何使用它来收集重要的细节,例如发生崩溃的内存位置的地址以及那时所有寄存器的值。这些细节通常提供有用的线索,可以解释内存错误的根本原因。
为了帮助理解我们如何构建这个程序,可以不带任何参数运行它:
$ ./handle_segv
Usage: ./handle_segv u|k r|w
u => user mode
k => kernel mode
r => read attempt
w => write attempt
$
可以看到,我们可以执行四种类型的无效内存访问:实际上,有四种错误情况:
-
无效用户[u]模式读[r]
-
无效用户[u]模式写[w]
-
内核无效[k]模式读[r]
-
无效内核[k]模式写[w]
我们使用的一些 typedef 和宏如下:
typedef unsigned int u32;
typedef long unsigned int u64;
#define ADDR_FMT "%lx"
#if __x86_64__ /* 64-bit; __x86_64__ works for gcc */
#define ADDR_TYPE u64
static u64 invalid_uaddr = 0xdeadfaceL;
static u64 invalid_kaddr = 0xffff0b9ffacedeadL;
#else
#define ADDR_TYPE u32
static u32 invalid_uaddr = 0xfacedeadL;
static u32 invalid_kaddr = 0xdeadfaceL;
#endif
main函数如下所示:
int main(int argc, char **argv)
{
struct sigaction act;
if (argc != 3) {
usage(argv[0]);
exit(1);
}
memset(&act, 0, sizeof(act));
act.sa_sigaction = myfault;
act.sa_flags = SA_RESTART | SA_SIGINFO;
sigemptyset(&act.sa_mask);
if (sigaction(SIGSEGV, &act, 0) == -1)
FATAL("sigaction SIGSEGV failed\n");
if ((tolower(argv[1][0]) == 'u') && tolower(argv[2][0] == 'r')) {
ADDR_TYPE *uptr = (ADDR_TYPE *) invalid_uaddr;
printf("Attempting to read contents of arbitrary usermode va uptr = 0x"
ADDR_FMT ":\n", (ADDR_TYPE) uptr);
printf("*uptr = 0x" ADDR_FMT "\n", *uptr); // just reading
} else if ((tolower(argv[1][0]) == 'u') && tolower(argv[2][0] == 'w')) {
ADDR_TYPE *uptr = (ADDR_TYPE *) & main;
printf
("Attempting to write into arbitrary usermode va uptr (&main actually) = 0x" ADDR_FMT ":\n", (ADDR_TYPE) uptr);
*uptr = 0x2A; // writing
} else if ((tolower(argv[1][0]) == 'k') && tolower(argv[2][0] == 'r')) {
ADDR_TYPE *kptr = (ADDR_TYPE *) invalid_kaddr;
printf
("Attempting to read contents of arbitrary kernel va kptr = 0x" ADDR_FMT ":\n", (ADDR_TYPE) kptr);
printf("*kptr = 0x" ADDR_FMT "\n", *kptr); // just reading
} else if ((tolower(argv[1][0]) == 'k') && tolower(argv[2][0] == 'w')) {
ADDR_TYPE *kptr = (ADDR_TYPE *) invalid_kaddr;
printf
("Attempting to write into arbitrary kernel va kptr = 0x" ADDR_FMT ":\n",
(ADDR_TYPE) kptr);
*kptr = 0x2A; // writing
} else
usage(argv[0]);
exit(0);
}
va = 虚拟地址。
这是关键部分:SIGSEGV 的信号处理程序:
static void myfault(int signum, siginfo_t * si, void *ucontext)
{
fprintf(stderr,
"%s:\n------------------- FATAL signal ---------------------------\n",
APPNAME);
fprintf(stderr," %s: received signal %d. errno=%d\n"
" Cause/Origin: (si_code=%d): ",
__func__, signum, si->si_errno, si->si_code);
switch (si->si_code) {
/* Possible values si_code can have for SIGSEGV */
case SEGV_MAPERR:
fprintf(stderr,"SEGV_MAPERR: address not mapped to object\n");
break;
case SEGV_ACCERR:
fprintf(stderr,"SEGV_ACCERR: invalid permissions for mapped object\n");
break;
/* SEGV_BNDERR and SEGV_PKUERR result in compile failure? */
/* Other possibilities for si_code; here just to show them... */
case SI_USER:
fprintf(stderr,"user\n");
break;
case SI_KERNEL:
fprintf(stderr,"kernel\n");
break;
*--snip--*
default:
fprintf(stderr,"-none-\n");
}
<...>
/*
* Placeholders for real-world apps:
* crashed_write_to_log();
* crashed_perform_cleanup();
* crashed_inform_enduser();
*
* Now have the kernel generate the core dump by:
* Reset the SIGSEGV to (kernel) default, and,
* Re-raise it!
*/
signal(SIGSEGV, SIG_DFL);
raise(SIGSEGV);
}
这里有很多要观察的地方:
-
我们打印出信号编号和原始值
-
我们通过
switch-case解释信号的原始值 -
特别是对于 SIGSEGV,SEGV_MAPERR 和 SEGV_ACCERR
接下来是有趣的部分:以下代码打印出故障指令或地址!不仅如此,我们设计了一种方法,通过它我们可以通过我们的dump_regs函数打印出大部分 CPU 寄存器。正如前面提到的,我们还使用辅助例程psiginfo(3)如下:
fprintf(stderr," Faulting instr or address = 0x" ADDR_FMT "\n",
(ADDR_TYPE) si->si_addr);
fprintf(stderr, "--- Register Dump [x86_64] ---\n");
dump_regs(ucontext); fprintf(stderr,
"------------------------------------------------------------\n");
psiginfo(si, "psiginfo helper");
fprintf(stderr,
"------------------------------------------------------------\n");
然后,我们只保留了一些虚拟存根,用于处理这种致命信号在真实世界应用程序中可能需要的功能(在这里,我们实际上没有编写任何代码,因为这当然是非常特定于应用程序的):
/*
* Placeholders for real-world apps:
* crashed_write_to_log();
* crashed_perform_cleanup();
* crashed_inform_enduser();
*/
最后,调用abort(3)使进程终止(因为它现在处于未定义状态,无法继续)是一种结束的方式。然而,请思考一下:如果我们现在abort(),进程将在内核有机会生成核心转储的情况下死亡。(如前所述,核心转储实质上是进程在崩溃时的动态内存段的快照;对于开发人员来说,它非常有用,可以用于调试和确定崩溃的根本原因)。因此,让内核生成核心转储确实是有用的。我们如何安排这个呢?这其实非常简单:我们需要做以下几点:
-
将
SIGSEGV信号的处理程序重置为(内核)默认值 -
在进程上重新引发信号
这段代码片段实现了这一点:
[...]
* Now have the kernel generate the core dump by:
* Reset the SIGSEGV to glibc default, and,
* Re-raise it!
*/
signal(SIGSEGV, SIG_DFL);
raise(SIGSEGV);
由于这是一个简单的情况,我们只需使用更简单的signal(2) API 将信号的操作恢复为默认值。然后,我们再次使用库 API raise(3) 来在调用进程上引发给定的信号。(出于易读性的考虑,错误检查代码已被省略。)
寄存器转储
如前所述,dump_regs函数打印出 CPU 寄存器的值;以下是关于此的一些需要注意的事项:
-
这是非常特定于 CPU 的(下面显示的示例情况仅适用于 x86_64 CPU)。
-
为了实际访问 CPU 寄存器,我们利用信号处理程序函数的未记录的第三个参数(注意:当与
SA_SIGINFO一起使用时),即所谓的用户上下文指针。它是可能解释的(正如我们在这里展示的),但是,当然,由于它在 glibc 系统调用(或其他)接口中没有正式可见,您不能依赖这个功能。谨慎使用(并进行大量测试)。
说到这里,让我们来看看代码:
/* arch - x86[_64] - specific! */
static inline void dump_regs(void *ucontext)
{
#define FMT "%016llx"
ucontext_t *uctx = (ucontext_t *)ucontext;
fprintf(stderr,
" RAX = 0x" FMT " RBX = 0x" FMT " RCX = 0x" FMT "\n"
" RDX = 0x" FMT " RSI = 0x" FMT " RDI = 0x" FMT "\n"
" RBP = 0x" FMT " R8 = 0x" FMT " R9 = 0x" FMT "\n"
" R10 = 0x" FMT " R11 = 0x" FMT " R12 = 0x" FMT "\n"
" R13 = 0x" FMT " R14 = 0x" FMT " R15 = 0x" FMT "\n"
" RSP = 0x" FMT "\n"
"\n RIP = 0x" FMT " EFLAGS = 0x" FMT "\n"
" TRAP# = %02lld ERROR = %02lld\n"
/* CR[0,1,3,4] unavailable */
" CR2 = 0x" FMT "\n"
, uctx->uc_mcontext.gregs[REG_RAX]
, uctx->uc_mcontext.gregs[REG_RBX]
, uctx->uc_mcontext.gregs[REG_RCX]
, uctx->uc_mcontext.gregs[REG_RDX]
, uctx->uc_mcontext.gregs[REG_RSI]
, uctx->uc_mcontext.gregs[REG_RDI]
, uctx->uc_mcontext.gregs[REG_RBP]
, uctx->uc_mcontext.gregs[REG_R8]
, uctx->uc_mcontext.gregs[REG_R9]
, uctx->uc_mcontext.gregs[REG_R10]
, uctx->uc_mcontext.gregs[REG_R11]
, uctx->uc_mcontext.gregs[REG_R12]
, uctx->uc_mcontext.gregs[REG_R13]
, uctx->uc_mcontext.gregs[REG_R14]
, uctx->uc_mcontext.gregs[REG_R15]
, uctx->uc_mcontext.gregs[REG_RSP]
, uctx->uc_mcontext.gregs[REG_RIP]
, uctx->uc_mcontext.gregs[REG_EFL]
, uctx->uc_mcontext.gregs[REG_TRAPNO]
, uctx->uc_mcontext.gregs[REG_ERR]
, uctx->uc_mcontext.gregs[REG_CR2]
);
}
现在,让我们运行两个测试用例:
*Test Case: Userspace, Invalid Read*
$ ./handle_segv u r
Attempting to read contents of arbitrary usermode va uptr = 0xdeadface:
handle_segv:
------------------- FATAL signal ---------------------------
myfault: received signal 11. errno=0
Cause/Origin: (si_code=1): SEGV_MAPERR: address not mapped to object
Faulting instr or address = 0xdeadface
--- Register Dump [x86_64] ---
RAX = 0x00000000deadface RBX = 0x0000000000000000 RCX = 0x0000000000000000
RDX = 0x0000000000000000 RSI = 0x0000000001e7b260 RDI = 0x0000000000000000
RBP = 0x00007ffc8d842110 R8 = 0x0000000000000008 R9 = 0x0000000000000000
R10 = 0x0000000000000000 R11 = 0x0000000000000246 R12 = 0x0000000000400850
R13 = 0x00007ffc8d8421f0 R14 = 0x0000000000000000 R15 = 0x0000000000000000
RSP = 0x00007ffc8d842040
RIP = 0x0000000000400e84 EFLAGS = 0x0000000000010202
TRAP# = 14 ERROR = 04
CR2 = 0x00000000deadface
------------------------------------------------------------
psiginfo helper: Segmentation fault (Address not mapped to object [0xdeadface])
------------------------------------------------------------
Segmentation fault (core dumped)
$
以下是一些需要注意的事项:
-
原始值是
SEGV_MAPERR:是的,我们尝试读取的任意用户空间虚拟地址(0xdeadface)不存在(或映射),因此发生了段错误! -
故障地址被显示为我们尝试读取的无效任意用户空间虚拟地址(
0xdeadface): -
另外:一个重要的值——故障指令或地址——实际上是保存在 x86 的控制寄存器 2(CR2)中,如下所示。
-
TRAP 号显示为 14;在 x86[_64]上的 trap 14 是页面故障。事实上:当进程尝试读取无效虚拟地址(
0xdeadface)时,错误访问导致了 x86[_64] MMU 引发了一个坏页故障异常,进而导致了操作系统故障处理程序的运行,并通过 SIGSEGV 杀死了进程。 -
CPU 寄存器也被转储。
好奇的读者也许会想知道每个寄存器究竟用来做什么。这超出了本书的范围;然而,读者可以通过查找 CPU OEM 的应用二进制接口(ABI)文档来找到有用的信息;其中包括函数调用、返回、参数传递等的寄存器使用。在 GitHub 存储库的进一步阅读部分查看 ABI 文档的更多内容。
-
psiginfo(3)也生效,打印出信号的原因和故障地址 -
消息
Segmentation fault (core dumped)告诉我们我们的策略奏效了:我们将 SIGSEGV 的信号处理重置为默认值,然后重新引发了信号,导致操作系统(内核)生成了一个核心转储。生成的核心文件(在 Fedora 28 x86_64 上生成)如下所示:
$ ls -l corefile*
-rw-------. 1 kai kai 389120 Jun 24 14:23 'corefile:host=<hostname>:gPID=2413:gTID=2413:ruid=1000:sig=11:exe=<!<path>!<to>!<executable>!ch13!handle_segv.2413'
$
以下是一些要提到的要点:
-
对核心转储的详细分析和解释超出了本书的范围。使用 GDB 分析核心转储很容易;稍微搜索一下就会有结果。
-
核心文件的名称因发行版而异;现代的 Fedora 发行版设置名称非常描述性(如您所见);实际上,核心文件名是通过
proc文件系统中的内核可调参数来控制的。有关详细信息,请参阅core(5)的手册页。
我们运行内核空间的无效写入测试用例,为我们的handle_segv程序如下:
*Test Case: Kernel-space, Invalid* ***Write*** $ ./handle_segv k w
Attempting to write into arbitrary kernel va kptr = 0xffff0b9ffacedead:
handle_segv:
------------------- FATAL signal ---------------------------
myfault: received signal 11. errno=0
Cause/Origin: (si_code=128): kernel
Faulting instr or address = 0x0
--- Register Dump [x86_64] ---
RAX = 0xffff0b9ffacedead RBX = 0x0000000000000000 RCX = 0x0000000000000000
RDX = 0x0000000000000000 RSI = 0x00000000023be260 RDI = 0x0000000000000000
RBP = 0x00007ffcb5b5ff60 R8 = 0x0000000000000010 R9 = 0x0000000000000000
R10 = 0x0000000000000000 R11 = 0x0000000000000246 R12 = 0x0000000000400850
R13 = 0x00007ffcb5b60040 R14 = 0x0000000000000000 R15 = 0x0000000000000000
RSP = 0x00007ffcb5b5fe90
RIP = 0x0000000000400ffc EFLAGS = 0x0000000000010206
TRAP# = 13 ERROR = 00
CR2 = 0x0000000000000000
------------------------------------------------------------
psiginfo helper: Segmentation fault (Signal sent by the kernel [(nil)])
------------------------------------------------------------
Segmentation fault (core dumped)$
请注意,这次陷阱值为 13;在 x86[_64] MMU 上,这是通用保护故障(GPF)。再次,这次错误访问导致了 x86[_64] MMU 引发了 GPF 异常,进而导致了操作系统故障处理程序的运行,并通过 SIGSEGV 杀死了进程。陷阱是 GPF 是一个线索:我们违反了保护规则;回想一下第一章中所述的内容:运行在更高、更特权级别的进程(或线程)总是可以访问更低特权级别的内存,但反之则不行。在这里,运行在第三特权级的进程尝试访问第零特权级的内存;因此,MMU 引发了 GPF 异常,操作系统将其杀死(通过SIGSEGV)。
这一次,不幸的是,CR2 值和因此故障地址为 0x0(在崩溃发生在内核空间的情况下)。然而,我们仍然可以从其他寄存器中获得有价值的细节(指令和堆栈指针值等),接下来我们将看到。
在源代码中找到崩溃位置
RIP(指令指针;IA-32 上的 EIP,ARM 上的 PC)是有用的:使用它的值和一些实用程序,我们几乎可以确定进程崩溃时代码的位置。如何?有几种方法;其中一些如下:
-
使用工具链实用程序
objdump(带有-d-S开关) -
更简单的方法是使用
gdb(1)(请参阅下文) -
使用
addr2line(1)实用程序
使用 GDB:
使用程序的调试版本(使用-g开关编译)加载gdb(1),然后使用如下所示的list命令:
$ gdb -q ./handle_segv_dbg
Reading symbols from ./handle_segv_dbg...done.
(gdb) list *0x0000000000400ffc
<< 0x0000000000400ffc is the RIP value >>
0x400ffc is in main (handle_segv.c:212).
207 } else if ((tolower(argv[1][0]) == 'k') && tolower(argv[2][0] == 'w')) {
208 ADDR_TYPE *kptr = (ADDR_TYPE *) invalid_kaddr; // arbitrary kernel virtual addr
209 printf
210 ("Attempting to write into arbitrary kernel va kptr = 0x" ADDR_FMT ":\n",
211 (ADDR_TYPE) kptr);
212 *kptr = 0x2A; // writing
213 } else
214 usage(argv[0]);
215 exit(0);
216 }
(gdb)
list * <address>命令确切地指出了导致崩溃的代码,为了清晰起见,这里再次重现:
(gdb) l *0x0000000000400ffc
0x400ffc is in main (handle_segv.c:212).
第 212 行如下:
212: *kptr = 0x2A; // writing
这完全正确。
使用addr2line:
addr2line(1)实用程序提供了类似的功能;再次运行它针对使用-g编译的二进制可执行文件的-e开关版本(用于调试构建):
$ addr2line -e ./handle_segv_dbg 0x0000000000400ffc
<...>/handle_segv.c:212
$
另外,想想:我们之前的ch12/altstack.c程序在其备用信号栈溢出时可能会遭受段错误;我们留给读者的练习是编写一个类似于此处所示的SIGSEGV处理程序来正确处理这种情况。
最后,我们已经表明处理段错误 SIGSEGV 对于找出崩溃原因非常有益;简单的事实仍然是,一旦此信号在进程上生成,该进程被认为处于未定义的,实际上是不稳定的状态。因此,不能保证我们在其信号处理程序中执行的任何工作实际上会按预期进行。因此,建议将信号处理代码保持最少。
信号 - 注意事项和陷阱
信号作为异步事件,可能以不立即显而易见的方式导致错误和错误(或者对程序员来说)。某些功能或行为直接或间接受到一个或多个信号到达的影响;您需要警惕可能的微妙竞争和类似条件。
我们已经涵盖的一个重要领域是:在信号处理程序内,您只能调用已记录为(或已设计为)异步信号安全的函数。其他领域也值得一些思考;继续阅读。
优雅地处理 errno
在使用系统调用和信号的程序中可能会出现与未初始化的全局整数errno的竞争。
errno 的作用是什么?
记住 errnoglobal;它是进程未初始化数据段中的未初始化全局整数(进程布局在第二章中已经涵盖,虚拟内存)。
errno 是用来做什么的?每当系统调用失败时,它会向用户空间返回-1。但是它为什么失败?啊,错误诊断,它失败的原因,以这种方式返回给用户空间:glibc 与内核一起,用正整数值 poke 全局 errno。这个值实际上是一个英文错误消息的二维数组的索引(它是以 NULL 结尾的);它被称为_sys_errlist。因此,查找_sys_errlist[errno]会显示英文错误消息:系统调用失败的原因。
开发人员不再执行所有工作,而是设计了方便的例程,如perror(3)、strerror(3)和error(3),通过查找_sys_errlist[errno]来发出错误消息。程序员经常在系统调用错误处理代码中使用这样的例程(事实上,我们确实这样做:查看我们的宏WARN和FATAL的代码 - 它们调用handle_err函数,该函数又调用perror(3)作为其处理的一部分)。
这是一个有用的查找项目 - 所有可能的errno值列表位于头文件/usr/include/asm-generic/errno-base.h中。
errno 竞争
考虑这种情况:
- 进程为几个信号设置了信号处理程序:
- 假设
SIGUSR1的信号处理程序称为handle_sigusr。
- 现在进程正在运行其代码的一部分,一个名为
foo的函数。
-
foo 发出一个系统调用,比如
open(2)。 -
系统调用失败返回
-1。 -
errno 设置为正整数
13,反映了错误的权限被拒绝(errno 宏 EACCES)。 -
系统调用的错误处理代码调用
perror(3)来发出英文错误消息。
所有这些似乎都很无辜,是的。但是,现在让我们考虑信号的情况;查看以下情景:
-
<...>
-
foo 发出一个系统调用,比如
open(2)。 -
系统调用失败返回
-1。 -
errno 设置为正整数
13,反映了错误的权限被拒绝(errno 宏 EACCES)。 -
信号
SIGUSR1此刻传递给进程。 -
控制转移到信号处理程序
handle_sigusr。 -
这里的代码发出另一个系统调用,比如
stat(2)。 -
stat(2)系统调用失败,返回-1。 -
现在
errno被设置为正整数9,反映了错误的坏文件号(errno 宏 EBADF)。 -
信号处理程序返回。
-
系统调用的错误处理代码调用
perror(3)来发出英文错误消息。
可以看到,由于事件序列,errno的值从 13 被覆盖为 9。结果是应用程序开发人员(以及项目中的其他人)现在被奇怪的错误报告所困扰(错误的坏文件号可能被报告两次!)。竞争——程序员的悲哀!
修复 errno 竞争
修复之前的竞争实际上非常简单。
每当你有一个信号处理程序,其中的代码可能导致errno值发生变化时,要在函数进入时保存errno,并在处理程序返回之前恢复它。
通过包含其头文件来简单地访问errno变量。以下是一个快速示例代码片段,显示了如何在信号处理程序中实现这一点:
<...>
include <errno.h>
<...>
static void handle_sigusr(int signum)
{
int myerrno = errno;
<... do the handling ...>
<... syscalls, etc ...>
errno = myerror;
}
正确睡眠
是的,即使睡眠也需要足够的知识才能正确执行!
通常,你的进程必须进入睡眠状态。我们都可能学会了使用sleep(3)API 来实现:
#include <unistd.h>
unsigned int sleep(unsigned int seconds);
举个简单的例子,假设进程必须以这种方式工作(伪代码如下):
<...>
func_a();
sleep(10);
func_b();
<...>
很明显:进程必须睡眠10秒;所示的代码应该可以工作。有问题吗?
嗯,是的,信号:如果进程进入睡眠状态,但是在睡眠三秒钟后收到一个信号呢?默认行为(也就是说,除非信号被屏蔽)是处理信号,你会想象,然后回到睡眠状态,剩下的时间(七秒)。但是,不,事实并非如此:睡眠被中止了!敏锐的读者可能会争辩说可以通过使用SA_RESTART标志来修复这种行为(被信号中断的阻塞系统调用);的确,这听起来是合理的,但现实是即使使用了该标志也没有帮助(睡眠必须手动重新启动)。
此外,重要的是要意识到sleep(3)API 文档规定其返回值是剩余的睡眠时间;因此,除非sleep(3)返回0,否则睡眠没有完成!实际上,开发人员期望在循环中调用sleep(3),直到返回值为0。
让进程(或线程)"进入睡眠"到底意味着什么?
关键点在于:处于睡眠状态的进程(或线程)在该状态下无法在 CPU 上运行;它甚至不是 OS 调度程序的候选对象(从技术上讲,从状态转换
运行->睡眠是从运行队列出队并进入 OS 的等待队列,反之亦然)。有关更多信息,请参阅第十七章,Linux 上的 CPU 调度。
因此,我们得出结论,仅仅在代码中使用sleep(3)并不是一个好主意,因为:
-
一旦被信号传递中断,睡眠必须手动重新启动。
-
sleep(3)的粒度非常粗糙:一秒。(对于现代微处理器来说,一秒是非常非常长的时间!许多现实世界的应用程序依赖于至少毫秒到微秒级的粒度。)
那么,解决方案是什么?
nanosleep 系统调用
Linux 提供了一个系统调用nanosleep(2),理论上可以提供纳秒级的粒度,也就是说,可以睡眠一纳秒。(实际上,粒度还取决于板上硬件定时器芯片的分辨率。)这是该 API 的原型:
#include <time.h>
int nanosleep(const struct timespec *req, struct timespec *rem);
系统调用有两个参数,都是指向数据类型为 struct timespec的结构的指针;该结构定义如下:
struct timespec {
time_t tv_sec; /* seconds */
long tv_nsec; /* nanoseconds */
};
显然,这允许您以秒和纳秒为单位指定睡眠时间;第一个参数req是所需时间(s.ns),第二个参数rem是剩余的睡眠时间。看,操作系统在这里帮了我们:如果睡眠被信号(任何非致命的信号)中断,nanosleep系统调用失败,返回-1,并将errno设置为值EINTR(中断的系统调用)。不仅如此,操作系统会计算并返回(到这第二个指针,一个值-结果类型的参数),准确到纳秒的剩余睡眠时间。这样,我们检测到这种情况,将req设置为rem,并手动重新发出nanosleep(2),使睡眠继续进行直到完全完成。
为了演示,我们接下来展示一个小应用程序(源代码:ch12/sleeping_beauty.c);用户可以调用通常的sleep(3)睡眠方法,也可以使用高度优越的nanosleep(2)API,以便睡眠时间准确:
static void sig_handler(int signum)
{
fprintf(stderr, "**Signal %d interruption!**\n", signum);
}
int main(int argc, char **argv)
{
struct sigaction act;
int nsec = 10, ret;
struct timespec req, rem;
if (argc == 1) {
fprintf(stderr, "Usage: %s option=[0|1]\n"
"0 : uses the sleep(3) function\n"
"1 : uses the nanosleep(2) syscall\n", argv[0]);
exit(EXIT_FAILURE);
}
/* setup signals: trap SIGINT and SIGQUIT */
memset(&act, 0, sizeof(act));
act.sa_handler = sig_handler;
sigemptyset(&act.sa_mask);
act.sa_flags = SA_RESTART;
if (sigaction(SIGINT, &act, 0) || sigaction(SIGQUIT, &act, 0))
FATAL("sigaction failure\n");
if (atoi(argv[1]) == 0) { /* sleep */
printf("sleep for %d s now...\n", nsec);
ret = sleep(nsec);
printf("sleep returned %u\n", ret);
} else if (atoi(argv[1]) == 1) { /* nanosleep */
req.tv_sec = nsec;
req.tv_nsec = 0;
while ((nanosleep(&req, &rem) == -1) && (errno == EINTR)) {
printf("nanosleep interrupted: rem time: %07lu.%07lu\n",
rem.tv_sec, rem.tv_nsec);
req = rem;
}
}
exit(EXIT_SUCCESS);
}
请注意前面代码中的以下内容:
-
将
0作为参数传递使我们调用通常的sleep(3).。 -
我们故意在这里编写代码而不使用循环,因为这是大多数程序员调用
sleep(3)的方式(因此我们可以看到其中的缺陷)。 -
将
1作为参数传递使我们调用强大的nanosleep(2)API;我们将所需时间初始化为 10 秒(与前面的情况相同)。 -
但是,这一次,我们在循环中调用
nanosleep(2),检查信号中断情况errno == EINTR,如果是的话, -
我们将
req设置为rem并再次调用它! -
(为了好玩,我们打印剩余时间
s.ns):
$ ./sleeping_beauty
Usage: ./sleeping_beauty option=[0|1]
0 : uses the sleep(3) function
1 : uses the nanosleep(2) syscall
$
让我们尝试两种情况:首先是通常的sleep(3)方法:
$ ./sleeping_beauty 0
sleep for 10 s now...
^C**Signal 2 interruption!**
sleep returned 7
$
睡眠几秒钟后,我们按下^C;信号到达,但睡眠被中止(如所示,睡眠还剩下额外的七秒,这里的代码简单地忽略了)!
现在是好的情况:通过nanosleep(2)睡眠:
$ ./sleeping_beauty 1
^C**Signal 2 interruption!**
nanosleep interrupted: rem time: 0000007.249192148
^\**Signal 3 interruption!**
nanosleep interrupted: rem time: 0000006.301391001
^C**Signal 2 interruption!**
nanosleep interrupted: rem time: 0000004.993030983
^\**Signal 3 interruption!**
nanosleep interrupted: rem time: 0000004.283608684
^C**Signal 2 interruption!**
nanosleep interrupted: rem time: 0000003.23244174
^\**Signal 3 interruption!**
nanosleep interrupted: rem time: 0000001.525725162
^C**Signal 2 interruption!**
nanosleep interrupted: rem time: 0000000.906662154
^\**Signal 3 interruption!**
nanosleep interrupted: rem time: 0000000.192637791
$
这一次,我们亲爱的睡美人(睡觉?)即使在连续中断多个信号的情况下也能完成。不过,你应该注意到这一点:确实会有一些开销。操作系统唯一保证的是睡眠至少持续所需的时间,可能会稍微长一些。
注意:尽管使用nanosleep(2)相对于通常的sleep(3)API 来说是一个高度优越的实现,但事实是,即使nanosleep也会受到(可能会变得显著)的时间超限的影响,当代码在循环中并且足够多的信号中断我们的循环很多次(就像在我们之前的例子中可能发生的那样)。在这种情况下,我们可能会睡得过多。为了解决这个问题,POSIX 标准和 Linux 提供了一个更好的clock_nanosleep(2)系统调用:使用它和实时时钟以及TIMER_ABSTIME标志值可以解决过度睡眠的问题。还要注意,尽管 Linux 的sleep(3)API 是通过nanosleep(2)内部实现的,但睡眠语义仍然如描述的那样;调用睡眠代码在循环中,检查返回值和失败情况是应用程序开发人员的责任。
实时信号
回想一下kill -l(l 代表列表)命令的输出;平台支持的信号都会显示出来——包括数字整数和符号名称。前 31 个信号是标准的 Unix 信号(在第十一章中看到,信号-第一部分,标准或 Unix 信号部分);我们现在已经在使用它们了。
信号编号 34 到 64 都以SIGRT开头——SIGRTMIN到SIGRTMAX——它们被称为实时信号:
$ kill -l |grep "SIGRT"
31)SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38)SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43)SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48)SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
$
(这里看到的第一个SIGSYS不是实时信号;它出现是因为它与其他 SIGRT 在同一行,所以grep(1)打印出来。)
与标准信号的不同之处
那么所谓的实时信号与常规标准信号有何不同;以下表格揭示了这一点:
| 特征 | 标准信号 | 实时信号 |
|---|---|---|
| 编号 | 1 - 31 ¹ | 34 - 64 ² |
| 标准首次定义于 | POSIX.1-1990(很旧) | POSIX 1003.1b:POSIX 的实时扩展(2001) |
| 分配的含义 | 单个信号具有特定含义(并相应命名);例外是SIGUSR[1|2] |
单个 RT 信号没有特定含义;它们的含义由应用程序定义 |
| 阻塞时和多个相同信号实例连续传递时的行为 | 在 n 个相同信号的实例中,n-1 个会丢失;只有 1 个实例保持挂起,并在解除阻塞时传递给目标进程 | 所有 RT 信号的实例都会被排队并在解除阻塞时由操作系统传递给目标进程(存在系统范围的上限³) |
| 信号优先级 | 相同:所有标准信号都是对等的 | FCFS,除非挂起;如果挂起,那么从最低编号的实时信号开始传递到最高编号的实时信号⁴ |
| 进程间通信(IPC) | 粗糙的 IPC;可以使用SIGUSR[1|2]进行通信,但无法传递数据 |
更好:通过sigqueue(3),可以向对等进程发送单个数据项,整数或指针值(对等进程可以检索它) |
标准信号和实时信号之间的差异
¹ 信号编号0?不存在,用于检查进程是否存在(稍后看到)。
² 一个常见问题:实时信号编号 32 和 33 发生了什么?答案:它们被 pthread 实现保留,因此应用程序开发人员无法使用。
³ 系统范围的上限是一个资源限制,因此可以通过prlimit(1)实用程序(或prlimit(2)系统调用)查询或设置:
$ prlimit |grep SIGPENDING
SIGPENDING max number of pending signals 63229 63229 signals
$
(回想一下第三章,资源限制,第一个数字是软限制,第二个是硬限制)。
⁴ RT 信号优先级:实时信号的多个实例将按照它们被传递的顺序进行处理(换句话说,先来先服务(FCFC)。但是,如果这些多个实时信号正在等待传递给进程,也就是说,它们当前被阻塞,那么它们将按照优先顺序进行处理,相当不直观地,SIGRTMIN是最高优先级信号,SIGRTMAX是最低优先级信号。
实时信号和优先级
POSIX 标准和 Linux 文档指出,当不同类型的多个实时信号正在等待传递给进程时(即进程正在阻塞它们);然后,在某个时刻,当进程的信号掩码解除阻塞(从而允许信号传递)时,信号确实按照优先顺序传递:从最低信号编号到最高信号编号。
让我们测试一下:我们编写一个程序,捕获并在传递三个实时信号时阻塞:{SIGRTMAX-5,SIGRTMAX,SIGRTMIN+5}。(查看kill -l的输出;它们的整数值分别为{59,64,39}。)
重要的是,我们的程序在sigaction(2)时将使用sigfillset(3)便利方法,用全 1 填充结构 sigaction 的信号掩码成员,从而确保所有信号在信号处理程序代码运行时被阻塞(屏蔽)。
考虑以下内容:
- 进程(代码:
ch12/rtsigs_waiter.c)捕获 RT 信号(使用 sigaction)
{SIGRTMAX-5,SIGRTMAX,SIGRTMIN+5}:整数值分别为{59,64,39}。
- 然后,我们有一个 shell 脚本(
bombard_sigrt.sh)不断地发送这三个实时信号(或者按照请求的次数)以三个一组的方式,顺序如下:
{SIGRTMAX-5,SIGRTMAX,SIGRTMIN+5}:整数值分别为{59,64,39}。
-
第一个 RT 信号(#59)导致进程进入信号处理程序例程;回想一下,我们已经在
sigaction(2)时指定了所有信号在信号处理程序代码运行时都被阻塞(屏蔽)。 -
我们故意使用我们的
DELAY_LOOP_SILENT宏来让信号处理程序运行一段时间。 -
因此,脚本传递的 RT 信号不能中断处理程序(它们被阻塞),因此操作系统将它们排队。
-
一旦信号处理程序完成并返回,队列中的下一个 RT 信号将被传递给进程。
-
按优先顺序,它们按从最低到最高的顺序交付,就像这样:
{SIGRTMIN+5, SIGRTMAX-5, SIGRTMAX}:整数值{39, 59, 64}。
下一次运行将在 Linux 上验证这种行为:
我们在这里不显示源代码;要查看完整的源代码,构建它并运行它,整个树可在 GitHub 上克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux/raw/master/ch12/rtsigs_waiter.c和github.com/PacktPublishing/Hands-on-System-Programming-with-Linux/raw/master/ch12/bombard_sigrt.sh。
$ ./rtsigs_waiter
Trapping the three realtime signals
Process awaiting signals ...
在另一个终端窗口中,我们运行 bombard 脚本:
$ ./bombard_sigrt.sh
Usage: bombard_sigrt.sh PID-of-process num-RT-signals-batches-to-send
(-1 to continously bombard the process with signals).
$ $ ./bombard_sigrt.sh $(pgrep rtsigs_waiter) 3
Sending 3 instances each of RT signal batch
{SIGRTMAX-5, SIGRTMAX, SIGRTMIN+5} to process 3642 ...
i.e. signal #s {59, 64, 39}
SIGRTMAX-5 SIGRTMAX SIGRTMIN+5 SIGRTMAX-5 SIGRTMAX SIGRTMIN+5 SIGRTMAX-5 SIGRTMAX SIGRTMIN+5
$
在rtsigs_waiter进程正在运行的原始终端窗口中,我们现在看到了这个:
sighdlr: signal 59, s=1 ; total=1; stack 0x7ffd2f9c6100 :*
sighdlr: signal 39, s=2 ; total=2; stack 0x7ffd2f9c6100 :*
sighdlr: signal 39, s=3 ; total=3; stack 0x7ffd2f9c6100 :*
sighdlr: signal 39, s=4 ; total=4; stack 0x7ffd2f9c6100 :*
sighdlr: signal 59, s=5 ; total=5; stack 0x7ffd2f9c6100 :*
sighdlr: signal 59, s=6 ; total=6; stack 0x7ffd2f9c6100 :*
sighdlr: signal 64, s=7 ; total=7; stack 0x7ffd2f9c6100 :*
sighdlr: signal 64, s=8 ; total=8; stack 0x7ffd2f9c6100 :*
sighdlr: signal 64, s=9 ; total=9; stack 0x7ffd2f9c6100 :*
注意以下内容:
-
脚本发送的第一个 RT 信号是
SIGRTMAX-5(值 59);因此,它进入信号处理程序并被处理。 -
当信号处理程序运行时,所有信号都被阻塞。
-
脚本继续输出剩余的 RT 信号(请参见其输出),而它们被屏蔽。
-
因此,它们由操作系统排队,并按优先顺序传递:从
SIGRTMIN(最高)到SIGRTMAX(最低)的编号 RT 信号的优先顺序是从低到高。 -
由于它们被排队,没有信号会丢失。
这是一个截图,展示了相同的情况,对于更多数量的 RT 信号:

将 10 传递给脚本(请参见右侧窗口)会使其传递 3x10:30 个 RT 信号,分为 10 批次的{SIGRTMIN+5, SIGRTMAX-5, SIGRTMAX}。请注意,在左侧窗口中,除了第一个实例之外,它们(被排队并)按优先顺序处理,从低到高——首先是所有的 39(SIGRTMIN+5),然后是所有的 59(SIGRTMAX-5),最后是最低优先级的 64(SIGRTMAX)RT 信号。
该脚本通过发出kill(1)命令向进程发送信号;这将在本章后面详细解释。
总之,实时信号的处理方式如下:
-
如果解除阻塞,它们将按 FCFS 顺序依次处理。
-
如果被阻塞,它们将按优先顺序排队并传递——最低的 RT 信号具有最高优先级,最高的 RT 信号具有最低优先级。
和往常一样,强烈建议您,读者,查看代码并自己尝试这些实验。
发送信号
我们通常看到内核向进程发送信号的情况;没有理由一个进程不能向另一个进程发送信号(或多个)。在本节中,我们将深入探讨从一个进程向另一个进程发送信号以及与此相关的想法的细节。
你可能会想,即使你可以向另一个进程发送信号,那有什么用呢?嗯,想想看:信号发送可以用作进程间通信(IPC)机制。此外,这是一种检查进程存在的方法!还有其他有用的情况,比如向自己发送信号。让我们进一步探讨这些情况。
只是杀死它们
我们如何向另一个进程发送信号:简短的答案是通过kill(2)系统调用。kill API 可以向给定 PID 的进程传递任何信号;来自kill(2)手册页的函数签名:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
注意它非常通用——你几乎可以向任何进程发送任何信号(也许更好的名字可能是sendsig,但当然,这并不像kill那样令人兴奋)。
用户命令kill(1)当然是kill(2)系统调用的包装器。
显然,根据前面的 API,您可以推断信号sig被发送到具有 PID 值pid的进程。不过,请稍等,还有一些特殊情况需要考虑;请参阅以下表格:
| kill PID 值 | 含义 |
|---|---|
| > 0 | 信号发送给 PID 等于该值的进程(通常情况)。 |
| 0 | 信号发送给调用者进程组内的所有进程。 |
| -1 | 信号发送给调用者有权限发送的所有进程,但不包括整体祖先进程 PID 1(传统上是 init,现在是 systemd)。² |
| < -1 | 信号发送给进程组 one 内具有 IDpid的所有进程。 |
¹ 进程组:每个进程都将成为一个进程组的成员(每个 pgrp 都有自己独特的 ID,等于第一个成员的 PID,称为进程组领导者。使用ps j查找进程组详细信息;还有系统调用get|set]pgid(2), [get|set]pgrp(2)可用。
如果通过管道运行一系列进程(例如,ps aux |tail |sort -k6n),并且在运行时在键盘上输入^C,那么我们知道信号 SIGINT 是通过内核的 tty 层生成的;但是发送给哪个进程呢?当前作为前置管道一部分运行的所有进程形成了前台进程组。关于信号传递的重要性:通过键盘生成的任何信号(如C*、**、^Z)都会传递给属于前台进程组的所有进程。(因此所有三个进程都会收到该信号。请查看更多阅读*部分,了解有关进程组的更多信息的 GitHub 存储库链接。)
在 Linux 上,kill(-1, sig)不会将sig发送给调用进程本身。
用加薪来杀死自己
尽管听起来戏剧性,但这里我们指出一个简单的包装 API:raise(3)库调用。以下是它的签名:
include <signal.h>
int raise(int sig);
这真的非常简单:给定一个信号编号,raise API 会向调用进程(或线程)发送给定的信号。如果所讨论的信号被捕获,raise将只在信号处理程序完成后返回一次。
回想一下,在本章的早些时候,我们在handle_segv.c程序中使用了这个 API:我们用它来确保对于信号 SIGSEGV,在我们自己的处理完成后,我们重新向自己发送相同的信号,从而确保核心转储发生。
(嗯,哲学上来说,获得加薪对您的幸福指数只能起到有限的作用。)
00 特工-有权杀人
在伊恩·弗莱明的书中,詹姆斯·邦德是一名双零特工(007):一名有权杀人的特工!
嗯,就像邦德一样,我们也可以杀死;嗯,当然是一个进程,也就是发送一个信号。这并不像邦德那样戏剧性和令人兴奋,但是,嘿,我们可以!嗯,当且仅当我们有权限这样做时。
所需的权限:发送进程必须满足以下条件之一:
-
拥有 root 权限-根据现代的能力模型(回想第八章,进程能力),要求是进程具有
CAP_KILL能力位设置;来自capabilities(7)的 man 页面:CAP_KILL:绕过发送信号的权限检查(参见kill(2))。 -
拥有目标进程,这意味着发送者的 EUID(有效 UID)或 RUID(真实 UID)和目标的 EUID 或 RUID 应该匹配。
kill(2)的 man 页面在 Linux 上详细说明了一些关于发送信号权限的特殊情况;如果感兴趣,可以看一下。
因此,尽管听起来很诱人,但只是执行一个循环(伪代码如下)并不一定适用于所有活动进程,主要是因为缺乏权限:
for i from 1 to PID_MAX
kill(i, SIGKILL)
即使你以 root 身份运行类似之前显示的代码,系统也会禁止突然终止关键进程,比如 systemd(或 init)。(为什么不试试——反正这是一个建议的练习。当然,尝试这样的东西是在自找麻烦;我们建议你在测试虚拟机中尝试。)
你在吗?
检查进程的存在非常重要,现在它还活着吗?对于应用程序来说可能至关重要。例如,应用程序函数接收进程的 PID 作为参数。在实际使用提供的 PID 之前(也许发送一个信号),验证一下进程是否有效是个好主意(如果它已经死了或 PID 无效怎么办?)。
kill(2)系统调用在这方面帮助我们:kill的第二个参数是要发送的信号;使用值0(回想一下没有编号为 0 的信号)验证第一个参数:PID。具体是如何验证的?如果kill(2)返回失败,要么 PID 无效,要么我们没有权限发送信号给进程(或进程组)。
以下伪代码演示了这一点:
static int app_func_A(int work, pid_t target)
{
[...]
if (kill(target, 0) < 0)
<handle it>
return -1;
*[...it's fine; do the work on 'target'...]*
}
信号作为 IPC
我们了解到,现代操作系统(如 Linux)使用的虚拟内存架构的一个基本副作用是,进程只能访问其自己的虚拟地址空间(VAS)内存;而且只能访问有效映射的内存。
实际上,这意味着一个进程不能读取或写入任何其他进程的 VAS。是的;但是,那么你如何与其他进程通信呢?这种情况在许多多进程应用程序中非常关键。
简短的回答:IPC 机制。Linux 操作系统有几种;在这里,我们使用其中一种:信号。
粗糙的 IPC
想想看,这很简单:进程 A 和 B 是多进程应用程序的一部分。现在进程 A 想要通知进程 B 它已经完成了一些工作;在收到这个信息后,我们期望进程 B 做出相同的确认。
我们可以通过信号设计一个简单的 IPC 方案,如下所示:
-
进程 A 正在执行它的工作。
-
进程 B 正在执行它的工作(它们当然是并行运行的)。
-
进程 A 达到一个里程碑;它通过发送
SIGUSR1(通过kill(2))通知进程 B。 -
捕获了信号后,进程 B 进入其信号处理程序并根据需要验证事物。
-
它通过发送进程 A 来确认消息,比如说,
SIGUSR2(通过kill(2))。 -
捕获了信号后,进程 A 进入其信号处理程序,理解来自 B 的确认已经收到,生活继续。
(读者可以尝试这个作为一个小练习。)
然而,我们应该意识到一个重要的细节:IPC 意味着能够向另一个进程发送数据。然而,在上面,我们无法传输或接收任何数据;只是我们可以通过信号进行通信的事实(嗯,你可以争论信号编号本身就是数据;在有限的意义上是真的)。因此,我们将这视为一种粗糙的 IPC 机制。
更好的 IPC - 发送数据项
这让我们来到下一个有趣的事实:通过信号是可以发送数据量子——一小段数据——的。要看如何做到这一点,让我们重新审视我们在本章前面学习的强大的siginfo_t结构。为了让信号处理程序接收到指针,回想一下我们在调用sigaction(2)时使用SA_SIGINFO标志。
回想一下,在struct siginfo_t中,前三个成员是简单的整数,第四个成员是结构的联合体,有七个——其中只有一个会在运行时实例化;实例化的取决于处理的信号!
为了帮助我们回忆,这是siginfo_t结构的初始部分:
typedef struct {
int si_signo; /* Signal number. */
int si_code;
int si_errno; /* If non-zero, an errno value associated with
this signal, as defined in <errno.h>. */
union
{
int _pad[__SI_PAD_SIZE];
/* kill(). */
struct
{
__pid_t si_pid; /* Sending process ID. */
__uid_t si_uid; /* Real user ID of sending process. */
} _kill;
[...]
在结构的联合体中,我们现在感兴趣的结构是处理实时信号的结构——这个:
[...]
/* POSIX.1b signals. */
struct
{
__pid_t si_pid; /* Sending process ID. */
__uid_t si_uid; /* Real user ID of sending process. */
__sigval_t si_sigval; /* Signal value. */
} _rt;
[...]
因此,很简单:如果我们捕获了一些实时信号并使用SA_SIGINFO,我们将能够检索到这个结构的指针;前两个成员显示了发送进程的 PID 和 RUID。这本身就是有价值的信息!
第三个成员,sigval_t,是关键(在 Ubuntu 上是/usr/include/asm-generic/siginfo.h,在 Fedora 上是/usr/include/bits/types/__sigval_t.h):
union __sigval
{
int __sival_int;
void *__sival_ptr;
};
typedef union __sigval __sigval_t;
注意,sigval_t本身是两个成员的联合:一个整数和一个指针!我们知道联合在运行时只能有一个成员实例化;所以这里的问题是:发送进程用数据填充前面的成员之一,然后向接收进程发送实时信号。接收方可以通过适当地取消引用前面的联合来提取发送的数据量。这样,我们就能够在进程之间发送数据;数据实际上是搭载在实时信号上的!非常酷。
但是想一想:我们只能使用其中一个成员来携带我们的数据,要么是整数int sival_int,要么是void * sival_ptr指针。应该使用哪一个?回想一下我们在第十章中学到的内容是很有启发性的,即进程创建:进程中的每个地址都是虚拟地址;也就是说,我的虚拟地址 X 可能指向的并不是你的虚拟地址 X 所指向的相同的物理内存。换句话说,尝试通过指针来传递数据,实际上只是一个虚拟地址,可能不会像预期的那样有效。(如果你对此不确定,我们建议重新阅读第十章中的malloc和The fork部分,进程创建。)
总之,使用整数来保存和传递数据给我们的对等进程通常是一个更好的主意。事实上,C 程序员知道如何从内存中提取每一个比特;你总是可以将整数视为位掩码,并传递更多的信息!
此外,C 库提供了一个辅助例程,可以很容易地发送一个带有嵌入数据的信号,sigqueue(3) API。它的签名:
#include <signal.h>
int sigqueue(pid_t pid, int sig, const union sigval value);
前两个参数很明显:要发送信号sig的进程;第三个参数value是讨论过的联合。
让我们试一试;我们编写一个小的生产者-消费者类型的应用程序。我们在后台运行消费者进程;它轮询,等待生产者发送一些数据。(正如你可能猜到的那样,轮询并不理想;在多线程主题中,我们将介绍更好的方法;但现在,我们只是简单地轮询。)当接收者检测到已经发送数据给它时,它会显示所有相关的细节。
首先,一个示例运行:首先,我们在后台运行消费者(接收者)进程(ch12/sigq_ipc/sigq_recv.c):
$ ./sigq_recv & [1] 13818
./sigq_recv: Hey, consumer here [13818]! Awaiting data from producer
(will poll every 3s ...)
$
接下来,我们运行生产者(ch12/sigq_ipc/sigq_sender.c),向消费者发送一个数据项:
$ ./sigq_sender
Usage: ./sigq_sender pid-to-send-to value-to-send[int]
$ ./sigq_sender $(pgrep sigq_recv) 42
Producer [13823]: sent signal 34 to PID 13818 with data item 42
$nanosleep interrupted: rem time: 0000002.705461411
消费者处理信号,理解数据已经到达,并在下一个轮询周期打印出详细信息:
Consumer [13818] received data @ Tue Jun 5 10:20:33 2018
:
signal # : 34
Producer: PID : 1000
UID : 1000 data item : 42
为了可读性,下面只显示了源代码的关键部分;要查看完整的源代码,构建并运行它,整个树都可以从 GitHub 上克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
这里是接收者:ch12/sigq_ipc/sigq_recv.c:main()函数:
#define SIG_COMM SIGRTMIN
#define SLP_SEC 3
[...]
static volatile sig_atomic_t data_recvd=0;
[...]
int main(int argc, char **argv)
{
struct sigaction act;
act.sa_sigaction = read_msg;
sigfillset(&act.sa_mask); /* disallow all while handling */
act.sa_flags = SA_SIGINFO | SA_RESTART;
if (sigaction(SIG_COMM, &act, 0) == -1)
FATAL("sigaction failure");
printf("%s: Hey, consumer here [%d]! Awaiting data from producer\n"
"(will poll every %ds ...)\n",
argv[0], getpid(), SLP_SEC);
/* Poll ... not the best way, but just for this demo... */
while(1) {
r_sleep(SLP_SEC);
if (data_recvd) {
display_recv_data();
data_recvd = 0;
}
}
exit(EXIT_SUCCESS);
}
我们在实时信号到达时进行轮询,每次循环迭代都在循环中睡眠三秒钟;轮询实际上并不是编码的最佳方式;但现在,我们只是保持简单并这样做(在第十四章中,使用 Pthreads 进行多线程编程第 I 部分-基础知识和第十五章中,使用 Pthreads 进行多线程编程第 II 部分-同步,我们将介绍其他有效的同步数据值的方法)。
正如在正确休眠部分中所解释的,我们更喜欢使用我们自己的包装器而不是nanosleep(2),我们的r_sleep()函数,保持休眠安全。
与此同时,发送者代码的一部分:ch12/sigq_ipc/sigq_sender.c:send_peer():
static int send_peer(pid_t target, int sig, int val)
{
union sigval sv;
if (kill(target, 0) < 0)
return -1;
sv.sival_int = val;
if (sigqueue(target, sig, sv) == -1)
return -2;
return 0;
}
这个函数执行检查目标进程是否确实存活的工作,如果是的话,通过有用的sigqueue(3)库 API 向其发送实时信号。一个关键点:我们将要发送的数据包装或嵌入到sigval联合体中,作为一个整数值。
回到接收者:当它确实接收到实时信号时,它指定的信号处理程序代码read_msg()将运行:
[...]
typedef struct {
time_t timestamp;
int signum;
pid_t sender_pid;
uid_t sender_uid;
int data;
} rcv_data_t;
static rcv_data_t recv_data;
[...]
/*
* read_msg
* Signal handler for SIG_COMM.
* The signal's receipt implies a producer has sent us data;
* read and place the details in the rcv_data_t structure.
* For reentrant-safety, all signals are masked while this handler runs.
*/
static void read_msg(int signum, siginfo_t *si, void *ctx)
{
time_t tm;
if (time(&tm) < 0)
WARN("time(2) failed\n");
recv_data.timestamp = tm;
recv_data.signum = signum;
recv_data.sender_pid = si->si_pid;
recv_data.sender_uid = si->si_uid;
recv_data.data = si->si_value.sival_int;
data_recvd = 1;
}
我们更新一个结构来保存数据(和元数据),使我们能够在需要时方便地打印它。
侧边栏 - LTTng
作为一个非常有趣的旁注,如果能够实际追踪发送者和接收者进程在执行时的流程,那不是很棒吗?嗯,Linux 提供了几种工具来做到这一点。其中比较复杂的是一个名为Linux Tracing Toolkit next generation(LTTng)的软件。
LTTng 真的很强大;一旦设置好,它就有能力跟踪内核和用户空间(尽管跟踪用户空间需要应用程序开发人员明确地对其代码进行仪器化)。嗯,您的作者使用 LTTng 来对系统(内核空间)进行跟踪,而前面的进程运行;LTTng 完成了它的工作,捕获了跟踪数据(以 CTF 格式)。
然后,使用了出色的Trace Compass GUI 应用程序以有意义的方式显示和解释跟踪会话;以下屏幕截图显示了一个示例;您可以看到发送者通过sigqueue(3)库 API 向接收进程发送信号的时间点,正如您所看到的,这被转换为rt_sigqueueinfo(2)系统调用(其在内核中的入口点显示为syscall_entry_rt_sigqueueinfo事件)。
接下来,接收进程(这里是sigq_trc_recv)接收(然后处理)了信号:

(作为一个有趣的事情:计算发送实时信号和接收信号之间的时间差,分别用紫色和红色标记。大约是 300 毫秒(微秒)。)
LTTng 的细节不在本书的范围之内;请参阅 GitHub 存储库上的进一步阅读部分。
为了完整起见,我们还注意到以下发送信号的 API:
-
pthread_kill(3): 一个 API,用于向同一进程中的特定线程发送信号 -
tgkill(2): 一个 API,用于向给定线程组中的特定线程发送信号 -
tkill(2): tgkill 的一个已弃用的前身
现在让我们暂时忽略这些;这些 API 在后面的第十四章中,即本书中的使用 Pthreads 进行多线程编程的第一部分 - 基础知识中,将在多线程的上下文中变得更加相关。
替代的信号处理技术
到目前为止,在前一章以及这一章关于信号的内容中,我们已经看到并学会了使用几种技术来异步捕获和处理信号。基本思想是:进程正在忙于执行其工作,运行其业务逻辑;突然收到一个信号;尽管如此,进程必须处理它。我们详细地看到了如何利用强大的sigaction(2)系统调用来做到这一点。
现在,我们以一种不同的方式来看待信号处理:同步处理信号,也就是说,如何让进程(或线程)等待(阻塞)信号并在其到达时处理它们。
即将到来的关于多线程的章节将提供一些相同用例。
同步等待信号
乍一看,以及传统的信号传递方式,似乎信号是异步的,为什么会有人尝试同步阻塞信号的传递呢?事实上:在大型项目中执行健壮的信号处理是一件难以正确和一致地做到的事情。许多复杂性源于信号异步安全问题;我们不允许在信号处理程序中使用任何 API;只有相对较小的 API 子集被认为是异步信号安全的,并且可以使用。这在大型程序中带来了重大障碍,当然,有时程序员会无意中引起缺陷(错误)(而且,这些缺陷在测试期间很难捕捉到)。
当消除具有信号安全要求设计的整个异步信号处理程序时,这些信号处理困难几乎消失了。如何做到?通过对信号进行同步阻塞,当信号到达时,立即处理它们。
因此,本节的目标是教会初学的系统程序员这些重要的概念(以及它们的 API);学会使用这些 API 可以显著减少异常和错误。
在 Linux 操作系统上存在许多有用的机制来执行同步信号处理;让我们从简单但有用的pause(2)系统调用开始。
请暂停
pause是一个非常好的阻塞调用的例子;当进程调用此 API 时,它会阻塞,也就是说,它会进入睡眠状态等待事件的发生;事件:任何信号到达。一旦信号到达,pause就会解除阻塞,执行继续。当然,传递致命信号将导致毫无防备的进程死亡:
include <unistd.h>
int pause(void);
在整个过程中,我们一直强调检查系统调用的失败情况-1是非常重要的:这是一种始终要遵循的最佳实践。pause(2)提出了一个有趣的异常情况:它似乎是唯一一个始终返回-1并且将errno设置为值EINTR的系统调用(中断当然是信号)。
因此,我们经常将pause编码如下:
(void)pause();
将类型转换为void是为了通知编译器和静态分析器等工具,我们并不真的关心来自pause的返回值。
永远等待或直到收到信号
通常,人们希望永远等待,或者直到收到信号。一种方法是非常简单但非常糟糕的,非常昂贵的在 CPU 上旋转的代码,比如:
while (1);
天啊!那太丑陋了:请不要编写这样的代码!
略微好一些,但仍然相当偏离的是:
while (1)
sleep(1);
pause可以有效且高效地设置一个有用的永远等待或直到我收到任何信号的语义,如下所示:
while (1)
(void)pause();
这种语义对于这种永远等待或直到我收到任何信号的情况非常有用,因为它很廉价(几乎没有 CPU 使用率,因为pause(2)会立即使调用者进入睡眠状态),并且只有在信号到达时才解除阻塞。然后,整个情景重复(当然是由于无限循环)。
通过sigwait*API 同步阻塞信号
接下来,我们简要介绍一组相关函数,即sigwait*API;它们如下:
-
sigwait(3) -
sigwaitinfo(2) -
sigtimedwait(2)
所有这些 API 都允许进程(或线程)在收到一个或多个信号时阻塞(等待)。
sigwait 库 API
让我们从sigwait(3)开始:
include <signal.h>
int sigwait(const sigset_t *set, int *sig);
sigwait(3)库 API 允许进程(或线程)阻塞,等待,直到信号集set中的任何信号待传递给它。一旦信号到达,sigwait就会解除阻塞;到达的特定信号及其整数值将放置在值-结果的第二个参数sig中。在底层,sigwait从进程(或线程)的挂起掩码中删除刚刚传递的信号。
因此,sigwait(3)相对于pause(2)具有以下优势:
-
您可以等待将特定信号传递给进程
-
当其中一个信号被传递时,它的值是已知的
sigwait(3)的返回值在成功时为0,在错误时为正值(请注意,它是一个库 API,errno不受影响)。 (在内部,sigwait(3)是通过sigtimedwait(2) API 实现的。)
然而,事情并不总是像乍一看那么简单。事实是有一些重要的要点需要考虑:
-
如果等待的信号没有被调用进程首先阻塞,就会设置一个风险情况,称为竞争。(从技术上讲,这是因为在信号传递到进程和
sigwait调用初始化之间存在一个机会窗口)。一旦运行,sigwait将原子地解除信号阻塞,允许它们被传递给调用进程。 -
如果一个信号(在我们定义的信号集中)也通过
sigaction(2)或signal(2)API 以及sigwait(3)API 被捕获,那么在这种情况下,POSIX 标准规定由实现决定如何处理传递的信号;Linux 似乎更倾向于通过sigwait(3)处理信号。(这是有道理的:如果一个进程发出sigwaitAPI,该进程会阻塞信号。如果信号变为挂起状态(意味着它刚刚被传递到进程),则sigwaitAPI 会吸收或消耗信号:它现在不再挂起传递到进程,因此不能通过sigaction(2)或signal(3)API 设置的信号处理程序捕获。)
为了测试这一点,我们编写了一个小应用程序ch12/sigwt/sigwt.c以及一个 shell 脚本ch12/sigwt/bombard.sh来对其进行所有信号的轰炸。(读者将像往常一样在书的 GitHub 存储库中找到代码;这次,我们留给读者来研究源代码并进行实验。)以下是一些示例运行:
在一个终端窗口中,我们按以下方式运行我们的sigwt程序:
$ ./sigwt
Usage: ./sigwt 0|1
0 => block All signals and sigwait for them
1 => block all signals except the SIGFPE and SIGSEGV and sigwait
(further, we setup an async handler for the SIGFPE, not the SIGSEGV)
$ ./sigwt 0
./sigwt: All signals blocked (and only SIGFPE caught w/ sigaction)
[SigBlk: 1 2 3 4 5 6 7 8 10 11 12 13 14 15 16 17 18 20 21 22 23 24 25 26 27 28 29 30 31 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 ]
./sigwt: waiting upon signals now ...
请注意,我们首先通过sigprocmask(2)阻塞了所有信号;我们调用我们的通用common.c:show_blocked_signals()函数来显示进程信号掩码中当前阻塞的所有信号;如预期的那样,所有信号都被阻塞,除了明显的 9、19、32 和 33 号信号(为什么?)。请记住,一旦运行,sigwait(3)将原子地解除信号阻塞,允许它们被传递给调用者。
在另一个终端窗口中,运行 shell 脚本;脚本的工作很简单:它发送(通过kill(1))从 1 到 64 的每个信号,除了SIGKILL(9)、SIGSTOP(19)、32 和 33——这两个 RT 信号保留供 pthread 框架使用:
$ ./bombard.sh $(pgrep sigwt) 1
Sending 1 instances each of ALL signals to process 2705
1 2 3 4 5 6 7 8 10 11 12 13 14 15 16 17 18 20 21 22 23 24 25 26 27 28 29 30 31 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
$
在原始窗口中,我们观察到输出:
Received signal# 1
Received signal# 2
Received signal# 3
Received signal# 4
Received signal# 5
Received signal# 6
Received signal# 7
Received signal# 8
Received signal# 10
Received signal# 11
[...]
Received signal# 17
Received signal# 18
Received signal# 20
Received signal# 21
[...]
Received signal# 31
Received signal# 34
Received signal# 35
Received signal# 36
Received signal# 37
[...]
Received signal# 64
所有传递的信号都通过sigwait进行了处理!包括SIGFPE(#8)和SIGSEGV(#11)。这是因为它们是由另一个进程(shell 脚本)同步发送的,而不是由内核发送的。
快速的pkill(1)会终止sigwt进程(如果需要提醒:SIGKILL 和 SIGSTOP 不能被屏蔽):
pkill -SIGKILL sigwt
现在进行下一个测试案例,使用选项1运行它:
$ ./sigwt
Usage: ./sigwt 0|1
0 => block All signals and sigwait for them
1 => block all signals except the SIGFPE and SIGSEGV and sigwait
(further, we setup an async handler for the SIGFPE, not the SIGSEGV) $ ./sigwt 1
./sigwt: removing SIGFPE and SIGSEGV from the signal mask...
./sigwt: all signals except SIGFPE and SIGSEGV blocked
[SigBlk: 1 2 3 4 5 6 7 10 12 13 14 15 16 17 18 20 21 22 23 24 25 26 27 28 29 30 31 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 ]
./sigwt: waiting upon signals now ...
注意信号编号 8(SIGFPE)和 11(SIGSEGV)现在被阻塞,而其他信号都不被阻塞(除了通常的 9、19、32、33)。请记住,一旦运行,sigwait(3)将原子地解除信号阻塞,允许它们被传递给调用者。
在另一个终端窗口中,运行 shell 脚本:
$ ./bombard.sh $(pgrep sigwt) 1
Sending 1 instances each of ALL signals to process 13759
1 2 3 4 5 6 7 8 10 11 ./bombard.sh: line 16: kill: (13759) - No such process
bombard.sh: "kill -12 13759" failed, loop count=1
$
在原始窗口中,我们观察到输出:
Received signal# 1
Received signal# 2
Received signal# 3
Received signal# 4
Received signal# 5
Received signal# 6
Received signal# 7
*** siggy: handled SIGFPE (8) ***
Received signal# 10
Segmentation fault (core dumped)
$
当我们捕获SIGFPE(通过sigaction(2))时,它被处理;然而,未捕获的SIGSEGV当然会导致进程异常死亡。一点也不愉快。
对代码进行一些小的调整揭示了一个有趣的方面;原始代码片段如下:
[...]
if (atoi(argv[1]) == 1) {
/* IMP: unblocking signals here removes them from the influence of
* the sigwait* APIs; this is *required* for correctly handling
* fatal signals from the kernel.
*/
printf("%s: removing SIGFPE and SIGSEGV from the signal mask...\n", argv[0]);
sigdelset(&set, SIGFPE);
#if 1
sigdelset(&set, SIGSEGV);
#endif
[...]
如果我们通过将前面的#if 1更改为#if 0来有效地阻止SIGSEGV会发生什么?让我们这样做,重新构建并重试:
[...]
Received signal# 1
Received signal# 2
Received signal# 3
Received signal# 4
Received signal# 5
Received signal# 6
Received signal# 7
*** siggy: handled SIGFPE (8) ***
Received signal# 10
Received signal# 11
Received signal# 12
[...]
这次SIGSEGV通过sigwait进行了处理!确实;但只是因为它是由进程人为生成的,而不是由操作系统发送的。
因此,像往常一样,还有更多内容:信号处理的具体方式由以下因素决定:
-
在调用
sigmask(或其变体)之前,进程是否阻塞信号 -
关于致命信号(如
SIGILL,SIGFPE,SIGSEGV,SIGBUS等),信号是如何生成的很重要:人为地,通过进程(kill(2))或实际由内核生成(由于某种错误) -
我们发现以下内容:
-
如果信号在调用
sigwait之前被进程阻塞,那么如果信号是通过kill(2)(或变体)人为地传递的,sigwait将在信号传递时解除阻塞,应用程序开发人员可以处理该信号。 -
然而,如果致命信号是由于内核的错误而通过操作系统传递的,那么无论进程是否阻塞它,都会发生默认操作,突然(和可耻地)终止进程!这可能不是人们想要的;因此,我们得出结论,最好通过通常的异步
sigaction(2)风格而不是通过sigwait(或其变体)来捕获前述的致命信号。
sigwaitinfo和sigtimedwait系统调用
sigwaitinfo(2)系统调用类似于 sigwait:提供一组要注意的信号,该函数将调用者置于休眠状态,直到其中任何一个信号(在集合中)挂起。这是它们的原型:
#include <signal.h>
int sigwaitinfo(const sigset_t *set, siginfo_t *info);
int sigtimedwait(const sigset_t *set, siginfo_t *info,
const struct timespec *timeout);
就返回而言,sigwait API 能够为我们提供传递给调用进程的信号编号。但是,请记住,sigaction(2) API 的一个更强大的特性是——能够在siginfo_t数据结构中返回有价值的诊断和其他信息。这正是sigwaitinfo(2)系统调用提供的功能!(我们在详细介绍SA_SIGINFO的信息部分中已经介绍了siginfo_t结构以及您可以从中解释的内容。)
sigtimedwait(2)呢?嗯,很明显;它与sigwaitinfo(2) API 相同,只是多了一个参数——超时值。因此,该函数将阻塞调用者,直到集合中的一个信号挂起,或超时到期(以先发生者为准)。超时是通过一个简单的timespec结构指定的,它允许提供秒和纳秒的时间:
struct timespec {
long tv_sec; /* seconds */
long tv_nsec; /* nanoseconds */
}
如果结构被 memset 为零,sigtimedwait(2)将立即返回,要么返回有关挂起信号的信息,要么返回错误值。sigwaitinfo(2)和sigtimedwait(2) API 在成功时返回实际的信号编号,失败时返回-1,并适当设置errno。
一个重要的要点(之前已经提到过,但很关键):sigwait、sigwaitinfo或sigtimedwait API 都不能等待内核同步生成的信号;通常是指示某种失败的信号,如SIGFPE和SIGSEGV。这些只能以正常的异步方式捕获——通过signal(2)或sigaction(2)。对于这种情况,正如我们反复展示的那样,sigaction(2)系统调用将是更好的选择。
signalfd(2) API
读者会回忆起,在第一章中,Linux 系统架构,在标题为Unix 哲学的要点的部分中,我们强调了 Unix 哲学的一个基石是:
在 Unix 上,一切都是一个进程;如果不是进程,就是一个文件。
经验丰富的 Unix 和 Linux 开发人员非常习惯将东西抽象为文件的概念;这包括设备、管道和套接字。为什么不包括信号呢?
这正是signalfd(2)系统调用的理念;使用signalfd,您可以创建一个文件描述符并将其与信号集关联起来。现在,应用程序员可以自由地使用各种熟悉的基于文件的 API 来监视信号 - 其中包括read(2)、select(2)和poll(2)(及其变体),以及close(2)。
与我们讨论过的sigwait*API 系列类似,signalfd是另一种让进程(或线程)同步阻塞信号的方法。
你如何使用signalfd(2) API?它的签名如下:
#include <sys/signalfd.h>
int signalfd(int fd, const sigset_t *mask, int flags);
第一个参数fd,要么是现有的信号描述符,要么是值-1。当传递-1 时,系统调用会创建一个新的信号文件描述符(显然,我们应该首先以这种方式调用它)。第二个参数mask是信号mask - 这个信号描述符将与之关联的信号集。与sigwait*API 一样,人们期望通过sigprocmask(2)来阻止这些信号。
重要的是要理解,signalfd(2)系统调用本身不是一个阻塞调用。阻塞行为只有在调用与文件相关的 API 时才会发生,比如read(2)、select(2)或poll(2)。只有在信号集中的一个信号被传递给调用进程(或者已经挂起在它上面)时,文件相关的 API 才会返回。
signalfd(2)的第三个参数是一个flags值 - 一种改变默认行为的方式。只有从 Linux 内核版本 2.6.27 开始,flags才能正常工作;可能的值如下:
-
SFD_NONBLOCK:在信号描述符上使用非阻塞 I/O 语义(相当于fcntl(2)的O_NONBLOCK)。 -
SFD_CLOEXEC:如果进程通过exec系列 APIs 执行另一个进程,确保关闭信号描述符(这对安全性很重要,否则,所有前任进程的打开文件都会在执行操作中继承到后继进程;相当于open(2)的FD_CLOEXEC)。
就返回值而言,signalfd(2) API 在成功时返回新创建的信号描述符;当然,如果第一个参数是-1 的话。如果不是,那么它应该是一个已经存在的信号描述符;然后,成功时返回这个值。失败时,像往常一样,返回-1,并且errno变量反映了诊断信息。
在这里,我们将限制使用signalfd(2)来通过熟悉的read(2)系统调用读取信号信息的讨论;这次是在signalfdAPI 返回的信号描述符上。
read(2)的工作原理简而言之(read(2)在附录 A中有详细介绍,文件 I/O 基础):我们将要读取的文件(在本例中是信号)描述符作为第一个参数,刚读取的数据的缓冲区作为第二个参数,要读取的最大字节数作为第三个参数:
ssize_t read(int fd, void *buf, size_t count);
这些是常见的typedefs:size_t本质上是一个无符号长整型
ssize_t本质上是一个有符号长整型
这里的第二个参数很特别:指向一个或多个signalfd_siginfo类型的结构的指针。struct signalfd_siginfo与我们在前面的siginfo_t 结构部分中详细介绍的siginfo_t非常类似。有关到达的信号的详细信息将在这里填充。
我们将有兴趣的读者从signalfd(2)的 man 页面中获取signalfd_siginfo数据结构的详细信息:linux.die.net/man/2/signalfd。该页面还包含一个小的示例程序。
read 的第三个参数,大小,在这种情况下必须至少是 sizeof(signalfd_siginfo)字节。
总结
在本章中,读者已经了解了一些关于信号的高级细节:如何通过适当的致命信号捕获来处理崩溃进程,以及在处理程序中获取关键细节,包括 CPU 寄存器等。通过学习解释强大的siginfo_t数据结构来实现这一点。此外,还涵盖了处理errno变量时的竞争情况,以及学习如何正确休眠。
实时信号及其与常规 Unix 信号的区别已经涵盖;然后,有关向其他进程发送信号的不同方式的部分。最后,我们看了一下通过同步阻塞一组信号来处理信号(使用各种 API)。
在下一章第十三章 定时器中,我们将利用我们在这里(以及前一章第十一章 信号-第一部分中获得的知识,并学习如何有效地设置和使用定时器。
第十三章:计时器
计时器使我们能够设置一个工件,当指定的时间到期时,操作系统会通知我们——这是一个普遍的应用程序(甚至是内核)特性。当然,计时器通常只有在与应用程序逻辑并行运行时才有用;这种异步通知行为是通过不同的方式实现的,很多时候是通过内核发送相关进程信号来实现的。
在本章中,我们将探讨 Linux 上用于设置和使用计时器的可用接口。这些接口分为两大类——较旧的 API(alarm(2)、[get|set]itimer(2))和闪亮的新 POSIX API(timer_create(2)、timer_[set|get]time(2)等)。当然,由于信号与计时器一起被广泛使用,我们也会使用信号接口。
我们还想指出,由于计时器的固有动态特性,静态地查看我们书中示例程序的输出是不够的;像往常一样,我们强烈建议读者克隆本书的 GitHub 存储库并自己尝试代码。
在本章中,读者将学习使用 Linux 内核提供的各种计时器接口(API)。我们首先介绍较旧的接口,尽管它们有一些限制,但在系统软件中仍然被广泛使用,因为需要。我们编写了一个简单的命令行界面(CLI)-仅数字时钟程序,并使用这些 API 进行分析。然后我们将读者引入更近期和功能强大的 POSIX 计时器 API 集。展示和研究了两个非常有趣的示例程序——一个“你有多快能反应”的游戏和一个跑步间隔计时器应用程序。最后简要提到了通过文件抽象使用计时器 API 以及看门狗计时器是什么。
较旧的接口
如前所述,较旧的接口包括以下内容:
-
alarm(2)系统调用 -
间隔计时器
[get|set]itimer(2)系统调用 API
让我们从它们中的第一个开始。
老式的闹钟
alarm(2)系统调用允许进程设置一个简单的超时机制;其签名如下:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
这是相当不言自明的。让我们举一个简单的例子:一个进程想要设置一个在三秒后到期的计时器,所以alarm(3)基本上就是用来做这个的代码。
在上述代码中到底发生了什么?在发出警报系统调用后三秒钟,也就是在定时器被装备后,内核将向进程发送信号SIGALRM。
SIGALRM(在 x86 上是信号#14)的默认操作是终止进程。
因此,我们期望开发人员捕获信号(最好通过sigaction(2)系统调用,如前面的第十一章和第十二章中所深入讨论的那样)。
如果传递给alarm的参数是0,则任何待处理的alarm(2)都将被取消(实际上,当调用alarmAPI 时,无论如何都会发生这种情况)。
请注意,alarmAPI 不同寻常地返回一个无符号整数(因此不能返回-1,这是通常的失败情况)。相反,它返回任何先前编程的超时秒数,如果没有挂起的超时,则返回零。
接下来是一个简单的程序(ch13/alarm1.c),演示了alarm(2)的基本用法;参数指定了超时的秒数。
为了可读性,以下仅显示源代码的关键部分;要查看完整的源代码、构建它并运行它,可以从 GitHub 克隆整个树:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
信号捕获和计时器装备代码如下所示:
[...]
/* Init sigaction to defaults via the memset,
* setup 'sig_handler' as the signal handler function,
* trap just the SIGALRM signal.
*/
memset(&act, 0, sizeof(act));
act.sa_handler = sig_handler;
if (sigaction(SIGALRM, &act, 0) < 0)
FATAL("sigaction on SIGALRM failed");
alarm(n);
printf("A timeout for %ds has been armed...\n", n);
pause(); /* wait for the signal ... */
内核向进程分发SIGALRM信号后会发生什么;也就是说,一旦定时器超时了?信号处理程序当然会运行。在这里:
static void sig_handler(int signum)
{
const char *str = " *** Timeout! [SIGALRM received] ***\n";
if (signum != SIGALRM)
return;
if (write(STDOUT_FILENO, str, strlen(str)) < 0)
WARN("write str failed!");
}
这里有一个快速的构建和测试运行:
$ make alarm1
gcc -Wall -UDEBUG -c ../common.c -o common.o
gcc -Wall -UDEBUG -c alarm1.c -o alarm1.o
gcc -Wall -UDEBUG -o alarm1 alarm1.o common.o
$ ./alarm1
Usage: ./alarm1 seconds-to-timeout(>0)
$ ./alarm1 3
A timeout for 3s has been armed...
*** Timeout! [SIGALRM received] *** *<< 3 seconds later! >>*
$
我们现在增强了先前的代码(ch13/alarm1.c)以使超时持续重复(源文件是ch13/alarm2_rep.c);已更改的相关代码片段如下:
[...]
alarm(n);
printf("A timeout for %ds has been armed...\n", n);
/* (Manually) re-invoke the alarm every 'n' seconds */
while (1) {
pause(); /* wait for the signal ... */
alarm(n);
printf(" Timeout for %ds has been (re)armed...\n", n);
}
[...]
虽然这里不适用,但要意识到调用alarm(2)会自动取消任何先前挂起的超时。快速试运行如下:
$ ./alarm2_rep 1
A timeout for 1s has been armed...
*** Timeout! [SIGALRM received] ***
Timeout for 1s has been (re)armed...
*** Timeout! [SIGALRM received] ***
Timeout for 1s has been (re)armed...
*** Timeout! [SIGALRM received] ***
Timeout for 1s has been (re)armed...
*** Timeout! [SIGALRM received] ***
Timeout for 1s has been (re)armed...
^C
$
警报现在重复(在上面的示例运行中,每秒一次)。还要注意我们如何用键盘Ctrl+C(发送SIGINT,因为我们没有捕获它,所以只终止前台进程)杀死进程。
Alarm API - 令人沮丧
现在我们已经看过使用(简单的)alarm(2)API,重要的是要意识到它有一些缺点:
-
非常粗糙的粒度超时(在现代处理器上是非常长的一秒!)
-
不可能同时运行多个超时
-
不可能在以后的时间点查询或修改超时值-尝试这样做将取消它
-
混合以下 API 可能会导致问题/冲突(在下面,后一个 API 可能是使用前一个 API 内部实现的)
-
alarm(2)和setitimer(2) -
alarm(2)和sleep(3) -
总是可能超时发生比预期晚(超时)
随着我们在本章中的进展,我们将发现更强大的函数可以克服大部分这些问题。(嗯,公平地说,可怜的alarm(2)确实有一个好处:对于简单的目的,它非常快速和容易使用!)
间隔计时器
间隔计时器 API 允许进程设置和查询可以按固定时间间隔自动重复的计时器。相关的系统调用如下:
#include <sys/time.h>
int getitimer(int which, struct itimerval *curr_value);
int setitimer(int which, const struct itimerval *new_value,
struct itimerval *old_value);
显然,setitimer(2)用于设置新的计时器;getitimer(2)可用于查询它,并返回剩余时间。
两者的第一个参数都是which,它指定要使用的计时器类型。Linux 允许我们使用三种间隔计时器类型:
-
ITIMER_REAL:使用此计时器类型在实时中倒计时,也称为挂钟时间。计时器到期时,内核会向调用进程发送信号SIGALRM。 -
ITIMER_VIRTUAL:使用此计时器类型在虚拟时间中倒计时;也就是说,只有当调用进程(所有线程)在 CPU 上运行在用户空间时,计时器才会倒计时。计时器到期时,内核会向调用进程发送信号SIGVTALRM。 -
ITIMER_PROF:使用此计时器类型也在虚拟时间中倒计时;这时,当调用进程(所有线程)在 CPU 上运行在用户空间和/或内核空间时,计时器会倒计时。计时器到期时,内核会向调用进程发送信号SIGPROF。
因此,要使计时器在特定时间过期时到期,请使用第一个;可以使用剩下的两种类型来对进程的 CPU 使用情况进行分析。每种类型的计时器一次只能使用一个(后面将详细介绍)。
要检查的下一个参数是itimerval数据结构(以及它的内部timeval结构成员;两者都在time.h头文件中定义):
struct itimerval {
struct timeval it_interval; /* Interval for periodic timer */
struct timeval it_value; /* Time until next expiration */
};
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
(顺便说一句,内部的time_t和suseconds_ttypedefs 都转换为长整型值。)
正如我们所看到的,这是setitimer(2)的第二个参数,它是指向名为new_value的结构体itimerval的指针,用于指定新计时器的到期时间,例如:
-
在
it_value结构成员中,放置初始超时值。该值随着计时器的运行而减少,并且在某个时候将变为零;在这一点上,与计时器类型对应的适当信号将被传递给调用进程。 -
在上一步之后,将检查
it_interval结构成员。如果它不为零,则该值将被复制到it_value结构中,导致定时器有效地自动重置并再次运行该时间量;换句话说,这就是 API 如何实现间隔定时器角色的方式。
此外,明确指出,时间到期以秒:微秒表示。
例如,如果我们想要每秒重复(间隔)超时,我们需要将结构初始化如下:
struct itimerval mytimer;
memset(&mytimer, 0, sizeof(struct itimerval));
mytimer.it_value.tv_sec = 1;
mytimer.it_interval.tv_sec = 1;
setitimer(ITIMER_REAL, &mytimer, 0);
(出于清晰起见,错误检查代码未在上述代码中显示。)这正是在接下来的简单数字时钟演示程序中完成的。
存在一些特殊情况:
-
要取消(或解除武装)定时器,请将
it_timer结构的两个字段都设置为零,并调用setitimer(2)API。 -
要创建一个单次定时器——即,到期后仅一次——将
it_interval结构的两个字段都初始化为零,然后调用setitimer(2)API。 -
如果
setitimer(2)的第三个参数为非 NULL,则将在此处返回先前的定时器值(就好像调用了getitmer(2)API 一样)。
与通常一样,这对系统调用在成功时返回0,在失败时返回-1(并适当设置errno)。
由于每种类型的定时器到期时会生成一个信号,因此在给定进程中只能同时运行每种类型的定时器的一个实例。如果我们尝试设置多个相同类型的定时器(例如,ITIMER_REAL),则总是可能会将多个相同信号的实例(在本例中为SIGALRM)同时传递给进程——并且传递给相同的处理程序例程。正如我们在第十一章和第十二章中学到的那样,信号-第一部分和信号-第二部分,常规的 Unix 信号不能排队,因此信号实例可能会被丢弃。实际上,在给定进程中最好(也最安全)同时使用每种类型的定时器的一个实例。
下表对比了我们之前看到的简单alarm(2)系统调用 API 和我们刚刚看到的更强大的[set|get]itimer(2)间隔定时器 API:
| 特性 | 简单定时器 [alarm(2)] |
间隔定时器 [setitimer(2), getitimer(2)] |
|---|---|---|
| 粒度(分辨率) | 非常粗糙;1 秒 | 很好的粒度;理论上为 1 微秒(实际上,在 2.6.16 HRT[1]之前通常为毫秒) |
| 查询剩余时间 | 不可能 | 是的,使用getitimer(2) |
| 修改超时 | 不可能 | 是的 |
| 取消超时 | 是 | 是 |
| 自动重复 | 不,但可以手动设置 | 是的 |
| 多个定时器 | 不可能 | 是的,但每个进程最多三个——每种类型一个(实时、虚拟和分析) |
表 1:简单alarm(2)API 和间隔定时器的快速比较
[1] 高分辨率定时器(HRT);从 Linux 2.6.16 开始实现。在 GitHub 存储库的进一步阅读部分中有一篇详细论文的链接。
没有应用的知识有什么用?让我们尝试一下间隔定时器 API。
一个简单的 CLI 数字时钟
我们人类非常习惯看到时钟每秒滴答一次。为什么不编写一个快速的 C 程序,模拟一个(非常简单的命令行)数字时钟,必须每秒显示我们正确的日期和时间!(嗯,个人而言,我更喜欢看到老式的模拟时钟,但是,嘿,这本书并没有涉及执行图形绘图的密切保密的秘密口诀。)
我们实现这一点非常简单,实际上:我们设置一个每秒超时一次的间隔定时器。下面是演示相当强大的setitimer(2)API 的基本用法的程序(ch13/intv_clksimple.c)。
为了可读性,以下仅显示了源代码的关键部分;要查看完整的源代码,构建并运行它,整个树都可以从 GitHub 克隆到这里:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
信号捕获和设置单秒间隔定时器如下所示:
static volatile sig_atomic_t opt;
[...]
int main(int argc, char **argv)
{
struct sigaction act;
struct itimerval mytimer;
[...]
memset(&act, 0, sizeof(act));
act.sa_handler = ticktock;
sigfillset(&act.sa_mask); /* disallow all signals while handling */
/*
* We deliberately do *not* use the SA_RESTART flag;
* if we do so, it's possible that any blocking syscall gets
* auto-restarted. In a timeout context, we don't want that
* to happen - we *expect* a signal to interrupt our blocking
* syscall (in this case, the pause(2)).
* act.sa_flags = SA_RESTART;
*/
if (sigaction(SIGALRM, &act, 0) < 0)
FATAL("sigaction on SIGALRM failed");
/* Setup a single second (repeating) interval timer */
memset(&mytimer, 0, sizeof(struct itimerval));
mytimer.it_value.tv_sec = 1;
mytimer.it_interval.tv_sec = 1;
if (setitimer(ITIMER_REAL, &mytimer, 0) < 0)
FATAL("setitimer failed\n");
while (1)
(void)pause();
请注意,关于为什么我们通常在处理传递超时的信号时不使用SA_RESTART标志的自解释注释。
设置间隔定时器很容易:我们初始化itimerval结构,将其中的秒成员设置为1(微秒部分保持为零),然后发出setitimer(2)系统调用。定时器被装载——它开始倒计时。当一秒钟过去后,内核将向进程传递SIGALRM信号(因为定时器类型是ITIMER_REAL)。信号处理程序ticktock将执行获取并打印当前时间戳的任务(请参见其代码如下)。由于间隔组件被设置为1,定时器将自动重复每秒触发一次。
static void ticktock(int signum)
{
char tmstamp[128];
struct timespec tm;
int myerrno = errno;
/* Query the timestamp ; both clock_gettime(2) and
* ctime_r(3) are reentrant-and-signal-safe */
if (clock_gettime(CLOCK_REALTIME, &tm) < 0)
FATAL("clock_gettime failed\n");
if (ctime_r(&tm.tv_sec, &tmstamp[0]) == NULL)
FATAL("ctime_r failed\n");
if (opt == 0) {
if (write(STDOUT_FILENO, tmstamp, strlen(tmstamp)) < 0)
FATAL("write failed\n");
} else if (opt == 1) {
/* WARNING! Using the printf / fflush here in a signal handler is
* unsafe! We do so for the purposes of this demo app only; do not
* use in production.
*/
tmstamp[strlen(tmstamp) - 1] = '\0';
printf("\r%s", tmstamp);
fflush(stdout);
}
errno = myerrno;
}
前面的信号处理程序例程每秒调用一次(当然,内核在定时器到期时向进程传递SIGALRM信号)。这个例程的工作很明确:它必须查询并打印当前日期时间;也就是时间戳。
获取当前时间
查询当前时间乍一看似乎很简单。许多程序员使用以下 API 序列来实现它:
time(2)
localtime(3)
strftime(3)
我们不这样做。为什么呢?回想一下我们在第十一章《信号-第一部分》中对异步信号安全(可重入)函数的讨论(在“可重入安全性和信号”部分)。在前面提到的三个 API 中,只有time(2) API 被认为是信号安全的;其他两个则不是(也就是说,它们不应该在信号处理程序中使用)。相关的 man 页面(signal-safety(7))证实了这一点。
因此,我们使用文档化的异步信号安全 API——time(2)、clock_gettime(2)和ctime_r(3)——来安全地获取时间戳的角色。以下是它们的快速查看。
clock_gettime(2)系统调用的签名如下:
int clock_gettime(clockid_t clk_id, struct timespec *tp);
第一个参数是要使用的时钟源或时钟类型;事实上,Linux 操作系统(和 glibc)支持许多不同的内置时钟类型;其中包括以下几种:
-
CLOCK_REALTIME:系统范围的挂钟时钟(实时);使用它来查询时间戳。 -
CLOCK_MONOTONIC:单调时钟按一个方向计数(显然是向上;通过时间倒流是一项仍在被疯狂(或者是吗?)的科学家们研究的功能)。它通常计算自系统启动以来经过的时间。 -
CLOCK_BOOTTIME(从 Linux 2.6.39 开始):这与CLOCK_MONOTONIC几乎相同,只是它考虑了系统暂停的时间。 -
CLOCK_PROCESS_CPUTIME_ID:衡量给定进程的所有线程在 CPU 上花费的 CPU 时间(通过 PID;使用clock_getcpuclockid(3)API 来查询)。 -
CLOCK_THREAD_CPUTIME_ID:衡量特定线程在 CPU 上花费的 CPU 时间(使用pthread_getcpuclockid(3)API 来查询)。
还有更多;请参考clock_gettime(2)的 man 页面以获取详细信息。对于我们当前的目的,CLOCK_REALTIME是我们要使用的时钟类型。
clock_gettime(2)的第二个参数是一个值-结果风格的参数;实际上,这是一个返回值。在成功返回时,它将保存timeval结构中的时间戳;该结构在time.h头文件中定义,并以秒和纳秒的形式保存当前时间戳:
struct timespec {
time_t tv_sec; /* seconds */
long tv_nsec; /* nanoseconds */
};
我们对秒数的值将会非常满意。
但是这个秒和纳秒的值是如何解释的呢?在 Unix 宇宙中,这实际上非常常见:Unix 系统将时间存储为自 1970 年 1 月 1 日午夜(00:00)以来经过的秒数——可以将其视为 Unix 的诞生!这个时间值被称为自纪元以来的时间或 Unix 时间。好吧,今天它将是一个相当大的秒数,对吧?那么如何以人类可读的格式表示它呢?我们很高兴你问,因为这正是ctime_r(3)API 的工作:
char *ctime_r(const time_t *timep, char *buf);
第一个参数将是我们从clock_gettime(2)API 返回的time_t成员的指针;再次,第二个参数是一个值结果式的返回——成功完成时,它将保存可读的时间戳!请注意,为缓冲区buf分配内存(并根据需要释放)是应用程序员的工作。在我们的代码中,我们只使用静态分配的本地缓冲区。(当然,我们对所有 API 执行错误检查。)
最后,根据用户传递的opt值,我们要么使用(安全的)write(2)系统调用,要么使用(不安全的!)printf(3)/fflush(3)API 来打印当前时间。
代码printf("\r%s", tmstamp);使用了\r格式的printf(3)——这是回车,它有效地将光标带回到同一行的开头。这给人一种不断更新的时钟的外观。这很好,除了使用printf(3)本身是不安全的!
试运行
这是一个试运行,首先使用信号安全的write(2)方法:
$ ./intv_clksimple
Usage: ./intv_clksimple {0|1}
0 : the Correct way (using write(2) in the signal handler)
1 : the *Wrong* way (using printf(3) in the signal handler) *@your risk*
$ ./intv_clksimple 0
Thu Jun 28 17:52:38 2018
Thu Jun 28 17:52:39 2018
Thu Jun 28 17:52:40 2018
Thu Jun 28 17:52:41 2018
Thu Jun 28 17:52:42 2018
^C
$
现在,这是一个使用信号不安全的printf(3)/fflush(3)方法的试运行:
$ ./intv_clksimple 1
*WARNING* [Using printf in signal handler]
Thu Jun 28 17:54:53 2018^C
$
看起来更好,时间戳不断刷新在同一行上,但是不安全。这本书无法向您展示,亲爱的读者,回车样式的printf("\r...")的愉快效果。在您的 Linux 系统上尝试一下,看看自己。
我们知道在信号处理程序中使用printf(3)和fflush(3)API 是不好的编程实践——它们不是异步信号安全的。
但是,如果低级设计规范要求我们确切使用这些 API 呢?好吧,总是有办法:为什么不重新设计程序,使用其中一个同步阻塞 API 来等待和捕获信号(请记住,当捕获致命信号如SIGILL、SIGFPE、SIGSEGV和SIGBUS时,建议使用通常的异步sigaction(2)API):sigwait(3)、sigwaitinfo(2)、sigtimedwait(2)甚至是signalfd(2)API(我们在第十二章中介绍过,信号-第二部分,通过 sigwait 同步阻塞信号的 API)。我们把这留给读者作为练习。
关于使用分析计时器的一些话
我们已经比较详细地探讨了ITIMER_REAL计时器类型的使用——它按实时倒计时。那么,使用另外两种——ITIMER_VIRTUAL和ITIMER_PROF——计时器呢?嗯,代码风格非常相似;没有什么新东西。对于新手开发人员来说,面临的问题是:信号可能根本不会到达!
让我们看一个使用ITIMER_VIRTUAL计时器的简单代码片段:
static void profalrm(int signum)
{
/* In production, do Not use signal-unsafe APIs like this! */
printf("In %s:%d sig=%d\n", __func__, __LINE__, signum);
}
[...]
// in main() ...
struct sigaction act;
struct itimerval t1;
memset(&act, 0, sizeof(act));
act.sa_handler = profalrm;
sigfillset(&act.sa_mask); /* disallow all signals while handling */
if (sigaction(SIGPROF, &act, 0) < 0)
FATAL("sigaction on SIGALRM failed");
[...]
memset(&t1, 0, sizeof(struct itimerval));
t1.it_value.tv_sec = 1;
t1.it_interval.tv_sec = 1;
if (setitimer(ITIMER_PROF, &t1, 0) < 0)
FATAL("setitimer failed\n");
while (1)
(void)pause();
运行时,没有输出出现——计时器似乎没有工作。
这真的不是这种情况——它正在工作,但问题在于:这个进程仅通过pause(2)来睡眠。在睡眠时,它不在 CPU 上运行;因此,内核几乎没有减少(前面提到的,每秒)间隔计时器!请记住,只有当进程在 CPU 上运行时,ITIMER_VIRTUAL和ITIMER_PROF计时器才会递减(或倒计时)。因此,一秒钟的计时器实际上从未到期,SIGPROF信号也从未发送。
因此,现在解决之前问题的方法变得明显:让我们在程序中引入一些 CPU 处理,并减少超时值。我们可靠的DELAY_LOOP_SILENT宏(参见源文件common.h)使进程在一些愚蠢的逻辑上旋转——重点是它变得 CPU 密集。此外,我们已经将定时器到期减少为每个进程在 CPU 上花费 10 毫秒:
[...]
memset(&t1, 0, sizeof(struct itimerval));
t1.it_value.tv_sec = 0;
t1.it_value.tv_usec = 10000; // 10,000 us = 10 ms
t1.it_interval.tv_sec = 0;
t1.it_interval.tv_usec = 10000; // 10,000 us = 10 ms
if (setitimer(ITIMER_PROF, &t1, 0) < 0)
FATAL("setitimer failed\n");
while (1) {
DELAY_LOOP_SILENT(20);
(void)pause();
}
这一次,运行时,我们看到了这个:
In profalrm:34 sig=27
In profalrm:34 sig=27
In profalrm:34 sig=27
In profalrm:34 sig=27
In profalrm:34 sig=27
...
性能分析定时器确实在工作。
更新的 POSIX(间隔)定时器机制
在本章前面,我们在表 1:简单 alarm(2) API 和间隔定时器的快速比较中看到,尽管间隔定时器[get|set]itimer(2)API 优于简单的alarm(2)API,但它们仍然缺乏重要的现代功能。现代 POSIX(间隔)定时器机制解决了一些缺点,其中一些如下:
-
通过增加纳秒粒度定时器(通过在 2.6.16 Linux 内核中集成的与架构无关的 HRT 机制)来改善分辨率一千倍。
-
一种通用的
sigevent(7)机制——这是一种处理异步事件的方式,例如定时器到期(我们的用例)、AIO 请求完成、消息传递等——来处理定时器到期。我们现在不再被迫将定时器到期与信号机制绑定。 -
重要的是,一个进程(或线程)现在可以设置和管理任意数量的定时器。
-
最终,总是有一个上限:在这种情况下,它是资源限制
RLIMIT_SIGPENDING。(更技术上地说,事实是操作系统为每个创建的定时器分配了一个排队的实时信号,这就是限制。)
这些观点将如下所述,继续阅读。
典型的应用程序工作流程
设置和使用现代 POSIX 定时器的设计方法(和使用的 API)如下;顺序通常如下所示:
-
信号设置。
-
假设正在使用的通知机制是信号,首先通过
sigaction(2)捕获信号。 -
创建和初始化定时器。
-
决定使用哪种时钟类型(或源)来测量经过的时间。
-
决定应用程序使用的定时器到期事件通知机制——通常是使用(通常的)信号还是(新生成的)线程。
-
上述决策通过
timer_create(2)系统调用实现;因此它允许创建一个定时器,当然,我们可以多次调用它来创建多个定时器。 -
使用
timer_settime(2)来装载(或解除装载)特定的定时器。装载定时器意味着有效地启动它运行——倒计时;解除装载定时器则相反——停止它。 -
查询特定定时器的剩余时间(到期时间)(及其间隔设置)使用
timer_gettime(2)。 -
使用
timer_getoverrun(2)检查给定定时器的超时计数。 -
使用
timer_delete(2)删除(显然也是解除装载)定时器。
创建和使用 POSIX(间隔)定时器
如前所述,我们使用强大的timer_create(2)系统调用为调用进程(或线程)创建定时器:
#include <signal.h>
#include <time.h>
int timer_create(clockid_t clockid, struct sigevent *sevp,
timer_t *timerid);
Link with -lrt.
我们必须链接实时(rt)库来使用这个 API。librt库实现了 POSIX.1b 对 POSIX 接口的实时扩展。在 GitHub 存储库的进一步阅读部分中找到librtman 页面的链接。
传递给timer_create(2)的第一个参数通知操作系统要使用的时钟源;我们避免重复这个问题,并参考本章前面涵盖的获取当前时间部分,其中我们列举了 Linux 中常用的几种时钟源。(另外,正如在那里指出的,可以参考clock_gettime(2)的 man 页面获取更多细节。)
传递给timer_create(2)的第二个参数很有趣:它提供了一种通用的方式来指定应用程序使用的计时器到期事件通知机制!为了理解这一点,让我们来看看sigevent结构:
#include <signal.h>
union sigval { /* Data passed with notification */
int sival_int; /* Integer value */
void *sival_ptr; /* Pointer value */
};
struct sigevent {
int sigev_notify; /* Notification method */
int sigev_signo; /* Notification signal */
union sigval sigev_value; /* Data passed with notification */
void (*sigev_notify_function) (union sigval);
/* Function used for thread notification (SIGEV_THREAD) */
void *sigev_notify_attributes; /* Attributes for notification
thread(SIGEV_THREAD) */
pid_t sigev_notify_thread_id;
/* ID of thread to signal (SIGEV_THREAD_ID) */
};
(回想一下,我们已经在第十一章和第十二章中使用了union sigval机制,将一个值传递给信号处理程序。信号-第一部分和信号-第二部分。)
sigev_notify成员的有效值在以下枚举:
通知方法:sigevent.sigev_notify |
意义 |
|---|---|
SIGEV_NONE |
事件到达时不执行任何操作-空通知 |
SIGEV_SIGNAL |
通过发送进程中sigev_signo成员中指定的信号来通知 |
SIGEV_THREAD |
通过调用(实际上是生成)一个(新的)线程,其函数为sigev_notify_function,传递给它的参数是sigev_value,如果sigev_notify_attributes不为 NULL,则应该是新线程的pthread_attr_t结构。(读者们,请注意,我们将在后续章节中详细介绍多线程。) |
SIGEV_THREAD_ID |
仅在 Linux 中使用,用于指定在计时器到期时将运行的内核线程;实际上,只有线程库才使用此功能。 |
表 2:使用 sigevent(7)机制
在第一种情况下,SIGEV_NONE,可以始终通过timer_gettime(2)API 手动检查计时器是否到期。
更有趣和常见的情况是第二种情况,SIGEV_SIGNAL。在这里,信号被传递给计时器已经到期的进程;进程的sigaction(2)处理程序的siginfo_t数据结构被适当地填充;对于我们的用例-使用 POSIX 计时器-如下:
-
si_code(或信号来源字段)设置为值SI_TIMER,表示 POSIX 计时器已经到期(在sigaction的 man 页面中查找其他可能性) -
si_signo设置为信号编号(sigev_signo) -
si_value将是union sigev_value中设置的值
对于我们的目的(至少在本章中),我们只考虑将sigevent通知类型设置为值SIGEV_SIGNAL(因此设置要在sigev_signo成员中传递的信号)。
传递给timer_create(2)的第三个参数,timer_t *timerid,是一个(现在常见的)值结果样式的参数;实际上,它是新创建的 POSIX 计时器的返回 ID!当然,系统调用在失败时返回-1(并相应设置errno),成功时返回0。timerid是计时器的句柄-我们通常将其作为参数传递给后续的 POSIX 计时器 API,以指定要操作的特定计时器。
军备竞赛-启动和停止 POSIX 计时器
如前所述,我们使用timer_settime(2)系统调用来启动或停止计时器:
#include <time.h>
int timer_settime(timer_t timerid, int flags,
const struct itimerspec *new_value,
struct itimerspec *old_value);
Link with -lrt.
由于可以同时运行多个并发的 POSIX 计时器,因此我们需要准确指定我们正在引用的计时器;这是通过第一个参数timer_id完成的,它是计时器的 ID,并且是先前看到的timer_create(2)系统调用的有效返回。
这里使用的重要数据结构是itimerspec;其定义如下:
struct timespec {
time_t tv_sec; /* Seconds */
long tv_nsec; /* Nanoseconds */
};
struct itimerspec {
struct timespec it_interval; /* Timer interval */
struct timespec it_value; /* Initial expiration */
};
因此,很明显:在第三个参数中,指向名为new_value的itimerspec结构的指针:
-
我们可以将时间指定到(理论上的)纳秒分辨率!请注意,时间是相对于由
timer_create(2)API 指定的时钟源来测量的。 -
这提醒我们,可以始终使用
clock_getres(2)API 查询时钟分辨率。 -
关于初始化
it_value(timespec结构): -
将其设置为非零值以指定初始计时器到期值。
-
将其设置为零以指定我们正在解除(停止)计时器。
-
如果这个结构已经保存了一个正值,那么它将被覆盖,并且定时器将使用新值重新启动。
-
不仅如此,通过将
it_interval(timespec 结构)初始化为非零值,我们将设置一个重复的间隔定时器(因此称为 POSIX 间隔定时器);时间间隔就是它初始化的值。定时器将持续无限期地触发,直到它被解除武装或删除。如果相反,这个结构被清零,定时器就变成了一次触发的定时器(当 it_value 成员中指定的时间过去时只触发一次)。
通常,将flags值设置为0——timer_settime(2)的 man 页面指定了一个可以使用的附加标志。最后,第四个参数old_value(同样是指向struct itimerspec的指针)的工作如下:
-
如果为
0,则会被简单地忽略。 -
如果非零,则是查询到给定定时器到期的剩余时间的一种方法。
-
到期时间将在
old_value->it_value成员中返回(以秒和纳秒为单位),设置的间隔将在old_value->it_interval成员中返回。
预期的成功返回值为0,失败时为-1(并适当设置了errno)。
查询定时器
可以随时查询给定的 POSIX 定时器,以获取剩余时间到定时器到期的时间,使用timer_gettime(2)系统调用 API;其签名如下:
#include <time.h>
int timer_gettime(timer_t timerid, struct itimerspec *curr_value);
显然,传递给timer_gettime(2)的第一个参数是要查询的特定定时器的 ID,传递的第二个参数是值结果样式返回——到期时间以它返回(在itimerspec类型的结构中)。
正如我们从前面所知道的,struct itimerval本身由两个timespec类型的数据结构组成;剩余时间到定时器到期将被放置在curr_value->it_value成员中。如果这个值为 0,则意味着定时器已经停止(解除武装)。如果放置在
curr_value->it_interval成员为正值时,表示定时器将重复触发的间隔(在第一次超时后);如果为 0,则意味着定时器是单次触发的(没有重复超时)。
示例代码片段显示工作流程
在接下来的内容中,我们展示了来自我们的样本程序ch13/react.c的代码片段(在下一节中更多地了解这个相当有趣的反应时间游戏应用程序),它清楚地说明了先前描述的步骤序列。
-
设置信号:
-
假设正在使用的通知机制是信号,首先通过
sigaction(2)捕获信号如下:
struct sigaction act;
[...]
// Trap SIGRTMIN : delivered on (interval) timer expiry
memset(&act, 0, sizeof(act));
act.sa_flags = SA_SIGINFO | SA_RESTART;
act.sa_sigaction = timer_handler;
if (sigaction(SIGRTMIN, &act, NULL) == -1)
FATAL("sigaction SIGRTMIN failed\n");
-
创建和初始化定时器:
-
决定使用的时钟类型(或来源)来测量经过的时间:
-
我们使用实时时钟
CLOCK_REALTIME作为我们的定时器来源,系统范围的挂钟时间。 -
决定应用程序使用的定时器到期事件通知机制——通常是使用(通常的)信号还是(新生成的)线程。
-
我们使用信号作为定时器到期事件通知机制。
-
上述决定是通过
timer_create(2)系统调用实现的,它允许创建一个定时器;当然,我们可以多次调用它来创建多个定时器:
struct sigevent sev;
[...]
/* Create and init the timer */
sev.sigev_notify = SIGEV_SIGNAL;
sev.sigev_signo = SIGRTMIN;
sev.sigev_value.sival_ptr = &timerid;
if (timer_create(CLOCK_REALTIME, &sev, &timerid) == -1)
FATAL("timer_create failed\n");
- 使用
timer_settime(2)API 来启动(或解除武装)特定的定时器。启动定时器意味着有效地开始计时或倒计时;解除武装定时器则相反——停止它的运行:
static struct itimerspec itv; // global
[...]
static void arm_timer(timer_t tmrid, struct itimerspec *itmspec)
{
VPRINT("Arming timer now\n");
if (timer_settime(tmrid, 0, itmspec, NULL) == -1)
FATAL("timer_settime failed\n");
jumped_the_gun = 0;
}
[...]
printf("Initializing timer to generate SIGRTMIN every %ld ms\n",
freq_ms);
memset(&itv, 0, sizeof(struct itimerspec));
itv.it_value.tv_sec = (freq_ms * 1000000) / 1000000000;
itv.it_value.tv_nsec = (freq_ms * 1000000) % 1000000000;
itv.it_interval.tv_sec = (freq_ms * 1000000) / 1000000000;
itv.it_interval.tv_nsec = (freq_ms * 1000000) % 1000000000;
[...]
arm_timer(timerid, &itv);
- 要查询特定定时器的剩余时间(到期时间)和其间隔设置,请使用
timer_gettime(2)
这在这个特定的应用程序中没有执行。
- 使用
timer_getoverrun(2)检查给定定时器的超时计数
在下一节“计算超时”中提供了此 API 的解释以及我们可能需要它的原因。
/*
* The realtime signal (SIGRTMIN) - timer expiry - handler.
* WARNING! Using the printf in a signal handler is unsafe!
* We do so for the purposes of this demo app only; do Not
* use in production.
*/
static void timer_handler(int sig, siginfo_t * si, void *uc)
{
char buf[] = ".";
c++;
if (verbose) {
write(2, buf, 1);
#define SHOW_OVERRUN 1
#if (SHOW_OVERRUN == 1)
{
int ovrun = timer_getoverrun(timerid);
if (ovrun == -1)
WARN("timer_getoverrun");
else {
if (ovrun)
printf(" overrun=%d [@count=%d]\n", ovrun, c);
}
}
#endif
}
}
- 使用
timer_delete(2)删除(显然解除武装)一个定时器
这不是这个特定应用程序中执行的(因为进程退出当然会删除与进程相关的所有定时器)。
正如timer_create(2)的 man 手册所告诉我们的,关于 POSIX(间隔)定时器的一些要点如下:
-
在
fork(2)之后,所有定时器都会自动解除武装;换句话说,定时器不会在子进程中继续到期。 -
在
execve(2)之后,所有定时器都被删除,因此在后继进程中将不可见。 -
值得注意的一点是(从 Linux 3.10 内核开始),proc 文件系统可以用来查询进程拥有的定时器;只需查找 cat 伪文件/proc/
/timers 以查看它们(如果存在)。 -
从 Linux 4.10 内核开始,POSIX 定时器是一个内核可配置的选项(在内核构建时,默认情况下启用)。
正如我们反复提到的,man 手册是开发人员可用的非常宝贵和有用的资源;再次,timer_create(2)的 man 手册提供了一个很好的示例程序;我们敦促读者参考 man 手册,阅读它,构建它并尝试运行程序。
计算超限
假设我们使用信号作为事件通知机制来告诉我们 POSIX 定时器已经到期,并且假设定时器到期时间非常短(比如几十微秒);例如,100 微秒。这意味着每 100 微秒信号将被传递给目标进程!
在这种情况下,可以合理地期望进程以如此高的速率接收相同的重复信号,不可能处理它。我们也知道,从我们对信号的了解来看,在类似这样的情况下,使用实时信号要远远优于使用常规 Unix 信号,因为操作系统有能力排队实时信号,但不能排队常规信号——它们(常规信号)将被丢弃,只保留一个实例。
因此,我们将使用实时信号(比如SIGRTMIN)来表示定时器到期;然而,即使使用一个非常小的定时器到期时间(例如,正如我们所说的 100 微秒),这种技术也不足以满足需求!进程肯定会被相同信号的快速传递所淹没。对于这些情况,我们可以获取定时器到期和实际信号处理之间发生的实际超限次数。我们该如何做到这一点?有两种方法:
-
一种是通过信号处理程序的
siginfo_t->_timer->si_overrun成员(这意味着我们在使用 sigaction 捕获信号时指定了SA_SIGINFO标志)——这是超限计数。 -
然而,这种方法是特定于 Linux 的(不可移植)。获得超限计数的更简单、可移植的方法是使用
timer_getoverrun(2)系统调用。这里的缺点是系统调用比内存查找的开销要大得多;就像生活中一样,有利的一面就有不利的一面。
POSIX 间隔定时器-示例程序
编程最终是通过实践来学习和理解的,而不仅仅是看或阅读。让我们听从自己的建议,编写一些体面的代码示例,以说明如何使用 POSIX(间隔)定时器 API。 (当然,亲爱的读者,这意味着你也要这样做!)
第一个示例程序是一个小型 CLI 游戏“你的反应有多快”?第二个示例程序是一个简单的跑步计时器的实现。继续阅读了解更多细节。
反应-时间游戏
我们都知道现代计算机很快!当然,这是一个非常相对的说法。有多快?这是一个有趣的问题。
有多快?
在第二章中,虚拟内存,在内存金字塔部分,我们看到了表 2:内存层次结构数字。这里,对数字进行了代表性的查看-不同类型的内存技术(嵌入式和服务器空间)的典型访问速度在表中列举。
快速回顾给出了典型内存(和网络)访问速度的以下内容。当然,这些数字仅供参考,最新的硬件可能具有更优越的性能特征;这里,重点是概念:
| CPU 寄存器 | CPU 缓存 | RAM | Flash | 磁盘 | 网络往返 |
|---|---|---|---|---|---|
| 300 - 500 ps | 0.5 ns(L1)至 20 ns(L3) | 50-100 ns | 25-50 us | 5-10 ms | >= 100s of ms |
表 3:硬件内存速度摘要表
这些延迟值大多是如此微小,以至于我们作为人类实际上无法将它们可视化(请参阅稍后的平均人类反应时间信息框)。所以,这带来了一个问题。我们人类究竟能够希望相当正确地可视化和理解哪些微小的数字?简短的答案是几百毫秒。
为什么要这样说?嗯,如果一个计算机程序告诉你尽快做出反应并立即按下某个键盘组合键,需要多长时间?所以,我们真正想要测试的是人对视觉刺激的反应时间。啊,通过编写这个精确的程序,我们可以通过实验证明这一点:一个反应计时器!
请注意,这个简单的视觉刺激反应测试并不被认为是科学的;我们完全忽略了重要的延迟产生机制,比如计算机系统硬件和软件本身。所以,当你尝试时,不要对结果感到沮丧!
我们的反应游戏-它是如何工作的
因此,在高层次上,这是程序的逐步计划(实际代码显示在下一节;我们建议您先阅读此内容,然后再查看代码):
-
创建并初始化一个简单的警报;将其设置为在程序启动后的 1 到 5 秒之间的任意时间过期
-
警报过期的时刻,执行以下操作:
-
设置 POSIX(间隔)计时器(到第一个参数指定的频率)。
-
显示一条消息,要求用户在键盘上按下*Ctrl *+ C。
-
获取时间戳(我们称之为
tm_start)。 -
当用户实际按下^C(*Ctrl *+ C;我们将通过
sigaction(2)捕获到),再次获取时间戳(我们称之为tm_end)。 -
计算用户的反应时间(作为
tm_end-tm_start)并显示它。
(注意前面的步骤遵循我们在本章前面描述的典型应用程序工作流程。)
此外,我们要求用户指定间隔计时器的间隔时间(第一个参数),以及作为第二个参数的可选详细选项。
进一步分解(更详细地),初始化代码执行以下操作:
-
通过
sigaction(2)捕获信号: -
SIGRTMIN:我们将使用信号通知来指定计时器到期;这是在我们的 POSIX 间隔计时器到期时生成的信号。 -
SIGINT:用户按下^C键盘组合时生成的信号。 -
SIGALRM:我们的初始随机警报过期时生成的信号 -
设置 POSIX 间隔计时器:
-
初始化
sigevent结构。 -
创建计时器(使用实时时钟源)并用
timer_create(2)。 -
将
itimerspec结构初始化为用户指定的频率值(以毫秒为单位)
然后:
- 向用户显示消息:
We shall start a timer anywhere between 1 and 5 seconds of starting this app.
GET READY ...
[ when the "QUICK! Press ^C" message appears, press ^C quickly as you can ]
-
在 1 到 5 秒之间的任意时间,警报会过期
-
我们进入
SIGALRM处理程序函数 -
显示
*** 快!按^C 键!***消息 -
调用
timer_settime(2)来设置计时器 -
获取
tm_start时间戳(使用clock_gettime(2)API) -
现在 POSIX 间隔计时器正在运行;它每
freq_ms毫秒到期一次(由用户提供的值);在详细模式下运行时,我们为每个计时器到期显示一个**.**。 -
用户在某个时候,近或远,做出反应并按下Ctrl+C(^C);在 SIGINT 的信号处理程序代码中,我们执行以下操作:
-
获取
tm_end时间戳(使用clock_gettime(2)API) -
通过
tm_end-tm_start计算增量(反应时间!),并显示它 -
退出。
反应 - 试验运行
最好是看到程序在运行中的情况;当然,读者最好(并且会更享受这个练习!)亲自构建并尝试一下:
$ ./react
Usage: ./react <freq-in-millisec> [verbose-mode:[0]|1]
default: verbosity is off
f.e.: ./react 100 => timeout every 100 ms, verbosity Off
: ./react 5 1 => timeout every 5 ms, verbosity On
How fast can you react!?
Once you run this app with the freq-in-millisec parameter,
we shall start a timer anywhere between 1 and 5 seconds of
your starting it. Watch the screen carefully; the moment
the message "QUICK! Press ^C" appears, press ^C (Ctrl+c simultaneously)!
Your reaction time is displayed... Have fun!
$
我们首先以 10 毫秒的频率运行它,而且不显示详细信息:
$ ./react 10
Initializing timer to generate SIGRTMIN every 10 ms
[Verbose: N]
We shall start a timer anytime between 1 and 5 seconds from now...
GET READY ...
[ when the "QUICK! Press ^C" message appears, press ^C quickly as you can ]
在 1 到 5 秒的随机间隔之后,出现这条消息,用户必须做出反应:
*** QUICK! Press ^C !!! ***
^C
*** PRESSED ***
Your reaction time is precisely 0.404794198 s.ns [~= 405 ms, count=40]
$
接下来,以 10 毫秒的频率和详细模式:
$ ./react 10 1
Initializing timer to generate SIGRTMIN every 10 ms
timer struct ::
it_value.tv_sec = 0 it_value.tv_nsec = 10000000
it_interval.tv_sec = 0 it_interval.tv_nsec = 10000000
[SigBlk: -none-]
[Verbose: Y]
We shall start a timer anytime between 1 and 5 seconds from now...
GET READY ...
[ when the "QUICK! Press ^C" message appears, press ^C quickly as you can ]
在 1 到 5 秒的随机间隔之后,出现这条消息,用户必须做出反应:
react.c:arm_timer:161: Arming timer now
*** QUICK! Press ^C !!! *
现在,每次 POSIX 间隔计时器到期时,句号字符.会迅速出现,也就是说,在这次运行中,每 10 毫秒出现一次。
.....................................^C
*** PRESSED ***
Your reaction time is precisely 0.379339662 s.ns [~= 379 ms, count=37]
$
在我们之前的样本运行中,用户反应需要 405 毫秒和 379 毫秒;正如我们提到的,它在数百毫秒的范围内。接受挑战——你能做得更好吗?
研究结果表明,人类平均反应时间如下:
| 刺激 | 视觉 | 听觉 | 触觉 |
|---|---|---|---|
| 平均人类反应时间 | 250 毫秒 | 170 毫秒 | 150 毫秒 |
来源:backyardbrains.com/experiments/reactiontime。我们已经习惯于使用短语,比如“眨眼之间”来表示非常快。有趣的是,眨眼实际上需要多长时间?研究表明,平均需要 300 到 400 毫秒!
反应游戏 - 代码视图
一些关键功能方面如下所示;首先是为SIGRTMIN设置信号处理程序并创建 POSIX 间隔的代码(ch13/react.c):
为了可读性,以下仅显示源代码的关键部分;要查看完整的源代码,构建并运行它,整个树都可以从 GitHub 克隆,链接在这里:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
static int init(void)
{
struct sigevent sev;
struct rlimit rlim;
struct sigaction act;
// Trap SIGRTMIN : delivered on (interval) timer expiry
memset(&act, 0, sizeof(act));
act.sa_flags = SA_SIGINFO | SA_RESTART;
act.sa_sigaction = timer_handler;
if (sigaction(SIGRTMIN, &act, NULL) == -1)
FATAL("sigaction SIGRTMIN failed\n");
[...]
/* Create and init the timer */
sev.sigev_notify = SIGEV_SIGNAL;
sev.sigev_signo = SIGRTMIN;
sev.sigev_value.sival_ptr = &timerid;
if (timer_create(CLOCK_REALTIME, &sev, &timerid) == -1)
FATAL("timer_create failed\n");
printf("Initializing timer to generate SIGRTMIN every %ld ms\n",
freq_ms);
memset(&itv, 0, sizeof(struct itimerspec));
itv.it_value.tv_sec = (freq_ms * 1000000) / 1000000000;
itv.it_value.tv_nsec = (freq_ms * 1000000) % 1000000000;
itv.it_interval.tv_sec = (freq_ms * 1000000) / 1000000000;
itv.it_interval.tv_nsec = (freq_ms * 1000000) % 1000000000;
[...]
意外开始的实现如下:
/* random_start
* The element of surprise: fire off an 'alarm' - resulting in SIGALRM being
* delivered to us - in a random number between [min..max] seconds.
*/
static void random_start(int min, int max)
{
unsigned int nr;
alarm(0);
srandom(time(0));
nr = (random() % max) + min;
#define CHEAT_MODE 0
#if (CHEAT_MODE == 1)
printf("Ok Cheater :-) get ready; press ^C in %ds ...\n", nr);
#endif
alarm(nr);
}
它的调用如下:
#define MIN_START_SEC 1
#define MAX_START_SEC 5
[...]
random_start(MIN_START_SEC, MAX_START_SEC);
信号处理程序(函数startoff)和与闹钟(SIGALRM)相关的逻辑如下:
static void arm_timer(timer_t tmrid, struct itimerspec *itmspec)
{
VPRINT("Arming timer now\n");
if (timer_settime(tmrid, 0, itmspec, NULL) == -1)
FATAL("timer_settime failed\n");
jumped_the_gun = 0;
}
/*
* startoff
* The signal handler for SIGALRM; arrival here implies the app has
* "started" - we shall arm the interval timer here, it will start
* running immediately. Take a timestamp now.
*/
static void startoff(int sig)
{
char press_msg[] = "\n*** QUICK! Press ^C !!! ***\n";
arm_timer(timerid, &itv);
write(STDERR_FILENO, press_msg, strlen(press_msg));
//—- timestamp it: start time
if (clock_gettime(CLOCK_REALTIME, &tm_start) < 0)
FATAL("clock_gettime (tm_start) failed\n");
}
请记住,当用户在四处游荡时,我们的 POSIX 间隔计时器会继续以用户指定的频率设置和重置自身(作为传递的第一个参数,我们将其保存在变量freq_ms中);因此,每freq_ms毫秒,我们的进程将接收到信号SIGRTMIN。这是它的信号处理程序例程:
static volatile sig_atomic_t gTimerRepeats = 0, c = 0, first_time = 1,
jumped_the_gun = 1; [...] static void timer_handler(int sig, siginfo_t * si, void *uc)
{
char buf[] = ".";
c++;
if (verbose) {
write(2, buf, 1);
#define SHOW_OVERRUN 1
#if (SHOW_OVERRUN == 1)
{
int ovrun = timer_getoverrun(timerid);
if (ovrun == -1)
WARN("timer_getoverrun");
else {
if (ovrun)
printf(" overrun=%d [@count=%d]\n", ovrun, c);
}
}
#endif
}
}
当用户(最终!)按下^C时,将调用 SIGINT 的信号处理程序(函数userpress):
static void userpress(int sig)
{
struct timespec res;
// timestamp it: end time
if (clock_gettime(CLOCK_REALTIME, &tm_end) < 0)
FATAL("clock_gettime (tm_end) failed\n");
[...]
printf("\n*** PRESSED ***\n");
/* Calculate the delta; subtracting one struct timespec
* from another takes a little work. A retrofit ver of
* the 'timerspecsub' macro has been incorporated into
* our ../common.h header to do this.
*/
timerspecsub(&tm_end, &tm_start, &res);
printf
(" Your reaction time is precisely %ld.%ld s.ns"
" [~= %3.0f ms, count=%d]\n",
res.tv_sec, res.tv_nsec,
res.tv_sec * 1000 +
round((double)res.tv_nsec / 1000000), c);
}
[...]
c = 0;
if (!gTimerRepeats)
exit(EXIT_SUCCESS);
}
运行:步行间隔计时器应用程序
本书的作者是一个自称业余跑步者。在我看来,跑步者/慢跑者,尤其是刚开始时(甚至经验丰富的人),可以从一致的跑步:步行模式中受益(单位通常是分钟)。
这背后的想法是,持续奔跑很难,尤其是对初学者来说。教练经常让新手跑步者遵循有用的跑步:步行策略;跑一段时间,然后休息一段时间,然后重复——再跑,再走——无限期地,或者直到达到目标距离(或时间)。
例如,当初学者跑 5 公里或 10 公里时,可能会遵循一致的 5:2 跑步:步行模式;也就是说,跑步 5 分钟,步行 2 分钟,重复这个过程,直到跑步结束。(而超级长跑者可能更喜欢类似 25:5 的策略。)
为什么不编写一个跑步:步行计时器应用程序,以帮助我们的初学者和认真的跑步者。
我们将这样做。不过,从更好地理解这个程序的角度来看,让我们想象一下程序已经编写并且正在运行——我们将试一试。
几次试运行
当我们简单地运行程序而不传递任何参数时,帮助屏幕会显示出来:
$ ./runwalk_timer
Usage: ./runwalk_timer Run-for[sec] Walk-for[sec] [verbosity-level=0|[1]|2]
Verbosity Level :: 0 = OFF [1 = LOW] 2 = HIGH
$
正如所见,程序期望至少有两个参数:
-
跑步时间(以秒为单位)[必需]
-
步行时间(以秒为单位)[必需]
-
冗长级别[可选]
可选的第三个参数,冗长级别,允许用户在程序执行时请求更多或更少的信息(这总是一种有用的工具,因此有助于调试程序)。我们提供了三种可能的冗长级别:
-
OFF:除了必需的内容之外,不显示任何内容(传递第三个参数 0) -
LOW:与关闭级别相同,另外我们使用句点字符**.**来显示时间流逝——每秒钟,**.**都会打印到stdout[默认] -
HIGH:与关闭级别相同,另外我们显示内部数据结构值、计时器到期时间等(传递第三个参数 2)
让我们首先尝试以默认的冗长级别(LOW)运行,使用以下规范:
-
运行 5 秒
-
步行 2 秒
好吧,好吧,我们知道,你比那更健康——你可以跑步:步行超过 5 秒:2 秒。原谅我们,但是这样的事情:为了演示的目的,我们并不真的想等到 5 分钟然后再过 2 分钟,只是为了看看它是否有效,对吧?(当您在跑步时使用这个应用程序时,请将分钟转换为秒并尝试!)。
话不多说;让我们启动一个 5:2 的跑步:步行 POSIX 计时器:
$ ./runwalk_timer 5 2
************* Run Walk Timer *************
Ver 1.0
Get moving... Run for 5 seconds
..... *<< each "." represents 1 second of elapsed time >>*
*** Bzzzz!!! WALK! *** for 2 seconds
..
*** Bzzzz!!! RUN! *** for 5 seconds
.....
*** Bzzzz!!! WALK! *** for 2 seconds
..
*** Bzzzz!!! RUN! *** for 5 seconds
....^C
+++ Good job, bye! +++
$
是的,它有效;我们通过输入^C(Ctrl+C)来中断它。
前面的试运行是在默认的冗长级别LOW;现在让我们以相同的 5:2 跑步:步行间隔重新运行它,但是将冗长级别设置为HIGH,通过传递2作为第三个参数:
$ ./runwalk_timer 5 2 2
************* Run Walk Timer *************
Ver 1.0
Get moving... Run for 5 seconds
trun= 5 twalk= 2; app ctx ptr = 0x7ffce9c55270
runwalk: 4.999s *<< query on time remaining >>*
runwalk: 3.999s
runwalk: 2.999s
runwalk: 1.999s
runwalk: 0.999s
its_time: signal 34. runwalk ptr: 0x7ffce9c55270 Type: Run. Overrun: 0
*** Bzzzz!!! WALK! *** for 2 seconds
runwalk: 1.999s
runwalk: 0.999s
its_time: signal 34. runwalk ptr: 0x7ffce9c55270 Type: Walk. Overrun: 0
*** Bzzzz!!! RUN! *** for 5 seconds
runwalk: 4.999s
runwalk: 3.999s
runwalk: 2.999s
runwalk: 1.999s
runwalk: 0.999s
its_time: signal 34. runwalk ptr: 0x7ffce9c55270 Type: Run. Overrun: 0
*** Bzzzz!!! WALK! *** for 2 seconds
runwalk: 1.999s
runwalk: 0.999s
its_time: signal 34. runwalk ptr: 0x7ffce9c55270 Type: Walk. Overrun: 0
*** Bzzzz!!! RUN! *** for 5 seconds
runwalk: 4.999s
runwalk: 3.999s
runwalk: 2.999s
^C
+++ Good job, bye! +++
$
细节被揭示;每秒钟,我们的 POSIX 计时器到期的剩余时间被显示出来(以毫秒为分辨率)。当计时器到期时,操作系统向进程发送实时信号SIGRTMIN;我们进入信号处理程序its_time,然后我们打印出从struct siginfo_t指针获得的信号信息。我们接收到信号号码(34)和联合体si->si_value中的指针,这是指向我们的应用程序上下文数据结构的指针,因此我们可以在没有使用全局变量的情况下访问它(稍后会详细介绍)。(当然,正如多次注意到的那样,在信号处理程序中使用printf(3)和变体是不安全的。我们在这里只是为了演示;不要在生产中这样编码。Bzzzz!!!消息代表计时器响起的声音,当然;程序指示用户相应地进行RUN!或WALK!,以及进行的秒数。整个过程无限重复。
低级设计和代码
这个简单的程序将允许您设置跑步和步行的秒数。它将相应地计时。
在这个应用程序中,我们使用一个简单的一次性 POSIX 计时器来完成工作。我们设置计时器使用信号通知作为计时器到期通知机制。我们为 RT 信号(SIGRTMIN)设置了一个信号处理程序。接下来,我们最初将 POSIX 计时器设置为在跑步期间到期,然后当信号在信号处理程序中到达时,我们重新设置(重新装载)计时器,使其在步行期间秒后到期。这基本上是无限重复的,或者直到用户通过按^C中止程序。
为了可读性,以下仅显示源代码的关键部分;要查看完整的源代码,构建并运行它,整个树可在 GitHub 上克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
许多现实世界的应用程序(实际上,任何软件)通常需要几个信息片段 - 在任何给定时间点都可用于所有函数 - 即全局可用。通常,可以将它们声明为全局(静态)变量并继续。我们有一个建议:为什么不将它们全部封装到一个单一的数据结构中?事实上,为什么不通过 typedef 一个结构来使其成为我们自己的?然后我们可以为其分配内存,初始化它,并以一种不需要它成为全局的方式传递其指针。那将是高效而优雅的。
// Our app context data structure
typedef struct {
int trun, twalk;
int type;
struct itimerspec *itmrspec;
timer_t timerid;
} sRunWalk;
在我们的应用程序中,为了保持简单,我们只是静态分配内存给(此外,请注意它是一个局部变量,而不是全局变量):
int main(int argc, char **argv)
{
struct sigaction act;
sRunWalk runwalk;
struct itimerspec runwalk_curval;
[...]
初始化工作在这里进行:
/*————————— Our POSIX Timer setup
* Setup a 'one-shot' POSIX Timer; initially set it to expire upon
* 'run time' seconds elapsing.
*/
static void runwalk_timer_init_and_arm(sRunWalk * ps)
{
struct sigaction act;
struct sigevent runwalk_evp;
assert(ps);
act.sa_sigaction = its_time;
act.sa_flags = SA_SIGINFO;
sigfillset(&act.sa_mask);
if (sigaction(SIGRTMIN, &act, 0) < 0)
FATAL("sigaction: SIGRTMIN");
memset(ps->itmrspec, 0, sizeof(sRunWalk));
ps->type = RUN;
ps->itmrspec->it_value.tv_sec = ps->trun;
runwalk_evp.sigev_notify = SIGEV_SIGNAL;
runwalk_evp.sigev_signo = SIGRTMIN;
// Pass along the app context structure pointer
runwalk_evp.sigev_value.sival_ptr = ps;
// Create the runwalk 'one-shot' timer
if (timer_create(CLOCK_REALTIME, &runwalk_evp, &ps->timerid) < 0)
FATAL("timer_create");
// Arm timer; will exire in ps->trun seconds, triggering the RT signal
if (timer_settime(ps->timerid, 0, ps->itmrspec, NULL) < 0)
FATAL("timer_settime failed");
}
[...]
runwalk_timer_init_and_arm(&runwalk);
[...]
在上述代码中,我们执行以下操作:
-
捕获实时信号(
SIGRTMIN)(在定时器到期时传递)。 -
初始化我们的应用上下文运行:步行数据结构:
-
特别是,将类型设置为运行,并将超时值(秒)设置为用户传递的第一个参数中的时间。
-
定时器到期事件通知机制被选择为通过
sigevent结构的sigev_notify成员进行信号传递。 -
通过
sigev_value.sival_ptr成员将传递的数据设置为指向我们应用上下文的指针是有用的;这样,我们可以在信号处理程序中始终访问它(消除了保持全局的需要)。 -
使用实时时钟源创建 POSIX 定时器,并将其 ID 设置为我们应用上下文运行步行结构的
timerid成员 -
装载 - 或启动 - 定时器。(回想一下,它已经初始化为在运行秒后到期。)
在我们之前的试运行中,运行设置为 5 秒,因此,从开始的 5 秒开始,我们将异步进入SIGRTMIN的信号处理程序its_time,如下所示:
static void its_time(int signum, siginfo_t *si, void *uctx)
{
// Gain access to our app context
volatile sRunWalk *ps = (sRunWalk *)si->si_value.sival_ptr;
assert(ps);
if (verbose == HIGH)
printf("%s: signal %d. runwalk ptr: %p"
" Type: %s. Overrun: %d\n",
__func__, signum,
ps,
ps->type == WALK ? "Walk" : "Run",
timer_getoverrun(ps->timerid)
);
memset(ps->itmrspec, 0, sizeof(sRunWalk));
if (ps->type == WALK) {
BUZZ(" RUN!");
ps->itmrspec->it_value.tv_sec = ps->trun;
printf(" for %4d seconds\n", ps->trun);
}
else {
BUZZ(" WALK!");
ps->itmrspec->it_value.tv_sec = ps->twalk;
printf(" for %4d seconds\n", ps->twalk);
}
ps->type = !ps->type; // toggle the type
// Reset: re-arm the one-shot timer
if (timer_settime(ps->timerid, 0, ps->itmrspec, NULL) < 0)
FATAL("timer_settime failed");
}
在信号处理代码中,我们执行以下操作:
-
(如前所述)访问我们的应用上下文数据结构(通过将
si->si_value.sival_ptr强制转换为我们的(sRunWalk *)数据类型)。 -
在高度冗长的模式下,我们显示更多细节(再次,不要在生产中使用
printf(3))。 -
然后,如果刚刚到期的定时器是
RUN,我们调用我们的蜂鸣器函数BUZZ并传递WALK消息参数,而且,重要的是: -
重新初始化超时值(秒)为用户传递的第二个参数的持续时间。
-
将类型从运行切换到步行。
-
通过
timer_settime(2)API 重新装载定时器。 -
在从刚刚到期的步行模式转换到运行模式时也是如此。
这样,进程将永远运行(或直到用户通过^C终止它),不断地在下一个运行:步行间隔超时。
通过 proc 查找定时器
还有一件事:有趣的是,Linux 内核允许我们深入了解操作系统;这通常是通过强大的 Linux proc 文件系统实现的。在我们当前的上下文中,proc 允许我们查找给定进程的所有定时器。这是如何做到的?通过读取伪文件/proc/<PID>/timers。看一看。下面的屏幕截图说明了这是如何在runwalk_timer进程上执行的。

左侧的终端窗口是runwalk_timer应用程序运行的地方;当它正在运行时,在右侧的终端窗口中,我们查找 proc 文件系统的伪文件/proc/<PID>/timers。输出清楚地显示了以下内容:
-
进程中只有一个(POSIX)定时器(ID 为
0)。 -
定时器到期事件通知机制是信号,因为我们可以看到
notify:signal/pid.<PID>和 signal: 34 与该定时器相关联(signal: 34 是SIGRTMIN;使用kill -l 34来验证)。 -
与此定时器相关的时钟源是
ClockID 0;也就是实时时钟。
快速提及
为了结束本章,我们简要介绍了两种有趣的技术:通过文件抽象模型和看门狗定时器。这些部分没有详细介绍;我们留给感兴趣的读者进一步了解。
通过文件描述符使用定时器
您是否还记得我们在本书的第一章中介绍的 Unix(因此也是 Linux)设计的一个关键理念?也就是说,一切都是一个进程;如果不是进程,就是一个文件。文件抽象在 Linux 上被广泛使用;在这里,我们也发现可以通过文件抽象来表示和使用定时器。
这是如何实现的?timerfd_* API 提供了所需的抽象。在本书中,我们不打算深入研究复杂的细节;相反,我们希望读者意识到,如果需要,可以使用文件抽象—通过read(2)系统调用读取定时器。
以下表格快速概述了timerfd_* API 集:
| API | 目的 | 等同于 POSIX 定时器 API |
|---|---|---|
timerfd_create(2) |
创建一个 POSIX 定时器;成功时返回值是与该定时器关联的文件描述符。 | timer_create(2) |
timerfd_settime(2) |
(解)装备由第一个参数fd引用的定时器。 |
timer_settime(2) |
timerfd_gettime(2) 在成功完成时,返回由第一个参数 fd 引用的定时器的到期时间和间隔。timer_gettime(2) |
表 4:timerfd_* API
include <sys/timerfd.h>
int timerfd_create(int clockid, int flags);
int timerfd_settime(int fd, int flags,
const struct itimerspec *new_value, struct itimerspec *old_value);
int timerfd_gettime(int fd, struct itimerspec *curr_value);
使用文件描述符表示各种对象的真正优势在于可以使用统一、强大的一组 API 对它们进行操作。在这种特殊情况下,我们可以通过read(2)、poll(2)、select(2)、epoll(7)和类似的 API 来监视基于文件的定时器。
如果创建了基于 fd 的定时器的进程进行了 fork 或 exec 怎么办?在fork(2)时,子进程将继承父进程通过timerfd_create(2)API 创建的任何定时器相关的文件描述符的副本。实际上,它与父进程共享相同的定时器。
在execve(2)时,定时器在继承的进程中仍然有效,并且将在超时时继续到期;除非在创建时指定了TFD_CLOEXEC标志。
更多详细信息(以及示例)可以在此处的 man 页面中找到:linux.die.net/man/2/timerfd_create。
关于看门狗定时器的简要说明
看门狗本质上是一种基于定时器的机制,用于定期检测系统是否处于健康状态,如果被认为不是,就重新启动它。
这是通过设置(内核)定时器(比如,60 秒超时)来实现的。如果一切正常,看门狗守护进程将在定时器到期之前一直解除定时器,并随后重新启用(装备)它;这被称为抚摸狗。如果守护进程不解除看门狗定时器(因为某些事情出了问题),看门狗会感到恼火并重新启动系统。
守护进程是系统后台进程;有关守护进程的更多信息,请参阅附录 B,守护进程。
纯软件看门狗实现将无法防止内核错误和故障;硬件看门狗(它连接到板复位电路)将始终能够在需要时重新启动系统。
看门狗定时器在嵌入式系统中经常被使用,特别是在深度嵌入式系统中(或者由于某种原因无法被人类接触到的系统);在最坏的情况下,它可以重新启动,并希望再次执行其指定的任务。一个著名的看门狗定时器导致重启的例子是 NASA 在 1997 年发送到火星表面的 Pathfinder 机器人(是的,就是在火星上遇到了优先级倒置并发错误的那个机器人。我们将在第十五章《使用 Pthreads 进行多线程编程第二部分-同步》中稍微探讨一下这个问题,关于多线程和并发)。是的,这就是在优秀电影《火星救援》中扮演角色的 Pathfinder 机器人!关于这个问题,我们将在 GitHub 存储库的“进一步阅读”部分中详细介绍。
总结
在本章中,读者已经了解了 Linux 在创建和使用定时器方面提供的各种接口。设置和管理超时是许多系统应用的重要组成部分,如果不是大多数系统应用的话。旧的接口——备受尊敬的alarm(2)API,以及[s|g]etitimer(2)系统调用——都有示例代码。然后,我们深入了解了更新更好的 POSIX 定时器,包括它们提供的优势以及如何在实际中使用它们。这在两个相当复杂的示例程序——react 游戏和 run:walk 定时器应用程序的帮助下得到了很大的帮助。最后,读者被介绍了通过文件抽象使用定时器的概念,以及看门狗定时器。
下一章将是我们开始在 Linux 上理解和使用强大的多线程框架的漫长三章旅程的地方。
第十四章:使用 Pthreads 进行多线程编程第一部分 - 基础知识
你是否使用过下载加速器类型的应用程序下载过大文件?你玩过在线游戏吗?飞行模拟器程序?使用过文字处理、网页浏览器、Java 应用程序等等?(在这里放一个笑脸表情的诱惑很高!)
很可能你至少使用过其中一些;那又怎样呢?所有这些不同的应用程序有一个共同点:它们很可能都是为多线程设计的,这意味着它们的实现使用多个线程并行运行。多线程确实已经成为现代程序员几乎是一种生活方式。
解释一个像多线程这样庞大的话题本身就是一项艰巨的任务;因此我们将其分成三个单独的章节进行覆盖。这是其中的第一章。
本章本身在逻辑上分为两个广泛的部分:在第一部分中,我们仔细考虑并理解线程模型背后的概念——多线程的“什么”和“为什么”。线程到底是什么,我们为什么需要线程,以及多线程在 Linux 平台上是如何发展的一个快速了解。
在第二部分中,我们将重点关注 Linux 上多线程的线程管理 API,即多线程的“如何”(在某种程度上)。我们将讨论创建和管理线程所需的 API 集合,并且当然会有很多实际的代码可以看到和尝试。
在这个话题的开始,我们还必须明确指出这样一个事实,即在本书中,我们只关注软件编程的多线程;特别是在 Linux 平台上的 POSIX 线程(pthreads)实现,具体来说是 Linux 平台上的 pthreads。我们不打算处理其他各种出现的多线程框架和实现(如 MPI、OpenMP、OpenCL 等)或硬件线程(超线程、具有 CUDA 的 GPU 等)。
在本章中,你将学习如何在 Linux 平台上使用多个线程进行编程,具体来说,是如何开始使用 pthread 编程模型或框架。本章大致分为两部分:
-
在第一部分,涵盖了关键的多线程概念——多线程的“什么”和“为什么”,为第二部分(以及后面两章关于多线程的内容)奠定了基础。
-
第二部分涵盖了在 Linux 上构建功能性多线程应用程序所需的基本 pthread API(它故意没有涵盖所有方面;接下来的两章将在此基础上展开)。
多线程概念
在本节中,我们将学习在 Linux 平台上多线程的“什么”和“为什么”。我们将从回答“线程到底是什么?”这个常见问题开始。
线程到底是什么?
在古老的 Unix 程序员的好(或坏?)旧日子里,有一个简单的软件模型(其他操作系统和供应商几乎完全继承了这个模型):有一个存在于虚拟地址空间(VAS)中的进程;VAS 本质上由称为段的同质区域(基本上是虚拟页面的集合)组成:文本、数据、其他映射(库)和栈。文本实际上是可执行的——事实上是机器——代码,它被馈送到处理器。我们在本书的早期部分已经涵盖了所有这些内容(你可以在第二章《虚拟内存》中复习这些基础知识)。
线程是进程内部的独立执行(或流)路径。在线程的生命周期和范围中,在我们通常使用的熟悉的过程式编程范式中,它只是一个函数。
因此,在我们之前提到的传统模型中,我们有一个执行线程;在 C 编程范式中,该线程是main()函数!想想看:main()线程是执行开始(至少从应用程序开发者的角度来看)和结束的地方。这个模型现在被称为单线程软件模型。与之相对的是什么?当然是多线程模型。所以,我们可以有多个线程与同一进程中的其他独立线程同时执行(并行)。
但是,等等,进程难道也不能产生并行性,并且在应用程序的不同方面上有多个副本在工作吗?当然可以:我们已经在第十章中以所有的荣耀(和影响)介绍了fork(2)系统调用。这被称为多进程模型。因此,如果我们有多进程——在这里,有几个进程并行运行,并且完成了工作——百万美元的问题就变成了:“为什么还要使用多线程?”(请存入一百万美元,我们将提供答案。)有几个很好的理由;请查看接下来的章节(特别是动机-为什么要使用线程?;我们建议第一次读者按照本书中所规定的顺序进行阅读)以获取更多细节。
资源共享
在第十章中,进程创建,我们反复指出,虽然 fork(2)系统调用非常强大和有用,但它被认为是一种重量级操作;执行 fork 需要大量的 CPU 周期(因此需要时间),而且在内存(RAM)方面也很昂贵。计算机科学家们正在寻找一种减轻这种情况的方法;结果,正如你所猜到的那样,就是线程。
不过,为了方便读者,我们在这里重现了一个图表——Linux 进程-在 fork()中的继承和非继承——来自第十章,进程创建:

图 1:Linux 进程-在 fork()中的继承和非继承
这个图表很重要,因为它向我们展示了为什么 fork 是一种重量级操作:每次调用 fork(2)系统调用时,父进程的完整虚拟地址空间和图表右侧的所有数据结构都必须被复制到新生的子进程中。这确实是很多工作和内存使用!(好吧,我们有点夸张:正如在第十章中所提到的,进程创建,*现代操作系统,特别是 Linux,确实费了很多功夫来优化 fork。尽管如此,它还是很重的。请查看我们的示例 1 演示程序,进程的创建和销毁比线程的创建和销毁要慢得多(并且需要更多的 RAM)。
事实是这样的:当一个进程创建一个线程时,该线程与同一进程的所有其他线程(几乎)共享所有内容——包括之前的虚拟地址空间、段和所有数据结构——除了栈。
每个线程都有自己的私有堆栈段。它位于哪里?显然,它位于创建进程的虚拟地址空间内;它确切地位于哪里对我们来说并不重要(回想一下,无论如何都是虚拟内存,而不是物理内存)。对应用程序开发人员来说,更相关和重要的问题是线程堆栈的大小。简短的答案是:与通常一样(在 Linux 平台上通常为 8MB),但我们将在本章后面详细介绍细节。只需这样想:main()的堆栈总是位于(用户模式)虚拟地址空间的顶部;进程中其余线程的堆栈通常位于该空间中的任何位置。实际上,它们通常位于堆和(main 的)堆栈之间的虚拟内存空间中。
以下图表帮助我们了解 Linux 上多线程进程的内存布局;图表的上部是pthread_create(3)之前的进程;下部显示了成功创建线程后的进程:

图 2:线程-除了堆栈之外,一切都在 pthread_create()中共享
进程文本段中的蓝色波浪线代表main()线程;它的堆栈也清晰可见。我们使用虚线表示所有这些内存对象(用户空间和内核空间)都在pthread_create(3)中被共享。显然可以看到,在pthread_create(3)之后,唯一的新对象是新线程本身(thrd2;在进程文本段中显示为红色波浪线)和刚刚创建的线程thrd2的新堆栈(红色)。将此图与图 1进行对比;当我们进行fork(2)时,几乎所有东西都必须复制到新生的子进程中。
到目前为止,我们描述的唯一区别是进程和线程之间的资源共享——进程不共享,它们复制;线程共享一切,除了堆栈。再深入一点,你会意识到软件和硬件状态都必须以每个线程为基础进行维护。Linux 操作系统正是这样做的:它在操作系统内部维护了一个每个线程的任务结构;任务结构包含所有进程/线程属性,包括软件和硬件上下文(CPU 寄存器值等)信息。
再深入挖掘一下,我们意识到操作系统确实会为每个线程维护以下属性的独立副本:堆栈段(因此堆栈指针)、可能的备用信号堆栈(在第十一章中介绍,信号-第一部分)、常规信号和实时信号掩码、线程 ID、调度策略和优先级、能力位、CPU 亲和性掩码以及 errno 值(不用担心,这些中的几个将在后面解释)。
多进程与多线程
为了清楚地理解为什么和如何线程可以提供性能优势,让我们进行一些实验!(实证的重要性-实验,尝试-是一个关键特征;我们的第十九章,故障排除和最佳实践,更多涵盖了这些内容)。首先,我们进行两个简单示例程序的比较:一个是比较创建和销毁进程与线程的程序,另一个是以两种方式进行矩阵乘法运算的程序——一种是传统的单线程进程模型,另一种是多线程模型。
因此,我们在这里真正比较的是使用多进程模型和多线程模型的执行时间性能。我们要请读者注意,我们现在不会费力详细解释线程代码的原因有两个:一是这不是重点,二是在我们详细介绍线程 API 之前,这样做没有意义。(因此,亲爱的读者,我们要求你暂时忽略线程代码;只需跟着我们,构建和重现我们在这里做的事情;随着你的学习,代码和 API 将变得清晰。)
示例 1 - 创建/销毁 - 进程/线程
进程模型:我们的做法是:在一个循环中(总共执行了 60,000 次!),通过调用fork(2)创建和销毁进程,然后退出。(我们处理了一些细节,比如在父进程中等待子进程死亡,以清除任何可能的僵尸进程,然后继续循环。)相关的代码如下(ch14/speed_multiprcs_vs_multithrd_simple/create_destroy/fork_test.c):
为了便于阅读,以下代码中只显示了相关部分;要查看和运行完整的源代码,可以在这里找到:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
...
#define NFORKS 60000
void do_nothing()
{
unsigned long f = 0xb00da;
}
int main(void)
{
int pid, j, status;
for (j = 0; j < NFORKS; j++) {
switch (pid = fork()) {
case -1:
FATAL("fork failed! [%d]\n", pid);
case 0: // Child process
do_nothing();
exit(EXIT_SUCCESS);
default: // Parent process
waitpid(pid, &status, 0);
}
}
exit(EXIT_SUCCESS);
}
我们在time(1)实用程序的前缀下运行它,这给了我们一个程序在处理器上花费的时间的大致概念;花费的时间显示为三个组成部分:real(总的挂钟时间),user(用户空间中花费的时间)和sys(内核空间中花费的时间):
$ time ./fork_test
real 0m10.993s
user 0m7.436s
sys 0m2.969s
$
显然,你在 Linux 系统上得到的确切数值可能会有所不同。而且,user + sys的总和也不会完全等于 real。
多线程模型
再次强调,我们的做法是:关键是要理解这里使用的代码(ch14/speed_multiprcs_vs_multithrd_simple/create_destroy/pthread_test.c)在所有方面都与前面的代码相同,只是这里我们使用线程而不是进程:在一个循环中(总共执行了 60,000 次!),通过调用pthread_create(3)创建和销毁线程,然后通过调用pthread_exit(3)退出。(我们处理了一些细节,比如在调用线程中等待兄弟线程终止,通过调用pthread_join(3)。)如前所述,让我们暂时跳过代码/API 的细节,只看执行情况:
$ time ./pthread_test
real 0m3.584s
user 0m0.379s
sys 0m2.704s
$
哇,线程化的代码运行速度大约比进程模型的代码快 3 倍!结论很明显:创建和销毁线程比创建和销毁进程要快得多。
技术方面的一点说明:对于更好奇的极客:为什么fork(2)比pthread_create(3)慢得多?熟悉操作系统开发的人会明白,Linux 在fork(2)的内部实现中大量使用了性能增强的写时复制(COW)内存技术。因此,问题是,如果 COW 被大量使用,那么是什么使 fork 变慢?简短的答案是:页表的创建和设置不能进行 COW;这需要一段时间。当创建同一进程的线程时,这项工作(页表设置)完全被跳过。
即便如此,Linux 的 fork 在今天任何可比较的操作系统中都被认为是最快的。
另外,衡量花费的时间和性能特征的一种更准确的方法是使用众所周知的perf(1)实用程序(请注意,在本书中,我们不打算详细介绍perf;如果感兴趣,请查看 GitHub 存储库的进一步阅读部分,其中有一些与perf相关的链接):
$ perf stat ./fork_test
Performance counter stats for './fork_test':
9054.969497 task-clock (msec) # 0.773 CPUs utilized
61,245 context-switches # 0.007 M/sec
202 cpu-migrations # 0.022 K/sec
15,00,063 page-faults # 0.166 M/sec
<not supported> cycles
<not supported> instructions
<not supported> branches
<not supported> branch-misses
11.714134973 seconds time elapsed
$
正如前面的代码所示,在虚拟机上,当前版本的perf不能显示所有的计数器;这在这里并不妨碍我们,因为我们真正关心的是执行所花费的最终时间——这显示在perf输出的最后一行中。
以下代码显示了多线程应用程序的perf(1):
$ perf stat ./pthread_test
Performance counter stats for './pthread_test':
2377.866371 task-clock (msec) # 0.587 CPUs utilized
60,887 context-switches # 0.026 M/sec
117 cpu-migrations # 0.049 K/sec
69 page-faults # 0.029 K/sec
<not supported> cycles
<not supported> instructions
<not supported> branches
<not supported> branch-misses
4.052964938 seconds time elapsed
$
对于感兴趣的读者,我们还提供了一个包装脚本(ch14/speed_multiprcs_vs_multithrd_simple/create_destroy/perf_runs.sh),允许用户使用perf(1)进行记录和报告会话。
示例 2-矩阵乘法-进程/线程
一个众所周知的练习是编写一个计算两个给定矩阵的(点)积的程序。基本上,我们想执行以下操作:
矩阵 C = 矩阵 A * 矩阵 B
再次强调的是,我们在这里实际上并不关心算法(和代码)的细节;我们关心的是在设计层面上如何执行矩阵乘法。我们提出(并编写相应的代码)两种方法:
-
按顺序,通过单线程模型
-
同时,通过多线程模型
注意:这些算法或代码都不打算是原创或突破性的;这些都是众所周知的程序。
在第一个模型中,一个线程-当然是main()-将运行并执行计算;程序可以在这里找到:ch14/speed_multiprcs_vs_multithrd_simple/matrixmul/prcs_matrixmul.c。
其次,我们将在目标系统上创建至少与 CPU 核心数相同的线程,以充分利用硬件(这个方面在本章的后面一节中处理,名为你可以创建多少线程?);每个线程将与其他线程并行执行一部分计算。程序可以在这里找到:ch14/speed_multiprcs_vs_multithrd_simple/matrixmul/thrd_matrixmul.c。
在多线程版本中,目前,我们只是在代码中硬编码 CPU 核心数为四,因为它与我们的本机 Linux 测试系统之一匹配。
为了真正了解我们的应用程序的进程和/或线程如何实际消耗 CPU 带宽,让我们使用有趣的gnome-system-monitor GUI 应用程序以图形方式查看资源消耗!(要运行它,假设已安装,只需在 shell 上键入$ gnome-system-monitor&)。
我们提醒您,所有软件和硬件要求都已在本书的 GitHub 存储库上提供的软件硬件清单材料中详细列出。
我们将按以下方式进行实验:
- 在具有四个 CPU 核心的本机 Linux 系统上运行应用程序:
仔细看前面的(带注释的)屏幕截图(如果您正在阅读电子版本,请放大);我们会注意到几个有趣的项目:
-
在前台是我们运行
prcs_matrixmul和thrd_matrixmul应用程序的终端窗口应用程序: -
我们使用
perf(1)来准确测量所花费的时间,并故意过滤除了执行期间经过的最终秒数之外的所有输出。 -
在背景中,您可以看到正在运行的
gnome-system-monitorGUI 应用程序。 -
(本机 Linux)系统-我们已经在其上进行了测试-有四个 CPU 核心:
-
找到系统上 CPU 核心数量的一种方法是使用以下代码:
getconf -a | grep _NPROCESSORS_ONLN | awk '{print $2}'
(您可以在源代码thrd_matrixmul.c中更新NCORES宏以反映此值)
-
prcs_matrixmul应用程序首先运行;当它运行时,它会在四个可用的 CPU 核心中的一个上消耗 100%的 CPU 带宽(它恰好是 CPU 核心#2) -
请注意,在 CPU 历史记录仪的中间到左侧,代表 CPU2 的红线飙升到 100%(用紫色椭圆标出并标记为进程)!
-
在实际拍摄屏幕截图时(OS 在 X 轴时间线上;它从右向左移动),CPU 恢复到正常水平。
-
接下来(在这次运行的间隔为 10 秒后),
thrd_matrixmul应用程序运行;这里的关键点在于:当它运行时,它会在所有四个 CPU 核心上消耗 100%的 CPU 带宽! -
请注意,在 X 轴时间线上大约在 15 秒标记之后(从右到左阅读),所有四个 CPU 核心都突然达到了 100%——这是在执行
thrd_matrixmul(用红色省略号突出显示并标记为 Threads)时发生的。
这告诉我们什么?非常重要的一点:底层的 Linux 操作系统 CPU 调度器将尝试利用硬件,并且如果可能的话,将我们的四个应用程序线程安排在四个可用的 CPU 上并行运行!因此,我们获得了更高的吞吐量、更高的性能和更高的性价比。
可以理解的是,此时您可能会对 Linux 如何执行 CPU(线程)调度产生很多疑问;不用担心,但请耐心等待——我们将在第十七章中详细探讨 Linux 的 CPU 调度。
- 限制为仅一个 CPU:
taskset(1)实用程序允许在指定的处理器核心上运行进程。 (将进程与给定的 CPU 关联起来的能力称为 CPU 亲和性。我们将在调度章节中回到这一点。)使用taskset的基本形式很容易:taskset -c <cpu-mask> <app-to-run-on-given-cpus>
正如您可以从以下截图中看到的,我们对系统上所有四个 CPU 核心(通常方式)执行thrd_matrixmul应用程序的运行进行了对比,以及通过taskset(1)指定 CPU 掩码在仅一个 CPU 上运行它;截图再次清楚地显示了,在前一次运行中,所有四个 CPU 都被操作系统利用(总共需要 8.084 秒),而在后一次运行中,只有一个 CPU(以绿色显示为 CPU3)被用于执行其代码(总共需要 11.189 秒):

根据本节刚学到的内容,您可能会得出结论:“嘿,我们找到答案了:让我们总是使用多线程。”但是,当然,经验告诉我们并没有银弹。事实是,尽管线程确实提供了一些真正的优势,但就像生活中的一切一样,它也有缺点。我们将在第十六章中推迟更多关于利弊的讨论,即使用 Pthreads 进行多线程编程第三部分;但请记住这一点。
现在,让我们进行另一个实验,以清楚地说明不仅多线程,而且多进程——使用 fork 生成多个进程——也非常有助于获得更高的吞吐量。
示例 3——内核构建
因此,最后一个实验(本节):我们将为 ARM Versatile Express 平台构建(交叉编译)Linux 内核版本 4.17(使用默认配置)。内核构建的细节等都不在本书的范围之内,但没关系:关键点在于内核构建绝对是一个 CPU 和 RAM 密集型的操作。不仅如此,现代的make(1)实用程序也支持多进程!可以通过其-jn选项开关告诉make要内部生成(fork)的作业数量,其中n是作业(线程)的数量。我们使用一个启发式(经验法则)来确定这个数量:
n = CPU 核心数量 * 2
(在具有大量核心的高端系统上乘以 1.5。)
了解了这一点,接下来看看接下来的实验。
在具有 1GB RAM、两个 CPU 核心和并行化 make -j4 的 VM 上
我们配置了虚拟机客户机具有两个处理器,并进行了并行化构建(通过指定make -j4):
$ cd <linux-4.17-kernel-src-dir>
$ perf stat make V=0 -j4 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- allscripts/kconfig/conf --syncconfig Kconfig
CHK include/config/kernel.release
SYSHDR arch/arm/include/generated/uapi/asm/unistd-oabi.h
SYSHDR arch/arm/include/generated/uapi/asm/unistd-common.h
WRAP arch/arm/include/generated/uapi/asm/bitsperlong.h
WRAP arch/arm/include/generated/uapi/asm/bpf_perf_event.h
WRAP arch/arm/include/generated/uapi/asm/errno.h
[...] *<< lots of output >>*
CC arch/arm/boot/compressed/string.o
AS arch/arm/boot/compressed/hyp-stub.o
AS arch/arm/boot/compressed/lib1funcs.o
AS arch/arm/boot/compressed/ashldi3.o
AS arch/arm/boot/compressed/bswapsdi2.o
AS arch/arm/boot/compressed/piggy.o
LD arch/arm/boot/compressed/vmlinux
OBJCOPY arch/arm/boot/zImage
Kernel: arch/arm/boot/zImage is ready
Performance counter stats for 'make V=0 -j4 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- all':
1174027.949123 task-clock (msec) # 1.717 CPUs utilized
3,80,189 context-switches # 0.324 K/sec
7,921 cpu-migrations # 0.007 K/sec
2,13,51,434 page-faults # 0.018 M/sec
<not supported> cycles
<not supported> instructions
<not supported> branches
<not supported> branch-misses
683.798578130 seconds time elapsed
$ ls -lh <...>/linux-4.17/arch/arm/boot/zImage
-rwxr-xr-x 1 seawolf seawolf 4.0M Aug 13 13:10 <...>/zImage*
$ ls -lh <...>/linux-4.17/vmlinux
-rwxr-xr-x 1 seawolf seawolf 103M Aug 13 13:10 <...>/vmlinux*
$
构建总共花费了大约 684 秒(11.5 分钟)。只是让您知道,用于 ARM 的压缩内核映像是名为zImage的文件;未压缩的内核映像(仅用于调试目的)是vmlinux文件。
在构建过程中,通过快速执行ps -LA确实显示了其多进程——而不是多线程——的性质:
$ ps -LA
[...]
11204 11204 pts/0 00:00:00 make
11227 11227 pts/0 00:00:00 sh
11228 11228 pts/0 00:00:00 arm-linux-gnuea
11229 11229 pts/0 00:00:01 cc1
11242 11242 pts/0 00:00:00 sh
11243 11243 pts/0 00:00:00 arm-linux-gnuea
11244 11244 pts/0 00:00:00 cc1
11249 11249 pts/0 00:00:00 sh
11250 11250 pts/0 00:00:00 arm-linux-gnuea
11251 11251 pts/0 00:00:00 cc1
11255 11255 pts/0 00:00:00 sh
11256 11256 pts/0 00:00:00 arm-linux-gnuea
11257 11257 pts/0 00:00:00 cc1
[...]
$
在具有 1GB RAM、一个 CPU 核心和顺序 make -j1 的 VM 上
我们配置客户 VM 只有一个处理器,清理构建目录,然后再次进行,但这次是顺序构建(通过指定make -j1):
$ cd <linux-4.17-kernel-src-dir>
$ perf stat make V=0 -j1 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- all
scripts/kconfig/conf --syncconfig Kconfig
SYSHDR arch/arm/include/generated/uapi/asm/unistd-common.h
SYSHDR arch/arm/include/generated/uapi/asm/unistd-oabi.h
SYSHDR arch/arm/include/generated/uapi/asm/unistd-eabi.h
CHK include/config/kernel.release
UPD include/config/kernel.release
WRAP arch/arm/include/generated/uapi/asm/bitsperlong.h
[...] *<< lots of output >>*
CC crypto/hmac.mod.o
LD [M] crypto/hmac.ko
CC crypto/jitterentropy_rng.mod.o
LD [M] crypto/jitterentropy_rng.ko
CC crypto/sha256_generic.mod.o
LD [M] crypto/sha256_generic.ko
CC drivers/video/backlight/lcd.mod.o
LD [M] drivers/video/backlight/lcd.ko
Performance counter stats for 'make V=0 -j1 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- all':
1031535.713905 task-clock (msec) # 0.837 CPUs utilized
1,78,172 context-switches # 0.173 K/sec
0 cpu-migrations # 0.000 K/sec
2,13,29,573 page-faults # 0.021 M/sec
<not supported> cycles
<not supported> instructions
<not supported> branches
<not supported> branch-misses
1232.146348757 seconds time elapsed
$
构建总共花费了大约 1232 秒(20.5 分钟),几乎是上一次构建的两倍长!
你可能会问这个问题:那么,如果使用一个进程构建大约花费了 20 分钟,而使用多个进程进行相同的构建大约花费了一半的时间,为什么还要使用多线程?多处理似乎也很好!
不,想一想:我们关于进程与线程创建/销毁的第一个例子告诉我们,生成(和终止)进程比使用线程慢得多。这仍然是许多应用程序利用的关键优势。毕竟,线程在创建和销毁方面比进程更有效。
在一个动态、不可预测的环境中,我们事先不知道需要多少工作,使用多线程能够快速创建工作线程(并快速终止它们)非常重要。想想著名的 Apache 网络服务器:它默认是多线程的(通过其 mpm_worker 模块,以便快速响应客户端请求)。同样,现代的 NGINX 网络服务器使用线程池(对于感兴趣的人,更多信息可以在 GitHub 存储库的“进一步阅读”部分找到)。
动机 - 为什么要使用线程?
线程确实提供了许多有用的优势;在这里,我们试图列举一些更重要的优势。我们认为这是对应用架构师使用多线程的动机,因为可能获得的优势。我们将这个讨论分为两个方面:设计和性能。
设计动机
在设计方面,我们考虑以下内容:
利用潜在的并行性
许多现实世界的应用程序将受益于以这样的方式设计它们,使得工作可以分成不同的单元,并且这些单元或工作包可以并行 - 与彼此同时运行。在实现层面,我们可以使用线程来实现工作包。
例如,下载加速器程序通过让几个线程执行网络 I/O 来利用网络。每个线程被分配下载文件的一部分的工作;它们都并行运行,有效地获得比单个线程更多的网络带宽,完成后,目标文件被拼接在一起。
有许多这样的例子;认识到并行性的潜力是架构师工作的重要部分。
逻辑分离
线程模型直观地适合让设计者逻辑上分离工作。例如,GUI 前端应用程序可能有几个线程管理 GUI 状态,等待并响应用户输入等。其他线程可以用于处理应用程序的业务逻辑。不将用户界面(UI)与业务逻辑混合在一起是良好设计的关键要素。
CPU 与 I/O 重叠
这一点与前面的类似——任务的逻辑分离。在我们讨论的背景下,CPU 指的是软件是 CPU 密集型或 CPU 绑定的(经典的例子是 C 代码的while(1));I/O 指的是软件处于阻塞状态 - 我们说它在等待 I/O,意味着它在等待某些其他操作完成(也许是文件或网络读取,或者任何阻塞 API),然后它才能继续前进;这被称为 I/O 绑定。
所以,这样想:假设我们有一系列要执行的任务(它们之间没有依赖关系):任务 A,任务 B,任务 C 和任务 D。
我们还可以说,任务 A 和任务 C 高度依赖 CPU,而任务 B 和任务 D 更依赖 I/O。如果我们使用传统的单线程方法,那么每个任务都必须按顺序执行;因此,进程最终会等待——也许要等很长时间——等待任务 B 和 D,从而延迟任务 C。另一方面,如果我们使用多线程方法,我们可以将任务分开为单独的线程。因此,即使任务 B 和 D 的线程在 I/O 上被阻塞,任务 A 和 C 的线程仍然可以取得进展。
这被称为 CPU 与 I/O 的重叠。在没有依赖关系的情况下,通过使用线程来解耦(和分离)任务,这是一种通常值得追求的设计方法。这会导致更好的应用程序响应能力。
经理-工人模型
线程非常容易适用于熟悉的经理-工人模型;一个经理线程(通常是main())根据需要创建工作线程(或者将它们汇集在一起);当工作出现时,工作线程处理它。想想繁忙的网络服务器。
IPC 变得更简单
在进程之间执行 IPC 需要学习曲线、经验和大量工作。对于属于一个进程的线程,它们之间的 IPC——通信——就像写入和读取全局内存一样简单(说实话,这并不那么简单,当我们在下一章中讨论并发和同步的主题时,我们将了解到,概念上和实际上,这仍然比处理 IPC 要少得多)。
性能动机
正如前一节的两个示例清楚地向我们展示的那样,使用多线程可以显著提高应用程序的性能;这其中的一些原因在这里提到。
创建和销毁
前面的示例 1 清楚地表明,创建和销毁线程所需的时间远远少于进程。许多应用程序几乎要求您几乎不断地这样做。(我们将看到,与进程相比,创建和销毁线程在编程上要简单得多。)
自动利用现代硬件的优势
前面的示例 2 清楚地说明了这一点:在现代多核硬件上运行多线程应用程序时(高端企业级服务器可以拥有超过 700 个 CPU 核心!),底层操作系统将负责将线程优化地调度到可用的 CPU 核心上;应用程序开发人员不需要关心这一点。实际上,Linux 内核将尽可能确保完美的 SMP 可伸缩性,这将导致更高的吞吐量,最终实现速度增益。(亲爱的读者,我们在这里是乐观的:现实是,随着并行性和 CPU 核心的增加,也伴随着并发问题的严重缺陷;我们将在接下来的章节中更详细地讨论所有这些。)
资源共享
我们已经在本章的开始部分的资源共享部分中涵盖了这一点(如果需要,可以重新阅读)。最重要的是:与进程创建相比,线程创建成本较低(销毁也是如此)。此外,与进程相比,线程的内存占用要低得多。因此,可以获得资源共享和相关的性能优势。
上下文切换
上下文切换是操作系统上不幸的现实-每次操作系统从运行一个进程切换到运行另一个进程时都必须进行的元工作(我们有自愿和非自愿的上下文切换)。上下文切换所需的实际时间高度依赖于硬件系统和操作系统的软件质量;通常情况下,对于基于 x86 的硬件系统,大约在几十微秒的范围内。这听起来很小:要想知道为什么这被认为很重要(而且确实很浪费),看看在平均 Linux 台式电脑上运行vmstat 3的输出(vmstat(1)是一个著名的实用程序;以这种方式使用,它给我们提供了系统活动的一个很好的总体视图;嘿,还可以尝试它的现代继任者dstat(1)):
$ vmstat 3
procs --------memory----------- --swap-- --io-- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 287332 664156 719032 6168428 1 2 231 141 73 22 23 16 60 1 0
0 0 287332 659440 719056 6170132 0 0 0 124 2878 2353 5 5 89 1 0
1 0 287332 660388 719064 6168484 0 0 0 104 2862 2224 4 5 90 0 0
0 0 287332 662116 719072 6170276 0 0 0 427 2922 2257 4 6 90 1 0
0 0 287332 662056 719080 6170220 0 0 0 12 2358 1984 4 5 91 0 0
0 0 287332 660876 719096 6170544 0 0 0 88 2971 2293 5 6 89 1 0
0 0 287332 660908 719104 6170520 0 0 0 24 2982 2530 5 6 89 0 0
[...]
(请查阅vmstat(1)的 man 页面,详细解释所有字段)。在system标题下,我们有两列:in和cs(硬件)中断和上下文切换,分别表示在过去一秒内发生的。只需看看数字(尽管忽略第一行输出)!这是相当高的。这就是为什么这对系统设计者来说真的很重要。
在同一进程的线程之间进行上下文切换所需的工作量(因此时间)要比在不同进程(或属于不同进程的线程)之间要少得多。这是有道理的:当整个进程保持不变时,大部分内核代码可以有效地被短路。因此,这成为使用线程的另一个优势。
线程的简要历史
线程-一个顺序控制流-现在已经存在很长时间了;只是以进程的名义存在(据报道,这是在 1965 年的伯克利分时系统时)。然后,在 20 世纪 70 年代初,Unix 出现了,将进程巩固为 VAS 和顺序控制流的组合。正如前面提到的,这现在被称为单线程模型,因为当然只有一个控制流-主函数-存在。
然后,1993 年 5 月,Sun Solaris 2.2 推出了 UI 线程,并推出了一个名为libthread的线程库,它公开了 UI API 集;实际上,这是现代线程。竞争的 Unix 供应商迅速推出了自己的专有多线程解决方案(带有暴露 API 的运行时库)-Digital 的 DECthreads(后来被 Compaq Tru64 Unix 吸收,随后是 HP-UX)、IBM 的 AIX、Silicon Graphics 的 IRIX 等等-每个都有自己的专有解决方案。
POSIX 线程
专有解决方案对拥有来自几家供应商的异构硬件和软件的大客户构成了重大问题;由于是专有的,很难让不同的库和 API 集相互通信。这是一个常见的问题-缺乏互操作性。好消息是:1995 年,IEEE 成立了一个单独的 POSIX 委员会-IEEE 1003.1c-POSIX 线程(pthreads)委员会,以制定多线程 API 的标准化解决方案。
POSIX:显然,IEEE 机构的原始名称是计算环境的便携式操作系统接口(POSICE)。Richard M. Stallman(RMS)建议将名称缩短为Unix 的便携式操作系统接口(POSIX),这个名称一直沿用至今。
因此,pthreads 是一个 API 标准;正式来说,是 IEEE 1003.1c-1995。所有 Unix 和类 Unix 操作系统供应商逐渐构建了支持 pthreads 的实现;因此,今天(至少在理论上),你可以编写一个 pthreads 多线程应用程序,并且它将在任何支持 pthreads 的平台上运行(在实践中,可能需要一些移植工作)。
Pthreads 和 Linux
当然,Linux 希望符合 POSIX 线程标准;但是谁会真正构建一个实现(记住,标准只是一个草案规范文件;它不是代码)?1996 年,Xavier Leroy 站出来构建了 Linux 的第一个 pthread 实现——一个名为 Linux 线程的线程库。总的来说,这是一个很好的努力,但并不完全兼容(当时全新的)pthread 标准。
早期解决问题的努力被称为下一代 Posix 线程(NGPT)。大约在同一时间,Red Hat 也派出一个团队来处理这个领域;他们称之为本机 Posix 线程库(NPTL)项目。在开源文化的最佳传统中,NGPT 开发人员与 NPTL 的同行合作,开始将 NGPT 的最佳特性合并到 NPTL 中。NGPT 的开发在 2003 年的某个时候被放弃;到那时,在 Linux 上实际的 pthread 实现——直到今天仍然存在的——是 NPTL。
更具体地说:尽管特性被集成到 2.6 版 Linux 内核(2003 年 12 月以后),NPTL 作为优越的线程 API 接口得到了巩固,这有助于大大提高线程性能。
NPTL 实现了 1:1 线程模型;这个模型提供了真正的多线程(用户和内核状态),也被称为本地线程模型。在这里,我们不打算深入探讨这些内部细节;在 GitHub 存储库的进一步阅读部分中提供了一个链接,供感兴趣的读者参考。
可以使用以下代码(在 Fedora 28 系统上)查找线程实现(自 glibc 2.3.2 以来):
$ getconf GNU_LIBPTHREAD_VERSION
NPTL 2.27
$
显然,这是 NPTL。
线程管理——基本的 pthread API
在这个第一章关于多线程的第二个重要部分中,我们现在将专注于机制:使用 pthread API,程序员究竟如何以有效的方式创建和管理线程?我们将探索基本的 pthread API 接口,以实现这一关键目的;这种知识是编写功能性和性能友好的 pthread 应用程序的基础。
我们将通过 API 集来介绍线程的生命周期——创建、终止、等待(等待)、以及一般地管理进程的线程。我们还将涵盖线程堆栈管理。
这当然意味着我们在 Linux 系统上安装了一个 pthread 运行时库。在现代 Linux 发行版上,这肯定是这样;只有在使用相当古怪的嵌入式 Linux 时,您才需要验证这一点。Linux 平台上 pthread 库的名称是 libpthread。
关于 pthread API 的一些关键点如下:
-
所有 pthread API 都需要在源文件中包含
<pthread.h>头文件。 -
该 API 经常使用面向对象的数据隐藏和数据抽象概念;许多数据类型是内部 typedefs;这种设计是故意的:我们希望代码是可移植的。因此,程序员不应该假设类型,并且必须使用提供的辅助方法来访问和/或查询数据类型。 (当然,代码本身是通常的过程式 C;然而,许多概念都是围绕对象导向建模的。有趣的是,Linux 内核也遵循这种方法。)
线程创建
用于创建线程的 pthread API 是pthread_create(3);其签名如下:
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
在编译 pthread 应用程序时,非常重要的是指定-pthread gcc选项开关(它启用了使用 libpthread 库所需的宏(后续将详细介绍)。
pthread_create是调用以在调用进程中创建新线程的 API。成功时,新线程将与该进程中可能存在的其他线程并发(并行)运行;但它将运行什么代码呢?它将从运行start_routine函数的代码开始(这是 API 的第三个参数:指向函数的指针)。当然,这个线程函数随后可以进行任意数量的函数调用。
新线程的线程 ID 将被存储在不透明数据项thread中——第一个参数(这是一个值-结果样式的参数)。它的数据类型pthread_t是故意不透明的;我们不能假设它是整数(或任何其他东西)。我们很快将遇到何时以及如何使用线程 ID。
请注意,第三个参数,函数指针——新线程运行的例程本身接收一个 void*参数——一个通用指针。这是一种常见且有用的编程技术,使我们能够向新创建的线程传递绝对任何值。 (这种参数通常在文献中被称为客户数据或标签。)我们如何传递它?通过pthread_create(3)的第四个参数arg。
pthread_create(3)的第二个参数是线程属性结构;在这里,程序员应该传递正在创建的线程的属性(我们很快将讨论其中的一些)。有一个快捷方式:在这里传递NULL意味着库应该在创建线程时使用默认属性。然而,在某个 Unix 上的默认值可能与另一个 Unix 或 Linux 上的默认值有很大不同;编写可移植的代码意味着不要假设任何默认值,而是显式地初始化适合应用程序的属性。因此,我们的建议肯定是不要传递NULL,而是显式地初始化一个pthread_attr_t结构并将其传递(接下来的代码示例将说明这一点)。
最后,pthread_create(3)的返回值在成功时为0,失败时为非零;errno将根据需要设置为几个值(我们建议您参考pthread_create(3)的手册页了解这些细节)。
当创建新线程时,它会从创建线程那里继承某些属性;其中包括以下内容:
-
创建线程的能力集(回想一下我们在第八章中的讨论,进程能力);这是特定于 Linux 的
-
创建线程的 CPU 亲和性掩码;这是特定于 Linux 的
-
信号掩码
新线程中的任何未决信号和未决定时器(警报)都将被清除。新线程的 CPU 执行时间也将被重置。
只要你知道,在 Linux libpthreads 实现中,pthread_create(3)调用了clone(2)系统调用,在内核中实际上创建了线程。
有趣的是,现代 glibc 的fork实现也调用了clone(2)系统调用。传递给clone(2)的标志确定了如何进行资源共享。
是时候写一些代码了!我们将为 pthread 编写一个非常简单(实际上相当有 bug 的)hello, world.应用程序(ch14/pthreads1.c):
[...]
#include <pthread.h>
#include "../common.h"
#define NTHREADS 3
void * worker(void *data)
{
long datum = (long)data;
printf("Worker thread #%ld says: hello, world.\n", datum);
printf(" #%ld: work done, exiting now\n", datum);
}
int main(void)
{
long i;
int ret;
pthread_t tid;
for (i = 0; i < NTHREADS; i++) {
ret = pthread_create(&tid, NULL, worker, (void *)i);
if (ret)
FATAL("pthread_create() failed! [%d]\n", ret);
}
exit(EXIT_SUCCESS);
}
正如你所看到的,我们循环三次,在每次循环迭代时创建一个线程。注意pthread_create(3)的第三个参数——一个函数指针(只提供函数名称就足够了;编译器会自动处理剩下的部分);这是线程的工作例程。这里是函数worker。我们还传递第四个参数给pthread_create——记住这是客户数据,任何你想传递给新创建线程的数据;这里我们传递循环索引i(当然,我们适当地对其进行类型转换,以免编译器抱怨)。
在worker函数中,我们通过再次将void *强制转换回其原始类型long来访问客户数据(作为形式参数data接收):
long datum = (long)data;
然后我们只是发出了一些 printf 来显示,是的,我们确实在这里。请注意,所有工作线程都运行相同的代码——worker函数。这是完全可以接受的;请记住,代码(文本)是按页权限进行读取执行的;并行运行文本不仅是可以的,而且通常是可取的(提供高吞吐量)。
构建它,我们提供了 Makefile;请注意,所有 pthread API 默认情况下并未链接,就像 glibc 一样。不,它们当然在 libpthread 中,我们需要显式编译(到我们的源文件)并通过-pthread指令链接到我们的二进制可执行文件中。Makefile 中的以下片段显示了这一点:
CC := gcc
CFLAGS=-O2 -Wall -UDEBUG -pthread
LINKIN := -pthread
#--- Target :: pthreads1
pthreads1.o: pthreads1.c
${CC} ${CFLAGS} -c pthreads1.c -o pthreads1.o
pthreads1: common.o pthreads1.o
${CC} -o pthreads1 pthreads1.o common.o ${LINKIN}
现在构建已经可以工作了,但是请注意,这个程序实际上并不工作得很好!在下面的代码中,我们通过循环运行./pthreads1来执行一些测试运行:
$ for i in $(seq 1 5); do echo "trial run #$i:" ; ./pthreads1; done trial run #1:
Worker thread #0 says: hello, world.
Worker thread #0 says: hello, world.
trial run #2:
Worker thread #0 says: hello, world.
Worker thread #0 says: hello, world.
#0: work done, exiting now
trial run #3:
Worker thread #1 says: hello, world.
Worker thread #1 says: hello, world.
#1: work done, exiting now
trial run #4:
trial run #5: $
正如您所看到的,hello, world.消息只是间歇性地出现,并且在第 4 和第 5 次试运行中根本没有出现(当然,由于时间问题,您尝试这个程序时看到的输出肯定会有所不同)。
为什么会这样?很简单:我们无意中设置了一个有 bug 的情况——竞争!到底在哪里?仔细再看一遍代码:一旦循环结束,main()函数会做什么?它调用exit(3);因此整个进程终止,不仅仅是主线程!而且谁能说工作线程在这发生之前完成了他们的工作呢?啊——这位女士们先生们,这就是您经典的竞争。
那么,我们该如何修复它呢?目前,我们将只进行一些快速修复;避免竞争代码的正确方法是通过同步;这是一个重要的话题,值得单独一章来讨论(您将会看到)。好的,首先,让我们解决主线程过早退出的问题。
终止
exit(3)库 API 会导致调用进程以及其所有线程终止。如果您希望单个线程终止,请让它调用pthread_exit(3)API:
#include <pthread.h>
void pthread_exit(void *retval);
这个参数指定了调用线程的退出状态;目前,我们忽略它,只传递NULL(我们将很快研究如何使用这个参数)。
那么,回到我们的竞争应用程序(ch14/pthreads1.c);让我们制作一个第二个更好的版本(ch14/pthreads2.c)。实际上,我们第一个版本的问题是竞争——主线程调用exit(3),导致整个进程可能在工作线程有机会完成工作之前就死掉了。所以,让我们通过让main()调用pthread_exit(3)来解决这个问题!另外,为什么不让我们的线程工作函数通过显式调用pthread_exit(3)来正确终止呢?
以下是worker()和main()函数的修改后的代码片段(ch14/pthreads2.c):
void * worker(void *data)
{
long datum = (long)data;
printf("Worker thread #%ld running ...\n", datum);
printf("#%ld: work done, exiting now\n", datum);
pthread_exit(NULL);
}
[...]
for (i = 0; i < NTHREADS; i++) {
ret = pthread_create(&tid, NULL, worker, (void *)i);
if (ret)
FATAL("pthread_create() failed! [%d]\n", ret);
}
#if 1
pthread_exit(NULL);
#else
exit(EXIT_SUCCESS);
#endif
[...]
让我们尝试一下前面的程序:
$ ./pthreads2
Worker thread #0 running ...
#0: work done, exiting now
Worker thread #1 running ...
#1: work done, exiting now
Worker thread #2 running ...
#2: work done, exiting now
$
好多了!
鬼魂的回归
还有一个隐藏的问题。让我们进行更多的实验:让我们编写这个程序的第三个版本(让我们称之为ch14/pthreads3.c)。在这个版本中,我们假设工作线程需要更长的时间来完成他们的工作(比它们目前所需的时间长)。我们可以很容易地通过一个简单的sleep(3)函数来模拟这一点,这将被引入到工作例程中:
[...]
void * worker(void *data)
{
long datum = (long)data;
printf("Worker thread #%ld running ...\n", datum);
sleep(3);
printf("#%ld: work done, exiting now\n", datum);
pthread_exit(NULL);
}
[...]
让我们试一试:
$ ./pthreads3
Worker thread #0 running ...
Worker thread #1 running ...
Worker thread #2 running ...
*[... All three threads sleep for 3s ...]*
#1: work done, exiting now
#0: work done, exiting now
#2: work done, exiting now
$
好了?看起来很好。真的吗?还有一个快速而次要的修改必须完成;将睡眠时间从 3 秒增加到 30 秒,然后重新构建和重试(我们这样做的唯一原因是给最终用户一个机会输入ps(1)命令,如下面的屏幕截图所示,然后应用程序就会死掉)。现在,在后台运行,并仔细观察!

查看前面的屏幕截图:我们在后台运行pthreads3应用程序;该应用程序(实际上是应用程序的主线程)创建了另外三个线程。这些线程只是通过每个休眠三十秒来阻塞。当我们在后台运行进程时,我们可以在 shell 进程上获得控制;现在我们使用ps(1)和-LA选项开关运行。从ps(1)的 man 页面上:
-
-A:选择所有进程;与-e相同 -
-L:显示线程,可能带有 LWP 和 NLWP 列
好吧!(GNU)ps(1)甚至可以通过使用-L选项开关来显示每个活动的线程(也尝试一下ps H)。使用-L开关,ps输出的第一列是进程的 PID(对我们来说非常熟悉);第二列是轻量级进程(LWP);实际上,这是内核所见的单个线程的 PID。有趣。不仅如此,仔细看看这些数字:PID 和 LWP 匹配的地方是进程的main()线程;PID 和 LWP 不同的地方告诉我们这是一个子线程,或者更准确地说是属于进程的对等线程;LWP 是操作系统所见的线程 PID。因此,在我们的示例运行中,我们有进程 PID 为 3906,以及四个线程:第一个是main()线程(因为其 PID == 其 LWP 值),而其余三个具有相同的 PID——证明它们属于同一个进程,但它们各自的线程 PID(它们的 LWP)是唯一的——3907、3908 和 3909!
我们一直在提到的问题是,在ps输出的第一行(代表main线程)中,进程名称后面跟着短语
<defunct>(极端右侧)。敏锐的读者会记得defunct是zombie的另一个术语!是的,臭名昭著的僵尸又回来了。
主线程通过调用pthread_exit(3)(回想一下ch14/pthreads3.c中的主代码)在进程中的其他线程之前退出;因此 Linux 内核将其标记为僵尸。正如我们在第十章中学到的那样,僵尸是不受欢迎的实体;我们真的不希望有僵尸挂在那里(浪费资源)。因此,问题当然是如何防止主线程成为僵尸?答案很简单:不要允许主线程在应用程序中的其他线程之前终止;换句话说,建议始终保持main()活动,等待所有其他线程死亡,然后再终止自身(从而终止进程)。如何做到?继续阅读。
再次强调(但我们还是要说!):只要其中至少一个线程保持活动状态,进程就会保持活动状态。
作为一个快速的旁白,工作线程何时运行相对于彼此和主线程?换句话说,第一个创建的线程是否保证首先运行,然后是第二个线程,然后是第三个,依此类推?
简短的答案是:没有这样的保证。特别是在现代的对称多处理器(SMP)硬件和像 Linux 这样的现代多进程和多线程能力的操作系统上,运行时的实际顺序是不确定的(这是一种说法,即无法知道)。实际上,这取决于操作系统调度程序来做出这些决定(也就是说,在没有实时调度策略和线程优先级的情况下;我们将在本书的后面讨论这些主题)。
我们的./pthreads2示例程序的另一个试运行显示了这种情况:
$ ./pthreads2
Worker thread #0 running ...
#0: work done, exiting now
Worker thread #2 running ...
#2: work done, exiting now
Worker thread #1 running ...
#1: work done, exiting now
$
你能看到发生了什么吗?在前面的代码中显示的顺序是:thread #0,然后是thread #2,然后是thread #1!这是不可预测的。在设计多线程应用程序时,不要假设任何特定的执行顺序(我们将在以后的章节中介绍同步,教我们如何实现所需的顺序)。
死亡的方式有很多
线程如何终止?事实证明有几种方式:
-
通过调用
pthread_exit(3)。 -
通过从线程函数返回,返回值会被隐式传递(就像通过
pthread_exit参数一样)。 -
隐式地,通过从线程函数中跳出;也就是说,到达右括号
};但请注意,这并不推荐(稍后的讨论将告诉你为什么) -
任何调用
exit(3)API 的线程,当然会导致整个进程以及其中的所有线程死掉。 -
线程被取消(我们稍后会讨论)。
有太多线程了吗?
到目前为止,我们知道如何创建一个应用程序进程,并在其中执行一些线程。我们将重复我们的第一个演示程序ch14/pthreads1.c中的代码片段,如下:
#include <pthread.h>
#define NTHREADS 3
[...]
int main(void)
{
[...]
for (i = 0; i < NTHREADS; i++) {
ret = pthread_create(&tid, NULL, worker, (void *)i);
if (ret)
FATAL("pthread_create() failed! [%d]\n", ret);
}
[...]
显然,进程-实际上我们指的是进程的主线程(或应用程序)-进入循环,每次循环迭代都会创建一个线程。因此,当完成时,我们将有三个线程,加上主线程,总共有四个线程,在进程中活动。
这是显而易见的。这里的重点是:创建线程比使用fork(2)创建(子)进程要简单得多;使用 fork 时,我们必须仔细编写代码,让子进程运行其代码,而父进程继续其代码路径(回想一下 switch-case 结构;如果愿意,可以快速查看我们的ch10/fork4.c代码示例)。使用pthread_create(3),对于应用程序员来说变得很容易-只需在循环中调用 API-就可以了!在前面的代码片段中,想象一下调整它,将NTHREADS的值从 3 更改为 300;就这样,进程将产生 300 个线程。如果我们将NTHREADS设为 3,000 呢?或者 30,000!?
思考这一点会引发一些相关的问题:一,你实际上能创建多少线程?二,你应该创建多少线程?请继续阅读。
你能创建多少线程?
如果你仔细想想,底层操作系统对应用程序可以创建的线程数量肯定有一些人为的限制;否则,系统资源会很快被耗尽。事实上,这并不是什么新鲜事;我们在第三章中的整个讨论实际上就是关于类似的事情。
关于线程(和进程),有两个(直接)限制影响着任何给定时间点可以存在的线程数量:
- 每个进程的资源限制:你会回忆起我们在第三章中讨论过,有两个实用程序可以查看当前定义的资源限制:
ulimit(1)和prlimit(1),后者是现代接口。让我们快速看一下最大用户进程的资源限制;还要意识到,尽管使用了单词进程,但实际上应该将其视为线程:
$ ulimit -u
63223
$
同样,prlimit()向我们展示了以下内容:
$ prlimit --nproc
RESOURCE DESCRIPTION SOFT HARD UNITS
NPROC max number of processes 63223 63223 processes
$
在这里,我们已经向你展示了如何通过 CLI 查询限制;要查看如何进行交互和使用 API 接口来更改它,请参考第三章,资源限制。
- 系统范围限制:Linux 操作系统维护着一个系统范围的(而不是每个进程的)限制,限制了在任何给定时间点可以活动的线程总数。这个值通过 proc 文件系统暴露给用户空间:
$ cat /proc/sys/kernel/threads-max
126446
$
因此,要理解的是,如果违反了前两个限制中的任何一个,pthread_create(3)(以及类似地,fork(2))将失败(通常将errno设置为值EAGAIN再试一次;操作系统实际上是在说:“我现在无法为你做到这一点,请稍后再试一次”)。
你能改变这些值吗?当然可以,但通常情况下,你需要 root(超级用户)访问权限才能这样做。(同样,我们已经在第三章中详细讨论了这些要点,资源限制)关于系统范围的限制,你确实可以作为 root 来改变它。但是,请等一下,盲目地改变系统参数而不了解其影响是失去对系统控制的一种确定方式!所以,让我们首先问自己这个问题:操作系统在启动时设置threads-max限制的值是基于什么的?
简短的回答是:它与系统上的 RAM 数量成正比。这是有道理的:最终,内存是关于创建线程和进程的关键限制资源。
对于我们亲爱的操作系统级别的极客读者来说,更详细地说:内核代码在启动时设置了/proc/sys/kernel/threads-max的值,以便操作系统中的线程(任务)结构最多可以占用可用 RAM 的八分之一。(threads-max的最小值是 20;最大值是常量FUTEX_TID_MASK 0x3fffffff。)
此外,默认情况下,最大线程数的每进程资源限制是系统限制的一半。
从前面的代码中可以看出,我们得到的值是 126,446;这是在一台带有 16GB RAM 的本机 Linux 笔记本电脑上完成的。在一台带有 1GB RAM 的虚拟机上运行相同的命令会得到以下结果:
$ cat /proc/sys/kernel/threads-max
7420
$ prlimit --nproc
RESOURCE DESCRIPTION SOFT HARD UNITS
NPROC max number of processes 3710 3710 processes
$
将threads-max内核可调整值设置为过高的值——超过FUTEX_TID_MASK——将导致它被降低到该值(但是,当然,在任何情况下,这几乎肯定都太大了)。但即使在限制范围内,你也可能走得太远,导致系统变得脆弱(可能会受到拒绝服务(DoS)攻击的影响!)。在嵌入式 Linux 系统上,降低限制实际上可能有助于约束系统。
代码示例——创建任意数量的线程
所以,让我们来测试一下:我们将编写我们先前程序的一个简单扩展,这次允许用户指定要在进程中尝试创建的线程数量作为参数(ch14/cr8_so_many_threads.c)。主函数如下:
int main(int argc, char **argv)
{
long i;
int ret;
pthread_t tid;
long numthrds=0;
if (argc != 2) {
fprintf(stderr, "Usage: %s number-of-threads-to-create\n", argv[0]);
exit(EXIT_FAILURE);
}
numthrds = atol(argv[1]);
if (numthrds <= 0) {
fprintf(stderr, "Usage: %s number-of-threads-to-create\n", argv[0]);
exit(EXIT_FAILURE);
}
for (i = 0; i < numthrds; i++) {
ret = pthread_create(&tid, NULL, worker, (void *)i);
if (ret)
FATAL("pthread_create() failed! [%d]\n", ret);
}
pthread_exit(NULL);
}
这很简单:我们将用户传递的字符串值作为第一个参数转换为数字值,然后我们循环numthrds次,每次调用pthread_create(3),从而在每次循环迭代时创建一个全新的线程!一旦创建,新线程会做什么?很明显——它们执行worker函数的代码。让我们来看一下:
void * worker(void *data)
{
long datum = (long)data;
printf("Worker thread #%5ld: pausing now...\n", datum);
(void)pause();
printf(" #%5ld: work done, exiting now\n", datum);
pthread_exit(NULL);
}
同样,这非常简单:工作线程只是发出一个printf(3)——这很有用,因为它们打印出它们的线程号——当然只是循环索引。然后,它们通过pause(2)系统调用进入睡眠状态。(这个系统调用很有用:它是一个完美的阻塞调用;它会将调用线程置于睡眠状态,直到收到信号。)
好了,让我们试一试:
$ ./cr8_so_many_threads
Usage: ./cr8_so_many_threads number-of-threads-to-create
$ ./cr8_so_many_threads 300
Worker thread # 0: pausing now...
Worker thread # 1: pausing now...
Worker thread # 2: pausing now...
Worker thread # 3: pausing now...
Worker thread # 5: pausing now...
Worker thread # 6: pausing now...
Worker thread # 4: pausing now...
Worker thread # 7: pausing now...
Worker thread # 10: pausing now...
Worker thread # 11: pausing now...
Worker thread # 9: pausing now...
Worker thread # 8: pausing now...
[...]
Worker thread # 271: pausing now...
Worker thread # 299: pausing now...
Worker thread # 285: pausing now...
Worker thread # 284: pausing now...
Worker thread # 273: pausing now...
Worker thread # 287: pausing now...
[...]
^C
$
它起作用了(请注意,我们已经截断了输出,因为在本书中显示太多内容)。请注意,线程启动和执行的顺序(发出它们的printf)是随机的。我们可以看到,我们创建的最后一个线程是加粗显示的——线程# 299(0 到 299 是 300 个线程)。
现在,让我们再次运行它,但这次让它创建一个不可能的大数量的线程(我们目前正在一台带有 1GB RAM 的虚拟机上尝试这个):
$ prlimit --nproc ; ulimit -u RESOURCE DESCRIPTION SOFT HARD UNITS
NPROC max number of processes 3710 3710 processes
3710 $ ./cr8_so_many_threads 40000
Worker thread # 0: pausing now...
Worker thread # 1: pausing now...
Worker thread # 2: pausing now...
Worker thread # 4: pausing now...
[...]
Worker thread # 2139: pausing now...
Worker thread # 2113: pausing now...
Worker thread # 2112: pausing now...
FATAL:cr8_so_many_threads.c:main:52: pthread_create() #2204 failed ! [11]
kernel says: Resource temporarily unavailable
$
显然,你将看到的结果取决于你的系统;我们鼓励读者在不同的系统上尝试一下。此外,实际的失败消息可能出现在你的终端窗口的更高位置;向上滚动以找到它!
线程的名称,如ps(1)所示,等等,可以通过pthread_setname_np(3)API 来设置;请注意,np后缀意味着该 API 是不可移植的(仅限 Linux)。
应该创建多少个线程?
你创建的线程数量确实取决于应用程序的性质。在我们的讨论中,我们将考虑应用程序倾向于是 CPU 还是 I/O 限制。
在本章的前面(特别是在设计动机和重叠 CPU 与 I/O的部分),我们提到了一个事实,即一个线程在执行行为上,处于一个连续体的某个位置,介于两个极端之间:一个极端是完全 CPU 限制的任务,另一个极端是完全 I/O 限制的任务。这个连续体可以被想象成这样:

图 3:CPU 限制/I/O 限制连续体
一个 100%的 CPU 绑定线程将不断地在 CPU 上运行;一个 100%的 I/O 绑定线程是一个总是处于阻塞(或等待)状态的线程,从不在 CPU 上执行。这两个极端在真实应用中都是不现实的;然而,很容易想象出它们倾向于出现的领域。例如,涉及大量数学处理(科学模型,矢量图形,如 Web 浏览器中的 Flash 动画,矩阵乘法等),(解)压缩实用程序,多媒体编解码器等领域肯定倾向于更多地受 CPU 限制。另一方面,我们人类每天与之交互的许多(但不是所有)应用程序(想想你的电子邮件客户端,Web 浏览器,文字处理等)倾向于等待人类做一些事情;实际上,它们倾向于受 I/O 限制。
因此,尽管有点简化,但这仍然作为一个有用的设计经验法则:如果正在设计的应用程序在性质上受到 I/O 限制,那么创建甚至是大量等待工作的线程是可以的;这是因为它们大部分时间都会处于休眠状态,因此不会对 CPU 造成任何压力(当然,创建太多线程会对内存造成压力)。
另一方面,如果应用程序被确定为高度 CPU 限制,那么创建大量线程将会给系统带来压力(最终导致抖动-一种现象,其中元工作的时间比实际工作的时间更长!)。因此,对于 CPU 限制的工作负载,经验法则是:
max number of threads = number of CPU cores * factor;
where factor = 1.5 or 2.
但需要注意的是,确实存在一些 CPU 核心不提供任何超线程(HT)功能;在这样的核心上,因子应该保持为 1。
实际上,我们的讨论相当简单:许多现实世界的应用程序(想想像 Apache 和 NGINX 这样的强大的 Web 服务器)将根据确切的情况、配置预设和当前工作负载动态地创建和调整所需的线程数量。然而,前面的讨论作为一个起点,让你开始思考多线程应用程序的设计。
线程属性
在本章早期的线程创建讨论中,我们看到了pthread_create(3)API;第二个参数是指向线程属性结构的指针:const pthread_attr_t *attr。我们提到过,在这里传递 NULL,实际上是让库使用默认属性创建线程。虽然这确实是这样,但问题在于,对于真正可移植的应用程序,这是不够的。为什么?因为默认线程属性在不同的实现中实际上有很大的差异。正确的方法是在线程创建时显式指定线程属性。
首先,当然,我们需要了解 pthread 具有哪些属性。以下表格列举了这些属性:
| 属性 | 含义 | **APIs: **pthread_attr_... |
可能的值 | Linux 默认 |
|---|---|---|---|---|
| 分离状态 | 创建可连接或分离的线程 | pthread_attr_ [get|set]detachstate |
PTHREAD_CREATE_JOINABLE PTHREAD_CREATE_DETACHED | PTHREAD_CREATE_JOINABLE |
| 调度/争用范围 | 我们与之竞争资源(CPU)的线程集 | pthread_attr_``[get|set]scope |
PTHREAD_SCOPE_SYSTEM PTHREAD_SCOPE_PROCESS | PTHREAD_SCOPE_SYSTEM |
| 调度/继承 | 确定调度属性是从调用线程隐式继承还是从 attr 结构显式继承 | pthread_attr_``[get|set]inheritsched |
PTHREAD_INHERIT_SCHED PTHREAD_EXPLICIT_SCHED | PTHREAD_INHERIT_SCHED |
| 调度/策略 | 确定正在创建的线程的调度策略 | pthread_attr_``[get|set]schedpolicy | SCHED_FIFO SCHED_RR
SCHED_OTHER | SCHED_OTHER |
| 调度/优先级 | 确定正在创建的线程的调度优先级 | pthread_attr_``[get|set]schedparam |
结构 sched_param 保存 int sched_priority | 0(非实时) |
|---|---|---|---|---|
| 栈/保护区域 | 线程栈的保护区域 | pthread_attr_``[get|set]guardsize |
字节中的栈保护区域大小 | 1 页 |
| 栈/位置,大小 | 查询或设置线程的栈位置和大小 | pthread_attr_ [get|set]stack``pthread_attr_
[get|set]stackaddr``pthread_attr_
[get|set]stacksize | 字节中的栈地址和/或栈大小 | 线程栈位置:左到 OSThread 栈大小:8 MB |
正如您所看到的,要清楚地理解这些属性的确切含义需要进一步的信息。请耐心等待我们在本章(实际上是本书)中继续进行,因为其中的一些属性及其含义将变得非常清楚(调度的详细信息将在第十七章中显示,Linux 上的 CPU 调度)。
代码示例 - 查询默认线程属性
现在,一个有用的实验是查询新创建线程的默认属性,其属性结构指定为 NULL(默认)。如何?pthread_default_getattr_np(3)将起作用(请注意,再次,_np后缀意味着它是一个仅限 Linux 的非可移植 API):
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <pthread.h>
int pthread_getattr_default_np(pthread_attr_t *attr);
有趣的是,由于此函数依赖于定义_GNU_SOURCE宏,因此我们必须首先定义该宏(在源代码中的早期);否则,编译会触发警告并可能失败。(在我们的代码中,我们首先使用#include "../common.h",因为我们的common.h头文件定义了_GNU_SOURCE宏。)
我们的代码示例可以在这里找到,位于本书的 GitHub 存储库中:ch14/disp_defattr_pthread.c 。
在下面的代码中,我们在运行 4.17.12 Linux 内核的 Fedora x86_64 箱上进行了试验:
$ ./disp_defattr_pthread
Linux Default Thread Attributes:
Detach State : PTHREAD_CREATE_JOINABLE
Scheduling
Scope : PTHREAD_SCOPE_SYSTEM
Inheritance : PTHREAD_INHERIT_SCHED
Policy : SCHED_OTHER
Priority : 0
Thread Stack
Guard Size : 4096 bytes
Stack Size : 8388608 bytes
$
为了便于阅读,只显示了源代码的关键部分;要查看完整的源代码,构建并运行它,整个树都可以从 GitHub 克隆到这里:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
这里的关键函数显示在以下代码中(ch14/disp_defattr_pthread.c);我们首先查询和显示线程属性结构的“分离状态”(这些术语将很快详细解释):
static void display_thrd_attr(pthread_attr_t *attr)
{
int detachst=0;
int sched_scope=0, sched_inh=0, sched_policy=0;
struct sched_param sch_param;
size_t guardsz=0, stacksz=0;
void *stackaddr;
// Query and display the 'Detached State'
if (pthread_attr_getdetachstate(attr, &detachst))
WARN("pthread_attr_getdetachstate() failed.\n");
printf("Detach State : %s\n",
(detachst == PTHREAD_CREATE_JOINABLE) ? "PTHREAD_CREATE_JOINABLE" :
(detachst == PTHREAD_CREATE_DETACHED) ? "PTHREAD_CREATE_DETACHED" :
"<unknown>");
接下来,将查询和显示各种调度属性(一些细节稍后在第十七章中讨论,Linux 上的 CPU 调度):
//--- Scheduling Attributes
printf("Scheduling \n");
// Query and display the 'Scheduling Scope'
if (pthread_attr_getscope(attr, &sched_scope))
WARN("pthread_attr_getscope() failed.\n");
printf(" Scope : %s\n",
(sched_scope == PTHREAD_SCOPE_SYSTEM) ? "PTHREAD_SCOPE_SYSTEM" :
(sched_scope == PTHREAD_SCOPE_PROCESS) ? "PTHREAD_SCOPE_PROCESS" :
"<unknown>");
// Query and display the 'Scheduling Inheritance'
if (pthread_attr_getinheritsched(attr, &sched_inh))
WARN("pthread_attr_getinheritsched() failed.\n");
printf(" Inheritance : %s\n",
(sched_inh == PTHREAD_INHERIT_SCHED) ? "PTHREAD_INHERIT_SCHED" :
(sched_inh == PTHREAD_EXPLICIT_SCHED) ? "PTHREAD_EXPLICIT_SCHED" :
"<unknown>");
// Query and display the 'Scheduling Policy'
if (pthread_attr_getschedpolicy(attr, &sched_policy))
WARN("pthread_attr_getschedpolicy() failed.\n");
printf(" Policy : %s\n",
(sched_policy == SCHED_FIFO) ? "SCHED_FIFO" :
(sched_policy == SCHED_RR) ? "SCHED_RR" :
(sched_policy == SCHED_OTHER) ? "SCHED_OTHER" :
"<unknown>");
// Query and display the 'Scheduling Priority'
if (pthread_attr_getschedparam(attr, &sch_param))
WARN("pthread_attr_getschedparam() failed.\n");
printf(" Priority : %d\n", sch_param.sched_priority);
最后,线程栈属性被查询和显示:
//--- Thread Stack Attributes
printf("Thread Stack \n");
// Query and display the 'Guard Size'
if (pthread_attr_getguardsize(attr, &guardsz))
WARN("pthread_attr_getguardsize() failed.\n");
printf(" Guard Size : %9zu bytes\n", guardsz);
/* Query and display the 'Stack Size':
* 'stack location' will be meaningless now as there is no
* actual thread created yet!
*/
if (pthread_attr_getstack(attr, &stackaddr, &stacksz))
WARN("pthread_attr_getstack() failed.\n");
printf(" Stack Size : %9zu bytes\n", stacksz);
}
在前面的代码中,我们使用pthread_getattr_default_np(3) API 来查询默认线程属性。它的对应物,pthread_setattr_default_np(3) API,允许您在创建线程时指定默认线程属性应该是什么,并且将第二个参数传递给pthread_create(3)。请参阅其手册以获取详细信息。
有一种编写类似程序的替代方法:为什么不创建一个带有 NULL 属性结构的线程,从而使其成为默认属性,然后使用pthread_getattr_np(3) API 来查询和显示实际的线程属性?我们把这留给读者作为一个练习(事实上,pthread_attr_init(3)的 man 页面提供了这样一个程序)。
连接
想象一个应用程序,其中一个线程(通常是main)产生了几个其他工作线程。每个工作线程都有特定的工作要做;一旦完成,它就会终止(通过pthread_exit(3))。创建线程如何知道工作线程何时完成(终止)?啊,这正是连接的作用。通过连接,创建线程可以等待另一个线程在进程内终止。
这不是听起来非常像父进程发出的wait(2)系统调用等待子进程死亡吗?是的,但正如我们马上会看到的那样,它肯定不是完全相同的。
同样重要的是,终止的线程的返回值被传递给发出对其的连接的线程。这样,它就知道工作线程是否成功完成了它的任务(如果没有,失败的值可以被检查以找出失败的原因):
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
pthread_join(3)的第一个参数thread是要等待的线程的 ID。它终止时,调用线程将在第二个参数中接收到终止的线程的返回值(是的,这是一个值-结果风格的参数),这当然是通过其pthread_exit(3)调用传递的值。
因此,连接非常有帮助;使用这个结构,你可以确保一个线程可以阻塞在任何给定线程的终止上。特别是在main线程的情况下,我们经常使用这种机制来确保main等待所有其他应用程序线程在它自己终止之前终止(从而防止我们之前看到的僵尸)。这被认为是正确的方法。
回想一下,在前面的部分,“幽灵的回归”中,我们清楚地看到了main线程在其对应线程之前死亡,成为了一个无意识的僵尸(ch14/pthreads3.c程序)。建立在这个先前代码的快速示例将有助于澄清事情。所以,让我们增强那个程序 - 现在我们将它称为ch14/pthreads_joiner1.c - 以便我们的main线程通过调用pthread_join(3) API 等待所有其他线程死亡,然后自己终止:
int main(void)
{
long i;
int ret, stat=0;
pthread_t tid[NTHREADS];
pthread_attr_t attr;
/* Init the thread attribute structure to defaults */
pthread_attr_init(&attr);
/* Create all threads as joinable */
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);
// Thread creation loop
for (i = 0; i < NTHREADS; i++) {
printf("main: creating thread #%ld ...\n", i);
ret = pthread_create(&tid[i], &attr, worker, (void *)i);
if (ret)
FATAL("pthread_create() failed! [%d]\n", ret);
}
pthread_attr_destroy(&attr);
这里有几件事情需要注意:
-
随后执行连接,我们需要每个线程的 ID;因此,我们声明了一个
pthread_t的数组(tid变量)。每个元素将存储相应线程的 ID 值。 -
线程属性:
-
到目前为止,我们在创建线程时没有明确地初始化和使用线程属性结构。在这里,我们纠正了这个缺点。
pthread_attr_init(3)用于初始化(为默认值)属性结构。 -
此外,我们通过在结构中设置这个属性(通过
pthread_attr_setdetachstate(3)API)来明确地使线程可连接。 -
一旦线程被创建,我们必须销毁线程属性结构(通过
pthread_attr_destroy(3)API)。
关键是要理解,只有将其分离状态设置为可连接的线程才能被连接。有趣的是,可连接的线程以后可以被设置为分离状态(通过调用pthread_detach(3) API);没有相反的例程。
代码继续;现在我们向你展示线程worker函数:
void * worker(void *data)
{
long datum = (long)data;
int slptm=8;
printf(" worker #%ld: will sleep for %ds now ...\n", datum, slptm);
sleep(slptm);
printf(" worker #%ld: work (eyeroll) done, exiting now\n", datum);
/* Terminate with success: status value 0.
* The join will pick this up. */
pthread_exit((void *)0);
}
简单:我们让所谓的工作线程睡 8 秒然后死掉;这次,pthread_exit(3)传递0作为返回状态。在下面的代码片段中,我们继续main的代码:
// Thread join loop
for (i = 0; i < NTHREADS; i++) {
printf("main: joining (waiting) upon thread #%ld ...\n", i);
ret = pthread_join(tid[i], (void **)&stat);
if (ret)
WARN("pthread_join() failed! [%d]\n", ret);
else
printf("Thread #%ld successfully joined; it terminated with"
"status=%d\n", i, stat);
}
printf("\nmain: now dying... <Dramatic!> Farewell!\n");
pthread_exit(NULL);
}
这是关键部分:在循环中,主线程通过pthread_join(3)API 阻塞(等待)每个工作线程的死亡;第二个(值-结果风格)参数实际上返回刚终止的线程的状态。遵循通常的成功返回零的约定,因此允许主线程判断工作线程是否成功完成工作。
让我们构建并运行它:
$ make pthreads_joiner1
gcc -O2 -Wall -UDEBUG -c ../common.c -o common.o
gcc -O2 -Wall -UDEBUG -c pthreads_joiner1.c -o pthreads_joiner1.o
gcc -o pthreads_joiner1 pthreads_joiner1.o common.o -lpthread
$ ./pthreads_joiner1
main: creating thread #0 ...
main: creating thread #1 ...
worker #0: will sleep for 8s now ...
main: creating thread #2 ...
worker #1: will sleep for 8s now ...
main: joining (waiting) upon thread #0 ...
worker #2: will sleep for 8s now ...
*<< ... worker threads sleep for 8s ... >>*
worker #0: work (eyeroll) done, exiting now
worker #1: work (eyeroll) done, exiting now
worker #2: work (eyeroll) done, exiting now
Thread #0 successfully joined; it terminated with status=0
main: joining (waiting) upon thread #1 ...
Thread #1 successfully joined; it terminated with status=0
main: joining (waiting) upon thread #2 ...
Thread #2 successfully joined; it terminated with status=0
main: now dying... <Dramatic!> Farewell!
$
当工作线程死亡时,它们被main线程通过pthread_join接收或加入;不仅如此,它们的终止状态-返回值-可以被检查。
好的,我们将复制前面的程序并将其命名为ch14/pthreads_joiner2.c。我们唯一的改变是,不是让每个工作线程睡眠相同的 8 秒,而是让睡眠时间动态变化。我们将更改代码;例如,这一行将被更改为:sleep(slptm);
新的一行将如下所示:sleep(slptm-datum);
在这里,datum是传递给线程的值-循环索引。这样,我们发现工作线程的睡眠如下:
-
工作线程#0 睡眠(8-0)= 8 秒
-
工作线程#1 睡眠(8-1)= 7 秒
-
工作线程#2 睡眠(8-2)= 6 秒
显然,工作线程#2 将首先终止;那又怎样?嗯,想想看:与此同时,main线程正在循环pthread_join,但是按照线程#0,线程#1,线程#2 的顺序。现在,线程#0 将最后死亡,线程#2 将首先死亡。这会有问题吗?
让我们试一试:
$ ./pthreads_joiner2
main: creating thread #0 ...
main: creating thread #1 ...
main: creating thread #2 ...
main: joining (waiting) upon thread #0 ...
worker #0: will sleep for 8s now ...
worker #1: will sleep for 7s now ...
worker #2: will sleep for 6s now ... *<< ... worker threads sleep for 8s, 7s and 6s resp ... >>*
worker #2: work (eyeroll) done, exiting now
worker #1: work (eyeroll) done, exiting now
worker #0: work (eyeroll) done, exiting now
Thread #0 successfully joined; it terminated with status=0
main: joining (waiting) upon thread #1 ...
Thread #1 successfully joined; it terminated with status=0
main: joining (waiting) upon thread #2 ...
Thread #2 successfully joined; it terminated with status=0
main: now dying... <Dramatic!> Farewell!
$
我们注意到什么?尽管工作线程#2 首先死亡,但工作线程#0 首先加入,因为在代码中,这是我们首先等待的线程!
线程模型加入和进程模型等待
到目前为止,您应该已经开始意识到,尽管pthread_join(3)和wait(2)(以及家族)API 似乎非常相似,但它们肯定不是等价的;它们之间存在几个差异,并在以下表中列举出来:
| 情况 | 线程:pthread_join(3) |
进程:waitpid |
|---|---|---|
| 条件 | 等待的线程必须将其分离状态属性设置为可连接的,而不是分离的。 | 无;任何子进程都可以(实际上必须)等待(回想一下我们的fork 规则#7) |
| 层次结构 | 无:任何线程都可以加入任何其他线程;没有父子关系的要求。实际上,我们不认为线程像进程那样严格存在父子关系;所有线程都是对等的。 | 存在严格的父子关系层次结构;只有父进程可以等待子进程。 |
| 顺序 | 使用线程时,必须强制加入(等待)指定为pthread_join(3)参数的特定线程。换句话说,如果有,比如说,三个线程在运行,主线程在一个升序循环中发出加入,那么它必须等待线程#1 的死亡,然后是线程#2,然后是线程#3。如果线程#2 提前终止,那就没办法了。 |
使用wait,进程可以等待(或停止)任何子进程的死亡,或者使用waitpid指定等待的特定子进程。 |
| 信号 | 在线程死亡时不发送信号。 | 在进程死亡时,内核向父进程发送SIGCHLD信号。 |
关于pthread_join(3)的另外一些要点如下:
-
您需要线程的线程 ID 才能加入它;这是故意这样做的,以便我们实际上只加入我们应用程序进程的线程。尝试加入其他线程(比如第三方库线程)将是糟糕的设计。
-
如果我们正在等待的线程(已经死亡)已经死亡了怎么办?然后
pthread_join(3)立即返回。 -
如果一个线程试图加入自己会怎样?这会导致失败(
errno设置为EDEADLK)。 -
试图让几个线程加入一个线程会导致未定义的行为;要避免这种情况。
-
如果一个试图连接到另一个线程的线程被取消(稍后会讨论),目标线程保持原样(可连接)。
检查生命,超时
有时,我们可能会遇到这样的情况,我们想要检查特定线程是否仍然存活;通过pthread_tryjoin_np(3) API 就可以做到这一点:
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <pthread.h>
int pthread_tryjoin_np(pthread_t thread, void **retval);
int pthread_timedjoin_np(pthread_t thread, void **retval,
const struct timespec *abstime);
pthread_tryjoin_np(3)的第一个参数是我们要连接的线程;(第二个参数和往常一样,是目标线程的终止状态)。注意 API 中的 try 短语 - 这通常指定调用是非阻塞的;换句话说,我们对目标线程执行非阻塞连接。如果目标线程仍然存活,那么 API 将立即返回错误:errno将被设置为EBUSY(手册页告诉我们,这意味着在调用时线程尚未终止)。
如果我们想要等待(阻塞)直到目标线程死亡,但不是永远?换句话说,我们想要等待一段给定的最长时间。这可以通过pthread_timedjoin_np(3) API 实现;前两个参数与pthread_join相同,而第三个参数指定了绝对时间的超时(通常称为 Unix 时间 - 自 1970 年 1 月 1 日午夜以来经过的秒数(和纳秒数) - 纪元!)。
如第十三章所述,定时器,timespec数据结构的格式如下:
struct timespec {
time_t tv_sec; /* seconds */
long tv_nsec; /* nanoseconds */
};
这很简单;但是我们如何将时间指定为 UNIX 时间(或自纪元以来的时间)?我们建议读者参考pthread_timedjoin_np(3)的手册页,其中提供了一个简单的示例(同时,我们建议您尝试这个 API 作为练习)。
当使用pthread_timedjoin_np(3) API 时,我注意到另一件事:连接可能超时,然后继续释放一些资源,比如在工作线程仍然存活并使用它时执行free(3)在堆缓冲区上。这显然是一个错误;这也表明你必须仔细考虑和测试设计;通常,对所有工作线程执行阻塞连接,从而确保它们在释放资源之前已经全部终止,是正确的方法。
再次提醒您,API 的后缀_np表示它们是不可移植的(仅限 Linux)。
连接还是不连接?
一个明确设置为分离状态的线程不能被连接;那么当它死亡时会发生什么?它的资源将被库处理。
一个明确设置为可连接状态的线程(或者可连接是默认状态)必须被连接;否则会导致一种资源泄漏。所以要小心:如果你已经创建了可连接的线程,那么你必须确保连接已经完成。
通常认为,通过主线程对其他应用程序线程执行连接是最佳实践,因为这可以防止我们之前看到的僵尸线程行为。此外,对于创建线程来说,了解它的工作线程是否成功执行了任务,如果没有,原因是什么通常是很重要的。连接使所有这些成为可能。
然而,可能你的应用程序不想等待一些工作线程;在这种情况下,请确保将它们创建为分离状态。
参数传递
回想一下pthread_create(3) API 的签名:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) **(void *), void *arg**);
第三个参数是线程函数 - 实际上是新生线程的生命和范围。它接收一个类型为void *的参数;这个参数传递给新生线程的是通过第四个参数pthread_create传递的:void *arg。
正如前面提到的,它的数据类型是一个通用指针,这样我们就可以实际上将任何数据类型作为参数传递,然后在线程例程中适当地进行类型转换和使用。到目前为止,我们已经遇到了相同的简单用例 - 通常是将整数值作为参数传递。在我们的第一个简单的多线程应用程序ch14/pthreads1.c中,在我们的main函数中,我们做了以下操作:
long i;
int ret;
pthread_t tid;
for (i = 0; i < NTHREADS; i++) {
ret = pthread_create(&tid, NULL, worker, (void *)i);
...
}
而在线程例程worker中,我们进行了简单的类型转换和使用:
void * worker(void *data)
{
long datum = (long)data;
...
这很简单,但确实引发了一个非常明显的问题:在pthread_create(3) API 中,似乎只有一个占位符用于arg(参数),如何传递多个数据项 - 实际上是几个参数 - 给线程例程?
将结构作为参数传递
前面的标题揭示了答案:我们传递一个数据结构。但是,具体来说呢?为数据结构的指针分配内存,初始化它,并将指针强制转换为void *进行传递。(事实上,这是 C 程序员常用的方法。)在线程例程中,像往常一样,进行类型转换并使用它。
为了澄清,我们将尝试这个(ch14/param_passing/struct_as_param.c):
为了可读性,只显示了源代码的关键部分;要查看完整的源代码,构建并运行它,整个树可以在 GitHub 上克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux.
/* Our data structure that we intend to pass as a parameter to the threads. City Airport information. */
typedef struct {
char IATA_code[IATA_MAXSZ];
/* http://www.nationsonline.org/oneworld/IATA_Codes/ */
char city[CITY_MAXSZ]; /* city name */
float latitude, longitude; /* coordinates of the city airport */
unsigned int altitude; /* metres */
/* todo: add # runways, runway direction, radio beacons freq, etc etc */
unsigned long reserved; /* for future use */
} Airport; /* yes! the {lat,long,alt} tuple is accurate :-) */
static const Airport city_airports[3] = {
{ "BLR", "Bangalore International", 13.1986, 77.7066, 904, 0 },
{ "BNE", "Brisbane International", 27.3942, 153.1218, 4, 0 },
{ "BRU", "Brussels National", 50.9010, 4.4856, 58, 0 },
};
举个例子,我们构建了自己的机场信息数据结构 airport,然后设置了一个数组(city_airports),初始化了其中的一些成员。
在main函数中,我们声明了一个指向机场结构的指针数组;我们知道单独的指针没有内存,所以在线程创建循环中,我们为每个指针分配内存,然后将其初始化为一个机场(通过简单的memcpy(3)):
Airport * plocdata[NTHREADS];
...
// Thread creation loop
for (i = 0; i < NTHREADS; i++) {
printf("main: creating thread #%ld ...\n", i);
/* Allocate and initialize data structure to be passed to the
* thread as a parameter */
plocdata[i] = calloc(1, sizeof(Airport));
if (!plocdata[i])
FATAL("calloc [%d] failed\n", i);
memcpy(plocdata[i], &city_airports[i], sizeof(Airport));
ret = pthread_create(&tid[i], &attr, worker, (void *)plocdata[i]);
if (ret)
FATAL("pthread_create() index %d failed! [%d]\n", i, ret);
}
好吧,我们已经知道前面的代码并不是真正的最佳选择;我们本可以只将city_airports[i]结构指针作为线程的参数传递。为了举例说明,我们使用刚刚分配的plocdata[i]结构,将一个结构memcpy到另一个结构中。
然后,在pthread_create(3)调用中,我们将指向我们数据结构的指针作为第四个参数传递。这将成为线程的参数;在线程例程中,我们声明一个相同数据类型的arg指针,并将其等同于我们接收到的类型转换数据指针:
void * worker(void *data)
{
Airport * arg = (Airport *)data;
int slptm=8;
printf( "\n----------- Airports Details ---------------\n"
" IATA code : %.*s %32s\n"
" Latitude, Longitude, Altitude : %9.4f %9.4f %9um\n"
, IATA_MAXSZ, arg->IATA_code,
arg->city,
arg->latitude, arg->longitude, arg->altitude);
...
然后我们可以将arg用作指向 Airport 的指针;在前面的演示代码中,我们只是打印了结构中的值。我们鼓励读者构建并运行此代码。
在前面的代码中,你注意到了%.*s C printf 格式说明符的技巧吗?当我们想要打印一个不一定以 NULL 结尾的字符串时,%.*s格式说明符允许我们指定大小,然后是字符串指针。字符串将只打印大小字节。
线程参数 - 不要这样做
将参数传递给线程例程时要牢记的关键事情是,必须保证传递的参数是线程安全的;基本上,在线程(或线程)使用它时不会以任何方式进行修改。
(线程安全是处理线程的一个关键方面;在接下来的章节中,我们将经常回顾这一点)。
为了更清楚地理解可能的问题,让我们举几个典型的例子。在第一个例子中,我们将尝试将循环索引作为参数传递给新创建的线程,比如在主函数中(代码:ch14/pthreads1_wrong.c):
printf("main: &i=%p\n", &i);
for (i = 0; i < NTHREADS; i++) {
printf("Creating thread #%ld now ...\n", i);
ret = pthread_create(&tid, NULL, worker, (void *)&i);
...
}
你注意到了吗?我们将参数传递为&i。那么?在线程例程中正确解引用它应该仍然有效,对吧:
void * worker(void *data)
{
long data_addr = (long)data;
long index = *(long *)data_addr;
printf("Worker thread: data_addr=%p value=%ld\n",
(void *)data_addr, index);
pthread_exit((void *)0);
}
看起来不错 - 让我们试试看!
$ ./pthreads1_wrong
main: &i=0x7ffebe160f00
Creating thread #0 now ...
Creating thread #1 now ...
Worker thread: data_addr=0x7ffebe160f00 value=1
Creating thread #2 now ...
Worker thread: data_addr=0x7ffebe160f00 value=2
Worker thread: data_addr=0x7ffebe160f00 value=3 $
嗯,它有效。但等等,再试几次 - 时间巧合可能会让你误以为一切都很好,但实际上并非如此:
$ ./pthreads1_wrong
main: &i=0x7fff4475e0d0
Creating thread #0 now ...
Creating thread #1 now ...
Creating thread #2 now ...
Worker thread: data_addr=0x7fff4475e0d0 value=2
Worker thread: data_addr=0x7fff4475e0d0 value=2
Worker thread: data_addr=0x7fff4475e0d0 value=3
$
有一个错误!index的值已经两次评估为值2;为什么?仔细思考:我们已经通过引用将循环索引传递了 - 作为循环变量的指针。线程 1 启动,并查找其值 - 线程 2 也是如此,线程 3 也是如此。但等等:这里难道不可能存在竞争吗?难道不可能在线程 1 运行并查找循环变量的值时,它已经在其下发生了变化(因为,不要忘记,循环是在主线程中运行的)?当然,这正是在前面的代码中发生的。
换句话说,通过地址传递变量是不安全的,因为在它被读取(由工作线程)的同时被写入(由主线程)时,其值可能会发生变化;因此,它不是线程安全的,因此会出现错误(竞争)。
解决方案实际上非常简单:不要通过地址传递循环索引;只需将其作为文字值传递:
for (i = 0; i < NTHREADS; i++) {
printf("Creating thread #%ld now ...\n", i);
ret = pthread_create(&tid, NULL, worker, (void *)i);
...
}
现在,每个工作线程都收到了循环索引的副本,从而消除了任何竞争,使其安全。
现在,不要草率地得出结论,嘿,好吧,所以我们永远不应该将指针(地址)作为参数传递。当然可以!只要确保它是线程安全的 - 在主线程和其他应用线程操作它时,它的值不会在其下发生变化。
参考我们在上一节演示的ch14/struct_as_param.c代码;我们非常明确地将线程参数作为结构体的指针传递。仔细看:在主线程创建循环中,每个指针都是单独分配的(通过calloc(3))。因此,每个工作线程都收到了结构体的副本;因此,一切都是安全的,而且运行良好。
一个有趣的练习(我们留给读者)是故意在struct_as_param应用程序中插入一个缺陷,方法是只分配一个结构(而不是三个),并将其传递给每个工作线程。这次,它将是竞争的,并且(最终)会失败。
线程堆栈
我们知道,每当创建一个线程时,它都会为其堆栈获取一个新的、新分配的内存块。这导致了这样的理解:(显然,但我们还是要说明)在线程函数中声明的所有局部变量都将保持私有,因为它们将驻留在该线程的堆栈中。(参考本章中的图 2 - 新创建线程的新堆栈显示为红色)。此外,每当发生上下文切换时,堆栈指针(SP)寄存器会更新为指向当前线程的堆栈。
获取和设置线程堆栈大小
了解并能够更改线程堆栈的大小很重要(请参阅 GitHub 存储库中进一步阅读部分提供的链接,其中提到了如何设置某个平台的堆栈太小导致了随机且难以调试的故障的真实经验)。
那么,默认的线程堆栈大小是多少?答案已经提供了;回想一下我们在本章前面运行的disp_defattr_pthread程序(在代码示例 - 查询默认线程属性部分):它告诉我们,在(现代 NPTL)Linux 平台上,默认的线程堆栈大小为 8 MB。
pthread API 集提供了一些例程来设置和查询线程堆栈大小。一种方法如下:
#include <pthread.h>
int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
int pthread_attr_getstacksize(const pthread_attr_t *attr,
size_t *stacksize);
由于我们之前已经在早期的disp_defattr_pthread程序中使用了pthread_attr_getstacksize(3),我们将避免在这里再次展示它的用法。使用互补的pthread_attr_setstacksize(3) API 可以轻松设置线程大小-第二个参数是所需的大小(以字节为单位)。不过,这两个 API 都包含_attr_短语,这意味着栈大小实际上是从线程属性结构中设置或查询的,而不是从活动线程本身。这使我们了解到我们只能在创建线程时通过设置属性结构(当然,随后作为第二个参数传递给pthread_create(3))来设置或查询栈大小。一旦线程被创建,其栈大小就无法更改。这条规则的例外是主线程的栈。
栈位置
线程栈实际上位于内存中的哪个位置(从技术上讲,给定进程的 VAS 中的哪个位置)?以下几点有助于我们理解:
-
主线程的栈总是位于进程 VAS 的顶部。
-
进程中所有其他线程的栈位于进程堆段和主栈之间的某个位置;这个具体位置对应用程序开发人员来说事先是未知的;无论如何,我们不应该需要知道。
-
这与直接相关,但很重要:回想一下第二章,“虚拟内存”中提到,对于大多数处理器,栈符合栈向下增长的语义;也就是说,栈段的增长方向是朝着较低的虚拟地址。
虽然我们不应该需要,但是有没有一种方法可以指定线程栈的位置?如果你坚持的话,是的:pthread_attr_[get|set]stack(3) API 可以用于此目的,以及设置和/或查询线程栈的大小:
#include <pthread.h>
int pthread_attr_setstack(pthread_attr_t *attr,
void *stackaddr, size_t stacksize);
int pthread_attr_getstack(const pthread_attr_t *attr,
void **stackaddr, size_t *stacksize);
虽然您可以使用pthread_attr_setstack来设置栈位置,但建议将此工作留给操作系统。此外,如果您确实使用它,还建议栈位置stackaddr和栈大小stacksize都是系统页面大小的倍数(并且位置对齐到页面边界)。通过posix_memalign(3) API 可以轻松实现将线程栈对齐到页面边界(我们已经在第四章,“动态内存分配”中涵盖了此 API 的示例用法)。
要小心:如果您在线程属性结构中指定栈位置,并且在循环中创建线程(这是正常的方式),您必须确保每个线程都接收到唯一的栈位置(通常通过通过前述的posix_memalign(3)分配栈内存,然后将其返回值作为栈位置传递)。当然,将用于线程栈的内存页面必须具有读写权限(回想一下第四章,“动态内存分配”中的mprotect(2))。
说了这么多,设置和查询线程栈的机制是直截了当的;真正关键的一点是:(强调)测试您的应用程序,以确保提供的线程栈内存是足够的。正如我们在第十一章,“信号-第一部分”中看到的,栈溢出是一个严重的缺陷,并将导致未定义的行为。
栈保护
这很好地引出了下一个问题:有没有一种方法可以让应用程序知道堆栈内存处于危险之中,或者说,已经溢出了?确实有:堆栈保护。保护内存是一个或多个虚拟内存页面的区域,它被故意放置,并且具有适当的权限,以确保任何尝试访问该内存都会导致失败(或某种警告;例如,SIGSEGV的信号处理程序可以提供这样的语义-但要注意一旦收到 SIGSEGV,我们就处于未定义状态,必须终止;但至少我们会知道并且可以修复堆栈大小!):
#include <pthread.h>
int pthread_attr_setguardsize(pthread_attr_t *attr, size_t guardsize);
int pthread_attr_getguardsize(const pthread_attr_t *attr,
size_t *guardsize);
保护区是在线程堆栈末尾分配的额外内存区域,其大小为指定的字节数。默认(保护)大小是系统页面大小。再次注意,保护大小是线程的一个属性,因此只能在线程创建时(而不是以后)指定。我们将运行(代码:ch14/stack_test.c)这样的应用程序:
$ ./stack_test
Usage: ./stack_test size-of-thread-stack-in-KB
$ ./stack_test 2560
Default thread stack size : 8388608 bytes
Thread stack size now set to : 2621440 bytes
Default thread stack guard size : 4096 bytes
main: creating thread #0 ...
main: creating thread #1 ...
main: creating thread #2 ...
worker #0:
main: joining (waiting) upon thread #0 ...
worker #1:
*** In danger(): here, sizeof long is 8
worker #2:
Thread #0 successfully joined; it terminated with status=1
main: joining (waiting) upon thread #1 ...
dummy(): parameter val = 115709118
Thread #1 successfully joined; it terminated with status=0
main: joining (waiting) upon thread #2 ...
Thread #2 successfully joined; it terminated with status=1
main: now dying... <Dramatic!> Farewell!
$
在前面的代码中,我们将 2,560 KB(2.5 MB)指定为线程堆栈大小。尽管这远低于默认值(8 MB),但事实证明足够了(至少对于 x86_64 来说,一个快速的粗略计算显示,对于给定的程序参数,我们将需要为每个线程堆栈分配至少 1,960 KB)。
在下面的代码中,我们再次运行它,但这次将线程堆栈大小指定为仅 256 KB:
$ ./stack_test 256
Default thread stack size : 8388608 bytes
Thread stack size now set to : 262144 bytes
Default thread stack guard size : 4096 bytes
main: creating thread #0 ...
main: creating thread #1 ...
worker #0:
main: creating thread #2 ...
worker #1:
main: joining (waiting) upon thread #0 ...
Segmentation fault (core dumped)
$
正如预期的那样,它导致段错误。
使用 GDB 检查核心转储将揭示关于为什么发生段错误的许多线索-包括非常重要的线程堆栈的状态(实际上是堆栈回溯)在崩溃时。然而,这超出了本书的范围。
我们绝对鼓励您学习使用诸如 GDB 这样强大的调试器(请参见 GitHub 存储库上的进一步阅读部分)。
此外(至少在我们的测试系统上),内核会向内核日志中发出有关此崩溃的消息;查找内核日志消息的一种方法是通过方便的实用程序dmesg(1)。以下输出来自 Ubuntu 18.04 框:
$ dmesg [...]
kern :info : [*<timestamp>*] stack_test_dbg[27414]: segfault at 7f5ad1733000 ip 0000000000400e68 sp 00007f5ad164aa20 error 6 in stack_test_dbg[400000+2000]
$
前面应用程序的代码可以在这里找到:ch14/stack_test.c:
为了便于阅读,只显示了源代码的关键部分;要查看完整的源代码,构建并运行它,整个树都可以从 GitHub 克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
int main(int argc, char **argv)
{
[...]
stack_set = atoi(argv[1]) * 1024;
[...]
/* Init the thread attribute structure to defaults */
pthread_attr_init(&attr);
[...]
/* Set thread stack size */
ret = pthread_attr_setstacksize(&attr, stack_set);
if (ret)
FATAL("pthread_attr_setstack(%u) failed! [%d]\n", TSTACK, ret);
printf("Thread stack size now set to : %10u bytes\n", stack_set);
[...]
在main中,我们展示了线程堆栈大小属性被初始化为用户传递的参数(以 KB 为单位)。然后代码继续创建三个工作线程,然后等待它们。
在线程工作例程中,我们只有线程#2 执行一些实际工作-你猜对了,是堆栈密集型工作。这段代码如下:
void * worker(void *data)
{
long datum = (long)data;
printf(" worker #%ld:\n", datum);
if (datum != 1)
pthread_exit((void *)1);
danger(); ...
danger函数,当然,是进行这项危险的、潜在的堆栈溢出工作的函数:
static void danger(void)
{
#define NEL 500
long heavylocal[NEL][NEL], alpha=0;
int i, j;
long int k=0;
srandom(time(0));
printf("\n *** In %s(): here, sizeof long is %ld\n",
__func__, sizeof(long));
/* Turns out to be 8 on an x86_64; so the 2d-array takes up
* 500 * 500 * 8 = 2,000,000 ~= 2 MB.
* So thread stack space of less than 2 MB should result in a segfault.
* (On a test box, any value < 1960 KB = 2,007,040 bytes,
* resulted in segfault).
*/
/* The compiler is quite intelligent; it will optimize away the
* heavylocal 2d array unless we actually use it! So lets do some
* thing with it...
*/
for (i=0; i<NEL; i++) {
k = random() % 1000;
for (j=0; j<NEL-1; j++)
heavylocal[i][j] = k;
/*printf("hl[%d][%d]=%ld\n", i, j, (long)heavylocal[i][j]);*/
}
for (i=0; i<NEL; i++)
for (j=0; j<NEL; j++)
alpha += heavylocal[i][j];
dummy(alpha);
}
前面的函数使用大量(线程)堆栈空间,因为我们声明了一个名为heavylocal的本地变量-一个NEL*NEL元素(NEL=500)的二维数组。在一个占用 8 字节的 x86_64 上,这大约相当于 2 MB 的空间!因此,将线程堆栈大小指定为小于 2 MB 的任何值应该导致堆栈溢出(堆栈保护内存区域实际上将检测到这一点),因此导致分段违规(或段错误);这正是发生的事情(正如您在我们的试运行中所看到的)。
有趣的是,如果我们仅声明本地变量但实际上没有使用它,现代编译器将会优化代码;因此,在代码中,我们努力对heavylocal变量进行一些(愚蠢的)使用。
关于堆栈保护内存区域的一些额外要点,以结束本讨论,如下:
-
如果应用程序使用了
pthread_attr_setstack(3),这意味着它正在自行管理线程堆栈内存,并且任何保护大小属性都将被忽略。 -
保护区域必须对齐到页面边界。
-
如果保护内存区域的大小小于一页,实际(内部)大小将会被舍入到一页;
pthread_attr_getguardsize(3)返回理论大小。 -
pthread_attr_[get|set]guardsize(3)的 man 页面提供了额外信息,包括实现中可能存在的 glibc 错误。
摘要
本章是关于在 Linux 平台上编写多线程应用程序的三章中的第一章。在这里,我们涵盖了两个关键领域:第一个是关于关于线程的重要概念,我们将其与进程模型进行了对比(我们在第九章进程执行和第十章进程创建中学习过)。我们详细介绍了为什么你会更喜欢多线程设计,并包括了三个例子。通过这种方式,我们展现了使用多线程设计方法的动机。
本章的第二部分着重介绍了实际的 pthread API(及其相关概念),我们如何创建线程,可以创建多少个线程以及应该创建多少个线程也有所讨论。还涉及了线程终止的基础知识,线程属性,向新创建的线程传递参数,什么是加入以及如何执行加入,最后还介绍了如何操纵线程堆栈(和堆栈保护)的细节。展示了许多示例程序来帮助巩固所学的概念。
在下一章中,我们将专注于另一个编写强大且安全的多线程软件的关键方面——并发性、竞争、临界区、死锁(及其避免)和原子性;我们如何使用互斥锁(及其变体)以及条件变量来处理这些问题。
第十五章:使用 Pthreads 进行多线程编程第二部分-同步
多线程强大并且在性能上产生巨大影响的一个关键原因是它适用于并行或并发的概念;根据我们在之前的[第十四章]中学到的,使用 Pthreads 进行多线程编程第一部分-基础,我们了解到一个进程的多个线程可以(而且确实)并行执行。在大型多核系统上(多核现在几乎是标准,即使在嵌入式系统中),效果会被放大。
然而,正如经验告诉我们的那样,总是存在权衡。并行性带来了丑陋的竞争和随后的缺陷的潜在可能。不仅如此,这种情况通常变得极其难以调试,因此也难以修复。
在本章中,我们将尝试:
-
让读者了解并发(竞争)缺陷的位置和具体内容
-
如何通过良好的设计和编码实践在多线程应用程序中避免这些问题
同样,本章分为两个广泛的领域:
-
在第一部分中,我们清楚地解释了问题,比如原子性的重要性和死锁问题。
-
在本章的后半部分,我们将介绍 pthread API 集提供给应用程序开发人员的锁定(和其他)机制,以帮助解决和完全避免这些问题。
竞争问题
首先,让我们尝试理解我们试图解决的问题是什么以及问题的确切位置。在上一章中,我们了解到一个进程的所有线程除了堆栈之外都共享一切;每个线程都有自己的私有堆栈内存空间。
仔细再看一下[第十四章],使用 Pthreads 进行多线程编程第一部分-基础:图 2(省略内核内容);虚拟地址空间-文本和数据段,但不包括堆栈段-在一个进程的所有线程之间共享。数据段当然是全局和静态变量所在的地方。
冒着过分强调这些事实的风险,这意味着给定进程的所有线程真正(如果不可能,则使 COW 也成为正常字体而不是写时复制(COW))共享以下内容:
-
文本段
-
数据段-初始化数据,未初始化数据(之前称为 BSS)和堆段
-
几乎所有由操作系统维护的进程的内核级对象和数据(再次参考[第十四章],使用 Pthreads 进行多线程编程第一部分-基础:图 2*)
一个非常重要的理解点是共享文本段根本不是问题。为什么?文本是代码;机器代码-构成我们所谓的机器语言的操作码和操作数-驻留在这些内存页中。回想一下[第二章],虚拟内存,所有文本(代码)的页面都具有相同的权限:读-执行(r-x)。这很重要,因为多个线程并行执行文本(代码)不仅是可以的-而且是鼓励的!毕竟,这就是并行性的全部意义。想想看;如果我们只读取和执行代码,我们不以任何方式修改它;因此,即使在并行执行时,它也是完全安全的。
另一方面,数据页的权限为读-写(rw)。这意味着一个线程 A 与另一个线程 B 并行工作在一个数据页上时是固有的危险。为什么?这是相当直观的:它们可能会破坏页面内的内存值。(可以想象两个线程同时写入全局链表,例如。)关键点是,共享的可写内存必须受到保护,以防止并发访问,以便始终保持数据完整性。
要真正理解为什么我们如此关心这些问题,请继续阅读。
并发和原子性
并发执行意味着多个线程可以在多个 CPU 核心上真正并行运行。当这在文本(代码)上发生时,这是好事;我们获得了更高的吞吐量。然而,一旦我们在处理共享可写数据时并发运行,我们将遇到数据完整性的问题。这是因为文本是只读的(和可执行的),而数据是可读写的。
当然,我们真正想要的是贪婪地同时拥有两全其美的情况:通过多个线程并发执行代码,但是在必须处理共享数据的时候停止并发(并行),并且只有一个线程按顺序运行数据部分,直到完成,然后恢复并行执行。
教学银行账户示例
一个经典(教学)例子是有缺陷的银行账户软件应用。想象一下,卡卢尔(不用说,这里使用了虚构的名字和数字),一个自由职业的雕塑家,有一个银行账户;他目前的余额是 12000.00 美元。同时发生了两笔交易,分别是 3000 美元和 8000 美元的存款,这是他成功完成工作的付款。毫无疑问,很快,他的账户余额应该反映出 23000.00 美元的金额(假设没有其他交易)。
为了这个例子,让我们想象银行软件应用是一个多线程进程;为了保持简单,我们考虑一个线程被分派来处理一个交易。软件运行的服务器系统是一台强大的多核机器——它有 12 个 CPU 核心。当然,这意味着线程可以同时在不同的核心上并行运行。
因此,让我们想象一下,对于卡卢尔的每一笔交易,我们都有一个线程在运行来执行它——线程 A 和线程 B。线程 A(在 CPU#0 上运行)处理 3000 美元的第一笔存款,而线程 B(在 CPU#1 上运行)处理(几乎立即的)8000 美元的第二笔存款。
我们在这里考虑两种情况:
- 偶然情况下,交易成功进行。下图清楚地显示了这种情况:

图 1:银行账户;由于偶然而正确
- 再次偶然情况下,交易不成功进行。下图显示了这种情况:

图 2:银行账户;由于偶然而不正确
在前面的表格中,问题区域已经被突出显示:很明显,线程 B 已经对余额进行了无效读取——它读取了 12000 美元的陈旧值(t4 时刻的值),而不是获取实际的当前值 15000 美元,导致卡卢尔损失了 3000 美元。
这是怎么发生的?简而言之,竞争条件导致了问题。要理解这场竞赛,请仔细看前面的表格并想象活动:
-
代表账户当前余额的变量;余额是全局的:
-
它位于数据段中
-
它被进程的所有线程共享
-
在 t3 时刻,CPU#0 上的线程 A:存款 3000 美元;
余额仍然是 12000 美元(尚未更新) -
在 t4 时刻,CPU#1 上的线程 B:存款 8000 美元;余额仍然是 12000 美元(尚未更新)
-
在 t5 时刻:
-
CPU#0 上的线程 A:更新余额
-
同时,但在另一个核心上:
-
CPU#1 上的线程 B:更新余额
-
偶然情况下,如果线程 B 在 CPU#1 上比线程 A 在 CPU#0 上更新
余额变量早了几微秒!? -
然后,线程 B 读取余额为 12000 美元(少了 3000 美元!)这被称为脏读,是问题的核心。这种情况被称为竞争;竞争是一种结果不确定和不可预测的情况。在大多数情况下,这将是一个问题(就像这里一样);在一些罕见的情况下,这并不重要,被称为良性竞争。
需要强调的事实是,存款和更新余额(或相反,取款和更新余额)的操作必须保证是原子的。它们不能竞争,因为那将是一个缺陷(错误)。
短语原子操作(或原子性)在软件编程上下文中意味着一旦开始,操作将在没有中断的情况下完成。
临界区
我们如何修复前面的竞争?这实际上非常简单:我们必须确保,如前所述,银行操作 - 存款、取款等 - 被保证执行两件事:
-
成为在那个时间点上运行代码的唯一线程
-
原子性 - 完成,不中断
一旦实现了这一点,共享数据将不受损坏。必须以前述方式运行的代码部分称为临界区。
在我们虚构的银行应用程序中,运行执行银行操作(存款或取款)的线程必须在临界区中执行,如下所示:
图 3:临界区
现在,假设银行应用程序已经根据这些事实进行了更正;线程 A 和线程 B 的垂直时间线执行路径现在如下:
图 4:正确的银行应用程序 - 临界区
在这里,一旦线程 A 和线程 B 开始它们的(存款)操作,它们就会独自完成(不中断);因此,按顺序和原子方式。
总结一下:
-
临界区是必须的代码:
-
在处理一些共享资源(如全局数据)时,不受其他线程的干扰运行
-
原子地运行(完成,不中断)
-
如果临界区的代码可以与其他线程并行运行,这是一个缺陷(错误),称为竞争。
-
为了防止竞争,我们必须保证临界区的代码独立和原子地运行
-
为此,我们必须同步临界区
现在,问题是:我们如何同步临界区?继续阅读。
锁定概念
软件中有几种形式的同步;其中一种常见的形式,也是我们将要大量使用的一种,称为锁定。在编程术语中,锁,如应用程序开发人员所见,最终是作为变量实例化的数据结构。
当需要临界区时,只需将临界区的代码封装在锁和相应的解锁操作之间。(现在,不要担心代码级 API 细节;我们稍后会涵盖。在这里,我们只关注正确理解概念。)
让我们表示临界区,以及同步机制 - 锁 - 使用图表(前述图 3的超集):
图 5:带锁的临界区
锁的基本前提如下:
-
在任何给定时间点,只有一个线程可以持有或拥有锁;该线程是锁的所有者。
-
在解锁时,当多个线程尝试获取或获取锁时,内核将保证只有一个线程会获取锁。
-
获得锁的线程称为赢家(或锁的所有者);尝试但未获得锁的线程称为失败者。
因此,想象一下:假设我们有三个线程 A、B 和 C,在不同的 CPU 核心上并行运行,都试图获取锁。锁的保证是确切地有一个线程得到它 - 假设线程 C 获胜,获取锁(因此线程 C 是锁的赢家或所有者);线程 A 和 B 是失败者。之后会发生什么?
-
赢家线程将锁操作视为非阻塞调用;它继续进入临界区(可能在处理一些共享可写资源,如全局数据)。
-
失败的线程将锁操作视为阻塞调用;他们现在阻塞(等待),但究竟在等待什么?(回想一下,阻塞调用是指我们等待事件发生并在事件发生后解除阻塞。)嗯,当然是解锁操作!
-
获胜的线程在(原子地)完成临界区后执行解锁操作。
-
现在线程 A 或 B 将获得锁,整个序列重复。
更一般地说,我们现在可以理解为:如果有 N 个线程竞争一个锁,那么锁操作(由操作系统)的保证是只有一个线程——获胜者——会获得锁。因此,我们将有一个获胜者和 N-1 个失败者。获胜的线程进入临界区的代码;与此同时,所有 N-1 个失败者线程等待(阻塞)解锁操作。在将来的某个时刻(希望很快),获胜者执行解锁操作;这将重新触发整个序列:N-1 个失败者再次竞争锁;我们将有一个获胜者和 N-2 个失败者;获胜的线程进入临界区的代码。与此同时,所有 N-2 个失败者线程等待(阻塞)解锁操作,依此类推,直到所有失败者线程都成为获胜者并因此运行了临界区的代码。
它是原子的吗?
关于对临界区进行原子执行的必要性的前述讨论可能会让您,程序员,感到担忧:也许您正在想,如何才能识别临界区?嗯,这很容易:如果您有并行性的潜力(多个线程可以并行运行通过代码路径)并且代码路径正在处理某些共享资源(通常是全局或静态数据),那么您就有一个临界区,这意味着您将通过锁定来保护它。
一个快速的经验法则:在大多数情况下,多个线程将通过代码路径运行。因此,从一般意义上讲,任何一种可写的共享资源的存在——全局变量、静态变量、IPC 共享内存区域,甚至是表示设备驱动程序中硬件寄存器的数据项——都会使代码路径成为临界区。规则是:保护它。
我们在上一节中看到的虚构的银行账户示例清楚地表明,我们有一个需要保护的临界区(通过锁定)。然而,有些情况下,我们可能并不清楚是否确实需要锁定。举个例子:在一个多线程的 C 应用程序中,我们有一个全局整数g;在某个时刻,我们增加它的值,比如:g++。
看起来很简单,但等等!这是一个可写的共享资源——全局数据;多个线程可能会并行运行这段代码,因此它成为一个需要保护的临界区(通过锁)。是的?还是不是?
乍一看,简单的增量(或减量)操作可能看起来是原子的(回想一下,原子操作是指在没有中断的情况下完成),因此不需要通过锁或任何其他形式的同步进行特殊保护。但事实真的是这样吗?
在我们继续之前,还有一个关键事实需要注意,那就是,现代微处理器上唯一保证原子性的是单个机器语言指令。每当一个机器指令完成后,CPU 上的控制单元会检查是否需要处理其他事情,通常是硬件中断或(软件)异常条件;如果需要,它会将程序计数器(IP 或 PC)设置为该地址并进行分支;如果不需要,执行将继续顺序进行,PC 寄存器将适当递增。
因此,请仔细考虑这一点:增量操作g++是否原子取决于两个因素:
-
正在使用的微处理器的指令集架构(ISA)(更简单地说,这取决于 CPU 本身)
-
该处理器的 C 编译器如何生成代码
如果编译器为g++ C 代码生成了单个机器语言指令,那么执行确实是原子的。但是真的吗?让我们找出来!(实证的重要性——实验,尝试事物——是一个关键特征;我们的第十九章,故障排除和最佳实践,涵盖了更多这样的要点)。
一个非常有趣的网站,godbolt.org(屏幕截图将随后出现),允许我们看到各种编译器如何编译给定的高级语言代码(在撰写本书时,它支持 14 种语言,包括 C 和 C++,以及各种编译器,当然包括 gcc(1)和 clang(1)。有趣的是,将语言下拉菜单设置为 C++后,还可以通过 gcc 为 ARM 进行编译!)。
让我们从访问这个网站开始,然后进行以下操作:
-
通过下拉菜单选择 C 作为语言
-
在右窗格中,选择编译器为 x86_64 gcc 8.2
-
在左窗格中,输入以下程序:
int g=41;
int main(void)
{
g ++;
}
以下是输出:

图 6:通过 x86_64 上的 gcc 8.2 进行 g++增量,无优化
看看右窗格——可以看到编译器生成的汇编语言(当然,随后将成为与处理器 ISA 相对应的机器代码)。那么呢?请注意,g++ C 高级语言语句在其左窗格中以淡黄色突出显示;右窗格中使用相同的颜色突出显示相应的汇编。有什么明显的发现吗?单行 C 代码g++已经变成了四条汇编语言指令。因此,根据我们之前的学习,这段代码本身不能被认为是原子的(但我们当然可以使用锁来强制它成为原子)。
下一个实验:保持一切不变,只是注意到在右窗格中有一个文本小部件,你可以在其中输入传递给编译器的选项开关;我们输入-O2,表示我们希望编译器使用优化级别 2(一个相当高的优化级别)。现在,输出为:

图 7:通过 x86_64 上的 gcc 8.2 进行 g++增量,优化级别 2
g++ C 代码现在只剩下一个汇编指令,因此确实变成了原子的。
使用 ARM 编译器,没有优化,g++转换为几行汇编——显然不是原子的:

图 8:通过 ARM 上的 gcc 7.2.1 进行 g++增量,无优化
我们的结论?对于应用程序来说,我们编写的代码通常很重要,要跨(CPU)架构保持可移植性。在前面的例子中,我们清楚地发现,编译器为简单的g++操作生成的代码有时是原子的,有时不是。(这将取决于几个因素:CPU 的 ISA,编译器,以及它编译的优化级别-On等等。)因此,我们唯一可以得出的安全结论是:要安全,并且无论何时存在关键部分,都要保护它(使用锁或其他手段)。
脏读
许多对这些主题新手的程序员会做出一个致命的假设,认为类似这样:好吧,我明白了,当修改共享资源——比如全局数据结构——时,我将需要将代码视为关键部分,并用锁来保护它,但是,我的代码只是在全局链表上进行迭代;它只是读取它而不是写入它,因此,这不是一个关键部分,不需要保护(我甚至会因高性能而得到好处)。
请打破这个泡泡!这是一个关键部分。为什么?想象一下:当您的代码在全局链表上进行迭代(仅读取它)时,正因为您没有采取锁定或以其他方式进行同步,另一个写入线程很可能正在写入数据结构,而您正在读取它。想一想:这是一场灾难的预兆;您的代码很可能最终会读取过时或不一致的数据。这就是所谓的脏读,当您不保护关键部分时,它可能发生。实际上,这正是我们虚构的银行应用示例中的缺陷。
再次强调这些事实:
-
如果代码正在访问任何类型的可写共享资源,并且存在并行性的潜力,那么它就是一个关键部分。保护它。
-
这些的一些副作用包括:
-
如果您的代码确实具有并行性,但仅适用于局部变量,则没有问题,这不是关键部分。(记住:每个线程都有自己的私有堆栈,因此在没有显式保护的情况下使用局部变量是可以的。)
-
如果全局变量标记为
const,那当然没问题——它是只读的,在任何情况下。
(尽管在 C 语言中,const 关键字实际上并不保证值确实是常量(通常理解的常量)!它只意味着变量是只读的,但它所引用的数据仍然可以在另一个指针从下面访问时被更改,使用宏而不是 const 关键字可能有所帮助)。
正确使用锁定有一个学习曲线,可能比其他编程结构陡峭一些;这是因为,首先必须学会识别关键部分,因此需要锁定(在前一节中介绍),然后学习和使用良好的设计锁定指南,第三,理解并避免令人讨厌的死锁!
锁定指南
在本节中,我们将提出一组小而重要的启发式或指导原则,供开发人员在设计和实现使用锁的多线程代码时牢记。这些可能适用于特定情况,也可能不适用;通过经验,人们学会在适当的时候应用正确的指导原则。
话不多说,它们在这里:
-
保持锁定粒度足够细:锁定数据,而不是代码。
-
简单是关键:涉及多个锁和线程的复杂锁定方案不仅会导致性能问题(极端情况是死锁),还会导致其他缺陷。保持设计尽可能简单始终是一个好的实践。
-
预防饥饿:持有锁定的时间任意长会导致失败者线程饿死;必须设计——并确实测试——以确保,作为一个经验法则,每个关键部分(在 lock 和 unlock 操作之间的代码)尽快完成。良好的设计确保代码关键部分不会花费太长时间;在锁定中使用超时是缓解这个问题的一种方法(稍后详细介绍)。
-
还要了解锁定会产生瓶颈。锁定的良好物理类比如下:
-
漏斗:将漏斗的茎视为关键部分——它只宽到足够容纳一个线程通过(赢家);失败者线程则保持阻塞在漏斗口
-
多车道繁忙公路上的一个收费站
因此,避免长时间的关键部分是关键:
- 将同步构建到设计中,并避免诱惑,比如,好吧,我先写代码,然后再回来看锁定。通常情况下效果不佳;锁定本身就很复杂;试图推迟其正确的设计和实现只会加剧问题。
让我们更详细地检查这些观点中的第一个。
锁定粒度
在应用程序中工作时,假设有几个地方需要通过锁定来保护数据,换句话说,有几个关键部分:

图 9:具有几个关键部分的时间线
我们已经在时间线上用实心红色矩形显示了关键部分(正如我们所学到的,需要同步锁定)。开发人员可能会意识到,为什么不简化一下呢?只需在 t1 时刻获取一个锁,然后在 t6 时刻解锁它:

图 10:粗粒度锁定
这将保护所有的关键部分。但这是以性能为代价的。想想看;每次一个线程运行前面的代码路径时,它必须获取锁,执行工作,然后解锁。这没问题,但并行性呢?它实际上被打败了;从 t1 到 t6 的代码现在被序列化了。这种过度放大的锁定所有关键部分的行为被称为粗粒度锁定。
回想我们之前的讨论:代码(文本)从来不是问题——根本不需要在这里锁定;只需锁定可写共享数据的地方。这些就是关键部分!这就产生了细粒度锁定——我们只在关键部分开始的时候获取锁,并在结束的时候解锁;以下图表反映了这一点:

图 11:细粒度锁定
正如我们之前所述,一个好的经验法则是锁定数据,而不是代码。
超细粒度锁定总是最好的吗?也许不是;锁定是一个复杂的业务。实际工作表明,有时即使在代码上工作(纯文本——关键部分之间的代码),持有锁也是可以的。这是一个平衡的行为;开发人员理想情况下应该利用经验和试错来判断锁定的粒度和效率,不断测试和重新评估代码路径的健壮性和性能。
在任何方向上走得太远都可能是一个错误;锁定粒度太粗会导致性能不佳,但粒度太细也是如此。
死锁及其避免
死锁是一种不希望发生的情况,其中相关线程无法再取得进展。死锁的典型症状是应用程序(或设备驱动程序或任何其他软件)似乎“挂起”。
常见的死锁类型
思考一些典型的死锁场景将有助于读者更好地理解。回想一下,锁的基本前提是只能有一个赢家(获得锁的线程)和 N-1 个输家。另一个关键点是只有赢家线程才能执行解锁操作,没有其他线程可以这样做。
自死锁(重新锁定)
了解了上述信息,想象一下这种情况:有一个锁(我们称之为 L1)和三个竞争它的线程(我们称它们为 A、B 和 C);假设线程 B 是赢家。这没问题,但是如果线程 B 在其关键部分内再次尝试获取相同的锁 L1 会发生什么呢?嗯,想想看:锁 L1 当前处于锁定状态,因此迫使线程 B 在其解锁时阻塞(等待)。然而,除了线程 B 本身,没有其他线程可能执行解锁操作,因此线程 B 最终将永远等待下去!这就是死锁。这种类型的死锁被称为自死锁,或重新锁定错误。
有人可能会争辩,实际上确实存在这种情况,锁能够递归地被获取吗?是的,正如我们将在后面看到的,这可以在 pthread API 中完成。然而,良好的设计通常会反对使用递归锁;事实上,Linux 内核不允许这样做。
ABBA 死锁
在涉及嵌套锁定的情况下,可能会出现更复杂的死锁形式:两个或更多竞争线程和两个或更多锁。在这里,让我们以最简单的情况为例:两个线程(A 和 B)与两个锁(L1 和 L2)一起工作。
假设这是在垂直时间线上展开的情况,如下表所示:
| 时间 | 线程 A | 线程 B |
|---|---|---|
| t1 | 尝试获取锁 L1 | 尝试获取锁 L2 |
| t2 | 获取锁 L1 | 获取锁 L2 |
| t3 | <--- 在 L1 的临界区中 ---> | <--- 在 L2 的临界区中 ---> |
| t4 | 尝试获取锁 L2 | 尝试获取锁 L1 |
| t5 | 阻塞,等待 L2 解锁 | 阻塞,等待 L1 解锁 |
| <永远等待:死锁> | <永远等待:死锁> |
很明显,每个线程都在等待另一个解锁它想要的锁;因此,每个线程都永远等待,保证了死锁。这种死锁通常被称为致命拥抱或 ABBA 死锁。
避免死锁
显然,避免死锁是我们希望确保的事情。除了锁定指南部分涵盖的要点之外,还有一个关键点,那就是获取多个锁的顺序很重要;始终保持锁定顺序一致将提供对抗死锁的保护。
为了理解原因,让我们重新看一下刚才讨论过的 ABBA 死锁场景(参考上表)。再次看表格:注意线程 A 获取锁 L1,然后尝试获取锁 L2,而线程 B 则相反。现在我们将表示这种情况,但有一个关键的警告:锁定顺序!这一次,我们将有一个锁定顺序规则;它可能很简单,比如:首先获取锁 L1,然后获取锁 L2:
锁 L1 --> 锁 L2
考虑到这种锁定顺序,我们发现情况可能会如下展开:
| 时间 | 线程 A | 线程 B |
|---|---|---|
| t1 | 尝试获取锁 L1 | 尝试获取锁 L1 |
| t2 | 获取锁 L1 | |
| t3 | <等待 L1 解锁> | <--- 在 L1 的临界区中 ---> |
| t4 | 解锁 L1 | |
| t5 | 获取锁 L1 | |
| t6 | <--- 在 L1 的临界区中 ---> | 尝试获取锁 L2 |
| t7 | 解锁 L1 | 获取锁 L2 |
| t8 | 尝试获取锁 L2 | <--- 在 L2 的临界区中 |
| t9 | <等待 L2 解锁> | ---> |
| t10 | 解锁 L2 | |
| t11 | 获取锁 L2 | <继续其他工作> |
| t12 | <--- 在 L2 的临界区中 ---> | ... |
| t13 | 解锁 L2 | ... |
关键点在于两个线程尝试按照给定顺序获取锁;首先是 L1,然后是 L2。在上表中,我们可以想象一种情况,即线程 B 首先获取锁,迫使线程 A 等待。这是完全正常和预期的;不发生死锁是整个重点。
确切的顺序本身并不重要;重要的是设计者和开发者记录并遵守要遵循的锁定顺序。
锁定顺序语义,实际上开发者关于这一关键点的评论,通常可以在 Linux 内核源代码树(截至本文撰写时的版本 4.19)中找到。以下是一个例子:virt/kvm/kvm_main.c``...
/*
* 锁的顺序:
*
* kvm->lock --> kvm->slots_lock --> kvm->irq_lock
*/
...
因此,回顾我们的第一个表格,我们现在可以清楚地看到,死锁发生是因为违反了锁定顺序规则:线程 B 在获取锁 L1 之前获取了锁 L2!
使用 pthread API 进行同步
既然我们已经涵盖了所需的理论背景信息,让我们继续进行实际操作:在本章的其余部分,我们将专注于如何使用 pthread API 进行同步,从而避免竞争。
我们已经了解到,为了保护任何类型的可写共享数据,我们需要在临界区域进行锁定。pthread API 为这种情况提供了互斥锁;我们打算只在临界区域内短暂持有锁。
然而,有些情况下,我们需要一种不同类型的同步 - 我们需要根据某个数据元素的值进行同步;pthread API 为这种情况提供了条件变量(CV)。
让我们依次来看看这些。
互斥锁
互斥锁一词实际上是互斥排斥的缩写;对于所有其他(失败的)线程的互斥排斥,一个线程 - 赢家 - 持有(或拥有)互斥锁。只有在它被解锁时,另一个线程才能获取锁。
一个常见问题:信号量和互斥锁之间的真正区别是什么?首先,信号量可以以两种方式使用 - 一种是作为计数器(使用计数信号量对象),另一种(我们这里关注的)基本上是作为互斥锁 - 二进制信号量。
二进制信号量和互斥锁之间存在两个主要区别:一是信号量用于在进程之间进行同步,而不是单个进程内部的线程(它确实是一个众所周知的 IPC 设施);互斥锁用于同步给定(单个)进程的线程。 (话虽如此,可以创建一个进程共享的互斥锁,但这并不是默认值)。
其次,信号量的 SysV IPC 实现提供了这样的可能性,即内核可以在所有者进程被突然终止时(总是可能通过信号#9)解锁信号量(通过semop(2) SEM_UNDO标志);对于互斥锁,甚至不存在这样的可能性 - 获胜者必须解锁它(我们稍后将介绍开发人员如何确保这一点)。
让我们从一个简单的示例开始,初始化、使用和销毁互斥锁。在这个程序中,我们将创建三个线程,仅在线程的工作例程中每次增加三个全局整数。
为了可读性,只显示了源代码的关键部分;要查看完整的源代码,请构建并运行它。整个树可在 GitHub 上克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
代码:ch15/mutex1.c:
static long g1=10, g2=12, g3=14; /* our globals */
pthread_mutex_t mylock; /* lock to protect our globals */
为了使用互斥锁,必须首先将其初始化为未锁定状态;可以这样做:
if ((ret = pthread_mutex_init(&mylock, NULL)))
FATAL("pthread_mutex_init() failed! [%d]\n", ret);
或者,我们可以将初始化作为声明来执行,例如:
pthread_mutex_t mylock = PTHREAD_MUTEX_INITIALIZER;
实际上,有一些可以为互斥锁指定的互斥属性(通过pthread_mutexattr_init(3) API);我们将在本章后面介绍这一点。现在,属性将是系统默认值。
另外,一旦完成,我们必须销毁互斥锁:
if ((ret = pthread_mutex_destroy(&mylock)))
FATAL("pthread_mutex_destroy() failed! [%d]\n", ret);
通常情况下,我们在循环中创建(三个)工作线程(我们不在这里显示这段代码,因为它是重复的)。这是线程的工作例程:
void * worker(void *data)
{
long datum = (long)data + 1;
if (locking)
pthread_mutex_lock(&mylock);
/*--- Critical Section begins */
g1 ++; g2 ++; g3 ++;
printf("[Thread #%ld] %2ld %2ld %2ld\n", datum, g1, g2, g3);
/*--- Critical Section ends */
if (locking)
pthread_mutex_unlock(&mylock);
/* Terminate with success: status value 0.
* The join will pick this up. */
pthread_exit((void *)0);
}
因为我们正在使用每个线程的可写共享(它在数据段中!)资源进行操作,我们意识到这是一个临界区域!
因此,我们必须保护它 - 在这里,我们使用互斥锁。因此,在进入临界区域之前,我们首先获取互斥锁,然后处理全局数据,然后解锁我们的锁,使操作安全地抵御竞争。(请注意,在前面的代码中,我们只在变量称为locking为真时才执行锁定和解锁;这是一种测试代码的故意方式。在生产中,当然,请取消if条件并执行锁定!)细心的读者还会注意到,我们将临界区域保持得相当短 - 它只包含全局更新和随后的printf(3),没有更多的内容。(这对于良好的性能很重要;回想一下我们在前一节关于锁定粒度中学到的内容)。
如前所述,我们故意为用户提供了一个选项,可以完全避免使用锁定,这当然会导致错误行为。让我们试一试:
$ ./mutex1
Usage: ./mutex1 lock-or-not
0 : do Not lock (buggy!)
1 : do lock (correct)
$ ./mutex1 1
At start: g1 g2 g3
10 12 14
[Thread #1] 11 13 15
[Thread #2] 12 14 16
[Thread #3] 13 15 17
$
它确实按预期工作。即使我们将参数传递为零,从而关闭锁定,程序(通常)似乎也能正常工作:
$ ./mutex1 0
At start: g1 g2 g3
10 12 14
[Thread #1] 11 13 15
[Thread #2] 12 14 16
[Thread #3] 13 15 17
$
为什么?啊,这很重要要理解:回想一下我们在前一节“它是原子的吗?”中学到的内容。对于一个简单的整数增量和编译器优化设置为高级别(实际上是-O2),整数增量很可能是原子的,因此不真正需要锁定。然而,这并不总是情况,特别是当我们对整数变量进行比简单的增量或减量更复杂的操作时(考虑读取/写入一个大的全局链表等)!最重要的是:我们必须始终识别关键部分并确保我们保护它们。
看到竞争
为了确切地演示这个问题(实际上看到数据竞争),我们将编写另一个演示程序。在这个程序中,我们将计算给定数字的阶乘(一个快速提醒:3!= 3 x 2 x 1 = 6;从学校时代记得的符号 N!表示 N 的阶乘)。以下是相关代码:
为了便于阅读,只显示了源代码的关键部分;要查看完整的源代码,构建并运行它。整个树可以从 GitHub 克隆到这里:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux.
代码:ch15/facto.c:
在main()中,我们初始化我们的互斥锁(并创建两个工作线程;我们没有显示创建线程、销毁线程以及互斥锁的代码):
printf( "Locking mode : %s\n"
"Verbose mode : %s\n",
(gLocking == 1?"ON":"OFF"),
(gVerbose == 1?"ON":"OFF"));
if (gLocking) {
if ((ret = pthread_mutex_init(&mylock, NULL)))
FATAL("pthread_mutex_init() failed! [%d]\n", ret);
}
...
线程的worker例程如下:
void * worker(void *data)
{
long datum = (long)data + 1;
int N=0;
...
if (gLocking)
pthread_mutex_lock(&mylock);
/*--- Critical Section begins! */
factorize(N);
printf("[Thread #%ld] (factorial) %d ! = %20lld\n",
datum, N, gFactorial);
/*--- Critical Section ends */
if (gLocking)
pthread_mutex_unlock(&mylock);
...
识别临界区,我们获取(然后解锁)我们的互斥锁。factorize函数的代码如下:
/*
* This is the function that calculates the factorial of the given parameter.
Stress it, making it susceptible to the data race, by turning verbose mode On; then, it will take more time to execute, and likely end up "racing" on the value of the global gFactorial. */
static void factorize(int num)
{
int i;
gFactorial = 1;
if (num <= 0)
return;
for (i=1; i<=num; i++) {
gFactorial *= i;
VPRINT(" i=%2d fact=%20lld\n", i, gFactorial);
}
}
仔细阅读前面的评论;这对这个演示很关键。让我们试一试:
$ ./facto
Usage: ./facto lock-or-not [verbose=[0]|1]
Locking mode:
0 : do Not lock (buggy!)
1 : do lock (correct)
(TIP: turn locking OFF and verbose mode ON to see the issue!)
$ ./facto 1
Locking mode : ON
Verbose mode : OFF
[Thread #2] (factorial) 12 ! = 479001600
[Thread #1] (factorial) 10 ! = 3628800
$
结果是正确的(自行验证)。现在我们关闭锁定并打开详细模式重新运行它:
$ ./facto 0 1
Locking mode : OFF
Verbose mode : ON
facto.c:factorize:50: i= 1 fact= 1
facto.c:factorize:50: i= 2 fact= 2
facto.c:factorize:50: i= 3 fact= 6
facto.c:factorize:50: i= 4 fact= 24
facto.c:factorize:50: i= 5 fact= 120
facto.c:factorize:50: i= 6 fact= 720
facto.c:factorize:50: i= 7 fact= 5040
facto.c:factorize:50: i= 8 fact= 40320
facto.c:factorize:50: i= 9 fact= 362880
facto.c:factorize:50: i=10 fact= 3628800
[Thread #1] (factorial) 10 ! = 3628800
facto.c:factorize:50: i= 1 fact= 1
facto.c:factorize:50: i= 2 fact= 7257600 *<-- Dirty Read!*
facto.c:factorize:50: i= 3 fact= 21772800
facto.c:factorize:50: i= 4 fact= 87091200
facto.c:factorize:50: i= 5 fact= 435456000
facto.c:factorize:50: i= 6 fact= 2612736000
facto.c:factorize:50: i= 7 fact= 18289152000
facto.c:factorize:50: i= 8 fact= 146313216000
facto.c:factorize:50: i= 9 fact= 1316818944000
facto.c:factorize:50: i=10 fact= 13168189440000
facto.c:factorize:50: i=11 fact= 144850083840000
facto.c:factorize:50: i=12 fact= 1738201006080000
[Thread #2] (factorial) 12 ! = 1738201006080000
$
啊哈!在这种情况下,10!是正确的,但12!是错误的!我们可以从前面的输出中清楚地看到发生了脏读(在计算 12!时的 i==2 迭代中),导致了缺陷。当然:我们在这里没有保护关键部分(锁定被关闭);难怪出错了。
我们再次要强调的是,这些竞争是微妙的时间巧合;在一个有错误的实现中,你的测试用例可能仍然会成功,但这并不能保证任何事情(它很可能在实际应用中失败,正如墨菲定律告诉我们的那样!)。(一个不幸的事实是测试可以揭示错误的存在,但不能保证它们的不存在。重要的是,第十九章,故障排除和最佳实践,涵盖了这些要点)。
读者会意识到,由于这些数据竞争是微妙的时间巧合,它们可能会或可能不会在您的测试系统上完全如此发生。多次重试应用程序可能有助于重现这些情况。
我们留给读者尝试在锁定模式和详细模式下使用用例;当然它应该工作。
互斥锁属性
互斥锁可以有几个与之关联的属性。此外,我们列举了其中的几个。
互斥锁类型
互斥锁可以是四种类型之一,默认情况下通常是正常互斥锁,但并不总是(这取决于实现)。使用的互斥锁类型会影响锁定和解锁的行为。这些类型是:PTHREAD_MUTEX_NORMAL,PTHREAD_MUTEX_ERRORCHECK,PTHREAD_MUTEX_RECURSIVE 和 PTHREAD_MUTEX_DEFAULT。
系统手册中关于pthread_mutex_lock(3)的行为取决于互斥锁类型的表格;为了读者方便,我们在这里重复了相同的内容。
如果线程尝试重新锁定已经锁定的互斥锁,则pthread_mutex_lock(3)将按照以下表格中的重新锁定列中描述的行为进行。如果线程尝试解锁未锁定或已解锁的互斥锁,则pthread_mutex_unlock(3)将按照以下表格中的非所有者解锁列中描述的行为进行:

如果互斥锁类型为 PTHREAD_MUTEX_DEFAULT,则pthread_mutex_lock(3)的行为可能对应于前表中描述的三种其他标准互斥锁类型之一。如果它不对应于这三种中的任何一种,对于标记为†的情况,行为是未定义的。
重新锁定列直接对应于我们在本章前面描述的自死锁场景,比如,尝试重新锁定已经锁定的锁(或许是诗意的措辞?)会产生什么影响。显然,除了递归和错误检查互斥锁的情况,最终结果要么是未定义的(这意味着任何事情都可能发生!),要么确实是死锁。
同样,除了拥有者之外的任何线程尝试解锁互斥锁都会导致未定义行为或错误。
人们可能会想:为什么锁定 API 的行为会根据互斥锁的类型而有所不同——在错误返回或失败方面?为什么不为所有类型都设定一个标准行为,从而简化情况?嗯,这是简单性和性能之间的通常权衡:实现的方式允许,例如,一个编写良好、在程序上经过验证正确的实时嵌入式应用程序放弃额外的错误检查,从而获得速度(这在关键代码路径上尤为重要)。另一方面,在开发或调试环境中,开发人员可能选择允许额外的检查,以便在发货前捕捉缺陷。(pthread_mutex_destroy(3)的 man 页面有一个名为错误检查和性能支持之间的权衡的部分,其中对这个方面进行了比较详细的描述。)
get和set互斥锁类型属性的一对 API(在上表的第一列)非常直接:
include <pthread.h>
int pthread_mutexattr_gettype(const pthread_mutexattr_t *restrict attr, int *restrict type);
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);
鲁棒互斥锁属性
看一下上表,人们会注意到鲁棒性列;这是什么意思?回想一下,只有互斥锁的拥有者线程可能解锁互斥锁;现在,我们问,如果拥有者线程碰巧死亡会怎么样?(首先,良好的设计将确保这种情况永远不会发生;其次,即使发生了,也有方法来保护线程取消,这是我们将在下一章中讨论的一个主题。)从表面上看,没有帮助;任何其他等待锁的线程现在都将陷入死锁(实际上,它们将被挂起)。这实际上是默认行为;这也是由称为 PTHREAD_MUTEX_STALLED 的鲁棒属性设置的行为。在这种情况下,可能的救援存在于另一个鲁棒互斥锁属性的值:PTHREAD_MUTEX_ROBUST。可以通过以下一对 API 查询和设置这些属性:
#include <pthread.h>
int pthread_mutexattr_getrobust(const pthread_mutexattr_t *attr,
int *robustness);
int pthread_mutexattr_setrobust(const pthread_mutexattr_t *attr,
int robustness);
如果在互斥锁上设置了此属性(值为 PTHREAD_MUTEX_ROBUST),那么如果拥有者线程在持有互斥锁时死亡,随后对锁的pthread_mutex_lock(3)将成功返回值EOWNERDEAD。不过,即使调用返回了(所谓的)成功返回,重要的是要理解,相关的锁现在被认为处于不一致状态,并且必须通过pthread_mutex_consistent(3)API 将其重置为一致状态:
int pthread_mutex_consistent(pthread_mutex_t *mutex);
这里返回值为零表示成功;互斥锁现在恢复到一致(稳定)状态,并且可以正常使用(使用它,当然在某个时候,你必须解锁它)。
总之,要使用鲁棒属性互斥锁,请使用以下方法:
- 初始化互斥锁:
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
-
在它上面设置 robust 属性:
pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST); -
拥有者线程
-
锁定它:
pthread_mutex_lock(&mylock)。 -
现在,假设线程所有者突然死亡(同时持有互斥锁)
-
另一个线程(可能是主线程)可以假定所有权:
-
首先,检测情况:
-
ret = pthread_mutex_lock(&mylock);
if (ret == EOWNERDEAD) {
- 然后,使其一致:
pthread_mutex_consistent(&mylock);
-
使用它(或解锁它)
-
解锁它:
pthread_mutex_unlock(&mylock);
我们不打算重复造轮子,我们将读者指向一个简单易读的示例,该示例使用了之前描述的 robust 互斥锁属性功能。在pthread_mutexattr_setrobust(3)的 man 页面中可以找到它。
在底层,Linux pthreads 互斥锁是通过futex(2)系统调用(因此由操作系统)实现的。futex(快速用户互斥锁)提供了快速、健壮、仅原子指令的锁定实现。更多详细信息的链接可以在 GitHub 存储库的进一步阅读部分中找到。
IPC、线程和进程共享的互斥锁
想象一个由几个独立的多线程进程组成的大型应用程序。现在,如果这些进程想要相互通信(他们通常会想要这样做),这该如何实现?答案当然是进程间通信(IPC)——为此目的存在的机制。广义上说,在典型的 Unix/Linux 平台上有几种 IPC 机制可用;这些包括共享内存(以及mmap(2))、消息队列、信号量(通常用于同步)、命名(FIFO)和无名管道、套接字(Unix 和互联网域),在一定程度上还有信号。
不幸的是,由于空间限制,我们在本书中没有涵盖进程 IPC 机制;我们敦促感兴趣的读者查看 IPC 部分在 GitHub 存储库的进一步阅读部分中提供的链接(和书籍)。
这里需要强调的是,所有这些 IPC 机制都是用于在 VM 隔离的进程之间进行通信。因此,我们在这里讨论的重点是多线程,那么给定进程内的线程如何相互通信呢?实际上很简单:就像可以设置并使用共享内存区域来有效和高效地在进程之间进行通信(写入和读取该区域,通过信号量同步访问),线程可以简单有效地使用全局内存缓冲区(或任何适当的数据结构)作为彼此通信的媒介,并且当然,通过互斥锁同步访问全局内存区域。
有趣的是,可以使用互斥锁作为不同进程的线程之间的同步原语。这是通过设置名为 pshared 或进程共享的互斥锁属性来实现的。获取和设置 pshared 互斥锁属性的一对 API 如下:
int pthread_mutexattr_getpshared(const pthread_mutexattr_t *attr,
int *pshared);
int pthread_mutexattr_setpshared(pthread_mutexattr_t *attr,
int pshared);
第二个参数 pshared 可以设置为以下之一:
-
PTHREAD_PROCESS_PRIVATE:默认值;在这里,互斥锁只对创建互斥锁的进程内的线程可见。
-
PTHREAD_PROCESS_SHARED:在这里,互斥锁对在创建互斥锁的内存区域中具有访问权限的任何线程可见,包括不同进程的线程。
但是,如何确保互斥锁存在的内存区域在进程之间是共享的(如果没有,将无法让相关进程使用互斥锁)?嗯,这实际上是基本的:我们必须使用我们提到的 IPC 机制之一——共享内存原来是正确的。因此,我们让应用程序设置一个共享内存区域(通过传统的 SysV IPC shmget(2)或较新的 POSIX IPC shm_open(2)系统调用),并且在这个共享内存中实例化我们的进程共享的互斥锁。
因此,让我们用一个简单的应用程序将所有这些联系在一起:我们将编写一个应用程序,创建两个共享内存区域:
-
一、一个小的共享内存区域,用作进程共享互斥锁和一次性初始化控制的共享空间(稍后详细介绍)
-
二、一个共享内存区域,用作存储 IPC 消息的简单缓冲区
我们将使用进程共享属性初始化互斥锁,以便在不同进程的线程之间同步访问;在这里,我们 fork 并让原始父进程和新生的子进程的线程竞争互斥锁。一旦它们(顺序地)获得它,它们将向第二个共享内存段写入消息。在应用程序结束时,我们销毁资源并显示共享内存缓冲区(作为一个简单的概念验证)。
让我们尝试一下我们的应用程序(ch15/pshared_mutex_demo.c):
为了便于阅读,我们在下面的代码中添加了一些空行。
$ ./pshared_mutex_demo
./pshared_mutex_demo:15317: shmem segment successfully created / accessed. ID=38928405
./pshared_mutex_demo:15317: Attached successfully to shmem segment at 0x7f45e9d50000
./pshared_mutex_demo:15317: shmem segment successfully created / accessed. ID=38961174
./pshared_mutex_demo:15317: Attached successfully to shmem segment at 0x7f45e9d4f000
[pthread_once(): calls init_mutex(): from PID 15317]
Worker thread #0 [15317] running ...
[thrd 0]: attempting to take the shared mutex lock...
[thrd 0]: got the (shared) lock!
#0: work done, exiting now
Child[15319]: attempting to taking the shared mutex lock...
Child[15319]: got the (shared) lock!
main: joining (waiting) upon thread #0 ...
Thread #0 successfully joined; it terminated with status=0
Shared Memory 'comm' buffer:
00000000 63 63 63 63 63 00 63 68 69 6c 64 20 31 35 33 31 ccccc.child 1531
00000016 39 20 68 65 72 65 21 0a 00 74 74 74 74 74 00 74 9 here!..ttttt.t
00000032 68 72 65 61 64 20 31 35 33 31 37 20 68 65 72 65 hread 15317 here
00000048 21 0a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 !...............
00000064 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000080 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000096 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000112 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
在现实世界中,事情并不像这样简单;还存在一个额外的同步问题需要考虑:如何确保互斥锁正确且原子地初始化(只由一个进程或线程),并且只初始化一次,其他线程应该如何使用它?在我们的演示程序中,我们使用了pthread_once(3) API 来实现互斥对象的一次性初始化(但忽略了线程等待并且只在初始化后使用的问题)。 (Stack Overflow 上的一个有趣的问答突出了这个问题;请看:stackoverflow.com/questions/42628949/using-pthread-mutex-shared-between-processes-correctly#*。)然而,事实是pthread_once(3) API 是用于在一个进程的线程之间使用的。此外,POSIX 要求once_control的初始化是静态完成的;在这里,我们在运行时执行了它,所以并不完美。
在main函数中,我们设置并初始化(IPC)共享内存段;我们敦促读者仔细阅读源代码(阅读所有注释),并自行尝试:
为了便于阅读,只显示了源代码的关键部分;要查看完整的源代码,请构建并运行它。整个树可以在 GitHub 上克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux.
...
/* Setup a shared memory region for the process-shared mutex lock.
* A bit of complexity due to the fact that we use the space within for:
* a) memory for 1 process-shared mutex
* b) 32 bytes of padding (not strictly required)
* c) memory for 1 pthread_once_t variable.
* We need the last one for performing guaranteed once-only
* initialization of the mutex object.
*/
shmaddr = shmem_setup(&gshm_id, argv[0], 0,
(NUM_PSMUTEX*sizeof(pthread_mutex_t) + 32 +
sizeof(pthread_once_t)));
if (!shmaddr)
FATAL("shmem setup 1 failed\n");
/* Associate the shared memory segment with the mutex and
* the pthread_once_t variable. */
shmtx = (pthread_mutex_t *)shmaddr;
mutex_init_once = (pthread_once_t *)shmaddr +
(NUM_PSMUTEX*sizeof(pthread_mutex_t)) + 32;
*mutex_init_once = PTHREAD_ONCE_INIT; /* see below comment on pthread_once */
/* Setup a second shared memory region to be used as a comm buffer */
gshmbuf = shmem_setup(&gshmbuf_id, argv[0], 0, GBUFSIZE);
if (!gshmbuf)
FATAL("shmem setup 2 failed\n");
memset(gshmbuf, 0, GBUFSIZE);
/* Initialize the mutex; here, we come across a relevant issue: this
* mutex object is already instantiated in a shared memory region that
* other processes might well have access to. So who will initialize
* the mutex? (it must be done only once).
* Enter the pthread_once(3) API: it guarantees that, given a
* 'once_control' variable (1st param), the 2nd param - a function
* pointer, that function will be called exactly once.
* However: the reality is that the pthread_once is meant to be used
* between the threads of a process. Also, POSIX requires that the
* initialization of the 'once_control' is done statically; here, we
* have performed it at runtime...
*/
pthread_once(mutex_init_once, init_mutex);
...
init_mutex函数用于使用进程共享属性初始化互斥锁,如下所示:
static void init_mutex(void)
{
int ret=0;
printf("[pthread_once(): calls %s(): from PID %d]\n",
__func__, getpid());
ret = pthread_mutexattr_init(&mtx_attr);
if (ret)
FATAL("pthread_mutexattr_init failed [%d]\n", ret);
ret = pthread_mutexattr_setpshared(&mtx_attr, PTHREAD_PROCESS_SHARED);
if (ret)
FATAL("pthread_mutexattr_setpshared failed [%d]\n", ret);
ret = pthread_mutex_init(shmtx, &mtx_attr);
if (ret)
FATAL("pthread_mutex_init failed [%d]\n", ret);
}
工作线程的代码——worker例程——如下所示。在这里,我们需要操作第二个共享内存段,这意味着这是一个关键部分。因此,我们获取进程共享锁,执行工作,然后解锁互斥锁:
void * worker(void *data)
{
long datum = (long)data;
printf("Worker thread #%ld [%d] running ...\n", datum, getpid());
sleep(1);
printf(" [thrd %ld]: attempting to take the shared mutex lock...\n", datum);
LOCK_MTX(shmtx);
/*--- critical section begins */
printf(" [thrd %ld]: got the (shared) lock!\n", datum);
/* Lets write into the shmem buffer; first, a 5-byte 'signature',
followed by a message. */
memset(&gshmbuf[0]+25, 't', 5);
snprintf(&gshmbuf[0]+31, 32, "thread %d here!\n", getpid());
/*--- critical section ends */
UNLOCK_MTX(shmtx);
printf("#%ld: work done, exiting now\n", datum);
pthread_exit(NULL);
}
请注意,锁定和解锁操作是通过宏执行的;这里它们是:
#define LOCK_MTX(mtx) do { \
int ret=0; \
if ((ret = pthread_mutex_lock(mtx))) \
FATAL("pthread_mutex_lock failed! [%d]\n", ret); \
} while(0)
#define UNLOCK_MTX(mtx) do { \
int ret=0; \
if ((ret = pthread_mutex_unlock(mtx))) \
FATAL("pthread_mutex_unlock failed! [%d]\n", ret); \
} while(0)
我们留给读者查看代码,在那里我们 fork 并让新生的子进程基本上做与前面的工作线程相同的事情——操作(相同的)第二个共享内存段;作为关键部分,它也尝试获取进程共享锁,一旦获取,执行工作,然后解锁互斥锁。
除非有令人信服的理由不这样做,在设置进程之间的 IPC 时,我们建议您使用专门为此目的设计的众多 IPC 机制之一(或其中一些)。使用进程共享互斥锁作为两个或多个进程的线程之间的同步机制是可能的,但请问自己是否真的需要。
话虽如此,使用互斥锁而不是传统的(二进制)信号量对象也有一些优点;其中包括互斥锁始终与一个所有者线程相关联,只有所有者才能对其进行操作(防止一些非法或有缺陷的情况),互斥锁可以设置为使用嵌套(递归)锁定,并有效地处理优先级反转问题(通过继承协议和/或优先级天花板属性)。
优先级反转,看门狗和火星
实时操作系统(RTOS)通常在其上运行时间关键的多线程应用程序。非常简单地说,但仍然是真的,RTOS 调度程序决定下一个要运行的线程的主要规则是最高优先级的可运行线程必须是正在运行的线程。(顺便说一下,我们将在第十七章中涵盖有关 Linux 操作系统的 CPU 调度,Linux 上的 CPU 调度;现在不用担心细节。)
优先级反转
让我们想象一个包含三个线程的应用程序;其中一个是高优先级线程(让我们称其为优先级为 90 的线程 A),另一个是低优先级线程(让我们称其为优先级为 10 的线程 B),最后是一个中等优先级线程 C。(SCHED_FIFO 调度策略的优先级范围是 1 到 99,99 是最高可能的优先级;稍后的章节中会详细介绍。)因此,我们可以想象我们在一个进程中有这三个不同优先级的线程:
-
线程 A:高优先级,90
-
线程 B:低优先级,10
-
线程 C:中等优先级,45
此外,让我们考虑一下我们有一些共享资源 X,线程 A 和 B 都渴望拥有它;这当然构成了一个关键部分,因此,我们需要同步访问它以确保正确性。我们将使用互斥锁来做到这一点。
正常情况可能是这样的(现在先忽略线程 C):线程 B 正在 CPU 上运行一些代码;线程 A 正在另一个 CPU 核心上处理其他事情。两个线程都不在关键部分;因此,互斥锁处于未锁定状态。
现在(在时间t1),线程 B 进入关键部分的代码并获取互斥锁,从而成为所有者。它现在运行关键部分的代码(处理 X)。与此同时,如果—在时间t2—线程 A 也碰巧进入关键部分,因此尝试获取互斥锁呢?嗯,我们知道它已经被锁定,因此线程 A 将不得不等待(阻塞),直到线程 B 执行(希望很快)解锁。一旦线程 B 解锁互斥锁(在时间t3),线程 A 获取它(在时间t4;我们认为延迟t4-t3非常小),生活(非常愉快地)继续。这看起来很好:

图 12:互斥锁定:正常情况
然而,也存在潜在的不良情况!继续阅读。
简要介绍看门狗定时器
看门狗是一种用于定期检测系统是否处于健康状态的机制,如果被认为不是,就会重新启动系统。这是通过设置(内核)定时器(比如,60 秒超时)来实现的。如果一切正常,看门狗守护进程(守护进程只是系统后台进程)将始终取消定时器(在其到期之前,当然),然后重新启用它;这被称为抚摸狗。如果守护进程没有这样做(由于某些事情出了大问题),看门狗就会生气并重新启动系统!纯软件看门狗实现将无法防止内核错误和故障;硬件看门狗(它连接到板复位电路)将始终能够在需要时重新启动系统。
通常,嵌入式应用的高优先级线程被设计为在其中必须完成一些工作的非常真实的截止日期;否则,系统被认为已经失败。人们不禁想,如果操作系统本身在运行时由于不幸的错误而崩溃或挂起(恐慌)会怎么样?然后应用线程就无法继续;我们需要一种方法来检测并摆脱这种困境。嵌入式设计人员经常利用看门狗定时器(WDT)硬件电路(以及相关的设备驱动程序)来精确实现这一点。如果系统或关键线程未能在截止日期前完成其工作(未能喂狗),系统将重新启动。
所以,回到我们的场景。假设我们对线程 A 的截止日期为 100 毫秒;在你的脑海中重复前面的锁定场景,但有一个区别(参考图 13:):
-
线程 B(低优先级线程)在时间t1获得互斥锁。
-
线程 A也在时间t2请求互斥锁(但必须等待线程 B 的解锁)。
-
在线程 B 完成关键部分之前,另一个中等优先级的线程 C(在同一 CPU 核心上运行,并且优先级为 45)醒来了!它会立即抢占线程 B,因为它的优先级更高(请记住,可运行的最高优先级线程必须是正在运行的线程)。
-
现在,在线程 C 离开 CPU 之前,线程 B 无法完成关键部分,因此无法执行解锁。
-
这反过来会显著延迟线程 A,它正在等待线程 B 尚未发生的解锁:
-
然而,线程 B 已被线程 C 抢占,因此无法执行解锁。
-
如果解锁的时间超过了线程 A 的截止日期(在时间t4)会怎么样?
-
然后看门狗定时器将会过期,强制系统重新启动:

图 13:优先级反转
有趣而不幸的是;你是否注意到最高优先级的线程(A)实际上被迫等待系统中优先级最低的线程(B)?这种现象实际上是一种已记录的软件风险,正式称为优先级反转。
不仅如此,想象一下,如果在线程 B 处于其关键部分(因此持有锁)时,有几个中等优先级的线程醒来会发生什么?线程 A 的潜在等待时间现在可能会变得非常长;这种情况被称为无界优先级反转。
火星探路者任务简介
非常有趣的是,这种精确的优先级反转场景在一个真正超凡脱俗的环境中发生了:在火星表面!美国宇航局成功地在 1997 年 7 月 4 日将一艘机器人飞船(探路者着陆器)降落在火星表面;然后它继续卸载并部署了一个更小的机器人——Sojourner Rover——到表面上。然而,控制器发现着陆器遇到了问题——每隔一段时间就会重新启动。对实时遥测数据的详细分析最终揭示了潜在问题——是软件,它遇到了优先级反转问题!值得赞扬的是,美国宇航局的喷气推进实验室(JPL)团队,以及 Wind River 公司的工程师,他们为美国宇航局提供了定制的 VxWorks RTOS,他们从地球上诊断和调试了这种情况,确定了根本原因是优先级反转问题,修复了它,上传了新的固件到探路者,一切都正常运行了:

图 14:火星探路者着陆器的照片
当微软工程师迈克·琼斯在 IEEE 实时研讨会上写了一封有趣的电子邮件,讲述了 NASA 的 Pathfinder 任务发生了什么事情时,这一消息以病毒式传播。这封电子邮件最终得到了 NASA 的 JPL 团队负责人格伦·里夫斯的详细回复,题为《火星上到底发生了什么?》。这和后续文章中捕捉到了许多有趣的见解。在我看来,所有软件工程师都应该读一读这些文章!(在 GitHub 存储库的进一步阅读部分查找提供的链接,标题为火星 Pathfinder 和优先级倒置。)
Glenn Reeves 强调了一些重要的教训和他们能够重现和解决问题的原因,其中之一是:我们坚信测试你所飞行的东西,飞行你所测试的哲学。实际上,由于设计决策将相关的详细诊断和调试信息保留在跟踪/日志环形缓冲区中,这些信息可以随意转储(并发送到地球),他们能够调试手头的根本问题。
优先级继承-避免优先级倒置
好的;但是如何解决优先级倒置这样的问题呢?有趣的是,这是一个已知的风险,互斥锁的设计包括了一个内置的解决方案。关于帮助解决优先级倒置问题的互斥锁属性存在两个——优先级继承(PI)和优先级上限。
PI 是一个有趣的解决方案。想想看,关键问题是操作系统调度线程的方式。在操作系统(尤其是在实时操作系统上),实时线程的调度——决定谁运行——基本上与竞争线程的优先级成正比:你的优先级越高,你运行的机会就越大。所以,让我们快速重新看一下我们之前的场景示例。回想一下,我们有这三个不同优先级的线程:
-
线程 A:高优先级,90
-
线程 B:低优先级,10
-
线程 C:中等优先级,45
优先级倒置发生在线程 B 长时间持有互斥锁时,从而迫使线程 A 在解锁时可能要等待太久(超过截止日期)。所以,想想这个:如果线程 B 一抓住互斥锁,我们就把它的优先级提高到系统上等待相同互斥锁的最高优先级线程的优先级。然后,当然,线程 B 将获得优先级 90,因此不能被抢占(无论是被线程 C 还是其他任何线程)!这确保了它快速完成临界区并解锁互斥锁;一旦解锁,它就会恢复到原来的优先级。这解决了问题;这种方法被称为 PI。
pthreads API 集提供了一对 API 来查询和设置协议互斥锁属性,你可以利用 PI:
int pthread_mutexattr_getprotocol(const pthread_mutexattr_t
*restrict attr, int *restrict protocol);
int pthread_mutexattr_setprotocol(pthread_mutexattr_t *attr,
int protocol);
协议参数可以取以下值之一:PTHREAD_PRIO_INHERIT,
PTHREAD_PRIO_NONE,或 PTHREAD_PRIO_PROTECT(默认为 PTHREAD_PRIO_NONE)。当互斥锁具有 INHERIT 或 PROTECT 协议之一时,其所有者线程在调度优先级方面会受到影响。
对于使用 PTHREAD_PRIO_INHERIT 协议初始化的任何互斥锁,持有锁(拥有它)的线程将继承任何线程的最高优先级(因此以该优先级执行),这些线程在任何使用此协议的互斥锁(鲁棒或非鲁棒)上阻塞(等待)。
对于使用 PTHREAD_PRIO_PROTECT 协议初始化的任何互斥锁,持有锁(拥有它)的线程将继承任何使用此协议的线程的最高优先级上限(因此以该优先级执行),无论它们当前是否在任何这些互斥锁(鲁棒或非鲁棒)上阻塞(等待)。
如果一个线程使用了使用不同协议初始化的互斥锁,它将以它们中定义的最高优先级执行。
在“开拓者”任务中,RTOS 使用的是著名的 VxWorks,由风河公司提供。互斥锁(或信号量)肯定具有 PI 属性;只是 JPL 软件团队忘记打开互斥锁的 PI 属性,导致了优先级反转问题!(实际上,软件团队对此非常清楚,并在几个地方使用了它,但没有在发生问题的地方使用 —— 这就是墨菲定律在起作用!)
此外,开发人员可以利用优先级上限——这是所有者线程执行临界区代码的最低优先级。因此,通过能够指定这一点,可以确保它具有足够高的值,以确保所有者线程在临界区时不会被抢占。Pthreads pthread_mutexattr_getprioceiling(3) 和 pthread_mutexattr_setprioceiling(3) API 可以用于查询和设置互斥锁的优先级上限属性。(它必须在有效的 SCHED_FIFO 优先级范围内,通常在 Linux 平台上为 1 到 99)。
再次强调,在实践中,使用优先级继承和上限属性存在一些挑战,主要是性能开销:
-
更重的任务/上下文切换可能会导致
-
优先级传播会增加开销
-
有许多线程和许多锁时,会有性能开销,同时也会有死锁的潜在风险
互斥属性使用摘要
实际上,如果您想彻底测试和调试您的应用程序,并且现在并不真的关心性能,那么请设置您的互斥锁如下:
-
在其上设置 robust 属性(允许捕获所有者死亡而不解锁的情况):
pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST) -
将类型设置为错误检查(允许捕获自死锁/重新锁定的情况):
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_ERRORCHECK)
另一方面,一个设计良好且经过验证的应用程序,需要您挤出性能,将使用正常(默认)的互斥锁类型和属性。前面的情况不会被捕捉到(而是导致未定义的行为),但是它们本来就不应该发生!
如果需要递归锁定,(显然)将互斥锁类型设置为 PTHREAD_MUTEX_RECURSIVE。对于递归互斥锁,重要的是要意识到,如果互斥锁被锁定 n 次,则为了被认为真正处于解锁状态(因此可以再次锁定),它也必须被解锁 n 次。
在多进程和多线程应用程序中,如果需要在不同进程的线程之间使用互斥锁,可以通过互斥对象的进程共享属性来实现。请注意,在这种情况下,包含互斥锁的内存本身必须在进程之间共享(通常使用共享内存段)。
PI 和优先级上限属性使开发人员能够保护应用程序免受众所周知的软件风险:优先级反转。
互斥锁定 - 附加变体
本节帮助理解互斥锁的附加变体,稍微不同的语义。我们将涵盖超时互斥锁变体、"忙等待"用例和读者-写者锁。
争取互斥锁超时
在前面的“锁定指南”部分中,在防止饥饿的标签下,我们了解到长时间持有互斥锁会导致性能问题;显然,失败的线程会饿死。避免这个问题的一种方法(尽管,当然,修复任何饥饿的根本原因才是重要的!)是让失败的线程等待一定时间后再等待互斥锁;如果等待时间超过一定时间,就放弃。这正是 pthread_mutex_timedlock(3) API 提供的功能:
#include <pthread.h>
#include <time.h>
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
很明显:所有锁定语义与通常的pthread_mutex_lock(3)一样,只是如果在锁上花费的阻塞时间(等待)超过第二个参数——作为绝对值指定的时间,API 返回失败——返回的值将是ETIMEDOUT。(我们已经在第十三章中详细编程了超时,定时器。)
请注意,其他错误返回值也是可能的(例如,对于先前所有者终止的鲁棒互斥锁,可能返回EOWNERDEAD,对于检查错误的互斥锁检测到死锁,等等)。有关详细信息,请参阅pthread_mutex_timedlock(3)的手册页。
忙等待(非阻塞变体)锁
我们知道互斥锁的正常工作方式:如果锁已经被锁定,那么尝试获取锁将导致该线程阻塞(等待)解锁事件发生。如果有人想要一个设计,大致如下:如果锁已被锁定,不要让我等待;我会做一些其他工作然后重试?这种语义通常被称为忙等待或非阻塞,并由 trylock 变体提供。顾名思义,我们尝试获取锁,如果成功,很好;如果没有,没关系——我们不会强迫线程等待。锁可以被进程内的任何线程(甚至是外部线程,如果它是进程共享的互斥锁)获取,包括相同的线程——如果它被标记为递归。但是等等;如果互斥锁确实是递归锁,那么获取它将立即成功,并且调用将立即返回。
其 API 如下:
int pthread_mutex_trylock(pthread_mutex_t *mutex);。
虽然这种忙等待语义偶尔会很有用——具体来说,它用于检测和防止某些类型的死锁——但在使用时要小心。想一想:对于一个轻度争用的锁(很少被使用的锁,在这种情况下,尝试获取锁的线程很可能会立即获得锁),使用这种忙等待语义可能是有用的。但对于一个严重争用的锁(在热代码路径上的锁,经常被获取和释放),这实际上可能会损害获得锁的机会!为什么?因为你不愿意等待它。(有时软件模仿生活,是吧?)
读者-写者互斥锁
想象一个多线程应用程序,有十个工作线程;假设大部分时间(比如 90%的时间),八个工作线程都在忙于扫描全局链表(或类似的数据结构)。现在,当然,由于它是全局的,我们知道它是一个临界区;如果没有用互斥锁保护它,很容易导致脏读错误。但是,这会带来很大的性能成本:因为每个工作线程都想要搜索列表,它被迫等待来自所有者的解锁事件。
计算机科学家已经提出了一种创新的替代方案,用于这种情况(也称为读者-写者问题),其中数据访问的大部分时间(共享)数据只被读取而不被写入。我们使用了一种特殊的互斥锁变体,称为读者-写者锁:
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
请注意,这是一种全新的锁类型:pthread_wrlock_t。
如果一个线程为自己获取了读锁,关键点在于:实现现在信任这个线程只会读取而不会写入;因此,不会进行实际的锁定,API 将直接返回成功!这样,读者实际上是并行运行的,从而保持了性能;没有安全问题或竞争,因为他们保证只会读取。
然而,一旦一个线程希望写入数据,它必须获得写锁:当这发生时,正常的锁定语义适用。写入线程现在必须等待所有读者执行解锁,然后写入者获得写锁并继续。在临界区内,没有线程——读者也不是写者——能够干预;它们将像通常一样阻塞(等待)写入者的解锁。因此,现在两种情况都得到了优化。
通常的嫌疑犯——用于设置读写互斥锁属性的 API 存在(按字母顺序排列):
-
pthread_rwlockattr_destroy(3P) -
pthread_rwlockattr_getpshared(3P) -
pthread_rwlockattr_setkind_np(3P) -
pthread_rwlockattr_getkind_np(3P) -
pthread_rwlockattr_init(3P) -
pthread_rwlockattr_setpshared(3P)
请注意,以_np结尾的 API 意味着它们是非便携的,仅适用于 Linux。
同样,读写锁定的 API 遵循通常的模式——超时和尝试变体也存在。
-
pthread_rwlock_destroy(3P) -
pthread_rwlock_init(3P) -
pthread_rwlock_timedrdlock(3P) -
pthread_rwlock_tryrdlock(3P) -
pthread_rwlock_unlock(3P) -
pthread_rwlock_rdlock(3P) -
pthread_rwlock_timedwrlock(3P) -
pthread_rwlock_trywrlock(3P) -
pthread_rwlock_wrlock(3P)
我们期望程序员按照正常的方式设置——初始化读写锁属性对象,初始化读写锁本身(使用pthread_rwlock_init(3P)),在完成后销毁属性结构,然后根据需要执行实际的锁定。
请注意,当使用读写锁时,应该仔细测试性能;已经注意到它比通常的互斥锁实现要慢。此外,还有一个额外的担忧,在负载下,读写锁的语义可能导致写入者饥饿。想象一下:如果读者不断出现,写入线程可能要等很长时间才能获得锁。
显然,使用读写锁也可能出现相反的动态:读者可能被饥饿。有趣的是,Linux 提供了一个非便携的 API,允许程序员指定要防止哪种类型的饥饿——读者还是写者,其中默认是写者被饥饿。调用此 API 进行设置的方法是pthread_rwlockattr_setkind_np(3)。这允许根据特定的工作负载进行一定程度的调整。(然而,实现显然仍然存在一个 bug,实际上,写者饥饿仍然是现实。我们不打算进一步讨论这一点;如有需要,读者可以参考手册页以获得进一步的帮助。)
然而,读写锁变体通常是有用的;想想那些经常需要扫描某些键值映射数据结构并执行某种表查找的应用程序。(例如,操作系统经常有网络代码路径经常查找路由表但很少更新它。)不变的是,所讨论的全局共享数据通常被读取,但很少被写入。
自旋锁变体
这里有一点重复:我们已经了解了互斥锁的正常工作方式;如果锁已经被锁定,那么尝试获取锁将导致该线程阻塞(等待解锁)。让我们深入一点;失败的线程究竟如何阻塞——等待——互斥锁的解锁?答案是,对于互斥锁,它们通过睡眠(被操作系统调度下 CPU)来实现。事实上,这是互斥锁的一个定义属性。
另一方面,还存在一种完全不同的锁——spinlock(在 Linux 内核中非常常用),其行为恰恰相反:它通过让失败的线程等待解锁操作来工作(旋转/轮询)——实际上,实际的 spinlock 实现要比这里描述的更加精细和高效;不过,这个讨论已经超出了本书的范围。乍一看,轮询似乎是让失败的线程等待解锁的一种不好的方式;它能够与 spinlock 很好地配合工作的原因在于临界区内所需的时间保证非常短(从技术上讲,小于执行两次上下文切换所需的时间),因此在临界区很小的情况下,使用 spinlock 比互斥锁更加高效。
尽管 pthread 实现确实提供了自旋锁,但应明确以下几点:
-
自旋锁只应该由使用实时操作系统调度策略(SCHED_FIFO,可能还有 SCHED_RR;我们在第十七章中讨论这些,Linux 上的 CPU 调度)的极端性能实时线程使用。
-
Linux 平台上的默认调度策略从不是实时的;它是非实时的 SCHED_OTHER 策略,非常适合非确定性应用程序;使用互斥锁是正确的方法。
-
在用户空间使用自旋锁不被认为是正确的设计方法;此外,代码将更容易受到死锁和(无限)优先级反转的影响。
出于上述原因,我们不深入研究以下 pthread spinlock API:
-
pthread_spin_init(3) -
pthread_spin_lock(3) -
pthread_spin_trylock(3) -
pthread_spin_unlock(3) -
pthread_spin_destroy(3)
如果需要,确保在各自的手册页中查找它们(但在使用时要格外小心!)。
一些互斥锁使用指南
除了之前提供的提示和指南(参考锁定指南部分)之外,也要考虑这一点:
-
应该使用多少个锁?
-
有了许多锁实例,如何知道何时使用哪个锁变量?
-
测试互斥锁是否被锁定。
我们逐一来看这些要点。
在小型应用程序中(如此处所示的类型),也许只使用一个锁来保护临界区就足够了;这样做的好处是保持简单(这很重要)。然而,在大型项目中,只使用一个锁来对可能遇到的每个临界区进行锁定可能会成为一个主要的性能瓶颈!思考一下为什么会这样:一旦代码中的任何地方遇到一个互斥锁,所有的并行性都会停止,代码将以串行方式运行;如果这种情况经常发生,性能将迅速下降。
有趣的是,Linux 内核多年来一直因为在代码库的大部分区域中使用了一把锁而导致了严重的性能问题——以至于它被昵称为大内核锁(BKL)(一个巨大的锁)。它最终在 Linux 内核的 2.6.39 版本中才被彻底摆脱(在 GitHub 存储库的进一步阅读部分中有关于 BKL 的更多链接)。
因此,虽然没有规则可以准确决定应该使用多少个锁,但启发式方法是考虑简单性与性能之间的权衡。在大型生产质量项目(如 Linux 内核)中,我们经常使用单个锁来保护单个数据——数据对象;通常,这是一种数据结构。这将确保在访问时保护全局数据,但只有实际访问它的代码路径,从而确保数据安全和并行性(性能)。
好的。现在,如果我们遵循这个指南,如果最终有几百个锁怎么办?(是的,在有几百个全局数据结构的大型项目中,这是完全可能的。)现在,我们有另一个实际问题:开发人员必须确保他们使用正确的锁来保护给定的数据结构(使用为数据结构 X 设计的锁 X 来访问数据结构 Y 有什么用呢?那将是一个严重的缺陷)。因此,一个实际的问题是我怎么确定哪个数据结构由哪个锁保护,或者另一种陈述方式是:我怎么确定哪个锁变量确实保护哪个数据结构?天真的解决方案是适当地命名每个锁,也许像lock_<DataStructureName>这样。嗯,这并不像看起来那么简单!
非正式的调查显示,程序员经常做的最困难的事情之一是变量命名!(请参阅 GitHub 存储库上的进一步阅读部分,以获取相关链接。)
因此,这里有一个提示:将保护给定数据结构的锁嵌入到数据结构本身中;换句话说,将其作为保护它的数据结构的成员!(再次,Linux 内核经常使用这种方法。)
互斥锁被锁定了吗?
在某些情况下,开发人员可能会想问:给定一个互斥锁,我能否找出它是锁定还是未锁定状态?也许推理是:如果锁定了,让我们解锁它。
有一种方法可以测试这个问题:使用pthread_mutex_trylock(3)API。如果它返回EBUSY,这意味着互斥锁当前被锁定(否则,它应该返回0,表示它是未锁定的)。但等等!这里存在一个固有的竞争条件;想一想:
if (pthread_mutex_trylock(&mylock) != EBUSY)) { <-- time t1
// it's unlocked <-- time t2
}
// it's locked
当我们到达时间 t2 时,没有保证另一个线程现在没有锁定该互斥锁!因此,这种方法是不正确的。(这种同步的唯一现实方法是放弃使用互斥锁,而是使用条件变量;这是我们在下一节中讨论的内容。)
这结束了我们对互斥锁的(相当长的)覆盖。在我们结束之前,我们想指出另一个有趣的地方:我们之前说过,原子意味着能够完整地运行临界代码段而不被中断。但现实是,我们的现代系统确实经常中断我们——硬件中断和异常是常态!因此,人们应该意识到:
-
在用户空间中,由于无法屏蔽硬件中断,进程和线程随时可能因此而中断。因此,使用用户空间代码实际上不可能真正地原子化。(但如果我们被硬件中断/故障/异常中断,那又怎样呢?它们会执行它们的工作并迅速将控制权交还给我们。我们几乎不可能与这些代码实体共享全局可写数据而发生竞争。)
-
在内核空间中,我们以操作系统特权运行,实际上可以屏蔽甚至硬件中断,从而使我们能够以真正的原子方式运行(你认为著名的 Linux 内核自旋锁是如何工作的?)。
现在我们已经介绍了用于锁定的典型 API,我们鼓励读者一方面以实际操作的方式尝试示例;另一方面,重新访问之前涵盖的部分,锁定指南和死锁。
条件变量
CV 是一种线程间的事件通知机制。在我们使用互斥锁来同步(串行化)对临界区的访问,从而保护它时,我们使用条件变量来促进有效的通信——根据数据项的值来同步进程的线程之间的通信。以下讨论将使这一点更清晰。
在多线程应用程序的设计和实现中,经常会面临这种情况:一个线程 B 正在执行一些工作,另一个线程 A 正在等待该工作的完成。只有当线程 B 完成工作时,线程 A 才能继续;我们如何在代码中高效地实现这一点?
没有 CV - 幼稚的方法
我们可能会记得线程的退出状态(通过pthread_exit(3))会传递回调用pthread_join(3)的线程;我们能利用这个特性吗?好吧,不行:首先,并不一定线程 B 一旦指定的工作完成就会终止(它可能只是一个里程碑,而不是它要执行的所有工作),其次,即使它终止了,也许除了调用pthread_join(3)的线程之外,可能还有其他线程需要知道。
好吧,为什么不让线程 A 通过简单的技术来轮询完成工作,即当工作完成时,线程 B 将一个全局整数(称为gWorkDone)设置为 1(当然线程 A 会轮询它),也许就像伪代码中的以下内容:
| 时间 | 线程 B | 线程 A |
|---|---|---|
| t0 | 初始化:gWorkDone = 0 |
<通用> |
| t1 | 执行工作... | while (!gWorkDone) ; |
| t2 | ... | ... |
| t3 | 工作完成;gWorkDone = 1 |
... |
| t4 | 检测到;跳出循环并继续 |
它可能有效,但实际上并不是。为什么呢?:
-
首先,对变量进行无限期的轮询在 CPU 方面非常昂贵(而且设计不好)。
-
其次,注意我们在没有保护的情况下操作共享可写全局变量;这正是引入数据竞争和 bug 的方法。
因此,前表中显示的方法被认为是幼稚、低效甚至可能有 bug(竞争条件)。
使用条件变量
正确的方法是使用 CV。条件变量是线程以高效的方式同步数据值的一种方式。它实现了与幼稚的轮询方法相同的最终结果,但以一种更高效、更重要的正确方式。
查看以下表格:
| 时间 | 线程 B | 线程 A |
|---|---|---|
| t0 | 初始化:gWorkDone = 0;初始化{CV,互斥锁}对 | <通用> |
| t1 | 等待来自线程 B 的信号:锁定相关的互斥锁;pthread_cond_wait() |
|
| t2 | 执行工作... | <...阻塞...> |
| t3 | 工作完成;锁定相关的互斥锁;向线程 A 发出信号:pthread_cond_signal();解锁相关的互斥锁 |
... |
| t4 | 解除阻塞;检查工作是否真的完成,如果是,解锁相关的互斥锁,然后继续... |
尽管前表显示了步骤的顺序,但需要一些解释。在幼稚的方法中,我们看到一个(严重的)缺点是全局共享数据变量在没有保护的情况下被操纵!条件变量通过要求条件变量始终与互斥锁相关联来解决了这个问题;我们可以将其视为{CV,互斥锁}对。
这个想法很简单:每当我们打算使用全局谓词来告诉我们工作是否已经完成(在我们的例子中是gWorkDone),我们会锁定互斥锁,读/写全局变量,解锁互斥锁,从而重要的是保护它。
CV 的美妙之处在于我们根本不需要轮询:等待工作完成的线程使用pthread_cond_wait(3)来阻塞(等待)事件发生,完成工作的线程通过pthread_cond_signal(3)API 向其对应的线程发出“信号”:
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
int pthread_cond_signal(pthread_cond_t *cond);
尽管我们在这里使用了“信号”这个词,但这与我们在之前的第十一章和第十二章中讨论的 Unix/Linux 信号和信号毫无关系。
(注意{CV,mutex}对是如何一起使用的)。当然,就像线程一样,我们必须首先初始化 CV 及其关联的互斥锁;CV 可以通过静态方式进行初始化:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
或者在运行时动态地通过以下 API 进行初始化:
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
如果需要设置 CV 的特定非默认属性,可以通过pthread_condattr_set*(3P)API 来设置,或者通过首先调用pthread_condattr_init(3P)API 并将初始化的 CV 属性对象作为第二个参数传递给pthread_cond_init(3P)来将 CV 设置为默认值:
int pthread_condattr_init(pthread_condattr_t *attr);
相反,当完成时,使用以下 API 来销毁 CV 属性对象和 CV 本身:
int pthread_condattr_destroy(pthread_condattr_t *attr);
int pthread_cond_destroy(pthread_cond_t *cond);
一个简单的 CV 使用演示应用程序
太多的初始化/销毁?查看下面的简单代码(ch15/cv_simple.c)将澄清它们的用法;我们编写一个小程序来演示条件变量及其关联互斥锁的用法。在这里,我们创建两个线程 A 和 B。然后,线程 B 执行一些工作,线程 A 在完成该工作后使用{CV,mutex}对进行同步:
为了便于阅读,只显示了源代码的关键部分;要查看完整的源代码,请构建并运行它。整个树可以从 GitHub 克隆到这里:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
...
#define LOCK_MTX(mtx) do { \
int ret=0; \
if ((ret = pthread_mutex_lock(mtx))) \
FATAL("pthread_mutex_lock failed! [%d]\n", ret); \
} while(0)
#define UNLOCK_MTX(mtx) do { \
int ret=0; \
if ((ret = pthread_mutex_unlock(mtx))) \
FATAL("pthread_mutex_unlock failed! [%d]\n", ret); \
} while(0)
static int gWorkDone=0;
/* The {cv,mutex} pair */
static pthread_cond_t mycv;
static pthread_mutex_t mycv_mutex = PTHREAD_MUTEX_INITIALIZER;
在前面的代码中,我们再次显示了实现互斥锁和解锁的宏,全局谓词(布尔)变量gWorkDone,当然还有{CV,mutex}对变量。
在下面的代码中,在 main 函数中,我们初始化了 CV 属性对象和 CV 本身:
// Init a condition variable attribute object
if ((ret = pthread_condattr_init(&cvattr)))
FATAL("pthread_condattr_init failed [%d].\n", ret);
// Init a {cv,mutex} pair: condition variable & it's associated mutex
if ((ret = pthread_cond_init(&mycv, &cvattr)))
FATAL("pthread_cond_init failed [%d].\n", ret);
// the mutex lock has been statically initialized above.
工作线程 A 和 B 被创建并开始他们的工作(我们这里不重复显示线程创建的代码)。在这里,你会找到线程 A 的工作例程 - 它必须等待直到线程 B 完成工作。我们使用{CV,mutex}对来轻松高效地实现这一点。
然而,该库要求应用程序在调用pthread_cond_wait(3P)API 之前保证关联的互斥锁被获取(锁定);否则,这将导致未定义的行为(或者当互斥锁类型为PTHREAD_MUTEX_ERRORCHECK或者鲁棒互斥锁时会导致实际失败)。一旦线程在 CV 上阻塞,互斥锁会自动释放。
此外,如果在线程在等待条件上阻塞时传递了信号,它将被处理并且等待将会恢复;这也可能导致虚假唤醒的返回值为零(稍后会详细介绍):
static void * workerA(void *msg)
{
int ret=0;
LOCK_MTX(&mycv_mutex);
while (1) {
printf(" [thread A] : now waiting on the CV for thread B to finish...\n");
ret = pthread_cond_wait(&mycv, &mycv_mutex);
// Blocking: associated mutex auto-released ...
if (ret)
FATAL("pthread_cond_wait() in thread A failed! [%d]\n", ret);
// Unblocked: associated mutex auto-acquired upon release from the condition wait...
printf(" [thread A] : recheck the predicate (is the work really "
"done or is it a spurious wakeup?)\n");
if (gWorkDone)
break;
printf(" [thread A] : SPURIOUS WAKEUP detected !!! "
"(going back to CV waiting)\n");
}
UNLOCK_MTX(&mycv_mutex);
printf(" [thread A] : (cv wait done) thread B has completed it's work...\n");
pthread_exit((void *)0);
}
非常重要的是要理解:仅仅从pthread_cond_wait(3P)返回并不一定意味着我们等待(阻塞)的条件 - 在这种情况下,线程 B 完成工作 - 实际发生了!在软件中,可能会发生虚假唤醒(由于其他事件 - 也许是信号而导致的虚假唤醒);健壮的软件将会在循环中重新检查条件,以确定我们被唤醒的原因是正确的 - 在我们这里,工作确实已经完成。这就是为什么我们在一个无限循环中运行,并且一旦从pthread_cond_wait(3P)中解除阻塞,就会检查全局整数gWorkDone是否确实具有我们期望的值(在这种情况下为 1,表示工作已经完成)。
好吧,但也要考虑这一点:即使是读取共享全局变量也会成为一个临界区(否则会导致脏读);因此,在这之前我们需要获取互斥锁。啊,这就是{CV,mutex}对的一个内置自动机制,真的帮助了我们——一旦调用pthread_cond_wait(3P),关联的互斥锁会自动原子释放(解锁),然后我们会阻塞在条件变量信号上。当另一个线程(这里是 B)向我们发出信号(显然是在同一个 CV 上),我们就会从pthread_cond_wait(3P)中解除阻塞,并且关联的互斥锁会自动原子锁定,允许我们重新检查全局变量(或其他内容)。所以,我们完成工作然后解锁它。
这是线程 B 的工作例程的代码,它执行一些示例工作然后向线程 A 发出信号:
static void * workerB(void *msg)
{
int ret=0;
printf(" [thread B] : perform the 'work' now (first sleep(1) :-)) ...\n");
sleep(1);
DELAY_LOOP('b', 72);
gWorkDone = 1;
printf("\n [thread B] : work done, signal thread A to continue ...\n");
/* It's not strictly required to lock/unlock the associated mutex
* while signalling; we do it here to be pedantically correct (and
* to shut helgrind up).
*/
LOCK_MTX(&mycv_mutex);
ret = pthread_cond_signal(&mycv);
if (ret)
FATAL("pthread_cond_signal() in thread B failed! [%d]\n", ret);
UNLOCK_MTX(&mycv_mutex);
pthread_exit((void *)0);
}
注意注释详细说明了为什么我们在信号之前再次获取互斥锁。好的,让我们试一下(我们建议您构建和运行调试版本,因为这样延迟循环才能正确显示):
$ ./cv_simple_dbg
[thread A] : now waiting on the CV for thread B to finish...
[thread B] : perform the 'work' now (first sleep(1) :-)) ...
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
[thread B] : work done, signal thread A to continue ...
[thread A] : recheck the predicate (is the work really done or is it a spurious wakeup?)
[thread A] : (cv wait done) thread B has completed it's work...
$
API 还提供了阻塞调用的超时变体:
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);
语义与pthread_cond_wait相同,只是如果第三个参数 abstime 中指定的时间已经过去,API 会返回(失败值为ETIMEDOUT)。用于测量经过的时间的时钟是 CV 的属性,并且可以通过pthread_condattr_setclock(3P)API 进行设置。
(pthread_cond_wait和pthread_cond_timedwait都是取消点;这个主题将在下一章中讨论。)
CV 广播唤醒
正如我们之前看到的,pthread_cond_signal(3P) API 用于解除阻塞在特定 CV 上的线程。这个 API 的变体如下:
int pthread_cond_broadcast(pthread_cond_t *cond);
这个 API 允许你解除阻塞在同一个 CV 上的多个线程。例如,如果有三个线程在同一个 CV 上阻塞;当应用程序调用pthread_cond_broadcast(3P)时,哪个线程会首先运行?嗯,这就像问,当线程被创建时,哪一个会首先运行(回想一下前一章中的讨论)。答案当然是,在没有特定调度策略的情况下,这是不确定的。当应用到 CV 解除阻塞并在 CPU 上运行时,也是同样的答案。
继续,一旦等待的线程解除阻塞,要记住关联的互斥锁会被获取,但当然只有一个解除阻塞的线程会首先获取它。同样,这取决于调度策略和优先级。在所有默认情况下,无法确定哪个线程会首先获取它。无论如何,在没有实时特性的情况下,这对应用程序不应该有影响(如果应用程序是实时的,那么首先在每个应用程序线程上阅读我们的第十七章,Linux 上的 CPU 调度,并设置实时调度策略和优先级)。
此外,这些 API 的手册页面清楚地指出,尽管调用前面的 API(pthread_cond_signal和pthread_cond_broadcast)的线程在这样做时不需要持有关联的互斥锁(请记住,我们总是有{CV,mutex}对),但严谨的正确语义要求他们持有互斥锁,执行信号或广播,然后解锁互斥锁(我们的示例应用程序ch15/cv_simple.c遵循了这一准则)。
为了结束对 CV 的讨论,这里有一些建议:
-
不要在信号处理程序中使用条件变量方法;该代码不被认为是异步信号安全的(回想我们之前的第十一章,信号-第一部分和第十二章,信号-第二部分)。
-
使用众所周知的 Valgrind 套件(回想一下,我们在第六章中介绍了 Valgrind 的 Memcheck 工具,内存问题的调试工具),特别是名为 helgrind 的工具,有时可以检测到 pthread 多线程应用程序中的同步错误(数据竞争)。使用方法很简单:
$ valgrind --tool=helgrind [-v] <app_name> [app-params ...]:
-
然而,像这种类型的许多工具一样,helgrind 经常会引发许多错误警报。例如,我们发现在我们之前编写的
cv_simple应用程序中消除printf(3)会消除 helgrind 中的许多(错误的)错误和警告! -
在调用
pthread_cond_signal和/或pthread_cond_broadcastAPI 之前,如果未首先获取相关的互斥锁(不是必需的),helgrind 会抱怨。
请尝试使用 helgrind(再次提醒,GitHub 存储库的进一步阅读部分有链接到其(非常好的)文档)。
摘要
我们开始本章时,重点关注并发性、原子性的关键概念,以及识别和保护关键部分的必要性。锁定是实现这一点的典型方式;pthread API 集提供了强大的互斥锁来实现。然而,在大型项目中使用锁定,尤其是隐藏的问题和危险,我们讨论了有用的锁定指南、死锁及其避免。
本章随后指导读者使用 pthread 互斥锁。这里涵盖了很多内容,包括各种互斥锁属性,识别和避免优先级反转问题的重要性,以及互斥锁的变化。最后,我们介绍了条件变量(CV)的需求和用法,以及如何有效地促进线程间事件通知。
下一章是这个关于多线程的三部曲的最后一章;在其中,我们将专注于线程安全的重要问题(和线程安全的 API),线程取消和清理,将信号与 MT 混合,一些常见问题和提示,并看看多进程与多线程模型的利弊。
第十六章:Pthreads 多线程第三部分
在第十四章和第十五章中已经涵盖了编写强大的多线程(MT)应用程序的许多原因和方法,本章重点介绍了教授读者多线程编程的几个关键安全方面。
它为开发安全和健壮的 MT 应用程序的许多关键安全方面提供了一些启示;在这里,读者将了解线程安全性,为什么需要它以及如何使函数线程安全。在运行时,可能会有一个线程杀死另一个线程;这是通过线程取消机制实现的——与取消一起,如何确保在线程终止之前,首先确保它释放任何仍在持有的资源(如锁和动态内存)?线程清理处理程序用于展示这一点。
最后,本章深入探讨了如何安全地混合多线程和信号,多进程与多线程的一些优缺点,以及一些技巧和常见问题解答。
线程安全
在开发多线程应用程序时一个关键,但不幸的是经常不明显的问题是线程安全。一个线程安全,或者如 man 页面所指定的那样,MT-Safe 的函数或 API 是可以安全地由多个线程并行执行而没有不利影响的函数。
要理解这个线程安全问题实际上是什么,让我们回到我们在附录 A中看到的程序之一,文件 I/O 基础知识;您可以在书的 GitHub 存储库中找到源代码:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux/raw/master/A_fileio/iobuf.c。在这个程序中,我们使用fopen(3)以附加模式打开文件,然后对其进行一些 I/O(读/写);我们在这里复制了该章节的一小段:
-
我们通过
fopen(3)在附加模式(a)中打开一个流到我们的目标,只是在/tmp目录中的一个常规文件(如果不存在,将创建它) -
然后,在一个循环中,对用户提供的迭代次数,我们将执行以下操作:
-
通过
fread(3)stdio 库 API 从源流中读取几个(512)字节(它们将是随机值) -
通过
fwrite(3)stdio 库 API 将这些值写入我们的目标流(检查 EOF 和/或错误条件)
这是代码片段,主要是testit函数执行实际的 I/O;参考:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux/raw/master/A_fileio/iobuf.c:
static char *gbuf = NULL;
static void testit(FILE * wrstrm, FILE * rdstrm, int numio)
{
int i, syscalls = NREAD*numio/getpagesize();
size_t fnr=0;
if (syscalls <= 0)
syscalls = 1;
VPRINT("numio=%d total rdwr=%u expected # rw syscalls=%d\n",
numio, NREAD*numio, NREAD*numio/getpagesize());
for (i = 0; i < numio; i++) {
fnr = fread(gbuf, 1, NREAD, rdstrm);
if (!fnr)
FATAL("fread on /dev/urandom failed\n");
if (!fwrite(gbuf, 1, fnr, wrstrm)) {
free(gbuf);
if (feof(wrstrm))
return;
if (ferror(wrstrm))
FATAL("fwrite on our file failed\n");
}
}
}
注意代码的第一行,它对我们的讨论非常重要;用于保存源和目标数据的内存缓冲区是一个全局(静态)变量,gbuf。
这是在应用程序的main()函数中分配的位置:
...
gbuf = malloc(NREAD);
if (!gbuf)
FATAL("malloc %zu failed!\n", NREAD);
...
那又怎样?在《附录 A》文件 I/O 基础中,我们以隐含的假设为前提,即进程是单线程的;只要这个假设保持不变,程序就能正常工作。但仔细想想;一旦我们想要将这个程序移植成多线程能力,这段代码就不够好了。为什么?很明显:如果多个线程同时执行testit函数的代码(这正是预期的),全局共享的可写内存变量gbuf的存在告诉我们,在代码路径中会有临界区。正如我们在《第十五章》使用 Pthreads 进行多线程 - 同步中详细学到的,每个临界区必须要么被消除,要么被保护起来以防止数据竞争。
在前面的代码片段中,我们高兴地在这个全局缓冲区上调用了fread(3)和fwrite(3),而没有任何保护。*想象一下多个线程同时运行这段代码路径;结果将是一片混乱。
所以,现在我们可以看到并得出结论,testit函数是不是线程安全的(至少,程序员必须记录这一事实,防止其他人在多线程应用中使用这段代码!)。
更糟糕的是,我们开发的前面的线程不安全函数被合并到一个共享库(在 Unix/Linux 上通常称为共享对象文件)中;任何链接到这个库的(多线程)应用程序都将可以访问这个函数。如果这样的应用程序的多个线程曾经调用它,我们就有了潜在的竞争 - 一个错误,一个缺陷!不仅如此,这样的缺陷是真正难以发现和理解的,会引起各种问题,也许还会有各种临时的应急措施(这只会让情况变得更糟,让客户对软件的信心更少)。灾难确实是以看似无辜的方式引起的。
我们的结论是,要么使函数线程安全,要么明确将其标记为线程不安全(如果有的话,只在单线程环境中使用)。
使代码线程安全
显然,我们希望使testit函数线程安全。现在问题变成了,我们究竟该如何做到呢?嗯,再次,这很简单:有两种方法(实际上不止两种,但我们稍后再讨论)。
如果我们能消除代码路径中的任何全局共享可写数据,我们将不会有临界区问题;换句话说,它将变得线程安全。因此,实现这一点的一种方法是确保函数只使用本地(自动)变量。该函数现在是可重入安全的。在进一步进行之前,了解一些关于可重入和线程安全的关键要点是很重要的。
可重入安全与线程安全
可重入安全究竟与线程安全有何不同?混淆确实存在。这里有一个简洁的解释:可重入安全是在多任务和多线程操作系统出现之前的一个问题,其含义是只有一个相关的线程在执行。为了使函数具有可重入安全性,它应该能够在上一个上下文尚未完成执行的情况下,从另一个上下文中被正确地重新调用(想象一个信号处理程序在已经执行的情况下重新调用给定的函数)。关键要求是:它应该只使用局部变量,或者具有保存和恢复它使用的全局变量的能力,以便它是安全的。(这些想法在《第十一章》信号 - 第一部分的可重入安全和信号部分中有详细讨论。正如我们在那一章中提到的,信号处理程序应该只调用那些保证是可重入安全的函数;在信号处理上下文中,这些函数被称为是异步信号安全的。)
另一方面,线程安全是一个更近期的问题-我们指的是支持多线程的现代操作系统。一个线程安全的函数可以在多个线程(可能在多个 CPU 核心上)同时并行调用,而不会破坏它。共享的可写数据是重要的,因为代码本身只能读取和执行,因此完全可以并行执行。
通过使用互斥锁使函数线程安全(这些讨论将详细介绍并举例说明)是可能的,但会引入性能问题。有更好的方法使函数线程安全:重构它,或者使用 TLS 或 TSD-我们将在“通过 TLS 实现线程安全”和“通过 TSD 实现线程安全”部分介绍这些方法。
简而言之,可重入安全关注的是一个线程在活动调用仍然存在时重新调用函数;线程安全关注的是多个线程-并发代码-同时执行相同的函数。 (一个优秀的 Stack Overflow 帖子更详细地描述了这一点,请参考 GitHub 存储库上的进一步阅读部分。)
现在,回到我们之前的讨论。理论上,只使用局部变量听起来不错(对于小型实用函数,我们应该设计成这样),但现实是,有些复杂的项目会以这样的方式发展,以至于在函数内部使用全局共享可写数据对象是无法避免的。在这种情况下,根据我们在之前的第十五章中学到的关于同步的知识,我们知道答案:识别和保护关键部分,使用互斥锁。
是的,那样可以,但会显著影响性能。请记住,锁会破坏并行性并使代码流程串行化,从而创建瓶颈。在不使用互斥锁的情况下实现线程安全才是真正构成可重入安全函数的关键。这样的代码确实是有用的,并且可以实现;有两种强大的技术可以实现这一点,称为 TLS 和 TSD。请稍作耐心,我们将在“通过 TLS 实现线程安全”和“通过 TSD 实现线程安全”部分介绍如何使用这些技术。
需要强调的一点是:设计师和程序员必须保证所有可以在任何时间点由多个线程执行的代码都被设计、实现、测试和记录为线程安全。这是设计和实现多线程应用程序时需要满足的关键挑战之一。
另一方面,如果可以保证一个函数始终只会被单个线程执行(例如在创建线程之前从 main()调用的早期初始化例程),那显然就不需要保证它是线程安全的。
总结表-使函数线程安全的方法
让我们总结前面的观点,以表格的形式告诉我们如何实现所有函数的重要目标-线程安全:
| 使函数线程安全的方法 | 评论 |
|---|---|
| 只使用局部变量 | 天真;在实践中难以实现。 |
| 使用全局和/或静态变量,并使用互斥锁保护关键部分 | 可行但可能会显著影响性能[1] |
| 重构函数,使其可重入安全-通过使用更多参数来消除函数中静态变量的使用 | 有用的方法-将几个旧的foo glibc 函数重构为foo_r。 |
| 线程本地存储(TLS) | 通过每个线程拥有一个变量副本来确保线程安全;工具链和操作系统版本相关。非常强大且易于使用。 |
| 线程特定数据(TSD) | 同样的目标:使数据线程安全-旧的实现,使用起来更麻烦。 |
表 1:使函数线程安全的方法
[1]虽然我们说使用互斥锁可能会显著影响性能,但在正常情况下,互斥锁的性能确实非常高(主要是因为在 Linux 上通过 futex-快速用户互斥锁进行内部实现)。
让我们更详细地查看这些方法。
第一种方法,只使用局部变量,是一个相当天真的方法,可能只适用于小型程序;我们就此打住。
通过互斥锁实现线程安全
考虑到函数确实使用全局和/或静态变量,并且决定继续使用它们(我们在表 1中提到的第二种方法),显然在代码中使用它们的地方构成了关键部分。正如第十五章“使用 Pthreads 进行多线程编程第二部分-同步”中详细展示的那样,我们必须保护这些关键部分;在这里,我们使用 pthread 的互斥锁来实现。
为了可读性,这里只显示了源代码的关键部分;要查看完整的源代码,构建并运行它,整个树都可以从 GitHub 克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
我们将这种方法应用于我们示例函数的 pthread 互斥锁的添加(我们适当地重命名它;在下面的片段中找到完整的源代码:ch16/mt_iobuf_mtx.c):
static void testit_mt_mtx(FILE * wrstrm, FILE * rdstrm, int numio,
int thrdnum)
{
...
for (i = 0; i < numio; i++) {
LOCK_MTX(&mylock);
fnr = fread(gbuf, 1, NREAD, rdstrm);
UNLOCK_MTX(&mylock);
if (!fnr)
FATAL("fread on /dev/urandom failed\n");
LOCK_MTX(&mylock);
if (!fwrite(gbuf, 1, fnr, wrstrm)) {
free(gbuf);
UNLOCK_MTX(&mylock);
if (feof(wrstrm))
return;
if (ferror(wrstrm))
FATAL("fwrite on our file failed\n");
}
UNLOCK_MTX(&mylock);
}
}
在这里,我们使用相同的宏来执行互斥锁和解锁,就像我们在(为了避免重复,我们不显示初始化互斥锁的代码,请参考第十五章“使用 Pthreads 进行多线程编程第二部分-同步”中的细节。我们还添加了一个额外的thrdnum参数到函数中,以便能够打印出当前正在运行的线程编号。)
关键点:在关键部分——我们访问(读取或写入)共享可写全局变量gbuf的代码部分——我们获取互斥锁,执行访问(在我们的情况下是fread(3)和fwrite(3)),然后释放互斥锁。
现在,即使多个线程运行前面的函数,也不会出现数据完整性问题。是的,它会工作,但会付出显著的性能代价;正如前面所述,每个关键部分(在lock和相应的unlock之间的代码)都将被序列化。因此,在代码路径中,锁定可能形成瓶颈,特别是如果,就像我们的示例一样,numio参数是一个大数,那么for循环将执行一段时间。类似地,如果函数是一个繁忙的函数并且经常被调用,那么也会产生瓶颈。(使用perf(1)进行快速检查,单线程版本执行 100,000 次 I/O 需要 379 毫秒,而带锁的多线程版本执行相同次数的 I/O 需要 790 毫秒。)
我们已经涵盖了这一点,但让我们快速测试一下自己:为什么我们没有保护使用变量fnr和syscalls的代码部分?答案是因为它是一个局部变量;更重要的是,当执行前面的函数时,每个线程都会获得自己的局部变量副本,因为每个线程都有自己的私有堆栈,局部变量是在堆栈上实例化的。
为了使程序工作,我们必须重构前面的函数如何实际设置为线程工作程序;我们发现需要使用自定义数据结构向每个线程传递各种参数,然后有一个小的wrapper函数—wrapper_testit_mt_mtx()—调用实际的 I/O 函数;我们留给读者详细查看源代码。
让我们运行它:
$ ./mt_iobuf_mtx 10000
./mt_iobuf_mtx: using default stdio IO RW buffers of size 4096 bytes; # IOs=10000
mt_iobuf_mtx.c:testit_mt_mtx:62: [Thread #0]: numio=10000 total rdwr=5120000 expected # rw syscalls=1250
mt_iobuf_mtx.c:testit_mt_mtx:66: gbuf = 0x23e2670
mt_iobuf_mtx.c:testit_mt_mtx:62: [Thread #1]: numio=10000 total rdwr=5120000 expected # rw syscalls=1250
mt_iobuf_mtx.c:testit_mt_mtx:66: gbuf = 0x23e2670
Thread #0 successfully joined; it terminated with status=0
Thread #1 successfully joined; it terminated with status=0
$
这揭示了全部情况;显然,正在使用的 I/O 缓冲区gbuf对于两个线程是相同的(看打印出的地址),因此需要对其进行锁定。
顺便说一下,在标准文件流 API 中存在(非标准)*_unlocked APIs,例如fread_unlocked(3)和fwrite_unlocked(3)。它们与常规 API 相同,只是在文档中明确标记为 MT-unsafe。不建议使用它们。
顺便说一下,打开的文件是进程的线程之间共享的资源;开发人员也必须考虑到这一点。在同一底层文件对象上同时使用多个线程进行 IO 可能会导致损坏,除非使用文件锁定技术。在这种特定情况下,我们明确使用互斥锁来保护临界区-这些临界区恰好是我们进行文件 I/O 的地方,因此显式文件锁定变得不必要。
通过函数重构实现线程安全
正如我们在前面的示例中看到的,我们需要互斥锁,因为gbuf全局缓冲区被所有应用程序线程用作它们的 I/O 缓冲区。因此,请考虑一下:如果我们可以为每个线程分配一个本地 I/O 缓冲区呢?那确实会解决问题!具体如何做将在下面的代码中展示。
但首先,现在您已经熟悉了之前的示例(我们在其中使用了互斥锁),请研究重构后程序的输出:
$ ./mt_iobuf_rfct 10000
./mt_iobuf_rfct: using default stdio IO RW buffers of size 4096 bytes; # IOs=10000
mt_iobuf_rfct.c:testit_mt_refactored:51: [Thread #0]: numio=10000 total rdwr=5120000 expected # rw syscalls=1250
iobuf = 0x7f283c000b20
mt_iobuf_rfct.c:testit_mt_refactored:51: [Thread #1]: numio=10000 total rdwr=5120000 expected # rw syscalls=1250
iobuf = 0x7f2834000b20
Thread #0 successfully joined; it terminated with status=0
Thread #1 successfully joined; it terminated with status=0
$
关键认识:这里使用的 I/O 缓冲区iobuf对于每个线程都是唯一的(只需查看打印出的地址)!因此,这消除了 I/O 函数中的临界区和使用互斥锁的需要。实际上,该函数仅使用本地变量,因此既可重入又线程安全。
为了可读性,这里只显示了源代码的关键部分。要查看完整的源代码,请构建并运行它;整个树可在 GitHub 上克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
以下代码片段清楚地显示了如何设置(完整源代码:ch16/mt_iobuf_rfct.c):
struct stToThread {
FILE *wrstrm, *rdstrm;
int thrdnum, numio;
char *iobuf;
};
static struct stToThread *ToThread[NTHREADS];
static void * wrapper_testit_mt_refactored(void *msg)
{
struct stToThread *pstToThread = (struct stToThread *)msg;
assert (pstToThread);
/* Allocate the per-thread IO buffer here, thus avoiding the global
* heap buffer completely! */
pstToThread->iobuf = malloc(NREAD);
...
testit_mt_refactored(pstToThread->wrstrm, pstToThread->rdstrm,
pstToThread->numio, pstToThread->thrdnum,
pstToThread->iobuf);
free(pstToThread->iobuf);
pthread_exit((void *)0);
}
可以看到,我们通过向自定义stToThread结构添加额外的缓冲区指针成员来进行重构。重要的部分是:在线程包装函数中,我们分配了内存并将指针传递给我们的线程例程。我们为此目的向我们的线程 I/O 例程添加了额外的参数:
static void testit_mt_refactored(FILE * wrstrm, FILE * rdstrm, int numio, int thrdnum, char *iobuf)
{
...
for (i = 0; i < numio; i++) {
fnr = fread(iobuf, 1, NREAD, rdstrm);
if (!fnr)
FATAL("fread on /dev/urandom failed\n");
if (!fwrite(iobuf, 1, fnr, wrstrm)) {
...
}
现在,在前面的 I/O 循环中,我们操作每个线程的iobuf缓冲区,因此没有临界区,也不需要锁定。
标准 C 库和线程安全
标准 C 库(glibc)中有相当多的代码不是线程安全的。什么?有人会问。但是,嘿,很多这些代码是在 20 世纪 70 年代和 80 年代编写的,当时多线程并不存在(至少对于 Unix 来说);因此,我们几乎不能责怪他们没有设计成线程安全!
不需要线程安全的 API 列表
标准 C 库 glibc 有许多较旧的函数,按照 Open Group 手册的说法,这些函数不需要线程安全(或者不需要线程安全)。POSIX.1-2017 的这一卷中定义的所有函数都应该是线程安全的,除了以下函数不需要线程安全。这实际上意味着什么?简单:这些 API 不是线程安全的。因此,请小心-不要在 MT 应用程序中使用它们。完整列表可以在以下网址找到:pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_09_01。
当然,前面的列表只适用于 POSIX.1-2017,并且可能会过时。读者必须意识到这个反复出现的问题,以及不断更新这样的信息的需要。
它们大多是库层(glibc)的 API。在所有前面的 API 中,只有一个-readdir(2)-是一个系统调用;这也被认为是不推荐使用的(我们应该使用它的 glibc 包装器readdir(3))。作为一个经验法则,所有系统调用都是编写为线程安全的。
一个有趣的事实:PHP,一种流行的 Web 脚本语言,被认为不是线程安全的;因此,提供 PHP 页面的 Web 服务器使用传统的多进程模型,而不是更快的多线程框架(例如,Apache 使用其内部的mpm_prefork模块-这是单线程的-来处理 PHP 页面)。
因此,看到我们刚刚讨论的内容,有人会得出结论说glibc不再适用于开发线程安全的 MT 应用程序吗?不,工作已经进行,将前面的许多 API 转换为线程安全。继续阅读。
从foo重构glibcAPI 为foo_r
当然,今天,随着 MT 应用程序成为事实上的现实,我们该怎么办呢?glibc的维护人员了解这些问题,并且已经使用了精确的重构技术-传递额外的参数以避免使用全局和/或静态变量(就像我们之前在ch16/mt_iobuf_rfct.c代码中所做的那样),包括使用参数作为返回值-来重构标准的glibc函数以使其成为线程安全。glibc的命名约定是,如果旧函数被命名为foo,则重构后的,通常是可重入和线程安全的版本被命名为foo_r。
为了帮助澄清这个讨论,让我们以一个glibcAPI 的例子来说明,它既有旧的foo功能,也有新的foo_r功能。ctime(3)API 经常被应用程序开发人员使用;给定一个 Unix 时间戳,它将其转换为可读的日期时间戳(ASCII 文本)。 (回想一下我们在第十三章中使用了ctimeAPI,定时器。)让我们回忆一下,直接来自第十三章,定时器,Unix 系统将时间存储为自 1970 年 1 月 1 日午夜(00:00)以来经过的秒数-可以将其视为 Unix 的诞生!这个时间值被称为自纪元以来的时间或 Unix 时间。好的,但是今天会是一个相当大的秒数,对吧?那么如何以人类可读的格式表示它呢?很高兴你问到了;这正是ctime(3)和ctime_r(3)API 的工作。
ctime(3)API 的签名如下:
include <time.h>
char *ctime(const time_t *timep);
你是否发现了多线程应用程序的问题?返回值是以纯 ASCII 文本表示的时间;它由ctime(3)存储在静态(因此是共享的)数据变量中。如果多个线程同时执行ctime(3)(这在现代多核系统上确实会发生),就会存在脏读或写共享数据的风险。这是因为它没有受到保护;仅仅因为当ctime(3)首次设计和实现时,只有一个线程会在给定时间点运行它。当然,这在今天不是这样的情况。换句话说,ctime(3)在手册页中被标记为 MT-Unsafe,也就是说,它不是线程安全的。因此,从 MT 应用程序中调用ctime(3)是错误的-你会面临在某个时候出现竞争、错误或缺陷的风险。
glibc的开发人员确实重新实现(重构)了ctime(3),使其成为可重入和线程安全;新的 API 被命名为ctime_r(3)。以下是它的手册页中的一句引用:可重入版本ctime_r()做同样的事情,但将字符串存储在用户提供的缓冲区中,该缓冲区至少应该有 26 个字节的空间。
char *ctime_r(const time_t *timep, char *buf);
太棒了!你注意到这里的关键点是ctime(3) API 已经被重构(并重命名为ctime_r(3)),通过让用户提供结果返回的缓冲区,使其成为可重入和线程安全的?用户将如何做到这一点?简单;下面是一些代码,展示了实现这一点的一种方式(我们只需要理解概念,没有显示错误检查):
// Thread Routine here
struct timespec tm;
char * mybuf = malloc(32);
...
clock_gettime(CLOCK_REALTIME, &tm); /* get the current 'UNIX' timestamp*/
ctime_r(&tm.tv_sec, mybuf); /* put the human-readable ver into 'mybuf'*/
...
free(mybuf);
想想看:执行前面代码的每个线程都会分配一个独立的唯一缓冲区,并将该缓冲区指针传递给ctime_r(3)例程。这样,我们确保不会互相干扰;API 现在是可重入和线程安全的。
请注意在前面的代码中,我们如何在 C 中实现了这种重构技巧:通过将要写入的唯一缓冲区作为值-结果式参数传递!这确实是一种常见的技术,通常由 glibc foo_r例程使用:我们通过传递一个或多个值给它(甚至返回给调用者,作为一种返回值)而不使用静态或全局变量(而是使用值-结果(或输入-输出)式参数)来保持例程的线程安全!
ctime(3)的 man 页面,以及大多数其他 API 的 man 页面,都记录了它描述的 API 是否是线程安全的:这一点非常重要!我们无法过分强调:多线程应用程序的程序员必须检查并确保在一个应该是线程安全的函数中调用的所有函数本身(记录为)是线程安全的。
这是ctime(3)man 页面的一部分截图,显示在ATTRIBUTES部分下的这些信息:

图 1:ctime(3) man 页面的 ATTRIBUTES 部分的截图
显然,MT-Safe 意味着例程是线程安全的;MT-Unsafe 意味着它不是。attributes(7)上的 man 页面深入探讨了这些细节;它清楚地指出,线程安全并不保证 API 也是原子的;请仔细阅读。
我们还注意到 man 页面指出,POSIX.1-2008 将ctime_r API 本身标记为过时,并建议使用strftime(3)来替代。请这样做。在这里,我们仅仅使用ctime(3)和ctime_r(3) API 来举例说明 glibc 例程的线程不安全和安全版本。
一些 glibc foo和foo_r API
ctime(3),这是不安全的线程,现在被它的线程安全的对应物ctime_r(3)所取代;这只是现代 glibc 中一种通用趋势的一个例子:
-
旧的、线程(MT-unsafe)不安全的函数被称为
foo -
有一个新的、线程(MT-Safe)安全的
foo_rAPI
为了让读者了解这一点,我们列举了一些(不是全部!)glibc foo_r风格的 API:
| asctime_r(3) crypt_r(3)
ctime_r(3)
drand48_r(3) | getpwnam_r(3) getpwuid_r(3)
getrpcbyname_r(3)
getrpcbynumber_r(3)
getrpcent_r(3)
getservbyname_r(3) | seed48_r(3) setkey_r(3)
srand48_r(3)
srandom_r(3)
strerror_r(3)
strtok_r(3) |
| getdate_r(3) getgrent_r(3)
getgrgid_r(3)
getgrnam_r(3)
gethostbyaddr_r(3)
gethostbyname2_r(3)
gethostbyname_r(3)
gethostent_r(3)
getlogin_r(3) | nrand48_r(3) ptsname_r(3)
qecvt_r(3)
qfcvt_r(3)
qsort_r(3)
radtofix_r(3)
rand_r(3)
random_r(3)
readdir_r(3) | ustrtok_r(3) val_gethostbyaddr_r(3)
val_gethostbyname2_r(3)
val_gethostbyname_r(3) |
表 3:一些 glibc foo_r API
这个列表并不是详尽无遗的;请注意ctime_r(3) API 在这个列表中。冒着重复的风险,请确保在 MT 应用程序中只使用foo_r API,因为它们是foo API 的线程安全版本。
通过 TLS 实现线程安全
前面的讨论是关于已经存在的标准 C 库 glibc 及其 API 集。那么新设计和开发的 MT 应用程序呢?显然,我们为它们编写的代码必须是线程安全的。
不要忘记我们如何通过重构将我们的testit_mt_refactored函数变得线程安全——添加一个iobuf参数,传递要用于 I/O 的缓冲区的地址——确保每个线程的缓冲区都是唯一的,因此是线程安全的(无需任何锁定)。
我们能自动获得这样的功能吗?嗯,是的:编译器(GCC 和 clang)确实提供了一个几乎神奇的功能来做类似的事情:TLS。使用 TLS,用__thread特殊存储类关键字标记的变量将在每个活动的线程中实例化一次。实际上,如果我们只使用本地和 TLS 变量,我们的函数将根据定义是线程安全的,而无需任何(昂贵的)锁定。
确实存在一些基本规则和注意事项;让我们来看看:
__thread关键字可以单独使用,也可以与(实际上,只能与)static或extern关键字一起使用;如果与它们一起使用,必须出现在它们之后。
__thread long l;
extern __thread struct MyStruct s1;
static __thread int safe;
-
更广泛地说,
__thread关键字可以针对任何全局和文件或函数作用域的static或extern变量进行指定。它不能应用于任何局部变量。 -
TLS 只能在(相当)新版本的工具链和内核上使用。
重要的是要理解:尽管它可能看起来类似于有锁的变量,但实际上并非如此!考虑这一点:给定一个名为mytls的 TLS 变量,不同的线程并行使用它是可以的。但是,如果一个线程对 TLS 变量使用地址运算符&mytls,它将具有该变量的实例的地址。任何其他线程,如果访问此地址,都可以使用此地址来访问该变量;因此,从实质上讲,它并没有真正被锁定。当然,如果程序员使用正常的约定(不让其他线程访问不同线程的 TLS 变量),那么一切都会很顺利。
重要的是要意识到 TLS 支持仅在 Linux 2.6 内核及更高版本、gcc ver 3.3 或更高版本和 NPTL 中可用。实际上,这意味着几乎任何相当新的 Linux 发行版都将支持 TLS。
因此,像往常一样,让我们通过 TLS 将我们的线程不安全的函数移植为线程安全。这真的很简单;我们所要做的就是将以前的全局缓冲区gbuf变成线程安全的 TLS 缓冲区(iobuf):
static __thread char iobuf[NREAD]; // our TLS variable
static void testit_mt_tls(FILE * wrstrm, FILE * rdstrm, int numio, int thrdnum)
{
int i, syscalls = NREAD*numio/getpagesize();
size_t fnr=0;
if (syscalls <= 0)
syscalls = 1;
VPRINT("[Thread #%d]: numio=%d total rdwr=%u expected # rw
syscalls=%d\n"
" iobuf = %p\n", thrdnum, numio, NREAD*numio, syscalls, iobuf);
...
唯一重要的变化是现在将iobuf变量声明为 TLS 变量;其他几乎都保持不变。快速测试确认每个线程都会收到 TLS 变量的单独副本:
$ ./mt_iobuf_tls 12500
./mt_iobuf_tls: using default stdio IO RW buffers of size 4096 bytes; # IOs=12500
mt_iobuf_tls.c:testit_mt_tls:48: [Thread #0]: numio=12500 total rdwr=6400000 expected # rw syscalls=1562
iobuf = 0x7f23df1af500
mt_iobuf_tls.c:testit_mt_tls:48: [Thread #1]: numio=12500 total rdwr=6400000 expected # rw syscalls=1562
iobuf = 0x7f23de9ae500
Thread #0 successfully joined; it terminated with status=0
Thread #1 successfully joined; it terminated with status=0
$
每个iobuf都是一个每个线程的 TLS 实例;每个都有一个唯一的地址。没有锁定,没有麻烦,工作完成。TLS 的实际使用很高;未初始化的全局errno是一个完美的例子。
TLS 似乎是一种强大且易于使用的技术,可以使函数线程安全;有什么缺点吗?嗯,想想看:
-
对于每个标记为 TLS 存储类的变量,将必须为每个活动的线程分配内存;如果我们有大型 TLS 缓冲区,这可能导致分配大量内存。
-
平台支持:如果您的 Linux 平台太旧,将不支持它(通常不应该是这种情况)。
通过 TSD 实现线程安全
在我们刚刚看到的 TLS 技术之前(也就是在 Linux 2.6 和 gcc 3.3 之前),如何保证编写的新 API 是线程安全的?还存在一种更古老的技术,称为 TSD。
总之,从应用程序开发人员的角度来看,TSD 是一个更复杂的解决方案——需要做更多的工作才能实现 TLS 轻松给我们的相同结果;使函数线程安全。
使用 TSD,线程安全的例程必须调用一个初始化函数(通常使用 pthread_once(3) 完成),该函数创建一个唯一的线程特定数据键(使用 pthread_key_create(3) API)。这个初始化例程使用 pthread_getspecific(3) 和 pthread_setspecific(3) API 将一个线程特定的数据变量(例如我们例子中的 iobuf 缓冲指针)与该键关联起来。最终的结果是数据项现在是线程特定的,因此是线程安全的。在这里,我们不深入讨论使用 TSD,因为它是一个旧的解决方案,在现代 Linux 平台上 TLS 轻松而优雅地取代了它。然而,对于感兴趣的读者,请参考 GitHub 仓库上的 进一步阅读 部分——我们提供了一个使用 TSD 的链接。
线程取消和清理
pthread 的设计提供了一个复杂的框架,用于实现多线程应用程序的另外两个关键活动:使应用程序中的一个线程取消(实际上是终止)另一个线程,以及使一个线程能够正常终止(通过 pthread_exit(3))或异常终止(通过取消)并能够执行所需的资源清理。
以下部分涉及这些主题。
取消线程
想象一个运行的 GUI 应用程序;它弹出一个对话框,通知用户它现在正在执行一些工作(也许还显示一个进度条)。我们想象这项工作是由整个应用程序进程的一个线程执行的。为了用户的方便,还提供了一个取消按钮;点击它应该导致正在进行的工作被取消。
我们如何实现这个?换句话说,如何终止一个线程?首先要注意的是,pthreads 提供了一个框架,用于正是这种类型的操作:线程取消。取消线程不是发送信号;它是一种让一个线程请求另一个线程死掉的方式。要实现这一点,我们需要理解并遵循提供的框架。
线程取消框架
为了带来清晰,让我们举个例子:假设一个应用程序的主线程创建了两个工作线程 A 和 B。现在,主线程想要取消线程 A。
请求取消目标线程(这里是 A)的 API 如下:
int pthread_cancel(pthread_t thread);
thread 参数是目标线程——我们(礼貌地)请求它请去死,非常感谢。
但是,你猜对了,事情并不像那么简单:目标线程有两个属性(它可以设置),决定它是否以及何时被取消:
-
取消能力状态
-
取消能力类型
取消能力状态
目标线程需要处于适当的取消能力状态。该状态是布尔型取消能力(在目标线程 A 上)要么是 启用 要么是 禁用;以下是设置这一点的 API:
int pthread_setcancelstate(int state, int *oldstate);
线程的两种可能的取消能力状态,作为第一个参数提供的值,如下所示:
-
PTHREAD_CANCEL_ENABLE(默认创建时) -
PTHREAD_CANCEL_DISABLE
显然,前一个取消能力状态将在第二个参数 oldstate 中返回。只有当目标线程的取消能力状态为启用时,才能取消线程。线程的取消能力状态在创建时默认为启用。
这是框架的一个强大特性:如果目标线程 A 正在执行关键活动,并且不希望被考虑取消,它只需将其取消能力状态设置为禁用,并在完成所述的关键活动后将其重置为启用。
取消能力类型
假设目标线程已启用取消状态是第一步;线程的可取消类型决定接下来会发生什么。有两种类型:延迟(默认)和异步。当线程的可取消类型是异步时,它可以在任何时候被取消(实际上,它应该立即发生,但并不总是保证);如果可取消类型是延迟(默认),它只能在下一个取消点时被取消(终止)。
取消点是一个(通常是阻塞的)函数列表(稍后会详细介绍)。当目标线程——记住,它是启用取消状态和延迟类型的——在其代码路径中遇到下一个取消点时,它将终止。
这是设置可取消类型的 API:
int pthread_setcanceltype(int type, int *oldtype);
作为第一个参数类型提供的两种可能的可取消类型值是:
-
PTHREAD_CANCEL_DEFERRED(默认创建时) -
PTHREAD_CANCEL_ASYNCHRONOUS
显然,以前的可取消类型将在第二个参数oldtype中返回。
呼!让我们尝试将这个取消框架表示为一个流程图:

图 2:Pthreads 取消
pthread_cancel(3)是一个非阻塞的 API。我们的意思是,即使目标线程已禁用其可取消状态,或者其可取消状态已启用但可取消类型是延迟的,并且尚未达到取消点,尽管目标线程可能需要一些时间才能真正死去,主线程的pthread_cancel(3)调用将成功返回(返回值为0),这意味着取消请求已成功排队。
在进行关键活动时短暂禁用取消状态是可以的,但是长时间禁用可能会导致应用程序看起来无响应。
通常不应该使用异步值作为可取消类型。为什么?嗯,这变成了一个竞赛,究竟是在线程分配一些资源(例如通过malloc(3)分配内存)之前取消,还是之后取消?在这种情况下,即使清理处理程序也不是真正有用。此外,只有被记录为“异步取消安全”的 API 才能安全地以异步方式取消;实际上只有很少的 API——只有取消 API 本身。因此,最好避免异步取消。另一方面,如果一个线程主要是高度 CPU 绑定的(执行一些数学计算,比如素数生成),那么使用异步取消可以帮助确保线程立即在请求时死亡。
另一个关键点:(在我们的例子中)主线程如何知道目标线程是否已经终止?请记住,主线程预期会加入所有线程;因此,目标线程在终止时将被加入,并且这里的关键是pthread_join(3)的返回值(状态)将是PTHREAD_CANCELED。pthread_join(3)是检查取消是否实际发生的唯一方法。
我们已经了解到,默认的取消类型为延迟时,实际的线程取消将不会发生,直到目标线程遇到取消点函数。取消点只是一个 API,在该 API 中,线程取消实际上被检测并由底层实现生效。取消点不仅限于 pthread API;许多 glibc 函数都充当取消点。读者可以通过在 GitHub 存储库的进一步阅读部分提供的链接(Open Group POSIX.1c 线程)找到取消点 API 的列表。作为一个经验法则,取消点通常是阻塞库 API。
但是,如果一个线程正在执行的代码中根本没有取消点(比如说,是一个 CPU 密集型的计算循环)怎么办?在这种情况下,可以使用异步取消类型,或者更好的是,通过调用void pthread_test_cancel(void);API 在循环中显式引入一个保证的取消点。
如果将要取消的目标线程调用此函数,并且有一个取消请求挂起,它将终止。
取消线程-一个代码示例
以下是一个简单的代码示例,演示了线程取消;我们让main线程创建两个工作线程(将它们视为线程 A 和线程 B),然后让main线程取消线程 A。同时,我们故意让线程 A 禁用取消(通过将取消状态设置为禁用),做一些虚假的工作(我们调用我们信任的DELAY_LOOP宏来模拟工作),然后重新启用取消。取消请求在下一个取消点生效(因为type默认为延迟),这里,就是sleep(3)API。
演示线程取消的代码(ch16/cancelit.c)如下。
为了可读性,这里只显示了源代码的关键部分。要查看完整的源代码,请构建并运行它。整个树可在 GitHub 上克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
我们在线程创建循环完成后在main中接着执行代码:
int main(void)
{
...
// Lets send a cancel request to thread A (the first worker thread)
ret = pthread_cancel(tid[0]);
if (ret)
FATAL("pthread_cancel(thread 0) failed! [%d]\n", ret);
// Thread join loop
for (i = 0; i < NTHREADS; i++) {
printf("main: joining (waiting) upon thread #%ld ...\n", i);
ret = pthread_join(tid[i], (void **)&stat);
...
printf("Thread #%ld successfully joined; it terminated with"
"status=%ld\n", i, stat);
if ((void *)stat == PTHREAD_CANCELED)
printf(" *** Was CANCELLED ***\n");
}
}
这是线程worker例程:
void * worker(void *data)
{
long datum = (long)data;
int slptm=8, ret=0;
if (datum == 0) { /* "Thread A"; lets keep it in a 'critical' state,
non-cancellable, for a short while, then enable
cancellation upon it. */
printf(" worker #%ld: disabling Cancellation:"
" will 'work' now...\n", datum);
if ((ret = pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL)))
FATAL("pthread_setcancelstate failed 0 [%d]\n", ret);
DELAY_LOOP(datum+48, 100); // the 'work'
printf("\n worker #%ld: enabling Cancellation\n", datum);
if ((ret = pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL)))
FATAL("pthread_setcancelstate failed 1 [%d]\n", ret);
}
printf(" worker #%ld: will sleep for %ds now ...\n", datum, slptm);
sleep(slptm); // sleep() is a 'cancellation point'
printf(" worker #%ld: work (eyeroll) done, exiting now\n", datum);
/* Terminate with success: status value 0.
* The join will pick this up. */
pthread_exit((void *)0);
}
快速测试运行显示它确实有效;可以看到线程 A 已被取消。我们建议您运行程序的调试版本,因为这样可以看到DELAY_LOOP宏的效果(否则它几乎会被编译器优化掉,几乎瞬间完成其工作):
$ ./cancelit_dbg
main: creating thread #0 ...
main: creating thread #1 ...
worker #0: disabling Cancellation: will 'work' now...
0 worker #1: will sleep for 8s now ...
main: joining (waiting) upon thread #0 ...
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
worker #0: enabling Cancellation
worker #0: will sleep for 8s now ...
Thread #0 successfully joined; it terminated with status=-1
*** Was CANCELLED ***
main: joining (waiting) upon thread #1 ...
worker #1: work (eyeroll) done, exiting now
Thread #1 successfully joined; it terminated with status=0
main: now dying... <Dramatic!> Farewell!
$
在线程退出时进行清理
考虑这种假设情况:一个线程获取互斥锁并分配了一些堆内存。显然,一旦它所在的临界区完成,我们期望它释放堆内存并解锁互斥锁。未进行这种清理将导致严重的,甚至是致命的应用程序错误(缺陷),如内存泄漏或死锁。
但是,有人会想,如果可怜的线程在释放和解锁之前被取消了怎么办?这可能发生,对吧?不!只要开发人员理解并使用 pthreads 框架提供的线程清理处理程序机制就不会发生。
当线程终止时会发生什么?以下步骤是 pthreads 清理框架的一部分:
-
所有清理处理程序都被弹出(清理处理程序推送的相反顺序)
-
如果存在 TSD 析构函数,则会被调用
-
线程死亡
这让我们看到了一个有趣的事实:pthreads 框架提供了一种保证线程在终止之前清理自己的方法-释放内存资源,关闭打开的文件等。
程序员可以通过设置线程清理处理程序来处理所有这些情况-实际上是一种析构函数。清理处理程序是一个在线程被取消或使用pthread_exit(3)终止时自动执行的函数;通过调用pthread_cleanup_push(3)API 来设置它:
void pthread_cleanup_push(void (*routine)(void *), void *arg);
显然,前面例程的第一个参数是清理处理程序函数指针,换句话说,是清理处理程序函数的名称。第二个参数是任何一个想要传递给处理程序的参数(通常是指向动态分配的缓冲区或数据结构的指针)。
通过相应的清理弹出例程可以实现相反的语义;当调用时,它会弹出清理处理程序堆栈,并以相反的顺序执行先前推送到清理处理程序堆栈上的清理处理程序:
void pthread_cleanup_pop(int execute);
还可以通过调用thread_cleanup_pop(3)API 并传递一个非零参数来显式调用清理堆栈上面的清理处理程序。
POSIX 标准规定,前面一对 API——推送和弹出清理处理程序——可以实现为扩展为函数的宏;事实上,在 Linux 平台上似乎是这样实现的。作为这一副作用,程序员必须在同一个函数内调用这两个例程(一对)。不遵守这一规定会导致奇怪的编译器失败。
正如所指出的,如果存在 TSD 析构处理程序,它们也会被调用;在这里,我们忽略了这一方面。
你可能会想,好吧,如果我们使用这些清理处理程序技术,我们可以安全地恢复状态,因为线程取消和终止都将保证调用任何注册的清理处理程序(析构函数)。但是,如果另一个进程(也许是一个 root 进程)向我的 MT 应用程序发送了一个致命信号(比如kill -9 <mypid>)呢?那么就没什么可做的了。请意识到,对于致命信号,进程中的所有线程,甚至整个进程本身,都将死亡(在这个例子中)。这是一个学术问题——一个无关紧要的问题。另一方面,一个线程不能随意被杀死;必须对其进行显式的pthread_exit(3)或取消操作。因此,懒惰的程序员没有借口——设置清理处理程序来执行适当的清理,一切都会好起来。
线程清理-代码示例
作为一个简单的代码示例,让我们修改我们之前重构的程序——ch16/mt_iobif_rfct.c,通过安装一个线程清理处理程序例程。为了测试它,如果用户将1作为第二个参数传递给我们的演示程序ch16/cleanup_hdlr.c,我们将取消第一个工作线程。
为了便于阅读,这里只显示了源代码的关键部分。要查看完整的源代码,请构建并运行它。整个树可在 GitHub 上克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
这是清理处理程序函数和重新编写的包装程序——现在带有清理处理程序推送和弹出 API:
static void cleanup_handler(void *arg)
{
printf("+++ In %s +++\n" " free-ing buffer %p\n", __func__, arg);
free(arg);
}
...
static void *wrapper_testit_mt_refactored(void *msg)
{
struct stToThread *pstToThread = (struct stToThread *)msg;
...
/* Allocate the per-thread IO buffer here, thus avoiding the global
* heap buffer completely! */
pstToThread->iobuf = malloc(NREAD);
...
/* Install a 'cleanup handler' routine */
pthread_cleanup_push(cleanup_handler, pstToThread->iobuf);
testit_mt_refactored(pstToThread->wrstrm, pstToThread->rdstrm,
pstToThread->numio, pstToThread->thrdnum,
pstToThread->iobuf);
/* *Must* invoke the 'push's counterpart: the cleanup 'pop' routine;
* passing 0 as parameter just registers it, it does not actually pop
* off and execute the handler. Why not? Because that's precisely what
* the next API, the pthread_exit(3) will implicitly do!
*/
pthread_cleanup_pop(0);
free(pstToThread->iobuf);
// Required for pop-ping the cleanup handler!
pthread_exit((void *)0);
}
在这里,main()设置了所需的线程取消:
...
if (atoi(argv[2]) == 1) {
/* Lets send a cancel request to thread A */
ret = pthread_cancel(tid[0]);
...
快速测试确认,在取消时,清理处理程序确实被调用并执行了清理:
$ ./cleanup_hdlr 23114 1
./cleanup_hdlr: using default stdio IO RW buffers of size 4096 bytes; # IOs=23114
main: sending CANCEL REQUEST to worker thread 0 ...
cleanup_hdlr.c:testit_mt_refactored:52: [Thread #0]: numio=23114 total rdwr=11834368 expected # rw syscalls=2889
iobuf = 0x7f2364000b20
cleanup_hdlr.c:testit_mt_refactored:52: [Thread #1]: numio=23114 total rdwr=11834368 expected # rw syscalls=2889
iobuf = 0x7f235c000b20
+++ In cleanup_handler +++
free-ing buffer 0x7f2364000b20
Thread #0 successfully joined; it terminated with status=-1
: was CANCELED
Thread #1 successfully joined; it terminated with status=0
$
线程和信号
在第十一章中,信号-第 I 部分,和第十二章中,信号-第 II 部分,我们详细介绍了信号。我们仍然在同一个 Unix/Linux 平台上;信号及其在应用程序设计/开发中的使用并没有因为我们现在正在处理 MT 应用程序而消失!我们仍然必须处理信号(请记住,你可以在 shell 上用简单的kill -l列出你平台上可用的信号)。
问题
那么问题是什么?在 MT 应用程序中,我们处理信号的方式有很大的不同。为什么?事实是,传统的信号处理方式与 pthread 框架并不真正兼容。如果你可以避免在 MT 应用程序中使用信号,请尽量这样做。如果不行(在现实世界的 MT 应用程序中通常是这样),那么请继续阅读——我们将详细介绍在 MT 应用程序中处理信号的方法。
但是为什么现在发出信号成了一个问题?很简单:信号是为进程模型设计和用于的。想想看:一个进程如何向另一个进程发送信号?很明显——使用kill(2)系统调用:
int kill(pid_t pid, int sig);
显然,第一个参数 pid 是要将信号sig(数字)传递给的进程的 PID。但是,这里我们看到,一个进程可以是多线程的——哪个特定线程会接收,哪个特定线程会处理这个信号?POSIX 标准懦弱地声明“任何准备好的线程都可以处理给定的信号”。如果所有线程都准备好了怎么办?那么谁来处理?所有的线程?至少可以说是模棱两可的。
POSIX 处理 MT 上的信号的解决方案
好消息是,POSIX 委员会为 MT 应用程序的开发人员提出了信号处理的建议。这个解决方案基于一个有趣的设计事实;虽然进程有一个由内核和sigaction(2)系统调用设置的信号处理表,但进程内的每个线程都有自己独立的信号掩码(使用它可以选择性地阻塞信号)和信号挂起掩码(内核记住了要传递给线程的挂起信号)。
知道这一点,POSIX 标准建议开发人员在 pthreads 应用程序中处理信号如下:
-
在主线程中屏蔽(阻塞)所有信号。
-
现在,主线程创建的任何线程都会继承其信号掩码,这意味着所有随后创建的线程中的信号都将被阻塞——这正是我们想要的。
-
创建一个专门的线程,专门用于执行整个应用程序的信号处理。它的工作是捕获(陷阱)所有必需的信号并处理它们(以同步方式)。
请注意,虽然可以通过sigaction(2)系统调用捕获信号,但在多线程应用程序中,信号处理的语义通常导致使用信号 API 的阻塞变体——sigwait(3)、sigwaitinfo(3)和sigtimedwait(3)库 API。通常最好在专用的信号处理程序线程中使用这些阻塞 API 来阻塞所有所需的信号。
因此,每当信号到达时,信号处理程序线程将被解除阻塞,并接收到信号;此外(假设我们使用sigwait(3) API),信号编号将更新到sigwait(3)的第二个参数中。现在它可以代表应用程序执行所需的信号处理。
代码示例-在 MT 应用程序中处理信号
遵循 POSIX 推荐的处理 MT 应用程序中信号的技术的快速演示如下(ch16/tsig.c):
为了便于阅读,这里只显示了源代码的关键部分。要查看完整的源代码,请构建并运行它。整个树都可以从 GitHub 克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
// ... in main:
/* Block *all* signals here in the main thread.
* Now all subsequently created threads also block all signals. */
sigfillset(&sigset);
if (pthread_sigmask(SIG_BLOCK, &sigset, NULL))
FATAL("main: pthread_sigmask failed");
...
/*--- Create the dedicated signal handling thread ---*/
ret = pthread_create(&pthrd[t], &attr, signal_handler, NULL);
if (ret)
FATAL("pthread_create %ld failed [%d]\n", t, ret);
...
工作线程并没有做太多事情——它们只是调用我们的DELAY_LOOP宏来模拟一些工作。在这里,看看信号处理程序线程例程:
static void *signal_handler(void *arg)
{
sigset_t sigset;
int sig;
printf("Dedicated signal_handler() thread alive..\n");
while (1) {
/* Wait for any/all signals */
if (sigfillset(&sigset) == -1)
FATAL("sigfillset failed");
if (sigwait(&sigset, &sig) < 0)
FATAL("sigwait failed");
/* Note on sigwait():
* sigwait suspends the calling thread until one of (any of) the
* signals in set is delivered to the calling thread. It then stores
* the number of the signal received in the location pointed to by
* "sig" and returns. The signals in set must be blocked and not
* ignored on entrance to sigwait. If the delivered signal has a
* signal handler function attached, that function is *not* called.
*/
switch (sig) {
case SIGINT:
// Perform signal handling for SIGINT here
printf("+++ signal_handler(): caught signal #%d +++\n", sig);
break;
case SIGQUIT:
// Perform signal handling for SIGQUIT here
printf("+++ signal_handler(): caught signal #%d +++\n", sig);
break;
case SIGIO:
// Perform signal handling for SIGIO here
printf("+++ signal_handler(): caught signal #%d +++\n", sig);
break;
default:
// Signal <whichever> caught
printf("*** signal_handler(): caught signal #%2d [unhandled] ***\n", sig);
break;
}
}
return (void *)0;
}
我们将其留给读者快速尝试,并注意输出。顺便问一下,你最终会如何杀死它?只需打开另一个终端窗口,然后从那里发出kill -9 <PID>。
为了方便读者,我们重复了第十二章中最重要的提示,信号-第二部分。
一个重要的要点是:sigwait(3)、sigwaitinfo(2)和sigtimedwait(2) API 都不能等待来自内核的同步生成的信号——通常是指示某种失败的信号,比如SIGFPE和SIGSEGV。这些只能以正常的异步方式捕获——通过signal(2)或sigaction(2)。对于这种情况,正如我们反复展示的那样,sigaction(2)系统调用将是更好的选择。
此外,在 MT 应用程序中屏蔽信号时,不要使用sigprocmask(2) API——它不是线程安全的。而是使用pthread_sigmask(3)库例程。
请注意,以下 API 可用于向进程内的线程发送信号:
-
pthread_kill(3):向同一进程内的特定线程发送信号的 API -
tgkill(2):向给定线程组内的特定线程发送信号的 API。 -
tkill(2):tgkill的已弃用前身。
查阅它们各自的手册页面上的详细信息。话虽如此,最好通过 pthread 取消框架来终止线程,而不是通过发送信号。
线程与进程-再次查看
从这个三部曲的开始(第十四章,使用 Pthreads 进行多线程编程第一部分-基础,第十五章,使用 Pthreads 进行多线程编程第二部分-同步,和第十六章,使用 Pthreads 进行多线程编程第三部分),关于多线程编程,关于多进程(单线程)与多线程的争论,我们一再说过,并不是完全优势或劣势——总是有一些优点和缺点,是一种权衡。
表 4和表 5描述了多进程(多个单线程进程)与多线程(单个进程内的多个线程)方法的一些优缺点。
多进程与多线程模型- MT 模型的优点
MT 模型相对于单线程进程的一些优点如下:
| 上下文 | 多进程(单线程)模型 | 多线程(MT)模型 |
|---|---|---|
| 为并行化工作负载设计 |
-
繁琐
-
不直观
-
重复使用 fork/wait 语义(创建大量进程)也不简单或直观
|
-
适用于构建并行化软件;在循环中调用
pthread_create(3)也很容易和直观 -
实现任务的逻辑分离变得容易
-
操作系统将隐式地使线程利用多核系统;对于 Linux 操作系统,调度的粒度是线程,而不是进程(关于这一点,下一章会详细介绍)*
-
重叠 CPU 与 IO 变得容易
|
| 创建/销毁性能 | 比较慢 | 比进程快得多;资源共享保证了这一点 |
|---|---|---|
| 上下文切换 | 慢 | 在进程的线程之间快得多 |
| 数据共享 | 通过 IPC(进程间通信)机制完成;需要学习曲线,可能相当复杂;需要同步(通过信号量) | 内在;给定进程的所有全局和静态数据项在线程之间隐式共享;需要同步(通过互斥锁) |
表 4:多进程与多线程模型- MT 模型的优点
多进程与多线程模型- MT 模型的缺点
MT 模型相对于单线程进程的一些缺点
| 上下文 | 多进程(单线程)模型 | 多线程(MT)模型 |
|---|---|---|
| 线程安全 | 没有这样的要求;进程始终具有地址空间分离。 | 最严重的缺点:MT 应用程序中可以由线程并行运行的每个函数都必须编写、验证和记录为线程安全。这包括应用程序代码和项目库,以及其链接到的任何第三方库。 |
| 应用程序完整性 | 在大型 MT 应用程序中,如果任何一个线程遇到致命错误(如段错误),整个应用程序现在都有 bug 并且必须关闭。 | 在多进程应用程序中,只有遇到致命错误的进程必须关闭;项目的其余部分继续运行[1]。 |
| 地址空间限制 | 在 32 位 CPU 上,用户模式应用程序可用的虚拟地址空间(VAS)相当小(2GB 或 3GB),但对于典型的单线程应用程序来说仍然足够大;在 64 位 CPU 上,VAS 是巨大的(2⁶⁴ = 16 EB)。 | 在 32 位系统上(许多嵌入式 Linux 产品仍然常见),用户模式的可用 VAS 将很小(2/3GB)。考虑到具有许多线程的复杂 MT 应用程序,这并不多!事实上,这是嵌入式供应商积极将产品迁移到 64 位系统的原因之一。 |
| Unix 的一切都是文件语义 | 语义成立:文件(描述符)、设备、套接字、终端等都可以被视为文件;此外,每个进程都有自己的资源副本。| 资源共享,被视为优势,也可以被视为劣势:
-
共享可能会破坏传统的 Unix 模型优势
-
共享打开文件、内存区域、IPC 对象、分页表、资源限制等会导致访问时的同步开销
|
| 信号处理 | 针对进程模型设计。 | 不适用于 MT 模型;可以做到,但处理信号有点笨拙。 |
|---|---|---|
| 设计、维护和调试 | 与 MT 模型相比相当直接。 | 增加了复杂性,因为程序员必须同时跟踪(在脑海中)多个线程的状态,包括众所周知的复杂锁定场景。调试死锁(和其他)情况可能会非常困难(诸如 GDB 和 helgrind 之类的工具有所帮助,但人仍然需要跟踪事物)。 |
表 5:多进程与多线程模型的比较 - MT 模型的缺点
[1] Google Chrome 开源项目的架构基于多进程模型;请参阅他们关于此的漫画改编:www.google.com/googlebooks/chrome/med_00.html。从软件设计的角度来看,该网站非常有趣。
Pthreads - 一些随机提示和常见问题
为了结束本章,我们提供了关于多线程的常见问题的答案,以及如何使用 GDB 调试 MT 应用程序的简要说明。请继续阅读。
您的 MT 应用程序中可以由线程并行运行的每个函数都必须编写、验证和记录为线程安全。这包括您的 MT 应用程序代码、项目库以及您链接到的任何第三方库。
Pthreads - 一些常见问题
- 问:在多线程进程中,当一个线程调用
exec*()例程之一时会发生什么?
答:调用应用程序(前任)完全被后续进程替换,后续进程将只是调用 exec 的线程。请注意,不会调用 TSD 析构函数或线程清理处理程序。
- 问:在多线程进程中,当一个线程调用
fork(2)时会发生什么?
答:这取决于操作系统。在现代 Linux 上,只有调用fork(2)的线程会在新的子进程中复制。所有在 fork 之前存在的其他线程都消失了。不会调用 TSD 析构函数或线程清理处理程序。在多线程应用程序中调用 fork 可能会导致困难;不建议这样做。在 GitHub 存储库的进一步阅读部分中找到有关这个问题的链接。
这样想:在 MT 应用程序中调用fork进行多进程处理被认为是错误的方法;仅为执行另一个程序而调用 fork 是可以的(通过我们学到的典型的 fork-exec-wait 语义)。换句话说,新生的子进程应该只调用被记录为异步信号安全和/或 exec*例程的函数来调用另一个应用程序。
此外,您可以设置处理程序,以在通过pthread_atfork(3)API 调用 fork 时运行。
- 问:多线程应用程序中资源限制(参见 ulimit/prlimit)的影响是什么?
答:所有资源限制 - 当然不包括堆栈大小限制 - 都由进程中的所有线程共享。在旧版 Linux 内核上,情况并非如此。
使用 GDB 调试多线程(pthread)应用程序
GDB 支持调试 MT 应用程序;几乎所有常用命令都可以正常工作,只有少数命令倾向于特定于线程。以下是需要注意的关键命令:
- 查看所有可见线程:
(gdb) info threads
Id Target Id Frame
<thr#> Thread <addr> (LWP ...) in <function> [at <srcfile>]
-
通过使用
thread <thread#>命令切换上下文到特定线程。 -
将给定命令应用于进程的所有线程:
(gdb) thread apply all <cmd> -
显示所有线程的堆栈(GDB 的回溯或
bt命令)(以下示例输出来自我们之前的 MT 应用程序mt_iobuf_rfct_dbg;首先,我们通过thread find .命令显示线程):
(gdb) thread find . Thread 1 has target name 'tsig_dbg'
Thread 1 has target id 'Thread 0x7ffff7fc9740 (LWP 24943)'
Thread 2 has target name 'tsig_dbg'
Thread 2 has target id 'Thread 0x7ffff77f7700 (LWP 25010)'
Thread 3 has target name 'tsig_dbg'
Thread 3 has target id 'Thread 0x7ffff6ff6700 (LWP 25194)' (gdb) thread apply all bt
Thread 3 (Thread 0x7fffeffff700 (LWP 21236)):
#0 testit_mt_refactored (wrstrm=0x603670, rdstrm=0x6038a0, numio=10, thrdnum=1, iobuf=0x7fffe8000b20 "")
at mt_iobuf_rfct.c:44
#1 0x00000000004010e9 in wrapper_testit_mt_refactored (msg=0x603c20) at mt_iobuf_rfct.c:88
#2 0x00007ffff7bbe594 in start_thread () from /lib64/libpthread.so.0
#3 0x00007ffff78f1e6f in clone () from /lib64/libc.so.6
Thread 2 (Thread 0x7ffff77f7700 (LWP 21235)):
#0 testit_mt_refactored (wrstrm=0x603670, rdstrm=0x6038a0, numio=10, thrdnum=0, iobuf=0x7ffff0000b20 "")
at mt_iobuf_rfct.c:44
#1 0x00000000004010e9 in wrapper_testit_mt_refactored (msg=0x603ad0) at mt_iobuf_rfct.c:88
#2 0x00007ffff7bbe594 in start_thread () from /lib64/libpthread.so.0
#3 0x00007ffff78f1e6f in clone () from /lib64/libc.so.6
Thread 1 (Thread 0x7ffff7fc9740 (LWP 21203)):
#0 0x00007ffff7bbfa2d in __pthread_timedjoin_ex () from /lib64/libpthread.so.0
#1 0x00000000004013ec in main (argc=2, argv=0x7fffffffcd88) at mt_iobuf_rfct.c:150
(gdb)
关于使用 pthread 进行 MT 编程的一些其他提示和技巧(包括我们已经遇到的几个),在 GitHub 存储库的进一步阅读部分中提到的博客文章中(Pthreads Dev - 避免的常见编程错误);请务必查看。
总结
在本章中,我们涵盖了使用强大的 pthreads 框架处理线程时的几个安全方面。我们看了线程安全的 API,它们是什么,为什么需要,以及如何使线程例程线程安全。我们还学习了如何让一个线程取消(有效地终止)给定的线程,以及如何让受害线程处理任何必要的清理工作。
本章的其余部分侧重于如何安全地混合线程与信号接口;我们还比较和对比了典型的多进程单线程与多线程(一个进程)方法的利弊(确实是一些值得思考的东西)。提示和常见问题解答结束了这一系列章节(第十四章,使用 Pthreads 进行多线程编程第一部分-基础知识 和本章)。
在下一章中,读者将通过详细了解 Linux 平台上的 CPU 调度,以及非常有趣的是,应用程序开发人员如何利用 CPU 调度(使用多线程应用程序演示)。
第十七章:Linux 上的 CPU 调度
人们经常问关于 Linux 的一个问题是,调度是如何工作的?我们将在本章中详细解答这个问题,以便用户空间应用程序开发人员清楚地掌握有关 Linux 上 CPU 调度的重要概念,以及如何在应用程序中强大地使用这些概念,我们还将涵盖必要的背景信息(进程状态机,实时等)。本章将以简要说明 Linux 操作系统如何甚至可以用作硬实时操作系统而结束。
在本章中,读者将了解以下主题:
-
Linux 进程(或线程)状态机,以及 Linux 在幕后实现的 POSIX 调度策略
-
相关概念,如实时和 CPU 亲和力
-
如何利用这一事实,即在每个线程基础上,您可以使用给定的调度策略和实时优先级来编程线程(将显示一个示例应用程序)
-
关于 Linux 也可以用作 RTOS 的简要说明
Linux 操作系统和 POSIX 调度模型
为了理解应用程序开发人员的调度(以及如何在实际代码中利用这些知识),我们首先必须涵盖一些必需的背景信息。
开发人员必须理解的第一个非常重要的概念是,操作系统维护一种称为内核可调度实体(KSE)的构造。*KSE 是操作系统调度代码操作的粒度。实际上,操作系统调度的是什么对象?是应用程序、进程还是线程?嗯,简短的答案是 Linux 操作系统上的 KSE 是一个线程。换句话说,所有可运行的线程都竞争 CPU 资源;内核调度程序最终是决定哪个线程在哪个 CPU 核心上运行以及何时运行的仲裁者。
接下来,我们将概述进程或线程的状态机。
Linux 进程状态机
在 Linux 操作系统上,每个进程或线程都会经历各种明确定义的状态,并通过对这些状态进行编码,我们可以形成 Linux 操作系统上进程(或线程)的状态机(在阅读本文时,请参考下一节中的图 1)。
既然我们现在了解了 Linux 操作系统上的 KSE 是一个线程而不是一个进程,我们将忽略使用单词进程的传统,而在描述通过各种状态的实体时使用单词线程。(如果更舒适的话,您可以在脑海中用线程替换进程。)
Linux 线程可以循环经历的状态如下(ps(1)实用程序通过此处显示的字母对状态进行编码):
-
R:准备运行或正在运行
-
睡眠:
-
S:可中断睡眠
-
D:不可中断睡眠
-
T:停止(或暂停/冻结)
-
Z:僵尸(或无效)
-
X:死亡
当线程新创建(通过fork(2),pthread_create(3)或clone(2)API)时,一旦操作系统确定线程完全创建,它通过将线程放入可运行状态来通知调度程序其存在。R状态的线程实际上正在 CPU 核心上运行,或者处于准备运行状态。我们需要理解的是,在这两种情况下,线程都被排队在操作系统内的一个称为运行队列(RQ)的数据结构上。运行队列中的线程是可以运行的有效候选者;除非线程被排队在操作系统运行队列上,否则不可能运行任何线程。 (供您参考,从 2.6 版开始,Linux 通过为每个 CPU 核心设置一个 RQ 来充分利用所有可能的 CPU 核心,从而获得完美的 SMP 可伸缩性。)Linux 不明确区分准备运行和运行状态;它只是将处于R状态的线程标记为准备运行或运行状态。
睡眠状态
一旦线程正在运行其代码,显然会一直这样做,直到通常发生以下几种情况:
-
它在 I/O 上阻塞,因此进入睡眠状态S或D,具体取决于(见下一段)。
-
它被抢占;没有状态改变,它仍然处于就绪运行状态R,在运行队列上。
-
它收到一个导致其停止的信号,因此进入状态T。
-
它收到一个信号(通常是 SIGSTOP 或 SIGTSTP),导致其终止,因此首先进入状态Z(僵尸状态是通向死亡的瞬态状态),然后实际死亡(状态 X)。
通常,线程在其代码路径中会遇到一个阻塞 API,这会导致它进入睡眠状态,等待事件。在被阻塞时,它会从原来的运行队列中移除(或出队),然后添加到所谓的等待队列(WQ)上。当它等待的事件发生时,操作系统会发出唤醒信号,导致它变为可运行状态(从等待队列中出队并加入运行队列)。请注意,线程不会立即运行;它将变为可运行状态(图 1中的Rr),成为调度程序的候选;很快,它将有机会在 CPU 上实际运行(Rcpu)。
一个常见的误解是认为操作系统维护一个运行队列和一个等待队列。不,Linux 内核为每个 CPU 维护一个运行队列。等待队列通常由设备驱动程序(以及内核)创建和使用;因此,可以有任意数量的等待队列。
睡眠的深度确定了线程被放入的确切状态。如果一个线程发出了一个阻塞调用,底层内核代码(或设备驱动程序代码)将其放入可中断睡眠状态,状态标记为S。可中断的睡眠状态意味着当发送给它的任何信号被传递时,线程将被唤醒;然后,它将运行信号处理程序代码,如果没有终止(或停止),将恢复睡眠(回想一下sigaction(2)中的SA_RESTART标志,来自第十一章,信号-第一部分)。这种可中断的睡眠状态S确实非常常见。
另一方面,操作系统(或驱动程序)可能会将阻塞线程放入更深的不可中断睡眠状态,此时状态标记为D。不可中断的睡眠状态意味着线程不会响应信号(没有;甚至没有来自 root 的 SIGKILL!)。当内核确定睡眠是关键的,并且线程必须等待挂起的事件时,会这样做(一个常见的例子是从文件中读取read(2)—当实际读取数据时,线程被放入不可中断的睡眠状态;另一个是挂载和卸载文件系统)。
性能问题通常是由非常高的 I/O 瓶颈引起的;高 CPU 使用率并不总是一个主要问题,但持续高的 I/O 会使系统感觉非常慢。确定哪个应用程序(实际上是进程和线程)导致了大量 I/O 的一个快速方法是过滤ps(1)输出,查找处于D状态的进程(或线程),即不可中断的睡眠状态。例如,参考以下内容:
$ ps -LA -o state,pid,cmd | grep "^D"
**D** 10243 /usr/bin/gnome-shell
**D** 13337 [kworker/0:2+eve]
**D** 22545 /home/<user>/.dropbox-dist/dropbox-lnx.x86_64-58.4.92/dropbox
$
请注意我们使用了ps -LA;-L开关显示所有活动的线程。 (FYI,前面方括号中显示的线程,[kworker/...],是一个内核线程。)
以下图表示了任何进程或线程的 Linux 状态机:

图 1:Linux 状态机
前面的图表显示了状态之间的转换,通过红色箭头。请注意,为了清晰起见,一些转换(例如,线程在睡眠或停止时可能被终止)在前面的图表中没有明确显示。
什么是实时?
关于“实时”(在应用程序编程和操作系统上下文中)的含义存在许多误解。实时基本上意味着实时线程(或线程)不仅要正确执行其工作,而且它们必须在给定的最坏情况截止日期内执行。实际上,实时系统的关键因素称为确定性。确定性系统对真实世界(或人工生成的)事件有保证的最坏情况响应时间;它们将在有限的时间约束内处理这些事件。确定性导致可预测的响应,在任何条件下都是如此,甚至在极端负载下也是如此。计算机科学家对算法进行分类的一种方式是通过它们的时间复杂度:大 O 符号。O(1)算法是确定性的;它们保证无论输入负载如何,都将在一定的最坏情况时间内完成。真实的实时系统需要 O(1)算法来实现其性能敏感的代码路径。
有趣的是,实时并不一定意味着真正快速。 VDC 调查(有关更多详细信息,请参阅 GitHub 存储库上的“进一步阅读”部分)显示,大多数实时系统的截止日期(实时响应时间)要求为 1 至 9 毫秒。只要系统能够始终且无故障地在给定的截止日期内处理事件(可能相当长),它就是实时的。
实时类型
实时通常被分类为三种类型,如下:
-
硬实时系统被定义为必须始终满足所有截止日期的系统。甚至一次未能满足截止日期都会导致系统的灾难性失败,包括可能造成人员伤亡、财务损失等。硬实时系统需要一个实时操作系统(RTOS)来驱动它。(此外,应用程序编写成硬实时也非常重要!)。可能的硬实时领域包括各种人员运输工具(飞机、船舶、宇宙飞船、火车和电梯)以及某些类型的军用或国防设备、核反应堆、医疗电子设备和股票交易所。(是的,股票交易所确实是一个硬实时系统;请阅读书籍《自动化:算法如何统治我们的世界》—请参阅 GitHub 存储库上的“进一步阅读”部分获取更多信息。)
-
软实时系统都是尽最大努力;截止日期确实存在,但绝对不能保证会被满足。系统将尽最大努力满足它们;未能做到这一点被认为是可以接受的(通常只是对最终用户而言更多是一种烦恼而不是危险)。消费类电子产品(如我们的智能手机、MP3 播放器、相机、平板电脑和智能音箱)是典型的例子。在使用它们时,经常会发生听音乐时出现故障,或者流媒体视频出现卡顿、缓冲和抖动。虽然令人讨厌,但用户不太可能因此而丧生。
-
中实时系统介于硬实时和软实时系统之间——截止日期很重要,尽可能会被满足,但同样,无法做出铁 clad 保证。由于错过太多截止日期而导致性能下降是一个问题。
调度策略
操作系统(OS)的一个关键工作是调度可运行的任务。POSIX 标准规定 POSIX 兼容的操作系统必须提供(至少)三种调度策略。调度策略实际上是操作系统用于调度任务的调度算法。在本书中,我们不会深入探讨这些细节,但我们确实需要应用程序开发人员了解可用的调度策略。这些如下:
-
SCHED_FIFO -
SCHED_RR -
SCHED_OTHER(也称为SCHED_NORMAL)
我们的讨论自然而然地将仅涉及 Linux 操作系统。
首先要理解的第一件重要事情是,普通的 Linux 操作系统不是实时操作系统;它不支持硬实时,并且被分类为通用目的操作系统(GPOS),就像其他操作系统一样——Unix,Windows 和 macOS。
不过,请继续阅读;我们将看到,虽然普通的 Linux 不支持硬实时,但确实可以运行一个经过适当打补丁的 Linux 作为 RTOS。
尽管 Linux 是一个 GPOS,但它很容易表现为一个软实时系统。事实上,它的高性能特征使其接近成为一个坚实的实时系统。因此,Linux 操作系统在消费电子产品(和企业)产品中的主要使用并不奇怪。
接下来,我们提到的前两个调度策略——SCHED_FIFO和SCHED_RR——是 Linux 的软实时调度策略。SCHED_OTHER(也称为SCHED_NORMAL)策略是非实时调度策略,并且始终是默认的。SCHED_OTHER策略在现代 Linux 内核上实现为完全公平调度器(CFS);其主要设计目标是提供整体高系统吞吐量和对每个可运行任务(线程)的公平性,确保线程不会饿死。这与实时策略算法的主要动机——线程的优先级相反。
对于SCHED_FIFO和SCHED_RR软实时策略,Linux 操作系统指定了一个优先级范围。这个范围是从 1 到 99,其中 1 是最低的实时优先级,99 是最高的。Linux 上的软实时调度策略设计遵循所谓的固定优先级抢占调度,这一点很重要。固定优先级意味着应用程序决定并固定线程优先级(并且可以更改它);操作系统不会。抢占是操作系统从运行线程手中夺走 CPU 的行为,将其降回运行队列,并切换到另一个线程。关于调度策略的精确抢占语义将在接下来进行介绍。
现在,我们将简要描述在这些不同的调度策略下运行意味着什么。
运行中的SCHED_FIFO线程只能在以下三种情况下被抢占:
-
它(不)自愿地放弃处理器(从技术上讲,它从R状态移出)。当任务发出阻塞调用或调用
sched_yield(2)等系统调用时会发生这种情况。 -
它停止或终止。
-
更高优先级的实时任务变为可运行状态。
这是需要理解的关键点:SCHED_FIFO任务是具有侵略性的;它以无限时间片运行,除非它被阻塞(或停止或终止),否则将继续在处理器上运行。然而,一旦更高优先级的线程变为可运行状态(状态R,进入运行队列),它将被优先于这个线程。
SCHED_RR的行为几乎与SCHED_FIFO相同,唯一的区别是:
-
它有一个有限的时间片,因此在时间片到期时可以被抢占的额外情况。
-
被抢占时,任务被移动到其优先级级别的运行队列尾部,确保所有相同优先级级别的
SCHED_RR任务依次执行(因此它的名称为轮询)。
请注意,在 RTOS 上,调度算法是简单的,因为它实际上只需要实现这个语义:最高优先级的可运行线程必须是正在运行的线程。
所有线程默认情况下都在SCHED_OTHER(或SCHED_NORMAL)调度策略下运行。这是一个明显的非实时策略,重点是公平性和整体吞吐量。从 Linux 内核版本 2.6.0 到 2.6.22(包括)的实现是通过所谓的 O(1)调度程序;从 2.6.23 开始,进一步改进的算法称为完全公平调度器(CFS)实现了这种调度策略(实际上是一种调度类)。有关更多信息,请参考以下表格:
| 调度策略 | 类型 | 优先级范围 |
|---|---|---|
SCHED_FIFO |
软实时:激进,不公平 | 1 到 99 |
SCHED_RR |
软实时:较不激进 | 1 到 99 |
SCHED_OTHER |
非实时:公平,时间共享;默认值 | 优先级范围(-20 到+19) |
尽管不太常用,但我们指出 Linux 也支持使用 SCHED_BATCH 策略的批处理模式进程执行策略。此外,SCHED_IDLE 策略用于非常低优先级的后台任务。(实际上,CPU 空闲线程 - 名为swapper,PID 为0,每个 CPU 都存在,并且只有在绝对没有其他任务想要处理器时才运行)。
查看调度策略和优先级
Linux 提供了chrt(1)实用程序来查看和更改线程(或进程)的实时调度策略和优先级。可以在以下代码中看到使用它来显示给定进程(按 PID)的调度策略和优先级的快速演示:
$ chrt -p $$
pid 1618's current scheduling policy: SCHED_OTHER
pid 1618's current scheduling priority: 0
$
在前面的内容中,我们已经查询了chrt(1)进程本身的调度策略和优先级(使用 shell 的$$变量)。尝试对其他线程执行此操作;您会注意到策略(几乎)总是SCHED_OTHER,而实时优先级为零。实时优先级为零意味着该进程不是实时的。
您可以通过将线程 PID(通过ps -LA的输出或类似方式)传递给chrt(1)来查询线程的调度策略和(实时)优先级。
nice value
那么,现在您可能会想知道,如果所有非实时线程(SCHED_OTHER)的优先级都为零,那么我如何在它们之间支持优先级?好吧,这正是SCHED_OTHER线程的nice value的用途:这是(较旧的)Unix 风格的优先级模型,现在在 Linux 上指定了非实时线程之间的相对优先级。
nice value是在现代 Linux 上介于-20到+19之间的优先级范围,基本优先级为零。在 Linux 上,这是一个每个线程的属性;当创建线程时,它会继承其创建者线程的nice value - 零是默认值。请参考以下图表:

图 2:Linux 线程优先级范围
从 2.6.23(使用 CFS 内核调度程序),线程的nice value对调度有很大影响(每个nice value度的因素为 1.25);因此,-20的nice value线程获得更多的 CPU 带宽(这对于像多媒体这样对 CPU 敏感的应用程序很有好处),而+19的nice value线程获得的 CPU 很少。
应用程序员可以通过nice(1)命令行实用程序以及nice(2),setpriority(2)和sched_setattr(2)系统调用(最后一个是最近和正确的使用方法)来查询和设置nice value。我们建议您参考这些 API 的相应手册页。
请记住,实时(SCHED_FIFO或SCHED_RR)线程在优先级方面始终优于SCHED_OTHER线程(因此几乎可以保证它将有机会更早运行)。
CPU 亲和力
让我们想象一个具有四个 CPU 核心的 Linux 系统,为简单起见,有一个准备运行的线程。这个线程将在哪个 CPU 核心上运行?内核将决定这一点;要意识到的关键事情是它可以在四个可用的 CPU 中的任何一个上运行!
程序员可以指定它可能运行的 CPU 吗?是的,确实;这个特性本身就叫做 CPU 亲和力。在 Linux 上,这是一个每个线程的属性(在操作系统内)。CPU 亲和力可以通过改变线程的 CPU 亲和力掩码来在每个线程上进行更改;当然,这是通过系统调用实现的。让我们看一下下面的代码:
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <sched.h>
int sched_setaffinity(pid_t pid, size_t cpusetsize,
const cpu_set_t *mask);
int sched_getaffinity(pid_t pid, size_t cpusetsize,
cpu_set_t *mask);
内核调度程序将遵守程序员设置的 CPU 掩码,即线程被允许执行的 CPU 集合。我们期望将 CPU 亲和力掩码指定为cpu_set_t对象。(我们建议读者参考sched_setaffinity(2)的手册页,它提供了一个示例程序)。
请注意,pthread 框架提供了包装 API pthread_setaffinity_np(3)和pthread_getaffinity_np(3),以在给定线程上执行相同的操作(它们在内部调用sched_setaffinity(2)系统调用)。
CPU 预留的一个有趣设计是 CPU 亲和力掩码模型,可以在多核系统上有效地为性能关键的线程(或线程)设置一个 CPU 核心。这意味着必须为该线程设置特定的 CPU 掩码,并且将所有其他线程的 CPU 掩码设置为排除核心 3。
尽管听起来很简单,但这并不是一个微不足道的练习;其中一些原因如下:
-
您必须意识到,预留的 CPU 并不是真正专门为指定的线程(们)保留的;对于真正的 CPU 预留,除了在该 CPU 上运行的给定线程(们)之外,整个系统上的所有其他线程都必须以某种方式被排除在该 CPU 之外。
-
作为一般准则,操作系统调度程序最了解如何在可用的 CPU 核心之间分配 CPU 带宽(它具有负载平衡器组件并了解 CPU 层次结构);因此,最好将 CPU 分配留给操作系统。
现代 Linux 内核支持一个非常强大的功能:控制组(cgroups)。关于 CPU 预留,可以通过 cgroup 模型实现。请参考 Stack Overflow 上的以下问答以获取更多详细信息:如何使用 cgroups 限制除白名单之外的所有进程到单个 CPU:unix.stackexchange.com/questions/247209/how-to-use-cgroups-to-limit-all-processes-except-whitelist-to-a-single-cpu。
为了方便起见,Linux 提供了taskset(1)实用程序,作为查询和指定任何给定进程(或线程)的 CPU 亲和力掩码的简单方法。在这里,我们将查询两个进程的 CPU 亲和力掩码。(我们假设我们运行的系统有四个 CPU 核心;我们可以使用lscpu(1)来查询这一点):
$ taskset -p 1
pid 1's current affinity mask: f
$ taskset -p 12446
pid 12446's current affinity mask: 7
$
PID 1(systemd)的 CPU 亲和力掩码是0xf,当然,这是二进制1111。如果设置了一个位1,则表示线程可以在由该位表示的 CPU 上运行。如果清除了该位0,则表示线程不能在由该位表示的 CPU 上运行。正如预期的那样,在一个四 CPU 的盒子上,CPU 亲和力位掩码默认为 0xf(1111),这意味着进程(或线程)可以在任何可用的 CPU 上运行。有趣的是,在前面的输出中,bash 进程似乎具有 CPU 亲和力掩码为7,这对应于二进制0111,这意味着它永远不会被调度到 CPU 3 上运行。
在下面的代码中,一个简单的 shell 脚本在循环中调用chrt(1)和taskset(1)实用程序,显示系统上每个进程的调度策略(实时)优先级和 CPU 亲和力掩码。
# ch17/query_sched_allprcs.sh
for p in $(ps -A -To pid)
do
chrt -p $p 2>/dev/null
taskset -p $p 2>/dev/null
done
我们鼓励读者在自己的系统上尝试这个。在下面的代码中,我们使用grep(1)来查找任何SCHED_FIFO任务:
$ ./query_sched_allprcs.sh | grep -A2 -w SCHED_FIFO
pid 12's current scheduling policy: SCHED_FIFO
pid 12's current scheduling priority: 99
pid 12's current affinity mask: 1
pid 13's current scheduling policy: SCHED_FIFO
pid 13's current scheduling priority: 99
pid 13's current affinity mask: 1
--
pid 16's current scheduling policy: SCHED_FIFO
pid 16's current scheduling priority: 99
pid 16's current affinity mask: 2
pid 17's current scheduling policy: SCHED_FIFO
pid 17's current scheduling priority: 99
pid 17's current affinity mask: 2
--
[...]
是的!我们找到了一些线程。哇,它们都是SCHED_FIFO实时优先级 99!让我们来看看这些线程是谁(还有一个很酷的一行脚本):
$ ps aux | awk '$2==12 || $2==13 || $2==16 || $2==17 {print $0}'
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 12 0.0 0.0 0 0 ? S 13:42 0:00 [migration/0]
root 13 0.0 0.0 0 0 ? S 13:42 0:00 [watchdog/0]
root 16 0.0 0.0 0 0 ? S 13:42 0:00 [watchdog/1]
root 17 0.0 0.0 0 0 ? S 13:42 0:00 [migration/1]
$
为了清晰起见,前面的代码中显示了通常不会显示的ps aux标题。此外,我们使用ps aux样式,因为内核线程会显示在括号中。
事实证明(至少在这个特定的例子中),它们都是内核线程(请参阅下面的信息框)。要理解的重要一点是,它们故意设置为SCHED_FIFO(实时)优先级 99,这样,当它们想要在 CPU 上运行时,它们几乎立即就会运行。实际上,让我们来看一下它们的 CPU 亲和性掩码:它们被故意分配(具有值如 1,2,4,8),以便它们与特定的 CPU 核心相关联。重要的是要理解,这些内核线程并不会占用 CPU;实际上,它们大部分时间都处于睡眠状态(状态S),只有在需要时才会立即行动。
内核线程与它们的用户空间对应物并没有太大的不同;它们也会竞争 CPU 资源。关键的区别在于,内核线程无法看到用户空间,它们只在内核虚拟地址空间中执行(而用户空间线程当然可以看到用户模式下的用户空间,并且在发出系统调用时会切换到内核空间)。
利用 Linux 的软实时能力
回想一下,在本章的前面,我们曾经说过:Linux 上的软实时调度策略设计遵循所谓的固定优先级抢占式调度;固定优先级意味着应用程序决定并固定线程优先级(并且可以更改它);操作系统不会。
应用程序不仅可以在线程优先级之间切换,甚至可以由应用程序开发人员更改调度策略(实际上是操作系统在后台使用的调度算法);这可以在每个线程的基础上进行。这确实非常强大;这意味着一个应用程序拥有,比如说,五个线程,可以决定为每个线程分配什么调度策略和优先级!
调度策略和优先级 API
显然,为了实现这一点,操作系统必须暴露一些 API;事实上,有一些系统调用处理这一点——改变给定进程或线程的调度策略和优先级。
这里列出了一些更重要的这些 API 中的一部分,实际上只是一小部分:
-
sched_setscheduler(2): 设置指定线程的调度策略和参数。 -
sched_getscheduler(2): 返回指定线程的调度策略。 -
sched_setparam(2): 设置指定线程的调度参数。 -
sched_getparam(2): 获取指定线程的调度参数。 -
sched_get_priority_max(2): 返回指定调度策略中可用的最大优先级。 -
sched_get_priority_min(2): 返回指定调度策略中可用的最小优先级。 -
sched_rr_get_interval(2): 获取在轮转调度策略下调度的线程使用的时间片。 -
sched_setattr(2): 设置指定线程的调度策略和参数。这个(特定于 Linux 的)系统调用提供了sched_setscheduler(2)和sched_setparam(2)功能的超集。 -
sched_getattr(2): 获取指定线程的调度策略和参数。这个(特定于 Linux 的)系统调用提供了sched_getscheduler(2)和sched_getparam(2)功能的超集。
sched_setattr(2)和sched_getattr(2)目前被认为是这些 API 中最新和最强大的。此外,在 Ubuntu 上,可以使用方便的man -k sched命令来查看与调度相关的所有实用程序和 API(-k:关键字)。
敏锐的读者很快会注意到我们之前提到的所有 API 都是系统调用(手册的第二部分),但 pthread API 呢?的确,它们也存在,并且,正如你可能已经猜到的那样,它们大多只是调用底层系统调用的包装器;在下面的代码中,我们展示了其中的两个:
#include <pthread.h>
int pthread_setschedparam(pthread_t thread, int policy,
const struct sched_param *param);
int pthread_getschedparam(pthread_t thread, int *policy,
struct sched_param *param);
重要的是要注意,为了设置线程(或进程)的调度策略和优先级,您需要以 root 访问权限运行。请记住,赋予线程特权的现代方式是通过 Linux Capabilities 模型(我们在第八章中详细介绍了进程特权)。具有CAP_SYS_NICE能力的线程可以任意将其调度策略和优先级设置为任何它想要的值。想一想:如果不是这样的话,那么几乎所有的应用程序都可以坚持以SCHED_FIFO优先级 99 运行,从而有效地使整个概念变得毫无意义!
pthread_setschedparam(3)在内部调用了sched_setscheduler(2)系统调用,pthread_getschedparam(3)在底层调用了sched_getscheduler(2)系统调用。它们的 API 签名是:
#include <sched.h>
int sched_setscheduler(pid_t pid, int policy,
const struct sched_param *param);
int sched_getscheduler(pid_t pid);
还存在其他 pthread API。请注意,这里显示的 API 有助于设置线程属性结构:pthread_attr_setinheritsched(3)、pthread_attr_setschedparam(3)、pthread_attr_setschedpolicy(3)和pthread_setschedprio(3)等。
sched(7)的 man 页面(在终端窗口中键入man 7 sched查找)详细介绍了用于控制线程调度策略、优先级和行为的可用 API。它提供了有关当前 Linux 调度策略、更改它们所需的权限、相关资源限制值和调度的内核可调参数,以及其他杂项细节。
代码示例-设置线程调度策略和优先级
为了巩固本章前几节学到的概念,我们将设计并实现一个小型演示程序,演示现代 Linux pthreads 应用程序如何设置单个线程的调度策略和优先级,以使线程(软)实时。
我们的演示应用程序将有三个线程。第一个当然是main()。以下要点显示了应用程序的设计目的:
- 线程 0(实际上是
main()):
这以SCHED_OTHER调度策略和实时优先级 0 运行,这是默认值。它执行以下操作:
-
查询
SCHED_FIFO的优先级范围,并打印出值 -
创建两个工作线程(可连接状态设置为分离状态);它们将自动继承主线程的调度策略和优先级
-
在循环中向终端打印字符
m(使用我们的DELAY_LOOP宏;比平常长一点) -
终止
-
工作线程 1:
-
将其调度策略更改为
SCHED_RR,将其实时优先级设置为命令行传递的值 -
休眠 2 秒(因此在 I/O 上阻塞,允许主线程完成一些工作)
-
唤醒后,它在循环中向终端打印字符
1(通过DELAY_LOOP宏) -
终止
-
工作线程 2:
-
将其调度策略更改为
SCHED_FIFO,将其实时优先级设置为命令行传递的值加上 10 -
休眠 4 秒(因此在 I/O 上阻塞,允许线程 1 完成一些工作)
-
唤醒后,它在循环中向终端打印字符
2 -
终止
让我们快速看一下代码(ch17/sched_rt_eg.c):
为了便于阅读,这里只显示了源代码的关键部分;要查看完整的源代码,并构建和运行它,整个树可在 GitHub 上克隆:github.com/PacktPublishing/Hands-on-System-Programming-with-Linux。
以下代码是main()的代码。(我们省略了显示错误检查代码):
#define NUMWORK 200
...
min = sched_get_priority_min(SCHED_FIFO);
max = sched_get_priority_max(SCHED_FIFO);
printf("SCHED_FIFO: priority range is %d to %d\n", min, max);
rt_prio = atoi(argv[1]);
...
ret = pthread_create(&tid[0], &attr, worker1, (void *)rt_prio);
ret = pthread_create(&tid[1], &attr, worker2, (void *)rt_prio);
pthread_attr_destroy(&attr);
DELAY_LOOP('m', NUMWORK+100);
printf("\nmain: all done, app exiting ...\n");
pthread_exit((void *)0);
}
以下代码是工作线程 1 的代码。我们省略了显示错误检查代码:
void *worker1(void *msg)
{
struct sched_param p;
printf(" RT Thread p1 (%s():%d:PID %d):\n"
" Setting sched policy to SCHED_RR and RT priority to %ld"
" and sleeping for 2s ...\n", __func__, __LINE__, getpid(), (long)msg);
p.sched_priority = (long)msg;
pthread_setschedparam(pthread_self(), SCHED_RR, &p);
sleep(2);
puts(" p1 working");
DELAY_LOOP('1', NUMWORK);
puts(" p1: exiting..");
pthread_exit((void *)0);
}
工作线程 2 的代码几乎与前面的工作线程相同;然而,不同之处在于我们将策略设置为SCHED_FIFO,并且将实时优先级提高了 10 分,从而使其更具侵略性。我们只在这里显示这个片段:
p.sched_priority = prio + 10;
pthread_setschedparam(pthread_self(), SCHED_FIFO, &p);
sleep(4);
puts(" p2 working");
DELAY_LOOP('2', NUMWORK);
让我们构建它(我们强烈建议构建调试版本,因为这样DELAY_LOOP宏的效果就可以清楚地看到),然后试一试:
$ make sched_rt_eg_dbg
gcc -g -ggdb -gdwarf-4 -O0 -Wall -Wextra -DDEBUG -pthread -c sched_rt_eg.c -o sched_rt_eg_dbg.o
gcc -o sched_rt_eg_dbg sched_rt_eg_dbg.o common_dbg.o -pthread -lrt
$
我们必须以 root 身份运行我们的应用程序;我们使用sudo(8)来做到这一点:
$ sudo ./sched_rt_eg_dbg 14
SCHED_FIFO: priority range is 1 to 99
main: creating RT worker thread #1 ...
main: creating RT worker thread #2 ...
RT Thread p1 (worker1():68:PID 18632):
Setting sched policy to SCHED_RR and RT priority to 14 and sleeping for 2s ...
m RT Thread p2 (worker2():101:PID 18632):
Setting sched policy to SCHED_FIFO and RT priority to 24 and sleeping for 4s ...
mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm p1 working
1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m11m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m11m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m11m1m1m p2 working
2m12m12m1m2m12m12m1m2m12m12m1m2m12m12m12m12m12m112m12m12m12m112m12m12m112m12m12m112m12m12m12m112m12m12m121m211m21m21m21m211m21m21m21m211m21m21m21m211m21m21m21m211m21m21m21m211m21m21m21
main: all done, app exiting ...
$
在前面的输出中,我们可以看到以下字符:
-
m:这意味着main线程目前正在 CPU 上运行 -
1:这意味着(软)实时工作线程 1 目前正在 CPU 上运行 -
2:这意味着(软)实时工作线程 2 目前正在 CPU 上运行
但是,哎呀,前面的输出并不是我们期望的:m,1和2字符混在一起,让我们得出它们已经被分时切片的结论。
但事实并非如此。仔细想想——输出与前面的代码中所显示的一样,是因为我们在多核系统上运行了应用程序(在前面的代码中,在一个具有四个 CPU 核心的笔记本电脑上);因此,内核调度程序巧妙地利用了硬件,在不同的 CPU 核心上并行运行了所有三个线程!因此,为了使我们的演示应用程序按我们的期望运行,我们需要确保它只在一个 CPU 核心上运行,而不是更多。如何做到?回想一下 CPU 亲和力:我们可以使用sched_setaffinity(2)系统调用来做到这一点。还有一种更简单的方法:我们可以使用taskset(1)来保证进程(因此其中的所有线程)只在一个 CPU 核心上运行(例如,CPU 0),方法是将 CPU 掩码值指定为01。因此,让我们执行以下命令:
$ sudo taskset 01 ./sched_rt_eg_dbg 14
[sudo] password for <username>: xxx
SCHED_FIFO: priority range is 1 to 99
main: creating RT worker thread #1 ...
main: creating RT worker thread #2 ...
m RT Thread p2 (worker2():101:PID 19073):
Setting sched policy to SCHED_FIFO and RT priority to 24 and sleeping for 4s ...
RT Thread p1 (worker1():68:PID 19073):
Setting sched policy to SCHED_RR and RT priority to 14 and sleeping for 2s ...
mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm p1 working
11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 p2 working
22222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222 p2 exiting ...
111111111111111111111111111111111111111111111111111111111111111111111111 p1: exiting..
mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm
main: all done, app exiting ...
$
是的,使用taskset(1)来确保整个应用程序——所有三个线程——在第一个 CPU 核心上运行产生了期望的效果。现在,仔细研究前面的输出;我们可以看到main()线程——非实时——首先运行了大约 2 秒;一旦经过了 2 秒,工作线程 1 就会醒来,变得可运行。由于它的策略和优先级远远超过了main(),它抢占了main()并运行,向终端打印 1s。请记住,工作线程 2 也在并行运行,但是它当然会睡眠 4 秒。所以,2 秒后——一共经过了 4 秒——工作线程 2 醒来,变得可运行。由于它的策略是SCHED_FIFO,更重要的是,它的优先级比线程 1 高 10 分,它抢占了线程 1 并运行,向终端打印2s。在它终止之前,其他线程无法运行;一旦它终止,工作线程 1 运行。同样,在它终止之前,main()无法运行;一旦它终止,main()最终获得 CPU 并完成,应用程序终止。有趣;你自己试试吧。
供您参考,关于pthread_setschedparam(3)的 man 页面有一个相当详细的示例程序:man7.org/linux/man-pages/man3/pthread_setschedparam.3.html。
软实时——额外考虑
还有一些额外的要点需要考虑:我们有权将线程与(软)实时策略和优先级相关联(前提是我们拥有 root 访问权限;或者 CAP_SYS_NICE 能力)。对于大多数人机交互应用领域来说,这不仅是不必要的,而且会给典型的桌面或服务器系统最终用户带来令人不安的反馈和副作用。一般来说,您应该避免在交互式应用程序上使用这些实时策略。只有在必须高度优先考虑一个线程时——通常是为了实时应用程序(可能在嵌入式 Linux 盒子上运行),或某些类型的基准测试或分析软件(perf(1)是一个很好的例子;可以指定--realtime=n参数给perf,使其以SCHED_FIFO优先级n运行)——您才应该考虑使用这些强大的技术。
此外,要使用的精确实时优先级留给应用架构师;对于SCHED_FIFO和SCHED_RR线程使用相同的优先级值(请记住,这两种策略是同级的,SCHED_FIFO更为激进)可能会导致不可预测的调度。仔细考虑设计,并相应地设置每个实时线程的策略和优先级。
最后,尽管本书没有深入介绍,但 Linux 的 cgroups 模型允许您强大地控制资源(CPU、网络和块 I/O)的带宽分配给特定进程或一组进程。如果需要这样做,请考虑使用 cgroups 框架来实现您的目标。
RTL - Linux 作为 RTOS
事实上,令人难以置信的是,Linux 操作系统可以用作 RTOS;也就是说,可以用作硬实时 RTOS。该项目最初是 Linutronix 的 Thomas Gleixner 的构想。
再次强调,这真的是开源模型和 Linux 的美丽之处;作为开源项目,有兴趣和动力的人将 Linux(或其他项目)作为起点,并在此基础上构建,通常会产生显著新颖和有用的产品。
关于该项目的一些要点如下:
-
修改 Linux 内核以成为 RTOS 是一个必然具有侵入性的过程;事实上,Linux 的领导者 Linus Torvalds 不希望这些代码出现在上游(原始)Linux 内核中。因此,实时 Linux 内核项目作为一个补丁系列存在(在 kernel.org 本身上;请参阅 GitHub 存储库上的进一步阅读部分中的链接以获取更多信息),可以应用于主线内核。
-
这一努力从 Linux 2.6.18 内核开始就已经成功进行(大约从 2006 年或 2007 年开始)。
-
多年来,该项目被称为 Preempt-RT(补丁本身被称为 PREEMPT_RT)。
-
后来(从 2015 年 10 月起),该项目的管理权被Linux 基金会(LF)接管——这是一个积极的举措。名称从 Preempt RT 更改为real-time Linux(RTL)。
-
事实上,RTL 路线图非常有推动相关的 PREEMPT_RT 工作上游(进入主线 Linux 内核;请参阅 GitHub 存储库上的进一步阅读部分以获取相关链接)的目标。
实际上,您可以应用适当的 RTL 补丁,然后将 Linux 用作硬实时 RTOS。行业已经开始在工业控制应用程序、无人机和电视摄像机中使用该项目;我们只能想象这将会大大增长。还要注意的是,拥有硬实时操作系统并不足以满足真正实时使用的要求;甚至应用程序也必须按照实时预期进行编写。请查看 RTL 项目维基站点上提供的HOWTO文档(请参阅 GitHub 存储库上的进一步阅读部分)。
总结
在本章中,我们涵盖了与 Linux 和实时 CPU 调度相关的重要概念。读者已经逐步了解了 Linux 线程状态机、实时性、CPU 亲和力以及可用的 POSIX 调度策略等主题。此外,我们展示了在 pthread 和系统调用层面利用这些强大机制的 API。演示应用程序强化了我们学到的概念。最后,我们简要介绍了 Linux 也可以用作硬实时(RTOS)的事实。
在下一章中,读者将学习如何利用现代技术实现最佳的 I/O 性能。


浙公网安备 33010602011771号