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

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

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

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

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

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

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

  1. Устанавливают специальный битовый флаг процессора, указывающий, что дан ный адрес памяти сейчас занят.
  2. Считывают значение из памяти в регистр.
  3. Изменяют значение в регистре.
  4. Если битовый флаг сброшен, повторяют операции, начиная с п. 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.

Кэш-линии

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

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

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

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

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

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

struct CUSTINFO
{

DWORD dwCustomerID;
// в основном "только для чтения1 int nBalanceDue,
// для чтения и записи char szName[100],
// в основном "только для чтения" FILETIME ttLastOrderDate;
// для чтения и записи
};

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

// определяем размер кэш-линии используемого процессора

#ifdef _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;
// в осноеном "только для чтения"
char szName[100];
// в основном "только для чтения"

// принудительно помещаем следующие элементы в другую кэш-линию
CACHE_PAD(bPad1, sizeof(DWORD) + 100);

int nBalanceDue;
// для чтения и записи
FILETIME ftLastOrderDate;
// для чтения и записи

// принудительно помещаем следующую структуру в другую кэш-линию
CACHE_PAD(bPad2, sizeof(int) + sizeof(FILETIME));

};

Макрос CACHE_ALIGN неплох, но не идеален. Проблема в том, что байтовый раз мер каждого элемента придется вводить в макрос вручную, а при добавлении, пере мещении или удалении элемента структуры — еще и модифицировать вызов макроса CACHE_PAD. В следующих версиях компилятор Microsoft C/C++ будет поддерживать новый синтаксис, упрощающий выравнивание элементов структур. Это будет что-то вроде __declepec(align(32)).

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

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

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

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

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

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

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

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

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

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

volatile BOOL q_fFinishedCalculation = FALSE;

int WINAPI WinMain( )
{
CreateThread( , RecalcFunc, );
... // ждем завершения пересчета
while (!g_fFinishedCalculation)
...
}

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

g_fFinishedCalculation = TRUE;
return(0);
}

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

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

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

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

MOV RegO, [g__fFinishedCalculation] ; копируем значение в регистр

Label TEST RegO, 0 ; равно ли оно нулю9

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

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

; (выходим из цикла)

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

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

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

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

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

const int MAX_TIMES = 1000,

int g_nIndex - 0,

DWORD g_dwTimes[MAX_TIMES];

DWORD WINAPI FirstThread(PVOID pvParam)
{

while (g_nIndex < MAX_TIMES)
{

g_dwTimes[g__nIndex] = GetTickCount();
g_nIndex++;
}

return(0),
}

DWORD WINAPI SecondThread(PVOID pvParam)
{

while Cg_nIndex < MAX_TIMES)
{

g_nIndex++;

g_dwTimes[g_nIndex - 1] = GetTickCount();
}

return(0);
}

Здесь предполагается, что функции обоих потоков дают одинаковый результат, хоть они и закодированы с небольшими различиями. Если бы исполнялась только функция FirstThread, она заполнила бы массив g_dwTimes набором чисел с возраста ющими значениями. Это верно и в отношении SecondThread - если бы она тоже ис полнялась независимо. В идеале обе функции даже при одновременном выполнении должны бы по-прежнему заполнять массив тем же набором чисел. Но в нашем коде возникает проблема: масив g_dwTimes не будет заполнен, как надо, потому что фун кции обоих потоков одновременно обращаются к одним и тем жс глобальным пере менным. Вот как это может произойти.

Допустим, мы только что начали исполнение обоих потоков в системе с одним процессором Первым включился в работу второй поток, т e функция SecondThread (что вполне вероятно), и только она успела увеличить счетчик g_nIndex 1, как си

стема вытеснила ее поток и перешла к исполнению FtrstThread Та заносит в g_dwTi mes[1] показания системного времени, и процессор вновь переключается на испол нение второго потока. SecondThread теперь присваивает элементу g_dwTtmes[1 - 1] новые показания системного времени Поскольку эта операция выполняется позже, новые показания, естественно, выше, чем записанные в элемент g_dwTimes[1]фyнк цией FirstThread Отметьте также, что сначала заполняется первый элемент массива и только потом нулевой. Таким образом, данные в массиве оказываются ошибочными.

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

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

const int MAX_TIMES = 1000;
int g_nIndex = 0;
DWORD g_dwTimes[MAX_TIMES];
CRITICAL_SECTION g_cs;

DWORD WINAPI FirstThread(PVOID pvParam)
{

for (BOOL fContinue = TRUE; fContinue; )
{
EnterCriticalSection(&g_cs);
if (g_nIndex < MAX_TIMES)
{

g_dwTimes[g_nlndex] = GetTickCount();
g_nIndex++;

}
else
fContinue = FALSE;
LeaveCriticalSection(&g_cs);
}

return(0);
}

 

DWORD WINAPI SecondThread(PVOID pvParam)
{

for (BOOL fContinue = TRUE; fContinue; )
{
EnterCriticalSection(&g_cs);
if (g__nIndex < MAX_TIMES)
{
g_nIndex++;
g_dwTimes[g_nIndex - 1] = GetTickCount();
}
else
fContinue = FALSE;
LeaveCriticalSecLion(&g_cs);
}

return(0);

}

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

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

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

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

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

NOTE:
Самое сложное — запомнить, что любой участок кода, работающего с разде ляемым ресурсом, нужно заключить в вызовы функций EnterCrtticalSection и LeaveCriticalSection. Если Вы забудете сделать это хотя бы в одном месте, ре сурс может быть поврежден Так, если в FirstThread убрать вызовы EnterCritical Section и LeaveCriticalSection, содержимое переменных g_nIndex и g_dwTimes станет некорректным — даже несмотря на то что в SecondThread функции EnterCriticalSection и LeaveCriticalSection вызываются правильно.

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

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

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

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

