简介

2023.10.08~2023.10.09
本人是生物学在读研究生,本科为生物信息学,一种结合生物学,计算机和数学统计的学科。
学习动机:Deep Learning在生物学上的应用收到关注。DL的学习需要掌握基本计算机概念,在学习计算机组成原理之前,看到了《码农的荒岛求生》公众号作者的这本书《图解计算机系统》,非常有趣,像我这样的外行也能搭建一些简单的基本概念框架。
收获:(简单提几点)

  - 明白了CPU、程序、进程、内存、寄存器、栈帧、线程、操作系统、内核、缓存、编译、I/O,汇编等计算机基础概念。
  - 明白了高级语言速度慢原因之一是因为需要编译,以及汇编语言的存在。
  - 内存分配的过程、寄存器的结构。
  - 明白了CPU是怎么懂得人们写的代码(指令集),以及CPU有不同的指令集框架。
  - 大型服务器需要排队的原因之一是因为线程安全。
  - 缓存catch是CPU和内存之间的桥梁,CPU核数与线程之间的关系。。。

联系方式:QQ:1132250853,V:yinhm1999,欢迎大家交流与指正。欢迎大佬们继续补充未看完的部分。

一、CPU是如何理解01二进制的?

准确的来说,CPU不认识也不理解任何东西
CPU就像一个单细胞一样,本身不具备任何思考能力,没什么自己的想法,只是简单的你给它一个刺激,它会有一个反应。image.png
那这个刺激是什么呢?是电压,硬件感知到的仅仅就是电压。 电压有两种,高电压和低电压。
你马上就能反应过来,这就是01二进制,高电压代表1低电压代表0,0和1仅仅是人类可以理解的东西,硬件电路可不理解这玩意,它仅仅就是靠电流驱动来工作。
让我们来看看这个简单的电路,这个就是与门
image.png
你能说这个电路理解它自己该做什么吗?它有自我意识吗?当然没有。 所以说这个问题的答案非常简单:CPU根本就不能理解任何东西,之所以CPU能正常工作,仅仅是因为你(制作CPU的人)让它这么工作
这个问题就好比你问一辆自行车是如何理解自己怎么跑起来的?还不是因为你设计了车轮、车链然后用脚一蹬跑起来的。
image.png
你希望两个开关都打开灯才亮,因此你这样设计电路,这就是与门;你希望任意一个开关打开灯就亮,因此你那样设计电路,这就是或门;你希望关闭开关灯才亮,这就是非门,有了与或非你可以搭建出任意复杂的逻辑电路,比如下面这个能执行加操作的加法器。
image.png
看看这个电路,你能说它知道自己是在执行加法操作吗,这当然是人类认为这个电路的输出等价于加法操作的结果。 尽管这个电路看上去很不错,给定两个输入得到的输出和我们人类认为的加法是一样一样的,但这有点简单。 除了加法是不是还应该有其它操作,如果有多种类型的操作那么就必须告诉电路该操作的类型是上面 (操作码),操作的数字是什么(操作数)。 这样我们给它一个输入就能按照我的想法来控制电路该怎么工作了,BOOM!!!宇宙大爆炸!
image.png
哦不对,CPU诞生了!
人类编写的代码必须首先转为01二进制,之后才能驱动CPU工作。 当然,怎么把一坨代码高效等价的转为1001011100。。。这项工作可不简单,人类探索了几十年, 一干人等还获得了图灵奖,可见这个问题的重要程度以及难度。
image.png
你今天能简单点一下build按钮或简单运行一个命令就能把你写的代码转为01串,要知道这简单的背后是靠无数天才榨干天量的脑细胞才实现的。
image.png
从这里应该应该能看出来,CPU根本也不会认识任何语言
现在我们能给CPU输入了,那么输出怎么办呢?
剩下的仅仅就是解释了,比如给你一个01串,01001101,你可以认为这是一个数字,也可以认为这
是一个字符,也可以是表示RGB颜色,一切都看你怎么解释,这就是软件的工作了。
最终的目的只有一个:让人类能看懂
整个流程就是这样的:
image.png
计算机真实一个非常神奇的机器,如此简单,却又能完成复杂无比的工作。 现在你应该明白了吧,计算机所谓能理解二进制就好比你的台灯能理解开关一样。 它们真的对此一无所知

二、CPU空闲时在干嘛?

人空闲时会发呆会无聊,计算机呢? 假设你正在用计算机浏览网页,当网页加载完成后你开始阅读,此时你没有移动鼠标,没有敲击键 盘,也没有网络通信,那么你的计算机此时在干嘛?有的同学可能会觉得这个问题很简单,但实际上,这个问题涉及从硬件到软件、从 CPU 到操作系统 等一系列环节,理解了这个问题你就能明白操作系统是如何工作的了。
你的计算机 CPU 使用率是多少? 如果此时你正在计算机旁,并且安装有 Windows 或者 Linux ,你可以立刻看到自己的计算机 CPU 使用率是多少。
这是博主的一台安装有 Win10 的笔记本:
image.png
可以看到大部分情况下 CPU 利用率很低,也就在 8% 左右,而且开启了 283 个进程,
这么多进程基本上无所事事
都在等待某个特定事件来唤醒自己,就好比你写了一个打印用户输入的程序,如果用户一直不按键盘,那么你的进程就处于这种状态。
有的同学可能会想也就你的比较空闲吧,实际上大部分个人计算机 CPU 使用率都差不多这样(排除掉看电影、玩游戏等场景),如果你的使用率总是很高,风扇一直在嗡嗡的转,那么不是软件 bug 就有可能是病毒。。。
那么有的同学可能会问,剩下的 CPU 时间都去哪里了?

剩下的 CPU 时间都去哪里了?

这个问题也很简单,还是以 Win10 为例,打开任务管理器,找到 “详细信息” 这一栏,你会发现有一个 “系统空闲进程”,其 CPU 使用率达到了 99%,正是这个进程消耗了几乎所有的 CPU 时间。
image.png
那么为什么存在这样一个进程呢?以及这个进程什么时候开始运行呢? 这就要从操作系统说起了。

程序、进程与操作系统

当你用最喜欢的代码编辑器编写代码时,这时的代码不过就是磁盘上的普通文件,此时的程序和操作系统没有半毛钱关系,操作系统也不认知这种文本文件。
image.png
程序员写完代码后开始编译,这时编译器将普通的文本文件翻译成二进制可执行文件,此时的程序依然是保存在磁盘上的文件,和普通没有本质区别。
image.png
但此时不一样的是,该文件是可执行文件,也就是说操作系统开始 “懂得” 这种文件,所谓 “懂得” 是 指操作系统可以识别、解析、加载,因此必定有某种类似协议的规范,这样编译器按照这种协议生成 可执行文件,操作系统就能加载了。
在 Linux 下可执行文件格式为 ELF ,在 Windows 下是 EXE 。
此时虽然操作系统可以识别可执行程序,**但如果你不去双击一下(或者在Linux下运行相应命令)的依 **
然和操作系统没有半毛钱关系。 但是当你运行可执行程序时魔法就出现了。 此时操作系统开始将可执行文件加载到内存,解析出代码段、数据段等,并为这个程序创建运行时需 要的堆区栈区等内存区域,此时这个程序在内存中就是这样了:
image.png
最后,根据可执行文件的内容,操作系统知道该程序应该执行的第一条机器指令是什么,并将其告诉CPU ,CPU 从该程序的第一条指令开始执行,程序就这样运行起来了。
一个在内存中运行起来的程序显然和保存在磁盘上的二进制文件是不一样的,总的有个名字吧,根据“弄不懂原则”,这个名字就叫
进程
,英文名叫做Process。 我们把一个运行起来的程序叫做进程,这就是进程的由来
此时操作系统开始掌管进程,现在进程已经有了,那么操作系统是怎么管理进程的呢?

调度器与进程管理

银行想必大家都去过,实际上如果你仔细观察的话银行的办事大厅就能体现出操作系统最核心的进程管理与调度。
首先大家去银行都要排队,类似的,进程在操作系统中也是通过队列来管理的。 同时银行还按照客户的重要程度划分了优先级,大部分都是普通客户;但当你在这家银行存上几个亿 时就能升级为 VIP 客户,优先级最高,每次去银行都不用排队,优先办理你的业务。
类似的,操作系统也会为进程划分优先级,操作系统会根据进程优先级将其放到相应的队列中供调度器调度。
image.png
这就是操作系统需要实现的最核心功能。
现在准备工作已经就绪。
接下来的问题就是操作系统如何确定是否还有进程需要运行。

队列判空:一个更好的设计

从上一节我们知道,实际上操作系统是用队列来管理进程的,那么很显然,如果队列已经为空,那么
说明此时操作系统内部没有进程需要运行,这是 CPU 就空闲下来了,此时,我们需要做点什么,就
像这样:

1
2
3
if (queue.empty()) {
do_someting();
}

这些编写内核代码虽然简单,但内核中到处充斥着 if 这种异常处理的语句,这会让代码看起来一团
糟,因此更好的设计是没有异常,那么怎样才能没有异常呢?
很简单,那就是让队列永远不会空,这样调度器永远能从队列中找到一个可供运行的进程。
而这也是为什么链表中通常会有哨兵节点的原因,就是为了避免各种判空,这样既容易出错也会让代
码一团糟。
image.png
就这样,内核设计者创建了一个叫做空闲任务的进程,这个进程就是Windows 下的我们最开始看到
的“系统空闲进程”,在 Linux 下就是第0号进程。
当其它进程都处于不可运行状态时,调度器就从队列中取出空闲进程运行,显然,**空闲进程永远处于 **
就绪状态,且优先级最低
既然我们已经知道了,当系统无所事事后开始运行空闲进程,那么这个空闲进程到底在干嘛呢?
这就需要硬件来帮忙了。

一切都要归结到硬件

在计算机系统中,一切最终都要靠 CPU 来驱动,CPU 才是那个真正干活的。
image.png
原来,CPU 设计者早就考虑到系统会存在空闲的可能,因此设计了一条机器指令,这个机器指令就是
halt 指令,停止的意思。
这条指令会让部分CPU进入休眠状态,从而极大减少对电力的消耗,通常这条指令也被放到循环中执
行,原因也很简单,就是要维持这种休眠状态。
值得注意的是,halt 指令是特权指令,也就是说只有在内核态下 CPU 才可以执行这条指令,程序员
写的应用都运行在用户态,因此你没有办法在用户态让 CPU 去执行这条指令。
此外,不要把进程挂起和 halt 指令混淆,当我们调用 sleep 之类函数时,暂停运行的只是进程,此时
如果还有其它进程可以运行那么 CPU 是不会空闲下来的,当 CPU 开始执行halt指令时就意味着系统
中所有进程都已经暂停运行。

软件硬件结合

现在我们有了 halt 机器指令,同时有一个循环来不停的执行 halt 指令,这样空闲任务进程的实际上
就已经实现了,其本质上就是这个不断执行 halt 指令的循环,大功告成。
这样,当调度器在没有其它进程可供调度时就开始运行空间进程,也就是在循环中不断的执行 halt 指
令,此时 CPU 开始进入低功耗状态。
image.png
在 Linux 内核中,这段代码是这样写的:

1
2
3
4
5
while (1) {
while(!need_resched()) {
cpuidle_idle_call();
}
}

其中 cpuidle_idle_call函数最终会执行 halt 指令,注意,这里删掉了很多细节,只保留最核心代码, **
实际上 Linux 内核在实现空闲进程时还要考虑很多很多,不同类型的 CPU 可能会有深睡眠浅睡眠之
类,操作系统必须要预测出系统可能的空闲时长并以此判断要进入哪种休眠等等,但这并不是我们关
注的重点。
总的来说,这就是计算机系统空闲时 CPU 在干嘛,就是在执行这一段代码,本质上就是 CPU 在执行
halt 指令。
实际上,对于个人计算机来说,halt 可能是 CPU 执行最多的一条指令,
全世界的 CPU 大部分时间都 **
用在这条指令上了,是不是很奇怪。
更奇怪的来了,有的同学可能已经注意到了,上面的循环可以是一个while(1) 死循环,而且这个循环
里没有break语句,也没有return,那么操作系统是怎样跳出这个循环的呢
关于这个问题,我们将会在后续文章中讲解。

总结

CPU 空闲时执行特定的 halt 指令,这看上去是一个很简单的问题,但实际上由于 halt 是特权指令,
只有操作系统才可以去执行,因此 CPU 空闲时执行 halt 指令就变成了软件和硬件相结合的问题。
操作系统必须判断什么情况下系统是空闲的,这涉及到进程管理和进程调度,同时,halt 指令其实是
放到了一个 while死循环中,操作系统必须有办法能跳出循环,所以,CPU 空闲时执行 halt 指令并
没有看上去那么简单。

三、编译器是如何工作的?

对于程序员来说编译器是非常熟悉的,每天都在用,但是当你在点击“Run”这个按钮或者执行编译命令时你知道编译器是怎样工作的吗?
这篇文章就为你解答这个问题。

编译器就是一个普通程序,没什么大不了的

什么是编译器?

编译器是一个将高级语言翻译为低级语言的程序。

首先我们一定要意识到编译器就是一个普通程序,没什么大不了的。 在没有弄明白编译器如何工作之前你可以简单的把编译器当做一个黑盒子,其作用就是输入一个文本文件输出一个二进制文件。
image.png
基本上编译器经过了以下几个阶段,等等,这句话教科书上也有,但是我相信很多同学其实并没有真正理解这几个步骤到底在说些什么,为了让你彻底理解这几个步骤,我们用一个简单的例子来讲解。
假定我们有一段程序:

1
2
3
4
while (y < z) {
int x = a + b;
y += x;
}

那么编译器是怎样把这一段程序人类认识的程序转换为CPU认识的二进制机器指令呢?

提取出每一个单词:词法分析

首先编译器要把源代码中的每个“单词”提取出来,在编译技术中“单词”被称为token。其实不只是每个单词被称为一个token,除去单词之外的比如左括号、右括号、赋值操作符等都被称为token。
从源代码中提取出token的过程就被称为词法分析,Lexical Analysis。 经过一遍词法分析,编译器得到了以下token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
T_While     while
T_LeftParen (
T_Identifier y
T_Less <
T_Identifier z
T_RightParen )
T_OpenBrace {
T_Int int
T_Identifier x
T_Assign =
T_Identifier a
T_Plus +
T_Identifier b
T_Semicolon ;
T_Identifier y
T_PlusAssign +=
T_Identifier x
T_Semicolon ;
T_CloseBrace }

就这样一个磁盘中保存的字符串源代码文件就转换为了一个个的token。

这些token想表达什么意思:语法分析

有了这些token之后编译器就可以根据语言定义的语法恢复其原本的结构,怎么恢复呢?
image.png
原来,编译器在扫描出各个token后根据规则将其用树的形式表示出来,这颗树就被称为语法树

语法树是不是合理的:语义分析

有了语法树后我们还要检查这棵树是不是合法的,比如我们不能把一个整数和一个字符串相加、比较符左右两边的数据类型要相同,等等。
这一步通过后就证明了程序合法,不会有编译错误。
image.png

根据语法树生成中间代码:代码生成

语义分析之后接下来编译器遍历语法树并用另一种形式来表示,用什么来表示呢?那就是中间代码,intermediate representation code,简称IR code
上述语法树可能就会表示为这样的中间代码:

1
2
3
4
Loop: x   = a + b
y = x + y
_t1 = y < z
if _t1 goto Loop

怎么样,这实际上已经比较接近最后的机器指令了。 只不过这还不是最终形态。

中间代码优化

在生成中间代码后要对其进行优化,我们可以看到,实际上可以把x = a + b这行代码放到循环外,因为每次循环都不会改变x的值,因此优化后就是这样了:

1
2
3
4
      x   = a + b
Loop: y = x + y
_t1 = y < z
if _t1 goto Loop

中间代码优化后就可以生成机器指令了。

代码生成

将上述优化后的中间代码转换为机器指令:

1
2
3
4
      add $1, $2, $3
Loop: add $4, $1, $4
slt $6, $1, $5
beq $6, loop

最终,编译器将程序员认识的代码转换为了CPU认识的机器指令。

总结

注意这篇简短的讲解不希望给大家留下这样的印象,那就是编译器是很简单的,恰恰相反,现代编译 器是非常智能并且极其复杂的,绝不是短短一篇文章就能讲清楚的,能实现一个编译器是困难的,实现一个好的编译器更是难上加难。
本文的目的旨在以极简的方式描述编译器的工作原理,这样你就不用把编译器当做一个黑盒了,希望这篇文章能对你有所帮助。

四、函数运行时在内存中是什么样子?

在开始本篇的内容前,我们先来思考几个问题。

  1. 我们先来看一段简单的代码:
    1
    2
    3
    4
    5
    void func(int a) {
    if (a > 100000000) return;
    int arr[100] = {0};
    func(a + 1);
    }
    你能看出这段代码会有什么问题吗?
  2. 我们在上一篇文章《高性能高并发服务器是如何实现的》中提到了一项关键技术——协程,你知道协程的本质是什么吗?有的同学可能会说是用户态线程,那么什么是用户态线程,这是怎么实现的?
  3. 函数运行起来后在内存中是什么样子?
    这几个问题看似没什么关联,但这背后都指向一样东西,这就是所谓的函数运行时栈run time stack。 接下来我们就好好看看到底什么是函数运行时栈,为什么彻底理解函数运行时栈对程序员来说非常重
    要。

从进程、线程到函数调用

汽车在高速上行驶时有很多信息,像速度、位置等等,通过这些信息我们可以直观的感受汽车的运行时状态。image.png
同样的,程序在运行时也有很多信息,像有哪些程序正在运行、这些程序执行到了哪里等等,通过这些信息我们可以直观的感受系统中程序运行的状态。
其中,我们创造了进程、线程这样的概念来记录有哪些程序正在运行,关于进程和线程的概念请参见《看完这篇还不懂进程、线程与线程池你来打我》。
进程和线程的运行体现在函数执行上,函数的执行除了函数内部执行的顺序执行还有子函数调用的控制转移以及子函数执行完毕的返回。其中函数内部的顺序执行乏善可陈,重点是函数的调用。
因此接下来我们的视角将从宏观的进程和线程拉近到微观下的函数调用,重点来讨论一下函数调用是怎样实现的。

函数执行的活动轨迹:栈

