Как стать автором
Обновить

Комментарии 94

Очень годная статья, спасибо вам за труд!
Спасибо за прочтение и обратную связь!
Совет про проверку joinable странный: ошибка-то заключается не в вызове join, а в вызове detach. В итоге программа остаётся неправильной, но уже никому об этом не рассказывает.
К «Ошибка №8: Взятие нескольких блокировок в разном порядке» ещё бы добавил использовать std::lock() на проектах до С++17

Советы годные есть, но по большей части — руководство для тех, кто хочет заюзать C++ потоки впервые и никогда их даже не трогал. Первый пример вообще падает, что явно повлечёт паломничество к гуглу. Ещё некоторые — элементарное непонимание/нечтение документации. На том же https://cppreference.com читать про библиотеку потоков — одно удовольствие.


А вот volatile — откуда вообще пошли про него слухи как про волшебную таблетку для многопоточности? Оно, если правильно помню, нынче нужно только в очень редких случаях, вроде переменной изменяемой в прерывании для ардуино.

В статье действительно приведены простейшие случаи, которые будут особенно полезны людям которые мало работали с многопоточностью или не имеют опыта работы с ней на С++. Опытные инженеры все эти проблемы знают на зубок, тем не менее бывает удобно иметь ссылку, которой можно было бы поделиться с менее опытным коллегой. Документация на cppreference.com действительно хороша, и всем советую ее читать. Целью статьи (я так думаю, это всего лишь перевод) был не пересказ документации, а обратить внимание на типовые ошибки и предложить их решения.
Первый пример вообще падает, что явно повлечёт паломничество к гуглу

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

Я о том и говорю. Написал новичок пример (hello world для потоков по-сути), запустил — ошибка. Полез сразу в гугл, что не так. Хуже, когда работает, но не так, как ожидаешь.


А про volatile всё равно интересно.

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

volatile в MSVC имеет семантику атомарной операции,


А как он это делает для сложных объектов?
volatile
If a struct member is marked as volatile, then volatile is propagated to the whole structure. If a structure does not have a length that can be copied on the current architecture by using one instruction, volatile may be completely lost on that structure.

Формально, верно.
Фактически, поскольку не-arm для ms, это почти всегда x86, Itanium похоронили, а на x86 инструкции чтения/записи по выровненному адресу атомарны, можно говорить, что в этих условиях volatile реализует семантику атомарной acq/rel операции, что ms косвенно подтверждает небесспорным утверждением: "This allows volatile objects to be used for memory locks and releases in multithreaded applications."
Но такие оговорки должны только утверждать во мнении, что оно не для этого.

А вот volatile — откуда вообще пошли про него слухи как про волшебную таблетку для многопоточности?


Из микроконтроллеров, наверное. Там оно нужно для доступа из всяких там прерываний. Но в многопоточности при использовании объектов синхронизации оно не нужно, так как эти объекты и так являются барьерами и памяти и компилятора.
Не, из Java Memory Model — там volatile гарантирует публикацию изменений.

Java, как бы, помоложе истории volatile в C/C++. И чёткое описание поведения как раз говорит за то, что проблему уже осознавали.

Вообще из статьи Александреску. Он потом сделал оговорку, что это работает только в некоторых случаях, но миф уже разлетелся.

От 2001 года? Но люди до сих пор помнят?)

Ещё вспомните:


“But throwing an exception from a constructor invoked by new causes a memory leak!” Nonsense! That’s an old-wives’ tale caused by a bug in one compiler – and that bug was immediately fixed over a decade ago.

https://isocpp.org/wiki/faq/exceptions#why-exceptions
Ведь помнят люди!

Из того, что отключается кэширование в регистрах, насколько я знаю (поправьте, если ошибаюсь), как одно из следствий отключения оптимизации. Но мне почему-то сразу было понятно, что запись в volatile переменные не атомарна и такими предубеждениями я не страдал.
На практике volatile использовал только один раз в жизни, на лабораторной. В реальных проектах обходились другими архитектурными решениями.

