理解Zombie和Daemon Process

潜水于CU(www.chinaunix.net),看到了大家对Zombie Process和Daemon Process的理解,同样也意识到以前自己对这两个概念理解的偏颇,想在这篇Blog中将之纠正。

一、Zombie Process
Zombie Process,译成中文为僵尸进程,以前我一直认为父进程先结束,子进程就变成了僵尸进程,事实上这与正确的理解恰恰相反,真惭愧,只是从字面理解了而并未深入研究。下面重新理解一下:

父子进程的退出次序无非两种:(这里的父进程并不等待子进程)
(1) 父进程先,子进程后
在《Unix环境高级编程》中Stevens是这样说的:“对于其父进程已经终止的所有进程,它们的父进程都改变为init进程。我们称这些进程由init进程领养。其操作过程大致是:在一个进程终止时,内核逐个检查所有活动进程,以判断它是否是正要终止的进程的子进程,如果是,则该进程的父进程ID就更改为1 ( init进程的ID )。这种处理方法保证了每个进程有一个父进程”。这样子进程退出后的“善后”工作就由init进程来完成了,不会产生Zombie Process,在后面Stevens谈到了避免子进程成为Zombie Process的一个技巧就是利用init进程托管。

(2) 子进程先,父进程后
用CU上一个网友的形象理解就是“小孩死了老爸不管就变僵尸了”。其实进程的退出应该分成两个阶段:
   a) 进程主程序退出,此时进程进入TASK_ZOMBIE状态。此时大部分与该进程相关的资源都已被释放了,包括该进程的运行的地址空间已不存在了,它拥有的东西包括内核进程栈信息、线程相关信息等其父进程可能需要知道的信息。
   b) 当其父进程获取上述进程留下的信息后(调用wait or waitpid)或者其父进程通知内核对该进程的信息不感兴趣(调用signal(SIGCHLD,SIG_IGN); )时,该进程在内核中的资源才被释放。只有这两部都完成了,该进程才算是真正意义上的优美退出。而产生Zombie Process的本质就在于只完成了a)步骤,而b)的步骤却迟迟没有进程来完成(这本来是fork该子进程的父进程的责任)。这样的话,该进程在内核中占用的资源始终不能得到释放,一旦系统内部Zombie Process多了,系统运行就会受到影响了。

二、Daemon Process
Daemon Process,译为守护进程、后台进程或精灵进程。其定义这里引用Stevens的话“守护进程是生存期长的一种进程。它们独立于控制终端并且周期性的执行某种任务或等待处理某些发生的事件。他们常常在系统引导装入时启动,在系统关闭时终止。unix系统有很多守护进程,大多数服务器都是用守护进程实现的”。

守护进程可以通过一步一步的改造普通进程而得来。创建守护进程的步骤很固定,但是想要完全理解为什么要这么做的话,要了解的东西还不少。我们先来看看Stevens的做法:
int daemon_init(void) {
        int pid;
        pid = fork();             ———–(1)
        if (pid < 0) {
                return -1;
        } else if (pid > 0) {
                exit(0);
        }

        /* child process */
        setsid();          ———-(2) 注[1]

        chdir("/"); 

        umask(0);

        关闭相关文件描述符(根据具体的系统而定)

        return 0;
}
由于在书中Stevens对这些已经说的很详细,这里只是简单说明:
(1) 这里父进程退出,子进程为init进程托管,所以你用ps -fj察看会发现其ppid == 1。这里子进程从亲生父进程那继承了进程组ID、会话(session)ID和控制终端。子进程由于派生于父进程所以不可能成为进程组首进程,这为其成为Daemon创造了先天的条件(可以调用setsid成为新的session的首进程)。而后天的条件则需其自己创造了。
(2) 而子进程要想成为Daemon,就必须建立新的会话(Session)。由于会话对控制终端的独享性,一旦子进程创建了新的会话,就会自动脱离原先继承的控制终端。由于已经是新的会话所以进程组ID和Session ID都为该子进程的PID,该进程也成为新的进程组的首进程。

在CU的讨论中,又有如下一些问题:
a) 如何禁止进程重新打开控制终端?
现在,进程已经成为无终端的会话首进程,但它可以重新申请打开一个控制终端。如何来做来阻止其重新打开一个控制终端呢?可以通过使进程不再成为会话组长来禁止进程重新打开控制终端。这个话题有时被说成“创建一个Daemon进程到底需要一次fork还是二次fork”
int daemon_init(void) {
        int pid;
        pid = fork();            
        if (pid < 0) {
                return -1;
        } else if (pid > 0) {
                exit(0);
        }

        /* new session founder process */
        setsid();        

        pid = fork();
       if (pid < 0) {
          return -1;
       } else if (pid > 0) {
          exit(0);
       }

        /* child process */
        chdir("/"); 

        umask(0);

         关闭相关文件描述符(根据具体的系统而定)

        return 0;
}

