From 8e95b9dfda50174ec510a2f3599b795785943253 Mon Sep 17 00:00:00 2001 From: loginov-dmitry <67510034+loginov-dmitry@users.noreply.github.com> Date: Fri, 4 Jun 2021 19:30:43 +0300 Subject: [PATCH] =?UTF-8?q?=D1=81=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=B8=D0=BD?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BE=20TId?= =?UTF-8?q?TCPServer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- multithread_in_delphi_for_beginners.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/multithread_in_delphi_for_beginners.md b/multithread_in_delphi_for_beginners.md index 849b799..d9439a1 100644 --- a/multithread_in_delphi_for_beginners.md +++ b/multithread_in_delphi_for_beginners.md @@ -1,6 +1,6 @@ # Многопоточное программирование в Delphi для начинающих -(редакция 1.9 от 05.04.2021г) +(редакция 1.10 от 04.06.2021г) Автор: Логинов Д.С. Пенза, 2021 @@ -21,6 +21,7 @@ markdown-редакторы не подошли, т.к. они либо не у - пример программирования в PPL. Нужно дать ссылки на статьи и видеоуроки в интернете - подробнее об OmniThreadLibrary - сначала нужно дождаться кроссплатформенной версии - функция SignalObjectAndWait +- рассмотреть проблему прерывания ожидания при использования TIdTCPServer (например, при ReadTimeOut равном 30 сек.). Хотелось бы, чтобы и нагрузка на процессор была минимальной и чтобы служба с 1000 подключений могла закрываться оперативно 2020-07-28 - начало работы над markdown-версией @@ -1053,6 +1054,8 @@ end. 7) При выходе из приложения в методе `TForm1.FormDestroy` выставляется флаг `StopThreadsFlag := True`, а затем выполняется цикл ожидания нулевого значения в переменной `ThreadCount`. Данная переменная проверяется каждые 10 мс (пауза в работе основного потока организована с помощью `Sleep(10)`). Выход из программы произойдет после того, как пользователь нажмёт кнопку «ОК», во всех сообщениях, открытых из дополнительных потоков. +:warning: **Внимание!** Данный подход к организации цикла ожидания имеет недостатки. Например, если из дополнительного потока произойдет вызов функции Syncronize, либо SendMessage, то цикл ожидания никогда не дождётся окончания работы потока, возникнет взаимная блокировка. Но если мы не будем использовать `FreeOnTerminate`, то с такой проблемой не столкнёмся. + 8) Кнопка `btnRunInParallelThread` может быть нажата произвольное количество раз. :warning: **Внимание!** *Функции `InterlockedIncrement` и `InterlockedDecrement` являются частью Windows API. В современных версиях Delphi Вы можете использовать их кроссплатформенные аналоги: `AtomicIncrement` и `AtomicDecrement`, к тому же они объявлены как inline и выполняются с максимально возможной скоростью*. @@ -2247,7 +2250,7 @@ end; Реализация потока логгирования находится в модуле CommonUtils\MTLogger.pas. Наименование класса потока: TLoggerThread. -Попробуйте скомпилировать проект SimpleLogger.dpr и запустить программу. Нажмите кнопку "Добавить сообщения в лог-файл без дополнительного потока". Программа покажет на экране время, которое заняла данная операция. У меня для 10000 сообщение уходит более 20 секунд. +Попробуйте скомпилировать проект SimpleLogger.dpr и запустить программу. Нажмите кнопку "Добавить сообщения в лог-файл без дополнительного потока". Программа покажет на экране время, которое заняла данная операция. У меня для 10000 сообщений уходит более 20 секунд. Теперь нажмите кнопку "Добавить сообщения в лог-файл через дополнительный поток". У меня для 10000 сообщений уходит 20 миллисекунд. Разница в 1000 раз! Впечатляет? Причины такой огромной разницы в следующем: @@ -2284,9 +2287,9 @@ end; 2. Без дополнительного потока данная функция вызывается 10000 раз, что по времени составляет более 20 секунд. При этом основной поток зависает на те же 20 секунд. -3. Наличие дополнительного потока позволяет минимизировать количество вызовов функции WriteStringToTextFile. Перед вызовом данной функции выполняется составление большой строки, состоящей из множества сообщений, накопленных в буфере (см. реализацию метода `TLoggerThread.Execute`). +3. Наличие дополнительного потока позволяет минимизировать количество вызовов функции `WriteStringToTextFile`. Перед вызовом данной функции выполняется составление большой строки, состоящей из множества сообщений, накопленных в буфере (см. реализацию метода `TLoggerThread.Execute`). -4. При использовании потока `TLoggerThread` Функция `WriteStringToTextFile` вызывается из дополнительного потока, а не из основного. Даже если функция `WriteStringToTextFile` будет выполняться в несколько раз дольше, на работу основного потока это никак не повлияет! +4. При использовании потока `TLoggerThread` функция `WriteStringToTextFile` вызывается из дополнительного потока, а не из основного. Даже если функция `WriteStringToTextFile` будет выполняться в несколько раз дольше, на работу основного потока это никак не повлияет! Обратите внимание на реализацию метода `TLoggerThread.Execute`: @@ -2498,7 +2501,7 @@ Consumer-поток должен в бесконечном цикле вызыв # 10. Где можно и где нельзя создавать и уничтожать потоки Данные замечания актуальны при разработке программ под Windows. Существует ряд мест, где не следует создавать либо уничтожать потоки. -1. Создавать потоки не следует в секции `initialization` (как в EXE, так и в DLL). По моему опыту создание дополнительных потоков в секции `initialization` EXE-модуля (не важно, в каком pas-файле) приводит к снижению стабильности приложения. Грубо говора, один раз из 100 возникает сбой при запуске приложения. +1. Создавать потоки не следует в секции `initialization` (как в EXE, так и в DLL). По моему опыту создание дополнительных потоков в секции `initialization` EXE-модуля (не важно, в каком pas-файле) приводит к снижению стабильности приложения. Грубо говоря, один раз из 100 возникает сбой при запуске приложения. 2. Не следует уничтожать потоки в секции `finalization` DLL-библиотеки. Если Вы попытаетесь это сделать, то, скорее всего программа зависнет (это происходит из-за особенностей выгрузки DLL-библиотек в ОС Windows). Если Вы разрабатываете DLL-библиотеку, в которой используются дополнительные потоки, то рекомендую реализовать в ней две экспортные функции – одну для создания необходимых объектов в библиотеке (например, `CreateLibrary`), вторая для уничтожения созданных объектов (например, `DestroyLibrary`). В этом случае код создания потоков можно разместить в `CreateLibrary` (либо создавать их позже), а код уничтожения объектов потоков разместить в `DestroyLibrary`. @@ -2594,8 +2597,17 @@ end; 3. Использование функции WinAPI-функции `Sleep` либо `WaitForXXX` для организации задержек в бесконечном цикле. Очень простое решение - реализовать в дополнительном потоке бесконечный цикл, периодически проверять значение какого-то флага (либо элемента в очереди), в зависимости от значения флага выполнять какое-либо действие, после чего переводить поток в спящий режим с помощью `Sleep` либо `WaitForXXX`. Если вы укажете время ожидания 1000 мс (или больше), то нет проблем (в том смысле, что нагрузка на процессор будет минимальной - примерно 50000 тактов в секунду при паузе в 1 секунду). Однако если Вы будете проверять значение флага каждые 10 миллисекунд, то нагрузка на процессор будет уже существенной - 2500000 тактов в секунду будет тратиться только на работу `Sleep(10)`. Если у вас запущено 1000 таких потоков и каждый расходует 2500000 тактов в секунду, то будет обеспечена 100% загрузка одного ядра процессора (либо эта загрузка будет размазана по нескольким ядрам). Т.е. ваши 1000 потоков ещё не делают ничего полезного, но уже грузят процессор по полной программе! :) Для того, чтобы такой проблемы не было, не рекомендуется использовать `Sleep` либо `WaitForXXX` с маленькой задержкой. Гораздо лучше использовать `WaitForXXX` с большой задержкой (в том числе, INFINITY), а при изменении значения флага следует переводить объект ядра, который ожидает функция `WaitForXXX`, в сигнальное состояние. В этом случае потоки не будут тратить на контроль состояния флага практически никаких ресурсов процессора! -4. Использование TIdTCPServer для поддержания большого количества сетевых подключений. В древних версиях Indy10 вызовы `Socket.ReadXXX` приводили к высокой загрузке процессора. Приходилось их "разбавлять" с помощью `Sleep(1)` для снижения нагрузки на процессор. Такая же ситуация была с методом `Socket.CheckForDataOnSource`. Более того, раньше метод `Socket.CheckForDataOnSource` ещё и глючил - не возвращал управление, пока не закончится время ожидания, даже если пришли данные (поэтому приходилось его вызывать с минимальным таймаутом). Указанные проблемы приводили к созданию огромной нагрузке на процессор при большом количестве сетевых подключений. -На данный момент в Indy10 эти проблемы решены. Актуальную версию Indy10 можно скачать с github, при этом она обычно работает c любой версией Delphi. Теперь методы `ReadXXX` и `CheckForDataOnSource` не создают никакой нагрузки на процессор и возвращают управление немедленно при появлении данных в сокете (в течение 100 микросекунд, если сервер и клиент запущены на одном компьютере). +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`). 5. Большой расход памяти при использовании некоторых менеджеров памяти. Современные высокопроизводительные менеджеры памяти, например, [tcmalloc](https://github.com/google/tcmalloc), могут создавать для каждого потока свой собственный пул блоков памяти. Благодаря этому удаётся минимизировать количество блокировок при выделении и освобождении памяти, что позволяет разрабатывать приложения, которые хорошо масштабируются по количеству ядер, т.е. могут максимально эффективно использовать доступные ресурсы памяти и процессора. Кстати, для Delphi имеется интерфейсный файл [tcmalloc.pas](https://github.com/obones/tcmalloc-delphi), позволяющий использовать менеджер памяти tcmalloc, представленный в виде библиотеки "libtcmalloc.dll". С точки зрения производительности, он значительно эффективнее встроенного в Delphi менеждера памяти, при этом поддерживаются любые версии Delphi, однако в нём отсутствуют многие плюшки, которые есть в FastMM4. Проблема таких менеджеров памяти заключается в том, что им требуется значительно больший объем памяти ОЗУ по сравнению со стандартным менеджером памяти. На каждый поток такой менеджер памяти запросто может выделить дополнительно по 1 МБ ОЗУ. При использовании такого менеджера памяти вы не сможете создавать тысячи потоков, поскольку память ОЗУ может очень быстро закончиться. С точки зрения разработки серверов это означает, что сетевую библиотеку Indy10 с таким менеджером памяти лучше не использовать. Вместо Indy10 вы можете рассмотреть сетевые библиотеки, работающие по другим принципам, например, использующие асинхронные сокеты Windows (это сложнее, чем Indy10) либо механизм "i/o completion ports" (это значительно сложнее, чем Indy10).