Як мені мати справу з мьютексами в рухомих типах на C ++?


86

За дизайном std::mutexне є рухомим та копіюваним. Це означає, що клас, Aщо містить мьютекс, не отримає конструктор переміщення за замовчуванням.

Як я можу зробити цей тип Aрухомим, захищеним від ниток?


4
Питання має химерність: Чи сама операція переміщення також повинна бути безпечною для потоку, чи достатньо, якщо інші звернення до об’єкта є потокобезпечними?
Йонас Шефер,

2
@paulm Це насправді залежить від дизайну. Я часто бачив, як у класі є змінна члена mutex, тоді застосовується лише std::lock_guardметод is.
Кори Крамер,

2
@Jonas Wielicki: Спочатку я думав, що його переміщення також повинно бути безпечним для потоків. Однак не те, що я знову про це думаю, це не має особливого сенсу, оскільки побудова об'єкта, що рухається, зазвичай призводить до втрати стану старого об'єкта. Отже, інші потоки не повинні мати доступу до старого об’єкта, якщо він буде переміщений .. інакше вони можуть незабаром отримати доступ до недійсного об’єкта. Чи правий я?
Джек Саббат,

2
будь ласка, перейдіть за цим посиланням, можна використовувати повне для нього justsoftwaresolutions.co.uk/threading/…
Раві Чаухан,

1
@Dieter Lücking: так, це ідея .. mutex M захищає клас B. Однак де я зберігаю обидва, щоб мати потокобезпечний, доступний об’єкт? І M, і B можуть перейти до класу A .. і в цьому випадку клас A матиме Mutex у класі.
Джек Саббат,

Відповіді:


105

Почнемо з трохи коду:

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

Я вклав туди кілька сугестивних псевдонімів типу, якими ми не зможемо скористатися в C ++ 11, але стаємо набагато кориснішими в C ++ 14. Наберіться терпіння, ми доїдемо.

Ваше запитання зводиться до:

Як написати конструктор переміщення та оператор присвоєння переміщення для цього класу?

Ми почнемо з конструктора переміщення.

Конструктор переміщення

Зверніть увагу, що учасник mutexбув зроблений mutable. Строго кажучи, це не обов’язково для учасників переміщення, але я припускаю, що ви також хочете скопіювати членів. Якщо це не так, немає потреби робити мутекс mutable.

При будівництві Aвам не потрібно блокувати this->mut_. Але вам потрібно заблокувати mut_об’єкт, з якого ви будуєте (перемістити або скопіювати). Це можна зробити так:

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

Зверніть увагу, що ми повинні були за замовчуванням побудувати члени thisfirst, а потім присвоювати їм значення лише після того, a.mut_як блокується.

Переміщення призначення

Оператор присвоєння переміщення істотно складніший, оскільки ви не знаєте, чи має якийсь інший потік доступ до lhs або rhs виразу присвоєння. І взагалі, потрібно захищатися від наступного сценарію:

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

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

Ось оператор призначення переміщення, який правильно захищає наведений вище сценарій:

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

Зверніть увагу, що std::lock(m1, m2)для блокування двох мьютексів потрібно використовувати , а не просто блокувати їх один за одним. Якщо ви заблокуєте їх один за одним, тоді, коли два потоки призначать два об'єкти в протилежному порядку, як показано вище, ви можете отримати глухий кут. Сенс у std::lockтому, щоб уникнути цього глухого кута.

Конструктор копіювання

Ви не запитували про членів копії, але ми могли б і поговорити про них зараз (якщо не ви, вони комусь знадобляться).

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

Конструктор копіювання схожий на конструктор переміщення, за винятком того, що ReadLockпсевдонім використовується замість WriteLock. В даний час це обидва псевдоніми, std::unique_lock<std::mutex>і тому це насправді не має ніякого значення.

Але в C ++ 14 у вас буде можливість сказати це:

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

Це може бути оптимізацією, але не точно. Вам доведеться виміряти, щоб визначити, чи є. Але за допомогою цієї зміни можна копіювати конструкцію з одного і того ж rhs в декількох потоках одночасно. Рішення C ++ 11 змушує вас робити такі потоки послідовними, навіть якщо rhs не змінюється.

Призначення копії

Для повноти, ось оператор присвоєння копії, який повинен бути досить зрозумілим після прочитання про все інше:

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

І т. Д.

Будь-які інші члени або безкоштовні функції, що мають доступ Aдо стану, також повинні бути захищені, якщо ви очікуєте, що кілька потоків зможуть їх викликати одночасно. Наприклад, ось 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_);
        }
    }

