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

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

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

Оглавление

 

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

238

Атомарный доступ: семейство Intelocked-функций........................................................................

240

Кэш-линии..............................................................................................................................................

247

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

249

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

250

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

251

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

254

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

257

Критические секции и обработка ошибок......................................................................................

258

«Тонкая» блокировка ...........................................................................................................................

260

Условные переменные........................................................................................................................

264

Приложение-пример Queue.............................................................................................................

265

Несколько полезных приемов........................................................................................................

278

Г Л А В А 8

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

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

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

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

считывает. Представьте, вы читаете книгу, а в это время кто-то переписывает текст на открытой вами странице. Ничего хорошего из этого не выйдет.

Потоки должны взаимодействовать друг с другом в двух основных случаях:

совместно используя разделяемый ресурс (чтобы не разрушить его);

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

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

С программированием для многопоточной среды я впервые столкнулся в 1992 г. Поначалу я делал уйму ошибок, так что в главах моих книг и

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

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

Атомарный доступ: семейство Intelocked-функций

Бо́льшая часть синхронизации потоков связана с атомарным доступом (atomic access) — монопольным захватом ресурса обращающимся к нему потоком. Возьмем простой пример.

// определяем глобальную переменную long g_x = 0;

DWORD WINAPI ThreadFunc1(PV0ID pvParam) { g_x++;

return(0);

}

DWORD WINAPI ThreadFunc2(PV0ID pvParam) { g_x++;

return(0);

}

Я объявил глобальную переменную g_x и инициализировал ее нулевым значением. Теперь представьте, что я создал два потока: один выполняет ThreadFunc1, другой — ThreadFunc2. Код этих функций идентичен: обе увеличивают значение глобальной переменной g_x на 1. Поэтому вы, наверное, подумали: когда оба потока завершат свою работу, значение g_х будет равно 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 ; поток 1: значение регистра увеличивается на 1 MOV [g_x], EAX ; поток 1: значение 1 помещается в g_x

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

NOV EAX, [g_x] ; поток 2: в регистр помещается 1

INC EAX ; поток 2: значение регистра увеличивается до 2 NOV [g_x], EAX ; поток 2: значение 2 помещается в g_x

После выполнения обоих потоков значение g_x будет равно 2. Это просто замечательно и как раз то, что мы ожидали: взяв переменную с нулевым значением, дважды увеличили ее на 1 и получили в результате 2. Прекрасно. Но постойте-ка, ведь Windows — это среда, которая поддерживает многопоточность и вытесняющую многозадачность. Значит, процессорное время в любой момент может быть отнято у одного потока и передано другому. Тогда код, приведенный мной выше, может выполняться и таким образом:

MOV 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

А если код будет выполняться именно так, конечное значение g_x окажется равным 1, а не 2, как мы думали! Довольно пугающе, особенно если учесть, как мало у нас рычагов управления планировщиком. Фактически, даже при сотне потоков, которые выполняют функции, идентичные нашей, в конечном итоге вполне можно получить в g_x все ту же единицу! Очевидно, что в таких условиях работать просто нельзя. Мы вправе ожидать, что, дважды увеличив 0 на 1, при любых обстоятельствах получим 2. Кстати, результаты могут зависеть от того, как именно компилятор генерирует машинный код, а также от того, как процессор выполняет этот код и сколько процессоров установлено в машине. Это объективная реальность, в которой мы не в состоянии что-либо изменить. Однако в Windows есть ряд функций, которые (при правильном их использовании) гарантируют корректные результаты выполнения кода.

Решение этой проблемы должно быть простым. Все, что нам нужно, — это способ, гарантирующий приращение значения переменной на уровне атомарного доступа, т. е. без прерывания другими потоками. Семейство Interlocked-функций как раз и дает нам ключ к решению подобных проблем. Большинство разработчиков программного обеспечения недооценивает эти функции, а ведь они невероятно полезны и очень просты для понимания. Все функции из этого семейства манипулируют переменными на уровне атомарного доступа. Взгляните на InterlockedExchangeAdd и ее 64-разрядную версию InterlockedExchangeAdd64, манипули-

рующую значениями типа LONGLONG:

LONG InterlockedExchangeAdd(

PLOMG volatile plAddend,

LONG lIncrement);

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

L0NGL0NG InterlockedExchangeAdd64(

PLOMGL0NG volatile pllAddend,

L0NGL0NG llIncrement);

