Khi mới viết Go, mình từng hình dung mỗi lệnh go tạo ra một OS thread. Cách nghĩ đó trộn hai thứ: công việc mình muốn chạy song song, và tài nguyên mà hệ điều hành cấp để chạy nó. Goroutine và OS thread đều liên quan đến chuyện nhiều việc cùng lúc, nhưng chúng sống ở hai tầng khác nhau. Tách được hai tầng này thì mình đỡ ngạc nhiên khi một chương trình tạo hàng nghìn goroutine mà máy không hết RAM ngay.

Bài này đi từ định nghĩa, qua một ví dụ ngắn, rồi tới bảng so sánh. Mình cố giữ ở mức đủ dùng khi đọc code, không đi vào từng công thức của scheduler. Nếu bạn muốn bản gốc từ nhóm Go, Effective Go vẫn là chỗ nên đọc song song.

OS thread là gìLiên kết tới mục này

OS thread là một luồng thực thi mà kernel nhìn thấy. Hệ điều hành cấp cho nó stack, đưa nó vào lịch của CPU, và chuyển ngữ cảnh khi thread phải chờ. Tạo thread không phải một phép gán trong chương trình. Đó là một yêu cầu xuống kernel, kèm bộ nhớ stack thường tính bằng megabyte và một khoản chi phí để hệ điều hành theo dõi thread đó.

Vì kernel phải quản lý, số thread “thoải mái” của một process thường dừng ở hàng trăm hoặc vài nghìn, tuỳ máy và tuỳ việc mỗi thread đang làm. Mỗi thread ngủ vẫn chiếm stack. Mỗi lần chuyển từ thread này sang thread kia có thể kéo theo một chuyến đi qua kernel. Với web server, mô hình một request một thread vẫn chạy được, nhưng nó đắt khi số kết nối tăng nhanh hơn số lõi CPU.

Goroutine là gìLiên kết tới mục này

Một goroutine là một hàm đang chạy dưới sự quản lý của Go runtime. Bạn tạo nó bằng go someFunc(). Runtime cấp một stack nhỏ, ban đầu chỉ vài kilobyte, rồi nới stack nếu hàm cần sâu hơn. Goroutine không tự đi xin kernel một thread riêng. Nó được xếp vào hàng đợi và một tập OS thread của process sẽ lần lượt chạy các goroutine đó.

Điểm đáng nhớ là lời hứa của goroutine rất hẹp: đây là một đơn vị công việc đồng thời, rẻ để tạo và rẻ để tạm dừng. Nó không hứa rằng mỗi goroutine có một lõi CPU. Nó cũng không hứa các goroutine chạy theo thứ tự bạn viết trong source. Nếu hai goroutine không đồng bộ với nhau, thứ tự in ra màn hình có thể đổi giữa các lần chạy.

Một ví dụ nhỏLiên kết tới mục này

Đoạn dưới tạo ba goroutine đọc chung một channel. Mình cố tình giữ ví dụ ngắn để nhìn chỗ lập lịch, không phải để dùng nguyên mẫu này trong production. Chưa có timeout, chưa có cách dừng theo context, và số worker bị viết cứng.

worker.goGo
func main() {
    jobs := make(chan int)
    var wg sync.WaitGroup

    for worker := 0; worker < 3; worker++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                fmt.Println("job", job)
            }
        }()
    }

    for n := 1; n <= 5; n++ {
        jobs <- n
    }
    close(jobs)
    wg.Wait()
}

Có năm job và ba goroutine, nhưng process không cần ba OS thread chỉ vì mình viết số ba. Runtime có thể chạy cả ba goroutine trên ít thread hơn, rồi nhường chỗ khi một goroutine đang chờ nhận từ channel. close(jobs) báo cho vòng range kết thúc. WaitGroup giữ main lại cho đến khi mọi worker trả Done. Nếu quên close, các worker đứng ở range mãi và Wait không bao giờ qua.

Sơ đồ khái niệm: nhiều thanh nhỏ đại diện goroutine được gom về hai thanh lớn đại diện OS thread.
Minh hoạ khái niệm: nhiều goroutine được runtime xếp lên ít OS thread hơn. Đây không phải trace của một chương trình thật.

