Tôi nên xử lý như thế nào với mutexes trong các loại có thể di chuyển trong C ++?


85

Theo thiết kế, std::mutexkhông thể di chuyển cũng như không thể sao chép. Điều này có nghĩa là một lớp A, chứa một mutex, sẽ không nhận một phương thức khởi tạo-di chuyển mặc định.

Làm cách nào để làm cho loại này Acó thể di chuyển được theo cách an toàn cho luồng?


4
Câu hỏi có một khó khăn: Liệu bản thân hoạt động di chuyển cũng là an toàn theo luồng hay là đủ nếu các truy cập khác vào đối tượng là an toàn cho luồng?
Jonas Schäfer

2
@paulm Điều đó thực sự phụ thuộc vào thiết kế. Tôi thường thấy một lớp có biến thành viên mutex, sau đó chỉ có std::lock_guardphạm vi phương thức is.
Cory Kramer

2
@Jonas Wielicki: Lúc đầu, tôi nghĩ việc di chuyển nó cũng phải an toàn. Tuy nhiên, không phải tôi nghĩ lại về nó, điều này không có ý nghĩa gì lắm, vì việc di chuyển-xây dựng một đối tượng thường làm mất hiệu lực trạng thái của đối tượng cũ. Vì vậy, các luồng khác phải không thể truy cập đối tượng cũ, nếu nó sẽ được di chuyển .. nếu không chúng có thể sớm truy cập vào một đối tượng không hợp lệ. Tôi nói đúng chứ?
Jack Sabbath

2
vui lòng theo liên kết này có thể sử dụng đầy đủ cho nó justsoftwaresolutions.co.uk/threading/…
Ravi Chauhan

1
@Dieter Lücking: vâng, đây là ý tưởng .. mutex M bảo vệ lớp B. Tuy nhiên, tôi lưu trữ cả hai ở đâu để có một đối tượng có thể truy cập, an toàn theo luồng? Cả M và B đều có thể vào lớp A .. và trong trường hợp này, lớp A sẽ có Mutex ở phạm vi lớp.
Jack Sabbath

Câu trả lời:


104

Hãy bắt đầu với một đoạn mã:

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:
    ...

Tôi đã đặt một số bí danh kiểu khá gợi ý mà chúng ta sẽ không thực sự tận dụng trong C ++ 11, nhưng trở nên hữu ích hơn nhiều trong C ++ 14. Hãy kiên nhẫn, chúng tôi sẽ đến đó.

Câu hỏi của bạn tổng hợp thành:

Làm cách nào để viết hàm tạo chuyển và toán tử gán di chuyển cho lớp này?

Chúng ta sẽ bắt đầu với hàm tạo di chuyển.

Move Constructor

Lưu ý rằng thành viên mutexđã được thực hiện mutable. Nói một cách chính xác thì điều này không cần thiết đối với các thành viên di chuyển, nhưng tôi cho rằng bạn cũng muốn các thành viên sao chép. Nếu không phải như vậy, không cần thiết phải tạo mutex mutable.

Khi thi công Akhông cần khóa this->mut_. Nhưng bạn cần phải khóa mut_đối tượng mà bạn đang xây dựng (di chuyển hoặc sao chép). Điều này có thể được thực hiện như vậy:

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

Lưu ý rằng chúng tôi phải xây dựng mặc định các thành viên của thisđầu tiên, và sau đó chỉ định giá trị cho chúng sau khi a.mut_bị khóa.

Chuyển nhiệm vụ

Toán tử gán di chuyển về cơ bản phức tạp hơn nhiều vì bạn không biết liệu một số luồng khác có đang truy cập lhs hoặc rhs của biểu thức gán hay không. Và nói chung, bạn cần đề phòng trường hợp sau:

// Thread 1
x = std::move(y);

// Thread 2
y = std::move(x);

Đây là toán tử gán di chuyển bảo vệ chính xác tình huống trên:

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

Lưu ý rằng người ta phải sử dụng std::lock(m1, m2)để khóa hai mutex, thay vì chỉ khóa chúng lần lượt. Nếu bạn khóa chúng lần lượt, sau đó khi hai luồng gán hai đối tượng theo thứ tự ngược lại như hình trên, bạn có thể nhận được một deadlock. Vấn đề std::locklà tránh bế tắc đó.

Copy Constructor

Bạn đã không hỏi về các thành viên sao chép, nhưng chúng ta cũng có thể nói về họ ngay bây giờ (nếu không phải bạn, ai đó sẽ cần họ).

    A(const A& a)
    {
        ReadLock  rhs_lk(a.mut_);
        field1_ = a.field1_;
        field2_ = a.field2_;
    }

