计算机是怎么运行的?
图灵机
图灵机的组成
-
纸带:由一个个连续的格子组成,每个格子可以写入字符
-
读写头:可以读取、写入纸带。还有下面这些部件:
- 存储单元:存放数据;
- 控制单元:识别字符是数据还是指令,以及控制程序的流程等;
- 运算单元:执行运算指令;
-
执行过程:读取纸带,执行操作,写入纸带
冯诺依曼模型
组成:
-
中央处理器(CPU)
-
32 位 CPU 一次可以计算 4 个字节;
-
64 位 CPU 一次可以计算 8 个字节;
-
寄存器:存储计算时的数据
- 通用寄存器:运算的数据,比如需要进行加和运算的两个数据。
- 程序计数器:存储 CPU 要执行下一条指令「所在的内存地址」。
- 指令寄存器:存放程序计数器指向的指令,指令被执行完成之前都存储在这里。
-
-
内存:最小的存储单位是字节(byte)
-
输入设备
-
输出设备
-
总线
- 地址总线:用于指定 CPU 将要操作的内存地址;
- 数据总线:用于读写内存的数据;
- 控制总线:用于发送和接收信号,比如中断、设备复位等信号,CPU 收到信号后自然进行响应;
-
线路位宽:线路的比特位个数
-
CPU位宽:最好不要小于线路位宽
程序执行的基本过程
- CPU 读取「程序计数器」得到指令的内存地址,「控制单元」操作「地址总线」指定需要访问的内存地址,通知内存设备准备数据,通过「数据总线」将指令数据传给 CPU,存入到「指令寄存器」。
- CPU 分析「指令寄存器」中的指令,确定指令的类型和参数,如果是计算类型的指令,就把指令交给「逻辑运算单元」运算;如果是存储类型的指令,则交由「控制单元」执行;
- CPU 执行完指令后,「程序计数器」的值自增,表示指向下一条指令。这个自增的大小,由 CPU 的位宽决定,比如 32 位的 CPU,指令是 4 个字节,需要 4 个内存地址存放,因此「程序计数器」的值会自增 4;
缓存
Why?
CPU与内存速度差别太大,引入缓存进行速度平衡
How?
-
寄存器:几十到几百个,64 位 CPU 中大多数寄存器可以存储 8 个字节。时延0.5时钟周期0.1ns。
-
CPU Cache:SRAM(Static Random-Access Memory,静态随机存储器),分为三级
- 一个 bit 的数据,通常需要 6 个晶体管
- L1 高速缓存,0.5MB,2~4时钟周期1ns,分成指令缓存和数据缓存
- L2 高速缓存,几MB,10~20时钟周期5ns
- L3 高速缓存,几十MB,20~60时钟周期10ns,多个 CPU 核心共用
-
# L1 Cache 「数据」缓存的容量大小 cat /sys/devices/system/cpu/cpu0/cache/index0/size # L1 Cache 「指令」缓存的容量大小 cat /sys/devices/system/cpu/cpu0/cache/index1/size # L2 Cache 的容量大小 cat /sys/devices/system/cpu/cpu0/cache/index2/size # L3 Cache 的容量大小 cat /sys/devices/system/cpu/cpu0/cache/index3/size
-
内存:DRAM (Dynamic Random Access Memory,动态随机存取存储器)
- 存储一个 bit 数据,只需要一个晶体管和一个电容,需要「定时刷新」电容
- 时延200~300时钟周期,100ns
-
SSD(Solid-state disk),时延10~100缪秒
-
机械硬盘(Hard Disk Drive, HDD),时延10ms
内存与Cache的映射
CPU 访问内存数据时,是一小块一小块数据读取(coherency_line_size )64KB
# 查看L1 Cache数据缓存一次载入数据的大小
cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
-
直接映射 Cache(Direct Mapped Cache):内存块的地址「映射」在一个 CPU Line(缓存块) 的地址
-
取模映射
-
CPU Line的组成:
- 索引
- 组标记(Tag):当前 CPU Line 中存储的数据对应的内存块
- 数据(Data)
- 有效位(Valid bit):0无效
-
-
一个内存的访问地址,包括组标记、CPU Line 索引、偏移量
CPU 访问内存地址的步骤:
- 根据内存地址中索引信息,计算在 CPU Cahe 中的索引(CPU Line 的地址)
- CPU Line有效直接读取,无效重新加载
- 对比内存地址中组标记和 CPU Line 中的组标记,确认 CPU Line 中的数据是我们要访问的内存数据
- 根据内存地址中偏移量信息,从 CPU Line 的数据块中,读取对应的字。
其他方法:
- 全相连 Cache (Fully Associative Cache)
- 组相连 Cache (Set Associative Cache)
如何写出让 CPU 跑得更快的代码?
-
提高缓存命中率:
-
数据缓存:顺序遍历数组,例如二维数组不要纵向遍历
-
指令缓存:分支预测器 if likely 和 unlikely
- 分支预测可以预测到接下来要执行 if 里的指令,还是 else 指令的话,就可以「提前」把这些指令放在指令缓存中,这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会很快。
-
-
将线程绑定在某一个 CPU 核心上,提高 L1 L2 cache的更新
CPU 缓存一致性
Why?
CPU cache数据修改后与内存不一致,什么时候写回内存?
How?
-
写直达(Write Through):把数据同时写入内存和 Cache 中
- 每次写操作都会写回到内存,这样写操作将会花费大量的时
-
写回(Write Back):新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才需要写到内存中
缓存一致性(Cache Coherence)
Why?
多核心L1 L2 cache数据不一致导致的问题,例如一个核修改了数据,其他核缓存了这个数据,不知道已修改就会产生错误。
How?
-
写传播(Wreite Propagation):某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache
- 多核同时修改核传播,其他核收到消息的顺序不一样也会产生问题
-
事务的串形化(Transaction Serialization):某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,消除了写传播顺序的问题。实现条件:
- CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
- 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新
-
MESI 协议:基于总线嗅探,用状态机机制降低了总线带宽压力,实现事务串行化。四个状态:
- Modified,已修改
- Invalidated,已失效
- Exclusive,独占
- Shared,共享
- Cache Line 状态是「已修改」或者「独占」状态时,修改更新其数据不需要发送广播给其他 CPU 核心,这在一定程度上减少了总线带宽压力。
Cache 伪共享
Why?
伪共享(False Sharing):多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象。
How?
经常会并发修改的数据,避免在同一个 Cache Line 中。
-
Linux 内核中存在 __cacheline_aligned_in_smp 宏定义,是用于解决伪共享的问题。
-
struct test { int a; int b __cacheline_aligned_in_smp; } - 缺点:会浪费一定的空间
-
-
go语言伪共享速度对比:10倍效率提升
// 伪共享速度对比
type t1 struct {
a int64
b int64
c [48]byte
d int64
e int64
f [48]byte
}
func main() {
t := t1{}
wg := sync.WaitGroup{}
wg.Add(2)
t0 := time.Now()
go func() {
for i := 0; i < 1000000; i++ {
t.a++
}
wg.Done()
}()
go func() {
for i := 0; i < 1000000; i++ {
t.d++
//t.b++
}
wg.Done()
}()
wg.Wait()
fmt.Println(time.Since(t0).String())
}
// 并发修改ab:5~10ms
// 并发修改ad:0.5ms