Linux进程

概述

进程(process)是一个执行中的程序的实例,是操作系统资源调度的一个基本单位。系统的每个程序都是运行在某个进程上下文(process context)中的。进程上下文包括:存放在内存中的代码和数据,进程的栈、通用目的寄存器值、程序计数器、环境变量以及打开文件描述符的集合。

进程

进程未应用程序提供两个重要的抽象:

  • 一个独立的逻辑控制流,它提供一个假象——好像每个程序独占地使用处理器
  • 一个私有的地址空间,它提供一个假象——好像每个程序独占地使用存储器

逻辑控制流

如果使用调试器(如GDB)单步执行程序,可以看到一系列的程序计数器(Program Counter,PC)的值,这些值唯一地对应与程序的可执行目标文件中的指令,或者是包含在运行时动态链接到程序的共享库中的指令。这个PC值的序列叫做逻辑控制流,简称逻辑流

系统中通常有许多程序在运行,进程为每个程序提供一种假象,好像它在独占使用处理器。下图有三个程序在运行,每个竖直的条表示一个进程的逻辑流的一部分。三个逻辑流是交错执行的,进程A先执行一会,然后进程B开始运行直到完成,接着进程C运行一会,再接着进程A运行直到完成,最后进程C运行到结束。

这个图表现出一个程序逻辑流的执行不一定是连续的,大多数情况下是断断续续的,这也是实际中进程运行的过程——轮流使用处理器,每个进程执行它的流的一部分,然后被抢占(导致进程暂时挂起),然后轮到其他进程执行。

并发流

一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流并发地运行。例如,上图的进程A和B并发运行,进程A和C并发运行,而进程B和C不是并发运行,因为二者的流的执行时间没有重叠。

多个流并发地执行的现象称为并发(concurrency),一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。上图进程A的流由两个时间片组成。

有一个需要区别的概念——并行(parallel):

  • 如果两个流并发地运行在不同的处理器核或者计算机上,则称它们为并行流(parallel flow),它们并行地运行(running in parallel),且并行地执行(parallel execution)。
  • 并行流并发流的一个真子集,并行流强调不同流同一时刻在执行,并发则是不同流在同一时间段执行

私有地址空间

进程为每个程序提供的另一个假象——好像它独占地使用存储器。一个进程为程序提供它自己的私有地址空间,一般情况下这个地址空间不能够被其他进程读/写。虽然不同进程的存储器内容一般不同,但是每个进程的地址空间都用通用的结构,如下图所示。该图是一个X86 Linux进程的地址空间的组织结构。

  • 地址空间的底部留给用户程序(包括文本、数据、堆、栈等段)。
  • 对于32位进程:代码段从地址0x08048000开始;对于64为进程:代码段从地址0x00400000开始。
  • 地址空间的顶部留给内核(包含内核在代表进程进程执行指令时使用的代码、数据、堆、栈),这部分空间对于用户代码来说是不可见的。

用户模式和内核模式

从上面的进程地址空间图可知,地址空间顶部属于内核,用户程序不能够访问它们,这是因为内核是整个系统得以运行的核心部分,如果用户程序有意或无意破坏内核空间,那将导致整个系统奔溃。因此操作系统需要限制一个应用可以执行的指令以及可以访问的地址空间范围。

处理器通常使用某个控制寄存器中的一个模式位(mode bit)提供这种限制。这个寄存器描述了进程当前享有的特权。

  • 当设置了模式位时,进程就运行在内核模式(kernel mode),可以执行指令集中的任何指令,并且可以访问任何内存位置。
  • 没有设置模式位时,进程就运行在用户模式(user mode),用户程序代码的进程在初始时都是运行在用户模式的,进程可以从用户模式切换到内核模式,但必须通过诸如中断、故障或者陷入系统调用这样的异常。
  • 当异常发生时,控制权传递到异常处理程序,处理器将用户模式切换到内核模式,异常处理程序运行在内核模式中,当它返回到应用程序代码时,处理器将模式从内核模式改为用户模式。

在Linux中,系统提供一个虚拟文件系统/proc,它允许用户模式进程访问内核数据结构的内容。/proc文件系统将许多内核数据结构的内容输出为一个用户陈旭可以读的文本文件的层次结构。

