3.5. 进程的销毁
大多数进程的“死亡”是它们自己主动退出导致的。当这种情况发生时,内核必须被告知,以便于销毁该进程所拥有的资源,包括内存、打开的文件、和其他零零碎碎的东西,这些东西都会在这本书之后的内容里面介绍到,比如信号量。
通常,进程的退出方式是通过调用exit()库函数,它会释放由c库分配的资源、执行每一个由用户注册的函数,最后执行一个系统调用来从操作系统中移除这个进程。exit()函数可以由用户在代码里面显式调用,此外,c编译器也总是在main函数的最后一行语句后插入exit();
另一种方式,内核强制杀死整个线程组。这种情况的发生通常是由于进程收到了一个未能处理的或者忽略的信号,或者这个进程在内核态引起了一个不可恢复的cpu异常。
3.5.1 进程的终止
在Linux 2.6里,有两个用来终止用户态应用的系统调用:
exit_group()
系统调用,终止整个线程组,也就是一个完整的多线程应用。 主要实现这个系统调用的内核函数叫做do_group_exit()
。 这个系统调用被c库函数exit()调用。_exit()
系统调用,终止单个进程,不影响该线程组中的其他线程。实现这个系统调用的函数主要是do_exit()
。这个系统调用一般会被linux线程库函数调用,比如pthread_exit()
。
3.5.1.1 do_group_exit()
函数
do_group_exit()
函数会杀死属于当前线程组的所有进程。它接收一个进程的退出码作为参数,这个退出码可以是在系统调用exit_group()
里面指定(正常终止),或者是由内核提供的一个错误码(异常终止)。这个函数执行以下操作:
- 检查终止进程的
SIGNAL_GROUP_EXIT
标志是否不为0,这意味着内核已经为这个线程组启动了一个退出程序。这种情况下,它会把current->signal->group_exit_code
字段的值作为退出码。 - 否则,设置进程的
SIGNAL_GROUP_EXIT
标志,并把退出码存储在current->signal->group_exit_code
里面。 - 调用
zap_other_threads()
函数杀死当前进程所在进程组中的其他进程。为了做到这个,函数会扫描current->tgid对应的PIDTYPE_TGID
哈希表里的PID列表,给列表中每一个与current不同的进程发送SIGKILL信号。因此,所有这样的进程最终都会执行do_exit()
函数而被杀死。 - 调用
do_exit()
函数,传入进程退出码作为参数。正如我们将要在下面看到的那样,do_exit()
杀死该进程,并不再返回。
3.5.1.2 do_exit()
函数
所有进程的终止都是由do_exit()
函数处理的,它会从内核数据结构里面删除大部分对终止进程的引用。do_exit()
接收一个进程退出码作为参数,然后执行以下操作:
- 设置进程描述符的flag字段的
PF_EXITING
标志,表示进程已经被干掉了。 - 必要的情况下,通过
del_timer_sync()
从一个动态定时器队列里面移除进程描述符(查看第6章)。 - 从进程描述符中分离与分页、信号量、文件系统、打开文件描述符、命名空间和I/O权限位图相关的数据结构。
- 如果实现被杀死进程的执行域(execution domain)和可执行格式(executable format)的内核函数包含在内核模块中,这个函数将减少它们的使用计数。
- 把进程描述符的
exit_code
字段值设置为进程退出码。这个退出码通常是_exit()
或者exit_group()
系统调用的参数(正常终止),或者由内核提供的一个错误码(异常终止)。 调用
exit_notify()
函数来执行以下操作:a. 更新父子进程的亲子关系。所有由终止进程创建的子进程将会变成终止进程所在线程组中另一个进程的子进程,如果该线程组中没有其他进程在运行,则会被init进程收养。
b. 检查该进程描述符的
exit_signal
字段是否不等于-1,以及该进程是否是线程组中最后一个进程(注意:一般的进程都是满足这个条件的;请查看之前的章节“clone(), fork(), and vfork()系统调用”中关于copy_process()
的描述中的第16步),如果是,则发送一个信号(通常是SIGCHLD)通知父进程子进程终止了。c. 否则,如果
exit_signal
等于-1或者线程组中包含其他进程时,仅当这个进程被跟踪(trace)的情况下才会给父进程发送SIGCHLD信号(这种情况下,父进程作为调试器会被通知这个轻量进程的退出)。d. 如果
exit_signal
不等于-1而且该进程没有被跟踪(trace),则设置进程描述符的exit_state
字段值为EXIT_DEAD
,然后调用release_task()
回收剩下的进程数据结构所占用的内存,并且将进程描述符的引用计数减一。此时,这个使用计数变成了1,因此该进程描述符自身不会立刻释放。e. 否则,如果
exit_signal
不为-1或者进程没有被跟踪(trace),设置exit_state
字段为EXIT_ZOMBIE
。我们将会在接下来的段落中看到对于僵尸进程发生了什么。f. 设置进程描述符的flags字段的
PF_FLAG
标志(参考第7章中“schedule()函数”部分)。调用
schedule()
函数来选择一个新的进程来运行。因为一个处于EXIT_ZOMBIE
状态的进程会被调度器忽略,这个进程在schedule()
中的switch_to
宏被执行后就停止运行了。就像我们将要在第7章中看到的那样,调度器会检查PF_DEAD
标志,然后把被替换的僵尸进程的描述符计数减一,表示这个进程已经不再存活了。
3.5.2 进程的移除
UNIX操作系统允许一个进程获取父进程的PID或者子进程的运行状态。比如,一个进程可能通过创建子进程来执行特殊的任务,在父进程里面调用wait族函数来检查子进程是否终止,如果子进程已经终止,父进程根据退出码可以知道这个任务是否被成功执行。
为了遵从这些设计原型,UNIX内核不能在进程一终止就丢弃描述符字段中的任何数据,除非是在父进程发起了一个与终止进程有关的wait族函数调用之后。这就是为什么要引入EXIT_ZOMBIE
状态的原因:虽然进程在技术上是死亡的,但它的进程描述符必须被保存,直到父进程被通知。
但如果父进程在子进程之前退出会发生什么呢?这种情况下,系统会被大量的僵尸进程淹没,它们的描述符将永远停留在内存中。正如之前提到的那样,通过强制使所有的孤儿进程成为init进程的子进程便可以解决这个问题。而init进程内部会通过调用wait族函数来检查其子进程的退出,这时将会清理僵尸进程。
release_task()
函数从僵尸进程的描述符中分离最后一个数据结构;这主要通过两种方式:调用do_exit()
函数,如果父进程对接收子进程的信号不感兴趣的话;或者当父进程收到子进程的信号时通过wait4()或waitpid()系统调用。对于后一种情况,进程描述符使用的内存也会被一并回收,而前一种情况下,这些内存的回收将由调度器负责。这个函数执行的步骤如下:
- 减少终止进程所属用户的进程数。这个数值存储在之前章节提到的
user_struct
结构里面(见copy_process()
的第4步)。 - 如果该进程处于被跟踪(traced)状态,则将它从调试器的
ptrace_children
列表里面移除,并且重新指派给原父进程。 - 调用
__exit_signal()
以取消任何挂起的信号并释放该进程的signal_struct
描述符,如果这个描述符没有被其他轻量级进程使用,还会移除这个数据结构。此外,该函数还会调用exit_itimers()
来分离任何POSIX间隔定时器(interval timer)。 - 调用
__exit_sighand()
移除信号处理函数。 调用
__unhash_process
, 依次执行以下任务:a.
nr_threads
变量减一。b. 调用两次
detach_pid()
来从PIDTYPE_PID
和PIDTYPE_TGID
类型的pidhash哈希表里面移除进程描述符。c. 如果该进程是线程组的主线程,再次调用两次
detach_pid()
从PIDTYPE_PGID
和PIDTYPE_SID
哈希表里面移除进程描述符。d. 使用
REMOVE_LINKS
宏从进程列表里面取消进程描述符的链接(unlink)。如果该进程不是主线程,且主线程处于僵尸态,以及该进程是线程组中最后一个进程时,发送一个信号给主线程的父进程来通知进程的死亡。
- 调用
sched_exit()
函数来调整父进程的时间片(这一步在逻辑上是对copy_process()
的描述中第17步的补充)。 调用
put_task_struct()
来减少进程描述符的使用计数;如果计数变为0,则终止任何遗留的对进程的引用:a. 减少进程所属用户的
user_struct
数据结构的引用计数(__count
字段)(查看copy_process()
的第五步),如果计数变为0,则释放这个结构。b. 释放进程描述符和用来容纳
tHread_info
描述符的内存区域,以及内核态堆栈。