Атомарный доступ: семейство Inferlockect-функций
Большая часть синхронизации потоков связана с атомарным доступом (atomic access) — монопольным захватом ресурса обращающимся к нему потоком. Возьмем простой пример.
// определяем глобальную переменную lorig g_x = 0;
DWORD WINAPI ThreadFunc1(PVOID pvParam) {
g_x++;
return(0); }
DWORD WINAPI ThreadFunc2(PVOID pvParam} {
g_x++;
return(0); }
Я объявил глобальную переменную g_n и инициализировал ее нулевым значением. Теперь представьте, что я создал два потока: один выполняет ThreadFunc1, другой — ThreadFunc2. Код этих функций идентичен: обе увеличивают значение глобальной переменной g_x на 1. Поэтому Вы, наверное, подумали: когда оба потока завершат свою работу, значение g_x будет равно 2. Так ли это? Может быть. При таком коде заранее сказать, каким будет конечное значенис g_x, нельзя. И вот почему. Допустим, компилятор сгенерировал для строки, увеличивающей g_x на 1, следующий код:
MOV EAX, [g_x] , значение из g_x помещается в регистр
INC EAX ; значение регистра увеличивается на 1
MOV [g_x], EAX ; значение из регистра помещается обратно в g_x
Вряд ли оба потока будут выполнять этот код в одно и то же время. Если они будут делать это по очереди — сначала один, потом другой, тогда мы получим такую картину:
MOV EAX, [g_x] ; поток 1 в регистр помещается 0
INC EAX ; поток V значение регистра увеличивается на 1
MOV [g_x], EAX , поток 1. значение 1 помещается в g_x
MOV EAX, [g_x] ; поток 2 в регистр помещается 1
INC EAX ; поток 2. значение регистра увеличивается до 2
MOV [g_x], EAX , поток 2. значение 2 помещается в g_x
После выполнения обоих потооков значение g_x будет равно 2. Это просто замечательно и как раз то, что мы ожидали: взяв переменную с нулевым значением, дважды увеличили ее на 1 и получили в результате 2. Прекрасно. Но постойте-ка, ведь Windows — это среда, которая поддерживает многопоточность и вытесняющую многозадачность. Значит, процессорное время в любой момент может быть отнято у одного потока и передано другому. Тогда код, приведенный мной выше, может выполняться и таким образом:
MOV EAX, [g_x] ; лоток V в регистр помещается 0
INC EAX ; поток 1. значение регистра увеличивается на 1
MOV EAX, [g_x] ; поток 2 в регистр помещается 0
INC EAX ; поток 2. значение регистра увеличивается на 1
MOV [g_x], EAX , поток 2. значение 1 помещается в g_x
MOV [g_x], EAX , поток V значение 1 помещается в g_x
А если код будет выполняться именно так, конечное значение g_x окажется равным 1, а не 2, как мы думали! Довольно пугающе, особенно если учесть, как мало у нас рычагов управления планировщиком. Фактически, даже при сотне потоков, которые выполняют функции, идентичные нашей, в конечном итоге вполне можно получить в g_x все ту же единицу! Очевидно, что в таких условиях работать просто нельзя. Мы вправе ожидать, что, дважды увеличив 0 на 1, при любых обстоятельствах получим 2. Кстати, результаты могут зависеть оттого, как именно компилятор генерирует машинный код, а также от того, как процессор выполняет этот код и сколько процессоров установлено в машине. Это объективная реальность, в которой мы не в состоянии что-либо изменить. Однако в Windows есть ряд функций, которые (при правильном их использовании) гарантируют корректные результаты выполнения кода.
Решение этой проблемы должно быть простым. Все, что нам нужно, — это способ, гарантирующий приращение значения переменной на уровне атомарного доступа, т.e. без прерывания другими потоками. Семейство Interlocked-функций как раз и дает нам ключ к решению подобных проблем. Большинство разработчиков программного обеспечения недооценивает эти функции, а ведь они невероятно полезны и очень просты для понимания. Все функции из этого семейства манипулируют переменными на уровне атомарного доступа. Взгляните на InterlockedExchangeAdd.
LONG InterlockedExchangeAdd( PLONG plAddend, LONG lIncrement);
Что может быть проще? Вы вызываете эту функцию, передавая адрес переменной типа LONG и указываете добавляемое значение InterlockedExchangeAdd гарантирует, что операция будет выполнена атомарно. Перепишем наш код вот так:
// определяем глобальную переменную long g_x = 0;
DWORD WINAPI ThreadFunc1(PVOID pvParam) {
InterlockedExchangeAdd(&g_x, 1);
return(0); }
DWORD WINAPI ThreadFunc2(PVOID pvPararr) {
InterlockedExchangeAdd(&g_x, 1);
return(0); }
Теперь Вы можете быть уверены, что конечное значение g_x будет равно 2. Ну, Вам уже лучше? Заметьте: в любом потоке, где нужно модифицировать значение разделяемой (общей) переменной типа LONG, следует пользоваться лишь Interlocked-функциями и никогда не прибегать к стандартным операторам языка С:
// переменная типа LONG, используемая несколькими потоками
LONG g_x;
// неправильный способ увеличения переменной типа LONG
g_x++;
// правильный способ увеличения переменной типа LONG
InterlockedExchangeAdd(&g_x, 1);
Как же работают Interlocked-функции? Ответ зависит от того, какую процессорную платформу Вы используете. На компьютерах с процессорами семейства x86 эти функции выдают по шине аппаратный сигнал, не давая другому процессору обратиться по тому же адресу памяти. На платформе Alpha Interlocked-функции действуют примерно так:
Вас, наверное, удивило, с какой это стати битовый флаг может оказаться сброшенным? Все очень просто. Его может сбросить другой процессор в системе, пытаясь модифицировать тот же адрес памяти, а это заставляет Interlocked-функции вернуться в п. 2.
Вовсе не обязательно вникать в детали работы этих функций. Вам нужно знать лишь одно: они гарантируют монопольное изменение значений переменных независимо oт того, как именно компилятор генерирует код и сколько процессоров установлено в компьютере. Однако Вы должны позаботиться о выравнивании адресов переменных, передаваемых этим функциям, иначе они могут потерпеть неудачу. (О выравнивании данных я расскажу в главе 13.)
Другой важный аспект, связанный с Interlocked-функциями, состоит в том, что они выполняются чрезвычайно быстро. Вызов такой функции обычно требует не более 50 тактов процессора, и при этом не происходит перехода из пользовательского режима в режим ядра (а он отнимает не менее 1000 тактов).
Кстати, InterlockedExchangeAdd позволяет не только увеличить, но и уменьшить значение — просто передайте во втором параметре отрицательную величину. InterlockedExchangeAdd возвращает исходное значение в *plAddend
Вот еще две функции из этого семейства:
LONG InterlockedExchange( PLONG plTarget, LONG IValue);
PVOTD InterlockedExchangePointer( PVOID* ppvTarget, PVOID* pvValue);
InterlockedExchange и InterlockedExchangePointer монопольно заменяют текущее значение переменной типа LONG, адрес которой передается в первом параметре, на значение, передаваемое во втором параметре. В 32-разрядпом приложении обе функции работают с 32-разрядными значениями, но в 64-разрядной программе первая оперирует с 32-разрядными значениями, а вторая — с 64-разрядными. Все функции возвращают исходное значение переменной InterlockedExchange чрезвычайно полезна при реализации спин-блокировки (spinlock):
// глобальная переменная, используемая как индикатор того, занят ли разделяемый ресурс
BOOL g_fResourceInUse = FALSE ;
...
void Func1() {
// ожидаем доступа к ресурсу
while (InterlockedExchange(&g_fResourceInUse, TRUE) = TRUE)
Sleep(0);
// получаем ресурс в свое распоряжение
// доступ к ресурсу больше не нужен
InterlockedFxchange(&g_fResourceInUse, FALSE); }
В этой функции постоянно "крутится" цикл while, в котором переменной g_fResourceInUse присваивается значение TRUE и проверяется ее предыдущее значение. Если оно было равно FALSE, значит, ресурс не был занят, но вызывающий поток только что занял его, на этом цикл завершается. В ином случае (значение было равно TRUE) ре сурс занимал другой поток, и цикл повторяется.
Если бы подобный код выполнялся и другим потоком, его цикл while работал бы до тех пор, пока значение переменной g_fResourceInUse вновь не изменилось бы на FALSE.
Вызов InterlockedExchange в конце функции показывает, как вернуть переменной g_fResourceInUse значение FALSE.
Применяйте эту методику с крайней осторожностью, потому что процессорное время при спин-блокировке тратится впустую. Процессору приходится постоянно сравнивать два значения, пока одно из них не будет "волшебным образом" изменено другим потоком. Учтите - этот код подразумевает, что все потоки, использующие спин-блокировку, имеют одинаковый уровень приоритета. К тому же. Вам, наверное, придется отключить динамическое повышение приоритета этих потоков (вызовом SetProcessPriorityBoost или SetThreadPriorityBoost).
Вы должны позаботиться и о том, чтобы переменная — индикатор блокировки и данные, защищаемые такой блокировкой, не попали в одну кэш-линию (о кэш-линиях я расскажу в следующем разделе). Иначе процессор, использующий ресурс, будет конкурировать с любыми другими процессорами, которые пытаются обратиться к тому же ресурсу. А это отрицательно cкажется на быстродействии.
Избегайте спин-блокировки на однопроцессорных машинах. "Крутясь" в цикле, поток впустую транжирит драгоценное процессорное время, не давая другому потоку изменить значение неременной. Применение функции Sleep в цикле while несколько улучшает ситуацию. С ее помощью Вы можете отправлять свой поток в сон на некий случайный отрезок времени и после каждой безуспешной попытки обратиться к ресурсу увеличивать этот отрезок. Тогда потоки не будут зря отнимать процессорное время. В зависимости от ситуации вызов Sleep можно убрать или заменить на вызов SwitchToThread (эта функция в Windows 98 не доступна). Очень жаль, но, по-видимому, Вам придется действовать здесь методом проб и ошибок.
Спин-блокировка предполагает, что защищенный ресурс не бывает занят надолго. И тогда эффективнее делать так: выполнять цикл, переходить в режим ядра и ждать. Многие разработчики повторяют цикл некоторое число раз (скажем, 4000) и, если ресурс к тому времени не освободился, переводят поток в режим ядра, где он спит, ожидая освобождения ресурса (и не расходуя процессорное время).
По такой схеме реализуются критические секции (critical sections).
Спин-блокировка полезна на многопроцессорных машинах, где один поток может "крутиться" в цикле, а второй — работать на другом процессоре. Но даже в таких условиях надо быть осторожным. Вряд ли Вам понравится, если поток надолго войдет в цикл, ведь тогда он будет впустую тратить процессорное время. О спин-блокировке мы еще поговорим в этой главе. Кроме того, в главе 10 я покажу, как использовать спин-блокировку на практике.
Последняя пара Interlocked-функций выглядит так:
PVOID InterlockedCompareExchange( PLONG pIOestination, LONG lExchange, LONG lComparand);
PVOID InterlockedCompareExchangePointer( PVOID* ppvDestination, PVOID pvExchange, PVOID pvComparand);
Они выполняют операцию сравнения и присвоения на уровне атомарного доступа. В 32-разрядном приложении обе функции работают с 32-разрядными значения ми, но в 64-разрядном приложении InterlockedCompareExchange используется для 32 разрядных значений, a InterlockedCompareExcbangePointer - для 64-разрядных. Вот как они действуют, если представить это в псевдокоде.
LONG InterlockedCompareExchange(PLONG plDestination, LONG lExchange, LONG lComparand) {
LONG lRet = *plDestination;
// исходное значение
if (*plDestination == lComparand)
*plDestination = lExchange;
return(lRet); }
Функция сравнивает текущее значение переменной типа LONG (на которую указывает параметр plDestination) со значением, передаваемым в параметре lComparand. Если значения совпадают, *plDestination получает значение параметра lExchange; в ином случае *pUDestination остается без изменений. Функция возвращает исходное значение *plDestination. И не забывайте, что все эти действия выполняются как единая атомарная операция.
Обратите внимание на отсутствие Interlocked-функции, позволяющей просто считывать значение какой-то переменной, не меняя его. Она и не нужна. Если один поток модифицирует переменную с помощью какой-либо Interlocked-функции в тот момент, когда другой читает содержимое той же переменной, ее значение, прочитанное вторым потоком, всегда будет достоверным.
Он получит либо исходное, либо измененное значение переменной. Поток, конечно, не знает, какое именно значение он считал, но главное, что оно корректно и не является некоей произвольной величиной. В большинстве приложений этого вполне достаточно.
Interlocked-функции можно также использовать в потоках различных процессов для синхронизации доступа к переменной, которая находится в разделяемой области памяти, например в проекции файла. (Правильное применение Interlocked-функций демонстрирует несколько программ-примеров из главы 9).
В Windows есть и другие функции из этого семейства, но ничего нового по сравнению с тем, что мы уже рассмотрели, они не делают. Вот еще две из них.
LONG Interlockedlncrernent(PLONG plAddend);
LONG IntorlockedDecrcment(PLONG plAddend);
InterlockedExchangeAdd полностью заменяет обе эти устаревшие функции. Новая функция умеет добавлять и вычитать произвольные значения, а функции Interlocked Increment и InterlockedDecrement увеличивают и уменьшают значения только на 1.