Hàm tạo bản sao trông giống như hàm tạo di chuyển ngoại trừ ReadLockbí danh được sử dụng thay cho WriteLock. Hiện tại, cả hai bí danh này std::unique_lock<std::mutex>và vì vậy nó không thực sự tạo ra bất kỳ sự khác biệt nào.

Nhưng trong C ++ 14, bạn sẽ có tùy chọn nói thế này:

    using MutexType = std::shared_timed_mutex;
    using ReadLock  = std::shared_lock<MutexType>;
    using WriteLock = std::unique_lock<MutexType>;

Đây có thể là một sự tối ưu hóa, nhưng không chắc chắn. Bạn sẽ phải đo để xác định xem nó có đúng như vậy không. Nhưng với sự thay đổi này, người ta có thể sao chép cấu trúc từ cùng một rhs trong nhiều luồng đồng thời. Giải pháp C ++ 11 buộc bạn phải thực hiện tuần tự các luồng như vậy, ngay cả khi rhs không được sửa đổi.

Sao chép bài tập

Để hoàn chỉnh, đây là toán tử gán bản sao, sẽ khá tự giải thích sau khi đọc về mọi thứ khác:

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

Và vân vân.

Bất kỳ thành viên nào khác hoặc các chức năng miễn phí có Atrạng thái của quyền truy cập cũng sẽ cần được bảo vệ nếu bạn muốn nhiều luồng có thể gọi chúng cùng một lúc. Ví dụ, đây là 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_);
        }
    }

Lưu ý rằng nếu bạn chỉ phụ thuộc vào std::swapviệc thực hiện công việc, việc khóa sẽ ở mức độ chi tiết sai, khóa và mở khóa giữa ba bước di chuyển std::swapsẽ thực hiện nội bộ.

Thật vậy, việc suy nghĩ về nó swapcó thể cung cấp cho bạn thông tin chi tiết về API mà bạn có thể cần cung cấp cho API "an toàn cho luồng" A, nói chung sẽ khác với API "không an toàn cho luồng" do vấn đề "khóa chi tiết".

Cũng cần lưu ý sự cần thiết phải bảo vệ chống lại "tự hoán đổi". "tự hoán đổi" phải là điều không nên. Nếu không có tự kiểm tra, một trong những sẽ khóa đệ quy cùng một mutex. Điều này cũng có thể được giải quyết mà không cần tự kiểm tra bằng cách sử dụng std::recursive_mutexcho MutexType.

Cập nhật

Trong các bình luận bên dưới, Yakk khá không hài lòng về việc phải tạo mặc định những thứ trong bản sao và di chuyển các hàm tạo (và anh ấy có lý). Nếu bạn cảm thấy đủ mạnh mẽ về vấn đề này, đến mức bạn sẵn sàng dành bộ nhớ cho nó, bạn có thể tránh nó như vậy:

  • Thêm bất kỳ loại khóa nào bạn cần làm thành viên dữ liệu. Các thành viên này phải đến trước dữ liệu đang được bảo vệ:

    mutable MutexType mut_;
    ReadLock  read_lock_;
    WriteLock write_lock_;
    // ... other data members ...
    
  • Và sau đó trong các hàm tạo (ví dụ: hàm tạo bản sao) thực hiện điều này:

    A(const A& a)
        : read_lock_(a.mut_)
        , field1_(a.field1_)
        , field2_(a.field2_)
    {
        read_lock_.unlock();
    }
    

Rất tiếc, Yakk đã xóa bình luận của anh ấy trước khi tôi có cơ hội hoàn thành bản cập nhật này. Nhưng anh ấy xứng đáng được ghi nhận vì đã thúc đẩy vấn đề này và tìm ra giải pháp cho câu trả lời này.

Cập nhật 2

Và dyp đã đưa ra gợi ý hay này:

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

2
Hàm tạo bản sao của bạn chỉ định các trường, nó không sao chép chúng. Điều đó có nghĩa là chúng cần được cấu trúc mặc định, đây là một hạn chế đáng tiếc.
Yakk - Adam Nevraumont

@Yakk: Vâng, đưa mutexesvào các loại lớp không phải là "một cách đúng". Nó là một công cụ trong hộp công cụ và nếu bạn muốn sử dụng nó, đây là cách thực hiện.
Howard Hinnant

@Yakk: Tìm kiếm câu trả lời của tôi cho chuỗi "C ++ 14".
Howard Hinnant

à, xin lỗi, tôi đã bỏ lỡ C ++ 14 bit đó.
Yakk - Adam Nevraumont

