Jak mam radzić sobie z muteksami w ruchomych typach w C ++?

86

Z założenia std::mutexnie można go przenosić ani kopiować. Oznacza to, że klasa Azawierająca muteks nie otrzyma domyślnego konstruktora przenoszenia.

Jak uczynić ten typ Aruchomym w sposób bezpieczny dla wątków?

Jack Sabbath
źródło
4
Pytanie jest dziwne: czy sama operacja przenoszenia również będzie bezpieczna dla wątków, czy też wystarczy, jeśli inne dostępy do obiektu są bezpieczne wątkowo?
Jonas Schäfer
2
@paulm To naprawdę zależy od projektu. Często widziałem, jak klasa ma zmienną składową mutex, wtedy tylko std::lock_guardzakres metody is.
Cory Kramer
2
@Jonas Wielicki: Na początku myślałem, że przenoszenie powinno być również bezpieczne dla wątków. Jednak nie żebym znowu o tym myślał, to nie ma większego sensu, ponieważ tworzenie obiektu w ruchu zwykle unieważnia stan starego obiektu. Zatem inne wątki nie mogą mieć dostępu do starego obiektu, jeśli ma zostać przeniesiony ... w przeciwnym razie mogą wkrótce uzyskać dostęp do nieprawidłowego obiektu. Czy mam rację?
Jack Sabbath
2
proszę podążać za tym linkiem, może użyć pełnego do tego justsoftwaresolutions.co.uk/threading/ ...
Ravi Chauhan
1
@Dieter Lücking: tak, to jest idea ... mutex M chroni klasę B. Gdzie jednak przechowywać oba, aby mieć bezpieczny dla wątków, dostępny obiekt? Zarówno M, jak i B mogą przejść do klasy A .. iw tym przypadku klasa A miałaby Mutex w zakresie klasy.
Jack Sabbath

Odpowiedzi:

105

Zacznijmy od fragmentu kodu:

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

Umieściłem tam kilka dość sugestywnych aliasów typów, których tak naprawdę nie wykorzystamy w C ++ 11, ale staną się znacznie bardziej przydatne w C ++ 14. Cierpliwości, dotrzemy na miejsce.

Twoje pytanie sprowadza się do:

Jak napisać konstruktor przenoszenia i operator przypisania przenoszenia dla tej klasy?

Zaczniemy od konstruktora przenoszenia.

Move Constructor

Zwróć uwagę, że członek mutexzostał utworzony mutable. Ściśle mówiąc, nie jest to konieczne dla członków ruchu, ale zakładam, że chcesz również kopiować członków. Jeśli tak nie jest, nie ma potrzeby tworzenia muteksu mutable.

Podczas konstruowania Anie musisz blokować this->mut_. Ale musisz zablokować mut_obiekt, z którego konstruujesz (przenieść lub skopiować). Można to zrobić w następujący sposób:

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

Zauważ, że musieliśmy domyślnie skonstruować elementy składowe thisfirst, a następnie przypisać im wartości dopiero po a.mut_zablokowaniu.

Przenieś przypisanie

Operator przypisania przeniesienia jest znacznie bardziej skomplikowany, ponieważ nie wiadomo, czy inny wątek uzyskuje dostęp do lewego lub prawego skrzydła wyrażenia przypisania. Ogólnie rzecz biorąc, musisz wystrzegać się następującego scenariusza:

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

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

Oto operator przypisania ruchu, który prawidłowo chroni powyższy scenariusz:

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

Zauważ, że należy użyć std::lock(m1, m2)do zablokowania dwóch muteksów, zamiast po prostu blokować je jeden po drugim. Jeśli zablokujesz je jeden po drugim, to gdy dwa wątki przypiszą dwa obiekty w odwrotnej kolejności, jak pokazano powyżej, możesz uzyskać zakleszczenie. Chodzi o std::lockto, aby uniknąć tego impasu.

Copy Constructor

Nie pytałeś o członków kopii, ale równie dobrze możemy porozmawiać o nich teraz (jeśli nie ty, ktoś będzie ich potrzebował).

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

