进程


当我们在一个现代系统上运行一个程序时,会得到一个假象,就好像我们的程序是系统中当前运行着的唯一的程序。我们的程序是独占地使用处理器和存储器。处理器就好像是无间断地一条接着一条地执行程序中的指令。最后,我们程序中的代码和数据好像是系统存储器中唯一的对象。这些假象都是通过进程的概念提供给我们的。

进程提供给应用程序两个关键的抽象:

  • 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
  • 一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用存储器系统。


一:私有地址空间

一个进程为每个程序提供它自己的私有地址空间。一般情况下,进程是无法访问到其他进程的地址空间的。 下面是一个进程地址空间的分布图:

我们看到:每个进程都有自己的 运行时堆, 代码和数据段(.data, .txt..)


二:独立的控制流:

操作系统使用一种称为上下文切换(context switch)的技术来实现多任务。 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。这种决定叫做调度(schedule),是由内核中称为调度器的代码处理的。内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。


三:进程控制:

1.创建和终止进程:

一个现有进程可以调用 fork 函数创建一个新进程: 函数原型如下:

    #include<unisted.h>
    pid_t fork(void);

这时,新创建的进程为子进程,创建它的进程为父进程。父子进程几乎但不完全相同。子进程会得到与父进程地址空间内容相同的一份(独立的)拷贝。

看如下C代码:

#include<unisted.h>
#include<sys/types.h>

int main(){
    pid_t pid;
    int x = 1;
    
    pid = fork();
    if(pid == 0){ /*child*/
        printf("child: x = %d\n", ++x);
        exit(0);
    }
    
    printf("parent: x = %d\n", --x);
    exit(0);
}

首先,需要解释的是 fork 函数的返回值,一个 pid_t 的类型值。实际上,每个进程都有一个唯一的正数(非零) 进程ID (PID),用来唯一得标识一个进程。其实,pid_t 就是一个 int 类型。 父进程与新创建的子进程之间最大的区别在于它们有不同的PID。

我们再来看看 fork 函数,它是很有趣的,因为它 只调用一次,却会返回两次。一次是在调用进程(父进程)中,一次是在新创建的子进程中,并且两个的返回值是不同的,在父进程中,fork 函数返回新创建的子进程的 pid,在子进程中,fork 函数返回 0

一定要清楚:fork 函数一经返回,你的程序就有了两个并发的独立的执行流,它们(父进程和子进程)都会交错的继续执行fork调用之后的指令。

当在Linux系统上运行这个程序时,我们得到如下的结果:

unix> ./fork
parent: x=0
child: x=2

程序中第8行的 if 语句是用来分辨父子进程的。因为在子进程中得到的 pid 为零,而父进程中总是得到非零的子进程的 pid。因此,if 语句只有子进程能进入执行。if 语句块中最后调用 exit 函数来终止进程。也就是说,子进程进入 if 语句块,执行了 printf 语句,最后遇到 exit 是得子进程终止。而父进程没有进入if语句块,执行了自己的 printf 语句,并由于程序代码全部执行完毕而终止。

从输出的 x 的值验证了上面的一个很重要的点:相同的但是独立的地址空间fork 函数使得创建的子进程得到与父进程有相同数据和程序代码的一份拷贝。在这里,当 fork 函数返回时,父子进程都有了各自的一份 x = 1 的本地变量,存放在各自进程的栈中。所以,父子进程对 x 所作的任何改变都是独立的。


二:加载并运行程序

大多时候,我们用 fork 函数创建子进程后,会让子进程调用一种 exec 函数以执行另一个程序。当进程调用一种 exec 函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。

exec 函数有6中,下面只介绍一种最基本的 execve

#include<unisted.h>

int execve(const char *filename, const char *argv[],    
            const char *envp[])

execve 函数加载并运行可执行目标文件filename,且带参数列表 argv 和 环境变量列表 envp。参数列表和环境表量列表都是一个以null结尾的指针数组,其中每个指针指向一个参数串。

下面展示一个例子,exec-demo.c 程序创建一个子进程,并让这个子进程通过调用 execve 去执行 echoall.c,它会打印出传给它的参数和环境变量。

//exec-demo.c
#include<unisted.h>
#include<stdlib.h>

int main(){
    pid_t pid;
    extern char *envrion[]; //引用全局环境变量
    char *argv[] = {"arg1", "arg2", NULL}; //创建参数列表
    
    //为envrion添加一条环境变量
    setenv("name", "cyanlong", 1);
    
    if((pid = fokr()) == 0)  /*child*/
        execve("echoall", argv, environ); //加载并运行新程序
    exit(0);
}

在这里,我们先来介绍一下 environ。lib库中定义的全局变量environ指向全局变量表。并且,Unix提供了几个函数来操作环境变量数组:

#include<stdlib.h>

char *getenv(const char *name);
int setenv(const char *name, const *newvalue, int overwrite);  

上面,我们通过setenv函数给全局变量 envrion 添加了一个环境变量,并将 argvevrion 通过调用 execve 传给了目标可执行文件 echoall

//echoall.c
int main(int argc, char **argv){
    extern char * envrion[];
    char **ptr;
    int i;
    
    //打印所有的参数
    for(i = 0; i < argc; i++)
        printf("argv[%d]: %s\n", i, argv[i]);
    
    //通过getenv查找环境变量值
    char *name = getenv("name");
    printf("name:%s\n", name);
}

当我们在linux中运行 exec-demo.c 时:

unix> ./exec-demo
argv[0]: arg1
argv[1]: argv2
name:cyanlong

最后:execve 函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新进程。


总结:

本文首先提出了进程的两个重要的抽象:独立的逻辑控制流和独立的地址空间。然后介绍了几个基本的控制进程的系统调用。 fork 函数用来创建一个新进程, exit 函数退出进程, exec 函数用一个全新的函数替换当前进程的代码,数据,堆和栈段。