Хотя CRITICAL_SECTION не относится к недокументированным структурам, Micro soft полагает, что Вам незачем знать, как она устроена И это правильно. Для нас она нвляется своего рода черным ящиком - сама структура известна, а ее элементы — нет. Конечно, поскольку CRITICAL_SECTION — не более чем одна из структур, мы можем сказать, из чего она состоит, изучив заголовочные файлы. (CRITICAT,_SECTlON опреде лена в файле WinNT.h как RTL_CRITICAL_SECTION, а тип структуры RTL_CRITICAL_SEC TION определен в файле WinBase.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.

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

VOID EnterCriticalSection(PCRITICAL_SECTION pcs);

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

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

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

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

HKEY_LOCAL_MACHlNE\System\CurrentControlSet\Control\Session Manager

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

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

BOOL TryEnterCriticalSection(PCRITICAL_SECTIQN pcs);

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

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

WINDOWS 2000
В Windows 98 функция TryEnterCriticalSection определена, но не реализована. При ее вызове всегда возвращается FALSE.

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

VOID LeaveCriticalSection(PCRITICAL_SECTION pcs);

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

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

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

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

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

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

Для использования спин-блокировки в критической секции нужно инициализи ровать счетчик циклов, вызвав:

BOOL InitalizeCriticalSectionAndSpinCount( PCRITICAL_SECTION pcs, DWORD dwSpinCount);

Как и в InitializeCriticalSection, первый параметр этой функции — адрес структуры критической секции. Но во втором параметре, dwSpinCount, передается число циклов спин-блокировки при попытках получить доступ к ресурсу до перевода потока в си

стояние ожидания. Этот параметр может принимать значения от 0 до 0x00FFFFFF. Учтите, что на однопроцессорной машине значение параметра dwSpinCount игнори руется и считается равным 0. Дело в том, что применение спин-блокировки в такой системе бессмысленно: поток, владеющий ресурсом, не сможет освободить его, пока другой поток «крутится» в циклах спин-блокировки.

Вы можете изменить счетчик циклов спин-блокировки вызовом:

DWORD SetCriticalSectionSpinCount( PCRITICAL_SECTION pcs, DWORD dwSpinCount);

И в этой функции значение dwSpinCount на однопроцессорной машине игнорируется.

Па мой взгляд, используя критические секции, Вы должны всегда применять спин блокировку — терять Вам просто нечего, Moгут возникнуть трудности в подборе зна чения dwSpinCount, по здесь нужно просто поэкспериментировать. Имейте в виду, что для критической секции, стоящей па страже динамической кучи Вашего процесса, этот счетчик равен 4000.

Как реализовать критические секции с применением спин-блокировки, я покажу в главе 10.

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

Вероятность того, что lnitializeCriticalSection потерпит неудачу, крайне мала, но все же существует. В свое время Microsoft не учла этого при разработке функции и опреде лила ее возвращаемое значение как VOID, т. e. она ничего не возвращает. Однако функция может потерпеть неудачу, так как выделяет блок памяти для внутрисистем ной отладочной информации. Если выделить память не удается, генерируется исклю чение STATUS_NO_MEMORY. Вы можете перехватить его, используя структурную об работку исключений (см. главы 23, 24 и 25).

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

В работе с критическими секциями может возникнуть ещс одна проблема. Когда за доступ к критической секции конкурирует два и более потоков, она использует объект ядра "событие" (Я покажу, как работать с этим объектом при описании C++ класса COptex в главе 10.) Поскольку такая конкуренция маловероятна, система не создает объект ядра «событие" до тех пор, пока он действительно не потребуется. Это экономит массу системных ресурсов — в большинстве критических секций конкурен ция потоков никогда не возникает.

Но если потоки все же будут конкурировать за критическую секцию в условиях нехватки памяти, система не сможет создать нужный объект ядра И тогда Enter CriticalSection возбудит исключение EXCEPTION_INVALID_HANDLE. Большинство раз работчиков просто игнорирует вероятность такой ошибки и не предусматривает для нее никакой обработки, поскольку она случается действительно очень редко Но если Вы хотите заранее подготовиться к такой ситуации, у Вас есть две возможности.v

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

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

новленным старшим битом Тогда функция создает объект «событие" и сопоставляет его с критической секцией. Если создать объект не удается, она возвращает FALSE, и это позволяет корректнее обрабатывать такие ситуации. Но успешно созданный объ ект ядра «событие" гарантирует Вам, что EnterCriticalSection выполнит свою задачу при любых обстоятельствах и никогда не вызовет исключение. (Всегда выделяя память под объекты ядра «событие», Вы неэкономно расходуете системные ресурсы. Поэтому делать так следует лишь в нескольких случаях, а именно: если программа может рух нуть из-за неудачного завершения функции EnterCriticatlSection, если Вы уверены в конкуренции потоков при обращении к критической секции или если программа будет работать в условиях нехватки памяти.)

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

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

На каждый разделяемый ресурс используйте отдельную структуру CRITICAL_SECTION

Если в Вашей программе имеется несколько независимых структур данных, создавайте для каждой из них отдельный экземпляр структуры CRITICAL_SECTION, Это лучше, чем защищать все разделяемые ресурсы одной критической секцией. Посмотрите на этот фрагмент кода:

int g_nNums[100]; // один разделяемый ресурс

TCHAR g_cChars[100]; // Другой разделяемый ресурс

CRITICAL_SECTION g_cs, // защищает оба ресурса

DWORD WINAPI ThreadFunc(PVOID pvParam)
{ EnterCriticalSection(&g_cs);
for (int x = 0; x < 100: x++)
{
g_nNums[x] = 0;
g_cChars|x] - TEXT('X');
}

LeaveCriticalSection(&g_cs);
return(0);
}

Здесь создана единственная критическая секция, защищающая оба массива — g_nNums и g_cChars — в период их инициализации. Но эти массивы совершенно раз личны. И при выполнении данного цикла ни один из потоков нс получит доступ ни к одному массиву. Теперь посмотрим, что будет, если ThreadFunc реализовать так:

DWORD WINAPI ThreadFunc(PVOID pvParam)
{
EnterCriticalSection(&g_cs);
for (int x = 0; x < 100; x++)

g_nNums[x] = 0;
for (x = 0; x < 100; x++)

g_cChars[x] = TEXT('X');
LeaveCriticalSection(&g_cs);
return(0);
}

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

int g_nNum[100]; // разделяемый ресурс

CRITICAL_SECTION g_csNums; // защищает g_nNums

TCHAR g_cChars[100]; // другой разделяемый ресурс

CRITICAL_SECTION g_csChars; // защищает g_cChars

DWORD WTNAPT ThreadFunc(PVOTD pvParam)
{
EnterCriticalSection(&g_csNums);
for (int x = 0; x < 100; x++)
g_nNums[x] = 0;
LeaveCriticalSection(&g_csNums);
EnterCriticalSection(&g_csChars);
for (x = 0; x < 100; x++)
g_cChars[x] = TEXT('X');
LeaveCriticalSection(&g_ csChars);
return(0);
}

Теперь другой поток сможет работать с массивом g_nNums, как только ThreadFunc закончит его инициализацию. Можно сделать и так, чтобы один поток инициализи ровал массив g_nNums, я другой — gcChars.

Одновременный доступ к нескольким ресурсам

Иногда нужен одновременный доступ сразу к двум структурам данных. Тогда Thread Func следует реализовать так:

DWORD WINAPI ThreadFunc(PVOID pvParam)
{

EnterCriticalSection(&g_csNums);

EnterCriticalSection(&g_csChars);

// в этом цикле нужен одновременный доступ к обоим ресурсам

for (int x = 0; x < 100; x++)
g_nNums[x] = g_cChars[x];

LeaveCriticalSection(&g_csChars);

LeaveCrilicalSection(&g_csNums};

return(0);
}

Предположим, доступ к обоим массивам требуется и другому потоку в данном процессе; при этом его функция написана следующим образом:

DWORD WINAPI OtherThreadFunc(PVOID pvParam)
{
EnterCriticalSection(&g_csChars);
EnterCriticalSection(&g_csNums);

for (int x = 0; x < 100; x++)
g_nNums[x] = g_cChars[x];
LeaveCriticalSection(&g_csNums);
LeaveCriticalSection(&g_csChars);
return(0);

}

Я лишь поменял порядок вызовов EnterCriticalSection и LeaveCriticalSection, Но из за того, что функции ThreadFunc и OtherThreadFunc написаны именно так, существу

ет вероятность взаимной блокировки (deadlock) Допустим, ThreadFunc начинает ис полнение и занимает критическую секцию g_csNums Получив от системы процессор ное время, поток с функцией OtherThreadFunc захватывает критическую секцию g_csChars Тут-то и происходит взаимная блокировка потоков Какая бы из функций — ThreadFunc или OtherThreadFunc — ни пыталась продолжить исполнение, она не су меет занять другую, необходимую ей критическую секцию

Эту ситуацию легко исправить, написав код обеих функций так, чтобы они вызы вали EnterCriticalSection в одинаковом порядке Заметьте, что порядок вызовов Leave CrititalSection несуществен, поскольку эта функция никогда не приостанавливает поток

Не занимайте критические секции надолго

Надолго занимая критическую секцию, Ваше приложение может блокировать другие потоки, что отрицательно скажется на его общей производительности Вот прием, позволяющий свести к минимуму время пребывания в критической секции Гледую щий код нс даст другому потоку изменять значение в g_s до тех пор, пока в окно не будет отправлено сообщение WM_SOMEMSG

SOMESTRUCT g, s;
CRITICAL_SECTION g_cs;

DWORD WINAPI SomeThread(PVOID pvParam)
{
EnterCriticalSection(&g_cs);

// посылаем в окно сообщение
SendMessage(hwndSomeWnd, WM_SOMEMSG, &g_s, 0);
LeaveCriticalSection(&g_cs);
return(0);
}

Трудно сказать, сколько времени уйдет на обработку WM_SOMEMSG оконной про цедурой — может, несколько миллисекунд, а может, и несколько лет В течение этого времени никакой другой поток не получит доступ к структуре g_s Поэтому лучше составить код иначе

SOMESTRUCT g_s;
CRITICAL_SECTION g_cs;

DWORO WINAPI SomeThread(PVOID pvParam)
{
EnterCriticalSection(&g_cs);
SOMESTRUCT sTemp = g_s;
LeaveCriticalSection(&g_cs);

// посылаем в окно сообщение
SendMessage(hwndSompWnd, WM_SOMEMSG, &sTemp, 0);
return(0);
}

Этот код сохраняет значение элемента g_t, во временной переменной sTemp Не трудно догадаться, что на исполнение этой строки уходит всего несколько тактов процессора Далее программа сразу вызывает LeaveCriticalSection — защищать глобаль ную структуру больше не нужно Так что вторая версия программы намного лучше первой, посколькудругие потоки «отлучаются» от структуры g_s лишь на несколько таков процессора, а не на неопределенно долгое время Такой подход предполагает, что «моментальный снимок» структуры вполне пригоден для чтения оконной проце дурой, а также что оконная процедура не будет изменять элементы этой структуры