[英]A readers/writer lock… without having a lock for the readers?
我覺得這可能是一種非常普遍和常見的情況,其中存在眾所周知的無鎖解決方案。
簡而言之,我希望有像讀者/作者鎖這樣的方法,但這並不要求讀者獲得鎖,因此可以獲得更好的平均性能。
相反,讀者會使用一些原子操作(128 位 CAS),而編寫者會使用互斥鎖。 我將擁有數據結構的兩個副本,一個用於正常成功查詢的只讀副本,以及一個在互斥鎖保護下更新的相同副本。 將數據插入可寫副本后,我們將其設為新的可讀副本。 舊的可讀副本然后依次插入,一旦所有待處理的讀者都讀完它,寫者旋轉剩余的讀者數量直到其為零,然后依次修改它,最后釋放互斥體。
或類似的東西。
沿着這些思路存在嗎?
如果您的數據適合 64 位值,則大多數系統都可以以原子方式廉價地讀取/寫入,因此只需使用std::atomic<my_struct>
。
對於小的和/或不經常寫入的數據,有幾種方法可以使讀取器對共享數據真正只讀,而不必對共享計數器或任何東西執行任何原子 RMW 操作。 這允許讀取端擴展到多個線程,而無需讀取器相互競爭(與 x86 上使用lock cmpxchg16b
或采用 RWlock 的 128 位原子讀取不同)。
理想情況下,只是通過atomic<T*>
指針 (RCU) 的額外間接級別,或者只是額外的負載 + 比較和分支 (SeqLock); 沒有原子 RMW 或 memory 屏障比讀取端的 acq/rel 或其他任何東西都強。
這可能適用於許多線程非常頻繁地讀取的數據,例如由計時器中斷更新但在整個地方讀取的時間戳。 或者一個通常永遠不會改變的配置設置。
如果您的數據更大和/或更頻繁地更改,則其他答案中建議的策略之一要求讀者仍然對某事采取 RWlock 或原子地增加計數器將更合適。 這不會完美地擴展,因為每個讀者仍然需要獲得包含鎖或計數器的共享緩存行的獨占所有權,以便它可以修改它,但是沒有免費午餐這樣的東西。
聽起來您正在發明 RCU (讀取復制更新),您可以在其中更新指向新版本的指針。
但請記住,無鎖讀取器可能會在加載指針后停止,因此您遇到了釋放問題。 這是 RCU 的難點。 在 kernel 中,可以通過設置同步點來解決,在該同步點您知道沒有超過某個時間 t 的閱讀器,因此可以釋放舊版本。 有一些用戶空間實現。 https://en.wikipedia.org/wiki/Read-copy-update和https://lwn.net/Articles/262464/ 。
對於 RCU,更改的頻率越低,您可以證明復制的數據結構越大。 例如,即使是一個中等大小的樹,如果它只由管理員交互更改,而讀者在數十個內核上運行,所有這些內核都在並行檢查某些東西,那么它也是可行的。 例如 kernel 配置設置是 RCU 在 Linux 中很棒的一件事。
如果您的數據很小(例如,32 位機器上的 64 位時間戳),另一個不錯的選擇是 SeqLock。 讀取器在將數據非原子復制到私有緩沖區之前/之后檢查序列計數器。 如果序列計數器匹配,我們就知道沒有撕裂。 (編寫器使用單獨的互斥體相互排除每個)。 使用 32 位原子實現 64 位原子計數器/ 如何使用 c++11 原子庫實現 seqlock 鎖。
在 C++ 中編寫一些可以有效編譯為可能會撕裂的非原子副本的東西有點像 hack,因為這不可避免地是數據競爭 UB。 (除非您對每個塊分別使用帶有mo_relaxed
的std::atomic<long>
,但是您正在使編譯器無法使用movdqu
或其他東西一次復制 16 個字節。)
SeqLock 使讀取器在每次讀取時都復制整個內容(或者理想情況下只是將其加載到寄存器中),因此它僅適用於小型結構或 128 位 integer 或其他東西。 但是對於少於 64 字節的數據,它可能非常好,如果您有很多讀取器和不頻繁的寫入,則比讓讀取器對 128 位數據使用lock cmpxchg16b
更好。
但是,它不是無鎖的:在修改 SeqLock 時休眠的編寫器可能會讓讀者無限期地重試。 對於小型 SeqLock,window 很小,顯然您希望在執行第一次序列計數器更新之前准備好所有數據,以最大限度地減少在更新過程中中斷暫停寫入器的機會。
最好的情況是只有 1 個寫者,所以它不必做任何鎖定; 它知道沒有其他東西會修改序列計數器。
事實證明,我正在考慮的雙結構解決方案與http://concurrencyfreaks.blogspot.com/2013/12/left-right-concurrency-control.html有相似之處
這是我想到的具體數據結構和偽代碼。
我們分配了一些名為 MyMap 的任意數據結構的兩個副本,並且一組三個指針中的兩個指針指向這兩個。 最初,一個由 achReadOnly[0].pmap 指向,另一個由 pmapMutable 指向。
關於 achReadOnly 的簡要說明:它有一個正常的 state 和兩個臨時狀態。 正常的 state 將是(單元格 0/1 的 WLOG):
achReadOnly = { { pointer to one data structure, number of current readers },
{ nullptr, 0 } }
pmapMutable = pointer to the other data structure
當我們完成對“另一個”的變異后,我們將它存儲在數組的未使用槽中,因為它是下一代只讀的,讀者可以開始訪問它。
achReadOnly = { { pointer to one data structure, number of old readers },
{ pointer to the other data structure, number of new readers } }
pmapMutable = pointer to the other data structure
然后,作者清除指向“the one”的指針,即上一代只讀,迫使讀者將 go 指向下一代。 我們將其移至 pmapMutable。
achReadOnly = { { nullptr, number of old readers },
{ pointer to the other data structure, number of new readers } }
pmapMutable = pointer to the one data structure
然后編寫器旋轉一些老讀者來命中一個(本身),此時它可以接收相同的更新。 該 1 被 0 覆蓋以清理以准備繼續前進。 雖然實際上它可能會被弄臟,因為它在被覆蓋之前不會被引用。
struct CountedHandle {
MyMap* pmap;
int iReaders;
};
// Data Structure:
atomic<CountedHandle> achReadOnly[2];
MyMap* pmapMutable;
mutex_t muxMutable;
data Read( key ) {
int iWhich = 0;
CountedHandle chNow, chUpdate;
// Spin if necessary to update the reader counter on a pmap, and/or
// to find a pmap (as the pointer will be overwritten with nullptr once
// a writer has finished updating the mutable copy and made it the next-
// generation read-only in the other slot of achReadOnly[].
do {
chNow = achReadOnly[ iWhich ];
if ( !chNow .pmap ) {
iWhich = 1 - iWhich;
continue;
}
chUpdate = chNow;
chNow.iReaders++;
} while ( CAS( ach[ iWhich ], chNow, chUpdate ) fails );
// Now we've found a map, AND registered ourselves as a reader of it atomicly.
// Importantly, it is impossible any reader has this pointer but isn't
// represented in that count.
if ( data = chnow.pmap->Find( key ) ) {
// Deregister ourselves as a reader.
do {
chNow = achReadOnly[ iWhich ];
chUpdate = chNow;
chNow.iReaders--;
} while ( CAS( ach[ iWhich ], chNow, chUpdate ) fails );
return data;
}
// OK, we have to add it to the structure.
lock muxMutable;
figure out data for this key
pmapMutable->Add( key, data );
// It's now the next-generation read-only. Put it where readers can find it.
achReadOnly[ 1 - iWhich ].pmap = pmapMutable;
// Prev-generation readonly is our Mutable now, though we can't change it
// until the readers are gone.
pmapMutable = achReadOnly[ iWhich ].pmap;
// Force readers to look for the next-generation readonly.
achReadOnly[ iWhich ].pmap = nullptr;
// Spin until all readers finish with previous-generation readonly.
// Remember we added ourselves as reader so wait for 1, not 0.
while ( achReadOnly[ iWhich ].iReaders > 1 }
;
// Remove our reader count.
achReadOnly[ iWhich ].iReaders = 0;
// No more readers for previous-generation readonly, so we can now write to it.
pmapMutable->Add( key, data );
unlock muxMutable;
return data;
}
我遇到的解決方案:
每個線程都有一個thread_local
的數據結構副本,可以隨意查詢,無需加鎖。 任何時候你找到你的數據,很好,你已經完成了。
如果您沒有找到您的數據,那么您將獲得主副本的互斥鎖。
這可能會有許多來自其他線程的新插入(可能包括您需要的數據。)。 檢查它是否有您的數據,如果沒有插入它。
最后,將所有最近的更新(包括您需要的數據條目)復制到您自己的thread_local
副本中。 釋放互斥鎖並完成。
讀者可以整天並行閱讀,即使正在更新,也無需加鎖。 僅在寫入時(或有時在趕上時)才需要鎖。 這種通用方法適用於廣泛的底層數據結構。 量子點
如果您有很多線程使用此結構,那么擁有許多thread_local
索引聽起來內存效率低下。
但是,索引找到的數據,如果是只讀的,只需要一個副本,被許多索引引用。 (幸運的是,這就是我的情況。)
此外,許多線程可能不會隨機訪問所有條目; 也許有些人只需要幾個條目,很快就會達到最終的 state ,他們的結構的本地副本可以在它增長很多之前找到所有需要的數據。 然而,許多其他線程可能根本沒有提到這一點。 (幸運的是,這就是我的情況。)
最后,為了“復制所有最近的更新”,如果添加到結構中的所有新數據都被推到向量的末尾,那么假設你的本地副本中有 4000 個條目,主副本有4020,您可以用幾個機器周期定位您需要添加的 20 個對象。 (幸運的是,這就是我的情況。)
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.