创建进程过程

  1. 申请空白PCB,获取唯一的数字标识符,并从PCB集合中索取一个空白的PCB

  2. 为新进程分配其运行所需的资源,包括各种物理或逻辑资源,如内存、文件、IO设备和CPU时间等。这些资源或从操作系统或从父进程获得。

  3. 初始化PCB

  • 初始化标识信息,将系统分配的标识符和父进程标识符填入新PCB中
  • 初始化处理机状态信息
  • 初始化处理机控制信息

如果进程就绪队列能够接纳新进程,便将新进程插入就绪队列。

fork-and-exec

使用fork创建一个新进程,该进程几乎是当前进程的完全拷贝; exec用来启动另外进程以取代当前运行的进程。

fork

  • fork的作用就是复制一个与当前进程一样的进程,新进程的所有数据,比如变量环境变量程序计数器等都和原进程一样,但是它是一个全新的进程,而且作为原进程的子进程。每个进程都启动一个从代码的同一位置开始执行的线程。这两个进程中的线程继续执行,就像是两个用户同时启动了该应用程序的两个副本,fork之后无法确定是子进程先运行还是父进程先运行,这依赖于系统的实现

  • fork 函数有两次返回值,子进程中返回0,父进程中返回子进程的pid,出现错误返回-1

  • 在linux/unix中,fork()产生的子进程相当于复制了整个父进程,首先复制了PCB,然后将内存页表共享到父进程的页面(写时复制)。通俗一点,子进程和父进程看起来是完全一样的,一样的代码段,一样的数据段,一样的进程控制块,但是他们是独立的,并且从内核返回到用户态时,系统调用对原进程返回子进程的pid,对子进程返回0,这样就可以区分父子进程了

  • fork出错可能有两种原因:
    1)当前的进程数已经达到了系统规定的上限,这时errno的值被设置为EAGAIN。
    2)系统内存不足,这时errno的值被设置为ENOMEM。

  • 子进程会进程父进程的哪些属性

    • 文件描述符表
    • 实际用户ID,实际组ID,有效用户id,有效组ID
    • 附属组ID
    • 进程组ID
    • 会话ID
    • 控制终端
    • 设置用户ID标志和设置组ID标志
    • 当前工作目录
    • 根目录
    • 文件模式创建屏蔽字
    • 信号屏蔽和安排
    • 对任一打开文件描述符的执行时关闭(close-on-exec)标志
    • 环境变量
    • 连接的共享存储段
    • 存储镜像
    • 资源限制
  • 子进程与父进程不同的属性

    • fork的返回值不同
    • 进程ID不同
    • 两个进程的PPID不同
    • 子进程的tms_utime, tms_stime, tms_cutime和tms_ustime 的值设置为0
    • 子进程不继承父进程设置的文件锁
    • 子进程的未处理闹钟被清除
    • 子进程的未处理信号集设置为空集

注意:在fork多线程的进程时,创建的子进程只包含一个线程,该线程是调用fork函数的那个线程的副本

fork 中的COW(写时复制)

写时拷贝是一种可以推迟甚至避免拷贝数据的技术

为什么要引入COW: 当发出fork()系统调用时,内核原样复制父进程的整个地址空间并把复制的那一份给子进程,这种行为非常耗时,然后大多数情况下,fork后跟随exec,通过装入一个新的程序开始它的执行,又完全丢弃了所继承的地址空间,为了提高效率,引入COW

COW基本思想: 父子进程共享页面而不是复制页面,然而,只要页面被共享,他们就不能修改。无论父进程还是子进程何时试图修改一个共享的页面,就产生一个错误,这是内核就复制到一个新的页面中并标记为可写,原来的页面仍然是写保护;当其他进程试图写入时,内核检查写进程是否是这个页的唯一属主,如果是,就把这个页面标记为对这个进程是可写的

由于fork后经常跟随exec,所以现在很多实现并不执行一个父进程数据段、栈和堆的完全副本,作为替代,使用了写时复制(Copy-On-Write, COW)技术。这些区域由父进程和子进程共享,而且内核将他们的访问权限改变为只读。如果父进程和子进程中的任何一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储系统中的“一页”。

fork 实例

实例1:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int globval = 6;
char buf[] = "a write to stdout\n";


int main(int argc, char * argv[]) {

    int var;
    pid_t pid;

    var = 88;
    if (write(STDOUT_FILENO, buf, sizeof(buf)-1) != sizeof(buf)-1) {
        printf("write error\n");
        exit(-1);
    }

    printf("before_fork\n");
    if((pid = fork()) < 0) {
        perror("fork");
        exit(-1);
    } else if(pid ==0) {
        globval++;
        var++;
    } else {
        sleep(2);
    }

    printf("pid=%d, glob=%d, var=%d\n", getpid(), globval,var);
    return 0;
}

运行结果:

[root@iz2zecj7a5r32f2axsctb9z process]# ./fork2
a write to stdout
before_fork
pid=21031, glob=7, var=89
pid=21030, glob=6, var=88

如果运行: ./fork2 > temp.out, 则输出结果为:

