
Роббинс Д. - Отладка приложений для Microsoft .NET и Microsoft Windows - 2004
.pdfГЛАВА 15 Блокировка в многопоточных приложениях |
533 |
|
|
ли один противоречащий интуиции, хотя и не новый в области компьютинга факт: иногда гораздо выгоднее не выполнять операцию на самом деле, а просто подож дать. Помните, когда мы только начинали, нам говорили никогда не ждать? Но в случае критических секций именно это и следует делать.
Обычно критические секции применяются для защиты небольших данных. Выше я говорил, что критическая секция защищается семафором и переключе ние в режим ядра для ее получения очень накладно. В первоначальном варианте функция EnterCriticalSection просто узнавала, можно ли получить критическую секцию. Если нет, EnterCriticalSection переключалась в режим ядра. Как правило, к тому моменту, когда поток успевает переключиться в режим ядра и обратно, ока зывается, что другой поток уже освободил критическую секцию миллион компь ютерных лет назад. Странный вывод сотрудников Microsoft заключался в том, что при работе на многопроцессорных системах надо проверять, доступна ли кри тическая секция, и, если нет, переходить в состояние спин блокировки и ждать, а потом проверять ее доступность снова. Очевидно, что на однопроцессорных си стемах счетчик циклов спин блокировки игнорируется. Если критическая секция недоступна и после второй проверки, выполняется переключение в режим ядра. Суть сказанного в том, что удержание потока в пользовательском режиме в пас сивном состоянии все же много выгоднее, чем переключение в режим ядра.
Для присвоения значения счетчику циклов спин блокировки критической сек ции служат две функции: InitializeCriticalSectionAndSpinCount, которую следует ис пользовать вместо InitializeCriticalSection, и SetCriticalSectionSpinCount, позво ляющая изменить первоначальное значение вашего счетчика или значение счет чика библиотечного кода, использующего только InitializeCriticalSection. Разу меется, для этого вам понадобится доступ к указателю на критическую секцию из своего кода.
Подобрать значение счетчика спин блокировки может оказаться нелегко. Если у вас есть две три недели для проработки всех сценариев, займите этим начина ющих программистов — они все равно бездельничают. Однако большинству из нас не так везет. Я всегда инициализирую этот счетчик значением 4000. Именно оно используется в Microsoft для куч ОС, и я всегда находил свой код менее тре бовательным, чтобы уменьшать это число. С другой стороны, оно достаточно ве лико, чтобы почти всегда удерживать код в пользовательском режиме.
Не используйте функции CreateThread/ExitThread
Одна из самых коварных ошибок, допускаемых при разработке многопоточных приложений, связана с функцией CreateThread. Конечно, возникает вопрос: если потоки нельзя создавать при помощи CreateThread, как же их вообще создавать? Вместо CreateThread следует всегда использовать _beginthreadex, функцию создания потоков из стандартной библиотеки C. Как вы уже догадались, раз уж CreateThread дополняет функция ExitThread для завершения потока, _beginthreadex тоже имеет соответствующую функцию _exitthreadex, которую также нужно использовать вместо
ExitThread.
Возможно, вы вызываете CreateThread в своей программе и не испытываете проблем. Увы, при этом возможны очень тонкие ошибки, потому что при исполь зовании CreateThread не инициализируется стандартная библиотека C. Работа стан

534 ЧАСТЬ IV Мощные средства и методы отладки неуправляемого кода
дартной библиотеки C основана на некоторых данных, отдельных для каждого потока, и определенные ее функции были разработаны до того, как стали нор мой высокопроизводительные многопоточные приложения. Так, функция strtok хранит обрабатываемую строку в памяти отдельного потока. Функция _beginthreadex гарантирует наличие данных, отдельных для потоков, а также всех остальных вещей, нужных стандартной библиотеке C. Для гарантии правильной очистки потока вызывайте _exitthreadex, которая правильно освобождает ресурсы стандартной библиотеки C, если вам нужно преждевременно завершить поток.
_beginthreadex работает так же и принимает те же параметры, что и CreateThread. Поток завершается возвратом из функции потока или вызовом _endthreadex. Для преждевременного завершения потоков служит _endthreadex. Как и CreateThread, _beginthreadex возвращает описатель потока, который нужно затем передать Close Handle, чтобы избежать утечки описателей.
В документации к _beginthreadex вы увидите функцию стандартной библиоте ки C по имени _beginthread. Избегайте ее, как чумы, потому что, по моему, ее по ведение по умолчанию просто ошибочно. Описатель, возвращаемый _beginthread, кэшируется, поэтому при быстром завершении потока и его перезаписи другим потоком описатель может оказаться неверным. Даже в документации к _beginthread указано, что безопаснее использовать _beginthreadex. При обзоре кода отметьте все вызовы _beginthread и _endthread, чтобы изменить их затем на _beginthreadex и _endthreadex соответственно.
Опасайтесь диспетчера памяти по умолчанию
Одна из компаний хотела сделать серверное приложение максимально быстрым. Когда программисты обнаружили, что увеличение числа потоков, которое, по их мнению, должно было обеспечивать масштабируемость вычислительной мощно сти, не возымело эффекта, они обратились к нам. Одна из первых вещей, кото рые я сделал, заключалась в остановке программы в отладчике и изучении распо ложения каждого потока при помощи окна Threads (потоки).
Приложение интенсивно работало с библиотекой STL, которая, как я говорил при обсуждении WinDBG в главе 8, сама по себе может ухудшать быстродействие, выделяя огромные объемы памяти. Остановив серверное приложение, я хотел увидеть, какие потоки находились в системе управления памятью стандартной библиотеки C. У всех нас есть исходный код управления памятью (ведь вы уста навливаете исходный код стандартной библиотеки C при каждой установке Micro soft Visual Studio, да?), и я увидел, что всю систему управления памятью защищает одна критическая секция. Это всегда пугало меня, так как мне кажется, что это может приводить к проблемам с производительностью. Но когда я взглянул на клиентс кое приложение, то просто пришел в ужас: 38 из 50 потоков были заблокирова ны на критической секции системы управления памятью стандартной библиоте ки C! Большая часть программы находилась в состоянии ожидания, ничего не делая! Стоит ли говорить, что это не вызвало у программистов особой радости.
Для большинства программ поставляемая Microsoft стандартная библиотека C подходит прекрасно, не вызывая проблем с памятью. Однако в более крупных серверных приложениях одна единственная критическая секция может все испор тить. Итак, прежде всего я хочу порекомендовать вам всегда тщательно обдумы
ГЛАВА 15 Блокировка в многопоточных приложениях |
535 |
|
|
вать использование STL и, если избежать этого не удается, обратите внимание на STLPort версии I (см. главу 2). Ранее я уже указывал на многие проблемы с STL. В контексте крупных многопоточных приложений библиотека STL от Microsoft может приводить к появлению узких мест.
Более серьезная проблема: что делать с единственной критической секцией стандартной библиотеки C? Для ее решения нужно предоставить каждому потоку отдельную кучу, а не использовать единственную глобальную кучу для всех пото ков. Это позволило бы потокам никогда не переключаться в режим ядра для вы деления или освобождения памяти. Конечно, создания отдельной кучи для каж дого потока недостаточно, так как порой память выделяется в одном потоке и освобождается в другом. К счастью, эта головоломка имеет три решения.
Первое — коммерческие системы управления памятью, обрабатывающие код работы с кучами отдельных потоков. Жаль, но цены на такие системы просто грабительские, и ваш начальник никогда не согласится на покупку. Второе реше ние обеспечивает значительное повышение производительности Windows 2000
иосновано на усовершенствованиях, внесенных Microsoft в механизм работы куч ОС (куч, создаваемых функцией HeapCreate и используемых при помощи HeapAlloc
иHeapFree). Чтобы задействовать преимущества кучи ОС, можно заменить все выделения памяти при помощи malloc/free соответствующими функциями Heap*. Что до функций new и delete языка C++, то для их замены нужно предоставить глобальные функции. Если ваша программа будет выполняться на многопроцес сорных системах, то третье решение может заключаться в использовании вели колепной библиотеки Hoard, написанной Эмери Бергером (Emery Berger) и пред назначенной для управления памятью многопроцессорных компьютеров (http:// www.hoard.org). Эта библиотека заменяет функции работы с памятью C и C++ и очень быстро работает на многопроцессорных системах. Если из за дублирова ния символов у вас возникнут проблемы с ее компоновкой, укажите компонов щику LINK.EXE ключ командной строки /FORCE:MULTIPLE. Помните, что Hoard пред назначена для многопроцессорных систем, поэтому на однопроцессорных ком пьютерах она может работать даже медленнее, чем диспетчер памяти по умол чанию.
Получайте дампы в реальных условиях
Один из наиболее огорчительных случаев имеет место, когда ваша программа блокируется в реальных условиях и, несмотря на все усилия, вы не можете вос произвести ошибку. Однако, благодаря последним усовершенствованиям библио теки DBGHELP.DLL, вы больше никогда не окажетесь в такой ситуации. Новые фун кции работы с минидампами позволяют сделать снимок блокировки и отладить ее в удобное для вас время. Функцию записи минидампа и мою улучшенную обо лочку для нее, SnapCurrentProcessMiniDump, находящуюся в библиотеке BUGSLAYER UTIL.DLL, я описал в главе 13.
Чтобы получить дамп в реальных условиях, нужно просто создать фоновый по ток, который создает и ожидает некоторое событие. При возникновении собы тия поток должен вызывать SnapCurrentProcessMiniDump и записывать дамп на диск. Соответствующая функция показана в следующем фрагменте псевдокода. Для ус

536 ЧАСТЬ IV Мощные средства и методы отладки неуправляемого кода
тановки события создайте отдельный исполняемый файл и скажите пользовате лям запускать его в нужной ситуации.
DWORD WINAPI DumperThread ( LPVOID ) |
|
{ |
|
HANDLE hEvents[2] ; |
|
hEvents[0] = CreateEvent ( NULL |
, |
TRUE |
, |
FALSE |
, |
_T ( |
"DumperThread" ) ) ; |
hEvents[1] = CreateEvent ( NULL |
, |
TRUE |
, |
FALSE |
, |
_T ( |
"KillDumperThread" ) ) ; |
int iRet = WaitForMultipleObjects ( 2 , hEvents , FALSE , INFINITE); while ( iRet != 1 )
{
// Возможно, каждому файлу следует присваивать уникальное имя. SnapCurrentProcessMiniDump ( MiniDumpWithFullMemory ,
_T ( "Program.DMP" ) ) ;
iRet = WaitForMultipleObjects ( 2 , hEvents , FALSE , INFINITE);
}
VERIFY ( CloseHandle ( hEvents[ 0 ] ) ) ; VERIFY ( CloseHandle ( hEvents[ 1 ] ) ) ; return ( TRUE ) ;
}
Уделяйте особое внимание обзору кода
Если вам на самом деле нужно включить в свое приложение многопоточные фраг менты, им нужно уделять повышенное внимание во время обзоров кода. При этом я советую назначать по одному человеку на каждый поток и каждый объект син хронизации. Обзор кода многопоточных приложений «многопоточен» во многих отношениях.
При обзоре кода представьте, что каждый поток выполняется с приоритетом реального времени на собственном процессоре, никогда не прерываясь. Просмат ривая код, каждый «наблюдатель за потоком» уделяет внимание только тем учас ткам, которые выполняются его потоком. Когда «наблюдатель за потоком» полу чает объект синхронизации, к нему подходит «наблюдатель за этим объектом». При освобождении объекта синхронизации «наблюдатель за объектом» уходит в нейтральный угол комнаты. Помимо представителей потоков и объектов, надо назначить нескольких программистов, наблюдающих за общей активностью по токов. Они должны оценивать общий ход выполнения программы и помогать искать места блокировки потоков.
Выполняя обзор кода, помните, что ваш процесс работает и с объектами син хронизации ОС, которые также могут привести к блокировке. В качестве приме ров таких объектов можно привести критическую секцию процесса, описывае




540 ЧАСТЬ IV Мощные средства и методы отладки неуправляемого кода
инициализации вторым потоком совместно используемых объектов. Так как второй поток был заблокирован на критической секции процесса, удержи ваемой первым потоком, а первый поток блокировался в ожидании второ го потока, результатом была обычная взаимоблокировка.
Полученный опыт
Урок очевиден: чтобы избежать блокировки, связанной с объектами ядра, не вызывайте внутри DllMain функции Wait* или EnterCriticalSection, пото му что критическая секция процесса блокирует остальные потоки. Как вы смогли убедиться, даже опытные программисты ошибаются в многопоточ ных программах, так что еще раз: проблемы подобного типа часто проис ходят там, где вы их ожидаете меньше всего.
Требования к DeadlockDetection
Вероятно, вы заметили, что выше я привел мало рекомендаций по поводу исправ ления блокировок. Большинство советов было профилактическими мерами и касались предотвращения блокировок, а не их разрешения. Всем известно, что исправить блокировки, используя отладчик, непросто. В этом разделе я предос тавлю вам дополнительную помощь — утилиту DeadlockDetection.
Вот основные требования, которыми я руководствовался при ее разработке.
1.Указание точного места блокировки в пользовательском коде. От утилиты, которая только сообщает, что вызов EnterCriticalSection заблокирован, толку мало. Эффективное средство должно указывать адрес (а значит, и исходный файл и номер строки) блокировки, чтобы можно было быстро ее исправить.
2.Отображение объекта синхронизации, вызвавшего блокировку.
3.Вывод информации о заблокированной функции Windows и переданных в нее параметрах. Это позволило бы узнать значения тайм аута и значения, передан ные в функцию.
4.Определение потока, вызвавшего блокировку.
5.Утилита должна быть «легкой», чтобы как можно меньше влиять на пользова тельскую программу.
6.Обработка выводимой информации должна быть расширяемой. Утилита дол жна поддерживать разные способы обработки информации, собранной в си стеме обнаружения блокировок, и давать возможность настройки и расшире ния вывода информации не только вам, но и другим программистам.
7.Средство должно обеспечивать легкую интеграцию с пользовательскими про граммами.
Работая с утилитами, подобными DeadlockDetection, следует помнить, что они неизбежно влияют на поведение исследуемого приложения. Можно рассматри вать это как еще одно наглядное подтверждение принципа неопределенности Гейзенберга. DeadlockDetection сама может вызывать в ваших программах блоки ровки, которые вы иначе не обнаружили бы, потому что выполняемая ею работа

ГЛАВА 15 Блокировка в многопоточных приложениях |
541 |
|
|
по сбору информации тормозит потоки. Я склонен считать это поведение одной из особенностей утилиты, потому что любая возможность блокировки в вашем коде указывает на ошибку, что является первым шагом к ее исправлению. Ошиб ки лучше всегда находить самому, чем оставлять такую радость своим клиентам.
Общие вопросы разработки DeadlockDetection
Чтобы DeadlockDetection удовлетворяла названным требованиям, я должен был ответить на ряд вопросов. Сначала я должен был определить, какие функции нужно отслеживать для воспроизведения полной истории блокировки (табл. 15 1).
Табл. 15-1. Функции, отслеживаемые утилитой DeadlockDetection
Тип |
Функции |
Функции работы с потоками |
CreateThread, ExitThread, SuspendThread, ResumeThread, |
|
TerminateThread, _beginthreadex, _beginthread, |
|
_exitthreadex, _exitthread, FreeLibraryAndExitThread |
Функции работы |
InitializeCriticalSection, |
с критическими секциями |
InitializeCriticalSectionAndSpinCount, |
|
DeleteCriticalSection, EnterCriticalSection, |
|
LeaveCriticalSection, SetCriticalSectionSpinCount, |
|
TryEnterCriticalSection |
Функции работы с мьютексами |
CreateMutexA, CreateMutexW, OpenMutexA, OpenMutexW, |
|
ReleaseMutex |
Функции работы с семафорами |
CreateSemaphoreA, CreateSemaphoreW, OpenSemaphoreA, |
|
OpenSemaphoreW, ReleaseSemaphore |
Функции работы с событиями |
CreateEventA, CreateEventW, OpenEventA, OpenEventW, |
|
PulseEvent, ResetEvent, SetEvent |
Функции блокировки |
WaitForSingleObject, WaitForSingleObjectEx, |
|
WaitForMultipleObjects, WaitForMultipleObjectsEx, |
|
MsgWaitForMultipleObjects, MsgWaitForMultipleObjectsEx, |
|
SignalObjectAndWait |
Специальные функции |
CloseHandle, ExitProcess, GetProcAddress, LoadLibraryA, |
|
LoadLibraryW, LoadLibraryExA, LoadLibraryExW, FreeLibrary |
|
|
Обдумав проблему сбора информации, необходимой для удовлетворения пер вых четырех требований, я понял, что мне нужно перехватывать функции из табл. 15 1 (устанавливать для них ловушки), регистрируя получение и освобождение объектов синхронизации. Перехват — нетривиальная задача; ее решение я рас смотрю в разделе «Перехват импортируемых функций». Для перехвата импорти руемых функций код DeadlockDetection должен находиться в DLL, потому что ловушки работают только в том адресном пространстве, в котором создаются. Это значит, что пользователь должен загружать DLL утилиты DeadlockDetection в свое адресное пространство. Данное требование не такое уж и жесткое, если учесть все его достоинства. Реализованная в форме DLL, утилита допускала бы легую интег рацию с пользовательской программой, что позволило бы удовлетворить требо вание 7.
Вы могли заметить, что я не включил в табл. 15 1 некоторые функции работы с сообщениями, способные вызывать блокировку, такие как SendMessage, PostMessage

