Có một loại lỗi khiến dev nào cũng từng phát điên: code chạy đúng 99 lần, đến lần thứ 100 lại cho kết quả sai, rồi chạy lại thì lại đúng tiếp. Không có stack trace rõ ràng, không tái hiện được trên máy local, nhưng lại xảy ra đều đặn trên production khi có nhiều người dùng cùng lúc. Đó chính là race condition, một trong những loại lỗi khó tìm nhất trong lập trình.
Race condition là gì
Race condition xảy ra khi kết quả của chương trình phụ thuộc vào thứ tự thực thi của nhiều tiến trình hoặc luồng (thread) chạy đồng thời, trong khi thứ tự đó lại không được đảm bảo. Nói đơn giản: hai (hoặc nhiều) đoạn code cùng "chạy đua" để truy cập và thay đổi một tài nguyên chung, và kết quả cuối cùng phụ thuộc vào việc ai "về đích" trước, điều mà máy tính không đảm bảo giống nhau ở mỗi lần chạy.
Ví dụ kinh điển nhất: một biến đếm count được hai luồng cùng thực hiện count = count + 1. Thao tác này trông như một bước, nhưng thực chất gồm ba bước ở cấp độ máy: đọc giá trị hiện tại, cộng thêm 1, rồi ghi lại. Nếu hai luồng cùng đọc giá trị count = 5 trước khi luồng nào kịp ghi lại, cả hai đều tính ra 6 và ghi đè lên nhau, kết quả cuối cùng là 6 thay vì 7.
Vì sao nó khó tìm đến vậy
Race condition phụ thuộc vào timing, tức thời điểm chính xác các luồng chạy xen kẽ nhau, thứ gần như không thể kiểm soát hay dự đoán. Trên máy lập trình viên, với lượng request thấp, hai luồng gần như không bao giờ "đụng độ" đúng lúc để lộ lỗi. Khi lên production với hàng trăm, hàng nghìn request đồng thời, xác suất va chạm tăng lên đáng kể và lỗi bắt đầu xuất hiện, nhưng không phải lần nào cũng vậy.
Điều này khiến race condition gần như vô hình với cách debug thông thường. Đặt breakpoint để xem từng bước thực chất làm chậm luồng đó lại, vô tình khiến lỗi biến mất, hiện tượng dev hay gọi đùa là "heisenbug" (lỗi biến mất khi bạn cố quan sát nó).
Một ví dụ dễ hình dung: bán vé cuối cùng
Hãy tưởng tượng hệ thống bán vé chỉ còn đúng 1 vé. Hai người dùng A và B bấm "mua vé" gần như cùng lúc. Luồng xử lý của A kiểm tra "còn vé không?" và nhận được "còn 1 vé". Ngay sau đó, luồng của B cũng kiểm tra và nhận cùng câu trả lời, vì luồng của A chưa kịp trừ kho xuống 0. Kết quả: cả A và B đều được xác nhận mua thành công, trong khi chỉ có 1 vé thực sự tồn tại. Đây là lý do nhiều hệ thống đặt vé, đặt phòng hay thương mại điện tử dễ gặp sự cố "bán vượt số lượng" trong các đợt sale lớn, lúc có nhiều người mua cùng lúc nhất.
Cách phát hiện và phòng tránh
Vì race condition không phải lúc nào cũng tái hiện được, cách hiệu quả nhất là thiết kế đúng ngay từ đầu. Một số kỹ thuật phổ biến: dùng lock hoặc mutex để chỉ một luồng được truy cập tài nguyên chung tại một thời điểm; dùng thao tác atomic (không thể bị chia nhỏ hay xen ngang) cho các phép tính đơn giản như tăng biến đếm; tận dụng transaction trong database để nhóm các thao tác đọc-ghi thành một khối; và hạn chế chia sẻ trạng thái có thể thay đổi (mutable state) giữa các luồng khi không thực sự cần.
Về kiểm thử, công cụ stress test (giả lập nhiều request đồng thời) và race detector như ThreadSanitizer hay race detector của Go hữu ích hơn nhiều so với chỉ chạy lại code và hy vọng lỗi xuất hiện. Khi review code, đoạn nào truy cập tài nguyên dùng chung (biến toàn cục, file, bản ghi database) giữa nhiều luồng hoặc nhiều request cũng đáng được soi kỹ hơn bình thường.
Race condition là lời nhắc rằng code "chạy đúng" khi test thủ công một mình không có nghĩa là logic của nó thực sự đúng. Chỉ khi nhiều người, nhiều luồng cùng chạm vào nó một lúc, sự thật mới lộ ra, và thường là vào đúng lúc tệ nhất.