玩过游戏的同学应该知道,有时你为了完成一项主线任务不得不去打一些支线的任务,支线任务中可能还有支线任务,当一个支线任务完成后退回到前一个支线任务,这是什么意思呢,举个例子你就明白了。
假设主线任务西天取经A依赖支线任务收服孙悟空B和收服猪八戒C,也就是说收服孙悟空B和收服猪八戒C完成后才能继续主线任务西天取经A;支线任务收服孙悟空B依赖任务拿到紧箍咒D,只有当任务D完成后才能回到任务B;整个任务的依赖关系如图所示:image.png
现在我们来模拟一下任务完成过程。
首先我们来到任务A,执行主线任务:
image.png
执行任务A的过程中我们发现任务A依赖任务B,这时我们暂停任务A去执行任务B:
image.png
执行任务B的时候,我们又发现依赖任务D:
image.png
执行任务D的时候我们发现该任务不再依赖任何其它任务,因此D完成后我们可以会退到前一个任
务,也就是B:
image.png
任务B除了依赖任务D外不再依赖其它任务,这样任务B完成后就可以回到任务A:
image.png
和任务D一样,C不依赖任何其它其它任务,任务C完成后就可以再次回到任务A,再之后任务A执行完毕,整个任务执行完成。
让我们来看一下整个任务的活动轨迹:
image.png
仔细观察,实际上你会发现这是一个First In Last Out 的顺序,天然适用于栈这种数据结构来处理。再仔细看一下栈顶的轨迹,也就是A、B、D、B、A、C、A,实际上你会发现这里的轨迹就是任务依赖树的遍历过程,是不是很神奇,这也是为什么树这种数据结构的遍历除了可以用递归也可以用栈来实现的原因。

A Box

函数调用也是同样的道理,你把上面的ABCD换成函数ABCD,本质不变。
因此,现在我们知道了,使用栈这种结构就可以用来保存函数调用信息。
和游戏中的每个任务一样,当函数在运行时每个函数也要有自己的一个“小盒子”,这个小盒子中保存了函数运行时的各种信息,这些小盒子通过栈这种结构组织起来,这个小盒子就被称为栈帧,stack frames,也有的称之为call stack,不管用什么命名方式,总之,就是这里所说的小盒子,这个小盒子就是函数运行起来后占用的内存,这些小盒子构成了我们通常所说的栈区。关于栈区详细的讲解你可以参考《深入理解操作系统:程序员应如何理解内存》一文。
那么函数调用时都有哪些信息呢?

控制转移

我们知道当函数A调用函数B的时候,控制从A转移到了B,所谓控制其实就是指CPU执行属于哪个函数的机器指令,CPU从开始执行属于函数A的指令切换到执行属于函数B的指令,我们就说控制从函数A转移到了函数B。
控制从函数A转移到函数B,那么我们需要有这样两个信息:
我从哪里来 (返回)
要到去哪里 (跳转)
是不是很简单,就好比你出去旅游,你需要知道去哪里,还需要记住回家的路。
函数调用也是同样的道理。当函数A调用函数B时,我们只要知道:
函数A对于的机器指令执行到了哪里 (我从哪里来,返回)
函数B第一条机器指令所在的地址 (要到哪里去,跳转)
有这两条信息就足以让CPU开始执行函数B对应的机器指令,当函数B执行完毕后跳转回函数A。
那么这些信息是怎么获取并保持的呢?
现在我们就可以打开这个小盒子,看看是怎么使用的了。
假设函数A调用函数B,如图所示:
image.png 当前,CPU执行函数A的机器指令,该指令的地址为0x400564,接下来CPU将执行下一条机器指令也
就是:

1
call 0x400540

这条机器指令是什么意思呢?
这条机器指令对应的就是我们在代码中所写的函数调用,注意call后有一条机器指令地址,注意观察上图你会看到,该地址就是函数B的第一条机器指令,从这条机器指令后CPU将跳转到函数B。
现在我们已经解决了控制跳转的“要到哪里去”问题,当函数B执行完毕后怎么跳转回来呢?
原来,call指令除了给出跳转地址之外还有这样一个作用,也就是把call指令的下一条指令的地址,也就是0x40056a push到函数A的栈帧中,如图所示:image.png
现在,函数A的小盒子变大了一些,因为装入了返回地址:
image.png
现在CPU开始执行函数B对应的机器指令,注意观察,函数B也有一个属于自己的小盒子(栈帧),可以
往里面扔一些必要的信息。
image.png
如果函数B中又调用了其它函数呢?
道理和函数A调用函数B是一样的。
让我们来看一下函数B最后一条机器指令ret,这条机器指令的作用是告诉CPU跳转到函数A保存在栈帧上的返回地址,这样当函数B执行完毕后就可以跳转到函数A继续执行了。
至此,我们解决了控制转移中“我从哪里来”的问题。

传递参数与获取返回值

函数调用与返回使得我们可以编写函数,进行函数调用。但调用函数除了提供函数名称之外还需要传递参数以及获取返回值,那么这又是怎样实现的呢?
在x86-64中,多数情况下参数的传递与获取返回值是通过寄存器来实现的。
假设函数A调用了函数B,函数A将一些参数写入相应的寄存器,当CPU执行函数B时就可以从这些寄存器中获取参数了。
同样的,函数B也可以将返回值写入寄存器,当函数B执行结束后函数A从该寄存器中就可以读取到返回值了。
我们知道寄存器的数量是有限的,当传递的参数个数多于寄存器的数量该怎么办呢?
这时那个属于函数的小盒子也就是栈帧又能发挥作用了。
原来,当参数个数多于寄存器数量时剩下的参数直接放到栈帧中,这样被调函数就可以从前一个函数的栈帧中获取到参数了
现在栈帧的样子又可以进一步丰富了,如图所示:
image.png
从图中我们可以看到,调用函数B时有部分参数放到了函数A的栈帧中,同时函数A栈帧的顶部依然保
存的是返回地址。

局部变量

我们知道在函数内部定义的变量被称为局部变量,这些变量在函数运行时被放在了哪里呢?
原来,这些变量同样可以放在寄存器中,但是当局部变量的数量超过寄存器的时候这些变量就必须放到栈帧中了。
因此,我们的栈帧内容又一步丰富了。
image.png
细心的同学可能会有这样的疑问,我们知道寄存器是共享资源可以被所有函数使用,既然可以将函数A的局部变量写入寄存器,那么当函数A调用函数B时,函数B的局部变量也可以写到寄存器,这样的话当函数B执行完毕回到函数A时寄存器的值已经被函数B修改过了,这样会有问题吧。
这样的确会有问题,因此我们在向寄存器中写入局部变量之前,一定要先将寄存器中开始的值保存起来,当寄存器使用完毕后再恢复原值就可以了。
那么我们要将寄存器中的原始值保存在哪里呢?
有的同学可能已经猜到了,没错,依然是函数的栈帧中。
image.png
最终,我们的小盒子就变成了如图所示的样子,当寄存器使用完毕后根据栈帧中保存的初始值恢复其内容就可以了。
现在你应该知道函数在运行时到底是什么样子了吧,以上就是问题3的答案。

Big Picture

需要再次强调的一点就是,上述讨论的栈帧就位于我们常说的栈区。
栈区,属于进程地址空间的一部分,如图所示,我们将栈区放大就是图左边的样子
image.png
关于栈区详细的讲解你可以参考《深入理解操作系统:程序员应如何理解内存》这篇。
最后,让我们回到文章开始的这段简单代码

1
2
3
4
5
6
7
8
void func(int a) {
if (a > 100000000) return;
int arr[100] = {0};
func(a + 1);
}
void main(){
func(0);
}

想一想这段代码会有什么问题?
原来,栈区是有大小限制的,当超过限制后就会出现著名的栈溢出问题,显然上述代码会导致这一问题的出现。
因此:

  1. 不要创建过大的局部变量
  2. 函数栈帧,也就是调用层次不能太多

总结

本章我们从几个看似没什么关联的问题出发,详细讲解了函数运行时栈是怎么一回事,为什么我们不能创建过多的局部变量。细心的同学会发现第2个问题我们没有解答,这个问题的讲解放到下一篇,也就是协程中讲解。

五、彻底理解回调函数

不知你是不是也有这样的疑惑,我们为什么需要回调函数这个概念呢?直接调用函数不就可以了?回调函数到底有什么作用?为什么回调函数正在变得越来越重要?
这篇文章就来为你解答这些问题,读完这篇文章后你的武器库将新增一件功能强大的利器

一切要从这样的需求说起

假设你们公司要开发下一代国民App“明日油条”,一款主打解决国民早餐问题的App,为了加快开发进度,这款应用由A小组和B小组协同开发。
其中有一个核心模块由A小组开发然后供B小组调用,这个核心模块被封装成了一个函数,这个函数就叫make_youtiao()。
如果make_youtiao()这个函数执行的很快并可以立即返回,那么B小组的同学只需要:

  1. 调用make_youtiao()
  2. 等待该函数执行完成
  3. 该函数执行完后继续后续流程
    从程序执行的角度看这个过程是这样的:
  4. 保存当前被执行函数的上下文
  5. 开始执行make_youtiao()这个函数
  6. make_youtiao()执行完后,控制转回到调用函数中
    image.png
    如果世界上所有的函数都像make_youtiao()这么简单,那么程序员大概率就要失业了,还好程序的世界是复杂的,这样程序员才有了存在的价值。

现实情况并不容易(同步、异步)

现实中make_youtiao()这个函数需要处理的数据非常庞大,假设有10000个,那么make_youtiao(10000)不会立刻返回,而是可能需要10分钟才执行完成并返回。
这时你该怎么办呢?想一想这个问题。
可能有的同学就像把头埋在沙子里的鸵鸟一样:和刚才一样直接调用不可以吗,这样多简单。
是的,这样做没有问题,但就像爱因斯坦说的那样“一切都应该尽可能简单,但是不能过于简单”。
想一想直接调用会有什么问题?
显然直接调用的话,那么调用线程会被阻塞暂停,在等待10分钟后才能继续运行。在这10分钟内该线程不会被操作系统分配CPU,也就是说该线程得不到任何推进。
这并不是一种高效的做法。
没有一个程序员想死盯着屏幕10分钟后才能得到结果。
那么有没有一种更加高效的做法呢?
想一想我们上一篇中那个一直盯着你写代码的老板(见《从小白到高手,你需要理解同步与异步》),我们已经知道了这种一直等待直到另一个任务完成的模式叫做同步。
如果你是老板的话你会什么都不干一直盯着员工写代码吗?因此一种更好的做法是程序员在代码的时候老板该干啥干啥,程序员写完后自然会通知老板,这样老板和程序员都不需要相互等待,这种模式被称为异步
回到我们的主题,这里一种更好的方式是调用make_youtiao()这个函数后不再等待这个函数执行完成,而是直接返回继续后续流程,这样A小组的程序就可以和make_youtiao()这个函数同时进行了,
就像这样:
image.png
在这种情况下,回调(callback)就必须出场了

为什么我们需要回调callback

有的同学可能还没有明白为什么在这种情况下需要回调,别着急,我们慢慢讲。
假设我们“明日油条”App代码第一版是这样写的:

1
2
make_youtiao(10000);
sell();

可以看到这是最简单的写法,意思很简单,制作好油条后卖出去
image.png
我们已经知道了由于make_youtiao(10000)这个函数10分钟才能返回,你不想一直死盯着屏幕10分钟等待结果,那么一种更好的方法是让make_youtiao()这个函数知道制作完油条后该干什么,即,更好的调用make_youtiao的方式是这样的:“制作10000个油条,炸好后卖出去”,因此调用make_youtiao就变成这样了:

1
make_youtiao(10000, sell);

看到了吧,现在make_youtiao这个函数多了一个参数,除了指定制作油条的数量外还可以指定制作好后该干什么,第二个被make_youtiao这个函数调用的函数就叫回调,callback。
现在你应该看出来了吧,虽然sell函数是你定义的,但是这个函数却是被其它模块调用执行的,就像这样:
image.png
make_youtiao这个函数是怎么实现的呢,很简单:

1
2
3
4
void make_youtiao(int num, func call_back) {
// 制作油条
call_back(); //执行回调
}

这样你就不用死盯着屏幕了,因为你把make_youtiao这个函数执行完后该做的任务交代给make_youtiao这个函数了,该函数制作完油条后知道该干些什么,这样就解放了你的程序。
有的同学可能还是有疑问,为什么编写make_youtiao这个小组不直接定义sell函数然后调用呢?
不要忘了明日油条这个App是由A小组和B小组同时开发的,A小组在编写make_youtiao时怎么知道B小组要怎么用这个模块,假设A小组真的自己定义sell函数就会这样写:

1
2
3
4
void make_youtiao(int num) {
real_make_youtiao(num);
sell(); //执行回调
}

同时A小组设计的模块非常好用,这时C小组也想用这个模块,然而C小组的需求是制作完油条后放到仓库而不是不是直接卖掉,要满足这一需求那么A小组该怎么写呢?

1
2
3
4
5
6
7
8
9
void make_youtiao(int num) {
real_make_youtiao(num);

if (Team_B) {
sell(); // 执行回调
} else if (Team_D) {
store(); // 放到仓库
}
}

故事还没完,假设这时D小组又想使用呢,难道还要接着添加if else吗?这样的话A小组的同学只需要维护make_youtiao这个函数就能做到工作量饱满了,显然这是一种非常糟糕的设计。
_TODO bad_图
所以你会看到,制作完油条后接下来该做什么不是实现make_youtiao的A小组该关心的事情,很明显只有调用make_youtiao这个函数的使用方才知道。
因此make_youtiao的A小组完全可以通过回调函数将接下来该干什么交给调用方实现,A小组的同学只需要针对回调函数这一抽象概念进行编程就好了,这样调用方在制作完油条后不管是卖掉、放到库存还是自己吃掉等等想做什么都可以,A小组的make_youtiao函数根本不用做任何改动,因为A小组是针对回调函数这一抽象概念来编程的。
以上就是回调函数的作用,当然这也是针对抽象而不是具体实现进行编程这一思想的威力所在。面向对象中的多态本质上就是让你用来针对抽象而不是针对实现来编程的。

异步回调

故事到这里还没有结束。
在上面的示例中,虽然我们使用了回调这一概念,也就是调用方实现回调函数然后再将该函数当做参数传递给其它模块调用。
但是,这里依然有一个问题,那就是make_youtiao函数的调用方式依然是同步的,关于同步异步请参考《从小白到高手,你需要理解同步与异步》,也就是说调用方是这样实现的:

1
2
make_youtiao(10000, sell);
// make_youtiao函数返回前什么都做不了

image.png
我们可以看到,调用方必须等待make_youtiao函数返回后才可以继续后续流程,我们再来看下make_youtiao函数的实现:

1
2
3
4
void make_youtiao(int num, func call_back) {
real_make_youtiao(num);
call_back(); //执行回调
}

看到了吧,由于我们要制作10000个油条,make_youtiao函数执行完需要10分钟,也就是说即便我们使用了回调,调用方完全不需要关心制作完油条后的后续流程,但是调用方依然会被阻塞10分钟,
这就是同步调用的问题所在。
如果你真的理解了上一节的话应该能想到一种更好的方法了。没错,那就是异步调用。
反正制作完油条后的后续流程并不是调用方该关心的,也就是说调用方并不关心make_youtiao这一函数的返回值,那么一种更好的方式是:把制作油条的这一任务放到另一个线程(进程)、甚至另一台机器上。
如果用线程实现的话,那么make_youtiao就是这样实现了:

1
2
3
4
5
6
void make_youtiao(int num, func call_back) {
// 在新的线程中执行处理逻辑
create_thread(real_make_youtiao,
num,
call_back);
}

image.png
看到了吧,这时当我们调用make_youtiao时就会立刻返回,即使油条还没有真正开始制作,而调用方也完全无需等待制作油条的过程,可以立刻执行后流程:

1
2
3
make_youtiao(10000, sell);
// 立刻返回
// 执行后续流程

这时调用方的后续流程可以和制作油条同时进行,这就是函数的异步调用,当然这也是异步的高效之处。

新的编程思维模式

让我们再来仔细的看一下这个过程。
程序员最熟悉的思维模式是这样的:

  1. 调用某个函数,获取结果
  2. 处理获取到的结果
    1
    2
    res = request();
    handle(res);
    这就是函数的同步调用,只有request()函数返回拿到结果后,才能调用handle函数进行处理,request函数返回前我们必须等待,这就是同步调用,其控制流是这样的:image.png
    但是如果我们想更加高效的话,那么就需要异步调用了,我们不去直接调用handle函数,而是作为参数传递给request:
    1
    request(handle);
    我们根本就不关心request什么时候真正的获取的结果,这是request该关心的事情,我们只需要把获取到结果后该怎么处理告诉request就可以了,因此request函数可以立刻返回,真的获取结果的处理可能是在另一个线程、进程、甚至另一台机器上完成。
    这就是异步调用,其控制流是这样的:
    image.png
    从编程思维上看,异步调用和同步有很大的差别,如果我们把处理流程当做一个任务来的话,那么同步下整个任务都是我们来实现的,但是异步情况下任务的处理流程被分为了两部分:
  3. 第一部分是我们来处理的,也就是调用request之前的部分
  4. 第二部分不是我们处理的,而是在其它线程、进程、甚至另一个机器上处理的
    我们可以看到由于任务被分成了两部分,第二部分的调用不在我们的掌控范围内,同时只有调用方才知道该做什么,因此在这种情况下回调函数就是一种必要的机制了。
    也就是说回调函数的本质就是“只有我们才知道做些什么,但是我们并不清楚什么时候去做这些,只有其它模块才知道,因此我们必须把我们知道的封装成回调函数告诉其它模块”。
    现在你应该能看出异步回调这种编程思维模式和同步的差异了吧。
    接下来我们给回调一个较为学术的定义
    **正式定义 **

    在计算机科学中,回调函数是指一段以参数的形式传递给其它代码的可执行代码。

这就是回调函数的定义了。
回调函数就是一个函数,和其它函数没有任何区别。
注意,回调函数是一种软件设计上的概念,和某个编程语言没有关系,几乎所有的编程语言都能实现回调函数。
对于一般的函数来说,我们自己编写的函数会在自己的程序内部调用,也就是说函数的编写方是我们自己,调用方也是我们自己。
但回调函数不是这样的,虽然函数编写方是我们自己,但是函数调用方不是我们,而是我们引用的其它模块,也就是第三方库,我们调用第三方库中的函数,并把回调函数传递给第三方库,第三方库中的函数调用我们编写的回调函数,如图所示:
image.png
而之所以需要给第三方库指定回调函数,是因为第三方库的编写者并不清楚在某些特定节点,比如我们举的例子油条制作完成、接收到网络数据、文件读取完成等之后该做什么,这些只有库的使用方才知道,因此第三方库的编写者无法针对具体的实现来写代码,而只能对外提供一个回调函数,库的使用方来实现该函数,第三方库在特定的节点调用该回调函数就可以了。
另一点值得注意的是,从图中我们可以看出回调函数和我们的主程序位于同一层中,我们只负责编写该回调函数,但并不是我们来调用的。
最后值得注意的一点就是回调函数被调用的时间节点,回调函数只在某些特定的节点被调用,就像上面说的油条制作完成、接收到网络数据、文件读取完成等,这些都是事件,也就是event,本质上我们编写的回调函数就是用来处理event的,因此从这个角度看回调函数不过就是event handler,因此回调函数天然适用于事件驱动编程event-driven,我们将会在后续文章中再次回到这一主题。

回调的类型

我们已经知道有两种类型的回调,这两种类型的回调区别在于回调函数被调用的时机。

同步回调

这种回调就是通常所说的同步回调synchronous callbacks、也有的将其称为阻塞式回调blocking callbacks,或者什么修饰都没有,就是回调,callback,这是我们最为熟悉的回调方式。
当我们调用某个函数A并以参数的形式传入回调函数后,在A返回之前回调函数会被执行,也就是说我们的主程序会等待回调函数执行完成,这就是所谓的同步回调。
image.png

异步回调

不同于同步回调, 当我们调用某个函数A并以参数的形式传入回调函数后,A函数会立刻返回,也就是说函数A并不会阻塞我们的主程序,一段时间后回调函数开始被执行,此时我们的主程序可能在忙其它任务,回调函数的执行和我们主程序的运行同时进行。
既然我们的主程序和回调函数的执行可以同时发生,因此一般情况下,主程序和回调函数的执行位于不同的线程或者进程中。
image.png
这就是所谓的异步回调,asynchronous callbacks,也有的资料将其称为deferred callbacks ,名字很形象,延迟回调。
从上面这两张图中我们也可以看到,异步回调要比同步回调更能充分的利用机器资源,原因就在于在同步模式下主程序会“偷懒”,因为调用其它函数被阻塞而暂停运行,但是异步调用不存在这个问题,主程序会一直运行下去。
因此,异步回调更常见于I/O操作,天然适用于Web服务这种高并发场景。

为什么异步回调这种思维模式正变得的越来越重要

在同步模式下,服务调用方会因服务执行而被阻塞暂停执行,这会导致整个线程被阻塞,因此这种编程方式天然不适用于高并发动辄几万几十万的并发连接场景,
针对高并发这一场景,异步其实是更加高效的,原因很简单,你不需要在原地等待,因此从而更好的利用机器资源,而回调函数又是异步下不可或缺的一种机制。

回调地狱,callback hell

有的同学可能认为有了异步回调这种机制应付起一切高并发场景就可以高枕无忧了。
实际上在计算机科学中还没有任何一种可以横扫一切包治百病的技术,现在没有,在可预见的将来也不会有,一切都是妥协的结果。
那么异步回调这种机制有什么问题呢?
实际上我们已经看到了,异步回调这种机制和程序员最熟悉的同步模式不一样,在可理解性上比不过同步,而如果业务逻辑相对复杂,比如我们处理某项任务时不止需要调用一项服务,而是几项甚至十几项,如果这些服务调用都采用异步回调的方式来处理的话,那么很有可能我们就陷入回调地狱中。
举个例子,假设处理某项任务我们需要调用四个服务,每一个服务都需要依赖上一个服务的结果,如果用同步方式来实现的话可能是这样的:

1
2
3
4
a = GetServiceA();
b = GetServiceB(a);
c = GetServiceC(b);
d = GetServiceD(c);

代码很清晰,很容易理解有没有。
我们知道异步回调的方式会更加高效,那么使用异步回调的方式来写将会是什么样的呢?

1
2
3
4
5
6
7
8
9
GetServiceA(function(a){
GetServiceB(a, function(b){
GetServiceC(b, function(c){
GetServiceD(c, function(d) {
....
});
});
});
});

我想不需要再强调什么了吧,你觉得这两种写法哪个更容易理解,代码更容易维护呢?
博主有幸曾经维护过这种类型的代码,不得不说每次增加新功能的时候恨不得自己化为两个分身,一个不得不去重读一边代码;另一个在一旁骂自己为什么当初选择维护这个项目。
异步回调代码稍不留意就会跌到回调陷阱中,那么有没有一种更好的办法既能结合异步回调的高效又能结合同步编码的简单易读呢?
幸运的是,答案是肯定的,我们会在后续文章中详细讲解这一技术。

总结

在这篇文章中,我们从一个实际的例子出发详细讲解了回调函数这种机制的来龙去脉,这是应对高并发、高性能场景的一种极其重要的编码机制,异步加回调可以充分利用机器资源,实际上异步回调最本质上就是事件驱动编程,这是我们接下来要重点讲解的内容。

六、自己动手实现malloc内存分配器

对内存分配器透彻理解是编程高手的标志之一
如果你不能理解malloc之类内存分配器实现原理的话,那你可能写不出高性能程序,写不出高性能程序就很难参与核心项目,参与不了核心项目那么很难升职加薪,很难升级加薪就无法走向人生巅峰, 没想到内存分配竟如此关键,为了走上人生巅峰你也要势必读完本文。
现在我们知道了,对内存分配器透彻的理解是写出高性能程序的关键所在,那么我们该怎样透彻理解内存分配器呢?
还有什么能比你自己动手实现一个理解的更透彻吗
image.png
接下来,我们就自己实现一个malloc内存分配器。读完本文后内存分配对你将不再是一个神秘的黑盒。
在讲解实现原理之前,我们需要回答一个基本问题,那就是我们为什么要发明内存分配器这种东西。

内存申请与释放

程序员经常使用的内存申请方式被称为动态内存分配,Dynamic Memory Allocation。我们为什么需要动态的去进行内存分配与释放呢?
答案很简单,因为我们不能提前知道程序到底需要使用多少内存。那我们什么时候才能知道呢?答案是只有当程序真的运行起来后我们才知道。
image.png
这就是为什么程序员需要动态的去申请内存的原因,如果能提前知道我们的程序到底需要多少内存,那么直接知道告诉编译器就好了,这样也不必发明malloc等内存分配器了。
知道了为什么要发明内存分配器的原因后,接下来我们着手实现一个。

程序员应如何看待内存

实际上,现代程序员是很幸福的,程序员很少去关心内存分配的问题。作为程序员,可以简单的认为我们的程序独占内存,注意,是独占哦
image.png
写程序时你从来没有关心过如果我们的程序占用过多内存会不会影响到其它程序,我们可以简单的认为每个程序(进程)独占4G内存(32位操作系统),即使我们的物理内存512M。不信你可以去试试,在即使只有512M大小的内存上你依然可以申请到2G内存来使用,可这是为什么呢?关于这个问题我们会在《深入理解操作系统》系列中详细阐述。
总之,程序员可以放心的认为我们的程序运行起来后在内存中是这样的:
image.png
作为程序员我们应该知道,内存动态申请和释放都发生在堆区,heap。
我们使用的malloc或者C++中的new申请内存时,就是从堆区这个区域中申请的
接下来我们就要自己管理堆区这个内存区域。
堆区这个区域实际上非常简单,真的是非常简单,你可以将其看做一大数组,就像这样:
image.png
从内存分配器的角度看,内存分配器根本不关心你是整数、浮点数、链表、二叉树等数据结构、还是对象、结构体等这些花哨的概念,在内存分配器眼里不过就是一个内存块,这些内存块中可以装入原生的字节序列,申请者拿到该内存块后可以塑造成整数、浮点数、链表、二叉树等数据结构以及对象、结构体等,这是使用者的事情,和内存分配器无关。
我们要在这片内存上解决两个问题:
实现一个malloc函数,也就是如果有人向我申请一块内存,我该怎样从堆区这片区域中找到一块返回给申请者。
实现一个free函数,也就是当某一块内存使用完毕后,我该怎样还给堆区这片区域。
这是内存分配器要解决的两个最核心的问题,接下来我们先去停车场看看能找到什么启示。

从停车场到内存管理

实际上你可以把内存看做一条长长的停车场,我们申请内存就是要找到一块停车位,释放内存就是把开走让出停车位。
image.png
只不过这个停车场比较特殊,我们不止可以停小汽车、也可以停占地面积很小的自行车以及占地面积很大的卡车,重点就是申请的内存是大小不一的,在这样的条件下你该怎样实现以下两个目标呢?
快速找到停车位,在内存申请中,这涉及到以最大速度找到一块满足要求的空闲内存
尽最大程度利用停车场,我们的停车场应该能停尽可能多的车,在内存申请中,这涉及到在给定条件下尽可能多的满足内存申请需求
现在,我们已经清楚的理解任务了,那么该怎么实现呢

任务拆分

现在我们已经明确要实现什么以及衡量其好坏的标准,接下来我们就要去设计实现细节了,让我们把任务拆分一下,怎么拆分呢?
我们可以自己想一下从内存的申请到释放需要哪些细节。
申请内存时,我们需要在内存中找到一块大小合适的空闲内存分配出去,那么我们怎么知道有哪些内存块是空闲的呢
image.png
因此,第一个实现细节出现了,我们需要把内存块用某种方式组织起来,这样我们才能追踪到每一块内存的分配状态
现在空闲内存块组织好了,那么一次内存申请可能有很多空闲内存块满足要求,那么我们该选择哪一个空闲内存块分配给用户呢?
image.png
因此,第二个实现细节出现了,我们该选择什么样的空闲内存块给到用户
接下来我们找到了一块大小合适的内存块,假设用户需要16个字节,而我们找到的这块空闲内存块大小为32字节,那么将16字节分配给用户后还剩下16字节,这剩下的内存该怎么处理呢?
因此,第三个实现细节出现了,分配出去内存后,空闲内存块剩余的空间该怎么处理?
image.png
最后,分配给用户的内存使用完毕,这是第四个细节出现了,我们该怎么处理用户还给我们的内存呢
以上四个问题是任何一个内存分配器必须要回答的,接下来我们就一一解决这些问题,解决完这些问题后一个崭新的内存分配器就诞生啦。

管理空闲内存块(负载)

空闲内存块的本质是需要某种办法来来区分哪些是空闲内存哪些是已经分配出去的内存。
有的同学可能会说,这还不简单吗,用一个链表之类的结构记录下每个空闲内存块的开始和结尾不就可以了,这句话也对也不对。
image.png
说不对,是因为如果要申请内存来创建这个链表那么这就是不对的,原因很简单,因为创建链表不可避免的要申请内存,申请内存就需要通过内存分配器,可是你要实现的就是一个内存分配器,你没有办法向一个还没有实现的内存分配器申请内存
说对也对,我们确实需要一个类似链表这样的结构来维护空闲内存块,但这个链表并不是我们常见的那种。
因为我们无法将空闲内存块的信息保存在其它地方,那么没有办法,我们只能将维护内存块的分配信息保存在内存块本身中,这也是大多数内存分配器的实现方法。
那么,为了维护内存块分配状态,我们需要知道哪些信息呢?很简单:
一个标记,用来标识该内存块是否空闲
一个数字,用来记录该内存块的大小
为了简单起见,我们的内存分配器不对内存对齐有要求,同时一次内存申请允许的最大内存块为2G,注意,这些假设是为了方便讲解内存分配器的实现而屏蔽一些细节,我们常用的malloc等不会有这样的限制
因为我们的内存块大小上限为2G,因此我们可以使用31个比特位来记录块大小,剩下的一个比特位用来标识该内存块是空闲的还是已经被分配出去了,下图中的f/a是free/allocate,也就是标记是已经分配出去还是空闲的。这32个比特位就是header,用来存储块信息。
image.png
剩下的灰色部分才是真正可以分配给用户的内存,这一部分也被称为负载,payload,我们调用malloc返回的内存起始地址正是这块内存的起始地址
现在你应该知道了吧,不是说堆上有10G内存,这里面就可以全部用来存储数据的,这里面必然有一部分要拿出来维护内存块的一些信息,就像这里的header一样。

跟踪内存分配状态

有了上图,我们就可以将堆这块内存区域组织起来并进行内存分配与释放了,如图所示:
image.png
在这里我们的堆区还很小,每一方框代表4字节,其中红色区域表示已经分配出去的,灰色区域表示空闲内存,每一块内存都有一个header,用带斜线的方框表示,比如16/1,就表示该内存块大小是16字节,1表示已经分配出去了;而32/0表示该内存块大小是32字节,0表示该内存块当前空闲。
细心的同学可能会问,那最后一个方框0/1表示什么呢?原来,我们需要某种特殊标记来告诉我们的内存分配器是不是已经到末尾了,这就是最后4字节的作用。
通过引入header我们就能知道每一个内存块的大小,从而可以很方便的遍历整个堆区。遍历方法很简单,因为我们知道每一块的大小,那么从当前的位置加上当前块的大小就是下一个内存块的起始位置,如图所示:
image.png
通过每一个header的最后一个bit位就能知道每一块内存是空闲的还是已经分配出去了,这样我们就能追踪到每一个内存块的分配信息,因此上文提到的第一个问题解决了。
接下来我们看第二个问题。

怎样选择空闲内存块

当应用程序调用我们实现的malloc时,内存分配器需要遍历整个空闲内存块找到一块能满足应用程序要求的内存块返回,就像下图这样:
image.png
假设应用程序需要申请4字节内存,从图中我们可以看到有两个空闲内存块满足要求,第一个大小为8字节的内存块和第三个大小为32字节的内存块,那么我们到底该选择哪一个返回呢?这就涉及到了分配策略的问题,实际上这里有很多的策略可供选择。

首次适应方法 First Fit

最简单的就是每次从头开始找起,找到第一个满足要求的就返回,这就是所谓的First fit方法,教科书中一般称为首次适应方法,当然我们不需要记住这样拗口的名字,只需要记住这是什么意思就可以了
image.png
这种方法的优势在于简单,但该策略总是从前面的空闲块找起,因此很容易在堆区前半部分因分配出内存留下很多小的内存块,因此下一次内存申请搜索的空闲块数量将会越来越多。

Next Fit

该方法是大名鼎鼎的Donald Knuth首次提出来的,如果你不知道谁是Donald Knuth,那么数据结构课上折磨的你痛不欲生的字符串匹配KMP算法你一定不会错过,KMP其中的K就是指Donald Knuth,该算法全称Knuth–Morris–Pratt string-searching algorithm,如果你也没听过KMP算法那么你
一定听过下面这本书:
image.png
这就是更加大名鼎鼎的《计算机程序设计艺术》,这本书就是Donald Knuth写的,如果你没有听过这本书请面壁思过一分钟,比尔盖茨曾经说过,如果你看懂了这本书就去给微软投简历吧,这本书也是很多程序员买回来后从来不会翻一眼只是拿来当做镇宅之宝用的。
不止比尔盖茨,有一次乔布斯见到Knuth老爷子后。。算了,扯远了,有机会再和大家讲这个故事,拉回来。
Next Fit说的是什么呢?这个策略和First Fit很相似,是说我们别总是从头开始找了,而是从上一次找到合适的空闲内存块的位置找起,老爷子观察到上一次找到某个合适的内存块的地方很有可能剩下的内存块能满足接下来的内存分配请求,由于不需要从头开始搜索,因此Next Fit将远快于First Fit。
image.png
然而也有研究表明Next Fit方法内存使用率不及First Fit,也就是同样的停车场面积,First Fit方法能停更多的车。
**Best Fit **
First Fit和Next Fit都是找到第一个满足要求的内存块就返回,但Best Fit不是这样。
Best Fit算法会找到所有的空闲内存块,然后将所有满足要求的并且大小为最小的那个空闲内存块返回,这样的空闲内存块才是最Best的,因此被称为Best Fit。就像下图虽然有三个空闲内存块满足要求,但是Best Fit会选择大小为8字节的空闲内存块。
image.png
显然,从直觉上我们就能得出Best Fit会比前两种方法能更合理利用内存的结论,各项研究也证实了这一点。
然而Best Fit最大的缺点就是分配内存时需要遍历堆上所有的空闲内存块,在速度上显然不及前面两种方法。
以上介绍的这三种策略在各种内存分配器中非常常见,当然分配策略远不止这几种,但这些算法不是该主题下关注的重点,因此就不在这里详细阐述了,假设在这里我们选择First Fit算法。

没有银弹

重要的是,从上面的介绍中我们能够看到,没有一种完美的策略,每一种策略都有其优点和缺点,我们能做到的只有取舍和权衡。因此,要实现一个内存分配器,设计空间其实是非常大的,要想设计出一个通用的内存分配器,就像我们常用的malloc是很不容易的。
image.png
其实不止内存分配器,在设计其它软件系统时我们也没有银弹。

分配内存

现在我们找到合适的空闲内存块了,接下来我们又将面临一个新的问题。
如果用户需要12字节,而我们的空闲内存块也恰好是12字节,那么很好,直接返回就可以了。
但是,如果用户申请12字节内存,而我们找到的空闲内存块大小为32字节,那么我们是要将这32字节的整个空闲内存块标记为已分配吗?就像这样:
image.png
这样虽然速度最快,但显然会浪费内存,形成内部碎片,也就是说该内存块剩下的空间将无法被利用到。
image.png
一种显而易见的方法就是将空闲内存块进行划分,前一部分设置为已分配,返回给内存申请者使用,后一部分变为一个新的空闲内存块,只不过大小会更小而已,就像这样:
image.png
我们需要将空闲内存块大小从32修改为16,其中消息头header占据4字节,剩下的12字节分配出去,并将标记为置为1,表示该内存块已分配。
分配出16字节后,还剩下16字节,我们需要拿出4字节作为新的header并将其标记为空闲内存块。

释放内存

到目前为止,我们的malloc已经能够处理内存分配请求了,还差最后的内存释放。
内存释放和我们想象的不太一样,该过程并不比前几个环节简单。我们要考虑到的关键一点就在于,与被释放的内存块相邻的内存块可能也是空闲的。如果释放一块内存后我们仅仅简单的将其标志位置为空闲,那么可能会出现下面的场景:
image.png
从图中我们可以看到,被释放内存的下一个内存块也是空闲的,如果我们仅仅将这16个字节的内存块标记为空闲的话,那么当下一次申请20字节时图中的这两个内存块都不能满足要求,尽管这两个空闲内存块的总数要超过20字节。
因此一种更好的方法是当应用程序向我们的malloc释放内存时,我们查看一下相邻的内存块是否是空闲的,如果是空闲的话我们需要合并空闲内存块,就像这样:
image.png
在这里我们又面临一个新的决策,那就是释放内存时我们要立即去检查能否够合并相邻空闲内存块吗?还是说我们可以推迟一段时间,推迟到下一次分配内存找不到满足要的空闲内存块时再合并相邻空闲内存块。
释放内存时立即合并空闲内存块相对简单,但每次释放内存时将引入合并内存块的开销,如果应用程序总是释放12字节然后申请12字节,然后在释放12字节等等这样重复的模式:

1
free(ptr);obj* ptr = malloc(12);free(ptr);obj* ptr = malloc(12);...

image.png
那么这种内存使用模式对立即合并空闲内存块这种策略非常不友好,我们的内存分配器会有很多的无用功。但这种策略最为简单,在这里我们依然选择使用这种简单的策略。
实际上我们需要意识到,实际使用的内存分配器都会有某种推迟合并空闲内存块的策略。

高效合并空闲内存块

合并空闲内存块的故事到这里就完了吗?问题没有那么简单。
让我们来看这样一个场景:
image.png
使用的内存块其前和其后都是空闲的,在当前的设计中我们可以很容易的知道后一个内存块是空闲的,因为我们只需要从当前位置向下移动16字节就是下一个内存块,但我们怎么能知道上一个内存块是不是空闲的呢?
image.png
我们之所以能向后跳是因为当前内存块的大小是知道的,那么我们该怎么向前跳找到上一个内存块呢?
还是我们上文提到的Donald Knuth,老爷子提出了一个很聪明的设计,我们之所以不能往前跳是因为不知道前一个内存块的信息,那么我们该怎么快速知道前一个内存块的信息呢?
image.png
Knuth老爷子的设计是这样的,我们不是有一个信息头header吗,那么我们就在该内存块的末尾再加一个信息尾,footer,footer一词用的很形象,header和footer的内容是一样的
因为上一内存块的footer和下一个内存块的header是相邻的,因此我们只需要在当前内存块的位置向上移动4直接就可以等到上一个内存块的信息,这样当我们释放内存时就可以快速的进行相邻空闲内存块的合并了。
image.png