2
lời giải thích tuyệt vời @HowardHinnant! trong C ++ 17, bạn cũng có thể sử dụng std :: scoped_lock lock (x.mut_, y_mut_); Bằng cách đó, bạn dựa vào việc triển khai để khóa một số mutex theo thứ tự phù hợp
fen

7

Vì dường như không có cách nào đẹp, gọn gàng, dễ dàng để trả lời câu hỏi này - giải pháp của Anton tôi nghĩ là đúng nhưng nó chắc chắn gây tranh cãi, trừ khi có câu trả lời tốt hơn, tôi khuyên bạn nên đặt một lớp như vậy lên đống và chăm sóc nó qua một std::unique_ptr:

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

Bây giờ nó là loại có thể di chuyển hoàn toàn và bất kỳ ai có khóa mutex bên trong khi di chuyển xảy ra vẫn an toàn, ngay cả khi nó còn đang tranh cãi liệu đây có phải là điều tốt nên làm

Nếu bạn cần sao chép ngữ nghĩa chỉ cần sử dụng

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

5

Đây là một câu trả lời lộn ngược. Thay vì nhúng "đối tượng này cần được đồng bộ hóa" làm cơ sở của kiểu, thay vào đó hãy chèn nó dưới bất kỳ kiểu nào.

Bạn xử lý một đối tượng đồng bộ rất khác nhau. Một vấn đề lớn là bạn phải lo lắng về deadlock (khóa nhiều đối tượng). Về cơ bản, nó không bao giờ nên là "phiên bản mặc định của một đối tượng": các đối tượng được đồng bộ hóa dành cho các đối tượng sẽ tranh chấp và mục tiêu của bạn phải là giảm thiểu sự tranh chấp giữa các chủ đề, chứ không phải quét nó dưới tấm thảm.

Nhưng đồng bộ hóa các đối tượng vẫn hữu ích. Thay vì kế thừa từ bộ đồng bộ hóa, chúng ta có thể viết một lớp bao bọc một kiểu tùy ý trong đồng bộ hóa. Người dùng phải nhảy qua một vài vòng để thực hiện các thao tác trên đối tượng vì nó đã được đồng bộ hóa, nhưng họ không bị giới hạn trong một số tập hợp giới hạn được mã hóa thủ công trên đối tượng. Họ có thể gộp nhiều thao tác trên đối tượng thành một hoặc có một thao tác trên nhiều đối tượng.

Đây là một trình bao bọc được đồng bộ hóa xung quanh một loại tùy ý 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ác tính năng C ++ 14 và C ++ 1z bao gồm.

điều này giả định rằng các consthoạt động là an toàn cho nhiều đầu đọc (đó là những gì stdcác vùng chứa giả định).

Sử dụng trông giống như:

synchronized<int> x = 7;
x.read([&](auto&& v){
  std::cout << v << '\n';
});

cho một intvới quyền truy cập đồng bộ.

Tôi khuyên bạn không nên có synchronized(synchronized const&). Nó hiếm khi cần thiết.

Nếu bạn cần synchronized(synchronized const&), tôi muốn thay thế T t;bằng std::aligned_storage, cho phép xây dựng vị trí thủ công và phá hủy thủ công. Điều đó cho phép quản lý trọn đời thích hợp.

Nếu không, chúng tôi có thể sao chép nguồn T, sau đó đọc 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);})
  )
{}

để phân công:

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

vị trí và các phiên bản lưu trữ được căn chỉnh lộn xộn hơn một chút. Hầu hết quyền truy cập vào tsẽ được thay thế bằng một hàm thành viên T&t()T const&t()const, ngoại trừ khi xây dựng nơi bạn phải nhảy qua một số vòng.

Bằng cách tạo synchronizedmột trình bao bọc thay vì một phần của lớp, tất cả những gì chúng ta phải đảm bảo là nội bộ lớp tôn trọng constlà nhiều trình đọc và viết nó theo cách đơn luồng.

Trong một số trường hợp hiếm hoi, chúng tôi cần một cá thể được đồng bộ hóa, chúng tôi chuyển qua các vòng như trên.

Xin lỗi vì bất kỳ lỗi chính tả nào ở trên. Có thể có một số.

Một lợi ích phụ ở trên là n-ary các phép toán tùy ý trên synchronizedcác đối tượng (cùng loại) hoạt động cùng nhau, mà không cần phải viết mã trước. Thêm khai báo kết bạn và synchronizedcác đối tượng n-ary thuộc nhiều loại có thể hoạt động cùng nhau. Tôi có thể phải chuyển accessra khỏi vai trò là một người bạn nội tuyến để đối phó với những cuộc chiến quá tải trong trường hợp đó.

