go的垃圾回收原理官方说法是是三色标记法+混合写屏障,但是,理论上怎么说是一回事,具体实现又是另一回事了,或者说实现已经偏离文档描述。总的来说,GC这部份的内容要比GMP跟内存分配都要复杂的多且出人意料。当前go版本:1.24

前言

目标对象

Go的GC并不扫描整个内存,只关注以下区域:

  1. heap上分配的对象(在mspan管理范围)
  2. data/bss段的全局变量
  3. 栈上的引用(扫描stackRoots)

触发类型

触发类型 备注
gcTriggerHeap 堆大小触发,heap内存达到一个临界点触发(最常用)
gcTriggerTime 时间触发,超时2min未执行GC则强制执行
gcTriggerCycle 手动触发,用户调用runtime.GC()

角色/分工

角色 作用 工作阶段 备注
Mark Worker(标记工作线程) 遍历对象图,标记存活对象,防止被错误回收 标记阶段(Marking) 运行时创建多个并发 Mark Worker,加快标记速度
Sweeper(清扫器) 清理未标记的对象,将其内存释放回空闲列表(mheap.free) 清扫阶段(Sweeping) 逐步清理,避免一次性 STW(Stop The World)
Scavenger(内存回收器) 释放长期未使用的堆内存,归还给OS以减少RSS 后台运行(定期触发) 主要针对大对象或空闲mspans,减少物理内存占用

标记工作线程

其中,标记工作线程的会根据工作模式进一步区分,如下

标记工作线程模式 备注
gcMarkWorkerNotWorker 默认,未运行
gcMarkWorkerDedicatedMode 专用标记任务,最高优先级,非抢占
gcMarkWorkerFractionalMode 比例标记任务,跟其他g共享时间,可被抢占
gcMarkWorkerIdleMode 空闲时执行的低优先级标记任务,需要p空闲

CPU限制

标记工作线程的CPU使用率被限制在25%(GOGC=100时),假设系统使用一个6核CPU,那么GC在标记阶段大约会使用1.5个CPU资源:

  1. 1个CPU由Dedicated(专用模式)的标记工作线程持续占用,该线程不允许抢占,直到标记任务完成
  2. 0.5个CPU由Fractional(比例模式)的标记工作线程使用,该线程仅在由额外CPU资源可用时运行,并会根据系统负责动态调整自身的CPU使用率

完整运行流程

  1. Sweep Termination(清理终止)

    • STW(Stop The World),确保所有P都达到GC安全点
    • 完成上一轮GC未完成的sweep(清扫),回收剩余的的mspan
    • 准备GC统计数据,为新一轮的GC计算目标heap大小、触发阈值等
  2. Mark(标记)

    • STW,切换GC状态
      • gcphase从_GCoff切换到_GCmark
      • 开启写屏障(write barrier),允许Mutator协助GC标记,以维护三色标记不变性
      • 启动GC后台线程,执行并发标记任务
      • 根对象入队(包括栈、全局变量)
    • 恢复世界(Start The World),GC线程进入并发标记阶段
      • 从根对象开始标记,遍历所有可达对象
      • 扫描灰色对象(已发现但未完全扫描的对象)并进行扫描,将其置黑,并将其引用的对象入队为灰色
      • 混合写屏障(Hybrid Write Barrier)确保一致性
    • 完成标记
  3. Mark Termination(标记终止)

    • STW,切换GC状态
      • gcphase从_GCmark切换到_GCmarktermination
      • 停止并发标记任务
      • 执行终结器finalizer,如果有的话
      • 清理mcache以确保没有悬挂对象
  4. Sweep(清理)

    • 切换GC状态
      • gcphase从_GCmarktermination切换回_GCoff
      • 关闭写屏障(Mutator不再协助GC标记)
    • 恢复世界(Start The World),进入并发清扫阶段
      • 清理未被标记的对象
      • 回收mspan到mheap或mcentral,部份回收到mcache
      • Mutator分配内存时,可能会触发增量清扫(Incremental Sweeping),加快回收过程
  5. 满足触发条件,启动下一轮GC

简单的说,标记然后清扫

阅读全文 »

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

阅读全文 »
0%