我应该如何处理C ++中可移动类型的互斥量?

时间:2015-05-01 11:41:19

标签: c++ mutex move-constructor

按照设计,std::mutex不可移动,也不可复制。这意味着拥有互斥锁的类A将不会收到default-move-constructor。

如何使这种类型A以线程安全的方式移动?

5 个答案:

答案 0 :(得分:90)

让我们从一些代码开始:

class A
{
    using MutexType = std::mutex;
    using ReadLock = std::unique_lock<MutexType>;
    using WriteLock = std::unique_lock<MutexType>;

    mutable MutexType mut_;

    std::string field1_;
    std::string field2_;

public:
    ...

我已经在那里放了一些相当暗示的类型别名,我们在C ++ 11中没有真正利用它,但在C ++ 14中变得更有用。请耐心等待,我们会到那儿。

您的问题归结为:

  

如何为此类编写移动构造函数和移动赋值运算符?

我们将从移动构造函数开始。

移动构造函数

请注意,成员mutex已成为mutable。严格来说,这对移动成员来说并不是必需的,但我假设你也想要复制成员。如果不是这种情况,则无需制作互斥mutable

构建A时,您无需锁定this->mut_。但是你需要锁定你正在构建的对象的mut_(移动或复制)。这可以这样做:

    A(A&& a)
    {
        WriteLock rhs_lk(a.mut_);
        field1_ = std::move(a.field1_);
        field2_ = std::move(a.field2_);
    }

请注意,我们必须先默认构建this的成员,然后仅在a.mut_被锁定后才为其分配值。

移动作业

移动赋值运算符要复杂得多,因为您不知道某个其他线程是否正在访问赋值表达式的lhs或rhs。一般而言,您需要防范以下情况:

// Thread 1
x = std::move(y);

// Thread 2
y = std::move(x);

以下是正确保护上述场景的移动赋值运算符:

    A& operator=(A&& a)
    {
        if (this != &a)
        {
            WriteLock lhs_lk(mut_, std::defer_lock);
            WriteLock rhs_lk(a.mut_, std::defer_lock);
            std::lock(lhs_lk, rhs_lk);
            field1_ = std::move(a.field1_);
            field2_ = std::move(a.field2_);
        }
        return *this;
    }

请注意,必须使用std::lock(m1, m2)锁定两个互斥锁,而不是一个接一个地锁定它们。如果你一个接一个地锁定它们,那么当两个线程以相反的顺序分配两个对象时,如上所示,你可以得到一个死锁。 std::lock的目的是避免这种僵局。

复制构造函数

你没有询问复制成员,但我们现在也可以谈谈它们(如果不是你,有人会需要它们。)

    A(const A& a)
    {
        ReadLock  rhs_lk(a.mut_);
        field1_ = a.field1_;
        field2_ = a.field2_;
    }

复制构造函数看起来很像移动构造函数,除了使用ReadLock别名而不是WriteLock。目前这两个别名都是std::unique_lock<std::mutex>,因此它确实没有任何区别。

但是在C ++ 14中,您可以选择这样说:

    using MutexType = std::shared_timed_mutex;
    using ReadLock  = std::shared_lock<MutexType>;
    using WriteLock = std::unique_lock<MutexType>;

这个可能是一个优化,但不是绝对的。您必须进行测量以确定它是否存在。但是通过这种改变,可以同时在多个线程中复制构造来自相同的rhs。 C ++ 11解决方案迫使你按顺序制作这样的线程,即使rhs没有被修改。

复制分配

为了完整性,这里是复制赋值运算符,在阅读其他所有内容之后应该是相当自我解释的:

    A& operator=(const A& a)
    {
        if (this != &a)
        {
            WriteLock lhs_lk(mut_, std::defer_lock);
            ReadLock  rhs_lk(a.mut_, std::defer_lock);
            std::lock(lhs_lk, rhs_lk);
            field1_ = a.field1_;
            field2_ = a.field2_;
        }
        return *this;
    }

等等

如果您希望多个线程能够立即调用它们,则还需要保护访问A状态的任何其他成员或免费功能。例如,这里是swap

    friend void swap(A& x, A& y)
    {
        if (&x != &y)
        {
            WriteLock lhs_lk(x.mut_, std::defer_lock);
            WriteLock rhs_lk(y.mut_, std::defer_lock);
            std::lock(lhs_lk, rhs_lk);
            using std::swap;
            swap(x.field1_, y.field1_);
            swap(x.field2_, y.field2_);
        }
    }

请注意,如果您只依赖std::swap执行此任务,则锁定将采用错误的粒度,在std::swap内部执行的三个移动之间锁定和解锁。

确实,考虑swap可以让您深入了解您可能需要为线程安全&#34;提供的API。 A,通常与&#34;非线程安全&#34;不同。 API,因为&#34;锁定粒度&#34;问题。

还要注意需要防止&#34;自我交换&#34;。 &#34;自交换&#34;应该是一个无操作。如果没有自检,则会递归锁定相同的互斥锁。这也可以通过std::recursive_mutex MutexType使用mutable MutexType mut_; ReadLock read_lock_; WriteLock write_lock_; // ... other data members ... 进行自我检查来解决。

<强>更新

在下面的评论中,Yakk非常不满意在副本中默认构造事物并移动构造函数(他有一点意见)。如果你对这个问题有足够的强烈感受,以至于你愿意花时间记忆它,你可以这样避免它:

  • 添加您需要的任何锁定类型作为数据成员。这些成员必须位于受保护的数据之前:

    A(const A& a)
        : read_lock_(a.mut_)
        , field1_(a.field1_)
        , field2_(a.field2_)
    {
        read_lock_.unlock();
    }
    
  • 然后在构造函数(例如复制构造函数)中执行以下操作:

        A(const A& a)
            : A(a, ReadLock(a.mut_))
        {}
    private:
        A(const A& a, ReadLock rhs_lk)
            : field1_(a.field1_)
            , field2_(a.field2_)
        {}
    

哎呀,在我有机会完成此更新之前,Yakk删除了他的评论。但他推动这个问题值得赞扬,并得到了解决方案。

更新2

dyp提出了这个好建议:

{{1}}

答案 1 :(得分:5)

鉴于这似乎并不是一个很好,干净,简单的方法来回答这个问题 - 安东的解决方案我认为是正确的,但它确实存在争议,除非有更好的答案我会建议把这样的课放在堆上并通过std::unique_ptr

来照顾它
auto a = std::make_unique<A>();

它现在是一个完全可移动的类型,任何在移动发生时锁定内部互斥锁的人仍然是安全的,即使它有争议这是否是一件好事

如果您需要复制语义,请使用

auto a2 = std::make_shared<A>();

答案 2 :(得分:5)

这是一个颠倒的答案。而不是将“此对象需要同步”作为类型的基础嵌入,而是将注入任何类型。

您以非常不同的方式处理同步对象。一个大问题是你必须担心死锁(锁定多个对象)。它也基本上不应该是你的“对象的默认版本”:同步对象是用于争用的对象,你的目标应该是最小化线程之间的争用,而不是在地毯下扫描它。

但同步对象仍然有用。我们可以编写一个包含同步任意类型的类,而不是从同步器继承。用户必须跳过几个箍才能对对象进行操作,因为它们是同步的,但它们并不局限于对象上的一些手动编码的有限操作集。他们可以将对象上的多个操作组合成一个,或者对多个对象进行操作。

这是围绕任意类型T的同步包装:

template<class T>
struct synchronized {
  template<class F>
  auto read(F&& f) const&->std::result_of_t<F(T const&)> {
    return access(std::forward<F>(f), *this);
  }
  template<class F>
  auto read(F&& f) &&->std::result_of_t<F(T&&)> {
    return access(std::forward<F>(f), std::move(*this));
  }
  template<class F>
  auto write(F&& f)->std::result_of_t<F(T&)> {
    return access(std::forward<F>(f), *this);
  }
  // uses `const` ness of Syncs to determine access:
  template<class F, class... Syncs>
  friend auto access( F&& f, Syncs&&... syncs )->
  std::result_of_t< F(decltype(std::forward<Syncs>(syncs).t)...) >
  {
    return access2( std::index_sequence_for<Syncs...>{}, std::forward<F>(f), std::forward<Syncs>(syncs)... );
  };
  synchronized(synchronized const& o):t(o.read([](T const&o){return o;})){}
  synchronized(synchronized && o):t(std::move(o).read([](T&&o){return std::move(o);})){}  
  // special member functions:
  synchronized( T & o ):t(o) {}
  synchronized( T const& o ):t(o) {}
  synchronized( T && o ):t(std::move(o)) {}
  synchronized( T const&& o ):t(std::move(o)) {}
  synchronized& operator=(T const& o) {
    write([&](T& t){
      t=o;
    });
    return *this;
  }
  synchronized& operator=(T && o) {
    write([&](T& t){
      t=std::move(o);
    });
    return *this;
  }
private:
  template<class X, class S>
  static auto smart_lock(S const& s) {
    return std::shared_lock< std::shared_timed_mutex >(s.m, X{});
  }
  template<class X, class S>
  static auto smart_lock(S& s) {
    return std::unique_lock< std::shared_timed_mutex >(s.m, X{});
  }
  template<class L>
  static void lock(L& lockable) {
      lockable.lock();
  }
  template<class...Ls>
  static void lock(Ls&... lockable) {
      std::lock( lockable... );
  }
  template<size_t...Is, class F, class...Syncs>
  friend auto access2( std::index_sequence<Is...>, F&&f, Syncs&&...syncs)->
  std::result_of_t< F(decltype(std::forward<Syncs>(syncs).t)...) >
  {
    auto locks = std::make_tuple( smart_lock<std::defer_lock_t>(syncs)... );
    lock( std::get<Is>(locks)... );
    return std::forward<F>(f)(std::forward<Syncs>(syncs).t ...);
  }

  mutable std::shared_timed_mutex m;
  T t;
};
template<class T>
synchronized< T > sync( T&& t ) {
  return {std::forward<T>(t)};
}

包含C ++ 14和C ++ 1z功能。

这假设const操作是多读者安全的(这是std容器所假设的。)

使用类似于:

synchronized<int> x = 7;
x.read([&](auto&& v){
  std::cout << v << '\n';
});

表示具有同步访问权限的int

我建议不要synchronized(synchronized const&)。这很少需要。

如果您需要synchronized(synchronized const&),我很想用T t;替换std::aligned_storage,允许手动放置构建,并进行手动销毁。这允许适当的终身管理。

除此之外,我们可以复制源T,然后从中读取:

synchronized(synchronized const& o):
  t(o.read(
    [](T const&o){return o;})
  )
{}
synchronized(synchronized && o):
  t(std::move(o).read(
    [](T&&o){return std::move(o);})
  )
{}
分配

synchronized& operator=(synchronized const& o) {
  access([](T& lhs, T const& rhs){
    lhs = rhs;
  }, *this, o);
  return *this;
}
synchronized& operator=(synchronized && o) {
  access([](T& lhs, T&& rhs){
    lhs = std::move(rhs);
  }, *this, std::move(o));
  return *this;
}
friend void swap(synchronized& lhs, synchronized& rhs) {
  access([](T& lhs, T& rhs){
    using std::swap;
    swap(lhs, rhs);
  }, *this, o);
}

展示位置和对齐的存储版本有点混乱。对t的大多数访问都会被成员函数T&t()T const&t()const所取代,除非您需要跳过一些环节。

通过使synchronized成为包装器而不是类的一部分,我们必须确保的是,类在内部将const视为多读者,并以单线程方式编写它

罕见的情况下,我们需要一个同步的实例,我们会跳过上面的箍。

对上述任何拼写错误道歉。可能有一些。

上述的另一个好处是synchronized对象(相同类型)上的n元任意操作可以一起工作,而无需事先对其进行硬编码。添加好友声明和多种类型的n-ary synchronized对象可以一起使用。在这种情况下,我可能不得不让access成为内联朋友来处理过载问题。

live example

答案 3 :(得分:4)

使用互斥和C ++移动语义是在线程之间安全有效地传输数据的绝佳方法。

想象一下&#39;制作人&#39;制作批量字符串并将其提供给(一个或多个)消费者的线程。这些批次可以由包含(可能很大)std::vector<std::string>个对象的对象表示。 我们绝对想要移动&#39;这些载体的内部状态进入消费者而没有不必要的重复。

您只需将互斥锁识别为对象的一部分,而不是对象状态的一部分。也就是说,您不想移动互斥锁。

您需要什么锁定取决于您的算法或您的对象的一般化程度以及您允许的使用范围。

如果您只是从共享状态&#39;生产者&#39;反对线程本地消费&#39;对象你可以只锁定移动的来自对象。

如果它是更通用的设计,则需要锁定两者。在这种情况下,您需要考虑死锁。

如果这是一个潜在的问题,那么使用std::lock()以无死锁的方式获取两个互斥锁上的锁。

http://en.cppreference.com/w/cpp/thread/lock

作为最后一点,您需要确保理解移动语义。 回想一下,从对象移动的处于有效但未知的状态。 完全有可能的是,一个没有执行移动的线程有一个有效的理由,当它可能找到有效但未知的状态时,尝试访问移动的对象。

我的制作人再一次敲打字符串,消费者正在带走整个负载。在这种情况下,每次生产者尝试添加到向量时,它可能会发现向量非空或空。

简而言之,如果从对象移动的潜在并发访问量相当于写入,那么它可能就可以了。如果它等于读取,那么考虑一下为什么可以读取任意状态。

答案 4 :(得分:2)

首先,如果要移动包含互斥锁的对象,则设计必定存在问题。

但是如果你决定这样做,你必须在移动构造函数中创建一个新的互斥锁,例如:

// movable
struct B{};

class A {
    B b;
    std::mutex m;
public:
    A(A&& a)
        : b(std::move(a.b))
        // m is default-initialized.
    {
    }
};

这是线程安全的,因为移动构造函数可以安全地假设其参数未在其他任何地方使用,因此不需要锁定参数。