Что делает компилятор 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++, как ожидается, оградит вас от таких проблем.)