1
0
mirror of https://github.com/loginov-dmitry/multithread.git synced 2024-11-28 09:33:03 +02:00
multithread/multithread_net_programming.md
2022-10-16 19:25:36 +03:00

61 KiB

Особенности многопоточного сетевого программирования в Delphi

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

Delphi - прекрасный язык программирования и замечательная среда быстрой разработки. На Delphi разрабатывают как десктопные приложения, так и сервера. Разработка серверов (рассматриваются TCP и HTTP-сервера) имеет свои особенности. Если 20 лет назад разрабатываемые на Delphi сервера отличались высокой производительностью благодаря компиляции напрямую в машинный код, то на 2022г многие конкуренты (языки программирования и фреймворки) обеспечивают сопоставимую производительность, а иногда и лучше. В качестве примеров можно отметить GoLang с его очень эффективным использованием многопоточности и C# с его встроенной в язык асинхронностью. На текущий момент даже на Java Script можно разработать вполне приличный по производительности HTTP-сервер (с использованием Node.js).

Данная статья поможет читателю лучше понять имеющиеся у языка Delphi сложности в части разработки высокопроизводительных серверов (TCP и HTTP).

1. В языке Delphi отсутствует встроенная в язык асинхронность (в отличие от конкурентов)

На 2022г это большая проблема. Наличие встроенной асинхронности позволяет работать с сотнями и тысячами (и даже с сотнями тысяч) сокетов и файлов, не прибегая к многопоточности. Если язык поддерживает асинхронность, то при вызове функции асинхронного чтения сокета (или файла), условно ReadAsync, не произойдёт блокировки потока, из которого вызвана эта функция. При этом программисту не требуется реализовывать каких-либо callback-функций. Допустим, имеется некий компонент TCPServer, в котором объявлен асинхронный (async) метод OnExecute. При подключении любого TCP-клиента будет произведён вызов метода OnExecute, в котором, например, может быть реализована такая последовательность команд для обмена информацией с клиентом (все названия функций выдуманы):

async procedure OnExecute(Socket)
begin
  if Socket.ReadLnAsync = 'time' then
    Socket.WriteLnAsync(DateTimeToStr(Now))
  else if Socket.ReadLnAsync = 'sql' then
  begin
    Dataset := ExecSQLAsync();
	Socket.WriteLnAsync(Dataset.ToString)
  end else if Socket.ReadLnAsync = 'file' then
  begin
    FileName := Socket.ReadLnAsync;
	if FileExistsAsync(FileName) then
	begin
      FileContent := ReadFileAsync(FileName);
	  Socket.WriteAsync(FileContent);
	end else
	  Socket.WriteLnAsync('ERROR: File not found');
  end else if Socket.ReadLnAsync = 'exit' then
  begin
    Socket.DisconnectAsync;
  end;
end

Стиль программирования тот же самый, к которому привыкли Delphi-программисты при разработке TCP-серверов на Indy10.
ОГРОМНОЕ отличие в том, что все вызовы OnExecute (для всех подключенных TCP-клиентов) осуществляются в одном и том же потоке (даже если подключены 1000 TCP-клиентов).
Здесь интересны следующие особенности:

  1. Вызовы асинхронных функций ReadLnAsync, WriteLnAsync, ExecSQLAsync, ReadFileAsync не приводят к блокировке потока (Delphi-программисты привыкли, что вызов любой функции обращения к базе данных или чтения файла может заблокировать на некоторое время поток, из которого был произведён этот вызов).
  2. Код остаётся линейным, можно указывать друг за другом последовательность любых асинхронных функций и быть уверенным, что очередная асинхронная функция начнёт свою работу только после завершения предыдущей.
  3. Вызов асинхронных функций ReadLnAsync, WriteLnAsync, ExecSQLAsync, ReadFileAsync не приводит к запуску каких-либо скрытых дополнительных потоков. Разумеется, для обслуживания механизма асинхронности может быть запущено несколько дополнительных worker-потоков (может буквально пару штук). Но сами вызовы функций для асинхронной работы с сокетами/файлами не требуют использования дополнительных потоков.
  4. Не используется никаких callback-функций. Это сильно упрощает программирование, делает код более понятным и простым для анализа и дальнейшего развития.

ℹ️ Информация! Как это работает? Если сильно упростить, то вызов функции для асинхронной работы с сокетом/файлом (например, ReadFileAsync) приводит к вызову соответствующей функции (для чтения сокета/файла) из состава API ОС в неблокирующем режиме, после чего выполняется "выход" из асинхронной процедуры OnExecute с сохранением текущего контекста (сохраняются значения всех локальных переменных и адрес, с которого затем можно будет продолжить выполнение кода в OnExecute после получения данных из ОС). Отдельный worker-поток ожидает завершения асинхронной операции с сокетом/файлом (путём вызова функции ОС, которая возвращает очередной элемент из очереди завершения операций ввода/вывода). Как только ОС завершила операцию с сокетом/файлом, worker-поток получает от ОС (здесь речь идёт про Windows, поскольку в других ОС используется иные подходы для работы с сокетами) результат этой операции, после чего размещает необходимую информацию в объекте "очередь" (либо "канал", как это принято в GoLang). В состоянии простоя тот поток, в контексте которого выполняется вызов асинхронной процедуры OnExecute, читает из очереди/канала информацию о результатах асинхронных операций и восстанавливает контекст прерванной процедуры OnExecute.

ℹ️ Информация! в C# и JavaScript вызов асинхронной функции выполняется совместно с оператором await. При этом механизм асинхронности в JavaScript реализован несколько иначе, нежели в C# (но сути это не меняет).

ℹ️ Информация! в GoLang асинхронность реализована совершенно по другому - через механизм go-рутин. Имеется пул worker-потоков, go-рутина (по сути, функция, запущенная в контексте дополнительного потока) может быть запущена в контексте любого свободного worker-потока, затем она может быть приостановлена (например, на операции чтения сокета), а затем возобновлена в контекста произвольного свободного worker-потока. После приостановки go-рутины worker-поток сразу же загружает контекст другой go-рутины, готовой к выполнению, и передаёт ей управление (либо worker-поток ожидает, пока не появиться какая-нибудь go-рутина, готовая к выполнению). Количество worker-потоков по умолчанию равно количеству ядер процессора. Этого может быть вполне достаточно для обслуживания соединений с тысячами подключенных TCP-клиентов. Данный механизм использует преимущества многоядерных процессоров по полной программе. По мере возрастания интенсивности сетевого обмена происходит равномерное увеличение загрузки всех ядер процессора. GoLang - идеальное средство для разработки транзитных серверов, основная цель которых - перебрасывать сетевые пакеты между различными узлами сети.

Итак, как уже было сказано, в Delphi отсутствует встроенная в язык асинхронность. Некоторые разработчики пытаются этот факт оспаривать, мол в библиотеке OmniThreadLibrary имеется функция Async, которая умеет запускать код в асинхронном режиме. Но на самом деле эта функция не имеет никакого отношения ко встроенной в язык асинхронности. Она запускает переданную в ней callback-функцию (либо анонимную функцию) в дополнительном потоке, а после окончания выполнения этой функции осуществляет вызов (в контексте основного потока) второй callback-функции (либо анонимной функции). Если выполнить 2 вызова функции Async подряд, то второй вызов произойдет сразу, не дожидаясь окончания первого вызова.

2. Где выполнять обработку обращений от подключенных клиентов - в одном потоке или в разных?

Из-за отсутствия асинхронности, при разработке сложных TCP (или HTTP) серверов программисту на выбор предоставляется лишь 2 варианта:

  1. Все вызовы OnExecute (или OnGetCommand) выполняются в одном потоке (например в основном). Так сделано в сетевой библиотеке Overbyte ICS. При этом программист должен очень внимательно следить за тем, какие функции он вызывает. Программист должен условно разделять вызываемые функции на "быстрые" и "медленные". Например, функция инкремента Inc() - это "быстрая" функция, она исполняется моментально. А функция обращения к базе данных (например, ExecSQL) - это "медленная" функция, время её выполнения зависит как от сложности SQL-запроса, так и от текущей нагрузки на СУБД (если СУБД занята другими задачами, то ответ на самый простейший SQL-запрос может задержаться на несколько секунд). Разумеется, функцию ExecSQL нельзя ни в коем случае вызывать из контекста OnExecute. Необходимо организовать запуск дополнительного потока и в нём произвести вызов функции ExecSQL. В противном случае ваш сервер не сможет эффективно обслуживать большое количество одновременно подключенных клиентов. Также "медленными" являются функции чтения/записи файлов (особенно, если файлы хранятся на жёстком диске HDD), сокетов, "каналов" (pipes), а также функции, в которых осуществляются длительные математические вычисления (либо иные причины длительного использования процессора). Следует учитывать, что при вызове функции OnExecute (или OnGetCommand) передаётся объект-контекст, содержащий необходимые данные по обмену и методы работы с сокетом. С помощью объекта-контекста осуществляется весь дальнейший обмен с подключенным клиентом. Если вы создали дополнительный поток (например для того, чтобы в нём выполнить функцию ExecSQL), то в этот же поток необходимо передать объект-контекст (после завершения ExecSQL можно с помощью объекта-контекста передать подключенному клиенту результат HTTP-запроса, либо произвести последовательность вызовов функций сетевого обмена, например Socket.WriteLn или Socket.ReadLn). Для обеспечения высокой производительности серверов не следует каждый раз создавать (а затем уничтожать) дополнительные потоки. Гораздо быстрее использовать пулы потоков, в которых некоторое количество worker-потоков (например, 500 шт) заранее созданы, но переведены в "спящий" режим (например, ждут с помощью функции WaitForSingleObject событие появления новой задачи для выполнения). Отличный пример такого пула реализован в стандартной библиотеке многопоточного программирования «Parallel Programming Library» (PPL). С её помощью очень удобно запускать в дополнительных потоках заданные callback-функции либо анонимные функции, например с помощью TTask.Run(). Большое значение играет скорость пробуждения свободного worker-потока. Эта скорость зависит в первую очередь от процессора. Хорошие процессоры умеют пробуждать потоки всего за несколько микросекунд (например, за 10 микросекунд). Это весьма хороший результат, сопоставимый со скоростью вызова многих обычных функций. Чем круче процессор, тем больше у него размер кэшей, значит выше вероятность того, что информация, необходимая для пробуждения потока, находится в кэше и её не требуется извлекать из платы ОЗУ.

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

Сложно сказать однозначно, какой из подходов лучше. В самых простых случаях (если не требуется вызывать "медленные" функции), первый вариант может показать лучшую производительность по сравнению со вторым вариантом, т.к. экономится время на создание и запуск дополнительных потоков, потребление памяти также намного меньше. Но далеко не факт, что асинхронная библиотека "Overbyte ICS" сможет показать выдающиеся результаты в плане быстродействия. Лучше выбрать другую сетевую библиотеку, которая НЕ основана на асинхронных сокетах Windows.

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

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

3. Недостатки при использовании большого количества дополнительных потоков

Разработанный на Delphi сервер (HTTP или TCP) может оперировать огромным количеством дополнительных потоков (сотни или даже тысячи потоков). Это создаёт массу проблем, с которыми вынуждены сражаться Delphi-программисты. Но это не повод всё бросать и срочно изучать другие языки. Delphi-программисту всегда есть к чему стремиться и есть чего улучшить. Тут хочется посочувствовать GoLang-программистам в том, что они используют технологию, которая обеспечивает (практически из коробки) максимальное быстродействие разрабатываемого сервера и почти не оставляет программисту пространства для манёвра (это шутка :).

