Linux守护进程(Daemon)介绍与C++实现

守护进程简介

守护进程(deamon)是生存期长的一种进程。它们常常在系统引导装入时启动(如果需要守护进程随系统自启动,需要在/etc/init.d目录下放置响应的启动脚本,或者利用systemctl来控制,还有一些其他方法如supervisor等,读者可自行网上搜索相关用法),仅在系统关闭时才终止。因为它们没有控制终端,所以说它们是在后台运行的。

从daemon的启动和管理方式区分,可以将daemon分为两大类:可独立启动的daemon(stand alone)和由一个超级daemon(super daemon)来统一管理的daemon。

  • stand alone:可单独自行启动的daemon。这种daemon启动后会一直占用内存和系统资源,最大的优点是响应速度快,多用于能够随时接受远程请求的服务,如WWW的daemon(httpd)、FTP的daemon(vsftpd)等。
  • super daemon:由一个特殊的daemon来统一管理。这种服务通过一个统一的daemon在需要时负责唤醒,当没有远程请求时,这些服务都是未启动的,等到有远程请求过来时,super daemon才唤醒相应的服务。当远程请求结束后,被唤醒的服务会关闭并释放系统资源。早期的super daemon是inetd,后来被xinetd替代了。super daemon本身是一个stand alone的服务,因为它需要管理后续的其他服务,所以它自己本身当然需要常驻内存中。

守护进程创建步骤

  1. 执行一个fork(),之后父进程退出,子进程继续执行。(结果就是daemon成为了init进程的子进程。)之所以要做这一步是因为下面两个原因:

    • 假设daemon是从命令行启动的,父进程的终止会被shell发现,shell在发现之后会显示出另一个shell提示符并让子进程继续在后台运行。

    • 子进程被确保不会称为一个进程组组长进程,因为它从其父进程那里继承了进程组ID并且拥有了自己的唯一的进程ID,而这个进程ID与继承而来的进程组ID是不同的,这样才能够成功地执行下一个步骤。

  2. 子进程调用setsid()开启一个新回话并释放它与控制终端之间的所有关联关系。结果就是使子进程:
    a. 成为新会话的首进程
    b. 成为一个新进程组的组长进程
    c. 没有控制终端
    ps -eo pid,ppid,pgid,cmd 查看

  3. 如果daemon从来没有打开过终端设备,那么就无需担心daemon会重新请求一个控制终端了。如果daemon后面可能会打开一个终端设备,那么必须要采取措施来确保这个设备不会成为控制终端。这可以通过下面两种方式实现:

    • 在所有可能应用到一个终端设备上的open()调用中指定O_NOCTTY标记。int fd = open(“/dev/tty”, O_RDWR | O_NOCTTY);
    • 或者更简单地说,在setsid()调用之后执行第二个fork(),然后再次让父进程退出并让孙子进程继续执行。这样就确保了子进程不会称为会话组长,因此根据System V中获取终端的规则,进程永远不会重新请求一个控制终端。(多一个fork()调用不会带来任何坏处。)
  4. 清除进程的umask以确保当daemon创建文件和目录时拥有所需的权限。

  5. 修改进程的当前工作目录,通常会改为根目录(/)。这样做是有必要的,因为daemon通常会一直运行直至系统关闭为止。如果daemon的当前工作目录为不包含/的文件系统,那么就无法卸载该文件系统。或者daemon可以将工作目录改为完成任务时所在的目录或在配置文件中定义一个目录,只要包含这个目录的文件系统永远不会被卸载即可。

  6. 关闭daemon从其父进程继承而来的所有打开着的文件描述符。(daemon可能需要保持继承而来的文件描述的打开状态,因此这一步是可选的或者可变更的。)之所以这样做的原因有很多。由于daemon失去了控制终端并且是在后台运行的,因此让daemon保持文件描述符0(标准输入)、1(标准输出)和2(标准错误)的打开状态毫无意义,因为它们指向的就是控制终端。此外,无法卸载长时间运行的daemon打开的文件所在的文件系统。因此,通常的做法是关闭所有无用的打开着的文件描述符,因为文件描述符是一种有限的资源。

  7. 在关闭了文件描述符0、1和2之后,daemon通常会打开/dev/null并使用dup2()(或类似的函数)使所有这些描述符指向这个设备。之所以要这样做是因为下面两个原因:

    • 它确保了当daemon调用了在这些描述符上执行I/O的库函数时不会出乎意料地失败。
    • 它防止了daemon后面使用描述符1或2打开一个文件的情况,因为库函数会将这些描述符当做标准输出和标准错误来写入数据(进而破坏了原有的数据)。

C++实现

直接将下面的示例代码负责粘贴到文件中,例如这里文件名为example_daemon.cpp,直接g++编译。

1
2
3
4
5
6
7
# 生成名为 example_daemon 的可执行文件
$ g++ -o example_daemon example_daemon.cpp
$ ./example_daemon

# 可以看到 example_daemon 进程在后台运行,并且其父进程ID为1
$ ps -ef | grep example_daemon
lvnux 17889 1 0 14:37 ? 00:00:00 ./example_daemon

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>


bool start_daemon()
{
int fd;

switch (fork()) {
case -1:
printf("fork() failed\n");
return false;

case 0:
break;

default:
exit(0);
}

/*
pid_t setsid(void);
进程调用setsid()可建立一个新对话期间。
如果调用此函数的进程不是一个进程组的组长,则此函数创建一个新对话期,结果为:
1、此进程变成该新对话期的对话期首进程(session leader,对话期首进程是创建该对话期的进程)。
此进程是该新对话期中的唯一进程。
2、此进程成为一个新进程组的组长进程。新进程组ID就是调用进程的进程ID。
3、此进程没有控制终端。如果在调用setsid之前次进程有一个控制终端,那么这种联系也被解除。
如果调用进程已经是一个进程组的组长,则此函数返回错误。为了保证不处于这种情况,通常先调用fork(),
然后使其父进程终止,而子进程继续执行。因为子进程继承了父进程的进程组ID,而子进程的进程ID则是新
分配的,两者不可能相等,所以这就保证了子进程不是一个进程组的组长。
*/
if (setsid() == -1) {
printf("setsid() failed\n");
return false;
}

switch (fork()) {
case -1:
printf("fork() failed\n");
return false;

case 0:
break;

default:
exit(0);
}

umask(0);
chdir("/");

long maxfd;
if ((maxfd = sysconf(_SC_OPEN_MAX)) != -1)
{
for (fd = 0; fd < maxfd; fd++)
{
close(fd);
}
}

fd = open("/dev/null", O_RDWR);
if (fd == -1) {
printf("open(\"/dev/null\") failed\n");
return false;
}

/*
// Standard file descriptors.
#define STDIN_FILENO 0 // Standard input.
#define STDOUT_FILENO 1 // Standard output.
#define STDERR_FILENO 2 // Standard error output.
*/

/*
int dup2(int oldfd, int newfd);
dup2()用来复制参数oldfd所指的文件描述符,并将它拷贝至参数newfd后一块返回。
如果newfd已经打开,则先将其关闭。
如果oldfd为非法描述符,dup2()返回错误,并且newfd不会被关闭。
如果oldfd为合法描述符,并且newfd与oldfd相等,则dup2()不做任何事,直接返回newfd。
*/
if (dup2(fd, STDIN_FILENO) == -1) {
printf("dup2(STDIN) failed\n");
return false;
}

if (dup2(fd, STDOUT_FILENO) == -1) {
printf("dup2(STDOUT) failed\n");
return false;
}

if (dup2(fd, STDERR_FILENO) == -1) {
printf("dup2(STDERR) failed\n");
return false;
}

if (fd > STDERR_FILENO) {
if (close(fd) == -1) {
printf("close() failed\n");
return false;
}
}

return true;
}


int main(int argc, char** argv)
{
start_daemon();

while (true)
{
sleep(100);
}

return 0;
}

在 Linux 系统中,/proc//status 文件提供了关于进程的各种信息,其中的 NSsid 字段表示进程的会话ID(Session ID)。在会话中,一个进程会被指定为会话首领(session leader),而该首领进程的进程组ID(PGID)和会话ID(SID)相同。当你在终端中按下Ctrl+C时,终端会向当前前台进程组的所有成员发送SIGINT信号。

例如:父进程启动了一个非daemon程序,并且在该程序中启动了一个子进程。子进程的会话ID(Session ID)仍然是shell进程的ID,但组ID(Process Group ID)是当前启动的这个进程的ID。
由于子进程的组ID与当前进程的ID相同,如果你在shell中按下Ctrl+C,将发送中断信号(SIGINT)给当前前台进程组的所有成员,即包括父进程和子进程。
在这种情况下,父进程和子进程都属于同一前台进程组,因此它们都会收到中断信号。如果你希望只有父进程或只有子进程接收中断信号,可以考虑在fork后修改子进程的组ID,使其与父进程不同,从而隔离它们的信号处理。这样,Ctrl+C 将只影响前台进程组的成员。

参考文献

1. 《Linux_UNIX系统编程手册》
2. 《UNIX环境高级编程》
3. https://segmentfault.com/a/1190000022770900
nephen wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
坚持原创技术分享,您的支持将鼓励我继续创作!