Квалификатор volaitile совершенно необходим в случае, если чтение или запись имеют побочный эффект, т.е. при использовании MMIO. Ваш Кэп)))
запись int по выровненному адресу атомарна независимо от volatile.
и чтение атомарно.

Что тут подразумевается под атомарностью?
Стандарт не требует от int быть не больше регистра процессора. А если он вдруг больше?

Например, на той же Ардуине int — 2 байта, а регистры по байту.
подразумевается, что чтение и запись в регистр атомарны, если адрес выровнен в памяти.
и volaitile тут ни при чем.
немного не так. в пределах одного треда/без прерываний — да, в отсутствие оптимизатора — тоже да

но оптимизатор может в другом треде ее заранее закешировать, например вынести из цикла =)

volatile запрещает именно такое поведение. Тем не менее стандарт явно прописывает, что не годится для использования для многопоточного обращения
Вы не понимаете что такое атомарность.
В данном случае речь идет не о чтение-сравнении-записи, а просто о чтении или записи. Теоритически, если переменная не выровнена в памяти и не влезает в регистр (не может быть считана в регистр за раз), то возможна ситуация когда будет считана только часть переменной, а позже будет дочитан уже кем то измененный остаток.
Только это все не про volatile.
По выровненному адресу и чтение и запись и еще некоторые операции для небольших переменных атомарны, но только с volatile. Без него компилятор может оптимизировать так, что упразднит вообще все записи в переменную если она далее по его мнению не используется, а в местах чтения переменной — оптимизировать до понимания переменной как константы или сохранить себе спокойно копию в регистр и не напрягаться по поводу реального содержимого самой переменной в памяти. В пределах даже многоядерной однопроцессорной системы все-таки атомарность есть так как кеш — общий. А вот как дела в многопроцессорных системах — теряюсь в догадках. Кто бы просвятил?
Да, согласен, что операции типа прочитать-изменить-записать в одной команде никак не атомарны, но: (из stackoverflow):
As pointed out by Peter Cordes in the comments, the LOCK prefix is not required for loads and stores, as those are always atomic on x86-64. However the Intel SDM (Volume 3, System Programming Guide) only guarantees that the following loads/stores are atomic:

Instructions that read or write a single byte.
Instructions that read or write a word (2 bytes) whose address is aligned on a 2 byte boundary.
Instructions that read or write a doubleword (4 bytes) whose address is aligned on a 4 byte boundary.
Instructions that read or write a quadword (8 bytes) whose address is aligned on an 8 byte boundary.
In particular, atomicity of loads/stores from/to the larger XMM and YMM vector registers is not guaranteed.

Это без lock и с volatile чтоб не нарваться на оптимизацию операций именно с конкретной переменной. На этом, конечно, далеко уехать сложно, да и процессор ничего не узнает о нашем желании не менять порядок выполнения команд, но для древнего подхода — установить флажок в одном потоке для другого потока — хватит.
Но правда ли это для многопроцессорных систем и для архитектур не х86/64?
На этом, конечно, далеко уехать сложно, да и процессор ничего не узнает о нашем желании не менять порядок выполнения команд, но для древнего подхода — установить флажок в одном потоке для другого потока — хватит.

И получите UB.


Древний подход с volatile — он не про установку флажка в разных потоках, он про установку флажка в ISR(Interrupt Service Routine), что не имеет к тредам никакого отношения.


Не учите людей плохому.

Переносимость даёт только std::atomic. volatile даёт только ошибки.

Не играйте с volatile. Подобные ошибки нереально сложно отлавливать. Плюсов от volatile при этом вы никаких не получите.
ну вот чтение/запись переменной это небольшая операция или нет?
люди не понимают, что атомарность и volatile в разных плоскостях находятся.
Не-оптимизация с устранением операций чтения/записи переменной и атомарность — ортогональные вещи. Может быть первое без второго, и наоборот.

