Здавалка
Главная | Обратная связь

Глава 6. Синхронизация потоков



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

В многопоточных приложениях при наличии общего (разделяемого) ресурса могут возникнуть следующие ситуации:

- попытка одновременного изменения ресурса;

- попытка чтения еще не измененного ресурса.

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

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

Пользовательский режим

Переход программы из пользовательского режима в режим ядра (п. 2.5) и обратно связан с определенными временными издержками. Windows предоставляет ряд средств, упрощающих синхронизацию потоков. При этом большинство задач синхронизации решается без использования объектов ядра, то есть в пользовательском режиме.

Атомарный доступ

Даже такая простая операция, как инкрементирование, на самом деле состоит из нескольких команд процессора:

mov eax,X // значение из переменной X помещается в регистр

inc eax // значение регистра увеличивается на 1

mov X,eax // значение из регистра помещается обратно в X

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

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

Например, функция InterlockedExchangeAdd инкрементирует переменную addend на заданное значение value, причем пока эта операция выполняется, другие потоки не смогут изменить значение данной переменой:

// addend = addend + value

LONG WINAPI InterlockedExchangeAdd(LONG* addend, LONG value);

Таким образом, вышеописанная задача с инкрементированием решается следующим образом:

// Глобальная переменная

long X = 0;

 

// Функция потока 1

DWORD WINAPI ThreadFunc1(PVOID param)

{

...

InterlockedExchangeAdd(&X, 1); // X++

...

return 0;

}

 

// Функция потока 2

DWORD WINAPI ThreadFunc2(PVOID param)

{

...

InterlockedExchangeAdd(&X, 1); // X++

...

return 0;

}

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

Критические секции

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

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

VOID InitializeCriticalSection(CRITICAL_SECTION* cs);

По окончании работы с критической секцией следует удалить ее вызовом функции DeleteCriticalSection:

VOID DeleteCriticalSection(CRITICAL_SECTION* cs);

Она сбрасывает все переменные-члены структуры CRITICAL_SECTION. Естественно, нельзя удалять критическую секцию в тот момент, когда ею все еще пользуется какой-либо поток.

Участок кода, работающий с разделяемым ресурсом, предваряется вызовом:

VOID EnterCriticalSection(CRITICAL_SECTION* cs);

Функция EnterCriticalSection выполняет следующие действия:

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

2. Если значения элементов структуры свидетельствуют, что ресурс уже захвачен вызывающим потоком, функция обновляет их, отмечая тем самым, сколько раз подряд этот поток захватил ресурс, и немедленно возвращает управление.

3. Если значения элементов структуры указывают на то, что ресурс занят другим потоком, функция переводит вызывающий поток в режим ожидания.

Функция TryEnterCriticalSection позволяет потоку быстро проверить, доступен ли ресурс:

BOOL TryEnterCriticalSection(CRITICAL_SECTION* cs);

Иначе говоря, в отличие от EnterCriticalSection, данная функция не переводит вызывающий поток в режим ожидания, если ресурс занят. Если TryEnterCriticalSection возвращает TRUE, значит, она обновила элементы структуры CRITICAL_SECTION так, чтобы они сообщали о захвате ресурса вызывающим потоком.

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

VOID LeaveCriticalSection(CRITICAL_SECTION* cs);

Эта функция просматривает элементы структуры CRITICAL_SECTION и уменьшает счетчик числа захватов ресурса вызывающим потоком на 1. Если его значение больше 0, LeaveCriticalSection ничего не делает и просто возвращает управление.

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

Следующий код демонстрирует использование критических секций для организации доступа к ресурсу:

CRITICAL_SECTION cs;

 

// Функция потока

DWORD WINAPI ThreadFunc(PVOID param)

{

...

 

EnterCriticalSection(&cs);

 

// Работа с ресурсом

 

LeaveCriticalSection(&cs);

 

...

 

return 0;

}

С применением функции TryEnterCriticalSection тот же самый пример выглядел бы так:

CRITICAL_SECTION cs;

 

// Функция потока

DWORD WINAPI ThreadFunc(PVOID param)

{

...

 

// Если ресурс свободен

if (TryEnterCriticalSection(&cs) == TRUE)

{

// Работа с ресурсом

 

LeaveCriticalSection(&cs);

}

else

{

// Ресурс занят

}

 

...

 

return 0;

}

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

