記一個關於std::unordered_map併發訪問的BUG

KatyuMarisa發表於2021-02-22

前言

刷題刷得頭疼,水篇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申請鎖。如果事務進行的是讀訪問操作,要呼叫LockManagerlock_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_sharedlock_exclusivelock_upgrade方法來獲取鎖,而鎖的粒度是RID級別的就可以了。Project4 Part1的要求就是讓我們實現上述三個方法。

設計與實現

具體的實現思路不難。我們先看一下LockManager的核心資料結構lock_table_,它實現了從RIDLockRequestQueue的對映,所有的事務在為某個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_exclusivelock_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的實現可能存在問題,於是去仔細檢視了RIDoperator ()方法和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定位的時候沒能跳出邏輯圈。實際上,試圖降低鎖粒度對我這種經驗很少的鶸來說,是非常危險的行為,因此我個人十分不建議在缺少全域性分析、缺乏經驗、沒有足量的測試的情況下寫併發程式碼,能單機解決它不香嘛(

相關文章