Разработка безопасных многопоточных систем сбора данных в Lazarus/Free Pascal

Курс посвящен созданию отказоустойчивых приложений для работы с COM-портами. Охватываются все этапы: от низкоуровневого чтения данных в TThread до проектирования потокобезопасных буферов и оптимизации GUI-интерфейса.

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 сосредоточена логика, которая будет выполняться параллельно.

Рассмотрим внутреннее устройство жизненного цикла потока. Он состоит из четырех ключевых фаз:

  • Инстанцирование (Constructor): Выделение памяти под объект в контексте создающего потока.
  • Запуск (Start/Resume): Сигнал операционной системе о готовности потока к выполнению.
  • Выполнение (Execute): Работа полезной нагрузки.
  • Терминация (Destroy): Очистка ресурсов и удаление объекта.
  • Конструктор и состояние 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, фоновый поток стоит и ждет. Это безопасно, но медленно. Если вы вызываете Synchronize слишком часто (например, на каждый пришедший байт), многопоточность теряет смысл — фоновый поток будет постоянно блокироваться, ожидая, пока GUI соизволит обработать данные.
  • Queue: Похож на Synchronize, но фоновый поток не ждет завершения. Он «бросает» запрос в очередь основного потока и идет работать дальше.
  • Критические секции (TCriticalSection): Объекты, которые позволяют «захватить» ресурс. Если один поток вошел в критическую секцию, другой будет ждать на входе, пока первый её не покинет. Это основа построения потокобезопасных буферов.
  • Модель «Производитель — Потребитель»

    В контексте сбора данных с COM-порта идеальная архитектура строится на паттерне Producer-Consumer.

  • 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 предоставляет мощный каркас, но он требует дисциплины.

  • Поток должен быть автономен. Чем меньше он знает о формах и компонентах GUI, тем лучше.
  • Данные должны передаваться через защищенные буферы.
  • Жизненный цикл должен контролироваться явно: от корректной инициализации в приостановленном состоянии до вежливой просьбы завершиться через Terminate.
  • Многопоточность — это не магия, делающая код быстрее. Это инструмент управления временем и ресурсами. В следующей главе мы перейдем от теории жизненного цикла к практике: как именно открыть последовательный порт так, чтобы поток чтения мог эффективно с ним взаимодействовать, не блокируя всю систему. Мы разберем библиотеки LazSerial и Synaser, которые станут нашими «глазами и ушами» в мире внешних устройств.

    2. Работа с последовательными портами в Lazarus: использование LazSerial и Synaser

    Работа с последовательными портами в Lazarus: использование LazSerial и Synaser

    Почему при написании простого терминала для COM-порта данные иногда приходят «рваными» кусками, а приложение намертво зависает при попытке закрыть порт, если устройство было внезапно отключено? Ответ кроется в фундаментальном различии между событийной моделью GUI и блокирующим вводом-выводом последовательных интерфейсов. Работа с UART (Universal Asynchronous Receiver-Transmitter) — это всегда баланс между скоростью реакции системы и стабильностью потока данных. В экосистеме Lazarus и Free Pascal доминируют два подхода к решению этой задачи: визуальный компонентный подход (LazSerial) и низкоуровневый процедурный подход (библиотека Synaser).

    Анатомия последовательного интерфейса в современных ОС

    Прежде чем переходить к коду, необходимо осознать, что современная операционная система (Windows или Linux) изолирует программиста от прямого управления регистрами микросхемы UART. Мы работаем с системными драйверами, которые предоставляют абстракцию в виде файлового дескриптора (в Unix) или хэндла (в Windows).

    Когда данные поступают на физический вход RX, они сначала попадают в аппаратный FIFO-буфер чипа, затем драйвер переносит их в системный буфер в оперативной памяти. Наша задача — вовремя забрать эти данные из системного буфера. Если мы будем делать это слишком медленно, буфер переполнится и данные будут потеряны (Buffer Overrun). Если мы будем опрашивать порт слишком часто в основном потоке GUI, интерфейс станет «дерганым», так как процессорное время будет тратиться на пустые циклы ожидания.

    Параметры соединения: не только BaudRate

    Настройка порта — это первый этап, где совершается большинство ошибок. Стандартный набор параметров (8N1) включает:

  • Baud Rate: Скорость передачи бит в секунду. Популярные значения: 9600, 115200, 921600.
  • Data Bits: Обычно 8, но может быть 7 или 5 для старых протоколов.
  • Parity: Контроль четности. Чаще всего None.
  • Stop Bits: 1 или 2.
  • Однако критически важным для многопоточности является параметр Timeout. В блокирующем режиме функция чтения будет ждать поступления данных указанное время. Если установить бесконечный таймаут, поток «уснет» навсегда, если устройство ничего не пришлет. Если установить нулевой таймаут, мы получим режим опроса (polling), который нещадно нагружает CPU.

    Библиотека Synaser: Мощь блокирующего ввода-вывода

    Библиотека synaser, входящая в состав пакета Ararat Synapse, является де-факто стандартом для профессиональной разработки на Pascal, когда требуется полный контроль над процессом. Она реализует синхронную (блокирующую) модель работы.

    Почему блокировка — это хорошо?

    На первый взгляд кажется, что блокирующие функции — это зло, так как они останавливают выполнение программы. Но в контексте многопоточности это идеальное поведение. Когда мы выносим работу с портом в отдельный поток TThread, блокировка функции ReadByte или RecvBufferStr означает, что поток не потребляет ресурсы процессора, пока в буфере порта нет данных. Операционная система сама «разбудит» наш поток, когда придут байты.

    Основные методы TBlockSerial

    Класс TBlockSerial инкапсулирует в себе все низкоуровневые вызовы API. Рассмотрим ключевые методы:

  • Connect: Устанавливает связь с портом. В Windows это строки вида COM1, в Linux — /dev/ttyS0 или /dev/ttyUSB0.
  • Config: Применяет настройки скорости и битов. Важно вызывать этот метод после успешного Connect.
  • SendString / SendByte: Отправка данных. Эти методы также блокирующие: они не вернут управление, пока данные не будут переданы в системный буфер отправки.
  • RecvPacket: Ожидание пакета данных с заданным таймаутом.
  • CanRead: Проверка наличия данных в буфере без блокировки.
  • Пример инициализации порта через Synaser:

    Нюанс с LastError

    В отличие от многих библиотек Lazarus, Synaser редко генерирует исключения (Exceptions). Вместо этого после каждой операции необходимо проверять свойство ser.LastError. Это критично для многопоточных систем: если устройство отключили (выдернули USB-кабель), LastError вернет ненулевое значение, и это будет сигналом для потока корректно завершить работу.

    LazSerial: Визуальный подход и событийная модель

    LazSerial — это обертка над Synaser, которая превращает библиотеку в визуальный компонент. Он удобен для быстрого прототипирования и простых утилит, где не требуется сложная логика обработки в фоновых потоках.

    Механизм OnRxData

    Главная особенность LazSerial — событие OnRxData. Оно срабатывает автоматически, когда в порт приходят данные. Внутри компонента работает скрытый поток-монитор, который следит за состоянием буфера и через Synchronize вызывает ваше событие в основном потоке.

    Плюсы LazSerial:

  • Простота: бросил на форму, настроил в инспекторе объектов, написал обработчик.
  • Автоматическое управление жизненным циклом порта.
  • Встроенная поддержка визуализации статуса (DTR, RTS).
  • Минусы для высоконагруженных систем:

  • Событие OnRxData выполняется в главном потоке. Если обработка данных (например, парсинг сложного JSON или запись в БД) занимает много времени, интерфейс будет «замерзать».
  • Ограниченный контроль над моментом чтения. Вы зависите от того, как часто внутренний поток компонента проверяет буфер.
  • Кроссплатформенность: Windows vs Linux

    Free Pascal славится своей переносимостью, но последовательные порты — это область, где различия ОС проявляются наиболее ярко.

    Именование портов

    В Windows порты именуются COM1, COM2 и так далее. Если номер порта больше 9, системное API требует префикса \\.\COM10. Synaser обрабатывает это автоматически, но об этом стоит помнить при ручной отладке.

    В Linux всё представлено в виде файлов в директории /dev/:

  • /dev/ttyS0 — стандартные аппаратные порты.
  • /dev/ttyUSB0 — USB-UART переходники (FTDI, CP2102).
  • /dev/ttyACM0 — устройства класса CDC (многие платы Arduino).
  • Права доступа в Linux

    Типичная проблема: программа на Lazarus запускается, но не может открыть порт в Linux. Это происходит потому, что текущий пользователь не входит в группу, имеющую доступ к последовательным устройствам (обычно это группы dialout или uucp). Решение: sudo usermod -a -G dialout 00 или $FF). Ваша логика парсинга должна быть готова к тому, что первый пакет данных может быть поврежден.

    Интеграция с архитектурой TThread

    Как было сказано в предыдущей статье, TThread предоставляет метод Execute. При работе с последовательным портом этот метод превращается в бесконечный цикл обслуживания порта.

    Важно понимать, где создавать объект порта. Если вы создадите TBlockSerial в основном потоке и передадите его в фоновый, вы можете столкнуться с проблемами владения ресурсами в некоторых ОС. Рекомендуемая практика: передавать в конструктор потока только параметры (имя порта, скорость), а сам объект TBlockSerial создавать и уничтожать внутри метода Execute.

    Использование CanRead(100) перед чтением позволяет потоку «засыпать» на 100 мс, если данных нет, и при этом быстро реагировать на флаг Terminated. Это гораздо эффективнее, чем Sleep(100), так как CanRead прервется немедленно при поступлении хотя бы одного байта.

    Диагностика и отладка

    Отладка многопоточного взаимодействия с железом осложняется тем, что точки останова (breakpoints) нарушают временные тайминги протокола. Пока вы стоите на паузе в отладчике, буфер порта переполняется, и после продолжения программа ведет себя неадекватно.

    Инструменты для отладки:

  • Мониторы портов: В Windows это утилиты типа Serial Port Monitor, в Linux — strace для перехвата системных вызовов чтения/записи.
  • Виртуальные порты: Утилиты типа com0com позволяют создать два виртуальных порта, соединенных «нуль-модемным» кабелем. Вы можете подключить свою программу к COM10, а терминал (например, PuTTY) к COM11 и имитировать ответы устройства.
  • Логирование: Запись всех входящих байтов в текстовый файл с метками времени (timestamp) в миллисекундах. Это единственный надежный способ понять, что произошло в динамике.
  • Выбор между Synaser и LazSerial

    Подводя итог сравнению:

  • Выбирайте Synaser, если вы строите профессиональную систему сбора данных, где чтение должно происходить в отдельном потоке, независимо от состояния GUI. Это даст вам максимальную стабильность и предсказуемость.
  • Выбирайте LazSerial, если вам нужно быстро набросать сервисную утилиту для настройки оборудования, где объем данных невелик, а скорость разработки важнее архитектурной чистоты.
  • В следующей главе мы детально спроектируем структуру фонового потока, который будет использовать Synaser для непрерывного сбора данных, и научимся правильно обрабатывать ситуации внезапного обрыва связи, не допуская утечек ресурсов и зависаний приложения. Мы объединим знания о жизненном цикле TThread с практическими методами блокирующего чтения, чтобы создать фундамент для надежной системы.

    3. Проектирование фонового потока для непрерывного чтения данных из UART

    Проектирование фонового потока для непрерывного чтения данных из UART

    Представьте, что ваше приложение — это диспетчер аэропорта. Если диспетчер отвлечется на заполнение бумажного отчета (отрисовку интерфейса или расчеты), он пропустит сигнал от пилота. В мире микроконтроллеров и датчиков «сигналы» приходят по UART непрерывно. Стоит основному потоку программы (GUI) на секунду задуматься над изменением размера окна или сложной отрисовкой графика, и входной буфер операционной системы переполнится, а данные будут безвозвратно потеряны. Единственный способ избежать этого — создать выделенного «дежурного», фоновый поток, чья единственная задача — слушать порт и немедленно забирать данные.

    Анатомия рабочего цикла потока чтения

    Проектирование потока для работы с последовательным портом существенно отличается от написания обычных вычислительных процедур. Здесь мы сталкиваемся с неопределенностью: мы не знаем точно, когда придут данные, какого объема они будут и не прервется ли связь физически.

    Основная логика потока сосредоточена в методе Execute. Однако типичная ошибка новичка — создать бесконечный цикл, который «пожирает» 100% ресурсов процессора, постоянно опрашивая порт. Правильный поток должен быть «терпеливым»: он должен проводить большую часть времени в состоянии ожидания, потребляя около 0% ресурсов CPU, и мгновенно просыпаться при появлении байтов в буфере.

    Для реализации такого поведения мы используем блокирующее чтение с таймаутом. Рассмотрим структуру метода Execute, использующую библиотеку Synaser (TBlockSerial), так как она идеально подходит для синхронной логики внутри потока:

    В этой структуре ключевым является метод CanRead(100). Он переводит поток в состояние ожидания на уровне ядра ОС. Если данные придут через 5 мс, поток проснется немедленно. Если данных нет, он проснется через 100 мс, проверит флаг Terminated и снова уйдет в ожидание. Это обеспечивает реактивность системы при минимальных энергозатратах.

    Стратегии накопления и первичной обработки

    Данные из UART редко приходят «красивыми» законченными пакетами. Из-за особенностей работы драйверов и USB-UART преобразователей (таких как FTDI или CH340), пакет в 100 байт может быть разбит на три фрагмента: 20, 50 и 30 байт. Поток чтения не должен пытаться интерпретировать эти данные на лету, если это замедляет цикл чтения.

    Существует три основных подхода к обработке входящего потока:

  • Побайтовый (Конечный автомат): Поток читает данные по одному байту и ищет маркеры начала и конца пакета. Это надежно, но создает высокую нагрузку на контекстные переключения, если данных много.
  • Блочный (Сырой поток): Поток просто забирает всё, что есть в буфере, и складывает в промежуточное хранилище. Разбором занимается другой механизм.
  • Гибридный: Поток ищет границы пакетов в считанном блоке и передает в GUI только валидные структуры.
  • Для систем сбора данных наиболее предпочтителен второй вариант с использованием промежуточного хранилища. Мы разделяем ответственность: поток чтения (Producer) только «черпает» данные из порта, а логика приложения (Consumer) их «переваривает».

    Проблема «замусоривания» при старте

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

    В Synaser это делается вызовом FSerial.Purge. Без этого ваш парсер может «сойти с ума», пытаясь найти смысл в случайном наборе байтов, оставшемся от прошлого запуска программы.

    Жизненный цикл и безопасное завершение

    Одной из самых сложных задач в многопоточном UART-клиенте является корректное закрытие порта. Если основной поток вызывает Free для объекта потока, а фоновый поток в этот момент заблокирован внутри функции чтения (например, ждет данные), может возникнуть ситуация «зависания» или Access Violation.

    Механизм WaitFor — ваш главный союзник. При закрытии приложения последовательность должна быть строго следующей:

  • Установить Terminated := True.
  • Если используется блокирующее чтение с бесконечным таймаутом (что не рекомендуется), необходимо принудительно закрыть порт из основного потока, чтобы вызвать ошибку чтения в фоновом и «вытолкнуть» его из блокировки.
  • Вызвать MyThread.WaitFor.
  • Только после этого уничтожить объект потока.
  • Рассмотрим нюанс с таймаутами. Если вы установите таймаут в CanRead равным 5000 мс, то при закрытии программы пользователь будет наблюдать «зависание» интерфейса на 5 секунд, пока поток не проснется и не увидит флаг завершения. Оптимальное значение таймаута — от 50 до 200 мс. Это незаметно для глаза и достаточно для быстрой реакции на закрытие.

    Взаимодействие с системным менеджером памяти

    Free Pascal использует собственный менеджер памяти, который по умолчанию не является потокобезопасным на некоторых платформах без подключения специальных модулей. Как уже упоминалось в курсе, cthreads обязателен для Unix. Но есть и другой аспект: динамические массивы и строки.

    Если ваш поток чтения создает строку из полученных байт и передает её в GUI:

    Вы должны быть уверены, что менеджер памяти корректно обрабатывает счетчик ссылок этой строки при передаче между потоками. В FPC строки (AnsiString) потокобезопасны в плане подсчета ссылок, но само выделение памяти в куче (heap) может стать узким местом при очень интенсивном обмене. Для высокоскоростных систем (свыше 921600 бод) рекомендуется использовать заранее выделенные статические буферы или пулы объектов.

    Синхронизация: первый рубеж

    Хотя детальный разбор критических секций будет в следующей главе, при проектировании потока чтения мы уже сейчас должны заложить «точки соприкосновения». Главный вопрос: где будут лежать данные в те миллисекунды, когда они уже ушли из порта, но еще не дошли до экрана?

    Типичная архитектура «Поток-Буфер-Интерфейс» предполагает наличие общего ресурса. Поток чтения не должен напрямую писать в свойства компонентов (например, Memo1.Lines.Add). Это приведет к немедленному краху приложения из-за конфликта с графической подсистемой (LCL не потокобезопасна).

    Использование Synchronize — самый простой, но и самый опасный путь. Когда поток вызывает Synchronize, он полностью останавливается и ждет, пока основной поток найдет время выполнить процедуру обновления. Если UART засыпает нас данными со скоростью 1000 пакетов в секунду, а GUI может отрисовать только 60 кадров в секунду, Synchronize превратит ваш скоростной поток чтения в медленную улитку. Буфер порта переполнится, и данные пропадут.

    Альтернатива: Метод Queue. В отличие от Synchronize, метод Queue не заставляет фоновый поток ждать. Он просто «бросает» задачу в очередь основного потока и продолжает чтение.

    При использовании Synchronize время может быть огромным. При использовании Queue оно стремится к нулю с точки зрения фонового потока.

    Обработка разрывов соединения и ошибок

    Реальный мир жесток: кабель могут выдернуть, устройство может перезагрузиться, или электромагнитная помеха может исказить данные так, что драйвер порта вернет ошибку. Хорошо спроектированный поток должен уметь восстанавливаться.

    Внутри цикла while not Terminated обязательно должна быть проверка состояния порта. В Synaser это проверяется через LastError после каждой операции.

    Важный нюанс: если устройство отключено физически (например, выдернут USB-RS232 адаптер), метод Connect будет возвращать ошибку. Поток не должен входить в «яростный цикл» переподключений, который нагрузит процессор. Используйте Sleep в случае ошибок, чтобы дать системе передохнуть.

    Проектирование для кроссплатформенности

    Lazarus позволяет писать код для Windows и Linux одновременно, но UART ведет себя по-разному. * В Windows порты имеют имена COM1, COM2. Для портов выше COM9 нужно использовать префикс \\.\COM10. Библиотека Synaser обычно обрабатывает это сама, но об этом стоит помнить. В Linux устройства называются /dev/ttyS или /dev/ttyUSB*. Кроме того, в Linux критически важны права доступа. Если ваша программа не может открыть порт, скорее всего, пользователя нужно добавить в группу dialout.

    При проектировании потока вынесите настройки порта в отдельный защищенный рекорд (record) или класс-конфигуратор. Это позволит легко менять параметры без переписывания логики Execute.

    Передавайте этот рекорд в конструктор потока. Это обеспечит инкапсуляцию и сделает ваш код чище.

    Буферизация: Кольцевой буфер против очереди объектов

    Когда данные считаны, их нужно где-то хранить до востребования. Если мы просто будем передавать каждый байт через Synchronize, система захлебнется. Нам нужен промежуточный склад.

    Два основных подхода:

  • Байтовый кольцевой буфер (Ring Buffer): Массив фиксированного размера с двумя указателями (голова и хвост). Идеален для побайтового потока данных. Поток чтения пишет в «голову», GUI читает из «хвоста».
  • Очередь пакетов (TThreadedQueue): Если данные уже разбиты на логические пакеты (например, строки или структуры), удобнее использовать TThreadedQueue из модуля Generics.Collections. Она автоматически обрабатывает блокировки и ожидание.
  • Использование кольцевого буфера требует осторожности при работе с индексами. Если «голова» догонит «хвост», произойдет переполнение (Overflow). Вы должны заранее решить: затирать старые данные или игнорировать новые. Для систем мониторинга обычно важнее свежие данные, поэтому затирание старых — допустимая стратегия.

    Пример логики «продюсера» данных

    Рассмотрим более детально, как поток должен складывать данные в защищенный буфер. Представим, что у нас есть глобальный объект DataStorage, защищенный критической секцией.

    Здесь мы видим разделение: блокировка (критическая секция) длится микросекунды — ровно столько, сколько нужно для Move в памяти. Весь остальной цикл потока свободен для работы с портом. Это и есть залог высокой производительности многопоточной системы.

    Тонкая настройка: Приоритеты потоков

    По умолчанию все потоки имеют приоритет tpNormal. Для потока чтения UART иногда имеет смысл поднять приоритет до tpHigher. Это заставит планировщик ОС выделять потоку кванты времени чаще.

    Однако будьте осторожны: если поток с высоким приоритетом войдет в плотный бесконечный цикл без пауз (Sleep или блокирующее чтение), он может «заморозить» основной интерфейс, так как у GUI просто не останется процессорного времени на обработку сообщений Windows/X11.

    > Золотое правило приоритетов: повышайте приоритет только тогда, когда вы уверены, что поток большую часть времени спит в ожидании события.

    Граничные случаи: Высокая нагрузка и «залипание»

    При скоростях выше 115200 бод объем данных может достигать нескольких мегабайт в минуту. В таких условиях стандартные компоненты Lazarus (например, TMemo) начинают сильно тормозить при добавлении текста.

    Если ваша задача — создать терминал, не пытайтесь выводить каждый пришедший байт немедленно. Накапливайте данные в потоке чтения и вызывайте обновление GUI не чаще 20-30 раз в секунду (каждые 33-50 мс). Человеческий глаз не заметит разницы, а нагрузка на систему упадет в десятки раз.

    Для этого в потоке можно использовать таймер или проверку времени:

    Итог проектирования

    Фоновый поток для UART — это не просто цикл вокруг функции Read. Это автономный механизм, который должен:

  • Корректно инициализировать и освобождать ресурсы.
  • Эффективно ожидать данные, не нагружая CPU.
  • Обрабатывать ошибки связи и уметь восстанавливаться.
  • Минимально блокировать общие ресурсы при передаче данных.
  • Быстро реагировать на команду завершения работы.
  • Правильно спроектированный поток делает приложение отзывчивым. Пользователь может двигать окно, открывать меню и строить графики, в то время как в «подвале» программы невидимый рабочий неустанно собирает каждый бит информации, приходящий из внешнего мира. Это разделение обязанностей — фундамент профессионального ПО для промышленной автоматизации и научных исследований.

    4. Критические секции и механизмы защиты разделяемых ресурсов памяти

    Критические секции и механизмы защиты разделяемых ресурсов памяти

    Представьте, что два человека одновременно пытаются записать разные числа в одну и ту же клетку бумажного блокнота. Один пишет «100», другой — «200». В итоге на бумаге может оказаться неразборчивое пятно или, что еще хуже, число «100200», если они писали цифры по очереди, но не договорились о границах. В многопоточном программировании на Free Pascal происходит то же самое: когда поток чтения из UART записывает данные в буфер, а поток GUI в это же время пытается их отобразить, возникает состояние гонки (race condition). Результатом становится не просто «мусор» на экране, а непредсказуемые аварийные завершения программы (Access Violation), которые крайне сложно отловить при отладке.

    Анатомия состояния гонки в системах сбора данных

    Состояние гонки возникает в тот момент, когда результат выполнения программы зависит от непредсказуемого порядка чередования операций в разных потоках. В контексте работы с COM-портом типичный сценарий выглядит так:

  • Поток чтения (Producer) получает пакет из 64 байт. Он начинает записывать эти байты в глобальный массив-буфер.
  • Планировщик ОС прерывает поток чтения на середине операции (например, записано 32 байта).
  • Основной поток (Consumer/GUI) просыпается и начинает считывать данные из этого же буфера для отрисовки графика.
  • Результат: График отображает «половинчатые» данные — смесь старого пакета и части нового. Если же в буфере хранились указатели или сложные структуры, обращение к недописанному объекту приведет к немедленному краху приложения.
  • Проблема усугубляется тем, что современные процессоры и компиляторы оптимизируют код, меняя порядок инструкций, а кэш-память ядер процессора может содержать разные копии одной и той же переменной. Без явных команд синхронизации один поток может просто «не заметить», что другой уже изменил данные.

    Критическая секция как базовый инструмент защиты

    Самым простым и эффективным способом предотвращения гонки данных в экосистеме Lazarus является критическая секция. В Free Pascal она представлена классом TCriticalSection из модуля SyncObjs.

    Критическая секция работает по принципу «ключ-замок». Это легковесный объект синхронизации, который позволяет только одному потоку в конкретный момент времени находиться внутри защищенного участка кода.

    Механизм работы TCriticalSection

    Когда поток вызывает метод Enter, происходит следующее: * Если секция свободна, поток «захватывает» её и продолжает выполнение. * Если секция уже занята другим потоком, текущий поток приостанавливается операционной системой. Он не потребляет ресурсы CPU, находясь в состоянии ожидания (блокировки). * Как только первый поток вызывает Leave, ОС «будит» один из ожидающих потоков и передает право владения ему.

    Важно понимать разницу в производительности. В отличие от мьютексов (Mutex), которые являются объектами ядра ОС и требуют дорогостоящего переключения контекста в режим ядра, критические секции в Windows и Linux оптимизированы для работы в пользовательском пространстве (User Mode), пока нет реальной конкуренции за ресурс. Это делает их идеальным выбором для защиты быстрых операций, таких как добавление байта в очередь или обновление переменной-счетчика.

    Реализация защиты буфера данных

    Рассмотрим практический пример. У нас есть класс потока, который аккумулирует данные из UART в динамический массив. Чтобы основной поток мог безопасно забрать эти данные, нам нужно защитить доступ к массиву.

    В этом примере блок try...finally является критически важным. Если внутри защищенного участка произойдет исключение (например, нехватка памяти при изменении размера массива) и мы не вызовем Leave, критическая секция останется заблокированной навсегда. Это приведет к «зависанию» всего приложения (Deadlock), так как любой другой поток, пытающийся вызвать Enter, будет ждать вечно.

    Атомарные операции: когда критические секции избыточны

    Иногда использование TCriticalSection кажется «стрельбой из пушки по воробьям». Если ваша задача — просто инкрементировать счетчик принятых пакетов или обновить логический флаг, можно использовать атомарные функции.

    Атомарная операция — это действие, которое выполняется процессором как единое целое, неделимо. Никакое прерывание или другой поток не могут вклиниться в середину этой операции. В Free Pascal для этого предназначен модуль Interlocked.

    Основные функции: * InterlockedIncrement(var Target: LongInt) — безопасно увеличивает значение на 1. * InterlockedExchange(var Target: LongInt; Value: LongInt) — атомарно записывает новое значение и возвращает старое. * InterlockedCompareExchange — записывает значение только в том случае, если текущее значение равно ожидаемому (база для алгоритмов Lock-free).

    Пример использования для счетчика ошибок:

    Здесь чтение FTotalErrors в GUI (если это 32-битное целое число на 32/64-битной системе) обычно атомарно само по себе, но использование Interlocked функций гарантирует корректность записи из фонового потока без накладных расходов на создание объектов синхронизации.

    Проблема инверсии приоритетов и взаимных блокировок

    При проектировании систем сбора данных с использованием критических секций разработчик неизбежно сталкивается с двумя ловушками: Deadlock (взаимная блокировка) и Priority Inversion (инверсия приоритетов).

    Взаимная блокировка (Deadlock)

    Это ситуация, когда Поток А удерживает Секцию 1 и ждет Секцию 2, а Поток Б удерживает Секцию 2 и ждет Секцию 1. Оба потока замирают навсегда.

    В системах UART это часто случается при попытке сделать «слишком сложную» архитектуру. Например:

  • Поток чтения захватывает буфер и пытается вызвать Synchronize для обновления формы.
  • Основной поток (GUI) в это время обрабатывает нажатие кнопки «Стоп», захватывает объект порта и пытается остановить поток чтения, ожидая доступа к тому же буферу.
  • Золотое правило: Всегда захватывайте несколько критических секций в одном и том же порядке во всех потоках. Если Поток А берет сначала Lock1, а потом Lock2, то и Поток Б должен брать их именно в такой последовательности.

    Инверсия приоритетов

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

    Для минимизации этого эффекта в Lazarus следует:

  • Держать критические секции открытыми как можно меньше времени.
  • Внутри секции выполнять только операции с памятью (копирование, перемещение указателей).
  • Никогда не выполнять внутри Enter/Leave операции ввода-вывода (запись в файл, отправку по сети) или вызовы Synchronize.
  • Мьютексы против Критических секций

    Часто возникает вопрос: когда использовать TMutex, а когда TCriticalSection?

    | Характеристика | Критическая секция | Мьютекс (TMutex) | | :--- | :--- | :--- | | Область действия | Только внутри одного процесса | Между разными процессами в ОС | | Скорость | Очень высокая (User Mode) | Медленнее (Kernel Mode) | | Владение | Поток-владелец может входить повторно (рекурсивно) | Поток-владелец может входить повторно | | Именованность | Нет | Может иметь имя (для поиска из других программ) |

    Для задач сбора данных внутри одного приложения Lazarus критические секции предпочтительнее в 99% случаев. Мьютексы стоит использовать только если у вас есть две разные программы (например, сервис сбора данных и отдельный конфигуратор), которые должны по очереди обращаться к одному и тому же COM-порту или файлу памяти.

    Реализация защищенного «двойного буфера»

    Один из самых эффективных паттернов для UART-систем — использование двойной буферизации. Поток чтения пишет в «активный» буфер. Когда основной поток готов забрать данные, он просто меняет указатели местами под защитой критической секции. Это сводит время блокировки к наносекундам.

    Рассмотрим концептуальную схему:

    Где — время нахождения в критической секции. Поскольку мы не копируем весь массив данных внутри секции, а только меняем адреса двух указателей, риск заблокировать GUI-поток практически исчезает.

    Этот подход позволяет потоку чтения работать максимально свободно. GUI-поток вызывает SwapAndGet по таймеру (например, 20 раз в секунду), забирает накопленный массив и спокойно обрабатывает его (парсит пакеты, рисует графики) уже вне критической секции.

    Тонкие нюансы: многопроцессорные системы и кэш-линии

    При работе на современных многоядерных процессорах (Intel Core, AMD Ryzen) возникает эффект, называемый False Sharing (ложное разделение). Если две разные переменные, защищенные разными критическими секциями, случайно окажутся в одной кэш-линии процессора (обычно 64 байта), производительность может упасть. Процессор будет заставлять ядра синхронизировать кэш, даже если потоки работают с абсолютно разными данными.

    В Lazarus при создании высоконагруженных систем сбора данных (например, чтение 10-20 портов одновременно в разных потоках) рекомендуется разносить объекты TCriticalSection и сами данные в памяти так, чтобы они не «соседствовали» слишком близко. Это достигается либо использованием выравнивания памяти, либо простым добавлением «пустых» полей (padding) в классы.

    Мониторинг блокировок

    Как понять, что ваше приложение тратит слишком много времени на ожидание критических секций? В среде Lazarus нет встроенного профайлера потоков «из коробки», но можно использовать простую обертку для отладки:

    Если вы видите такие предупреждения в консоли, значит, либо критическая зона слишком велика (в ней делаются тяжелые вычисления), либо возникла ситуация скрытого дедлока.

    Использование TMultiReadExclusiveWriteSynchronizer

    В некоторых системах сбора данных чтение происходит гораздо чаще, чем запись. Например, один поток пишет данные из UART, а пять разных окон GUI или модулей анализа хотят одновременно читать эти данные.

    Использование обычной TCriticalSection в этом случае неоптимально: если одно окно читает данные, остальные четыре будут ждать, хотя чтение — операция безопасная для одновременного выполнения.

    Для таких случаев в Free Pascal есть класс TMultiReadExclusiveWriteSynchronizer (часто сокращаемый до TMREWS). Он позволяет:

  • Множеству потоков одновременно удерживать блокировку на чтение (BeginRead).
  • Только одному потоку захватывать блокировку на запись (BeginWrite), при этом все читатели блокируются.
  • Это значительно повышает пропускную способность системы в сценариях «один пишет — многие читают». Однако помните, что этот объект тяжелее, чем обычная критическая секция, и его не стоит использовать там, где запись происходит постоянно (как в случае с потоком UART).

    Практические рекомендации по безопасности памяти

    При передаче данных из потока в GUI через разделяемую память всегда следуйте правилу «Один владелец». Как только данные переданы через защищенный буфер, фоновый поток должен «забыть» о них, а основной — взять на себя ответственность за их удаление.

    Если вы используете объекты (классы), а не простые типы вроде TBytes или String, ситуация усложняется. Передача объекта через критическую секцию передает только указатель. Если фоновый поток после этого случайно изменит свойство этого объекта или вызовет его деструктор, GUI-поток упадет с ошибкой Access Violation.

    Безопасный алгоритм для объектов:

  • Поток чтения создает объект и заполняет его данными.
  • Поток чтения захватывает секцию и помещает указатель в очередь.
  • Поток чтения «забывает» про объект (присваивает локальной переменной nil).
  • GUI поток извлекает указатель под секцией.
  • GUI поток использует данные и сам вызывает .Free.
  • Соблюдение этого цикла владения вместе с грамотным применением критических секций делает многопоточную систему сбора данных такой же стабильной, как и однопоточное приложение, при этом сохраняя отзывчивость интерфейса и высокую скорость обработки входящего трафика.