Например, как volatile можно объявить большую структуру — и доступ к её элементам не будет кэшироваться, но атомарность от этого не появится. Но: это некэширование для одного процессора и скомпилированного кода в нём. Без явного барьера нет гарантии, что процессор выставит эту операцию на шину вовремя — и поэтому volatile для IO адресов явно надо поддерживать машинно-зависимыми методами. Фактически, без них, без атомиков и без средств межнитевой синхронизации, у volatile только один смысл — убирание оптимизаций процессора для тестирования производительности :)

С другой стороны, в принципе можно кэшировать чтение и запись атомарных переменных. Например, две записи или два чтения одной и той же переменной с memory_order_seq_cst могут быть (в абстрактном языке) сведены в одну операцию, если между ними нет никаких других действий. Но, насколько помню, для C++ следует рассчитывать на то, что это не делается — даже если чтение/запись задаётся с memory_order_relaxed; а для прочих режимов тем более надо рассчитывать согласно их свойствам.
Фактически, без них, без атомиков и без средств межнитевой синхронизации, у volatile только один смысл — убирание оптимизаций процессора для тестирования производительности :)

Мне это кажется преувеличением — volatile в C и C++ как раз и позволяет полагаться на то, что железо сможет правильно отработать ввод-вывод. Но «в сферическом смысле» конечно да)))
> Мне это кажется преувеличением — volatile в C и C++ как раз и позволяет полагаться на то, что железо сможет правильно отработать ввод-вывод.

Вместе с прочими средствами гарантии — да. Без них — нет.

Вот пример. Сейчас почти все ethernet адаптеры управляются так, что пакеты для отправки пишутся в память, а затем устройству задаётся «тебе есть работа, начинай отправлять». Пусть это «начинай отправлять» будет, упрощаем, записью адреса структуры отправки в некий регистр. Пусть регистр объявлен как

volatile void **ether_send;


и мы делаем отправку кодом вида

send_header *h = malloc(sizeof(send_header));
h->data = data;
h->length = length;
list_add_tail(&send_list, h);
// 1
*ether_send = h;
// 2


Что будет? У нас нет гарантии, что до записи в *ether_send всё остальное записано в память: компилятор может перенести эту запись. Надо обеспечить эту гарантию. Для этого в позицию `// 1` надо вставить нечто, что будет форсировать эту запись. Варианты:

1. Для x86 достаточно такого (GCC/Clang):

asm("" : : : "memory");


это уже компилятору указание «ой, здесь выполняется что-то с сайд-эффектом, я должен «сбросить» всё, что накопил».

Дальше сработает гарантия x86, что все записи «экспортируются» согласно потоку команд, и запись в регистр отправки будет позже всех нужных записей в память. (Уточнение: у x86 в это не входят «строковые» команды типа STORS, MOVS… если нужно их учитывать, лучше явно вызвать SFENCE.)

2. Для всех прочих, считаем, архитектур нужен явный write memory barrier — предположим, функция называется wmb(). Функция может быть inline из одной ассемблерной команды, но она точно так же обязана иметь пометку memory как side effect.

А вот теперь вопрос — а нужен ли нам вообще был volatile? Оказывается, нет, потому что можно было точно так же в позицию `// 2` вставить wmb().

Теперь пусть у нас, наоборот, чтение из такой сетевухи. Будет обратно симметричная картина:

volatile void **ether_recv;
...
// 3
struct net_recv *received = (struct net_recv *) (*ether_recv);
// 4
тут разбираем что получили -
  received->flags, received->data, и т.п.


в позицию 4 придётся вставить rmb() по любому, причём даже на x86, потому что для чтений, в отличие от записи, нет гарантии упорядочения согласно потоку команд. Роль этого rmb() выполняет LFENCE, поэтому может быть описано как

asm("lfence" : : : "memory");


И точно так же, *ether_recv может быть объявлено как volatile, но можно и не объявлять, если в позицию `// 3` тоже вставить rmb().

Заметим, что тут происходит управление кодогенерацией в компиляторе. Поэтому я не уточнял, в какой памяти пакеты. Это может быть память собственно сетевой карты, тогда всё равно должны быть команды барьера для компилятора, чтобы компилятор не унёс запись в память позже «толчка» отправки. Может быть память при процессоре (обычный RAM), тогда дело или для процессора память помечается как некэшируемая, или сетевуха должна уметь читать из процессорных кэшей, или нужен явный сброс конкретных страниц (x86 умеет такое). Это уже зависит от местных реалий.

