Câu thần chú "goroutine rẻ" ai cũng nghe, nhưng ít người đo xem rẻ tới đâu và nhờ đâu. Bí mật nằm ở stack: một OS thread trên Linux cấp stack cố định mặc định 8MB ngay lúc sinh ra, còn một goroutine bắt đầu chỉ với 2KB và tự phình khi cần. Bài này không kể lý thuyết — ta chạy code trong container Go 1.23 và đo từng con số: stack ban đầu là bao nhiêu bytes, nó lớn lên theo quy luật nào, và cái giá phải trả.

Ý tưởng: stack nhỏ, tự lớn

OS thread phải đoán trước dung lượng stack tối đa và cấp cả khối ngay từ đầu — vì thay đổi stack của thread giữa chừng là chuyện của kernel, rất đắt. Go đảo ngược: mỗi goroutine khởi đầu với một stack tí hon (2KB), và khi một lời gọi hàm phát hiện stack sắp hết chỗ, runtime chèn một đoạn kiểm tra (morestack) cấp một stack lớn gấp đôi, copy toàn bộ khung hàm cũ sang, chỉnh lại các con trỏ, rồi chạy tiếp. Vì goroutine được runtime quản lý (không phải kernel), việc này rẻ và làm được bất cứ lúc nào.

Ảnh chụp đoạn mã Go nền tối minh hoạ stack goroutine khởi đầu 2KB tự phình khi cần, đo stack ban đầu 100000 goroutine cùng sống chia StackInuse cho số goroutine ra stack mỗi goroutine, stack di dời đệ quy sâu ghi địa chỉ biến local qua các mốc địa chỉ nhảy vùng hàng MB giữa các mốc là copystack đã di dời, hàm deep nhận depth max con trỏ mảng uintptr khai var local mảng 32 byte lấy địa chỉ bằng unsafe Pointer, vì sao rẻ OS thread cấp stack cố định lớn 8MB ngay từ đầu Go làm ngược stack goroutine bắt đầu 2KB chỉ lớn khi cần qua morestack copystack nên chạy được hàng trăm nghìn goroutine chỉ tốn vài trăm MB

Hình 1: Hai phép đo — chia StackInuse cho số goroutine để lấy stack ban đầu; và ghi địa chỉ &local qua các mốc đệ quy để bắt khoảnh khắc stack di dời.

Đo thật 1: stack ban đầu đúng 2KB

Cách đo sạch nhất: sinh 100.000 goroutine, giữ tất cả cùng sống (block trên một channel), rồi đọc runtime.MemStats.StackInuse trước và sau. Chia hiệu số cho số goroutine ra stack trung bình mỗi cái.

const N = 100000
for i := 0; i < N; i++ {
    go func() { wg.Done(); <-block }()  // giữ sống để đo
}
wg.Wait()
runtime.ReadMemStats(&after)
grew := after.StackInuse - before.StackInuse
fmt.Printf("Mỗi goroutine: %d bytes\n", grew/N)

Ảnh chụp bảng kết quả đo thật nền tối trong container Go 1.23 linux arm64 về stack goroutine, phần 1 stack ban đầu 100000 goroutine cùng sống số goroutine sống 100001 StackInuse tăng 205127680 bytes bằng 195.6 MB trung bình mỗi goroutine 2051 bytes khoảng 2.0 KB đúng như thiết kế goroutine sinh ra với stack 2KB không phải 8MB, phần 2 stack di dời khi đệ quy sâu địa chỉ biến local nhảy vùng độ sâu 0 địa chỉ 0x0040000910e0 độ sâu 25000 50000 75000 đều di dời stack mới địa chỉ cách nhau hàng MB runtime cấp stack mới và copy khung sang, phần 3 StackInuse phình theo độ sâu gấp đôi mỗi lần độ sâu 0 288 KB độ sâu 15000 131360 KB độ sâu 30000 262432 KB tỉ lệ x2.00 độ sâu 60000 524576 KB x2.00 tỉ lệ x2.00 chính xác chính sách gấp đôi stack khi cần thêm

Hình 2: 100.000 goroutine tốn 195,6 MB stack — 2051 bytes mỗi cái, đúng con số 2KB thiết kế. Đệ quy sâu làm &local nhảy vùng hàng MB (di dời). Và StackInuse phình theo tỉ lệ x2.00 — gấp đôi.

Kết quả: 2051 bytes mỗi goroutine (~2.0 KB). Con số này khớp chính xác kích thước stack tối thiểu (_StackMin = 2048) mà runtime dùng khi tạo goroutine, cộng chút lề. Để so sánh: cùng 100.000 đơn vị thực thi mà là OS thread với stack 8MB thì cần 800GB địa chỉ stack — bất khả thi. Với goroutine, chỉ 195,6 MB.

Đo thật 2: stack di dời khi phình

Điều thú vị là khi stack lớn lên, nó không "nối thêm" mà được cấp mới hoàn toàn và copy sang — nghĩa là địa chỉ bộ nhớ của các biến cục bộ thay đổi. Ta chứng minh bằng cách đệ quy sâu và ghi lại địa chỉ một biến cục bộ ở vài mốc:

func deep(depth, max int, addrs *[]uintptr) {
    var local [32]byte
    a := uintptr(unsafe.Pointer(&local[0]))
    if depth%25000 == 0 { *addrs = append(*addrs, a) }
    if depth < max { deep(depth+1, max, addrs) }
}

Địa chỉ &local ở độ sâu 0, 25000, 50000, 75000 lần lượt cách nhau hàng MB (0x...910e0 → 0x...64fce0 → 0x...b428e0 → 0x...8354e0). Nếu stack chỉ nối thêm liền mạch, địa chỉ sẽ trôi đều một chiều theo bước khung; ở đây chúng nhảy sang vùng khác hẳn, vì mỗi lần phình runtime cấp một stack mới ở chỗ khác và copy toàn bộ khung sang. Đây chính là copystack trong runtime.

Điều này giải thích một quy tắc quan trọng của Go: con trỏ tới biến trên stack vẫn an toàn dù stack di dời, vì runtime biết mọi con trỏ trong stack và chỉnh chúng khi copy. Nhưng con trỏ đó không phải là một địa chỉ cố định theo thời gian — bạn không được lưu uintptr của biến stack rồi dùng lại sau, vì stack có thể đã dời đi.

Đo thật 3: quy luật gấp đôi

Đo StackInuse tại các mốc độ sâu tăng dần cho thấy quy luật cấp phát rõ ràng: 262432 KB → 524576 KB là tỉ lệ x2.00 chính xác. Runtime không phình từng chút một (sẽ tốn quá nhiều lần copy) mà nhân đôi mỗi lần cần thêm — chiến lược khấu hao giống append slice hay vector C++: số lần copy giảm về O(log n) theo tổng kích thước, nên chi phí copy trung bình cho mỗi lần gọi hàm là hằng số.

Ứng dụng thực tế

Hàng trăm nghìn goroutine là khả thi thật. Vì stack khởi đầu chỉ 2KB, một server Go có thể mở một goroutine cho mỗi kết nối/mỗi request mà không lo cạn bộ nhớ như mô hình thread-per-connection. Đây là nền tảng của mô hình đồng thời "cứ spawn goroutine" trong Go.

Đệ quy quá sâu vẫn có giới hạn. Stack goroutine có trần (mặc định 1GB, chỉnh bằng debug.SetMaxStack). Đệ quy không đáy sẽ fatal error: stack overflow khi chạm trần — tôi gặp đúng lỗi này khi thử pad khung quá lớn. Với thuật toán đệ quy sâu không kiểm soát, hãy chuyển sang vòng lặp + stack tường minh.

Lần phình stack có chi phí. Nếu một hàm nóng liên tục vượt/tụt qua ngưỡng stack (gọi sâu rồi trả về rồi lại gọi sâu), nó có thể bị copy stack lặp đi lặp lại — "stack thrashing". Hiếm, nhưng nếu pprof cho thấy thời gian bất thường ở runtime.morestack/copystack, đó là dấu hiệu. Cách giảm: tránh khung quá lớn trên đường nóng, hoặc cấu trúc lại để độ sâu ổn định hơn.

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

Rẻ khi khởi tạo, trả giá khi phình. Stack nhỏ ban đầu là thứ khiến goroutine rẻ, nhưng cái giá là mỗi lần vượt ngưỡng phải cấp mới + copy. Chiến lược gấp đôi giữ tổng chi phí copy ở mức khấu hao thấp, nhưng nó không phải bằng không — với workload biết trước cần stack lớn, không có cách "cấp sẵn" (Go cố ý không cho, để giữ mô hình đơn giản).

Đừng phụ thuộc địa chỉ stack cố định. Vì stack di dời, mọi mánh khoé giữ uintptr trỏ vào biến stack đều sai. Đây là lý do unsafe.Pointer có luật rất nghiêm ngặt về việc không được "ẩn" con trỏ dưới dạng uintptr qua các lời gọi có thể phình stack.

Stack và heap là hai câu chuyện khác nhau. Bài này đo stack; biến "thoát" (escape) lên heap lại là chuyện của escape analysis — quyết định biến nào ở lại stack (được copy/thu hồi tự do khi hàm trả về) và biến nào lên heap (do GC quản). Đó là chủ đề ta sẽ đo riêng.

Ba ý mang về

  1. Goroutine khởi đầu với stack 2KB (đo thật 2051 bytes/goroutine trên 100.000 goroutine), khác hẳn OS thread cấp cố định 8MB — đây là lý do gốc rễ khiến "spawn hàng trăm nghìn goroutine" là khả thi thật.
  2. Stack phình bằng cách gấp đôi và copy: đo thật tỉ lệ x2.00 chính xác, và địa chỉ biến cục bộ nhảy vùng hàng MB giữa các mốc đệ quy — bằng chứng runtime cấp stack mới rồi di dời toàn bộ khung (copystack).
  3. An toàn nhưng có trần và có giá: runtime chỉnh mọi con trỏ khi di dời nên con trỏ stack luôn đúng, nhưng đừng lưu uintptr của biến stack; đệ quy sâu chạm trần 1GB sẽ stack overflow, và lần phình phải trả giá copy.

Bài tập làm thử

Bài 1 (đọc hiểu). Bài viết đo stack ban đầu của goroutine bằng cách này:

const N = 100000
var before, after runtime.MemStats

runtime.ReadMemStats(&before)
var wg sync.WaitGroup
block := make(chan struct{})
wg.Add(N)
for i := 0; i < N; i++ {
    go func() {
        wg.Done()
        <-block // giữ goroutine sống để đo
    }()
}
wg.Wait()
runtime.ReadMemStats(&after)

grew := after.StackInuse - before.StackInuse
fmt.Printf("Mỗi goroutine: %d bytes\n", grew/N)

Kết quả đo thật là 2051 bytes/goroutine. Tại sao phải cho goroutine <-block (giữ sống) thay vì để nó chạy xong rồi thoát ngay? Và vì sao con số ra là 2051 chứ không phải đúng 2048?

Đáp án

Nếu goroutine chạy xong và thoát ngay, StackInuse sẽ giảm trở lại trước khi ReadMemStats(&after) kịp đọc, khiến phép đo sai lệch hoặc bằng 0 — phải giữ tất cả 100.000 goroutine cùng sống (block trên channel) để StackInuse phản ánh đúng tổng dung lượng đang cấp phát. Con số 2051 (thay vì đúng 2048 = _StackMin) là vì có thêm một chút overhead/lề cho mỗi goroutine (cấu trúc quản lý stack, căn chỉnh bộ nhớ), nhưng vẫn khớp gần đúng với kích thước tối thiểu 2KB mà runtime cấp khi tạo goroutine.

Bài 2 (sửa lỗi). Một người viết code sau để "lưu lại" địa chỉ của một biến cục bộ rồi dùng lại sau một đoạn đệ quy sâu:

