C++ folly庫解讀(三)Synchronized —— 比std::lock_guard/std::unique_lock更易用、功能更強大的同步機制

張雅宸 發表於 2021-06-18
C++

目錄

  • 傳統同步方案的缺點
  • folly/Synchronized.h 簡單使用
  • Synchronized的模板引數
  • withLock()/withRLock()/withWLock() —— 更易用的加鎖方式
  • 升級鎖
  • ulock()和 withULockPtr()
  • Timed Locking
  • Synchronized 與 std::condition_variable
  • acquireLocked() —— 同時鎖多個資料
  • 使用一把鎖,鎖多個資料
  • struct
  • std::tuple
  • Benchmark

folly/Synchronized.h 提供了一種更簡單、更不容易出錯的同步機制,可以用來替代傳統 C++標準庫中使用較複雜、較容易出錯的同步機制。

傳統同步方案的缺點

一般是將需要同步的資料和鎖一一配對,即 —— associate mutexes with data, not code :

class RequestHandler {
  ...
  std::mutex requestMutex_;
  RequestQueue requestQueue_;

  processRequest(const Request& request);
};

void RequestHandler::processRequest(const Request& request) {
  std::lock_guard<std::mutex> lg(requestMutex_);
  requestQueue_.push_back(request);
}

然而,操作這些資料成員,開發人員必須注意,正確的獲取鎖、獲取正確的鎖。

一些常見的錯誤包括:

  • 運算元據之前沒有獲取鎖。
  • 獲取了不配對的鎖,這個鎖不是用來鎖這個資料的。
  • 獲取了讀鎖,但是試圖去修改資料。
  • 獲取了寫鎖,但是對資料只有 const access.

一般在使用時,需要提醒開發人員:“別忘了 xxxx”,那一般都會出錯,比如 new 的物件別忘了 delete : )

folly/Synchronized.h 簡單使用

上面的程式碼可以用 folly/Synchronized.h 重寫為:

class RequestHandler {
  folly::Synchronized<RequestQueue> requestQueue_;

  processRequest(const Request& request);
};

void RequestHandler::processRequest(const Request& request) {
  requestQueue_.wlock()->push_back(request);
}

為什麼 folly/Synchronized.h 更加有效呢?

  • 與傳統使用方式不同,這裡鎖和資料是結合成了一個物件 —— requestQueue_。傳統方案中,需要尋找鎖和資料的配對關係。
  • 幾乎不可能在不獲取鎖的情況下,去運算元據,還是因為它們被封裝成了一個物件。傳統方案加不加鎖全靠自覺。
  • 在 push_back 後,鎖立即被釋放。

如果在臨界區有多個操作,那麼可以使用如下方法:

{
      auto lockedQueue = requestQueue_.wlock();
      lockedQueue->push_back(request1);
      lockedQueue->push_back(request2);
}

wlock 返回一個 LockedPtr 物件,這個物件可以被理解為指向資料成員的指標。只有這個物件存在,那麼鎖就會被鎖住,所以最好為這個物件顯示定義一個 scope.

更好的方式,是使用 lambdas :

void RequestHandler::processRequest(const Request& request) {
      requestQueue_.withWLock([&](auto& queue "&") {
        // withWLock() automatically holds the lock for the
        // duration of this lambda function
        queue.push_back(request);
      });
}

使用 withWLock 配合 lambdas 強制定義了一個 scope,更清晰。

Synchronized的模板引數

Synchronized 有兩個模板引數,資料型別和鎖型別:

template <class T, class Mutex = SharedMutex>

如果不指定第二個模板引數,預設是 folly::SharedMutex。只要被 folly::LockTraits 支援的都可以使用,比如 std::mutex、std::recursive_mutex、std::timed_mutex,。std::recursive_timed_mutex、folly::SharedMutex、folly::RWSpinLock、folly::SpinLock.

根據鎖型別的不同,Synchronized 會提供不同的 API:

  • 共享鎖和升級鎖:如果存在 lock_shared()成員函式,Synchronized 會提供 wlock(),rlock(),ulock()三個方法來獲取不同的鎖型別。其中,rlock()只提供對資料成員 const access.
  • 排他鎖:lock()

withLock()/withRLock()/withWLock() —— 更易用的加鎖方式

withLock()在上面提到過了,可以用來替代 lock()。在持有鎖的期間,執行一個 lambda 或者 function. withRLock()/withWLock()同理可以替代 rlock()/wlock().

