Независимый порядок чтения-изменения-записи - PullRequest
1 голос
/ 24 февраля 2020

Я запускал кучу алгоритмов через Relacy , чтобы проверить их правильность, и наткнулся на что-то, чего я действительно не понял. Вот его упрощенная версия:

#include <thread>
#include <atomic>
#include <iostream>
#include <cassert> 

struct RMW_Ordering
{
    std::atomic<bool> flag {false};
    std::atomic<unsigned> done {0}, counter {0};
    unsigned race_cancel {0}, race_success {0}, sum {0};

    void thread1() // fail
    {
        race_cancel = 1; // data produced

        if (counter.fetch_add(1, std::memory_order_release) == 1 &&
            !flag.exchange(true, std::memory_order_relaxed))
        {
            counter.store(0, std::memory_order_relaxed);
            done.store(1, std::memory_order_relaxed);
        }
    }

    void thread2() // success
    {
        race_success = 1; // data produced

        if (counter.fetch_add(1, std::memory_order_release) == 1 &&
            !flag.exchange(true, std::memory_order_relaxed))
        {
            done.store(2, std::memory_order_relaxed);
        }
    }

    void thread3()
    {
        while (!done.load(std::memory_order_relaxed)); // livelock test
        counter.exchange(0, std::memory_order_acquire);
        sum = race_cancel + race_success;
    }
};

int main()
{
    for (unsigned i = 0; i < 1000; ++i)
    {
        RMW_Ordering test;

        std::thread t1([&]() { test.thread1(); });    
        std::thread t2([&]() { test.thread2(); });
        std::thread t3([&]() { test.thread3(); });

        t1.join();
        t2.join();
        t3.join();

        assert(test.counter == 0);
    }

    std::cout << "Done!" << std::endl;
}

Два потока стремятся войти в защищенную область, а последний изменяет done , освобождая третий поток от бесконечного l oop. Пример немного надуманный, но исходный код должен запросить эту область через флаг , чтобы сигнализировать «готово».

Изначально fetch_add имел acq_rel упорядочение, потому что я был обеспокоен тем, что обмен может быть переупорядочен раньше, что может привести к тому, что один поток потребует флаг, сначала попытается проверить fetch_add , и предотвратить другой поток (который проходит проверку приращения). ) от успешного изменения графика. Во время тестирования с Relacy я решил, что произойдет ли ожидаемая мной живая блокировка, если я переключусь с acq_rel на release , и, к моему удивлению, это не произошло , Затем я использовал relaxed для всего, и опять же, без livelock.

Я пытался найти какие-либо правила, относящиеся к этому, в стандарте C ++, но мне удалось выкопать только эти:

1.10.7 Кроме того, существуют расслабленные операции атома c, которые не являются операциями синхронизации, и операции атома c чтения-изменения-записи, которые имеют специальные характеристики.

29.3.11 Atomi c Операции чтения-изменения-записи должны всегда читать последнее значение (в порядке изменения), записанное перед записью, связанной с операцией чтения-изменения-записи.

Могу ли я всегда полагаться на то, что операции RMW не переупорядочиваются - даже если они влияют на различные области памяти - и есть ли в стандарте что-либо, гарантирующее такое поведение?

РЕДАКТИРОВАТЬ :

Я придумал более простую настройку, которая должна немного лучше проиллюстрировать мой вопрос. Вот сценарий CppMem для него:

int main() 
{
    atomic_int x = 0; atomic_int y = 0;
{{{
{
    if (cas_strong_explicit(&x, 0, 1, relaxed, relaxed))
    {
        cas_strong_explicit(&y, 0, 1, relaxed, relaxed);
    }
}
|||
{
    if (cas_strong_explicit(&x, 0, 2, relaxed, relaxed))
    {
        cas_strong_explicit(&y, 0, 2, relaxed, relaxed);
    }
}
|||
{
    // Is it possible for x and y to read 2 and 1, or 1 and 2?
    x.load(relaxed).readsvalue(2);
    y.load(relaxed).readsvalue(1);
}
}}}
  return 0; 
}

Я не думаю, что инструмент достаточно сложен, чтобы оценить этот сценарий, хотя, похоже, он показывает, что это возможно. Вот почти эквивалентная установка Relacy:

#include "relacy/relacy_std.hpp"

struct rmw_experiment : rl::test_suite<rmw_experiment, 3>
{
    rl::atomic<unsigned> x, y;

    void before()
    {
        x($) = y($) = 0;
    }

    void thread(unsigned tid)
    {
        if (tid == 0)
        {
            unsigned exp1 = 0;
            if (x($).compare_exchange_strong(exp1, 1, rl::mo_relaxed))
            {
                unsigned exp2 = 0;
                y($).compare_exchange_strong(exp2, 1, rl::mo_relaxed);
            }
        }
        else if (tid == 1)
        {
            unsigned exp1 = 0;
            if (x($).compare_exchange_strong(exp1, 2, rl::mo_relaxed))
            {
                unsigned exp2 = 0;
                y($).compare_exchange_strong(exp2, 2, rl::mo_relaxed);
            }
        }
        else
        {
            while (!(x($).load(rl::mo_relaxed) && y($).load(rl::mo_relaxed)));
            RL_ASSERT(x($) == y($));
        }
    }
};

int main()
{
    rl::simulate<rmw_experiment>();
}

