Почему преобразование условной записи в безусловную запись не является потокобезопасной оптимизацией?

#c #multithreading #c 11 #concurrency #thread-safety

#c #многопоточность #c 11 #параллелизм #потокобезопасность

Вопрос:

В докладе о параллелизме и модели памяти C 11 Херб Саттер приводит примеры незаконных оптимизаций.

http://channel9.msdn.com/Shows/Going Deep/Cpp-and-Beyond-2012-Herb-Sutter-atomic-Weapons-2-of-2

Из слайда на минуте 17:

 void f(vector<widget>amp; v) {
    if(v.length()>0) xMutex.lock();
    for(int i = 0; i < v.length();   i)
          x;                                  // write is conditional
    if(v.length()>0) xMutex.unlock();
}
  

«Очень вероятное (хотя и глубоко ошибочное) преобразование центрального цикла:»

 r1 = x;
for(int i = 0; i < v.length();   i)
      r1;                                     // oops: write is not conditional
x = r1;
  

Он объясняет: «…эта запись не является условной, это будет происходить при каждом выполнении, даже если значение doOptionalWork равно false , что приведет к вводу записи, которая не защищена блокировкой мьютекса, которая вводит гонку … «

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

 // "optimized" version 1
void f(vector<widget>amp; v) {
    if(v.length() > 0) xMutex.lock()
    r1 = x;
    for(int i = 0; i < v.length();   i)
          r1;
    x = r1;
    if(v.length() > 0) xMutex.unlock();
}
  

Но это также может быть так.

 // "optimized" version 2
void f(vector<widget>amp; v) {
    if(v.length() > 0) xMutex.lock()
    r1 = x;
    for(int i = 0; i < v.length();   i)
          r1;
    if(v.length() > 0) xMutex.unlock();
    x = r1;
}
  

Очевидно, что версия 2 не является потокобезопасной, но я не уверен в версии 1. Является ли версия 1 потокобезопасной? Что, если в игре нет других строк, которые записывают в x?

Только сейчас я начал вводить «Либо v.length() равно 0, либо нет …» и понял, что даже тавтологии подводят меня в многопоточном мире. Я не знаю, с чего я могу начать рассуждать об этом.

Ответ №1:

Мьютекс используется только в том случае, если внутри вектора что-то есть. Одновременный запуск этого метода над двумя пустыми векторами приводит к гонке данных, потому что мы вообще не блокируем, но записываем в x.

Ответ №2:

Представьте, что есть другой поток, выполняющий следующий код:

 xMutex.lock()
  x;
xMutex.unlock();
  

Если приведенный выше код выполняется одновременно с (преобразованной) функцией f с пустым вектором, то приращение to x может потеряться, хотя это невозможно на уровне исходного кода.

Ответ №3:

Вызов mutex. lock() функции — это то, что блокирует мьютекс. Мьютекс — это взаимоисключающая блокировка, которая позволяет одному и только одному вызывающему вызывать lock() и возвращать. Последующие вызывающие абоненты будут блокироваться, ожидая вызова исходного вызывающего абонента unlock() .

Это «взаимоисключающий» в том смысле, что только одному потоку разрешено получать блокировку.

Это определение мьютекса.

в опубликованном вами коде второй блок не завершает цикл приращения lock() unlock() вызовами and .

Это означает, что два потока, вызывающие функции, которые изменяют переменную, измененную в цикле, будут наступать друг на друга. Можно записать переменную непосредственно перед (или после) другим циклом, считывающим переменную. Код ожидает, что переменная будет согласована с функцией, поэтому поведение функции будет неправильным.

Ответ №4:

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

 #include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

// we have very simple widgets
typedef int widget;

// x and its mutex are shared by the threads
static int x = 0;
std::mutex xMutex;

static std::chrono::milliseconds central_loop_dur(10);
static std::chrono::milliseconds before_central_loop(0);

void f(std::vector<widget>amp; v) {

    // The first thread acquires the lock.
    // The second thread has an empty vector so passes through before the lock is released.
    if(v.size() > 0) xMutex.lock();
    // The first thread will take about 50ms to write to x.
    // The second thread reads x nearly concurrently with the first thread. Both see 0.
    int r = x;

    // The first thread passes through here.
    // The second thread snoozes.
    std::this_thread::sleep_for(before_central_loop);
    before_central_loop = std::chrono::milliseconds(200);

    for(auto i : v) {
        std::this_thread::sleep_for(central_loop_dur);
          r;
    }
    // At 50ms, the first thread writes to x.
    // At 200ms, the second thread obliviously writes a previous value of x back to x.
    x = r;
    if(v.size() > 0) xMutex.unlock();
}

void thread_main(size_t vec_size) {
    std::vector<widget> v(vec_size);
    f(v);
}

int main() {
    std::thread t1(thread_main, 5);
    std::thread t2(thread_main, 0);
    t1.join();
    t2.join();

    std::cout << "The value of x = " << x << std::endl;
}
  

Без инструкций ожидания, если бы все происходило по порядку, результат был бы x = 5. Вместо этого программа (с высокой вероятностью) выведет x = 0, даже если поток 1 увеличивается в 5 раз!