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

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

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

Для большей наглядности можно рассмотреть следующий код:

Ожидаем завершение потока

// Запускаем поток
HANDLE hWorkerThread = ::CreateThread( ... );
// Перед окончанием работы надо каким-либо образом сообщить
рабочему потоку, что пора закачивать
...
// Ждем завершения потока
DWORD dwWaitResult = ::WaitForSingleObject(
hWorkerThread, INFINITE );
if( dwWaitResult != WAIT_OBJECT_0 )
{
// обработка ошибки
}
// Хэндл потока можно закрыть
::CloseHandle( hWorkerThread );

В этом примере мы создаем поток с помощью функции CreateThread, а затем, перед завершением программы, ожидаем, когда наш поток отработает, чтобы закрыть его описатель. Собственно, вся магия заключается тут в API-функции WaitForSingleObject. Но об этом чуть ниже.

WaitForSingleObject и WaitForMultipleObjects

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

Сколько времени ждать наступления сигнального состояния, API решает на основе второго параметра — времени в миллисекундах. В случае его истечения функция вернет WAIT_TIMEOUT. Ожидать изменения состояния объекта можно бесконечно, для этого нужно передать во втором параметре значение INFINITE. Или же можно вообще не приостанавливать поток, а просто проверить состояния кернел обжекта, сделав интервал ожидания равным нулю. Когда объект переходит в сигнальное состояние, функция завершается, возвращая значение WAIT_OBJECT_0.

Но из названия WaitForSingleObject должно быть понятно, что это всего лишь частный случай API-функции WaitForMultipleObjects, которая предоставляет гораздо больше возможностей. Давай взглянем на ее прототип.

Описание WaitForMultipleObjects

DWORD WINAPI WaitForMultipleObjects(
__in DWORD nCount,
__in const HANDLE *lpHandles,
__in BOOL bWaitAll,
__in DWORD dwMilliseconds
);

Основное отличие этой API-функции от WaitForSingleObject в том, что она может ожидать изменения состояния сразу нескольких объектов.

Описатели этих объектов передаются в массиве в параметре const HANDLE *lpHandles. Количество элементов массива задается с помощью DWORD nCount, а BOOL bWaitAll позволяет приостанавливать поток до тех пор, пока все объекты ядра не перейдут в сигнальное состояние.

Возвращаемые значения практически идентичны значениям WaitForSingleObject, с той лишь разницей, что если bWaitAll == FALSE, то в случае установки одного из объектов в сигнальное состояние мы получаем на выходе WAIT_OBJECT_0 + object_index_in_array. То есть, если из результата вычесть WAIT_OBJECT_0, то мы узнаем индекс объекта в массиве, который перешел в сигнальное состояние. Теперь, когда мы поняли, как работают функции WaitFor***, можно подробнее рассмотреть, чем же таким являются объекты ядра.

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

 

Объект ядра Событие

Событие или event — это самый примитивный объект синхронизации. По сути, это просто флаг, который может находиться либо в нейтральном состоянии, либо в сигнальном. Создать ивент можно с помощью функции CreateEvent.

Описание CreateEvent

HANDLE WINAPI CreateEvent(
__in_opt LPSECURITY_ATTRIBUTES lpEventAttributes,
__in BOOL bManualReset,
__in BOOL bInitialState,
__in_opt LPCTSTR lpName
);

События имеют возможность автосброса. Для этого надо установить параметр BOOL bManualReset в значение FALSE. В этом случае при обработке ивента функцией WaitFor*** объект автоматически перейдет в нейтральное состояние. Если же bManualReset == TRUE, то сброс флага осуществляется функцией ResetEvent.
События могут быть именованными и не именованными. В первом случае к ивенту можно получить доступ из другого процесса, открыв объект с помощью API OpenEvent. Такие events удобно использовать при межпроцессорной синхронизации. Также событию можно задать начальное состояние с помощью параметра BOOL bInitialState. Если он равен TRUE, то объект будет создан сразу в сигнальном состоянии.

Events в связке с WaitFor***-функциями обеспечивает простой и удобный способ синхронизации потоков.

 

Объект ядра Мьютекс

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

В отличие от события, мьютекс имеет разные состояния для разных тредов. Так, для потока, который захватил mutex, он выглядит как свободный, и все последующие вызовы WaitFor*** вернут результат, говорящий о том, что объект находится в сигнальном состоянии. Все же другие потоки будут видеть его как захваченный объект, то есть в нейтральном состоянии и WaitFor*** API будут ждать, пока он освободится.

Перевести мьютекс обратно в сигнальное состояние можно с помощью функции ReleaseMutex. Кроме того mutex имеет счетчик ссылок. Это означает, что ReleaseMutex надо вызывать столько же раз, сколько были вызваны WaitFor***.

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

 

Пример работы Mutex

