《计算机底层的秘密》读书笔记

一. 虚拟内存和程序内存布局

栈区在内存的最高地址处,中间有一大段空隙,接着是堆区,malloc 就是从这里开始分配内存的。数据区和代码区是从可执行文件中加载进来的。

值得注意的是,每个程序运行起来后代码区都是从内存地址 0x400000 开始的。实现这一效果的是操作系统虚拟内存技术。虚拟内存让每个程序都有这样一种幻觉,那就是每个程序运行起来后都认为自己独占内存。然后由操作系统记录物理内存和虚拟内存的映射关系,记录这种映射关系的称为页表,每个进程都有单属于自己的页表。

虚拟化技术是说通过软件在计算机硬件之上进行抽象,将硬件资源分割为多个虚拟计算机,在这些计算机之上运行操作系统,这些操作系统可以共享硬件资源,完成这一工作的软件被称为虚拟机监控器(Hypervisor),运行在虚拟机监控器之上的操作系统被称为虚拟机。

容器也是一种虚拟化技术,虚拟的是操作系统这层软件资源,容器利用操作系统提供的能力将进程隔离起来,并控制进程对 CPU、内存及磁盘的访问,让每个容器里的进程认为操作系统中只有这些进程存在。

二. 线程安全

  • 给定一段代码,不管其在多少个线程中被调用到,也不管这些线程按照什么样的顺序被调用,当其都能给出正确结果时,我们就称这段代码是线程安全的。

  • 实现线程安全无非就是围绕线程私有资源和线程共享资源来进行的,首先你需要识别出哪些是线程私有的,哪些是线程间共享的,然后对症下药即可。

  • 协程 就是用户态的线程。和线程一样,会被暂停运行时保存运行状态,然后从保存的状态中恢复并继续进行。只不过线程的调度是由操作系统进行的,而协程的调度都是由程序员自己控制,因此需要保存或者恢复的信息更轻量,效率更高,理论上只要内存空间足够我们就能开启无数协程。

  • 线程 的运行时栈帧信息存储在栈区中;协程 的运行时栈帧信息存储在堆区中;

  • 协程 最重要的作用之一就是可以让程序员以同步的方式来进行异步编程。

  • 回调函数:是指一段以参数的形式传递给其它代码的可执行代码。只有我们才知道做些什么,但是我们并不清楚什么时候去做这些,只有其它模块才知道,因此必须把我们知道的封装成回调函数告诉其它模块。回调函数是一种软件设计上的概念。

  • 闭包(Closure):回调函数可以绑定一部分运行时数据,这部分数据只能在 A 处获得,而无法在 B 处获得,即无法在调用该回调函数的地方获得。当回调函数与一部分数据绑定后统一作为一个变量对待时,闭包(Closure)就出现了。

  • js 的 Promise 是一个很好解决回调地狱的例子,主要原则就是将参数和回调函数分离。https://juejin.cn/post/7065129481513467941

  • 假设两个函数 A 和 B,函数 A 调用函数 B,当函数 A 所在的线程(进程) 因调用函数 B 被操作系统挂起而暂停运行时,我们说函数 B 的调用是阻塞式的,否则就是非阻塞式的。一般情况下,阻塞几乎都与 I/O 有关。

  • 同步调用不一定是阻塞的,但阻塞调用一定是同步的。

  • 事件循环接收到请求后,将我们实现的 handler 方法封装为协程并分发给各个工作线程,供他们调度执行,工作线程拿到协程后开始执行其入口函数,也就是 handler 函数。当某个协程因阻塞式调用主动释放 CPU 后,该工作线程将去找下一个具备运行条件的协程并执行,这样在协程中发起阻塞式调用就不会阻塞工作线程,从而达到高效利用系统资源的目的。

