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

新手上路,请多包涵

按照设计, std::mutex 既不可移动也不可复制。这意味着持有互斥锁的类 A 不会收到默认的移动构造函数。

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

原文由 Jack Sabbath 发布,翻译遵循 CC BY-SA 4.0 许可协议

阅读 1.3k
2 个回答

让我们从一些代码开始:

 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 中变得更加有用。耐心点,我们会到达那里的。

你的问题归结为:

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

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

移动构造函数

请注意,成员 mutexmutable 。严格来说,这对于移动成员来说不是必需的,但我假设您也想要复制成员。如果不是这种情况,则无需制作互斥锁 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 可以让您深入了解您可能需要为“线程安全”提供的 API A ,这通常与“非线程”不同-safe” API,因为“锁定粒度”问题。

还要注意防止“自我交换”的必要性。 “自我交换”应该是无操作的。如果没有自检,就会递归地锁定同一个互斥锁。这也可以在没有自检的情况下通过使用 std::recursive_mutex for MutexType 来解决。

更新

在下面的评论中,Yakk 非常不满意必须在复制和移动构造函数中默认构造东西(他有一点)。如果你对这个问题感觉足够强烈,以至于你愿意在上面花费内存,你可以像这样避免它:

  • 添加您需要的任何锁类型作为数据成员。这些成员必须位于受保护的数据之前:
   mutable MutexType mut_;
  ReadLock  read_lock_;
  WriteLock write_lock_;
  // ... other data members ...

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

糟糕,在我有机会完成此更新之前,Yakk 删除了他的评论。但他在推动这个问题并为这个答案找到解决方案方面值得称赞。

更新 2

dyp 提出了这个好建议:

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

原文由 Howard Hinnant 发布,翻译遵循 CC BY-SA 3.0 许可协议

鉴于似乎没有一个很好、干净、简单的方法来回答这个问题 - 我 认为 Anton 的解决方案是正确的,但它绝对值得商榷,除非出现更好的答案,否则我建议将这样一个类放在堆上并照顾它通过 std::unique_ptr

 auto a = std::make_unique<A>();

它现在是一种完全可移动的类型,任何在移动发生时锁定内部互斥体的人仍然是安全的,即使这是否是一件好事值得商榷

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

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

原文由 Mike Vine 发布,翻译遵循 CC BY-SA 4.0 许可协议

推荐问题