Засады многопоточности
В данной статье я опишу свои самые свежие и яркие впечатления от многопоточного программирования. Это мои впечатления, мой опыт и я буду рад, если он будет полезен другим программистам.
В крайней статье я утверждал, что более серьезные проблемы их поджидают в случае взаимодействующих потоков. Но одно эти проблемы предполагать или даже предсказывать, а другое — столкнуться с ними непосредственно.
Предсказанное сбылось, как говорится, по полной программе. Думаю, что озвученные далее проблемы для кого-то не станут новостью, но будут и те, кто о них не подозревает, как не подозревал и я. А потому захотелось их зафиксировать и поделиться, с чем пришлось столкнуться. Ну, и рассказать, как я выкрутился, попав в не совсем привычные для меня ситуации (в автоматном программировании, подчеркну, они не возникли бы в принципе).
Итак, создав ранее тест потоков (о нем подробнее см. [1]), гоняя его многократно и в разных режимах, я заметил, что пусть редко, но выскакивают некорректные результаты. В подобных случаях я грешу обычно на себя. А в данном случае тем более, т.к., что там скрывать, имею весьма небольшой опыт использования потоков.
Но в процессе экспериментов обнажились проблемы, которые сложно списать на недостаток опыты. Что-то при этом удалось преодолеть сразу, с чем-то пришлось повозиться, но были и те проблемы, которые не удалось поправить, даже при наличии достаточно большого опыте в программировании вообще. О последнем, не об опыте, конечно, а о проблемах, и пойдет далее речь… И даже не о проблемах, а о довольно нежданных и негаданных «засадах», возникших на пути освоения многопоточности.
Доступ к общему ресурсу
Ничто не предвещало беды, когда при увеличении числа потоков и достаточной длительности работы теста время от времени значение общей переменной-счетчика (далее просто счетчика) перестало быть правильным. Например, при десяти потоках и числе циклов у каждого из потоков равном миллиону (см. также первоисточник рассматриваемой задачи [2]) вместо десяти миллионов выскакивал результат на несколько единиц меньше. Ошибки, правда, были редкими по времени, мизерными по значению и каких-то особых опасений не вызывали. Все списывалось на недостаток опыта работы с потоками и в надежде на последующую нормализацию работы теста.
Когда же игнорировать ошибки стало невозможно, то был реанимирован аналогичный тест, но созданный вне среды ВКПа. Так исключалось ее возможное влияние. Хотя по заложенной логике код теста не был завязан с кодом ВКПа. Были лишь использованы ее диалоговые и визуальные возможности. И вдруг, работая до этого правильно, он напрочь сломался. Результаты походили на тестирование без синхронизации, хотя тест использовал мютекс. Произошло это после, казалось бы, косметических изменений кода потока, который был в данном случае просто приведен к аналогичному коду в ВКПа, мало по сути отличаясь от своего исходного варианта. Но если в ВКПа были только редкие ошибки, то в аналоге — при каждом запуске теста.
Попытки откатить код к старому варианту после его изменений сделать стало сложно. Но поскольку старый тест был заблаговременно архивирован, то после постепенных и внимательных его изменений (своеобразных «плясок с бубном») было установлено, что проблема в инициализации общего ресурса (см. закомментированный тест и такой же, но только выше, в листинге 1). В варианте теста из архива он очищался до создания потоков, в новом — после. И тут, «как вспышка яркого света», все вдруг стало понятно… :)
Запущенный поток (см. метод start ()
класса потока) «мгновенно» приступает к работе и, видимо, успевает
отработать какое-то число циклов до старта следующих потоков. В том числе и до момента
инициализации счетчика (вернемся к листингу 1). Таким образом, на момент сброса
ресурса потоки истратят какое-то число своих циклов и будут иметь их разное число
по отношению к моменту сброса счетчика (см. листинг 2). При этом, когда счетчик
сброшен до момента создания потоков, они его изменяют, включаясь последовательно
в работу, и это не влияет на конечный результат. С новым тестом, когда ресурс
сбрасывался после создания потоков, ситуация получается обратная, т.к. какое-то
число циклов потоки отработают фактически бесполезно. Потому-то старый тест
работает правильно, а новый — нет. Предположить иное сложно, т.к. только
простое перемещение инициализации счетчика в другое место приводит к
безупречной работе теста.
Листинг 1. Код создания и запуска потоков
void MainWindow::Process() {
bIfLoad = false;
ui->lineEditMaxValue->setText(QString::number(nMaxValue).toStdString().c_str());
ui->lineEditNumberOfThreads->setText(QString::number(pVarNumberOfThreads).toStdString().c_str());
pCSetVarThread = new CSetVarThread();
int i;
for (i=0; iAdd(var);
}
timeLotThreads.start();
pVarExtrCounter = 0; // инициализация счетчика
TIteratorCVarThread next= pCSetVarThread->pArray->begin();
while (next!=pCSetVarThread->pArray->end()) {
CVarThread var= *next;
var.pQThread->start(QThread::Priority(0));
pCSetVarThread->nActive++;
next++;
}
// pVarExtrCounter = 0; // инициализация счетчика
bIfLoad = true;
ui->lineEditTime->setText("");
ui->lineEditCounters->setText("");
}
Листинг 2. Код потока
void ThCounter::run() {
// while (!pFThCounter->bIfLoad);
string str;
int n=0;
while (npIfSemaphoreLotThreads;
bool bMx = pFThCounter->pIfMutexLotThreads;
if (bSm || bMx) {
if (bSm) pFThCounter->AddCounterSem();
else {
pFThCounter->AddCounterMx();
}
}
else pFThCounter->AddCounter();
n++;
}
pFThCounter->pCSetVarThread->nActive--;
if (pFThCounter->pCSetVarThread->nActive==0) {
pFThCounter->ViewConter(pFThCounter->pVarExtrCounter);
pFThCounter->ViewTime();
}
}
Какой же вывод можно сделать, опираясь на подобные результаты? Работая с потоками, нужно постоянно учитывать их параллельную работу, которую они начинают — и это важно! — в точке своего запуска. Такой параллелизм внешне скрыт, что часто приводит, как оно произошло в моем случае, к неверному представлению о работе программы. Но самое интересное все же случилось далее…
Доступ к общему ресурсу
Тест работал, потоки вливались в работу по мере их создания, а результат был стабильно верным. Не устраивало, пожалуй, одно — момент начала работы потоков. Хотелось, чтобы они синхронно начали работу со счетчиком. Но как это сделать, если запуск потоков асинхронен по своей природе, т.к. код их запуска строго последовательный и другим быть пока не может. Одно из напрашивающихся решений — ввести глобальный флаг, разрешающий им работу с ресурсом. И, кстати, подобный флаг одновременно решил бы и проблему инициализации счетчика.
Сделать это совсем просто. Для этого достаточно добавить одну строчку в начало кода потока, где он и будет ждать пока флаг не будет установлен (см. листинг 2).
Ввел строку. Под отладчиком работает без вопросов. Вне — работа начинается, порой, как-то коряво, а потом тест вообще жестко виснет. Правда, если указать время ожидания завершения потока, то приложение самостоятельно завершит работу, выдав Fail Fast. Ситуация из разряда кошмарного сна программиста. Просто потому, что отладкой ошибку «пофиксить» как-то не получается (самой-то ошибки, вроде, нет), а понять, где она кроется, только по поведению программы проблематично… И что в такой ситуации делать?
Думать…
Виснуть приложение может только, если поток не завершается. Других вариантов, вроде, нет, т.к. все остальное прекрасно себя вело до этого — внесения цикла. Т.е. он, пожалуй, единственный кандидат, к которому можно предъявить претензии. Решение — разорвать его, вставив выход из него по значению флага работы потока — bIfRun. Другими словами, приводим данный цикл к виду:
Листинг 3. Добавление цикла в начало потока
while (!pFThCounter->bIfLoad) {
if (!bIfRun) break; // выход, если поток зависнет
}
Тест виснуть перестал. Уф! — можно выдохнуть. Угадали. Теперь хотя бы тест можно перезапускать кнопкой Reset (VCPa). Это уже что-то, но радости мало, т.к. тест все равно не работает. Провал?! Думаем дальше… Напрашивается следующее решение: поскольку налицо проблема с циклом, то поступим жестче — уберем его совсем. Можно, например, перенести проверку во внутрь цикла потока? Пробуем, преобразовав основной код цикла потока к виду:
Листинг 4. Устранение внешнего цикла
int n=0;
while (nbIfLoad) {
...
n++;
}
}
pFThCounter->DecrementActive();
И — о, чудо! — тест заработал (см. также видео к статье, ссылка в конце статьи). Как и чем это объяснить? Не знаю. Прямо двойная засада. Одна — это проблема добавленного цикла. Как он может стать вечным, если флаг явно установлен? Но, ведь, зараза, судя по реакции на флаг внутри него, — виснет?! Другая проблема — неэквивалентность эквивалентных преобразований. Предъявлять претензии к С++? Возможно. Но это мой взгляд дилетанта в проектировании компиляторов. Т.е. копать в эту сторону — себе, как говорится, дороже. Пусть судят специалисты в этом деле. В конце концов, есть простой проект, который эту «засаду» подтверждает и как бы устраняет. Правда, все это слабое утешение. Я же лично пока просто буду избегать подобных конструкций в потоке.
Выводы
На такой печальной ноте — жесткого виса теста, как предполагалось, будет завершена статья… Но, к счастью, решение нашлось. О нем я рассказал выше. Объяснить можно, конечно, все. В том числе и найденное решение. Оно не такое уж сложное и проблемы не было бы от слова совсем, если бы оно было изначальным (но тогда бы мы не узнали про засаду?!).
Но кто мне объяснит, как можно допускать, чтобы какой-то код по-разному проявлял себя в разных режимах проектирования программы — отладки и выпуска? Подобная ситуация напрягает, пожалуй, больше, чем ошибки и/или неверная работа теста. Хотя потоки — есть потоки и проблема, возможно, совсем не в коде, а в поведении потоков. Но тогда, как бороться уже с этим (только с чем)?
Поток — непредсказуемый и капризный партнер. В коллективе аналогичных «субъектов» его недостатки только множатся. При общении друг с другом — многократно. Контролировать подобный коллектив весьма сложно (вспомним начало статьи [3]). Теория убеждает и даже доказывает, что вряд ли вообще такое возможно. По отношению к потокам, конечно.
А потому выбирайте правильных партнеров! Надежных, верных, предсказуемых. Ну, вы, наверняка, уже понимаете, куда я клоню… Однако, в любом случае, выбирая, думайте своей головой, а не видитесь на рекламу. Пусть даже многообещающую. А ее сейчас — сверх меры. Я же, например, жду, когда искусственный интеллект от написания статей (про те же потоки) и политических выступлений (ака Вольфович) начнет качественно переводить техническую документацию. Как вы думаете, дождусь, а? Очень уж для меня это актуально. А то появились советы читать, мол, документацию…
Ну, а эту статью я написал, клянусь, сам. И даже код — не прибегая к услугам ChatGPT. А так хочется… халявы! ;) А интересно, что «генеративный интеллект» (так и подмывает сказать — дегенеративный) скажет про автоматное программирование, если ему задать такой вопрос? Задайте кто-нибудь… Или, кстати, может, он что-нибудь дельное посоветует по поводу последней засады? Пока мой интеллект здесь находится в полной отключке… Сломался то есть :(
Ссылка на видео — https://youtu.be/1-skz0PT0Zk
Литература:
1. Все секреты многопоточности. [Электронный ресурс], Режим доступа: https://habr.com/ru/articles/818903/ свободный. Яз. рус. (дата обращения 11.06.2024).
2. Многопоточность в Python: очевидное и невероятное. [Электронный ресурс], Режим доступа: https://habr.com/ru/articles/764420/ свободный. Яз. рус. (дата обращения 11.06.2024).
3. Sructured concurency в языке Go. [Электронный ресурс], Режим доступа: https://habr.com/ru/hubs/parallel_programming/articles/ свободный. Яз. рус. (дата обращения 11.06.2024).