百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 优雅编程 > 正文

正点原子I.MX6U嵌入式Linux C应用编程 第十章 进程(上)

sinye56 2025-02-06 16:18 5 浏览 0 评论

本章将讨论进程相关的知识内容,虽然在前面章节内容已经多次向大家提到了进程这个概念,但并未真正地向大家解释这个概念;在本章,我们将一起来学习Linux下进程相关的知识内容,虽然进程的基本概念比较简单,但是其所涉及到的细节内容比较多,所以本章篇幅也会相对比较长,所以,大家加油!

本章将会讨论如下主题内容。

  • 程序与进程基本概念;
  • 程序的开始与结束;
  • 进程的环境变量与虚拟地址空间;
  • 进程ID;
  • fork()创建子进程;
  • 进程的消亡与诞生;
  • 僵尸进程与孤儿进程;
  • 父进程监视子进程;
  • 进程关系与进程的六种状态;
  • 守护进程;
  • 进程间通信概述。

进程与程序

main()函数由谁调用?

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()函数,如此一来,在我们的应用程序当中便可以获取到命令行参数了。

程序如何结束?

程序结束其实就是进程终止,进程终止的方式通常有多种,大体上分为正常终止和异常终止,正常终止包括:

  • 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()函数,那么将不会执行注册的终止处理函数。

何为进程?

本小节正式向大家介绍进程这个概念,前面的内容中也已经多次提到了,其实这个概念本身非常简单,进程其实就是一个可执行程序的实例,这句话如何理解呢?可执行程序就是一个可执行文件,文件是一个静态的概念,存放磁盘中,如果可执行文件没有被运行,那它将不会产生什么作用,当它被运行之后,它将会对系统环境产生一定的影响,所以可执行程序的实例就是可执行文件被运行。

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

进程号

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 测试结果

进程的环境变量

每一个进程都有一组与其相关的环境变量,这些环境变量以字符串形式存储在一个字符串数组列表中,把这个数组称为环境列表。其中每个字符串都是以“名称=值(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环境变量

应用程序中获取环境变量

在我们的应用程序当中也可以获取当前进程的环境变量,事实上,进程的环境变量是从其父进程中继承过来的,譬如在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 测试结果

添加/删除/修改环境变量

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);

清空环境变量

有时,需要清除环境变量表中的所有变量,然后再进行重建,可以通过将全局变量environ赋值为NULL来清空所有变量。

environ = NULL;

也可通过clearenv()函数来操作,函数原型如下所示:

#include

int clearenv(void);

clearenv()函数内部的做法其实就是将environ赋值为NULL。在某些情况下,使用setenv()函数和clearenv()函数可能会导致程序内存泄漏,前面提到过,setenv()函数会为环境变量分配一块内存缓冲区,随之称为进程的一部分;而调用clearenv()函数时没有释放该缓冲区(clearenv()调用并不知晓该缓冲区的存在,故而也无法将其释放),反复调用者两个函数的程序,会不断产生内存泄漏。

环境变量的作用

环境变量常见的用途之一是在shell中,每一个环境变量都有它所表示的含义,譬如HOME环境变量表示用户的家目录,USER环境变量表示当前用户名,SHELL环境变量表示shell解析器名称,PWD环境变量表示当前所在目录等,在我们自己的应用程序当中,也可以使用进程的环境变量。

进程的内存布局

历史沿袭至今,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体系中进程内存布局

进程的虚拟地址空间

上一小节我们讨论了C语言程序的构成以及运行时进程在内存中的布局方式,在Linux系统中,采用了虚拟内存管理技术,事实上大多数现在操作系统都是如此!在Linux系统中,每一个进程都在自己独立的地址空间中运行,在32位系统中,每个进程的逻辑地址空间均为4GB,这4GB的内存空间按照3:1的比例进行分配,其中用户进程享有3G的空间,而内核独自享有剩下的1G空间,如下所示:

图 10.4.1 Linux系统下逻辑地址空间划分

学习过驱动开发的读者对“虚拟地址”这个概念应该并不陌生,虚拟地址会通过硬件MMU(内存管理单元)映射到实际的物理地址空间中,建立虚拟地址到物理地址的映射关系后,对虚拟地址的读写操作实际上就是对物理地址的读写操作,MMU会将物理地址“翻译”为对应的物理地址,其关系如下所示:

图 10.4.2 虚拟地址到物理地址的映射关系

Linux系统下,应用程序运行在一个虚拟地址空间中,所以程序中读写的内存地址对应也是虚拟地址,并不是真正的物理地址,譬如应用程序中读写0x80800000这个地址,实际上并不对应于硬件的0x80800000这个物理地址。

为什么需要引入虚拟地址呢?

计算机物理内存的大小是固定的,就是计算机的实际物理内存,试想一下,如果操作系统没有虚拟地址机制,所有的应用程序访问的内存地址就是实际的物理地址,所以要将所有应用程序加载到内存中,但是我们实际的物理内存只有4G,所以就会出现一些问题:

  • 当多个程序需要运行时,必须保证这些程序用到的内存总量要小于计算机实际的物理内存的大小。
  • 内存使用效率低。内存空间不足时,就需要将其它程序暂时拷贝到硬盘中,然后将新的程序装入内存。然而由于大量的数据装入装出,内存的使用效率就会非常低。
  • 进程地址空间不隔离。由于程序是直接访问物理内存的,所以每一个进程都可以修改其它进程的内存数据,甚至修改内核地址空间中的数据,所以有些恶意程序可以随意修改别的进程,就会造成一些破坏,系统不安全、不稳定。
  • 无法确定程序的链接地址。程序运行时,链接地址和运行地址必须一致,否则程序无法运行!因为程序代码加载到内存的地址是由系统随机分配的,是无法预知的,所以程序的运行地址在编译程序时是无法确认的。

针对以上的一些问题,就引入了虚拟地址机制,程序访问存储器所使用的逻辑地址就是虚拟地址,通过逻辑地址映射到真正的物理内存上。所有应用程序运行在自己的虚拟地址空间中,使得进程的虚拟地址空间和物理地址空间隔离开来,这样做带来了很多的优点:

  • 进程与进程、进程与内核相互隔离。一个进程不能读取或修改另一个进程或内核的内存数据,这是因为每一个进程的虚拟地址空间映射到了不同的物理地址空间。提高了系统的安全性与稳定性。
  • 在某些应用场合下,两个或者更多进程能够共享内存。因为每个进程都有自己的映射表,可以让不同进程的虚拟地址空间映射到相同的物理地址空间中。通常,共享内存可用于实现进程间通信。
  • 便于实现内存保护机制。譬如在多个进程共享内存时,允许每个进程对内存采取不同的保护措施,例如,一个进程可能以只读方式访问内存,而另一进程则能够以可读可写的方式访问。
  • 编译应用程序时,无需关心链接地址。前面提到了,当程序运行时,要求链接地址与运行地址一致,在引入了虚拟地址机制后,便无需关心这个问题。

关于本小节的内容就介绍这么多,理解本小节的内容可以帮助我们更好地理解后面小节中将要介绍的内容。

fork()创建子进程

一个现有的进程可以调用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计算机来说,在某一个时间它只能运行某一个进程的代码指令,那其它进程怎么办呢(多核处理器也是如此,同一时间每个核它只能运行某一个进程的代码)?这里就出现了调度的问题,系统是这样做的,每一个进程(或线程)执行一段固定的时间,时间到了之后切换执行下一个进程或线程,依次轮流执行,这就称为调度,由操作系统负责这件事情,当然系统调度的实现本身是一件非常复杂的事情,需要考虑的因素很多,这里只是让大家有个简单地认识,系统调度的基本单元是线程,关于线程,后面章节内容将会向大家介绍。

父、子进程间的文件共享

调用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 < 4; i++) //循环写入4次

write(fd, "1122", 4);

close(fd);

_exit(0);

default:

/* 父进程 */

for (i = 0; i < 4; i++) //循环写入4次

write(fd, "AABB", 4);

close(fd);

exit(0);

}

}

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

运行测试:

图 10.6.2 测试结果

有上述测试结果可知,此种情况下,父、子进程分别对同一个文件进行写入操作,结果是接续写,不管是父进程,还是子进程,在每次写入时都是从文件的末尾写入,很像使用了O_APPEND标志的效果。其原因也非常简单,图 9.6.1中便给出了答案,子进程继承了父进程的文件描述符,两个文件描述符都指向了一个相同的文件表,意味着它们的文件偏移量是同一个、绑定在了一起,相互影响,子进程改变了文件的位置偏移量就会作用到父进程,同理,父进程改变了文件的位置偏移量就会作用到子进程。

再来测试另外一种情况,父进程在调用fork()之后,此时父进程和子进程都去打开同一个文件,然后再对文件进行写入操作,测试代码如下:

示例代码 10.6.2 父、子各自打开同一个文件实现文件共享

#include

#include

#include

#include

#include

#include

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”,测试结果如下:

图 10.6.3 测试结果

从测试结果可知,这种文件共享方式实现的是一种两个进程分别各自对文件进行写入操作,因为父、子进程的这两个文件描述符分别指向的是不同的文件表,意味着它们有各自的文件偏移量,一个进程修改了文件偏移量并不会影响另一个进程的文件偏移量,所以写入的数据会出现覆盖的情况。

fork()函数使用场景

fork()函数有以下两种用法:

  • 父进程希望子进程复制自己,使父进程和子进程同时执行不同的代码段。这在网络服务进程中是常见的,父进程等待客户端的服务请求,当接收到客户端发送的请求事件后,调用fork()创建一个子进程,使子进程去处理此请求、而父进程可以继续等待下一个服务请求。
  • 一个进程要执行不同的程序。譬如在程序app1中调用fork()函数创建了子进程,此时子进程是要去执行另一个程序app2,也就是子进程需要执行的代码是app2程序对应的代码,子进程将从app2程序的main函数开始运行。这种情况,通常在子进程从fork()函数返回之后立即调用exec族函数来实现,关于exec函数将在后面内容向大家介绍。

系统调用vfork()

除了fork()系统调用之外,Linux系统还提供了vfork()系统调用用于创建子进程,vfork()与fork()函数在功能上是相同的,并且返回值也相同,在一些细节上存在区别,vfork()函数原型如下所示:

#include

#include

pid_t vfork(void);

使用该函数需要包含头文件

从前面的介绍可知,可以将fork()认作对父进程的数据段、堆段、栈段以及其它一些数据结构创建拷贝,由此可以看出,使用fork()系统调用的代价是很大的,它复制了父进程中的数据段和堆栈段中的绝大部分内容,这将会消耗比较多的时间,效率会有所降低,而且太浪费,原因有很多,其中之一在于,fork()函数之后子进程通常会调用exec函数,也就是fork()第二种使用场景下,这使得子进程不再执行父程序中的代码段,而是执行新程序的代码段,从新程序的main函数开始执行、并为新程序重新初始化其数据段、堆段、栈段等;那么在这种情况下,子进程并不需要用到父进程的数据段、堆段、栈段(譬如父程序中定义的局部变量、全局变量等)中的数据,此时就会导致浪费时间、效率降低。

事实上,现代Linux系统采用了一些技术来避免这种浪费,其中很重要的一点就是内核采用了写时复制(copy-on-write)技术,关于这种技术的实现细节就不给大家介绍了,有兴趣读者可以自己搜索相应的文档了解。

出于这一原因,引入了vfork()系统调用,虽然在一些细节上有所不同,但其效率要高于fork()函数。类似于fork(),vfork()可以为调用该函数的进程创建一个新的子进程,然而,vfork()是为子进程立即执行exec()新的程序而专门设计的,也就是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()实现要高出许多,除非速度绝对重要的场合,我们的程序当中应舍弃vfork()而使用fork()。

使用示例

示例代码 10.7.1 vfork()函数使用示例

#include

#include

#include

#include

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);

}

}

测试结果:

图 10.7.1 测试结果

在正式的使用场合下,一般应在子进程中立即调用exec,如果exec调用失败,子进程则应调用_exit()退出(vfork产生的子进程不应调用exit退出,因为这会导致对父进程stdio缓冲区的刷新和关闭)。上述示例代码只是一个简单地演示,并不是vfork()的真正用法,后面学习到exec的时候还会再给大家进行介绍。

fork()之后的竞争条件

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

示例代码 10.8.1 fork()竞争条件测试代码

#include

#include

#include

int main(void)

{

switch (fork()) {

case -1:

perror("fork error");

exit(-1);

case 0:

/* 子进程 */

printf("子进程打印信息\n");

_exit(0);

default:

/* 父进程 */

printf("父进程打印信息\n");

exit(0);

}

}

示例代码中我们是无法确认"子进程打印信息"和"父进程打印信息"谁先会被打印出来,有时子进程先被执行,打印出"子进程打印信息",而有时父进程会先被执行,打印出"子进程打印信息",测试结果如下所示:

