Net-Base Журнал

24.07.2026

Delphi: TParallel.For с потокобезопасным Progress-UI (TThread.Queue) без взаимоблокировок

Как сочетать Delphi TParallel.For с потокобезопасным интерфейсом прогресса: обновления через TThread.Queue, корректная агрегация, обработка отмены и типичные ловушки взаимоблокировок при отладке и эксплуатации.

24.07.2026

От темы в журнале к проектной практике

Соответствующие страницы услуг и технологий к статье

Кто в Delphi пытается распараллелить ресурсоёмкие или I/O-нагруженные задания, быстро приходит к Parallel Programming Library (PPL) и, в частности, к TParallel.For. Эффект часто заметен сразу — до тех пор, пока не нужно «быстро» обновить интерфейс прогресса. Именно здесь возникают типичные зависания: кажущиеся случайными UI-freeze’ы, ProgressBar, которая скачет назад, или полный дедлок при пошаговой отладке.

В этой статье речь о TParallel.For потокобезопасной Progress-UI: надёжный паттерн, который работает в VCL и FMX, аккуратно агрегирует обновления UI через TThread.Queue, учитывает Cancel/Abort и последовательно обходит типичные ловушки дедлоков. Фокус на реальных условиях эксплуатации: воспроизводимое поведение, понятные зоны ответственности и советы по отладке, которые помогают даже если ошибка проявляется только «у клиента».

Почему Progress-UI в TParallel.For так часто выходит из строя

TParallel.For как правило выполняется на рабочих потоках из Delphi-пула потоков. Эти потоки не должны напрямую обращаться к VCL- или FMX-контролам, поскольку UI-фреймворки (Message Loop, Window Handles, Rendering) привязаны к главному потоку. Даже, казалось бы, безобидный ProgressBar.Position := … в рабочем потоке может вызывать неопределённое поведение: спорадические AVs, замороженные окна или «мерцающие» обновления.

Очевидным «ремонтом» часто становится TThread.Synchronize. Да, это решает проблему потокобезопасности, но в параллельных циклах быстро порождает другую проблему: последовательный бутылочное горлышко. Каждый рабочий поток ждёт главный поток, который занят рендерингом и обработкой Synchronize-вызовов. Под нагрузкой это выглядит как дедлок — даже если по сути это эффект starvation/lockstep.

И есть ещё класс настоящих дедлоков: главный поток ждёт (например, через WaitFor, Task.Wait или косвенно через блокирующие вызовы) завершения параллельной операции, в то время как рабочие потоки пытаются отправить работу в главный поток через Synchronize или при неудачном использовании Queue. Результат: главный поток ждёт рабочих, рабочие ждут главный поток.

TThread.Queue vs. TThread.Synchronize: der praktische Unterschied

Unschärfer Debugger-Blick mit Notizen zu Threads und Queue als Kontext für Queue vs Synchronize
При отладке быстро видно, ждут ли рабочие потоки главный поток или просто ставят в очередь обновления.

Оба механизма служат для безопасного выполнения кода в главном потоке. Разница — в семантике ожидания:

  • TThread.Synchronize: вызывающий рабочий поток ждёт, пока главный поток выполнит код. Это «синхронно», увеличивает задержки и является классическим фактором возникновения дедлоков, если главный поток в данный момент заблокирован.
  • TThread.Queue: Worker-поток помещает код только в очередь для Main Thread и продолжает работу. Это «асинхронно», разъединяет потоки и в параллельных сценариях почти всегда является лучшим выбором по умолчанию — при условии контроля частоты обновлений.

Важно: Queue — это не пропуск без ограничений. Если вы в каждой итерации цикла отправляете обновление в Queue, вы переполняете очередь Main Thread. В таком случае UI подвисает не из‑за deadlock, а из‑за простого избытка сообщений. Интерфейс выглядит «тормознутым», и завершение обработки задерживается, потому что ещё сотни или тысячи обновлений UI будут выполнены позже.

Краевой случай, который действительно опасен: ожидание в UI-Thread

В корпоративных приложениях часто встречается следующий сценарий: нажали кнопку «Start», UI деактивируется, открывается ProgressDialog, затем синхронно «ожидают», пока всё завершится, и после этого повторно активируют интерфейс. Этот паттерн — в основе многих deadlocks.

