Почему возникает состояние гонки, когда аппаратное обеспечение обеспечивает согласованность

#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
#include <mutex>

using namespace std::chrono;

const int nthreads = 4;
const int64_t ndata = 1600000;

int total_sum = 0;

void compute_sum(int tid, std::vector<int>& d)
{
  int st = (ndata / nthreads) * tid;
  int en = (ndata / nthreads) * (tid + 1);
  for (int i = st; i < en; i++) {
    total_sum += d[i];
  }
}

int main(int argc, char ** arg)
{
  std::vector<std::thread> threads;
  
  std::vector<int> data;
  for (int i = 0; i < ndata; i++) {
    data.push_back(i);
  }

  for (int i = 0; i < nthreads; i++) {
    threads.push_back(std::thread(compute_sum, i, std::ref(data)));
  }
  
  for (auto &th : threads) {
    th.join();
  }
  return 0;
}

Здесь total_sum используется двумя потоками, поэтому возникает состояние гонки, и этот код испорчен (пока не используются атомы или блокировки).
Но когда когерентность на аппаратном уровне реализуется по принципу протокола MESI, не должна ли общая память правильно обрабатываться самим оборудованием, чтобы не возникало состояний гонки?

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

NathanOliver 28.08.2024 16:37

Так что просто используйте atomic<int> и доверьте реализацию, которая сделает абсолютно минимум, необходимый для атомарности обновлений. Я уверен, что разработчик библиотеки также знает о MESI.

BoP 28.08.2024 16:58

Вы предполагаете, что total_sum += d[i] означает, что в точном порядке исходного кода компилятор выдаст одну единственную инструкцию, которая загружает, добавляет, а затем сохраняет значение total_sum из/в память. Но когда вы пишете на языке высокого уровня, таком как C++, код, который вы пишете, не имеет такого соответствия. Нет никаких причин, по которым компилятор должен компилировать его именно так. Например, гораздо разумнее (и разрешено в соответствии с правилами абстрактной машины) хранить total_sum в реестре, чтобы поведение аппаратного кэша вообще не имело значения.

user17732522 28.08.2024 19:30

Также (все вышеперечисленное плюс) при отсутствии ограничений памяти оптимизатор позволяет модифицировать код так, как если бы он имел только один поток выполнения.

Richard Critten 28.08.2024 19:45
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
4
67
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

Протокол MESI (Википедия) — это механизм, который повышает производительность поддержания согласованности кэша ЦП.

Кэш ЦП (Википедия) — это аппаратный компонент, который находится между ЦП и основной памятью компьютера.

Согласованность кэша ЦП совершенно не связана с потокобезопасностью:

  • ваш код или
  • коллекции, такие как std::vector<>, которые может использовать ваш код, или
  • все, что вы могли бы сделать на C++, или
  • все, что вы можете сделать в машинном коде, во что компилируется C++.

Другими словами, одно не имеет абсолютно ничего общего с другим.

Знание о существовании кэша ЦП и его внутренней работе полезно опытным программистам только как средство написания кода, который будет работать более оптимально за счет учета особенностей работы кэша. (Например, при переборе всех элементов двумерного массива, объявленного как Array[Y][X], вы обычно добьетесь большей производительности, если ваш внешний цикл будет выполнять итерацию по Y, а внутренний цикл — по X.)

Я предположил следующее: «Предположим, что процессор имеет два ядра, и поток 1 выполняется на ядре 1, а поток 2 — на ядре 2. Допустим, первоначально total_sum извлекается из памяти, к нему обращается поток 1, а затем записывается обратно. а затем доступен потоку 2 и т. д. Разве протоколы когерентности не заботятся об этой синхронизации». Именно это я имел в виду, когда задавал вопрос. Понятия не имею, какой ответ вы дали :(

Suriyaa MM 29.08.2024 11:35

У каждого потока не всегда будет назначенное ядро. Планировщик операционной системы будет постоянно переназначать потоки различным ядрам.

Mike Nakis 29.08.2024 12:14
total_sum не обязательно ответят; как указывали другие, его можно хранить в реестре. Чтобы это произошло, вам нужно объявить это volatile или позаботиться о синхронизации программно.
Mike Nakis 29.08.2024 12:15

Если и когда total_sum в конечном итоге будет записан обратно в память, и если он будет записан в кеш, который не является общим для всех ядер (некоторые кеши являются общими для нескольких ядер в некоторых архитектурах), тогда протоколы когерентности позаботятся о том, чтобы это было видно. правильным ядром, которое должно его прочитать, но опять же, вы ничего не можете сделать, чтобы облегчить или предотвратить это со стороны C++, и поэтому вас как программиста C++ это не волнует.

Mike Nakis 29.08.2024 12:18

Это похоже на беспокойство о квантовых взаимодействиях, когда все, что вы пытаетесь сделать, — это играть в футбол. Да, конечно, на низком уровне всё сводится к квантовым взаимодействиям, но думать в терминах квантов при игре в футбол совершенно бесполезно; подойдет простая ньютоновская механика. Когда вы играете в футбол, вас не волнует квантовая механика, и нет никаких знаний о квантовой механике, которые либо улучшат, либо испортят вашу футбольную игру.

Mike Nakis 29.08.2024 12:23

Другими словами, было время, когда процессоры не имели кэша L1, L2, L3 и не существовало протоколов когерентности, и наше программное обеспечение работало. Затем однажды производители процессоров представили кэши процессоров. Для этого не потребовалось переписывать какое-либо программное обеспечение: все, что раньше работало, продолжало работать, а все, что раньше глючило, продолжало так же глючить.

Mike Nakis 29.08.2024 12:25

Спасибо!!!!. Я не думаю о квантовых взаимодействиях, я просто хотел знать, почему здесь вообще возникает состояние гонки. (Мой профессор высказал эту идею в классе). В любом случае, я удовлетворен ответом.

Suriyaa MM 29.08.2024 12:36

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

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

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

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

Другие вопросы по теме

Похожие вопросы

Производный виджет Gtk::DrawingArea загружается из файла компоновщика Glade неправильно
Ошибка нарушения прав доступа при многопоточном сканировании диска с помощью C++
Является ли замена всех const std::string & на std::string_view хорошим выбором?
C++ Неопределенное поведение при использовании переинтерпретации приведения
Как получить понятное имя, принадлежащее дескриптору HMONITOR в Windows?
Цикл while в C++ завершается сбоем, если целое число содержит более 10 цифр
Какова точная цитата из стандарта C++, в которой говорится, что ошибка вывода типа при выведении аргумента шаблона не является ошибкой (SFINAE)?
Метод удаления двоичного дерева поиска удаляет все узлы, а не только выбранный C++
Ошибка Boost::program_options «опция не может быть указана более одного раза» при реализации программы с несколькими режимами
Как обнаружить клавишу Enter при вставке многострочного текста (содержащего `\r`)?