我們再詳細說一下這種方式的好處。下面的函式將 vector 裡的所有元素都 double:

auto locked = vec.lock();
for (int& n : *locked) {
    n *= 2;
}

使用 lock()/wlock()/rlock()的一個重要注意事項:一個指向資料的指標或者引用,它的生命週期一定不要比 LockedPtr 物件長(lock()/wlock()/rlock()的返回值型別)。 如果我們將上面的例子這樣寫就會出問題:

// No. NO. NO!
for (int& n : *vec.wlock()) {
      n *= 2;
}

vec.wlock()返回的 LockPtr 物件在 range iterators 建立後就銷燬了(詳細解釋見 Range-based for loop Temporary range expression 小節),range iterators 指向了 vector data,但此時鎖已經被釋放。想想如果要 debug 這種問題,會用多少時間 😃

這時 withLock()/withRLock()/withWLock()的好處就體現出來了,鎖會在 for loop 期間一直持有:

vec.withLock([](auto& data "") {
    for (int& n : data) {
        n *= 2;
    }
});

withLock 定義為(withRLock/withWLock 類似):

  /**
   * Invoke a function while holding the lock.
   *
   * A reference to the datum will be passed into the function as its only
   * argument.
   *
   * This can be used with a lambda argument for easily defining small critical
   * sections in the code.  For example:
   *
   *   auto value = obj.withLock([](auto& data "") {
   *     data.doStuff();
   *     return data.getValue();
   *   });
   */
  template <class Function>
  auto withLock(Function&& function) {
    return function(*lock());
  }

  template <class Function>
  auto withLock(Function&& function) const {
    return function(*lock());
  }

升級鎖

ulock()和 withULockPtr()

Synchronized 還支援升級鎖。升級鎖與共享鎖可以共存,但是與排它鎖互斥。

/**
 * An enum to describe the "level" of a mutex.  The supported levels are
 *  Unique - a normal mutex that supports only exclusive locking
 *  Shared - a shared mutex which has shared locking and unlocking functions;
 *  Upgrade - a mutex that has all the methods of the two above along with
 *            support for upgradable locking
 */
enum class MutexLevel { UNIQUE, SHARED, UPGRADE };

升級鎖解決的問題是:先對資料進行讀操作,然後根據一定的條件會進行寫操作。

升級鎖可以通過 uclock()或者 withULockPtr()獲得:

{
    // only const access allowed to the underlying object when an upgrade lock
    // is acquired
    auto ulock = vec.ulock();
    auto newSize = ulock->size();
}

auto newSize = vec.withULockPtr([](auto ulock "") {
    // only const access allowed to the underlying object when an upgrade lock
    // is acquired
    return ulock->size();
});

通過下面的函式可以進行升級或者降級:

  • moveFromUpgradeToWrite()
  • moveFromWriteToUpgrade()
  • moveFromWriteToRead() // withWLockPtr()獲得的 wlock 可以呼叫此函式降級為 rlock
  • moveFromUpgradeToRead()

呼叫這些函式的 LockedPtr 會被設定為 invalid null state,並返回另一個鎖住特定鎖的 LockedPtr。這些操作都是原子性的,中間不會出現 unlocked 狀態。

比如現在有一個 cache,資料結構為 unordered_map,需求是先檢查對應的 key 是否在 unordered_map 中,如果在則返回對應的 value,不在則初始化 value 為 0:

folly::Synchronized<std::unordered_map<int64_t, int64_t>> cache;

int64_t res = cache.withULockPtr([key,value](auto ulock "key,value") {
    int64_t cache_value;
    auto iter = ulock->find(key);
    if (iter != ulock->end()) {
        cache_value = iter->second;
    } else {
        cache_value = 0;

        // ulock is now null
        auto wlock = ulock.moveFromUpgradeToWrite();
        (*wlock)[key] = cache_value;
    }

    return cache_value;
});

Timed Locking

如果初始化 Synchronized 的鎖型別支援時間,lock()/wlock()/rlock()可以傳入一個型別為 std::chrono::duration 的引數:

void fun(Synchronized<vector<string>>& vec) {
      {
        auto locked = vec.lock(10ms);
        if (!locked) {
          throw std::runtime_error("failed to acquire lock");
        }
        locked->push_back("hello");
        locked->push_back("world");
      }
      LOG(INFO) << "successfully added greeting";
}

Synchronized 與 std::condition_variable

如果 Synchronized 的鎖型別是 std::mutex,那麼可以和 std::condition_variable 配合使用。