Konstruktor kopiujący wygląda podobnie do konstruktora przenoszenia, z wyjątkiem tego, że ReadLockalias jest używany zamiast WriteLock. Obecnie oba pseudonimy, std::unique_lock<std::mutex>więc nie ma to większego znaczenia.

Ale w C ++ 14 będziesz mieć możliwość powiedzenia tego:

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

Może to być optymalizacja, ale nie zdecydowanie. Będziesz musiał zmierzyć, aby określić, czy tak jest. Ale dzięki tej zmianie można kopiować konstrukcję z tego samego prawa w wielu wątkach jednocześnie. Rozwiązanie C ++ 11 zmusza cię do tworzenia takich wątków sekwencyjnie, mimo że prawa oś nie są modyfikowane.

Kopiuj przypisanie

Dla kompletności, oto operator przypisania kopiowania, który powinien być dość oczywisty po przeczytaniu wszystkiego innego:

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

Itd.

Wszyscy inni członkowie lub bezpłatne funkcje, które mają dostęp do Astanu, również będą musiały być chronione, jeśli oczekujesz, że wiele wątków będzie mogło wywoływać je jednocześnie. Na przykład tutaj 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_);
        }
    }

Zwróć uwagę, że jeśli polegasz tylko na std::swapwykonaniu zadania, blokowanie będzie miało niewłaściwą szczegółowość, blokowanie i odblokowywanie między trzema ruchami, które std::swapbyłyby wykonywane wewnętrznie.

Rzeczywiście, myślenie o tym swapmoże dać ci wgląd w API, które możesz potrzebować, aby zapewnić "bezpieczne wątkowo" A, które ogólnie będzie różniło się od "niegwintowanego" API, ze względu na problem "granularności blokowania".

Zwróć również uwagę na potrzebę ochrony przed „samodzielną wymianą”. „Self-swap” nie powinno być opcją. Bez samokontroli można by rekurencyjnie zablokować ten sam muteks. Można to również rozwiązać bez samokontroli, używając std::recursive_mutexfor MutexType.

Aktualizacja

W komentarzach poniżej Yakk jest dość niezadowolony z konieczności domyślnego konstruowania rzeczy w konstruktorach kopiowania i przenoszenia (i ma rację). Jeśli czujesz się wystarczająco mocno w tej kwestii, tak bardzo, że chcesz poświęcić jej pamięć, możesz tego uniknąć w następujący sposób:

  • Dodaj dowolne typy blokad, których potrzebujesz jako członków danych. Członkowie ci muszą poprzedzić chronione dane:

    mutable MutexType mut_;
    ReadLock  read_lock_;
    WriteLock write_lock_;
    // ... other data members ...
    
  • A potem w konstruktorach (np. Konstruktorze kopiującym) zrób tak:

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

Ups, Yakk usunął swój komentarz, zanim udało mi się ukończyć tę aktualizację. Ale on zasługuje na uznanie za forsowanie tej kwestii i znalezienie rozwiązania w tej odpowiedzi.

Zaktualizuj 2

I dyp wpadł na tę dobrą sugestię:

    A(const A& a)
        : A(a, ReadLock(a.mut_))
    {}
private:
    A(const A& a, ReadLock rhs_lk)
        : field1_(a.field1_)
        , field2_(a.field2_)
    {}
Howard Hinnant
źródło
2
Twój konstruktor kopiujący przypisuje pola, ale ich nie kopiuje. Oznacza to, że muszą być konfigurowalne jako domyślne, co jest niefortunnym ograniczeniem.
Yakk - Adam Nevraumont
@Yakk: Tak, wprowadzanie mutexesdo typów klas nie jest „jedyną prawdziwą drogą”. Jest to narzędzie w przyborniku i jeśli chcesz go użyć, oto jak.
Howard Hinnant
@Yakk: Wyszukaj w mojej odpowiedzi ciąg „C ++ 14”.
Howard Hinnant
ah, przepraszam, przegapiłem 14-bitowy C ++.
Yakk - Adam Nevraumont
2
świetne wyjaśnienie @HowardHinnant! w C ++ 17 możesz również użyć blokady std :: scoped_lock (x.mut_, y_mut_); W ten sposób polegasz na implementacji, która zablokuje kilka muteksów w odpowiedniej kolejności
fen
7

Biorąc pod uwagę, że nie ma ładnego, czystego, łatwego sposobu na odpowiedź - rozwiązanie Antona uważam za poprawne, ale jest zdecydowanie dyskusyjne, chyba że pojawi się lepsza odpowiedź, poleciłbym postawić taką klasę na stosie i dbać o nią przez std::unique_ptr:

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

Jest to teraz w pełni ruchomy typ i każdy, kto ma blokadę wewnętrznego muteksu podczas wykonywania ruchu, jest nadal bezpieczny, nawet jeśli można dyskutować, czy jest to dobra rzecz do zrobienia

Jeśli potrzebujesz semantyki kopiowania, po prostu użyj

auto a2 = std::make_shared<A>();
Mike Vine
źródło
5

To jest odwrócona odpowiedź. Zamiast osadzać „te obiekty muszą być zsynchronizowane” jako podstawę typu, zamiast tego wstrzyknij go pod dowolny typ.

Zsynchronizowanym obiektem postępujesz w bardzo różny sposób. Jednym dużym problemem jest to, że musisz się martwić o zakleszczenia (blokowanie wielu obiektów). W zasadzie nigdy nie powinno to być twoją „domyślną wersją obiektu”: zsynchronizowane obiekty są przeznaczone dla obiektów, które będą ze sobą konkurować, a Twoim celem powinno być zminimalizowanie konfliktu między wątkami, a nie zamiatanie ich pod dywan.

Ale synchronizacja obiektów jest nadal przydatna. Zamiast dziedziczyć po synchronizatorze, możemy napisać klasę, która zawija dowolny typ w synchronizacji. Użytkownicy muszą przeskoczyć kilka obręczy, aby wykonać operacje na obiekcie teraz, gdy jest on zsynchronizowany, ale nie są ograniczeni do jakiegoś ręcznie zakodowanego ograniczonego zestawu operacji na obiekcie. Mogą łączyć wiele operacji na obiekcie w jedną lub wykonywać operacje na wielu obiektach.

Oto zsynchronizowana otoka wokół dowolnego typu 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)};
}

Zawiera funkcje C ++ 14 i C ++ 1z.

zakłada to, że constoperacje są bezpieczne dla wielu czytników (tak stdzakładają kontenery).

Zastosowanie wygląda następująco:

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

dla intz dostępem zsynchronizowanym.

Radziłbym nie mieć synchronized(synchronized const&). Rzadko jest potrzebny.

Jeśli zajdzie taka potrzeba synchronized(synchronized const&), kusiłbym, aby zastąpić T t;go std::aligned_storage, pozwalając na ręczne umieszczanie konstrukcji i ręczne niszczenie. To pozwala na właściwe zarządzanie przez całe życie.

Poza tym moglibyśmy skopiować źródło T, a następnie przeczytać z niego:

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

do cesji:

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

rozmieszczenie i wyrównane wersje przechowywania są nieco bardziej chaotyczne. Większość dostępu do programu tzostałaby zastąpiona funkcją składową T&t()i T const&t()const, z wyjątkiem konstrukcji, w których musiałbyś przeskoczyć przez niektóre obręcze.

Tworząc synchronizedopakowanie zamiast części klasy, wszystko, co musimy zapewnić, to to, że klasa wewnętrznie szanuje ją constjako wielokrotnego czytnika i zapisuje ją w sposób jednowątkowy.

W rzadkich przypadkach potrzebujemy zsynchronizowanej instancji, przeskakujemy przez obręcze, takie jak powyżej.

Przepraszamy za wszelkie literówki wymienione powyżej. Prawdopodobnie jest kilka.

Dodatkową korzyścią wynikającą z powyższego jest to, że n-dowolne dowolne operacje na synchronizedobiektach (tego samego typu) działają razem, bez konieczności programowania ich na stałe. Dodaj deklarację znajomego, a n-arne synchronizedobiekty wielu typów mogą ze sobą współpracować. W takim przypadku być może będę musiał accesszrezygnować z bycia przyjacielem na linii, aby radzić sobie z konfliktami przeciążenia.