图 10.8.1 测试结果

从测试结果可知,虽然绝大部分情况下,父进程会先于子进程被执行,但是并不排除子进程先于父进程被执行的可能性。而对于有些特定的应用程序,它对于执行的顺序有一定要求的,譬如它必须要求父进程先运行,或者必须要求子进程先运行,程序产生正确的结果它依赖于特定的执行顺序,那么将可能因竞争条件而导致失败、无法得到正确的结果。

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

示例代码 10.8.2 利用信号来调整进程间动作

#include

#include

#include

#include

#include

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);

}

}

示例代码比较简单,这里我们希望子进程先运行打印相应信息,之后再执行父进程打印信息,在父进程分支中,直接调用了sigsuspend()使父进程进入挂起状态,由子进程通过kill命令发送信号唤醒,测试结果如下:

图 10.8.2 测试结果

进程的诞生与终止

进程的诞生

一个进程可以通过fork()或vfork()等系统调用创建一个子进程,一个新的进程就此诞生!事实上,Linux系统下的所有进程都是由其父进程创建而来,譬如在shell终端通过命令的方式执行一个程序./app,那么app进程就是由shell终端进程创建出来的,shell终端就是该进程的父进程。

既然所有进程都是由其父进程创建出来的,那么总有一个最原始的父进程吧,否则其它进程是怎么创建出来的呢?确实如此,在Ubuntu系统下使用"ps -aux"命令可以查看到系统下所有进程信息,如下:

图 10.9.1 查看所有进程信息

上图中进程号为1的进程便是所有进程的父进程,通常称为init进程,它是Linux系统启动之后运行的第一个进程,它管理着系统上所有其它进程,init进程是由内核启动,因此理论上说它没有父进程。

init进程的PID总是为1,它是所有子进程的父进程,一切从1开始、一切从init进程开始!

一个进程的生命周期便是从创建开始直至其终止。

进程的终止

通常,进程有两种终止方式:异常终止和正常终止,分别在3.3小节和8.11小节中给大家介绍过,如前所述,进程的正常终止有多种不同的方式,譬如在main函数中使用return返回、调用exit()函数结束进程、调用_exit()或_Exit()函数结束进程等。

异常终止通常也有多种不同的方式,譬如在程序当中调用abort()函数异常终止进程、当进程接收到某些信号导致异常终止等。

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

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

  • 如果程序中注册了进程终止处理函数,那么会调用终止处理函数。在9.1.2小节给大家介绍如何注册进程的终止处理函数;
  • 刷新stdio流缓冲区。关于stdio流缓冲区的问题,稍后编写一个简单地测试程序进行说明;
  • 执行_exit()系统调用。

所以,由此可知,exit()函数会比_exit()会多做一些事情,包括执行终止处理函数、刷新stdio流缓冲以及调用_exit(),在前面曾提到过,在我们的程序当中,父、子进程不应都使用exit()终止,只能有一个进程使用exit()、而另一个则使用_exit()退出,当然一般推荐的是子进程使用_exit()退出、而父进程则使用exit()退出。其原因就在于调用exit()函数终止进程时会刷新进程的stdio缓冲区。接下来我们便通过一个示例代码进行说明:

示例代码 10.9.1 exit()之stdio缓冲测试代码1

#include

#include

#include

int main(void)

{

printf("Hello World!\n");

switch (fork()) {

case -1:

perror("fork error");

exit(-1);

case 0:

/* 子进程 */

exit(0);

default:

/* 父进程 */

exit(0);

}

}

在上述代码中,在fork()创建子进程之前,我们通过printf()打印了一行包括换行符\n在内字符串,在fork()创建子进程之后,都使用exit()退出进程,正常的情况下程序就只会打印一行"Hello World!",这是一个正常的情况,事实上也确实如此,如下所示:

图 10.9.2 测试结果

打印结果确实如我们所料,接下来将代码进行简单地修改,把printf()打印的字符串最后面的换行符\n去掉,如下所示:

示例代码 10.9.2 exit()之stdio缓冲测试代码2

#include

#include

#include

int main(void)

{

printf("Hello World!");

switch (fork()) {

case -1:

perror("fork error");

exit(-1);

case 0:

/* 子进程 */

exit(0);

default:

/* 父进程 */

exit(0);

}

}

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

图 10.9.3 测试结果

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

