线程安全的向量:这个实现是线程安全的吗?

Thread-safe vector: Is this implementation thread-safe?

本文关键字:安全 线程 实现 向量      更新时间:2023-10-16

我有一个关于术语线程安全的问题。我举个例子:

#include <mutex>
#include <vector>
/// A thread-safe vector
class ThreadSafeVector {
private:
  std::mutex m;
  std::vector<double> v;
public:
  // add double to vector
  void add(double d) {
    std::lock_guard<std::mutex> lg(m);
    v.emplace_back(d);
  }
  // return length of vector  
  int length() {
    std::lock_guard<std::mutex> lg(m);
    return v.size();
  }   
};

你会调用这个类,即它的所有方法,线程安全吗?

编辑[星期日,CEST晚上9点]

在得到一些不错的"是的,但是"的答案和可选的实现之后,我在下面的回答中提供了我自己的观点。基本上,它可以归结为一个简单的问题,类的线程安全性是否只需要为其方法的并行执行提供强原子性和可见性保证,或者类是否必须提供超出其自身作用域的保证(例如串行执行)。

IMHO:

这既安全又有用:

  void add(double d) {
    std::lock_guard<std::mutex> lg(m);
    v.emplace_back(d);
  }

这是安全但无用的:

  // return length of vector  
  int length() {
    std::lock_guard<std::mutex> lg(m);
    return v.size();
  }   

因为当你得到你的长度时,它很可能已经改变了,所以对它的推理不太可能有用。

这个怎么样?

template<class Func>
decltype(auto) do_safely(Func&& f)
{
  std::lock_guard<std::mutex> lock(m);
  return f(v);
}

被这样调用:

myv.do_safely([](auto& vec) { 
  // do something with the vector
  return true;  // or anything you like
});

您所提供的是线程安全的。然而,这样做的问题是,您不能添加允许访问元素的方法而不失去线程安全性。而且,这种线程安全性是非常低效的。有时你想遍历整个容器,有时你想一个接一个地添加多个元素。

作为一种替代方法,您可以将锁定的责任放在调用者身上。这样更有效率。
/// A lockable vector
class LockableVector
{
public:
    using mutex_type = std::shared_timed_mutex;
    using read_lock = std::shared_lock<mutex_type>;
    using write_lock = std::unique_lock<mutex_type>;
    // default ctor
    LockableVector() {}
    // move-ctor
    LockableVector(LockableVector&& lv)
    : LockableVector(std::move(lv), lv.lock_for_writing()) {}
    // move-assign
    LockableVector& operator=(LockableVector&& lv)
    {
        lv.lock_for_writing();
        v = std::move(lv.v);
        return *this;
    }
    read_lock lock_for_reading() { return read_lock(m); }
    write_lock lock_for_writing() { return write_lock(m); }
    // add double to vector
    void add(double d) {
        v.emplace_back(d);
    }
    // return length of vector
    int length() {
        return v.size();
    }
    // iteration
    auto begin() { return v.begin(); }
    auto end() { return v.end(); }
private:
    // hidden, locked move-ctor
    LockableVector(LockableVector&& lv, write_lock)
    : v(std::move(lv.v)) {}
    mutex_type m;
    std::vector<double> v;
};
int main()
{
    LockableVector v;
    // add lots of elements
    { /// create a scope for the lock
        auto lock = v.lock_for_writing();
        for(int i = 0; i < 10; ++i)
            v.add(i);
    }
    // print elements
    { /// create a scope for the lock
        auto lock = v.lock_for_reading();
        std::cout << std::fixed << std::setprecision(3);
        for(auto d: v)
            std::cout << d << 'n';
    }
}

同时拥有读锁和写锁可以提高效率,因为在当前没有线程正在写的情况下,可以同时拥有多个读锁。

虽然这是线程安全的,但效率不高。您可以通过使用shared_mutex (c++ 14或Boost,它不在c++ 11中)轻松地提高效率。这是因为如果两个线程请求这个大小,这应该不是问题。但是,如果一个线程请求size,而另一个线程想要添加一个元素,那么应该只允许其中一个线程访问。

所以我要这样修改你的代码:

#include <mutex>
#include <vector>
#include <shared_mutex>
/// A thread-safe vector
class ThreadSafeVector {
private:
  mutable std::shared_timed_mutex m; //notice the mutable
  std::vector<double> v;
public:
  // add double to vector
  void add(double d) {
    std::unique_lock<std::shared_timed_mutex> lg(m); //just shared_mutex doesn't exist in C++14, you're welcome to use boost::shared_mutex, it's the same
    v.emplace_back(d);
  }
  // return length of vector  
  //notice the const, because this function is not supposed to modify your class
  int length() const {
    std::shared_lock<std::shared_timed_mutex> lg(m);
    return v.size();
  }   
};

要记住的几件事:

  • std::mutex(和所有其他互斥体)是不可复制的。这意味着您的类现在是不可复制的。要使其可复制,您必须自己实现复制构造函数并绕过复制互斥锁。

  • 总是让你的互斥锁在容器中mutable。这是因为修改互斥锁并不意味着修改类的内容,这与我添加到length()方法中的const是兼容的。const意味着该方法不修改类中的任何内容。

虽然vector在开始使用时看起来像是线程安全的,但您会发现并非如此。例如,我想在小于5的向量上添加任务(保持它不大于5)

ThreadSafeVector tv;
if( tv.length() < 5 ) tv.add( 10.0 );

是否可以在多线程环境中正常工作?不。当你给向量添加更多的逻辑时,它会变得越来越复杂。

我会的。这两个公共方法都由锁保护,并且所有特殊的成员函数(复制/移动构造函数/赋值)都被隐式删除,因为std::mutex既不可复制也不可移动。

我们的内部团队正在讨论线程安全的含义。Slavas评论"虽然length()在技术上是"线程安全的",但实际上并不是",将其归结为矛盾的本质。也许,用"是"或"不是"来回答我这个简单的问题是不可能的。

下面是我的观点:线程安全只需要关于并行执行其操作的干净的语义。类ThreadSafeVector是线程安全的,因为它的函数保证了以下操作的并行执行:

  • 原子性:交错的线程不会导致不一致的状态(因为锁)
  • visibility:状态被传播给其他线程(由于互斥锁引起的内存障碍)

调用线程安全的类并不要求它的任何可能的聚合使用本身都必须是线程安全的,即类上方法的串行执行不必是线程安全的。例子:

if (a.length() == 0) a.add(42);

当然,这一行不是线程安全的,因为它本身不是原子的,类甚至没有提供"工具"来做这样的事情。但是,仅仅因为我可以从线程安全的操作构造一个非线程安全的序列,并不意味着线程安全的操作就真的不是线程安全的。

在传统的线程不安全类中包装线程安全的最好和最无错误的方法之一是使用监视器:

template<class T>
class monitor
{
public:
    template<typename ...Args>
    monitor(Args&&... args) : m_cl(std::forward<Args>(args)...){}
    struct monitor_helper
    {
        monitor_helper(monitor* mon) : m_mon(mon), m_ul(mon->m_lock) {}
        T* operator->() { return &m_mon->m_cl;}
        monitor* m_mon;
        std::unique_lock<std::mutex> m_ul;
    };
    monitor_helper operator->() { return monitor_helper(this); }
    monitor_helper ManuallyLock() { return monitor_helper(this); }
    T& GetThreadUnsafeAccess() { return m_cl; }
private:
    T           m_cl;
    std::mutex  m_lock;
};

这将允许您以线程安全的方式访问包装类的所有方法:

monitor<std::vector<int>> threadSafeVector {5};

然后使用

threadSafeVector->push_back(5);

或任何其他成员函数,以便在锁下执行调用。更多信息请看我的原始回答。

这不会神奇地使多个调用在逻辑上是线程安全的(正如在其他答案中讨论的那样),但是这个系统也有一种方法来实现这一点:

// You can explicitly take a lock then call multiple functions
// without the overhead of a relock each time. The 'lock handle'
// destructor will unlock the lock correctly. This is necessary
// if you want a chain of logically connected operations 
{
    auto lockedHandle = threadSafeVector.ManuallyLock();
    if(!lockedHandle->empty())
    {
        lockedHandle->pop_back();
        lockedHandle->push_back(-3);
    }
}