如何在C ++中处理可移动类型中的互斥锁?


85

通过设计,std::mutex既不能移动,也不能复制。这意味着A持有互斥量的类不会收到default-move-constructor。

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


4
这个问题有一个怪癖:移动操作本身也应该是线程安全的,或者其他对对象的访问是否是线程安全的就足够了吗?
乔纳斯·谢弗

2
@paulm那真的取决于设计。我经常看到一个类有一个互斥量成员变量,然后只有std::lock_guardis方法是作用域的。
科里·克莱默

2
@Jonas Wielicki:起初我认为移动它也应该是线程安全的。但是,并不是我再考虑一下,这没有多大意义,因为移动构造对象通常会使旧对象的状态无效。因此,如果要移动旧对象,其他线程一定不能访问它。否则它们可能很快就会访问无效对象。我对吗?
杰克·安息日

2
请关注此链接,可能会用完
Ravi Chauhan

1
@DieterLücking:是的,就是这个主意。互斥锁M保护类B。但是,我都在哪里存储这两个对象以拥有线程安全的可访问对象?M和B都可能进入A类。在这种情况下,A类将在类范围内具有Mutex。
杰克·安息日

Answers:


104

让我们从一些代码开始:

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

您的问题可以归结为:

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

我们将从move构造函数开始。

移动构造函数

请注意,该成员mutex已创建mutable。严格来说,这对于move成员来说不是必需的,但是我假设您还需要复制成员。如果不是这种情况,则无需制作互斥体mutable

构造时A,不需要锁定this->mut_。但是,您确实需要锁定mut_要从中构造对象的(移动或复制)。可以这样完成:

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

请注意,我们必须默认构造thisfirst的成员,然后仅在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_;
    }

复制构造函数看起来与move构造函数非常相似,只不过使用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复制构造。即使未修改rhs,C ++ 11解决方案也会迫使您将此类线程顺序化。

复制作业

为了完整起见,这是副本赋值运算符,在阅读了其他所有内容后,它应该很容易解释:

    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可以使您深入了解可能需要提供“线程A安全”的API,由于“锁定粒度”问题,通常与“非线程安全” API有所不同。

还要注意需要防止“自我交换”。“自我交换”应该是禁止操作的。没有自检,将递归锁定同一互斥锁。如果没有使用std::recursive_mutexfor进行自我检查,也可以解决此问题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_)
    {}

2
您的复制构造函数会分配字段,但不会复制它们。这意味着它们需要默认可构造的,这是一个不幸的限制。
Yakk-Adam Nevraumont

@Yakk:是的,将mutexes类类型放入并不是一种“正确的方法”。它是工具箱中的工具,如果要使用它,方法就是这样。
霍华德·辛南特

@Yakk:在我的答案中搜索字符串“ C ++ 14”。
霍华德·辛南特

啊,对不起,我错过了C ++ 14位。
Yakk-亚当·内夫罗蒙特2015年

2
很棒的解释@HowardHinnant!在C ++ 17中,您还可以使用std :: scoped_lock lock(x.mut_,y_mut_); 这样,你要靠实现以适当的顺序来锁定几个互斥

7

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

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

它现在是完全可移动的类型,并且在发生移动时在内部互斥锁上处于锁定状态的任何人仍然是安全的,即使它是否是一件好事也存在争议

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

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

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元任意操作可以一起工作,而无需事先对其进行硬编码。添加一个好友声明synchronized,多种类型的n元对象可能会一起工作。access在那种情况下,我可能不得不成为内联朋友,以应对超载冲突。

现场例子


4

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

想象一下一个“生产者”线程,该线程生产成批的字符串并将其提供给(一个或多个)消费者。那些批次可以由包含(可能很大)std::vector<std::string>对象的对象表示。我们绝对希望将这些向量的内部状态“移动”到它们的使用者中,而不必进行不必要的重复。

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

您需要哪种锁定取决于算法或对象的通用性以及允许的使用范围。

如果只从共享状态的“生产者”对象移动到线程本地的“消费”对象,则可以只锁定对象移动对象。

如果是更一般的设计,则需要同时锁定两者。在这种情况下,您需要考虑死锁。

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

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

最后,您需要确保您了解移动语义。回想一下,从对象移出的对象处于有效但未知的状态。不执行移动的线程很有可能有充分的理由尝试在找到有效但未知的状态时尝试从对象访问移动的对象。

同样,我的生产者只是在敲弦,而消费者则在承担全部负担。在那种情况下,生产者每次尝试将向量添加到向量时,都可能会发现向量为非空或空。

简而言之,如果对从对象移动的潜在并发访问等于一次写操作,则可能没问题。如果等于读取,请考虑为什么可以读取任意状态。


3

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

但是,如果您仍然决定这样做,则必须在move构造函数中创建一个新的互斥体,例如:

// movable
struct B{};

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

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


2
那不是线程安全的。如果a.mutex锁定,该怎么办::您松开该状态。-1

2
@DieterLücking只要参数是对移出对象的唯一引用,就没有理智地锁定其互斥锁。即使是这样,也没有理由锁定新创建对象的互斥体。如果有的话,这就是带有互斥体的可移动对象的总体设计不良的一个论点。
安东·萨文

1
@DieterLücking这不是真的。您可以提供说明问题的代码吗?而不是形式A a; A a2(std::move(a)); do some stuff with a
安东·萨文

2
但是,如果这是最好的方法,那么无论如何我都会建议-new将该实例放置在一个实例中std::unique_ptr-看起来更干净,并且不太可能引起混乱。好问题。
Mike Vine

1
@MikeVine我认为您应该将其添加为答案。
安东·萨文
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.