Sao chép hàm tạo với đối số không const được đề xuất bởi các quy tắc an toàn luồng?


9

Tôi có một trình bao bọc cho một số mã di sản.

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

Trong mã kế thừa này, hàm mà Sao chép lại một đối tượng không phải là một chủ đề an toàn (khi gọi cùng một đối số đầu tiên), do đó nó không được đánh dấu consttrong trình bao bọc. Tôi đoán theo các quy tắc hiện đại: https://herbsutter.com/2013/01/01/video-you-dont-ledge-const-and-mutable/

Đây có duplicatevẻ là một cách tốt để thực hiện một hàm tạo sao chép, ngoại trừ chi tiết không phải là nó const. Vì vậy, tôi không thể làm điều này trực tiếp:

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

Vậy đâu là lối thoát cho tình huống nghịch lý này?

(Chúng ta cũng nói rằng legacy_duplicatenó không an toàn cho luồng nhưng tôi biết để đối tượng ở trạng thái ban đầu khi nó thoát. Là một hàm C, hành vi chỉ được ghi lại nhưng không có khái niệm về hằng số.)

Tôi có thể nghĩ về nhiều tình huống có thể xảy ra:

(1) Một khả năng là không có cách nào để thực hiện một hàm tạo sao chép với ngữ nghĩa thông thường cả. (Vâng, tôi có thể di chuyển đối tượng và đó không phải là thứ tôi cần.)

(2) Mặt khác, sao chép một đối tượng vốn không an toàn theo chủ đề theo nghĩa là sao chép một loại đơn giản có thể tìm thấy nguồn ở trạng thái được sửa đổi một nửa, vì vậy tôi có thể tiếp tục và làm điều này có lẽ,

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) hoặc thậm chí chỉ cần khai báo duplicateconst và nói dối về an toàn luồng trong tất cả các bối cảnh. (Sau khi tất cả các chức năng kế thừa không quan tâm constnên trình biên dịch thậm chí sẽ không phàn nàn.)

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

(4) Cuối cùng, tôi có thể theo logic và tạo một hàm tạo sao chép có đối số không phải là 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;}
};

Nó chỉ ra rằng điều này hoạt động trong nhiều bối cảnh, bởi vì những đối tượng này thường không const.

Câu hỏi là, đây là một tuyến đường hợp lệ hay phổ biến?

Tôi không thể đặt tên cho chúng, nhưng tôi trực giác mong đợi rất nhiều vấn đề trong quá trình có một nhà xây dựng bản sao không phải là const. Có lẽ nó sẽ không đủ điều kiện là một loại giá trị vì sự tinh tế này.

(5) Cuối cùng, mặc dù điều này có vẻ là quá mức cần thiết và có thể có chi phí thời gian chạy cao, tôi có thể thêm một 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;
};

Nhưng bị buộc phải làm điều này có vẻ như bi quan và làm cho lớp học lớn hơn. Tôi không chắc. Tôi hiện đang nghiêng về (4) , hoặc (5) hoặc kết hợp cả hai.

- EDIT

Một lựa chọn khác:

(6) Quên tất cả ý nghĩa của hàm thành viên trùng lặp và chỉ cần gọi legacy_duplicatetừ hàm tạo và khai báo rằng hàm tạo sao chép không phải là luồng an toàn. (Và nếu cần, hãy tạo một loại khác an toàn cho chủ đề của loại này, A_mt)

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

CHỈNH SỬA 2

Đây có thể là một mô hình tốt cho những gì chức năng kế thừa làm. Lưu ý rằng bằng cách chạm vào đầu vào, cuộc gọi không phải là chuỗi an toàn đối với giá trị được biểu thị bằng đối số đầu tiên.

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
" Trong mã kế thừa này, chức năng sao chép một đối tượng không phải là luồng an toàn (khi gọi cùng một đối số đầu tiên) " Bạn có chắc chắn về điều đó không? Có một số trạng thái không có trong Lđó được sửa đổi bằng cách tạo một Lthể hiện mới ? Nếu không, tại sao bạn tin rằng hoạt động này không an toàn cho luồng?
Nicol Bolas

Vâng, đó là tình huống. Có vẻ như trạng thái bên trong của đối số đầu tiên được sửa đổi trong quá trình xuất hiện. Vì một số lý do (một số "tối ưu hóa" hoặc thiết kế xấu hoặc đơn giản là theo đặc tả), hàm legacy_duplicatecó thể được gọi với cùng một đối số đầu tiên từ hai luồng khác nhau.
alfC

@TedLyngmo ok tôi đã làm. Mặc dù về mặt kỹ thuật trong c ++ pre 11 const có ý nghĩa mờ hơn trong sự hiện diện của các chủ đề.
alfC

@TedLyngmo vâng, nó là một video khá hay. thật đáng tiếc khi video chỉ đề cập đến các thành viên phù hợp và không đề cập đến vấn đề xây dựng (cũng là điều không thể thiếu trên đối tượng khác. Trong viễn cảnh, có thể không có cách nội tại nào làm cho luồng trình bao bọc này an toàn khi sao chép mà không cần thêm một lớp trừu tượng khác (và một biến thể cụ thể).
alfC

Vâng, điều đó làm tôi bối rối và có lẽ tôi là một trong những người không biết ý constnghĩa thực sự của nó. :-) Tôi sẽ không nghĩ hai lần về việc lấy một const&bản sao của mình miễn là tôi không sửa đổi other. Tôi luôn nghĩ về sự an toàn của luồng như một thứ gì đó thêm vào trên bất kỳ thứ gì cần được truy cập từ nhiều luồng, thông qua việc đóng gói và tôi thực sự mong chờ câu trả lời.
Ted Lyngmo

Câu trả lời:


0

Tôi sẽ chỉ bao gồm cả hai tùy chọn của bạn (4) và (5), nhưng rõ ràng chọn tham gia vào hành vi không an toàn của luồng khi bạn nghĩ rằng nó là cần thiết cho hiệu suất.

Dưới đây là một ví dụ đầy đủ.

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

Đầu ra:

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

Điều này tuân theo hướng dẫn kiểu Google trong đó consttruyền đạt sự an toàn của luồng, nhưng mã gọi API của bạn có thể từ chối sử dụngconst_cast


Cảm ơn bạn đã trả lời, tôi nghĩ rằng nó không thay đổi asnwer của bạn và tôi không chắc chắn nhưng một mô hình tốt hơn legacy_duplicatecó thể là 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; }(tức là không phải là const in)
alfC

Câu trả lời của bạn rất thú vị vì nó có thể được kết hợp với tùy chọn (4) và phiên bản rõ ràng của tùy chọn (2). Đó là, A a2(a1)có thể cố gắng để an toàn chủ đề (hoặc bị xóa) và A a2(const_cast<A&>(a1))sẽ không cố gắng để an toàn chủ đề.
alfC

2
Có, nếu bạn có kế hoạch sử dụng Atrong cả bối cảnh an toàn luồng và không an toàn luồng, bạn nên kéo const_castmã cuộc gọi để rõ ràng nơi an toàn luồng được biết là bị vi phạm. Bạn có thể đẩy an toàn thêm đằng sau API (mutex) nhưng không sao để che giấu sự không an toàn (const_cast).
Michael Grachot

0

TLDR: Hãy khắc phục việc thực hiện các chức năng sao chép của bạn, hoặc giới thiệu một mutex (hoặc một số thiết bị khóa thích hợp hơn, có lẽ một spinlock, hoặc đảm bảo mutex của bạn được cấu hình để quay trước khi làm bất cứ điều gì nặng) cho bây giờ , sau đó sửa chữa thực hiện trùng lặp và loại bỏ khóa khi khóa thực sự trở thành một vấn đề.

Tôi nghĩ một điểm quan trọng cần chú ý là bạn đang thêm một tính năng không tồn tại trước đó: khả năng sao chép một đối tượng từ nhiều luồng cùng một lúc.

Rõ ràng, trong các điều kiện bạn đã giải mã, đó sẽ là một lỗi - một điều kiện chủng tộc, nếu bạn đã làm điều đó trước đây, mà không sử dụng một số loại đồng bộ hóa bên ngoài.

Do đó, bất kỳ việc sử dụng tính năng mới này sẽ là thứ bạn thêm vào mã của mình, không kế thừa chức năng hiện có. Bạn nên là người biết liệu việc thêm khóa có thực sự tốn kém hay không - tùy thuộc vào tần suất bạn sẽ sử dụng tính năng mới này.

Ngoài ra, dựa trên mức độ phức tạp nhận thức của đối tượng - bằng cách đối xử đặc biệt mà bạn đang đưa ra, tôi sẽ giả định rằng quy trình sao chép không phải là một công việc tầm thường, do đó, khá tốn kém về mặt hiệu suất.

Dựa trên những điều trên, bạn có hai con đường bạn có thể đi theo:

A) Bạn biết rằng việc sao chép đối tượng này từ nhiều luồng sẽ không xảy ra thường xuyên đủ để chi phí khóa bổ sung trở nên tốn kém - rất rẻ, ít nhất là cho rằng thủ tục sao chép hiện tại là đủ tốn kém, nếu bạn sử dụng một spinlock / mutex pre-spin, và không có tranh chấp về nó.

