本文仅介绍程序运行流程以及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)
}
阅读全文 »

Mutex(MUTualEx)-互斥锁是一种可以保证每次只有一个goroutine访问贡献资源的方法。这个资源可以是一段程序代码、一个整数、一个map、一个struct、一个channel或其他任何东西。通过观察Mutex的源代码实现,可以将Mutex看作是一个队列(FIFO/LIFO),具体看后面的详细描述

当前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
package main

import (
"fmt"
"sync"
)

type Container struct {
mu sync.Mutex
counters map[string]int
}

func (c *Container) inc(name string) {
c.mu.Lock() // 互斥锁,获取失败等待挂起
defer c.mu.Unlock() // 解锁
c.counters[name]++ // 共享资源
}

func main() {
c := Container{
counters: map[string]int{"a": 0, "b": 0},
}

var wg sync.WaitGroup

doIncrement := func(name string, n int) {
for i := 0; i < n; i++ {
c.inc(name)
}
wg.Done()
}

wg.Add(3)
go doIncrement("a", 10000)
go doIncrement("a", 10000)
go doIncrement("b", 10000)

wg.Wait()
fmt.Println(c.counters)
}

上述代码运行结果如下

1
2
3
4
go run ./main.go

# 输出如下
# map[a:20000 b:10000]
阅读全文 »
0%