Московский физико-технический институт
Опубликован: 12.12.2007 | Доступ: свободный | Студентов: 5489 / 1826 | Оценка: 4.34 / 4.14 | Длительность: 13:57:00
ISBN: 978-5-94774-827-7
Лекция 8:

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

< Лекция 7 || Лекция 8: 12 || Лекция 9 >

Critical Sections

В составе API ОС Windows имеются специальные и эффективные функции для организации входа в критическую секцию и выхода из нее потоков одного процесса в режиме пользователя. Они называются EnterCriticalSection и LeaveCriticalSection и имеют в качестве параметра предварительно проинициализированную структуру типа CRITICAL_SECTION.

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

CRITICAL_SECTION cs; 
DWORD WINAPI SecondThread() 
{ 
 InitializeCriticalSection(&cs);

EnterCriticalSection(&cs); 
… критический участок кода
LeaveCriticalSection(&cs); 
} 

main () 
{
InitializeCriticalSection(&cs);
CreateThread(NULL, 0, SecondThread,…);


EnterCriticalSection(&cs); 
… критический участок кода
LeaveCriticalSecLion(&cs); 
DeleteCriticalSection(&cs);
}

Функции EnterCriticalSection и LeaveCriticalSection реализованы на основе Interlocked-функций, выполняются атомарным образом и работают очень быстро. Существенным является то, что в случае невозможности входа в критический участок поток переходит в состояние ожидания. Впоследствии, когда такая возможность появится, поток будет "разбужен" и сможет сделать попытку входа в критическую секцию. Механизм пробуждения потока реализован с помощью объекта ядра "событие" (event), которое создается только в случае возникновения конфликтной ситуации.

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

Прогон программы

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

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

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

Почти все объекты ядра, рассмотренные ранее, в том числе, процессы, потоки и файлы, пригодны для решения задач синхронизации. В контексте задач синхронизации о каждом из объектов можно сказать, находится ли он в свободном (сигнальном, signaled state) или занятом (nonsignaled state) состоянии. Правила перехода объекта из одного состояния в другое зависят от объекта. Например, если поток выполняется, то он находится в занятом состоянии, а если поток успешно завершил ожидание семафора, то семафор находится в занятом состоянии.

Потоки находятся в состоянии ожидания, пока ожидаемые ими объекты заняты. Как только объект освобождается, ОС будит поток и позволяет продолжить выполнение. Для приостановки потока и перевода его в состояние ожидания освобождения объекта используется функция

DWORD WaitForSingleObject(HANDLE hObject, DWORD dwMilliseconds);

где hObject - описатель ожидаемого объекта ядра, а второй параметр - максимальное время ожидания объекта.

Поток создает объект ядра при помощи семейства функций Create ( CreateSemaphore, CreateThread и т.д.), после чего объект посредством описателя становится доступным всем потокам данного процесса. Копия описателя может быть получена при помощи функции DuplicateHandle и передана другому процессу, после чего потоки смогут воспользоваться этим объектом для синхронизации.

Другим, более распространенным способом получения описателя является открытие существующего объекта по имени, поскольку многие объекты имеют имена в пространстве имен объектов. Имя объекта - один из параметров Create -функций. Зная имя объекта, поток, обладающий нужными правами доступа, получает его описатель с помощью Open -функций. Напомним, что в структуре, описывающей объект, имеется счетчик ссылок на него, который увеличивается на 1 при открытии объекта и уменьшается на 1 при его закрытии.

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

Семафоры

Известно, что семафоры, предложенные Дейкстрой в 1965 г., представляет собой целую переменную в пространстве ядра, доступ к которой, после ее инициализации, может осуществляться через две атомарные операции: wait и signal (в ОС Windows это функции WaitForSingleObject и ReleaseSemaphore соответственно).

wait(S):  если S <= 0 процесс блокируется 
   (переводится в состояние ожидания);    
              в противном случае S = S - 1;                               
signal(S):  S = S + 1

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

Ниже приведен пример синхронизации программы async с помощью семафоров.

#include <windows.h>
#include <stdio.h>
#include <math.h>

int Sum = 0, iNumber=5, jNumber=300000;
HANDLE hFirstSemaphore, hSecondSemaphore;

DWORD WINAPI SecondThread(LPVOID)
{
  int i,j;
  double a,b=1.;
    for (i = 0; i < iNumber; i++)
  {
    WaitForSingleObject(hSecondSemaphore, INFINITE); 
    for (j = 0; j < jNumber; j++)
    {
      Sum = Sum + 1;  a=sin(b); 
     }
    
     ReleaseSemaphore(hFirstSemaphore, 1, NULL);
    }
  return 0;
}

void main()
{
  int i,j;
  HANDLE  hThread;
  DWORD  IDThread;
  double a,b=1.;

  hFirstSemaphore = CreateSemaphore(NULL, 0, 1, "MyFirstSemaphore");
  hSecondSemaphore = CreateSemaphore(NULL, 1, 1, "MySecondSemaphore1");
  hThread=CreateThread(NULL, 0, SecondThread, NULL, 0, &IDThread);
  if (hThread == NULL) return;    
  
  for (i = 0; i < iNumber; i++)
  {
	WaitForSingleObject(hFirstSemaphore, INFINITE);
        for (j = 0; j < jNumber; j++)
    {
      Sum = Sum - 1;  a=sin(b);
	}
    	printf(" %d ",Sum);	
	ReleaseSemaphore(hSecondSemaphore, 1, NULL);  
  }	  

WaitForSingleObject(hThread, INFINITE); // ожидание окончания потока SecondThread
CloseHandle(hFirstSemaphore);
CloseHandle(hSecondSemaphore);
printf(" %d ",Sum);	
return;
}

В данной программе синхронизация действий двух потоков , обеспечивающая одинаковый результат для всех запусков программы, выполнена с помощью двух семафоров, примерно так, как это делается в задаче producer-consumer, см., например [ Таненбаум ] . Потоки поочередно открывают друг другу дорогу к критическому участку. Первым начинает работать поток SecondThread, поскольку значение счетчика удерживающего его семафора проинициализировано единицей при создании этого семафора. Синхронизацию с помощью семафоров потоков разных процессов рекомендуется выполнить в качестве самостоятельного упражнения.

Мьютексы

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

Если поток завершается, не освободив мьютекс, последний переходит в свободное состояние. Отличие от семафоров в том, что поток, занявший мьютекс, получает права на владение им. Только этот поток может освободить мьютекс. Поэтому мнение о мьютексе как о семафоре с максимальным значением 1 не вполне соответствует действительности.

События

Объекты "события" - наиболее примитивные объекты ядра. Они предназначены для информирования одного потока другим об окончании какой-либо операции. События создаются функцией CreateEvent. Простейший вариант синхронизации: переводить событие в занятое состояние функцией WaitForSingleObject и в свободное - функцией SetEvent.

В руководстве по программированию [ Рихтер ] , [ Харт ] , рассматриваются более сложные сценарии, связанные с типом события (сбрасываемые вручную и сбрасываемые автоматически) и с управлением синхронизацией групп потоков, а также ряд дополнительных полезных функций.

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

Суммарные сведения об объектах ядра

В руководствах по программированию, см., например, [ Рихтер ] , и в MSDN содержатся сведения и о других объектах ядра применительно к синхронизации потоков.

В частности, существуют следующие свойства объектов:

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

Синхронизация в ядре

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

Одна из проблем связана с тем, что код ядра зачастую работает на приоритетных IRQL (уровни IRQL рассмотрены в "Базовые понятия ОС Windows" ) уровнях "DPC/dispatch" или "выше", известных как "высокий IRQL". Это означает, что традиционные средства синхронизации, связанные с приостановкой потока, не могут быть использованы, поскольку процедура планирования и запуска другого потока имеет более низкий приоритет. Вместе с тем существует опасность возникновения события, чей IRQL выше, чем IRQL критического участка, который будет в этом случае вытеснен. Поэтому в подобных ситуациях прибегают к приему, который называется "запрет прерываний" [ Карпов ] , [ Таненбаум ] . В случае Windows этого добиваются, искусственно повышая IRQL критического участка до самого высокого уровня, используемого любым возможным источником прерываний. В результате критический участок может беспрепятственно выполнить свою работу.

К сожалению, для мультипроцессорных систем подобная стратегия не годится. Запрет прерываний на одном из процессоров не исключает прерываний на другом процессоре, который может продолжить свою работу и получить доступ к критическим данным. В этом случае нужен специальный протокол установки взаимоисключения. Основой этого протокола является установка блокирующей переменной (переменой-замка), сопоставленной с каждой глобальной структурой данных, с помощью TSL команды. Поскольку установка замка происходит в результате активного ожидания, то говорят, что код ядра устанавливает (захватывает) спин-блокировку. Установка спин-блокировки происходит при высоких IRQL уровнях, поэтому код ядра, захватывающего спин-блокировку и удерживающего ее для выполнения критической секции кода, никогда не вытесняется. Установка и освобождение спин-блокировок осуществляется функциями ядра KeAcquireSpinlock и KeReleaseSpinlock, которые активно используются в ядре и драйверах устройств. На однопроцессорных системах установка и снятие спин-блокировок реализуется простым повышением и понижением IRQL.

Наконец, имея набор глобальных ресурсов, в данном случае - спин-блокировок, необходимо решить проблему возникновения потенциальных тупиков [ Сорокина ] . Например, поток 1 захватывает блокировку 1, а поток 2 захватывает блокировку 2. Затем поток 1 пытается захватить блокировку 2, а поток 2 - блокировку 1. В результате оба потока ядра виснут. Одним из решений данной проблемы является нумерация всех ресурсов и выделение их только в порядке возрастания номеров [ Карпов ] . В случае Windows имеется иерархия спин-блокировок: все они помещаются в список в порядке убывания частоты использования и должны захватываться в том порядке, в каком они указаны в списке.

В случае низких IRQL синхронизация осуществляется традиционным образом - при помощи объектов ядра.

Заключение

Проблема недетерминизма является одной из ключевых в параллельных вычислительных средах. Традиционное решение - организация взаимоисключения. Для синхронизации с применением переменной-замка используются Interlocked-функции, поддерживающие атомарность некоторой последовательности операций. Взаимоисключение потоков одного процесса легче всего организовать с помощью примитива Crytical Section. Для более сложных сценариев рекомендуется применять объекты ядра, в частности, семафоры, мьютексы и события. Рассмотрена проблема синхронизации в ядре, основным решением которой можно считать установку и освобождение спин-блокировок.

< Лекция 7 || Лекция 8: 12 || Лекция 9 >
Ирина Оленина
Ирина Оленина
Николай Сергеев
Николай Сергеев

Здравствуйте! Интересует следующий момент. Как осуществляется контроль доступа по тому или иному адресу с точки зрения обработки процессом кода процесса. Насколько я понял, есть два способа: задание через атрибуты сегмента (чтение, запись, исполнение), либо через атрибуты PDE/PTE (чтение, запись). Но как следует из многочисленных источников, эти механизмы в ОС Windows почти не задействованы. Там ключевую роль играет менеджер памяти, задающий регионы, назначающий им атрибуты (PAGE_READWRITE, PAGE_READONLY, PAGE_EXECUTE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_NOACCESS, PAGE_GUARD: их гораздо больше, чем можно было бы задать для сегмента памяти) и контролирующий доступ к этим регионам. Непонятно, на каком этапе может включаться в работу этот менеджер памяти? Поскольку процессор может встретить инструкцию: записать такие данные по такому адресу (даже, если этот адрес относится к региону, выделенному менеджером памяти с атрибутом, например, PAGE_READONLY) и ничего не мешает ему это выполнить. Таким образом, менеджер памяти остается в стороне не участвует в процессе...