Видимость обновления члена C ++ внутри критической секции, когда она не атомарна - PullRequest
0 голосов
/ 14 января 2019

Я наткнулся на следующий обзор кода StackExchange и решил прочитать его для практики. В коде есть следующее:

Примечание: Я не ищу рецензирование кода, а это просто вставка копии кода из ссылки, чтобы вы могли сосредоточиться на проблеме под рукой без вмешательства другого кода , Я не заинтересован в реализации «умного указателя», просто понимаю модель памяти:

// Copied from the link provided (all inside a class)

unsigned int count;
mutex m_Mutx;

void deref()
{
    m_Mutx.lock();
    count--;
    m_Mutx.unlock();
    if (count == 0)
    {
        delete rawObj;
        count = 0;
    }
}

Видя это, я сразу же думаю: «Что, если два потока войдут, когда count == 1 и не увидят обновления друг друга? Могут ли оба в конечном итоге увидеть count как ноль и двойное удаление? И возможно ли для двух потоков: заставить count стать -1 и тогда удаление никогда не произойдет?

Мьютекс будет гарантировать, что один поток войдет в критическую секцию, однако гарантирует ли это, что все потоки будут должным образом обновлены? Что говорит мне модель памяти C ++, чтобы я мог сказать, что это состояние гонки или нет?

Я посмотрел на страницу cppreference модели памяти и std :: memory_order cppreference , однако последняя страница, похоже, имеет дело с параметром atomic. Я не нашел ответ, который искал, или, возможно, я неправильно его прочитал. Может кто-нибудь сказать мне, если то, что я сказал, является неправильным или правильным, и является ли этот код безопасным или нет?

Для исправления кода, если он сломан:

Правильный ли ответ на этот вопрос, чтобы превратить счет в атомарный член? Или это работает и после снятия блокировки мьютекса все потоки видят значение?

Мне также любопытно, будет ли это правильным ответом:

Примечание: Я не ищу рецензирование кода и не пытаюсь понять, решит ли это решение проблему с моделью памяти C ++.

#include <atomic>
#include <mutex>

struct ClassNameHere {
    int* rawObj;
    std::atomic<unsigned int> count;
    std::mutex mutex;

    // ...

    void deref()
    {
        std::scoped_lock lock{mutex};
        count--;
        if (count == 0)
            delete rawObj;
    }
};

Ответы [ 4 ]

0 голосов
/ 14 января 2019

Если два потока потенциально * вводят deref() одновременно, то независимо от предыдущего или ранее ожидаемого значения count происходит гонка данных и ваша вся программа даже те части, которые вы ожидаете иметь в хронологическом порядке, имеют неопределенное поведение , как указано в стандарте C ++ в [intro.multithread / 20] (N4659):

Два действия потенциально одновременны, если

(20.1) они выполняются разными потоками или

(20.2) они не секвенированы, по крайней мере, один выполняется обработчиком сигнала, и они оба не выполняются одним и тем же вызовом обработчика сигнала.

Выполнение программы содержит гонку данных, если она содержит два потенциально одновременных конфликтующих действия, по крайней мере одно из которых не атомарный, и ни один не происходит раньше другого, за исключением особого случая для обработчиков сигналов, описанных ниже. Любая такая гонка данных приводит к неопределенному поведению.

Потенциально одновременными действиями в этом случае, конечно, являются чтение count вне заблокированного раздела и запись count в нем.

*) То есть, если это позволяют токовые входы.

ОБНОВЛЕНИЕ 1 : В разделе, на который вы ссылаетесь, описывается порядок атомарной памяти, объясняется, как атомарные операции синхронизируются друг с другом и с другими примитивами синхронизации (такими как мьютексы и барьеры памяти). Другими словами, он описывает, как атомы могут использоваться для синхронизации, чтобы некоторые операции не были гонками данных. Это не относится здесь. Стандарт использует консервативный подход: если в других частях стандарта явно не указано, что два конфликтующих доступа не являются одновременными, у вас есть гонка данных и, следовательно, UB (где конфликты означают одно и то же место в памяти, и по крайней мере один из них не ' только для чтения).

0 голосов
/ 14 января 2019

"что, если два потока входят, когда count == 1" - если это происходит, что-то еще подозрительно. Идея интеллектуальных указателей заключается в том, что пересчет связан с временем жизни объекта (областью действия). Уменьшение происходит, когда объект (посредством разворачивания стека) уничтожается. Если два потока инициируют это, refcount не может быть просто 1, если нет другой ошибки.

Однако, что может случиться, если два потока введут этот код, когда count = 2. В этом случае операция декремента блокируется мьютексом, поэтому она никогда не может достичь отрицательных значений. Опять же, это предполагает не глючный код в другом месте. Поскольку все, что для этого нужно - это удалить объект (а затем избыточно установить счетчик в ноль), ничего плохого не произойдет.

Может произойти двойное удаление. Если два потока в count = 2 уменьшат счет, они оба смогут увидеть count = 0 впоследствии. Просто определите, нужно ли удалять объект внутри мьютекса, как простое исправление. Сохраните эту информацию в локальной переменной и обработайте ее соответствующим образом после освобождения мьютекса.

Что касается вашего третьего вопроса, то превращение счёта в атом не может исправить вещи волшебным образом. Кроме того, суть атома в том, что вам не нужен мьютекс, потому что блокировка мьютекса - дорогостоящая операция. При использовании атомики вы можете комбинировать такие операции, как декремент и проверка на ноль, что аналогично предложенному выше исправлению. Атомика обычно медленнее, чем "нормальные" целые числа. Они все еще быстрее, чем мьютекс.

0 голосов
/ 14 января 2019

Ваша блокировка предотвращает эту операцию count-- попадает в беспорядок, когда выполняется одновременно в разных потоках. Однако это не гарантирует, что значения count синхронизированы, так что повторное чтение вне одного критического раздела будет нести риск гонки данных.

Вы можете переписать его следующим образом:

void deref()
{
    bool isLast;
    m_Mutx.lock();
    --count;
    isLast = (count == 0);
    m_Mutx.unlock();
    if (isLast) {
        delete rawObj;
    }
}

Таким образом, блокировка гарантирует, что доступ к count синхронизирован и всегда находится в допустимом состоянии. Это действительное состояние переносится в некритическую секцию через локальную переменную (без условия гонки). Таким образом, критическая секция может быть довольно короткой.

Более простой версией будет синхронизация всего тела функции; это может стать недостатком, если вы хотите делать более сложные вещи, чем просто delete rawObj:

void deref()
{
    std::lock_guard<std::mutex> lock(m_Mutx);
    if (! --count) {
        delete rawObj;
    }
}

Кстати: std::atomic allone не решит эту проблему, поскольку это синхронизирует только каждый отдельный доступ, но не «транзакцию». Следовательно, ваш scoped_lock необходим, и - поскольку это охватывает всю функцию, - std::atomic становится излишним.

0 голосов
/ 14 января 2019

В обоих случаях происходит гонка данных. Поток 1 уменьшает счетчик до 1, и непосредственно перед оператором if происходит переключение потока. Поток 2 уменьшает счетчик до 0, а затем удаляет объект. Поток 1 возобновляет работу, видит, что count равно 0, и снова удаляет объект.

Переместите unlock() в конец функции. Или, что лучше, используйте std::lock_guard для блокировки; его деструктор разблокирует мьютекс, даже когда вызов delete вызывает исключение.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...