收工

至此,我们的内存分配器就已经设计完毕了。
我们的简单内存分配器采用了First Fit分配算法;找到一个满足要求的内存块后会进行切分,剩下的作为新的内存块;同时当释放内存时会立即合并相邻的空闲内存块,同时为加快合并速度,我们引入了Donald Knuth的设计方法,为每个内存块增加footer信息。
这样,我们自己实现的内存分配就可以运行起来了,可以真正的申请和释放内存

总结

本文从0到1实现了一个简单的内存分配器,但不希望这里的阐述给大家留下内存分配器实现很简单的印象,实际上本文实现的内存分配器还有大量的优化空间,同时我们也没有考虑线程安全问题,但这些都不是本文的目的。
本文的目的在于把内存分配器的本质告诉大家,对于想理解内存分配器实现原理的同学来说这些已经足够了,而对于要编写高性能程序的同学来说实现自己的内存池是必不可少的,内存池实现也离不开这里的讨论。

七、线程池是如何实现的?

大家生活中肯定都有这样的经验,那就是大众化的产品都比较便宜,但便宜的大众产品就是一个词,普通;而可以定制的产品一般都价位不凡,这种定制的产品注定不会在大众中普及,因此定制产品就是一个词,独特。
有的同学可能会有疑问,你不是要聊技术吗?怎么又说起消费了?
原来技术也有大众货以及定制品。
通用 VS 定制 **
作为程序员(C/C++)我们知道申请内存使用的是malloc,malloc其实就是一个通用的大众货,什么场景下都可以用,
但是什么场景下都可以用就意味着什么场景下都不会有很高的性能。**
image.png
malloc性能不高的原因一在于其没有为特定场景做优化,除此之外还在于malloc看似简单,但是其调用过程是很复杂的,一次malloc的调用过程可能需要经过操作系统的配合才能完成。
那么调用malloc时底层都发生了什么呢?简单来说会有这样典型的几个步骤:

  1. malloc开始搜索空闲内存块,如果能找到一块大小合适的就分配出去
  2. 如果malloc找不到一块合适的空闲内存,那么调用brk等系统调用扩大堆区从而获得更多的空闲内存
  3. malloc调用brk后开始转入内核态,此时操作系统中的虚拟内存系统开始工作,扩大进程的堆区,注意额外扩大的这一部分内存仅仅是虚拟内存,操作系统并没有为此分配真正的物理内存
  4. brk执行结束后返回到malloc,从内核态切换到用户态,malloc找到一块合适的空闲内存后返回
    image.png
    以上就是一次内存申请的完整过程,我们可以看到,一次内存申请过程其实是非常复杂的,关于这个问题的详细讨论你可以参考这里
    既然每次分配内存都要经过这么复杂的过程,那么如果程序大量使用malloc申请内存那么该程序注定无法获得高性能
    幸好,除了大众货的malloc,我们还可以私人定制,也就是针对特定场景自己来维护内存申请和分配,这就是高性能高并发必备的内存池技术

内存池技术有什么特殊的吗?

有的同学可能会说,等等,那malloc和这里提到的内存池技术有什么区别呢?
第一个区别在于我们所说的malloc其实是标准库的一部分,位于标准库这一层;而内存池是应用程序的一部分。
image.png
其次在于定位,我们自己实现的malloc其实也是定位通用性的,通用性的内存分配器设计实现往往比较复杂,但是内存池技术就不一样了,内存池技术专用于某个特定场景,以此优化程序性能,但内存池技术的通用性是很差的,在一种场景下有很高性能的内存池基本上没有办法在其它场景也能获得高性能,甚至根本就不能用于其它场景,这就是内存池这种技术的定位。
image.png
那么内存池技术是怎样优化性能的呢?

内存池技术原理

简单来说,内存池技术一次性获取到大块内存,然后在其之上自己管理内存的申请和释放,这样就绕过了标准库以及操作系统
image.png
也就是说,通过内存池,一次内存的申请再也不用去绕一大圈了。
除此之外,我们可以根据特定的使用模式来进一步优化,比如在服务器端,每次用户请求需要创建的对象可能就那几种,那么这时我们就可以在自己的内存池上提前创建出这些对象,当业务逻辑需要时就从内存池中申请已经创建好的对象,使用完毕后还回内存池。
因此我们可以看到,这种为某些应用场景定制的内存池相比通用的比如malloc内存分配器会有大的优势。
接下来我们就着手实现一个。

实现内存池的考虑

值得注意的是,内存池实际上有很多的实现方法,在这里我们还是以服务器端编程为例来说明。
假设你的服务器程序非常简单,处理用户请求时只使用一种对象(数据结构),那么最简单的就是我们提前申请出一堆来,使用的时候拿出一个,使用完后还回去:
image.png
怎么样,足够简单吧!这样的内存池只能分配特定对象(数据结构),当然这样的内存池需要自己维护哪些对象是已经被分配出去的,哪些是还没有被使用的。
但是,在这里我们可以实现一个稍微复杂一些的,那就是可以申请不同大小的内存,而且由于是服务器端编程,那么一次用户请求过程中我们只申请内存,只有当用户请求处理完毕后一次性释放所有内存,从而将内存申请释放的开销降低到最小。
因此,你可以看到,内存池的设计都是针对特定场景的。
现在,有了初步的设计,接下来就是细节了。

数据结构

为了能够分配大小可变的对象,显然我们需要管理空闲内存块,我们可以用一个链表把所有内存块链接起来,然后使用一个指针来记录当前空闲内存块的位置,如图所示:
image.png
从图中我们可以看到,有两个空闲内存块,空闲内存之间使用链表链接起来,每个内存块都是前一个的2倍,也就是说,当内存池中的空闲内存不足以分配时我们就向malloc申请内存,只不过其大小是前一个的2倍:
image.png
其次,我们有一个指针free_ptr,指向接下来的空闲内存块起始位置,当向内存池分配内存时找到free_ptr并判断当前内存池剩余空闲是否足够就可以了,有就分配出去并修改free_ptr,否则向malloc再次成倍申请内存。
从这里的设计可以看出,我们的内存池其实是不会提供类似free这样的内存释放函数的,如果要释放内存,那么会一次性将整个内存池释放掉,这一点和通用的内存分配器是不一样。
现在,我们可以分配内存了,还有一个问题是所有内存池设计不得不考虑的,那就是线程安全,这个话题你可以参考这里

线程安全

显然,内存池不应该局限在单线程场景,那我们的内存池要怎样实现线程安全呢?
有的同学可能会说这还不简单,直接给内存池一把锁保护就可以了。
image.png
这种方法是不是可行呢?还是那句话,It depends,要看情况。
如果你的程序有大量线程申请释放内存,那么这种方案下锁的竞争将会非常激烈,线程这样的场景下使用该方案不会有很好的性能。
那么还有没有一种更好的办法吗?答案是肯定的

线程局部存储

既然多线程使用线程池存在竞争问题,那么干脆我们为每个线程维护一个内存池就好了,这样多线程间就不存在竞争问题了。
那么我们该怎样为每个线程维护一个内存池呢?
线程局部存储,Thread Local Storage正是用于解决这一类问题的,什么是线程局部存储呢?
简单说就是,我们可以创建一个全局变量,因此所有线程都可以使用该全局变量,但与此同时,我们将该全局变量声明为线程私有存储,那么这时虽然所有线程依然看似使用同一个全局变量,但该全局变量在每个线程中都有自己的副本,变量指向的值是线程私有的,相互之间不会干扰
image.png
关于线程局部存储,可以参考这里
假设这个全局变量是一个整数,变量名字为global_value,初始值为100,那么当线程A将global_value修改为200时,线程B看到的global_value的值依然为100,只有线程A看到的global_value为200,这就是线程局部存储的作用。

线程局部存储+内存池

有了线程局部存储问题就简单了,我们可以将内存池声明为线程局部存储,这样每个线程都只会操作属于自己的内存池,这样就再也不会有锁竞争问题了。
image.png
注意,虽然这里给出了线程局部存储的设计,但并不是说加锁的方案就比不上线程局部存储方案,还是那句话,一切要看使用场景,如果加锁的方案够用,那么我们就没有必要绞尽脑汁的去用其它方案,因为加锁的方案更简单,代码也更容易维护。
还需要提醒的是,这里只是给出了内存池的一种实现方法,并不是说所有内存池都要这么设计,内存池可以简单也可复杂,一切要看实际场景,这一点也需要注意。

其它内存池形式

到目前为止我们给出了两种内存池的设计方法,第一种是提前创建出一堆需要的对象(数据结构),自己维护好哪些对象(数据结构)可用哪些已被分配;第二种可以申请任意大小的内存空间,使用过程中只申请不释放,最后一次性释放。这两种内存池天然适用于服务器端编程。
最后我们再来介绍一种内存池实现技术,这种内存池会提前申请出一大段内存,然后将这一大段内存切分为大小相同的小内存块。
image.png
然后我们自己来维护这些被切分出来的小内存块哪些是空闲的哪些是已经被分配的,比如我们可以使用栈这种数据结构,最初把所有空闲内存块地址push到栈中,分配内存是就pop出来一个,用户使用完毕后再push回栈里。
image.png
从这里的设计我们可以看出,这种内存池有一个限制,这个限制就是说程序申请的最大内存不能超过这里内存块的大小,否则不足以装下用户数据,这需要我们对程序所涉及的业务非常了解才可以。
用户申请到内存后根据需要将其塑造成特定对象(数据结构)。
关于线程安全的问题,可以同样采用线程局部存储的方式来实现:
image.png

一个有趣的问题

除了线程安全,这里还有一个非常有趣的问题,那就是如果线程A申请的对象被线程B拿去释放,我们的内存池该怎么处理呢?
这个问题之所以有趣是因为我们必须知道该内存属于哪个线程的局部存储,但申请的内存本身并不能告诉你这样的信息
有的同学可能会说这还不简单,不就是一个指针到另一个指针的映射吗,直接用map之类存起来就好了,但问题并没有这么简单,原因就在于如果我们切分的内存块很小,那么会存在大量内存块,这就需要存储大量的映射关系,有没有办法改进呢?
改进方法是这样的,一般来说,我们申请到的大段内存其实是会按照特定大小进行内存对齐,我们假设总是按照4K字节对齐,那么该大段内存的起始地址后12个bit(4K = 2^12)为总是0,比如地址0x9abcd000,同时我们也假设申请到的大段内存大小也是4K:
image.png
那么我们就能知道该大段内存中的各个小内存块起始地址除了后12个bit位外都是一样的:
image.png
这样拿到任意一个内存的地址我们就能知道对应的大段内存的起始地址,只需要简单的将后12个bit置为0即可,有了大段内存的起始地址剩下的就简单了,我们可以在大段内存中的最后保存对应的线程局部存储信息:
image.png
这样我们对任意一个内存块地址进行简单的位运算就可以得到对应的线程局部存储信息,大大减少了维护映射信息对内存的占用。

总结

内存池是高性能服务器中常见的一种优化技术,在这里我们介绍了三种实现方法,值得注意的是,内存池实现没有统一标准,一切都要根据具体场景定制,因此我们可以看到内存池设计是有针对性的,当然其反面就是不具备通用性。

八、线程安全代码到底是怎么编写的?

相信有很多同学在面对多线程代码时都会望而生畏,认为多线程代码就像一头难以驯服的怪兽,你制服不了这头怪兽它就会反过来吞噬你。
夸张了哈,总之,多线程程序有时就像一潭淤泥,走不进去退不出来。
可这是为什么呢?为什么多线程代码如此难以正确编写呢

从根源上思考

关于这个问题,本质上是有一个词语你没有透彻理解,这个词就是所谓的线程安全,thread safe。
如果你不能理解线程安全,那么给你再多的方案也是无用武之地
接下来我们了解一下什么是线程安全,怎样才能做到线程安全。
这些问题解答后,多线程这头大怪兽自然就会变成温顺的小猫咪。

关你什么屁事

生活中我们口头上经常说的一句话就是“关你屁事”,大家想一想,为什么我们的屁事不关别人?
原因很简单,这是我的私事啊!我的衣服、我的电脑,我的手机、我的车子、我的别墅以及私人泳池(可以没有,但不妨碍想象),我想怎么处理就怎么处理,妨碍不到别人,只属于我一个人的东西以及事情当然不关别人,即使是屁事也不关别人
image.png
我们在自己家里想吃什么吃什么,想去厕所就去厕所!因为这些都是我私有的,只有我自己使用
那么什么时候会和其它人有交集呢?
答案就是公共场所
在公共场所下你不能像在自己家里一样想去哪就去哪,想什么时候去厕所就去厕所,为什么呢?原因很简单,因为公共场所下的饭馆、卫生间不是你家的,这是公共资源,大家都可以使用的公共资源。
如果你想去饭馆、去公共卫生间那么就必须遵守规则,这个规则就是排队,只有前一个人用完公共资源后下一个人才可以使用,而且不能同时使用,想使用就必须排队等待
上面这段话道理足够简单吧。
如果你能理解这段话,那么驯服多线程这头小怪兽就不在话下。

维护公共场所秩序

如果把你自己理解为线程的话,那么在你自己家里使用私有资源就是所谓的线程安全,原因很简单,因为你随便怎么折腾自己的东西(资源)都不会妨碍到别人
但到公共场所浪的话就不一样了,在公共场所使用的是公共资源,这时你就不能像在自己家里一样想怎么用就怎么用想什么时候用就什么时候用,公共场所必须有相应规则,这里的规则通常是排队,只有这样公共场所的秩序才不会被破坏,线程以某种不妨碍到其它线程的秩序使用共享资源就能实现线程安全。
因此我们可以看到,这里有两种情况:
线程私有资源,没有线程安全问题
共享资源,线程间以某种秩序使用共享资源也能实现线程安全。
本文都是围绕着上述两个核心点来讲解的,现在我们就可以正式的聊聊编程中的线程安全了。

什么是线程安全

我们说一段代码是线程安全的,当且仅当我们在多个线程中同时且多次调用的这段代码都能给出正确的结果,这样的代码我们才说是线程安全代码,Thread Safety,否则就不是线程安全代码,thread unsafe.。
非线程安全的代码其运行结果是由掷骰子决定的。
image.png
怎么样,线程安全的定义很简单吧,也就是说你的代码不管是在单个线程还是多个线程中被执行都应该能给出正确的运行结果,这样的代码是不会出现多线程问题的,就像下面这段代码:

1
2
3
4
5
int func() {
int a = 1;
int b = 1;
return a + b;
}

对于这样段代码,无论你用多少线程同时调用、怎么调用、什么时候调用都会返回2,这段代码就是线程安全的。
那么我们该怎样写出线程安全的代码呢?
要回答这个问题,我们需要知道我们的代码什么时候呆在自己家里使用私有资源,什么时候去公共场所浪使用公共资源,也就是说你需要识别线程的私有资源和共享资源都有哪些,这是解决线程安全问题的核心所在。
image.png

线程私有资源

线程都有哪些私有资源呢?啊哈,我们在上一篇《线程到底共享了哪些进程资源》中详细讲解了这个问题。
线程运行的本质其实就是函数的执行,函数的执行总会有一个源头,这个源头就是所谓的入口函数,CPU从入口函数开始执行从而形成一个执行流,只不过我们人为的给执行流起一个名字,这个名字就叫线程。
既然线程运行的本质就是函数的执行,那么函数运行时信息都保存在哪里呢
答案就是栈区,每个线程都有一个私有的栈区,因此在栈上分配的局部变量就是线程私有的,无论我们怎样使用这些局部变量都不管其它线程屁事。
image.png
线程私有的栈区就是线程自己家

线程间共享数据

除了上一节提到的剩下的区域就是公共场合了,这包括:
用于动态分配内存的堆区,我们用C/C++中的malloc或者new就是在堆区上申请的内存
全局区,这里存放的就是全局变量
文件,我们知道线程是共享进程打开的文件
image.png
有的同学可能说,等等,在上一篇文章不是说还有代码区和动态链接库吗?
要知道这两个区域是不能被修改的,也就是说这两个区域是只读的,因此多个线程使用是没有问题的。
在刚才我们提到的堆区、数据区以及文件,这些就是所有的线程都可以共享的资源,也就是公共场所,线程在这些公共场所就不能随便浪了。
线程使用这些共享资源必须要遵守秩序,这个秩序的核心就是对共享资源的使用不能妨碍到其它线程,无论你使用各种锁也好、信号量也罢,其目的都是在维护公共场所的秩序。
知道了哪些是线程私有的,哪些是线程间共享的,接下来就简单了。
值得注意的是,关于线程安全的一切问题全部围绕着线程私有数据与线程共享数据来处理,抓住了线程私有资源和共享资源这个主要矛盾也就抓住了解决线程安全问题的核心
接下来我们看下在各种情况下该怎样实现线程安全,依然以C/C++代码为例,但是这里讲解的方法适用于任何语言,请放心,这些代码足够简单。

只使用线程私有资源

我们来看这段代码:

1
int func() { int a = 1; int b = 1; return a + b;}

这段代码在前面提到过,无论你在多少个线程中怎么调用什么时候调用,func函数都会确定的返回2,该函数不依赖任何全局变量,不依赖任何函数参数,且使用的局部变量都是线程私有资源,这样的代码也被称为无状态函数,stateless,很显然这样的代码是线程安全的。
这样的代码请放心大胆的在多线程中使用,不会有任何问题。
有的同学可能会说,那如果我们还是使用线程私有资源,但是传入函数参数呢?

线程私有资源+函数参数

这样的代码是线程安全的吗?自己先想一想这个问题。
答案是it depends,也就是要看情况。看什么情况呢?

1,按值传参

如果你传入的参数的方式是按值传入,那么没有问题,代码依然是线程安全的:

1
int func(int num) { num++; return num;}

这这段代码无论在多少个线程中调用怎么调用什么时候调用都会正确返回参数加1后的值。原因很简单,按值传入的这些参数是线程私有资源。

2,按引用传参

但如果是按引用传入参数,那么情况就不一样了:

1
2
3
4
int func(int* num) {
++(*num);
return *num;
}

如果调用该函数的线程传入的参数是线程私有资源,那么该函数依然是线程安全的,能正确的返回参数加1后的值。
但如果传入的参数是全局变量,就像这样:

1
2
3
4
int global_num = 1;
int func(int* num) { ++(*num); return *num;}
// 线程1void thread1() { func(&global_num);}
// 线程2void thread1() { func(&global_num);}

那此时func函数将不再是线程安全代码,因为传入的参数指向了全局变量,这个全局变量是所有线程可共享资源,这种情况下如果不改变全局变量的使用方式,那么对该全局变量的加1操作必须施加某种秩序,比如加锁。
image.png
有的同学可能会说如果我传入的不是全局变量的指针(引用)是不是就不会有问题了?
答案依然是it depends,要看情况。
即便我们传入的参数是在堆上(heap)用malloc或new出来的,依然可能会有问题,为什么?
答案很简单,因为堆上的资源也是所有线程可共享的
假如有两个线程调用func函数时传入的指针(引用)指向了同一个堆上的变量,那么该变量就变成了这两个线程的共享资源,在这种情况下func函数依然不是线程安全的。
改进也很简单,那就是每个线程调用func函数传入一个独属于该线程的资源地址,这样各个线程就不会妨碍到对方了,因此,写出线程安全代码的一大原则就是能用线程私有的资源就用私有资源,线程之间尽最大可能不去使用共享资源
如果线程不得已要使用全局资源呢?

使用全局资源

使用全局资源就一定不是线程安全代码吗?
答案还是。。有的同学可能已经猜到了,答案依然是要看情况。
如果使用的全局资源只在程序运行时初始化一次,此后所有代码对其使用都是只读的,那么没有问题,就像这样:

1
2
3
4
int global_num = 100; //初始化一次,此后没有其它代码修改其值
int func() {
return global_num;
}

我们看到,即使func函数使用了全局变量,但该全局变量只在运行前初始化一次,此后的代码都不会对其进行修改,那么func函数依然是线程安全的。
但,如果我们简单修改一下func:

1
2
int global_num = 100; 
int func() { ++global_num; return global_num;}

这时,func函数就不再是线程安全的了,对全局变量的修改必须加锁保护。

线程局部存储

接下来我们再对上述func函数简单修改:

1
2
3
4
5
__thread int global_num = 100; 
int func() {
++global_num;
return global_num;
}

我们看到全局变量global_num前加了关键词__thread修饰,这时,func代码就是又是线程安全的了。
为什么呢?
其实在上一篇文章中我们讲过,被__thread关键词修饰过的变量放在了线程私有存储中,Thread Local Storage,什么意思呢?
意思是说这个变量是线程私有的全局变量:
global_num是全局变量
global_num是线程私有的
image.png
各个线程对global_num的修改不会影响到其它线程,因为是线程私有资源,因此func函数是线程安全的。
说完了局部变量、全局变量、函数参数,那么接下来就到函数返回值了。

函数返回值

这里也有两种情况,一种是函数返回的是值;另一种返回对变量的引用。

1,返回的是值

我们来看这样一段代码:

1
int func() { int a = 100; return a;}

毫无疑问,这段代码是线程安全的,无论我们怎样调用该函数都会返回确定的值100。

2,返回的是引用

我们把上述代码简单的改一改:

1
int* func() { static int a = 100; return &a;}

如果我们在多线程中调用这样的函数,那么接下来等着你的可能就是难以调试的bug以及漫漫的加班长夜。。
image.png
很显然,这不是线程安全代码,产生bug的原因也很简单,你在使用该变量前其值可能已经被其它线程修改了。因为该函数使用了一个静态全局变量,只要能拿到该变量的地址那么所有线程都可以修改该变量的值,因为这是线程间的共享资源,不到万不得已不要写出上述代码,除非老板拿刀架在你脖子上。
但是,请注意,有一个特例,这种使用方法可以用来实现设计模式中的单例模式,就像这样:

1
2
3
4
5
6
7
8
class S {
public:
static S& getInstance() {
static S instance;
return instance;
}
private: S() {} // 其它省略
}

为什么呢?
因为无论我们调用多少次func函数,static局部变量都只会被初始化一次,这种特性可以很方便的让我们实现单例模式。
最后让我们来看下这种情况,那就是如果我们调用一个非线程安全的函数,那么我们的函数是线程安全的吗?

调用非线程安全代码

假如一个函数A调用另一个函数B,但B不是线程安全,那么函数A是线程安全的吗?
答案依然是,要看情况。
我们看下这样一段代码,这段代码在之前讲解过:

1
2
int global_num = 0;
int func() { ++global_num; return global_num;}

我们认为func函数是非线程安全的,因为func函数使用了全局变量并对其进行了修改,但如果我们这样调用func函数:

1
2
3
4
5
6
int funcA() {
mutex l;
l.lock();
func();
l.unlock();
}

虽然func函数是非线程安全的,但是我们在调用该函数前加了一把锁进行保护,那么这时funcA函数就是线程安全的了,其本质就是我们用一把锁间接的保护了全局变量。
再看这样一段代码:

1
2
3
4
int func(int *num) {
++(*num);
return *num;
}

一般我们认为func函数是非线程安全的,因为我们不知道传入的指针是不是指向了一个全局变量,但如果调用func函数的代码是这样的:

1
2
3
4
void funcA() {
int a = 100;
func(&a);
}

那么这时funcA函数依然是线程安全的,因为传入的参数是线程私有的局部变量,无论多少线程调用funcA都不会干扰到其它线程。
看了各种情况下的线程安全问题,最后让我们来总结一下实现线程安全代码都有哪些措施。

如何实现线程安全

从上面各种情况的分析来看,实现线程安全无外乎围绕线程私有资源和线程共享资源这两点,你需要识别出哪些是线程私有,哪些是共享的,这是核心,然后对症下药就可以了。
不使用任何全局资源,只使用线程私有资源,这种通常被称为无状态代码
线程局部存储,如果要使用全局资源,是否可以声明为线程局部存储,因为这种变量虽然是全局的,但每个线程都有一个属于自己的副本,对其修改不会影响到其它线程
只读,如果必须使用全局资源,那么全局资源是否可以是只读的,多线程使用只读的全局资源不会有线程安全问题。
原子操作,原子操作是说其在执行过程中是不可能被其它线程打断的,像C++中的std::atomic修饰过的变量,对这类变量的操作无需传统的加锁保护,因为C++会确保在变量的修改过程中不会被打断。**我们常说的各种无锁数据结构通常是在这类原子操作的基础上构建的 **。
同步互斥,到这里也就确定了你必须要以某种形式使用全局资源,那么在这种情况下公共场所的秩序必须得到维护,那么怎么维护呢?通过同步或者互斥的方式,这是一大类问题,我们将在《深入理解操作系统》系列文章中详细阐述这一问题。

总结

怎么样,想写出线程安全的还是不简单的吧,如果本文你只能记住一句话的话,那么我希望是这句,
这也是本文的核心:**实现线程安全无外乎围绕线程私有资源和线程共享资源来进行,你需要识别出哪些是线程私有,哪些是共享的,然后对症下药就可以了。 **

九、程序员应如何理解协程(待看)

普通的函数

从普通函数到协程

Show Me The Code

图形化解释

函数只是协程的一种特例

协程的历史

协程是如何实现的

总结

十、10个内存引发的大坑(待看)

返回局部变量地址

错误的理解指针运算

解引用有问题的指针

读取未初始化的内存

内存泄漏

引用已被释放的内存

循环遍历是0开始的

指针大小与指针所指向对象的大小不同

栈缓冲器溢出

操作指针所指对象而非指针本身

总结

十一、CPU是如何读写内存的?

看一下这个段代码:

1
int a = mem[2];

这是一段简单内存读取代码,可就是这段代码底层发生了什么呢?
如果你觉得这是一个非常简单的问题,那么你真应该好好读读本文,我敢保证这个问题绝没有你想象的那么简单
注意,一定要完本文,否则可能会得出错误的结论
闲话少说,让我们来看看CPU在读写内存时底层究竟发生了什么。
image.png

谁来告诉CPU读写内存

我们第一个要搞清楚的问题是:谁来告诉CPU去读写内存?
答案很明显,是程序员,更具体的是编译器。
CPU只是按照指令按部就班的执行,机器指令从哪里来的呢?是编译器生成的,程序员通过高级语言编写程序,编译器将其翻译为机器指令,机器指令来告诉CPU去读写内存。
在精简指令集架构下会有特定的机器指令,Load/Store指令来读写内存,以x86为代表的复杂指令集架构下没有特定的访存指令。
精简指令集下,一条机器指令操作的数据必须来存放在寄存器中,不能直接操作内存数据,因此RISC下,数据必须先从内存搬运到寄存器,这就是为什么RISC下会有特定的Load/Store访存指令。
而x86下无此限制,一条机器指令操作的数据可以来自于寄存器也可以来自内存,因此这样一条机器指令在执行过程中会首先从内存中读取数据。
关于复杂指令集以及精简指令集你可以参考这两篇文章《CPU进化论:复杂指令集》与《不懂精简指令集还敢说自己是程序员?

两种内存读写

现在我们知道了,是特定的机器指令告诉CPU要去访问内存。
不过,值得注意的是,不管是RISC下特定的Load/Store指令还是x86下包含在一条指令内部的访存操作,这里读写的都是内存中的数据,除此之外还要意识到,CPU除了从内存中读写数据外,还要从内存中读取下一条要执行的机器指令。
毕竟,我们的计算设备都遵从冯诺依曼架构:程序和数据一视同仁,都可以存放在内存中
image.png
现在,我们清楚了CPU读写内存其实是由两个因素来驱动的:

  1. 程序执行过程中需要读写来自内存中的数据
  2. CPU需要访问内存读取下一条要执行的机器指令
    然后CPU根据机器指令中包含的内存地址或者PC寄存器中下一条机器指令的地址访问内存。
    这不就完了吗?有了内存地址,CPU利用硬件通路直接读内存就好了,你可能也是这样的想的。
    真的是这样吗?别着急,我们接着往下看,这两节只是开胃菜,正餐才刚刚开始。

急性子吃货 VS 慢性子厨师

假设你是一个整天无所事事的吃货,整天无所事事,唯一的爱好就是找一家餐厅吃吃喝喝,由于你是职业吃货,因此吃起来非常职业,1分钟就能吃完一道菜,但这里的厨师就没有那么职业了,炒一道菜速度非常慢,大概需要1小时40分钟才能炒出一道菜,速度比你慢了100倍,如果你是这个吃货,大概率会疯掉的。
而CPU恰好就是这样一个吃货,内存就是这样一个慢吞吞的厨师,而且随着时间的推移这两者的速度差异正在越来越大:
image.png
在这种速度差异下,CPU执行一条涉及内存读写指令时需要等“很长一段时间“数据才能”缓缓的“从内存读取到CPU中,在这种情况你还认为CPU应该直接读写内存吗

无处不在的28定律(缓存Cache)

28定律我想就不用多介绍了吧,在《不懂精简指令集还敢说自己是程序员》这篇文章中也介绍过,CPU执行指令符合28定律,大部分时间都在执行那一少部分指令,这一现象的发现奠定了精简指令集设计的基础。
而程序操作的数据也符合类似的定律,只不过不叫28定律,而是叫principle of locality,程序局部性原理
如果我们访问内存中的一个数据A,那么很有可能接下来再次访问到,同时还很有可能访问与数据A相邻的数据B,这分别叫做时间局部性空间局部性
image.png
如图所示,该程序占据的内存空间只有一少部分在程序执行过程经常用到
有了这个发现重点就来了,既然只用到很少一部分,那么我们能不能把它们集中起来呢?就像这样:
image.png
集中起来然后呢?放到哪里呢?
当然是放到一种比内存速度更快的存储介质上,这种介质就是我们熟悉的SRAM,普通内存一般是DRAM,这种读写速度更快的介质充当CPU和内存之间的Cache,这就是所谓的缓存。

四两拨千斤

我们把经常用到的数据放到cache中存储,CPU访问内存时首先查找cache,如果能找到,也就是命中,那么就赚到了,直接返回即可,找不到再去查找内存并更新cache。
我们可以看到,有了cache,CPU不再直接与内存打交道了
image.png
但cache的快速读写能力是有代价的,代价就是Money,造价不菲,因此我们不能把内存完全替换成cache的SRAM,那样的计算机你我都是买不起的
因此cache的容量不会很大,但由于程序局部性原理,因此很小的cache也能有很高的命中率,从而带来性能的极大提升,有个词叫四两拨千斤,用到cache这里再合适不过。

天下没有免费的午餐

虽然小小的cache能带来性能的极大提升,但,这也是有代价的。
这个代价出现在写内存时。
当CPU需要写内存时该怎么办呢?
现在有了cache,CPU不再直接与内存打交道,因此CPU直接写cache,但此时就会有一个问题,那就是cache中的值更新了,但内存中的值还是旧的,这就是所谓的不一致问题,inconsistent.
就像下图这样,cache中变量的值是4,但内存中的值是2。
image.png

同步缓存更新

常用 redis 的同学应该很熟悉这个问题,可是你知道吗?这个问题早就在你读这篇文章用的计算设备其包含的CPU中已经遇到并已经解决了。 **
最简单的方法是这样的,当我们更新cache时一并把内存也更新了,这种方法被称为 write-through,很形象吧。
可是如果当CPU写cache时,cache中没有相应的内存数据该怎么呢?这就有点麻烦了,首先我们需要把该数据从内存加载到cache中,然后更新cache,再然后更新内存。
image.png
这种实现方法虽然简单,但有一个问题,那就是性能问题,在这种方案下
写内存就不得不访问内存**,上文也提到过CPU和内存可是有很大的速度差异哦,因此这种方案性能比较差。
有办法解决吗?答案是肯定的。

异步更新缓存

这种方法性能差不是因为写内存慢,写内存确实是慢,更重要的原因是CPU在同步等待,因此很自然的,这类问题的统一解法就是把同步改为异步。
关于同步和异步的话题,你可以参考这篇文章《从小白到高手,你需要理解同步和异步》。
异步的这种方法是这样的,当CPU写内存时,直接更新cache,然后,注意,更新完cache后CPU就可以认为写内存的操作已经完成了,尽管此时内存中保存的还是旧数据。
当包含该数据的cache块被剔除时再更新到内存中,这样CPU更新cache与更新内存就解耦了,也就是说,CPU更新cache后不再等待内存更新,这就是异步,这种方案也被称之为write-back,这种方案相比write-through来说更复杂,但很显然,性能会更好。
image.png
现在你应该能看到,添加cache后会带来一系列问题,更不用说cache的替换算法,毕竟cache的容量限,当cache已满时,增加一项新的数据就要剔除一项旧的数据,那么该剔除谁就是一个非常关键的问题,限于篇幅就不在这里详细讲述了,你可以参考《深入理解操作系统》第7章有关于该策略的讲解。

多级cache

现代CPU为了增加CPU读写内存性能,已经在CPU和内存之间增加了多级cache,典型的有三级,L1、L2和L3,CPU读内存时首先从L1 cache找起,能找到直接返回,否则就要在L2 cache中找,L2 cache中找不到就要到L3 cache中找,还找不到就不得不访问内存了。
因此我们可以看到,现代计算机系统CPU和内存之间其实是有一个cache的层级结构的
image.png
越往上,存储介质速度越快,造价越高容量也越小;越往下,存储介质速度越慢,造价越低但容量也越大。
现代操作系统巧妙的利用cache,以最小的代价获得了最大的性能。
但是,注意这里的但是,要想获得极致性能是有前提的,那就是程序员写的程序必须具有良好的局部性,充分利用缓存
高性能程序在充分利用缓存这一环节可谓绞尽脑汁煞费苦心。
鉴于cache的重要性,现在增大cache已经成为提升CPU性能的重要因素,因此你去看当今的CPU布局,其很大一部分面积都用在了cache上。
image.png
你以为这就完了吗?
哈哈,哪有这么容易的,否则也不会是终面题目了。
那么当CPU读写内存时除了面临上述问题外还需要处理哪些问题呢?

多核,多问题

当摩尔定律渐渐失效后鸡贼的人类换了另一种提高CPU性能的方法,既然单个CPU性能不好提升了,我们还可以堆数量啊,这样,CPU进入多核时代,程序员开始进入苦逼时代。
拥有一堆核心的CPU其实是没什么用的,关键需要有配套的多线程程序才能真正发挥多核的威力,但写过多线程程序的程序员都知道,能写出来不容易,能写出来并且能正确运行更不容易,关于多线程与多线程编程的详细阐述请参见《深入理解操作系统》第5、6两章(关注公众号“码农的荒岛求生”并回复“操作系统”)。
CPU开始拥有多个核心后不但苦逼了软件工程师,硬件工程师也不能幸免。
前文提到过,为提高CPU 访存性能,CPU和内存之间会有一个层cache,但当CPU有多个核心后新的问题来了:
image.png
现在假设内存中有一变量X,初始值为2。
系统中有两个CPU核心C1和C2,现在C1和C2要分别读取内存中X的值,根据cache的工作原理,首次读取X不能命中cache,因此从内存中读取到X后更新相应的cache,现在C1 cache和C2 cache中都有变量X了,其值都是2。
接下来C1需要对X执行+2操作,同样根据cache的工作原理,C1从cache中拿到X的值+2后更新cache,在然后更新内存,此时C1 cache和内存中的X值都变为了4。
image.png
然后C2也许需要对X执行加法操作,假设需要+4,同样根据cache的工作原理,C2从cache中拿到X的值+4后更新cache,此时cache中的值变为了6(2+4),再更新内存,此时C2 cache和内存中的X值都变为了6。
image.png
看出问题在哪里了吗?
一个初始值为2的变量,在分别+2和+4后正确的结果应该是2+2+4 = 8,但从上图可以看出内存中X的值却为6,问题出在哪了呢?

多核cache一致性

有的同学可能已经发现了,问题出在了内存中一个X变量在C1和C2的cache中有共计两个副本,当C1更新cache时没有同步修改C2 cache中X的值
image.png
解决方法是什么呢?
显然,如果一个cache中待更新的变量同样存在于其它核心的cache,那么你需要一并将其它cache也更新好。
现在你应该看到,CPU更新变量时不再简单的只关心自己的cache和内存,你还需要知道这个变量是不是同样存在于其它核心中的cache,如果存在需要一并更新。
当然,这还只是简单的读,写就更加复杂了,实际上,现代CPU中有一套协议来专门维护缓存的一致性,比较经典的包括MESI协议等。
为什么程序员需要关心这个问题呢?原因很简单,你最好写出对cache一致性协议友好的程序因为cache频繁维护一致性也是有性能代价的
同样的,限于篇幅,这个话题不再详细阐述,该主题同样值得单独成篇,敬请期待。
够复杂了吧! **
怎么样?到目前为止,是不是CPU读写内存没有看上去那么简单?
现代计算机中CPU和内存之间有多级cache,
CPU读写内存时不但要维护cache和内存的一致性,同样需要维护多核间cache的一致性
image.png
你以为这就完了,NONO,最大的谜团其实是接下来要讲的。
你以为的不是你以为的 **
现代程序员写程序基本上不需要关心
内存是不是足够这个问题
,但这个问题在远古时代绝对是困扰程序员的一大难题。
如果你去想一想,其实现代计算机内存也没有足够大的让我们随便申请的地步,但是你在写程序时是不是基本上没有考虑过内存不足该怎么办? **
为什么我们在内存资源依然处于匮乏的现代可以做到申请内存时却进入内存极大丰富的共产主义理想社会了呢?
原来这背后的功臣是我们熟悉的
操作系统
操作系统对每个进程都维护一个假象,即,每个进程独占系统内存资源;同时给程序员一个承诺,让程序员可以认为在写程序时有一大块连续的内存可以使用。
这当然是不可能不现实的,因此操作系统给进程的地址空间必然不是真的,但我们又不好将其称之为“
假的地址空间”,这会让人误以为计算机科学界里骗子横行,因此就换了一个好听的名字,虚拟内存,一个“假的地址空间**”更高级的叫法。
进程其实一直活在操作系统精心维护的幻觉当中,就像《盗梦空间》一样,关于虚拟内存的详尽阐述请参见《深入理解操作系统》第七章(关注公众号“码农的荒岛求生”并回复“操作系统”)。
image.png
从这个角度看,其实最擅长包装的是计算机科学界,哦,对了,他们不但擅长包装还擅长抽象。

