Резервирование региона с одновременной передачей физической памяти
Иногда нужно одновременно зарезервировать регион и передать ему физическую память. В таком случае VirtualAlloc можно вызвать следующим образом:
PVOID pvMem = VirtualAlloc(NULL, 99 * 1024, MEM_PESERVE | MFM_COMMIT, PAGE_READWRITE);1}
Этот вызов содержит запрос на выделение региона размером 99 Кб и передачу ему 99 Кб физической памяти. Обрабатывая этот запрос, система сначала просматривает адресное пространство Вашего процесса, пытаясь найти непрерывную незарезервированную область размером не менее 100 Кб (на машинах с 4-килобайювыми страницами) или 104 Кб (на машинах с 8-килобайтовыми страницами).
Система просматривает адресное пространство потому, что в pvAddress указан NULL. Если бы он содержал конкретный адрес памяти, система проверила бы только его — подходит ли по размеру расположенное за ним адресное пространство. Окажись он недостаточным, функция VirtualAlloc вернула бы NULL.
Если системе удается зарезервировать подходящий регион, она передает ему физическую память. И регион, и переданная память получают один атрибут защиты — в данном случае PAGE_READWRITE.
Наконец, функция VirtuaLAlloc возвращает виртуальный адрес этого региона, который потом записывается в переменную pvMem. Если же система не найдет в адресном пространстве подходящую область или не сумеет передать ей физическую память, VirtualAlloc вернет NULL.
Конечно, при резервировании региона с одновременной передачей ему памяти можно указать в парметре pvAddress конкретный адрес или запросить систему подобрать свободное место в верхней части адресного пространства процесса. Последнее реализуют так- в параметр pvAddress заносят NULL, a значение параметра fdwAllocationType комбинируют с флагом MEM_TOP_DOWN.
Резервирование региона в адресном пространстве
Для этого предназначена функция VirtualAlloc:
PVOID VirtualAlloc( PVOID pvAddress, SIZE_T dwSize, DWORD fdwAllocationType, DWORD fdwProtecf);
В первом параметров, pvAddress, содержится адрес памяти, указывающий, где именно система должна зарезервировать адресное пространство. Обычно в этом параметре передают NULL, тем самым сообщая функции VirtualAlloc, что система, ведущая учет свободных областей, должна зарезервировать регион там, где, по ее мнению, будет лучше. Поэтому нет никаких гарантий, что система станет резервировать регионы, начиная с нижних адресов или, нaoбopoт, с верхних. Однако с помощью флага MEM_ TOP_DOWN (о нем речь впереди) Вы можете сказать свое веское слово.
Для большинства программистов возможность выбора конкретного адреса резервируемого региона — нечто совершенно новое. Вспомните, как это делалось раньше операционная система просто находила подходящий по размеру блок памяти, выделяла этот блок и возвращала его адрес. Но поскольку каждый процесс владеет собственным адресным пространством, у Вас появляется возможность указывать операционной системе желательный базовый адрес резервируемого региона.
Допустим, нужно выделить регион, начиная с "отметки" 50 Мб в адресном пространстве процесса. Тогда параметр pvAdress должен быть равен 52 428 800 (50 * 1024 * 1024). Если по этому адресу можно разместить регион требуемого размера, система зарезервирует его и вернет соответствующий адрес. Если же по этому адресу свободного пространства недостаточно или просто нет, система не удовлетворит запрос, и функция VirtualAlloc вернет NULL. Адрес, передаваемый pvAdress, должен укладываться в границы раздела пользовательского режима Вашего процесса, так как иначе VirtualAlloc потерпит неудачу и вернет NULL.
Как я уже говорил в главе 13, регионы всегда резервируются с учетом гранулярности выделения памяти (64 Кб для существующих реализаций Windows). Поэтому, если Вы попытаетесь зарезервировать регион по адресу 19668992 (300 x 65 536 + 8192), система округлит этот адрес до ближайшего меньшего числа, кратного 64 Кб, и на самом делс зарезервирует регион по адресу 19 660 800 (300 x 65 536).
Если VirtualAlloc в состоянии удовлетворить запрос, она возвращает базовый адрес зарезервированного региона. Если параметр pvAddress содержал конкретный адрес, функция возвращает этот адрес, округленный при необходимости до меньшей величины, кратной б4 Кб.
Второй параметр функции VirtualAlloc — dwSize - указывает размер резервируемого региона в байтах. Поскольку система резервирует регионы только порциями, кратными размеру страницы, используемой данным процессором, то попытка зарезервировать, скажем, 62 Кб даст регион размером 64 Кб (если размер страницы составляет 4, 8 или l6 Кб).
Третий параметр fdwAllocationType, сообщает системе, что именно Вы хотите сделать: зарезервировать регион или передать физическую память. (Такое разграничение необходимо, поскольку VirtualAlloc позволяет не только резервировать регионы, но и передавать им физическую память.) Поэтому, чтобы зарезервировать регион адресного пространства, в этом параметре нужно передать идентификатор MEM_RESERVE.
Если Вы хотите зарезервировать регион и не собираетесь освобождать его в ближяйшее время, попробуйте выделить его в диапазоне самых старших — насколько это возможно - адресов. Тогда регион не окажется где-нибудь в середине адресного пространства процесса, что позволит не допустить вполне вероятной фрагментации этого пространства. Чтобы зарезервировать регион по самым старшим адресам, при вызове функции VirtualAlloc в параметре pvAddress передайте NULL, а в параметре fdwAlloc - cationType - флаг MEM_RESERVE, скомбинированный с флагом MEM_TOP_DOWN.
NOTE:
В Windows 98 флаг MEM_TOP_DOWN игнорируется.
Последний параметр fdwProtect, указывает атрибут защиты, присваиваемый региону Заметьте, что атрибут защиты, связанный с регионом, не влияет на переданную память, отображаемую на этот регион. Но если ему не передана физическая память, то — какой бы атрибут защиты у него ни был — любая попытка обращения по одному из адресов в этом диапазоне приведет к нарушению доступа для данного потока.
Резервируя регион, присваивайте ему тот атрибут защиты, который будет чаще всего использоваться с памятью, передаваемой региону.
Скажем, если Вы собираетесь передать региону физическую память с атрибутом защиты PAGEREADWRITE (этот атрибут самый распространенный), то и резервировать его следует с тем же атрибутом. Система работает эффективнее, когда атрибут защиты региона совпадает с ат рибутом защиты передаваемой памяти.
Вы можете использовать любой из следующих атрибутов защиты: PAGE_NOACCESS, PAGE_READWRITE, PAGE_READONLY, PAGE_EXECUTE, PAGE_EXECUTE_READ или PAGE_ EXECUTE_READWRITE. Но указывать атрибуты PAGE_WRITECOPY или PAGE_EXECUTE_WRITECOPY нельзя: иначе функция VirtualAtloc не зарезервирует регион и вернет NULL. Кроме того, при резервировании региона флаги PAGE_GUARD, PAGE_WRITECOMBINE или PAGE_NOCACHE применять тоже нельзя — они присваиваются только передаваемой памяти.
NOTE:
Windows 98 поддерживаетлишь атрибугы защиты PAGE_NOACCESS, PAGE_READONLY и PAGE_READWRITE Попытка резервирования региона с атрибутом PAGE_EXECUTE или PAGE_EXECUTE_READ дает регион с атрибутом PAGE_READONLY. А указав PAGE_EXECUTE_READWRITE, Вы получите регион с атрибутом PAGE_READWRITE.
Режим обработки ошибок
С каждым процессом связан набор флагов, сообщающих системе, каким образом про цесс должен реагировать на серьезные ошибки: повреждения дисковых носителей, необрабатываемые исключения, ошибки операций поиска файлов и неверное вырав нивание данных. Процесс может указать системе, как обрабатывать каждую из этих ошибок, через функцию SetErrorMode\
UINT SetErrorMode(UINT fuErrorMode) ;
Параметр fuErrorMode — это набор флагов, комбинируемых побитовой операцией OR
Флаг |
Описание | ||
SEM FAILCRITICALERRORS |
Система не выводит окно с сообщением от обра ботчика критических ошибок и возвращает ошибку в вызывающий процесс | ||
SEM_NOGPFAULTERRORBOX |
Система не выводит окно с сообщением о наруше нии общей защиты; этим флагом манипулируют только отладчики, самостоятельно обрабатывающие нарушения общей защиты с помощью обработчика исключений | ||
SEM_NOOPENFILEERRORBOX |
Система не выводит окно с сообщением об отсут ствии искомого файла | ||
SEM_NOALIGNMENTFAULTEXCEPT |
Система автоматически исправляет нарушения в вы равнивании данных, и они становятся невидимы при ложению: этот флаг не действует на процессорах x8б |
По умолчанию дочерний процесс наследует от родительского флаги, указываю щие на режим обработки ошибок. Иначе говоря, если у процесса в данный момент установлен флаг SEM_NOGPFAULTERRORBOX и он порождает другой процесс, этот
флаг будет установлен и у дочернего процесса. Однако "наследник" об этом не уве домляется, и он вообще может быть нс рассчитан на обработку ошибок такого типа (к данном случае — нарушений общей защиты). В результате, если в одном из пото ков дочернего процесса все-таки произойдет подобная ошибка, этот процесс может завершиться, ничего не сообщив пользователю. Но родительский процесс способен предотвратить наследование дочерним процессом своего режима обработки ошибок, указав при вызове функции CreateProcess флаг CREATE_DEFAULT_ERROR_MODE (о CreateProcess чуть позже).
Сброс содержимого физической памяти
WINDOWS 98
Windows 98 не поддерживает сброс физической памяти.
Когда Вы модифицируете содержимое страниц физической памяти, система пытает ся как можно дольше хранить эти изменения в оперативной памяти. Однако, выпол няя приложения, система постоянно получает запросы на загрузку в оперативную память страниц из ЕХЕ-файлов, DLL и/или страничного файла. Любой такой запрос заставляет систему просматривать оперативную память и выгружать модифицирован ные страницы в страничный файл.
Windows 2000 позволяет программам увеличить свою производительность за счет сброса физической памяти. Вы сообщаете системе, что данные на одной или несколь ких страницах памяти не изменялись. Если система в процессе поиска свободной страницы в оперативной памяти выбирает измененную страницу, то должна сначала записать ее в страничный файл Эта операция отнимаетдовольно много времени и отрицательно сказывается на производительности. Поэтому в большинстве приложе ний желательно, чтобы система как можно дольше хранила модифицированные стра ницы в страничном файле.
Однако некоторые программы занимают блоки памяти на очень малое время, а потом им уже не требуется их содержимое Для большего быстродействия программа может попросить систему не записывать определенные страницы в страничный файл. И тогда, если одна из этих счраниц понадобится для других целей, системе не при дется сохранять ее в страничном файле, чтo, естественно, повысит скорость работы программы. Такой отказ от страницы (или страниц) памяти называется сбросам фи зической памяти (resetting of physical storage) и инициируется вызовом функции VirtualAlloc с передачей ей в третьем параметре флага MEM_RESET
Если страницы, па которые Вы ссылаетесь при вызове VirtualAlloc, находятся в стра ничном файле, система их удалит. Когда в следующий раз программа обратится к памяти, она получит новые страницы, инициализированные нулями Если же Вы сбра сываете страницу, находящуюся в оперативной памяти, система помечает ее как нс изменявшуюся, и она не записывается в страничный файл.
Но, хотя ее содержимое не обнуляется, читать такую страницу памяти уже пельзя Если системе не понадобится эта страница оператикной памяти, ее содержимое останется прежним. В ином слу чае система может забрать ее в свое распоряжение, и тогда обращение к этой стра нице приведет к тому, что система предоставит программе новую страницу, заполнен ную нулями. А поскольку этот процесс нам не подвластен, лучше считать, что после сброса страница содержит только мусор
При сбросе физической памяти надо учитывать и несколько других моментов. Во первых, когда Вы вызываете VtrtualAlloc, базовый адрес обычно округляется до бли жайшего меньшего значения, кратного размеру страниц, а количество байтов — до ближайшего большего значения, кратного той же величине. Такой механизм округ ления базового адреса и количества байтов был бы очень опасен при сбросе физи ческой памяти; поэтому VirtualAlloc при передаче ей флага MEMRESET округляет эти значения прямо наоборот.Допустим, в Вашей программе есть следующий исходный код:
PINT pnData = (PINT) VirtualAlloc(NULL, 1024, MEM_FlESERVE | MEM_COMMIT, PAGE_READWRITE);
pn[0] = 100;
pn[1] = 200;
VirtualAlloc((PVOID) pnData, sizeof(int), MEM_RESFT, PAGE_READWRITE);
Этот код передает одну страницу памяти, а затем сообщает, что первые четыре байта (sizeof(int)) больше не нужны и их можно сбросить. Однако, как и при любых других действиях с памятью, этa операция выполняется только над блоками памяти, размер которых кратен размеру страниц В данном случае вызов завершится неудач но (VirtualAlloc вернет NULL) Почему? Дело в том, что при вызове VirtualAlloc Вы ука зали флаг MEM_RESET и базовый адрес, переданный функции, теперь округляется до ближайшего большего значения, кратного размеру страниц, а количество байтов — до ближайшего меньшего значения, кратного той же величине Так делается, чтобы исключить случайную потерю важных данных В предыдущем примере округление количества байтов до ближайшего меньшего значения дает 0, а эта величина недо пустима.
Второе, о чем следует помнить при сбросе памяти, — флаг MEM_RESET нельзя комбинировать (логической операцией OR) ни с какими другими флагами. Следую щий вызов всегда будет заканчиваться неудачно:
PVOID pvMem = VirtualAlloc(NULL, 1024, MEM_RESERVE | MEM_COMMIT | MFM_RESET, PAGE_READWRITE);
Впрочем, комбинировать флаг MEM_RESET с другими флагами все равно бессмысленно
И, наконец, последнее. Вызов VirtualAlloc с флагом MEM_RESET требует передачи корректного атрибута защиты страницы, даже несмотря на то что он не будет исполь зоваться данной функцией.
Сценарий 1: асинхронный вызов функций
Допустим, у Вас есть серверный процесс с основным потоком, который ждет клиентский запрос. Получив его, он порождает отдельный поток для обработки этого запроса. Тем самым основной поток освобождается для приема следующего клиентского запроса. Такой сценарий типичен в клиент-серверных приложениях. Хотя он и так то незатейлив, при желании его можно реализовать с использованием новых функций пула потоков.
Получая клиентский запрос, основной поток вызывает:
BOOL QueueUserWorkItem( PTHREAD_START_ROUTINE pfnCallback, PVOID pvContext, ULONG dwFlags);
Эта функция помещает "рабочий элемент" (work item) в очередь потока в пуле и тут же возвращает управление. Рабочий элемент — это просто вызов функции (па которую ссылается параметр pfnCallback), принимающей единственный параметр, pvContext. В конечном счете какой-то поток из пула займется обработкой этого элемента, в результате чего будет вызвана Ваша функция. У этой функции обратного вызова, за реализацию которой отвечаете Вы, должен быть следующий прототип:
DWORD WINAPI WorkItemFunc(PVOID pvContext);
Несмотря па то, что тип возвращаемого значения определен как DWORD, на самом деле оно игнорируется.
Обратите внимание, что Вы сами никогда не вызываете CreateThread. Она вызывается из пула потоков, автоматически создаваемого для Вашего процесса, а к функции WorkItemFunc обращается один из потоков этого пула. Кроме того, данный поток не уничтожается сразу после обработки клиентского запроса, а возвращается в пул, оставаясь готовым к обработке любых других элементов, помещаемых в очередь. Ваше приложение может стать гораздо эффективнее, так как Вам больше не придется создавать и уничтожать потоки для каждого клиентского запроса. А поскольку потоки связаны с определенным портом завершения, количество одновременно работающих потоков не может превышать число процессоров более чем в 2 раза. За счет этого переключения контекста происходят реже.
Многое в пуле потоков происходит скрытно от разработчика: QueueUserWorkItem проверяет число потоков, включенных в сферу ответственности компонента поддержки других операций (нс относящихся к вводу-выводу), и в зависимости от текущей нагрузки (количества рабочих элементов в очереди) может передать емудругие потоки.
После этого QueueUserWorkltem выполняет операции, эквивалентные вызову PostQueuedCompletionStatus, пересылая информацию о рабочем элементе в порт за вершения ввода-вывода. В конечном счете поток, ждущий на этом объекте, извлекает Ваше сообщение (вызовом GetQueuedCompletionStatus) и обращается к Вашей функции. После того как она возвращает управление, поток вновь вызывает GetQueuedComplettonStatus, ожидая появления следующего рабочего элемента.
Пул рассчитан на частую обработку асинхронного ввода-вывода — всякий раз, когда поток помещает в очередь запрос на ввод-вывод к драйверу устройства. Пока драйвер выполняет его, поток, поставивший запрос в очередь, не блокируется и может заниматься другой работой. Асинхронный ввод-вывод — ключ к созданию высокоэффективных, масштабируемых приложений, так как позволяет одному потоку обрабатывать запросы от множества клиентов по мере их поступления; ему не приходится обрабатывать их последовательно или останавливаться, ожидая завершения ввода-вывода.
Но Windows накладывает одно ограничение на запросы асинхронного ввода-вывода, если поток, послав такой запрос драйверу устройства, завершается, данный запрос теряется н никакие потоки о его судьбе не уведомляются. В хорошо продуманном пуле, число потоков увеличивается и уменьшается в зависимости от потребностей его клиентов. Поэтому, если поток посылает запрос и уничтожается из-за сокращения пула, то уничтожается и этот запрос. Как правило, это не совсем то, что хотелось бы, и здесь нужно найти какое-то решение.
Если Вы хотите поместить в очередь рабочий элемент, который выдает запрос на асинхронный ввод-вывод, то не сможете передать этот элемент компоненту поддер жки других операций в пуле потоков. Его примет лишь компонент поддержки ввода вывода. Последний включает набор потоков, которые не завершаются, пока есть хотя бы один запрос на ввод-вывод; поэтому для выполнения кода, выдающего запросы на асинхронный ввод-вывод, Вы должны пользоваться только этими потоками.
Чтобы передать рабочий элемент компоненту поддержки ввода-вывода, Вы може те по-прежнему пользоваться функцией QueueUserWorkltem, но в параметре dwFlags
следует указать флаг WT_EXECUTEINIOTHREAD. А обычно Вы будете указывать в этом параметре флаг WT_EXECUTEDEFAULT (0) — он заставляет систему передать рабочий элемент компоненту поддержки других операций (не связанных с вводом-выводом).
В Windows есть функции (вроде RegNotifyChangeKeyValue), которые асинхронно выполняют операции, не относящиеся к вводу-выводу. Они также требуют, чтобы вызывающий поток не завершался преждевременно. С этой целью Вы можете исполь зовать флаг WT_EXECUTETNPERSISTENTTHREAD, который заставляет поток таймера выполнять поставленную в очередь функцию обратного вызова для рабочего элемен та. Так как этот компонент существует постоянно, асинхронная операция в конечном счете обязательно будет выполнена Вы должны позаботиться о том, чтобы функция обратного вызова выполнялась быстро и не блокировала работу компонента поддер жки таймера.
Хорошо продуманный пул должен также обеспечивать максимальную готовность потоков к обработке запросов. Если в пуле четыре потока, а в очереди сто рабочих элементов, то единовременно можно обработать только четыре элемента Это не проблема, если на обработку каждого элемента уходит лишь несколько миллисекунд, но в ином случае программа не сумеет своевременно обслуживать запросы.
Естественно, система не настолько умна, чтобы предвидеть, чем будет заниматься функция Вашего рабочего элемента, но если Вам заранее известно, что па это уйдет длительное время, вызовите QueueUserWorkltem с флагом WT_EXECUTELONGFUNC TION — он заставит пул создать новый поток, если остальные потоки будут в это вре мя заняты. Так, добавив в очередь 10 000 рабочих элементов (с флагом WT_EXECUTE LONGFUNCTION), Вы получите 10 000 новых потоков в пуле. Чтобы избежать этого, делайте перерывы между вызовами QueueUserWorkltem, и тогда часть потоков успсст завершиться до порождения новых.
Ограничение на количество потоков в пуле накладывать нельзя, иначе может воз никать взаимная блокировка потоков. Представьте очередь из 10 000 элементов, заб локированных 10 001-м и ждущих его освобождения.
Установив предел в 10 000, Вы запретите выполнение 10001-го потока, и в результате целых 10 000 потоков оста нутся навечно заблокированными.
Используя функции пула, будьте осторожны, чтобы нс доводить дело до тупико вых ситуаций. Особую осторожность проявляйте, если функция Вашего рабочего эле мента использует критические секции, семафоры, мьютексы и др. — это увеличивает вероятность взаимной блокировки. Вы должны всегда точно знать, поток какого ком понента пула выполняет Ваш код. Также будьте внимательны, если функция рабочего элемента содержится в DLL, которая может быть динамически выгружена из памяти. Поток, вызывающий функцию из выгруженной DLL, приведет к нарушению доступа. Чтобы предотвратить выгрузку DLL при наличии рабочих элементов в очереди, со здайте контрольный счетчик для таких элементов: его значение должно увеличиваться перед вызовом QueueUserWorkItem и уменьшаться после выполнения функции рабо чего элемента. Выгрузка DLL допустима только после того, как этот счетчик обиулится.
Сценарий 2: вызов функций через определенные интервалы времени
Иногда какие-то операции приходится выполнять чсрсз определенные промежутки времени. В Windows имеется объект ядра "ожидаемый таймер", который позволяет легко получатьуведомления по истечении заданного времени. Многие программис ты создают такой объект для каждой привязанной к определенному времени задаче, но это ошибочный путь, ведущий к пустой трате системных ресурсов. Вместо этого
Вы можете создать единственный ожидаемый таймер и каждый раз перенастраивать его на другое время ожидания. Однако такой код весьма непрост. К счастью, теперь эту работу можно поручить новым функциям пула потоков.
Чтобы какой-то рабочий элемент выполнялся через определенные интервалы времени, первым делом создайте очередь таймеров, вызвав функцию:
HANDLE CreateTimerQueue();
Очередь таймеров обеспечивает организацию набора таймеров. Представьте, что один исполняемый файл предоставляет несколько сервисов. Каждый сервис может потребовать создания таймеров, скажем, для определения того, какой клиент перестал отвечать, для сбора и обновления некоей статистической информации по расписа нию и т. д, Выделять каждому сервису ожидаемый таймер и отдельный поток крайне неэффективно. Вместо этого у каждого сервиса должна быть своя очередь таймеров (занимающая минимум системных ресурсов), а поток компонента поддержки тайме ра и объект ядра "ожидаемый таймер" должны разделяться всеми сервисами. По окон чании работы сервиса его очередь вместе со всеми созданными в ней таймерами просто удаляется.
Вы можете создавать таймеры в очереди, вызывая функцию:
ROOL CreateTimerQueueTimer( PHANDLE phNewTimer, HANDLE hTimerQueue, WAITORTIMERCALLBACK pfnCallback, PVOID pvContext, DWORD dwOueTime, DWORD dwPeriod, ULONG dwFlags);
Во втором параметре Вы передаете описатель очереди, в которую нужно помес тить новый таймер. Если таймеров немного, в зтом параметре можно передать NULL и вообще не вызывать CreateTimerQueue, Такое значение параметра заставит функцию CreateTimerQueueTimer использовать очередь по умолчанию и упростит программи рование.
Параметры pfnCallback и pvContext указывают на Вашу функцию обратного вызова и данные, передаваемые сй в момент срабатывания таймера. Параметр dwDue Time задает время первого срабатывания, a dwPeriod — время последующих срабаты ваний. (Передача в dwDueTime нулевого значения заставляет систему вызвать Вашу функцию по возможности немедленно, что делает функцию CreateTimerQueueTimer похожей на QueueUserWorkItem,) Если dwPeriod равен 0, таймер сработает лишь раз, и рабочий элемент будет помещен в очередь только единожды. Описатель нового тай мера возвращается в пареметре phNewTimer.
Прототип Вашей функции обратного вызова должен выглядеть так:
VOID WINAPI WaitOrTimerCallback( PVOTD pvContext, BOOL fTimerOrWaitFired);
Когда она вызывается, параметр fTimerOrWaitFired всегда принимает значение TRUE, сообщая тем самым, что таймер сработал.
Теперь поговорим о параметре dwFlags функции CreateTimerQueueTimer. Он сооб щает функции, как обрабатывать рабочий элемент, помещаемый в очередь. Вы може те указать флаг WT_EXECUTEDEFAULT, если хотите, чтобы рабочий элемент был об работан одним из потоков пула, контролируемых компонентом поддержки других операций, WT_EXECUTEINIOTHREAD — если в определенный момент нужно выдать
асинхронный запрос на ввод-вывод, или WT_EXECUTEINPERSISTENTTHREAD — если элементом должен заняться один из постоянных потоков. Для рабочего элемента, требующего длительного времени обработки, следует задать флаг WT_EXECUTELONG FUNCTION
Вы можете пользоваться еще одним флагом, WT_EXECUTEINTIMERTHREAD, кото рый нуждается в более подробном объяснении. Как видно из таблицы 11-1, пул пото ков включает компонент поддержки таймера Этот компонент создает единственный объект ядра "ожидаемый таймер", управляя временем его срабатывания, и всегда со стоит из одного потока Вызывая CreateTimerQueueTimer, Вы заставляете его пробу диться, добавить Ваш таймер в очередь и перенастроить объект ядра "ожидаемый таймер". После этого поток компонента поддержки таймера переходит в режим "тре вожного" ожидания АРС-вызова от таймера.
Обнаружив АРС-вызов в своей очереди, поток пробуждается, обновляет очередь таймеров, перенастраивает объект ядра "ожи даемый таймер", а зачем решает, что делать с рабочим элеменюм, который теперь следует обработать.
Далее поток проверяет наличие следующих флагов: WT_EXУCUTEDEFAULT, WT_EXE CUTEINTOTHREAD, WT_EXECUTEINPERSISTENTTHREAD, WT_EXECUTELONGFUNCTION и WT_EXECUTEINTIMERTHREAD И сейчас Вы, наверное, поняли, что делает флаг WT_EXECUTEINTIMERTHREAD: он заставляет поток компонента поддержки таймера обработать рабочий элемент. Хотя такой механизм обработки элемента более эффек тивен, он очень опасен! Пока выполняется функция рабочего элемента, поток ком понента поддержки таймера ничем другим заниматься не может Ожидаемый таймер будет по-прежнему ставить АРС-вызовы в соответствующую очередь потока, но эти рабочие элементы не удастся обработать до завершения текущей функции. Так что, поток компонента поддержки танмера годится для выполнения лишь "быстрого" кода, не блокирующего этот ресурс надолго.
Флаги WT_EXECUTEINIOTHREAD, WT_EXECUTEINPERSISTENTTHREAD и WT_EXE CUTEINTIMERTHREAD являются взаимоисключающими. Если Вы не перелаете ни один из этих флагов (или используете WT_EXECUTEDEFAULT), рабочий элемент помеща ется в очередь одною из потоков в компоненте поддержки других операций (не свя занныхс вводом-выводом), Kpоме того, WT_EXECUTELONGFUNCTION игнорируется, если задан флаг WT_EXECUTEINTIMERTHREAD.
Ненужный таймер удаляется с помощью функции.
BOOL DeleteTimerQueueTimer( HANDLE hTimerQueue, HANDLE hTimer, HANDLE hCompletionEvent);
Вы должны вызывать ее даже для "одноразовых" таймеров, если они уже сработа ли Параметр hTimerQueue указывает очередь, в которой находится таймер, a hTimer — удаляемый таймер, последний описатель возвращается CreateTtmetQuetteTtmer при создании таймера
Последний параметр, hCompletionEvent, определяет, каким образом Вас следует уведомлять об отсутствии необработанных рабочих элементов, поставленных в оче редь этим таймером.
Если в нем передать INVALID_HANDLE_VALUE, функция Delete TimerQueueTimer вернет управление только после обработки всех поставленных в очередь элементов. Задумайтесь, что это значит: удалив таймер в процессе обработ ки запущенного им рабочего элемента, Вы создаете тупиковую ситуацию, так? Вы ждете окончания его обработки и сами жс прерываете ее! Вот почему ноток может
удалить таймер, только если это не он обрабатывает рабочий элемент, поставленный в очередь данным таймером.
Кроме того, используя поток компонента поддержки таймера, никогда не удаляй те какой-либо из таймеров во избежание взаимной блокировки. Попытка удалить таймер приводит к тому, что в очередь этого потока помещается АРС-уведомление. Но если поток ждет удаления таймера, то сам удалить его он уже не в состоянии — вот и тупик.
Вместо значения INVALID_HANDLE_VALUE в параметре hCompletionEvent можни передать NULL. Это подскажет функции, что таймер следует удалить — и чем раньше, тем лучше. В таком случае DeleteTimerQueueTimer немедленно вернет управление, но Вы не узнаете, когда будут обработаны все элементы, поставленные в очередь этим таймером. И, наконец, в параметре hCompletionEvent можно передать описатель объек та ядра "событие". Тогда DeleteTimerQueueTimer немедленно вернет управление, а по ток компонента поддержки таймера освободит событие, как только будут обработа ны все элементы из очереди, Но прежде чем вызывать DeleteTimerQueueTimer, Вы дол жны позаботиться о том, чтобы это событие находилось в занятом состоянии, иначе Ваша программа ошибочно решит, что всс элементы уже обработаны.
Вы можете изменять время первого и последующих срабатываний существующе го таймера, используя функцию:
BOOL ChangeTimerQueueTimer( HANDLF hTimerQueue, HANDLE hTimer, ULONG dwDueTime, ULONG dwPeriod);
Ей передаются описатели очереди и самого таймера, который надо перенастро ить, а также параметры dwDueTime и dwPeriod (время срабатывания и периодичность). Учтите: эта функция нс влияет на уже сработавший "одноразовый" таймер.
Вы може те применять ее совершенно свободно, без всяких опасений насчет тупиковых ситу аций.
Для удаления очереди таймеров предназначена функция:
BOOL DeleteTimerQueueEx( HANDLE hTimerQueue, HANDLE hCompletionEvent);
Она принимает описатель существующей очереди и удаляет все таймеры в ней, избавляя от необходимости вызова DeleteTimerQueueTimer для каждого таймера по отдельности. Параметр hCompletionEvent идентичен такому же параметру функции DeleteTimerQueueTimer, а это значит, что, как и в предыдущем случае, Вы должны по мнить о возможности тупиковых ситуаций, — будьте осторожны.
Прежде чем рассматривать следующий вариант, позвольте обратить Ваше внима ние на несколько нюансов. Компонент поддержки таймера создает объект ядра "ожи даемый таймер", и тот посылает АРС-вызовы в очередь, а не переходит в свободное состояние. Иначе говоря, операционная система постоянно ставит АРС-вызовы в оче редь, и события таймера никогда не теряются. Такой механизм гарантирует, что на писанная Вами функция обратного вызова будет срабатывать с заданной периодич ностью. Только имейте в виду, что все это происходит с использованием множества потоков, а значит, какие-то части этой функции, видимо, потребуют синхронизации.
Если Вас это не устраивает и Вы хотите, чтобы новый вызов помещался в очередь, скажем, через 10 секунд после завершения обработки предыдущего, создавайте в кон це функции рабочего элемента однократно срабатывающие таймеры. Или единствен
ный таймер, ни с длительным временем ожидания, а в конце все той же функции вызывайте ChangeTimerQueueTimer для псренастройкитаймера
Сценарий 3: вызов функций при освобождении отдельных объектов ядра
Microsoft обнаружила, что во многих приложениях потоки порождаются только для того, чтобы ждать на тех или иных объектах ядра. Как только объект освобождается, поток посылает уведомление и снова переходит к ожиданию того же объекта. Неко торые разработчики умудряются писать программы так, что в них создается несколь ко потоков, ждущих один объект. Это невероятное расточительство системных ресур сов Конечно, издержки от создания потоков существенно меньше, чем от создания процессов, но и потоки не воздухом питаются У каждого из них свой стек, не говоря уж об огромном количестве команд, выполняемых процессором при создании и унич тожении потока Поэтому надо стараться сводить любые издержки к минимуму.
Если Вы хотитe зарегистрировать рабочий элемент так, чтобы он обрабатывался при освобождении какого-либо объекта ядра, используйте еще одну новую функцию пула потоков
BOOL RegisterWaitForSingleOb]ect( PHANOLE phNewWaitObject, HANDLE hObject, WAITORTIMERCALLBACK pfnCallback, PVOIO pvContext, ULONG dwMilliseconrts, ULONG dwFlags);
Эта функция передает Ваши параметры компоненту поддержки ожидания в пуле , потоков. Вы сообщаете ему, что рабочий элемент надо поставить в очередь, как толь
ко освободится объект ядра (на который указывает bObject) Кроме того, Вы можете задать ограничение по времени, т. e. элемент будет помещен в очередь через опреде ленное время, даже если объект ядра так и нс освободится (При этом допустимы значения INFINITE и 0.) В общем, эта функция похожа на хорошо известную функ цию WattForSingIeObject (см. главу 9). Зарегистрировав рабочий элемент на ожидание указанного объекта, RegisterWaitForStngleObject возвращает в параметре phNewWait Object описатель, идентифицирующий объект ожидания
Данный компонент реализует ожидание зарегистрированных объектов через Wait ForMultipleObjects и поэтому накладывает те же ограничения, что и эта функция Одно из них заключается в том, что нельзя ожидать тот жс объект несколько paз. Так что придется вызывать DuplicateHandle и отдельно регистрировать исходный и продуб лированный описатель Вам должно быть известно, что единовременно функция WaitForMultipleObjects способна отслеживать не болсе 64 (MAXIMUM_WAIT_OBJECTS) объектов А что будет, если попробовить зарегистрировать с ее помощью более 64 объектов? Компонент поддержки ожидания создаст еще один поток, который тоже вы зовет WaitForMultipleObjects. (На самом деле новый поток создается на каждые 63 объек та, потому что потокам приходится использовать объект ядра "ожидаемый таймер", контролирующий таймауты)
По умолчанию рабочий элемент, готовый к обработке, помещается в очередь к потокам компонента поддержки других операций (не связанных с вводом-выводом). В конечном счете один из его потоков пробудится и вызовет Вашу функцию, у кото рой должен быть следующий прототип.
VOID WINAPI WaitOrTimerCallbackFunc( PVOID pvContext, BOOLEAN fTimerOrWaitFired);
Параметр pfTimerOrWaitFired принимает значение TRUE, если время ожидания ис текло, или FALSE, если объект освободился раньше.
В параметре dwFlags функции RegisterWaitForSingleObject можно передать флаг WT_EXECUTEINWAITTHREAD, который заставляет выполнить функцию рабочего эле мента в одном из потоков компонента поддержки ожидания. Это эффективнее, пото му что тогда рабочий элемент не придется ставить в очередь компонента поддержки других операций. Но в то же время и опаснее, так как этот поток не сможет ждать освобождения других объектов. Используйте этот флаг, только если Ваша функция выполняется быстро
Вы можете также передать флаг WT_EXECUTEINIOTHREAD, если Ваш рабочий эле мент выдаст запрос на асинхронный ввод-вывод, или WT_EXECUTEINPERSISTENT THREAD, если ему понадобится операция с использованием постоянно существующе го потока. В случае длительного выполнения функции рабочего элемента можно при менить флаг WT_EXECUTELONGFUNCTION Указывайте этот флаг, только если рабо чий элемент передается компоненту поддержки ввода-вывода или других операций, — функцию, требующую продолжительной обработки, нельзя выполнять в потоке, ко торый относится к компоненту поддержки ожидания.
И последний флаг, о котором Вы должны знать, — WT_EXECUTEONLYONCE. До пустим, Вы зарегистрировались на ожидание объекта ядра "процесс" После перехо да в свободное состояние он так и останется в этом состоянии, что заставит компо нент поддержки ожидания постоянно включать в очередь рабочие элементы. Так вот, чтобы избежать этого, Вы можете использовать флаг WT_EXECUTEONLYONCE — он сообщает пулу потоков прекратить ожидание объекта после первой обработки рабо чего элемента.
Теперь представьте, что Вы ждете объект ядра "событие с автосбросом": сразу после освобождения он тут же возвращается в занятое состояние; при этом в очередь ста вится соответствующий рабочий элемент. На этом этапе пул продолжает отслеживать объект и снова ждет его освобождения или того момента, когда истечет время, выде ленное на ожидание. Если состояние объекта Вас больше не интересует, Вы должны снять его с регистрации. Это необходимо даже для отработавших объектов, зарегис трированных с флагом WT_EXECUTEONLYONCE. Вот как выглядит требуемая для этого функция:
BOOL UnregisterWaitEx( HANOLE hWaitHandle, HANDLE hCompletionEvent);
Первый параметр указывает на объект ожидания (его описатель возвращается RegisterWaitForSingleObject), а второй определяет, каким образом Вас следует уведом лять о выполнении последнего элемента в очереди. Как и в DeleteTimerQueueTimer, Вы можете передать в этом параметре NULL (если уведомление Вас не интересует), INVA LID_HANULEVALUF, (функция блокируется до завершения обработки всех элементов в очереди) или описатель объекта-события (переходящего в свободное состояние при завершении обработки очередного элемента). В ответ на неблокирующий вызов Unre gisterWaitEx возвращает TRUE, если очередь пуста, и FALSE в ином случае (при этом GetLastError возвращает STATUS_PENDING).
И вновь будьте осторожны, передавая значение INVALIDHANDLE_VALUE. Функция рабочего элемента заблокирует сама себя, если попытается снять с регистрации выз вавший cc объект ожидания. Такая попытка подобна команде: приостановить меня, пока я не закончу выполнение, — полный тупик. Но UnregisterWaitEx разработана так, чтобы предотвращать тупиковые ситуации, когда поток компонента поддержки ожи дания выполняет рабочий элемент, а тот пытается снять с регистрации запустивший его объект ожидания. И еще один момент: не закрывайте описатель объекта ядра до тех пор, пока не снимете его с регистрации. Иначе недействительный описатель по падет в WaitForMultipleObjects, к которой обращается поток компонента поддержки ожидания.Функция моментально завершится с ошибкой, и этот компонент переста нет корректно работать.
И последнее- никогда не вызывайте PulseEvent для освобождения объекта-события, зарегистрированного на ожидание. Поток компонента поддержки ожидания скорее всего будет чем-то занят и пропустит этот импульс от PulseEvent. Но эта проблема для Вас не нова — PulseEvent создает ее почти во всех архитектурах поддержки потоков
Сценарий 4; вызов функций по завершении запросов на асинхронный ввод-вывод
Последний сценарий самый распространенный. Ваше серверное приложение выдает запросы на асинхронный ввод-вывод, и Вам нужен пул потоков, готовых к их обра ботке. Это как раз тот случай, на который и были изначально рассчитаны порты за вершения ввода-вывода Если бы Вы управляли собственным пулом потоков, Вы со здали бы порт завершения ввода-вывода и пул потоков, ждущих на этом порте Kpo мс того, Вы открыли бы пару-тройку устройств ввода-вывода и связали бы их описа тели с портом. По мерс завершения асинхронных запросов на ввод-вывод, драйверы устройств помещали бы "рабочие элементы" в очередь порта завершения.
Это прекрасная архитектура, позволяющая небольшому количеству потоков эф фективно обрабатывать несколько рабочих элементов, и очень хорошо, что она за
ложена в функции пуля потоков. Благодаря этому Вы сэкономите уйму времени и сил. Для использования преимуществ данной архитектуры надо лишь открыть требуемое устройство и сопоставить его с компонентом поддержки других операций (не свя занных с вводом-выводом) Учтите, что все потоки в этом компоненте ждут на порте завершения Чтобы сопоставить устройство с компонентом поддержки других опе раций, вызовите функцию:
BOOL BindIoCompletionCallback( HANDLE hDevice, POVERLAPPED_COMPLETION_ROUTINE pfnCallback, ULONG dwFlags);
Эта функция обращается к CreateIoCompletionPort, передавая eй hDevice и описа тель внутреннего порта завершения. Ее вызов также гарантирует, что в компоненте поддержки других операций есть хотя бы один поток Ключ завершения, сопостав ленный с устройством, — это адрес перекрывающейся подпрограммы завершения Так что, когда ввод-вывод на устройство завершается, компонент пула уже знает, какую функцию надо вызвать для обработки завершенного запроса. У подпрограммы завер шения должен быть следующий прототип:
VOID WINAPI OverlappedCompletionRoutine( DWORD dwErrorCode, DWORD dwNumberOfBytesTransfeгred, POVERLAPPED pOverlapped);
Заметьте, структура OVERLAPPED передается не в BindIoCompletionCallback, а в функции типа ReadFile и WriteFile. Сиосма внутренне отслеживает эту структуру вме сте с запросом на ввод-вывод.
После его завершения система поместит адрес струк туры в порт завершения для последующей передачи Вашей OverlappedCompletion Routine А поскольку адрес подпрограммы завершения — это и ключ завершения, то для передачи дополнительной контекстной информации в OverlappedCompletion Routine Вы должны прибегнуть к традиционному трюку и разместить эту информа цию в конце структуры OVERLAPPED.
Также учтите, что закрытие устройства приводит к немедленному завершению всех текущих запросов на ввод-вывод и дает ошибку Будьте готовы к этому в своей функ ции обратного вызова Если Вы хотите, чтобы после закрытия устройства функции обратного вызова больше не выполнялись, создайте в своем приложении контроль ный счетчик. При выдаче запроса на ввод-вывод Вы будете увеличивать его значение на 1, а при завершении — соответственно уменьшать.
Каких-то специальных флагов для функции BindloComplettonCallback сейчас не предусматривается, поэтому Вы должны передавать 0 в параметре dwFlags. Но, по моему, один флаг, WT_EXECUTEINIOTHREAD, ей следовало бы поддерживать. После завершения запроса на ввод-вывод он заставил бы поместить этот запрос в очередь одного из потоков компонента поддержки других операций (не связанных с вводом выводом) Всдь OverlappedCompletionRoutine, вероятно, выдаст еще один запрос на асинхронный ввод-вывод. Однако, если поток завершается, всс выданные им запро сы на ввод-вывод автоматически уничтожаются Кроме того, надо учесть, что потоки в компоненте поддержки других операций создаются и уничтожаются в зависимости от текущей нагрузки. При низкой нагрузке поток может быть закрыт, оставив неза вершенные запросы. Если бы функция BtndIoCompletionCallback поддерживала флаг WT_EXECUTEINIOTHREAD, то поток, ждущий на порте завершения, мог бы пробудить ся и передать результат потоку компонента поддержки ввода-вывода И поскольку эти
потоки никогда не завершаются при наличии запросов, Вы могли бы выдавать такие запросы, не опасаясь потерять их
Флаг WT_EXECUTEINIOTHREAD был бы, конечно, очень удобен, но Вы можете легко эмулировать все то, о чем я сейчас говорил В своей функции OverlappedCompletionRoutme просто вызовите QueueUserWorkltem с флагом WT_EXECUTEINIOTHREAD и передайте нужные данные (наверное, как минимум, структуру OVERLAPPED) Ничего другого функции пула Вам и не предложили бы.
Сегодняшние Windows-платформы
Сейчас Microsoft поставляет операционные системы Windows с тремя ядрами. Каждое ядро оптимизировано под свои виды вычислительных задач. Microsoft пытается переманить разработчиков программного обеспечения на Windows-платформы, утверждая, что интерфейс прикладного программирования (application programming interface, APT) у каждой из них одинаков. Это означает лишь то, что, научившись писать Windows-приложения для одного ядра, Вы поймете, как сделать то же самое для остальных.
Поскольку я объясняю, как писать Windows-приложения на основе Windows API, то теоретически все, о чем Вы узнаете из моей книги, применимо ко всем трем ядрам. На самом деле они сильно отличаются друг от друга, и поэтому одни и те же функции соответствующих операционных систем реализованы по-разному. Скажем так: базовые концепции одинаковы, но детали могут различаться.
Начнем с того, что представляют собой эти три ядра Windows.
Семафоры
Объекты ядра "семафор" используются для учета ресурсов Как и все объекты ядра, они содержат счетчик числа пользователей, но, кроме того, поддерживают два 32 битных значения со знаком: одно определяет максимальное число ресурсов (контро лируемое семафором), другое используется как счетчик текущего числа ресурсов
Попробуем разобраться, зачем нужны все эти счетчики, и для примера рассмот рим программу, которая могла бы использовать семафоры. Допустим, я разрабатываю серверный процесс, в адресном пространстве которого выделяется буфер для хране ния клиентских запросов. Размер этого буфера "зашит" в код программы и рассчи тан на хранение максимум пяти клиентских запросов. Если новый клиент пытается связаться с сервером, когда эти пять запросов еще не обработаны, генерируется ошиб ка, которая сообщает клиенту, что сервер занят и нужно повторить попытку позже При инициализации мой серверный процесс создает пул из пяти потоков, каждый из которых готов обрабатывать клиентские запросы по мере их поступления.
Изначально, когда запросов от клиентов еще нет, сервер не разрешает выделять процессорное время каким-либо потокам в пуле. Но как только серверу поступает, скажем, три клиентских запроса одновременно, три потока в пуле становятся плани руемыми, и система начинает выделять им процессорное время Для слежения за ре сурсами и планированием потоков семафор очень удобен. Максимальное число ре сурсов задается равным 5, что соответствует размеру буфера. Счетчик текущего чис ла ресурсов первоначально получает нулевое значение, так как клиенты еще не выда ли ни одного запроса. Этот счетчик увеличивается на 1 в момент приема очередного клиентского запроса и на столько же уменьшается, когда запрос передается на обра ботку одному из серверных потоков в пуле.
Для семафоров определены следующие правила:
когда счетчик текущего числа ресурсов становится больше 0, семафор пере ходит в свободное состояние,
если этот счетчик равен 0, семафор занят,
система не допускает присвоения отрицательных значений счетчику текуще го числа ресурсов;
счетчик текущего числа ресурсов не может быть больше максимального чис ла ресурсов
Не путайте счетчик текущего числа ресурсов со счетчиком числа пользователей объекта-семафора
Объект ядра "семафор" создается вызовом CreateSemapbore
HANDLE CreateSemaphore( PSECURITY_ATTRIBUTE psa, LONG lInitialCount, LONG lMaximumCount, PCTRTR pszName)
О параметрах psa и pszName я рассказывал в главе 3 Разумеется, любой процесс может получить свой ("процессо-зависимый") описатель существующего объекта "се мафор", вызвав OpenSemaphore
HANDLE OpenSemaphore( DWORD fdwAccess, BOOL bInhentHandle, PCTSTR pszName);
Параметр lMaximumCount сообщает системе максимальное число ресурсов, обра батываемое Вашим приложением Поскольку это 32-битное значение со знаком, пре дельное число ресурсов можетдостигать 2 147 483 647 Параметр lInitiа1Соипt указы вает, сколько из этих ресурсов доступно изначально (на данный момент) При ини циализяции моего серверного процесса клиентских запросов нет, поэтому я вызы ваю CreateSemaphore так
HANDLE hSem = CreateSemaphore(NULL, 0, 5, NULL);
Это приводит к созданию семафора со счетчиком максимального числа ресурсов равным 5, при этом изначально ни один ресурс не доступен (Кстати, счетчик числа пользователей данного объекта ядра равен 1, так как я только что создал этот объект, не запутайтесь в счетчиках) Поскольку счетчику текущего числа ресурсов присвоен 0 семафор находится в занятом состоянии А это значит, что любой поток, ждущий се мафор, просто засыпает
Поток получаст доступ к ресурсу, вызывая одну из Wait-функций и передавая ей описатель семафора, который охраняет этот ресурс Wait-функция проверяет у сема фора счетчик гекущего числа ресурсов если его значение больше 0 (семафор свобо ден), уменьшает значение этого счетчика на 1, и вызывающий поток остается плани руемым Очень важно, что семафоры выполняют эту операцию проверки и присвое ния на уровне атомдрного доступа; иначе говоря, когда Вы запрашиваете у семафора какой-либо ресурс, операционная система проверяет, доступен ли этот ресурс, и, если да, уменьшает счетчик текущего числа ресурсов, не позволяя вмешиваться в эту опе рацию другому потоку Только после того как счетчик ресурсов будет уменьшен на 1, доступ к ресурсу сможет запросить другой поток
Если Wait- функция определяет, что счетчик текущего числа ресурсов равен 0 (се мафор занят), система переводит вызывающий поток в состояние ожидания Когда другой поток увеличит значение этого счетчика, система вспомнит о ждущем потоке и снова начнет выделять ему процессорное время (а он, захватив ресурс, уменьшит значение счетчика на 1).
Поток увеличивает значение счетчика текущего числа ресурсов, вызывая функцию ReleaseSemaphore
BOOL ReleaseSemaphore( HANDLE hSem,
LONG lReleaseCount, PLONG p]PreviousCount);
Она просто складывает величину lReleaseCount со значением счетчика текущего числа ресурсов. Обычно в параметре lReleaseCount передают 1, но это вовсе не обяза тельно: я часто передаю в нем значения, равные или большие 2. Функция возвращает исходное значение счетчика ресурсов в *plPreviousCount Если Вас не интересует это значение (а в большинстве программ так оно и есть), передайте в параметре plPre viousCount значение NULL.
Было бы удобнее определять состояние счетчика текущего числа ресурсов, не меняя его значение, но такой функции в Windows нет. Поначалу я думал, что вызовом ReleaseSemapbore с передачей ей во втором параметре нуля можно узнать истинное значение счетчика в переменной типа LONG, на которую указывает параметр plPre viousCount. Но не вышло: функция занесла туда пуль. Я передал во втором параметре заведомо большее число, и — тот же результат. Тогда мне стало ясно: получить значе ние этого счетчика, не изменив его, невозможно.
Синхронизация в сценарии "один писатель/группа читателей"
Во многих приложениях возникает одна и та же проблемя синхронизации, о которой часто говорят как о сценарии "один писатель/группа читателей" (single-wrirer/ multiple-readers). В чем ее суть? Представьте: произвольное число потоков пытается получить доступ к некоему разделяемому ресурсу. Каким-то потокам ("писателям") нужно модифицировать данные, а каким-то ("читателям") — лишь прочесть эти данные. Синхронизация такого процесса необходима хотя бы потому, что Вы должны соблюдать следующие правила:
Когда один поток что-то пишет в область общих данных, другие этого делать не могут. Когда один поток что-то пишет в область общих данных, другие не могут ни чего считывать оттуда. Когда один поток считывает что-то из области общих данных, другие не могут туда ничего записывать. Когда один поток считывает что-тo из области общих данных, другие тоже могут это делать.
Посмотрим на проблему в контексте базы данных. Допустим, с ней работают пять конечных пользователей: двое вводят в нее записи, трое — считывают.
В этом сценарии правило 1 необходимо потому, что мы, конечно же, не можем позволить одновременно обновлять одну и ту же запись. Иначе информация в записи будет повреждена.
Правило 2 запрещает доступ к записи, обновляемой в данный момент другим пользователем. Будь то иначе, один пользователь считывал бы запись, когда другой пользователь изменял бы ее содержимое. Что увидел бы на мониторе своего компьютера первый пользователь, предсказать не берусь. Правило 3 служит тем же целям, что и правило 2. И действительно, какая разница, кто первый получит доступ к данным: тот, кто записывает, или тот, кто считывает, — все равно одновременно этого делать нельзя.
И, наконец, последнее правило. Оно введено для большей эффективности работы баз данных. Если никто не модифицирует записи в базе данных, все пользователи могут свободно читать любые записи. Также предполагается, что количество "читателей" превышает число "писателей".
О'кэй, суть проблемы Вы ухватили.
А теперь вопрос: как ее решить?
NOTE:
Я представлю здесь совершенно новый код. Решения этой проблемы, которые я публиковал в прежних изданиях, часто критиковались по двум причинам. Во первых, предыдущие реализации работали слишком медленно, так как я писал их в расчете на самые разные сценарии. Например, я шире использовал объекты ядра, стремясь синхронизировать доступ к базе данных потоков из разных процессов. Конечно, эти реализации работали и в сценарии для одного процесса, но интенсивное использование объектов ядра приводило в этом случае к существенным издержкам. Похоже, сценарий для одного процесса более распространен, чем я думал.
Во-вторых, в моей реализации был потенциальный риск блокировки noтоков-"писателей". Из правил, о которых я рассказал в начале этого раздела, вытекает, что потоки-"писатели" — при обращении к базе данных очень большого количества потоков-"читателей" — могут вообще не получить доступ к этому ресурсу.
Все эти недостатки я теперь устранил. В новой реализации объекты ядра применяются лишь в тсх случаях, когда без них не обойтись, и потоки синхронизируются в основном за счет использования критической секции.
Плоды своих трудов я инкапсулировал в С++-класс CSWMRG (я произношу его название как swimerge); это аббревиатура от "single writer/multiple reader guard". Он содержится в фцйлах SWMRG.h и SWMRG.cpp (см. листинг на рис. 10-3).
Использовать CSWMRG проще простого. Вы создаете объект С++-класса CSWMRG и вызываете нужные в Вашей программе функции-члены. В этом классе всего три метода (не считая конструктора и деструктора):
VOID CSWMRG:;WaitToRead(); // доступ к разделяемому ресурсу для чтения
VOID CSWMRG::WaitToWrite(); // монопольный доступ к разделяемому ресурсу для записи
VOID CSWMRG::Done(); // вызывается по окончании работы с ресурсом
Первый метод (WaitToRead) вызывается перед выполнением кода, что-либо считывающего из разделяемого ресурса, а второй (WaitToWrite) - перед выполнением кода, который считывает и записывает данные в разделяемом ресурсе.
К последнему методу (Done) программа обращается, закончив работу с этим ресурсом. Куда уж проще, а?
Объект CSWMRG содержит набор переменных-членов (см. таблицу ниже), отражающих то, как потоки работают с разделяемым ресурсом на данный момент. Остальные подробности Вы узнаете из исходного кода.
Переменная | Описание |
m_cs | Охраняет доступ к остальным членам класса, обеспечивая опера ции с ними на атомарном уровне |
т_nActive | Отражает текущее состояние разделяемого ресурса. Если она равна 0, ни один поток к ресурсу не обращается. Ее значение, большее 0, со общает текущее число потоков, считывающих данные из ресурса. Отрицательное значение (-1) свидетельствует о том, что какой-то поток записывает данные в ресурс. |
m_nWaitingReaders | Сообщает количество потоков "читателей", которым нужен доступ к ресурсу. Значение этой переменной инициализируется 0 и увели чивается на 1 всякий раз, когда поток вызывает WaitToRead в то вре мя, как т nActive равна — 1. |
т_nWaitingWriters | Сообщает количество потоков-"писателей", которым нужен доступ к ресурсу. Значение этой переменной инициализируется 0 и увели чивается на 1 всякий раз, когда поток вызывает WaitToWrite в то вре мя, как т nActive больше 0, |
т_hsemWriters | Когда потоки-"писатели"> вызывают WaitToWrtie, но получают отказ в доступе, так как m_nActive больше 0, они переходят в состояние ожидания этого семафора. Пока ждет хотя бы один поток-"писатель", новые потоки-"читатели" получают отказ в доступе к ресурсу. Тем самым я не даю потокам-"читателям" монополизировать доступ к этому ресурсу. Когда последний поток-"читатель", работавший с ресурсом, вызывает Done, семафор освобождается со счетчиком, равным 1, и система пробуждает один ждущий поток-"писатель". |
m_hsemReaders | Когда потоки-"читатели" вызывают WaitToRead, но получают отказ в доступе, так как m_nActive равна - 1, они переходят в состояние ожидания этого семафора. Когда последний из ждущих потоков-"писателей" вызывает Done, семафор освобождается со счетчиком, равным m_nWaitingReaders, и система пробуждает все ждущие потоки-"читатели". |
Системная информация
Многие параметры операционной системы (размер страницы, гранулярность выделения памяти и др) зависят от используемого в компьютере процессора. Поэтому нельзя жестко "зашивать" их значения в исходный код пpoгpaмм. Эту информацию надо считывать в момент инициализации процесса с помощью функции GetSystemfnfo:
VOID GetSystemInfo(LPSYSTEM_INFO psinf);
Вы должны передать в GetSystemInfo адрес структуры SYSTEM_INFO, и функция инициализирует элементы этой структуры:
typedef struct _SYSTEM_INFO
{
union
{
DWORD dwOemIdж
// не используйте этот элемент он устарел
struct
{
WORD wProcessorArchitecture;
WORD wRescrved;
};
};
DWORD dwPageSize;
LPVOID lpMinimurnApplirationAddress;
LPVOID lpMaximumApplicationAddress;
DWORD_PTR dwActiveProcessorMask;
DWORD dwNumberOfProcessors;
DWORD dwProcessorType;
DWORD dwAllocationGranularity;
WORD wProcessorLevel;
WORD wProcessorRpvi4inn;
} SYSTEM INFO *LPSYSlEM_INFO;
При загрузке система определяет значения элементов этой структуры, для конкретной системы их значения постоянны Функция GetSystemInfo предусмотрена специально для того, чтобы и приложения могли получать эту информацию. Из всех элементов структуры лишь четыре имеют отношение к памяти. Они описаны в следующей таблице.
Элемент | Описание | ||
dwPageStze | Размер страницы памяти. На процессорах x86 это значение ра но 4096, а на процессорах Alpha — 8192 байтам. | ||
IpMinimumApplicationAddress | Минимальный адрес памяти доступного адресного пространен для каждого процесса. В Windows 98 это значение равно 4 194 304, или 0x00400000, поскольку нижние 4 Мб адресного пространства каждого процесса недоступны. В Windows 2000 это значение равно 65 536, или 0x00010000, так как в этой сие теме резервируются лишь первые 64 Кб адресного пространст каждого процесса. | ||
lpMaximwnApplicationAddress | Максимальный адрес памяти доступного адресного простран ства, отведенного в "личное пользование" каждому процессу. В Windows 98 этот адрес равен 2 147 483 647, или 0x7FFFFFFF, так как верхние 2 Гб занимают общие файлы, проецируемые в память, и разделяемый код операционной системы. В Windows 2000 этот адрес соответствует началу раздела для кода и данных режима ядра за вычетом 64 Кб. | ||
dwAllocationGranularity | Гранулярность резервирования регионов адресного простран ства На момент написания книги это значение составляет 64 для всех платформ Windows. |
Остальные элементы этой структуры показаны в таблице ниже.
Элемент | Описание |
dwOemld | Устарел, больше не используется |
wReserved | Зарезервирован на будущее; пока не используется |
dwNumberOfProcessors | Число процессоров в компьютере |
dwActiveProcessorMask | Битовая маска, которая сообщает, какие процессоры активны (выполняют потоки) |
dwProcessorType | Используется только в Windows 98; сообщает тип процессора, например Intel 386, 486 или Pentium |
wProcessorArchitecture | Используется только в Windows 2000; сообщает тип архитектуры процессора, например Intel, Alpha, 64-разрядный Intel или 64-разрядный Alpha |
wProcessorLevel | Используется только в Windows 2000; сообщает дополнительные подробности об архитектуре процессора, например Intel Pentium Pro или Pentium II |
wProcessorRevision | Используется только в Windows 2000; сообщает дополнительные подробности об уровне данной архитектуры процессора |
самая примитивная разновидность объектов ядра.
События - самая примитивная разновидность объектов ядра. Они содержат счетчик числа пользователей (как и все объекты ядра) и две булевы переменные: одна сооб щает тип данного объекта-события, другая — его состояние (свободен или занят).
События просто уведомляют об окончании какой-либо операции. Объекты-собы тия бывают двух типов: со сбросом вручную (manual-reset events) и с автосбросом (auto-reset events). Первые позволяют возобновлять выполнение сразу нескольких ждущих потоков, вторые — только одного.
Объекты-события обычно используют в том случае, когда какой-то поток выпол няет инициализацию, а затем сигнализирует другому потоку, что тот может продол жить работу. Инициализирующий поток переводит объект "событие" в занятое состо яние и приступает к своим операциям. Закончив, он сбрасывает событие в свободное состояние. Тогда другой поток, который ждал перехода события в свободное состоя ние, пробуждается и вновь становится планируемым.
Объект ядра "событие" создается функцией CreateEvent:
HANDLE CreateEvent(
PSECURITY_ATTRIBUTES psa, BOOL fManualReset, BOOL fInitialState, PCTSTR pszName);
В главе 3 мы обсуждали общие концепции, связанные с объектами ядра, — защи ту, учет числа пользователей объектов, наследование их описателей и совместное использование объектов за счет присвоения им одинаковых имен. Поскольку всс это Вы теперь знаете, я не буду рассматривать первый и последний параметры данной функции.
Пареметр fManualReset (булева переменная) сообщает системе, хотите Вы создать событие со сбросом вручную (TRUE) или с автосбросом (FALSE). Параметру fInitialState определяет начальное состояние события — свободное (TRUE) или занятое (FALSE). После того как система создает объект событие, CreateEvent возвращает описатель события, специфичный для конкретного процесса. Потоки из других процессов мо гут получить доступ к этому объекту: 1) вызовом CreateEvent с тем же параметром pszName;, 2) наследованием описателя; 3) применением функции DuplicateHandle;, и 4) вызовом OpenEvent c передачей в параметре pszName имени, совпадающего с ука занным в аналогичном параметре функции CreateEvent. Вот что представляет собой функция OpenEvent.
HANDLE OpenEvent( DWORD fdwAccess, BOOL fInhent, PCTSTR pszName);
Ненужный объект ядра "событие" следует, как всегда, закрыть вызовом CloseHandle Создав собьпис, Вы можете напрямую управлять его состоянием. Чтобы перевес ти его в свободное состояние, Вы вызываете:
BOOL SetEvenT(HANDLE hEvenеt);
А чтобы поменять его на занятое
BOOL ResetEvent(HANDLE hEvent);
Вот так все просто
Для событий с автосбросом действует следующее правило Когда его ожидание потоком успешно завершается, этот объект автоматически сбрасывается в занятое состояние. Отсюда и произошло название таких объектов-событий Для этого объек та обычно не требуется вызывать ResetEvent, поскольку система сама восстанавливает его состояние А для событий со сбросом вручную никаких побочных эффектов ус пешного ожидания не предусмотрено.
Рассмотрим небольшой пример тому, как на практике использовать объекты ядра "событие" для синхронизации потоков Начнем с такого кода.
// глобальный описатель события со сбросом вручную (в занятом состоянии)
HANDLE g_hEvent;
int WINAPI WinMain( )
{
// создаем объект "событие со сбросом вручную (в занятом состоянии)
g_hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
// порождаем три новых потока
HANDLE hThread[3];
DWORD dwThreadTD;
hThread[0] = _beginthreadex(NULL, 0, WordCount, NULL, 0, &dwThreadlD);
hThread[1] = _beginthreadex(NULL, 0, SpellCheck, NULL, 0, &dwThreadID);
hTbread[2] = _beginthreadex(NULL, 0, GrarrmarCheck, NULL, 0, &dwThreadID);
OpenFileAndReadContentsIntoMemory( );
// разрешаем всем грем потокам обращаться к памяти
SetEvent(g__hEvent),
}
DWORD WINAPI WordCount(PVOID pvParam)
{
// ждем, когда в память будут загружены данные из файла
WaitForSingleObject(g_hEvent, iNeiNITE);
// обращаемся к блоку памяти
return(0);
}
DWORD WINAPI SpellCheck(PVOID pvParam)
{
// ждем, когда в память будут загружены данные из файла
WaitForSingleObject(g_hFvent, INFINITE);
// обращаемся к блоку пэмяти
return(0};
}
DWORD WINAPI GrammarCheck(PVOID pvParam)
{
// ждем, когда в память будут загружены данные из файла
WaitForSingleObject(g_hFvent, INFINITE);
// обращаемся к блоку памяти
return(0);
}
При запуске этот процесс создает занятое событие со сбросом вручную и запи сывает его описатель в глобальную переменную. Это упрощает другим потокам про цесса доступ к тому жс объекту-событию Затем порождается три потока. Они ждут, когда в память будут загружены данные (текст) из некоего файла, и потом обращают ся к этим данным, один поток подсчитывает количество слов, другой проверяет ор фографические ошибки, третий — грамматические Все три функции потоков начи нают работать одинаково каждый поток вызывает WaitForSingleObject, которая при останавливает его до гех пор, пока первичный поток не считает в память содержи мое файла
Загрузив нужные данные, первичный поток вызывает SetEvent, которая переводит событие в свободное состояние. В этот момент система пробуждает три вторичных потока, и они, вновь получив процессорное время, обращаются к блоку памяти За метьте, что они получают доступ к памяти в режиме только для чтения. Это единствен ная причина, по которой все три потока могут выполняться одновременно
Если событие со сбросом вручную заменить на событие с автосбросом, програм ма будет вести себя совершенно иначе После вызова первичным потоком функции SetEvent система возобновит выполнение только одного из вторичных потоков. Ка кого именно — сказать заранее нельзя. Остальные два потока продолжат ждать.
Поток, вновь ставший планируемым, получает монопольный доступ к блоку па мяги, где хранятся данные, считанные из файла Давайте перепишем функции пото ков так, чтобы перед самым возвратом управления они (подобно функции WinMain) вызывали SetFvent Теперь функции потоков выглядят следующим образом:
DWORD WINAPI WordCount(PVOID pvParam)
{
// ждем, когда в память будут загружены данные из файла
WaitForSingleObject(a_hEvent, INFINITE);
// обращаемся к блоку памяти
SetEvent(g_hEvent);
return(0);
}
DWORD WINAPI SpellCneck(PVOID pvParam)
{
// ждем, когда в память будут загружены данные из файла
WaitForSingleObject(g htvent, INFINITE);
// обращаемся к блоку памяти
SetEvent(g_hEvent);
return(0);
}
DWORD WINAPI GrammarCheck(PVOID pvParam)
{
// ждем, когда в память будут загружены данные из файла
WaitForSingleObject(g_hEvent, INFINITF);
// обращаемся к блоку памяти
SetEvent(g_hEvent);
return(0);
}
Закончив свою работу с данными, поток вызывает SetEvent, которая разрешает системе возобновить выполнение следующего из двух ждущих потоков. И опять мы не знаем, какой поток выберет система, но так или иначе кто-то из них получит мо нопольный доступ к тому же блоку памяти. Когда и этот поток закончит свою работу, он тоже вызовет SetEvent, после чего с блоком памяти сможет монопольно опериро вать третий, последний поток Обратите внимание, что использование события с ав тосбросом снимает проблему с доступом вторичных потоков к памяти как для чте ния, так и для записи; Вам больше не нужно ограничивать их доступ только чтением. Этот пример четко иллюстрирует различия в применении событий со сбросом вруч ную и с автосбросом.
Для полноты картины упомяну о еще одной функции, которую можно использо вать с объектами-событиями
BOOL PulseEvent(HANDLE hEvent);
PuteeEvent освобождает событие и тут жс переводит его обратно в занятое состо яние; ее вызов равнозначен последовательному вызову SelEvent и ResetEvent. Если Вы вызываете PulseEvent для события со сбросом вручную, любые потоки, ждущие этот объект; становятся планируемыми. При вызове этой функции применительно к со бытию с автосбросом пробуждается только одип из ждущих потоков. А если ни один из потоков не ждет объект-событие, вызов функции не дает никакого эффекта
Особой пользы от PulseEuent я не вижу В сущности, я никогда не пользовался ею на практике, потому что абсолютно неясно, какой из потоков заметит этот импульс и станет планируемым Наверное, в каких-то сценариях PulseEuent можст пригодиться, но ничего такого мне в голову не приходит Когда мы перейдем к рассмотрению фун кции SignalObjectAndWait, я расскажу о PulseEvent чуть подробнее.
Содержимое компакт-диска и требования к системе
Компакт-диск, прилагаемый к книге, содержит исходный код и исполняемые файлы всех программ-примеров. Эти программы написаны и скомпилированы с использованием Microsoft Visual C++ 6.0. Большая их часть будет работать в Windows 95, Windows 98, Windows NT 4.0 и Windows 2000, но некоторые программы требуют такую функциональность, которая поддерживается только Windows NT 4.0 и Windows 2000. Если Вы захотите самостоятельно скомпилировать какие-то примеры, Вам понадобится Microsoft Visual C++ 6.0.
В корневом каталоге компакт-диска находится общий заголовочный файл (Cmnhdr.h) и около трех десятков каталогов, в которых хранятся соответствующие программыпримеры. В каталогах x86 и Alpha32 содержатся отладочные версии тех же программ - их можно запускать прямо с компакт-диска.
Вставив компакт-диск в привод CD-ROM, Вы увидите окно Welcome. Если оно не появится, перейдите в каталог Setup на компакт-диске и запустите файл PressCDx86.exe или PressCDAlpha32.exe (в зависимости от того, какой процессор в Вашем компьютере).
Техническая поддержка
Microsoft Fress публикует исправления на http://mspress.microsoft.com/support
Если у Вас есть какие-нибудь комментарии, вопросы или идеи, касающиеся моей книги, пожалуйста, направляйте их в Microsoft Press по обычной или электронной почте:
Microsoft Press
Attn: ProgrammingApplicationsforMicrosoft Windows, 4lh cd., editor
One Microsoft Way
Redmond, WA 98052-6399
mspinput@microsoft.com
Совместное использование объектов ядра несколькими процессами
Время от времени возникает необходимость в разделении объектов ядра между потоками, исполняемыми в разных процессах. Причин тому может быть несколько:
• объекты "проекции файлов" позволяют двум процессам, исполняемым на одной машине, совместно использовать одни и те же блоки данных;
• почтовые ящики и именованные каналы дают возможность программам обмениваться данными с процессами, исполняемыми на других машинах в сети;
• мьютексы, семафоры и события позволяют синхронизировать потоки, исполняемые в разных процессах, чтобы одно приложение могло уведомить другое об окончании той или иной операции.
Но поскольку описатели объектов ядра имеют смысл только в конкретном процессе, разделение объектов ядра между несколькими процессами — задача весьма непростая. У Microsoft было несколько веских причин сделать описатели процессно-зависимыми, и самая главная — устойчивость операционной системы к сбоям. Если бы описатели объектов ядра были общесистемными, то один процесс мог бы запросто получить описатель объекта, используемого другим процессом, и устроить в нем (этом процессе) настоящий хаос. Другая причина — защита. Объекты ядра защищены, и процесс, прежде чсм оперировать с ними, должен запрашивать разрешение на доступ к ним.
Три механизма, позволяющие процессам совместно использовать одни и те же объекты ядра, мы рассмотрим в следующем разделе.
Совместный доступ процессов к данным через механизм проецирования
В Windows всегда было много механизмов, позволяющих приложениям легко и быс тро разделять какие-либо данные. К этим механизмам относятся RPC, COM, OLE, DDE, оконные сообщения (особенно WM_COPYDATA), буфер обмена, почтовые ящики, сокеты и т. д. Самый низкоуровневый механизм совместного использования данных на одной машине — проецирование файла в память. На нем так или иначе базируют ся все перечисленные мной механизмы разделения данных Поэтому, если Вас инте ресует максимальное быстродействие с минимумом издержек, лучше всего применять именно проецирование.
Совместное использование данных в этом случае происходит так: два или более процесса проецируют в намять представления одного и того же объекта "проекция файла", т. e. делягодни и те жс страницы физической памяти. В результате, когда один процесс записывает данные в представление общего объекта "проекция файла", из менения немедленно отражаются на представлениях в других процессах. Но при этом все процессы должны использовать одинаковое имя объекта "проекция файла"
А вот что происходит при запуске приложения. При открытии ЕХЕ-файла на дис ке система вызывает CreateFile, с помощью CreateFileMapping создает объект "проек ция файла" и, наконец, вызывает MapVtewQfFileEx (с флагом SEC_IMAGE) для отобра жения ЕХЕ-файла на адресное пространство только что созданного процесса. Map ViewOfFileEx вызывается вместо MapViewOfFile, чтобы представление файла было спро ецировано по базовому адресу, значение которого хранится в самом ЕХЕ-файле По том создается первичный поток процесса, адрес первого байта исполняемого кода в спроецированном представлении заносится в регистр указателя команд (IP), и про цессор приступает к исполнению кода.
Если пользователь запустит второй экземпляр того же приложения, система уви дит, что объект "проекция файла" для нужного ЕХЕ-файла уже существует и не станет создавать новый объект. Она просто спроецирует еще одно представление файла — на этот раз в контексте адресного пространства только что созданного второго про цесса, т. e. одновременно спроецирует один и тот же файл на два адресных простран ства Это позволяет эффективнее использовать память, так как оба процесса делят одни и те же страницы физической памяти, содержащие порции исполняемого кода.
Как и все объекты ядра, проекции файлов можно совместно использовать из не скольких процессов тремя методами: наследованием описателей, именованием и дуб лированием описателей. Подробное объяснение этих трех методов см. в главе 3.
Создание DLL для использования с другими средствами разработки (отличными от Visual C++)
Если Вы используете Visual С++ для сборки как DLL, так и обращающегося к ней EXE-файла, то все сказанное ранее справедливо, и Вы можете спокойно пропустить этот раздел. Но если Вы создаете DLL на Visual С++, а ЕХЕ-файл — с помощью средств разработки от других поставщиков, Вам не миновать дополнительной работы.
Я уже упоминал о том, как применять модификатор extern при "смешанном" программировании на С и С++. Кроме того, я говорил, что из-за искажения имен нужно применять один и тот же компилятор. Даже при программировании на стандартном С инструментальные средства от разных поставщиков создают проблемы. Дело в том, что компилятор Microsoft С, экспортируя С-функцию, искажает eе имя, даже если Вы вообще не пользуетесь С++. Это происходит, только когда Ваша функция экспортируется по соглашению __stdcall. (Увы, это самое популярное соглашение.) Тогда компилятор Microsoft искажает имя С-функции. впереди ставит знак подчеркивания, а к концу добавляет суффикс, состоящий из символа @ и числа байтов, передаваемых функции в качестве параметров. Например, следующая функция экспортируется в таблицу экспорта DLL как _MyFunc@8:
__declspec(dllexport) LONG __stdcall MyFunc(int a, int b);
Если Вы решите создать ЕХЕ-файл с помощью средств разработки от другого поставщика, то компоновщик попытается скомпоновать функцию MyFunc, которой нет в файле DLL, созданном компилятором Microsoft, и, естественно, произойдет ошибка.
Чтобы средствами Microsoft собрать DLL, способную работать с инструментарием от другого поставщика, нужно указать компилятору Microsoft экспортировать имя функции без искажений. Сделать это можно двумя способами. Первый — создать DEF-файл для Вашего проекта и включить в него раздел EXPORTS так:
EXPORTS MyFunc
Компоновщик от Microsoft, анализируя этот DEF-файл, увидит, что экспортировать надо обе функции: __MyFunc@8 и MyFunc. Поскольку их имена идентичны (не считая вышеописанных искажений), компоновщик на основе информации из DEF-файла экспортирует только функцию с именем MyFunc, а функцию _MуFипс@8 не экспортирует вообще.
Может, Вы подумали, что при сборке ЕХЕ-файла с такой DLL компоновщик от Microsoft, ожидая имя _MyFunc8, не найдет Вашу функцию? В таком случае Вам будет приятно узнать, что компоновщик все сделает правильно и корректно скомпонует ЕХЕ-файл с функцией MyFunc.
Если Вам не по душе DEF-файлы, можете экспортировать неискаженное имя функции еще одним способом. Добавьте в один из файлов исходного кода DLL такую строку:
#pragma comment(linker, "/export:MyFunc=_MyFunc@8")
Тогда компилятор потребует от компоновщика экспортировать функцию MyFunc с той же точкой входа, что и _MyFunc@8. Этот способ менее удобен, чем первый, так как здесь приходится самостоятельно вставлять дополнительную директиву с искаженным именем функции. И еще один минус этого способа в том, что из DLL экспортируется два идентификатора одной и той же функции MyFunc и _МуFипс@8, тогда как при первом способе — только идентификатор MyFunc. По сути, второй способ не имеет особых преимуществ перед первым — он просто избавляет от DEF-файла.
Создание DLL-модуля
Создавая DLL, Вы создаете набор функций, которые могут быть вызваны из ЕХЕ-модуля (или другой DLL), DLL может экспортировать переменные, функции или C++ классы в другие модули. На самом дслс я бы не советовал экспортировать переменные, потому что это снижает уровень абстрагирования Вашего кода и усложняет его поддержку. Кроме того, С++-классы можно экспортировать, только если импортирующие их модули транслируются тем же компилятором. Так что избегайте экспорта С++-классов, если Вы не уверены, что разработчики ЕХЕ-модулей будут пользоваться тем же компилятором.
При разработке DLL Вы сначала создаете заголовочный файл, в котором содержатся экспортируемые из нее переменные (типы и имена) и функции (прототипы и имена). В этом же файле надо определить все идентификаторы и структуры данных, используемые экспортируемыми функциями и переменными. Заголовочный файл включается во всс модули исходного кода Вашей DLL Более того, Вы должны поставлять его вместе со своей DLL, чтобы другие разработчики могли включать его в свои модули исходного кода, которые импортируют Ваши функции или переменные Единый заголовочный файл, используемый при сборке DLL и любых исполняемых модулей, существенно облегчает поддержку приложения.
Вот пример единого залоловочного файла, включаемого в исходный код DLL- и ЕХЕ-модулей.
Модуль MyLib.h
Этот заголовочный файл надо включать в самое начало исходных файлов Вашей DLL следующим образом:
MyLibFile1.cpp
При компиляции исходного файла DLL, показанного на предыдущем листинге, MYLIBAPI определяется как __declspec(dllexport) до включения заголовочного файла MyLib.h. Такой модификатор означает, что данная переменная, функция или С++-класс экспортируется из DLL Заметьте, что идентификатор MYLIBAPI помещен и заголовочный файл до определения экспортируемой переменной или функции.
Также обратите внимание, что в файле MyLibFilel.cpp перед экспортируемой переменной или функцией не ставится идентификатор MYITBAPI. Он здесь не нужен, проанализировав заголовочный файл, компилятор запоминает, какие переменные и функции являются экспортируемыми.
Идентификатор MYLIRAPT включает extern. Пользуйтесь этим модификатором только в коде на С++, но ни в коем случае не в коде на стандартном С. Обычно компиляторы С++ искажают (mangle) имена функций и переменных, что может приводить к серьезным ошибкам при компоновке. Представьте, что DLL написана на С++, а исполняемый код — на стандартном С. При сборке DLL имя функции будет искажено, но при сборке исполняемого модуля — нет. Пытаясь скомпоновать исполняемый модуль, компоновщик сообщит об ошибке исполняемый модуль обращается к несуществующему идентификатору Модификатор extern не дает компилятору искажать имена переменных или функций, и они становятся доступными исполняемым модулям, написанным на С, С++ или любом другом языке программирования.
Теперь Вы знаете, как используется заголовочный файл в исходных файлах DLL. А как насчет исходных файлов ЕХЕ-модули? В них MYLIBAPI определять не надо: включая заголовочный файл, Вы определяете этот идентификатор как __declspec(dllimport), и при компиляции исходного кода ЕХЕ-модуля компилятор поймет, что переменные и функции импортируются из DLL.
Просмотрев стандартные заголовочные файлы Windows (например, WinBase.h), Вы обнаружите, что практически тот же подход исповедует и Microsoft.
Создание дополнительной кучи
Дополнительные кучи в процессе создаются вызовом HeapCreate
HANDLE HeapCreate( DWORD fdwOptions, SIZE_T dwInitialSize, SIZE_T dwMaximumSize);
Параметр fdwOptions модифицирует способ выполнения операций над кучей. В нем можно указать 0, HEAP_NO_SERIALIZE, HEAP_GENERATE_EXCEPTIONS или комбинацию последних двух флагов.
По умолчанию действует принцип последовательного доступа к куче, что позволяет не опасаться одновременного обращения к ней сразу нескольких потоков. При попытке выделения из кучи блока памяти функция HeapAlloc (ее параметры мы обсудим чуть позже) делает следующее:
Просматривает связанный список выделенных и свободных блоков памяти. Находит адрес свободного блока. Выделяет новый блок, помечая свободный как занятый. Добавляет новый элемент в связанный список блоков памяти.
Флаг HEAP_NO_SERIALIZE использовать не следует, и вот почему. Допустим, два потока одновременно пытаются выделить блоки памяти из одной кучи. Первый поток выполняет операции по пп 1 и 2 и получает адрес свободного блокз памяти. Но только он соберется перейти к третьему этапу, как его вытеснит второй поток и тоже выполнит операции по пп. 1 и 2. Поскольку первый поток не успел дойти до этапа 3, второй поток обнаружит тот же свободный блок памяти.
Итак, оба потока считают, что они нашли свободный блок памяти в куче. Поэтому поток 1 обновляет связанный список, помечая новый блок как занятый. После этого и поток 2 обновляет связанный список, помечая тот же блок как занятый. Ни один из потоков пока ничего не подозревает, хотя оба получили адреса, указывающие на один и тот же блок памяти.
Ошибку такого рода обнаружить очень трудно, поскольку она проявляется не сразу. Но в конце концов сбой произойдет и, будьте уверены, это случится в самый не подходящий момент, Вот какие проблемы это может вызвать.
Повреждение связанного списка блоков памяти. Эта проблема не проявится до попытки выделения или освобождения блока.
Оба потока делят один и тот же блок памяти. Оба записывают в него свою информацию Когда поток 1 начнет просматривать содержимое блока, он не поймет данные, записанные потоком 2.
Один из потоков, закончив работу с блоком, освобождает его, и это приводит к тому, что другой поток записывает данные в невыделенную память Происходит повреждение кучи.
Решение этих проблем — предоставить одному из потоков монопольный доступ к куче и ее связанному списку (пока он не закончит все необходимые операции с кучей). Именно так и происходит в отсутствие флага HEAP_NO_SERIALIZE. Этот флаг можно использовать без опаски только при выполнении следующих условий'
в процессе существует лишь один поток;
в процессе несколько потоков, но с кучей работает лишь один из них;
в процессе несколько потоков, но он сам регулирует доступ потоков к кучс, применяя различные формы взаимоисключения, например критические сек ции, объекты-мьютексы или семафоры (см. главы 8 и 9).
Если Вы не уверены, нужен ли Вам флаг HEAP_NO_SERIALIZE, лучше не пользуйтесь им. В его отсутствие скорость работы многопоточной программы может чуть снизиться из-за задержек при вызовах функций, управляющих кучами, но зато Вы избежите риска повреждения кучи и ее данных.
Другой флаг, HEAP_GENERATE_EXCEPTIONS, заставляет систему генерировать исключение при любом провале попытки выделения блока в куче Исключение (см. гла вы 23, 24 и 25) — еще один способ уведомления программы об ошибке. Иногда приложение удобнее разрабатывать, полагаясь на перехват исключений, а не на проверку значений, возвращаемых функциями.
Второй параметр функции HeapCreate — dwlnilialSize - определяет количество байтов, первоначально передаваемых куче. При необходимости функция округляет это значение до ближайшей большей величины, кратной размеру страниц. И после дний параметр, clwMaximumSize, указывает максимальный объем, до которого может расширяться куча (предельный объем адресного пространства, резервируемого под кучу). Если он больше 0, Вы создадите кучу именно такого размера и не сможете его увеличить. А если этот параметр равен 0, система резервирует регион и, если надо, расширяет его до максимально возможного объема. При успешном создании кучи HeapCreate возвращает описатель, идентифицирующий новую кучу.Он используется и другими функциями, работающими с кучами.
Создание ЕХЕ-модуля
Вот пример исходного кода ЕХЕ-модуля, который импортирует идентификаторы, экспортируемые DLL, и ссылается на них в процессе выполнения.
MyExeFile1.cpp
Создавая файлы исходного кода для ЕХЕ-модуля, Вы должны включить в них заголовочный файл DLL, иначе импортируемые идентификаторы окажутся неопределенными, и компилятор выдаст массу предупреждений и сообщений об ошибках.
MYLIBAPI в исходных файлах ЕХЕ-модуля до заголовочного файла DLL не определяется. Поэтому при компиляции приведенного выше кода MYLIBAPI за счет заголовочного файла MyLib.h будет определен как _declspec (dllimport). Встречая такой модификатор перед именем переменной, функции или С++-класса, компилятор понимает, что данный идентификатор импортируется из какого-то DLL-модуля. Из какого именно, ему не известно, да это его и не интересует. Компилятору нужно лишь убедиться в корректности обращения к импортируемым идентификаторам.
Далее компоновщик собирает все OBJ-модули в конечный ЕХЕ-модуль. Для этого он должен знать, в каких DLL содержатся импортируемые идентификаторы, на которые есть ссылки в коде. Информацию об этом он получает из передаваемого ему LIB-файла. Я уже говорил, что этот файл — просто список идентификаторов, экспортируемых DLL. Компоновщик должен удостовериться в существовании идентификатора, на который Вы ссылаетесь в коде, и узнать, в какой DLL он находится. Если компоновщик сможет разрешить все ссылки на внешние идентификаторы, на свет появится ЕХЕ-модуль.
Создание инверсных семафоров и типов данных, безопасных в многопоточной среде
Как-то ряз я писал одну программу, и мне понадобился объект ядра, который вел бы себя прямо противоположно тому, как ведет себя семафор. Мне нужно было, чтобы он переходил в свободное состояние, когда его счетчик текущего числа ресурсов обнуляется, и оставался в занятом состоянии, пока значение этого счетчика больше 0.
Я мог бы придумать много применений такому объекту. Например, потокдолжсн пробудиться после того, как определенная операция будет выполнена 100 раз. Чтобы осуществить это, нужен объект ядра, счетчик которого можно было бы инициализировать этим значением. Пока он больше 0, объект остается в занятом состоянии. По окончании каждой операции Вы уменьшаете счетчик в объекте ядра на 1. Как только счетчик обнуляется, объект переходит в свободное состояние, сообщая другому потоку, что тот может пробудиться и чем-то заняться. Это типичная задача, и я не понимаю, почему в Windows нет подходящего синхронизирующего объекта.
В сущности, Microsoft могла бы легко решить эту задачу, предусмотрев в семафоре возможность присвоения отрицательных значений его счетчику текущего числа ресурсов. Тогда Вы инициализировали бы счетчик семафора значением -99 и по окончании каждой операции вызывали бы ReleaseSemaphore. Как только его счетчик достиг бы значения 1, объект перешел бы в свободное состояние. После этого мог бы пробудиться другой Ваш поток и выполнить свою работу. Увы, Microsoft запрещает присвоение счетчику семафора отрицательных значений, и вряд ли здесь что-то переменится к обозримом будущем.
В этом разделе я познакомлю Вас со своим набором С++-классов, которые действуют как инверсный семафор и делают уйму всяких других вещей. Исходный код этих классов находится в файле Interlocked.h (см листинг на рис. 10-2).
Когда я впервые взялся за решение этой проблемы, я понимал, что главное в нем — обеспечить безопасность манипуляций над переменной в многопоточной среде. Я хотел найти элегантное решение, которое позволило бы легко писать код, ссылающийся на эту переменную.
Очевидно, что самый простой способ обезопасить какой-то ресурс от повреждения в многопоточной среде, — защитить его с помощью критической секции. В С++ это можно сделать без особого труда. Достаточно создать C++ класс, который содержит защищаемую переменную и структуру CRITICAL_SECTION. В конструкторе Вы вызываете lnitializeCriticalSection, а в деструкторе — DeleteCritical Section. Затем для любой переменной-члена Вы вызываете EnterCriticalSection, что-то делаете с этой переменной и вызываете LeaveCriticalSection. Если Вы именно так реализуете С++-класс, то писать безопасный код, обращающийся к какой-либо структуре данных, будет несложно. Этот принцип положен мной в основу всех С++-классов, о которых я буду рассказывать в данном разделе. (Конечно, вместо критических секций я мог бы использовать оптекс, рассмотренный в предыдущем разделе.)
Первый класс, CResGuard, охраняет доступ к ресурсу. Он содержит два элемента данных: CRITICAL_SECTION и LONG. Последний используется для слежения за тем, сколько раз поток, владеющий ресурсом, входил в критическую секцию. Эта информация полезна при отладке. Конструктор и деструктор объекта CResGuard вызывают сответственно InitializeCriticalSeclion и DeleteCriticalSection. Поскольку создать объект может лишь единственный поток, конструктор и деструктор какого-либо С++-объекта не обязательно должен быть реентерабельным. Функция-член IsGuarded просто сообщает, была ли хоть раз вызвана EnterCriticalSection для данного объекта. Как я уже говорил, все это предназначено для отладки. Включение CRITICAL_SECTION в C++ объект гарантирует корректность инициализации и удаления критической секции.
Класс CResGuard также включает открытый вложенный С++-класс CGuard. Объект CGuard содержит ссылку на объект CResGuard и предусматривает лишь конструктор и деструктор. Конструктор обращается к функции-члену Guard класса CResGuard, вызывающей EnterCriticalSection, а деструктор — к функции-члену Unguard того же класса, вызывающей LeaveCriticalSection.
Такая схема упрощает манипуляции с CRITICAL_SECTION. Вот небольшой фрагмент кода, иллюстрирующий применение этих классов:
struct SomeDataStruct
{
...
} g_SomeSharedData;
// Создаем объект CResGuard, защищающий g_SomeSharedData.
// Примечание: Конструктор инициализирует критическую секцию, а деструктор удаляет ее.
CResGuard g_rgSomeSharedData;
void AFunction()
{
// эта функция работает с разделяемой структурой данных
// защищаем ресурс от одновременного доступа со стороны нескольких потоков
CResGuard::CGuard gDummy(g_rgSomeSharedData);
// входим в критическую секцию
// работаем c ресурсом g_SomeSharedData
...
}
// Примечание: LeaveCriticalSection вызывается, когда gDummy
// выходит за пределы области видимости
Следующий С++-класс, CInterlockedType, содержит все, что нужно для создания объекта данных, безопасного в многопоточной среде. Я сделал CInterlockediype классом шаблона, чтобы его можно было применять для любых типов данных. Поэтому Вы можете использовать его, например, с целочисленной переменной, строкой или произвольной структурой данных.
Каждый экземпляр объекта CInterlockedType содержит два элемента данных. Первый - это экземпляр шаблонного типа данных, который Вы хотите сделать безопасным в многопоточной среде. Он является закрытым, и им можно манипулировать только через функции-члены класса CInterlockedType. Второй элемент данных представляет собой экземпляр объекта CResGuard, так чго класс, производный от CInterlockedType, может легко защитить свои данные.
Предполагается, что Вы всегда будете создавать свой класс, используя класс CInterlockedType как базовый. Ранее я уже говорил, что класс CInterlockedType предоставляет все необходимое для создания объекта, безопасного в многопоточной среде, но производный класс должен сам позаботиться о корректном использовании элементов CInterlockedType.
Класс CInterlockedType содержит всего четыре открытые функции - конструктор, инициализирующий объект данных, и конструктор, не инициализирующий этот объект, а также виртуальный деструктор, который ничего не делает, и оператор приведения типа (cast operator).
Последний просто гарантирует безопасный доступ к данным, охраняя ресурс и возвращая текущее значение объекта. (Ресурс автоматически разблокируется при выходе локальной переменной x за пределы ее области видимости.) Этот оператор упрощает безопасную проверку значения объекта данных, содержащегося в классе.
В классе CInterlockedType также присутствуют три невиртуальные защищенные функции, которые будут вызываться производным классом. Две функции GetVal возвращают текущее значение объекта данных. В отладочных версиях файла обе эти функции сначала проверяют, охраняется ли объект данных. Если бы он не охранял ся, GetVal могла бы вернуть значение объекта, а затем позволить другому потоку изменить его до того, как первый поток успеет что-то сделать с этим значением. Я предполагаю, что вызывающий поток получает значение объекта для того, чтобы как-то изменить его. Поэтому функции GetVal требуют от вызывающего потока охраны доступа к данным. Определив, что данные охраняются, функции GetVal возвращают текущее значение.
Эти функции идентичны с тем исключением, что одна из них манипулирует константной версией объекта. Благодаря зтому Вы можете без проблем писать код, работающий как с константными, так и с неконстантными данными.
Третья невиртуальная защищенная функция-член — SetVal. Желая модифицировать данные, любая функция-члсн производного класса должна защитить доступ к этим данным, а потом вызвать функцию SetVal. Как и GetVal, функция SetVal сначала проводит отладочную проверку, чтобы убедиться, не забыл ли код производного класса защитить доступ к данным. Затем SetVal проверяет, действительно ли данные изменяются. Если да, SetVal сохраняет старое значение, присваивает объекту новое значение и вызывает виртуальную защищенную функцию-члеи OnValChanged, передавая ей оба значения. В классе CInterlockedType последняя функция реализована так, что она ничего не делает. Вы можете использовать эту функцию-член для того, чтобы расширить возможности своего производного класса, но об этом мы поговорим, когда дойдем до рассмотрения класса CWhenZero.
До сих пор речь шла в основном об абстрактных классах и концепциях. Теперь посмотрим, как пользоваться этой архитектурой на благо всего человечества. Я представлю Вам CInterlockedScalar — класс шаблона, производный от CInterlockedType. С его помощью Вы сможете создавать безопасные в многопоточной среде скалярные (простые) типы данных — байт, символ, l6-, 32- или 64-битное целое, вещественное значение (с плавающей точкой) и т. д. Поскольку CInterlockedScalar является производным от класса CInrerlockedType, у него нет собственных элементов данных. Конструктор CInterlockedScalar просто обращается к конструктору CInterlockedType, передавая ему начальное значение объекта скалярных данных Класс CInterlockedScalar работает только с числовыми значениями, и в качестве начального значения я выбрал нуль, чтобы наш объект всегда создавался в известном состоянии. Ну а деструктор класса CInterlockedScalar вообще ничего не делает.
Остальные функции-члены класса CInterlockedScalar отвечают за изменение скалярного значения. Для каждой операции над ним предусмотрена отдельная функция член. Чтобы класс CInterlockedScalar мог безопасно манипулировать своим объектом данных, все функции-члены псрсд выполнением какой-либо операции блокируют доступ к этому объекту. Функции очень просты, и я не стану подробно объяснять их; просмотрев исходный код, Вы сами поймете, что они делают. Однако я покажу, как пользоваться этими классами. В следующем фрагменте кода объявляется безопасная в многопоточной среде переменная типа BYTE и над ней выполняется серия операций:
CInterlockedScalar<BYTE> b = 5, // безопасная переменнан типа BYTE
BYTE b2 = 10; // небезопасная переменная типа BYTF
b2 = b++; // b2=5, b=6
b *= 4; // b=24
b2 = b, // b2=24, b=24
b += b; // b=48
b %= 2; // b=0
Работа с безопасной скалярной переменной также проста, как и с небезопасной. Благодаря замещению (перегрузке) операторов в С++ даже код в таких случаях фактически одинаков! С помощью С++-классов, о которых я уже рассказал, любую небезопасную переменную можно легко превратить в безопасную, внеся лишь минимальные изменения в исходный код своей программы.
Проектируя все эти классы, я хотел создать объект, чье поведение было бы противоположно поведению семафора. Эту функциональность предоставляет мой C++ класс CWhenZero, производный от CInrerlockedScaIar Когда скалярное значение равно 0, объект CWhenZero пребывает в свободном состоянии, а когда оно не равно 0 — в занятом.
Как Вам известно, С++-объекты не поддерживают такие состояния — в них могут находиться только объекты ядра. Значит, в CWhenZero нужны дополнительные элементы данных с описателями объектов ядра "событие". Я включил в объект CWhenZero два элемента данных: m_hevtZero (описатель объекта ядра "событие", переходящего в свободное состояние, когда объект данных содержит нулевое значение) и m_hevtNotZero (описатель объекта ядра "событие", переходящего в свободное состояние, когда объект данных содержит ненулевое значение).
Конструктор CWhenZero принимает начальное значение для объекта данных, а также позволяет указать, какими должны быть объекты ядра "событие" — со сбросом вручную (по умолчанию) или с автосбросом. Далее конструктор, вызывая CreateEvent, создает два объекта ядра "событие" и переводит их в свободное или занятое состоя ние в зависимости от того, равно ли нулю начальное значение. Деструктор CWhenZero просто закрывает описатели этих двух объектов ядра. Поскольку CWhenZero открыто наследует от класса CInterlockedScalar, все функции-члены перегруженного опера тора доступны и пользователям объекта CWhenZero.
Помните защищенную функцию-член OnValChanged, объявленную внутри класса CInterLockedType. Так вот, класс CWhenZero замещает эту виртуальную функцию. Она отвечает за перевод объектов ядра "событие" в свободное или занятое состояние в соответствии со значением объекта данных. OnValChanged вызывается при каждом изменении этого значения. Ее реализация в CWhenZero проверяет, равно ли нулю новое значение. Если да, функция устанавливает событие m_hevtZero и сбрасывает событие m_hevtNotZero.
Нет — все делается наоборот. Теперь, если Вы хотите, чтобы поток ждал нулевого значения объекта данных, от Вас требуется лишь следующее:
CWhenZero<BYTE> b = 0; // безопасная переменная типа BYTE
// немедленно возвращает управление, так как b равна 0
WaitForSingleObject(b, INFINITE);
b = 5;
// возвращает управление, только если другой поток присваивает D нулевое значение
WaitForSingleObject(b, INFINITE);
Вы можете вызывать WaitForSingleObject именно таким образом, потому что класс CWhenZero включает и функцию-член оператора приведения, которая приводит объект CWhenZero к типу HANDLE объекта ядра. Иначе говоря, передача С++-объекта CWhenZero любой Windows-функции, ожидающей HANDLE, приводит к автоматическому вызову функции-члена оператора приведения, возвращаемое значение которой и передастся Windows-функции. В данном случае эта функция-член возвращает описатель объекта ядра "событие" m_hevtZero.
Описатель события m_hevtNotZero внутри класса CWhenZero позволяет писать код, ждущий ненулевого значения объекта данных. К сожалению, в класс нельзя включить второй оператор приведения HANDLE — для получения описателя m_hevtNotZero. Поэтому мне пришлось добавить функцию-член GetNotZeroHandle, которая используется так.
CWhenZero<BYTE> b = 5; // безопасная переменная типа BYTE
// немедленно возвращает управление, так как b не равна 0
WaitForSingleObject(b GetNotZeroHandle(), INFINITE);
b = 0,
// возвращает управление, только если другой поток присваивает b ненулевое значение WaitForSingleObject(b.GetNotZeroHandle(), INFINITE);
Создание объекта ядра
Когда процесс инициализируется в первый paз, таблица описателей еще пуста. Но стоит одному из его потоков вызвать функцию, создающую объект ядра (например, CreateFtleMapping), как ядро выделяет для этого объекта блок памяти и инициализирует его, далее ядро просматривает таблицу описателей, принадлежащую данному процессу, и отыскивает свободную запись. Поскольку таблица еще пуста, ядро обнаруживает структуру с индексом 1 и инициализирует ее. Указатель устанавливается на внутренний адрес структуры данных объекта, маска доступа — на доступ без ограничений и, наконец, определяется последний компонент — флаги (О флагах мы поговорим позжс, в разделе о наследовании.)
Вот некоторые функции, создающие объекты ядра (список ни в коей мере на полноту не претендует):
HANDLE CreateThread(
PSECURITY_ATTRIBUTES psa,
DWORD dwStackSize,
PTHREAD_START_ROUTINE pfnStartAddr,
PVOID pvParam,
DWORD dwCreationFlags,
PDWORD pdwfhreadId);
HANDEE CreateFile(
PCTSTR pszFileName,
DWORD dwDesiredAccebS,
DWORD dwShareMode,
PSECURITY_ATTRIBUTES psa,
DWORD dwCreationDistribution,
DWORD dwFlagsAndAttnbutes,
HANDEE hTemplateFile);
HANDLE CreateFileMapping(
HANDLE hFile,
PSECURITY_ATTRIBUTES psa,
DWORD flProtect,
DWORD dwMdximumSizcHigh,
DWORD dwMaximumSizeLow,
PCTSTR pszName);
HANDLE CreateSemaphore(
PSECURITY_ATTRIBUTES psa,
LONG lInitialCount,
LONG lMaximumCount,
PCTSTR pszName);
Все функции, создающие объекты ядра, возвращают описатели, которые привязаны к конкретному процессу и могут быть использованы в любом потоке данного процесса. Значение описателя представляет собой индекс в таблице описателей, принадлежащей процессу, и таким образом идентифицирует место, где хранится информация, связанная с объектом ядра. Вот поэтому при отладке своего приложения и просмотре фактического значения описателя объекта ядра Вы и видите такие малые величины: 1, 2 и т. д. Но помните, что физическое содержимое описателей не задокументировано и может быть изменено. Кстати, в Windows 2000 это значение определяет, по сути, не индекс, а скорее байтовое смещение нужной записи от начала таблицы описателей.
Всякий раз, когда Вы вызываете функцию, принимающую описатель объекта ядра как аргумент, Вы передаете ей значение, возвращенное одной из Create-функций. При этом функция смотрит в таблицу описателей, принадлежащую Вашему процессу, и считывает адрес нужного объекта ядра.
Если Вы передаете неверный индекс (описатель), функция завершается с ошибкой и GetLastError возвращает 6 (ERROR_INVALID_HANDLE). Это связано с тем, что на самом деле описатели представляют собой индексы в таблице, их значения привязаны к конкретному процессу и недейовительны в других процессах.
Если вызов функции, создающей объект ядра, оказывается неудачен, то обычно возвращается 0 (NULL). Такая ситуация возможна только при острой нехватке памяти или при наличии проблем с защитой. К сожалению, отдельные функции возвращают в таких случаях пе 0, а -1 (INVALID_HANDLE_VALUE) Например, если CreateFile не сможет открыть указанный файл, она вернет именно INVALID_HANDLE_VALUE. Будьте очень осторожны при проверке значения, возвращаемого функцией, которая создает объект ядра. Так, для CreateMutex проверка на INVALID_HANDlE_VALUE бессмысленна:
HANDLE hMutex = CreateMutex(...);
if (hMutex == lNVALID_HANDLE_VALUE) {
// этот код никогда не будет выполнен, так как
// при ошибке CreateMutex возвращает NLlLL
}
Точно так же бессмыслен и следующий код:
HANDIE hFile = CreateFile(.. );
if (hFile — NULL} {
// и этот код никогда не будет выполнен, так как
// при ошибке CreateFile возвращает lNVALID_HANDLE_VALUE (-1)
}
Создание оболочки вокруг функции потока
Другой способ состоит в том, что Вы помещаете входную функцию первичного по тока (main, wmain, WinMain или wWinMairi) в блок try-except. Фильтр исключений дол жен всегда возвращать EXCEPTION_EXECUTE_HANDLER, чтобы исключение действи тельно обрабатывалось; это предотвратит вызов UnbandledExceptionFilter.
В обряботчике исключений Вы выводите на экран диалоговое окно с какой-ни будь диагностической информацией. Пользователь может скопировать эту информа цию и передать ее в службу технической поддержки Вашего приложения, что помо жет выявить источник проблем. Это диалоговое окно надо разработать так, чтобы пользователь мог завершить приложение, но не отлаживать.
Этому способу присущ один недостаток: он позволяет перехватывать только те исключения, которые возникают в первичном потоке Если исключение происходит в любом другом потоке процесса, система вызывает функцию UnhandledExceptionFilter. Чтобы вывернуться из этой ситуации, придется также включить блоки try-except во входные функции всех вторичных потоков Вашего процесса.
Создание оболочки вокруг всех функций потоков
Функция SetUnhandledExceptionFilter позволяет включать все функции потоков в SEH фрейм:
PTOP_LEVEL_EXCEPTION_FILTER SetUnhandledExceptionFilter( PTOP_LEVEL_EXCEPTION_FILTER pTopLevelExceptionFilter);
После ее вызова необработанное исключение, возникшее в любом из потоков процесса, приведет к вызову Вашего фильтра исключений Адрес фильтра следует передать в единственном параметре функции SetUnhandledExceptionFilter. Прототип этой функции-фильтра должен выглядеть так:
LONG UnhandledExceptionFilter(PEXCEPTION_POINTCRS pExceptionInfo);
По форме она идентична функции UnhandledExceptionFilter Внутри фильтра мож но проводить любую обработку, а возвращаемым значением должен быть один из трех идентификаторов типа EXCEPTION_*. В следующей таблице описано, что происходит в случае возврата каждого из идентификаторов.
Идентификатор |
Действие | ||
EXCEPTION_EXECUTE_HANDLER |
Процесс просто завершается, так как система не выполняет никаких операций в своем обработчи ке исключений | ||
EXCEPTION_CONTINUE_EXECUTION |
Выполнение продолжается с инструкции, выгнав шей исключение; Вы можете модифицировать ин формацию об исключении, па которую указывает параметр типа PEXCEPTION_POINTERS | ||
EXCEPTION_CONTINUE_SEARCH |
Выполняется обычная Windows-функция UnhandledExceptionFilter |
Чтобы функция UnhandledExceptionFilter вновь стала фильтром по умолчанию, вызовите SetUnhandledExceptinnFilter со значением NULL, Заметьте также, что всякий раз, когда устанавливается новый фильтр для необработанных исключений, SetUnhandledExceptionFilter возвращает адрес ранее установленного фильтра. Если таким фильтром была UnhandledExceptionFilter, возвращается NULL. Если Ваш фильтр возвра щает EXCEPTION_CONTINUE_SEARCH, Вы должны вызывать ранее установленный фильтр, адрес которого вернула SetUnbandledExceptionFilter.
Создание программ, способных использовать и ANSI, и Unicode
Неплохая мысль — заранее подготовить свое приложение к Unicode, даже если Вы пока не планируете работать с этой кодировкой. Вот главное, что для этого нужно:
привыкайте к тому, что текстовые строки — это массивы символов, а не массивы байтов или значений типа char; используйте универсальные типы данных (вроде TCHAR или PTSTR) для текстовых символов и строк; используйте явные типы данных (вроде BYTE или PBYTE) для байтов, указателей на байты и буферов данных; применяйте макрос _TEXT для определения символьных и строковых литералов; предусмотрите возможность глобальных замен (например, PSTR на PTSTR); модифицируйте логику строковой арифметики. Например, функции обычно принимают размер буфера в символах, а не в байтах. Это значит, что вместо sizeof(szBuffer) Вы должны передавать (sizeof(szBuffer) / sizeof(TCHAR)). Но блок памяти для строки известной длины выделяется в байтах, а не символах, т. e. вместо malloc(nCharacters) нужно использовать malloc(nCbaracters *sizeof(TCHAR)) Из всего, что я перечислил, это запомнить труднее всего — если Вы ошибетесь, компилятор не выдаст никаких предупреждений.
Разрабатывая программы-примеры для первого издания книги, я сначала написал их так, что они компилировались только с использованием ANSI. Но, дойдя до этой главы (она была тогда в конце), понял, что Unicode лучше, и решил написать примеры, которые показывали бы, как легко создавать программы, компилируемые с применением и Unicode, и ANSI. B конце концов я преобразовал все программы-примеры так, чтобы их можно было компилировать в расчете на любой из этих стандартов.
Конверсия всех программ заняла примерно 4 часа — неплохо, особенно если учесть, что у меня совсем не было опыта в этом деле.
В Windows есть набор функций для работы с Unicode-строками. Эти функции перечислены ниже.
Функция | Описание | ||
lstrcat | Выполняет конкатенацию строк | ||
lstrcmp | Сравнивает две строки с учетом регистра букв | ||
lstrcmpi | Сравнивает две строки без учета регистра букв | ||
lstrcpy | Копирует строку в другой участок памяти | ||
lstrlen | Возвращает длину строки в символах |
Они реализованы как макросы, вызывающие либо Unicode-, либо ANSI-версию функции в зависимости от того, определен ли UNICODE при компиляции исходного модуля Например, если UNICODE не определен, lstrcat раскрывается в lstrcatA, определен — в lstrcatW.
Строковые функции lstrcmp и lstrcmpi ведут себя не так, как их аналоги из библиотеки С (strcmp, strcmpi, wcscmp и wcscmpf), которые просто сравнивают кодовые позиции в символах строк. Игнорируя фактические символы, они сравнивают числовое значение каждого символа первой строки с числовым значением символа второй строки. Но lstrcmp и lstrcmpi реализованы через вызовы Windows-функции CompareString:
int CompareString(
LCID lcid,
DWORD fdwStyle,
PCWSTR pString1,
int cch1,
PCWSTR pString2,
int cch2);
Она сравнивает две Unicode-строки. Первый параметр задаст так называемый идентификатор локализации (locale ID, LCID) — 32-битное значение, определяющее конкретный язык. С помощью этого идентификатора CompareString сравнивает строки с учетом значения конкретных символов в данном языке. Так что она действует куда осмысленнее, чем функции библиотеки С.
Когда любая из функций семейства lstrcmp вызывает CompareString, в первом параметре передается результат вызова Windows-функции GetThreadLocale.
LCID GetThreadLocale();
Она возвращает уже упомянутый идентификатор, который назначается потоку в момент его создания.
Второй параметр функции CompareString указывает флаги, модифицирующие метод сравнения строк. Допустимые флаги перечислены в следующей таблице.
Флаг | Действие |
NORM_IGNORECASE | Различия в регистре букв игнорируются |
NORM_IGNOREKANATYPE | Различия между знаками хираганы и катаканы игнорируются |
NORM_IGNORENONSPACE | Знаки, отличные от пробелов, игнорируются |
NORM_IGNORESYMBOLS | Символы, отличные от алфавитно-цифровых, игнорируются |
NORM_IGNOREWIDTH |
Разница между одно- и двухбайтовым представлением одного и того же символа игнорируется |
SORT_STRINGSORT |
Знаки препинания обрабатываются так же, как и символы, от- личные от алфавитно-цифровых |
Вызывая CompareString, функция lstrcmp передает в параметре fdwStyle нуль, а lstrcmpi — флаг NORM_IGNORECASE. Остальные четыре параметра определяют две строки и их длину. Если cch1 равен -1, функция считает, что строка pString2 завершается нулевым символом, и автоматически вычисляет ее длину. То же относится и к параметрам cch2 wpString2.
Многие функции С-библиотеки с Unicode-строками толком не работают. Так, tolower и toupper неправильно преобразуют регистр букв со знаками ударения. Поэтому для Unicode-строк лучше использовать соответствующие Windows-функции. К тому же они корректно работают и с ANSI-строками.
Первые две функции:
PTSTR CharLower(PTSTR pszStnng);
PTSTR CharUpper(PTSTR pszString);
преобразуют либо отдельный символ, либо целую строку с нулевым символом в конце. Чтобы преобразовать всю строку, просто передайте ее адрес. Но, преобразуя отдельный символ, Вы должны передать его так:
TCHAR cLowerCaseCnr = CharLower((PTSTR) szString("O"));
Приведение типа отдельного символа к PTSTR вызывает обнуление старших 16 битов передаваемого значения, а в его младшие 16 битов помещается сам символ. Обнаружив, что старшие 16 битов этого значения равны 0, функция поймет, что Вы хотите преобразовать не строку, а отдельный символ. Возвращаемое 32-битное зна чение содержит результат преобразования в младших 16 битах.
Следующие две функции аналогичны двум предыдущим за исключением того, что они преобразуют символы, содержащиеся в буфере (который не требуется завершать нулевым символом).
DWORD CharLowerBuff(
PTSTR pszString,
DWORD cchString);
DWORD CharUppprRuff(
PTSTR pszString,
DWORD cchString);
Прочие функции библиотеки С (например, isalpba, islowern isupper) возвращают значение, которое сообщает, является ли данный символ буквой, а также строчная она или прописная. В Windows API тоже есть подобные функции, но они учитывают и язык, выбранный пользователем в Control Panel:
BOOL IsCharAlpha(TCHAR ch);
BOOL IsCharAlphaNumeric(TCHAR ch);
BOOL IsCharLower(TCHAR oh);
BOOL IsCharUpper(TCHAR ch);
И последняя группа функций из библиотеки С, о которых я хотел рассказать, — prmtf. Если при компиляции _UNICODE определен, они ожидают передачи всех символьных и строковых параметров в Unicode; в ином случае — в ANSI.
Microsoft ввела в семейство фупкций printf своей С-библиотеки дополнительные типы полей, часть из которых не поддерживается в ANSI C. Они позволяют легко сравнивать и смешивать символы и строки с разной кодировкой. Также расширена функция wsprintf операционной системы. Вот несколько примеров (обратите внимание на использование буквы s в верхнем и нижнем регистре):
char szA[100]; // строковый буфер e ANSI
WCHAR szW[100]; // строковый буфер в Unicode
// обычный вызов sprintf: все строки в ANSI
sprintf(szA, "%s", "ANSI Str");
// преобразуем строку из Unicode в ANSI
sprintf(szA, "%S", "Unicode Str");
// обычный вызов swprintf. все строки в Unicode
swprintf(szW, L"%s", L"Unicode Str");
// преобразуем строку из ANSI в Unicode
swprintf(s/W, L"%S", "ANSI Str");
Специальные флаги атрибутов защиты
Кроме рассмотренных атрибутов защиты, существует три флага атрибутов защиты PAGE_NOCACHE, PAGE_WRITECOMBINE и PAGE_GUARU. Они комбинируются с любыми атрибутами защиты (кроме PAGE_NOACCESiS) побитовой операцией OR.
Флаг PAGE_NOCACHE отключает кэширование переданных страниц. Как правило, использовать этот флаг не рекомендуется; он предусмотрен главным образом для разработчиков драйверов устройств, которым нужно манипулировать буферами памяти.
Флаг PAGE_WRITECOMBINE тоже предназначен для разработчиков драйверов ycтройств. Он позволяет объединять несколько операций записи на устройство в одип пакет, что увеличивает скорость передачи данных
Флаг PAGE_GUARD позволнет приложениям получать уведомление (через механизм исключений) в тот момент, когда на страницу записывается какой-нибудь баит Window.s 2000 использует этот флаг при создании стека потока. Подробнее на эту тему см. раздел "Стек потока" в главе 16.
WINDOWS 98
Windows 98 игнорирует флаги атрибутов защиты PAGE_NOCACHE, PAGE_WRITECOMBINE и PAGE_GUARD
Стандартная куча процесса
При инициализации процесса система создает в его адресном пространстве стандартную кучу (process's default heap). Ее размер по умолчанию — 1 Мб. Но система позволяет увеличивать этот размер, для чего надо указать компоновщику при сборке программы ключ /HEAP (Однако при сборке DLL этим ключом пользоваться нельзя, так как для DLL куча не создается.)
/HEAP:reserve[,commit]
Стандартная куча процесса необходима многим Windows-функциям. Например, функции ядра Windows 2000 выполняют все операции с использованием Unicode символов и строк. Если вызвать ANSI-версию какой-нибудь Windows-функции, ей придется, преобразовав строки из ANSI в Unicode, вызывать свою Unicode-версию. Для преобразования строк ANSI-функции нужно выделить блок памяти, в котором она размещает Unicode-версию строки. Этот блок памяти заимствуется из стандартной кучи вызывающего процесса. Есть и другие функции, использующие временные блоки памяти, которые тоже выделяются из стандартной кучи процесса. Из нее же черпают себе память и функции 16~разрядной Windows, управляющие кучами (LocalAlloc и GlobalAlloc).
Поскольку стандартную кучу процесса используют многие Windows-функции, а потоки Вашего приложения могут одновременно вызвать массу таких функций, доступ к этой куче разрешается только по очереди. Иными словами, система гарантирует, что в каждый момент времени только один поток сможет выделить или освободить блок памяти в этой куче. Если же два потока попытаются выделить в ней блоки памяти одновременно, второй поток будет ждать, пока первый поток не выделит свой блок. Принцин последовательного доступа потоков к кучс немного снижает производительность многопоточной программы. Если в программе всего один поток, для быстрейшего доступа к куче нужно создать отдельную кучу и не использовать стандартную. Но Windows-функциям этого, увы, не прикажешь — они работают с кучей только последнего типа.
Как я уже говорил, куч у одного процесса может быть несколько. Они создаются и разрушаются в период его существования. Но стандартная куча процесса создается в начале его исполнения и автоматически уничтожается по его завершении — сами уничтожить ее Вы не можете. Каждую кучу идентифицирует своЙ описатель, и все Windows-функции, которые выделяют и освобождают блоки в ее пределах, требуют передавать им этот описатель как параметр.
Описатель стандартной кучи процесса возвращает функция GеtProcessHeap.
HANDLE GelProcessHeap();
Статическая локальная память потока
Статическая локальная память потока основана на той же концепции, что и динамическая, — она предназначена для того, чтобы с потоком можно было сопоставить те или иные данные. Однако статическую TLS использовать гораздо проще, так как при этом не нужно обращаться к каким-либо функциям.
Возьмем такой пример. Вы хотите сопоставлять стартовое время с каждым потоком, создаваемым программой. В этом случае нужно лишь объявить переменную для хранения стартового времени:
__declspec(thread) DWORD gt_dwStartTime = 0;
Префикс _dectepec(thread) - модификатор, поддерживаемый компилятором Microsoft Visual C++. Он сообщает компилятору, что соответствующую переменную следует поместить в отдельный раздел EXE- или DLL-файла. Переменная, указываемая за __dectepec(thread), должна быть либо глобальной, либо статической внутри (или вне) функции. Локальпую переменную с модификатором __declspec(thread) объявить нельзя. Но это не должно Вас беспокоить, ведь локальные переменные и так связаны с конкретным потоком. Кстати, глобальные TLS-переменные я помечаю префиксом gt_, а статические — sf_.
Обрабатывая программу, компилятор выносит все TLS-переменные в отдельный раздел, и Вы вряд ли удивитесь, что этому разделу присваивается имя .tls. Компоновщик объединяет эти разделы из разных объектных модулей и создаст в итоге один большой раздел .tls, помещаемый в конечный EXE- или DLL-файл.
Работа статической TLS строится на тесном взаимодействии с операционной системой. Загружая приложение в память, система отыскивает в ЕХЕ-файле раздел .tls и динамически выделяет блок памяти для хранения всех статических TLS-переменных. Всякий раз, когда Ваша программа ссылается на одну из таких переменных, ссылка переадресуется к участку, расположенному в выделенном блоке памяти. В итоге компилятору приходится генерировать дополнительный код для ссылок на статические TLS-переменные, что увеличивает размер приложения и замедляет скорость его работы. В частности, на процессорах x86 каждая ссылка на статическую TLS-переменную заставляет генерировать три дополнительные машинные команды.
Если в процессе создается другой поток, система выделяет еще один блок памяти для хранения статических переменных нового потока. Только что созданный поток имеет доступ лишь к своим статическим TLS-переменным, и не может обратиться к TLS-переменным любого другого потока.
Вот так в общих чертах и работает статическая TLS-память. Теперь посмотрим, что происходит при участии DLL. Ведь скорее всего Ваша программа, использующая статические TLS-персменные, связывается с какой-нибудь DLL, в которой тоже применяются переменные этого типа. Загружая такую программу, система сначала определяет объем ее раздела .rts, а затем добавляет эту величину к сумме размеров всех разделов .tls, содержащихся в DLL, которые связаны с Вашей программой. При создании потоков система автоматически выделяет блок памяти, достаточно большой, чтобы в нем уместились все TLS-переменные, необходимые как приложению, так и неявно связываемым с ней DLL. Все так хорошо, что даже не верится!
И не верьте! Подумайте, что будет, если приложение вызовет LoadLibrary и подключит DLL, тоже содержащую статические TLS-переменные. Системе придется проверить потоки, уже существующие в процессе, и увеличить их блоки TLS-памяти, чтобы подогнать эти блоки под дополнительные требования, предъявляемые новой DLL. Ну а если Вы вызовете FreeLibrary для выгрузки DLL со статическими TLS-переменными, системе придется ужать блоки памяти, сопоставленные с потоками в данном процессе.
Это слишком большая нагрузка на операционную систему. Кроме того, допуская явную загрузку DLL, содержащих статические TLS-перемснные, система не в состоянии должным образом инициализировать TLS-данные, что при попытке обращения к ним может вызвать нарушение доступа. Это, пожалуй, единственный недостаток статической TLS; при использовании динамической TLS такой проблемы нет. DLL, работающие с динамической TLS, могут загружаться и выгружаться из выполняемой программы в любой момент и без всяких проблем.
Статические данные не разделяются несколькими экземплярами EXE или DLL
Когда Вы создаете новый процесс для уже выполняемого приложения, система про сти открывает другое проецируемое в память представление (view) объекта "проек ция файла" (file-mapping object), идентифицирующего образ исполняемого файла, и создает новые объекты "процесс" и "поток" (для первичного потока) Этим объектам присваиваются идентификаторы процесса и потока. С помощью проецируемых в память файлов несколько одновременно выполняемых экземпляров приложения мо жет совместно использовать один и тот же код, загруженный в оперативную память. Здесь возникает небольшая проблема. Процессы используют линейное (flat) ад ресное пространство. При компиляции и компоновке программы весь ее код и дан ные объединяются в нечто, так сказать, большое и цельное Данные, конечно, отделе ны от кода, но только в том смысле, что они расположены вслед за кодом в ЕХЕ-фай ле<snoska На самом деле содержимое файла разбито на отдельные разделы (sections). Код находится в одном разделе, а глобальные переменные — в другом Разделы выравниваются по грани цам страниц Приложение определяет размер страницы через функцию GetSystemInfo. В EXE- или DLL-флйле раздел кода обычно предшествует разделу данных.
>. Вот упрощенная иллюстрация того, как код и данные приложения загружаются в виртуальную память, а затем отображаются на адресное пространство процесса:
Теперь допустим, что запущен второй экземпляр программы. Система просто-на просто проецирует страницы виртуальной памяти, содержащие код и данные файла, на адресное пространство второго экземпляра приложения:
Если один экземпляр приложения модифицирует какие-либо глобальные перемен ные, размещенные на странице данных, содержимое памяти изменяется для всех эк земпляров этого приложения. Такое изменение могло бы привести к катастрофичес ким последствиям и поэтому недопустимо.
Система предотвращает подобные ситуации, применяя механизм копирования при записи. Всякий раз, когда программа пытается записывать что-то в файл, спрое цированный в память, система перехватывает эту попытку, выделяет новый блок па мяти, копирует в него нужную программе страницу и после этого разрешает запись в новый блок памяти.
Благодаря этому работа остальных экземпляров программы пе нарушается. Вот что получится, когда первый экземпляр программы попытается из менить какую-нибудь глобальную переменную на второй странице данных:
Система выделяет новую страницу и копирует на нее содержимое страницы дан ных 2. Адресное пространство первого экземпляра изменяется так, чтобы отобразить новую страницу данных на тот же участок, что и исходную. Теперь процесс может изменить глобальную переменную, не затрагивая данные другого экземпляра .
Аналогичная цепочка событий происходит и при отладке приложения. Например, запустив несколько экземпляров программы, Вы хотите отладить только один из них. Вызвав отладчик, Вы ставите в строке исходного кода точку прерывания. Отладчик модифицирует Ваш код, заменяя одну из команд на языке ассемблера другой — зас тавляющей активизировать сам отладчик. И здесь Вы сталкиваетесь с той же пробле мой. После модификации кода все экземпляры программы, доходя до исполнения измененной команды, приводили бы к его активизации. Чтобы этого избежать, сис тема вновь использует копирование при записи. Обнаружив попытку отладчика из менить код, она выделяет новый блок памяти, копирует туда нужную страницу и по зволяет отладчику модифицировать код на этой копии.
WINDOWS 98
При загрузке процесса система просматривает все страницы образа файла. Физическая память из страничного файла передается сразу только тем стра ницам, которые должны быть защищены атрибутом копирования при записи. При обращении к такому участку образа файла в память загружается соответ ствующая страница. Если cc модификации не происходит, она может быть выгружена из памяти и при необходимости загружена вновь. Если же страни ца файла модифицируется, система перекачивает ее на одну из ранее передан ных страниц в страничном файле.
Поведение Windows 2000 и Windows 98 в подобных случаях одинаково, кроме ситуации, когда в память загружено два экземпляра одного модуля и никаких данных не изменено. Тогда процессы под управлением Windows 2000 могут совместно использовать данные, а в Windows 98 каждый процесс полу чает свою копию этих данных.Но если в память загружен лишь один экземп ляр модуля или же данные были модифицированы (что чаще всего и бывает), Windows 2000 и Windows 98 ведут себя одинаково.
Статические данные разделяются несколькими экземплярами EXE или DLL
По умолчанию для большей безопасности глобальные и статические данные нс разделя ются несколькими проекциями одного и того же EXE или DLL. Но иногда удобнее, чтобы несколько проекций EXE разделяли единственный экземпляр переменной. Например, в Windows не так-то просто определить, запущено ли несколько экземп ляров приложения. Если бы у Вас была переменная, доступная всем экземплярам при ложения, она могла бы отражать число этих экземпляров Тогда при запуске нового экземпляра приложения его поток просто проверил бы значение глобальной пере менной (обновленное другим экземпляром приложения) и, будь оно больше 1, сооб щил бы пользователю, что запустить можно лишь один экземпляр; после чего эта копия приложения была бы завершена.
В этом разделе мы рассмотрим метод, обеспечивающий совместное использова ние переменных всеми экземплярами EXE или DLL. Но сначала Вам понадобятся кое какие базовые сведения.
Любой образ EXE- или DLL-файла состоит из группы разделов. По соглашению имя каждого стандартного раздела начинается с точки Например, при компиляции про граммы весь код помещается в раздел .text, неинициализированные данные - в раз дел .bss, а инициализированные — в раздел .data.
С каждым разделом связана одна из комбинаций атрибутов, перечисленных в сле дующей таблице.
Атрибут | Описание | ||
READ | Разрешает чтение из раздела | ||
WRITE | Разрешает запись в раздел | ||
EXECUTЕ | Содержимое раздела можно исполнять | ||
SHARED | Раздел доступен нескольким экземплярам приложения (этот атрибут отклю чает механизм копирования при записи) |
Запустив утилиту DumpBin из Microsoft Visual Studio (c ключом /Headers), Вы уви дите список разделов в файле образа EXE или DLL Пример такого списка, показан ный ниже, относится к ЕХЕ-файлу.
SECTION HEADER #1 text name 11A70 virtual size 1000 virtual address 12000 size of raw data 1000 file pointer to raw data
0 file pointer to relocation table 0 file pointer to line numbers 0 number of relocations 0 number of line numbers 60000020 flags Code Execute Read
SECTION HEADER #2 rdata name
1F6 virtual size
13000 virtual address 1000 size of raw data 13000 file pointer to raw data
0 file poinLer lo relocation tabie 0 file pointer to line numbers 0 number ot relocations 0 number of line numbers 40000040 flags
Initialized Data Read Only
SECTION HEADER #3 .data name
560 virtual size 14000 virtual address 1000 size of raw data 14000 file pointer to raw data
0 filc pointer to relocation table 0 file pointer to line numbers 0 number of relocations 0 number of line numbers C0000040 flags
Initialized Data Read Write
SECTION HtADER #4 .idata name
58D virtual size 15000 virtual address 1000 size of raw data 15000 file pointer to raw data
0 file pointer to relocation table 0 file pointer to line numbers 0 number of relocations 0 number of line numbers C0000040 flags
Initialized Data Read Write
SECTION HEADER #5 .didat name
7A2 vi rtual size 16000 virtual address 1000 size of raw data 16000 file pointer to raw data
0 file pointer to relocation table 0 file pointer to line numbers 0 number of relocations 0 number of line numbers C0000040 flags
Initialized Data Read Write
SECTION HEADER #6 .reloc name
26D virtual size 17000 virtual address 1000 size of raw data 17000 file pointer to raw data
0 file pointer to relocation table 0 file pointer to line numbers 0 number of relocations 0 number of line numbers 42000040 flags
Initialized Data Discardable Read Only
Summary
1000 data 1000 didat 1000 idata 1000 rdata 1000 .reloc 12000 text
Некоторые из часто встречающихся разделов перечислены в таблице ниже
Имя раздела | Описание |
bss | Неинициализированные данные |
CRT | Неизменяемые данные библиотеки С |
data | Инициализированные данные |
.debug | Отладочная информация |
.didat | Таблица имен для отложенного импорта (delay imported names table) |
edata | Таблица экспортируемых имен |
idata | Таблица импортируемых имен |
.rdata | Неизменяемые данные периода выполнения |
.reloc | Настроечная информация — таблица переадресации (relocation table) |
.rsrc | Ресурсы |
.text | Код ЕХЕ или DLL |
.tls | Локальная память потока |
.xdata | Таблица для обработки исключений |
Кроме стандартных разделов, генерируемых компилятором и компоновщиком, можно создавать свои разделы в EXE- или DLL-файле, используя директиву компи лятора:
#pragma data_seg("имя_раздела")
Например, можно создать раздел Shared, в котором содержится единственная пе ременная типа LONG:
#pragma data_seg("Shared") LONG g_lInstanceCount = 0;
#pragma data_seg()
Обрабатывая этот код, компилятор создаст раздел Shared и поместит в него все инициализированные переменные, встретившиеся после директивы #pragma. В нашем примере в этом разделе находится переменная g_lInstanceCount. Директива #pragma data_seg() сообщает компилятору, чти следующие за ней переменные нужно вновь помещать в стандартный раздел данных, а нс в Shared. Важно помнить, что компиля тор помещает в новый раздел только инициализированные переменные. Если из пре дыдущего фрагмента кода исключить инициализацию переменной, она будет вклю чена в другой раздел:
#pragma data_seg("Shared") LONG g_lInslanceCount;
#pragma data_seg()
Однако в компиляторе Microsoft Visual C++ 6.0 предусмотрен спецификатор allo cate, который позволяет помещать неинициализированные данные в любой раздел. Взгляните на этот код:
// создаем раздел Shared и заставляем компилятор
// поместить в него инициализированные данные
#pragma data_seg("Shared")
// инициализированная переменная, по умолчанию помещается в раздел Shared
int а = 0;
// неинициализированная переменная, по умолчанию помещается в другой раздел
int b;
// сообщаем компилятору прекратить включение инициализированных данных
// в раздел Shared
#pragma data_seg()
// инициализированная переменная, принудительно помещается в раздел Shared
__declspec(allocate("Shared")) int с = 0;
// неинициализированная переменная, принудительно помещается в раздел Shared
__declspec(allocate("Shared")) int d;
// инициализированная переменная, по умолчанию помещается в другой раздел
int e = 0;
// неинициализированная переменная, по умолчанию помещается в другой раздел
int f;
Чтобы спецификатор allocate работал корректно, сначала должен быть создан соответствующий раздел. Так что, убрав из предыдущего фрагмента кода первую стро ку #pragma data_seg, Вы нс смогли бы его скомпилировать.
Чаще всего переменные помещают в собственные разделы, намереваясь сделать их разделяемыми между несколькими проекциями EXE или DLL. По умолчанию каж дая проекция получает свой набор переменных. Но можно сгруппировать в отдель ном разделе переменные, которые должны быть доступны всем проекциям EXE или DLL; тогда система не станет создавать новые экземпляры этих переменных для каж дой проекции EXE или DLL.
Чтобы переменные стали разделяемыми, одного указания компилятору выделить их в какой-то раздел мало. Надо также сообщить компоновщику, что переменные в
этом разделе должны быть общими Для этого предназначен ключ /SECTION компоновщика
/SECTION:имя,атрибуты
За двоеточием укажите имя раздела, атрибуты которого Вы хотите изменить В нашем примере нужно изменить атрибуты раздела Shared, поэтому ключ должен выг лядеть так
/SECTION:Shared,RWS
После запятой мы задаем требуемые атрибуты При этом используются такие со кращения R (READ), W (WRITE), E (EXECUTE) и S(SHARED) В данном случае мы ука чали, что раздел Shared должен быть "читаемым", "записываемым" и "разделяемым" Если Вы хотите изменить атрибуты более чем у одного раздела, указывайте ключ /SECTION для каждого такого раздела
Соответствующие директивы для компоновщика можно вставлять прямо в исходный код
#pragma comment(linker, /SECTION Shared,RWS )
Эта строка заставляет компилятор включить строку "/SECTION Shared,RWS" в осо бый раздел drectve Компоновщик, собирая OBJ-модули, проверяет этот раздел в каж дом OBJ-модуле и действует так, словно все эти строки переданы ему как аргументы в командной строке Я всегда применяю этот оченъ удобный метод перемещая файл исходного кода в новый проект, не надо изменять никаких параметров в диалоговом окне Project Settings в Visual C++
Хотя создавать общие разделы можно, Microsoft нс рекомендует это делать Во первых, разделение памяти таким способом может нарушить защиту Во вторых, на личие общих переменных означает, что ошибка в одном приложении повлияет на другое, так как этот блок данных нс удастся защитить от случайной записи
Представьте, Вы написали два приложения, каждое из которых требует от пользо вателя вводить пароль При этом Вы решили чуть-чуть облегчить жизнь пользовате лю если одна из программ уже выполняется на момент запуска другой, то вторая счи тывает пароль из общей памяти Так что пользователю не пужно повторно вводить пароль, если одно из приложений уже запущено
Все выглядит вполне невинно В конце концов только Ваши приложения загружа ют данную DLL, и только они знают, где искать пароль, содержащийся в общем разде ле памяти Но хакеры не дремлют, и если им захочется узнать Ваш пароль, то макси мум, что им понадобится, — написать небольшую программу, загружающую Вашу DLL, и понаблюдать за общим блоком памяти Когда пользователь введет пароль, хакере кая программа тут же ею узнает
Трудолюбивая хакерская программа может также предпринять серию попыток угадать пароль, записывая его варианты в общую память А угадав, сможет посылать любые команды этим двум приложениям Данную проблему можно было бы решить, если бы существовал какой-нибудь способ разрешать загрузку ULL только определен ным программам Но пока эю невозможно — любая протрамма, вызвав IoadLibrary, способна явно загрузить любую DLL
Статус виртуальной памяти
Windows-функция GlobalMemoryStatus позволяет отслеживать текущее состояние па мяти
VOID GlobalMemoryStatus(LPMEMORYSTATUS pmst);
На мой взгляд, она названа крайне неудачно, имя GlobalMemorySlatus подразумевает, что функция каким-то образом связана с глобальными кучами в 16-разрядной Windows. Мне кажется, что лучше было бы назвать функцию GlobalMemoryStatus по другому — скажем, VirtualMemoryStatus.
При вызове функции GlobaUdemoryStatus Вы должны передать адрес структуры MEMORYSTATUS. Вот эта структура:
typedef struct _MEMORYSTATUS
{
DWORD dwLength;
DWORD dwMemoryLoad;
SIZE_T dwTotalPhys;
SIZE_T dwAvailPhys;
SIZE_T dwTotalPageFile;
SIZE_T dwAvailPageFile;
SIZE_T dwTotalVirtual;
SIZE_T dwAvailVirtual;
} MEMORYSTATUS, *LPMEMORYSTATUS;
Перед вызовом GlobalMemoryStatus надо записать в элемент dwLength размер структуры в байтах. Такой принцип вызова функции дает возможность Microsoft расширять эту структуру в будущих версиях Windows, не нарушая работу существующих приложений. После вызова GlobalMemoryStatus инициализирует остальные элементы струк туры и возвращает управление. Назначение элементов этой структуры Вы узнаете из следующего раздела, в котором рассматривается программа-пример VMStat.
Если Вы полагаете, что Ваше приложение будет работать на машинах с объемом оперативной памяти более 4 Гб или файлом подкачки более 4 Гб, используйте новую функцию GlobalMemoryStatusEx:
BOOL GlobalHemoryStatusEx(LPMEHORYSTATUSEX pmst);
Вы должны передать ей адрес новой структуры MEMORYSTATUSEX:
typedef struct _MEMORYSTATUSEX
{
DWORD dwLength;
DWORD dwMemoryLoad;
DWORDLONG ullTotalPhys;
DWORDLONG ullAvailPhys;
DWORDLONG ullTotalPageFile;
DWORDLONG ullAvaiIPageFile;
DWORDLONG ullTotalVirtual;
DWORDLONG uUAvailVirtual;
DWOHDLONG ullAvailExtendedVirtual;
} MEMORYSTATUSEX, *LPMEMORYSTATUSEX;
Эта структура идентична первоначальной структуре MEMORYSTATUS с одним исключением все ее элементы имеют размер по 64 бита, что позволяет оперировать со значениями, превышающими 4 Гб. Последний элемент, ullAvailExtendedVirtual, указывает размер незарезервированной памяти в самой большой области памячи виртуального адресного пространства вызывающего процесса. Этот элемент имеет смысл только для процессоров определенных архитектур при определенных конфигурациях.
В Windows 98 стеки ведут
В Windows 98 стеки ведут себя почти так же, как и в Windows 2000. Но отличия все же есть.
На рис. 16-4 показано, как в Windows 98 может выглядеть регион стека (зарезервированный с адреса 0x00530000) размером 1 Мб.
Адрес | Размер | Состояние страницы |
0x00640000 | 16 страниц (65 536 байтов) | Верхняя часть стека (зарезервирована для перехвата обращений к несуществующей области стека) |
0x0063F000 | 1 страница (4096 байтов) | Переданная страница с атрибутом PAGE_READWRITE (задействованная область стека) |
0x0063E000 | 1 страница (4096 байтов) | Страница с атрибутом PAGE_NOACCESS (заменяет флаг PAGE_GUARD) |
0x00638000 | 6 страниц (24 576 байтов) | Страницы, зарезервированные для перехвата переполнения стека |
0x00637000 | 1 страница (4096 байтов) | Переданная страница с атрибутом PAGE_READWRITE (для совместимости с 16-разрядными компонентами) |
0x00540000 | 247 страниц (1 011 712 байтов) | Страницы, зарезервированные для дальнейшего расширения стека |
0x00530000 | 16 страниц (65 536 байтов) | Нижняя часть стека {зарезервирована для перехвата переполнения стека) |
Во-первых, размер региона на самом деле 1 Мб плюс 128 Кб, хотя мы хотели создать стек объемом всего 1 Мб. В Windows 98 система резервирует под стек на 128 Кб больше, чем было запрошено. Собственно стек располагается в середине этого региона, а по обеим его границам размещаются блоки по 64 Кб каждый.
Блок перед стеком предназначен для перехвата его переполнения, а блок после стека — для перехвата обращений к несуществующим областям стека. Чтобы понять, какая польза от последнего блока, рассмотрим такой фрагмент кода:
int WINAPI WinMain(HINSTANCE hinstExe, HINSTANCE, PSTR pszCmdLine, int nGmdShow)
{
char szBuf[100];
szBuf[10000] - 0; // обращение к несуществующей области стека
return(0);
}
Когда выполняется оператор присвоения, происходит попытка обращения за конец стека потока. Разумеется, ни компилятор, ни компоновщик не уловят эту ошибку в приведенном фрагменте кода, но, если приложение работает под управлением Windows 98, выполнение этого оператора вызовет нарушение доступа.
Это одна из приятных особенностей Windows 98, отсутствующих в Windows 2000, в которой сразу за стеком потока может быть расположен другой регион. И если Вы случайно обратитесь за пределы стека, Вы можете испортить содержимое области памяти, принадлежащей другой части Вашего процесса, — система ничего не заметит.
Второе отличие: в стеке нет страниц с флагом атрибутов защиты PAGE_GUARD. Пocкoлькy Windows 98 такой флаг не поддерживает, при расширении стека потока она действует несколько иначе. Она помечает страницу переданной памяти, располагаемой под стеком, атрибутом PAGE_NOACCESS (на рис, 16-4 — по адресу 0х0063Е000). Когда поток обращается к этой странице, происходит нарушение доступа. Система перехватывает это исключение, меняет атрибут защиты страницы с PAGE_NOACCESS на PAGE_READWRITE и передает память новой "сторожевой" странице, размещаемой сразу за предыдущей.
Третье: обратите внимание на единственную страницу с атрибутом PAGE_READWRITE по адресу 0x00637000. Она создается для совместимости с 16-разрядной Win dows. Хотя Microsoft нигде нс говорит об этом, разработчики обнаружили, что первые 16 байтов cегмента стека 16-разрядной программы содержат информацию о ее стeке, локальной куче и локальной таблице атомарного доступа. Поскольку Win32 приложения в Windows 98 часто обращаются к 16-разрядным DLL и некоторые из этих DLL предполагают наличие тех самых 16 байтов в начале сегмента стека, Microsoft пришлось эмулировать подобные данные и в Windows 98. Когда 32-разрядный код обращается к 16-разрядному, Windows 98 отображает 16-битный селектор процессо ра на 32-разрядный стек и записывает в регистр сегмента стека (SS) такое значение, чтобы он указывал на страницу по адресу 0x00637000. И тогда 16-разрядный код, получив доступ к своим 16 байтам в начале сегмента стека, продолжает выполнение без всяких проблем
По мере роста стека потока, выполняемого под управлением Windows 98, блок памяти по адресу 0x0063F000 постепенно увеличивается, а сторожевая страница смещается вниз до тех пор, пока не будет достигнут предел в 1 Мб, после чего она исчезает так же, как и в Windows 2000.
Одновременно система смещает позицию страницы, предназначенной для совместимости с компонентами 16-разрядной Windows, и она, в конце концов, попадает в 64-килобайтовый блок, расположенный в начале региона стека. Поэтому целиком заполненный стек в Windows 98 выглядит так, как показано на рис. 16-5.
Адрес | Размер | Состояние страницы |
0x00640000 | 16 страниц (65 536 байтов) |
Верхняя часть стека (зарезервирована для перехвата обращений к несуществующей области стека) |
0x00540000 | 256 страниц (1 Мб) | Переданная страница с атрибутом PAGE_READWRITE (задействованная область стека) |
0x00539000 | 7 страниц (28 672 байта) | Страницы, зарезервированные для перехвата переполнения стека |
0x00538000 | 1 страница (4096 байтов) | Переданная страница с атрибутом PAGE_READWRITE (для совместимости с 16-разрядными компонентами) |
0x00530000 | 8 страниц (32 768 байтов) | Нижняя часть стека (зарезервирована для перехвата переполнения стека) |
Строковые функции Windows
Windows предлагает внушительный набор функций, работающих со строками. Они похожи на строковые функции из библиотеки С, например на strcpy и wcscpy. Однако функции Windows являются частью операционной системы, и многие ее компоненты используют именно их, а не аналоги из библиотеки С. Я советую отдать предпочтение функциям операционной системы. Это немного повысит быстродействие Вашего приложения. Дело в том, что к ним часто обращаются такие тяжеловесные процессы, как оболочка операционной системы (Explorer.exe), и скорее всего эти функции будут загружены в память еще до запуска Вашего приложения.
NOTE:
Данные функции доступны в Windows 2000 и Windows 98. Но Вы сможете вызывать их и в более ранних версиях Windows, если установите Internet Explorer версии 4.0 или выше.
По классической схеме именования функций в операционных системах их имена состоят из символов нижнего и верхнего регистра и выглядят так; StrCat, StrChr, StrCmp, StrCpy и т. д. Для использования этих функций включите в программу заголовочный файл ShlWApi.h. Кроме того, как я уже говорил, каждая строковая функция существует в двух версиях — для ANSI и для Unicode (например, StrCatA и StrCatW).
Поскольку это функции операционной системы, их имена автоматически преобразуются в нужную форму, если в исходном тексте Вашей программы перед ее сборкой определен UNICODE.
Структура CONTEXT
К этому моменту Вы должны понимать, какую важную роль играет структура CONTEXT в планировании потоков. Система сохраняет в ней состояние потока перед самым отключением его от процессора, благодаря чему его выполнение возобновляется с того места, где было прервано.
Вы, наверное, удивитесь, но в документации Platform SDK структуре CONTEXT отведен буквально один абзац:
"В структуре CONTEXT хранятся данные о состоянии регистров с учетом специфики конкретного процессора. Она используется системой для выполнения различных внутренних операций. В настоящее время такие структуры определены для процессоров Intel, MIPS, Alpha и PowerPC. Соответствующие определения см. в заголовочном файле WinNT.h".
В документации нет ни слова об элементах этой структуры, набор которых зависит от типа процессора. Фактически CONTEXT — единственная из всех структур Windows, специфичнаядля конкретного процессора.
Так из чего же состоит структура CONTEXT? Давайте посмотрим. Её элементы четко соответствуют регистрам процессора. Например, для процессоров x86 в число элементов входят Eax, Ebx, Ecx, Edx и т д., а для процессоров Alpha — IntVO, IntTO, IntT1, IntSO, IntRa, IntZero и др. Структура CONTEXT для процессоров x86 выглядит так.
typedef struct _CONTEXT {
//
// Флаги, управляющие содержимым записи CONTEXT.
//
// Если запись контекста используется как входной параметр, тогда раздел,
// управляемый флагом (когда он установлен), считается содержащим
// действительные значения, Если запись котекста используется для
// модификации контекста потока, то изменяются только те разделы, для
// которых флаг установлен
//
// Если запись контекста используется как входной и выходной параметр
// для захвата контекста потока, возвращаются только те разделы контекста,
// для которых установлены соответствующие флаги. Запись контекста никогда
// не используется только как выходной параметр.
//
DWORD ContextFlags;
//
// Этот раздел определяется/возвращается, когда в ContextFlags установлен
// флаг CONTEXT_DEBUG_REGISTERS.
Заметьте, что CONTEXT_DEBUG_REGISTERS
// не включаются в CONTEXT_FUlL.
//
DWORD Dr0;
DWORD Dr1;
DWORD Dr2;
DWORD Dr3;
DWORD Dr6;
DWORD Dr7;
//
// Этот раздел определяется/возвращается, когда в ContextFlags
// установлен флаг CONTEXT_FLOATING_POINT,
// FLOATING_SAVE_AREA FloatSave;
//
// Этот раздел определяется/возвращается, когда в ContextFlags
// установлен флаг CONTEXT_SEGMENTS
//
DWORD SegGs;
DWORD SegFs;
DWORD SegEs;
DWORD SegDs;
//
// Этот раздел определяется/возвращается, когда в ContextFlags
// установлен флаг CONTEXT_INTEGER
//
DWORD Edi;
DWORD Esi,
DWORD Ebx;
DWORD Fdx;
DWORD Ecx;
DWORD Eax;
//
// Этот раздел определяется/возвращается, когда в ContextFlags
// установлен флаг CONTEXT_CONTROL.
//
DWORD Ebp,
DWORD Eip;
DWORD SegCs; // следует очистить
DWORD EFlags, // следует очистить
DWORD Esp,
DWORD SegSs;
//
// Этот раздел определяется/возвращается, когда в ContextFlags
// установлен флаг CONTEXT_EXTENDED_REGISTERS
// Формат и смысл значений зависят от типа процессора.
//
BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;
Эта структура разбита на несколько разделов. Раздел CONTEXT_CONTROL содер жит управляющие регистры процессора: указатель команд, указатель стека, флаги и адрес возврата функции. (В отличие от x86, который при вызове функции помещает адрес возврата в стек, процессор Alpha сохраняет адрес возврата в одном из регистров). Раздел CONTEXT_INTEGER соответствует целочисленным регистрам процессора, CONTEXT_FLOATING_POINT — регистрам с плавающей точкой, CONTEXT_SEGMENTS — сегментным регистрам (только для x86), CONTEXT_DEBUG_REGISTERS — регистрам, предназначенным для отладки (только для x86), a CONTEXT_EXTENDED_REGISTERS — дополнительным регистрам (только для x86).
Windows фактически позволяет заглянуть внутрь объекта ядра "поток" и получить сведения о текущем состоянии регистров процессора. Для этого предназначена функция:
BOOL GetThreadContext( HANDLE hThread, PCONTEXT pContext);
Создайте экземпляр структуры CONTEXT, инициализируйте нужные флаги (в элементе ContextFlags) и передайте функции GetThreadContext адрес этой структуры.
Функция поместит значения в элементы, сведения о которых Вы запросили.
Прежде чем обращаться к GetThreadContext, приостяновите поток вызовом SuspendThread, иначе поток может быть подключен к процессору, и значения регистров существенно изменятся. На самом деле у потока есть два контекста - пользовательского режима и режима ядра. GetThreadContext возвращает лишь первый из них. Если Вы вызываете SuspendThread, когда поток выполняет код операционной системы, пользовательский контекст можно считать достоверным, даже несмотря на то что поток еще не остановлен (он все равно не выполнит ни одной команды пользовательского кода до последующего возобновления).
Единственный элемент структуры CONTEXT, которому не соответствует какой либо регистр процессора, — ContextFlags. Присутствуя во всех вариантах этой структуры независимо от типа процессора, он подсказывает функции GetThreadContext, значения каких регистров Вы хотите узyать. Например, чтобы получить значения управляющих регистров для потока, напишите что-то вроде:
// создаем экземпляр структуры
CONTEXT CONTEXT Context;
// сообщаем системе, что нас интересуют сведения
// только об управляющих регистрах
Context ContextFlags = CONTEXT_CONTROL;
// требуем от системы информацию о состоянии
// регистров процессора для данного потока
GetThreadContext(hThread, &Context);
// действительные значения содержат элементы структуры CONTEXT,
// соответствующие управляющим регистрам, остальные значения
// не определены
Перед вызовом GetThreadContext надо инициализировать элемент ContextFlags. Чтобы получить значения как управляющих, так и целочисленных регистров, иници ализируйте его так
// сообщаем системе, что нас интересуют
// управляющие и целочисленные регистры
Context.ContextFlags = CONTEXT_CONTROL | CONTEXT INTEGER;
Есть еще один идентификатор, позволяющий узнать значения важнейших регис тров (т. e. используемых, по мнению Microsoft, чаще всего):
// сообщаем системе, что нас интересуют
// все значимые регистры
Context.ContextFlags = CONTEXT_FULL;
CONTEXT_FULL определен в файле WinNT.h, как показано в таблице.
Тип процессора | Определение CONTEXT_FULL |
x86 | CONTEXT_CONTROL | CONTEXT INTEGER | CONTEXT_SEGMENTS |
Alpha | CONTEXT_CONTROL | CONTEXT_FLOATING_POINT | CONTEXT_INTEGER |
Тип процессора | Указатель команд | Указатель стека |
х86 | CONTEXT.Eip | CONTEXT.Esp |
Alpha | CONTEXT.Fir | CONTEXT.IntSp |
BOOL SetThreadContext( HANDLE hThread, CONST CONTEXT *pContext);
Перед этой операцией поток тожe нужно приостановить, иначе результаты могут быть непредсказуемыми.
Прежде чем обращаться к SetThreadContext, инициализируйте элемент ContextFlags, как показано ниже.
CONTEXT Context;
// приостанавливаем поток
SuspendThread(hThread);
// получаем регистры для контекста потока
Context.ContextFlags = CONTEXT_CONTROL;
GetThreadContext(hThread, &Context);
// устанавливаем указатель команд по своему выбору;
// в нашем примере присваиваем значение 0x00010000
#if defined(_ALPHA_)
Context.Fir = 0x00010000;
#elif defined(_X86_)
Context.Eip = 0x00010000;
#else
#error Module contains CPU-specific code, modify and recompile.
#endif
// вносим изменения в регистры потока, ContextFlags
// можно и не инициализировать, так как это уже сделано
Context.ConlrolFlags = CONTEXT_CONTROL; SetThreadContext(hThread, &Context);
// возобновляем выполнение потока; оно начнется с адреса 0x00010000
ResumeThread(hThread);
Этот код, вероятно, приведет к ошибке защиты (нарушению доступа) в удаленном потоке; система сообщит о необработанном исключении, и удаленный процесс будет закрыт.Все верно — не Ваш, а удаленный. Вы благополучно обрушили другой процесс, оставив свой в целости и сохранности!
Функции GetTbreadContext и SetThreadContext наделяют Вас огромной властью над потоками, но пользоваться ею нужно с осторожностью. Вызывают их лишь считанные приложения. Эти функции предназначены для отладчиков и других инструментальных средств, хотя обращаться к ним можно из любых программ.
Подробнее о структуре CONTEXT мы поговорим в главе 24.
Связывание модулей
Модификация базовых адресов действительно очень важна и позволяет существенно повысить производительность всей системы Но Вы можете сделать еще больше. Допустим, Вы должным образом модифицировали базовые адреса всех модулей своего приложения. Вспомните из главы 19, как загрузчик определяет адреса импортируемых идентификаторов он записывает виртуальные адреса идентификаторов в раздел импорта ЕХЕ-модуля. Это позволяет, ссылаясь на импортируемые идентификаторы, адресоваться к нужным участкам в памяти
Давайте поразмыслим Сохраняя виртуальные адреса импортируемых идентификаторов в разделе импорта ЕХЕ-модуля, загрузчик записывает их на те страницы памяти, где содержится этот раздел Здесь включается в paбoтy механизм копирования при записи, и их копии попадают в страничный файл. И у нас опять та же проблема, что и при модификации базовых адресов: отдельные части проекции модуля периодически сбрасываются в страничный файл и вновь подгружаются из него. Кроме того, загрузчику приходится преобразовывать адреса всех импортируемых идентификаторов (для каждого модуля), на что может понадобиться немалое время.
Для ускорения инициализации и сокращения объема памяти, занимаемого Вашим приложением, можно применить связывание модулей (module binding) Суть этой операции в том, что в раздел импорта модуля помещаются виртуальные адреса всех импортируемых идентификаторов. Естественно, она имеет смысл, только если проводится до загрузки модуля
В Visual Studio есть еще одна утилита, Bind.exe. Информацию о том, как ею пользоваться, Вы получите, запустив Bind.exe без ключей в командной строке. Она описана в документации Platform SDK, и я не буду ее здесь детально рассматривать Добавлю лишь, что в ней, как и в утилите Rebase, тоже нет ничего сверхъестественного: она просто вызывает функцию BindlmageEx для каждого указанного файла. Вот что представляет собой эта функция.
BOOL BindImageEx(
DWORD dwFlags, // управляющие флаги
PSTR pszImageName, // полное имя обрабатываемого файла
PSTR pszDllPath, // пугь для поиска образов файлов
PSTR pszSymbolPath, // путь для поиска О1ладочной информации
PIMAGEHLP_STATUS_ROUTINE StatusRoutine); // функция обратного вызова
Последний параметр, StatusRoutine, — адрес функции обратного вызова, к которой периодически обращается BindImageEx, позволяя отслеживать процесс связывания Прототип функции обратного вызова должен выглядеть так:
BOOL WINAPI StatusRoutine(
IMAGtHLP_STATUS_RLASON Reason, // причина неудачи
PSTR pszImageName, // полное имя обрабатываемою файла
PSTR pszDllName. // полное имя DLL
ULONG_PTR VA, // вычисленный виртуальный адрес
ULONG_PTR Parameter); // дополнительные сведения (зависят от значения Reason)
Когда Вы запускаете утилиту Bind, указывая ей нужный файл, она выполняет следующие операции.
Открывает раздел импорта указанного файла. Открывает каждую DLL, указанную в разделе импорта, и просматривает ее заголовок, чтобы определить предпочтительный базовый адрес. Отыскивает все импортируемые идентификаторы в разделе экспорта DLL Получает RVA (относительный виртуальный адрес) идентификатора, суммирует его с предпочтительным базовым адресом модуля и записывает полученное значение в раздел импорта обрабатываемого файла. Вносит в раздел импорта модуля некоторую дополнительную информацию, включая имена всех DLL, с которыми связывается файл, и их временные метки.
В главе 19 мы исследовали раздел импорта CaIc,exe с помощью утилиты DumpBln. В конце выведенного ею текста можно заметить информацию о связывании, добавленную при операции по п 5 Вот эти строки
Header contains the following bound import information: Bound to SHELL32 dll [36E449EO] Mon Мяг 08 14:06:24 1999 Bound to MSVCRT dll [36BB8379] Fri Feb Ob 15:49:13 1999 Bound to ADVAPI32.dll [36E449E1] Mon Mar 08 14:06:25 1999 Bound to KERNEL32 dll [36DDAD55] Wed Mar 03 13:44:53 1999 Bound to GDI32 dll [36E449EO] Mon Mar 08 14:06:24 1999 Bound to USER32 dll [36E449EO] Mon Mar 08 14:06:24 1999
Здесь видно, с какими модулями связан файл Calc.exe, а номер в квадратных скобках идентифицирует время создания каждого DLL-модуля Это 32-разрядное значение расшифровывается и отображается за квадратными скобками в более привычном нам виде
Утилита Bind использует два важных правила.
При инициализации процесса все необходимые DLL действительно загружаются по своим предпочтительным базовым адресам. Вы можете соблюсти это правило, применив утилиту Rebase. Адреса идентификаторов в разделе экспорта остаются неизменными со времени последнего связывания. Загрузчик проверяет это, сравнивая временную метку каждой DLL со значением, сохраненным при операции по п. 5.
Конечно, если загрузчик обнаружит, что нарушено хотя бы одно из правил, он решит, что Bind не справилась со своей задачей, и самостоятельно модифицирует раздел импорта исполняемого модуля (по обычной процедуре). Но если загрузчик увидит, что модуль связан, нужные DLL загружены по предпочтительным базовым
адресам и временные метки корректны, он фактически ничего делать не будет, и приложение сможет немедленно начать свою работу'
Кроме того, приложение не потребует лишнего места в страничном файле. И очень жаль, что многие коммерческие приложения поставляются без должной модификации базовых адресов и связывания.
О'кэй, теперь Вы знаете, что все модули приложения нужно связывать. Но вот вопрос когда? Если Вы свяжете модули в своей системе, Вы привяжете их к системным DLL, установленным на Вашем компьютере, а у пользователя могут быть установлены другие версии DLL. Поскольку Вам заранее не известно, в какой операционной системе (Windows 98, Windows NT или Windows 2000) будет запускаться Ваше приложение и какие сервисные пакеты в ней установлены, связывание нужно проводить в процессе установки приложения.
Естественно, если пользователь применяет конфигурацию с альтернативной загрузкой Windows 98 и Windows 2000, то для одной из операционных систем модули будут связаны неправильно. Тот же эффект даст и обновление операционной системы установкой в ней сервисного пакета. Эту проблему ни Вам, ни тем более пользователю решить не удастся. Microsoft следовало бы поставлять с операционной системой утилиту, которая автоматически проводила бы повторное связывание всех модулей после обновления системы.Но, увы, такой утилиты нет.
Сводная таблица объектов, используемых для синхронизации потоков
В следующей таблице суммируются сведения о различных объектах ядра примени тельно к синхрониаации потоков.
Объект |
Находится в занятом состоянии, когда |
Переходит в свободное состояние, когда |
Побочный эффект успешного ожидания | ||||
Процесс |
процесс еще активен поток еще активен |
процесс завершается (ExitProcess, TerminateProcess) |
Нет | ||||
Объект |
Находится в занятом состоянии, когда: |
Переходит в свободное состояние, когда: |
Побочный эффект успешного ожидания | ||||
Задание |
время, выделенное заданию, еще не истекло |
время, выделенное заданию, истекло |
Нет | ||||
Файл |
выдан запрос на ввод-вывод |
завершено выполнение запроса на ввод-вывод |
Нет | ||||
Консольный ВВОД |
ввода нет |
ввод есть |
Нет | ||||
Уведомление об изменении файла |
в файловой системе нет изменений |
файловая система обнаруживает изменения |
Сбрасывается в исходное состояние | ||||
Событие с автосбросом |
вызывается ResetEvent, PulseEvent или ожидание успешно завершилось |
вызывается SetEvent или PulseEvent |
Сбрасывается в исходное состояние | ||||
Событие со сбросом вручную |
вызывается ResetEvent или PulseEvent |
вызывается SetEvent или PulseEvent |
Нет | ||||
Ожидаемый таймер с автосбросом |
вызывается CancelWaitable- Тiтеr или ожидание успешно завершилось |
наступает время срабатывания (SetWaitableTimer) |
Сбрасывается в исходное состояние | ||||
Ожидаемый таймер со сбросом вручную |
вызывается CancelWaitableTimer |
наступает время срабатывания (SetWaitableTimef) |
Нет | ||||
Семафор |
ожидание успешно завершилось |
счетчик > 0 (ReleaseSemaphore) |
Счетчик уменьшается на 1 | ||||
Мьютекс |
ожидание успешно завершилось |
поток освобождает мьютекс (ReleaseMutex) |
Передается пото ку во владение | ||||
Критическая секция (поль зовательского режима) |
ожидание успешно завершилось ( (Try)EnterCriticalSection) |
поток освобождает критическую секцию (LeaveCriticalSection) |
Передается потоку во владение |
Interlocked-функции (пользовательского режима) никогда не приводят к исключе нию потока из числа планируемых; они лишь изменяют какое-то значение и тут же возвращают управление.
Таблица описателей объектов ядра
При инициализации процесса система создает в нем таблицу описателей, используемую только для объектов ядра. Сведения о структуре этой таблицы и управлении ею незадокументированы. Вообще-то я воздерживаюсь от рассмотрения недокументированных частей операционных систем. Но в данном случае стоит сделать исключение, — квалифицированный Windows-программист, на мой взгляд, должен понимать, как устроена таблица описателей в процессе. Поскольку информация о таблице описателей незадокументирована, я не ручаюсь за ее стопроцентную достоверность и к тому же эта таблица по-разному реализуется в Windows 2000, Windows 98 и Windows СЕ. Таким образом, следующие разделы помогут понять, что представляет собой таблица описателей, но вот что система действительно делает с ней — этот вопрос я оставляю открытым.
В таблице 3-1 показано, как выглядит таблица описателей, принадлежащая процессу. Как видите, это просто массив структур данных. Каждая структура содержит указатель на какой-нибудь объект ядра, маску доступа и некоторые флаги.
Индекс | Указатель на блок памяти объекта ядра | Маска доступа (DWORD с набором битовых флагов) | Флаги (DWORD с набором битовых флагов) |
1 | 0х???????? | 0х???????? | 0x???????? |
2 | 0х???????? | 0x???????? | 0x???????? |
Таблица 3-1. Структура таблицы описателей, принадлежащей процессу
Текстовые файлы
Текстовых файлов в кодировке Unicode пока очень мало. Ни в одном текстовом файле, поставляемом с операционными системами или другими программными продуктами Microsoft, не используется Unicode. Думаю, однако, что эта тенденция изменится в будущем — пусть даже в отдаленном. Например, программа Notepad в Windows 2000 позволяет создавать или открывать как Unicode-, так и ANSI-файлы. Посмотрите на
ее диалоговое окно Save Аs (рис. 2-2) и обратите внимание на предлагаемые форматы текстовых файлов.
Рис. 2-2. Диалоговое окно Save As программы Notepad в Windows 2000
Многим приложениям, которые открывают и обрабатывают текстовые файлы (например, компиляторам), было бы удобнее, если после открытия файла можно было бы определить, содержит он символы в ANSI или в Unicode. B этом может помочь функция IsTextUnicode.
DWORD IsTextUnicode(CONST PVOID pvBuffer, int cb, PINT pResult);
Проблема с текстовыми файлами в том, что не существует четких и строгих правил относительно их содержимого. Это крайне затрудняет определение того, содержит файл символы в ANSI или в Unicode Поэтому IsTextUnicode применяет набор статистических и детерминистских методов для того, чтобы сделать взвешенное предположение о содержимом буфера. Поскольку тут больше алхимии, чем точной науки, нет гарантий, что Вы не получите неверные результаты от IsTextUnicode.
Первый ее парамегр, pvBuffer, указывает на буфер, подлежащий проверке. При этом используется указатель типа void, поскольку неизвестно, в какой кодировке данный массив символов.
Параметр сb определяет число байтов в буфере, на который указываетр pvBuffer. Так как содержимое буфера не известно, сb - счетчик именно байтов, а не символов. Заметьте: вовсе не обязательно задавать всю длину буфера. Но чем больше байтов проанализирует функция, тем больше шансов получить правильный результат.
Параметр pResult — это адрес целочисленной переменной, которую надо инициализировать перед вызовом функции. Ее значение сообщает, какие тесты должна провести IsTextUnicode. Если pResult равен NULL, функция IsTextUnicode делает все проверки. (Подробнее об этом см. документацию Platform SDK.)
Функция возвращает TRUE, если считает, что буфер содержит текст в Unicode, и FALSE — в ином случае. Да-да, она возвращает именно булево значение, хотя в прототипе указано DWORD. Если через целочисленную переменную, на которую указывает pResuIt, были запрошены лишь определенные тесты, функция (перед возвратом управления) устанавливает ее биты в соответствии с результатами этих тестов.
WINDOWS 98:
В Windows 98 функция IsTextUnicode по сути не реализована и просто возвращает FALSE; последующий вызов GetLastError дает код ошибки ERRORCALL_NOT_IMPLEMENTED.
Применение функции IsTextUnicode иллюстрирует программа-пример FileRev (см. главу 17).
Текущие диск и каталог для процесса
Текущий каталог текущего диска — то место, где Windows-функции ищут файлы и подкаталоги, если полные пути в соответствующих параметрах не указаны. Например, если поток в процессе вызывает функцию CreateFile, чтобы открыть какой-нибудь файл, а полный путь не задан, система просматривает список файлов в текущем ката логе текущего диска. Этот каталог отслеживается самой системой, и, поскольку такая информация относится ко всему процессу, смена текущего диска или каталога одним из потоков распространяется и на остальные потоки в данном процессе.
Поток может получать и устанавливать текущие каталог и диск для процесса с помощью двух функций:
DWORD GetCurrentDirectory( DWORD cchCurDir, PTSTR pszCurDir);
BOOL SetCurrentDirectory(PCTSTR pszCurDir);
Текущие каталоги для процесса
Система отслеживает текущие диск и каталог для процесса, но не текущие каталоги на каждом диске. Однако в операционной системе предусмотрен кое-какой сервис для манипуляций с текущими каталогами на разных дисках. Он реализуется через пере менные окружения конкретного процесса. Например:
=C:=C-\Utility\Bin =D:=D:\Program Files
Эти переменные указывают, что текущим каталогом на диске С является \Utllity\ Bin, а на диске D — Program Files.
Если Вы вызываете функцию, передавая ей путь с именем диска, отличного от текущего, система сначала просматривает блок переменных окружения и пытается найти переменную, связанную с именем указанного диска. Если таковая есть, система выбирает текущий каталог на заданном диске в соответствии с ее значением, нет — текущим каталогом считается корневой.
Скажем, если текущий каталог для процесса — C:\Uiiltty\Bin и Вы вызываете фун кцию CreateFile, чтобы открыть файл D:\ReadMe.txt, система ищет переменную =D:. Поскольку переменная =D: существует, система пытается открыть файл ReadMe txt в каталоге D:\Program Files. А если бы таковой переменной не было, система искала бы файл ReadMe.txt в корневом каталоге диска D. Кстати, файловые Windows-функции никогда не добявляют и не изменяют переменные окружения, связанные с именами дисков, а лишь считывают их значения.
NOTE:
Для смены текущего каталога вместо Windows-функции SetCurrentDirectory можно использовать функцию _chdir из библиотеки С Внутренне она тоже обращается к SetCurrentDirettory, но, кроме того, способна добавлять или мо дифицировать переменные окружения, что позволяет запоминать в програм ме текущие каталоги на различных дисках.
Если родительский процесс создает блок переменных окружения и хочст пере дать его дочернему процессу, тот не наследует текущие каталоги родительского про цесса автоматически. Вместо этого у дочернего процесса текущими на всех дисках становятся корневые каталоги. Чтобы дочерний процесс унаследовал текущие ката логи родительского, последний должен создать соответствующие переменные окру жения (и сделать это до порождения другого процесса). Родительский процесс мо жет узнать, какие каталоги являются текущими, вызвав GetFullPathName:
DWORD GetFullPathName( PCTSTR pszFile, DWORD cchPath, PTSTR pszPath, PTSTR *ppszFilePart);
Например, чтобы получить текущий каталог на диске С, функцию вызывают так:
TCHAR szCurDir[MAX_PATH];
DWORD GetFullPathName(TEXT("C."), MAX_PATH, szCurDir, NULL);
Не забывайте, что переменные окружения процесса должны всегда храниться в алфавитном порядке Позтому переменные, связанные с дисками, обычно приходит ся размещать в самом начале блока.
Типы данных, определенные в Windows для Unicode
В заголовочных файлах Windows определены следующие типы данных.
Тип данных |
Описание | ||
WCHAR |
Unicode-символ | ||
PWSTR |
Указатель на Unicode -строку | ||
PCWSTR |
Указатель на стоковую константу в Unicode |
Эти типы данных относятся исключительно к символам и строкам в кодировке Unicode. B заголовочных файлах Windows определены также универсальные (ANSI, Unicode) типы данных PTSTR и PCTSTR, указывающие — в зависимости от того, определен ли при компиляции макрос UNICODE, — на ANSI или на Unicode-строку.
Кстати, на этот раз имя макроса UNICODE не предваряется знаком подчеркивания Дело в том, что макрос _UNICODE используется в заголовочных файлах библиотеки С, а макрос UNICODE — в заголовочных файлах Windows. Для компиляции модулей исходного кода обычно приходится определять оба макроса.
Учет пользователей объектов ядра
Объекты ядра принадлежат ядру, а не процессу. Иначе говоря, если Ваш процесс вызывает функцию, создающую объект ядра, а затем завершается, объект ядра может быть не разрушен. В большинстве случаев такой объект все же разрушается; но если созданный Вами объект ядра используется другим процессом, ядро запретит разрушение объекта до тех пор, пока от него не откажется и тот процесс.
Ядру известно, сколько процессов использует конкретный объект ядра, посколь ку в каждом объекте есть счетчик числа его пользователей. Этот счетчик — один из элементов данных, общих для всех типов объектов ядра. В момент создания объекта счетчику присваивается 1. Когда к существующему объекту ядра обращается другой процесс, счетчик увеличивается на 1. А когда какой-то процесс завершается, счетчики всех используемых им объектов ядра автоматически уменьшаются на 1. Как только счетчик какого-либо объекта обнуляется, ядро уничтожает этот объект.
Уничтожение кучи
Кучу можно уничтожить вызовом HeapDestroy:
BOOL HeapDestroy(HANDLE hHeap);
Обращение к этой функции приводит к освобождению всех блоков памяти внутри кучи и возврату системе физической памяти и зарезервированного региона адресного пространства, занятых кучей. При успешном выполнении функция возвращает TRUE. Если при завершении процесса Вы не уничтожаете кучу, это делает система, но — подчеркну еще раз — только в момент завершения процесса. Если куча создана потоком, она будет уничтожена лишь при завершении всего процесса.
Система не позволит уничтожить стандартную кучу процесса — она разрушается только при завершении процесса. Если Вы передадите описатель этой кучи функции HeapDestroy, система просто проигнорирует Ваш вызов.
Unicode- и ANSI-функции в Windows
Я уже говорил, что существует две функции CreateWindowEx одна принимает строки в Umcode, другая — в ANSI Все так, но в действительности прототипы этих функций чуть чуть отличаются
HWND WINAPI CreateWindowExW(
DWORD dwExStyle,
PCWSTR pClassName,
PCWSTR pWindowName,
DWORD dwStyle,
int X,
int Y,
int nWidth,
int nHeight,
HWND hWndParent,
HMENU hHenu,
HINSTANCE hInstance,
PVOID pParam);
HWND WINAPI CreatcWindowExA(
DWORD dwExStyle,
PCSTR pClassName,
PCSTR pWindowName,
DWORD dwStyle,
int X
int Y,
int nWidth,
inT nHeight,
HWND hWndParent,
HMENU hMenu,
HINSTANCF hInstance,
PVOID pParam);
CteateWindowExW — это Unicode-версия Буква W в конце имени функции — аббpевиатуpa слова wide (широкий) Символы Unicode занимают по 16 битов каждый, поэтому их иногда называют широкими символами (wide characters) Буква А в конце имени CreateWindoivExA указывает, что данная версия функции принимаетАNSI-строки
Но обычно CreateWmdowExW или CreateWindowExA напрямую не вызывают А обращаются к CreateWindowEx — макросу, определенному в файле WmUser.h.
#ifdef UNICODE
#define CreateWindowEx CreateWindowExW
#else
#define CreateWindowEx CreateWindowExA
#endif // UNICODE
Какая именно версия CreateWindowEx будет вызвана, зависит от того, определен ли UNICODE в период компиляции. Перенося 1б-разрядное Windows-приложение на платформу Win32. Вы, вероятно, не станете определять UNICODE. Тогда все вызовы CreateWindowEx будут преобразованы в вызовы CreateWindowExA — ANSI-версии функции. И перенос приложения упростится, ведь 16-разрядная Windows работает только с ANSI-версией CreateWindowEx.
В Windows 2000 функция CreateWindowExA — просто шлюз (транслятор), который выделяет память для преобразования строк из ANSI в Unicode и вызывает CreateWindowExW, передавая ей преобразованные строки. Когда CreateWindowExW вернет управление, CrealeWindowExA освободит буферы и передаст Вам описатель окна.
Разрабатывая DLL, которую будут использовать и другие программисты, предусматривайте в ней по две версии каждой функции — для ANSI и для Unicode.
В ANSI- версии просто выделяйте память, преобразуйте строки и вызывайте Unicode-версию той же функции. (Этот процесс я продемонстрирую позже.)
В Windows 98 основную работу выполняет CreateWindowExA. В этой операцион ной системе предусмотрены точки входа для всех функций Windows, принимающих Unicode-строки, но функции не транслируют их в ANSi, а просто сообщают об ошибке. Последующий вызов GetLastError дает ERRORCALL_NOT_IMPLEMENTED. Должным образом действуют только ANSI-версии функций. Ваше приложение не будет работать в Windows 98, если в скомпилированном коде присутствуют вызовы "широкосимвольных" функций.
Некоторые функции Windows API (например, WinExec или OpenFile) существуют только для совместимости с 16-разрядными программами, и их надо избегать. Лучше заменить все вызовы WinExec и OpenFile вызовами CreateProcess и CreateFile соответственно. Тем более, что старые функции просто обращаются к новым. Самая серьезная проблема с ними в том, что они не принимают строки в Unicode, при их вызове Вы должны передавать строки в ANSI. С другой стороны, в Windows 2000 у всех новых или пока не устаревших функций обязательно есть как ANSI-, так и Unicode-версия.
Unicode и библиотека С
Для использования Unicode-строк были введены некоторые новые типы данных. Стандартный заголовочный файл String.h модифицирован в нем определен wchar_t — тип данных для Unicode-символа
typedef unsigned short wchar_t
Если Вы хотите, скажем, создать буфер для хранения Unicode-строки длиной до 99 символов с нулевым символом в конце, поставьте оператор:
wchar_t szBuffer[100];
Он создает массив из ста 16-битных значений. Конечно, стандартные функции библиотеки С для работы со строками вроде strcpy, strchr и strcat оперируют только с ANSI-строками — они не способны корректно обрабатывать Unicode-строки. Поэтому в ANSI С имеется дополнительный набор функций. На рис. 2-1 приведен список строковых функций ANSI C и эквивалентных им Unicode-функций.
char * strcat(char *, const char *);
wchar_t * wcscat(wchar_t *, const wchar t *);
char * strchr(const char *, int);
wchar_t * wcschr(const wchar_t *, wchar_t);
int strcmp(const char *, const char *);
int wcscmp(const wchar_t *, const wchar_t *);
char * strcpy(char *, const char *);
wchar_t * wcscpy(wchar_t *, const wchar_t *);
size_t strlen(const char *);
size_t wcslen(const wchar_t *);
Рис. 2-1. Строковые функции ANSI C и их Unicode-аналоги
Обратите внимание, что имена всех новых функций начинаются с wcs — это аббревиатура wide character set (набор широких символов) Таким образом, имена Unicode-функций образуются простой заменой префикса str соответствующих ANSI-функций на wcs.
NOTE:
Один очень важный момент, о котором многие забывают, заключается в том, что библиотека С, предоставляемая Microsoft, отвечает стандарту ANSI. A он требует, чтобы библиотека С поддерживала символы и строки в Unicode Это значит, что Вы можете вызывать функции С для работы с Unicode-символами и строками даже при работе в Windows 98. Иными словами, функции wcscat, wcslen, wcstok и т. д. прекрасно работают и в Windows 98; беспокоиться нужно за функции операционной системы.
Код, содержащий явные вызовы str- или wcs-функций, просто так компилировать с использованием и ANSI, и Unicode нельзя.
Чтобы реализовать возможность компиляции "двойного назначения", замените в своей программе заголовочный файл String.h на TChar.h Он помогает создавать универсальный исходный код, способный задействовать как ANSI, так и Unicode, — и это единственное, для чего нужен файл TChar.h. Он состоит из макросов, заменяющих явные вызовы str- или wcs-функций. Если при компиляции исходного кода Вы определяете _UNICODE, макросы ссылаются на wcs-функции, а в его отсутствие — на str-фупкции.
Например, в TChar.h есть макрос _tcscpy. Если Вы включили этот заголовочный файл, но UNlCODE не определен, _tcscpy раскрывается в ANSI-функцию strcpy, а если _UNICODE определен — в Unicode-функцию wcscpy, В файле TChar.h определены универсальные макросы для всех стандартных строковых функций С. При использовании этих макросов вместо конкретных имен ANSI- или Unicode-функций Ваш код можно будет компилировать в расчете как на Unicode, так и на ANSI.
Но, к сожалению, это ещe не все. В файле TChar.h есть дополнительные макросы.
Чтобы объявить символьный массив универсального назначения" (ANSI/Unicode), применяется тип данных TCHAR. Если _UNICODE определен, TCHAR объявляется так:
typedef wchar_L TCHAR;
А если UNICODE не определен, то:
typedef char TCHAR
Используя этот тип данных, можно объявить строку символов как:
TCHAR szString[100];
Можно также определять указатели на строки:
TCHAR *szError = "Error";
Правда, в этом операторе есть одна проблема. По умолчанию компилятор Microsoft С++ транслирует строки как состоящие из символов ANSI, а не Unicode. B итоге этот оператор нормально компилируется, если UNICODE не определен, но в ином случае дает ошибку. Чтобы компилятор сгенерировал Unicode-, a не ANSI-строку, оператор надо переписать так:
TCHAR *szFrror = L"Error";
Заглавная буква L перед строковым литералом указывает компилятору, что его надо обрабатывать как Unicode-строку. Тогда, размещая строку в области данных программы, компилятор вставит между всеми символами нулевые байты.Но возникает другая проблема — программа компилируется, только если _UNICODE определен Следовательно, нужен макрос, способный избирательно ставить L перед строковым литералом. Эту работу выполняет макрос _TEXT, также содержащийся в Tchar.h. Если _UNICODE определен, _TEXT определяется как:
#define _TEXT(x) L ## x
В ином случае _TEXT определяется следующим образом:
#define _TEXT(x) x
Используя этот макрос, перепишем злополучный оператор так, чтобы его можно было корректно компилировать независимо от того, определен _UNICODE или нет:
TCHAR *szError = _TEXT("Error");
Макрос _TEXT применяется и для символьных литералов. Например, чтобы проверить, является ли первый символ строки заглавной буквой J:
if (szError[0] == _TEXT('J')) {
// первый символ - J
} else {
// первый символ - не J
}
Unicode и СОМ
Когда Microsoft переносила СОМ из 16-разрядной Windows на платформу Win32, руководство этой компании решило, что все методы СОМ-интсрфейсов, работающие со строками, должны принимать их только в Unicode. Это было удачное решение, так как СОМ обычно используется для того, чтобы компоненты могли общаться друг с другом, a Unicode позволяет легко локализовать строки.
Eсли Вы разрабатываете программу для Windows 2000 или Windows СЕ и при этом используете СОМ, то выбора у Вас просто нет. Применяя Unicode во всех частях программы, Вам будет гораздо проще обращаться и к операционной системе, и к COM объектам.
Если Вы пишете для Windows 98 и тоже используете СОМ, то попадаете в затруднительное положение. СОМ требует строк в Unicode, а большинство функций операционной системы — строк в ANSI. Это просто кошмар! Я работал над несколькими такими проектами, и мне приходилось писать прорву кода только для того, чтобы гонять строки из одного формата в другой.
Unicode: набор широких символов
Unicode — стандарт, первоначально разработанный Apple и Xerox в 1988 г. В 1991 г был создан консорциум для совершенствования и внедрения Unicode. В него вошли компании Apple, Compaq, Hewlett-Packard, IBM, Microsoft, Oracle, Silicon Graphics, Sybase, Unisys и Xcrox. (Полный список компаний — членов консорциума см на www.Unicode.org.) Эта группа компаний наблюдает за соблюдением стандарта Unicode, описание которого Вы найдете в книге Tbe Unicode Standard издательства Addison-Wesley (ее электронный вариант можно получить на том же www.Unicode.org).
Строки в Unicode просты и логичны. Все символы в них представлены 16-битными значениями (по 2 байта на каждый). В них нет особых байтов, указывающих, чем является следующий байт — частью того же символа или новым символом. Это значит, что прохождение по строке реализуется простым увеличением или уменьшением значения указателя. Функции CharNext, CharPrev и lsDBCSLeadByte больше не нужны.
Так как каждый символ — 16-битное число, Unicode позволяет кодировать 65 536 символов, что более чем достаточно для работы с любым языком. Разительное отличие от 256 знаков, доступных в однобайтовом наборе!
В настоящее время кодовые позиции определены для арабского, китайского, греческого, еврейского, латинского (английского) алфавитов, а также для кириллицы (русского), японской каны, корейского хантыль и некоторых других алфавитов, Кроме того, в набор символов включено большое количество знаков препинания, математических и технических символов, стрелок, диакритических и других знаков. Все
вместе они занимают около 35 000 кодовых позиций, оставляя простор для будущих расширений.
Эти 65 536 символов разбиты на отдельные группы. Некоторые группы, а также включенные в них символы показаны в таблице.
1 Кодовая позиция (code point) — позиция знака в наборе символов.
16-битный код |
Символы |
16-битный код |
Символы | ||||
0000-007F |
ASCII |
0300-U36F |
Общие диакритические | ||||
0080-00FF |
Символы Latin 1 |
0400-04FF |
Кириллица | ||||
0100-017F |
Европейские латинские |
0530-058F |
Армянский | ||||
01 80-01FF |
Расширенные латинские |
0590-05FF |
Еврейский | ||||
0250-02AF |
Стандартные фонетические |
0600-06FF |
Арабский | ||||
02BO-02FF |
Модифицированные литеры |
0900-097F |
Деванагари |
Около 29 000 кодовых позиций пока не заняты, но зарезервированы на будущее. Примерно 6 000 позиций оставлено специально для программистов (на их усмотрение).
Управление курсором мыши
В концепцию локального состояния ввода входит и управление состоянием курсора мыши. Поскольку мышь, как и клавиатура, должна быть доступна всем потокам, Win dows не позволяет какому-то одному потоку монопольно распоряжаться курсором мыши, изменяя его форму или ограничивая область его перемещения. Посмотрим, как система управляет этим курсором
Один из аспектов управления курсором мыши заключается в его отображении или гашении, Если поток вьзывает ShowCursor(FAI.SE), то система скрывает курсор, когда он оказывается на любом окне, созданном этим потоком, и показывает курсор вся кий раз, когда он попадает в окно, созданное другим потоком.
Другой аспект управления курсором мыши — возможность ограничить его пере мещение каким-либо прямоугольным участком. Для этого надо вызвать функцию ClipCursor:
BOOL ClipCursor(CONST RECT *ргс);
Она ограничивает перемещение курсора мыши прямоугольником, на который указывает параметр prc. И опять система разрешает потоку ограничить перемещение курсоря заданным прямоугольником. Но, когда возникает событие асинхронной ак тивизации, т. e. когда пользователь переключается в окно другого приложения, нажи мает клавиши Ctrl+Esc или же поток вызывает SetForegroundWindow, система снимает ограничения на передвижение курсора, позволяя свободно перемещать его по экрану.
И здесь мы подошли к концепции захвата, мыши (mouse capture). "Захватывая" мышь (вызовом SetCapture), окно требует, чтобы все связанные с мышью сообщения RIT отправлял в очередь виртуального ввода вызывающего потока, а из нее — устано вившему захват окну до тех пор, пока программа не вызовет ReleaseCapture.
Как и в предыдущих случаях, это тоже снижает отказоустойчивость системы, но без компромиссов, увы, не обойтись. Вызывая SetCapture, поток заставляет RIT поме щать все сообщения от мыши в свою очередь виртуального ввода. При этом SetCapture соответственно настраивает переменные локального состояния ввода данного потока.
Обычно приложение вызывает SetCapture, когда пользователь нажимает кнопку мыши.
Но поток может вызвать эту функцию, даже если нажатия кнопки мыши не было. Если SetCapture вызывается при нажатой кнопке, захват действует для всей си стемы Как только система определяет, что все кнопки мыши отпущены, RIT переста ет направлять сообщения от мыши исключительно в очередь виртуального ввода дан ного потока. Вместо этого он передает сообщения в очередь ввода, связанную с ок ном, "поверх" которого курсор находится в данный момент. И это нормальное пове дение системы, когда захват мыши не установлен
Однако для вызвавшего SetCapture потока ничего не меняется. Всякий раз, когда курсор оказывается на любом из окон, созданных установившим захват потоком, со общения от мыши направляются в окно, применительно к которому этот захват и установлен. Иначе говоря, когда пользователь отпускает всс кнопки мыши, захват осуществляется на уровне лишь данного потока, а нс всей системы
Если пользователь попытается активизировать окно, созданное другим потоком, система автоматически отправит установившему захват потоку сообщения о нажатии и отжатии кнопок мыши. Затем она изменит переменные локального состояния вво да потока, чтобы отразить тот факт, что поток более не работает в режиме захвата. Словом, Microsoft считает, что захват мыши чаще всего применяется для выполнения таких операций, как щелчок и перетаскивание экранного объекта.
Последняя переменная локального состояния ввода, связанная с мышью, относится к форме курсора. Всякий раз, когда поток вызывает SetCursor для изменения формы курсора, переменные локального состояния ввода соответствующим образом обнов ляются То есть переменные локального состояния ввода всегда запоминают послед нюю форму курсора, установленную потоком
Допустим, пользователь перемещает курсор мыши на окно Вашей программы, окно получает сообщение WM_SETCURSOR, и Вы вызываете SetCursor, чтобы преоб разовать курсор в "несочные часы". Вызвав SetCursor, программа начинает выполнять какую-то длительную операцию (Бесконечный цикл — лучший пример длительной операции Шутка ) Далее пользователь перемещает курсор из окна Вашей программы в окно другого приложения, и это окно может изменить форму курсора.
Для такого изменения переменные локального состояния ввода не нужны Но пе реведем курсор обратно в то окно, поток которого по-прежнсму занят обработкой. Системе "хочется" послать окну сообщения WM_SETCURSOR, но процедура этого окна не может выбрать их из очереди, так кяк его поток продолжает свою операцию. Тогда система определяет, какая форма была у курсора в прошлый раз (информация об этом содержится в переменных локального состояния ввода данного потока), и автомати чески восстанавливает ее (в нашем примере — "песочные часы"). Теперь пользовате лю четко видно, что в этом окне работа еще не закончена и придется подождать.
Утилита для сохранения позиций элементов на рабочем столе
Эта утилита, "22 DIPS.exe" (см. листинг на рис. 22-2), использует ловушки окон для внедрения DLL в адресное пространство Explorer exe. Файлы исходного кода и ресурсов этой программы и DLL находятся в каталогах 22-DIPS и 22-DIPSLib на компактдиске, прилагаемом к книге.
Компьютер я использую в основном для работы, и, на мой взгляд, самое оптимальное в этом случае разрешение экрана - 1152 x 8б4 Иногда я запускаю на своем компьютере кос-какие игры, но большинство из них рассчитано на разрешение 640 x 480 Когда у меня появляется настроение поиграть, приходится открывать апплет Display в Control PaneI и устанавливать разрешение 640 x 480, а закончив игру, вновь возвращаться в DispIay и восстанавливать разрешение 1152 x 864.
Возможность изменять экранное разрешение "на лету" — очень удобная функция Windows. Единственное, что мне не по душе, — при смснс экранного разрешения не сохраняются позиции ярлыков на рабочем столе. У мепя на рабочем столе масса ярлыков для быстрого доступа к часто используемым программам и файлам. Стоит мне
сменить разрешение, размеры рабочего стола изменяются, и ярлыки перестраиваются так, что ужс ничего не найдешь А когда я восстанавливаю прежнее разрешение, ярлыки так и остаются вперемешку. Чтобы навести порядок, приходится вручную перемещать каждый ярлык на свое место — очень интересное занятие!
В общем, мнс это так осточертело, что я придумал утилиту, сохраняющую позиции элементов на экране (Desktop Item Position Saver, DIPS). DIPS состоит из крошечного исполняемого файла и компактной DLL. После запуска исполняемого файла появляется следующее окно
В этом окне поясняется, как работать с утилитой. Когда она запускается с ключом 5 в командной строке, в реестре создается подраздел
HKEY_CURRENT_USER\Software\Richter\Desktop Item Position Saver
куда добавляемся по одному параметру на каждый ярлык, расположенный на Вашем рабочем столе. Значение каждого параметра ~ позиция соответствующего ярлыка. Утилиту DIPS следует чапускать перед установкой более низкого экранного разрешения.
Всласть наигравшись и восстановив нормальное разрешение, вновь запустите DIPS — на этот раз с ключом R. Тогда DlPS откроет соответствующий подраздел реестра и восстановит для каждого объекта рабочего стола его исходную позицию.
На первый взгляд утилита DIPS тривиальна и очень проста в реализации, Вроде бы только и надо, что получить описатель элемента управления ListView рабочего стола, заставить его (послав соответствующие сообщения) перечислить все ярлыки и определить их координаты, а потом сохранить полученные данные в реестре. Но попробуйте, и Вы убедитесь, что все не так просто. Проблема в том, что большинство оконных сообщений для стандартных элементов управления (например, LVM_GETITEM и LVM_GETITEMPOSITION) не может преодолеть границы процессов. Почему?
Сообщение LVM_GETITEM требует, чтобы Вы передали в параметре lParam адрес структуры LV_ITEM. Поскольку cc адрес имеет смысл лишь в адресном пространстве процесса — отправителя сообщения, процесс-приемник не может безопасно использовать его. Поэтому, чтобы DIPS работала так, как было обещано, в Explorer.exe надо внедрить код, посылающий сообщения LVM_GETITEM и LVM_GETITEMPOSITION элементу управления ListView рабочего стола.
NOTE
В отличие от новых стандартных элементов управления встроенные (кнопки, поля, метки, списки, комбинированные списки и т. д.) позволяют передавать оконные сообщения через границы процессов. Например, окну списка, созданному каким-нибудь потоком другого процесса, можно послать сообщение LB_GETTEXT, чей параметр lParam указывает на строковый буфер в адресном пространстве процесса-отправителя. Это срабатывает, потому что операционная система специально проверяет, не отправлено ли сообщение LB_GETTEXT,
и, если да, самя создает проецируемый в память файл и копирует строковые данные из адресного пространства одного процесса в адресное пространство другого
Почему Microsoft решила по-разному обрабатывать встроенные и новые элементы управления? Дело в том, что в 16-разрядной Windows, в которой все приложения выполняются в едином адресном пространстве, любая программа могла послать сообщение LB_GETTEXT окну, созданному другой программой.
Чтобы упростить перенос таких приложений в Win32, Microsoft и пошла на эти ухищрения. А поскольку в 16-разрядной Windows нет новых элементов управления, то проблемы их переноса тоже нет, и Microsoft ничего подобного для них делать не стала.
Сразу после запуска DIPS получает описатель окна элемента управления LisrView рабочего стола:
// окно ListView рабочего стола - "внук" окна ProgMan
hwndLV = GetFirstChild(GetFirstChild(hindWindow(__TEXT("ProgHan"), NULL)));
Этот код сначала ищет окно класса ProgMan. Даже несмотря на то что никакой Program Manager не запускается, новая оболочка по-прежнему создает окно этого класса — для совместимости с приложениями, рассчитанными на старые версии Windows. У окна ProgMan единственное дочернее окно класса SHELLDLL_DefView, у которого тоже одно дочернее окно — класса SysListView32. Оно-то и служит элементом управления ListView рабочего стола. (Кстати, всю эту информацию я выудил благодаря Spy++.)
Получив описатель окна ListView, я определяю идентификатор создавшего его потока, для чего вызываю GetWindowThreadProcessId. Этот идентификатор я передаю функции SetDIPSHook, реализованной в DIPSLib.cpp. Последняя функция устанавливает ловушку WH_GETMESSAGE для данного потока и вызывает:
PostThreadMessage(dwThreadId, WM_NULL, 0, 0);
чтобы разбудить поток Windows Explorer. Поскольку для него установлена ловушка WH_GETMESSAGE, операционная система автоматически внедряет мою DIPSLib.dll в адресное пространство Explorer и вызывает мою функцию GetMsgProc. Та сначала проверяет, впервые ли она вызвана, и, если да, создает скрытое окно с заголовком "Richter DIPS". Возьмите на заметку что это окно создается потоком, принадлежащим Explorer. Пока окно создается, поток DIPS.exc возвращается из функции SetDIPSHook и вызывает:
GetMessage(&msg, NULL, 0, 0);
Этот вызов "усыпляет"- поток до появления в очереди какого-нибудь сообщения Хотя DIPS,exe сам не создает ни одного окна, у него всс же есть очередь сообщений, и они помещаются туда исключительно в результате вызовов PostThreadMessage. Взгляните на код GetMsgProc в DIPSLih.cpp: сразу после обращения к CreateDialog стоит вызов PostThreadMessage, который вновь пробуждает поток DIPS exe.
Идентификатор потока сохраняется в разделяемой переменной внутри функции SetDIPSHook.
Очередь сообщений я использую для синхронизации потоков. В этом нет ничего противозаконного, и иногда гораздо проще синхронизировать потоки именно так, не прибегая к объектам ядра — мьютексам, семафорам, событиям и т. д. (В Windows очень богатый API; пользуйтесь этим.)
Когда поток DIPS.exe пробуждается, он узнает, что серверное диалоговос окно уже создано, и обращается к FindWmdow, чтобы получить его описатель. С этого момента для оргапичации взаимодействия между клиентом (утилитой DIPS) и сервером (скрытым диалоговым окном) можно использовать механизм оконных сообщений. Поскольку это диалоговое окно создано потоком, выполняемым в контексте процесса Explorer, нас мало что ограничивает в действиях с Explorer.
Чтобы сообщить своему диалоговому окну сохранить или восстановить позиции ярлыков на экране, достаточно послать сообщение:
// сообщаем окну DIPS, c каким окном ListView работать
// и что делать: сохранять или восстанавливать позиции ярлыков
SendMessage(hwndDIPS, WM_APP, (WPARAM) hwndLV, fSave);
Процедура диалогового окна проверяет сообщение WM_APP. Когда она принимает это сообщение, параметр wParam содержит описатель нужного элемента управления ListView, a lParam — булево значение, определяющее, сохранять текущие позиции ярлыков в реестре или восстанавливать.
Так как здесь используется SendMessage, а не PostMessage, управление не передается до завершения операции. Если хотите, определите дополнительные сообщения для процедуры диалогового окна — это расширит возможности программы в управлении Explorer. Закончив, я завершаю работу сервера, для чего посылаю ему сообщение WM_CLOSE, которое говорит диалоговому окну о необходимости самоуничтожения.
Наконец, перед своим завершением DIPS вновь вызывает SetDlPSHook, но на этот раз в качестве идентификатора потока передается 0 Получив нулевое значение, функция снимает ловушку WH_GETMESSAGE. А когда ловушка удаляется, операционная система автоматически выгружает DIPSLib.dll из адресного пространства процесса Explorer, и это означает, что теперь процедура диалогового окна болыпе не принадлежит данному адресному пространству Поэтому важно уничтожить диалоговое окно заранее — до снятия ловушки.Иначе очередное сообщение, направленное диалоговому окну, вызовет нарушение доступа. И тогда Explorer будет аварийно завершен операционной системой — с внедрением DLL шутки плохи!
DIPSLib