Linux 进程

Linux 进程

程序与进程基本概念:

文件是一个静态的概念,存放磁盘中,如果可执行文件没有被运行,那它将不会产生什么作用,当它被运行之后,它将会对系统环境产生一定的影响,所以可执行程序的实例就是可执行文件被运行。

进程是一个动态过程,而非静态文件,它是程序的一次运行过程,当应用程序被加载到内存中运行之后它就称为了一个进程,当程序运行结束后也就意味着进程终止,这就是进程的一个生命周期。

程序如何结束

正常终止包括:

  1. main()函数中通过 return 语句返回来终止进程;
  2. 应用程序中调用 exit()函数终止进程;
  3. 应用程序中调用_exit()或_Exit()终止进程;

以上这些是在前面的课程中给大家介绍的,异常终止包括:

  1. 应用程序中调用 abort()函数终止进程;
  2. 进程接收到一个信号,譬如 SIGKILL 信号。

注册进程终止处理函数 atexit()

atexit()库函数用于注册一个进程在正常终止时要调用的函数.需要说明的是,如果程序当中使用了_exit()或_Exit()终止进程而并非是 exit()函数,那么将不会执行注册的终止处理函数。

进程的环境变量

在我们的应用程序当中也可以获取当前进程的环境变量,事实上,进程的环境变量是从其父进程中继承过来的,譬如在 shell 终端下执行一个应用程序,那么该进程的环境变量就是从其父进程(shell 进程)中继承过来的。新的进程在创建之前,会继承其父进程的环境变量副本。

进程的内存布局

历史沿袭至今,C 语言程序一直都是由以下几部分组成的:

⚫ 正文段。也可称为代码段,这是 CPU 执行的机器语言指令部分,文本段具有只读属性,以防止程序由于意外而修改其指令;正文段是可以共享的,即使在多个进程间也可同时运行同一段程序。

⚫ 初始化数据段。通常将此段称为数据段,包含了显式初始化的全局变量和静态变量,当程序加载到内存中时,从可执行文件中读取这些变量的值。

⚫ 未初始化数据段。包含了未进行显式初始化的全局变量和静态变量,通常将此段称为 bss 段,这一名词来源于早期汇编程序中的一个操作符,意思是"由符号开始的块"(block started by symbol),在程序开始执行之前,系统会将本段内所有内存初始化为 0,可执行文件并没有为 bss 段变量分配存储空间,在可执行文件中只需记录 bss 段的位置及其所需大小,直到程序运行时,由加载器来分配这一段内存空间。

⚫ 栈。函数内的局部变量以及每次函数调用时所需保存的信息都放在此段中,每次调用函数时,函数传递的实参以及函数返回值等也都存放在栈中。栈是一个动态增长和收缩的段,由栈帧组成,系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值。

⚫ 堆。可在运行时动态进行内存分配的一块区域,譬如使用 malloc()分配的内存空间,就是从系统堆内存中申请分配的。

fork() 创建子进程:

fork()系统调用的关键在于,完成对其调用后将存在两个进程,一个是原进程(父进程)、另一个则是创建出来的子进程,并且每个进程都会从 fork()函数的返回处继续执行,会导致调用 fork()返回两次值,子进程返回一个值、父进程返回一个值。在程序代码中,可通过返回值来区分是子进程还是父进程。

fork()调用成功后,将会在父进程中返回子进程的 PID,而在子进程中返回值是 0;如果调用失败,父进程返回值-1,不创建子进程,并设置 errno。

fork()函数调用完成之后,父进程、子进程会各自继续执行 fork()之后的指令,它们共享代码段,但并不共享数据段、堆、栈等,而是子进程拥有父进程数据段、堆、栈等副本,所以对于同一个局部变量,它们打印出来的值是不相同的,因为 fork()调用返回值不同,在父、子进程中赋予了 pid 不同的值。

父、子进程间的文件共享

