Comments 94
Советы годные есть, но по большей части — руководство для тех, кто хочет заюзать C++ потоки впервые и никогда их даже не трогал. Первый пример вообще падает, что явно повлечёт паломничество к гуглу. Ещё некоторые — элементарное непонимание/нечтение документации. На том же https://cppreference.com читать про библиотеку потоков — одно удовольствие.
А вот volatile
— откуда вообще пошли про него слухи как про волшебную таблетку для многопоточности? Оно, если правильно помню, нынче нужно только в очень редких случаях, вроде переменной изменяемой в прерывании для ардуино.
Первый пример вообще падает, что явно повлечёт паломничество к гуглу
Вот тут не понял, первый пример и должен падать. Там нет вызова join() как и написано в тексте. Он не должен работать. Допускаю что где-то при публикации могли проскочить опечатки, но примеры я проверял, они компилируются и работают (по крайней мере на моей платформе и версии компилятора).
Вот тут не понял, первый пример и должен падать.
Я о том и говорю. Написал новичок пример (hello world для потоков по-сути), запустил — ошибка. Полез сразу в гугл, что не так. Хуже, когда работает, но не так, как ожидаешь.
А про volatile
всё равно интересно.
volatile в MSVC имеет семантику атомарной операции, это нестандартное расширение.
Полагаю, порочная практика тянется со времён одноядерных процов, которые многое прощали, а volatile было достаточно, чтобы запретить кеширование переменной в регистре и сделать изменения видимыми другому потоку. Так и повелось.
volatile в MSVC имеет семантику атомарной операции,
А как он это делает для сложных объектов?
docs.microsoft.com/en-us/cpp/cpp/volatile-cpp?view=vs-2017#microsoft-specific
Формально, верно.
Фактически, поскольку не-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, как бы, помоложе истории 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 использовал только один раз в жизни, на лабораторной. В реальных проектах обходились другими архитектурными решениями.
и чтение атомарно.
Что тут подразумевается под атомарностью?
Стандарт не требует от int быть не больше регистра процессора. А если он вдруг больше?
но оптимизатор может в другом треде ее заранее закешировать, например вынести из цикла =)
volatile запрещает именно такое поведение. Тем не менее стандарт явно прописывает, что не годится для использования для многопоточного обращения
В данном случае речь идет не о чтение-сравнении-записи, а просто о чтении или записи. Теоритически, если переменная не выровнена в памяти и не влезает в регистр (не может быть считана в регистр за раз), то возможна ситуация когда будет считана только часть переменной, а позже будет дочитан уже кем то измененный остаток.
Только это все не про volatile.
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), что не имеет к тредам никакого отношения.
Не учите людей плохому.
Не играйте с volatile. Подобные ошибки нереально сложно отлавливать. Плюсов от volatile при этом вы никаких не получите.
люди не понимают, что атомарность и volatile в разных плоскостях находятся.
Например, как volatile можно объявить большую структуру — и доступ к её элементам не будет кэшироваться, но атомарность от этого не появится. Но: это некэширование для одного процессора и скомпилированного кода в нём. Без явного барьера нет гарантии, что процессор выставит эту операцию на шину вовремя — и поэтому volatile для IO адресов явно надо поддерживать машинно-зависимыми методами. Фактически, без них, без атомиков и без средств межнитевой синхронизации, у volatile только один смысл — убирание оптимизаций процессора для тестирования производительности :)
С другой стороны, в принципе можно кэшировать чтение и запись атомарных переменных. Например, две записи или два чтения одной и той же переменной с memory_order_seq_cst могут быть (в абстрактном языке) сведены в одну операцию, если между ними нет никаких других действий. Но, насколько помню, для C++ следует рассчитывать на то, что это не делается — даже если чтение/запись задаётся с memory_order_relaxed; а для прочих режимов тем более надо рассчитывать согласно их свойствам.
Фактически, без них, без атомиков и без средств межнитевой синхронизации, у volatile только один смысл — убирание оптимизаций процессора для тестирования производительности :)
Мне это кажется преувеличением — 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 очень сильно сократился.
А вот теперь вопрос — а нужен ли нам вообще был volatile? Оказывается, нет, потому что можно было точно так же в позицию // 2
вставить wmb().
Вообще-то нужен, в противном случае компилятор может увидеть что по адресу 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 формирование дескриптора пакета, а это уже убивает все возможные оптимизации в этом формировании. Остаётся только порядок операций из-за зависимостей данных.
А явный барьер решает эту проблему дёшево и эффективно.
Только на x86. И при этом делает не то что вы ожидаете:
* результат этой записи другой поток может увидеть очень не скоро (секунды??)
* оптимизатор может выкинуть такую запись, и ваша программа сломается
Другими словами — знание внутреннего устройства x86 вам не помогут, компилятор сделает всё исходя из предположения что разработчик для многопоточного кода не использует volatile.
Хотите жутких проблем для себя и компании — используйте volatile.
В компютерах где устройства замаплены на память — типа DEC
Читаете бы дважды с клавиатуры символ — а компилятор думает что это дурость и оптимизирует до одного чтения.
Так в современном x86 полно устройств, которые мапят точно так же свой ввод-вывод на память, даже если это не кусок видеопамяти или чего-то подобного.
У этого подхода появилось особое преимущество с распространением виртуализации: достаточно распределить адреса так, чтобы какие-то страницы памяти достались только этому устройству, чтобы можно было его напрямую выдать в виртуального гостя.
А вот volatile — откуда вообще пошли про него слухи как про волшебную таблетку для многопоточности?Компилятор не имеет право оптимизировать чтение переменных, помеченных volatile. На это опирался примитивный способ синхронизации потоков с помощью флагов (работаем в цикле пока флаг все еще true) во времена, когда честных атомарных переменных еще не было в языке.
Речь о том, что с 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, то забота компилятора это обеспечить не только барьером компилятора, но и памяти.
Если надо работать с железом и важен порядок записи в память, то лучше сразу копать в сторону ядра и драйверов устройств.
www.kernel.org/doc/Documentation/memory-barriers.txt
Проще всего конечно будет найти хороший пример для подражания и следовать ему.
> 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, выглядит хорошей тулзой.
И правильный код будет выглядеть как:
#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;
}
Строго говоря стандарт C++ не регламентирует поведение volatile объектов в runtime. Например, MVC++ добавляет release/acquire и барьеры памяти при доступе к таким объектам (https://docs.microsoft.com/en/cpp/cpp/volatile-cpp?view=vs-2017#microsoft-specific)
Что касается отмены оптимизации, то очень даже работает в случае, когда компилятор выполняет вычисления в коде, которые без volatile смело отбрасывает.
class Some {
public:
Some() : thread_(std::thread([this](){ run(); })) {}
void run();
private:
std::thread thread_;
.............
};
Ошибка №18: Создавать намного больше «выполняющихся» потоков, чем доступно ядер
Поясните, пожалуйста, «намного больше» — это насколько?
На компьютере запущено множество процессов (около сотни, допустим) и все они выполняются разными ядрами CPU (в зависимости от планировщика). Получается, что они работают неэффективно? Или я где-то не прав?
Число активных потоков должно быть меньше числа ядер, иначе пользователь заметит лаги UI. На сервере пользователей нет, но лучше всё равно одно ядро оставить для нужд операционной системы, администратора и для фоновых задач.
Число ждущих или спящих потоков может быть любым, и ограничивается не количеством ядер, а доступной оперативной памятью для стеков всех этих потоков и местом в системных таблицах.
В реальности все эти специальные костыли позволяют пользователю хоть как-то работать при полной загрузке процессора, но лаги UI всё равно заметны.
можно запустить «сложные вычисления длительностью больше кванта времени» в низкоприоритетном потоке и никаких тормозов заметно не будет.
void CallHome(string message)
{
std::osyncstream{cout} << "Thread " << this_thread::get_id() << " says " << message << endl;
}
Потоки ввода-вывода гарантируют атомарность вывода при выполнении операции << (то есть один вывод не может прервать другой).
Как всегда, каждое решение имеет цену.
Надо конечно посмотреть кот, но вряд ли это убирает лок. Скорее всего просто каждый поток выплевывает пачку вызовов cout << x << y<< z <<… << endl единой пачкой через этот буфер, но также по очереди между потоками.
ЗЫ (а вы видели, какой это ад в ассемблере ?)
Статья полезная, я бы добавил еще грех №21 "использование мьютексов вместо событий".
Главное, что если убрать из заголовка "С++", то смысл не изменится: все эти проблемы (ну, разве что кроме GUI) известны системным программистам с 1960-х годов.
Топ 20 ошибок при работе с многопоточностью на С++ и способы избежать их