Что делает компилятор C++, чтобы гарантировать, что разные, но смежные области памяти безопасны для использования в разных потоках?

Допустим, у меня есть структура:

struct Foo {
  char a;  // read and written to by thread 1 only
  char b;  // read and written to by thread 2 only
};

Из того, что я понимаю, стандарт C++ гарантирует безопасность вышеуказанного, когда два потока работают в двух разных местах памяти.

Я думаю, однако, что, поскольку char a и char b попадают в одну и ту же строку кэша, компилятор должен выполнить дополнительную синхронизацию.

Что именно здесь происходит?

Ответ 1

Это зависит от оборудования. На оборудовании, с которым я знаком, C++ не нужно делать ничего особенного, потому что с точки зрения аппаратного обеспечения доступ к разным байтам даже в кэшированной строке обрабатывается "прозрачно". От аппаратного обеспечения эта ситуация на самом деле не отличается от

char a[2];
// or
char a, b;

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

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

Ответ 2

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

std::array<std::uint8_t, 8u> c;

void f()
{
    c[0] ^= 0xfa;
    c[3] ^= 0x10;
    c[6] ^= 0x8b;
    c[7] ^= 0x92;
}

Здесь, в модели однопоточной памяти, компилятор может генерировать код, подобный следующему (псевдо-сборка; предполагается, что аппаратные средства с прямым порядком байтов):

load r0, *(std::uint64_t *) &c[0]
xor r0, 0x928b0000100000fa
store r0, *(std::uint64_t *) &c[0]

Скорее всего, это будет быстрее на обычном оборудовании, чем на xor'ing отдельных байтов. Однако он считывает и записывает незатронутые (и не упомянутые) элементы c с индексами 1, 2, 4 и 5. Если другие потоки записывают данные в эти области памяти одновременно, эти изменения могут быть перезаписаны.

По этой причине подобные оптимизации часто невозможно использовать в многопоточной модели памяти. Пока компилятор выполняет только загрузку и сохранение соответствующей длины или объединяет доступы только при отсутствии промежутка (например, доступ к c[6] и c[7] все еще может быть объединен), аппаратное обеспечение обычно уже обеспечивает необходимые гарантии для правильного исполнения.

(Тем не менее, существуют/были некоторые архитектуры со слабыми и неинтуитивными гарантиями порядка памяти, например, DEC Alpha не отслеживает указатели как зависимость данных, как это делают другие архитектуры, поэтому в некоторых случаях необходимо ввести явный барьер памяти в низкоуровневом коде. В этом вопросе Линуса Торвальдса есть довольно известная маленькая сплетня. Однако соответствующая реализация C++, как ожидается, оградит вас от таких проблем.)