Hướng dẫn debug đa luồng hiệu quả trong Visual Studio cho dev .NET
Đã có lần một bạn đồng nghiệp nhắn cho chúng tôi lúc gần nửa đêm. Ứng dụng của bạn ấy thỉnh thoảng treo, không theo quy luật nào rõ ràng. Lỗi chỉ xuất hiện khi nhiều luồng chạy cùng lúc. Bạn đặt breakpoint, chương trình dừng đúng chỗ, nhưng giá trị biến lại không khớp với những gì bạn đã tính.
Nếu bạn từng làm việc với code song song, có lẽ bạn cũng quen với cảm giác này. Đa luồng là việc nhiều đoạn code chạy đồng thời trong cùng một tiến trình, nên thứ tự thực thi không còn bám theo thứ tự bạn viết. Mỗi lần chạy có thể cho một kịch bản khác nhau.
Điều khó chịu nhất là loại lỗi này thường biến mất khi bạn cố tái hiện. Chạy lại thì mọi thứ ổn. Chạy thêm vài lần, lỗi lại lộ ra ở chỗ khác. Cả buổi chiều có thể trôi qua chỉ để chứng minh lỗi thật sự tồn tại.
Thói quen debug cũ không còn phù hợp với đa luồng
Phần lớn lập trình viên quen debug theo kiểu tập trung vào một luồng tại một thời điểm. Với chương trình đơn luồng, cách này vẫn ổn. Khi có nhiều luồng, bạn cần thấy đồng thời luồng nào đang làm gì. Breakpoint thông thường dừng toàn bộ tiến trình, nên bạn không nhìn thấy hai luồng tranh giành tài nguyên ngay khi xung đột xảy ra.
Vấn đề thường không nằm ở kỹ năng viết code mà ở chỗ chưa biết dùng đúng công cụ. Visual Studio đã có sẵn nhiều tính năng dành riêng cho đa luồng, nhưng không ít người chưa từng mở chúng ra. Gặp bug đa luồng đầu tiên, nhiều bạn chỉ mò mẫm bằng cách rải Console.WriteLine khắp nơi.
Nếu bạn cần kiểm tra nhanh các lỗi thường gặp trước khi đi sâu vào đa luồng, hãy đọc checklist debug nhanh lỗi thường gặp trong ASP.NET. Có nền tảng đó, việc đọc log và khoanh vùng lỗi sẽ dễ hơn nhiều.
Một thói quen nhỏ nhưng hữu ích là đặt tên cho luồng ngay khi tạo, ví dụ Thread.CurrentThread.Name = “OrderWorker”. Khi mở cửa sổ Threads, bạn sẽ thấy cái tên dễ hiểu thay vì một con số ID khó nhớ.
Đa luồng khác debug thông thường ở điểm nào
Trước khi thao tác cụ thể, bạn nên hiểu vì sao đa luồng khó hơn. Khi nắm được gốc rễ, bạn sẽ biết nên dùng công cụ nào và vào thời điểm nào.
Tất cả các luồng cùng tồn tại trong một vùng bộ nhớ và có thể truy cập chung biến hoặc tài nguyên. Việc theo dõi luồng nào đang giữ giá trị gì khó hơn nhiều. Một luồng có thể đang dừng giữa hàm, trong khi luồng khác đã đi qua hàm đó từ lâu.
Hai lỗi phổ biến nhất là race condition và deadlock. Race condition xảy ra khi hai luồng cùng ghi vào một biến, kết quả phụ thuộc vào luồng nào xong trước. Deadlock thì khó chịu hơn: hai luồng chờ nhau mãi, mỗi luồng giữ một tài nguyên mà luồng kia cần. Ứng dụng đứng im, không crash và không báo lỗi.
Ví dụ dưới đây minh họa race condition. Hai luồng cùng tăng một biến dùng chung mà không đồng bộ:
int counter = 0;
var t1 = new Thread(() => { for (int i = 0; i { for (int i = 0; i < 100000; i++) counter++; });
t1.Start(); t2.Start();
t1.Join(); t2.Join();
Console.WriteLine(counter); // Thường nhỏ hơn 200000, mỗi lần một khác
Cách sửa đơn giản là dùng Interlocked.Increment(ref counter) hoặc bọc đoạn ghi bằng lock. Khi nhận diện được dấu hiệu này, bạn sẽ khoanh vùng lỗi nhanh hơn nhiều.
Một trường hợp thực tế: đơn hàng bị ghi trùng số tiền
Đội ngũ chúng tôi từng xử lý một job xử lý đơn hàng chạy song song để tăng tốc. Trên máy dev mọi thứ vẫn ổn. Khi đưa lên môi trường có tải thật, thỉnh thoảng đơn hàng bị ghi trùng số tiền.
Debug bằng breakpoint mất cả buổi mà vẫn không tái hiện được lỗi. Lý do là breakpoint làm chậm một luồng đúng lúc, vô tình che đi race condition. Lỗi còn bị che khuất thêm bởi exception trong luồng phụ không được bắt đúng cách, khiến log gần như không ghi nhận gì. Sau khi làm gọn lại cách xử lý lỗi, chúng tôi mới khoanh vùng được nguyên nhân.
Bài học khá rõ. Nên áp dụng cách xử lý exception đúng thay vì try-catch tràn lan ngay từ đầu, và làm quen với công cụ debug đa luồng trước khi gặp bug thật.
Các bước debug đa luồng hiệu quả với Visual Studio
Phần này đi vào thao tác thực tế. Hai công cụ bạn sẽ dùng nhiều nhất là cửa sổ Threads và breakpoint có điều kiện. Khi kết hợp đúng cách, lỗi đa luồng sẽ lộ diện nhanh hơn hẳn.
Mở và đọc cửa sổ Threads
Cửa sổ Threads liệt kê toàn bộ luồng đang chạy trong ứng dụng. Bạn có thể mở nó theo các bước sau:
- Chạy ứng dụng ở chế độ Debug bằng F5 và để nó dừng tại một breakpoint.
- Vào menu Debug, chọn Windows, rồi chọn Threads. Tổ hợp Ctrl+Alt+H cũng mở được cửa sổ này.
- Xem cột Location để biết mỗi luồng đang đứng ở dòng nào.
- Nhấp đúp vào một luồng để chuyển ngữ cảnh sang luồng đó.
Luồng hiện tại có mũi tên vàng ở đầu dòng. Khi nghi ngờ một luồng cụ thể, bạn có thể nhấp chuột phải và chọn Freeze để tạm khóa luồng đó. Cách này giúp cô lập luồng đáng ngờ trong khi vẫn quan sát các luồng còn lại.
Breakpoint có điều kiện để dừng đúng luồng, đúng lúc
Breakpoint thường dừng chương trình mỗi lần chạm dòng đó, bất kể luồng nào. Với đa luồng, điều này gây nhiễu. Hãy nhấp chuột phải vào breakpoint, chọn Conditions và nhập biểu thức như threadId == 7 hoặc orderAmount > 1000000. Visual Studio sẽ chỉ dừng khi điều kiện đúng.
Bạn cũng có thể dùng Hit Count để chỉ dừng ở lần chạm thứ N. Cách này hữu ích khi lỗi chỉ xuất hiện sau một số vòng lặp nhất định.
Sai lầm hay gặp và cách tránh
Một lỗi thường gặp là đặt điều kiện breakpoint quá phức tạp, nối nhiều phép so sánh bằng &&. Mỗi lần luồng chạm breakpoint, Visual Studio phải đánh giá toàn bộ điều kiện, khiến chương trình chậm đi. Điều đó đôi khi còn làm thay đổi thời điểm xảy ra race condition mà bạn đang tìm.
Cách an toàn hơn là tách bước. Trước tiên, đặt điều kiện theo threadId để xác định đúng luồng gây lỗi. Khi đã khoanh được luồng, mới thêm điều kiện theo giá trị biến.
Ngoài ra, cửa sổ Parallel Stacks rất đáng dùng. Mở qua Debug, Windows, Parallel Stacks. Công cụ này hiển thị call stack của tất cả luồng dưới dạng sơ đồ, giúp bạn thấy ngay hai luồng nào đang cùng gọi vào một hàm dùng chung. Parallel Watch cho phép theo dõi cùng một biến trên nhiều luồng dưới dạng bảng. Nếu một giá trị lệch hẳn so với phần còn lại, luồng đó rất có thể là thủ phạm.
Debug đúng cách giúp tiết kiệm thời gian đáng kể
Khi thành thạo cửa sổ Threads và breakpoint có điều kiện, bạn không còn phải đoán mò cả buổi. Cửa sổ Threads cho bức tranh toàn cảnh, còn breakpoint điều kiện giúp cô lập đúng luồng gây lỗi. Thời gian tiết kiệm được có thể dành cho việc khác, chẳng hạn tối ưu hiệu năng ứng dụng .NET ngay trong cùng dự án.
Nếu bạn mới bắt đầu, đừng cố nhớ hết mọi tính năng trong một lần. Hãy bắt đầu với cửa sổ Threads để làm quen với việc quan sát nhiều luồng. Khi gặp bug khó tái hiện, thêm breakpoint có điều kiện. Khi cần ôn lại các thói quen cơ bản, bạn có thể xem thêm top 7 lưu ý về debug và tối ưu mã cho dev .NET.
Tham khảo thêm tài liệu chính thức của Microsoft về cửa sổ Threads trong Visual Studio để nắm rõ hơn các tùy chọn hiển thị và thao tác với luồng.


