如何用自然的语法实现线程安全的容器?

时间:2019-02-20 07:56:06

标签: c++ templates thread-safety c++14 temporary-objects

前言

下面的代码按原样使用将导致未定义的行为:

vector<int> vi;
...
vi.push_back(1);  // thread-1
...
vi.pop(); // thread-2

传统方法是使用std::mutex进行修复:

std::lock_guard<std::mutex> lock(some_mutex_specifically_for_vi);
vi.push_back(1);

但是,随着代码的增长,这种事情开始显得很麻烦,因为每次在方法前都会有一个锁。而且,对于每个对象,我们可能都必须维护一个互斥体。

客观

在不影响访问对象和声明显式互斥量的语法的前提下,我想创建一个模板,使其完成所有样板工作。例如

Concurrent<vector<int>> vi;  // specific `vi` mutex is auto declared in this wrapper
...
vi.push_back(1); // thread-1: locks `vi` only until `push_back()` is performed
...
vi.pop ()  // thread-2: locks `vi` only until `pop()` is performed

在当前的C ++中,这是不可能实现的。但是,我尝试了一个代码,如果仅将vi.更改为vi->,那么事情就会按上述代码注释中的预期工作。

代码

// The `Class` member is accessed via `->` instead of `.` operator
// For `const` object, it's assumed only for read purpose; hence no mutex lock
template<class Class,
         class Mutex = std::mutex>
class Concurrent : private Class
{
  public: using Class::Class;

  private: class Safe
           {
             public: Safe (Concurrent* const this_,
                           Mutex& rMutex) :
                     m_This(this_),
                     m_rMutex(rMutex)
                     { m_rMutex.lock(); }
             public: ~Safe () { m_rMutex.unlock(); }

             public: Class* operator-> () { return m_This; }
             public: const Class* operator-> () const { return m_This; }
             public: Class& operator* () { return *m_This; }
             public: const Class& operator* () const { return *m_This; }

             private: Concurrent* const m_This;
             private: Mutex& m_rMutex;
           };

  public: Safe ScopeLocked () { return Safe(this, m_Mutex); }
  public: const Class* Unsafe () const { return this; }

  public: Safe operator-> () { return ScopeLocked(); }
  public: const Class* operator-> () const { return this; }
  public: const Class& operator* () const { return *this; }

  private: Mutex m_Mutex;
};

Demo

问题

  • 是否使用临时对象调用带有重载operator->()的函数会导致C ++中出现不确定的行为?
  • 这个小实用程序类是否在所有情况下都为封装对象提供线程安全的目的

说明

对于相互依赖的语句,需要更长的锁定时间。因此,引入了一种方法:ScopeLocked()。这等效于std::lock_guard()。但是给定对象的互斥锁是在内部维护的,因此在语法上仍然更好。
例如而不是低于有缺陷的设计(如答案所示):

if(vi->size() > 0)
  i = vi->front(); // Bad: `vi` can change after `size()` & before `front()`

应该依靠以下设计:

auto viLocked = vi.ScopeLocked();
if(viLocked->size() > 0)
  i = viLocked->front();  // OK; `vi` is locked till the scope of `viLocked`

换句话说,对于相互依赖的语句,应该使用ScopeLocked()

5 个答案:

答案 0 :(得分:13)

不要这样做。

几乎不可能创建一个线程安全的集合类,其中每个方法都需要一个锁。

请考虑以下并发类实例。

Concurrent<vector<int>> vi;

开发人员可能会这样做:

 int result = 0;
 if (vi.size() > 0)
 {
     result = vi.at(0);
 }

另一个线程可能会在调用size()at(0)的第一个线程之间进行此更改。

vi.clear();

所以现在,操作的同步顺序是:

vi.size()  // returns 1
vi.clear() // sets the vector's size back to zero
vi.at(0)   // throws exception since size is zero

因此,即使您具有线程安全的向量类,两个竞争线程也可能导致异常在意外地方抛出。

那只是最简单的例子。还有其他方法,多个线程试图同时进行读/写/迭代可能会无意间破坏您对线程安全的保证。

您提到整个事情都是由这种麻烦的模式引起的:

vi_mutex.lock();
vi.push_back(1);
vi_mutex.unlock();

实际上,有一些帮助程序类可以使此程序更清洁,即lock_guard,它将使用一个互斥锁来锁定其构造函数并在析构函数上解锁

{
    lock_guard<mutex> lck(vi_mutex);
    vi.push_back(1);
}

然后其他实践中的代码将变为线程安全ala:

{
     lock_guard<mutex> lck(vi_mutex);
     result = 0;
     if (vi.size() > 0)
     {
         result = vi.at(0);
     }
}

更新:

我编写了一个示例程序,使用您的Concurrent类来演示导致问题的争用条件。这是代码:

Concurrent<list<int>> g_list;

void thread1()
{
    while (true)
    {
        if (g_list->size() > 0)
        {
            int value = g_list->front();
            cout << value << endl;
        }
    }

}

void thread2()
{
    int i = 0;
    while (true)
    {
        if (i % 2)
        {
            g_list->push_back(i);
        }
        else
        {
            g_list->clear();
        }
        i++;
    }
}

int main()
{

    std::thread t1(thread1);
    std::thread t2(thread2);

    t1.join(); // run forever

    return 0;
}

在未优化的构建中,上述程序在几秒钟内崩溃。 (零售有点难,但是错误仍然存​​在。)

答案 1 :(得分:9)

