共享內存無鎖隊列的實現

共享內存無鎖隊列的實現

躲在樹上的數據庫 2017-11-06 211標籤: 消息隊列 , 無鎖隊列

作者:範健

導語: 共享內存無鎖隊列是老調重彈了,相關的實現網上都能找到很多。但看了公司內外的很多實現,都有不少的問題,於是自己做了重新實現。主要是考慮了一些異常情況加強健壯性,並且考慮了C++11的內存模型。

爲什麼需要共享內存無鎖隊列?

爲了便於查找定位問題,需要做一個日誌收集跟蹤系統,每個業務模塊都需要調用SDK輸出格式化的本地日誌並將日誌發送到遠端。

爲了避免發送日誌阻塞業務,典型的做法是業務線程將日誌寫入隊列,另一個線程異步地從隊列中讀取數據併發送。考慮到IO性能,且日誌數據能容忍小概率的丟失,所以隊列不應該是在磁盤上。又因爲業務模塊可能是多線程模式也可能是多進程模式,所以隊列應該是在共享內存中。

簡單的做法是,對隊列的讀寫都加鎖,但這樣無疑會導致高併發下性能瓶頸就在這把鎖上。所以我們需要無鎖隊列。看了公司內外很多版本的無鎖隊列實現,多多少少都有些問題,所以自己重新實現了一個版本。

環形數組

大部分無鎖隊列都是用環形數組實現的,簡單高效,這裏也不例外。假設隊列長度爲queue_len,用read_index表示可讀的位置,用write_index表示可寫的位置。

每次修改read_index或write_index的時候都需要將其歸一化:

read_index %= queue_len

隊列已使用空間used_len的計算爲:

write_index >= read_index ?
  write_index - read_index : queue_len - read_index + write_index

判斷隊列IsEmpty的條件爲:

read_index == write_index

如果不做特殊處理,判斷隊列IsFull的條件和IsEmpty的條件一樣,從而難以區分。所以我們將隊列可寫入長度設爲queue_len-1。這樣判斷長度爲write_len的數據是否可以寫入的條件爲:

// 注意是 < 而不是 <= 
used_len + write_len < queue_len

一寫一讀

先來考慮一寫一讀的場景,實現起來最簡單。

寫操作:先判斷是否可以寫入,如果可以,則先寫數據,寫完數據後再修改write_index。

讀操作:先判斷是否可以讀取used_len > 0,如果可以,則先讀數據,讀完再修改read_index。

因爲read_index和write_index都只會有一個地方去寫,所以其實不需要加鎖也不需要原子操作,直接修改即可。需要注意讀寫數據的時候都需要考慮遇到數組尾部的情況。

多寫一讀

再來考慮複雜些的多寫一讀的場景。因爲多個生產者都會修改write_index,所以在不加鎖的情況下必須使用原子操作,筆者使用的是GCC內置原子操作函數:

// __sync系列的內置函數在C++11之後已經過時,不建議使用
// C++11的std::atomic函數就是用__atomic系列內置函數實現的,所以也考慮了C++11提出的內存模型
// 該函數在*ptr == *expected的時候,將*ptr = desired,並返回true,否則返回false,並將*expected = *ptr
// 最後兩個參數分別表示修改成功和失敗時使用的內存模型,後面會講
bool __atomic_compare_exchange_n (type *ptr, type *expected, type desired, bool weak, int success_memorder, int failure_memorder);

一種錯誤實現:

有的實現在寫入過程中對write_index使用了多次原子操作,比如先原子增加write_index,再寫入數據,如果寫入失敗,再原子減小write_index,看起來每次操作都是原子的,但多個原子操作連在一起就不是原子操作了,整個寫入過程中對write_index應當只有一次原子操作。

常見的錯誤實現:

1 .先讀取write_index,判斷新的數據是否有足夠的空間可以寫入。
1.1 如果沒有足夠空間則返回隊列滿。

2 .如果有足夠的空間,則準備寫入。
2.1 一寫的時候,是先寫數據再改write_index。多寫的時候爲了避免同時寫到同一片內存,需要先申請空間再寫入數據。即先原子增加write_index,如果成功,再寫入數據。
2.2 爲了避免在生產者還未寫完數據的時候,消費者就嘗試讀取,所以需要個同步機制告訴消費者數據正在寫入中。比如頭部預留一個字節,初始爲0表示正在寫入,寫完數據後再改爲1表示寫入完成。頭部中一般還有2字節表示數據長度。

3 .消費者發現used_len > 0即可嘗試讀取。
3.1 如果首字節爲0,表示數據正在寫入,等待。
3.2 如果首字節不爲0,表示數據已寫完,可以讀取。

4 .消費者讀取數據後,需要將read_index前移到合適的位置,且因爲只有一個消費者,這裏無需使用原子操作。

這種實現看似OK,其實也有問題。如果生產者在修改write_index之後,在修改頭部首字節爲1之前,這段時間內crash的話,就會導致消費者永遠停留在等待生產者寫完的狀態上,且這個狀態無法自動恢復。

我的優化一:

  1. 消費者發現頭部首字節爲0,則等待,但最多等待一段時間比如5ms。
  2. 在寫入數據限制了最大長度的前提下,以現代計算機的速度,從修改write_index然後copy數據最後修改頭部首字節爲1,這段時間是非常快的,遠小於5ms。
  3. 如果等待5ms後,發現首字節還是0,則認爲該生產者crash了,根據頭部中的長度信息,向前跳過這個非法數據塊。

但如果生產者還沒來得及寫入數據長度就crash了呢?就想跳過非法數據塊也不知道該跳多少了。

我的優化二:

1 .將隊列分成N個定長block,定義如下:

struct Block {
 union {
     struct {
         bool m_used;
         uint8_t m_blk_cnt;
         uint8_t m_blk_idx;
         uint16_t m_blk_len;
     };
     char m_head_reserved[8];
 };
 char m_data[kBlockDataSize];

 bool CanUsed(uint8_t expected_blk_idx) const {
     return m_used && expected_blk_idx == m_blk_idx
         && m_blk_cnt <= kMaxBlockCount
         && m_blk_idx < m_blk_cnt
         && m_blk_len <= kBlockDataSize;
 }
};

2 .生產者寫數據時先計算需要的blk_cnt,再原子地將write_index前移blk_cnt。寫數據的時候第一個block最後寫,每個block內部依然是最後寫頭部首字節m_used = true。

3 .當等待5ms後發現m_used還是false,認爲寫入者crash之後,就可以以block爲單位向前跳躍,直到跳到一個合法block或者沒有可以讀取的數據爲止。合法block判斷條件爲blk.CanUsed(0)。

這樣就算生產者在任意時刻crash,消費者都有能力自動恢復,找到下一個合法block。但如果消費者並沒有真正crash只是因爲某種神祕的原因寫入太慢超過了5ms,怎麼辦?

  1. 首先,因爲消費者已經跳過,所以它這次寫入的數據肯定是不會被消費了,即極小概率會遺漏數據。
  2. 其次,我們考慮更極小概率的情況,只有當生產者慢到隊列循環了完整一輪,其它生產者重新申請到這片block準備寫入,纔會產生數據髒寫。
  3. 再次,就算真的出現數據髒寫,一般頭部的blk_cnt和blk_idx等信息不會對不上,消費者每次消費數據都會通過CanUsed函數檢測,檢測不通過的都會跳過。
  4. 最後,如果說非要考慮極端情況,可以通過在頭部中再加入block_crc和total_crc來校驗數據。筆者考慮到日誌數據容忍這種極小概率的錯亂,所以省略了。

內存模型

看似完美了,真的嗎?其實不然。以上還沒有考慮內存模型。因爲編譯器的優化,實際代碼執行順序不一定是你寫的順序。也就是說雖然我們是先寫數據最後設置m_used = true,但實際執行順序並不一定真的如此,有可能先執行了m_used = true,再執行數據copy,這就亂套了。因此我們需要指定內存模型。關於內存模型推薦參閱文章http://blog.jobbole.com/106516/

1.生產者對於m_used的修改,內存模型應該使用release。保證在這個操作之前的memory accesses不會重排到這個操作之後去,這樣就不會向消費者提前釋放可用信號。

__atomic_store_n(&blk.m_used, true, __ATOMIC_RELEASE);

2 .消費者對於m_used的讀取,內存模型應該使用acquire。保證在這個操作之後的memory accesses不會重排到這個操作之前去,這樣就不會提前讀到生產者還未寫完的數據。

__atomic_load_n(&m_used, __ATOMIC_ACQUIRE);

3 .對write_index的修改,即調用atomic_compare_exchange_n函數,最後兩個參數應該都是ATOMIC_RELAXED,即內存模式使用relaxed,即沒有約束。因爲write_index只是多生產者之間用來做類似互斥的競爭,本來就是靠m_used真正約束生產者和消費者之間的行爲順序。

共享內存

另外一個值得一提的點是,共享內存我使用mmap,而非shmget。因爲擔心一臺機器上部署的程序太多,可能出現共享內存key衝突的情況。萬一出現共享內存衝突,被別的程序寫壞了,就會出現莫名其妙的情況。所以使用mmap指定模塊相關的文件路徑,就不用太擔心了。

需要多讀嗎?

如果再進一步實現多寫多讀,需要對read_index也考慮原子操作,加上稍顯複雜的block檢查跳躍邏輯,實現難度較高。但我們首先該問一個問題,真的需要多讀嗎?

我認爲是不需要的:

  1. 首先,消費者可以批量讀取,一次讀取足夠或者全部的可讀數據。通過對後續業務邏輯的優化,一般單讀都能滿足性能要求。
  2. 其次,可以一讀批量讀取後再做進一步進程內多線程分發,會更加簡單。
  3. 再次,如果單讀真的不能滿足性能要求,說明讀後的業務邏輯非常重,那麼這個時候,性能瓶頸就肯定不會是隊列讀取這裏了,那麼給讀加鎖無疑是更合適的選擇。

有感而發

  1. 要寫出高健壯性的代碼,一定要時刻記得,程序可能會在你的任何一行代碼處因爲bug或者意外crash,不要想當然以爲執行了上一行代碼就一定會執行下一行代碼。crash後重啓是否能正常恢復?
  2. 寫多線程多進程相關的邏輯,涉及到併發操作的時候,要考慮仔細,需不需要加鎖?不加鎖會有什麼問題?
  3. 使用共享內存等共享資源時,更要想到,這資源不是我獨佔的,萬一被有意或無意的篡改了數據該怎麼辦?能否儘量避免被別人篡改?如果被篡改,是否有發現和恢復機制?
  4. 不要以爲你寫的代碼順序就是真正的執行順序,需要考慮內存模型。

轉自:https://cloud.tencent.com/community/article/854927 侵刪

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章