Synchronized<vector<string>, std::mutex> vec;
std::condition_variable emptySignal;

// Assuming some other thread will put data on vec and signal
// emptySignal, we can then wait on it as follows:
auto locked = vec.lock();
emptySignal.wait(locked.getUniqueLock(),
                    [&] { return !locked->empty(); });

getUniqueLock()返回一個 std::unique_lockstd::mutex的引用。但是不推薦這麼使用,因為這繞過了 Synchronized 的 API,可以直接操作對應的鎖:

 /**
   * Get a reference to the std::unique_lock.
   *
   * This is provided so that callers can use Synchronized<T, std::mutex>
   * with a std::condition_variable.
   *
   * While this API could be used to bypass the normal Synchronized APIs and
   * manually interact with the underlying unique_lock, this is strongly
   * discouraged.
   */
  std::unique_lock<std::mutex>& getUniqueLock() { return lock_; }

acquireLocked() —— 同時鎖多個資料

假如需要將一個 vector 的資料拷貝到另一個 vector,wlock()可能會實現需求:

void fun(Synchronized<vector<int>>& a, Synchronized<vector<int>>& b) {
    auto lockedA = a.wlock();
    auto lockedB = b.wlock();
    ... use lockedA and lockedB ...
}

但是如果一個執行緒呼叫 fun(x,y),另一個執行緒呼叫 func(y,x),就很有可能出現死鎖。經典的解決方式是,所有的執行緒以同樣的順序獲取鎖。許多庫的實現是通過比較鎖地址的大小來決定加鎖順序:

void fun(Synchronized<vector<int>>& a, Synchronized<vector<int>>& b) {
    auto ret = folly::acquireLocked(a, b);
    auto& lockedA = std::get<0>(ret);
    auto& lockedB = std::get<1>(ret);
    ... use lockedA and lockedB ...
}

// 實現:通過比較鎖地址的大小
/**
 * Acquire locks for multiple Synchronized<T> objects, in a deadlock-safe
 * manner.
 *
 * The locks are acquired in order from lowest address to highest address.
 * (Note that this is not necessarily the same algorithm used by std::lock().)
 * For parameters that are const and support shared locks, a read lock is
 * acquired.  Otherwise an exclusive lock is acquired.
 *
 * use lock() with folly::wlock(), folly::rlock() and folly::ulock() for
 * arbitrary locking without causing a deadlock (as much as possible), with the
 * same effects as std::lock()
 */
template <class Sync1, class Sync2>
std::tuple<detail::LockedPtrType<Sync1>, detail::LockedPtrType<Sync2>>
acquireLocked(Sync1& l1, Sync2& l2) {
  if (static_cast<const void*>(&l1) < static_cast<const void*>(&l2)) {
    auto p1 = l1.contextualLock();
    auto p2 = l2.contextualLock();
    return std::make_tuple(std::move(p1), std::move(p2));
  } else {
    auto p2 = l2.contextualLock();
    auto p1 = l1.contextualLock();
    return std::make_tuple(std::move(p1), std::move(p2));
  }
}

C++17 引入了 structured binding syntax,可以使程式碼更簡單:

void fun(Synchronized<vector<int>>& a, Synchronized<vector<int>>& b) {
     auto [lockedA, lockedB] = folly::acquireLocked(a, b);
     ... use lockedA and lockedB ...
}

acquireLockedPair()返回 std::pair,在不支援 C++17 的編譯器情況下,使用也很方便。

使用一把鎖,鎖多個資料

比如一個 bidirectional map,需要同時操作。一般有兩個方案:

Struct

class Server {
    struct BiMap {
    map<int, string> direct;
    map<string, int> inverse;
    };
    Synchronized<BiMap> bimap_;
    ...
};

...
bimap_.withLock([](auto& locked "") {
    locked.direct[0] = "zero";
    locked.inverse["zero"] = 0;
});

std::tuple

class Server {
    Synchronized<tuple<map<int, string>, map<string, int>>> bimap_;
    ...
};

...
bimap_.withLock([](auto& locked "") {
    get<0>(locked)[0] = "zero";
    get<1>(locked)["zero"] = 0;
});

Benchmark

SynchronizedBenchmark.cpp

下篇文章寫一下 Synchronized 的基本實現 :)

參考資料:

(完)

朋友們可以關注下我的公眾號,獲得最及時的更新:

C++ folly庫解讀(三)Synchronized —— 比std::lock_guard/std::unique_lock更易用、功能更強大的同步機制