这项工作充满了风险和性能问题。迭代器通常取决于整个数据结构的状态,如果数据结构以某些方式更改,则迭代器通常会失效。这意味着,迭代器在创建时需要在整个数据结构上保留一个互斥量,或者您需要定义一个特殊的迭代器,该迭代器仅仔细锁定当前所依赖的东西,这可能比当前指向的节点/元素的状态。这将需要内部知识来实现​​所包装内容的实现。

作为一个例子,考虑一下事件序列如何进行:

线程1:

 void thread1_func(Concurrent<vector<int>> &cq)
 {
       cq.push_back(1);
       cq.push_back(2);
 }

线程2:

 void thread2_func(Concurrent<vector<int>> &cq)
 {
       ::std::copy(cq.begin(), cq.end(), ostream_iterator<int>(cout, ", "));
 }

您如何看待这种情况?即使每个成员函数都很好地包装在一个互斥锁中,因此它们都已序列化并且是原子的,但您仍在调用未定义的行为,因为一个线程更改了数据结构而另一个正在迭代。

您可以使创建迭代器也锁定互斥体。但是,如果同一线程创建另一个迭代器,则它应该能够获取互斥体,因此您需要使用recursive mutex

当然,这意味着在一个线程对其进行迭代时,任何其他线程都无法触摸您的数据结构,从而大大减少了并发机会。

它也很容易出现比赛条件。一个线程进行调用,并发现有关其感兴趣的数据结构的某些事实。然后,假设这一事实成立,它将进行另一次调用。但是,当然,事实不再成立,因为在获取事实和使用事实之间还有其他线程戳了它的鼻子。使用size然后决定是否对其进行迭代的示例只是一个示例。

答案 2 :(得分:6)

  

使用临时对象调用带有重载的operator->()的函数会导致C ++中的不确定行为

不。临时人员只会在end of the full expression处被摧毁,从而使它们重生。而且,使用带有重载operator->的临时对象来“修饰”成员访问权限,恰恰是为什么以这种方式定义重载运算符的原因。它用于记录日志,专用构建中的性能评估,并且就像您自己发现的那样,将所有成员访问锁定到封装的对象。

  

在这种情况下,基于范围的for循环语法不起作用。它给出了编译错误。解决该问题的正确方法是什么?

据我所知,您的Iterator函数未返回实际的迭代器。将Safe<Args...>(std::forward<Args>(args)...);与参数列表Iterator(Class::NAME(), m_Mutex)进行比较。从Base推论Args中的参数时,Class::NAME()是什么?

  

这个小实用程序类是否在所有情况下都为封装对象提供线程安全的目的?

对于简单的值类型,它看起来相当安全。但这当然取决于通过包装程序完成的所有访问。

对于更复杂的容器,考虑到迭代器无效,那么使单个成员访问原子级并不一定会阻止竞争条件(如注释中所述)。我想您可能会创建一个迭代器包装器,该包装器会在容器的整个生命周期内将其锁定...但是您将失去大部分有用的容器API。

答案 3 :(得分:1)

除了其他问题,您对const的假设也是错误的。对于许多stl类型,const方法仍然要求在执行期间保护容器以防修改。

为此,至少需要一个共享的互斥锁,并且还需要将其声明为mutable,以便可以将其锁定在const路径中。到那时,最好意识到std::shared_mutex实现也由于引入了额外的同步点而违反了规范,这归因于从boost复制的过早的“排他性优先”调度策略。将它们视为具有std::mutex相同约束的性能优化,而不必依赖规范。

使用const迭代器(cbegincend)时,您还必须能够获得整个事务的锁定。

因此,您也需要ScopedLock才能访问const


与其他响应相同的结论是,直接在->上内联Concurrent是危险的设计选择。典型的手枪瞄准您的脚。从.->运算符进行幼稚重构时,几乎可以确保这一点消失。

答案 4 :(得分:0)

我忍不住要回答这个问题,因为我已经在这样的实用程序库上工作了几个月了。自然,我认为这个主意非常好:它可以使代码更清晰,更安全。 回答问题:

  1. 正如已经回答的那样:它不会导致不确定的行为,因为临时对象存在于它所在的代码行的整个执行过程中。
  2. 您的实用程序类可以像std::lock_guard一样普遍使用。 std::lock_guard是C ++ 11中提供线程安全性的首选机制,无论您使用的对象是什么。

许多答案都指出了您班级的可能误用(“ std::vector中的迭代器”示例),但是我认为这些都是不相关的。当然,您必须尝试限制滥用的可能性,但是您最终不能将其全部删除。无论如何,使用std::lock_guard都会遇到相同的迭代器问题,并且该库的目的不是消除多线程错误,而是至少使用类型系统消除一些错误。

我在您的代码中看到的一些问题:

  • 标准库区分std::lock_guardstd::unique_lock,我认为保持这种区别很重要。前者用于日常互斥锁,后者则用于std :: condition_variable。
  • 您在互斥锁上显式调用了lock()unlock(),这避免了共享互斥锁的有益使用,因为共享互斥锁具有lock_shared方法用于只读访问。
  • 您可以通过const指针/ const引用来访问封装的对象。只读访问仍然需要锁定互斥锁,因为另一个线程可能正在同时修改该对象:您可能正在读取部分更新的信息。
  • 您的课程没有标准课程灵活。例如,std::lock_guard可以使用std::adopt_lock标签接受一个已经锁定的互斥体,这可能非常有用。

如果您有兴趣,我很乐意为您指出我自己的实现方式。