Ai cũng biết "goroutine rẻ hơn thread", nhưng câu đó thường dừng ở kích thước stack (bài trước đã đo: 2KB so với 8MB). Có một chiều thứ hai quan trọng không kém: chi phí chuyển ngữ cảnh — mỗi lần trao quyền chạy từ đơn vị này sang đơn vị kia tốn bao nhiêu. Bài này đo trực tiếp trong container Go 1.23, so chuyển giữa hai goroutine với chuyển giữa hai OS thread, và con số chênh nhau tới 130 lần.

Hai kiểu chuyển ngữ cảnh

"Chuyển ngữ cảnh" (context switch) là hành động dừng đơn vị đang chạy, lưu trạng thái của nó (thanh ghi, con trỏ stack), rồi khôi phục và chạy tiếp một đơn vị khác. Điểm mấu chốt là ai làm việc đó và ở đâu:

  • Chuyển goroutine: do bộ lập lịch của Go làm, hoàn toàn trong user-space. Nó chỉ lưu vài thanh ghi cần thiết rồi đổi sang stack của goroutine khác. Không gọi vào kernel.
  • Chuyển OS thread: do bộ lập lịch của kernel làm. Phải đi qua syscall (trap vào kernel), kernel chọn thread kế, khôi phục toàn bộ trạng thái, và thường làm mất hiệu lực một phần TLB/cache. Đắt hơn nhiều bậc.

Ảnh chụp đoạn mã Go nền tối minh hoạ chuyển ngữ cảnh goroutine vs OS thread với GOMAXPROCS bằng 1, hàm goroutinePingPong dùng hai channel không đệm ping và pong hai goroutine trao qua lại mỗi vòng hai lần chuyển do bộ lập lịch Go ở user-space, hàm threadPingPong tạo hai pipe a2b và b2a hai goroutine gọi runtime LockOSThread ghim vào OS thread riêng ping-pong qua syscall Read và Write mỗi write đánh thức thread kia mỗi read block nên kernel chuyển ngữ cảnh giữa hai OS thread, khác biệt bản chất chuyển goroutine chỉ lưu vài thanh ghi rồi nhảy sang stack khác ở lại user-space không gọi kernel chuyển OS thread phải qua syscall bộ lập lịch kernel làm mới TLB cache đắt hơn hàng trăm lần

Hình 1: Hai cách trao quyền chạy. Channel dùng bộ lập lịch Go (user-space); pipe + LockOSThread buộc kernel chuyển giữa hai OS thread thật.

Đo thật: ping-pong 1 triệu vòng

Cách đo công bằng nhất là cho hai đơn vị "ném bóng" qua lại đúng một triệu lần và bấm giờ. Với goroutine, ta dùng hai channel không đệm; với thread, ta khoá mỗi goroutine vào một OS thread riêng (runtime.LockOSThread) và cho chúng ping-pong qua pipe bằng syscall.

func goroutinePingPong(rounds int) time.Duration {
    ping, pong := make(chan struct{}), make(chan struct{})
    go func() { for i := 0; i < rounds; i++ { <-ping; pong <- struct{}{} } }()
    start := time.Now()
    for i := 0; i < rounds; i++ { ping <- struct{}{}; <-pong }  // mỗi vòng 2 lần chuyển
    return time.Since(start)
}

Đặt GOMAXPROCS(1) để cả hai goroutine buộc phải luân phiên trên cùng một P — mỗi lần gửi/nhận là một lần bộ lập lịch thực sự chuyển ngữ cảnh. Kết quả:

Ảnh chụp bảng kết quả đo thật nền tối trong container Go 1.23 linux arm64 GOMAXPROCS bằng 1 về chi phí chuyển ngữ cảnh, ping-pong 1000000 vòng mỗi vòng bằng 2 lần chuyển ngữ cảnh, chuyển goroutine qua channel tổng 164.26ms bằng 164.3 ns mỗi vòng, chuyển thread qua pipe cộng lock tổng 21.465 giây bằng 21465.3 ns mỗi vòng, tỷ lệ thread trên goroutine bằng 130.7 lần, đối chứng bằng benchmark chuẩn go test bench BenchmarkGoroutineSwitch 16162296 lần 147.9 ns mỗi op 0 B mỗi op 0 allocs mỗi op khớp phép đo tay khoảng 150 tới 164 ns mỗi vòng và không cấp phát heap, cốt lõi chuyển giữa hai goroutine tốn khoảng 150 ns và 0 cấp phát vì ở lại user-space chuyển giữa hai OS thread qua kernel tốn khoảng 21 micro giây đắt hơn khoảng 130 lần

Hình 2: Chuyển goroutine 164 ns/vòng; chuyển thread 21465 ns/vòng — chênh 130,7 lần. Benchmark chuẩn xác nhận 147,9 ns/op và 0 cấp phát.

Con số rõ ràng: 164,3 ns mỗi vòng cho goroutine, 21.465 ns mỗi vòng cho OS thread — thread đắt hơn 130,7 lần. Vì mỗi vòng gồm hai lần chuyển (đi và về), chi phí một lần chuyển goroutine là khoảng 80 ns, còn một lần chuyển thread khoảng 10,7 µs.

Để chắc chắn con số goroutine không phải ngẫu nhiên, tôi chạy lại bằng benchmark chuẩn: BenchmarkGoroutineSwitch cho 147,9 ns/op và quan trọng là 0 B/op, 0 allocs/op — chuyển goroutine không hề chạm tới heap, nên không tạo áp lực cho GC. Con số khớp phép đo tay.

Vì sao thread đắt đến vậy

Phần lớn 21 µs của thread đến từ việc phải xuống kernel: mỗi syscall.Write là một trap đánh thức thread đang block trong syscall.Read, và kernel phải chạy bộ lập lịch của nó để chọn và khôi phục thread kia. Ngoài chi phí trap thuần, còn có cái giá gián tiếp: chuyển thread thường kéo theo mất mát cache và TLB vì hai thread có thể có không gian làm việc khác nhau. Goroutine tránh toàn bộ điều đó — nó ở trong cùng một tiến trình, cùng một OS thread (khi chưa cần đổi M), bộ lập lịch Go chỉ đổi con trỏ stack và vài thanh ghi.

Cần nói thẳng cho công bằng: phép đo thread ở đây bao gồm chi phí syscall pipe, vì đó là cách hiện thực một lần trao quyền qua lại giữa hai thread. Đó chính là điểm mấu chốt — không có cách nào để hai OS thread "bắt tay" mà không đi qua kernel, trong khi hai goroutine thì có (qua channel, thuần user-space).

Ứng dụng thực tế

Mô hình "goroutine cho mỗi việc + channel" là khả thi nhờ điều này. Một pipeline Go có thể có hàng chục tầng, mỗi tầng là goroutine nối nhau bằng channel, và dữ liệu chảy qua với chi phí chuyển ~150 ns mỗi bước. Nếu mỗi tầng là một OS thread giao tiếp qua pipe, chi phí đó nhân lên 130 lần và pipeline sẽ nghẹt.

Nhưng đừng lạm dụng channel cho đường siêu nóng. 150 ns vẫn là chi phí thật. Trong một vòng lặp chạy hàng trăm triệu lần, nếu mỗi lần lặp phải qua một channel thì 150 ns/lần cộng dồn thành vấn đề. Khi đó, gộp công việc theo lô (batch) để giảm số lần chuyển, hoặc dùng cấu trúc chia sẻ có khoá nhẹ, thường tốt hơn.

GOMAXPROCS thay đổi bức tranh. Phép đo này ép GOMAXPROCS=1 để buộc luân phiên trên một P. Với nhiều P, hai goroutine giao tiếp có thể chạy song song trên hai lõi và chi phí "chuyển" biến thành chi phí đồng bộ hoá qua channel giữa hai lõi — một câu chuyện khác (liên quan cache coherency). Điểm cần nhớ: con số 150 ns là chi phí chuyển ngữ cảnh trên cùng một P, không phải mọi tình huống.

Đánh đổi cần cân nhắc

Rẻ không có nghĩa là miễn phí. Chuyển goroutine rẻ hơn thread 130 lần, nhưng vẫn tốn ~80 ns mỗi lần. Thiết kế "một goroutine cho mỗi phần tử nhỏ xíu" trong vòng lặp cực nóng vẫn có thể thua một vòng lặp tuần tự đơn giản. Đo trước khi chia nhỏ.

LockOSThread có công dụng riêng, đừng dùng bừa. Trong bài này ta dùng LockOSThread để cố tình tạo chi phí thread thật cho phép đo. Trong thực tế nó dùng cho việc cần cùng một OS thread (gọi thư viện C giữ trạng thái theo thread, khởi tạo đồ hoạ, runtime.LockOSThread cho signal handling). Khoá goroutine vào thread làm mất lợi thế lập lịch nhẹ — chỉ dùng khi thật cần.

Chuyển goroutine không cấp phát, nên không gây áp lực GC. Đây là điểm cộng ẩn: 0 allocs/op nghĩa là dù bạn chuyển goroutine hàng triệu lần, GC không bị kéo vào. Đó là lý do các hàng đợi/pipeline dựa trên channel không làm phình heap.

Ba ý mang về

  1. Chuyển giữa hai goroutine tốn ~150 ns và 0 cấp phát (đo thật 164 ns/vòng tay, 147,9 ns/op benchmark) vì nó ở lại user-space — bộ lập lịch Go chỉ đổi stack và vài thanh ghi, không gọi kernel.
  2. Chuyển giữa hai OS thread tốn ~21 µs, đắt hơn 130 lần, vì bắt buộc đi qua syscall, bộ lập lịch kernel và làm mới cache/TLB — không có cách nào để hai thread bắt tay mà không xuống kernel.
  3. Đây là lý do sâu xa của mô hình đồng thời Go: hàng nghìn goroutine giao tiếp qua channel chạy mượt, nhưng 150 ns vẫn là chi phí thật — đường siêu nóng nên gộp lô để giảm số lần chuyển, và GOMAXPROCS đổi hẳn bức tranh khi có song song thật.

Phần sau ta đi vào một tham số điều khiển toàn bộ mức song song đó: Phần sau mổ xẻ GOMAXPROCS và bài toán nhận biết container — vì sao Go có thể hiểu sai số lõi khả dụng khi chạy trong Docker/Kubernetes, và cách sửa.