По сумме сказанного как раз и получается, что смысл volatile для I/O очень сильно сократился.
Хм, так низко вне реального режима x86 и ARM не падал, но там это вполне прозрачно для программиста. А что сейчас в драйверах это должен программист делать или компилятор?
Программист, конечно. Компилятор… уже есть языки, которые умеют отслеживать такие ситуации и сами вставлять необходимые команды, но C этого, наверно, никогда не будет уметь. Зато писатели драйверов (в массе) обучены этому. Тут не так много тонкостей, проходится за полдня с перекурами. И часто во всяких вспомогательных bus_write_int() необходимые барьеры уже присутствуют, и дополнительно заботиться не нужно.
А вот теперь вопрос — а нужен ли нам вообще был volatile? Оказывается, нет, потому что можно было точно так же в позицию // 2 вставить wmb().

Вообще-то нужен, в противном случае компилятор может увидеть что по адресу ether_send ничего никогда не читается, и удалить присваивание. И барьер тут никак не поможет.

> Вообще-то нужен, в противном случае компилятор может увидеть что по адресу ether_send ничего никогда не читается, и удалить присваивание.

Он не может такого увидеть, потому что ему явно запретили делать выводы о том, что происходит, когда вызывается wmb(). За ним стоит функция или про которую он ничего не знает, кроме названия, или которая пусть даже inline, но с явной пометкой «неизвестный доступ к памяти».

Простой пример.

int *b;
void f1(int a) {
  *b = a;
  *b = a;
}
void f2(int a) {
  *b = a;
  asm volatile ("" ::: "memory");
  *b = a;
}


Ассемблер полученного:

f1:
        movq    b(%rip), %rax
        movl    %edi, (%rax)
        ret
f2:
        movq    b(%rip), %rax
        movl    %edi, (%rax)
        movq    b(%rip), %rax
        movl    %edi, (%rax)
        ret


Нельзя полагать, что после asm() то же состояние в *b, что было до него => обязан записать ещё раз.
А с чего это простой барьер будет неизвестными модификациями памяти?
> А с чего это простой барьер будет неизвестными модификациями памяти?

Я не знаю, какой барьер для вас «простой». Но я не вижу смысла в барьере без пометки «side effect: memory»: он просто не будет выполнять предписанную ему задачу. Поэтому я предполагаю, что любой барьер имеет такую пометку.

Если вы представляете себе какой-то случай, когда барьер нужен, но он не имеет такой пометки… опишите подробнее, пожалуйста, сложу в свою копилку курьёзов.

в случае если