func deep(depth, max int, saved *uintptr) {
    var local [32]byte
    if depth == 0 {
        *saved = uintptr(unsafe.Pointer(&local[0])) // lưu địa chỉ ban đầu
    }
    if depth < max {
        deep(depth+1, max, saved)
    }
    if depth == max {
        // sau khi đệ quy xong, dùng lại địa chỉ đã lưu để đọc dữ liệu
        p := unsafe.Pointer(*saved)
        _ = p
    }
}

Đoạn code này sai ở đâu, dựa theo cơ chế copystack mà bài viết đã đo?

Đáp án

Lỗi là lưu địa chỉ biến stack dưới dạng uintptr rồi dùng lại sau một chuỗi lời gọi có thể khiến stack phình (morestack/copystack). Bài đo cho thấy khi stack lớn lên, runtime cấp một stack mới hoàn toàn ở vùng nhớ khác và copy toàn bộ khung sang, đồng thời tự chỉnh mọi con trỏ Go đang trỏ vào stack đó — nhưng nó không biết để chỉnh một uintptr đã bị "ẩn" khỏi kiểu con trỏ. Sau khi đệ quy sâu làm stack di dời, *saved là một địa chỉ trỏ vào vùng nhớ cũ đã không còn hợp lệ (dangling), đọc lại nó là hành vi không xác định. Đây đúng là lý do unsafe.Pointer có luật nghiêm ngặt: không được chuyển con trỏ stack thành uintptr rồi lưu giữ qua các lời gọi có thể phình stack.

Bài 3 (vận dụng). Dựa trên số liệu đo được (2051 bytes/goroutine cho stack ban đầu), một server cần phục vụ 100.000 kết nối đồng thời, mỗi kết nối một goroutine. Hãy so sánh sơ bộ tổng bộ nhớ stack cần thiết với việc dùng 100.000 OS thread (stack mặc định 8MB/thread trên Linux). Model goroutine giải quyết được bài toán gì mà model thread không giải quyết được?

Đáp án

Với goroutine: 100.000 × 2051 bytes ≈ 195,6 MB (đúng số liệu bài đo). Với OS thread: 100.000 × 8MB = 800.000 MB = 800GB — vượt xa dung lượng địa chỉ/bộ nhớ khả dụng của hầu hết máy chủ, tức là bất khả thi. Model goroutine giải quyết được bài toán "một đơn vị thực thi cho mỗi kết nối/request" ở quy mô hàng trăm nghìn mà không cạn bộ nhớ, nhờ stack khởi đầu nhỏ (2KB) và chỉ phình khi thật sự cần — đây là nền tảng của mô hình đồng thời "cứ spawn goroutine" trong Go.

Bài 4 (bẫy/đánh đổi). Bài viết đề cập tác giả "gặp đúng lỗi fatal error: stack overflow khi thử pad khung quá lớn" trong lúc đệ quy sâu. Stack goroutine có trần mặc định là bao nhiêu, và nếu thuật toán của bạn cần đệ quy sâu không kiểm soát được, hướng xử lý đúng theo bài là gì?

Đáp án

Trần mặc định là 1GB (chỉnh được bằng debug.SetMaxStack). Dù stack tăng trưởng động và runtime tự lo việc cấp mới + copy, nó vẫn có giới hạn trên — đệ quy không đáy hoặc quá sâu sẽ chạm trần và chương trình dừng với fatal error: stack overflow, không phải lỗi có thể recover() được như panic thông thường. Với thuật toán đệ quy sâu không kiểm soát, bài khuyến nghị chuyển sang vòng lặp cộng với một cấu trúc stack tường minh (ví dụ slice làm stack thủ công) thay vì dựa vào đệ quy hàm.

Phần sau ta so trực tiếp chi phí một thao tác cơ bản nhất: Phần sau đo chi phí chuyển ngữ cảnh giữa goroutine so với giữa OS thread — vì sao chuyển goroutine rẻ hơn nhiều và con số thực là bao nhiêu.