而对于示例代码 9.9.2来说,printf()并没有添加换行符\n,当调用printf()时并不会立即读取缓冲区中的数据进行显示,由此fork()之后创建的子进程也自然拷贝了缓冲区的数据,当它们调用exit()函数时,都会刷新各自的缓冲区、显示字符串,所以就会看到打印出了两次相同的字符串。

可以采用以下任一方法来避免重复的输出结果:

  • 对于行缓冲设备,可以加上对应换行符,譬如printf打印输出字符串时在字符串后面添加\n换行符,对于puts()函数来说,本身会自动添加换行符;
  • 在调用fork()之前,使用函数fflush()来刷新stdio缓冲区,当然,作为另一种选择,也可以使用setvbuf()和setbuf()来关闭stdio流的缓冲功能,这些内容在第四章中已经给大家介绍过;
  • 子进程调用_exit()退出进程、而非使用exit(),调用_exit()在退出时便不会刷新stdio缓冲区,这也解释前面为什么我们要在子进程中使用_exit()退出这样做的一个原因。将示例代码 9.9.2中子进程的退出操作exit()替换成_exit()进行测试,打印的结果便只会显示一次字符串,大家自己动手试一试!

关于本小节的内容,到这里就结束了,虽然笔者觉得自己已经介绍得很详细了,如果大家觉得还有不懂的地方,可以自己编写程序进行测试、验证,编程是一门动手实践性很强的工作,大家要善于从中发现一些问题,然后自己能够编写程序进行测试、验证,大家加油!

监视子进程

在很多应用程序的设计中,父进程需要知道子进程于何时被终止,并且需要知道子进程的终止状态信息,是正常终止、还是异常终止亦或者被信号终止等,意味着父进程会对子进程进行监视,本小节我们就来学习下如何通过系统调用wait()以及其它变体来监视子进程的状态改变。

wait()函数

对于许多需要创建子进程的进程来说,有时设计需要监视子进程的终止时间以及终止时的一些状态信息,在某些设计需求下这是很有必要的。系统调用wait()可以等待进程的任一子进程终止,同时获取子进程的终止状态信息,其函数原型如下所示:

#include

#include

pid_t wait(int *status);

使用该函数需要包含头文件

函数参数和返回值含义如下:

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手册。

使用示例

示例代码 10.10.1 wait()函数使用示例

#include

#include

#include

#include

#include

#include

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);

}

示例代码中,通过for循环创建了3个子进程,父进程中循环调用wait()函数等待回收子进程,并将本次回收的子进程进程号以及终止状态打印出来,编译测试结果如下:

图 10.10.1 测试结果

waitpid()函数

使用wait()系统调用存在着一些限制,这些限制包括如下:

  • 如果父进程创建了多个子进程,使用wait()将无法等待某个特定的子进程的完成,只能按照顺序等待下一个子进程的终止,一个一个来、谁先终止就先处理谁;
  • 如果子进程没有终止,正在运行,那么wait()总是保持阻塞,有时我们希望执行非阻塞等待,是否有子进程终止,通过判断即可得知;
  • 使用wait()只能发现那些被终止的子进程,对于子进程因某个信号(譬如SIGSTOP信号)而停止(注意,这里停止指的暂停运行),或是已停止的子进程收到SIGCONT信号后恢复执行的情况就无能为力了。

而设计waitpid()则可以突破这些限制,waitpid()系统调用函数原型如下所示:

#include

#include

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信号而恢复运行的子进程的状态信息。

从以上的介绍可知,waitpid()在功能上要强于wait()函数,它弥补了wait()函数所带来的一些限制,具体在实际的编程使用当中,可根据自己的需求进行选择。

使用示例

使用waitpid()替换wait(),改写示例代码 9.10.1。

示例代码 10.10.2 waitpid()阻塞方式

#include

#include

#include

#include

#include

#include

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 = waitpid(-1, &status, 0);

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);

}

将wait(&status)替换成了waitpid(-1, &status, 0),通过上面的介绍可知,waitpid()函数的这种参数配置情况与wait()函数是完全等价的,运行结果与示例代码 9.10.1运行结果相同,这里不再演示!

将上述代码进行简单修改,将其修改成轮训方式,如下所示:

示例代码 10.10.3 waitpid()轮训方式

#include

#include

#include

#include

#include

#include

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);

}

将waitpid()函数的options参数添加WNOHANG标志,将waitpid()配置成非阻塞模式,使用轮训的方式依次回收各个子进程,测试结果如下:

