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

更多关于进程管理的学习

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

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

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

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

  • 调查进程的本质
  • 继续探讨进程状态和一些调度机制
  • 更多地了解进程创建
  • 介绍在C++中用于线程操作的系统调用

基本要求

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

  • 一个能够编译和执行C++20的基于Linux的系统(例如,Linux Mint 21
  • GCC12.2编译器(https://gcc.gnu.org/git/gcc.gitgcc-source)带有-std=c++2a-lpthread标志
  • 或者,对于所有示例,您可以使用https://godbolt.org/
  • 本章中的所有代码示例可从以下网址下载:https://share.xueplus.com/s/9fXDVv-4fijh.html

分解进程创建

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

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

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

再次,正如我们在上一章中已经提到的,程序就像所有其他操作系统资源一样,是一个文件。C++程序是一个可执行对象文件,其中包含必须给予CPU的代码 - 例如,指令。这个文件是编译的结果。编译器是另一个程序,它将C++代码转换为机器指令。意识到我们的系统支持什么指令是至关重要的。操作系统和编译器是集成标准、库、语言特性等的先决条件,而且很有可能编译后的对象文件无法在与我们的系统不完全匹配的另一个系统上运行。此外,同样的代码,在另一个系统或通过另一个编译器编译,很可能会有不同的可执行对象文件大小。文件越大,从NVM加载程序到主内存(最常使用的随机访问内存RAM))的时间就越长。为了分析我们的代码的速度,并为给定系统尽可能地优化它,我们将看一下我们的数据或指令沿其路径的通用图表。这有点离题,所以请耐心等待:

Figure 2.1 – Loading a program and its sequence of instruction execution events

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

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

此外,根据它们的角色,识别出三种类型的缓存:指令缓存数据缓存地址转换旁路缓冲TLB)。前两者不言自明,而TLB与CPU缓存不直接相关 - 它是一个独立的单元。它用于数据和指令的地址,但其作用是加速虚拟地址到物理地址的转换,我们将在本章后面讨论。

RAM经常被使用,主要涉及双数据速率同步动态随机访问内存DDR SDRAM)存储电路。这是一个非常重要的点,因为不同的DDR总线配置有不同的速度。无论速度如何,它仍然不如CPU内部传输快。即使CPU负载100%,DDR很少被充分利用,因此成为我们的第一个显著瓶颈。如第1章中所述,NVM比DDR慢得多,这是它的第二个显著瓶颈。我们鼓励您分析您的系统,并查看速度差异。

重要提示

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

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

内存段

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

  • 文本:这是将在机器上执行的代码。它在编译时创建。当它到达运行时,它是进程的只读部分。当前的机器指令在那里找到,根据编译器的不同,您可能也会在那里找到const变量。
  • 数据:这个段也在编译时创建,包含初始化的全局、静态或同时是全局和静态的数据。它用于预先分配的存储,每当您不想依赖运行时分配时。
  • BSS:与数据段相反,BSS在对象文件中不分配空间 - 它只标记程序运行时所需的存储。它包含未初始化的全局、静态或同时是全局和静态的数据。这个段在编译时创建。其数据被认为理论上根据语言标准初始化为0,但实际上是在进程启动时由操作系统的程序加载器设置为0
  • :程序栈是一个内存段,代表运行中的程序例程 - 它保存它们的局部变量,并跟踪在被调用的函数返回时从哪里继续。它在运行时构建,并遵循后进先出LIFO)政策。我们希望保持它小而快。
  • :这是另一个在运行时创建的段,用于动态内存分配。对于许多嵌入式系统来说,它被认为是禁忌,但我们将在本书后面进一步探讨它。有趣的教训有待学习,而且不总是能够避免它。

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

Figure 2.2 – The memory segments of two processes

图2.2 - 两个进程的内存段

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

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

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

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

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

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

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

总大小是2688字节,其中2040字节是指令,640字节是数据,8字节是BSS。如您所见,我们没有任何全局或静态数据,但仍然有648字节放在那里。请记住,编译器仍在工作,因此那里有一些分配的符号,我们可以在需要时进一步分析它们:

$ readelf -s test

现在,让我们关注其他事情,并修改代码如下:

void test_func(){
    static uint32_t test_var;
} 

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

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

所以,BSS变大了 - 不是增加了4字节,而是8字节。让我们再次检查我们新变量的大小:

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

一切都好 - 如预期,无符号32位整数占用4字节,但编译器在那里放了一些额外的符号。我们还可以看到它在BSS段中,这是由符号前的字母b表示的。现在,让我们再次更改代码:

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

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

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

如预期,数据段增加了4字节,我们的变量就在那里(请参阅符号前的字母d)。您还可以看到,编译器将BSS的使用减少到了4字节,并且整个对象文件的大小更小了 - 只有2688字节。

让我们做最后一个改变:

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

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

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

正确!我们可以看到符号前的字母r文本大小是2044,而不是之前的2040。编译器再次生成了8字节的BSS看起来相当有趣,但我们可以接受。如果我们从定义中删除static,大小会发生什么变化?我们鼓励您尝试一下。

