GMP & Concurrency Model Deep Dive#
I. GMP Efficient Scheduling#
1. Core Concepts#
| Component | Full Name | Description |
|---|---|---|
| G (Goroutine) | Lightweight thread | Stack starts at 2KB (dynamically growable), contains program counter, stack pointer, register state |
| M (Machine) | OS thread | Corresponds to OS thread, responsible for executing G’s code, contains g0 (scheduling stack), curg (current G), m0 (main thread) |
| P (Processor) | Logical processor | Count determined by GOMAXPROCS, holds local run queue (LRQ, capacity 256), responsible for scheduling decisions and context switching |
G States: _Gidle, _Grunnable, _Grunning, _Gsyscall, _Gwaiting, _Gdead
2. Design Principles#
type g struct {
stack stack // stack info
sched gobuf // scheduling context
atomicstatus uint32 // G state
goid int64 // G ID
m *m // currently bound M
// ...
}
type p struct {
id int32
status uint32 // _Pidle, _Prunning
runqhead uint32 // local queue head
runqtail uint32 // local queue tail
runq [256]guintptr // local queue
runnext guintptr // next G to run (highest priority)
// ...
}
type m struct {
g0 *g // scheduling stack
curg *g // current G
p *p // currently bound P
nextp *p // next P (for wake-up)
spinning bool // whether doing work stealing
// ...
}3. Scheduling Strategies#
3.1 Scheduling Loop (schedule function)#
func schedule() {
_g_ := getg()
// Check global queue every 61 scheduling cycles
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
// Get one G from global queue
gp := globrunqget(_g_.m.p.ptr(), 1)
if gp != nil {
execute(gp)
}
}
// Get G from local queue, global queue, or netpoller
gp, inheritTime := runqget(_g_.m.p.ptr())
if gp == nil {
gp, inheritTime = findrunnable() // blocks until G found
}
execute(gp)
}3.2 Four Main Scheduling Triggers#
| Trigger | Condition | Implementation |
|---|---|---|
| Active scheduling | Calling Gosched() | Put current G back to queue, yield P |
| Passive scheduling | Blocking operations (syscall, channel, lock) | G state switches to _Gsyscall or _Gwaiting |
| Preemptive scheduling | Go 1.14+, signal-based | Send SIGURG signal, stack growth detection |
| Syscall scheduling | Entering syscall | Release P, M enters syscall, wake idle M |
Active scheduling:
func Gosched() {
// Put current G back to queue, yield P
goparkunlock(&runtime.sched.lock, waitReasonGosched, traceEvGoSched, 1)
}Preemptive scheduling:
// Signal-based preemptive scheduling
func preemptM(mp *m) {
// Send SIGURG signal
signalM(mp, sigPreempt)
}
// Stack growth detection (cooperative)
func morestack() {
// Check if preemption is needed
preempt = stackguard0 == stackPreempt
}Syscall scheduling:
func entersyscall() {
// Release P, M enters syscall
pp := _g_.m.p.ptr()
_g_.m.p = 0
pp.status = _Psyscall
// If there are idle P's and no spinning M, wake one
if sched.npidle > 0 && sched.nmspinning == 0 {
wakep()
}
}4. Work Stealing Mechanism#
func findrunnable() (gp *g, inheritTime bool) {
pp := _g_.m.p.ptr()
// 1. Local queue
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime
}
// 2. Global queue
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(pp, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false
}
}
// 3. Netpoller
if netpollinited() && atomic.Load(&netpollWaiters) > 0 {
if list := netpoll(0); !list.empty() {
gp := list.pop()
injectglist(&list)
return gp, false
}
}
// 4. Work stealing - steal half from other P's
for i := 0; i < 4; i++ {
for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
if sched.gcwaiting != 0 {
return nil, false
}
p2 := allp[enum.position()]
if pp == p2 {
continue
}
if gp := runqsteal(pp, p2, stealRunNextG); gp != nil {
return gp, false
}
}
}
// 5. Idle M yields P
if sched.runqsize != 0 {
return nil, false
}
// 6. Block and wait
stopm()
return nil, false
}5. Performance Optimization Keys#
5.1 P Count Selection#
// Best practice: P count = CPU cores
GOMAXPROCS = runtime.NumCPU()
// I/O intensive: can set more P's
// CPU intensive: equal to core count5.2 Local Queue Optimization#
- Prioritize
runnext(LIFO, cache-friendly) - Local queue capacity 256, avoiding frequent lock contention
- Batch transfer (
stealWork)
5.3 Memory Allocation Optimization#
- P holds
mcache, avoiding cross-P contention mcentralcache,mheapglobal heap
6. Deep Dive Analysis#
6.1 Syscall Optimization#
// ❌ Bad: syscall on every read
for {
syscall.Read(fd, buf)
}
// ✅ Good: batch reading
bufio.NewReader(fd).Read(buf)6.2 Lock Contention Issues#
// Use P-localization technique
type LocalCache struct {
data []int
mu sync.Mutex
}
// Optimization: private cache per P
var localCaches [runtime.GOMAXPROCS(0)]*LocalCache7. Performance Monitoring#
7.1 GODEBUG Scheduler Trace#
GODEBUG=schedtrace=1000,scheddetail=1 ./program
# Output example:
# SCHED 1004ms: gomaxprocs=8 idleprocs=4 threads=5 spinningthreads=0 idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0]7.2 pprof Analysis#
import _ "net/http/pprof"
// View goroutine blocking
curl http://localhost:6060/debug/pprof/goroutine?debug=2
// Trace analysis
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// Analyze: go tool trace trace.out8. Optimization Techniques#
8.1 Goroutine Pooling#
type Pool struct {
work chan func()
sem chan struct{}
}
func (p *Pool) Schedule(task func()) {
select {
case p.work <- task:
case p.sem <- struct{}{}:
go p.worker(task)
}
}8.2 Lock-Free Queue Using P Locality#
// Leverage P's local queue mechanism
var taskQueues [runtime.GOMAXPROCS(0)]chan Task
func Submit(task Task) {
pid := runtime_procPin()
taskQueues[pid] <- task
runtime_procUnpin()
}8.3 NUMA-Aware Scheduling (Go 1.21+)#
import "runtime"
runtime.SetCPUAffinity([]int{0, 2, 4, 6}) // Bind to NUMA node 09. Scheduling Evolution#
| Version | Feature | Description |
|---|---|---|
| Go 1.0 | Basic GMP | Goroutine-based scheduling |
| Go 1.1 | Performance boost | M:N scheduler |
| Go 1.2 | Preemption attempt | Cooperative-based |
| Go 1.5 | Improved P | Work stealing optimization |
| Go 1.8 | More aggressive | Syscall optimization |
| Go 1.14 | Asynchronous preemption | Signal-based preemption |
| Go 1.21 | NUMA-aware | Improved multi-core performance |
GMP Scheduling Core Advantages:
- M:N model: Few threads can host a large number of goroutines
- Work stealing: Dynamically balance load, maximize CPU utilization
- P localization: Reduce lock contention, improve cache hit rate
- Preemptive: Avoid long occupation causing starvation of other G’s
- Adaptive: Dynamically adjust scheduling strategy based on load
Understanding GMP is crucial for writing efficient programs, especially when handling mixed scenarios of high concurrency, I/O intensive, and CPU intensive workloads, enabling targeted optimization of goroutine usage patterns.
II. Goroutine Channels & Concurrency Patterns#
1. Goroutine Deep Dive#
Essentially a user-space coroutine.
2. Channel Implementation#
2.1 Memory Structure#
type hchan struct {
qcount uint // current queue element count
dataqsiz uint // circular queue size
buf unsafe.Pointer // pointer to ring buffer
elemsize uint16
closed uint32
elemtype *_type
sendx uint // send index
recvx uint // receive index
recvq waitq // waiting receiver G queue
sendq waitq // waiting sender G queue
lock mutex // mutex lock
}2.2 Unbuffered vs Buffered#
// Unbuffered: synchronous communication, handoff mode
unbuf := make(chan int)
// Underlying flow:
// Sender G1 -> chan lock -> finds G2 in recvq -> directly copies data to G2 -> wakes G2
// Implements lock-free exchange, no intermediate buffer
// Buffered: asynchronous communication, circular queue
buf := make(chan int, 10)
// Underlying flow:
// Send: lock -> put into buf[sendx] -> sendx++ -> unlock
// Receive: lock -> take from buf[recvx] -> recvx++ -> unlock
// Doesn't block unless full/empty3. Concurrency Patterns#
3.1 Generator Pattern#
func generator(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func main() {
ch := generator(1, 2, 3, 4)
for v := range ch {
println(v)
}
}3.2 Pipeline Pattern#
// Pipeline processing
func multiply(done <-chan struct{}, in <-chan int, factor int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * factor:
case <-done:
return
}
}
}()
return out
}
// Usage: 1 -> x2 -> +1
func main() {
done := make(chan struct{})
defer close(done)
gen := generator(1, 2, 3)
mult := multiply(done, gen, 2)
add := add(done, mult, 1)
for v := range add {
println(v) // 3, 5, 7
}
}3.3 Fan-in / Fan-out#
// Fan-out: distribute tasks to multiple workers
func fanOut(in <-chan int, workers int) []<-chan int {
channels := make([]<-chan int, workers)
for i := 0; i < workers; i++ {
channels[i] = worker(in)
}
return channels
}
// Fan-in: merge multiple channels
func fanIn(channels ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
multiplex := func(c <-chan int) {
defer wg.Done()
for n := range c {
out <- n
}
}
wg.Add(len(channels))
for _, c := range channels {
go multiplex(c)
}
go func() {
wg.Wait()
close(out)
}()
return out
}3.4 Select Multiplexing#
// Non-blocking operation
func tryReceive(ch chan int) (int, bool) {
select {
case v := <-ch:
return v, true
default:
return 0, false
}
}
// Timeout control
func withTimeout(ch chan int, timeout time.Duration) (int, error) {
select {
case v := <-ch:
return v, nil
case <-time.After(timeout):
return 0, errors.New("timeout")
}
}
// Heartbeat detection
func heartbeat(interval time.Duration) <-chan struct{} {
heart := make(chan struct{})
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
heart <- struct{}{}
}
}
}()
return heart
}3.5 Worker Pool#
type Task func() interface{}
type Pool struct {
tasks chan Task
results chan interface{}
workers int
wg sync.WaitGroup
quit chan struct{}
}
func NewPool(workers, buffer int) *Pool {
return &Pool{
tasks: make(chan Task, buffer),
results: make(chan interface{}, buffer),
workers: workers,
quit: make(chan struct{}),
}
}
func (p *Pool) worker(id int) {
defer p.wg.Done()
for {
select {
case task, ok := <-p.tasks:
if !ok {
return
}
p.results <- task()
case <-p.quit:
return
}
}
}
func (p *Pool) Start() {
p.wg.Add(p.workers)
for i := 0; i < p.workers; i++ {
go p.worker(i)
}
}
func (p *Pool) Submit(task Task) {
p.tasks <- task
}
func (p *Pool) Results() <-chan interface{} {
return p.results
}
func (p *Pool) Stop() {
close(p.quit)
p.wg.Wait()
close(p.results)
}3.6 Tee Channel (Branching)#
// Duplicate a single channel to multiple outputs
func tee(in <-chan int, count int) []<-chan int {
outs := make([]chan int, count)
for i := range outs {
outs[i] = make(chan int)
}
go func() {
defer func() {
for _, out := range outs {
close(out)
}
}()
for v := range in {
for _, out := range outs {
out <- v
}
}
}()
result := make([]<-chan int, count)
for i, out := range outs {
result[i] = out
}
return result
}3.7 Context Propagation and Cancellation#
// Hierarchical cancellation
func worker(ctx context.Context, id int, in <-chan int) {
for {
select {
case v, ok := <-in:
if !ok {
return
}
fmt.Printf("Worker %d: %d\n", id, v)
case <-ctx.Done():
fmt.Printf("Worker %d cancelled: %v\n", id, ctx.Err())
return
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
ch := make(chan int)
for i := 0; i < 3; i++ {
go worker(ctx, i, ch)
}
// Send data...
time.Sleep(time.Second)
cancel() // Cancel all workers
}4. Advanced Concurrency Techniques#
4.1 Lock-Free Channel#
// Single Producer Single Consumer (SPSC) using ring buffer lock-free
type SPSCQueue struct {
buffer []interface{}
size uint64
mask uint64
head uint64 // consumer index
tail uint64 // producer index
}
func NewSPSCQueue(size uint64) *SPSCQueue {
// size must be power of 2
return &SPSCQueue{
buffer: make([]interface{}, size),
size: size,
mask: size - 1,
}
}
// Lock-free production
func (q *SPSCQueue) TryPush(v interface{}) bool {
head := atomic.LoadUint64(&q.head)
tail := atomic.LoadUint64(&q.tail)
if tail+1 == head || (tail == q.size-1 && head == 0) {
return false // full
}
q.buffer[tail&q.mask] = v
atomic.StoreUint64(&q.tail, tail+1)
return true
}4.2 Nil Channel#
// nil channel always blocks in select
func merge(chs ...<-chan int) <-chan int {
out := make(chan int)
for _, ch := range chs {
go func(c <-chan int) {
for v := range c {
out <- v
}
}(ch)
}
return out
}
// Improved: dynamically remove closed channels
func mergeDynamic(chs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for i, ch := range chs {
wg.Add(1)
go func(idx int, c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
chs[idx] = nil // mark as nil
}(i, ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}5. Performance Optimization#
5.1 Channel Capacity#
// Throughput vs Latency
// Unbuffered: lowest latency, lowest throughput
// Buffered: higher throughput, increased latency jitter
// Heuristic formula
optimalBufferSize = (1 + expectedBurstSize) * numberWorkers
// Practical benchmarking
func benchmarkBuffer(size int) {
ch := make(chan int, size)
// When producer and consumer speeds mismatch, buffer smooths fluctuations
}5.2 Common Pitfalls#
// ❌ 1. Producer not closing channel causes goroutine leak
func leak() {
ch := make(chan int)
go func() {
for v := range ch { // never exits
fmt.Println(v)
}
}()
ch <- 1
// ch not closed, goroutine blocks forever
}
// ❌ 2. Sending to closed channel panics
func bad() {
ch := make(chan int)
close(ch)
ch <- 1 // panic
}
// ✅ 3. Reading from closed channel returns zero value immediately
func ok() {
ch := make(chan int)
close(ch)
v := <-ch // v = 0, no panic
v, ok := <-ch // ok = false
}
// Detecting goroutine leaks
import "runtime/pprof"
func detectLeak() {
before := runtime.NumGoroutine()
// Execute operation...
time.Sleep(time.Second)
after := runtime.NumGoroutine()
if after > before {
fmt.Printf("Leak detected: %d -> %d\n", before, after)
// Print stack to find leak point
pprof.Lookup("goroutine").WriteTo(os.Stdout, 1)
}
}
// Deadlock detection (compile time)
// go build -race detects data race
// Use go-deadlock package to detect deadlock
import "github.com/sasha-s/go-deadlock"
var mu deadlock.Mutex // Replace sync.Mutex6. Architecture Design Principles#
- Don’t communicate by sharing memory; share memory by communicating
- Pipeline depth 3-5 levels, deeper increases latency
- Worker Pool size =
runtime.GOMAXPROCS(0) * 2-4(higher for I/O intensive) - Channel lifecycle is clear: whoever creates, closes
- Use context for timeout and cancellation, avoid infinite blocking
- Prefer buffered channel + select default for non-blocking
- Benchmark to validate concurrency models, don’t prematurely optimize