Note: see the link below for the English version of this article.
https://duongnt.com/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 lock
và Interlocked.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, lock
và Interlocked.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.
hay quá a ah
nhờ quyển Parallel Programming and Concurrency with C# 10 and .NET 6 mà biết đến a hehe
mong a ra thêm nhiều bài viết hơn nữa nhá
Cảm ơn bạn!