定义了 goroutine 间,内存可见性的保证,即一个 goroutine 对变量的读取,可以观察到不同 goroutine 中同一变量写入所产生的值,且程序需要避免数据竞争,确保可预测的行为;
当没有数据竞争时,程序的行为如同所有 goroutine 都在一个处理器上通过多路复用的方式执行,被称为 无数据竞争-顺序一致性(DRF-SC)模型;
go 通过 race 检测数据竞争并报告,且终止程序;
内存的读取可以保证相对 c++ 可控,因为 go 保证读取到的值是已经写入且未被覆盖的值,而 c++ 会出现完全不可预知的情况(未定义行为,由编译器决定处理);
内存操作:
- 操作种类,如普通读写、原子读写(其他 goroutine 看不到中间结果)、互斥锁、channel 等操作;
- 操作在程序中的位置;
- 正在访问的 内存位置 和 变量;
- 正在操作的 读或写的 值;
注:串行化:指由一个 goroutine 执行对应任务,本质是指 操作按照一定顺序一个接一个执行,但中间结果对外是可见的,即原子操作一定是串行化的,但串行化的执行不一定是原子操作(即操作可以被分割出多个阶段,阶段的间隙中,中间结果对外是可见的,如果不分割串行化执行的操作,那么此时就等于原子操作了);
单 goroutine 的执行,可被视为:单个 goroutine 执行的一组内存操作行为,要求:
- 一组的内存操作的顺序,必须和该 goroutine 的正确顺序执行一致,即
sequenced before
关系,偏序关系; - 映射,定义了内存操作之间的关系,即 映射
W:W[r] = w
,读操作r
从写操作w
读取了数据,意为w
在r
之前完成,且完成同步内存; - 对于 非同步读取的数据,必须满足:(1)写入 发生在 读取 之前;(2)在 写入 完成到 读取 时,不能有其他 写入 操作干扰;
- 如果 读写/写写 其中一个是 非同步 的,且操作间没有明确的先后顺序(
happens before sequence
),则发生读写数据竞争; - 可通过同步原语保护顺序执行;
- 如果 读写/写写 其中一个是 非同步 的,且操作间没有明确的先后顺序(
偏序关系(Partial Order): 是定义在一个集合上的二元关系,它满足以下三个条件:
- 自反性(Reflexivity):对于集合中的任何元素 。a,有 $a \leq a$;
- 反对称性(Antisymmetry):如果 $a \leq b$ 且 $b \leq a$,则 $a = b$;
- 传递性(Transitivity):如果 $a \leq b$ 且 $b \leq c$,则 $a \leq c$;
在偏序关系中,不要求任意两个元素之间都可以比较,这意味着某些元素可能无法直接比较,例如,集合中的元素 a 和 b 可能既不满足 $a \leq b$,也不满足 $b \leq a$,即它们是不可比较的; 如:子集关系是一个典型的偏序关系。对于集合 $A = {1, 2}$、$B = {1}$、$C = {2}$,我们有 $B \subseteq A$ 和 $C \subseteq A$,但是 b 和 c 无法比较(因为它们之间既不是子集关系,也不是包含关系);
全序关系(Total Order): 是一种特殊的偏序关系,它满足偏序关系的所有条件,并且还具备一个额外的条件:
- 可比性(Comparability):对于集合中的任意两个元素 a 和 b,要么 $a \leq b$,要么 $b \leq a$。即任何两个元素都可以进行比较;
全序关系表示的是一个线性排列,集合中的所有元素都可以按某种顺序排列起来;
实数集 上的大小关系 $\leq$ 是一个全序关系,因为对于任意两个实数 a 和 b,要么 $a \leq b$,要么 $b \leq a$;
sequence before
顺序一致性,即执行语句的执行顺序 严格按照书写顺序执行;
synchronized before
通过 锁 等同步原语形成的同步顺序;
happens before
保证了顺序性和可见性,一个操作在另一个操作前被观察到,如:操作 A happens-before 操作B,此时 A 的所有操作造成的影响,对于 B 执行时都是可见的;
在单一 goroutine 中,天然满足该关系;
race检测器的实现
本质上是 LLVM 的 ThreadSanitizer(TSan)竞态检测器;
工作方式:
分配了大量额外的虚拟内存,然后根据正在读取或写入的内存地址,它在虚拟内存中有另一个记录点(用于跟踪并检测并发访问情况),在那里记录了最后一个线程(检测器认为它是线程,但实际上是goroutine)的信息,当最后一个线程执行读取或者写入,然后每次异步事件发生时,如从一个goroutine到另一个的通信,如果在多线程程序中存在一个读操作和一个写操作,并且它们之间没有确定的happens before sequence(指两个并发操作之间存在一种确定的顺序关系,即第一个操作发生在第二个操作之前)将它们连接起来,那么这种情况就被称为竞态条件;
通过某种方式快速动态地算出:该读取操作是否发生在写入操作前,可能会减少10倍的速度,但确实值得(原始的ThreadSanitizer减慢了几百几千倍);
结构
mheap 负责管理堆内存的分配和释放,通过维护堆的状态,保证内存的高效使用和释放;
- 并发安全:mheap 在进行内存分配和释放时需要保证并发安全,因为 Go 语言是并发编程语言,可能有多个 goroutine 同时访问堆内存。mheap 通过使用锁或其他并发控制机制来保证对堆内存的访问操作的原子性和线程安全性;
内存申请
要通过 make
、new
或者普通的变量声明等方式实现。这些操作实际上会调用底层的运行时函数,从 mheap 中分配一定大小的内存块;(使用mallocgc
方法)
多级缓存
堆(mheap)
空间换时间,一次缓存,多次复用;负责管理,即一次申请足够的内存空间,减少向操作系统多次申请内存的损耗;是全局唯一的;在go runtime
内;
- 操作系统的视角下,堆是用户进程中缓存的内存;
- GO进程视角下,堆是所有对象的内存;
是运行时系统(runtime system)中的一个关键组件,用于管理堆内存;堆内存是由运行时系统动态分配和释放的内存区域,用于存储程序运行时动态分配的变量和数据结构;
- 作用:mheap 主要负责管理运行时的堆内存,包括内存的分配、释放和维护等操作;
- 数据结构:mheap 是一个结构体,其中包含了一些字段来描述堆内存的状态,如已分配和空闲内存块的信息等;
- 初始化:在程序启动时,mheap 会被初始化为一块内存区域,用于存储堆内存中的数据结构和元信息;
page:大小为8kb,是堆的最小存储单元;
mcentral
是 Go 内存分配器中负责管理相同大小的内存块的结构体。每个 mcentral 维护两个 mspan 链表,一个用于部分使用的内存块,一个用于空间已被完全分配的内存块;
为了给不同大小的对象分配内存,从小到大按照内存大小分级,当对象需要内存时,也是先找到对应等级的mcentral从中申请内存(一个mcentral有一个互斥锁);
- 通过双向链表,mcentral 能够高效地在这两个链表中移动 mspan 对象。例如,当一个 mspan 从部分使用变为完全空闲时,需要从部分使用链表中移除并插入到空闲链表中;
type mcentral struct {
_ sys.NotInHeap
spanclass spanClass
partial [2]spanSet // list of spans with a free object
full [2]spanSet // list of spans with no free objects
}
使用长度为2数组:是因为方便GC,分开已经被 swept 或者还没有的;
GC 只需要扫描 partial 列表中的 spans,因为这些 spans 仍然有可用的空闲对象,可能包含需要被标记的对象。而 full 列表中的 spans 是完全分配的,不包含空闲对象,因此可以跳过扫描,大大减少了 GC 的工作量。
mcache
每一个P,都有其对应的一个本地私有的 mcache
;
冗余一部分 mcentral
的等级内存,即每种 spanclass 的 mspan
均缓存了一个;
有一个对象分配器 tiny allocator ,用于处理小于 16B 的对象的内存分配;
对象可直接申请内存,因为 mcache
仅属于当前P,没有并发问题;
如果剩余内存空间不够分配,会继续向上申请(即 mcentral
层);
type mcache struct {
...
tiny uintptr
tinyoffset uintptr
tinyAllocs uintptr
// The rest is not accessed on every malloc.
alloc [numSpanClasses]*mspan // spans to allocate from, indexed by spanClass
...
}
mspan
是最小的管理单元,大小可以从8B到80 KB不等,被划分为67种不同的规格(或称为sizeclass),以支持不同大小对象的内存分配;
外部碎片:
定义:外部碎片是由于某些未分配的连续内存区域太小,以至于不能满足任意进程的内存分配请求,从而不能被进程利用的内存区域,分配前的碎片;
mcentral 按照对象的尺寸划分好一个个的内存单元(span),并按照实际大小放到对应的内存单元(mspan)里面去,这种做法有效地减少了外部碎片的产生,因为mspan为不同大小的对象提供了预分配的、适当大小的内存空间,避免了因为不断按照随机大小分配内存而导致的外部碎片问题;
内部碎片:
定义:内部碎片是由于采用固定大小的内存分区,当一个进程不能完全使用分给它的固定内存区域时就产生的碎片,分配后的碎片;
无法完全消除内部碎片,因为mspan是按照一系列预定义的大小规格来管理的,当实际对象的大小小于mspan分配的大小时,就会产生内部碎片;
span的实现,让mcentral上的锁粒度减小;
每个 mspan 结构体管理一块内存区域,这个区域会被进一步划分成小的内存块(slots),每个内存块可以单独分配给不同的对象,为了跟踪这些内存块的分配状态,Go使用了一个位图(allocBits),其中每一位表示一个内存块的状态:0 表示未分配,1 表示已分配;
// 1.21.3
type mspan struct {
_ sys.NotInHeap
next *mspan // next span in list, or nil if none
prev *mspan // previous span in list, or nil if none
...
startAddr uintptr // address of first byte of span aka s.base() (该内存块在虚拟地址的起始位)
npages uintptr // number of pages in span (间接表示mspan的大小,方便优化碎片和GC)
manualFreeList gclinkptr // list of free objects in mSpanManual spans
freeindex uintptr //表示从该索引开始往后搜索该 mspan 中的下一个空闲对象
nelems uintptr // number of object in the span. (即最多可存放object数量)
allocCache uint64
allocBits *gcBits
...
spanclass spanClass // size class and noscan (uint8) (mspan的等级)
...
}
allocCache
:
- 缓存 allocBits 状态:缓存了 allocBits 的状态,但是它保存的是 allocBits 的补码(即:未分配的位为 1,已分配的位为 0),这使得可以直接使用位操作来快速查找未分配的内存块;
- 位移操作:allocCache 会被适当地位移,使得最低位对应 freeindex 位置的位,这样在检查和更新 allocCache 时可以简化操作;
- 优化CTZ操作:因为 allocCache 保存的是 allocBits 的补码,直接使用 count trailing zeros(ctz64)操作可以快速找到第一个未分配的内存块(即从后往前找第一个1,未分配);
freeindex
& nelems
& allocBits
:
freeindex
是一个索引值,表示从该索引开始往后搜索该mspan
中的下一个空闲对象;allocBits
是一个位图,用于标记该mspan
中的对象分配情况;- 当需要分配内存时,会从
freeindex
开始搜索allocBits
,直到找到一个空闲的对象(即allocBits
中对应位为 0),然后将freeindex
调整到该对象的后一个位置,以便下次搜索; - 如果
freeindex
等于nelems,表示该
mspan
中没有空闲对象; allocBits
的每一位对应一个对象的分配状态,如果对应位为 0,则表示该对象是空闲的;否则,表示该对象已被分配;- 对象 n 的地址计算公式为
n * elemsize + (start << pageShift)
,其中n
是对象的索引,elemsize
是每个对象的大小,start 是该mspan
的起始地址,pageShift
是页面大小的位偏移量;
spanclass
:高七位表示当前 mspan
的等级,最后一位用于标识当前 span
有无存放在堆上的内存,如果有(true,有指针),GC 就需要额外处理释放,反之就不用 GC;
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}