C++併發型模式#7: 讀寫鎖 - shared_mutex

本文轉載自C++併發型模式#7: 讀寫鎖 - shared_mutex,作者是鄧作恆,其博客地址爲:http://dengzuoheng.github.io/

讀者-寫者問題

考慮有一塊共享內存, 外加好些個線程需要訪問這塊共享內存, 雖然我們可以直接上mutex, 把訪問全部互斥, 但是, 如果寫入很少的情況寫把讀取也互斥了, 又感覺沒什麼必要, 併發讀不好嗎? 怎麼讓多個讀者同時訪問共享資源, 就是所謂的讀者-寫者問題.

讀寫鎖, 又稱”共享-互斥鎖”, 便是試圖解決這個問題, 使得讀操作可以併發重入, 寫操作則互斥.

讀寫鎖有不同的優先策略, 一種是讀者優先, 即只有全部讀操作都完成, 寫操作纔可以進行, 但是這樣如果一直都有讀操作的話, 寫操作會餓死–等很久很久, 等到天荒地老, 都沒等到沒讀者的時候.

另一種是寫者優先, 等待已經開始的讀操作, 在完成寫操作前不增加新讀者.

讀者優先的讀寫鎖可以用兩個mutex和一個counter簡單實現一下[2]:

class shared_mutex {
    int m_shared_count;
    boost::mutex m_mutex_count;
    boost::mutex m_mutex_write;
public:
    shared_mutex() : m_shared_count(0) {}
    void lock() {
        m_mutex_write.lock();
    }
    void unlock() {
        m_mutex_write.unlock();
    }
    void lock_shared() {
        m_mutex_count.lock();
        m_shared_count++;
        if (m_shared_count == 1) {
            m_mutex_write.lock();
        }
        m_mutex_count.unlock();
    }
    void unlock_shared() {
        m_mutex_count.lock();
        m_shared_count--;
        if (m_shared_count == 0) {
            m_mutex_write.unlock();
        }
        m_mutex_count.unlock();
    }
};

因爲boost及c++17中將讀寫鎖稱爲shared_mutex, 所以這裏的接口皆依boost, 讀鎖爲lock_shared(), 寫鎖爲lock().

這裏m_mutex_count是用來保護m_shared_count的; 第一個讀鎖時把m_mutex_write鎖了, 最後一個讀鎖解時才解m_mutex_write, 所以只要還有讀者, lock()就無法獲得m_mutex_write. 所以, 如果讀者源源不斷, 寫鎖就一直鎖不到.

boost實現

boost的shared_mutex基於Alexander Terekhov提出的算法[1],

shared_lock_guard 和 shared_lock

對普通的mutex, 我們有raii的lock_guard, 對shared_mutex, 自然也會有shared_lock_guard:

plate<typename SharedMutex>
class shared_lock_guard : boost::noncopyable {
    SharedMutex& m_shared_mutex;
public:
    explicit shared_lock_guard(SharedMutex& m) : m_shared_mutex(m) {
        m_shared_mutex.lock_shared();
    }
    ~shared_lock_guard() {
        m_shared_mutex.unlock_shared();
    } 

對於普通的mutex, 我們有raii的更靈活的unique_lock, 對shared_mutex, 自然也會有shared_lock其實還有upgrade_lock以及相互轉換的各種lock, 能把名字記住已經不容易了:

struct defer_lock_t{};
struct try_to_lock_t{};
struct adopt_lock_t{};
const defer_lock_t defer_lock={};
const try_to_lock_t try_to_lock={};
const adopt_lock_t adopt_lock={};
template<typename SharedMutex>
class shared_lock : boost::noncopyable {
    SharedMutex* m_shared_mutex;
    bool m_is_locked;
public:
    shared_lock() : m_shared_mutex(NULL), m_is_locked(false) {}
    explicit shared_lock(SharedMutex& m) : m_shared_mutex(&m), m_is_locked(true) {
        lock();
    }
    shared_lock(SharedMutex& m, adopt_lock_t) : m_shared_mutex(&m), m_is_locked(true) {
    }
    shared_lock(SharedMutex& m, defer_lock_t) : m_shared_mutex(&m), m_is_locked(false) {
    }
    shared_lock(SharedMutex& m, try_to_lock_t) : m_shared_mutex(&m), m_is_locked(false) {
        try_lock();
    }
    ~shared_lock() {
        if (owns_lock()) {
            m_shared_mutex->unlock_shared();
        }
    }
    void lock() {
        if(owns_lock()) {
            throw boost::lock_error();
        }
        m_shared_mutex->lock_shared();
        m_is_locked = true;
        
    }
    bool try_lock() {
        if(owns_lock()) {
            throw boost::lock_error();
        }
        m_is_locked = m_shared_mutex->try_lock_shared();
        return m_is_locked;
    }
    void unlock() {
        if(!owns_lock()) {
            throw boost::lock_error();
        }
        m_shared_mutex->unlock_shared();
        m_is_locked = false;;
    }
    bool owns_lock() {
        return m_is_locked;
    }
};

因爲unique_lock和shared_lock一般要求可以移動的, 所以用的是SharedMutex*, 而不是引用.

shared_mutex

boost的讀寫鎖並沒有使用ptherad_rwlock, 而是用mutex和condition_variable實現, 一方面可能是跨平臺的考慮, 一方面可能是因爲boost提供讀鎖升級到寫鎖, 而pthread不提供. boost中的鎖升級稱爲upgrade, shared_mutex也有lock_upgrade得到可升級的讀鎖, 但是簡單起見, 我們下面先不考慮upgrade. (下面代碼片段可能來自boost1.41, 也可能來自1.68, 但這兩版本除了簡單重構, 沒有太大區別).

boost的shared_mutex中, 沒有明確的優先級; 既然不是讀者優先, 就得加寫鎖的時候, 先置一flag, 標記要即將加寫鎖, 阻塞其他新讀者. 但是, 對於已經有的讀鎖, 寫者是要等的; 這樣, 我們需要兩個條件變量, 一個給讀者, 一個給寫者. 另外, 寫鎖的互斥不是用mutex實現的, 而是又置了另一flag, 標記已經加了寫鎖, 其他寫鎖等着.

boost.shared_mutex將這些flags, 加上讀者的計數, 集中成一個內部結構體, 稱之爲state_data:

class shared_mutex {
    struct state_data {
        unsigned shared_count;
        bool exclusived;
        bool exclusive_entered;
    };
    state_data m_state;
    boost::mutex m_mutex_state;
    boost::condition_variable m_shared_cond;
    boost::condition_variable m_exclusive_cond;
public:
    shared_mutex(){}
    ~shared_mutex(){}
    void lock_shared();
    bool try_lock_shared();
    void unlock_shared();
    void lock();
    bool try_lock();
    void unlock();
};

其中m_mutex_state是保護m_state的. exclusive_entered表示即將加寫鎖, exclusive_entered爲真時, 不能再加讀鎖. exclusived表示已經加了寫鎖, 進入互斥狀態. shared_count則是讀者數量.

因爲之後還得加上upgrade相關的標記, shared_state還會變得更復雜, 所以, shared_mutex的實現中, 就給state_data加了些方法, 以便調用:

class shared_mutex {
    struct state_data {
        unsigned shared_count;
        bool exclusived;
        bool exclusive_entered;
        state_data() : 
            shared_count(0),
            exclusived(false),
            exclusive_entered(false) {}
        bool can_lock_shared() const { return !(exclusived || exclusive_entered);}
        bool no_shared() const { return shared_count == 0;}
        bool one_shared() const { return shared_count == 1;}
        bool can_lock() const { return no_shared() && !exclusived;}
        void lock() {
            exclusived = true;
        }
        void unlock() {
            exclusived = false;
            exclusive_entered = false;
        }
        void lock_shared() {
            ++shared_count;
        }
        void unlock_shared() {
            --shared_count;
        }
    };
    
};

我們先來看寫鎖shared_mutex::lock(), 因爲這是我們先前最清楚的:

void shared_mutex::lock() {
    boost::unique_lock<boost::mutex> lk(m_mutex_state);
    while (!m_state.can_lock()) {
        m_state.exclusive_entered = true;
        m_exclusive_cond.wait(lk);
    }
    m_state.exclusived = true;
}

首先將exclusive_entered設爲true, 然後等待已經有的讀鎖完成, 再把exclusived設爲true.

爲什麼exclusive_entered在while循環中? 因爲boost的shared_mutex沒有誰優先, 所以最後一個讀鎖解鎖的時候, 得讓正在等待的讀寫者公平競爭(就是把他們都喚醒, 誰搶到就是誰的), 於是最後一個讀鎖解鎖的時候, 會將exclusive_entered置爲false, 讓讀者有機會競爭. 這樣一來, 寫者可能被喚醒後發現機會被讀者搶了, 然後就繼續等, 爲保公平, 就得再把exclusive_entered設爲true, 否則可能再也競爭不過讀者了.

shared_mutex::try_lock()有所不同, 因爲它不會去等已有的讀鎖(其實lk也可以用try_to_lock):

bool shared_mutex::try_lock() {
    boost::unique_lock<boost::mutex> lk(m_mutex_state);
    if (!m_state.can_lock()) {
        return false;
    }
    m_state.exclusived = true;
    return true;
}

shared_mutex::unlock除了改變m_state之外, 還需要通知正在等待的讀者和寫者, 因爲寫者優先, 所以先通知寫者:

void shared_mutex::unlock() {
    boost::unique_lock<boost::mutex> lk(m_mutex_state);
    m_state.exclusived = false;
    m_state.exclusive_entered = false;
    m_exclusive_cond.notify_one();
    m_shared_cond.notify_all();
}

因爲通知正在等待的讀者和寫者這個操作以後還會有許多次, 我們就將之提取成shared_mutex的一個私有方法:

void shared_mutex::notify_waiters() {
    m_exclusive_cond.notify_one();
    m_shared_cond.notify_all();
}

shared_mutex::lock_shared()其實也很簡單, 只是改個計數而已:

void shared_mutex::lock_shared() {
    boost::unique_lock<boost::mutex> lk(m_mutex_state);
    while (!m_state.can_lock_shared()) {
        m_shared_cond.wait(lk);
    }
    m_state.lock_shared();
}
bool try_lock_shared() {
    boost::unique_lock<boost::mutex> lk(m_mutex_state);
    if (m_state.can_lock_shared()) {
        m_state.lock_shared();
        return true;
    }
    return false;
}

shared_mutex::unlock_shared() 的要點我們在解釋shared_mutex::lock()便已指出, 最後一個讀者解鎖時要特殊處理一下:

void shared_mutex::unlock_shared() {
    boost::unique_lock<boost::mutex> lk(m_mutex_state);
    m_state.unlock_shared();
    if (m_state.no_shared()) {
        m_state.exclusive_entered = false;
        notify_waiters();
    }
}

升級

boost的shared_mutex提供了升級, 即從讀鎖升級爲寫鎖, 叫upgrade_lock, 也可能叫upgrade_mutex; 這個升級並不是把讀鎖解了然後加個寫鎖這麼簡單, shared_mutex的升級隱含了一個目標, 就是升級後, 數據沒被修改. 這使得只能有一個讀鎖是可升級的, 否則可能競爭, 如果可能競爭, 升級後就不知道有沒有被別的線程修改. [1]

爲了實現這個目標, 鎖升級便有最高優先級, 即最後一個讀鎖解鎖時, 先通知正在升級的鎖, 然後再通知其他, 這得多一個條件變量.

下面我們開始實現, 首先給state_data加個flag, 保證只有一個可升級鎖, 然後給shared_mutex加些新接口:

ass shared_mutex {
    struct state_data {
        // ...
        state_data() : /*...,*/ upgrade(false) */ {}
        bool upgrade;
        bool can_lock_upgrade() const { return can_lock_shared() && !upgrade;}
        void lock_upgrade() {
            ++shared_count;
            upgrade = true;
        }
        void unlock_upgrade() { 
            upgrade = false;
            --shared_count;
        }
        // ...
    };
    boost::condition_variable m_upgrade_cond;
    // ...
    void lock_upgrade();
    bool try_lock_upgrade();
    void unlock_upgrade();
    void unlock_upgrade_and_lock();
};

shared_mutex::lock_upgrade()跟shared_mutex::lock_shared()差不多, 只是多考慮新加的upgradeflag而已:

void shared_mutex::lock_upgrade() {
    boost::unique_lock<boost::mutex> lk(m_mutex_state);
    while (!m_state.can_lock_upgrade()) {
        m_shared_cond.wait(lk);
    }
    m_state.lock_upgrade();
}
bool shared_mutex::try_lock_upgrade() {
    boost::unique_lock<boost::mutex> lk(m_mutex_state);
    if (!m_state.can_lock_upgrade()) {
        return false;
    }
    m_state.lock_upgrade();
    return true;
}

shared_mutex::unlock_upgrade()需要注意如果還有讀鎖, 可以通知一下可能正在lock_upgrade()等的讀者:

void shared_mutex::unlock_upgrade() {
    boost::unique_lock<boost::mutex> lk(m_mutex_state);
    m_state.unlock_upgrade();
    if (m_state.no_shared()) {
        m_state.exclusive_entered = false;
        notify_waiters();
    } else {
        m_shared_cond.notify_all();
    }
}

shared_mutex::unlock_upgrade_and_lock()其實也是解讀鎖然後加寫鎖, 因爲優先upgrade並不是這裏保證的, 而是一會兒要修改的unlock_shared():

void shared_mutex::unlock_upgrade_and_lock() {
    boost::unique_lock<boost::mutex> lk(m_mutex_state);
    m_state.unlock_shared();
    while (!m_state.no_shared()) {
        m_upgrade_cond.wait(lk);
    }
    m_state.lock();
    m_state.upgrade = false;
}

注意這裏等的是m_state.no_shared()而不是can_lock(), 這是有理由的, 稍後解釋.

shared_mutex::unlock_shared()需要改一下:

void shared_mutex::unlock_shared() {
    boost::unique_lock<boost::mutex> lk(m_mutex_state);
    m_state.unlock_shared();
    if (m_state.no_shared()) {
        if(m_state.upgrade) {
            // As there is a thread doing a unlock_upgrade_and_lock that is waiting for state.no_shared()
            // avoid other threads to lock, lock_upgrade or lock_shared, so only this thread is notified.
            m_state.upgrade = false;
            m_state.exclusived = true;
            m_upgrade_cond.notify_one();
        } else {
            m_state.exclusive_entered = false;
        }
        
        notify_waiters();
    }
}

這裏需要注意, 如果是最後一個讀鎖了, m_state.upgrade仍然爲true, 說明有upgrade_lock在升級, 需要將m_state.exclusived設爲true, 所以其他lock, lock_upgrade, lock_shared都無法進行了, 只有即將被notify的unlock_upgrade_and_lock; 因爲m_state.exclusive現在是true, 所以unlock_upgrade_and_lock只能等no_shared(), 不能等can_lock().

另外, 爲什麼將m_state.upgrade設爲false, 其實我不是很明白, 十多年前最開始的版本就有了, 但似乎沒有什麼地方需要它是false, 因爲exclusive就能保證其他鎖加不上了. 爲此我去so上提了個問題, 有人指出, 從狀態機的視角考慮, exclusive和upgrade不該同時爲true.

我們喜歡raii, 所以, lock_upgrade()也有對應的upgrade_lock, 而unlock_upgrade_and_lock()則是從upgrade_lock移動到unique_lock的時候使用的, 假如我們有移動構造:

template<typename Mutex>
unique_lock<Mutex>::unique_lock(upgrade_lock<Mutex>&& other):
    m(other.m),is_locked(other.is_locked)
{
    other.is_locked=false;
    other.m = NULL;
    if(is_locked)
    {
        m->unlock_upgrade_and_lock();
    }
}

STL實現

標準庫中的shared_mutex是基於Howard E. Hinnant的提案[3], 但是C++17標準中沒有支持升級, 所以下面也不討論upgrade的情況.

簡單地說, 這個實現中, 以兩個條件變量作爲兩道”門”, 第一道門表示沒有正在寫, 第二道門表示沒有正在讀; 對於讀者, 能過第一道門便可加讀鎖; 對於寫者, 先過第一道門, 然後將第一道關了, 在過第二道門, 過了便是加上了寫鎖.

用一個unsigned儲存所有狀態, 第1位表示exclusive_entered, 其餘位存讀者數目, 一堆操作皆是位運算; 之所以只用一個unsigned, 是希望以後可以改成原子變量, 也算是一種優化讀寫鎖性能的期望.

我們先聲明一下接口:

class shared_mutex {
    std::mutex mut_;
    std::condition_variable gate1_;
    std::condition_variable gate2_;
    unsigned state_;
    /* example:
     *  sizeof(unsigned) == 4;
     *  CHART_BIT == 8;
     *  EXCLUSIVE_WAITING_BLOCKED_MASK == 0x80000000;
     *  MAX_SHARED_COUNT_MASK == 0x7fffffff;
     *  NO_EXCLUSIVE_NO_SHARED == 0x00000000;
     */
    static const unsigned EXCLUSIVE_ENTERED_MASK = 1U << (sizeof(unsigned) * CHAR_BIT - 1);
    static const unsigned MAX_SHARED_COUNT_MASK = ~EXCLUSIVE_ENTERED_MASK;
    static const unsigned NO_EXCLUSIVE_NO_SHARED = 0;
public:
    shared_mutex() : state_(NO_EXCLUSIVE_NO_SHARED) {}
    // Exclusive ownership
    void lock();
    bool try_lock();
    void unlock();
    // Shared ownership
    void lock_shared();
    bool try_lock_shared();
    void unlock_shared();
};

直接看位運算的代碼怪眼花的, 於是這裏整理一下, 以私有函數代替原來的位運算語句, 與上面的討論一樣, 這些私有函數都是對state_的操作, 調用前都假設已經獲取到mut_了:

class shared_mutex {
    // ...
private:
    bool _exclusive_entered() const { return (state_ & EXCLUSIVE_ENTERED_MASK); }
    unsigned _shared_count() const { return (state_ & MAX_SHARED_COUNT_MASK); }
    bool _no_shared() const { return _shared_count() == 0;}
    bool _full_shared() const { return _shared_count() == MAX_SHARED_COUNT_MASK; }
    bool _can_lock() const { return state_ == NO_EXCLUSIVE_NO_SHARED; }
    bool _can_lock_shared() const { return (!_exclusive_entered() && !_full_shared());}
    void _lock_shared() {
        const unsigned num = _shared_count() + 1;
        state_ &= ~MAX_SHARED_COUNT_MASK;
        state_ |= num;
    }
    void _unlock_shared() {
        const unsigned num = _shared_count() - 1;
        state_ &= ~MAX_SHARED_COUNT_MASK;
        state_ |= num;
    }
    void _lock() {
        state_ = EXCLUSIVE_ENTERED_MASK;
        assert(_no_shared() && _exclusive_entered());
    }
    void _unlock() {
        state_ = NO_EXCLUSIVE_NO_SHARED;
        assert(_no_shared() && !_exclusive_entered());
    }
    void _enter_exclusive() {
        state_ |= EXCLUSIVE_ENTERED_MASK;
    }
    // ...
};

畢竟unsigned是有限的, 讀者數量也是有上限的, 滿了就不給加了, 所以有_full_shared()表示已滿, _can_lock_shared()也要求未滿.

下面我們直接看shared_mutex::lock() 和 shared_mutex::try_lock():

void shared_mutex::lock()
{
    std::unique_lock<std::mutex> lk(mut_);
    while (_exclusive_entered()) {
        gate1_.wait(lk);
    }
    _enter_exclusive();
    while (!_no_shared()) {
        gate2_.wait(lk);
    }
    _lock(); // unnecessary
}
bool shared_mutex::try_lock()
{
    std::unique_lock<std::mutex> lk(mut_, std::try_to_lock);
    if (lk.owns_lock() && _can_lock()) {
        _lock();
        return true;
    }
    return false;
}

第一道門, 如果沒其他寫者進入, 則當前寫者進入, 進入後關了門(_enter_exclusive()), 這樣其他讀者和寫者都不能進了. 然後在第二道門前等所有讀者出去, 自己進去, 這寫鎖便是加上了. 所以那句_lock()其實沒有必要, 因爲此時必然是互斥的.

對於try_lock, 連mut_都是try的, _can_lock()表示既沒有讀者, 也沒有寫者在第一道門內, 所以可直接過二道門, 完成加鎖, 這時_lock()就是必須的了.

shared_mutex::unlock()則會讓state_回到沒有讀者, 也沒有寫者的狀態:

void shared_mutex::unlock()
{
    {
        std::lock_guard<std::mutex> _(mut_);
        _unlock();
    }
    gate1_.notify_all();
}

如果有寫鎖, 讀者都會被阻在第一道門外, 所以這裏notify的是gate1_.

那麼, shared_mutex::lock_shared()就是讀者等在第一道門的故事:

void shared_mutex::lock_shared()
{
    std::unique_lock<std::mutex> lk(mut_);
    while (!_can_lock_shared()) {
        gate1_.wait(lk);
    }
    _lock_shared();
}
bool shared_mutex::try_lock_shared()
{
    std::unique_lock<std::mutex> lk(mut_, std::try_to_lock);
    if (lk.owns_lock() && _can_lock_shared()) {
        _lock_shared();
        return true;
    }
    return false;
}

shared_mutex::unlock()稍複雜, 我們之前說過, std::shared_mutex考慮了讀者滿了的情況, 所以解鎖時, 如果解鎖前是滿的, 解鎖後自然不滿了, 就得通知在門外等候的其他讀者. 另外, 如果有寫者在第一道門內, 最後一個讀者離開時, 需通知該寫者可以進第二道門了:

void shared_mutex::unlock_shared()
{
    std::lock_guard<std::mutex> lk(mut_);
    const bool full_shared_before = _full_shared();
    _unlock_shared();
    if (_exclusive_entered()) {
        if (_no_shared()) {
            gate2_.notify_one();
        }
    } else {
        if (full_shared_before) {
            gate1_.notify_one();
        }
    }
}

因爲不用考慮升級, 所以代碼還是稍稍簡潔易懂一些, 看明白了上面這被我”整理”過的代碼, 再去看文獻[3,4]中的版本, 想必會更容易一些.

這個實現比boost的實現更偏向寫者, boost中最後一個讀者解鎖時, 即通知在等的讀者, 也通知在等的寫者, 讓他們都參與競爭. Hinnant覺得這樣寫者有飢餓嫌疑, 畢竟讀者比寫者多, 錯失良機的話可能就是等很久了. 所以, stl的實現中, 如果有寫者進到二道門, 則只通知該寫者.

被批判的讀寫鎖

人們沒少批判讀寫鎖的性能問題[5,6,7].

從上面兩個版本的實現便可看出, 無論boost還是stl, shared_mutex總得有個狀態和計數, 那麼, 爲了保護這個狀態, 自然有mutex, 這意味着, 無論我們加讀鎖還是加寫鎖, shared_mutex自己都得鎖個mutex, 開銷不可能比我們鎖個mutex小[8].

所以, 臨界區很小的時候, 讀寫鎖可能不會比直接粗暴的mutex快; 臨界區很大又說明代碼寫得不好, 縮小臨界區是我等畢生心願. 所以用不用讀寫鎖還是測過才知道.

如果需要很高的性能, RCU(Read-Copy Update)是一種可行的選擇[9], 不過需要系統支持. 我們以後討論RCU的時候此坑有緣再填系列, 再具體評測讀寫鎖和RCU的性能差異.

另外, 從正確性來說, 拿着讀鎖進行寫操作也不是不可能, 這樣就跟無保護併發寫一樣了; 實現上, 讀鎖是可重入的, 而寫鎖會阻塞其他讀鎖, 這可能造成讀鎖重入時死鎖[8].

我自己工作中倒是沒有碰到需要讀寫鎖的時候, 自然也沒被坑過, 所以這裏就不作評價了.

Reference:

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