进程
详细剖析进程,包括以下内容:
⚫ 程序与进程基本概念;
⚫ 程序的开始与结束;
⚫ 进程的环境变量与虚拟地址空间;
⚫ 进程 ID;
⚫ fork()创建子进程;
⚫ 进程的消亡与诞生;
⚫ 僵尸进程与孤儿进程;
⚫ 父进程监视子进程;
⚫ 进程关系与进程的六种状态;
⚫ 守护进程;
⚫ 进程间通信概述。
关于进程间通信简介,移步:https://blog.csdn.net/Thmos_vader/article/details/140743256
进程与程序
main()函数的调用
-
程序在运行 main()函数之前先执行一段引导代码,最终由这段引导代码去调用应用程序的 main()函数;
-
在编写应用程序的时候,不用考虑引导代码的问题,在编译链接时,由链接器将引导代码链接到我们的应用程序当中,一起构成最终的可执行文件;
-
对于操作系统下的应用程序来说,链接器和加载器都是很重要的角色;
原型
int main(void)
/*或*/
int main(int argc, char *argv[])
结束程序
程序结束其实就是进程终止,进程终止的方式分为正常终止和异常终止;
正常终止
⚫ main()函数中通过 return 语句返回来终止进程;
⚫ 应用程序中调用 exit()函数终止进程;
⚫ 应用程序中调用_exit()或_Exit()终止进程;
以上这些是在前面的课程中给大家介绍的,异常终止包括:
⚫ 应用程序中调用 abort()函数终止进程;
⚫ 进程接收到一个信号,譬如 SIGKILL 信号。
注册进程终止处理函数 atexit()
用于注册一个进程在正常终止时要调用的函数;
#include <stdlib.h>
int atexit(void (*function)(void));
/*
参数:
function:函数指针,指向注册的函数,此函数无需传入参数、无返回值.
返回值:
成功返回 0;
失败返回非 0.
实例
如果程序当中使用了_exit()或_Exit()终止进程而并非是 exit()函数,那么将不会执行注册的终止处理函数;
/*示例代码 9.1.1 atexit()函数使用示例*/
#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(0);
}
——————
——————
进程概念
-
进程其实就是一个可执行程序的实例,程序结束其实就是进程终止;
-
可执行程序就是一个可执行文件,文件是一个静态的概念,存放磁盘中;
-
进程是一个动态过程,而非静态文件,它是程序的一次运行过程;
进程号
-
每一个进程都有一个进程号(processID,简称 PID);
-
作用:用于唯一标识系统中某一个进程,在某些系统调用中,进程号可以作为传入参数、也可作为返回值;
-
ps 命令可以查到系统中进程相关的一些信息,包括每个进程的进程号,如下所示;
getpid()
系统调用 getpid()来获取本进程的进程号;
原型
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
/*函数返回值为 pid_t 类型变量,便是对应的进程号。
getppid()
系统调用getppid()获取父进程的进程号;
原型
#include <sys/types.h>
#include <unistd.h>
pid_t getppid(void);
——————
——————
进程的环境变量
-
环境列表:每个进程都有一组与其相关的环境变量,这些环境变量以字符串形式存储在一个字符串数组列表;
-
每个字符串都是以“名称=值(name=value)”形式定义,所以环境变量是“名称-值”的成对集合;
env 命令查看到 shell 进程的所有环境变量:
export 命令还可以添加一个新的环境变量或删除一个环境变量:
export LINUX_APP=123456 # 添加 LINUX_APP 环境变量
export -n LINUX_APP # 删除 LINUX_APP 环境变量
环境变量的作用
-
shell 中,每一个环境变量都有它所表示的含义,譬如 HOME 环境变量表示用户的家目录,USER 环境变量表示当前用户名,SHELL 环境变量表示 shell 解析器名称,PWD 环境变量表示当前所在目录等;
-
自己的应用程序当中,也可以使用进程的环境变量;
获取环境变量
获取环境变量environ
-
新的进程在创建之前,会继承其父进程的环境变量副本;
-
进程的环境变量是从其父进程中继承过来,应用程序当中也可以获取当前进程的环境变量;
-
应用程序中environ 是一个全局变量,通过 environ 变量指向环境变量存放的字符串数组;
原型
extern char **environ; // 申明外部全局变量 environ
典例
编写应用程序,获取进程的所有环境变量;
通过字符串数组元素是否等于 NULL 来判断是否已经到了数组的末尾;
/*示例代码 9.2.1 获取进程环境变量*/
#include <stdio.h>
#include <stdlib.h>
extern char **environ;
int main(int argc, char *argv[])
{
int i;
/* 打印进程的环境变量 */
for (i = 0; NULL != environ[i]; i++)
puts(environ[i]);
exit(0);
}
获取指定环境变量 getenv()
- 获取某个指定的环境变量,可以使用库函数 getenv();
- getenv()需要注意,不应该去修改其返回的字符串,修改该字符串意味着修改了环境变量对应的值;
- Linux 提供了相应的修改函数,如果需要修改环境变量的值应该使用这些函数,不应直接改动该字符串
原型
#include <stdlib.h>
char *getenv(const char *name);
/*
参数:
name:指定获取的环境变量名称。
返回值:
如果存放该环境变量,则返回该环境变量的值对应字符串的指针;
如果不存在该环境变量,则返回 NULL。
典例
/*示例代码 9.2.2 getenv()函数使用示例*/
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
const char *str_val = NULL;
if (2 > argc) {
fprintf(stderr, "Error: 请传入环境变量名称\n");
exit(-1);
}
/* 获取环境变量 */
str_val = getenv(argv[1]);
if (NULL == str_val) {
fprintf(stderr, "Error: 不存在[%s]环境变量\n", argv[1]);
exit(-1);
}
/* 打印环境变量的值 */
printf("环境变量的值: %s\n", str_val);
exit(0);
}
添加/删除/修改环境变量
-
C库提供修改、添加、删除环境变量的函数,譬如 **putenv()、setenv()、unsetenv()、clearenv()**函数等;
-
简单地方式向进程环境变量表中添加环境变量:
NAME=value ./app
# 在执行程序的时候,在其路径前面添加环境变量,以 name=value 的形式添加;
# 如果是多个环境变量,则在./app 前面放置多对 name=value 即可,以空格分隔.
putenv()函数
- 向进程的环境变量数组中添加一个新的环境变量,或者修改一个已经存在的环境变量对应的值;
原型
-
该函数调用成功之后,参数 string 所指向的字符串就成为了进程环境变量的一部分;
-
函数将设定 environ 变量(字符串数组)中的某个元素(字符串指针)指向该 string 字符串,而不是指向它的复制副本,因此,不能随意修改参数 string 所指向的内容,这将影响进程的环境变量;
#include <stdlib.h>
int putenv(char *string);
/*
参数:
string:参数 string 是一个字符串指针,指向 name=value 形式的字符串。
返回值:
成功返回 0;
失败将返回非 0 值,并设置 errno。
典例
使用 putenv()函数为当前进程添加一个环境变量;
示例代码 9.2.3 putenv()函数使用示例
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
if (2 > argc) {
fprintf(stderr, "Error: 传入 name=value\n");
exit(-1);
}
/* 添加/修改环境变量 */
if (putenv(argv[1])) {
perror("putenv error");
exit(-1);
}
exit(0);
}
setenv()函数
用于向进程的环境变量列表中添加一个新的环境变量或修改现有环境变量对应的值
原型
#include <stdlib.h>
int setenv(const char *name, const char *value, int overwrite);
/*
参数:
name:需要添加或修改的环境变量名称。
value:环境变量的值。
overwrite:若参数 name 标识的环境变量已经存在:
在参数 overwrite 为 0 的情况下,setenv()函数将不改变现有环境变量的值,本次调用没有产生任何影响;
在参数 overwrite 为非0的情况下,若参数 name标识的环境变量已存在,则覆盖,不存在则添加新的环境变量。
返回值:
成功返回 0;
失败将返回-1,并设置 errno。
典例
/*示例代码 9.2.4 setenv()函数使用示例*/
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
if (3 > argc) {
fprintf(stderr, "Error: 传入 name value\n");
exit(-1);
}
/* 添加环境变量 */
if (setenv(argv[1], argv[2], 0)) {
perror("setenv error");
exit(-1);
}
exit(0);
}
setenv()与 putenv()函数区别
setenv()函数为形如 name=value 的字符串分配一块内存缓冲区;并将参数 name 和参数 value 所指向的字符串复制到此缓冲区中,以此来创建一个新的环境变量;
⚫ putenv()函数并不会为 name=value 字符串分配内存;
⚫ setenv()可通过参数overwrite控制是否要修改现有变量的值而仅以添加变量为目的,putenv()不能进行控制。
⚫ 推荐大家使用 setenv()函数,这样使用自动变量作为 setenv()的参数也不会有问题。
unsetenv()函数
unsetenv()函数可以从环境变量表中移除参数 name 标识的环境变量
原型
#include <stdlib.h>
int unsetenv(const char *name);
清空环境变量
environ 赋值为 NULL
通过将全局变量 environ 赋值为 NULL来清空所有变量;
environ = NULL;
clearenv()函数
clearenv()函数内部的做法其实就是将environ赋值为NULL;
#include <stdlib.h>
int clearenv(void);
某些情况下,使用setenv()函数和clearenv()函数可能会导致程序内存泄漏:
调用 setenv()函数会为环境变量分配一块内存缓冲区,随之称为进程的一部分;
调用 clearenv()函数时没有释放该缓冲区(clearenv()调用并不知晓该缓冲区的存在,故而也无法将其释放);
反复调用者两个函数的程序,会不断产生内存泄漏。
——————
——————
进程的内存布局
C 语言程序段的构成以及运行时进程在内存中的布局方式;
C 程序组成
正文段:也可称为代码段,这是 CPU 执行的机器语言指令部分,文本段具有只读属性,以防止程序由于意外而修改其指令;正文段是可以共享的,即使在多个进程间也可同时运行同一段程序。
初始化数据段:通常将此段称为数据段,包含了显式初始化的全局变量和静态变量,当程序加载到内存中时,从可执行文件中读取这些变量的值。
未初始化数据段:包含了未进行显式初始化的全局变量和静态变量,通常将此段称为 bss 段,这一名词来源于早期汇编程序中的一个操作符,意思是“由符号开始的块”(block started by symbol),在程序开始执行之前,系统会将本段内所有内存初始化为 0,可执行文件并没有为 bss 段变量分配存储空间,在可执行文件中只需记录 bss 段的位置及其所需大小,直到程序运行时,由加载器来分配这一段内存空间。
栈:函数内的局部变量以及每次函数调用时所需保存的信息都放在此段中,每次调用函数时,函数传递的实参以及函数返回值等也都存放在栈中。
栈是一个动态增长和收缩的段,由栈帧组成,系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值。
堆:可在运行时动态进行内存分配的一块区域,譬如malloc()分配的内存空间,就是从系统堆内存中申请分配的。
查看段大小
Linux 下的 size 命令:查看二进制可执行文件的文本段、数据段、bss 段的段大小:
典型布局方式
这些段在内存中的典型布局方式,当然,并不要求具体的实现一定是以这种方式安排其存储空间:
进程的虚拟地址空间
虚拟内存管理技术
-
Linux 系统中,采用了虚拟内存管理技术,每一个进程都在自己独立的地址空间中运行;
-
程序中读写的内存地址对应也是虚拟地址;
-
32 位系统中,每个进程的逻辑地址空间均为 4GB,按用户进程 : 内核独自享有 为 3:1 的比例进行分配;
-
硬件“MMU” 会将物理地址“翻译”为对应的物理地址:
虚拟地址会通过硬件 MMU(内存管理单元)映射到实际的物理地址空间中;
建立虚拟地址到物理地址的映射关系后,对虚拟地址的读写操作实际上就是对物理地址的读写操作;
虚拟地址引入
计算机的物理内存的大小是固定,如果操作系统没有虚拟地址机制,程序访问的内存地址就是实际的物理地址;
所以要将所有应用程序加载到内存中,但是我们实际的物理内存只有 4G,所以就会出现一些问题:
⚫ 当多个程序需要运行时,必须保证这些程序用到的内存总量要小于计算机实际的物理内存的大小。
⚫ 内存使用效率低。内存空间不足时,就需要将其它程序暂时拷贝到硬盘中,然后将新的程序装入内存。
然而由于大量的数据装入装出,内存的使用效率就会非常低。
⚫ 进程地址空间不隔离。由于程序是直接访问物理内存的,所以每一个进程都可以修改其它进程的内存数据,
甚至修改内核地址空间中的数据,所以有些恶意程序可以随意修改别的进程,就会造成一些破坏,系统不安全、不稳定。
⚫ 无法确定程序的链接地址。程序运行时,链接地址和运行地址必须一致,否则程序无法运行!
因为程序代码加载到内存的地址是由系统随机分配的,是无法预知的,所以程序的运行地址在编译程序时是无法确认的。
目的
针对以上的一些问题,就引入了虚拟地址机制:逻辑地址映射、空间隔离;
程序访问存储器所使用的逻辑地址就是虚拟地址,通过逻辑地址映射到真正的物理内存上。
所有应用程序运行在自己的虚拟地址空间中,使得进程的虚拟地址空间和物理地址空间隔离开来;
优点
隔离、共享内存、内存保护机制、无需关心链接地址;
⚫ 进程与进程、进程与内核相互隔离。
一个进程不能读取或修改另一个进程或内核的内存数据,这是因为每一个进程的虚拟地址空间映射到了不同的物理地址空间。提高了系统的安全性与稳定性。
⚫ 在某些应用场合下,两个或者更多进程能够共享内存。
因为每个进程都有自己的映射表,可以让不同进程的虚拟地址空间映射到相同的物理地址空间中。通常,共享内存可用于实现进程间通信。
⚫ 便于实现内存保护机制。
譬如在多个进程共享内存时,允许每个进程对内存采取不同的保护措施,例如,一个进程可能以只读方式访问内存,而另一进程则能够以可读可写的方式访问。
⚫ 编译应用程序时,无需关心链接地址。
前面提到了,当程序运行时,要求链接地址与运行地址一致,
——————
——————
fork()创建子进程
-
现有的进程可以调用 fork()函数创建一个新的进程;
-
调用 fork()函数的进程称为父进程,由 fork()函数创建出来的进程被称为子进程(child process);
-
父子进程共享代码段,在内存中只存在一份代码段数据;
-
fork()函数完成之后,父、子进程各自继续 fork()之后的指令,最终父进程 exit()结束,而子进程_exit()结束;
原型
fork()为系统调用;
#include <unistd.h>
pid_t fork(void);
/*
调用成功: 将会在父进程中返回子进程的 PID,而在子进程中返回值是 0;
调用失败: 父进程返回值-1,不创建子进程,并设置 errno。
实例1
使用 fork()创建子进程:
fork()之后的语句被执行了两次,所以 switch…case 语句被执行了两次;
第一次进入到了"case 0"分支,通过上面的介绍可知,fork()返回值为 0 表示当前处于子进程;在子进程中我们通过 getpid()获取到子进程自己的 PID(46802),通过 getppid()获取到父进程的 PID(46803),将其打印出来。
第二次进入到了 default 分支,表示当前处于父进程,此时 fork()函数的返回值便是创建出来的子进程对应PID。
fork()函数调用完成之后,父、子进程各自继续 fork()之后的指令,最终父进程 exit()结束,而子进程_exit()结束;
/*示例代码 9.5.1 fork()使用示例*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
pid_t pid;
pid = fork(); //父子各自继续 fork()之后的指令
switch (pid)
{
case -1:
perror("fork error");
exit(-1);
case 0:
printf("这是子进程打印信息<pid: %d, 父进程 pid: %d>\n",getpid(), getppid());
_exit(0); //子进程使用_exit()退出
default:
printf("这是父进程打印信息<pid: %d, 子进程 pid: %d>\n",getpid(), pid);
exit(0);
}
}
Tips:C 库函数 exit()建立在系统调用-exit()之上;
在调用了 fork()之后,父、子进程中一般只有一个会通过调用 exit()退出进程,而另一个则应使用_exit()退出;
实例2
在 exit()函数之前添加了打印信息,而从上图中可以知道,打印的 pid 值并不相同,0 表示子进程打印出来的,46953 表示的是父进程打印出来的;
证实:
fork()函数调用完成之后,父进程、子进程会各自继续执行 fork()之后的指令;
它们共享代码段,但并不共享数据段、堆、栈等,而是子进程拥有父进程数据段、堆、栈等副本;
所以对于同一个局部变量,打印出来的值不同,因fork()调用返回值不同,在父、子进程中赋予了 pid 不同的值。
/*示例代码 9.5.2 fork()函数使用示例 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);
}
}
关于子进程
父子进程间的文件共享
共享的两种方式
- 接续写与覆盖: open 打开文件 与 调用 fork()创建子进程的先后!
简介
-
调用 fork()函数之后,子进程会获得父进程所有文件描述符的副本,这些副本的创建方式类似于 dup();
-
这也意味着父、子进程对应的文件描述符均指向相同的文件表,如下图所示:
子进程拷贝了父进程的文件描述符表,使得父、子进程中对应的文件描述符指向了相同的文件表;
也意味着父、子进程中对应的文件描述符指向了磁盘中相同的文件,因而这些文件在父、子进程间实现了共享。
譬如,如果子进程更新了文件偏移量,那么这个改变也会影响到父进程中相应文件描述符的位置偏移量!!!
典例1
-
父进程 open 打开文件之后,才调用 fork()创建子进程,所以子进程继承了父进程打开的文件描述符 fd;
-
我们需要验证的便是两个进程对文件的写入操作是分别各自写入、还是每次都在文件末尾接续写;
/*示例代码 9.6.1 子进程继承父进程文件描述符实现文件共享*/
#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;
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);
}
}
-
父、子进程分别对同一个文件进行写入操作,结果是接续写,不管是父进程,还是子进程,在每次写入时都是从文件的末尾写入,很像使用了 O_APPEND 标志的效果;
-
子进程继承了父进程的文件描述符,两个文件描述符都指向了一个相同的文件表,意味着它们的文件偏移量是同一个、绑定在了一起,相互影响;
-
子进程改变文件的位置偏移量就会作用到父进程,同理,父进程改变了文件的位置偏移量就会作用到子进程。
典例2
- 父进程在调用 fork()之后,此时父进程和子进程都去打开同一个文件,然后再对文件进行写入操作;
/*示例代码 9.6.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 (0 > fd)
{
perror("open error");
_exit(-1);
}
for (i = 0; i < 4; i++) //循环写入 4 次
write(fd, "1122", 4);
close(fd);
_exit(0);
default:
/* 父进程 */
fd = open("./test.txt", O_WRONLY);
if (0 > fd)
{
perror("open error");
exit(-1);
}
for (i = 0; i < 4; i++) //循环写入 4 次
write(fd, "AABB", 4);
close(fd);
exit(0);
}
}
父进程调用 fork()之后,然后在父、子进程中都去打开 test.txt 文件,然后在对其进行写入操作,子进程调用了 4 次 write、每次写入“1122”;而父进程调用了 4 次 write、每次写入“AABB”;
-
这种文件共享方式实现的是一种两个进程分别各自对文件进行写入操作;
-
因为父、子进程的这两个文件描述符分别指向的是不同的文件表,意味着它们有各自的文件偏移量,一个进程修改了文件偏移量并不会影响另一个进程的文件偏移量,所以写入的数据会出现覆盖的情况。
fork()使用场景
fork()函数有以下两种用法:
⚫ 父进程希望子进程复制自己,使父进程和子进程同时执行不同的代码段。
这在网络服务进程中是常见的,父进程等待客户端的服务请求,当接收到客户端发送的请求事件后,
调用 fork()创建一个子进程,使子进程去处理此请求、而父进程可以继续等待下一个服务请求。
⚫ 一个进程要执行不同的程序。
譬如在程序 app1 中调用 fork()函数创建了子进程,此时子进程是要去执行另一个程序 app2,也就是子进程需要执行的代码是 app2 程序对应的代码,子进程将从 app2程序的 main 函数开始运行。
这种情况,通常在子进程从 fork()函数返回之后立即调用 exec 族函数来实现,关于 exec 函数将在后面内容介绍。
系统调用 vfork()
-
Linux 系统还提供了 vfork()系统调用用于创建子进程;
-
vfork()与 fork()函数在功能上是相同的,并且返回值也相同,在一些细节上存在区别。
原型
/*使用该函数需要包含头文件<sys/types.h>和<unistd.h>。*/
#include <sys/types.h>
#include <unistd.h>
pid_t vfork(void);
fork缺陷与改进
缺陷
fork()认作对父进程的数据段、堆段、栈段以及其它一些数据结构创建拷贝,
由此可见,使用 fork()系统调用的代价是很大的,它复制了父进程中的数据段和堆栈段中的绝大部分内容,这将会消耗比较多的时间,效率会有所降低,而且太浪费;
原因有很多,其中之一在于,fork()函数之后子进程**通常会调用 exec 函数**,也就是 fork()第二种使用场景下,这使得子**进程不再执行父程序中的代码段**,而是执行新程序的代码段,从新程序的 main 函数开始执行、并为新程序重新初始化其数据段、堆段、栈段等;
那么在这种情况下,子进程并不需要用到父进程的数据段、堆段、栈段(譬如父程序中定义的局部变量、全局变量等)中的数据,此时就会导致浪费时间、效率降低。
现代 Linux 系统内核采用了写时复制(**copy-on-write**)技术改进
改进
-
引入了 vfork()系统调用,虽然在一些细节上有所不同,但其效率要高于 fork()函数。
-
类似于 fork(),vfork()可以为调用该函数的进程创建一个新的子进程,vfork()是为子进程立即执行 exec()新的程序而专门设计的,也就是 fork()函数的第二个使用场景。
vfork&fork()区别
- 我们的程序当中应舍弃 vfork()而使用 fork(),除非速度绝对重要的场合;
⚫ vfork()与 fork()一样都创建了子进程,但 vfork()函数并不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用 exec(或-exit),于是也就不会引用该地址空间的数据。
不过在子进程调用 exec 或-exit 之前,它在父进程的空间中运行、子进程共享父进程的内存。这种优化工作方式的实现提高的效率;但如果子进程修改了父进程的数据(除了 vfork 返回值的变量)、进行了函数调用、或者没有调用 exec 或_exit 就返回将可能带来未知的结果。
⚫ 另一个区别在于,vfork()保证子进程先运行,子进程调用 exec 之后父进程才可能被调度运行。
虽然 vfork()系统调用在效率上要优于 fork(),但是 vfork()可能会导致一些难以察觉的程序 bug,所以尽量避免使用 vfork()来创建子进程,虽然 **fork()在效率上并没有 vfork()高,但是现代的 Linux 系统内核已经采用了写时复制技术来实现 fork(),其效率较之于早期的 fork()实现要高出许多**,除非速度绝对重要的场合,
典例
- 正式的使用场合下,一般应在子进程中立即调用 exec,如果 exec 调用失败,子进程则应调用_exit()退出(vfork 产生的子进程不应调用 exit 退出,因为这会导致对父进程 stdio 缓冲区的刷新和关闭)。
/*示例代码 9.7.1 vfork()函数使用示例*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
int main(void)
{
pid_t pid;
int num = 100;
pid = vfork();
switch (pid)
{
case -1:
perror("vfork error");
exit(-1);
case 0:
/* 子进程 */
printf("子进程打印信息\n");
printf("子进程打印 num: %d\n", num);
_exit(0);
default:
/* 父进程 */
printf("父进程打印信息\n");
printf("父进程打印 num: %d\n", num);
exit(0);
}
}
上述示例代码只是一个简单地演示,并不是 vfork()的真正用法,后面学习到 exec 还会介绍;
fork()之后的竞争条件
-
父子依赖于特定的执行顺序,则需采用某种同步技术来实现;
-
譬如前面给大家介绍的信号,如果要让子进程先运行,则可使父进程被阻塞,等到子进程来唤醒它;
问题引入
调用 fork 之后,无法确定父、子两个进程谁将率先访问 CPU;
典例
-
调用 fork()之后,子进程成为了一个独立的进程,可被系统调度运行,而父进程也继续被系统调度运行;
-
出现了一个问题,调用 fork 之后,无法确定父、子两个进程谁将率先访问 CPU,也就是说无法确认谁先被系统调用运行(在多核处理器中,它们可能会同时各自访问一个 CPU);
-
将导致谁先运行、谁后运行这个顺序是不确定的。
/*示例代码 9.8.1 fork()竞争条件测试代码*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
switch (fork()) {
case -1:
perror("fork error");
exit(-1);
case 0:
原子哥在线教学:www.yuanzige.com 论坛:http://www.openedv.com/forum.php
330
I.MX6U 嵌入式 Linux C 应用编程指南
/* 子进程 */
printf("子进程打印信息\n");
_exit(0);
default:
/* 父进程 */
printf("父进程打印信息\n");
exit(0);
}
}
虽然绝大部分情况下,父进程会先于子进程被执行,但是并不排除子进程先于父进程被执行的可能性;
确保执行顺序
-
通过采用采用某种同步技术来实现,;
-
譬如前面给大家介绍的信号,如果要让子进程先运行,则可使父进程被阻塞,等到子进程来唤醒它;
典例
-
希望子进程先运行打印相应信息,之后再执行父进程打印信息;
-
在父进程分支中,直接调用了 sigsuspend()使父进程进入挂起状态,由子进程通过 kill 命令发送信号唤醒;
示例代码 9.8.2 利用信号来调整进程间动作
#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 (-1 == sigaction(SIGUSR1, &sig, NULL)) {
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); //发送信号给父进程、唤醒它
_exit(0);
default:
/* 父进程 */
if (-1 != sigsuspend(&wait_mask))//挂起、阻塞
exit(-1);
printf("父进程开始执行\n");
printf("父进程打印信息\n");
exit(0);
}
}
——————
——————
进程的诞生与终止
-
init 进程的 PID 总是为 1,它是所有子进程的父进程,一切从 1 开始、一切从 init 进程开始!
-
一个进程的生命周期便是从创建开始直至其终止。
诞生
-
一个进程可以通过 fork()或 vfork()等系统调用创建一个子进程,一个新的进程就此诞生!
-
Linux系统下的所有进程都是由其父进程创建而来,譬如在 shell 终端通过命令的方式执行一个程序./app,那么 app进程就是由 shell 终端进程创建出来的,shell 终端就是该进程的父进程。
-
最原始的父进程:"ps -aux"命令可以查看到系统下所有进程信息
-
进程号为 1 的进程便是所有进程的父进程,通常称为 init 进程;
-
它管理着系统上所有其它进程,init 进程是由内核启动,因此理论上说它没有父进程;
终止
exit与-exit区别?
异常终止
- 譬如在程序当中调用 abort()函数异常终止进程、当进程接收到某些信号导致异常终止等;
进程的正常终止
- 譬如在 main 函数中使用 return 返回、调用 exit()函数结束进程、调用_exit()或_Exit()函数结束进程等;
_exit()函数和 exit()函数的 status 参数定义了进程的终止状态,父进程可以调用 wait()函数以获取该状态;
虽然参数 status 定义为 int 类型,但仅有低 8 位表示它的终止状态;
一般来说,终止状态为 0 表示进程成功终止,而非 0 值则表示进程在执行过程中出现了一些错误而终止;
譬如文件打开失败、读写失败等等,对非 0 返回值的解析并无定例.
-
一般**使用 exit()库函数而非-exit()**系统调用,原因在于 exit()最终也会通过_exit()终止进程;
exit()调用_exit()之前执行的动作:
⚫ 如果程序中注册了进程终止处理函数,那么会调用终止处理函数。 ⚫ 刷新 stdio 流缓冲区。关于 stdio 流缓冲区的问题,稍后编写一个简单地测试程序进行说明; ⚫ 执行_exit()系统调用。
-
程序当中,父、子进程不应都使用 exit()终止,只能有一个进程使用 exit()、而另一个则使用_exit()退出;
原因:调用 exit()函数终止进程时会刷新进程的 stdio 缓冲区!!!
典例1
- 在 fork()创建子进程之前,我们通过 printf()打印了一行包括换行符\n 在内字符串;
- 在fork()创建子进程之后,都使用 exit()退出进程,正常的情况下程序就只会打印一行"Hello World!"
/*示例代码 9.9.1 exit()之 stdio 缓冲测试代码 1*/
#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);
}
}
典例2
把 printf()打印的字符串最后面的换行符\n去掉!
示例代码 9.9.2 exit()之 stdio 缓冲测试代码 2
#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 换行
符,对于 puts()函数来说,本身会自动添加换行符;
⚫ 在调用 fork()之前,使用函数 fflush()来刷新 stdio 缓冲区,当然,作为另一种选择,也可以使用
setvbuf()和 setbuf()来关闭 stdio 流的缓冲功能,这些内容在 3.11 中已经给大家介绍过;
⚫ 子进程调用_exit()退出进程、而非使用 exit(),调用_exit()在退出时便不会刷新 stdio 缓冲区,这也
解释前面为什么我们要在子进程中使用_exit()退出这样做的一个原因。将示例代码 9.9.2 中子进程
的退出操作 exit()替换成_exit()进行测试,打印的结果便只会显示一次字符串,大家自己动手试一试!
监视子进程
-
应用程序的设计中,父进程需要知道子进程于何时被终止,并且需要知道子进程的终止状态信息:
正常终止、还是异常终止亦或者被信号终止,意味着父进程会对子进程进行监视
-
学习下如何通过系统调用 wait()以及其它变体来监视子进程的状态改变;
函数wait()
- 设计需求监视子进程的终止时间以及终止时的一些状态信息;
- 系统调用 wait()可以等待进程的任一子进程终止,同时获取子进程的终止状态信息;
- 创建任何子进程之前,为 SIGCHLD 信号绑定处理函数,确保无僵尸进程存在且主进程可实现异步;
原型
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
/*
使用该函数需要包含头文件<sys/types.h>和<sys/wait.h>。
参数:
status:参数 status 用于存放子进程终止时的状态信息,
参数 status 可以为 NULL,表示不接收子进程;
终止时的状态信息。
返回值:
若成功则返回终止的子进程对应的进程号;
失败则返回-1。
wait()执行动作
-
调用 wait()函数,如果其所有子进程都还在运行,则 wait()会一直阻塞等待,直到某一个子进程终止;
-
如果进程调用 wait(),但是该进程并没有子进程,也就意味着该进程并没有需要等待的子进程;
那么 wait()将返回错误,也就是返回-1、并且会将 errno 设置为 ECHILD。
-
如果进程调用 wait()之前,它的子进程当中已经有一个或多个子进程已终止,那么调用 wait()也不会阻塞。
wait()函数的作用除了获取子进程的终止状态信息之外,更重要的一点,就是回收子进程的一些资源,俗称为子进程“收尸”,后面详细介绍。
所以在调用 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; 其它的宏定义,具体查看 man 手册!
典例
通过 for 循环创建了 3 个子进程,父进程中循环调用 wait()函数等待回收子进程,并将本次回收的子进程进程号以及终止状态打印出来;
/*示例代码 9.10.1 wait()函数使用示例*/
#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 (-1 == ret)
{
if (ECHILD == errno)
{
printf("没有需要等待回收的子进程\n");
exit(0);
}
else
{
perror("wait error");
exit(-1);
}
}
printf("回收子进程<%d>, 终止状态<%d>\n", ret,WEXITSTATUS(status));
}
exit(0);
}
函数waitpid()
-
使用 wait()系统调用存在着一些限制:
⚫ 如果父进程创建了多个子进程,使用 wait()将无法等待某个特定的子进程的完成, 只能按照顺序等待下一个子进程的终止,一个一个来、谁先终止就先处理谁; ⚫ 如果子进程没有终止,正在运行,那么 wait()总是保持阻塞, 有时我们希望执行非阻塞等待,是否有子进程终止,通过判断即可得知; ⚫ 使用 wait()只能发现那些被终止的子进程,对于子进程因某个信号(譬如 SIGSTOP 信号)而停止 (注意,这里停止指的暂停运行),或是已停止的子进程收到 SIGCONT 信号后恢复执行的情况就无能为力.
-
waitpid()在功能上要强于 wait(),弥补 wait()函数一些限制,具体编程使用中,可根据自己的需求进行选择;
原型
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
/*
参数:
pid:参数 pid 用于表示需要等待的某个具体子进程,关于参数 pid 的取值范围如下:
⚫ 如果 pid 大于 0,表示等待进程号为 pid 的子进程;
⚫ 如果 pid 等于 0,则等待与调用进程(父进程)同一个进程组的所有子进程;
⚫ 如果 pid 小于-1,则会等待进程组标识符与 pid 绝对值相等的所有子进程;
⚫ 如果 pid 等于-1,则等待任意子进程。wait(&status)与 waitpid(-1, &status, 0)等价。
status:与 wait()函数的 status 参数意义相同。
options:稍后介绍。
返回值:
返回值与 wait()函数的返回值意义基本相同,
在参数 options 包含了 WNOHANG 标志的情况下,返回值会出现 0,稍后介绍。
参数 options 是一个位掩码,可以包括 0 个或多个如下标志:
⚫ WNOHANG:如果子进程没有发生状态改变(终止、暂停),则立即返回,也就是执行非阻塞等待,
可以实现轮训 poll,通过返回值可以判断是否有子进程发生状态改变,若返回值等于 0 表示没有发生改变。
⚫ WUNTRACED:除返回终止的子进程的状态信息外,还返回因信号而停止(暂停运行)的子进程状态信息;
⚫ WCONTINUED:返回那些因收到 SIGCONT 信号而恢复运行的子进程的状态信息。
典例1
将 示例代码 9.10.1的wait(&status)替换成 waitpid(-1, &status, 0);
waitpid参数配置情况与 wait()完全等价!结果相同,这里不再演示
典例2
waitpid()函数的 options 参数添加 WNOHANG 标志,将 waitpid()配置成非阻塞模式,
修改成轮训方式,使用轮训的方式依次回收各个子进程;
/*示例代码 9.10.3 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 (0 > ret)
{
if (ECHILD == errno)
exit(0);
else
{
perror("wait error");
exit(-1);
}
}
else if (0 == ret)
continue;
else
printf("回收子进程<%d>, 终止状态<%d>\n", ret,WEXITSTATUS(status));
}
exit(0);
}
函数waitid()
waitid()与 waitpid()类似,不过 waitid()提供了更多的扩展功能;
具体的使用方法通过 man学习!
僵尸进程与孤儿进程
父进程与子进程的生命周期往往是不相同的
⚫ 父进程先于子进程结束:孤儿进程,所有的孤儿进程都自动成为 init 进程(进程号为 1)的子进程;
⚫ 子进程先于父进程结束:
孤儿进程
- 判定子进程的“生父”是否还“在世”方法之一:某一子进程的父进程结束后,该子进程调用 getppid()将返回 1,init 进程变成了孤儿进程的“养父”;
典例
子进程休眠 3 秒钟,保证父进程先结束,而父进程休眠 1 秒钟,保证子进程能够打印出第一个 printf(),也就是在父进程结束前,打印子进程的父进程进程号;
子进程 3 秒休眠时间过后,再次打印父进程的进程号,此时它的“生父”已经结束了!
/*示例代码 9.10.4 孤儿进程测试*/
#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);
}
打印结果并不是 1,意味着并不是 init 进程,而是 1911,通过"ps -axu"查询知,进程号 1911 对应 upstart进程:
/sbin/upstart 进程与 Ubuntu 系统图形化界面有关系,是图形化界面的后台守护进程,可负责“收养”孤儿进程;
僵尸进程
-
进程结束之后,通常需要其父进程为其“收尸”,回收子进程占用的一些内存资源,父进程通过调用wait()(或其变体 waitpid()、waitid()等)函数回收子进程资源,归还给系统;
-
如果子进程先于父进程结束,此时父进程还未来得及给子进程“收尸”,此时子进程就变成了一个僵尸进程;
-
当父进程调用 wait()(或其变体,下文不再强调)为子进程“收尸”后,僵尸进程就会被内核彻底删除。
如果父进程未调用 wait()就退出,此时 init 进程会接管它的子进程并自动调用 wait(),从系统中移除僵尸进程
注意
如果父进程创建了某一子进程,子进程已经结束,而父进程还在正常运行,但父进程并未调用 wait()回收子进程,此时子进程变成一个僵尸进程。
首先来说,这样的程序设计是有问题的,如果系统中存在大量的僵尸进程,它们势必会填满内核进程表,从而阻碍新进程的创建。需要注意的是,僵尸进程是无法通过信号将其杀死的,即使是“一击必杀”信号 SIGKILL 也无法将其杀死,那么这种情况下,只能杀死僵尸进程的
父进程(或等待其父进程终止),这样 init 进程将会接管这些僵尸进程,从而将它们从系统中清理掉!所以,在我们的一个程序设计中,一定要监视子进程的状态变化,如果子进程终止了,要调用 wait()将其回收,避免僵尸进程!
典例
子进程已经退出,但其父进程并没调用 wait()为其“收尸”,使得子进程成为一个僵尸进程;
/*示例代码 9.10.5 产生僵尸进程*/
#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);
}
使用命令"ps -aux"可以查看到该僵尸进程:
可查看到子进程 113456 依然存在,可以看到其状态栏显示的是“Z”(zombie,僵尸),表它是一个僵尸进程;
僵尸进程无法被信号杀死,要么等待其父进程终止、要么杀死其父进程,让 init 进程来处理;
当我们杀死其父进程之后,僵尸进程也会被随之清理;
SIGCHLD 信号
应在创建任何子进程之前,为 SIGCHLD 信号绑定处理函数;
应用:实现异步方式监视子进程,避免僵尸进程的产生;
信号介绍
- 以下两种情况时,父进程会收到该信号:
⚫ 当父进程的**某个子进程终止**时,父进程会收到 SIGCHLD 信号;
⚫ 当父进程的**某个子进程因收到信号而停止(暂停运行)或恢复**时,内核也可能向父进程发送该信号。
- 通过 SIGCHLD 信号解决尴尬情况:
子进程的终止属于异步事件,父进程事先是无法预知的,如果父进程有自己需要做的事情,
它不**能一直wait()阻塞等待子进程终止**(或轮训),这样父进程将啥事也做不了;
解决方法:SIGCHLD 信号处理函数中循环以非阻塞方式来调用 waitpid(),直至无其它终止的子进程需要处理为止;
- 通常SIGCHLD 信号处理函数内部代码
while (waitpid(-1, NULL, WNOHANG) > 0)
continue;
/* 上述代码一直循环下去,直至 waitpid()返回 0,表明再无僵尸进程存在;
或者返回-1,表明有错误发生。
实际应用
系统默认处理方式是将其忽略,要捕获它、绑定信号处理函数
-
那既然子进程状态改变时(终止、暂停或恢复),父进程会收到 SIGCHLD 信号,SIGCHLD 信号的系统默认处理方式是将其忽略;
-
所以我们要捕获它、绑定信号处理函数,在信号处理函数中调用 wait()收回子进程,回收完毕之后再回到父进程自己的工作流程中;
典例
通过 SIGCHLD 信号实现异步方式监视子进程。
/*示例代码 9.10.6 异步方式监视 wait 回收子进程*/
#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);
}
执行新程序
-
如何在程序中运行一个新的程序,从新程序的 main()函数开始运行?
-
子进程的工作非运行父进程的代码段,而是运行另一新程序代码,此时子进程用 exec 函数来实现运行;
-
可执行新程序,亦可执行shell命令;
函数execve()
- 将新程序加载到某一进程的内存空间
将一个外部的可执行文件加载到进程的内存空间运行,新的程序替换旧的程序;
而进程的栈、数据、以及堆数据会被新程序的相应部件所替换,然后从新程序的 main()函数开始执行;
- 对 execve()的成功调用将永不返回,也无需检查它的返回值,实际上,一旦该函数返回,就表明它发生错误!
原型
系统调用 execve(),还提供了一系列以 exec 为前缀命名的库函数:
函数参数各异,当其功能相同,通常将这些函数(包括系统调用 execve())称为 exec 族函数;
exec 函数是 exec 族函数、而非指某一个函数,常将调用这些 exec 函数加载一个外部新程序的过程称 exec 操作
#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp[]);
/*
参数:
filename:指向需要载入当前进程空间的新程序的路径名,既可以是绝对路径、也可以是相对路径。
argv: 指定了传递给新程序的命令行参数,是一个字符串数组。
该数组对应于 main(int argc, char *argv[])函数的第二个参数 argv;且格式也与之相同,是由字符串指针 所组成的数组,以 NULL 结束。argv[0]对应的便是新程序自身路径名。
envp:参数 envp 也是一个字符串指针数组,指定了新程序的环境变量列表,
参数 envp 其实对应于新程序的 environ 数组,同样也是以 NULL 结束,所指向字符串格式为name=value。
返回值:
execve 调用成功将不会返回;
失败将返回-1,并设置 errno。
典例
编写一个简单地程序,在测试程序 testApp 当中通过 execve()函数运行另一个新程序 newApp。
/*示例代码 9.11.1 execve()函数使用示例*/
#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);
}
上述程序编译成一个可执行文件 testApp。
接着编写新程序,在新程序当中打印出环境变量和传参:
/*示例代码 9.11.2 新程序*/
#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);
}
testApp 程序中,成功通过 execve()运行了另一个新的程序 newApp,
当newApp 程序运行完成退出后,testApp 进程就结束了。
库函数exec(族)
-
execve()函数也属于 exec 族函数中的一员,但它属于系统调用;
-
exec 族函数包括多个不同的函数,这些函数命名都以 exec 为前缀;
exec 族函数中的库函数,基于系统调用 execve()而实现;
-
参数各异、但功能相同,包括:execl()、execlp()、execle()、execv()、execvp()、execvpe();
原型
#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()
同
execl()和 execv()都是基本的 exec 函数,都可用于执行一个新程序;
异
- 参数 path 意义和格式都相同,与系统调用 execve()的 filename 参数相同,指向新程序的路径名;
- execl()和 execv()不同的在于第二个参数:
- 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 所指定的目录列表中寻找相应的可执行文件,,执行的新程序是 Linux 命令有用;
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);
exec 族函数使用示例
介绍的 6 个 exec 库函数运行 ls 命令,并加入参数-a 和-l;
//1、execl()函数运行 ls 命令。
/*示例代码 9.11.3 execl 执行 ls 命令*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
execl("/bin/ls", "ls", "-a", "-l", NULL);
perror("execl error");
exit(-1);
}
//2、execv()函数运行 ls 命令。
/*示例代码 9.11.4 execv()执行 ls 命令*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
char *arg_arr[5];
arg_arr[0] = "ls";
arg_arr[1] = "-a";
arg_arr[2] = "-l";
arg_arr[3] = NULL;
execv("/bin/ls", arg_arr);
perror("execv error");
exit(-1);
}
//3、execlp()函数运行 ls 命令。
/*示例代码 9.11.5 execlp()执行 ls 命令*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
execlp("ls", "ls", "-a", "-l", NULL);
perror("execlp error");
exit(-1);
}
//4、execvp()函数运行 ls 命令。
/*示例代码 9.11.6 execvp()执行 ls 命令*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
char *arg_arr[5];
arg_arr[0] = "ls";
arg_arr[1] = "-a";
arg_arr[2] = "-l";
arg_arr[3] = NULL;
execvp("ls", arg_arr);
perror("execvp error");
exit(-1);
}
//5、execle()函数运行 ls 命令。
/*示例代码 9.11.7 execle()执行 ls 命令*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
extern char **environ;
int main(void)
{
execle("/bin/ls", "ls", "-a", "-l", NULL, environ);
perror("execle error");
exit(-1);
}
//6、execvpe()函数运行 ls 命令。
/*示例代码 9.11.8 execvpe()执行 ls 命令*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
extern char **environ;
int main(void)
{
char *arg_arr[5];
arg_arr[0] = "ls";
arg_arr[1] = "-a";
arg_arr[2] = "-l";
arg_arr[3] = NULL;
execvpe("ls", arg_arr, environ);
perror("execvpe error");
exit(-1);
}
以上所有的这些示例代码,运行结果都是一样的,与"ls -al"命令效果相同:
system()函数
-
方便我们的程序当中执行任意 shell 命令;
-
内部的是通过**调用 fork()、execl()以及 waitpid()**这三个函数来实现它的功能;
-
牺牲效率为代价,对效率或速度有所要求,那么建议大家不是直接使用 system()
/* 首先 system()会调用 fork()创建子进程来运行 shell(可以把这个子进程成为 shell 进程); 并通过 shell 执行参数command 所指定的命令;譬如:*/ system("ls -la") system("echo HelloWorld")
原型
#include <stdlib.h>
int system(const char *command);
/*
参数:
command:
参数 command 指向需要执行的 shell 命令;
以字符串的形式提供,譬如"ls -al"、"echo HelloWorld"等。
返回值:
关于 system()函数的返回值有多种不同的情况,:
⚫ 当参数 command 为 NULL,如果 shell 可用则返回一个非 0 值,若不可用则返回 0;
针对一些非UNIX 系统,该系统上可能是没有 shell 的,这样就会导致 shell 不可能;
如果 command 参数不为NULL,则返回值从以下的各种情况所决定。
⚫ 如果无法创建子进程或无法获取子进程的终止状态,那么 system()返回-1;
⚫ 如果子进程不能执行 shell,则 system()的返回值就好像是子进程通过调用_exit(127)终止了;
⚫ 如果所有的系统调用都成功,system()函数会返回执行 command 的 shell 进程的终止状态。
优缺
优点:
使用上方便简单,编程时无需自己处理对 fork()、exec 函数、waitpid()
以及 exit()等调用细节,system()内部会代为处理;
缺点:
以牺牲效率为代价的,使用 system()运行 shell命令需要至少创建两个进程,
一个进程用于运行 shell、另外一个或多个进程则用于运行参数 command 中解析出来的命令,
每一个命令都会调用一次 exec 函数来执行;
典例
执行测试程序,将需要执行的命令通过参数传给 main()函数,在 main 函数中调用 system()来执行该命令
/*示例代码 9.11.9 system()函数使用示例*/
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
int ret;
if (2 > argc)
exit(-1);
ret = system(argv[1]);
if (-1 == ret)
fputs("system error.\n", stderr);
else
{
if (WIFEXITED(ret) && (127 == WEXITSTATUS(ret)))
fputs("could not invoke shell.\n", stderr);
}
exit(0);
}
进程状态与进程关系
进程状态
-
处于暂停态的进程是可以恢复进入到就绪态的,譬如收到 SIGCONT 信号。
-
一个新创建的进程会处于就绪态,只要得到 CPU 就能被执行;
-
Linux 系统下进程通常存在 6 种不同的状态:
就绪态、运行态、僵尸态、可中断睡眠状态(浅度睡眠)、不可中断睡眠状态(深度睡眠)以及暂停态;
⚫ 就绪态(Ready):
指该进程满足被 CPU 调度的所有条件但此时并没有被调度执行,只要得到 CPU就能够直接运行;
该进程已准备被 CPU 执行,当一个进程时间片到达,操作系统调度程序会从就绪态链表中调度一个进程;
⚫ 运行态:
指该进程当前正在被 CPU 调度运行,处于就绪态的进程得到 CPU 调度就会进入运行态;
⚫ 僵尸态:
僵尸态进程其实指的就是僵尸进程,指该进程已经结束、但其父进程还未给它“收尸”;
⚫ 可中断睡眠状态:
可中断睡眠也称为浅度睡眠,表示睡的不够“死”,还可以被唤醒,一般可通过信号唤醒;
⚫ 不可中断睡眠状态:
不可中断睡眠称为深度睡眠,深度睡眠无法被信号唤醒,只能等待相应的条件成立才能结束睡眠状态。
把浅度睡眠和深度睡眠统称为等待态(或者叫阻塞态),表示进程处于一种等待状态,等待某种条件成立之 后便会进入到就绪态;
所以,处于等待态的进程是无法参与进程系统调度的。
⚫ 暂停态:
暂停并不是进程的终止,表示进程暂停运行,一般可通过信号将进程暂停,譬如 SIGSTOP信号;
进程关系
无关系(相互独立)、父子进程关系、进程组以及会话。
-
在 Linux 系统下,每个进程都有自己唯一的标识:
进程号(进程 ID、PID),也有自己的生命周期,进程都有自己的父进程、而父进程也有父进程;
形成一个以 init 进程为根的进程家族树;当子进程终止时,父进程会得到通知并能取得子进程的退出状态
- 层次关系:譬如进程组和会话;
由此可知,进程间存在着多种不同的关系:无关系(相互独立)、父子进程关系、进程组以及会话。
无关系
两个进程间没有任何关系,相互独立。
父子进程关系
两个进程间构成父子进程关系,譬如一个进程 fork()创建出了另一个进程,那么这两个进程间就构成了父子进程关系,调用 fork()的进程称为父进程、而被 fork()创建出来的进程称为子进程;
当然,如果“生父”先与子进程结束,那么 init 进程(“养父”)就会成为子进程的父进程,它们之间同样也是父子进程关系。
进程组
-
每个进程除了有一进程 ID、父进程 ID 之外,还有一个进程组 ID,用于标识该进程属于哪一个进程组
-
进程组是一个或多个进程的集合,这些进程并不是孤立的,它们彼此之间或者存在父子、兄弟关系,或者在功能上有联系。
-
一个进程只能为其或其子进程设置进程组 ID,子进程调用 exec 函数后,该子进程的进程组 ID不可改
Linux 系统设计进程组实质上是为了方便对进程进行管理。
假设为了完成一个任务,需要并发运行 100个进程,但当处于某种场景时需要终止这 100 个进程,若没有进程组就需要一个一个去终止,这样非常麻烦且容易出现一些问题;
有了进程组的概念之后,就可以将这 100 个进程设置为一个进程组,这些进程共享一个进程组 ID,这样一来,终止这 100 个进程只需要终止该进程组即可。
- 关于进程组需要注意以下以下内容:
⚫ 每个进程必定属于某一个进程组、且只能属于一个进程组;
⚫ 每一个进程组有一个组长进程,组长进程的 ID 就等于进程组 ID;
⚫ 在组长进程的 ID 前面加上一个负号即是操作进程组;
⚫ 组长进程不能再创建新的进程组;
⚫ 只要进程组中还存在一个进程,则该进程组就存在,这与其组长进程是否终止无关;
⚫ 一个进程组可包含一个或多个进程,进程组的生命周期从被创建开始,到其内所有进程终止或离开该进程组;
⚫ 默认情况下,新创建的进程会继承父进程的进程组 ID。
getpgrp()或 getpgid()
- 通过系统调用 getpgrp()或 getpgid()都用于获取进程组 ID
- getpgrp()就等价于 getpgid(0)
原型
#include <unistd.h>
pid_t getpgid(pid_t pid);
pid_t getpgrp(void);
/*
getpgrp():
没有参数,返回值总是调用者进程对应的进程组 ID;
getpgid():
参数:
可通过参数 pid 指定获取对应进程的进程组 ID,
如果参数 pid 为 0 表示获取调用者进程的进程组 ID。
返回值:
成功将返回进程组 ID;
失败将返回-1、并设置 errno。
典例
/*示例代码 9.12.1 获取进程组 ID*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
pid_t pid = getpid();
printf("进程组 ID<%d>---getpgrp()\n", getpgrp());
printf("进程组 ID<%d>---getpgid(0)\n", getpgid(0));
printf("进程组 ID<%d>---getpgid(%d)\n", getpgid(pid), pid);
exit(0);
}
可见:新创建的进程对应的进程组 ID 等于该进程的 ID
setpgid()或 setpgrp()
- 系统调用 setpgid()或 setpgrp()可以加入一个现有的进程组或创建一个新的进程组;
- setpgrp()函数等价于 setpgid(0, 0).
- 一个进程只能为其或其子进程设置进程组 ID,子进程调用 exec 函数后,该子进程的进程组 ID不可改
原型
#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);
int setpgrp(void);
/*
setpgid()
参数 pid 指定的进程的进程组 ID 设置为参数 gpid。
如这两个参数相等(pid==gpid),则由 pid 指定的进程变成为进程组的组长进程,创建了一个新进程;
如果参数 pid 等于 0,则使用调用者的进程 ID;
另外,如果参数 gpid 等于 0,则创建一个新的进程组,由参数 pid 指定的进程作为进程组组长进程。
典例
/*示例代码 9.12.2 创建进程组或加入一个现有进程组*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("更改前进程组 ID<%d>\n", getpgrp());
setpgrp();
printf("更改后进程组 ID<%d>\n", getpgrp());
exit(0);
}
会话
-
会话是一个或多个进程组的集合;
-
一个会话可包含一个或多个进程组,但只能有一个前台进程组,其它的是后台进程组;
-
每个会话都有一个会话首领(leader),即创建会话的进程;
-
一个会话可以有控制终端、也可没有控制终端,在有控制终端的情况下也只能连接一个控制终端;
这通常是登录到其上的终端设备(在终端登录情况下)或伪终端设备(譬如通过 SSH 协议网络登录), 一个会话中的进程组可被分为一个前台进程组以及一个或多个后台进程组;
-
会话的首领进程连接一个终端之后,该终端就成为会话的控制终端,
与控制终端建立连接的会话首领进程被称为控制进程;
产生在终端上的输入和信号将发送给会话的前台进程组中的所有进程, 譬如 Ctrl + C(产生 SIGINT 信号)、Ctrl + Z(产生 SIGTSTP 信号)、Ctrl + \(产生SIGQUIT 信号)等等这些由控制终端产生的信号。
其与进程组、进程之间的关系如下图所示
一个进程组由组长进程的 ID 标识,而对于会话来说,会话的首领进程的进程组 ID 将作为该会话的标识,
也就是会话 ID(sid),在默认情况下,新创建的进程会继承父进程的会话 ID。
getsid()
通过系统调用 getsid()可以获取进程的会话 ID:
原型
#include <unistd.h>
pid_t getsid(pid_t pid);
/*
参数:
如果参数 pid 为 0,则返回调用者进程的会话 ID;
如果参数 pid不为 0,则返回参数 pid 指定的进程对应的会话 ID。
返回值:
成功情况下,该函数返回会话 ID,
失败则返回-1、并设置 errno。
典例
/*示例代码 9.12.3 获取进程的会话 ID*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("会话 ID<%d>\n", getsid(0));
exit(0);
}
setsid()
-
系统调用setsid()可以创建一个会话;
-
调用者进程不是进程组的组长进程,调用 setsid()将创建一个新的会话;
调用者进程是新会话的首领进程,也是新的进程组的组长进程,调用 setsid()创建会话无控制终端
原型
#include <unistd.h>
pid_t setsid(void);
/*
成功将返回新会话的会话 ID;
失败将返回-1,并设置 errno。
守护进程(Daemon)
守护进程定义
-
也称精灵进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生;
-
特点:
⚫ 长期运行。 守护进程是一种生存期很长的一种进程,它们一般在系统启动时开始运行,除非强行终止,否则直到系统关机都会保持运行。 与守护进程相比,普通进程都是在用户登录或运行程序时创建,在运行结束或用户注销时终止,但守护进程不受用户登录注销的影响,它们将会一直运行着、直到系统关机。 ⚫ 与控制终端脱离。 在 Linux 中,系统与用户交互的界面称为终端,每一个从终端开始运行的进程都会依附于这个终端,这是上一小节给大家介绍的控制终端,也就是会话的控制终端。 当控制终端被关闭的时候,该会话就会退出,由控制终端运行的所有进程都会被终止,这使得普通进程都是和运行该进程的终端相绑定的; 但守护进程能突破这种限制,它脱离终端并且在后台运行,脱离终端的目的是为了避免进程在运行的过程中的信息在终端显示并且进程也不会被任何终端所产生的信息所打断。
-
Linux 中大多数服务器就是用守护进程实现:
譬如,Internet 服务器inetd、Web 服务器 httpd 等。 同时,守护进程完成许多系统任务,譬如作业规划进程 crond 等
查看守护进程
- 守护进程 Daemon,通常简称为 d,一般进程名后面带有 d 就表示它是一个守护进程。
- 守护进程与终端无任何关联,用户登录与注销与守护进程无关、不受其影响,守护进程自成进程组、自成会话,即pid=gid=sid
命令"ps -ajx"查看系统所有的进程:
TTY 一栏是问号?表示该进程没有控制终端,也就是守护进程,
COMMAND 一栏使用中括号[]括起来的表示内核线程,这些线程是在内核里创建,没有用户空间代码,因此没有程序文件名和命令行;
通常采用 k 开头的名字,表示 Kernel。
编写守护进程程序
守护进程一般以单例模式运行;
1) 创建子进程、终止父进程
父进程调用 fork()创建子进程,然后父进程使用 exit()退出,这样做实现了下面几点。
第一,如果该守护进程是作为一条简单地 shell 命令启动,那么父进程终止会让 shell 认为这条命令已经执行完毕。
第二,虽然子进程继承了父进程的进程组ID,但它有自己独立的进程ID,这保证了子进程不是一个进程组的组长进程,这是下面将要调用 setsid 函数的先决条件!
2) 子进程调用 setsid 创建会话
这步是关键,在子进程中调用上一小节给大家介绍的 setsid()函数创建新的会话,由于之前子进程并不是进程组的组长进程,所以调用 setsid()会使得子进程创建一个新的会话,子进程成为新会话的首领进程,同样也创建了新的进程组、子进程成为组长进程,此时创建的会话将没有控制终端。
所以这里调用 setsid 有三个作用:
1>让子进程摆脱原会话的控制;
2>让子进程摆脱原进程组的控制;
3>让子进程摆脱原控制终端的控制。
在调用 fork 函数时,子进程继承了父进程的会话、进程组、控制终端等,虽然父进程退出了,但原先
的会话期、进程组、控制终端等并没有改变,因此,那还不是真正意义上使两者独立开来。setsid 函数能够
使子进程完全独立出来,从而脱离所有其他进程的控制。
3) 将工作目录更改为根目录
子进程是继承了父进程的当前工作目录,由于在进程运行中,当前目录所在的文件系统是不能卸载的,这对以后使用会造成很多的麻烦。
因此通常的做法是让“/”作为守护进程的当前目录,当然也可以指定其它目录来作为守护进程的工作目录。
4) 重设文件权限掩码 umask
文件权限掩码 umask 用于对新建文件的权限位进行屏蔽,在 5.5.5 小节中有介绍。
由于使用 fork 函数新建的子进程继承了父进程的文件权限掩码,这就给子进程使用文件带来了诸多的麻烦。因此,把文件权限掩码设置为 0,确保子进程有最大操作权限、这样可以大大增强该守护进程的灵活性。设置文件权限掩码的函数是 umask,通常的使用方法为 umask(0)。
5) 关闭不再需要的文件描述符
子进程继承了父进程的所有文件描述符,这些被打开的文件可能永远不会被守护进程(此时守护进程指的就是子进程,父进程退出、子进程成为守护进程)读或写,但它们一样消耗系统资源,可能导致所在的文件系统无法卸载,所以必须关闭这些文件,这使得守护进程不再持有从其父进程继承过来的任何文件描述符。
6) 将文件描述符号为 0、1、2 定位到/dev/null
将守护进程的标准输入、标准输出以及标准错误重定向到/dev/null,这使得守护进程的输出无处显示、也无处从交互式用户那里接收输入。
7) 其它:忽略 SIGCHLD 信号
处理 SIGCHLD 信号不是必须的,但对于某些进程,特别是并发服务器进程往往是特别重要的,服务器
进程在接收到客户端请求时会创建子进程去处理该请求,如果子进程结束之后,父进程没有去 wait 回收子
进程,则子进程将成为僵尸进程;
如果父进程 wait 等待子进程退出,将又会增加父进程的负担、也就是增加服务器的负担,影响服务器进程的并发性能,在 Linux 下,可以将 SIGCHLD 信号的处理方式设置为SIG_IGN,也就是忽略该信号,可让内核将僵尸进程转交给 init 进程去处理,这样既不会产生僵尸进程、又省去了服务器进程回收子进程所占用的时间。
编写典例
第 4 步中调用 sysconf(_SC_OPEN_MAX)用于获取当前系统允许进程打开的最大文件数量;
编写都是根据上面的介绍来完成的,这里就不再啰嗦,示例代码中使用到的函数在前面都已经学习过;
/*示例代码 9.13.1 守护进程示例代码*/
#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 (0 > pid)
{
perror("fork error");
exit(-1);
}
else if (0 < pid)//父进程
exit(0); //直接退出
/**子进程*/
/* 1.创建新的会话、脱离控制终端 */
if (0 > setsid())
{
perror("setsid error");
exit(-1);
}
/* 2.设置当前工作目录为根目录 */
if (0 > chdir("/"))
{
perror("chdir error");
exit(-1);
}
/* 3.重设文件权限掩码 umask */
umask(0);
/* 4.关闭所有文件描述符 */
for (i = 0; i < sysconf(_SC_OPEN_MAX); i++)
close(i);
/* 5.将文件描述符号为 0、1、2 定位到/dev/null */
open("/dev/null", O_RDWR);
dup(0);
dup(0);
/* 6.忽略 SIGCHLD 信号 */
signal(SIGCHLD, SIG_IGN);
/* 正式进入到守护进程 */
for ( ; ; ) {
sleep(1);
puts("守护进程运行中......");
}
exit(0);
}
SIGHUP信号
因为程序当中一般不会对 SIGHUP 信号进行处理,所以对应的处理方式为系统默认方式,SIGHUP 信号的系统默认处理方式便是终止进程。
-
用户准备退出会话时,系统向该会话发出 SIGHUP 信号,会话将 SIGHUP 信号发送给所有子进程;
-
子进程接收到 SIGHUP 信号后,便自动终止,当所有会话中的所有进程都退出时,会话也就终止了;
如果忽略该信号,将会出现什么样的结果?
典例
调用 signal()函数将 SIGHUP 信号的处理方式设置为忽略
/*示例代码 9.13.2 忽略 SIGHUP 示例代码*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
int main(void)
{
/*处理方式设置为忽略*/
signal(SIGHUP, SIG_IGN);
for ( ; ; )
{
sleep(1);
puts("进程运行中......");
}
}
发现 testApp 进程依然还在运行,但此时它已经变成了守护进程,脱离了控制终端;
当程序当中忽略 SIGHUP 信号之后,进程不会随着终端退出而退出;
事实上,控制终端只是会话中的一个进程,只有会话中的所有进程退出后,会话才会结束;很显然当程序中忽略了 SIGHUP 信号,导致该进程不会终止,所以会话也依然会存在,
从典例可知,其会话 ID 等于 23601,但此时会话已经没有控制终端了。
单例模式运行
- 通常情况:
一个程序可以被多次执行,即程序在还没有结束的情况下,又再次执行该程序,也就是系统中同时存在多个该程序的实例化对象(进程);
譬如大家所熟悉的聊天软件 QQ,我们可以在电脑上同时登陆多个 QQ 账号,譬如还有一些游戏也是如此,在一台电脑上同时登陆多个游戏账号,只要你电脑不卡机、随便你开几个号。
- 单例模式运行:
对有些程序设计来说,不允许出现这种情况,程序只能被执行一次,只要该程序没有结束,就无法再次运行;
譬如系统中守护进程,这些守护进程一般都是服务器进程,服务器程序只需要运行一次即可,能够在系统整个的运行过程中提供相应的服务支持,多次同时运行并没有意义、甚至还会带来错误!
- 下面讨论:如何实现程序具有单例模式运行的功能?
通过文件存在与否进行判断
用一个文件的存在与否来做标志,在程序运行正式代码之前,先判断一个特定的文件是否存在,如果存在则表明进程已经运行,此时应该立马退出;如果不存在则表明进程没有运行,然后创建该文件,当程序结束时再删除该文件即可!
典例
通过一个特定文件的存在与否来做判断,当然这个特定的文件的命名要弄的特殊一点,避免在文件系统中不会真的存在该文件(需要该文件存在);
/*示例代码 9.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);
}
-
通过当前目录下的 testApp.lock 文件作为特定文件进行判断该文件是否存在,如果在实际应用编程中使用了这种方法,这个特定文件需要存放在一个特定的路径下。
-
代码中以 O_RDONLY | O_CREAT | O_EXCL 的方式打开文件,如果文件不存在则创建文件,如果文件存在则 open 会报错返回-1;使用 atexit 注册进程终止处理函数,当程序退出时,使用 remove()删除该文件。
首先第一次以后台方式运行了 testApp 程序,之后再运行 testApp 程序,由于文件已经存在,所以 open()调用会失败,所以意味着进程正在运行中,所以会打印相应的字符串然后退出。直到第一次运行的程序结束时,才能执行 testApp 程序,这样就实现了一个简单地具有单例模式运行功能的程序。
存在问题
⚫ 程序中使用_exit()退出,那么将无法执行 delete_file()函数,意味着无法删除这个特定的文件;
⚫ 程序异常退出。程序异常同样无法执行到进程终止处理函数 delete_file(),同样将导致无法删除这
个特定的文件;
⚫ 计算机掉电关机。这种情况就更加直接了,计算机可能在程序运行到任意位置时发生掉电关机的情况,这是无法预料的;如果文件没有删除就发生了这种情况,计算机重启之后文件依然存在,导致程序无法执行。
针对第一种情况,我们使用 exit()代替_exit()可以很好的解决这种问题;
但是对于第二种情况来说,异常退出,譬如进程接收到信号导致异常终止,有一种解决办法便是设置信号处理方式为忽略信号,这样当进程接收到信号时就会被忽略,或者是针对某些信号注册信号处理函数,譬如 SIGTERM、SIGINT 等,在信号处理函数中删除文件然后再退出进程;但依然有个问题,并不是所有信号都可被忽略或捕获的,譬如SIGKILL 和 SIGSTOP,这两个信号是无法被忽略和捕获的,故而这种也不靠谱。
针对第三种情况的解决办法便是,使得该特定文件会随着系统的重启而销毁,这个怎么做呢?其实这个
非常简单,将文件放置到系统/tmp 目录下,/tmp 是一个临时文件系统,当系统重启之后/tmp 目录下的文件
就会被销毁,所以该目录下的文件的生命周期便是系统运行周期。
使用文件锁
- 使用文件锁来实现,事实上这种方式才是实现单例模式运行靠谱的方法
同样也需要通过一个特定的文件来实现,当程序启动之后,首先打开该文件,调用 open 时一般使用O_WRONLY | O_CREAT 标志,当文件不存在则创建该文件,然后尝试去获取文件锁,
若成功,则将程序的进程号(PID)写入到该文件中,写入后不要关闭文件或解锁(释放文件锁),保证进程一直持有该文件锁;
若是程序获取锁失败,代表程序已经被运行、则退出本次启动。
Tips:当程序退出或文件关闭之后,文件锁会自动解锁!
- 通过系统调用flock()、fcntl()或库函数 lockf()均可实现对文件进行上锁
典例
以系统调用flock()为例,系统调用 flock()产生的是咨询锁(建议性锁)、并不能产生强制性锁;
/*示例代码 9.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);
}
/* 以非阻塞方式获取文件锁 */
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());
write(fd, str, strlen(str));//写入 pid
for ( ; ; )
sleep(1);
exit(0);
}
- 程序启动首先打开一个特定的文件,这里只是举例,以当前目录下的 testApp.pid 文件作为特定文件,以 O_WRONLY | O_CREAT 方式打开,如果文件不存在则创建该文件;
- 打开文件之后使用 flock 尝试获取文件锁,调用 flock()时指定了互斥锁标志 LOCK_NB,意味着同时只能有一个进程拥有该锁,
- 如果获取锁失败,表示该程序已经启动了,无需再次执行,然后退出;
- 如果获取锁成功,将进程的 PID 写入到该文件中,当程序退出时,会自动解锁、关闭文件。
-
这种机制在一些程序尤其是服务器程序中常见,服务器程序使用这种方法来保证程序的单例模式行;
-
在 Linux 系统中/var/run/目录下有很多以.pid 为后缀结尾的文件,这个实际上是为了保证程序以单例模式运行而设计的,作为程序实现单例模式运行所需的特定文件,如下所示:
open()调用会失败,所以意味着进程正在运行中,所以会打印相应的字符串然后退出。直到第一次运行的程序结束时,才能执行 testApp 程序,这样就实现了一个简单地具有单例模式运行功能的程序。