Типичные варианты (в зависимости от кодовой базы):

  • Main Thread запускает TParallel.For и затем вызывает блокирующую логику ожидания (напрямую или косвенно).
  • ProgressDialog в конструкторе или OnShow вызывает процедуру, которая внутри ожидает.
  • Кнопка «Abbrechen» устанавливает флаг, но Main Thread всё равно остаётся в цикле ожидания.

Если воркер‑потоки в это время используют Synchronize, deadlock практически гарантирован. С Queue тоже может возникнуть зависание, если Main Thread заблокирован и не обрабатывает сообщения — тогда и очередь не будет обработана.

Практическое следствие выглядит так: Main Thread не должен блокирующе ожидать завершения параллельного цикла, если параллельно требуются обновления UI. Вместо этого обработку либо полностью выносят в фоновую задачу, либо организуют «асинхронное завершение» (Callback/Queued Abschlussaktion), которое в конце снимет блокировку с UI.

Чистый подход: прогресс только в агрегированном виде, обновления UI — с ограничением частоты

Сцена рабочего места с абстрактным индикатором прогресса и таймером как символом дросселированных обновлений UI
Агрегация и временное дросселирование предотвращают переполнение UI из‑за слишком большого количества обновлений.

Надёжный шаблон состоит из трёх ясно разделённых областей ответственности:

  • Worker-Threads выполняют основную работу для каждого элемента/индекса. Они сообщают только о прогрессе в потокобезопасной форме (счётчик, Queue, thread-safe Queue).
  • Aggregator (часто: Main Thread или выделенный таймер в UI) вычисляет из прогресса статус для UI (позиция, текст, ETA) и обновляет Controls. Так вы избегаете 1:1‑обновлений на итерацию.
  • Abschluss (также в Main Thread): реактивировать UI, отобразить результат, обобщить ошибки, освободить ресурсы.

Почему такое разделение работает так хорошо: нагрузка может быть высокочастотной (тысячи элементов), но UI требует лишь нескольких обновлений в секунду. На практике достаточно 5–10 обновлений/секунду, при очень быстрых задачах даже 2–4. Всё, что выше, обычно лишь визуальный шум и отнимает время CPU в цикле обработки сообщений.

Потокобезопасный подсчёт: атомарно вместо блокировок

Для простой ProgressBar часто достаточно атомарного счётчика. «Атомарно» означает: инкремент и чтение происходят без Race Condition, типично через TInterlocked. Это позволяет избежать Locks (Critical Sections) в hot path цикла.

Отработанная минимальная идея:

  • Общий объём известен заранее (например, количество записей, файлов, ID).
  • Каждая итерация атомарно увеличивает DoneCounter.
  • UI-таймер периодически читает счётчик и устанавливает ProgressBar.Position.

Преимущество: нет TThread.Queue на элемент, нет перегрузки UI. Недостаток: нет подробных сообщений по каждому элементу (например, имя файла). Зато можно добавить второе, дросселируемое статусное сообщение (см. следующий раздел).

Статусные сообщения без спама: «последний статус побеждает»

Если вы дополнительно хотите отображать короткий текст (текущий элемент, фаза, сообщение об ошибке), нужен шаблон, который не будет на каждом шаге воркера захлёстывать UI. На практике схема «последний статус побеждает» работает очень хорошо:

  • Worker записывает статусную информацию в потокобезопасную структуру (например, атомарно заменяемая строка или защищённая небольшой блокировкой).
  • UI-таймер периодически переносит последний зафиксированный статус в Label.

Так UI остаётся отзывчивым, и при этом видно, что «что-то происходит». Важнее тут не сам строковый текст, а время жизни: не переносите ссылки на краткоживущие объекты из воркер-потоков в UI-поток. Если вы передаёте объекты, явно определяйте владение (Ownership).

TParallel.For потокобезопасный Progress-UI с TThread.Queue: надёжный шаблон

Есть сценарии, где одного UI-таймера недостаточно: например, когда в конце нужно гарантированно выполнить ровно одно обновление «Готово», или когда UI-обновление — более сложный шаг (например, запись в окно лога, но с дросселированием). Тогда подходит TThread.Queue — но не для каждой итерации, а целенаправленно.

Практичный подход — это очередь только для событий с низкой частотой:

  • Start-Event (подготовить UI, заблокировать кнопки)
  • Периодические Progress-Events (не чаще чем каждые X миллисекунд)
  • Fehler-Events (опционально собираемые)
  • Done-Event (сбросить UI, показать результат)

Периодичность вы добиваетесь не в UI-потоке, а в контексте воркера: воркеры ставят UI-обновление в очередь (queue) только если с момента последнего UI-обновления прошло достаточное время. Для этого подходит монотонный источник времени (например, TickCount) плюс атомарное значение «last update».

Важно: само UI-обновление должно быть «быстрым». Дорогие вычисления, файловый I/O или обращения к базе данных не принадлежат помещённому в очередь UI-колбэку. Колбэк должен только считывать состояния и устанавливать элементы управления.

Cancel-Handling: прерывание без зависаний

В реальных приложениях отмена не опциональна. Ключевое: Cancel — это не «Kill», а кооперативное завершение. Воркеры должны регулярно проверять, установлен ли сигнал отмены, и корректно завершаться. В Delphi для этого есть несколько способов (в зависимости от конструкции PPL): собственный Volatile-флаг, атомарный Boolean или концепция Cancellation через Tasks (в зависимости от версии и структуры Delphi).

Для эксплуатации важны два правила:

  • Сигнал отмены должен обнаруживаться быстро: проверяйте флаг прерывания в разумных местах, а не только в конце итерации, если итерация может занимать несколько секунд.
  • Сигнал отмены должен выполнять очистку: нельзя оставлять открытые дескрипторы, временные файлы, транзакции или блокировки. Это значит: в каждой итерации воркера обязательны блоки try/finally, если задействованы ресурсы.

С точки зрения UI Cancel должен лишь установить сигнал и перевести интерфейс в состояние «Stopping…». Фактическое завершение и восстановление UI происходят в событии Done, а не сразу по клику.

Как избежать Deadlocks: самые частые ловушки на практике

Diagramm eines zyklischen Wartens zwischen Threads als Visualisierung eines Deadlocks
Deadlocks часто возникают из-за циклического ожидания: UI-поток блокирован, воркеры ожидают доступа к UI.

Ловушка 1: WaitFor/Task.Wait в главном потоке

Если главный поток блокирован, он не может выполнять Queue-колбэки и обрабатывать сообщения. Это выглядит как deadlock, даже если воркеры корректно продолжают работу. Решение: никаких блокирующих ожиданий в UI‑потоке. Вместо этого завершение делать через TThread.Queue или через управление событиями (например, таймер проверяет «готово»).

Ловушка 2: Synchronize внутри удерживаемого лока

Классика: воркер удерживает Critical Section, затем вызывает Synchronize, а в UI‑колбэке (прямо или косвенно) снова требуется та же Critical Section. Результат: круг ожидания. Правило простое: никакой передачи управления UI (Synchronize/Queue) из‑под удерживаемого лока. Если лок необходим, сначала скопируйте все данные в локальные переменные, покиньте лок, и только затем поставьте задачу в очередь.

Ловушка 3: UI-колбэк вызывает реентрантность

Иногда само обновление UI не «безобидно»: присвоение свойств может вызвать события (OnChange, OnResize), которые в свою очередь запускают логику, обращающуюся к состояниям воркера. Это не deadlock в строгом смысле, но вызывает труднообъяснимые зависания и race conditions. Меры: перемещать обновления UI в «тихие» пути (временно отключать события) или использовать защиту от реентрантности (например, атомарный guard для фазы обновления).

Ловушка 4: слишком много обновлений в очереди

Даже без ожиданий UI может «встать», если вы генерируете десятки тысяч queued‑колбэков. Симптомы: ProgressBar долго «догоняет», окно реагирует вяло, загрузка CPU в главном потоке высокая. Решение: дросселировать (временное окно), агрегировать (счётчик) или использовать реальную структуру Producer/Consumer, при которой может быть только одно ожидающее UI‑обновление (Coalescing).

Если становится сложнее: собирать результаты, аккумулировать ошибки, обеспечивать порядок

