You are on page 1of 385

前言

作为程序员这些年写过很多代码,但在一个阳光明媚的午后我盯着自己屏幕上的代码脑海里浮现出了
一个疑问,“这些代码在底层到底是怎么运行起来的,我写的每一行代码到底是什么意思?”

然而我并没有答案,尽管大部分情况下我的代码“看起来”好像也能“正确”完成工作,可是一旦遇到一
些相对“高级”的问题时往往束手无策,比如程序运行Core Dump、内存泄漏、程序运行起来很慢等
等,这个思考结果着实让我大吃一惊吓出一身冷汗,我竟然对自己所写的代码“一无所知”。

于是我的脑海里紧接着就出现了一个画面,自己就是那个手持火把穿过炸药厂幸存下来而不自知的傻
瓜。

仔细思考后我找到了问题所在,自己的知识体系一直存在漏洞,或者干脆就没有形成知识体系,于是
我决定好好研究一下计算机底层知识,并在学习过程中将其分享出来,于是就形成了这本书。

关于作者

大家好,我是小风,是这本《计算机底层的秘密》作者,电子书的内容来自我的公众号“码农的荒岛
求生”,欢迎大家关注,我会在第一时间将最新文章发布在公众号:

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
你管这破玩意叫CPU?

每次回家开灯时你有没有想过,用你按的开关实际上能打造出 复杂的 CPU来,只不过需要的数量可


能比较多,也就几十亿个吧。

伟大的发明

过去200年人类最重要的发明是什么?蒸汽机?电灯?火箭?这些可能都不是,最重要的也许是这个
小东西:
这个小东西就叫晶体管,你可能会问,晶体管有什么用呢?

实际上晶体管的功能简单到不能再简单,给一端通上电,那么电流可以从另外两端通过,否则不能通
过,其本质就是一个开关。

就是这个小东西的发明让三个人获得了诺贝尔物理学奖,可见其举足轻重的地位。

无论程序员编写的程序多么复杂,软件承载的功能最终都是通过这个小东西简单的开闭完成的,除了
神奇二字,我想不出其它词来。

AND、OR、NOT

现在有了晶体管,也就是开关,在此基础之上就可以搭积木了,你随手搭建出来这样三种组合:

两个开关只有同时打开电流才会通过,灯才会亮
两个开关中只要有一个打开电流就能通过,灯就会亮
当开关关闭时电流通过灯会亮,打开开关灯反而电流不能通过灯会灭

天赋异禀的你搭建的上述组合分别就是:与门,AND Gate、或门,OR gate、非门,NOT gate,用


符号表示就是这样:
道生一、一生二、二生三、三生万物

最神奇的是,你随手搭建的三种电路竟然有一种很amazing的特性,那就是:任何一个逻辑函数最终
都可以通过AND、OR以及NOT表达出来,这就是所谓的逻辑完备性,就是这么神奇。

也就是说给定足够的AND、OR以及NOT门,就可以实现任何一个逻辑函数,除此之外我们不需要任
何其它类型的逻辑门电路,这时我们认为{AND、OR、NOT}就是逻辑完备的。

这一结论的得出吹响了计算机革命的号角,这个结论告诉我们计算机最终可以通过简单的{AND、
OR、NOT}门构造出来,就好比基因。

老子有云:道生一、一生二、二生三、三生万物,实乃异曲同工之妙。
虽然,我们可以用{AND、OR、NOT}来实现所有的逻辑运算,但我们真的需要把所有的逻辑运算都用
{AND、OR、NOT}们实现出来吗?显然不是,而且这也不太可行。

计算能力是怎么来的

现在能生成万物的基础元素与或非门出现了,接下来我们着手设计CPU 最重要的能力:计算,以加法
为例。

由于CPU只认知 0 和 1,也就是二进制,那么二进制的加法有哪些组合呢:

0 + 0,结果为0,进位为0
0 + 1,结果为1,进位为0
1 + 0,结果为1,进位为0
1 + 1,结果为0,进位为1,二进制嘛!

注意进位一列,只有当两路输入的值都是 1 时,进位才是 1 ,看一下你设计的三种组合电路,这就是


与门啊,有没有!

再看下结果一列,当两路输入的值不同时结果为1,输入结果相同时结果为0,这就是异或啊,有没
有!我们说过与或非门是逻辑完备可以生万物的,异或逻辑当然不在话下,用一个与门和一个异或门
就可以实现二进制加法:

上述电路就是一个简单的加法器,就问你神奇不神奇,加法可以实现,其它的也一样能用与或非门实
现,逻辑完备嘛。
根据需要可以将不同的算数运算设计出来,这就是所谓的arithmetic/logic unit,ALU,CPU 中专门
负责运算的模块,本质上和上面的简单电路没什么区别,就是更加复杂而已。

现在,通过与或非门的组合我们获得了计算能力。

但,只有计算能力是不够的,电路需要能记得住信息。

神奇的记忆能力

到目前为止,你设计的组合电路比如加法器天生是没有办法存储信息的,它们只是简单的根据输入得
出输出,但输入输出总的有个地方能够保存起来,这就是需要电路能保存信息。

电路怎么能保存信息呢?你不知道该怎么设计,这个问题解决不了你寝食难安,吃饭时在思考、走路
时在思考,蹲坑时在思考,直到有一天你在梦中遇一位英国物理学家,他给了你这样一个简单但极其
神奇的电路,因为这个电路有记忆功能:

这是两个NAND门的组合,不要紧张,NAND也是有你设计的与或非门组合而成的,所谓NAND门就
是与非门,先与然后取非,比如给定输入1和0,那么与运算后为0,非运算后为1,这就是与非门,这
些不重要。
比较独特的是该电路的组合方式,一个NAND门的输出是两一个NAND门的输入,该电路的组合方式
会生成一种很有趣的特性,只要给S和R段输入1,那么这个电路只会有两种状态:

要么a端为1,此时B=0、A=1、b=0;
要么a端为0,此时B=1、A=0、b=1;

不会再有其他可能了,我们把a端的值作为电路的输出。

此后,如果你把S端置为0的话(R保持为1),那么电路的输出也就是a端永远为1,这时就可以说我们把
1存到电路中了;而如果你把R段置为0的话(S保持为1),那么电路的输出也就是a端永远为0,此时我
们可以说把0存到电路中了。

就问你神奇不神奇,电路竟然具备了信息存储能力。

现在为保存信息你需要同时设置S端和R端,但你的输入是有一个,为此你对电路进行了简答的改造:

这样,当D为0时,整个电路保存的就是0,否则就是1。

这正是我们想要的。

寄存器与内存的诞生

现在你的电路能存储一个比特位了,想存储多个还不简单,简单的组合即可:
我们管这个组合电路就叫寄存器,你没有看错,我们常说的寄存器就是这个东西。

你不满足,还要继续搭建更加复杂的电路以存储更多信息,同时提供寻址功能,就这样内存也诞生
了。

寄存器、内存都离不开上一节那个简单电路,只要通电,这个电路中就保存信息,但是断点后保存的
信息就丢掉了,现在你应该明白为什么内存在断电后信息就丢了吧。

硬件还是软件?

现在我们可以计算、也可以存储,但现在还有一个问题,那就是尽管我们可以用{AND、OR、NOT}表
达出所有的逻辑函数,但我们真的有必要把所有的逻辑运算都用与或非门实现出来吗?这显然是不现
实的。

这就好比厨师,你没有听说哪个酒店的厨师专门只做一道菜吗?

最终的成品是比较复杂的,千差万别,但制作每道菜品的方式大同小异,其中包括刀工、颠勺技术
等,这些是基本功,制作每道菜品都要经过这些步骤,变化的也无非就是食材、火候、调料的差异,
这些放到菜谱中即可,这样给他一个菜谱他就能制作出任意的菜来,在这里厨师就好比硬件,菜谱就
好比软件。
同样的道理,我们没有必要为所有的计算逻辑实现出对应的硬件,硬件只需要提供最基本的功能,最
终所有的计算逻辑都通过这些最基本的功能表达出来就好,这就是所谓的软件一词的来源,硬件不可
变,但软件可变,因此称为软件,不变的硬件但提供不同的软件就能让硬件提供全新的功能,无比天
才的思想,人类真的是太聪明了。

同样一台计算机硬件,安装上word你就能编辑文档,安装上VS你就能写代码,安装上游戏你就能玩
王者农药,硬件还是那套硬件,提供不同的软件就是实现不同的功能,每次打开电脑使用各种App时
没有在内心高呼一声牛逼你都对不起计算机这么伟大的发明创造,这就是所谓的通用计算设备,这一
思想是计算机科学的祖师爷图灵提出的。
扯远了,接下来我们看下硬件是怎么提供所谓的基本功能的。

硬件的基本功

让我们来思考一个问题,CPU怎么能知道自己要去对两个数进行加法计算,以及哪两个数进行加法计
算呢?

很显然,你得告诉CPU,该怎么告诉呢?还记得上一节中给初始的菜谱吗?没错,CPU也需要一张菜
谱告诉自己该接下来该干啥,在这里菜谱就是机器指令,指令通过我们上述实现的组合电路来执行。

接下来我们面临另一个问题,那就是这样的指令应该会很多吧,废话,还是以加法指令为例,你可以
让CPU计算1+1,也可以计算1+2等等,实际上单单加法指令就可以有无数中组合,显然CPU不可能
去实现所有的指令。

实际上CPU只需要提供加法操作,你提供操作数就可以了,CPU 说:“我可以打人”,你告诉CPU该打
谁、CPU 说:“我可以唱歌”,你告诉CPU唱什么,CPU 说我可以做饭,你告诉CPU该做什么饭,CPU
说:“我可以炒股”,你告诉CPU快滚一边去吧韭菜。

因此我们可以看到CPU只提供机制或者说功能(打人、唱歌、炒菜,加法、减法、跳转),我们提供策
略(打谁、歌名、菜名,操作数,跳转地址)。

CPU 表达机制就通过指令集来实现的。

指令集与指令执行
指令集告诉我们 CPU 可以执行什么指令,每种指令需要提供什么样的操作数。不同类型的CPU会有
不同的指令集。

指令集中的指令其实都非常简单,画风大体上是这样的:

从内存中读一个数,地址是abc
对两个数加和
检查一个数是不是大于6
把这数存储到内存,地址是abc
等等

看上去很像碎碎念有没有,这就是机器指令,我们用高级语言编写的程序,比如对一个数组进行排
序,最终都会等价转换为上面的碎碎念指令,然后 CPU 一条一条的去执行,很神奇有没有。

接下来我们看一条可能的机器指令:

这条指令占据16比特,其中前四个比特告诉我们这是加法指令,这意味着该CPU的指令集中可以包含
2^4也就是16个机器指令,这四个比特位告诉我们该指令可以做什么,剩下的bite告诉我们该怎么
做,也就是把寄存器R6和寄存器R2中的值相加然后写到寄存器R6中。

可以看到,机器指令是非常繁琐的,现代程序员都使用高级语言来编写程序,关于高级程序语言以及
机器指令的话题请参见《你管这破玩意叫编程语言》。

指挥家:让我们演奏一曲

现在我们的电路有了计算功能、存储功能,还可以通过指令告诉该电路执行什么操作,还有一个问题
没有解决。

我们的电路有很多部分,用来计算的、用来存储的,以最简单的加法为例,假设我们要计算1+1,这
两个数分别来自寄存器R1 和 R2,要知道寄存器中可以保存任意值,我们怎么能确保加法器开始工作
时R1和R2中在这一时刻保存的都是1而不是其它数?

这个问题就是靠什么来协调靠什么来同步各个部分让它们协同工作呢?就像一场成功的交响乐演出是
离不开指挥家,我们的计算组合电路中也需要这样一个指挥家。
负责指挥角色的就是时钟信号。

时钟信号就像指挥家手里的拿的指挥棒,指挥棒挥动一下整个乐队会整齐划一的有个相应动作,同样
的,在时钟信号的每一次电压改变,整个电路中的各个寄存器(也就是整个电路的状态)会更新一下,
这样我们就能确保整个电路协同工作不会这里提到的问题。

现在你应该知道CPU的主频是什么意思了吧,主频是说一秒钟指挥棒挥动了多少次,当然主频越高
CPU在一秒内完成的操作也就越多。

大功告成

现在我们有了可以完成各种计算的ALU、可以存储信息的寄存器以及控制它们系统工作的时钟信号,
这些就是一个极简版的CPU啦。

总结

一个小小的开关竟然能构造出功能强大的 CPU ,这背后理论和制造工艺的突破是人类史上的里程碑


时刻,说 CPU 是智慧的结晶简直再正确不过。

本文从一枚开关开始讲解了 CPU 构造的基本原理,希望这篇对大家理解 CPU 有所帮助。

关注作者
也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

你管这破玩意叫线程?
一起要从CPU说起

你可能会有疑问,讲多线程为什么要从CPU说起呢?原因很简单,在这里没有那些时髦的概念,你可
以更加清晰的看清问题的本质。

CPU并不知道线程、进程之类的概念。

CPU只知道两件事:

1. 从内存中取出指令
2. 执行指令,然后回到1

你看,在这里CPU确实是不知道什么进程、线程之类的。

接下来的问题就是CPU从哪里取出指令呢?答案是来自一个被称为Program Counter(简称PC)的寄存
器,也就是我们熟知的程序计数器,在这里大家不要把寄存器想的太神秘,你可以简单的把寄存器理
解为内存,只不过存取速度更快而已。

PC寄存器中存放的是什么呢?这里存放的是指令在内存中的地址,什么指令呢?是CPU将要执行的下
一条指令。
那么是谁来设置PC寄存器中的指令地址呢?

原来PC寄存器中的地址默认是自动加1的,这当然是有道理的,因为大部分情况下CPU都是一条接一
条顺序执行,当遇到if、else时,这种顺序执行就被打破了,CPU在执行这类指令时会根据计算结果
来动态改变PC寄存器中的值,这样CPU就可以正确的跳转到需要执行的指令了。

聪明的你一定会问,那么PC中的初始值是怎么被设置的呢?

在回答这个问题之前我们需要知道CPU执行的指令来自哪里?是来自内存,废话,内存中的指令是从
磁盘中保存的可执行程序加载过来的,磁盘中可执行程序是编译器生成的,编译器又是从哪里生成的
机器指令呢?答案就是我们定义的函数。
注意是函数,函数被编译后才会形成CPU执行的指令,那么很自然的,我们该如何让CPU执行一个函
数呢?显然我们只需要找到函数被编译后形成的第一条指令就可以了,第一条指令就是函数入口。

现在你应该知道了吧,我们想要CPU执行一个函数,那么只需要把该函数对应的第一条机器指令的地
址写入PC寄存器就可以了,这样我们写的函数就开始被CPU执行起来啦。

你可能会有疑问,这和线程有什么关系呢?

从CPU到操作系统

上一小节中我们明白了CPU的工作原理,我们想让CPU执行某个函数,那么只需要把函数对应的第一
条机器执行装入PC寄存器就可以了,这样即使没有操作系统我们也可以让CPU执行程序,虽然可行但
这是一个非常繁琐的过程,我们需:
在内存中找到一块大小合适的区域装入程序
找到函数入口,设置好PC寄存器让CPU开始执行程序

这两个步骤绝不是那么容易的事情,如果每次在执行程序时程序员自己手动实现上述两个过程会疯掉
的,因此聪明的程序员就会想干脆直接写个程序来自动完成上面两个步骤吧。

机器指令需要加载到内存中执行,因此需要记录下内存的起始地址和长度;同时要找到函数的入口地
址并写到PC寄存器中,想一想这是不是需要一个数据结构来记录下这些信息:

struct *** {
void* start_addr;
int len;

void* start_point;
};

接下来就是起名字时刻。

这个数据结构总要有个名字吧,干脆就叫进程(Process)好了,我们的指导原则就是一定要听上去比
较神秘,总之大家都不容易弄懂就对了,我将其称为“弄不懂原则”。

就这样进程诞生了。

CPU执行的第一个函数也起个名字,第一个要被执行的函数听起来比较重要,干脆就叫main函数吧。

完成上述两个步骤的程序也要起个名字,根据“弄不懂原则”这个“简单”的程序就叫操作系统
(Operating System)好啦。

就这样操作系统诞生了,程序员再也不用自己手动加载可执行程序了。

现在进程和操作系统都有了,一切看上去都很完美。

从单核到多核,如何充分利用多核

人类的一大特点就是生命不息折腾不止,从单核折腾到了多核。

这时,假设我们想写一个程序并且要分利用多核该怎么办呢?

有的同学可能会说不是有进程吗,多开几个进程不就可以了?听上去似乎很有道理,但是主要存在这
样几个问题:

进程是需要占用内存空间的(从上一节能看到这一点),如果多个进程基于同一个可执行程序,那
么这些进程其内存区域中的内容几乎完全相同,这显然会造成内存的浪费
计算机处理的任务可能是比较复杂的,这就涉及到了进程间通信,由于各个进程处于不同的内存
地址空间,进程间通信天然需要借助操作系统,这就在增大编程难度的同时也增加了系统开销
该怎么办呢?

从进程到线程

让我再来仔细的想一想这个问题,所谓进程无非就是内存中的一段区域,这段区域中保存了CPU执行
的机器指令以及函数运行时的堆栈信息,要想让进程运行,就把main函数的第一条机器指令地址写
入PC寄存器,这样进程就运行起来了。

进程的缺点在于只有一个入口函数,也就是main函数,因此进程中的机器指令只能被一个CPU执
行,那么有没有办法让多个CPU来执行同一个进程中的机器指令呢?
聪明的你应该能想到,既然我们可以把main函数的第一条指令地址写入PC寄存器,那么其它函数和
main函数又有什么区别呢?

答案是没什么区别,main函数的特殊之处无非就在于是CPU执行的第一个函数,除此之外再无特别之
处,我们可以把PC寄存器指向main函数,就可以把PC寄存器指向任何一个函数。

当我们把PC寄存器指向非main函数时,线程就诞生了。

至此我们解放了思想,一个进程内可以有多个入口函数,也就是说属于同一个进程中的机器指令可以
被多个CPU同时执行。

注意,这是一个和进程不同的概念,创建进程时我们需要在内存中找到一块合适的区域以装入进程,
然后把CPU的PC寄存器指向main函数,也就是说进程中只有一个执行流。
但是现在不一样了,多个CPU可以在同一个屋檐下(进程占用的内存区域)同时执行属于该进程的多个
入口函数,也就是说现在一个进程内可以有多个执行流了。

总是叫执行流好像有点太容易理解了,再次祭出”弄不懂原则“,起个不容易懂的名字,就叫线程吧。

这就是线程的由来。

操作系统为每个进程维护了一堆信息,用来记录进程所处的内存空间等,这堆信息记为数据集A。

同样的,操作系统也需要为线程维护一堆信息,用来记录线程的入口函数或者栈信息等,这堆数据记
为数据集B。

显然数据集B要比数据A的量要少,同时不像进程,创建一个线程时无需去内存中找一段内存空间,因
为线程是运行在所处进程的地址空间的,这就是为什么各种教材上提的创建线程要比创建进程快(当然
还有其它原因)。

值得注意的是,有了线程这个概念后,我们只需要开启一个进程并创建多个线程就可以让所有CPU都
忙起来,这就是所谓高性能、高并发的根本所在。很简单,只需要创建出数量合适的线程就可以了。

另外值得注意的一点是,由于各个线程共享进程的内存地址空间,因此线程之间的通信无需借助操作
系统,这给程序员带来极大方便的同时也带来了无尽的麻烦,多线程遇到的多数问题都出自于线程间
通信简直太方便了以至于非常容易出错。出错的根源在于CPU执行指令时根本没有线程的概念,多线
程编程面临的互斥与同步问题需要程序员自己解决,关于互斥与同步问题限于篇幅就不详细展开了,
大部分的操作系统资料都有详细讲解。
最后需要提醒的是,不是说一定要有多核才能使用多线程,在单核的情况下一样可以创建出多个线
程,原因在于线程是操作系统层面的实现,和有多少个核心是没有关系的,CPU在执行机器指令时也
意识不到执行的机器指令属于哪个线程。

线程与内存

在前的讨论中我们知道了线程和CPU的关系,也就是把CPU的PC寄存器指向线程的入口函数,这样线
程就可以运行起来了,这就是为什么我们创建线程时必须指定一个入口函数的原因。那么线程和内存
又有什么关联呢?

我们知道函数在被执行的时产生的数据包括函数参数、局部变量、返回地址等信息,这些信息是保存
在栈中的,线程这个概念还没有出现时进程中只有一个执行流,因此只有一个栈,这个栈的栈底就是
进程的入口函数,也就是main函数,那么有了线程以后了呢?

有了线程以后一个进程中就存在多个执行入口,即同时存在多个执行流,那么只有一个执行流的进程
需要一个栈来保存运行时信息,那么很显然有多个执行流时就需要有多个栈来保存各个执行流的信
息,也就是说操作系统要为每个线程在进程的地址空间中分配一个栈,即每个线程都有独属于自己的
栈,能意识到这一点是极其关键的。
同时我们也可以看到,创建线程是要消耗进程内存空间的,这一点也值得注意。

线程的使用

现在有了线程的概念,那么接下来作为程序员我们该如何使用线程呢?
从声明周期的角度讲,线程要处理的任务有两类:长任务和短任务。

长任务,顾名思义,就是任务存活的时间很长,比如以我们常用的word为例,我们在word中编辑的
文字需要保存在磁盘上,往磁盘上写数据就是一个任务,那么这时一个比较好的方法就是专门创建一
个写磁盘的线程,该写线程的生命周期和word进程是一样的,只要打开word就要创建出该写线程,
当用户关闭word时该线程才会被销毁,这就是长任务。

这种场景非常适合创建专用的线程来处理某些特定任务,这种情况比较简单。

有长任务,相应的就有短任务。

短任务,这个概念也很简单,那就是任务的处理时间很短,比如一次网络请求、一次数据库查询等,
这种任务可以在短时间内快速处理完成。因此短任务多见于各种Server,像web server、database
server、file server、mail server等,这也是互联网行业的同学最常见的场景,这种场景是我们要重
点讨论的。

这种场景有两个特点:一个是任务处理所需时间短;另一个是任务数量巨大。

如果让你来处理这种类型的任务该怎么办呢?

你可能会想,这很简单啊,当server接收到一个请求后就创建一个线程来处理任务,处理完成后销毁
该线程即可,So easy。
这种方法通常被称为thread-per-request,也就是说来一个请求就创建一个线程,如果是长任务,那
么这种方法可以工作的很好,但是对于大量的短任务这种方法虽然实现简单但是有这样几个缺点:

1. 从前几节我们能看到,线程是操作系统中的概念(这里不讨论用户态线程实现、协程之类),因此
创建线程天然需要借助操作系统来完成,操作系统创建和销毁线程是需要消耗时间的
2. 每个线程需要有自己独立的栈,因此当创建大量线程时会消耗过多的内存等系统资源

这就好比你是一个工厂老板(想想都很开心有没有),手里有很多订单,每来一批订单就要招一批工
人,生产的产品非常简单,工人们很快就能处理完,处理完这批订单后就把这些千辛万苦招过来的工
人辞退掉,当有新的订单时你再千辛万苦的招一遍工人,干活儿5分钟招人10小时,如果你不是励志
要让企业倒闭的话大概是不会这么做到的,因此一个更好的策略就是招一批人后就地养着,有订单时
处理订单,没有订单时大家可以闲呆着。

这就是线程池的由来。

从多线程到线程池

线程池的概念是非常简单的,无非就是创建一批线程,之后就不再释放了,有任务就提交给这些线程
处理,因此无需频繁的创建、销毁线程,同时由于线程池中的线程个数通常是固定的,也不会消耗过
多的内存,因此这里的思想就是复用、可控。
线程池是如何工作的

可能有的同学会问,该怎么给线程池提交任务呢?这些任务又是怎么给到线程池中线程呢?

很显然,数据结构中的队列天然适合这种场景,提交任务的就是生产者,消费任务的线程就是消费
者,实际上这就是经典的生产者-消费者问题。

现在你应该知道为什么操作系统课程要讲、面试要问这个问题了吧,因为如果你对生产者-消费者问题
不理解的话,本质上你是无法正确的写出线程池的。

限于篇幅在这里博主不打算详细的讲解生产者消费者问题,参考操作系统相关资料就能获取答案。这
里博主打算讲一讲一般提交给线程池的任务是什么样子的。

一般来说提交给线程池的任务包含两部分:1) 需要被处理的数据;2) 处理数据的函数

struct task {
void* data; // 任务所携带的数据
handler handle; // 处理数据的方法
}

线程池中的线程会阻塞在队列上,当生产者向队列中写入数据后,线程池中的某个线程会被唤醒,该
线程从队列中取出上述结构体,执行:

while(true) {
struct task = GetFromQueue(); // 从队列中取出数据
task->handle(task->data); // 处理数据
}
以上就是线程池最核心的部分。

理解这些你就能明白线程池是如何工作的了。

线程池中线程的数量

现在线程池有了,那么线程池中线程的数量该是多少呢?

在接着往下看前先自己想一想这个问题。

如果你能看到这里说明还没有睡着。

要知道线程池的线程过少就不能充分利用CPU,线程创建的过多反而会造成系统性能下降,内存占用
过多,线程切换造成的消耗等等。因此线程的数量既不能太多也不能太少,那到底该是多少呢?

回答这个问题,你需要知道线程池处理的任务有哪几类,有的同学可能会说你不是说有两类吗?长任
务和短任务,这个是从生命周期的角度来看的,那么从处理任务所需要的资源角度看也有两种类型,
这就是没事儿找抽型和。。啊不,是CPU密集型和I/O密集型。

CPU密集型

所谓CPU密集型就是说处理任务不需要依赖外部I/O,比如科学计算、矩阵运算等等。在这种情况下
只要线程的数量和核数基本相同就可以充分利用CPU资源。

I/O密集型

这一类任务可能计算部分所占用时间不多,大部分时间都用在了比如磁盘I/O、网络I/O等,这种情况
下就稍微复杂一些了,你需要利用性能测试工具评估出用在I/O等待上的时间,这里记为WT(wait
time),以及CPU计算所需要的时间,这里极为CT(computing time),那么对于一个N核的系统,合适
的线程数大概是N * (1 + WT/CT),假设I/O等待时间和计算时间相同,那么你大概需要2N个线程才能
充分利用CPU资源,注意这只是一个理论值,具体设置多少需要根据真实的业务场景进行测试。

当然充分利用CPU不是唯一需要考虑的点,随着线程数量的增多,内存占用、系统调度、打开的文件
数量、打开的socker数量以及打开的数据库链接等等是都需要考虑的。

因此这里没有万能公式,要具体情况具体分析。

线程池不是万能的
线程池仅仅是多线程的一种使用形式,因此多线程面临的问题线程池同样不能避免,像死锁问题、
race condition问题等等,关于这一部分同样可以参考操作系统相关资料就能得到答案,所以基础很
重要呀老铁们。

线程池使用的最佳实践

线程池是程序员手中强大的武器,互联网公司的各个server上几乎都能见到线程池的身影,使用线程
池前你需要考虑:

充分理解你的任务,是长任务还是短任务、是CPU密集型还是I/O密集型,如果两种都有,那么
一种可能更好的办法是把这两类任务放到不同的线程池中,这样也许可以更好的确定线程数量
如果线程池中的任务有I/O操作,那么务必对此任务设置超时,否则处理该任务的线程可能会一
直阻塞下去
线程池中的任务最好不要同步等待其它任务的结果

总结

本节我们从CPU开始一路来到常用的线程池,从底层到上层、从硬件到软件。注意,这里通篇没有出
现任何特定的编程语言,线程不是语言层面的(依然不考虑用户态线程),但是当你真正理解了线程
后,相信你可以在任何一门语言下用好多线程,你需要理解的是道,此后才是术。

希望这篇文章对大家理解线程以及线程池有所帮助。

接下的一篇将是与线程池密切配合实现高性能、高并发的又一关键技术:I/O多路复用,敬请期待。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
执行I/O操作时底层发生了什么?

你有没有想过当我们执行I/O操作时计算机底层都发生了些什么?

在回答这个问题之前,我们先来看下为什么对于计算机来说I/O是极其重要的。

不能执行I/O的计算机是什么?

相信对于程序员来说I/O操作是最为熟悉不过的了:

当我们使用C语言中的printf、C++中的"<<",Python中的print,Java中的System.out.println等时,
这是I/O;

当我们使用各种语言读写文件时,这也是I/O;

当我们通过TCP/IP进行网络通信时,这同样是I/O;

当我们使用鼠标龙飞凤舞时,当我们扛起键盘在评论区里指点江山亦或是埋头苦干努力制造bug时、
当我们能看到屏幕上的漂亮的图形界面时等等,这一切都是I/O。

想一想,如果没有I/O计算机该是一种多么枯燥的设备,不能看电影、不能玩游戏,也不能上网,这
样的计算机最多就是一个大号的计算器。

既然I/O这么重要,那么到底什么才是I/O呢?
什么是I/O

I/O就是简单的数据Copy,仅此而已。

这一点很重要,为了加深大家的印象,来,everybody,follow me,那边树上的朋友,还有那边墙
上的朋友们,举起你们的双手,跟我唱,苍茫的天涯是。。。sorry,I/O仅仅就是数据copy、I/O仅
仅就是数据copy。

既然是copy数据,又是从哪里copy到哪里呢?

如果数据是从外部设备copy到内存中,这就是Input。

如果数据是从内存copy到外部设备,这就是Output。

内存与外部设备之间的数据copy就是I/O(Input/Output),仅此而已。
I/O与CPU

现在我们知道了什么是I/O,接下来就是重点部分了,大家注意,坐稳了。

我们知道现在的CPU其主频都是数Gz起步,这是什么意思的呢?简单说就是CPU执行机器指令的速度
是纳秒级别的,而通常的I/O比如磁盘操作,一次磁盘seek大概在毫秒级别,因此如果我们把CPU比
作战斗机的话,那么I/O操作的速度就是肯德鸡。
也就是说当我们的程序跑来时(CPU执行机器指令),其速度是要远远快于I/O速度的,那么接下来的问
题就是二者速度相差这么大,那么我们该如何设计、该如何更加合理的高效利用系统资源呢?

既然有速度差异,而且进程在执行完I/O操作前不能继续向前推进,那么显然只有一个办法,那就是
等待,wait。

同样是等待,有聪明的等待,也有傻傻的等待,简称傻等,那么是选择聪明的等待呢还是选择傻等
呢?

假设你是一个急性子(CPU),需要等待一个重要的文件,不巧的是这个文件只能快递过来(I/O),那么
这时你是选择什么事情都不干了,深情的的注视着门口就像盼望着你的哈尼一样专心等待这个快递
呢?还是暂时先不要管快递了,玩个游戏看个电影刷会儿短视频等快递来了再说呢?

很显然,更好的方法就是先去干其它事情,快递来了再说。

因此这里的关键点就是手头上的事情可以先暂停,切换到其它任务,等快递过来了再切换回来。

理解了这一点你就能明白执行I/O操作时底层都发生了什么。

我们以读取磁盘文件内容为例。

执行I/O时底层都发生了什么

在上一篇《看完这篇还不懂多线程与线程池你来打我》中,我们引入了进程和线程的概念,实际上操
作系统调度的是线程而不是进程,为了更加清晰的理解I/O过程,我们暂时假设操作系统只有进程这
样的概念,先不去考虑线程,这并不会影响我们的讨论。

现在内存中有两个进程,进程A和进程B,当前进程A正在运行,如图所示:
进程A中有一段读取文件的代码,不管在什么语言中通常我们自定义一个用来装数据的buff,然后调
用read之类的函数,像这样:

read(buff);

这就是一种典型的I/O操作,当CPU执行到这段代码的时候会向磁盘发送读取请求,注意与CPU执行
指令的速度相比,I/O操作操作是非常慢的,因此操作系统是不可能把宝贵的计算资料浪费在无谓的
等待上的,这时重点来了,注意接下来是重点哦。

由于外部设备执行I/O操作是相当慢的,因此在I/O操作完成之前进程是无法继续向前推进的,这就是
所谓的阻塞,即通常所说的block。操作系统检测到进程向I/O设备发起请求后就暂停进程的运行,怎
么暂停运行呢?很简单,只需要记录下当前进程的运行状态并把CPU的PC寄存器指向其它进程的指令
就可以了。
进程有暂停就会有继续执行,因此操作系统必须保存被暂停的进程以备后续继续执行,显然我们可以
用队列来保存被暂停执行的进程,如图所示,进程A被暂停执行并被放到阻塞队列中(注意,不同的操
作系统会有不同的实现,可能每个I/O设备都有一个对应的阻塞队列,但这种实现细节上的差异不影
响我们的讨论)。

这时操作系统已经向磁盘发送了I/O请求,因此磁盘driver开始将磁盘中的数据copy到进程A的buff
中,注意虽然这时进程A已经被暂停执行了,但这并不妨碍磁盘向内存中copy数据。注意,现代磁盘
想内存copy数据时无需借助CPU的帮助,这就是所谓的DMA(Direct Memory Access),这个过程如图
所示:
让磁盘先copy着数据,我们接着聊。

实际上操作系统中除了有阻塞队列之外也有就绪队列,所谓就绪队列是指队列里的进程准备就绪可以
被CPU执行了,你可能会问为什么不直接执行非要有个就绪队列呢?答案很简单,那就是僧多粥少,
在只有2个核的机器上可以创建出成千上万个进程,CPU不可能同时执行这么多的进程,因此必然存
在这样的进程,即使其一切准备就绪也不能被分配到计算资源,这样的进程就被放到了就绪队列。

现在进程B就位于就绪队列,万事俱备只欠CPU,如图所示:
当进程A被暂停执行后CPU是不可以闲下来的,因此就绪队列中还有嗷嗷待哺的进程,这时操作系统
开始在就绪队列中找可以执行的下一个进程,也就是这里的进程B。

此时操作系统将进程B从就绪队列中取出,找出进程B被暂停时执行到的机器指令位置,然后将CPU的
PC寄存器指向该位置,这样进程B就开始运行啦,如图所示:
注意,注意,接下来的这段是重点中的重点。

注意观察上图,此时进程B在被CPU执行,磁盘在向进程A的内存空间中copy数据,看出来了吗,大
家都在忙,谁都没有在闲着,数据copy和指令执行在同时进行,在操作系统的调度下CPU、磁盘都得
到了充分的利用,这就是程序员的智慧所在。

现在你应该理解为什么操作系统这么重要了吧。

此后磁盘终于将全部数据都copy到了进程A的内存中,这时磁盘通知操作系统任务完成啦,你可能会
问怎么通知呢?这就是中断。

操作系统接收到磁盘中断后发现数据copy完毕,进程A重新获得继续运行的资格,这时操作系统小心
翼翼的把进程A从阻塞队列放到了就绪队列当中,如图所示:
注意,操作系统是不会直接运行进程A的,进程A必须被放到就绪队列中等待,这样对大家都公平。

此后进程B继续执行,进程A继续等待,进程B执行了一会儿后操作系统认为进程B执行的时间够长
了,因此把进程B放到就绪队列,并把进程A取出继续执行。

注意操作系统把进程B放到的是就绪队列,因此进程B被暂停运行仅仅是因为时间片到了而不是因为发
起I/O请求被阻塞,如图所示:
进程A继续执行,此时buff中已经装满了想要的数据,进程A就这样愉快的运行下去了,就好像从来
没有被暂停过一样,进程对于自己被暂停一事一无所知,这就是操作系统的魔法。

现在你应该明白了I/O是一个怎样的过程了吧。

这种进程执行I/O操作被阻塞暂停执行的方式被称为阻塞式I/O,blocking I/O,这也是最常见最容易
理解的I/O方式,有阻塞式I/O就有非阻塞式I/O,在这里我们暂时先不考虑这种方式。

零拷贝,Zero-copy

在本节开头我们说过暂时只考虑进程而不考虑线程,现在我们放宽这个条件,实际上也非常简单,只
需要把前图中调度的进程改为线程就可以了,这里的讨论对于线程一样成立。
最后需要注意的一点就是上面的讲解中我们直接把磁盘数据copy到了进程空间中,但实际上一般情况
下I/O数据是要首先copy到操作系统内部,然后操作系统再copy到进程空间中。因此我们可以看到这
里其实还有一层经过操作系统的copy,对于性能要求很高的场景其实也是可以绕过操作系统直接进行
数据copy的,这也是本文描述的场景,这种绕过操作系统直接进行数据copy的技术被称为Zero-
copy,也就零拷贝,高并发、高性能场景下常用的一种技术,原理上很简单吧。

总结

本文讲解的是程序员常用的I/O,一般来说作为程序员我们无需关心,但是理解I/O背后的底层原理对
于设计高性能、高并发系统是极为有益的,希望这篇能对大家加深对I/O的认识有所帮助。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
从小白到高手,你需要理解同步与异步

