Существуют ли проблемы оптимизации компилятора с обменом переменными между потоками?

Рассмотрим следующий пример. Цель состоит в том, чтобы использовать два потока, один для "вычисления" значения и один, который потребляет и использует вычисленное значение (я попытался упростить это). Вычислительная нить сигнализирует другому потоку, что значение было вычислено и готово с использованием переменной условия, после чего поток ожидания потребляет значение.

// Hopefully this is free from errors, if not, please point them out so I can fix
// them and we can focus on the main question
#include <pthread.h>
#include <stdio.h>

// The data passed to each thread. These could just be global variables.
typedef struct ThreadData
{
  pthread_mutex_t mutex;
  pthread_cond_t cond;
  int spaceHit;
} ThreadData;

// The "computing" thread... just asks you to press space and checks if you did or not
void* getValue(void* td)
{
  ThreadData* data = td;

  pthread_mutex_lock(&data->mutex);

  printf("Please hit space and press enter\n");
  data->spaceHit = getchar() == ' ';
  pthread_cond_signal(&data->cond);

  pthread_mutex_unlock(&data->mutex);

  return NULL;
}

// The "consuming" thread... waits for the value to be set and then uses it
void* watchValue(void* td)
{
  ThreadData* data = td;

  pthread_mutex_lock(&data->mutex);
  if (!data->spaceHit)
      pthread_cond_wait(&data->cond, &data->mutex);
  pthread_mutex_unlock(&data->mutex);

  if (data->spaceHit)
      printf("You hit space!\n");
  else
    printf("You did NOT hit space!\n");

  return NULL;
}

int main()
{
  // Boring main function. Just initializes things and starts the two threads.
  pthread_t threads[2];
  pthread_attr_t attr;
  ThreadData data;
  data.spaceHit = 0;

  pthread_mutex_init(&data.mutex, NULL);
  pthread_cond_init(&data.cond, NULL);

  pthread_attr_init(&attr);
  pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);
  pthread_create(&threads[0], &attr, watchValue, &data);
  pthread_create(&threads[1], &attr, getValue, &data);

  pthread_join(threads[0], NULL);
  pthread_join(threads[1], NULL);

  pthread_attr_destroy(&attr);
  pthread_mutex_destroy(&data.mutex);
  pthread_cond_destroy(&data.cond);

  return 0;
}

Мой главный вопрос связан с потенциальной оптимизацией, выполняемой компилятором. Является ли компилятор разрешенными непростая оптимизация и "оптимизация" потока программы, так что происходит следующее:

void* watchValue(void* td)
{
  ThreadData* data = td;

  pthread_mutex_lock(&data->mutex);
  if (!data->spaceHit) // Here, it might remember the result of data->spaceHit
      pthread_cond_wait(&data->cond, &data->mutex);
  pthread_mutex_unlock(&data->mutex);

  if (remember the old result of data->spaceHit without re-getting it)
      printf("You hit space!\n");
  else
    printf("You did NOT hit space!\n");
  // The above if statement now may not execute correctly because it didn't
  // re-get the value of data->spaceHit, but "remembered" the old result
  // from the if statement a few lines above

  return NULL;
}

Я немного параноик, что статический анализ компилятора может определить, что data->spaceHit не изменяется между двумя операторами if и поэтому оправдывает использование старого значения data->spaceHit вместо повторного получения нового значения, Я не знаю достаточно о оптимизации потоков и компилятора, чтобы узнать, безопасен ли этот код или нет. Это?


Примечание. Я написал это на C и пометил это как C и С++. Я использую это в библиотеке С++, но поскольку я использую API-интерфейсы потоковой передачи (pthreads и Win32) и имею возможность вставлять C в эту часть библиотеки С++, я отметил это как C и С++.

Ответ 1

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

Однако функции pthread_mutex_lock и pthread_mutex_unlock должны побеждать не только оптимизацию кэширования компилятора, но также и любые оптимизаторы переупорядочения оборудования. Если поток A готовит некоторые общие данные, а затем "публикует" его, выполняя разблокировку, это должно совпадать с другими потоками. Например, он не может появиться на другом процессоре, который блокируется, но обновления общих переменных еще не завершены. Таким образом, функции должны выполнять любые необходимые барьеры памяти. Все это было бы напрасно, если бы компилятор мог перемещать обращения к данным вокруг вызовов функций или кэшировать вещи на уровне регистра, так что когерентность нарушена.

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

Сигнализация состояния не имеет состояния, поэтому поток ожидания может блокироваться навсегда. Просто потому, что вы вызываете pthread_cond_signal безоговорочно в поток ввода getValue не означает, что watchValue провалится через ожидание. Возможно, что getValue выполняется первым, а spaceHit не установлено. Затем watchValue входит в мьютекс, видит, что spaceHit является ложным и выполняет ожидание, которое может быть неопределенным. (Единственное, что его спасет, - это побочное пробуждение, по иронии судьбы, потому что нет цикла.)

В принципе логика, которую вы, похоже, ищете, - это простой семафор:

// Consumer:
wait(data_ready_semaphore);
use(data);

// Producer:
data = produce();
signal(data_ready_semaphore);

В этом стиле взаимодействия нам не нужен мьютекс, на что намечено использование незащищенного использования data->spaceHit в вашем watchValue. Более конкретно, с синтаксисом семафора POSIX:

// "watchValue" consumer
sem_wait(&ready_semaphore);
if (data->spaceHit)
  printf("You hit space!\n");
else
  printf("You did NOT hit space!\n");

// "getValue" producer
data->spaceHit = getchar() == ' ';
sem_post(&ready_semaphore);

Возможно, реальный код, который вы упростили для примера, может просто использовать семафоры.

P.S. также pthread_cond_signal не обязательно находиться внутри мьютекса. Он потенциально может быть вызван в операционную систему, поэтому область, защищенная мьютексом, которая должна быть всего лишь несколькими машинными инструкциями для защиты общих переменных, может взорваться до сотен машинных циклов, поскольку она содержит вызов сигнализации.

Ответ 2

Нет, компилятору не разрешено кэшировать значение data->spaceHit для вызовов pthread_cond_wait() или pthread_mutex_unlock(). Они оба специально называются функциями [которые] синхронизируют память по отношению к другим потокам, которые обязательно должны действовать как барьеры компилятора.

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

Ответ 3

(Позже Edit: похоже, что последующие ответы предоставили лучший ответ на этот запрос. Я оставлю этот ответ здесь как ссылку на подход, который также не отвечает на вопрос. Посоветуйте, если вы рекомендуете другой подход.)

Тип ThreadData сам по себе не является изменчивым.

Реализация его как "данных" в main() является изменчивой. Данные указателей 'в getValue() и watchValueValue() также указывают на изменчивые версии типа ThreadData.

Хотя мне нравится первый ответ за его герметичность, переписывая

ThreadData data;  // main()
ThreadData* data; // getValue(), watchValueValue()

to

volatile ThreadData data;  // main()
volatile ThreadData* data; // getValue(), watchValueValue()
                           // Pointer `data` is not volatile, what it points to is volatile.

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