CAS 的基本概念
CAS(Compare-and-Swap)是一種多執行緒併發程式設計中常用的原子操作,用於實現多執行緒間的同步和互斥訪問。 它操作通常包含三個引數:一個記憶體地址(通常是一個共享變數的地址)、期望的舊值和新值。
CompareAndSwap(記憶體地址,期望的舊值,新值)
CAS 操作會比較記憶體地址處的值與期望的舊值是否相等,如果相等,則將新值寫入該記憶體地址; 如果不相等,則不進行任何操作。這個比較和交換的操作是一個原子操作,不會被其他執行緒中斷。
CAS 通常是透過硬體層面的CPU指令實現的,其原子性是由硬體保證的。具體的實現方式根據環境會有所不同。
CAS 操作通常會有一個返回值,用於表示操作是否成功。返回結果可能是true或false,也可能是記憶體地址處的舊值。
相比於傳統的鎖機制,CAS 有一些優勢:
-
原子性:CAS 操作是原子的,不需要額外的鎖來保證多執行緒環境下的資料一致性,避免了鎖帶來的效能開銷和競爭條件。
-
無阻塞:CAS 操作是無阻塞的,不會因為資源被鎖定而導致執行緒的阻塞和上下文切換,提高了系統的併發性和可伸縮性。
-
適用性:CAS 操作可以應用於廣泛的資料結構和演算法,如自旋鎖、計數器、佇列等,使得它在實際應用中具有較大的靈活性和適用性。
C# 中如何使用 CAS
在 C# 中,我們可以使用 Interlocked 類來實現 CAS 操作。
Interlocked 類提供了一組 CompareExchange 的過載方法,用於實現不同型別的資料的 CAS 操作。
public static int CompareExchange(ref int location1, int value, int comparand);
public static long CompareExchange(ref long location1, long value, long comparand);
// ... 省略其他過載方法
public static object CompareExchange(ref object location1, object value, object comparand);
public static T CompareExchange<T>(ref T location1, T value, T comparand) where T : class;
CompareExchange 方法將 location1 記憶體地址處的值與 comparand 比較,如果相等,則將 value 寫入 location1 記憶體地址處,否則不進行任何操作。
該方法返回 location1 記憶體地址處的值。
透過判斷方法返回值與 comparand 是否相等,我們就可以知道 CompareExchange 方法是否執行成功。
演算法示例
在使用 CAS 實現無鎖演算法時,通常我們不光是為了比較和更新一個資料,還需要在更新成功後進行下一步的操作。結合 while(true) 迴圈,我們可以不斷地嘗試更新資料,直到更新成功為止。
虛擬碼如下:
while (true)
{
// 讀取資料
oldValue = ...;
// 計算新值
newValue = ...;
// CAS 更新資料
result = CompareExchange(ref location, newValue, oldValue);
// 判斷 CAS 是否成功
if (result == oldValue)
{
// CAS 成功,執行後續操作
break;
}
}
在複雜的無鎖演算法中,因為每一步操作都是獨立的,連續的操作並非原子,所以我們不光要藉助 CAS,每一步操作前都應判斷是否有其他執行緒已經修改了資料。
示例1:計數器
下面是一個簡單的計數器類,它使用 CAS 實現了一個執行緒安全的自增操作。
public class Counter
{
private int _value;
public int Increment()
{
while (true)
{
int oldValue = _value;
int newValue = oldValue + 1;
int result = Interlocked.CompareExchange(ref _value, newValue, oldValue);
if (result == oldValue)
{
return newValue;
}
}
}
}
CLR 底層原始碼中,我們也會經常看到這樣的程式碼,比如 ThreadPool 增加執行緒時的計數器。
https://github.com/dotnet/runtime/blob/release/6.0/src/libraries/System.Private.CoreLib/src/System/Threading/ThreadPoolWorkQueue.cs#L446
internal void EnsureThreadRequested()
{
//
// If we have not yet requested #procs threads, then request a new thread.
//
// CoreCLR: Note that there is a separate count in the VM which has already been incremented
// by the VM by the time we reach this point.
//
int count = _separated.numOutstandingThreadRequests;
while (count < Environment.ProcessorCount)
{
int prev = Interlocked.CompareExchange(ref _separated.numOutstandingThreadRequests, count + 1, count);
if (prev == count)
{
ThreadPool.RequestWorkerThread();
break;
}
count = prev;
}
}
示例2:佇列
下面是一個簡單的佇列類,它使用 CAS 實現了一個執行緒安全的入隊和出隊操作。相較於上面的計數器,這裡的操作更加複雜,我們每一步都需要考慮是否有其他執行緒已經修改了資料。
這樣的演算法有點像薛定諤的貓,你不知道它是死是活,只有當你試圖去觀察它的時候,它才可能會變成死或者活。
public class ConcurrentQueue<T>
{
// _head 和 _tail 是兩個偽節點,_head._next 指向佇列的第一個節點,_tail 指向佇列的最後一個節點。
// _head 和 _tail 會被多個執行緒修改和訪問,所以要用 volatile 修飾。
private volatile Node _head;
private volatile Node _tail;
public ConcurrentQueue()
{
_head = new Node(default);
// _tail 指向 _head 時,佇列為空。
_tail = _head;
}
public void Enqueue(T item)
{
var node = new Node(item);
while (true)
{
Node tail = _tail;
Node next = tail._next;
// 判斷給 next 賦值的這段時間,是否有其他執行緒修改過 _tail
if (tail == _tail)
{
// 如果 next 為 null,則說明從給 tail 賦值到給 next 賦值這段時間,沒有其他執行緒修改過 tail._next,
if (next == null)
{
// 如果 tail._next 為 null,則說明從給 tail 賦值到這裡,沒有其他執行緒修改過 tail._next,
// tail 依舊是佇列的最後一個節點,我們就可以直接將 node 賦值給 tail._next。
if (Interlocked.CompareExchange(ref tail._next, node, null) == null)
{
// 如果_tail == tail,則說明從上一步 CAS 操作到這裡,沒有其他執行緒修改過 _tail,也就是沒有其他執行緒執行過 Enqueue 操作。
// 那麼當前執行緒 Enqueue 的 node 就是佇列的最後一個節點,我們就可以直接將 node 賦值給 _tail。
Interlocked.CompareExchange(ref _tail, node, tail);
break;
}
}
// 如果 next 不為 null,則說明從給 tail 賦值到給 next 賦值這段時間,有其他執行緒修改過 tail._next,
else
{
// 如果沒有其他執行緒修改過 _tail,那麼 next 就是佇列的最後一個節點,我們就可以直接將 next 賦值給 _tail。
Interlocked.CompareExchange(ref _tail, next, tail);
}
}
}
}
public bool TryDequeue(out T item)
{
while (true)
{
Node head = _head;
Node tail = _tail;
Node next = head._next;
// 判斷 _head 是否被修改過
// 如果沒有被修改過,說明從給 head 賦值到給 next 賦值這段時間,沒有其他執行緒執行過 Dequeue 操作。
if (head == _head)
{
// 如果 head == tail,說明佇列為空
if (head == tail)
{
// 雖然上面已經判斷過佇列是否為空,但是在這裡再判斷一次
// 是為了防止在給 tail 賦值到給 next 賦值這段時間,有其他執行緒執行過 Enqueue 操作。
if (next == null)
{
item = default;
return false;
}
// 如果 next 不為 null,則說明從給 tail 賦值到給 next 賦值這段時間,有其他執行緒修改過 tail._next,也就是有其他執行緒執行過 Enqueue 操作。
// 那麼 next 就可能是佇列的最後一個節點,我們嘗試將 next 賦值給 _tail。
Interlocked.CompareExchange(ref _tail, next, tail);
}
// 如果 head != tail,說明佇列不為空
else
{
item = next._item;
if (Interlocked.CompareExchange(ref _head, next, head) == head)
{
// 如果 _head 沒有被修改過
// 說明從給 head 賦值到這裡,沒有其他執行緒執行過 Dequeue 操作,上面的 item 就是佇列的第一個節點的值。
// 我們就可以直接返回。
break;
}
// 如果 _head 被修改過
// 說明從給 head 賦值到這裡,有其他執行緒執行過 Dequeue 操作,上面的 item 就不是佇列的第一個節點的值。
// 我們就需要重新執行 Dequeue 操作。
}
}
}
return true;
}
private class Node
{
public readonly T _item;
public Node _next;
public Node(T item)
{
_item = item;
}
}
}
我們可以透過以下程式碼來進行測試
using System.Collections.Concurrent;
var queue = new ConcurrentQueue<int>();
var results = new ConcurrentBag<int>();
int dequeueRetryCount = 0;
var enqueueTask = Task.Run(() =>
{
// 確保 Enqueue 前 dequeueTask 已經開始執行
Thread.Sleep(10);
Console.WriteLine("Enqueue start");
Parallel.For(0, 100000, i => queue.Enqueue(i));
Console.WriteLine("Enqueue done");
});
var dequeueTask = Task.Run(() =>
{
Thread.Sleep(10);
Console.WriteLine("Dequeue start");
Parallel.For(0, 100000, i =>
{
while (true)
{
if (queue.TryDequeue(out int result))
{
results.Add(result);
break;
}
Interlocked.Increment(ref dequeueRetryCount);
}
});
Console.WriteLine("Dequeue done");
});
await Task.WhenAll(enqueueTask, dequeueTask);
Console.WriteLine(
$"Enqueue and dequeue done, total data count: {results.Count}, dequeue retry count: {dequeueRetryCount}");
var hashSet = results.ToHashSet();
for (int i = 0; i < 100000; i++)
{
if (!hashSet.Contains(i))
{
Console.WriteLine("Error, missing " + i);
break;
}
}
Console.WriteLine("Done");
輸出結果:
Dequeue start
Enqueue start
Enqueue done
Dequeue done
Enqueue and dequeue done, total data count: 100000, dequeue retry count: 10586
Done
上述的 retry count 為 797,說明在 100000 次的 Dequeue 操作中,有 10586 次的 Dequeue 操作需要重試,那是因為在 Dequeue 操作中,可能暫時沒有資料可供 Dequeue,需要等待其他執行緒執行 Enqueue 操作。
當然這個 retry count 是不穩定的,因為在多執行緒環境下,每次執行的結果都可能不一樣。
總結
CAS 操作是一種樂觀鎖,它假設沒有其他執行緒修改過資料,如果沒有修改過,那麼就直接修改資料,如果修改過,那麼就重新獲取資料,再次嘗試修改。
在藉助 CAS 實現較為複雜的資料結構時,我們不光要依靠 CAS 操作,還需要注意每次操作的資料是否被其他執行緒修改過,考慮各個可能的分支,以及在不同的分支中,如何處理資料。
歡迎關注個人技術公眾號