进程(英语:process),是计算机中已运行程序的实体。进程为曾经是分时系统的基本运作单位。在面向进程设计的系统(如早期的UNIX,Linux2.4及更早的版本)中,进程是程序的基本执行实体;在面向线程设计的系统(如当代多数操作系统、Linux 2.6及更新的版本)中,进程本身不是基本运行单位,而是线程的容器。程序本身只是指令、数据及其组织形式的描述,进程才是程序(那些指令和数据)的真正运行实例。若干进程有可能与同一个程序相关系,且每个进程皆可以同步(循序)或异步(平行)的方式独立运行。现代计算机系统可在同一段时间内以进程的形式将多个程序加载到存储器中,并借由时间共享(或称时分复用),以在一个处理器上表现出同时(平行性)运行的感觉。同样的,使用多线程技术(多线程即每一个线程都代表一个进程内的一个独立执行上下文)的操作系统或计算机架构,同样程序的平行线程,可在多CPU主机或网络上真正同时运行(在不同的CPU上)。
注:以上内容来自维基百科。
本节将介绍基本的进程控制原语,包括进程的创建与退出,以及设置除进程标识符(PID)以外的其他标识符。
1 创建进程
Linux系统允许任何一个用户进程创建一个子进程,创建成功后,子进程存在于系统之中,并且独立于父进程。该子进程可以接受系统调度,可以得到分配的系统资源。系统也可以检测到子进程的存在,并且赋予它与父进程同样的权利。
Linux系统下使用fork()函数创建一个子进程,其函数原型如下:
#include <unistd.h> pid_t fork(void);
在讨论fork()函数之前,有必要先明确父进程和子进程两个概念。除了0号进程(该进程是系统自举时由系统创建的)以外,Linux系统中的任何一个进程都是由其他进程创建的。创建新进程的进程,即调用fork()函数的进程就是父进程,而新创建的进程就是子进程。
补充(维基百科):
在UNIX里,除了进程0(即PID=0的交换进程,Swapper Process)以外的所有进程都是由其他进程使用系统调用fork创建的,这里调用fork创建新进程的进程即为父进程,而相对应的为其创建出的进程则为子进程,因而除了进程0以外的进程都只有一个父进程,但一个进程可以有多个子进程。操作系统内核以进程标识符(Process Identifier,即PID)来识别进程。进程0是系统引导时创建的一个特殊进程,在其调用fork创建出一个子进程(即PID=1的进程1,又称init)后,进程0就转为交换进程(有时也被称为空闲进程),而进程1(init进程)就是系统里其他所有进程的祖先。
进程0:Linux引导中创建的第一个进程,完成加载系统后,演变为进程调度、交换及存储管理进程。
进程1:init 进程,由0进程创建,完成系统的初始化. 是系统中所有其它用户进程的祖先进程。
Linux中1号进程是由0号进程来创建的,因此必须要知道的是如何创建0号进程,由于在创建进程时,程序一直运行在内核态,而进程运行在用户态,因此创建0号进程涉及到特权级的变化,即从特权级0变到特权级3,Linux是通过模拟中断返回来实现特权级的变化以及创建0号进程,通过将0号进程的代码段选择子以及程序计数器EIP直接压入内核态堆栈,然后利用iret汇编指令中断返回跳转到0号进程运行。
fork()函数不需要参数,返回值是一个进程标识符(PID)。对于返回值,有以下3种情况:
(1) 对于父进程,fork()函数返回新创建的子进程的ID。
(2) 对于子进程,fork()函数返回0。由于系统的0号进程是内核进程,所以子进程的进程标识符不会是0,由此可以用来区别父进程和子进程。
(3) 如果创建出错,则fork()函数返回-1。
fork()函数会创建一个新的进程,并从内核中为此进程分配一个新的可用的进程标识符(PID),之后,为这个新进程分配进程空间,并将父进程的进程空间中的内容复制到子进程的进程空间中,包括父进程的数据段和堆栈段,并且和父进程共享代码段。这时候,系统中又多了一个进程,这个进程和父进程一模一样,两个进程都要接受系统的调度。
注意:由于在复制时复制了父进程的堆栈段,所以两个进程都停留在了fork()函数中,等待返回。因此,fork()函数会返回两次,一次是在父进程中返回,另一次是在子进程中返回,这两次的返回值是不一样的。
下面给出的示例程序用来创建一个子进程,该程序在父进程和子进程中分别输出不同的内容。
//@file fork.c //@brief create a new process #include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { pid_t pid;//to store pid value pid = fork();//create a new process if (pid < 0) { //error perror("fail to fork"); exit(-1); } else if (pid == 0) { //sub-process printf("Sub-process, PID: %u, PPID: %u ", getpid(), getppid()); } else { //parent process printf("Parent, PID: %u, Sub-process PID: %u ", getpid(), pid); } return 0; }
程序运行结果如下:
xiaomanon@xiaomanon-machine:~/Documents/c_code$ ./fork Parent, PID: 2598, Sub-process PID: 2599 Sub-process, PID: 2599, PPID: 2598
由于创建的新进程和父进程在系统看来是地位平等的两个进程,所以运行机会也是一样的,我们不能够对其执行先后顺序进行假设,先执行哪一个进程取决于系统的调度算法。如果想要指定运行的顺序,则需要执行额外的操作。正因为如此,程序在运行时并不能保证输出顺序和上面所描述的一致。
2 父子进程的共享资源
子进程完全复制了父进程的地址空间的内容,包括堆栈段和数据段的内容。子进程并没有复制代码段,而是和父进程共用代码段。这样做是存在其合理依据的,因为子进程可能执行不同的流程,那么就会改变数据段和堆栈段,因此需要分开存储父子进程各自的数据段和堆栈段。但是代码段是只读的,不存在被修改的问题,因此这一个段可以让父子进程共享,以节省存储空间,如下图所示。
下面给出一个示例来说明这个问题。该程序定义了一个全局变量global、一个局部变量stack和一个指针heap。该指针用来指向一块动态分配的内存区域。之后,该程序创建一个子进程,在子进程中修改global、stack和动态分配的内存中变量的值。然后在父子进程中分别打印出这些变量的值。由于父子进程的运行顺序是不确定的,因此我们先让父进程额外休眠2秒,以保证子进程先运行。
//@file fork.c //@brief resource sharing between parent-process and sub-process #include <stdio.h> #include <stdlib.h> #include <unistd.h> int global = 1; /*global variable, stored at data section*/ int main(void) { pid_t pid;//to store pid value int stack = 1;//local variable, stored at stack int *heap;//pointer to a heap variable heap = (int *)malloc(sizeof(int)); *heap = 2;//set the heap value to 2 pid = fork();//create a new process if (pid < 0) { //error perror("fail to fork"); exit(-1); } else if (pid == 0) { //sub-process, change values global++; stack++; (*heap)++; //print all values printf("In sub-process, global: %d, stack: %d, heap: %d ", global, stack, *heap); exit(0); } else { //parent process sleep(2);//sleep 2 secends to make sure the sub-process runs first printf("In parent-process, global: %d, stack: %d, heap: %d ", global, stack, *heap); } return 0; }
程序运行效果如下:
xiaomanon@xiaomanon-machine:~/Documents/c_code$ ./fork In sub-process, global: 2, stack: 2, heap: 3 In parent-process, global: 1, stack: 1, heap: 2
由于父进程休眠了2秒钟,子进程先于父进程运行,因此会先在子进程中修改数据段和堆栈段中的内容。因此不难看出,子进程对这些数据段和堆栈段中内容的修改并不会影响到父进程的进程环境。
父进程的资源大部分被fork()函数所复制,只有小部分是子进程与父进程不同的。子进程继承的资源情况如下表所示:
现在的Linux内核实现fork()函数时往往实现了在创建子进程时并不立即复制父进程的数据段和堆栈段,而是当子进程修改这些数据内容时复制才会发生,内核才会给子进程分配进程空间,将父进程的内容复制过来,然后继续后面的操作。这样的实现更加合理,对于一些只是为了复制自身完成一些工作的进程来说,这样做的效率会更高。这也是现代操作系统中一个重要的概念——“写时复制”的一个重要体现。
3 fork出错的情况
有两种情况可能会导致fork()函数出错:
(1) 系统中已经有太多的进程存在了
(2) 调用fork()函数的用户进程太多了
一般情况下,系统都会对一个用户所创建的进程数加以限制。如果操作系统不对其加限制,那么恶意用户可以利用这一缺陷攻击系统。下面是一个利用进程的特性编写的一个病毒程序,该程序是一个死循环,在循环中不断调用fork()函数来创建子进程,直到系统中不能容纳如此多的进程而崩溃为止。下图展示了这种情况:
//@file fork.c //@brief do bad thing, always create sub-process #include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { while (1) fork(); return 0; }
程序运行结果如下:
xiaomanon@xiaomanon-machine:~/Documents/c_code$ ./fork & [1] 13618 xiaomanon@xiaomanon-machine:~/Documents/c_code$ ps -u xiaomanon bash: fork: retry: Resource temporarily unavailable bash: fork: retry: Resource temporarily unavailable bash: fork: retry: Resource temporarily unavailable bash: fork: retry: Resource temporarily unavailable bash: fork: Resource temporarily unavailable
系统可能会变得很慢,以上是本人在Ubuntu 14.04LTS(虚拟机)上的测试结果,需要重启才能解决问题。
注意:在现在的操作系统中,这种情况是不被允许的。因此,系统中限制了一个用户创建的进程的数量,这种进攻已经不能奏效。
4 创建共享空间的子进程
进程在创建一个新的子进程之后,子进程的地址空间完全和父进程分开。父子进程是两个独立的进程,接受系统调度和分配系统资源的机会均等,因此父进程和子进程更像是一对兄弟。如果父子进程共用父进程的地址空间,则子进程就不是独立于父进程的。
Linux环境下提供了一个与fork()函数类似的函数,也可以用来创建一个子进程,只不过新进程与父进程共用父进程的地址空间,其函数原型如下:
#include <unistd.h> pid_t vfork(void);
vfork()和fork()函数的区别有以下两点:
(1) vfork()函数产生的子进程和父进程完全共享地址空间,包括代码段、数据段和堆栈段,子进程对这些共享资源所做的修改,可以影响到父进程。由此可知,vfork()函数与其说是产生了一个进程,还不如说是产生了一个线程。
(2) vfork()函数产生的子进程一定比父进程先运行,也就是说父进程调用了vfork()函数后会等待子进程运行后再运行。
下面的示例程序用来验证以上两点。在子进程中,我们先让其休眠2秒以释放CPU控制权,在前面的fork()示例代码中我们已经知道这样会导致其他线程先运行,也就是说如果休眠后父进程先运行的话,则第(2)点则为假;否则为真。第(2)点为真,则会先执行子进程,那么全局变量便会被修改,如果第(1)点为真,那么后执行的父进程也会输出与子进程相同的内容。代码如下:
//@file vfork.c //@brief vfork() usage #include <stdio.h> #include <stdlib.h> #include <unistd.h> int global = 1; int main(void) { pid_t pid; int stack = 1; int *heap; heap = (int *)malloc(sizeof(int)); *heap = 1; pid = vfork(); if (pid < 0) { perror("fail to vfork"); exit(-1); } else if (pid == 0) { //sub-process, change values sleep(2);//release cpu controlling global = 999; stack = 888; *heap = 777; //print all values printf("In sub-process, global: %d, stack: %d, heap: %d ", global, stack, *heap); exit(0); } else { //parent-process printf("In parent-process, global: %d, stack: %d, heap: %d ", global, stack, *heap); } return 0; }
程序运行效果如下:
xiaomanon@xiaomanon-machine:~/Documents/c_code$ ./vfork
In sub-process, global: 999, stack: 888, heap: 777 In parent-process, global: 999, stack: 888, heap: 777
注意:如果不在子进程中添加exit()函数退出的话,会导致执行父进程时出现段错误,原因目前还没弄明白。
5 在函数内部调用vfork
在使用vfork()函数时应该注意不要在任何函数中调用vfork()函数。下面的示例是在一个非main函数中调用了vfork()函数。该程序定义了一个函数f1(),该函数内部调用了vfork()函数。之后,又定义了一个函数f2(),这个函数没有实际的意义,只是用来覆盖函数f1()调用时的栈帧。main函数中先调用f1()函数,接着调用f2()函数。
//@file vfork.c //@brief vfork() usage #include <stdio.h> #include <stdlib.h> #include <unistd.h> int f1(void) { vfork(); return 0; } int f2(int a, int b) { return a+b; } int main(void) { int c; f1(); c = f2(1,2); printf("%d ",c); return 0; }
程序运行效果如下:
xiaomanon@xiaomanon-machine:~/Documents/c_code$ ./vfork 3 Segmentation fault (core dumped)
通过上面的程序运行结果可以看出,一个进程运行正常,打印出了预期结果,而另一个进程似乎出了问题,发生了段错误。出现这种情况的原因可以用下图来分析一下:
左边这张图说明调用vfork()之后产生了一个子进程,并且和父进程共享堆栈段,两个进程都要从f1()函数返回。由于子进程先于父进程运行,所以子进程先从f1()函数中返回,并且调用f2()函数,其栈帧覆盖了原来f1()函数的栈帧。当子进程运行结束,父进程开始运行时,就出现了右图的情景,父进程需要从f1()函数返回,但是f1()函数的栈帧已经被f2()函数的所替代,因此就会出现父进程返回出错,发生段错误的情况。
由此可知,使用vfork()函数之后,子进程对父进程的影响是巨大的,其同步措施势在必行。
6 退出进程
当一个进程需要退出时,需要调用退出函数。Linux环境下使用exit()函数退出进程,其函数原型如下:
#include <stdlib.h> void exit(int status);
exit()函数的参数表示进程的退出状态,这个状态的值是一个整型,保存在全局变量$?中,在shell中可以通过“echo $?”来检查退出状态值。
注意:这个退出函数会深入内核注销掉进程的内核数据结构,并且释放掉进程的资源。
7 exit函数与内核函数的关系
exit函数是一个标准的库函数,其内部封装了Linux系统调用_exit()函数。两者的主要区别在于exit()函数会在用户空间做一些善后工作,例如清理用户的I/O缓冲区,将其内容写入 磁盘文件等,之后才进入内核释放用户进程的地址空间;而_exit()函数直接进入内核释放用户进程的地址空间,所有用户空间的缓冲区内容都将丢失。
8 设置进程所有者
每个进程都有两个用户ID,实际用户ID和有效用户ID。通常这两个ID的值是相等的,其取值为进程所有者的用户ID。但是,在有些场合需要改变进程的有效用户ID。Linux环境下使用setuid()函数改变一个进程的实际用户ID和有效用户ID,其函数原型如下:
#include <unistd.h>
int setuid(uid_t uid);
setuid()函数的参数表示改变后的新用户ID,如果成功修改当前进程的实际用户ID和有效用户ID,函数返回值为0;如果失败,则返回-1。只有两种用户可以修改进程的实际用户ID和有效用户ID:
(1) 根用户:根用户可以将进程的实际用户ID和有效用户ID更换。
(2) 其他用户:其该用户的用户ID等于进程的实际用户ID或者保存的用户ID。
也就是说,用户可以将自己的有效用户ID改回去。这种情况多出现于下面的情况:一个进程需要具有某种权限,所以将其有效用户ID设置为具有这种权限的用户ID,当进程不需要这种权限时,进程还原自己之前的有效用户ID,使自己的权限复原。下面给出一个修改的示例:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { uid_t uid, euid; uid = getuid(); euid = geteuid(); printf("Before, uid: %d, euid: %d ", uid, euid); if (setuid(1024) == -1) { perror("fail to set uid"); exit(-1); } uid = getuid(); euid = geteuid(); printf("After, uid: %d, euid: %d ", uid, euid); return 0; }
程序运行效果如下:
xiaomanon@xiaomanon-machine:~/Documents/c_code$ ./setuid Before, uid: 1000, euid: 1000 fail to set uid: Operation not permitted xiaomanon@xiaomanon-machine:~/Documents/c_code$ sudo ./setuid
Before, uid: 0, euid: 0 After, uid: 1024, euid: 1024
说明:为了保证程序正确运行,用户应当具有该用户权限。以上示例中,当前用户就没有修改uid的权限,而使用超级用户权限时,能够成功修改。那么,如何让当前用户拥有修改用户ID的权限呢?
Linux环境下还提供了只修改有效用户ID的函数seteuid(),以及修改修改实际组ID和有效组ID的函数,其参数和返回值含义与setuid()的类似,函数原型如下所示:
#include <unistd.h> int seteuid(uid_t uid); int setgid(gid_t gid); int setegid(gid_t gid);
9 参考文献
[1] 吴岳,Linux C程序设计大全,清华大学出版社
[2] IBM, UNIX进程揭秘, developerWorks