Summary

网络部分的我们暂时学这么多,今天开始复习一下操作系统。 操作系统我们用一个故事从头讲到尾:你开了一家饭馆。 一开始是你一个人的小厨房,后来请了厨师、开了分店、生意越做越大。饭馆里遇到的每一个麻烦,计算机里都有一模一样的版本,操作系统里的每个概念,都是为了解决其中某一个麻烦而出现的。

先把这家饭馆里的角色和计算机对上号:

饭馆里计算机里
菜谱程序
照着菜谱做菜的整个过程进程
厨房(连同里面的锅、调料、冰箱)进程拥有的资源
厨师的一双手CPU 核心
在同一个厨房里干活的几个厨师线程
厨房里的冰箱和料台内存
远处的仓库磁盘
饭馆经理操作系统内核

这一篇讲最基础的两个角色:进程和线程。


菜谱和做菜:程序与进程

周末你在家给家人做一个蛋糕。桌上摊着一张菜谱,写着:200 克面粉、3 个鸡蛋、打发、搅拌、烤 30 分钟……

菜谱本身只是一张纸,放在抽屉里,它什么也不会发生。你照着它开始做,这个"做蛋糕"的过程才真正动起来:你站在灶台前,面前摆着面粉和鸡蛋,手里拿着打蛋器,心里记着"现在做到第 4 步了"。

计算机里是一样的:

  • 程序就是那张菜谱:硬盘上的一个文件,里面是一条条指令,本身不会动。
  • 进程是"照着菜谱做菜"的整个过程:程序被加载进内存,CPU 开始一条条执行它的指令。

同一张菜谱可以同时做好几份。你和你妈各拿一张一样的菜谱,在两个灶台上各做一个蛋糕,一个做到打发,一个已经进了烤箱,互不相干。计算机里,你开三个终端窗口,就是同一个终端程序跑出了三个进程,每个进程的进度、数据都是各自的。

一个"做蛋糕的过程"里包含哪些东西?

做蛋糕这件事(进程)
 ├─ 菜谱                       → 程序代码
 ├─ 面粉、鸡蛋、做到一半的面糊     → 数据(变量、堆、栈)
 ├─ 做到第几步了                 → 程序计数器(下一条要执行的指令)
 ├─ 手边的打蛋器、烤箱、量杯       → 打开的文件、网络连接等资源
 └─ 这是谁在做、给谁做的          → 进程号 PID、所属用户等身份信息

所以说进程不只是"一段在跑的代码",而是代码加上它运行需要的全部东西。操作系统给每个进程配一整套这些东西,并且记在一张"登记卡"上,叫进程控制块(PCB),上面写着这个进程是谁、做到哪一步了、手上有哪些资源。


一个人怎么"同时"做三道菜:并发与进程状态

并发和并行

晚上家里来了客人,你要一个人做三道菜:红烧肉、炒青菜、番茄蛋汤。

你不会做完红烧肉再做下一道。红烧肉下锅炖上,要炖 40 分钟,这段时间你不用盯着,就去洗青菜;青菜炒好,汤的水也开了,打个鸡蛋进去;中途再去看一眼红烧肉的火。一个小时后,三道菜一起上桌。

在客人看来,你"同时"做了三道菜。但其实你只有一双手,任何一个时刻都只在做一件事,只是在几件事之间来回切换得很快。这个就叫并发。

如果你妈妈也来帮忙,你炒青菜的同时她在做汤,两件事是真的在同一时刻进行的,这叫并行。

计算机里也一样:一个 CPU 核心就是厨师的一双手,同一时刻只能执行一个进程的指令。电脑上开着几百个进程,靠的是操作系统让它们轮流用 CPU,每个用几毫秒就换下一个,切换快到你感觉不出来,这是并发。有 8 个核心,就能真正同时跑 8 个,这是并行。

单核也能并发,但单核不可能并行。

一道菜的三种状态

回头看你做的三道菜,每道菜在任何时刻都处在三种状态之一:

  • 正在做:你的手正在炒它。一双手同一时刻只能有一道菜处于这个状态。
  • 等你来做:青菜已经洗好切好,就差你腾出手来炒。它什么都不缺,只缺你。
  • 在等别的事:红烧肉在锅里炖着,要等 40 分钟;汤在等水烧开。这时候你守着它也没用,它不需要你的手。

进程也是这三种状态,名字分别叫运行、就绪、阻塞:

                ┌──────── 你被别的事叫走了(时间片用完)──────────────┐
                ▼                                               │
  新建 ──▶ 【就绪】什么都不缺,只等 CPU ──── 轮到它了 ────▶ 【运行】正在用 CPU
                ▲                                               │
                │                                               │ 要等一件事:
                └────── 等的事情发生了 ──────── 【阻塞】 ◀─────────┘  读磁盘、等网络、等锁
                         (肉炖好了、数据到了)

这张图里有两点值得重视:

  1. 阻塞的进程完全不占 CPU。 炖着的红烧肉不需要你的手,你可以去忙别的。同样,一个进程在等网络数据、等磁盘读写时,操作系统会把 CPU 让给别人。所以,如果一个程序大部分时间都在等(等网络、等磁盘),开很多个也不会把 CPU 用满;如果它一直在算,开得比核心数还多,就只会互相抢,谁也快不起来。

  2. 阻塞结束后不是直接运行,而是先回到就绪。 红烧肉炖好了,你正在炒青菜,不会扔下锅铲立刻去收汁,而是等手空出来。进程等的数据到了,也只是变成"就绪",排队等 CPU。

切换时要记住做到哪了

你炒着青菜,突然想起汤该放鸡蛋了。你得先记住"青菜已经放了盐,还没放蒜",然后去弄汤。弄完回来,看一眼刚才记下的,接着放蒜。如果忘了记,回来可能又放一遍盐。

操作系统切换进程时做的是同一件事:把当前进程"做到哪一步、手上拿着什么"(程序计数器和各个寄存器的值)保存到它的 PCB 里,再把下一个进程上次保存的状态恢复出来。这个过程叫上下文切换。每次切换都要花一点时间,切换得太频繁,时间就都花在"记笔记、看笔记"上了。


开饭馆了:一个厨房不够用

你的手艺不错,干脆开了家饭馆。一开始只有你一个厨师,客人一多就忙不过来:一桌点了需要慢炖两小时的佛跳墙,你还能抽空做别的;可要是一桌客人要你现场讲解怎么做菜,你就被一桌拴死了,后面的客人全在干等。

要同时招呼更多客人,就得有更多的人一起干活。有两种扩张的办法。

开分店(多进程)

每多一批客人,就开一家分店。每家分店有自己独立的厨房、灶台、冰箱、调料,各做各的。

好处是互不影响。 一家分店的厨房着火了,其他分店照常营业;一家分店的厨师把盐当成糖用了,也只坏了这家店的菜。

问题有两个。

一是贵:每开一家分店都要租店面、买全套厨具,开得越多越吃不消。

二是分店之间没法共享东西:总店熬了一大锅高汤,分店想用,得派人开车送过去。分店之间不能直接走进对方厨房拿东西。

这就是计算机里的多进程。每个进程有自己独立的一套内存和资源,操作系统保证一个进程绝对碰不到另一个进程的内存:

  • 一个进程崩溃,不会影响其他进程,这就是隔离,是进程最核心的价值。浏览器给每个标签页开一个进程,就是为了一个网页崩了不至于把整个浏览器带走。
  • 创建一个进程要给它准备一整套内存空间和资源,开销大。
  • 进程之间要交换数据,只能通过操作系统提供的专门渠道,叫进程间通信(IPC),比如管道、共享内存、socket,相当于派人送高汤。

同一个厨房里多请几个厨师(多线程)

另一个办法是不开分店,在同一个厨房里多请几个厨师。大家共用一个冰箱、一套调料、一锅高汤,但每个人有自己的一块菜板,手里拿着自己那桌的订单小票,各自记着自己做到哪了。

这就是线程:同一个进程里的多个执行者,共享这个进程的全部资源,但各自有自己的执行进度。

┌──────────────────────────── 一个厨房(进程)────────────────────────────┐
│                                                                     │
│   大家共用:冰箱、调料、高汤、灶台、餐具                                   │
│            (代码、全局变量、堆内存、打开的文件)                           │
│                                                                     │
│   ┌────────────────┐   ┌────────────────┐   ┌────────────────┐      │
│   │ 厨师甲(线程 1)   │   │ 厨师乙(线程 2)  │   │ 厨师丙(线程 3)   │       │
│   │ 自己的菜板       │   │ 自己的菜板      │   │ 自己的菜板       │       │
│   │ 自己的订单小票   │   │ 自己的订单小票   │    │ 自己的订单小票   │       │
│   │ 记着做到第几步   │   │ 记着做到第几步   │    │ 记着做到第几步   │       │
│   └────────────────┘   └────────────────┘   └────────────────┘       │
│     (栈、寄存器、程序计数器:每个线程各一份)                                │
└──────────────────────────────────────────────────────────────────────┘

哪些共用、哪些各有一份,是理解线程的关键:

同一个进程里的线程共用每个线程各有一份
代码(菜谱)栈(自己的菜板和小票,记着调用到哪一层、局部变量是什么)
全局变量、堆内存(冰箱和调料)寄存器和程序计数器(手上正拿着什么、做到第几步)
打开的文件、网络连接(灶台和餐具)线程编号

每个厨师的小票必须各有一份,因为每个人在做不同的菜,进度不一样。三个人共用一张小票记进度,马上就乱了。栈也是这个道理:栈里记的是"函数调用到哪一层、每层的局部变量是什么",也就是这个线程走到哪了。

冰箱可以共用,因为里面的东西本来就是大家都要用的。这正是多线程比多进程方便的地方:厨师甲熬的高汤放进冰箱,厨师乙伸手就能拿到,不用开车去送。线程之间交换数据,直接读写同一块内存就行。

请一个厨师也比开一家分店便宜得多:不用租店面、买厨具,给他一块菜板就能上岗。创建线程只需要分配一个栈和少量记录信息,比创建进程快得多。


共用一个厨房的麻烦

同一个厨房里多了几个厨师,出菜快了,但很快出了两件事。

白板上的鸡蛋数对不上

厨房门口挂着一块白板,写着"冰箱里还剩 5 个鸡蛋"。规矩是:谁拿了鸡蛋,就把白板上的数减一。

某天中午最忙的时候,厨师甲和厨师乙几乎同时需要鸡蛋:

厨师甲:看白板,剩 5 个
厨师乙:看白板,剩 5 个              ← 甲还没来得及改
厨师甲:拿一个鸡蛋,把白板改成 4
厨师乙:拿一个鸡蛋,把白板改成 4      ← 把甲改的结果又写了一遍

实际拿走了 2 个,冰箱里只剩 3 个,白板上却写着 4

问题出在"看一眼、减一、写回去"这三步中间被别人插了进来。每个人单独做都没错,两个人交错着做,结果就错了。

计算机里一模一样。两个线程同时执行"计数器加一"这行代码,CPU 实际做的是三步:

① 从内存读出计数器的值
② 加 1
③ 写回内存

两个线程的这三步交错在一起,就会出现上面的情况:加了两次,结果只多了 1。这叫数据竞争。做压力测试时发 20 万个请求,访问计数器只显示 10 万出头,就是这样丢的。

出现这个问题的根源是资源共享:分店之间各有各的白板,不会出这种事;同一个厨房共用一块白板,就必须约定好"改白板的时候别人不许改"。这种约定就是锁。

一个厨师点着了厨房,整个店停业

又有一天,厨师丙炸东西时油锅起火,火势蔓延,整个厨房都被熏黑了。

结果不是"厨师丙那桌的菜做不了了",而是整个厨房停业,其他厨师手上的菜全部作废,所有客人都得走。因为大家共用一个厨房,一个人闯的祸会波及所有人。

线程也是这样。一个线程写坏了内存(比如访问了不该访问的地址),这块内存可能正是别的线程在用的,操作系统没法判断哪些线程还是好的,只能把整个进程杀掉,里面的所有线程一起结束。

把两种扩张办法放在一起比较:

开分店(多进程)多请厨师(多线程)
开一个要花多少多:要一整套店面和厨具少:给一块菜板就行
交换东西慢:要派人送(IPC)快:伸手就拿(共享内存)
出事波及多大只影响这一家分店整个厨房停业
要不要约定规矩基本不用必须约定,否则白板数对不上

没有哪个绝对更好,看你更怕什么。Chrome 浏览器更怕一个网页搞垮整个浏览器,所以每个标签页一个进程;大多数服务器程序更看重效率,用多线程;Nginx 两者结合,一个管理进程带几个工作进程,哪个工作进程挂了,管理进程再重新拉起一个。


厨师也请不起了:一万桌客人

饭馆越做越大,某个节日同时来了一万桌客人。如果每桌配一个厨师,你得请一万个厨师。

问题来了:每个厨师都要一个工位、一个储物柜,哪怕他大部分时间只是在等炖锅、闲着没事,工位和柜子也得给他留着。而且厨师们轮班换岗都要经过经理登记、安排,一万个厨师来回换岗,经理光是安排换班就忙不过来了。

线程也有这两个成本:

  • 每个线程都要一块不小的栈。Linux 默认给每个线程预留 8MB 栈空间,一万个线程就要预留 80GB,而大部分线程只是在等网络数据,栈里几乎是空的。
  • 线程由操作系统内核来调度,也就是由"经理"来安排换岗。每次从一个线程换到另一个,都要从普通模式进入内核模式,保存一大堆状态,再换回来,大约要一到几微秒。线程一多,CPU 大量时间花在了换岗本身。

这个"怎么让一台机器同时招呼一万个客人"的问题,在 1999 年被正式提出,叫 C10K 问题。后来人们找到了两种办法,饭馆里也能找到对应:

办法一:一个服务员盯很多桌。 不再一桌配一个人,而是一个服务员在大厅里看着几百桌,哪桌客人举手了就过去,没人举手就站着看。客人大部分时间在吃饭聊天,并不需要人。这就是事件驱动:少数几个线程,借助操作系统的帮助同时盯着成千上万个连接,哪个连接有数据来了就去处理哪个。Nginx、Redis 都是这么做的。

办法二:请学徒,由领班自己排班。 厨师很贵,而且换岗要经过经理。那就招一大批学徒,学徒不占正式工位,只需要一个小凳子;谁干什么、什么时候换人,由厨房领班当场安排,不用去经理那里登记。领班手下只有几个正式厨师的位置,但能调度几百个学徒轮流上灶。这就是协程:在程序内部自己实现,是一种比线程轻得多的执行者,占的内存很小,切换也不用经过内核。

                 分店(进程)       厨师(线程)        学徒(协程)
占多少地方        一整套店面        一个工位            一个小凳子
                 (几 MB 起)       (默认 8MB 栈)      (几 KB)
换人要多久        很久              要经过经理          领班当场安排
                 (要换整套内存)   (要进内核,微秒级) (不进内核,更快)
谁来安排          经理              经理                领班
能有多少          几百              几千                几十万

把饭馆翻译回计算机

故事讲完了,把几个底层细节补上。

操作系统怎么启动一个新进程。 在 Linux 里敲下一个命令时,你所在的 shell(它自己也是一个进程)分两步完成:先调用 fork(),把自己复制一份,得到一个子进程;再在子进程里调用 exec(),把这个复制品整个换成要运行的新程序。好比你要新开一家分店,先把现有分店原样复制一家,再把里面的菜谱换成新的。这样拆成两步,中间可以做些调整,比如 ./app > log.txt 的输出重定向,就是在复制之后、换菜谱之前,把子进程的输出改到了文件里。

进程之间为什么碰不到彼此的内存。 每个进程看到的都是一套自己专属的内存地址,叫虚拟地址空间。两个进程都访问"地址 1000",操作系统会把它们翻译到两块不同的物理内存上。就像两家分店的储物柜都从 1 号开始编号,但"1 号柜"在两家店里是完全不同的柜子。