ví dụ trực tiếp


4

Sử dụng mutexes và C ++ chuyển ngữ nghĩa là một cách tuyệt vời để chuyển dữ liệu giữa các luồng một cách an toàn và hiệu quả.

Hãy tưởng tượng một chuỗi 'nhà sản xuất' tạo ra nhiều chuỗi chuỗi và cung cấp chúng cho (một hoặc nhiều) người tiêu dùng. Các lô đó có thể được đại diện bởi một đối tượng chứa các đối tượng (có thể lớn) std::vector<std::string>. Chúng tôi hoàn toàn muốn 'di chuyển' trạng thái bên trong của những vectơ đó vào người tiêu dùng của họ mà không có sự trùng lặp không cần thiết.

Bạn chỉ cần nhận ra mutex là một phần của đối tượng không phải là một phần của trạng thái của đối tượng. Đó là, bạn không muốn di chuyển mutex.

Việc khóa bạn cần tùy thuộc vào thuật toán của bạn hoặc mức độ tổng quát của các đối tượng và phạm vi sử dụng bạn cho phép.

Nếu bạn chỉ di chuyển từ một đối tượng 'nhà sản xuất' ở trạng thái chia sẻ sang một đối tượng 'tiêu thụ' luồng cục bộ, bạn có thể chỉ khóa đối tượng được di chuyển từ đó.

Nếu đó là một thiết kế chung hơn, bạn sẽ cần phải khóa cả hai. Trong trường hợp như vậy, bạn cần phải xem xét khóa chết.

Nếu đó là một vấn đề tiềm ẩn thì hãy sử dụng std::lock()để có được khóa trên cả hai mutex theo cách không có bế tắc.

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

Lưu ý cuối cùng, bạn cần đảm bảo rằng bạn hiểu ngữ nghĩa chuyển động. Nhớ lại rằng đối tượng được chuyển từ được để ở trạng thái hợp lệ nhưng không xác định. Hoàn toàn có thể xảy ra trường hợp một luồng không thực hiện việc di chuyển có lý do hợp lệ để cố gắng truy cập vào đối tượng được di chuyển từ khi nó có thể tìm thấy trạng thái hợp lệ nhưng không xác định đó.

Một lần nữa, nhà sản xuất của tôi chỉ cắt đứt dây và người tiêu dùng đang lấy đi toàn bộ tải trọng. Trong trường hợp đó, mỗi khi nhà sản xuất cố gắng thêm vào vectơ, nó có thể tìm thấy vectơ không trống hoặc rỗng.

Nói tóm lại, nếu khả năng truy cập đồng thời vào đối tượng được chuyển từ đối tượng thành một bản ghi thì có khả năng là OK. Nếu nó là một lần đọc thì hãy nghĩ xem tại sao có thể đọc một trạng thái tùy ý.


3

Trước hết, phải có điều gì đó sai trong thiết kế của bạn nếu bạn muốn di chuyển một đối tượng có chứa mutex.

Nhưng nếu bạn quyết định làm điều đó bằng mọi cách, bạn phải tạo một mutex mới trong hàm tạo move, ví dụ:

// movable
struct B{};

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

Điều này là an toàn cho luồng, bởi vì hàm khởi tạo di chuyển có thể giả định một cách an toàn rằng đối số của nó không được sử dụng ở bất kỳ nơi nào khác, vì vậy không cần khóa đối số.


2
Đó không phải là chủ đề an toàn. Điều gì sẽ xảy ra nếu a.mutexbị khóa,: Bạn mất trạng thái đó. -1

2
@ DieterLücking Miễn là đối số là tham chiếu duy nhất đến đối tượng được chuyển đến, không có lý do chính đáng nào để khóa mutex của nó. Và ngay cả khi đúng như vậy, không có lý do gì để khóa mutex của một đối tượng mới được tạo. Và nếu có, đây là một đối số cho thiết kế xấu tổng thể của các đối tượng di động với mutexes.
Anton Savin

1
@ DieterLücking Điều này không đúng. Bạn có thể cung cấp một đoạn mã minh họa vấn đề? Và không phải trong hình thức A a; A a2(std::move(a)); do some stuff with a.
Anton Savin

2
Tuy nhiên, nếu đây là cách tốt nhất thì dù sao tôi cũng khuyên bạn nên newnâng cấp phiên bản và đặt nó trong một std::unique_ptr- có vẻ rõ ràng hơn và không có khả năng dẫn đến các vấn đề nhầm lẫn. Câu hỏi hay.
Mike Vine

1
@MikeVine Tôi nghĩ bạn nên thêm nó làm câu trả lời.
Anton Savin
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.