Tình huống ác mộng: một service đang chạy bỗng treo cứng — không crash, không in log, không phản hồi request nào. Bạn không thể sửa nếu không biết nó đang kẹt ở đâu: goroutine/thread nào, dòng code nào, đang chờ gì. May thay, nhiều runtime hiện đại có một "cửa hậu" tuyệt vời: gửi một tín hiệu để ép chương trình tự khai toàn bộ trạng thái nội tại. Bài này (phần 7 loạt Debug) chạy thật kỹ thuật này với Go — gửi SIGQUIT cho một chương trình bị treo và để nó in ra stack của mọi goroutine, chỉ đúng dòng đang kẹt.

Vì sao tín hiệu là công cụ debug

Tín hiệu (signal) là cách hệ điều hành "gõ vai" một tiến trình. Ngoài các tín hiệu quen thuộc (SIGTERM yêu cầu dừng, SIGKILL giết cứng), có những tín hiệu mà runtime của ngôn ngữ bắt để làm việc chẩn đoán:

  • Go: SIGQUIT (kill -QUIT, hay Ctrl+\ ở terminal) → runtime in stack của tất cả goroutine rồi thoát.
  • Java: kill -3 (SIGQUIT) → in thread dump ra stdout.
  • Python: không có sẵn như vậy, nhưng py-spy dump --pid (công cụ ngoài) làm điều tương tự mà không cần sửa code.

Điểm mạnh: không cần dừng bằng debugger, không cần thêm log, không cần gdb — chỉ một tín hiệu và chương trình tự phơi bày trạng thái.

kill -QUIT <pid>     # Go: in stack mọi goroutine

Ảnh chụp đoạn mã nền tối minh hoạ tín hiệu để debug bắt chương trình treo tự khai nó kẹt ở đâu, chương trình treo không log không phản hồi làm sao biết kẹt đâu app đứng im không crash không log không sửa được nếu không biết goroutine thread nào đang kẹt ở đâu nhiều runtime có cửa hậu qua tín hiệu để tự khai trạng thái, SIGQUIT Go tự in stack tất cả goroutine kill -QUIT pid hoặc Ctrl gạch chéo ở terminal runtime Go bắt SIGQUIT in stack mọi goroutine rồi thoát thấy chính xác goroutine nào kẹt ở dòng code nào Java kill -3 in thread dump Python py-spy dump, đọc dump tìm goroutine bị chặn goroutine 18 chan receive trạng thái đang chờ channel main.congViec work hang.go:12 đúng dòng đang kẹt trạng thái chan receive semacquire IO wait cho biết chờ gì, crash panic SIGSEGV cũng in stack lúc chết panic nil pointer dereference signal SIGSEGV main.main work crash.go:4 dòng gây crash core dump ulimit -c unlimited lưu ảnh bộ nhớ lúc chết mổ bằng gdb

Hình 1: Chương trình treo không cho thông tin gì; gửi SIGQUIT khiến Go in stack mọi goroutine (Java kill -3 in thread dump); đọc dump tìm goroutine bị chặn và dòng code; panic/SIGSEGV cũng tự in nơi crash.

Đo thật: bắt một deadlock tự khai

Mình viết một chương trình Go có một goroutine chờ mãi trên một channel không ai gửi (kẹt), rồi gửi SIGQUIT:

Ảnh chụp bảng kết quả chạy thật SIGQUIT dump goroutine Go output thật, một Go treo goroutine chờ nhận từ channel gửi kill -QUIT SIGQUIT quit tổng số goroutine trong dump 8 runtime in stack mọi goroutine không cần gdb không cần log, hai goroutine thủ phạm kẹt ở channel receive đúng dòng code goroutine 18 chan receive main.congViec work u_sig hang.go dòng 12 dòng mũi tên ch đang kẹt created by main.main work u_sig hang.go dòng 19, ba crash SIGSEGV cũng in stack lúc chết panic runtime error invalid memory address or nil pointer dereference signal SIGSEGV segmentation violation addr 0x0 goroutine 1 running main.main work u_sig crash.go dòng 4 dòng gây crash, kết không log không phản hồi tín hiệu ép chương trình tự khai stack trạng thái goroutine chan receive cộng dòng code bằng tìm ra deadlock

Hình 2: Chạy thật — SIGQUIT in SIGQUIT: quit và stack 8 goroutine; goroutine thủ phạm ở trạng thái [chan receive] chỉ đúng main.congViec tại hang.go:12 (dòng <-ch), tạo bởi main.main tại hang.go:19; và SIGSEGV/panic in nơi crash main.main tại crash.go:4.

  • Dump toàn bộ goroutine: kill -QUIT khiến Go in SIGQUIT: quit rồi stack của 8 goroutine. Không cần gdb, không cần thêm dòng log nào — runtime tự làm.
  • Chỉ đúng thủ phạm: goroutine kẹt ở trạng thái [chan receive] (đang chờ nhận từ channel), và stack chỉ chính xác main.congViec tại hang.go:12 — đúng dòng <-ch. Nó còn cho biết goroutine này được tạo ở hang.go:19. Trạng thái trong ngoặc ([chan receive], [semacquire] chờ khoá, [IO wait] chờ mạng) cho biết đang chờ gì — đủ để chẩn đoán deadlock.
  • Crash cũng tự khai: một chương trình Go truy cập con trỏ nil in ngay panic: nil pointer dereference [signal SIGSEGV] và stack chỉ main.main tại crash.go:4. Bạn biết dòng nào gây crash mà không cần công cụ ngoài.

Còn core dump và gdb?

Kỹ thuật cổ điển hơn: core dump — khi chương trình crash, kernel lưu một ảnh chụp toàn bộ bộ nhớ ra file core (cần bật ulimit -c unlimited), rồi bạn mở bằng gdb ./binary core để xem stack, biến, trạng thái lúc chết. Rất mạnh cho C/C++ và các binary không tự in stack. (Môi trường lab này không có sẵn gdb nên mình không demo bước gdb — nói thẳng thay vì bịa; nhưng cơ chế là: crash → core file → gdb đọc lại.) Với các runtime tự in stack như Go/Java, thường không cần tới core dump cho phần lớn trường hợp.

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

SIGQUIT làm chương trình Go thoát — đây là biện pháp chẩn đoán, không phải giám sát liên tục. Sau khi in stack, chương trình dừng. Với một service production đang treo, đây thường là điều bạn muốn (dump để phân tích rồi restart), nhưng biết trước hậu quả. Nếu chỉ muốn xem stack mà không giết, dùng công cụ ngoài (py-spy cho Python, hoặc GODEBUG/pprof endpoint cho Go — bài sau) để lấy stack của tiến trình đang chạy mà không làm nó thoát.

Dump goroutine dài — biết cách lọc. Một service thật có hàng nghìn goroutine; dump ra hàng nghìn dòng. Đừng đọc hết — tìm các goroutine ở trạng thái chờ đáng ngờ (chan receive, semacquire, select) và các dòng code của bạn (main.*, package của bạn), bỏ qua các goroutine hệ thống (runtime.*, GC). Nhóm nhiều goroutine cùng kẹt ở một chỗ = dấu hiệu deadlock/nghẽn ở đó.

Bật core dump có rủi ro bảo mật và dung lượng. Core dump chứa toàn bộ bộ nhớ tiến trình lúc crash — có thể gồm mật khẩu, khoá, dữ liệu người dùng. Trên production, bật ulimit -c unlimited bừa bãi vừa tốn đĩa (core của app lớn có thể hàng GB) vừa lộ bí mật nếu file core bị đọc trộm. Bật có kiểm soát, đặt đường dẫn an toàn (/proc/sys/kernel/core_pattern), và dọn sau khi phân tích.

Ba ý mang về

  1. Tín hiệu ép chương trình tự khai trạng thái: đo thật kill -QUIT khiến Go in stack 8 goroutine — không cần gdb, không cần thêm log; Java dùng kill -3 (thread dump), Python dùng py-spy dump.
  2. Dump chỉ đúng chỗ kẹt: đo thật goroutine [chan receive] chỉ main.congViec tại hang.go:12 (dòng <-ch) — trạng thái + dòng code cho bạn tìm ra deadlock ngay; crash/SIGSEGV cũng in nơi gây lỗi (crash.go:4).
  3. Core dump là phương án cổ điển cho crash: crash → file core (cần ulimit -c unlimited) → gdb đọc lại trạng thái lúc chết; nhưng SIGQUIT thoát chương trình (dùng py-spy/pprof nếu muốn xem mà không giết), và core dump chứa bí mật nên bật có kiểm soát.

Nguồn

Phần sau ta tìm thủ phạm ở cấp hệ thống: dùng top/ps đọc từ /proc để tìm tiến trình ngốn CPU hay RAM nhất, và đọc đúng cột để không quy tội nhầm.