Đồng thời là lý do nhiều người chọn Go. Bài này bắt đầu chặng đó bằng con số.
Đo
for i := 0; i < 200_000; i++ {
go func() { <-cho }()
}
tạo 200.000 goroutine : 493 ms (2,47 micro giây/cái)
goroutine đang sống : 200.001
bộ nhớ tăng : 115 MB (576 byte mỗi goroutine)
sau khi xong : 1 goroutine
Hai con số đáng nhớ: 2,47 micro giây để tạo, 576 byte để giữ.
Đặt cạnh sê-ri Java trước — bài 67 đo trên cùng máy này:
luồng nền tảng Java : 97,3 micro giây | ngăn xếp 1 MB đặt chỗ
luồng ảo Java : 31,6 micro giây | ~800 byte
goroutine Go : 2,47 micro giây | 576 byte
Goroutine rẻ hơn luồng nền tảng gần bốn mươi lần về thời gian tạo. Và Java phải chờ tới bản 21 (2023) mới có thứ tương đương, trong khi Go có từ ngày đầu.
Vì sao rẻ
Goroutine không phải luồng của hệ điều hành. Bộ chạy Go dùng mô hình M:N: nhiều goroutine chạy trên ít luồng hệ thống.
NumCPU=16 GOMAXPROCS=16
GOMAXPROCS là số goroutine chạy song song thật cùng lúc, mặc định bằng số nhân. Hai trăm nghìn goroutine kia chia nhau mười sáu luồng.
Ngăn xếp là chỗ tiết kiệm chính: goroutine bắt đầu với vài trăm byte và lớn dần khi cần, thay vì đặt chỗ 1MB như luồng hệ thống. Khi ngăn xếp đầy, Go cấp vùng lớn hơn và chép sang — bạn không thấy gì cả.
Bộ lập lịch cũng có tính năng đáng biết: khi một goroutine chặn ở I/O hoặc time.Sleep, luồng mang nó đi làm việc khác ngay. Đây chính là cơ chế mà luồng ảo Java mô phỏng lại, và bài 79 sê-ri Java đã cho thấy Java 21 vẫn còn chỗ bị ghim mà Go không có.
Hệ quả cho thiết kế
Đây mới là phần quan trọng, không phải con số.
Không cần pool goroutine. Ở Java, ExecutorService tồn tại vì luồng đắt. Trong Go, tạo một goroutine cho mỗi request, mỗi kết nối, mỗi việc — và vứt đi khi xong. net/http làm đúng vậy: mỗi request một goroutine.
Mô hình "một goroutine cho mỗi việc" là mặc định. Mã đọc từ trên xuống, không callback, không CompletableFuture. Đây là thứ Go bán, và con số 2,47 µs là lý do nó khả thi.
Nhưng vẫn cần giới hạn. Goroutine rẻ, còn thứ nó dùng thì không: kết nối cơ sở dữ liệu, file descriptor, bộ nhớ cho dữ liệu đang xử lý. Mười nghìn goroutine cùng truy vấn CSDL là mười nghìn kết nối được xin — và bài 66 sê-ri Java đã cho thấy chuyện gì xảy ra.
Giới hạn bằng semaphore (channel có đệm) hoặc errgroup.SetLimit — bài 41.
Bắt đầu và kết thúc
go lam() // gọi hàm
go func() { ... }() // hàm ẩn danh, nhớ dấu () cuối
Không có Thread.start(), không có đối tượng để giữ. Và quan trọng: không có cách nào tham chiếu tới goroutine đã tạo. Không có join(), không có ID, không có kill().
Muốn chờ thì dùng sync.WaitGroup — bài 35. Muốn dừng thì dùng context — bài 29 đã nói.
Đây là quyết định thiết kế: Go buộc bạn phối hợp qua channel hoặc context, thay vì cầm tay điều khiển từng goroutine.
main kết thúc là tất cả chết
func main() {
go fmt.Println("có thể không bao giờ in ra")
}
Khi main trả về, chương trình thoát ngay — mọi goroutine đang chạy bị cắt ngang, defer của chúng không chạy.
Đây là lỗi người mới hay mắc: chạy chương trình thấy không in gì, tưởng goroutine hỏng.
Trong dịch vụ thật, đây là lý do cần graceful shutdown — bài 58.
Panic trong goroutine giết cả chương trình
Bài 25 đã nhắc, và nó đáng nhắc lại vì hậu quả nghiêm trọng:
go func() {
panic("nổ") // giết TOÀN BỘ chương trình
}()
recover ở goroutine cha không bắt được. Mỗi goroutine phải tự lo:
go func() {
defer func() {
if r := recover(); r != nil { log.Printf("panic: %v", r) }
}()
lam()
}()
Với goroutine chạy mã bạn không kiểm soát hoàn toàn, mẫu này là bắt buộc.
Đếm goroutine để phát hiện rò rỉ
fmt.Println(runtime.NumGoroutine())
Trong phép đo, con số về đúng 1 sau khi mọi goroutine xong. Nếu trong dịch vụ của bạn nó tăng đều theo thời gian, bạn có rò rỉ goroutine — bài 38 sẽ nói cách tìm.
Đây là chỉ số đáng đưa lên bảng giám sát, và nó rẻ tới mức không có lý do gì để không có.
Thử ba mươi giây
func main() {
go fmt.Println("A")
fmt.Println("B")
}
Chạy vài lần. Có lần in B, có lần in B A, gần như không bao giờ in A B. Ba mươi giây đó cho thấy hai điều: goroutine không chạy ngay lập tức, và main không chờ ai cả.
Ngày mai: channel — cách goroutine nói chuyện với nhau.