Understanding Go's Concurrency Model: Goroutine, Scheduler (GMP) and Channels
Go implements a two‑level N:M concurrency model where lightweight Goroutines (G) run on logical processors (P) backed by OS threads (M), with the GMP scheduler managing run queues and channels—mutex‑protected circular buffers with send/receive queues—providing efficient, preemptive multitasking and communication.
Concurrent programming is a core concern for developers, and Go (Golang) is renowned for its built‑in high‑concurrency capabilities. This article explains the implementation of Goroutine and channel, the underlying thread models, and the Go scheduler.
1. Thread implementation models
There are three thread models:
User‑level thread model (N:1) : Managed entirely in user space; all user threads map to a single kernel scheduling entity (KSE). Lightweight but a blocking I/O operation blocks the whole process.
Kernel‑level thread model (1:1) : Each user thread is a kernel thread; true parallelism but higher overhead.
Two‑level thread model (N:M) : Combines the advantages of the above two models. User threads are scheduled onto multiple kernel threads (KSEs) by a user‑level scheduler.
Go adopts the two‑level model.
2. Go's concurrency mechanism
In Go, an independent control flow that is not managed by the OS kernel is called a Goroutine . Goroutine is a lightweight coroutine that, together with the Go scheduler, implements a two‑level thread model.
The scheduler uses three structures, known as the GMP model :
G : Represents a Goroutine. It stores the stack, status, and registers needed for context switching.
M : Represents an OS thread (kernel thread). Each M is bound to a single KSE.
P : Represents a logical processor. A P holds a run queue of ready Goroutines and provides execution context for Ms.
Key relationships:
M ↔ KSE is 1:1.
P ↔ M is 1:1 (but a P can be reassigned to another M).
G ↔ P is many‑to‑one (a G must be attached to a P to run).
G structure (partial source)
type g struct {
stack stack // Goroutine stack range [stack.lo, stack.hi)
stackguard0 uintptr // Used for preemptive scheduling
m *m // Thread that runs this G
sched gobuf // Scheduler data for this G
atomicstatus uint32 // Goroutine state
...
}M structure (partial source)
type m struct {
g0 *g // Special G for runtime tasks
gsignal *g // G for signal handling
curg *g // Currently running G
p puintptr // Associated P
nextp puintptr // Potential P to bind
oldp puintptr // P before a system call
spinning bool // Whether M is searching for a runnable G
lockedg *g // G locked to this M
}P structure (partial source)
type p struct {
status uint32
m muintptr // Associated M
runqhead uint32
runqtail uint32
runq [256]guintptr // Local run queue
runnext guintptr // Cached runnable G
gFree struct { gList int32 }
...
}3. Scheduler
The scheduler repeatedly calls runtime.schedule , which selects a runnable G from the local P run‑queue or the global run‑queue, or blocks waiting for work.
func schedule() {
_g_ := getg()
// Try global run queue every 61 calls
if gp == nil {
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 1)
unlock(&sched.lock)
}
}
// Try local P run queue
if gp == nil {
gp, inheritTime = runqget(_g_.m.p.ptr())
}
// If still nil, block until a G becomes runnable
if gp == nil {
gp, inheritTime = findrunnable()
}
execute(gp, inheritTime)
}The execute function binds the selected G to the current M, changes its state to _Grunning , and finally jumps to the Goroutine’s entry point via runtime.gogo :
func execute(gp *g, inheritTime bool) {
_g_ := getg()
_g_.m.curg = gp
gp.m = _g_.m
casgstatus(gp, _Grunnable, _Grunning)
gp.stackguard0 = gp.stack.lo + _StackGuard
if !inheritTime {
_g_.m.p.ptr().schedtick++
}
gogo(&gp.sched)
}When a Goroutine finishes, runtime.goexit0 marks it as _Gdead , clears its fields, puts it back into the free list, and triggers a new scheduling cycle.
func goexit0(gp *g) {
casgstatus(gp, _Grunning, _Gdead)
gp.m = nil
// ... clear other fields ...
gfput(_g_.m.p.ptr(), gp) // return to free list
schedule() // start next cycle
}4. Channels
Channels are the primary communication primitive between Goroutines. Internally they are represented by runtime.hchan , which includes a mutex‑protected circular buffer and two wait queues (send and receive).
type hchan struct {
qcount uint // Number of elements in the buffer
dataqsiz uint // Buffer size
buf unsafe.Pointer // Pointer to the buffer
elemsize uint16 // Size of each element
closed uint32 // Closed flag
elemtype *_type // Element type
sendx uint // Send index
recvx uint // Receive index
recvq waitq // Waiting receivers
sendq waitq // Waiting senders
lock mutex // Protects the channel
}Creating a channel calls runtime.makechan , which allocates the hchan structure and, depending on element type and buffer size, allocates the buffer either together with the struct or separately.
func makechan(t *chantype, size int) *hchan {
elem := t.elem
mem, overflow := math.MulUintptr(elem.size, uintptr(size))
// ... error checks ...
var c *hchan
switch {
case mem == 0:
c = (*hchan)(mallocgc(hchanSize, nil, true))
c.buf = c.raceaddr()
case elem.ptrdata == 0:
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
c.buf = add(unsafe.Pointer(c), hchanSize)
default:
c = new(hchan)
c.buf = mallocgc(mem, elem, true)
}
c.elemsize = uint16(elem.size)
c.elemtype = elem
c.dataqsiz = uint(size)
return c
}Sending to a channel invokes runtime.chansend . The function first locks the channel, checks for closure, then follows three possible paths:
If there are waiting receivers, the value is copied directly to the receiver (bypassing the buffer).
If the buffer has free slots, the value is stored in the circular buffer.
Otherwise the sender is placed on the channel’s send queue and the Goroutine blocks via gopark .
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
if c == nil {
if !block { return false }
gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
throw("unreachable")
}
lock(&c.lock)
if c.closed != 0 { unlock(&c.lock); panic("send on closed channel") }
// Direct send to waiting receiver
if sg := c.recvq.dequeue(); sg != nil {
send(c, sg, ep, func(){ unlock(&c.lock) }, 3)
return true
}
// Buffer has space?
if c.qcount < c.dataqsiz {
qp := chanbuf(c, c.sendx)
typedmemmove(c.elemtype, qp, ep)
c.sendx = (c.sendx + 1) % c.dataqsiz
c.qcount++
unlock(&c.lock)
return true
}
// Blocked send
if !block { unlock(&c.lock); return false }
// enqueue sudog and park
// ... (omitted for brevity) ...
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
// ...
}Receiving from a channel uses runtime.chanrecv with symmetric logic: it first checks for waiting senders, then for buffered data, and finally blocks the receiver if necessary.
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
if c == nil {
if !block { return false, false }
gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
throw("unreachable")
}
lock(&c.lock)
if c.closed != 0 && c.qcount == 0 {
unlock(&c.lock)
if ep != nil { typedmemclr(c.elemtype, ep) }
return true, false
}
// Direct receive from waiting sender
if sg := c.sendq.dequeue(); sg != nil {
recv(c, sg, ep, func(){ unlock(&c.lock) }, 3)
return true, true
}
// Buffered data available?
if c.qcount > 0 {
qp := chanbuf(c, c.recvx)
if ep != nil { typedmemmove(c.elemtype, ep, qp) }
typedmemclr(c.elemtype, qp)
c.recvx = (c.recvx + 1) % c.dataqsiz
c.qcount--
unlock(&c.lock)
return true, true
}
// Blocked receive
if !block { unlock(&c.lock); return false, false }
// enqueue sudog and park
// ... (omitted for brevity) ...
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
// ...
}Closing a channel calls runtime.closechan , which marks the channel as closed, wakes all waiting senders (which will panic) and receivers (which will return zero values), and clears pending sudog elements.
func closechan(c *hchan) {
lock(&c.lock)
c.closed = 1
var glist gList
// Wake all receivers
for { sg := c.recvq.dequeue(); if sg == nil { break }
if sg.elem != nil { typedmemclr(c.elemtype, sg.elem); sg.elem = nil }
gp := sg.g; gp.param = nil; glist.push(gp) }
// Wake all senders (they will panic)
for { sg := c.sendq.dequeue(); if sg == nil { break }
gp := sg.g; gp.param = nil; glist.push(gp) }
unlock(&c.lock)
for !glist.empty() { gp := glist.pop(); gp.schedlink = 0; goready(gp, 3) }
}Overall, the combination of Goroutine, channel, and the GMP scheduler forms a robust and efficient concurrency model that underpins Go’s high‑performance server‑side development.
Tencent Cloud Developer
Official Tencent Cloud community account that brings together developers, shares practical tech insights, and fosters an influential tech exchange community.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.