前言
刷題刷得頭疼,水篇blog。這個BUG是我大約一個月前,在做15445實現lock_manager的時候遇到的一個很惡劣但很愚蠢的BUG,排查 + 摸魚大概花了我三天的時間,根本原因是我在使用std::unordered_map
做併發的時候考慮不周。但由於這個BUG無法在我的本地復現,只能提交程式碼後再gradescope上看到執行日誌,而且列印的日誌還不能太多,因為gradescope的執行比較慢,列印日誌如果稍微多加一點就會報TIMEOUT,所以著實讓我抓狂了一段時間。最後的解決也很突然,非常有戲劇性,可以考慮拿來水點東西。感覺自己寫blog很拖刷leetcode和背八股的節奏,所以找到實習前可能寫不了太多了。
功能描述
15445 Project4要求我們為bustub
新增併發訪問控制,實現tuple粒度的strict 2PL。簡單來說,一個RID
表示著一個tuple
在磁碟上的位置,因此可以唯一標識tuple
;一個事務就是一個執行緒。事務會併發的對這些tuple
進行讀寫訪問,因此必須要引入lock來對訪問進行同步,而這個Project要求我們將lock的粒度設定為tuple。為了實現這一點,bustub
設定了一個單例的LockManager
(但其實現方法並不是單例的),要求任何事務在訪問某個RID之前,都要向這個LockManager
申請鎖。如果事務進行的是讀訪問操作,要呼叫LockManager
的lock_shared
方法申請S-LOCK(共享鎖,或者說讀鎖),否則操作就是寫操作,要呼叫lock_exclusive
方法申請X-LOCK(獨佔鎖,或者說是寫鎖);如果事務已經獲取了S-LOCK,希望在不釋放S-LOCK的情況下將鎖升級為X-LOCK,則要呼叫lock_upgrade
方法。此外,由於strict 2PL只能保證Serializable Schedule,但無法保證不存在死鎖,因此LockManager
還需要實現死鎖檢測功能(但這不是本篇blog的重點)。LockManager
的宣告如下:
class LockManager {
enum class LockMode { SHARED, EXCLUSIVE, UPGRADE };
enum class RIDLockState { UNLOCKED, S_LOCKED, X_LOCKED };
class LockRequest {
public:
LockRequest(txn_id_t txn_id, LockMode lock_mode)
: txn_id_(txn_id), lock_mode_(lock_mode), granted_(false), aborted_(false) {}
txn_id_t txn_id_;
LockMode lock_mode_;
bool granted_;
bool aborted_;
};
class LockRequestQueue {
public:
LockRequestQueue() = default;
LockRequestQueue(const LockRequestQueue &rhs) = delete;
LockRequestQueue operator=(const LockRequestQueue &rhs) = delete;
// DISALLOW_COPY(LockRequestQueue);
RIDLockState state_;
std::mutex queue_latch_;
std::list<LockRequest> request_queue_;
std::condition_variable cv_; // for notifying blocked transactions on this rid
bool upgrading_ = false;
};
public:
/**
* Creates a new lock manager configured for the deadlock detection policy.
*/
LockManager() {
enable_cycle_detection_ = true;
cycle_detection_thread_ = new std::thread(&LockManager::RunCycleDetection, this);
LOG_INFO("Cycle detection thread launched");
}
~LockManager() {
enable_cycle_detection_ = false;
cycle_detection_thread_->join();
delete cycle_detection_thread_;
LOG_INFO("Cycle detection thread stopped");
}
/*
* [LOCK_NOTE]: For all locking functions, we:
* 1. return false if the transaction is aborted; and
* 2. block on wait, return true when the lock request is granted; and
* 3. it is undefined behavior to try locking an already locked RID in the same transaction, i.e. the transaction
* is responsible for keeping track of its current locks.
*/
/**
* Acquire a lock on RID in shared mode. See [LOCK_NOTE] in header file.
* @param txn the transaction requesting the shared lock
* @param rid the RID to be locked in shared mode
* @return true if the lock is granted, false otherwise
*/
bool LockShared(Transaction *txn, const RID &rid);
/**
* Acquire a lock on RID in exclusive mode. See [LOCK_NOTE] in header file.
* @param txn the transaction requesting the exclusive lock
* @param rid the RID to be locked in exclusive mode
* @return true if the lock is granted, false otherwise
*/
bool LockExclusive(Transaction *txn, const RID &rid);
/**
* Upgrade a lock from a shared lock to an exclusive lock.
* @param txn the transaction requesting the lock upgrade
* @param rid the RID that should already be locked in shared mode by the requesting transaction
* @return true if the upgrade is successful, false otherwise
*/
bool LockUpgrade(Transaction *txn, const RID &rid);
/**
* Release the lock held by the transaction.
* @param txn the transaction releasing the lock, it should actually hold the lock
* @param rid the RID that is locked by the transaction
* @return true if the unlock is successful, false otherwise
*/
bool Unlock(Transaction *txn, const RID &rid);
/**
* Checks if the graph has a cycle, returning the newest transaction ID in the cycle if so.
* @param[out] txn_id if the graph has a cycle, will contain the newest transaction ID
* @return false if the graph has no cycle, otherwise stores the newest transaction ID in the cycle to txn_id
*/
bool HasCycle(txn_id_t *txn_id);
/** @return the set of all edges in the graph, used for testing only! */
std::vector<std::pair<txn_id_t, txn_id_t>> GetEdgeList();
/** Runs cycle detection in the background. */
void RunCycleDetection();
private:
std::unordered_map<txn_id_t, std::vector<txn_id_t>> BuildTxnRequestGraph();
std::mutex latch_;
std::atomic<bool> enable_cycle_detection_;
std::thread *cycle_detection_thread_;
/** Lock table for lock requests. */
std::unordered_map<RID, LockRequestQueue> lock_table_;
/** Waits-for graph representation. */
std::unordered_map<txn_id_t, std::vector<txn_id_t>> waits_for_;
};
目前你只需要知道這個,為了同步多個執行緒(事務)的併發訪問,執行緒會呼叫單例的LockManager
中的lock_shared
、lock_exclusive
、lock_upgrade
方法來獲取鎖,而鎖的粒度是RID
級別的就可以了。Project4 Part1的要求就是讓我們實現上述三個方法。
設計與實現
具體的實現思路不難。我們先看一下LockManager
的核心資料結構lock_table_
,它實現了從RID
到LockRequestQueue
的對映,所有的事務在為某個RID
申請lock時,必須將自己的請求構建一個LockRequest
結構,插入到這個RID
對應的LockRequestQueue
裡面,等待自己的請求被執行。圖例如下:
本圖中共有5個RID,理所應當有5個LockRequestQueue
,這裡為了簡便起見我只畫了其中的兩個。上圖中有四個事務(1,2,7,4)申請RID 1的鎖,三個事務(3,2,1)申請RID 4的鎖,請求到來的順序、授予鎖的順序都是FIFO。由於RID 1的前三個請求都是S-LOCK,因此這把S-LOCK可以被授予給三個事務,而第四個事務(txn 4)的X-LOCK請求則被阻塞;
能畫出這個圖之後,相應的程式碼也不難實現了,這裡我以虛擬碼的形式給出lock_shared
的邏輯,lock_exclusive
和lock_upgrade
的邏輯都很類似,故不再給出,我所想討論的BUG也在下面的程式碼裡面:
bool LockManager::LockShared(Transaction *txn, const RID &rid) {
// Assertions for transaction state, isolation level and so on.
......
// BUG IS HERE!!!!!!
latch_.lock();
if (lock_table_.find(rid) == lock_table_.end()) {
// INSERTION MAY HAPPENDS HERE!
lock_table_[rid].state_ = RIDLockState::UNLOCKED;
}
// block other txns' visit on this rid,then we could saftly release latch_
std::unique_lock lk(lock_table_[rid].queue_latch_);
latch_.unlock();
// BUG IS HERE!!!!
LockRequest req = MakeRequest(txn->id, LOCK_SHARED);
lock_table_[rid].queue_.emplace_back(req);
auto req_iter = --lock_table[rid].queue_.end();
// waiting on condition_variable
while (wait(lock_table_[rid].queue_.cv_, lk)) ....
// Success, return
lk.unlock();
return true; // unlock the queue
}
lock_table_
是一個std::unordered_map<RID, LockRequestQueue>
,最開始的時候是空的,當出現一個新的RID時,會向這個unordered_map
中新增一對新的 (RID, LockRequestQueue)的Pair。這是一個對unordered_map
的寫操作,而unordered_map
並不是執行緒安全的,如果多個執行緒同時呼叫lock_shared
去獲取同一個RID
的鎖,而這個RID
之前並不在lock_table_
裡,那麼lock_table
可能就會併發的插入同一個RID,這是非常危險的操作!!!我們必須避免這一情景,通常的方法是在可能引入插入的操作之前,獲取latch_來保證單執行緒對lock_table_的訪問操作:
// BUG IS HERE!!!!!!
latch_.lock();
if (lock_table_.find(rid) == lock_table_.end()) {
// INSERTION MAY HAPPENDS HERE!
lock_table_[rid].state_ = RIDLockState::UNLOCKED;
}
// block other txns' visit on this rid,then we could saftly release latch_
std::unique_lock lk(lock_table_[rid].queue_latch_);
latch_.unlock();
正如前文所述,由於operator []
的使用可能會引入插入操作,因此這裡我用latch_.lock()
來隔絕其他執行緒對lock_table_
的訪問操作,這樣可以避免多個執行緒對同一個RID同時進行插入操作的情景,當lock_table_[rid].state_ = RIDLockState::UNLOCKED;
這行程式碼執行完畢後,這個RID的KV Pair已經存放在了這個unordered_map
裡面,然後我們拿到更細粒度的鎖lock_table_[rid].queue_latch_
,就可以釋放掉latch_
了,因為雖然後續的程式碼會訪問lock_table_
裡的LockRequestQueue
,但由於對應的RID均已經存在,因此這些操作應該看做是對lock_table_
的讀操作;雖然這個過程中會有新的RID
插入,但不應該會對這些已經存在的RID產生影響(這裡是我的第一個疏忽),因此無需關注。而那些針對LockRequestQueue
的讀寫操作,由更加細粒度的鎖LockRequestQueue.queue_latch_
來提供同步,而其在latch_
釋放之前就已經被獲取了,因此也不存在併發問題。
可以看到,這段程式碼是非常stupid的,因為當初為了寫的快一點,我大量的使用了lock_table_[rid]
來獲取LockRequestQueue
的引用,而operator []
的操作並不是常量級的,這會引入非常多的開銷(本意是測試通過之後再修改)。更重要的是這裡是我的第二個疏忽,正是前後這兩個疏忽造成了BUG。
BUG產生情景
在完成編碼後我在本地跑了上千次測試,都可以完美PASS測試樣例,但當我把程式碼提交到gradescope時卻失敗了,提示某些請求一直沒有得到排程導致超時了。於是我在程式碼里加了點小trick,把排程失敗時lock_table_的狀態列印出來,於是產生了下面的日誌:
先解釋一下日誌的輸出。一個RID可以由一個二元組(page_num, slot_num)唯一表示,一個請求可以由三元組(txn_id, is_granted, lock_type)表示。我希望通過前文所述的二元組和三元組展示一下LockManager
中鎖排程的狀態。上圖中(txn 5, granted, S-LOCKED)表示事務5申請了RID(9, 9)的S-LOCK,且lock_manager已經將S-LOCK授權給了它。
注意到這個日誌裡有兩個 RID(1, 1),且它們的hashval是一模一樣的,這說明std::unordered_map中出現了兩個同樣的鍵!。我後面又多次提交程式碼檢視lock_table_
的狀態,但都獲得了類似的結果,即std::unordered_map中總是會有兩個相同的鍵,或者說,擁有同樣的hasval的鍵被分到了兩個bucket中,而我們通過下標訪問lock_table_[rid]時,至多隻能訪問到其中的一個bucket,因此也只有這個bucket中的LockRequest
可能被排程,而另一個bucket由於無法被訪問到,因此其中的請求就可能永遠都不會被排程了,這就導致了測試程式碼的超時。
我們知道,std::unordered_map
通過Key獲取對應的Value的規則是首先計算這個Key對應的hashval % bucket_num獲取得到K-V對所在的bucket,雖然不同的Key會有不同的hashval,但他們可能會有相同的hashval % bucket_num,因此可能會被放入到同一個bucket中;為了從bucket中找到唯一的K-V對,又需要呼叫operator ==
來找到唯一的目標Key;因此發現這個BUG後,我第一個想法就是RID
的實現可能存在問題,於是去仔細檢視了RID
的operator ()
方法和operator ==
的實現,然後打消了這個念頭。其實前文中我也提到了,我在日誌中列印了RID對應的hashval,兩個鍵的hashval都是一樣的,卻在不同的bucket中,這種情景基本不可能是operator ()
方法和operator ==
實現錯誤所能觸發的、
【世界名畫之:我程式碼錯了,肯定是XXX的問題】
然後我又考慮是不是因為我前面試圖降低鎖粒度的方法存在問題,但用紙筆模擬了多種情景、又拿狀態機之類的理論推導了一下,最終也宣告了我的懷疑破產。
這樣一來我的思路就完全斷掉了,於是我希望能獲取到更多的這個BUG產生時的程式上下文資訊。由於測試程式碼只涉及到了10個RID,而這種情況出現時,lock_table_的size會膨脹到11,因此這個時機可以作為一個排查BUG、獲取當前lock_table_狀態的切入點,因此我又往自己的程式碼裡新增了一系列的邏輯,邊列印日誌邊準備捕獲這個瞬間,但測試程式碼又被TIMEOUT了,因為gradescope的執行速度比較慢,列印太多日誌會導致超時,拿不到我想要的東西。
這樣一時間我的除錯就陷入了僵局,我猜不到這個BUG產生的可能原因,無法在本地復現這個BUG,甚至無法通過日誌的方式獲取到更多的資訊。
BUG的解決
這個BUG的解決也很富有戲劇性,大概有兩天我的思路沒有進展,直到第二天晚上偶然開啟cppreference時注意到了std::unordered_map
的一個之前沒注意到的細節:rehash
。最初始時,std::unordered_map
最初一般只有7個bucket,但隨著插入量的增長,同一個bucket中的元素越來越多,越來越多的時間會被花費在bucket內部的線性查詢上,因此std::unordered_map
會在適當時機進行擴容操作,增添bucket的數量,並將之前的k-v pair重新分配到其對應的桶中。
https://en.cppreference.com/w/cpp/container/unordered_map
我自己寫了一點測試程式碼瞭解rehash
的行為後,猜測可能是併發訪問下rehash
造成了std::unordered_map
的undefined行為,但這種想法一旦成立,也就意味著我前文中降低鎖粒度所思考的邏輯存在著嚴重的問題。驗證方法也很簡單,在lock_table_
建立時,把桶的數量開到足夠大,這樣就不會出現rehash
的情景了:
LockManager() {
enable_cycle_detection_ = true;
cycle_detection_thread_ = new std::thread(&LockManager::RunCycleDetection, this);
// reserve enough buckets to avoid rehash
lock_table_.reserve(100);
LOG_INFO("Cycle detection thread launched");
}
修改後再次提交到gradescope,順利通過。這樣基本石錘了時rehash
導致lock_table
中出現了兩個相同的key;
BUG的分析
當然,我不能使用這種投機取巧的方法去過測試,我又重看了前面的lock_shared
方法,很快發現了問題,這裡我換一批註釋:
bool LockManager::LockShared(Transaction *txn, const RID &rid) {
......
latch_.lock();
if (lock_table_.find(rid) == lock_table_.end()) {
// INSERTION AND REHASH MAY HAPPENDS HERE!
lock_table_[rid].state_ = RIDLockState::UNLOCKED;
}
std::unique_lock lk(lock_table_[rid].queue_latch_);
latch_.unlock();
LockRequest req = MakeRequest(txn->id, LOCK_SHARED);
// IF REHASH AND THIS LINE IS RUNNING AT THE SAME TIME, WHAT WILL HAPPEN ?
lock_table_[rid].queue_.emplace_back(req);
auto req_iter = --lock_table[rid].queue_.end();
// AND WHAT MAY HAPPEND HERE ???
while (wait (lock_table_[rid].queue_.cv_, lk)) ....
lk.unlock();
return true; // unlock the queue
}
錯誤已經很明顯了,我在插入LockRequest
前,使用了operator []
來獲取對應的LockRequestQueue
的引用,本意是認為新的RID的插入並不會影響這一過程,但如果在operator []
執行的過程中發生了rehash
,那麼 bucket_num的值就會發生改變(應該是從7變為11),而這個過程中,同一個hashval就可能被送到不同的bucket中,因此就產生了undefined behaviour。
那麼這個BUG該如何解決呢?再次閱讀cpp reference我們可以得知,rehash
不會導致引用或者指標失效,因此我們可以在持有latch_
的時候,直接獲取到對應的LockRequestQueue的引用,以後只通過這個引用來訪問LockRequestQueue
,虛擬碼如下:
bool LockManager::LockShared(Transaction *txn, const RID &rid) {
......
latch_.lock();
if (lock_table_.find(rid) == lock_table_.end()) {
// INSERTION AND REHASH MAY HAPPENDS HERE!
lock_table_[rid].state_ = RIDLockState::UNLOCKED;
}
// acquire the reference of LockRequestQueue;
LockRequestQueue &queue = lock_table_[rid];
latch_.unlock();
// rehash does not invalid reference queue, so it's safe here.
std::unique_lock lk(queue.queue_latch_);
LockRequest req = MakeRequest(txn->id, LOCK_SHARED);
queue.queue_.emplace_back(req);
auto req_iter = --queue.queue_.end();
while (wait (queue.cv_, lk)) ....
lk.unlock();
return true; // unlock the queue
}
還剩下一個問題:在rehash
的過程中,引用是否會失效?我個人查了查相關資料,沒有找到對應的討論情況。雖然我個人認為引用不會失效,但很明顯我們不應該依靠這種僥倖心理來寫程式碼,後續重構的時候我會像一個更好地方法來解決這個問題。
總結
std::unordered_map<Key, Value>
是一個無法保證執行緒安全的資料結構,我們必須自己來處理它的併發訪問。併發訪問可以支援單個程式的寫操作,或者多個程式的併發讀操作。一般情況下我們可以把對Value的寫操作,看做是一個對std::unordered_map<Key, Value>
的讀操作,因為這個操作並不改變Key與Value的對映關係。operator[]
是一個十分需要小心使用的方法,因為它既可能對應一個讀操作,也可能對應一個寫操作,如果這個方法觸發了插入行為,那麼其中的後設資料就會被修改,如果裝載引子接近了上限值時還可能觸發rehash,因此operator []
不應該併發的呼叫。
這算是我個人遇到的最難受的BUG之一了,既沒法在本地復現,又沒法得到更多的資訊。個人非常大的失誤是不瞭解std::unordered_map
的行為,並且在BUG定位的時候沒能跳出邏輯圈。實際上,試圖降低鎖粒度對我這種經驗很少的鶸來說,是非常危險的行為,因此我個人十分不建議在缺少全域性分析、缺乏經驗、沒有足量的測試的情況下寫併發程式碼,能單機解決它不香嘛(