代码在计算机中是怎样运动的🏊♀️
也许你用几周时间掌握了所有的程序语言, 也许你从网上的只言片语拼凑出了一段能跑起来的程序, 但,
这段代码在跑的过程中是如何运动的. 它从内存出发直奔CPU并将正确结果精准刻录到硬盘了吗? 还是在不知道什么地方里游荡并在途中造就了多位野指针?
它的运动又在计算机中引发了什么反应. 也许你只需要做个布尔判断却动员了整根内存条? 会不会7个核心在围观一个干活?
也许, 我们知道一件事情发生的时候究竟发生了什么, 下一次这样的事情发生, 就是我们故意的了.
赛道一览
处理器、内存、硬盘. 没了.
$$[CPU] ↔ [RAM] ↔ [DISK]$$
中央处理器单元(CPU), 邮票那么大, 是所有计算发生的地方, 按照你的代码,告诉程序往哪跑.
随机读取储存(RAM), 计算机的短期记忆, 需要供电维持. 所谓开机, 就是喊内存起来干活. 程序跑, 都是跑在内存上的. 所有屏幕上发生的事情, 都是内存发生的事情的剪影.
磁盘(DISK), 物理储存单元, 不用通电, 通过磁头的机械运动定位文件位置, 光盘转圈读写数据(安个喇叭就是留声机). 程序没跑的时候就在硬盘里睡觉.
硬盘有多慢
很多人不喜欢关机, 因为开机之后第一次打开文件会比较卡. 其实不是第一次打开卡, 而是第二次打开变快了.
$$[CPU] ↔ [RAM] ↔ [DISK CACHE] ↔ [DISK]$$
你双击打开一个程序, 是程序的数据从硬盘拉到内存的过程. 但一个程序有很多文件, 每换个文件, 磁头就要重新定位, 你等的就是这个磁头寻址时间. 后续打开程序, 一些关键数据被储存在缓存上, 在相同的文件拉取顺序下, 磁头无需再花时间寻址.
cache,音同cash, 缓存, 虽然叫硬盘缓存, 其实内存划出来的. 硬盘, 始终是快不起来的.
编写程序的时候要避免和硬盘打交道, 使用程序的时候也要注意是不是在和硬盘打交道. 如果你的计算文件跑不起来, 你往往需要的是两根更大的内存条, 而不是上超算.
CPU有多快
CPU里面也有个地方暂存内存拿来的数据, 这个cache还叫内存, 但它长在cpu里, 并且工艺不同(更贵).
$$[CPU] ↔ [CPU CACHE] ↔ [RAM] ↔ [DISK CACHE] ↔ [DISK]$$
当CPU找RAM请求数据, 它先去cache里找, 找到了, 直接开始计算. 没找到, CPU就在那干敲, 直到数据被送过来, 才干真活.这被称为 cache miss. 它能造成最高500倍的延迟.
规避cache miss 倒也简单, 只需认准一件事: 局部性.
时域局部性: 如果可以连续访问一块内存, 不要中途跑去做别的事, 比如在一个重复写入的循环的中间跑去定义一个变量.
空间局部性: 如果一些数据总是一起被调用, 尽量把它们绑在相邻的内存上. 比如用tuple来表示坐标.
如果将写代码比作在现代的大都市里闲逛, 那么规避cache miss 可以类比为别被车撞.
使用静态类型语言, 选择合适的数据结构, 写紧凑的循环, 别让cpu干敲.
CPU到底有多快
CPU像钟一样敲击, 每秒大概三十亿次(i.e. 3GHz主频), 每敲一次称为一个时钟周期.
每个时钟周期里CPU都可以在一小块数据上执行一个CPU指令, 但不同的CPU指令需要的时钟周期是不同的.
- 什么都不做需要一个时钟周期.
- 整数加减法也是一个时钟周期.
- 浮点数除法则是十五个时钟周期.
尽管CPU执行一个指令到给出结果或需要多个时钟周期, 但它可以在一个指令的中途插入新的指令. 比如在做除法的十五个时钟周期内我可以再做一个除法这样到第十八个时钟周期的时候两个除法就都做完了.
因此, 当CPU遇到分支, 也就是你的if while switch, 会面临以下情况:
判断条件还没算出来呢, CPU已经招呼你往里塞指令了
现代的CPU会随机选一条分支先执行下去, 等判断条件确定了, 再决定是否丢弃刚才这段计算. 这个叫, 分支预测.
如果你的判断条件足够简洁, 且有一个大概率发生的分支, 那么你的cpu将猜的很准, 所以, 不要害怕写if, 但要把if 写得优雅.
当然, 如果你想榨干CPU, 我想你更应该读的是这个What scientists must know about hardware to write fast code .
现实中如果我们使用了不那么棒的数据结构, 不时让cpu干瞪眼, 我们的程序也还是跑得动的.
事件 | 时钟周期/个 |
---|---|
在cpu cache里移动数据 | 1 |
一次错误的分支预测 | 10 |
一次cache miss | 500 |
从磁盘读取数据 | 500万 |
如果没合理安排硬盘读取, 我们的程序运行速度会骤降. 但更多时候我们还是在内存上的, 内存可就有点绕了.
内存有多绕
程序大抵是由编程者写下的代码创建的对象跑在内存上的, 但内存容量是有限的.
你创建一个对象, 无论是一个UI页面, 还是一个储存了数据的向量, 这个对象如果被创建在内存上, 这块内存就不能再给别的对象用了, 要等这个对象被销毁, 再等它占有的内存被释放, 这块内存才被允许做别的事.
分配, 销毁, 释放, 内存管理是个大讲究.
有些语言,比如C, Rust, 需要手动管理内存, 意味着你需要把释放体现在代码上. 人们讨论语言性能到最后, 就是如何安全地手动管理内存.
民用语言常常内置垃圾回收(GC)程序, GC会追踪无法被调用的对象, 并将它们占有的内存释放掉. 一个很简单的例子:
object = [1,2,3,4,5]
sleep(2)
object = nothing
两秒之后, [1,2,3,4,5]
这串数据编程者无法再调用它, 它已经名义上被销毁, 但占用的内存还在, GC会自动找到它, 将这一块内存释放.
GC虽好, 但它始终是你的程序里一个不受你控制的程序.
好在, 并非所有的对象都面临分配问题, 一些语言孜孜不倦诱导编程者将程序对象抽象成「值类型」, 从而这个对象可以放在内存里一个更高效的区域,「栈」上面, 而不是需要靠绳子牵着的「堆」.比如:
但如果你只有几周时间来掌握所有的编程语言, 或许
- 学会git clone
- 成功运行你下载的代码
- 用你熟练使用的文本编辑器打开src文件夹
$$唔?这行代码对内存说了什么,唔?$$
能怎么绕
程序的行进在于内存持续向CPU核心提交数据, 而现代的CPU往往有多颗核心.
你可以轻易向不同的CPU提交不同的内存, 但你不能将一块内存同时提交到多颗CPU, 物理上不行.
你可以把内存切的更细, 将不要紧的部分踢给别的CPU去做, 过一会儿拿回来, 或者不拿回来, 就需要编程者通过异步编程写在代码中了.
async await是目前为止最简洁的异步语法, 比如:
我想下饺子, 但我不想包, 我等别的CPU包.
func 下饺子() async {
let 饺子 = await 包饺子()
煮(饺子)
return
}
能出现在await后面的也必须是个async, vice versa.
//一个函数, 要一块内存包饺子, 这块内存会在递给CPU之后变成一个饺子, async, 哦, 它需要别的cpu帮助.
func 包饺子() async -> 饺子 {
//在func要来的这块内存上, 划出一块来, 放饺子皮, 这块内存要等其他的cpu擀出来.
let 皮儿 = await 擀(面剂子)
//等饺子皮儿到了, 我放上馅, 一捏, 做好一个饺子.
return 一捏(皮儿,馅儿)
}//这块内存就剩这个饺子了.
最后, 吃饺子这件事需要亲力亲为,这意味着我们需要从异步思维回到严格的时间线中,swift的做法是这样的:
//我不是async了
func 吃饺子() {
// 我回到主cpu了
Task { @MainActor in
// 我等饺子煮好从别的cpu端过来
await 下饺子()
// 我吃
吃()
}
}
每种语言对async的实现不尽相同, 但当你需要跟内存对话的时候, 你总是需要async的.
🏊♀️
你不会一个链接都没点开过吧?