Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Lection 10.doc
Скачиваний:
16
Добавлен:
13.04.2015
Размер:
207.36 Кб
Скачать

8.2. Синхронизация потоков

С реализацией многопоточности тесно связано понятие синхронизация - управление совместным доступом различных потоков к одним и тем же ресурсам (данные, процессор, экранные формы, модемы, принтеры и т.д.). Этот процесс очень сложный и в ОС Windows существуют различные способы синхронизации, которые будут рассмотрены ниже. Все потоки в системе обычно имеют доступ к её ресурсам – файлам, блокам памяти, последовательным портам и т.д. Любой поток, запрашивая нужный ресурс, должен получить монопольный доступ к ресурсу, что и является одной из задач синхронизации потоков. Таким образом, основными задачами синхронизации потоков являются:

  • монопольный доступ к совместно разделяемому ресурсу, иначе потоки могут разрушить его;

  • уведомление некоторых потоков о завершении каких-либо операций в других потоках.

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

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

Тупики имеют место тогда, когда поток ожидает ресурс, который в данный момент принадлежит другому потоку. Предположим Поток 1 получает доступ к объекту А и, для того чтобы продолжать работу, ждет возможности получения доступа к объекту Б. В то же время Поток 2 получает доступ к объекту Б и ждет возможности получить доступ к объекту А. Данная ситуация приводит к блокированию обоих потоков. Теперь ни Поток 1, ни Поток 2 не будут исполняться. Возникновения ситуаций гонок и тупиков можно избежать, если использовать методы синхронизации, рассматриваемые ниже.

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

Атомарный доступ (atomic access) - монопольный захват ресурсов обращающимся к нему потоком, - это есть основа основ синхронизации потоков. Interlocked функции – самый простой способ синхронизации потоков за счёт атомарного доступа к переменной, эти функции гарантируют монопольный доступ к переменной типа LONG независимо от того, какой именно код генерируется компилятором и сколько процессоров имеет компьютер. Важной особенностью является то, что это самый быстрый способ синхронизации потоков из всех существующих. Вызов одной такой функции требует не более 50 тактов процессора, а при использовании синхронизирующих объектов ядра требуется переход в режим ядра, забирающий не менее 1000 тактов процессора и выход из режима ядра. Существуют следующие Interlocked функции: InterlockedIncrement, InterlockedDecrement, InterlockedExchangeAdd, InterlockedExchange, InterlockedExchangePointer. Функции InterlockedExchange и InterlockedExchangePointer монопольно заменяют текущее значение переменной типа LONG, адрес которой передаётся в первом параметре, на значение, передаваемое во втором параметре. В 32-разрядном приложении обе функции работают с 32-разрядными значениями, но в 64-разрядном, - первая функция работают с 32-разрядными значениями, а вторая с 64-разрядными. Функция InterlockedExchangeAdd прибавляет значение второго параметра к первому. Все функции возвращают исходное значение переменной. Принцип работы Interlocked-функций состоит в следующем. Для процессоров семейства Intel x86 эти функции выдают по шине аппаратный сигнал, не давая другому процессору обратиться по этому адресу. Ддя процессоров Alpha устанавливается специальный битовый флаг процессора, указывающий, что данный адрес памяти сейчас занят, далее значение считывается из памяти в регистр и меняется в регистре, и если флаг не был сброшен, то записывается обратно в память.

Спин-блокировка (spin-lock). Это способ использования функции InterlockedExchange для монопольного доступа к разделяемому ресурсу. Для решения этой задачи вводится переменная, которая используется как флаг, показывающий состояние разделяемого ресурса (свободен/занят). Если ресурс занят, то цикл будет работать и загружать процессор до тех пор, пока ресурс не освободится. Спин-блокировка обычно используется, когда защищаемый ресурс занят недолго, а также для реализации критических секций (critical section). Данный метод рассчитан на одинаковый приоритет потоков, работающих с разделяемым ресурсом, и динамическое изменение приоритетов потоков должно быть отключено. При использовании спин-блокировки нельзя допустить попадания флага занятости ресурса и защищаемых данных в одну кэш-линию, это может вызвать колоссальное снижение производительности на многопроцессорных машинах.

Кэш-линии процессора (CPU cache lines) необходимо учитывать при создании высокоэффективных приложений для многопроцессорных машин. Когда процессору нужно считать из памяти один байт, он извлекает не только его, но и столько смежных байтов, сколько потребуется для заполнения кэш-линии. В зависимости от типа процессора, кэш-линии состоят из 32 или 64 байтов и всегда выравниваются по границам, кратным 32 или 64 байтам. Приложение работает с набором смежных байтов, и если эти байты находятся в кэше, процессору не приходится снова обращаться к шине памяти, что будет означать повышение производительности. В многопроцессорной системе кэш-линии могут сильно уменьшить быстродействие приложения по следующим причинам:

1. CPU1 считывает байт и смежные с ним байты из оперативной памяти и заполняет свою кэш-линию;

2. CPU2 повторяет действия CPU1, после чего имеет свою копию данных в своей кэш-линии;

