Khi bạn chạy docker run --cpus=1 hay đặt CPU limit trong Kubernetes, container được cấp một phần CPU. Nhưng "một phần CPU" nghĩa là gì, và điều gì xảy ra khi ứng dụng đòi hơn thế? Câu trả lời — cơ chế cfs_quota của cgroup — có một hệ quả tinh vi khiến rất nhiều dịch vụ bị đuôi trễ (tail latency) thảm họa mà không ai hiểu tại sao. Bài này đo trực tiếp hiện tượng đó, gọi là throttling, và vấp đúng cái bẫy đã hành hạ vô số hệ thống production: cấp nguyên một CPU mà ứng dụng vẫn đứng hình.
cgroup giới hạn CPU thế nào
Nhân Linux giới hạn CPU của một cgroup bằng hai con số, đọc ở cpu.max: quota và period. Nghĩa là: trong mỗi chu kỳ dài period micro giây, các tiến trình trong cgroup được dùng tối đa quota micro giây thời gian CPU. Ví dụ --cpus=1 của Docker đặt cpu.max = 100000 100000 — mỗi chu kỳ 100 mili giây, cgroup được 100 mili giây CPU, tức đúng một CPU.
Chuyện gì xảy ra khi cgroup dùng hết quota trước khi chu kỳ kết thúc? Nhân đóng băng (throttle) toàn bộ tiến trình trong cgroup — treo chúng lại, không cho chạy — cho tới khi chu kỳ mới bắt đầu và quota được nạp lại. Đây là cách giới hạn CPU hoạt động: không phải làm chậm đều, mà là cho chạy hết tốc rồi đóng băng. Với một CPU limit trên phần lẻ, đây chính là nguồn gốc của những cú khựng bí ẩn. Tôi muốn đo: đóng băng đó dài bao nhiêu, và điều gì quyết định nó?
Đo: cấp 1 CPU, nhưng vẫn đóng băng
Tôi viết một chương trình chạy T luồng, mỗi luồng lặp một vòng bận đọc đồng hồ liên tục; khi thấy hai lần đọc cách nhau hơn 4 mili giây, tôi biết luồng đó vừa bị đóng băng một khoảng. Chạy trong container --cpus=1 (đúng một CPU quota), tăng dần số luồng:
| Số luồng | Gap lớn nhất | Thời gian đóng băng (trên 3 s) |
|---|---|---|
| 1 luồng | 6 ms | ~1% (mượt) |
| 2 luồng | 62 ms | ~50% |
| 4 luồng | 86 ms | ~75% |
| 8 luồng | 106 ms | ~87% |
Với một luồng, mọi thứ mượt: khe hở lớn nhất chỉ 6 ms, gần như không bị đóng băng. Một luồng bận dùng đúng 100% của một CPU, khớp quota, chạy trơn tru. Nhưng khi tăng lên bốn luồng — vẫn trong đúng ngân sách một CPU đó — ứng dụng bị đóng băng những khoảng dài tới 86 mili giây, và tổng cộng nó đứng hình 75% thời gian. Tám luồng thì đóng băng tới 106 ms mỗi lần, đứng hình 87% thời gian. Càng nhiều luồng, đóng băng càng nặng, dù tổng CPU dùng vẫn bị chặn ở đúng một CPU.
Một lần tôi đo hớ: "một CPU" không phải "mượt như một CPU"
Tôi vào bài với một giả định tưởng chừng hiển nhiên: cấp cho container nguyên một CPU thì nó chạy mượt. Và với một luồng, đúng là mượt — nên tôi suýt dừng ở đó, kết luận "1 CPU là đủ, chạy trơn tru". Nhưng con số bốn luồng đóng băng 86 ms lật ngược tất cả.
Điều tôi bỏ sót: quota được chia cho tất cả các luồng cộng lại, và nhiều luồng đốt nó nhanh hơn hẳn. Bốn luồng chạy song song trên bốn nhân (máy có nhiều nhân) sẽ tiêu 100 mili giây quota chỉ trong khoảng 25 mili giây thực — bốn luồng, mỗi luồng 25 ms, cộng lại 100 ms quota, cạn sạch. Sau 25 ms đầu chu kỳ, quota hết, và cả bốn luồng cùng bị đóng băng suốt 75 ms còn lại của chu kỳ 100 ms, cho tới khi chu kỳ mới nạp quota. Kết quả: ứng dụng giật cục — chạy 25 ms, chết 75 ms, chạy 25 ms, chết 75 ms — dù mức CPU trung bình của nó không hề vượt quá một CPU.
Đây là chỗ đo hớ chí mạng: CPU trung bình dưới hạn mức, mà ứng dụng vẫn đứng hình phần lớn thời gian. Hai tín hiệu mâu thuẫn — "không vượt quota" và "đóng băng 75%" — là dấu hiệu tôi đang nhìn nhầm đại lượng. Tôi nhìn thông lượng trung bình (ổn) mà quên độ trễ đuôi (thảm họa). Một request rơi trúng lúc đóng băng sẽ chờ tới 75 mili giây không vì lý do gì trong code cả — chỉ vì cgroup đang treo mọi luồng. Bài học đo lường: "nhanh" (hay "đủ CPU") vô nghĩa nếu chưa hỏi "có mượt, có đều không" — và số luồng là biến ẩn quyết định, "có một CPU quota" hoàn toàn không bằng "chạy mượt như một CPU thật".
Cách chữa cũng lộ ra ngay khi hiểu nguyên nhân. Tôi đo lại bốn luồng nhưng cấp --cpus=4 (quota bằng số luồng): khe hở lớn nhất trở về 6 ms, mượt hoàn toàn. Khi quota đủ cho mức song song thật sự của ứng dụng, không còn cạn quota sớm, không còn đóng băng.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là CPU limit trên ứng dụng đa luồng là nguồn đuôi trễ số một trong container. Rất nhiều dịch vụ Java, Go, Node bị "thỉnh thoảng chậm 50–100 ms không rõ lý do" trong Kubernetes, và thủ phạm thường là CFS throttling: ứng dụng có nhiều luồng (thread pool, GC, runtime) hơn số CPU mà limit cho phép, nên đốt quota sớm rồi bị đóng băng. Khi thấy đuôi trễ dạng bậc thang đúng bằng bội số của period (100 ms), hãy kiểm cpu.stat: nr_throttled và throttled_usec tăng đều là bằng chứng.
Hệ quả thứ hai là khớp số luồng với CPU limit, hoặc nới limit cho khớp song song. Nếu bạn giới hạn ứng dụng ở 1 CPU nhưng nó chạy 8 luồng, bạn đang tự tạo đóng băng. Hai hướng chữa: giảm số luồng runtime cho khớp limit (ví dụ đặt GOMAXPROCS, số luồng GC, hay pool size = số CPU limit), hoặc nới limit lên cho khớp mức song song thật. Với nhiều dịch vụ, bỏ hẳn CPU limit (chỉ giữ CPU request để đảm bảo tối thiểu) là lựa chọn đúng — vì limit chỉ chặn phần bùng phát vốn vô hại khi máy còn rảnh, đổi lại gây đóng băng có hại.
Hệ quả thứ ba, về đo lường: đừng đo một tài nguyên bằng mức trung bình khi cái đau nằm ở phân bố. Con số mang theo: cgroup giới hạn CPU bằng quota/period; dùng hết quota giữa chu kỳ thì MỌI luồng bị đóng băng tới chu kỳ sau — nên một container cấp 1 CPU vẫn đứng hình tới ~85 ms mỗi 100 ms nếu nó chạy 4 luồng (đốt chung quota nhanh gấp 4), dù CPU trung bình không vượt hạn; chữa bằng cách khớp số luồng với quota. Mức trung bình nói "ổn"; độ trễ đuôi nói "thảm họa" — và người dùng cảm nhận cái sau. Đo đúng đại lượng mới thấy đúng vấn đề.
Thử ba mươi giây
Nếu bạn có một dịch vụ trong container hay Kubernetes bị chậm thất thường, kiểm throttling ngay: cat /sys/fs/cgroup/cpu.stat và nhìn nr_throttled (số lần bị đóng băng) và throttled_usec (tổng thời gian bị đóng băng). Chạy lại sau vài giây; nếu hai con số tăng nhanh, ứng dụng của bạn đang bị bóp CPU và đó chính là nguồn đuôi trễ. Xem hạn mức hiện tại bằng cat /sys/fs/cgroup/cpu.max (dạng quota period; max nghĩa là không giới hạn). Muốn thử tận tay, chạy docker run --cpus=1 ... một chương trình nhiều luồng bận và xem cpu.stat: bạn sẽ thấy nr_throttled nhảy vọt — đúng cái đóng băng bài này đo, và là lý do "cấp 1 CPU" không đủ để một ứng dụng đa luồng chạy mượt.