此时,您可能已经意识到,更大的编译时段通常意味着更大的可执行文件。而更大的可执行文件意味着程序启动所需的时间更长,因为从NVM复制数据到主内存的速度显著慢于从主内存复制数据到CPU缓存。我们将在讨论上下文切换时回到这个讨论。如果我们想让我们的启动快速,那么我们应该考虑更小的编译时段,但更大的运行时段。这是一个通常由软件架构师或了解良好系统概览和知识的人来进行的平衡行为。必须考虑NVM读/写速度、DDR配置、CPU和RAM在系统启动、正常工作和关闭期间的负载、活动进程的数量等先决条件。

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

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

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

现在,让我们暂时放下这些,试图简化代码的目标 - 它需要指导CPU执行操作并修改数据。我们的任务是考虑什么是正确的指令,以便我们可以节省在重新调度或通过阻塞资源而无所作为的时间。让我们来看看我们的进程可能会处于的状态:

Figure 2.3 – Linux task states and their dependencies

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

前图中详细描述了状态,但Linux将它们以四种一般字母表示法呈现给用户:

  • 执行中(R - 运行和可运行):处理器(核心或线程)提供给进程的指令 - 任务正在运行。调度算法可能会强制它放弃执行。然后,任务变为可运行的,并被添加到可运行的队列中,等待轮换。这两种状态是不同的,但都被标记为执行中的 进程
  • 睡眠(D - 不可中断和S - 可中断):还记得上一章中关于文件读/写的例子吗?那是一种等待外部资源的不可中断睡眠形式。直到资源可用且进程再次可执行,睡眠无法通过信号被中断。可中断睡眠不仅取决于资源的可用性,而且允许通过信号控制进程。
  • 停止(T):您是否曾经使用Ctrl + Z停止过进程?那就是将进程置于停止状态的信号,但取决于信号请求,它可能被忽略,进程将继续。或者,进程可能被停止,直到它被发出信号再次继续。我们将在本书后面讨论信号。
  • 僵尸(Z):我们在第1章中看到了这个状态 - 进程已终止,但在操作系统的任务向量中仍然可见。

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

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

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

$ ps a
PID TTY STAT TIME COMMAND 

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

调度机制

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

高层次的调度

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

低层次的调度

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

重要提示

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

以下是一个基本概述:

Figure 2.4 – Ready /blocked task queues

图2.4 - 就绪/阻塞任务队列

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

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

  • 先到先服务FCFS):如今,这种方式很少使用,因为较长的任务可能会阻塞系统性能,重要的进程可能永远不会执行。

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

  • 最高优先级优先HPF):这里,任务有优先级,其中最高的将被执行。但谁设定优先级值,谁决定是否一个进来的进程将导致重新调度?Kleinrock规则是其中一种方法,优先级会随着任务在队列中的停留时间线性增加。根据运行-停留比率,执行不同的顺序 - FCFS,Last-CFS,SJF等。关于这个问题的一个有趣文章可以在这里找到:https://dl.acm.org/doi/10.1145/322261.322266。

  • 轮转法:这是一个无资源饥饿且抢占式的算法,其中每个任务在相等的部分中获得时间量子。任务以循环顺序执行。每个任务获得一个等于时间量子的CPU时间槽。当它过期时,任务被推到队列的后面。你可能已经推断出来了,队列的长度和量子的值(通常在10到300毫秒之间)非常重要。在现代操作系统调度器中,丰富这种算法以保持公平是一种额外的技术。

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

    $ chrt -m
    SCHED_OTHER   标准的轮转时间共享策略
    SCHED_BATCH   用于“批处理”式进程的执行
    SCHED_IDLE    用于运行非常低优先级的后台作业。
    SCHED_FIFO    先进先出策略
    SCHED_RR      轮转法
    

这种方法是复杂的,值得单独写一本书。

我们在这里关心的是以下内容:

  • 优先级:其值是实际的任务优先级,用于调度。0到99之间的值用于实时进程,而100到139之间的值用于用户进程。
  • Nice:其值在用户空间级别有意义,用于运行时调整进程的优先级。根用户可以将其设置为-20到+19,普通用户可以将其设置为0到+19,其中较高的nice值意味着较低的优先级。默认值是0。

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

$ nice -5 ps

这里,-5意味着5。将其设置为5需要sudo权限:

$ sudo nice -5 ps

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

$ sudo renice -n -10 -p 9610

这将把nice值设置为-10

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

$ sudo chrt --rr 99 ./test

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

调度算法的主题广泛,并不仅关系到操作系统任务的执行,还关系到其他领域,如网络数据管理。我们无法在这里完全涵盖它,但重要的是说明如何最初处理它,并了解您系统的优势。说到这里,让我们继续看看进程管理。

了解更多关于进程创建的信息

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

介绍 fork()

让我们看一个例子;之后我们将讨论它:

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

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

Parent with pid: 92745
Child with pid: 92746

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

您可能会想,由于复制,大的编译时段和栈将导致不必要的内存使用,尤其是当我们不改变它们时。您是对的!幸运的是,我们使用的是虚拟地址空间。这允许操作系统在内存上有额外的管理和抽象。在上一节中,我们讨论了具有相同text段的进程将共享一个副本,因为它是只读的。Linux采用了一种优化,其中dataBSS将通过它们的单个实例共享。如果没有一个进程更新它们,复制将被推迟到第一次写入。无论谁做了这个动作,都会启动副本的创建并使用它。这种技术被称为写时复制。所以,进程创建的唯一代价将是子进程元数据和父进程页表的时间和内存。尽管如此,请确保您的代码不会无休止地fork(),因为这将导致所谓的fork炸弹,导致系统服务拒绝和资源饥饿。下一节将涵盖通过exec在其自己的地址空间中创建子进程。

exec和clone()

exec函数调用实际上不是系统调用,而是一组以execXX(<args>)模式的系统调用。每个都有特定的作用,但最重要的是,它们通过其文件系统路径(称为pathname)创建一个新进程。调用进程的内存段被完全替换并初始化。让我们调用前一节中fork示例的二进制可执行文件,将其命令行参数设置为NULL。这段代码类似于前一个示例,但进行了一些更改:

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

结果如下:

Parent with pid: 12191
Child with pid: 12192

您可能会看到打印输出中缺少了什么。"进程已调用!"信息在哪里?如果出了问题,比如找不到可执行文件,那么我们将观察到"进程创建失败!"。但在这种情况下,我们知道它已经运行了,因为有父进程和子进程的输出。这个答案可以在这段代码示例之前的段落中找到 - 内存段被替换为test_fork的内存段。

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

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

  • e要求函数使用指向系统环境变量的指针数组,这些变量被传递给新创建的进程。
  • l要求将命令行参数存储在一个临时数组中,并将它们传递给函数调用。这只是为了在处理数组大小时提供方便。
  • p要求将路径的环境变量(在Unix中视为PATH)传递给新加载的进程。
  • v在本书前面已经使用过 - 它要求将命令行参数提供给函数调用,但它们以指针数组的形式传递。在我们的示例中,我们将其设置为NULL,以简化操作。

现在让我们看看这是什么样的:

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

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

让我们看一个简单的例子:假设我们有一个通过命令行终端 - shell启动的进程系统命令。它不是在后台运行的 - 从前一章中,我们知道在这种情况下,我们不会在行尾加上&。这可以通过以下图形表示:

Figure 2.5 – Executing commands from the shell

图2.5 - 从shell执行命令

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

终止进程

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

  • void _exit(int status)
  • void exit(int status)

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

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

所以,子进程将按预期进行,因为没有发生特别的事情,但我们使其能够更好地管理其终止策略。输出将与前面的示例相同。我们将在下一节中用一个代码片段进一步丰富这一点。

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

阻塞调用进程

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

  • pid_t wait(int *status);
  • pid_t waitpid(pid_t pid, int *status, int options);
  • int waitid(idtype_t idtype, id_t id, siginfo_t * infop , int options);

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

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

这次执行的结果如下:

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

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

此外,idtypewaitid()系统调用允许我们不仅等待某个特定进程,还可以等待一组进程。其状态参数提供了有关实际状态更新的详细信息。让我们再次修改示例:

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

输出结果如下:

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

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

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

值得注意的是,在POSIX和Linux的线程管理政策中,我们之前讨论过,默认情况下,一个线程会等待同一线程组中其他线程的子线程。也就是说,我们将在下一节中介绍一些线程管理。

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

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

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

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

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

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

加入和分离线程

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

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

加入和分离线程

无论是通过POSIX系统调用还是C++加入线程,这个动作都是必需的,以便通过指定的线程执行例程并等待其终止。需要注意的是,在Linux上,pthread_join()的线程对象必须是可加入的,而C++线程对象默认不是可加入的。分别加入线程是一种好习惯,因为同时加入多个线程可能会导致未定义的行为。它的工作原理与wait()系统调用相同,但它涉及的是线程而非进程。

就像进程可以作为守护进程运行一样,线程也可以通过分离(在POSIX中使用pthread_detach(),在C++中使用thread::detach())变成守护线程。我们将在以下示例中看到这一点,但我们也将分析线程的可加入设置:

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

相应的输出如下:

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

前面的示例相当简单 - 我们创建了两个线程对象:一个将从主线程句柄分离(detached_routine()),而另一个(joined_thread())将在退出后加入主线程。我们在创建它们时和设置它们工作后检查它们的可加入状态。正如预期的那样,线程执行它们的例程后,直到它们终止前都不再是可加入的。

线程终止

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

由于C++是在系统调用接口之上的抽象,它使用线程对象的范围来管理其生命周期,并做得很好。当然,背景中发生的任何事情都是特定于实现的,并且取决于系统和编译器。我们将在本书的后面部分再次回顾这个主题,因此请利用这个机会熟悉这些接口。

总结

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值