上下文切换

上下文切换(context swtich)是一种较高层形式的异常控制流。内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。这个状态一些对象的值组成:包括通用的目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构(如描述地址空间的页表、包含有关当前进程信息的检查表、包含进程以打开文件的信息的文件表)。

内核可以通过调度(scheduling),在进程执行的某个时刻,抢占当前进程,并重新开始一个先前被抢占的进程。这个过程由内核的调度器(scheduler)的代码处理。

内核调度一个新的进程运行后,它就抢占当前进程,并使用上下文切换机制将控制转移到新的进程。上下文切换主要包括三个步骤:

  • 保存当前进程的上下文
  • 恢复某个先前被抢占的进程被保存的上下文
  • 将控制传递给这个新恢复的进程

下图给出了进程上下文切换的示例:

进程A一开始运行在用户模式中,直到它通过执行系统调用read陷入内核,内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输,由于磁盘传输需要较长时间,内核并不是等待磁盘传输完成,而是从进程A切换到进程B,切换过程需要执行上下文切换,如图所示。于是进程B在磁盘传输的间隔中执行了一会,直到磁盘控制器发出一个中断信号,通知数据已经从磁盘传送到内存。内核判定进程B已经运行足够长的时间,就执行一个从进程B到进程A的上下文切换,并将控制返回给进程A中紧随read调用之后的那条指令,进程A继续运行。

进程控制

*nix系统提供了大量从C程序中操作进程的系统调用,下面介绍几个常用的系统调用

获得进程ID

每个进程都有一个唯一的正数的进程ID(process identification,PID)。

#include <sys/types.h>
#include <unistd.h>

// return : 调用进程的PID
pid_t getpid(void);

// return : 调用进程的父进程的ID
pid_t getppid(void);

创建进程

从程序员的角度,可以认为进程总是处于下面三种状态之一:

  • 运行:进程要么在CPU上执行,要么等待被执行且最终会被内核调度。
  • 停止:进程的执行被挂起(suspended),且不会被调度。当收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU信号时,进程就停止,并且保持停止状态直到它收到一个SIGCONT信号,在这个时刻,进程再次开始运行。
  • 终止:进程永远停止。进程会因为三种原因终止:1. 收到一个默认行为为终止进程的信号(如SIGKILL),2. 从主程序返回,3. 调用exit函数
#include <sys/types.h>
#include <unistd.h>

// 调用一次,返回两次
// return : 子进程返回0,父进程返回子进程的PID。如果出错则返回-1
pid_t fork(void);

父进程通过调用fork函数创建一个新的运行的子进程。子进程得到与父进程用户级虚拟地址空间相同的一份副本,包括代码段、数据段、堆、共享库、用户栈以及父进程任何打开的文件描述符的副本。因此,父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。

一个父进程使用fork创建子进程的示例:

// fork_example.c

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
    pid_t pid;
    
    if((pid = fork() == 0) {    /* 子进程 */
        printf("Child\n");
    }
    else if(pid > 0) {          /* 父进程 */
        printf("Parent\n");    
    }
    else {                      /* 出错 */
        fprintf(stderr, "fork error: %s", strerror(errno));
        exit(0);
    }
    
    exit(0);
}

编译运行:

$ gcc -Wall fork_example.c -o fork_example

$ ./fork_example
Child
Parent

$ ./fork_example
Parent
Child

$ ./fork_example
Parent
Child

从上的运行结果可以看到一些细微之处:

  • fork调用一次,返回两次:fork被父进程调用一次,返回两次——一次在父进程中返回子进程ID,一次在新创建的子进程中返回0。
  • 并发执行:父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们的逻辑控制流中的指令。因此上面的运行结果不是固定的,我们不能对不同进程中指令的执行做任何假设。
  • 相同但独立的地址空间:新创建的子进程和原来的父进程的地址空间是一样的,但它们是相互独立的,其地址空间是私有的。
  • 共享文件:上面父子进程的输出都指向屏幕,因为子进程拥有父进程打开文件描述符数组的一份拷贝,每一个打开的文件描述符表项指向与父进程指向相同的文件表表项。当父进程调用fork时,stdout文件流是打开的,并且指向屏幕。子进程继承这个文件,因此它的输出也是指向屏幕。

