对象池的一个 race condition

262 阅读2分钟
原文链接: zhuanlan.zhihu.com

拙作《Linux 多线程服务端编程》第 1.11 节介绍了如何用 shared_ptr/weak_ptr 实现对象池,最近有读者指出对象销毁有 race condition。本文介绍一下复现及修复的方法。

从第 22 页的 version 3 开始的代码有这个 race condition,包括第 1.11.1 节的 version 4 和第 1.11.2 节的弱回调版,见试读样张,配套代码见 GitHub。这个 race condition 再次验证了对象的销毁比创建更难。

Race condition

为了突出重点,本文以 version 3 为例,介绍 race condition 的成因及修复方法,完整代码(包括修复)见 GitHub。为了便于下文讨论,我把 version 3 代码的代码用 C++11 重新实现,贴在这里。

class Stock : boost::noncopyable
{
 public:
  Stock(const string& name)
    : name_(name)
  {
  }

  const string& key() const { return name_; }

 private:
  string name_;
};

// 对象池
class StockFactory : boost::noncopyable
{
 public:

  std::shared_ptr<Stock> get(const string& key)
  {
    std::shared_ptr<Stock> pStock;
    muduo::MutexLockGuard lock(mutex_);
    std::weak_ptr<Stock>& wkStock = stocks_[key];
    pStock = wkStock.lock();
    if (!pStock)
    {
      pStock.reset(new Stock(key),
                   [this] (Stock* stock) { deleteStock(stock); });
      wkStock = pStock;
    }
    return pStock;
  }

 private:

  void deleteStock(Stock* stock)
  {
    if (stock)
    {
      muduo::MutexLockGuard lock(mutex_);
      stocks_.erase(stock->key());
    }
    delete stock;
  }

  mutable muduo::MutexLock mutex_;
  std::unordered_map<string, std::weak_ptr<Stock> > stocks_;
};

Race condition 发生在 StockFactory::deleteStock() 这个成员函数里,如果进入 deleteStock 之后,在 lock 之前,有别的线程调用了相同 key 的 StockFactory::get(),会造成此 key 被从 stocks_ 哈希表中错误地删除,因此会重复创建 Stock 对象。程序不会 crash 也不会有 memory leak,但是程序中存在两个相同 key 的 Stock 对象,违背了对象池应有的语意。下图描绘了 race condition 的发生过程。

复现

这个 race condition 可以用 sleep() 很容易地复现出来,见 GitHub 上的代码,编译时须定义 REPRODUCE_BUG 这个宏。

修复

修复这个 race condition 的办法很简单,在 deleteStock() 中,拿到 lock 之后,检查一下 weak_ptr 是否 expired(),然后只在 expired() 为 true 的情况下从 stocks_ 中删掉 key。

void deleteStock(Stock* stock)
  {
    if (stock)
    {
      muduo::MutexLockGuard lock(mutex_);
      auto it = stocks_.find(stock->key());
      assert(it != stocks_.end());
      if (it->second.expired())
      {
        stocks_.erase(it);
      }
    }
    delete stock;
  }

修复之后,原来的 race condition 不复存在:

思考题

如果把条件 if (it->second.expired()) 改成 if (!it->second.lock()),即试着将 weak_ptr 提升为 shared_ptr,如果提升不成功,则 erase key,这样做有没有问题?

这样做有可能造成死锁,因为 muduo Mutex 是不可重入的。race condition:如果 weak_ptr::lock() 成功,拿到一个 shared_ptr (use_count 应该 > 1),然后在此 shared_ptr 析构之前,其他线程释放了这个对象,使得 use_count 降为 1,那么当此 shared_ptr 析构的时候,会递归调用 deleteStock(),从而造成死锁。

题图

Herb Sutter 在 CppCon2016 上也提到了类似的对象池技术,他的实现对应书中的 version 2,没有这个 race condition,但对象池的大小只增不减。演讲视频:My CppCon talk video is online,幻灯片: CppCon/CppCon2016

题外话

承蒙读者厚爱,《Linux 多线程服务端编程》自从 2013 年 1 月面世以来,截至 2017 年 11 月,累计印刷 10 次,印数共 2 万册。