Издание четвертое windows ® для профессионалов создание эффективных Win32-приложений с учетом специфики 64-разрядной версии Windows


Атомарный доступ семейство Interlocked



Pdf просмотр
страница22/68
Дата28.11.2016
Размер3.57 Mb.
Просмотров12620
Скачиваний0
1   ...   18   19   20   21   22   23   24   25   ...   68
Атомарный доступ семейство Interlocked-функций
Бо
' льшая часть синхронизации потоков связана с атомарным доступом (atomic access) — монопольным захватом ресурса обращающимся к нему потоком. Возьмем простой пример.

188
Ч АС Т Ь I I
НАЧИНАЕМ РАБОТАТЬ определяем глобальную переменную long g_x = 0;
DWORD WINAPI ThreadFunc1(PVOID pvParam) {
g_x++;
return(0);
}
DWORD WINAPI ThreadFunc2(PVOID pvParam) Я объявил глобальную переменную
g_x и инициализировал ее нулевым значением. Теперь представьте, что я создал два потока один выполняет
ThreadFunc1, другой. Код этих функций идентичен обе увеличивают значение глобальной переменной
g_x на 1. Поэтому Вы, наверное, подумали когда оба потока завершат свою работу, значение
g_x будет равно 2. Так ли это Может быть. При таком коде заранее сказать, каким будет конечное значение
g_x, нельзя. И вот почему. Допустим,
компилятор сгенерировал для строки, увеличивающей
g_x наследующий код EAX, [g_x]
; значение из g_x помещается в регистр EAX
; значение регистра увеличивается назначение из регистра помещается обратно в Вряд ли оба потока будут выполнять этот код водно и тоже время. Если они будут делать это по очереди — сначала один, потом другой, тогда мы получим такую картину EAX, [g_x]
; поток 1: в регистр помещается 0
INC EAX
; поток 1: значение регистра увеличивается на 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 будет равно 2. Это просто замечательно и как раз то, что мы ожидали взяв переменную с нулевым значением, дважды увеличили ее на 1 и получили в результате 2. Прекрасно. Но постойте ка, ведь — это среда, которая поддерживает многопоточность и вытесняющую многозадачность. Значит, процессорное время в любой момент может быть отнято у одного потока и передано другому. Тогда код, приведенный мной выше, может выполняться и таким образом EAX, [g_x]
; поток 1: в регистр помещается 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
; поток 1: значение 1 помещается в g_x

189
Г ЛАВА 8
Синхронизация потоков в пользовательском режиме
А если код будет выполняться именно так, конечное значение
g_x окажется равным, а не 2, как мы думали Довольно пугающе, особенно если учесть, как мало у нас рычагов управления планировщиком. Фактически, даже при сотне потоков, которые выполняют функции, идентичные нашей, в конечном итоге вполне можно получить в
g_x все туже единицу Очевидно, что в таких условиях работать просто нельзя.
Мы вправе ожидать, что, дважды увеличив 0 на 1, при любых обстоятельствах получим. Кстати, результаты могут зависеть оттого, как именно компилятор генерирует машинный кода также оттого, как процессор выполняет этот код и сколько процессоров установлено в машине. Это объективная реальность, в которой мы не в состоянии что либо изменить. Однако весть ряд функций, которые (при правильном их использовании) гарантируют корректные результаты выполнения кода.
Решение этой проблемы должно быть простым. Все, что нам нужно, — это способ, гарантирующий приращение значения переменной на уровне атомарного доступа, те. без прерывания другими потоками. Семейство
Interlocked функций как рази дает нам ключ к решению подобных проблем. Большинство разработчиков программного обеспечения недооценивает эти функции, а ведь они невероятно полезны и очень просты для понимания. Все функции из этого семейства манипулируют переменными на уровне атомарного доступа. Взгляните на
InterlockedExchangeAdd:
LONG InterlockedExchangeAdd(
PLONG plAddend,
LONG Что может быть проще Вы вызываете эту функцию, передавая адрес переменной типа LONG и указываете добавляемое значение.
InterlockedExchangeAdd гарантирует,
что операция будет выполнена атомарно. Перепишем наш код вот так определяем глобальную переменную long g_x = 0;
DWORD WINAPI ThreadFunc1(PVOID pvParam) {
InterlockedExchangeAdd(&g_x, 1);
return(0);
}
DWORD WINAPI ThreadFunc2(PVOID pvParam) {
InterlockedExchangeAdd(&g_x, Теперь Вы можете быть уверены, что конечное значение
g_x будет равно 2. Ну, Вам уже лучше Заметьте в любом потоке, где нужно модифицировать значение разделяемой (общей) переменной типа LONG, следует пользоваться лишь
Interlocked функ циями и никогда не прибегать к стандартным операторам языка C:
// переменная типа LONG, используемая несколькими потоками g_x;
M
// неправильный способ увеличения переменной типа LONG
g_x++;
M
// правильный способ увеличения переменной типа LONG
InterlockedExchangeAdd(&g_x, 1);

190
Ч АС Т Ь I I
НАЧИНАЕМ РАБОТАТЬ
Как же работают
Interlocked функции? Ответ зависит оттого, какую процессорную платформу Вы используете. На компьютерах с процессорами семейства
x86 эти функции выдают по шине аппаратный сигнал, не давая другому процессору обратиться потому же адресу памяти. На платформе Alpha
Interlocked функции действуют примерно так:
1.
Устанавливают специальный битовый флаг процессора, указывающий, что данный адрес памяти сейчас занят.
2.
Считывают значение из памяти в регистр.
3.
Изменяют значение в регистре.
4.
Если битовый флаг сброшен, повторяют операции, начиная с п. 2. Вином случае значение из регистра помещается обратно в память.
Вас, наверное, удивило, с какой это стати битовый флаг может оказаться сброшенным Все очень просто. Его может сбросить другой процессор в системе, пытаясь модифицировать тот же адрес памяти, а это заставляет
Interlocked функции вернуться в п. Вовсе необязательно вникать в детали работы этих функций. Вам нужно знать лишь одно они гарантируют монопольное изменение значений переменных независимо оттого, как именно компилятор генерирует код и сколько процессоров установлено в компьютере. Однако Вы должны позаботиться о выравнивании адресов переменных, передаваемых этим функциям, иначе они могут потерпеть неудачу. (О
выравнивании данных я расскажу в главе Другой важный аспект, связанный с
Interlocked функциями, состоит в том, что они выполняются чрезвычайно быстро. Вызов такой функции обычно требует не более тактов процессора, и при этом не происходит перехода из пользовательского режима в режим ядра (а он отнимает не менее 1000 тактов).
Кстати,
InterlockedExchangeAdd позволяет не только увеличить, но и уменьшить значение — просто передайте во втором параметре отрицательную величину.
Interlo
ckedExchangeAdd возвращает исходное значение в *plAddend.
Вот еще две функции из этого семейства InterlockedExchange(
PLONG plTarget,
LONG lValue);
PVOID InterlockedExchangePointer(
PVOID* ppvTarget,
PVOID pvValue);
InterlockedExchange и InterlockedExchangePointer монопольно заменяют текущее значение переменной типа LONG, адрес которой передается в первом параметре, назначение, передаваемое во втором параметре. В 32 разрядном приложении обе функции работают с 32 разрядными значениями, нов разрядной программе первая оперирует с 32 разрядными значениями, а вторая — с 64 разрядными. Все функции возвращают исходное значение переменной.
InterlockedExchange чрезвычайно полезна при реализации спин блокировки (spinlock):
// глобальная переменная, используемая как индикатор того, занят ли разделяемый ресурс g_fResourceInUse = FALSE;
M

191
Г ЛАВА 8
Синхронизация потоков в пользовательском режиме void Func1() {
// ожидаем доступа к ресурсу while (InterlockedExchange(&g_fResourceInUse, TRUE) == TRUE)
Sleep(0);
// получаем ресурс в свое распоряжение доступ к ресурсу больше ненужен, В этой функции постоянно крутится цикл
while, в котором переменной g_fResour
ceInUse присваивается значение TRUE и проверяется ее предыдущее значение. Если оно было равно FALSE, значит, ресурс не был занятно вызывающий поток только что занял его на этом цикл завершается. Вином случае (значение было равно TRUE) ресурс занимал другой потоки цикл повторяется.
Если бы подобный код выполнялся и другим потоком, его цикл
while работал бы до тех пор, пока значение переменной
g_fResourceInUse вновь не изменилось бы на. Вызов
InterlockedExchange в конце функции показывает, как вернуть переменной значение Применяйте эту методику с крайней осторожностью, потому что процессорное время при спин блокировке тратится впустую. Процессору приходится постоянно сравнивать два значения, пока одно из них не будет волшебным образом изменено другим потоком. Учтите этот код подразумевает, что все потоки, использующие спин блокировку, имеют одинаковый уровень приоритета. К тому же, Вам, наверное, придется отключить динамическое повышение приоритета этих потоков (вызовом
SetPro
cessPriorityBoost или SetThreadPriorityBoost).
Вы должны позаботиться и о том, чтобы переменная — индикатор блокировки и данные, защищаемые такой блокировкой, не попали в одну кэш линию (о кэш линиях я расскажу в следующем разделе. Иначе процессор, использующий ресурс, будет конкурировать с любыми другими процессорами, которые пытаются обратиться к тому же ресурсу. А это отрицательно скажется на быстродействии.
Избегайте спин блокировки на однопроцессорных машинах. Крутясь в цикле,
поток впустую транжирит драгоценное процессорное время, не давая другому потоку изменить значение переменной. Применение функции
Sleep в цикле while несколько улучшает ситуацию. С ее помощью Вы можете отправлять свой поток в сон на некий случайный отрезок времени и после каждой безуспешной попытки обратиться к ресурсу увеличивать этот отрезок. Тогда потоки не будут зря отнимать процессорное время. В зависимости от ситуации вызов
Sleep можно убрать или заменить на вызов
SwitchToThread (эта функция вне доступна. Очень жаль, но, по видимому, Вам придется действовать здесь методом проб и ошибок.
Спин блокировка предполагает, что защищенный ресурс не бывает занят надолго. И тогда эффективнее делать так выполнять цикл, переходить в режим ядра и ждать.
Многие разработчики повторяют цикл некоторое число раз (скажем, 4000) и, если ресурс к тому времени не освободился, переводят поток в режим ядра, где он спит,
ожидая освобождения ресурса (и не расходуя процессорное время. По такой схеме реализуются критические секции (critical Спин блокировка полезна на многопроцессорных машинах, где один поток может крутиться в цикле, а второй — работать на другом процессоре. Но даже в таких условиях надо быть осторожным. Вряд ли Вам понравится, если поток надолго вой

192
Ч АС Т Ь I I
НАЧИНАЕМ РАБОТАТЬ
дет в цикл, ведь тогда он будет впустую тратить процессорное время. Оспин блокировке мы еще поговорим в этой главе. Кроме того, в главе 10 я покажу, как использовать спин блокировку на практике.
Последняя пара
Interlocked функций выглядит так InterlockedCompareExchange(
PLONG plDestination,
LONG lExchange,
LONG lComparand);
PVOID InterlockedCompareExchangePointer(
PVOID* ppvDestination,
PVOID pvExchange,
PVOID Они выполняют операцию сравнения и присвоения на уровне атомарного доступа. В 32 разрядном приложении обе функции работают с 32 разрядными значениями, нов разрядном приложении
InterlockedCompareExchange используется для разрядных значений, а
InterlockedCompareExchangePointer — для 64 разрядных. Вот как они действуют, если представить это в псевдокоде InterlockedCompareExchange(PLONG plDestination,
LONG lExchange, LONG lComparand) {
LONG lRet = *plDestination; // исходное значение if (*plDestination == lComparand)
*plDestination = Функция сравнивает текущее значение переменной типа LONG (на которую указывает параметр
plDestination) со значением, передаваемым в параметре lComparand.
Если значения совпадают,
*plDestination получает значение параметра lExchange; вином случае
*plDestination остается без изменений. Функция возвращает исходное значение
*plDestination. И не забывайте, что все эти действия выполняются как единая атомарная операция.
Обратите внимание на отсутствие
Interlocked функции, позволяющей просто считывать значение какой то переменной, не меняя его. Она и ненужна. Если один поток модифицирует переменную с помощью какой либо
Interlocked функции в тот момент, когда другой читает содержимое той же переменной, ее значение, прочитанное вторым потоком, всегда будет достоверным. Он получит либо исходное, либо измененное значение переменной. Поток, конечно, не знает, какое именно значение он считал, но главное, что оно корректно и не является некоей произвольной величиной. В большинстве приложений этого вполне достаточно.
Interlocked функции можно также использовать в потоках различных процессов для синхронизации доступа к переменной, которая находится в разделяемой области памяти, например в проекции файла. (Правильное применение
Interlocked функ ций демонстрирует несколько программ примеров из главы Весть и другие функции из этого семейства, но ничего нового по сравнению стем, что мы уже рассмотрели, они не делают. Вот еще две из них:

193
Г ЛАВА 8
Синхронизация потоков в пользовательском режиме InterlockedIncrement(PLONG plAddend);
LONG InterlockedDecrement(PLONG plAddend);
InterlockedExchangeAdd полностью заменяет обе эти устаревшие функции. Новая функция умеет добавлять и вычитать произвольные значения, а функции
Interlocked
Increment и InterlockedDecrement увеличивают и уменьшают значения только на 1.
Кэш-линии
Если Вы хотите создать высокоэффективное приложение, работающее на многопроцессорных машинах, то просто обязаны уметь пользоваться кэш линиями процессора. Когда процессору нужно считать из памяти один байт, он извлекает не только его, но и столько смежных байтов, сколько требуется для заполнения кэш линии. Такие линии состоят из 32 или 64 байтов (в зависимости от типа процессора) и всегда выравниваются по границам, кратным 32 или 64 байтам. Кэш линии предназначены для повышения быстродействия процессора. Обычно приложение работает с набором смежных байтов, и, если эти байты уже находятся в кэше, процессору не приходится снова обращаться к шине памяти, что обеспечивает существенную экономию времени.
Однако кэш линии сильно усложняют обновление памяти в многопроцессорной среде. Вот небольшой пример:
1.
Процессор 1 считывает байт, извлекая этот и смежные байты в свою кэш линию.
2.
Процессор 2 считывает тот же байта значит, и тот же набор байтов, что и процессор 1; извлеченные байты помещаются в кэш линию процессора Процессор 1 модифицирует байт памяти, и этот байт записывается в его кэш линию. Но эти изменения еще не записаны в оперативную память.
4.
Процессор 2 повторно считывает тот же байт. Поскольку он уже помещен в кэш линию этого процессора, последний не обращается к памяти и, следовательно, не видит новое значение данного байта.
Такой сценарий был бы настоящей катастрофой. Но разработчики чипов прекрасно осведомлены об этой проблеме и учитывают ее при проектировании своих процессоров. В частности, когда один из процессоров модифицирует байты в своей кэш линии, об этом оповещаются другие процессоры, и содержимое их кэш линий объявляется недействительным. Таким образом, в примере, приведенном выше, после изменения байта процессором 1, кэш процессора 2 был бы объявлен недействительным.
На этапе 4 процессор 1 должен сбросить содержимое своего кэша в оперативную память, а процессор 2 — повторно обратиться к памяти и вновь заполнить свою кэш линию. Как видите, кэш линии, которые, как правило, увеличивают быстродействие процессора, в многопроцессорных машинах могут стать причиной снижения произ водительности.
Все это означает, что Вы должны группировать данные своего приложения в блоки размером с кэш линии и выравнивать их по тем же правилам, которые применяются к кэш линиям. Ваша цель — добиться того, чтобы различные процессоры обращались к разным адресам памяти, отделенным друг от друга по крайней мере границей кэш линии. Кроме того, Вы должны отделить данные только для чтения (или редко используемые данные) отданных для чтения и записи. И еще Вам придется позаботиться о группировании тех блоков данных, обращение к которым происходит примерно водно и тоже время.

194
Ч АС Т Ь I I
НАЧИНАЕМ РАБОТАТЬ
Вот пример плохо продуманной структуры данных CUSTINFO {
DWORD
dwCustomerID;
// в основном "только для чтения nBalanceDue;
// для чтения и записи char szName[100];
// в основном "только для чтения ftLastOrderDate;
// для чтения и записи
};
А это усовершенствованная версия той же структуры определяем размер кэш линии используемого процессора _X86_
#define CACHE_ALIGN 32
#endif
#ifdef _ALPHA_
#define CACHE_ALIGN 64
#endif
#ifdef _IA64_
#define CACHE_ALIGN ??
#endif
#define CACHE_PAD(Name, BytesSoFar) \
BYTE Name[CACHE_ALIGN ((BytesSoFar) % CACHE_ALIGN)]
struct CUSTINFO {
DWORD
dwCustomerID;
// в основном "только для чтения szName[100];
// в основном "только для чтения принудительно помещаем следующие элементы в другую кэш линию, sizeof(DWORD) + 100);
int nBalanceDue;
// для чтения и записи для чтения и записи принудительно помещаем следующую структуру в другую кэш линию, sizeof(int) + Макрос CACHE_ALIGN неплох, ноне идеален. Проблема в том, что байтовый размер каждого элемента придется вводить в макрос вручную, а при добавлении, перемещении или удалении элемента структуры — еще и модифицировать вызов макроса. В следующих версиях компилятор Microsoft C/C++ будет поддерживать новый синтаксис, упрощающий выравнивание элементов структур. Это будет что то вроде __
declspec(align(32)).
Лучше всего, когда данные используются единственным потоком (самый простой способ добиться этого — применять параметры функций и локальные переменные) или одним процессором (это реализуется привязкой потока коп ределенному процессору. Если Вы пойдете по такому пути, можете вообще забыть о проблемах, связанных с кэш линиями.

195
Г ЛАВА 8
Синхронизация потоков в пользовательском режиме
Более сложные методы синхронизации потоков
Interlocked функции хороши, когда требуется монопольно изменить всего одну переменную. С них и надо начинать. Но реальные программы имеют дело со структурами данных, которые гораздо сложнее единственной 32 или 64 битной переменной. Чтобы получить доступна атомарном уровне к таким структурам данных, забудьте об
Interlocked функциях и используйте другие механизмы, предлагаемые В предыдущем разделе я подчеркнул неэффективность спин блокировки на одно процессорных машинах и обратил Ваше внимание на то, что со спин блокировкой надо быть осторожным даже в многопроцессорных системах. Хочу еще раз напомнить, что основная причина связана с недопустимостью пустой траты процессорного времени. Так что нам нужен механизм, который позволил бы потоку, ждущему освобождения разделяемого ресурса, не расходовать процессорное время.
Когда поток хочет обратиться к разделяемому ресурсу или получить уведомление о некоем особом событии, он должен вызвать определенную функцию операционной системы и передать ей параметры, сообщающие, чего именно он ждет. Как только операционная система обнаружит, что ресурс освободился или что особое событие произошло, эта функция вернет управление потоку, и тот снова будет включен в число планируемых. (Это не значит, что поток тут же начнет выполняться система подключит его к процессору по правилам, описанным в предыдущей главе.)
Пока ресурс занят или пока не произошло особое событие, система переводит поток в ждущий режим, исключая его из числа планируемых, и берет на себя роль агента, действующего в интересах спящего потока. Она выведет его из ждущего режима, когда освободится нужный ресурс или произойдет особое событие».
Большинство потоков почти постоянно находится в ждущем режиме. И когда система обнаруживает, что все потоки уже несколько минут спят, срабатывает механизм управления электропитанием.
Худшее, что можно сделать
Если бы синхронизирующих объектов не было, а операционная система неумела отслеживать особые события, потоку пришлось бы самостоятельно синхронизировать себя сними, применяя метод, который я как рази собираюсь продемонстрировать.
Но поскольку в операционную систему встроена поддержка синхронизации объектов,
никогда не применяйте этот метод.
Суть его в том, что поток синхронизирует себя с завершением какой либо задачи в другом потоке, постоянно просматривая значение переменной, доступной обоим потокам. Возьмем пример BOOL g_fFinishedCalculation = FALSE;
int WINAPI WinMain(...) {
CreateThread(..., RecalcFunc, ...);
M
// ждем завершения пересчета while (!g_fFinishedCalculation)
;
M
}
см. след. стр.

196
Ч АС Т Ь I I
НАЧИНАЕМ РАБОТАТЬ WINAPI RecalcFunc(PVOID pvParam) {
// выполняем пересчет = Как видите, первичный поток (он исполняет функцию
WinMain) при синхронизации по такому событию, как завершение функции
RecalcFunc, никогда не впадает в спячку. Поэтому система по прежнему выделяет ему процессорное время за счет других потоков, занимающихся чем то более полезным.
Другая проблема, связанная с подобным методом опроса, в том, что булева переменная может не получить значения TRUE — например, если у первичного потока более высокий приоритет, чему потока, выполняющего функцию
RecalcFunc. В этом случае система никогда не предоставит процессорное время потоку, а он никогда не выполнит оператор, присваивающий значение переменной
g_fFinishedCalculation. Если бы мы не опрашивали поток, выполняющий функцию
WinMain, а просто отправили в спячку, это позволило бы системе отдать его долю процессорного времени потокам с более низким приоритетом, в частности потоку
RecalcFunc.
Вполне допускаю, что опрос иногда удобен. В конце концов, именно это и делается при спин блокировке. Но есть два способа его реализации корректный и некорректный. Общее правило таково избегайте применения спин блокировки и опроса.
Вместо этого пользуйтесь функциями, которые переводят Ваш поток в состояние ожидания до освобождения нужного ему ресурса. Как это правильно сделать, я объясню в следующем разделе.
Прежде всего позвольте обратить Ваше внимание на одну вещь вначале приведенного выше фрагмента кода я использовал спецификатор
volatile — без него работа моей программы просто немыслима. Он сообщает компилятору, что переменная может быть изменена извне приложения — операционной системой, аппаратным устройством или другим потоком. Точнее, спецификатор
volatile заставляет компилятор исключить эту переменную из оптимизации и всегда перезагружать ее значение из памяти. Представьте, что компилятор сгенерировал следующий псевдокод для оператора из предыдущего фрагмента кода, [g_fFinishedCalculation]
; копируем значение в регистр Reg0, 0
; равно ли оно нулю == 0, Label
; в регистре находится 0, повторяем цикл в регистре находится ненулевое значение (выходим из цикла)
Если бы я не определил булеву переменную как volatile, компилятор мог бы оптимизировать наш код на C именно так. При этом компилятор загружал бы ее значение в регистр процессора только раза потом сравнивал бы искомое значение с содержимым регистра. Конечно, такая оптимизация повышает быстродействие, поскольку позволяет избежать постоянного считывания значения из памяти оптимизирующий компилятор скорее всего сгенерирует код именно так, как я показал. Но тогда наш поток войдет в бесконечный цикли никогда не проснется. Кстати, если структура определена как volatile, таковыми становятся и все ее элементы, те. при каждом обращении они считываются из памяти.
1   ...   18   19   20   21   22   23   24   25   ...   68


База данных защищена авторским правом ©nethash.ru 2019
обратиться к администрации

войти | регистрация
    Главная страница


загрузить материал