a write to stdout
before_fork
pid=21031, glob=7, var=89
before_fork
pid=21030, glob=6, var=88

需要注意:write函数是不带缓冲区的,而标准I/O是带缓冲的,如果标准输出到终端设备,则它为行缓冲,通过\n来刷新缓冲区;否则就是全缓冲。

实例2: 使用for循环创建子进程

#include <stdio.h>
#include <errno.h>
#include <unistd.h>

/*
* 子进程的个数
*/
int main(int argc, char *argv[]) {

    int i = 0;
    pid_t pid;
    for (i=0; i < 3; i++) {
        pid = fork();
    }
    if(pid ==0) {
        printf("child process, pid:%d, ppid:%d\n",getpid(), getppid());
    } else {
        printf("parent process, pid:%d, ppid:%d\n", getpid(), getppid());
    }
    sleep(1);
    return 0;
}

运行结果:

[root@iz2zecj7a5r32f2axsctb9z fork]# ./fork3
parent process, pid:24575, ppid:9399
parent process, pid:24577, ppid:24575
child process, pid:24578, ppid:24575
parent process, pid:24576, ppid:24575
parent process, pid:24580, ppid:24576
child process, pid:24581, ppid:24576
child process, pid:24582, ppid:24580
child process, pid:24579, ppid:24577
`

实例中一共创建了7个子进程,创建过程如图所示:
子进程创建过程
可以得出循环n次,复制得到的子进程数量为2^n - 1

实例3: 通过for循环创建指定数量的子进程

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char *argv[]) {

    int i=0;
    pid_t pid;
    for(i=0; i<3; i++) {
        pid = fork();
        if(pid < 0) {
            perror("fork");
            break;
        }
        if(pid == 0) break;
    }
    printf("pid=%d,ppid=%d\n",getpid(),getppid());
    if(pid > 0){
        for(i=0;i<3;i++){
            wait(NULL);
        }
    }
    return 0;
}

执行结果:

[root@iz2zecj7a5r32f2axsctb9z fork]# ./fork4
pid=30784,ppid=9399
pid=30785,ppid=30784
pid=30786,ppid=30784
pid=30787,ppid=30784

结论: 若使用for循环创建指定数量的子进程,可以在fork后子进程中将for循环break即可

实例3: 一共输出多少个”=”

#include <stdio.h>
#include <unistd.h>

int main(){
    for(int i = 0;i<2;++i){
        fork();
        printf("=");
    }
    return 0;
}

运行结果:

========

分析: 由于第一次创建的时候 ‘ = ’在缓冲区,所以在拷贝地址空间的时候也把缓冲区的内容复制给了第二个进程,接下来两个进程又分别创建一个进程,创建的进程都带有 ‘=’ ,然后四个进程分别往缓冲区里输入 ‘=’,最后刷新缓冲区;特别注意的就是:缓冲区也是内存,在进程创建的时候也会被拷贝到另一个进程中

实例4: 输出多少个”+”

#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[]) {

    fork();
    fork() && fork() || fork();
    fork();
    printf("+");
    return 0;
}

结果为20个,主要是 fork() && fork() || fork()的执行:
fork()&&fork()||fork()

可知fork()&&fork()||fork()会变成5个进程,加上前面的fork 和 后面的fork, 可以得出 4 * 5 = 20 个进程,每个进程输出一个+,所以结果为20

vfork

vfork也用于创建一个新的进程,与fork的区别有:

  • 父子进程执行顺序

    fork(): 父子进程的执行次序不确定。
    vfork():保证子进程先运行,在它调用 exec(进程替换) 或 exit(退出进程)之后父进程才可能被调度运行。否则子进程将依赖于父进程的运行,而父进程也依赖于子进程运行,就这样一直僵持下去形成了死锁

  • 进程空间

    fork(): 子进程拷贝父进程的地址空间,子进程是父进程的一个复制品。子进程拷贝父进程的数据段、堆栈段。虚拟内存拷贝,共享物理内存, 采用写时复制。
    vfork():子进程共享父进程的地址空间(准确来说,在调用 exec(进程替换) 或 exit(退出进程) 之前与父进程数据是共享的), 虚拟内存共享, 子进程共享父进程的数据段,堆栈段,代码段。

实例1: 查看执行顺序和值的修改

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(int argc, char * argv[]) {
    int i = 100;
    pid_t pid = vfork();
    if(pid < 0) {
        perror("vfork");
        return -1;
    }else if(pid == 0) {
        i++;
        printf("子进程中的i=%d\n",i);
        sleep(5);
        exit(0);
    }else {
        printf("父进程中i=%d\n",i);
    }
    return 0;
}

运行结果:

[root@iz2zecj7a5r32f2axsctb9z fork]# ./vfork2
子进程中的i=101
父进程中i=101

结论: 先执行子进程,后执行父进程; 子进程中修改i的值也改变了父进程中变量值,因为子进程在父进程的地址空中运行。

实例2:

#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h>

int main(int argc, char * argv[]) {
    int count = 0;
    pid_t pid=vfork();
    int i =0 ;
    if(pid < 0) {
        perror("fork");
        return -1;
    }else if(pid == 0) {
        printf("child process, pid=%d, ppid=%d\n", getpid(),getppid());
        for(; i< 5; ++i) {
            printf("count = %d ", ++count);
            printf("I am a child pid: %d, return: %d\n", getpid(),pid);
        }
        exit(0);
    }else {
        printf("parent process, pid=%d, ppid=%d\n", getpid(),getppid());
        printf("i= %d\n", i);
        for(; i< 5; ++i) {
            printf("count = %d ", ++count);
            printf("I am a parent pid: %d, return: %d\n", getpid(),pid);
        }
    }
    return 0;
}

运行结果:

[root@iz2zecj7a5r32f2axsctb9z fork]# ./vfork
child process, pid=4891, ppid=4890
count = 1 I am a child pid: 4891, return: 0
count = 2 I am a child pid: 4891, return: 0
count = 3 I am a child pid: 4891, return: 0
count = 4 I am a child pid: 4891, return: 0
count = 5 I am a child pid: 4891, return: 0
parent process, pid=4890, ppid=9399
i= 0
count = 6 I am a parent pid: 4890, return: 4891
count = 7 I am a parent pid: 4890, return: 4891
count = 8 I am a parent pid: 4890, return: 4891
count = 9 I am a parent pid: 4890, return: 4891
count = 10 I am a parent pid: 4890, return: 4891

实例3: 在子进程中不调用exec或者exit会发生什么情况

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
    pid_t pid = vfork();

    if (pid < 0) {
        perror("vfork");
        return -1;
    } else if(pid == 0) {
        printf("child process, pid=%d, ppid=%d\n", getpid(), getppid());
        sleep(2);
    } else {
        printf("parent process, pid=%d, ppid=%d\n", getpid(), getppid());
    }
    return 0;
}

运行结果:

[root@iz2zecj7a5r32f2axsctb9z fork]# ./vfork3
child process, pid=23209, ppid=23208
parent process, pid=23208, ppid=9399
child process, pid=23212, ppid=23208
段错误

结论: 如果子进程中修改数据(除了用于存放vfork返回值的变量)、进行函数调用、或者没有调用exec或exit就返回都可能会带来未知的结果。

clone

clone也用于创建一个进程,可以指定父进程与子进程共享哪些资源

关于fork/vfork时的文件共享

文件共享

  • fork的一个特性是父进程的所有打开的文件描述符都被复制到子进程中,父子进程的每个相同的打开描述符共享一个文件表项

  • 如果父进程和子进程写同一个描述符指向的文件,但又没有任何形式的同步(如父进程等待子进程),那么他们的输出就会相互混合。

  • 在fork之后处理文件描述符有两种常见的情况:

    • 父进程等待子进程完成。在这种情况下,父进程无需对其描述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享描述符的文件偏移量已执行了相应的更新。
    • 父、子进程各自执行不同的程序段。在这种情况下,在fork之后,父、子进程各自关闭它们不需要使用的文件描述符,这样就不会干扰对方使用的文件描述符。这种方法是网络服务进程中经常使用的。

exec

exec函数使用新进程的镜像替换当前进程。

exec函数执行另一个程序,当进程调用一种exec函数时,该进程执行的程序完全替换成新程序,而新程序从其main函数开始执行。 因为调用exec并不创建新进程,所以前后的进程ID并未改变。 exec只是用磁盘上的一个新程序替换了当前进程正文段、数据段、堆段和栈段。将当前进程的.text、.data替换为所要加载的程序的.text、.data,然后让进程从新的.text第一条指令开始执行,但进程ID不变,换核不换壳

#include <unistd.h>
int execl(const char *pathname, const char *arg0, ..., (char *)0);
int execv(const char *pathname, char *const argv[]);
int execle(const char *pathname, const char *arg0,..., (char *)0, char *const envp[]);
int execve(const char *pathname, char *const argv[], char *const envp[]);
int execlp(const char *filename, const char *arg0, ... , (char *)0);
int execvp(const char *filename, char *const argv[]);
int fexecve(int fd, char *const argv[], char *const envp[]);

命名规律:

  • l 表示命令行参数列表
  • v 表示命名行参数数组
  • p 表示PATH环境变量
  • e 表示自定义环境变量表

其中只有execve是内核的系统调用,其他的是库函数,它们最终都要调用该系统调用。
exec函数族

执行exec后,进程ID没有变,但新程序从调用进程继承以下属性:

  • 进程ID和父进程ID
  • 实际用户ID和实际组ID
  • 附属组ID
  • 进程组ID
  • 会话ID
  • 控制终端
  • 闹钟尚余留的时间
  • 当前工作目录/根目录
  • 文件模式创建屏蔽字
  • 文件锁
  • 进程信号屏蔽
  • 未处理信号
  • 资源限制
  • nice值
  • tms_utime, tms_stime, tms_cutime, tms_cstime

system

文档更新时间: 2021-03-08 13:23   作者:周国强