" title="Написать письмо">Написать письмо
Донаты на карту ВТБ:
2200 4002 2461 6363

Статистика

Пользователи : 1
Статьи : 2356
Просмотры материалов : 8945149
 
Многопоточность: получилось-3 (10.09.2025). Печать E-mail
2025 - Сентябрь
10.09.2025 12:14
Save & Share
Прошёл год с того момента, как удалось реализовать макетирование многопоточности на Qt и Borland C++ Builder v.6.0 (полная загрузка процессора и неторможение интерфейса программы). За это время произошло полное разочарование в баганутой Qt (отдельно ещё отметилась здесь, здесь, здесь, здесь, здесь, здесь, здесь, здесь, здесь, здесь - окончательно порвав пердак здесь).

Поэтому, многопоточность применялась в 2 программах, написанных именно в Borland. Перечитав прошлую статью - пришло понимание, что требуется дополнение: уже не просто макетирование, а работа с реальными данными пошла.


Первая программа требовала нетормознутость интерфейса при выполнении фоновых работ. Одновременно использовалось максимум 3 потока: интерфейс (первый по умолчанию), обработка файла, обработка информации из файла. Проблем с программой не было - всё шло в соответствии с предыдущим материалом, но с дополнениями:
- в классы потоков была добавлена в private критическая секция (CRITICAL_SECTION csCThread;);
- реализация многопоточности была реализована в отдельном .CPP и .H-файле. Каждый поток имел своё фиксированное уникальное название и делал свою уникальную работу;
- запуск потока проводился в таймере, поток начал работать автоматически из-за false (CThread_Read *oThread_Read = new CThread_Read(false););
- в начале метода Execute() нужно написать "FreeOnTerminate = true;": для освобождения памяти от потока, каким бы способом (Suspend/Terminate/закончился) он ни был остановлен;
- проверка, работает ли ещё поток, делалась с помощью 2 глобальных переменных bool: если функция потока натыкалась на true в начале своего выполнения - этот поток, лишний, автоматически уничтожался return функции;
- так как у одного потока проводилось взаимодействие с элементами интерфейса - в его теле Execute() обязательно выполнялись InitializeCriticalSection(&csCThread), EnterCriticalSection(&csCThread) и LeaveCriticalSection(&csCThread), DeleteCriticalSection(&csCThread). Между ними находились функции работы с интерфейсом - перед работой с каждым элементом, ему делался .lock(), если возможно;
- перед самым выходом из программы: после всех delete для динамических элементов разных - всем потокам делались принудительные Suspend() и Terminate() - и только потом принудительно Application->Terminate().

Думая, что с многопоточностью теперь всё пойдёт как по маслу, - усложнил задачу во второй программе: параллельная обработка огромного массива данных всеми доступными потоками одновременно (-1 на интерфейс):
- засунул класс потока в файлы формы, чтобы файлов было меньше;
- реальное число потоков с помощью SYSTEM_INFO и dwNumberOfProcessors - получить невозможно (всегда 1шт), поэтому пока вручную было поставлено число потоков виртуальной машины, минус 1;
- глобальный массив на 128 потоков (теоретический максимум существующих, на примере 64-ядерного AMD 5995WX за 350000руб). Для каждого потока заранее подготавливалась своя незанятая порция данных, имелся флаг занятости потоком и результат обработки данных. Здесь бы больше подошла структура - но так повезло, что все данные были unsigned __int64 (частое выполнение потоков по 1 числу, а не редкое с большим объёмом данных);
- одна функция для всех потоков, с анализом внутри: какой поток по счёту запускается, отметка забирания себе какой-то порции свободных данных - чтобы другие одновременно работающие потоки шли лесом;
- проверка, что потоки друг с другом не конфликтуют (завязана на уникальных числах в глобальном массиве). Дополнительно: если с массивом поток вёл чтение или запись - массив "блокировался" глобальной переменной (и остальные потоки терпеливо ждали в while, пока переменная снова станет false).

Какое же было удивление, когда работа на одном потоке оказалась быстрее раз в 100, чем на двух и выше. В поиске бага, была задействована виртуалка с полностью отлаженной ОС для предыдущей программы - добавился ещё баг. У первой программы интерфейс не тормозит, у второй - висит (при этом, у обеих в коде - отсутствует ProcessMessages()). Перепроверил все строки создания и запуска потоков - всё одинаковое.

Второй баг был элементарным: в интерфейсе программы крутился while (true), циклично проверяющий окончание работы всех потоков по переменным глобального массива (по порции данных, равной количеству потоков). А т.к. сами потоки работали медленно - поэтому и интерфейс висел. Как ни странно, ProcessMessages() решила эту проблему.

ProcessMessages() решила и другое: скорость работы многопоточная стала как однопоточная. Однако это только на первый взгляд: родительская ОС показывала загрузку 8% при однопоточном варианте и на 12% при 2-поточном (поменял на 2, думая меньше грузить систему). И тут резко начало доходить:
- при макетировании в ранних статьях, использовалось многократное нажатие одной и той же кнопки с кодом для потока while-true - без исходного кода и данных. Это - просто факт, что многопоточность нормально запускается, - однако не гарантирует нормальную работоспособность именно с исходниками, и тем более с данными;
- дело в ОС Windows XP x32 SP3 - и SP3 тут спасает только логическим "да" в "можно использовать многопоточность", поэтому макетирование прошло полностью успешно. SP3, работает только как 1.5-2 потока - медленно, но верно. Поэтому у первой программы, где 3 потока практически никогда не пересекались по времени работы, - всё было отлично, число одновременно работающих потоков не превышало 2 - при этом первый, интерфейс, вообще без нагрузки. Ни исходный код не тормозил, ни данные не перемешивались друг с другом - идиллия;
- в Windows 10 же, на реальной машине, вообще появилось сообщение: "Thread creation error: Недостаточно памяти для обработки команды" (WTF?!1). Оно само ушло, когда все описанные ниже ошибки с потоками удалось исправить;
- кто сказал, что второй поток обязан грузиться всегда на 100% (было верно при while-true в потоке при макетировании - но не при обычном коде);
- VirtualBox не позволяет виртуальной машине выйти на нужное количество потоков, ограничивая его каким-то странным числом 12.5%, когда потоков в процессоре 12. Вот это и есть те самые 1.5-2 потока, которые использует Windows XP SP3 с реальными данными (SP2 будет использовать только 1);
- кто сказал, что внутри VirtualBox поток аналоговый, а не цифровой? Если в родительской ОС, при однопоточности приложения, процент загрузки ЦП равен чётко делению 100% на количество физических потоков - то в Borland внутри виртуальной машины - всё может быть иначе. И, действительно: ставишь 2 потока без ProcessMessages() - загрузка ЦП внутри виртуальной машины всегда 100% (код кнопки давит своей загрузкой, замедляя второй поток), загрузка ЦП реального - всегда 8% (как будто 1 полный поток). С ProcessMessages() - получается 12.5%: пик многопоточности Windows XP (при этом, видно: на интерфейс bcb.exe тратится 10-37% виртуального ЦП, а на EXE программы - синхронно 90-63%);
- дальнейшие эксперименты показали: в виртуалке потоки просто делят между собой проценты от процессора. Если второй поток из 2 (первый - while-true) - сожрёт 90-63% ЦП, если второй и третий поток из 3 - каждый будет жрать 45-31.5%. Как следствие - хоть 6 виртуальных потоков ставь, хоть 2 - время обработки данных будет практически одинаковым;
- далее выясняется, что выходные данные начали друг с другом путаться при каждом нажатии клавиши обработки входных данных, если входных данных много (и визуально видно, и проверка на такую ситуацию срабатывает). Добавление volatile к переменным массива и блокирующей его переменной - лишь уменьшили частоту проблемы. Парился долго - была ошибка: в while-true было неверное условие проверки глобального массива: нужно не только проверять, что поток вошёл в нужную часть данных, - но и что он из неё не просто вышел, а и записал флаг корректности своего завершения (фактически, результат обработки данных).

Далее, в Windows 10 на реальной машине, была получена успешная работа: нагрузка ЦП на 25-30%, вместо 8% в однопоточном варианте. При 12 потоках и нагрузке на 5 потоков (выставил обратно, что у ЦП "6" потоков) - результат не очень, но было получено главное: чем больше потоков, начиная с 2, - тем больше загрузка ЦП реальными данными.

Так и не получилось получить количество потоков у процессора в виртуалке Windows XP. В реальной ОС, в Windows 10 - определяет корректно 12. Надо поставить Windows XP на реальную машину и посмотреть, как там оно.

Создание потоков в цикле с одинаковыми именами, равно как и вручную с разными: имеют одинаковый эффект - объекты с одинаковыми именами не уничтожают друг друга и не пересоздаются.

ProcessMessages(), по итогу: при однопотоковой работе, когда программа создаёт 1 цифровой поток, вдобавок к интерфейсному, - добавляет второму потоку процентов процессора на обработку данных (через уменьшение процентов первого, интерфейсного). При многопотоковом реальном ЦП - никак не влияет ни на скорость, ни на суммарный процент нагрузки ЦП. Это означает, что выгодно даже в одноядерном однопотоковом ЦП иметь суммарно 2 виртуальных потока, которые будут биться за проценты ЦП в одном потоке физическом. И интерфейс висеть не будет.

(добавлено 11.09.2025) Продолжая шаманить с исходниками, удалось добиться повышения загрузки ЦП до 38% (соответствует однопоточной скорости 6.612ГГц на i5-10400). На своём E5-2696 V2 в Windows 7 x64 - выжал всего 20% (соответствует 6ГГц). В ProcessLasso прямо видно: один поток нагружен процентов на 80, а остальные все возле низа прыгают. Поэтому, итоговый прирост скорости - максимум в 4.75 раза.

И этот максимум - не соблюдается. Visual Basic v.6.0 1998 года, на котором была написана старая однопоточная версия программы, - достаточно быстрый. Borland C++ Builder v.6.0 2002 года - может, быстрее его, а может и нет. Сравнение 2 версий программ на разных языках и количестве потоков: выяснилось, что реальное ускорение - примерно в 2 раза. Причины:
- многопоточность - требует отдельного обслуживающего себя кода и дополнительных проверок. А также искусства этой многопоточностью пользоваться. Это порождает дополнительную нагрузку на ЦП, входящую в те 38%;
- с учётом, что в C-подобных языках постоянно приходится вручную перетипировать данные и делать прочую рутинную муть, - реально, большой вопрос в лучшей скорости Borland относительно VB. Даже то, что в старой программе шло взаимодействие с объектом Excel (который точно тормознутости добавляет), однопоточную версию на Borland это не спасло: скорость у них вышла одинаковой. Соответственно, в очередной раз подтверждается: лучше писать на VB-подобных языках, чем на C-подобных (и как классно было писать на IBM LotusScript в 2008-2010 годах);
- разбирая ускорение сборки Qt: в ОС тоже можно было наблюдать за прыжками ползунков загрузки потоков ЦП во время сборки - номиналы загрузки от низа до верха плясали. Теоретическое ускорение должно было быть в 12 раз - а вышло максимум в 4.4 раза. То есть, разработчики Qt - тоже криворучки (кто бы мог подумать); особенно если учесть, что параметр ускорения сборки вообще по умолчанию всегда в проекте стоять должен.

Итак, итоговый прирост скорости - всего в 2 раза. Можно играться с исходниками дальше, можно не играться - но нужно поднять вопрос: стоит ли такой прирост всего этого геморроя. Возможно, умение пользования многопоточностью приходит с каким-то уникальным опытом, который ещё не изучен; и именно из-за такого геморроя и потерь времени - программисты РФ смотрят на многопоточность и думают: ну её на фиг.

(добавлено 12.09.2025) Windows XP SP2 на реальной машине с Q8300 2.5ГГц (4-ядерный 4-поточный) - программой загружается только один поток (чётко 25%). После установки SP3 - чуть больше 2 потоков (~54%, падающее позже до 25%, - но логически многопоточность работает). После установки SP4 - то же самое. В Windows XP x64 SP2 - баг исправляется: 49-54% уже всегда, без падения до 25%, - но всё равно 2 с небольшим потока из 4 (точнее, все 4 загружены, условно, наполовину: нет индикации загруженности % каждого потока). Windows 7 x64 SP1 и SP2 - так же 50-54%.

То есть, добавляются кислые плюшки:
- многопоточность в разных ОС ведёт себя по-разному - однако со времён Windows XP x64 ситуация стабилизируется, если судить по тестированию на одном ЦП;
- на разных ЦП скорость в пересчёте на суммарные гигагерцы получается разная - и скорость работы программы будет разная. Процессор может быть мощнее по потокам и гигагерцам - но многопоточность может выйти хуже из-за задействования, например, только половины потоков. Возможно, ситуация стабилизировалась при выходе процессоров, у которых ядер стало меньше потоков. Q8300 показал самый слабый результат 5ГГц.

cThread::Terminate(); в конце Execute() потока - обязательно. Иначе поток, отработав, просто будет скучать - и из приложения становится невозможно выйти.

Начиная с Windows 7 - программа требует наличия библиотеки stlpmt45.dll.

Расставленные ловушки времени - показали, что время на форматирование численных данных в текстовый массив, запись его в файл и отображение в Memo - мизерное, в сравнении с потоковой обработкой данных (даже если сама порция данных маленькая). К счастью, ловушки были расставлены не слишком поздно, - и реальную скорость обработки определённой малой порции данных удалось засечь в разных местах:
- i5-10400: внутри VirtualBox в Windows XP SP3 - 547мс, в реальной Windows 10 x64 v.22H2 - 131мс;
- в реальных ОС на реальном Q8300: Windows XP SP3 - 428мс, Windows XP x64 SP2 - 377мс, Windows 7 x64 SP1 - 373мс, Windows 10 x64 v.22H2 - не поставилась - пришлось ставить Windows 11 v.21H2 - 383мс. Числа - примерно тождественны указанным ранее процентам загрузки ЦП в этих ОС;
- в реальной ОС i5-10400, Windows 10 x64 v.22H2: сравнение старой версии программы на VB6 против многопоточной на Borland (на большом объёме данных). 359с против того, что новая программа перестала работать, - надо думать дальше. Там сложно: в виртуалке с Windows XP SP3 - медленно и дорабатывает до конца за 1234с, а в Windows 10 - нагрузка падает до одного потока где-то через минуту, и так бесконечно и держится. То есть, while в кнопке ждёт и ждёт, пока все данные будут обработаны, - а 1 из потоков умер и никогда не закончит работу, для которой был создан (об этом же говорит и невозможность закрыть программу: поток не до конца самовыпилился или встал - без Terminate()). Так и не разобрался, в чём дело.

В Windows 11 v.21H2 - был получен самый высокий результат загрузки ЦП у Q8300: 64%. Но потом, т.к. программа имеет баг, - скатывается до 25% спустя минуту.

(добавлено 19.09.2025) В книге Ю.П. Федоренко "Алгоритмы и программы на C++ Builder" - вообще о многопоточности ни слова. То же самое касается Стенли Липпман, Жози Лажойе "Язык программирования C++. Полное руководство" (вот тебе и "полное" аж третье издание). Ну и как в РФ могут вырасти программисты с успешным многопоточным программированием - если обучающих материалов толком нет.

По Qt - замечена книга М. Шлее "Qt 5.10. Профессиональное программирование на C++". Глава 38 "Процессы и потоки" - да, описание многопоточности есть. Там же описываются критические секции - но до сих пор непонятно, работают ли они в Borland C++ Builder или нет.

ИИ подсказывает исходники по задействованию мьютексов - все они безрезультатны: ИИ пишет, что для 6 версии билдера, - а по факту - для высших.

(добавлено 23.09.2025) Ошибка была исправлена - с получением нового опыта (напоминание: все проблемы - наблюдались в Windows 10 на новом ЦП i5-10400 - и не наблюдались в Windows XP в виртуальной машине на "старом 6-потоковом" процессоре и на старом процессоре Q8300):
- проверялась корректность места вызова Terminate потока - его нужно делать именно в конце потока, а не когда-нибудь где-нибудь Terminate в главной функции. Иначе - вплоть до краха программы (при условии, что потоки не плодились как грибы);
- даже когда добавил мьютексы (hMutex = CreateMutex(NULL, FALSE, NULL) с CloseHandle(hMutex) в основной функции и WaitForSingleObject(hMutex, INFINITE) с ReleaseMutex(hMutex) в самом потоке) - ошибка сохранялась;
- ошибка выявлена: пара потоков, почему-то, обрабатывают одну и ту же порцию данных - какая-то одна другая порция данных никогда не будет проанализирована - основной цикл всё время крутится в while(жду) - вычисления не идут. Не помогает даже delay при запуске потоков (смещение их запуска): всё равно рулетка;
- исправление ошибки. Ожидание while, пока массив данных не освободится от действия всех потоков, - на фиг. Поток автоматом не запускать, присвоить ему номер порции данных и запустить его с помощью Resume(). Так как каждый поток обращается именно к своей порции данных - все порции данных обрабатываются корректно. И массив с этими данными - не страдает. То есть, когда поток меняет значение в глобальном массиве - он меняет конкретные биты в памяти, не влияя при этом на другие числа массива (на другие порции данных);
- проверялось изменение глобальной переменной (счётчик запущенных потоков). Даже если потоки запускаются одновременно в for одной строкой - счётчик отражает именно то количество потоков, сколько и порций обрабатываемых данных. Но прикол в том, что счётчик увеличивается до InitializeCriticalSection и EnterCriticalSection, - это не показатель именно их корректной работы. Множественное везение подряд на 15.5млн запусках за раз? Да!11 Потому что при 12 запуске - счётчик показал на 1 запуск меньше: переменная записалась одновременно, условно, с 34 на 35 - вместо 34 на 35 и 35 на 36. Какого же хрена весь самоконтроль показал, что всё исправно с обрабатываемыми данными? Не потому что весь остальной код потока был между EnterCriticalSection и LeaveCriticalSection, а потому что произошло вышеописанное разграничение при работе с данными, и ни 1 поток никогда не стучится в данные соседнего.

По итогу, можно считать:
- у Borland C++ Builder v.6.0 есть проблема с параллельными вычислениями с новыми процессорами (от какого года считать новизну - неизвестно). Многопоточное программирование - можно применять, параллельные вычисления - с осторожностью: чтобы потоки писали данные в разные места RAM, в разные элементы массива или в разные переменные;
- нет доказанной эффективности критических секций и мьютексов (именно в контексте параллельного программирования с одним местом хранения данных). Единственный способ проверки - крутить в триллионном цикле увеличение глобальной переменной, но на это нет времени.

Чем более оптимальна будет функция потока (а особенно - механизм их контроля в главной функции) - тем больше % от ЦП удастся откусить. Добавление в главную функцию обновление строки интерфейса - уменьшило количество % от ЦП, потому что потоки ждут - а ОС показывает среднее значение загрузки по каким-то своим вычислениям. С принудительным разграничением данных: удалось загрузить процессор до 40% - и чем большие числа в порции данных (чем дольше поток её обрабатывает) - тем процент становится выше.

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

Оптимизация кода помогла достигнуть увеличения скорости в 3.2 раза. Однако выяснилось, что если кормить потоки порциями данных, количество которых равно количеству потоков, - скорость работы неоптимальная. Возникает ситуация при работе с большими порциями данных: 10 потоков все отработали - и терпеливо простаивают, пока 11-й всё закончит. В итоге, средняя нагрузка на ЦП, показываемая ОС, упала до 30%. Попытался увеличивать скорость, скармливая новые порции данных только что освободившимся потокам - обнаружилось:
- Suspend не работает должным образом (свойство Suspended всегда false). Вручную проставить свойство Busy классу потока пришлось: как самоконтроль работы потока;
- перенести сами данные не в глобальный массив, а локально в поток, - не удалось: только Terminate в конце потока позволяет ему работать нормально. Соответственно, приходится постоянно создавать новые объекты потоков. ИИ на эту тему говорит то же самое;
- мутексы порождают дескрипторы, 1 раз не удалось удалить их корректно. Дескрипторы в ОС - не бесконечны: где-то на 10млн дескрипторов программа вырубилась;
- данные приходят неупорядоченными - нужно вручную их сортировать после окончания работы с потоками.

Поэтому, кормить потоки порциями данных, количество которых равно количеству потоков, - гораздо более надёжно. В итоге, такая реализация вышла быстрее, чем пытаться загрузить все потоки сразу после их освобождения (функция запуска потоков, переписанная под другой способ работы, тормозит работу потоков выше, чем исходный вариант). Плюс самоконтроль проще: можно посчитать суммарное время обработки данных всеми потоками за 1 итерацию.

Обновлено ( 09.12.2025 10:56 )
 
 

Последние новости


©2008-2026. All Rights Reserved. Разработчик - " title="Сергей Белов">Сергей Белов. Материалы сайта предоставляются по принципу "как есть". Автор не несет никакой ответственности и не гарантирует отсутствие неправильных сведений и ошибок. Вся ответственность за использование материалов лежит полностью на читателях. Размещение материалов данного сайта на иных сайтах запрещено без указания активной ссылки на данный сайт-первоисточник (ГК РФ: ст.1259 п.1 + ст.1274 п.1-3).

Много статей не имеет срока устаревания. Есть смысл смотреть и 2011, и даже 2008 год. Политика сайта: написать статью, а потом обновлять ее много лет.
Рекламодателям! Перестаньте спамить мне на почту с предложениями о размещении рекламы на этом сайте. Я никогда спамером/рекламщиком не был и не буду!
Top.Mail.Ru