542 ЧАСТЬ IV Мощные средства и методы отладки неуправляемого кода
и WaitMessage. Сначала я намеревался реализовать и их поддержку, однако когда я запустил под управлением DeadlockDetection классическую программу Чарльза Петцольда (Charles Petzold) «Hello World!» с графическим пользовательским ин терфейсом, DeadlockDetection сообщила столько вызовов, что работа программы
вконечном счете была нарушена. Чтобы сделать DeadlockDetection как можно компактнее и быстрее, мне пришлось отказаться от этих функций.
Решение проблемы сбора информации, удовлетворяющей требованиям 1–4, прямо вытекает из выбранного мной подхода внутрипроцессного перехвата фун кций. Это значит, что при любом вызове функций работы с потоками и функций синхронизации управление будет передаваться в код DeadlockDetection со всей нужной мне информацией.
Сделать DeadlockDetection максимально компактной и быстрой (требование 5) оказалось довольно трудно. Я старался, чтобы код был как можно более эффек тивным, однако в связи с заданными мной целями при этом возникли трудности. Так как вам лучше известно, какие типы объектов синхронизации вы используете
всвоей программе, я решил сгруппировать их, чтобы вы могли указать именно те функции, которые хотите перехватывать. Скажем, если вас интересует только блокировка на мьютексах, вы можете обрабатывать только функции работы с мьютексами.
Япозволяю во время выполнения указывать, какие наборы функций работы с объектами синхронизации вы хотите отслеживать. Кроме того, вы можете вклю чать/отключать DeadlockDetection любое число раз. Вы даже можете назначить своей программе сочетание клавиш (accelerator) или специальный пункт меню, который включает/выключает всю систему DeadlockDetection. Такое ограничение области и времени действия необходимо для соответствия требованию 5 и по могает удовлетворить требованию 7.
После этого мне осталось разобраться только с требованием 6: обеспечить максимальную расширяемость обработки выводимой информации. Я хотел пре доставить вам широкие возможности конфигурирования параметров вывода, а не навязывать какой либо жестко закодированный формат. Отделив перехват функ ций и основную логику программы от кода вывода, я смог улучшить возможность повторного использования кода, потому что разработать только новый модуль вывода гораздо проще, чем переписывать ядро программы. Я назвал модули вы вода расширениями DeadlockDetection или, сокращенно, DeadDetExt. DeadDetExt — это просто DLL, которые экспортируют несколько функций, вызываемых Dead lockDetection в случае необходимости.
Что ж, пришло время описать работу с DeadlockDetection.
Использование DeadlockDetection
Перед использованием DeadlockDetection нужно разместить в одном месте DEAD LOCKDETECTION.DLL, ее файл инициализации и нужную библиотеку DeadDetExt. Файл инициализации — это простой файл INI, в котором должно быть указано хотя бы имя загружаемого файла DeadDetExt. Например, файл DEADLOCKDETEC TION.INI, который загружает поставляемую вместе с утилитой библиотеку TEXT FILEDDEXT.DLL, содержит следующую информацию: