blogbyAndrew

Go Scheduler Deep Dive: GMP Model, Preemption và Netpoll

April 4, 2026

Code editor displaying Go programming language source code

Bài viết này là bản dịch và bổ sung giải thích từ bài gốc Go Scheduler của nghiant3223. Nội dung được sử dụng cho mục đích học tập, phi thương mại. Mọi quyền thuộc về tác giả gốc.

Giới thiệu

Go ra mắt năm 2009 bởi Google, nhanh chóng trở thành một trong những ngôn ngữ phổ biến nhất cho backend development và distributed systems. Một trong những điểm mạnh nổi bật nhất của Go chính là concurrency model dựa trên goroutines — lightweight user-space threads được Go runtime quản lý hoàn toàn, thay vì phụ thuộc vào OS threads truyền thống.

Goroutine: Là đơn vị thực thi nhẹ (lightweight execution unit) do Go runtime quản lý. Mỗi goroutine chỉ tốn khoảng 2-8 KB stack memory ban đầu, so với 1-8 MB cho một OS thread. Một chương trình Go có thể chạy hàng triệu goroutines đồng thời.

Concurrency: Là khả năng xử lý nhiều tasks đồng thời (not necessarily in parallel). Go cung cấp concurrency thông qua goroutines và channels, cho phép viết code đơn giản nhưng hiệu quả cho các bài toán I/O-bound và CPU-bound.

Go cũng cung cấp channels — cơ chế communication và synchronization giữa các goroutines, theo triết lý nổi tiếng:

"Don't communicate by sharing memory; share memory by communicating."

Hiểu cách Go scheduler hoạt động bên trong giúp developer:

Nội dung bài viết

Bài viết sẽ đi sâu vào các chủ đề sau:

  1. Compilation và Go Runtime — quá trình biên dịch và vai trò của runtime
  2. Primitive Scheduler đến GMP Model — evolution của scheduler design
  3. GMP Model chi tiết — cấu trúc G, M, P và cách chúng phối hợp
  4. Bootstrap và tạo Goroutine — quá trình khởi tạo chương trình Go
  5. Schedule Loop — vòng lặp scheduling chính
  6. Preemption — cooperative và non-cooperative preemption
  7. System Call handling — cách scheduler xử lý syscalls
  8. Network I/O và Netpoll — I/O multiplexing trong Go runtime
  9. GC, Runtime Functions — garbage collection và các hàm runtime quan trọng

Compilation và Go Runtime

Quá trình biên dịch

Go compiler biến source code thành executable binary qua ba giai đoạn chính:

text
.go source  --->  .s assembly  --->  .o object  --->  executable binary
  (compile)        (assemble)         (link)
  1. Compile: Go source code (.go) được chuyển thành assembly (.s) cho target platform
  2. Assemble: Assembly code được chuyển thành object files (.o)
  3. Link: Object files được link với nhau và với Go runtime để tạo thành executable binary cuối cùng

Điểm quan trọng là Go statically links runtime vào mỗi binary. Điều này có nghĩa mọi Go binary đều chứa toàn bộ runtime bên trong — bao gồm scheduler, garbage collector, và memory allocator.

Go Runtime là gì?

Go Runtime: Là tập hợp các functions và data structures cung cấp scheduling, memory management, và garbage collection. Runtime được viết bằng Go và assembly, nằm trong runtime package của Go standard library.

Go runtime không phải là virtual machine (như JVM). Nó là một library được compile và link trực tiếp vào application binary:

text
+--------------------------------------------------+
|              Executable Binary                   |
|                                                  |
|  +-------------------+  +---------------------+  |
|  | Application Code  |  |    Go Runtime       |  |
|  |                   |  |                     |  |
|  | - main()          |  | - Scheduler (GMP)   |  |
|  | - your packages   |  | - Memory Allocator  |  |
|  | - goroutines      |  | - Garbage Collector |  |
|  |                   |  | - Netpoll           |  |
|  +-------------------+  +---------------------+  |
|                                                  |
+--------------------------------------------------+
Relationship between Go runtime and application code

Compiler thay thế keywords bằng runtime calls

Go compiler tự động thay thế một số keywords và built-in functions bằng các lời gọi đến runtime:

Keyword / FunctionRuntime CallMô tả
goruntime.newprocTạo goroutine mới
newruntime.newobjectAllocate memory cho object
make (channel)runtime.makechanTạo channel mới
make (map)runtime.makemapTạo map mới
make (slice)runtime.makesliceTạo slice mới

Ví dụ, khi bạn viết:

go
go myFunction()

Compiler sẽ chuyển thành:

go
runtime.newproc(myFunction)

Các hàm đặc biệt trong runtime

Một số functions trong runtime không có Go implementation — chúng tồn tại hoàn toàn ở assembly level hoặc được compiler xử lý đặc biệt:

Compiler Intrinsic: Là function mà compiler nhận diện và thay thế trực tiếp bằng machine code tối ưu, thay vì thực hiện function call thông thường. Trong Go, getg là một compiler intrinsic.

//go:linkname directive

//go:linkname: Là compiler directive cho phép một function trong package này liên kết (link) với implementation ở package khác tại link time, bỏ qua Go's normal visibility rules.

Directive này được sử dụng rộng rãi trong standard library để kết nối public API với internal runtime implementation:

go
// In time package:
//go:linkname Sleep runtime.timeSleep
func Sleep(d Duration)
 
// Actual implementation lives in runtime package:
// runtime/time.go
func timeSleep(ns int64) {
    // ... implementation
}

Khi user gọi time.Sleep(), linker sẽ redirect call đến runtime.timeSleep(). Cơ chế này giúp runtime giữ implementation internal trong khi vẫn expose clean public API.

Từ Primitive Scheduler đến GMP Model

Threading Models

Trước khi đi vào Go scheduler, cần hiểu ba mô hình threading cơ bản mà các ngôn ngữ lập trình sử dụng để map user-space threads lên kernel threads.

User-space Thread: Thread được quản lý bởi runtime/library ở user space, kernel không biết đến sự tồn tại của chúng. Goroutines trong Go là user-space threads.

Kernel Thread: Thread được quản lý trực tiếp bởi OS kernel, có thể được schedule lên CPU cores. Kernel threads tốn nhiều tài nguyên hơn user-space threads.

N:1 Model (Many-to-One)

Nhiều user-space threads được map lên một kernel thread duy nhất.

N:1 multithreading model - many user threads mapped to one kernel thread

1:1 Model (One-to-One)

Mỗi user-space thread được map trực tiếp lên một kernel thread.

1:1 multithreading model - each user thread mapped to one kernel thread

Đây là model mà hầu hết các ngôn ngữ như Java, C++, Rust sử dụng (thông qua pthreads hoặc tương đương).

M:N Model (Many-to-Many)

M user-space threads được map lên N kernel threads (M >= N).

M:N multithreading model - many user threads mapped to many kernel threads

Go sử dụng M:N model, với goroutines (G) là user-space threads và OS threads (M) là kernel threads. Go runtime chịu trách nhiệm schedule goroutines lên OS threads một cách hiệu quả.

Primitive Scheduler (trước Go 1.1)

Phiên bản đầu tiên của Go scheduler rất đơn giản — chỉ có hai entities:

Tất cả goroutines được đặt trong một global run queue duy nhất, được bảo vệ bởi một mutex lock. Mọi M muốn lấy G để chạy đều phải acquire lock này.

Primitive Go scheduler with global run queue and mutex lock
text
+-------+  +-------+  +-------+
|  M0   |  |  M1   |  |  M2   |
+---+---+  +---+---+  +---+---+
    |          |          |    
    v          v          v    
    +---------------------+    
    |  Global Run Queue   |    
    | (protected by mutex)|    
    |                     |    
    | [G1][G2][G3]...[Gn] |    
    +---------------------+    

Vấn đề của Primitive Scheduler

Năm 2012, Dmitry Vyukov (kỹ sư tại Google) đã phân tích và chỉ ra ba vấn đề nghiêm trọng:

1. Lock Contention (Tranh chấp khóa)

Mọi thao tác trên run queue — push, pop, hoặc thậm chí kiểm tra queue — đều cần acquire global mutex. Khi số lượng M tăng lên, các threads liên tục phải chờ nhau, tạo ra bottleneck nghiêm trọng.

2. Poor Locality (Locality kém)

Goroutines thường xuyên bị chuyển qua lại giữa các threads. Một goroutine được tạo trên M0 có thể chạy trên M1, rồi resume trên M2. Điều này phá hủy cache locality — data mà goroutine cần có thể đã nằm trong L1/L2 cache của thread cũ nhưng không có trong cache của thread mới.

3. Memory Waste (Lãng phí bộ nhớ)

Mỗi M có một mcache (memory cache cho allocation, có thể lên đến 2 MB). Vấn đề là mcache gắn với M, kể cả khi M đang bị block trong syscall và không làm gì cả. Trong thực tế, tỉ lệ active threads so với total threads có thể chỉ là 1:100 — 99 threads đang block nhưng vẫn giữ mcache.

Proposal 1: Local Run Queue

Ý tưởng đầu tiên là cho mỗi M một local run queue riêng:

Proposal 1 - each thread M has its own local run queue

Cách hoạt động:

Proposal này giải quyết được lock contention (mỗi M có queue riêng) và locality (goroutine có xu hướng chạy trên cùng M đã tạo ra nó). Tuy nhiên, nó không giải quyết memory waste — mcache vẫn gắn với M, và work stealing trở nên tốn kém khi có nhiều M bị block (phải scan qua nhiều idle threads).

Proposal 2: Logical Processor (P) — GMP Model

Giải pháp cuối cùng là giới thiệu entity thứ ba — P (Processor):

Proposal 2 - introducing P (Processor) entity forming the GMP model

P (Processor): Là logical processor trong GMP model. P giữ local run queue và mcache. Số lượng P được set bằng GOMAXPROCS (mặc định bằng số CPU cores). M phải acquire một P trước khi có thể thực thi goroutines.

Thay đổi cốt lõi:

text
Before (Primitive):           After (GMP):

  M0   M1   M2                M0--P0   M1--P1
  |    |    |                 |        |
  v    v    v                 v        v
  [Global Queue]              [LRQ0]   [LRQ1]
                                  \     /
                               [Global Queue]

Thiết kế GMP model giải quyết cả ba vấn đề:

