搜索
您的当前位置:首页正文

操作系统-15-进程的创建

来源:榕意旅游网

从这一节起,我们将详细讲解进程的一生。进程如人生,进程的一生同样包含三个阶段,创建,运行和终结,本节是进程三部曲的开篇:进程创建。

接下来,我们讲解关于进程创建的诸多问题。

进程是由谁创建的?在什么情况下创建的?
幸好这个问题不像鸡生蛋蛋生鸡那样,这个问题的答案相对简单,进程的创建者有两种:

1.操作系统可以创建新的进程
2.进程也可以创建新的进程

其中,进程的创建者被称为父进程,创建出的进程被称为子进程。

1,操作系统创建进程:初始化

作为计算机的Boss,最初的进程是由操作系统创建的,操作系统在初始化的过程中会创建一系列进程。这些进程中有的是用户可见的,主要用来和用户进行交互,比如Windows或Linux开机后输入用户密码,这就是一个进程,这类进程被称之为前端进程(Foreground Processes);

有的是用户看不到的在背后默默运行的进程,比如用来检测系统是否有更新的进程,这类进程被称之为后端进程(Background Process)。在Windows或Linux系统中,虽然开机后我们没有打开任何程序,但是使用Windows下的任务管理器或者Linux中的ps命令,你都会发现操作系统已经创建了很多进程。

2,进程创建进程:系统调用

其实从本质上来说,进程最终都是由操作系统创建出来的,但是操作系统把创建进程作为一项服务提供给了用户程序。还记得程序员应该怎样向操作系统请求服务吗,没错,就是通过系统调用。用户程序可以通过系统调用来创建新的进程。在Linux(Unix)下这个系统调用是大名鼎鼎的fork,在Windows下这个系统调用叫做CreateProcess。

比如在Linux系统中,我们通过命令行来运行程序,其实命令行解释器,比如常用的bash,也是一个进程,bash进程等待用户输入,然后调用系统调用创建新的进程来执行命令。比如我们常用的ps命令,注意ps本身就是一个可执行程序,当用户敲击回车按键后,bash调用fork创建新的进程,这个新的进程运行的就是ps程序,bash本身不关心ps进程是如何工作的,bash要做的就是等待,等待ps运行完成并输出结果后返回到bash,然后等待用户的下一次输入,如图所示:

从操作系统使用者的角度来说,一切皆为进程。操作系统中你能看到的、能用的都是进程。

因此,学习操作系统要以进程为核心,须知,操作系统中的一切都是为进程来服务的。

程序员什么情况下需要创建进程
作为程序员,我们在什么情况下需要使用进程呢?顺便说下,这也是笔者经常使用的一个面试题 😃

接下来从三个角度来讲解:任务处理、充分利用多核、增强系统稳定性。

1,任务处理

在这里我们以Unix系统为例来说明。Unix中大量使用了进程,Unix哲学之一就是:

一个程序只需要做一件事并且做到最好

让这些程序可以协同工作,由用户决定如何把这些小的程序组合来完成复杂的任务。比如在Unix下,我们需要去文件夹中找到所有包含字符串“anetos”的文件并把这些文件按照名称排序显示出来,那么在Unix下你就可以这样来完成任务:grep -r anetos * | sort 。其中grep程序用来查找有哪些文件包含了字符串“anetos”,得到这些文件后把文件名作为输入传递给sort程序,sort排序后显示出来,“|”表示grep的输出用作sort的输入,如图所示,bash这个进程使用fork创建出来了两个子进程,一个是grep,另一个是sort,这两个进程通力合作完成了我们的任务。

其实你可以把多个进程协同完成一项任务想象成这样一个过程:假设你的老板交给你一项任务,而作为主管的你肯定不会事事亲力亲为,因此你把这项任务拆分成了三个部分并找到得力的三个小兵,分别让他们去执行各自的任务并告诉他们完成后汇报给自己。在这个例子中,这三个小兵就好比创建出来的进程(子进程),作为主管的你就是创建进程的进程(父进程)。

2,充分利用多核

在多核系统中(也就是有多个CPU的计算机中),多个进程可以运行在不同的CPU上,这显然会加快处理速度,因为这些进程是真正的同时运行,也就是我们常说的并发。

早期的操作系统是不支持线程的,在这里我们也暂时不考虑多线程的情况 ,稍后的课程中我们会详细讲解线程。

在不考虑多线程的情况下,一个进程在某一时刻只能在一个CPU上运行,注意这里的意思不是说一个进程只能一直在某一个CPU上运行,当进程被暂停并重新运行后,该进程可能会在另一个CPU上被执行,这就是进程调度,CPU从执行线程A切换到线程B的过程被称为进程切换(Context Switch)。

假如我们的系统中有四个CPU,为完成某项任务我们创建了一个进程,那么该进程就只能在一个CPU上运行,即使其它三个CPU当前无事可做,因此在这种情况下,我们没有充分利用CPU资源。

虽然多进程可以充分利用多核,但是,像任何事物一样,多进程同样存在缺点,那就是进程切换的代价比较大(我们将会在本章稍后的部分详细讲解进程切换),进程切换过程会消耗较多的CPU时间,而CPU在这段时间没有在执行有用的任务。为解决这个问题,现代操作系统都开始支持线程(Thread),我们将在本书“程序员应如何理解线程”这一章中进行详细讲解。

尽管多进程技术有切换代价较大的缺点,但是进程依然凭借其独特的能力在程序设计领域占有一席之地。接下来,就让我们看看这项独特的本领。

3,增强系统稳定性

进程的这项独特本领就是稳定性较高。不管在任何领域,系统的稳定性都是工程师们不断努力去实现的,软件工程也不例外,而进程是实现系统稳定性的一项技术。

这种便利性的另一个问题就是当某个线程崩溃后,该线程所在的整个进程都会退出,当然进程中的所有线程也都不复存在。因此你会看到,同多线程相比,多进程有很好的系统稳定性,父进程创建的子进程崩溃后不会影响到其它子进程以及父进程。

因此在对系统稳定性较高的领域,比如Web服务器Nginx,会经常见到多进程技术的使用,这正是利用了进程相互隔离互不干扰的优点。

在了解了进程创建的Who、When、Why几个问题后,接下来我们依然以Unix系统为例看一下如何从进程的角度来理解操作系统。

从进程的角度来理解操作系统

Unix系统中的进程和人颇有几分相似之处。

每个进程在系统中都有自己唯一的id,通常是一个正整数,用来识别进程,这个id被称之为进程描述符(Process Identifier),简称pid。pid之于进程就好比身份证号之于人一样,通过身份证号就可以唯一确定某个人;同样,通过pid,我们就可以唯一确定一个进程。

Unix中创建进程的进程被称之为父进程,被创建的进程被称为子进程。就像人类的子女会继承父母的基因一样,子进程同样会继承父进程的部分资源,比如一部分内存空间以及父进程打开的文件等,这一点我们不在这里展开,具体内容会在“操作系统如何管理内存”和“操作系统如何管理文件”两章中详细讲解,让我们把注意力先放在进程上面。

同父进程一样,被创建出来的子进程同样也可以创建进程,因此,你会发现最终这些进程会形成一种类似于树的关系,叫做进程树,就像人的族谱一样。Unix中有专门的命令可以按照树的形式打印出系统中所有进程的关系,比如pstree。

下图就是Unix系统运行起来后形成的一个典型的进程树,注意这里只显示了部分进程。从图中你可以看到init进程是所有进程的祖先,Unix系统启动之后创建init进程(操作系统创建进程),init进程运行起来后开始创建一系列子进程(进程创建进程),比如这里的login进程,kthreadd进程,sshd进程。login进程用来管理登录到该系统的用户,用户成功登陆后login进程创建bash进程,bash进程就是我们熟悉的命令行界面了;kthreadd进程本身同样创建一系列进程,注意这些进程工作在内核模式,也就是说这些进程是操作系统的一部分(注意操作系统自己也可以创建属于自己的进程);sshd进程用来管理ssh链接,当我们使用ssh命令远程登录时,远端计算机上就是sshd这个进程来管理ssh链接的,链接成功后,sshd创建bash进程,这样我们就可以通过ssh来进行远程控制了。在Unix下你可以通过使用ps命令看到这些进程。

因此以进程这个角度我们可以这样来看待操作系统,如下图所示:真正用来执行具体任务的是进程,这些进程分为两部分,一部分工作在用户模式,代表用户,比如用来编辑代码的vim进程、用来编译程序的gcc进程、用来查阅资料的浏览器进程、听音乐的播放器进程等等;另一部分工作在内核模式,代表操作系统,比如进行内存管理、设备管理等。而决定哪个进程运行的是调度器,决定调度器是否运行的就是中断。需要注意的是,该图仅仅用于帮助我们从进程的角度来理解操作系统,真实的操作系统中很少会有这样清晰的划分。

实例讲解进程创建:fork与exec
Unix世界中使用系统调用fork来创建进程,fork是一个很有趣的函数,该函数有两种返回值,一种返回值用来表示接下来的代码将会在父进程中被执行;另一种返回值表示接下来的代码将会在子进程中被执行。如代码所示,编译后的可执行程序我们命名为anetos:

include <sys/types.h>
include <stdio.h>
include <unistd.h>
int main()
{
pid_t pid;

/* 使用系统调用fork创建新进程 */
pid = fork();
if (pid < 0) { /* 调用失败 */
    printf("调用失败.\n");
    return 1;
}
else if (pid == 0) { /* 接下来就是子进程 */
    printf("我是子进程.\n");
    execlp("/bin/ls","ls",NULL);
}
else { /* 父进程,接下来的代码依然在该进程中执行 */
    printf("我是父进程.\n");
    /* 等待子进程执行完成 */
    wait(NULL);
    printf("子进程执行完成.\n");
}
return 0;

}
注意,虽然printf(“我是子进程.\n”)以及printf(“我是父进程.\n”)这两段代码写在同一个源文件中,但是,注意这两段代码实际上运行在两个不同的进程当中,父进程与子进程。此外,子进程的内存其实是用父进程的内存来初始化的,换句话说就是fork系统调用执行创建子进程后,子进程的内存其实是父进程的一个拷贝,如下图所示:

只要父子进程不对同一块内存进行写操作,那么这块内存就会一直共享下去,一旦父进程或子进程改变某一块内存,那么这块内存将不在被父子进程共享,而是父子进程各有自己不同的版本,这就是写时复制技术Copy On Write,由于该技术涉及到内存,我们将在“操作系统如何管理内存”这一章中再次回到这一话题。你会看到在Unix中用fork进行进程创建是非常快速的,因为子进程会共享大部分父进程内存,然后使用Copy On Write技术保持进程独立性。父子进程双方对内存的读写不会影响到对方。

现在我们知道了fork系统调用后会创建出一个和父进程一样的子进程,一般来说,创建出一个和父进程相同的子进程是没什么用的。因此通过fork创建出子进程后,子进程通常会调用另一个系统调用exec来执行其它程序,比如子进程通过exec系统调用开始运行/bin/ls程序。exec系统调用会将新的程序加载到内存当中,这样一个新的程序就在子进程的内存中运行起来了,如图所示:

总结
在进程三部曲的开篇,我们详细讲解了谁来创建进程,如何创建进程。作为程序员我们需要充分利用进程这一强大的武器来完成我们的任务,因此在这一节中我们从三个角度讲解了程序员在什么情况下需要创建进程。

操作系统是复杂的,但是从进程的角度我们可以更加容易的理解操作系统,因此我们在这一节中讲解了如何从进程的角度来理解操作系统。

最后我们用了一个实际的例子来讲解了如何用系统调用fork和exec来创建新的进程。

接下来让我们聆听进程三部曲的中篇,生命的乐章之进程运行,理解一下进程是如何运行的。

本文来自《码农的荒岛求生》

因篇幅问题不能全部显示,请点此查看更多更全内容

Top