1)实验平台:正点原子阿尔法Linux开发板 2)平台购买地址:https://item.taobao.com/item.htm?id=603672744434 2)全套实验源码+手册+视频下载地址:http://www.openedv.com/thread-300792-1-1.html 3)对正点原子Linux感兴趣的同学可以加群讨论:935446741 4)关注正点原子公众号,获取最新资料更新
第九章进程
本章将讨论进程相关的知识内容,虽然在前面章节内容已经多次向大家提到了进程这个概念,但并未真正地向大家解释这个概念;在本章,我们将一起来学习Linux下进程相关的知识内容,虽然进程的基本概念比较简单,但是其所涉及到的细节内容比较多,所以本章篇幅也会相对比较长,所以,大家加油! 本章将会讨论如下主题内容。
程序与进程基本概念; 程序的开始与结束; 进程的环境变量与虚拟地址空间; 进程ID; fork()创建子进程; 进程的消亡与诞生; 僵尸进程与孤儿进程; 父进程监视子进程; 进程关系与进程的六种状态; 守护进程; 进程间通信概述
10.1进程与程序 10.1.1main()函数由谁调用? C语言程序总是从main函数开始执行,main()函数的原型是: int main(void) 或 int main(int argc, char *argv[]) 如果需要向应用程序传参,则选择第二种写法。不知大家是否想过“谁”调用了main()函数?事实上,操作系统下的应用程序在运行main()函数之前需要先执行一段引导代码,最终由这段引导代码去调用应用程序的main()函数,我们在编写应用程序的时候,不用考虑引导代码的问题,在编译链接时,由链接器将引导代码链接到我们的应用程序当中,一起构成最终的可执行文件。 当执行应用程序时,在Linux下输入可执行文件的相对路径或绝对路径就可以运行该程序,譬如./app或/home/dt/app,还可根据应用程序是否接受传参在执行命令时在后面添加传入的参数信息,譬如./app arg1 arg2或/home/dt/app arg1 arg2。程序运行需要通过操作系统的加载器来实现,加载器是操作系统中的程序,当执行程序时,加载器负责将此应用程序加载内存中去执行。 所以由此可知,对于操作系统下的应用程序来说,链接器和加载器都是很重要的角色! 再来看看argc和argv传参是如何实现的呢?譬如./app arg1 arg2,这两个参数arg1和arg2是如何传递给应用程序的main函数的呢?当在终端执行程序时,命令行参数(command-line argument)由shell进程逐一进行解析,shell进程会将这些参数传递给加载器,加载器加载应用程序时会将其传递给应用程序引导代码,当引导程序调用main()函数时,在由它最终传递给main()函数,如此一来,在我们的应用程序当中便可以获取到命令行参数了。 10.1.2程序如何结束? 程序结束其实就是进程终止,进程终止的方式通常有多种,大体上分为正常终止和异常终止,正常终止包括: main()函数中通过return语句返回来终止进程; 应用程序中调用exit()函数终止进程; 应用程序中调用_exit()或_Exit()终止进程; 以上这些是在前面的课程中给大家介绍的,异常终止包括: 应用程序中调用abort()函数终止进程; 进程接收到一个信号,譬如SIGKILL信号。 注册进程终止处理函数atexit() atexit()库函数用于注册一个进程在正常终止时要调用的函数,其函数原型如下所示: #include
int atexit(void (*function)(void)); 使用该函数需要包含头文件。 函数参数和返回值含义如下: function:函数指针,指向注册的函数,此函数无需传入参数、无返回值。 返回值:成功返回0;失败返回非0。 测试 编写一个测试程序,使用atexit()函数注册一个进程在正常终止时需要调用的函数,测试代码如下。 示例代码 10.1.1 atexit()函数使用示例
#include
#include
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);
}
运行结果:
图 10.1.1 测试结果 需要说明的是,如果程序当中使用了_exit()或_Exit()终止进程而并非是exit()函数,那么将不会执行注册的终止处理函数。 10.1.3何为进程? 本小节正式向大家介绍进程这个概念,前面的内容中也已经多次提到了,其实这个概念本身非常简单,进程其实就是一个可执行程序的实例,这句话如何理解呢?可执行程序就是一个可执行文件,文件是一个静态的概念,存放磁盘中,如果可执行文件没有被运行,那它将不会产生什么作用,当它被运行之后,它将会对系统环境产生一定的影响,所以可执行程序的实例就是可执行文件被运行。 进程是一个动态过程,而非静态文件,它是程序的一次运行过程,当应用程序被加载到内存中运行之后它就称为了一个进程,当程序运行结束后也就意味着进程终止,这就是进程的一个生命周期。 10.1.4进程号 Linux系统下的每一个进程都有一个进程号(process ID,简称PID),进程号是一个正数,用于唯一标识系统中的某一个进程。在Ubuntu系统下执行ps命令可以查到系统中进程相关的一些信息,包括每个进程的进程号,如下所示:
图 10.1.2 ps命令查看进程信息 上图中红框标识显示的便是每个进程所对应的进程号,进程号的作用就是用于唯一标识系统中某一个进程,在某些系统调用中,进程号可以作为传入参数、有时也可作为返回值。譬如系统调用kill()允许调用者向某一个进程发送一个信号,如何表示这个进程呢?则是通过进程号进行标识。 在应用程序中,可通过系统调用getpid()来获取本进程的进程号,其函数原型如下所示: #include #include
pid_t getpid(void); 使用该函数需要包含头文件和。 函数返回值为pid_t类型变量,便是对应的进程号。 使用示例 使用getpid()函数获取进程的进程号。 示例代码 10.1.2 getpid()使用示例
#include
#include
#include
#include
int main(void)
{
pid_t pid = getpid();
printf("本进程的PID为: %d\n", pid);
exit(0);
} 运行结果:
图 10.1.3 测试结果 除了getpid()用于获取本进程的进程号之外,还可以使用getppid()系统调用获取父进程的进程号,其函数原型如下所示:
#include
#include
pid_t getppid(void);
返回值对应的便是父进程的进程号。
使用示例
获取进程的进程号和父进程的进程号。
示例代码 10.1.3 getppid()使用示例
#include
#include
#include
#include
int main(void)
{
pid_t pid = getpid(); //获取本进程pid
printf("本进程的PID为: %d\n", pid);
pid = getppid(); //获取父进程pid
printf("父进程的PID为: %d\n", pid);
exit(0);
}
运行结果:
图 10.1.4 测试结果 10.2进程的环境变量 每一个进程都有一组与其相关的环境变量,这些环境变量以字符串形式存储在一个字符串数组列表中,把这个数组称为环境列表。其中每个字符串都是以“名称=值(name=value)”形式定义,所以环境变量是“名称-值”的成对集合,譬如在shell终端下可以使用env命令查看到shell进程的所有环境变量,如下所示:
图 10.2.1 env命令查看环境变量 使用export命令还可以添加一个新的环境变量或删除一个环境变量: export LINUX_APP=123456 # 添加LINUX_APP环境变量
图 10.2.2 export添加环境变量 使用"export -n LINUX_APP"命令则可以删除LINUX_APP环境变量。 export -n LINUX_APP # 删除LINUX_APP环境变量 10.2.1应用程序中获取环境变量 在我们的应用程序当中也可以获取当前进程的环境变量,事实上,进程的环境变量是从其父进程中继承过来的,譬如在shell终端下执行一个应用程序,那么该进程的环境变量就是从其父进程(shell进程)中继承过来的。新的进程在创建之前,会继承其父进程的环境变量副本。 环境变量存放在一个字符串数组中,在应用程序中,通过environ变量指向它,environ是一个全局变量,在我们的应用程序中只需申明它即可使用,如下所示: extern char **environ; // 申明外部全局变量environ 测试 编写应用程序,获取进程的所有环境变量。 示例代码 10.2.1 获取进程环境变量
#include
#include
extern char **environ;
int main(int argc, char *argv[])
{
int i;
/* 打印进程的环境变量 */
for (i = 0; NULL != environ[i]; i++)
puts(environ[i]);
exit(0);
}
通过字符串数组元素是否等于NULL来判断是否已经到了数组的末尾。 运行结果:
图 10.2.3 测试结果 获取指定环境变量getenv() 如果只想要获取某个指定的环境变量,可以使用库函数getenv(),其函数原型如下所示: #include
char *getenv(const char *name); 使用该函数需要包含头文件。 函数参数和返回值含义如下: name:指定获取的环境变量名称。 返回值:如果存放该环境变量,则返回该环境变量的值对应字符串的指针;如果不存在该环境变量,则返回NULL。 使用getenv()需要注意,不应该去修改其返回的字符串,修改该字符串意味着修改了环境变量对应的值,Linux提供了相应的修改函数,如果需要修改环境变量的值应该使用这些函数,不应直接改动该字符串。 使用示例 示例代码 10.2.2 getenv()函数使用示例
#include
#include
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);
}
运行结果:
图 10.2.4 测试结果 10.2.2添加/删除/修改环境变量 C语言函数库中提供了用于修改、添加、删除环境变量的函数,譬如putenv()、setenv()、unsetenv()、clearenv()函数等。 putenv()函数 putenv()函数可向进程的环境变量数组中添加一个新的环境变量,或者修改一个已经存在的环境变量对应的值,其函数原型如下所示: #include
int putenv(char *string); 使用该函数需要包含头文件。 函数参数和返回值含义如下: string:参数string是一个字符串指针,指向name=value形式的字符串。 返回值:成功返回0;失败将返回非0值,并设置errno。 该函数调用成功之后,参数string所指向的字符串就成为了进程环境变量的一部分了,换言之,putenv()函数将设定environ变量(字符串数组)中的某个元素(字符串指针)指向该string字符串,而不是指向它的复制副本,这里需要注意!因此,不能随意修改参数string所指向的内容,这将影响进程的环境变量,出于这种原因,参数string不应为自动变量(即在栈中分配的字符数组),因为定义吃变量。 测试 使用putenv()函数为当前进程添加一个环境变量。 示例代码 10.2.3 putenv()函数使用示例
#include
#include
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()函数 setenv()函数可以替代putenv()函数,用于向进程的环境变量列表中添加一个新的环境变量或修改现有环境变量对应的值,其函数原型如下所示: #include
int setenv(const char *name, const char *value, int overwrite); 使用该函数需要包含头文件。 函数参数和返回值含义如下: name:需要添加或修改的环境变量名称。 value:环境变量的值。 overwrite:若参数name标识的环境变量已经存在,在参数overwrite为0的情况下,setenv()函数将不改变现有环境变量的值,也就是说本次调用没有产生任何影响;如果参数overwrite的值为非0,若参数name标识的环境变量已经存在,则覆盖,不存在则表示添加新的环境变量。 返回值:成功返回0;失败将返回-1,并设置errno。 setenv()函数为形如name=value的字符串分配一块内存缓冲区,并将参数name和参数value所指向的字符串复制到此缓冲区中,以此来创建一个新的环境变量,所以,由此可知,setenv()与putenv()函数有两个区别: putenv()函数并不会为name=value字符串分配内存; setenv()可通过参数overwrite控制是否需要修改现有变量的值而仅以添加变量为目的,显然putenv()并不能进行控制。 推荐大家使用setenv()函数,这样使用自动变量作为setenv()的参数也不会有问题。 使用示例 示例代码 10.2.4 setenv()函数使用示例
#include
#include
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);
}
除了上面给大家介绍的函数之外,我们还可以通过一种更简单地方式向进程环境变量表中添加环境变量,用法如下: NAME=value ./app 在执行程序的时候,在其路径前面添加环境变量,以name=value的形式添加,如果是多个环境变量,则在./app前面放置多对name=value即可,以空格分隔。 unsetenv()函数 unsetenv()函数可以从环境变量表中移除参数name标识的环境变量,其函数原型如下所示: #include
int unsetenv(const char *name); 10.2.3清空环境变量 有时,需要清除环境变量表中的所有变量,然后再进行重建,可以通过将全局变量environ赋值为NULL来清空所有变量。 environ = NULL; 也可通过clearenv()函数来操作,函数原型如下所示: #include
int clearenv(void); clearenv()函数内部的做法其实就是将environ赋值为NULL。在某些情况下,使用setenv()函数和clearenv()函数可能会导致程序内存泄漏,前面提到过,setenv()函数会为环境变量分配一块内存缓冲区,随之称为进程的一部分;而调用clearenv()函数时没有释放该缓冲区(clearenv()调用并不知晓该缓冲区的存在,故而也无法将其释放),反复调用者两个函数的程序,会不断产生内存泄漏。 10.2.4环境变量的作用 环境变量常见的用途之一是在shell中,每一个环境变量都有它所表示的含义,譬如HOME环境变量表示用户的家目录,USER环境变量表示当前用户名,SHELL环境变量表示shell解析器名称,PWD环境变量表示当前所在目录等,在我们自己的应用程序当中,也可以使用进程的环境变量。 10.3进程的内存布局 历史沿袭至今,C语言程序一直都是由以下几部分组成的: 正文段。也可称为代码段,这是CPU执行的机器语言指令部分,文本段具有只读属性,以防止程序由于意外而修改其指令;正文段是可以共享的,即使在多个进程间也可同时运行同一段程序。 初始化数据段。通常将此段称为数据段,包含了显式初始化的全局变量和静态变量,当程序加载到内存中时,从可执行文件中读取这些变量的值。 未初始化数据段。包含了未进行显式初始化的全局变量和静态变量,通常将此段称为bss段,这一名词来源于早期汇编程序中的一个操作符,意思是“由符号开始的块”(block started by symbol),在程序开始执行之前,系统会将本段内所有内存初始化为0,可执行文件并没有为bss段变量分配存储空间,在可执行文件中只需记录bss段的位置及其所需大小,直到程序运行时,由加载器来分配这一段内存空间。 栈。函数内的局部变量以及每次函数调用时所需保存的信息都放在此段中,每次调用函数时,函数传递的实参以及函数返回值等也都存放在栈中。栈是一个动态增长和收缩的段,由栈帧组成,系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值。 堆。可在运行时动态进行内存分配的一块区域,譬如使用malloc()分配的内存空间,就是从系统堆内存中申请分配的。 Linux下的size命令可以查看二进制可执行文件的文本段、数据段、bss段的段大小:
图 10.3.1 size命令 图 9.3.2显示了这些段在内存中的典型布局方式,当然,并不要求具体的实现一定是以这种方式安排其存储空间,但这是一种便于我们说明的典型方式。
图 10.3.2 在Linux/x86-32体系中进程内存布局 10.4进程的虚拟地址空间 上一小节我们讨论了C语言程序的构成以及运行时进程在内存中的布局方式,在Linux系统中,采用了虚拟内存管理技术,事实上大多数现在操作系统都是如此!在Linux系统中,每一个进程都在自己独立的地址空间中运行,在32位系统中,每个进程的逻辑地址空间均为4GB,这4GB的内存空间按照3:1的比例进行分配,其中用户进程享有3G的空间,而内核独自享有剩下的1G空间,如下所示:
图 10.4.1 Linux系统下逻辑地址空间划分 学习过驱动开发的读者对“虚拟地址”这个概念应该并不陌生,虚拟地址会通过硬件MMU(内存管理单元)映射到实际的物理地址空间中,建立虚拟地址到物理地址的映射关系后,对虚拟地址的读写操作实际上就是对物理地址的读写操作,MMU会将物理地址“翻译”为对应的物理地址,其关系如下所示:
图 10.4.2 虚拟地址到物理地址的映射关系 Linux系统下,应用程序运行在一个虚拟地址空间中,所以程序中读写的内存地址对应也是虚拟地址,并不是真正的物理地址,譬如应用程序中读写0x80800000这个地址,实际上并不对应于硬件的0x80800000这个物理地址。 为什么需要引入虚拟地址呢? 计算机物理内存的大小是固定的,就是计算机的实际物理内存,试想一下,如果操作系统没有虚拟地址机制,所有的应用程序访问的内存地址就是实际的物理地址,所以要将所有应用程序加载到内存中,但是我们实际的物理内存只有4G,所以就会出现一些问题: 当多个程序需要运行时,必须保证这些程序用到的内存总量要小于计算机实际的物理内存的大小。 内存使用效率低。内存空间不足时,就需要将其它程序暂时拷贝到硬盘中,然后将新的程序装入内存。然而由于大量的数据装入装出,内存的使用效率就会非常低。 进程地址空间不隔离。由于程序是直接访问物理内存的,所以每一个进程都可以修改其它进程的内存数据,甚至修改内核地址空间中的数据,所以有些恶意程序可以随意修改别的进程,就会造成一些破坏,系统不安全、不稳定。 无法确定程序的链接地址。程序运行时,链接地址和运行地址必须一致,否则程序无法运行!因为程序代码加载到内存的地址是由系统随机分配的,是无法预知的,所以程序的运行地址在编译程序时是无法确认的。 针对以上的一些问题,就引入了虚拟地址机制,程序访问存储器所使用的逻辑地址就是虚拟地址,通过逻辑地址映射到真正的物理内存上。所有应用程序运行在自己的虚拟地址空间中,使得进程的虚拟地址空间和物理地址空间隔离开来,这样做带来了很多的优点: 进程与进程、进程与内核相互隔离。一个进程不能读取或修改另一个进程或内核的内存数据,这是因为每一个进程的虚拟地址空间映射到了不同的物理地址空间。提高了系统的安全性与稳定性。 在某些应用场合下,两个或者更多进程能够共享内存。因为每个进程都有自己的映射表,可以让不同进程的虚拟地址空间映射到相同的物理地址空间中。通常,共享内存可用于实现进程间通信。 便于实现内存保护机制。譬如在多个进程共享内存时,允许每个进程对内存采取不同的保护措施,例如,一个进程可能以只读方式访问内存,而另一进程则能够以可读可写的方式访问。 编译应用程序时,无需关心链接地址。前面提到了,当程序运行时,要求链接地址与运行地址一致,在引入了虚拟地址机制后,便无需关心这个问题。 关于本小节的内容就介绍这么多,理解本小节的内容可以帮助我们更好地理解后面小节中将要介绍的内容。 10.5fork()创建子进程 一个现有的进程可以调用fork()函数创建一个新的进程,调用fork()函数的进程称为父进程,由fork()函数创建出来的进程被称为子进程(child process),fork()函数原型如下所示(fork()为系统调用): #include
pid_t fork(void); 使用该函数需要包含头文件。 在诸多的应用中,创建多个进程是任务分解时行之有效的方法,譬如,某一网络服务器进程可在监听客户端请求的同时,为处理每一个请求事件而创建一个新的子进程,与此同时,服务器进程会继续监听更多的客户端连接请求。在一个大型的应用程序任务中,创建子进程通常会简化应用程序的设计,同时提高了系统的并发性(即同时能够处理更多的任务或请求,多个进程在宏观上实现同时运行)。 理解fork()系统调用的关键在于,完成对其调用后将存在两个进程,一个是原进程(父进程)、另一个则是创建出来的子进程,并且每个进程都会从fork()函数的返回处继续执行,会导致调用fork()返回两次值,子进程返回一个值、父进程返回一个值。在程序代码中,可通过返回值来区分是子进程还是父进程。 fork()调用成功后,将会在父进程中返回子进程的PID,而在子进程中返回值是0;如果调用失败,父进程返回值-1,不创建子进程,并设置errno。 fork()调用成功后,子进程和父进程会继续执行fork()调用之后的指令,子进程、父进程各自在自己的进程空间中运行。事实上,子进程是父进程的一个副本,譬如子进程拷贝了父进程的数据段、堆、栈以及继承了父进程打开的文件描述符,父进程与子进程并不共享这些存储空间,这是子进程对父进程相应部分存储空间的完全复制,执行fork()之后,每个进程均可修改各自的栈数据以及堆段中的变量,而并不影响另一个进程。 虽然子进程是父进程的一个副本,但是对于程序代码段(文本段)来说,两个进程执行相同的代码段,因为代码段是只读的,也就是说父子进程共享代码段,在内存中只存在一份代码段数据。 使用示例1 使用fork()创建子进程。 示例代码 10.5.1 fork()使用示例
#include
#include
#include
int main(void)
{
pid_t pid;
pid = fork();
switch (pid) {
case -1:
perror("fork error");
exit(-1);
case 0:
printf("这是子进程打印信息\n",
getpid(), getppid());
_exit(0); //子进程使用_exit()退出
default:
printf("这是父进程打印信息\n",
getpid(), pid);
exit(0);
}
}
上述示例代码中,case 0是子进程的分支,这里使用了_exit()结束进程而没有使用exit()。 Tips:C库函数exit()建立在系统调用_exit()之上,这两个函数在3.3小节中向大家介绍过,这里我们强调,在调用了fork()之后,父、子进程中一般只有一个会通过调用exit()退出进程,而另一个则应使用_exit()退出,具体原因将会在后面章节内容中向大家做进一步说明! 直接测试运行查看打印结果:
图 10.5.1 测试结果 从打印结果可知,fork()之后的语句被执行了两次,所以switch…case语句被执行了两次,第一次进入到了"case 0"分支,通过上面的介绍可知,fork()返回值为0表示当前处于子进程;在子进程中我们通过getpid()获取到子进程自己的PID(46802),通过getppid()获取到父进程的PID(46803),将其打印出来。 第二次进入到了default分支,表示当前处于父进程,此时fork()函数的返回值便是创建出来的子进程对应的PID。 fork()函数调用完成之后,父进程、子进程会各自继续执行fork()之后的指令,最终父进程会执行到exit()结束进程,而子进程则会通过_exit()结束进程。 使用示例2 示例代码 10.5.2 fork()函数使用示例2
#include
#include
#include
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);
}
}
运行结果:
图 10.5.2 测试结果 在exit()函数之前添加了打印信息,而从上图中可以知道,打印的pid值并不相同,0表示子进程打印出来的,46953表示的是父进程打印出来的,所以从这里可以证实,fork()函数调用完成之后,父进程、子进程会各自继续执行fork()之后的指令,它们共享代码段,但并不共享数据段、堆、栈等,而是子进程拥有父进程数据段、堆、栈等副本,所以对于同一个局部变量,它们打印出来的值是不相同的,因为fork()调用返回值不同,在父、子进程中赋予了pid不同的值。 关于子进程 子进程被创建出来之后,便是一个独立的进程,拥有自己独立的进程空间,系统内唯一的进程号,拥有自己独立的PCB(进程控制块),子进程会被内核同等调度执行,参与到系统的进程调度中。 子进程与父进程之间的这种关系被称为父子进程关系,父子进程关系相比于普通的进程间关系多多少少存在一些关联与“羁绊”,关于这些关联与“羁绊”我们将会在后面的课程中为大家介绍。 Tips:系统调度。Linux系统是一个多任务、多进程、多线程的操作系统,一般来说系统启动之后会运行成百甚至上千个不同的进程,那么对于单核CPU计算机来说,在某一个时间它只能运行某一个进程的代码指令,那其它进程怎么办呢(多核处理器也是如此,同一时间每个核它只能运行某一个进程的代码)?这里就出现了调度的问题,系统是这样做的,每一个进程(或线程)执行一段固定的时间,时间到了之后切换执行下一个进程或线程,依次轮流执行,这就称为调度,由操作系统负责这件事情,当然系统调度的实现本身是一件非常复杂的事情,需要考虑的因素很多,这里只是让大家有个简单地认识,系统调度的基本单元是线程,关于线程,后面章节内容将会向大家介绍。 10.6父、子进程间的文件共享 调用fork()函数之后,子进程会获得父进程所有文件描述符的副本,这些副本的创建方式类似于dup(),这也意味着父、子进程对应的文件描述符均指向相同的文件表,如下图所示:
图 10.6.1 父、子进程间的文件共享 由此可知,子进程拷贝了父进程的文件描述符表,使得父、子进程中对应的文件描述符指向了相同的文件表,也意味着父、子进程中对应的文件描述符指向了磁盘中相同的文件,因而这些文件在父、子进程间实现了共享,譬如,如果子进程更新了文件偏移量,那么这个改变也会影响到父进程中相应文件描述符的位置偏移量。 接下来我们进行一个测试,父进程打开文件之后,然后fork()创建子进程,此时子进程继承了父进程打开的文件描述符(父进程文件描述符的副本),然后父、子进程同时对文件进行写入操作,测试代码如下所示: 示例代码 10.6.1 子进程继承父进程文件描述符实现文件共享
#include
#include
#include
#include
#include
#include
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 ret) {
if (ECHILD == errno)
exit(0);
else {
perror("wait error");
exit(-1);
}
}
else if (0 == ret)
continue;
else
printf("回收子进程, 终止状态\n", ret,
WEXITSTATUS(status));
}
exit(0);
}
将waitpid()函数的options参数添加WNOHANG标志,将waitpid()配置成非阻塞模式,使用轮训的方式依次回收各个子进程,测试结果如下:
图 10.10.2 测试结果 10.10.3waitid()函数 除了以上给大家介绍的wait()和waitpid()系统调用之外,还有一个waitid()系统调用,waitid()与waitpid()类似,不过waitid()提供了更多的扩展功能,具体的使用方法笔者便不再介绍,大家有兴趣可以自己通过man进行学习。 10.10.4僵尸进程与孤儿进程 当一个进程创建子进程之后,它们俩就成为父子进程关系,父进程与子进程的生命周期往往是不相同的,这里就会出现两个问题: 父进程先于子进程结束。 子进程先于父进程结束。 本小节我们就来讨论下这两种不同的情况。 孤儿进程 父进程先于子进程结束,也就是意味着,此时子进程变成了一个“孤儿”,我们把这种进程就称为孤儿进程。在Linux系统当中,所有的孤儿进程都自动成为init进程(进程号为1)的子进程,换言之,某一子进程的父进程结束后,该子进程调用getppid()将返回1,init进程变成了孤儿进程的“养父”;这是判定某一子进程的“生父”是否还“在世”的方法之一,通过下面的代码进行测试: 示例代码 10.10.4 孤儿进程测试
#include
#include
#include
int main(void)
{
/* 创建子进程 */
switch (fork()) {
case -1:
perror("fork error");
exit(-1);
case 0:
/* 子进程 */
printf("子进程被创建, 父进程\n", getpid(), getppid());
sleep(3); //休眠3秒钟等父进程结束
printf("父进程\n", getppid());//再次获取父进程pid
_exit(0);
default:
/* 父进程 */
break;
}
sleep(1);//休眠1秒
printf("父进程结束!\n");
exit(0);
}
在上述代码中,子进程休眠3秒钟,保证父进程先结束,而父进程休眠1秒钟,保证子进程能够打印出第一个printf(),也就是在父进程结束前,打印子进程的父进程进程号;子进程3秒休眠时间过后,再次打印父进程的进程号,此时它的“生父”已经结束了。 我们来看看打印结果:
图 10.10.3 测试结果 可以发现,打印结果并不是1,意味着并不是init进程,而是1911,这是怎么回事呢?通过"ps -axu"查询可知,进程号1911对应的是upstart进程,如下所示:
图 10.10.4 upstart进程 事实上,/sbin/upstart进程与Ubuntu系统图形化界面有关系,是图形化界面下的一个后台守护进程,可负责“收养”孤儿进程,所以图形化界面下,upstart进程就自动成为了孤儿进程的父进程,这里笔者是在Ubuntu 16.04版本下进行的测试,可能不同的版本这里看到的结果会有不同。 既然在图形化界面下孤儿进程的父进程不是init进程,那么我们进入Ubuntu字符界面,按Ctrl + Alt + F1进入,如下所示:
图 10.10.5 Ubuntu字符界面 输入Linux用户名和密码登录,我们在运行一次:
图 10.10.6 测试结果 字符界面模式下无法显示中文,所以出现了很多白色小方块,从打印结果可以发现,此时孤儿进程的父进程就成了init进程,大家可以自己测试下,按Ctrl + Alt + F7回到Ubuntu图形化界面。 僵尸进程 进程结束之后,通常需要其父进程为其“收尸”,回收子进程占用的一些内存资源,父进程通过调用wait()(或其变体waitpid()、waitid()等)函数回收子进程资源,归还给系统。 如果子进程先于父进程结束,此时父进程还未来得及给子进程“收尸”,那么此时子进程就变成了一个僵尸进程。子进程结束后其父进程并没有来得及立马给它“收尸”,子进程处于“曝尸荒野”的状态,在这么一个状态下,我们就将子进程成为僵尸进程;至于名字由来,肯定是对电影情节的一种效仿! 当父进程调用wait()(或其变体,下文不再强调)为子进程“收尸”后,僵尸进程就会被内核彻底删除。另外一种情况,如果父进程并没有调用wait()函数然后就退出了,那么此时init进程将会接管它的子进程并自动调用wait(),故而从系统中移除僵尸进程。 如果父进程创建了某一子进程,子进程已经结束,而父进程还在正常运行,但父进程并未调用wait()回收子进程,此时子进程变成一个僵尸进程。首先来说,这样的程序设计是有问题的,如果系统中存在大量的僵尸进程,它们势必会填满内核进程表,从而阻碍新进程的创建。需要注意的是,僵尸进程是无法通过信号将其杀死的,即使是“一击必杀”信号SIGKILL也无法将其杀死,那么这种情况下,只能杀死僵尸进程的父进程(或等待其父进程终止),这样init进程将会接管这些僵尸进程,从而将它们从系统中清理掉!所以,在我们的一个程序设计中,一定要监视子进程的状态变化,如果子进程终止了,要调用wait()将其回收,避免僵尸进程。 示例代码 编写示例代码,产生一个僵尸进程。 示例代码 10.10.5 产生僵尸进程 #include
#include
#include
int main(void)
{
/* 创建子进程 */
switch (fork()) {
case -1:
perror("fork error");
exit(-1);
case 0:
/* 子进程 */
printf("子进程被创建\n", getpid());
sleep(1);
printf("子进程结束\n");
_exit(0);
default:
/* 父进程 */
break;
}
for ( ; ; )
sleep(1);
exit(0);
}
在上述代码中,子进程已经退出,但其父进程并没调用wait()为其“收尸”,使得子进程成为一个僵尸进程,使用命令"ps -aux"可以查看到该僵尸进程,测试结果如下:
图 10.10.7 测试结果 通过命令可以查看到子进程113456依然存在,可以看到它的状态栏显示的是“Z”(zombie,僵尸),表示它是一个僵尸进程。僵尸进程无法被信号杀死,大家可以试试,要么等待其父进程终止、要么杀死其父进程,让init进程来处理,当我们杀死其父进程之后,僵尸进程也会被随之清理。 10.10.5SIGCHLD信号 SIGCHLD信号在第八章中给大家介绍过,当发生以下两种情况时,父进程会收到该信号: 当父进程的某个子进程终止时,父进程会收到SIGCHLD信号; 当父进程的某个子进程因收到信号而停止(暂停运行)或恢复时,内核也可能向父进程发送该信号。 子进程的终止属于异步事件,父进程事先是无法预知的,如果父进程有自己需要做的事情,它不能一直wait()阻塞等待子进程终止(或轮训),这样父进程将啥事也做不了,那么有什么办法来解决这样的尴尬情况,当然有办法,那就是通过SIGCHLD信号。 那既然子进程状态改变时(终止、暂停或恢复),父进程会收到SIGCHLD信号,SIGCHLD信号的系统默认处理方式是将其忽略,所以我们要捕获它、绑定信号处理函数,在信号处理函数中调用wait()收回子进程,回收完毕之后再回到父进程自己的工作流程中。 不过,使用这一方式时需要掌握一些窍门! 由8.4.1和8.4.2小节的介绍可知,当调用信号处理函数时,会暂时将引发调用的信号添加到进程的信号掩码中(除非sigaction()指定了SA_NODEFER标志),这样一来,当SIGCHLD信号处理函数正在为一个终止的子进程“收尸”时,如果相继有两个子进程终止,即使产生了两次SIGCHLD信号,父进程也只能捕获到一次SIGCHLD信号,结果是,父进程的SIGCHLD信号处理函数每次只调用一次wait(),那么就会导致有些僵尸进程成为“漏网之鱼”。 解决方案就是:在SIGCHLD信号处理函数中循环以非阻塞方式来调用waitpid(),直至再无其它终止的子进程需要处理为止,所以,通常SIGCHLD信号处理函数内部代码如下所示: while (waitpid(-1, NULL, WNOHANG) > 0) continue; 上述代码一直循环下去,直至waitpid()返回0,表明再无僵尸进程存在;或者返回-1,表明有错误发生。应在创建任何子进程之前,为SIGCHLD信号绑定处理函数。 使用示例 通过SIGCHLD信号实现异步方式监视子进程。 示例代码 10.10.6 异步方式监视wait回收子进程
#include
#include
#include
#include
#include
#include
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("子进程被创建\n", getpid());
sleep(1);
printf("子进程结束\n");
_exit(0);
default:
/* 父进程 */
break;
}
sleep(3);
exit(0);
}
运行结果如下:
图 10.10.8 测试结果 10.11执行新程序 在前面已经大家提到了exec函数,当子进程的工作不再是运行父进程的代码段,而是运行另一个新程序的代码,那么这个时候子进程可以通过exec函数来实现运行另一个新的程序。本小节我们就来学习下,如何在程序中运行一个新的程序,从新程序的main()函数开始运行。 10.11.1execve()函数 系统调用execve()可以将新程序加载到某一进程的内存空间,通过调用execve()函数将一个外部的可执行文件加载到进程的内存空间运行,使用新的程序替换旧的程序,而进程的栈、数据、以及堆数据会被新程序的相应部件所替换,然后从新程序的main()函数开始执行。 execve()函数原型如下所示: #include
int execve(const char *filename, char *const argv[], char *const envp[]); 使用该函数需要包含头文件。 函数参数和返回值含义如下: filename:参数filename指向需要载入当前进程空间的新程序的路径名,既可以是绝对路径、也可以是相对路径。 argv:参数argv则指定了传递给新程序的命令行参数。是一个字符串数组,该数组对应于main(int argc, char *argv[])函数的第二个参数argv,且格式也与之相同,是由字符串指针所组成的数组,以NULL结束。argv[0]对应的便是新程序自身路径名。 envp:参数envp也是一个字符串指针数组,指定了新程序的环境变量列表,参数envp其实对应于新程序的environ数组,同样也是以NULL结束,所指向的字符串格式为name=value。 返回值:execve调用成功将不会返回;失败将返回-1,并设置errno。 对execve()的成功调用将永不返回,而且也无需检查它的返回值,实际上,一旦该函数返回,就表明它发生了错误。 基于系统调用execve(),还提供了一系列以exec为前缀命名的库函数,虽然函数参数各异,当其功能相同,通常将这些函数(包括系统调用execve())称为exec族函数,所以exec函数并不是指某一个函数、而是exec族函数,下一小节将会向大家介绍这些库函数。 通常将调用这些exec函数加载一个外部新程序的过程称为exec操作。 使用示例 编写一个简单地程序,在测试程序testApp当中通过execve()函数运行另一个新程序newApp。 示例代码 10.11.1 execve()函数使用示例
#include
#include
#include
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。 接着编写新程序,在新程序当中打印出环境变量和传参,如下所示: 示例代码 10.11.2 新程序
#include
#include
extern char **environ;
int main(int argc, char *argv[])
{
char **ep = NULL;
int j;
for (j = 0; j 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);
}
运行测试:
图 10.11.4 测试结果 10.12进程状态与进程关系 本小节来聊一聊关于进程状态与进程关系相关的话题。 10.12.1进程状态 Linux系统下进程通常存在6种不同的状态,分为:就绪态、运行态、僵尸态、可中断睡眠状态(浅度睡眠)、不可中断睡眠状态(深度睡眠)以及暂停态。 就绪态(Ready):指该进程满足被CPU调度的所有条件但此时并没有被调度执行,只要得到CPU就能够直接运行;意味着该进程已经准备好被CPU执行,当一个进程的时间片到达,操作系统调度程序会从就绪态链表中调度一个进程; 运行态:指该进程当前正在被CPU调度运行,处于就绪态的进程得到CPU调度就会进入运行态; 僵尸态:僵尸态进程其实指的就是僵尸进程,指该进程已经结束、但其父进程还未给它“收尸”; 可中断睡眠状态:可中断睡眠也称为浅度睡眠,表示睡的不够“死”,还可以被唤醒,一般来说可以通过信号来唤醒; 不可中断睡眠状态:不可中断睡眠称为深度睡眠,深度睡眠无法被信号唤醒,只能等待相应的条件成立才能结束睡眠状态。把浅度睡眠和深度睡眠统称为等待态(或者叫阻塞态),表示进程处于一种等待状态,等待某种条件成立之后便会进入到就绪态;所以,处于等待态的进程是无法参与进程系统调度的。 暂停态:暂停并不是进程的终止,表示进程暂停运行,一般可通过信号将进程暂停,譬如SIGSTOP信号;处于暂停态的进程是可以恢复进入到就绪态的,譬如收到SIGCONT信号。 一个新创建的进程会处于就绪态,只要得到CPU就能被执行。以下列出了进程各个状态之间的转换关系,如下所示:
图 10.12.1 进程各状态之间的切换 10.12.2进程关系 介绍完进程状态之后,接下来聊一聊进程关系,在Linux系统下,每个进程都有自己唯一的标识:进程号(进程ID、PID),也有自己的生命周期,进程都有自己的父进程、而父进程也有父进程,这就形成了一个以init进程为根的进程家族树;当子进程终止时,父进程会得到通知并能取得子进程的退出状态。 除此之外,进程间还存在着其它一些层次关系,譬如进程组和会话;所以,由此可知,进程间存在着多种不同的关系,主要包括:无关系(相互独立)、父子进程关系、进程组以及会话。 1、无关系 两个进程间没有任何关系,相互独立。 2、父子进程关系 两个进程间构成父子进程关系,譬如一个进程fork()创建出了另一个进程,那么这两个进程间就构成了父子进程关系,调用fork()的进程称为父进程、而被fork()创建出来的进程称为子进程;当然,如果“生父”先与子进程结束,那么init进程(“养父”)就会成为子进程的父进程,它们之间同样也是父子进程关系。 3、进程组 每个进程除了有一个进程ID、父进程ID之外,还有一个进程组ID,用于标识该进程属于哪一个进程组,进程组是一个或多个进程的集合,这些进程并不是孤立的,它们彼此之间或者存在父子、兄弟关系,或者在功能上有联系。 Linux系统设计进程组实质上是为了方便对进程进行管理。假设为了完成一个任务,需要并发运行100个进程,但当处于某种场景时需要终止这100个进程,若没有进程组就需要一个一个去终止,这样非常麻烦且容易出现一些问题;有了进程组的概念之后,就可以将这100个进程设置为一个进程组,这些进程共享一个进程组ID,这样一来,终止这100个进程只需要终止该进程组即可。 关于进程组需要注意以下以下内容: 每个进程必定属于某一个进程组、且只能属于一个进程组; 每一个进程组有一个组长进程,组长进程的ID就等于进程组ID; 在组长进程的ID前面加上一个负号即是操作进程组; 组长进程不能再创建新的进程组; 只要进程组中还存在一个进程,则该进程组就存在,这与其组长进程是否终止无关; 一个进程组可以包含一个或多个进程,进程组的生命周期从被创建开始,到其内所有进程终止或离开该进程组; 默认情况下,新创建的进程会继承父进程的进程组ID。 通过系统调用getpgrp()或getpgid()可以获取进程对应的进程组ID,其函数原型如下所示: #include
pid_t getpgid(pid_t pid); pid_t getpgrp(void); 首先使用该函数需要包含头文件。 这两个函数都用于获取进程组ID,getpgrp()没有参数,返回值总是调用者进程对应的进程组ID;而对于getpgid()函数来说,可通过参数pid指定获取对应进程的进程组ID,如果参数pid为0表示获取调用者进程的进程组ID。 getpgid()函数成功将返回进程组ID;失败将返回-1、并设置errno。 所以由此可知,getpgrp()就等价于getpgid(0)。 使用示例 示例代码 10.12.1 获取进程组ID
#include
#include
#include
int main(void)
{
pid_t pid = getpid();
printf("进程组ID---getpgrp()\n", getpgrp());
printf("进程组ID---getpgid(0)\n", getpgid(0));
printf("进程组ID---getpgid(%d)\n", getpgid(pid), pid);
exit(0);
}
测试结果:
图 10.12.2 测试结果 从上面的结果可以发现,其新创建的进程对应的进程组ID等于该进程的ID。 调用系统调用setpgid()或setpgrp()可以加入一个现有的进程组或创建一个新的进程组,其函数原型如下所示: #include
int setpgid(pid_t pid, pid_t pgid); int setpgrp(void); 使用这些函数同样需要包含头文件。 setpgid()函数将参数pid指定的进程的进程组ID设置为参数gpid。如果这两个参数相等(pid==gpid),则由pid指定的进程变成为进程组的组长进程,创建了一个新的进程;如果参数pid等于0,则使用调用者的进程ID;另外,如果参数gpid等于0,则创建一个新的进程组,由参数pid指定的进程作为进程组组长进程。 setpgrp()函数等价于setpgid(0, 0)。 一个进程只能为它自己或它的子进程设置进程组ID,在它的子进程调用exec函数后,它就不能更改该子进程的进程组ID了。 使用示例 示例代码 10.12.2 创建进程组或加入一个现有进程组
#include
#include
#include
int main(void)
{
printf("更改前进程组ID\n", getpgrp());
setpgrp();
printf("更改后进程组ID\n", getpgrp());
exit(0);
}
4、会话 介绍完进程组之后,再来看下会话,会话是一个或多个进程组的集合,其与进程组、进程之间的关系如下图所示:
图 10.12.3 会话 一个会话可包含一个或多个进程组,但只能有一个前台进程组,其它的是后台进程组;每个会话都有一个会话首领(leader),即创建会话的进程。一个会话可以有控制终端、也可没有控制终端,在有控制终端的情况下也只能连接一个控制终端,这通常是登录到其上的终端设备(在终端登录情况下)或伪终端设备(譬如通过SSH协议网络登录),一个会话中的进程组可被分为一个前台进程组以及一个或多个后台进程组。 会话的首领进程连接一个终端之后,该终端就成为会话的控制终端,与控制终端建立连接的会话首领进程被称为控制进程;产生在终端上的输入和信号将发送给会话的前台进程组中的所有进程,譬如Ctrl + C(产生SIGINT信号)、Ctrl + Z(产生SIGTSTP信号)、Ctrl + \(产生SIGQUIT信号)等等这些由控制终端产生的信号。 当用户在某个终端登录时,一个新的会话就开始了;当我们在Linux系统下打开了多个终端窗口时,实际上就是创建了多个终端会话。 一个进程组由组长进程的ID标识,而对于会话来说,会话的首领进程的进程组ID将作为该会话的标识,也就是会话ID(sid),在默认情况下,新创建的进程会继承父进程的会话ID。通过系统调用getsid()可以获取进程的会话ID,其函数原型如下所示: #include
pid_t getsid(pid_t pid); 使用该函数需要包含头文件,如果参数pid为0,则返回调用者进程的会话ID;如果参数pid不为0,则返回参数pid指定的进程对应的会话ID。成功情况下,该函数返回会话ID,失败则返回-1、并设置errno。 使用示例 示例代码 10.12.3 获取进程的会话ID
#include
#include
#include
int main(void)
{
printf("会话ID\n", getsid(0));
exit(0);
}
打印结果:
图 10.12.4 测试结果 使用系统调用setsid()可以创建一个会话,其函数原型如下所示: #include
pid_t setsid(void); 如果调用者进程不是进程组的组长进程,调用setsid()将创建一个新的会话,调用者进程是新会话的首领进程,同样也是一个新的进程组的组长进程,调用setsid()创建的会话将没有控制终端。 setsid()调用成功将返回新会话的会话ID;失败将返回-1,并设置errno。 10.13守护进程 本小节学习守护进程,将对守护进程的概念以及如何编写一个守护进程程序进行介绍。 10.13.1何为守护进程 守护进程(Daemon)也称为精灵进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生,主要表现为以下两个特点: 长期运行。守护进程是一种生存期很长的一种进程,它们一般在系统启动时开始运行,除非强行终止,否则直到系统关机都会保持运行。与守护进程相比,普通进程都是在用户登录或运行程序时创建,在运行结束或用户注销时终止,但守护进程不受用户登录注销的影响,它们将会一直运行着、直到系统关机。 与控制终端脱离。在Linux中,系统与用户交互的界面称为终端,每一个从终端开始运行的进程都会依附于这个终端,这是上一小节给大家介绍的控制终端,也就是会话的控制终端。当控制终端被关闭的时候,该会话就会退出,由控制终端运行的所有进程都会被终止,这使得普通进程都是和运行该进程的终端相绑定的;但守护进程能突破这种限制,它脱离终端并且在后台运行,脱离终端的目的是为了避免进程在运行的过程中的信息在终端显示并且进程也不会被任何终端所产生的信息所打断。 守护进程是一种很有用的进程。Linux中大多数服务器就是用守护进程实现的,譬如,Internet服务器inetd、Web服务器httpd等。同时,守护进程完成许多系统任务,譬如作业规划进程crond等。 守护进程Daemon,通常简称为d,一般进程名后面带有d就表示它是一个守护进程。守护进程与终端无任何关联,用户的登录与注销与守护进程无关、不受其影响,守护进程自成进程组、自成会话,即pid=gid=sid。通过命令"ps -ajx"查看系统所有的进程,如下所示:
图 10.13.1 查看系统中的所有进程 TTY一栏是问号?表示该进程没有控制终端,也就是守护进程,其中COMMAND一栏使用中括号[]括起来的表示内核线程,这些线程是在内核里创建,没有用户空间代码,因此没有程序文件名和命令行,通常采用k开头的名字,表示Kernel。 10.13.2编写守护进程程序 如何将自己编写的程序运行之后变成一个守护进程呢?本小节就来学习如何编写守护进程程序,编写守护进程一般包含如下几个步骤: 1)创建子进程、终止父进程 父进程调用fork()创建子进程,然后父进程使用exit()退出,这样做实现了下面几点。第一,如果该守护进程是作为一条简单地shell命令启动,那么父进程终止会让shell认为这条命令已经执行完毕。第二,虽然子进程继承了父进程的进程组ID,但它有自己独立的进程ID,这保证了子进程不是一个进程组的组长进程,这是下面将要调用setsid函数的先决条件! 2)子进程调用setsid创建会话 这步是关键,在子进程中调用上一小节给大家介绍的setsid()函数创建新的会话,由于之前子进程并不是进程组的组长进程,所以调用setsid()会使得子进程创建一个新的会话,子进程成为新会话的首领进程,同样也创建了新的进程组、子进程成为组长进程,此时创建的会话将没有控制终端。所以这里调用setsid有三个作用:让子进程摆脱原会话的控制、让子进程摆脱原进程组的控制和让子进程摆脱原控制终端的控制。 在调用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进程去处理,这样既不会产生僵尸进程、又省去了服务器进程回收子进程所占用的时间。 守护进程一般以单例模式运行,关于单例模式运行请看9.14小节内容。 接下来,我们根据上面的介绍的步骤,来编写一个守护进程程序,示例代码如下所示: 示例代码 10.13.1 守护进程示例代码
#include
#include
#include
#include
#include
#include
#include
int main(void)
{
pid_t pid;
int i;
/* 创建子进程 */
pid = fork();
if (0 > pid) {
perror("fork error");
exit(-1);
}
else 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
关注
打赏
最近更新
- 深拷贝和浅拷贝的区别(重点)
- 【Vue】走进Vue框架世界
- 【云服务器】项目部署—搭建网站—vue电商后台管理系统
- 【React介绍】 一文带你深入React
- 【React】React组件实例的三大属性之state,props,refs(你学废了吗)
- 【脚手架VueCLI】从零开始,创建一个VUE项目
- 【React】深入理解React组件生命周期----图文详解(含代码)
- 【React】DOM的Diffing算法是什么?以及DOM中key的作用----经典面试题
- 【React】1_使用React脚手架创建项目步骤--------详解(含项目结构说明)
- 【React】2_如何使用react脚手架写一个简单的页面?