B) Bạn nghi ngờ rằng việc sao chép từ nhiều luồng sẽ xảy ra thường xuyên đủ để khóa thêm là một vấn đề. Sau đó, bạn thực sự chỉ có một tùy chọn - sửa mã trùng lặp của bạn. Nếu bạn không sửa nó, dù sao bạn cũng sẽ cần khóa, cho dù ở lớp trừu tượng này hay ở nơi nào khác, nhưng bạn sẽ cần nó nếu bạn không muốn lỗi - và như chúng tôi đã thiết lập, trong đường dẫn này, bạn giả sử việc khóa đó sẽ quá tốn kém, do đó, lựa chọn duy nhất là sửa mã trùng lặp.

Tôi nghi ngờ rằng bạn thực sự đang ở trong tình huống A, và chỉ cần thêm một spinex / spin mutex gần như không bị phạt hiệu suất khi không kiểm chứng, sẽ hoạt động tốt (mặc dù vậy, hãy nhớ điểm chuẩn).

Về lý thuyết, có một tình huống khác:

C) Trái ngược với sự phức tạp dường như của chức năng sao chép, nó thực sự không quan trọng, nhưng không thể được sửa chữa vì một số lý do; nó tầm thường đến nỗi ngay cả một spinlock không được kiểm chứng cũng đưa ra sự xuống cấp hiệu suất không thể chấp nhận được đối với sự trùng lặp; trùng lặp trên các chủ đề parallell được sử dụng hiếm khi; sao chép trên một luồng duy nhất được sử dụng mọi lúc, làm giảm hiệu suất hoàn toàn không thể chấp nhận được.

Trong trường hợp này, tôi đề nghị như sau: khai báo các hàm tạo / toán tử sao chép mặc định đã bị xóa, để ngăn chặn bất kỳ ai vô tình sử dụng chúng. Tạo hai phương thức sao chép có thể gọi rõ ràng, một phương thức an toàn một luồng và một phương thức không an toàn; làm cho người dùng của bạn gọi họ một cách rõ ràng, tùy thuộc vào ngữ cảnh. Một lần nữa, không có cách nào khác để đạt được hiệu suất luồng đơn chấp nhận được và đa luồng an toàn, nếu bạn thực sự ở trong tình huống này và bạn không thể sửa lỗi triển khai sao chép hiện có. Nhưng tôi cảm thấy rất khó có khả năng là bạn thực sự.

Chỉ cần thêm mutex / spinlock và điểm chuẩn.


Bạn có thể chỉ cho tôi tài liệu về spinlock / mutex trước khi quay trong C ++ không? Có một cái gì đó phức tạp hơn những gì được cung cấp bởi std::mutex? Chức năng trùng lặp không có gì bí mật, tôi đã không đề cập đến nó để giữ vấn đề ở mức cao và không nhận được câu trả lời về Bộ KH & ĐT. Nhưng vì bạn đã đi sâu đến mức tôi có thể cung cấp cho bạn thêm chi tiết. Hàm kế thừa là MPI_Comm_dupvà độ an toàn phi luồng hiệu quả được mô tả ở đây (tôi đã xác nhận nó) github.com/pmodels/mpich/issues/3234 . Đây là lý do tại sao tôi không thể sửa chữa trùng lặp. (Ngoài ra, nếu tôi thêm một mutex, tôi sẽ cố gắng thực hiện tất cả các cuộc gọi MPI an toàn cho chuỗi.)
alfC

Đáng buồn là tôi không biết nhiều std :: mutex, nhưng tôi đoán nó sẽ quay một số trước khi để quá trình ngủ. Một thiết bị đồng bộ hóa nổi tiếng nơi bạn có thể điều khiển thủ công này là: docs.microsoft.com/en-us/windows/win32/api/synchapi/ Lỗi Tôi chưa so sánh hiệu suất, nhưng có vẻ như std :: mutex là bây giờ vượt trội: stackoverflow.com/questions/9997473/ và được triển khai bằng cách sử dụng: docs.microsoft.com/en-us/windows/win32/sync/ phỏng
DeducibleSteak

Có vẻ như đây là mô tả hay về những cân nhắc chung cần tính đến: stackoverflow.com/questions/5869825/ Lời
DeducibleSteak

Cảm ơn một lần nữa, tôi đang ở trong Linux nếu điều đó quan trọng.
alfC

Dưới đây là một so sánh hiệu suất khá chi tiết (đối với một ngôn ngữ khác, nhưng tôi đoán đây là thông tin và chỉ dẫn về những gì mong đợi): matklad.github.io/2020/01/04/ . lề khi không có tranh chấp, có thể thua nặng khi có tranh chấp.
DeducibleSteak
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.