przykład na żywo

Yakk - Adam Nevraumont
źródło
4

Używanie muteksów i semantyki przenoszenia w języku C ++ to doskonały sposób na bezpieczne i wydajne przesyłanie danych między wątkami.

Wyobraź sobie wątek „producenta”, który tworzy partie sznurków i dostarcza je (jednemu lub większej liczbie) konsumentów. Te partie mogą być reprezentowane przez obiekt zawierający (potencjalnie duże) std::vector<std::string>obiekty. Absolutnie chcemy „przenieść” stan wewnętrzny tych wektorów na ich konsumentów bez niepotrzebnego powielania.

Po prostu rozpoznajesz muteks jako część obiektu, a nie część stanu obiektu. Oznacza to, że nie chcesz przenosić muteksu.

Rodzaj blokowania, którego potrzebujesz, zależy od algorytmu lub stopnia uogólnienia obiektów i zakresu zastosowań, na które zezwalasz.

Jeśli przenosisz się tylko z obiektu „producenta” stanu współużytkowanego do obiektu „konsumującego” lokalnego wątku, możesz zablokować tylko obiekt przeniesiony z obiektu.

Jeśli jest to bardziej ogólny projekt, musisz zablokować oba. W takim przypadku należy rozważyć zamknięcie martwe.

Jeśli jest to potencjalny problem, użyj, std::lock()aby uzyskać blokady na obu muteksach w sposób wolny od zakleszczenia.

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

Na koniec upewnij się, że rozumiesz semantykę przenoszenia. Przypomnij sobie, że przeniesiony z obiektu pozostaje w prawidłowym, ale nieznanym stanie. Jest całkowicie możliwe, że wątek, który nie wykonuje przenoszenia, ma ważny powód, aby próbować uzyskać dostęp do przeniesionego obiektu, gdy może znaleźć ten prawidłowy, ale nieznany stan.

Znowu mój producent po prostu wystukuje struny, a konsument zabiera cały ładunek. W takim przypadku za każdym razem, gdy producent próbuje dodać do wektora, może się okazać, że wektor jest niepusty lub pusty.

Krótko mówiąc, jeśli potencjalny równoczesny dostęp do przeniesionego z obiektu sprowadza się do zapisu, prawdopodobnie będzie OK. Jeśli sprowadza się to do czytania, zastanów się, dlaczego czytanie dowolnego stanu jest w porządku.

Persixty
źródło
3

Przede wszystkim musi być coś nie tak z projektem, jeśli chcesz przenieść obiekt zawierający mutex.

Ale jeśli i tak zdecydujesz się to zrobić, musisz stworzyć nowy mutex w konstruktorze przenoszenia, czyli np .:

// movable
struct B{};

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

Jest to bezpieczne dla wątków, ponieważ konstruktor przenoszenia może bezpiecznie założyć, że jego argument nie jest używany nigdzie indziej, więc blokowanie argumentu nie jest wymagane.

Anton Savin
źródło
2
To nie jest bezpieczne dla wątków. Co jeśli a.mutexjest zablokowany: tracisz ten stan. -1
2
@ DieterLücking Dopóki argument jest jedynym odniesieniem do przeniesionego obiektu, nie ma rozsądnego powodu, aby blokować jego muteks. A nawet jeśli tak jest, nie ma powodu, aby blokować muteks nowo utworzonego obiektu. A jeśli tak, jest to argument za ogólnie złym projektem ruchomych obiektów z muteksami.
Anton Savin
1
@ DieterLücking To po prostu nieprawda. Czy możesz podać kod ilustrujący problem? A nie w formie A a; A a2(std::move(a)); do some stuff with a.
Anton Savin
2
Gdyby jednak był to najlepszy sposób, poleciłbym mimo wszystko newpodnieść instancję i umieścić ją w std::unique_ptr- która wydaje się czystsza i prawdopodobnie nie doprowadzi do zamieszania. Dobre pytanie.
Mike Vine
1
@MikeVine Myślę, że powinieneś dodać to jako odpowiedź.
Anton Savin