参考引用
1. 进程与程序
1.1 main() 函数由谁调用?
- C 语言程序总是从 main 函数开始执行
int main(void) int main(int argc, char *argv[]) // 如果需要向应用程序传参,则选择该种写法
- 操作系统下的应用程序在运行 main() 函数之前需要先执行一段引导代码,最终由这段引导代码去调用应用程序中的 main() 函数,在编译链接时,由链接器将引导代码链接到应用程序中,一起构成最终的可执行文件
- 程序运行需要通过操作系统的加载器来实现,加载器是操作系统中的程序,当执行程序时,加载器负责将此应用程序加载到内存中去执行
- 当在终端执行程序时,命令行参数(arg1, arg2…)由 shell 进程逐一解析,shell 进程会将这些参数传递给加载器,加载器加载应用程序时会将其传递给应用程序引导代码,当引导程序调用 main() 函数时,再由它最终传递给 main() 函数
1.2 程序如何结束?
- 程序结束其实就是进程终止,进程终止的方式通常有多种,分为正常终止和异常终止
- 正常终止
- main() 函数中通过 return 语句返回来终止进程
- 应用程序中调用 exit() 函数终止进程
- 应用程序中调用 _exit() 或 _Exit() 终止进程
- 异常终止
- 应用程序中调用 abort() 函数终止进程;
- 进程接收到一个信号,如 SIGKILL 信号
- 正常终止
注册进程终止处理函数 atexit()
- atexit() 库函数用于注册一个进程在正常终止时要调用的函数
#include <stdlib.h> // function:函数指针,指向注册的函数,此函数无需传入参数、无返回值 // 返回值:成功返回 0;失败返回非 0 int atexit(void (*function)(void));
- 示例
#include <stdio.h> #include <stdlib.h> static void bye(void) { puts("Goodbye!"); } int main(int argc, char *argv[]) { if (atexit(bye)) { fprintf(stderr, "cannot set exit function\n"); exit(-1); } // 如果程序当中使用了 _exit() 或 _Exit() 终止进程而并非是 exit() 函数,那么将不会执行注册的终止处理函数 exit(0); }
$ gcc test.c -o test $ ./test Goodbye!
1.3 何为进程?
- 进程就是一个可执行程序的实例(可执行文件被运行),可执行程序就是一个可执行文件,文件是一个静态的概念,存放在磁盘中,如果可执行文件没有被运行,那它将不会产生什么作用,当它被运行之后,它将会对系统环境产生一定的影响
- 进程是一个动态过程,而非静态文件,它是程序的一次运行过程
- 当应用程序被加载到内存中运行之后它就称为了一个进程
- 当程序运行结束后也就意味着进程终止,这就是进程的一个生命周期
1.4 进程号
- Linux 系统下的每一个进程都有一个进程号(process ID,PID),进程号是一个正数,用于唯一标识系统中的某一个进程,使用 ps -aux 命令可以查看系统中进程相关信息
- 在某些系统调用中,进程号可以作为传入参数,有时也可作为返回值,如:系统调用 kill() 允许调用者向某一个进程(通过进程号进行标识)发送一个信号
1.4.1 getpid()
- 通过系统调用 getpid() 来获取本进程的进程号
#include <sys/types.h> #include <unistd.h> pid_t getpid(void);
// 使用示例 pid_t pid = getpid(); printf("本进程的 PID 为: %d\n", pid);
1.4.2 getppid()
- 使用 getppid()系统调用获取父进程的进程号
#include <sys/types.h> #include <unistd.h> pid_t getppid(void);
// 使用示例 // 获取本进程 pid pid_t pid = getpid(); printf("本进程的 PID 为: %d\n", pid); // 获取父进程 pid pid = getppid(); printf("父进程的 PID 为: %d\n", pid);
2. 进程的环境变量
- 每一个进程都有一组与其相关的环境变量,这些环境变量以字符串形式存储在一个字符串数组列表中,把这个数组称为环境列表。其中每个字符串都是以 “名称=值(name=value)” 形式定义
- 使用 export 命令添加一个新的环境变量
$ export LINUX_APP=123456 # 添加 LINUX_APP 环境变量
- 使用 export -n 命令删除一个环境变量
$ export -n LINUX_APP # 删除 LINUX_APP 环境变量
2.1 应用程序中获取环境变量
- 进程的环境变量是从其父进程中继承过来的
- 如:在 shell 终端下执行一个应用程序,那么该进程的环境变量就是从其父进程(shell 进程)中继承过来的
- 新的进程在创建之前,会继承其父进程的环境变量副本
2.1.1 environ 获取所有环境变量
- 环境变量存放在一个字符串数组中,在应用程序中,通过 environ 变量指向它,environ 是一个全局变量,在应用程序中只需申明它即可使用
extern char **environ; // 申明外部全局变量 environ
- 示例
#include <stdio.h> #include <stdlib.h> extern char **environ; int main(int argc, char *argv[]) { int i; /* 打印进程的环境变量 */ // 通过字符串数组元素是否等于 NULL 来判断是否已经到了数组的末尾 for (i = 0; environ[i] != NULL; i++) puts(environ[i]); exit(0); }
2.1.2 getenv() 获取指定环境变量
-
如果只想要获取某个指定的环境变量,可以使用库函数 getenv()
- 注意:不应该去修改其返回的字符串,修改该字符串意味着修改了环境变量对应的值
#include <stdlib.h> // name :指定获取的环境变量名称 // 返回值:如果存放该环境变量,则返回该环境变量的值对应字符串的指针;如果不存在该环境变量,则返回 NULL char *getenv(const char *name);
-
示例
#include <stdio.h> #include <stdlib.h> int main(int argc, char *argv[]) { const char *str_val = NULL; if (argc < 2) { fprintf(stderr, "Error: 请传入环境变量名称\n"); exit(-1); } /* 获取环境变量 */ str_val = getenv(argv[1]); if (str_val == NULL) { fprintf(stderr, "Error: 不存在[%s]环境变量\n", argv[1]); exit(-1); } /* 打印环境变量的值 */ printf("环境变量的值: %s\n", str_val); exit(0); }
$ gcc getenv.c -o getenv $ ./getenv PATH 环境变量的值: /opt/ros/melodic/bin:/opt/gcc-arm-none-eabi-9-2020-q2-update/bin:/home/yue/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
2.2 添加/删除/修改环境变量
2.2.1 putenv() 函数
- putenv() 函数可向进程的环境变量数组中添加一个新的环境变量,或修改一个已存在的环境变量对应的值
- putenv() 函数将设定 environ 变量(字符串数组)中的某个元素(字符串指针)指向该 string 字符串,而不是指向它的复制副本
#include <stdlib.h> // string:一个字符串指针,指向 name = value 形式的字符串 // 返回值:成功返回 0;失败将返回非 0 值,并设置 errno int putenv(char *string);
2.2.2 setenv() 函数
-
setenv() 函数可替代 putenv() 函数,推荐使用 setenv() 函数
- setenv() 函数为形如 name=value 的字符串分配一块内存缓冲区,并将参数 name 和参数 value 所指向的字符串复制到此缓冲区中,以此来创建一个新的环境变量
- setenv() 与 putenv() 函数有两个区别
- putenv() 函数并不会为 name=value 字符串分配内存
- setenv() 可通过参数 overwrite 控制是否需要修改现有变量的值而仅以添加变量为目的,putenv() 则不能
#include <stdlib.h> // name:需要添加或修改的环境变量名称 // value:环境变量的值 // overwrite:若 name 标识的环境变量已存在且 overwrite 为 0,setenv() 函数将不改变现有环境变量的值 // 若 name 标识的环境变量已存在且 overwrite 为非 0,则覆盖,不存在则表示添加新的环境变量 // 返回值:成功返回 0;失败将返回-1,并设置 errno int setenv(const char *name, const char *value, int overwrite);
-
示例
#include <stdio.h> #include <stdlib.h> int main(int argc, char *argv[]) { if (argc < 3) { fprintf(stderr, "Error: 传入 name value\n"); exit(-1); } /* 添加环境变量 */ // 返回值:成功返回 0;失败将返回-1 if (setenv(argv[1], argv[2], 0)) { perror("setenv error"); exit(-1); } exit(0); }
2.2.3 unsetenv() 函数
- unsetenv() 函数可以从环境变量表中移除参数 name 标识的环境变量
#include <stdlib.h> int unsetenv(const char *name);
2.3 清空环境变量
- 可以通过将全局变量 environ 赋值为 NULL 来清空所有变量
environ = NULL;
- 也可通过 clearenv() 函数来操作
- clearenv() 函数内部的做法其实就是将 environ 赋值为 NULL
- 在某些情况下,使用 setenv() 函数和 clearenv() 函数可能会导致程序内存泄漏,setenv() 函数会为环境变量分配一块内存缓冲区,随之成为进程的一部分;而调用 clearenv() 函数时并没有释放该缓冲区,反复调用这两个函数的程序会不断产生内存泄漏
#include <stdlib.h> int clearenv(void);
3. 进程的内存布局
-
C 语言程序由以下几部分组成
- 正文段
- 也称代码段,这是 CPU 执行的机器语言指令部分,文本段具有只读属性,以防程序由于意外而修改其指令
- 正文段是可以共享的,即使在多个进程间也可同时运行同一段程序
- 初始化数据段
- 通常将此段称为数据段,包含了显式初始化的全局变量和静态变量,当程序加载到内存中时,从可执行文件中读取这些变量的值
- 未初始化数据段
- 包含了未进行显式初始化的全局变量和静态变量,通常将此段称为 bss(block started by symbol,由符号开始的块)段,在程序开始执行之前,系统会将本段内所有内存初始化为 0,可执行文件并没有为 bss 段变量分配存储空间,在可执行文件中只需记录 bss 段的位置及其所需大小,直到程序运行时,由加载器来分配这一段内存空间
- 栈
- 函数内的局部变量以及每次函数调用时所需保存的信息都放在此段中,每次调用函数时,函数传递的实参以及函数返回值等也都存放在栈中
- 栈是一个动态增长和收缩的段,由栈帧组成,系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值
- 堆
- 可在运行时动态进行内存分配的一块区域,如:使用 malloc() 分配的内存空间,就是从系统堆内存中申请分配的
- 正文段
-
Linux/x86-32 体系中进程内存布局
4. 进程的虚拟地址空间
-
Linux 系统中采用了虚拟内存管理技术,应用程序运行在一个虚拟地址空间中
-
Linux 系统中每一个进程都在自己独立的地址空间中运行,在 32 位系统中,每个进程的逻辑地址空间均为 4GB:其中用户进程享有 3G 的空间,而内核独自享有剩下的 1G 空间
-
虚拟地址会通过硬件 MMU(内存管理单元)映射到实际的物理地址空间中,建立虚拟地址到物理地址的映射关系后,对虚拟地址的读写操作实际上就是对物理地址的读写操作,MMU 会将虚拟地址 “翻译” 为对应的物理地址
4.1 为什么需要引入虚拟地址?
-
计算机物理内存的大小是固定的,如果操作系统没有虚拟地址机制,所有应用程序访问的内存地址就是实际的物理地址,要将所有应用程序加载到内存中,但是实际的物理内存只有 4G,所以就会出现一些问题
- 当多个程序运行时,必须保证这些程序使用的内存总量要小于计算机实际的物理内存大小
- 内存使用效率低
- 内存空间不足时,就需要将其它程序暂时拷贝到硬盘中,然后将新的程序装入内存
- 然而由于大量的数据装入装出,内存的使用效率就会非常低
- 进程地址空间不隔离
- 由于程序是直接访问物理内存的,每一个进程都可以修改其它进程的内存数据,甚至修改内核地址空间中的数据,所以有些恶意程序可以随意修改别的进程,就会造成一些破坏,系统不安全、不稳定
- 无法确定程序的链接地址
- 程序运行时,链接地址和运行地址必须一致,否则程序无法运行
- 因为程序代码加载到内存的地址是由系统随机分配的,是无法预知的,所以程序的运行地址在编译程序时是无法确认的
-
针对上述问题引入了虚拟地址机制。程序访问存储器所使用的逻辑地址就是虚拟地址,通过逻辑地址映射到真正的物理内存上,所有应用程序运行在自己的虚拟地址空间中,使得进程的虚拟地址空间和物理地址空间隔离开来,这样做带来了很多的优点
- 进程与进程、进程与内核相互隔离
- 一个进程不能读取或修改另一个进程或内核的内存数据,这是因为每一个进程的虚拟地址空间映射到了不同的物理地址空间,提高了系统的安全性与稳定性
- 在某些应用场合下,两个或者更多进程能够共享内存
- 因为每个进程都有自己的映射表,可以让不同进程的虚拟地址空间映射到相同的物理地址空间中
- 通常,共享内存可用于实现进程间通信
- 便于实现内存保护机制
- 多个进程共享内存时,允许每个进程对内存采取不同的保护措施:一个进程可能以只读方式访问内存,而另一进程则能够以可读可写的方式访问
- 编译应用程序时,无需关心链接地址
- 进程与进程、进程与内核相互隔离
5. fork() 创建子进程
-
一个现有的进程可以调用系统调用 fork() 函数创建一个新的进程
- 调用 fork() 函数的进程称为父进程
- 由 fork() 函数创建出来的进程被称为子进程(child process)
#include <unistd.h> pid_t fork(void);
-
创建子进程的作用
- 创建多个进程是任务分解时行之有效的方法
- 提高系统的并发性(即同时能够处理更多的任务或请求,多个进程在宏观上实现同时运行)
-
如何区分父、子进程?
fork() 成功调用后将存在两个进程,一个是原进程(父进程),另一个则是创建出来的子进程,并且每个进程都会从 fork() 函数的返回处继续执行,会导致调用 fork() 返回两次值,子进程返回一个值,父进程返回一个值
- fork() 调用成功后,将会在父进程中返回子进程的 PID,而在子进程中返回值是 0
- 如果调用失败,父进程返回值 -1,不创建子进程,并设置 errno
-
fork() 调用成功后,子进程和父进程会继续执行 fork() 调用之后的指令
- 子进程、父进程各自在自己的进程空间中运行,事实上,子进程是父进程的一个副本
- 如:子进程拷贝了父进程的数据段、堆、栈以及继承了父进程打开的文件描述符,父进程与子进程并不共享这些存储空间,这是子进程对父进程相应部分存储空间的完全复制
- 执行 fork() 之后,每个进程均可修改各自的栈数据以及堆段中的变量,而并不影响另一个进程
- 但是两个进程执行相同的代码段,因为代码段是只读的,也就是说父、子进程共享代码段,在内存中只存在一份代码段数据
-
示例 1:使用 fork() 创建子进程
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { pid_t pid; pid = fork(); switch (pid) { case -1: perror("fork error"); exit(-1); case 0: printf("这是子进程打印信息<pid: %d, 父进程 pid: %d>\n", getpid(), getppid()); // 调用 fork() 后,父、子进程中一般只有一个通过 exit() 退出进程,而另一个则应使用 _exit() 退出 _exit(0); // 子进程使用 _exit() 退出 default: printf("这是父进程打印信息<pid: %d, 子进程 pid: %d>\n", getpid(), pid); exit(0); // 父进程使用 exit() 退出 } }
$ gcc fork.c -o fork $ ./fork 这是父进程打印信息<pid: 3369, 子进程 pid: 3370> 这是子进程打印信息<pid: 3370, 父进程 pid: 3369>
-
示例 2
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { pid_t pid; pid = fork(); switch (pid) { case -1: perror("fork error"); exit(-1); case 0: printf("这是子进程打印信息\n"); printf("%d\n", pid); _exit(0); default: printf("这是父进程打印信息\n"); printf("%d\n", pid); exit(0); } }
$ gcc fork2.c -o fork2 $ ./fork2 # 对同一个局部变量,父、子进程打印出来的值不同,因为 fork() 调用返回值不同,在父、子进程中赋予了 pid 不同的值 这是父进程打印信息 3391 这是子进程打印信息 0
-
子进程被创建出来之后,便是一个独立的进程,拥有自己独立的进程空间、系统内唯一的进程号并拥有自己独立的 PCB(进程控制块),子进程会被内核同等调度执行,参与到系统的进程调度中
系统调度
- Linux 是一个多任务、多进程、多线程的操作系统,系统启动后会运行成百上千个不同的进程,对于单核 CPU 计算机来说,在某一个时间它只能运行某一个进程的代码指令,那其它进程怎么办呢(多核处理器也是如此,同一时间每个核它只能运行某一个进程的代码)?这里就出现了调度的问题,系统是这样做的:每一个进程(或线程)执行一段固定的时间,时间到了之后切换执行下一个进程或线程,依次轮流执行,这就称为调度
- 系统调度的基本单元是线程
6. 父、子进程间的文件共享
-
调用 fork() 函数之后,子进程会获得父进程所有文件描述符的副本,这些副本的创建方式类似于 dup(),这也意味着父、子进程对应的文件描述符均指向相同的文件表,如果子进程更新了文件偏移量,那么这个改变也会影响到父进程中相应文件描述符的位置偏移量
-
示例 1:子进程继承父进程文件描述符实现文件共享
#include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int main(void) { pid_t pid; int fd; int i; fd = open("./test.txt", O_RDWR | O_TRUNC); if (fd < 0) { perror("open error"); exit(-1); } pid = fork(); switch (pid) { case -1: perror("fork error"); close(fd); exit(-1); case 0: /* 子进程 循环写入 4 次 */ for (i = 0; i < 4; i++) { write(fd, "1122", 4); } close(fd); _exit(0); default: /* 父进程 循环写入 4 次 */ for (i = 0; i < 4; i++) { write(fd, "AABB", 4); } close(fd); exit(0); } }
$ gcc fork3.c -o fork3 $ ./fork3 $ cat test.txt # 父、子进程分别对同一个文件进行写入操作,结果是接续写,父、子进程在每次写入时都是从文件的末尾写入 # 因为子进程继承了父进程的文件描述符,两个文件描述符都指向了一个相同的文件表 # 子进程改变了文件的位置偏移量就会作用到父进程,同理,父进程改变了文件的位置偏移量就会作用到子进程 AABBAABBAABBAABB1122112211221122
-
示例 2:父、子各自打开同一个文件实现文件共享
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int main(void) { pid_t pid; int fd; int i; pid = fork(); switch (pid) { case -1: perror("fork error"); exit(-1); case 0: fd = open("./test.txt", O_WRONLY); if (fd < 0) { perror("open error"); _exit(-1); } for (i = 0; i < 4; i++) { write(fd, "1122", 4); } close(fd); _exit(0); default: fd = open("./test.txt", O_WRONLY); if (fd < 0) { perror("open error"); exit(-1); } for (i = 0; i < 4; i++) { write(fd, "AABB", 4); } close(fd); exit(0); } }
$ gcc fork4.c -o fork4 $ ./fork4 $ cat test.txt # 因为父、子进程的这两个文件描述符分别指向不同的文件表,意味着它们有各自的文件偏移量 # 一个进程修改了文件偏移量并不会影响另一个进程的文件偏移量,所以写入的数据会出现覆盖的情况 1122112211221122
- fork() 函数使用场景
- 1、父进程希望子进程复制自己,使父进程和子进程同时执行不同的代码段
- 这在网络服务进程中是常见的,父进程等待客户端的服务请求,当接收到客户端发送的请求事件后,调用 fork() 创建一个子进程,使子进程去处理此请求,而父进程可以继续等待下一个服务请求
- 2、一个进程要执行不同的程序
- 如:在程序 app1 中调用 fork() 函数创建子进程,此时子进程是要去执行另一个程序 app2,也就是子进程需要执行 app2 程序对应的代码,子进程将从 app2 程序的 main 函数开始运行
- 这种情况,通常在子进程从 fork() 函数返回之后立即调用 exec 族函数来实现
- 1、父进程希望子进程复制自己,使父进程和子进程同时执行不同的代码段
7. 系统调用 vfork()
-
除了 fork() 系统调用之外,Linux 系统还提供了 vfork() 系统调用用于创建子进程,vfork() 与 fork() 函数在功能上是相同的,并且返回值也相同,在一些细节上存在区别
#include <sys/types.h> #include <unistd.h> pid_t vfork(void);
-
使用 fork() 系统调用的代价是很大的,它复制了父进程中的数据段和堆栈段中的绝大部分内容,这将会消耗比较多的时间导致效率降低
- fork() 函数之后子进程通常会调用 exec 函数(fork 第二种使用场景),这使得子进程不再执行父程序中的代码段,而是执行新程序的代码段,从新程序的 main 函数开始执行,并为新程序重新初始化其数据段、堆段、栈段等;那么,子进程并不需要用到父进程的数据段、堆段、栈段中的数据,此时就会导致浪费时间、效率降低
-
fork() 上述缺陷解决办法
- 现代 Linux 采用了一些技术来避免这种浪费,如:内核采用了写时复制(copy-on-write)技术
- 引入 vfork() 系统调用,其效率要高于 fork() 函数,类似于 fork(),vfork() 可以为调用该函数的进程创建一个新的子进程,然而,vfork() 是为子进程立即执行 exec() 新的程序而专门设计的
虽然 vfork() 在效率上优于 fork(),但 vfork() 可能会导致一些 bug,尽量避免使用 vfork() 创建子进程
-
vfork() 与 fork() 函数主要有以下两个区别
- vfork() 与 fork() 一样都创建了子进程,但 vfork() 函数并不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用 exec(或 _exit),于是也就不会引用该地址空间的数据。不过在子进程调用 exec 或_exit 之前,它在父进程的空间中运行,子进程共享父进程的内存,这种优化工作方式的实现提高的效率
- 另一个区别在于,vfork() 保证子进程先运行,子进程调用 exec 之后父进程才可能被调度运行
8. fork() 之后的竞争条件
-
调用 fork() 之后,子进程成为了一个独立的进程,可被系统调度运行,而父进程也继续被系统调度运行,这里出现了一个问题:调用 fork 之后,无法确定父、子两个进程谁将率先访问 CPU,也就是说无法确认谁先被系统调用运行(在多核处理器中,它们可能会同时各自访问一个 CPU),这将导致谁先运行、谁后运行这个顺序是不确定的
-
fork() 竞争条件测试
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { switch (fork()) { case -1: perror("fork error"); exit(-1); case 0: printf("子进程打印信息\n"); _exit(0); default: printf("父进程打印信息\n"); exit(0); } }
$ gcc fork5.c -o fork5 # 绝大部分情况下,父进程会先于子进程被执行,但并不排除子进程先于父进程被执行的可能性 $ ./fork5 父进程打印信息 子进程打印信息 $ ./fork5 父进程打印信息 子进程打印信息 $ ./fork5 子进程打印信息 父进程打印信息 $ ./fork5 父进程打印信息 子进程打印信息 ...
对于有些特定的应用程序,它对于执行的顺序有一定要求的,如:它必须要求父进程先运行,或者必须要求子进程先运行,程序产生正确的结果依赖于特定的执行顺序,那么将可能因竞争条件而导致失败,无法得到正确的结果
- 那如何明确保证执行顺序呢?
- 可以通过采用信号来实现,如果要让子进程先运行,则可使父进程被阻塞,等到子进程来唤醒它
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <signal.h> #include <sys/types.h> static void sig_handler(int sig) { printf("接收到信号\n"); } int main(void) { struct sigaction sig = {0}; sigset_t wait_mask; /* 初始化信号集 */ sigemptyset(&wait_mask); /* 设置信号处理方式 */ sig.sa_handler = sig_handler; sig.sa_flags = 0; if (sigaction(SIGUSR1, &sig, NULL) == -1) { perror("sigaction error"); exit(-1); } switch (fork()) { case -1: perror("fork error"); exit(-1); case 0: /* 子进程 */ printf("子进程开始执行\n"); printf("子进程打印信息\n"); printf("~~~~~~~~~~~~~~~\n"); sleep(2); kill(getppid(), SIGUSR1); // kill 命令发送信号给父进程并唤醒它 _exit(0); default: /* 父进程 */ if (sigsuspend(&wait_mask) != -1) { // 挂起、阻塞 exit(-1); } printf("父进程开始执行\n"); printf("父进程打印信息\n"); exit(0); } }
$ gcc fork6.c -o fork6 $ ./fork6 子进程开始执行 子进程打印信息 ~~~~~~~~~~~~~~~ 接收到信号 父进程开始执行 父进程打印信息
9. 进程的诞生与终止
9.1 进程的诞生
- 一个进程可以通过 fork() 或 vfork() 等系统调用创建一个子进程,一个新的进程就此诞生
- 事实上,Linux 系统下的所有进程都是由其父进程创建而来,如:在 shell 终端通过命令的方式执行一个程序 ./app,那么 app 进程就是由 shell 终端进程创建出来的,shell 终端就是该进程的父进程
- 在 Ubuntu 系统下使用 “ps -aux” 命令可以查看到系统下所有进程信息
- 下图中进程号为 1 的进程便是所有进程的父进程,通常称为 init 进程,它是 Linux 系统启动之后运行的第一个进程,它管理着系统上所有其它进程,init 进程是由内核启动,因此理论上说它没有父进程
# $ ps -aux USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1 0.1 0.0 225492 9408 ? Ss 12:47 0:29 /sbin/init splash root 2 0.0 0.0 0 0 ? S 12:47 0:00 [kthreadd] root 3 0.0 0.0 0 0 ? I< 12:47 0:00 [rcu_gp] ... ...
9.2 进程的终止
- 通常,进程有两种终止方式:异常终止和正常终止(详见 1.2 小节)
- _exit() 函数和 exit() 函数的 status 参数定义了进程的终止状态,父进程可以调用 wait() 函数以获取该状态。虽然参数 status 定义为 int 类型,但仅有低 8 位表示它的终止状态,一般来说,终止状态为 0 表示进程成功终止,而非 0 值则表示进程在执行过程中出现了一些错误而终止
- 在程序当中,一般使用 exit() 库函数而非 _exit() 系统调用,原因在于 exit() 最终也会通过 _exit() 终止进程,但在此之前,它将会完成一些其它的工作,exit() 函数会执行的动作如下
- 如果程序中注册了进程终止处理函数,那么会调用终止处理函数
- 刷新 stdio 流缓冲区
- 执行 _exit() 系统调用
- 父、子进程不应都使用 exit() 终止,一般推荐:子进程使用 _exit() 退出、而父进程则使用 exit() 退出,其原因就在于调用 exit() 函数终止进程时会刷新进程的 stdio 缓冲区
- 进程的用户空间内存中维护了 stdio 缓冲区,通过 fork() 创建子进程时会复制这些缓冲区。标准输出设备默认使用的是行缓冲,当检测到换行符 \n 时会立即显示函数 printf() 输出的字符串
- 若包含换行符,会立即读走缓冲区中的数据并显示,读走之后此时缓冲区就空了;若不包含换行符,调用 printf() 并不会立即读取缓冲区中的数据进行显示,由此 fork() 之后创建的子进程也自然拷贝了缓冲区的数据,当它们调用 exit() 函数时,都会刷新各自的缓冲区、显示字符串
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { printf("Hello World!\n"); switch (fork()) { case -1: perror("fork error"); exit(-1); case 0: /* 子进程 */ exit(0); default: /* 父进程 */ exit(0); } }
$ gcc fork7.c -o fork7 $ ./fork7 Hello World!
// 若将上述代码的 \n 去掉 //printf("Hello World!\n"); printf("Hello World!");
$ gcc fork8.c -o fork8 $ ./fork7 Hello World!Hello World!$
如何避免上述重复输出问题?
- 对于行缓冲设备,可以加上对应换行符 \n
- 在调用 fork() 之前,使用函数 fflush() 来刷新 stdio 缓冲区
- 子进程调用 _exit() 退出进程,调用 _exit() 在退出时不会刷新 stdio 缓冲区
10. 监视子进程
- 在很多应用程序的设计中,父进程需要知道子进程何时被终止,并且需要知道子进程的终止状态信息,是正常终止、还是异常终止亦或者被信号终止等,意味着父进程会对子进程进行监视
10.1 wait() 函数
-
系统调用 wait() 可以等待进程的任一子进程终止,同时获取子进程的终止状态信息
#include <sys/types.h> #include <sys/wait.h> // status:用于存放子进程终止时的状态信息,可以为 NULL,表示不接收子进程终止时的状态信息 // 返回值:若成功则返回终止的子进程对应的进程号;失败则返回-1 pid_t wait(int *status);
-
系统调用 wait() 将执行如下动作
- 调用 wait() 函数,如果其所有子进程都还在运行,则 wait() 会一直阻塞等待,直到某一个子进程终止
- 调用 wait() 函数,但是该进程并没有子进程,也就意味着该进程并没有需要等待的子进程,那么 wait() 将返回错误,也就是返回 -1、并且会将 errno 设置为 ECHILD
- 如果进程调用 wait() 之前,它的子进程当中已经有一个或多个子进程已经终止了,那么调用 wait() 也不会阻塞,而是立刻回收子进程资源,然后返回到正常的程序流程中,一次 wait() 调用只能处理一次
-
参数 status 不为 NULL 的情况下,则 wait() 会将子进程的终止时的状态信息存储在它指向的 int 变量中,可以通过以下宏来检查 status 参数
- WIFEXITED(status):如果子进程正常终止,则返回 true
- WEXITSTATUS(status):返回子进程退出状态,是一个数值,其实就是子进程调用 _exit() 或 exit() 时指定的退出状态;wait() 获取得到的 status 参数并不是调用 _exit() 或 exit() 时指定的状态,可通过 WEXITSTATUS 宏转换
- WIFSIGNALED(status):如果子进程被信号终止,则返回 true
- WTERMSIG(status):返回导致子进程终止的信号编号。如果子进程是被信号所终止,则可以通过此宏获取终止子进程的信号
- WCOREDUMP(status):如果子进程终止时产生了核心转储文件,则返回 true
-
示例:循环创建 3 个子进程并回收
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <errno.h> int main(void) { int status; int ret; int i; /* 循环创建 3 个子进程 */ for (i = 1; i <= 3; i++) { switch (fork()) { case -1: perror("fork error"); exit(-1); case 0: /* 子进程 */ printf("子进程<%d>被创建\n", getpid()); sleep(i); _exit(i); default: /* 父进程 */ break; } } sleep(1); printf("-----------------\n"); for (i = 1; i <= 3; i++) { ret = wait(&status); if (ret == -1) { if (ECHILD == errno) { printf("没有需要等待回收的子进程\n"); exit(0); } else { perror("wait error"); exit(-1); } } printf("回收子进程<%d>, 终止状态<%d>\n", ret, WEXITSTATUS(status)); } exit(0); }
$ gcc wait.c -o wait $ ./wait 子进程<3961>被创建 子进程<3962>被创建 子进程<3963>被创建 ----------------- 回收子进程<3961>, 终止状态<1> 回收子进程<3962>, 终止状态<2> 回收子进程<3963>, 终止状态<3>
10.2 waitpid() 函数
-
使用 wait() 系统调用存在着一些限制
- 如果父进程创建了多个子进程,使用 wait() 将无法等待某个特定的子进程的完成,只能按照顺序等待下一个子进程的终止,一个一个来、谁先终止就先处理谁
- 如果子进程没有终止而是正在运行,那么 wait() 总是保持阻塞,有时希望执行非阻塞等待,是否有子进程终止,通过判断即可得知
- 使用 wait() 只能发现那些被终止的子进程,对于子进程因某个信号(如 SIGSTOP 信号)而停止,或是已停止的子进程收到 SIGCONT 信号后恢复执行的情况就无能为力了
-
系统调用 waitpid() 函数可以突破上述限制
#include <sys/types.h> #include <sys/wait.h> // pid:用于表示需要等待的某个具体子进程 /* pid > 0,表示等待进程号为 pid 的子进程 pid = 0,则等待与调用进程(父进程)同一个进程组的所有子进程 pid < -1,则会等待进程组标识符与 pid 绝对值相等的所有子进程 pid = -1,则等待任意子进程,此时 wait(&status) 与 waitpid(-1, &status, 0) 等价 */ // status:与 wait() 函数的 status 参数意义相同 pid_t waitpid(pid_t pid, int *status, int options);
-
options: 是一个位掩码,可以包括 0 个或多个如下标志
- WNOHANG:如果子进程没有发生状态改变(终止、暂停)则立即返回,也就是执行非阻塞等待,可以实现轮训 poll,通过返回值可以判断是否有子进程发生状态改变,若返回值等于 0 表示没有发生改变
- WUNTRACED:除了返回终止的子进程的状态信息外,还返回因信号而停止(暂停运行)的子进程状态信息
- WCONTINUED:返回那些因收到 SIGCONT 信号而恢复运行的子进程的状态信息
-
示例 1
// 将 10.1 小节中的 wait(&status)替换成了 waitpid(-1, &status, 0)
$ gcc waitpid.c -o waitpid $ ./waitpid 子进程<4009>被创建 子进程<4010>被创建 子进程<4011>被创建 ----------------- 回收子进程<4009>, 终止状态<1> 回收子进程<4010>, 终止状态<2> 回收子进程<4011>, 终止状态<3>
-
示例 2:waitpid() 轮训方式
- 将 waitpid() 函数的 options 参数添加 WNOHANG 标志,将 waitpid() 配置成非阻塞模式,使用轮训的方式依次回收各个子进程
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <errno.h> int main(void) { int status; int ret; int i; /* 循环创建 3 个子进程 */ for (i = 1; i <= 3; i++) { switch (fork()) { case -1: perror("fork error"); exit(-1); case 0: /* 子进程 */ printf("子进程<%d>被创建\n", getpid()); sleep(i); _exit(i); default: /* 父进程 */ break; } } sleep(1); printf("-----------------\n"); for (;;) { ret = waitpid(-1, &status, WNOHANG); if (ret < 0) { if (ECHILD == errno) { exit(0); } else { perror("wait error"); exit(-1); } } else if (ret == 0) { continue; } else printf("回收子进程<%d>, 终止状态<%d>\n", ret, WEXITSTATUS(status)); } exit(0); }
$ gcc wait2.c -o wait2 $ ./wait2 子进程<4047>被创建 子进程<4048>被创建 子进程<4049>被创建 ----------------- 回收子进程<4047>, 终止状态<1> 回收子进程<4048>, 终止状态<2> 回收子进程<4049>, 终止状态<3>
10.3 僵尸进程与孤儿进程
- 当一个进程创建子进程之后,它们俩就成为父子进程关系,父进程与子进程的生命周期往往是不相同的,这里就会出现两个问题
- 父进程先于子进程结束
- 子进程先于父进程结束
10.3.1 孤儿进程
-
父进程先于子进程结束,此时子进程变成了一个 “孤儿”,把这种进程称为孤儿进程
- Linux 中所有孤儿进程都自动成为 init 进程(进程号为 1)的子进程,换言之:某一子进程的父进程结束后,该子进程调用 getppid() 将返回 1,init 进程变成了孤儿进程的 “养父”,这是判定某一子进程的 “生父” 是否还 “在世” 的方法之一
-
孤儿进程测试
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { switch (fork()) { case -1: perror("fork error"); exit(-1); case 0: /* 子进程 */ printf("子进程<%d>被创建, 父进程<%d>\n", getpid(), getppid()); sleep(3); // 子进程休眠 3 秒钟,保证父进程先结束 printf("父进程<%d>\n", getppid()); // 再次获取父进程 pid,此时 “生父” 已经结束 _exit(0); default: /* 父进程 */ break; } sleep(1); // 父进程休眠 1 秒钟,保证在父进程结束前子进程能够打印父进程进程号 printf("父进程结束!\n"); exit(0); }
$ gcc child.c -o child $ ./child 子进程<4179>被创建, 父进程<4178> 父进程结束! $ 父进程<1082> # 打印结果并不是 1,意味着并不是 init 进程
# 查看进程号 1082 对应的进程如下 # /lib/systemd/systemd 是 Ubuntu 系统下的一个后台守护进程,可负责 “收养” 孤儿进程 $ ps -axu USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND ... yue 1082 0.0 0.0 78228 9376 ? Ss 12:48 0:00 /lib/systemd/systemd --user ...
10.3.2 僵尸进程
-
进程结束之后,通常需要其父进程为其 “收尸”,回收子进程占用的一些内存资源,父进程通过调用 wait()(或其变体 waitpid()、waitid() 等)函数回收子进程资源,归还给系统,如果子进程先于父进程结束,此时父进程还未来得及给子进程 “收尸”,那么此时子进程就变成了一个僵尸进程
- 当父进程调用 wait() 为子进程 “收尸” 后,僵尸进程就会被内核彻底删除。另外一种情况,如果父进程并没有调用 wait() 函数然后就退出了,那么此时 init 进程将会接管它的子进程并自动调用 wait(),从系统中移除僵尸进程
- 僵尸进程无法通过信号将其杀死,只能杀死僵尸进程的父进程(或等待其父进程终止),这样 init 进程将会接管这些僵尸进程,从而将它们从系统中清理掉
-
示例:产生僵尸进程
// 子进程已经退出,但其父进程并没调用 wait()为其“收尸”,使得子进程成为一个僵尸进程 #include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { switch (fork()) { case -1: perror("fork error"); exit(-1); case 0: /* 子进程 */ printf("子进程<%d>被创建\n", getpid()); sleep(1); printf("子进程结束\n"); _exit(0); default: /* 父进程 */ break; } for ( ; ; ) sleep(1); exit(0); }
$ gcc child2.c -o child2 $ ./child2 子进程<4317>被创建 子进程结束
$ ps -axu USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND ... yue 4316 0.0 0.0 4388 772 pts/0 S+ 22:51 0:00 ./child2 # 状态栏 STAT 显示 “Z”(zombie,僵尸),表示它是一个僵尸进程 # defunct 僵尸 yue 4317 0.0 0.0 0 0 pts/0 Z+ 22:51 0:00 [child2] <defunct> ...
10.4 SIGCHLD 信号
-
当发生以下两种情况时,父进程会收到 SIGCHLD 信号
- 当父进程的某个子进程终止时,父进程会收到 SIGCHLD 信号
- 当父进程的某个子进程因收到信号而停止(暂停运行)或恢复时,内核也可能向父进程发送该信号
-
子进程的终止属于异步事件,父进程事先是无法预知的,如果父进程有自己需要做的事情,它不能一直 wait() 阻塞等待子进程终止(或轮训),这样父进程将啥事也做不了,可通过 SIGCHLD 信号解决这个问题
- 子进程状态改变时,父进程会收到 SIGCHLD 信号,SIGCHLD 信号的系统默认处理方式是将其忽略,所以要捕获它并绑定信号处理函数,在信号处理函数中调用 wait() 收回子进程,回收完毕再回到父进程工作流程
-
当调用信号处理函数时,会暂时将引发调用的信号添加到进程的信号掩码中(除非 sigaction() 指定了 SA_NODEFER 标志),这样一来,当 SIGCHLD 信号处理函数正在为一个终止的子进程 “收尸” 时,如果相继有两个子进程终止,即使产生了两次 SIGCHLD 信号,父进程也只能捕获到一次 SIGCHLD 信号,结果是父进程的 SIGCHLD 信号处理函数每次只调用一次 wait(),那么就会导致有些僵尸进程成为 “漏网之鱼”
- 解决方案:在 SIGCHLD 信号处理函数中以循环非阻塞方式来调用 waitpid(),直至再无其它终止的子进程需要处理为止
// 下述代码一直循环下去,直至 waitpid() 返回 0,表明再无僵尸进程存在;或者返回 -1,表明有错误发生 // 应在创建任何子进程之前,为 SIGCHLD 信号绑定处理函数 while (waitpid(-1, NULL, WNOHANG) > 0) continue;
-
示例:通过 SIGCHLD 信号实现异步方式监视子进程
#include <stdio.h> #include <stdlib.h> #include <signal.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> static void wait_child(int sig) { /* 替子进程收尸 */ printf("父进程回收子进程\n"); while (waitpid(-1, NULL, WNOHANG) > 0) continue; } int main(void) { struct sigaction sig = {0}; /* 为 SIGCHLD 信号绑定处理函数 */ sigemptyset(sig.sa_mask); sig.sa_handler = wait_child; sig.sa_flags = 0; if (-1 == sigaction(SIGCHLD, &sig, NULL)) { perror("sigaction error"); exit(-1); } switch (fork()) { case -1: perror("fork error"); exit(-1); case 0: /* 子进程 */ printf("子进程<%d>被创建\n", getpid()); sleep(1); printf("子进程结束\n"); _exit(0); default: /* 父进程 */ break; } sleep(3); exit(0); }
$ gcc test.c -o test $ ./test 子进程<4318>被创建 子进程结束 父进程回收子进程
11. 执行新程序
- 当子进程的工作不再是运行父进程的代码段,而是运行另一个新程序的代码,那么这个时候子进程可以通过 exec 族函数来实现运行另一个新的程序
11.1 execve() 函数
- 系统调用 execve() 可以将新程序加载到某一进程的内存空间
- 通过调用 execve() 函数将一个外部的可执行文件加载到进程的内存空间运行,使用新的程序替换旧的程序,而进程的栈、数据、以及堆数据会被新程序的相应部件所替换,然后从新程序的 main() 函数开始执行
#include <unistd.h> // filename:指向需要载入当前进程空间的新程序的路径名,既可以是绝对路径、也可以是相对路径 // argv:指定传递给新程序的命令行参数。是一个字符串数组,该数组对应于 main 函数第二个参数 argv // 且格式也与之相同,是由字符串指针所组成的数组,以 NULL 结束。argv[0] 对应的便是新程序自身路径名 // envp:参数 envp 也是一个字符串指针数组,指定了新程序的环境变量列表,参数 envp 其实对应于新程序的 environ 数组 // 同样也是以 NULL 结束,所指向的字符串格式为 name=value // 返回值:execve 调用成功将不会返回;失败将返回-1,并设置 errno int execve(const char *filename, char *const argv[], char *const envp[]);
- 示例:测试程序 testApp 通过 execve() 函数运行另一个新程序 newApp
// testApp.c #include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(int argc, char *argv[]) { char *arg_arr[5]; char *env_arr[5] = {"NAME=app", "AGE=25", "SEX=man", NULL}; if (2 > argc) exit(-1); arg_arr[0] = argv[1]; arg_arr[1] = "Hello"; arg_arr[2] = "World"; arg_arr[3] = NULL; execve(argv[1], arg_arr, env_arr); perror("execve error"); exit(-1); }
// newApp.c #include <stdio.h> #include <stdlib.h> extern char **environ; int main(int argc, char *argv[]) { char **ep = NULL; int j; for (j = 0; j < argc; j++) printf("argv[%d]: %s\n", j, argv[j]); puts("env:"); for (ep = environ; *ep != NULL; ep++) printf(" %s\n", *ep); exit(0); }
$ gcc testApp.c -o testApp $ gcc newApp.c -o newApp $ ./testApp ./newApp argv[0]: ./newApp argv[1]: Hello argv[2]: World env: NAME=app AGE=25 SEX=man
为什么需要在子进程中执行新程序?
- 虽然可以直接在子进程分支编写子进程需要运行的代码,但是不够灵活,扩展性不够好,直接将子进程需要运行的代码单独放在一个可执行文件中不是更好吗,所以就出现了 exec 操作
11.2 exec 库函数
-
exec 族函数包括多个不同的函数,这些函数命名都以 exec 为前缀,这些库函数都是基于系统调用 execve() 实现的,通常将调用这些 exec 函数加载一个外部新程序的过程称为 exec 操作
#include <unistd.h> extern char **environ; int execl(const char *path, const char *arg, ... /* (char *) NULL */); int execlp(const char *file, const char *arg, ... /* (char *) NULL */); int execle(const char *path, const char *arg, ... /*, (char *) NULL, 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[]);
-
execl() 和 execv() 都是基本的 exec 函数,都可用于执行一个新程序,它们之间的区别在于参数格式不同
- 参数 path 意义和格式都相同,与系统调用 execve() 的 filename 参数相同,指向新程序的路径名,既可以是绝对路径,也可以是相对路径
- execv() 的 argv 参数与 execve() 的 argv 参数相同,也是字符串指针数组;而 execl()把参数列表依次排列,使用可变参数形式传递,本质上也是多个字符串,以 NULL 结尾
// execv 传参 char *arg_arr[5]; arg_arr[0] = "./newApp"; arg_arr[1] = "Hello"; arg_arr[2] = "World"; arg_arr[3] = NULL; execv("./newApp", arg_arr); // execl 传参 execl("./newApp", "./newApp", "Hello", "World", NULL);
-
execlp() 和 execvp() 在 execl() 和 execv() 基础上加了一个 p,这个 p 其实表示的是 PATH 路径
- execl() 和 execv() 要求提供新程序的路径名,而 execlp() 和 execvp() 则允许只提供新程序文件名,系统会在由环境变量 PATH 所指定的目录列表中寻找相应的可执行文件
- execlp() 和 execvp() 函数也兼容相对路径和绝对路径的方式
-
execle() 和 execvpe() 这两个函数在命名上加了一个 e,这个 e 其实表示的是 environment 环境变量
- 意味着这两个函数可以指定自定义的环境变量列表给新程序,参数 envp 与系统调用 execve() 的 envp 参数相同,也是字符串指针数组
// execvpe 传参 char *env_arr[5] = {"NAME=app", "AGE=25", "SEX=man", NULL}; char *arg_arr[5]; arg_arr[0] = "./newApp"; arg_arr[1] = "Hello"; arg_arr[2] = "World"; arg_arr[3] = NULL; execvpe("./newApp", arg_arr, env_arr); // execle 传参 execle("./newApp", "./newApp", "Hello", "World", NULL, env_arr);
11.3 system() 函数
-
使用 system() 函数可以很方便地在程序当中执行任意 shell 命令
#include <stdlib.h> // command :参数 command 指向需要执行的 shell 命令,以字符串的形式提供,如 "ls -al"、"echo HelloWorld" 等 int system(const char *command);
-
system() 函数内部是通过调用 fork()、execl() 及 waitpid() 来实现它的功能,首先 system() 会调用 fork() 创建一个子进程来运行 shell(可以把这个子进程称为 shell 进程),并通过 shell 执行参数 command 所指定的命令
-
system() 的返回值
- 当参数 command 为 NULL
- 如果 shell 可用则返回一个非 0 值,若不可用则返回 0
- 针对一些非 UNIX 系统,该系统上可能是没有 shell 的,这样就会导致 shell 不可能
- 当参数 command 参数不为 NULL,则返回值从以下的各种情况所决定
- 如果无法创建子进程或无法获取子进程的终止状态,那么 system() 返回 -1
- 如果子进程不能执行 shell,则 system() 的返回值就好像是子进程通过调用 _exit(127) 终止了
- 如果所有的系统调用都成功,system() 函数会返回执行 command 的 shell 进程的终止状态
- 当参数 command 为 NULL
-
system() 的主要优点在于使用上方便简单,编程时无需自己处理对 fork()、exec 函数、waitpid() 以及 exit() 等调用细节,system() 内部会代为处理,但使用 system() 函数其效率会大打折扣,如果程序对效率或速度有所要求,那么不建议直接使用 system()
-
示例:system() 函数使用
#include <stdio.h> #include <stdlib.h> int main(int argc, char *argv[]) { int ret; if (argc < 2) exit(-1); ret = system(argv[1]); if (ret == -1) fputs("system error.\n", stderr); else { if (WIFEXITED(ret) && (WEXITSTATUS(ret) == 127)) fputs("could not invoke shell.\n", stderr); } exit(0); }
$ gcc sys.c -o sys $ ./sys pwd /home/yue/桌面/test $ ./sys 'ls -al' 总用量 292 drwxrwxr-x 2 yue yue 4096 12月 24 14:43 . drwxr-xr-x 3 yue yue 4096 12月 20 21:31 .. -rwxrwxr-x 1 yue yue 8584 12月 23 22:48 child ... ...
12. 进程状态与进程关系
12.1 进程状态
-
Linux 系统下进程通常存在 6 种不同的状态
- 就绪态:指该进程满足被 CPU 调度的所有条件但此时并没有被调度执行,只要得到 CPU 就能够直接运行;意味着该进程已准备好被 CPU 执行,当一个进程的时间片到达,操作系统会从就绪态链表中调度一个进程
- 运行态:指该进程当前正在被 CPU 调度运行,处于就绪态的进程得到 CPU 调度就会进入运行态
- 僵尸态:僵尸态进程其实指的就是僵尸进程,指该进程已经结束,但其父进程还未给它 “收尸”
- 可中断睡眠状态:可中断睡眠也称为浅度睡眠,表示睡的不够 “死”,还可以被唤醒,一般来说可以通过信号来唤醒
- 不可中断睡眠状态:不可中断睡眠称为深度睡眠,深度睡眠无法被信号唤醒,只能等待相应的条件成立才能结束睡眠状态。把浅度睡眠和深度睡眠统称为等待态(或者叫阻塞态),表示进程处于一种等待状态,等待某种条件成立之后便会进入到就绪态,处于等待态的进程无法参与进程系统调度
- 暂停态:暂停并不是进程的终止,表示进程暂停运行,一般可通过信号将进程暂停,譬如 SIGSTOP 信号;处于暂停态的进程是可以恢复进入到就绪态的,如收到 SIGCONT 信号
-
进程各个状态之间的转换关系
12.2 进程关系
进程间存在着多种不同的关系,主要包括:无关系(相互独立)、父子进程关系、进程组以及会话
12.2.1 无关系
- 两个进程间没有任何关系,相互独立
12.2.2 父、子进程关系
- 两个进程间构成父子进程关系,如一个进程 fork() 创建出了另一个进程,那么这两个进程间就构成了父子进程关系,调用 fork() 的进程称为父进程,而被 fork() 创建出来的进程称为子进程
- 如果 “生父” 先于子进程结束,那么 init 进程(“养父”)就会成为子进程的父进程,它们之间同样也是父子进程关系
12.2.3 进程组
- 每个进程除了有一个进程 ID、父进程 ID 外,还有一个进程组 ID,用于标识该进程属于哪一个进程组,进程组是一个或多个进程的集合,这些进程并不是孤立的,它们彼此之间或者存在父子、兄弟关系,或者在功能上有联系
- Linux 系统设计进程组实质上是为了方便对进程进行管理
- 假设为了完成一个任务,需要并发运行 100 个进程,当需要终止这 100 个进程时,若没有进程组就需要一个一个去终止,这样非常麻烦且容易出现一些问题
- 有了进程组的概念之后,就可以将这 100 个进程设置为一个进程组,这些进程共享一个进程组 ID,这样一来,终止这 100 个进程只需要终止该进程组即可
- 关于进程组需要注意以下内容
- 每个进程必定属于某一个进程组,且只能属于一个进程组
- 每一个进程组有一个组长进程,组长进程的 ID 就等于进程组 ID
- 在组长进程的 ID 前面加上一个负号即是操作进程组
- 组长进程不能再创建新的进程组
- 只要进程组中还存在一个进程,则该进程组就存在,这与其组长进程是否终止无关
- 一个进程组可包含一个或多个进程,进程组的生命周期从被创建开始,到其内所有进程终止或离开该进程组
- 默认情况下,新创建的进程会继承父进程的进程组 ID
- 通过系统调用 getpgrp() 或 getpgid() 可以获取进程对应的进程组 ID
#include <unistd.h> // 可通过参数 pid 指定获取对应进程的进程组 ID,如果参数 pid 为 0 表示获取调用者进程的进程组 ID // getpgid() 函数成功将返回进程组 ID;失败将返回-1、并设置 errno pid_t getpgid(pid_t pid); // 返回值总是调用者进程对应的进程组 ID,getpgrp() 就等价于 getpgid(0) pid_t getpgrp(void);
- 调用系统调用 setpgid() 或 setpgrp() 可以加入一个现有的进程组或创建一个新的进程组
#include <unistd.h> int setpgid(pid_t pid, pid_t pgid); int setpgrp(void); // setpgrp() 函数等价于 setpgid(0, 0)
12.2.4 会话
-
会话是一个或多个进程组的集合,其与进程组、进程之间的关系如下图所示
-
一个会话可包含一个或多个进程组,但只能有一个前台进程组,其它的是后台进程组,每个会话都有一个会话首领(leader),即创建会话的进程
- 一个会话可以有控制终端、也可没有控制终端,在有控制终端的情况下也只能连接一个控制终端,这通常是登录到其上的终端设备(在终端登录情况下)或伪终端设备(如:通过 SSH 协议网络登录)
- 会话的首领进程连接一个终端后,该终端就成为会话的控制终端,与控制终端建立连接的会话首领进程被称为控制进程
- 产生在终端上的输入和信号将发送给会话的前台进程组中的所有进程
- 如 Ctrl + C(产生 SIGINT 信号)、Ctrl + Z(产生 SIGTSTP 信号)、Ctrl + \(产生 SIGQUIT 信号)等
- 一个进程组由组长进程的 ID 标识,而对于会话来说,会话的首领进程的进程组 ID 将作为该会话的标识,也就是会话 ID(sid),在默认情况下,新创建的进程会继承父进程的会话 ID
- 通过系统调用 getsid() 可以获取进程的会话 ID
#include <unistd.h> // 如果参数 pid 为 0,则返回调用者进程的会话 ID;如果参数 pid 不为 0,则返回参数 pid 指定的进程对应的会话 ID // 成功情况下,该函数返回会话 ID,失败则返回 -1、并设置 errno pid_t getsid(pid_t pid);
- 使用系统调用 setsid() 可以创建一个会话
#include <unistd.h> // 如果调用者进程不是进程组的组长进程,调用 setsid() 将创建一个新的会话,调用者进程是新会话的首领进程 // 同样也是一个新的进程组的组长进程,调用 setsid() 创建的会话将没有控制终端 // setsid() 调用成功将返回新会话的会话 ID;失败将返回-1,并设置 errno pid_t setsid(void);
13. 守护进程
13.1 何为守护进程
-
守护进程(Daemon)也称为精灵进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生,主要表现为以下两个特点
- 长期运行
- 守护进程是一种生存期很长的一种进程,它们一般在系统启动时开始运行,除非强行终止,否则直到系统关机都会保持运行
- 与守护进程相比,普通进程都是在用户登录或运行程序时创建,在运行结束或用户注销时终止,但守护进程不受用户登录注销的影响,它们将会一直运行着、直到系统关机
- 与控制终端脱离
- 在 Linux 中,系统与用户交互的界面称为终端,每一个从终端开始运行的进程都会依附于这个终端,也就是会话的控制终端,当控制终端被关闭的时候,该会话就会退出,由控制终端运行的所有进程都会被终止,这使得普通进程都是和运行该进程的终端相绑定的
- 但守护进程能突破这种限制,它脱离终端并且在后台运行,脱离终端的目的是为了避免进程在运行的过程中的信息在终端显示,并且进程也不会被任何终端产生的信息所打断
- 长期运行
-
守护进程 Daemon,通常简称为 d,一般进程名后面带有 d 就表示它是一个守护进程
- 守护进程与终端无任何关联,用户的登录与注销与守护进程无关、不受其影响
- 守护进程自成进程组、自成会话,即 pid=gid=sid
- 通过命令 “ps -ajx” 查看系统所有的进程
# TTY 一栏是问号 ?表示该进程没有控制终端,也就是守护进程 # 其中 COMMAND 一栏使用中括号 [] 括起来的表示内核线程,这些线程是在内核里创建 # 没有用户空间代码,因此没有程序文件名和命令行,通常采用 k(Kernel)开头 $ ps -ajx PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 0 1 1 1 ? -1 Ss 0 0:15 /sbin/init splash 0 2 0 0 ? -1 S 0 0:00 [kthreadd] 2 3 0 0 ? -1 I< 0 0:00 [rcu_gp] ... ...
13.2 编写守护进程程序
-
1. 创建子进程、终止父进程
- 父进程调用 fork() 创建子进程,然后父进程使用 exit() 退出,这样做实现了下面几点
- 第一,如果该守护进程是作为一条简单地 shell 命令启动,那么父进程终止会让 shell 认为这条命令已经执行完毕
- 第二,虽然子进程继承了父进程的进程组 ID,但它有自己独立的进程 ID,这保证了子进程不是一个进程组的组长进程,这是调用 setsid 函数的先决条件
- 父进程调用 fork() 创建子进程,然后父进程使用 exit() 退出,这样做实现了下面几点
-
2. 子进程调用 setsid 创建会话
- 子进程成为新会话的首领进程,创建了新的进程组且子进程成为组长进程,此时创建的会话将没有控制终端
- 这里调用 setsid 有三个作用
- 让子进程摆脱原会话的控制
- 让子进程摆脱原进程组的控制
- 让子进程摆脱原控制终端的控制
- setsid 函数能够使子进程完全独立出来,从而脱离所有其他进程的控制
-
3. 将工作目录更改为根目录
- 子进程继承了父进程的当前工作目录,由于在进程运行中,当前目录所在的文件系统是不能卸载的,这对以后使用会造成很多的麻烦
- 通常的做法是让 “/” 作为守护进程的当前目录,当然也可以指定其它目录来作为守护进程的工作目录
-
4. 重设文件权限掩码 umask
- 文件权限掩码 umask 用于对新建文件的权限位进行屏蔽,由于使用 fork 函数新建的子进程继承了父进程的文件权限掩码,这就给子进程使用文件带来了诸多麻烦
- 因此,把文件权限掩码设置为 0,确保子进程有最大操作权限,这样可以大大增强该守护进程的灵活性,设置文件权限掩码的函数是 umask,通常的使用方法为 umask(0)
-
5. 关闭不再需要的文件描述符
- 子进程继承了父进程的所有文件描述符,这些被打开的文件可能永远不会被守护进程(此时守护进程指的就是子进程,父进程退出、子进程成为守护进程)读或写,但它们一样消耗系统资源,可能导致所在的文件系统无法卸载,所以必须关闭这些文件,这使得守护进程不再持有从其父进程继承过来的任何文件描述符
-
6. 将文件描述符号为 0、1、2 定位到 /dev/null
- 将守护进程的标准输入、标准输出以及标准错误重定向到 /dev/null,这使得守护进程的输出无处显示,也无处从交互式用户那里接收输入
-
7. 其它:忽略 SIGCHLD 信号
- 处理 SIGCHLD 信号对于并发服务器进程往往特别重要,服务器进程在接收到客户端请求时会创建子进程去处理该请求
- 如果子进程结束之后,父进程没有去 wait 回收子进程,则子进程将成为僵尸进程
- 如果父进程 wait 等待子进程退出,将又会增加父进程(服务器)的负担,影响服务器进程的并发性能
- Linux 下可以将 SIGCHLD 信号的处理方式设置为 SIG_IGN,也就是忽略该信号,可让内核将僵尸进程转交给 init 进程去处理,这样既不会产生僵尸进程,又省去了服务器进程回收子进程所占用的时间
- 处理 SIGCHLD 信号对于并发服务器进程往往特别重要,服务器进程在接收到客户端请求时会创建子进程去处理该请求
-
示例:守护进程
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <signal.h> int main(void) { pid_t pid; int i; /* 创建子进程 */ pid = fork(); if (pid < 0) { perror("fork error"); exit(-1); } else if (pid > 0) { // 父进程 exit(0); // 直接退出 } /* 1.创建新的会话、脱离控制终端 */ if (setsid() < 0) { perror("setsid error"); exit(-1); } /* 2.设置当前工作目录为根目录 */ if (chdir("/") < 0) { perror("chdir error"); exit(-1); } /* 3.重设文件权限掩码 umask */ umask(0); /* 4.关闭所有文件描述符 */ // sysconf(_SC_OPEN_MAX) 用于获取当前系统允许进程打开的最大文件数量 for (i = 0; i < sysconf(_SC_OPEN_MAX); i++) { close(i); } /* 5.将文件描述符号为 0、1、2 定位到/dev/null */ // /dev/null 是一个黑洞文件,看不到输出信息 open("/dev/null", O_RDWR); dup(0); dup(0); /* 6.忽略 SIGCHLD 信号 */ signal(SIGCHLD, SIG_IGN); /* 正式进入到守护进程 */ for (;;) { sleep(1); puts("daemon process running..."); } exit(0); }
$ gcc dae.c -o dae $ ./dae # 运行之后,没有任何打印信息输出,原因在于守护进程已经脱离了控制终端,它的打印信息并不会输出显示到终端 # 使用 "ps -ajx" 命令查看进程,dae 进程成为了一个守护进程,与控制台脱离 $ ps -ajx PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 0 1 1 1 ? -1 Ss 0 0:19 /sbin/init splash 0 2 0 0 ? -1 S 0 0:00 [kthreadd] ... 1072 3714 3714 3714 ? -1 Ss 1000 0:00 ./dae 2098 3715 3715 2098 pts/0 3715 R+ 1000 0:00 ps -ajx
当关闭当前控制终端时,dae 进程并不会受到影响,依然会正常继续运行;而对于普通进程来说,终端关闭,那么由该终端运行的所有进程都会被强制关闭,因为它们处于同一个会话。守护进程可以通过终端命令行启动,但通常它们是由系统初始化脚本进行启动,如:/etc/rc* 或 /etc/init.d/* 等
13.3 SIGHUP 信号
-
当用户准备退出会话时,系统向该会话发出 SIGHUP 信号,会话将 SIGHUP 信号发送给所有子进程,子进程接收到 SIGHUP 信号后,便会自动终止,当所有会话中的所有进程都退出时,会话也就终止了
- 因为程序当中一般不会对 SIGHUP 信号进行处理,所以对应的处理方式为系统默认方式,SIGHUP 信号的系统默认处理方式便是终止进程
-
示例:忽略 SIGHUP 测试
- 当程序当中忽略 SIGHUP 信号之后,进程不会随着终端退出而退出,事实上,控制终端只是会话中的一个进程,只有会话中的所有进程退出后,会话才会结束;很显然当程序中忽略了 SIGHUP 信号,导致该进程不会终止,所以会话也依然会存在
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <signal.h> int main(void) { signal(SIGHUP, SIG_IGN); for (; ;) { sleep(1); puts("进程运行中......"); } }
$ gcc sighup.c -o sighup $ ./sighup 进程运行中...... 进程运行中...... 进程运行中...... ...
# 关闭终端,再重新打开终端,使用下述命令查看 sighup 进程是否存在 $ ps -ajx | grep sighup 3774 3810 3809 3774 pts/0 3809 S+ 1000 0:00 grep --color=auto sighup
14. 单例模式运行
- 通常情况下,一个程序可以被多次执行,即程序在还没有结束的情况下,又再次执行该程序,也就是系统中同时存在多个该程序的实例化对象(进程)如:聊天软件 QQ,可以在电脑上同时登陆多个 QQ 账号
- 但有些程序只能被执行一次,只要该程序没有结束,就无法再次运行,把这种情况称为单例模式运行
- 如:系统中守护进程,这些守护进程一般都是服务器进程,服务器程序只需要运行一次即可,能够在系统整个运行过程中提供相应的服务支持,多次同时运行并没有意义,甚至还会带来错误
14.1 通过文件存在与否进行判断
-
用一个文件的存在与否来做标志,在程序运行正式代码之前,先判断一个特定的文件是否存在
- 如果存在则表明进程已经运行,此时应该立马退出
- 如果不存在则表明进程没有运行,然后创建该文件,当程序结束时再删除该文件即可
-
示例:简单方式实现单例模式运行
- 使用这种方法实现单例模式运行并不靠谱
#include <stdio.h> #include <stdlib.h> #include <sys/file.h> #include <sys/types.h> #include <sys/stat.h> #include <unistd.h> #define LOCK_FILE "./testApp.lock" static void delete_file(void) { remove(LOCK_FILE); } int main(void) { /* 打开文件 */ int fd = open(LOCK_FILE, O_RDONLY | O_CREAT | O_EXCL, 0666); if (-1 == fd) { fputs("不能重复执行该程序!\n", stderr); exit(-1); } /* 注册进程终止处理函数 */ if (atexit(delete_file)) exit(-1); puts("程序运行中..."); sleep(10); puts("程序结束"); close(fd); // 关闭文件 exit(0); }
14.2 使用文件锁
-
使用文件锁方式才是实现单例模式运行靠谱的方法
#include <stdio.h> #include <stdlib.h> #include <sys/file.h> #include <sys/types.h> #include <sys/stat.h> #include <unistd.h> #include <string.h> #define LOCK_FILE "./testApp.pid" int main(void) { char str[20] = {0}; int fd; /* 打开 lock 文件,如果文件不存在则创建 */ fd = open(LOCK_FILE, O_WRONLY | O_CREAT, 0666); if (-1 == fd) { perror("open error"); exit(-1); } /* 以非阻塞方式获取文件锁 */ // 使用 flock 尝试获取文件锁,调用 flock() 时指定了互斥锁标志 LOCK_NB,意味着同时只能有一个进程拥有该锁 if (-1 == flock(fd, LOCK_EX | LOCK_NB)) { // 如果获取锁失败,表示该程序已经启动了,无需再次执行,然后退出 fputs("不能重复执行该程序!\n", stderr); close(fd); exit(-1); } puts("程序运行中..."); ftruncate(fd, 0); // 将文件长度截断为 0 sprintf(str, "%d\n", getpid()); // 如果获取锁成功,将进程的 PID 写入到该文件中 write(fd, str, strlen(str)); for ( ; ; ) sleep(1); exit(0); }
$ gcc app.c -o app $ ./app & [1] 3959 程序运行中... $ $ ./app 不能重复执行该程序! $ ./app 不能重复执行该程序! ...
-
在 Linux 系统中 /var/run/ 目录下有很多以 .pid 为后缀结尾的文件,这个实际上是为了保证程序以单例模式运行而设计的,作为程序实现单例模式运行所需的特定文件
- 如果要去实现一个以单例模式运行的程序,如一个守护进程,那么也应该将这个特定文件放置于 Linux 系统 /var/run/ 目录下,并且文件的命名方式为 name.pid(name 表示进程名)
$ cd /var/run $ ls acpid.pid boltd cups gdm3.pid log mount pppconfig shm sudo udev uuidd acpid.socket console-setup dbus initctl mlocate.daily.lock network rsyslogd.pid snapd-snap.socket systemd udisks2 vboxadd-service.sh alsa crond.pid dhclient-enp0s3.pid initramfs mono-xsp4 NetworkManager screen snapd.socket thermald user avahi-daemon crond.reboot gdm3 lock mono-xsp4.pid plymouth sendsigs.omit.d spice-vdagentd tmpfiles.d utmp