03.01 数据、指令与存储层次:一条 IM 消息怎样占用资源
本章的代码输出、单位换算、缓存轨迹和容量数字均为按明确假设推导的预期。本次编写未运行 Go 示例、性能测试或系统实验;纸上模型不代表某台机器的实测结果。
本章从什么问题出发
你已在 01.01–01.04 集合与文本学会变量、函数、数组、切片与文本。现在能把若干条消息放进 []string,却还不能仅凭“有一万条消息”回答它占用多少内存、扫描需要多久、程序退出后还能否找回。
虚构的教学 IM 出现一个需求:给待处理消息设置资源预算,避免接收越来越快、处理越来越慢时无限保留数据。 在选择限制值之前,先弄清“消息大小”“内存”“处理速度”分别指什么。
| 阅读层次 | 小节 | 要回答的问题 |
|---|---|---|
| 起步 | 一、位、字节与单位 | 256 B 与 256 bit 有什么差别? |
| 起步 | 二、数据的解释规则 | 同样的位为什么可以表示数字或文字? |
| 应用 | 三、正文与附加开销 | 正文总长为什么不是进程总内存? |
| 起步 | 四、地址与位置 | 程序看到的位置编号如何帮助访问数据? |
| 应用 | 五、指令与工作量 | 一行 Go 代码究竟要求机器做什么? |
| 起步 | 六、存储层次 | 数据在哪里,等待多久,断电后能否保留? |
| 第二遍深化 | 七、局部性与工作集 | 访问顺序怎样改变一个明确缓存模型的结果? |
| 综合 | 八、缓冲预算与验收 | 如何提出带假设、可复核的限制? |
第一次可先完成一至六节和第八节的基本预算,再回到第二节的大小端与第七节推演。这里只用已学的语言语法;位权、比例和速率都在本章解释,不要求先读数学卷、Go 指针或操作系统教材。
用户 u-a、u-b 与会话 c-a 都是虚构身份。现有文本校验仍按原始字节数,不自动去除空格,等于上限合法。本章的容量数字是新设定的资源模型,不替代原来的校验规则,也不提供网络交付保证。
一、把“多大”和“多快”写成可以计算的单位
1.1 一个位为什么只有两种状态
位,英文 bit,是一种二选一信息单位,常记为 0 或 1。硬件如何用电压、电荷等状态实现它,是电路层的问题;本章只使用这个抽象。
一位有 2 种组合。两位可以是 00、01、10、11,共 4 种。每再增加一位,原来的每种组合都能再接 0 或 1,所以组合数乘 2。数学把连续乘 n 次 2 写作 2^n:
1 位:2 种
2 位:2 × 2 = 4 种
3 位:2 × 2 × 2 = 8 种
8 位:2^8 = 256 种字节,英文 byte,在这里是 8 位,单位符号是大写 B。因此 1 B = 8 bit。256 种状态若按无符号整数解释,可以编号为 0 到 255;共有 256 个数,最大值却是 255,因为从 0 开始。
本章的 2^n 是数学记法;Go 中 ^ 不是乘方运算符。后面的 Go 预算代码直接使用整数乘法。
一字节能保存什么取决于解释约定。它可以是一项 0–255 的数值,也可以是较长文字编码中的一个部分。不能由“一字节”推出“一句话”或“一个汉字”。
1.2 十进制与二进制:位置决定权重
熟悉的十进制数 253 表示 2×100 + 5×10 + 3×1。每向左移动一位,权重乘 10。
二进制每位只能用 0、1,每向左一位权重乘 2。观察八位模式 00001101:
| 位权 | 128 | 64 | 32 | 16 | 8 | 4 | 2 | 1 |
|---|---|---|---|---|---|---|---|---|
| 位值 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 1 |
| 对总值的贡献 | 0 | 0 | 0 | 0 | 8 | 4 | 0 | 1 |
将贡献相加,得到 13。前导零不改变这个无符号数的数值,但它们让我们看清这是一个八位位置图。
再看 00010000:只有权重 16 的位为 1,值是 16。它不是十进制的一万。读一串数字前,先确定进制,就像读“10”前要明确单位一样。
自己推演: 00001111 是 8+4+2+1=15;再加 1 后进位到 00010000,即 16。位数有限时,进位可能超出当前表示范围,第二节再区分这种边界。
1.3 kB 与 KiB:相近名称表达不同数量
容量常常很大,使用前缀可以少写几个零。两套前缀要分清:
| 名称 | 符号 | 字节数 |
|---|---|---|
| 千字节,十进制 | kB | 1000 B |
| 兆字节,十进制 | MB | 1000×1000 = 1000000 B |
| 千二进制字节 | KiB | 1024 B,即 2^10 B |
| 兆二进制字节 | MiB | 1024×1024 = 1048576 B,即 2^20 B |
| 吉字节,十进制 | GB | 1000000000 B |
| 吉二进制字节 | GiB | 1073741824 B,即 2^30 B |
二进制前缀用于明确 1024 倍的计量,十进制前缀用于 1000 倍。NIST 的二进制前缀说明
本课程公式优先写 B、KiB、MiB。看到口语或旧工具显示 KB、MB,应查看该工具的定义;不能因为名字相似就默认按 1024 或 1000 换算。
例如同样的 1048576 B,可以表示成 1 MiB,也可以表示成 1.048576 MB。底层数量没有变化,变的是计量单位。相反,采购或配置中的“32 MB”与“32 MiB”不是同一数量。
1.4 容量和速率之间差一个时间单位
B 表示数据量,B/s 表示每秒多少字节,bit/s 表示每秒多少位。这里的 /s 就是“每秒”,不是装饰。
假设一条链路标称 100 Mbit/s,且 M 按十进制定义:
100 Mbit/s = 100000000 bit/s
100000000 bit/s ÷ 8 bit/B = 12500000 B/s
12500000 B/s = 12.5 MB/s这只是单位换算。实际业务消息还可能有协议头、重传、排队、对方处理和调度等待,不能据此保证聊天正文每秒恰好传输 12.5 MB。
如果假设通道持续提供 8 Mbit/s 的有效数据速率,传输 1 MiB 数据需要多久?
数据量:1048576 B × 8 bit/B = 8388608 bit
时间:8388608 bit ÷ 8000000 bit/s = 1.048576 s单位中的 bit 相除后只剩 s,提醒我们结果是时间。这个理想模型额外假定速率恒定、无额外开销和等待;如果 8 Mbit/s 只是硬件额定速率,就还没有获得相同的业务时间保证。
反例: “消息有 1 MB,所以延迟是 1 MB”没有意义。大小不是延迟;需要速率、固定等待和具体路径才能讨论时间。
二、位模式需要解释规则,才能成为有意义的数据
2.1 同样八位可以表示 255,也可以表示 -1
位模式 11111111 的各位都为 1。按无符号整数解释,值为:
128 + 64 + 32 + 16 + 8 + 4 + 2 + 1 = 255若按八位二补码有符号整数解释,最高位的权重是 -128,其余仍是正权重:
-128 + 64 + 32 + 16 + 8 + 4 + 2 + 1 = -1因此 uint8 能表示 0–255,int8 能表示 -128–127。这里不是“第一位只存正负、剩下七位存绝对值”的简单编码;否则解释 -1 时会得到错误结果。
位模式本身没有附带“请按 int8 看”的口头说明。程序的类型、文件格式或通信协议,需要提供解释规则。给定同一串字节,读成整数、文本或图像数据可能得到完全不同的意义。
2.2 位宽限制范围,但不自动表达业务合法性
int32、int64 分别给出固定的 32 位与 64 位有符号整数表示。位更多通常允许更大范围;Go 的 int 则随实现选择 32 或 64 位。它们与指针宽度、实际可用内存不是可随意互换的概念。
回顾 01.02 的两个独立情况:
// 故意错误片段:常量 128 不能由 int8 表示。
var count int8 = 128// 独立片段:放在 main 内并导入 fmt。
var count int8 = 127
count = count + 1
fmt.Println(count)第二段的预期为 -128:这里进行了有类型的运行时整数运算,发生溢出。Go 不会仅因这种有符号整数溢出就自动 panic。常量可表示性检查与变量运算的规则不同。Go 数值类型与溢出规则
对于“消息条数”业务,负数通常没有意义,但语言类型不会自动替你建立条数上限。增加位宽、检查输入、控制集合规模解决的是不同层面的限制。资源预算还应检查乘法,不能只确保每个单独参数看起来合理。
2.3 浮点数的范围与精度也要分开看
浮点数可以用“有效数字 × 某个底数的幂”建立直觉。十进制的 3.25×10^4 表示 32500;常用二进制浮点在自己的编码规则中使用二进制有效数字与指数。
有限的位数要分配给符号、指数和有效数字,因此能表示很大的数与能精确区分相邻的数是两件事。范围很广,不表示每个整数、每个小数都能精确表示。
例如 0.5 = 1/2,可以用有限二进制位表示;0.1 在二进制中不断重复,存进有限精度的 float64 时需要近似。不要由此推出“所有小数都不精确”,也不能用显示的小数位数判断内部精度。
本章的字节数、条数、副本数是离散计数,优先用整数精确计算。最后展示“约 6.1035 MiB”时才做除法近似;接受或拒绝容量边界时仍比较整数 B,避免显示舍入影响决策。
2.4 十六进制让一串位更容易阅读
八个二进制位较长,技术文档常把每四位写成一个十六进制数字。十六进制使用 0–9 表示 0–9,再用 A–F 表示 10–15。
| 二进制四位 | 十六进制 | 十进制 |
|---|---|---|
| 0000 | 0 | 0 |
| 1001 | 9 | 9 |
| 1010 | A | 10 |
| 1111 | F | 15 |
前缀 0x 常用于说明后面的数是十六进制。例如 0x41 表示 4×16+1=65;对应八位模式 01000001。它同时也是 ASCII 与 UTF-8 对字母 A 使用的编码字节。
“65”可以是计数值;字节 0x41 也可以按文本编码读成 A。数字打印成字符、字符编码成字节、把数值转成十进制文本是不同操作,不能因为屏幕上能显示就混为一件事。
2.5 文本编码决定消息正文的字节序列
01.04 已经区分过 Go 字符串字节、Unicode 码点和用户看到的文字组合。这里继续使用确定的 UTF-8 字面量:
| 文本 | UTF-8 编码字节,十六进制 | 字节数 |
|---|---|---|
A | 41 | 1 |
你 | E4 BD A0 | 3 |
好 | E5 A5 BD | 3 |
你好 | E4 BD A0 E5 A5 BD | 6 |
| 一个普通空格 | 20 | 1 |
len("你好") 得到 6;它没有承诺这段文本总共两个屏幕格,也没有建立“所有汉字都三字节”的规则。复杂文字组合、其他码点、显示宽度另有语义。
Go 字符串可以保存任意不可变字节序列,并不天然保证有效 UTF-8。这里的文字例子是有效 UTF-8;当外部输入可能不合法时,编码校验是另外一个需求。Go 字符串说明
2.6 第二遍阅读:多字节整数怎样排列
一个整数超过一字节时,还需要约定多个字节的先后。取十六进制整数 0x0102:它等于 1×256+2=258。
| 约定 | 较低地址中的字节 | 下一地址中的字节 |
|---|---|---|
| 大端,big endian | 01 | 02 |
| 小端,little endian | 02 | 01 |
大端把较高权重字节放在前面,小端把较低权重字节放在前面。这里变化的是多字节数的字节次序,不是把每个字节内部的位倒过来。
若约定读取大端,两个字节 01 02 读成 258;若错误地按小端读,便是 2×256+1=513。字节没有损坏,双方的解释约定却不一致。
大小端不会自动由 Go 的 int 名字解决。文件与协议应明确宽度和字节序,不能把某台机器的本地布局直接当成所有机器都遵守的协议。Go 的 encoding/binary 提供明确选择字节序的工具,具体 API 待后续编码章节再用。
三、正文大小、表示大小与进程内存是三张不同的账
3.1 先只统计正文,建立最小事实
正文,也可在本题中称为 payload,是用户实际提交的文本内容。假设一批消息是 A、你好、OK,正文总量为:
1 B + 6 B + 2 B = 9 B这是三个正文按当前编码表示的长度和。它有明确价值:若不压缩且逐条计量,这批原始正文共有 9 个字节需要被解释。但它不等于“整个消息列表只占 9 B 内存”。
在程序中还需要知道是哪条消息、由谁发出、属于哪个会话、边界在哪里。这些描述正文的数据叫元信息或 metadata。它们可以有自己的存储与编码开销,即使用户只发送一个 A。
3.2 给消息加一层最小教学封装
本节新增一个纸上格式:每条消息前放一个 2 B 的长度字段,之后是原样正文。长度字段只记录正文的字节数,且采用大端。它是本课的简化格式,不是 OpenIM 协议。
A: [00 01] [41] 共 3 B
你好: [00 06] [E4 BD A0 E5 A5 BD] 共 8 B
OK: [00 02] [4F 4B] 共 4 B合起来是 3+8+4=15 B。也可以先算正文 9 B,再加 3 条×2 B/条=6 B 的长度字段,得到同一结果。
长度字段让接收方知道从哪里切分各条正文,但真实网络协议还可能包含消息类别、身份、校验、加密相关信息等。我们没有为它们规定格式,因此不能把 15 B 宣称成真实传输的全部流量。
采用 2 B 无符号长度字段也会有表示范围:16 位有 2^16=65536 种状态,最大可写 65535。实际业务上限可以小得多。字段能表示某个长度,不等于业务允许接收它。
3.3 []string 连续的是元素,正文可能在别处
切片连接到底层数组,数组中的元素按其类型组织。如果元素类型是 byte,元素本身就是一个字节;如果元素类型是 string,元素保存的是字符串值所需要的描述,实际文本字节可以在另外的位置。
下面是关系图,不代表固定内存布局或实测地址:
messages 的切片描述
│ 选择底层数组的一段
▼
[字符串值 0] [字符串值 1] [字符串值 2] [预留元素槽位]
│ │ │
▼ ▼ ▼
A 的字节 你好的字节 OK 的字节底层数组元素连续,不等于图中三个正文也首尾相接。顺着 messages 取每个字符串的长度,与真正逐字节扫描所有正文,会访问不同的数据。Go 切片规范
这也说明,copy 一个 []string 会复制字符串元素值,不会按每个文本字节做独立深复制。字符串不可变,所以共享已有正文可以保持语言语义;不能仅看列表有两份就把每条正文内存机械乘 2。
3.4 长度、容量和保留范围
回顾 len 与 cap:长度是当前可通过普通下标访问的元素数;容量涉及从当前起点可以扩展到的底层数组范围。它们的单位首先是元素,不是字节。
例如 len(messages)=3、cap(messages)=8,并不能直接说正文占用 8 B。若纸上假设每个字符串元素槽位为 16 B,八个槽位就需要 8×16=128 B 的槽位空间,还没有计入正文与切片自身等内容。
这里的 16 B 是为计算特意给定的假设,不是 Go 规范对所有架构和实现的保证。真实对象布局、对齐、分配器取整和共享关系需要具体环境证据。
一个小子切片可能仍使更大的底层数组保持可达。把长度改小或赋成空值,不等于此前所有相关内存立即归还操作系统。还要看其他引用、垃圾回收和运行时管理,后续 03.06、05 卷会继续说明。
3.5 正文求和既不是通用上界,也未必是物理下界
应谨慎使用“最低要占多少内存”。若假定每条正文都独立、原样保存且不压缩,正文长度和是这部分内容存储的基础量,其他开销再另加。
若两个字符串共享同一份正文,或者系统使用压缩,按条求和可能重复计算物理存储;若有编码副本、预留容量或保留了大缓冲区,实际内存又可能远大于正文和。因此离开假设,“正文长度和就是进程内存的下限”也不总成立。
RSS 是常见的进程驻留内存指标,描述某种系统统计口径下该进程驻留在物理内存中的页面量。这里的“页面”暂指操作系统管理内存的块;RAM 在本章指主内存。页面的大小、映射与管理机制留到后续章节。它还可能涉及程序代码、运行时、栈、其他对象和共享页等;具体计数与精度要看工具。它没有承诺恰好等于 Go 对象长度之和。Linux RSS 统计说明
本章先建立几张账:正文逻辑字节量、某个表示的编码大小、假设布局下的对象开销、操作系统观察到的进程资源。它们可以互相解释,但不能随意把一个数字换名后当成另一个。
四、地址告诉程序到哪里找数据
4.1 先用一个假想的字节位置表
地址用于标识某个地址空间中的位置。在本章的字节寻址模型里,每个可寻址位置保存一个字节。假设一个区域从十进制位置 1000 开始:
| 假想地址 | 保存的字节,十六进制 | 若按 ASCII 文本解释 |
|---|---|---|
| 1000 | 41 | A |
| 1001 | 42 | B |
| 1002 | 43 | C |
| 1003 | 44 | D |
1000 是位置,41 是内容,两者不能混为一谈。同一个位置可以在不同时间保存不同内容;相同的字节也可以出现在不同位置。
这里的编号完全为讲解而设,并非本机运行结果。地址也不是用户 ID、消息 ID 或文件行号。这些业务身份即使写成整数,意义和稳定性都与内存位置不同。
4.2 下标与地址怎样建立联系
假设一个字节数组从上表的 1000 开始,元素下标 0 对应第一个位置,下标 1 对应下一个位置。对 byte 这样的单字节元素,在这个简化区域中可以写:
元素位置 = 起点 + 下标 × 每元素字节数
= 1000 + i × 1如果每个元素占 4 B,则相邻元素起点相差 4,而不是 1。语言帮你完成元素寻址,不要求你每次在 Go 代码中手算地址。
这一模型只讲普通连续元素的逻辑布局。复合对象内部可能还有对齐或指向其他内容的描述;[]string 的元素位置计算不能自动推导出字符串正文所在的位置。
4.3 进程看到的是自己的虚拟地址空间
进程可以先理解为一个正在运行的程序实例及其运行所需状态。同一程序启动两次,可以形成两个实例。完整的进程、线程和系统调用将在 03.02 展开。
常见现代操作系统让进程使用自己的虚拟地址空间:它看到一套位置编号,硬件和操作系统共同将这些位置关联到实际内存,并进行相应的访问保护。OSTEP:地址空间
进程甲看到的地址 1000 ── 映射关系甲 ──→ 物理区域 P
进程乙看到的地址 1000 ── 映射关系乙 ──→ 物理区域 Q即使地址数值相同,甲、乙也可能读到不同内容。这里 P、Q 是标签,不是实验测得的内存编号。某些显式共享场景也可以让不同进程映射同一底层数据;地址空间隔离并不意味着任何数据都永不共享。
连续的虚拟地址也不必对应连续的物理内存。地址转换、页、页表和 TLB 会在 03.05 分层讲解,现在先记住“程序看到的位置”与“物理存放位置”有一个转换层。
4.4 地址空间很大,不表示已经占用同样多的 RAM
能编号到很大的地址,不等于电脑装有那么多内存,也不等于进程已经把整个空间用满。地址空间、已经建立的映射、实际触碰的数据与当前驻留量是不同的问题。
有的分配或映射会先建立逻辑范围,后续访问才引发额外处理;具体行为受操作系统、分配方式和状态影响。本章不把“申请了多少字节”直接画成相同数量的 RAM 立刻被永久占满。
类似地,丢弃一个 Go 变量的可见名字,也不能直接推出 RAM 数字马上下降。变量的作用域描述名字在哪里可用;对象是否仍被其他数据引用,运行时何时回收,操作系统何时得到空闲页面,需要继续区分。Go 垃圾回收指南
4.5 源码变量并非固定贴在一个物理格子上
刚学编程时,可以把变量画成一个装值的格子,这有助于追踪赋值。深入系统时要知道,这是一种语言层模型。
编译器可能把某个值放在寄存器里,可能将它写到内存,也可能发现它无需真正存在而消除相关工作。同一个表达式的实现还会随目标架构和优化改变。没有必要在本章提前学习 &、* 或 unsafe,也不需要猜某个变量的真实地址。
你现在需要的能力是分清:名称、值、位置和业务身份。消息在切片中从下标 1 移到下标 0,不说明它成为另一个用户的消息;地址变化也不应被当作长期业务身份变化。
五、源码表达意图,机器指令实现工作
5.1 CPU 怎样执行一个程序
CPU 是执行指令的处理器。一个简化的理解过程是:取出指令,解释它要做的操作,执行取数、计算、比较或跳转,再继续后面的工作。
现实处理器可能并行处理多条指令、重排内部执行并预测分支;起步先用有序语义模型理解程序的结果,不把简化顺序图当成处理器内部每个时钟周期的照片。OSTEP:程序运行与指令
Go 程序从文字到运行,经过若干层:
Go 源文件
↓ 编译:检查语法和类型、建立中间表示、优化
面向目标架构的机器代码与其他数据
↓ 链接:组合程序所需部分
可执行文件
↓ 操作系统加载并建立运行环境
进程执行指令,使用 CPU、内存与系统接口图只帮助定位职责。一次 go run 会自动安排有关步骤,但屏幕上输入一条命令,不代表内部只做一步工作。不同目标架构需要适合它的指令集,不能把所有电脑理解成执行完全相同的机器编码。
5.2 用完整程序追踪正文长度和
下面程序只使用已学的字符串切片、普通函数、循环与类型转换。可在自己新建的 im-resource 学习目录中,按 01.01 的方式初始化模块;每个完整程序单独替换 main.go,不要将多个同名函数拼接。
package main
import "fmt"
func bodyBytes(messages []string) int64 {
var total int64
for _, body := range messages {
total = total + int64(len(body))
}
return total
}
func main() {
messages := []string{"A", "你好", "OK"}
fmt.Println(bodyBytes(messages))
}预期输出:
9len(body) 返回本条字符串的字节长度,类型为 int;这里转成 int64 后累加,以便让返回类型明确。这个完整例子只处理给定的小输入,不把它当作无上限生产计数接口。
| 循环步骤 | body | len(body) | 之前 total | 之后 total |
|---|---|---|---|---|
| 1 | A | 1 | 0 | 1 |
| 2 | 你好 | 6 | 1 | 7 |
| 3 | OK | 2 | 7 | 9 |
这个表描述语言层可观察的值变化。它没有告诉你每一步需要几个 CPU 周期。
5.3 读取长度与扫描正文不是同一工作
Go 字符串的长度可从其描述中取得,计算 len(body) 不需要先沿正文逐字节寻找结束标志。因此上面的循环主要按消息条数处理字符串元素,不能说它必然读取这 9 个正文编码字节来完成求和。
对比下面独立的完整程序:
package main
import "fmt"
func byteValueSum(data []byte) int {
total := 0
for _, value := range data {
total = total + int(value)
}
return total
}
func main() {
data := []byte{65, 66, 67}
fmt.Println(byteValueSum(data))
}预期输出 198,因为 65+66+67=198。这次循环必须按程序语义取得每个元素的值,其处理对象是正文样式的字节序列;它算的不是长度 3,也不是字符数。
这只是一个小教学计算,不是可靠的消息校验算法或安全校验和。我们用它观察“有几个元素”和“每个元素是什么”会要求不同工作。
5.4 一行 Go 不对应固定数量的机器指令
源码中的一次相加,可能涉及读取操作数、算术和保存结果,也可能因为值已经在寄存器里而减少操作。编译器还可能进行常量折叠、消除无用计算或调整循环。
“这是 3 行,所以是 3 条指令”“循环十次,所以固定十个周期”都缺少必要信息。即使拿到某个版本的反汇编,也还要区分指令条数与实际执行时间。Go 的汇编文档同样说明其工具链表示与最终目标指令之间的层次。Go 汇编说明
CPU 频率表示时钟节奏,例如某个 GHz 标记对应每秒多少十亿次时钟周期。它不是“每秒完成多少条 IM 消息”的直接标尺。每条消息的指令、内存等待、系统调用和调度都可能不同。
5.5 先数清做了什么,再提出性能问题
现在可以提出比“代码是不是快”更具体的问题:
- 只统计每条消息的长度,还是检查每个正文编码字节?
- 一批消息重复扫描几次?每次是否得到业务需要的结果?
- 是否生成额外编码副本?副本需要保留多久?
- 等待发生在 CPU、内存访问、队列还是文件/网络接口?
这些问题先确定工作范围,再决定测量什么。循环次数、字节数量是有用的解释证据,实际时间仍需具体实现、输入和环境。后续性能章节再安排有控制条件的测量。
六、寄存器、缓存、内存与设备各自承担什么
6.1 为什么需要多个层次
如果所有数据都能无限量地放在离执行单元最近、访问最方便的位置,存储就简单了。现实中容量、访问延迟、带宽、成本和持久性之间存在约束,因此系统使用多个层次。
先定义三个常见指标:
- 容量:能放多少内容,常以 B 计量。
- 访问延迟:从发起一次访问到获得需要结果的等待时间。
- 带宽:单位时间内能够传送多少数据,例如 B/s。
一个系统可以单次等待较长,却在大批量连续传输时提供较高带宽;这两项不是同一个排名。带宽也不同于业务吞吐,后者可能按条/s、请求/s 统计。
6.2 用角色认识层次,不背未经测量的时间表
| 层次 | 主要角色 | 容量与访问特点的直觉 | 对断电保留的基本认识 |
|---|---|---|---|
| CPU 寄存器 | 保存正在计算所需的少量值和执行状态 | 紧靠执行工作,数量有限 | 普通常规运行中不能当作断电存储 |
| CPU 缓存 | 在处理器附近保留部分内存数据和指令 | 按块提供较快的重复访问,容量有限,常有多级 | 不提供普通文件的断电持久承诺 |
| 主存,通常指 DRAM | 保存运行中的代码、对象与系统数据 | 比缓存容纳更多活跃数据,访问代价另有层次 | 常规 DRAM 依赖供电维持数据 |
| SSD/HDD 等存储设备 | 保存文件等长期数据 | 通常可容纳更大数据,访问受设备与 I/O 路径影响 | 介质可持久,但完成条件需单独确认 |
表中没有给出固定纳秒或固定容量,因为处理器、设备、连接方式、负载和访问模式都影响数字。L1、L2、L3 是常见缓存级别名称,不要求每台机器拥有同样级数、大小或共享方式。
处理器官方架构手册提供具体架构的执行环境、缓存与内存说明。读它们时需要固定处理器范围,不能把一款处理器的配置当成所有电脑的定义。
6.3 缓存命中只是少走了一部分路径
缓存是在较近或较快的位置保留较远处内容的一种机制。需要的数据在某一级缓存中,称为命中;不在,就可能需要从更低层取得,称为未命中。
未命中不表示这条消息不合法,也不等于数据丢失。命中也不是零时间:仍然要定位数据、传送数据并完成相应操作;不同级命中的代价也可能不同。
CPU 缓存通常按缓存行保存和传输相邻的一块字节。实际缓存行大小与策略要看架构。第七节会另给一个故意很小的纸上模型,不把模型的 4 B 当作真实 CPU 参数。
CPU 执行需要一个值
↓
某一级缓存已有?──是──→ 从该层取得
│否
↓
继续向更低层寻找,并可能补入缓存并不是每次访问都必须先后读一遍所有层次。也不能说所有 CPU 缓存都采用同一种包含关系、替换规则或写策略;写入何时传播到其他层属于更具体的架构问题。
6.4 三种“缓存”不要混用
| 名称 | 大致由谁管理 | 存放或加速的对象 | 本课程深入位置 |
|---|---|---|---|
| CPU 缓存 | 处理器硬件机制为主 | 内存访问涉及的数据与指令块 | 本章建立直觉,性能章节回访 |
| 操作系统页缓存 | 操作系统 | 文件等 I/O 相关的内存页面 | 03.06–03.08 |
| 应用缓存,例如 Redis 中的缓存数据 | 应用与缓存服务 | 业务记录、查询结果等 | 第 07 卷 |
Redis 是软件系统,它自己也运行在 CPU 和内存上。应用缓存命中仍可能发生网络往返、编码与 CPU 缓存未命中。使用同一个“缓存”词,不表示这些机制拥有相同的操作接口、单位或故障保证。
本章不要求部署 Redis,也不把 CPU 缓存策略套成业务缓存过期策略。
6.5 数据写到哪里,决定此时承诺了什么
考虑未来聊天记录保存的简化路径:
应用手中的数据
→ 可选的应用缓冲
→ 操作系统接受写入,可能进入页缓存
→ 设备处理相关写入
→ 在具体同步与设备条件下获得所需持久性每个箭头的完成点都需要定义。“函数返回了”“系统调用接受了字节”“设备完成了写入”“断电后仍可恢复”不是自动相同的事件。
例如 Linux write 成功返回,不保证数据已经写入持久介质;返回量还可能小于请求量。fsync 是要求同步相关状态的重要接口,但何时调用、目录变化怎样处理、设备是否正确兑现承诺,需要在 03.08 的完整场景中学习。write 官方手册
这里不提供一条命令就保证所有聊天记录永不丢失的配方。先建立完成层次,后续再分析文件、事务和恢复协议。
6.6 退出、崩溃和断电不是同一个故障
| 故障 | 本章能确定什么 | 还不能直接推出什么 |
|---|---|---|
| 正常程序退出 | 进程结束,原来的普通内存状态不再作为该实例继续运行 | 所有数据都已经保存成功 |
| 进程崩溃 | 应用可能没来得及执行计划中的收尾 | 已被系统接受的数据必然全丢或必然全保留 |
| 机器断电 | 常规易失存储不能承担保留数据的承诺 | 之前发起的所有设备写入都已可靠完成 |
SSD 的非易失介质提供持久存储基础,但设备前面的各级缓冲与完成条件仍然存在。若业务要承诺“可以恢复”,必须说明恢复哪部分状态、针对哪种故障、用什么证据确认。
七、第二遍阅读:局部性为什么重要,又为什么不能直接报加速倍数
7.1 时间局部性与空间局部性
时间局部性:刚访问过的数据,接下来还可能再次使用。例如重复检查同一份配置。
空间局部性:访问某个位置后,接下来还可能访问附近位置。例如依次扫描字节数组的相邻元素。附近应理解为存储位置关系,不是两条消息的业务含义相近。
缓存可以利用这两种倾向,但它们不是所有程序必须满足的规律。OSTEP:局部性
工作集是某个时间窗口中活跃使用的数据集合。一个用户有多年消息历史,不代表当前一次请求会把全部历史作为工作集。窗口变了、请求变了,工作集也可能变。
7.2 先声明一台不存在的纸上缓存
下面完全是便于手算的模型,不是硬件规格或测量工具输出。约定:
- 有 16 个字节元素,下标为 0–15。
- 每 4 个相邻字节组成一个块:块 0 放 0–3,块 1 放 4–7,块 2 放 8–11,块 3 放 12–15。
- 缓存最多放 2 个块,开始为空;每块可以放入任意缓存位置。
- 每次读一个字节。块已在缓存就是命中;否则取入整个块,计一次未命中。
- 放满时淘汰最久没有被访问的块,称为 LRU;不预取,不讨论写入。
- 以下列表把最近用过的块放右边,最久未用的块放左边。
这些假设确定后,结果才能被重复推导。真实 CPU 的缓存映射、替换、预取和并行机制可能完全不同。
7.3 顺序访问:读一次块,使用里面多个字节
访问顺序为 0,1,2,...,15。前八次:
| 次数 | 访问下标 | 所属块 | 结果 | 访问后缓存,从旧到新 |
|---|---|---|---|---|
| 1 | 0 | 0 | 未命中 | [0] |
| 2 | 1 | 0 | 命中 | [0] |
| 3 | 2 | 0 | 命中 | [0] |
| 4 | 3 | 0 | 命中 | [0] |
| 5 | 4 | 1 | 未命中 | [0, 1] |
| 6 | 5 | 1 | 命中 | [0, 1] |
| 7 | 6 | 1 | 命中 | [0, 1] |
| 8 | 7 | 1 | 命中 | [0, 1] |
随后读 8 时取入块 2,淘汰块 0;读 9–11 都命中块 2。读 12 时取入块 3,淘汰块 1;13–15 都命中。
总计:16 次字节访问,4 次未命中,12 次命中。命中比例为 12/16=0.75=75%。比例就是一部分数量除以总数量,百分数将它乘 100 后加 %。
7.4 同样读完所有元素,另一种顺序产生不同轨迹
改成以下交错顺序:
0, 4, 8, 12,
1, 5, 9, 13,
2, 6, 10, 14,
3, 7, 11, 15它有规律,不能称为真正随机。它仍然读每个元素一次,但轮流访问四个块,缓存只容纳两个。
| 次数 | 下标 | 块 | 结果 | 淘汰 | 访问后缓存 |
|---|---|---|---|---|---|
| 1 | 0 | 0 | 未命中 | 无 | [0] |
| 2 | 4 | 1 | 未命中 | 无 | [0, 1] |
| 3 | 8 | 2 | 未命中 | 0 | [1, 2] |
| 4 | 12 | 3 | 未命中 | 1 | [2, 3] |
| 5 | 1 | 0 | 未命中 | 2 | [3, 0] |
| 6 | 5 | 1 | 未命中 | 3 | [0, 1] |
| 7 | 9 | 2 | 未命中 | 0 | [1, 2] |
| 8 | 13 | 3 | 未命中 | 1 | [2, 3] |
每次回到某块之前,它已经被淘汰;剩余八次同样如此。总计 16 次未命中、0 次命中。
两种方式读的字节值总数相同,模型中的取块次数不同。这说明仅说“都循环十六次”可能遗漏重要机制,但还不能宣称第一种程序在真实机器上恰好快四倍。
7.5 改变假设,结论也会改变
若缓存能放 4 个块,两种访问方式第一次读到各块时各有一次未命中,随后都能命中,总结果都是 4 次未命中。若数据根本没被真正读取,或者编译器消除了计算,上面的访问轨迹就不是实际执行轨迹。
反向顺序 15,14,...,0 仍依次使用邻近字节,在本模型中也有 4 次未命中;它不能被随意归类为“随机访问,所以必然很慢”。
回到 []string:按下标顺序取元素,有助于理解元素数组的局部性;每条正文分布在哪里、是否被读取、是否共享,还需要另看。只计算 len 的例子不能拿来证明“顺序读取全部聊天正文”的性能。
7.6 从纸上假设走到日后的实际证据
将来要验证某种访问顺序,需要至少写清:相同输入规模、相同计算结果、具体机器、编译器与构建选项、工作集大小、是否包含分配和 I/O、数据冷热状态以及重复测量方法。
还应排除把准备数据的时间算进一个版本、却不算进另一个版本等不公平比较。预取、并行处理、分支、其他进程与系统负载,都可能影响结果。
本章只完成机制推演和实验问题设计。现在可以提出“在这些输入和机器条件下,交错访问是否引入更多等待”的假设;获得测量证据之前,不把假设写成通用性能保证。
八、综合:给 IM 缓冲写一份有边界的资源预算
8.1 先说业务状态,再定义计算模型
假设教学 IM 在某个阶段保留待处理消息,直到下一处理阶段接手。本题只预算这个阶段的记录,不代表已发送、已落盘或对端已读。
使用四个参数:
| 参数 | 含义 | 单位 | 本次假设 |
|---|---|---|---|
| N | 同时保留的消息条数 | 条 | 10000 |
| P | 每条正文预留的字节上限 | B/条/份 | 256 |
| H | 每条每份额外记录开销 | B/条/份 | 64 |
| C | 独立保存完整记录的份数 | 份 | 2 |
这里额外假设每份都独立保存正文及 H,互不共享,忽略压缩;每条按 P 预留。这些是模型定义,不是语言或 OpenIM 的实现事实。这里的“副本”特指完整独立保存的一份记录;如果程序只复制一个引用或共享正文,应重新建立公式。
P=256 是新的预算场景参数,不宣称前面校验例子的上限突然改成 256。若使用其他业务上限,应显式修改参数、前提和验收。
8.2 一步一步把条数变成字节数
一份记录的预算:
P + H = 256 + 64 = 320 B/条/份每条保存两份:
320 B/条/份 × 2 份 = 640 B/条一万条:
M = N × (P + H) × C
= 10000 × 320 × 2
= 6400000 B
= 6.4 MB
≈ 6.1035 MiB公式中的单位帮助检查是否漏乘或多乘。M 只表示该模型覆盖的记录预算,不是进程 RSS 的承诺。它也不是无条件上界:如果 H 实际高于 64、发生额外编码或保留容量,模型就漏项。
如果 P=256 来自“平均正文长度”,结果只是平均情形的估算,不能宣称所有输入都受这个上界限制。本题特意使用“每条预留上限”,需要实际实现与输入约束兑现它。
8.3 从预算反推最多保留多少条
假设你在纸上给进程安排 32 MiB 总目标,另为运行时与其他用途预留 16 MiB,给本模型留下 16 MiB:
32 MiB = 33554432 B
预留 = 16777216 B
可用 = 16777216 B
每条 = 640 B16777216 ÷ 640 不能得到整数条。最大完整条数取不超过结果的整数部分:26214 条。
| 情景 | 模型字节数 | 与 16 MiB 的关系 |
|---|---|---|
| 0 条 | 0 | 没有记录占用,但不代表进程为零内存 |
| 10000 条 | 6400000 | 留余量 10377216 B |
| 26214 条 | 16776960 | 留余量 256 B |
| 26215 条 | 16777600 | 超出 384 B |
最后 256 B 不够再容纳一条 640 B 记录。这是向下取整的业务原因:不能接收“半条消息”来满足整数预算。
给其他用途预留 16 MiB 也只是本题假设,不能替代实际测量。如果其他用途会超过预留,这个整体目标依旧可能被打破。将来需要把模型预测与运行指标对照,找出漏项和峰值。
8.4 用已有语法写一个有范围的估算函数
下面是另一个独立完整程序。函数先限制参数范围,再相乘;返回 false 表示不接受这个模型输入,并非“消息发送失败”。
package main
import "fmt"
func estimateBytes(count, bodyLimit, extra, copies int64) (int64, bool) {
if count < 0 || count > 1000000 {
return 0, false
}
if bodyLimit < 0 || bodyLimit > 4096 {
return 0, false
}
if extra < 0 || extra > 4096 {
return 0, false
}
if copies < 1 || copies > 4 {
return 0, false
}
return count * (bodyLimit + extra) * copies, true
}
func main() {
const available int64 = 16 * 1024 * 1024
const count int64 = 10000
const bodyLimit int64 = 256
const extra int64 = 64
const copies int64 = 2
used, ok := estimateBytes(count, bodyLimit, extra, copies)
if !ok {
fmt.Println("拒绝:预算参数超出模型范围")
return
}
fmt.Printf("模型需要 %d B\n", used)
if used > available {
fmt.Println("模型预算不足")
return
}
fmt.Printf("模型剩余 %d B\n", available-used)
}固定输入的预期输出:
模型需要 6400000 B
模型剩余 10377216 B参数都用 int64,并限制最大结果为 1000000×(4096+4096)×4=32768000000,小于 int64 最大值。这里先控制输入,使后面的乘法有可解释边界;不能把一次安全示例推广为任意乘法永不溢出。
资源函数允许 0 条、0 B 正文预算,是为了表达空集合等计量情景。它不替代消息文本的非空校验。调用者不能看到 ok=true 就说“这条消息业务上合法”。
8.5 只限制每条大小,仍然可能积压
假设初始没有待处理记录,持续 10 s:每秒进入 5000 条,每秒离开这个阶段 4000 条,速率保持不变,也没有额外丢弃或重试。
每秒净增加 = 5000 - 4000 = 1000 条/s
10 s 后净增加 = 1000 条/s × 10 s = 10000 条按前面的 640 B/条,是额外 6400000 B 模型占用。即使每条都不超过 P,只要进入长期多于离开,保留量仍会继续增长。
“离开这个阶段”可以是下一阶段接手,不自动代表对方收到。将来讨论流控、丢弃、拒绝、排队或持久队列时,要先定义业务可接受行为。本章只说明:容量预算需要条数、大小、副本及保留时间共同参与。
8.6 迁移到 OpenIM:找到累计点,再列出未知项
课程固定参考 OpenIM v3.8.3-patch.16,提交 f6411a8a1a31d3df36f4c2b3ad28481a94141e1f。本章实际读取了 sync_msg.go 中的 GetSeqMessage。
在已读范围内,该函数遍历请求中的会话项,通过数据库接口取得一批消息,将批次追加到对应结果的消息列表,并设置结束信息。这里不要求初学者理解完整方法、指针、map 或 RPC 语法,只把“批次逐步累计到结果”作为资源问题入口。
由此可以提出待研究问题:请求有多少会话,每批有多少消息,正文及元信息多大,结果何时释放,底层元素或正文是否共享。仅看一次 append,无法得出固定复制次数、每条内存大小或进程峰值。
我们没有据此审计入口限制、数据库内部、序列化、缓存、SDK 或完整恢复链路,也没有得出性能和持久保证。本题 256 B、64 B、两份副本与 32 MiB 预算都来自教学假设,不能作为 OpenIM 的配置建议。固定版本与阅读范围见 IM 阅读地图。
8.7 分层练习与反馈
先写出单位、前提和推导,再查看反馈。答案中的模型结果仍不等于实测结果。
练习 1:256 bit 与 256 B 相差多少?
反馈: 256 bit 是 32 B;256 B 是 2048 bit。后者的数据量是前者的 8 倍。字母大小写承载单位含义。
练习 2:1 MiB 与 1 MB 哪个更大,相差多少字节?
反馈: 1 MiB=1048576 B,1 MB=1000000 B,相差 48576 B。不能只写“差一点”,因为大批量估算会累计差异。
练习 3:二进制 00010110 按无符号数解释是多少?
反馈: 16+4+2=22。先标位权再相加;前导零表示占位,不改变这个数值。
练习 4:11111111 为什么能解释为 255 或 -1?
反馈: 无符号解释各位权重为正;int8 的二补码解释把最高位权重记为 -128。位模式一样,解释规则不同。不能只看字节就猜业务含义。
练习 5:大端的 01 02 应读为多少?当成小端会怎样?
反馈: 大端是 1×256+2=258,小端是 2×256+1=513。问题是字节序约定不匹配,不是字节本身一定损坏。
练习 6:A、你好、OK 的正文总共 9 B,为什么本题封装后是 15 B?
反馈: 每条新增 2 B 长度字段,三条加 6 B。这个总数仅覆盖已定义的教学格式,没有计算真实网络路径的其他开销。
练习 7:两份 []string 列表包含同样正文,是否一定复制两份全部文本?
反馈: 不一定。复制列表元素可以共享不可变字符串数据;底层数组也可能共享。先画元素与正文的关系,不能仅按列表数量断定物理副本数。
练习 8:两个进程都打印地址 1000,是否必然在改同一份内存?
反馈: 不必然。地址属于各自地址空间,可映射到不同物理区域;也存在显式共享情景。单独的地址数值不足以作判断。
练习 9:统计 []string 的 len 和,是否已经逐字节读过每条正文?
反馈: 没有这个结论。字符串长度可从描述中获得;要计算每个字节值之和或校验编码,会涉及不同的访问。性能比较必须比较实际工作。
练习 10:缓存模型改为能保存 4 个块,两种访问顺序各未命中几次?
反馈: 从空缓存开始都为 4 次。首次访问各块时取入,之后无需淘汰。本题不把容量改变前的差距套到改变后的模型。
练习 11:两块缓存模型中,0、4、0、8、0 的结果是什么?
反馈: 对应块为 0、1、0、2、0。依次未命中、未命中、命中、未命中、命中,共 3 次未命中。第三次访问使块 0 成为最近使用项,读块 2 时淘汰块 1。这体现时间局部性与 LRU 更新。
练习 12:正文长度和只有 6 MiB,进程 RSS 为更大数值,是否必然内存泄漏?
反馈: 不能直接判定。对象描述、容量、运行时、其他数据、共享页及统计口径都可能造成差异。要看实际保留关系和变化趋势,不能用两种不同账目的不相等直接下结论。
练习 13:沿用预算模型,把 C 从 2 改为 3,一万条需要多少?
反馈: 10000×320×3=9600000 B。前提是每份记录仍独立保留 P+H;若只是多一个共享引用,不能沿用这个乘法。
练习 14:P=256 是平均值,能否据此允许任意 26214 条都不超预算?
反馈: 不能。较大的消息组合可能超过估计。需要定义最大长度、实际累积字节或其他可兑现的限制,平均值只能支持相应的平均情景分析。
练习 15:估算函数收到 -1 条、0 条或 5 份副本分别怎样?
反馈: -1 条返回 false;0 条且其他参数合法时返回 0 与 true;5 份超出本函数约定返回 false。拒绝参数是模型边界,不表示发生了网络发送。
练习 16:文件写入调用成功,就可以回复“断电后必能恢复”吗?
反馈: 不可以。先明确调用接受了多少数据、完成在哪一层、需要哪些同步与设备条件。普通写成功本身不足以给出该承诺,后续持久化章会构造故障点验证。
8.8 本章验收与后续方向
基础验收需要留下四份证据:一张带单位的容量换算表;一张正文、表示和进程内存的范围表;一份逐步预算及边界;一段指出未知项的资源说明。
第二遍再增加缓存访问轨迹,解释改变容量后为什么结果变化,以及为什么不能直接把未命中比例换成真实加速倍数。
接下来03.02 进程与系统调用解释程序怎样成为进程并读取本地历史;03.05 深入虚拟内存,03.06 讨论分配与统计,03.08 建立文件持久化模型。你不需要在本章一次掌握所有内部机制,但应能指出每个资源结论的单位、假设、位置与证据范围。
参考资料
- Operating Systems: Three Easy Pieces:从程序执行、地址空间、局部性与持久化建立系统视角;本章重组为 Go 初学者可推演的 IM 例子。
- OSTEP:文件与目录:后续理解文件接口与同步的阅读入口。
- Go 语言规范:数据类型、字符串、切片、赋值和整数运算规则。
正文各节链接到相应官方资料。资料用于核对与组织知识。书中实验结果需要在各自的环境前提下理解,不能直接作为本机性能证据。