进程
概念
程序:包含一系列信息的文件,这些信息描述了如何在运行时创建一个进程
进程:描述为,由内核定义的抽象实体,该实体分配用以执行程序的各项系统资源。从内核角度看,进程由用户空间和一系列数据结构组成,其中用户空间包含了程序代码以及代码所使用的变量,而内核数据结构则用户维护进程状态信息。记录在内核的数据结构中的信息包括许多与进程有关的标识号(ID),虚拟内存表,打开的文件描述符表,信号传递及处理的有关信息,进程资源使用及限制,当前工作目录和大量的其他信息
进程号
#include <unistd.h>
pid_t getpid(void);
//总是返回调用者的进程号
pit_t getppid(void); //获取父进程的进程号
进程内存布局
每个进程所分配的内存由很多部分组成,称为“段”
文本段:进程运行的程序机器语言指令,只读
初始化数据段: 显示初始化的全局变量和静态变量。程序加载到内存时,从可执行文件中读取这些变量的值
未初始化数据段(BSS): 未进行显示初始化的全局变量和静态变量。已初始化和未初始化的变量分开放,主要原因在于程序在磁盘上存储时,没有必要为未初始化的变量分配存储空间,。可执行程序只需要记录未初始化数据段的位置及所需大小,直到运行时再由程序加载器来分配这一空间
栈:村塾函数的局部变量(自动变量),实参和返回值
堆:动态进行内存分配的一块区域
虚拟内存管理
虚拟内存的规划之一是将每个程序使用的内存切割成固定大小的“页”,相应的RAM划分成与虚拟内存页尺寸相同的页帧。同一时刻仅有部分成语需要驻留在物理内存页帧中。程序未使用的页拷贝保存在交换区(swap)--这是磁盘空间中的保留区域,作为RAM的补充,仅在需要时载入物理内存。
若进程访问的页目前未驻留在物理内存中,将会发生页面错误(page fault),内核即刻挂起进程的执行,同时从磁盘中将该页面载入内存。
为支持这一组织方式,内核需要为每个进程维护一个页表。该页表描述了每页在进程虚拟地址空间中位置。页表中的每个条目要么指出一个虚拟页面在RAM中的所在位置,要么表明其当前其驻留在磁盘上
在进程虚拟地址空间中,并非所有的地址范围都需要页表条目。通常情况下,由于可能存在大段的虚拟地址空间并未投入使用,故而没有必要为其维护相应的页表条目。若进程试图访问的地址并无页表条目与之对应,那么进程将收到一个SIGSEV信号
栈和栈帧
x86-32体系架构之上的linux,栈驻留在内存的高端并向下增长。专用寄存器(栈指针),用于跟踪当前栈顶
命令行参数
C语言程序必须有一个称为main()的函数,作为程序的启动点。当执行程序时,命令行参数(由shell逐一解析)通过两个入参提供给main()函数,第一个参数argc,标识命令行参数的个数,第二个参数char*argv[],是一个指向命令行参数的指针数组,每一参数是以"空字符"结尾的字符串,第一个字符串,argv[0],是该程序的名称
#include <stdio.h>
int main(int argc, char*argv[]){
int j;
for(j=0; j<argc; j++){
printf("argv[%d] = %s\n", j, argv[j]);
}
return 0;
}
环境列表
每一个进程都有与其相关的称为环境列表的字符串数组,其中每个字符串都以名称=值(name=value)形式定义 新进程在创建之时,会继承父进程的环境副本。子进程创建后,父,子进程均可更改各自的环境变量,且这些变更对对方不再可见
环境变量的最常见用途之一是在shell中,通过在自身环境中防止变量值,shell就可以确保把这些值传递给其所创建的进程,并以此来执行用户命令
大多数shell使用export命令向环境变量中添加变量值
SHELL=/bin/bash
export SHELL
C语言程序中,可以使用全局变量char ** environ访问环境变量
#include <stdio.h>
extern char** environ;
int main(int argc, char*argv[]){
char** ep;
for(ep=environ; *ep!=NULL; ep++){
puts(*ep);
}
return 0;
}
系统和进程信息
UNIX实现提供一个/proc虚拟文件系统。该文件系统驻留于/proc目录中,包含了各种用于展示内核信息的文件,并且允许程序通过常规文件IO系统调用来方便的读取。之所以将/proc文件系统称为虚拟,是因为其包含的文件和子目录并未存储与磁盘上,而是由内核在进程访问此类信息时动态创建而成
对于系统中每个进程,内核都提供了相应的目录,命令为/proc/PID,PID是进程ID
进程的创建
fork()允许(父)进程创建一新进程(子进程),新的子进程几乎是父进程的翻版:子进程获得父进程的栈,数据段,堆和执行文本段的拷贝
exit()终止一进程,将进程占用的所有资源归还内核
wait(&status):如果子进程尚未调用exit()终止,那么wait()会挂起父进程直到子进程终止;子进程的终止状态通过wait()的status参数返回
execve(pathname, argv, envp)加载一个新程序(路径名为pathname,参数列表为argc, 环境变量为envp)到当前内存。这将丢弃现存的程序文本段,并为新程序重新创建栈/数据段及堆
创建新的进程:fork()
#include <unistd.h>
pid_t fork(void);
完成对其调用后将存在两个进程,且每个进程都会从fork()的返回处继续执行 这两个进程将执行相同的程序文本段,但各自拥有不同的栈段/数据段,以及堆段的拷贝。子进程的栈,数据以及栈段开始时是对父进程内存相应各部分的完全复制。执行fork()之后,每个进程均可修改各自的栈数据,以及堆段中的变量,而并不影响另一进程
通过fork()返回值来区分父子进程。父进程中,将返回新创建子进程的进程ID。子进程中fork()返回0。返回-1时表示创建失败
pid_t childPid;
switch(childPid=fork()){
case -1:
//handle error
case 0:
//process for child
default:
//process for parent
}
父子进程间的文件共享
执行fork()时,子进程会获得父进程所有文件描述符的副本。这些副本的创建方式类似于dup(),这意味着父子进程中对应的描述符均指向相同的打开文件句柄。打开文件句柄包含当前文件偏移量以及文件状态标志(由open()设置,通过fcntl()的F_SETFL操作改变)。
一个打开文件的这些属性银子会在父子进程之间共享,如果子进程更新了文件偏移量,那么这种改变也会影响到父进程中相应的描述符
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <stdio.h>
int main(int argc, char*argv){
int fd, flags;
char tempalte[] = "/tmp/testXXXXXX";
setbuf(stdout, NULL); //disable buffering of stdout
fd = mkstemp(tempalte);
if(fd==-1){
printf("mkstemp error");
return -1;
}
printf("File offset before fork(): %lld\n", (long long)lseek(fd, 0, SEEK_CUR));
flags = fcntl(fd, F_GETFL);
if(flags==-1){
printf("fcntl error\n");
return -1;
}
printf("O_APPEND flag before fork() is: %s\n", (flags & O_APPEND)?"on":"off");
switch(fork()){
case -1:
printf("fork error.\n");
return -1;
case 0:
if(lseek(fd, 1000, SEEK_SET)==-1){
printf("lseek \n");
return -1;
}
flags |= O_APPEND;
if(fcntl(fd, F_SETFL, flags)==-1){
printf("fcntl set \n");
return -1;
}
default:
if(wait(NULL)==-1){
printf("wait error \n");
return -1;
}
printf("child has exited.\n");
printf("File offset in parent: %lld\n", (long long)lseek(fd, 0, SEEK_CUR));
flags = fcntl(fd, F_GETFL);
if(flags==-1){
printf("fcntl get\n");
return -1;
}
printf("O_APPEND flag in parent is: %s\n", (flags & O_APPEND)?"on":"off");
return 0;
}
}
父子进程间共享打开文件属性,假设父子进程同时写入一文件,共享文件偏移量会确保二者不会覆盖彼此的输出内容。不过,这并不能阻止父子进程的输出随意混杂在一起,要想规避这一现象,需要进行进程间同步。
比如,父进程可以使用wait()来暂停运行并等待子进程退出。shell就是这么做的:只有当执行命令的子进程退出后,shell才会打印出提示符(除非用户在命令行最后加上&,后台运行)
fork()的内存语义
在早期的UNIX实现中,fork()的复制:将父进程内存拷贝至交换空间,以此创建新进程映像,而在父进程保持自身内存的同时,将换出映像置为子进程
不过,要是简单的将父进程虚拟内页拷贝到新的子进程,那就太浪费了。原因有很多,其中之一时:fork()之后常常伴随着exec(),这回用新程序替换进程的代码段,并重新初始化其数据段,对战。大部分现代UNIX采用两种技术来避免这种浪费:
- 内核将每一进程的代码段标记为只读。这样父子进程可共享同一段代码段,fork()在为子进程创建代码段时,其所构建是的一系列进程级页表项均指向与父进程相同的物理内存页帧
- 对于父进程的数据段,堆栈的各页,内核采用写时复制技术。最初,内核做了一些设置,令这些段的页表项均指向与父进程相同的物理内存页,并将这些页面自身标记为只读。调用for()之后,内核会捕获所有父进程或子进程针对这些页面的修改企图,并为将要修改的页面创建拷贝。系统将新的页面拷贝分配给遭内核捕获的进程,进程还会对子进程的相应页表做适当调整。从这一刻起,父子进程可以分别修改各自的页拷贝,不再相互影响
控制进程的内存需求
通过将fork()和wait()组合使用
将对某函数func()的调用置于括号中。由执行程序可知,由于所有可能的变化都发生于子进程,故而从对func()的调用之前开始,父进程的内存使用量将保持不变
pid_t childPid;
int status;
childPid = fork();
if(childPid==-1)
errExit("fork");
if(childPid==0)
exit(func(argc)); //将func()的返回结果置于exit()的8位传出值中
if(wait(&status)==-1) //父进程调用wait()可获得该值
errExit("wait")
vfork()
vfork()是为子进程立即执行exec()的程序而专门设计的
现代UNIX采用写时复制来实现fork(),其效率较之于早期的fork()效率高出很多,进而将对vfork()的需求剔除殆尽
vfork()的用以在于执行该调用用后,系统将保证子进程先于父进程获得调度以使用CPU
fork()之后的竞争条件
调用fork()后,无法确定父子进程间谁先率先访问CPU。如果为了产生正确的结果而需要依赖于特定的执行序列,那么将可能因为竞争条件失败
同步信号以规避竞争条件
进程的终止
进程有两种终止方式。其一为异常终止,由对一信号的接收而引发,该信号的默认动作为终止当前进程,可能产生核心转储。
进程使用_exit()系统调用正常终止
#include <unistd.h>
void _exit(int status);
_exit()的status参数定义了进程的终止状态,父进程可调用wait()以获取该状态
调用_exit()的程序总会成功终止(即,_exit()从不返回)
虽然可将0-255之间的任意值赋给_exit()的status参数,并传递给父进程,不过如果取值大于128将在shell脚本中引发混乱。原因在于,当以信号(signal)终止一命令时,shell会将变量$?置为128与信号值之和,以表征这一事实。如果这与进程调用_exit()时所使用的相同status值混杂起来,将令shell无法区分
程序一般不会直接调用_exit(),而是调用库函数exit(),他会在_exit()前执行各种动作
- 调用退出处理程序
- 刷新stdio流缓冲区
- 使用status提供的值执行_exit()系统调用
监控子进程
wait()系统调用:
等待调用进程的任一子进程终止,同时在参数status所向的缓冲区中返回该子进程的终止状态
#include <wait.h>
pid_t wait(int *status);
- if on(previsouly unwaited-for) child of the calling processing has yet terminated,调用将一直阻塞,直到某个子进程终止,如果调用时已由子进程终止,wait()则立即返回
- 如果status非空,那么关于子进程如何终止的信息会通过status指向的执行变量返回
- 内核将会未父进程下所有子进程的运行总量追加进程CPU时间以及资源使用数据
- 将终止子进程的ID作为wait()的结果返回 出错时,wait()返回-1。
waitpid()系统调用
wait()存在的诸多限制:
- 如果父进程已经创建多个子进程,使用wait()将无法等待某个特定子进程的完成,只能按顺序等待下一个子进程的终止
- 如果没有子进程退出,wait()总是保持阻塞状态
- 使用wait()只能发现那些已经终止的子进程。对于子进程因某个信号(如SIGSTOP或SIGTTIN)而停止,或是已停止子进程收到SIGCONT信号后恢复执行的情况就无能为力了
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
参数pid
>0:等待进程IP为pid的子进程
==0:等待与父进程同一个进程组的所有子进程
<-1:等待进程组表示符与pid绝对值相等的所有子进程
==-1:等待任意子进程
参数options:位掩码
WUNTRACED:除了返回终止子进程的信息外,还返回因信号而停止的子进程信息
WCONTINUED:返回那些因收到SIGCONT信号而恢复执行的已停止的子进程信息
WNOHANG:
孤儿进程与僵尸进程
init进程会接管孤儿进程。即某一进程的父进程终止后,对getppid()的调用将返回1,这是判定子进程的“生父”是否“在世”的方法之一
在父进程wait()之前,子进程就已终止,将会发生什么?即使子进程已经结束,系统仍然允许其父进程在之后的某一时刻去执行wait(),以确定该子进程是如何终止的。内核通过将子进程转为僵尸进程来处理种种情况。
这也意味着将释放子进程所把持的大部分资源,以便其他进程重新使用。该进程所唯一保留的是内核进程表中的一条记录,其中包含了子进程ID,终止状态,资源使用数据等信息
当父进程执行wait()后,由于不再需要子进程所剩余的最后信息,故而内核将删除僵尸进程。另一方面,如果父进程未执行wait()便退出,那么init进程将接管子进程并自动调用wait(),从而从系统中移除僵尸进程
如果父进程创建了某一进程,但并未执行wait(),那么在内核的进程表中将为该子进程永久保留一条记录。如果存在大量此类僵尸进程,他们势必填满内核进程表,从而阻碍新进程的创建。
既然无法用信号杀死僵尸进程,那么从系统中将其移除的唯一方法就是杀掉他们的父进程(或等待其父进程终止),此时Init进程将会接管和等待这些僵尸进程,从而从系统中将他们清除
//创建一个僵尸进程
#include <stdio.h>
#include <signal.h>
#include <libgen.h>
#define CMD_SIZE 200
int main(int argc, char* argv[]){
char cmd[CMD_SIZE];
pid_t childPid;
setbuf(stdout, NULL);
printf("Parent PID=%ld\n", (long)getpid());
switch(childPid=fork()){
case -1:
printf("fork err\n");
return -1;
case 0:
printf("Child (PID=%\d) exiting\n", (long)getpid());
_exit(0);
default:
sleep(3);
snprintf(cmd, CMD_SIZE, "ps | grep %s", basename(argv[0]));
cmd[CMD_SIZE-1] = '\0';
system(cmd);
if(kill(childPid, SIGKILL)==-1){
printf("kill error\n");
}
sleep(3);
printf("After sending SIGKILL to zombie (PID=%\d):\n", (long)childPid);
system(cmd);
return 0;
}
}
SIGCHLD信号
无论一个子进程于何时终止,系统都会向其父进程发送SIGHLD信号。对该信号的默认处理是将其忽略,不过也可以安装信号处理程序来捕获它
//TODO
程序的执行
系统调用execve()可以将新程序加载到某一进程的内存空间。在这一操作过程中,将丢弃旧有程序,进程的栈,数据以及堆会被系程序的相应部件所替换
由fork()生成的子进程对execve()的调用最为频繁
#include <unistd.h>
int execve(const char*pathname, char *const argv[], char *const envp[]);
参数pathname包含了准备载入当前进程空间的新程序的路径名
argv参数指定传递给新进程的命令行参数
envp参数指定了新程序的环境列表
system()
程序可通过调用system()函数来执行任意的shell命令
#include <stdlib.h>
int system(const char *command)
函数system()创建一个子进程来运行shell,并以之命令执行command
system()有点:
- 无需处理对fork(),exec(),wait()和exit()的调用细节
- system()会代为处理错误和信号
- 因为system()使用shell来执行命令,所以会在执行command之前对其进行所有的常规shell处理,替换以及重定向操作
这些优点是以效率低为代价的。使用system()运行命令需要创建至少两个进程。一个用于运行shell,另一个或多个用于shell所执行的命令(执行每个命令都会调用一次exec())
system()的实现
//TODO
进程创建和程序执行
进程记账
打开进程记账功能后,内核会在每个进程终止时将一条记账信息写入系统级的进程记账文件。这条账单记录包含了内核为进程所维护的多种信息,包括终止状态以及进程消耗的CPU时间 linux的进程记账功能属于可选内核组件,可以通过CONGFIG_BSD_PROCESS_ACCT选项进行配置
clone()
clone()创建一个新进程,对比fork(),clone()在进程创建期间对步骤的控制更为精确。clone()主要用于线程库的实现
#define _GUN_SOURCE
#include <sched.h>
int clone(int (*func)(void*), void *child_stack, int flags, void *func_argc,...)
clone()生成的子进程继续运行时不以调用处为七点,转而去调用参数func所指定的函数,func又称为子函数。调用子函数时的参数由func_arg指定
对内核而言,fork() vfork()和clone()最终都由同一函数实现:do_fork()
clone()的参数flags服务于双重目的。首先,其低字节中存放着子进程的终止信号,子进程退出时其父进程将收到这一信号。 flags的剩余字节存放了位掩码,用于控制clone()的操作 进程和线程都是内核调度实体(KSE),只是与其他KSE之间对属性(虚拟内存,打开文件描述符,对信号的处置,进程ID等)的共享程度不同 clone()参数flags的位掩码
共享文件描述符: CLONE_FILES:如果指定了CLONE_FILES标志,父子进程会共享同一个打开文件描述符。即无论哪个进程对文件描述符的分配和释放都会影响另一进程。如果未设置CLONE_FILE,那么就不会共享文件描述符表,子进程获取的是父进程调用clone()时文件描述符表的一份拷贝。这些描述符副本与其父进程中的相应描述符均指向相同的打开文件
POSIX线程规范要求进程中的所有线程共享相同的打开文件描述符
共享与文件系统相关的信息: CLONE_FS:如果制定了CLONE_FS标志,那么父子进程将共享与文件系统相关的信息。如果未设置CLONE_FS,父子进程对此类信息各持一份
POSIX线程规范要求实现CLONE_FS标志所提供的属性共享
.........