Skip to content

Latest commit

 

History

History
2800 lines (2134 loc) · 325 KB

multithread_in_delphi_for_beginners.md

File metadata and controls

2800 lines (2134 loc) · 325 KB

Многопоточное программирование в Delphi для начинающих

(редакция 1.12 от 24.07.2021г)
Автор: Логинов Д.С.
Пенза, 2021

Оглавление

  1. Вступление ...

    1.1 Для чего используется многопоточное программирование в Delphi ...

    1.2 Можно ли избежать многопоточного программирования ...

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

    1.4 Поддержка многопоточности в операционных системах и процессорах ...

  2. Базовый класс многопоточности - TThread ...

  3. Предотвращаем зависание основного потока (имитация задачи длительного вычисления) ...

  4. Управление временем жизни потоков ...

    4.1 Использование свойства TThread.Terminated для выхода из метода Execute ...

    4.2 Главная форма отвечает за прекращение работы потока и уничтожение объекта потока ...

    4.3 Главная форма отвечает за уничтожение объекта потока с разовой задачей ...

    4.4 Главная форма отвечает за уничтожение нескольких долгоживущих потоков ...

    4.5 Использование списка TObjectList для хранения ссылок на объекты потоков ...

    4.6 Простой пример организации паузы в работе потока ...

    4.7 Использование свойства FreeOnTerminate для автоматического уничтожения объекта потока при завершении работы потока ...

    4.8 Организация корректного завершения работы программы при использовании свойства FreeOnTerminate...

  5. Передача информации из дополнительного потока в основной ...

    5.1 Обращение к визуальным компонентам формы из дополнительного потока – как нельзя делать ...

    5.2 Использование метода TThread.Synchronize для передачи данных в основной поток ...

    5.3 Периодическое чтение основным потоком данных, подготовленных в дополнительном потоке ...

    5.4 Использование функции SendMessage для передачи данных в основной поток ...

    5.5 Использование функции PostMessage для передачи очереди сообщений в основной поток ...

    5.6 Использование списка TThreadList для передачи очереди данных в основной поток ...

    5.7 Использование метода TThread.Synchronize для передачи данных в основной поток – вариант с использованием анонимной процедуры ...

    5.8 Использование метода TThread.Queue для передачи данных в основной поток ...

  6. Планирование потоков ...

    6.1 Роль системного таймера ...

    6.2 Влияние количества запущенных потоков на производительность системы ...

    6.3 Приоритеты потоков в Windows ...

  7. Основные объекты синхронизации ...

    7.1 Главный поток управляет работой дополнительного потока с помощью объекта "Event" (событие) ...

    7.2 Использование объекта "Event" для разработки потока логгирования ...

    7.3 Пара слов об объекте "Mutex" ...

    7.4 Пара слов об объекте "Semaphore" ...

    7.5 Критическая секция и монитор ...

    7.6 Механизм синхронизации "много читателей и один писатель" (MREW) ...

  8. Обработка исключений в дополнительных потоках ...

  9. Передача данных в дополнительный поток ...

    9.1 Использование обычного списка (TList, TStringList, TThreadList) и периодический контроль списка ...

    9.2 Использование обычного списка (TList, TStringList, TThreadList) и объекта Event ...

    9.3 Использование очереди TThreadedQueue<T> (множество producer-потоков и один consumer-поток) ...

  10. Где можно и где нельзя создавать и уничтожать потоки ...

  11. Немного о threadvar ...

  12. Работа с базой данных из дополнительного потока ...

  13. Проблемы, возникающие при создании большого количества потоков ...

  14. Выполнение длительной операции в дополнительном потоке с отображением информационного модального окна, блокирующего работу пользователя с программой ...

1. Вступление

В данной работе (условно назовём её «Учебник») речь пойдет о многопоточном программировании в Delphi. Учебник написан для сообщества Delphi-программистов (к этому сообществу следует отнести также Lazarus-программистов). В дальнейшем я буду использовать термин «программист», подразумевая именно Delphi-программиста.

В современных версиях Delphi (начиная с XE7) имеется удобная библиотека многопоточного программирования «Parallel Programming Library» (PPL) и в последние годы всё больше примеров в интернете даётся именно для PPL. Она хорошо подходит для тех задач, где требуется распараллелить математические вычисления либо выполнить параллельную обработку однотипной информации (например, из множества файлов на SSD-накопителе). На самом деле, возможности PPL ограничены только фантазией программиста.

В данной работе основной упор будет сделан на классическом способе многопоточного программирования в Delphi – работе с классом TThread (вернее, с классами-наследниками, которые разрабатывают программисты для решения тех или иных задач). Программисты должны уметь применять этот подход на практике и должны ориентироваться в чужом коде, где применяется классический подход.

К сожалению, в интернете (в открытых источниках) очень мало качественного материала, который помог бы начинающему программисту научиться работать с классом TThread. Формально, материала по многопоточному программированию в Delphi полно, однако большая его часть, вопреки стараниям его разработчиков, не учит многопоточному программированию, а чаще сбивает с толку, в том числе из-за множества ошибочных примеров и советов.

Несмотря на название "Многопоточное программирование в Delphi для начинающих", автор рекомендует ознакомиться с данным "учебником" не только начинающим разработчикам, но и профессионалам, которые неплохо ориентируются в данной теме. Учитывая большой объём материала, есть вероятность, что профессиональным разработчикам эта работа также покажется интересной.

Для данного учебника автором было разработано большое количество примеров - программ на языке Delphi. Все примеры разработаны исключительно автором, в них нет каких-либо заимствований из внешних источников.

Хочется заранее сообщить, что сообщество Delphi-программистов очень положительно восприняло данную работу. Среди сообщений на форуме встречается такое сообщение: "Это не руководство для начинающих программистов, а скорее руководство для любых программистов, которые начинают писать многопоточные приложения на Delphi. Такую тему надо закреплять в шапке форума".

1.1 Для чего используется многопоточное программирование в Delphi

Я хотел бы выделить две основные причины использования многопоточности в Delphi-программах:

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

  2. Задачи длительного ожидания завершения ввода-вывода или освобождения объекта ядра ОС. Примеры: выполнение HTTP-запроса (или REST-запроса) к какому-либо сайту в интернете, обмен данными по протоколу TCP, обмен с устройством по COM-порту, запуск и ожидание работы других программ на компьютере и многое другое. Для всех этих задач характерна низкая нагрузка на процессор. Ресурсы процессора практически не задействуются, однако программа, запустившая длительную операцию, вынуждена ожидать её завершения. Очевидно, что если мы при разработке обычного VCL-приложения попытаемся запустить такую задачу без вынесения в отдельный поток, то интерфейс пользователя подвиснет на время, которое требуется для ожидания завершения длительной операции.

1.2 Можно ли избежать многопоточного программирования

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

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

Но вы должны помнить, что если операция выполняется более 5 секунд и пользователь щелкнет мышкой на форме, то в заголовке формы появится надпись "не отвечает", в следствие чего могут быть глюки при работе программы, связанные с тем, что активная форма может улететь на задний план, либо может активироваться форма, которая находится позади модальной формы.

ℹ️ Внимание! При создании формы всегда передавайте параметр AOwner в конструкторе формы. Например, если из формы Form1 создаётся форма Form2, то код создания может выглядеть следующим образом: Form2 := TForm2.Create(AOwner), где AOwner - это Form1. В этом случае проблемы с "улетанием" формы на задний план будут происходить намного реже.

ℹ️ Информация! Проблемы с формами возникают именно после того, как появляется надпись "не отвечает". До этого (в первые 4 секунды подвисания) никаких проблем не возникает. Вы можете отсрочить появление надписи "не отвечает". Для этого следует создать в реестре текстовый параметр "HungAppTimeout" и указать ему значение таймаута в миллисекундах, например 20000. Данный параметр находится в ветке "HKEY_CURRENT_USER\Control Panel\Desktop", а новое значение вступит в силу после очередного входа в систему. Обсуждение HungAppTimeout.

  1. При необходимости выполнить длительную операцию в главном потоке Вы можете отобразить на экране Splash-форму с текстом "Ждите! Выполняется длительная операция...". Сначала отображаем Splash-форму в немодальном режиме (FormStyle=fsStayOnTop), затем выполняем длительную операцию, затем скрываем Splash-форму. Пример данного подхода находится в папке "ExNotUseThreads".

  2. В некоторых случаях Вы можете реализовать код выполнения длительной операции в отдельном процессе. В этом случае Ваше приложение может запустить другую программу, которая выполнит заданную операцию. При этом основное приложение сможет заниматься другими задачами. На форуме SQL.ru есть множество примеров того, как запустить заданный исполняемый файл с необходимым набором аргументов. К сожалению, в составе Delphi отсутствует удобные классы-обёртки для запуска процесса, взаимодействия с ним и мониторинга его состояния (зато такой класс TProcess есть в Lazarus, а также имеется его порт для Delphi). Данный подход весьма распространён в Linux, поскольку в его составе есть огромное количество готовых утилит практически на все случаи жизни, тем более раньше (лет 15-20 назад) в Linux многопоточности не было (нельзя было создать несколько потоков в рамках одного процесса). В Windows таких утилит намного меньше (по крайней мере мы о них редко слышим), тут основной упор на использование технологий COM / Ole / ActiveX / DCOM.

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

В современном программировании принято разделять разрабатываемую систему на бэкенд и фронтэнд. Бэкенд – это сервис, который отвечает за хранение данных, их обработку и взаимодействие с фронтэндом. Фронтэнд – это программа, представляющая интерфейс пользователя. Она, как правило, не хранит и не обрабатывает данные. Её цель – обеспечить для пользователя наиболее удобную работу.

Delphi – это прежде всего инструмент для разработки фронтэнда, поскольку мы создаём в первую очередь программы с графическим интерфейсом пользователя (GUI). Программист фронтэнда должен сделать всё возможное, чтобы пользователю было максимально комфортно работать. Никаких подвисаний интерфейса во фронтэнде быть не должно! Однако этого достичь в Delphi весьма нелегко. Если мы запрашиваем с сервера данные без использования многопоточности, то интерфейс программы подвисает на время обработки запроса. Некоторые сетевые библиотеки позволяют частично обойти данную проблему (например, режим антизаморозки в Overbyte ICS или Indy), однако далеко не всегда данный режим применим.

Основной режим работы в библиотеке Overbyte ICS – асинхронный, при котором весь сетевой обмен выполняется в рамках одного потока, этот поток не блокируется (т.е. не подвисает), однако программист должен самостоятельно реализовать обработчики различных событий (например, обработчик события получения данных). Если протокол обмена с сервером состоит из множества запросов и ответов, то наша программа значительно усложняется, мы можем легко столкнуться с явлением «callback hell» (ад функций обратных вызовов). Конечно, профессиональный программист такого не допустит и сможет реализовать машину состояний (конечный автомат), которая избавит от проблемы «callback hell», но профессионалы, к сожалению, на дороге просто так не валяются. Напомню, статья предназначена в первую очередь для новичков, которые вряд ли смогут легко справиться с этой задачей. К тому же, даже если мы и реализуем асинхронный сетевой обмен данными в рамках одного потока, мы столкнемся с необходимостью обработки принятых данных, необходимостью выполнять запросы к базе данных, работать с файлами (на медленном HDD), а в конечном итоге – к подвисаниям интерфейса пользователя. Знание многопоточного программирования нам помогает справиться с этой проблемой.

У других современных языков программирования (C#, JavaScript, Rust, Kotlin и т.д., их число постоянно растёт) проблема «callback hell» решена на уровне языка: введён встроенный механизм асинхронности: async / await. Смысл в том, что программист на этих языках пишет простой последовательный код (без обработчиков событий), а компилятор автоматически преобразует этот код в асинхронный с использованием машины состояний. Т.е. программист на этих языках теперь не сталкивается с теми сложностями, с которыми сталкиваемся мы при использовании асинхронных сетевых библиотек (кстати, это не только Overbyte ICS, но и ещё десяток других, в том числе коммерческих).

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

Мы же используем Delphi (на мой взгляд – наиболее мощный инструмент для разработки кроссплатформенных GUI-приложений практически для любой платформы) и вынуждены пользоваться теми возможностями, которые этот инструмент нам предлагает, а встроенной асинхронности у данного инструмента пока нет.

Давайте же перейдём к теме многопоточности!

1.4 Поддержка многопоточности в операционных системах и процессорах

Буду говорить своими словами, очень упрощённо. Обязательно почитайте материал на эту тему в Интернете (очень много классных статей на эту тему есть на ресурсе habr.com, например, в статьях, посвящённых языку C#).

Я буду говорить в первую очередь о компьютерах с ОС Windows и процессорах с архитектурой Intel x86 (либо AMD x86_64), однако аналогичные механизмы есть в различных устройствах, оснащённых ОС Linux, Android, MacOS, iOS и процессорами, на которых работают эти ОС.

Итак, на компьютере одновременно запущены сотни программ (приложений). Мы называем их также «процессы». Процесс (process) – это объект операционной системы. Когда мы запускаем программу, ОС создаёт объект «процесс». При создании процесса ОС создаёт также объект «поток» (thread), для него создаётся стек вызовов (call stack) и код запускается на исполнение. Исполняемый код всегда выполняется в контексте какого-то одного потока. В любом процессе всегда есть минимум один поток. При необходимости, в процессе может быть более одного потока (например, в процессе WEB-сервера могут быть созданы тысячи потоков).

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

Важно знать, что в ядре ОС присутствует код, который отвечает за распределение процессорного времени между потоками. Данный код часто называют "планировщик задач" (system scheduler) либо "диспетчер ядра". В том случае, если поток выполняет длительную вычислительную задачу (обрабатывает информацию), планировщик задач автоматически выполнит действия, необходимые для перевода данного потока в спящий режим, как только поток истратит выделенный ему квант времени (предположим, 32 мс). Кстати, в большинстве случаев потоки намного раньше переходят в спящий режим, т.к. выполняют свою задачу раньше, чем успевает закончиться выделенный квант времени (time slice), либо в связи с ожиданием какого-либо события.

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

Каждый раз, когда поток переходит в спящий режим, происходит сохранение его контекста (сохраняется текущее состояние регистров процессорного ядра, на котором поток выполняется). При переводе потока в рабочий режим выполняется обратное действие – восстановление контекста потока (загрузка ранее сохраненных значений в регистры процессорного ядра).

Кстати, операция переключения контекста по времени далеко не бесплатная, поэтому производители процессоров и разработчики ОС постоянно делают улучшения для повышения скорости данной операции. Если Вы хотите создать эффективный высокопроизводительный WEB-сервер, то будет не лишним подумать над тем, как минимизировать количество переключений контекста при исполнении Вашего кода. Я не буду давать рекомендаций по поводу создания высокопроизводительного WEB-сервера, поскольку задача эта больше подходит для специализированных языков программирования WEB-серверов, например GoLang, а Delphi заточен в первую очередь на разработку фронтэнда, а также корпоративного программного обеспечения.

2. Базовый класс многопоточности - TThread

TThread – это базовый класс, инкапсулирующий функционал для обеспечения работы параллельного потока. Если мы хотим реализовать код, который должен выполняться в отдельном потоке, то нам необходимо реализовать наследника от класса TThread и, как минимум, реализовать override-метод Execute, который перекроет виртуальный абстрактный метод Execute, объявленный в родительском классе TThread. Код, находящийся внутри метода Execute будет исполняться в отдельном потоке.

При создании класса-наследника от TThread мы можем реализовать конструктор, деструктор, объявить собственные поля, методы, свойства и т.д., в зависимости от поставленной перед нами задачи.

В данном разделе я не буду подробно останавливаться на описании возможностей класса TThread. Читайте внимательно следующие разделы, надеюсь, в них Вы найдёте необходимую для Вас информацию.

ℹ️ Информация! Дополнительные потоки создаются с помощью вызова соответствующей функции операционной системы, например CreateThread в ОС Windows. Класс TThread является обёрткой, которая добавляет к системному дополнительному потоку объектно-ориентированное измерение, т.е. позволяет создать объект, который привязывается к системному дополнительному потоку.

⚠️ Внимание Не рекомендуется заниматься реализацией бизнес-логики в наследниках класса TThread. Вы должны рассматривать своих наследников от TThread как механизм (один из механизмов) организации многопоточности в Вашем приложении, но не как место для реализации бизнес-логики. Бизнес-логику лучше реализовать в отдельных классах, работа которых будет зависеть от TThread по минимуму.

ℹ️ Информация! Существуют и другие популярные средства для многопоточного программирования в Delphi. К ним стоит отнести:
- Parallel programming library - стандартная библиотека для параллельного программирования в современных версиях Delphi.
- OmniThreadLibrary – популярная библиотека для параллельного программирования (на июль 2020 библиотека поддерживает только Windows, однако в будущем может появиться поддержка других операционных систем). Ссылка: https://github.com/gabr42/OmniThreadLibrary

3. Предотвращаем зависание основного потока (имитация задачи длительного вычисления)

Ниже представлен пример кода, в котором имитируется задача длительного вычисления (функция DoLongCalculations). Данная функция запускается двумя путями: 1) из основного потока (см. обработчик кнопки btnRunInMainThread); 2) из параллельного потока (см. обработчик кнопки btnRunInParallelThread). Данный пример Вы можете найти в репозитории (папка Ex1).

ℹ️ Информация! Все примеры к данному «Учебнику» доступны по ссылке: https://github.com/loginov-dmitry/multithread

unit Ex1Unit;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls;

type
  TMyThread = class(TThread)
  public
    procedure Execute; override;
  end;

  TForm1 = class(TForm)
    btnRunInParallelThread: TButton;
    btnRunInMainThread: TButton;
    procedure btnRunInParallelThreadClick(Sender: TObject);
    procedure btnRunInMainThreadClick(Sender: TObject);
  private
    { Private declarations }
  public
    { Public declarations }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

function DoLongCalculations: Int64;
var
  I: Integer;
begin
  Result := 0;
  // Очень длинный цикл. Имитирует длительные вычисления.
  for I := 1 to MaxInt do
    Result := Result + Random(1000);
end;

procedure MyShowMessage(Msg: string);
begin
  Windows.MessageBox(0, PChar(Msg), '', MB_OK);
end;

procedure TForm1.btnRunInParallelThreadClick(Sender: TObject);
begin
  // Запускает параллельный поток
  TMyThread.Create(False);
end;

{ TMyThread }

procedure TMyThread.Execute;
var
  V: Int64;
begin
  FreeOnTerminate := True;
  V := DoLongCalculations;
  MyShowMessage('Результат: ' + IntToStr(V));
end;

procedure TForm1.btnRunInMainThreadClick(Sender: TObject);
var
  V: Int64;
begin
  V := DoLongCalculations;
  MyShowMessage('Результат: ' + IntToStr(V));
end;

end.

Нажмите кнопку btnRunInMainThread для запуска функции DoLongCalculations из основного потока. Через несколько секунд появится сообщение с результатом вычислений. Длительность вычислений предсказать сложно, она зависит от характеристик процессора, а также от его текущего состояния. Приблизительное время – от 5 до 20 секунд. Обратите внимание, что после запуска вычислений вы не можете выполнять каких-либо действий с окном (нажимать кнопки, изменять размеры окна и т.д.) до момента окончания вычислений. Таким образом, запуск функции DoLongCalculations из основного приводит к подвисанию интерфейса пользователя.

А теперь нажмите кнопку btnRunInParallelThread для запуска функции DoLongCalculations из дополнительного потока. Вы увидите, что интерфейс пользователя не подвис, а продолжает реагировать на Ваши действия. Через несколько секунд появится сообщение с результатом вычислений. Таким образом, запуск функции DoLongCalculations из параллельного потока не приводит к подвисанию интерфейса пользователя.

Попробуйте нажать кнопку btnRunInParallelThread два раза друг за другом. Через несколько секунд (скорее всего дольше, чем до этого) появится два сообщения с результатами вычислений. Т.е. параллельно друг другу два дополнительных потока произведут вычисления, а затем выведут результат на экран.

Сложно предположить, сколько времени займут вычисления при выполнении двух параллельных потоков. Это зависит от того, сколько физических ядер у процессора, а также от того, на одном или на двух разных физических ядрах выполняются потоки. Если наши два потока будут выполняться на двух разных физических ядрах, то время их выполнения будет таким же, как время выполнения одного потока, который мы запускали до этого. Но если они будут выполняться на одном физическом ядре, то время их выполнения будет примерно в 2 или 3 раза больше (т.е. мы не получим реального распараллеливания вычислений, хотя интерфейс пользователя и не будет подвисать).

Я не случайно выделил слово «физических», поскольку в современных процессорах есть понятие «логическое ядро» (можете почитать в Интернете про «Hyper-Threading»). Если оба потока будут работать на двух логических ядрах одного и того же физического ядра, то мы также не получим реального распараллеливания вычислений.

⚠️ Замечание по выдаче окна с сообщением из дополнительного потока
В данном примере для выдачи пользователю сообщения из дополнительного потока используется функция MyShowMessage. В ней производится вызов стандартной WinAPI-функции MessageBox, которая отображает на экране окно с заданным текстовым сообщением. Хотя в Delphi и имеется похожая функция для вывода сообщений – ShowMessage, однако её нельзя вызывать из дополнительного потока, поскольку она не является «потокобезопасной», т.е. при обращении к ней из дополнительного потока могут происходить сбои в работе программы (может и не каждый раз).
Несмотря на то, что в данном примере код параллельного потока выдаёт пользователю окно с сообщением, я не рекомендую так делать в реальных программах. Важно помнить, что визуальное информирование пользователя должно выполняться из основного потока, тогда мы сможем избежать многих проблем.

⚠️ Замечания по использованию свойства TThread.FreeOnTerminate
Обратите внимание, что в примере используется свойство FreeOnTerminate класса TThread. В начале метода TMyThread.Execute мы выставили ему значение True. Сделано этого для того, чтобы программа автоматически уничтожила объект TMyThread (т.е. вызвала деструктор объекта) сразу после завершения работы метода Execute. Поскольку мы не завели отдельной переменной для хранения ссылки на созданный объект TMyThread, то единственный способ уничтожить созданный объект – попросить его самостоятельно себя уничтожить сразу после завершения работы метода Execute. Для этого мы и воспользовались свойством FreeOnTerminate.
Это лишь один из способов управления временем жизни объекта потока. Другие способы будет представлены в следующих разделах.

⚠️ Запущенный поток не завершается при выходе из программы
В данном примере есть следующая проблема: мы можем закрыть программу, не дожидаясь окончания работы дополнительного потока. Хотя для первого примера это не является проблемой, однако в реальных программах такая ситуация является недопустимой. Запомните правило: при выходе из программы Вы должны корректно завершать все созданные Вами дополнительные потоки. Можно сказать по-другому: нельзя допустить завершения работы программы, если имеются запущенные Вами дополнительные потоки. Если Вы не будете следовать этому правилу, то Ваша программа с большой вероятностью будет глючить.

4. Управление временем жизни потоков

При программировании на языке Delphi программист должен самостоятельно думать о том, каким образом будет уничтожаться тот или иной созданный им объект.

При программировании дополнительных потоков мы должны определиться, каким образом будет завершаться работа метода Execute и каким образом будет уничтожаться созданный нами объект потока. При этом мы должны гарантированно завершить работу потока и уничтожить объект потока при выходе из программы. Если поток выполняет очень длительную важную задачу и его нельзя прерывать, то мы должны заблокировать возможность выхода из программы до момента завершения работы потока.

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

Примеры однократного выполнения кода в методе Execute:

  1. Выполнение вычислений при нажатии пользователем кнопки. В этом случае вполне уместно при каждом нажатии пользователем кнопки создавать дополнительный поток и запускать его.
  2. Выполнение запроса к базе данных и обработка данных при срабатывании таймера. Здесь следует пояснить, что таймер не должен срабатывать слишком часто, иначе расходы на запуск и уничтожение потока могут оказаться значительными. Также хочу отметить, что при срабатывании таймера не следует обращаться к базе данных из основного потока, лучше это делать из дополнительного потока, разумеется, в отдельном подключении.
  3. Выполнение заданной функции или процедуры в контексте дополнительного потока с навешиванием модальной формы ожидания окончания выполнения заданной функции.
  4. Можно придумать ещё сотню различных ситуаций, но такой необходимости у нас нет!

Многократное выполнение кода в методе Execute используется при периодическом выполнении какой-либо задачи, например:

  1. Раз в секунду выполняется запрос к БД и при наличии нового задания производится обмен с каким-либо устройством.
  2. Раз в 15 секунд выполняется синхронизация данных с сервером.
  3. 2 раза в секунду выполняется опрос состояния какого-либо устройства (например, фискального регистратора, топливно-раздаточной колонки, системы измерения уровня и т.д.).
  4. Можно также придумать ещё сотню различных ситуаций.

ℹ️ Внимание! Если периодическая задача выполняется редко (например, каждые 10 минут), рекомендуется каждый раз (если это не сложно!) для такой задачи создавать новый поток. Вероятно, это лучше, чем часами удерживать дополнительный поток в спящем состоянии (особенно, если вы разрабатываете 32-разрядное Windows-приложение).

4.1 Использование свойства TThread.Terminated для выхода из метода Execute

В том случае, если поток запущен на длительное время (например, пока пользователь не закроет программу) и периодически выполняет одну и ту же задачу, необходимо предусмотреть механизм завершения работы метода Execute (работа потока завершается именно после выхода из метода Execute). Код внутри метода Execute должен периодически проверять флаг (например, переменную логического типа), который программа должна установить для того, чтобы поток знал, что пора завершаться.

Свойство TThread.Terminated как раз и является тем флагом, который необходимо периодически проверять для завершения работы метода Execute. Ниже представлен пример реализации метода Execute, в котором анализируется значения свойства Terminated (см. папку Ex2).

procedure TMyLongThread.Execute;
  procedure WaitTimeout(ATimeOut: Integer);
  begin
    Sleep(ATimeOut);
  end;
begin
  while not Terminated do
  begin
    DoUsefullTask;
    WaitTimeout(10000); // Ожидаем таймаут 10 сек.
  end;
end;

В данном примере реализован бесконечный цикл while, критерием выхода из которого является значение свойства Terminated, равное True.

Метод DoUsefullTask имитирует выполнение полезной работы, которая длится 5 секунд. После каждой итерации выполнения полезной работы поток должен на какое-то время переходить в спящее состояние. Напомню, что в спящем состоянии поток не создаёт какой-либо нагрузки на процессор. Благодаря этому мы даём возможность работать другим потокам (в том числе в других процессах).

Для перевода потока в спящее состояние мы вызываем вложенную (nested) процедуру WaitTimeout и передаём ей длительность ожидания. В свою очередь в ней происходит вызов стандартной WinAPI-функции Sleep, после чего поток переходит в спящее состояние, длительность которого задана в миллисекундах (в примере – 10 секунд).

⚠️ Внимание! Если поток переведён в спящее состояние с помощью функции Sleep, то не существует другого способа выйти из этого состояния кроме истечения указанного временного периода.
Попробуйте в примере (Ex2) запустить параллельный поток (с помощью кнопки btnRunParallelThread), а затем закрыть программу. Вы обнаружите, что выход из программы произойдет не сразу, а спустя какое-то время (максимум 15 секунд). На практике вызов функции Sleep для длительных задержек является недопустимым!

4.2 Главная форма отвечает за прекращение работы потока и уничтожение объекта потока

Ниже приведён полный пример из каталога Ex2. В нём, как уже было сказано, код в методе Execute в бесконечном цикле выполняет заданную задачу и засыпает на некоторое время. Работа метода Execute завершается только при выходе из программы:

unit Ex2Unit;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls;

type
  TMyLongThread = class(TThread)
  private
    procedure DoUsefullTask; // Процедура для имитации полезной работы
  public
    procedure Execute; override;
  end;

  TForm1 = class(TForm)
    btnRunParallelThread: TButton;
    procedure btnRunParallelThreadClick(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
  private
    { Private declarations }
    MyThread: TMyLongThread;
  public
    { Public declarations }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

procedure TForm1.btnRunParallelThreadClick(Sender: TObject);
begin
  // Запускает параллельный поток
  if MyThread = nil then
    MyThread := TMyLongThread.Create(False)
  else
    raise Exception.Create('Дополнительный поток уже запущен!');
end;

{ TMyLongThread }

procedure TMyLongThread.DoUsefullTask;
begin
  // Реальный поток может выполнять какую угодно полезную работу
  // В учебных целях делаем паузу 5 секунд для имитации задержки, которая
  // может возникнуть при выполнении полезной работы
  Sleep(5000);
end;

procedure TMyLongThread.Execute;
  procedure WaitTimeout(ATimeOut: Integer);
  begin
    Sleep(ATimeOut);
  end;
begin
  while not Terminated do
  begin
    DoUsefullTask;
    WaitTimeout(10000); // Ожидаем таймаут 10 сек.
  end;
end;

procedure TForm1.FormDestroy(Sender: TObject);
begin
  // При закрытии программы необходимо завершить работу потока
  // и уничтожить объект потока MyThread
  if MyThread <> nil then
    MyThread.Free;
end;

end.

Обратите внимание на следующие особенности данного примера:

  1. не используется свойство FreeOnTerminate, т.е. объект потока не уничтожает себя автоматически после завершения метода Execute;
  2. при создании объекта потока ссылка на созданный объект запоминается в переменной MyThread, которая является полем класса TForm1;
  3. блокируется создание нескольких объектов класса TMyLongThread;
  4. завершение работы потока и уничтожение объекта потока выполняется при выходе из программы путём вызова стандартного метода MyThread.Free (в обработчике FormDestroy). Хотя технически и нет необходимости проверять переменную MyThread на равенство nil перед вызовом метода Free, однако в данном случае проверка на nil делает код более наглядным, поскольку программа не обязывает пользователя нажимать кнопку btnRunParallelThread.

Важно понимать хотя бы приблизительно, что происходит при вызове MyThread.Free. А происходит примерно следующее:

  1. Производится вызов метода TThread.Terminate, который выставляет свойству TThread.Terminated значение True.
  2. Главный поток (т.е. поток, управляющий интерфейсом пользователя) переводится в спящее состояние до тех пор, пока в дополнительном потоке не произойдет выход из метода Execute.
  3. В методе Execute класса TMyLongThread рано или поздно произойдёт проверка значения свойства Terminated и бесконечный цикл while прервётся, а после этого произойдёт выход из метода Execute и работа параллельного потока будет завершена.
  4. Главный поток вновь возобновит свою работу и окончательно уничтожит объект потока MyThread.

ℹ️ Совет! Старайтесь и Вы сохранять ссылки на создаваемые потоки и уничтожать объекты потоков при закрытии (или уничтожении) форм, в которых Вы создавали соответствующий поток!

4.3 Главная форма отвечает за уничтожение объекта потока с разовой задачей

Следующий пример (Ex3) интересен тем, что дополнительный поток выполняет разовую задачу длительностью 5 секунд. Обратите внимание, что после завершения работы дополнительного потока объект потока MyThread всё ещё жив, и жить он будет до тех пор, пока не будет произведён выход из программы. Также в примере добавлена визуализация работы дополнительного потока с помощью модуля ProgressViewer.pas. Я его иногда использую в некоторых рабочих проектах, но лицензия не запрещает его использовать всём желающим. Думаю, что так будет интереснее наблюдать за работой потоков.

unit Ex3Unit;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, ProgressViewer;

type
  TMyShortThread = class(TThread)
  private
    procedure DoUsefullTask; // Процедура для имитации полезной работы
  public
    procedure Execute; override;
  end;

  TForm1 = class(TForm)
    btnRunParallelThread: TButton;
    procedure btnRunParallelThreadClick(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
  private
    { Private declarations }
    MyThread: TMyShortThread;
  public
    { Public declarations }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

procedure TForm1.btnRunParallelThreadClick(Sender: TObject);
begin
  // Запускает параллельный поток. Если объект потока уже создан,
  // то уничтожает его.
  if MyThread <> nil then
    FreeAndNil(MyThread);
  MyThread := TMyShortThread.Create(False);
end;

{ TMyShortThread }

procedure TMyShortThread.DoUsefullTask;
var
 AProgress: TProgressViewer;
begin
  // Реальный поток может выполнять какую угодно полезную работу
  // В учебных целях делаем паузу 5 секунд для имитации задержки, которая
  // может возникнуть при выполнении полезной работы
  AProgress := TProgressViewer.Create('Выполняется поток TMyShortThread');
  Sleep(5000);
  AProgress.TerminateProgress;
end;

procedure TMyShortThread.Execute;
begin
  DoUsefullTask;
end;

procedure TForm1.FormDestroy(Sender: TObject);
begin
  // При закрытии программы необходимо завершить работу потока
  // и уничтожить объект потока MyThread
  if MyThread <> nil then
    MyThread.Free;
end;

end.

Хотелось бы отменить ещё некоторые особенности этого примера:

  1. При нажатии кнопки btnRunParallelThread программа уничтожает объект потока, если он был запущен ранее. Предлагаю поэкспериментировать с этой кнопкой – понажимать её несколько раз. Вы обнаружите, что если окно с сообщением «Выполняется поток TMyShortThread» отображается на экране, то нажатие кнопки btnRunParallelThread приводит к подвисанию интерфейса пользователя до тех пор, пока окно с сообщением не исчезнет. Затем окно с сообщением появится ещё раз, и отсчёт времени начнётся с нуля.
  2. При выходе из программы возможны 3 варианта:
  • Вариант 1: пользователь не нажимал кнопку btnRunParallelThread, поэтому объект потока не создан и ссылка MyThread равна nil. В этом случае программа гарантированно закроется без задержки.
  • Вариант 2: пользователь нажал кнопку btnRunParallelThread и сразу, не дожидаясь окончания работы потока (т.е. сообщение «Выполняется поток TMyShortThread» ещё висело на экране), закрыл программу. В этом случае произойдет подвисание интерфейса пользователя (максимум 5 секунд), затем, после завершения работы дополнительного потока, произойдет уничтожение объекта потока, после чего программа закроется.
  • Вариант 3: пользователь нажал кнопку btnRunParallelThread, подождал более 5 секунд (сообщение «Выполняется поток TMyShortThread» исчезло) и закрыл программу. В этом случае произойдет вызов метода MyThread.Free, который просто уничтожит объект потока, который к этому моменту уже завершил свою работу, а после этого программа закроется.

4.4 Главная форма отвечает за уничтожение нескольких долгоживущих потоков

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

unit Ex4Unit;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, ProgressViewer;

type
  TMyLongThread = class(TThread)
  private
    FTaskNum: Integer;
    procedure DoUsefullTask1; // Первая задача
    procedure DoUsefullTask2; // Вторая задача
    procedure DoFinalizeTask; // Задача запускается при завершении работы потока
  public
    constructor Create(TaskNum: Integer);
    procedure Execute; override;
  end;

  TForm1 = class(TForm)
    btnRunParallelThreads: TButton;
    Label1: TLabel;
    cbTerminateMode: TComboBox;
    procedure btnRunParallelThreadsClick(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
  private
    { Private declarations }
    MyThread1: TMyLongThread; // Поток для первой задачи
    MyThread2: TMyLongThread; // Поток для второй задачи
  public
    { Public declarations }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

procedure TForm1.btnRunParallelThreadsClick(Sender: TObject);
begin
  // Запускает параллельный поток для задачи 1
  if MyThread1 = nil then
    MyThread1 := TMyLongThread.Create(1);

  // Запускает параллельный поток для задачи 2
  if MyThread2 = nil then
    MyThread2 := TMyLongThread.Create(2);
end;

{ TMyLongThread }

constructor TMyLongThread.Create(TaskNum: Integer);
begin
  inherited Create(False); // Вызываем родительский конструктор

  // Запоминаем параметр TaskNum. Он нужен в методе Execute
  FTaskNum := TaskNum;
end;

procedure TMyLongThread.DoFinalizeTask;
begin
  Sleep(5000); // Данная условная задача занимает 5 секунд
end;

procedure TMyLongThread.DoUsefullTask1;
begin
  Sleep(1000); // Данная условная задача занимает 1 секунду
end;

procedure TMyLongThread.DoUsefullTask2;
begin
  Sleep(2000); // Данная условная задача занимает 2 секунды
end;

procedure TMyLongThread.Execute;
  procedure WaitTimeout(ATimeOut: Integer);
  begin
    Sleep(ATimeOut);
  end;
begin
  while True do
  begin
    if Terminated then
    begin
      DoFinalizeTask; // Некоторые действия при завершении потока
      Exit; // Завершаем работу потока
    end else
    begin
      if FTaskNum = 1 then
        DoUsefullTask1  // Запускаем задачу 1
      else
        DoUsefullTask2; // Запускаем задачу 2

      if not Terminated then // Дополнительная проверка не повредит!
        WaitTimeout(1000); // Ожидаем таймаут 1 сек
    end;
  end;
end;

procedure TForm1.FormDestroy(Sender: TObject);
var
  AProgress: TProgressViewer;
begin
  AProgress := TProgressViewer.Create('Выход из программы');
  try
    if cbTerminateMode.ItemIndex = 1 then
    begin // Выбран режим "Одновременно (быстрее)"
      if Assigned(MyThread1) then
        MyThread1.Terminate; // Выставляем флаг Terminated
      if Assigned(MyThread2) then
        MyThread2.Terminate; // Выставляем флаг Terminated
    end;
    MyThread1.Free;
    MyThread2.Free;
  finally
    AProgress.TerminateProgress;
  end;  
end;

end.

В этом примере имеются следующие особенности:

  1. Поток TMyLongThread выполняет периодически указанное действие: DoUsefullTask1 либо DoUsefullTask2. Номер действия (TaskNum) указан при создании потока, например:
    MyThread1 := TMyLongThread.Create(1);
    MyThread2 := TMyLongThread.Create(2);

  2. При нажатии кнопки btnRunParallelThreads создаются два потока: MyThread1 (для решения задачи 1) и MyThread2 (для решения задачи 2). Для обоих потоков используется один и тот же класс TMyLongThread.

  3. В классе TMyLongThread объявлен собственный конструктор Create.

Важно! Обратите внимание, что при реализации собственного конструктора мы должны обязательно вызвать родительский конструктор, например: inherited Create(False)! При вызове конструктора TThread.Create требуется указать логическое значение (параметр CreateSuspended). Мы указали значение False. Это значит, что мы не хотим запускать поток в замороженном (Suspended) состоянии. Наш поток должен запускаться сразу, как только отработает конструктор. Если бы мы создавали поток в замороженном состоянии, то для запуска метода Execute нам пришлось бы вызвать метод TThread.Resume либо его современную версию TThread.Start. Такая необходимость возникает редко (по крайней мере, в моих проектах).
Важно! Вы должны знать, что запуск метода Execute в дополнительном потоке произойдет только после того, как работа конструктора полностью завершится!

  1. Логика бесконечного цикла while в методе Execute изменена. Если флаг Terminated выставлен в True, то выполняются некоторые действия (метод DoFinalizeTask) и производится выход из метода Execute. Если же флаг Terminated имеет значение False, то осуществляется вызов метода DoUsefullTask1 либо DoUsefullTask2, в зависимости от переменной FTaskNum. Далее, перед вызовом функции WaitTimeout, производится дополнительная проверка флага Terminated. Если он выставлен, то не нужно тратить лишнее время на ожидание, вместо этого мы должны как можно быстрее завершить работу потока!

  2. В программе пользователь может выбрать один из способов завершения работы потоков: «последовательно» или «одновременно». Если выбрать вариант «последовательно» и закрыть программу, то в методе TForm1.FormDestroy будет произведён сначала вызов MyThread1.Free, а затем вызов MyThread2.Free. В том случае, если пользователь предварительно запустил потоки, мы увидим окно с текстом «Выход из программы», которое будет отображаться примерно 10 секунд.
    Если же мы выберем вариант «одновременно» и закроем программу, то окно с текстом «Выход из программы», будет отображаться примерно 5 секунд. Это происходит благодаря тому, что перед вызовом MyThread1.Free осуществляется уведомление потоков о необходимости завершения их работы путем вызова метода TThread.Terminate (MyThread1.Terminate и MyThread2.Terminate). При вызове MyThread1.Free ожидается завершение потока MyThread1, а затем выполняется уничтожение объекта потока. При вызове MyThread2.Free ожидать завершения второго потока долго не придётся, т.к. завершаться он начал одновременно с первым потоком после вызова MyThread2.Terminate.

ℹ️ Совет! Старайтесь при выходе из программы заранее вызывать метод TThread.Terminate для каждого из запущенных потоков!

⚠️ Внимание! Для данного примера перед вызовом метода Terminate необходимо проверять ссылку на объект потока. Если в ней содержится значение nil, то вызывать метод Terminate нельзя, т.к. это приведёт к ошибке доступа к памяти! Также нельзя обращаться к полям, свойствам и методам объекта, который не создан! Единственный метод, который допускает свой вызов для несозданного объекта: TObject.Free. В нём проверяется значение ссылки на объект и если там nil, то деструктор не вызывается.

4.5 Использование списка TObjectList для хранения ссылок на объекты потоков

В том случае, если в Вашем приложении множество потоков, каждый из которых выполняет различные задачи, Вы можете использовать список TObjectList для хранения ссылок на создаваемые объекты потоков. В таком случае Вам не придётся писать код уничтожения каждого потока, достаточно вызвать метод Free для списка TObjectList.

В следующем примере (Ex5) демонстрируется работа со списком TObjectList. Как и в предыдущем примере, в нём реализовано 2 режима завершения работы потоков при выходе из программы: последовательный и одновременный.

Ниже представлен исходный код модуля Ex5Unit:

unit Ex5Unit;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, ProgressViewer, Contnrs, MTUtils;

type
  TMyLongThread1 = class(TThread)
  private
    FUsefullTaskTime: Integer;
  public
    constructor Create(UsefullTaskTime: Integer);
    procedure Execute; override;
  end;

  TMyLongThread2 = class(TThread)
  public
    procedure Execute; override;
  end;

  TMyLongThread3 = class(TThread)
  private
    FUsefullTaskTime: Integer;
  public
    constructor Create(UsefullTaskTime: Integer);
    procedure Execute; override;
  end;

  TMyLongThread4 = class(TThread)
  public
    procedure Execute; override;
  end;

  TForm1 = class(TForm)
    btnRunParallelThreads: TButton;
    Label1: TLabel;
    cbTerminateMode: TComboBox;
    procedure btnRunParallelThreadsClick(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure FormCreate(Sender: TObject);
  private
    { Private declarations }
    FList: TObjectList; // Потоки для первой и второй задачи
  public
    { Public declarations }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

procedure TForm1.btnRunParallelThreadsClick(Sender: TObject);
begin
  // Запускаем 4 параллельных потока
  if FList.Count = 0 then
  begin
    FList.Add(TMyLongThread1.Create(1000));
    FList.Add(TMyLongThread2.Create(False));
    FList.Add(TMyLongThread3.Create(2000));
    FList.Add(TMyLongThread4.Create(False));
  end;
end;

{ TMyLongThread }

constructor TMyLongThread1.Create(UsefullTaskTime: Integer);
begin
  inherited Create(False); // Вызываем родительский конструктор
  FUsefullTaskTime := UsefullTaskTime;
end;

procedure TMyLongThread1.Execute;
begin
  while not Terminated do
  begin
    EmulateUsefullWork(FUsefullTaskTime);
    ThreadWaitTimeout(Self, 60000); // Ожидаем таймаут 60 сек
  end;
  Sleep(5000); // Оставлено для демонстрации режима "Одновременно"  
end;

procedure TForm1.FormCreate(Sender: TObject);
begin
  FList := TObjectList.Create;
end;

procedure TForm1.FormDestroy(Sender: TObject);
var
  AProgress: TProgressViewer;
  I: Integer;
begin
  AProgress := TProgressViewer.Create('Выход из программы');
  try
    if cbTerminateMode.ItemIndex = 1 then
    begin // Выбран режим "Одновременно (быстрее)"
      // Выставляем флаг Terminated для всех потоков. Можно использовать
      // родительский класс TThread для операции приведения типов.
      for I := 0 to FList.Count - 1 do
        TThread(FList[I]).Terminate;
    end;
    // При уничтожении списка TObjectList будут уничтожены все объекты потоков
    FList.Free;
  finally
    AProgress.TerminateProgress;
  end;  
end;

{ TMyLongThread2 }

procedure TMyLongThread2.Execute;
begin
  while not Terminated do
  begin
    EmulateUsefullWork(2000);
    ThreadWaitTimeout(Self, 60000); // Ожидаем таймаут 60 сек
  end;
  Sleep(5000); // Оставлено для демонстрации режима "Одновременно"
end;

{ TMyLongThread3 }

constructor TMyLongThread3.Create(UsefullTaskTime: Integer);
begin
  inherited Create(False);
  FUsefullTaskTime := UsefullTaskTime;
end;

procedure TMyLongThread3.Execute;
begin
  while not Terminated do
  begin
    EmulateUsefullWork(FUsefullTaskTime);
    ThreadWaitTimeout(Self, 60000); // Ожидаем таймаут 60 сек
  end;
  Sleep(5000); // Оставлено для демонстрации режима "Одновременно"
end;

{ TMyLongThread4 }

procedure TMyLongThread4.Execute;
begin
  while not Terminated do
  begin
    EmulateUsefullWork(1000);
    ThreadWaitTimeout(Self, 60000); // Ожидаем таймаут 60 сек
  end;
  Sleep(5000); // Оставлено для демонстрации режима "Одновременно"
end;

end.

Обратите внимание на следующие особенности данного примера:

  1. Реализованы 4 класса-наследника от TThread: TMyLongThread1, TMyLongThread2, TMyLongThread3, TMyLongThread4. Это сделано для того, чтобы показать читателю, что в список TObjectList можно добавить произвольные объекты потоков и в любом количестве.
  2. Список FList создаётся в конструкторе формы и уничтожается в деструкторе. Класс TObjectList объявлен в модуле «Contnrs» (вечно забываю, какие буквы в этом слове и приходится пользоваться поиском по исходникам Delphi).
  3. Для организации паузы в работе потоков используется простая и удобная функция ThreadWaitTimeout. из модуля MTUtils.pas. Она будет приведена ниже.
  4. Для эмуляции полезной работы потоков используется функция EmulateUsefullWork из модуля MTUtils.pas. Она состоит из одной строчки: Sleep(WorkTime).
  5. В режиме одновременного завершения работы потоков в методе TForm1.FormDestroy в цикле выставляется значение True свойству Terminated для каждого объекта-потока из списка FList.
  6. При создании объектов-потоков (в обработчике TForm1.btnRunParallelThreadsClick) переменные для объектов-потоков не объявляются. Результат, возвращаемый при создании объекта (например, TMyLongThread1.Create(1000)), можно сразу сохранять в списке FList.

4.6 Простой пример организации паузы в работе потока

Ниже приведён код функции ThreadWaitTimeout из модуля MTUtils.pas:

procedure ThreadWaitTimeout(AThread: TThread; ATimeout: Integer);
var
  p: TTimeInterval;
  T: TThreadAccessTerminated;
begin
  // Получаем доступ к protected-свойству Terminated
  T := TThreadAccessTerminated(AThread);
  // Если поток нужно завершить, то сразу выходим из цикла
  if T.Terminated then Exit;

  p.Start; // Начинаем замер времени
  while True do
  begin
    if T.Terminated or (p.ElapsedMilliseconds >= ATimeout) then
      Exit;
    // Замораживаем поток примерно на 20 мс
    Sleep(ThreadWaitTimeoutSleepTime);
  end;
end;

Организация паузы в этой функции основана на циклическом вызове функции Sleep с небольшим таймаутом (20 мс). При этом контролируется свойство TThread.Terminated. Опытные программисты могут использовать более сложные способы организации паузы, например, с использованием объекта синхронизации «Event». Однако на текущем этапе обучения поднимать тему объектов синхронизации считаю преждевременным. Для большинства практических задач Вам будет достаточно функции ThreadWaitTimeout. Задержку в 20 мс считаю оптимальной, она обеспечивает достаточную точность паузы, но при этом не приводит к слишком частым переключениям контекста, т.е. практически не оказывает нагрузки на процессор. При необходимости Вы можете присвоить глобальной переменной ThreadWaitTimeoutSleepTime другое значение, отличное от 20.

Также обратите внимание, что в этой функции для организации замеров промежутков времени используется структура (record) TTimeInterval, объявленная в модуле TimeIntervals.pas. Вы можете свободно использовать модуль TimeIntervals.pas в собственных проектах. Обсуждение данного модуля ведётся на форуме SQL.ru

4.7 Использование свойства FreeOnTerminate для автоматического уничтожения объекта потока при завершении работы потока

Давайте снова рассмотрим первый пример (Ex1). В нём был показан вариант реализации потока с использованием свойства FreeOnTerminate. Как уже было отмечено ранее, при значении свойства FreeOnTerminate=True объект потока уничтожается автоматически, как только завершается работа потока. Вроде удобно, да? Не требуется заводить переменную для объекта-потока, не нужно писать вызов метода Free для уничтожения объекта потока. Однако тут не всё так просто! Я бы даже сказал, что у такого подхода больше минусов, чем плюсов. Отмечу такие минусы:

  1. Сложно контролировать состояние такого потока, поскольку не объявлена переменная для объекта-потока.
  2. Сложно управлять таким потоком (по той же причине).
  3. Если же мы решим объявить для такого потока переменную и будем пытаться с нею работать, то рискуем нарваться на ошибку доступа к памяти «Access Violation», поскольку объект потока может быть уничтожен в любой момент времени.
  4. Сложно организовать корректный выход из программы. Как уже было сказано ранее, при выходе из программы мы должны гарантировать завершение работы всех запущенных нами потоков. Но это сложно сделать, если у нас нет ссылки на объект потока.

В первом примере было бы лучше использовать список TObjectList для хранения ссылок на созданные объекты потоков. Однако такое усложнение могло бы отпугнуть читателя на этапе знакомства с первым примером.

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

4.8 Организация корректного завершения работы программы при использовании свойства FreeOnTerminate

В следующем примере (Ex6) демонстрируется один из подходов к организации корректного выхода из программы при использовании свойства FreeOnTerminate. Ниже представлен код модуля Ex6Unit:

unit Ex6Unit;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, ProgressViewer, MTUtils , TimeIntervals;

type
  TMyThread = class(TThread)
  private
    FThreadNum: Integer;
  public
    procedure Execute; override;
    constructor Create;
    destructor Destroy; override;
  end;

  TForm1 = class(TForm)
    btnRunInParallelThread: TButton;
    procedure btnRunInParallelThreadClick(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
  private
    { Private declarations }
  public
    { Public declarations }
  end;

var
  Form1: TForm1;
  ThreadCount: Integer;
  StopThreadsFlag: Boolean;
implementation

{$R *.dfm}

procedure TForm1.btnRunInParallelThreadClick(Sender: TObject);
begin
  // Запускаем параллельный поток
  TMyThread.Create;
end;

procedure TForm1.FormDestroy(Sender: TObject);
var
  pv: TProgressViewer;
begin
  // Выставляем флаг StopThreadsFlag, чтобы все потоки завершились
  StopThreadsFlag := True;
  
  // Задерживаем выход из программы, пока не будут завершены все потоки
  if ThreadCount > 0 then
  begin
    pv := TProgressViewer.Create('Ожидаем завершение потоков');
    while ThreadCount > 0 do
      Sleep(10);
    pv.TerminateProgress;
  end;
end;

{ TMyThread }

constructor TMyThread.Create;
begin
  inherited Create(False);
  // Увеличиваем глобальную переменную ThreadCount на 1 и запоминаем
  // полученное значение
  FThreadNum := InterlockedIncrement(ThreadCount);
end;

destructor TMyThread.Destroy;
begin
  inherited;
  // Уменьшаем глобальную переменную ThreadCount на 1
  InterlockedDecrement(ThreadCount);
end;

procedure TMyThread.Execute;
var
  ti: TTimeInterval;
begin
  FreeOnTerminate := True;

  // Организуем паузу 10 секунд. При этом каждые 20 мс 
  // проверяем флаг StopThreadsFlag
  ti.Start;
  while ti.ElapsedSeconds < 10 do
  begin
    // Заканчиваем ожидание, если выставлен флаг StopThreadsFlag
    if StopThreadsFlag then Break;
    Sleep(20);
  end;

  ThreadShowMessageFmt('Работа потока #%d завершена!', [FThreadNum]); 
end;

end.

Особенности данного примера:

  1. Введены две глобальные переменные: ThreadCount (количество запущенных потоков) и StopThreadsFlag (флаг завершения потоков).

  2. Для увеличения переменной ThreadCount на единицу используется потокобезопасная функция InterlockedIncrement. В принципе, в этом примере мы могли бы использовать операцию инкремента Inc (либо ThreadCount := ThreadCount + 1), однако, операция Inc не является потокобезопасной и при одновременном вызове Inc(ThreadCount) из нескольких потоков мы могли бы получить некорректное значение (например, 9 вместо 10).

  3. Каждому запущенному потоку присваивается порядковый номер FThreadNum. Для этого используется результат, который возвращает функция InterlockedIncrement. Благодаря функции InterlockedIncrement гарантируется, что каждый поток получит свой уникальный порядковый номер. Если бы мы использовали следующий код для увеличения ThreadCount и запоминания FThreadNum:
    ThreadCount := ThreadCount + 1; // либо Inc(ThreadCount)
    FThreadNum := ThreadCount;
    то столкнулись бы со следующими проблемами (с вероятностью примерно 1%):
    а) при одновременном вызове ThreadCount := ThreadCount + 1 из нескольких потоков мы можем получить некорректное значение (например, 9 вместо 10). Тут есть очень простое объяснение. Допустим, 2 потока начали выполнять команду инкремента одновременно, в этом момент в переменной ThreadCount хранилось значение 8. Далее оба потока одновременно увеличили это значение на 1, получилось значение 9. Функция InterlockedIncrement такого не допустит!
    б) даже если инкремент ThreadCount был выполнен корректно, существует риск того, что в поле FThreadNum нескольких потоков попадёт одно и то же значение. Причина этого может заключаться в том, что планировщик задач Windows может в любой момент приостановить работу потока, например, непосредственно перед инструкцией FThreadNum := ThreadCount. После того, как планировщик возобновит работу потока, в переменной ThreadCount может оказаться совсем другое значение. Функция InterlockedIncrement такого не допустит!

  4. Для уменьшения переменной ThreadCount на единицу используется потокобезопасная функция InterlockedDecrement в методе-деструкторе TMyThread.Destroy.

  5. Для остановки потоков используется глобальная переменная StopThreadsFlag. Ей присваивается значение True в методе TForm1.FormDestroy. Переменная StopThreadsFlag проверяется в цикле в методе TMyThread.Execute. Цикл прерывается после того, как в переменной StopThreadsFlag окажется значение True. Перед выходом из метода TMyThread.Execute выдаётся сообщение с текстом «Работа потока #%d завершена!». Для вывода сообщения используется функция ThreadShowMessageFmt, объявленная в модуле MTUtils. Также обратите внимание, что деструктор будет вызван после выхода из метода TMyThread.Execute!

  6. Для организации цикла ожидания 10 секунд в методе TMyThread.Execute используется тип TTimeInterval, объявленный в модуле TimeIntervals. Обратите внимание, что поддержка методов в записях появилась в версии Delphi 2007, поэтому в более ранних версиях Delphi примеры не скомпилируются.

  7. При выходе из приложения в методе TForm1.FormDestroy выставляется флаг StopThreadsFlag := True, а затем выполняется цикл ожидания нулевого значения в переменной ThreadCount. Данная переменная проверяется каждые 10 мс (пауза в работе основного потока организована с помощью Sleep(10)). Выход из программы произойдет после того, как пользователь нажмёт кнопку «ОК», во всех сообщениях, открытых из дополнительных потоков.

⚠️ Внимание! Данный подход к организации цикла ожидания имеет недостатки. Например, если из дополнительного потока произойдет вызов функции Syncronize, либо SendMessage, то цикл ожидания никогда не дождётся окончания работы потока, возникнет взаимная блокировка. Но если мы не будем использовать FreeOnTerminate, то с такой проблемой не столкнёмся.

  1. Кнопка btnRunInParallelThread может быть нажата произвольное количество раз.

⚠️ Внимание! Функции InterlockedIncrement и InterlockedDecrement являются частью Windows API. В современных версиях Delphi Вы можете использовать их кроссплатформенные аналоги: AtomicIncrement и AtomicDecrement, к тому же они объявлены как inline и выполняются с максимально возможной скоростью.

5. Передача информации из дополнительного потока в основной

При разработке многопоточных приложений в Delphi очень актуальной является задача передачи информации, подготовленной в дополнительном потоке, в главный поток. Это весьма сложная тема, требующая от Delphi-программиста повышенного внимания, т.к. при недостаточных знаниях у программиста есть очень высокий шанс сделать программу глюченной и нестабильной.

5.1 Обращение к визуальным компонентам формы из дополнительного потока – как нельзя делать

Внимание! Не допускается обращение к визуальным компонентам из дополнительного потока!

Т.е. внутри метода Execute мы не можем просто так взять, и изменить что-либо на форме. Например, следующие инструкции внутри метода Execute недопустимы:

Form1.Label1 := 'Привет';
Form1.Show;
Form1.ShowModal;
Form1.Button1.Visible := True;
Form1.Memo1.Lines.Add('Привет');
и т.д.

Запомните, что интерфейс пользователя обслуживается основным (главным) потоком! Любые изменения, влияющие на интерфейс пользователя должны всегда выполняться в контексте основного потока! Это не является уникальной особенностью Delphi. Для других языков программирования, в которых имеется поддержка разработки визуальных интерфейсов, действует такое же правило!

5.2 Использование метода TThread.Synchronize для передачи данных в основной поток

В Delphi есть механизм, позволяющий дополнительному потоку выполнить запуск процедуры (метода) в контексте основного потока. Для этого предназначен метод TThread.Synchronize. Суть работы данного метода состоит в следующем:

  1. Метод Synchronize запоминает в списке SyncList (глобальная переменная типа TList) переданный ему метод (метод представляет собой структуру-запись TMethod, в которой есть всего 2 поля: адрес функции и указатель на объект). Например, если мы выполним такой код: TThread.Synchronize(nil, Form1.Show), то в списке будет сохранён указатель на объект (т.е. Form1) и адрес процедуры TForm.Show.
  2. Основной поток периодически проверяет наличие элементов в списке SyncList. Если в списке обнаружен элемент, то производится запуск метода, хранящегося в списке.
  3. Метод TThread.Synchronize, вызванный в дополнительном потоке, вернёт управление после того, как основной поток завершит вызов переданного ему метода.

ℹ️ Обратите внимание, что в метод TThread.Synchronize можно передавать только методы-процедуры без параметров! Если нужны параметры, то Вы должны завести в своём классе потока дополнительные поля, присвоить им значения до вызова Syncronize, а затем использовать эти значения в методе, который вы передали в Syncronize.

ℹ️ Совет! В современных версиях Delphi в метод Syncronize можно передавать анонимную процедуру. В этом случае намного реже возникает необходимость в объявлении дополнительных полей в классе потока.

⚠️ Внимание! Каждый вызов TThread.Synchronize несёт в себе значительные накладные расходы, поэтому старайтесь использовать его как можно реже. В идеале, старайтесь обойтись без него.

Внимание! Нельзя уничтожать дополнительные потоки из метода, который Вы передаёте в TThread.Synchronize! Это приводит к зависанию программы.

Ниже представлен пример (Ex7), в котором дополнительный поток выполняет длительные вычисления (суммирует ряд чисел от 1 до MaxValue) и периодически вызывает метод Synchronize для отображения прогресса на главной форме.

unit Ex7Unit;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, MTUtils, ComCtrls;

type
  TMyThread = class(TThread)
  private
    FResult: Int64;
    FCurrValue: Integer;
    procedure SetProgressParams;
    procedure SetProgressCurrValue;
  public
    MaxValue: Integer;
    procedure Execute; override;
  end;

  TForm1 = class(TForm)
    btnRunInParallelThread: TButton;
    ProgressBar1: TProgressBar;
    Label1: TLabel;
    labResult: TLabel;
    edMaxValue: TEdit;
    procedure btnRunInParallelThreadClick(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
  private
    { Private declarations }
    FMyThread: TMyThread;
  public
    { Public declarations }
  end;

var
  Form1: TForm1;
implementation

{$R *.dfm}

procedure TForm1.btnRunInParallelThreadClick(Sender: TObject);
begin
  // Уничтожаем запущенный поток
  if Assigned(FMyThread) then
    FreeAndNil(FMyThread);

  // Создаём поток в спящем состоянии
  FMyThread := TMyThread.Create(True);

  // Запоминаем длину ряда в поле MaxValue
  FMyThread.MaxValue := StrToIntDef(edMaxValue.Text, 0);

  // Пробуждаем поток для выполнения вычислений
  FMyThread.Resume;
end;

procedure TForm1.FormDestroy(Sender: TObject);
begin
  FMyThread.Free;
end;

{ TMyThread }

procedure TMyThread.Execute;
var
  Res: Int64;
  CurrVal: Integer;
begin
  // Выставляем параметры компонента ProgressBar1
  Synchronize(SetProgressParams);

  // Выполняем некоторые вычисления
  Res := 0;
  CurrVal := 0;
  while CurrVal < MaxValue do
  begin
    if Terminated then Break;
    Inc(CurrVal);
    Res := Res + CurrVal;

    if CurrVal mod 10000 = 0 then
    begin // Обновление прогресса выполняется только 1 раз из 10000
      FCurrValue := CurrVal;
      FResult    := Res;
      Synchronize(SetProgressCurrValue);
    end;
  end;

  // Обновляем прогресс в конце вычислений
  FCurrValue := CurrVal;
  FResult    := Res;
  Synchronize(SetProgressCurrValue);
end;

procedure TMyThread.SetProgressCurrValue;
begin
  Form1.ProgressBar1.Position := FCurrValue;
  Form1.labResult.Caption := IntToStr(FResult);
end;

procedure TMyThread.SetProgressParams;
begin
  Form1.ProgressBar1.Max := MaxValue;
  Form1.ProgressBar1.Position := 0;
  Form1.labResult.Caption := '0';
end;

end.

Особенности данного примера:

  1. Объект дополнительного потока создаётся с параметром True (FMyThread := TMyThread.Create(True)). Это означает, что метод Execute не будет вызван до тех пор, пока программа не вызовет метод Resume (либо Start в современных версиях Delphi). Таким образом, Вы можете создавать поток в замороженном состоянии, а запускать его позже, когда Вам будет удобно.
  2. Запуск дополнительного потока осуществляется вызовом FMyThread.Resume.
    Внимание! В объявлении класса TThread рядом с методом Resume находится метод Suspend, предназначенный для принудительного перевода потока в спящее состояние. Вы не должны его использовать! Многие начинающие программисты пытаются управлять состоянием потока с помощью метода Suspend, однако, как правило, ни к чему хорошему это не приводит, кроме глюков в программе.
  3. Установка параметров компонента ProgressBar1, а также визуализация прогресса осуществляется вызовом метода Synchronize: Synchronize(SetProgressParams) и Synchronize(SetProgressCurrValue).
    Внимание! Не забывайте вызывать метод Synchronize! Нет ничего проще, чем забыть это сделать (например, вызвать SetProgressCurrValue вместо Synchronize(SetProgressCurrValue))! Delphi Вам не подскажет, зато при работе программы будут возникать сбои.
  4. Перед вызовом Synchronize(SetProgressCurrValue) выполняется установка значений полям FCurrValue и FResult, поскольку они в дальнейшем используются в методе SetProgressCurrValue.
  5. Вызов Synchronize(SetProgressCurrValue) выполняется каждую десятетысячную итерацию цикла (см. условие if CurrVal mod 10000 = 0 then…). Если вызывать Synchronize чаще, то скорость вычислений замедлится, а нагрузка на главный поток программы возрастёт. Обратите внимание, что для выхода из программы не обязательно дожидаться окончания работы потока. Вызов FMyThread.Free в методе TForm1.FormDestroy не приводит к зависанию программы, несмотря на то, что в методе TMyThread.Execute последней строкой осуществляется вызов метода Synchronize. Взаимной блокировки главного и дополнительного потока не происходит, т.к. в деструкторе объекта потока вызывается код, который предотвращает взаимную блокировку.

5.3 Периодическое чтение основным потоком данных, подготовленных в дополнительном потоке

В следующем примере (Ex8) задача визуализации текущего состояния вычислений, выполняющихся в дополнительном потоке, решается без использования метода Synchronize. На главной форме находится таймер, который через определённые промежутки времени (каждые 100 мс) считывает из объекта-потока FMyThread свойства CalcResult, CurrValue и ThreadStateInfo и отображает их значения на главной форме.

На мой взгляд, такой код становится проще и логичнее, чем при использовании метода Synchronize. Дополнительный поток занимается своим делом (выполняет вычисления) и не мешает главному потоку. Главный поток занимается своим делом – обслуживает интерфейс пользователя. Даже если главный поток подвиснет, что часто происходит при работе с базами данных, это никак не повлияет на работу дополнительного потока.

Вы не всегда сможете обойтись без Synchronize, но если задача позволяет и качество кода не ухудшится, то старайтесь сделать это. Но имейте ввиду, что сложные структуры, такие как строки, динамические массивы, объекты, варианты, и т.п. придётся (вероятно) защитить от одновременного доступа из разных потоков. Одной из целей данного примера является показать, как защитить строку string.

Ниже исходный код примера Ex8:

unit Ex8Unit;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, MTUtils, ComCtrls, ExtCtrls, SyncObjs;

type
  TMyThread = class(TThread)
  private
    FMaxValue: Integer;
    FResult: Int64;
    FCurrValue: Integer;

    // Информация о текущем состоянии потока
    FThreadStateInfo: string;

    function GetThreadStateInfo: string;
    procedure SetThreadStateInfo(const Value: string);
  public
    constructor Create(MaxValue: Integer);
    procedure Execute; override;
    property CalcResult: Int64 read FResult;
    property CurrValue: Integer read FCurrValue;

    // Свойство для доступа к строке FThreadStateInfo с помощью
    // потокозащищенных методов GetThreadStateInfo и SetThreadStateInfo
    property ThreadStateInfo: string read GetThreadStateInfo 
                                     write SetThreadStateInfo;
  end;

  TForm1 = class(TForm)
    btnRunInParallelThread: TButton;
    ProgressBar1: TProgressBar;
    Label1: TLabel;
    labResult: TLabel;
    edMaxValue: TEdit;
    Timer1: TTimer;
    Label2: TLabel;
    labThreadStateInfo: TLabel;
    procedure btnRunInParallelThreadClick(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure Timer1Timer(Sender: TObject);
  private
    { Private declarations }
    FMyThread: TMyThread;
  public
    { Public declarations }
  end;

var
  Form1: TForm1;
implementation

{$R *.dfm}

procedure TForm1.btnRunInParallelThreadClick(Sender: TObject);
var
  MaxValue: Integer;
begin
  // Уничтожаем запущенный поток
  if Assigned(FMyThread) then
    FreeAndNil(FMyThread);

  MaxValue := StrToInt(edMaxValue.Text);
  ProgressBar1.Max := MaxValue;
  ProgressBar1.Position := 0;
  labResult.Caption := '0';
  labThreadStateInfo.Caption := '???';

  FMyThread := TMyThread.Create(MaxValue);
end;

procedure TForm1.FormDestroy(Sender: TObject);
begin
  FreeAndNil(FMyThread);
end;

procedure TForm1.Timer1Timer(Sender: TObject);
begin
  if Assigned(FMyThread) then
  begin
    ProgressBar1.Position := FMyThread.CurrValue;
    labResult.Caption     := IntToStr(FMyThread.CalcResult);
    labThreadStateInfo.Caption := FMyThread.ThreadStateInfo;
  end;
end;

{ TMyThread }

constructor TMyThread.Create(MaxValue: Integer);
begin
  FMaxValue := MaxValue;
  inherited Create(False);
end;

procedure TMyThread.Execute;
begin
  ThreadStateInfo := 'Start';

  while FCurrValue < FMaxValue do
  begin
    if Terminated then Break;
    Inc(FCurrValue);
    FResult := FResult + FCurrValue;
    ThreadStateInfo := Format('Progress: %f%%',
      [FCurrValue / FMaxValue * 100]);
  end;
  ThreadStateInfo := 'Complete';
end;

function TMyThread.GetThreadStateInfo: string;
begin
  // Защищаем строку с помощью критической секции. Если её убрать,
  // то в главном потоке периодически будет возникать ошибка
  // "Invalid pointer operation" либо "Out of memory"
  StringProtectSection.Enter; // Входим в режим защиты
  Result := FThreadStateInfo;
  StringProtectSection.Leave; // Выходим из режима защиты
end;

procedure TMyThread.SetThreadStateInfo(const Value: string);
begin
  StringProtectSection.Enter; // Входим в режим защиты
  FThreadStateInfo := Value;
  StringProtectSection.Leave; // Выходим из режима защиты
end;

end.

Особенности данного примера:

  1. Введено дополнительное строковое поле FThreadStateInfo (информация о текущем состоянии потока). Доступ к этому полю осуществляется с помощью потокозащищённого свойства ThreadStateInfo, использующего методы GetThreadStateInfo и SetThreadStateInfo для чтения и записи строки FThreadStateInfo. Перед тем, как записать либо прочитать строку, выполняется вход в режим защиты с помощью кода StringProtectSection.Enter. После того, как работа со строкой завершилась, выполняется выход из режима защиты с помощью кода StringProtectSection.Leave.
    ⚠️ Внимание! После каждого входа в режим защиты обязательно должен осуществляться выход из режима защиты.
    ℹ️ Совет! Защищать строки (и другие структуры и объекты) имеет смысл при наличии двух условий:
    а) предполагается, что будет одновременный доступ к строке (структуре, объекту, массиву) из разных потоков;
    б) строка (структура, объект, массив) не является «константной», т.е. она может меняться в зависимости от логики программы (в таких случаях используют ещё термин «mutable» или «мутабельный»).
    ⚠️ Внимание! Если строка (структура, объект, массив) является «константной», т.е. если значение присваивается лишь один раз и больше не меняется, то нет смысла её защищать!

  2. Глобальная переменная StringProtectSection (класс TCriticalSection) объявлена в модуле MTUtils, а соответствующий ей объект создаётся в секции initialization.

  3. Непосредственный доступ к полям класса TMyThread (FResult, FCurrValue и FThreadStateInfo) допускается только из методов класса TMyThread. Сторонний код должен использовать соответствующие свойства: CalcResult, CurrValue, ThreadStateInfo. Свойства CalcResult и CurrValue обеспечивают доступ к полям FResult и FCurrValue только для чтения.

ℹ️ Что будет, если закомментировать строки StringProtectSection.Enter и StringProtectSection.Leave? У меня в этом случае в программе иногда возникают следующие ошибки: "Invalid pointer operation" либо "Out of memory". Особенно легко ошибки воспроизвести, если запустить одновременно 4 экземпляра программы. Я не могу объяснить, из-за чего выдаётся "Out of memory", а с ошибкой "Invalid pointer operation" объяснение может быть таким: при каждом присвоении новой строки переменной FThreadStateInfo происходит две вещи:
а) выделяется новая область памяти под новую строку (с помощью функции менеджера памяти GetMem);
б) освобождается память, занятая старой строкой (с помощью функции менеджера памяти FreeMem).
Память будет освобождена при условии, если старая строка нигде больше не используется, т.е. у неё нулевой счётчик ссылок.
Главный поток при чтении незащищенной строки FThreadStateInfo может попасть в такую ситуацию, при которой:
а) память под старую строку уже освобождена с помощью FreeMem;
б) в переменной FThreadStateInfo всё ещё хранится указатель на старую строку. При попытке обращения к строке по недействительному указателю возникает ошибка «Invalid pointer operation». Применение критической секции избавляет нас от этой проблемы.

5.4 Использование функции SendMessage для передачи данных в основной поток

Мы уже рассмотрели возможность использования метода Synchronize для вызова указанного метода-процедуры в контексте основного потока. Также в Delphi существует альтернативный механизм передачи данных в основной поток, основанный на передаче и обработке оконных сообщений. Суть этого механизма состоит в том, что дополнительный поток вызывает стандартную Windows-функцию SendMessage (по ней я не планирую давать подробную информацию, поэтому читайте официальную документацию), а в классе формы (например, TForm1) реализуется метод обработки соответствующего оконного сообщения (этот метод вызывается в контексте основного потока). Вызов функции SendMessage является блокирующим, поэтому работа дополнительного потока будет приостановлена в точке вызова функции SendMessage до тех пор, пока не завершится работа метода обработки оконного сообщения.

Функция SendMessage объявлена следующим образом:

function SendMessage(hWnd: HWND; Msg: UINT; wParam: WPARAM; lParam: LPARAM): LRESULT; stdcall;

При вызове данной функции мы должны передать Handle компонента, в котором реализован обработчик сообщения (параметр hWnd), код сообщения (параметр Msg), а также, если Вам это необходимо, параметры (wParam, lParam).

Параметры wParam и lParam являются целочисленными (32 или 64 бита в зависимости от разрядности разрабатываемого приложения), поэтому с их помощью Вы можете передать а) целое число; б) ссылку на любой объект любого класса; в) указатель на любой блок выделенной памяти. Метод обработки оконного сообщения объявляется с указанием ключевого слова message и кода сообщения. Пример объявления:

type
  TForm1 = class(TForm)
  private
    procedure UMProcessMyMessage(var Msg: TMessage); message WM_USER + 1;
  public
  end;

⚠️ Внимание! Если Вы вводите свои (нестандартные коды сообщений), то необходимо использовать коды не менее WM_USER, например: WM_USER + 1, WM_USER + 2, WM_USER + 3 и т.д.

Рекомендуется заводить константы для нестандартных оконных сообщения (иначе Вы быстро забудете, что у Вас означает WM_USER + 1). Пример объявления такой константы:

const
  UM_PROCESS_MY_MESSAGE = WM_USER + 1;

Префикс «UM_» является сокращением от «User message».
Ниже представлен исходный код модуля из примера Ex9:

unit Ex9Unit;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, MTUtils, ComCtrls;

const
  UM_PROGRESS_INIT   = WM_USER + 1;
  UM_PROGRESS_CHANGE = WM_USER + 2;

type
  TProgressData = class
    CurrValue: Integer;
    CalcResult: Int64;
    ThreadStateInfo: string;
  end;

  TMyThread = class(TThread)
  private
    ProgressData: TProgressData;
    FFormHandle: THandle;
    FMaxValue: Integer;
  public
    procedure Execute; override;
    constructor Create(AMaxValue: Integer; AFormHandle: THandle);
    destructor Destroy; override;
  end;

  TForm1 = class(TForm)
    btnRunInParallelThread: TButton;
    ProgressBar1: TProgressBar;
    Label1: TLabel;
    labResult: TLabel;
    edMaxValue: TEdit;
    Label2: TLabel;
    labThreadStateInfo: TLabel;
    procedure btnRunInParallelThreadClick(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
  private
    { Private declarations }
    FMyThread: TMyThread;
    procedure UMProgressInit(var Msg: TMessage); message UM_PROGRESS_INIT;
    procedure UMProgressChange(var Msg:TMessage); message UM_PROGRESS_CHANGE;
  public
    { Public declarations }
  end;

var
  Form1: TForm1;
implementation

{$R *.dfm}

procedure TForm1.btnRunInParallelThreadClick(Sender: TObject);
begin
  // Уничтожаем запущенный поток
  if Assigned(FMyThread) then
    FreeAndNil(FMyThread);

  // Создаём и запускаем новый поток
  FMyThread := TMyThread.Create(StrToInt(edMaxValue.Text), Handle);
end;

procedure TForm1.FormDestroy(Sender: TObject);
begin
  FMyThread.Free;
end;

procedure TForm1.UMProgressChange(var Msg: TMessage);
var
  ProgressData: TProgressData;
begin
  ProgressData := TProgressData(Msg.WParam);
  ProgressBar1.Position      := ProgressData.CurrValue;
  labResult.Caption          := IntToStr(ProgressData.CalcResult);
  labThreadStateInfo.Caption := ProgressData.ThreadStateInfo;
end;

procedure TForm1.UMProgressInit(var Msg: TMessage);
var
  MaxValue: Integer;
begin
  MaxValue := Msg.WParam;
  ProgressBar1.Max := MaxValue;
  ProgressBar1.Position := 0;
  labResult.Caption := '0';
  labThreadStateInfo.Caption := 'Start';
end;

{ TMyThread }

constructor TMyThread.Create(AMaxValue: Integer; AFormHandle: THandle);
begin
  inherited Create(False);
  FMaxValue := AMaxValue;
  FFormHandle := AFormHandle;
  ProgressData := TProgressData.Create;
end;

destructor TMyThread.Destroy;
begin
  //ProgressData.Free; - НЕЛЬЗЯ ТУТ!
  inherited;
  ProgressData.Free;
end;

procedure TMyThread.Execute;
var
  CurrVal: Integer;
begin
  // Выставляем параметры компонента ProgressBar1
  SendMessage(FFormHandle, UM_PROGRESS_INIT, FMaxValue, 0);
  ThreadWaitTimeout(Self, 1000); // Просто пауза 1 сек.
  CurrVal := 0;
  // Выполняем некоторые вычисления
  while CurrVal < FMaxValue do
  begin
    if Terminated then Break;
    Inc(CurrVal);
    ProgressData.CurrValue := CurrVal;
    ProgressData.CalcResult := ProgressData.CalcResult + CurrVal;
    ProgressData.ThreadStateInfo := Format('Progress: %f%%',
      [CurrVal / FMaxValue * 100]);

    // Обновление прогресса выполняется только 1 раз из 10000
    if CurrVal mod 10000 = 0 then
      SendMessage(FFormHandle, UM_PROGRESS_CHANGE, WPARAM(ProgressData), 0);
  end;

  // Обновляем прогресс в конце вычислений
  SendMessage(FFormHandle, UM_PROGRESS_CHANGE, WPARAM(ProgressData), 0);
end;

end.

В данном примере используется два способа передачи данных в основной поток с использованием функции SendMessage. В первом способе (код UM_PROGRESS_INIT) целочисленное значение FMaxValue передаётся в качестве параметра wParam. Во втором способе (код UM_PROGRESS_CHANGE) передаётся ссылка на объект класса TProgressData в качестве параметра wParam. Для передачи ссылки на объект в качестве параметра wParam необходимо выполнять приведение типа к WPARAM следующим образом: WPARAM(ProgressData). С lParam ситуация аналогична (тип LPARAM).

Объект ProgressData создаётся в конструкторе TMyThread.Create, а уничтожается в деструкторе TMyThread.Destroy. Для использования объекта в методе TForm1.UMProgressChange требуется использовать приведение типа к TProgressData: ProgressData := TProgressData(Msg.WParam).

Обратите внимание, что уничтожать объект ProgressData в деструкторе TMyThread.Destroy следует после того, как был вызван inherited. Если уничтожать объект до вызова inherited, то при уничтожении объекта-потока (FMyThread.Free) будет возникать ошибка "Access violation" в том случае, если Вы попробуете закрыть программу, не дожидаясь окончания вычислений. Это объясняется следующим образом:
1) при вызове FMyThread.Free происходит вызов деструктора TMyThread.Destroy, в котором вызывается inherited;
2) при вызове inherited срабатывает родительский деструктор TThread.Destroy, в котором выставляется флаг Terminated;
3) внутри метода TMyThread.Execute всё ещё продолжается выполняться код и проверяется флаг Terminated. Объект ProgressData к этому моменту должен существовать, иначе при обращении к нему возникнет "Access Violation".

ℹ️ Обратите внимание, что при уничтожении объекта-потока (FMyThread.Free) главный поток не зависает, несмотря на то, что последней командой в методе TMyThread.Execute является SendMessage. Это происходит благодаря тому, что в деструкторе потока есть код, который обеспечивает предотвращение взаимной блокировки.

⚠️ Внимание! Функцией SendMessage Вы можете пользоваться только при разработке программы под Windows. Данная функция может быть недоступна при программировании под другие операционные системы! Универсальным (кроссплатформенным) способом передачи данных в основной поток является TThread.Synchronize либо TThread.Queue (в современных версиях Delphi).

5.5 Использование функции PostMessage для передачи очереди сообщений в основной поток

Функция PostMessage очень похожа на функцию SendMessage и также входит в состав Windows API. Однако существует принципиальная разница: функция PostMessage не является блокирующей, т.е. она вернёт управление сразу же после вызова. Из-за такой особенности Вы не всегда сможете использовать функцию PostMessage в тех же сценариях, что и SendMessage или Synchronize. При использовании функции PostMessage следует соблюдать осторожность, поскольку Вы можете столкнуться со следующими проблемами, которые не могут возникнуть при использовании SendMessage:

  1. Переполнение оконной очереди сообщений из-за слишком частого вызова функции PostMessage (т.е. главный поток попросту не сможет обрабатывать сообщения из оконной очереди с той же скоростью, с которой сообщения добавляются в оконную очередь).
  2. Задержка визуализации актуальной информации, поскольку обработка сообщений из оконной очереди выполняется по принципу FIFO. Допустим, дополнительный поток решил передать в основной поток 100 сообщений с помощью PostMessage, а главный поток при обработке каждого оконного сообщения выполняет запрос к базе данных, длительность которого составляет 1 секунду. Это приведёт к тому, что актуальная информация будет показана пользователю лишь спустя 100 секунд.
  3. Обращение к недействительному указателю и, как следствие, ошибка «Invalid pointer operation» либо «Access violation». Такую ошибку легко получить, если дополнительный поток выполнит создание объекта, затем он вызовет функцию PostMessage (передаст в неё ссылку на объект), а затем сразу же уничтожит созданный ранее объект. Ошибка возникнет в основном потоке, как только в нём произойдет вызов обработчика оконного сообщения и он попытается обратиться к объекту по недействительному указателю.

Пример использования функции PostMessage для передачи данных в очередь сообщений основного потока находится в папке «Ex10». К сожалению, объём кода в примере получился большим, поэтому считаю, что нет необходимости приводить здесь весь пример целиком. Ниже приведён код метода TMyThread.Execute, а также код обработчика сообщения UMProgressChange:

procedure TMyThread.Execute;
var
  CurrVal: Integer;
  CalcResult: Int64;
  ThreadStateInfo: string;
  ProgressData: TProgressData;
begin
  CurrVal := 0;
  CalcResult := 0;
  // Выполняем некоторые вычисления
  while CurrVal < FMaxValue do
  begin
    if Terminated then Break;
    Inc(CurrVal);
    CalcResult := CalcResult + CurrVal;
    ThreadStateInfo := Format('Progress: %f%%', [CurrVal / FMaxValue * 100]);

    // Обновление прогресса выполняется только 1 раз из 10000
    if CurrVal mod 10000 = 0 then
    begin
      // Создаём объект ProgressData непосредственно перед PostMessage
      ProgressData:=TProgressData.Create(CurrVal,CalcResult,ThreadStateInfo);
      PostMessage(FFormHandle, UM_PROGRESS_CHANGE, WPARAM(ProgressData), 0);
      Inc(PostMessageCount);
    end;
  end;

  // Обновляем прогресс в конце вычислений
  ProgressData := TProgressData.Create(CurrVal, CalcResult, ThreadStateInfo);
  PostMessage(FFormHandle, UM_PROGRESS_CHANGE, WPARAM(ProgressData), 0);
  Inc(PostMessageCount);

  // Этот флаг необходим, чтобы главный поток мог убедиться, что
  // доп. поток отработал корректно до последнего
  EndWork := True;
end;

procedure TForm1.UMProgressChange(var Msg: TMessage);
var
  ProgressData: TProgressData;
begin
  ProgressData := TProgressData(Msg.WParam);
  ProgressBar1.Position      := ProgressData.CurrValue;
  labResult.Caption          := IntToStr(ProgressData.CalcResult);
  labThreadStateInfo.Caption := ProgressData.ThreadStateInfo;
  Inc(FPostMsgProcessCount);
  // Здесь необходимо уничтожить объект TProgressData:
  ProgressData.Free;
end;

Обратите внимание на то, что объект ProgressData создаётся в методе TMyThread.Execute, а уничтожается в методе UMProgressChange.

В окне программы отображается количество вызовов функции PostMessage и количество вызовов обработчика UMProgressChange. В данном примере вызов функции PostMessage выполняется 1 раз из 10000.

⚠️ Если производить вызов 1 раз из 100 (или чаще), то будет происходить переполнение оконной очереди сообщений и обработчик UMProgressChange будет срабатывать меньшее количество раз по сравнению с количеством вызовов функции PostMessage. Для нашего примера это плохо тем, что будет происходить утечка памяти, поскольку перед каждым вызовом функции PostMessage выполняется создание объекта ProgressData. Если обработчик UMProgressChange будет срабатывать реже, чем PostMessage, то не все объекты ProgressData будут уничтожены.

5.6 Использование списка TThreadList для передачи очереди данных в основной поток

При разработке многопоточных программ очень часто возникает необходимость организовать передачу очереди данных между потоками. Для организации такой очереди можно использовать массивы или списки. Очень важно, чтобы массив / список был защищён от одновременного доступа из нескольких потоков. В простейшем случае мы можем защитить массив / список с помощью критической секции. В Delphi имеются специализированные классы, позволяющие организовать очередь сообщений для обмена данными между потоками. Одним из таких классов является TThreadList – потокобезопасный список. По сути, это обычный TList, в котором используется критическая секция. Он не допускает одновременного доступа к списку из разных потоков.

В примере Ex11 будет решена такая задача: несколько дополнительных потоков будут генерировать текстовую информацию (события), а главный поток будет отображать дату, время, номер потока и текст события в компоненте TListBox.
Ниже представлен листинг кода примера Ex11:

unit Ex11Unit;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, MTUtils, ComCtrls, ExtCtrls, StrUtils, Contnrs;

type
  TLogMessage = class
    ThreadId: Integer;
    EventTime: TDateTime;
    EventText: string;
    constructor Create(AThreadId: Integer; AEventTime: TDateTime; 
      AEventText: string);
  end;

  TMyThread = class(TThread)
  private
    procedure LogEvent(s: string);
  protected
    procedure Execute; override;
  end;

  TForm1 = class(TForm)
    btnRunInParallelThread: TButton;
    Timer1: TTimer;
    Button1: TButton;
    ListBox1: TListBox;
    procedure btnRunInParallelThreadClick(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure Timer1Timer(Sender: TObject);
    procedure FormCreate(Sender: TObject);
    procedure Button1Click(Sender: TObject);
  private
    { Private declarations }
    FList: TObjectList;
  public
    { Public declarations }
  end;

var
  Form1: TForm1;
  ThreadCounter: Integer;
  EventList: TThreadList;
implementation

{$R *.dfm}

procedure TForm1.btnRunInParallelThreadClick(Sender: TObject);
begin
  // Создаём и запускаем новый поток
  FList.Add(TMyThread.Create(False));
end;

procedure TForm1.Button1Click(Sender: TObject);
begin
  FList.Clear;
end;

procedure TForm1.FormCreate(Sender: TObject);
begin
  FList := TObjectList.Create;
  EventList := TThreadList.Create;;
end;

procedure TForm1.FormDestroy(Sender: TObject);
begin
  FList.Free;

  // В списке могут остаться элементы. Это не страшно, поскольку
  // выполняется выход из программы
  EventList.Free;
end;

procedure TForm1.Timer1Timer(Sender: TObject);
var
  L, TmpList: TList;
  I: Integer;
  m: TLogMessage;
  Cnt: Integer;
begin
  // Определяем, есть ли элементы в списке EventList
  L := EventList.LockList;
  try
    Cnt := L.Count;
  finally
    EventList.UnlockList;
  end;

  if Cnt = 0 then Exit;

  TmpList := TList.Create;
  try
    L := EventList.LockList;
    try
      // Переносим все элементы во временный список
      TmpList.Assign(L);
      // Очищаем список EventList
      L.Clear;
    finally
      // Как можно быстрее снимаем блокировку списка
      EventList.UnlockList;
    end;

    // Дальше обрабатываем элементы из временного списка
    for I := 0 to TmpList.Count - 1 do
    begin
      m := TLogMessage(TmpList[I]);
      if ListBox1.Count < 50000 then
        ListBox1.Items.Add(Format('%s [T:%d] - %s',
          [FormatDateTime('hh:nn:ss.zzz', m.EventTime), m.ThreadId, m.EventText]));
      // Здесь необходимо уничтожить объект TLogMessage
      m.Free;
    end;

    ListBox1.ItemIndex := ListBox1.Count - 1;
  finally
    TmpList.Free;
  end;
end;

{ TMyThread }

procedure TMyThread.Execute;
var
  I: Integer;
begin
  I := 0;
  LogEvent('Thread start');
  while not Terminated do
  begin
    Inc(I);
    LogEvent('Event #' + IntToStr(I));
    ThreadWaitTimeout(Self, 1000);
  end;
  LogEvent('Thread stop');
end;

procedure TMyThread.LogEvent(s: string);
var
  L: TList;
  m: TLogMessage;
begin
  m := TLogMessage.Create(GetCurrentThreadId, Now, s);
  L := EventList.LockList;
  L.Add(m);
  EventList.UnlockList;
end;

{ TLogMessage }

constructor TLogMessage.Create(AThreadId: Integer; AEventTime: TDateTime;
  AEventText: string);
begin
  ThreadId  := AThreadId;
  EventTime := AEventTime;
  EventText := AEventText;
end;

end.

В данном примере используется таймер TTimer, который срабатывает каждые 100 мс. В обработчике таймера TForm1.Timer1Timer выполняются следующие действия:

  1. Проверяется количество элементов в списке EventList;
  2. Если в списке EventList есть элементы, то они копируются во временный список TmpList, а из списка EventList все элементы удаляются;
  3. Элементы из списка TmpList выводятся в список ListBox1.

Обратите внимание, как следует пользоваться объектом класса TThreadList. Для начала необходимо выполнить блокировку списка с помощью метода LockList и запомнить ссылку на объект класса TList: L := EventList.LockList. Далее с полученной ссылкой вы можете производить любые действия (добавление, удаление, очистка элементов списка TList). После того, как Вы закончили работать со списком, необходимо его разблокировать. Для этого следует вызвать метод UnlockList.

ℹ️ Внимание! Не следует устанавливать блокировку на длительное время! Старайтесь проектировать свой код таким образом, чтобы длительность блокировки была как можно меньше.

Обратите внимание, что в обработчике TForm1.Timer1Timer вызов методов LockList / UnlockList выполняется дважды. Сначала мы блокируем список для доступа к количеству элементов (свойство TList.Count), а затем блокируем список EventList для копирования элементов во временный список TmpList. При этом для гарантированного снятия блокировки, а также для более удобного визуального выделения участка кода, на который действует блокировка, мы используем конструкцию try..finally. Подробное описание оператора try..finally смотрите в статье Описание оператора try..finally..end.

В данном примере используется функция GetCurrentThreadId из состава Windows API. Данная функция возвращает идентификатор того потока, который вызвал эту функцию. Идентификатор каждого потока в операционной системе является уникальным, вне зависимости от того, какой процесс его создал.

5.7 Использование метода TThread.Synchronize для передачи данных в основной поток – вариант с использованием анонимной процедуры

В современных версиях Delphi можно передавать анонимную процедуру при вызове метода TThread.Synchronize.

Анонимная процедура / функция, как следует из названия, не имеет имени и может использоваться в качестве параметра при вызове другой процедуры/функции. Механизм работы анонимных функций довольно сложный и у меня нет возможности его здесь описывать (в этом нет ничего страшного, поскольку в интернете есть много информации на данную тему). Хочу отметить, что анонимные функции давно являются нормой в современных языках программирования. Термин «анонимная функция» используется в Delphi (на мой взгляд, это не очень удачное название, поскольку очень часто анонимная функция присваивается переменной, а переменная имеет вполне конкретное имя). В других языках программирования данный механизм может называться иначе, например: делегат, замыкание, лямбда. В независимости от того, как этот механизм называется в том или ином языке, существует одна очень важная особенность: анонимная функция «видит» все переменные, которые являются внешними по отношению к тому месту, в котором она расположена. В случае передачи анонимной процедуры в метод Synchronize, её вызов будет произведён в контексте главного потока, однако внутри анонимной процедуры мы вполне можем использовать локальные переменные, которые были объявлены в методе Execute.

Ниже представлен простейший пример вызова метода Synchronize с анонимной процедурой из метода Execute:

  CurTime := Now; // Запоминаем время ДО вызова Synchronize
  Synchronize(
    procedure
    begin
      Form1.labLabLastThreadTime.Caption :=
        'Последний поток был запущен: ' + DateTimeToStr(CurTime);
    end);

5.8 Использование метода TThread.Queue для передачи данных в основной поток

В современных версиях Delphi имеется ещё один метод, позволяющий произвести вызов заданной процедуры в контексте основного потока: TThread.Queue. Данный метод имеет параметры, аналогичные методу TThread.Synchronize. Основное отличие от метода Synchronize заключается в том, что метод Synchronize является блокирующим, а метод Queue блокирующим не является. Также как и функция PostMessage, метод Queue возвращает управление немедленно. Метод Queue, как и метод Synchronize, может принимать в качестве параметра:
а) метод (процедуру);
б) анонимную процедуру;
в) обычную процедуру (в этом случае компилятор создаёт анонимную процедуру, из которой производится вызов обычной процедуры).

Суть работы метода состоит TThread.Queue в следующем:

  1. Метод Queue добавляет в конец списка SyncList (глобальная переменная типа TList) информацию, необходимую для вызова переданной процедуры.
  2. Основной поток периодически проверяет наличие элементов в списке SyncList. Если в списке обнаружен элемент, то производится запуск процедуры, хранящейся в списке.
  3. Метод TThread.Queue, вызванный в дополнительном потоке, вернёт управление немедленно, т.е. он не будет дожидаться, когда основной поток выполнит запуск заданной процедуры.

С появлением метода TThread.Queue многие Delphi-программисты стали гораздо реже использовать старые способы взаимодействия с основным потоком (Synchronize, SendMessage, PostMessage). Вероятная причина этого заключается в том, что дополнительный поток не блокируется при вызове Queue, и никак не зависит от того, насколько сильно в данный момент загружен главный поток программы. Метод TThread.Queue можно использовать в большинстве тех случаев, когда раньше приходилось использовать метод Synchronize. Если Вы используете метод Queue, то должны учитывать, что при вызове заданной процедуры в контексте основного потока дополнительный поток продолжает выполняться и существует риск одновременного обращения к объекту / структуре / массиву / строке из главного и из дополнительного потока. Мы помним, что в некоторых случаях мы должны защищать сложные объекты от одновременного доступа, например, с помощью критической секции, либо с помощью механизма TMonitor.

Пример Ex12 демонстрирует применение метода TThread.Queue для журналлирования событий, происходящих в дополнительном потоке.

unit Ex12Unit;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, MTUtils, ComCtrls, ExtCtrls, StrUtils, Contnrs,
  Generics.Collections;

type
  TMyThread = class(TThread)
  private
    procedure LogEvent(EventText: string);
  protected
    procedure Execute; override;
  end;

  TForm1 = class(TForm)
    btnRunInParallelThread: TButton;
    Button1: TButton;
    ListBox1: TListBox;
    labLabLastThreadTime: TLabel;
    btnClearListBox: TButton;
    procedure btnRunInParallelThreadClick(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure FormCreate(Sender: TObject);
    procedure Button1Click(Sender: TObject);
    procedure btnClearListBoxClick(Sender: TObject);
  private
    { Private declarations }
    FList: TObjectList<TMyThread>;
  public
    { Public declarations }
  end;

var
  Form1: TForm1;
implementation

{$R *.dfm}

procedure TForm1.btnClearListBoxClick(Sender: TObject);
begin
  ListBox1.Clear;
end;

procedure TForm1.btnRunInParallelThreadClick(Sender: TObject);
begin
  // Создаём и запускаем новый поток
  FList.Add(TMyThread.Create(False));
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  t: TMyThread;
begin
  for t in FList do
    t.Terminate; // Сообщаем потокам о необходимости завершаться
  FList.Clear; // Уничтожаем потоки
end;

procedure TForm1.FormCreate(Sender: TObject);
begin
  FList := TObjectList<TMyThread>.Create;
end;

procedure TForm1.FormDestroy(Sender: TObject);
begin
  FList.Free;
end;

{ TMyThread }

procedure TMyThread.Execute;
var
  I: Integer;
  CurTime: TDateTime;
begin
  CurTime := Now; // Запоминаем время ДО вызова Queue
  Queue(
    procedure
    begin
      Form1.labLabLastThreadTime.Caption :=
        'Последний поток был запущен: ' + DateTimeToStr(CurTime);
    end);

  LogEvent('Thread start');
  I := 0;
  while not Terminated do
  begin
    Inc(I);
    LogEvent('Event #' + IntToStr(I));
    ThreadWaitTimeout(Self, 500);
  end;
  LogEvent('Thread stop');
end;

procedure TMyThread.LogEvent(EventText: string);
var
  ThreadId: Cardinal;
  EventTime: TDateTime;
begin
  // Запоминаем ID потока и текущее время ДО вызова Queue
  ThreadId := GetCurrentThreadId;
  EventTime := Now;

  TThread.Queue(nil,
    procedure
    begin
      Form1.ListBox1.Items.Add(Format('%s [T:%d] - %s',
        [FormatDateTime('hh:nn:ss.zzz', EventTime), ThreadId, EventText]));
      Form1.ListBox1.ItemIndex := Form1.ListBox1.Count - 1;
    end);
end;

end.

Обратите внимание на следующие особенности данного примера:

  1. В методе TMyThread.LogEvent при вызове метода Queue используется следующая форма: TThread.Queue(nil, АнонимнаяПроцедура). Благодаря тому, что используется значение nil, анонимная процедура не ассоциируется с объектом дополнительного потока. Благодаря этому гарантируется, что основной поток обработает все вызовы Queue с параметром nil. Даже если дополнительный поток 1000 раз подряд вызовет метод Queue, а главный поток вызовет метод Free для объекта-потока, не успев обработать все вызовы Queue, объект потока будет уничтожен, но главный поток всё равно обработает все вызовы.
    Следует быть предельно внимательным при использовании такой формы вызова метода Queue. Дело в том, что если в анонимной процедуре будут обращения к полям / свойствам / методам объекта-потока, то в ситуации досрочного уничтожения объекта потока произойдёт ошибка доступа к памяти (например, «Invalid pointer operation», «Access violation» либо «Out of memory») при вызове процедуры в контексте основного потока.

  2. В анонимной процедуре, которая является параметром метода Queue в методе TMyThread.LogEvent, отсутствуют обращения к полям / свойствам / методам объекта-потока. Реализация данной анонимной процедуры никак не привязана к состоянию дополнительного потока. В ней не произойдет ошибки, даже если объект-поток будет уничтожен раньше времени.

  3. В методе TMyThread.LogEvent все данные, которые используются в анонимной процедуре, формируются заранее. В анонимной процедуре используются переменные EventTime, ThreadId, EventText. Переменные EventTime и ThreadId объявляются в методе TMyThread.LogEvent и им присваиваются значения до вызова Queue. Переменная EventText является параметром метода LogEvent, т.е. её значение также сформировано до вызова Queue.
    ℹ️ К сожалению, в метод Queue невозможно передать дополнительные параметры, которые в дальнейшем могли бы использоваться при вызове анонимной процедуры. Но благодаря тому, что переменные EventTime, ThreadId, EventText объявлены в методе LogEvent и их значения сформированы до вызова Queue, то мы можем их рассматривать как параметры вызова анонимной процедуры. При каждом вызове метода Queue (внутри метода LogEvent) переменные EventTime, ThreadId, EventText будут «захватываться», а затем использоваться при вызове анонимной процедуры в основном потоке.

  4. Метод Queue вызывается из отдельного метода TMyThread.LogEvent. Это очень важно! Если мы хотим эмулировать передачу параметров в анонимную функцию, то вызов метода Queue лучше выполнять из отдельного метода. Например:

procedure TMyThread.CallMyFuncInMainThread(param1: Integer; param2: string);
begin
  TThread.Queue(nil,
    procedure
    var
      s: string;
    begin
      s := Format('param1=%d; param2=%s', [param1, param2]);
      Form1.ListBox1.Items.Add(s);
    end);
end;

Вызов из Execute:

  I := 111;
  sVal := 'Text 1';
  CallMyFuncInMainThread(I, sVal);
  I := 222;
  sVal := 'Text 2';
  CallMyFuncInMainThread(I, sVal);

Благодаря этому, при передаче анонимной процедуры в метод Queue происходит захват параметров и переменных отдельного метода. Если бы мы в методе Execute выполнили 2 раза подряд вызов Queue вместо CallMyFuncInMainThread, например:

  I := 111;
  sVal := 'Text 1';
  TThread.Queue(nil,
    procedure
    var
      s: string;
    begin
      s := Format('param1=%d; param2=%s', [I, sVal]);
      Form1.ListBox1.Items.Add(s);
    end);

  I := 222;
  sVal := 'Text 2';
  TThread.Queue(nil,
    procedure
    var
      s: string;
    begin
      s := Format('param1=%d; param2=%s', [I, sVal]);
      Form1.ListBox1.Items.Add(s);
    end);

то для обоих вызовов анонимной процедуры использовались бы одни и те же значения захваченных переменных I и sVal. В этом случае в ListBox1 будет добавлено 2 одинаковых строки:
param1=222; param2=Text 2
param1=222; param2=Text 2

Конечно, если после первого вызова Queue поставить паузу (например, Sleep(100)), то главный поток скорее всего успеет выполнить вызов первой анонимной процедуры с правильными значениями 111 и «Text 1».

  1. В методе Execute присутствует вызов метода Queue, в который передаётся анонимная процедура, которая записывает в компонент labLabLastThreadTime время запуска дополнительного потока. Обратите внимание, что здесь используется форма вызова без указания nil. Это означает, что анонимная процедура будет ассоциирована с объектом потока, что в свою очередь означает, что при уничтожении потока необработанный главным потоком вызов Queue (вернее, информация, необходимая для вызова процедуры), будет удалена из очереди отложенных вызовов. В данном случае нет никаких рисков по следующим причинам:
    а) метод Queue вызывается в начале метода Execute, поток не завершится сразу же после данного вызова Queue (пользователь просто не успеет так быстро нажать кнопку «Остановить потоки»), значит главный поток успеет обработать данный вызов Queue. б) даже если главный поток не успеет выполнить отложенный вызов процедуры, а дополнительный поток по каким-то причинам будет уничтожен, необходимая для вызова отложенной процедуры информация попросту будет удалена из очереди. В данном случае это не приведёт к негативным последствиям, поскольку процедура не выполняет никакой ответственной работы.
    ℹ️ Внимание! Будьте внимательны при использовании формы вызова метода Queue без указания nil! В этом случае вызов отложенной процедуры может не произойти (если дополнительный поток был уничтожен до того, как главный поток произвёл вызов процедуры). В некоторый случаях это может приводить к некорректной работе программы (если программа написана таким образом, что вызов отложенной процедуры является обязательным), либо к утечке памяти (если отложенная процедура должна освободить память, выделенную перед вызовом метода Queue).

  2. В методе Execute организована пауза 500 мс между вызовами метода LogEvent в цикле.
    ⚠️ Внимание! Не следует вызывать метод Queue слишком часто. Если дополнительный поток будет вызывать метод Queue слишком часто, то главный поток программы не будет успевать обрабатывать элементы очереди SyncList, в результате чего размер списка отложенных вызовов SyncList будет увеличиваться, а интерфейс пользователя будет выглядеть зависшим.

  3. В данном примере для хранения ссылок на объекты-потоки используется список, объявленный следующим образом: FList: TObjectList<TMyThread>. Это позволяет избавиться от необходимости использовать операцию приведения типа (как при использовании обычного TObjectList).

Для исследования работы метода Queue вы можете использовать пример Ex12Full. Он наглядно демонстрирует проблему обработки отложенных вызовов при уничтожении дополнительных потоков. Его особенности следующие:

  1. Понижается приоритет дополнительного потока с помощью кода Priority := tpLowest. Это означает, что потоку будет отводиться гораздо меньше процессорного времени, чем обычно. Цель этого - сделать так, чтобы код, который выполняется в конце метода Execute (цикл от 1 до 100) занимал процессор на минимальное количество времени. По этим же причинам в данном цикле дополнительно вызывается Sleep(0). Благодаря этому главный поток начнёт обработку очереди отложенных вызовов раньше, чем завершится цикл, т.е. что-нибудь из этого цикла скорее всего попадёт в ListBox.

  2. Переменной ThreadWaitTimeoutSleepTime, которая используется в процедуре ThreadWaitTimeout, выставляется значение «1». Это означает, что процедура ThreadWaitTimeout завершит свою работу сразу же, как только объекту потока будет произведён вызов метода Terminate. Это важно в том случае, если Вы запустили несколько потоков. Благодаря этому у всех потоков цикл от 1 до 100 будет выполняться практически одновременно.

  3. В анонимной процедуре, которая реализована в методе TMyThread.LogEvent, вызывается Sleep(5). Это необходимо для того, чтобы главный поток не мог сразу (за свой квант времени) обработать все сообщения из очереди.

  4. Реализовано 2 способа вызова метода TThread.Queue в методе TMyThread.LogEvent: 1) ассоциировать анонимную процедуру с объектом потока; 2) не ассоциировать. Если выбран способ 1, то при уничтожении потоков в список ListBox попадут не все строки от 1 до 100. Это означает, что при уничтожении объекта-потока произошло удаление всех отложенных вызовов (т.е. записей из списка SyncList), которые ассоциированы с объектом потока, но ещё не обработаны главным потоком. Если выбран способ 2, то в ListBox попадут все строки от 1 до 100.

6. Планирование потоков

В разделе 1.3 было коротко сказано о том, что в операционной системе имеется планировщик (scheduler), который отвечает за распределение процессорного времени между потоками. Иногда его называют "диспетчер ядра" (kernel’s dispatcher). Я считаю, что читатель должен иметь хотя бы минимальное представление о работе планировщика. Благодаря этому усвоение такого сложного материала, как синхронизация между потоками, должно пройти легче.

Для получения дополнительной информации о работе механизма планирования потоков в Windows рекомендую изучить документацию на сайте microsoft.com.

Если Вы хотите вникнуть в детали реализации планировщика, то рекомендую статью Марка Руссиновича и Дэвида Соломона Processes, Threads, and Jobs in the Windows Operating System.

6.1 Роль системного таймера (system clock)

Как уже было сказано в разделе 1.3, системный планировщик автоматически выполняет действия, необходимые для перевода потока в спящий режим, как только поток истратит выделенный ему квант времен (time slice), либо процессорное время потребуется потоку с более высоким приоритетом. Также поток может самостоятельно перейти в спящий режим в тех случаях, если он выполнил свою работу, либо перешёл в режим ожидания события. Каждый раз, когда поток переходит в спящий режим, происходит сохранение его контекста (сохраняется текущее состояние регистров процессорного ядра, на котором поток выполняется). При возобновлении работы потока выполняется обратное действие – восстановление контекста потока (загрузка ранее сохраненных значений в регистры процессорного ядра).

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

ℹ️ Информация В ОС Windows адресное пространство системы отделено от адресного пространства пользовательских программ. Если пользовательская программа вызывает функцию ядра (например, Sleep, SwitchToThread, SetEvent и др.), то ОС выполняет переключение процессора в режим ядра (kernel mode), после которого дальнейший код этих функций выполняется в контексте адресного пространства ядра операционной системы). При завершении работы функций ядра производится обратное переключение процессора в пользовательский режим и восстановление состояния регистров процессора.

Процесс планирования работы потоков выполняется системным планировщиком периодически при каждом срабатывании системного таймера (system clock). Раньше системный таймер представлял собой отдельную микросхему, которая выдавала на процессор аппаратный сигнал прерывания приблизительно раз в 16 миллисекунд. Что представляет собой современный системный таймер, я не знаю. Вероятно, он встроен в процессор или эмулируется программно.

Логика обработки прерывания от системного таймера примерно такая:

  1. системный таймер подаёт аппаратный сигнал прерывания на процессор;
  2. осуществляется запуск системного планировщика на одном из ядер процессора;
  3. планировщик анализирует состояние всех потоков в системе и принимает решение о необходимости запуска или приостановки того или иного потока (он сохраняет необходимую информацию в память ядра ОС);
  4. планировщик подаёт на процессор сигнал программного прерывания APC (асинхронный вызов процедуры) в том случае, если необходимо приостановить либо возобновить работу потока на том или ином ядре процессора (если же требуется остановить либо запустить поток на том же ядре, на котором выполняется планировщик, то есть возможность обойтись без сигнала прерывания APC);
  5. выполняется обработка сигнала прерывания APC (на том или ином ядре процессора), в результате которой одни потоки приостанавливаются, а другие возобновляют свою работу;

Частота срабатывания (разрешение) системного таймера составляет от 1 мс до 16 мс. Причем, по умолчанию используется значение 16 мс. Но это значение очень часто переопределяется программным способом (некоторые программы его меняют на 1 мс). На практике приходится видеть только одно из двух значений: 1 мс либо 16 мс. Для того, чтобы определить разрешение системного таймера, Вы можете воспользоваться утилитой Марка Руссиновича "Clockres.exe". Значение "Current timer interval" показывает разрешение системного таймера.

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

⚠️ Внимание! Не следует увеличивать частоту срабатывания системного таймера! Программы от этого работать быстрее не будут! Наоборот, общая производительность снизится (по имеющимся оценкам, более чем на 2%).

ℹ️ Частота срабатывания системного таймера сильно влияет на работу функции Sleep! Если таймер срабатывает очень часто (1000 раз в секунду), то функция Sleep работает с максимальной точностью (плюс/минус 0.5 мс). Если таймер срабатывает раз в 16 мс, то и точность работы функции Sleep составит примерно 16 мс.

ℹ️ Разрешение системного таймера никак не влияет на функции GetTickCount и GetTickCount64. Их точность соответствует максимальному интервалу системного таймера, т.е. примерно 16 мс. В связи с этим я не советую использовать эти функции для измерения интервалов времени (хотя вроде для этого они и были предназначены). Если Вас устраивает точность замеров 1 мс, то лучше используйте обычную функцию Now (она возвращаёт текущее время с точностью до миллисекунды в том случае, если разрешение системного таймера выставлено в 1 мс). Если требуется производить замеры в большей точностью, то используйте функцию QueryPerformanceCounter, либо более удобный модуль "TimeIntervals.pas", который ранее уже был рассмотрен.

Отметим также следующие особенности планирования потоков в Windows:

  1. Для каждого ядра процессора планировщик создаёт свою очередь потоков, готовых к запуску. При обращениях к планировщику, не связанных с работой системного таймера (например, при вызове функций ядра Sleep, SwitchToThread, SetEvent), анализ всех потоков не выполняется. Учитывается только информация, заранее подготовленная планировщиком, поэтому такой код диспетчера ядра выполняется максимально быстро.
  2. При вызове функции SwitchToThread выполняется анализ очереди потоков, готовых к запуску, на том же ядре, на котором был запущен поток, который вызвал функцию SwitchToThread. Переключение на другой поток произойдёт максимально быстро, за несколько микросекунд (т.к. не требуется использовать прерывание APC), либо переключения на другой поток не произойдёт.
  3. Функция Sleep(0) отличается от SwitchToThread тем, что учитываются потоки, готовые к запуску, не только на этом, но и на других ядрах процессора. Если на ядре CPU1 поток вызвал функцию Sleep(0), а на ядре CPU2 имеется готовый к запуску поток, то его запуск может быть отменён на ядре CPU2 и произведён на ядре CPU1 (скорее всего без использования прерывания APC, поэтому переключение на другой поток займёт всего несколько микросекунд). Если готового к запуску потока нет ни на одном ядре процессора, то произойдет выход из функции Sleep(0).

⚠️ Я привёл здесь очень упрощенный алгоритм работы планировщика. Это лишь вершина айсберга. На самом деле эффективное планирование потоков - это очень сложная задача, при решении которой необходимо учитывать тысячи нюансов. Я думаю, что общий объём кода диспетчера ядра Windows может достигать сотен тысяч строк программного кода.

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

Очевидно, что чем больше потоков, тем хуже производительность системы. Я предполагаю, что при каждом запуске системного планировщика по прерыванию от системного таймера он производит анализ всех запущенных потоков, независимо от их состояния. Чем больше потоков, тем больше действий будет выполнять планировщик.

Однако большое количество потоков, находящихся в спящем состоянии, не оказывает никакого влияния на процесс переключения контекста между активными потоками. Т.е. если поток вызвал функцию Sleep(0) или SwitchToThread, то учитываются данные, заранее подготовленые планировщиком, лишние действия не выполняются.

6.3 Приоритеты потоков в Windows

При разработке многопоточной программы для Windows существует возможность назначать потоку уровень приоритета (свойство TThread.Priority). По умолчанию используется уровень приоритета tpNormal. Тип TThreadPriority объявлен следующим образом:

{$IFDEF MSWINDOWS}
  TThreadPriority = (tpIdle, tpLowest, tpLower, tpNormal, tpHigher, tpHighest,
    tpTimeCritical) platform;
{$ENDIF MSWINDOWS}

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

ℹ️ Внимание! У приложения, которое находится на переднем плане, длительность кванта времени увеличивается примерно в 3 раза. В том случае, если два приложения будут привязаны к одному ядру процессора, то одну и ту же вычислительную задачу быстрее (примерно в 3 раза) сможет решить приложение, которое находится на переднем плане. На моих компьютерах с Windows 7 длительность кванта времени у приложений на заднем плане (думаю, что и у служб тоже) составляет 32 мс, а у приложений на переднем плане - 96 мс.

Уровень приоритета потока влияет на выделение процессорного времени как между потоками в рамках одного процесса, так и между потоками различных процессов.

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

Если в Вашей программе работают 2 потока (оба выполняют математические вычисления), у одного уровень приоритета tpNormal, а у второго tpLower и оба потока привязаны к одному и тому же ядру процессора, то первому потоку гораздо чаще будет предоставляться процессорное время (например, 98%). С другой стороны, если у этих же потоков не будет привязки к одному и тому же ядру, то они получат одинаковое процессорное время на разных ядрах.

Существует 2 крайних уровня приоритетов: tpIdle и tpTimeCritical. Поток с приоритетом tpIdle получит процессорное время только в том случае, если нет других активных потоков с более высоким приоритетом. Поток с приоритетом tpTimeCritical будет забирать себе всё процессорное время, т.е. потоки с более низким приоритетом не получат квант времени, если выполняется поток с приоритетом tpTimeCritical. Это верно для одноядерного процессора. Если процессор многоядерный (сейчас это обычная ситуация), то, скорее всего, будут выполняться одновременно и поток с приоритетом tpIdle и поток с приоритетом tpTimeCritical.

ℹ️ Внимание! В каталоге CalcTimeQuant находится программа, позволяющая производить эксперименты с приоритетами и замеры длительности квантов времени.

ℹ️ Помимо терминов "уровень приоритета потока" и "класс приоритета процесса", существует также термин "базовый приоритет потока". Как определить базовый приоритет потока, смотрите в таблице приоритетов.

7. Основные объекты синхронизации

ОС Windows (возможно, и Linux, а также и другие ОС) предлагает множество различных объектов ядра для реализации задач синхронизации между потоками и между процессами, в том числе: Event, Mutex, Semaphore, Thread, Process и другие. Их объединяет то, что каждый такой объект создается в памяти ядра ОС, поэтому доступ к любому из объектов может быть запрошен из любого приложения. Таким образом, из одного приложения есть возможность дождаться, например, окончания потока, запущенного в другом приложении. Либо в одном приложении можно создать объект события "Event", а в другом приложении вызвать функцию из семейства WaitFor, которая будет ожидать возникновения события в первом приложении.

Независимо от того, что используется в качестве объекта синхронизации, для отслеживания состояния объекта синхронизации используется одна из функций из семейства WaitFor, например: WaitForSingleObject, WaitForSingleObjectEx, WaitForMultipleObjects, WaitForMultipleObjectsEx, MsgWaitForMultipleObjects, MsgWaitForMultipleObjectsEx. Все эти функции уникальны тем, что работают на уровне системного планировщика (диспетчера ядра). Прикладной программист (мы с вами) не имеет возможности реализовать аналогичную функцию.

Я могу предположить логику работы функции WaitForSingleObject. В момент вызова этой функции (в неё передается ссылка на объект синхронизации, а также максимальное время ожидания в миллисекундах) системный планировщик добавляет в структуры объекта синхронизации информацию о потоке, который вызвал функцию WaitForSingleObject. Как только изменяется состояние объекта синхронизации, планировщик определяет список потоков, ожидающих данный объект на вызове WaitFor, выбирает из очереди первый ожидающий поток и запускает его (например с использованием механизма APC). Сразу после этого функция WaitFor возвращает результат (например, WAIT_OBJECT_0, если объект синхронизации перешёл в состояние signaled).

Запуск потока, ожидающего на вызове WaitFor в случае изменения состояния объекта синхронизации на signaled, выполняется весьма быстро (от 5 до 50 микросекунд, в зависимости от способа, который ОС выбрала для запуска потока). В том случае, если объект синхронизации не перешёл в состояние signaled, а время ожидания закончилось, то функция WaitFor возвращает результат WAIT_TIMEOUT по срабатыванию системного таймера. Т.е., если мы укажем максимальное время ожидание 1 мс, то фактическое время ожидания может составить от 1 до 16 мс, в зависимости от разрешения системного таймера.

Преимуществом объектов синхронизации уровня ядра по сравнению с примитивами синхронизации уровня пользователя, основанными на spin-блокировках, является меньшее потребление ресурсов процессора при длительных интервалах ожидания. Например, если мы решим 100 миллисекунд покрутить цикл spin-блокировки, то процессор окажется на 100% загружен всё это время, а другие потоки не получат необходимый им ресурс процессора. Если же мы будем ожидать 100 мс на объекте синхронизации ядра, то процессор на это время будет доступен для других потоков и в целом компьютер сможет выполнить больший объем полезной работы. С другой стороны, если с помощью spin-блокировки выполняется защита простого участка кода, выполняемого за пару микросекунд, то накладные расходы на spin-блокировку могут оказаться ниже, чем расходы системного планировщика на объект синхронизации.

В любом случае, для защиты критического участка кода от одновременного доступа из нескольких потоков, чаще всего (в ОС Windows) наиболее эффективным является использование примитива синхронизации ОС "критическая секция". Критическая секция сначала пытается использовать цикл spin-блокировки (в режиме user-mode), а затем, если защищаемый ресурс так и не освободился, то переходит к использованию объекта синхронизации уровня ядра "мьютекс" (в режиме kernel-mode). Вместо критической секции в современных вериях Delphi Вы можете использовать System.TMoninor (метод TMonitor.Enter для начала защиты участка кода и TMonitor.Leave для окончания защиты. При этом для блокировки в режиме kernel-mode используется объект Event, а не Mutex).

7.1 Главный поток управляет работой дополнительного потока с помощью объекта "Event" (событие)

В любой ОС (Windows, Unix, Linix, MacOS, Android, iOS и т.д.) существует очень важный объект синхронизации - Event (событие). С его помощью можно синхронизировать работу нескольких процессов, либо работу нескольких потоков в рамках одного процесса. Очень часто объект Event используется при ожидании события завершения ввода/вывода. Например, при работе с COM-портом мы можем создать объект Event и попросить ОС, чтобы она вызвала SetEvent в тот момент, когда в порту появились новые данные. При этом наш код может ожидать появления новых данных с помощью функции из семейства WaitFor. Аналогично можно поступить при организации обмена данными по сети, либо при работе с файлами.

Но я хочу продемонстрировать простой пример использования объекта Event для управления работой дополнительного потока по команде из главного потока. В данном примере (папка ExSync\ExEvent\FastStopThread) дополнительный поток периодически (каждые 5 секунд) выполняет полезную работу (вызывает метод DoUsefullWork). Пауза 5 секунд организована с помощью кода Event.WaitFor(5 * 1000).

На главной форме приложения находятся 3 кнопки: "Запустить поток", "Разбудить поток" и "Остановить поток". При нажатии кнопки "Запустить поток" выполняется создание дополнительного потока:

MyThread := TMyThread.Create(cbUseProgressViewer.Checked);

Конструктор потока выглядит следующим образом:

constructor TMyThread.Create(UseProgressViewer: Boolean);
const
  STATE_NONSIGNALED = FALSE;
  AUTO_RESET  = FALSE;
begin
  inherited Create(False);
  // Создаём объект "Event" в состоянии "nonsignaled" и просим, чтобы
  // он автоматически переходил в состояние "nonsignaled" после WaitFor
  Event := TEvent.Create(nil, AUTO_RESET, STATE_NONSIGNALED, '', False);
  FUseProgressViewer := UseProgressViewer;
end;

Для работы с объектом ядра "Event" используется класс-обёртка TEvent. Данная обёртка является кроссплатформенной и поддерживает все платформы, под которые Delphi позволяет скомпилировать приложение.

В конструкторе объявлены 2 констатны: STATE_NONSIGNALED и AUTO_RESET. Эти константы используются при создании объекта Event. Константа STATE_NONSIGNALED означает, что объект Event должен быть создан в "несигнальном" состоянии.

ℹ️ Информация! Объект Event может находиться в одном из двух состояний: "сигнальное" или "несигнальное". Если он находится в сигнальном состоянии, то функция WaitForSingleObject (либо другая функция из семейства WaitFor), вызванная для данного объекта Event, завершит свою работу немедленно, при этом результат будет равен WAIT_OBJECT_0. Если же объект Event находится в несигнальном состоянии, то функция WaitForSingleObject не вернёт управление, пока не закончится заданный таймаут (результат будет WAIT_TIMEOUT), либо пока объект не перейдёт в сигнальное состояние (результат будет WAIT_OBJECT_0). В случае некорректной работы с объектом синхронизации функция WaitForSingleObject может вернуть WAIT_FAILED либо WAIT_ABANDONED.

Для чего нужна константа AUTO_RESET? Если при создании объекта Event передан параметр ManualReset=True, то функция WaitFor оставляет объект Event в состоянии "signaled" (если он был создан в состоянии "signaled", либо был переведён в состояние "signaled" с помощью функции SetEvent). В этом случае дальнейшие вызовы WaitFor будут выполняться мгновенно (каждый раз возвращать результат WAIT_OBJECT_0), поэтому в таком режиме мы не сможем использовать объект Event для организации паузы, либо нам придётся каждый раз вручную вызывать функцию ResetEvent для перевода объекта Event в несигнальное состояние. Константа AUTO_RESET (по сути это ManualReset=False) избавляет нас от необходимости вызова дополнительной функции ResetEvent.

Метод TMyThread.Execute реализован следующим образом:

procedure TMyThread.Execute;
var
  WaitRes: TWaitResult;
  pv: TProgressViewer;
begin
  pv := nil; // Чтобы компилятор не выдавал Warning
  while not Terminated do
  begin
    // Внимание! Визуализация ожидания события вносит значительные накладные
    // расходы и значительно увеличивает время уничтожения потока!
    if FUseProgressViewer then
      pv := TProgressViewer.Create('Ожидание события');

    WaitRes := Event.WaitFor(5 * 1000); // Ожидание события - 5 секунд

    if WaitRes = wrSignaled then
      tiSignalled.Stop; // Останавливаем измерение времени

    if FUseProgressViewer then
      pv.TerminateProgress;

    // Выполняем полезную работу если окончился таймаут ожидания (wrTimeout),
    // либо если произошёл вызов метода WakeUp
    if (WaitRes = wrTimeout) or ((WaitRes = wrSignaled) and (not Terminated)) then
      DoUsefullWork;
  end;
end;

Здесь реализован цикл while, на каждой итерации которого проверяется флаг Terminated. Если Terminated=True, то происходит выход из цикла и завершение работы дополнительного потока. На каждой итерации цикла выполняется код WaitRes := Event.WaitFor(5 * 1000). Это пауза 5 секунд. В принципе, в предыдущих примерах мы уже решали задачу организации паузы в работе дополнительного потока. Для этого в модуль MTUtils.pas была добавлена функция ThreadWaitTimeout. Основной недостаток функции ThreadWaitTimeout заключается в том, что для организации паузы используется цикл, в котором многократно вызывается функция Sleep(ThreadWaitTimeoutSleepTime), где ThreadWaitTimeoutSleepTime по умолчанию равен 20 мс. Т.е. если мы решили завершить работу потока, то фактически он может завершиться лишь спустя 20 мс. Для большинства задач этого достаточно.

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

а) при вызове функции Sleep выполняется переключение процессора в режим ядра с сохранением контекста работы потока;
б) планировщик в контексте вызова функции Sleep анализирует состояние очереди "готовых" потоков для каждого ядра процессора, при необходимости запускает "готовый" поток и выполняет кучу важной работы;
в) каждый раз по срабатыванию системного таймера запускается процесс планирования потоков, в результате которого возобновляется работа нашего потока (если окончился таймаут ожидания 1 мс).

А представьте, что такой поток не единственный, а их 1000 штук! В этом случае большая часть ресурсов процессора будет занята выполнением описанного выше алгоритма!

Если же ожидание организовано с помощью объекта Event, то таких проблем нет! Даже если будут 1000 потоков, каждый из которых ожидает свой объект Event с помощью функции WaitFor, нагрузка на процессор будет минимальной. Возобновление работы потоков будет выполняться только в тех случаях, когда это необходимо (закончился таймаут ожидания либо объект Event перешёл в состояние "signaled"). Более того, возобновление работы потока при переходе объекта Event в состояние "signaled" будет произведено почти моментально (всего за несколько микросекунд), вне зависимости от разрешения системного таймера.

Обратите внимание, что в данном примере Вы можете оценить скорость возобновления работы потока после вызова SetEvent. Для этого следует нажать кнопку "Разбудить поток" и в строке "Возобновление потока после SetEvent заняло" появится значение в микросекундах.

Также необходимо строго соблюдать порядок вызовов метода Terminate и SetEvent в деструкторе потока:

destructor TMyThread.Destroy;
begin
  // Очень важно, чтобы вызов Terminate был раньше вызова SetEvent!
  Terminate;      // 1. Выставляем флаг Terminated
  tiSignalled.Start;
  Event.SetEvent; // 2. Переводим Event в состояние SIGNALED
  inherited;      // 3. Дожидаемся выхода из метода Execute
  Event.Free;     // 4. Уничтожаем объект Event
end;

Мы знаем, что при уничтожении дополнительного потока из основного потока деструктор отрабатывает в контексте основного потока. Как только произойдет вызов Event.SetEvent, планировщик может приостановить работу основного потока и возобновить работу дополнительного потока на том же ядре процессора. Если к этому моменту не был вызван метод Terminate, то выход из метода TMyThread.Execute не произойдет и будет в очередной раз вызван метод Event.WaitFor(5 * 1000). Если пауза в WaitFor больше (несколько минут), то программа подвиснет на несколько минут, пока деструктор объекта-потока не завершит свою работу. Такой глюк не всегда просто поймать, поскольку в большинстве случаев при вызове SetEvent планировщик не приостанавливает работу основного потока.

7.2 Использование объекта "Event" для разработки потока логгирования

В папке ExSync\ExEvent\SimpleLogger находится проект, в котором демонстрируется разработка простейшего потока логгирования.

При разработке программного обеспечения очень важно иметь возможность записи в log-файл различных событий, происходящих в программе. Чем подробнее будет информация в log-файле (конечно, в разумных пределах), тем проще разобраться с причинами проблемы, происходящей у клиента (заказчика). Существуют множество готовых систем логгирования для Delphi, в том числе LDSLogger (моей разработки), LoggerPro и многие другие.

Идея данного примера заключается в том, чтобы показать:
а) пример простейшей системы логгирования; б) наличие проблемы с производительностью при попытке записи в log-файл без использования дополнительного потока; в) пример использования объекта Event для немедленного информирования потока о наличии новой информации для записи в log-файл; г) пример организации очереди (из строк) для передачи информации в дополнительный поток.

Реализация потока логгирования находится в модуле CommonUtils\MTLogger.pas. Наименование класса потока: TLoggerThread.

Попробуйте скомпилировать проект SimpleLogger.dpr и запустить программу. Нажмите кнопку "Добавить сообщения в лог-файл без дополнительного потока". Программа покажет на экране время, которое заняла данная операция. У меня для 10000 сообщений уходит более 20 секунд.
Теперь нажмите кнопку "Добавить сообщения в лог-файл через дополнительный поток". У меня для 10000 сообщений уходит 20 миллисекунд. Разница в 1000 раз! Впечатляет?

Причины такой огромной разницы в следующем:

  1. Функция записи строки в лог файл реализована следующем образом:
procedure WriteStringToTextFile(AFileName: string; Msg: string);
var
  AFile: TextFile;
begin
  try
    // Открываем файл при каждом добавлении строки! При большом объеме записи
    // в лог файл это очень нерационально!
    AssignFile(AFile, AFileName);
    if FileExists(AFileName) then
      Append(AFile)
    else
      Rewrite(AFile);
    Writeln(AFile, Msg);
    CloseFile(AFile);
  except
    on E: Exception do
    begin
      // Внимание! В реальном приложении выдача пользователю сообщения об ошибке
      // записи в лог-файл недопустима!
      if AllowMessageBoxIfError then
        ThreadShowMessageFmt('Ошибка при записи в файл [%s] строки "%s": %s', [AFileName, Msg, E.Message]);
    end;
  end;
end;

Каждый раз файл открывается, а после добавления строки закрывается. У меня каждый вызов этой функции выполняется порядка 2-х миллисекунд (на быстром SSD это может происходить намного быстрее).

  1. Без дополнительного потока данная функция вызывается 10000 раз, что по времени составляет более 20 секунд. При этом основной поток зависает на те же 20 секунд.

  2. Наличие дополнительного потока позволяет минимизировать количество вызовов функции WriteStringToTextFile. Перед вызовом данной функции выполняется составление большой строки, состоящей из множества сообщений, накопленных в буфере (см. реализацию метода TLoggerThread.Execute).

  3. При использовании потока TLoggerThread функция WriteStringToTextFile вызывается из дополнительного потока, а не из основного. Даже если функция WriteStringToTextFile будет выполняться в несколько раз дольше, на работу основного потока это никак не повлияет!

Обратите внимание на реализацию метода TLoggerThread.Execute:

procedure TLoggerThread.Execute;
var
  TmpList: TStringList;
begin
  while True do
  begin
    // Завершаем работу потока в том случае, если пользователь выходит из программы,
    // а поток успел скинуть в лог-файл все сообщения из списка LogStrings
    if Terminated and (LogStrings.Count = 0) then Exit;

    // Ожидаем переключения объекта Event в состояние signaled, но не более 2х секунд
    // При вызове метода TLoggerThread.AddToLog выполняется вызов Event.SetEvent,
    // однако лучше перестраховаться и не делать ожидание бесконечным
    Event.WaitFor(2000);

    // Проверяем свойство Count без критической секции (это безопасно)
    if LogStrings.Count > 0 then
    begin
      TmpList := TStringList.Create;
      try
        // 1. Входим в критическую секцию
        CritSect.Enter;
        try
          // 2. Копируем все строки из LogStrings в TmpList
          TmpList.Assign(LogStrings);
          // 3. Очищаем список LogStrings
          LogStrings.Clear;
        finally
          // 4. Выходим из критической секции
          CritSect.Leave;
        end;

        // Список TmpList является локальным, поэтому его не нужно защищать
        // критической секцией.

        // 5. За одно действие записываем все строки из списка TmpList в лог-файл
        WriteStringToTextFile(FLogFileName, Trim(TmpList.Text));
      finally
        TmpList.Free;
      end;
    end;
  end;
end;

Здесь используется временный список TmpList, в который копируется текущее содержимое списка LogStrings, причем операция копирования и очистки списка LogStrings защищена с помощью критической секции. Благодаря временному списку минимизируется время нахождения программы в критической секции (копирование строк занимает максимум пару миллисекунд). Сохранение содержимого списка TmpList находится вне критической секции, поэтому длительность сохранения в файл никак не влияет на основной поток.

Метод добавления стоки для записи в log-файл реализован следующим образом:

procedure TLoggerThread.AddToLog(const Msg: string);
begin
  CritSect.Enter;
  try
    LogStrings.Add(Format('%s [P:%d T:%d] - %s', [FormatDateTime('dd.mm.yyyy hh:nn:ss.zzz', Now),
      GetCurrentProcessId, GetCurrentThreadId, Msg]));
  finally
    CritSect.Leave;
  end;
  Event.SetEvent;
end;

Здесь список LogStrings также защищён с помощью критической секции, а после добавления новой строки вызывается Event.SetEvent. При вызове метода Event.SetEvent происходит прерывание метода Event.WaitFor(2000), с помощью которого поток ожидает появление данных в списке LogStrings.

Благодаря явно заданному таймауту 2000 мс мы исключаем вероятность возникновения редкого состояния, при котором основной поток добавил новое событие и вызвал Event.SetEvent, а дополнительный поток параллельно был разбужен предыдущим вызовом SetEvent и проигнорировал новый вызов SetEvent (т.е. перевод объекта Event в состояние "NONSIGNALED" при прерывании метода Event.WaitFor(2000) происходит одновременно с новым вызовом Event.SetEvent, поэтому новый вызов может быть проигнорирован).

7.3 Пара слов об объекте "Mutex"

Я рамках данной работы я не вижу большого смысла подробно останавливаться на объекте ядра "Mutex". Если у читателя есть желание с ним познакомиться поближе, существует бесконечный океан информации в Интернете. Delphi-программисту для работы с мьютексом доступны API-функции Windows: CreateMutex, OpenMutex, ReleaseMutex, WaitForSingleObject, WaitForMultipleObjects и т.д. Также доступен кроссплатформенный класс-обёртка TMutex из модуля "System.SyncObjs".

Отмечу вкратце только основные моменты:

  1. Для объекта Mutex применяется понятие "владение" (owned). У мьютекса может быть только один владелец. Первый поток, применивший к мьютексу операцию WaitForXXX (одна из функцию из семейства WaitFor) автоматически становится владельцем мьютекса, а мьютекс переходит в состояние "занят". Как только поток-владелец вызывает функцию ReleaseMutex, он перестаёт быть владельцем, а мьютекс переходит в состояние "свободен". При этом диспетчер ядра проверяет очередь потоков, ожидающих освобождения мьютекса на вызове функции WaitForXXX и передаёт мьютекс во владение первому потоку из очереди (если такая очередь вообще имеется) и работа очередного потока-владельца возобновляется.

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

  3. Не следует защищать с помощью мьютекса критические участки кода, которые выполняются очень быстро (несколько микросекунд) и не связаны с операциями ввода/вывода. В таких случаях выгоднее использовать критическую секцию / TMonitor / spin-блокировку / атомарные функции.

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

  5. Сам факт наличия созданного объекта "именованный мьютекс" в одном приложении может использоваться при принятии решений в другом приложении. Например, если первый экземпляр программы создал именованный мьютекс с именем "MyProgramStartProtect", то второй экземпляр после создания мьютекса с таким же именем получит GetLastError = ERROR_ALREADY_EXISTS, после чего можно прервать запуск второго экземпляра приложения.

  6. Поток, владеющий мьютексом, может повторно вызвать функцию WaitFor по отношению к этому мьютексу. В этом случае блокировка потока не выполняется, но поток должен гарантированно вызвать функцию ReleaseMutex. Например так:

WaitForSingleObject(hMutex, INFINITY);
try
  some code...
finally
  ReleaseMutex(hMutex);
end;

⚠️ Внимание! Не забывайте проверять результат функции WaitForXXX даже с параметром INFINITY. Она может вернуть управление не только при успешном окончании ожидания мьютекса, но также и в случае ошибки (например, если другой поток вызвал CloseHandle по отношению к этому же мьютексу, а Handle у мьютекса был единственным, либо мьютексом владел поток из другого процесса, но процесс завершился без вызова ReleaseMutex).

7.4 Пара слов об объекте "Semaphore"

Семафор - старейший объект синхронизации режима ядра. Его реализация присутствует во всех популярных ОС. На его основе разработано множество различных алгоритмов синхронизации. Очень хорошо данная тема рассмотрена в wiki-статье Семафор (программирование).

Delphi-программисту для работы с семафором доступны API-функции Windows: CreateSemaphore, OpenSemaphore, ReleaseSemaphore, WaitForSingleObject, WaitForMultipleObjects и т.д. Также доступы кроссплатформенные класс-обёртка TSemaphore и класс TLightweightSemaphore (легковесный семафор) из модуля "System.SyncObjs".

ℹ️ Конкретный пример использования семафора. Есть множество клиентов, которые формируют отчёт через веб-интерфейс. Есть сервер, который обрабатывает запросы от браузеров, формирует отчёт и возвращает результат. Возможности сервера ограничены. Если он будет формировать одновременно более 10 отчётов, то столкнётся с аппаратными ограничениями компьютера (объём ОЗУ, количество ядер процессора, скорость накопителя). Например, при формировании 20 отчётов 32-битный процесс сервера упрётся в ограничение виртуального адресного пространства (2 ГБ). Если количество ядер недостаточно, то может быть другая ситуация: сервер будет слишком медленно выполнять другие задачи (например, обмен с торговыми точками или майнинг криптовалюты :). Все эти проблемы легко решить с помощью семафора: создаём семафор и указываем ограничение на 10 потоков.

7.5 Критическая секция и монитор

Примитив синхронизации "Критическая секция" уже упоминался в данной статье. Для работы с критической секцией в Delphi рекомендуется использовать кроссплатформенный класс TCriticalSection из модуля "System.SyncObjs". Это наиболее популярный примитив синхронизации для защиты различных объектов / структур / массивов от одновременного доступа из разных потоков.

Также вместо класса TCriticalSection Вы можете использовать System.TMonitor (методы TMonitor.Enter и TMonitor.Leave). Более того, версия TCriticalSection для ОС, отличающихся от Windows, основаны именно на System.TMonitor. С точки зрения производительности критическая секция и монитор практически не отличаются. TMonitor в Delphi - это попытка скопировать класс Monitor, который ранее был реализован в C#. Однако в C# использование монитора удобнее, т.к. имеется синтаксический сахар в виде lock(Object) { ... }, который избавляет от необходимости вызывать методы Monitor.Enter и Monitor.Leave. В качестве объекта-монитора Вы можете использовать любой созданный объект, например так:

o := TObject.Create;
TMonitor.Enter(o);
try
  some code...
finally
  TMonitor.Leave(o);
end;

Для работы данного механизма выделяются дополнительные 4 байта (или 8 байт для x64) при создании любого объекта любого класса (даже если Вы ничего не знаете о "мониторе"). По сути, для каждого создаваемого объекта создаётся скрытое поле размером SizeOf(Pointer). Это второе скрытое поле у объекта (изначально было только одно скрытое поле, в котором хранится указатель на таблицу виртуальных методов класса, соответствующего объекту). По умолчанию данное поле равно NIL, однако в момент вызова TMonitor.Enter создаётся (в динамической памяти) структура TMonitor и указатель на эту структуру сохраняется в данном поле. Структура TMonitor уничтожается автоматически при вызове деструктора объекта.

На хабре есть статья .NET: Инструменты для работы с многопоточностью и асинхронностью, в которой замечательно описывается назначение Monitor и представлены наглядные примеры его использования.

7.6 Механизм синхронизации "много читателей и один писатель" (MREW)

В Delphi, как и во многих языках программирования, присутствует сложный объект синхронизации, работающий по принципу "много читателей и один писатель" (System.SysUtils.TMultiReadExclusiveWriteSynchronizer). Например, есть массив, состоящий из 1 млн. элементов (это может быть TDataSet с 1 млн. записей). Программисту было лень делать его индексацию, поиск элементов в массиве программист организовал методом "тупого перебора", поэтому время поиска занимает много времени (например, 500 миллисекунд). Но у программиста 16-ядерный процессор, поэтому ему на эту проблему наплевать. Одновременно поиск в массиве могут выполнять множество (до 16) потоков. Но где-то раз в 5 секунд в этот массив необходимо вносить изменения (изменить значение элементов массива, добавить новый элемент или удалить ненужный). А тут уже без синхронизации не обойтись! Но синхронизация с помощью обычной критической секции не подходит, т.к. из-за длительного времени поиска блокировка критической секцией будет занимать от 500 (в лучшем случае) до 16х500 (в худшем случае) миллисекунд (в худшем случае это целых 8 секунд!). На помощь такому программисту приходит TMultiReadExclusiveWriteSynchronizer. С его помощью все 16 потоков по-прежнему смогут параллельно выполнять поиск в массиве, не блокируя друг друга, но при необходимости изменения массива произойдёт блокировка: поток, который хочет изменить массив, дождётся, когда все остальные потоки завершат чтение, выполнит изменение массива в монопольном режиме, а затем разрешит остальным потокам вновь выполнять чтение массива.

⚠️ Внимание! Не используйте метод "тупого перебора" для массивов из 1 млн. элементов. Лучше заранее индексируйте такой массив с помощью словаря (TDictionary), либо сразу используйте такой словарь для хранения данных. В этом случае для защиты словаря будет достаточно обычной критической секции, а время поиска будет минимальным (менее 0.01 мс)!

8. Обработка исключений в дополнительных потоках

Если при выполнении кода в дополнительном потоке (например, в TMyThread.Execute) возникнет ошибка (исключение), то работа метода Execute прервётся (если Вы не добавили в него try..except для перехвата исключения). Но это не приведёт к прекращению работы всей программы. Кроме того, если объект потока ещё не уничтожен, то Вы сможете запросить у него информацию об ошибке (свойство TThread.FatalException) и показать её пользователю (либо использовать каким-то иным способом). Желательно завершить обработку объекта TThread.FatalException до того, как будет уничтожен объект потока, в противном случае существует вероятность возникновения сбоев (из-за того, что объект FatalException уничтожается в деструкторе объекта потока), но это не точно! :)

Если Вы разрабатываете долгоживущий поток (например, раз в 10 секунд он запрашивает информацию из базы данных и передаёт её в основной поток для обновления GUI), то каждая итерация цикла должна быть защищена с помощью блока try..except. Но ошибку мало перехватить, её также нужно как-то обработать, например уведомить пользователя, или сохранить информацию об ошибке в лог. Ни в коем случае не показывайте пользователю сообщение об ошибке, блокирующее работу дополнительного потока. В идеале в программе должна быть какая-то панель уведомлений, на которой появится текст ошибки и он может даже мигать для привлечения внимания пользователя. Как организовать такую панель - Вы подумайте сами, а я покажу, как вывести информацию об ошибке в лог и в компонент TListBox, тем более у нас уже есть модуль MTLogger.pas.

Пример находится в папке "ExExceptions". Это очень простой пример, Вы самостоятельно с ним разберётесь. Если в нём что-то непонятно, то прочитайте материал данной статьи. Я лишь приведу исходный код метода TMyThread.Execute:

procedure TMyThread.Execute;
begin
  DefLogger.AddToLog('Доп. поток запущен');
  while not Terminated do
  try
    ThreadWaitTimeout(Self, 5000);
    if not Terminated then
      DoUsefullWork;
  except
    on E: Exception do
    begin
      // Выводим ошибку в лог:
      DefLogger.AddToLog(Format('%s [%s]', [E.Message, E.ClassName]));

      // Показываем ошибку пользователю:
      LastErrTime := Now;
      LastErrMsg  := E.Message;
      LastErrClass := E.ClassName;
      Synchronize(AddExceptionToGUI);
    end;
  end;
  DefLogger.AddToLog('Доп. поток остановлен');
end;

После нажатия кнопки "Запустить доп. поток" необходимо подождать 5 сек. до появления первого сообщения об ошибке.

9. Передача данных в дополнительный поток, обмен между потоками

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

Существуют различные способы передачи данных в дополнительный поток. Существует популярная схема (шаблон) взаимодействия между потоками "producer - consumer" (производитель - потребитель), в которой один (или несколько) потоков (producer) размещает в некую очередь какие-нибудь данные (например команду "посчитай криптографический хэш sha-256 такого-то файла", а другой поток (consumer) извлекает данные (команду) из очереди и выполняет её обработку. Consumer-потоков может быть несколько, в этом случае они могут извлекать из очереди разные команды и выполнять их обработку параллельно (например, 4 consumer-потока могут одновременно вычислять хэш sha-256 у 4-х разных файлов). Consumer-потоки могут решать чисто вычислительные задачи, например, вычислять хэш sha-256 у заранее загруженного (в память) файла, в этом случае количество таких потоков не должно превышать количество ядер процессора. Также consumer-потоки могут решать задачи, не оказывающие существенной нагрузки на процессор (например, http-запрос к REST-серверу), в этом случае количество consumer-потоков может в разы превышать число ядер процессора.

Producer-поток может ожидать появления результата у consumer-потока, а может и не ожидать. Producer-поток должен контролировать количество заданий, накопленных в очереди: если их количество достигло заданного лимита, то producer-поток не должен добавлять в очередь новую задачу, иначе мы можем столкнуться с ситуацией, при которой consumer-потоки не будут успевать обрабатывать задания и программа будет работать некорректно.

9.1 Использование обычного списка (TList, TStringList, TThreadList) и периодический контроль списка

В данном случае consumer-поток должен периодически, например раз в 20 миллисекунд, проверять количество элементов в списке, затем обрабатывать найденные элементы, затем удалять их из списка. Задержка осуществляется с помощью Sleep(20). Разумеется, Вы должны защищать список от одновременного доступа из разных потоков (например, с помощью критической секции). Если Вы используете списки Classes.TThreadList или Generics.Collections.TThreadList<T>, то дополнительная защита не требуется, т.к. в них уже встроена критическая секция.

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

9.2 Использование обычного списка (TList, TStringList, TThreadList) и объекта Event

Данный способ аналогичен предыдущему, однако вместо периодического контроля списка используется вызов функции WaitFor, которая блокирует consumer-поток до тех пор, пока producer-поток не вызовет функцию SetEvent. Пока consumer-поток заблокирован на вызове WaitFor, он не использует процессор. Данный способ был продемонстрирован ранее в разделе 7.2, посвящённому объекту ядра "Event" (пример ExSync\ExEvent\SimpleLogger).

В качестве преимущества данного способа (а также предыдущего) отмечу то, что consumer-поток может за один раз извлечь из списка сразу все записи и выполнить их обработку (схема с одним consumer-потоком и множеством producer-потоков). За счёт этого можно значительно повысить производительность программы (например, писать в лог сразу несколько строк, а не по одной).

9.3 Использование очереди TThreadedQueue<T> (множество producer-потоков и один consumer-поток)

TThreadedQueue<T> - это класс, позволяющий создать очередь (с произвольным типом данных) для передачи данных в consumer-поток (и организации ожидания результата от consumer-потока). В языке GoLang присутствует близкий по возможностям механизм channel (канал с произвольным типом данных), который является основным механизмом обмена данными между потоками (вернее, go-рутинами). В Delphi термин "канал" не используется, однако суть практически та же самая!

Consumer-поток должен в бесконечном цикле вызывать метод TThreadedQueue.PopItem для извлечения первого элемента из очереди. Producer-поток для добавления данных в конец очереди должен использовать метод TThreadedQueue.PushItem. Размер очереди ограничен (указывается при создании объекта очереди). Если очередь полна, то producer-поток будет заблокирован на очередном вызове метода TThreadedQueue.PushItem. Если очередь пуста, то consumer-поток будет заблокирован на вызове метода TThreadedQueue.PopItem. Для блокировки потока используется объект ядра Event, поэтому заблокированный поток не расходует ресурсы процессора, а время разблокировки consumer-потока (при добавлении нового элемента в очередь) составит (условно) от 5 до 50 микросекунд (при условии наличия свободных ядер у процессора).

Пример использования TThreadedQueue находится в папке "ExQueue/ExThreadedQueue/".

При запуске программы создаётся один consumer-поток, который исполняет команды, которые другие потоки кладут в очередь ConsumerQueue. Также есть несколько producer-потоков, которые хотят получить у consumer-потока результат обработки своей команды. Producer-потоки периодически кладут в очередь ConsumerQueue команды, а затем читают ответ на команду из своей очереди. В каждом producer-потоке создаётся своя очередь ResQueue. Ссылка на ResQueue передаётся в очередь ConsumerQueue вместе с идентификатором команды CmdId. Может быть любое количество producer-потоков (новые потоки создаются при нажатии соответствующей кнопки). Также можно запросить данные у consumer-потока путём нажатия соответствующей кнопки на форме. В этом случае будут выполнены замеры скорости переключения контекста между потоками и результаты замеров будут показаны пользователю (подробная информация о замерах представлена в файле ExQueue/ExThreadedQueue/ThreadedQueueUnit.pas).

Лично мне очень понравилось использовать очередь TThreadedQueue. Благодаря ей код взаимодействия между потоками получился очень простым и наглядным. TThreadedQueue целесообразно использовать в тех задачах, где требуется обеспечить максимально быструю реакцию на появление новых данных в очереди. Однако необходимо предусмотреть способ завершения работы consumer-потока, а для этого у элемента в очереди должен быть соответствующий признак. В примере этим признаком является нулевое значения поля CmdId. Я рекомендую вводить такой признак и именно нулевое значение трактовать как сигнал к завершению работы потока. В таком случае, даже если producer-поток просто вызовет метод TThreadedQueue.DoShutDown, consumer-поток немедленно "получит" из очереди элемент с нулевым значением признака (это результат функции Default(T)).

⚠️ Внимание! Не во всех версиях Delphi очередь TThreadedQueue работает хорошо! Эмбаркадера много раз вносила исправления.

ℹ️ Внимание! Рекомендую самостоятельно рассмотреть исходные коды примера "ExQueue/ExThreadedQueue". Требуется версия Delphi с поддержкой Generics.Collections.

10. Где можно и где нельзя создавать и уничтожать потоки

Данные замечания актуальны при разработке программ под Windows. Существует ряд мест, где не следует создавать либо уничтожать потоки.

  1. Создавать потоки не следует в секции initialization (как в EXE, так и в DLL). По моему опыту создание дополнительных потоков в секции initialization EXE-модуля (не важно, в каком pas-файле) приводит к снижению стабильности приложения. Грубо говоря, один раз из 100 возникает сбой при запуске приложения.
  2. Не следует уничтожать потоки в секции finalization DLL-библиотеки. Если Вы попытаетесь это сделать, то, скорее всего программа зависнет (это происходит из-за особенностей выгрузки DLL-библиотек в ОС Windows). Если Вы разрабатываете DLL-библиотеку, в которой используются дополнительные потоки, то рекомендую реализовать в ней две экспортные функции – одну для создания необходимых объектов в библиотеке (например, CreateLibrary), вторая для уничтожения созданных объектов (например, DestroyLibrary). В этом случае код создания потоков можно разместить в CreateLibrary (либо создавать их позже), а код уничтожения объектов потоков разместить в DestroyLibrary.

11. Немного о threadvar

При разработке многопоточного приложения в Delphi программист может использовать ключевое слово threadvar (локальная переменная потока) при объявлении глобальных переменных. Для каждого дополнительного потока создаётся собственная копия таких переменных. Один поток не сможет прочитать значение, которое записал в эту переменную другой поток. Переменные, объявленные в threadvar, создаются при создании дополнительного потока, а уничтожаются при завершении работы потока.

⚠️ Внимание! Обычно Вам не требуется использовать threadvar при объявлении глобальных переменных. Если Вам нужно объявить переменные, к которым будет иметь доступ только один дополнительный поток, объявите их в секции private вашего класса (наследника от TThread) (мы так уже многократно делали в предыдущих примерах). Для уместного использования threadvar необходимо иметь действительно очень вескую причину!

ℹ️ Информация! На мой взгляд, наиболее ярким примером уместного использования threadvar является UniGui - классный фреймворк, позволяющий разрабатывать мощные, масштабные "десктопные" приложения, которые работают в браузере. При разработке программы на UniGui код по стилю написания очень похож на обычный Delphi-код для десктопной программы. Однако каждый запрос пользователя (а пользователей может работать одновременно несколько сотен) обрабатывается в отдельном потоке. Для каждого пользователя в момент обращения к UniGui-приложению с помощью браузера создаётся необходимый набор форм, модулей данных, компонентов и всего остального таким образом, что пользователь, не догадывается, что одновременно с ним работают ещё сотни других пользователей.

Вот типичный код получения ссылки на объект формы и модуля данных в UniGui:

function MainmForm: TMainmForm;
begin
  Result := TMainmForm(UniMainModule.GetFormInstance(TMainmForm));
end;

function UniMainModule: TUniMainModule;
begin
  Result := TUniMainModule(UniApplication.UniMainModule)
end;

Это обычные функции! Они не являются методами какого либо класса. Однако мы можем смело обращаться к форме MainmForm либо к модулю данных UniMainModule из любой другой формы. Мы можем из обработчика OnClick кнопки, лежащей на форме MainmForm, обратиться с компоненту TDataSet, который находится в модуле данных UniMainModule.

А теперь подумайте, сколько в программе создано форм MainmForm и модулей данных UniMainModule, если работают одновременно 1000 пользователей? Правильно, может быть создано 1000 форм и 1000 модулей данных. Так как же UniGui понимает, какой TUniMainModule нужно вернуть если функция UniMainModule объявлена без параметров? Вдруг будет возвращён экземпляр TUniMainModule от чужой сессии, в которой работает другой пользователь?

Ответ прост: функция UniApplication возвращает соответствующую переменную класса TUniGUIApplication, объявленную в секции threadvar. При обработке запроса от пользователя:

  1. UniGui создаёт (либо получает из пула) поток, который будет отвечать за обработку запроса, пришедшего от браузера;
  2. Поток анализирует запрос, извлекает из него идентификатор сессии и отыскивает объект TUniGUIApplication, который соответствует данному идентификатору сессии;
  3. Поток сохраняет найденный объект TUniGUIApplication в переменной, объявленной в секции threadvar;
  4. Поток запускает необходимые обработчики событий (например, OnClick). Из этих обработчиков событий можно смело вызывать функции UniApplication, UniMainModule, MainmForm и т.д.

⚠️ Внимание! Следует быть осторожным при объявлении в секции threadvar некоторых типов (строки, динамические массивы, варианты, интерфейсы). Причины этого хорошо описаны в официальной документации.

12. Работа с базой данных из дополнительного потока

Лично я не рекомендую работать с базой данных из десктопного приложения напрямую. Намного перспективнее разработать, как сейчас модно говорить, "микросервис", который:
а) будет находиться на том же компьютере, что и база данных (либо на соседнем сервере);
б) сможет работать с базой данных по наиболее быстрому протоколу с минимальными сетевыми задержками;
в) сможет принимать от клиентов с помощью JSON-запросов команды на запрос данных и изменение данных. При этом клиентскому приложению вообще необязательно использовать сущность "таблица базы данных", оно может оперировать понятием "объект бизнес-логики". Какие именно таблицы в БД используются для хранения данных объекта бизнес-логики, решает исключительно микросервис. Благодаря такому подходу значительно увеличивается производительность системы по сравнению с работой с базой данных напрямую, особенно в тех случаях, когда клиентское приложение находится на значительном удалении от базы данных, т.е. имеются высокие сетевые задержки;

ℹ️ Информация! Если Вы до сих пор не работали с JSON и не знаете, что это такое, советую обязательно этот пробел заполнить, т.к. на текущий момент JSON является общепринятым форматом организации, хранения и передачи информации (как когда-то XML, только проще и удобнее).

Использование вспомогательного микросервиса:
а) позволит повысить безопасность базы данных (TCP-порт для подключения к базе данных выводить наружу не требуется);
б) позволит использовать любую бесплатную базу данных (SQLite, Firebird, PostgreSQL и др., а также NoSql базы данных), при этом приложению-клиенту даже не нужно знать, какая база данных используется;
в) позволит изменять правила обработки бизнес-логики без необходимости обновления приложений-клиентов;

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

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

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

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

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

ℹ️ Рекомендация. Я рекомендую все автоматические операции с базой данных выполнять только из дополнительного потока. В идеале, как я считаю, в программе не должно быть ни одного таймера с обработчиком события OnTimer, в котором бы выполнялись обращения к базе данных через главный компонент подключения, используемый в десктопном приложении. Такой подход очень помогает в тех случаях, когда происходит разрыв подключения к БД. Иначе, если разрыв подключения к БД произошёл, то при каждом срабатывании обработчика OnTimer мы будем иметь подвисание интерфейса программы (из-за попытки установить новое сетевое подключение к БД), либо каждый раз на экране будет выдаваться новое окно с сообщением об ошибке. Будет лучше, если подобные ошибки будут возникать в дополнительных потоках и фиксироваться в логах, а у пользователя останется возможность работать с программой и выполнять те операции, которые не связаны с базой данных. Можно даже отобразить на экране отдельное окно с кнопкой "Выполнить попытку подключиться к БД".

⚠️ Внимание! Вы не можете создать в программе единственный компонент подключения к базе данных (например, TIBDataBase) и использовать его для работы с базой данных одновременно из нескольких потоков.

Причины этого следующие:

а) некоторые компоненты подключения к БД не рассчитаны на одновременную работу с ними из нескольких потоков (в том числе TIBDataBase). Внутри компонентов находятся различные списки, например список подключенных объектов TIBDataSet или список транзакций. Эти списки не всегда защищены от одновременного доступа;
б) компонент подключения устанавливает соединение с сервером управления базы данных (СУБД) (например, по протоколу TCP/IP) и дальнейний обмен с СУБД осуществляется по принципу "запрос / ответ". Если один поток выполнил запрос к БД, то компонент подключения передаёт информацию в СУБД и ждёт ответа от СУБД. До тех пор, пока не будет ответа от СУБД, компонент подключения не будет передавать в СУБД новые запросы, даже если они поступают из других потоков. Таким образом, нет никакого смысла пытаться работать с базой данных из нескольких потоков с использованием одного компонента подключения.

ℹ️ Информация! Очень важно при многопоточной работе с БД иметь пул подключений. Пул подключений - это по сути массив (список), в котором хранятся активные подключения к БД. Благодаря использованию пулов Вы экономите значительное время на процедуру установки соединения с БД, особенно если используется TLS-шифрование канала связи. Если Вы используете компоненты IBX для работы с Firebird, то можете использовать пул подключений из библиотеки ibxFBUtils. Данный пул я использую в различных коммерческих проектах более 10 лет, поэтому можно быть уверенным, что в нём всё вылизано досконально.

Итак, типовая работа с базой данных в дополнительном потоке выглядит следующим образом:

  1. Создаём объект подключения либо берём готовое подключение из пула;
  2. Создаём объект-транзакцию (при необходимости);
  3. Выполняем необходимые запросы к базе данных;
  4. Коммитим транзакцию (если была произведена модификация данных в БД);
  5. Уничтожаем объект подключения либо возвращаем его обратно в пул.

13. Проблемы, возникающие при создании большого количества потоков

  1. Ограниченность адресного пространства. Сейчас практически никто не использует 32-разрядную ОС Windows. Если мы скомпилируем своё приложение в 32-битном режиме (с размером стека по умолчанию 1 МБ), запустим его в 64-битной ОС Windows и попытаемся создать 2000 потоков, то получим ошибку. Система не в состоянии создать более 1650 потоков. На каждый созданный поток Windows выделяет 1 МБ виртуальной памяти для 32-битного стека и 256 КБ виртуальной памяти для 64-битного стека (его Windows создаёт и поддерживает автоматически для обеспечения возможности работы 32-битных программ в 64-битной ОС). Насколько я понимаю, на размер 64-битного стека мы влиять не можем, но, если уменьшим размер виртуальной памяти под 32-битный стек (это можно сделать в окне Project / Options / Linker / Max stack size), то можем значительно увеличить максимальное количество потоков в приложении. При этом необходимо воздержаться от объявления локальных статических массивов с большим числом элементов. Данная проблема (с ограниченностью адресного пространства) актуальна только для 32-битных приложений. Если вы компилируете 64-битное приложение, то данной проблемы не существует.

  2. Ограниченность физической памяти. Информация актуальна для 64-битной Windows и стандартного менеджера памяти (либо FastMM4). Один поток в 32-битной программе занимаем минимум 88 КБ ОЗУ. В 64-битной программе поток весит меньше - минимум 52 КБ ОЗУ (очевидно, экономия достигается за счёт того, что используется только один стек - 64-битный). Это значение не зависит от объёма виртуальной памяти, выделенной под стек. Учитывайте это, если планируете создавать огромное количество потоков!

  3. Использование функции WinAPI-функции Sleep либо WaitForXXX для организации задержек в бесконечном цикле. Очень простое решение - реализовать в дополнительном потоке бесконечный цикл, периодически проверять значение какого-то флага (либо элемента в очереди), в зависимости от значения флага выполнять какое-либо действие, после чего переводить поток в спящий режим с помощью Sleep либо WaitForXXX. Если вы укажете время ожидания 1000 мс (или больше), то нет проблем (в том смысле, что нагрузка на процессор будет минимальной - примерно 50000 тактов в секунду при паузе в 1 секунду). Однако если Вы будете проверять значение флага каждые 10 миллисекунд, то нагрузка на процессор будет уже существенной - 2500000 тактов в секунду будет тратиться только на работу Sleep(10). Если у вас запущено 1000 таких потоков и каждый расходует 2500000 тактов в секунду, то будет обеспечена 100% загрузка одного ядра процессора (либо эта загрузка будет размазана по нескольким ядрам). Т.е. ваши 1000 потоков ещё не делают ничего полезного, но уже грузят процессор по полной программе! :)
    Для того, чтобы такой проблемы не было, не рекомендуется использовать Sleep либо WaitForXXX с маленькой задержкой. Гораздо лучше использовать WaitForXXX с большой задержкой (в том числе, INFINITY), а при изменении значения флага следует переводить объект ядра, который ожидает функция WaitForXXX, в сигнальное состояние. В этом случае потоки не будут тратить на контроль состояния флага практически никаких ресурсов процессора!

  4. Неправильное использование компонента TIdTCPServer. При разработке TCP-серверов Delphi-программисты чаще всего используют компонент TIdTCPServer. При правильном использовании данный компонент может держать до 50000 подключений (существуют реальные примеры с таким количеством подключений). Разумеется, для этого программа должна быть 64-битной. Для поддержки 50000 подключений будет создано 50000 потоков и выделено около 3 ГБ ОЗУ, что весьма затратно. При таком количестве потоков ни в коем случае не должно быть циклов, в которых выполняется проверка чего-либо каждые 10 мс. Значение Socket.ReadTimeout должно быть не менее 10000 (10 секунд). При использовании Socket.CheckForDataOnSource для ожидания приёма данных от клиента также желательно использовать значение не менее 10000.
    ⚠️ Внимание! Перед каждым вызовом Socket.CheckForDataOnSource необходима проверка if Socket.InputBuffer.Size = 0 then, что обусловлено особенностями реализации метода CheckForDataOnSource. Если в буфере есть данные, то выполнять вызов Socket.CheckForDataOnSource не следует!

ℹ️ Информация! Сами по себе данные в буфере Socket.InputBuffer появиться не могут. Появляются они там в следующих случаях:
а) в контексте вызова метода Socket.ReadXXX,
б) в контексте вызова метода Socket.CheckForDataOnSource,
в) в момент подключения клиента к серверу, если клиент сразу же передал данные на сервер.

ℹ️ Информация! На практике Delphi-программисту очень редко приходится разрабатывать TCP-сервер, способный держать 50000 соединений. Всё зависит от того, что именно делает TCP-сервер. Если он не выполняет какой-то особой обработки, а просто возвращает в ответ на запрос клиента текущее время (или иную информацию, которую не нужно запрашивать из базы данных или получать в результате сложных вычислений), то проблем никаких нет. Однако, если на каждый запрос приходится выполнять обращение к базе данных или производить сложные вычисления, то ресурсы сервера могут закончиться гораздо раньше (например, на 1000 соединений). В связи с этим рекомендую минимизировать количество обращений к базе данных и стараться держать всю необходимую информацию в памяти TCP-сервера, периодически обновляя её в фоновом потоке.

⚠️ Внимание! Не рекомендую использовать TIdTCPServer для решения задачи транзита данных. Данная задача отличается тем, что TCP-сервер не выполняет практически никакой обработки данных, а лишь передаёт клиенту "Б" данные, принятые от клиента "А" (и обратно). Если таких клиентов ("А" и "Б") будет 50000, то программа будет использовать около 3 ГБ ОЗУ. Загрузка процессора при активном обмене данными между клиентами будет также весьма приличной, поскольку при большом количестве потоков будет происходить очень много переключений контекста, а каждое переключение мы оцениваем в 50000 тактов. Существуют гораздо более удачные варианты решения задачи транзита данных: асинхронные сокеты и порты завершения ввода/вывода. И в том и в другом способе используется фиксированное количество потоков (например, 8 потоков для 4-ядерного процессора) и требуется намного меньше ОЗУ (как минимум, в 10 раз меньше, чем при использовании TIdTCPServer). Количество переключений контекста также намного меньше (сравнение имеет смысл производить только при высокой интенсивности обмена данными), т.к. за один квант времени каждый поток может обработать множество соединений.

  1. Большой расход памяти при использовании некоторых менеджеров памяти. Современные высокопроизводительные менеджеры памяти, например, tcmalloc, могут создавать для каждого потока свой собственный пул блоков памяти. Благодаря этому удаётся минимизировать количество блокировок при выделении и освобождении памяти, что позволяет разрабатывать приложения, которые хорошо масштабируются по количеству ядер, т.е. могут максимально эффективно использовать доступные ресурсы памяти и процессора. Кстати, для Delphi имеется интерфейсный файл tcmalloc.pas, позволяющий использовать менеджер памяти tcmalloc, представленный в виде библиотеки "libtcmalloc.dll". С точки зрения производительности, он значительно эффективнее встроенного в Delphi менеждера памяти, при этом поддерживаются любые версии Delphi, однако в нём отсутствуют многие плюшки, которые есть в FastMM4.
    Проблема таких менеджеров памяти заключается в том, что им требуется значительно больший объем памяти ОЗУ по сравнению со стандартным менеджером памяти. На каждый поток такой менеджер памяти запросто может выделить дополнительно по 1 МБ ОЗУ. При использовании такого менеджера памяти вы не сможете создавать тысячи потоков, поскольку память ОЗУ может очень быстро закончиться. С точки зрения разработки серверов это означает, что сетевую библиотеку Indy10 с таким менеджером памяти лучше не использовать. Вместо Indy10 вы можете рассмотреть сетевые библиотеки, работающие по другим принципам, например, использующие асинхронные сокеты Windows (это сложнее, чем Indy10) либо механизм "i/o completion ports" (это значительно сложнее, чем Indy10).

  2. Проблема масштабирования стандартного менеджера памяти (и FastMM4). Дело в том, что архитектура менеджера памяти FastMM4 закладывалась ещё во времена 1-ядерных процессоров и 1-поточных программ. Для программы, в которой отсутствуют дополнительные потоки, FastMM4 является оптимальным выбором. В дальнейшем на Delphi стали разрабатывать многопоточные программы, но процессоры всё ещё оставались 1-ядерными. Для многопоточной программы, запущенной на 1-ядерном процессоре, FastMM4 также являлся оптимальным выбором. К сожалению, для многоядерных процессоров FastMM4 не расчитан. Вы не сможете разработать высокопроизводительную программу, которая максимально эффективно утилизирует ядра процессора - узким местом будут обращения к функциям менеджера памяти (GetMem, FreeMem, создание объектов, работа со строками и динамическими массивами и т.д.). Если 2 потока решат одновременно создать объект (TObject.Create), то FastMM4 поставит вызовы функции менеджера памяти в очередь с использованием атомарной функции LockCmpxchg. При этом, если не удалось заблокировать ресурс, то может произойти вызов функции Sleep(1), которая заморозит ожидающий поток до очередного срабатывания системного таймера (примерно на 16 мс). Хорошая новость заключается в том, что автор библиотеки FastMM4 разработал новый менеджер памяти - FastMM5, который намного лучше масштабируется, однако является платным для коммерческого использования.

14. Выполнение длительной операции в дополнительном потоке с отображением информационного модального окна, блокирующего работу пользователя с программой

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

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

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

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

Вы можете перед началом длительной операции отобразить на экране дополнительное окно к надписью "Выполняется операция ХХХ. Ждите!", а в конце операции закрыть его (см п. 1.2). Однако при таком способе есть несколько минусов:

  1. Невозможно отобразить прошедшее время выполнения операции;
  2. Windows может навесить собственное окно с надписью "Не отвечает", после чего возможна проблема с "вылетом" текущего окна на задний план (в этом случае пользователь не сможет ничего нажать в окне, которое находится на переднем плане и может принять кардинальное решение о перезагрузке компьютера);
  3. В программе не работают никакие автоматические операции, запускающиеся из основного потока (обработчик TTimer.OnTimer не будет вызываться, пока основной поток не освободится);
  4. Основной поток не обрабатывает вызовы SendMessage, PostMessage, Synchronize, Queue;
  5. Если в программе используются асинхронные сетевые компоненты, использующие основной поток (например, Overbyte ICS), то их работа будет приостановлена, а соединения могут быть разорваны;
  6. Сложно организовать информирование пользователя о текущем состоянии выполняемой операции;
  7. Невозможно прервать ход выполнения длительной операции, даже если прерывание операции логично и не приводит к каким-либо проблемам (например, при формировании отчёта).

Далее демонстрируется пример, в котором все перечисленные проблемы решены! По сути, это готовое решение, котором можно использовать практически в любом VCL-проекте. Но мне хотелось бы, чтобы читатель досконально разобрался с исходными кодами. Мною было потрачено на этот пример много дней и весь код я постарался привести в максимально читабельный вид. Также мною разработан модуль ParamsUtils.pas, который значительно упрощает передачу именованного списка параметров различного типа, что весьма полезно в ситуации, когда мы не можем напрямую вызвать целевую функцию с её "родными" параметрами.

В папке ExWaitWindow находится пример, в котором демонстрируется способ отображения дополнительного модального окна при запуске длительной операции с помощью функции DoOperationInThread. Ниже пример использования функции DoOperationInThread:

procedure TMainForm.Button3Click(Sender: TObject);
begin
  DoOperationInThread(Self, OPERATION_TYPE_NONE, 'Длительные вычисления',
    TParamsRec.Build(['Min', 300, 'Max', 700]),
    ProgressOperation, NEED_SHOW_STOP_BTN);
end;

В данном примере на экране отображается модальное окно с надписью "Длительные вычисления". Данное окно отображается на экране около 5 секунд. В этом окне отображаются следующие элементы:

  1. Время, прошедшее от начала операции;
  2. Полоса ProgressBar, которая индицирует ход вычислений;
  3. Текущее значение, используемое в вычислительном алгоритме;
  4. Границы вычисления Min и Max;
  5. Кнопка "Отмена", позволяющая досрочно прервать выполнение операции.

⚠️ Внимание! Обратите внимание, что длительная операция запускается в дополнительном потоке!

Объявление функции DoOperationInThread выглядит следующим образом:

  function DoOperationInThread(AOwner: TForm; OperType: Integer; OperationName: string; AParams: TParamsRec;
    WorkFunc: TWorkFunction; ShowStopButton: Boolean; AResParams: PParamsRec = RES_PARAMS_NIL): Boolean; overload;

Назначение параметров функции DoOperationInThread:

  • AOwner - объект формы, из которой вызывается функция DoOperationInThread (допускается использовать значение NIL);
  • OperType - тип выполняемой операции. Если рабочая функция WorkFunc выполняет только одну операцию, то можно использовать константу OPERATION_TYPE_NONE;
  • OperationName - наименование выполняемой операции. Отображается в модальном окне. Рабочая функция может его изменить;
  • WorkFunc - рабочая функция, выполняющая длительную операцию. Запускается из дополнительного потока. В неё передаются входные параметры AParams. Она может установить значения выходных параметров AResParams. Также она может управлять содержимым модального информационного окна. В качестве WorkFunc можно использовать обычную функцию, метод, либо анонимную функцию (для современных Delphi);
  • AParams - входящие параметры для передачи в рабочую функцию. Обеспечивается удобный доступ к параметрам по имени;
  • ShowStopButton - определяет, нужно ли показывать кнопку "Отмена" в информационном окне. Если Вы отображаете кнопку "Отмена", то рабочая функция должна поддерживать возможность прерывания длительной операции;
  • AResParams - указатель на структуру TParamsRec, который передаётся в рабочую функцию. С помощью данного указателя рабочая функция возвращает результат своей работы (в виде произвольного количества именованных параметров);
  • Result - признак успешного выполнения рабочей функции. Функция DoOperationInThread возвращает это значение.

ProgressOperation - это рабочая функция, содержащая код длительной операции.

Объявление функции ProgressOperation соответствует типу TWorkFunction, который объявлен следующим образом:

  TWorkFunction = function (OperType: Integer; AParams: TParamsRec; AResParams: PParamsRec; wsi: IWaitStatusInterface): Boolean;

ℹ️ Информация! Это лишь один из способов объявления типа рабочей функции. Также в функцию DoOperationInThread допускается передавать метод объекта и анонимную функцию.

Назначение параметров рабочей функции:

  • OperType - тип выполняемой операции. Если рабочая функция выполняет только одну операцию, то она может не анализировать значение этого параметра;
  • AParams - входящие параметры (произвольное количество именованных параметров);
  • AResParams - указатель на структуру TParamsRec. С помощью данного указателя функция возвращает результат своей работы;
  • wsi - интерфейсная ссылка (IWaitStatusInterface), предназначенная для управления содержимым модального окна ожидания;
  • Result - признак успешного выполнения рабочей функции.

Ниже представлен пример реализации рабочей функции ProgressOperation:

function ProgressOperation(OperType: Integer; par: TParamsRec; AResParams: PParamsRec; wsi: IWaitStatusInterface): Boolean;
var
  I: Integer;
begin
  wsi.SetProgressMinMax(par.I('Min'), par.I('Max'));
  wsi.StatusLine[2] := Format('Min=%d; Max=%d', [par.I('Min'), par.I('Max')]);
  for I := par.I('Min') to par.I('Max') do
  begin
    wsi.StatusLine[1] := 'Текущее значение: ' + IntToStr(I);
    wsi.ProgressPosition := I;
    Sleep(10);
    wsi.CheckNeedStop;
  end;
  Result := True;
end;

Особенности функции ProgressOperation:

  • управляет элементом ProgressBar (метод SetProgressMinMax и свойство ProgressPosition интерфейса IWaitStatusInterface);
  • устанавливает 1-ю и 2-ю строки текста, отображаемого в модальном окне ожидания длительной операции;
  • на каждой итерации цикла проверяет, была ли нажата кнопка "Отмена". Если кнопка была нажата, то будет сгенерировано исключение Exception;
  • если пользователь нажал кнопку "Отмена", то исключение будет сгенерировано в основном потоке;
  • доступ к значениям целочисленных параметров Min и Max осуществляется с помощью кода par.I('Min') и par.I('Max');
  • функция возвращает признак успешного завершения Result := True.

Реализация функции DoOperationInThread находится в модуле WaitFrm.pas. Модуль распространяется по свободной лицензии, поэтому любой желающий может вносить в свою копию модуля любые изменения и использовать модуль на своё усмотрение.

Для управления содержимым окна ожидания используется интерфейс IWaitStatusInterface:

  IWaitStatusInterface = interface
    // private
    function GetOperationName: string;
    procedure SetOperationName(const Value: string);
    function GetStatusText: string;
    procedure SetStatusText(const Value: string);
    function GetNeedStop: Boolean;
    procedure SetNeedStop(const Value: Boolean);
    function GetStatusLine(LineNum: Integer): string;
    procedure SetStatusLine(LineNum: Integer; const Value: string);
    procedure SetProgressPosition(Value: Double);
    function GetProgressPosition: Double;

    // public
    property OperationName: string read GetOperationName write SetOperationName;
    property StatusText: string read GetStatusText write SetStatusText;
    property NeedStop: Boolean read GetNeedStop write SetNeedStop;
    property ProgressPosition: Double read GetProgressPosition write SetProgressPosition;
    property StatusLine[LineNum: Integer]: string read GetStatusLine write SetStatusLine;
    procedure ClearStatusText;
    procedure CheckNeedStop;
    procedure SetProgressMinMax(AMin, AMax: Double);
    function GetProgressMin: Integer;
    function GetProgressMax: Integer;
  end;

Обратите внимание, что в данном интерфейсе отсутствует строка IID, поскольку использование данного интерфейса в технологии COM не имеет смысла, а значит обращений к методу QueryInterface не будет!

Структура TParamsRec находится в модуле ParamsUtils.pas. Модуль распространяется по свободной лицензии.

Обратите на модуль ParamsUtils.pas также особое внимание. Вот комментарии к нему:

{Основу модуля ParamsUtils составляет структура (record) TParamsRec, которая хранит
именованный (либо неименованный) список параметров. Данная структура необходима
для работы функции DoOperationInThread. Однако Вы можете использовать её как
универсальный способ передачи параметров в функцию, принимающую произвольное количество
параметров различного типа. Это намного лучше, чем передавать
параметры в виде вариантного массива (либо массива вариантов), поскольку обеспечивается
доступ к параметру по имени (а не только по индексу).

  Что лучше? Классический способ передачи параметров в виде массива с произвольным количеством элементов:
    MyFunc(VarArrayOf([s, pr, cnt, v, crt, Now]))
  при этом доступ к элементам массива возможен только по индексу, например:
  sTovarName := Params[0];
  sSumma := Params[1] * Params[2]

  или с использованием структуры TParamsRec:
    MyFunc(TParamsRec.Build(['Tovar', s, 'Price', pr, 'Count', cnt, 'VidOpl', v, 'CardNum', crt, 'SaleTime', Now]))
  при этом доступ к элементам массива возможен по имени, например:
  sTovarName := par.S('Tovar');
  sSumma := par.C('Price') * par.C('Count');

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

Не используйте TParamsRec для передачи слишком большого количества параметров, т.к.
для доступа к значению параметра используется последовательный поиск строки в массиве
параметров, а это не самый быстрый способ доступа!}