Windows

       

Атомарный доступ: семейство 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-функции действуют при мерно так:

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

  • Считывают значение из памяти в регистр.

  • Изменяют значение в регистре.

  • Если битовый флаг сброшен, повторяют операции, начиная с п. 2. В ином слу чае значение из регистра помещается обратно в память.


  • Вас, наверное, удивило, с какой это стати битовый флаг может оказаться сброшен ным? Все очень просто. Его может сбросить другой процессор в системе, пытаясь модифицировать тот же адрес памяти, а это заставляет Interlocked-функции вернуть ся в п. 2.

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



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

    Кстати, InterlockedExchangeAdd позволяет не только увеличить, но и уменьшить значение — просто передайте во втором параметре отрицательную величину. Interlo ckedExchangeAdd возвращает исходное значение в *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_fResour ceInUse присваивается значение TRUE и проверяется ее предыдущее значение. Если оно было равно FALSE, значит, ресурс не был занят, но вызывающий поток только что занял его, на этом цикл завершается. В ином случае (значение было равно TRUE) ре сурс занимал другой поток, и цикл повторяется

    Если бы подобный код выполнялся и другим потоком, его цикл while работал бы до тех пор, пока значение переменной g_fResourceInUse вновь не изменилось бы на FALSE. Вызов InterlockedExchange в конце функции показывает, как вернуть перемен ной g_fResourceInUse значение FALSE.



    Применяйте эту методику с крайней осторожностью, потому что процессорное время при спин-блокировке тратится впустую Процессору приходится постоянно сравнивать два значения, пока одно из них не будет "волшебным образом» изменено другим потоком. Учтите - этот код подразумевает, что все потоки, использующие спин блокировку, имеют одинаковый уровень приоритета. К тому же. Вам, наверное, при дется отключить динамическое повышение приоритета этих потоков (вызовом SetPro cessPriorityBoost или 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.


    Содержание раздела