golang的内存分配机制最初是基于TCMalloc,演化至今已经有了很大差异。其原理是:slab + tiling algorithm + 层级内存分配。本文仅介绍如何通过mallocgc分配内存,不涉及栈内存分配管理、手动内存管理等内容。当前go版本:1.24

前言

slab + tiling

内存分配基本单位-mspan(即slab),内部再划分更小的块(即tiling)

小对象(<=32KB)按预定义的sizeclass分成不同的mspan,每个mspan最低有8KB,切割成指定大小的块。而大对象(>32KB)的sizeclass=0,不限制大小,mspan不分块。如下

1
2
3
4
5
6
7
8
// mspann -> sizeclass=0  -> nKB  -> | <-----   nKB   -----> |
// ...
// mspanx -> sizeclass=1 -> 8KB -> | 8B | 8B | ... | 8B |
// ...
// mspany -> sizeclass=5 -> 8KB -> | 48B | 48B | ... | 48B |
// ...
// mspanz -> sizeclass=65 -> 80KB
// ...

make([]int, 5),创建一块40B大小的内存区域,经过计算这个内存块会从sizeclass=5的mspan分配(该mspan的每个元素大小为48B)

层级内存分配

参考TCMalloc,内存分配时主要分为3级:mcache、mcentral、mheap

  1. mcache为线程缓存,每个p都有一个,所有的内存分配/回收都是先通过mcache,访问不需要加锁
  2. mcentral负责向mheap申请分配内存、管理mspan,按spanclass分组,嵌入到mheap结构体,全局唯一
  3. mheap负责从系统申请内存进行分配管理,一般情况下访问都要加锁,全局唯一

执行mallocgc分配内存时,mheap、mcentral、mcache的关系如下

1
2
3
4
5
6
7
//  mallocgc
// |
// | (<=32KB)
// |--> p.mcache.alloc[spanclass] --> mheap.central[spanclass] --> mheap
// |
// | (>32KB)
// |--> mheap
  • 如果对象<=32KB
    • 从p的mcache找一个空闲的mspan分配一段内存/地址空间
    • 如果mspan没有空闲空间,从mcentral申请一个新的/重用一个已清扫的mspan
    • 如果mcentral也没有可用mspan,通过mheap跟OS申请一段内存,初始化一个mspan返回
  • 如果对象>32KB
    • 通过mheap跟OS申请一段内存,初始化一个mspan返回

系统内存状态

这里需要了解系统内存的几个状态,以及go内部是如何从系统分配、释放内存的

状态 含义
None 默认状态,未映射,地址空间未被保留或使用
Reserved 已保留,但未提交,即地址空间已经被申请,但尚未向操作系统请求实际物理内存
Prepared 已提交但未使用,已经向操作系统申请了物理内存,但可能未完全初始化
Ready 可用状态,内存已初始化,可用于分配

状态转换函数

函数 主要作用 是否分配物理内存 是否映射虚拟内存 内存状态转换 备注
sysAlloc 直接申请内存 ✅ 是 ✅ 是 None -> Ready 可能会触发 mmap
sysReserve 保留虚拟地址空间,但不映射 ❌ 否 ✅ 是 None -> Reserved 预留地址,后续 sysMap
sysMap 将预留的虚拟地址映射为实际物理内存 ✅ 是 ✅ 是 Reserved -> Prepared 只有 sysReserve 过的地址能 sysMap
sysUsed 标记某段地址正在使用 ✅ 是 ✅ 是 Prepared -> Ready 可能会触发 madvise 让物理页生效
sysUnused 标记某段地址未使用,可以回收物理内存 ⚠️ 可能 ❌ 否 Ready -> Prepared MADV_DONTNEED,内存仍属于进程
sysFault 让一段地址变成不可访问 ❌ 否 ✅ 是 Ready -> Reserved mprotect(PROT_NONE),用于调试
sysFree 释放虚拟内存,归还给 OS ✅ 是 ✅ 是 -> None munmap,这段内存不能再用

我对Go内存分配的理解

  1. 内存管理的本质是地址空间的组织和维护
  2. Go的内存策略:尽量保留虚拟地址,按需释放物理页
  3. Go会一次性申请64MB内存(Reserved-虚拟地址空间),但并不是立即分配物理页,而是在需要分配时使用sysMap + sysUsed使一小部份内存可用(Reserved->Prepared->Ready)

GC助攻

为了防止内存分配速度过快,导致GC跟不上,分配时会判断是否需要协助GC标记/清扫。每次都要判断是否需要协助标记,而清扫只发生在获取新的mspan和大对象分配场景下

阅读全文 »

本文仅介绍程序运行流程以及GMP如何寻找g并运行,其他如抢占、死锁、信号处理、profiling等内容不打算深入。当前go版本:1.24

前提

先讲几个概念

进程、线程、协程

  1. 进程:程序的一个实例,也是操作系统的一个task,操作系统的资源分配最小单位
  2. 线程:一种概念,操作系统调度的最小单位,一个进程可以包含多个线程,线程之间共享内存、文件描述符等资源。不同操作系统的实现并不一致,linux下进程与线程的结构都是task_structure,也就是说他们是一样的,不同线程之间用指针指向同一份资源如内存空间实现共享。
  3. 协程:也称为用户态线程,由应用程序自行实现/调度,一般情况下是协作式的,由开发者决定task何时让出cpu,go支持抢占式调度

线程与协程的映射模型

  1. 1:1模型,每个用户线程对应一个内核线程,如在c中pthread创建的线程,此时也可以理解为用户态线程就是内核态线程
  2. 1:N模型,一个内核线程对应多个用户线程,无法充分利用多核的并行性,现已淘汰
  3. M:N模型,多个用户线程对应多个内核线程,实现较复杂,使用者较少,go是其中一个

GMP模型

go早期的M:N模型遇到一些性能问题,如锁竞争激烈、线程创建/销毁频繁、CPU缓存失效等,为了解决这些问题引入了P。在GMP模型中

  1. G-goroutine,用户态线程
  2. M-machine,系统线程相关
  3. P-processor,缓存、调度上下文等,其数量一般与CPU核心数量一致。P的出现使得调度变得本地化,避免全局锁竞争,提升了CPU缓存命中率等,最终使得go的并发调度更加高效

go程序的运行流程

go程序启动时的入口是_rt0_amd64,该函数是汇编代码,具体如下

1
2
3
4
5
6
// src/runtime/asm_amd64.s
// 系统入口点
TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ 0(SP), DI // argc
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)

runtime·rt0_go也是汇编代码,比较长,主要逻辑如下

  1. g0、m0双向绑定(g0、m0是全局变量,静态编译,因此指针已知,放在src/runtime/proc.go)
  2. runtime·args - 复制命令行参数(args函数放在src/runtime/runtime1.go)
  3. runtime·osinit - 系统初始化(osinit函数放在src/runtime/os_linux.go)
  4. runtime·schedinit - 调度器初始化(schedinit函数放在src/runtime/proc.go)
  5. runtime·mainPC - 纪录runtime·main的地址(main函数放在src/runtime/proc.go,其内部调用main_main,也就是用户自己编写的main函数)
  6. runtime·newproc - 创建G用于运行runtime·main,放到p的runq里,等待调度
  7. runtime·mstart - 运行runtime·mstart(汇编函数,实际调用的是runtime·mstart0,放在src/runtime/proc.go,内部进行栈初始化、信号注册等,最后运行调度函数schedule)
阅读全文 »

Timer-一次性定时器,Ticker-周期性定时器。从1.23版本开始,将异步实现改为同步实现,但你仍然可以使用AfterFunc创建异步定时器,或者通过改变asynctimerchan变量启用异步实现

asynctimerchan变量可选项如下