三. 内存

  • 内存是由一个个存储单元(memory 组成的),每个存储单元(比特)都只能表示 0/1。为了表达更多的信息,我们将 8 个存储单元组成一个字节,这样每个字节就能表示 256 种信息。然后再将 4 个字节为一个单位来表示整数表示更多信息。等等。

  • 每个字节在内存中都有自己的地址,这就是我们常说的内存地址,通过一个内存地址我们可以唯一的找到这 8 个存储单元,这就是寻址(addressing)。

  • 指针是内存地址的高级抽象,这个抽象的目的就在于屏蔽间接寻址(Indirect addressing);指针在赋予你直接操作内存能力的同时,也提出了更高要求,即你需要确保不会错误的操作指针。

  • 引用是指针的进一步抽象,很多语言(比如 Java、Python)为了屏蔽内存细节,取消了指针,不允许直接操作内存地址,以此换取编程的便利性。

  • 栈帧(stack frame)保存了函数运行时的各种信息,包括返回地址、函数中的局部变量、调用参数及使用的寄存器等信息,栈帧组成了栈区。

  • 假设函数 A 调用函数 B,函数 A 将一些参数写入对应的寄存器,当 CPU 执行函数 B 的时候,可以从这些寄存器中获取参数;同样的,函数 B 也可以将返回值写入寄存器,当函数 B 执行结束后可以从寄存器中获取返回值。当参数数量少于寄存器数量时,剩下的参数可以直接放在栈帧中。

  • 怎么在堆区中找到空闲内存块?

    • First Fit :从头开始遍历,找到第一个符合大小的空闲块就返回;
    • Next Fit:从上一次找到合适的空闲内存块的位置找起,找到第一个符合大小的空闲块就返回;
    • Best Fit:遍历所有的空闲内存块,找到符合要求并且大小为最小的那个内存块返回;
  • 释放内存时,仅仅需要将用户申请到的首地址(ADDR)传递给释放函数,函数只需要将该地址减去 header 的大小(4字节),就可以获取到内存块对应 header 的首地址,然后将其标记为空闲即可;

  • 进程就像是网络通信中的客户端,操作系统就像是服务器端,系统调用(System Call,操作系统为程序员留下的一些特殊“暗号”,如文件读写、网络通信)就像是网络请求;

  • 最上层是应用程序,应用程序一般只与标准库(用户态,屏蔽操作系统差异)打交道 ,标准库通过系统调用和操作系统交互,操作系统管理底层软件;

  • Linux 专门提供了一个叫做 brk 的系统调用, 其指向了堆区的顶部,用来增大或者减小堆区的。如果 malloc 没有找到空闲内存块,就会向操作系统发出请求增大堆区,如 brk 调用;

  • malloc 调用,程序员申请到的内存其实是是虚拟内存,发生在用户态。分配物理内存被推迟到了真正使用该内存的那一刻,此时会产生一个缺页错误(page fault),因为虚拟内存并没有关联到任何物理内存。操作系统捕捉到该错误后开始分配真正的物理内存(由用户态切换到内核态),通过修改页表建立好虚拟内存和真实物理内存的映射关系,而后程序开始使用该内存(由内核态切换到用户态),从程序员的角度来看就好像从一开始改内存就被分配好一样;

  • 内存池技术一次性申请一大块内存,在其之上自己管理内存的分配和释放,内存池技术是在应用程序实现的,绕过了标准库和操作系统,一个很好的例子就是 Okio 的 “Segment Pool”;

  • 内存的寻址粒度是字节级别的,也就是说每个字节都有它的内存地址,CPU 可以直接通过这个地址获取相应的内容;而 SSD 是以块的粒度来管理数据的,CPU 没有办法直接访问存储在 SSD 上的数据。

四. CPU

  • 无论程序员编写的程序多么复杂,软件承载的功能最终都是通过晶体管(三极管)简单的开闭来完成的。

  • 任何一个逻辑函数最终都可以通过与门、或门和非门表达出来,这就是逻辑完备性。也就是说给定足够的与门、或门和非门,就可以实现任何一个逻辑函数。

  • 用一个与门和一个异或门就可以实现二进制加法:

  • 寄存器的存储都是由电路来实现的,通过给输入端设置高低电压,让输出端有固定的输出,此时我们就可以说把数据存储在电路中了;内存(Memory)每 8 个比特位为一字节,每个字节都有自己的唯一编号,利用该编号就能从电路中读取存储的信息,同时提供寻址功能;

  • 我们没有必要把所有的计算逻辑都用电路这种硬件实现出来,我们可以给不变的硬件提供不同的软件,从而让硬件实现全新的功能。

  • 指令集 告诉我们 CPU 可以执行什么指令,每种指令需要提供的操作数,不同类型的 CPU 会有不同的指令集。从系统分层的角度来看,指令集是软件和硬件的交汇点,在指令集之上是软件的世界,在指令集之下是硬件的世界,指令集是软件和硬件的交汇点,也是软件和硬件通信的接口。

  • 复杂指令集(Complex Instruction Set Computer,CISC),机器指令长度不固定;机器指令高度编码;当今普遍存在于桌面 PC 和服务器端的 X86 架构就是基于复杂指令集的;

  • 精简指令集(Relegate Interesting Stuff to Compiler,RISC),机器指令长度固定;提供简单机器指令,更依赖编译器优化;内存访问依赖 LOAD/STORE 架构(LOADSTORE 两类机器指令负责读写内存,其它指令只能操作 CPU 内部的寄存器而不能去读写内存);

  • 时钟信号 每改变一次电压,整个电路中的各个寄存器(也就是整个电路的状态)都会更新一下,这样我们就能确保整个电路协同工作。

  • CPU 出现空闲,即系统没有任何可调度的进程时,调度器就从队列中取出空闲进程并运行,空闲进程永远处于就绪状态,且优先级最低。空闲进程本质上就是不断执行 halt 指令(让 CPU 内部的部分模块进入休眠状态,从而极大降低对电力的损耗,CPU 大部分时间都用在这条指令上了)的循环。

  • 计算机操作系统会每隔一段时间就产生定时器中断,CPU 在检测到该中断信号后转去执行操作系统内部的中断处理程序。

  • 原码(正数加上负号)和反码(原码的翻转)做加法运算,都不可避免的要加入额外的组合电路确保有符号数相加的准确性。

  • 补码(反码加1)的设计可以让计算时根本不用关心数字的正负,从而简化电路设计,尽管补码对人类来说不够直观。

  • 从宏观上看,整个操作系统是这样运作的:程序员把脑海中思考的问题用程序的方式表达出来,编译器负责将人类认识的程序转为可以控制 CPU 的 01 机器指令,因此 CPU 根本不认识任何编程语言,理解编程语言的是编译器,现在我们能给 CPU 输入了,那输出呢?输出其实也是 01 串,剩下的仅仅就是解释了。

  • CPU 分支预测:CPU 会猜一下后续会走哪个分支,如果猜对了则流水线照常继续;如果猜错了,那么对不起,流水线上已经执行的错误分支指令全部作废,显然 CPU 猜错了则会有性能损耗。

  • CPU 为什么需要寄存器?原因很简单,速度,CPU 访问内存的速度大概是访问寄存器的 1/100。

    • 栈寄存器(Stack Pointer):函数运行时栈帧的栈顶信息就保存在栈寄存器中,其指向栈区的底部,通过该寄存器就能跟踪函数的调用栈。
    • 指令地址寄存器(Program Counter):程序计数器,简称 PC,存放下一条待执行的命令。
    • 状态寄存器(Status Register):保存状态信息,比如算术运算中的进位,比如记录当前 CPU 是运行在内核态还是用户态。
  • 通过寄存器,我们可以知道程序运行到当前这一时刻最细粒度的切面,这一时刻寄存器中保存的所有信息就是通常所说的上下文(Context)。只要拿到一个程序运行时的上下文并保存起来,就可以随时暂停该程序的运行,也可以利用该信息随时恢复程序的运行。“函数调用”、“系统调用”、“线程切换”、“中断处理”等全部依赖上下文的保存和恢复。

五. Cache

  • 现在计算机系统的存储体系:

  • 程序的局部性原理:

    • 时间局部性:如果一个程序访问一块内存,之后还会多次应用该内存;
    • 空间局部性:如果一个程序访问一块内存,之后也会引用相邻的内存;
  • 现代 CPU 与内存之间会增加一层 Cache,Cache 造价高昂、容量有限,但是访问速度几乎和 CPU 的访问速度一样快,Cache 中保存了近期从内存中获取的数据,CPU 无论是需要从内存中取出指令或数据都首先从 Cache 中查找,只要命名 Cache 就不需要访问内存。

  • 一般现代 CPU 与内存之间增加了三层 Cache,分别是 L1 Cache、L2 Cache、L3 Cache,其访问速度依次递减,但容量依次递增。它们与 CPU 核心作为整体封装在寄存器芯片中。

  • Cache 与内存交互的基本单位是 cache line,也就是一整块数据,大小通常是 64 字节。

  • 现代操作系统把内存当成磁盘的 Cache。操作系统总是将部分空闲内存用作磁盘的 Cache,缓存从磁盘中读取到的数据,这就是 Linux 系统 page cache 的基本原理。

  • 软件设计中我们要解决 Cache 的一致性问题,CPU 亦是如此,不仅如此,多核 CPU 的多 Cache 一致性问题也得一并维护好。频繁地维持 cache 一致性可能导致 cache 不但没有起到应用的作用反而拖累了程序性能。因此,如果有办法避免多线程之间共享数据,就应该尽量避免。

  • 虚拟内存和磁盘?每个进程都有一个自己的标准大小的地址空间,而且这个地址空间的大小和物理内存无关,进程地址空间的大小可以超过物理内存。那么,如果系统中有 N 个进程,这 N 个进程实际使用的内存已经占满了物理内存,当 N+1 个进程申请内存时,会发生什么呢?既然读写文件时内存可以作为磁盘的 Cache,那么磁盘也可以作为内存的“仓库”,我们可以把某些进程不常用的内存数据写入磁盘(内存 swap),从而释放一部分占据的物理内存空间,这样 N+1 个进程就又可以申请到内存了。

  • CPU 是如何读取内存的?首先,CPU 能看到的都是虚拟内存地址,显然 CPU 操作内存时发出的读写指令使用的也都是虚拟内存地址,该地址必须被转换为真实的物理内存地址,转换完毕后开始查找 cache,如 L1 cache、L2 cache、L3 cache,在任何一层能查找到都直接返回,查找不到就不得不开始访问内存。由于虚拟内存的存在,进程的数据可能会被替换到磁盘中,因此本次读取可能也无法命中内存,这时就不得不将磁盘中的进程数据加载到内存,然后读取内存。

  • 指令乱序执行(Out of Order Execution,OoOE):由于 CPU 和内存之间速度差异巨大,如果 CPU 必须严格按照顺序执行机器指令,那么在等待指令依赖的操作数时,流水线内部就会出现“空隙”,即 slots,但如果我们用其它已经准备就绪的指令来填充这些“空隙”的话,那么显然就可以加快指令的执行速度。因此 OoOE 可以充分利用流水线,但在 CPU 外部看来,指令是按照顺序执行并且指令的执行结果是按照顺序生效的。

  • 内存屏障的目的就是确保某个核心执行指令的顺序在其它核心看来与代码顺序是一致的。如果你的场景不涉及多线程无锁编程,那么不需要关心指令重排序问题。

  • 无锁编程(lock-free programming)是指可以在不使用锁保护的情况下,在多线程中操作共享资源,原理是利用原子操作,如 CAS(Compare And Swap)操作等,这类指令要么执行,要么不执行,不存在中间状态。

六. 计算机 IO

  • I/O 其实就是数据拷贝。从外部设备拷贝到内存,就是 Input;从内存拷贝到外部设备的,就是 Output;

  • 设备寄存器(Device Register)存放与设备相关的一些信息,主要有以下两类寄存器:

    • 存放数据的寄存器:比如用户按下键盘的按键,信息就会存放在这类寄存器中;
    • 存放控制信息及状态信息的寄存器:通过读写这类寄存器可以对设备进行控制或者查看设备状态(设备是否可读、是否可写等);
  • 在计算机的底层,本质上有两种 I/O 实现方法:一种用特定的 I/O 机器指令;另一种是复用内存读写指令,但把地址空间的一部分分配给设备;

  • CPU 通过设备寄存器,就能知道此时有没有设备输入或触发,而感知设备寄存器主要有两种方式:同步的轮询和异步的中断处理。

  • CPU 如何检测到有中断信号呢?CPU 执行机器指令的最后一个阶段需要去检测是否有硬件产生中断信号。

  • 对于现代计算机系统来说,其实磁盘处理 I/O 是不需要 CPU 参与的,实现这个机制的就是 DMA(Direct Memory Access)。CPU 下达指令告诉 DMA 该怎样去复制数据,DMA 在明确了自己的工作目标后,开始进行总线制裁,申请对总线的使用权,此后开始操作设备。假设我们从磁盘读取数据,当数据读取设备控制器的 buffer 后,DMA 开始将这些数据写入指定的内存地址,这样就完成了一次数据复制工作,当数据传输完毕后利用中断机制通知 CPU。

  • 设备驱动(Device Driver)是一段属于操作系统的代码;设备控制器(Device Controller)可以理解为硬件,其作用是接收来自设备驱动的命令并以此控制外部设备;

  • 如下是一种典型的 I/O 操作,该函数在底层需要通过操作系统调用向操作系统发起文件读取请求,该请求在内核中会被转为磁盘能理解的命令并发送给磁盘,CPU 发出命令后,会去执行其它就绪线程。此后 DMA 机制将数据拷贝到某一块内存中,这块内存就是调用 read 函数时传入的 buffer,当数据拷贝完毕后,磁盘向 CPU 发出中断信号,CPU 重新接管程序。

char buffer[len]
read(buffer)
  • 一般情况下,I/O 数据首先被拷贝到操作系统内部,然后操作系统将其拷贝到进程地址空间中。因此可以看到这里其实还有一层操作系统的拷贝,当然我们也可以绕过操作系统直接将数据拷贝到进程地址空间中,这就是零拷贝(Zero Copy)技术。

  • 实际上,所有的 I/O 设备都被抽象为文件这个概念,一切皆文件(Everything is File),磁盘、网络数据、终端,甚至进程间通信工具管道 pipe 等被当成文件对待。这一抽象可以让程序员使用一套接口就能操作所有外部设备,如用 open 打开文件、用 read/write 读写文件、用 seek 改变读写位置、用 close 关闭文件等。

  • 在 UNIX/Linux 世界中,文件都有唯一的文件描述符(File Descriptors),程序员使用文件描述符处理 I/O 操作;IO多路复用

  • MMAP,借助 mmap,在处理大文件时,只要你的进程地址空间足够大,就可以把整个大文件映射到进程地址空间中,即使该文件大小超过物理内存也不是问题。

七. 其它

posted @   JMCui  阅读(622)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
历史上的今天:
2019-09-08 多线程编程学习八(原子操作类).
2017-09-08 浅析多线程的对象锁和Class锁
点击右上角即可分享
微信分享提示