HANDLE hMutex;
void Func()
{
::WaitForSingleObject(hMutex, INFINITE);

::ReleaseMutex(hMutex);
}
DWORD WINAPI thread1(LPVOID param)
{
::WaitForSingleObject(hMutex, INFINITE);
Func();
::ReleaseMutex(hMutex);
}
DWORD WINAPI thread2(LPVOID param)
{
::WaitForSingleObject(hMutex, INFINITE);
...
::ReleaseMutex(hMutex);
}
int main(...)
{
hMutex = ::CreateMutex(NULL, FALSE, NULL);
HANDLE hThread1 = ::CreateThread(NULL, 0, thread1, ...);
HANDLE hThread2 = ::CreateThread(NULL, 0, thread2, ...);
}

В этом коде поток thread1 дважды вызывает WaitForSingleObject для нашего мьютекса. Причем вызовы эти вложены друг в друга, то есть сначала два раза вызывается WaitForSingleObject, а затем — два раза ReleaseMutex.

Оба вызова проходят WaitFor*** проходят гладко, так как для thread1 наш мьютекс находится в сигнальном состоянии, несмотря на автосброс. Но этот автосброс влияет на thread2, который ожидает, пока объект станет свободным.

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

 

Объект ядра Семафор (semaphore)

Поведение семафора сложнее, чем у других объектов синхронизации. Несмотря на то, что у него нет привязки к контексту потока, как у мьютекса, но зато semaphore обладает внутренним счетчиком. Каждый раз, когда WaitFor***-функция определяет семафор в сигнальном состоянии, этот счетчик уменьшается на единицу. Как только счетчик достигнет нуля, семафор переходит в нейтральное состояние. Создать semaphore можно с помощью API-функции CreateSemaphore.

Описание CreateSemaphore

HANDLE WINAPI CreateSemaphore(
__in_opt LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
__in LONG lInitialCount,
__in LONG lMaximumCount,
__in_opt LPCTSTR lpName
);

Как видно из прототипа функции, здесь можно задать максимальное число счетчика (параметр LONG lMaximumCount) и сразу инициализировать этот счетчик некоторым числом (LONG lInitialCount). ReleaseSemaphore может увеличить этот счетчик, причем не обязательно на единицу, а на необходимое заданное значение.
Объект семафор может использоваться для ограничения числа активных потоков в приложении или для других более сложных задач.

 

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

Еще одним синхронизационным примитивом являются critical section. Для работы с ними есть свой набор API, и их нельзя использовать в функциях ожидания WaitFor***. По своим свойствам критические секции очень похожи на мьютексы — в их случае тоже отсутствует угроза взаимоблокировки в контексте одного потока.
Перед использованием critical section следует сначала ее инициализировать. Для этого потребуется API-функция InitializeCriticalSection. Единственный параметр, который она принимает — это указатель на объект типа CRITICAL_SECTION (память под этот объект следует выделить заранее). После инициализации с критической секцией можно работать.

Функция EnterCriticalSection переводит объект CRITICAL_SECTION в состояние «занято», после чего ни один другой поток не сможет выполнить код критической секции. LeaveCriticalSection освобождает объект.

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

Кстати, насчет времени ожидания функцией EnterCriticalSection я немного соврал. Задать его можно, но только для всех критических секций сразу и только в реестре (ключ HKEY_LOCAL_ MACHINESYSTEMCurrentControlSetControlSession Manager CriticalSectionTimeout). По истечении этого времени WINAPI генерирует исключение EXCEPTION_POSSIBLE_DEADLOCK, которое вполне может означать, что в приложении случилась взаимоблокировка потоков — deadlock. Но об этом чуть позже, пока скажу только, что не стоит эти исключения заворачивать в null.

Помимо EnterCriticalSection есть еще TryEnterCriticalSection, которая не ожидает освобождения объекта критической секции, а просто возвращает FALSE, если потоку не удалось войти в critical section.

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

 

Атомарные операции

Атомарные операции — это еще один вид синхронизации в многопоточных приложениях. Но их главное отличие от всего прочего заключается в том, что они выполняют лишь простые действия. Для каждой атомарной операции есть своя API-функция. Все эти API начинаются со слова Interlocked, и количество их потрясает воображение :).

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

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

 

Deadlock

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

Тут возможен deadlock

DWORD WINAPI thread1(LPVOID param)
{
::WaitForSingleObject(hEventA, INFINITE);
...
::WaitForSingleObject(hEventB, INFINITE);
...
}
DWORD WINAPI thread2(LPVOID param)
{
::WaitForSingleObject(hEventB, INFINITE);
...
::WaitForSingleObject(hEventA, INFINITE);
...
}

У нас есть два потока, которые поочередно ожидают два ивента: A и B. Возможна такая ситуация, при которой первый поток захватит событие A и будет ожидать освобождения события B, а второй поток, наоборот, захватит event B и будет ждать A. В этом случае мы получим взаимоблокировку, которая приведет к полному зависанию этих двух тредов. Для того чтобы этого избежать, следует либо использовать WaitForMultipleObjects с параметром bWaitAll, равным TRUE, либо ожидать события A и B в жестко оговоренным порядке. И то и другое достаточно сложно сделать в больших системах. Плюс дедлокам подвержены и критические секции, где мы не можем переложить заботу о них на плечи ОС, используя WaitForMultipleObjects.

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

 

Заключение

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

 

Links

  • http://goo.gl/H2NLa — все, что вы хотели знать о синхронизации в Windows, но боялись спросить.

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии