Long

欢迎来到Long的博客站点

初探进程

0. 前言

本文只是对进程的一个初步介绍,后续将逐步讨论线程,进程调度算法等方面。

1. 概念

对于进程,相信很多人都非常清楚,那就是一个正在运行着的程序。这里要清楚的是,如果是单处理器,一个时刻只能运行一个程序,那么是否此刻只有一个进程呢,不是的,这里概念中运行着的程序,指的是在计算机中所有可运行的程序,只要一个程序被调入内存中,那么该程序就是一个进程。这样来理解进程有点抽象,那么具体点就是一个进程包括程序计数器、寄存器和变量的当前值。如果我们只需要运行一个程序,那么完全不需要进程这个东西,直接执行该程序即可,甚至连操作系统都可以不要,进程的出现就是为了在一个时间段可以运行多个程序,在宏观上看上去,多个程序处于并发的执行。更具《现代操作系统》,在概念上来说,每个进程拥有自己的虚拟cpu,但是实际上都知道这些进程复用一个cpu。

2. 进程的创建

说明:本文中所讨论的情况基本上都是linux(类unix)下的情形

了解了什么是进程,那么我们如何创建一个进程,很简单,就是使用fork系统调用。但是知道可以有4种方式会创建进程还是必要的:

  • 系统初始化
  • 正在运行的程序调用了fork系统调用
  • 用户启动一个程序
  • 批处理作业的初始化

在系统启动时,通常会启动一个init进程,这个进程是当前系统中所有进程的父进程,接着该进程会创建一系列的守护进程(daemon),这时候也创建了一些进程。

但是,如果我们向在后面创建一个进程怎么办,很简单,也是可以的,那就是我们在自己的程序中通过fork系统调用来创建一个子进程,创建该子进程有什么好处呢,有的。

比如我们需要从网卡的缓冲区中读取大量的数据,然后处理这些数据,此时我们可以创建一个子进程,让该子进程去读取数据,然后把该数据放入一个共享区域(消息队列、共享内存)中,父进程从共享区域中读取数据,然后进行处理。在父进程进行数据的处理过程中,子进程仍然可以去网卡缓冲区中读取数据,也就是说,二者可以看成是并行执行的。我们具体分析一个在单处理器中,上述所说的情况的具体运行过程,在单处理器中,每个时刻只能有一个进程占用cpu,也就是每个时刻只有一个进程处于运行态。父进程创建子进程,两者就是独立的进程,除了子进程可以继承父进程的资源,其余的都不行。我们假设子进程child用于从网络中读取数据,父进程parent用于处理数据。此时调度程序选择child来执行,child尝试从网卡缓冲区中读取数据,但是,此时缓冲区中没有数据,child直接阻塞,调度程序挂起child,选择parent来执行,但是因为child没有读取到数据,所以也没办法运行,此时cpu空转,知道时间片用完,如果此时网卡缓存区中仍然没有数据,那么cpu继续空转,直到网卡缓冲区中有数据到达,此时网卡控制器发送一个中断信号给cpu,cpu响应该中断,从中断向量表中找到该中断信号控制程序的入口地址,陷入到内核中执行中断控制程序,当执行完该中断控制程序后,此时网卡缓冲区中有数据,child转为就绪态,调度程序可以选择child执行,也可以选择parent执行,假设此时调度程序选择child执行,child把网卡中的数据放入共享区域中,继续等待网络中的数据到来,进入阻塞态。调度parent执行,parent从共享缓冲区中读取数据进行处理,直到时间片用完,调度其他程序执行,child和parent在这种情形下循环执行。这样显然cpu的利用率提高了非常多。

创建进程的方式还有就是用户直接启动一个程序,在linux中,通过shell来输入命令启动一个进程而,但是shell也是通过fork来创建一个子进程,然后通过execv系统调用将子进程的内存映像换成需要启动的程序,为什么需要两步来启动一个程序呢,其实很好理解,子进程和父进程拥有相同的内存映像(但是是独立的),此时在创建子进程之前,父进程可能打开了某些文件,使用了某些资源,同时,子进程继承了父进程的这些资源,但是这些资源对于新启动的子进程来说可能不需要,那么在execv之前,fork之后,可以选择处理原来可能的文件描述符,或者对stdin,stdout,stderr进行重定向。、

其实从上面的情形来看,在unix或者类unix中只有一个系统调用可以用来创建进程,那就是fork,以上的情形其实都是通过fork来创建进程,没有本质上的区别,只是过程有些不一样而已。

刚刚提到了,父进程创建子进程,二者是相同的内存映像,但是独立的,也就是二者具有不同的地址空间。在unix中,子进程的初始地址空间是父进程的一个副本,但是二者也共享了一些内存空间,比如不可写的区域,代码区。在linux中不一样,子进程共享父进程所有的内存区,但是,是采取写时复制的机制的,如果一个进程需要对内存区中的内容进行修改,那么就复制该区域,以确保被修改的内存发生在私有的区域中。

来段代码来理解一下父、子进程地址空间的问题

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

int main(void)
{
    int a = 1;

    if (!fork())            //子进程
    {
        printf("I am child, a = %d, &a = %p.\n", a, &a);
        a++;
        printf("I am child, a = %d, &a = %p.\n", a, &a);
    }
    else                //父进程
    {
        sleep(1);
        printf("I am parent, a = %d, &a = %p.\n", a, &a);
    }
    wait(NULL);

    return 0;
}

第一次运行:

I am child, a = 1, &a = 0x7ffca80ee3e4.
I am child, a = 2, &a = 0x7ffca80ee3e4.
I am parent, a = 1, &a = 0x7ffca80ee3e4.

第二次运行:

I am child, a = 1, &a = 0x7ffd43a793f4.
I am child, a = 2, &a = 0x7ffd43a793f4.
I am parent, a = 1, &a = 0x7ffd43a793f4.

第三次运行:

I am child, a = 1, &a = 0x7ffca66b7864.
I am child, a = 2, &a = 0x7ffca66b7864.
I am parent, a = 1, &a = 0x7ffca66b7864.

从这里可以看出来,子进程中a的地址和父进程中的地址是相同的,也就是说子进程要么共享父进程的内存,要么复制了父进程内存,拥有父进程内存的副本。接着看,我们说过,在linux中采用写时复制机制为子进程分配内存空间,子进程中还没有对a进行修改的时候,二者在物理内存上面指的是同一个物理内存,当子进程中对a修改后,操作系统为子进程分配一个物理页(一般大小为4kB)。但是我们在程序的运行结果中可以看出来,父子进程中a的地址是相同的,但要知道,这里显示的是虚拟地址,当然相同了,前面又说会采用写时复制机制,进程中的a修改了,我们会想当然的认为a应该分配到不同的地址空间中去,这里的写时复制是子进程中a的虚拟地址通过页表的映射到不同的物理页中,完成所谓的写时复制操作,这样当在子进程中修改a的值,打印的又是虚拟地址,当然没有改变的,实际上在物理页上已经是不同的。关于内存管理这一块,后面我会写几篇文章,其实就是根据《现代操作系统》所做的读书笔记。

3. 进程的终止

说完了进程的创建,接下来就是进程的终止,当我们写一个c/c++程序时,有时会发生运行时错误,出现Segmentation fault (core dumped),出现段错误,这就是因为访问了不该访问的内存所造成的进程终止,这种错误是进程自身造成的。一般进程终止的方式有一下几种:

  • 正常退出(自愿终止)、
  • 错误退出(自愿终止)
  • 严重错误(非自愿)
  • 被其他进程杀死(非自愿)

正常退出没什么好说的,就是一个进程执行完毕了,通过exit系统调用退出;错误退出就比如刚刚的发生段错误、除数为0、执行一条非法指令等。这类错误发生时,进程会收到一个信号,如果进程没有对该信号进行处理,那么进程就直接终止,同时,进程也可以自行接收这些信号,然后自行处理接收这些信号后的行为。

严重错误就是进程由于缺少某些资源完全执行不下去,这里的资源是在执行后不可能获得的,和因为缺少某些资源进入阻塞态的进程不一样,进入阻塞态的进程最后是可以获得资源的,只是时间的问题。

被其他进程杀死,这里其实就是进程发送一个信号给其他进程,把其他进程杀死,但是接收到信号的进程也可以预先设置接受到信号后进行上面行为,默认是进程直接终止,这和第二种情况是一样的;这里有一点需要注意的就是,是否允许一个用户进程终止其他用户的进程,很显然,这是不允许的。这里就涉及到每个进程拥有用户id,用户组id,每个进程都有这些属性,进程的用户id就是启动该进程的用户所具有的id,进程用户组id就是该用户所属组的id,每个用户都有一个属组。

4. 进程的层次结构

在上面讲到了进程的创建,我们现在来了解一下linux系统的大致启动过程,这里省略BIOS和bootloader等启动过程。开始时,一个init进程特殊进程出现在启动映像中。init进程开始运行,读入一个说明终端数量的文件。接着,为每个终端启动一个新进程。这些终端等待用户的登录,一旦某个用户登录成功后,就启动一个shell进程,shell进程等待用户启动程序,这样用户的程序由shell进程通过fork和execv来创建,一个接着一个下去,这样就形成了进程树,从这里可以知道,每个进程都有一个父进程,一个进程可以有零到多个子进程。

5. 进程状态

前面多次提到了进程状态问题件,这里统一说一下进程状态,我们知道进程就是一个执行的程序,为了更好的描述进程的过程,就产生了进程的状态。总共有三种典型状态。

下面来依次说明各状态之间的转换,从就绪态转为运行态,此时的进程唯独缺少cpu,此时调度程序把进程调度到cpu中运行,进程转为运行态;从运行态转为就绪态也是由于调度程序引起的,此时调度程序认为该进程运行了足够时间了,该进程完全感觉不到调度程序的存在。也就是说调度程序就是决定下一个时刻运行哪个进程,运行多长时间,这就是调度算法要做的事情了,后面的文章将会具体讲讲调度算法。

从运行态到阻塞态,比如一个进程调用了read系统调用,但此时缓冲区中没有数据,此时操作系统把该进程挂起。

一个进程能不能从阻塞态直接到运行态,这是可以的,比如此时内存中只有一个进程,当该进程需要的数据准备好了之后,就直接运行了;但是我们更细致的也可以认为该进程先进入就绪态,再进入运行态,也是可以的。或者内存中有多个进程,但是之前阻塞过的进程需要的数据准备好了,优先级更高,那么调度程序可能就直接调度该进程了。我们一般认为一个进程除了没有cpu,其他什么都有,就是在就绪态,所以从更细致的角度看待进程,仍然可以认为每个进程都可能经历这三种状态。

6. 进程的实现

操作系统管理一个进程都是通过维护一张表格来实现的,这张表就是进程表,每个进程占用一个进程表项(进程控制块),每个表项都保存了进程状态的关键信息。如表所示:

《初探进程》

这里再详细讲述一下cpu维持多个进程的执行的过程。

比如此时有两个进程,在当前的时刻,A进程执行,B进程处于就绪态。
此时A进程发生阻塞(调用了read或者其他阻塞式的系统调用),A进程转换到阻塞态,A进程主动挂起;调度程序从就绪进程队列中选择B进程(此时就绪队列中只有B进程)执行,B进程进入到运行态;在B进程时间片用完之前,发生了中断,比如A进程需要的数据已经到达,此时系统中断B进程的执行,中断硬件把进程B的程序计数器、程序状态字、一个或者多个寄存器压入栈中,pc指针然后跳转到中断向量(中断触发)所指示的地址处(中断服务程序)开始执行,这些操作都是有硬件来完成的,因为这些都是由硬件来完成,但是我们需要把进程B的相关信息保存到进程B的进程表项中,所以接下来需要一段汇编代码把这些信息保存到进程表项中,然后把刚刚保存到栈中的数据清空。然后cpu从中断服务程序处开始执行,等中断服务程序运行完毕之后。此时A需要数据已经准备好了,此时A进程进入就绪态,B进程也在就绪队列中,此时调度程序选择一个进程执行。假如此时选择B进程执行,那么不是直接让B进程执行,而是需要恢复现场,也就是将B进程装入之前保存的寄存器值,栈指针、内存映射,这时才启动该进程执行,以上的操作都是有一段汇编代码来完成的。

点赞
  1. 匿名说道:

    🤗🤗🤗🤗

发表评论

电子邮件地址不会被公开。