Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

Рихтер Дж., Назар К. - Windows via C C++. Программирование на языке Visual C++ - 2009

.pdf
Скачиваний:
6257
Добавлен:
13.08.2013
Размер:
31.38 Mб
Скачать

248 Часть II. Приступаем к работе

Такой сценарий был бы настоящей катастрофой. Но разработчики чипов прекрасно осведомлены об этой проблеме и учитывают ее при проектировании своих процессоров. В частности, когда один из процессоров модифицирует байты в своей кэш-линии, об этом оповещаются другие процессоры, и содержимое их кэшлиний объявляется недействительным. Таким образом, в примере, приведенном выше, после изменения байта процессором 1, кэш процессора 2 был бы объявлен недействительным. На этапе 4 процессор 1 должен сбросить содержимое своего кэша в оперативную память, а процессор 2 — повторно обратиться к памяти и вновь заполнить свою кэш-линию. Как видите, кэш-линии, которые, как правило, увеличивают быстродействие процессора, в многопроцессорных машинах могут стать причиной снижения производительности.

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

Вот пример плохо продуманной структуры данных:

struct CUSTINF0 {

 

DWORD

dwCustomerID;

// в основном "только для чтения"

int

nBalanceDue;

// для чтения и записи

wchar_t

szName[100];

// в основной "только для чтения"

FILETIME

ftLastOrderDate;

// для чтения и записи

};

 

 

А это усовершенствованная версия той же структуры:

#define CACHE_ALIGN 64

// принудительно помещаем следующие элементы в другую кэш-линию struct __declspec(align(CACHE_ALIGN)) CUSTINF0 {

DWORD

dwCustomerID;

// в основном "только для чтения"

wchar_t

szName[l00];

// в основном "только для чтения"

// принудительно помещаем следующие элементы в другую кэш-линию

_declspac(align(CAGHE_ALIGN))

 

int nBalanceDue;

// для чтения и записи

FILETIME ftLastOrderDate;

// для чтения и записи

};

Проще всего определить размер кэш-линии процессора вызовом Win32функции GetLogicalProcessorInformation. Эта функция возвращает массив

Глава 8. Синхронизация потоков в пользовательском режиме.docx 249

структур SYSTEM_LOGICAL_PROGESS0R_INFORMATION. Узнать размер кэш-

линии можно, проанализировав поле Cache этой структуры, содержащее ссылку на структуру CACHE_DESCRIPTOR, в которой, в свою очередь, имеется поле LineSize, представляющее размер кэш-линии. Получив эту информацию, можно управлять выравниванием полей с помощью директивы __decfapec(align(#)) компилятора С/С++. Подробнее об использовании __declspec(align(#)), см. по ссылке http://msdn2.microsoftcom/en^is/library/83ythb65&spx.

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

Более сложные методы синхронизации потоков

Interlocked-функции хороши, когда требуется монопольно изменить всего одну переменную. С них и надо начинать. Но реальные программы имеют дело со структурами данных, которые гораздо сложнее единственной 32-или 64-битной переменной. Чтобы получить доступ на атомарном уровне к таким структурам данных, забудьте o6 Interlocked-функциях и используйте другие механизмы, предлагаемые Windows.

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

Когда поток хочет обратиться к разделяемому ресурсу или получить уведомление о некоем «особом событии», он должен вызвать определенную функцию операционной системы и передать ей параметры, сообщающие, чего именно он ждет. Как только операционная система обнаружит, что ресурс освободился или что «особое событие» произошло, эта функция вернет управление потоку, и тот снова будет включен в число планируемых. (Это не значит, что поток тут же начнет выполняться; система подключит его к процессору по правилам, описанным в предыдущей главе.)

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

250 Часть II. Приступаем к работе

выведет его из ждущего режима, когда освободится нужный ресурс или произойдет «особое событие».

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

Худшее, что можно сделать

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

Суть его в том, что поток синхронизирует себя с завершением какой-либо задачи в другом потоке, постоянно просматривая значение переменной, доступной обоим потокам. Возьмем пример:

volatile BOOL g_fFinishedCalculation = FALSE;

int WINAPI _tWinMain(…) { CreateThread(…, RecalcFunc, …);

// ждем завершения пересчета while (!g_fFinishedCalculation)

;

}

DWORD WINAPI RecalcFunc(PVOID pvParam) { // выполняем пересчет

… g_fFihlshedCalculation = TRUE; return(0);

}

Как видите, первичный поток (он исполняет функцию _tWinMain) при синхронизации по такому событию, как завершение функции RecalcFunc, никогда не впадает в спячку. Поэтому система по-прежнему выделяет ему процессорное время за счет других потоков, занимающихся чем-то более полезным.

Другая проблема, связанная с подобным методом опроса, в том, что булева переменная g_fFinishedCalculation может не получить значения TRUE — например, если у первичного потока более высокий приоритет, чем у потока, выполняющего функцию RecalcFunc. В этом случае система никог-

да

не предоставит процессорное время потоку RecalcFunc, а он никогда

не

выполнит оператор, присваивающий значение TRUE переменной g_

fFinishedCalculation. Если бы мы не опрашивали поток, выполняющий функцию _tWinMain, а просто отправили в спячку, это позволило бы системе от-

Глава 8. Синхронизация потоков в пользовательском режиме.docx 251

дать его долю процессорного времени потокам с более низким приоритетом, в частности потоку RecalcFunc.

Вполне допускаю, что опрос иногда удобен. В конце концов, именно это и делается при спин-блокировке. Но есть два способа его реализации: корректный и некорректный. Общее правило таково: избегайте применения спин-блокировки и опроса. Вместо этого пользуйтесь функциями, которые переводят ваш поток в состояние ожидания до освобождения нужного ему ресурса. Как это правильно сделать, я объясню в следующем разделе.

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

NOV

RegO,

[g_fFinishedCalculation]

; копируем значение в регистр

Label:

TEST

RegO, 0

; равно ли оно нулю?

JMP

RegO == 0, Label

; в регистре находится 0, повторяем цикл

 

 

 

; в регистре находится ненулевое значение

Если бы я не определил булеву переменную как volatile, компилятор мог бы оптимизировать наш код на С именно так. При этом компилятор загружал бы ее значение в регистр процессора только раз, а потом сравнивал бы искомое значение с содержимым регистра. Конечно, такая оптимизация повышает быстродействие, поскольку позволяет избежать постоянного считывания значения из памяти; оптимизирующий компилятор, скорее всего, сгенерирует код именно так, как я показал. Но тогда наш поток войдет в бесконечный цикл и никогда не проснется. Кстати, если структура определена как volatile, таковыми становятся и все ее элементы, т. е. при каждом обращении они считываются из памяти.

Вас, наверное, заинтересовало, а не следует ли объявить как volatile и мою переменную g_fResourceInUse в примере со спин-блокировкой. Отвечаю: нет, потому что она передается Interlocked-функции по ссылке, а не по значению. Передача переменной по ссылке всегда заставляет функцию считывать ее значение из памяти, и оптимизатор никак не влияет на это.

Критические секции

Критическая секция (critical section) — это небольшой участок кода, требующий монопольного доступа к каким-то общим данным. Она позволяет

252 Часть II. Приступаем к работе

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

Вот пример кода, который демонстрирует, что может произойти без критической секции:

const

int COUNT = 1000;

int

g_nSum = 0;

DWORD WINAPI FirstThread(PVOID pvParam) { g_nSum = 0;

for (int n - 1; n <= COUNT; n++) { g_nSum += n;

}

return(g_nSum);

}

DWORD WINAPI SecondThread(PV0ID pvParam) { g_nSum = 0;

for (int i = 1; n <= COUNT; n++) { g_nSum += n;

}

return(g_nSum);

}

Здесь предполагается, что функции обоих потоков дают одинаковый результат, хоть они и закодированы с небольшими различиями. Если бы исполнялась только функция FirstThread, она бы сложила все числа от 0 до значения COUNT. Это верно и в отношении SecondThread — если бы она тоже исполнялась независимо. Но в нашем коде возникает проблема: функции обоих потоков (возможно, работающих на разных процессорах) одновременно обращаются к одной и той же глобальной переменной (g_nSum), значение которой в результате изменяется непредсказуемым образом.

Согласен, пример довольно надуманный (к тому же сумму легко вычислить и без использования цикла, например, так: g_nSum = COUNT * (COUNT + 1) / 2), но, чтобы привести реалистичный, нужно минимум несколько страниц кода. Важно другое: теперь вы легко представите, что может произойти в действительности. Возьмем пример с управлением связанным списком объектов. Если доступ к связанному списку не синхронизирован, один поток может добавить элемент в список в тот момент, когда другой поток пытается найти в нем какой-то элемент. Ситуация станет еще более угрожающей, если оба потока одновременно добавят в список новые элементы. Так что, используя критические секции, можно и нужно координировать доступ потоков к структурам данных.

Глава 8. Синхронизация потоков в пользовательском режиме.docx 253

Теперь, когда вы видите все «подводные камни», попробуем исправить этот фрагмент кода с помощью критической секции:

const int COUNT = 10; int g_nSum = 0; CRITICAL_SECTION g_cs;

DW0R0 WINAPI FirstThread(PVOID pvParam) { EnterCriticalSection(&g_cs);

g_nSum = 0;

for (int n = 1; n <= C0UNT; n++) { g_nSum += n;

}

LeaveCriticalSection(&g_cs); return(g_nSum);

}

DWORD WINAPI SecondThread(PVOID pvParam) { EnterСriticalSection(&g_cs);

g_nSum = 0;

for (int n = 1; n <= COUNT; n++) { g_nSum += n;

}

LeaveCriticalSection(&g_cs); return(g_nSum);

}

Я создал экземпляр структуры данных CRITICAL_SECTION — g_cs, а потом «обернул» весь код, работающий с разделяемым ресурсом (в нашем примере это строки cg_nSum), вызовами EnterCriticalSection и LeaveCriticalSection. Заметьте,

что при вызовах этих функций я передаю адрес g_cs.

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

Если у вас есть ресурсы, всегда используемые вместе, вы можете поместить их в одну кабинку — единственная структура CRITICAL_SECTION будет охранять их всех. Но если ресурсы не всегда используются вместе (например, потоки 1 и 2 работают с одним ресурсом, а потоки 1 и 3 — с другим), вам придется создать им по отдельной кабинке, или структуре CRITICAL_SECTION.

Теперь в каждом участке кода, где вы обращаетесь к разделяемому ресурсу, вызывайте EnterCriticalSection, передавая ей адрес структуры CRITICAL_

254 Часть II. Приступаем к работе

SECTION, которая выделена для этого ресурса. Иными словами, поток, желая обратиться к ресурсу, должен сначала убедиться, нет ли на двери кабинки знака «занято». Структура CRITICAL_SECTION идентифицирует кабинку, в которую хочет войти поток, а функция EnterCriticalSection — тот инструмент, с помощью которого он узнает, свободна или занята кабинка. EnterCriticalSection допустит вызвавший ее поток в кабинку, если определит, что та свободна. В ином случае (кабинка занята) EnterCriticalSection заставит его ждать, пока она не освободится.

Поток, покидая участок кода, где он работал с защищенным ресурсом, должен вызвать функцию LeaveCriticalSection. Тем самым он уведомляет систему о том, что кабинка с данным ресурсом освободилась. Если вы забудете это сделать, система будет считать, что ресурс все еще занят, и не позволит обратиться к нему другим ждущим потокам. То есть вы вышли из кабинки и оставили на двери знак «занято».

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

если в FirstThread убрать вызовы EnterCriticalSection и LeaveCriticalSection, содержимое переменной g_nSum станет некорректным — даже не-

смотря на то что в SecondThread функции EnterCriticalSection и LeaveCriticalSection вызываются правильно.

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

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

Критические секции: важное дополнение

Теперь, когда у вас появилось общее представление о критических секциях (зачем они нужны и как с их помощью можно монопольно распоряжаться разделяемым ресурсом), давайте повнимательнее приглядимся к тому, как они устроены. Начнем со структуры CRITICAL_SECTION. Вы не найдете ее в Platform SDK — о ней нет даже упоминания. В чем дело?

Хотя CRITICAL_SECTION задокументирована, Microsoft полагает, что вам незачем знать, как она устроена. И это правильно. Для нас она является своего рода чѐрным ящиком: сама структура известна, а ее элементы —

Глава 8. Синхронизация потоков в пользовательском режиме.docx 255

нет. Конечно, поскольку CRITICAL_SECTION — не более чем одна из структур, мы можем сказать, из чего она состоит, изучив заголовочные файлы. (CRITICAL_SECTION определена в файле WinBase.h как RTL_CRITICAL_SECTION, а тип структуры RTL_CRITICAL_SECTION определен в файле WinNT.h). Но никогда не пишите код, прямо ссылающийся на ее элементы.

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

Обычно структуры CRITICAL_SECTION создаются как глобальные переменные, доступные всем потокам процесса. Но ничто не мешает нам создавать их как локальные переменные или переменные, динамически размещаемые &куче. Есть только два условия, которые надо соблюдать. Во-первых, все потоки, которым может понадобиться ресурс, должны знать адрес структуры CRITICAL_SECTION, которая защищает этот ресурс. Вы можете получить ее адрес, используя любой из существующих механизмов. Во-вторых, элементы структуры CRITICAL_SECTION следует инициализировать до обращения какого-либо потока к защищенному ресурсу. Структура инициализируется вызовом:

VOID InitializeCriticalSection(PCRITICAL_SECTION pcs);

Эта функция инициализирует элементы структуры CRITICAL_SECTION, на которую указывает параметр pcs. Поскольку вся работа данной функции заключается в инициализации нескольких переменных-членов, она не дает сбоев и поэтому ничего не возвращает (void). InitializeCriticalSection должна быть вызвана до того, как один из потоков обратится к EnterCriticalSection. В документации Platform SDK недвусмысленно сказано, что попытка воспользоваться неинициализированной критической секцией даст непредсказуемые результаты.

Если вы знаете, что структура CRITICAL_SECTION больше не понадобится ни одному потоку, удалите ее, вызвав DeleteCriticalSection:

VOID DeleteCriticalSection(PCRITICAL_SECTION pcs);

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

Участок кода, работающий с разделяемым ресурсом, предваряется вызовом:

VOI0 EnterCriticalSection(PCRITICAL_SECTION pcs);

256 Часть II. Приступаем к работе

Первое, что делает EnterCriticalSection, — исследует значения элементов структуры CRITICAL_SECTION. Если ресурс занят, в них содержатся сведения о том, какой поток пользуется ресурсом. EnterCriticalSection выполняет следующие действия.

Если ресурс свободен, EnterCriticalSection модифицирует элементы структуры, указывая, что вызывающий поток занимает ресурс, после чего немедленно возвращает управление, и поток продолжает свою работу (получив доступ к ресурсу).

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

LeaveCriticalSection.

Если значения элементов структуры указывают на то, что ресурс занят другим потоком, EnterCriticalSection переводит вызывающий поток в режим ожидания. Это потрясающее свойство критических секций: поток, пребывая в ожидании, не тратит ни кванта процессорного времени! Система запоминает, что данный поток хочет получить доступ к ресурсу, и — как только поток, занимавший этот ресурс, вызывает LeaveCriticalSection — вновь начинает выделять нашему

потоку процессорное время. При этом она передает ему ресурс, автоматически обновляя элементы структуры CRITICAL_SECTION.

Внутреннее устройство EnterCriticalSection не слишком сложно; она выполняет лишь несколько простых операций. Чем она действительно ценна, так это способностью выполнять их на уровне атомарного доступа. Даже если два потока на многопроцессорной машине одновременно вызовут EnterCriticalSection, функция все равно корректно справится со своей задачей: один поток получит ресурс, другой — перейдет в ожидание.

Поток, переведенный EnterCriticalSection в ожидание, может надолго лишиться доступа к процессору, а в плохо написанной программе — даже вообще не получить его. Когда именно так и происходит, говорят, что поток «голодает».

Примечание. В действительности потоки, ожидающие освобождения критической секции, никогда не блокируются «навечно». EnterCriticalSection устроена так, что по истечении определенного времени генерирует исключение. После этого вы можете подключить к своей программе отладчик и посмотреть, что в ней случилось. Длительность времени ожидания функци-

ей EnterCriticalSection определяется значением параметра CriticalSectionTimeout, который хранится в следующем разделе системного реестра:

HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager

Глава 8. Синхронизация потоков в пользовательском режиме.docx 257

Длительность времени ожидания измеряется в секундах и по умолчанию равна 2 592 000 секунд (что составляет ровно 30 суток). Не устанавливайте слишком малое значение этого параметра (например, менее 3 секунд), так как иначе вы нарушите работу других потоков и приложений, которые обычно ждут освобождения критической секции дольше трех секунд.

Вместо EnterCriticalSection вы можете воспользоваться:

B00L TryEnterCriticalSection(PCRITICAL_SECTION pcs);

Эта функция никогда не приостанавливает выполнение вызывающего потока. Но возвращаемое ею значение сообщает, получил ли этот поток доступ к ресурсу. Если при ее вызове указанный ресурс занят другим потоком, она возвращает FALSE. Во всех остальных случаях возвращается TRUE.

TryEnterCriticalSection позволяет потоку быстро проверить, доступен ли ресурс, и, если нет, заняться чем-нибудь другим. Если функция возвращает TRUE, значит, она обновила элементы структуры CRITICAL_SECTION так, чтобы они сообщали о захвате ресурса вызывающим потоком. Отсюда следует, что для каждого вызова функции TryEnterCriticalSection, где она возвращает TRUE, надо предусмотреть парный вызов LeaveCriticalSection.

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

VOID LeaveCriticalSection(PCRITICAL_SECTION pcs);

Эта функция просматривает элементы структуры CRITICAL_SECTION и уменьшает счетчик числа захватов ресурса вызывающим потоком на 1. Если его значение больше 0, LeaveCriticalSection ничего не делает и просто возвращает управление.

Если значение счетчика достигло 0, LeaveCriticalSection сначала выясняет, есть ли в системе другие потоки, ждущие данный ресурс в вызове EnterCriticalSection. Если есть хотя бы один такой поток, функция настраивает значения элементов структуры, чтобы они сигнализировали о занятости ресурса, и отдает его одному из ждущих потоков (поток выбирается «по справедливости»). Если же ресурс никому не нужен, LeaveCriticalSection соответственно сбрасывает элементы структуры. Как и EnterCriticalSection, функция LeaveCriticalSection вы-

полняет все действия на уровне атомарного доступа. Однако LeaveCriticalSection никогда не приостанавливает поток, а управление возвращает немедленно.

Критические секции и спин-блокировка

Когда поток пытается войти в критическую секцию, занятую другим потоком, он немедленно приостанавливается. А это значит, что поток переходит из пользовательского режима в режим ядра (на что затрачивается около 1000 тактов процессора). Цена такого перехода чрезвычайно высока. На много-

Соседние файлы в предмете Программирование на C++