Nhiều lập trình viên đến từ các ngôn ngữ GC cũ mang một nỗi sợ: "stop-the-world" — khoảnh khắc bộ thu gom rác đóng băng cả chương trình để dọn dẹp, có thể kéo dài hàng trăm mili-giây. GC của Go vẫn có STW, nhưng nó nhỏ tới mức đáng kinh ngạc. Bài này chỉ ra chính xác GC còn dừng chương trình ở đâu, đo thời gian dừng thật là bao nhiêu, và điều gì làm nó dài ra.

Hai điểm STW còn lại

GC hiện đại của Go chạy phần lớn công việc (đánh dấu, quét, thậm chí quét stack goroutine) đồng thời với chương trình. Nhưng vẫn còn hai khoảnh khắc mỗi chu kỳ mà nó phải dừng tất cả goroutine:

  1. Sweep termination (đầu chu kỳ): dừng mọi goroutine, kết thúc phần quét dở của chu kỳ trước, và bật write barrier để chuẩn bị đánh dấu.
  2. Mark termination (cuối chu kỳ): dừng mọi goroutine, chốt việc đánh dấu, tắt write barrier, và chuẩn bị pha quét.

Giữa hai điểm này, pha mark (đánh dấu) chạy đồng thời — chương trình vẫn chạy bình thường. Sau mark termination, pha sweep (thu hồi rác) cũng chạy đồng thời ở nền.

Ảnh chụp sơ đồ nền tối stop the world STW GC còn dừng chương trình ở đâu, GC hiện đại chạy phần lớn đồng thời nhưng vẫn phải dừng tất cả goroutine ở hai khoảnh khắc ngắn mỗi chu kỳ để chuyển pha an toàn, STW 1 sweep termination đầu chu kỳ dừng mọi goroutine kết thúc quét dở của chu kỳ trước bật write barrier, mark đồng thời chương trình vẫn chạy quét stack cũng đồng thời, STW 2 mark termination cuối chu kỳ dừng mọi goroutine chốt việc đánh dấu tắt write barrier chuẩn bị sweep, sweep đồng thời thu hồi rác nền không dừng, đo STW PauseNs là vòng đệm 256 mẫu thời gian dừng gần nhất var ms runtime MemStats runtime ReadMemStats p bằng ms PauseNs thời gian dừng lần GC gần nhất, trong gctrace hai số đầu và cuối phần clock chính là 2 STW gc 1 0.21 cộng 2.5 cộng 0.14 ms STW 0.21 cộng STW 0.14 mark 2.5 đồng thời, vì sao vẫn cần STW chuyển pha bật tắt write barrier chốt trạng thái đánh dấu đòi hỏi một khoảnh khắc mọi goroutine đồng ý về trạng thái GC

Hình 1: Hai điểm STW mỗi chu kỳ — sweep termination (đầu) và mark termination (cuối). Giữa chúng, mark chạy đồng thời; sau đó sweep cũng đồng thời.

Vì sao vẫn cần STW

Câu hỏi tự nhiên: nếu Go đã chạy đồng thời được gần hết, sao không bỏ luôn STW? Vì chuyển pha cần một khoảnh khắc mọi goroutine đồng ý về trạng thái GC. Bật/tắt write barrier (bài trước) không thể làm giữa lúc các goroutine đang tự do sửa con trỏ — nếu một goroutine thấy barrier bật còn goroutine khác thấy tắt, bất biến tricolor sẽ vỡ. STW tạo một điểm đồng bộ ngắn để mọi goroutine cùng chuyển trạng thái an toàn. Go tối thiểu hoá STW bằng cách đẩy gần hết việc (mark, sweep, quét stack) ra ngoài STW.

Đo thật: STW theo số goroutine

Câu hỏi thực dụng: STW có phình lên khi có nhiều goroutine không? (Historically, quét stack tất cả goroutine từng là thủ phạm làm STW dài.) Đo với số goroutine sống khác nhau, đọc runtime.MemStats.PauseNs:

Ảnh chụp bảng kết quả đo thật nền tối STW Go 1.23 arm64 10 P, STW có tăng theo số goroutine sống không, goroutine 100 STW trung bình 38.9 us STW max 112.8 us, 10000 56.1 us 226.1 us, 100000 160.7 us 315.2 us, 500000 136.1 us 229.3 us, STW tăng nhẹ khi nhiều goroutine 39 tới 161 us nhưng luôn dưới 1 ms kể cả với nửa triệu goroutine quét stack phần lớn đã đồng thời, gctrace với 100000 goroutine thấy 2 điểm STW gc 1 0.21 cộng 2.5 cộng 0.14 ms clock 1 MB stacks gc 2 0.12 cộng 5.6 cộng 0.32 ms clock 4 MB stacks số đầu sweep term cộng số cuối mark term bằng 2 lần STW mỗi chu kỳ số giữa mark chạy đồng thời không dừng

Hình 2: STW trung bình tăng nhẹ từ 38,9 us (100 goroutine) lên 160,7 us (100k), nhưng luôn dưới 1 ms kể cả nửa triệu goroutine. gctrace cho thấy hai điểm STW (số đầu + số cuối phần clock).

Kết quả đo:

  • 100 goroutine: STW trung bình 38,9 µs, max 112,8 µs.
  • 10.000: trung bình 56,1 µs, max 226 µs.
  • 100.000: trung bình 160,7 µs, max 315 µs.
  • 500.000: trung bình 136 µs, max 229 µs.

STW có tăng theo số goroutine — từ ~39 µs (100 goroutine) lên ~161 µs (100k) — nhưng luôn dưới 1 mili-giây, kể cả với nửa triệu goroutine. Đây là thành tựu lớn: các GC cũ dừng hàng trăm mili-giây và tệ hơn khi heap/số thread lớn; Go giữ STW ở mức micro-giây gần như hằng số nhờ đẩy việc quét stack (phần từng làm STW dài) ra chạy đồng thời.

Nhìn gctrace với 100k goroutine: gc 1: 0.21+2.5+0.14 ms clock — số đầu (0,21 ms, sweep termination) và số cuối (0,14 ms, mark termination) là hai lần STW; số giữa (2,5 ms, mark) chạy đồng thời không dừng.

Ứng dụng thực tế

Go phù hợp cho dịch vụ độ trễ đuôi (tail latency) nghiêm ngặt. Vì STW dưới 1 ms kể cả ở quy mô lớn, một API server Go không bị "khựng" gây spike độ trễ p99/p999 như hệ thống GC stop-the-world cũ. Đây là lý do Go được chọn cho hạ tầng cần độ trễ ổn định.

Nhiều goroutine làm STW dài hơn một chút — cân nhắc ở quy mô cực lớn. Nếu bạn có hàng trăm nghìn goroutine sống và độ trễ đuôi cực kỳ quan trọng, ~160 µs STW có thể đáng chú ý. Giảm số goroutine sống đồng thời (worker pool giới hạn) giữ STW nhỏ hơn. Nhưng với đa số ứng dụng, đây không phải vấn đề.

Đo STW thật bằng PauseNs hoặc PauseTotalNs. runtime.MemStats.PauseNs là vòng đệm 256 mẫu thời gian dừng gần nhất — công cụ chính xác để giám sát STW trong production. Kết hợp với gctrace để thấy STW nằm ở pha nào. Nếu STW vượt vài ms, có gì đó bất thường.

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

STW nhỏ đổi bằng độ phức tạp và chi phí đồng thời. Để giữ STW dưới 1 ms, Go trả giá bằng write barrier (bài trước), CPU cho mark đồng thời, và một mô hình GC phức tạp. Đây là đánh đổi có chủ đích: độ trễ thấp quan trọng hơn thông lượng thô cho phần lớn dịch vụ Go. Nếu bạn cần thông lượng tuyệt đối (batch processing), GC đồng thời hơi phí so với STW đơn giản.

STW không phải là toàn bộ chi phí GC. ~160 µs STW nghe nhỏ, nhưng tổng chi phí GC còn gồm mark đồng thời (chia sẻ CPU) và write barrier. Đừng nhầm "STW nhỏ" với "GC rẻ" — đo tổng bằng cột %CPU trong gctrace.

Không thể loại bỏ STW hoàn toàn. Có những nghiên cứu GC không-STW, nhưng chúng phức tạp hơn nhiều và có đánh đổi riêng. Go chọn "STW cực nhỏ" thay vì "không STW" — đơn giản hơn, đủ tốt cho gần như mọi workload. Đừng kỳ vọng STW = 0.

Ba ý mang về

  1. GC Go dừng chương trình ở đúng hai điểm mỗi chu kỳ: sweep termination (đầu, bật write barrier) và mark termination (cuối, tắt write barrier) — cần thiết để mọi goroutine đồng bộ chuyển pha an toàn, không thể làm giữa lúc goroutine sửa con trỏ.
  2. STW cực nhỏ và gần như không phụ thuộc quy mô: đo thật vài chục tới vài trăm micro-giây, tăng nhẹ từ 39 us (100 goroutine) lên 161 us (100k) nhưng luôn dưới 1 ms kể cả nửa triệu goroutine — nhờ đẩy quét stack ra chạy đồng thời.
  3. Đây là lý do Go hợp cho dịch vụ độ trễ đuôi nghiêm ngặt: STW dưới 1 ms không gây spike p99 như GC cũ — giám sát bằng PauseNs, và giảm số goroutine sống nếu cần STW nhỏ hơn nữa ở quy mô cực lớn.

Phần sau ta gom mọi thứ đã học về GC thành kỹ thuật thực chiến: Phần sau tổng hợp cách giảm áp lực GC trong thực tế — kết hợp giảm allocation, tái dùng, chỉnh GOGC/GOMEMLIMIT, và đo hiệu quả trên workload thật.