Зверніть увагу, що якщо ви просто залежате від std::swapвиконання роботи, блокування буде мати неправильну деталізацію, блокування та розблокування між трьома ходами, які std::swapмогли б виконуватися внутрішньо.

Дійсно, обдумування swapможе дати вам уявлення про API, який вам може знадобитися, щоб забезпечити "потокобезпечний" A, який загалом буде відрізнятися від "не потокового" API через проблему "блокування деталізації".

Також зверніть увагу на необхідність захисту від "самостійної заміни". "самозаміна" повинна бути забороною. Без самоперевірки один би рекурсивно зафіксував один і той же мьютекс. Це також можна вирішити без самоперевірки, використовуючи std::recursive_mutexfor MutexType.

Оновлення

У коментарях нижче Якк досить невдоволений тим, що за замовчуванням конструює речі в конструкторах копіювання та переміщення (і він має сенс). Якщо ви ставитеся до цього питання досить сильно, настільки, що ви готові витратити на нього пам'ять, ви можете уникнути його так:

  • Додайте будь-які потрібні вам типи блокування в якості членів даних. Ці учасники повинні прибути перед захищеними даними:

    mutable MutexType mut_;
    ReadLock  read_lock_;
    WriteLock write_lock_;
    // ... other data members ...
    
  • А потім у конструкторах (наприклад, конструкторі копіювання) зробіть це:

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

На жаль, Якк стер свій коментар ще до того, як я отримав можливість завершити це оновлення. Але він заслуговує на честь за те, що просунув це питання та отримав рішення у цій відповіді.

Оновлення 2

І Dyp запропонував цю слушну пропозицію:

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

2
Конструктор копіювання призначає поля, а не копіює їх. Це означає, що їх потрібно будувати за замовчуванням, що є прикрим обмеженням.
Якк - Адам Неврамонт

@Yakk: Так, введення mutexesв типи класів не є "єдиним істинним способом". Це інструмент у наборі інструментів, і якщо ви хочете ним скористатися, ось як.
Говард Хіннант

@Yakk: Шукати у моїй відповіді рядок "C ++ 14".
Говард Хіннант,

ах, вибачте, я пропустив цей C ++ 14 біт.
Якк - Адам Неврамон

2
чудове пояснення @HowardHinnant! у C ++ 17 ви також можете використовувати std :: scoped_lock lock (x.mut_, y_mut_); Таким чином, ви покладаєтесь на реалізацію, щоб зафіксувати кілька мьютексів у належному порядку
фен

7

Враховуючи, що, здається, не існує хорошого, чистого, простого способу відповісти на це - рішення Антона, я вважаю правильним, але воно, безумовно, спірне, якщо не з’явиться краща відповідь, я б рекомендував поставити такий клас на купу і доглядати через std::unique_ptr:

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

Зараз це повністю рухомий тип, і кожен, хто має блокування на внутрішньому мьютексі, поки відбувається переміщення, все ще безпечний, навіть якщо сперечається, чи це добре робити

Якщо вам потрібна семантика копіювання, просто використовуйте

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

5

Це перевернута відповідь. Замість того, щоб вбудовувати "ці об'єкти потрібно синхронізувати" як основу типу, замість цього вводьте його під будь-який тип.

Ви поводитесь із синхронізованим об’єктом зовсім інакше. Велике питання полягає в тому, що ви повинні турбуватися про глухих кутах (блокуванні кількох об’єктів). По суті, це ніколи не повинно бути вашою "версією об'єкта за замовчуванням": синхронізовані об'єкти призначені для об'єктів, які будуть в суперечці, і вашою метою має бути мінімізація суперечок між потоками, а не підмітання їх під килимок.

Але синхронізація об’єктів все одно корисна. Замість того, щоб успадковувати синхронізатор, ми можемо написати клас, який обертає довільний тип у синхронізацію. Користувачам доводиться перебирати кілька обручів, щоб виконувати операції з об’єктом тепер, коли він синхронізований, але вони не обмежуються деяким обмеженим набором операцій над об’єктом, кодованим вручну. Вони можуть складати декілька операцій над об’єктом в одну або мати операції з кількома об’єктами.

Ось синхронізована обгортка навколо довільного типу 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 ++ 14 та C ++ 1z.

це передбачає, що constоперації безпечні для кількох читачів (саме це stdпередбачають контейнери).

Використання виглядає так:

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

для intсинхронізованого доступу.

Я б радив не мати synchronized(synchronized const&). Це рідко потрібно.

Якщо вам потрібно synchronized(synchronized const&), я мав би спокусу замінити T t;на std::aligned_storage, дозволивши ручне будівництво розміщення, і зробити ручне знищення. Це дозволяє належним чином керувати життям.

За винятком цього, ми могли б скопіювати джерело 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);})
  )
{}

для призначення:

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

версії розміщення та вирівняного сховища трохи заплутаніші. Більшість доступу до tбуде замінено функцією-членом T&t()і T const&t()const, за винятком конструкції, де вам доведеться перестрибнути кілька обручів.

Роблячи synchronizedобгортку замість частини класу, все, що нам потрібно переконатись, це те, що клас внутрішньо поважає, constяк багатокористувацький, і записує його в одній нитці.

У рідкісних випадках нам потрібен синхронізований екземпляр, ми переходимо через обручі, як зазначено вище.

Вибачення за будь-які помилки в наведеному вище. Є, мабуть, такі.

Побічною перевагою вищезазначеного є те, що n-довільні довільні операції над synchronizedоб’єктами (того самого типу) працюють разом, без необхідності жорсткого кодування перед цим. Додайте декларацію про друзів, і n-арні synchronizedоб’єкти різних типів можуть працювати разом. Можливо, мені доведеться accessвідмовитись від того, щоб бути вбудованим другом, щоб у такому разі мати справу з обмеженими перевантаженнями.

живий приклад


4

Використання мьютексів та семантики переміщення C ++ - це чудовий спосіб безпечної та ефективної передачі даних між потоками.

Уявіть собі «виробницьку» нитку, яка створює партії рядків і надає їх (одному або кільком) споживачам. Ці партії можуть бути представлені об’єктом, що містить (потенційно великі) std::vector<std::string>об’єкти. Ми абсолютно хочемо "перемістити" внутрішній стан цих векторів до їх споживачів без зайвого дублювання.

Ви просто розпізнаєте мютекс як частину об'єкта, а не частину стану об'єкта. Тобто ви не хочете переміщати мьютекс.

Яке блокування вам потрібно, залежить від вашого алгоритму або того, наскільки узагальненими є ваші об’єкти, і який спектр використання ви дозволяєте.

Якщо ви тільки коли - або перейти від загального стану «виробника» об'єкта до локального потоку «споживаючи» об'єкт , який ви могли б бути в порядку , щоб тільки зафіксувати рухається від об'єкта.

Якщо це більш загальний дизайн, вам доведеться заблокувати обидва. У такому випадку вам потрібно буде врахувати глухий кут.

Якщо це є потенційною проблемою, скористайтеся std::lock()для отримання блокування на обох мьютексах вільним способом.

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

На завершення потрібно переконатись, що ви розумієте семантику переміщення. Нагадаємо, що переміщений об’єкт залишається у дійсному, але невідомому стані. Цілком можливо, що потік, який не виконує переміщення, має поважну причину для спроби отримати доступ до переміщеного з об’єкта, коли він може виявити цей дійсний, але невідомий стан.

Знову мій виробник просто вибиває струни, а споживач забирає все навантаження. У цьому випадку кожен раз, коли виробник намагається додати до вектора, він може виявити, що вектор не є порожнім або порожнім.

Коротше кажучи, якщо потенційний одночасний доступ до переміщеного з об'єкта становить запис, це, мабуть, все в порядку. Якщо це становить прочитане, то подумайте, чому нормально читати довільний стан.


3

Перш за все, у вашому дизайні має бути щось не так, якщо ви хочете перемістити об’єкт, що містить мьютекс.

Але якщо ви все-таки вирішите це зробити, вам доведеться створити новий мьютекс у конструкторі переміщення, наприклад:

// movable
struct B{};

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

Це безпечно для потоку, оскільки конструктор переміщення може спокійно припустити, що його аргумент більше ніде не використовується, тому блокування аргументу не потрібно.


2
Це не є безпечним для ниток. Що якщо a.mutexзаблоковано: Ви втратите цей стан. -1

2
@ DieterLücking Поки аргумент є єдиним посиланням на переміщений об'єкт, немає жодної розумної причини блокування його мьютексу. І навіть якщо це так, немає причин блокувати мьютекс новоствореного об’єкта. І якщо є, це аргумент загальної поганої конструкції рухомих об'єктів із мьютексами.
Антон Савін

1
@ DieterLücking Це просто неправда. Чи можете ви надати код, що ілюструє проблему? І не у формі A a; A a2(std::move(a)); do some stuff with a.
Антон Савін

2
Однак, якби це був найкращий спосіб, я б все-таки рекомендував - newпідняти екземпляр і помістити його в std::unique_ptr- це здається чистішим і, швидше за все, не призведе до плутанини. Хороше питання.
Mike Vine

1
@MikeVine Я думаю, вам слід додати це як відповідь.
Антон Савін
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.