Bạn có một service chậm dưới tải. CPU profile cho thấy... không có gì đặc biệt — không hàm nào ngốn CPU. Nhưng nó vẫn chậm. Vì sao? Rất có thể vì tranh chấp khóa: các goroutine dành phần lớn thời gian chờ mutex, không phải chạy. CPU profile mù trước điều này (goroutine chờ không dùng CPU). Công cụ đúng là mutex profile — nó đo chính xác thời gian goroutine bị chặn bởi khóa. Bài này đo thật và chỉ cách tìm ra khóa nghẽn.
Bật mutex profile
Mutex profile tắt mặc định (vì có chi phí). Bật bằng một dòng:
runtime.SetMutexProfileFraction(1)
// 0 = tắt (mặc định)
// 1 = ghi MỌI sự kiện chặn (chi tiết nhất, tốn hơn)
// n = lấy mẫu 1/n sự kiện (nhẹ hơn cho production)
Với production, dùng n lớn (ví dụ 100) để giảm chi phí; với debug cục bộ, dùng 1 để thấy đầy đủ.

Hình 1: Mutex profile. Bật bằng SetMutexProfileFraction, ghi bằng pprof.Lookup("mutex"), phân tích bằng go tool pprof, và cách sửa tranh chấp.
Ghi và phân tích
Ghi profile ra file (hoặc dùng -mutexprofile với go test, hoặc HTTP /debug/pprof/mutex):
f, _ := os.Create("mutex.pprof")
pprof.Lookup("mutex").WriteTo(f, 0)
f.Close()
Phân tích bằng go tool pprof -top -unit=ms mutex.pprof. Loại profile là delay — tổng thời gian các goroutine chờ khóa. Hàm nào có delay lớn → khóa của nó là nút thắt.
Đo thật: 20,8 giây chờ ở một khóa
Cho 10 goroutine tranh một mutex nóng (giữ khóa 10µs mỗi lần), và một goroutine dùng mutex nguội:

Hình 2: go tool pprof -top cho Type: delay = 20.863ms (20,8 giây) tổng thời gian chờ, 100% quy về main.main.func1 (goroutine mutex nóng). Mutex nguội không xuất hiện.
Output cho 20.863ms = 20,8 giây tổng thời gian chờ (cộng dồn 10 goroutine tranh khóa trong vài giây thực), và 100% quy về main.main.func1 — đúng goroutine giữ mutex nóng. Mutex nguội (muNguoi) không xuất hiện trong profile vì tranh chấp không đáng kể (bị drop). Profile chỉ thẳng vào thủ phạm.
Vì sao mutex profile khác CPU profile
Đây là điểm cốt lõi:
- CPU profile: đo thời gian chạy — goroutine đang làm việc thật.
- Mutex profile: đo thời gian chờ khóa — goroutine bị chặn, không dùng CPU.
Một khóa tranh chấp cao khiến goroutine chờ nhiều mà CPU thấp — CPU profile không thấy gì, chỉ mutex profile bắt được. Đây là lý do khi service "chậm mà CPU không cao", mutex profile (và block profile — họ hàng của nó) là công cụ đầu tiên nên xem.
Ứng dụng thực tế
Chẩn đoán "chậm mà CPU thấp". Khi throughput không tăng dù thêm core, hoặc độ trễ cao mà CPU nhàn, gần như chắc là tranh chấp khóa (hoặc I/O). Bật mutex profile để xác nhận và tìm khóa cụ thể — đừng đoán.
Bật -mutexprofile trong benchmark. go test -bench . -mutexprofile mutex.pprof cho profile tranh chấp của benchmark. Rất hữu ích khi so hai cài đặt: xem cái nào tranh chấp ít hơn, không chỉ nhanh hơn trung bình.
Đặt endpoint pprof trên production. Import _ "net/http/pprof" mở /debug/pprof/mutex. Với SetMutexProfileFraction(100) (lấy mẫu nhẹ), bạn có thể lấy mutex profile từ production đang chạy để tìm nút thắt thật dưới tải thật — an toàn hơn nhiều so với tái hiện cục bộ.
Đánh đổi cần cân nhắc
Mutex profile có chi phí — chọn fraction cẩn thận. SetMutexProfileFraction(1) ghi mọi sự kiện chặn, tốn thêm CPU và bộ nhớ. Trên production dùng giá trị lớn (100–1000) để lấy mẫu nhẹ; số liệu vẫn đủ tìm nút thắt lớn. Đừng để fraction=1 chạy production lâu dài.
Profile chỉ thấy tranh chấp đã xảy ra. Mutex profile ghi các sự kiện chặn thật trong lần chạy đó. Nếu tải test không tạo ra tranh chấp giống production, profile bỏ sót. Như mọi profiling, chạy trên tải đại diện — hoặc lấy từ production.
Tìm được khóa nghẽn mới là nửa việc. Profile chỉ ra khóa nào tranh chấp, không chỉ cách sửa. Các hướng sửa (đã học ở loạt bài trước): sharding (chia một khóa thành N khóa theo shard), atomic/RWMutex cho đọc-nhiều, copy-on-write với atomic.Pointer, hoặc đơn giản là giảm vùng khóa (giữ khóa ngắn nhất có thể, ít việc trong critical section). Chọn theo mẫu truy cập.
Ba ý mang về
- Mutex profile đo tổng thời gian goroutine CHỜ khóa (tranh chấp), thứ CPU profile không thấy: đo thật, 10 goroutine tranh một mutex nóng tích lũy 20,8 giây chờ, 100% quy về đúng khóa đó, còn mutex nguội bị loại khỏi profile vì tranh chấp không đáng kể.
- Bật bằng
runtime.SetMutexProfileFraction(1)(hoặc-mutexprofile/HTTP), phân tích bằnggo tool pprof: loại profile làdelay, hàm có delay lớn nhất chỉ ra khóa nghẽn — dùng fraction lớn (100+) cho production để lấy mẫu nhẹ. - Mutex profile là công cụ số một cho "chậm mà CPU thấp": khóa tranh chấp cao làm goroutine chờ (không dùng CPU) nên CPU profile mù — sau khi tìm ra khóa, sửa bằng sharding, atomic/RWMutex, copy-on-write, hoặc giảm vùng khóa; nhưng profile phải chạy trên tải đại diện mới bắt đúng tranh chấp.
Bài tập làm thử
Bài 1 (đọc hiểu). Một service chậm dưới tải, nhưng CPU profile không cho thấy hàm nào ngốn CPU bất thường. Dựa vào bài học, đâu là nguyên nhân khả dĩ nhất, và tại sao CPU profile "mù" trước nguyên nhân đó?
Đáp án
Nguyên nhân khả dĩ nhất là tranh chấp khóa (mutex contention): các goroutine dành phần lớn thời gian chờ mutex chứ không phải chạy. CPU profile chỉ đo thời gian goroutine đang thực sự thực thi (dùng CPU); một goroutine đang bị chặn ở Lock() không dùng CPU nên không xuất hiện trong CPU profile — cần dùng mutex profile (đo loại delay, tức thời gian chờ khóa) mới thấy được nút thắt này.
Bài 2 (sửa lỗi/vận dụng). Đoạn mã sau chạy trên production nhưng khiến chi phí CPU/bộ nhớ tăng đáng kể chỉ vì bật profiling. Sửa lại để phù hợp với khuyến nghị của bài cho môi trường production.
func main() {
runtime.SetMutexProfileFraction(1) // ghi mọi sự kiện chặn
// ...
}
Đáp án
func main() {
runtime.SetMutexProfileFraction(100) // hoặc 1000, lấy mẫu nhẹ hơn
// ...
}
SetMutexProfileFraction(1) ghi mọi sự kiện chặn — chi tiết nhất nhưng tốn thêm CPU và bộ nhớ, chỉ nên dùng khi debug cục bộ. Trên production nên dùng giá trị n lớn (ví dụ 100–1000) để chỉ lấy mẫu 1/n sự kiện, giảm chi phí trong khi số liệu vẫn đủ để tìm ra nút thắt lớn.
Bài 3 (đọc số liệu đo thật). Bài đo thật cho 10 goroutine tranh một mutex nóng (giữ khóa 10µs mỗi lần) và một goroutine dùng mutex nguội. Kết quả go tool pprof -top cho Type: delay tổng 20.863ms (20,8 giây), 100% quy về main.main.func1, còn mutex nguội không xuất hiện. Giải thích ý nghĩa từng con số này và tại sao mutex nguội "biến mất" khỏi profile.
Đáp án
20,8 giây là tổng cộng dồn thời gian mà cả 10 goroutine phải chờ để giành được mutex nóng (không phải 20,8 giây thời gian thực trôi qua) — con số lớn vì có nhiều goroutine cùng chờ trong một khoảng thời gian thực ngắn hơn nhiều. "100% quy về main.main.func1" nghĩa là toàn bộ thời gian chờ đó đều đến từ đúng đoạn code giữ mutex nóng, xác nhận đây chính là nút thắt. Mutex nguội (muNguoi) không xuất hiện vì tranh chấp trên nó không đáng kể — pprof có xu hướng loại/không hiển thị các sự kiện chặn quá nhỏ so với phần chiếm đa số, nên profile chỉ thẳng vào thủ phạm thật.
Bài 4 (vận dụng thực tế). Bạn nghi ngờ một khóa sync.Mutex bảo vệ một map dùng chung đang là nút thắt trong service của mình dưới tải thật ở production, nhưng không muốn tái hiện lại tải đó ở môi trường local (vì có thể không giống production). Nêu cách làm phù hợp nhất theo bài, và điều kiện an toàn khi làm việc này trên production.
Đáp án
Import _ "net/http/pprof" để mở endpoint /debug/pprof/mutex, và gọi runtime.SetMutexProfileFraction(100) (hoặc giá trị lớn tương tự) để lấy mẫu nhẹ thay vì fraction=1. Sau đó lấy mutex profile trực tiếp từ production đang chạy dưới tải thật — cách này đáng tin hơn nhiều so với cố tái hiện cục bộ vì tải thử nghiệm có thể không tạo ra đúng kiểu tranh chấp giống thực tế. Điều kiện an toàn: dùng fraction lớn (không để 1 chạy lâu dài) để tránh chi phí CPU/bộ nhớ phụ trội đáng kể trên hệ thống đang phục vụ người dùng thật.
Bài 5 (bẫy/đánh đổi). Sau khi mutex profile chỉ ra chính xác khóa nào là nút thắt, một lập trình viên vẫn không biết nên sửa như thế nào. Nêu ít nhất ba hướng sửa cụ thể được bài đề cập, và giải thích ngắn gọn khi nào nên chọn hướng nào.
Đáp án
Ba (trong số các) hướng sửa: (1) sharding — chia một khóa lớn thành N khóa theo shard, dùng khi dữ liệu có thể phân vùng độc lập; (2) atomic hoặc RWMutex — dùng cho trường hợp đọc nhiều/ghi ít, để nhiều goroutine đọc đồng thời không tranh chấp; (3) copy-on-write với atomic.Pointer — cho phép đọc không cần khóa hoàn toàn, phù hợp khi ghi hiếm và đọc rất thường xuyên; ngoài ra còn có cách đơn giản là giảm vùng khóa (giữ khóa ngắn nhất có thể, làm ít việc nhất trong critical section) — luôn nên cân nhắc trước tiên vì ít rủi ro và dễ áp dụng nhất. Việc tìm ra khóa nghẽn (qua profile) chỉ là nửa việc; chọn đúng cách sửa phải dựa vào mẫu truy cập thực tế (đọc/ghi tỉ lệ ra sao, dữ liệu có phân vùng được không).
Phần sau ta chuyển sang một chủ đề nền tảng khác của Go nâng cao — phản chiếu kiểu lúc chạy: Phần sau mổ xẻ package reflect — Type và Value, cách kiểm và thao tác giá trị mà không biết kiểu lúc biên dịch, và chi phí thật của reflection.