Copy constructor from non-const argument suggested by thread-safety rules?

淺唱寂寞╮ 提交于 2020-02-21 10:29:14

问题


I have a wrapper to some piece of legacy code.

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
};

In this legacy code, the function that “duplicates” an object is not thread safe (when calling on the same first argument), therefore it is not marked const in the wrapper. I guess following modern rules: https://herbsutter.com/2013/01/01/video-you-dont-know-const-and-mutable/

This duplicate looks like a good way to implement a copy constructor, except for the detail that is it not const. Therefore I cannot do this directly:

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;}
};

So what is the way out this paradoxical situation?

(Let's say also that legacy_duplicate is not thread-safe but I know leaves the object in the original state when it exits. Being a C-function the behavior is only documented but has no concept of constness.)

I can think of many possible scenarios:

(1) One possibility is that there is no way to implement a copy constructor with the usual semantics at all. (Yes, I can move the object and that is not what I need.)

(2) On the other hand, copying an object is inherently non-thread-safe in the sense that copying a simple type can find the source in an half-modified state, so I can just go forward and do this perhaps,

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) or even just declare duplicate const and lie about thread safety in all contexts. (After all the legacy function doesn't care about const so the compiler will not even complain.)

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

(4) Finally, I can follow the logic and make a copy-constructor that takes a non-const argument.

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

It turns out that this works in many contexts, because these objects are not usually const.

The question is, it this a valid or common route?

I cannot name them, but I intuitively expect lots of problems down the road of having a non-const copy constructor. Probably it will not qualify as a value-type because of this subtlety.

(5) Finally, although this seems to be an overkill and could have a steep runtime cost, I could add a mutex:

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;
};

But being forced to do this looks like pessimization and makes the class bigger. I am not sure. I am currently leaning towards (4), or (5) or a combination of both.

—— EDIT

Another option:

(6) Forget about all the non-sense of the duplicate member function and simply call legacy_duplicate from the constructor and declare that the copy constructor is not thread safe. (And if necessary make another thread-safe versión of the type, A_mt)

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

EDIT 2

This could be a good model for what the legacy function does. Note that by touching the input the call is not thread safe with respect to the value represented by the first argument.

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:


I would just include both your options (4) and (5), but explicitly opt-in to thread-unsafe behavior when you think it is necessary for performance.

Here is a complete example.

#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);
}

Output:

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

This follows the Google style guide in which const communicates thread safety, but code calling your API can opt-out using const_cast




回答2:


TLDR: Either fix the implementation of your duplication function, or introduce a mutex (or some more appropriate locking device, perhaps a spinlock, or make sure your mutex is configured to spin before doing anything heavier) for now, then fix the implementation of duplication and remove the locking when the locking actually becomes a problem.

I think a key point to notice is that you are adding a feature that did not exist before: the ability to duplicate an object from multiple threads at the same time.

Obviously, under the conditions you have decribed, that would have been a bug - a race condition, if you had been doing that before, without using some kind of external synchronization.

Therefore, any use of the this new feature will be something you add to your code, not inherit as existing functionality. You should be the one who knows whether adding the extra locking will actually be costly - depending on how often you are going to be using this new feature.

Also, based on the perceived complexity of the object - by the special treatment you are giving it, I'm going to assume that the duplication procedure is not a trivial one, therefore, already quite expensive in terms of performance.

Based on the above, you have two paths you can follow:

A) You know that copying this object from multiple threads will not happen often enough for the overhead of the additional locking to be costly - perfhaps trivially cheap, at least given that the existing duplication procedure is costly enough on its own, if you use a spinlock/pre-spinning mutex, and there is no contention on it.

B) You suspect that copying from multiple threads will happen often enough for the extra locking to be a problem. Then you really have only one option - fix your duplication code. If you don't fix it, you will need locking anyway, whether at this layer of abstraction or somewhere else, but you will need it if you don't want bugs - and as we've established, in this path, you assume that locking will be too costly, therefore, the only option is fixing the duplication code.

I suspect that you are really in situation A, and just adding a spinlock/spinning mutex that has close to no performance penalty when uncontested, will work just fine (remember to benchmark it, though).

There is, in theory, another situation:

C) In contrast to the seeming complexity of the duplication function, it is actually trivial, but can't be fixed for some reason; it is so trivial that even an uncontested spinlock introduces an unacceptable performance degradation to duplication; duplication on parallell threads is used rarely; duplication on a single thread is used all the time, making the performance degradation absolutely unacceptable.

In this case, I suggest the following: declare the default copy constructors / operators deleted, to prevent anyone from using them accidentally. Create two explicitly callable duplication methods, a thread safe one, and a thread unsafe one; make your users call them explicitly, depending on context. Again, there is no other way to achieve acceptable single thread performance and safe multi threading, if you really are in this situation and you just can't fix the existing duplication implementation. But I feel it is highly unlikely that you really are.

Just add that mutex/spinlock and benchmark.



来源:https://stackoverflow.com/questions/60087792/copy-constructor-from-non-const-argument-suggested-by-thread-safety-rules

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!