Режим ядра

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

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

Функции ожидания

Объект ядра может находиться в свободном (signaled state) или занятом состоянии (no signaled state). Правила, по которым объект переходит в свободное или занятое состояние, зависят от типа этого объекта.

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

Windows предоставляет семейство функций, которые позволяют потоку приостановиться и ждать освобождения определенного объекта ядра. Такие функции называются функциями ожидания или Wait-функциями. Чаще всего используют функцию WaitForSingleObject:

DWORD WaitForSingleObject(HANDLE hObject, DWORD milliseconds);

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

Ниже приводится пример того, как можно ожидать завершения работы дочернего процесса:

PROCESS_INFORMATION procInfo;

 

// Создание дочернего процесса

if (CreateProcess(..., &procInfo) == TRUE)

{

CloseHandle(procInfo.hThread);

 

// Ожидание завершения дочернего процесса

WaitForSingleObject(procInfo.hProcess, INFINITE);

 

CloseHandle(procInfo.hProcess);

 

// Продолжение вычислений

 

...

}

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

Если задать вполне определенное время ожидания, то, анализируя результат работы функции WaitForSingleObject, можно определить, освободился ли объект или закончилось предельное время ожидания:

// Ожидание освобождения оъекта hObject

DWORD result = WaitForSingleObject(hObject, 1000);

 

switch (result)

{

case WAIT_OBJECT_0:

// Объект освободился

break;

case WAIT_TIMEOUT:

// Объект не освободился в течении 1000 мс

break;

}

Другая функция WaitForMultipleObjects позволяет ожидать освобождения сразу нескольких объектов (или одного из них):

DWORD WaitForMultipleObjects(

DWORD count,

HANDLE* objects,

BOOL waitAll,

DWORD milliseconds);

Параметр count определяет, сколько объектов из массива objects следует ожидать. Параметр waitAll задает режим работы функции: следует ждать освобождения всех объектов (TRUE) или только одного из них (FALSE). Последний параметр – предельное время ожидания освобождения объектов.

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

События

События (event) – самая примитивная разновидность объектов ядра. Можно сказать, что событие имеет две важные характеристики: режим работы и состояние (свободен или занят).

Существует два вида (режима работы) событий:

- с автосбросом (auto-reset);

- со сбросом вручную (manual-reset).

Если событие с автосбросом освобождается, оно сразу же захватывается одним из ожидающих потоков. Иначе говоря, Wait-функция, ожидающая объект ядра «событие», сначала ждет его освобождения, а затем захватывает объект, и он автоматически переходит (сбрасывается) в занятое состояние. Следовательно, такие события могут пробудить лишь один из ожидающих потоков. Данный вид событий обычно применяют, когда необходимо дождаться освобождения некоторого ресурса и сразу же захватить его.

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

BOOL ResetEvent(HANDLE hEvent);

Естественно, что в таком случае, после ожидания до вызова ResetEvent может произойти переключение потоков и объект-событие вновь перейдет в занятое состояние. В связи с этим, события со сбросом вручную обычно используют, когда необходимо просто дождаться момента освобождения объекта (свершения некоторого события), чтобы можно было продолжить работу. Например, объект-событие может сигнализировать об окончании какой-то длительной операции, после которой необходимо выполнить определенные действия. В таком случае потоку, который будет выполнять длительные действия передается дескриптор объекта-события, а ожидающий поток приостанавливает свои действия вызовом Wait-функции. Поскольку Wait-функция не производит захват событий со сбросом вручную, такие события пробуждают все ждущие потоки.

Для перевода события в свободное состояние необходимо вызвать функцию SetEvent:

BOOL SetEvent(HANDLE hEvent);

Наконец, объект ядра «событие» создается функцией CreateEvent:

HANDLE CreateEvent(

PSECURITY_ATTRIBUTES sa,

BOOL manualReset,

BOOL initialState,

PCSTR name);

Параметр manualReset определяет режим работы события: с автосбросом (FALSE) или со сбросом вручную (TRUE). Параметр initialState определяет начальное состояние события – свободное (TRUE) или занятое (FALSE). Последний параметр определяет имя объекта.

