1
0
mirror of https://github.com/loginov-dmitry/multithread.git synced 2025-02-20 07:58:22 +02:00

добавлено несколько новых разделов

This commit is contained in:
loginov-dmitry 2021-01-05 14:18:40 +03:00
parent 50d2a5158d
commit 582c9d2eba

View File

@ -1,6 +1,6 @@
# Многопоточное программирование в Delphi для начинающих
(редакция 1.6 от 03.01.2021г)
(редакция 1.6 от 05.01.2021г)
Автор: Логинов Д.С.
Пенза, 2021
@ -90,7 +90,7 @@ markdown-редакторы не подошли, т.к. они либо не у
6.3 Приоритеты потоков в Windows [...](#about_priority_in_windows)
7. Основные объекты синхронизации в Windows [...](#main_sync_objects)
7. Основные объекты синхронизации [...](#main_sync_objects)
7.1 Главный поток управляет работой дополнительного потока с помощью объекта "Event" (событие) [...](#sync_obj_event)
@ -100,9 +100,15 @@ markdown-редакторы не подошли, т.к. они либо не у
7.4 Пара слов об объекте "Semaphore" [...](#sync_obj_semaphore)
8. Где можно и где нельзя создавать и уничтожать потоки [...](#bad_places_for_create_free_threads)
7.5 Критическая секция и монитор [...](#sync_crit_sect)
7.6 Механизм синхронизации "много читателей и один писатель" (MREW) [...](#sync_mrews)
8. Обработка исключений в дополнительных потоках [...](#threads_exceptions)
9. Где можно и где нельзя создавать и уничтожать потоки [...](#bad_places_for_create_free_threads)
9. Немного о threadvar [...](#about_threadvar)
10. Немного о threadvar [...](#about_threadvar)
# 1. Вступление <a name="intro"></a>
@ -2026,7 +2032,7 @@ end;
Если Вы хотите вникнуть в детали реализации планировщика, то рекомендую статью Марка Руссиновича и Дэвида Соломона [Processes, Threads, and Jobs in the Windows Operating System](https://www.microsoftpressstore.com/articles/article.aspx?p=2233328&seqNum=7).
# 6.1 Роль системного таймера (system clock) <a name="os_timer"></a>
## 6.1 Роль системного таймера (system clock) <a name="os_timer"></a>
Как уже было сказано в разделе 1.3, системный планировщик автоматически выполняет действия, необходимые для перевода потока в спящий режим, как только поток истратит выделенный ему квант времен (time slice), либо процессорное время потребуется потоку с более высоким приоритетом. Также поток может самостоятельно перейти в спящий режим в тех случаях, если он выполнил свою работу, либо перешёл в режим ожидания события. Каждый раз, когда поток переходит в спящий режим, происходит сохранение его контекста (сохраняется текущее состояние регистров процессорного ядра, на котором поток выполняется). При возобновлении работы потока выполняется обратное действие – восстановление контекста потока (загрузка ранее сохраненных значений в регистры процессорного ядра).
@ -2062,13 +2068,13 @@ end;
:warning: *Я привёл здесь очень упрощенный алгоритм работы планировщика. Это лишь вершина айсберга. На самом деле эффективное планирование потоков - это очень сложная задача, при решении которой необходимо учитывать тысячи нюансов. Я думаю, что общий объём кода диспетчера ядра Windows может достигать сотен тысяч строк программного кода.*
# 6.2 Влияние количества запущенных потоков на производительность системы <a name="thread_count_cost"></a>
## 6.2 Влияние количества запущенных потоков на производительность системы <a name="thread_count_cost"></a>
Очевидно, что чем больше потоков, тем хуже производительность системы. Я предполагаю, что при каждом запуске системного планировщика по прерыванию от системного таймера он производит анализ всех запущенных потоков, независимо от их состояния. Чем больше потоков, тем больше действий будет выполнять планировщик.
Однако большое количество потоков, находящихся в спящем состоянии, не оказывает никакого влияния на процесс переключения контекста между активными потоками. Т.е. если поток вызвал функцию `Sleep(0)` или `SwitchToThread`, то учитываются данные, заранее подготовленые планировщиком, лишние действия не выполняются.
# 6.3 Приоритеты потоков в Windows <a name="about_priority_in_windows"></a>
## 6.3 Приоритеты потоков в Windows <a name="about_priority_in_windows"></a>
При разработке многопоточной программы для Windows существует возможность назначать потоку уровень приоритета (свойство `TThread.Priority`). По умолчанию используется уровень приоритета `tpNormal`. Тип `TThreadPriority` объявлен следующим образом:
@ -2094,7 +2100,7 @@ end;
:information_source: Помимо терминов "уровень приоритета потока" и "класс приоритета процесса", существует также термин "базовый приоритет потока". Как определить базовый приоритет потока, смотрите в [таблице приоритетов](https://docs.microsoft.com/en-us/windows/win32/procthread/scheduling-priorities).
# 7. Основные объекты синхронизации в Windows <a name="main_sync_objects"></a>
# 7. Основные объекты синхронизации <a name="main_sync_objects"></a>
ОС Windows (возможно, и Linux, а также и другие ОС) предлагает множество различных объектов ядра для реализации задач синхронизации между потоками и между процессами, в том числе: `Event`, `Mutex`, `Semaphore`, `Thread`, `Process` и другие. Их объединяет то, что каждый такой объект создается в памяти ядра ОС, поэтому доступ к любому из объектов может быть запрошен из любого приложения. Таким образом, из одного приложения есть возможность дождаться, например, окончания потока, запущенного в другом приложении. Либо в одном приложении можно создать объект события "Event", а в другом приложении вызвать функцию из семейства `WaitFor`, которая будет ожидать возникновения события в первом приложении.
@ -2109,7 +2115,7 @@ end;
В любом случае, для защиты критического участка кода от одновременного доступа из нескольких потоков, чаще всего (в ОС Windows) наиболее эффективным является использование примитива синхронизации ОС "критическая секция". Критическая секция сначала пытается использовать цикл spin-блокировки (в режиме user-mode), а затем, если защищаемый ресурс так и не освободился, то переходит к использованию объекта синхронизации уровня ядра "мьютекс" (в режиме kernel-mode). Вместо критической секции в современных вериях Delphi Вы можете использовать `System.TMoninor` (метод `TMonitor.Enter` для начала защиты участка кода и `TMonitor.Leave` для окончания защиты. При этом для блокировки в режиме kernel-mode используется объект Event, а не Mutex).
# 7.1 Главный поток управляет работой дополнительного потока с помощью объекта "Event" (событие) <a name="sync_obj_event"></a>
## 7.1 Главный поток управляет работой дополнительного потока с помощью объекта "Event" (событие) <a name="sync_obj_event"></a>
В любой ОС (Windows, Unix, Linix, MacOS, Android, iOS и т.д.) существует очень важный объект синхронизации - Event (событие). С его помощью можно синхронизировать работу нескольких процессов, либо работу нескольких потоков в рамках одного процесса. Очень часто объект Event используется при ожидании события завершения ввода/вывода. Например, при работе с COM-портом мы можем создать объект Event и попросить ОС, чтобы она вызвала `SetEvent` в тот момент, когда в порту появились новые данные. При этом наш код может ожидать появления новых данных с помощью функции из семейства `WaitFor`. Аналогично можно поступить при организации обмена данными по сети, либо при работе с файлами.
@ -2207,7 +2213,7 @@ end;
Мы знаем, что при уничтожении дополнительного потока из основного потока деструктор отрабатывает в контексте основного потока. Как только произойдет вызов `Event.SetEvent`, планировщик может приостановить работу основного потока и возобновить работу дополнительного потока на том же ядре процессора. Если к этому моменту не был вызван метод `Terminate`, то выход из метода `TMyThread.Execute` не произойдет и будет в очередной раз вызван метод `Event.WaitFor(5 * 1000)`. Если пауза в WaitFor больше (несколько минут), то программа подвиснет на несколько минут, пока деструктор объекта-потока не завершит свою работу. Такой глюк не всегда просто поймать, поскольку в большинстве случаев при вызове SetEvent планировщик не приостанавливает работу основного потока.
# 7.2 Использование объекта "Event" для разработки потока логгирования <a name="sync_obj_event_logger"></a>
## 7.2 Использование объекта "Event" для разработки потока логгирования <a name="sync_obj_event_logger"></a>
В папке ExSync\ExEvent\SimpleLogger находится проект, в котором демонстрируется разработка простейшего потока логгирования.
@ -2332,9 +2338,9 @@ end;
Благодаря явно заданному таймауту 2000 мс мы исключаем вероятность возникновения редкого состояния, при котором основной поток добавил новое событие и вызвал `Event.SetEvent`, а дополнительный поток параллельно был разбужен предыдущим вызовом SetEvent и проигнорировал новый вызов SetEvent (т.е. перевод объекта Event в состояние "NONSIGNALED" при прерывании метода `Event.WaitFor(2000)` происходит одновременно с новым вызовом `Event.SetEvent`, поэтому новый вызов может быть проигнорирован).
# 7.3 Пара слов об объекте "Mutex" <a name="sync_obj_mutex"></a>
## 7.3 Пара слов об объекте "Mutex" <a name="sync_obj_mutex"></a>
Я рамках данной работы я не вижу большого смысла подробно останавливаться на объекте ядра "Mutex". Если у читателя есть желание с ним познакомиться поближе, существует бесконечный океан информации в Интернете.
Я рамках данной работы я не вижу большого смысла подробно останавливаться на объекте ядра "Mutex". Если у читателя есть желание с ним познакомиться поближе, существует бесконечный океан информации в Интернете. Delphi-программисту для работы с мьютексом доступны API-функции Windows: CreateMutex, OpenMutex, ReleaseMutex, WaitForSingleObject, WaitForMultipleObjects и т.д. Также доступен кроссплатформенный класс-обёртка TMutex из модуля "System.SyncObjs".
Отмечу вкратце только основные моменты:
@ -2348,15 +2354,91 @@ end;
5. Сам факт наличия созданного объекта "именованный мьютекс" в одном приложении может использоваться при принятии решений в другом приложении. Например, если первый экземпляр программы создал именованный мьютекс с именем "MyProgramStartProtect", то второй экземпляр после создания мьютекса с таким же именем получит GetLastError = ERROR_ALREADY_EXISTS, после чего можно прервать запуск второго экземпляра приложения.
# 7.4 Пара слов об объекте "Semaphore" <a name="sync_obj_semaphore"></a>
6. Поток, **владеющий** мьютексом, может повторно вызвать функцию WaitFor по отношению к этому мьютексу. В этом случае блокировка потока не выполняется, но поток должен гарантированно вызвать функцию ReleaseMutex. Например так:
```pascal
WaitForSingleObject(hMutex, INFINITY);
try
some code...
finally
ReleaseMutex(hMutex);
end;
```
:warning: **Внимание!** Не забывайте проверять результат функции WaitForXXX даже с параметром INFINITY. Она может вернуть управление не только при успешном окончании ожидания мьютекса, но также и в случае ошибки (например, если другой поток вызвал CloseHandle по отношению к этому же мьютексу, а Handle у мьютекса был единственным, либо мьютексом владел поток из другого процесса, но процесс завершился без вызова ReleaseMutex).
## 7.4 Пара слов об объекте "Semaphore" <a name="sync_obj_semaphore"></a>
Семафор - старейший объект синхронизации режима ядра. Его реализация присутствует во всех популярных ОС. На его основе разработано множество различных алгоритмов синхронизации. Очень хорошо данная тема рассмотрена в wiki-статье [Семафор (программирование)](https://ru.wikipedia.org/wiki/%D0%A1%D0%B5%D0%BC%D0%B0%D1%84%D0%BE%D1%80_(%D0%BF%D1%80%D0%BE%D0%B3%D1%80%D0%B0%D0%BC%D0%BC%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5)).
Delphi-программисту для работы с семафором доступны API-функции Windows: CreateSemaphore, OpenSemaphore, ReleaseSemaphore, WaitForSingleObject, WaitForMultipleObjects и т.д. Также доступы кроссплатформенные класс-обёртка TSemaphore и класс TLightweightSemaphore (легковесный семафор) из модуля "System.SyncObjs".
За многие десятилетия было опубликовано множество научных работ, в которых показано, как реализовать тот или иной алгоритм синхронизации на основе семафора. К сожалению, с практической точки зрения есть трудности: весьма сложно понять, для чего нужно применять семафор непосредственно программисту при решении прикладных задач (вероятно, в будущем у меня появятся удачные примеры использования семафоров). Понятно, что семафор позволяет ограничить количество потоков, которые одновременно работают над выполнением какой-то задачи. Но на практике получается обычно наоборот - один поток выполняет множество задач (по очереди). Пишут либо про некие абстрактные "ресурсы", доступ к которым контролируется с помощью семафора, либо знакомят с проблемой взаимоблокировок и с её решением (см. "Обедающие философы"), либо чрезмерно детально описывают решение некоторой прикладной задачи, в которой смогли удачно вписать использование семафора.
К сожалению, я не решал задач, которые могли бы продемонстрировать возможности семафоров при разработке прикладного ПО. К тому же я не собирался в рамках данной статьи (для начинающих) демонстрировать сложные алгоритмы синхронизации между потоками.
# 8. Где можно и где нельзя создавать и уничтожать потоки <a name="bad_places_for_create_free_threads"></a>
## 7.5 Критическая секция и монитор <a name="sync_crit_sect"></a>
Примитив синхронизации "Критическая секция" уже упоминался в данной статье. Для работы с критической секцией в 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. В качестве объекта-монитора Вы можете использовать любой созданный объект, например так:
```pascal
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: Инструменты для работы с многопоточностью и асинхронностью](https://habr.com/ru/post/459514/), в которой замечательно описывается назначение Monitor и представлены наглядные примеры его использования.
## 7.6 Механизм синхронизации "много читателей и один писатель" (MREW) <a name="sync_mrews"></a>
В Delphi, как и во многих языках программирования, присутствует сложный объект синхронизации, работающий по принципу "много читателей и один писатель" (System.SysUtils.TMultiReadExclusiveWriteSynchronizer). Например, есть массив, состоящий из 1 млн. элементов (это может быть TDataSet с 1 млн. записей). Программисту было лень делать его индексацию, поиск элементов в массиве программист организовал методом "тупого перебора", поэтому время поиска занимает много времени (например, 500 миллисекунд). Но у программиста 16-ядерный процессор, поэтому ему на эту проблему наплевать. Одновременно поиск в массиве могут выполнять множество (до 16) потоков. Но где-то раз в 5 секунд в этот массив необходимо вносить изменения (изменить значение элементов массива, добавить новый элемент или удалить ненужный). А тут уже без синхронизации не обойтись! Но синхронизация с помощью обычной критической секции не подходит, т.к. из-за длительного времени поиска блокировка критической секцией будет занимать от 500 (в лучшем случае) до 16х500 (в худшем случае) миллисекунд (в худшем случае это целых 8 секунд!). На помощь такому программисту приходит TMultiReadExclusiveWriteSynchronizer. С его помощью все 16 потоков по-прежнему смогут параллельно выполнять поиск в массиве, не блокируя друг друга, но при необходимости изменения массива произойдёт блокировка: поток, который хочет изменить массив, дождётся, когда все остальные потоки завершат чтение, выполнит изменение массива в монопольном режиме, а затем разрешит остальным потокам вновь выполнять чтение массива.
:warning: **Внимание!** Не используйте метод "тупого перебора" для массивов из 1 млн. элементов. Лучше заранее индексируйте такой массив с помощью словаря (TDictionary), либо сразу используйте такой словарь для хранения данных. В этом случае для защиты словаря будет достаточно обычной критической секции, а время поиска будет минимальным (менее 0.01 мс)!
# 8. Обработка исключений в дополнительных потоках <a name="threads_exceptions"></a>
Если при выполнении кода в дополнительном потоке (например, в TMyThread.Execute) возникнет ошибка (исключение), то работа метода Execute прервётся (если Вы не добавили в него try..except для перехвата исключения). Но это не приведёт к прекращению работы всей программы. Кроме того, если объект потока ещё не уничтожен, то Вы сможете запросить у него информацию об ошибке (свойство TThread.FatalException) и показать её пользователю (либо использовать каким-то иным способом). Желательно завершить обработку объекта TThread.FatalException до того, как будет уничтожен объект потока, в противном случае существует вероятность возникновения сбоев (из-за того, что объект FatalException уничтожается в деструкторе объекта потока), но это не точно! :)
Если Вы разрабатываете долгоживущий поток (например, раз в 10 секунд он запрашивает информацию из базы данных и передаёт её в основной поток для обновления GUI), то каждая итерация цикла должна быть защищена с помощью блока try..except. Но ошибку мало перехватить, её также нужно как-то обработать, например уведомить пользователя, или сохранить информацию об ошибке в лог. Ни в коем случае не показывайте пользователю сообщение об ошибке, блокирующее работу дополнительного потока. В идеале в программе должна быть какая-то панель уведомлений, на которой появится текст ошибки и он может даже мигать для привлечения внимания пользователя. Как организовать такую панель - Вы подумайте сами, а я покажу, как вывести информацию об ошибке в лог и в компонент TListBox, тем более у нас уже есть модуль MTLogger.pas.
Пример находится в папке "ExExceptions". Это очень простой пример, Вы самостоятельно с ним разберётесь. Если в нём что-то непонятно, то прочитайте материал данной статьи. Я лишь приведу исходный код метода TMyThread.Execute:
```pascal
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. Где можно и где нельзя создавать и уничтожать потоки <a name="bad_places_for_create_free_threads"></a>
Данные замечания актуальны при разработке программ под Windows. Существует ряд мест, где не следует создавать либо уничтожать потоки.
1. Создавать потоки не следует в секции `initialization` (как в EXE, так и в DLL). По моему опыту создание дополнительных потоков в секции `initialization` EXE-модуля (не важно, в каком pas-файле) приводит к снижению стабильности приложения. Грубо говора, один раз из 100 возникает сбой при запуске приложения.
@ -2364,7 +2446,7 @@ end;
Если Вы разрабатываете DLL-библиотеку, в которой используются дополнительные потоки, то рекомендую реализовать в ней две экспортные функции – одну для создания необходимых объектов в библиотеке (например, `CreateLibrary`), вторая для уничтожения созданных объектов (например, `DestroyLibrary`). В этом случае код создания потоков можно разместить в `CreateLibrary` (либо создавать их позже), а код уничтожения объектов потоков разместить в `DestroyLibrary`.
# 9. Немного о threadvar <a name="about_threadvar"></a>
# 10. Немного о threadvar <a name="about_threadvar"></a>
При разработке многопоточного приложения в Delphi программист может использовать ключевое слово `threadvar` (локальная переменная потока) при объявлении глобальных переменных. Для каждого дополнительного потока создаётся собственная копия таких переменных. Один поток не сможет прочитать значение, которое записал в эту переменную другой поток. Переменные, объявленные в `threadvar`, создаются при создании дополнительного потока, а уничтожаются при завершении работы потока.