进程调度:深入理解exit和wait4的作用
date: 2014-10-27 10:16
1 进程控制原语
这部分详情请参考APUE(第2版)第8章
1.1 进程退出
有2个函数用来正常终止一个进程:_exit立即进入内核,exit则先执行一些清理工作,包括调用执行各终止处理程序(通过atexit函数注册)和关闭所有标准I/O流(为所有打开流执行fclose函数),然后调用_exit进入内核。
<unistd.h>
void _exit(int status);
<stdlib.h>
void exit(int status);
两个exit函数都带有一个int类型的参数,称之为终止状态或退出状态(exit_status)。main函数中返回一个整型值与用该值调用exit是等价的。于是main函数中,exit(0)等价于return 0。
此外,进程也可能因为其他一些情况而异常终止(比如收到一个越界访问的信号SIGSEGV)。不管进程如何终止,最后都会执行内核中的同一段代码(此即后面要讨论的系统调用exit的内核代码)。这段代码关闭进程所有打开的文件描述,并释放掉进程所占用的资源。
不管进程如何终止,我们都希望进程能通知其父进程,可以理解为子进程去世时给父进程发一个“报丧”信号,告之自己是如何终止的。父进程可以调用wait函数获取子进程的退出状态。
讨论下面三个特别的问题:
- 如果父进程在子进程退出之前退出呢?子进程退出时该把报丧信号发给谁?这种情况下将由init进程“领养”父进程的所有子进程。
- 如果子进程已经终止了,但父进程没有调用wait函数获取它的终止状态又如何?内核为每个终止进程保存了一定量的信息,包括子进程的ID、进程终止状态以及进程使用的CPU时间总量,可以理解为子进程虽已去世,但还留着“尸体”等着父进程“收尸”。尸体要保留到父进程调用wait函数来收尸为止,在此之前,该子进程便成为一个僵尸进程(zombile)。
- 如果被init进程“领养”的进程终止了,系统中岂不会有大量的僵尸进程?不用担心,init进程被设计成“无论何时,只要有一个子进程终止,init就会调用wait函数来为之收尸”,从而防止了在系统中有很多僵尸进程。
1.2 等待子进程终止
有4个wait相关的函数(此外还有一个waitid函数,这里没列出来,具体参考APUE)
<wait.h>
pid_t wait4 (pid_t pid, int *status, int options, struct rusage *rusage);
pid_t wait( int* status );
pid_t wait3(int* status, int options, struct rusage* rusage);
pid_t waitpid(pid_t pid, int* status, int options);
wait用来等待任一子进程终止,waitpid可用来等待特定的子进程退出(当然也可以等待任意子进程退出),wait3多了一个rusage参数,要求内核返回由终进程及其所有子进程使用的资源汇总。这三个函数都是通过系统调用wait4来实现,我们来分析下wait4的参数:
这四个函数的返回值都是对应终止子进程的pid,父进程据此可以知道哪个子进程终止了。
1.3 进程组与会话
每个进程除了有一个进程ID之外,还属于一个进程组。进程组是一个或多个进程的集合,每个进程组有一个唯一标识进程组ID,进程组ID类似于进程ID,可存放在pid_t数据类型中。进程task_struct结构中pgrp成员即表示进程所属进程组的ID。
每个进程组都可以有一个组长进程。组长进程的标识是,其进程ID等于其进程组ID。
一个进程可以调用setpgid来加入一个现有组(作为组的成员)或者创建一个新的进程组(作为组长)。
一个用户login到系统中以后,可能会启动许多不同的进程(组),所有这些进程使用同一个控制终端(或用来模拟一个终端的窗口),这些使用同一个控制终端的进程(组)属于同一个会话(session)。
会话可以是一个或多个进程组的集合。通常由shell的管道线将几个进程编程一组。一个会话中的几个进程组可以分为一个前台进程组以及若干个后台进程组。比如如下shell命令
proc1 | proc2 &
proc3 | proc4 | proc5
将构成一个会话,该会话中有三个进程组:
- 其一、前台进程组即{proc3, proc4, proc5},它们在控制终端的前台运行;
- 其二、后台进程组{proc1, proc2};
- 其三、shell进程单成一个后台进程组。
一个会话也有一个唯一标识session ID,类似进程组ID,也可以存放在pid_t数据类型中。进程task_struct结构中session成员即表示进程所属会话。一个会话有一个会话首进程(session leader),会话首进程是创建该会话的进程,其task_struct结构中的leader成员非0。
2 系统调用exit
根据对进程控制原语的了解,以及进程创建过程的了解,不难想象出exit所要做的工作:
- 根据进程的财产登记表卡task_struct结构清算进程财产并回收;
- 解散进程的家谱,将该进程的子进程交由init进程“领养”。
- 保留尸体(task_struct结构本身以及task_struct结构所在的两个页面)并给父进程发报丧信号,等到父进程来收尸。
- 当前进程终止了,当然需要调度器启动一次调度。
另外进程调用exit表示进程要最终退出历史舞台了,意即当前进程在exit函数的执行过程中逐步走向消亡,不会从这个函数中返回了。
2.1 主要流程
exit系统调用内核入口为sys_exit:
<kernel/exit.c>
asmlinkage long sys_exit(int error_code)
{
do_exit((error_code&0xff)<<8);
}
可见其核心是do_exit,do_exit的主要流程如下:
关于关于流程图,在重点讨论下几个问题。
2.2 进程的p_opptr与p_pptr
task_struct结构中有两个成员用来指向其父进程,p_oppt和p_pptr,前者可以理解为进程的生父(orginal parent),后者可以理解为进程的养父或者监护人。在进程创建之初,进程的生父与监护人一致。但在运行中,进程的监护人可以暂时改变。比如一个进程通过系统调用ptrace来跟踪另一进程时,被跟踪继承的p_pptr将被设置为跟踪进程,跟踪进程暂时成了被跟踪进程的监护人,而被跟踪进程的生父仍然不变。
有趣的是,在判断当前进程所在的进程组是否为孤儿进程组、在给父进程发报丧信号时以及将子进程加入新的家谱时(在此之前,已经将子进程的p_pptr设置为子进程的p_opptr),都只认监护人p_pptr,而很少关注其生父p_opptr。看来进程行事时只认其监护人而不认其亲生父亲,与现实世界何其相似也。
2.3 为什么要让父进程来收尸(task_struct结构以及其所在的系统空间的两个页面),而不是子进程自行消亡?##
一方面,进程的task_struct结构中有很多统计信息,比如CPU使用时间等,让父进程来料理后事可以将这些信息并入父进程的统计信息而不至于丢失;另一方面,也是更重要的一方面,无论如何系统必须得有一个当前进程,在中断以及异常的服务程序中要用到当前进程的系统空间堆栈。如果在下一个进程投入运行之前,就把当前进程的系统空间回收,这样就存在一个空档,如果恰巧此时有中断发生就会造成问题。
3 系统调用wait4
进程在调用exit之后,系统还保留着进程的尸体等待其父进程来料理后事,父进程正在wait4中等着哩。
了解了wait4的原语,理解其内核实现应该很容易了。wait4的内核入口是sys_wait,同样定义在exit.c文件中。其主要流程如下:
函数的主题为两层循环,如果当前进程为线程,外层循环则遍历同线程组所有进程。内存循环是变量进程的所有子进程。还记得进程的家谱吗,通过进程的家谱则可以遍历所有子进程。当满足下列条件之一时,通过goto end_wait4来结束这个系统调用:
- 所等待的子进程状态为TASK_STOPPED或者为TASK_ZOMBIE;
- 所等待的子进程存在,但不在上述两个状态;而入参options设置了WNOHANG标志,表示非阻塞。
- 所等待的子进程不存在(进程号为pid的进程或者不存在,或者不是当前进程的子进程)。
否则,当前进程将自己的状态设置为TASK_INTERRUPTIBLE,并再循环外调用schedule来进入浅度睡眠而让其他的进程先运行。别忘了,在此之前(sys_wait4函数开始处)定义了一个等待节点wait,并加入了当前进程的等待队列头wait_chldexit。此后,如果有子进程退出,子进程调用do_notify_parent来通知父进程。父进程被唤醒后,继续从repeat标号处重新开始执行。
等待队列节点wait_queue_t类型以及等待队列头wait_queue_head_t类型定义在<include/linux/wait.h>中:
struct __wait_queue {
unsigned int flags;
#define WQ_FLAG_EXCLUSIVE 0x01
struct task_struct * task;
struct list_head task_list;
#if WAITQUEUE_DEBUG
long __magic;
long __waker;
#endif
};
typedef struct __wait_queue wait_queue_t;
struct __wait_queue_head {
wq_lock_t lock;
struct list_head task_list;
#if WAITQUEUE_DEBUG
long __magic;
long __creator;
#endif
};
typedef struct __wait_queue_head wait_queue_head_t;
等待队列节点通过task_list链入到等待队列头所领衔的链表中,同时每个等待队列节点都关联了一个进程的task_struct结构,当通过wake_up系列函数来唤醒等待队列头所领衔的等待队列时,将唤醒所有或者其中一个等待节点(如果传入WQ_FLAG_EXCLUSIVE标志将独占唤醒,只唤醒其中一个节点)所关联的进程。
上一篇: 怎样明确地使用atexit函数?
下一篇: Python中的多线程编程指南
推荐阅读
-
Java 类加载器的作用 - 简介:类加载器是 Java™ 中一个非常重要的概念。类加载器负责将 Java 类的字节码加载到 Java 虚拟机中。本文首先详细介绍了 Java 类加载器的基本概念,包括代理模型、加载类的具体过程和线程上下文类加载器等。然后介绍了如何开发自己的类加载器,最后介绍了类加载器在 Web 容器和 OSGi™ 中的应用。 类加载器是 Java 语言的一项创新,也是 Java 语言广受欢迎的重要原因之一。它允许将 Java 类动态加载到 Java 虚拟机中并执行。类加载器从 JDK 1.0 开始出现,最初是为了满足 Java Applets 的需求而开发的,Java Applets 需要从远程位置下载 Java 类文件并在浏览器中执行。现在,类加载器已广泛应用于网络容器和 OSGi。一般来说,Java 应用程序的开发人员不需要直接与类加载器交互;Java 虚拟机的默认行为足以应对大多数情况。但是,如果遇到需要与类加载器交互的情况,而您又不太了解类加载器的机制,就很容易花费大量时间调试异常,如 ClassNotFoundException 和 NoClassDefFoundError。本文将详细介绍 Java 的类加载器,帮助读者深入理解 Java 语言中的这一重要概念。下面先介绍一些基本概念。 类加载器的基本概念 顾名思义,类加载器用于将 Java 类加载到 Java 虚拟机中。一般来说,Java 虚拟机以如下方式使用 Java 类:Java 源程序(.java 文件)经 Java 编译器编译后转换为 Java 字节代码(.class 文件)。类加载器负责读取 Java 字节代码并将其转换为 java.lang 实例。每个实例都用来表示一个 Java 类。通过该实例的 newInstance 方法创建该类的对象。实际情况可能更加复杂,例如,Java 字节代码可能是由工具动态生成或通过网络下载的。 基本上,所有类加载器都是 java.lang.ClassLoader 类的实例。下面将详细介绍这个 Java 类。 java.lang.ClassLoader 类简介 java.lang.ClassLoader 类的基本职责是根据给定类的名称为其查找或生成相应的字节码,然后根据这些字节码定义一个 Java 类,即 java.lang.Class 类的实例。除此之外,ClassLoader 还负责加载 Java 应用程序所需的资源,如图像文件和配置文件。不过,本文只讨论它加载类的功能。为了履行加载类的职责,ClassLoader 提供了许多方法,其中比较重要的方法如表 1 所示。下文将详细介绍这些方法。 表 1.与加载类相关的 ClassLoader 方法
-
windows下进程间通信的(13种方法)-摘 要 本文讨论了进程间通信与应用程序间通信的含义及相应的实现技术,并对这些技术的原理、特性等进行了深入的分析和比较。 ---- 关键词 信号 管道 消息队列 共享存储段 信号灯 远程过程调用 Socket套接字 MQSeries 1 引言 ---- 进程间通信的主要目的是实现同一计算机系统内部的相互协作的进程之间的数据共享与信息交换,由于这些进程处于同一软件和硬件环境下,利用操作系统提供的的编程接口,用户可以方便地在程序中实现这种通信;应用程序间通信的主要目的是实现不同计算机系统中的相互协作的应用程序之间的数据共享与信息交换,由于应用程序分别运行在不同计算机系统中,它们之间要通过网络之间的协议才能实现数据共享与信息交换。进程间通信和应用程序间通信及相应的实现技术有许多相同之处,也各有自己的特色。即使是同一类型的通信也有多种的实现方法,以适应不同情况的需要。 ---- 为了充分认识和掌握这两种通信及相应的实现技术,本文将就以下几个方面对这两种通信进行深入的讨论:问题的由来、解决问题的策略和方法、每种方法的工作原理和实现、每种实现方法的特点和适用的范围等。 2 进程间的通信及其实现技术 ---- 用户提交给计算机的任务最终都是通过一个个的进程来完成的。在一组并发进程中的任何两个进程之间,如果都不存在公共变量,则称该组进程为不相交的。在不相交的进程组中,每个进程都独立于其它进程,它的运行环境与顺序程序一样,而且它的运行环境也不为别的进程所改变。运行的结果是确定的,不会发生与时间相关的错误。 ---- 但是,在实际中,并发进程的各个进程之间并不是完全互相独立的,它们之间往往存在着相互制约的关系。进程之间的相互制约关系表现为两种方式: ---- (1) 间接相互制约:共享CPU ---- (2) 直接相互制约:竞争和协作 ---- 竞争——进程对共享资源的竞争。为保证进程互斥地访问共享资源,各进程必须互斥地进入各自的临界段。 ---- 协作——进程之间交换数据。为完成一个共同任务而同时运行的一组进程称为同组进程,它们之间必须交换数据,以达到协作完成任务的目的,交换数据可以通知对方可以做某事或者委托对方做某事。 ---- 共享CPU问题由操作系统的进程调度来实现,进程间的竞争和协作由进程间的通信来完成。进程间的通信一般由操作系统提供编程接口,由程序员在程序中实现。UNIX在这个方面可以说最具特色,它提供了一整套进程间的数据共享与信息交换的处理方法——进程通信机制(IPC)。因此,我们就以UNIX为例来分析进程间通信的各种实现技术。 ---- 在UNIX中,文件(File)、信号(Signal)、无名管道(Unnamed Pipes)、有名管道(FIFOs)是传统IPC功能;新的IPC功能包括消息队列(Message queues)、共享存储段(Shared memory segment)和信号灯(Semapores)。 ---- (1) 信号 ---- 信号机制是UNIX为进程中断处理而设置的。它只是一组预定义的值,因此不能用于信息交换,仅用于进程中断控制。例如在发生浮点错、非法内存访问、执行无效指令、某些按键(如ctrl-c、del等)等都会产生一个信号,操作系统就会调用有关的系统调用或用户定义的处理过程来处理。 ---- 信号处理的系统调用是signal,调用形式是: ---- signal(signalno,action) ---- 其中,signalno是规定信号编号的值,action指明当特定的信号发生时所执行的动作。 ---- (2) 无名管道和有名管道 ---- 无名管道实际上是内存中的一个临时存储区,它由系统安全控制,并且独立于创建它的进程的内存区。管道对数据采用先进先出方式管理,并严格按顺序操作,例如不能对管道进行搜索,管道中的信息只能读一次。 ---- 无名管道只能用于两个相互协作的进程之间的通信,并且访问无名管道的进程必须有共同的祖先。 ---- 系统提供了许多标准管道库函数,如: pipe——打开一个可以读写的管道; close——关闭相应的管道; read——从管道中读取字符; write——向管道中写入字符; ---- 有名管道的操作和无名管道类似,不同的地方在于使用有名管道的进程不需要具有共同的祖先,其它进程,只要知道该管道的名字,就可以访问它。管道非常适合进程之间快速交换信息。 ---- (3) 消息队列(MQ) ---- 消息队列是内存中独立于生成它的进程的一段存储区,一旦创建消息队列,任何进程,只要具有正确的的访问权限,都可以访问消息队列,消息队列非常适合于在进程间交换短信息。 ---- 消息队列的每条消息由类型编号来分类,这样接收进程可以选择读取特定的消息类型——这一点与管道不同。消息队列在创建后将一直存在,直到使用msgctl系统调用或iqcrm -q命令删除它为止。 ---- 系统提供了许多有关创建、使用和管理消息队列的系统调用,如: ---- int msgget(key,flag)——创建一个具有flag权限的MQ及其相应的结构,并返回一个唯一的正整数msqid(MQ的标识符); ---- int msgsnd(msqid,msgp,msgsz,msgtyp,flag)——向队列中发送信息; ---- int msgrcv(msqid,cmd,buf)——从队列中接收信息; ---- int msgctl(msqid,cmd,buf)——对MQ的控制操作; ---- (4) 共享存储段(SM) ---- 共享存储段是主存的一部分,它由一个或多个独立的进程共享。各进程的数据段与共享存储段相关联,对每个进程来说,共享存储段有不同的虚拟地址。系统提供的有关SM的系统调用有: ---- int shmget(key,size,flag)——创建大小为size的SM段,其相应的数据结构名为key,并返回共享内存区的标识符shmid; ---- char shmat(shmid,address,flag)——将当前进程数据段的地址赋给shmget所返回的名为shmid的SM段; ---- int shmdr(address)——从进程地址空间删除SM段; ---- int shmctl (shmid,cmd,buf)——对SM的控制操作; ---- SM的大小只受主存限制,SM段的访问及进程间的信息交换可以通过同步读写来完成。同步通常由信号灯来实现。SM非常适合进程之间大量数据的共享。 ---- (5) 信号灯 ---- 在UNIX中,信号灯是一组进程共享的数据结构,当几个进程竞争同一资源时(文件、共享内存或消息队列等),它们的操作便由信号灯来同步,以防止互相干扰。 ---- 信号灯保证了某一时刻只有一个进程访问某一临界资源,所有请求该资源的其它进程都将被挂起,一旦该资源得到释放,系统才允许其它进程访问该资源。信号灯通常配对使用,以便实现资源的加锁和解锁。 ---- 进程间通信的实现技术的特点是:操作系统提供实现机制和编程接口,由用户在程序中实现,保证进程间可以进行快速的信息交换和大量数据的共享。但是,上述方式主要适合在同一台计算机系统内部的进程之间的通信。 3 应用程序间的通信及其实现技术 ---- 同进程之间的相互制约一样,不同的应用程序之间也存在竞争和协作的关系。UNIX操作系统也提供一些可用于应用程序之间实现数据共享与信息交换的编程接口,程序员可以通过自己编程来实现。如远程过程调用和基于TCP/IP协议的套接字(Socket)编程。但是,相对普通程序员来说,它们涉及的技术比较深,编程也比较复杂,实现起来困难较大。 ---- 于是,一种新的技术应运而生——通过将有关通信的细节完全掩盖在某个独立软件内部,即底层的通讯工作和相应的维护管理工作由该软件内部来实现,用户只需要将通信任务提交给该软件去完成,而不必理会它的具体工作过程——这就是所谓的中间件技术。 ---- 我们在这里分别讨论这三种常用的应用程序间通信的实现技术——远程过程调用、会话编程技术和MQSeries消息队列技术。其中远程过程调用和会话编程属于比较低级的方式,程序员参与的程度较深,而MQSeries消息队列则属于比较高级的方式,即中间件方式,程序员参与的程度较浅。 ---- 4.1 远程过程调用(RPC)
-
聊聊进程、线程和Coroutine:深入理解线程间的“枷锁”现象
-
第二章:深入理解Linux编程 - 进程工作原理:剖析进程定义与特性、状态转换、关键数据结构、从创建到结束的过程、睡眠与唤醒机制、暂停与重启操作,以及处理器调度的核心概念
-
深入理解分贝单位的秘密(下):dBm、dBFS和dBV的神奇作用你可能还没察觉到
-
深入理解进程线程(二):探讨进程的构成、状态和特性
-
深入理解JVM的构成和各个部分的作用
-
掌握Lua编程:深入理解do...end和repeat...until语句的作用
-
理解并探索exit和atexit函数的作用
-
理解C/C++中的abort、atexit、exit和_Exit函数的作用与区别