为什么切换进程比切换线程贵。 换到另一个进程,等于换了一整套储物柜编号,CPU 里缓存的"编号对应哪个柜子"的速查表(叫 TLB)全部作废,接下来一段时间每次取东西都要重新查表,所以变慢。同一个进程里的线程共用一套编号,切换时速查表继续有效。这是线程比进程轻的最主要原因,这个问题也是面试过程中经常会被问到的。

Linux 里进程和线程其实是同一种东西。 Linux 内核里没有专门的"线程",只有"任务"(task)。创建进程和创建线程用的是同一个系统调用 clone(),区别只在于传进去的选项:选了"共享内存空间" “共享打开的文件"等一串选项,创建出来的就是线程;什么都不共享,就是进程。用饭馆的话说,开分店和请厨师是同一份合同,只是勾选的条款不同:勾了"共用厨房”,就是请了个厨师;什么都不勾,就是开了个分店。

这些东西都能在系统里直接看到。 Linux 上每个进程在 /proc/<进程号>/ 下都有一个目录:status 文件里有它的父进程号和线程数,fd 目录里列着它打开的所有文件和网络连接,maps 文件是它的内存布局。ps 命令的 STAT 列显示状态:R 是运行或就绪,S 是阻塞(在睡眠等事件),T 是被暂停,这个要你自己动手上去看一看。


总结

整篇的故事线是这样的:

你一个人在家做菜
  │ 菜谱 = 程序,做菜的过程 = 进程
  │ 一双手轮流做三道菜 = 并发;两个人一起做 = 并行
  │ 每道菜处在 正在做 / 等你来做 / 在等别的事 = 运行 / 就绪 / 阻塞
  ▼
开了饭馆,一个人忙不过来
  │
  ├─ 开分店 = 多进程
  │    好处:一家出事不影响别家(隔离)
  │    问题:开店贵;分店之间送东西麻烦(IPC)
  │
  └─ 一个厨房多请厨师 = 多线程
       好处:共用冰箱调料,便宜又方便
       问题:白板上的数对不上(数据竞争);一人失火全店停业
  ▼
一万桌客人,厨师也请不起了(C10K)
  ├─ 一个服务员盯很多桌 = 事件驱动
  └─ 请学徒,领班自己排班 = 协程

最该记住的几句话:

  • 程序是菜谱,进程是做菜的过程。 进程包含代码、数据、执行进度和它占用的资源。
  • 进程有运行、就绪、阻塞三种状态,阻塞的进程不占 CPU。
  • 进程的核心价值是隔离:各有各的内存,一个崩了不影响别的。代价是创建贵、交换数据麻烦。
  • 线程是同一个进程里的多个执行者:共用内存和资源,各有自己的栈和执行进度。代价是要防数据竞争,而且一个线程出事会带走整个进程。
  • 线程比进程轻,主要轻在切换时不用换一整套内存地址。 协程比线程更轻,轻在占的内存小、切换不经过内核。

动手练习

/proc 相关的需要 Linux 机器,其余在 macOS 上也能做。

  1. 同一张菜谱,两个进程。 开两个终端窗口,分别执行 echo $$,会打出两个不同的进程号:同一个 shell 程序,跑出了两个进程。
  2. 看一个进程的"厨房"。 在 Linux 服务器上找一个正在运行的服务,pgrep nginx(或其他服务名)拿到进程号,然后:cat /proc/<进程号>/status 找到 PPid(父进程)和 Threads(线程数);ls -l /proc/<进程号>/fd 看它打开了哪些文件和连接;cat /proc/<进程号>/maps 找到 [heap] 和 [stack] 两行,这就是它的堆和栈在内存里的位置。
  3. 把三种状态都看一遍。 执行 sleep 300 &,用 ps -o pid,stat,command 看它的状态是 S(阻塞,在等时间到);执行 yes > /dev/null &,它一直在算,状态是 R;用 kill -STOP <进程号> 暂停它,状态变成 T,kill -CONT 恢复。看完记得用 kill 把它们结束掉。
  4. 看看一个程序有多少线程。 macOS 上打开"活动监视器",添加"线程"一列,看看浏览器、微信各开了多少线程;Linux 上用 ps -eLf | grep <程序名> | wc -l。