Đây chính là nền tảng của Go scheduler hiện đại, được merge vào Go 1.1 (2013) và tiếp tục được cải tiến đến ngày nay.

GMP Model

Go scheduler hoạt động dựa trên mô hình GMP — ba thành phần cốt lõi phối hợp với nhau để quản lý hàng nghìn goroutine trên một số lượng hạn chế OS thread.

Goroutine (G): Là đơn vị thực thi nhẹ trong Go, được biểu diễn bởi g struct chứa metadata, execution state, stack (khởi tạo 2KB, tự động grow khi cần), và program counter. Khi goroutine hoàn thành, nó được recycle vào free list thay vì bị hủy — chi phí tạo mới thấp hơn nhiều so với OS thread.

Thread/Machine (M): Là OS kernel thread thực sự do hệ điều hành quản lý. Mỗi M có một goroutine đặc biệt gọi là g0 chạy trên system stack (do kernel cấp), dùng để thực thi scheduler code và runtime code. Khi cần schedule goroutine mới, M chuyển sang g0 để thực hiện.

Processor (P): Là logical processor, số lượng được xác định bởi GOMAXPROCS. Mỗi P sở hữu một local run queue gồm runnext (1 slot ưu tiên cao nhất) và runq (circular queue). Ngoài ra, P còn chứa mcache để memory allocation và quản lý timers thông qua min-heap.

Goroutine States

Goroutine state machine showing transitions between Idle, Runnable, Running, Syscall, Waiting, and Dead states

Goroutine có 6 trạng thái chính:

StateGiá trịMô tả
Idle_GidleGoroutine vừa được allocate, chưa được khởi tạo
Runnable_GrunnableĐã sẵn sàng chạy, nằm trong run queue, chờ được gán cho M
Running_GrunningĐang thực thi trên một M, có P gắn kèm
Syscall_GsyscallĐang thực hiện system call, không sử dụng stack
Waiting_GwaitingĐang bị block bởi runtime (channel, mutex, sleep, I/O)
Dead_GdeadĐã hoàn thành hoặc vừa được khởi tạo, sẵn sàng recycle

Thread States

Thread state machine showing transitions between Running, Syscall, Spinning, and Sleep states

Thread (M) có 4 trạng thái:

StateMô tả
RunningĐang thực thi Go code hoặc runtime code, có P gắn kèm
SyscallĐang bị block trong system call, P có thể bị tách ra (handoff)
SpinningĐang tìm kiếm goroutine để steal từ P khác. Số lượng spinning thread bị giới hạn: chỉ cho phép khi spinning threads < một nửa số busy processors — tránh lãng phí CPU
SleepKhông có việc làm, nằm trong idle list chờ được đánh thức

Processor States

Processor state machine showing transitions between Idle, Running, Syscall, GCStop, and Dead states

Processor (P) có 5 trạng thái:

StateMô tả
IdleKhông gắn với M nào, nằm trong idle P list
RunningĐang gắn với một M và thực thi goroutine
SyscallM đang trong system call, P tạm thời không được sử dụng và có thể bị steal bởi M khác
GCStopBị dừng bởi garbage collector trong STW (Stop-The-World) phase
DeadKhông còn được sử dụng (khi GOMAXPROCS giảm dynamically)

Program Bootstrap

Khi một chương trình Go khởi động, runtime thực hiện một chuỗi các bước khởi tạo trước khi user code được chạy:

  1. Thread M0 và Goroutine G0 được tạo: M0 là main thread — thread đầu tiên của process. G0 là goroutine đặc biệt gắn với M0, chạy trên system stack. TLS (Thread-Local Storage) được setup để M0 có thể truy cập goroutine hiện tại qua getg().

  2. procresize khởi tạo Processors: Hàm procresize tạo ra đúng GOMAXPROCS processor, tất cả bắt đầu ở trạng thái Idle. P0 (processor đầu tiên) được gắn ngay với M0.

  3. Main goroutine thực thi runtime.main: Runtime tạo main goroutine và đặt vào run queue. Goroutine này thực thi runtime.main, trong đó spawn sysmon trên một dedicated thread riêng — sysmon không cần P để hoạt động.

  4. runtime.main gọi main.main: Đây là điểm bắt đầu thực sự của user code. Từ đây, chương trình của bạn bắt đầu chạy.

Sysmon

Sysmon là background daemon thread chạy song song với toàn bộ chương trình, đảm nhiệm các tác vụ giám sát quan trọng:

Program bootstrap sequence showing M0, G0, processor initialization, and sysmon creation

Tạo Goroutine

Khi bạn viết go func(), compiler chuyển đổi thành lời gọi runtime.newproc. Quá trình tạo goroutine gồm 3 giai đoạn:

Khởi tạo Goroutine

Runtime ưu tiên lấy goroutine từ free list (recycle) thay vì allocate mới. Goroutine mới nhận stack 2KB và được setup để khi function hoàn thành, nó sẽ return vào goexit.

Hàm goexit được push vào đáy stack — khi goroutine's function return, goexit thực hiện cleanup: đưa G vào free list để recycle và quay lại schedule loop tìm goroutine tiếp theo.

Đưa Goroutine vào Queue

Goroutine mới được đặt vào runnext của P hiện tại — đây là slot có độ ưu tiên cao nhất, goroutine ở đây sẽ được chạy tiếp theo.

Nếu runnext đã có goroutine khác, goroutine cũ bị đẩy xuống runq (local run queue). Nếu runq đã đầy (capacity 256), một nửa số goroutine trong queue sẽ được chuyển sang global run queue để cân bằng tải.

text
+--------------------------------------------------------------+
|                        Processor (P)                         |
|                                                              |
|  runnext: [new G] <---------- go func() creates new G here   |
|                                                              |
|  runq: [G1][G2]...[G256]                                     |
|         |                                                    |
|         v (if full)                                          |
|  half -> global runq                                         |
+--------------------------------------------------------------+

Wake Up Processor

Sau khi goroutine được đưa vào queue, runtime kiểm tra xem có P nào đang idle không. Nếu có, runtime đánh thức một M (hoặc tạo mới nếu cần) để gắn với P đó — nhằm tối đa hóa concurrency.

Việc tạo goroutine mới là trigger chính để đánh thức idle thread. Đây là cơ chế đảm bảo rằng khi có work mới, hệ thống luôn cố gắng tận dụng tối đa số processor khả dụng.

Creating goroutine flow showing initialization, queue placement, and processor wake-up

Schedule Loop

Hàm schedule() là trung tâm của Go scheduler — nhiệm vụ chính là tìm và thực thi goroutine sẵn sàng chạy. Hàm này được gọi trong các trường hợp sau:

Khi schedule() tìm được một goroutine (G) phù hợp, quá trình diễn ra như sau:

  1. G chuyển trạng thái từ Runnable sang Running
  2. Thread gọi hàm gogo() (được viết bằng assembly) để restore registers và nhảy vào code của G
  3. G bắt đầu thực thi

Vòng lặp Schedule

Điểm quan trọng là schedule loop không bao giờ kết thúc — nó liên tục tìm và chạy goroutine. Khi một goroutine hoàn thành, flow diễn ra:

  1. Khi tạo goroutine, runtime đã push goexit() lên stack frame, nên khi G return, nó sẽ tự động gọi goexit()
  2. goexit() gọi goexit0() để dọn dẹp: chuyển G state sang Dead, đưa G vào free list để tái sử dụng, và hủy liên kết G-M
  3. goexit0() gọi lại schedule() — vòng lặp tiếp tục
text
                    Schedule Loop
                    =============

  +-> schedule()
  |       |
  |       v
  |   findRunnable() ---> select a G
  |       |
  |       v
  |   execute(G)
  |       |
  |       v
  |   gogo() [assembly] ---> restore registers
  |       |
  |       v
  |   [G runs user code]
  |       |
  |       v
  |   goexit() ---> auto-called when G returns
  |       |
  |       v
  |   goexit0()
  |       |  - G state -> Dead
  |       |  - Put G in free list
  |       |  - Drop G-M association
  |       |
  +-------+
  
  Alternative path (syscall):
  
  [G runs] -> entersyscall() -> [blocked in kernel]
                                       |
                                       v
                                exitsyscall()
                                       |
                                       v
                                  schedule()

Tim Goroutine de chay (findRunnable)

Hàm findRunnable() thực hiện quá trình tìm kiếm goroutine theo 9 bước ưu tiên. Đây là thuật toán cốt lõi quyết định goroutine nào được chạy tiếp:

Bước 1: Kiểm tra trace reader goroutine

Nếu runtime đang thu thập execution trace (qua runtime/trace package), trace reader goroutine được ưu tiên cao nhất để đảm bảo trace data được xử lý kịp thời.

Bước 2: Kiểm tra GC worker goroutine

Nếu garbage collector đang chạy và cần worker goroutine, GC sẽ được ưu tiên. GC worker thực hiện marking phase — quét object graph để xác định live objects.

Bước 3: Lấy từ global run queue (xác suất 1/61)

Starvation prevention: Cứ mỗi 61 lần gọi schedule(), hàm sẽ kiểm tra global run queue trước. Nếu không có cơ chế này, goroutine trong global queue có thể bị "bỏ đói" vĩnh viễn vì local queue luôn được kiểm tra trước và có thể luôn có goroutine sẵn sàng.

Con số 61 là số nguyên tố — được chọn để tránh pattern lặp đều đặn và đảm bảo phân phối đều hơn.

Bước 4: Kiểm tra local run queue

Local run queue gồm hai thành phần:

runnext được kiểm tra trước, sau đó mới đến runq.

Bước 5: Kiểm tra global run queue

Nếu local queue trống, kiểm tra global run queue. Khác với bước 3 (chỉ kiểm tra theo xác suất), bước này kiểm tra vô điều kiện.

Bước 6: Kiểm tra netpoll

Kiểm tra network poller xem có goroutine nào đang chờ I/O đã sẵn sàng hay không. Nếu có socket readable/writable, goroutine tương ứng sẽ được đánh thức.

Bước 7: Work stealing từ P khác

Work stealing: Khi P hết việc, nó sẽ "ăn cắp" goroutine từ local run queue của P khác. Cơ chế này giúp cân bằng tải giữa các processor — tránh tình trạng một P quá tải trong khi P khác idle.

Quá trình steal thực hiện tối đa 4 lần thử (attempts), chọn P victim ngẫu nhiên:

Lý do runnext chỉ bị steal ở lần cuối: goroutine trong runnext được kỳ vọng chạy trên P hiện tại để tận dụng CPU cache locality. Chỉ khi thực sự không tìm được goroutine nào khác, scheduler mới steal runnext từ P khác.

Bước 8: Kiểm tra GC worker lần nữa

Kiểm tra lại GC worker một lần nữa — có thể GC đã bắt đầu trong thời gian tìm kiếm.

Bước 9: Kiểm tra global queue lần nữa (nếu M đang spinning)

Spinning thread: Thread đang trong trạng thái tích cực tìm kiếm goroutine. Spinning thread tiêu tốn CPU nhưng đổi lại giảm latency — goroutine mới có thể được chạy ngay lập tức mà không cần đánh thức thread đang ngủ.

Nếu M đang spinning, kiểm tra global queue lần cuối trước khi từ bỏ.

Lấy batch từ global queue

Ở các bước 3, 5 và 9, khi lấy goroutine từ global queue, scheduler lấy theo batch thay vì từng goroutine một:

text
batch_size = (global_queue_size / num_P) + 1

Giá trị này bị giới hạn (cap) bởi:

Trong batch, một goroutine được trả về trực tiếp để thực thi ngay. Phần còn lại được đưa vào local run queue của P hiện tại.

Khi không tìm được goroutine

Nếu sau 9 bước vẫn không tìm được goroutine nào:

Blocking trên netpoll: M chờ trên network poller cho đến khi timer gần nhất hết hạn hoặc có I/O event. Khi netpoll trả kết quả (có goroutine I/O-ready), M quay lại schedule loop.

Idle state: Nếu P không có timer nào đang chờ:

  1. P được đưa vào idle P list — danh sách các processor rảnh rỗi
  2. M đi ngủ bằng stopm(), sử dụng futex syscall ở mức kernel
  3. Trong trạng thái sleep, M không tiêu tốn CPU — kernel sẽ không schedule thread này cho đến khi nó được đánh thức
  4. Khi một thread khác tạo goroutine mới hoặc có event cần xử lý, nó sẽ đánh thức M đang ngủ

Goroutine Preemption

Preemption là cơ chế cho phép scheduler dừng một goroutine đang chạy để nhường CPU cho goroutine khác. Nếu không có preemption, một goroutine chạy vòng lặp vô hạn mà không có function call sẽ chiếm giữ P mãi mãi — tất cả goroutine khác trên P đó bị "bỏ đói" và không bao giờ được thực thi.

Preemption: Hành động scheduler dừng goroutine đang chạy (dù chưa hoàn thành) để gán P cho goroutine khác. Go sử dụng hai cơ chế: cooperative preemption (dựa vào function call) và non-cooperative preemption (dựa vào signal).

Non-cooperative Preemption

Non-cooperative preemption: Cơ chế preemption không cần sự hợp tác của goroutine. Runtime gửi signal để buộc goroutine dừng ngay lập tức, bất kể goroutine đang thực thi code gì.

sysmon là daemon goroutine chạy trên một dedicated thread riêng — thread này không cần P để hoạt động. sysmon liên tục giám sát tất cả P đang ở trạng thái Running. Khi phát hiện một goroutine sử dụng P liên tục hơn 10ms, quá trình preemption diễn ra:

  1. sysmon gửi SIGURG signal đến thread (M) đang chạy goroutine đó, sử dụng tgkill syscall
  2. Signal handler của thread được kích hoạt, chuyển quyền điều khiển sang asyncPreempt — hàm assembly lưu toàn bộ register state
  3. asyncPreempt gọi asyncPreempt2
  4. asyncPreempt2 gọi gopreempt_m: goroutine bị tách khỏi M và được đưa vào global run queue
  5. Thread quay lại schedule loop để tìm goroutine mới
Non-cooperative preemption flow: sysmon detects long-running goroutine, sends SIGURG signal to thread
Signal delivery and handler execution flow showing kernel interrupting user-space thread

Lưu ý: Vì preemption là asynchronous (signal delivery phụ thuộc vào kernel scheduling), goroutine có thể chạy vượt quá 10ms trước khi thực sự bị interrupt. Thời gian 10ms chỉ là ngưỡng trigger, không phải giới hạn cứng.

Cooperative Preemption (truoc Go 1.14)

Trước Go 1.14, Go chỉ hỗ trợ cooperative preemption — goroutine chỉ bị preempt khi nó chủ động nhường CPU bằng cách gọi runtime.Gosched(). Trong các vòng lặp chặt (tight loop), developer phải tự thêm runtime.Gosched():

go
for {
    // Compute-intensive work...
    runtime.Gosched() // Manually yield to scheduler
}

Cách tiếp cận này có nhiều hạn chế:

Cooperative Preemption (Go 1.14+)

Từ Go 1.14, compiler chèn stack guard check vào phần đầu (prologue) của mọi function. Khi goroutine gọi bất kỳ function nào, quá trình kiểm tra diễn ra:

  1. Function prologue load giá trị stackguard0 từ G struct
  2. So sánh stackguard0 với stack pointer hiện tại
  3. Nếu stackguard0 == stackPreempt (giá trị đặc biệt): nhảy sang morestack_noctxt -> newstack -> gopreempt_m

Khi sysmon phát hiện goroutine chạy quá 10ms, nó set stackguard0 = stackPreempt. Lần gọi function tiếp theo sẽ trigger preemption tự động.

Ví dụ assembly trên ARM64 cho function prologue:

text
MOVD 16(R28), R16    # Load stackguard0 from G struct
SUB $48, RSP, R17    # Calculate stack frame limit
CMP R16, R17         # Compare stackguard0 with stack pointer
BLS 96(PC)           # Branch to morestack if overflow/preempt

Giải thích:

Cooperative preemption flow: sysmon sets stackguard0, next function call triggers morestack and preemption

So sanh qua Runtime Trace

Sự khác biệt giữa hai cơ chế có thể quan sát rõ ràng qua Go runtime trace:

Runtime trace showing initial goroutine scheduling

Non-cooperative preemption: Goroutine chạy liên tục 10ms+ trước khi bị signal interrupt. Trace hiển thị các block dài trên mỗi P, cho thấy goroutine chiếm giữ P trong khoảng thời gian đáng kể trước khi yield.

Runtime trace showing non-cooperative preemption with 10ms+ goroutine blocks

Cooperative preemption: Goroutine yield tại mỗi function call boundary (ví dụ fmt.Printf). Trace hiển thị các block rất ngắn (microsecond-level), cho thấy goroutine chuyển đổi nhanh chóng.

Runtime trace showing cooperative preemption with microsecond-level context switches at function boundaries

Tai sao can ca hai co che?

Cả hai cơ chế preemption tồn tại song song vì chúng bổ sung cho nhau:

Kết hợp cả hai đảm bảo scheduler luôn có khả năng lấy lại quyền điều khiển, bất kể goroutine đang thực thi loại code nào.

Handling System Calls

System Call (syscall): Là các services do kernel cung cấp cho user-space programs — bao gồm đọc file, thiết lập network connection, allocate memory, và nhiều thao tác khác. Go standard library abstract phần lớn syscalls, nhưng hiểu cách chúng hoạt động là chìa khóa để nắm rõ Go runtime internals.

Phân loại System Call

Go runtime cung cấp hai wrappers cho system calls, phân biệt theo thời gian thực thi dự kiến:

RawSyscall: Wrapper thực hiện syscall trực tiếp mà không thông báo cho scheduler. Dùng cho các syscall nhanh, thời gian thực thi có thể dự đoán được — ví dụ getpid, gettime.

Syscall: Wrapper bọc RawSyscall với logic thông báo scheduler trước và sau khi thực hiện. Dùng cho các syscall có thời gian thực thi không thể dự đoán — ví dụ read, write, connect.

go
func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno) {
    runtime_entersyscall()
    r1, r2, err = RawSyscall6(trap, a1, a2, a3, 0, 0, 0)
    runtime_exitsyscall()
}

Cấu trúc rất rõ ràng: Syscall bọc RawSyscall với hai hàm runtime_entersyscall()runtime_exitsyscall() — thông báo cho scheduler biết goroutine đang vào và ra khỏi syscall, để scheduler có thể điều phối tài nguyên hợp lý.

Scheduling trong Syscall

Trước khi syscall (runtime_entersyscall -> reentersyscall)

Khi goroutine chuẩn bị thực hiện syscall, runtime thực hiện các bước sau:

  1. G chuyển state: Running -> Syscall
  2. Lưu execution context: Save SP (stack pointer), PC (program counter), FP (frame pointer) để khôi phục sau khi syscall hoàn thành
  3. M tách khỏi P: M detaches khỏi P hiện tại, P chuyển sang trạng thái Syscall
text
+--------------+ entersyscall +--------------+
| G: Running   | ---------->  | G: Syscall   |
| M: has P     |              | M: no P      |
| P: Running   |              | P: Syscall   |
+--------------+              +--------------+

sysmon monitoring

sysmon: Là một system goroutine chạy trên background thread riêng, liên tục monitor trạng thái của tất cả P trong hệ thống.

Khi sysmon phát hiện một P ở trạng thái Syscall quá lâu (> 10ms), nó thực hiện processor handoff — gắn P đó cho một M khác (M1) để các goroutine runnable trong local run queue của P tiếp tục được thực thi.

Lưu ý quan trọng: Đây là processor handoff, không phải preemption. M ban đầu vẫn bị block cùng G trong syscall — vì thread (M) là đơn vị thực thi của kernel, M thực hiện syscall thay mặt cho G, nên association giữa M và G được duy trì cho đến khi syscall hoàn thành.

Tại sao điều này quan trọng? P trong trạng thái Syscall không thể được sử dụng bởi M khác cho đến khi sysmon seize nó hoặc syscall hoàn thành. Nếu nhiều goroutines cùng thực hiện syscall đồng thời và tất cả P đều bị lock trong trạng thái Syscall, chương trình sẽ không thể tiến triển (no progress). Đây là lý do tại sao Dgraph hardcode GOMAXPROCS=128 — tăng số lượng P để có nhiều processor hơn cho disk I/O scheduling.

Sau khi syscall (runtime_exitsyscall)

Khi syscall hoàn thành, runtime cố gắng đưa goroutine trở lại trạng thái Running thông qua hai đường dẫn:

Fast path: Có P available — P gốc vẫn ở trạng thái Syscall (sysmon chưa seize), hoặc tìm được idle P bất kỳ. G chuyển state: Syscall -> Running, tiếp tục thực thi ngay lập tức.

Slow path: Không có P nào available. Runtime thử lấy idle P thêm một lần nữa:

text
                   exitsyscall
                       |
              +--------+--------+
              |                 |
         Fast path         Slow path
              |                 |
       P available?      Try idle P again
          Yes |              |         |
              v          Found P    No P
        G: Running          |         |
                       Schedule G   G -> global queue
                       on P         M -> stopm (sleep)

Network I/O và Netpoll

Theo Go Developer Survey, khoảng 75% use cases của Go là web services. Go được thiết kế từ đầu để giải quyết C10K problem một cách hiệu quả — xử lý hàng chục nghìn concurrent connections trên một server.

C10K Problem: Bài toán kỹ thuật kinh điển về việc làm sao một server có thể xử lý 10,000 concurrent connections. Các giải pháp truyền thống dựa trên thread-per-connection không scale được do overhead của OS threads.

HTTP Server Under the Hood

Khi bạn viết một HTTP server đơn giản với http.ListenAndServe(), Go abstract rất nhiều syscalls phía sau:

Socket system calls flow in HTTP server: socket, bind, listen, accept
Go HTTP server meme showing simplicity of Go net/http

I/O Models

Để hiểu cách Go xử lý network I/O, trước tiên cần nắm 3 I/O models cơ bản:

Blocking I/O: Thread bị suspend cho đến khi data sẵn sàng. Đơn giản nhưng cần N threads cho N connections — không scale.

Non-blocking I/O: Syscall trả về ngay lập tức với data (nếu có) hoặc error EAGAIN (nếu chưa sẵn sàng). Hiệu quả nhưng phức tạp khi phải liên tục polling.

I/O Multiplexing: Sử dụng select/poll/epoll để theo dõi nhiều file descriptors cùng lúc. Application block trên select thay vì block trên từng I/O operation riêng lẻ.

Blocking I/O model diagramNon-blocking I/O model diagramI/O multiplexing model diagram

I/O Model trong Go

Go kết hợp non-blocking I/O với I/O multiplexing để đạt hiệu suất cao nhất. Tuy nhiên, Go không sử dụng select/poll truyền thống (vì performance giảm khi số lượng file descriptors lớn). Thay vào đó, Go sử dụng các platform-specific mechanisms:

PlatformMechanism
Linuxepoll
Darwin (macOS)kqueue
WindowsIOCP (I/O Completion Ports)

Tất cả được abstract thông qua netpoll function trong runtime — cho phép Go code viết theo blocking style nhưng thực tế chạy non-blocking phía sau.

Cách Netpoll hoạt động

Step 1: Tạo epoll instance và đăng ký goroutine

Khi Go accept một TCP connection, socket được tạo với flag SOCK_NONBLOCK để đảm bảo non-blocking I/O. Quá trình đăng ký diễn ra như sau:

  1. net.netFD wraps file descriptor -> trigger epoll_create (thông qua sync.Once — chỉ tạo một epoll instance duy nhất cho toàn bộ process lifetime)
  2. runtime.pollDesc được allocate chứa scheduling metadata và G references -> gọi epoll_ctl với EPOLL_CTL_ADD để đăng ký FD vào epoll instance
  3. poll.FD quản lý read/write operations với polling support
Netpoll descriptor registration flow: netFD, pollDesc, and epoll

Lưu ý: Việc chỉ sử dụng một epoll instance duy nhất có known issues (Go issue #65064). Hiện đang có discussions về việc sử dụng multiple epoll instances hoặc chuyển sang io_uring.

Go cũng sử dụng epoll cho file I/O: gọi SetNonblock để chuyển FD sang non-blocking mode, sau đó đăng ký vào cùng epoll instance.

Step 2: Polling file descriptors

Khi goroutine thực hiện read operation:

  1. poll.FD.Read gọi read syscall
  2. Nếu trả về EAGAIN (data chưa sẵn sàng) -> poll_runtime_pollWait park goroutine (chuyển sang Waiting state)
  3. netpoll function gọi epoll_wait để monitor tối đa 128 file descriptors cùng lúc
  4. Khi data sẵn sàng, epoll_wait trả về runtime.pollDesc của các FD đã ready
  5. Runtime extract G references từ pollDesc và đưa các goroutine này trở lại runnable state

Khi nào netpoll được gọi? Trong findRunnable, runtime chỉ consult netpoll khi không tìm thấy goroutine nào trong local run queue và global run queue.

delay parameter của netpoll:

Step 3: Unregister file descriptors

Khi connection được close:

  1. poll.FD.destroy gọi poll_runtime_pollClose
  2. poll_runtime_pollClose gọi epoll_ctl với EPOLL_CTL_DEL để remove FD khỏi epoll instance

Quan trọng: Phải unregister FD khi close connection để tránh FD leakgoroutine starvation — nếu FD không được unregister, goroutine đang chờ data trên FD đó sẽ không bao giờ được wake up.

Full netpoll process diagram integrated with GMP model

Garbage Collector

Go sử dụng tracing GC với thuật toán tri-color marking. GC chạy concurrent với chương trình — giảm thiểu thời gian STW (Stop-The-World) pauses.

Tri-color Marking: Thuật toán GC chia objects thành 3 nhóm: white (chưa visited, có thể là garbage), gray (đã visited nhưng chưa scan references), và black (đã visited và đã scan tất cả references). Objects còn white sau khi marking hoàn thành sẽ bị sweep.

GC hoạt động qua 4 phases:

PhaseMô tảConcurrent?
First STWTất cả P pause tại safe pointsKhông — STW
MarkingGC goroutines chạy concurrent với regular Gs trên cùng P
Second STWFinalize marking, đảm bảo tất cả objects được scanKhông — STW
SweepingBackground memory reclamation

Trong findRunnable, runtime tìm kiếm cả regular goroutines và GC goroutines (steps 2 và 8 trong schedule loop) — đảm bảo GC work được thực hiện song song với application code.

Các Runtime Functions quan trọng

getg()

getg(): Lấy pointer đến goroutine struct (G) hiện tại. Không có Go implementation — compiler thay thế bằng instruction đọc G từ TLS hoặc dedicated register.

getg() không có source code nào implement. Compiler nhận diện function này và thay thế trực tiếp bằng machine instruction đọc G pointer từ:

G pointer được lưu vào TLS/register trong hai trường hợp:

gopark()

gopark(): Park goroutine hiện tại — chuyển từ Running sang Waiting state. Goroutine sẽ không được schedule cho đến khi được explicitly unpark (ví dụ bởi channel send, mutex unlock, hoặc timer).

Quy trình hoạt động:

  1. Set stackguard0 = stackPreempt — đánh dấu goroutine cần được preempt
  2. Chuyển execution sang g0 thông qua mcall(park_m)
  3. Drop association giữa G và M — G không còn gắn với M nào
  4. Gọi unlockf callback:
    • Return false: G được reschedule ngay lập tức (useful khi condition đã thay đổi)
    • Return true: M vào schedule loop, tìm goroutine khác để chạy
go
func gopark(unlockf func(*g, unsafe.Pointer) bool, ...) {
    mp.waitunlockf = unlockf
    releasem(mp)
    mcall(park_m)
}

startm()

startm(): Schedule một M để chạy P. Đảm bảo mọi P có work đều có M tương ứng để thực thi.

Quy trình:

  1. Nếu P parameter là nil -> lấy idle P từ global list
  2. Nếu không có idle P -> return (tất cả processors đang busy)
  3. Với P available:
    • Tìm idle M trong idle list -> wake up bằng futex
    • Nếu không có idle M -> tạo thread mới bằng clone syscall với mstart entry point

stopm()

stopm(): Đưa M vào trạng thái sleep — thêm M vào idle list và suspend bằng futex syscall. M không tiêu tốn CPU khi sleeping.

stopm không return cho đến khi M được đánh thức — thường xảy ra khi có goroutine mới được tạo (qua newproc) hoặc khi có P cần M để chạy.

handoffp()

handoffp(): Transfer P từ M đang bị block sang M khác (M1). Đảm bảo P không bị idle khi vẫn còn work cần thực hiện.

handoffp kiểm tra các conditions sau để quyết định có cần transfer P hay không:

Nếu không có condition nào thỏa mãn — P được trả về idle list.

GOMAXPROCS

GOMAXPROCS: Số lượng P (logical processors) tối đa mà Go runtime sử dụng. Xác định bao nhiêu goroutines có thể chạy thực sự parallel.

runtime.GOMAXPROCS(n) set số lượng P. Mặc định bằng runtime.NumCPU() — query số CPU cores từ OS.

Vấn đề trong containers: Mặc định, runtime.NumCPU() query từ OS và trả về tổng số CPU cores của host machine, không phải cgroup limits. Điều này có thể gây ra vấn đề trong containerized environments. Hiện có ongoing proposal để Go tự động respect cgroup CPU limits.

"This call will go away when the scheduler improves." — Go documentation

Dgraph hardcode GOMAXPROCS=128 để có nhiều P hơn cho I/O scheduling — cho phép nhiều goroutines thực hiện disk I/O đồng thời mà không bị block lẫn nhau.

Goexit

runtime.Goexit(): Gracefully terminate goroutine hiện tại. Tất cả deferred functions được thực thi trước khi goroutine kết thúc. Các goroutines khác tiếp tục chạy bình thường.

Đặc điểm quan trọng:

Tổng kết

Go Scheduler cho phép lightweight concurrency thông qua mô hình GMP:

Các cơ chế chính:

Cơ chếMô tả
PreemptionNon-cooperative (SIGURG signal) + cooperative (stack guard check)
Work StealingIdle threads steal goroutines từ local run queue của P khác
Netpollepoll-based I/O multiplexing, cho phép blocking-style code chạy non-blocking
Syscall HandlingProcessor handoff giữ P busy trong khi M bị block trong syscall
GC IntegrationConcurrent marking với minimal STW pauses

Sự kết hợp của các cơ chế này cho phép Go xử lý hàng triệu goroutines hiệu quả, với overhead thấp và latency ổn định — đặc biệt phù hợp cho web services, microservices, và distributed systems.

References

Bài viết liên quan