volatile int *  b;
void f1(int a) {
  *b = a;
  *b = a;
}

        movq    b(%rip), %rax
        movl    %edi, (%rax)
        movl    %edi, (%rax)
        ret```
Это понятно, но заметно непрактично.
Если вы *ether_send пометите как volatile, то компилятор может переставить запись остального после его записи — потому что не видит никакой связи между сторонними эффектами и тем, что не имеет таких эффектов. Значит, надо помечать как volatile формирование дескриптора пакета, а это уже убивает все возможные оптимизации в этом формировании. Остаётся только порядок операций из-за зависимостей данных.
А явный барьер решает эту проблему дёшево и эффективно.
> запись int по выровненному адресу атомарна независимо от volatile.

Только на x86. И при этом делает не то что вы ожидаете:
* результат этой записи другой поток может увидеть очень не скоро (секунды??)
* оптимизатор может выкинуть такую запись, и ваша программа сломается

Другими словами — знание внутреннего устройства x86 вам не помогут, компилятор сделает всё исходя из предположения что разработчик для многопоточного кода не использует volatile.

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

В компютерах где устройства замаплены на память — типа DEC

Читаете бы дважды с клавиатуры символ — а компилятор думает что это дурость и оптимизирует до одного чтения.
Не обязательно к экзотике прибегать. В драйверах такая штука используется повсеместно на всех популярных платформах.
> В компютерах где устройства замаплены на память — типа DEC

Так в современном x86 полно устройств, которые мапят точно так же свой ввод-вывод на память, даже если это не кусок видеопамяти или чего-то подобного.

У этого подхода появилось особое преимущество с распространением виртуализации: достаточно распределить адреса так, чтобы какие-то страницы памяти достались только этому устройству, чтобы можно было его напрямую выдать в виртуального гостя.
А вот volatile — откуда вообще пошли про него слухи как про волшебную таблетку для многопоточности?
Компилятор не имеет право оптимизировать чтение переменных, помеченных volatile. На это опирался примитивный способ синхронизации потоков с помощью флагов (работаем в цикле пока флаг все еще true) во времена, когда честных атомарных переменных еще не было в языке.
А еще ядро было одно и кэша как правило не было.
Оно и сейчас будет работать.
Речь о том, что с volatile компилятор вставит реальное чтение/запись с памятью и процессор тоже со временем все сделает так как было задуманно.
Если не особо критично в какой конкретно момент времени это должно произойти, то все сработает как задумывалось.
Другое дело, что лучше использовать нормальные средства для синхронизации, а не колхозить.
Если не ошибаюсь, с volatile ещё гарантируется порядок операций.
Например,
int a=10;
int b=5;
Не гарантируется инициализация b после a.

Но
volatile int a=10;
volatile int b=5;

гарантируется.

Это я где-то когда-то читал. Если ошибаюсь, поправьте. :)
Не, не гарантируется.
Даже если сами на ассемблере напишите, порядок все равно не гарантируется.
процессор волен исполнять инструкции так как ему удобнее.
Более того, у х86 ассемблер это всего лишь внешний программный интерфейс, внутри процессора инструкции декодируются в микрооперации и исполняются в произвольном порядке.
Даже когда вы думаете что считываете переменную в регистр, на самом деле в процессоре там целый пул этих регистров и он может параллельно выполнять инструкции завязанные на один и тот же программный регистр.
Все что может компилятор — это сгенерировать машинный код.
На уровне машинного кода, да — volatile обяжет компилятор добавить операции чтения/записи с памятью.
В вашем первом примере компилятор вообще может выкинуть инициализацию переменных.
Не, не гарантируется.


Точно ли? В статье была речь вот о чём:
2. приведение к volatile указателю там, где нужно

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

/*
* Пишем в физическую память для активации устройства
*/
*((volatile int*)base_addr + 0xff) = 0;
*((volatile int*)base_addr + 0xff) = 0;
*((volatile int*)base_addr + 0xff) = 0;

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

we.easyelectronics.ru/blog/Soft/2593.html

Раз это работает для указателей на память, то так понимаю, для обычных volatile переменных тоже порядок определён.

И тут тоже пишут: Стандарт говорит, что последовательность чтения-записи должна сохраняться только для данных с квалификатором volatile.
Там автор не до конца понимает о чем пишет.
Он даже синтаксис языка не до конца понимает (вот отличный перл: «const int const * const p = &i»).
Но в целом он тоже упомянул, что volatile не гарантирует порядка исполнения/записи в память и необходимость использования дополнительных механизмов вроде memory barrier.
Т.е. в приведенном примере если записывать не нули, а например 1, 2, 3, то нет гарантии того, что они будут записаны в таком порядке, а не вперемешку.
Т.е. в приведенном примере если записывать не нули, а например 1, 2, 3, то нет гарантии того, что они будут записаны в таком порядке, а не вперемешку.


Судя по второй ссылке, гарантия всё же есть. Там же сказано «Стандарт говорит, что последовательность чтения-записи должна сохраняться только для данных с квалификатором volatile.» То есть, в моём примере с переменными порядок должен быть вроде как сохранён.
Во первых это ссылка не на стандарт, а на чей то пересказ.
Во вторых в статье как раз опровергают ваш вывод — у вас данные не volatile. Там у них тоже кастуется к volatile * и через него перезаписывается и они пишут, что в этом случае стандарт не гарантирует.
В третьих — всё что стандарт может гарантировать, это что компилятор сгенерирует машинный код где нужные инструкции записи в память будут присутсвовать в нужном порядке. Стандарт не может гарантировать, что процессор исполнит их именно в таком порядке. Это уже личное дело процессора.
Посмотрите en.wikipedia.org/wiki/Memory_barrier
Во первых это ссылка не на стандарт, а на чей то пересказ.


Ну, значит, определённости в этом нет. Осталось найти того, кто чётко и точно всё распишет и расскажет, что же гарантируется сейчас, а что не гарантируется.
Но в комментариях вроде как никто не оспорил, что для volatile переменных порядок выполнения гарантируется. Спор был про указатель. Хотя и там нашли, что вроде как и в этом случае порядок сохраняется, раз уж приводят к volatile В этом случае работает вообще всё, как изначально написано.

Во вторых в статье как раз опровергают ваш вывод — у вас данные не volatile.


В исходном примере как раз данные и были volatile:
volatile int a=10;
volatile int b=5;

и через него перезаписывается и они пишут, что в этом случае стандарт не гарантирует.


Да, в этом случае не гарантирует. Но компиляторы всё равно это делают по историческим причинам. Было бы интересно, если б кто рассказал, как запись в память по указателю однозначно гарантировать. asm volatile ("" ::: «memory»), наверное, надо использовать в этом случае (мне это нужно для работы с платами на шинах).

Стандарт не может гарантировать, что процессор исполнит их именно в таком порядке. Это уже личное дело процессора.


А вот чтобы процессор всё исполнял в нужном порядке, для того компилятор нужные барьеры памяти вводит. Поэтому раз стандарт гарантирует последовательность работы с переменными volatile, то забота компилятора это обеспечить не только барьером компилятора, но и памяти.
У меня не получилось играя с volatile вынудить компилятор сделать что то особенное. Компилятор просто ограничивался вполне очевидными инструкциями записи в память в правильном порядке и всё. Но я от него другого и не ожидал, потому что это уже должна быть забота программиста.
Если надо работать с железом и важен порядок записи в память, то лучше сразу копать в сторону ядра и драйверов устройств.
www.kernel.org/doc/Documentation/memory-barriers.txt
Проще всего конечно будет найти хороший пример для подражания и следовать ему.
Согласно C99, пункт 5.1.2.3(2):

> Accessing a volatile object, modifying an object, modifying a file, or calling a function that does any of those operations are all side effects which are changes in the state of the execution environment. Evaluation of an expression may produce side effects. At certain specified points in the execution sequence called sequence points, all side effects of previous evaluations shall be complete and no side effects of subsequent evaluations
shall have taken place. (A summary of the sequence points is given in annex C.)

Тут на самом деле надо добавить в список любые действия, эффект которых компилятору неизвестен (вызов функции, которую он не знает, и т.д.) Но суть описана — да, в вашем примере запись в a и запись в b переупорядочены не будут: это действия с сайд-эффектом.
А вот действия без сайд-эффекта «обтекают» такие операции, как вода камни, и могут быть выполнены и раньше, и позже — это полностью право компилятора.

А вот дальше начинается вопрос, что будет на уровне исполняющей машины. Даже формально сделанную запись в память можно не показывать другим процессорам/ядрам или внешним устройствам, пока не давно явной команды на то. И тут много вариантов, например:
— x86, обычная память: гарантируется, что другие процессоры/ядра/гипертреды увидят; а вот для устройств — нет, кэш может задержать это надолго, если вообще не задержать до перекрытия новым значением; гарантируется показ «коллегам» до другой операции записи этим же исполнителем;
— x86, память с явной пометкой write-through или uncached (сюда включается и доступ к I/O через память): будет отправлено сразу, гарантируется порядок записи согласно потоку команд;
— ARM, MIPS и много прочих: требуется явный вызов команды барьера.

Повторюсь, без машинно-зависимых методов (типа выключения кэширования через MTRR для I/O area) смысл сейчас в volatile только один — убирание оптимизаций процессора для тестирования производительности или логики компиляции :) а с ними может оказаться, и оказывается чаще всего, что и volatile не нужен.

П. 12 не понравился. Неужели нет более элегантного решения?
А еще автору стоило бы упомянуть про замечательный инструмент thread sanitizer, который есть в gcc и clang. Позволяет обнаружить множество проблем многопоточности. Я специально себе делаю билд под линукс и периодически проверяю проект thread sanitizer'ом и другими sanitizer'ами. Жаль в MSVC нет подобных штук.

Выглядит немного громоздко, да, но в целом IMHO ок. Что предложил бы только — передать аргумент-переменную по референсу, а не загромождать глобальное пространство.


П.С.: спасибо за указание на thread sanitizer, выглядит хорошей тулзой.

Есть замечательный std::packaged_task, который делает примерно то же самое

И правильный код будет выглядеть как:
Код
#include <iostream>
#include <thread>
#include <exception>
#include <stdexcept>
#include <future>
 
void LaunchRocket()
{
  std::this_thread::sleep_for(std::chrono::milliseconds(100));
  throw std::runtime_error("Catch me in MAIN");
}

int main()
{
  std::packaged_task<void()> task(LaunchRocket);
  
  std::future<void> result = task.get_future();
  
  std::thread t1(std::move(task));
  t1.join();
 
  try
  {
    result.get();
  }
  catch (const std::exception &ex)
  {
    std::cout << "Thread exited with exception: " << ex.what() << "\n";
  }
 
  return 0;
} 

Под Linux еще Valgrind есть.

Строго говоря стандарт C++ не регламентирует поведение volatile объектов в runtime. Например, MVC++ добавляет release/acquire и барьеры памяти при доступе к таким объектам (https://docs.microsoft.com/en/cpp/cpp/volatile-cpp?view=vs-2017#microsoft-specific)

volatile куда корректнее трактовать как отмену оптимизации, но точно не атомарность. Ну и грубый пример: если для volatile int i сложение ( i+=321; ) ещё может как-то где-то дать (но не гарантировать) атомарность, то умножение ( i *= 321;) уж точно не будет атомарным.
Что касается отмены оптимизации, то очень даже работает в случае, когда компилятор выполняет вычисления в коде, которые без volatile смело отбрасывает.
Я бы добавил ещё, что инициализация std::thread в списке инициализации конструктора — это почти всегда плохая идея.
class Some {
 public:
  Some() : thread_(std::thread([this](){ run(); })) {}
  void run();
 private:
  std::thread thread_;
  .............
};
Ошибка №18: Создавать намного больше «выполняющихся» потоков, чем доступно ядер

Поясните, пожалуйста, «намного больше» — это насколько?
На компьютере запущено множество процессов (около сотни, допустим) и все они выполняются разными ядрами CPU (в зависимости от планировщика). Получается, что они работают неэффективно? Или я где-то не прав?
Поток может или проводить активные вычисления, или чего-то ждать.

Число активных потоков должно быть меньше числа ядер, иначе пользователь заметит лаги UI. На сервере пользователей нет, но лучше всё равно одно ядро оставить для нужд операционной системы, администратора и для фоновых задач.

Число ждущих или спящих потоков может быть любым, и ограничивается не количеством ядер, а доступной оперативной памятью для стеков всех этих потоков и местом в системных таблицах.
У планировщика Windows есть специальные костыли методы планирования, повышающие приоритет вышедшим из ожидания потокам и у UI-потоков, имеющих сообщения в очереди, которые позволяют пользователю не замечать тормозов UI.
Это работало бы если бы все команды пользователя никогда не требовали бы сложных вычислений длительностью больше кванта времени.

В реальности все эти специальные костыли позволяют пользователю хоть как-то работать при полной загрузке процессора, но лаги UI всё равно заметны.
это прекрасно работает еще со времен одноядерных процессоров.
можно запустить «сложные вычисления длительностью больше кванта времени» в низкоприоритетном потоке и никаких тормозов заметно не будет.
Если запустить их в низкоприоритетном потоке — то они вообще не будут выполнены в условиях, когда потоки с нормальным приоритетом грузят процессор на 100%.
Зависит от дисциплины планирования и используемой операционной системы. Та же винда использует алгоритм «справедливого» планирования, одним из правил которой является повышение приоритета потока до максимального в пользовательском пространстве, если в течение 4 секунд поток ни разу не получил управление, то ли на один, то ли на 2 кванта времени, с последующим опусканием до исходного.
Вот задержка на 4 секунды как раз и даст пользователю ощущение того, что его компьютер лагает и тормозит.
А речь не о UI-потоках идёт, а о низкоприоритетных фоновых.
Если пользователь сделал некоторое действие и ожидает результата — он заметит лаг независимо от того, в каком потоке его действие обрабатывалось.
ну тогда у меня для вас плохие новости.
Более правильный и быстрый способ получить корректный вывод — без всяких std::mutex использовать std::osyncstream из C++20. Такой способ позволит полностью убрать большую критическую секцию в которой происходит медленный ввод/вывод. Потоки не будут в принципе блокироваться:
void CallHome(string message)
{
  std::osyncstream{cout} << "Thread " << this_thread::get_id() << " says " << message << endl;
}
А как оно работает внутри? Данные буферируются и записываются целиком? Или просто внутренняя блокировка работает?
Потоки ввода-вывода гарантируют атомарность вывода при выполнении операции << (то есть один вывод не может прервать другой).
Да, данные накапливаются в буффер, но в сам поток не пишутся пока не будет вызван деструктор osyncstream. В деструкторе данные выводятся через однократный вызов <<
И все зависает до момента, пока не выведется все задуманное? Я в смысле, что много так не выведешь, а если понемногу, но «почасту», то сколь велика будет разница?
Всё — это что? Поток блокируется только на время вывода уже подготовленного полного буфера, и всего один раз.
ОС удобнее выводить большими кусками, а не маленькими. Так что понемного и часто — медленнее.
И Вы всерьез называете то, что в Вашем примере «большим куском»? И говорите о переходе от синхронного посимвольного вывода к выводу целой строки, как о революции? Хотя да, наконец-то добавили, 40 лет не прошло…
Почитайте исходники iostream. Вы говорите что-то странное и видите революции там, где их нет.
И тогда чем же отличается osyncstream от обычного буферированного std::out? Только тем, что осуществляет фактический вывод в деструкторе? Чем же это лучше? Мне кажется, что если деструктор делает что-то кроме освобождения ресурсов, то это не запрещенное, но сомнительное решение.
То есть отсутствие блокировок разменяли на аллокацию.
Как всегда, каждое решение имеет цену.
Не обязательно. Есть куча способов избежать динамической аллокации: от страшненького thread_local буффера, до статического буффера в osyncstream или какого-нибудь lock free обмена буферами с нижележащим потоком.
Но все они будут работать лишь до определённого размера, после которого всё равно придётся перейти на динамическую аллокацию. А статический буфер — это вообще жуть с точки зрения reenterability.
С чего бы? Буфер у каждого вызова свой — там нет реентерабельности.

Надо конечно посмотреть кот, но вряд ли это убирает лок. Скорее всего просто каждый поток выплевывает пачку вызовов cout << x << y<< z <<… << endl единой пачкой через этот буфер, но также по очереди между потоками.
ЗЫ (а вы видели, какой это ад в ассемблере ?)
Давайте договоримся о терминах. Что Вы вкладываете в слова «статический буфер»?
Я — то, что подразумевается в POSIX-функциях, которые возвращают указатель на внутренние строки, например strtok.
про статический это не ко мне

Статья полезная, я бы добавил еще грех №21 "использование мьютексов вместо событий".
Главное, что если убрать из заголовка "С++", то смысл не изменится: все эти проблемы (ну, разве что кроме GUI) известны системным программистам с 1960-х годов.

Зарегистрируйтесь на Хабре , чтобы оставить комментарий

Публикации