题图:by Li-Yang @Unsplash
在计算机发展初期,CPU 的计算能力非常有限,计算资源稀缺而昂贵。最早的时候一个 CPU 只能同时运行一个任务,这简直让人无法忍受。什么叫做只能运行一个程序呢?这就像大学上自习占座一样,一旦有人占了那个桌子,其他人就再也没法用了,无论是这个人出去上厕所,踢球,你都不能去用那个座位,如果你竟然偷着坐呢了,屁股还没把椅子捂热乎,就会有个神秘人拍拍你的肩膀说「童靴,这里有人」。无论当前任务在使用 CPU 进行计算,还是在读写磁盘 IO 或进行网络交互,它都得占着CPU,即使中间有空档你也别想用。
于是大家试图通过各种方式来改变这一现象。首先出场的是多通道程序,程序员们很快写了一个监控程序,发现当前任务不用 CPU 计算时,就唤醒其他等待 CPU 资源的程序,让 CPU 资源能够得到充分利用。但多通道的问题是调度乏力,不分青红皂白和轻重缓急,不管是急诊还是普通门诊,该等都得等。
第二出场的是分时系统,分时系统是一种协作模式,每个程序运行一小段时间都得主动把 CPU 让出来给其他程序,这样每个程序都有机会用到 CPU 一小段时间。这时操作系统的监控程序也完善了一些,能够处理相对复杂的请求。早期的 Windows 和 mac OS 都是采用这种方式来调度程序的。分时系统的问题是,一旦某个程序死循环,系统就没招了,只能干等着,和死机了一模一样。
第三个隆重登场的是多任务系统,程序员们让操作系统接管了所有的硬件资源,变得更加高级智能,系统进程开始分级,有的是特权级别,有的是平民级别,所有的应用程序以进程和线程方式运行,CPU 的分配方式采用了抢占式,就是说操作系统可以强制把 CPU 的资源分配给目前最需要的程序。程序员们成功了,几乎完美的控制了一切,并造成了很多任务都在同时运行的假象,如果用两个字来形容的话,那就是「和谐」。目前 macOS、Linux、Windows 都是采用这种方式进行任务管理的。
以上就是单核单CPU的情况,但无论线程间的切换多么快,这些都是并发,而不是并行。
终于,多核 CPU 和分布式系统被干出来了,一台计算机可以拥有多颗 CPU,每颗 CPU 可以有多核,同时,成千上万台的机器被连接在一起进行计算,大家一看挺晕的,史称「云计算」。随着硬件的变化,软件技术同时开始革新,各种语言开始支持并行计算,比如 Erlang/Scala 的 Actor&Message 模型,Go 语言的 goroutine 机制,Java 的ForkJoinPool,OC 的 Grand Central Dispatch 技术,当然还有各种分布式计算框架。
最后出场的是容器技术。到了容器时代,容器如何管理进程呢?或者说容器和进程的关系是什么呢?
我们常说的程序,一旦开始执行,就从磁盘上的二进制文件,变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合。像这样一个程序运起来后的计算机执行环境的总和,就是进程。容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个「边界」。
对于 Docker 等大多数 Linux 容器来说,Cgroups 技术是用来制造约束的主要手段,而 Namespace 技术则是用来修改进程视图的主要方法。在这里,进程的管理又和以前有所不同了。
你可能会觉得 Cgroups 和 Namespace 这两个概念很抽象,不用担心,接下来我们一起动手实践一下,你就很容易理解这两项技术了。
假设你已经有了一个 Linux 操作系统上的 Docker 项目在运行,比如 Ubuntu 16.04 和 Docker CE 18.05。
接下来,让我们首先创建一个容器来试试。
$ docker run -it busybox /bin/sh
/ #
这个命令是Docker项目最重要的一个操作,-it 参数告诉了 Docker 项目在启动容器后,需要给我们分配一个文本输入和输出环境,也就是终端,这样我们就可以和这个 Docke r容器进行交互了。而 /bin/sh 就是我们要在Docker容器里运行的程序。
所以,上面这条指令翻译成人类的语言就是:请帮我启动一个容器,在容器里执行 /bin/sh,并且给我分配一个命令行终端跟这个容器交互。
这样,Ubuntu 16.04 机器就变成了一个宿主机,而一个运行着 /bin/sh 的容器,就跑在了这个宿主机里面。
上面的例子和原理,如果你接触过 Docker,一定不会感到陌生。此时,如果我们在容器里执行一下 ps 指令,就会发现一些更有趣的事情:
/ # ps
PID USER TIME COMMAND
1 root 0:00 /bin/sh
10 root 0:00 ps
你可以看到,我们在 Docker 里最开始执行的 /bin/sh,就是这个容器内部的第1号进程(PID=1),而这个容器里一共只有两个进程在运行。这就意味着,前面执行的 /bin/sh,以及我们刚刚执行的 ps,已经被Docker隔离在了一个跟宿主机完全不同的世界当中。
这是怎么做到呢?
本来,每当我们在宿主机上运行了一个 /bin/sh 程序,操作系统都会给它分配一个进程编号,比如 PID=100。这个编号是进程的唯一标识,就像员工的工牌一样。所以 PID=100,可以粗略地理解为这个 /bin/sh 是我们公司里的第 100 号员工,而第 1 号员工就自然是比尔 · 盖茨这样统领全局的人物。
而现在,我们要通过 Docker 把这个 /bin/sh 程序运行在一个容器当中。这时候,Docker 就会在这个第 100 号员工入职时给他施一个「障眼法」,让他永远看不到前面的其他 99 个员工,更看不到比尔 · 盖茨。这样,他就会错误地以为自己就是公司里的第 1 号员工。
这种机制,其实就是对被隔离应用的进程空间做了手脚,使得这些进程只能看到重新计算过的进程编号,比如 PID=1。可实际上,他们在宿主机的操作系统里,还是原来的第100号进程。
这种技术,就是 Linux 里面的 Namespace 机制。而 Namespace 的使用方式也非常有意思:它其实只是 Linux 创建新进程的一个可选参数。我们知道,在 Linux 系统中创建线程的系统调用是 clone(),比如:
int pid = clone(main_function, stack_size, SIGCHLD, NULL);
这个系统调用就会为我们创建一个新的进程,并且返回它的进程号 pid。
而当我们用 clone() 系统调用创建一个新进程时,就可以在参数中指定 CLONE_NEWPID 参数,比如:
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);
这时,新创建的这个进程将会具备一个全新的进程空间,在这个进程空间里,它的 PID 是 1。而在宿主机真实的进程空间里,这个进程的 PID 还是真实的数值,比如 100。
当然,我们还可以多次执行上面的 clone() 调用,这样就会创建多个 PID Namespace,而每个 Namespace 里的应用进程,都会认为自己是当前容器里的第 1 号进程,它们既看不到宿主机里真正的进程空间,也看不到其他 PID Namespace 里的具体情况。
而除了我们刚刚用到的 PID Namespace,Linux 操作系统还提供了 Mount、UTS、IPC、Network 和 User 这些Namespace,用来对各种不同的进程上下文进行「障眼法」操作。
比如,Mount Namespace,用于让被隔离进程只看到当前 Namespace 里的挂载点信息;Network Namespace,用于让被隔离进程看到当前 Namespace 里的网络设备和配置。
这,就是 Linux 容器最基本的实现原理了。
所以,Docker 容器这个听起来玄而又玄的概念,实际上是在创建容器进程时,指定了这个进程所需要启用的一组Namespace参数。这样,容器就只能「看」到当前 Namespace 所限定的资源、文件、设备、状态,或者配置。而对于宿主机以及其他不相关的程序,它就完全看不到了。
所以说,容器,其实是一种特殊的进程而已。
我们在这篇文章里简要描述了单核 CPU 的进程管理,再到多核 CPU 进程、容器的进程管理,读完之后,你是否对进程了有了更多了解呢?
关于容器进程的部分内容,节选自极客时间的专栏「深入剖析 Kubernetes」,也就是大名鼎鼎的 k8s 容器框架,专栏作者是张磊,官方 k8s 社区资深开发者。该专栏目前已经超过 7000 人订阅,原价 99,今天是最后一天 68 元优惠期,欢迎来一起学习。