Linux进程基础

进程概述 

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 <stdlib.h>

int atexit(void (*function)(void));

使用该函数需要包含头文件<stdlib.h>。

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

function:函数指针,指向注册的函数,此函数无需传入参数、无返回值。

返回值:成功返回 0;失败返回非 0。

需要说明的是,如果程序当中使用了_exit()或_Exit()终止进程而并非是 exit()函数,那么将不会执行注册的终止处理函数。

何为进程?

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

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

把哪些文件进行编译链接生成一个可执行文件是由编译链接过程来实现的,我们可以编写makefile文件来指定这一过程。

进程号

Linux 系统下的每一个进程都有一个进程号(processID,简称 PID),进程号是一个正数,用于唯一标识系统中的某一个进程。在 Ubuntu 系统下执行 ps 命令可以查到系统中进程相关的一些信息,包括每个进程的进程号,如下所示:

注意对比文件描述符、进程号、线程号等等。

上图中红框标识显示的便是每个进程所对应的进程号,进程号的作用就是用于唯一标识系统中某一个进程,在某些系统调用中,进程号可以作为传入参数、有时也可作为返回值。譬如系统调用 kill()允许调用者向某一个进程发送一个信号,如何表示这个进程呢?则是通过进程号进行标识。

在应用程序中,可通过系统调用 getpid()来获取本进程的进程号,其函数原型如下所示:

#include <sys/types.h>

#include <unistd.h>

pid_t getpid(void);

使用该函数需要包含头文件<sys/types.h>和<unistd.h>。

函数返回值为 pid_t 类型变量,便是对应的进程号。

除了 getpid()用于获取本进程的进程号之外,还可以使用 getppid()系统调用获取父进程的进程号,其函数原型如下所示:

#include <sys/types.h>

#include <unistd.h>

pid_t getppid(void);

返回值对应的便是父进程的进程号。

应用程序中操作环境变量

在我们的应用程序当中也可以获取当前进程的环境变量,事实上,进程的环境变量是从其父进程中继承过来的,譬如在 shell 终端下执行一个应用程序,那么该进程的环境变量就是从其父进程(shell 进程)中继承过来的。新的进程在创建之前,会继承其父进程的环境变量副本。

环境变量存放在一个字符串数组中,在应用程序中,通过 environ 变量指向它,environ 是一个全局变量,在我们的应用程序中只需申明它即可使用,如下所示:

extern char **environ;

// 申明外部全局变量 environ

获取指定环境变量 getenv()

如果只想要获取某个指定的环境变量,可以使用库函数 getenv(),其函数原型如下所示:

#include <stdlib.h>

char *getenv(const char *name);

使用该函数需要包含头文件<stdlib.h>。

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

name:指定获取的环境变量名称。

返回值:如果存放该环境变量,则返回该环境变量的值对应字符串的指针;如果不存在该环境变量,则返回 NULL。

使用 getenv()需要注意,不应该去修改其返回的字符串,修改该字符串意味着修改了环境变量对应的值,Linux 提供了相应的修改函数,如果需要修改环境变量的值应该使用这些函数,不应直接改动该字符串。

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

C 语言函数库中提供了用于修改、添加、删除环境变量的函数,譬如 putenv()、setenv()、unsetenv()、 clearenv()函数等。

putenv()函数

putenv()函数可向进程的环境变量数组中添加一个新的环境变量,或者修改一个已经存在的环境变量对应的值,其函数原型如下所示:

#include <stdlib.h>

int putenv(char *string);

使用该函数需要包含头文件<stdlib.h>。

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

string:参数 string 是一个字符串指针,指向 name=value 形式的字符串。

返回值:成功返回 0;失败将返回非 0 值,并设置 errno。

该函数调用成功之后,参数 string 所指向的字符串就成为了进程环境变量的一部分了,换言之,putenv()函数将设定 environ 变量(字符串数组)中的某个元素(字符串指针)指向该 string 字符串,而不是指向它的复制副本,这里需要注意!因此,不能随意修改参数 string 所指向的内容,这将影响进程的环境变量,出于这种原因,参数 string 不应为自动变量(即在栈中分配的字符数组)。

setenv()函数

setenv()函数可以替代 putenv()函数,用于向进程的环境变量列表中添加一个新的环境变量或修改现有环境变量对应的值,其函数原型如下所示:

#include <stdlib.h>

int setenv(const char *name, const char *value, int overwrite);

使用该函数需要包含头文件<stdlib.h>。

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

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()的参数也不会有问题。

除了上面给大家介绍的函数之外,我们还可以通过一种更简单地方式向进程环境变量表中添加环境变量,用法如下:

NAME=value ./app

在执行程序的时候,在其路径前面添加环境变量,以 name=value 的形式添加,如果是多个环境变量,则在./app 前面放置多对 name=value 即可,以空格分隔。

unsetenv()函数

unsetenv()函数可以从环境变量表中移除参数 name 标识的环境变量,其函数原型如下所示:

#include <stdlib.h>

int unsetenv(const char *name);

清空环境变量

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

environ = NULL;

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

#include <stdlib.h>

int clearenv(void);

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

进程的内存布局

历史沿袭至今,C 语言程序一直都是由以下几部分组成的:

正文段。也可称为代码段,这是 CPU 执行的机器语言指令部分,文本段具有只读属性,以防止程序由于意外而修改其指令;正文段是可以共享的,即使在多个进程间也可同时运行同一段程序。

初始化数据段。通常将此段称为数据段,包含了显式初始化的全局变量和静态变量,当程序加载到内存中时,从可执行文件中读取这些变量的值。

未初始化数据段。包含了未进行显式初始化的全局变量和静态变量,通常将此段称为 bss 段,这一名词来源于早期汇编程序中的一个操作符,意思是“由符号开始的块”(block started by symbol),在程序开始执行之前,系统会将本段内所有内存初始化为 0,可执行文件并没有为 bss 段变量分配存储空间,在可执行文件中只需记录 bss 段的位置及其所需大小,直到程序运行时,由加载器来分配这一段内存空间。

。函数内的局部变量以及每次函数调用时所需保存的信息都放在此段中,每次调用函数时,函数传递的实参以及函数返回值等也都存放在栈中。栈是一个动态增长和收缩的段,由栈帧组成,系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值。

。可在运行时动态进行内存分配的一块区域,譬如使用 malloc()分配的内存空间,就是从系统堆内存中申请分配的。

Linux 下的 size 命令可以查看二进制可执行文件的文本段、数据段、bss 段的段大小:

图 9.3.2 显示了这些段在内存中的典型布局方式,当然,并不要求具体的实现一定是以这种方式安排其存储空间,但这是一种便于我们说明的典型方式。

进程的虚拟地址空间

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

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

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

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

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

⚫ 当多个程序需要运行时,必须保证这些程序用到的内存总量要小于计算机实际的物理内存的大小。

内存使用效率低。内存空间不足时,就需要将其它程序暂时拷贝到硬盘中,然后将新的程序装入内存。然而由于大量的数据装入装出,内存的使用效率就会非常低。

进程地址空间不隔离。由于程序是直接访问物理内存的,所以每一个进程都可以修改其它进程的内存数据,甚至修改内核地址空间中的数据,所以有些恶意程序可以随意修改别的进程,就会造成一些破坏,系统不安全、不稳定。

无法确定程序的链接地址。程序运行时,链接地址和运行地址必须一致,否则程序无法运行!因为程序代码加载到内存的地址是由系统随机分配的,是无法预知的,所以程序的运行地址在编译程序时是无法确认的。

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

⚫ 进程与进程、进程与内核相互隔离。一个进程不能读取或修改另一个进程或内核的内存数据,这是因为每一个进程的虚拟地址空间映射到了不同的物理地址空间。提高了系统的安全性与稳定性。

⚫ 在某些应用场合下,两个或者更多进程能够共享内存。因为每个进程都有自己的映射表,可以让不同进程的虚拟地址空间映射到相同的物理地址空间中。通常,共享内存可用于实现进程间通信。

⚫ 便于实现内存保护机制。譬如在多个进程共享内存时,允许每个进程对内存采取不同的保护措施,例如,一个进程可能以只读方式访问内存,而另一进程则能够以可读可写的方式访问。

⚫ 编译应用程序时,无需关心链接地址。前面提到了,当程序运行时,要求链接地址与运行地址一致,在引入了虚拟地址机制后,便无需关心这个问题。

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

fork()创建子进程

一个现有的进程可以调用 fork()函数创建一个新的进程,调用 fork()函数的进程称为父进程,由 fork()函数创建出来的进程被称为子进程(child process),fork()函数原型如下所示(fork()为系统调用):

#include <unistd.h>

pid_t fork(void);

使用该函数需要包含头文件<unistd.h>。

在诸多的应用中,创建多个进程是任务分解时行之有效的方法,譬如,某一网络服务器进程可在监听客户端请求的同时,为处理每一个请求事件而创建一个新的子进程,与此同时,服务器进程会继续监听更多的客户端连接请求。在一个大型的应用程序任务中,创建子进程通常会简化应用程序的设计,同时提高了系统的并发性(即同时能够处理更多的任务或请求,多个进程在宏观上实现同时运行)。

理解 fork()系统调用的关键在于,完成对其调用后将存在两个进程,一个是原进程(父进程)、另一个则是创建出来的子进程,并且每个进程都会从 fork()函数的返回处继续执行,会导致调用 fork()返回两次值,子进程返回一个值、父进程返回一个值。在程序代码中,可通过返回值来区分是子进程还是父进程。

fork()调用成功后,将会在父进程中返回子进程的 PID,而在子进程中返回值是 0;如果调用失败,父进程返回值-1,不创建子进程,并设置 errno。

fork()调用成功后,子进程和父进程会继续执行 fork()调用之后的指令,子进程、父进程各自在自己的进程空间中运行。事实上,子进程是父进程的一个副本,譬如子进程拷贝了父进程的数据段、堆、栈以及继承了父进程打开的文件描述符,父进程与子进程并不共享这些存储空间,这是子进程对父进程相应部分存储空间的完全复制,执行 fork()之后,每个进程均可修改各自的栈数据以及堆段中的变量,而并不影响另一个进程。

虽然子进程是父进程的一个副本,但是对于程序代码段(文本段)来说,两个进程执行相同的代码段,因为代码段是只读的,也就是说父子进程共享代码段,在内存中只存在一份代码段数据。

使用示例 1

使用 fork()创建子进程。

上述示例代码中,case 0 是子进程的分支,这里使用了_exit()结束进程而没有使用 exit()。

Tips:C 库函数 exit()建立在系统调用_exit()之上,这两个函数在 3.3 小节中向大家介绍过,这里我们强调,在调用了 fork()之后,父、子进程中一般只有一个会通过调用 exit()退出进程,而另一个则应使用_exit()退出,具体原因将会在后面章节内容中向大家做进一步说明!

直接测试运行查看打印结果:

从打印结果可知,fork()之后的语句被执行了两次,所以 switch…case 语句被执行了两次,第一次进入到了"case 0"分支,通过上面的介绍可知,fork()返回值为 0 表示当前处于子进程;在子进程中我们通过 getpid()获取到子进程自己的 PID(46802),通过 getppid()获取到父进程的 PID(46803),将其打印出来。第二次进入到了 default 分支,表示当前处于父进程,此时 fork()函数的返回值便是创建出来的子进程对应的 PID。fork()函数调用完成之后,父进程、子进程会各自继续执行 fork()之后的指令,最终父进程会执行到 exit() 结束进程,而子进程则会通过_exit()结束进程。 fork()函数调用完成之后,父进程、子进程会各自继续执行 fork()之后的指令,它们共享代码段,但并不共享数据段、堆、栈等,而是子进程拥有父进程数据段、堆、栈等副本,所以对于同一个局部变量,它们打印出来的值是不相同的,因为 fork()调用返回值不同,在父、子进程中赋予了 pid 不同的值。

关于子进程

子进程被创建出来之后,便是一个独立的进程,拥有自己独立的进程空间,系统内唯一的进程号,拥有自己独立的 PCB(进程控制块),子进程会被内核同等调度执行,参与到系统的进程调度中。

子进程与父进程之间的这种关系被称为父子进程关系,父子进程关系相比于普通的进程间关系多多少少存在一些关联与“羁绊”,关于这些关联与“羁绊”我们将会在后面的课程中为大家介绍。

Tips:系统调度。Linux 系统是一个多任务、多进程、多线程的操作系统,一般来说系统启动之后会运行成百甚至上千个不同的进程,那么对于单核 CPU 计算机来说,在某一个时间它只能运行某一个进程的代码指令,那其它进程怎么办呢(多核处理器也是如此,同一时间每个核它只能运行某一个进程的代码)?这里就出现了调度的问题,系统是这样做的,每一个进程(或线程)执行一段固定的时间,时间到了之后切换执行下一个进程或线程,依次轮流执行,这就称为调度,由操作系统负责这件事情,当然系统调度的实现本身是一件非常复杂的事情,需要考虑的因素很多,这里只是让大家有个简单地认识,系统调度的基本单元是线程。

fork()函数使用场景

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

⚫ 父进程希望子进程复制自己,使父进程和子进程同时执行不同的代码段。这在网络服务进程中是常见的,父进程等待客户端的服务请求,当接收到客户端发送的请求事件后,调用 fork()创建一个子进程,使子进程去处理此请求、而父进程可以继续等待下一个服务请求。

⚫ 一个进程要执行不同的程序。譬如在程序 app1 中调用 fork()函数创建了子进程,此时子进程是要去执行另一个程序 app2,也就是子进程需要执行的代码是 app2 程序对应的代码,子进程将从 app2程序的 main 函数开始运行。这种情况,通常在子进程从 fork()函数返回之后立即调用 exec 族函数来实现,关于 exec 函数将在后面内容向大家介绍。

系统调用 vfork()

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

#include <sys/types.h>

#include <unistd.h>

pid_t vfork(void);

使用该函数需要包含头文件<sys/types.h>和<unistd.h>。

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

出于这一原因,引入了 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(),写时复制 (copy-on-write)技术,关于这种技术的实现细节就不给大家介绍了,有兴趣读者可以自己搜索相应的文档了解。 其效率较之于早期的 fork()实现要高出许多,除非速度绝对重要的场合,我们的程序当中应舍弃 vfork()而使用 fork()。

在正式的使用场合下,一般应在子进程中立即调用 exec,如果 exec 调用失败,子进程则应调用_exit()退出

fork()之后的竞争条件

调用 fork()之后,子进程成为了一个独立的进程,可被系统调度运行,而父进程也继续被系统调度运行,这里出现了一个问题,调用 fork 之后,无法确定父、子两个进程谁将率先访问 CPU,也就是说无法确认谁先被系统调用运行(在多核处理器中,它们可能会同时各自访问一个 CPU),这将导致谁先运行、谁后运行这个顺序是不确定的,那如何明确保证某一特性执行顺序呢?这个时候可以通过采用采用某种同步技术来实现,比如信号。

进程的诞生与终止

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

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

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

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

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

进程的终止

通常,进程有两种终止方式:异常终止和正常终止,分别在 3.3 小节和 8.12 小节中给大家介绍过,如前所述,进程的正常终止有多种不同的方式,譬如在 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 缓冲区。

exit、_exit、_Exit

当程序在执行某个函数出错的时候,如果此函数执行失败会导致后面的步骤不能在进行下去时,应该在出错时终止程序运行,不应该让程序继续运行下去,那么如何退出程序、终止程序运行呢?有过编程经验的读者都知道使用 return,一般原则程序执行正常退出 return 0,而执行函数出错退出 return -1,前面我们所编写的示例代码也是如此。

在 Linux 系统下,进程(程序)退出可以分为正常退出和异常退出,注意这里说的异常并不是执行函数出现了错误这种情况,异常往往更多的是一种不可预料的系统异常,可能是执行了某个函数时发生的、也有可能是收到了某种信号等,这里我们只讨论正常退出的情况。

在 Linux 系统下,进程正常退出除了可以使用 return 之外,还可以使用 exit()、_exit()以及_Exit(),下面我们分别介绍。

_exit()和_Exit()函数

main 函数中使用 return 后返回,return 执行后把控制权交给调用函数,结束该进程。调用_exit()函数会清除其使用的内存空间,并销毁其在内核中的各种数据结构,关闭进程的所有文件描述符,并结束进程、将控制权交给操作系统。_exit()函数原型如下所示:

#include <unistd.h>

void _exit(int status);

调用函数需要传入 status 状态标志,0 表示正常结束、若为其它值则表示程序执行过程中检测到有错误发生。

用法很简单,大家可以自行测试!

_Exit()函数原型如下所示:

#include <stdlib.h>

void _Exit(int status);

_exit()和_Exit()两者等价,用法作用是一样的,这里就不再讲了,需要注意的是这 2 个函数都是系统调用。

exit()函数

exit()函数_exit()函数都是用来终止进程的,exit()是一个标准 C 库函数,而_exit()和_Exit()是系统调用。

执行 exit()会执行一些清理工作,最后调用_exit()函数。exit()函数原型如下:

#include <stdlib.h>

void exit(int status);

该函数是一个标准 C 库函数,使用该函数需要包含头文件<stdlib.h>,该函数的用法和_exit()/_Exit()是一样的,这里就不再多说了。

本小节就给大家介绍了 3 中终止进程的方法:

⚫ main 函数中运行 return;

⚫ 调用 Linux 系统调用_exit()或_Exit();

⚫ 调用 C 标准库函数 exit()。

不管你用哪一种都可以结束进程,但还是推荐大家使用 exit(),其实关于 return、exit、_exit/_Exit()之间的区别笔者在上面只是给大家简单地描述了一下,甚至不太确定我的描述是否正确,因为笔者并不太多去关心其间的差异,对这些概念的描述会比较模糊、笼统,如果大家看不明白可以自己百度搜索相关的内容,当然对于初学者来说,不太建议大家去查找这些东西,至少对你现阶段来说,意义不是很大。

监视子进程

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

wait()函数

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

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

waitpid()函数

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

⚫ 如果父进程创建了多个子进程,使用 wait()将无法等待某个特定的子进程的完成,只能按照顺序等待下一个子进程的终止,一个一个来、谁先终止就先处理谁;

⚫ 如果子进程没有终止,正在运行,那么 wait()总是保持阻塞,有时我们希望执行非阻塞等待,是否有子进程终止,通过判断即可得知;

⚫ 使用 wait()只能发现那些被终止的子进程,对于子进程因某个信号(譬如 SIGSTOP 信号)而停止(注意,这里停止指的暂停运行),或是已停止的子进程收到 SIGCONT 信号后恢复执行的情况就无能为力了。

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

#include <sys/types.h>

#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *status, int options);

使用该函数需要包含头文件<sys/types.h>和<sys/wait.h>。

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

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()函数所带来的一些限制,具体在实际的编程使用当中,可根据自己的需求进行选择。

僵尸进程与孤儿进程 

当一个进程创建子进程之后,它们俩就成为父子进程关系,父进程与子进程的生命周期往往是不相同的,这里就会出现两个问题:

⚫ 父进程先于子进程结束。

⚫ 子进程先于父进程结束。

本小节我们就来讨论下这两种不同的情况。

孤儿进程

父进程先于子进程结束,也就是意味着,此时子进程变成了一个“孤儿”,我们把这种进程就称为孤儿进程。在 Linux 系统当中,所有的孤儿进程都自动成为 init 进程(进程号为 1)的子进程,换言之,某一子进程的父进程结束后,该子进程调用 getppid()将返回 1,init 进程变成了孤儿进程的“养父”;这是判定某一子进程的“生父”是否还“在世”的方法之一,

僵尸进程

进程结束之后,通常需要其父进程为其“收尸”,回收子进程占用的一些内存资源,父进程通过调用wait()(或其变体 waitpid()、waitid()等)函数回收子进程资源,归还给系统。

如果子进程先于父进程结束,此时父进程还未来得及给子进程“收尸”,那么此时子进程就变成了一个僵尸进程。子进程结束后其父进程并没有来得及立马给它“收尸”,子进程处于“曝尸荒野”的状态,在这么一个状态下,我们就将子进程成为僵尸进程;至于名字由来,肯定是对电影情节的一种效仿!

当父进程调用 wait()(或其变体,下文不再强调)为子进程“收尸”后,僵尸进程就会被内核彻底删除。

另外一种情况,如果父进程并没有调用 wait()函数然后就退出了,那么此时 init 进程将会接管它的子进程并自动调用 wait(),故而从系统中移除僵尸进程。

如果父进程创建了某一子进程,子进程已经结束,而父进程还在正常运行,但父进程并未调用 wait()回收子进程,此时子进程变成一个僵尸进程。首先来说,这样的程序设计是有问题的,如果系统中存在大量的僵尸进程,它们势必会填满内核进程表,从而阻碍新进程的创建。需要注意的是,僵尸进程是无法通过信号将其杀死的,即使是“一击必杀”信号 SIGKILL 也无法将其杀死,那么这种情况下,只能杀死僵尸进程的父进程(或等待其父进程终止),这样 init 进程将会接管这些僵尸进程,从而将它们从系统中清理掉!所以,在我们的一个程序设计中,一定要监视子进程的状态变化,如果子进程终止了,要调用 wait()将其回收,避免僵尸进程。

SIGCHLD 信号

SIGCHLD 信号在第八章中给大家介绍过,当发生以下两种情况时,父进程会收到该信号:

⚫ 当父进程的某个子进程终止时,父进程会收到 SIGCHLD 信号;

⚫ 当父进程的某个子进程因收到信号而停止(暂停运行)或恢复时,内核也可能向父进程发送该信号。

子进程的终止属于异步事件,父进程事先是无法预知的,如果父进程有自己需要做的事情,它不能一直wait()阻塞等待子进程终止(或轮训),这样父进程将啥事也做不了,那么有什么办法来解决这样的尴尬情况,当然有办法,那就是通过 SIGCHLD 信号。

那既然子进程状态改变时(终止、暂停或恢复),父进程会收到 SIGCHLD 信号,SIGCHLD 信号的系统默认处理方式是将其忽略,所以我们要捕获它、绑定信号处理函数,在信号处理函数中调用 wait()收回子进程,回收完毕之后再回到父进程自己的工作流程中。

执行新程序

在前面已经大家提到了 exec 函数,当子进程的工作不再是运行父进程的代码段,而是运行另一个新程序的代码,那么这个时候子进程可以通过 exec 函数来实现运行另一个新的程序。本小节我们就来学习下,如何在程序中运行一个新的程序,从新程序的 main()函数开始运行。

execve()函数

系统调用 execve()可以将新程序加载到某一进程的内存空间,通过调用 execve()函数将一个外部的可执行文件加载到进程的内存空间运行,使用新的程序替换旧的程序,而进程的栈、数据、以及堆数据会被新程序的相应部件所替换,然后从新程序的 main()函数开始执行。

execve()函数原型如下所示:

#include <unistd.h>

int execve(const char *filename, char *const argv[], char *const envp[]);

使用该函数需要包含头文件<unistd.h>。

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

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 操作。

exec 库函数

exec 族函数包括多个不同的函数,这些函数命名都以 exec 为前缀,上一小节给大家介绍的 execve()函数也属于 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[]);

使用这些函数需要包含头文件<unistd.h>。

不赘述,有需要自行查阅资料。

system()函数

使用 system()函数可以很方便地在我们的程序当中执行任意 shell 命令,本小节来学习下 system()函数的用法,以及介绍 system()函数的实现方法。

首先来看看 system()函数原型,如下所示:

#include <stdlib.h>

int system(const char *command);

这是一个库函数,使用该函数需要包含头文件<stdlib.h>。

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

command:参数 command 指向需要执行的 shell 命令,以字符串的形式提供,譬如"ls -al"、"echo HelloWorld"等。

返回值:关于 system()函数的返回值有多种不同的情况,稍后给大家介绍。

system()函数其内部的是通过调用 fork()、execl()以及 waitpid()这三个函数来实现它的功能,首先 system()会调用 fork()创建一个子进程来运行 shell(可以把这个子进程成为 shell 进程),并通过 shell 执行参数command 所指定的命令。譬如:

system("ls -la")

system("echo HelloWorld")

system()的返回值如下:

⚫ 当参数 command 为 NULL,如果 shell 可用则返回一个非 0 值,若不可用则返回 0;针对一些非UNIX 系统,该系统上可能是没有 shell 的,这样就会导致 shell 不可能;如果 command 参数不为NULL,则返回值从以下的各种情况所决定。

⚫ 如果无法创建子进程或无法获取子进程的终止状态,那么 system()返回-1;

⚫ 如果子进程不能执行 shell,则 system()的返回值就好像是子进程通过调用_exit(127)终止了;

⚫ 如果所有的系统调用都成功,system()函数会返回执行 command 的 shell 进程的终止状态。

system()的主要优点在于使用上方便简单,编程时无需自己处理对 fork()、exec 函数、waitpid()以及 exit()等调用细节,system()内部会代为处理;当然这些优点通常是以牺牲效率为代价的,使用 system()运行 shell命令需要至少创建两个进程,一个进程用于运行 shell、另外一个或多个进程则用于运行参数 command 中解析出来的命令,每一个命令都会调用一次 exec 函数来执行;所以从这里可以看出,使用 system()函数其效率会大打折扣,如果我们的程序对效率或速度有所要求,那么建议大家不是直接使用 system()。 通常情况下是可以使用的。

进程状态

Linux 系统下进程通常存在 6 种不同的状态,分为:就绪态、运行态、僵尸态、可中断睡眠状态(浅度睡眠)、不可中断睡眠状态(深度睡眠)以及暂停态。

⚫ 就绪态(Ready):指该进程满足被 CPU 调度的所有条件但此时并没有被调度执行,只要得到 CPU就能够直接运行;意味着该进程已经准备好被 CPU 执行,当一个进程的时间片到达,操作系统调度程序会从就绪态链表中调度一个进程;

⚫ 运行态:指该进程当前正在被 CPU 调度运行,处于就绪态的进程得到 CPU 调度就会进入运行态;

⚫ 僵尸态:僵尸态进程其实指的就是僵尸进程,指该进程已经结束、但其父进程还未给它“收尸”;

⚫ 可中断睡眠状态:可中断睡眠也称为浅度睡眠,表示睡的不够“死”,还可以被唤醒,一般来说可以通过信号来唤醒;

⚫ 不可中断睡眠状态:不可中断睡眠称为深度睡眠,深度睡眠无法被信号唤醒,只能等待相应的条件成立才能结束睡眠状态。把浅度睡眠和深度睡眠统称为等待态(或者叫阻塞态),表示进程处于一种等待状态,等待某种条件成立之后便会进入到就绪态;所以,处于等待态的进程是无法参与进程系统调度的。

⚫ 暂停态:暂停并不是进程的终止,表示进程暂停运行,一般可通过信号将进程暂停,譬如 SIGSTOP信号;处于暂停态的进程是可以恢复进入到就绪态的,譬如收到 SIGCONT 信号。

一个新创建的进程会处于就绪态,只要得到 CPU 就能被执行。

以下列出了进程各个状态之间的转换关系,如下所示

守护进程

本小节学习守护进程,将对守护进程的概念以及如何编写一个守护进程程序进行介绍。

何为守护进程

守护进程(Daemon)也称为精灵进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生,主要表现为以下两个特点:

长期运行。守护进程是一种生存期很长的一种进程,它们一般在系统启动时开始运行,除非强行终止,否则直到系统关机都会保持运行。与守护进程相比,普通进程都是在用户登录或运行程序时创建,在运行结束或用户注销时终止,但守护进程不受用户登录注销的影响,它们将会一直运行着、直到系统关机。

与控制终端脱离。在 Linux 中,系统与用户交互的界面称为终端,每一个从终端开始运行的进程都会依附于这个终端,这是上一小节给大家介绍的控制终端,也就是会话的控制终端。当控制终端被关闭的时候,该会话就会退出,由控制终端运行的所有进程都会被终止,这使得普通进程都是和运行该进程的终端相绑定的;但守护进程能突破这种限制,它脱离终端并且在后台运行,脱离终端的目的是为了避免进程在运行的过程中的信息在终端显示并且进程也不会被任何终端所产生的信息所打断。

守护进程是一种很有用的进程。Linux 中大多数服务器就是用守护进程实现的,譬如,Internet 服务器inetd、Web 服务器 httpd 等。同时,守护进程完成许多系统任务,譬如作业规划进程 crond 等。

守护进程 Daemon,通常简称为 d,一般进程名后面带有 d 就表示它是一个守护进程。守护进程与终端无任何关联,用户的登录与注销与守护进程无关、不受其影响,守护进程自成进程组、自成会话,即pid=gid=sid。通过命令"ps -ajx"查看系统所有的进程,如下所示:

TTY 一栏是问号?表示该进程没有控制终端,也就是守护进程,其中 COMMAND 一栏使用中括号[]括起来的表示内核线程,这些线程是在内核里创建,没有用户空间代码,因此没有程序文件名和命令行,通常采用 k 开头的名字,表示 Kernel。

单例模式运行

通常情况下,一个程序可以被多次执行,即程序在还没有结束的情况下,又再次执行该程序,也就是系统中同时存在多个该程序的实例化对象(进程),譬如大家所熟悉的聊天软件 QQ,我们可以在电脑上同时登陆多个 QQ 账号,譬如还有一些游戏也是如此,在一台电脑上同时登陆多个游戏账号,只要你电脑不卡机、随便你开几个号。但对于有些程序设计来说,不允许出现这种情况,程序只能被执行一次,只要该程序没有结束,就无法再次运行,我们把这种情况称为单例模式运行。譬如系统中守护进程,这些守护进程一般都是服务器进程,服务器程序只需要运行一次即可,能够在系统整个的运行过程中提供相应的服务支持,多次同时运行并没有意义、甚至还会带来错误!

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值