Linux 守护进程的编写及使用方法

正常程序的后台运行及前后台切换

测试程序:

// test1-1.c
#include <stdio.h>
#include <unistd.h>

#define SLEEP_TIME 5

int main()
{
	while(1){
		printf("ID\n");
		sleep(SLEEP_TIME);
	}
}

// test1-2.c
#include <stdio.h>
#include <unistd.h>

#define SLEEP_TIME 5

int main()
{
	while(1){
		printf("Name\n");
		sleep(SLEEP_TIME);
	}
}

将 test1-1 放入后台运行的方法:

在程序已运行的情况下,在终端按下 [Ctrl] + z,使工作转入后台,但此时工作停止,需要使用 bg 来使之运行。

查看后台运行的 test1-1 的信息:

一是可以通过 jobs 命令查看;二是使用通用的进程查看命令 top 或 ps -ef。

使后台的 test1-1 重新回到前台:

使用 fg 命令。

上述操作如下图:

0

使用同样的方法,我们运行 test1-2,同样将它放入后台运行。

此时通过 jobs,可以发现有两个工作,如下图:

1

让指定工作回到前台:

fg + 工作序号。如 fg 2 会让 test1-2 回到前台。

同理,如果后台有多个工作停止,则使用 bg + 工作序号可以使指定工作开始后台运行。

另外,[1]- [2]+,加号代表当前工作为[2],减号代表当前工作的下一个工作为[1]。它们表明了工作执行的顺序,如果你使用不加序号的 fg 命令,则默认把带加号的工作提到前台。

查看 test1-1 test1-2 的PPID(父进程标识):

方法可能不唯一,我使用的是

ps -ef | grep 'test'
# 之后我们可以再执行下面的来验证一下
echo $$ # 打印当前 shell 的 PID

上面操作的结果如下:

2

可以看出,4288 是当前 shell 的 PID,也是 test1-1 和 test1-2 的 PPID。这说明当前 shell 就是它们的父进程。

用 SecureCRT 再登录一个控制台,用什么命令可以看到 test1-1/test1-2 的信息?此时父进程标识是谁?如何查看?

依然可以用上面的 ps 命令查看这些信息。

当 test1-1/test1-2 在后台运行时,如果 CTRL+D 退出该控制台登录,在另一个控制台再查看 test1-1/test1-2 的信息,此时父进程标识是谁?

父进程是 init 进程。PID 是 1。

如何将一个正常程序直接放入后台运行?(不要通过按键切换)

在命令后加上 &,比如 ./test1-1 &

能否使 test1-1/test1-2 在终端退出登录后继续运行(不是再次运行)?此时打印信息能否继续出现在新登录的终端上?

将它们放在后台运行,在终端退出登陆后,只要系统没有关闭,进程就继续运行,只是它们的 PPID 变为 init 进程的 PID(后来发现 Ubuntu GUI 界面下的 init 进程的 PID 不是 1)。另外,注意工作在后台保持运行状态才可以,如果是按下 [Ctrl] + z 后没有 bg 直接退出登录,则进程会自动结束。

重新登录后,打印信息不会继续出现在新的终端上,原因如下:

会不会出现在新的终端上取决于当前 shell 的标准输出/错误输出的文件描述符是否与 test1-1 test1-2 进程相同。在上一个终端未退出时,默认情况下(没有使用重定向)它们继承了父进程(即旧 shell)的文件描述符。在旧 shell 结束后,它们继承 init 进程的文件描述符,而新的终端的 PID 一定不是 init 的 PID,我们通过下面的命令查看两者的标准输出/错误输出是否相同:

3

1u 是标准输出,2u 是错误输出,可以看出两者的这两个文件描述符并不一样。(分别是 /dev/null 和 /dev/pts/1)

守护进程的作用、用途、父进程标识的特点

先补充一点基础知识。

进程状态相关

在 sched.h 中有 struct task_struct 的定义(从操作系统课程得知,这就是 Process Control Block 的实现)。

在 Linux 2.6 内核中,用户级进程有下面几种状态

// 运行或就绪状态
#define TASK_RUNNING 0
// 处于等待带队伍,等待资源有效时被唤醒,并可被中断
#define TASK_INTERRUPTIBLE 1
// 基本同上,但不可被中断
#define TASK_UNINTERRUPTIBLE 2
// 即将从任务链表中删除,但在 task 中仍然有一个 task_struct 数据结构,等待父进程释放
#define TASK_ZOMBIE 4
// 进程已结束,已释放相应资源,但未释放 PCB,处于这一状态的进程可以被唤醒
#define TASK_STOPPED 8

进程/父进程/进程组/会话

括号内为对应函数。

进程号为 PID (getpid);

父进程号为 PPID 用户层无法修改父进程号 (getppid);

进程组是一个/多个进程集合,与同一作业(job)相关联,可以接受来自同一终端的各种信号。一般来说,父进程的 PID 等于其 PGID(进程组长的 PID号),子进程的 PGID 等于 PPID (getpgid/setpgid);

一个进程只能为自己或子进程设置 PGID,一旦子进程调用了 exec() 函数,则无法改变子进程 PGID。

在大多数作业控制的 shell 中,首先使父进程成为新的进程组长,在创建子进程 后调用 setpgid(),修改其子进程的进程组长。若不这样做,则创建进程后,由于父子进程执行次序不定,在一段时间内子进程组员身份不确定。

会话是一个/多个进程组集合 (getsid/setsid)。

如果调用进程已经是一个进程组组长,则函数返回错误。所以,我们通常先 fork 创建子进程,然后使父进程终止。

一个会话可以有一个控制终端,建立与控制终端连接的会话首进程为控制进程。(可以想象一下打开一个终端窗口)

一个会话中的进程组可以分为一个前台进程组和多个后台进程组。

输入中断键会将中断信号发给前台进程组所有进程。

守护进程概念

Daemon 是在后台运行的一种特殊进程,脱离于终端。通常周期性地执行某种任务或等待处理某些发生的事件,常见的守护进程如 Web 服务器的 httpd。如上,一般以 d 结尾的进程都是守护进程,d 取 daemon 首字母。

在终端用 nohup 启动的进程也是守护进程。

守护进程编程要点

(1) 屏蔽有关控制终端操作的信号,防止在守护进程没有正常运行前,控制终端收到干扰退出或者挂起。

signal(SIGTTOU, SIG_IGN);
signal(SIGTTIN, SIG_IGN);
signal(SIGTSTP, SIG_IGN);
signal(SIGHUP , SIG_IGN);

(2) 后台运行,防止挂起终端将其放入后台执行。方法是使父进程终止。

/* fork() 函数用于从父进程复制出一个子进程。正常情况下 fork() 函数调用一次返回两次,分别在父进程中返回当前进程 PID,在子进程中返回 0 */

pid = fork();
if(pid > 0) // parent
	exit(0);

(3) 脱离原会话/原控制终端/原进程组。

// 调用 setsid() 使子进程成为新的 session leader

setsid();

(4) 禁止进程重新打开控制终端(让它不再是会话组长)。方法是再次创建子进程。

pid = fork();
if(pid > 0)
	exit(0);

(5) 关闭打开的文件描述符。防止资源浪费,避免文件系统挂/卸载等错误。

#define NOFILE 256
for(int i = 0; i < NOFILE; i++)
	close(i);

(6) 改变当前工作目录。避免文件系统挂/卸载等错误。写运行日志的进程可以将工作目录改到 /tmp,一般的可以改到根目录。

chdir("/");

(7) 重新设定文件创建 mask。mask 刚开始是继承父进程的的,为了给守护进程充分权限,清除 mask。

umask(0);

(8) 处理 SIGCHLD (子进程退出)信号。服务器进程通常会创建子进程来处理请求。如果父进程不等待子进程结束,则子进程将成为僵尸进程(zombie)占用系统资源,如果等待,则会增加父进程负担。

signal(SIGCHLD, SIG_IGN);

另外,还可以有日志记录/kill信号处理的考虑。

守护进程编写

示例如下:

// tset2.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <signal.h>
#include <string.h>
#include <errno.h>
#include <stdlib.h>

#define NOFILE 256
#define SLEEP_TIME 5

int main()
{
	// ignore the signals
	signal(SIGTTOU, SIG_IGN);
	signal(SIGTTIN, SIG_IGN);
	signal(SIGTSTP, SIG_IGN);
	signal(SIGHUP, SIG_IGN);

	// fork a new sub-process
	pid_t pid;
	pid = fork();
	if(pid < 0)
		fprintf(stderr, "fork error: %s\n", strerror(errno));
	if(pid > 0)
		exit(0);

	// Open a new session
	setsid();

	// fork a new sub-sub-process
	pid = fork();
	if(pid < 0)
		fprintf(stderr, "fork error: %s\n", strerror(errno));
	if(pid > 0)
		exit(0);

	// close the file descriptor
	// 为了看到打印结果,我们暂时不关闭描述符
	int i;
	// for(i = 0; i < NOFILE; i++)
	//	close(i);

	// change the current directory
	chdir("/");

	// clear the mask
	umask(0);

	// deal with SIGCHLD
	signal(SIGCHLD, SIG_IGN);

	// now we can repeatly print the id
	while(1){
		printf("ID\n");
		sleep(SLEEP_TIME);
	}
	return 0;
}

./test2 运行直接成为守护进程。

查看 test2 进程信息:

2-0

可以看出 PPID 是 1,即 init 进程。

在另一个控制台中也可查看到 test2 的信息,用上述同样的方法。

由于是守护进程,脱离了终端,所以即使 [CTRL] + D 退出登录,只要操作系统保持运行,则 test2 会保持运行。

再次登录控制台后,test2 仍在运行,但是打印信息不能继续出现在新登录的终端上,原因同第一题。

那么,如果想在进程中打印信息并保证始终能被查看到,该怎么做?

如果已经关闭了从父进程继承来的文件描述符,则守护进程的输出是无法在终端上看到的。如果没有关闭,则也只会在当前的终端上打印信息,一旦退出再次登录,则无法查看(有一个想法,如果将输出写入/dev/pts/新终端编号可不可以打印在新终端上?)。

所以,使用守护进程惯用的方法:日志。

Linux系统中为了功能分离,有一个专门的 Daemon 来负责收集进程产生的日志。早期是 syslogd。这类似于一个C/S架构。syslogd 监听514端口,应用程序可以通过接口来调用 syslog。后来许多Linux 发行版用 rsyslogd 代替了 syslogd,但是客户端的接口依然同 syslog:

#include <syslog.h>

vodi openlog(const char *ident, int option, int facility);

void syslog(int priority, const char *format, ...);

void closelog(void);

上面函数的使用方法可以参考 man openlog。

syslog的配置文件是 /etc/syslog.conf
rsyslog的配置文件是 /etc/rsyslog.conf

在上面的示例程序源代码中加上这些,变成:

	openlog("test2", LOG_PID, LOG_USER);
	while(1){
		printf("ID\n);
		syslog(LOG_INFO, "%s", "ID\n");
		sleep(SLEEP_TIME);
	}
	closelog();
	return  0;

编译运行后无论在哪个终端,都可以通过

tail -f /var/log/messages
# 具体日志路径参考 /etc/rsyslog.conf 中的设定

查看守护进程的输出情况。如下图:

2-1

守护进程再次分裂子进程(僵尸进程的处理)

孤儿进程与僵尸进程

孤儿进程指父进程先退出,子进程被 init 进程收养。(例如,父进程创建子进程,父进程退出,子进程不退出)

僵尸进程(zombie)指子进程退出,但是没有从进程表中删除的进程。(例如,父进程创建子进程,然后子进程退出,父进程不处理子进程)

如果父进程退出,那么僵尸进程将会被 init 处理,所以不好的是在子进程退出后,父进程不退出,也不处理已经退出的子进程。

父进程处理已退出子进程的方法

可以通过显式忽略内核发出的 SIGCHLD 信号来让系统帮助处理;也可以编写信号捕捉函数(handle)使用 wait 或者 waitpid 函数处理。

下面两个示例中只展示出了守护进程产生子进程及子进程行为的代码,守护进程本身的产生过程代码与第二题中 test2.c 的代码相同

// relative macro definitions
#define NOFILE 256
#define MAIN_SLEEP_TIME 5
#define SUB_SLEEP_TIME 60
#define MAIN_DUP_NUM 10
#define SUB_DUP_NUM 3
// test3-1.c
	// signal(SIGCHLD, SIG_IGN);

	// generate sub-process
	for(i = 0; i < MAIN_DUP_NUM; i++){
		if((pid = fork()) < 0)
			fprintf(stderr, "fork error: %s\n", strerror(errno));
		if(pid == 0)
			break;
	}
	pid_t my_pid, ppid;
	my_pid = getpid();
	ppid = getppid();
	if(pid > 0){ // main process
		while(1){
			printf("%u - %u - ID - main\n", my_pid, ppid);
			sleep(MAIN_SLEEP_TIME);
		}
	}
	else{ // sub process
		for(i = 0; i < SUB_DUP_NUM; i++){
			printf("%u - %u - ID - sub\n", my_pid, ppid);
			sleep(SUB_SLEEP_TIME);
		}
	}
// test3-2.c
void wait_sub(int sign)
{
	int status;
	while(waitpid(-1, &status, WNOHANG) > 0)
		;
}

// .....

	// -------------one     method-------------
	// signal(SIGCHLD, SIG_IGN);

	// -------------another method-------------
	// signal(SIGCHLD, wait_sub);

	// generate sub-process
	for(i = 0; i < MAIN_DUP_NUM; i++){
		if((pid = fork()) < 0)
			fprintf(stderr, "fork error: %s\n", strerror(errno));
		if(pid == 0)
			break;
	}
	pid_t my_pid, ppid;
	my_pid = getpid();
	ppid = getppid();
	if(pid > 0){ // main process
		while(1){
			printf("%u - %u - ID - main\n", my_pid, ppid);
			sleep(MAIN_SLEEP_TIME);
		}
	}
	else{ // sub process
		for(i = 0; i < SUB_DUP_NUM; i++){
			printf("%u - %u - ID - sub\n", my_pid, ppid);
			sleep(SUB_SLEEP_TIME);
		}
	}

首先,我们可以看一下守护进程与子进程之间的关系:

守护进程的 PPID 为 1;
子进程的 PPID 为守护进程的 PID

3-1

test3-1 子进程退出后成为僵尸进程,可以用 ps -ef grep ‘test’ 查看,也可以用 top 命令查看,下面是用 ps 命令查看的截图(标明 defunct 的进程):

3-0

test3-2.c 中展示了两种方法处理已退出子进程,它的子进程可以正常被处理。

编制信号处理程序:

void wait_sub(int sign)
{
	int status;
	while(waitpid(-1, &status, WNOHANG) > 0)
		;
}
signal(SIGCHLD, wait_sub);

显式忽略,交给操作系统:

signal(SIGCHLD, SIG_IGN);

杀死僵尸进程

一种方法是最直接的,用

ps -ef | grep 'test'

获得僵尸进程 PID,再用

kill 9 PID1 PID2 ...

如果守护进程不重要,可以采用直接 kill 9 守护进程PID 的方法顺带杀掉僵尸进程。

如何杀死守护进程?如何杀死守护进程的子进程?

对前面几题的杀死进程指令 kill 的一点点补充

kill 严格来说是发送信号的工具。默认发送的是 SIGTERM 终止信号,我之前使用的都是 kill 9 PID,发送的是 SIGKILL 信号,即强制杀死。一般来说使用 SIGTERM 就可以了,SIGTERM 信号可以被进程捕获,这样可以让进程很好地退出,而 SIGKILL 不可以被进程捕获,带有强制性。更多内容可以参考 man 7 signal(包括各种信号的解释)。

如何杀死 test4-1 分裂出来的一个子进程?

杀死一个子进程的方法与之前几题相同,直接 kill PID 即可。

如何快速杀死 test4-1 分裂出来的全部子进程?(不能用 kill 连续跟 10 个进程号的方法)

一种方法是使用一段 bash 命令,用 ps grep cut 等命令选出所有子进程(根据 PPID选),然后用 xargs 传递给 kill,实际上和题目上不让用的方法差不多,只不过是批处理了。

另外可以使用 pkill 命令:

pkill -15 -P PPID # 同一个守护进程的所有子进程的 PPID 相同

如果杀死 test4-1,其子进程会发生哪些变化?

子进程成为孤儿进程,被 init 进程收养。

如何在杀死守护进程后,使它的全部子进程自动退出?

使用信号处理程序。
当然,发给守护进程的信号不能是 SIGKILL 或 SIGSTOP 等不能捕获的,这种情况下会导致信号处理程序无效,子进程成为孤儿进程。
按照用 SIGTERM 信号结束守护进程的方法来考虑,可以写一个捕获该信号的处理程序,在处理程序中先用 kill 给所有子进程发终止信号,再 waitpid,然后守护进程退出。

test4-1 用来测试快速杀死 test4-1 的所有子进程

test4-1 代码与第三题基本相同,故不列出。

使用 pkill:

4-0

可以看出,pkill 后仅有守护进程存在。

4-1

test4-2 用来测试全部子进程自动退出

首先 man 2 kill,有下面这一段:

The  kill()  system  call can be used to send any signal to any process group or process.
If pid is positive, then signal sig is sent to the process with the ID specified by pid.
If pid equals 0, then sig is sent to every process in the process group of the calling process.
If pid equals -1, then sig is sent to every process for which the calling  process  has  permission  to  send  signals,  except for process 1 (init), but see below.
If pid is less than -1, then sig  is  sent  to  every  process  in  the process group whose ID is -pid.
If  sig  is 0, then no signal is sent, but error checking is still performed; this can be used to check for the existence of a process ID  or process group ID.

所以有下面的信号处理程序:

void kill_sub(int sign)
{
	int status;
	
	kill(0, SIGTERM);
	
	while(waitpid(-1, &status, WNOHANG) > 0)
		;
	exit(0);
}

信号绑定:

signal(SIGTERM, kill_sub);

测试:

4-2

测试结果:

4-3

4-4

守护进程再次分裂子进程(极限测试)

基本要求

在 05 子目录下写 test5-1.c,用 main 函数带参数方式带入一个参数表示循环次数,然后循环指定参数产生子进程,每个子进程中定义一个大小为 1024 的字符数组,任意赋值,然后进入死循环(为了屏幕干净,不用打印信息);主进程每分裂若干个子进程(例如:10 个/100 个等,可自行决定)后打印一次”已分裂***个子进程”,循环结束后/或分裂子进程失败后打印分裂成功的总数,然后进入死循环;写配套的 makefile 文件,make 后可生成 test5-1 可执行文件

执行方法为 ./test5-1 1000 表示分裂 1000 个子进程

虚拟机的内存设置为 512MB/1024MB/2048MB,分裂数量分别达到多少时,分裂子进程会失败?

结果如下图:

512MB

5-0

1024MB

5-5

2048MB

5-4

把 char str[1024]改为 char str[1024*10],再次测试三种内存下的最大分裂数量

512MB

5-1

1024MB

5-2

2048MB

5-3

前两问的思考:

就 str 数组来说,内存占用变为原来的 10 倍,但是最后成功分裂的子进程数目确实基本持平的,这说明进程数的限制可能不是内存。

写 test5-2.c,要求与 test5-1 相同,但是子进程给 str 赋值完后,不要死循环,等待 20 秒后子进程退出,在这种情况下,如果做到在小内存的情况下分裂完成指定大数量的子进程?(例如:在 512MB 内存情况下,分别 100000 个子进程且必须都分裂成功)

另外,此处一并包括了在 test5-2.c 中加适当的语句,看分裂的子进程的最大进程号是多少?

让父进程不断 fork,在达到上限后过一段时间会有子进程退出,之后又可以 fork 成功。

512MB,分裂 100000

在 3100 处明显卡顿一下,符合预期:

5-8

最终结果:

5-9

写 test5-3.c,基本要求同 test5-2,但是由守护进程负责回收每个子进程退出信号,设置两个全局变量做为计数器,一个记录分裂成功的数量,一个记录回收成功的数量,要求全部分裂完成后,且所有子进程都退出后,两个计数器的值要相同

5-7

提示:当极限测试导致子进程分裂失败后,Linux 系统还能正常操作吗?

不可以正常操作,正常的操作的命令都是通过当前 shell fork 出一个新的子进程实现的,很明显系统进程数已经达到上限。(然而提示是什么鬼)

下面是在做 512MB ,字符数组为 1024 长度时进程达上限后的截图:

5-5

5-6

本小题的 test5-3 用 512MB 内存的虚拟机测试 200000 个进程,完成正确且速度最快的取前 8 名,每人 1 分的额外加分(不考虑提交时间先后)

没有刻意去测 200000 个进程,时间有点长。如果是 512MB 按照常规思路去想,肯定是依赖于 20s 后退出这个条件来保证能够持续分裂的。但是这样大家都一样了……

查了些资料,系统对于进程数最大值也有限制,分为硬限制软限制

对于最大进程号来说,可以通过

sysctl -w kernel.pid_max=220000

来保证能够给出 200000 个 PID,否则当前实验环境(Redhat 7.1)默认 PID 最大只可以到:

5-10

通过 man 2 sysctl 发现可以在程序中调用 sysctl 去修改 pid_max,不过没有测试。

最后

在一系列的测试中,有时候会有父进程提前退出,子进程全被 init 收养的情况,要是有 500 个子进程去手动 kill 掉太麻烦了,写了个 bash 命令,放在这里:

#!/bin/bash

ps -ef | grep 'test' | head -n 1 | awk '{print $2}' | xargs pkill -9 -P

还有杀掉父进程的:

#!/bin/bash

ps -ef | grep 'test' | head -n 1 | awk '{print $2}' | xargs kill

附加题 【未记录】

要求:

ques-0

ques-0

十秒后

ques-0

ques-0

ques-0

3秒后

ques-0

ques-0

感悟

这一系列题做下来,感觉老师是想让我们通过实践来体会服务器端容器如 apache 或 nginx 的核心工作原理。尤其是在附加题里边涉及到如何修改父/子进程名称时感受尤为强烈——修改 main 函数 argv[0] 的思路就是来自于 Github 上 nginx 的源代码。

目前最基本的守护进程对子进程的管理我们已经实践,还差的是网络编程,比如建立 socket 连接、监听端口等。把这些组合起来,就有一个服务器程序的雏形了。

另外有两点体会:

  1. 尽量做到知其然,知其所以然。即使最近没时间了,也要记下来,过一阵子搞懂。比如附加题中环境变量和 argv 在栈上存放的位置,以前研究过一次,但当时还没有记录的习惯,所以忘记了。一方面通过查阅资料,一方面使用 GDB 调试自己看一看。

  2. 要保持时刻学习的心态,不要故步自封,不要停留在舒适区。在上网络课之前,我觉得自己的 Linux 知识技能已经够用了,但是在作业过程中还是学到了非常多有意思的东西。

参考