终止进程

#include <stdlib.h>

void exit(int status);

exit函数以status退出状态来终止进程,另外一种设置退出状态的方法是在主程序中返回一个整数值(如在主程序中return 0)。

回收子进程

当一个进程终止时,内核并不立刻将其从系统中清除,进程保持一种已终止的状态,直到被它的父进程回收(reap)。

  • 当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后将子进程清除。一个终止了当未被回收的进程称为僵死进程(zombie)。僵死进程不能运行,但是它们仍然占用存储器资源。
  • 如果父进程没有回收它的僵死子进程就终止了,那么内核会安排init进程来回收这些僵死子进程。init进程的PID为1,它是在系统初始化时由内核创建的。

一个进程可以通过调用waitpid函数,来等待他的子进程终止或者停止。

#include <sys/types.h>
#include <sys/wait.h>

// @pid		: pid>0,等待一个单独的子进程pid;pid=-1,等待由父进程的所有子进程 
// @status	: 返回以回收子进程的状态
// @options	: 选项,设置等待的行为,默认情况为0
// return 	: 如果成功,返回子进程PID;如果options设置了WNOHANG,返回0;如果出错,返回-1
pid_t waitpid(pid_t pid, int *status, int options);

参数解析:

1. 等待集合pid

  • 如果pid>0,那么等待集合就是一个单独的子进程pid
  • 如果pid=-1,那么等待集合就是父进程创建的所有子进程的集合

2. 等待行为options

默认情况下,options=0,此时waitpid挂起调用进程,直到它的等待集合中一个子进程终止。如果等待集合中的一个进程在刚调用waitpid时刻就已经终止,那么waitpid立即返回。
我们可以通过options修改默认行为:

  • WNOHANG:如果等待集合中的任何子进程都没有终止,那么立即返回0。在等待子进程终止的同时,如果还有其他有用的工作要做,这个选项会很有用。
  • WUNTRACED:挂起调用进程的执行,直到等待集合中的一个进程变为已终止或者被停止。返回的PID为导致返回的已终止或被停止子进程的PID。默认情况下只返回已终止的子进程,不返回被停止的子进程。
  • WNOHANG | WUNTRACED:立刻返回,如果等待集合中没有任何子进程被停止或者已终止,那么返回0,如果有,则返回对应的子进程PID

3. 已回收子进程的状态

如果status非NULL,那么waitpid就会在status参数中返回子进程的状态信息。下面介绍集合解释status参数的宏,它们定义在wait.h中:

  • WIFEXITED(status) :如果子进程通过调用exit或者return正常终止,就返回真
  • WEXITSTATUS(status):返回一个正常终止的子进程的退出状态,只有WIFEXITED为真时才定义这个状态
  • WIFSIGNALED(status):如果子进程是因为一个未捕获的信号终止的,就返回真
  • WTERMSIG(status) :返回导致子进程终止的信号的编号。之后再WIFSIGNAALED为真时才定义这个状态
  • WIFSTOPPED(status) :如果引起返回的子进程当前状态是被停止,就返回真
  • WSTOPSIG(status) :返回引起子进程停止的信号的数量,只有在WIFSTOPPED为真时,才定义这个状态

4. 出错处理

  • 如果调用进程没有子进程,那么waitpid返回-1,并设置errno为ECHILD
  • 如果waitpid被一个信号中断,那么返回-1,并设置errno为EINTR
  • 诸如ECHILD、EINTR的返回代码,包含在errno.h中

5. wait函数

waitpid(-1, &status, 0)表示挂起调用进程的执行,直到有一个子进程终止了,返回该子进程的PID。这个调用形式比较常用,因此它有一个对应的简化版本wait函数

#include <sys/types.h>
#include <sys/wait.h>

// return:如果成功则返回子进程的PID;如果出错则返回-1
pid_t wait(int *status);

6. 使用waitpid回收子进程的示例

例一:不按照特定顺序回收僵死子进程

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>

#define N 2

int main()
{
	int status, i;
	pid_t pid;
	
	/* 父进程创建N个子进程 */
	for(i = 0; i < N; ++i)
		if((pid = fork()) == 0)
			exit(100 + i);
			
	/* 父进程不按照子进程的创建顺序回收僵死子进程 */
	while((pid = waitpid(-1, &status, 0) > 0) {
		if(WIFEXITED(status))	/* 子进程正常终止 */
			printf("child %d terminated with exit status = %d\n", pid, WEXITSTATUS(status));
		else
			printf("child %d terminated abnormally\n", retpid);
	}
	
	/* 回收所有子进程后,再次调用waitpid(-1, &status, 0)会返回-1,并且设置errno为ECHILD */
	if(errno != ECHILD)
	{
		fprintf(stderr, "waitpid error : %s", strerror(errno));
		exit(0);
	}
	
	exit(0);
}

例二:不按照创建子进程的顺序来回收僵死子进程

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>

#define N 2

int main()
{
	int status, i;
	pid_t pid[N], retpid;
	
	/* 父进程创建N个子进程 */
	for(i = 0; i < N; ++i)
		if((pid[i] = fork()) == 0)
			exit(100 + i);
			
	/* 父进程不按照子进程的创建顺序回收僵死子进程 */
	while((retpid = waitpid(-1, &status, 0) > 0) {
		if(WIFEXITED(status))	/* 子进程正常终止 */
			printf("child %d terminated with exit status = %d\n", retpid, WEXITSTATUS(status));
		else
			printf("child %d terminated abnormally\n", retpid);
	}
	
	/* 回收所有子进程后,再次调用waitpid(-1, &status, 0)会返回-1,并且设置errno为ECHILD */
	if(errno != ECHILD)
	{
		fprintf(stderr, "waitpid error : %s", strerror(errno));
		exit(0);
	}
	
	exit(0);
}

让进程休眠

如果想让进程挂起一段指定的时间,可以使用sleep函数:

#include <unistd.h>

// @secs : 要休眠的秒数
// return : 如果请求的时间量已到,返回0;如果sleep过早返回(比如被一个信号中断),返回还剩下的要休眠秒数
unsigned int sleep(unsigned int secs);

此外还有一个函数——pause,它能够让调用进程休眠,直到该进程收到一个信号

#include <unistd.h>

int pause(void);

加载并运行程序

我们可以通过execve函数,在当前进程的上下文中加载并运行一个程序

#include <unistd.h>

// @filename: 可执行目标文件的文件名
// @argv    : 参数列表,以null结尾的指针数组,其中每个指针都指向一个参数串
// @envp    : 环境变量列表,以null结尾的指针数组,其每个指针指向一个环境变量串,每个串都是形如“NAME=VALUE”的名字-值对
// return   : 如果成功,则不返回;如果失败,则返-1
int execve(const char *filename, const char *argv[], const char *envp[]);

只有当出现错误时,如找不到可执行文件filename,execve才会返回到调用程序,否则不返回。
在execve加载filename之后,调用启动代码,启动代码设置栈,并将控制传递给新程序的主函数,该主函数原型如下:

int main(int argc, char **argv, char **envp);
// 等价于
int main(int argc, char *argv[], char *envp[]);

像Linux Shell这些程序大量使用了fork和execve函数。shell是一个交互型的应用级程序,它代表用户运行其他程序。shell执行一系列的读/求值步骤,然后终止。读取一个命令行,然后解析命令行,接着调用fork函数,产生一个shell的子进程,再调用execve函数加载命令对应的可执行文件,配置参数列表以及环境变量列表,最后代表用户执行。

操作进程的工具

Linux提供了大量的监控和操作进程的工具:

  • top:打印出关于当前进程资源使用的信息
  • ps:列出当前系统中的进程(包括僵死进程)
  • strace:打印一个正在运行的程序和它的子进程调用的每一个系统调用的轨迹
  • pmap:显示进程的内存映射
  • /proc:一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。例如,输入"cat/proc/loadavg"可以看到Linux系统上当前的平均负载

参考文献

  • Randal E.Bryant, DavidR.O’Hallaron, 布赖恩特,等. 深入理解计算机系统[M]. 机械工业出版社, 2011.
  • W.Richard Stevens, Stephen A.Rago, 史蒂文斯, 等. UNIX 环境高级编程 [M]. 人民邮电出版社, 2014.
posted @ 2017-05-16 20:17  west000  阅读(289)  评论(0编辑  收藏  举报