赚钱平台网站,创意工作室网站,杏坛网站建设,用wordpress做的网站有哪些参考引用 UNIX 环境高级编程 (第3版)嵌入式Linux C应用编程-正点原子 1. 进程与程序
1.1 main() 函数由谁调用#xff1f;
C 语言程序总是从 main 函数开始执行int main(void)
int main(int argc, char *argv[]) // 如果需要向应用程序传参#xff0c;则选择该种写法操作系… 参考引用 UNIX 环境高级编程 (第3版)嵌入式Linux C应用编程-正点原子 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.hstatic 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 IDPID进程号是一个正数用于唯一标识系统中的某一个进程使用 ps -aux 命令可以查看系统中进程相关信息 在某些系统调用中进程号可以作为传入参数有时也可作为返回值如系统调用 kill() 允许调用者向某一个进程通过进程号进行标识发送一个信号
1.4.1 getpid()
通过系统调用 getpid() 来获取本进程的进程号#include sys/types.h
#include unistd.hpid_t getpid(void);// 使用示例
pid_t pid getpid();
printf(本进程的 PID 为: %d\n, pid);1.4.2 getppid()
使用 getppid()系统调用获取父进程的进程号#include sys/types.h
#include unistd.hpid_t getppid(void);// 使用示例
// 获取本进程 pid
pid_t pid getpid();
printf(本进程的 PID 为: %d\n, pid);// 获取父进程 pid
pid getppid();
printf(父进程的 PID 为: %d\n, pid);2. 进程的环境变量
每一个进程都有一组与其相关的环境变量这些环境变量以字符串形式存储在一个字符串数组列表中把这个数组称为环境列表。其中每个字符串都是以 “名称值namevalue” 形式定义 使用 export 命令添加一个新的环境变量 $ export LINUX_APP123456 # 添加 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.hextern 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.hint 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/bin2.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() 函数为形如 namevalue 的字符串分配一块内存缓冲区并将参数 name 和参数 value 所指向的字符串复制到此缓冲区中以此来创建一个新的环境变量setenv() 与 putenv() 函数有两个区别 putenv() 函数并不会为 namevalue 字符串分配内存setenv() 可通过参数 overwrite 控制是否需要修改现有变量的值而仅以添加变量为目的putenv() 则不能 #include stdlib.h// name需要添加或修改的环境变量名称
// value环境变量的值
// overwrite若 name 标识的环境变量已存在且 overwrite 为 0setenv() 函数将不改变现有环境变量的值// 若 name 标识的环境变量已存在且 overwrite 为非 0则覆盖不存在则表示添加新的环境变量
// 返回值成功返回 0失败将返回-1并设置 errno
int setenv(const char *name, const char *value, int overwrite);示例 #include stdio.h
#include stdlib.hint main(int argc, char *argv[]) {if (argc 3) {fprintf(stderr, Error: 传入 name value\n);exit(-1);}/* 添加环境变量 */// 返回值成功返回 0失败将返回-1if (setenv(argv[1], argv[2], 0)) {perror(setenv error);exit(-1);}exit(0);
}2.2.3 unsetenv() 函数
unsetenv() 函数可以从环境变量表中移除参数 name 标识的环境变量#include stdlib.hint unsetenv(const char *name);2.3 清空环境变量
可以通过将全局变量 environ 赋值为 NULL 来清空所有变量environ NULL;也可通过 clearenv() 函数来操作 clearenv() 函数内部的做法其实就是将 environ 赋值为 NULL在某些情况下使用 setenv() 函数和 clearenv() 函数可能会导致程序内存泄漏setenv() 函数会为环境变量分配一块内存缓冲区随之成为进程的一部分而调用 clearenv() 函数时并没有释放该缓冲区反复调用这两个函数的程序会不断产生内存泄漏 #include stdlib.hint clearenv(void);3. 进程的内存布局 C 语言程序由以下几部分组成 正文段 也称代码段这是 CPU 执行的机器语言指令部分文本段具有只读属性以防程序由于意外而修改其指令正文段是可以共享的即使在多个进程间也可同时运行同一段程序 初始化数据段 通常将此段称为数据段包含了显式初始化的全局变量和静态变量当程序加载到内存中时从可执行文件中读取这些变量的值 未初始化数据段 包含了未进行显式初始化的全局变量和静态变量通常将此段称为 bssblock 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.hpid_t fork(void);创建子进程的作用 创建多个进程是任务分解时行之有效的方法提高系统的并发性即同时能够处理更多的任务或请求多个进程在宏观上实现同时运行 如何区分父、子进程 fork() 成功调用后将存在两个进程一个是原进程父进程另一个则是创建出来的子进程并且每个进程都会从 fork() 函数的返回处继续执行会导致调用 fork() 返回两次值子进程返回一个值父进程返回一个值 fork() 调用成功后将会在父进程中返回子进程的 PID而在子进程中返回值是 0如果调用失败父进程返回值 -1不创建子进程并设置 errno fork() 调用成功后子进程和父进程会继续执行 fork() 调用之后的指令 子进程、父进程各自在自己的进程空间中运行事实上子进程是父进程的一个副本如子进程拷贝了父进程的数据段、堆、栈以及继承了父进程打开的文件描述符父进程与子进程并不共享这些存储空间这是子进程对父进程相应部分存储空间的完全复制执行 fork() 之后每个进程均可修改各自的栈数据以及堆段中的变量而并不影响另一个进程但是两个进程执行相同的代码段因为代码段是只读的也就是说父、子进程共享代码段在内存中只存在一份代码段数据 示例 1使用 fork() 创建子进程 #include stdio.h
#include stdlib.h
#include unistd.hint 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.hint 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.hint 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.hint 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
# 因为父、子进程的这两个文件描述符分别指向不同的文件表意味着它们有各自的文件偏移量
# 一个进程修改了文件偏移量并不会影响另一个进程的文件偏移量所以写入的数据会出现覆盖的情况
1122112211221122fork() 函数使用场景 1、父进程希望子进程复制自己使父进程和子进程同时执行不同的代码段 这在网络服务进程中是常见的父进程等待客户端的服务请求当接收到客户端发送的请求事件后调用 fork() 创建一个子进程使子进程去处理此请求而父进程可以继续等待下一个服务请求 2、一个进程要执行不同的程序 如在程序 app1 中调用 fork() 函数创建子进程此时子进程是要去执行另一个程序 app2也就是子进程需要执行 app2 程序对应的代码子进程将从 app2 程序的 main 函数开始运行这种情况通常在子进程从 fork() 函数返回之后立即调用 exec 族函数来实现
7. 系统调用 vfork() 除了 fork() 系统调用之外Linux 系统还提供了 vfork() 系统调用用于创建子进程vfork() 与 fork() 函数在功能上是相同的并且返回值也相同在一些细节上存在区别 #include sys/types.h
#include unistd.hpid_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.hint 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.hstatic 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.hint 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)如果子进程正常终止则返回 trueWEXITSTATUS(status)返回子进程退出状态是一个数值其实就是子进程调用 _exit() 或 exit() 时指定的退出状态wait() 获取得到的 status 参数并不是调用 _exit() 或 exit() 时指定的状态可通过 WEXITSTATUS 宏转换WIFSIGNALED(status)如果子进程被信号终止则返回 trueWTERMSIG(status)返回导致子进程终止的信号编号。如果子进程是被信号所终止则可以通过此宏获取终止子进程的信号WCOREDUMP(status)如果子进程终止时产生了核心转储文件则返回 true 示例循环创建 3 个子进程并回收 #include stdio.h
#include stdlib.h
#include unistd.h
#include sys/types.h
#include sys/wait.h
#include errno.hint 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, 终止状态310.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示例 2waitpid() 轮训方式 将 waitpid() 函数的 options 参数添加 WNOHANG 标志将 waitpid() 配置成非阻塞模式使用轮训的方式依次回收各个子进程 #include stdio.h
#include stdlib.h
#include unistd.h
#include sys/types.h
#include sys/wait.h
#include errno.hint 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;} elseprintf(回收子进程%d, 终止状态%d\n, ret, WEXITSTATUS(status));}exit(0);
}$ gcc wait2.c -o wait2
$ ./wait2
子进程4047被创建
子进程4048被创建
子进程4049被创建
-----------------
回收子进程4047, 终止状态1
回收子进程4048, 终止状态2
回收子进程4049, 终止状态310.3 僵尸进程与孤儿进程
当一个进程创建子进程之后它们俩就成为父子进程关系父进程与子进程的生命周期往往是不相同的这里就会出现两个问题 父进程先于子进程结束子进程先于父进程结束
10.3.1 孤儿进程 父进程先于子进程结束此时子进程变成了一个 “孤儿”把这种进程称为孤儿进程 Linux 中所有孤儿进程都自动成为 init 进程进程号为 1的子进程换言之某一子进程的父进程结束后该子进程调用 getppid() 将返回 1init 进程变成了孤儿进程的 “养父”这是判定某一子进程的 “生父” 是否还 “在世” 的方法之一 孤儿进程测试 #include stdio.h
#include stdlib.h
#include unistd.hint 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.hint 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.hstatic 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 结束所指向的字符串格式为 namevalue
// 返回值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.hint main(int argc, char *argv[]) {char *arg_arr[5];char *env_arr[5] {NAMEapp, AGE25, SEXman, 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.hextern 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:NAMEappAGE25SEXman为什么需要在子进程中执行新程序 虽然可以直接在子进程分支编写子进程需要运行的代码但是不够灵活扩展性不够好直接将子进程需要运行的代码单独放在一个可执行文件中不是更好吗所以就出现了 exec 操作 11.2 exec 库函数 exec 族函数包括多个不同的函数这些函数命名都以 exec 为前缀这些库函数都是基于系统调用 execve() 实现的通常将调用这些 exec 函数加载一个外部新程序的过程称为 exec 操作 #include unistd.hextern 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] {NAMEapp, AGE25, SEXman, 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 进程的终止状态 system() 的主要优点在于使用上方便简单编程时无需自己处理对 fork()、exec 函数、waitpid() 以及 exit() 等调用细节system() 内部会代为处理但使用 system() 函数其效率会大打折扣如果程序对效率或速度有所要求那么不建议直接使用 system() 示例system() 函数使用 #include stdio.h
#include stdlib.hint 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); // 返回值总是调用者进程对应的进程组 IDgetpgrp() 就等价于 getpgid(0)
pid_t getpgrp(void); 调用系统调用 setpgid() 或 setpgrp() 可以加入一个现有的进程组或创建一个新的进程组#include unistd.hint 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 将作为该会话的标识也就是会话 IDsid在默认情况下新创建的进程会继承父进程的会话 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 就表示它是一个守护进程 守护进程与终端无任何关联用户的登录与注销与守护进程无关、不受其影响守护进程自成进程组、自成会话即 pidgidsid通过命令 “ps -ajx” 查看系统所有的进程 # TTY 一栏是问号 表示该进程没有控制终端也就是守护进程
# 其中 COMMAND 一栏使用中括号 [] 括起来的表示内核线程这些线程是在内核里创建
# 没有用户空间代码因此没有程序文件名和命令行通常采用 kKernel开头
$ ps -ajxPPID PID PGID SID TTY TPGID STAT UID TIME COMMAND0 1 1 1 ? -1 Ss 0 0:15 /sbin/init splash0 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 函数的先决条件 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 进程去处理这样既不会产生僵尸进程又省去了服务器进程回收子进程所占用的时间 示例守护进程 #include stdio.h
#include stdlib.h
#include unistd.h
#include sys/types.h
#include sys/stat.h
#include fcntl.h
#include signal.hint 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 COMMAND0 1 1 1 ? -1 Ss 0 0:19 /sbin/init splash0 2 0 0 ? -1 S 0 0:00 [kthreadd]
...1072 3714 3714 3714 ? -1 Ss 1000 0:00 ./dae2098 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.hint main(void) {signal(SIGHUP, SIG_IGN);for (; ;) {sleep(1);puts(进程运行中......);}
}$ gcc sighup.c -o sighup
$ ./sighup
进程运行中......
进程运行中......
进程运行中......
...# 关闭终端再重新打开终端使用下述命令查看 sighup 进程是否存在
$ ps -ajx | grep sighup3774 3810 3809 3774 pts/0 3809 S 1000 0:00 grep --colorauto sighup14. 单例模式运行
通常情况下一个程序可以被多次执行即程序在还没有结束的情况下又再次执行该程序也就是系统中同时存在多个该程序的实例化对象进程如聊天软件 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.lockstatic 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.pidint 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); // 将文件长度截断为 0sprintf(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.pidname 表示进程名 $ 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