Note: see the link below for the English version of this article.

https://duongnt.com/interlocked-synchronization

interlocked-synchronization

Đồng bộ (synchronization) là một phần quan trọng của lập trình đa luồng. Trong .NET standard, ta có nhiều cách để đồng bộ luồng. Trong số đó, có lẽ phương pháp phổ biến nhất là sử dụng lock. Nhưng nếu ta có thể thay lock bằng các hàm trong lớp Interlocked thì ta có thể cải thiện hiệu năng một cách đáng kể.

Các bạn có thể tải toàn bộ code ví dụ trong bài từ link dưới đây.

https://github.com/duongntbk/InterlockedDemo

Một bài toán đơn giản cần tới đồng bộ

Giả sử ta có một danh sách chứa 10,000 số nguyên đầu tiên.

var src = Enumerable.Range(1, 10_000);

Nếu không cẩn thận, khi tính tổng của danh sách này ta có thể mắc sai lầm như dưới đây.

long sum = 0;
Parallel.ForEach(src, n =>
{
    // Các đoạn code khác nếu cần...

    sum += n
});

// Kết quả hầu như chắc chắn sẽ không phải là 50,005,000

Tất nhiên đoạn code trên không thể cho ta kết quả chính xác. Đó là vì khi một thread muốn cập nhật giá trị của sum thì đầu tiên nó phải đọc giá trị hiện tại của sum. Nhưng cùng lúc đó, một thread khác có thể thay đổi giá trị của biến này. Và khi thread thứ nhất ghi giá trị mới vào sum, nó sẽ ghi đè lên giá trị mà thread thứ hai vừa cập nhật.

Đồng bộ luồng sử dụng lock

Như đã nói đến trong phần đầu, ta có thể đồng bộ bước cập nhật sum bằng cách sử dụng lock.

private static readonly object _lockObj = new object();

long sum = 0;
Parallel.ForEach(src, n =>
{
    // Các đoạn code khác nếu cần...

    lock (_lockObj)
    {
        sum += n;
    }
});

// Kết quả sẽ là 50,005,000

Tuy nhiên việc thực hiện lock mỗi khi cần cập nhật sum sẽ ảnh hưởng xấu tới hiệu năng. Hơn thế nữa, nếu ta dùng _lockObj sai cách thì có thể gây ra deadlock.

Cộng hai số một cách nguyên tử bằng Interlocked.Add

Như đã nhắc đến ở trên, lý do ta cần đến đồng bộ là vì một thread có thể thay đổi giá trị của sum trong khi một thread khác đang cập nhật sum nửa chừng. Vì thế, nếu ta có thể đảm bảo bước cập nhật sum là nguyên tử thì việc đồng bộ sẽ trở nên không cần thiết. Ta có thể thực hiện điều này bằng hàm Interlocked.Add. Ta sẽ dùng signature dưới đây.

// Thực hiện hai bước sau một cách nguyên tử:
// - Cộng "value" vào "location1",
// - Ghi giá trị tổng vào biến "location1".
public static long Add(ref long location1, long value);

Đoạn code để tính tổng danh sách bằng Interlocked.Add sẽ như dưới đây.

long sum = 0;
Parallel.ForEach(src, n =>
{
    // Các đoạn code khác nếu cần...

    Interlocked.Add(ref sum, n)
});

// Kết quả sẽ là 50,005,000

So sánh hiệu năng

Dưới đây là so sánh hiệu năng của lockInterlocked.Add.

Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
NoSynchronize 55.01 μs 0.642 μs 0.601 μs 6.7749 0.2441 34 KB
Lock 235.25 μs 10.391 μs 30.638 μs 14.1602 1.4648 74 KB
LockLocalVar 53.87 μs 0.754 μs 0.706 μs 6.3477 0.3052 32 KB
Interlocked 69.77 μs 0.766 μs 0.717 μs 7.3242 0.2441 37 KB
InterlockedLocalVar 53.52 μs 0.526 μs 0.439 μs 6.3477 0.2441 33 KB

Ta có thể thấy rằng khi không dùng thread local variable, Interlocked.Add nhanh hơn 3 lần so với lock. Khi sử dụng thread local variable, lockInterlocked.Add có tốc độ xấp xỉ nhau. Điều này cũng hợp lý, vì khi dùng thread local variable thì ta chỉ phải thực hiện lock một lần cho mỗi thread.

Những hàm khác trong lớp Interlocked

Interlocked.Add không phải là hàm duy nhất trong lớp Interlocked. Trong phần dưới đây ta sẽ xem xét một vài tình huống mà Interlocked có thể giúp ta cải thiện hiệu năng.

Đếm số phần tử trong một danh sách bằng Interlocked.Increment

Hàm Interlocked.Increment có thể cộng 1 vào một biến một cách nguyên tử. Ta sẽ dùng nó để đếm số phần tử thỏa mãn một điều kiện cho trước trong danh sách.

Đoạn code dưới đây đếm số phần tử chẵn trong một danh sách. Nó thực hiện đồng bộ bằng từ khóa lock.

long sum = 0;
Parallel.ForEach(src, n =>
{
    if (n % 2 == 0)
    {
        lock (_lockObj)
        {
            sum++;
        }
    }
});
return sum;

Ta có thể viết lại đoạn code trên để sử dụng Interlocked.Increment.

long sum = 0;
Parallel.ForEach(src, n =>
{
    if (predicate(n))
    {
        Interlocked.Increment(ref sum);
    }
});
return sum;

Từ kết quả so sánh hiệu năng, ta có thể thấy Interlocked.Increment nhanh hơn gấp đôi so với lock.

Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
NoSynchronize 63.43 μs 1.254 μs 1.047 μs 6.5918 0.2441 33 KB
Lock 155.51 μs 3.080 μs 7.199 μs 8.7891 0.4883 45 KB
Interlocked 66.29 μs 0.888 μs 0.741 μs 6.7139 0.2441 34 KB

Kiểm tra flag bằng Interlocked.Exchange

Hàm Interlocked.Exchange có thể thực hiện đồng thời hai bước sau một cách nguyên tử.

  • Gán giá trị mới cho một biến
  • Trả lại giá trị ban đầu.

Giả sử ta không được phép gọi một hàm nào đó đồng thời từ nhiều thread. Dưới đây là cách ta kiểm tra một flag trước khi gọi hàm đó.

Parallel.For(0, load, (i, loop) =>
{
    // Các đoạn code khác nếu cần...

    lock (_lockObj)
    {
        if (!_isSafeBool)
        {
            return;
        }
        else
        {
            _isSafeBool = false;
        }
    }
    DummyDoWork();

    lock (_lockObj)
    {
        _isSafeBool = true;
    }
});

Nếu có thể đổi flag từ bool sang int thì ta có thể viết lại đoạn code trên với Interlocked.Exchange. Ta quy ước nếu biến int có giá trị 1 thì ta được phép gọi hàm, còn nếu nó có giá trị 0 thì một thread khác đang gọi hàm mất rồi.

Parallel.For(0, load, (i, loop) =>
{
    // Các đoạn code khác nếu cần...

    // Gán giá trị 0 cho _isSafe, đồng thời kiểm tra giá trị trước gán.
    // Nếu giá trị trước gán là 1 thì ta có thể gọi hàm.
    if (Interlocked.Exchange(ref _isSafe, 0) == 1)
    {
        DummyDoWork();

        // Nhớ gán _isSafe về lại 1 để các thread khác có thể gọi hàm.
        Interlocked.Exchange(ref _isSafe, 1);
    }
});

Từ kết quả chạy thử, ta có thể thấy Interlocked.Exchange nhanh gấp 7 lần lock.

Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
Lock 185.48 μs 8.815 μs 25.574 μs 0.9766 7 KB
Interlocked 26.70 μs 0.076 μs 0.067 μs 0.6714 4 KB

Tại sao Interlocked lại nhanh như vậy?

Không phải mọi phương pháp đồng bộ đều giống nhau. Trong .NET ta có 3 kiểu đồng bộ.

  • User-mode: chúng sử dụng những lệnh chuyên biệt trong CPU để đồng bộ thread. Nếu một thread không thể sử dụng tài nguyên thì trong lúc đợi được phép dùng tài nguyên đó, thread sẽ vẫn chạy. Vì bước đồng bộ được thực hiện trong phần cứng nên các phương pháp sử dụng user-mode đều rất nhanh. Ví dụ: volatile, Interlocked.
  • Kernel-model: cần tới sự hỗ trợ từ hệ điều hành. Các phương pháp này khiến thread phải chuyển đổi giữa managed code, native user-mode code, và native kernel-mode. Những lần đổi context này sẽ ảnh hưởng lớn tới hiệu năng. Ví dụ: Semaphore, Mutex.
  • Hybrid: khi không có tranh chấp tài nguyên, các phương pháp này nhanh không kém gì các phương pháp dùng user-mode. Chỉ khi nào có tranh chấp tài nguyên thì chúng mới chuyển sang sử dụng kernel-mode. Ví dụ: Monitor, SemaphoreSlim, ReaderWriterLockSlim.

Interlocked là phương pháp thuộc loại user-mode. Vì thế nó cũng có lợi thế về tốc độ giống như các phương pháp sử dụng user-mode khác.

Ngược lại, từ khóa lock thực ra sử dụng Monitor. Điều này được ghi rõ tại đây.

lock (x)
{
    // Code của bạn...
}

Khi x là kiểu reference, đoạn code trên tương đương với đoạn code dưới đây.

object __lockObj = x;
bool __lockWasTaken = false;
try
{
    System.Threading.Monitor.Enter(__lockObj, ref __lockWasTaken);
    // Code của bạn...
}
finally
{
    if (__lockWasTaken) System.Threading.Monitor.Exit(__lockObj);
}

Monitor là phương pháp hybrid. Vì thế khi có tranh chấp tài nguyên, nó sẽ chuyển sang sử dụng kernel-mode và bị chậm lại.

Kết thúc

Mỗi khi phải giải quyết một vấn đề đồng bộ, ta nên thử xem có thể dùng Interlocked thay cho lock hay không. Nhưng ta cũng nên nhớ rằng đây không phải là viên đạn bạc, và sẽ có những tình huống nó không phải là giải pháp phù hợp nhất.

A software developer from Vietnam and is currently living in Japan.

2 Thoughts on “Đồng bộ sử dụng Interlocked trong C#”

Leave a Reply