调用 fork()函数之后,子进程会获得父进程所有文件描述符的副本,这些副本的创建方式类似于 dup(),这也意味着父、子进程对应的文件描述符均指向相同的文件表。

c 复制代码
int main(void)
{
 pid_t pid;
 int fd;
 int i;
 fd = open("./test.txt", O_RDWR | O_TRUNC);
 if (0 > fd) {
 perror("open error");
 exit(-1);
 }
 pid = fork();
 switch (pid) {
 case -1:
 perror("fork error");
 close(fd);
 exit(-1);
 case 0:
 /* 子进程 */
 for (i = 0; i < 4; i++) //循环写入 4 次
 write(fd, "1122", 4);
 close(fd);
 _exit(0);
 default:
 /* 父进程 */
 for (i = 0; i < 4; i++) //循环写入 4 次
 write(fd, "AABB", 4);
 close(fd);
 exit(0);
 }
}
 

上述代码中,父进程 open 打开文件之后,才调用 fork()创建了子进程,所以子进程了继承了父进程打开的文件描述符 fd,我们需要验证的便是两个进程对文件的写入操作是分别各自写入、还是每次都在文件末尾接续写入。

有上述测试结果可知,此种情况下,父、子进程分别对同一个文件进行写入操作,结果是接续写,不管是父进程,还是子进程,在每次写入时都是从文件的末尾写入.子进程继承了父进程的文件描述符,两个文件描述符都指向了一个相同的文件表,意味着它们的文件偏移量是同一个、绑定在了一起,相互影响,子进程改变了文件的位置偏移量就会作用到父进程,同理,父进程改变了文件的位置偏移量就会作用到子进程。

fork()之后的竞争条件

调用 fork()之后,子进程成为了一个独立的进程,可被系统调度运行,而父进程也继续被系统调度运行,这里出现了一个问题,调用 fork 之后,无法确定父、子两个进程谁将率先访问 CPU,也就是说无法确认谁先被系统调用运行(在多核处理器中,它们可能会同时各自访问一个 CPU),这将导致谁先运行、谁后运行这个顺序是不确定.

如何明确保证某一特性执行顺序呢?这个时候可以通过采用采用某种同步技术来实现,譬如前面给大家介绍的信号,如果要让子进程先运行,则可使父进程被阻塞,等到子进程来唤醒它。

执行新程序

在前面已经大家提到了 exec 函数,当子进程的工作不再是运行父进程的代码段,而是运行另一个新程序的代码,那么这个时候子进程可以通过 exec 函数来实现运行另一个新的程序。

execve()函数

系统调用 execve()可以将新程序加载到某一进程的内存空间,通过调用 execve()函数将一个外部的可执行文件加载到进程的内存空间运行,使用新的程序替换旧的程序,而进程的栈、数据、以及堆数据会被新程序的相应部件所替换,然后从新程序的 main()函数开始执行

基于系统调用 execve(),还提供了一系列以 exec 为前缀命名的库函数,虽然函数参数各异,当其功能相同,通常将这些函数(包括系统调用 execve())称为 exec 族函数,所以 exec 函数并不是指某一个函数、而是 exec 族函数。

execl()和 execv()

execlp()和 execvp()在 execl()和 execv()基础上加了一个 p

execle()和 execvpe()这两个函数在命名上加了一个 e

system()函数

使用 system()函数可以很方便地在我们的程序当中执行任意 shell 命令。

system()函数其内部的是通过调用 fork()、execl()以及 waitpid()这三个函数来实现它的功能,首先 system()会调用 fork()创建一个子进程来运行 shell(可以把这个子进程成为 shell 进程),并通过 shell 执行参数command 所指定的命令。

进程的消亡与诞生

_exit()函数和 exit()函数的 status 参数定义了进程的终止状态(termination status),父进程可以调用 wait()函数以获取该状态。虽然参数 status 定义为 int 类型,但仅有低 8 位表示它的终止状态,一般来说,终止状态为 0 表示进程成功终止,而非 0 值则表示进程在执行过程中出现了一些错误而终止,譬如文件打开失败、读写失败等等,对非 0 返回值的解析并无定例。

在我们的程序当中,一般使用 exit()库函数而非_exit()系统调用,原因在于 exit()最终也会通过_exit()终止进程,但在此之前,它将会完成一些其它的工作,exit()函数会执行的动作如下:

  1. 如果程序中注册了进程终止处理函数,那么会调用终止处理函数。在 9.1.2 小节给大家介绍如何注册进程的终止处理函数;
  2. 刷新 stdio 流缓冲区。关于 stdio 流缓冲区的问题,稍后编写一个简单地测试程序进行说明;
  3. 执行_exit()系统调用。
    exit()函数会比_exit()会多做一些事情,包括执行终止处理函数、刷新 stdio 流缓冲以及调用_exit(),在前面曾提到过,在我们的程序当中,父、子进程不应都使用 exit()终止,只能有一个进程使用 exit()、而另一个则使用_exit()退出,当然一般推荐的是子进程使用_exit()退出、而父进程则使用 exit()退出。其原因就在于调用 exit()函数终止进程时会刷新进程的 stdio 缓冲区。接下来我们便通过一个示例代码进行说明:
C 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
 printf("Hello World!");
 switch (fork()) {
 case -1:
 perror("fork error");
 exit(-1);
 case 0:
 /* 子进程 */
 exit(0);
 default:
 /* 父进程 */
 exit(0);
 }
}

printf 中将字符串后面的\n 换行符给去掉了,接下再进行测试,结果如下:

从打印结果可知,"Hello World!"被打印了两次,这是怎么回事呢?在程序当中明明只使用了 printf 打印了一次字符串。要解释这个问题,首先要知道,进程的用户空间内存中维护了 stdio 缓冲区,0 小节给大家介绍过,因此通过 fork()创建子进程时会复制这些缓冲区。标准输出设备默认使用的是行缓冲,当检测到换行符\n 时会立即显示函数 printf()输出的字符串,在示例代码 9.9.1 中 printf 输出的字符串中包含了换行符,所以会立即读走缓冲区中的数据并显示,读走之后此时缓冲区就空了,子进程虽然拷贝了父进程的缓冲区但是空的,虽然父、子进程使用 exit()退出时会刷新各自的缓冲区,但对于空缓冲区自然无数据可读。而对于示例代码 9.9.2 来说,printf()并没有添加换行符\n,当调用 printf()时并不会立即读取缓冲区中的数据进行显示,由此 fork()之后创建的子进程也自然拷贝了缓冲区的数据,当它们调用 exit()函数时,都会刷新各自的缓冲区、显示字符串,所以就会看到打印出了两次相同的字符串。

父进程监视子进程:

  1. wait()函数

系统调用 wait()可以等待进程的任一子进程终止,同时获取子进程的终止状态信息

  1. waitpid()函数
    wait() 系统调用用于使父进程等待其子进程的终止,但它确实存在一些限制。
  • 按顺序等待子进程。
  • 阻塞行为。wait()是一个阻塞调用,这意味着如果没有子进程终止,父进程将一直等待下去。如果我们希望在不阻塞的情况下检查子进程的状态,可以使用 waitpid(),其中可以指定选项参数,如 WNOHANG,从而实现非阻塞等待。这样,父进程可以在没有子进程终止时继续执行其他任务。
  • 处理停止的子进程。wait() 仅能处理那些已经终止的子进程。如果子进程由于接收到 SIGSTOP 信号而被暂停,或者一个已经停止的子进程接收到 SIGCONT 信号恢复执行,wait() 无法提供这些信息。

僵尸进程与孤儿进程

当一个进程创建子进程之后,它们俩就成为父子进程关系,父进程与子进程的生命周期往往是不相同

的,这里就会出现两个问题:

⚫ 父进程先于子进程结束。 孤儿进程

父进程先于子进程结束,也就是意味着,此时子进程变成了一个"孤儿",我们把这种进程就称为孤儿进程。

⚫ 子进程先于父进程结束。 僵尸进程

如果子进程先于父进程结束,此时父进程还未来得及给子进程"收尸",那么此时子进程就变成了一个僵尸进程。子进程结束后其父进程并没有来得及立马给它"收尸",子进程处于"曝尸荒野"的状态,在这么一个状态下,我们就将子进程成为僵尸进程

如果父进程创建了某一子进程,子进程已经结束,而父进程还在正常运行,但父进程并未调用 wait()回收子进程,此时子进程变成一个僵尸进程。首先来说,这样的程序设计是有问题的,如果系统中存在大量的僵尸进程,它们势必会填满内核进程表,从而阻碍新进程的创建。需要注意的是,僵尸进程是无法通过信号将其杀死的,即使是"一击必杀"信号 SIGKILL 也无法将其杀,那么这种情况下,只能杀死僵尸进程的

父进程(或等待其父进程终止),这样 init 进程将会接管这些僵尸进程,从而将它们从系统中清理掉!所以,在我们的一个程序设计中,一定要监视子进程的状态变化,如果子进程终止了,要调用 wait()将其回收,避免僵尸进程。

子进程的终止属于异步事件,父进程事先是无法预知的,如果父进程有自己需要做的事情,它不能一直wait()阻塞等待子进程终止(或轮训),这样父进程将啥事也做不了,那么有什么办法来解决这样的尴尬情况,当然有办法,那就是通过 SIGCHLD 信号。

那既然子进程状态改变时(终止、暂停或恢复),父进程会收到 SIGCHLD 信号,SIGCHLD 信号的系统默认处理方式是将其忽略,所以我们要捕获它、绑定信号处理函数,在信号处理函数中调用 wait()收回子进程,回收完毕之后再回到父进程自己的工作流程中。

当调用信号处理函数时,会暂时将引发调用的信号添加到进程的信号掩码中(除非 sigaction()指定了 SA_NODEFER 标志),这样一来,当 SIGCHLD 信号处理函数正在为一个终止的子进程"收尸"时,如果相继有两个子进程终止,即使产生了两次 SIGCHLD 信号,父进程也只能捕获到一次 SIGCHLD 信号,结果是,父进程的 SIGCHLD 信号处理函数每次只调用一次 wait(),那么就会导致有些僵尸进程成为"漏网之鱼"。

解决方案就是:在 SIGCHLD 信号处理函数中循环以非阻塞方式来调用 waitpid(),直至再无其它终止的子进程需要处理为止,所以,通常 SIGCHLD 信号处理函数内部代码如下所示:

bash 复制代码
while (waitpid(-1, NULL, WNOHANG) > 0)
   continue;

上述代码一直循环下去,直至 waitpid()返回 0,表明再无僵尸进程存在;或者返回-1,表明有错误发生。

应在创建任何子进程之前,为 SIGCHLD 信号绑定处理函数。

进程的六种状态

Linux 系统下进程通常存在 6 种不同的状态,分为:就绪态、运行态、僵尸态、可中断睡眠状态(浅度睡眠)、不可中断睡眠状态(深度睡眠)以及暂停态。

进程关系

在 Linux 系统下,每个进程都有自己唯一的标识:进程号(进程 ID、PID),也有自己的生命周期,进程都有自己的父进程、而父进程也有父进程,这就形成了一个以 init 进程为根的进程家族树;当子进程终止时,父进程会得到通知并能取得子进程的退出状态。除此之外,进程间还存在着其它一些层次关系,譬如进程组和会话。

由此可知,进程间存在着多种不同的关系,主要包括:无关系(相互独立)、父子进程关系、进程组以及会话。

进程组

