daemon程序实现注意事项

之前在《如何让程序真正地后台运行》一文中提到了程序后台运行的写法,但是里面的示例程序在某些场景下是会有问题的,这里先不说什么问题,我们先看看这个磁盘满的问题是怎么产生的,通过这篇文章你将会学习到大量linux命令的实操使用。

找到导致磁盘满的程序

当发现磁盘占用比较多的时候,可以通过下面的命令,查看各个挂载路径的占用情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ df -h
udev 3.9G 0 3.9G 0% /dev
tmpfs 784M 2.0M 782M 1% /run
/dev/sda11 19G 6.5G 12G 37% /
tmpfs 3.9G 91M 3.8G 3% /dev/shm
tmpfs 5.0M 4.0K 5.0M 1% /run/lock
tmpfs 3.9G 0 3.9G 0% /sys/fs/cgroup
/dev/sda12 9.4G 37M 8.8G 1% /tmp
/dev/sda14 6.4G 168M 5.9G 3% /boot
/dev/sda10 57G 2.0G 52G 4% /home
/dev/sda1 256M 33M 224M 13% /boot/efi
tmpfs 784M 16K 784M 1% /run/user/121
tmpfs 784M 44K 784M 1% /run/user/1000

当然我这里并没有哪个挂载路径的磁盘占用率比较高,这里假设home占用比较高,然后可以通过:

1
2
3
4
5
$ cd /home
$ du -h --max-depth=1
1.9G ./shouwang
16K ./lost+found
1.9G .

这样可以逐层知道哪些目录有了不该有的大文件。

当然你也可以使用find直接找出大文件,比如查找当前目录下大于800M的文件:

1
$ find . -type f -size +800M

find的用法可以参考《find命令高级用法》。

如果找到了该文件,并且确认是无用文件,那么就可以删除了。

但是如果仍然有程序打开了该文件,那么即便你删除了文件,其占用的磁盘空间也并不会释放,因为仍然它的”文件引用”不是0,文件并不会被删除。
在《rm删除文件空间就释放了吗?》一文中,有更加详细的解释。

所以你需要看一下,是否还有程序打开该文件,举个例子:

1
2
3
$ lsof config.json
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
less 6750 shouwang 4r REG 8,10 233 3411160 config.json

从上面的结果,可以看到,是less程序打开了config.json文件,并且它的进程id是6750。

找到进程之后,根据实际情况决定是否需要停止程序,然后删除大文件。

找不到大文件?

现实常常可能不如意,比如虽然可以通过df命令看到某些挂载路径磁盘占用率比较高,但是始终找不到大文件,那么你就要考虑,是不是大文件看似被删除了,但是还有程序打开。要找到这样的文件,其实也很简单,前面已经介绍过了:

1
lsof |grep deleted

lsof能看到被打开的文件,而如果文件被删除了(比如使用rm命令),但是仍然有程序打开,则会是deleted状态,举个例子:

1
2
$ touch test.txt
$ less test.txt

创建一个文件test.txt,并随意输入一些内容,然后使用less命令打,随后在另一个终端,删除该文件:

1
2
3
$ rm test.txt
$ lsof |grep test.txt |grep deleted
less 6989 shouwang 4r REG 8,10 134 3541262 /home/shouwang/workspaces/shell/testdeleted/test.txt (deleted)

可以看到打开该文件的进程id为6989,我们看一下这个程序打开的文件:

1
2
3
4
5
6
7
8
9
$ ls -al /proc/6989/fd
dr-x------ 2 shouwang shouwang 0 10月 6 10:57 .
dr-xr-xr-x 9 shouwang shouwang 0 10月 6 10:56 ..
lrwx------ 1 shouwang shouwang 64 10月 6 10:57 0 -> /dev/pts/1
lrwx------ 1 shouwang shouwang 64 10月 6 10:57 1 -> /dev/pts/1
lrwx------ 1 shouwang shouwang 64 10月 6 10:57 2 -> /dev/pts/1
lr-x------ 1 shouwang shouwang 64 10月 6 10:57 3 -> /dev/tty
lr-x------ 1 shouwang shouwang 64 10月 6 10:57 4 -> '/home/shouwang/workspaces/shell/testdeleted/test.txt (deleted)'
$ du -h

(关于proc虚拟文件系统,可以参考《Linux中不可错过的信息宝库》)。从上面也可以看到,文件描述符4的文件为test.txt,但是deleted状态。

停止这个进程,你会发现所占用的磁盘空间会被释放。

不完善的daemon实现

通常在终端启动一个程序后,文件描述符0,1,2通常对应标准输入,标准输出,标准错误。从前面的例子中也能窥见一二,它打开的是/dev/pts/1,其实就是当前终端。更多信息可以参考《如何理解Linux shell中“2>&1”》。

回到开始的问题,之前例子中daemonize的参考实现如下:

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
//来源:公众号【编程珠玑】
//https://www.yanbinghu.com
#include<stdio.h>
#include<sys/resource.h>
#include<fcntl.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/stat.h>
/*实现仅供参考,可根据实际情况调整*/
int daemonize()
{
/*清除文件权限掩码*/
umask(0);

/*父进程退出*/
pid_t pid;
if((pid=fork()) < 0)
{
/*for 出错*/
perror("fork error");
return -1;
}
else if(0 != pid)/*父进程*/
{
printf("father exit\n");
exit(0);
}
/*子进程,成为组长进程,并且摆脱终端*/
setsid();

/*修改工作目录*/
if(chdir("/") < 0)
{
perror("change dir failed");
return -1;
}

struct rlimit rl;
/*先获取文件描述符最大值*/
if(getrlimit(RLIMIT_NOFILE,&rl) < 0)
{
perror("get file decription failed");
return -1;
}
/*如果无限制,则设置为1024*/
if(rl.rlim_max == RLIM_INFINITY)
rl.rlim_max = 1024;

/*为了使得终端有输出,保留了文件描述符0,1,2;实际上父进程可能没有打开2以上的文件描述符*/
int i;
for(i = 3;i < rl.rlim_max;i++)
close(i);
return 0;
}
int main(void)
{
if(0 == daemonize())
{
while(1)
{
printf("daemonize ok\n");
sleep(2);
}
}
else
{
printf("daemonize failed\n");
sleep(1);
}
return 0;
}

这里注意到,daemonize函数最后关闭了2以上的文件描述符。

在其中一个终端运行上面的例子:

1
2
3
4
5
6
7
8
$ gcc -o daemon daemon.c  #编译
$ ./daemon #运行
$ ls -al /proc/`pidof daemon`/fd #查看打开的文件
dr-x------ 2 shouwang shouwang 0 10月 6 11:26 .
dr-xr-xr-x 9 shouwang shouwang 0 10月 6 11:26 ..
lrwx------ 1 shouwang shouwang 64 10月 6 11:26 0 -> /dev/pts/4
lrwx------ 1 shouwang shouwang 64 10月 6 11:26 1 -> /dev/pts/4
lrwx------ 1 shouwang shouwang 64 10月 6 11:26 2 -> /dev/pts/4

可以看到0,1,2打开的是程序所在的终端,这时关闭该终端,在另外一个终端执行:

1
2
3
4
$  ls -al /proc/`pidof daemon`/fd 
lrwx------ 1 shouwang shouwang 64 10月 6 11:26 0 -> '/dev/pts/4 (deleted)'
lrwx------ 1 shouwang shouwang 64 10月 6 11:26 1 -> '/dev/pts/4 (deleted)'
lrwx------ 1 shouwang shouwang 64 10月 6 11:26 2 -> '/dev/pts/4 (deleted)'

发现0,1,2都是deleted状态了,因为关闭前面启动程序的终端后,也相当于删除了它标准输入输出和标准错误指向的文件。

实际上,到这里,都没有任何问题,程序中的printf打印最多无法打印出来而已。

但是,如果程序不是终端启动的呢?或者说没有终端的环境,比如crontab启动,at命令启动:

1
$ at now <<< “./daemon"

at命令表示当前时间执行daemon程序。
再看看它打开的文件:

1
2
3
4
$ ls -l /proc/`pidof daemon`/fd
lr-x------ 1 shouwang shouwang 64 10月 6 11:42 0 -> '/var/spool/cron/atjobs/a00001019765fe (deleted)'
lrwx------ 1 shouwang shouwang 64 10月 6 11:42 1 -> '/var/spool/cron/atspool/a00001019765fe (deleted)'
lrwx------ 1 shouwang shouwang 64 10月 6 11:42 2 -> '/var/spool/cron/atspool/a00001019765fe (deleted)'

看见没有,你会发现它打开了一些奇怪的文件。

为什么会有这些奇怪的文件?

很明显,我们自己写的程序中并没有打开这样的文件,但是从文件名可以推断,它看能是cron程序打开的。那么怎么会变成daemon程序打开了呢?

这要从fork说起,之前在《如何创建子进程?》中说到过,fork出来的子进程会继承父进程的文件描述符,我们的daemon实现已经将2以上的描述符关闭了,但是并没有关闭0,1,2,而由于daemon程序自己实际上没有打开任何文件,0,1,2是空着的,实际上就变成了打开的是父进程曾经打开的文件。

但是由于printf持续向标准输出打印信息,即不断向描述符1打开的文件写入内容,而该文件又是deleted状态,最终可能会导致磁盘空间占用不断增大,但是又找不到实际的大文件。

为了验证我们的想法,可以看下前面的文件内容到底是什么:

1
2
3
4
5
6
$ tail -5  /proc/`pidof daemon`/fd/1
daemonize ok
daemonize ok
daemonize ok
daemonize ok
daemonize ok

看到了吗,这既是我们程序的打印!竟然打印到一个毫无相关的文件中了

小结

从上面的例子可以看到,要想实现一个线上可用的daemon程序,还必须重定向标准输入,标准输出和标准错误,比例:

1
2
3
4
/* redirect stdin, stdout, and stderr to /dev/null */
open("/dev/null", O_RDONLY);
open("/dev/null", O_RDWR);
open("/dev/null", O_RDWR);

如果我们不关心这些输入输出,则重定向到/dev/null,相当于丢弃该内容,关于/dev/null,这里有更多的介绍《linux下这些特殊的文件》。

是否要重定向标准输入输出,完全取决于你的实际应用场景,比如某些情况你可能就是需要将标准输出指向父进程的文件,则可以不需要重定向。当然了,至于实现,更推荐的做法是调用daemon函数

1
2
#include <unistd.h>
int daemon(int nochdir, int noclose);

总结

本文主要涉及以下内容:

守望 wechat
关注公众号[编程珠玑]获取更多原创技术文章
出入相友,守望相助!