Volatile и CreateThread - PullRequest
       27

Volatile и CreateThread

4 голосов
/ 29 июля 2011

Я только что задал вопрос, связанный с volatile: массив volatile c ++

Однако мой вопрос породил дискуссию о том, что делает volatile.

Некоторые утверждают, что при использовании CreateThread() вам не нужно беспокоиться о volatiles. Microsoft, с другой стороны, приводит пример volatile при использовании двух потоков, созданных CreateThread().

Я создал следующий пример в Visual C ++ Express 2010, и не имеет значения, помечаете ли вы done как volatile или нет

#include "targetver.h"
#include <Windows.h>
#include <stdio.h>
#include <iostream>
#include <tchar.h>

using namespace std;

bool done = false;
DWORD WINAPI thread1(LPVOID args)
{
    while(!done)
    {

    }
    cout << "Thread 1 done!\n";
    return 0;
}
DWORD WINAPI thread2(LPVOID args)
{
    Sleep(1000);
    done = 1;
    cout << "Thread 2 done!\n";
    return 0;
}

int _tmain(int argc, _TCHAR* argv[])
{
DWORD thread1Id;
HANDLE hThread1;
DWORD thread2Id;
HANDLE hThread2;

hThread1 = CreateThread(NULL, 0, thread1, NULL, 0, &thread1Id);
hThread2 = CreateThread(NULL, 0, thread2, NULL, 0, &thread2Id);
Sleep(4000);
CloseHandle(hThread1);
CloseHandle(hThread2);

return 0;
}

Можете ли вы ВСЕГДА быть уверены, что поток 1 остановится, если done не volatile?

Ответы [ 6 ]

9 голосов
/ 29 июля 2011

Что volatile делает:

  • Запрещает компилятору оптимизировать любой доступ. Каждое чтение / запись приводит к инструкции чтения / записи.
  • Запрещает компилятору переупорядочивать доступ с другими значениями .

Что volatile не делает:

  • Сделать доступ атомарным.
  • Предотвратить переупорядочивание компилятора с помощью энергонезависимого доступа.
  • Внести изменения из одного потока в другой поток.

Некоторые непереносимые поведения, на которые не следует полагаться в кроссплатформенном C ++:

  • VC ++ расширил volatile, чтобы предотвратить любое изменение порядка с другими инструкциями. Другие компиляторы этого не делают, потому что это отрицательно влияет на оптимизацию.
  • x86 делает выравниваемое чтение / запись переменных размером с указатель и более мелкими атомарными и сразу же видимыми для других потоков. Другие архитектуры этого не делают.

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

Заборы гарантируют, что в момент использования все предыдущие чтения / записи будут завершены. В C ++ 11 заборы управляются в различных точках с использованием перечисления std::memory_order. В VC ++ вы можете использовать _ReadBarrier(), _WriteBarrier() и _ReadWriteBarrier() для этого. Я не уверен насчет других компиляторов.

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

Вот пример неправильного использования:

int res1, res2;
volatile bool finished;

void work_thread(int a, int b)
{
    res1 = a + b;
    res2 = a - b;
    finished = true;
}

void spinning_thread()
{
    while(!finished); // spin wait for res to be set.
}

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

volatile int res1, res2;
volatile bool finished;

void work_thread(int a, int b)
{
    res1 = a + b;
    res2 = a - b;
    finished = true;
}

void spinning_thread()
{
    while(!finished); // spin wait for res to be set.
}

Этот тривиальный пример будет работать на x86, но он будет неэффективным. С одной стороны, это заставляет res1 быть установленным до res2, даже если мы на самом деле не заботимся об этом ... мы просто хотим, чтобы они оба были установлены до finished. Применение этого порядка между res1 и res2 будет препятствовать только действительным оптимизациям, снижая производительность.

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

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

int res1, res2;
std::atomic<bool> finished;

void work_thread(int a, int b)
{
    res1 = a + b;
    res2 = a - b;
    finished.store(true, std::memory_order_release);
}

void spinning_thread()
{
    while(!finished.load(std::memory_order_acquire));
}

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

3 голосов
/ 29 июля 2011

volatile просто не позволяет компилятору делать предположения (читай: оптимизировать) доступ к объявленному значению volatile.Другими словами, если вы объявляете что-то volatile, вы в основном говорите, что оно может изменить его значение в любое время по причинам, о которых не знает компилятор, поэтому каждый раз, когда вы ссылаетесь на переменную, оно должно искать значение в это время.
В этом случае компилятор может решить на самом деле кэшировать значение done в регистре процессора, независимо от изменений, которые могут произойти в другом месте - то есть поток 2, установив его на true.
Я полагаю, причина того, что это сработало в вашем примере, заключается в том, что все ссылки на done на самом деле были реальным расположением done в памяти.Вы не можете ожидать, что это всегда будет иметь место, особенно когда вы начинаете запрашивать более высокие уровни оптимизации.
Кроме того, я хотел бы отметить, что это неправильное использование ключевого слова volatile для синхронизации.Это может произойти атомно, но только в силу обстоятельств.Я бы посоветовал вам использовать вместо этого конструкцию синхронизации потоков, например wait condition или mutex.См. http://software.intel.com/en-us/blogs/2007/11/30/volatile-almost-useless-for-multi-threaded-programming/ для фантастического объяснения.

1 голос
/ 29 июля 2011

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

1 голос
/ 29 июля 2011

Можете ли вы ВСЕГДА быть уверены, что поток 1 остановится, если не выполнено volatile

Всегда? Нет. Но в этом случае присвоение done выполняется в том же модуле, и цикл while будет , вероятно, не оптимизирован. Зависит от того, как MSVC выполняет свои оптимизации.

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

0 голосов
/ 29 июля 2011

volatile НЕ механизм синхронизации. Это НЕ гарантирует атомарность и порядок. Если вы не можете гарантировать, что все операции, выполняемые с общим ресурсом, являются атомарными, то вы ДОЛЖНЫ использовать правильную блокировку !

Наконец, я настоятельно рекомендую прочитать эти статьи:

  1. Volatile: практически бесполезен для многопоточного программирования
  2. Должен ли volatile получить семантику атомарности и видимости потока?
0 голосов
/ 29 июля 2011

Компиляция в Linux, g ++ 4.1.2, я добавляю в эквивалент вашего примера:

#include <pthread.h>

bool done = false;

void* thread_func(void*r) {
  while(!done) {};
  return NULL;
}

void* write_thread_func(void*r) {
  done = true;
  return NULL;
}


int main() {
  pthread_t t1,t2;
  pthread_create(&t1, NULL, thread_func, NULL);
  pthread_create(&t2, NULL, write_thread_func, NULL);
  pthread_join(t1, NULL);
  pthread_join(t2, NULL);
}

При компиляции с -O3 компилятор кэширует значение, поэтому он проверяется один раз и затем вводитбесконечный цикл, если это не было сделано в первый раз.

Однако затем я изменил программу следующим образом:

#include <pthread.h>

bool done = false;
pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;

void* thread_func(void*r) {
  pthread_mutex_lock(&mu);
  while(!done) {
    pthread_mutex_unlock(&mu);
    pthread_mutex_lock(&mu);
  };
  pthread_mutex_unlock(&mu);
  return NULL;
}

void* write_thread_func(void*r) {

  pthread_mutex_lock(&mu);
  done = true;
  pthread_mutex_unlock(&mu);
  return NULL;
}


int main() {
  pthread_t t1,t2;
  pthread_create(&t1, NULL, thread_func, NULL);
  pthread_create(&t2, NULL, write_thread_func, NULL);
  pthread_join(t1, NULL);
  pthread_join(t2, NULL);
}

Пока это все еще вращение (оно просто несколько раз блокирует /разблокирует мьютекс), компилятор изменил вызов, чтобы всегда проверять значение done после возврата из pthread_mutex_unlock, заставляя его работать должным образом.

Дальнейшие тесты показывают, что вызов любой внешней функции вызывает ее повторное выполнение.изучить переменную.

...