1. Архитектура многопоточности в Free Pascal и жизненный цикл TThread
Архитектура многопоточности в Free Pascal и жизненный цикл TThread
Представьте, что ваше приложение для сбора данных с датчиков — это диспетчерская вышка аэропорта. Если диспетчер (основной поток GUI) решит лично выйти на взлетную полосу, чтобы измерить давление в шинах самолета (длительная операция чтения из COM-порта), работа всей вышки замрет. Пилоты не получат команд, экраны радаров перестанут обновляться, а пользователи увидят «Приложение не отвечает». В мире Free Pascal и Lazarus многопоточность — это не просто способ ускорить вычисления, а единственный метод сохранить отзывчивость интерфейса при интенсивном взаимодействии с внешним миром.
Фундамент: как ОС и Free Pascal видят потоки
В операционных системах семейства Windows и Linux поток (thread) является минимальной единицей планирования. Процессорное время распределяется между потоками, а не между процессами. Процесс — это лишь контейнер, владеющий адресным пространством, дескрипторами файлов и другими ресурсами. Все потоки внутри одного процесса разделяют эту память. Это критически важный момент: любой поток может прочитать или изменить глобальную переменную, созданную в другом потоке того же процесса.
Free Pascal Runtime Library (RTL) предоставляет кроссплатформенную обертку над системными вызовами CreateThread (Windows) или pthread_create (POSIX). Однако работа напрямую с API операционной системы в Pascal — путь тернистый и чреватый ошибками. Именно поэтому основным инструментом разработчика является класс TThread из модуля Classes.
Для корректной работы многопоточности в Free Pascal (FPC) необходимо учитывать важную техническую деталь. На Unix-подобных системах (Linux, macOS, FreeBSD) первым делом в секции uses файла проекта (.lpr) должен стоять модуль cthreads.
Если пропустить cthreads, попытка создать поток приведет к немедленному завершению программы с ошибкой времени выполнения. Этот модуль инициализирует менеджер памяти для работы в конкурентной среде, гарантируя, что выделение и освобождение памяти (например, при создании строк или динамических массивов) будет безопасным.
Анатомия класса TThread
Класс TThread является абстрактным. Это означает, что вы никогда не создаете экземпляр самого TThread, а всегда наследуетесь от него, переопределяя метод Execute. Именно в Execute сосредоточена логика, которая будет выполняться параллельно.
Рассмотрим внутреннее устройство жизненного цикла потока. Он состоит из четырех ключевых фаз:
Конструктор и состояние CreateSuspended
При вызове inherited Create(True) или inherited Create(False) в конструкторе вашего класса, вы определяете, запустится ли поток немедленно. Параметр CreateSuspended — это развилка. Если передать False, поток попытается начать выполнение Execute сразу после завершения конструктора.
Однако в профессиональной разработке систем сбора данных чаще используется True. Это позволяет настроить параметры потока до его фактического старта: задать приоритет (Priority), установить параметры подключения к COM-порту или инициализировать внутренние буферы.
Свойство FreeOnTerminate заслуживает особого внимания. Если оно установлено в True, вам не нужно вызывать .Free для объекта потока. Как только метод Execute завершится, объект будет уничтожен автоматически. Это удобно для фоновых задач «выполнил и забыл», но опасно, если основной поток планирует обращаться к свойствам этого объекта после его завершения.
Жизненный цикл и метод Execute
Метод Execute — это сердце потока. Как только операционная система выделяет квант времени, управление передается в начало этого метода. Важно понимать: когда выполнение доходит до конца Execute, поток считается завершенным.
В системах сбора данных поток чтения обычно представляет собой бесконечный цикл. Однако «бесконечный» — это фигура речи. Правильный цикл всегда должен проверять свойство Terminated.
Механизм остановки
Никогда не используйте системные функции принудительного уничтожения потоков (типа TerminateThread в Windows). Это «ядерный удар» по приложению: поток мгновенно замирает, не успев закрыть файлы, освободить критические секции или вернуть память в кучу. Результат — утечки ресурсов и непредсказуемые зависания всей программы.
Корректный путь — метод Terminate. Он не останавливает поток мгновенно, а лишь устанавливает флаг Terminated := True. Поток должен сам проверить этот флаг и выйти из цикла Execute.
Приоритеты и планировщик
В Free Pascal доступно свойство Priority. Оно варьируется от tpIdle (самый низкий) до tpTimeCritical. Для потока чтения данных из UART часто велик соблазн поставить tpTimeCritical, чтобы не пропустить ни одного байта.
Однако стоит помнить о «голодании» (starvation). Если поток с высоким приоритетом будет непрерывно загружать ядро процессора, основной поток GUI может не получить времени даже на отрисовку курсора мыши. Для большинства задач сбора данных на скоростях до 115200 бод стандартного приоритета tpNormal или чуть повышенного tpHigher более чем достаточно, при условии правильного использования блокирующего чтения.
Взаимодействие с памятью: ловушки для новичков
Самая большая архитектурная проблема многопоточности — это разделяемые ресурсы (Shared Resources). Представьте, что у вас есть глобальный массив DataBuffer. Поток чтения записывает в него новые значения, а поток GUI в это же время пытается их прочитать, чтобы построить график.
В какой-то момент может возникнуть «состояние гонки» (race condition). Например, поток GUI считывает количество элементов, начинает цикл отрисовки, но в этот момент поток чтения очищает массив. Программа обращается к несуществующему индексу и падает с External: SIGSEGV.
Чтобы этого избежать, используются механизмы синхронизации. В рамках архитектуры TThread у нас есть три основных пути:
Synchronize, фоновый поток стоит и ждет. Это безопасно, но медленно. Если вы вызываете Synchronize слишком часто (например, на каждый пришедший байт), многопоточность теряет смысл — фоновый поток будет постоянно блокироваться, ожидая, пока GUI соизволит обработать данные.Synchronize, но фоновый поток не ждет завершения. Он «бросает» запрос в очередь основного потока и идет работать дальше.Модель «Производитель — Потребитель»
В контексте сбора данных с COM-порта идеальная архитектура строится на паттерне Producer-Consumer.
Такое разделение ответственности гарантирует, что даже если GUI на секунду «задумается» (например, при изменении размера окна), данные из порта не будут потеряны, так как поток чтения продолжает работать и наполнять очередь.
Особенности работы менеджера памяти
Free Pascal использует свой менеджер памяти. При работе с потоками важно понимать, как ведут себя динамические типы данных: string, UnicodeString, динамические массивы. FPC использует подсчет ссылок для строк. Когда вы передаете строку из одного потока в другой, счетчик ссылок должен инкрементироваться атомарно.
Именно здесь кроется причина обязательности cthreads в Linux. Без него операции со строками в многопоточной среде приведут к порче памяти, так как инкремент счетчика будет выполняться не атомарно, и два потока могут одновременно решить, что строку пора удалять.
Исключения в потоках
Что произойдет, если внутри Execute возникнет ошибка (например, деление на ноль или ошибка доступа к порту)? Если исключение не перехвачено внутри Execute блоком try..except, поток просто молча завершится. Основная программа даже не узнает, что сбор данных прекратился.
Поэтому хорошим тоном считается оборачивать все содержимое Execute в глобальный try..except.
Жизненный цикл: правильное завершение
Когда пользователь закрывает программу, мы должны корректно остановить все потоки. Простое закрытие формы не убивает фоновые потоки автоматически. Если поток чтения заблокирован на ожидании данных из COM-порта, он может «повиснуть», не давая процессу завершиться.
Правильный алгоритм выхода:
MyThread.Terminate.Read из библиотеки Synaser), нужно закрыть дескриптор порта из основного потока. Это вызовет немедленную ошибку чтения в фоновом потоке, он выйдет из блокировки, увидит флаг Terminated и завершит Execute.MyThread.WaitFor. Этот метод заставляет основной поток ждать, пока фоновый поток действительно закончит работу.Использование WaitFor критично, если FreeOnTerminate = False. Если же FreeOnTerminate = True, вызывать WaitFor небезопасно, так как объект может быть уже уничтожен к моменту вызова. В таких случаях используют механизмы событий (RTLEvent) или просто полагаются на то, что поток завершится сам до полного закрытия приложения.
Проблема «засыпания» потока
Часто разработчики используют Sleep(10) внутри цикла чтения, чтобы снизить нагрузку на процессор. Однако Sleep — грубый инструмент. В Windows минимальный квант Sleep обычно составляет около 15.6 мс. Если вам нужно опрашивать датчик с частотой 100 Гц, Sleep(10) может превратиться в Sleep(16), что нарушит тайминги.
В продвинутых системах сбора данных вместо Sleep используются события синхронизации или блокирующие вызовы API. Например, функция чтения из порта может ждать появления хотя бы одного байта в течение 100 мс. Если данные пришли — поток просыпается мгновенно. Если нет — он «спит» на уровне ядра ОС, не потребляя циклы процессора.
Резюмируя архитектурный подход
Создание многопоточного приложения в Lazarus — это всегда баланс между безопасностью и производительностью. TThread предоставляет мощный каркас, но он требует дисциплины.
Terminate.Многопоточность — это не магия, делающая код быстрее. Это инструмент управления временем и ресурсами. В следующей главе мы перейдем от теории жизненного цикла к практике: как именно открыть последовательный порт так, чтобы поток чтения мог эффективно с ним взаимодействовать, не блокируя всю систему. Мы разберем библиотеки LazSerial и Synaser, которые станут нашими «глазами и ушами» в мире внешних устройств.