asynctimerchan description
0 同步实现,从1.23版本开始启用
1 旧版异步实现
2 同1,异步实现,但修复了1的问题,debug用

定时器的精确度因系统不同而不同,具体如下

OS resolution
Unix ~1ms
>= Windows 1803 ~0.5ms
< Windows 1803 ~16ms

快速上手

深入了解源代码前,先了解其功能如何使用

Timer-一次性定时器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"fmt"
"time"
)

func main() {
// 定时器1
timer1 := time.NewTimer(2 * time.Second)

<-timer1.C
fmt.Println("Timer 1 fired")

// 定时器2
timer2 := time.NewTimer(time.Second)
go func() {
<-timer2.C
fmt.Println("Timer 2 fired")
}()
stop2 := timer2.Stop()
if stop2 {
fmt.Println("Timer 2 stopped")
}

// 定时器3
time.Sleep(2 * time.Second)
}

上述示例代码运行效果如下

1
2
3
4
go run main.go

# Timer 1 fired
# Timer 2 stopped

Ticker-周期性定时器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
"fmt"
"time"
)

func main() {
// 每500ms执行一次
ticker := time.NewTicker(500 * time.Millisecond)
done := make(chan bool)

go func() {
for {
select {
case <-done:
return
case t := <-ticker.C:
fmt.Println("Tick at", t)
}
}
}()

// 挂起休眠1600ms
time.Sleep(1600 * time.Millisecond)
// 停止定时器
ticker.Stop()
done <- true
fmt.Println("Ticker stopped")
}

上述示例代码运行效果如下

1
2
3
4
5
6
go run main.go

# Tick at 2025-02-27 10:41:07.875146141 +0800 CST m=+0.500099485
# Tick at 2025-02-27 10:41:08.37515345 +0800 CST m=+1.000100767
# Tick at 2025-02-27 10:41:08.875159521 +0800 CST m=+1.500100789
# Ticker stopped
阅读全文 »

netpoll是golang用来处理网络I/O事件的底层机制,主要通过操作系统的I/O多路复用机制如Linux的epoll、BSD的kqueue、Windows的IOCP等来实现

数据结构

核心的数据结构是pollDesc,用于存储与文件描述符相关的事件数据,一般被放入如epoll的epoll_event.data来传递信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
type pollDesc struct {
_ sys.NotInHeap // 放置转换成interface{}时申请heap内存
link *pollDesc // next指针,用于pollcache链表
fd uintptr // 文件描述符
fdseq atomic.Uintptr // 计数器,类似时间戳,确保过期的消息不会被处理,只在获取/放回cache时改变
atomicInfo atomic.Uint32 // 5个状态位+fdseq(这两个数据有位交叉冲突,没搞懂)
rg atomic.Uintptr // 读状态,读G的地址也作为一种状态
wg atomic.Uintptr // 写状态,写G的地址也作为一种状态
lock mutex // 锁,保护下列字段
closing bool // 是否被移除出netpoll
rrun bool // rt-读定时器是否在运行
wrun bool // wt-写定时器是否在运行
user uint32 // cookie,linux/bsd应该没用到
rseq uintptr // 读计数器,类似fdseq,只有获取/放回cache以及设置deadline时改变
rt timer // 读定时器
rd int64 // 读过期时刻,-1为已过期
wseq uintptr // 写计数器,类似fdseq,只有获取/放回cache以及设置deadline时改变
wt timer // 写定时器
wd int64 // 写过期时刻,-1为已过期
self *pollDesc // 当前实例指针
}

// pollDesc缓存,重复使用,避免反复申请内存
type pollCache struct {
lock mutex // 锁
first *pollDesc // 链表头部指针,pollDesc指针都从头部写入 new -> old -> ...
}

pollDesc部份字段讲解如下

  1. atomicInfo是一个无符号32位整型数,每位用途如下
16bit 11bit 1bit 1bit 1bit 1bit 1bit
fdseq unused pollFDSeq pollExpiredWriteDeadline pollExpiredReadDeadline pollEventErr pollClosing

注意:fdseq占据20位数据,但在atomicInfo里,fdseq要向左移位16位,看起来是数据丢失了,没搞明白。同样有问题的还有taggedPointerPack

  1. rgwg的状态列表如下
state_name state_val description
pdNil 0 默认值
pdReady 1 io可读,下一个状态是pdNil
pdWait 2 准备挂起,下一个状态是G pointer-挂起,pdReady-io可读,pdNil-超时/关闭
G pointer 0xabc goroutine指针-挂起,下一个状态是pdReady-io可读,pdNil-超时/关闭
阅读全文 »

time涉及的内容较多,如生成/存储时间、比较时间、获取时间信息、时区、夏令时等,本文仅介绍一些自己感兴趣的地方

日历计算基于格里高利历(公历),1年有365天(闰年有366天)。同时支持墙上时钟(wall clock)和单调时钟(monotonic clock),其中墙上时钟用于时间同步、报时(time-telling),单调时钟用于时间测量(time-measuring)。并不是所有函数都支持单调时钟,如字符串编码/解码函数就会舍弃单调时钟数据

注意:

  1. 时间精确度:纳秒
  2. 大部分都是线程安全,除了
    • GobDecode
    • UnmarshalBinary
    • UnmarshalJSON
    • UnmarshalText
  3. 有些系统会在进程休眠时停止单调时钟,会导致一些函数计算异常,如
    • Sub
    • Since
    • Until
    • Before
    • After
    • Add
    • Equal
    • Compare
  4. 字符串编码时,保存的是Location的offset,会导致dst-夏令时丢失,相关函数
    • GobEncode
    • MarshalBinary
    • AppendBinary
    • MarshalJSON
    • MarshalText
    • AppendText
  5. 字符串编码/解码时,会丢弃单调时钟信息

当前go版本:1.24

快速上手

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import (
"fmt"
"time"
)

func main() {
p := fmt.Println

// 当前时刻
now := time.Now()
p(now)

// 指定时刻
then := time.Date(
2009, 11, 17, 20, 34, 58, 651387237, time.UTC)
p(then)

p(then.Year()) // 年
p(then.Month()) // 月
p(then.Day()) // 日
p(then.Hour()) // 时
p(then.Minute()) // 分
p(then.Second()) // 秒
p(then.Nanosecond()) // 纳秒
p(then.Location()) // 时区

p(then.Weekday()) // 星期

// 判断两个时刻先后顺序
p(then.Before(now))
p(then.After(now))
p(then.Equal(now))

// 时长/时刻差值
diff := now.Sub(then)
p(diff)

p(diff.Hours()) // 转换成总小时数
p(diff.Minutes()) // 转换成总分钟数
p(diff.Seconds()) // 转换成总秒数
p(diff.Nanoseconds()) // 转换成总纳秒数

p(then.Add(diff))
p(then.Add(-diff))
}

运行结果如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
go run main.go

# 2025-02-22 10:30:14.247195 +0800 CST m=+0.000103986 # 当前时刻
# 2009-11-17 20:34:58.651387237 +0000 UTC # 指定时刻
# 2009 # 年
# November # 月
# 17 # 日
# 20 # 时
# 34 # 分
# 58 # 秒
# 651387237 # 纳秒
# UTC # 时区
# Tuesday # 星期
# true
# false
# false
# 133805h55m15.595807763s # 时长/时刻差值
# 133805.9209988355 # 总小时数
# 8.028355259930129e+06 # 总分钟数
# 4.817013155958078e+08 # 总秒数
# 481701315595807763 # 总纳秒数
# 2025-02-22 02:30:14.247195 +0000 UTC
# 1994-08-13 14:39:43.055579474 +0000 UTC
阅读全文 »

channel-管道,是go语言中一种常见的goroutine的通信方式

当前go版本:1.24

快速上手

示例1. 两个goroutine之间使用channel传递数据

1
2
3
4
5
6
7
8
9
10
11
message := make(chan string)

// 新goroutine
go func() {
time.Sleep(time.Second * 2)
message <- "Hello from goroutine!"
}()

// 当前goroutine
msg := <-message
fmt.Println(msg)

示例2. 使用select同时监听多个goroutine的响应数据,实际上,业务代码中一般都是跟定时器搭配使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ch1 := make(chan int)
ch2 := make(chan int)

go func() {
time.Sleep(time.Second * 1)
ch1 <- 1
}()

go func() {
time.Sleep(time.Second * 2)
ch2 <- 2
}()

for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println("Received from ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("Received from ch2:", msg2)
}
}
阅读全文 »

从1.24版开始,sync.Map改用HashTrieMap重构,与之前的双map实现不同,HashTrieMap更像是一个B树,简单的示例图如下

1
2
3
4
5
6
7
//  root -> | idx0    | idx1       | ... | idx15 |
// | &entry0 | &indirect0 | ... | nil |
// |
// children
// v
// | idx0 | idx1 | ... | idx15 |
// | nil | &entry1 | ... | nil |

使用哈希函数生成64位的哈希值,从高到低4位为一个idx,最多有16层,每个节点可容纳16个元素,最多可容纳16^16=2^64个元素

当前go版本:1.24

HashTrieMap的开关放在文件src/internal/buildcfg/exp.go的函数ParseGOEXPERIMENT

数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
type HashTrieMap[K comparable, V any] struct {
inited atomic.Uint32 // 是否已初始化
initMu Mutex // 锁,用于初始化
root atomic.Pointer[indirect[K, V]] // 根节点
keyHash hashFunc // 哈希函数,用于key
valEqual equalFunc // cmp函数,用于value
seed uintptr // 哈希种子
}

// 内部节点
type indirect[K comparable, V any] struct {
node[K, V] // isEntry=false
dead atomic.Bool // 是否被删除
mu Mutex // 锁,用于children
parent *indirect[K, V] // 父节点指针
children [nChildren]atomic.Pointer[node[K, V]] // 16个子节点,指向indirect或entry
}

// 叶子节点
type entry[K comparable, V any] struct {
node[K, V] // isEntry=true
overflow atomic.Pointer[entry[K, V]] // 指针,当两个entry哈希值相同时以链表方式存储
key K // 键
value V // 值
}

// entry和indirect的共有属性
type node[K comparable, V any] struct {
isEntry bool // 判断是叶子节点还是内部节点
}
阅读全文 »

map不支持并发读写,但我们可以转变下思路,将value改为一个指向结构体entry的指针,结构体内部的字段我们是可以随意修改的,如下,将并发读写map改为并发读map,读写转移到entry

1
2
3
4
5
type entry struct {
p any
}

var m map[string]*entry

上面的方法看起来解决了并发读写的问题,但还不够,当有新的key写入时,还是变回了原来的map并发读写。sync.Map提供了一种思路,使用两个map,read负责已有key的并发读写,dirty负责新key的读写,只有当read找不到key,才去找dirty。

现在还剩最后一个问题,read和dirty如何保证数据一致/同步,我们可以改造entry,使其指向value的指针,如此一来,read和dirty的entry可以指向同一个value,如下,这就是sync.Map的大致思路

1
2
3
4
5
6
7
8
9
type entry struct {
p *any
}
// read -> key0|*entry0 entry0.p -> &value
// -> key1|*entry1
//
// dirty -> key0|*entry0
// -> key1|*entry1
// -> key2|*entry2

注意:尽管如此,sync.Map并不完美,以上设计导致我们无法直接计算出哈希表的元素数量,需要遍历进行统计,而且还不一定准确

当前go版本:1.23,1.24版本改为HashTrieMap实现

快速上手

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
"sync"
)

func main() {
var syncMap sync.Map

// 写
syncMap.Store("blog", "VictoriaMetrics")

// 读
value, ok := syncMap.Load("blog")
fmt.Println(value, ok)

// 删除
syncMap.Delete("blog")
value, ok = syncMap.Load("blog")
fmt.Println(value, ok)
}

上述代码运行结果如下

1
2
3
4
5
go run ./main.go

# 输出如下
# VictoriaMetrics true
# <nil> false
阅读全文 »

在使用redis/memcached缓存系统时,可能会遇到以下三个问题

  1. cache penetration(缓存穿透) - 数据既不在cache中也不在db中,可以用布龙过滤器处理
  2. cache avalanche(缓存雪崩) - 同一时刻出现大量的key失效,可以将过期时间随机化或者不设置过期时间
  3. cache breakdown(缓存击穿)/cache stampede(缓存踩踏) - 热门的key过期,客户端加锁或者不设置过期时间

缓存击穿问题中,客户端加锁使穿行化访问是一个值得考虑的解决方法,可以降低服务器(cache/db)的压力。但另一方面,这也会让大量的请求被阻塞,吞吐量下降。实际上,同一时刻的请求可以共享响应数据,这就是singleflight解决的问题

singleflight不是标准库的一部份,但go的internal目录内复制了一份singleflight源码,该源码也是本文在讨论的

当前go版本:1.24

快速上手

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package main

import (
"fmt"
"math/rand"
"sync"
"sync/atomic"
"time"

"golang.org/x/sync/singleflight"
)

var callCount atomic.Int32
var wg sync.WaitGroup

// 模拟db请求
func fetchData() (interface{}, error) {
callCount.Add(1)
time.Sleep(100 * time.Millisecond)
// 返回的数据是随机的
return rand.Intn(100), nil
}

// 包装fetchData和singleflight
func fetchDataWrapper(g *singleflight.Group, id int) error {
defer wg.Done()

time.Sleep(time.Duration(id) * 40 * time.Millisecond)
v, err, shared := g.Do("key-fetch-data", fetchData)
if err != nil {
return err
}

fmt.Printf("Goroutine %d: result: %v, shared: %v\n", id, v, shared)
return nil
}

func main() {
var g singleflight.Group

const numGoroutines = 5
wg.Add(numGoroutines)

// 模拟:发起5个请求访问db
for i := 0; i < numGoroutines; i++ {
go fetchDataWrapper(&g, i)
}

wg.Wait()
fmt.Printf("Function was called %d times\n", callCount.Load())
}

上述代码运行结果如下

1
2
3
4
5
6
7
8
9
go run ./main.go

# 输出如下,结果是随机的
# Goroutine 1: result: 2, shared: true
# Goroutine 0: result: 2, shared: true
# Goroutine 2: result: 2, shared: true
# Goroutine 3: result: 94, shared: true
# Goroutine 4: result: 94, shared: true
# Function was called 2 times

可以看到,G0、G1、G2共享result=2,G3、G4共享result=94

阅读全文 »

RWMutex-读写锁,该锁可以被任意多个reader持有,或被一个writer持有。通过观察RWMutex的源代码实现,可以将RWMutex看作是FIFO队列,具体看后面的详细描述

当前go版本:1.24

快速上手

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package main

import (
"fmt"
"sync"
)

func main() {
var size = 100
var count int
var wg sync.WaitGroup
var rwm sync.RWMutex

queue := make([]int, size)

for i := 0; i < size; i++ {
go func() {
wg.Add(1)
defer wg.Done()

rwm.Lock() // 写锁
count++ // 更新资源
rwm.Unlock()
}()
}

for i := 0; i < size; i++ {
indx := i
go func() {
wg.Add(1)
defer wg.Done()

rwm.RLock() // 读锁
queue[indx] = count // 只读
rwm.RUnlock()
}()
}

wg.Wait()

fmt.Println(count)
fmt.Println(queue)
}
阅读全文 »
0%