虚拟化:
进程(Process):是一个运行的程序。程序本身是呆在磁盘上,一堆指令或者可能有一些静态的数据,等待被OS运行。
我们是想要一次运行多个进程的,尽管我们实际上只有几个CPU能用,OS如何提供这种近似无限的CPU供应?
运行一个进程->停止一个进程->运行另一个进程;然后不断重复,以达到CPU的时分(time sharing),但是越多进程会带来性能上的影响。与时分相对应的是空分(space sharing),例如:硬盘的某个块一旦给了某个文件,那除非文件删除,否则这个块就不会给其他的文件。
而要完成这种运行多进程的要求,需要有底层和上层的智慧:
底层的机制(low-level mechanisms),比如:上下文切换(context switch)。
而上层的智慧(high-level intelligence),是调度策略(scheduling policies)。策略可能会利用一些历史信息(比如:上一分钟哪个程序运行的时间多)、或负载信息(比如:是什么类型的程序在运行)、或性能指标(比如:交互性能或者吞吐量是否优化?)来做出程序上的调度决定。
为了明白什么组成了进程,需要知道它的机器状态(machine state):
- 一个是内存,指令在内存中,数据也在内存中。因此,进程可以访问的内存(地址空间,address space)是进程的一部分。
- 另一部分是寄存器,很多指令显性地读和更新寄存器。还有一些特殊的寄存器,比如,程序计数器(PC, short for program counter)有时也被叫做指令指针(IP,short for instruction pointer),它告诉我们程序的下一条执行的指令是什么;栈指针(stack pointer)和相关的帧指针(frame pointer)被用来管理栈(包含函数参数,局部变量和返回地址)。
- 最后,可能还有程序访问的存储设备。这些I/O的可能包括一系列进程已经打开的文件。
Process API:
Create:双击应用打开,或者在shell里面使用指令,OS都会创建新的进程来执行程序。
Destroy:有创建就得有销毁。
Wait:等待进程结束运行。
Miscellaneous Control:暂停进程,然后恢复。
Status: 进程的状态信息,比如:运行了多久了,或者当前进程处于什么状态。
如何创建进程?
- 首先,OS把代码和静态数据(比如,已初始化的变量)加载到内存(进程的地址空间)。这些程序最初以可执行格式存在于硬盘。
早期的OS,以积极的(eagerly)方式加载,而现在的OS,以懒惰的(lazily)方式加载,需要执行的时候再加载,而不是一次性都先加载。(这部分涉及到页(paging)和交换(swapping)。) - 然后,需要初始化运行时的栈(run-time stack)。比如前面提到的C语言的栈,包含局部变量,函数参数,返回地址。OS可能把argc和argv填充给main()函数了。
- 然后,需要分配内存给程序的堆(heap)。C程序中,使用malloc和free函数来动态请求和释放空间。一些有趣的数据结构比如链表、哈希表、树等需要用到heap。
- OS还会做其他的初始化任务,比如相关的I/O。例如,UNIX系统中,每个进程默认有三个打开的文件描述符(file descriptors),标准输入,输出和错误。(standard input, output and error)。这些描述符容易让程序从终端中读取输入和输出到屏幕。
- 做完这些,OS要设置程序执行的阶段。将程序开始在main()函数,跳到main()对应的路线(routine),然后把CPU的控制权交给新建的进程,进程就开始执行。
进程状态:
运行(running):正在处理器运行。
准备(ready):资源什么的都准备好,但是OS没选该进程运行。
阻塞(blocked):因为I/O请求而阻塞进程,而将处理器资源给其他的进程。
……可能还有初始(initial)状态(刚被创建)、最终(final)状态(退出但没被OS清理,也叫僵尸(zombie)状态)等。父(parent)进程检查进程的状态,如果进程返回的值正常,则说明子进程成功完成了任务。父进程应该等待(wait())子进程的完成,然后清理进程的相关数据结构。
OS持有的进程列表包含了所有进程的信息。每一项有时被叫做进程控制块(PCB,short for process control block),其实就是包含特定进程信息的结构。
仿真:IO阻塞的时候是否切换别的进程?别的进程切换之后,是否优先运行IO之前阻塞的进程?这些都会影响到最终耗费的CPU时间。
系统调用:fork()用来创建一个新的进程。尝试运行以下程序。
#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
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(short for process identifier)。然后调用fork()新建进程,奇怪的是,新建的进程几乎是被调用进程的确切(exact)拷贝。但是,是从fork这里开始,而不是从main开始的。
hello world (pid:14622)
hello, I am parent of 14623 (pid:14622)
hello, I am child (pid:14623)
但是,fork返回值在两种进程是不同的,新建(子)进程为0,而父为正,所以可以像上面的程序一样处理不同进程的逻辑。而且可能子进程比父进程先打印。执行的顺序不是预先决定好的(non-determinism),会带来一些有趣的问题,特别是在多线程(multi-threaded)的程序中。而想预先决定好,下面的wait()可以试试。
系统调用:wait()可以做一些等待的操作。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.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());
}
else
{ // parent goes down this path (main)
int rc_wait = wait(NULL);
printf("hello, I am parent of %d (rc_wait:%d) (pid:%d)\n",
rc, rc_wait, (int)getpid());
}
return 0;
}
依然是打印,但是父进程的先wait(),然后打印wait的返回值。
hello world (pid:15222)
hello, I am child (pid:15223)
hello, I am parent of 15223 (rc_wait:15223) (pid:15222)
如果子进程先运行,那child先打印;如果父进程先运行,会调用wait(),等待子进程结束后再运行。因此,总是子进程先打印。(有一些例子导致wait()比子进程早返回,需要使用man查看相关细节。)
最后是exec():在linux,有很多种变种execl(), execlp(), execle(), execv(), execvp(), and execvpe()。
#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 rc_wait = wait(NULL);
printf("hello, I am parent of %d (rc_wait:%d) (pid:%d)\n",
rc, rc_wait, (int)getpid());
}
return 0;
}
exec()并不产生新的进程,而是把代码和静态数据从新的执行中覆盖。程序内存空间的堆和栈重新初始化了。而且也一去不复返,不会继续后面的程序内容了("this shouldn’t print out"不会执行并打印了)。
为什么这个接口会这么奇怪?fork()和exec()的区分,可以帮助构建一个UNIX shell程序。
prompt> wc p3.c > newfile.txt
wc之后,重定向输出到newfile.txt。shell可以很轻易的做到:fork()进程之后,在执行exec()之前,将标准输出关闭,并打开文件newfile.txt,这样,执行exec()时候的输出就都导向文件了。
比如下面你的函数,就是这么干的:
#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
fprintf(stderr, "fork failed\n");
exit(1);
}
else if (rc == 0)
{
// child: redirect standard output to a file
close(STDOUT_FILENO);
open("./p4.output", 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"); // arg: file to count
myargs[2] = NULL; // mark end of array
execvp(myargs[0], myargs); // runs word count
}
else
{
// parent goes down this path (main)
int rc_wait = wait(NULL);
}
return 0;
}
当open()被调用,STDOUT FILENO是第一个可用的文件描述符。
UNIX的管道(pipes)也是类似的,但是使用的是pipe()系统调用。一个进程的输出连接到内核的管道,这部分可以无缝地作为另一个进程的输入。比如在shell中使用"|"来达到进程连接管道:
grep -o foo file | wc -l
除了这几个API,还有kill()系统调用(听说killall更容易用),是用来发送任意信号(signals)给进程的,进而改变进程的状态。而一些按键的组合比如ctrl-c发送了SIGINT(中断,interrupt)给当前运行的进程。ctrl-z发送SIGTSTP(停止,stop)信号,来暂停进程。后续可以通过“fg”或别的命令来恢复进程。一个进程需要使用signal()系统调用来捕捉多种信号,才能对特定的信号做出响应。
谁能发信号?现代的OS强调用户(user)的概念,当用户登录之后,OS把资源分不同的份给每个用户,每个用户可以控制自己的进程。
"ps"可以看当前的processes。
"top"可以看processes和对应使用的资源,比如CPU。
超级用户(superuser, root):一个系统需要有管理的人,不会受到大部分系统用户的的限制。可以控制其他用户的进程,也可以执行一些命令比如“shutdown”,但是为了安全性,尽可能在普通用户(regular user)下处理。