Skip to content
On this page

进程

抽象:进程

进程的定义非常简单:运行中的程序。它们本身没有生命周期,只是存在于磁盘上的一些指令(有些是静态数据),是操作系统让这些字节运行起来,让程序发挥作用。

事实表明,人们常常希望能同时运行多个程序,一个正常的操作系统可能会有上百个进程同时运行(比如此时我的 Windows PC 运行了 184 个进程),因此我们有一个挑战:如何创造一个拥有很多 CPU 的假象?

操作系统通过虚拟化创造这种假象,通过让一个进程只运行一个时间片,然后切换到其它线程,这就是时分共享(time sharing) CPU 技术,允许用户运行多个并发进程,潜在的开销就是每个程序的运行都慢一点。

要实现 CPU 的虚拟化,操作系统就需要一些低级机制以及一些高级智能。我们将低级机制称为机制,机制是一些低级方法和协议,实现了所需功能。在这些机制之上,有一些智能以策略的形式存在。策略是操作系统内做出某种决定的算法,比如给定一组可能的程序在计算机运行,操作系统要执行哪个程序,操作系统中的调度策略会做出决定,可能利用历史信息,如工作负载或是性能指标。

抽象:进程概念

一个进程只是一个正在运行的程序,我们可以清点它在执行过程中访问或影响的系统的不同部分,从而概括一个进程。

为了理解进程是什么,我们必须理解它的机器状态:程序在运行时可以读取或更新的内容,在任何时刻,机器的哪些部分对执行该程序很重要。

进程的机器状态有一个明显的组成部分,就是内存,指令存在于内存中,程序读取和写入的数据也在内存中,因此进程可以访问的内存是该进程的一部分。机器状态的另一部分是寄存器(中央处理器内用来暂存指令、数据和地址的电脑存储器),许多指令明确地读取或更新寄存器,因此寄存器对于执行进程很重要。

进程 API

所有操作系统都以某些形式提供这些 API:

  1. 创建:操作系统必须包含创建新进程的方法,在 shell 键入命令和点击程序图标的时候都会调用 API 创建新进程
  2. 销毁:由于存在创建进程的接口,所以也存在强制销毁进程的接口,很多进程会在运行完成后自行退出,但是有些不退出(或者程序失控),这时候这个接口非常有用
  3. 等待:有时等待进程停止运行是有用的,所以经常提供某种等待接口
  4. 其他控制:除了杀死或等待,有时候有其他控制,比如暂停和恢复
  5. 状态:有些接口可以获得有关进程的状态信息,例如运行多久或是什么状态

进程创建:更多细节

这里我们会解开一个谜:程序如何变成进程?

操作系统运行程序的第一件事是将代码和所有静态数据(例如初始化变量)加载到内存里,加载到进程的地址空间中,在早期系统中,加载过程尽早完成,在运行程序之前全部完成,现代操作系统惰性(lazily)执行该过程,即仅在程序执行期间需要加载的代码或数据片段才会被加载。

代码和静态数据加载到内存后,操作系统在运行进程之前还需要执行其他操作。必须为程序的运行时栈分配一些内存。比如C程序使用栈存放局部变量、函数参数、返回地址。操作系统分配这些内存,并提供给进程。操作系统也可能用参数初始化栈。

操作系统也可能为程序的堆分配一些内存,在C程序中,堆用显式的 malloc() 来请求这样的空间,并用 free() 显式的释放,起初堆会很小,随着程序运行堆会越来越大,操作系统也会分配更多内存给进程,以满足这些调用。

操作系统还将执行一些其他初始化任务,特别是与输入/输出相关的任务。

进程状态

进程在给定时间可能处在不同的状态,简而言之,进程可以处于下面3种状态之一

  1. 运行:在运行状态下,进程正在处理器上运行,这意味着它在执行指令
  2. 就绪:在就绪状态下,进程已经准备好运行,但是由于某些原因,操作系统选择不在此时运行
  3. 阻塞:在阻塞状态下,一个进程执行了某种操作,知道发生了其他时间时才会准备运行,比如进程向磁盘发起 I/O 请求时,它会被阻塞,因此其他进程可以使用处理器

稍微深入进程 API

下面的示例在 Windows 上不可用,因为这是 UNIX 特供的方法。

fork()

cpp
#include <studio.h>
#include <stulib.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    printf("hello world (pid:%d)\n", (int) getpid());
    int rc = fork();
    if (rc < 0) {
        fprintf(stderr, "fork failed\n");
        exit(1)
    } else if(rc == 0) {
        printf("hello, I'm child (pid:%d)\n", (int) getpid());
    } else {
        printf("hello, I'm parent of %d (pid:%d)", rc, (int) getpid());
    }
    return 0;
}
shell
$ ./main
hello world (pid:29146)
hello, I'm parent of 29147 (pid:29146)
hello, I'm child (pid:29147)

进程调用 fork() 系统调用时,操作系统复制了一个跟这个进程完全一样的新进程,对于操作系统来说,这时两个完全一样的进程都从 fork() 系统调用中返回。当然子线程并没有完全拷贝父进程,子进程有独立的地址空间、寄存器、程序计数器。同时,两个进程从 fork() 获得的返回值是不同的,父进程获得了子进程的 PID,子进程获得 0,如果子进程并没有成功创建,那么父进程获得的返回会是负数。

注意,子进程和父进程哪个先输出自己的信息并不确定,要看操作系统的调度。

wait()

cpp
#include <studio.h>
#include <stulib.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    printf("hello world (pid:%d)\n", (int) getpid());
    int rc = fork();
    if (rc < 0) {
        fprintf(stderr, "fork failed\n");
        exit(1)
    } else if(rc == 0) {
        printf("hello, I'm child (pid:%d)\n", (int) getpid());
    } else {
        wait(NULL)
        printf("hello, I'm parent of %d (pid:%d)", rc, (int) getpid());
    }
    return 0;
}

注意,我在父进程那里加了个 wait(),这个方法会暂停程序,直到子进程完成了执行,所以这次的代码执行下来肯定是 child 输出在先:

shell
$ ./main
hello world (pid:29146)
hello, I'm child (pid:29147)
hello, I'm parent of 29147 (pid:29146)

exec()

cpp
#include <studio.h>
#include <stulib.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    printf("hello world (pid:%d)\n", (int) getpid());
    int rc = fork();
    if (rc < 0) {
        fprintf(stderr, "fork failed\n");
        exit(1)
    } else if(rc == 0) {
        printf("hello, I'm child (pid:%d)\n", (int) getpid());
        char *myargs[2];
        myargs[0] = strdup("wc");
        myargs[1] = strdup("p3.c");
        myargs[2] = NULL;
        execvp(myargs[0], myargs)
    } else {
        wait(NULL);
        printf("hello, I'm parent of %d (pid:%d)", rc, (int) getpid());
    }
    return 0;
}

exec() 接收可执行程序的名称及参数,并从可执行程序中加载代码和静态数据,并重写自己的代码段

Released under the MIT License.