TParallel.For идеален, когда итерации независимы. В бизнес‑ПО же итерации часто лишь «в значительной степени» независимы: они читают файлы, вызывают REST-APIs, записывают строки в базу данных. Тогда нужно аккуратно спланировать три дополнительных момента:

  • Потокобезопасный сбор результатов: либо локальный буфер для каждого потока (объединять в конце), либо потокобезопасная очередь/коллекция. Избегайте локов в hot path.
  • Обработка ошибок: исключения из рабочих потоков нужно собирать. На практике зарекомендовали себя два подхода: запомнить первое исключение и инициировать Cancel, либо собрать все исключения и в конце показать их сводно.
  • Порядок: если выводу требуется стабильный порядок (например, логи по индексу), параллельная обработка с последующей сортировкой часто проще, чем «потокобезопасная упорядоченная вставка».

Для UI это означает: не показывайте каждое сообщение об ошибке немедленно. Это приводит к аду модальных диалогов. Собирайте ошибки (например, список строк) и показывайте в конце сводку или экспортируемый лог.

Отладка: как действительно обнаружить взаимную блокировку

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

Используйте окно потоков и стеки вызовов

Если UI зависает, просмотрите все потоки: где находится главный поток (Main Thread)? Ожидает ли он? Находится ли он в цикле сообщений (Message-Loop)? Где находятся рабочие потоки? Если рабочие потоки застряли в Synchronize, причина почти всегда: «главный поток заблокирован» или «главному потоку нужен lock».

Помечайте места Queue/Synchronization

Разместите целенаправленное логирование в точках передачи (перед Queue, в Queue-Callback, в конце итерации). В рабочей системе это часто ценнее, чем точки останова, потому что важна синхронизация по времени. Убедитесь, что само логирование потокобезопасно и не блокирует (например, не выводите логи в UI напрямую из рабочих потоков).

Измеряйте время последнего обновления

Если UI «зависает», возможно, оно просто должно обработать слишком много обновлений. Измеряйте в главном потоке, как часто в секунду выполняется обновление UI и сколько это занимает времени. Как только UI-колбекам требуется более нескольких миллисекунд, необходимы дросселирование или упрощение.

Когда действительно имеет смысл использовать TParallel.For с прогресс-индикацией (Progress-UI)?

Параллелизация не самоцель. Она оправдана особенно когда:

  • итерации достаточно большие (миллисекунды до секунд), чтобы накладные расходы пула потоков были несущественны,
  • задача нагружает CPU (парсинг, компрессия, хеширование) или имеет хорошо параллелизуемый ввод/вывод (несколько файлов, несколько HTTP-запросов с ограничениями),
  • есть чёткая стратегия Cancel и обработки ошибок,
  • требования UI допускают агрегированный индикатор прогресса.

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

Практический чек-лист: как сохранить стабильность UI

  • Главный поток не блокируется: никаких Waits, никаких длинных циклов без обработки сообщений (Message Pump).
  • Рабочие потоки не обращаются к контролам: никаких обращений VCL/FMX вне UI-потока.
  • Обновления UI регулируются: счетчики/таймеры или объединённые (coalesced) обновления через Queue вместо обновления на каждую итерацию.
  • Никаких вызовов Synchronize изнутри захватов (Locks).
  • Cancel кооперативен, проверяется часто и корректно очищает ресурсы.
  • Исключения собираются и обрабатываются упорядоченно в конце.

Вывод: развяжите через Queue, стабилизируйте с помощью агрегации

Надёжная Progress-UI при TParallel.For не entsteht не durch „irgendwo Synchronize rein“, sondern durch ein klares Architekturprinzip: рабочие потоки работают независимо, главный поток остаётся свободным и обрабатывает только несколько быстрых обновлений интерфейса. TThread.Queue ist dabei das richtige Werkzeug, wenn Sie es gezielt und gedrosselt einsetzen. Für „zähes“ oder instabiles Verhalten sind fast immer zwei Ursachen verantwortlich: der Main Thread wartet irgendwo blockierend – oder er ertrinkt in zu vielen queued Updates.

Следующий шаг

Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.

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

  • Текущее состояние, целевое состояние и технические риски оцениваются совместно.
  • REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
  • Вы заранее видите, какой путь экономически и операционно жизнеспособен.

Поделиться записью

Поделиться этой записью напрямую

LinkedIn, X, XING, Facebook, WhatsApp und E-Mail sind sofort verfügbar. für Instagram bereiten wir Link und Kurztext direkt vor.

Электронная почта

Instagram открывается в новой вкладке. Ссылка и короткий текст предварительно копируются в буфер обмена.