每个进程除了有一个进程 ID、父进程 ID 之外,还有一个进程组 ID,用于标识该进程属于哪一个进程组,进程组是一个或多个进程的集合,这些进程并不是孤立的,它们彼此之间或者存在父子、兄弟关系,或者在功能上有联系。Linux 系统设计进程组实质上是为了方便对进程进行管理。假设为了完成一个任务,需要并发运行 100个进程,但当处于某种场景时需要终止这 100 个进程,若没有进程组就需要一个一个去终止,这样非常麻烦且容易出现一些问题;有了进程组的概念之后,就可以将这 100 个进程设置为一个进程组,这些进程共享一个进程组 ID,这样一来,终止这 100 个进程只需要终止该进程组即可。

关于进程组需要注意以下以下内容:

  1. 每个进程必定属于某一个进程组、且只能属于一个进程组;
  2. 每一个进程组有一个组长进程,组长进程的 ID 就等于进程组 ID;
  3. 在组长进程的 ID 前面加上一个负号即是操作进程组;
  4. 组长进程不能再创建新的进程组;
  5. 只要进程组中还存在一个进程,则该进程组就存在,这与其组长进程是否终止无关;
  6. 一个进程组可以包含一个或多个进程,进程组的生命周期从被创建开始,到其内所有进程终止或离开该进程组;
  7. 默认情况下,新创建的进程会继承父进程的进程组 ID。

系统调用 getpgrp()或 getpgid()可以获取进程对应的进程组 ID

系统调用 setpgid()或 setpgrp()可以加入一个现有的进程组或创建一个新的进程组

会话

一个会话可包含一个或多个进程组,但只能有一个前台进程组,其它的是后台进程组;每个会话都有一个会话首领(leader),即创建会话的进程。一个会话可以有控制终端、也可没有控制终端,在有控制终端的情况下也只能连接一个控制终端,这通常是登录到其上的终端设备(在终端登录情况下)或伪终端设备(譬如通过 SSH 协议网络登录),一个会话中的进程组可被分为一个前台进程组以及一个或多个后台进程组。

会话的首领进程连接一个终端之后,该终端就成为会话的控制终端,与控制终端建立连接的会话首领进程被称为控制进程;产生在终端上的输入和信号将发送给会话的前台进程组中的所有进程,譬如 Ctrl + C(产生 SIGINT 信号)、Ctrl + Z(产生 SIGTSTP 信号)、Ctrl + \(产生 SIGQUIT 信号)等等这些由控制终端产生的信号。当用户在某个终端登录时,一个新的会话就开始了;当我们在 Linux 系统下打开了多个终端窗口时,实际上就是创建了多个终端会话。

一个进程组由组长进程的 ID 标识,而对于会话来说,会话的首领进程的进程组 ID 将作为该会话的标识,也就是会话 ID(sid),在默认情况下,新创建的进程会继承父进程的会话 ID。

通过系统调用 getsid()可以获取进程的会话 ID

系统调用 setsid()可以创建一个会话

守护进程:

守护进程(Daemon)也称为精灵进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生。守护进程与终端无任何关联,用户的登录与注销与守护进程无关、不受其影响,守护进程自成进程组、自成会话,即pid=gid=sid。
编写守护进程程序

  1. 创建子进程、终止父进程
  2. 子进程调用 setsid 创建会话
  3. 将工作目录更改为根目录
  4. 重设文件权限掩码 umask
  5. 关闭不再需要的文件描述符
  6. 将文件描述符号为 0、1、2 定位到/dev/null
  7. 其它:忽略 SIGCHLD 信号

进程间通信概述:概述进程间通信的机制和方法,说明进程如何互相传递信息。

所谓进程间通信指的是系统中两个进程之间的通信,不同的进程都在各自的地址空间中、相互独立、隔离,所以它们是处在于不同的地址空间中,因此相互通信比较难,Linux 内核提供了多种进程间通信的机制。内容以了解为主、了解进程间通信以及内核提供的进程间通信机制,并不详解介绍进程间通信,如果大家在今后的工作当中参与开发的应用程序是一个多进程程序、需要考虑进程间通信的问题,此时再去深入学习这方面的知识。

对 UNIX 发展做出重大贡献的两大主力 AT&T 的贝尔实验室及 BSD (加州大学伯克利分校的伯克利软件发布中心)在进程间通信方面的侧重点有所不同。前者对 UNIX 早期的进程间通信手段进行了系统的改进和扩充,形成了"System V IPC",通信进程局限在单个计算机内;后者则跳过了该限制,形成了基于套接字(Socket,也就是网络)的进程间通信机制。

⚫ UNIX IPC:管道、FIFO、信号;

⚫ System V IPC:信号量、消息队列、共享内存;

⚫ POSIX IPC:信号量、消息队列、共享内存;

⚫ Socket IPC:基于 Socket 进程间通信。

管道和 FIFO

把一个进程连接到另一个进程的数据流称为管道,管道被抽象成一个文件,以前曾提及过管道文件(pipe)这样一种文件类型。

管道包括三种:

  • 普通管道 pipe:通常有两种限制,一是单工,数据只能单向传输;二是只能在父子或者兄弟进程间使用;
  • 流管道 s_pipe:去除了普通管道的第一种限制,为半双工,可以双向传输;只能在父子或兄弟进程间使用;
  • 有名管道 name_pipe(FIFO):去除了普通管道的第二种限制,并且允许在不相关(不是父子或兄弟关系)的进程间进行通讯。

消息队列

消息队列是消息的链表,存放在内核中并由消息队列标识符标识,消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺陷。消息队列包括 POSIX 消息队列和 System V 消息队列。

消息队列是 UNIX 下不同进程之间实现共享资源的一种机制,UNIX 允许不同进程将格式化的数据流以消息队列形式发送给任意进程,有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。

信号量

信号量是一个计数器,与其它进程间通信方式不大相同,它主要用于控制多个进程间或一个进程内的多个线程间对共享资源的访问,相当于内存中的标志,进程可以根据它判定是否能够访问某些共享资源,同时,进程也可以修改该标志,除了用于共享资源的访问控制外,还可用于进程同步。它常作为一种锁机制,防止某进程在访问资源时其它进程也访问该资源,因此,主要作为进程间以及同一个进程内不同线程之间的同步手段。

套接字(Socket)

Socket 是一种 IPC 方法,是基于网络的 IPC 方法,允许位于同一主机(计算机)或使用网络连接起来的不同主机上的应用程序之间交换数据,说白了就是网络通信。

相关推荐
致奋斗的我们2 分钟前
RHCE的学习(7)
linux·服务器·网络·学习·redhat·rhce·rhcsa
昨天今天明天好多天40 分钟前
【Linux】ClickHouse 部署
linux·服务器·clickhouse
taolichao3040 分钟前
架设一台NFS服务器,按照要求配置
linux·运维·服务器
程序员yt41 分钟前
2025秋招八股文--服务器篇
linux·运维·服务器·c++·后端·面试
2301_8107301041 分钟前
RHCSA基础命令整理1
linux·运维·服务器
Chris-zz1 小时前
Linux:磁盘深潜:探索文件系统、连接之道与库的奥秘
linux·网络·c++·1024程序员节
TeYiToKu1 小时前
笔记整理—linux驱动开发部分(1)驱动梗概
linux·c语言·arm开发·驱动开发·嵌入式硬件
lishing61 小时前
Linux驱动开发(2):第一个内核模块
linux·运维·驱动开发
从后端到QT1 小时前
ubuntu编译ffmpeg
linux·运维·ubuntu
Teamol20202 小时前
求助帖:ubuntu22.10 auto install user-data配置了为何还需要选择语言键盘(如何全自动)
linux·ubuntu·1024程序员节