Что может быть проще? Вы вызываете эту функцию, передавая адрес переменной типа LONG, и указываете добавляемое значение. InterlockedExchangeAdd гарантирует, что операция будет выполнена атомарно. Перепишем наш код вот так:

// определяем глобальную переменную long g_x = 0;

DWORD WINAPI ThreadFunc1(PV0ID pvParam) { InterlockedExchangeAdd(&g_x, 1); return(0);

}

DWORD WINAPI ThreadFunc2(PV0ID pvParam) { InterlockedExchangeAdd(&g_x, 1); return(0);

}

Теперь вы можете быть уверены, что конечное значение g_x будет равно 2. Ну, вам уже лучше? Заметьте: в любом потоке, где нужно модифицировать значение разделяемой (общей) переменной типа LONG, следует пользоваться лишь Interlocked- функциями и никогда не прибегать к стандартным операторам языка С:

//переменная типа L0NG, используемая несколькими потоками

LONG g_x; …

//неправильный способ увеличения переменной типа LONG

G_x++; …

// правильный способ увеличения переменной типа L0NG

InterlockedExchangeAdd(&g_x, 1);

Как же работают Interlocked-функции? Ответ зависит от того, какую процессорную платформу вы используете. На компьютерах с процессорами семейства x86 эти функции выдают по шине аппаратный сигнал, не давая другому процессору обратиться по тому же адресу памяти.

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

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

Примечание. Библиотека С поддерживает функций _aligned_malloc, которая служит для выделения надлежащим образом выровненных блоков памяти. Вот как выглядит прототип этой функции:

void * _aligned_malloc(size_t size, size_t alignment);

Аргумент size определяет размер блока в байтах, а alignment — границу (в байтах), по которой должен быть выровнен блок. Аргумент alignment может принимать значения, представляющие собой различные степени числа 2.

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

Кстати, InterlockedExchangeAdd позволяет не только увеличить, но и уменьшить значение — просто передайте во втором параметре отрицательную величи-

ну. InterlockedExchangeAdd возвращает исходное значение в *plAddend.

Вот еще три функции из этого семейства:

LONG InterlockedExchange(

PL0NG volatile plTarget,

LONG lValue);

L0NGL0NG InterlockedExchange64(

PL0NGL0NG volatile plTarget,

L0NGL0N6 lValue);

PV0ID InterlockedExchangePointer(

PV0ID* volatile ppvTarget,

PV0ID pvValue);

InterlockedExchange и InterlockedExchangePointer монопольно заменяют теку-

щее значение переменной типа LONG, адрес которой передается в первом параметре, на значение, передаваемое во втором параметре. В 32-разрядном приложении обе функции работают с 32-разрядными значениями, но в 64-разрядной программе первая оперирует с 32-разрядными значениями, а вторая — с 64разрядными. Все функции возвращают исходное значение переменной. InterlockedExchange чрезвычайно полезна при реализации спин-блокировки (spinlock):

//глобальная переменная, используемая как индикатор того, занят ли

//разделяемый ресурс

B00L g_fResourceInUse = FALSE; … void Func1() {

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

// ожидаем доступа к ресурсу

while (IntertockedExchange (&g_fResourceInUee, TRUE) == TRUE) Sleep(0);

//получаем ресурс в свое распоряжение

//доступ к ресурсу больше не нужен

InterlockedExchange(&g_fResourceInUse, FALSE);

}

В этой функции постоянно «крутится» цикл while, в котором переменной g_fResourceInUse присваивается значение TRUE и проверяется ее предыдущее значение. Если оно было равно FALSE, значит, ресурс не был занят, но вызывающий поток только что занял его; на этом цикл завершается. В ином случае (значение было равно TRUE) ресурс занимал другой поток, и цикл повторяется.

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

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

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

Избегайте спин-блокировки на однопроцессорных машинах. «Крутясь» в цикле, поток впустую транжирит драгоценное процессорное время, не давая другому потоку изменить значение переменной. Применение функции Sleep в цикле while несколько улучшает ситуацию. С ее помощью вы можете отправлять свой поток в сон на некий случайный отрезок времени и, после каждой безуспешной попытки обратиться к ресурсу, увеличивать этот отрезок. Тогда потоки не будут зря отнимать процессорное время. В зависимости от ситуации вызов Sleep можно убрать или заменить вызовом SwitchToThread. Очень жаль, но, по-видимому, вам придется действовать здесь методом проб и ошибок.

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

Спин-блокировка предполагает, что защищенный ресурс не бывает занят надолго. И тогда эффективнее делать так: выполнять цикл, переходить в режим ядра и ждать. Многие разработчики повторяют цикл некоторое число раз (скажем, 4000) и, если ресурс к тому времени не освободился, переводят поток в режим ядра, где он спит, ожидая освобождения ресурса (и не расходуя процессорное время). По такой схеме реализуются критические секции (critical sections).

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

Последняя пара Interlocked-функций выглядит так:

PV0ID InterlockedCompareExchange(

PL0NG plDestination,

LONG lExchange,

LONG lComparand);

PV0ID InterlockedCompareExchangePointer(

PVOID* ppvDestination,

PV0ID pvExchange,

PV0ID pvComparand);

Они выполняют операцию сравнения и присвоения на уровне атомарного доступа. В 32-разрядном приложении обе функции работают с 32-разрядными значениями, но в 64-разрядном приложении InterlockedCompareExchange используется для 32-разрядных значений, а InterlockedCompareExchangePointer — для 64-

разрядных. Вот как они действуют, если представить это в псевдокоде:

LONG InterlockedCompareExchange(PLONG plDestination,

LONG lExchange, L0NQ lComparand) {

L0Nfi lRet = *plDestination;

// исходное значение

if (*plDestination == lComparand) *plDestination = lExchange;

return(lRet);

}

Функция сравнивает текущее значение переменной типа LONG (на которую указывает параметр *plDestination) со значением, передаваемым в параметре lComparand. Если значения совпадают, *plDestination получает значение параметра lExchange; в ином случае *plDestination остается без изменений. Функция возвращает исходное значение *plDestination. И не забывайте, что все эти действия выполняются как единая атомарная операция.

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

L0NGL0NG InterlockedCompareExchange64(

LONGLONG pllDestination,

L0NGL0NG llExchange,

LONGLONG llComparand);

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

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

LONG InterlockedIncrement(PLONG plAddend);

LONG InterlockedDecrement(PLONG plAddend);

InterlockedExchangeAdd полностью заменяет обе эти устаревшие функции. Новая функция умеет добавлять и вычитать произвольные значения, а функции

InterlockedIncrement и InterlockedDecrement увеличивают и уменьшают значения только на 1.

Поддерживается также набор вспомогательных Interlocked-функций OR, AND

и XOR, основанных на InterlockedCompareExchange64. В их реализации (см. файл

WinBase.h) показанные выше спин-блокировки используются следующим образом:

LONGLONG InterlockedAnd64(

LONGLONG* Destination,

LONGLONG Value) {

LONGLONG Old;

do {

Old = *Destination;

} while (InterlockedCompareExchange64(Destination, Old & Value, Old) != Old);

return Old;

}

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

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

8-1.

Табл. 8-1. Функции для работы с однонаправленными списками с взаимоблокировкой

Функция

Описание

InitializeSListHead

Создает пустой стек

InterlockedPushEntrySList

Добавляет элемент в начало стека

InteHockedPopEntrySLisi

Возвращает элемент стека, попутно удаляя его

InterlockedFhishSList

Очищает стек

QueryDepthSList

Возвращает число элементов стека

Кэш-линии

Если вы хотите создать высокоэффективное приложение, работающее на многопроцессорных машинах, то просто обязаны уметь пользоваться кэш-линиями процессора (CPU cache lines). Когда процессору нужно считать из памяти один байт, он извлекает не только его, но и столько смежных байтов, сколько требуется для заполнения кэш-линии. Такие линии состоят из 32 или 64 байтов (в зависимости от типа процессора) и всегда выравниваются по границам, кратным 32 или 64 байтам. Кэш-линии предназначены для повышения быстродействия процессора. Обычно приложение работает с набором смежных байтов, и, если эти байты уже находятся в кэше, процессору не приходится снова обращаться к шине памяти, что обеспечивает существенную экономию времени.

Однако кэш-линии сильно усложняют обновление памяти в многопроцессорной среде. Вот небольшой пример:

1.Процессор 1 считывает байт, извлекая этот и смежные байты в свою кэшлинию.

2.Процессор 2 считывает тот же байт, а значит, и тот же набор байтов, что и процессор 1; извлеченные байты помещаются в кэш-линию процессора 2.

3.Процессор 1 модифицирует байт памяти, и этот байт записывается в его кэшлинию. Но эти изменения еще не записаны в оперативную память.

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

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