在这篇文章中我们来讨论一下到底什么是同步,什么是异步,以及在编程中这两个概念到底意味着什
么,这些是进一步掌握高性能、高并发技术的基础,因此非常关键。

相信很多同学遇到同步异步这两个词的时候大脑瞬间就像红绿灯失灵的十字路口一样陷入一片懵逼的
状态,是的,这两个看上去很像实际上也很像的词汇给博主造成过很大的困扰,这两个词背后所代表
的含义到底是什么呢?

我们先从工作场景讲起。

苦逼程序员

假设现在老板分配给了你一个很紧急并且很重要的任务,让你下班前必须写完(万恶的资本主义)。
为了督促进度,老板搬了个椅子坐在一边盯着你写代码。

你心里肯定已经骂上了“WTF,你有这么闲吗?盯着老子,你就不能去干点其他事情吗?”

老板仿佛接收到了你的脑电波一样:“我就在这等着,你写完前我哪也不去,厕所也不去”
这个例子中老板交给你任务后就一直等待什么都不做直到你写完,这个场景就是所谓的同步。

第二天,老板又交给了你一项任务。

不过这次就没那么着急啦,这次老板轻描淡写“小伙子可以啊,不错不错,你再努力干一年,明年我
就财务自由了,今天的这个任务不着急,你写完告诉我一声就行”。

这次老板没有盯着你写代码而是转身刷视频去了,你写完后简单的和老板报告了一声“我写完了”。
这个例子老板交代完任务就去忙其它事情,你完成任务后简单的告诉老板任务完成,这就是所谓的异
步。

值得注意的是,在异步这种场景下重点是在你写代码的同时老板在自己刷剧,这两件事在同时进行,
因此这就是为什么一般来说异步比同步高效的本质所在,不管同步异步应用在什么场景下。

因此,我们可以看到同步这个词往往和任务的“依赖”、“关联”、“等待”等关键词相关,而异步往往和任
务的“不依赖”,“无关联”,“无需等待”,“同时发生”等关键词相关。

By the way,如果遇到一个在身后盯着你写代码的老板,三十六计走为上策。
打电话与发邮件

作为一名苦逼的程序员是不能只顾埋头搬砖的,平时工作中的沟通免除不了,其中一种高效的沟通方
式是吵架。。。啊不,是电话。

call

通常打电话时都是一个人在说另一个人听,一个人在说的时候另一个人等待,等另一个人说完后再接
着说,因此在这个场景中你可以看到,“依赖”、“关联”、“等待”这些关键词出现了,因此打电话这种沟
通方式就是所谓的同步。

另一种码农常用的沟通方式是邮件。
email

邮件是另一种必不可少沟通方式,因为没有人傻等着你写邮件什么都不做,因此你可以慢慢悠悠的
写,当你在写邮件时收件人可以去做一些像摸摸鱼啊、上个厕所、和同时抱怨一下为什么十一假期不
放两周之类有意义的事情。

同时当你写完邮件发出去后也不需要干巴巴的等着对方什么都不做,你也可以做一些像摸鱼之类这样
有意义的事情。
在这里,你写邮件别人摸鱼,这两件事又在同时进行,收件人和发件人都不需要相互等待,发件人写
完邮件的时候简单的点个发送就可以了,收件人收到后就可以阅读啦,收件人和发件人不需要相互依
赖、不需要相互等待。

你看,在这个场景下“不依赖”,“无关联”,“无需等待”这些关键词就出现了,因此邮件这种沟通方式就
是异步的。

同步调用

现在终于回到编程的主题啦。

既然现在我们已经理解了同步与异步在各种场景下的意义(I hope so),那么对于程序员来说该怎样理


解同步与异步呢?

我们先说同步调用,这是程序员最熟悉的场景。

一般的函数调用都是同步的,就像这样:

funcA() {
// 等待函数funcB执行完成
funcB();

// 继续接下来的流程
}

funcA调用funcB,那么在funcB执行完前,funcA中的后续代码都不会被执行,也就是说funcA必须
等待funcB执行完成,就像这样:
从上图中我们可以看到,在funcB运行期间funcA什么都做不了,这就是典型的同步。

注意,一般来说,像这种同步调用,funcA和funcB是运行在同一个线程中的,这是最为常见的情
况。

但值得注意的是,即使运行在两个不能线程中的函数也可以进行同步调用,像我们进行IO操作时实际
上底层是通过系统调用(关于系统调用请参考《程序员应如何理解系统调用》)的方式向操作系统发
出请求的,比如磁盘文件读取:

read(file, buf);

这就是我们在《读取文件时,程序经历了什么》中描述的阻塞式I/O,在read函数返回前程序是无法
继续向前推进的

read(file, buf);
// 程序暂停运行,
// 等待文件读取完成后继续运行
如图所示:

只有当read函数返回后程序才可以被继续执行。

当然,这也是同步调用,但是和上面的同步调用不同的是,函数和被调函数运行在不同的线程中。

因此我们可以得出结论,同步调用和函数与被调函数是否运行在同一个线程是没有关系的。

在这里我们还要再次强调,同步方式下函数和被调函数无法同时进行。

同步编程对程序员来说是最自然最容易理解的。

但容易理解的代价就是在一些场景下,注意,是在某些场景不是所有场景哦,同步并不是高效的,因
为任务没有办法同时进行。

接下来我们看异步调用。

异步调用
有同步调用就有异步调用。

如果你真的理解了本节到目前为止的内容的话,那么异步调用对你来说不是问题。

一般来说,异步调用总是和I/O操作等耗时较高的任务如影随形,像磁盘文件读写、网络数据的收
发、数据库操作等。

我们还是以磁盘文件读取为例。

在read函数的同步调用方式下,文件读取完之前调用方是无法继续向前推进的,但如果read函数可以
异步调用情况就不一样了。

假如read函数可以异步调用的话,即使文件还没有读取完成,read函数也可以立即返回。

read(file, buff);
// read函数立即返回
// 不会阻塞当前程序

就像这样:
可以看到,在异步这种调用方式下,调用方不会被阻塞,函数调用完成后可以立即执行接下来的程
序。

这时异步的重点就在于调用方接下来的程序执行可以和文件读取同时进行,从上图中我们也能看出这
一点,这就是异步的高效之处。

但是,请注意,异步调用对于程序员来说在理解上是一种负担,代码编写上更是一种负担,总的来
说,上帝在为你打开一扇门的时候会适当的关上一扇窗户。

有的同学可能会文,在同步调用下,调用方不再继续执行而是暂停等待,被调函数执行完后很自然的
就是调用方继续执行,那么异步调用下调用方怎知到被调函数是否执行完成呢?

这就分为了两种情况:

1. 调用方根本就不关心执行结果
2. 调用方后续需要知道执行结果

第一种情况比较简单,该情况无需讨论。

第二种情况下就比较有趣了,通常有两种实现方式:

一种是通知机制,也就是说当任务执行完成后发送信号用来通知调用方任务完成,注意这里的信号就
有很多实现方式了,Linux中的signal,或者使用信号量等机制都可以实现。

另一种是就是回调,也就是我们常说的callback,关于回调我们将在下一篇文章中重点讲解。

接下来我们用一个具体的例子讲解一下同步调用与异步调用。

同步 vs 异步

我们以常见的Web服务来举例说明这一问题。

一般来说Web Server接收到用户请求后会有一些典型的处理逻辑,最常见的就是数据库查询(当然,
你也可以把这里的数据库查询换成其它I/O操作,比如磁盘读取、网络通信等),在这里我们假定处理
一次用户请求需要经过步骤A、B、C然后读取数据库,数据库读取完成后需要经过步骤D、E、F,就
像这样:
# 处理一次用户请求需要经过的步骤:

A;
B;
C;
数据库读取;
D;
E;
F;

其中步骤A、B、C和D、E、F不需要任何I/O,也就是说这六个步骤不需要读取文件、网络通信等,涉
及到I/O操作的只有数据库查询这一步。

一般来说这样的Web Server有两个典型的线程:主线程和数据库处理线程,注意,这讨论的只是典型
的场景,具体业务实际上可会有差别,但这并不影响我们用两个线程来说明问题。

首先我们来看下最简单的实现方式,也就是同步。

这种方式最为自然也最为容易理解:

// 主线程
main_thread() {
A;
B;
C;
发送数据库查询请求;
D;
E;
F;
}

// 数据库线程
DataBase_thread() {
while(1) {
数据库读取;
}
}

这就是最为典型的同步方法,主线程在发出数据库查询请求后就会被阻塞而暂停运行,直到数据库查
询完毕后面的D、E、F才可以继续运行,就像这样:
从图中我们可以看到,主线程中会有“空隙”,这个空隙就是主线程的“休闲时光”,主线程在这段休闲
时光中需要等待数据库查询完成才能继续后续处理流程。

在这里主线程就好比监工的老板,数据库线程就好比苦逼搬砖的程序员,在搬完砖前老板什么都不做
只是紧紧的盯着你,等你搬完砖后才去忙其它事情。

显然,高效的程序员是不能容忍主线程偷懒的。

是时候祭出大杀器了,这就是异步。

在异步这种实现方案下主线程根本不去等待数据库是否查询完成,而是发送完数据库读写请求后直接
处理下一个请求。
有的同学可能会有疑问,一个请求需要经过A、B、C、数据库查询、D、E、F这七个步骤,如果主线
程在完成A、B、C、数据库查询后直接进行处理接下来的请求,那么上一个请求中剩下的D、E、F几
个步骤怎么办呢?

如果大家还没有忘记上一小节内容的话应该知道,这有两种情况,我们来分别讨论。

主线程不关心数据库操作结果

在这种情况下,主线程根本就不关心数据库是否查询完毕,数据库查询完毕后自行处理接下来的D、
E、F三个步骤,就像这样:
看到了吧,接下来重点来了哦。

我们说过一个请求需要经过七个步骤,其中前三个是在主线程中完成的,后四个是在数据库线程中完
成的,那么数据库线程是怎么知道查完数据库后要处理D、E、F这几个步骤呢?

这时,我们的另一个主角回调函数就开始登场啦。

没错,回调函数可以用来解决这一问题的。
将D、E、F这几个步骤封装到第一个函数中,我们将该函数命名为handle_DEF_after_DB_query:

void handle_DEF_after_DB_query () {
D;
E;
F;
}

这样主线程在发送数据库查询请求时将该函数一并当做参数传递过去:

DB_query(request, handle_DEF_after_DB_query);

数据库线程处理完查询后直接调用handle_DEF_after_DB_query就可以了,这就是回调函数的作用。

也有的同学可能会有疑问,为什么这个函数要传递给数据库线程而不是数据库线程自己定义自己调用
呢?

因为从软件组织结构上讲,这不是数据库线程该做的工作。

数据库线程需要做的仅仅就是查询数据库、然后调用一个处理函数,至于这个处理函数做了些什么数
据库线程根本就不关心,也不应该关心。

你可以传入各种各样的回调函数。也就是说数据库系统可以针对回调函数这一抽象的函数变量来编
程,从而更好的应对变化,因为回调函数的内容改变不会影响到数据库线程的逻辑,而如果数据库线
程自己定义处理函数那么这种设计就没有灵活性可言了。

而从软件开发的角度看,假设数据库线程逻辑是其它团队研发的,并用作库提供给其它团队,当数据
库团队在研发时怎么可能知道数据库查询后该怎么处理呢,因此该团队在编写代码时简单的使用一个
回调函数即可。

与此同时,显然只有使用方才知道查询完数据库后该做些什么,因此使用方在使用时简单的传入这个
回调函数就可以了。

这样数据库线程的团队就和使用方团队就实现了所谓的解耦。

现在你应该明白回调函数的作用了吧。

如果你觉得上面这段有帮到你,请伸出你的小手帮忙分享再看一下,原创不易,你的一个在看博主能
高兴半天,拜托大家啦。

不容易啊,容我喝口水叉会儿腰歇一歇。

我们继续。

另外仔细观察上面两张图,你能看出为什么异步比同步高效吗?

原因很简单,这也是我们在本篇提到过的,异步天然就无需等待,无依赖。
从上一张图中我们可以看到主线程的“休闲时光”不见了,取而代之的是不断的工作、工作、工作,就
像苦逼的996程序员一样,而且数据库线程也没有那么大段大段的空闲了,取而代之的也是工作、工
作、工作。

主线程处理请求和数据库处理查询可以同时进行,因此从系统性能上看,这样的设计能更加充分的利
用系统资源,更加快速的处理请求,从用户的角度看,系统的响应也会更加迅速。

这就是异步的高效之处。

但我们应该也可以看出,异步编程并不如同步来的容易理解,系统可维护性上也不如同步模式。

那有没有一种方法既能结合同步模式的容易理解又能结合异步模式的高效呢?答案是肯定的,这一技
术我们将在后续章节详细讲解。

接下来我们看第二种情况,那就是主线程需要关心数据库查询结果。

主线程关心数据库操作结果

在这种情况下,数据库线程需要将查询结果利用通知机制发送给主线程,主线程在接收到消息后继续
处理上一个请求的后半部分,就像这样:
从这里我们可以看到,主线程同样也没有“休闲时光”,只不过在这种情况下数据库线程是比较清闲
的,从这里示例的情况下并没有上一种方法高效,但是依然要比同步模式下要高效。

最后需要注意的是,并不是所有的情况下异步都一定比同步高效,还需要结合具体业务各个阶段的复
杂度以及IO的复杂度具体情况具体分析。
总结

在这篇文章中我们从各种场景分析了同步与异步这两个概念,但是不管在什么场景下,同步往往意味
着双方要相互等待、相互依赖,而异步意味着双方相互独立、各行其是。希望本篇能对大家理解这两
个重要的概念有所帮助。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

你管这破玩意叫IO多路复用?
什么是文件

程序员使用I/O最终都逃不过文件。

因为这篇同属于高性能、高并发系列,讲到高性能、高并发就离不开Linux/Unix,因此这里就来讨论
一下Linux世界中的文件。

实际上对于程序员来说文件是一个很简单的概念,我们只需要将其理解为一个N byte的序列就可以
了:

b1, b2, b3, b4, ....... bN

实际上所有的I/O设备都被抽象为了文件这个概念,一切皆文件,Everything isFile,磁盘、网络数
据、终端,甚至进程间通信工具管道pipe等都被当做文件对待。

所有的I/O操作也都是通过文件读写来实现的,这一非常优雅的抽象可以让程序员使用一套接口就能
实现所有I/O操作。

常用的I/O操作接口一般有以下几类:

打开文件,open
改变读写位置,seek
文件读写,read、write
关闭文件,close

程序员通过这几个接口几乎可以实现所有I/O操作,这就是文件这个概念的强大之处。
文件描述符

在本篇第二节I/O过程中我们讲到,要想读取比如磁盘数据我们需要指定一个buff用来装入数据,是这
样用的:

read(buff);

但是这里我们忽略了一个问题,那就是虽然我们执行了往哪里写数据,但是我们该从哪里读数据呢?
从上一节中我们知道,通过文件这个概念我们能实现几乎所有I/O操作,因此这里少的一个主角就是
文件。

那么我们一般都这么使用文件呢?

如果你周末去比较火的餐厅吃饭应该会有体会,一般周末这样的餐厅都会排队,然后服务员会给你一
个排队序号,通过这个序号服务员就能找到你,这里的好处就是服务员无需记住你是谁、你的名字是
什么、是不是保护环境爱好小动物等等,这里的关键点就是服务员对你一无所知,但是依然可以通过
一个号码就能找到你。

同样的,在Linux世界使用文件,我们也需要借助一个号码,根据“弄不懂原则”,这个号码就被称为了
文件描述符file descriptors,在Linux世界中鼎鼎大名,其道理和上面那个排队号码一样。

因此,文件描述仅仅就是一个数字而已,但是通过这个数字我们可以操作一个打开的文件,这一点要
记住。
有了文件描述符,进程对文件一无所知,比如文件在磁盘的什么位置上、内存是如何管理文件的等
等,这些信息属于操作系统,进程无需关心,操作系统只需要给进程一个文件描述符就足够了。

因此我们来完善上述程序:

int fd = open(file_name);
read(fd, buff);

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

文件描述符太多了怎么办
经过了这么多的铺垫,终于到高性能、高并发这一主题了。

从前几节我们知道,所有I/O操作都可以通过文件样的概念来进行,这当然包括网络通信。

如果你是一个web服务器,当三次握手成功以后,我们通过调用accept同样会得到一个文件描述符,
只不过这个文件描述符是用来进行网络通信的,通过读写该文件描述符你就可以同客户端通信。在这
里为了概念上好理解,我们称之为链接描述符,通过这个描述符我们就可以读写客户端的数据了。

int conn_fd = accept(...);

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

if(read(conn_fd, request_buff) > 0) {


do_something(request_buff);
}

是不是非常简单,然而世界终归是复杂的,也不是这么简单的。

接下来就是比较复杂的了。

既然我们的主题是高并发,那么server端就不可能只和一个客户端通信,而是成千上万个客户端。这
时你需要处理不再是一个描述符这么简单,而是有可能要处理成千上万个描述符。

为了不让问题一上来就过于复杂,我们先简单化,假设只同时处理两个客户端的请求。

有的同学可能会说,这还不简单,这样写不就行了:
if(read(socket_fd1, buff) > 0) { // 处理第一个
do_something();
}
if(read(socket_fd2, buff) > 0) {
do_something();

在本篇第二节中我们讨论过这是非常典型的阻塞式I/O,如果读取第一个请求进程被阻塞而暂停运
行,那么这时我们就无法处理第二个请求了,即使第二个请求的数据已经就位,这也就意味着所有其
它客户端必须等待,而且通常情况下也不会只有两个客户端而是成千上万个,上万个连接也要这样串
行处理吗。

聪明的你一定会想到使用多线程,为每个请求开启一个线程,这样一个线程被阻塞不会影响到其它线
程了,注意,既然是高并发,那么我们要为成千上万个请求开启成千上万个线程吗,大量创建销毁线
程会严重影响系统性能。

那么这个问题该怎么解决呢?

这里的关键点在于在进行I/O时,我们并不是到该文件描述对于的I/O设备是否是可读的、是否是可写
的,在外设的不可读或不可写的状态下进行I/O只会导致进程阻塞被暂停运行。

因此要优雅的解决这个问题,就要从其它角度来思考这个问题了。
不要打电话给我,有需要我会打给你

大家生活中肯定会接到过推销电话,而且不止一个,一天下来接上十个八个推销电话你的身体会被掏
空的。

这个场景的关键点在于打电话的人并不知道你是不是要买东西,只能来一遍遍问你,因此一种更好的
策略是不要让他们打电话给你,记下他们的电话,有需要的话打给他们。

也就是不要打电话给我,有需要我会打给你。

在这个例子中,你,就好比内核,推销者就好比应用程序,电话号码就好比文件描述符,和你用电话
沟通就好比I/O。

现在你应该明白了吧,处理多个文件描述符的更好方法其实就存在于推销电话中。

因此相比上一节中我们主动通过I/O接口主动问内核这些文件描述符对应的外设是不是已经就绪了,
一种更好的方法是,我们把这些内核一股脑扔给内核,并霸气的告诉内核:“我这里有1万个文件描述
符,你替我监视着它们,有可以读写的文件描述符时你就告诉我,我好处理”。而不是弱弱的问内
核:“第一个文件描述可以读写了吗?第二个文件描述符可以读写吗?第三个文件描述符可以读写了
吗?”

这样应用程序就从“繁忙”的主动变为清闲的被动了,反正哪些设备ok了内核会通知我, 能偷懒我才不
要那么勤奋。
这是一种不同的处理I/O的机制,同样需要起一个名字,再次祭出“弄不懂原则”,就叫I/O多路复用
吧,这就是 I/O multiplexing。

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

multiplexing一词其实多用于通信领域,为了充分利用通信线路,希望在一个信道中传输多路信号,
要想在一个信道中传输多路信号就需要把这多路信号结合为一路,将多路信号组合成一个信号的设备
被称为multiplexer,显然接收方接收到这一路组合后的信号后要恢复原先的多路信号,这个设备被称
为demultiplexer,如图所示:

回到我们的主题。

所谓I/O多路复用指的是这样一个过程:

1. 我们拿到了一堆文件描述符(不管是网络相关的、还是磁盘文件相关等等,任何文件描述符都可
以)
2. 通过调用某个函数告诉内核:“这个函数你先不要返回,你替我监视着这些描述符,当这堆文件
描述符中有可以进行I/O读写操作的时候你再返回”
3. 当调用的这个函数返回后我们就能知道哪些文件描述符可以进行I/O操作了。

那么有哪些函数可以用来进行I/O多路复用呢?

在Linux世界中有这样三种机制可以用来进行I/O多路复用:

select
poll
epoll

接下来我们就简单介绍一下牛掰的I/O多路复用三剑客。
I/O多路复用三剑客

本质上select、poll、epoll都是阻塞式I/O,也就是我们常说的同步I/O。

select:初出茅庐

在select这种I/O多路复用机制下,我们需要把想监控的文件描述集合通过函数参数的形式告诉
select,然后select会将这些文件描述符集合拷贝到内核中,我们知道数据拷贝是有性能损耗的,因
此为了减少这种数据拷贝带来的性能损耗,Linux内核对集合的大小做了限制,并规定用户监控的文
件描述集合不能超过1024个,同时当select返回后我们仅仅能知道有些文件描述符可以读写了,但是
我们不知道是哪一个,因此程序员必须再遍历一边找到具体是哪个文件描述符可以读写了。

因此,总结下来select有这样几个特点:

我能照看的文件描述符数量有限,不能超过1024个
用户给我的文件描述符需要拷贝的内核中
我只能告诉你有文件描述符满足要求了,但是我不知道是哪个,你自己一个一个去找吧(遍历)

因此我们可以看到,select机制的特性在高性能网络服务器动辄几万几十万并发链接的场景下无疑是
低效的。

poll:小有所成

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

epoll:独步天下

在select面临的三个问题中,文件描述数量限制已经在poll中解决了,剩下的两个问题呢?

针对第一个epoll使用的策略是各个击破与共享内存。
实际上文件描述符集合变化的频率比较低,select和poll频繁的拷贝整个集合,内核都快要烦死了,
epoll通过引入epoll_ctl很体贴的做到了只操作那些有变化的文件描述符,同时epoll和内核还成为了
好朋友,共享了同一块内存,这块内存中保存的就是那些已经可读或者可写的的文件描述符集合,这
样就减少了内核和程序的内存拷贝开销。

针对第二点,epoll使用的策略是“当小弟”。

在select和poll机制下,进程要亲自下场去各个文件描述符上等待,任何一个文件描述可读或者可写
就唤醒进程,但是进程被唤醒后也是一脸懵逼并不知道到底是哪个文件描述符可读或可写,还要再从
头到尾检查一遍。

但epoll就懂事多了,主动找到进程要当小弟替大哥出头。

在这种机制下,进程不需要亲自下场了,进程只要等待在epoll上,epoll代替进程去各个文件描述符
上等待,当哪个文件描述符可读或者可写的时候就告诉epoll,epoll用小本本认真记录下来然后唤醒
大哥:“进程大哥,快醒醒,你要处理的文件描述符我都记下来了”,这样进程被唤醒后就无需自己从
头到尾检查一遍,因为epoll都已经记下来了。

因此我们可以看到,在这种机制下,实际上利用的就是“不要打电话给我,有需要我会打给你”,这就
不需要一遍一遍像孙子一样问各个文件描述符了,而是翻身做主人当大爷了,“你们那个文件描述符
可读或者可写了主动报上来”,这中机制实际上就是大名鼎鼎的事件驱动,event-driven,这也是我们
下一篇的主题。

实际上在Linux平台,epoll基本上就是高并发的代名词。

限于篇幅,关于epoll的详细使用方法就不在这里讲解了。

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

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

CPU是如何理解01二进制的?
准确的来说,CPU不认识也不理解任何东西。

CPU就像一个单细胞一样,本身不具备任何思考能力,没什么自己的想法,只是简单的你给它一个刺
激,它会有一个反应。

那这个刺激是什么呢?是电压,硬件感知到的仅仅就是电压。

电压有两种,高电压和低电压。

你马上就能反应过来,这就是01二进制,高电压代表1低电压代表0,0和1仅仅是人类可以理解的东
西,硬件电路可不理解这玩意,它仅仅就是靠电流驱动来工作。

让我们来看看这个简单的电路,这个就是与门:
你能说这个电路理解它自己该做什么吗?它有自我意识吗?当然没有。

所以说这个问题的答案非常简单:

CPU根本就不能理解任何东西,之所以CPU能正常工作,仅仅是因为你(制作CPU的人)让它这么工
作。

这个问题就好比你问一辆自行车是如何理解自己怎么跑起来的?还不是因为你设计了车轮、车链然后
用脚一蹬跑起来的。
你希望两个开关都打开灯才亮,因此你这样设计电路,这就是与门;你希望任意一个开关打开灯就
亮,因此你那样设计电路,这就是或门;你希望关闭开关灯才亮,这就是非门,有了与或非你可以搭
建出任意复杂的逻辑电路,比如下面这个能执行加操作的加法器。
看看这个电路,你能说它知道自己是在执行加法操作吗,这当然是人类认为这个电路的输出等价于加
法操作的结果。

尽管这个电路看上去很不错,给定两个输入得到的输出和我们人类认为的加法是一样一样的,但这有
点简单。

除了加法是不是还应该有其它操作,如果有多种类型的操作那么就必须告诉电路该操作的类型是上面
(操作码),操作的数字是什么(操作数)。

这样我们给它一个输入就能按照我的想法来控制电路该怎么工作了,BOOM!!!宇宙大爆炸!
哦不对,CPU诞生了!

人类编写的代码必须首先转为01二进制,之后才能驱动CPU工作。

当然,怎么把一坨代码高效等价的转为1001011100。。。这项工作可不简单,人类探索了几十年,
一干人等还获得了图灵奖,可见这个问题的重要程度以及难度。
你今天能简单点一下build按钮或简单运行一个命令就能把你写的代码转为01串,要知道这简单的背
后是靠无数天才榨干天量的脑细胞才实现的。
从这里应该应该能看出来,CPU根本也不会认识任何语言。

现在我们能给CPU输入了,那么输出怎么办呢?

剩下的仅仅就是解释了,比如给你一个01串,01001101,你可以认为这是一个数字,也可以认为这
是一个字符,也可以是表示RGB颜色,一切都看你怎么解释,这就是软件的工作了。

最终的目的只有一个:让人类能看懂。

整个流程就是这样的:
计算机真实一个非常神奇的机器,如此简单,却又能完成复杂无比的工作。

现在你应该明白了吧,计算机所谓能理解二进制就好比你的台灯能理解开关一样。

它们真的对此一无所知。

关注作者
也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

CPU空闲时在干嘛?
人空闲时会发呆会无聊,计算机呢?

假设你正在用计算机浏览网页,当网页加载完成后你开始阅读,此时你没有移动鼠标,没有敲击键
盘,也没有网络通信,那么你的计算机此时在干嘛?

有的同学可能会觉得这个问题很简单,但实际上,这个问题涉及从硬件到软件、从 CPU 到操作系统


等一系列环节,理解了这个问题你就能明白操作系统是如何工作的了。
你的计算机 CPU 使用率是多少?

如果此时你正在计算机旁,并且安装有 Windows 或者 Linux ,你可以立刻看到自己的计算机 CPU 使


用率是多少。

这是博主的一台安装有 Win10 的笔记本:

可以看到大部分情况下 CPU 利用率很低,也就在 8% 左右,而且开启了 283 个进程,这么多进程基


本上无所事事,都在等待某个特定事件来唤醒自己,就好比你写了一个打印用户输入的程序,如果用
户一直不按键盘,那么你的进程就处于这种状态。

有的同学可能会想也就你的比较空闲吧,实际上大部分个人计算机 CPU 使用率都差不多这样(排除掉


看电影、玩游戏等场景),如果你的使用率总是很高,风扇一直在嗡嗡的转,那么不是软件 bug 就有
可能是病毒。。。

那么有的同学可能会问,剩下的 CPU 时间都去哪里了?

剩下的 CPU 时间去哪里了?


这个问题也很简单,还是以 Win10 为例,打开任务管理器,找到 “详细信息” 这一栏,你会发现有一
个 “系统空闲进程”,其 CPU 使用率达到了 99%,正是这个进程消耗了几乎所有的 CPU 时间。

那么为什么存在这样一个进程呢?以及这个进程什么时候开始运行呢?

这就要从操作系统说起了。

程序、进程与操作系统

当你用最喜欢的代码编辑器编写代码时,这时的代码不过就是磁盘上的普通文件,此时的程序和操作
系统没有半毛钱关系,操作系统也不认知这种文本文件。

程序员写完代码后开始编译,这时编译器将普通的文本文件翻译成二进制可执行文件,此时的程序依
然是保存在磁盘上的文件,和普通没有本质区别。
但此时不一样的是,该文件是可执行文件,也就是说操作系统开始 “懂得” 这种文件,所谓 “懂得” 是
指操作系统可以识别、解析、加载,因此必定有某种类似协议的规范,这样编译器按照这种协议生成
可执行文件,操作系统就能加载了。

在 Linux 下可执行文件格式为 ELF ,在 Windows 下是 EXE 。

此时虽然操作系统可以识别可执行程序,但如果你不去双击一下(或者在Linux下运行相应命令)的依
然和操作系统没有半毛钱关系。

但是当你运行可执行程序时魔法就出现了。

此时操作系统开始将可执行文件加载到内存,解析出代码段、数据段等,并为这个程序创建运行时需
要的堆区栈区等内存区域,此时这个程序在内存中就是这样了:
最后,根据可执行文件的内容,操作系统知道该程序应该执行的第一条机器指令是什么,并将其告诉
CPU ,CPU 从该程序的第一条指令开始执行,程序就这样运行起来了。

一个在内存中运行起来的程序显然和保存在磁盘上的二进制文件是不一样的,总的有个名字吧,根
据“弄不懂原则”,这个名字就叫进程,英文名叫做Process。

我们把一个运行起来的程序叫做进程,这就是进程的由来。

此时操作系统开始掌管进程,现在进程已经有了,那么操作系统是怎么管理进程的呢?

调度器与进程管理

银行想必大家都去过,实际上如果你仔细观察的话银行的办事大厅就能体现出操作系统最核心的进程
管理与调度。

首先大家去银行都要排队,类似的,进程在操作系统中也是通过队列来管理的。

同时银行还按照客户的重要程度划分了优先级,大部分都是普通客户;但当你在这家银行存上几个亿
时就能升级为 VIP 客户,优先级最高,每次去银行都不用排队,优先办理你的业务。

类似的,操作系统也会为进程划分优先级,操作系统会根据进程优先级将其放到相应的队列中供调度
器调度。

这就是操作系统需要实现的最核心功能。

现在准备工作已经就绪。

接下来的问题就是操作系统如何确定是否还有进程需要运行。

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

if (queue.empty()) {
do_someting();
}

这些编写内核代码虽然简单,但内核中到处充斥着 if 这种异常处理的语句,这会让代码看起来一团
糟,因此更好的设计是没有异常,那么怎样才能没有异常呢?

很简单,那就是让队列永远不会空,这样调度器永远能从队列中找到一个可供运行的进程。

而这也是为什么链表中通常会有哨兵节点的原因,就是为了避免各种判空,这样既容易出错也会让代
码一团糟。

就这样,内核设计者创建了一个叫做空闲任务的进程,这个进程就是Windows 下的我们最开始看到
的“系统空闲进程”,在 Linux 下就是第 0号进程。

当其它进程都处于不可运行状态时,调度器就从队列中取出空闲进程运行,显然,空闲进程永远处于
就绪状态,且优先级最低。

既然我们已经知道了,当系统无所事事后开始运行空闲进程,那么这个空闲进程到底在干嘛呢?

这就需要硬件来帮忙了。

一切都要归结到硬件
在计算机系统中,一切最终都要靠 CPU 来驱动,CPU 才是那个真正干活的。

原来,CPU 设计者早就考虑到系统会存在空闲的可能,因此设计了一条机器指令,这个机器指令就是
halt 指令,停止的意思。

这条指令会让部分CPU进入休眠状态,从而极大减少对电力的消耗,通常这条指令也被放到循环中执
行,原因也很简单,就是要维持这种休眠状态。

值得注意的是,halt 指令是特权指令,也就是说只有在内核态下 CPU 才可以执行这条指令,程序员


写的应用都运行在用户态,因此你没有办法在用户态让 CPU 去执行这条指令。

此外,不要把进程挂起和 halt 指令混淆,当我们调用 sleep 之类函数时,暂停运行的只是进程,此时


如果还有其它进程可以运行那么 CPU 是不会空闲下来的,当 CPU 开始执行halt指令时就意味着系统
中所有进程都已经暂停运行。

软件硬件结合

现在我们有了 halt 机器指令,同时有一个循环来不停的执行 halt 指令,这样空闲任务进程的实际上


就已经实现了,其本质上就是这个不断执行 halt 指令的循环,大功告成。

这样,当调度器在没有其它进程可供调度时就开始运行空间进程,也就是在循环中不断的执行 halt 指
令,此时 CPU 开始进入低功耗状态。
在 Linux 内核中,这段代码是这样写的:

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 指令并
没有看上去那么简单。

希望这篇文章对大家理解 CPU 和操作系统有所帮助。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

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

这篇文章就为你解答这个问题。

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

什么是编译器?

编译器是一个将高级语言翻译为低级语言的程序。
首先我们一定要意识到编译器就是一个普通程序,没什么大不了的。

在没有弄明白编译器如何工作之前你可以简单的把编译器当做一个黑盒子,其作用就是输入一个文本
文件输出一个二进制文件。

基本上编译器经过了以下几个阶段,等等,这句话教科书上也有,但是我相信很多同学其实并没有真
正理解这几个步骤到底在说些什么,为了让你彻底理解这几个步骤,我们用一个简单的例子来讲解。

假定我们有一段程序:

while (y < z) {
int x = a + b;
y += x;
}

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

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

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

经过一遍词法分析,编译器得到了以下token:

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之后编译器就可以根据语言定义的语法恢复其原本的结构,怎么恢复呢?
原来,编译器在扫描出各个token后根据规则将其用树的形式表示出来,这颗树就被称为语法树。

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

有了语法树后我们还要检查这棵树是不是合法的,比如我们不能把一个整数和一个字符串相加、比较
符左右两边的数据类型要相同,等等。

这一步通过后就证明了程序合法,不会有编译错误。
根据语法树生成中间代码:代码生成

语义分析之后接下来编译器遍历语法树并用另一种形式来表示,用什么来表示呢?那就是中间代码,
intermediate representation code,简称IR code。

上述语法树可能就会表示为这样的中间代码:

Loop: x = a + b
y = x + y
_t1 = y < z
if _t1 goto Loop

怎么样,这实际上已经比较接近最后的机器指令了。

只不过这还不是最终形态。

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

x = a + b
Loop: y = x + y
_t1 = y < z
if _t1 goto Loop

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

代码生成

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

add $1, $2, $3


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

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

总结

注意这篇简短的讲解不希望给大家留下这样的印象,那就是编译器是很简单的,恰恰相反,现代编译
器是非常智能并且极其复杂的,绝不是短短一篇文章就能讲清楚的,能实现一个编译器是困难的,实
现一个好的编译器更是难上加难。

本文的目的旨在以极简的方式描述编译器的工作原理,这样你就不用把编译器当做一个黑盒了,希望
这篇文章能对你有所帮助。

关注作者
也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

函数运行时在内存中是什么样子?
在开始本篇的内容前,我们先来思考几个问题。

1. 我们先来看一段简单的代码:
void func(int a) {
if (a > 100000000) return;
int arr[100] = {0};
func(a + 1);
}

你能看出这段代码会有什么问题吗?

2. 我们在上一篇文章《高性能高并发服务器是如何实现的》中提到了一项关键技术——协程,你知
道协程的本质是什么吗?有的同学可能会说是用户态线程,那么什么是用户态线程,这是怎么实
现的?
3. 函数运行起来后在内存中是什么样子?

这几个问题看似没什么关联,但这背后都指向一样东西,这就是所谓的函数运行时栈,run time
stack。

接下来我们就好好看看到底什么是函数运行时栈,为什么彻底理解函数运行时栈对程序员来说非常重
要。

从进程、线程到函数调用

汽车在高速上行驶时有很多信息,像速度、位置等等,通过这些信息我们可以直观的感受汽车的运行
时状态。
同样的,程序在运行时也有很多信息,像有哪些程序正在运行、这些程序执行到了哪里等等,通过这
些信息我们可以直观的感受系统中程序运行的状态。

其中,我们创造了进程、线程这样的概念来记录有哪些程序正在运行,关于进程和线程的概念请参见
《看完这篇还不懂进程、线程与线程池你来打我》。

进程和线程的运行体现在函数执行上,函数的执行除了函数内部执行的顺序执行还有子函数调用的控
制转移以及子函数执行完毕的返回。其中函数内部的顺序执行乏善可陈,重点是函数的调用。

因此接下来我们的视角将从宏观的进程和线程拉近到微观下的函数调用,重点来讨论一下函数调用是
怎样实现的。

函数执行的活动轨迹:栈

玩过游戏的同学应该知道,有时你为了完成一项主线任务不得不去打一些支线的任务,支线任务中可
能还有支线任务,当一个支线任务完成后退回到前一个支线任务,这是什么意思呢,举个例子你就明
白了。

假设主线任务西天取经A依赖支线任务收服孙悟空B和收服猪八戒C,也就是说收服孙悟空B和收服猪
八戒C完成后才能继续主线任务西天取经A;

支线任务收服孙悟空B依赖任务拿到紧箍咒D,只有当任务D完成后才能回到任务B;
整个任务的依赖关系如图所示:

现在我们来模拟一下任务完成过程。

首先我们来到任务A,执行主线任务:

执行任务A的过程中我们发现任务A依赖任务B,这时我们暂停任务A去执行任务B:

执行任务B的时候,我们又发现依赖任务D:
执行任务D的时候我们发现该任务不再依赖任何其它任务,因此C完成后我们可以会退到前一个任
务,也就是B:

任务B除了依赖任务C外不再依赖其它任务,这样任务B完成后就可以回到任务A:

现在我们回到了主线任务A,依赖的任务B执行完成,接下来是任务C:

和任务D一样,C不依赖任何其它其它任务,任务C完成后就可以再次回到任务A,再之后任务A执行完
毕,整个任务执行完成。

让我们来看一下整个任务的活动轨迹:

仔细观察,实际上你会发现这是一个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,如图所示:

当前,CPU执行函数A的机器指令,该指令的地址为0x400564,接下来CPU将执行下一条机器指令也
就是:

call 0x400540

这条机器指令是什么意思呢?

这条机器指令对应的就是我们在代码中所写的函数调用,注意call后有一条机器指令地址,注意观察
上图你会看到,该地址就是函数B的第一条机器指令,从这条机器指令后CPU将跳转到函数B。

现在我们已经解决了控制跳转的“要到哪里去”问题,当函数B执行完毕后怎么跳转回来呢?

原来,call指令除了给出跳转地址之外还有这样一个作用,也就是把call指令的下一条指令的地址,也
就是0x40056a push到函数A的栈帧中,如图所示:
现在,函数A的小盒子变大了一些,因为装入了返回地址:

现在CPU开始执行函数B对应的机器指令,注意观察,函数B也有一个属于自己的小盒子(栈帧),可以
往里面扔一些必要的信息。
如果函数B中又调用了其它函数呢?

道理和函数A调用函数B是一样的。

让我们来看一下函数B最后一条机器指令ret,这条机器指令的作用是告诉CPU跳转到函数A保存在栈
帧上的返回地址,这样当函数B执行完毕后就可以跳转到函数A继续执行了。

至此,我们解决了控制转移中“我从哪里来”的问题。

传递参数与获取返回值

函数调用与返回使得我们可以编写函数,进行函数调用。但调用函数除了提供函数名称之外还需要传
递参数以及获取返回值,那么这又是怎样实现的呢?

在x86-64中,多数情况下参数的传递与获取返回值是通过寄存器来实现的。

假设函数A调用了函数B,函数A将一些参数写入相应的寄存器,当CPU执行函数B时就可以从这些寄
存器中获取参数了。

同样的,函数B也可以将返回值写入寄存器,当函数B执行结束后函数A从该寄存器中就可以读取到返
回值了。

我们知道寄存器的数量是有限的,当传递的参数个数多于寄存器的数量该怎么办呢?

这时那个属于函数的小盒子也就是栈帧又能发挥作用了。

原来,当参数个数多于寄存器数量时剩下的参数直接放到栈帧中,这样被调函数就可以从前一个函数
的栈帧中获取到参数了。
现在栈帧的样子又可以进一步丰富了,如图所示:

从图中我们可以看到,调用函数B时有部分参数放到了函数A的栈帧中,同时函数A栈帧的顶部依然保
存的是返回地址。

局部变量

我们知道在函数内部定义的变量被称为局部变量,这些变量在函数运行时被放在了哪里呢?

原来,这些变量同样可以放在寄存器中,但是当局部变量的数量超过寄存器的时候这些变量就必须放
到栈帧中了。

因此,我们的栈帧内容又一步丰富了。
细心的同学可能会有这样的疑问,我们知道寄存器是共享资源可以被所有函数使用,既然可以将函数
A的局部变量写入寄存器,那么当函数A调用函数B时,函数B的局部变量也可以写到寄存器,这样的
话当函数B执行完毕回到函数A时寄存器的值已经被函数B修改过了,这样会有问题吧。

这样的确会有问题,因此我们在向寄存器中写入局部变量之前,一定要先将寄存器中开始的值保存起
来,当寄存器使用完毕后再恢复原值就可以了。

那么我们要将寄存器中的原始值保存在哪里呢?

有的同学可能已经猜到了,没错,依然是函数的栈帧中。
最终,我们的小盒子就变成了如图所示的样子,当寄存器使用完毕后根据栈帧中保存的初始值恢复其
内容就可以了。

现在你应该知道函数在运行时到底是什么样子了吧,以上就是问题3的答案。

Big Picture

需要再次强调的一点就是,上述讨论的栈帧就位于我们常说的栈区。

栈区,属于进程地址空间的一部分,如图所示,我们将栈区放大就是图左边的样子。
关于栈区详细的讲解你可以参考《深入理解操作系统:程序员应如何理解内存》这篇。

最后,让我们回到文章开始的这段简单代码:

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. 该函数执行完后继续后续流程

从程序执行的角度看这个过程是这样的:

1. 保存当前被执行函数的上下文
2. 开始执行make_youtiao()这个函数
3. make_youtiao()执行完后,控制转回到调用函数中
如果世界上所有的函数都像make_youtiao()这么简单,那么程序员大概率就要失业了,还好程序的世
界是复杂的,这样程序员才有了存在的价值。

现实情况并不容易

现实中make_youtiao()这个函数需要处理的数据非常庞大,假设有10000个,那么
make_youtiao(10000)不会立刻返回,而是可能需要10分钟才执行完成并返回。

这时你该怎么办呢?想一想这个问题。

可能有的同学就像把头埋在沙子里的鸵鸟一样:和刚才一样直接调用不可以吗,这样多简单。

是的,这样做没有问题,但就像爱因斯坦说的那样“一切都应该尽可能简单,但是不能过于简单”。

想一想直接调用会有什么问题?

显然直接调用的话,那么调用线程会被阻塞暂停,在等待10分钟后才能继续运行。在这10分钟内该线
程不会被操作系统分配CPU,也就是说该线程得不到任何推进。

这并不是一种高效的做法。
没有一个程序员想死盯着屏幕10分钟后才能得到结果。

那么有没有一种更加高效的做法呢?

想一想我们上一篇中那个一直盯着你写代码的老板(见《从小白到高手,你需要理解同步与异步》),
我们已经知道了这种一直等待直到另一个任务完成的模式叫做同步。

如果你是老板的话你会什么都不干一直盯着员工写代码吗?因此一种更好的做法是程序员在代码的时
候老板该干啥干啥,程序员写完后自然会通知老板,这样老板和程序员都不需要相互等待,这种模式
被称为异步。

回到我们的主题,这里一种更好的方式是调用make_youtiao()这个函数后不再等待这个函数执行完
成,而是直接返回继续后续流程,这样A小组的程序就可以和make_youtiao()这个函数同时进行了,
就像这样:

在这种情况下,回调(callback)就必须出场了。

为什么我们需要回调callback

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

make_youtiao(10000);
sell();

可以看到这是最简单的写法,意思很简单,制作好油条后卖出去。

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

make_youtiao(10000, sell);

看到了吧,现在make_youtiao这个函数多了一个参数,除了指定制作油条的数量外还可以指定制作
好后该干什么,第二个被make_youtiao这个函数调用的函数就叫回调,callback。

现在你应该看出来了吧,虽然sell函数是你定义的,但是这个函数却是被其它模块调用执行的,就像
这样:
make_youtiao这个函数是怎么实现的呢,很简单:

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函数就会这样写:

void make_youtiao(int num) {


real_make_youtiao(num);
sell(); //执行回调
}

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

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函数的调用方式依然是同步的,关于同步异步请
参考《从小白到高手,你需要理解同步与异步》,也就是说调用方是这样实现的:

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

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

void make_youtiao(int num, func call_back) {


real_make_youtiao(num);
call_back(); //执行回调
}
看到了吧,由于我们要制作10000个油条,make_youtiao函数执行完需要10分钟,也就是说即便我
们使用了回调,调用方完全不需要关心制作完油条后的后续流程,但是调用方依然会被阻塞10分钟,
这就是同步调用的问题所在。

如果你真的理解了上一节的话应该能想到一种更好的方法了。

没错,那就是异步调用。

反正制作完油条后的后续流程并不是调用方该关心的,也就是说调用方并不关心make_youtiao这一
函数的返回值,那么一种更好的方式是:把制作油条的这一任务放到另一个线程(进程)、甚至另一台
机器上。

如果用线程实现的话,那么make_youtiao就是这样实现了:

void make_youtiao(int num, func call_back) {


// 在新的线程中执行处理逻辑
create_thread(real_make_youtiao,
num,
call_back);
}
看到了吧,这时当我们调用make_youtiao时就会立刻返回,即使油条还没有真正开始制作,而调用
方也完全无需等待制作油条的过程,可以立刻执行后流程:

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

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

新的编程思维模式

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

1. 调用某个函数,获取结果
2. 处理获取到的结果

res = request();
handle(res);

这就是函数的同步调用,只有request()函数返回拿到结果后,才能调用handle函数进行处理,
request函数返回前我们必须等待,这就是同步调用,其控制流是这样的:

但是如果我们想更加高效的话,那么就需要异步调用了,我们不去直接调用handle函数,而是作为参
数传递给request:
request(handle);

我们根本就不关心request什么时候真正的获取的结果,这是request该关心的事情,我们只需要把获
取到结果后该怎么处理告诉request就可以了,因此request函数可以立刻返回,真的获取结果的处理
可能是在另一个线程、进程、甚至另一台机器上完成。

这就是异步调用,其控制流是这样的:

从编程思维上看,异步调用和同步有很大的差别,如果我们把处理流程当做一个任务来的话,那么同
步下整个任务都是我们来实现的,但是异步情况下任务的处理流程被分为了两部分:

1. 第一部分是我们来处理的,也就是调用request之前的部分
2. 第二部分不是我们处理的,而是在其它线程、进程、甚至另一个机器上处理的。
我们可以看到由于任务被分成了两部分,第二部分的调用不在我们的掌控范围内,同时只有调用方才
知道该做什么,因此在这种情况下回调函数就是一种必要的机制了。

也就是说回调函数的本质就是“只有我们才知道做些什么,但是我们并不清楚什么时候去做这些,只
有其它模块才知道,因此我们必须把我们知道的封装成回调函数告诉其它模块”。

现在你应该能看出异步回调这种编程思维模式和同步的差异了吧。

接下来我们给回调一个较为学术的定义

正式定义

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

这就是回调函数的定义了。

回调函数就是一个函数,和其它函数没有任何区别。

注意,回调函数是一种软件设计上的概念,和某个编程语言没有关系,几乎所有的编程语言都能实现
回调函数。

对于一般的函数来说,我们自己编写的函数会在自己的程序内部调用,也就是说函数的编写方是我们
自己,调用方也是我们自己。

但回调函数不是这样的,虽然函数编写方是我们自己,但是函数调用方不是我们,而是我们引用的其
它模块,也就是第三方库,我们调用第三方库中的函数,并把回调函数传递给第三方库,第三方库中
的函数调用我们编写的回调函数,如图所示:
而之所以需要给第三方库指定回调函数,是因为第三方库的编写者并不清楚在某些特定节点,比如我
们举的例子油条制作完成、接收到网络数据、文件读取完成等之后该做什么,这些只有库的使用方才
知道,因此第三方库的编写者无法针对具体的实现来写代码,而只能对外提供一个回调函数,库的使
用方来实现该函数,第三方库在特定的节点调用该回调函数就可以了。

另一点值得注意的是,从图中我们可以看出回调函数和我们的主程序位于同一层中,我们只负责编写
该回调函数,但并不是我们来调用的。

最后值得注意的一点就是回调函数被调用的时间节点,回调函数只在某些特定的节点被调用,就像上
面说的油条制作完成、接收到网络数据、文件读取完成等,这些都是事件,也就是event,本质上我
们编写的回调函数就是用来处理event的,因此从这个角度看回调函数不过就是event handler,因此
回调函数天然适用于事件驱动编程event-driven,我们将会在后续文章中再次回到这一主题。

回调的类型

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

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

有同步回调就有异步回调。

异步回调
不同于同步回调, 当我们调用某个函数A并以参数的形式传入回调函数后,A函数会立刻返回,也就
是说函数A并不会阻塞我们的主程序,一段时间后回调函数开始被执行,此时我们的主程序可能在忙
其它任务,回调函数的执行和我们主程序的运行同时进行。
既然我们的主程序和回调函数的执行可以同时发生,因此一般情况下,主程序和回调函数的执行位于
不同的线程或者进程中。

这就是所谓的异步回调,asynchronous callbacks,也有的资料将其称为deferred callbacks ,名字


很形象,延迟回调。

从上面这两张图中我们也可以看到,异步回调要比同步回调更能充分的利用机器资源,原因就在于在
同步模式下主程序会“偷懒”,因为调用其它函数被阻塞而暂停运行,但是异步调用不存在这个问题,
主程序会一直运行下去。

因此,异步回调更常见于I/O操作,天然适用于Web服务这种高并发场景。
为什么异步回调这种思维模式正变得的越来越重要

在同步模式下,服务调用方会因服务执行而被阻塞暂停执行,这会导致整个线程被阻塞,因此这种编
程方式天然不适用于高并发动辄几万几十万的并发连接场景,

针对高并发这一场景,异步其实是更加高效的,原因很简单,你不需要在原地等待,因此从而更好的
利用机器资源,而回调函数又是异步下不可或缺的一种机制。

回调地狱,callback hell

有的同学可能认为有了异步回调这种机制应付起一切高并发场景就可以高枕无忧了。

实际上在计算机科学中还没有任何一种可以横扫一切包治百病的技术,现在没有,在可预见的将来也
不会有,一切都是妥协的结果。

那么异步回调这种机制有什么问题呢?

实际上我们已经看到了,异步回调这种机制和程序员最熟悉的同步模式不一样,在可理解性上比不过
同步,而如果业务逻辑相对复杂,比如我们处理某项任务时不止需要调用一项服务,而是几项甚至十
几项,如果这些服务调用都采用异步回调的方式来处理的话,那么很有可能我们就陷入回调地狱中。

举个例子,假设处理某项任务我们需要调用四个服务,每一个服务都需要依赖上一个服务的结果,如
果用同步方式来实现的话可能是这样的:

a = GetServiceA();
b = GetServiceB(a);
c = GetServiceC(b);
d = GetServiceD(c);

代码很清晰,很容易理解有没有。

我们知道异步回调的方式会更加高效,那么使用异步回调的方式来写将会是什么样的呢?

GetServiceA(function(a){
GetServiceB(a, function(b){
GetServiceC(b, function(c){
GetServiceD(c, function(d) {
....
});
});
});
});
我想不需要再强调什么了吧,你觉得这两种写法哪个更容易理解,代码更容易维护呢?

博主有幸曾经维护过这种类型的代码,不得不说每次增加新功能的时候恨不得自己化为两个分身,一
个不得不去重读一边代码;另一个在一旁骂自己为什么当初选择维护这个项目。

异步回调代码稍不留意就会跌到回调陷阱中,那么有没有一种更好的办法既能结合异步回调的高效又
能结合同步编码的简单易读呢?

幸运的是,答案是肯定的,我们会在后续文章中详细讲解这一技术。

总结

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

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
自己动手实现malloc内存分配器

对内存分配器透彻理解是编程高手的标志之一。

如果你不能理解malloc之类内存分配器实现原理的话,那你可能写不出高性能程序,写不出高性能程
序就很难参与核心项目,参与不了核心项目那么很难升职加薪,很难升级加薪就无法走向人生巅峰,
没想到内存分配竟如此关键,为了走上人生巅峰你也要势必读完本文。

现在我们知道了,对内存分配器透彻的理解是写出高性能程序的关键所在,那么我们该怎样透彻理解
内存分配器呢?

还有什么能比你自己动手实现一个理解的更透彻吗?
接下来,我们就自己实现一个malloc内存分配器。读完本文后内存分配对你将不再是一个神秘的黑
盒。

在讲解实现原理之前,我们需要回答一个基本问题,那就是我们为什么要发明内存分配器这种东西。

内存申请与释放

#####

程序员经常使用的内存申请方式被称为动态内存分配,Dynamic Memory Allocation。我们为什么需


要动态的去进行内存分配与释放呢?

答案很简单,因为我们不能提前知道程序到底需要使用多少内存。那我们什么时候才能知道呢?答案
是只有当程序真的运行起来后我们才知道。
这就是为什么程序员需要动态的去申请内存的原因,如果能提前知道我们的程序到底需要多少内存,
那么直接知道告诉编译器就好了,这样也不必发明malloc等内存分配器了。

知道了为什么要发明内存分配器的原因后,接下来我们着手实现一个。

程序员应如何看待内存

#####

实际上,现代程序员是很幸福的,程序员很少去关心内存分配的问题。作为程序员,可以简单的认为
我们的程序独占内存,注意,是独占哦。
写程序时你从来没有关心过如果我们的程序占用过多内存会不会影响到其它程序,我们可以简单的认
为每个程序(进程)独占4G内存(32位操作系统),即使我们的物理内存512M。不信你可以去试试,在即
使只有512M大小的内存上你依然可以申请到2G内存来使用,可这是为什么呢?关于这个问题我们会
在《深入理解操作系统》系列中详细阐述。

总之,程序员可以放心的认为我们的程序运行起来后在内存中是这样的:
作为程序员我们应该知道,内存动态申请和释放都发生在堆区,heap。

我们使用的malloc或者C++中的new申请内存时,就是从堆区这个区域中申请的。

接下来我们就要自己管理堆区这个内存区域。

堆区这个区域实际上非常简单,真的是非常简单,你可以将其看做一大数组,就像这样:
从内存分配器的角度看,内存分配器根本不关心你是整数、浮点数、链表、二叉树等数据结构、还是
对象、结构体等这些花哨的概念,**在内存分配器眼里不过就是一个内存块,这些内存块中可以装入
原生的字节序列**,申请者拿到该内存块后可以塑造成整数、浮点数、链表、二叉树等数据结构以及
对象、结构体等,这是使用者的事情,和内存分配器无关。

我们要在这片内存上解决两个问题:

实现一个malloc函数,也就是如果有人向我申请一块内存,我该怎样从堆区这片区域中找到一块
返回给申请者。
实现一个free函数,也就是当某一块内存使用完毕后,我该怎样还给堆区这片区域。

这是内存分配器要解决的两个最核心的问题,接下来我们先去停车场看看能找到什么启示。
从停车场到内存管理

实际上你可以把内存看做一条长长的停车场,我们申请内存就是要找到一块停车位,释放内存就是把
车开走让出停车位。

只不过这个停车场比较特殊,我们不止可以停小汽车、也可以停占地面积很小的自行车以及占地面积
很大的卡车,重点就是申请的内存是大小不一的,在这样的条件下你该怎样实现以下两个目标呢?

快速找到停车位,在内存申请中,这涉及到以最大速度找到一块满足要求的空闲内存
尽最大程度利用停车场,我们的停车场应该能停尽可能多的车,在内存申请中,这涉及到在给定
条件下尽可能多的满足内存申请需求

现在,我们已经清楚的理解任务了,那么该怎么实现呢?

任务拆分

现在我们已经明确要实现什么以及衡量其好坏的标准,接下来我们就要去设计实现细节了,让我们把
任务拆分一下,怎么拆分呢?

我们可以自己想一下从内存的申请到释放需要哪些细节。

申请内存时,我们需要在内存中找到一块大小合适的空闲内存分配出去,那么我们怎么知道有哪些内
存块是空闲的呢?
因此,第一个实现细节出现了,我们需要把内存块用某种方式组织起来,这样我们才能追踪到每一块
内存的分配状态。

现在空闲内存块组织好了,那么一次内存申请可能有很多空闲内存块满足要求,那么我们该选择哪一
个空闲内存块分配给用户呢?
因此,第二个实现细节出现了,我们该选择什么样的空闲内存块给到用户。

接下来我们找到了一块大小合适的内存块,假设用户需要16个字节,而我们找到的这块空闲内存块大
小为32字节,那么将16字节分配给用户后还剩下16字节,这剩下的内存该怎么处理呢?

因此,第三个实现细节出现了,分配出去内存后,空闲内存块剩余的空间该怎么处理?

最后,分配给用户的内存使用完毕,这是第四个细节出现了,我们该怎么处理用户还给我们的内存
呢?

以上四个问题是任何一个内存分配器必须要回答的,接下来我们就一一解决这些问题,解决完这些问
题后一个崭新的内存分配器就诞生啦。
管理空闲内存块

空闲内存块的本质是需要某种办法来来区分哪些是空闲内存哪些是已经分配出去的内存。

有的同学可能会说,这还不简单吗,用一个链表之类的结构记录下每个空闲内存块的开始和结尾不就
可以了,这句话也对也不对。

说不对,是因为如果要申请内存来创建这个链表那么这就是不对的,原因很简单,因为创建链表不可
避免的要申请内存,申请内存就需要通过内存分配器,可是你要实现的就是一个内存分配器,你没有
办法向一个还没有实现的内存分配器申请内存。
说对也对,我们确实需要一个类似链表这样的结构来维护空闲内存块,但这个链表并不是我们常见的
那种。

因为我们无法将空闲内存块的信息保存在其它地方,那么没有办法,我们只能将维护内存块的分配信
息保存在内存块本身中,这也是大多数内存分配器的实现方法。

那么,为了维护内存块分配状态,我们需要知道哪些信息呢?很简单:

一个标记,用来标识该内存块是否空闲
一个数字,用来记录该内存块的大小

为了简单起见,我们的内存分配器不对内存对齐有要求,同时一次内存申请允许的最大内存块为2G,
注意,这些假设是为了方便讲解内存分配器的实现而屏蔽一些细节,我们常用的malloc等不会有这样
的限制。

因为我们的内存块大小上限为2G,因此我们可以使用31个比特位来记录块大小,剩下的一个比特位
用来标识该内存块是空闲的还是已经被分配出去了,下图中的f/a是free/allocate,也就是标记是已经
分配出去还是空闲的。这32个比特位就是header,用来存储块信息。

剩下的灰色部分才是真正可以分配给用户的内存,这一部分也被称为负载,payload,我们调用
malloc返回的内存起始地址正是这块内存的起始地址。

现在你应该知道了吧,不是说堆上有10G内存,这里面就可以全部用来存储数据的,这里面必然有一
部分要拿出来维护内存块的一些信息,就像这里的header一样。

跟踪内存分配状态

有了上图,我们就可以将堆这块内存区域组织起来并进行内存分配与释放了,如图所示:
在这里我们的堆区还很小,每一方框代表4字节,其中红色区域表示已经分配出去的,灰色区域表示
空闲内存,每一块内存都有一个header,用带斜线的方框表示,比如16/1,就表示该内存块大小是
16字节,1表示已经分配出去了;而32/0表示该内存块大小是32字节,0表示该内存块当前空闲。

细心的同学可能会问,那最后一个方框0/1表示什么呢?原来,我们需要某种特殊标记来告诉我们的
内存分配器是不是已经到末尾了,这就是最后4字节的作用。

通过引入header我们就能知道每一个内存块的大小,从而可以很方便的遍历整个堆区。遍历方法很简
单,因为我们知道每一块的大小,那么从当前的位置加上当前块的大小就是下一个内存块的起始位
置,如图所示:
通过每一个header的最后一个bit位就能知道每一块内存是空闲的还是已经分配出去了,这样我们就
能追踪到每一个内存块的分配信息,因此上文提到的第一个问题解决了。

接下来我们看第二个问题。

怎样选择空闲内存块

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

First Fit

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

Next Fit

该方法是大名鼎鼎的Donald Knuth首次提出来的,如果你不知道谁是Donald Knuth,那么数据结构


课上折磨的你痛不欲生的字符串匹配KMP算法你一定不会错过,KMP其中的K就是指Donald Knuth,
该算法全称Knuth–Morris–Pratt string-searching algorithm,如果你也没听过KMP算法那么你
一定听过下面这本书:
这就是更加大名鼎鼎的《计算机程序设计艺术》,这本书就是Donald Knuth写的,如果你没有听过
这本书请面壁思过一分钟,比尔盖茨曾经说过,如果你看懂了这本书就去给微软投简历吧,这本书也
是很多程序员买回来后从来不会翻一眼只是拿来当做镇宅之宝用的。

不止比尔盖茨,有一次乔布斯见到Knuth老爷子后。。算了,扯远了,有机会再和大家讲这个故事,
拉回来。

Next Fit说的是什么呢?这个策略和First Fit很相似,是说我们别总是从头开始找了,而是从上一次找


到合适的空闲内存块的位置找起,老爷子观察到上一次找到某个合适的内存块的地方很有可能剩下的
内存块能满足接下来的内存分配请求,由于不需要从头开始搜索,因此Next Fit将远快于First Fit。
然而也有研究表明Next Fit方法内存使用率不及First Fit,也就是同样的停车场面积,First Fit方法能停
更多的车。

Best Fit

First Fit和Next Fit都是找到第一个满足要求的内存块就返回,但Best Fit不是这样。

Best Fit算法会找到所有的空闲内存块,然后将所有满足要求的并且大小为最小的那个空闲内存块返
回,这样的空闲内存块才是最Best的,因此被称为Best Fit。就像下图虽然有三个空闲内存块满足要
求,但是Best Fit会选择大小为8字节的空闲内存块。
显然,从直觉上我们就能得出Best Fit会比前两种方法能更合理利用内存的结论,各项研究也证实了
这一点。

然而Best Fit最大的缺点就是分配内存时需要遍历堆上所有的空闲内存块,在速度上显然不及前面两
种方法。

以上介绍的这三种策略在各种内存分配器中非常常见,当然分配策略远不止这几种,但这些算法不是
该主题下关注的重点,因此就不在这里详细阐述了,假设在这里我们选择First Fit算法。

没有银弹

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

其实不止内存分配器,在设计其它软件系统时我们也没有银弹。
分配内存

现在我们找到合适的空闲内存块了,接下来我们又将面临一个新的问题。

如果用户需要12字节,而我们的空闲内存块也恰好是12字节,那么很好,直接返回就可以了。

但是,如果用户申请12字节内存,而我们找到的空闲内存块大小为32字节,那么我们是要将这32字
节的整个空闲内存块标记为已分配吗?就像这样:

这样虽然速度最快,但显然会浪费内存,形成内部碎片,也就是说该内存块剩下的空间将无法被利用
到。
一种显而易见的方法就是将空闲内存块进行划分,前一部分设置为已分配,返回给内存申请者使用,
后一部分变为一个新的空闲内存块,只不过大小会更小而已,就像这样:

我们需要将空闲内存块大小从32修改为16,其中消息头header占据4字节,剩下的12字节分配出去,
并将标记为置为1,表示该内存块已分配。
分配出16字节后,还剩下16字节,我们需要拿出4字节作为新的header并将其标记为空闲内存块。

释放内存

到目前为止,我们的malloc已经能够处理内存分配请求了,还差最后的内存释放。

内存释放和我们想象的不太一样,该过程并不比前几个环节简单。我们要考虑到的关键一点就在于,
与被释放的内存块相邻的内存块可能也是空闲的。如果释放一块内存后我们仅仅简单的将其标志位置
为空闲,那么可能会出现下面的场景:

从图中我们可以看到,被释放内存的下一个内存块也是空闲的,如果我们仅仅将这16个字节的内存块
标记为空闲的话,那么当下一次申请20字节时图中的这两个内存块都不能满足要求,尽管这两个空闲
内存块的总数要超过20字节。

因此一种更好的方法是当应用程序向我们的malloc释放内存时,我们查看一下相邻的内存块是否是空
闲的,如果是空闲的话我们需要合并空闲内存块,就像这样:
在这里我们又面临一个新的决策,那就是释放内存时我们要立即去检查能否够合并相邻空闲内存块
吗?还是说我们可以推迟一段时间,推迟到下一次分配内存找不到满足要的空闲内存块时再合并相邻
空闲内存块。

释放内存时立即合并空闲内存块相对简单,但每次释放内存时将引入合并内存块的开销,如果应用程
序总是释放12字节然后申请12字节,然后在释放12字节等等这样重复的模式:

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


那么这种内存使用模式对立即合并空闲内存块这种策略非常不友好,我们的内存分配器会有很多的无
用功。但这种策略最为简单,在这里我们依然选择使用这种简单的策略。

实际上我们需要意识到,实际使用的内存分配器都会有某种推迟合并空闲内存块的策略。

高效合并空闲内存块

合并空闲内存块的故事到这里就完了吗?问题没有那么简单。

让我们来看这样一个场景:
使用的内存块其前和其后都是空闲的,在当前的设计中我们可以很容易的知道后一个内存块是空闲
的,因为我们只需要从当前位置向下移动16字节就是下一个内存块,但我们怎么能知道上一个内存块
是不是空闲的呢?
我们之所以能向后跳是因为当前内存块的大小是知道的,那么我们该怎么向前跳找到上一个内存块
呢?

还是我们上文提到的Donald Knuth,老爷子提出了一个很聪明的设计,我们之所以不能往前跳是因
为不知道前一个内存块的信息,那么我们该怎么快速知道前一个内存块的信息呢?
Knuth老爷子的设计是这样的,我们不是有一个信息头header吗,那么我们就在该内存块的末尾再加
一个信息尾,footer,footer一词用的很形象,header和footer的内容是一样的。

因为上一内存块的footer和下一个内存块的header是相邻的,因此我们只需要在当前内存块的位置向
上移动4直接就可以等到上一个内存块的信息,这样当我们释放内存时就可以快速的进行相邻空闲内
存块的合并了。
收工

至此,我们的内存分配器就已经设计完毕了。

我们的简单内存分配器采用了First Fit分配算法;找到一个满足要求的内存块后会进行切分,剩下的
作为新的内存块;同时当释放内存时会立即合并相邻的空闲内存块,同时为加快合并速度,我们引入
了Donald Knuth的设计方法,为每个内存块增加footer信息。

这样,我们自己实现的内存分配就可以运行起来了,可以真正的申请和释放内存。

总结
本文从0到1实现了一个简单的内存分配器,但不希望这里的阐述给大家留下内存分配器实现很简单的
印象,实际上本文实现的内存分配器还有大量的优化空间,同时我们也没有考虑线程安全问题,但这
些都不是本文的目的。

本文的目的在于把内存分配器的本质告诉大家,对于想理解内存分配器实现原理的同学来说这些已经
足够了,而对于要编写高性能程序的同学来说实现自己的内存池是必不可少的,内存池实现也离不开
这里的讨论。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
线程池是如何实现的?

大家生活中肯定都有这样的经验,那就是大众化的产品都比较便宜,但便宜的大众产品就是一个词,
普通;而可以定制的产品一般都价位不凡,这种定制的产品注定不会在大众中普及,因此定制产品就
是一个词,独特。

有的同学可能会有疑问,你不是要聊技术吗?怎么又说起消费了?

原来技术也有大众货以及定制品。

通用 VS 定制

作为程序员(C/C++)我们知道申请内存使用的是malloc,malloc其实就是一个通用的大众货,什么场
景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能。
malloc性能不高的原因一在于其没有为特定场景做优化,除此之外还在于malloc看似简单,但是其调
用过程是很复杂的,一次malloc的调用过程可能需要经过操作系统的配合才能完成。

那么调用malloc时底层都发生了什么呢?简单来说会有这样典型的几个步骤:

1. malloc开始搜索空闲内存块,如果能找到一块大小合适的就分配出去
2. 如果malloc找不到一块合适的空闲内存,那么调用brk等系统调用扩大堆区从而获得更多的空闲
内存
3. malloc调用brk后开始转入内核态,此时操作系统中的虚拟内存系统开始工作,扩大进程的堆
区,注意额外扩大的这一部分内存仅仅是虚拟内存,操作系统并没有为此分配真正的物理内存
4. brk执行结束后返回到malloc,从内核态切换到用户态,malloc找到一块合适的空闲内存后返回

以上就是一次内存申请的完整过程,我们可以看到,一次内存申请过程其实是非常复杂的,关于这个
问题的详细讨论你可以参考这里。

既然每次分配内存都要经过这么复杂的过程,那么如果程序大量使用malloc申请内存那么该程序注定
无法获得高性能。

幸好,除了大众货的malloc,我们还可以私人定制,也就是针对特定场景自己来维护内存申请和分
配,这就是高性能高并发必备的内存池技术。

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

有的同学可能会说,等等,那malloc和这里提到的内存池技术有什么区别呢?

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

那么内存池技术是怎样优化性能的呢?
内存池技术原理

简单来说,内存池技术一次性获取到大块内存,然后在其之上自己管理内存的申请和释放,这样就绕
过了标准库以及操作系统:

也就是说,通过内存池,一次内存的申请再也不用去绕一大圈了。

除此之外,我们可以根据特定的使用模式来进一步优化,比如在服务器端,每次用户请求需要创建的
对象可能就那几种,那么这时我们就可以在自己的内存池上提前创建出这些对象,当业务逻辑需要时
就从内存池中申请已经创建好的对象,使用完毕后还回内存池。

因此我们可以看到,这种为某些应用场景定制的内存池相比通用的比如malloc内存分配器会有大的优
势。

接下来我们就着手实现一个。

实现内存池的考虑

值得注意的是,内存池实际上有很多的实现方法,在这里我们还是以服务器端编程为例来说明。

假设你的服务器程序非常简单,处理用户请求时只使用一种对象(数据结构),那么最简单的就是我们
提前申请出一堆来,使用的时候拿出一个,使用完后还回去:
怎么样,足够简单吧!这样的内存池只能分配特定对象(数据结构),当然这样的内存池需要自己维护
哪些对象是已经被分配出去的,哪些是还没有被使用的。

但是,在这里我们可以实现一个稍微复杂一些的,那就是可以申请不同大小的内存,而且由于是服务
器端编程,那么一次用户请求过程中我们只申请内存,只有当用户请求处理完毕后一次性释放所有内
存,从而将内存申请释放的开销降低到最小。

因此,你可以看到,内存池的设计都是针对特定场景的。

现在,有了初步的设计,接下来就是细节了。

数据结构

为了能够分配大小可变的对象,显然我们需要管理空闲内存块,我们可以用一个链表把所有内存块链
接起来,然后使用一个指针来记录当前空闲内存块的位置,如图所示:
从图中我们可以看到,有两个空闲内存块,空闲内存之间使用链表链接起来,每个内存块都是前一个
的2倍,也就是说,当内存池中的空闲内存不足以分配时我们就向malloc申请内存,只不过其大小是
前一个的2倍:

其次,我们有一个指针free_ptr,指向接下来的空闲内存块起始位置,当向内存池分配内存时找到
free_ptr并判断当前内存池剩余空闲是否足够就可以了,有就分配出去并修改free_ptr,否则向
malloc再次成倍申请内存。
从这里的设计可以看出,我们的内存池其实是不会提供类似free这样的内存释放函数的,如果要释放
内存,那么会一次性将整个内存池释放掉,这一点和通用的内存分配器是不一样。

现在,我们可以分配内存了,还有一个问题是所有内存池设计不得不考虑的,那就是线程安全,这个
话题你可以参考这里。

线程安全

显然,内存池不应该局限在单线程场景,那我们的内存池要怎样实现线程安全呢?

有的同学可能会说这还不简单,直接给内存池一把锁保护就可以了。
这种方法是不是可行呢?还是那句话,It depends,要看情况。

如果你的程序有大量线程申请释放内存,那么这种方案下锁的竞争将会非常激烈,线程这样的场景下
使用该方案不会有很好的性能。

那么还有没有一种更好的办法吗?答案是肯定的。
线程局部存储

既然多线程使用线程池存在竞争问题,那么干脆我们为每个线程维护一个内存池就好了,这样多线程
间就不存在竞争问题了。

那么我们该怎样为每个线程维护一个内存池呢?

线程局部存储,Thread Local Storage正是用于解决这一类问题的,什么是线程局部存储呢?

简单说就是,我们可以创建一个全局变量,因此所有线程都可以使用该全局变量,但与此同时,我们
将该全局变量声明为线程私有存储,那么这时虽然所有线程依然看似使用同一个全局变量,但该全局
变量在每个线程中都有自己的副本,变量指向的值是线程私有的,相互之间不会干扰。

关于线程局部存储,可以参考这里。

假设这个全局变量是一个整数,变量名字为global_value,初始值为100,那么当线程A将
global_value修改为200时,线程B看到的global_value的值依然为100,只有线程A看到的
global_value为200,这就是线程局部存储的作用。

线程局部存储+内存池

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

还需要提醒的是,这里只是给出了内存池的一种实现方法,并不是说所有内存池都要这么设计,内存
池可以简单也可复杂,一切要看实际场景,这一点也需要注意。

其它内存池形式

到目前为止我们给出了两种内存池的设计方法,第一种是提前创建出一堆需要的对象(数据结构),自
己维护好哪些对象(数据结构)可用哪些已被分配;第二种可以申请任意大小的内存空间,使用过程中
只申请不释放,最后一次性释放。这两种内存池天然适用于服务器端编程。

最后我们再来介绍一种内存池实现技术,这种内存池会提前申请出一大段内存,然后将这一大段内存
切分为大小相同的小内存块:
然后我们自己来维护这些被切分出来的小内存块哪些是空闲的哪些是已经被分配的,比如我们可以使
用栈这种数据结构,最初把所有空闲内存块地址push到栈中,分配内存是就pop出来一个,用户使用
完毕后再push回栈里。

从这里的设计我们可以看出,这种内存池有一个限制,这个限制就是说程序申请的最大内存不能超过
这里内存块的大小,否则不足以装下用户数据,这需要我们对程序所涉及的业务非常了解才可以。
用户申请到内存后根据需要将其塑造成特定对象(数据结构)。

关于线程安全的问题,可以同样采用线程局部存储的方式来实现:

一个有趣的问题

除了线程安全,这里还有一个非常有趣的问题,那就是如果线程A申请的对象被线程B拿去释放,我们
的内存池该怎么处理呢?

这个问题之所以有趣是因为我们必须知道该内存属于哪个线程的局部存储,但申请的内存本身并不能
告诉你这样的信息。

有的同学可能会说这还不简单,不就是一个指针到另一个指针的映射吗,直接用map之类存起来就好
了,但问题并没有这么简单,原因就在于如果我们切分的内存块很小,那么会存在大量内存块,这就
需要存储大量的映射关系,有没有办法改进呢?

改进方法是这样的,一般来说,我们申请到的大段内存其实是会按照特定大小进行内存对齐,我们假
设总是按照4K字节对齐,那么该大段内存的起始地址后12个bit(4K = 2^12)为总是0,比如地址
0x9abcd000,同时我们也假设申请到的大段内存大小也是4K:
那么我们就能知道该大段内存中的各个小内存块起始地址除了后12个bit位外都是一样的:
这样拿到任意一个内存的地址我们就能知道对应的大段内存的起始地址,只需要简单的将后12个bit
置为0即可,有了大段内存的起始地址剩下的就简单了,我们可以在大段内存中的最后保存对应的线
程局部存储信息:
这样我们对任意一个内存块地址进行简单的位运算就可以得到对应的线程局部存储信息,大大减少了
维护映射信息对内存的占用。

总结

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

希望本文对大家理解内存池有所帮助

关注作者
也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

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

相信有很多同学在面对多线程代码时都会望而生畏,认为多线程代码就像一头难以驯服的怪兽,你制
服不了这头怪兽它就会反过来吞噬你。

夸张了哈,总之,多线程程序有时就像一潭淤泥,走不进去退不出来。

可这是为什么呢?为什么多线程代码如此难以正确编写呢?

从根源上思考

关于这个问题,本质上是有一个词语你没有透彻理解,这个词就是所谓的线程安全,thread safe。

如果你不能理解线程安全,那么给你再多的方案也是无用武之地。

接下来我们了解一下什么是线程安全,怎样才能做到线程安全。

这些问题解答后,多线程这头大怪兽自然就会变成温顺的小猫咪。

关你什么屁事
生活中我们口头上经常说的一句话就是“关你屁事”,大家想一想,为什么我们的屁事不关别人?

原因很简单,这是我的私事啊!我的衣服、我的电脑,我的手机、我的车子、我的别墅以及私人泳池
(可以没有,但不妨碍想象),我想怎么处理就怎么处理,妨碍不到别人,只属于我一个人的东西以及
事情当然不关别人,即使是屁事也不关别人。

我们在自己家里想吃什么吃什么,想去厕所就去厕所!因为这些都是我私有的,只有我自己使用。

那么什么时候会和其它人有交集呢?

答案就是公共场所。

在公共场所下你不能像在自己家里一样想去哪就去哪,想什么时候去厕所就去厕所,为什么呢?原因
很简单,因为公共场所下的饭馆、卫生间不是你家的,这是公共资源,大家都可以使用的公共资源。

如果你想去饭馆、去公共卫生间那么就必须遵守规则,这个规则就是排队,只有前一个人用完公共资
源后下一个人才可以使用,而且不能同时使用,想使用就必须排队等待。

上面这段话道理足够简单吧。

如果你能理解这段话,那么驯服多线程这头小怪兽就不在话下。

维护公共场所秩序
如果把你自己理解为线程的话,那么在你自己家里使用私有资源就是所谓的线程安全,原因很简单,
因为你随便怎么折腾自己的东西(资源)都不会妨碍到别人;

但到公共场所浪的话就不一样了,在公共场所使用的是公共资源,这时你就不能像在自己家里一样想
怎么用就怎么用想什么时候用就什么时候用,公共场所必须有相应规则,这里的规则通常是排队,只
有这样公共场所的秩序才不会被破坏,线程以某种不妨碍到其它线程的秩序使用共享资源就能实现线
程安全。

因此我们可以看到,这里有两种情况:

线程私有资源,没有线程安全问题
共享资源,线程间以某种秩序使用共享资源也能实现线程安全。

本文都是围绕着上述两个核心点来讲解的,现在我们就可以正式的聊聊编程中的线程安全了。

什么是线程安全

#####

我们说一段代码是线程安全的,当且仅当我们在多个线程中同时且多次调用的这段代码都能给出正确
的结果,这样的代码我们才说是线程安全代码,Thread Safety,否则就不是线程安全代码,thread-
unsafe.。

非线程安全的代码其运行结果是由掷骰子决定的。
怎么样,线程安全的定义很简单吧,也就是说你的代码不管是在单个线程还是多个线程中被执行都应
该能给出正确的运行结果,这样的代码是不会出现多线程问题的,就像下面这段代码:

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

对于这样段代码,无论你用多少线程同时调用、怎么调用、什么时候调用都会返回2,这段代码就是
线程安全的。

那么我们该怎样写出线程安全的代码呢?

要回答这个问题,我们需要知道我们的代码什么时候呆在自己家里使用私有资源,什么时候去公共场
所浪使用公共资源,也就是说你需要识别线程的私有资源和共享资源都有哪些,这是解决线程安全问
题的核心所在。
线程私有资源

#####

线程都有哪些私有资源呢?啊哈,我们在上一篇《线程到底共享了哪些进程资源》中详细讲解了这个
问题。

线程运行的本质其实就是函数的执行,函数的执行总会有一个源头,这个源头就是所谓的入口函数,
CPU从入口函数开始执行从而形成一个执行流,只不过我们人为的给执行流起一个名字,这个名字就
叫线程。

既然线程运行的本质就是函数的执行,那么函数运行时信息都保存在哪里呢?

答案就是栈区,每个线程都有一个私有的栈区,因此在栈上分配的局部变量就是线程私有的,无论我
们怎样使用这些局部变量都不管其它线程屁事。
线程私有的栈区就是线程自己家。

线程间共享数据

#####

除了上一节提到的剩下的区域就是公共场合了,这包括:

用于动态分配内存的堆区,我们用C/C++中的malloc或者new就是在堆区上申请的内存
全局区,这里存放的就是全局变量
文件,我们知道线程是共享进程打开的文件
有的同学可能说,等等,在上一篇文章不是说还有代码区和动态链接库吗?

要知道这两个区域是不能被修改的,也就是说这两个区域是只读的,因此多个线程使用是没有问题
的。

在刚才我们提到的堆区、数据区以及文件,这些就是所有的线程都可以共享的资源,也就是公共场
所,线程在这些公共场所就不能随便浪了。

线程使用这些共享资源必须要遵守秩序,这个秩序的核心就是对共享资源的使用不能妨碍到其它线
程,无论你使用各种锁也好、信号量也罢,其目的都是在维护公共场所的秩序。

知道了哪些是线程私有的,哪些是线程间共享的,接下来就简单了。

值得注意的是,关于线程安全的一切问题全部围绕着线程私有数据与线程共享数据来处理,抓住了线
程私有资源和共享资源这个主要矛盾也就抓住了解决线程安全问题的核心。

接下来我们看下在各种情况下该怎样实现线程安全,依然以C/C++代码为例,但是这里讲解的方法适
用于任何语言,请放心,这些代码足够简单。
只使用线程私有资源

#####

我们来看这段代码:

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

这段代码在前面提到过,无论你在多少个线程中怎么调用什么时候调用,func函数都会确定的返回
2,该函数不依赖任何全局变量,不依赖任何函数参数,且使用的局部变量都是线程私有资源,这样
的代码也被称为无状态函数,stateless,很显然这样的代码是线程安全的。

这样的代码请放心大胆的在多线程中使用,不会有任何问题。

有的同学可能会说,那如果我们还是使用线程私有资源,但是传入函数参数呢?
线程私有资源+函数参数

这样的代码是线程安全的吗?自己先想一想这个问题。

答案是it depends,也就是要看情况。看什么情况呢?

1,按值传参

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

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

这这段代码无论在多少个线程中调用怎么调用什么时候调用都会正确返回参数加1后的值。

原因很简单,按值传入的这些参数是线程私有资源。

2,按引用传参

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

int func(int* num) {


++(*num);
return *num;
}

如果调用该函数的线程传入的参数是线程私有资源,那么该函数依然是线程安全的,能正确的返回参
数加1后的值。

但如果传入的参数是全局变量,就像这样:

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

那此时func函数将不再是线程安全代码,因为传入的参数指向了全局变量,这个全局变量是所有线程
可共享资源,这种情况下如果不改变全局变量的使用方式,那么对该全局变量的加1操作必须施加某
种秩序,比如加锁。
有的同学可能会说如果我传入的不是全局变量的指针(引用)是不是就不会有问题了?

答案依然是it depends,要看情况。

即便我们传入的参数是在堆上(heap)用malloc或new出来的,依然可能会有问题,为什么?

答案很简单,因为堆上的资源也是所有线程可共享的。

假如有两个线程调用func函数时传入的指针(引用)指向了同一个堆上的变量,那么该变量就变成了这
两个线程的共享资源,在这种情况下func函数依然不是线程安全的。

改进也很简单,那就是每个线程调用func函数传入一个独属于该线程的资源地址,这样各个线程就不
会妨碍到对方了,因此,写出线程安全代码的一大原则就是能用线程私有的资源就用私有资源,线程
之间尽最大可能不去使用共享资源。

如果线程不得已要使用全局资源呢?

使用全局资源
#####

使用全局资源就一定不是线程安全代码吗?

答案还是。。有的同学可能已经猜到了,答案依然是要看情况。

如果使用的全局资源只在程序运行时初始化一次,此后所有代码对其使用都是只读的,那么没有问
题,就像这样:

int global_num = 100; //初始化一次,此后没有其它代码修改其值


int func() {
return global_num;
}

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

但,如果我们简单修改一下func:

int global_num = 100;


int func() { ++global_num; return global_num;}

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

线程局部存储

#####

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

__thread int global_num = 100;


int func() {
++global_num;
return global_num;
}

我们看到全局变量global_num前加了关键词__thread修饰,这时,func代码就是又是线程安全的
了。

为什么呢?

其实在上一篇文章中我们讲过,被__thread关键词修饰过的变量放在了线程私有存储中,Thread
Local Storage,什么意思呢?

意思是说这个变量是线程私有的全局变量:
global_num是全局变量
global_num是线程私有的

各个线程对global_num的修改不会影响到其它线程,因为是线程私有资源,因此func函数是线程安
全的。

说完了局部变量、全局变量、函数参数,那么接下来就到函数返回值了。

函数返回值

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

1,返回的是值

我们来看这样一段代码:

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

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

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

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

如果我们在多线程中调用这样的函数,那么接下来等着你的可能就是难以调试的bug以及漫漫的加班
长夜。。

很显然,这不是线程安全代码,产生bug的原因也很简单,你在使用该变量前其值可能已经被其它线
程修改了。因为该函数使用了一个静态全局变量,只要能拿到该变量的地址那么所有线程都可以修改
该变量的值,因为这是线程间的共享资源,不到万不得已不要写出上述代码,除非老板拿刀架在你脖
子上。

但是,请注意,有一个特例,这种使用方法可以用来实现设计模式中的单例模式,就像这样:
class S {
public:
static S& getInstance() {
static S instance;
return instance;
}
private: S() {} // 其它省略
}

为什么呢?

因为无论我们调用多少次func函数,static局部变量都只会被初始化一次,这种特性可以很方便的让
我们实现单例模式。

最后让我们来看下这种情况,那就是如果我们调用一个非线程安全的函数,那么我们的函数是线程安
全的吗?

调用非线程安全代码

假如一个函数A调用另一个函数B,但B不是线程安全,那么函数A是线程安全的吗?

答案依然是,要看情况。

我们看下这样一段代码,这段代码在之前讲解过:

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

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

int funcA() {
mutex l;
l.lock();
func();
l.unlock();
}

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

再看这样一段代码:
int func(int *num) {
++(*num);
return *num;
}

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

void funcA() {
int a = 100;
func(&a);
}

那么这时funcA函数依然是线程安全的,因为传入的参数是线程私有的局部变量,无论多少线程调用
funcA都不会干扰到其它线程。

看了各种情况下的线程安全问题,最后让我们来总结一下实现线程安全代码都有哪些措施。

如何实现线程安全

从上面各种情况的分析来看,实现线程安全无外乎围绕线程私有资源和线程共享资源这两点,你需要
识别出哪些是线程私有,哪些是共享的,这是核心,然后对症下药就可以了。

不使用任何全局资源,只使用线程私有资源,这种通常被称为无状态代码

线程局部存储,如果要使用全局资源,是否可以声明为线程局部存储,因为这种变量虽然是全局
的,但每个线程都有一个属于自己的副本,对其修改不会影响到其它线程

只读,如果必须使用全局资源,那么全局资源是否可以是只读的,多线程使用只读的全局资源不
会有线程安全问题。

原子操作,原子操作是说其在执行过程中是不可能被其它线程打断的,像C++中的std::atomic修
饰过的变量,对这类变量的操作无需传统的加锁保护,因为C++会确保在变量的修改过程中不会
被打断。我们常说的各种无锁数据结构通常是在这类原子操作的基础上构建的 。

同步互斥,到这里也就确定了你必须要以某种形式使用全局资源,那么在这种情况下公共场所的
秩序必须得到维护,那么怎么维护呢?通过同步或者互斥的方式,这是一大类问题,我们将在
《深入理解操作系统》系列文章中详细阐述这一问题。

总结

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

希望本文对大家编写多线程程序有帮助。

关注作者

程序员应如何理解协程

作为程序员,想必你多多少少听过协程这个词,这项技术近年来越来越多的出现在程序员的视野当
中,尤其高性能高并发领域。当你的同学、同事提到协程时如果你的大脑一片空白,对其毫无概
念。。。

那么这篇文章正是为你量身打造的。

话不多说,今天的主题就是作为程序员,你应该如何彻底理解协程。
普通的函数

我们先来看一个普通的函数,这个函数非常简单:

def func():
print("a")
print("b")
print("c")

这是一个简单的普通函数,当我们调用这个函数时会发生什么?

1. 调用func
2. func开始执行,直到return
3. func执行完成,返回函数A

是不是很简单,函数func执行直到返回,并打印出:

abc

So easy,有没有,有没有!

很好!

注意这段代码是用python写的,但本篇关于协程的讨论适用于任何一门语言,因为协程并不是一种语
言的特性。而我们只不过恰好使用了python来用作示例,因其足够简单。

那么协程是什么呢?

从普通函数到协程

接下来,我们就要从普通函数过渡到协程了。

和普通函数只有一个返回点不同,协程可以有多个返回点。

这是什么意思呢?

void func() {
print("a")
暂停并返回
print("b")
暂停并返回
print("c")
}
普通函数下,只有当执行完print("c")这句话后函数才会返回,但是在协程下当执行完print("a")后func
就会因“暂停并返回”这段代码返回到调用函数。

有的同学可能会一脸懵逼,这有什么神奇的吗?我写一个return也能返回,就像这样:

void func() {
print("a")
return
print("b")
暂停并返回
print("c")
}

直接写一个return语句确实也能返回,但这样写的话return后面的代码都不会被执行到了。

协程之所以神奇就神奇在当我们从协程返回后还能继续调用该协程,并且是从该协程的上一个返回点
后继续执行。

这足够神奇吧,就好比孙悟空说一声“定”,函数就被暂停了:

void func() {
print("a")

print("b")

print("c")
}

这时我们就可以返回到调用函数,当调用函数什么时候想起该协程后可以再次调用该协程,该协程会
从上一个返回点继续执行。

Amazing,有没有,集中注意力,千万不要翻车。

只不过孙大圣使用的口诀“定”字,在编程语言中一般叫做yield(其它语言中可能会有不同的实现,但本
质都是一样的)。
需要注意的是,当普通函数返回后,进程的地址空间中不会再保存该函数运行时的任何信息,而协程
返回后,函数的运行时信息是需要保存下来的,那么函数的运行时状态到底在内存中是什么样子呢,
关于这个问题你可以参考这里。

接下来,我们就用实际的代码看一看协程。

Show Me The Code

下面我们使用一个真实的例子来讲解,语言采用python,不熟悉的同学不用担心,这里不会有理解上
的门槛。

在python语言中,这个“定”字同样使用关键词yield,这样我们的func函数就变成了:

void func() {
print("a")
yield
print("b")
yield
print("c")
}

注意,这时我们的func就不再是简简单单的函数了,而是升级成为了协程,那么我们该怎么使用呢,
很简单:

def A():
co = func() # 得到该协程
next(co) # 调用协程
print("in function A") # do something
next(co) # 再次调用该协程

我们看到虽然func函数没有return语句,也就是说虽然没有返回任何值,但是我们依然可以写co =
func()这样的代码,意思是说co就是我们拿到的协程了。

接下来我们调用该协程,使用next(co),运行函数A看看执行到第3行的结果是什么:

显然,和我们的预期一样,协程func在print("a")后因执行yield而暂停并返回函数A。

接下来是第4行,这个毫无疑问,A函数在做一些自己的事情,因此会打印:

ain function A

接下来是重点的一行,当执行第5行再次调用协程时该打印什么呢?
如果func是普通函数,那么会执行func的第一行代码,也就是打印a。

但func不是普通函数,而是协程,我们之前说过,协程会在上一个返回点继续运行,因此这里应该执
行的是func函数第一个yield之后的代码,也就是print("b")。

ain function Ab

看到了吧,协程是一个很神奇的函数,它会自己记住之前的执行状态,当再次调用时会从上一次的返
回点继续执行。

图形化解释

为了让你更加彻底的理解协程,我们使用图形化的方式再看一遍,首先是普通的函数调用:
在该图中,方框内表示该函数的指令序列,如果该函数不调用任何其它函数,那么应该从上到下依次
执行,但函数中可以调用其它函数,因此其执行并不是简单的从上到下,箭头线表示执行流的方向。

从图中我们可以看到,我们首先来到funcA函数,执行一段时间后发现调用了另一个函数funcB,这
时控制转移到该函数,执行完成后回到main函数的调用点继续执行。

这是普通的函数调用。

接下来是协程。
在这里,我们依然首先在funcA函数中执行,运行一段时间后调用协程,协程开始执行,直到第一个
挂起点,此后就像普通函数一样返回funcA函数,funcA函数执行一些代码后再次调用该协程,注
意,协程这时就和普通函数不一样了,协程并不是从第一条指令开始执行而是从上一次的挂起点开始
执行,执行一段时间后遇到第二个挂起点,这时协程再次像普通函数一样返回funcA函数,funcA函
数执行一段时间后整个程序结束。

函数只是协程的一种特例

怎么样,神奇不神奇,和普通函数不同的是,协程能知道自己上一次执行到了哪里。
现在你应该明白了吧,协程会在函数被暂停运行时保存函数的运行状态,并可以从保存的状态中恢复
并继续运行。

很熟悉的味道有没有,这不就是操作系统对线程的调度嘛,线程也可以被暂停,操作系统保存线程运
行状态然后去调度其它线程,此后该线程再次被分配CPU时还可以继续运行,就像没有被暂停过一
样。

只不过线程的调度是操作系统实现的,这些对程序员都不可见,而协程是在用户态实现的,对程序员
可见。

这就是为什么有的人说可以把协程理解为用户态线程的原因。

此处应该有掌声。

也就是说现在程序员可以扮演操作系统的角色了,你可以自己控制协程在什么时候运行,什么时候暂
停,也就是说协程的调度权在你自己手上。

在协程这件事儿上,调度你说了算。

当你在协程中写下yield的时候就是想要暂停该协程,当使用next()时就是要再次运行该协程。

现在你应该理解为什么说函数只是协程的一种特例了吧,函数其实只是没有挂起点的协程而已。

协程的历史
有的同学可能认为协程是一种比较新的技术,然而其实协程这种概念早在1958年就已经提出来了,要
知道这时线程的概念都还没有提出来。

到了1972年,终于有编程语言实现了这个概念,这两门编程语言就是Simula 67 以及Scheme。

但协程这个概念始终没有流行起来,甚至在1993年还有人考古一样专门写论文挖出协程这种古老的技
术。

因为这一时期还没有线程,如果你想在操作系统写出并发程序那么你将不得不使用类似协程这样的技
术,后来线程开始出现,操作系统终于开始原生支持程序的并发执行,就这样,协程逐渐淡出了程序
员的视线。

直到近些年,随着互联网的发展,尤其是移动互联网时代的到来,服务端对高并发的要求越来越高,
协程再一次重回技术主流,各大编程语言都已经支持或计划开始支持协程。

那么协程到底是如何实现的呢?

协程是如何实现的

让我们从问题的本质出发来思考这个问题。

协程的本质是什么呢?

其实就是可以被暂停以及可以被恢复运行的函数。

那么可以被暂停以及可以被恢复意味着什么呢?

看过篮球比赛的同学想必都知道(没看过的也能知道),篮球比赛也是可以被随时暂停的,暂停时大家
需要记住球在哪一方,各自的站位是什么,等到比赛继续的时候大家回到各自的位置,裁判哨子一响
比赛继续,就像比赛没有被暂停过一样。

看到问题的关键了吗,比赛之所以可以被暂停也可以继续是因为比赛状态被记录下来了(站位、球在
哪一方),这里的状态就是计算机科学中常说的上下文,context。

回到协程。

协程之所以可以被暂停也可以继续,那么一定要记录下被暂停时的状态,也就是上下文,当继续运行
的时候要恢复其上下文(状态),那么接下来很自然的一个问题就是,函数运行时的状态是什么?

这个关键的问题的答案就在《函数运行起来后在内存中是什么样子的》这篇文章中,函数运行时所有
的状态信息都位于函数运行时栈中。

函数运行时栈就是我们需要保存的状态,也就是所谓的上下文,如图所示:
从图中我们可以看出,该进程中只有一个线程,栈区中有四个栈帧,main函数调用A函数,A函数调
用B函数,B函数调用C函数,当C函数在运行时整个进程的状态就如图所示。

现在我们已经知道了函数的运行时状态就保存在栈区的栈帧中,接下来重点来了哦。

既然函数的运行时状态保存在栈区的栈帧中,那么如果我们想暂停协程的运行就必须保存整个栈帧的
数据,那么我们该将整个栈帧中的数据保存在哪里呢?

想一想这个问题,整个进程的内存区中哪一块是专门用来长时间(进程生命周期)存储数据的?是不是
大脑又一片空白了?

先别空白!

很显然,这就是堆区啊,heap,我们可以将栈帧保存在堆区中,那么我们该怎么在堆区中保存数据
呢?希望你还没有晕,在堆区中开辟空间就是我们常用的C语言中的malloc或者C++中的new。

我们需要做的就是在堆区中申请一段空间,让后把协程的整个栈区保存下,当需要恢复协程的运行时
再从堆区中copy出来恢复函数运行时状态。

再仔细想一想,为什么我们要这么麻烦的来回copy数据呢?

实际上,我们需要做的是直接把协程的运行需要的栈帧空间直接开辟在堆区中,这样都不用来回copy
数据了,如图所示。
从图中我们可以看到,该程序中开启了两个协程,这两个协程的栈区都是在堆上分配的,这样我们就
可以随时中断或者恢复协程的执行了。

有的同学可能会问,那么进程地址空间最上层的栈区现在的作用是什么呢?

这一区域依然是用来保存函数栈帧的,只不过这些函数并不是运行在协程而是普通线程中的。

现在你应该看到了吧,在上图中实际上有3个执行流:

1. 一个普通线程
2. 两个协程

虽然有3个执行流但我们创建了几个线程呢?

一个线程。

现在你应该明白为什么要使用协程了吧,使用协程理论上我们可以开启无数并发执行流,只要堆区空
间足够,同时还没有创建线程的开销,所有协程的调度、切换都发生在用户态,这就是为什么协程也
被称作用户态线程的原因所在。

掌声在哪里?
因此即使你创建了N多协程,但在操作系统看来依然只有一个线程,也就是说协程对操作系统来说是
不可见的。

这也许是为什么协程这个概念比线程提出的要早的原因,可能是写普通应用的程序员比写操作系统的
程序员最先遇到需要多个并行流的需求,那时可能都还没有操作系统的概念,或者操作系统没有并行
这种需求,所以非操作系统程序员只能自己动手实现执行流,也就是协程。

现在你应该对协程有一个清晰的认知了吧。
总结

到这里你应该已经理解协程到底是怎么一回事了,但是,依然有一个问题没有解决,为什么协程这种
技术又一次重回视线,协程适用于什么场景下呢?该怎么使用呢?

关于这些问题,下一篇文章将会给你答案。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
10个内存引发的大坑

对程序员来说内存相关的 bug 排查难度几乎和多线程问题并驾齐驱,当程序出现运行异常时可能距离


真正有 bug 的那行代码已经很远了,这就导致问题定位排查非常困难,这篇文章将总结涉及内存的
一些经典 bug ,快来看看你知道几个,或者你的程序中现在有几个。。。

返回局部变量地址

我们来看这样一段代码:

int fun() {
int a = 2;
return &a;
}
void main() {
int* p = fun();
*p = 20;
}

这段代码非常简单,func 函数返回一个指向局部变量的地址,main 函数中调用 fun 函数,获取到指


针后将其设置为 20。
你能看出这段代码有什么问题吗?

问题在于局部变量 a 位于 func 的栈帧中,当 func 执行结束,其栈帧也不复存在,因此 main 函数中


调用 func 函数后得到的指针指向一个不存在的变量:

尽管上述代码仍然可以“正常”运行,但如果后续调用其它函数比如funcB,那么指针p指向的内容将被
funcB 函数的栈帧内容覆盖掉,又或者修改指针 p 实际上是在破坏 funcB 函数的栈帧,这将导致极其
难以排查的 bug。

错误的理解指针运算

int sum(int* arr, int len) {


int sum = 0;
for (int i = 0; i < len; i++) {
sum += *arr;
arr += sizeof(int);
}
return sum;
}

这段代码本意是想计算给定数组的和,但上述代码并没有理解指针运算的本意。

指针运算中的加1并不是说移动一个字节而是移动一个单位,指针指向的数据结构大小就是一个单
位。因此,如果指针指向的数据类型是 int,那么指针加 1 则移动 4 个字节(32位),如果指针指向的
是结构体,该结构体的大小为 1024 字节,那么指针加 1 其实是移动 1024 字节。
从这里我们可以看出,移动指针时我们根本不需要关心指针指向的数据类型的大小,因此上述代码简
单的将arr += sizeof(int)改为arr++即可。

解引用有问题的指针

C语言初学者常会犯一个经典错误,那就是从标准输入中获取键盘数据,代码是这样写的:

int a;
scanf("%d", a);

很多同学并不知道这样写会有什么问题,因为上述代码有时并不会出现运行时错误。

原来 scanf 会将a的值当做地址来对待,并将从标准输入中获取到的数据写到该地址中。

这时接下来程序的表现就取决于a的值了,而上述代码中局部变量a的值是不确定的,那么这时:

1. 如果a的值作为指针指向代码区或者其它不可写区域,操作系统将立刻kill掉该进程,这是最好的
情况,这时发现问题还不算很难
2. 如果a的值作为指针指向栈区,那么此时恭喜你,其它函数的栈帧已经被破坏掉了,那么程序接
下来的行为将脱离掌控,这样的 bug 极难定位
3. 如果a的值作为指针指向堆区,那么此时也恭喜你,代码中动态分配的内存已经被你破坏掉了,
那么程序接下来的行为同样脱离掌控,这样的bug也极难定位
读取未初始化的内存

我们来看这样一段代码:

void add() {
int* a = (int*)malloc(sizeof(int));
*a += 10;
}

上述代码的错误之处在于假设从堆上动态分配的内存总是初始化为 0,实际上并不是这样的。

我们需要知道,当调用 malloc 时实际上有以下两种可能:

1. 如果 malloc 自己维护的内存够用,那么 malloc 从空闲内存中找到一块大小合适的返回,注意,


这一块内存可能是之前用过后释放的。在这种情况下,这块内存包含了上次使用时留下的信息,
因此不一定为0
2. 如果 malloc 自己维护的内存不够用,那么通过 brk 等系统调用向操作系统申请内存,在这种情
况下操作系统返回的内存确实会被初始化为0。

原因很简单,操作系统返回的这块内存可能之前被其它进程使用过,这里面也许会包含了一些敏
感信息,像密码之类,因此出于安全考虑防止你读取到其它进程的信息,操作系统在把内存交给
你之前会将其初始化为0。

现在你应该知道了吧,你不能想当然的假定 malloc 返回给你的内存已经被初始化为 0,你需要自己


手动清空。

内存泄漏

void memory_leak() {
int *p = (int *)malloc(sizeof(int));
return;
}
上述代码在申请一段内存后直接返回,这样申请到的这块内存在代码中再也没有机会释放掉了,这就
是内存泄漏。

内存泄漏是一类极为常见的问题,尤其对于不支持自动垃圾回收的语言来说,但并不是说自带垃圾回
收的语言像 Java 等就不会有内存泄漏,这类语言同样会遇到内存泄漏问题。

有内存泄漏问题的程序会不断的申请内存,但不去释放,这会导致进程的堆区越来越大直到进程被操
作系统 Kill 掉,在 Linux 系统中这就是有名的 OOM 机制,Out Of Memory Killer。

幸好,有专门的工具来检测内存泄漏出在了哪里,像valgrind、gperftools等。

内存泄漏是一个很有意思的问题,对于那些运行时间很短的程序来说,内存泄漏根本就不是事儿,因
为对现代操作系统来说,进程退出后操作系统回收其所有内存,这就是意味着对于这类程序即使有内
存泄漏也就是发生在短时间内,甚至你根本就察觉不出来。

但是对于服务器一类需要长时间运行的程序来说内存泄漏问题就比较严重了,内存泄漏将会影响系统
性能最终导致进程被 OOM 杀掉,对于一些关键的程序来说,进程退出就意味着收入损失,特别是在
节假日等重要节点出现内存泄漏的话,那么肯定又有一批程序员要被问责了。

引用已被释放的内存

void add() {
int* a = (int*)malloc(sizeof(int));
...
free(a);
int* b = (int*)malloc(sizeof(int));
*b = *a;
}
这段代码在堆区申请了一块内存装入整数,之后释放,可是在后续代码中又再一次引用了被释放的内
存块,此时a指向的内存保存什么内容取决于malloc 内部的工作状态:

1. 指针a指向的那块内存释放后没有被 malloc 再次分配出去,那么此时a指向的值和之前一样


2. 指针a指向的那块内存已经被 malloc分配出去了,此时a指向的内存可能已经被覆盖,那么*b得
到的就是一个被覆盖掉的数据,这类问题可能要等程序运行很久才会发现,而且往往难以定位。

循环遍历是0开始的

void init(int n) {
int* arr = (int*)malloc(n * sizeof(int));
for (int i = 0; i <= n; i++) {
arr[i] = i;
}
}

这段代码的本意是要初始化数组,但忘记了数组遍历是从 0 开始的,实际上述代码执行了 n+1 次赋


值操作,同时将数组 arr 之后的内存用 i 覆盖掉了。
这同样取决于 malloc 的工作状态,如果 malloc 给到 arr 的内存本身比n*sizeof(int)要大,那么覆盖
掉这块内存可能也不会有什么问题,但如果覆盖的这块内存中保存有 malloc 用于维护内存分配信息
的话,那么此举将破坏 malloc 的工作状态。

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

int **create(int n) {
int i;
int **M = (int **)malloc(n * sizeof(int));
for (i = 0; i < n; i++)
M[i] = (int *)malloc(m * sizeof(int));
return M;
}

这段代码的本意是要创建一个n*n二维数组,但其错误出现在了第3行,应该是 sizeof(int *) 而不是


sizeof(int),实际上这行代码创建了一个包含有 n 个 int 的数组,而不是包含 n 个 int 指针的数组。

但有趣的是,这行代码在int和int*大小相同的系统上可以正常运行,但是对于int指针比int要大的系
统来说,上述代码同样会覆盖掉数组M之后的一部分内存,这里和上一个例子类似,如果这部分内存
是 malloc 用来保存内存分配信息用的,那么也许当释放这段内存时才会出现运行时异常,此时可能
已经距离出现问题的那行代码很远了,这类 bug 同样难以排查。

栈缓冲器溢出
void buffer_overflow() {
char buf[32];
gets(buf);
return;
}

上面这段代码总是假定用户的输入不过超过 32 字节,一旦超过后,那么将立刻破坏栈帧中相邻的数
据,破坏函数栈帧最好的结果是程序立刻crash,否则和前面的例子一样,也许程序运行很长一段时
间后才出现错误,或者程序根本就不会有运行时异常但是会给出错误的计算结果。

实际上在上面几个例子中也会有“溢出”,不过是在堆区上的溢出,但栈缓冲器溢出更容易导致问题,
因为栈帧中保存有函数返回地址等重要信息,一类经典的黑客攻击技术就是利用栈缓冲区溢出,其原
理也非常简单。

原来,每个函数运行时在栈区都会存在一段栈帧,栈帧中保存有函数返回地址,在正常情况下,一个
函数运行完成后会根据栈帧中保存的返回地址跳转到上一个函数,假设函数A调用函数B,那么当函数
B运行完成后就会返回函数A,这个过程如图所示:
你可以在《函数运行时在内存中是什么样子》这篇文章中找到关于函数运行时栈帧的详细讲解。

但如果代码中存在栈缓冲区溢出问题,那么在黑客的精心设计下,溢出的部分会“恰好”覆盖掉栈帧中
的返回地址,将其修改为一个特定的地址,这个特定的地址中保存有黑客留下的恶意代码,如图所
示:
这样当该进程运行起来后实际上是在执行黑客的恶意代码,这就是利用缓冲区溢出进行攻击的一个经
典案例。

操作指针所指对象而非指针本身
void delete_one(int** arr, int* size) {
free(arr[*size - 1]);
*size--;
}

arr 是一个指针数组,这段代码的本意是要删除掉数组中最后一个元素,同时将数组的大小减一。

但上述代码的问题在于*和--有相同的优先级,该代码实际上会将 size 指针减1而不是把 size 指向的值


减1。

如果你足够幸运的话那么上述程序运行到*size--时立刻 crash,这样你就有机会快速发现问题。但更
有可能的是上述代码会看上去一切正常的继续运行并返回一个错误的执行结果,这样的bug排查起来
会让你终生难忘,因此当不确定优先级时不要吝啬括号,加上它。

总结

内存是计算机系统中至关重要的一个组成部分,C/C++这类偏底层的语言在带来高性能的同事也带来
内存相关的无尽问题,而这类问题通常难以排查,不过知彼知己,当你理解了常见的内存相关问题后
将极大减少出现此类问题的概率。

希望这篇文章对大家理解内存与指针有所帮助。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
CPU是如何读写内存的?

看一下这个段代码:

int a = mem[2];

这是一段简单内存读取代码,可就是这段代码底层发生了什么呢?

如果你觉得这是一个非常简单的问题,那么你真应该好好读读本文,我敢保证这个问题绝没有你想象
的那么简单。

注意,一定要完本文,否则可能会得出错误的结论。

闲话少说,让我们来看看CPU在读写内存时底层究竟发生了什么。
谁来告诉CPU读写内存

我们第一个要搞清楚的问题是:谁来告诉CPU去读写内存?

答案很明显,是程序员,更具体的是编译器。

CPU只是按照指令按部就班的执行,机器指令从哪里来的呢?是编译器生成的,程序员通过高级语言
编写程序,编译器将其翻译为机器指令,机器指令来告诉CPU去读写内存。

在精简指令集架构下会有特定的机器指令,Load/Store指令来读写内存,以x86为代表的复杂指令集
架构下没有特定的访存指令。

精简指令集下,一条机器指令操作的数据必须来存放在寄存器中,不能直接操作内存数据,因此RISC
下,数据必须先从内存搬运到寄存器,这就是为什么RISC下会有特定的Load/Store访存指令,明白了
吧。
而x86下无此限制,一条机器指令操作的数据可以来自于寄存器也可以来自内存,因此这样一条机器
指令在执行过程中会首先从内存中读取数据。

关于复杂指令集以及精简指令集你可以参考这两篇文章《CPU进化论:复杂指令集》与《不懂精简指
令集还敢说自己是程序员?》

两种内存读写

现在我们知道了,是特定的机器指令告诉CPU要去访问内存。

不过,值得注意的是,不管是RISC下特定的Load/Store指令还是x86下包含在一条指令内部的访存操
作,这里读写的都是内存中的数据,除此之外还要意识到,CPU除了从内存中读写数据外,还要从内
存中读取下一条要执行的机器指令。

毕竟,我们的计算设备都遵从冯诺依曼架构:程序和数据一视同仁,都可以存放在内存中。
现在,我们清楚了CPU读写内存其实是由两个因素来驱动的:

1. 程序执行过程中需要读写来自内存中的数据
2. CPU需要访问内存读取下一条要执行的机器指令

然后CPU根据机器指令中包含的内存地址或者PC寄存器中下一条机器指令的地址访问内存。

这不就完了吗?有了内存地址,CPU利用硬件通路直接读内存就好了,你可能也是这样的想的。

真的是这样吗?别着急,我们接着往下看,这两节只是开胃菜,正餐才刚刚开始。

急性子吃货 VS 慢性子厨师

假设你是一个整天无所事事的吃货,整天无所事事,唯一的爱好就是找一家餐厅吃吃喝喝,由于你是
职业吃货,因此吃起来非常职业,1分钟就能吃完一道菜,但这里的厨师就没有那么职业了,炒一道
菜速度非常慢,大概需要1小时40分钟才能炒出一道菜,速度比你慢了100倍,如果你是这个吃货,
大概率会疯掉的。

而CPU恰好就是这样一个吃货,内存就是这样一个慢吞吞的厨师,而且随着时间的推移这两者的速度
差异正在越来越大:
在这种速度差异下,CPU执行一条涉及内存读写指令时需要等“很长一段时间“数据才能”缓缓的“从内
存读取到CPU中,在这种情况你还认为CPU应该直接读写内存吗?

无处不在的28定律

28定律我想就不用多介绍了吧,在《不懂精简指令集还敢说自己是程序员》这篇文章中也介绍过,
CPU执行指令符合28定律,大部分时间都在执行那一少部分指令,这一现象的发现奠定了精简指令集
设计的基础。

而程序操作的数据也符合类似的定律,只不过不叫28定律,而是叫principle of locality,程序局部性
原理。

如果我们访问内存中的一个数据A,那么很有可能接下来再次访问到,同时还很有可能访问与数据A相
邻的数据B,这分别叫做时间局部性和空间局部性。
如图所示,该程序占据的内存空间只有一少部分在程序执行过程经常用到。

有了这个发现重点就来了,既然只用到很少一部分,那么我们能不能把它们集中起来呢?就像这样:
集中起来然后呢?放到哪里呢?

当然是放到一种比内存速度更快的存储介质上,这种介质就是我们熟悉的SRAM,普通内存一般是
DRAM,这种读写速度更快的介质充当CPU和内存之间的Cache,这就是所谓的缓存。

四两拨千斤

我们把经常用到的数据放到cache中存储,CPU访问内存时首先查找cache,如果能找到,也就是命
中,那么就赚到了,直接返回即可,找不到再去查找内存并更新cache。

我们可以看到,有了cache,CPU不再直接与内存打交道了。
但cache的快速读写能力是有代价的,代价就是Money,造价不菲,因此我们不能把内存完全替换成
cache的SRAM,那样的计算机你我都是买不起的。

因此cache的容量不会很大,但由于程序局部性原理,因此很小的cache也能有很高的命中率,从而
带来性能的极大提升,有个词叫四两拨千斤,用到cache这里再合适不过。

天下没有免费的午餐

虽然小小的cache能带来性能的极大提升,但,这也是有代价的。

这个代价出现在写内存时。

当CPU需要写内存时该怎么办呢?

现在有了cache,CPU不再直接与内存打交道,因此CPU直接写cache,但此时就会有一个问题,那就
是cache中的值更新了,但内存中的值还是旧的,这就是所谓的不一致问题,inconsistent.

就像下图这样,cache中变量的值是4,但内存中的值是2。
同步缓存更新

常用 redis 的同学应该很熟悉这个问题,可是你知道吗?这个问题早就在你读这篇文章用的计算设备
其包含的CPU中已经遇到并已经解决了。

最简单的方法是这样的,当我们更新cache时一并把内存也更新了,这种方法被称为 write-
through,很形象吧。

可是如果当CPU写cache时,cache中没有相应的内存数据该怎么呢?这就有点麻烦了,首先我们需要
把该数据从内存加载到cache中,然后更新cache,再然后更新内存。
这种实现方法虽然简单,但有一个问题,那就是性能问题,在这种方案下写内存就不得不访问内存,
上文也提到过CPU和内存可是有很大的速度差异哦,因此这种方案性能比较差。

有办法解决吗?答案是肯定的。

异步更新缓存

这种方法性能差不是因为写内存慢,写内存确实是慢,更重要的原因是CPU在同步等待,因此很自然
的,这类问题的统一解法就是把同步改为异步。

关于同步和异步的话题,你可以参考这篇文章《从小白到高手,你需要理解同步和异步》。

异步的这种方法是这样的,当CPU写内存时,直接更新cache,然后,注意,更新完cache后CPU就可
以认为写内存的操作已经完成了,尽管此时内存中保存的还是旧数据。

当包含该数据的cache块被剔除时再更新到内存中,这样CPU更新cache与更新内存就解耦了,也就是
说,CPU更新cache后不再等待内存更新,这就是异步,这种方案也被称之为write-back,这种方案
相比write-through来说更复杂,但很显然,性能会更好。
现在你应该能看到,添加cache后会带来一系列问题,更不用说cache的替换算法,毕竟cache的容量
有限,当cache已满时,增加一项新的数据就要剔除一项旧的数据,那么该剔除谁就是一个非常关键
的问题,限于篇幅就不在这里详细讲述了,你可以参考《深入理解操作系统》第7章有关于该策略的
讲解。

多级cache

现代CPU为了增加CPU读写内存性能,已经在CPU和内存之间增加了多级cache,典型的有三级,
L1、L2和L3,CPU读内存时首先从L1 cache找起,能找到直接返回,否则就要在L2 cache中找,L2
cache中找不到就要到L3 cache中找,还找不到就不得不访问内存了。

因此我们可以看到,现代计算机系统CPU和内存之间其实是有一个cache的层级结构的。
越往上,存储介质速度越快,造价越高容量也越小;越往下,存储介质速度越慢,造价越低但容量也
越大。

现代操作系统巧妙的利用cache,以最小的代价获得了最大的性能。

但是,注意这里的但是,要想获得极致性能是有前提的,那就是程序员写的程序必须具有良好的局部
性,充分利用缓存。

高性能程序在充分利用缓存这一环节可谓绞尽脑汁煞费苦心,关于这一话题值得单独成篇,关注公众
号“码农的荒岛求生”,并回复“todo”,你可以看到之前所有挖坑的进展如何。

鉴于cache的重要性,现在增大cache已经成为提升CPU性能的重要因素,因此你去看当今的CPU布
局,其很大一部分面积都用在了cache上。
你以为这就完了吗?

哈哈,哪有这么容易的,否则也不会是终面题目了。

那么当CPU读写内存时除了面临上述问题外还需要处理哪些问题呢?

多核,多问题

当摩尔定律渐渐失效后鸡贼的人类换了另一种提高CPU性能的方法,既然单个CPU性能不好提升了,
我们还可以堆数量啊,这样,CPU进入多核时代,程序员开始进入苦逼时代。

拥有一堆核心的CPU其实是没什么用的,关键需要有配套的多线程程序才能真正发挥多核的威力,但
写过多线程程序的程序员都知道,能写出来不容易,能写出来并且能正确运行更不容易,关于多线程
与多线程编程的详细阐述请参见《深入理解操作系统》第5、6两章(关注公众号“码农的荒岛求生”并回
复“操作系统”)。

CPU开始拥有多个核心后不但苦逼了软件工程师,硬件工程师也不能幸免。

前文提到过,为提高CPU 访存性能,CPU和内存之间会有一个层cache,但当CPU有多个核心后新的
问题来了:
现在假设内存中有一变量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。
然后C2也许需要对X执行加法操作,假设需要+4,同样根据cache的工作原理,C2从cache中拿到X的
值+4后更新cache,此时cache中的值变为了6(2+4),再更新内存,此时C2 cache和内存中的X值
都变为了6。
看出问题在哪里了吗?

一个初始值为2的变量,在分别+2和+4后正确的结果应该是2+2+4 = 8,但从上图可以看出内存中X的
值却为6,问题出在哪了呢?

多核cache一致性

有的同学可能已经发现了,问题出在了内存中一个X变量在C1和C2的cache中有共计两个副本,当C1
更新cache时没有同步修改C2 cache中X的值。
解决方法是什么呢?

显然,如果一个cache中待更新的变量同样存在于其它核心的cache,那么你需要一并将其它cache也
更新好。

现在你应该看到,CPU更新变量时不再简单的只关心自己的cache和内存,你还需要知道这个变量是
不是同样存在于其它核心中的cac**he**,如果存在需要一并更新。

当然,这还只是简单的读,写就更加复杂了,实际上,现代CPU中有一套协议来专门维护缓存的一致
性,比较经典的包括MESI协议等。

为什么程序员需要关心这个问题呢?原因很简单,你最好写出对cache一致性协议友好的程序,因为
cache频繁维护一致性也是有性能代价的。

同样的,限于篇幅,这个话题不再详细阐述,该主题同样值得单独成篇,敬请期待。

够复杂了吧!

怎么样?到目前为止,是不是CPU读写内存没有看上去那么简单?

现代计算机中CPU和内存之间有多级cache,CPU读写内存时不但要维护cache和内存的一致性,同
样需要维护多核间cache的一致性。
你以为这就完了,NONO,最大的谜团其实是接下来要讲的。

你以为的不是你以为的

现代程序员写程序基本上不需要关心内存是不是足够这个问题,但这个问题在远古时代绝对是困扰程
序员的一大难题。

如果你去想一想,其实现代计算机内存也没有足够大的让我们随便申请的地步,但是你在写程序时是
不是基本上没有考虑过内存不足该怎么办?

为什么我们在内存资源依然处于匮乏的现代可以做到申请内存时却进入内存极大丰富的共产主义理想
社会了呢?

原来这背后的功臣是我们熟悉的操作系统。

操作系统对每个进程都维护一个假象,即,每个进程独占系统内存资源;同时给程序员一个承诺,让
程序员可以认为在写程序时有一大块连续的内存可以使用。
这当然是不可能不现实的,因此操作系统给进程的地址空间必然不是真的,但我们又不好将其称之
为“假的地址空间”,这会让人误以为计算机科学界里骗子横行,因此就换了一个好听的名字,虚拟内
存,一个“假的地址空间”更高级的叫法。

进程其实一直活在操作系统精心维护的幻觉当中,就像《盗梦空间》一样,关于虚拟内存的详尽阐述
请参见《深入理解操作系统》第七章(关注公众号“码农的荒岛求生”并回复“操作系统”)。

从这个角度看,其实最擅长包装的是计算机科学界,哦,对了,他们不但擅长包装还擅长抽象。

天真的CPU

CPU真的是很傻很天真的存在。

上一节讲的操作系统施加的障眼法把CPU也蒙在鼓里。

CPU执行机器指令时,指令指示CPU从内存地址A中取出数据,然后CPU执行机器指令时下发命
令:“给我从地址A中取出数据”,尽管真的能从地址A中取出数据,但这个地址A不是真的,不是真
的,不是真的。

因为这个地址A属于虚拟内存,也就是那个“假的地址空间”,现代CPU内部有一个叫做MMU的模块将
这假的地址A转换为真的地址B,将地址A转换为真实的地址B之后才是本文之前讲述的关于cache的那
一部分。
你以为这终于应该讲完了吧!

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

有这样一段代码:

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型车。
20世纪初,福特将流水线技术应用到汽车的批量生产,效率得到千倍提高,使得汽车这种奢侈品开始
能够为大众消费,深刻影响了现代社会的方方面面,注意上图中一辆车的价格。。。

100 年后又一个美国人携带他的时尚电动车再一次席卷全球,这就是特斯拉。

接下来我们仔细看一下流水线技术。

特斯拉与流水线

假设组装一辆特斯拉需要经过:组装车架、安装引擎、安装电池、检验四道工序,同时假设每个步骤
需要 20 分钟,因此如果所有工序都由一个组装站点来完成,那么组装一辆特斯拉需要80分钟。

但如果每个步骤都交给一个特定站点来组装的话就不一样了,此时生产一辆车的时间依然是80分钟,
但这只是第一辆车所需要的时间,此后工厂可以每20分钟就交付一辆特斯拉。
注意,流水线并没有减少组装一辆车的时间,只是增加了工厂的吞吐能力。

流水线技术的使用极大增加了工厂交付车辆的效率。

CPU 与超级工厂

其实 CPU 本身也是一座超级工厂。
只不过CPU这座工厂生产的不是特斯拉,而是机器指令。

工厂内部有流水线极大提高了生产效率,CPU 没有理由不拥有。

你可以想象一下,不管你现在看这篇文章用的是PC 还是智能手机,其内部的 CPU 都有一条复杂度不


亚于特斯拉超级工厂的流水生产线。

如果我们把CPU处理的一条机器指令当做一辆特斯拉的话,那么对于现代CPU这座超级工厂来说,一
秒钟的时间内可以交付数十亿量特斯拉,效率完爆任何当今制造界的工业流水线,CPU 才是一座名副
其实的超级工厂。

如果特斯拉超级工厂也如 CPU 一般高效的话,特斯拉可能比现在的自行车都要便宜,地球人民人手


一辆特斯拉不成问题,算上外星人也不成问题。

机器指令与流水线

实际上说 CPU 生产机器指令是不正确的,CPU 其实不是在生产机器指令而是在处理机器指令,生产


机器指令的是编译器,CPU需要处理机器指令以此来指挥整个计算机系统工作。

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

当 if 遇到流水线

实际上 CPU 内部的流水线和现实中的并不完全一样。

程序员在代码中编写的 if 语句一般会被编译器翻译成一条跳转指令,

if 语句其实起到一种分支的作用,如果条件成立则需要执行if内部的逻辑,否则不执行;因此跳转指
令会依赖自身的执行结果来决定到底要不要跳转,这会对流水线产生影响。

有的同学可能不明白,这能产生什么影响呢?

现在,让我们仔细观察一下特斯拉流水线,你会发现当前一辆车还没有完全制造完成时后一辆车就已
经进入到流水线了。
对于CPU来说道理是一样的,当一条跳转指令还没有完成时后面的指令就需要进入到流水线,因此问
题来了:

跳转指令需要依赖自身的执行结果来决定到底要不要跳转,那么在跳转指令没有执行完的情况下 CPU
怎么知道后面哪个分支的指令能进入到流水线呢?
CPU 能预测未来吗?

预测未来

对此 CPU 当然是不知道的。

那么该怎么办呢?

很简单,一个字,猜。

你没有看错,CPU 会猜一下 if 语句可能会走哪个分支,如果猜对了流水线照常继续,如果猜错了,对


不起,流水线上已经执行的后续指令全部作废,因此我们可以看到如果CPU猜错了会有性能损耗。

现代 CPU 将“猜”的这个过程称为分支预测。

当然,CPU 中的分支预测并不是简单的抛硬币式的随机瞎猜,而且有特定策略,比如可能会基于执行
跳转指令的历史去进行预测等等。

知道了分支预测就可以解释文章开头的问题了。
程序员的心思你别猜

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

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

CPU 在执行完跳转指令之前必须决定后续哪个分支的指令会进入到流水线,猜对了流水线照常进行,
猜错了有性能损耗。

那么如果一个数组是有序的:

而如果一个数组是无序的:

你觉得哪种更好猜一些?

如果你给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位:

(arr[i] - 256) >> 31

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

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

也就是说如果arr[i] - 256 大于0 的话那么差值会与上 0xffffffff,其结果就是保持不变,否则会与上


0,其结果就是sum会加上0,这样就不需要 if 判断了。

利用位运算,即使数组是无序的也不会有性能问题,代价就是代码可读性会降低很多,这里,我们再
一次看到天下没有免费的午餐。

总结

虽然 CPU 体积很小,只有指甲那么大,但 CPU 可能是人类有史以来建造过的最复杂的东西,在这里


实现了很多有趣的功能,程序员只有彻底理解 CPU 才能更好的利用这些功能编写性能优异的程序。
希望这篇对大家理解 CPU 有所帮助。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

CPU进化论:复杂指令集的诞生
英国生物学家达尔文于 1859 年出版了震动整个学术界和宗教界的《物种起源》,达尔文在这本书里
提出了生物进化论学说,认为生命在不断演变进化,物竞天择适者生存。
没有历史的计算机

生命是这样,实际上计算机技术也是如此。

计算机技术也和生命体一样在不断演变进化,在讨论一项技术时,如果不了解其演变过程而仅仅着眼
于当下就会让人疑惑,不巧的是这正是当前计算机教育的现状——没有历史。

因此,在这里我将尝试从历史的角度来讲讲 CPU,以及 CPU 的发展历程。

本篇主要关注CPU与复杂指令集CISC。

首先来看下什么是CPU。

什么是CPU?

我们都是程序员,那么从程序员的角度来看,CPU的工作其实是很简单的。
我们编写的所有程序,不管是简单的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,亦或是存放这篇文章的微信数据中心服务器,其本质都是下面这张简单的
图,这张图是一切计算设备的起源。
代码也是要占存储空间的

从冯诺依曼结构中我们就能知道为什么当今可执行程序中,比如Windows下的EXE或者Linux下的ELF
文件,即包含机器指令也包含数据,对于程序员来说我们可以简单的认为可执行程序中有两部分内
容:数据段以及代码段:

由此可见,程序员写的代码是要占据存储空间的,要知道在1970s年代,内存大小仅仅数KB到数十
KB,这是当今程序员不可想象的,因为现在(2021年)的智能手机内存都已经数GB。如图所示是1974
年发布的Intel 1103内存芯片:
大小只有 1KB 的英特尔1103存储芯片的于1974年发布,这标志着计算机工业界开始进入动态随机存
储DRAM时代,DRAM也就是我们熟知的内存。

大家可以思考一下,几KB的内存,可谓寸土寸金,这么小的内存要想装入更多的程序就必须仔细的设
计机器指令以节省程序占据的空间,这就要求:

1. 一条机器指令尽可能完成更多的任务,这很容易理解,就像在《你管这破玩意叫编程语言》这篇
中的例子一样,你更希望有一条“给我端杯水”的指令,而不是自己去写“迈出左脚;停住;迈出右
脚;直到饮水机;伸出右手;拿起水杯;接水。。。”等等这样的汇编代码

2. 机器指令长度不固定,也就是变长机器指令,简单的指令占据更少的空间

3. 机器指令高度编码(encoded),提高代码密度,节省空间

复杂指令集诞生的必然

基于对程序员方便编写汇编语言以及节省代码存储空间的需要,直接促成了复杂指令集的设计,因此
我们可以看到复杂指令集是这一时期必然的选择,该指令集就这样诞生了并开始成为主流。

就这样经过一段时间后,人们发现了新的问题,由于单条指令比较复杂,设计解码机器指令的硬件
(CPU的一部分)成了一件非常麻烦的事情,该怎样解决这一问题呢?
CPU真的在直接执行机器指令吗?

作为程序员,我们知道,对于重复使用的代码其实是没有必要一遍遍编写的,你可以把这些代码封装
到函数中,这样每次使用时只需要调用这个函数就好了,这个思路可以解决上述问题。

对于指令集中的每一条机器指令都有一小段对应的程序,这些程序存储在CPU中,这些程序都是由更
简单的指令组成,这些指令就是所谓的微代码,Microcode。

就这样CPU的指令集可以添加更多的指令,代价仅仅是再多一些简单的微代码而已,是不是很天才的
设计。

在这里也可以看到,一般我们认为CPU直接执行机器指令,严格来说这是不正确的,对于含有微代码
设计的CPU来说,CPU直接执行的并不是机器指令,而是微代码,微代码是CPU以及机器指令的中间
层,机器指令相对于微代码来说是“更高级的语言”,机器指令对程序员来说可见,但微代码对程序员
来说不可见,程序员无法直接使用微代码来控制CPU。
而在这一时期,这些微代码普遍存放在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,这是第一个趋势。

此外在这一时期随着编译技术的进步,编译器越来越成熟,渐渐的程序员们开始依靠编译器来生成汇
编指令而不再自己手工编写。

这两个趋势的出现让人们有了更多思考。

化繁为简

19世纪末20世纪初意大利经济学家Pareto发现,在任何一组东西中,最重要的只占其中一小部分,
约20%,其余80%尽管是多数,却是次要的,这就是著名的二八定律,机器指令的执行频率也有类似
的规律。

大概80%的时间CPU都在执行那20%的机器指令,同时CISC中一部分比较复杂的指令并不怎么被经常
用到,而且那些设计编译器的程序员也更倾向于组合一些简单的指令来完成特定任务。

与此同时我们在上文提到过的一位计算机科学家,被派去改善微代码设计,但后来这老哥发现有问题
的是微代码本身,因此开始转过头来去思考微代码这种设计的问题在哪里。
他的早期工作提出一个关键点,复杂指令集中那些被认为可以提高性能的指令其实在内部被微代码拖
后腿了,如果移除掉微代码,程序反而可以运行的更快,并且可以节省构造CPU消耗的晶体管数量。

由于微代码的设计思想是将复杂机器指令在CPU内部转为相对简单的机器指令,这一过程对编译器不
可见,也就是说你没有办法通过编译器去影响CPU内部的微代码运行行为,因此如果微代码出现bug
那么编译器是无能为力的,你没有办法通过编译器生成其它机器指令来修复问题而只能去修改微代码
本身。

此外他还发现,有时一些复杂的机器指令执行起来要比等价的多个简单指令要。

这一切都在提示:为什么不直接用一些简单到指令来替换掉那些复杂的指令呢?

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

1,指令本身的复杂度

精简指令集的思想其实很简单,干嘛要去死磕复杂的指令,去掉复杂指令代之以一些简单的指令。

有了简单指令CPU内部的微代码也不需要了,没有了微代码这层中间抽象,编译器生成的机器指令对
CPU的控制力大大增强,有什么问题让写编译器的那帮家伙修复就好了,显然调试编译器这种软件要
比调试CPU这种硬件要简单很多。

注意,精简指令集思想不是说指令集中指令的数量变少,而是说一条指令背后代表的动作更简单了。

举个简单的例子,复杂指令集中的一条指令背后代表的含义是“吃饭”的全部过程,而精简指令集中的
一条指令仅仅表示“咀嚼一下”的其中一个小步骤。

博主在《你管这破玩意叫编程语言》一文中举得例子其实更形象一些,复杂指令集下一条指令可以表
示“给我端杯水”,而在精简指令集下你需要这样表示:

2,编译器

精简指令集的另一个特点就是编译器对CPU的控制力更强。

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

3,load/store architecture

在复杂指令集下,一条机器指令可能涉及到从内存中取出数据、执行一些操作比如加和、然后再把执
行结果写回到内存中,注意这是在一条机器指令下完成的。

但在精简指令集下,这绝对是大写的禁忌,精简指令集下的指令只能操作寄存器中的数据,不可以直
接操作内存中的数据,也就是说这些指令比如加法指令不会去访问内存。

毕竟数据还是存放在内存中的,那么谁来读写内存呢?

原来在精简指令集下有专用的 load 和 store 两条机器指令来负责内存的读写,其它指令只能操作


CPU内部的寄存器,这是和复杂指令集一个很鲜明的区别。

你可能会好奇,用两条专用的指令来读写内存有什么好处吗?别着急,在本文后半部分我们还会回到
load/store指令。

以上就是三点就是精简指令集的设计哲学。

接下来我们用一个例子来看下RISC和CISC的区别。

两数相乘

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

我们分别来看下在CISC和在RISC下的会怎样实现。

1,CISC

复杂指令集的一个主要目的就是让尽可能少的机器指令来完成尽可能多的任务,在这种思想下CPU需
要在从内存中拿到一条机器指令后“自己去完成一系列的操作”,这部分操作对外不可见。

在这种方法下,CISC中可能会存在一条叫做MULT的机器指令,MULT是乘法multiplication的简写。

当CPU执行MULT这条机器指令时需要:

1. 从内存中加载地址A上的数,存放在寄存器中
2. 从内存中夹杂地址B上的数,存放在寄存器中
3. ALU根据寄存器中的值进行乘积
4. 将乘积写回内存

以上这几部统统都可以用这样一条指令来完成:

MULT A B

MULT就是所谓的复杂指令了,从这里我们也可以看出,复杂指令并不是说“MULT A B”这一行指令本
身有多复杂,而是其背后所代表的任务复杂。

这条机器指令直接从内存中加载数据,程序员(写汇编语言或者写编译器的程序员)根本就不要自己显
示的从内存中加载数据,实际上这条机器指令已经非常类似高级语言了,我们假设内存地址A中的值
为变量a,地址B中的值为变量b,那么这条机器指令基本等价于高级语言中这样一句:
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指令把寄
存器中的数据写回内存,因此如果一个程序员想完成上述任务就需要写这些汇编指令:

LOAD RA, A
LOAD RB, B
PROD RA, RB
STORE A, RA

现在你应该看到了,同样一项任务,在CISC下只需要一条机器指令,而在RISC下需要四条机器指令,
显然RISC下的程序本身所占据的空间要比CISC大,而且这对直接用汇编语言来写程序的程序员来说是
很不友好的,因为更繁琐嘛!再来看看这样图感受一下:
但RISC设计的初衷也不是让程序员直接使用汇编语言来写程序,而是把这项任务交给编译器,让编译
器来生成机器指令。

标准从来都是一个好东西

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

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

这些指令都非常简单,CPU内部不需要复杂的硬件逻辑来进行解码,因此更节省晶体管,这些节省下
来的晶体管可用于其它功能上。

最关键的是,注意,由于每一条指令都很简单,执行的时间都差不多,因此这使得一种能高效处理机
器指令的方法成为可能,这项技术是什么呢?

我们在《CPU遇上特斯拉,程序员的心思你别猜》这篇文章中提到过,这就是有名的流水线技术。

指令流水线

流水线技术是初期精简指令集的杀手锏。
在这里我们还是以生产汽车(新能源)为例来介绍一下。

假设组装一辆汽车需要经过四个步骤:组装车架、安装引擎、安装电池、检验。

假设这每个步骤需要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。

CISC迎来至暗时刻。
接下来CISC该如何绝地反击,要知道Inter以及AMD (x86处理器两大知名生产商) 的硬件工程师们绝
非等闲之辈。

预知后事如何,请听下回分解。

总结

CISC中微代码设计的复杂性让人们重新思考CPU到底该如何设计,基于对执行指令的重新审视RISC设
计哲学应运而生。

RISC中每条指令更加简单,执行时间比较标准,因此可以很高效的利用流水线技术,这一切都让采用
RISC架构的CPU获得了很好性能。

面对RISC,CISC阵营也开始全面反思应如何应对挑战。后续文章将继续这一话题。

希望本文对大家理解精简指令集有所帮助

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
CPU核数与线程数有什么关系?

作为一名美食资浅爱好者,尽管小风哥我厨艺拙计,但依然阻挡不了我对烹饪的热爱。

那小风哥我通常是怎么做菜的呢?

大厨与菜谱

你没猜错,做菜之前先去下一份菜谱,照着菜谱一步步来:起锅烧油、葱姜蒜末下锅爆香、倒入切好
的食材、大火翻炒、加入适量酱油、加入适量盐、继续翻炒、出锅喽!
这样一道色香味俱佳的小炒大功告成,装盘端出来拿起筷子一尝,难吃死了。

火候有点过,酱油加的有点少,盐加多了,中餐里的“火候”以及“适量”是最为神秘的存在,可以意会
不可言传。因此相对肯德基麦当劳之类的标准工业品,中餐更像是艺术。每个人炒出来的菜味道都不
一样,显然嘛,每个人对火候以及适量的理解是不一样的。

对不起,跑题了

虽然小风哥我厨艺不怎么样,但输厨艺不能输气场,有时我会几样一起来,这边炒着A菜,那边炒着B
菜。

也就是说,我可以同时按照两份菜谱去做饭,如果小风哥足够快,那么我可以同时炒 N 样菜。

炒菜与线程

实际上CPU和厨师一样,都是按照菜谱(机器指令)去执行某个动作,从操作系统的角度讲当CPU切换
回用户态后,CPU执行的一段指令就是线程,或者说属于某个线程。
这和炒菜一样,我可以按照菜谱抄鱼香肉丝,那么炒菜时这就是鱼香肉丝线程;我可以按照菜谱抄宫
保鸡丁,那么炒菜时这就是宫保鸡丁线程。

厨师个数就好比CPU核心数,炒菜的样数就好比线程数,这时我问你,你觉得厨师的个数和可以同时
抄几样菜有关系吗?

答案当然是没有。

CPU的核心数和线程个数没有什么必然的关系。

单个核心上可以跑任意多个线程,只要你的内存够就行;计算机系统内也可以有任意多核数,只要你
有钱就行。

看到这个答案你是不是觉得有点疑惑、有点疑问、有点不明所以,这好像和其它人说的不一样啊!

别着急,我们慢慢讲。

傻傻的CPU

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

有多少线程是谁需要来关心的呢?是操作系统。

线程是操作系统的把戏。

操作系统与多任务

很久很久以前,计算机一次只能执行一个任务,你不能像现在这样在计算机上一边看电影一边在下小
电影,哦,不对,一边写代码,一边下载资料。

要么你先写代码,写完代码后再去下资料,要么你先下资料然后再写代码,总之,这两个任务不能同
时进行。

这显然很不方便,就这样,多任务——Multi-Tasking,诞生了。
你CPU不是只知道执行机器指令吗?很好,那我操作系统就通过修改你的PC寄存器,让你CPU执行A
任务的机器指令一段时间,然后下一段时间再去执行B任务的机器指令,再然后下一个时间段去执行C
任务的机器指令,由于每一段时间非常少,通常在毫秒级别,那么在人类看来A、B、C三个任务在“同
时”运行。

这就是多任务的本质。

进程与线程

CPU不知道执行的某一段机器指令属于A任务还是B任务,只有操作系统知道,同时操作系统还能知道
任务A和B任务是否属于同一个地址空间。

如果属于同一个地址空间,那么任务A和任务B就是我们熟悉的“多线程”;如果不属于同一个地址空
间,那么任务A和任务B就是我们熟悉的“多进程”,现在你应该明白这两个概念了吧。
这里出现了一个有点拗口的名词,地址空间,Address Space,关于地址空间的概念以及进程线程这
一部分更加详细的讲解,请参考小风哥的《深入理解操作系统》第7章,关注公众号"码农的荒岛求
生"并回复”操作系统“即可。

值得注意的是,计算机系统还在单核时代就已经有多线程的概念了,我们之前说过,即使是单核也可
以执行多个线程,那么有的同学可能会有疑问,在单核的系统中开启多个线程有什么意义吗?

单核与多线程

假设现在有两个任务,任务A和任务B,每个任务需要的计算时间都是5分钟,那么无论是任务A和任
务B串行执行还是放到两个线程中并行执行,在单核环境下执行完这两个任务总需要10分钟,因此有
的同学觉得单核下多线程没什么用。
实际上,线程这个概念为程序员提供了一种编程抽象,我们可以把一项任务进行划分,然后把每一个
子任务放到一个个线程中去运行。

假如你的程序带有图形界面,某个UI元素背后需要的大量运算,这时为了防止执行该运算时UI产生卡
顿,那么可以把这个运算任务放到一个单独的线程中去。

因此如果你的目的是防止当前线程因执行某项操作而不得不等待,那么在这样的应用场景下,你根本
就不需要关心系统内是单核还是多核以及有多少个核。

阻塞式I/O

这也是使用线程的经典场景。

如果没有线程,那么执行阻塞式I/O时整个进程会被操作系统暂停,但如果你开启两个线程,其中一
个线程被阻塞时另一个线程依然可以继续向前推进。

这样的话你就不需要去使用反人类的异步IO了。

当然,这一切的前提是你的场景不涉及高性能以及高并发,如果涉及的话那这就是另一个话题了,如
果你想了解这一话题,关注公众号“码农的荒岛求生”并回复“高并发”即可。

在这种简单的场景下,你创建线程时也不需要关心系统中是单核还是多核。

多核时代
实际上,线程这个概念是从2003年左右才开始流行的,为什么?因为这一时期,多核时代到来了。

之所以产生多核,是因为单核的性能提升越来越困难了。

尽管采用多进程也可以充分利用多核,但毕竟多进程编程是很繁琐的,这涉及复杂的进程间通信机
制、进程间切换的较高性能损耗、进程间内存相互隔离带来的对内存消耗等。

线程这个概念很好的解决了上述问题,开始成为多核时代的主角,要想充分利用多核资源,线程是程
序员的首选工具。

真正的并行

有了多核后,运行在两个线程中的任务A和任务B实现了真正的并行。

此前这样一句话广为引用,这句话是这么说的:
threads are for people who can't program state machines

“线程是为那些不懂状态机的人准备的”,这句话在单核时代有它的道理,因为在单核时代,所有的任
务都不是在同时向前推进,而是“交错”前进,A前进一点,然后B前进一点,线程并不是实现这种“伪并
行”唯一的方法,状态机也可以。

但在多核时代,这句话就不再适用了,对于大多数程序员来说多进程多线程几乎是充分利用多核资源
的唯一方法。

如果你的场景是想充分利用多核,那么这时你的确需要知道系统内有多少核数,一般来说你创建的线
程数需要与核数保持线性关系。

也就是说,如果你的核数翻倍,那么创建的线程数也要翻倍。

需要多少线程?

值得注意的是,线程不是越多越好。

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

总结

线程数和CPU核心数可以没有任何关联,如果在使用线程时仅仅针对上述提到的几个简单场景,那么
你根本不需要关心CPU是单核还是多核。

但当你需要利用线程充分发挥多核威力时,通常情况下你创建的线程数与核数要保持一种线性关系,
最佳系数通常需要测试才能得到。

我是小风哥,希望这篇文章对大家理解多核以及多线程有所帮助。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
你管这破玩意叫mmap?

大家好,我是小风哥!

废话少说,这篇文章带你讲解下稍显神秘的mmap到底是怎么一回事。

简单的与麻烦的

用代码中读写内存对程序员来说是非常方便非常自然的,

但代码中读写磁盘对程序员来说就不那么方便不那么自然了。

回想一下,你在代码中读写内存有多简单:

定义一个数组:

int a[100];
a[0] = 2;

看到了吧,这时你就在读内存,甚至你可能在写这段代码时下意识里都没有去想读内存这件事。
再想想你是怎样读磁盘文件的?

char buf[1024];

int fd = open("/filepath/abc.txt");
read(fd, buf, 1024);
// 操作buf等等

看到了吧,读写磁盘文件其实是一件很麻烦的事情,首先你需要open一个文件,意思是告诉操作系
统“Hey,操作系统,我要开始读abc.txt这个文件了,把这个文件的所有信息准备好,然后给我一个代
号”,这个代号就是所谓的文件描述符,只要知道这代号,你就能从操作系统中获取关于这个代号所
代表文件的一切信息。

那么为什么要用这样一个代号呢?

原来在Unix/Linux世界中“Everything is a file!”:
进程间通信用的管道,pipes
网络通信用的socket
高性能I/O多路复用,epoll
磁盘上保存的用户文件
...

这些资源在内核中的表示千奇百怪,但对于用户态程序来说程序员可以使用统一的方式来指代它们,
这种统一的方式可以使用最简单的数字来表示,这就是文件描述符。

现在你应该看到了,操作磁盘文件要比操作内存复杂很多,根本原因就在于寻址方式不同。

对内存来说我们可以直接按照字节粒度去寻址,但对磁盘上保存的文件来说则不是这样的,磁盘上保
存的文件是按照块(block)的粒度来寻址的,因此你必须先把磁盘中的文件读取到内存中,然后再内存
中再按照字节粒度来操作文件内容。

既然直接操作内存很简单,那么我们有没有办法像读写内存那样去直接读写磁盘文件呢?

答案是肯定的。

要开脑洞了

对于像我们这样在用户态编程的程序员来说,内存在我们眼里就是一段连续的空间。啊哈,巧了,磁
盘上保存的文件在程序员眼里也是存放一段连续的空间中(有的同学可能会说文件其实是在磁盘上离
散存放的,请注意,我们在这里只从文件使用者的角度来讲)。
那么这两段空间有没有办法关联起来呢?

答案是肯定的?

怎么关联呢?

答案就是。。。。。。你猜对了吗?答案是通过虚拟内存。

虚拟内存我们已经讲解过很多次了,虚拟内存就是假的地址空间,是进程看到的幻象,其目的是让每
个进程都认为自己独占内存,那么既然是假的地址空间,显然就必须有什么东西能把这些假的地址转
换为真的,这就是CPU内部MMU的作用,当CPU执行内存读写指令时,MMU会把这个假的地址转换
为真实的物理内存地址。这一段中出现了很多名词,很多结论,如果你对这些依然不熟悉的话那么关
于虚拟内存完整的详细的讲解请参考博主的深入理解操作系统,关注公众号码农的荒岛求生并回复操
作系统即可。

既然进程看到地址空间是假的那么一切都好办了。

既然是假的,那么就有做手脚的操作空间,怎么做手脚呢?
从普通程序员眼里看文件不是保存在一段连续的磁盘空间上吗?我们可以直接把这段空间映射到进程
的内存中,就像这样:

假设文件长度是100字节,我们把该文件映射到了进程的内存中,地址是从600 ~ 800,那么当你直接
读写600 ~ 800这段内存时,实际上就是在直接操作磁盘文件。

这一切是怎么做到呢?

魔术师操作系统

原来这一切背后的功劳是操作系统。

当我们首次读取600~800这段地址空间时,操作系统会检测的这一操作,因为此时这段内存中什么内
容都还没有,此时操作系统自己读取磁盘文件填充到这段内存空间中,此后程序就可以像读内存一样
直接读取磁盘内容了。

写操作也很简单,用户程序依然可以直接修改这块内存,此后操作系统会在背后将修改内容写回磁
盘。

现在你应该看到了,其实采用mmap这种方法磁盘依然还是按照块的粒度来寻址的,只不过在操作系
统的一番骚操作下对于用户态的程序来说“看起来”我们能像读写内存那样直接读写磁盘文件了,从按
块粒度寻址到按照字节粒度寻址,这中间的差异就是操作系统来填补的。

我想你现在应该大体明白mmap是什么意思了。
接下来你肯定要问的问题就是,mmap有什么好处呢?我为什么要使用mmap?

即使使用read/write用起来可能不像直接读写内存那样直观,但是我已经习惯了呀,我有什么理由一
定要用mmap吗?

别着急,我们一件件说。

内存copy与系统调用

我们常用的标准IO,也就是read/write其底层是涉及到系统调用的,同时当使用read/write读写文件
内容时,需要将数据从内核态copy到用户态,修改完毕后再从用户态copy到内核态,显然,这些都
是有开销的。

而mmap则无此问题,基于mmap读写磁盘文件不会招致系统调用以及额外的内存copy开销,但
mmap也不是完美的,mmap也有自己的缺点。
其中一方面在于为了创建并维持地址空间与文件的映射关系,内核中需要有特定的数据结构来实现这
一映射,这当然是有性能开销的,除此之外另一点就是缺页问题,page fault。

用mmap将文件映射到进程地址空间后,当我们引用的一段其对应的文件内容还没有真正加载到内存
后就会产生中断,这个中断就是缺页,page fault,操作系统检测到这一信号后把相应的文件内容加
载到内存。

注意,缺页中断也是有开销的,而且不同的内核由于内部的实现机制不同,其系统调用、数据copy以
及缺页处理的开销也不同,因此就性能上来说我们不能肯定的说mmap就比标准IO好。这要看标准IO
中的系统调用、内存调用的开销与mmap方法中的缺页中断处理的开销哪个更小,开销小的一方将展
现出更优异的性能。

还是那句话,谈到性能,单纯的理论分析就不是那么好用了,你需要基于真实的场景基于特定的操作
系统以及硬件去测试才能有结论。
大文件处理

到目前为止我想大家对mmap最直观的理解就是可以像直接读写内存那样来操作磁盘文件,这是其中
一个优点。

另一个优点在于mmap其实是和操作系统中的虚拟内存密切相关的,这就为mmap带来了一个很有趣
的优势。

这个优势在于处理大文件场景,这里的大文件指的是文件的大小超过你的物理内存,在这种场景下如
果你使用传统的read/write,那么你必须一块一块的把文件搬到内存,处理完文件的一小部分再处理
下一部分。

这种需要在内存中开辟一块空间——也就是我们常说的buffer,的方案听上去就麻烦有没有,而且还
需要操作系统把数据从内核态copy到用户态的buffer中。

但如果用mmap情况就不一样了,只要你的进程地址空间足够大,可以直接把这个大文件映射到你的
进程地址空间中,即使该文件大小超过物理内存也可以,这就是虚拟内存的巧妙之处了,当物理内存
的空闲空间所剩无几时虚拟内存会把你进程地址空间中不常用的部分扔出去,这样你就可以继续在有
限的物理内存中处理超大文件了,这个过程对程序员是透明的,程序员根本就意识不要,虚拟内存都
给你处理好了。关于虚拟内存的透彻讲解请参考博主的深入理解操作系统,关注公众号码农的荒岛求
生并回复操作系统即可。

注意,mmap与虚拟内存的结合在处理大文件时可以简化代码设计,但在性能上是否优于传统的
read/write方法就不一定了,还是那句话关于mmap与传统IO在涉及到性能时你需要基于真实的应用
场景测试。
使用mmap处理大文件要注意一点,如果你的系统是32位的话,进程的地址空间就只有4G,这其中
还有一部分预留给操作系统,因此在32位系统下可能不足以在你的进程地址空间中找到一块连续的空
间来映射该文件,在64位系统下则无需担心地址空间不足的问题,这一点要注意。

节省内存

这可能是mmap最大的优势,以及最好的应用场景了。

假设有一个文件,很多进程的运行都依赖于此文件,而且还是有一个特定,那就是这些进程是以只读
(read-only)的方式依赖于此文件。

你一定在想,这么神奇?很多进程以只读的方式依赖此文件?有这样的文件吗?

答案是肯定的,这就是动态链接库。

要想弄清楚动态链接库,我们就不得不从静态库说起。

假设有三个程序A、B、C依赖一个静态库,那么链接器在生成可执行程序A、B、C时会把该静态库
copy到A、B、C中,就像这样:
假设你本身要写的代码只有2MB大小,但却依赖了一个100MB的静态库,那么最终生成的可执行程
序就是102MB,尽管你本身的代码只有2MB。

而且从图中我们可以看出,可执行程序A、B、C中都有一部分静态库的副本,这里面的内容是完全一
样的,那么很显然,这些可执行程序放在磁盘上会浪费磁盘空间,加载到内存中运行时会浪费内存空
间。

那么该怎么解决这个问题呢?

很简单,可执行程序A、B、C中为什么都要各自保存一份完全一样的数据呢?其实我们只需要在可执
行程序A、B、C中保存一小点信息,这点信息里记录了依赖了哪个库,那么当可执行程序运行起来后
再吧相应的库加载到内存中:
依然假设你本身要写的代码只有2MB大小,此时依赖了一个100MB的动态链接库,那么最终生成的
可执行程序就是2MB,尽管你依赖了一个100MB的库。

而且从图中可以看出,此时可执行程序ABC中已经没有冗余信息了,这不但节省磁盘空间,而且节省
内存空间,让有限的内存可以同时运行更多的进程,是不是很酷。

现在我们已经知道了动态库的妙用,但我们并没有说明动态库是怎么节省内存的,接下来mmap就该
登场了。

你不是很多进程都依赖于同一个库嘛,那么我就用mmap把该库直接映射到各个进程的地址空间中,
尽管每个进程都认为自己地址空间中加载了该库,但实际上该库在内存中只有一份。
mmap就这样很神奇和动态链接库联动起来了。

总结

mmap在博主眼里是一种很独特的机制,这种机制最大的诱惑在于可以像读写内存样方便的操作磁盘
文件,这简直就像魔法一样,因此在一些场景下可以简化代码设计。

但谈到mmap的与标准IO(read/write)的性能情况就比较复杂了,标准IO设计到系统调用以及用户态
内核态的copy问题,而mmap则涉及到维持内存与磁盘文件的映射关系以及缺页处理的开销,单纯的
从理论分析这二者半斤八两,如果你的应用场景对性能要求较高,那么你需要基于真实场景进行测
试。

我是小风哥,希望这篇文章对大家理解mmap有所帮助。

关注作者
也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

彻底理解零拷贝
计算机处理的任务大体可以分为两类:CPU密集型与IO密集型。

所谓CPU密集型就好比程序员,天天坐在办公室里闷头写代码不怎么需要与外界交互(CPU计算);而
IO密集型更像是销售,几乎不怎么呆在办公室里,需要不断与外界沟通交流(输入输出)。

对于程序员来说哪类更容易应对呢?显然是CPU密集型,原因也很简单,CPU密集型任务通常大部分
时间都在用户态(呆在办公室里),很少涉及底层,像操作系统以及硬件等,因此程序的运行行为更容
易被程序掌控。
而与CPU密集型相对于的,IO密集型任务花在用户态的时间相对更少,这类任务更多的需要操作系统
的协助(与其它人交互),也就是花在内核态的时间更多,由于IO密集型任务涉及两部分软件:用户
态的应用程序与内核态的操作系统,因此程序的运行行为对程序员来说控制力有限。

注意,用户态受程序员控制,内核态受操作系统控制,关于用户态内核态的更详尽讲解参见博主的深
入理解操作系统一书,关注公众号码农的荒岛求生并操作系统即可。

然而不巧的是,当前流行的互联网技术其实也就是网络应用,更多的属于IO密集型,涉及:软件部分
的操作系统与数据库,以及硬件部分的磁盘与网络,这些软硬件显然是在内核态,用户态部分只是冰
山一角。当遇到IO后,多线程的魔法就消失了,因为相对于CPU速度来说,瓶颈往往出现在IO设备
上,这时候任意你有再多核再多线程都没有用了。

传统的IO标准接口都是基于数据拷贝的,这篇文章我们主要关注该怎样从数据拷贝的角度来优化IO操
作。

为什么IO接口要基于数据拷贝?

为了让广大码农们更好的沉迷于自己的一亩三分地,防止ta们分心去关心计算机中的硬件资源分配问
题,操作系统诞生了。

操作系统本质上就是一个管家,目的就是更加公平合理的给各个进程分配硬件资源,在操作系统出现
之前,程序员需要直面各类硬件,就像这样:

在这一时期程序员真可谓掌控全局,掌控全局带来的后果就是你需要掌控所有细节,这显然不利于生
产力的释放。
操作系统应用而生。

现在计算机系统就变成这样了:

现在应用程序不需要和硬件直接交互了,仅从IO的角度上看,操作系统变成了一个类似路由器的角
色,把应用程序递交过来的数据分发到具体的硬件上去,或者从硬件接收数据并分发给相应的进程。

数据传递是通过什么呢?就是我们常说的buffer,所谓buffer就是一块可用的内存空间,用来暂存数
据。
操作系统这一中间商导致的问题就是:你需要首先把东西交给操作系统,操作系统再转手交给硬件,
这就必然涉及到数据拷贝。

这就是为什么传统的IO操作必然需要进行数据拷贝的原因所在。

有的同学可能不以为意,不就是数据拷贝吗?应该很快吧!

要知道数据拷贝不只涉及CPU一个模块,还包括内存、总线,在CPU看来,内存是一个非常低速的设
备,让CPU去完成数据拷贝这样的任务就是在浪费人才,幸好现代计算机系统中普遍采用DMA技术,
这类技术可以在没有CPU的参与下实现软件和硬件之间的数据拷贝,从而减轻CPU负担,然而用户态
和内核态这类软件和软件之间的数据拷贝则无此机制,在这种情况下数据拷贝依然需要CPU的参与。

现状就是这样的,接下来我们用一个实例来让大家对该问题有一个更直观的认知。

网络服务器

有些网络服务器其实是非常简单的,浏览器打开一个网页其实是需要很多数据的,包括看到的图片、
html文件、css文件、js文件等等,这些文件的内容可能是不怎么改变的,那么当浏览器请求这类文件
时服务器端的工作其实是非常简单:服务器只需要从磁盘中抓出该文件然后丢给网络发送出去就好
了。
代码基本上类似这样:

read(fileDesc, buf, len);


write(socket, buf, len);

这两段代码非常简单,第一行代码从文件中读取数据存放在buf中,然后将buf中的数据通过网络发送
出去。

注意观察buf,服务器全程没有对buf中的数据进行任何修改,buf里数据在用户态逛了一圈后挥一挥
衣袖没有带走半点云彩就回到了内核态。

这两行看似简单的代码实际上在底层发生了什么呢?

答案是这样的:
在程序看来简单的两行代码在底层是比较复杂的,看到这张图你应该真心感激操作系统,操作系统就
像一个无比称职的管家,替你把所有脏活累活都承担下来,好让你悠闲的在用户态指点江山。

这简单的两行代码涉及:四次数据拷贝以及四次上下文切换:
1. read函数会涉及一次用户态到内核态的切换,操作系统会向磁盘发起一次IO请求,当数据准备好
后通过DMA技术把数据拷贝到内核的buffer中,注意本次数据拷贝无需CPU参与。
2. 此后操作系统开始把这块数据从内核拷贝到用户态的buffer中,此时read()函数返回,并从内核
态切换回用户态,到这时read(fileDesc, buf, len);这行代码就返回了,buf中装好了新鲜出炉的数
据。
3. 记下来send函数再次导致用户态与内核态的切换,此时数据需要从用户态buf拷贝到网络协议子
系统的buf中,具体点该buf属于在代码中使用的这个socket。
4. 此后send函数返回,再次由内核态返回到用户态;此时在程序员看来数据已经成功发出去了,但
实际数据依然停留在内核中,此后第四次数据copy开始,利用DMA技术把数据从socket buf拷贝
给网卡,然后真正的发送出去。

这就是看似简单的这两行代码在底层的完整过程。

你觉得这个过程有什么问题吗?

发现问题

有的同学肯定已经注意到了,既然在用户态没有对数据进行任何修改,那为什么要这么麻烦的让数据
在用户态来个一日游呢?直接在内核态从磁盘给到网卡不就可以了吗?

恭喜你,答对了!
这种优化思路就是所谓的零拷贝技术,Zero Copy。

总体上来看,数据拷贝会有以下三种情况:

1. 用户态不需要真正的去访问数据,就像上面这个示例,用户态根本不需要知道buf里面装的是什
么。在这种情况下无需把数据从内核态拷贝到用户态然后再把数据从用户态拷贝会内核态。

数据无需用户态感知,数据拷贝完全发生在内核态。

2. 内核态不要真正的去访问数据,用户态程序可以绕过内核直接和硬件交互,这样就避免了内核的
参与,从而减少数据拷贝的可能。

内核无需感知数据。
3. 如果内核态和用户态不得不进行数据交互,则优化用户态与内核态数据的交互方式,有的同学可
能会问,除了数据拷贝难道还有其它交互方式吗?答案是肯定的,接下来你会看到。

知道了解决问题的思路,我们来看下为了实现零拷贝,计算机系统中都有哪些巧妙的设计。

mmap

是的,就是mmap,在《mmap可以让程序员实现哪些骚操作》一文中我们对其进行了详细讲解,你
能想到mmap还可以实现零拷贝吗?

对于本文提到的网络服务器我们可以这样修改代码:

buf = mmap(file, len);


write(socket, buf, len);

你可能会想仅仅将read替换为mmap会有什么优化吗?

如果你真的理解了mmap就会知道,mmap仅仅将文件内容映射到了进程地址空间中,并没有真正的
拷贝到进程地址空间,这节省了一次从内核态到用户态的数据拷贝。

同样的,当调用write时数据直接从内核buf拷贝给了socket buf,而不是像read/write方法中把用户
态数据拷贝给socket buf。
我们可以看到,利用mmap我们节省了一次数据拷贝,上下文切换依然是四次。
尽管mmap可以节省数据拷贝,但维护文件与地址空间的映射关系也是有代价的,除非CPU拷贝数据
的时间超过维系映射关系的代价,否则基于mmap的程序性能可能不及传统的read/write。

此外,如果映射的文件被其它进程截断,在Linux系统下你的进程将立即接收到SIGBUS信号,因此这
种异常情况也需要正确处理。

除了mmap之外,还有其它办法也可以实现零拷贝。

sendfile

你没有看错,在Linux系统下为了解决数据拷贝问题专门设计了这一系统调用:

#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

Windows下也有一个作用类似的API:TransmitFile。

这一系统调用的目的是在两个文件描述之间拷贝数据,但值得注意的是,数据拷贝的过程完全是在内
核态完成,因此在网络服务器的这个例子中我们将把那两行代码简化为一行,也就是调用这里的
sendfile。

使用sendfile将节省两次数据拷贝,因为数据无需传输到用户态:
调用sendfile后,首先DMA机制会把数据从磁盘拷贝到内核buf中,接下来把数据从内核buf拷贝到相
应的socket buf中,最后利用DMA机制将数据从socket buf拷贝到网卡中。

我们可以看到,同使用传统的read/write相比少了一次数据拷贝,而且内核态和用户态的切换只有两
次。

有的同学可能已经看出了,这好像不是零拷贝吧,在内核中这不是还有一次从内核态buf到socket
buf的数据拷贝吗?这次拷贝看上去也是没有必要的。

的确如此,为解决这一问题,单纯的软件机制已经不够用了,我们需要硬件来帮一点忙,这就是DMA
Gather Copy。

sendfile 与DMA Gather Copy

传统的DMA机制必须从一段连续的空间中传输数据,就像这样:

很显然,你需要在源头上把所有需要的数据都拷贝到一段连续的空间中:
现在肯定有同学会问,为什么不直接让DMA可以从多个源头收集数据呢?

这就是所谓的DMA Gather Copy。

有了这一特性,无需再将内核文件buf中的数据拷贝到socket buf,而是网卡利用DMA Gather Copy


机制将消息头以及需要传输的数据等直接组装在一起发送出去。

在这一机制的加持下,CPU甚至完全不需要接触到需要传输的数据,而且程序利用sendfile编写的代
码也无需任何改动,这进一步提升了程序性能。
当前流行的消息中间件kafka就基于sendfile来高效传输文件。

其实你应该已经看出来了,高效IO的秘诀其实很简单:尽量少让CPU参与进来。

实际上sendfile的使用场景是比较受限的,大前提是用户态无需看到操作的数据,并且只能从文件描
述符往socket中传输数据,而且DMA Gather Copy也需要硬件支持,那么有没有一种不依赖硬件特性
同时又能在任意两个文件描述符之间以零拷贝方式高效传递数据的方法呢?

答案是肯定的!这就要说到Linux下的另一个系统调用了:splice。

Splice

这里还要再次强调一下不管是sendfile还是这里的splice系统调用,使用的大前提都是无需在用户态看
到要传递的数据。

让我们再来看一下传统的read/write方法。

在这一方法下必须将数据从内核态拷贝的用户态,然后在从用户态拷贝回内核态,既然用户态无需对
该数据有任何操作,那么为什么不让数据传输直接在内核态中进行呢?

现在目标有了,实现方法呢?

答案是借助Linux世界中用于进程间通信的管道,pipe。
还是以网络服务器为例,DMA把数据从磁盘拷贝到文件buf,然后将数据写入管道,当在再次调用
splice后将数据从管道读入socket buf中,然后通过DMA发送出去,值得注意的是向管道写数据以及
从管道读数据并没有真正的拷贝数据,而仅仅传递的是该数据相关的必要信息。

你会看到,splice和sendfile是很像的,实际上后来sendfile系统调用就是基于splice实现的,既然有
splice那么为什么还要保留sendfile呢?答案很简单,如果直接去掉sendfile,那么之前依赖该系统调
用的所有程序将无法正常运行。

接下来我们在看一种可以绕过内核直接在用户态操作硬件的方法。

总结

本文介绍了很多零拷贝的优化技巧,但是注意,一定要注意,如果你的程序对性能要没有到那种极度
苛刻哪怕慢1ns都不行的时候,忘掉本文讲解的这些所谓优化技巧,老老实实用read/write,相比这
些所谓的技巧,内存拷贝没有那么糟糕。

本文仅仅告诉你为了追求高性能系统中都有哪些乱七八糟的设计。

我是小风哥,希望这篇文章对大家理解IO有所帮助。

关注作者
操作系统与内核有什么区别?

通用底盘技术

Canoo公司有一项核心技术专利,这就是它们的通用电动底盘技术,长得是这个样子,非常像一个滑
板:
这个带轮子、有电池、能动的滑板已经包含了一辆车最核心的组件,差的就是一个外壳。

这个看起来像滑板的东西就是所谓的电池系统和底盘一体化技术,Canoo公司在它们的通用底盘上加
装不同的外壳就能制造出不同的车型。

什么是内核?

在上面这个示例中,包含轮子以及电池系统的底盘就好比内核,而套上外壳加上椅子以及内饰后的整
体成品就好比操作系统。

内核仅仅是操作系统的一部分,是真正与硬件交互的那部分软件,与硬件交互包括读写硬盘、读写网
盘、读写内存以及任何连接到系统中的硬件。

除了与硬件交互外,内核还负责分配资源,分配什么资源呢?所谓资源就是硬件,比如CPU时间、内
存、IO等等,这些都是资源。
现在我们知道了内核负责分配资源,那么问题来了,要怎么分配这些资源呢?答案就是以进程的形式
来分配资源。

怎么分配呢?

一句话:虚拟大法好。

每个进程都认为自己在独占CPU,这通过CPU时间片来实现,内核让CPU在各个进程之间快速切换,
这样程序员写好程序员后直接运行即可,即使在单核系统中运行成百上千个进程都没有问题。

每个进程都认为自己在独占内存,这通过虚拟内存来实现。

有的同学可能会问,为什么都要虚拟化呢?

答案显而易见,因为计算机系统内的资源是有限的,我们只有几个CPU核心、几个G的内存,但却要
同时运行几百几千个进程,除此之外我们别无它法。

如果你还知道有其它更高效的方法那么赶紧放下手机,马上将你的思想写成论文发表出来,下一届的
图灵奖非你莫属,当然在发表获奖感言的时候一定要记得表示是受到了【码农的荒岛求生】这个公众
号的启发才想到的。
因此,内核的职责就是以进程的形式来分配CPU时间,以虚拟内存的形式来分配物理内存,以文件的
形式来管理IO设备。

什么是操作系统?

然而只有一个内核实际上是做不了什么真正有用的事情,就像上面示例中那个通用底盘一样,这个底
盘确实能跑起来,但你没办法开着这样一个底盘出去浪,因为这个底盘很难用。

因此,你不得不加装上方向盘、座椅以及车身外壳等,同样的道理,内核是给人用的,为了与内核交
互,发明了命令行以及图形界面GUI。
除了给普通用户提供使用的接口之外,操作系统还需要给程序员提供编写程序的接口,当我们写的程
序依赖内核提供的服务时是该怎么办呢?

有的同学说我们需要依赖内核提供的服务吗?

想一想,进行网络编程时你有没有自己编写过处理TCP/IP协议栈数据的代码?你有没有自己写代码从
网卡上收发数据?都没有,实际上你需要做的仅仅是简单的调用一些socket接口就可以了。

网络编程仅仅是其中的一项,其它还包括文件IO、创建进程、创建线程等等等等,这些是内核提供
的,那么我们该怎么使用呢?

答案就是通过所谓的系统调用,system call。

通过系统调用,我们可以像使用普通函数那样向操作系统请求服务,当然,直接使用系统调用是非常
繁琐的,因此通常会在这之上提供一层封装。
在Windows平台就是给程序员提供编程接口的是Windows API,这层API包罗万象,不但包括上文提
到对系统调用的封装,还包括其它功能,像创建带有图形界面的应用程序等等。

但在Linux世界你找不到一种类似Windows API的东西,毕竟Windows是微软自家产品,什么都可
以打包起来,Linux只是一个开源的内核,如果一定要找一个类似的东西话那就是libc,也就是C标准
库,这里同样包括了对系统调用的封装以及一些库函数,但libc不包含创建带有图形界面应用程序的
功能。

现在我们知道了,操作系统需要提供两种接口:

给用户提供操作接口。
给程序员提供编程接口。
这些就是好比汽车的外壳,我们(用户和程序员)看得见摸得着,外壳加上底盘——也就是内核,才是
功能完善的操作系统。

各种各样的操作系统

实际上我们熟悉的Linux只是内核而不能称得上是操作系统,Ubuntu则可以认为是操作系统,其内核
是Linux;RedHat也是操作系统,其内核同样是Linux;我们可以看到,尽管Ubuntu和RedHat是不
同的操作系统,但其内核可以是相同的。

这就好比它们可以基于同样的底盘打造出不同的车型。

而我们熟悉的Windows也是操作系统,其内核是Windows NT内核。

总结

内核就像本文开头提到的电动底盘,包含了一个汽车的最核心元素;但这样一个底盘并没有什么实际
用处,当搭配上外壳以及座椅后才是一辆真正有用的车,这就好比操作系统。值得注意的是,不同的
操作系统可以有相同的内核。

当我们在使用方便的智能手机以及个人PC时不应忘记,正是操作系统在背后的默默工作让一堆硬件电
路变得这么好用。

希望这篇文章对大家理解操作系统以及内核有所帮助。
关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

彻底理解树的递归遍历
如果把你丢到迷宫里该怎么找到出口呢?思考一下这个问题。
很显然,像没头苍蝇一样乱冲乱撞对于走迷宫来说是没有用的,以最小代价找到迷宫的唯一出路就是
系统性的将迷宫搜索一遍,所谓系统性指的是所有迷宫的路我们只走一遍而不重复,这是找到迷宫出
口最高效的方法,千万不要将走迷宫寄希望于横冲直撞走大运的概率上。

什么是二叉树

在计算机科学中二叉树,binary tree,是一种数据结构,在该数据结构中每个节点最多有两个子节
点,如图所示:

二叉树的定义就是这样简单,那么二叉树和迷宫有什么关系呢?

本质上二叉树不过就是一个低配版的简单迷宫,因为每一岔口上最多就只有两条路。

如果你能系统性的搜索迷宫那么你就应该明白如何遍历二叉树,

如何遍历二叉树

想一下该如何系统性的搜索一个迷宫呢?显然,要想高效的搜索迷宫必须依赖搜索策略。

这个策略实际上非常简单,对于每一条岔口都应用以下两条规则:

1. 对于每一个岔口,依次从左到右走一遍并记录下哪些路已经走过了
2. 如果当前的路已经是死胡同了或者当前岔口全部搜索完毕,那么我们应该原路返回上一个岔口

由于本质上二叉树也是迷宫,因此就以二叉树为例讲解一下。

由于二叉树最多有两个节点,也就是说二叉树这种迷宫每个岔口最多两条路,应用上述规则:

1. 对于每一个节点,先走左手边的路,左手边的路全部走完回到这个节点后继续右手边的路
2. 如果当前是叶子节点或两条路都已搜索完毕则返回父节点
接下来以上图中的二叉树为例来讲解一下该策略。

迷宫入口就是节点1,如下图a所示,那么在节点1上我们遇到两条路,依据上述策略首先走左手边的
路,这样来到了节点2,如下图b所示;

在节点2上我们还是面对两条路,依然使用上述策略我们来到了节点3,如下图c所示;

来到节点3后我们发现是一条死胡同,根据策略我们需要回退到来时的路,也就是回退到节点2,注
意,这在计算机算法中被称为回溯,这是系统性搜索中常见的操作,如下图d所示。

当回退到节点2后我们发现左手边的路都已经搜索过了,那么接下来我们可以放心的搜索2这个节点右
手边的路了,这样我们来到了节点4,如下图e所示;

来到节点4后依然是死胡同,因此我们需要回退到节点2,图f;回退到节点2后我们发现两条路都已搜
索完毕,那么我们可以放心的继续回退,这样我们又一次来到了节点1,如图g所示;

来到节点1后我们发现走手边的路已经全部搜索完毕,因此可以开始搜索1右手边的路了,这样我们来
到了节点5,如图h所示;

来到节点5后我们发现这里只有一条路,因此来到了节点6,如图i所示:

节点6同样是死胡同,因此回退到节点5,如图j所示;
回到节点5后我们发现所有的路都已搜索完毕,继续回退,这样我们就又来到了节点1,但这时,节点
1左右子树全部遍历完毕,如图k所示;就这样我们遍历完了整个二叉树。

有的同学看到这里可能会有疑问,那我们平时所说的先序遍历、中序遍历以及后续遍历指的是什么意
思呢?

实际上不管是先序、中序还是后续遍历都是上面的过程,不同点在于我们在什么时机下喊一声“到此
一游”:

一到某个岔口就喊一声,这叫先序遍历;

搜索完左手边的路回退到岔口时喊一声,这叫中序遍历;

左右两条路都搜索完回退到岔口时喊一声,这叫后续遍历;

实际上这三种遍历方式本质上是一样的,区别仅仅在于处理节点的时机不同。

值得注意的一旦是,彻底理解二叉树的遍历极其重要,这是解决几乎所有关于二叉树问题的基础。

递归实现二叉树的遍历

在讲解递归遍历二叉树前我们首先用代码表示一下二叉树的结构:

struct tree {
struct tree* left;
struct tree* right;
int value;
};
从定义上我们可以看出树本身就是递归定义的,二叉树的左子树是二叉树(struct tree* left),二叉树
的右子树也是二叉树(struct tree* right)。假设给定一颗二叉树t,我们该如何遍历这颗二叉树呢?

struct tree* t; // 给定一颗二叉树

有的同学可能会觉得二叉树的遍历是一个非常复杂的过程,真的是这样的吗?

假设我们已经实现了树的遍历函数,这个函数是这样定义的:

void search_tree(struct tree* t);

只要调用search_tree函数我们就能把一棵树的所有节点打印出来:

struct tree* t; // 给定一颗二叉树


search_tree(t); // 打印二叉树所有节点

要是真的有这样一个函数实际上我们的任务就完成了,如果我问你用这个函数把树t的左子树节点都
打印出来该怎么写代码你肯定会觉得侮辱智商,很简单啊,不就是把树t的左子树传给search_tree这
个函数吗?

seartch_tree(t->left); // 打印树t的左子树

那么打印树t的右子树呢?同样easy啊

search_tree(t->right); // 打印树t的右子树

是不是很简单,那么打印当前节点的值呢?你肯定已经懒得搭理我了 :)

printf("%d ", t->value); // 打印根节点的值

至此我们可以打印出根节点的值,也可以打印出树t的左子树节点,也可以打印出树t的右子树节点,
如果我问你既然这些问题都解决了,那么该如何实现search_tree()这个函数?

如果你不知道,那么就该我说这句话了:很简单啊有没有,不就是把上面几行代码写在一起嘛

void search_tree(struct tree* t) {


printf("%d ", t->value); // 打印根节点的值
seartch_tree(t->left); // 打印树t的左子树
search_tree(t->right); // 打印树t的右子树
}

是不是很简单,是不是很easy,惊喜不惊喜,意外不意外,我们在仅仅只靠给出函数定义并凭借丰富
想象的情况下就把这个函数给实现了 :)
当然我们需要对特殊情况进行处理,如果给定的一棵树为空,那么直接返回,最终代码就是:

void search_tree(struct tree* t) {


if (t == NULL) // 如果是一颗空树则直接返回
return;

printf("%d ", t->value); // 打印根节点的值


seartch_tree(t->left); // 打印树t的左子树
search_tree(t->right); // 打印树t的右子树
}

有的同学可能会一脸懵逼,这个函数就这样实现了?正确吗,不用怀疑,这段代码无比正确,你可以
自己构造一棵树并试着运行一下这段代码。

上述代码就是树的递归遍历。

我知道这些一脸懵逼的同学心里的怎么想的,这段代码看上去确实正确,运行起来也正确,那么这段
代码的运行过程是什么样的呢?

递归调用过程

假设有这样一段代码:

void C() {
}

void A() {
B();
}

void main() {
A();
}

A()会调用B(),B()会调用C(),那么函数调用过程如图所示:
实际上每一个函数被调用时都有对应的一段内存,这段内存中保存了调用该函数时传入的参数以及函
数中定义的局部变量,这段内存被称为函数帧,函数的调用过程具有数据结构中栈的性质,也就是先
进后出,比如当函数C()执行完毕后该函数对应的函数帧释放并回到函数B,函数B执行完毕后对应的
函数帧被释放并回到函数A。

有了上述知识我们就可以看一下树的递归调用函数是如何执行的了。为简单起见,我们给定一颗比较
简单的树:

当在该树上调用search_tree函数时整个递归调用过程是怎样的呢,如图所示:
首先在根节点1上调用search_tree(),当打印完当前节点的值后在1的左子树节点上调用
search_tree,这时第二个函数帧入栈;打印完当前节点的值(2)后在2的左子树上调用search_tree,
这样第三个函数帧入栈;同样是打印完当前节点的值后(3)在3的左子树上调用search_tree,第四个函
数帧入栈;由于3的左子树为空,因此第四个函数帧执行第一句时就会退出,因此我们又来到了第三
个函数帧,此时节点3的左子树遍历完毕,因此开始在3的右子树节点上调用search_tree,接下来的
过程如图所示:

这个过程会一直持续直到节点1的右子树也遍历完毕后整个递归调用过程运行完毕。注意,函数帧中
实际上不会包含代码,这里为方便观察search_tree的递归调用过程才加上去的。上图中没有将整个
调用过程全部展示出来,大家可以自行推导节点5和节点6是如何遍历的。

从这个过程中我们可以看到,函数的递归调用其实没什么神秘的,和普通函数调用其实是一样的,只
不过递归函数的特殊之处在于调用的不是其它函数而是本身。

从上面的函数调用过程可以得出一个重要的结论,那就是递归函数不会一直调用下去,否则就是栈溢
出了,即著名的Stack Overflow,那么递归函数调用栈在什么情况下就不再增长了呢,在这个例子中
就是当给定的树已经为空时递归函数调用栈将不再增长,因此对于递归函数我们必须指明在什么情况
下递归函数将直接返回,也就是常说的递归函数的出口。
递归实现树的三种遍历方法

到目前为止,我们已经知道了该如何遍历树、如何用代码实现以及代码的调用过程,注意打印语句的
位置:

printf("%d ", t->value); // 打印根节点的值


seartch_tree(t->left); // 打印树t的左子树
search_tree(t->right); // 打印树t的右子树

中序和后序遍历都可以很容易的用递归遍历方法来实现,如下为中序遍历:

void search_in_order(struct tree* t) {


if (t == NULL) // 如果是一颗空树则直接返回
return;

search_in_order(t->left); // 打印树t的左子树
printf("%d ", t->value); // 打印根节点的值
search_in_order(t->right); // 打印树t的右子树
}

后序遍历则为:

void search_post_order(struct tree* t) {


if (t == NULL) // 如果是一颗空树则直接返回
return;

search_in_order(t->left); // 打印树t的左子树
search_in_order(t->right); // 打印树t的右子树
printf("%d ", t->value); // 打印根节点的值
}

至此,有的同学可能会觉得树的遍历简直是太简单了,那么如果让你用非递归的方式来实现树的遍历
你该怎么实现呢?

在阅读下面的内容之前请确保你已经真正理解了前几节的内容。
如果你还是不能彻底理解请再多仔细阅读几遍。

如何将递归转为非递归
虽然递归实现简单,但是递归函数有自己特定的问题,比如递归调用会耗费很多的栈空间,也就是内
存;同时该过程也较为耗时,因此其性能通常不及非递归版本。

那么我们该如何实现非递归的遍历树呢?

要解决这个问题,我们必须清楚的理解递归函数的调用过程。

从递归函数的调用过程可以看出,递归调用无非就是函数帧入栈出栈的过程,因此我们可以直接使用
栈来模拟这个过程,只不过栈中保存的不是函数的运行状态而是树节点。

确定用栈来模拟递归调用这一

点后,接下来我们就必须明确两件事:

1. 什么情况下入栈
2. 什么情况下出栈

我们还是以先序遍历为例来说明。

仔细观察递归调用的过程,我们会发现这样的规律:

1. 不管三七二十一先把从根节点开始的所有左子树节点放入栈中
2. 查看栈顶元素,如果栈顶元素有右子树那么右子树入栈并以右子树为新的起点并重复过程1直到
栈空为止

现在我们可以回答这两个问题了。

什么情况下入栈?

最开始时先把从根节点开始的所有左子树节点放入栈中,第二步中如果栈顶有右子树那么重复过程
1,这两种情况下会入栈。

那么什么情况下出栈呢?

当查看栈顶元素时实际上我们就可以直接pop掉栈顶元素了,这是和递归调用不同的一点,为什么
呢?因为查看栈顶节点时我们可以确定一点事,那就是当前节点的左子树一定已经处理完毕了,因此
对于栈顶元素来说我们需要的仅仅是其右子树的信息,拿到右子树信息后栈顶节点就可以pop掉了。

因此上面的描述用代码来表示就是:

void search(tree* root) {


if(root == NULL)
return ;
stack<tree*>s;

// 不管三七二十一先把从根节点开始的所有左子树节点放入栈中
while(root){
s.push(root);
root=root->left;
}

while(!s.empty()){
// 查看栈顶元素,如果栈顶元素有右子树那么右子树入栈并重复过程1直到栈空为止
tree* top = s.top();
tree* t = top->right;
s.pop();

while(t){
s.push(t);
t = t->left;
}
}
return r;
}

上述代码是实现树的三种非递归遍历的基础,请务必理解。

接下来就可以实现树的三种非递归遍历了。

非递归遍历

有的同学可能已经注意到了,上一节中的代码中没有printf语句,如果让你利用上面的代码以先序遍
历方式打印节点该怎么实现呢?如果你真的已经理解了上述代码那么就非常简单了,对于先序遍历来
说,我们只需要在节点入栈之前打印出来就可以了:

void search_pre_order(tree* root) {


if(root == NULL)
return ;
stack<tree*>s;

// 不管三七二十一先把从根节点开始的所有左子树节点放入栈中
while(root){
printf("%d ", root->value); // 节点入栈前打印
s.push(root);
root=root->left;
}

while(!s.empty()){
// 查看栈顶元素,如果栈顶元素有右子树那么右子树入栈并重复过程1直到栈空为止
tree* top = s.top();
tree* t = top->right;
s.pop();

while(t){
printf("%d ", root->value); // 节点入栈前打印
s.push(t);
t = t->left;
}
}
return r;
}

那么对于中序遍历呢?实际上也非常简单,我们只需要在节点pop时打印就可以了:

void search_in_order(tree* root) {


if(root == NULL)
return ;
stack<tree*>s;

// 不管三七二十一先把从根节点开始的所有左子树节点放入栈中
while(root){
s.push(root);
root=root->left;
}

while(!s.empty()){
// 查看栈顶元素,如果栈顶元素有右子树那么右子树入栈并重复过程1直到栈空为止
tree* top = s.top();
printf("%d ", top->value); // 节点pop时打印
tree* t = top->right;
s.pop();

while(t){
s.push(t);
t = t->left;
}
}
return r;
}

对于后续遍历呢?

后续遍历相对复杂,原因就在于出栈的情况不一样了。

在先序和中序遍历过程中,只要左子树处理完毕实际上栈顶元素就可以出栈了,但是后续遍历情况不
同,什么是后续遍历?只有左子树和右子树都遍历完毕才可以处理当前节点,这是后续遍历,那么我
们该如何知道当前节点的左子树和右子树都处理完了呢?
显然我们需要某种方法记录下遍历的过程,实际上我们只需要记录下遍历的前一个节点就足够了。

如果我们知道了遍历过程中的前一个节点,那么我们就可以做如下判断了:

1. 如果前一个节点是当前节点的右子树,那么说明右子树遍历完毕可以pop了
2. 如果前一个节点是当前节点的左子树而且当前节点右子树为空,那么说明可以pop了
3. 如果当前节点的左子树和右子树都为空,也就是叶子节点那么说明可以pop了

这样什么情况下出栈的问题就解决了,如果不符合这些情况就不能出栈。

只需要根据以上分析对代码稍加修改就可以了:

void search_post_order(tree* root) {


if(root == NULL)
return ;
stack<tree*>s;
TreeNode* last=NULL; // 记录遍历的前一个节点

// 不管三七二十一先把从根节点开始的所有左子树节点放入栈中
while(root){
s.push(root);
root=root->left;
}

while(!s.empty()){
tree* top = s.top();
if (top->left ==NULL && top->right == NULL || // 当前节点为叶子节点
last==top->right || // 前一个节点为当前节点的右
子树
top->right==NULL && last==top->left){ // 前一个节点为当前节点左子树且右子
树为空
printf("%d ", top->value); // 节点pop时打印
last = top; // 记录下前一个节点
s.pop();
} else {
tree* t = top->right;
while(t){
s.push(t);
t = t->left;
}
}
}
return r;
}
总结

树的递归遍历相对简单且容易理解,但是递归调用实际上隐藏了相对复杂的遍历过程,要想以非递归
的方式来遍历二叉树就需要仔细理解递归调用过程。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

CPU寄存器是如何装入结构体的?
我们在之前的文章中有很多讲解了CPU与寄存器,然后有同学问了这样一个问题:CPU内部的寄存器
数量有限,容量有限,那么我们使用的庞大的数据结构是怎样装入寄存器供CPU计算的呢?
内存与数据

真正有用的程序是离不开数据的,比如一个int、一个float等,这些都是非常简单的数据,当然也有非
常复杂的数据,这样的数据通常在内存中以数据结构的形式组织起来,比如你创建了一个数组、一个
链表、创建了一棵树、一张图,就像这样:
那么这些数据很显然存放在内存中,这些数据在不同的场景下有不同的大小,从数B、数KB到数百GB
都有可能,与此同时,CPU内部的寄存器数量是固定的,容量也是极其有限的,那么CPU是如何利用
有限的资源操作庞大的数据结构呢?

要回答这一问题,我们要认识一位农夫,因为他不生产数据,他只是数据的搬运工,这位农夫就
是。。

数据搬运机器指令

你没有看错,这位农夫就是我们之前多次提到的机器指令。

机器指令中除了负责逻辑运算、执行流控制、函数调用等指令外,还有一类指令,这类执行只负责和
内存打交道,典型的就是精简指令集架构中的Load/Store机器指令,即内存读写指令。
原来,从宏观上看的话,那么存放在内存中的数据,比如一个数组,可能会非常庞大,但是具体到代
码,每一个步骤操作的数字又会非常简单,就像这样:

char* huge_arr = char int[4 * 1024* 1024 *1024];

我们创建了一个长度为1G的数组,每个int 4字节,则这个数组的大小就是4GB,这显然是一个很庞大
的数组。

对于这样的数据,我们通常都会怎么使用呢?

最常见的情况可能是遍历一边,然后对每个字符进行一个简单操作,这里以计算数组之和为例:

long int sum = 0;


for (int i = 0; i <1 * 1024* 1024 *1024; i++) {
sum += huge_arr[i];
}

虽然整个数组多达4GB,但具体到每一步我们一次只能操作一个元素,就像这里的:

sum += huge_arr[i];

这行代码翻译成机器指令可能是这样的,我们假设此时i为100:

load $r0 100($r2)


add $r1 $r1 $r0

第一行指令中数组首地址存放在寄存器r2中,100($r2)表示数组首地址+100,这样我们就能得到
huge_arr[100]的地址了,然后将该地址中的值利用load指令加载到寄存器r0中。

第二行就简单多了,r1寄存器中保存的是sum的值,该行指令执行过后r1中的值就已经加上了
huge_arr[100]。

现在你应该能看出来了吧,虽然我们不能把整个数组加载到寄存器供CPU计算,但这其实是没有必要
的,因为我们一次只能操作数组中的一个元素,我们只需要把这一个元素加载到寄存器就足矣了。
对于其它复杂的数据结构也是同样的道理,无论多么复杂的数据,代码对其一次的操作都是很简单很
微小的,这一微小的操作使用的基本元素都可以通过内存读写指令加载到寄存器,修改完后再写会内
存。

编译器

现在你应该知道了为什么CPU内部那么少的寄存器能操作内存中庞大的数据结构,实际上由于内存中
的数据要远大于CPU寄存器的容量,因此我们必须精心挑选,那些经常使用的数据放到寄存器中的时
间要长一点。

在上面的示例中,r2寄存器保存的是huge_arr这个数组在内存中的起始地址,那么这个数据应该放到
寄存器中,因为后续遍历到的每一个元素都要用到该地址,这项工作就是编译器来完成的。
编译器把那些经常使用的数据放到寄存器,剩下的放到内存中,然后利用内存读写指令在寄存器和内
存之间来回搬运数据。

总结

通过本文不难发现,实际上我们没有必要一次性把整个数据全部装到CPU寄存器中,而是用到哪些才
装载哪些。在最细粒度的操作中,依赖的操作数都可以直接加载到内存,这通常是由内存读写机器指
令来完成的。

我是小风哥,希望这篇文章对大家理解CPU与寄存器有所帮助。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
CPU可以跑多快?从地球到火星的距离告诉你

Google大神jeff 曾经展示过一张图,这种图介绍了系统中各种关键操作的时延具体有多少。

需要注意的是这张图上的数据自2012年后就没有再更新过了,统计自2020年的最新数据见这里:
这张图中一个小的黑方块代表1纳秒,一个蓝色的方块代表100纳秒,一个绿色的方块代表10微秒,
一个红色的方块代表1毫秒。

尽管这种表示方法已经比第一张图形象很多了,但在我们(人类)看来对这些纳秒没太多概念,毕竟人
类的反应时间仅仅0.2 -0.3秒,比这更短的时间人类是没有太多感觉的。

为了让大家能更加直观的感受速度差异,我们依然已第一张表为例,并且把计算机世界中的0.5纳秒
当做1秒来换算一下,这样你就能清楚的感受到这些计算机世界中各个硬件巨大的速度差异了。

我们再来看一下:

现在就很有意思了,假定L1 cache的访问延迟为1s,那么访问内存的延迟就高达3分钟。

从内存上读取1MB数据需要5天,从SSD上读取1MB需要20天,磁盘上读取1MB数据高达1年的时
间。

更有趣的来了,假设物理机重启的时间为2分钟,如果也将0.5ns视为1s的话那么2分钟就相当于5600
年,中华文明上下五千年,大概就是这样一个尺度。
现在你应该能直观的感受到CPU的速度到底有多快了吧。

以上都是基于时间维度换算的。

接下来我们基于距离维度进行了一次更有意思的换算。

从CPU访问L1 cache 的时延为0.5ns,假定在这个时间尺度下我们能行走1米,大概是你从在家里走两


步拿个快递的距离。

CPU访问内存的时延里我们可以行走200米,大概是你出门去个便利店的距离。

CPU从内存中读取1MB的时延我们可以行走500公里,这个距离大概是从北京到青岛的直线距离。
网络包在数据中心内部走一圈的时延可以让我们行走1000公里,大概是从北京到上海的直线距离。

从SSD中读取1MB的时延可以让我们行走2000公里,大概是从北京到深圳的距离。
从磁盘中读取1MB的时延可以让我们行走40000公里,正好是围绕地球转一圈的距离。

而网络数据包从美国加利福尼亚到荷兰转一圈的时延可以让我们行走30万公里,正好是从地球到月球
的距离。

物理机一次重启的时延可以让我们行走1.2亿公里,正好是从地球到火星的距离。

现在你应该对计算机系统中各种时延有一个清晰的认知了吧。

更多有趣硬核的技术文章欢迎关注码农的荒岛求生。

关注作者
也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

回调函数实现的原理是什么?
其实之前小风哥写过关于回调函数原理的文章,在这里《10张图让你彻底理解回调函数》,这篇文章
内容很全面,但还是有很多同学在微信上问我有没有简化版的,以下就是回调函数原理的极简版。

其实回调函数和普通函数没有本质的区别。

首先让我们来看看普通的函数调用,假设我们在A函数中调用函数func:
void A() {
...
func();
...
}

想一想,你怎么知道可以调用func呢?哦,原来func是你自己定义的:

void func() {
blablabla;
}

这很简单吧,现在假设你编写的这段代码无比之牛逼,全世界的程序员都无比疯狂的想引入到自己的
项目中,这时你会把A函数编写成一个库供全世界的码农使用。

但此时所有人都发现一个问题,那就是他们都想在A函数中的某个特定点上执行一段自己的代码,作
为这个库的创作者你可能会这样实现:

void A() {
...

// 特定点

if (张三) {
funcA();
} else if (李四) {
funcB();
}
...
}

假设全世界有一千万码农,那你是不是要有一千万个if else。。。想想这样的代码就很刺激有没有!

更好的办法是什么呢?把函数也当做变量!你可以这样定义A函数:

void A(func f) {
...
f();
...
}

任何一个码农在调用你的A函数时传入一个函数变量,A函数在合适的时机调用你传入的函数变量,从
而节省了一千万行代码。

为了让这个过程更加难懂一些,这个函数变量起了一个无比难懂的称呼:回调函数。
现在你应该明白了回调函数是怎么一回事了吧,相比回调函数来说我更愿意将其看做函数变量。

以上就是回调函数的基本原理,有想看更详细版本的请参考这里。

以上仅仅是回调函数的一种用途,回调函数在基于事件驱动编程以及异步编程时更是必备的,关于事
件驱动编程你可以参考这里,GUI编程的同学对此肯定很熟悉。

希望这里的讲解对大家理解回调函数有所帮助。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
程序员还需要理解汇编吗?

写代码不知道究竟写了什么,就像手持火把穿过炸药厂,你可能会活下来,但这纯属幸运。

大家好,我是小风哥。

不管一个程序多么简单,只有几行代码,比如你写的helloworld程序,也不管一个程序多么复杂,几
千万几亿行代码,比如浏览器程序或者干脆操作系统,最终,都是要转换为一条条极其简单的机器指
令被CPU运行,小风哥词汇量匮乏,只能在这里给出“神奇”二字。

当程序员使用C/C++、Java、Python等语言时,这些编程语言对我们屏蔽了机器指令级别的差异,很
显然,这更加高效,因为你一行用高级语言的代码可能会被编译器翻译为几行甚至十几行机器指令,
显然使用汇编语言的生产力是远不及高级语言的,而且现代编译器足够智能,其生成的指令足以媲美
一个汇编熟练工手写的汇编代码,而且最棒的是高级语言编写的代码可以跨平台运行,而机器指令则
是和具体平台绑定一起的。

所以,快2022年了,程序员还需要理解汇编吗?

我的答案是,大部分人都不需要,除了那些编程高手、黑客,还有编写操作系统、编译器、驱动的程
序员,以及对底层好奇的同学们。

这里的理解不是说要手写汇编,而是仅仅看懂就好,那理解了汇编语言有什么好处吗?

如果你想真正弄清楚CPU在细节层面是如何工作的,那么理解汇编语言是第一步。

汇编语言可以告诉你函数调用是如何实现的,当你使用if for while等底层到底发生了什么。

理解了汇编语言,当使用多线程编程时你会深刻的理解多线程将会怎样访问共享数据,为什么访问共
享数据一次只能由一个线程来操作。

如果你在学习C语言或者挣扎在指针的泥潭里,那么你可以看看汇编,比如有这样一段代码:

void swap(int* a, int* b) {


int t = *a;
*a = *b;
*b = t;
}

这是C语言中非常简单整数交换代码,这里涉及到了指针操作,那么这段代码生成的汇编指令是什么
样的呢?

我们使用命令:

gcc -S -Os test.c

来查看生成的汇编指令:
swap:
movl (%rdi), %eax
movl (%rsi), %edx
movl %edx, (%rdi)
movl %eax, (%rsi)
ret

寄存器rdi中保存的就是指针a,寄存器rsi保存的是指针b,(%rdi)的意思将寄存器rdi中的值作为内存
地址并读取该地址中的值, “movl (%rdi), %eax”的意思就是将a指向的值放到寄存器eax,剩下的就简
单了,从这里可以看出C语言中的所谓指针无非就是一个内存地址或者更通俗一点就好比门牌号。

哦,对了,很多同学买来当做镇宅之宝的《计算机程序设计与艺术》,这本书中的算法可是用汇编来
讲解的,高级编程语言这层外衣就像当季流行的潮款,一茬又一茬,汇编语言就朴实无华的多了,这
几乎不会存在过时的风险。

但这篇文章绝不是鼓励你一股脑去学汇编,学习任何东西都要讲究目的,搞清楚目的再去学,否则大
概率就是在浪费时间,本篇介绍了一些汇编语言的好处,但不能否认这些可能对你来说不是必须的,
但你如果你对底层好奇,那么汇编语言可以解答你很多的疑惑,小风哥对汇编其实也不是很了解,早
些年学到的已经完整还给老师了,只不过最近几年感觉汇编的确能解答一些问题,如果你也有同感,
不妨去了解一下。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
什么是异步编程?

大家好,我是小风哥。

之前很多同学在微信上问能不能讲讲异步编程是怎么一回事儿,今天就和大家简单聊一聊这个话题。

我们以函数调用为例,假设有这样的代码:

void B() {
lines = read(filename);
sum(lines);
}
void A() {
...
B();
...
}

代码非常简单,B函数读取一个文件,文件里保存的是一行行数据,然后加和,A函数中的某个位置调
用B函数。

此时,我们说这是同步调用,因为A函数后续代码必须等待B函数处理完文件才能继续执行。

随着业务不断发展,B函数处理的文件越来越庞大,此时处理一次文件耗时2小时,假设A函数后还有
一行重要的代码:
void A() {
...
B();
something_important();
...
}

这就意味着调用B函数后需要等待2个小时才能执行到something_important这行代码,而
something_important函数对时间要求非常苛刻,该怎样改进呢?

其实很简单,我们可以在B函数内部创建一个线程,在线程中处理文件:

void handle_file() {
lines = read(filename);
sum(lines);
}
void B() {
thread t(handle_file);
}
void A() {
...
B();
something_important();
...
}

这样B函数被调用后创建完线程即可立即返回,紧接着开始执行something_important这行代码,
CPU在执行something_important这行代码时可能文件还没有开始处理,这样函数A不再依赖于文件
处理,这时我们说函数B函数就是异步调用的,函数A异步于文件处理。

如果是在单核系统下,CPU会不断在处理文件线程和A函数线程间切换,看上去这两个线程就好像在
同时运行,但如果是在多核系统下,这两个线程可以真正的并行起来。

在编程语境下,异步往往和线程进程等相关。

最后举一个例子。

同步就好比你排队去自助售票机取电影票(话说小风哥已经很久很久没有去电影院看电影了),你必
须排队等待前一个人取完电影票才能到你,你不能在前一个取票的过程中取自己的票,这时我们说取
电影票时你和前一个人是同步的。

而异步就好比去吃大餐,你在座位上看菜单点菜,其它人也可以点菜,你不需要等待其它人吃完饭才
能下单,这时我们说你点菜和其它人吃饭是异步的。
关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

彻底理解C语言中的指针
大家好,我是小风哥。

假定给你一块非常小的内存,这块内存只有8字节,这里也没有高级语言,没有操作系统,你操作的
数据单位是单个字节,你该怎样读写这块内存呢?
注意这里的限定,再读一遍,没有高级语言,没有操作系统,在这样的限制之下,你必须直面内存读
写的本质。

这个本质是什么呢?

本质是你需要意识到内存就是一个一个装有字节的小盒子,这些小盒子从0到N编好了序号。

这时如果你想计算1+2,那么你必须先把1和2分别放到两个小盒子中,假设我们使用Store指令,把数
字1放到第6号小盒子,那么用指令表示就是这样

store 1 6

注意看这条指令,这里出现了两个数字:1和6,虽然都是数字,但这两个数字的含义是不同的,一个
代表数值,一个代表内存地址。

与写对应的是读,假设我们使用load指令,就像这样:

load r1 6
现在依然有一个问题,这条指令到底是数字6写入r1寄存器还是把第6号小盒子中装的数字写入r1寄存
器?

可以看到,数字在这里是有歧义的,它既可以表示数值也可以表示地址,为加以区分我们需要给数字
添加一个标识,比如对于前面加上$符号的就表示数值,否则就是地址:

store $1 6load r1 6

这样就不会有歧义了。

现在第6号内存中装入了数值1:

即地址6代表数字1:

地址6 -> 数字1

但“地址6”对人类来说太不友好了,人类更喜欢代号,也就是起名字,假设我们给“地址6”换一个名
字,叫做a,a代表的就是地址6,a中存储的值就是1,用人类在代数中直观的表示就是:
a = 1

就这样所谓的变量一词诞生了。

我们可以看到,从表面上看变量a等价于数值1,但背后还隐藏着一个重要的信息,那就是变量a代表
的数字1存储在第6号内存地址上,即变量a或者说符号a背后的含义是:

1. 表示数值1
2. 该数值存储在第6号内存地址

到现在为止第2个信息好像不太重要,先不用管它。

既然有变量a,就会有变量b,如果有这样一个表示:

b = a

把a的值给到b,这个赋值在内存中该怎么表示呢?

很简单,我们为变量b也找一个小盒子,假设变量b放在第2号小盒子上:
可以看到,我们完全copy了一份变量a的数据。

现在有了变量,接下来让我们升级一下,假设变量a不仅仅可以表示占用1个字节的数据,也可以表示
占用任意多内存的数据,就像这样:
现在变量a占据5个字节,足足占用了整个内存的一大半空间,此时如果我们依然想要表示b = a会怎
样呢?

如果你依然采用copy 的方法会发现我们的内存空间已经不够用了,因为整个内存大小就8字节,采用
copy的方法仅这两个变量代表的数据就将占据10字节。

怎么办呢?

不要忘了变量a背后可是有两个含义的,再让我们看一下:

1. 表示数值1
2. 该数值存储在第6号内存地址

重点看一下第2个含义,这个含义告诉我们什么呢?

它告诉我们不管一个变量占据多少内存空间,我们总可以通过它在内存中地址找到该数据,而内存地
址仅仅就是一个数字,这个数字和该数据占用空间的大小无关。
啊哈,现在变量的第2个含义终于排上用场了,如果我们想用变量b也去指代变量a,干嘛非要直接
copy一份数据呢?直接使用地址就不好了,就像这样:

变量a在内存中地址为3,因此变量b中我们可以仅仅存储3这个数字即可。

现在变量b就开始变得非常有趣了。

首先变量b没什么特殊的,只不过变量b存储的东西我们不可以按照数值来解释,而是必须按照地址来
解释。

当一个变量不仅仅可以用来保存数值也可以保存内存地址时,指针诞生了。

有很多资料仅仅说指针就是地址,但小风哥认为这是一种偷懒的解释,仅仅停留在汇编层面来理解,
有失偏颇,在高级语言中,指针首先是一个变量,只不过这个变量保存的恰好是地址而已,指针是内
存地址的更高一级抽象。

如果仅仅把指针理解为内存地址的话你就必须知道所谓的间接寻址。

这是什么意思呢?
如果使用汇编语言来加载变量a的值该怎么写呢?

load r1 1

想一想,这是不是会有问题,因此这样的话该指令会把数值3加载到r1寄存器中,然而我们想要把内
存地址1中保存的数值也解释为内存地址,这时必须为1再次添加一个标识,比如@:

load r1 @1

这时该指令会首先把内存地址1中保存的值读取出来发现是3,然后再次把3按照内存地址进行解释,3
指向的数据就是变了a:

地址1 -> 地址3 -> 数据a

这就是所谓的间接寻址,Indirect addressing,在汇编语言下你必须能意识到这一层间接寻址,因为
在汇编语言中是没有变量这个概念的。

然而高级语言则不同,这里有变量的概念,此时地址1代表变量b,但使用变量的一个好处就在于很多
情况下我们只需要关心其第一个含义,也就是说我们只需要关心变量b中保存了地址3,而不需要关心
变量b到底存储在哪里,这样使用变量b时我们就不需在大脑里想一圈间接寻址这一问题了,在程序员
的大脑里变量b直接指向数据a:

b -> 数据a

再来对比一下:

地址1 -> 地址3 -> 数据a # 汇编语言层面变量b -> 数据a # 高级语言层面

这就是为什么我说指针其实是内存地址的更高级抽象,这个抽象的目的就在于屏蔽间接寻址。

当变量不仅仅可以存值也可以存放地址时,一个全新的时代到来了:看似松散的内存在内部竟然可以
通过指针组织起来,同时这也让程序直接处理复杂的数据结构成为可能,比如就像下图这样:
这就是所谓的链表了。

指针这个概念首次出现在 PL/I 语言中,当时是为了增加链表处理能力,大家不要以为链表这种数据结


构是非常司空见惯的,这在1964年左右并不是一件容易的事情,关于链表你还可以参考这篇《彻底理
解链表》。

值得一提的是,Multics操作系统就是 PL/I 语言实现的,这也是第一个用高级语言实现的操作系统,


然而Multics操作系统在商业上并不成功,参与该项目的Ken Thompson, Dennis Ritchie后来决定自
己写一个更简单的,Unix以及C语言诞生了,或许是在开发Multic时见识到了PL/I语言中指针的威
力,C语言中也有指针的概念。
关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

程序员应如何理解标准库
记得当年在学了C/C++语言后一直有这样的疑惑,常用的printf函数以及C++中的cout函数到底是在哪
里实现的?

相信不止我有这个疑问,这篇文章就来回答这个问题。
C/C++语言是怎样实现的

详细有的同学一定觉得编程语言是十分神秘的,实际上不是这样的。

一门编程语言的本质是什么?

本质上一门语言不过就是一堆规则(rules)而已,就像汉语中的主谓宾一样,就像

if之后必须是一个括号(),这个括号中必须是一个bool表达式
while之后必须是一个括号(),这个括号中必须是一个bool表达式
continue语言必须出现在while语句中
等等

有的同学可能会问,为什么一定要有这堆规则呢,原来,只有有了规则之后编译器才能知道该怎么处
理我们写的程序。

编译器在遇到if后就知道,接下来紧跟的一定是一个左括号,之后一定是一个bool表达式,再之后一
定是一个右括号。

如果我们写的程序不满足这样的规则,结果就是编译器开始抱怨编译错误(compile error)。

让我们回到主题,实际上C/C++以及任何一门编程语言都是这样的一堆规则,对于C/C++来说,每年
都有一群来自被称为International Organization for Standardization (ISO)组织的人来制定C/C++语
言的规则,因此这群人坐下来讨论的这堆规则实际上就是一个标准,每一次讨论都会重新修改制定新
的标准并对外发布,这就是为什么C/C++有各种版本: C99, C11, C++03, C++11, C++14等等,其中
的数字其实就是来自指定标准的年份。

对外发布的标准中包含两部分内容:

1. C/C++支持哪些特性
2. C/C++API,程序员可以在他们的C/C++程序中直接调用这些API,这些API就被称为标准库
(Standard Library)

注意发布的标准中只定义了API,但是并不包括实现,肯定有同学会问,那么是谁来实现标准中定义
的API呢?

C/C++标准库的实现

至此,我们终于可以开始讨论标准库的实现问题了,实际上专门有一群人负责来根据发布的API来实
现标准库,程序员在实现除了一些比如数学计算之外,像文件读写、内存分配、线程创建等等相关的
API的实现,这些程序员必须借助相应操作系统提供的功能,那么这些程序是怎样使用操作系统提供
的功能的呢?答案就是借助系统调用(System Call),注意很多同学可能意识这一点,但是这一点相
当重要,那就是我们所写的代码有很多是需要依赖操作系统的,操作系统其实提供了很多功能,程序
员使用这些功能的方式其实就是借助系统调用(关于系统调用,博主在《操作系统:以程序员的角度》
中有详细的讲解)。

因此我们知道,其实每一个平台(操作系统)上都有自己特定的标准库实现,因为不同的操作系统提供
的功能是不同的,提供的系统调用也是不同的。

现在我们就可以回答最开始提出的问题了,原来printf和cout等等的代码是实现在标准库中,那么这
些标准库在哪里呢,我们的程序又是怎么用到标准库的呢?

标准库在哪里?怎样使用?

让我们用C语言写一个简单的Hello World程序:

#include <stdio.h>

int main() {
printf("hello world\n");
return 0;
}

然后编译、执行:

$ gcc helloworld.c -o hw
$ ./hw
hello world

我们可以看到程序正确运行了,但是问题来了,既然我们已经知道了printf其实是实现在了标准库
中,那么这个过程中哪里涉及到标准库了?

要回答这个问题,我们需要知道编译可执行程序中的一个过程:链接,关于链接博主在《彻底理解链
接器》系列文章中有详解的讲解。简单来说,链接的作用就是把程序依赖的各个库打包起来。要想看
到可执行程序依赖哪些库,我们借助一个叫ldd的工具:

$ ldd hw
linux-vdso.so.1 => (0x00007ffe075d3000)
libc.so.6 => /usr/lib64/libc.so.6 (0x00007fcd58b75000)
/lib64/ld-linux-x86-64.so.2 (0x000055c5dbea4000)

我们注意到可执行程序hw依赖一个叫做libc.so.6的库,位于/usr/lib64/libc.so.6,这个libc.so.6就是
我们苦苦寻找的标准库。

Linux中以.so结尾的文件被称为动态链接库,难怪我们看不到标准库的实现,原来都被实现好打包到
了动态链接库中了,关于动态链接库详见《彻底理解链接器》中第三篇。
现在我们知道了标准库是什么,在哪里,有的同学可能会问,那么我们是怎么用标准库的呢?

原来,编译器gcc在编译程序是默认情况下就自动链接了标准库,因为大家写程序免不了使用标准库
提供的API,因此gcc等编译器自动把标准库打包到了可执行程序了。

现在你应该明白了吧。

接下来我们就看看各个平台下标准库的实现。

Linux标准库实现

Linux下标准库的实现被称为GNU C Library,也被称为glibc,这个名字肯定有同学听过。

glibc是Linux平台中使用最为广泛的,然而有一段时间Linux发行版中的标准库多使用Libc,在经过了
数年的开发后glibc又开始优于了Libc,Linux发行版又开始转回了glibc,现在在Linux发行版上你会看
到磁盘上有一个libc.so.6的文件,这个文件其实就是现代版的glibc,只不过名字遵从了Linux发行版
的习惯。

关于C++的标准库实现在了 libstdc++,你在Linux平台中使用ldd工具就能看到这个标准库。

Windows标准库实现

Windows标准库实现是和微软的官方编译器Visual Studio绑定在一起的,该标准库曾被称为C/C++
Run-time Library (CRT)

从Windows95开始,微软以MSVCRT+版本号.DLL的命名实行来发布,到了1997年,将其简化为了
MSVCRT.DLL。

从Visual Studio 2015之后,Windows中C/C++标准库被称为了Universal C Runtime Library


(Universal CRT,简称UCRT),即UCRTBASE.DLL,此后Windows标准库开始同Win10一起发布。

总结

一个看似简单的问题实际上往往并不那么简单,在这篇文章中,我们从一个简单的问题开始不断挖掘
背后涉及到的方方面面,希望这篇文章能帮你彻底理解标准库。

关注作者
也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

程序员应如何理解多态?
相信很多同学在学习C/C++后都有这样的疑问,#include这句话到底是怎么意思?这句话的背后隐含
了什么?我们常用的stdio.h存放在了哪里?

这篇文章就来解答这个问题。

谁来处理头文件
有上述疑问的同学很可能是因为不熟悉一个叫预编译器(preprocessor)的东西。

让我们简单的了解一下可执行程序的生成过程。

程序员写的大家都可读的代码是不能被CPU直接执行的,CPU可以执行的代码是二进制机器指令,因
此一定有某个过程将程序员写的程序转换为了机器指令,这就是编译器。

以上大部分同学应该都知道,但是你知道编译器在将代码翻译成机器指令前其实还有一个步骤吗?这
个步骤就是预编译。

那么预编译都用来做什么呢?请注意,接下来是重点:

预编译的工作非常简单,预编译器找到源文件中#include指定的文件,然后copy这些文件的内容并
粘贴到#include这一行所在的位置。

假设在源文件a.c的第一行有一句#include <stdio.h>,那么预编译器怎么处理?

预编译找到stdio.h,把stdio.h的内容粘贴到a.c的第一行中。

是不是很简单,完成这一过程后才是编译器的任务。

因此我们知道,原来#include其实是告诉预编译器把指定的头文件内容粘贴到当前include所在的位
置,也就是进行文本替换。
头文件是不会被编译的

从上一节中我们知道头文件原来是被预编译器处理的,编译器在编译源文件时拿到的是已经被预编译
器处理过后的源文件,因此头文件是不会被编译器直接处理的。

这一点要能意识到。

#include可以被放到源文件的任意位置

实际上#include可以出现在代码中的任意一行,只不过我们习惯了在开头使用#include,这是因为变
量在声明之前是不能被使用的。

但是我们已经知道了#include其实就是告诉预编译器做一个简单的文本替换,因此任何需要进行文本
替换的需求其实都可以通过#include来完成的,记得博主很早在阅读C代码看到#include用作文本替
换时大吃一惊,原来#include还可以这样使用,类似这样:

typedef enum {
#include <test.h>
enum1,
enum2,
} test_enum;

实际上就是test.h中包含了一系列可以放到enum中的名字而已,预编译器在处理时会把test.h中的内
容在这一行展开,这样编译器拿到的就是完整的enum定义了。

如何查看预编译器处理后的文件

一些好奇心强的同学可能会问那我们能不能看到预编译器处理后的文件吗?

答案是可以的。

假设使用的编译器是gcc,那么使用-E选项就可以,-E选项告诉编译器在处理源文件时不要编译、不要
汇编和链接,仅预处理。

$ gcc -E test.c

使用上述命令就可以看到预处理后的文件是什么样子的。

其它编译器肯定也能找到类似的支持。
两种使用头文件的方式

你一定注意到了,其实#include有两种写法,一种是#include<>;另一种是#include “”,即:

#include <code.h>
#include "code.h"

那么这两种使用方法有什么区别吗?

注意,知道这两种用法背后的含义对于程序员来说是非常重要的。

预编译器要想处理头文件首先必须要能找到这个头文件。

如果一个头文件放到了<>中,那么预编译器会在系统头文件所在的路径下开始找,在Linux下这个路
径是/usr/include,让我们来看一下/usr/include这个文件夹下都有什么:

我们可以看到这里有很多头文件,注意划红线的位置,原来我们常用的#include<stdio.h>就放在了
这里,现在终于解答一个困扰了我们很久的问题。

实际上这里存放的就是标准库头文件,关于标准库参见《程序员应如何理解标准库》。

接下来就简单了,如果头文件被放到了双引号“”中呢?

很显然只不过就是预编译器搜索路径不再是系统头文件所在路径了,而是以源文件所在位置开始查
找,当然不同的编译器策略可能稍有差别。

当在这些路径中找不到include的头文件时就会抛出错误“fatal error: ***.h: No such file or


directory”,这是程序员经常遇到的错误,现在你应该知道怎么排查这类问题了吧。

为什么要使用头文件
最后来回答一下为什么的问题,是啊,程序员为什么要使用头文件这种东西呢?

还记得开始学编程时用的经典的HelloWorld程序吗?

#include <stdio.h>

int main() {
printf("hello world\n");
return 0;
}

在这段简单的代码中实际上如果我们不使用printf函数打印东西的话根本就不需要stdio.h,那么程序
就变成了这样:

int main() {
return 0;
}

注意该代码不依赖任何头文件,不要怀疑,该代码可以正确的编译运行。

如果程序员愿意的话可以把项目所有实现代码都放到这个文件中,就像这样:

void funA() {
...
}
void funB() {
...
}
int main() {
...
funA();
funB();
}

该程序不依赖任何头文件,所有实现代码都放到了一个源文件,而对于现在稍微有规模的软件项目其
代码量都在十几万、甚至上百万,你能想象一个包含十几万行代码的源文件是一种怎样的场景吗?

这样的代码有没有可能写出呢?

答案是有的,只要这个项目的程序员对于所有使用到的轮子都从头到尾重复打造一遍,比如自己实现
用到的标准库中的函数,比如printf,自己实现各种数据结构等等等等。

而且这样的项目有没有办法维护呢?

答案是有的,重赏之下必有勇夫,只要开出百万年薪必然有人入坑。
因此,我们发现这样写代码不但要重复造轮子还极其难以维护。

所以现在的软件工程一项原则就是复用,能用其它人的代码绝不会自己重复写一份。

那么问题来了,我们该怎样使用其它人写好的代码呢?

我们该调用哪些函数,这些函数的返回值是什么?参数是什么?

头文件帮程序员解决了上述问题。

头文件里仔仔细细的写好了该模块有哪些函数可供使用者调用、返回值是什么、参数是什么,但头文
件中并不会包含实现,这是因为C/C++语言不要求函数的声明和实现必须呆在同一个地方。

因此你会看到,头文件的作用其实是和我们常用的说明书没什么区别。

现在问题就简单了,我们再也不需要一个包含几十万代码的源文件了,程序员可以将其模块化,各个
团队负责一个模块,每个模块会编写一些头文件供其它人调用,同时每个模块只写一次,其它团队有
需要可以直接使用,最后再把各个模块组合起来,这样大家各司其职又能最大程度实现代码复用。

最后值得注意的一点就是,头文件其实是让编译器知道该怎样生成调用函数的机器指令,而真正将相
关代码打包到可执行程序的是链接器,因此作为程序员不仅需要指定用哪些头文件,还要指定头文件
中函数的实现代码,也就是程序员常说的库在哪里。

总结

现在大家应该对头文件有一个全面的认知了吧,原来include只是告诉预编译器在当前位置展开头文
件,同时我们也知道了两种include的使用方法及其区别,最后我们了解了为什么要发明头文件这种
技术,希望这篇文章能帮你彻底理解头文件。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
程序员应如何理解多态?

面向对象编程领域有个非常重要的概念,那就是多态,但是你真的理解这到底是什么意思吗?程序员
该如何理解多态?

英文中的多态

多态一词其英文为“polymorphism”,在讲解多态之前让我们来分析一下这个单词。

这个单词其实包含了两部分,一部分是poly;另一部分是morph,这两个词在希腊语中是很多词的词
根。

1. poly,在希腊语中原指许多的意思,包含poly这个词根的有:polygon,即多边形;polygolt,
即多语言。
2. morph,原指形态,包含morph这个词根的有:morphology,即形态学;morpheus,即希腊
神话中的梦神,可以幻化成任何形态,值得一提的是,电影《黑客帝国》找到Neo的男主角之一
就叫morpheus,中文译作了墨菲斯。

现在这两个单词拼凑在一起的polymorphism意思就直白多了,在编程语言当中,多态是指用相同的
接口去表示不同的实现。
加一点代码:为什么使用多态

让我们用一点代码来说明问题,假设代码中有三个class: 自行车(Bicycle)、汽车(Car)和卡车(Truck),
这三个class分别有这样三个实现:Ride()、Run()、Launch(),实际上都是让它们发动起来,如果没有
多态的话我们该怎样开动它们呢?(注意这里没有采用特定语言,因为多态是一个通用的概念)

// 实现
Bicycle bicyle = new Bicycle();
Car car = new Car();
Truck truck = new Truck();

// 使用部分
bicyle.Ride();
car.Run();
truck.Launch();

注意,以上代码包含了两部分:一部分是类的实现;另一部分是类的使用,意识到这一点对于理解多
态非常关键。

现在我们已经知道了使用类的代码包含两部分,在这样的代码实现中如果Bicyle的接口修改了,那么
使用部分的代码同样需要修改,这是程序员所不想看到的。如果有多态会怎样呢?

实际上自行车(Bicycle)、汽车(Car)和卡车(Truck)都是交通工具(Vehicle),把它们发动起来都是让它们
Run起来,因此,如果有多态的话我们可以这样写代码:

// 实现部分
List<Vehicle> vehicles = { new Bicycle(),
new Car(),
new Truck() };
// 使用部分
for (v : vechicles)
v.Run();

怎么样,代码是不是一下就简洁多了,最棒的是不管实现部分代码怎么改动都不会影响到使用部分的
代码,实际上你可以往vehicles中增加任意多对象都不会影响到使用部分,这就是设计模式当中所谓
的"只针对抽象编程,而不是针对实现编程"。

因此我们可以看到,尽管自行车、汽车、卡车是不同的东西,但是当我们将其抽象为交通工具后就可
以一视同仁的对待它们,这就是多态。

现在你应该理解什么是多态以及为什么需要多态了吧。

当然为了实现多态,类的定义要稍稍改动一下:
class Vehicle { // 新增抽象类
void Run() {}
}

class Bicycle: Vehicle {


void Run() {......} // Ride修改为Run
}
class Car: Vehicle{
voie Run() {......} // 无需改动
}

class Truck: Vehicle {


void Run() {......} // Launch修改为Run
}

实际上如果你仔细想想的话整数和浮点数就是一种多态,因为你可以把一个整数和一个浮点数相加,
也就是以相同的方式使用它们,尽管整数和浮点数是两个不同的数据类型。

有趣的类比

接下来使用一个类比来加深大家对多态的理解。

假如美国总统想使用多态的话他该怎么用呢?

首先,美国总统有许多幕僚:五角大楼部长(Military Advisers)、司法部部长(Legal Advisers)、能源


部部长(Energy)、医疗健康部长(Medical Advisers)等等。

在编程领域中实现与使用应该尽可能隔离,就像你不能指望总统精通每一件事一样,美国总统不是这
些领域的专家,总统不熟悉的东西很多,但他知道一件事:如何运转一个国家,如果没有多态的话总
统该怎样运转一个国家呢?

class President {
void RunCountry() {
// 总统根据每个人的身份来告诉它们该做些什么

// 五角大楼部长Tom
tom.IncreaseTroopNumbers();
tom.ImproveSecurity();

// 能源部部长Jerry
jerry.FindOil();
jerry.buildMoreOilShip();

// 医疗健康部长John
John.IncreasePremiums();
John.AddPreexistingConditions();
}
}

在这里我们可以看到,总统正在事实亲力亲为:总统即需要了解增加军队数量又需要知道去哪里开采
更多石油,这就意味着如果中东政策改变后总统必须修改其下发的命令,同样五角大楼部长Tom类也
要修改。实际上我们想要的是只改变五角大楼部长Tom类而不应该修改总统类,因为总统是很忙的,
总统不应该关心这些具体细节,总统想要的只是发出命令,剩下的就交给这些部长了,这样总统就有
更多的时间去打高尔夫啦 :)

为了让总统有时间去打高尔夫,使用多态是一个很好的办法。

多态本质上就是让我们实现一个抽象类或者更具体的就是给出一组通用的接口(common interface),
也就是让各个部长Tom、Jerry、John实现一个接口,姑且就叫“Advise()”,这样总统的任务就简单多
了:

class President {
void RunCountry(Ministers ministers) {
for (m : ministers)
m.Advise();
}
}

因此我们可以看到总统实际上不需要关心细节,所有细节都交给各个部长;总统需要做的就是找到各
个部长然后听取它们的意见即可。

总结

在这里我们详细讲解了面向对象编程领域中一个非常重要的概念,多态。

多态可以让程序员针对抽象而不是具体实现来编程,这样的代码会有更好的可扩展性。

当然为了使用多态你需要进行抽象,也就是定义一个接口让不同的对象去实现,这样从这个接口的角
度看各个对象就一样了,因此可以以一致的方式来使用这些不同类型的对象,这就是多态的威力。

今天就到这了,希望这篇文章能帮到你。

关注作者
也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

为什么抽象在计算机科学中如此重要?
All problems in computer science can be solved by another level of indirection.

没有抽象的世界

想象这样一种场景,如果我们的语言中没有代词这种形式,那么我们想表达“张三是个好人“该怎么说
呢?可能是这样的:
”你还记得我说过的人吧,穿着邋邋遢遢的,公司在中关村,整天背着个双肩包,写代码的,天天
996,这个人是个好人“,看到了吧,在没有代词的情况下我们想表达一件事是非常困难的,因为我们
需要具体的描述清楚所有细节,但是有了”张三“这种抽象后,一切都简单了,我们只需要针对张三这
种抽象进行交流,再也不需要针对一堆细节进行交流了,抽象大大增强了表现力,这就是抽象的力
量。

接下来回到计算机世界。

计算机使用层面

我们在使用计算机时其实抽象就在发挥作用,在Word中编辑文档时我们不会去考虑CPU是如何处理
这些字符的,这些字符是如何被保存到磁盘的。在浏览网页时我们不需要关心网页中的数据是如何在
网络中传输的、浏览器是怎样把这些数据适当的渲染出来的,我们需要做的仅仅就是在Word中简单
的输入字符,用鼠标或者手指滑动网页。

因此只要在使用计算机,那么抽象就在发挥作用,只不过是我们没有意识到而已,而之所以我们没有
意识到是因为抽象工作的太好了。

编程语言层面

程序员也可以从抽象中获得极大好处,因为软件是复杂的,但程序员可以通过抽象来控制复杂度,方
法就是抽象。

比如一个好的设计就是对某项功能抽象出一组简单的API,这样其它程序员在使用这个模块时只需要
关注这几个简单的API而不是一堆内部实现细节。

不同的编程语言提供了不同的机制来让程序员实现这种抽象。

比如面向对象语言(OOP)的一大优势就是让程序员方便进行抽象,这样类的使用者就无需关心类的实
现了,更不用提OOP中的多态、抽象类等,有了这些程序员可以只针对抽象而不是具体实现进行编
程,这样的程序会有更好的可扩展性,也能更好的应对需求的变化。

系统设计层面

计算机从本质上将就是在抽象的基础上建立起来的。计算机科学中的一大主题其实就是在不同层面提
供抽象表示从而对外屏蔽实现细节。
对于CPU来说,其对外提供的是一堆指令集,程序员只需要使用这些指令就可以指挥CPU工作了,这
样就无需从细节上知道CPU是如何取出指令、执行指令的。

在操作系统层面,我们将I/O设备抽象成了文件、把程序的运行抽象成了进程、把程序运行时占用的
内存抽象成了虚拟内存、又把进程和进程运行以来的环境抽象成了容器、最后把所有的一切包括操作
系统、进程、CPU、内存、磁盘、网络抽象成了虚拟机。现在虚拟机技术是云计算的基石,实际上这
种技术在上世纪60年代就出现了,并在当前火热的云计算中大放异彩。

难怪计算机科学中有一句名言,“计算机科学中没有什么是不能通过增加一层抽象解决的”,当然后面
还有半句,“除了存在太多抽象层这个问题”。

总结

抽象的目的其实就是通过移除不必要的信息从而减少复杂度,因此抽象可以让我们更加关注重点。

在这里没有用太多编程语言中的示例来讲解,其实这也是一种抽象,那就是学习编程也好其它领域也
罢,最好理解“顶层原理”,这个是通用的。个人一直有这样一种观点,那就是学计算机不是学一堆语
言语法,那不过就是一些文法规则的实现细节罢了,编程高手之所以是高手不在于比别人有多了解一
门编程语言有什么用法,最重要的还是这所说的“顶层原理”,那么顶层原理在哪里呢?就存在于我们
常说的基础中:操作系统、编译原理、网络、数据结构算法等。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
彻底理解堆

在计算机科学中堆是一种很有趣的数据结构,实际上通常用数组来存储堆中的元素,但是我们却可以
把数组中元素视为树,如图所示:

这就是一个普通的数组,但是我们可以将其看做如下图所示的树:
这是怎么做到的呢?

原来虽然我们是在数组中存储的堆元素,但是这里面有一条隐藏的规律,如果你仔细看上图就会发
现:

每一个左子树节点的下标是父节点的2倍
每一个右子树节点的下标是父节点的2倍再加1

也就是说在数组中实际上隐藏了上面的这两条规律,如图所示:

堆这种数据结构最棒的地方在于我们无需像树一样存储左右子树的信息,而只需要通过下标运算就可
以轻松的找到一个节点的左子树节点、右子树节点以及父节点,如下所示,相对于树这种数据结构来
说堆更加节省内存。
int parent(int i){ // 计算给定下标的父节点
return i/2;
}
int left(int i){ // 计算给定下标的左子树节点
return 2*i;
}
int right(int i){ // 计算给定下标的右子树节点
return 2*i+1;
}

除了上述数组下标上的规律以外,你还会发现堆中的每一个节点的值都比左右子树节点大,这被称为
大根堆,即对于大根堆来说以下一定成立:

array[i] > array[left(i)] && array[i] > array[right(i)] == true

相应的如果堆中每个一节点的值都比左右子树节点的值小,那么这被称为小根堆,即对于小根堆来说
以下一定成立:

array[i] < array[left(i)] && array[i] < array[right(i)] == true

以上就是堆这种数据结构的全部内容了。

那么接下来的问题就是,给定一个数组,我们该如何将数组中的值调整成一个堆呢?

如何在给定数组上创建堆

在这里我们以大根堆为例来讲解如何在给定数组上创建一个堆。

给定数组的初始状态如下图a所示,从图中我们看到除array[2]之外其它所有节点都满足大根堆的要求
了,接下来我们要做的就是把array[2]也调整成为大根堆,那么该怎么调整呢?
很简单,我们只需要将array[2]和其左右子树节点进行比较,最大的那个和array[2]进行交换,如图b
所示,array[2]和其左子树array[4]以及右子树array[5]中最大的是array[4],这样array[2]和array[4]
进行交换,这样array[2]就满足大根堆的要求了,如图b所示;
但此时array[4]不满足要求,怎么办呢?还是重复上面的过程,在array[4]的左子树和右子树中选出一
个最大的和array[4]交换,最终我们来到了图c,此时所有元素都满足了堆的要求,这个过程就好比石
子在水中下沉,一些资料中将这个过程称形象的称为“shift down”。
现在我们知道了假设堆中有一个元素i不满足大根堆的要求,那么该如何调整呢:

void keep_max_heap(int i){


int l = left(i);
int r = right(i);
int larget = i;
if (l < heap_size && array[l] > array[i])
larget = l;
if (r < heap_size && array[r] > array[larget])
larget = r;
if (larget != i){
swap(array[larget], array[i]);
max_heap(larget);
}
}

以上代码即keep_max_heap函数就是刚才讲解调整节点的过程,该过程的时间复杂度为O(logn)。

但是到目前为止我们依然不知道该如何在给定的数组上创建堆,不要着急,我们首先来观察一下给定
的数组的初始状态,如图所示:
实际上堆是一颗完全二叉树,那么这对于我们来说有什么用呢?这个性质非常有用,这个性质告诉我
们要想将一个数组转换为堆,我们只需要从第一个非叶子节点开始调整即可。

那么第一个非叶子节点在哪里呢?假设堆的大小为heap_size,那么第一个非叶子节点就是:

heap_size / 2;

可这是为什么呢?原因很简单,因为第一个非叶子节点总是最后一个节点的父节点,因此第一个非叶
子节点就是:

parent(heap_size) == heap_size / 2

有了这些准备知识就可以将数组转为堆了,我们只需要依次在第一个非叶子节点到第二个节点上调用
一下keep_max_heap就可以了:

void build_max_heap() {
for (int i = heap_size/2; i>=1; i--)
keep_max_heap(i);
}

这样,一个堆就建成了。
增加堆节点以及删除堆节点

对于堆这种数据结构来说除了在给定数组上创建出一个堆之外,还需要支持增加节点以及删除节点的
操作,在这里我们依然以大根堆为例来讲解,首先来看删除堆节点。

删除节点

删除堆中的一个节点实际用到的正是keep_max_heap这个过程,假设删除的是节点i,那么我只需要
将节点i和最后一个元素交换,并且在节点i上调用keep_max_heep函数就可以了:

void delete_heep_node(int i) {
swap(array[i], array[heap_size]);
--heap_size;
keep_max_heap(i);
}

注意在该过程中不要忘了将堆的大小减一。

增加节点

增加堆中的一个节点相对容易,如图所示,假设堆中新增了一个节点16,那么该如何位置堆的性质
呢?很简单,我们只需要将16和其父节点进行比较,如果不符合要求就交换,并重复该过程直到根节
点为止,这个过程就好比水中的气泡上浮,有的资料也将这个过程形象的称为“shift up”,该过程的时
间复杂度为O(logn)。

用代码表示就是如下add_heap_node函数:
void add_heap_node(int i){
if (i == 0)
return;
int p = parent(i);
if(array[i] > array[p]) {
swap(array[i], array[p]);
add_heap_node(p);
}
}

至此,关于堆的性质、堆的创建以及增删就讲解完毕了,接下来我们看一下堆这种数据结构都可以用
来做些什么。

堆的应用

在这一节中我们介绍三种堆常见的应用场景。

排序

有的同学可能会有疑问,堆这种数据结构该如何来排序呢?

让我们来仔细想一想,对于大根堆来说其性质就是所有节点的值都比其左子树节点和右子树节点的值
要大,那么我们很容易得出以下结论,对于大根堆来说:

堆中的第一个元素就是所有元素的最大值。
有了这样一个结论就可以将堆应用在排序上了:

1. 将大根堆中的第一个元素和最后一个元素交换
2. 堆大小减一
3. 在第一个元素上调用keep_max_heap维持大根堆的性质

这个过程能进行排序是很显然的,实际上我们就是不断的将数组中的最大值放到数组最后一个位置,
次大值放到最大值的前一个位置,利用的就是大根堆的第一个元素是数组中所有元素最大值这个性
质。

用代码表示就如下所示:
void heap_sort(){
build_max_heap();
for(int i=heap_size-1;i>=1;i--){
swap(array[0],array[i]);
--heap_size;
keep_max_heap(0);
}
}

执行完heap_sort函数后array中的值就是有序的了,堆排序的时间复杂度为O(nlogn)。

求最大(最小)的K个数

对于给定数组如何求出数组中最大的或者最小的K个数,有的同学可能觉得非常简单,不就是排个序
然后就得到最大的或最小的K个数了吗,我们知道,排序的时间复杂度为O(nlogn),那么有没有什么
更快的方法吗?

答案是肯定的,堆可以来解决这个问题,在这里我们以求数组中最小的K个值为例。

对于给定的数组,我们可以将数组中的前k个数建成一个大根堆,注意是大根堆,建成大根堆后
array[0]就是这k个数中的最大值;

接下来我们依次将数组中K+1之后的元素依次和array[0]进行比较:

1. 如果比array[0]大,那么我们知道该元素一定不属于最小的K个数;
2. 如果比array[0]小,那么我们知道array[0]就肯定不属于最小的K个数了,这时我们需要将该元素
和array[0]进行交换,并在位置0上调用keep_max_heap函数维护大根堆的性质

这样比较完后堆中的所有元素就是数组中最小的k个数,整个过程如下所示:

void get_maxk(int k) {
heap_size = k; // 设置堆大小为k
build_max_heap(); // 创建大小为k的堆
for(int i=k;i<array.size();i++){
if(array[i] >= array[0]) // 和堆顶元素进行比较,小于堆顶则处理
continue;
array[0] = array[i];
keep_max_heap(0);
}
}

那么对于求数组中最大的k个数呢,显然我们应该建立小根堆并进行同样的处理。
注意使用堆来求解数组中最小K个元素的时间复杂度为O(nlogk),显然k<n,那么我们的算法优于排序
算法。

定时器是如何实现的

我们要讲解的堆的最后一个应用是定时器,timer。

定时器相信有的同学已经使用过了,定义一个timer变量,传入等待时间以及一个回调函数,到时后
自动调用我们传入的回调函数,是不是很神奇,那么你有没有好奇过定时器到底是怎么实现的呢?

我们先来看看定时器中都有什么,定时器中有两种东西:

一个是我们传入的时延,比如传入2那就是等待2秒钟;传入10就是等待10秒钟;
另一个是我们传入的回调函数,当定时器到时之后调用回调函数。

因此我们要做的就是在用户指定的时间之后调用回调函数,就这么简单;为做到这一点,显然我们必
须知道什么时间该调用用户传入的回调函数。

最简单的一种实现方式是链表,我们将用户定义的定时器用链表管理起来,并按照等待时间大小降序
链接,这样我们不断检查链表中第一个定时器的时间,如果到时后则调用其回调函数并将其从链表中
删除。

链表的这种实现方式比较简单,但是有一个缺点,那就是我们必须保持链表的有序性,在这种情况下
向链表中新增一个定时器就需要遍历一边链表,因此时间复杂度为O(n),如果定时器很多就会有性能
问题。

那么该怎样改进呢?

初看起来,堆这种数据结构和定时器八竿子打不着,但是如果你仔细想一想定时器的链表实现就会看
到,我们实际上要找的仅仅就是时延最短的那一个定时器,链表之所以有序就是为此目的服务的,那
么要得到一个数组中的最小值我们一定要让数组有序才可以吗?

实际上我们无需维护数组的有序就可以轻松得到数组的最小值,答案就是刚学过的小根堆。

只要我们将所有的定时器维护成为一个小根堆,那么我们就可以很简单的获取时延最小的那个定时器
(堆顶),同时向堆中新增定时器无需遍历整个数组,其时间复杂度为O(logn),比起链表的实现要好很
多。

首先我们看一下定时器的定义:

typedef void (*func)(void* d);


class timer
{
public:
timer(int delay, void* d, func cb) {
expire = time(NULL) + delay; // 计算定时器触发时间
data = d;
f = cb;
}
~timer(){}

time_t expire; // 定时器触发时间


void* data; // timer中所带的数据
func f; // 操作数据的回调函数
int i; // 在堆中的位置
};

该定时器的定义非常简单,用户需要传入时延,回调函数以及回调函数的参数,注意在定时器内部我
们记录的不是时延,而是将时延和当前的时间进行加和从而得到了触发定时器的时间,这样在处理定
时器时只需要简单的比较当前时间和定时器触发时间的大小就可以了,同时使用i来记录该timer在堆
中的位置。

至于堆我们简单的使用vector而不是普通的数组,这样数组的扩增问题就要交给STL了 :)

注意在这里定时器是从无到有一个一个加入到堆的,因此在向堆中加入定时器时就开始维护堆的性
质,如下即为向堆中增加定时器add_timer函数:

void add_timer(timer* t){


if (heap_size == timers.size()){
timers.push_back(t);
} else {
timers[heap_size]=t;
}
t->i = heap_size;
++heap_size;
add_heap_node(heap_size-1);
}

当我们删除定时器节点时同样简单,就是堆的节点删除操作:

void del_timer(timer* t){


if (t == NULL || heap_size == 0)
return;
int pos = t->i;
swap_pos(timers[pos], timers[heap_size-1]); // 注意不要忘了交换定时器下标
swap(timers[pos], timers[heap_size-1]);
--heap_size;
keep_min_heap(pos); // 该函数实现请参见大根堆的keep_max_heap
}
当我可以向堆中增加删除定时器节点后就可以开始不断检测堆中是否有定时器超时了:

void run(){
while(heap_size) {
if (time(NULL) < timers[0]->expire) // 注意这里会导致CPU占用过高
continue; // 真正使用时应该调用相应函数挂起等待
if (timers[0]->f)
timers[0]->f(timers[0]->data); // 调用用户回调函数
del_timer(timers[0]);
}
}

注意在这种简单的实现方式下,当堆中没有定时器超时时会存在while循环的空转问题从而导致CPU
使用率上升,在真正使用时应该调用相关的函数挂起等待。

总结

堆是一种性质非常优良的数据结构,在计算机科学中有着非常广泛的应用,希望大家能通过这篇文章
掌握这种数据结构。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
为什么数据结构与算法如此重要

我想这是许多人的疑问,是啊,为什么数据结构与算法很重要呢?

实际上之所以有很多问题我们不明白不理解,是因为我们所处的角度的问题,在这里如果你站在老板
的角度上思考问题一切就简单了。

升职加薪

老板开公司的目的是为了什么?钱啊,数据结构与算法有什么用呢,能为老板省钱啊,能为老板省钱
的技术你说重要不重要。同样一个程序,你的运行起来需要10台服务器,另一个人的只需要2台,如
果你是老板的话该为谁升职加薪呢?

数据结构与算法就是这样一种能为老板多赚钱(能省钱也就是多赚钱)的技术。

那站在程序员的角度呢?

很多初学者甚至有工作经验的程序员避免学习数据结构与算法,一方面因为其固有的复杂性;另一方
面他们觉得数据结构和算法在实际工作中根本就没什么用嘛,我们用一个最简单的游戏来开始本篇的
讨论。

有一个猜数的游戏,玩家说出一个数字,主持人会告诉玩家这个数字是大了还是小了,看谁猜的次数
最少,最好的办法是什么呢?很显然:
假设抛出的数字是100,主持人告诉你猜大了,那么接下来应该猜50;如果主持人告诉你猜小了,那
么接下来应该是75;如果又猜大了,那么接下来应该是62,如果又小了,那么应该是56,
Congratulations,恭喜你猜到了,实际上你在用二分查找的策略。

这个简单的示例应该能让你意识到现实生活中算法的重要性,如果你仅仅认为数据结构与算法只在面
试时才有用那就大错特错了。

因此,简单总结一下数据结构与算法的重要性:

1. 通过面试,找到心仪的offer,进入大厂
2. 使用数据结构与算法高效解决面临的复杂问题,为老板省钱从而升职加薪

进入顶尖大厂

许多人会问为什么很多大厂面试第一关就是算法而不是编程语言、框架等等。

现实中公司尤其是拥有大规模用户群的的大厂会面临很多复杂且有挑战的问题,这些问题都是由像
hash表、树、图以及各种算法来解决的,比起其它方面,面试官其实更看中候选者是否能运用数据结
构与算法来高效解决给定问题。

来自Google、微软、Facebook、Amazon等公司的程序员可以获得更高的薪水,为什么?在这些公
司中编写代码仅仅占据了大概20%-30%的时间,那么剩下的时间都在干嘛呢?剩下的时间是在寻找或
设计更高效的算法以节省公司的资源(主要是服务器),为什么这些人主要在做这件事呢?因为这些公
司拥有全世界最有的用户,Facebook、YouTube、Twitter、 Instagram、 GoogleMaps等其用户都
有数十亿之多,这背后需要的计算资源可想而知,那么这些资源可都是用钱堆出来的,你用高效的算
法为公司节省哪怕0.1%的计算资源,换算成钱的话可能都有数亿美金了,这就是算法的重要之处。

假设你在Facebook工作并且想出来一个很牛的算法,使得计算速度由O(N^2)提升到了O(NLogN),假
设这里的问题规模N为一亿(考虑到Facebook的用户规模这已经很保守了),那么O(NLogN)大概为8
亿,而O(N^2)是100000000亿,那么你的算法从效率上提升了大概一千万倍,想一想这种效率上的
提升能为公司节约多少成本。

现在你应该知道为什么世界上顶尖公司都喜欢雇佣那些聪明家伙了吧,就是因为他们在代码效率的一
点提升就能为公司节约极大的成本。

我们的现实世界充满了各种富有挑战的问题,有些问题甚至依然没有高效的解法,深度理解这些问
题,哪怕你的解法能提升一点点效率,有了这样的能力想进大厂我想不会有哪个老板会傻到拒绝你
吧。

Data structure and algorithms help in understanding the nature of the problem at a
deeper level and thereby a better understanding of the world.
关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。

彻底理解链表
大家好,我是小风哥。

链表是计算机科学中极其经典的一种数据结构,那么作为程序员我们该怎样理解链表呢?
货车 VS 火车

作为两大运输工具,货车以及火车想必大家都很熟悉,但你想没想过这两者的区别?

我们首先来看货车。

对于货车的话,如果有一堆货物想用货车来运输,那么你首先要考虑的是什么呢?

答案显而易见,载重。因为货车的载重是有限的,不可变的,你没办法把货车拆了临时装上一截,如
果货物的重量是10吨,那么想用货车运输则必须找一个载重不小于10吨的,否则你没有办法拉走。

假设你现在选好一辆货车,但不巧的是,当你把东西都装到车上以后发现客户又额外追加一些订单,
因此还有一些货物需要一并运走,但由于货车的载重有限,这是你该怎么办呢?

没有办法,你不得不重新去找一辆载重更多的货车,我们假设载重更大的车位B车,原来的车为A车,
这是你需要把A车的货物搬运到B车,然后再把剩下的装到B车上拉走。

接着我们再来看火车。
对于火车的话就很有趣了,与货车不同的是,对于火车来说你不需要考虑载重的问题,你可以认为火
车的载重是无限的,如果有更多货物要运输该怎么办呢?很简单,找更多车厢过来挂接到火车上就可
以了,你根本就不需要像货车那样很麻烦的需要把A车的货物搬运到B车。

这就是火车的妙用。

现在你应该看出来了吗,货车就好比数组,火车就好比链表。

数据结构与语言无关

注意这里说的数组以及链表特指用来组织数据的结构,与任何语言无关,你可以在C/C++中使用数组
或链表,当然也可以在Java、Python等语言中使用数组或者链表。

记住,数据结构是一种组织数据的方式,和语言无关。

无论你用什么语言来使用数组或者链表,其在底层的表示都是一样的,不同点仅仅在于外观,所谓外
观就是你看到的样子。
在C/C++中可能需要你自己用指针来等来实现链表、在Java、Python等语言中可能只需要使用自带的
链表就好了,这就是所谓的外观,而之所以外观不同在于抽象层次有高低之分,C/C++抽象层次更低
一些,因此你能看到更多细节,而Java、Python等抽象层次更高一点因此你只能知道一个叫做链表的
东西,拿过来用就好。

但抽象并不是魔法,总要有人来实现细节,要想真正理解链表,你需要知道其底层的实现,数据结
构,数据结构,既然是组织数据的结构,那么数据存放在哪里呢?

很显然,内存,数据结构在这一层级就和语言无关了,因此你能更清楚的看到本质。

接下来我们看看数组以及链表在内存中是怎么表示的。

内存,最重要的是内存

首先我们来看数组,假设你要装载的货物是16个字节,那么如果你想用数组来装载数据的话该怎么办
呢?

很显然,你需要从内存中申请16个字节,而且是连续的字节,就像卡车一样,一上来容量就固定了。
这里一个小方格代表一个4字节。

这时如果你想在容量16个字节的数组中再装入8字节数据该怎么办?没办法,原来的数组就不再可用
了,你需要再次从内存中申请24字节,并且把原来的数据copy过来,此后再把剩余的8字节装入数
组。

就像这样:
图:带copy以及装入额外20字节。

接下来,我们看链表,依然假设需要装载的货物是40个字节。

链表与数组截然不同的地方在于就像火车一样,你无须一次性申请40字节的空间,而是一节车厢一节
车厢的申请,而且更棒的是这些车厢也不需要和数组一样是连续的,就像这样:
你可以看到,这些车厢可以很松散的分布在内存的各个角落中,当你装满16字节想要再装8字节怎么
办?很简单,只需要再从内存中找2个车厢挂接上去就好了,就像这样:
原来的16字节根本就无需改动。

从这里也能看出来,数组是静态的,创建好后就不能改动;而链表是动态的,你可以根据需求来动态
的增加或者减少链表的长度。

接下来有同学就会问了,既然链表的车厢可以离散的分布在内存中的各个角落,那么你怎么知道一节
车厢到底属于哪个火车(链表)呢?你怎么能知道当前车厢的后一截车厢是哪一个呢?

链表是如何形成的?

要想明白这个问题,火车依然是为一个绝佳的示例。
想一想火车是如何形成的,火车是由火车头、火车尾以及一节节车厢组成,火车头和火车尾以及各节
车厢没有本质区别,因此我们重点关注在一节车厢上。

一节车厢有哪些关键因素呢:

一节车厢只知道自己的负重以及它的下一节车厢是谁

这就足够了。

一节车厢不需要关心一辆火车是如何形成的,它只需要关心自己装载了什么,以及它的下一节车厢是
谁,这就是为什么链表的节点可以离散的散落在内存的各个角落,因为尽管车厢是不连续的,但每一
节车厢都知道自己的下一节车厢是谁:

现在你应该看到了吧,只要你能找到一个头节点,你可以拎出整条链表。这就是链表的奇妙之处。
这也告诉我们为什么增加或者删除一节车厢这么简单了:你只需要改动节点本身以及该节点的前后临
近就可以了:

而数组这种一次性的数据结构(创建好后就无法修改)则对改动很不友好,链表则无此问题。

但链表的这种特性也有自己的缺点,这世界上没有完美的数据结构。

对于数组来说,只要知道数组下标,我们可以一步从数组中找出该元素,但链表则不可以,如果我问
你链表的第10个节点在哪里?除非从头到尾数一遍否则在不借助其它方法的情况下你是没有办法知道
的。

理解了这些你觉得链表还难吗?

Show me your code

接下来我们定义一个节点,叫做node:
struct node {
?
};

那么node里的内容应该是什么呢?

很显然,有这节车厢装载的货物有没有,我们将其称作loads,类型是什么呢,这个其实是无所谓
的,简单起见,我们急用int来表示:

struct node {
?
int loads; // 装载的货物
};

最后还有一个关键点的地方,这节车厢怎么知道下一节车厢在哪里?显然你得有个地址,也就是
address,本质上就是内存地址,那么在C/C++可以看到内存地址的语言中通用的内存地址该怎么表
示呢?

很简单void*,因此这节车厢就是:

struct node {
void* address; // 下一节车厢是谁
int loads; // 装载的货物
};

有的同学看到这里可能会问了,这和书上教的不一样呀?

如果你真的理解了链表的本质就不会有这种疑问了,实际上你可以把任意内存块都用链表串在一起,
管它这些内存块中装的是什么数据!

只不过我们一般都是把同类型内存块用链表链接起来:

struct node {
struct node* address; // 下一节车厢是谁
int loads; // 装载的货物
};

哦!对了,为了让大家更好的理解链表,address这个名字换成next更形象一些,下一节车厢嘛,
loads换成value更加教科书一些:

struct node {
struct node* next; // 下一节车厢是谁
int value; // 装载的货物
};
这是不是你在书里看到?但是你应该明白,链表可以不必这样写。

总结

这是小风哥第一篇系统讲解数据结构与算法的系列文章,由于数据结构与算法内容较多,因此特意开
了一个新的号叫做“小风算法”,哈哈,“小风算法”也是小风哥亲自操刀哈,欢迎大家多多关注。

以后“码农的荒岛求生”这个号主要关注计算机底层技术,“小风算法”这个号主要关注数据结构与算
法,欢迎大家去关注。

关注作者

也欢迎大家扫描下方二维码添加我的个人微信号,备注“加群”,我拉你进微信技术交流群。
知乎回答

有了线程,为什么还要有协程?

在计算机科学的历史上协程这个概念的出现要早于线程,如果你去看《计算机程序设计艺术》这本
书,Knuth老爷子这里专门提到协程,实际上你该问既然有了协程为什么还要有线程?

说实话这两个没有任何本质的区别,说到底线程也好协程也把无非就是执行流的暂停与恢复,从现代
操作系统的角度来看协程实现在用户态,线程实现在内核态。

为什么线程这么流行呢?因为线程这个概念把执行流管理的复杂度“封装”在了操作系统中,对程序员
可不见,使用起来更加方便,但协程就不一样了,不是随随便便哪个程序员能在用户态搞定执行流管
理的,这个相对复杂,现代编程语言近近几年开始支持协程,否则你需要自己造轮子,这导致其很难
用,难用的东西从来都流行不起来,想一想为什么iPhone要用图形界面而不是给你一个命令行界面
呢?

为什么编程更关注内存而很少关注CPU?

我来告诉你答案吧,真相只有一个,之所以你很少关心CPU是因为:

编译器和操作系统替你关心了。

如果你的工作是编写编译器或者操作系统那么不关心CPU是寸步难行的,编写编译器不理解CPU怎么
生成机器指令?不理解CPU怎么优化机器指令?编写操作系统不理解CPU怎么实现操作系统的初始化
任务?怎么才能实现线程切换?

要知道整个计算机系统是有层次的,最下层是硬件,然后是操作系统,接着是编译器,然后是各种语
言,最后才是基于语言的各种应用程序。

从这个角度看,之所以你觉得不需要关心CPU是因为你的工作层次在最表层,在这这里当然是不需要
要关心CPU的,也没办法关心。

但内存就不一样了,不管你在哪个层次工作,程序总是要操作数据的吧,数据总是要放在内存吧,数
据的使用方式决定了需要栈和堆这两种区域,程序员必须决定数据放在哪里,这个工作没人能够替
代。

而对于抽象层次更高的语言,堆栈都省了。

因此当你总在应用层工作就会觉需要关心内存而不需要关心CPU。
为什么会出现复杂指令集的CPU?就拿intel举例把,为什么英特尔采用的是x86
而不是risc阵营的指令集呢?

问题1:为什么会出现复杂指令集? 1970s年代,这一时期编译器还非常菜,不像现在这么智能,没
多少人信得过编译器,大部分程序还是用汇编语言纯手工编写,因此大家普遍认为指令集应该更加丰
富一些、指令本身功能更强大一些,程序员常用的操作最好都有对应的特定指令,毕竟大家都在直接
用汇编语言来写程序,如果指令集很少或者指令本身功能单一,那么程序员用汇编指令写起程序会会
非常繁琐,很不方便,如果你在这个时期用汇编写程序你也会这样想。

在这一时期,计算机的内存只有几KB大小,可谓寸土寸金,这么小的内存要想装入更多的程序就必须
仔细的设计机器指令以节省程序占据的空间,这就要求:a),一条机器指令尽可能完成更多的任务;
b) 机器指令长度不固定,也就是变长机器指令,简单的指令占据更少的空间;c),机器指令高度编码
(encoded),提高代码密度,节省空间。

这是基于这样的现状使得复杂指令集的出现成为必然。

问题2:为什么英特尔采用的是x86而不是risc阵营的指令集呢? 很简单,因为英特尔生产x86处理器
时,risc阵营还不存在。

You might also like