Comments 9
Пример состояния гонки, как не надо создавать потоки в С++. По мотивам найденного в реальном софте. Воспроизводилось только на определённой архитектуре.
Будет печатать "111 -1", а не как предполагается "111 999", "X() finished" напечатает после чисел. Потому что поток стартует раньше чем завершится инициализация класса. Вместо задержки в 1мс была инициализация кучи других классов.
#include <stdio.h>
#include <chrono>
#include <thread>
#include <cstring>
#include <new>
struct S {
S() {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
};
struct X {
void worker() { printf("%d %d\n", first, last); }
X() : t(&X::worker, this) { puts("X() finished"); }
int first = 111;
std::thread t;
S s;
int last = 999;
};
int main() {
alignas(X) char buf[sizeof(X)];
memset(buf, -1, sizeof(buf));
new(buf) X();
}
Какой ужас хрестоматийный пиздец
Потому что поток стартует раньше чем завершится инициализация класса.
Не, ну голова-то дана не только чтобы в нее есть...
Потоки создаем в состоянии suspended, завершаем всю инициализацию и только потом переводим его в состояние active. Как-то так...
Это не про гонки и не про синхронизацию разделяемых ресурсов. Это просто про понимание того что когда и как там внутри происходит.
Давайте представим, что мы работаем над созданием интернет-банкинга, предоставляющего клиентам доступ к своим финансовым данным и операциям через веб-интерфейс.
Вы бы хоть другой пример выбрали. Потому что там все это происходит ну совсем не так. И никаких гонок на уровне клиентов там нет и быть не может.
Второй момент. Есть разделяемые ресурсы, совместное использование которых приходится регулировать разработчику. Например, разделяемая память. И есть ресурсы, за совместное использование которых отвечает система. Например, очереди, сокеты, файлы.
Строго говоря, к С++ все это вообще никакого отношения не имеет, это вопрос общей архитектуры.
Как пример - у вас есть несколько потоков или процессов (не важно), которым необходимо обмениваться некоторой информацией. И вы знаете что скорость обмена для вас не является критичным параметром в данной задаче (но т.е. будет там 1мс на блок или 1мкс не играет роли на фоне всего остального).
И вот здесь нет смысла связываться с разделяемой памятью - это слишком сложно и оправданно только тогда когда требуется предельная скорость именно обмена данными.
Можно использовать системные средства. Например, именованные локальные сокеты (UNIX socket). Или майлслоты (Win). Или очереди (*usrq, *dtaq в as/400). Каждый поток (процесс) создает свой объект с уникальным именем который является "почтовым ящиком" для него. Он оттуда будет читать все, что ему посылают другие. Все остальные могут туда писать все что нужно передать данному потоку.
И здесь все вопросы синхронизации отдаются на откуп системе, она за всем этим следит. Лично использовал такой подход в ситуации когда было три потока - один коммуникация с удаленными контроллерами по UDP, второй коммуникация с интерфейсными клиентами по TCP и третий - обработка и маршрутизация посылок от клиента к контроллеру и от контроллера к клиенту. И никаких гонок, никаких проблем с синхронизацией при минимальных затратах.
Второй пример, с чем часто сталкиваюсь сейчас - параллельная обработка больших объемов данных. Есть головное задание, которое производит первичный отбор данных для обработки, есть несколько (5-10) заданий-обработчиков которые уже занимаются обработкой каждого блока данных. И есть конвейер (у нас - *usrq). Головное задание выкладывает отобранные данные на конвейер, обработчики их оттуда разбирают и обрабатывают. И опять - вся синхронизация на системе. На разработчике только "положить в очередь" и "взять из очереди".
Может стоит добавить в обе статьи про UB дисклеймер: "Приведённые в статье фрагменты кода служат сугубо для демонстрации, не стоит использовать их в реальных проектах (ну, по крайней мере берите фрагменты, которые помечены как исправленный код)"?
Ну, а если серьёзно, то большое спасибо за такой развёрнутый фидбек. Очень интересный и полезный комментарий.
Пример, как было описано, сугубо синтетический и не несёт в себе не то, что близости к продовскому коду, а даже и особой осмысленности:) Как-то раз, я ревьювил смарт-контракт на Solidity и думал: "Ё моё, в этот писец бы ещё многопоточку добавить". Ну, время пришло и я реализовал что-то похожее. Интернет-банком это стало по остаточному принципу.
Я просто знаю как мобильные клиенты устроены (точнее, как они связаны с реальными данными) - от реальных данных они бесконечно далеко. У них есть REST API - они просто дергают запросы. А дальше это длинным путем идет до центрального сервера, где уже выполняется. Там даже нет понятия "аккаунт". Есть ПИН клиента и все запросы идут по нему.
И перевести деньги со счета на счет - это не просто переложить их из одного аккаунта в другой. Это создать платежный документ и отправить его в очередь на обработку. Т.е. никаких "гонок" и конфликтов тут в принципе не бывает.
А если говорить про сложности работы с теми объектами, которые вам нужно синхронизировать руками (например, разделяемая память), то там проблем очень много и в про них даже не упомянули.
Например, нужно выставлять семафор что произошли какие-то изменения в состоянии разделяемого объекта. Ну вот простой пример. Есть 4 потока. Первый поток генерирует что-то такое, чем нужно поделиться с остальными тремя. Он выкладывает это в разделяемую память и выставляет флаг "есть изменения, разбирайте".
Дальше он генерирует новую порцию изменений. Но он не может их выложить просто так - прежде нужно убедиться что предыдущая порция была принята всеми тремя потоками. Если это не так - он будет стоять и ждать. При плотном двустороннем обмене еще дедлоки весьма вероятны
Это даже без учета блокировок чтение-запись.
Если использовать подход "почтовых ящиков", все становится проще. По сути это очередь входящих сообщений для каждого потока/процесса. Т.е. тут уже исключаются гонки и дедлоки т.к. хранилище данных для процессов разделены. Остаются только блокировки на уровне чтение-запись, но они решаются или использованием конкурентных очередей или использованием системных объектов где за всем этим следит сама система. И все становится просто - нужно что-то кому-то отправить - бросаешь в нужный "почтовый ящик" и идешь дальше заниматься своими делами. Ну и свой ящик проверяешь на предмет входящих.
В подавляющем большинстве случаев такой подход обеспечивает достаточную производительность при минимальных затратах.
Но, повторюсь, все это не имеет отношения к конкретному языку, все это из области общей архитектуры IPC (InterProcess Communications).
Пример уб с семантикой перемещений неверный, в c++ для классов с user-defined деструктором не генерятся move-конструктор и move-оператор= -> resource просто копируется, никакого уб там нет
Подводные камни C++. Решаем загадки неопределённого поведения, ч. 2