复制具有线程安全规则建议的非const参数的构造函数?


9

我有一些旧代码的包装。

class A{
   L* impl_; // the legacy object has to be in the heap, could be also unique_ptr
   A(A const&) = delete;
   L* duplicate(){L* ret; legacy_duplicate(impl_, &L); return ret;}
   ... // proper resource management here
};

在此旧版代码中,“复制”对象的函数不是线程安全的(调用相同的第一个参数时),因此const在包装器中未对其进行标记。我猜想遵循现代规则:https : //herbsutter.com/2013/01/01/video-you-dont-know-const-and-mutable/

duplicate看起来是实现复制构造函数的一种好方法,除了细节不是const。因此,我不能直接这样做:

class A{
   L* impl_; // the legacy object has to be in the heap
   A(A const& other) : L{other.duplicate()}{} // error calling a non-const function
   L* duplicate(){L* ret; legacy_duplicate(impl_, &ret); return ret;}
};

那么如何解决这种矛盾的情况呢?

(也可以说这legacy_duplicate不是线程安全的,但是我知道对象退出时将其保持在原始状态。作为C函数,该行为仅记录在案,而没有常量性的概念。)

我可以想到许多可能的情况:

(1)一种可能性是根本没有办法实现具有通常语义的副本构造函数。(是的,我可以移动对象,而这不是我所需要的。)

(2)另一方面,复制对象本质上是非线程安全的,因为复制简单类型可以找到处于半修改状态的源,因此我可以继续进行此操作,

class A{
   L* impl_;
   A(A const& other) : L{const_cast<A&>(other).duplicate()}{} // error calling a non-const function
   L* duplicate(){L* ret; legacy_duplicate(impl_, &ret); return ret;}
};

(3)甚至只是声明duplicateconst并在所有上下文中都涉及线程安全。(毕竟,旧版功能不在乎,const因此编译器甚至不会抱怨。)

class A{
   L* impl_;
   A(A const& other) : L{other.duplicate()}{}
   L* duplicate() const{L* ret; legacy_duplicate(impl_, &ret); return ret;}
};

(4)最后,我可以遵循逻辑并制作一个采用非const参数的复制构造函数

class A{
   L* impl_;
   A(A const&) = delete;
   A(A& other) : L{other.duplicate()}{}
   L* duplicate(){L* ret; legacy_duplicate(impl_, &ret); return ret;}
};

事实证明,这在许多情况下都有效,因为这些对象通常不是const

问题是,这是有效路线还是普通路线?

我无法命名它们,但是凭直觉我期望在使用非const复制构造函数的过程中会遇到很多问题。由于这种微妙之处,它可能不符合价值类型的要求。

(5)最后,尽管这似乎是一个过大的选择,并且可能会花费很高的运行时间,但我可以添加一个互斥量:

class A{
   L* impl_;
   A(A const& other) : L{other.duplicate_locked()}{}
   L* duplicate(){
      L* ret; legacy_duplicate(impl_, &ret); return ret;
   }
   L* duplicate_locked() const{
      std::lock_guard<std::mutex> lk(mut);
      L* ret; legacy_duplicate(impl_, &ret); return ret;
   }
   mutable std::mutex mut;
};

但是被迫这样做看起来像是悲观,使班级扩大了。我不确定。我目前倾向于(4)(5)或两者兼而有之。

- 编辑

另外一个选项:

(6)忽略所有重复成员函数的废话,只需legacy_duplicate从构造函数中调用并声明复制构造函数不是线程安全的。(并在必要时制作另一种类型的线程安全版本,A_mt

class A{
   L* impl_;
   A(A const& other){legacy_duplicate(other.impl_, &impl_);}
};

编辑2

这对于遗留函数的功能而言可能是一个很好的模型。请注意,通过触摸输入,相对于第一个参数表示的值,该调用不是线程安全的。

void legacy_duplicate(L* in, L** out){
   *out = new L{};
   char tmp = in[0];
   in[0] = tmp; 
   std::memcpy(*out, in, sizeof *in); return; 
}

1
在此旧版代码中,复制对象的函数不是线程安全的(调用相同的第一个参数时) ”“您确定吗?是否存在不包含L通过创建新L实例对其进行修改的状态?如果不是,为什么您认为此操作不是线程安全的?
Nicol Bolas

是的,就是这种情况。看起来在执行期间第一个参数的内部状态被修改。由于某些原因(某些“优化”或不良的设计,或者仅是通过规格说明),legacy_duplicate无法使用来自两个不同线程的相同第一个参数调用该函数。
alfC

@TedLyngmo好吧,我做到了。尽管从技术上讲,在c ++ pre 11中,const在存在线程的情况下具有更模糊的含义。
alfC

@TedLyngmo是的,这是一个非常不错的视频。遗憾的是,视频仅与适当的成员打交道,而没有涉及构造问题(在“其他”对象上也具有恒定性)。从透视图上看,可能没有使该包装器线程在复制时变得安全的内在方式,而无需添加另一层抽象(和具体的互斥体)。
alfC

是的,那让我感到困惑,我可能就是那些不知道const真正含义的人之一。:-) const&只要我不修改,我就不会三思而后行地把我的复制ctor放入other。我一直认为线程安全是通过封装从多个线程访问任何需要添加的东西,我真的很期待答案。
Ted

Answers:


0

我只是同时包括了选项(4)和(5),但是当您认为对性能而言是必要的时,将明确选择加入线程不安全的行为。

这是一个完整的例子。

#include <cstdlib>
#include <thread>

struct L {
  int val;
};

void legacy_duplicate(const L* in, L** out) {
  *out = new L{};
  std::memcpy(*out, in, sizeof *in);
  return;
}

class A {
 public:
  A(L* l) : impl_{l} {}
  A(A const& other) : impl_{other.duplicate_locked()} {}

  A copy_unsafe_for_multithreading() { return {duplicate()}; }

  L* impl_;

  L* duplicate() {
    printf("in duplicate\n");
    L* ret;
    legacy_duplicate(impl_, &ret);
    return ret;
  }
  L* duplicate_locked() const {
    std::lock_guard<std::mutex> lk(mut);
    printf("in duplicate_locked\n");
    L* ret;
    legacy_duplicate(impl_, &ret);
    return ret;
  }
  mutable std::mutex mut;
};

int main() {
  A a(new L{1});
  const A b(new L{2});

  A c = a;
  A d = b;

  A e = a.copy_unsafe_for_multithreading();
  A f = const_cast<A&>(b).copy_unsafe_for_multithreading();

  printf("\npointers:\na=%p\nb=%p\nc=%p\nc=%p\nd=%p\nf=%p\n\n", a.impl_,
     b.impl_, c.impl_, d.impl_, e.impl_, f.impl_);

  printf("vals:\na=%d\nb=%d\nc=%d\nc=%d\nd=%d\nf=%d\n", a.impl_->val,
     b.impl_->val, c.impl_->val, d.impl_->val, e.impl_->val, f.impl_->val);
}

输出:

in duplicate_locked
in duplicate_locked
in duplicate
in duplicate

pointers:
a=0x7f85e8c01840
b=0x7f85e8c01850
c=0x7f85e8c01860
c=0x7f85e8c01870
d=0x7f85e8c01880
f=0x7f85e8c01890

vals:
a=1
b=2
c=1
c=2
d=1
f=2

这遵循了Google样式指南,在该指南const传达了线程安全性,但是调用API的代码可以选择退出使用const_cast


谢谢你的答案,我想它不会改变你的asnwer,我不知道,但对于一个更好的模型legacy_duplicate可能是void legacy_duplicate(L* in, L** out) { *out = new L{}; char tmp = in[0]; /*some weird call here*/; in[0] = tmp; std::memcpy(*out, in, sizeof *in); return; }(即非const in
alfC

您的答案非常有趣,因为它可以与选项(4)和选项(2)的显式版本结合使用。也就是说,A a2(a1)可以尝试成为线程安全的(或被删除),而A a2(const_cast<A&>(a1))根本不尝试成为线程安全的。
alfC

2
是的,如果您打算同时A在线程安全和线程不安全的上下文中使用,则应将其拉到const_cast调用代码,以便清楚地知道在哪里违反了线程安全。可以将额外的安全性推到API(互斥体)后面,但是可以隐藏不安全性(const_cast)。
Michael Graczyk

0

TLDR:要么解决您的重复数据删除功能的实现,或引入一个互斥(或更合适一些锁定装置,也许是一个自旋锁,或做任何事情较重之前,请确保您的互斥体被配置为旋)现在,然后修复重复执行并在实际出现锁定问题时删除锁定。

我认为需要注意的关键点是,您要添加一个以前不存在的功能:能够同时从多个线程复制对象的功能。

显然,在您描述的条件下,这将是一个错误-竞赛条件,如果您以前曾这样做,而未使用某种外部同步。

因此,对该新功能的任何使用都将添加到代码中,而不是作为现有功能继承。您应该是一个知道添加额外锁定是否实际上会很昂贵的人-具体取决于您将使用该新功能的频率。

同样,基于对象的感知复杂性-通过给予您特殊的待遇,我将假定复制过程不是一件容易的事,因此,就性能而言已经很昂贵了。

基于上述内容,您可以遵循两个路径:

A)您知道从多个线程复制该对象不会经常发生,以致额外锁定的开销非常昂贵-可能微不足道,至少考虑到现有的复制过程本身就足够昂贵,如果您使用自旋锁/预旋转互斥锁,并且没有争用。

B)您怀疑从多个线程进行复制经常会发生,从而导致额外的锁定成为问题。然后,您实际上只有一个选择-修复重复代码。如果您不修复它,则无论如何都需要锁定,无论是在此抽象层还是在其他位置,但是如果您不想使用错误,则将需要使用它-正如我们已经确定的那样,在此路径中,您假设锁定成本太高,因此,唯一的选择就是修复重复代码。

我怀疑您确实处于情况A,仅添加自旋锁/自旋互斥锁即可在没有竞争的情况下几乎不降低性能,但效果很好(不过请记住要对其进行基准测试)。

从理论上讲,还有另一种情况:

C)与复制函数看似复杂相比,它实际上是微不足道的,但由于某些原因无法修复;它是如此琐碎,以至于即使是无可争议的自旋锁也会导致复制性能下降到无法接受的程度。并行线程上的复制很少使用;一直在单个线程上使用重复,因此绝对不能接受性能下降。

在这种情况下,我建议您执行以下操作:声明默认的复制构造函数/运算符已删除,以防止任何人意外使用它们。创建两个可显式调用的复制方法,一个线程安全的方法和一个线程不安全的方法;让用户根据上下文明确地给他们打电话。同样,如果确实处于这种情况下,并且无法修复现有的复制实现,则没有其他方法可以实现可接受的单线程性能和安全的多线程。但是我觉得你真的不太可能。

只需添加该互斥锁/自旋锁和基准。


您能指出C ++中自旋锁/预旋转互斥锁的资料吗?提供的内容是否更复杂std::mutex?复制函数不是秘密,我没有提到它是为了将问题保持在较高水平,并且不会收到有关MPI的答案。但是自从您深入研究之后,我可以为您提供更多详细信息。遗留函数是MPI_Comm_dup,有效的非线程安全性在此处描述(我确认了)github.com/pmodels/mpich/issues/3234。这就是为什么我无法修复重复的原因。(此外,如果我添加一个互斥锁,我将很容易使所有MPI调用都成为线程安全的。)
alfC

遗憾的是,我对std :: mutex的了解不多,但是我猜想它在使进程进入睡眠之前会进行一些旋转。您可以手动控制它的著名同步设备是:docs.microsoft.com/zh-cn/windows/win32/api/synchapi / ...我没有比较性能,但似乎std :: mutex是现在上级:stackoverflow.com/questions/9997473/...:采用和实施docs.microsoft.com/en-us/windows/win32/sync/...
DeducibleSteak

看来这是一般考虑的很好的描述考虑到:stackoverflow.com/questions/5869825/...
DeducibleSteak

再次感谢,如果重要的话,我在Linux中。
alfC

这里有一个较详细的性能比较(对于不同的语言,但我想这是信息和指示什么样的期待):matklad.github.io/2020/01/04/... 的TLDR是-自旋锁赢得由一个非常小没有争用时的保证金,有争用时可能会严重损失。
DeducibleSteak
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.