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

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

< Лекция 7 || Лекция 8: 12 || Лекция 9 >
Аннотация: Проблема недетерминизма является одной из ключевых в параллельных вычислительных средах. Традиционное решение — организация взаимоисключения. Для синхронизации с применением переменной-замка используются Interlocked-функции, поддерживающие атомарность некоторой последовательности операций. Взаимоисключение потоков одного процесса легче всего организовать с помощью примитива CriticalSection. Для более сложных сценариев рекомендуется применять объекты ядра, в частности, семафоры, мьютексы и события. Рассмотрена проблема синхронизации в ядре, основным решением которой можно считать установку и освобождение спин-блокировок

Введение. Проблема взаимоисключения

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

Предположим, что два потока, фиксирующие какие-либо события, пытаются дать приращение общей переменной Count, счетчику этих событий ( рис. 8.1).

Два параллельных потока увеличивают значение общей переменной Count

Рис. 8.1. Два параллельных потока увеличивают значение общей переменной Count

Операция Count++ не является атомарной. Код операции Count++ будет преобразован компилятором в машинный код, который выглядит примерно так:

(1) MOV EAX, [Count]  ;   значение из Count помещается в регистр 
(2) INC EAX      ;        значение регистра увеличивается на 1 
(3) MOV [Count], EAX ;    значение из регистра помещается обратно в Count

В мультипрограммной системе с разделением времени может наступить неблагоприятная ситуация перемешивания (interleaving'а), когда поток T1 выполняет шаг (1), затем вытесняется потоком T2 , который выполняет шаги (1)-(3), а уже после этого поток T1 заканчивает операцию, выполняя шаги (2)-(3). В этом случае результирующее приращение переменной Count будет равно 1 вместо правильного приращения - 2.

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

Для устранения условий состязания необходимо обеспечить каждому потоку эксклюзивный доступ к разделяемым данным. Такой прием называется взаимоисключением (mutual exclusion). Часть кода потока, выполнение которого может привести к race condition, называется критической секцией (critical section). Например, операции (1)-(3) в примере, приведенном выше, являются критическими секциями обоих потоков. Таким образом, взаимоисключение необходимо обеспечить для критических секций потоков.

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

while (some condition) {	    
entry section	   
 critical section	   
 exit section	   
 remainder section
	}

Внешний цикл означает, что нас будут интересовать многочисленные попытки входа в критическую секцию (синхронизация единичных попаданий может быть обеспечена и другими средствами). Наиболее важным с точки зрения синхронизации является пролог ( entry section ), где принимается решение о том, может ли поток быть допущенным в критическую секцию. В эпилоге ( exit section ) обычно открывается шлагбаум для других потоков, а операции, не входящие в критическую секцию, сосредоточены в remainder section.

Переменная-замок

Одним из возможных не вполне корректных решений проблемы синхронизации является использование переменной-замка. Например, можно сделать условием вхождения в критическую секцию значение 0 некоторой разделяемой переменной lock. Сразу же после проверки это значение меняется на 1 (закрытие замка). При выходе из критической секции замок открывается (значение переменной lock сбрасывается в 0 ).

shared int lock = 0;
 T1                                       T2
        while (some condition) {
         while(lock); 
         lock = 1;
                critical section
          lock = 0;
                remainder section
}

К сожалению, предложенное решение не всегда обеспечивает взаимоисключение. Вследствие того, что действие-пролог, состоящее из двух операций while(lock); lock = 1; не является атомарным, существует отличная от нуля вероятность вытеснения потока между этими операциями. При этом управление может перейти ко второму потоку, который, узнав, что переменная lock все еще равна 0, может войти в свою критическую секцию.

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

TSL команды

Многие вычислительные архитектуры имеют инструкции, которые могут обеспечить атомарность последовательности операций при входе в критическую секцию. Такие команды называются Test and_Set Lock или TSL командами. Если представить себе такую команду как функцию

int TSL (int *target){ 
int tmp = *target; 
*target = 1; 
return tmp; 
}

то, заменив в предыдущем примере последовательность операций while(lock); lock = 1; на TSL(lock), мы получаем решение проблемы взаимоисключения.

Семейство Interlocked-функций

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

LONG InterlockedExchangeAdd( PLONG plAddend, LONG lncrement);

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

InterlockedExchangeAdd (&Count,  1);

В MSDN можно прочитать и про другие Interlocked-функции. Например, в качестве TSL инструкции, необходимой для решения проблемы входа в критическую секцию, можно применить функцию InterlockedCompareExchange.

Реализация Interlocked-функций зависит от аппаратной платформы. На x86-процессорах они выдают по шине аппаратный сигнал, закрывая для других процессоров конкретный адрес памяти.

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

Прогон программы синхронизации с помощью переменной замка

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

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

int Sum = 0, iNumber=5, jNumber=300000;

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

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

  hThread=CreateThread(NULL, 0, SecondThread, NULL, 0, &IDThread);
  if (hThread == NULL) return;    

  for (i = 0; i < iNumber; i++)
  {
    for (j = 0; j < jNumber; j++)
    {
      Sum = Sum - 1;  a=sin(b);
    }
	printf(" %d ",Sum);	
  }	  
	  
 WaitForSingleObject(hThread, INFINITE); // ожидание окончания потока SecondThread
 printf(" %d ",Sum);	
 }

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

Рекомендуется ввести в данную программу синхронизацию с помощью глобальной переменной-замка, включив в нее операции while(lock) ; и lock=1 ; и добиться предсказуемости в работе программы.

Поскольку ситуация, в которой квант времени, выделенный потоку, истекает между while(lock) ; и lock=1 ; маловероятна, можно смоделировать ее искусственно, введя между этими операциями паузу (функция Sleep, например). Наконец, желательно реализовать правильное решение путем опроса и модификации переменной замка с помощью TSL-инструкции (функция InterlockedCompareExchange ).

Спин-блокировка

Рассмотренные решения проблемы синхронизации, безусловно, являются корректными. Они реализуют следующий алгоритм: перед входом в критическую секцию поток проверяет возможность входа и, если такой возможности нет, продолжает опрос значения переменной-замка. Такое поведение потока, связанное с его вращением в пустом цикле, называется активным ожиданием или спин-блокировкой (spin lock).

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

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

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

< Лекция 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) и ничего не мешает ему это выполнить. Таким образом, менеджер памяти остается в стороне не участвует в процессе...