Go runtime之 Goroutine 原理

Goroutine

定义

image-20231211173422892

Goroutine 是一个与其他 goroutines 并行运行在同一地址空间的 Go 函数或方法。一个运行的程序由一个或更多个 goroutine 组成。它与线程、写成、进程等不同。它是一个 goroutine。

​ – Rob Pike

Goroutine 在同一个用户地址空间里并行独立执行 functionschannels 则用于 goroutines 间的通信和同步访问控制。

可执行程序可以分成两个层:用户代码和运行时。

运行时提供接口函数供用户代码调用,用来管理 goroutineschannels 和其他一些内置抽象结构。

用户代码对操作系统 API的任何调用都会被运行时层截取,以方便调度和垃圾回收。

goroutine 和 thread 的区别

image-20231212092551002

例如在一个进程的虚拟地址空间内,从低位 0x00000000 到高位 0x7fffffff,堆地址从低地址位到高地址位使用,栈从高地址位到地址位。

在进程内开启多个线程,是从栈上进行分配。

  • 内存占用:创建一个 goroutine 的栈内存消耗为 2KB(Linux AMD64 GO v1.4后),运行过程中,如果栈空间不够用,会自动进行扩容。

创建一个 thread 为了尽量避免极端情况下操作系统线程栈的溢出,默认会为其分配一个较大的栈内存( 1-8 MB 栈内存,线程标准 POSIX Thread),而且还需要一个被称为 guard page 的区域用于和其他 thread 的栈空间进行隔离(当进程中有内存溢出,使用 guard page 内存时,可也就是当有应用态使用这部分内存,则可能会被操作系统干掉。避免溢出时操作的是其他线程上的内存。)。而栈内存空间一旦创建和初始化完成之后,其大小就不能再有变化,这决定了在某些特殊场景下系统线程栈还是有溢出的风险。而且线程创建过多,也会导致栈内存耗尽。

  • 创建、销毁:线程创建和销毁都会有巨大的消耗,是内核级的交互(trap

POSIX 线程(定义了创建和操作线程的一套 API)通常是在已有的进程模型中增加的逻辑扩展,所以线程控制和进程控制很相似。而进入内核调度所消耗的性能代价比较高,开销较大。goroutine 是用户态线程,是由 go runtime 管理,创建和销毁的消耗非常小。

  • 调度切换:抛开陷入内核,线程切换会消耗 1000-1500 纳秒(上下文保存成本高,较多寄存器,公平性,复杂时间计算统计),一个纳秒平均可以执行 12-18 条指令。

所以由于线程切换,执行指令的条数会减少 12000-18000。goroutine 的切换约为 200ns(用户态、3个寄存器),相当于 2400-3600 条指令。因此 goroutines 切换成本比 threads 要小得多。

  • 复杂性:线程的创建和退出复杂,多个 thread 间通讯复杂(share memory

不能大量创建线程(参考早期 httpd),成本高,使用网络多路复用,存在大量 callback(参考 twemproxynginx的代码)。对于应用服务线程门槛高,例如需要做第三方库隔离,需要考虑引用线程池等。

M:N 模型

image-20231212095740231

Go 创建 M 个线程( CPU 执行调度的单元,内核的 task_struct),之后创建的 N 个 goroutine 都会依附在这 M 个线程上执行,即 M:N 模型。

它们能够同时运行,与线程类似,但相比之下非常轻量。因此,程序运行时,goroutines 的个数应该是远大于线程的个数的(pthread 是内核线程,在计算机领域,POSIX 线程(通常称为 pthreads)是一种独立于编程语言的执行模型,也是一种并行执行模型。它允许程序控制多个在时间上重叠的不同工作流。)

同一个时刻,一个线程只能跑一个 goroutine。当 goroutine发生阻塞(chan阻塞、mutexsyscall等等)时,Go 会把当前的 goroutine 调度走,让其他 goroutine 来继续执行,而不是让线程阻塞休眠,尽可能多的分发任务出去,让 CPU 忙。

GMP 调度模型

  • G :goroutine 的缩写,每次 go func() 都代表一个G,无限制。

    使用 struct runtime.g,包含了当前 goroutine的状态、堆栈、上下文。

  • M:工作线程(OS thread)也被称为 Machine,使用 struct runtime.m,所有 M 是有线程栈的

    如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。当指定了线程栈,则 M.stack -> G.stack(M 的栈,指向 G 的栈),M 的 PC 寄存器指向 G 提供的函数,然后去执行。(执行的时候,内存指向 G 的栈,切换到G的寄存器,执行完了之后,再切换回来,也就是 g0。)

  • P:在 golang 早期(go1.2 之前)没有 P 的概念。只有 GM 模型。

go/src/runtime/runtime2.go

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
type g struct {
// Stack parameters.
// stack describes the actual stack memory: [stack.lo, stack.hi).
// stackguard0 is the stack pointer compared in the Go stack growth prologue.
// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
// stackguard1 is the stack pointer compared in the C stack growth prologue.
// It is stack.lo+StackGuard on g0 and gsignal stacks.
// It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink

_panic *_panic // innermost panic - offset known to liblink
_defer *_defer // innermost defer
m *m // current m; offset known to arm liblink
sched gobuf
syscallsp uintptr // if status==Gsyscall, syscallsp = sched.sp to use during gc
syscallpc uintptr // if status==Gsyscall, syscallpc = sched.pc to use during gc
stktopsp uintptr // expected sp at top of stack, to check in traceback
// param is a generic pointer parameter field used to pass
// values in particular contexts where other storage for the
// parameter would be difficult to find. It is currently used
// in three ways:
// 1. When a channel operation wakes up a blocked goroutine, it sets param to
// point to the sudog of the completed blocking operation.
// 2. By gcAssistAlloc1 to signal back to its caller that the goroutine completed
// the GC cycle. It is unsafe to do so in any other way, because the goroutine's
// stack may have moved in the meantime.
// 3. By debugCallWrap to pass parameters to a new goroutine because allocating a
// closure in the runtime is forbidden.
param unsafe.Pointer
atomicstatus uint32
stackLock uint32 // sigprof/scang lock; TODO: fold in to atomicstatus
goid int64
schedlink guintptr
waitsince int64 // approx time when the g become blocked
waitreason waitReason // if status==Gwaiting

preempt bool // preemption signal, duplicates stackguard0 = stackpreempt
preemptStop bool // transition to _Gpreempted on preemption; otherwise, just deschedule
preemptShrink bool // shrink stack at synchronous safe point

// asyncSafePoint is set if g is stopped at an asynchronous
// safe point. This means there are frames on the stack
// without precise pointer information.
asyncSafePoint bool

paniconfault bool // panic (instead of crash) on unexpected fault address
gcscandone bool // g has scanned stack; protected by _Gscan bit in status
throwsplit bool // must not split stack
// activeStackChans indicates that there are unlocked channels
// pointing into this goroutine's stack. If true, stack
// copying needs to acquire channel locks to protect these
// areas of the stack.
activeStackChans bool
// parkingOnChan indicates that the goroutine is about to
// park on a chansend or chanrecv. Used to signal an unsafe point
// for stack shrinking. It's a boolean value, but is updated atomically.
parkingOnChan uint8

raceignore int8 // ignore race detection events
sysblocktraced bool // StartTrace has emitted EvGoInSyscall about this goroutine
tracking bool // whether we're tracking this G for sched latency statistics
trackingSeq uint8 // used to decide whether to track this G
runnableStamp int64 // timestamp of when the G last became runnable, only used when tracking
runnableTime int64 // the amount of time spent runnable, cleared when running, only used when tracking
sysexitticks int64 // cputicks when syscall has returned (for tracing)
traceseq uint64 // trace event sequencer
tracelastp puintptr // last P emitted an event for this goroutine
lockedm muintptr
sig uint32
writebuf []byte
sigcode0 uintptr
sigcode1 uintptr
sigpc uintptr
gopc uintptr // pc of go statement that created this goroutine
ancestors *[]ancestorInfo // ancestor information goroutine(s) that created this goroutine (only used if debug.tracebackancestors)
startpc uintptr // pc of goroutine function
racectx uintptr
waiting *sudog // sudog structures this g is waiting on (that have a valid elem ptr); in lock order
cgoCtxt []uintptr // cgo traceback context
labels unsafe.Pointer // profiler labels
timer *timer // cached timer for time.Sleep
selectDone uint32 // are we participating in a select and did someone win the race?

// Per-G GC state

// gcAssistBytes is this G's GC assist credit in terms of
// bytes allocated. If this is positive, then the G has credit
// to allocate gcAssistBytes bytes without assisting. If this
// is negative, then the G must correct this by performing
// scan work. We track this in bytes to make it fast to update
// and check for debt in the malloc hot path. The assist ratio
// determines how this corresponds to scan work debt.
gcAssistBytes int64
}

type m struct {
g0 *g // goroutine with scheduling stack g0在 M 中
morebuf gobuf // gobuf arg to morestack
divmod uint32 // div/mod denominator for arm - known to liblink
_ uint32 // align next field to 8 bytes

// Fields not known to debuggers.
procid uint64 // for debuggers, but offset not hard-coded
gsignal *g // signal-handling g
goSigStack gsignalStack // Go-allocated signal handling stack
sigmask sigset // storage for saved signal mask
tls [tlsSlots]uintptr // thread-local storage (for x86 extern register)
mstartfn func()
curg *g // current running goroutine
caughtsig guintptr // goroutine running during fatal signal
p puintptr // attached p for executing go code (nil if not executing go code)
nextp puintptr
oldp puintptr // the p that was attached before executing a syscall
id int64
mallocing int32
throwing int32
preemptoff string // if != "", keep curg running on this m
locks int32
dying int32
profilehz int32
spinning bool // m is out of work and is actively looking for work
blocked bool // m is blocked on a note
newSigstack bool // minit on C thread called sigaltstack
printlock int8
incgo bool // m is executing a cgo call
freeWait atomic.Uint32 // Whether it is safe to free g0 and delete m (one of freeMRef, freeMStack, freeMWait)
fastrand uint64
needextram bool
traceback uint8
ncgocall uint64 // number of cgo calls in total
ncgo int32 // number of cgo calls currently in progress
cgoCallersUse uint32 // if non-zero, cgoCallers in use temporarily
cgoCallers *cgoCallers // cgo traceback if crashing in cgo call
park note
alllink *m // on allm
schedlink muintptr
lockedg guintptr
createstack [32]uintptr // stack that created this thread.
lockedExt uint32 // tracking for external LockOSThread
lockedInt uint32 // tracking for internal lockOSThread
nextwaitm muintptr // next m waiting for lock
waitunlockf func(*g, unsafe.Pointer) bool
waitlock unsafe.Pointer
waittraceev byte
waittraceskip int
startingtrace bool
syscalltick uint32
freelink *m // on sched.freem

// these are here because they are too large to be on the stack
// of low-level NOSPLIT functions.
libcall libcall
libcallpc uintptr // for cpu profiler
libcallsp uintptr
libcallg guintptr
syscall libcall // stores syscall parameters on windows

vdsoSP uintptr // SP for traceback while in VDSO call (0 if not in call)
vdsoPC uintptr // PC for traceback while in VDSO call

// preemptGen counts the number of completed preemption
// signals. This is used to detect when a preemption is
// requested, but fails. Accessed atomically.
preemptGen uint32

// Whether this is a pending preemption signal on this M.
// Accessed atomically.
signalPending uint32

dlogPerM

mOS

// Up to 10 locks held by this m, maintained by the lock ranking code.
locksHeldLen int
locksHeld [10]heldLockInfo
}

type p struct {
id int32
status uint32 // one of pidle/prunning/...
link puintptr
schedtick uint32 // incremented on every scheduler call
syscalltick uint32 // incremented on every system call
sysmontick sysmontick // last tick observed by sysmon
m muintptr // back-link to associated m (nil if idle)
mcache *mcache // mcache m 的本地缓存
pcache pageCache
raceprocctx uintptr

deferpool []*_defer // pool of available defer structs (see panic.go)
deferpoolbuf [32]*_defer

// Cache of goroutine ids, amortizes accesses to runtime·sched.goidgen.
goidcache uint64
goidcacheend uint64

// Queue of runnable goroutines. Accessed without lock.
runqhead uint32
runqtail uint32
runq [256]guintptr // P 的本地队列
// runnext, if non-nil, is a runnable G that was ready'd by
// the current G and should be run next instead of what's in
// runq if there's time remaining in the running G's time
// slice. It will inherit the time left in the current time
// slice. If a set of goroutines is locked in a
// communicate-and-wait pattern, this schedules that set as a
// unit and eliminates the (potentially large) scheduling
// latency that otherwise arises from adding the ready'd
// goroutines to the end of the run queue.
//
// Note that while other P's may atomically CAS this to zero,
// only the owner P can CAS it to a valid G.
runnext guintptr // 下一个可以运行的 G

// Available G's (status == Gdead) 全局队列
gFree struct {
gList
n int32
}

sudogcache []*sudog
sudogbuf [128]*sudog

// Cache of mspan objects from the heap.
mspancache struct {
// We need an explicit length here because this field is used
// in allocation codepaths where write barriers are not allowed,
// and eliminating the write barrier/keeping it eliminated from
// slice updates is tricky, moreso than just managing the length
// ourselves.
len int
buf [128]*mspan
}

tracebuf traceBufPtr

// traceSweep indicates the sweep events should be traced.
// This is used to defer the sweep start event until a span
// has actually been swept.
traceSweep bool
// traceSwept and traceReclaimed track the number of bytes
// swept and reclaimed by sweeping in the current sweep loop.
traceSwept, traceReclaimed uintptr

palloc persistentAlloc // per-P to avoid mutex

_ uint32 // Alignment for atomic fields below

// The when field of the first entry on the timer heap.
// This is updated using atomic functions.
// This is 0 if the timer heap is empty.
timer0When uint64

// The earliest known nextwhen field of a timer with
// timerModifiedEarlier status. Because the timer may have been
// modified again, there need not be any timer with this value.
// This is updated using atomic functions.
// This is 0 if there are no timerModifiedEarlier timers.
timerModifiedEarliest uint64

// Per-P GC state
gcAssistTime int64 // Nanoseconds in assistAlloc
gcFractionalMarkTime int64 // Nanoseconds in fractional mark worker (atomic)

// gcMarkWorkerMode is the mode for the next mark worker to run in.
// That is, this is used to communicate with the worker goroutine
// selected for immediate execution by
// gcController.findRunnableGCWorker. When scheduling other goroutines,
// this field must be set to gcMarkWorkerNotWorker.
gcMarkWorkerMode gcMarkWorkerMode
// gcMarkWorkerStartTime is the nanotime() at which the most recent
// mark worker started.
gcMarkWorkerStartTime int64

// gcw is this P's GC work buffer cache. The work buffer is
// filled by write barriers, drained by mutator assists, and
// disposed on certain GC state transitions.
gcw gcWork

// wbBuf is this P's GC write barrier buffer.
//
// TODO: Consider caching this in the running G.
wbBuf wbBuf

runSafePointFn uint32 // if 1, run sched.safePointFn at next safe point

// statsSeq is a counter indicating whether this P is currently
// writing any stats. Its value is even when not, odd when it is.
statsSeq uint32

// Lock for timers. We normally access the timers while running
// on this P, but the scheduler can also do it from a different P.
timersLock mutex

// Actions to take at some time. This is used to implement the
// standard library's time package.
// Must hold timersLock to access.
timers []*timer

// Number of timers in P's heap.
// Modified using atomic instructions.
numTimers uint32

// Number of timerDeleted timers in P's heap.
// Modified using atomic instructions.
deletedTimers uint32

// Race context used while executing timer functions.
timerRaceCtx uintptr

// scannableStackSizeDelta accumulates the amount of stack space held by
// live goroutines (i.e. those eligible for stack scanning).
// Flushed to gcController.scannableStackSize once scannableStackSizeSlack
// or -scannableStackSizeSlack is reached.
scannableStackSizeDelta int64

// preempt is set to indicate that this P should be enter the
// scheduler ASAP (regardless of what G is running on it).
preempt bool

// Padding is no longer needed. False sharing is now not a worry because p is large enough
// that its size class is an integer multiple of the cache line size (for any of our architectures).
}

GM 调度器

image-20231212102400898

Go 1.2 前的调度器实现,限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。

每个 goroutine 对应于 runtime 中的一个抽象结构:G;而 thread 作为 物理CPU 的存在而被抽象为一个结构:M(machine)。

所有创建的 G 都会在一个全局队列中排队,等待被 M 执行(M从 global queue中拿取任务执行)。此时 global queue 会有一个全局大锁 Sched.Lock,会影响 global queue吞吐。

goroutine 调用了一个阻塞的系统调用,运行这个 goroutine 的线程就会被阻塞,这时至少应该再创建、唤醒一个线程来运行别的没有阻塞的 goroutine。线程这里可以创建不止一个,可以按需不断的创建,而活跃的线程(处于非阻塞状态的线程)的最大个数存储在变量 GOMAXPROCS 中。(创建、唤醒一个线程的开销是比较大的。)

  1. 遇到 syscall 唤醒一个 M,避免有 G 在等;
  2. 遇到 chan blocking 切换 G 状态为 waiting,当前 M 去执行其他 G,waiting G chan 完成后,恢复为 runable,等待 M 来重新执行(不一定是之前的M)

GM 调度模型的问题

  • 单一全局互斥锁(Sched.Lock)和集中状态存储

    导致所有 goroutine 相关操作,比如:创建、结束、重新调度等都要上锁

  • Goroutine 传递问题

    M 经常在 M 之间传递 可运行goroutine,这导致调度延迟增大以及额外的性能损耗(刚创建的 G 放到了全局队列,而不是本地M执行,不必要的开销和延迟;以及当一个 goroutine 阻塞的时候,需要从 M 放回 global queue,等到 runable 的时候,再从global queue中可能被其他的 M 拿到并执行)

  • Per-M 持有内存缓存(M.mcache)

    类似TCMalloc的结构,每个 M 持有 mcachestack alloc,然后只有在 M 运行 Go 代码时才需要使用的内存(每个 mcache 可以高达 2mb),当 M 在处于 syscall 时并不需要。运行 Go 代码和阻塞在 syscall 的 M 的比例高达 1:100,造成了很大的浪费。

    同时内存亲缘性也较差,G 当前在 M 运行后对 M 的内存进行了预热,因为现在 G 调度到同一个 M 的概率不高,数据局部性不好。(proc a new proc b

  • 严重的线程阻塞/解锁

    在系统调用的情况下,工作线程经常被阻塞和取消阻塞,这增加了很多开销。比如 M 找不到G,此时 M 就会进入频繁阻塞/唤醒来进行检查的逻辑,以便及时发现新的 G 来执行

GM 调度模型的问题可以参考这边文章

Scalable Go Scheduler Design Doc

[翻译]Scalable Go Scheduler Design Doc[Go 可扩展调度设计文档]

GMP 概念

  • P:Processor 是一个抽象的概念,并不是真正的物理 CPU

    Dmitry Vyukov 的方案是引入一个结构 P,它代表了 M 所需的上下文环境,也是处理用户级代码逻辑的处理器。

    它负责衔接 M 和 G 的调度上下文,将等待执行的 G 与 M 对接。

    当 P 有任务时需要创建或者唤醒一个 M 来执行它队列里的任务。所以 P/M 需要进行绑定,构成一个执行单元。

    P 决定了并行任务的数量,可通过 runtime.GOMAXPROCS 来设定。在 GO 1.5 之后 GOMAXPROCS 被默认设置可用的核数,而之前默认为1。

Tips:uber-go/automaxprocs

Automatically set GOMAXPROCS to match Linux container CPU quota。

自动设置GOMAXPROCS以匹配Linux容器CPU配额。

cache, timerg,cache/stackallc 从 M 移到了 P,而 G 队列也被分为两类,保留全局 G 队列,同时每个 P 中都会有一个本地的 G 队列。(g0还是放在M里面)

GMP 调度器

image-20231212113431292

引入了 local queue,因为 P 的存在,runtime 并不需要做一个集中式的 goroutine 调度,每一个 M 都会在 P's local queueglobal queue 或者其他 P 队列中找 G 执行,减少全局锁对性能的影响。

这也是 GMP Work-stealing 调度算法的核心。

注意 P 的本地 G 队列还是可能面临一个并发访问的场景,为了避免加锁,这里 P 的本地队列是一个 LockFree 的队列,窃取 G 时使用 CAS 原子操作来完成。

关于 LockFreeCAS 的知识参见:简化概念下的 lock-free 编程

Work-stealing 调度算法

image-20231212163850477

当一个 P 执行完本地所有的 G 之后,并且全局队列为空的时候,会随机尝试挑选一个受害者 P,从它的 G 队列中窃取一半的 G。否则会从全局队列中获取(当前个数/GOMAXPROCS)个 G。

为了保证公平性,从随机位置上的 P 开始,而且遍历的顺序也随机化了(选择一个小于 GOMAXPROCS,且和它互为质数的步长),保证遍历的顺序也随机化了。

找到任务之后,切换调用栈执行任务。再循环不断的获取任务,直到进入休眠。

image-20231213105053906

光窃取是不够的,可能会导致全局对了饥饿。(优先级:1/61概率去全局队列拿;全局队列没有或者是60/61再去本地队列;本地队列没有的话,再去同其他 P steal;其他队列也没有再检查全局队列;全局队列没有,就 poll network,处理网络请求中已经 ready的。)

P 的调度算法中还会每个 N 轮调度之后,就去全局队列拿一个 G。

谁放入的全局队列呢?

image-20231213105300133

新建 G 时,P 的本地 G 队列放不下,已满并达到 256 个的时候会放半数 G 到全局队列去,阻塞的系统调用返回时找不到空闲 P 也会放到全局队列。

Syscall

Go 有自己封装的 syscall,也就是进入和退出 syscall 的时候执行 entersyscall/exitsyscall,也只有被封装了系统调用才有可能出发重新调度,它将改变 P 的状态为 syscall

系统监视器(system monitor),称为 sysmon,会定时扫描。在执行系统调用时,如果某个 P 的 G 执行超过一个 sysmon tick,则脱离 M。

调用 syscall 后会解绑 P,然后 M 和 G 进入阻塞,而 P 此时的状态就是 syscall,表明这个 P 的 G 正在 syscall 中,这时的 P 是不能被调度给别的 M 的。

如果在短时间内阻塞的 M 就唤醒了,那么 M 会优先来重新获取这个 P,能够取到就继续绑回去,这样有利于数据的局部性。

image-20231213110031349

在执行 syscall 时,如果某个 P 的 G 执行超过一个 sysmon tick(10ms),就会把他设为 idle,重新调度给需要的 M,强制解绑。上图 P3 与 M 解绑,进入 P sched。M 调用 G 依然执行 syscall

image-20231213110248609

P1 和 M 脱离后目前在 idle list 中等待被绑定(处于 syscall 状态)。G35 在 syscall 结束之后,会被自己放到全局队列,等待被 P 获取。

syscall 结束后 M 按照如下规则执行直到满足其中一个条件:

  • 尝试获取同一个 P(P1),恢复执行 G(获取同一个 P,应该是考虑情缘性,执行相同的 G。)
  • 尝试获取 idle list 中的其他空闲 P,恢复执行 G
  • 找不到空闲 P,把 G 放回 global queue,M 放回到 idle list

OS thread

当使用了 syscall,Go 无法限制 Blocked OS threads 的数量。

The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit. This package’s GOMAXPROCS function queries and changes the limit.

GOMAXPROCS 变量限制了可以同时执行用户级 Go 代码的操作系统线程数量。在系统调用中代表 Go 代码被阻塞的线程数量没有限制;这些线程不计入 GOMAXPROCS 限制。本软件包的 GOMAXPROCS 函数可查询并更改限制。

Tips:使用 syscall 写程序要认真考虑 pthread exhaust 问题。syscall 会产生 Blocked OS threads

Sping thread

线程自旋是相对于线程阻塞而言的,表象就是循环执行一个指定逻辑(调度逻辑,目的是不停地寻找 G)。

这样做的问题显而易见,如果 G 迟迟不来,CPU 会白白浪费在这无意义的计算上。但好处也很明显,降低了 M 的上下文切换成本,提高了性能。(自旋时间从 20us ,然后 1ms 后翻倍,直到 10ms,不断重置)

Go 的设计者倾向于高性能的并发表现,选择了后者。

在两个地方引入自旋:

  • 类型 1:M 不带 P 的找 P 挂载(一有 P 释放就结合)
  • 类型2 :M 带 P 的找 G 运行(一有 runable 的 G 就执行)

为了避免过多浪费 CPU 资源,自旋的 M 最多只允许 GOMAXPROCSBusy P)。同时当有类型1 的自旋 M 存在时,类型 2 的自旋 M 就不阻塞,阻塞会释放 P,一释放 P 就马上被类型 1 的自旋 M 抢走了,没必要。

对于未绑定的 游离态 的 M,会进入休眠阻塞态。

在新 G 被创建、M 进入系统调用、M 从空闲被激活者三种状态变化前,调度器会确保至少有一个自旋 M 存在(唤醒或创建一个 M),除非没有空闲的 P。

  • 当新 G 创建,如果有可用 P,就意味着新 G 可以被立即执行,即便不再同一个 P 也无妨,所以我们保留一个自旋的 M (这时应该不存在类型 1 的自旋,只有类型 2 的自旋)就可以保证新 G 很快被运行。
  • 当 M 进入系统调用,意味着 M 不知道何时可以醒来,那么 M 对应的 P 中剩下的 G 就得有新的 M 来执行,所以我们保留一个自旋的 M 来执行剩下的 G (这时应该不存在类型 2 的自旋,只有类型 1 的自旋)。
  • 如果 M 从空闲变成活跃,意味着可能一个处于自旋状态的 M 进入工作状态了,这时要检查并确保还有一个自旋 M 存在,以防还有 G 或者还有 P 空着的。

sysmon

image-20231213112335054

sysmon 也叫监控线程,它无需 P 也可以运行,他是一个死循环,每 20us~10ms 循环一次,循环完一次就 sleep 一会,为什么会是一个变动的周期呢,主要是避免空转,如果每次循环都没什么需要做的事,那么 sleep 的时间就会加大。

  • 释放闲置超过 5分钟的 span 物理内存;
  • 如果超过 2分钟没有垃圾回收,强制执行;
  • 将长时间未处理的 netpoll 添加到全局队列;
  • 向长时间运行的 G 任务发出抢占调度;
  • 收回因 syscall 长时间阻塞的 P;

协作式抢占,当 P 在 M 上执行时间超过 10ms,sysmon 调用 preemptone 将 G 标记为 stackPreempt(代表这个 G 为可被抢占)。因此需要在某个地方触发检测逻辑,Go 当前是在检查栈是否溢出的地方判定(morestack()),M 会保存当前 G 的上下文,重新进入调度逻辑。

死循环:非抢占式:runtime: golang scheduler is not preemptive - it’s cooperative? #11462

信号抢占:golang 基于信号的抢占式调度的设计实现原理

image-20231213112512194

异步抢占,注册 sigurg 信号,通过 sysmon 检测,对 M 对应的线程发送信号,触发注册的 handler,它往当前 G 的 PC 中(指令的寄存器中)插入一条指令(调用某个方法实现跳出),在处理完 handler,G 恢复后,自己把自己推到了 global queue 中。

Tips:发生程序 hang 死情况时,通常可以使用一些工具做诊断:

  • go tool pprof
  • perf,top

Network poller

image-20231214103348046

Go 所有的 I/O 都是阻塞的。然后通过 goroutine + channel 来处理并发。

因此所有的 IO 逻辑都是直来直去的,不再需要回调,不再需要 future,要的仅仅是 step by step。这对于代码的可读性是很有帮助的。

G 发起网络 I/O 操作也不会导致 M 被阻塞(仅阻塞 G ),从而不会导致大量 M 被创建出来。将异步 I/O 转换为阻塞的 I/O 的部分称为 netpoller

打开或接受连接都被设置为非阻塞模式。如果你试图对其进行 I/O 操作,并且文件描述符数据还没有准备好,它将返回一个错误代码,然后调用 netpoller,等待后续被 runtime network poller将其调度到 global queue。G 会进入 gopark 函数,将当前正在执行的 G 状态保存起来,然后切换到新的堆栈上执行新的 G。

那么 G 什么时候被调度回来呢?

image-20231214103822220

netowrk poller 触发点:

  • sysmon
  • schedule():M 找 G 的调度函数
  • GC:start the world

调用 netpoll() 在某一次调度 G 的过程中,处于就绪状态的 fd 对应的 G 就会被调度回来。(也就是从 ready 的网络事件中恢复 G)。

G 的 gopark 状态: G 置为 waiting 状态,等待显示 goready 唤醒,在 poller 中用得较多,还有锁、chan 等。

Scheduler Affinity

GM 调度器时代的,chan 操作导致的切换代价。

  • coroutine #7 正在等待消息,阻塞在 chan。一旦收到消息,这个 goroutine 就被推到全局队列

    image-20231214104019605

  • 然后,chan 推送消息,goroutine #X 将在可用线程上运行,而 goroutine #8 将阻塞在 chan

    image-20231214104212317

  • goroutine #7 现在在可用线程上运行

    image-20231214104253595

chan 来回通信的 goroutine 会导致频繁的 blocks,即频繁地在本地队列中重新排队。

然而,由于本地队列是 FIFO 实现,如果另一个 goroutine 占用线程,unblock goroutine 不能保证尽快运行。

image-20231214104408459

同时 Go 情缘性调度的一些限制:

  • work-stealing

    当 P 的 local queue 任务不够,同时 global queuenetwork poller 也会空,这时从其他 P 窃取任务运行,然后任务就运行到了其他线程,情缘性就没有意义。

  • 系统调用。

    syscall 产生,Go 把当前线程设置为 blocking mode,让一个新的线程接管了这个 P (过一个 sysmon tick 才会交给其他 M,大多数 syscall 都是很快的)

goroutine #9chan 被阻塞后恢复。但是,它必须等待 #2#5#4 之后才能运行。

goroutine #5 将阻塞其线程,从而延迟 goroutine #9,并使其面临被另一个 P 窃取的风险。

针对 communicate-and-wait 模式,进行了情缘性调度的优化。

当前 local queue 使用了 FIFO 实现,unlock 的 G 无法尽快执行,如果队列中前面存在占用线程的其他 G 。

Go 1.5 在 P 中引入了 runnext 特殊的一个字段,可以高优先级执行 unblock G

image-20231214104704320

goroutine #9 现在被标记为runnext(下一个可运行的)。这种新的优先级排序允许 goroutine 在再次被阻塞之前快速运行。此时就会被其他的 P 给 steal 过去。

这一变化对运行中的标准库产生了总体上的积极影响,提高了一些包的性能。

GMP 问题总结

  • 单一全局互斥锁(Sched.Lock)和集中状态存储

    G 被分成全局队列和 P 的本地队列,全局队列依旧是全局锁,但是使用场景明显很少,P 本地队列使用无锁队列,使用原子操作来面对可能的并发场景(其他 P steal

  • Goroutine 传递问题

    G 创建时,就在 P 的本地队列,可以避免在 G 之间传递(窃取除外),G 对 P 的数据局部性好;当 G 开始执行了,系统调用返回后 M 会尝试获取可用 P,获取到了的话可以避免在 M 之间传递。而且优先获取调用阻塞前的 P,所以 G 对 M 数据局部性好,G 对 P 的数据局部性也好。

  • Per-M 持有内存缓存(M.mcache

    内存 mcache 只存在 P 的结构中,P 最多只有 GOMAXPROCS个,远小于 M 的个数,所以内存没有过多的消耗。

  • 严重的线程阻塞/解锁

    通过引入自旋,保证任何时候都有处于等待状态的自旋 M,避免在等待可用的 P 和 G 时频繁的阻塞和唤醒。

    – by Dmitry Vyukov Scalable Go Scheduler Design Doc

Goroutine Lifecycle

Go 程序启动

整个程序始于一段汇编,而在随后的 runtime.rt0_go(也是汇编程序)中,会执行很多初始化工作。

go/src/runtime/asm_amd64.s

1
2
3
4
5
6
7
8
// _rt0_amd64 is common startup code for most amd64 systems when using
// internal linking. This is the entry point for the program from the
// kernel for an ordinary -buildmode=exe program. The stack holds the
// number of arguments and the C-style argv.
TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ 0(SP), DI // argc
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)

image-20231212165201810

  • 绑定 m0g0m0 就是程序的主程序,程序启动必然会拥有一个主线程,这个就是 m0g0 负责调度,即 schedule() 函数。
  • 创建 P,绑定 m0p0,首先会创建逻辑 CPU 核数个 P,存储在 sched 的空闲链表(pidle),也就是 P 的初始化
  • 新建任务 gp0 本地队列,m0g0 会创建一个指向 runtime.main()g,并放到 p0 的本地队列。

runtime.main():启动 sysmon 线程;启动 GC 协程;执行 init,即代码中的各种 init 函数;执行 main.main 函数。

OS thread 创建

image-20231212171451806

准备运行新 goroutine 将唤醒 P 以更好的分发工作。这个 P 将创建或者唤醒一个与之关联的 M 绑定到一个 OS thread。

image-20231212171625449

go func() 中出发 Wakeup 唤醒机制:

有空闲的 P 而没有在 spining(自旋) 状态的 M 时候,需要去唤醒一个空闲(睡眠)的 M 或者新建一个。

M0 main

程序启动后,Go 已经将主线程和 M 绑定 (rt0_go)。

goroutine 创建完后,它是放在当前 P 的 local queue 还是 global queue

runtime.runqput 这个函数会尝试把 newg 放到本地队列上,如果本地队列满了,它会将本地队列的前半部分和 newg 迁移到全局队列中。剩下的事情就等待 M 自己去拿任务了。

当线程首次创建时,会执行一个特殊的 G,即 g0,它负责管理和调度 G。Go:g0,特殊的 Goroutine

image-20231212171839676

Reference

Go: Goroutine, OS Thread and CPU Management

goroutine背后的系统知识

Go’s work-stealing scheduler

Go的核心 goroutine sysmon

深入Golang调度器之GMP模型

Golang Goroutine的调度机制

Go语言调度器之调度main goroutine(14)

Go语言调度器之盗取goroutine(17)

Go调度详解

Golang/Go goroutine调度器原理/实现【原】

Golang源码探索(二) 协程的实现原理

golang scheduler

The Go netpoller

Go: g0, 特殊的goroutine

Go 语言调度(一): 系统调度

Go 语言调度(三): 并发

Go的核心 goroutine sysmon

Go: How Does a Goroutine Start and Exit?

Go: g0, Special Goroutine

Go: How Does Go Recycle Goroutines?

Go: What Does a Goroutine Switch Actually Involve?

golang 基于信号的抢占式调度的设计实现原理

Go runtime 源码分析

Go 1.2 Runtime Symbol Information

Go: Asynchronous Preemption

Go: Goroutine and Preemption

Go: gsignal, Master of Signals