После создания объекта-события CreateEvent возвращает его дескриптор, специфичный для конкретного процесса. Для получения доступа к этому объекту можно воспользоваться приемами, описанными в п. 3.6.

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

Ожидаемые таймеры

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

Также как и события, ожидаемые таймеры бывают двух видов:

- с автосбросом (auto-reset);

- со сбросом вручную (manual-reset).

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

Ожидаемые таймеры часто применяются при написании программ-планировщиков, которые, например, позволяют устанавливать напоминания о запланированных встречах. Таймеры можно использовать и при реализации коммуникационных протоколов. Допустим, если клиент делает запрос серверу и тот не отвечает в течение определенного времени, клиент считает, что сервер не доступен.

Создание таймера выполняется в два этапа:

- создание объекта-ядра «ожидаемый таймер»;

- настройка таймера.

Чтобы создать ожидаемый таймер, достаточно вызвать функцию CreateWaitableTimer:

HANDLE CreateWaitableTimer(

PSECURITY_ATTRIBUTES sa,

BOOL manualReset,

PCSTR name);

Параметр manualReset определяет режим работы таймера: с автосбросом (FALSE) или со сбросом вручную (TRUE).

Функция CreateWaitableTimer возвращает дескриптор ожидаемого таймера, специфичный для конкретного процесса. Для получения доступа к этому объекту можно воспользоваться приемами, описанными ранее (п. 3.6).

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

BOOL SetWaitableTimer(

HANDLE hTimer,

LARGE_INTEGER* dueTime,

LONG period,

PTIMERAPCROUTINE completionRoutine,

PVOID argCompletionRoutine,

BOOL resume);

В параметре hTimer передается дескриптор нужного таймера, dueTime задает время первого срабатывания, а period – период (в миллисекундах) последующих срабатываний. Последний параметр resume полезен на компьютерах с поддержкой режима сна. Если в качестве значения resume передать TRUE, при срабатывании таймера, машина выйдет из режима сна (если она в таковом находилась), и пробудятся потоки, ожидавшие этот таймер.

Ожидаемые таймеры дают возможность создавать очередь асинхронных вызовов процедур (Asynchronous Procedure Call, APC) для потока, вызывающего SetWaitableTimer. APC-функция completionRoutine, переданная при настройке таймера, может быть выполнена в контексте данного потока, при условии, что он находится в состоянии ожидания и таймер освободился. APC-функция ожидаемого таймера должна иметь следующий прототип:

VOID TimerHandler(LPVOID argCompletionRoutine,

DWORD lowValue, DWORD highValue);

Первый параметр APC-функции принимает значение одноименного параметра, который был задан при настройке таймера, последние два параметра определяют время срабатывания таймера.

Чтобы отменить срабатывание таймера, необходимо вызвать CancelWaitableTimer:

BOOL CancelWaitableTimer(HANDLE hTimer);

В результате таймер не сработает до тех пор, пока вновь не будет перенастроен функцией SetWaitableTimer.

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

// Создание таймера

HANDLE hTimer = CreateWaitableTimer(NULL, FALSE, "Timer1");

// Запуск таймера с текущего момента с периодичностью 1000 мс

LARGE_INTEGER dueTime = { 0 };

SetWaitableTimer(hTimer, &dueTime, 1000, NULL, NULL, FALSE);

Наконец, следует пояснить, чем отличается ожидаемый таймер от User-таймеров (настраиваемых через функцию SetTimer). Ожидаемые таймеры реализованы в ядре, а значит, не столь тяжеловесны, как User-таймеры.

User-таймеры генерируют сообщения WM_TIMER, посылаемые тому потоку, который вызвал SetTimer (в случае таймеров с обратной связью) или создал определенное окно (в случае оконных таймеров). Таким образом, о срабатывании User-таймера уведомляется только один поток, а ожидаемый таймер позволяет ждать любому числу потоков.

Семафоры

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

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

Для семафоров определены следующие правила:

- если счетчик свободных ресурсов равен нулю, семафор занят;

- когда счетчик становиться больше 0, семафор переходит в свободное состояние;

- система не допускает присвоения отрицательных значений счетчику;

- счетчик не может быть больше максимального количества ресурсов.

Объект ядра «семафор» создается вызовом функции CreateSemaphore:

HANDLE CreateSemaphore(

PSECURITY_ATTRIBUTES sa,

LONG initialCount,

LONG maximumCount,

PCSTR name);

Параметр maximumCount определяет максимальное количество ресурсов, а initialCount – количество изначально свободных ресурсов (на данный момент). Обычно при создании семафора в качестве initialCount передают 0, чтобы после создания семафор находился в занятом состоянии.

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

Поток получает доступ к ресурсу, вызывая одну из Wait-функций и передавая ей дескриптор семафора, который «охраняет» этот ресурс. Wait-функция проверяет у семафора счетчик свободных ресурсов, и если его значение больше 0 (семафор свободен), уменьшает значение этого счетчика на 1, и вызывающий поток остается планируемым.

Чтобы увеличить значение счетчика свободных ресурсов, необходимо вызвать функцию ReleaseSemaphore:

BOOL ReleaseSemaphore(

HANDLE hSemaphore,

LONG releaseCount,

PLONG previousCount);

В параметре releaseCount передается количество освобождаемых ресурсов. Если значение счетчика свободных ресурсов действительно поменялось, в параметре previousCount будет возвращено предыдущее значение счетчика.

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

// Создание семафора (в занятом состоянии)

hSemaphore = CreateSemaphore(NULL, 0, 5, NULL);

 

// Инициализация сервера

...

 

// Освобождение семафора (информирует клиентов о готовности сервера)

ReleaseSemaphore(hSemaphore, 5, NULL);

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

Мьютексы

Мьютексы (mutex) гарантируют потокам взаимоисключающий доступ к единственному ресурсу. Отсюда и произошло название этих объектов (mutual exclusion). Они содержат счетчик числа пользователей, счетчик рекурсии и переменную, в которой запоминается идентификатор потока. Мьютексы ведут себя точно так же, как и критические секции. Однако если последние являются объектами пользовательского режима, то мьютексы – объектами ядра. Кроме того, в отличие от критической секции, мьютекс позволяет синхронизировать потоки разных процессов; при этом можно задать максимальное время ожидания доступа к ресурсу.

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

Если мьютекс захвачен одним из потоков, он находится в занятом состоянии, иначе – в свободном. Для потока, который захватил мьютекс, последний всегда находится в свободном состоянии, для остальных – в занятом. Это значит, что поток-владелец может захватывать объект-мьютекс сколь угодно раз. Всякий раз, когда поток захватывает объект-мьютекс, счетчик рекурсии в этом объекте увеличивается на 1.

Мьютекс создается вызовом функции CreateMutex:

HANDLE CreateMutex(

PSECURITY_ATTRIBUTES sa,

BOOL initialOwner,

PCSTR name);

Параметр initialOwner определяет начальное состояние мьютекса. Если в нем передается FALSE, объект-мьютекс не принадлежит ни одному из потоков и поэтому находится в свободном состоянии. Если же в нем передается TRUE, мьютекс запоминает идентификатор вызывающего потока, а счетчик рекурсии получает значение 1.

Функция CreateMutex возвращает дескриптор мьютекса, специфичный для конкретного процесса. Для получения доступа к этому объекту можно воспользоваться приемами, описанными ранее (п. 3.6).

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

Когда поток, занимающий ресурс, заканчивает с ним работать, он должен освободить мьютекс вызовом функции ReleaseMutex:

BOOL ReleaseMutex(HANDLE hMutex);

Эта функция уменьшает счетчик рекурсии в объекте-мьютексе на 1. Если данный объект передавался во владение потоку неоднократно, поток обязан вызвать ReleaseMutex столько раз, сколько необходимо для обнуления счетчика рекурсии. Как только счетчик станет равен 0, переменная, хранящая идентификатор потока, обнулится, и объект-мьютекс освободится.

Мьютекс «знает», какому потоку принадлежит, поэтому если какой-то посторонний поток попытается освободить мьютекс вызовом функции ReleaseMutex, то она, проверив идентификаторы потоков и обнаружив их несовпадение, ничего делать не станет, а просто вернет FALSE.

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







©2015 arhivinfo.ru Все права принадлежат авторам размещенных материалов.