天真的CPU

CPU真的是很傻很天真的存在。
上一节讲的操作系统施加的障眼法把CPU也蒙在鼓里。
CPU执行机器指令时,指令指示CPU从内存地址A中取出数据,然后CPU执行机器指令时下发命令:“给我从地址A中取出数据”,尽管真的能从地址A中取出数据,但这个地址A不是真的,不是真的,不是真的。
因为这个地址A属于虚拟内存,也就是那个“假的地址空间”,现代CPU内部有一个叫做MMU的模块将这假的地址A转换为真的地址B,将地址A转换为真实的地址B之后才是本文之前讲述的关于cache的那一部分。
image.png
你以为这终于应该讲完了吧!
NONO!
CPU给出内存地址,此后该地址被转为真正的物理内存地址,接下来查L1 cache,L1 cache不命中查L2 cache,L2 cache不命中查L3 cache,L3 cache不能命中查内存。
各单位注意,各单位注意,到查内存时还不算完,现在有了虚拟内存,内存其实也是一层cache,是磁盘的cache,也就是说查内存也有可能不会命中,因为内存中的数据可能被虚拟内存系统放到磁盘中了,如果内存也不能命中就要查磁盘
So crazy,限于篇幅这个过程不再展开,《深入理解操作系统》第七章有完整的讲述。
至此,CPU读写内存时完整的过程阐述完毕。

总结

现在你还认为CPU读写内存非常简单吗?
这一过程涉及到的硬件以及硬件逻辑包括:L1 cache、L2 cache、L3 cache、多核缓存一致性协议、MMU、内存、磁盘;软件主要包括操作系统。
这一看似简单的操作涉及几乎所有计算机系统中的核心组件,需要软件以及硬件密切配合才能完成
这个过程给程序员的启示是:1),现代计算机系统是非常复杂的;2),你需要写出对cache友好的程序

十二、CPU与分支预测

18世纪流水线的诞生带来了制造技术的变革,人类当今拥有琳琅满目物美价廉的商品和流水线技术的发明密不可分,因此当你喝着可乐、吹着空调、坐在特斯拉里拿着智能手机刷这篇文章时需要感谢流水线技术

一段有趣的代码

有这样一段代码:

1
2
3
4
5
6
for (int k = 0; k < 10000; k++){ 
for (int i = 0; i < arr.size(); i++) {
if(arr[i] > 256)
sum += arr[i];
}
}

这段代码非常简单,给定一个数组,计算所有大于256 的元素之和,重复计算 10000 遍。
这段代码本身平淡无奇,但有趣的是:如果这个数组是有序的,那么这段代码的运行速度会比处理无序数组快将近10 倍(不同的机器、CPU架构可能会稍有差异)。
可这是为什么呢?这和制造业使用的流水线又有什么关系呢?且听我慢慢道来。

流水线技术的诞生

1769年,英国人乔赛亚·韦奇伍德开办了一家陶瓷工厂,这家工厂生产的陶瓷乏善可陈,但其内部的管理方式极具创新性,传统的方法都是由制陶工专人来完成,但韦奇伍德研究后将整个制陶工艺流程分成了几十道工序,每一道工序都交给专人完成,这样传统的制陶人不复存在,这便是工业流水线最早的雏形。

发扬光大

虽然流水线技术可以说是英国人发明的,但发扬光大的却是美国人,这便是福特与T型车。
image.png
20世纪初,福特将流水线技术应用到汽车的批量生产,效率得到千倍提高,使得汽车这种奢侈品开始能够为大众消费,深刻影响了现代社会的方方面面,注意上图中一辆车的价格。。。
100 年后又一个美国人携带他的时尚电动车再一次席卷全球,这就是特斯拉。
接下来我们仔细看一下流水线技术。

特斯拉与流水线

假设组装一辆特斯拉需要经过:组装车架、安装引擎、安装电池、检验四道工序,同时假设每个步骤需要 20 分钟,因此如果所有工序都由一个组装站点来完成,那么组装一辆特斯拉需要80分钟。
但如果每个步骤都交给一个特定站点来组装的话就不一样了,此时生产一辆车的时间依然是80分钟,但这只是第一辆车所需要的时间,此后工厂可以每20分钟就交付一辆特斯拉。
image.png
注意,流水线并没有减少组装一辆车的时间,只是增加了工厂的吞吐能力。
流水线技术的使用极大增加了工厂交付车辆的效率。

CPU 与超级工厂

其实 CPU 本身也是一座超级工厂。
image.png
只不过CPU这座工厂生产的不是特斯拉,而是机器指令。
工厂内部有流水线极大提高了生产效率,CPU 没有理由不拥有。
你可以想象一下,不管你现在看这篇文章用的是PC还是智能手机,其内部的 CPU 都有一条复杂度不亚于特斯拉超级工厂的流水生产线
如果我们把CPU处理的一条机器指令当做一辆特斯拉的话,那么对于现代CPU这座超级工厂来说,一秒钟的时间内可以交付数十亿量特斯拉,效率完爆任何当今制造界的工业流水线,CPU 才是一座名副其实的超级工厂
如果特斯拉超级工厂也如 CPU 一般高效的话,特斯拉可能比现在的自行车都要便宜,地球人民人手一辆特斯拉不成问题,算上外星人也不成问题。

机器指令与流水线

实际上说 CPU 生产机器指令是不正确的,CPU 其实不是在生产机器指令而是在处理机器指令,生产机器指令的是编译器,CPU需要处理机器指令以此来指挥整个计算机系统工作。
同生产一辆特斯拉需要四道工序一样,处理一条机器指令大体上也可以分为四个步骤:取指、译码、执行、回写,这几个阶段分别由特定的硬件来完成 (注意,真实 CPU 内部可能会将执行一条指令分解为数十个阶段)。
image.png
怎么样,是不是和超级工厂生产特斯拉没什么区别,当今CPU用每秒处理数十亿机器指令的能力驱动着智能手机好让你流畅的刷公众号、短视频、刷微博、刷知乎,这里,流水线技术功不可没。

当 if 遇到流水线

实际上 CPU 内部的流水线和现实中的并不完全一样。
程序员在代码中编写的 if 语句一般会被编译器翻译成一条跳转指令,
if 语句其实起到一种分支的作用,如果条件成立则需要执行if内部的逻辑,否则不执行;因此跳转指令会依赖自身的执行结果来决定到底要不要跳转,这会对流水线产生影响。
有的同学可能不明白,这能产生什么影响呢?
现在,让我们仔细观察一下特斯拉流水线,你会发现当前一辆车还没有完全制造完成时后一辆车就已经进入到流水线了
image.png

对于CPU来说道理是一样的,当一条跳转指令还没有完成时后面的指令就需要进入到流水线,因此问题来了:
跳转指令需要依赖自身的执行结果来决定到底要不要跳转,那么在跳转指令没有执行完的情况下 CPU 怎么知道后面哪个分支的指令能进入到流水线呢
image.png
CPU 能预测未来吗?

预测未来

对此 CPU 当然是不知道的。
那么该怎么办呢?
很简单,一个字,
你没有看错,CPU 会猜一下 if 语句可能会走哪个分支,如果猜对了流水线照常继续,如果猜错了,对不起,流水线上已经执行的后续指令全部作废,因此我们可以看到如果CPU猜错了会有性能损耗。
现代 CPU 将“猜”的这个过程称为分支预测
当然,CPU 中的分支预测并不是简单的抛硬币式的随机瞎猜,而且有特定策略,比如可能会基于执行跳转指令的历史去进行预测等等。
知道了分支预测就可以解释文章开头的问题了。

程序员的心思你别猜

现在我们知道,程序员编写的 if 语句对应的是跳转指令:

1
if (arr[i] >= 256) {sum += arr[i];}

CPU 在执行完跳转指令之前必须决定后续哪个分支的指令会进入到流水线,猜对了流水线照常进行,猜错了有性能损耗。
那么如果一个数组是有序的:
image.png
而如果一个数组是无序的:
image.png
你觉得哪种更好猜一些?
如果你给CPU一个无序数组,那么 Arr[i] 是否大于256 基本上就是随机的,对于随机事件,不要说CPU的分支预测,任何其它预测手段都将无效,否则这就不是随机事件了
如果 CPU 猜的不对,那么流水线上的后续指令将作废,这就解释了为什么处理有序数组要比处理无序数组性能好了,因为在数组有序的情况下,CPU 的分支预测几乎不会猜错,流水线上的指令不会被频繁作废。
这对程序员的启示就是:如果你编写了 if 语句,那么你最好让 CPU 大概率能猜对
有的同学看到这里,可能会觉得每一条 if 语句都性能低下,恨不得从此不再写if else,真的是这样吗?

编写 If else时需要注意什么

实际上如果你编写的if语句没有位于对性能要求很高的核心代码部分,那么分支预测失败这种问题无需关心。
实际上现代 CPU 的分支预测是很聪明的,对于非核心部分的if 语句分支预测失败带来的性能损失可以忽略不计。
但是对于文章开头提到的代码,程序的大部分时间都用在了 for 循环中,这时你就要注意了,当然前提还是这段代码对时间要求非常严苛,否则你也没必要为了这点性能去优化。
好奇的同学可能会问,如果给定的数组是无序的,那么上面提到的这段该怎么优化呢?

性能优化

实际上非常简单,只需要移除 if 语句就可以,该怎么移除呢?
没有 if 语句的话,那么 sum 每次都必须加上一个数,如果arr[i]比256大,那么 sum 加上差值,否则sum 加 0即可,这样就消除了if 判断。
我们计算arr[i] - 256的值,并将其向右移动31位:

1
(arr[i] - 256) >> 31

这样得到的数不是0 (0x00000000),就是 -1 (0xffffffff),然后我们对其取反,再次与上 arr[i] 即可:

1
sum += ~((arr[i] - 256) >> 31) & arr[i];

也就是说如果arr[i] - 256 大于0的话那么差值会与上 0xffffffff,其结果就是保持不变,否则会与上0,其结果就是sum会加上0,这样就不需要 if 判断了。
利用位运算,即使数组是无序的也不会有性能问题,代价就是代码可读性会降低很多,这里,我们再一次看到天下没有免费的午餐

总结

虽然 CPU 体积很小,只有指甲那么大,但 CPU 可能是人类有史以来建造过的最复杂的东西,在这里实现了很多有趣的功能,程序员只有彻底理解 CPU 才能更好的利用这些功能编写性能优异的程序。

十三、CPU进化论:复杂指令集的诞生

英国生物学家达尔文于 1859 年出版了震动整个学术界和宗教界的《物种起源》,达尔文在这本书里提出了生物进化论学说,认为生命在不断演变进化,物竞天择适者生存。
**没有历史的计算机 **
生命是这样,实际上计算机技术也是如此。
计算机技术也和生命体一样在不断演变进化,在讨论一项技术时,如果不了解其演变过程而仅仅着眼于当下就会让人疑惑,不巧的是这正是当前计算机教育的现状——没有历史。
因此,在这里我将尝试从历史的角度来讲讲 CPU,以及 CPU 的发展历程。
本篇主要关注CPU与复杂指令集CISC。
首先来看下什么是CPU。

什么是CPU?

我们都是程序员,那么从程序员的角度来看,CPU的工作其实是很简单的。
image.png
我们编写的所有程序,不管是简单的Hello World,还是复杂的比如PhotoShop之类大型App,最终都会被编译器转为一条条简单的机器指令,因此在CPU看来所有程序是没有什么本质区别的,无非就是一个包含的指令多,一个包含的指令少,这些指令就保存在可执行文件中,程序运行时被加载到内存开始被CPU执行。
管你是简单程序还是复杂程序,CPU才不关心这些,它只需要简单一条一条的执行就可以了,因此,在程序员眼里 CPU 是一个很简单的家伙。
有很多同学可能会好奇CPU是怎么构造出来,你可以参考《链接你管这破玩意叫CPU》。
接下来我们的视角就可以进一步聚焦了,CPU执行的是什么机器指令呢?

CPU的能力圈:指令集

我们该怎样描述一个人的能力呢?写过简历的同学肯定都知道,就像这样:

会写代码
会炒菜
会唱歌
会跳舞
会炒股
。。。

巴菲特有一个词用的很好,这叫能力圈,如果一个人会“写代码”,那么你命令这个人“写代码”,他就能写出代码来(现实情况下你让他写代码他可能会过来打你)。
CPU也是同样的道理,每种类型的CPU都要自己的能力圈,只不过CPU的能力圈有一个特殊的名字,叫做 Instruction Set Architecture ,ISA,也就是指令集,指令集中包含各种各样的指令:

会加法
会从内存把数据搬运到寄存器
会跳转
会比较大小
。。。

指令集告诉我们一个CPU可以干嘛。
你从ISA中找一条指令发给CPU,CPU就是完成这条指令所代表的任务。
ISA有什么用呢,当然是程序员用来编程啦!
没错,最初的程序都是面向CPU直接用汇编来写程序,这一时期也非常的朴实无华,没有那么多花哨的概念,什么面向对象啦,什么设计模式啦,统统没有,总之这个时期的程序员写代码只需要看看ISA就可以了。
这就是指令集的概念,注意,指令集是CPU告诉程序员该怎么让自己工作的。
不同的CPU会有不同类型的指令集指令集的类型除了影响程序员写汇编程序之外还会影响CPU的硬件设计,到底CPU该采用什么类型的指令集,CPU该如何设计,这一论战持续至今,并且愈发精彩。
接下来我们看一下第一种也是最先诞生的指令集类型:复杂指令集,Complex Instruction Set Computer,简称CISC。当今普遍存在于桌面PC以及服务器端的x86架构就是基于复杂指令集CISC,生产x86处理器的厂商就是我们熟悉的“等,等等等等”英特尔以及AMD。

抽象:少就是多

直到1970s年代,这一时期编译器还非常菜,不像现在这么智能,没多少人信得过编译器,大部分程序还是用汇编语言纯手工编写 (这一点极为重要,对于接下来理解复杂指令集非常关键),这对现代程序员来说是无法想象的,不要说手写汇编语言,就是看懂汇编语言的程序员都不会很多。
当然,现代编译器已经足够强大足够智能,编译器生成的汇编语言已经足够优秀,因此当今程序员,除了编写操作系统以及部分驱动的那帮家伙,剩下的几乎已经意识不到汇编语言的存在了,不要觉得可惜,这是生产力进步的表现,用高级语言编写程序的效率可是汇编语言望尘莫及的。
题外话说的有点多,总之,这一时期的大部分程序都是直接通过汇编语言编写的,因此大家普遍认为指令集应该更加丰富一些、指令本身功能更强大一些,程序员常用的操作最好都有对应的特定指令,毕竟大家都在直接用汇编语言来写程序,如果指令集很少或者指令本身功能单一,那么程序员用汇编指令写起程序会会非常繁琐,很不方便,如果你在这个时期用汇编写程序你也会这样想
这就是这个时期一些计算机科学家所谓的抹平差异,semantic gap,抹平什么差异呢?
大家认为高级语言中的一些概念比如函数调用、循环控制、复杂的寻址模式、数据结构和数组的访问等都应该直接有对应的机器指令,这些就是现代大家认为的复杂指令集CISC非常鲜明的特点。
除了更方便的使用汇编语言写程序,另一点需要考虑就是存储。

物种起源

当今的计算机都遵从冯诺依曼架构,该架构的核心思想之一是“程序应该和数据一样都作为比特保存在计算机存储设备中”,下面这张图是所有计算设备的鼻祖,你现在看这篇文章用计算设备,不管是智能手机或者iPad、PC,亦或是存放这篇文章的微信数据中心服务器,其本质都是下面这张简单的图,这张图是一切计算设备的起源
image.png
代码也是要占存储空间的 **
从冯诺依曼结构中我们就能知道为什么当今可执行程序中,比如Windows下的EXE或者Linux下的ELF文件,即包含机器指令也包含数据,对于程序员来说我们可以简单的认为可执行程序中有两部分内容:数据段以及代码段:
image.png
由此可见,程序员写的代码是要占据存储空间的,要知道在1970s年代,内存大小仅仅数KB到数十KB,这是当今程序员不可想象的,因为现在(2021年)的智能手机内存都已经数GB。如图所示是1974年发布的Intel 1103内存芯片:
image.png
大小只有 1KB 的英特尔1103存储芯片的于1974年发布,这标志着计算机工业界开始进入动态随机存储DRAM时代,DRAM也就是我们熟知的内存。
大家可以思考一下,几KB的内存,可谓寸土寸金,
这么小的内存要想装入更多的程序就必须仔细的设计机器指令以节省程序占据的空间**,这就要求:

  1. 一条机器指令尽可能完成更多的任务,这很容易理解,就像在《你管这破玩意叫编程语言》这篇中的例子一样,你更希望有一条“给我端杯水”的指令,而不是自己去写“迈出左脚;停住;迈出右脚;直到饮水机;伸出右手;拿起水杯;接水。。。”等等这样的汇编代码
  2. 机器指令长度不固定,也就是变长机器指令,简单的指令占据更少的空间
  3. 机器指令高度编码(encoded),提高代码密度,节省空间

复杂指令集诞生的必然

基于对程序员方便编写汇编语言以及节省代码存储空间的需要,直接促成了复杂指令集的设计,因此我们可以看到复杂指令集是这一时期必然的选择,该指令集就这样诞生了并开始成为主流。
就这样经过一段时间后,人们发现了新的问题,由于单条指令比较复杂,设计解码机器指令的硬件(CPU的一部分)成了一件非常麻烦的事情,该怎样解决这一问题呢?

CPU真的在直接执行机器指令吗?

作为程序员,我们知道,对于重复使用的代码其实是没有必要一遍遍编写的,你可以把这些代码封装到函数中,这样每次使用时只需要调用这个函数就好了,这个思路可以解决上述问题。
对于指令集中的每一条机器指令都有一小段对应的程序,这些程序存储在CPU中,这些程序都是由更简单的指令组成,这些指令就是所谓的微代码,Microcode。
image.png
就这样CPU的指令集可以添加更多的指令,代价仅仅是再多一些简单的微代码而已,是不是很天才的设计。
在这里也可以看到,一般我们认为CPU直接执行机器指令,严格来说这是不正确的,对于含有微代码设计的CPU来说,CPU直接执行的并不是机器指令,而是微代码,微代码是CPU以及机器指令的中间层,机器指令相对于微代码来说是“更高级的语言”,机器指令对程序员来说可见,但微代码对程序员来说不可见,程序员无法直接使用微代码来控制CPU
image.png
而在这一时期,这些微代码普遍存放在ROM中,Read-Only Memory,而ROM普遍要比内存便宜,因此依靠存储在ROM中的微代码来设计更多复杂指令进而减少程序本身对内存的占用是非常划算的。

新的问题

一切看上去都很好,有了复杂指令集,程序员可以更方便的编写汇编程序,这些程序也不需要占用很多存储空间,代价就是CPU中需要有微代码来简化CPU设计。
然而这一设计随着时间的推移又出现了新的问题。
作为程序员我们知道代码难免会有bug,微代码也不会有例外。但修复微代码的bug要比修复普通程序的bug困难的多,你无法像普通程序那样来测试、调试微代码,这一切都太复杂了。
而且微代码设计非常消耗晶体管,1979年代的Motorola 68000 处理器就采用该设计,其中三分之一的晶体管都用在了微代码上。
同年,计算机科学家Dave Patterson被委以重任来改善微代码设计,为此他还专门发表了论文,但他后来又推翻了自己想法,认为微代码设计的复杂性问题很难解决,有问题的是微代码这种设计本身。。
因此,有人开始反思,是不是还会有更好的设计。。。
预知后事如何请听下回分解。

总结

CPU是整个计算机系统的核心,CPU指令集ISA更是核心中的核心。
本文从历史的角度讲述了复杂指令集出现的必然,复杂指令集对于那些直接使用汇编语言进行编程的程序员来说是很方便的,同时复杂指令集的指令密度更高,相同的存储空间可以存储更多程序,这一切都推动了复杂指令集的发展。
然而任何事物都有其必然性以及局限性,复杂指令集也不例外,随着时间的推移采用复杂指令集的CPU设计出现各种各样的问题,面对这些问题一部分人开始重新思考指令集到底该如何设计,我们将在下篇文章中继续讲述这一话题。

十四、CPU进化论:复杂指令集的诞生

在上一篇文章《CPU进化论:复杂指令集》中我们从历史的角度讲述了复杂指令集出现的必然,随着时间的推移,采用复杂指令集架构的CPU出现各种各样的问题,面对这些问题一部分人开始重新思考指令集到底该如何设计。
在这一时期,两个趋势的出现促成一种新的指令集设计思想。

内存与编译器

时间来到了1980s年代,此时容量“高达”64K的内存开始出现,内存容量上终于不再捉襟见肘,价格也开始急速下降,在1977年,1MB内存的价格高达**$5000,要知道这可是1977年的5000刀,但到了1994年,1MB内存价格就急速下降到大概只有$6,这是第一个趋势。
image.png
此外在这一时期随着编译技术的进步,编译器越来越成熟,
渐渐的程序员们开始依靠编译器来生成汇编指令而不再自己手工编写**。
这两个趋势的出现让人们有了更多思考。

化繁为简

19世纪末20世纪初意大利经济学家Pareto发现,在任何一组东西中,最重要的只占其中一小部分,约20%,其余80%尽管是多数,却是次要的,这就是著名的二八定律,机器指令的执行频率也有类似的规律。
大概80%的时间CPU都在执行那20%的机器指令,同时CISC中一部分比较复杂的指令并不怎么被经常用到,而且那些设计编译器的程序员也更倾向于组合一些简单的指令来完成特定任务。 **
与此同时我们在上文提到过的一位计算机科学家,被派去改善微代码设计,但后来这老哥发现有问题的是微代码本身,因此开始转过头来去思考微代码这种设计的问题在哪里。
他的早期工作提出一个关键点,复杂指令集中那些被认为可以提高性能的指令其实在内部被微代码拖后腿了,如果移除掉微代码,程序反而可以运行的更快,并且可以节省构造CPU消耗的晶体管数量。
image.png
由于微代码的设计思想是将复杂机器指令
在CPU内部转为相对简单的机器指令,这一过程对编译器不可见,也就是说你没有办法通过编译器去影响CPU内部的微代码运行行为,因此如果微代码出现bug那么编译器是无能为力的,你没有办法通过编译器生成其它机器指令来修复问题而只能去修改微代码本身。
此外他还发现,有时一些复杂的机器指令执行起来要比等价的多个简单指令要(作者没写,应该是简单)。
image.png
这一切都在提示:
为什么不直接用一些简单的指令来替换掉那些复杂的指令呢**?

精简指令集哲学

基于对复杂指令集的思考,精简指令集哲学诞生了,精简指令集主要体现在以下三个方面:

1,指令本身的复杂度

精简指令集的思想其实很简单,干嘛要去死磕复杂的指令,去掉复杂指令代之以一些简单的指令。有了简单指令CPU内部的微代码也不需要了,没有了微代码这层中间抽象,编译器生成的机器指令对CPU的控制力大大增强,有什么问题让写编译器的那帮家伙修复就好了,显然调试编译器这种软件要比调试CPU这种硬件要简单很多。
注意,精简指令集思想不是说指令集中指令的数量变少,而是说一条指令背后代表的动作更简单了。举个简单的例子,复杂指令集中的一条指令背后代表的含义是“吃饭”的全部过程,而精简指令集中的一条指令仅仅表示“咀嚼一下”的其中一个小步骤。
博主在《你管这破玩意叫编程语言》一文中举得例子其实更形象一些,复杂指令集下一条指令可以表示“给我端杯水”,而在精简指令集下你需要这样表示:
image.png

2,编译器

精简指令集的另一个特点就是编译器对CPU的控制力更强。
在复杂指令集下,CPU会对编译器隐藏机器指令的执行细节,就像微代码一样,编译器对此无能为力。
而在精简指令集下CPU内部的操作细节暴露给编译器,编译器可以对其进行控制,也因此,精简指令集RISC还有一个有趣的称呼:“Relegate Interesting Stuff to Compiler”,把一些有趣的玩意儿让编译器来完成。

3,load/store architecture

在复杂指令集下,一条机器指令可能涉及到从内存中取出数据、执行一些操作比如加和、然后再把执行结果写回到内存中,注意这是在一条机器指令下完成的。
但在精简指令集下,这绝对是大写的禁忌,精简指令集下的指令只能操作寄存器中的数据,不可以直接操作内存中的数据,也就是说这些指令比如加法指令不会去访问内存。
image.png
毕竟数据还是存放在内存中的,那么谁来读写内存呢?
原来在精简指令集下有专用的 load 和 store 两条机器指令来负责内存的读写,其它指令只能操作CPU内部的寄存器,这是和复杂指令集一个很鲜明的区别。
你可能会好奇,用两条专用的指令来读写内存有什么好处吗?别着急,在本文后半部分我们还会回到load/store指令。
以上就是三点就是精简指令集的设计哲学。
接下来我们用一个例子来看下RISC和CISC的区别。

两数相乘

如图所示就是最经典的计算模型,最右边是内存,存放机器指令和数据,最左侧是CPU,CPU内部是寄存器和计算单元ALU,进一步了解CPU请参考《你管这破玩意叫CPU?》
image.png
内存中的地址A和地址B分别存放了两个数,假设我们想计算这两个数字之和,然后再把计算结果写回内存地址A。
我们分别来看下在CISC和在RISC下的会怎样实现。

1,CISC

复杂指令集的一个主要目的就是让尽可能少的机器指令来完成尽可能多的任务,在这种思想下CPU需要在从内存中拿到一条机器指令后“自己去完成一系列的操作”,这部分操作对外不可见。
在这种方法下,CISC中可能会存在一条叫做MULT的机器指令,MULT是乘法multiplication的简写。
当CPU执行MULT这条机器指令时需要:

  1. 从内存中加载地址A上的数,存放在寄存器中
  2. 从内存中夹杂地址B上的数,存放在寄存器中
  3. ALU根据寄存器中的值进行乘积
  4. 将乘积写回内存
    以上这几部统统都可以用这样一条指令来完成:
    1
    MULT A B
    MULT就是所谓的复杂指令了,从这里我们也可以看出,**复杂指令并不是说“MULT A B”这一行指令本身有多复杂,而是其背后所代表的任务复杂。 **
    这条机器指令直接从内存中加载数据,程序员(写汇编语言或者写编译器的程序员)根本就不要自己显示的从内存中加载数据,实际上这条机器指令已经非常类似高级语言了,我们假设内存地址A中的值为变量a,地址B中的值为变量b,那么这条机器指令基本等价于高级语言中这样一句:
    1
    a = a * b;
    这就是我们在上一篇《CPU进化论:复杂指令集》中提到的所谓抹平差异,semantic gap,抹平高级语言和机器指令之间的差异,让程序员或者编译器使用最少的代码就能完成任务,因为这会节省程序本身占用的内存空间,要知道在在1977年,1MB内存的价格大概需要$5000,省下来的就是钱
    因为一条机器指令背后的操作很多,而程序员仅仅就写了一行“MULT A B”,这行指令背后的复杂操作就必须由CPU直接通过硬件来实现,这加重了CPU 硬件本身的复杂度,需要的晶体管数量也更多。
    接下来我们看RISC方法。

2,RISC

相比之下RISC更倾向于使用一系列简单的指令来完成一项任务,我们来看下一条MULT指令需要完成的操作:

  1. 从内存中加载地址A上的数,存放在寄存器中
  2. 从内存中加载地址B上的数,存放在寄存器中
  3. ALU根据寄存器中的值进行乘积
  4. 将乘积写回内存
    这几步需要a)从内存中读数据;b)乘积;c) 向内存中写数据,因此在RISC下会有对应的LOAD、PROD、STORE指令来分别完成这几个操作。
    Load指令会将数据从内存搬到寄存器;PROD指令会计算两个寄存器中数字的乘积;Store指令把寄存器中的数据写回内存,因此如果一个程序员想完成上述任务就需要写这些汇编指令:
    1
    2
    3
    4
    LOAD RA, A
    LOAD RB, B
    PROD RA, RB
    STORE A, RA
    现在你应该看到了,同样一项任务,在CISC下只需要一条机器指令,而在RISC下需要四条机器指令,显然RISC下的程序本身所占据的空间要比CISC大,而且这对直接用汇编语言来写程序的程序员来说是很不友好的,因为更繁琐嘛!再来看看这样图感受一下:
    image.png
    但RISC设计的初衷也不是让程序员直接使用汇编语言来写程序,而是把这项任务交给编译器,让编译器来生成机器指令。

标准从来都是一个好东西

让我们再来仔细的看一下RISC下生成的几条指令:

1
LOAD RA, ALOAD RB, BPROD RA, RBSTORE A, RA

这些指令都非常简单,CPU内部不需要复杂的硬件逻辑来进行解码,因此更节省晶体管,这些节省下来的晶体管可用于其它功能上。
最关键的是,注意,由于每一条指令都很简单,执行的时间都差不多,因此这使得一种能高效处理机器指令的方法成为可能,这项技术是什么呢?
我们在《CPU遇上特斯拉,程序员的心思你别猜》这篇文章中提到过,这就是有名的流水线技术

指令流水线

流水线技术是初期精简指令集的杀手锏。
在这里我们还是以生产汽车(新能源)为例来介绍一下。
假设组装一辆汽车需要经过四个步骤:组装车架、安装引擎、安装电池、检验。
image.png
假设这每个步骤需要10分钟,如果没有流水线技术,那么生产一辆汽车的时间是40分钟,只有第一辆汽车完整的经过这四个步骤后下一辆车才能进入生产车间。
这就是最初复杂指令集CPU的工作场景。
显然这是相当低效的,因为当前一辆车在进行最后一个步骤时,前三个步骤:组装车架、安装引擎、安装电池,这三个步骤的工人是空闲。
CPU的道理也是一样的,低效的原因在于没有充分利用资源,在这种方法下有人会偷懒。
但引入流水线技术就不一样了,当第一辆车还在安装引擎时后一辆车就可以进入流水线来组装车架了,采用流水线技术,四个步骤可以同时进行,最大可能的充分利用资源
原来40分钟才能生产一辆车,现在有了流水线技术可以10分钟就生产出一辆车。
注意,这里的假设是每个步骤都需要10分钟,如果流水线每个阶段的耗时不同,将显著影响流水线的处理能力
假如其中一个步骤,安装电池,需要20分钟,那么安装电池的前一个和后一个步骤就会有10分钟的空闲,这显然不能充分利用资源。
精简指令集的设计者们当然也明白这个道理,因此他们尝试让每条指令执行的时间都差不多一样,尽可能让流水线更高效的处理机器指令,而这也是为什么在精简指令集中存在Load和Store两条访问内存指令的原因。
由于复杂指令集指令与指令之间差异较大,执行时间参差不齐,没办法很好的以流水线的方式高效处理机器指令(后续我们会看到复杂指令集会改善这一点)。
第一代RISC处理器即为全流水线设计,典型的就是五级流水线,大概1到2个时钟周期就能执行一条指令,而这一时期的CISC大概5到10个时钟周期才能执行一条指令,尽管RISC架构下编译出的程序需要更多指令,但RISC精简的设计使得RISC架构下的CPU更紧凑,消耗更少的晶体管(无需微代码),因此带来更高的主频,这使得RISC架构下的CPU完成相同的任务速度优于CISC。
有流水线技术的加持,采用精简指令集设计的CPU在性能上开始横扫其复杂指令集对手。

名扬天下

到了1980年代中期,采用精简指令集的商业CPU开始出现,到1980年代后期,采用精简指令集设计的CPU就在性能上轻松碾压所有传统设计。
到了1987年采用RISC设计的MIPS R2000处理器在性能上是采用CISC架构(x86)的Intel i386DX两到三倍。
所有其它CPU生成厂商都开始跟进RISC,积极采纳精简指令集设计思想,甚至操作系统MINIX(就是那个Linus上大学时使用的操作系统)的作者Andrew Tanenbaum在90年代初预言:“5年后x86将无人问津”,x86正是基于CISC。
image.png
CISC迎来至暗时刻
接下来CISC该如何绝地反击,要知道Inter以及AMD (x86处理器两大知名生产商) 的硬件工程师们绝非等闲之辈。
预知后事如何,请听下回分解。

总结

CISC中微代码设计的复杂性让人们重新思考CPU到底该如何设计,基于对执行指令的重新审视RISC设计哲学应运而生。
RISC中每条指令更加简单,执行时间比较标准,因此可以很高效的利用流水线技术,这一切都让采用RISC架构的CPU获得了很好性能。
面对RISC,CISC阵营也开始全面反思应如何应对挑战。后续文章将继续这一话题。

十五:CPU核数与线程数有什么关系?

作为一名美食资浅爱好者,尽管小风哥我厨艺拙计,但依然阻挡不了我对烹饪的热爱。
那小风哥我通常是怎么做菜的呢?

大厨与菜谱

你没猜错,做菜之前先去下一份菜谱,照着菜谱一步步来:起锅烧油、葱姜蒜末下锅爆香、倒入切好的食材、大火翻炒、加入适量酱油、加入适量盐、继续翻炒、出锅喽!
image.png
这样一道色香味俱佳的小炒大功告成,装盘端出来拿起筷子一尝,难吃死了。 火候有点过,酱油加的有点少,盐加多了,中餐里的“火候”以及“适量”是最为神秘的存在,可以意会不可言传。因此相对肯德基麦当劳之类的标准工业品,中餐更像是艺术。每个人炒出来的菜味道都不一样,显然嘛,每个人对火候以及适量的理解是不一样的。
对不起,跑题了。
image.png
虽然小风哥我厨艺不怎么样,但输厨艺不能输气场,有时我会几样一起来,这边炒着A菜,那边炒着B菜。
也就是说,我可以同时按照两份菜谱去做饭,如果小风哥足够快,那么我可以同时炒 N 样菜。

炒菜与线程

实际上CPU和厨师一样,都是按照菜谱(机器指令)去执行某个动作,从操作系统的角度讲当CPU切换回用户态后,CPU执行的一段指令就是线程,或者说属于某个线程
image.png
这和炒菜一样,我可以按照菜谱抄鱼香肉丝,那么炒菜时这就是鱼香肉丝线程;我可以按照菜谱抄宫保鸡丁,那么炒菜时这就是宫保鸡丁线程。
厨师个数就好比CPU核心数,炒菜的样数就好比线程数,这时我问你,你觉得厨师的个数和可以同时抄几样菜有关系吗?
答案当然是没有。
CPU的核心数和线程个数没有什么必然的关系
单个核心上可以跑任意多个线程,只要你的内存够就行;计算机系统内也可以有任意多核数,只要你有钱就行。
看到这个答案你是不是觉得有点疑惑、有点疑问、有点不明所以,这好像和其它人说的不一样啊!
别着急,我们慢慢讲。

傻傻的CPU

CPU根本不理解自己执行的指令属于哪个线程,CPU也不需要理解这些,CPU需要做的事情就是根据PC寄存器中的地址从内存中取出后执行,其它没了
image.png
你看CPU才不管你系统内有多少线程。
有多少线程是谁需要来关心的呢?是操作系统。
线程是操作系统的把戏。

操作系统与多任务

很久很久以前,计算机一次只能执行一个任务,你不能像现在这样在计算机上一边看电影一边在下小电影,哦,不对,一边写代码,一边下载资料。
要么你先写代码,写完代码后再去下资料,要么你先下资料然后再写代码,总之,这两个任务不能同时进行
这显然很不方便,就这样,多任务——Multi-Tasking,诞生了。
image.png
你CPU不是只知道执行机器指令吗?很好,那我操作系统就通过修改你的PC寄存器,让你CPU执行A任务的机器指令一段时间,然后下一段时间再去执行B任务的机器指令,再然后下一个时间段去执行C任务的机器指令,由于每一段时间非常少,通常在毫秒级别,那么在人类看来A、B、C三个任务在“同时”运行。
这就是多任务的本质。

进程与线程

CPU不知道执行的某一段机器指令属于A任务还是B任务,只有操作系统知道,同时操作系统还能知道任务A和B任务是否属于同一个地址空间
如果属于同一个地址空间,那么任务A和任务B就是我们熟悉的“多线程”;如果不属于同一个地址空间,那么任务A和任务B就是我们熟悉的“多进程”,现在你应该明白这两个概念了吧。
image.png
这里出现了一个有点拗口的名词,地址空间,Address Space,关于地址空间的概念以及进程线程这一部分更加详细的讲解,请参考《深入理解操作系统》第7章。
值得注意的是,计算机系统还在单核时代就已经有多线程的概念了,我们之前说过,即使是单核也可以执行多个线程,那么有的同学可能会有疑问,在单核的系统中开启多个线程有什么意义吗?

单核与多线程

假设现在有两个任务,任务A和任务B,每个任务需要的计算时间都是5分钟,那么无论是任务A和任务B串行执行还是放到两个线程中并行执行,在单核环境下执行完这两个任务总需要10分钟,因此有的同学觉得单核下多线程没什么用。实际上,线程这个概念为程序员提供了一种编程抽象,我们可以把一项任务进行划分,然后把每一个子任务放到一个个线程中去运行。
image.png
假如你的程序带有图形界面,某个UI元素背后需要的大量运算,这时为了防止执行该运算时UI产生卡顿,那么可以把这个运算任务放到一个单独的线程中去。
因此如果你的目的是防止当前线程因执行某项操作而不得不等待,那么在这样的应用场景下,你根本就不需要关心系统内是单核还是多核以及有多少个核。

阻塞式I/O

