Лабораторная работа № 4. Синхронизация потоков в среде ОС Windows

 

Цель: Изучение объектов синхронизации потоков в операционной системе Windows.

Задачи:

1. Изучение теоретического материала по синхронизации потоков.

2. Изучение объектов синхронизации.

3. Составление алгоритма программы.

4. Программная реализация.

 

Ход работы:

1. Ознакомиться с исходными текстами примеров использования объектов синхронизации.

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

3. Разработать программу в соответствии с полученным заданием и примерами использования соответствующих объектов.

4. Написать отчет и представить его для защиты вместе с исполняемым модулем программы и ее исходными текстами.

 

Ход защиты:

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

2. Пояснить работу изученных механизмов по программному коду.

3. Назвать и объяснить работу объектов синхронизации, неиспользованных в программе (по выбору студента).

 

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

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

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

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

 

Блокирующие переменные

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

В Windows NT /2000/ XP перед изменением критических данных, поток выполняет системный вызов EnterCriticalSection, в рамках которого сначала выполняет проверка блокирующей переменной, отражающей состояние ресурса. Если он занят (значение блокирующей переменной равно 0), он блокирует поток и делает отметку о том, что поток должен быть активизирован, когда соответствующий ресурс освободится. Поток, который в это время использует данный ресурс, после выхода из критической секции должен выполнить вызов LeaveCriticalSection, в результате блокирующая переменная получает значение 1 (ресурс свободен), а ОС просматривает очередь ожидающих этот ресурс потоков и переводит первый поток в состояние готовности. Эти, а также некоторые другие функции Win 32 API для работы с критическими секциями приведены ниже.

VOID EnterCriticalSection (PCRITICAL_SECTION Section);

 

VOID LeaveCriticalSection (PCRITICAL_SECTION Section);

 

Когда ни один поток не использует критическую секцию, ее можно удалить

VOID DeleteCriticalSection (PCRITICAL_SECTION Section);

 

Когда критическая секция является локальной, ее нужно инициализировать

VOID InitializeCriticalSection (PCRITICAL_SECTION Section);

 

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

BOOL TryEnterCriticalSection (PCRITICAL_SECTION Section);

 

Она возвращает FALSE, если ресурс занят другим потоком, и TRUE, если поток захватил нужный ресурс.

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

int Numbers[500];       // первый разделяемый ресурс

CRITICAL_SECTION Nums;

double Doubles[500];

CRITICAL_SECTION DoubleNums; // второй разделяемый ресурс

 

DWORD ThFunction (PVOID Parametr) // функция потока

{

// Вход в обе критические секции

EnterCriticalSection (&Nums);

EnterCriticalSection (&DoubleNums);

// В этом коде требуется одновременный доступ

// к обоим разделяемым ресурсам

for(int j = 0; j < 500; j ++) Doubles[j] = Numbers[j] = 500 – j;

// Покидаем критические секции в обратном порядке

LeaveCriticalSection (&DoubleNums);

LeaveCriticalSection (&Nums);

return 0;

}

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

DWORD ThFunction1 (PVOID Parametr) // функция потока

{ // Вход в обе критические секции

EnterCriticalSection (&DoubleNums);

EnterCriticalSection (&Nums);

// В этом коде требуется одновременный доступ

// к обоим разделяемым ресурсам

for(int j = 0; j < 500; j ++) Doubles[j] = Numbers[j] = 500 – j;

// Покидаем критические секции в обратном порядке

LeaveCriticalSection (&Nums);

LeaveCriticalSection (&DoubleNums);

return 0;

}

Здесь существует вероятность того, что ThFunction занимает критическую секцию Nums, а поток с функцией ThFunction 1 захватывает DoubleNums. И теперь, какая бы функция не выполнялась, она не сумеет войти в другую, так необходимую ей критическую секцию.

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

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

Для работы с семафорами вводятся два примитива (действия) P и V. Пусть переменная S представляет собой семафор, тогда действия V (S) и P (S) определяются так:

· V (S): переменная S увеличивается на 1. Выборка, инкремент и сохранение не могут быть прерваны. К переменной S нет доступа другим потокам во время выполнения этой операции.

· P (S): уменьшение S, если это возможно. Если S равно 0, то поток, вызывающий операцию P, пока декремент станет возможным. Проверка и уменьшение являются неделимой операцией.

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

Блокирующие переменные и семафоры Дийкстры не подходят для
синхронизации потоков разных процессов. ОС должна предоставлять потокам системные объекты синхронизации, которые были бы видны для всех потоков, даже если они принадлежат разным процессам и работают в разных адресных пространствах. Набор таких объектов зависит от конкретной ОС, которая создает их по запросам пользователей. Примерами синхронизирующих объектов являются системные семафоры, мьютексы, события, таймеры и др. Работа с синхронизирующими объектами подобна работе с файлами: их можно создавать, открывать, закрывать, уничтожать. Кроме того, для синхронизации могут использоваться файлы, процессы и потоки. Все синхронизирующие объекты могут находиться в двух состояниях: сигнальном (свободном) и несигнальном (занятом). Для каждого объекта смысл сигнального состояния зависит от типа объекта.

Потоки с помощью специального системного вызова сообщают ОС, что они хотят синхронизировать свое выполнение с состоянием некоторого объекта (WaitForSingleObject в Windows NT /2000/ XP). Другой системный вызов может переводить объект в сигнальное состояние (например, SetEvent в Windows NT /2000/ XP).

Поток может ожидать установки сигнального состояния не одного объекта, а нескольких (WaitForMultipleObjects). При этом он может попросить ОС активизировать его при установке либо одного указанного объекта, либо всех. Поток может в качестве аргумента системного вызова ожидания указать также максимальное время, которое он будет ожидать перехода объекта в сигнальное состояние, после чего ОС должна активизировать его в любом случае. Установки некоторого объекта в сигнальное состояние могут ожидать сразу несколько потоков. В зависимости от объекта в состояние готовности могут переводиться либо все ожидающие это событие поток либо один из них.

В ОС Windows NT /2000/ XP есть довольно богатый набор функций, которые ожидают перехода в сигнальное состояние одного или нескольких объектов.

DWORD WaitForSingleObject (

HANDLE Object,

DWORD Milliseconds); // определяет ожидания,

                   // INFINITE - бесконечное ожидание

В следующем коде поток блокирован, пока не выполнится другой
поток Th 1.

WaitForSingleObject (Th1, INFINITE);

 

Второй пример демонстрирует значение таймаута, не равное INFINITE.

DWORD dw = WaitForSingleObject(Th1, 10000);

switch (dw) {

case WAIT_OBJECT_0: // поток завершил работу

break;

case WAIT_TIMEOUT: // поток не завершился через 10 сек.

break;

case WAIT_FAILED: // произошла какая-то ошибка

break;

}

Сразу несколько объектов или один из списка можно с помощью
функции

DWORD WaitForMultipleObjects (

DWORD Counter,  // количество объектов ядра (от 1 до 64)

HANDLE *Objects, // массив описателей объектов

BOOL WaitForAll, // ожидать все (TRUE) или

               // один из списка (FALSE)

DWORD Milliseconds); // определяет ожидания,

                   // INFINITE - бесконечное ожидание

Следующий код демонстрирует использование этой функции.

HANDLE hh[2];

hh[0] = Th1; hh[1] = Th2;

DWORD = WaitForMultipleObjects(2, hh, FALSE, 10000);

switch (dw) {

case WAIT_FAILED: // произошла какая-то ошибка

break;

case WAIT_TIMEOUT: // поток не завершился через 10 сек.

break;

case WAIT_OBJECT_0: // поток Th1 завершил работу

break;

case WAIT_OBJECT_0 + 1: // поток Th2 завершил работу

break;

}

 

В числе других в ОС можно встретить такие объекты как событие, мьютекс, системный семафор, таймер.

Объект-событие используется для того, чтобы оповестить другие потоки о том, что некоторые действия завершены. События обычно используют в том случае, когда какой-то поток выполняет инициализацию, а затем сигнализирует другому потоку, что он может продолжить работу. Инициализирующий поток переводит «событие» в несигнальное состояние и приступает к своим операциям. Закончив, он сбрасывает «событие» в сигнальное состояние. Тогда другой поток, ждавший перехода события в сигнальное состояние, переводится в состояние готовности. В ОС Windows NT /2000/ XP события
содержат счетчик числа пользователей и две логических переменных: тип события и состояние. Эти объекты могут быть двух типов: с автосбросом и с ручным сбросом. Событие создается функцией

HANDLE CreateEvent (

PSECURITY_ATTRIBUTES Attributes, // атрибуты защиты

BOOL ManualOrAuto, // ручной (TRUE) или

                 // автоматический (FALSE) сброс

 BOOL Initial, // Начальное состояние: свободен (TRUE) или

            // занят (FALSE)

PCTSTR Name); // Символьное имя объекта

 

Созданное событие может открываться с помощью функции

HANDLE OpenEvent (

DWORD Access, // режим доступа

BOOL Inherit, // наследование

PCTSTR Name); // Символьное имя объекта

 

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

BOOL SetEvent (HANDLE Event);

 

 

Сменить его на занятое можно при помощи функции

BOOL ResetEvent (HANDLE Event);

 

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

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

HANDLE Event1;

int WinMain(...)

{

// Создается событие с ручным сбросом в сигнальном состоянии

Event1 = CreateEvent (NULL, FALSE, FALSE, NULL);

// Создаются два потока, причем пропущены все параметры,

// кроме функции потока

HANDLE Th1 = CreateThread (..., Function1,...);

HANDLE Th2 = CreateThread (..., Function2,...);

...

// далее можно выполнять любые действия

...

CloseHandle (Event1);

}

 

DWORD WINAPI Function1 (PVOID Parametr)

{

WaitForSingleObject (Event1, INFINITE);

...

SetEvent (Event1);

return 0;

}

 

DWORD WINAPI Function2 (PVOID Parametr)

{

WaitForSingleObject (Event1, INFINITE);

...

SetEvent (Event1);

return 0;

}

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

HANDLE CreateWaitableTimer (

PSECURITY_ATTRIBUTES Attributes, // атрибуты защиты

BOOL ManualOrAuto,

// ручной (TRUE) или автоматический (FALSE) сброс состояния

PCTSTR Name); // Символьное имя объекта

 

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

HANDLE SetWaitableTimer (

HANDLE Timer, // нужный таймер

const LARGE_INTEGER *DueTime,

// Когда таймер должен сработать впервые

LONG Period, // сколько будет срабатывать таймер,

           // 0 – для одного срабатывания

PTIMERAPCROUTINE ComplRoutine,

// процедура для асинхронного вызова

PVOID ArgumentsToRoutine,

// аргумент для процедуры асинхронного вызова

BOOL Resume);

// необходим для компьютеров с поддержкой спящего режима

 

Чтобы перевести ожидаемый таймер в несигнальное состояние, нужно вызвать

HANDLE CancelWaitableTimer (

HANDLE Timer); // нужный таймер

 

В следующем фрагменте кода таймер настраивается так, чтобы сработать в первый раз 29 февраля 2004 года в 17.30, и после этого – каждые три часа, то есть 10800000 миллисекунд.

HANDLE Timer1;

SYSTEMTIME Time1;

FILETIME LocalTime, UTC_Time;

LARGE_INTEGER IntUTC;

 

// создается таймер с автосбросом

Timer1 = CreateWaitableTimer (NULL, FALSE, NULL);

// задаются параметры для таймера

Time1.wYear = 2004; Time1.wMonth = 2; Time1.wDay = 29;

Time1.wHour = 17; Time1.wMinute = 30; Time1.wSecond = 0;

Time1.wMilliseconds = 0;

 

SystemTimeToFileTime (&Time1, &LocalTime);

LocalFileTimeToFileTime (&LocalTime, &UTC_Time);

IntUTC.LowPart = UTC_Time.dwLowDateTime;

IntUTC.HighPart = UTC_Time.dwHighDateTime;

 

// наконец устанавливается таймер

SetWaitableTimer (Timer1, &IntUTC, 10800000, NULL, NULL, FALSE);

...

CloseHandle (Timer1);

 

Можно также устанавливать время срабатывания не в абсолютных единицах, а в относительных, которые рассчитываются в блоках по 100 нс (то есть 0,1 сек. равна миллиону таких блоков), число при этом должно быть отрицательным. В следующем коде показано, как установить таймер на срабатывание через 20 секунд после вызова соответствующей функции.

 

HANDLE Timer1;

LARGE_INTEGER LargeInt;

// создается таймер с автосбросом

Timer1 = CreateWaitableTimer (NULL, FALSE, NULL);

// задаются параметры для таймера,

// который должен сработать через 20 сек.

// Время берется в блоках по 100 нс.

int UnitsBySeconds = 10000000;

LargeInt = -20 * UnitsBySeconds;

// наконец устанавливается таймер,

// который сработает вначале через 20 сек,

// а потом каждые три часа

SetWaitableTimer (Timer1, &LargeInt, 10800000, NULL, NULL, FALSE);

...

CloseHandle (Timer1);

 

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

Семафор в целом был описан выше. Что касается ОС Windows NT /2000/ XP, то семафор в них является объектом ядра, и чаще всего такие объекты используются для учета ресурсов. В них помимо остальных параметров, характерных для многих объектов ядра, есть еще два специфичных: один используется для установки максимально возможного числа ресурсов, а второй – это счетчик настоящего количества ресурсов. Для семафоров определены следующие правила работы:

1. Семафор переходит в сигнальное состояние, если значение счетчика ресурсов больше 0.

2. Семафор занят, если значение счетчика равно 0.

3. Не допускается установка отрицательного значения счетчика.

4. Счетчик не может иметь значение, большее максимального числа ресурсов.

 

Семафор создается вызовом функции

HANDLE CreateSemaphore (

PSECURITY_ATTRIBUTES Attributes, // атрибуты защиты

LONG Initial, // количество ресурсов, доступных изначально

LONG Maximum, // максимальное количество ресурсов

PCTSTR Name); // Символьное имя объекта

Получить описатель существующего семафора можно с помощью функции

HANDLE OpenSemaphore (

DWORD Access, // режим доступа

BOOL Inherit, // наследование

PCTSTR Name); // Символьное имя объекта

Поток может увеличить счетчик настоящего количества ресурсов, вызвав функцию

BOOL ReleaseSemaphore (

HANDLE Semaphore, // описатель объекта-семафора

LONG ReleaseCount,   

// насколько увеличить счетчик ресурсов (обычно 1)

PLONG PreviousCount);

// исходное значение счетчика (обычно передают NULL)

 

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

HANDLE Semaphore; LONG Max = 12, PreviousCount;

// создание семафора с одинаковыми значениями счетчиков равными 12

Semaphore = CreateSemaphore(NULL, cMax, cMax, NULL); 

// безымянный семафор

if (Semaphore == NULL)

{ // проверка ошибок

}

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

DWORD WaitResult;

WaitResult = WaitForSingleObject(Semaphore, 0);

switch (WaitResult) {

// Семафор свободен

case WAIT_OBJECT_0:

   // можно создать следующее окно

   break;

// Семафор занят, время прошло

case WAIT_TIMEOUT:

   // Не создавать следующее окно

   break;

}

Когда поток закрывает окно, он вызывает функцию ReleaseSemaphore, чтобы увеличить счетчик семафора.

if (!ReleaseSemaphore(Semaphore, 1, NULL))

{ // Возникла ошибка

}

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

1. Если идентификатор потока равен 0, мьютекс находится в сигнальном состоянии и не захвачен ни одним потоком.

2. Если идентификатор потока не равен 0, мьютекс захвачен одним потоком и находится в несигнальном состоянии.

3. Мьютексы могут нарушать правила, действующие в ОС.

 

Процесс может создать мьютекс вызовом функции

HANDLE CreateMutex (

PSECURITY_ATTRIBUTES Attributes, // атрибуты защиты

BOOL InitialOwner, // начальное состояние мьютекса

PCTSTR Name); // Символьное имя объекта

 

Получить описатель существующего мьютекса можно с помощью функции

HANDLE OpenMutex (

DWORD Access, // режим доступа

BOOL Inherit, // наследование

PCTSTR Name); // Символьное имя объекта

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

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

BOOL ReleaseMutex (HANDLE Mutex);

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

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

HANDLE Thread1, Thread2;

HANDLE Mutex1;

int WinMain(...)

{

Mutex1 = CreateMutex (NULL, FALSE, “Mutex1”);

// Создается мьютекс

// Создаются два потока, причем пропущены все параметры,

// кроме функции потока

HANDLE Th1 = CreateThread (..., Function1,...);

HANDLE Th2 = CreateThread (..., Function2,...);

// далее можно выполнять любые действия

...

CloseHandle (Mutex1);

}

 

DWORD WINAPI Function1 (PVOID Parametr)

{

WaitForSingleObject (Mutex1, INFINITE);

...

ReleaseMutex (Mutex1);

return 0;

}

DWORD WINAPI Function2 (PVOID Parametr)

{

WaitForSingleObject (Mutex1, INFINITE);

...

ReleaseMutex (Mutex1);

return 0;

}









Понравилась статья? Добавь ее в закладку (CTRL+D) и не забудь поделиться с друзьями:  



double arrow
Сейчас читают про: