無鎖與等待無關程式設計

ABA 問題(the ABA problem)

/ AY-BEE-AY /

想像你瞥一眼停車格,看到一輛紅車,轉頭去忙別的,再瞥回來,又看到一輛紅車。你斷定什麼都沒變。但在這中間,那輛紅車開走了,停進來一輛「不同」的紅車。在你眼中看起來一模一樣,世界卻已挪動。ABA 問題正是這個陷阱,也是無鎖程式設計中最著名的單一陷阱。名字來自一個值走過 A、再到 B、又回到 A。

為何它會咬人。CAS 重試迴圈假設:若值仍是你讀到的舊值,那麼自你讀取以來什麼都沒變。但 CAS 只檢查位元是否相等——它分辨不出「從未改變」與「變掉又變回來」。具體以一個無鎖堆疊(stack)為例:執行緒一讀到 head = 節點 A,並算出新的 head 應是 A 的後繼。在它的 CAS 執行前,執行緒二彈出 A、彈出 B、釋放 B,再把 A 推回——於是 head 又是 A,但 A 的 next 指標如今指向已被釋放並重用的記憶體。執行緒一的 CAS 看到 head == A、成功,並裝上一個指向垃圾的指標。位元相符,意義不符。

各種修法都在補上原始位元所缺的資訊。標籤式或版本式指標(tagged / versioned pointer)把指標和一個每次改變就遞增的計數器配成一對,使得 A-版本1 與 A-版本3 不再相等——這通常需要雙倍寬度 CAS(在一個原子步驟裡同時比較指標與標籤,例如 x86-64 的 cmpxchg16b)。LL/SC 完全避開 ABA,因為它偵測的是中間的寫入而非值。而更根本的解藥是安全記憶體回收——危害指標(hazard pointer)或 epoch——它保證在另一個執行緒可能還在看 A 時,節點 A 絕不會被釋放並重用,於是那個危險的重用根本不會發生。

/* 一條會騙過無鎖堆疊 CAS 的時間線: */ T1:讀 head = A;打算把 head 設成 A->next(也就是 B) T2:彈出 A、彈出 B、釋放 B、推回 A /* head 又是 A,A->next 已過時 */ T1:CAS(&head, A, B) 成功 /* B 已被釋放!head 現在指向垃圾 */ /* 標籤指標修法:把 {ptr, counter} 一起比較 */ struct tptr { Node *ptr; uintptr_t tag; }; /* 用 CAS 比較整個 16 位元組的一對 */

CAS 看到 A == A 而成功,但它看到的 A 是不同的化身;版本計數器或危害指標能阻止這種無聲的重用。

ABA 本身不算是記憶體損毀的錯誤——它是 CAS 在「位元相等但已不代表同一件事」上成功。它只在記憶體能被釋放並重用時才現形,這正是安全回收是最根本解法的原因。

又称
ABA hazardstale-but-equal hazardABA 危害