Khác nhau ở chỗ nàoLiên kết tới mục này

Bảng dưới là cách mình nhớ khi đọc code, không phải benchmark trên một máy cụ thể. Các mức bộ nhớ là độ lớn thường gặp trong tài liệu và trong thực hành, đủ để so thứ tự, không đủ để ghi vào một báo cáo năng lực.

So sánh nhanh để nhớ tầng nào đang trả chi phí. Số liệu là độ lớn thường gặp, không phải kết quả đo của bài viết.
OS threadGoroutine
Ai tạoKernel, qua syscallGo runtime, qua từ khoá go
Stack ban đầuThường cỡ megabyteVài kilobyte, nới khi cần
Ai lập lịchHệ điều hànhGo scheduler
Chi phí chuyểnCó thể đi qua kernelPhần nhiều ở user space
Số lượng thực tếHàng trăm tới vài nghìn đã đáng kểHàng chục nghìn vẫn là việc bình thường

Một hệ quả hay gặp: chương trình có thể có hàng nghìn goroutine nhưng chỉ một số OS thread bằng số lõi, cộng thêm vài thread mà runtime giữ cho việc hệ thống. Khi bạn nhìn số thread trong công cụ của hệ điều hành, con số đó không phải số goroutine. Ngược lại, một goroutine bị khoá trên một syscall đồng bộ có thể giữ một OS thread lâu hơn bạn nghĩ. Runtime tạo thêm thread để các goroutine khác vẫn chạy, và đây là lúc mô hình “goroutine rẻ vô hạn” bắt đầu cần điều kiện.

Khi nào sự khác nhau này quan trọngLiên kết tới mục này

  • Khi bạn tạo goroutine theo từng request mà không giới hạn. Rẻ không có nghĩa là miễn phí. Mỗi goroutine vẫn giữ stack, biến và có thể giữ kết nối.
  • Khi một thư viện gọi syscall chặn bên trong goroutine. Phần đó chiếm OS thread cho đến khi syscall trả về.
  • Khi bạn cần thứ tự, timeout hoặc huỷ việc. Goroutine không mang sẵn những thứ đó. Channel, context.Context và một chỗ chọn số worker mới là phần thiết kế.
  • Khi đo. Số goroutine tăng mà độ trễ không giảm thường nghĩa là nghẽn nằm ở database, khoá, hoặc mạng, không phải ở chỗ “chưa đủ thread”.

Scheduler làm gì, ở mức đủ dùngLiên kết tới mục này

Go runtime giữ một mô hình thường được nhắc là M:N. Nhiều goroutine được ghép lên ít OS thread hơn. Scheduler theo dõi goroutine nào đang chạy, goroutine nào đang chờ channel, và thread nào đang rảnh. Khi một goroutine chờ, runtime có thể chạy goroutine khác trên cùng thread thay vì để thread ngủ.

Chi tiết các hàng đợi cục bộ, việc cướp việc từ lõi khác, và cách syscall được xử lý nằm ngoài bài này. Điều mình cần khi review là một câu hỏi thực tế: goroutine này có đường thoát không, và khi nó chờ thì nó chờ trên cái gì. Nếu câu trả lời là “chờ một lock toàn cục” thì thêm goroutine chỉ làm hàng chờ dài hơn.

Một OS thread là tài nguyên của kernel. Một goroutine là công việc mà runtime Go xếp lên những thread đó.

Mang vào lúc đọc codeLiên kết tới mục này

Lần sau thấy một vòng tạo goroutine, mình tách câu hỏi thành hai. Câu thứ nhất: công việc này có cần chạy đồng thời không, và lấy kết quả bằng cách nào. Câu thứ hai: số lượng việc đồng thời có bị chặn bởi một tài nguyên hẹp hơn, như pool kết nối database, hay không. Goroutine trả lời câu thứ nhất với chi phí thấp. Nó không trả lời câu thứ hai.

Nếu hai câu đó rõ, việc chọn giữa một goroutine, một worker pool, hay chỉ chạy tuần tự trở nên bình thường. Tuần tự không phải là thất bại. Với một việc nhanh và một tài nguyên đã nghẽn, tuần tự còn dễ giải thích hơn một đám goroutine đang chờ cùng một khoá.