相关推荐

程序员:JDK的安装与配置(完整版)_jdk的安装方法

对于Java程序员来说,jdk是必不陌生的一个词。但怎么安装配置jdk,对新手来说确实头疼的一件事情。我这里以jdk10为例,详细的说明讲解了jdk的安装和配置,如果有不明白的小伙伴可以评论区留言哦下...

Linux中安装jdk并配置环境变量_linux jdk安装教程及环境变量配置

一、通过连接工具登录到Linux(我这里使用的Centos7.6版本)服务器连接工具有很多我就不一一介绍了今天使用比较常用的XShell工具登录成功如下:二、上传jdk安装包到Linux服务器jdk...

麒麟系统安装JAVA JDK教程_麒麟系统配置jdk

检查检查系统是否自带java在麒麟系统桌面空白处,右键“在终端打开”,打开shell对话框输入:java–version查看是否自带java及版本如图所示,系统自带OpenJDK,要先卸载自带JDK...

学习笔记-Linux JDK - 安装&amp;配置

前提条件#检查是否存在JDKrpm-qa|grepjava#删除现存JDKyum-yremovejava*安装OracleJDK不分系统#进入安装文件目...

Linux新手入门系列:Linux下jdk安装配置

本系列文章是把作者刚接触和学习Linux时候的实操记录分享出来,内容主要包括Linux入门的一些理论概念知识、Web程序、mysql数据库的简单安装部署,希望能够帮到一些初学者,少走一些弯路。注意:L...

测试员必备:Linux下安装JDK 1.8你必须知道的那些事

1.简介在Oracle收购Sun后,Java的一系列产品就被整合到Oracle官网中,打开官网乍眼一看也不知道去哪里下载,还得一个一个的摸索尝试,而且网上大多数都是一些Oracle收购Sun前,或者就...

Linux 下安装JDK17_linux 安装jdk1.8 yum

一、安装环境操作系统:JDK版本:17二、安装步骤第一步:下载安装包下载Linux环境下的jdk1.8,请去官网(https://www.oracle.com/java/technologies/do...

在Ubuntu系统中安装JDK 17并配置环境变量教程

在Ubuntu系统上安装JDK17并配置环境变量是Java开发环境搭建的重要步骤。JDK17是Oracle提供的长期支持版本,广泛用于开发Java应用程序。以下是详细的步骤,帮助你在Ubuntu系...

如何在 Linux 上安装 Java_linux安装java的步骤

在桌面上拥抱Java应用程序,然后在所有桌面上运行它们。--SethKenlon(作者)无论你运行的是哪种操作系统,通常都有几种安装应用程序的方法。有时你可能会在应用程序商店中找到一个应用程序...

Windows和Linux环境下的JDK安装教程

JavaDevelopmentKit(简称JDK),是Java开发的核心工具包,提供了Java应用程序的编译、运行和开发所需的各类工具和类库。它包括了JRE(JavaRuntimeEnviro...

linux安装jdk_linux安装jdk软连接

JDK是啥就不用多介绍了哈,外行的人也不会进来看我的博文。依然记得读大学那会,第一次实验课就是在机房安装jdk,编写HelloWorld程序。时光飞逝啊,一下过了十多年了,挣了不少钱,买了跑车,娶了富...

linux安装jdk,全局配置,不同用户不同jdk

jdk1.8安装包链接:https://pan.baidu.com/s/14qBrh6ZpLK04QS8ogCepwg提取码:09zs上传文件解压tar-zxvfjdk-8u152-linux-...

运维大神教你在linux下安装jdk8_linux安装jdk1.7

1.到官网下载适合自己机器的版本。楼主下载的是jdk-8u66-linux-i586.tar.gzhttp://www.oracle.com/technetwork/java/javase/downl...

window和linux安装JDK1.8_linux 安装jdk1.8.tar

Windows安装JDK1.8的步骤:步骤1:下载JDK打开浏览器,找到JDK下载页面https://d.injdk.cn/download/oraclejdk/8在页面中找到并点击“下载...

最全的linux下安装JavaJDK的教程(图文详解)不会安装你来打我?

默认已经有了linux服务器,且有root账号首先检查一下是否已经安装过java的jdk任意位置输入命令:whichjava像我这个已经安装过了,就会提示在哪个位置,你的肯定是找不到。一般我们在...

取消回复欢迎 发表评论: