进程
进程
argc 和 argv 传参是如何实现的呢?
当在终端执行程序时,命令行参数(command-line argument)由 shell 进程逐一进行解析,shell 进程会将这些参数传递给加载器,加载器加载应用程序时会将其传递给应用程序引导代码,当引导程序调用 main()函数时,在由它最终传递给 main()函数,如此一来,在我们的应用程序当中便可以获取到命令行参数了。
注册进程终止处理函数atexit()
1 |
|
function:函数指针,指向注册的函数,此函数无需传入参数、无返回值。
返回值:成功返回 0;失败返回非 0。
进程是操作系统对一个正在运行的程序的一种抽象。
进程是一个动态过程,而非静态文件,它是程序的一次运行过程,当应用程序被加载到内存中运行之后它就称为了一个进程,当程序运行结束后也就意味着进程终止,这就是进程的一个生命周期。*
在应用程序中,可通过系统调用 getpid()来获取本进程的进程号
1 |
|
1 |
|
环境变量
每一个进程都有一组与其相关的环境变量,这些环境变量以字符串形式存储在一个字符串数组列表中,把这个数组称为环境列表。其中每个字符串都是以“名称=值(name=value)”形式定义,所以环境变量是“名称-值”的成对集合,譬如在 shell 终端下可以使用 env 命令查看到 shell 进程的所有环境变量。
事实上,进程的环境变量是从其父进程中继承过来的,譬如在 shell 终端下执行一个应用程序,那么该进程的环境变量就是从其父进程(shell 进程)中继承过来的。新的进程在创建之前,会继承其父进程的环境变量副本。
1 | extern char **environ; // 申明外部全局变量 environ |
1 |
|
获取指定环境变量getenv()
1 |
|
name:指定获取的环境变量名称。
返回值:如果存放该环境变量,则返回该环境变量的值对应字符串的指针;如果不存在该环境变量返回NULL。
1 |
|
添加删除修改环境变量
putenv函数用于向进程的环境变量数组中添加一个新的环境变量,或者修改一个已经存在的环境变量的值。
1 |
|
string:参数 string 是一个字符串指针,指向 name=value 形式的字符串。
返回值:成功返回 0;失败将返回非 0 值,并设置 errno。
该函数调用成功之后,参数 string 所指向的字符串就成为了进程环境变量的一部分了,换言之,putenv()函数将设定 environ 变量(字符串数组)中的某个元素(字符串指针)指向该 string 字符串,而不是指向它的复制副本,这里需要注意!因此,不能随意修改参数 string 所指向的内容,这将影响进程的环境变量,出于这种原因,参数 string 不应为自动变量(即在栈中分配的字符数组)
setenv()函数可以替代 putenv()函数,用于向进程的环境变量列表中添加一个新的环境变量或修改现有环境变量对应的值。
1 |
|
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()并不能进行控制。
1 |
|
unsetenv()函数
unsetenv()函数可以从环境变量表中移除参数 name 标识的环境变量。
1 |
|
清空环境变量
1 |
|
clearenv()函数内部的做法其实就是将environ赋值为NULL。在某些情况下,使用setenv()函数和clearenv()函数可能会导致程序内存泄漏,前面提到过,setenv()函数会为环境变量分配一块内存缓冲区,随之称为进程的一部分;而调用 clearenv()函数时没有释放该缓冲区(clearenv()调用并不知晓该缓冲区的存在,故而也无法将其释放),反复调用者两个函数的程序,会不断产生内存泄漏。
进程的内存布局
⚫ 正文段。也可称为代码段,这是 CPU 执行的机器语言指令部分,文本段具有只读属性,以防止程序由于意外而修改其指令;正文段是可以共享的,即使在多个进程间也可同时运行同一段程序。
⚫ 初始化数据段。通常将此段称为数据段,包含了显式初始化的全局变量和静态变量,当程序加载到内存中时,从可执行文件中读取这些变量的值。
⚫ 未初始化数据段。包含了未进行显式初始化的全局变量和静态变量,通常将此段称为 bss 段,这一名词来源于早期汇编程序中的一个操作符,意思是“由符号开始的块”(block started by symbol),在程序开始执行之前,系统会将本段内所有内存初始化为 0,可执行文件并没有为 bss 段变量分配存储空间,在可执行文件中只需记录 bss 段的位置及其所需大小,直到程序运行时,由加载器来分配这一段内存空间。
⚫ 栈。函数内的局部变量以及每次函数调用时所需保存的信息都放在此段中,每次调用函数时,函数传递的实参以及函数返回值等也都存放在栈中。栈是一个动态增长和收缩的段,由栈帧组成,系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值。
⚫ 堆。可在运行时动态进行内存分配的一块区域,譬如使用 malloc()分配的内存空间,就是从系统堆内存中申请分配的。
进程的虚拟地址空间
在 Linux 系统中,采用了虚拟内存管理技术,虚拟地址会通过硬件 MMU(内存管理单元)映射到实际的物理地址空间中,建立虚拟地址到物理地址的映射关系后,对虚拟地址的读写操作实际上就是对物理地址的读写操作,MMU 会将物理地址“翻译”为对应的物理地址。
使得进程的虚拟地址空间和物理地址空间隔离开来,这样做带来了很多的优点:
⚫ 进程与进程、进程与内核相互隔离。一个进程不能读取或修改另一个进程或内核的内存数据,这是因为每一个进程的虚拟地址空间映射到了不同的物理地址空间。提高了系统的安全性与稳定性。
⚫ 在某些应用场合下,两个或者更多进程能够共享内存。因为每个进程都有自己的映射表,可以让不同进程的虚拟地址空间映射到相同的物理地址空间中。通常,共享内存可用于实现进程间通信。
⚫ 便于实现内存保护机制。譬如在多个进程共享内存时,允许每个进程对内存采取不同的保护措施,例如,一个进程可能以只读方式访问内存,而另一进程则能够以可读可写的方式访问。
⚫ 编译应用程序时,无需关心链接地址。前面提到了,当程序运行时,要求链接地址与运行地址一致,在引入了虚拟地址机制后,便无需关心这个问题。
虚拟内存
虚拟内存是一个抽象概念,每个进程都在独占地使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间。
fork创建子进程
一个现有的进程可以调用 fork()函数创建一个新的进程,调用 fork()函数的进程称为父进程,由 fork()函数创建出来的进程被称为子进程。
1 |
|
在诸多的应用中,创建多个进程是任务分解时行之有效的方法,譬如,某一网络服务器进程可在监听客户端请求的同时,为处理每一个请求事件而创建一个新的子进程,与此同时,服务器进程会继续监听更多的客户端连接请求。在一个大型的应用程序任务中,创建子进程通常会简化应用程序的设计,同时提高了系统的并发性。
在程序代码中,可通过返回值来区分是子进程还是父进程。fork()调用成功后,将会在父进程中返回子进程的 PID,而在子进程中返回值是 0;如果调用失败,父进程返回值-1,不创建子进程,并设置 errno。
ork()调用成功后,子进程和父进程会继续执行 fork()调用之后的指令,子进程、父进程各自在自己的进程空间中运行。事实上,子进程是父进程的一个副本,譬如子进程拷贝了父进程的数据段、堆、栈以及继承了父进程打开的文件描述符,父进程与子进程并不共享这些存储空间,这是子进程对父进程相应部分存储空间的完全复制,执行 fork()之后,每个进程均可修改各自的栈数据以及堆段中的变量,而并不影响另一个进程。
1 |
|
子进程被创建出来之后,便是一个独立的进程,拥有自己独立的进程空间,系统内唯一的进程号,拥有自己独立的 PCB(进程控制块),子进程会被内核同等调度执行,参与到系统的进程调度中。
父子进程间的文件共享
调用 fork()函数之后,子进程会获得父进程所有文件描述符的副本,这些副本的创建方式类似于 dup(),这也意味着父、子进程对应的文件描述符均指向相同的文件表。
子进程拷贝了父进程的文件描述符表,使得父、子进程中对应的文件描述符指向了相同的文件表,也意味着父、子进程中对应的文件描述符指向了磁盘中相同的文件,因而这些文件在父、子进程间实现了共享.
fork()函数有以下两种用法:
⚫ 父进程希望子进程复制自己,使父进程和子进程同时执行不同的代码段。这在网络服务进程中是常见的,父进程等待客户端的服务请求,当接收到客户端发送的请求事件后,调用 fork()创建一个子进程,使子进程去处理此请求、而父进程可以继续等待下一个服务请求。
⚫ 一个进程要执行不同的程序。譬如在程序 app1 中调用 fork()函数创建了子进程,此时子进程是要去执行另一个程序 app2,也就是子进程需要执行的代码是 app2 程序对应的代码,子进程将从 app2程序的 main 函数开始运行。这种情况,通常在子进程从 fork()函数返回之后立即调用 exec 族函数来实现。
fork()之后的竞争条件
调用 fork 之后,无法确定父、子两个进程谁将率先访问 CPU,也就是说无法确认谁先被系统调用运行(在多核处理器中,它们可能会同时各自访问一个 CPU),这将导致谁先运行、谁后运行这个顺序是不确定的。
为确保使子进程优先于父进程或者父进程优先于子进程,可以用信号阻塞来实现。
1 |
|
子进程先运行打印相应信息,之后再执行父进程打印信息,在父进程分支中,直接调用了 sigsuspend()使父进程进入挂起状态,由子进程通过 kill 命令发送信号唤醒。
进程的诞生与终止
init 进程的 PID 总是为 1,它是所有子进程的父进程,一切从 1 开始、一切从 init 进程开始!
进程有两种终止方式:异常终止和正常终止。
一般使用 exit()库函数而非_exit()系统调用,原因在于 exit()最终也会通过_exit()终止进程,但在此之前,它将会完成一些其它的工作,exit()函数会执行的动作如下:
⚫ 如果程序中注册了进程终止处理函数,那么会调用终止处理函数。
⚫ 刷新 stdio 流缓冲区。关于 stdio 流缓冲区的问题,稍后编写一个简单地测试程序进行说明;
⚫ 执行_exit()系统调用。
一般推荐的是子进程使用_exit()退出、而父进程则使用 exit()退出。其原因就在于调用 exit()函数终止进程时会刷新进程的 stdio 缓冲区。
1 |
|
有换行符通过 fork()创建子进程时会复制这些缓冲区。标准输出设备默认使用的是行缓冲,当检测到换行符\n 时会立即显示函数 printf()输出的字符串;无换行符所以会立即读走缓冲区中的数据并显示,读走之后此时缓冲区就空了,子进程虽然拷贝了父进程的缓冲区,但是空的,虽然父、子进程使用 exit()退出时会刷新各自的缓冲区,但对于空缓冲区自然无数据可读。
可以采用以下任一方法来避免重复的输出结果:
⚫ 对于行缓冲设备,可以加上对应换行符,譬如 printf 打印输出字符串时在字符串后面添加\n 换行符,对于 puts()函数来说,本身会自动添加换行符;
⚫ 在调用 fork()之前,使用函数 fflush()来刷新 stdio 缓冲区,当然,作为另一种选择,也可以使用setvbuf()和 setbuf()来关闭 stdio 流的缓冲功能;
⚫ 子进程调用_ exit()退出进程不是使用exit(),调用_exit()在退出时便不会刷新 stdio 缓冲区。
监视子进程
父进程需要知道子进程于何时被终止,并且需要知道子进程的终止状态信息,是正常终止、还是异常终止亦或者被信号终止等,意味着父进程会对子进程进行监视。
wait()函数
1 |
|
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;
1 |
|
通过 for 循环创建了 3 个子进程,父进程中循环调用 wait()函数等待回收子进程,并将本次回收的子进程进程号以及终止状态打印出来。
waitpid()函数
使用 wait()系统调用存在着一些限制,这些限制包括如下:
⚫ 如果父进程创建了多个子进程,使用 wait()将无法等待某个特定的子进程的完成,只能按照顺序等待下一个子进程的终止,一个一个来、谁先终止就先处理谁;
⚫ 如果子进程没有终止,正在运行,那么 wait()总是保持阻塞,有时我们希望执行非阻塞等待,是否有子进程终止,通过判断即可得知;
⚫ 使用 wait()只能发现那些被终止的子进程,对于子进程因某个信号(譬如 SIGSTOP 信号)而停止(注意,这里停止指的暂停运行),或是已停止的子进程收到 SIGCONT 信号后恢复执行的情况就无能为力了。
1 |
|
pid:参数 pid 用于表示需要等待的某个具体子进程,关于参数 pid 的取值范围如下:
⚫ 如果 pid 大于 0,表示等待进程号为 pid 的子进程;
⚫ 如果 pid 等于 0,则等待与调用进程(父进程)同一个进程组的所有子进程;
⚫ 如果 pid 小于-1,则会等待进程组标识符与 pid 绝对值相等的所有子进程;
⚫ 如果 pid 等于-1,则等待任意子进程。wait(&status)与 waitpid(-1, &status, 0)等价。
status:与 wait()函数的 status 参数意义相同。
参数 options 是一个位掩码,可以包括 0 个或多个如下标志:
⚫ WNOHANG:如果子进程没有发生状态改变(终止、暂停),则立即返回,也就是执行非阻塞等
待,可以实现轮训 poll,通过返回值可以判断是否有子进程发生状态改变,若返回值等于 0 表示没
有发生改变。
⚫ WUNTRACED:除了返回终止的子进程的状态信息外,还返回因信号而停止(暂停运行)的子进
程状态信息;
⚫ WCONTINUED:返回那些因收到 SIGCONT 信号而恢复运行的子进程的状态信息。
返回值:返回值与 wait()函数的返回值意义基本相同,在参数 options 包含了 WNOHANG 标志的情况
下,返回值会出现 0。
1 |
|
1 |
|
僵尸进程和孤儿进程
父进程先于子进程结束,也就是意味着,此时子进程变成了一个“孤儿”,我们把这种进程就称为孤儿进程。在 Linux 系统当中,所有的孤儿进程都自动成为 init 进程(进程号为 1)的子进程,换言之,某一子进程的父进程结束后,该子进程调用 getppid()将返回 1,init 进程变成了孤儿进程的“养父”。
进程结束之后,通常需要其父进程为其“收尸”,回收子进程占用的一些内存资源,父进程通过调用wait()(或其变体 waitpid()、waitid()等)函数回收子进程资源,归还给系统。如果子进程先于父进程结束,此时父进程还未来得及给子进程“收尸”,那么此时子进程就变成了一个僵尸进程。
SIGCHLD 信号在第八章中给大家介绍过,当发生以下两种情况时,父进程会收到该信号:
⚫ 当父进程的某个子进程终止时,父进程会收到 SIGCHLD 信号;
⚫ 当父进程的某个子进程因收到信号而停止(暂停运行)或恢复时,内核也可能向父进程发送该信号。
子进程的终止属于异步事件,父进程事先是无法预知的,如果父进程有自己需要做的事情,它不能一直wait()阻塞等待子进程终止(或轮训),这样父进程将啥事也做不了,那么有什么办法来解决这样的尴尬情况,当然有办法,那就是通过 SIGCHLD 信号。
那既然子进程状态改变时(终止、暂停或恢复),父进程会收到 SIGCHLD 信号,SIGCHLD 信号的系统默认处理方式是将其忽略,所以我们要捕获它、绑定信号处理函数,在信号处理函数中调用 wait()收回子进程,回收完毕之后再回到父进程自己的工作流程中。
当调用信号处理函数时,会暂时将引发调用的信号添加到进程的信号掩码中(除非 sigaction()指定SA_NODEFER 标志),这样一来,当 SIGCHLD 信号处理函数正在为一个终止的子进程“收尸”时,如果相继有两个子进程终止,即使产生了两次 SIGCHLD 信号,父进程也只能捕获到一次 SIGCHLD 信号,结果是,父进程的 SIGCHLD 信号处理函数每次只调用一次 wait(),那么就会导致有些僵尸进程成为“漏网之鱼”。
解决方案就是:在 SIGCHLD 信号处理函数中循环以非阻塞方式来调用 waitpid(),直至再无其它终止的
子进程需要处理为止,所以,通常 SIGCHLD 信号处理函数内部代码如下所示:
1 | while (waitpid(-1, NULL, WNOHANG) > 0) |
上述代码一直循环下去,直至 waitpid()返回 0,表明再无僵尸进程存在;或者返回-1,表明有错误发生。
应在创建任何子进程之前,为 SIGCHLD 信号绑定处理函数。
1 |
|
执行新程序
系统调用 execve()可以将新程序加载到某一进程的内存空间,通过调用 execve()函数将一个外部的可执行文件加载到进程的内存空间运行,使用新的程序替换旧的程序,而进程的栈、数据、以及堆数据会被新程序的相应部件所替换,然后从新程序的 main()函数开始执行。
1 |
|
filename:参数 filename 指向需要载入当前进程空间的新程序的路径名,既可以是绝对路径、也可以是相对路径。
argv:参数 argv 则指定了传递给新程序的命令行参数。是一个字符串数组,该数组对应于 main(int argc, char *argv[])函数的第二个参数 argv,且格式也与之相同,是由字符串指针所组成的数组,以 NULL 结束。argv[0]对应的便是新程序自身路径名。
envp:参数 envp 也是一个字符串指针数组,指定了新程序的环境变量列表,参数 envp 其实对应于新程序的 environ 数组,同样也是以 NULL 结束,所指向的字符串格式为 name=value。
返回值:execve 调用成功将不会返回;失败将返回-1,并设置 errno。
1 | //编写一个简单地程序,在测试程序 testApp 当中通过 execve()函数运行另一个新程序 newApp。 |
exec 族函数包括多个不同的函数,这些函数命名都以 exec 为前缀。
1 |
|
system()函数
使用 system()函数可以很方便地在我们的程序当中执行任意 shell 命令。
1 |
|
system()函数其内部的是通过调用 fork()、execl()以及 waitpid()这三个函数来实现它的功能,首先 system()会调用 fork()创建一个子进程来运行 shell(可以把这个子进程成为 shell 进程),并通过 shell 执行参数command 所指定的命令。
system()的返回值如下:
⚫ 当参数 command 为 NULL,如果 shell 可用则返回一个非 0 值,若不可用则返回 0;针对一些非UNIX 系统,该系统上可能是没有 shell 的,这样就会导致 shell 不可能;如果 command 参数不为NULL,则返回值从以下的各种情况所决定。
⚫ 如果无法创建子进程或无法获取子进程的终止状态,那么 system()返回-1;
⚫ 如果子进程不能执行 shell,则 system()的返回值就好像是子进程通过调用_exit(127)终止了;
⚫ 如果所有的系统调用都成功,system()函数会返回执行 command 的 shell 进程的终止状态。
进程状态
Linux 系统下进程通常存在 6 种不同的状态:
⚫ 就绪态(Ready):指该进程满足被 CPU 调度的所有条件但此时并没有被调度执行,只要得到 CPU就能够直接运行;意味着该进程已经准备好被 CPU 执行,当一个进程的时间片到达,操作系统调度程序会从就绪态链表中调度一个进程;
⚫ 运行态:指该进程当前正在被 CPU 调度运行,处于就绪态的进程得到 CPU 调度就会进入运行态;
⚫ 僵尸态:僵尸态进程其实指的就是僵尸进程,指该进程已经结束、但其父进程还未给它“收尸”;
⚫ 可中断睡眠状态:可中断睡眠也称为浅度睡眠,表示睡的不够“死”,还可以被唤醒,一般来说可以通过信号来唤醒;
⚫ 不可中断睡眠状态:不可中断睡眠称为深度睡眠,深度睡眠无法被信号唤醒,只能等待相应的条件成立才能结束睡眠状态。把浅度睡眠和深度睡眠统称为等待态(或者叫阻塞态),表示进程处于一种等待状态,等待某种条件成立之后便会进入到就绪态;所以,处于等待态的进程是无法参与进程系统调度的。
⚫ 暂停态:暂停并不是进程的终止,表示进程暂停运行,一般可通过信号将进程暂停,譬如 SIGSTOP信号;处于暂停态的进程是可以恢复进入到就绪态的,譬如收到 SIGCONT 信号。
关于进程组需要注意以下以下内容:
⚫ 每个进程必定属于某一个进程组、且只能属于一个进程组;
⚫ 每一个进程组有一个组长进程,组长进程的 ID 就等于进程组 ID;
⚫ 在组长进程的 ID 前面加上一个负号即是操作进程组;
⚫ 组长进程不能再创建新的进程组;
⚫ 只要进程组中还存在一个进程,则该进程组就存在,这与其组长进程是否终止无关;
⚫ 一个进程组可以包含一个或多个进程,进程组的生命周期从被创建开始,到其内所有进程终止或离开该进程组;
⚫ 默认情况下,新创建的进程会继承父进程的进程组 ID。
1 |
|
1 |
|
1 |
|
setpgid()函数将参数 pid 指定的进程的进程组 ID 设置为参数 gpid。如果这两个参数相等(pid==gpid),则由 pid 指定的进程变成为进程组的组长进程,创建了一个新的进程;如果参数 pid 等于 0,则使用调用者的进程 ID;另外,如果参数 gpid 等于 0,则创建一个新的进程组,由参数 pid 指定的进程作为进程组组长进程。
setpgrp()函数等价于 setpgid(0, 0)。
一个进程只能为它自己或它的子进程设置进程组 ID,在它的子进程调用 exec 函数后,它就不能更改该子进程的进程组 ID 了。
守护进程(Daemon)也称为精灵进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生,主要表现为以下两个特点:
⚫ 长期运行。守护进程是一种生存期很长的一种进程,它们一般在系统启动时开始运行,除非强行终止,否则直到系统关机都会保持运行。与守护进程相比,普通进程都是在用户登录或运行程序时创建,在运行结束或用户注销时终止,但守护进程不受用户登录注销的影响,它们将会一直运行着、直到系统关机。
⚫ 与控制终端脱离。在 Linux 中,系统与用户交互的界面称为终端,每一个从终端开始运行的进程都会依附于这个终端,这是上一小节给大家介绍的控制终端,也就是会话的控制终端。当控制终端被关闭的时候,该会话就会退出,由控制终端运行的所有进程都会被终止,这使得普通进程都是和运行该进程的终端相绑定的;但守护进程能突破这种限制,它脱离终端并且在后台运行,脱离终端的目的是为了避免进程在运行的过程中的信息在终端显示并且进程也不会被任何终端所产生的信息所打断。
守护进程 Daemon,通常简称为 d,一般进程名后面带有 d 就表示它是一个守护进程。守护进程与终端无任何关联,用户的登录与注销与守护进程无关、不受其影响,守护进程自成进程组、自成会话,即pid=gid=sid。通过命令”ps -ajx”查看系统所有的进程。
编写守护进程的步骤
1) 创建子进程、终止父进程
父进程调用 fork()创建子进程,然后父进程使用 exit()退出,这样做实现了下面几点。第一,如果该守护进程是作为一条简单地 shell 命令启动,那么父进程终止会让 shell 认为这条命令已经执行完毕。第二,虽然子进程继承了父进程的进程组ID,但它有自己独立的进程ID,这保证了子进程不是一个进程组的组长进程,这是下面将要调用 setsid 函数的先决条件!
2) 子进程调用 setsid 创建会话
这步是关键,在子进程中调用上一小节给大家介绍的 setsid()函数创建新的会话,由于之前子进程并不是进程组的组长进程,所以调用 setsid()会使得子进程创建一个新的会话,子进程成为新会话的首领进程,同样也创建了新的进程组、子进程成为组长进程,此时创建的会话将没有控制终端。所以这里调用 setsid 有三个作用:让子进程摆脱原会话的控制、让子进程摆脱原进程组的控制和让子进程摆脱原控制终端的控制。在调用 fork 函数时,子进程继承了父进程的会话、进程组、控制终端等,虽然父进程退出了,但原先的会话期、进程组、控制终端等并没有改变,因此,那还不是真正意义上使两者独立开来。setsid 函数能够使子进程完全独立出来,从而脱离所有其他进程的控制。
3) 将工作目录更改为根目录
子进程是继承了父进程的当前工作目录,由于在进程运行中,当前目录所在的文件系统是不能卸载的,这对以后使用会造成很多的麻烦。因此通常的做法是让“/”作为守护进程的当前目录,当然也可以指定其它目录来作为守护进程的工作目录。
4) 重设文件权限掩码 umask
文件权限掩码 umask 用于对新建文件的权限位进行屏蔽。由于使用 fork 函数新建的子进程继承了父进程的文件权限掩码,这就给子进程使用文件带来了诸多的麻烦。因此,把文件权限掩码设置为 0,确保子进程有最大操作权限、这样可以大大增强该守护进程的灵活性。设置文件权限掩码的函数是 umask,通常的使用方法为 umask(0)。
5) 关闭不再需要的文件描述符
子进程继承了父进程的所有文件描述符,这些被打开的文件可能永远不会被守护进程(此时守护进程指的就是子进程,父进程退出、子进程成为守护进程)读或写,但它们一样消耗系统资源,可能导致所在的文件系统无法卸载,所以必须关闭这些文件,这使得守护进程不再持有从其父进程继承过来的任何文件描述符。
6) 将文件描述符号为 0、1、2 定位到/dev/null
将守护进程的标准输入、标准输出以及标准错误重定向到/dev/null,这使得守护进程的输出无处显示、也无处从交互式用户那里接收输入。
7) 其它:忽略 SIGCHLD 信号
处理 SIGCHLD 信号不是必须的,但对于某些进程,特别是并发服务器进程往往是特别重要的,服务器进程在接收到客户端请求时会创建子进程去处理该请求,如果子进程结束之后,父进程没有去 wait 回收子进程,则子进程将成为僵尸进程;如果父进程 wait 等待子进程退出,将又会增加父进程的负担、也就是增加服务器的负担,影响服务器进程的并发性能,在 Linux 下,可以将 SIGCHLD 信号的处理方式设置为SIG_IGN,也就是忽略该信号,可让内核将僵尸进程转交给 init 进程去处理,这样既不会产生僵尸进程、又省去了服务器进程回收子进程所占用的时间。
1 |
|
单例模式
但对于有些程序设计来说,不允许出现这种情况,程序只能被执行一次,只要该程序没有结束,就无法再次运行,我们把这种情况称为单例模式运行。
首先这是一个非常简单且容易想到的方法:用一个文件的存在与否来做标志,在程序运行正式代码之前,先判断一个特定的文件是否存在,如果存在则表明进程已经运行,此时应该立马退出;如果不存在则表明进程没有运行,然后创建该文件,当程序结束时再删除该文件即可!
有很大的问题,主要包括如下三个方面:
⚫ 程序中使用_exit()退出,那么将无法执行 delete_file()函数,意味着无法删除这个特定的文件;
⚫ 程序异常退出。程序异常同样无法执行到进程终止处理函数 delete_file(),同样将导致无法删除这个特定的文件;
⚫ 计算机掉电关机。这种情况就更加直接了,计算机可能在程序运行到任意位置时发生掉电关机的情况,这是无法预料的;如果文件没有删除就发生了这种情况,计算机重启之后文件依然存在,导致程序无法执行。
由此使用文件锁
同样也需要通过一个特定的文件来实现,当程序启动之后,首先打开该文件,调用 open 时一般使用O_WRONLY | O_CREAT 标志,当文件不存在则创建该文件,然后尝试去获取文件锁,若是成功,则将程序的进程号(PID)写入到该文件中,写入后不要关闭文件或解锁(释放文件锁),保证进程一直持有该文件锁;若是程序获取锁失败,代表程序已经被运行、则退出本次启动。Tips:当程序退出或文件关闭之后,文件锁会自动解锁!
1 |
|
程序启动首先打开一个特定的文件,这里只是举例,以当前目录下的 testApp.pid 文件作为特定文件,以 O_WRONLY | O_CREAT 方式打开,如果文件不存在则创建该文件;打开文件之后使用 flock 尝试获取文件锁,调用 flock()时指定了互斥锁标志 LOCK_NB,意味着同时只能有一个进程拥有该锁,如果获取锁失败,表示该程序已经启动了,无需再次执行,然后退出;如果获取锁成功,将进程的 PID 写入到该文件中,当程序退出时,会自动解锁、关闭文件。