CodeGym /Các khóa học /C# SELF /Race Condition (trạng thái đua)

Race Condition (trạng thái đua)

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

1. Giới thiệu

Trong ứng dụng đa luồng, tồn tại race condition không phải là câu hỏi "nếu" mà là "khi nào nó xảy ra". Ngay cả khi bạn nghĩ mã của mình đáng tin cậy, và bạn chỉ có "hai luồng nhỏ", nơi "mọi thứ rõ ràng và đơn giản", race condition vẫn có thể ẩn náu trong những đoạn logic vô hại nhất.

Race condition là gì và tại sao nó đáng sợ? Hãy tưởng tượng hai người cùng sửa một tờ giấy — một người ghi, người kia xóa. Đôi khi mọi thứ ổn, nhưng đôi khi thành một mớ không đọc được. Trong lập trình hậu quả còn thú vị hơn: lỗi không xảy ra luôn mà chỉ trong những điều kiện gần như ngẫu nhiên.

Race condition (trạng thái đua) — tình huống mà kết quả thực thi chương trình phụ thuộc vào luồng nào truy cập tài nguyên hoặc thực hiện hành động trước. Vấn đề này chỉ xảy ra khi có truy cập đồng thời (đa luồng), khi hai hoặc nhiều luồng truy cập cùng dữ liệu hoặc tài nguyên chia sẻ.

Điều gì xảy ra khi có đua?

Đây là sơ đồ đơn giản. Giả sử ta có hai luồng và một tài nguyên chia sẻ (ví dụ biến X):


     +---------+           +---------+          
     | Luồng 1 |           | Luồng 2 |          
     +----+----+           +----+----+          
          |                     |              
          |     Đọc X           |              
          | <-------------------|              
          |                     |              
          |     Tăng X          |              
          |-------------------> |              
          |                     |              
          |     Ghi X           |              
          | <-------------------|              

Nếu cả hai luồng cùng đọc giá trị biến X, tăng nó rồi ghi lại, một luồng sẽ "ghi đè" thay đổi của luồng kia, và tổng số lần tăng sẽ không đúng như mong đợi.

2. Ví dụ cổ điển về Race Condition

Hãy xem ví dụ. Giả sử ta muốn đếm số lần nhấn nút từ các luồng khác nhau hoặc số tác vụ đã xử lý.

Ta lấy một biến đơn giản và vài luồng tăng nó:

using System;
using System.Threading;

class Program
{
    static int counter = 0; // Tài nguyên chia sẻ

    static void Main()
    {
        Thread t1 = new Thread(IncrementCounter);
        Thread t2 = new Thread(IncrementCounter);

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

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

        Console.WriteLine("Giá trị mong đợi: 200000");
        Console.WriteLine("Giá trị thực tế: " + counter);
    }

    static void IncrementCounter()
    {
        for (int i = 0; i < 100_000; i++)
        {
            counter++; // << Đây có thể là chỗ xảy ra vấn đề!
        }
    }
}

Kỳ vọng của chúng ta là gì?

Vì mỗi luồng tăng counter 100000 lần, ta mong giá trị cuối cùng là 200000.

Thực tế nhận được?

Đôi khi — có, 200000. Nhưng thường giá trị sẽ nhỏ hơn — đôi khi nhỏ hơn nhiều. Thực hiện lại thí nghiệm, kết quả sẽ dao động!

Tại sao vậy?

Phép toán counter++ không phải là nguyên tử. Thực tế nó thực hiện như sau (đơn giản hóa):

  1. Đọc giá trị hiện tại của counter (ví dụ 0)
  2. Tăng lên 1 (ra 1)
  3. Ghi trở lại (counter = 1)

Nếu hai luồng cùng đọc giá trị cũ đồng thời, cả hai có thể ghi cùng một giá trị mới, nhưng thực tế chỉ tăng một lần.

Minh họa với hai luồng:

Giả sử counter = 0.

  • Luồng 1: đọc 0
  • Luồng 2: đọc 0
  • Luồng 1: tính 0 + 1 = 1
  • Luồng 2: tính 0 + 1 = 1
  • Luồng 1: ghi 1
  • Luồng 2: ghi 1 (ghi đè lần tăng của Luồng 1)

"Chúc mừng", bạn vừa mất một lần tăng! Trong hàng nghìn hoặc triệu thao tác — kết quả sẽ dao động nhiều.

3. Thêm ví dụ: không chỉ mỗi increment!

Hỗn loạn trong bếp

Cho vui, tưởng tượng một quán cafe nhỏ. Hai đầu bếp rán trứng trên cùng một chảo, nhưng không đồng bộ hành động:

  • Người thứ nhất bỏ một cái trứng, người thứ hai ngay lập tức bỏ cái của họ lên trên — chúng lẫn vào nhau;
  • Một người nghĩ "mình đã để hai trứng", người kia cũng nghĩ vậy, nhưng thực tế trong chảo là ba, họ nghĩ là bốn;
  • Bắt đầu hỗn loạn...

Trong lập trình race condition cũng gây "hỗn loạn": kết quả phụ thuộc vào chuỗi các thao tác nhanh và không kiểm soát.

Khi các luồng cản trở nhau: truy cập dữ liệu đồng thời

Giả sử bạn viết app ngân hàng, và khách đồng thời nạp và rút tiền từ cùng một tài khoản bằng hai luồng (ví dụ một luồng chuyển tiền online, luồng kia quầy giao dịch):

account.Balance += 500;    // Luồng 1: nạp tiền
account.Balance -= 300;    // Luồng 2: rút tiền

Nếu các thao tác này không được bảo vệ, số dư cuối cùng có thể sai: một số thao tác sẽ "bị mất" nếu các luồng chạy đồng thời.

4. Những lưu ý hữu ích

Tại sao race condition là vấn đề?

Khó bắt và tái tạo. Lỗi có thể chỉ xuất hiện trên máy quá tải hoặc trong điều kiện hiếm.

Khó debug. Khi debug luồng có thể chạy khác đi, và lỗi biến mất.

Hỏng tính toàn vẹn dữ liệu. Bạn nhận dữ liệu không chính xác, bị corrupt, đôi khi không nhận ra.

Bảo mật. Trong ứng dụng quan trọng race condition có thể dẫn đến rò rỉ, phá hủy dữ liệu và thậm chí lỗ hổng bảo mật.

Biểu đồ "timing của đua"


+-----------------------+     +-----------------------+
| Luồng 1               |     | Luồng 2               |
+-----------------------+     +-----------------------+
| 1. Đọc counter        |     |                       |
| 2. Tăng counter       |     |                       |
| (nhưng chưa ghi)      |     |                       |
|                       |     | 1. Đọc counter        |
|                       |     | 2. Tăng counter       |
|                       |     | 3. Ghi counter        |
|                       |     | (counter = 1)         |
| 3. Ghi counter        |     |                       |
| (counter = 1)         |     |                       |
+-----------------------+     +-----------------------+

Cả hai luồng đã làm increment nhưng kết quả chỉ ghi một lần!

Nơi thường gặp race condition

  • Bất cứ biến toàn cục hoặc static nào mà nhiều luồng truy cập.
  • Danh sách, queue, collection được thêm từ nhiều luồng.
  • Sự kiện và delegate, nếu subscribe/unsubscribe diễn ra đồng thời (ví dụ UI + background tasks).
  • Caching, dictionary, quản lý connection.
  • Bất cứ tương tác với file, log, DB mà không có transaction hoặc lock.

Cách tránh race condition: giới thiệu ngắn

  • Đồng bộ hóa! (chi tiết hơn — trong các bài sau).
  • Dùng các cấu trúc và thư viện sẵn: lock, Monitor, mutex, semaphore, v.v.
  • Với thao tác đơn giản — dùng các phương thức nguyên tử (Interlocked.Increment và các phương thức khác).
  • Dùng các collection thread-safe (ConcurrentBag, ConcurrentDictionary).
  • Luôn suy nghĩ: "nếu hai hàm của mình được gọi đồng thời thì sao?"

5. Mẹo hữu ích

Mẹo tìm và chẩn đoán race

  • Đừng tin tưởng ngay cả các phép toán đơn giản (increment ++, gán) nếu dùng nhiều luồng.
  • Tránh truy cập chia sẻ biến khi có thể.
  • Nếu thấy bug "dao động", lỗi khó tái tạo — nghĩ tới race!
  • Dùng công cụ phân tích luồng (dotTrace, Concurrency Visualizer, Thread Sanitizer).
  • Thực hiện stress tests — càng nhiều luồng và thao tác càng tăng khả năng phát hiện lỗi.

Cái gì được và không được làm mà không cần đồng bộ

Thao tác An toàn trong môi trường đa luồng? Giải thích
Gán int 🟩 Đôi khi* Chỉ khi một luồng viết, các luồng khác chỉ đọc, nếu không — race
Increment (++/--) 🟥 Không Không nguyên tử! Race Condition
Đọc string 🟩 Đôi khi* Nếu string không bị thay đổi sau khi tạo
Gán object 🟩 Đôi khi* Với điều kiện không có ghi đồng thời
Thêm vào List<T> 🟥 Không List<T> không thread-safe
Interlocked.Increment
🟩 Có Phương thức nguyên tử đặc biệt

— "Đôi khi" nghĩa là nếu chỉ một luồng ghi và các luồng khác chỉ đọc thì an toàn; nếu có nhiều luồng ghi đồng thời — luôn có đua.

6. Lỗi và bẫy thường gặp

Trong ví dụ trên ta thấy counter++ là vấn đề. Một cái bẫy khác: tăng hoặc kiểm tra giá trị trong điều kiện.

Ví dụ: Lỗi vui với "chạy lần đầu"

if (!alreadyStarted)
{
    alreadyStarted = true;
    // Lấy ví dụ khởi tạo...
}

Nếu điều kiện này được vài luồng thực hiện đồng thời, mỗi luồng có thể thấy alreadyStarted == false và vào khối! Kết quả — khởi tạo hai lần, có thể gây lỗi.

2
Nhiệm vụ
C# SELF, mức độ, bài học
Đã khóa
Công nhân và nhiệm vụ của họ
Công nhân và nhiệm vụ của họ
1
Khảo sát/đố vui
, cấp độ , bài học
Không có sẵn
Giới thiệu về đa luồng
Những điều cơ bản về đa luồng trong C#
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION