Подтвердить что ты не робот

Требуется ли мьютекс для синхронизации простого флага между pthreads?

Предположим, что у меня есть несколько рабочих потоков, например:

while (1) {
    do_something();

    if (flag_isset())
        do_something_else();
}

У нас есть пара вспомогательных функций для проверки и установки флага:

void flag_set()   { global_flag = 1; }
void flag_clear() { global_flag = 0; }
int  flag_isset() { return global_flag; }

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

Мой вопрос: Нужно ли мне что-то делать, чтобы синхронизировать доступ к global_flag? Если да, то какова минимальная работа для синхронизации в переносном режиме?

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

A: нет необходимости синхронизировать, поскольку установка или очистка флага не создает условия гонки:

Нам просто нужно определить флаг как volatile, чтобы убедиться, что он действительно читается из общей памяти каждый раз, когда он проверяется:

volatile int global_flag;

Он может не распространяться на другие ядра ЦП сразу, но рано или поздно гарантируется.

B: необходима полная синхронизация, чтобы гарантировать, что изменения в флагове распространяются между потоками:

Установка общего флага в одном ядре ЦП не обязательно делает его видимым другим ядром. Мы должны использовать мьютексы, чтобы гарантировать, что изменения флага всегда распространяются путем недействительности соответствующих строк кэша на других ЦП. Код становится следующим:

volatile int    global_flag;
pthread_mutex_t flag_mutex;

void flag_set()   { pthread_mutex_lock(flag_mutex); global_flag = 1; pthread_mutex_unlock(flag_mutex); }
void flag_clear() { pthread_mutex_lock(flag_mutex); global_flag = 0; pthread_mutex_unlock(flag_mutex); }

int  flag_isset()
{
    int rc;
    pthread_mutex_lock(flag_mutex);
    rc = global_flag;
    pthread_mutex_unlock(flag_mutex);
    return rc;
}

C: Синхронизация необходима, чтобы убедиться, что изменения в флагове распространяются между потоками:

Это то же самое, что B, но вместо использования мьютекса с обеих сторон (читателя и писателя) мы устанавливаем его только на стороне записи. Потому что логика не требует синхронизации. нам просто нужно синхронизировать (аннулировать другие кеши) при изменении флага:

volatile int    global_flag;
pthread_mutex_t flag_mutex;

void flag_set()   { pthread_mutex_lock(flag_mutex); global_flag = 1; pthread_mutex_unlock(flag_mutex); }
void flag_clear() { pthread_mutex_lock(flag_mutex); global_flag = 0; pthread_mutex_unlock(flag_mutex); }

int  flag_isset() { return global_flag; }

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

Итак, какой?

Я думаю, что A и B - очевидный выбор, B - более безопасный. Но как насчет C?

Если C нормально, есть ли другой способ заставить изменение флага быть видимым на всех ЦП?

Возникает один из близких вопросов: Защищает ли переменная с помощью mutex pthread, она также не кэшируется?... но на самом деле это не так.

4b9b3361

Ответ 1

"Минимальный объем работы" - это явный барьер памяти. Синтаксис зависит от вашего компилятора; на GCC вы можете сделать:

void flag_set()   {
  global_flag = 1;
  __sync_synchronize(global_flag);
}

void flag_clear() {
  global_flag = 0;
  __sync_synchronize(global_flag);
}

int  flag_isset() {
  int val;
  // Prevent the read from migrating backwards
  __sync_synchronize(global_flag);
  val = global_flag;
  // and prevent it from being propagated forwards as well
  __sync_synchronize(global_flag);
  return val;
}

Эти барьеры памяти достигают двух важных целей:

  • Они принудительно завершают компилятор. Рассмотрим такой цикл, как:

     for (int i = 0; i < 1000000000; i++) {
       flag_set(); // assume this is inlined
       local_counter += i;
     }
    

    Без барьера компилятор может оптимизировать его для:

     for (int i = 0; i < 1000000000; i++) {
       local_counter += i;
     }
     flag_set();
    

    Вставка барьера заставляет компилятор немедленно записывать переменную.

  • Они заставляют CPU заказывать свои записи и читать. Это не столько проблема с одним флагом - большинство архитектур процессора в конечном итоге будут видеть флаг, который будет установлен без барьеров на уровне процессора. Однако порядок может измениться. Если у нас есть два флага и на поток A:

      // start with only flag A set
      flag_set_B();
      flag_clear_A();
    

    И в потоке B:

      a = flag_isset_A();
      b = flag_isset_B();
      assert(a || b); // can be false!
    

    Некоторые архитектуры процессоров позволяют переупорядочивать эти записи; вы можете видеть, что оба флага являются фальшивыми (т.е. сначала создается флаг A write). Это может быть проблемой, если флаг защищает, скажем, действующий указатель. Пределы памяти заставляют упорядочивать записи для защиты от этих проблем.

Обратите также внимание на то, что на некоторых процессорах можно использовать семантику барьера "приобретать-освобождать" для дальнейшего снижения накладных расходов. Однако такое различие не существует на x86 и потребует встроенной сборки на GCC.

Хороший обзор того, какие барьеры памяти и зачем они нужны, можно найти в каталоге документации ядра Linux. Наконец, обратите внимание, что этого кода достаточно для одного флага, но если вы хотите синхронизировать и с любыми другими значениями, вы должны очень осторожно пройтись. Замок, как правило, самый простой способ сделать что-то.

Ответ 2

Вы должны не вызывать случаи гонки данных. Это поведение undefined, и компилятору разрешено делать что угодно и все, что ему нравится.

Юмористический блог на тему: http://software.intel.com/en-us/blogs/2013/01/06/benign-data-races-what-could-possibly-go-wrong

Случай 1: Флаг синхронизации не синхронизирован, поэтому разрешено все. Например, компилятору разрешено включать

flag_set();
while(weArentBoredLoopingYet())
    doSomethingVeryExpensive();
flag_clear()

в

while(weArentBoredLoopingYet())
    doSomethingVeryExpensive();
flag_set();
flag_clear()

Примечание: этот вид расы на самом деле очень популярен. Ваш размер может отличаться. Одна рука, де-факто реализация pthread_call_once включает в себя гонку данных, подобную этой. С другой стороны, это поведение undefined. В большинстве версий gcc вы можете избавиться от него, потому что gcc выбирает не использовать свое право оптимизировать этот способ во многих случаях, но это не "spec" код.

B: полная синхронизация - это правильный вызов. Это просто то, что вам нужно сделать.

C: может работать только синхронизация на писателе, если вы можете доказать, что никто не хочет ее читать во время написания. Официальное определение гонки данных (из спецификации С++ 11) - это один поток, записывающий переменную, в то время как другой поток может одновременно читать или записывать одну и ту же переменную. Если ваши читатели и писатели работают сразу, у вас все еще есть гонка. Однако, если вы можете доказать, что писатель пишет один раз, есть некоторая синхронизация, а затем все читатели читают, тогда читателям не нужна синхронизация.

Что касается кэширования, то правило заключается в том, что блокировка/разблокировка мьютекса синхронизируется со всеми потоками, которые блокируют/разблокируют один и тот же мьютекс. Это означает, что вы не увидите каких-либо необычных эффектов кеширования (хотя под капотом ваш процессор может делать впечатляющие вещи, чтобы ускорить этот запуск... он просто обязан сделать так, чтобы он не делал ничего особенного). Тем не менее, если вы не синхронизируете, вы не получите никаких гарантий, что в другом потоке нет изменений, которые вам нужны!

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

Если у вас есть С++ 11, простой ответ заключается в использовании atomic_flag, который предназначен для того, чтобы делать именно то, что вы хотите. И в большинстве случаев он предназначен для правильной синхронизации.

Ответ 3

В примере, который вы опубликовали, достаточно использовать случай A, если...

  • Получение и установка флага принимает только одну инструкцию ЦП.
  • do_something_else() не зависит от того, установлен ли флаг во время выполнения этой процедуры.

Если получение и/или установка флага принимает более одной команды CPU, тогда вы должны иметь какую-то форму блокировки.

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

Надеюсь, что это поможет.

Ответ 4

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

main task {

  // do forever
  while (true)

    // wait for job
    while (x != null) {
      sleep(some);
      x = grabTheJob(); 
    }

    // select worker
    bool found = false;
    for (n = 0; n < NUM_OF_WORKERS; n++)
     if (workerList[n].getFlag() != AVAILABLE) continue;
     workerList[n].setJob(x);
     workerList[n].setFlag(DO_IT_PLS);
     found = true;
    }

    if (!found) panic("no free worker task! ouch!");

  } // while forever
} // main task


worker task {

  while (true) {
    while (getFlag() != DO_IT_PLS) sleep(some);
    setFlag(BUSY_DOING_THE_TASK);

    /// do it really

    setFlag(AVAILABLE);

  } // while forever 
} // worker task

Итак, если есть один флаг, который одна сторона устанавливает, то A и другая - B и C (основная задача устанавливает его в DO_IT_PLS, а рабочий устанавливает его в BUSY и AVAILABLE), нет confilct. Играйте с примером "реальной жизни", скажем, когда учитель дает студентам разные задания. Учитель выбирает ученика, дает ему задание. Затем учитель ищет следующего доступного ученика. Когда студент готов, он возвращается в пул доступных учеников.

UPDATE: просто уточните, есть только один поток main() и несколько - настраиваемые числа рабочих потоков. Поскольку main() запускает только один экземпляр, нет необходимости синхронизировать выбор и запуск рабочих.