Hình dung một nhà hàng. Luồng hệ điều hành như một người bồi bàn: thuê thêm một người thì đắt, và mỗi người cần cả một khu phục vụ riêng dù có dùng hết hay không. Goroutine thì như kê thêm một cái bàn — gần như không tốn gì để thêm, và một nhóm bồi bàn cố định chạy qua chạy lại phục vụ tất cả. Đồng thời là lý do nhiều người chọn Go, và 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 — nhiều bàn, ít bồi bàn.

  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 — số bồi bàn thật đứng trong bếp. 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òn một nước hay: khi một goroutine chặn ở I/O hoặc time.Sleep, luồng mang nó đi làm việc khác ngay — người bồi không đứng chờ cái bàn còn đang xem thực đơn, mà quay sang phục vụ bàn kế. Đâ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ó.

Và nếu muốn thấy hai tính nết nền tảng của goroutine trong 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ả.

Mẫu số chung

Cái mẹo làm goroutine rẻ không phải của riêng Go — nó là luồng xanh (green thread), và câu chuyện dài hơn nhiều người tưởng. Java thuở đầu có green thread, rồi bỏ để lấy luồng native 1:1; Go đặt cược vào M:N từ ngày đầu; Java quay lại với luồng ảo (Loom, 2023). Tổ tiên tinh thần là tiến trình của Erlang/BEAM — hàng triệu tiến trình siêu rẻ, có từ thập niên 1980. Kotlin có coroutine, Python có asyncio, Node có một vòng lặp sự kiện duy nhất.

Bỏ qua tên gọi, mọi hệ chia theo đúng hai trục.

  • Ai lập lịch? Hệ điều hành (1:1, mỗi đơn vị là một luồng kernel — đắt) hay bộ chạy của ngôn ngữ (M:N — rẻ). Luồng kernel là đơn vị đắt ở khắp nơi, nên mọi câu chuyện "hàng triệu X" đều là một bộ chạy đem ghép X lên vài luồng kernel. Đó là toàn bộ bí mật của 576 byte.
  • Thứ "tạm dừng được" trông như thế nào? Một ngăn xếp lớn dần (goroutine, luồng ảo Java, tiến trình Erlang) — gọi là stackful; hay một máy trạng thái biên dịch sẵn, không ngăn xếp (async/await của Rust, C#, JavaScript) — gọi là stackless.

Trục thứ hai mới là chỗ quyết định cảm giác viết mã. Stackful nghĩa là mọi hàm đều chặn được và mã đọc như tuần tự — không có "hàm màu đỏ, hàm màu xanh", không phải rải async/await khắp nơi. Stackless thì nhanh và ít tốn bộ nhớ hơn nữa, nhưng bắt bạn tô màu hàm: hàm async chỉ gọi được từ hàm async khác. Go cố ý chọn stackful, và chính vì thế "một goroutine cho mỗi request, mã đọc từ trên xuống" mới là mặc định tự nhiên — đó là phần thưởng trực tiếp của lựa chọn đó.

Sợi chỉ chung đáng mang theo: khi sang một ngôn ngữ mới, hỏi đúng hai câu — "bộ chạy lập lịch hay kernel lập lịch" (đoán được concurrency có rẻ không) và "stackful hay stackless" (đoán được bạn có phải viết async/await không). Hai câu đó vẽ ra gần như toàn bộ tính cách đồng thời của một ngôn ngữ.

Ngày mai: channel — cách goroutine nói chuyện với nhau.

Bài tập làm thử

Bài 1 (đọc hiểu). Chương trình sau chạy vài lần liên tiếp. Giải thích tại sao thứ tự output không cố định, và tại sao gần như không bao giờ in ra A B.

func main() {
	go fmt.Println("A")
	fmt.Println("B")
}
Đáp án

Goroutine go fmt.Println("A") không chạy ngay lập tức khi được tạo ra — nó chỉ được lên lịch, còn main() tiếp tục chạy dòng kế tiếp (fmt.Println("B")) ngay lập tức mà không chờ goroutine kia. Vì vậy có lần in B, có lần in B A (nếu goroutine kịp chạy trước khi main kết thúc), nhưng gần như không bao giờ in A B vì dòng lệnh chính luôn thực thi fmt.Println("B") trước khi goroutine mới có cơ hội chạy. Thêm nữa, khi main kết thúc, chương trình thoát ngay và goroutine chưa kịp chạy sẽ bị cắt ngang hoàn toàn.

Bài 2 (sửa lỗi). Đoạn mã dưới đây có ý định in ra một dòng từ goroutine trước khi chương trình kết thúc, nhưng thường không in được gì cả. Sửa lại để đảm bảo goroutine chạy xong trước khi main kết thúc.

func main() {
	go fmt.Println("có thể không bao giờ in ra")
}
Đáp án
func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("giờ chắc chắn in ra")
	}()
	wg.Wait()
}

Khi main kết thúc, chương trình thoát ngay lập tức và mọi goroutine đang chạy bị cắt ngang — defer của chúng cũng không chạy. Dùng sync.WaitGroup để main chờ goroutine hoàn thành trước khi kết thúc.

Bài 3 (vận dụng thực tế). Bài viết đo được tạo một goroutine tốn 2,47 micro giây và 576 byte, trong khi một luồng nền tảng Java tốn 97,3 micro giây và ngăn xếp đặt chỗ 1MB. Dựa trên số liệu này và kết luận "hệ quả cho thiết kế" trong bài, giải thích tại sao mẫu "một goroutine cho mỗi request HTTP" là hợp lý trong Go nhưng lại cần một ExecutorService (pool luồng) khi làm tương tự bằng luồng nền tảng ở Java.

Đáp án

Vì goroutine rẻ tới mức tạo mới cho mỗi request, mỗi kết nối, mỗi việc rồi vứt đi khi xong là hoàn toàn khả thi — net/http của Go làm đúng vậy, mỗi request một goroutine. Ngược lại, luồng hệ điều hành (như luồng nền tảng Java) đắt cả về thời gian tạo (gần 40 lần so với goroutine) lẫn bộ nhớ (ngăn xếp đặt chỗ cố định 1MB mỗi luồng, so với vài trăm byte lớn dần của goroutine), nên tạo một luồng mới cho mỗi request sẽ nhanh chóng cạn tài nguyên hệ thống — buộc phải dùng ExecutorService để tái sử dụng một số lượng luồng cố định thay vì tạo mới liên tục.

Bài 4 (bẫy/đánh đổi). Một dịch vụ Go xử lý mười nghìn request đồng thời, mỗi request mở một goroutine để truy vấn cơ sở dữ liệu, không có giới hạn nào. Theo bài viết, đây có phải là thiết kế an toàn không? Nêu rủi ro và cách khắc phục.

Đáp án

Không an toàn. Dù goroutine tự nó rẻ (576 byte, 2,47µs), thứ nó dùng thì không — ở đây là kết nối cơ sở dữ liệu. Mười nghìn goroutine cùng truy vấn CSDL đồng nghĩa mười nghìn kết nối được xin cùng lúc, điều mà hầu hết CSDL và connection pool không chịu nổi. Cách khắc phục: giới hạn số goroutine chạy đồng thời bằng semaphore (channel có đệm) hoặc errgroup.SetLimit, để số kết nối CSDL đồng thời nằm trong ngưỡng an toàn.

Bài 5 (đọc hiểu — panic trong goroutine). Đoạn mã sau chạy trong một dịch vụ HTTP. Điều gì xảy ra nếu lam() panic, và cách phòng đúng là gì?

go func() {
	lam()
}()
Đáp án

Panic trong một goroutine mà không được recover sẽ giết toàn bộ chương trình — recover ở goroutine cha (kể cả main) không bắt được panic xảy ra ở một goroutine khác. Cách phòng đúng: mỗi goroutine chạy mã không hoàn toàn kiểm soát được phải tự có recover riêng:

go func() {
	defer func() {
		if r := recover(); r != nil {
			log.Printf("panic: %v", r)
		}
	}()
	lam()
}()