CodeGym /Các khóa học /C# SELF /Phân tích nâng cao Race Conditions

Phân tích nâng cao Race Conditions

C# SELF
Mức độ, Bài học
Có sẵn

1. Ví dụ phức tạp hơn về Race Condition

Race Condition — là một trong những loại lỗi ranh ma nhất. Tại sao? Vì chúng thường xảy ra rất hiếm, phụ thuộc vào tốc độ CPU, độ trễ của OS, các chuyển đổi ngẫu nhiên giữa các luồng, và chỉ tái hiện khi bạn đã muốn về nhà từ lâu. Những lỗi này không bị bắt bởi bất kỳ static analyzer nào. Unit test cũng khó mà bắt được chúng (trừ khi bạn là nhà ngoại cảm), nhưng một khi... chúng sẽ xuất hiện ở production.

Để tránh những bất ngờ khó chịu như vậy, cần hiểu rõ cách race condition phát sinh và cách xử lý.

Ví dụ 1. Ngân hàng không có đạo đức

Giả sử ta có một lớp account rất đơn giản:

public class Account
{
    public int Balance = 0;

    public void Deposit(int amount)
    {
        Balance += amount;
    }

    public void Withdraw(int amount)
    {
        Balance -= amount;
    }
}

Hãy tưởng tượng hai luồng cùng lúc cố gắng nạp vào tài khoản 100 và 200 euro, rồi mỗi luồng rút lần lượt 50. Nếu là môi trường đơn luồng, số dư luôn là: (0 + 100 + 200 - 50 - 50) = 200.

Vậy trong thực tế đa luồng thì sao? Hãy thử nghiệm:

var acc = new Account();

var t1 = new Thread(() => {
    acc.Deposit(100);
    acc.Withdraw(50);
});
var t2 = new Thread(() => {
    acc.Deposit(200);
    acc.Withdraw(50);
});

t1.Start(); t2.Start();
t1.Join(); t2.Join();

Console.WriteLine(acc.Balance); // Và mỗi lần chạy có thể cho kết quả khác nhau!

Giải thích:
Nếu cả hai luồng đều đọc Balance là 0, sau đó một luồng ghi 100, luồng kia ghi 200, nhưng giữa các thao tác đó xảy ra hoán đổi ngữ cảnh — số dư có thể "mất" hoặc "thêm" tiền. Ngân hàng không khóa — mơ ước của kẻ gian!

Ví dụ 2. Biến-flag

Một lỗi rất phổ biến — dùng biến-flag làm chỉ báo trạng thái:

bool isReady = false;

void Worker()
{
    while (!isReady)
    {
        // chờ...
    }
    // làm gì đó
}

Một luồng khác có thể ghi isReady = true. Thoạt nhìn có vẻ an toàn: có gì sai chứ? Hóa ra, ngay cả đọc và ghi một biến boolean cũng có thể không an toàn! Lý do: tối ưu hóa của compiler, cache của CPU, reorder lệnh trong hệ thống đa bộ xử lý.

Chuyện gì có thể xảy ra?
Một luồng có thể không nhận ra thay đổi của biến, tiếp tục vòng lặp vô tận, dù luồng khác đã đặt isReady = true từ lâu. Để truyền "flag" giữa các luồng luôn dùng các primitive chuyên dụng (volatile, Interlocked, events, v.v. — xem chi tiết trong tài liệu chính thức).

Ví dụ 3. Kiểm tra null và tạo object

Hãy tưởng tượng một Singleton:

public class Singleton
{
    private static Singleton _instance;

    public static Singleton Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new Singleton();
            }
            return _instance;
        }
    }
}

Mã trông đơn giản? Nhưng nếu nhiều luồng cùng gọi Instance đồng thời, hoàn toàn có thể tạo ra nhiều instance! Đây là ví dụ kinh điển của double-check locking mà không có đồng bộ: trường _instance bị khởi tạo theo kiểu race nếu không bảo vệ bằng lock.

2. Race Condition xuất hiện trong code như thế nào

Thao tác "Read-Modify-Write" — giải phẫu của cái ác

Lấy lại ví dụ increment: counter++

Ở mức CPU, đó là:

  1. Đọc từ bộ nhớ: register = counter;
  2. Tăng: register = register + 1;
  3. Ghi ngược lại: counter = register;

Điều gì xảy ra nếu hai luồng thực hiện khối này gần như cùng lúc?

  • Cả hai luồng đọc counter = 0.
  • Cả hai tăng register lên 1.
  • Cả hai ghi lại 1.

Kết quả: với hai lần increment, giá trị chỉ tăng 1! Một lần tăng bị "ăn mất" — và chẳng ai để ý.

Minh họa "va chạm" của hai luồng


Luồng A        |   Luồng B        |   counter-------------------------------------------
Đọc (0)        |                  |   0
               |   Đọc (0)        |   0
Tăng (1)       |                  |   0
               |   Tăng (1)       |   0
Ghi (1)        |                  |   1
               |   Ghi (1)        |   1  <-- Oops! Mong đợi 2, nhận 1

Tại sao Race Condition khó bắt?

  • Race Condition "ngụy trang". Thêm vài dòng Console.WriteLine — lỗi có thể biến mất. Vì luồng đi theo đường khác.
  • Lỗi phụ thuộc vào số nhân, tải của chúng, phiên bản OS và .NET. Hôm qua chạy tốt, hôm nay sụp đổ.
  • Kết quả dao động: lỗi không luôn xuất hiện, chỉ dưới "những tình huống đặc biệt".
  • Debug thì… thú vị khó tả.

3. Race Condition trong collections và các lớp .NET

Race Condition không chỉ xảy ra với biến. Collections, queues và thậm chí các lớp chuẩn của .NET không luôn thread-safe.

Ví dụ: List<T> không an toàn cho đa luồng

Nếu hai luồng cùng gọi .Add() lên một List<T> bình thường, hậu quả có thể thảm khốc:

var list = new List<int>();
var tasks = new List<Task>();

for (int t = 0; t < 10; t++)
{
    int threadNum = t;
    tasks.Add(Task.Run(() => {
        for (int i = 0; i < 1000; i++)
            list.Add(threadNum * 1000 + i);
    }));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(list.Count); // Rất có thể sẽ < 10000

Ở đây race condition xảy ra trong phương thức Add(), vì bên trong collection có thể mở rộng mảng nội bộ, copy các phần tử, và trong lúc đó luồng khác lại thêm... Hệ quả — dữ liệu bị mất, exceptions hoặc collection bị hỏng.

Ví dụ: Nhiều luồng dùng cùng một indexer

var array = new int[10];
void Worker(int index, int value)
{
    array[index] = value;
}

Parallel.Invoke(
    () => Worker(5, 1),
    () => Worker(5, 2)
);

Kết quả là phần tử array[5] có thể là 1 hoặc 2 — tùy luồng nào ghi sau cùng. Đôi khi hành vi này mong muốn, nhưng thường — nguồn bug tinh vi và khó bắt lỗi.

4. Những điểm hữu ích

Race Condition và các thao tác không nguyên tử

Cẩn trọng đặc biệt khi thao tác trông "nhỏ", nhưng không đảm bảo atomicity.

  • i++, i--
  • a = b
  • myObject.Property = value
  • "Kiểm tra flag — thay đổi giá trị"
  • "Lấy object — thay đổi trạng thái nội tại của nó"

Tất cả những thứ này riêng lẻ đều không phải hành động nguyên tử! Chúng có thể bị luồng khác "chen vào" giữa các bước nhỏ.

Những huyền thoại và cạm bẫy về Race Condition

Huyền thoại 1: "Nếu biến là int thì an toàn, nó là primitive".
Thực tế: Ngay cả các thao tác trên int không đảm bảo nguyên tử nếu không đánh dấu volatile và không dùng API chuyên dụng.

Huyền thoại 2: "Nếu tôi ghi vào collection, còn luồng khác chỉ đọc — ổn thôi".
Thực tế: Không! Trong .NET collections không đảm bảo integritiy khi có đọc và ghi đồng thời, bạn có thể gặp objects "hỏng" hoặc exceptions lạ.

Huyền thoại 3: "Race Condition chỉ xuất hiện ở services tải nặng".
Thực tế: Ngay cả trong utilities nhỏ, game, desktop app cũng có thể gặp race. Chỉ là nó xảy ra ít hơn hoặc không ngay lập tức.

Làm sao để bảo vệ khỏi Race Condition?

  • Dùng primitives đồng bộ ( lock, Mutex, Monitor, v.v.).
  • Với các thao tác tăng/giảm cơ bản trên số — dùng Interlocked (tài liệu).
  • Với collections — dùng các lớp thread-safe chuyên dụng (ConcurrentBag, ConcurrentDictionary, v.v. — xem tài liệu).
  • Không dùng biến-flag hay trạng thái mà không có bảo vệ (dùng volatile, events, primitives đồng bộ).

Khi nào Race Condition xảy ra

Tình huống Có thể có Race Condition? Cách bảo vệ
Nhiều luồng chỉ đọc dữ liệu Không (nếu không có thay đổi) -
Một luồng ghi, luồng khác đọc Có
lock/volatile
Nhiều luồng ghi Có
lock/Interlocked
Nhiều luồng thay đổi collection Có Collections thread-safe
Nhiều luồng dùng flag Có volatile, lock, events

Atomicity — không phải liều thuốc thần

Đôi khi ngay cả các thao tác nguyên tử cũng không cứu được khỏi các "race" về mặt logic.

Ví dụ:

if (!cache.ContainsKey(key))
{
    cache[key] = GetData(key);
}

Nếu hai luồng cùng đi qua if này đồng thời, cả hai sẽ thấy key chưa có, và cả hai tạo giá trị mới — luồng sau cùng sẽ overwrite kết quả của luồng trước! Ở đây không phải Interlocked hay các phép toán nguyên tử trên int cứu được — cần lock toàn bộ hoặc dùng hàm chuyên dụng ( GetOrAdd trong ConcurrentDictionary ).

4. Lỗi điển hình của sinh viên khi làm việc với Race Condition

Lỗi #1: nghĩ rằng lock chỉ cần cho "mấy thứ to lớn".
Trên thực tế ngay cả một biến đơn giản cũng có thể hỏng nếu không bảo vệ.

Lỗi #2: khóa sai đối tượng.
Ví dụ, khóa một string hoặc một object có thể truy cập từ bên ngoài class. Kết quả là đồng bộ không hoạt động như mong muốn.

Lỗi #3: tin rằng kết quả "gần như luôn đúng".
Nếu bug xuất hiện hiếm — không có nghĩa là có thể bỏ qua việc bảo vệ tài nguyên.

Lỗi #4: dùng async methods mà không đồng bộ hóa.
Hy vọng "may mắn" và nhận về lỗi hỗn loạn ở những nơi không ngờ tới.

Lỗi #5: viết Singleton với double-check nhưng không có lock.
Thay vì một object trên bộ nhớ xuất hiện nhiều bản sao, và debugging thành cơn ác mộng.

2
Nhiệm vụ
C# SELF, mức độ, bài học
Đã khóa
Đồng bộ hóa truy cập tài nguyên qua `Monitor`
Đồng bộ hóa truy cập tài nguyên qua `Monitor`
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION