Вопрпос по разделяемой переменной при параллельных вычислениях

Решил я таки попытаться реализовать систему параллельных вычислений, похожую на так называемую Job System, что описана в известной книге Джейсона Грегори по игровым движкам. А так как опыта в многопоточности у меня нет, то в процессе написания возникают вопросы.

Постараюсь выдать тут концентрат одного такого назревшего вопроса. Допустим, имеются два потока. Первый ждёт некоторой задачи, в бесконечном цикле проверяя разделяемую переменную-указатель на класс этой задачи. Этот указатель объявлен, разумеется, как volatile, чтобы применялось обращение к памяти, а не к регистрам. Вот псевдокод:

//В потоке 1
CWorkThread::MainFunc()
{
    ...
    while (!m_pExpectJob);   //ждём задачу (указатель m_pExpectJob является volatile)
    m_pExpectJob->JobFunc(); //выполняем задачу
    ...
}

Второй поток, соответственно, заносит эту задачу в поле m_pExpectJob ожидающего потока, пускай как-то так:

//В потоке 2
CWorkThread::MainFunc()
{
    ...
    pWaitThread->m_pExpectJob = m_pJobQueue->TakeJob(); //передаём задачу на выполнение
    ...
}

Собственно вопрос: насколько работоспособен такой подход на многоядерных процессорах?

Я как рассуждаю: операции непосредственного чтения и записи в память (инструкция mov) уже сами по себе неделимы, а значит дополнительная синхронизация с помощью каких-либо мьютексов или атомиков по идее не нужна. Однако так ли это на самом деле? Может ли одновременное обращение к данной переменной из разных ядер (одно на чтение, второе на запись) привести к сбою? Или могут ли проблемы возникнуть с кэшем (L1 и L2, насколько я знаю, являются локальными для ядер, а значит в основную память корректное значение вроде как может и не попасть)?

Может всё-таки лучше использовать атомарные операции, в которых есть блокировка шины (префикс lock), например такие, как InterlockedIncrement()? Однако, блокировка шины в цикле, как я полагаю, может привести к потере производительности, если цикл затянется. По-этому и интересует работоспособность варианта без блокировки.


Ответы (1 шт):

Автор решения: Fat-Zer

Необходимые оговорки

Во-первых, если тип m_pExpectJob не объявлен, как атомарный тип, то с точки зрения C++ нет никаких гарантий, что запись в него будет атомарной, а следовательно попытка конкурентного чтения/записи в него из разных потоков — это, вообще говоря, гонка данных, что является UB. Так что для дальнейших рассуждений будем считать, что наша реализация (компилятор/платформа) даёт некоторые дополнительные гарантии, что простые чтение/запись m_pExpectJob атомарны.

Во-вторых, это «горячее» ожидание на процессоре: оно, конечно, имеет право на существование, но надо иметь в виду, что это, вообще говоря, достаточно расточительно, а на однопроцессорной системе — вообще бессмысленно; так что в таком виде на практике это применяется не так часто.

Что может пойти не так

С одной переменной, с учётом оговорок, IRL почти всегда будет всё работать, как и ожидается. Проблемы начинаются если есть ещё один разделяемый объект. Обычно, например, мы передаём не только задачу, но и данные для неё:

class CJob {
  void* data;
  // ...
};
volatile CJob *m_pExpectJob

// ...

// Поток1

CJob *job = m_pJobQueue->TakeJob();
job->data = my_data;              // (1)
pWaitThread->m_pExpectJob = job;  // (2)

// ...

// Поток2

while(!m_pExpectJob);             // (3)
m_pExpectJob->JobFunc(job->data); // (4)

Так вот, на некоторых архитектурах это может привести к тому, что job->data в (4) не будет инициализирована. На x86 с её сильной моделью памяти всё в данном случае должно быть нормально, но на каком-нибудь arm'е это вполне вероятное событие.

Да, т.к. m_pExpectJob объявлен как volitile, то компилятор, конечно, не сможет переставить инструкции (1) и (2), но он и не сделает ни каких дополнительных действий, дабы этого не сделал процессор через механизм внеочередного исполнения. Нарваться на проблемы вызванные внеочередным исполнением в x86, вообще говоря, тоже можно, но сложнее.

Дабы всё это гарантированно предотвратить необходимо добавить барьеры памяти (fence). В корректном варианте пример выше будет выглядеть как-то так:

std::atomic<CJob*> m_pExpectJob;

// ...

// Поток1

CJob *job = m_pJobQueue->TakeJob();
job->data = my_data;
pWaitThread->m_pExpectJob.store(job, std::memory_order_release);

// ...

// Поток2
CJob *job = 0
do {
  job = m_pExpectJob.load(std::memory_order_relaxed);
} while(!job);
std::atomic_thread_fence(std::memory_order_acquire);
m_pExpectJob->JobFunc(job->data);

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


в которых есть блокировка шины (префикс lock), например такие, как InterlockedIncrement()? Однако, блокировка шины в цикле, как я полагаю, может привести к потере производительности, если цикл затянется.

На x86 для чтения переменной блокировка шины не обязательна: обычный mov в регистр с выравненного адреса вполне атомарен. Если целиться под x86, то можно не парится с release-acqure семантикой и лепить всё «как есть» обычными std::atomic с std::memory_order_seq_cst: код будет почти такой же (не считая, разве что, записи), но корректный и переносимый.

На каком-нибудь arm'е volatile-варианты будут работать некорректно, а в стандартном варианте «без излишеств» — не оптимально... там всё это веселье с release-acqure используется во всю силу.

→ Ссылка