这也是使用线程的经典场景。
如果没有线程,那么执行阻塞式I/O时整个进程会被操作系统暂停,但如果你开启两个线程,其中一个线程被阻塞时另一个线程依然可以继续向前推进。
这样的话你就不需要去使用反人类的异步IO了。
当然,这一切的前提是你的场景不涉及高性能以及高并发,如果涉及的话那这就是另一个话题了,如果你想了解这一话题,关注公众号“码农的荒岛求生”并回复“高并发”即可。
在这种简单的场景下,你创建线程时也不需要关心系统中是单核还是多核。

多核时代

实际上,线程这个概念是从2003年左右才开始流行的,为什么?因为这一时期,多核时代到来了。
image.png
之所以产生多核,是因为单核的性能提升越来越困难了。
尽管采用多进程也可以充分利用多核,但毕竟多进程编程是很繁琐的,这涉及复杂的进程间通信机制、进程间切换的较高性能损耗、进程间内存相互隔离带来的对内存消耗等。
线程这个概念很好的解决了上述问题,开始成为多核时代的主角,要想充分利用多核资源,线程是程序员的首选工具。

真正的并行

有了多核后,运行在两个线程中的任务A和任务B实现了真正的并行。
此前这样一句话广为引用,这句话是这么说的:

threads are for people who can’t program state machines

“线程是为那些不懂状态机的人准备的”,这句话在单核时代有它的道理,因为在单核时代,所有的任务都不是在同时向前推进,而是“交错”前进,A前进一点,然后B前进一点,线程并不是实现这种“伪并行”唯一的方法,状态机也可以。
image.png
但在多核时代,这句话就不再适用了,对于大多数程序员来说多进程多线程几乎是充分利用多核资源的唯一方法。
如果你的场景是想充分利用多核,那么这时你的确需要知道系统内有多少核数,**一般来说你创建的线程数需要与核数保持线性关系。 **
也就是说,如果你的核数翻倍,那么创建的线程数也要翻倍。

需要多少线程?

值得注意的是,线程不是越多越好。
如果你的线程是不涉及任何I/O、没有任何同步互斥之类的纯计算类型,那么每个核心一个线程通常是最佳选择。但通常来说,线程都需要一定的I/O,可能需要一定的同步互斥,那么这时适当增加线程可能会提高性能,但当线程数量到达一个临界值后性能开始下降,这时线程间切换的开销将显著增加。
这里之所以用适当这个词,是因为这很难去量化,只能用你实际的程序根据真正的场景进行测试才能得到这个值

总结

线程数和CPU核心数可以没有任何关联,如果在使用线程时仅仅针对上述提到的几个简单场景,那么你根本不需要关心CPU是单核还是多核。
但当你需要利用线程充分发挥多核威力时,通常情况下你创建的线程数与核数要保持一种线性关系,最佳系数通常需要测试才能得到。

十六:彻底理解IO多路复用

在讲解该技术之前,我们需要预习一下文件以及文件描述符。

什么是文件

程序员使用I/O最终都逃不过文件这个概念。
在Linux世界中文件是一个很简单的概念,作为程序员我们只需要将其理解为一个N byte的序列就可
以了:
_b1, b2, b3, b4, ……. bN _
实际上所有的I/O设备都被抽象为了文件这个概念,一切皆文件,Everything is File,磁盘、网络数
据、终端,甚至进程间通信工具管道pipe等都被当做文件对待。
image.png
所有的I/O操作也都可以通过文件读写来实现,这一非常优雅的抽象可以让程序员使用一套接口就能对所有外设I/O操作
常用的I/O操作接口一般有以下几类:
打开文件,open
改变读写位置,seek
文件读写,read、write
关闭文件,close
程序员通过这几个接口几乎可以实现所有I/O操作,这就是文件这个概念的强大之处。

文件描述符

在上一篇《读取文件时,程序经历了什么》(一定要看)中我们讲到,要想进行I/O读操作,像磁盘数据,我们需要指定一个buff用来装入数据,一般都是这样写的:

1
read(buff);

但是这里我们忽略了一个关键问题,那就是虽然我们指定了往哪里写数据,但是我们该从哪里读数据呢?
从上一节中我们知道,通过文件这个概念我们能实现几乎所有I/O操作,因此这里少的一个主角就是文件
那么我们一般都怎样使用文件呢?
如果周末你去比较火的餐厅吃饭应该会有体会,一般周末人气高的餐厅都会排队,然后服务员会给你一个排队序号,通过这个序号服务员就能找到你,这里的好处就是服务员无需记住你是谁、你的名字是什么、来自哪里、喜好是什么、是不是保护环境爱护小动物等等,这里的关键点就是服务员对你一无所知,但依然可以通过一个号码就能找到你
同样的,在Linux世界要想使用文件,我们也需要借助一个号码,根据“弄不懂原则”,这个号码就被称为了文件描述符,file descriptors,在Linux世界中鼎鼎大名,其道理和上面那个排队号码一样。
因此,文件描述仅仅就是一个数字而已,但是通过这个数字我们可以操作一个打开的文件,这一点要记住。
image.png
有了文件描述符,进程可以对文件一无所知,比如文件在磁盘的什么位置、加载到内存中又是怎样管理的等等,这些信息统统交由操作系统打理,进程无需关心,操作系统只需要给进程一个文件描述符就足够了。
因此我们来完善上述程序:

1
2
int fd = open(file_name); // 获取文件描述符
read(fd, buff);

怎么样,是不是非常简单。

文件描述符太多了怎么办

经过了这么多的铺垫,终于要到高性能、高并发这一主题了。
从前几节我们知道,所有I/O操作都可以通过文件样的概念来进行,这当然包括网络通信。
如果你有一个web服务器,当三次握手成功以后,我们会调用accept来获取一个链接,调用该函数我们同样会得到一个文件描述符,通过这个文件描述符就可以处理客户端发送的请求并且把处理结果发送回去。也就是说通过这个描述符我们就可以和客户端进行通信了。

1
2
// 通过accept获取客户端的文件描述符
int conn_fd = accept(...);

server的处理逻辑通常是读取客户端请求数据,然后执行某些特定逻辑:

1
2
3
if(read(conn_fd, request_buff) > 0) {
do_something(request_buff);
}

是不是非常简单,然而世界终归是复杂的,当然也不是这么简单的。
接下来就是比较复杂的了。
既然我们的主题是高并发,那么server就不可能只和一个客户端通信,而是可能会同时和成千上万个客户端进行通信。这时你需要处理不再是一个描述符这么简单,而是有可能要处理成千上万个描述符
为了不让问题一上来就过于复杂,我们先简单化,假设只同时处理两个客户端的请求。
有的同学可能会说,这还不简单,这样写不就行了:

1
2
3
4
5
6
7
8
if(read(socket_fd1, buff) > 0) { 
// 处理第一个
do_something();
}
if(read(socket_fd2, buff) > 0) {
// 处理第二个
do_something();
}

在上一篇《读取文件时,程序都经历了什么》中我们讨论过这是非常典型的阻塞式I/O,如果此时没有数据可读那么进程会被阻塞而暂停运行,这时我们就无法处理第二个请求了,即使第二个请求的数据已经就位,这也就意味着处理某一个客户端时由于进程被阻塞导致剩下的所有其它客户端必须等待,在同时处理几万客户端的server上,这显然是不能容忍的。
聪明的你一定会想到使用多线程,为每个客户端请求开启一个线程,这样一个客户端被阻塞就不会影响到处理其它客户端的线程了,注意,既然是高并发,那么我们要为成千上万个请求开启成千上万个线程吗?大量创建销毁线程会严重影响系统性能。
那么这个问题该怎么解决呢?
这里的关键点在于,我们事先并不知道一个文件描述对应的I/O设备是否是可读的、是否是可写的
在外设的不可读或不可写的状态下进行I/O只会导致进程阻塞被暂停运行。
因此要优雅的解决这个问题,就要从其它角度来思考这个问题了。

不要打电话给我,有需要我会打给你

大家生活中肯定会接到过推销电话,而且不止一个,一天下来接上十个八个推销电话你的身体会被掏空的。
这个场景的关键点在于打电话的人并不知道你是不是要买东西,只能来一遍遍问你,因此一种更好的策略是不要让他们打电话给你,记下他们的电话,有需要的话打给他们,这样推销员就不会一遍一遍的来烦你了(虽然现实生活中这并不可能)。
在这个例子中,你,就好比内核,推销者就好比应用程序,电话号码就好比文件描述符,和你用电话沟通就好比I/O。
现在你应该明白了吧,处理多个文件描述符的更好方法其实就存在于推销电话中。
因此相比上一节中我们通过I/O接口主动问内核这些文件描述符对应的外设是不是已经就绪了,一种更好的方法是,我们把这些感兴趣的文件描述符一股脑扔给内核,并霸气的告诉内核:“我这里有1万个文件描述符,你替我监视着它们,有可以读写的文件描述符时你就告诉我,我好处理”。而不是弱弱的问内核:“第一个文件描述可以读写了吗?第二个文件描述符可以读写吗?第三个文件描述符可以读吗?。。。”
这样应用程序就从“繁忙”的主动变为了清闲的被动反正文件描述可读可写了内核会通知我,能偷懒我才不要那么勤奋。
这是一种更加高效的I/O处理机制,现在我们可以一次处理多路I/O了,为这种机制起一个名字吧,再次祭出“弄不懂原则”,就叫I/O多路复用吧,这就是 I/O multiplexing。

I/O多路复用,I/O multiplexing

multiplexing一词其实多用于通信领域,为了充分利用通信线路,希望在一个信道中传输多路信号,要想在一个信道中传输多路信号就需要把这多路信号结合为一路,将多路信号组合成一个信号的设备被称为multiplexer,显然接收方接收到这一路组合后的信号后要恢复原先的多路信号,这个设备被称为demultiplexer,如图所示:
image.png
回到我们的主题。
所谓I/O多路复用指的是这样一个过程:

  1. 我们拿到了一堆文件描述符(不管是网络相关的、还是磁盘文件相关等等,任何文件描述符都可以)
  2. 通过调用某个函数告诉内核:“这个函数你先不要返回,你替我监视着这些描述符,当这堆文件描述符中有可以进行I/O读写操作的时候你再返回
  3. 当调用的这个函数返回后我们就能知道哪些文件描述符可以进行I/O操作了。
    也就是说通过I/O多路复用我们可以同时处理多路I/O。那么有哪些函数可以用来进行I/O多路复用呢?
    在Linux世界中有这样三种机制可以用来进行I/O多路复用:
    select
    poll
    epoll
    接下来我们就来介绍一下牛掰的I/O多路复用三剑客。
    image.png

I/O多路复用三剑客

本质上select、poll、epoll都是阻塞式I/O,也就是我们常说的同步I/O,原因在于调用这些I/O多路复用函数时如果任何一个需要监视的文件描述符都不可读或者可写那么进程会被阻塞暂停执行,直到有文件描述符可读或者可写才继续运行。

1,select:初出茅庐

在select这种I/O多路复用机制下,我们需要把想监控的文件描述集合通过函数参数的形式告诉select,然后select会将这些文件描述符集合拷贝到内核中,我们知道数据拷贝是有性能损耗的,因此为了减少这种数据拷贝带来的性能损耗,Linux内核对集合的大小做了限制,并规定用户监控的文件描述集合不能超过1024个,同时当select返回后我们仅仅能知道有些文件描述符可以读写了,但是我们不知道是哪一个,因此程序员必须再遍历一边找到具体是哪个文件描述符可以读写了。
因此,总结下来select有这样几个特点:
我能照看的文件描述符数量有限,不能超过1024个
用户给我的文件描述符需要拷贝的内核中
我只能告诉你有文件描述符满足要求了,但是我不知道是哪个,你自己一个一个去找吧(遍历)
因此我们可以看到,select机制的这些特性在高并发网络服务器动辄几万几十万并发链接的场景下无疑是低效的。

2,poll:小有所成

poll和select是非常相似的,poll相对于select的优化仅仅在于解决了文件描述符不能超过1024个的限制,select和poll都会随着监控的文件描述数量增加而性能下降,因此不适合高并发场景。

3,epoll:独步天下

在select面临的三个问题中,文件描述数量限制已经在poll中解决了,剩下的两个问题呢?
针对拷贝问题,epoll使用的策略是各个击破共享内存
实际上文件描述符集合的变化频率比较低,select和poll频繁的拷贝整个集合,内核都快被烦死了,
epoll通过引入epoll_ctl很体贴的做到了只操作那些有变化的文件描述符,同时epoll和内核还成为了好朋友,共享了同一块内存,这块内存中保存的就是那些已经可读或者可写的的文件描述符集合,这样就减少了内核和程序的拷贝开销。
针对需要遍历文件描述符才能知道哪个可读可写这一问题,epoll使用的策略是“当小弟”。在select和poll机制下,进程要亲自下场去各个文件描述符上等待,任何一个文件描述可读或者可写就唤醒进程,但是进程被唤醒后也是一脸懵逼并不知道到底是哪个文件描述符可读或可写,还要再从头到尾检查一遍。
但epoll就懂事多了,主动找到进程要当小弟替大哥出头。
image.png
在这种机制下,进程不需要亲自下场了,进程只要等待在epoll上,epoll代替进程去各个文件描述符上等待,当哪个文件描述符可读或者可写的时候就告诉epoll,epoll用小本本认真记录下来然后唤醒大哥:“进程大哥,快醒醒,你要处理的文件描述符我都记下来了”,这样进程被唤醒后就无需自己从头到尾检查一遍,因为epoll小弟都已经记下来了。
因此我们可以看到,在epoll这种机制下,实际上利用的就是“不要打电话给我,有需要我会打给你”这种策略,进程不需要一遍一遍麻烦的问各个文件描述符,而是翻身做主人了,“你们这些文件描述符有哪个可读或者可写了主动报上来”,这种机制实际上就是大名鼎鼎的事件驱动,Event-driven,这也是我们下一篇的主题。
实际上在Linux平台,epoll基本上就是高并发的代名词

总结

基于一切皆文件的设计哲学,I/O也可以通过文件的形式实现,高并发场景下要与多个文件交互,这就离不开高效的I/O多路复用技术,本文我们详细讲解了什么是I/O多路复用以及使用方法,这其中以epoll为代表的I/O多路复用(基于事件驱动)技术使用非常广泛,实际上你会发现但凡涉及到高并发、高性能的场景基本上都能见到事件驱动的编程方法,当然这也是下一篇我们要重点讲解的主题,敬请期待。

十七、你管这破玩意叫mmap?

十八、彻底理解零拷贝

十九、操作系统与内核有什么区别?

通用底盘技术 **
Canoo公司有一项核心技术专利,这就是它们的通用电动底盘技术,长得是这个样子,非常像一个滑板:
image.png
这个带轮子、有电池、能动的滑板已经包含了一辆车
最核心**的组件,差的就是一个外壳。
这个看起来像滑板的东西就是所谓的电池系统和底盘一体化技术,Canoo公司在它们的通用底盘上加装不同的外壳就能制造出不同的车型。

什么是内核?

在上面这个示例中,包含轮子以及电池系统的底盘就好比内核,而套上外壳加上椅子以及内饰后的整体成品就好比操作系统
内核仅仅是操作系统的一部分,是真正与硬件交互的那部分软件,与硬件交互包括读写硬盘、读写网盘、读写内存以及任何连接到系统中的硬件。
除了与硬件交互外,内核还负责分配资源,分配什么资源呢?所谓资源就是硬件,比如CPU时间、内存、IO等等,这些都是资源。
image.png
现在我们知道了内核负责分配资源,那么问题来了,要怎么分配这些资源呢?答案就是以进程的形式来分配资源。
怎么分配呢?
一句话:虚拟大法好
每个进程都认为自己在独占CPU,这通过CPU时间片来实现,内核让CPU在各个进程之间快速切换,这样程序员写好程序员后直接运行即可,即使在单核系统中运行成百上千个进程都没有问题。
每个进程都认为自己在独占内存,这通过虚拟内存来实现。
有的同学可能会问,为什么都要虚拟化呢?
答案显而易见,因为计算机系统内的资源是有限的,我们只有几个CPU核心、几个G的内存,但却要同时运行几百几千个进程,除此之外我们别无它法。
如果你还知道有其它更高效的方法那么赶紧放下手机,马上将你的思想写成论文发表出来,下一届的图灵奖非你莫属。
因此,内核的职责就是以进程的形式来分配CPU时间,以虚拟内存的形式来分配物理内存,以文件的形式来管理IO设备。

什么是操作系统?

然而只有一个内核实际上是做不了什么真正有用的事情,就像上面示例中那个通用底盘一样,这个底盘确实能跑起来,但你没办法开着这样一个底盘出去浪,因为这个底盘很难用。
因此,你不得不加装上方向盘、座椅以及车身外壳等,同样的道理,内核是给人用的,为了与内核交互,发明了命令行以及图形界面GUI。
image.png
除了给普通用户提供使用的接口之外,操作系统还需要给程序员提供编写程序的接口,当我们写的程序依赖内核提供的服务时是该怎么办呢?
有的同学说我们需要依赖内核提供的服务吗?
想一想,进行网络编程时你有没有自己编写过处理TCP/IP协议栈数据的代码?你有没有自己写代码从网卡上收发数据?都没有,实际上你需要做的仅仅是简单的调用一些socket接口就可以了。
网络编程仅仅是其中的一项,其它还包括文件IO、创建进程、创建线程等等等等,这些是内核提供的,那么我们该怎么使用呢?
答案就是通过所谓的系统调用,system call。
通过系统调用,我们可以像使用普通函数那样向操作系统请求服务,当然,直接使用系统调用是非常繁琐的,因此通常会在这之上提供一层封装
image.png
在Windows平台就是给程序员提供编程接口的是Windows API,这层API包罗万象,不但包括上文提到对系统调用的封装,还包括其它功能,像创建带有图形界面的应用程序等等。
但在Linux世界你找不到一种类似Windows API的东西,毕竟Windows是微软自家产品,什么都可以打包起来,Linux只是一个开源的内核,如果一定要找一个类似的东西话那就是libc,也就是C标准库,这里同样包括了对系统调用的封装以及一些库函数,但libc不包含创建带有图形界面应用程序的功能。
现在我们知道了,操作系统需要提供两种接口:
给用户提供操作接口。
给程序员提供编程接口。
这些就是好比汽车的外壳,我们(用户和程序员)看得见摸得着,外壳加上底盘——也就是内核,才是功能完善的操作系统。
image.png

各种各样的操作系统

实际上我们熟悉的Linux只是内核而不能称得上是操作系统,Ubuntu则可以认为是操作系统,其内核是Linux;RedHat也是操作系统,其内核同样是Linux;我们可以看到,尽管Ubuntu和RedHat是不同的操作系统,但其内核可以是相同的。
这就好比它们可以基于同样的底盘打造出不同的车型。
而我们熟悉的Windows也是操作系统,其内核是Windows NT内核。

总结

内核就像本文开头提到的电动底盘,包含了一个汽车的最核心元素;但这样一个底盘并没有什么实际用处,当搭配上外壳以及座椅后才是一辆真正有用的车,这就好比操作系统。值得注意的是,不同的操作系统可以有相同的内核。
当我们在使用方便的智能手机以及个人PC时不应忘记,正是操作系统在背后的默默工作让一堆硬件电路变得这么好用。