Вот список проблем, которые возникают при использовании большого количества дополнительных потоков:

  1. На каждый поток по умолчанию выделяется 1 МБ из адресного пространства процесса. Это создаёт проблему для 32-битного сервера, особенно если он запущен на 64-битной ОС Windows. Если сервер является 64-битным, то его данная проблема не касается, поскольку адресное пространство 64-битной программы практически безграничное.

  2. На каждый поток выделяется около 70 КБ ОЗУ. Это небольшая цифра. Если сервер выполняет обработку запросов, полученных одновременно от 10000 подключенных клиентов и на каждое подключение выделен дополнительный поток, то сервер будет потреблять уже 700 МБ. С учётом стоимости ОЗУ это вполне допустимо. Но это в разы больше того, что требуется программе, разработанной на GoLang для решения аналогичной задачи.

  3. Невозможность использования самых быстрых менеджеров памяти. Дело в том, что самые быстрые менеджеры памяти рассчитаны на небольшое количество потоков. К примеру, в Лазарусе (FPC) используется весьма быстрый менеджер памяти, он отлично масштабируется в зависимости от количество ядер процессора (т.е. скорость операций выделения/освобождения блоков памяти примерно прямо пропорциональна количеству задействованных ядер процессора). Но проблема такого быстрого менеджера памяти заключается в том, что он на каждый запущенный дополнительный поток выделяет сразу большой объём оперативной памяти (примерно 1 МБ). Этот участок памяти привязывается к соответствующему потоку. Если поток вызывает функцию GetMem, то выделение блока памяти осуществляется из связанного с ним участка памяти. Такая операция не требует сложных средств синхронизации (теоретически, можно обойтись без Interlocked-команд, но придётся решать вопрос: как организовать вызовы FreeMem из "чужих" потоков). Если задействовать такой быстрый менеджер памяти на 10000 потоках, то он сожрёт дополнительно 10 ГБ ОЗУ. А это уже серьёзно и малоприменимо для разработки сервера на Delphi.
    Можно обойтись стандартным менеджером памяти, который компилируется в Delphi-программы по умолчанию (на базе FastMM4). При его использовании расход памяти ОЗУ минимальный и не зависит от количества потоков. Но он и не был разработан для многоядерных процессоров (его разрабатывали во времена одноядерных процессоров и он даже на сегодняшний момент отлично подходит для десктопных программ, которые не занимаются задачей интенсивной обработки информации во множестве потоков). Если ваш сервер должен обрабатывать обращения одновременно от нескольких тысяч клиентов, то встроенный менеджер памяти будет узким местом: если два потока одновременно решат вызвать функцию GetMem (этот вызов выполняется во многих случаях, например при создании объекта или выделении памяти под динамический массив либо строку), то обращение из второго потока может быть поставлено в очередь (в худшем случае он сможет выделить блок памяти лишь спустя 16 миллисекунд). Т.е. при интенсивном обращении к стандартному менеджеру памяти одновременно из нескольких потоков мы столкнёмся со снижением производительности нашей программы, независимо от количества ядер у процессора.
    К счастью для Delphi-программиста, существует ряд альтернативных менеджеров памяти, в которых обе задачи успешно решены (обеспечено небольшое потребление памяти ОЗУ и хорошая масштабируемость по ядрам процессора). Это FastMM5, [FastMM4_AVX] https://github.com/maximmasiutin/FastMM4-AVX), [SynScaleMM](https://github.com/synopse/mORMot, https://synopse.info/forum/viewtopic.php?id=156), менеджер памяти для FPC mormot.core.fpcx64mm.pas.

  4. Существуют накладные расходы на переключение контекста между потоками. На самом деле, при использовании любого языка программирования данная проблема имеет место, даже при использовании GoLang. Только в GoLang контекст переключается не между потоками, а между задачами, которые выполняются последовательно в контексте того или иного worker-потока. Раньше операция переключения контекста между потоками выполнялась очень долго (в среднем 50 микросекунд). Современные (не самые дешёвые) процессоры справляются с этой задачей гораздо быстрее (от 5 до 10 микросекунд). Это сопоставимо со скоростью вызова функции StrToInt (это не самая шустрая функция, но кто-нибудь задумывался, что у неё имеются какие-нибудь проблемы с производительностью?). Мы должны стараться делать всё от нас зависящее для того, чтобы было как можно меньше переключений контекста между потоками. Ни в коем случае нельзя использовать такие циклы:

i := 0;
while i < 1000 do
begin
  Inc(i);
  if Terminated then Exit;
  Sleep(1);  
end;

Данный код приводит к жуткому количеству операций переключения контекста (в худшем случае - 1000 операций в секунду). Если мы создадим 100 дополнительных потоков, привяжем их к одному ядру и во всех потоках выполним данный код, то ядро будет загружено на 100%. Хотя в данном случае программист всего лишь хотел организовать паузу в 1 секунду с оперативным завершением работы потока. А всего-навсего программисту нужно было разобраться с объектом Event и функцией WaitForSingleObject.

  1. Зависание (или медленная работа) сервера при одновременном выполнении длительных вычислительных операций в нескольких потоках. По умолчанию потоки запускаются с приоритетом "Normal". Нет ничего плохого, если поток ожидает данные из сокета (в этом случае без разницы какой у потока приоритет). Однако если 10 потоков с приоритетом "Normal" выполняют длительные вычисления, а у процессора всего лишь 8 ядер, то подвиснет весь компьютер и скорее всего нормальной работы вы от такой программы не добъётесь. Перед началом вычислений вы должны менять приоритет потока на Lower, тогда проблем не будет. Различные программы-майнеры работают именно с таким приоритетом: в одной стороны они используют процессор на 100%, с другой стороны вам они никак не мешают, так как ваши программы скорее всего работают в приоритете Normal. Но даже если вы для своих вычислительных потоков выставите приоритет Lower, вы должны ограничивать количество таких потоков. Представьте себе, что одно вычисление занимает 10 секунд. Подключенный клиент готов ждать 20 секунд. На вашем сервере есть 8 ядер. Соответственно 8 клиентов могут одновременно отправить запрос и они получат ответ через 10 секунд. Но если вы не будете ограничивать количество вычислительных потоков, то при одновременной отправке запроса от 24 клиентов ни один из них не сможет дождаться ответа за 20 секунд, поскольку вычисления будут выполняться не поочерёдно, а "одновременно" (ОС Windows в таком случае задействует свой механизм вытесняющей многозадачности с выделением ограниченных квантов времени), поэтому вычисление будет выполняться не 10, а 30 секунд. В таком случае лучше обслужить хотя бы 8 клиентов, чем загрузить процессор работой, результаты которой никто не дождётся.

С помощью библиотеки «Parallel Programming Library» (PPL) вы можете создать отдельный пул для потоков, в которых выполняется обработка запросов на вычислительные операции и осуществлять запуск вычислительных задач с помощью класса System.Threading.TFuture. Только не забывайте выставлять приоритет Lower.

ℹ️ Информация! Не обязательно для вычислительных задач запускать дополнительные потоки. Можно произвести вычисления в том же потоке, если вы оформите область кода, в которой производятся вычисления, хотя бы с помощью переменной-счетчика. Т.е. перед началом вычислений проверяем значение счетчика. Если значение счётчика больше или равно количеству ядер процессора, то генерируем ошибку "отказано в обслуживании", либо ожидаем (в цикле, c периодическим вызовом Sleep(500) или WaitForSingleObject) приемлемого значения счётчика. Далее, если дождались приемлемого значения, увеличиваем значение на 1 (с помощью InterlockedIncrement) и приступаем к вычислениям. По завершению вычислений уменьшаем значение счётчика на 1 (с помощью InterlockedDecrement). Не забываем использовать TRY..FINALLY для гарантии того, что значение счётчика будет уменьшено по завершению вычислений. Для более надежного контроля можно использовать объект "семафор", который уже содержит аналогичный счётчик и гарантирует, что в защищённую область не проникнет непрошеный поток. Но если сравнивать приведённые варианты, то самым простым, на мой взгляд, является использование отдельного дополнительного потока (например, через System.Threading.TFuture).

  1. Необходимость защищать данные (объекты, массивы, строки) от одновременного доступа из параллельных потоков. Например, если два потока одновременно пытаются изменить строку (string), то с большой вероятностью возникнет ошибка ("Access violation" либо "Invalid pointer operation"), а в худшем случае произойдет повреждение участка памяти и дальнейшая работа программы будет непредсказуемой. Для защиты от одновременного доступа можно использовать такие примитивы, как "критическая секция" (TCriticalSection), "мьютекс" (TMutex), "монитор" (System.TMonitor), "спин блокировка" (TSpinWait, TSpinLock), "один писатель - много читателей" (TMultiReadExclusiveWriteSynchronizer, TMREWSync). Основные примитивы блокировки доступны в модуле System.SyncObjs. Кроме того, некоторые классы в RTL имеют встроенную защиту от одновременного доступа (например, TThreadList).

ℹ️ Не следует использовать примитивы синхронизации "по любому поводу". При бездумном их использовании можно очень сильно ухудшить производительность программы. Блокировка должна занимать как можно меньше времени (в идеале - не более 1 миллисекунды).

  1. Необходимость обладать определёнными знаниями в области многопоточного программирования. Нюансов очень много! Если что-нибудь не учли, программа будет глючить и большую часть времени вы будете тратить не на разработку, а на борьбу с этими глюками. Рекомендую свою статью по многопоточному программированию в Delphi.

  2. Операция "создание потока" занимает довольно много времени (примерно 100 микросекунд). Если мы разрабатываем TCP-сервер, в котором постоянное соединение с клиентом удерживается длительное время, то можно не волноваться. Однако если разрабатывается HTTP-сервер, который на большинство запросов от клиентов возвращает заранее подготовленные данные, то может оказаться, что время создания потоков превышает время обработки данных, а это уже ненормально! При разработке HTTP-сервера лучше использовать пул потоков. Например, в Indy10 для использования пула потоков следует использовать компонент TIdSchedulerOfThreadPool. При использовании других известных сетевых фреймворков (например, mORMot, DelphiMVCFramework), пул потоков по умолчанию наверняка используется. При использовании пула потоков необходимо задать максимальное (возможно, и минимальное) количество потоков в пуле. Желательно, чтобы пул умел автоматически удалять неиспользуемые потоки, чтобы освободить память ОЗУ, если в данный момент времени количество обращений от клиентов минимальное.

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

В качестве примера указана СУБД Firebird (версии не ниже 3.0), хотя по большому счёту название СУБД значения не имеет.

Что будет, если сервер принял одновременно 500 HTTP-запросов, для обработки которых следует выполнить SQL-запрос к базе данных (например, Firebird)? Здесь есть очень много нюансов. Могу сказать точно, что СУБД одновременно не будет обрабатывать все 500 запросов, а поставит их в очередь. Более того, некоторые СУБД ограничивают максимальное количество подключений либо используемых ядер процессора (например, в зависимости от количества купленных лицензий). В Firebird 3.0 подобных ограничений, к счастью, нет.

Идеальные условия следующие: на сервере много ОЗУ (основные данные из БД полностью помещается в ОЗУ), СУБД максимально эффективно использует как ОЗУ, так и ядра процессора (например, Firebird 3.0), накопитель - хороший SSD, база данных (и СУБД) находится на том же компьютере, где и наш HTTP-сервер. В этом случае любой SQL-запрос к СУБД мы можем рассматривать как "вычислительный", т.е. можно пренебречь временем чтения данных из SSD-накопителя и временем, уходящим на доставку SQL-запросов в СУБД. В этом случае мы можем специально для запросов к СУБД выделить пул из нескольких потоков, исходя из количества ядер процессора. Приоритет потокам менять нет смысла, т.к. потоки лишь будут транслировать запросы в СУБД и возвращать результаты запросов. Если на нашем сервере 8 ядер процессора и мы отправим в СУБД одновременно 8 SQL-запросов, то есть риск "подвесить" весь сервер на некоторое время. Для того, чтобы этого избежать, можно настроить привязку СУБД к ядрам процессора (так, чтобы осталось хотя бы одно ядро свободным от посягательств со стороны СУБД) либо уменьшить приоритет потоков СУБД (если СУБД поддерживает такую возможность). Например, в случае Firebird можно изменить значение параметра CpuAffinityMask. Указанная ситуация ("идеальные" условия) характерна тем, что мы можем точно рассчитать оптимальное количество потоков для обращения к СУБД, а увеличение потоков в пуле не приведёт к увеличению производительности HTTP-сервера.

Однако, очень часто база данных и СУБД вынесены на отдельный сервер, при обращении к этому серверу будут возникать сетевые задержки (например, 1 мс), вместо SSD может использоваться HDD, ОЗУ недостаточно для кэширования основных данных из БД. В этом случае гораздо сложнее определить оптимальный размер пула потоков. В таком случае можно ли вообще не использовать пул потоков? СУБД способна обработать 500 одновременных SQL-запросов, рано или поздно на каждый запрос будет получен ответ. Здесь в первую очередь необходимо думать о клиентах - будет ли 500-й клиент ожидать, когда сервер обработает 499 запросов от других клиентов? Смотря сколько времени уходит на обработку каждого запроса, а это время зависит от сложности SQL-запроса, аппаратной конфигурации сервера, текущей нагрузки на СУБД. Если каждый SQL-запрос обрабатывается 1 секунду, СУБД использует 8 ядер для обработки запросов, то на обработку всех 500 запросов уйдет 63 секунды. Если клиент ожидает ответа не более 20 секунд, то большая часть клиентов разорвёт соединение раньше, чем СУБД успеет обработать запросы от этих клиентов. Значит наш сервер должен заранее отказывать клиентам в обслуживании и не пытаться обрабатывать запросы, ответ на которые никому не нужен. Поэтому механизм ограничения количества одновременных SQL-запросов все-таки необходим. Причем пул потоков на основе PPL может в данном сценарии не подойти. Здесь лучше реализовать очередь SQL-запросов, а потоки из пула будут разгребать элементы из этой очереди и отправлять в СУБД, а также дожидаться результата выполнения отправленного запроса. Перед добавлением нового элемента в очередь необходимо контролировать количество элементов, которые ещё не отправлены в СУБД и если их количество больше некоторого значения (например 8 шт), то ожидать заданное время, пока их количество не уменьшиться до приемлемого значения. Если время ожидания истекло, то выдавать клиенту ошибку "отказ в обслуживании".

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

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

Как все-таки определить оптимальное количество SQL-запросов, которые можно одновременно передать на исполнение в СУБД (в "неидеальных" условиях)? Чёткой формулы нет! Значение нужно подбирать экспериментально. Минимальное значение - это количество ядер процессора на сервере. Но значение необходимо увеличивать в следующих случаях:
а) имеются длительные сетевые задержки (1 мс или больше) при обращении к серверу (можно их измерить с помощью команды ping). Чем больше сетевые задержки, тем больше должно быть искомое значение;
б) на сервере мало ОЗУ и используется медленный HDD. Чем чаще СУБД обращается к HDD, тем больше должно быть искомое значение;

5. Некоторые замечания по разработке TCP-сервера на Indy10

  1. Не используйте слишком маленькое значение свойства Socket.ReadTimeout. При разработке декстопных программ (с графическим интерфейсом) логично использовать маленькое значение, например 2 секунды, но в первую очередь для того, чтобы поток обмена смог оперативно отреагировать на операцию "Выход из программы". Дело в том, что если мы вызвали операцию Socket.ReadXXX, то выход их функции ReadXXX может произойти лишь по трём причинам: а) пришли данные (в нужном количестве); б) закончился таймаут ожидания (ReadTimeout); в) закрыли сокет (любым способом). Таким образом, мы не можем моментально завершить поток, в котором осуществляется вызов ReadXXX. Мы не можем одновременно вызывать функцию ReadXXX и ждать эвент с помощью WaitForSingleObject. Для досрочного завершения функции ReadXXX мы можем из другого потока выполнить закрытие сокета, причем делать это нужно не через штатный метод Disconnect, а путём вызова метода Socket.Binding.CloseSocket.

Что касается сервера, то никто не ждёт от него моментальной реакции на команду "Выйти из программы" или "Остановить служба". Минимальное значение свойства Socket.ReadTimeout должно быть не менее 10 секунд. Если требуется быстро разорвать все установленные соединения, то достаточно выполнить команду TCPServer.Active := False (при этом для каждого установленного подключения будет вызван метод Socket.Binding.CloseSocket).

ℹ️ Информация! После чтения из сокета любого байта отсчёт времени таймаута начинается заново! Если на сервере ReadTimeout равен 10 секунд, клиент отправил в сервер строку "Hello" с интервалом 5 сек между символами, то сервер с помощью Socket.ReadLn примет эту строку через 20 сек, причем ошибки таймаута не будет.

  1. Функции чтения данных из сокета Socket.ReadXXX не создают нагрузку на процессор (кроме ситуации с маленьким значением свойства Socket.ReadTimeout).

  2. Функции чтения данных из сокета Socket.ReadXXX считывают данные из буфера InputBuffer (TIdBuffer). Данные в этот буфер попадают при обращении к некоторым методам (например, Socket.ReadXXX, Socket.CheckForDataOnSource и некоторые другие). Например, клиент мог отправить в сервер пакет размером 1000 байт. Сервер читает данные с помощью метода Socket.ReadByte. При первом вызове Socket.ReadByte библиотека Indy10 может считать в буфер Socket.InputBuffer все 1000 байт, но метод ReadByte вернёт только первый из них, а остальные байты останутся в буфере. При следующем вызове метода ReadByte библиотека Indy не будет проверять сокет на наличие новых данных, а сразу вернёт очередной байт из InputBuffer (этот процесс весьма хорошо оптимизирован). Однако, если затем сервер вызовет функцию Socket.ReadBytes(Bytes, 2000), то библиотека Indy поймёт, что в буфере InputBuffer нет такого количества байт и перейдёт в режим ожидания чтения новых данных из сокета (ресурсы процессора на такое ожидание не тратятся).

  3. Помните, что метод Socket.ReadLn возвращает пустую строку вместо генерации исключения "ошибка таймаута".

  4. Помните, что метод TIdTCPConnection.Connected приводит к генерации исключения, если обе программы запущены на одном компьютере и одну из программ "убили" через диспетчер задач. Это лишь один из примеров. Поэтому защищайте вызов TIdTCPConnection.Connected с помощью TRY..EXCEPT. Рекомендую разработать отдельную функцию для доступа к свойству Connected, например:

function IndyIsConnected(conn: TIdTCPConnection): Boolean;
begin
  try
    Result := conn.Connected;
  except
    conn.Disconnect;
    Result := False;
  end;
end;
  1. Помните, что метод TIdTCPConnection.Connected вернёт TRUE, если соединение закрыто, но во входном буфере InputBuffer остался хотя бы один непрочитанный байт.

  2. Имейте ввиду, что вызов метода TIdTCPConnection.Disconnect не приводит к очистке буфера InputBuffer. Если вы вызвали метод Disconnect, а буфер InputBuffer содержит хотя бы один байт, то при следующей попытке вызвать метод TIdTCPClient.Connect возникнет ошибка "соединение уже установлено". Поэтому после вызова метода Disconnect необходимо выполнять очистку буфера Socket.InputBuffer. Это касается разработки как сервера, так и клиента.

  3. TCP-сервер должен быть готов к сценарию исчезновения подключения, при котором не генерируется никакого сигнала об отсутствии соединения. Такое происходит, если выдернуть Ethernet-кабель из компьютера. Конечно, через какое-то время проблема обнаружится и Windows закроет сокет, но это произойдёт через 120 минут (это значение параметра KeepAliveTime в Windows по умолчанию). Необходимо обходить такие ситуации путём использования свойства Socket.ReadTimeout. Также данная проблема обнаружится немедленно при вызове метода Socket.WriteXXX (будет сгенерировано исключение).

  4. Перед вызовом функции Socket.CheckForDataOnSource рекомендуется проверять наличие данных в буфере InputBuffer. Если данные в буфере есть, а вы всё равно вызвали метод CheckForDataOnSource, то Indy будет ожидать поступления новых данных.

  5. Используйте актуальную версию библиотеки Indy10. Если вы пользуетесь старой версией Delphi (например, 2007), то в комплекте с ней идёт древняя версия Indy10, в которой полно глюков и многие методы реализованы крайне неоптимально.

  6. Для логгирования входящих и отправленных данных используйте свойство Intercept (перехватчик). Нужно кинуть на форму или создать объект-перехватчик (например, TIdLogFile) и присвоить этот объект свойству Intercept. Доступные перехватчики находятся на панели компонентов в разделе "Indy Intercepts".

6. Некоторые замечания по разработке HTTP-сервера на Indy10

  1. Компонент TIdHTTPServer может прослушивать одновременно множество TCP-портов. Например, порт 80 для приёма http-запросов и порт 443 для приёма HTTPS-запросов.

  2. Если для приёма HTTPS-запросов используется порт, отличный от 443, то необходимо реализовать метод TIdHTTPServer.OnQuerySSLPort, в котором следует возвращать значение VUseSSL, в зависимости от номера порта APort.

  3. Не обязательно делать поддержку HTTPS. С поддержкой HTTPS отлично справляется реверс-прокси (например, nginx). Он поддерживает любые типы современных протоколов защиты передаваемых данных, в том числе TLS 1.3, знаком администраторам и самостоятельно может разрулить вопросы, связанные с автоматическим обновлением сертификатов.

  4. Для повышения производительности считаю разумным использовать пул потоков. Для этого можно кинуть на форму (либо создать) компонент TIdSchedulerOfThreadPool и присвоить данный компонент свойству TIdHTTPServer.Scheduler.

  5. К сожалению, в Indy10 до сих пор отсутствует поддержка протокола защиты TLS 1.3.