Утверждение никогда не нарушается, поэтому 1 и 2 (или наоборот) невозможны в соответствии с Relacy.

Ответы [ 2 ]

2 голосов
/ 25 февраля 2020

В вашем первом примере гарантируется, что flag.exchange всегда выполняется после counter.fetch_add, потому что короткое замыкание && - т.е., если первое выражение принимает значение false, второе выражение никогда не выполняется. Стандарт C ++ гарантирует это, поэтому компилятор не может переупорядочивать два выражения (независимо от того, какой порядок памяти они используют).

Как уже объяснил Питер Кордес, стандарт C ++ ничего не говорит о том, можно ли переупорядочить инструкции относительно на атомы c операций. В целом, большинство оптимизаций компилятора основаны на как если бы :

Описания semanti c в этом международном стандарте определяют параметризованную недетерминированную c абстрактную машину. Настоящий международный стандарт не устанавливает требования к структуре соответствующих реализаций. В частности, им не нужно копировать или эмулировать структуру абстрактной машины. Скорее, соответствующие реализации требуются для эмуляции (только) наблюдаемого поведения абстрактной машины [..].

Это положение иногда называют правилом «как будто», потому что реализация свободна игнорировать любые Требование этого международного стандарта, если результат равен , как если бы требование было выполнено, насколько это можно определить из наблюдаемого поведения программы. Например, фактическая реализация не должна оценивать часть выражения, если она может сделать вывод, что ее значение не используется, и что никакие стороны, влияющие на наблюдаемое поведение программы, не создаются.

Ключевой аспект вот "наблюдаемое поведение". Предположим, у вас есть два расслабленных атома c нагрузок A и B на двух различных атомах c объектов, где A секвенируется до B .

std::atomic<int> x, y;

x.load(std::memory_order_relaxed); // A
y.load(std::memory_order_relaxed); // B

Отношение «последовательность до» является частью определения отношения «происходит до», поэтому можно предположить, что эти две операции не могут быть переупорядочены. Однако, поскольку эти две операции ослаблены, нет никакой гарантии относительно «наблюдаемого поведения», т. Е. Даже при исходном порядке x.load ( A ) может вернуть более новый результат, чем y.load ( B ), поэтому компилятор мог бы свободно переупорядочивать их, поскольку конечная программа не сможет определить разницу (т. Е. Наблюдаемое поведение эквивалентно). Если это не будет эквивалентно, тогда у вас будет состояние гонки! ; -)

Чтобы предотвратить такие переупорядочения, вы должны полагаться на (межпотоковое) отношение «до». Если x.load ( A ) будет использовать memory_order_acquire, то компилятор должен будет предположить, что эта операция синхронизируется с какой-либо операцией освобождения, таким образом устанавливая (межпотоковое) отношение «происходит до». Предположим, что какой-то другой поток выполняет два обновления атома c:

y.store(42, std::memory_order_relaxed); // C
x.store(1, std::memory_order_release); // D

Если загрузка захвата A видит хранилище значений по релизу магазина D , затем эти две операции синхронизируются друг с другом, тем самым устанавливая отношение «происходит до». Поскольку y.store секвенируется до x.store, а x.load секвенируется до, транзитивность отношения «происходит до» гарантирует, что y.store происходит до y.load. Изменение порядка двух загрузок или двух магазинов разрушит эту гарантию и, следовательно, также изменит наблюдаемое поведение. Таким образом, компилятор не может выполнять такие переупорядочения.

В общем, спор о возможных переупорядочениях является неправильным подходом. На первом этапе вы всегда должны определить, какие отношения требуются до (например, y.store должно произойти до y.load). Следующим шагом является обеспечение того, чтобы эти отношения происходили до того, как правильно установлены отношения во всех случаях. По крайней мере, именно так я подхожу к аргументам корректности для своих реализаций алгоритмов без блокировки.

Относительно Relacy: Relacy только моделирует модель памяти, но зависит от порядка операций, сгенерированного компилятором. Таким образом, даже если компилятор может переупорядочить две инструкции, но не сделает этого, вы не сможете идентифицировать это с Relacy.

1 голос
/ 24 февраля 2020

Я еще не полностью понял ваш код, но на жирный вопрос есть простой ответ:

Могу ли я всегда полагаться на то, что операции RMW не переупорядочиваются - даже если они влияют на разные области памяти

Нет, вы не можете. Переупорядочивание во время компиляции двух расслабленных RMW в одном потоке очень разрешено. (Я думаю, что на большинстве процессоров переупорядочение двух RMW на практике, вероятно, невозможно на практике. ISO C ++ не различает для этого guish время компиляции и время выполнения.)

Но учтите, что Атоми c RMW включает в себя и груз, и магазин, и обе части должны оставаться вместе. Таким образом, любой вид RMW не может двигаться раньше после операции получения или после операции освобождения.

Также, конечно, сама RMW, являющаяся операцией освобождения и / или получения, может прекратить переупорядочение в одной или другое направление.


Конечно, модель памяти C ++ формально не определяется с точки зрения локального переупорядочения доступа к согласованной с кэшем разделяемой памяти, только с точки зрения синхронизации с другим потоком и создания события. до / после отношений. Но если вы игнорируете переупорядочение IRIW (2 потока считывателей не согласны с порядком двух потоков записи, делающих независимые хранилища для разных переменных), это в значительной степени 2 разных способа моделирования одной и той же вещи.

...