1. Giới thiệu
Trong ứng dụng đa luồng, tài nguyên chung là mọi thứ mà hai hay nhiều luồng có thể truy cập đồng thời. Nó có thể là:
- Biến (ví dụ, counter toàn cục hoặc list).
- Đối tượng (ví dụ, collection người dùng).
- Tập tin hoặc socket mạng.
- Bất kỳ cấu trúc dữ liệu nào bị thay đổi bởi các luồng khác nhau.
Trong ứng dụng console chúng ta thường xuyên gặp biến và đối tượng "được share" giữa các luồng.
Tương tự
Hãy tưởng tượng hai người cùng cố ghi gì đó vào cùng một cuốn sổ mà không thỏa thuận lượt. Tốt nhất thì chữ xấu, tệ nhất thì ai đó ghi đè dữ liệu của người kia. Trong lập trình tình huống y hệt, chỉ khác là những “người” đó là các luồng.
Tóm tắt các tài nguyên thường xuyên xảy ra race
Trong bảng dưới đây — các tài nguyên hay gặp, nguy hiểm khi truy cập đồng thời từ nhiều luồng:
| Tài nguyên | Nhóm vấn đề | Ví dụ |
|---|---|---|
| Biến kiểu int | Tăng/giảm không chính xác | counters, indexes |
| Collections dùng chung | Mất/hỏng phần tử, exceptions | shared list đơn hàng |
| Objects | Thay đổi trạng thái không tuần tự | flags, properties |
| Files | Hỏng dữ liệu, đọc/ghi sai | log-files, configuration |
2. Race condition: nó thể hiện như thế nào?
Ví dụ: Counter lượt truy cập
Giả sử ta muốn đếm xem người dùng nhấn nút bao nhiêu lần (hoặc trong ví dụ này, bao nhiêu lần các luồng tăng biến). Phiên bản đơn giản của code:
int counter = 0;
void Increment() {
counter++;
}
Bây giờ tạo hai thread, mỗi thread gọi Increment() 100 000 lần:
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 100_000; i++)
{
counter++;
}
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Ozhidali: 200000, poluchili: {counter}");
}
}
Về mặt logic counter phải được tăng bao nhiêu lần? 200000! Nhưng nếu bạn chạy code này nhiều lần, gần như chắc chắn sẽ thấy các số khác nhau: 185000, 192500, 198765… Tại sao?
3. Tại sao counter++ không phải là phép toán nguyên tử?
Cách thực sự counter++ hoạt động
Trong C# và các ngôn ngữ cấp cao khác chương trình được dịch ra tập hợp lệnh máy. Toán tử counter++ không biến thành một lệnh thần kỳ "cộng 1 vào biến". Thực tế là:
- Luồng ĐỌC giá trị từ bộ nhớ (counter).
- Tăng giá trị đó lên 1 (trong register CPU).
- Ghi giá trị mới trở lại bộ nhớ (counter).
Nếu hai luồng làm điều này gần như cùng lúc, cả hai có thể đọc cùng một giá trị cũ, tăng nó, và cả hai ghi lại kết quả, khiến một lần increment bị mất.
Kịch bản race
Giả sử counter bằng 1000. Cả hai luồng đọc giá trị đó (bước 1), mỗi luồng tăng thành 1001 (bước 2), rồi cả hai ghi lại 1001 (bước 3). Thảm họa: một increment bị mất!
Minh họa race
| Thời điểm | Luồng 1 | Luồng 2 | Giá trị counter |
|---|---|---|---|
| 1 | Đọc 1000 | 1000 | |
| 2 | Đọc 1000 | 1000 | |
| 3 | Tăng lên 1001 | Tăng lên 1001 | 1000 (chưa ghi) |
| 4 | Ghi 1001 | 1001 | |
| 5 | Ghi 1001 | 1001 |
Kết quả: hai lần increment chỉ làm tăng 1!
4. Thêm ví dụ: "bug ẩn"
Nếu race condition không phải với số thì sao?
Giờ tưởng tượng vài luồng cùng thêm phần tử vào cùng một list:
using System;
using System.Collections.Generic;
using System.Threading;
class Program
{
static List<int> numbers = new List<int>();
static void AddNumbers()
{
for (int i = 0; i < 10000; i++)
{
numbers.Add(i);
}
}
static void Main()
{
Thread t1 = new Thread(AddNumbers);
Thread t2 = new Thread(AddNumbers);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Ozhidali: 20000, poluchili: {numbers.Count}");
}
}
Code này cũng có thể cho kết quả khác nhau mỗi lần chạy: đôi khi app crash (exception), đôi khi bạn thấy ít phần tử hơn mong đợi.
Tại sao? Vì List<T> mặc định không thread-safe. Khi hai luồng gọi Add cùng lúc, cấu trúc bên trong có thể bị hỏng.
5. Tính nguyên tử của các thao tác
Thao tác nguyên tử là gì?
Thao tác nguyên tử là thao tác thực hiện toàn bộ, không thể bị các luồng khác can thiệp ở giữa. Giống như một "transaction": hoặc toàn bộ thực hiện, hoặc không.
- Gán giá trị kiểu int như myVar = 42; trên hầu hết nền tảng là atomic (trừ khi đó là object rất to).
- Nhưng counter++ thì không — nó là ba bước liên tiếp.
Thao tác atomic đặc biệt
Trong .NET có các lớp cho thao tác nguyên tử: ví dụ Interlocked. Cách này sẽ được xem trong các bài sau.
Ví dụ increment nguyên tử với Interlocked.Increment:
using System.Threading;
int counter = 0;
Interlocked.Increment(ref counter); // operation nguyên tử!
6. Tại sao khó bắt race condition?
Race condition nguy hiểm vì:
- Có thể chỉ xuất hiện khi load cao.
- Không bắt được 100%, có thể 5% hoặc thậm chí 0.01% các lần chạy.
- Crash “random” và xảy ra ở chỗ không ngờ tới.
Cách nhận ra vấn đề?
Nếu mỗi lần chạy chương trình bạn nhận kết quả khác nhau (và sai), nên nghi ngờ race condition.
Trò đùa của lập trình viên
"Nếu bug xuất hiện hiếm và được fix bằng việc thêm Thread.Sleep(50) — bạn đang có vấn đề nghiêm trọng hơn bạn tưởng."
7. Một vài lưu ý hữu ích
Synchronization
Để bảo vệ các critical section (đoạn code thao tác tài nguyên chung) cần phải sync chúng. Nhưng đó là chủ đề của bài sau. Hiện tại quan trọng là biết phát hiện và giải thích vấn đề.
Lỗi phổ biến của người mới
Nhiều newbie nghĩ: "Mình có counter++ — có gì sai chứ?" Tiếc là khi bạn có hơn một luồng, mọi thứ có thể đi sai! Ngay cả những thứ tưởng đơn giản: đọc/ghi biến, Add vào list, thay đổi trạng thái object, v.v.
Vị trí race condition trong thực tế
Trong ứng dụng đa luồng hiện đại (ví dụ server API, xử lý web-requests, game và mobile app) hầu như luôn có tài nguyên chung. Không sync sẽ dẫn tới xử lý đơn hàng sai, crash, memory leaks và cực kỳ khó debug.
Trong phỏng vấn vị trí middle/senior người ta chắc chắn hỏi: "Race condition là gì? Làm sao tránh?" Nếu bạn có thể nêu các ví dụ ở trên — và giải thích cơ chế — các recruiter sẽ hài lòng!
GO TO FULL VERSION