3. CPU1 модифицирует байт памяти, и записывает его в свою кэш-линию, после чего остальные копии данных в кэш-линиях других процессоров будут объявлены недействительными;

4. Остальным процессорам придется обновить свои кэш-линии и повторить операции после того, как CPU1 обновит оперативную память новым, модифицированным значением.

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

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

Критические секции (critical section) — это части исходного кода, которые не могут выполняться двумя потоками в одно и то же время. Используя критиче­скую секцию, можно упорядочить выполнение конкретных частей исход­ного кода. Критические секции могут использоваться только в пределах одно­го процесса, одного приложения.

Система может вытеснить поток и передать процессорное время другому потоку, но к защищённому ресурсу ни один поток доступа не получит, пока данный поток не выйдет из критической секции. Критические секции реализованы на основе Interlocked-функций и выполняются очень быстро, но их недостаток заключается в том, что они дают возможности синхронизировать потоки только в рамках одного процесса. Для каждого разделяемого ресурса используется отдельно выделенный экземпляр структуры CRITICAL_SECTION, который обычно является глобальным, и с которым оперируют специальные функции входа и выхода в/из критической секции. Перед использованием структуры она инициализируется функцией InitializeCriticalSection.

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

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

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

При попытке потока войти в критическую секцию, занятую другим потоком, он немедленно приостанавливается, переходя из пользовательского режима в режим ядра. На многопроцессорной машине поток, захвативший ресурс и поток, переходящий в режим ожидания (в режим ядра), могут выполняться на разных процессорах. Тогда появится вероятность того, что ресурс будет освобождён ещё до того, как второй поток перейдёт в режим ядра. Система позволяет задать количество циклов спин-блокировки перед тем, как поток перейдет в режим ожидания (режим ядра). Для этого можно использовать функцию InitializeCriticalSectionAndSpinCount или SetCriticalSectionAndSpinCount. Параметром этих функций является счетчик блокировок, возможное значение которого лежит в пределах от 0 до 0x00FFFFFF. На однопроцессорной машине значение параметра этих функций всегда равно нулю, т.к. поток, владеющий ресурсом, не сможет освободить его, пока другой поток крутится в циклах спин-блокировки.

Функция EnterCriticalSection переводит поток в режим ожидания на определённое время, после чего генерируется исключение. Длительность ожидания определена в ключе

HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager.

По умолчанию оно равно 2 592 000 секунд (30 суток). Если [VOID InitializeCriticalSection(…)] потерпит неудачу, то будет выброшено исключение STATUS_NO_MEMORY, перехватить которое можно структурной обработкой исключений. Функция [BOOL InitializeCriticalSectionAndSpinCount(…)] в этом случае вернёт FALSE. Во время работы критической секции может возникнуть ситуация, когда два и более потоков конкурируют за доступ к ресурсу. При этом критическая секция создаёт объект ядра «событие» (event kernel object). Если к такой ситуации ещё добавится и большая нехватка памяти, то функция EnterCriticalSection сгенерирует исключение EXCEPTION_INVALID_HANDLE. Можно использовать структурную обработку исключений и перехватывать ошибку, при этом придётся дождаться появления свободной памяти.

Второй способ решения этой проблемы – создать критическую секцию функцией InitializeCriticalSectionAndSpinCount, передавая в параметр этой функции значение, у которого старший бит установлен. Таким образом память под объект ядра «событие» будет выделена заранее.

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

Пример 8.1 Использование критических секций

int iMyRes1;

char cMyRes2;

CRITICAL_SECTION csResource1;

CRITICAL_SECTION csResource2;

DWORD WINAPI ThreadFunc(PVOID pvParam)

{

EnterCriticalSection(&csResource1);

// доступ к ресурсу iMyRes1

LeaveCriticalSection(&csResource1);

// -------------выполняется код потока----------------

EnterCriticalSection(&csResource2);

// доступ к ресурсу cMyRes2

LeaveCriticalSection(&csResource2);

// -------------выполняется код потока----------------

EnterCriticalSection(&csResource1);

EnterCriticalSection(&csResource2);

// одновременный доступ к нескольким ресурсам (iMyRes1 и cResource2)

LeaveCriticalSection(&csResource2);

LeaveCriticalSection(&csResource1);

}

Ситуация взаимной блокировки (dead lock) может возникнуть вследствие несоблюдении порядка вхождения в критические секции:

DWORD WINAPI ThreadFunc1(PVOID pvParam)

{

EnterCriticalSection(&csResource1); // доступ к ресурсу iMyRes1

EnterCriticalSection(&csResource2); // доступ к ресурсу cMyRes2

// одновременный доступ к нескольким ресурсам (iMyRes1 и cMyRes2)

LeaveCriticalSection(&csResource2);

LeaveCriticalSection(&csResource1);

}

DWORD WINAPI ThreadFunc2(PVOID pvParam)

{

EnterCriticalSection(&csResource2); // доступ к ресурсу cMyRes2

EnterCriticalSection(&csResource1); // доступ к ресурсу iMyRes1

// одновременный доступ к нескольким ресурсам (iMyRes1 и cMyRes2)

LeaveCriticalSection(&csResource2);

LeaveCriticalSection(&csResource1);

}

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]