b) 是否处理SIGCHLD信号?
很多Daemon进程在运行过程中还会fork出很多子进程,如果父进程不等待这些子进程,它们结束后就会变成Zombie Process,仍然占用了系统的资源,简单的调用signal(SIGCHLD, SIGIGN);就可以避免这种事情的发生,这个根据程序的需要可选。

三、总结
在实际的开发中,Zombie Process的产生往往是由于设计不当造成的。而创建Daemon Process也是不局限于上面Stevens的做法,当然必要的步骤是不能省略的。

四、参考资料
1、《Unix环境高级编程》
2、《深入理解Linux内核》

[注1]进程组、会话(Session)和控制终端(Control Terminal)之间的关系
理解Daemon Process涉及到进程组、会话(Session)和控制终端(Control Terminal)等多个概念,下面是它们的概念和之间的关系:
进程组:进程组是一个或多个进程的集合。每个进程组有一个唯一的进程组ID;
会话:一个或多个进程组的集合;
控制终端:通常是我们在登录的终端设备(终端登录情况)或伪终端设备(网络登录情况)。

一个会话若干个进程组(一般一个前台进程组和若干个后台进程组) 0或1个控制终端

理解dup和dup2

看到ChinaUnix(CU)上的一个帖子后,觉得自己对dup和dup2特别是后者的理解还是有欠缺的,这两个接口看起来很简单,但是理解起来也真的并不是那么容易。

相信大部分在Unix/Linux下编程的程序员手头上都有《Unix环境高级编程》(APUE)这本超级经典巨著。作者在该书中讲解dup/dup2之前曾经讲过“文件共享”,这对理解dup/dup2还是很有帮助的。这里做简单摘录以备在后面的分析中使用:
Stevens said:
(1) 每个进程在进程表中都有一个记录项,每个记录项中有一张打开文件描述符表,可将视为一个矢量,每个描述符占用一项。与每个文件描述符相关联的是:
   (a) 文件描述符标志。
   (b) 指向一个文件表项的指针。
(2) 内核为所有打开文件维持一张文件表。每个文件表项包含:
   (a) 文件状态标志(读、写、增写、同步、非阻塞等)。
   (b) 当前文件位移量。
   (c) 指向该文件v节点表项的指针。
图示:
   文件描述符表
   ————
fd0  0   | p0  ————-> 文件表0 ———> vnode0
   ————
fd1  1   | p1  ————-> 文件表1 ———> vnode1
   ————
fd2  2   | p2 
   ————
fd3  3   | p3 
   ————
… …
… …
   ————

一、单个进程内的dup和dup2
假设进程A拥有一个已打开的文件描述符fd3,它的状态如下:
  进程A的文件描述符表(before dup2)
   ————
fd0  0   | p0 
   ————
fd1  1   | p1  ————-> 文件表1 ———> vnode1
   ————
fd2  2   | p2 
   ————
fd3  3   | p3  ————-> 文件表2 ———> vnode2
   ————
… …
… …
   ————

经下面调用:
n_fd = dup2(fd3, STDOUT_FILENO);后进程状态如下:

进程A的文件描述符表(after dup2)
   ————
fd0  0   | p0 
   ————
n_fd 1   | p1  ————
   ————               \
fd2  2   | p2                 \
   ————                 _\|
fd3  3   | p3  ————-> 文件表2 ———> vnode2
   ————
… …
… …
   ————
解释如下:
n_fd = dup2(fd3, STDOUT_FILENO)表示n_fd与fd3共享一个文件表项(它们的文件表指针指向同一个文件表项),n_fd在文件描述符表中的位置为STDOUT_FILENO的位置,而原先的STDOUT_FILENO所指向的文件表项被关闭,我觉得上图应该很清晰的反映出这点。按照上面的解释我们就可以解释CU中提出的一些问题:
(1) "dup2的第一个参数是不是必须为已打开的合法filedes?" — 答案:必须。
(2) "dup2的第二个参数可以是任意合法范围的filedes值么?" — 答案:可以,在Unix其取值区间为[0,255]。

另外感觉理解dup2的一个好方法就是把fd看成一个结构体类型,就如上面图形中画的那样,我们不妨把之定义为:
struct fd_t {
 int index;
 filelistitem *ptr;
};
然后dup2匹配index,修改ptr,完成dup2操作。

在学习dup2时总是碰到“重定向”一词,上图完成的就是一个“从标准输出到文件的重定向”,经过dup2后进程A的任何目标为STDOUT_FILENO的I/O操作如printf等,其数据都将流入fd3所对应的文件中。下面是一个例子程序:
#define TESTSTR "Hello dup2\n"
int main() {
        int     fd3;

        fd3 = open("testdup2.dat", 0666);
        if (fd < 0) {
                printf("open error\n");
                exit(-1);
        }

        if (dup2(fd3, STDOUT_FILENO) < 0) {       
                printf("err in dup2\n");
        }
        printf(TESTSTR);
        return 0;
}
其结果就是你在testdup2.dat中看到"Hello dup2"。

二、重定向后恢复
CU上有这样一个帖子,就是如何在重定向后再恢复原来的状态?首先大家都能想到要保存重定向前的文件描述符。那么如何来保存呢,象下面这样行么?
int s_fd = STDOUT_FILENO;
int n_fd = dup2(fd3, STDOUT_FILENO);
还是这样可以呢?
int s_fd = dup(STDOUT_FILENO);
int n_fd = dup2(fd3, STDOUT_FILENO);
这两种方法的区别到底在哪呢?答案是第二种方案才是正确的,分析如下:按照第一种方法,我们仅仅在"表面上"保存了相当于fd_t(按照我前面说的理解方法)中的index,而在调用dup2之后,ptr所指向的文件表项由于计数值已为零而被关闭了,我们如果再调用dup2(s_fd, fd3)就会出错(出错原因上面有解释)。而第二种方法我们首先做一下复制,复制后的状态如下图所示:
进程A的文件描述符表(after dup)
   ————
fd0  0   | p0 
   ————
fd1  1   | p1  ————-> 文件表1 ———> vnode1
   ————                 /|
fd2  2   | p2               /
   ————             /
fd3  3   | p3  ————-> 文件表2 ———> vnode2
   ————          /
s_fd 4   | p4  ——/ 
   ————
… …
… …
   ————

调用dup2后状态为:
进程A的文件描述符表(after dup2)
   ————
fd0  0   | p0 
   ————
n_fd 1   | p1  ————
   ————               \
fd2  2   | p2                \
   ————                _\|
fd3  3   | p3  ————-> 文件表2 ———> vnode2
   ————
s_fd 4   | p4  ————->文件表1 ———> vnode1
   ————
… …
… …
   ————
dup(fd)的语意是返回的新的文件描述符与fd共享一个文件表项。就如after dup图中的s_fd和fd1共享文件表1一样。

确定第二个方案后重定向后的恢复就很容易了,只需调用dup2(s_fd, n_fd);即可。下面是一个完整的例子程序:
#define TESTSTR "Hello dup2\n"
#define SIZEOFTESTSTR 11

int main() {
        int     fd3;
        int     s_fd;
        int     n_fd;

        fd3 = open("testdup2.dat", 0666);
        if (fd3 < 0) {
                printf("open error\n");
                exit(-1);
        }

        /* 复制标准输出描述符 */
        s_fd = dup(STDOUT_FILENO);
        if (s_fd < 0) {
                printf("err in dup\n");
        }

        /* 重定向标准输出到文件 */
        n_fd = dup2(fd3, STDOUT_FILENO);
        if (n_fd < 0) {
                printf("err in dup2\n");
        }
        write(STDOUT_FILENO, TESTSTR, SIZEOFTESTSTR);   /* 写入testdup2.dat中 */

        /* 重定向恢复标准输出 */
        if (dup2(s_fd, n_fd) < 0) {
                printf("err in dup2\n");
        }
        write(STDOUT_FILENO, TESTSTR, SIZEOFTESTSTR); /* 输出到屏幕上 */
        return 0;
}
注意这里我在输出数据的时候我是用了不带缓冲的write库函数,如果使用带缓冲区的printf,则最终结果为屏幕上输出两行"Hello dup2",而文件testdup2.dat中为空,原因就是缓冲区作怪,由于最终的目标是屏幕,所以程序最后将缓冲区的内容都输出到屏幕。

三、父子进程间的dup/dup2
由fork调用得到的子进程和父进程的相同文件描述符共享同一文件表项,如下图所示:
父进程A的文件描述符表
   ————
fd0  0   | p0 
   ————
fd1  1   | p1  ————-> 文件表1 ———> vnode1
   ————                            /|\
fd2  2   | p2                            |
   ————                             |
                                              |
子进程B的文件描述符表                |
   ————                             |
fd0  0   | p0                            |
   ————                             |
fd1  1   | p1  ———————|
   ————
fd2  2   | p2 
   ————
所以恰当的利用dup2和dup可以在父子进程之间建立一条“沟通的桥梁”。这里不详述。

四、小结
灵活的利用dup/dup2可以给你带来很多强大的功能,花了一些时间总结出上面那么多,不知道自己理解的是否透彻,只能在以后的实践中慢慢探索了。

参考资料:
1、《Unix环境高级编程》

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats