CodeGym /Các khóa học /C# SELF /Mẫu đảm bảo an toàn luồng

Mẫu đảm bảo an toàn luồng

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

1. Mẫu «Không khoá nhưng vẫn an toàn» (Lock-Free / Wait-Free)

Bạn đã biết rằng các collection Concurrent là an toàn cho đa luồng. Nhưng họ làm thế nào mà không bắt bạn phải bao quanh code bằng các khối lock? Câu trả lời nằm ở các thuật toán nâng cao — lock-free (không khoá) và wait-free (không chờ).

Giải thích ngắn gọn các khái niệm thuật toán lock-free và wait-free

Lock-Free (không khoá)

Bản chất: Đảm bảo rằng ít nhất một luồng luôn có thể tiến một bước, ngay cả khi các luồng khác gặp trễ hoặc bị ngắt.

Khác với lock: Khi dùng lock các luồng cạnh tranh sẽ chờ cho tới khi khoá được thả. Trong thuật toán lock-free các luồng không chờ nhau theo nghĩa cổ điển: khi xung đột họ chỉ thử lại.

Ví dụ: Hàng đợi ở hộ thu ngân: với lock bạn đứng chờ. Trong lock-free bạn đến, thấy đang có người, rồi thử lại ngay — không có việc «xếp hàng» chung.

Wait-Free (không chờ)

Bản chất: Một bảo đảm mạnh hơn: mỗi luồng sẽ tiến lên trong một số hữu hạn bước của chính nó, bất kể các luồng khác làm gì. Không ai «quay vòng» vô hạn.

Khác biệt: Trong lock-free một luồng có thể lặp lại thao tác vô hạn do xung đột; trong wait-free thì điều đó không xảy ra.

Thực tế: Triển khai wait-free khó hơn nhiều, nên thường gặp hơn là lock-free hoặc các cách tiếp cận lai.

2. Cách các collection Concurrent đạt được an toàn cho luồng

Khối xây dựng chính của thuật toán lock-free là các phép toán nguyên tử ở mức CPU. Trong .NET ta dùng lớp System.Threading.Interlocked để truy cập chúng.

Phép toán Interlocked

Các phép toán nguyên tử nhanh trên kiểu nguyên thủy (int, long): ví dụ Interlocked.Increment, Interlocked.Decrement, Interlocked.CompareExchange.

Ví dụ: Interlocked.Increment(ref value) — tăng nguyên tử; Interlocked.CompareExchange(ref location, value, comparand) — so sánh và nếu khớp thì cập nhật một cách nguyên tử.

CAS (Compare-And-Swap) — So sánh-và-Đổi

Phép CAS được hiện thực trong .NET như Interlocked.CompareExchange. Logic chung:

  1. Đọc giá trị hiện tại của biến.
  2. Tính giá trị mới dựa trên giá trị đã đọc.
  3. Cố gắng ghi nó chỉ khi biến vẫn bằng giá trị ban đầu. Nếu không — lặp lại thử.

Ví dụ: bộ đếm lock-free đơn giản dùng Interlocked

using System.Threading;
using System.Threading.Tasks;

class CounterExample
{
    static int regularCounter = 0;
    static int interlockedCounter = 0;

    static void IncrementRegular(int iterations)
    {
        for (int i = 0; i < iterations; i++)
        {
            regularCounter++; // Không an toàn cho đa luồng!
        }
    }

    static void IncrementInterlocked(int iterations)
    {
        for (int i = 0; i < iterations; i++)
        {
            Interlocked.Increment(ref interlockedCounter); // Nguyên tử!
        }
    }
}

//Trong Main:
Task t1 = Task.Run(() => IncrementRegular(500_000));
Task t2 = Task.Run(() => IncrementRegular(500_000));
Task.WaitAll(t1, t2);
Console.WriteLine($"Bộ đếm thường: {regularCounter}"); // hầu như luôn nhỏ hơn 1_000_000

regularCounter = 0; // Reset cho test tiếp theo
t1 = Task.Run(() => IncrementInterlocked(500_000));
t2 = Task.Run(() => IncrementInterlocked(500_000));
Task.WaitAll(t1, t2);
Console.WriteLine($"Bộ đếm Interlocked: {interlockedCounter}"); // Sẽ đúng 1_000_000

Phương thức Interlocked.Increment đảm bảo tính nguyên tử của phép tăng: dữ liệu không bị mất ngay cả khi nhiều luồng cùng truy cập.

3. Tại sao điều này quan trọng cho khả năng mở rộng và hiệu năng

Giảm overhead: các khoá cổ điển (lock) có thể gây chuyển đổi ngữ cảnh và chờ trong kernel OS. Lock-free giảm tối đa chi phí đó.

Không có deadlock: các luồng không chờ nhau — không có chỗ để deadlock hình thành.

Khả năng mở rộng tốt hơn: trên hệ đa nhân các luồng ít can nhiễu lẫn nhau hơn, không có «nút thắt cổ chai» do một khoá chung.

Độ phản hồi cao hơn: không ai «treo» vì chờ kéo dài.

Cái nhìn nhanh vào cấu trúc bên trong của ConcurrentQueue<T>

Đơn giản hóa: queue gồm các segment liên kết. Khi Enqueue một luồng sẽ đẩy «đuôi» một cách nguyên tử qua CompareExchange; khi TryDequeue — nó dịch «đầu» nguyên tử chỉ khi chưa bị thay đổi. Thực tế phức tạp hơn (xử lý ABA và GC), nhưng mấu chốt là dùng các phép toán nguyên tử thay vì các khoá nặng.

4. Hiệu năng của các collection Concurrent

So sánh hiệu năng với lock trên các collection bình thường

Khi cạnh tranh thấp, khác biệt không lớn, thậm chí đôi khi dùng lock trên collection bình thường có thể tương đương. Nhưng khi cạnh tranh cao các collection Concurrent thường nhanh hơn đáng kể vì không có chờ khoá chung.

Ví dụ: so sánh (ý tưởng, không chạy)

using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Diagnostics; // Cho Stopwatch
using System.Threading.Tasks;

class PerformanceTest
{
    static List<int> regularList = new List<int>();
    static ConcurrentQueue<int> concurrentQueue = new ConcurrentQueue<int>();
    static object lockObject = new object();

    const int Iterations = 1_000_000;
    const int NumTasks = 4; // Số tác vụ song song

    public static void RunTests()
    {
        Console.WriteLine("Test hiệu năng (thêm phần tử):");

        // Test với List bình thường và lock
        regularList.Clear();
        Stopwatch sw = Stopwatch.StartNew();
        Parallel.For(0, NumTasks, (i) =>
        {
            for (int j = 0; j < Iterations / NumTasks; j++)
            {
                lock (lockObject)
                {
                    regularList.Add(j);
                }
            }
        });
        sw.Stop();
        Console.WriteLine($"List với lock: {sw.ElapsedMilliseconds} ms. Count: {regularList.Count}");

        // Test với ConcurrentQueue
        concurrentQueue.Clear();
        sw = Stopwatch.StartNew();
        Parallel.For(0, NumTasks, (i) =>
        {
            for (int j = 0; j < Iterations / NumTasks; j++)
            {
                concurrentQueue.Enqueue(j);
            }
        });
        sw.Stop();
        Console.WriteLine($"ConcurrentQueue: {sw.ElapsedMilliseconds} ms. Count: {concurrentQueue.Count}");
        // Mong đợi ConcurrentQueue nhanh hơn đáng kể khi NumTasks > 1
    }
}

Kết luận: Nếu bạn thấy lock quanh các collection, đó thường là dấu hiệu nên chuyển sang các tương đương Concurrent.

6. Các chi tiết hữu ích

Ảnh hưởng của contention tới hiệu năng

Contention — khi nhiều luồng cùng truy cập một tài nguyên. Càng nhiều cạnh tranh thì càng nhiều chờ đợi và hiệu năng càng kém.

Các collection Concurrent được thiết kế để giảm contention: ví dụ ConcurrentBag<T> dùng storage theo luồng, còn ConcurrentDictionary<TKey, TValue> dùng khoá phân đoạn (striped locking).

Chìa khóa hiệu năng — giảm contention: nếu có thể, tách dữ liệu giữa các luồng hoặc dùng nhiều collection.

Chọn collection phù hợp cho từng kịch bản

Collection Thứ tự Khi nào dùng Khi nào không dùng
ConcurrentQueue<T>
FIFO (First-In, First-Out) Hàng đợi tác vụ, logging, xử lý bất đồng bộ, Producer-Consumer. Nếu thứ tự không quan trọng, cần LIFO hoặc cần giới hạn kích thước với khoá.
ConcurrentStack<T>
LIFO (Last-In, First-Out) Lịch sử thao tác (Undo/Redo), duyệt đồ thị (DFS), pool đối tượng ưu tiên «mới nhất». Nếu FIFO là bắt buộc hoặc cần ổn định thứ tự.
ConcurrentBag<T>
Không đảm bảo Pool đối tượng, khi producer và consumer thường là cùng một luồng; kịch bản TPL cần locality. Nếu thứ tự phần tử quan trọng.
ConcurrentDictionary<TKey, TValue>
Không Cache, session người dùng, đếm thống kê, tổng hợp song song. Nếu bạn không cần dictionary.
BlockingCollection<T> (trên ConcurrentQueue) FIFO (hoặc collection cơ sở khác) Producer-Consumer với các thao tác chặn và giới hạn kích thước, kết thúc tiện lợi. Nếu không cần thao tác chặn hoặc giới hạn kích thước.

7. Mẹo tối ưu

Tránh gọi ToArray() thường xuyên ở chỗ «nóng»

ToArray() tạo bản sao toàn bộ collection — tốn bộ nhớ và thời gian. Dùng chỉ khi cần «ảnh chụp» và càng ít càng tốt. Để lấy số lượng thì dùng Count (nhớ rằng đó là ảnh chụp tại thời điểm gọi).

Cẩn thận khi duyệt các collection Concurrent

Iterator không đảm bảo ổn định khi có sửa đổi song song: có thể bỏ sót phần tử hoặc thấy trạng thái không nhất quán. Để có view ổn định thì chụp ảnh trước bằng ToArray().

// Tệ: có thể bỏ sót phần tử hoặc thấy thay đổi trong khi duyệt
foreach (var item in myConcurrentQueue) { /* ... */ }

// Tốt: duyệt trên ảnh chụp cố định
var snapshot = myConcurrentQueue.ToArray();
foreach (var item in snapshot) { /* ... */ }

Giảm «lưu lượng» qua collection

Gom nhóm task/dữ liệu: ít lần gọi Add/Take — ít khả năng xảy ra contention. Ví dụ thay vì 1000 tin nhắn riêng lẻ — gửi một «gói» 1000.

Theo dõi nguồn gây cạnh tranh

Nếu thấy suy giảm, đo xem đâu là điểm có contention lớn nhất. Có thể thay đổi thiết kế để các luồng làm việc với dữ liệu cục bộ hoặc các collection khác nhau.

2
Nhiệm vụ
C# SELF, mức độ, bài học
Đã khóa
Bộ đếm an toàn luồng sử dụng Interlocked
Bộ đếm an toàn luồng sử dụng Interlocked
1
Khảo sát/đố vui
, cấp độ , bài học
Không có sẵn
Concurrent-collection
Bộ sưu tập thread-safe
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION