进程
A process is simply a running program
进程是对操作系统如何运行程序的一种抽象。
进程的组成:由程序运行过程中的机器状态(machine state)决定
- 内存:程序的代码(指令)和静态数据必须先载入内存,所以进程必须包括它可寻址的内存(address space)。
- 寄存器:指令由CPU执行,包括取指令,操作数据等等,由CPU中的各种寄存器完成;
- 通用寄存器,用于传送和暂存数据,也可用于参与运算并保存运算结果(如累加器)。
- 程序计数器(program counter ),也叫指令指针(instruction pointer),记录当前正被执行的指令。
- 栈指针(stack pointer)和帧指针(frame pointer),管理函数调用栈,函数调用栈包括参数、局部变量和返回地址等信息。
- IO:很多程序有持久化操作,操作系统将所有IO都抽象成文件,所以进程还包括它打开的所有文件列表
创建进程
进程的创建过程包括:
- 加载:加载程序代码和静态数据到内存
- 预加载:在运行程序之前一次全部加载完。
- 懒加载:只加载程序运行时需要的部分代码和数据,由分页和交换( paging and swapping)实现。
- 初始化:分配内存及初始化IO
- 栈空间:函数的局部变量,参数,返回地址;main函数的实参(argc或argv)
- 堆空间:代码和静态数据以及动态分配的对象(malloc,free)
- IO:标准输入、输出、错误
- 执行:从entry point运行程序,即执行main方法
fork()
fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程。
为了方便起见,把调用fork()的进程称作父进程,新创建的进程称作子进程。
父进程调用fork()函数后,系统先给子进程分配资源,然后把父进程的所有值都复制到子进程中(只有少数值与父进程的值不同),相当于父进程克隆了自己。
fork()函数的奇怪之处在于它仅仅被调用一次,却能够返回两次。
它可能有三种不同的返回值:
- 在父进程中,返回新创建子进程的进程ID;
- 在子进程中,返回0,可以理解为子进程的子进程ID(没有,就0呗);
- 如果出现错误,返回一个负值。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[]){
printf("hello world (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child (new process)
printf("hello, I am child (pid:%d)\n", (int) getpid());
} else { // parent goes down this path (main)
printf("hello, I am parent of %d (pid:%d)\n",rc, (int) getpid());
}
return 0;
}
hello world (pid:29146)
hello, I am parent of 29147 (pid:29146)
hello, I am child (pid:29147)
fork()成功后,系统中出现两个基本完全相同的进程,这两个进程执行顺序是不固定的,取决于系统的调度策略。
每个进程都有一个唯一的进程标识,可以通过getpid()函数获得;还有一个记录父进程标识的变量,可以通过getppid()函数获得。
wait()
the wait() system call allows a parent to wait for a child process to finish what it has been doing
修改之前的例子,让父进程等待子进程执行完再执行。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[]){
printf("hello world (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child (new process)
printf("hello, I am child (pid:%d)\n", (int) getpid());
} else { // parent goes down this path (main)
int wc = wait(NULL);
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n", rc, wc, (int) getpid());
}
return 0;
}
exec()
The exec() system call allows a program to run a different program.
调用exec()不会创建新进程,而是将当前运行的程序转换为不同的运行程序。
进程调用exec()时,会从可执行文件加载代码和静态数据,并覆盖它当前的代码段和静态数据,因此,进程内存空间的堆栈以及其他部分都将重新初始化。
使用execvp调用wc程序计算文件p3.c中的单词个数:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
int main(int argc, char *argv[]){
printf("hello world (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child (new process)
printf("hello, I am child (pid:%d)\n", (int) getpid());
char *myargs[3];
myargs[0] = strdup("wc"); // program: "wc" (word count)
myargs[1] = strdup("p3.c"); // argument: file to count
myargs[2] = NULL; // marks end of array
execvp(myargs[0], myargs); // runs word count
printf("this shouldn’t print out");
} else { // parent goes down this path (main)
int wc = wait(NULL);
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n", rc, wc, (int) getpid());
}
return 0;
}
为什么将fork()和exec()分开?
fork()和exec()的分离对于构建UNIX shell至关重要。
shell可以在fork()和exec()之间运行其它的代码,此代码可以改变即将运行的程序的环境,从而可以轻松实现各种有趣的功能。
例如:计算单词并将结果保存到文件
wc p3.c > newfile.txt
大概的过程如下:
- 当前shell进程调用fork()创建一个子进程,然后调用wait()等待子进程执行完
- 子进程先关闭标准输出并打开文件newfile.txt,然后调用exec()执行wc程序
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/wait.h>
int main(int argc, char *argv[]){
int rc = fork();
if (rc < 0) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child: redirect standard output to a file
close(STDOUT_FILENO);
open("./newfile.txt", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);
// now exec "wc"...
char *myargs[3];
myargs[0] = strdup("wc"); // program: "wc" (word count)
myargs[1] = strdup("p4.c"); // argument: file to count
myargs[2] = NULL; // marks end of array
execvp(myargs[0], myargs); // runs word count
} else { // parent goes down this path (main)
int wc = wait(NULL);
}
return 0;
}
受限直接执行(Limited Direct Execution)
操作系统通过虚拟化CPU使得每个进程看来像是独占CPU。
操作系统通过分时(time sharing)来实现这种虚拟化:每次运行一个进程一段时间,再切换到另一个进程执行一段时间,以此类推。
实现分时虚拟化时面临的两个挑战:性能和控制。
- 如何在不增加系统额外开销的情况下实现这种虚拟化?
- 如何在保持对CPU控制的同时高效地运行进程?
操作系统权衡之下,采取了受限直接执行的方式来解决这两个问题。
直接执行(Direct Execution)
为了尽可能快地运行进程,让进程直接在CPU上运行,这就是直接执行地基本思想。
因此,当OS启动一个程序时,会在进程列表中为它创建一个进程条目,为进程分配内存页并将程序加载到内存中,定位并跳转到入口点(main函数)执行用户代码。
直接执行虽然快,但有两个重要的问题:
- 如何应对受限制的操作?如果进程希望执行某种受限制的操作,比如向磁盘发出I/O请求,或者访问更多的系统资源(如CPU或内存),该怎么办?
- 如何切换进程?当我们运行一个进程时,操作系统如何停止它的运行并切换到另一个进程,从而实现虚拟化CPU所需的时间共享?
问题1:受限操作
如果进程可以使用CPU的任何操作,比如访问整个IO系统,那系统保护性荡然无存。
为此,操作系统引入一种新的处理器模式,称为用户模式(user mode)。
用户进程运行在用户模式,其代码功能会受到限制。例如,进程不能直接发出IO请求,这样做会导致处理器异常,然后操作系统可能会终止该进程。
与用户模式相反的是内核模式(kernel mode),即操作系统(或内核)运行的模式。
在内核模式下运行的代码可以做它喜欢做的任何事情,包括特权操作,比如发出I/O请求和执行所有类型的受限指令。
那么问题来了:当用户进程希望执行某种特权操作时,它应该怎么办?
为了实现这一点,几乎所有现代硬件都为用户程序提供了执行系统调用(system call)的能力。
系统调用允许内核小心地向用户程序公开某些关键功能,比如访问文件系统、创建和销毁进程、与其他进程通信以及分配更多内存。
要执行系统调用,程序必须执行一条特殊的 trap 指令,同时该指令跳转到内核,将权限级别提升到内核模式。一旦进入内核,操作系统现在就可以执行所需的任何特权操作,从而为调用进程执行所需的工作。当操作完成时,操作系统调用一条特殊的 return-from-trap 指令,该指令返回到调用程序,同时将特权级别降低到用户模式。
在执行trap指令时,硬件必须保存足够多的调用者寄存器状态,以便能够在OS发出return-from-trap指令时正确返回。
例如,在x86上,执行trap指令时,处理器会把程序计数器、标志和一些其他寄存器保存到每个进程的内核堆栈(kernel stack)上,执行return-from-trap指令时将从内核堆栈中取出这些值并恢复用户模式程序的执行。
那么问题又来了:trap指令如何知道在操作系统中运行哪些代码?
显然,不能让调用进程直接指定要跳转的地址,这样做将允许程序跳转到内核的任何地方,这非常危险。因此内核必须小心控制trap执行的代码。
内核通过在引导(boot time)时设置陷阱表(trap table)来实现这一点。
当机器启动时,它以特权(内核)模式启动,因此OS可以根据需要自由配置机器硬件。
OS要做的第一件事就是告诉硬件在某些异常事件发生时要运行什么代码。操作系统会使用一些特殊指令通知硬件陷阱处理程序(trap handlers)的位置。硬件会记住这些处理程序的位置,直到下一次重新引导机器,这样硬件就知道异常事件(系统调用或其它异常)发生时该做什么了。
以下时整个时间线:
问题2:进程间切换
如果一个进程在CPU上运行,这就意味着操作系统没有运行(没有获得CPU)。如果操作系统不运行,它怎么能做任何事情呢?
也就是说,操作系统如何重新控制CPU?解决办法有两种:协作式和非协作式。
协作式:等待系统调用
协作式是比较老的操作系统采用的方式,操作系统信任系统进程的行为是合理的。假定运行时间过长的进程会周期性地放弃CPU,以便操作系统可以决定运行其他任务。
在协作式调度系统中,OS通过等待系统调用或某种非法操作的发生来重新控制CPU。
OS会提供一个yield系统调用,让进程主动调用yield放弃CPU。
OS还会以trap handler的形式提供一些异常((如被0除))处理程序,这样用户进程在异常发生时,就可以调用相应trap指令转到操作系统。
这种被动的方式不是很理想,例如,如果一个进程进入无限循环,并且从未进行系统调用,会发生什么?那么操作系统能做什么呢?(看来要重启了!!!)
非协作式:接管控制权
非协作式操作系统结合硬件特性,采用定时器中断(timer interrupt)接管CPU控制权。
定时器设备可以被编程成每隔多少毫秒触发一次中断。当中断被触发时,当前正在运行的进程将被停止,一个预先配置的中断处理程序将在OS中运行。此时,OS已经重新控制了CPU,因此可以做它想做的事情:停止当前进程,并启动一个不同的进程。
操作系统必须通知硬件定时器中断发生时要执行的代码。
因此,在引导时,操作系统必须启动计时器(特权操作)。一旦定时器启动,操作系统就会感到安全,因为控制权最终会返回给它,操作系统可以自由地运行用户程序。
当中断发生时,硬件要保存正在运行的程序的状态,以便后续的return-from-trap指令能够正确地恢复正在运行的程序。这点与系统调用的方式基本相同。
上下文切换(Saving and Restoring Context)
既然OS已经重新获得了控制权,它可以继续运行当前进程,也可以切换到另一个进程,这取决于调度策略。
如果操作系统决定切换进程,它将执行一段低层代码,称为上下文切换(context switch)。
上下文切换在概念上很简单:操作系统为当前进程保存一些寄存器值(例如,保存到它的内核堆栈中),并为即将执行的进程恢复一些寄存器值(也是从它的内核堆栈中)。
这样,操作系统就可以确保当 return-from-trap 指令执行时,系统不会返回到正在运行的进程,而是重新执行另一个进程。
定时器中断完整时间线: