48511

Системное программное обеспечение

Конспект

Информатика, кибернетика и программирование

Расположение СПО в общей структуре ЭВМ Современная компьютерная система состоит из одного или нескольких процессоров оперативной памяти дисков клавиатуры монитора принтеров сетевого интерфейса и других устройств то есть является сложной комплексной системой. Обычно на этом уровне находятся внутренние регистры центрального процессора CPU Centrl Processing Unit и арифметико-логическое устройство. На каждом такте процессора из регистра выбирается один или два операнда которые обрабатываются в арифметико-логическом устройстве...

Русский

2013-12-11

1.5 MB

9 чел.

Системное программное обеспечение – компьютерные программы, которые контролируют функционирование самого компьютера, а не удовлетворяют непосредственно потребности пользователя (сравни: applications software (прикладное программное обеспечение)). Примерами являются операционные системы и так называемые утилиты, которые выполняют такие задачи, как копирование файлов, проверку состояния магнитных дисков и т.д. Эти программы обычно продаются вместе с компьютером и могут быть даже уже введены в него (см.: firmware (встроенная программа)). Некоторые программы, например компиляторы и интерпретаторы, участвующие в создании прикладного программного обеспечения, также принято относить к системному программному обеспечению.

Расположение СПО в общей структуре ЭВМ

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

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

Рис.1. Компьютерная система состоит из аппаратного обеспечения, системных программ и приложений

Выше расположен микроархитектурный уровень, на котором физические устройства рассматриваются с точки зрения функциональных единиц. Обычно на этом уровне находятся внутренние регистры центрального процессора (CPU - Central Processing Unit) и арифметико-логическое устройство. На каждом такте процессора из регистра выбирается один или два операнда, которые обрабатываются в арифметико-логическом устройстве (например, действием операции сложения или логического И). Результат сохраняется в одном или нескольких регистрах. В некоторых машинах операции над данными контролируются программными приложениями, которые называются микропрограммами. В других компьютерах такой контроль выполняется напрямую аппаратными цепями.

Определенная система команд передается по маршруту передачи данных. Некоторые команды могут быть выполнены за один цикл передачи данных, другие требуют нескольких циклов. Такие команды могут использовать регистры или другие возможности аппаратуры. Команды, видимые для работающего на ассемблере программиста, формируют уровень ISA (Instruction Set Architecture — архитектура системы команд), часто называемый машинным языком.

Обычно машинный язык содержит от 50 до 300 команд, служащих преимущественно для перемещения данных по компьютеру, выполнения арифметических операций и сравнения величин. Управление устройствами на этом уровне осуществляется с помощью загрузки определенных величин в специальные регистры устройств. Например, диску можно дать команду чтения, записав в его регистры адрес места на диске, адрес в основной памяти, число байтов для чтения и направление действия (чтение или запись). На практике нужно передавать большее количество параметров, а статус операции, возвращаемый диском, достаточно сложен. Кроме того, при программировании многих устройств ввода-вывода (I/O — Input/ Output) очень важную роль играют временные соотношения.

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

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

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

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

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

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

Классификация и структура СПО: операционная система, загрузчики, трансляторы, компиляторы и интерпретаторы, отладчики, утилиты

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

Транслятор – программа, преобразующая текст, написанный на алгоритмическом языке, в программу, состоящую из машинных команд.

Компилятор – транслятор, обрабатывающий исходный тест целиком, делающий его перевод и создающий законченный вариант программы на машинном языке.

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

Интерфейс операционной системы: основные принципы и стандарты. Системные вызовы

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

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

Для того чтобы прояснить механизм системных вызовов, кратко рассмотрим системный вызов read. Как упоминалось выше, у него есть три параметра: первый служит для задания файла, второй указывает на буфер, третий задает количество байтов, которое нужно прочитать. Как практически все системные вызовы, он запускается из программы на С с помощью вызова библиотечной процедуры с тем же именем, что и системный вызов: read. Вызов из программы на С может выглядеть так:

count = read(fd. buffer. nbytes):

Системный вызов (и библиотечная процедура) возвращает количество действительно прочитанных байтов в переменной count. Обычно эта величина совпадает с параметром nbytes, но может быть меньше, если, например, в процессе чтения процедуре встретился конец файла.

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

Системные вызовы выполняются за серию шагов. Вернемся к упоминавшемуся выше примеру вызова read для того, чтобы разъяснить этот момент. Сначала при подготовке к вызову библиотечной процедуры read, которая фактически осуществляет системный вызов read, вызывающая программа помещает параметры в стек, как показано в шагах 1-3 на рис. 2. Компиляторы С и C++ помещают параметры в стек в обратном порядке, так исторически сложилось (чтобы первым был параметр для print/, то есть строка формата оказалась на вершине стека). Первый и третий параметры передаются по значению, а второй параметр передается по ссылке, то есть передается адрес буфера (на то, что это ссылка, указывает символ &), а не его содержимое. Затем следует собственно вызов библиотечной процедуры (шаг 4). Эта команда процессора представляет собой обычную команду вызова процедуры и применяется для вызова любых процедур.

Библиотечная процедура, возможно, написанная на ассемблере, обычно помещает номер системного вызова туда, где его ожидает операционная система, например в регистр (шаг 5). Затем она выполняет команду TRAP (эмулированное прерывание) для переключения из пользовательского режима в режим ядра и начинает выполнение с фиксированного адреса внутри ядра (шаг 6). Запускаемая программа ядра проверяет номер системного вызова и затем отправляет его нужному обработчику, как правило, используя таблицу указателей на обработчики системных вызовов, индексированную по номерам вызовов (шаг 7). В этом месте начинает функционировать обработчик системных вызовов (шаг 8). Как только он завершает свою работу, управление может возвращаться в пространство пользователя к библиотечной процедуре, к команде, следующей за командой TRAP (шаг ,9). Эта процедура в свою очередь передает управление программе пользователя обычным способом, которым производится возврат из вызванной процедуры (шаг 10). Чтобы закончить работу, программа пользователя должна очистить стек, как это делается и после каждого вызова процедуры (шаг 11). Учитывая, что стек растет вниз, последняя команда увеличивает указатель стека ровно настолько, насколько нужно для удаления параметров, помещенных в стек перед запросом read, Теперь программа может продолжать свою работу.

Рис. 2.11 этапов выполнения системного вызова read(fd, buffer, nbytes)

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

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

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

Теперь посмотрим, как реализованы системные вызовы в Windows. В Windows и Unix фундаментально отличаются соответствующие модели программирования. Программы UNIX состоят из кода, который выполняет те или иные действия, обращаясь к системе с системными запросами для предоставления ему конкретных услуг. В противоположность этому программы в Windows обычно приводятся в действие событиями. Основной модуль программы ждет, когда произойдет какое-либо событие, затем вызывает процедуру для его обработки. Типичными событиями являются: нажатие клавиши мыши или клавиатуры, передвижение мыши или появление гибкого диска в дисководе. Затем обработчики, вызываемые для обработки события, переписывают содержимое экрана и внутреннее состояние программы. Все это ведет к совершенно отличному от UNIX стилю программирования. Конечно, в Windows тоже есть системные вызовы. В UNIX вызовы почти один к одному идентичны библиотечным процедурам, используемым для обращения к системным вызовам. Другими словами, для каждого системного вызова существует одна библиотечная процедура, обычно с тем же названием, которая вызывается для обращения к нему. Ситуация в системе Windows совершенно иная. Во-первых, фактические системные вызовы и запускающиеся для их выполнения библиотечные вызовы полностью разделены. Корпорацией Microsoft определен набор процедур, называемый Win32 API (Application Program Interface – интерфейс прикладных программ). Предполагается, что программисты должны использовать его для вызова служб операционной системы. Этот интерфейс частично поддерживается всеми версиями Windows, начиная с Windows 95. Отделяя интерфейс от фактических системных вызовов, Microsoft поддерживает возможность изменения со временем действительных системных вызовов (даже от одной версии к другой), не делая при этом недействительными существующие программы. Ситуация на самом деле складывается неоднозначная, так как в Windows 2000 появилось много новых вызовов, ранее недоступных. Но сейчас мы будем понимать под Win32 интерфейс, поддерживаемый всеми версиями Windows.

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

Другое различие заключается в том, что в UNIX графический интерфейс пользователя (например, X Windows или Motif) запускается целиком в пользовательском пространстве. Поэтому для вывода на экран достаточно системного вызова write и несколько других незначительных вызовов. Конечно, существует достаточно большое количество обращений к X Windows и GUI, но они не являются системными вызовами в любом смысле этого слова.

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

Лекция 2. Стандарт Win32. Обработка ошибок. Работа со строками

Стандарт Win32

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

  •  Память. ОС управляет большим, неструктурированным пространством адресов виртуальной памяти и незаметно для пользователя перемещает информацию из физической памяти на диск и обратно.
  •  Файловые системы. ОС управляет пространством именованных файлов и обеспечивает как прямой, так и последовательный доступ, а также управление каталогами и файлами. Большая часть систем имеет иерархическое пространство имен.
  •  Именование и определение местоположения ресурсов. Правила именования файлов допускает длинные описательные имена, а схема именования распространяется на такие объекты, как устройства, объекты синхронизации и межпроцессного взаимодействия. Также ОС определяет местоположение именованных объектов и управляет доступом к ним.
  •  Многозадачность. ОС должна управлять процессами, потоками и другими модулями независимого асинхронного выполнения. Задачи могут выгружаться и планироваться в соответствии с динамически определяемыми приоритетами.
  •  Связь и синхронизация. ОС управляет связью и синхронизацией между задачами в пределах одной системы, а также сообщением между системами по сети и через Internet.
  •  Защита и безопасность. ОС обеспечивает гибкие механизмы защиты ресурсов от неправомочного и случайного доступа и повреждения.

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

  •  Windows 2000, предназначена для серверов и настольных систем;
  •  Windows NT 4.0
  •  Windows 98 и 95 – настольные ОС, не имеющие, в частности, возможностей безопасности NT и 2000
  •  Windows CE, предназначенная для малых систем, обеспечивает подмножество функций Win32

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

  •  Windows 9x и CE не имеют никаких функций безопасности.
  •  Windows 9x работает только на системах Intel.
  •  Только 2000/NT поддерживает симметричные многопроцессорные системы (SMP).
  •  Windows 9x не поддерживает расширенные символы Unicode, тогда как 2000/NT и CE используют Unicode всюду, включая имена файлов.
  •  Многочисленные различия в реализации API Win32 и архитектуре систем могут влиять на производительность, и рекомендации по повышению быстродействия на одной платформе Windows могут быть неприменимы к другой.
  •  Как правило, Windows 9x и CE поддерживают меньшее количество ресурсов, таких как открытые файлы и параллельные процессы.
  •  Windows 9x и CE имеют ограниченную поддержку асинхронного ввода-вывода.
  •  Многие функции Win32 в Windows 9x имеют ограниченную реализацию, что делает их практически бесполезными: формально функция доступна (программа может ее вызвать), но возвращает результат, указывающий на то, что вызов потерпел неудачу. Это же касается CE.

Следовательно, Windows 9x можно рассматривать как минимальную настольную клиентскую платформу. Windows 2000 и NT, с другой стороны, можно считать высококачественными платформами клиента и сервера, удовлетворяющими требованиям самых больших приложений. Наконец, CE предназначена для самых минимальных и встроенных систем.

Существует несколько основных принципов Win32, которые следует запомнить.

  •  Почти каждый системный  ресурс – это объект ядра, который идентифицируется дескриптором. Эти дескрипторы выполняют все задачи, для которых в UNIX предназначены, например, описатели файлов и идентификаторы процессов.
  •  Объектами ядра должен управлять API Win32. Нет никаких «обходных путей». Это условие соответствует принципам абстракции данных в объектно-ориентированном программировании, хотя Win32 не является объектно-ориентированным.
  •  Объекты включают файлы, процессы, потоки, каналы для межпроцессного взаимодействия, отображения в память, события и многое другое. Объекты имеют атрибуты безопасности.
  •  В отличие от UNIX, основным модулем выполнения в Win32 является не процесс, а поток. Процесс может содержать один или более потоков.
  •  Имена стандартных типов данных, требуемых для API, состоят из символов верхнего регистра и также описательны. Часто используются следующие типы: BOOL, HANDLE, DWORD, LPTSTR, LPSECURITY_ATTRIBUTES.
  •  Для стандартных типов данных не применяется оператор *; различаются, в частности, типы LPTSTR (определенный как TCHAR *) и LPCTSTR (определенный как const TCHAR *).
  •  Имена переменных, по крайней мере, в прототипах функций, также подчиняются соглашениям. Например, lpszFileName – это длинный указатель на строку с завершающим нулем (long pointer to a zero-terminated string), представляющую имя файла. Это так называемая венгерская нотация.
  •  Для того, чтобы получить возможность использовать функции Win API, необходимо включить один или несколько заголовочных файлов: WINDOWS.H (этот файл включает все остальные), WINBASE.H, WINNT.H

Обработка ошибок

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

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

DWORD GetLastError(VOID);

Она просто возвращает 32-битный код ошибки для данного потока. Теперь, когда у Вас есть код ошибки, Вам нужно обменять его на что-нибудь более внятное. Список кодов ошибок, определенных Microsoft, содержится в заголовочном файле WinError.h. Для формирования описания ошибки  есть специальная функция FormatMessage.

DWORD FormatMessage(
DWORD dwFlags,
LHCVOID pSource,
DWORD dwMessageId,
DWORD dwLanguageId,
PTSTR pszBuffer,
DWORD nSize,
va_list *Arguments);

MessageBox(NULL, (LPCTSTR)lpMsgBuf, NULL, MB_OK | MB_ICONINFORMATION);
LocalFree(lpMsgBuf);

Проблема локализации, стандарты ANSI и UNICODE

Сейчас необходимо сделать перерыв и объяснить, как Windows обрабатывает символы, и какие различия делаются между 8- и 16-разрядными символами и универсальными символами.

Win32 поддерживает стандартные 8-разрядные символы (тип char или CHAR), a Windows 2000/NT — также "расширенные" 16-разрядные символы (тип WCHAR, определенный как тип С wchar_t). В документации Microsoft набор 8-разрядных символов называется ASCII, но фактически это набор Latin-1; название ASCII будем использовать для удобства. Эти типы позволяют представлять символы и буквы всех основных языков, включая английский, французский, испанский, немецкий и китайский, с использованием представления Unicode.

Ниже перечислены этапы создания универсального приложения Win32, которое может быть сформировано с использованием как символов Unicode, так и 8-разрядных.

  1.  Определите для всех символов и строк универсальные типы TCHAR, LPTSTR и LPCTSTR.
  2.  Чтобы получить расширенные символы Unicode (тип ANSI С wchar_t), включите во все  исходные  модули определения #define UNICODE и #define _UNICODE; в противном случае TCHAR будет эквивалентен типу CHAR (char в ANSI С). Определение должно предшествовать оператору #include <windows. h>. Первая из этих переменных препроцессора управляет определениями функций Win32, а вторая — определениями библиотеки С.
  3.  Длина символьных буферов, которые используются, например, в ReadFile, должна вычисляться с помощью sizeof (TCHAR).
  4.  Применяйте набор универсальных функций строкового и символьного ввода-вывода библиотеки С из <tchar.h>. Типичные функции — fgettc, _itot (вместо itoa), _stprintf (вместо sprintf), _tstcpy (вместо strcpy), _ttoi, _totupper, _totlower и _tprintf. Полный и подробный список можно найти во встроенной справке. Действие всех этих функций зависит от определения UNICODE. Этот список неполон; memchr — пример функции, не имеющей версии для расширенных символов. Версии других функций будут представлены по мере необходимости.
  5.  Строковые константы должны иметь одну из трех форм. Это правило касается и отдельных символов. Первые две формы — ANSI С; третья, макрос _T (также TEXT и _TEXT), поддерживается компилятором Microsoft С.

"Эта строка использует 8-разрядные символы"

 L "Эта строка использует 16-разрядные символы"

Т ("Эта строка использует универсальные символы")

  1.  После <windows.h> включите <tchar.h>. Этот файл содержит необходимые определения для текстовых макросов и универсальных функций библиотеки С.

В Windows 2000/NT Unicode используется повсюду; в Unicode представлены имена файлов NTFS и полные имена. Если переменная UNICODE не определена, 8-разрядные строки преобразуются в расширенные символы, когда это требуется системными функциями. Если программа должна работать в Windows 95 или 98, которые не основаны на Unicode, UNICODE и _UNICODE определять не следует. В NT это определение не обязательно, если только исполняемая программа не должна работать в обеих системах.

Во всех последующих программах для символов и символьных строк будем использовать тип TCHAR, а не обычный char, если только не будет особой причины работать с отдельными 8-разрядными символами. Аналогично, тип LPTSTR обозначает указатель на универсальную строку, a LPCTSTR указывает также на строковую константу. Иногда эта стратегия вносит в программы некоторую путаницу, но это единственный способ обеспечить гибкость, необходимую для разработки программ как в форме Unicode, так и в форме 8-разрядных символов, которую можно будет легко преобразовать в Unicode. Кроме того, эта методика соответствует общепринятой, если не универсальной, практике в промышленности.

Рекомендуется просмотреть системные файлы включения, чтобы увидеть, как определены TCHAR и интерфейсы системных функций и как они зависят от определения переменных препроцессора UNICODE и _UNICODE. Как правило, код определения выглядит так:

#ifdef  UNICODE

#define  TCHAR WCHAR

#else

#define TCHAR CHAR

#endif

Альтернативные универсальные функции обработки строк

Для сравнения строк можно использовать функции lstгcmp и lstrcmpi, а не универсальные _tсscmap и _tcscmpi. Это позволяет учитывать во время выполнения определенный язык и регион, или местную настройку (locale), а также выполнять сравнение слов, а не строк. При сравнении строк просто сравниваются числовые значения символов, тогда как при сравнении слов учитывается специфический для языка порядок слов. Два эти метода могут давать различные результаты для таких пар строк, как соор/со-ор и were/we 're.

Существует также группа функций Win32 для работы с символами и строками Unicode. Эти функции неявно принимают местные настройки. Типичные примеры — CharUpper, которая может работать как со строками, так и с отдельными символами, и IsCharAlphaNumeric. Другие строковые функции включают CompareString (учитывающую местную настройку) и MultiByteToWideChar. Многобайтовые символы в Windows 3.1 и 9х расширяют 8-разрядный набор символов для отображения символов дальневосточных языков с помощью двух байтов.

Стратегии Unicode

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

  •  Только 8-разрядные символы. Следует игнорировать Unicode и по-прежнему использовать тип данных char (или CHAR) и функции стандартной библиотеки С,например printf, atoi и strcmp.
  •  8-разрядные символы и допускается Unicode. Следует выполнить предыдущие рекомендации для универсального приложения, но не определять обе переменные препроцессора для Unicode.
  •  Только Unicode. Выполнить вышеуказанные рекомендации и определить переменные препроцессора. Также можно использовать исключительно расширенные символы и версии функций для расширенных символов. Полученные в результате программы не будут правильно работать в Windows 9x.
  •  Unicode и 8-разрядные символы. Программа содержит код как для Unicode, так и для ASCII и в ходе выполнения решает, какую часть кода использовать, на основании переключателей командной строки или других факторов.

Написание универсального кода, несмотря на дополнительные затраты времени, позволяет программисту сохранять гибкость и формировать версии для Windows 9x и NT раздельно.


Лекция 3 Объекты ядра

Изучение Windows API мы начнем с объектов ядра и их описателей (handles). Этот материал посвящен сравнительно абстрактным концепциям, т. e. мы, не углубляясь в специфику тех или иных объектов ядра, рассмотрим их общие свойства. Эти объекты используются системой и нашими приложениями для управления множеством самых разных ресурсов процессами, потоками, файлами и т. д.

Что такое объект ядра

Создание, открытие и прочие операции с объектами ядра станут для Вас, как разработчика Windows-приложений, повседневной рутиной. Система позволяет создавать и оперировать с несколькими типами таких объектов, в том числе, маркерами доступа (access token objects), файлами (file objects), проекциями файлов (file-mapping objects), портами завершения ввода-вывода (I/O completion port objects), заданиями (job objects), почтовыми ящиками (mailslot objects), мьютсксами (mutex objects), каналами (pipe objects), процессами (process objects), семафорами (semaphore objects), потоками (thread objects) и ожидаемыми таймерами (waitable timer objects). Эти объекты создаются Windows-функциями Например, CreateFtleMapping заставляет систему
сформировать объект "проекция файла". Каждый объект ядра — на самом деле просто блок памяти, выделенный ядром и доступный только ему. Этот блок представляет собой структуру данных, в эл
ементах которой содержится информация об объекте. Некоторые элементы (дескриптор защиты, счетчик числа пользователей и др.) присутствуют во всех объектах, но большая их часть специфична для объектов конкретного типа. Например, у объекта "процесс" есть идентификатор, базовый приоритет и код завершения, а у объекта "файл" — смещение в байтах, режим разделения и режим открытия.

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

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

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

Учет пользователей объектов ядра

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

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

Защита

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

WIN98:
В Windows 98 дескрипторы защиты отсутствуют, так как она не предназначена для выполнения серверных прилож
ений. Тем не менее Вы должны знать о тонкостях, связанных с защитой, и реализовать соответствующие механизмы, чтобы Ваше приложение корректно работало и в Windows 2000.

Почти все функции, создающие объекты ядра, принимают указатель на структуру SECURITY_ATTRIBUTES как аргумент, например:

HANDLE CreateFileMapping(
HANDLE hFile.
PSECURITY_ATTRI
BUTES psa,
DWORD flPr
otect,
DWORD dwMaximumSiz
eHigh,
DWORD dwMaximuniSizeLow,
PCTSTR pszNarne);

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

typedef struct _SECURITY_ATTRIBUTES {
DWORD nLength,
LPVOID lpSecurityDescriptor;
BOOL bInherttHandle;
} SECURITY_ATTRIBUTES;

Хотя структура   называется SECURITY__ATTRIBUTES, лишь один cc элемент имеет отношение к защите — lpSecuntyDescnptor. Если надо ограничить доступ к созданному Вами объекту ядра, создайте дескриптор защиты и инициализируйте структуру SECURITY_ATTRIBUTES следующим образом:

SECURITY_ATTRIBUTES sa;

sa.nLength = sizeof(sa); // используется для выяснения версий
sa.lpSecuntyDescriptor = pSD, // адрес инициализированной SD
sa.bInheritHandle = FALSE; // об этом позже
HANDLE hFileMapping = CreateFileMapping(INVALID_HANDLE_VALUE, &sa, PAGE_REAOWRITE, 0, 1024, "MyFileMapping");

Рассмотрение элемента bInheritHandle я отложу до раздела о наследовании, так как этот элемент не имеет ничего общего с защитой.

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

HANDLE hFileMapping = OpenFileMapping(FILE_MAP_READ, FALSE, "MyFileMapping");

Передавая FILE_MAPREAD первым параметром в функцию OpenFileMapping, я сообщаю, что, как только мне предоставят доступ к проекции файла, я буду считывать из нее данные. Функция OpenFileMapping, прежде чем вернуть действительный описатель, проверяет тип защиты объекта. Если меня, как зарегистрировавшегося пользователя, допускают к существующему объекту ядра "проекция файла", OpenFileMapping возвращает действительный описатель. Но если мне отказывают в доступе, OpenFileMapping возвращает NULL, а вызов GetLastError дает код ошибки 5 (или ERROR_ACCESS_DENIED). Но опять же, в основной массе приложений защиту не используют, и поэтому я больше не буду задерживаться на этой теме

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

Представьте, что при запуске приложение считывает данные из какого-то раздела реестра Чтобы делать это корректно, оно должно вызывать функцию RegOpenKeyEx, передавая значение KEY_QUERY_VALUE, которое разрешает операцию чтения в указанном разделе.

Однако многие приложения для Windows 98 создавались без учета специфики Windows 2000 Поскольку Windows 98 не защищает свой реестр, разработчики часто вызывали RegQpenKeyEx со значением KEY_ALL_ACCESS. Так проще и не надо ломать голову над том, какой уровень доступа требуется на самом деле. Но проблема в том, что раздел реестра может быть доступен для чтения и блокирован для записи. В Windows 2000 вызов RegOpenKeyEx со значением KEY_ALL_ACCESS заканчивается неудачно, и без соответствующего контроля ошибок приложение может повести себя совершенно непредсказуемо.

Если бы разработчик хоть немного подумал о защите и поменял значение KEY_ALL_ACCESS на KEY_QUERY_VALUE (только-то и всего!), его продукт мог бы работать в обеих операционных системах

Пренебрежение флагами, определяющими уровень доступа, — одна из самых крупных ошибок, совершаемых разработчиками. Правильное их использование позволило бы легко перенести многие приложения Windows 98 в Windows 2000.

Кроме объектов ядра Ваша программа может использовать объекты других типов — меню, окна, курсоры мыши, кисти и шрифты. Они относятся к объектам User или GDI Новичок в программировании для Windows может запутаться, пытаясь отличить объекты User или GDI от объектов ядра. Как узнать, например, чьим объектом — User или ядра — является данный значок? Выяснить, не принадлежит ли объект ядру, проще всего так проанализировать функцию, создающую объект. Практически у всех функций, создающих объекты ядра, есть параметр, позволяющий указать атрибуты защиты, — как у CreateFileMapping.

В то же время у функций, создающих объекты User или GDI, нет параметра типа PSECURITY_ATTRIBUTES, и пример тому — функция CreateIcon

HICON CreateIcon(
HINSTANCE hinst.
int nWidth,
int nHeight,
BYTE cPlanes,
BYTE cBitsPixel,
CONST BYTE *pbANDbits,
CONST BYTE *pbXORbits);

Таблица описателей объектов ядра

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

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

Индекс

Указатель на блок памяти объекта ядра

Маска доступа (DWORD с набором битовых флагов)

Флаги (DWORD с набором битовых флагов)

1

0xF0000000

0x????????

0x00000000

2

0xF0000010

0х????????

0x00000001

Таблица 3-1. Структура таблицы описателей, принадлежащей процессу

Создание объекта ядра

Когда процесс инициализируется в первый paз, таблица описателей еще пуста. Но стоит одному из его потоков вызвать функцию, создающую объект ядра (например, CreateFtleMapping), как ядро выделяет для этого объекта блок памяти и инициализирует его, далее ядро просматривает таблицу описателей, принадлежащую данному процессу, и отыскивает свободную запись. Поскольку таблица еще пуста, ядро обнаруживает структуру с индексом 1 и инициализирует ее. Указатель устанавливается на внутренний адрес структуры данных объекта, маска доступа — на доступ без ограничений и, наконец, определяется последний компонент — флаги.

Вот некоторые функции, создающие объекты ядра (список ни в коей мере на полноту не претендует)

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)

}

Закрытие объекта ядра

Независимо от того, как именно Вы создали объект ядра, по окончании работы с ним его нужно закрыть вызовом CloseHandle

BOOL CloseHandle(HANDLE hobj);

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

Если же описатель неверен, происходит одно из двух. В нормальном режиме выполнения процесса CloseHandle возвращает FALSE, a GetLastError — код ERROR_INVALID_HANDLE. Но при выполнении процесса в режиме отладки система просто уведомляет отладчик об ошибке.

Перед самым возвратом управления CloseHandle удаляет соответствующую запись из таблицы описателей: данный описатель теперь недействителен в Вашем процессе и использовать его нельзя. При этом запись удаляется независимо от того, разрушен объект ядра или нет! После вызова CloseHandle Вы больше не получите доступ к этому объекту ядра; но, если его счетчик не обнулен, объект остается в памяти Тут все нормально, это означает лишь то, что объект используется другим процессом (или процессами). Когда и остальные процессы завершат свою работу с этим объектом (тоже вызвав CloseHandle), он будет разрушен.

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

Совместное использование объектов ядра несколькими процессами

Время от времени возникает необходимость в разделении объектов ядра между потоками, исполняемыми в разных процессах. Причин тому может быть несколько:

• объекты "проекции файлов" позволяют двум процессам, исполняемым на одной машине, совместно использовать одни и те же блоки данных;

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

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

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

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

Наследование описателя объекта

Наследование применимо, только когда процессы связаны родственными отношениями (родительский - дочерний). Например, родительскому процессу доступен один или несколько описателей объектов ядра, и он решает, породив дочерний процесс, передать ему по наследству доступ к своим объектам ядра. Чтобы такой сценарий наследования сработал, родительский процесс должен выполнить несколько операций.

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

Чтобы создать наследуемый описатель, родительский процесс выделяет и инициализирует структуру SECURITY_ATTRIBUTES, а затем передает ее адрес требуемой Create-функции. Следующий код создаст объект-мьютекс и возвращает его описатель:

SECURITY_ATTRIBUTES sa;
sa.nLength = sizeof(sa);
sa.lpSecuntyDescriptor = NULL;
sa.bInheritHandle =- TRUE; // делаем возвращаемый описатель н
аследуемым
HANDLE hMutex = CreateMutex(&sa, FALSE, NULL);

Этот код инициализирует структуру SECURTY_ATTRIBUTES, указывая, что объект следует создать с защитой по умолчанию (в Windows 98 это игнорируется) и что возвращаемый описатель должен быть наследуемым.

WINDOWS 98:
Хотя Windows 98 не полностью поддерживает защиту, она все же поддержива
ет наследование и поэтому корректно обрабатывает элемент bInheritHandle.

А теперь перейдем к флагам, которые хранятся в таблице описателей, принадлежащей процессу. В каждой ее записи присутствует битовый флаг, сообщающий, является данный описатель наследуемым или нет. Если Вы, создавая объект ядра, передадите в параметре типа PSECURITY_ATTRIBUTES значение NULL, то получите ненаследуемый описатель, и этот флаг будет нулевым. А если элемент bInheritHandle равен TRUE, флагу пpиcвaивaeтcя 1.

Допустим, какому-то процессу принадлежит таблица описателей, как в таблице 3-2.

Индекс

Указатель на блок памяти объекта ядра

Маска доступа (DWORD с набором битовых флагов)

Флаги (DWORD с набором битовых флагов)

1

0xF0000000

0x????????

0x00000000

2

0x00000000

(неприменим)

(неприменим)

3

0xF0000010

0х????????

0x00000001

Таблица 3-2. Таблица описателей с двумя действительными записями

Эта таблица свидетельствует, что данный процесс имеет доступ к двум объектам ядра: описатель 1 (ненаследуемый) и 3 (наследуемый)

Следующий этап — родительский процесс порождает дочерний. Это делается с помощью функции CreateProcess,

BOOL CreateProcess(
PCTSTR pszApplicationName,
PTSTR pszCommandLine,
PSECURITY_ATTRIBUTES psaProcess,
PSECURITY_ATTRIBUTES psaThread,
BOOL bInheritHandles,
DWORD fdwCreale,
PVOIO pvEnvironment,
PCTSTR pszCurDir,
PSTARTUPINFO psiStartInfo,
PPROCESS_INFORMATION ppiPr
ocInfo);

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

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

В таблице 3-3 показано состояние таблицы описателей в дочернем процессе — перед самым началом его исполнения. Как видите, записи 1 и 2 не инициализированы, и поэтому данные описатели неприменимы в дочернем процессе Однако индекс 3 действительно идентифицирует объект ядра по тому же (что и в родительском) адресу 0xF0000010. При этом маска доступа и флаги в родительском и дочернем процессах тоже идентичны. Так что, если дочерний процесс в свою очередь породит новый ("внука" по отношению к исходному родительскому), "внук" унаследует данный описатель объекта ядра с теми же значением, правами доступа и флагами, а счетчик числа пользователей этого объекта ядра вновь увеличится на 1.

Индекс

Указатель на блок памяти объекта ядра

Маска доступа (DWORD с набором битовых флагов)

Флаги (DWORD с набором битовых флагов)

1

0x00000000

(неприменим)

(неприменим)

2

0x00000000

(неприменим)

(неприменим)

3

0xF0000010

0х????????

0x00000001

Таблица 3-3. Таблица описателей в дочернем процессе (после того как он унаследовал от родительского один наследуемый описатель)

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

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

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

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

Еще один прием: родительский процесс добавляет в свой блок переменных окружения новую переменную Она должна быть "узнаваема" дочерним процессом и содержать значение наследуемого описятеля объекта ядра, Далее родительский процесс создает дочерний, тот наследует переменные окружения родительского процесса и, вызвав GetEnvironmentVariable, получает нужный описатель. Такой прием особенно хорош, когда дочерний процесс тоже порождает процессы, — ведь все переменные окружения вновь наследуются.

Изменение флагов описателя

Иногда встречаются ситуации, в которых родительский процесс создает объект ядра с наследуемым описателем, а затем порождает два дочерних процесса. Но наследуемый описатель нужен только одному из них. Иначе говоря, время от времени возникает необходимость контролировать, какой из дочерних процессов наследует описатели объектов ядра. Для этого модифицируйте флаг наследования, связанный с описателем, вызовом SetHandleInformation

BOOL SetHandleInformation(
HANDLE hObject,
DWORD dwMask,
DWORD dwFlags);

Как видите, эта функция принимает три параметра. Первый (bObject) идентифицирует допустимый описатель. ВтороЙ (dwMask) сообщает функции, какой флаг (или флаги) Вы хотите изменить На сегодняшний день с каждым описателем связано два флага:

#define HANDLE FLAG_INHERIT 0x00000001
#define HANDLE FLAG PROTECT FROM CLOSE 0x00000002

Чтобы изменить сразу все флаги объекта, нужно объединить их побитовой операцией OR.

И, наконец, третий параметр функции SetHandleInformation — dwFlags — указывает, в какое именно состояние следует перевести флаги. Например, чтобы установить флаг наследования для описателя объекта ядра

SetHandleInformation(hobj, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);

а чтобы сбросить этот флаг:

SetHandleInformation(hobj, HANDLE_FLAG_INHERIT, 0);

Флаг HANDLE_FLAGPROTECT_FROM_CLOSE сообщает системе, что данный описатель закрывать нельзя:

SetHandleInformation(hobj, HANDLE_FLAG_PROTECT_FROM_CLOSE, HANDLE_FLAG_PROTECT_FROM_CLOSE);
CloseHandle(hobj); //
генерируется исключение

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

У этого подхода, впрочем, есть один недостаток. Дочерний процесс, вызвав:

SetHandleInformation(hobj, HANDLEMFLAG_PROlECl_FROM_CLOSE, 0);
CloseHandle(hobj);

может сбросить флаг HANDLE_FLAG_PROTECT_FROM_CLOSE и закрыть затем соответствующий описатель. Родительский процесс ставит на то, что дочерний не исполнит этот код. Но одновременно он ставит и на то, что дочерний процесс породит ему "внука", поэтому в целом ставки не слишком рискованны.

Для полноты картины стоит, пожалуй, упомянуть и функцию GetHandleInformation:

BOOL GetHandleInformation(
HANDLE hObj,
PDWORD pdwFlags);

Эта функция возвращает текущие флаги для заданного описателя в переменной типа DWORD, на которую укапывает pdwFlags. Чтобы проверить, является ли описатель наследуемым, сделайте так:

DWORD dwFlags;
GetHandleInformation(hObj, &dwFlags);
BOOL fHandleIsInheritable = (0 != (dwFlags & HANDLE_FLAG_INHERIT));

Именованные объекты

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

HANDLE CreateMutex(
PSLCURITY_ATTRI
BUTES psa,
BOOL bIniti
alOwner,
PCTSTR pszName);

HANDLE CreateSemaphore(
PSECURITY_ATTRI
BUTES psa,
LONG lInitia
lCount,
LONG lMaximu
mCount,
PCTSTR pszNarne);

Последний параметр, pszName, у всех этих функций одинаков. Передавая в нем NULL, Вы создаете безымянный (анонимный) объект ядра. В этом случае Вы можете разделять объект между процессами либо через наследование (см. предыдущий раздел), либо с помощью DuplicateHandle (см. следующий раздел). А чтобы разделять объект по имени, Вы должны присвоить ему какое-нибудь имя. Тогда вместо NULL в параметре pszName нужно передать адрес строки с именем, завершаемой нулевым символом. Имя может быть длиной до MAX_PATH знаков (это значение определено как 260). К сожалению, Microsoft ничего не сообщает о правилах именования объектов ядра. Например, создавая объект с именем JeffObj, Вы никак не застрахованы от того, что в системе еще нет объекта ядра с таким именем. И что хуже, все эти объекты делят единое пространство имен. Из-за этого следующий вызов CreateSemaphore будет всегда возвращать NULL:

HANDLE hMutex = CreateMutex(NULL. FALSE, "JeffObj");
HANDLE hSem = CreateSemaphore(NULL, 1, 1, "JeffObj");
DWORD dwErrorCode = GetLastError();

После выполнения этого фрагмента значение dwErrorCode будет равно 6 (ERROR_INVALID_HANDLE). Полученный код ошибки не слишком вразумителен, но другого не дано.

Теперь, когда Вы научились именовать объекты, рассмотрим, как разделять их между процессами по именам. Допустим, после запуска процесса А вызывается функция:

HANDLE hMutexPronessA = CreateMutex(NULL, FALSE, "JeffMutex");

Этот вызов заставляет систему создать новенький, как с иголочки, объект ядра "мъютекс" и присвоить ему имя JeffMutex. Заметьте, что описатель hMutexProcessA в процессе А не является наследуемым, — он и не должен быть таковым при простом именовании объектов.

Спустя какое-то время некий процесс порождает процесс В. Необязательно, что-бы последний был дочерним от процесса А; он может быть порожден Explorer или любым другим приложением. (В этом, кстати, и состоит преимущество механизма именования объектов перед наследованием.) Когда процесс В приступает к работе, исполняется код:

HANDLE hMutexProcessB = CreateMutex(NULL, FALSE, "JeffMutex");

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

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

NOTE:
Разделяя объекты ядра по именам, помните об одной крайне важной вещи.

Вызывая CreateMutex, процесс В передает ей атрибуты защиты и второй параметр. Так вот, эти параметры игнорируются, если объект с указанным именем уже существует! Приложение может определить, что оно делает: создает новый объект ядра или просто открывает уже существующий, — вызвав GetLastError сразу же после вызова одной из Create-функций:

HANDLE hMutex = CreateMutex(&sa, FALSE, "JeffObj");
if (GetLastError() == ERROR_ALREADY_EXISTS) {
// открыт описатель существующего объекта sa.lpSecurityDescriptor и второй параметр (FALSE) игнорир
уются
} else {
// создан совершенно новый объект sa.lpSecurityDescriptor и второй параметр (FALSE) используются при созд
ании объекта
}

Есть и другой способ разделения объектов по именам. Вместо вызова Create-функции процесс может обратиться к одной из следующих Open-функций:

HANDLE OpenMutex(
DWORD dwDesiredA
ccess,
BOOL bI
nheritHandle,
PCTSTR pszName);

HANDLE OpenSemaphore(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
PCTSTR pszName),

Заметьте: все эти функции имеют один прототип. Последний параметр, pszName, определяет имя объекта ядра. В нем нельзя передать NULL — только адрес строки с нулевым символом в конце Эти функции просматривают единое пространство имен объектов ядра, пытаясь найти совпадение. Если объекта ядра с указанным именем нет, функции возвращают NULL, a GetLastError — код 2 (ERROR_FILE_NOT_FOUND). Но если объект ядра с заданным именем существует и если его тип идентичен тому, что Вы указали, система проверяет, разрешен ли к данному объекту доступ запрошенного вида (через параметр dwDesiredAccess). Если такой вид доступа разрешен, таблица описателей в вызывающем процессе обновляется, и счетчик числа пользователей объекта возрастает на 1 Если Вы присвоили параметру bInheritHandle значение TRUE, то получше наследуемый описатель.

Главное отличие между вызовом Create- и Open-функций в том, что при отсутствии указанного объекта Create-функция создает его, а Open-функция просто уведомляет об ошибке.

Как я уже говорил, Microsoft ничего не сообщает о правилах именования объектов ядра Но представьте себе, что пользователь запускает две программы от разных компаний и каждая программа пытается создать объект с именем "MyObject". Ничего хорошего из этого не выйдет. Чтобы избежать такой ситуации, я бы посоветовал создавать GUID и использовать его строковое представление как имя объекта.

Именованные объекты часто применяются для того, чтобы не допустить запуска нескольких экземпляров одного приложения. Для этого Вы просто вызываете одну из Create-функций в своей функции main или WinMain и создаете некий именованный объект. Какой именно — не имеет ни малейшего значения. Сразу после Create-функции Вы должны вызвать GetLastError. Если она вернет ERROR_ALREADY_EXISTS, значит, один экземпляр Вашего приложения уже выполняется и новый его экземпляр можно закрыть. Вот фрагмент кода, иллюстрирующий этот прием:

int WINAPI WinMain(HINSTANCE hinstExe, HINSTANCE, PSTR pszCmdLine, int nCmdShow} {
HANDLE h = CreateMutex(NULL, FALSE, "{FA531CC1-0497-11d3-A180-00105A276C3E}");
lf (GetLastError() == ERROR_ALREADY_EXISTS){
// экземпляр этого приложения уже выпо
лняется
return(0),
}

// запущен первый экземпляр данного приложения
// перед выходом закрываем объект
CloseHandle(h),
return(0);
Дублирование описателей объектов

Последний механизм совместного использования объектов ядра несколькими процессами требует функции DuplicateHandle;

BOOL DuplicateHandle(
HANDLE hSourceProcessHandle,
HANDLE hSourceHandle,
HANDLE hTargetProcessHandle,
PHANDLE phTargetHandle,
DWORD dwDesiredAccess,
BOOL bInheritHandle,
DWORD dwOptions);

Говоря по-простому, эта функция берет запись в таблице описателей одного процесса и создает ее копию в таблице другого. DuplicateHandle принимает несколько параметров, но на самом деле весьма незамысловата Обычно ее применение требует наличия в системе трех рапных процессов.

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

Второй параметр, hSourceHandle, — описатель объекта ядра любого типа. Однако его значение специфично нс для процесса, вызывающего DuplicateHandle, а для того, на который указывает описатель hSourceProcessHandie. Параметр pbTargetHandle — это адрес переменной типа HANDLE, в которой возвращается индекс записи с копией описателя из процесса-источника. Значение возвращаемого описателя специфично для процесса, определяемого параметром bTargetProcessHandle.

Предпоследние два параметра DuplicateHandle позволяют задать маску доступа и флаг наследования, устанавливаемые для данного описателя в процессе-приемнике. И, наконец, параметр dwOptions может быть 0 или любой комбинацией двух флагов. DUPLICATE_SAME_ACCESS и DUPLICATE_CLOSE_SOURCE

Первый флаг подсказывает DuplicateHandle: у описателя, получаемого процессом-приемником, должна быть та же маска доступа, что и у описателя в процессе-источнике Этот флаг заставляет DuplicateHandle игнорировать параметр dwDesiredAccess.

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

Попробуем проиллюстрировать работу функции Duplicatellandle на примере. Здесь S — это процесс-источник, имеющий доступ к какому-то объекту ядра, Т — это процесс-приемник, который получит доступ к тому же объекту ядра, а С — процесс-катализатор, вызывающий функцию DuplicateHandle.

Поскольку функции DuplicateHandle передан флаг DUPLICATE_SAME_ACCESS, маска доступа для этого описателя в процессе Т идентична маске доступа в процессе S. Кроме того, данный флаг заставляет DuplicateHandle проигнорировать параметр dwDesiredAccess. Заметьте также, что система установила битовый флаг наследования, так как в параметре bInberitHandle функции DuplicateHandle мы передали TRUE.

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

Как и механизм наследования, функция DuplicateHandle тоже обладает одной странностью: процесс-приемник никак не уведомляется о том, что он получил доступ к новому объекту ядра. Поэтому процесс С должен каким-то образом сообщить процессу Т, что тот имеет теперь доступ к новому объекту; для этого нужно воспользоваться одной из форм межпроцессной связи и передать в процесс Т значение описателя в переменной bObj. Ясное дело, в данном случае не годится ни командная строка, ни изменение переменных окружения процесса Т, поскольку этот процесс уже выполняется. Здесь придется послать сообщение окну или задействовать какой-нибудь другой механизм межпроцессной связи.

Я рассказал Вам о функции DuplicateHandle в самом общем виде. Надеюсь, Вы увидели, насколько она гибка. Но эта функция редко используется в ситуациях, требующих участия трех разных процессов. Обычно ее вызывают применительно к двум процессам. Представьте, что один процесс имеет доступ к объекту, к которому хочет обратиться другой процесс, или что один процесс хочет предоставить другому доступ к "своему" объекту ядра. Например, если процесс S имеет доступ к объекту ядра и Вам нужно, чтобы к этому объекту мог обращаться процесс Т, используйте DuplicateHandle так:

// весь приведенный ниже код исполняется процессом S
// создаем объект-мьютекс, до
ступный процессу S
HANDLE hObjProcessS = CreateMutex(NULL, FALSE, NULL);

// открываем описатель объекта ядра "процесс Т"
HANDLE hProcessT = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwProcessIdT);
HANDLE hObjProcessT; // неинициализир
ованный описатель,

// связанный с процессом Т
// предоставляем процессу Т до
ступ к объекту-мьютексу
DuplicateHandle(GetCurrentProcess(), hObjProcessS, hProcessT,
&hObjProcessT, 0, FALSE, DUPLICATE_SAME_ACCESS);

// используем какую-нибудь форму межпроцессной связи, чтобы передать
// значение описателя из hOb]ProcessS в процесс Т

// связь с процессом Т больше не нужна
CloseHandle(hProcessT),

// если процессу S не нужен объект-мьютекс, он должен закрыть его
CloseHandle(hObjProcessS);

Вызов GetCurrentProcess возвращает псевдоописатель, который всегда идентифицирует вызывающий процесс, в данном случае — процесс S. Как только функция DuplicateHandle возвращает управление, bObjProcessT становится описателем, связанным с процессом Т и идентифицирующим тот же объект, что и описатель bObjProcessS (когда на него ссылается код процесса S). При этом процесс S ни в коем случае не должен исполнять следующий код:

// процесс S никогда не должен пытаться исполнять код,
// закрывающий продублированный опис
атель
CloseHandle(hObjProcessT);

Если процесс S выполнит этот код, вызов может дать (а может и не дать) ошибку Он будет успешен, если у процесса S случайно окажется описатель с тем же значением, что и в hObjProcessT. При этом процесс S закроет неизвестно какой объект, и что будет потом — остается только гадать.

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

Лекция 5. Синхронизация процессов и потоков

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

Проблема разбивается на три пункта. Первый мы уже упомянули: передача информации от одного процесса другому. Второй связан с контролем над деятельностью процессов: как гарантировать, что два процесса не пересекутся в критических ситуациях (представьте себе два процесса, каждый из которых пытается завладеть последним мегабайтом памяти). Третий касается согласования действий процессов: если процесс А должен поставлять данные, а процесс В выводить их на печать, то процесс В должен подождать и не начинать печатать, пока не поступят данные от процесса А. Мы рассмотрим все три случая в следующем подразделе.

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

Состояние состязания

В некоторых операционных системах процессы, работающие совместно, могут сообща использовать некое общее хранилище данных. Каждый из процессов может считывать из общего хранилища данных и записывать туда информацию. Это хранилище представляет собой участок в основной памяти (возможно, в структуре данных ядра) или файл общего доступа. Местоположение совместно используемой памяти не влияет на суть взаимодействия и возникающие проблемы. Рассмотрим межпроцессное взаимодействие на простом, но очень распространенном примере: спулер печати. Если процессу требуется вывести на печать файл, он помещает имя файла в специальный каталог спулера. Другой процесс, демон печати, периодически проверяет наличие файлов, которые нужно печатать, печатает файл и удаляет его имя из каталога.

Представьте, что каталог спулера состоит из большого числа сегментов, пронумерованных 0, 1, 2, ..., в каждом их которых может храниться имя файла. Также есть две совместно используемые переменные: out, указывающая па следующий файл для печати, и in, указывающая на следующий свободный сегмент. Эти две переменные можно хранить в одном файле (состоящем из двух слов), доступном всем процессам. Пусть в данный момент сегменты с 0 по 3 пусты (эти файлы уже напечатаны), а сегменты с 4 по 6 заняты (эти файлы ждут своей очереди на печать). Более или менее одновременно процессы A и В решают поставить файл в очередь на печать.

В соответствии с законом Мерфи (он звучит примерно так: «Если что-то плохое может случиться, оно непременно случится») возможна следующая ситуация. Процесс А считывает значение (7) переменной т и сохраняет его в локальной переменной next_free_slot. После этого происходит прерывание по таймеру, и процессор переключается на процесс В. Процесс В, в свою очередь, считывает значение переменной in и сохраняет его (опять 7) в своей локальной переменной next_free_slot. В данный момент оба процесса считают, что следующий свободный сегмент — седьмой.

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

Наконец управление переходит к процессу А, и он продолжает с того места, на котором остановился. Он обращается к переменной next_free_slot, считывает ее значение и записывает в седьмой сегмент имя файла (разумеется, удаляя при этом имя файла, записанное туда процессом В), Затем он заменяет значение m на 8 (next_free_slot +1=8). Структура каталога спулера не нарушена, так что демон печати не заподозрит ничего плохого, но файл процесса В не будет напечатан. Пользователь, связанный с процессом В, может в этой ситуации полдня описывать круги вокруг принтера, ожидая требуемой распечатки. Ситуации, в которых два (и более) процесса считывают или записывают данные одновременно и конечный результат зависит от того, какой из них был первым, называются состояниями состязания. Отладка программы, в которой возможно состояние состязания, вряд ли может доставить удовольствие. Результаты большинства тестовых прогонов будут хорошими, но изредка будет происходить нечто странное и необъяснимое.

Критические области

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

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

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

  1.  Два процесса не должны одновременно находиться в критических областях.
  2.  В программе не должно быть предположений о скорости или количестве процессоров.
  3.  Процесс, находящийся вне критической области, не может блокировать другие процессы.
  4.  Невозможна ситуация, в которой процесс вечно ждет попадания в критическую область.

В абстрактном виде требуемое поведение процессов представлено на рис. 2.15. Процесс A попадает в критическую область в момент времени T1. Чуть позже, в момент времени Т2, процесс В пытается попасть в критическую область, но ему это не удается, поскольку в критической области уже находится процесс А, а два процесса не должны одновременно находиться в критических областях. Поэтому процесс В временно приостанавливается, до наступления момента времени T3, когда процесс A выходит из критической области. В момент времени T4 процесс В также покидает критическую область, и мы возвращаемся в исходное состояние, когда ни одного процесса в критической области не было.

Взаимное исключение с активным ожиданием

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

Запрещение прерываний

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

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

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

Строгое чередование

Другой метод реализации взаимного исключения иллюстрирован на рис. 2.16.

Рис. 2.16. Предлагаемое решение проблемы критической области: процесс 0 (слева); процесс 1 (справа).

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

На рис. 2.16 целая переменная turn, изначально равная 0, отслеживает, чья очередь входить в критическую область. Вначале процесс 0 проверяет значение turn, считывает 0 и входит в критическую область. Процесс 1 также проверяет значение turn, считывает 0 и после этого входит в цикл, непрерывно проверяя, когда же значение turn будет равно 1. Постоянная проверка значения переменной в ожидании некоторого значения называется активным ожиданием. Подобного способа следует избегать, поскольку он является бесцельной тратой времени процессора. Активное ожидание используется только в случае, когда есть уверенность в небольшом времени ожидания. Блокировка, использующая активное ожидание, называется спин-блокировкой.

Когда процесс 0 покидает критическую область, он изменяет значение turn на 1, позволяя процессу 1 попасть в критическую область. Предположим, что процесс 1 быстро покидает свою критическую область, так что оба процесса теперь находятся вне критической области, и значение turn равно 0. Теперь процесс 0 выполняет весь цикл быстро, выходит из критической области и устанавливает значение turn равным 1. В этот момент значение turn равно 1, и оба процесса находятся вне критической области.

Неожиданно процесс 0 завершает работу вне критической области и возвращается к началу цикла. Но войти в критическую область он не может, поскольку значение turn равно 1 и процесс 1 находится вне критической области. Процесс 0 зависнет в своем цикле while, ожидая, пока процесс 1 изменит значение turn на 0. Получается, что метод поочередного доступа к критической области не слишком удачен, если один процесс существенно медленнее другого.

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

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

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

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

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

Алгоритм Петерсона

Датский математик Деккер (Т, Dekker) был первым, кто разработал программное решение проблемы взаимного исключения, не требующее строгого чередования.

В 1981 году Петерсон (G. L. Peterson) разработал существенно более простой алгоритм взаимного исключения. С этого момента алгоритм Деккера стал считаться устаревшим. Алгоритм Петерсона, представленный в листинге 2.1, состоит из двух процедур, написанных на ANSI С, что предполагает необходимость прототипов для всех определяемых и используемых функций. В целях экономии места мы не будем приводить прототипы для этого и последующих примеров.

Листинг 2.1. Решение Петерсона для взаимного исключения

#define FALSE О

#define TRUE  1

#def1ne N      2 /* Количество процессов */

int turn; /* Чья сейчас очередь? */

int interested[N]; /* Все переменные изначально равны О (FALSE) */

void enter_region(int process): /* Процесс 0 или 1 */

{

int other: /* Номер второго процесса */

other = 1 - process: /* Противоположный процесс */

interested[process] = TRUE; /* Индикатор интереса*/

turn = process: /* Установка флага*/

while (turn == process && interested[other] == TRUE) /* Пустой оператор */;

}

void leave_region(int process)       /* process: процесс, покидающий критическую область */

{

interested[process] = FALSE: /* Индикатор выхода из критической области */

}

Прежде чем обратиться к совместно используемым переменным (то есть перед тем, как войти в критическую область), процесс вызывает процедуру enter_region со своим номером (0 или 1) в качестве параметра. Поэтому процессу при необходимости придется подождать, прежде чем входить в критическую область. После выхода из критической области процесс вызывает процедуру leave_region, чтобы обозначить свой выход и тем самым разрешить другому процессу вход в критическую область.

Рассмотрим работу алгоритма более подробно. Исходно оба процесса находятся вне критических областей. Процесс 0 вызывает enter_region, задает элементы массива и устанавливает переменную turn равной 0. Поскольку процесс 1 не заинтересован в попадании в критическую область, процедура возвращается. Теперь, если процесс 1 вызовет enter_region, ему придется подождать, пока interested [0] примет значение FALSE, а это произойдет только в тот момент, когда процесс 0 вызовет процедуру leave_region, чтобы покинуть критическую область.

Представьте, что оба процесса вызвали enter_region практически одновременно. Оба сохранят свои номера в turn. Сохранится номер того процесса, который был вторым, а предыдущий номер будет утерян. Предположим, что вторым был процесс 1, так что значение turn равно 1. Когда оба процесса дойдут до оператора while, процесс 0 войдет в критическую область, а процесс 1 останется в цикле и будет ждать, пока процесс 0 выйдет из критической области.

Теперь перейдем к рассмотрению существующих решений проблем синхронизаций в ОС Windows.

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

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

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

DWORD WINAPI ThreadFunc1(PVOID pvParam) {
            
g_x++;
            
return(0); }

DWORD WINAPI ThreadFunc2(PVOID pvParam} {
            g_x++;
            r
eturn(0); }

Я объявил глобальную переменную g_n и инициализировал ее нулевым значением. Теперь представьте, что я создал два потока: один выполняет ThreadFunc1, другой — ThreadFunc2 Код этих функций идентичен: обе увеличивают значение глобальной переменной g_x на 1. Поэтому Вы, наверное, подумали: когда оба потока завершат свою работу, значение g_x будет равно 2. Так ли это? Может быть. При таком коде заранее сказать, каким будет конечное значение g_x, нельзя. И вот почему. Допустим, компилятор сгенерировал для строки, увеличивающей g_x на 1, следующий код:

MOV EAX, [g_x]; значение из g_x помещается в регистр

INC EAX; значение регистра увеличивается на 1

MOV [g_x], EAX; значение из регистра помещается обратно в g_x

Вряд ли оба потока будут выполнять этот код в одно и то же время. Если они будут делать это по очереди — сначала один, потом другой, тогда мы получим такую картину:

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

INC EAX ; поток V значение регистра увеличивается на 1

MOV [g_x], EAX , поток 1. значение 1 помещается в g_x

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

INC EAX ; поток 2. значение регистра увеличивается до 2

MOV [g_x], EAX , поток 2. значение 2 помещается в g_x

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

MOV EAX, [g_x] ; лоток V в регистр помещается 0
INC
 EAX ; поток 1. значение регистра увеличивается на 1

MOV EAX, [g_x] ; поток 2 в регистр помещается 0
INC EAX ; поток 2. значение регистра увеличив
ается на 1
MOV [g_x], EAX , поток 2. значение 1 помещ
ается в g_x

MOV [g_x], EAX , поток V значение 1 помещается в g_x

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

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

LONG InterlockedExchangeAdd( PLONG plAddend, LONG lIncrement);

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

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

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

DWORD WINAPI ThreadFunc2(PVOID pvPararr) {
InterlockedExchang
eAdd(&g_x, 1);
r
eturn(0); }

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

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

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

// правильный способ увеличения переменной типа LONG
InterlockedExchangeAdd(&g_x, 1);

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

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

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

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

  •  замена значения переменной типа LONG на указанное Вами;
  •  одновременное сравнение и присвоение в случае равенства;

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

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

Interlocked-функции можно также использовать в потоках различных процессов для синхронизации доступа к переменной, которая находится в разделяемой области памяти, например в проекции файла.

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

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

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

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

const int MAX_TIMES = 1000;

int g_nIndex = 0;

DWORD g_dwTimes[MAX_TIMES];

DWORD WINAPI FirstThread(PVOID pvParam)
{

   while (g_nIndex < MAX_TIMES)
   {

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

   return(0);
}

DWORD WINAPI SecondThread(PVOID pvParam)
{

   while (g_nIndex < MAX_TIMES)
   {

       g_nIndex++;

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

   return(0);
}

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

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

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

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

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

DWORD WINAPI FirstThread(PVOID pvParam)
{

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

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

       }
       else
           fCo
ntinue = FALSE;
       LeaveCriticalSe
ction(&g_cs);
   }

   return(0);
}

 

DWORD WINAPI SecondThread(PVOID pvParam)
{

   for (BOOL fContinue = TRUE; fContinue; )
   {
       EnterCriticalSe
ction(&g_cs);
       if (g__nIndex < MAX_TIMES)
       {
           g_nIndex++;
           g_dwTimes[g_nIndex - 1] = GetTic
kCount();
       }
       else
           fCo
ntinue = FALSE;
       LeaveCriticalS
ecLion(&g_cs);
   }

   return(0);

}

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

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

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

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

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

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

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

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

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

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

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

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

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

VOID InitializeCriticalSection(PCRITICAL_SECTION pcs);

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

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

VOID DeleteCriticalSection(PCRITICAL__SECTION pcs);

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

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

VOID EnterCriticalSection(PCRITICAL_SECTION pcs);

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

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

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

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

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

HKEY_LOCAL_MACHlNE\System\CurrentControlSet\Control\Session Manager

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

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

BOOL TryEnterCriticalSection(PCRITICAL_SECTIQN pcs);

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

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

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

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

VOID LeaveCriticalSection(PCRITICAL_SECTION pcs);

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

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

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

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

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

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

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

BOOL InitalizeCriticalSectionAndSpinCount( PCRITICAL_SECTION pcs, DWORD dwSpinCount);

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

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

DWORD SetCriticalSectionSpinCount( PCRITICAL_SECTION pcs, DWORD dwSpinCount);

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

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

Синхронизация при помощи объектов ядра

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

Теперь мы рассмотрим, как синхронизировать потоки с помощью объектов ядра. Вы увидите, что такие объекты предоставляют куда больше возможностей, чем механизмы синхронизации в пользовательском режиме. В сущности, единственный их недостаток — меньшее быстродействие Дело в том, что при вызове любой из функций, упоминаемых в этой главе, поток должен перейти из пользовательского режима в режим ядра. А такой переход обходится очень дорого — в 1000 процессорных тактов на платформе x86. Прибавьте сюда еще и время, которое необходимо на выполнение кода этих функций в режиме ядра.

К этому моменту я уже рассказал Вам о нескольких объектах ядра, в том числе о процессах и потоках. Почти все они годятся и для решения задач синхронизации. В случае синхронизации потоков о каждом из этих объектов говорят, что он находится либо в свободном (signaled state), либо в занятом состоянии (nonsignaled state). Переход из одного состояния в другое осуществляется по правилам, определенным Microsoft для каждого из объектов ядра. Так, объекты ядра «процесс» сразу после создания всегда находятся в занятом состоянии. В момент завершения процесса операционная система автоматически освобождает его объект ядра "процесс", и он навсегда остается в этом состоянии.

Объект ядра «процесс» пребывает в занятом состоянии, пока выполняется сопоставленный с ним процесс, и переходит в свободное состояние, когда процесс завершается. Внутри этого объекта поддерживается булева переменная, которая при создании объекта инициализируется как FALSE («занято»). По окончании работы процесса операционная система меняет значение этой переменной на TRUE, сообщая тем самым, что объект свободен.

Если Вы пишете код, проверяющий, выполняется ли процесс в данный момент, Вам нужно лишь вызвать функцию, которая просит операционную систему проверить значение булевой переменной, принадлежащей объекту ядра «процесс». Тут нет ничего сложного. Вы можете также сообщить системе, чтобы та перевела Ваш поток в состояние ожидания и автоматически пробудила его при изменении значения буле вой переменной с FALSE на TRUE. Тогда появляется возможность заставить поток в родительском процессе, ожидающий завершения дочернего процесса, просто заснуть до освобождения объекта ядра, идентифицирующего дочерний процесс. В дальнейшем Вы увидите, что в Windows есть ряд функций, позволяющих легко решать эту задачу.

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

Следующие объекты ядра бывают в свободном или занятом состоянии:

  •  процессы
  •  потоки
  •  задания
  •  файлы
  •  консольный ввод
  •  уведомления об изменении файлов
  •  события
  •  ожидаемые таймеры
  •  семафоры
  •  мьютексы

Потоки могут засыпать и в таком состоянии ждать освобождения какого-либо объекта. Правила, по которым объект переходит в свободное или занятое состояние, зависят от типа этого объекта.

Понятия «свободен-занят» можно рассматривать по аналогии с обыкновенным флажком. Когда объект свободен, флажок поднят, а когда он занят, флажок опущен.

Потоки спят, пока ожидаемые ими объекты заняты (флажок опущен). Как только объект освободился (флажок поднят), спящий поток замечает это, просыпается и возобновляет выполнение.

Wait-функции

Wait-функции позволяют потоку в любой момент приостановиться и ждать освобождения какого-либо объекта ядра. Из всего семейства этих функций чаще всего используется WaitForSingleObject:

DWORD WaitForSingleObject( HANDLE hObject, DWORD dwMilliseconds);

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

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

WaitForSingleObject(hProcess, INFINITE);

В данном случае константа INFINITE, передаваемая во втором параметре, подсказывает системе, что вызывающий поток готов ждать этого события хоть целую вечность. Именно эта константа обычно и передается функции WaitForSingleObject, но Вы можете указать любое значение в миллисекундах. Кстати, константа INFINITE определена как 0xFFFFFFFF (или -1). Разумеется, передача INFINlTE не всегда безопасна. Если объект так и не перейдет в свободное состояние, вызывающий поток никогда не проснется; одно утешение, тратить драгоценное процессорное время он при этом не будет.

Вот пример, иллюстрирующий, как вызывать WaitForSingleObject со значением таймаута, отличным от INFINITE:

DWORD dw = WaitForSlngleObject(hProcess, 5000);

switch (dw)
{
case WAIT_OBJECT_0:
//
процесс завершается
break;

case WAIT_TIMEOUT:
// процесс не завершился в т
ечение 5000 мс
break;

case WAIT_FAILED:
// неправильный вызов функции (неверный оп
исатель?)
break;
}

Данный код сообщает системе, что вызывающий поток не должен получать процессорное время, пока не завершится указанный процесс или не пройдет 5000 мс (в зависимости от того, что случится раньше). Поэтому функция вернет управление либо до истечения 5000 мс, если процесс завершится, либо примерно через 5000 мс, если процесс к тому времени не закончит свою работу. Заметьте, что в параметре dwMilliseconds можно передать 0, и гогда WaitForSingleObject немедленно вернет управление.

Возвращаемое значение функции WaitForSingleObject указывает, почему вызывающий поток снова стал планируемым. Если функция возвращает WAITOBTECT_0, объект свободен, а если WAIT_TIMEOUT — заданное время ожидания (таймаут) истекло. При передаче неверного параметра (например, недопустимого описателя) WaitForSingleObject возвращает WAIT_FAILED. Чтобы выяснить конкретную причину ошибки, вызовите функцию GetLastError().

Функция WaitForMultipleObjects аналогична WaitForSingleObject c тем исключением, что позволяет ждать освобождения сразу нескольких объектов или какого-то одного из списка объектов:

DWORD WaitForMultipleObjects(DWOHD dwCount, CONST HANDLE* phObjects, BOOL fWaitAll, DWORD dwMilliseconds);

Параметр dwCount определяет количество интересующих Вас объектов ядра Его значение должно быть в пределах от 1 до MAXIMUM_WAIT_OBJECTS (в заголовочных файлах Windows оно определено как 64). Параметр phObjects — это указатель на массив описателей объектов ядра.

WaitForMultipleObjects приостанавливает поток и заставляет его ждать освобождения либо всех заданных объектов ядра, либо одного из них. Параметр fWaitAll как раз и определяет, чего именно Вы хотите от функции. Если он равен TRUE, функция не даст потоку возобновить свою работу, пока не освободятся все объекты.

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

Возвращаемое значение функции WaitForMultipleObjects сообщает, почему возобновилосъ выполнение вызвавшего ее потока. Значения WAIT_FAILED и WAIT_TIMEOUT никаких пояснений не требуют. Если Вы передали TRUE в параметре fWaitAll и все объекты перешли в свободное состояние, функция возвращает значение WAIT_OBJECT_0. Если fWaitAll приравнен FALSE, она возвращает управление, как только освобождается любой из объектов. Вы, по-видимому, захотите выяснить, какой именно объект освободился. В этом случае возвращается значение от WAIT_OBJECT_0 до WAIT_OBJECT_0 + dwCount - 1. Иначе говоря, если возвращаемое значение не равно WAIT_TIMEOUT или WAIT_FAILED, вычтите из него значение WAlT_OBJECT_0, и Вы получите индекс в массиве описателей, на который указывает второй параметр функции WaitForMultipleObjects. Индекс подскажет Вам, какой объект перешел в незанятое состояние

Если Вы передаете FALSE в параметре fWaitAll, функция WaitForMultipleObjects сканирует массив описателей (начиная с нулевого элемента), и первый же освободившийся объект прерывает ожидание. Это может привести к нежелательным последствиям. Например, Ваш поток ждет завершения трех дочерних процессов; при этом Вы передали функции массив с их описателями. Если завершается процесс, описатель которого находится в нулевом элементе массива, WaitForMultipleObjects возвращает управление. Теперь поток может сделать то, что ему нужно, и вновь вызвать эту функцию, ожидая завершения другого процесса. Если поток передаст те же три описателя, функция немедленно вернет управление, и Вы снова получите значение WAIT_OBJECT_0. Таким образом, пока Вы не удалите описатели тех объектов, об освобождении которых функция уже сообщила Вам, код будет работать некорректно.

Чем ценна функция WaitForMultipleObjects, так это тем, что она выполняет все действия на уровне атомарного доступа. Когда поток обращается к этой функции, она ждет освобождения всех объектов и в случае успеха вызывает в них требуемые побочные эффекты; причем все действия выполняются как одна операция.

Возьмем такой пример. Два потока вызывают WaitForMultipleObjects совершенно одинаково.

HANDLE h[2];

h[0] = hAutoResetEvent1;

// изначально занят
h[1] = hAutoReset
Event2;

// изначально занят
WaitForMultipleObjects(2, h, TRUE, INFINITE);

На момент вызова WaitForMultipleObjects эти объекты-события заняты, и оба потока переходят в режим ожидания. Но вот освобождается объект hAutoResetEventl. Это становится известным обоим потокам, однако ни один из них не пробуждается, так как объект hAutoResetEvent2 по-прежнему занят. Поскольку потоки все еще ждут, никакого побочного эффекта для объекта hAutoResetEvent1 не возникает.

Наконец освобождается и объект hAutoResetEvent2. В этот момент один из потоков обнаруживает, что освободились оба объекта, которых он ждал. Его ожидание успешно завершается, оба объекта снова переводятся в занятое состояние, и выполнение потока возобновляется. А что же происходит со вторым потоком? Он продолжает ждать и будет делать это, пока вновь не освободятся оба объекта-события.

Как я уже упоминал, WaitForMiltipleObjects работает на уровне атомарного доступа, и это очень важно. Когда она проверяет состояние объектов ядра, никто не может «у нее за спиной» изменить состояние одного из этих объектов. Благодаря этому исключаются ситуации со взаимной блокировкой. Только представьте, что получится, если один из потоков, обнаружив освобождение hAutoResetEventl, сбросит его в занятое состояние, а другой поток, узнав об освобождении hAutoResetEvent2, тоже переведет его в занятое состояние. Оба потока просто зависнут, первый будет ждать освобождения объекта, захваченного вторым потоком, а второй — освобождения объекта, захваченного первым. WaitForMultipleObjects гарантирует, что такого не случится никогда.

Тут возникает интересный вопрос. Если несколько потоков ждет один объект ядра, какой из них пробудится при освобождении этого объекта? Официально Microsoft отвечает на этот вопрос так: «Алгоритм действует честно». Что это за алгоритм, Microsoft не говорит, потому что не хочет связывать себя обязательствами всегда придерживаться именно этого алгоритма. Она утверждает лишь одно – если объект ожидается несколькими потоками, то всякий раз, когда этот объект переходит в свободное состояние, каждый из них получает шанс на пробуждение.

Таким образом, приоритет потока не имеет значения – поток с самым высоким приоритетом не обязательно первым захватит объект.

На самом деле этот алгоритм просто использует популярную схему "первым вошел — первым вышел" (FIFO). B принципе, объект захватывается потоком, ждавшим дольше всех. Но в системе могут произойти какие-то события, которые повлияют на окончательное решение, и из-за этого алгоритм становится менее предсказуемым. Вот почему Microsoft и не хочет говорить, как именно он работает. Одно из таких событий — приостановка какого-либо потока. Если поток ждет объект и вдруг приостанавливается, система просто забывает, что он ждал этот объект. А причина в том, что нет смысла планировать приостановленный поток. Когда он в конце концов возобновляется, система считает, что он только что начал ждать данный объект.

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

События

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

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

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

Объект ядра «событие» создается функцией CreateEvent:

HANDLE CreateEvent(
PSECURITY_ATTRIBUTES psa, BOOL fManualReset, BOOL fInitia
lState, PCTSTR pszName);

Параметр fManualReset (булева переменная) сообщает системе, хотите Вы создать событие со сбросом вручную (TRUE) или с автосбросом (FALSE). Параметру fInitialState определяет начальное состояние события — свободное (TRUE) или занятое (FALSE). После того как система создает объект событие, CreateEvent возвращает описатель события, специфичный для конкретного процесса. Потоки из других процессов могут получить доступ к этому объекту: 1) вызовом CreateEvent с тем же параметром pszName;, 2) наследованием описателя; 3) применением функции DuplicateHandle;, и 4) вызовом OpenEvent c передачей в параметре pszName имени, совпадающего с указанным в аналогичном параметре функции CreateEvent. Вот что представляет собой функция OpenEvent.

HANDLE OpenEvent( DWORD fdwAccess, BOOL fInherit, PCTSTR pszName);

Ненужный объект ядра «событие» следует, как всегда, закрыть вызовом CloseHandle. Создав собьпие, Вы можете напрямую управлять его состоянием. Чтобы перевести его в свободное состояние, Вы вызываете:           BOOL SetEvenT(HANDLE hEvenеt);

А чтобы поменять его на занятое:           BOOL ResetEvent(HANDLE hEvent);

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

Для полноты картины упомяну о еще одной функции, которую можно использовать с объектами-событиями:          BOOL PulseEvent(HANDLE hEvent);

PulseEvent освобождает событие и тут же переводит его обратно в занятое состояние; ее вызов равнозначен последовательному вызову SelEvent и ResetEvent. Если Вы вызываете PulseEvent для события со сбросом вручную, любые потоки, ждущие этот объект; становятся планируемыми. При вызове этой функции применительно к событию с автосбросом пробуждается только один из ждущих потоков. А если ни один из потоков не ждет объект-событие, вызов функции не дает никакого эффекта.

Ожидаемые таймеры

Ожидаемые таймеры (waitable timers)это объекты ядра, которые самостоятельно переходят в свободное состояние в определенное время или через регулярные промежутки времени. Чтобы создать ожидаемый таймер, достаточно вызвать функцию CreateWaitableTimer.

HANDLE CreateWaitableTimer( PSECURITY_ATTRIBUTES psa, BOOL fManualReset, PCTSTR pszName);

О параметрахр psa и pszName мы уже говорили. Разумеется, любой процесс может получить свой («процессно-зависимый») описатель существующего объекта "ожидаемый таймер", вызвав OpenWaitableTimer.

HANDLE OpenWaitableTimer( DWORD dwDesiredAccess, BOOL bInheritHandle, PCTSTR pszName);

По аналогии с событиями параметр fManualReset определяет тип ожидаемого таймера: со сбросом вручную или с автосбросом. Когда освобождается таймер со сбросом вручную, возобновляется выполнение всех потоков, ожидавших этот объект, а когда в свободное состояние переходит таймер с автосбросом — лишь одного из потоков.

Объекты «ожидаемый таймер» всегда создаются в занятом состоянии. Чтобы сообщить таймеру, в какой момент он должен перейти в свободное состояние, вызовите функцию SetWaitableTimer.

BOOL SetWaitableTimer( HANDLE hTimer, const LARGE_INTEGER *pDueTime, LONG lPeriod, PTIMERAPCROUTINE pfnCompletionRoutine, PVOID pvArgToCotnpletionRoutine, BOOI fResume);

Эта функция принимает несколько параметров, в которых легко запутаться. Очевидно, что hTimer определяет нужный таймер. Следующие два параметра (pDиеТiте и lPeriod) используются совместно, первый из них задает, когда таймер должен сработать в первый раз, второй определяет, насколько часто это должно происходить в дальнейшем. Попробуем для примера установить таймер так, чтобы в первый раз он сработал 1 января 2002 года в 1:00 PM, а потом срабатывал каждые 6 часов.

// объявляем свои локальные переменные 

HANDLE hTimer;
SYSTEMTIME st;
FILETIME ftLocal, ftUTC;
LARGE_INTEGER liUTC;

// создаем таймер с автосбросом
hTimer = CreateWa
itableTimer(NULL, FALSE, NULL);

// таймер должен сработать в первый раз 1 января 2002 года в 1:00 PM
// по местному времени

st.wYear = 2002; // год
st.wMonth = 1; // я
нварь
st.w
DayOfWeek = 0; // игнорируется
st.wDay = 1, // первое число месяца
st.wHour = 13; // 1 PM
st.wMinute = 0; // 0 минут
st.wSecond = 0, // 0 секунд
st.wMilliseconds = 0; // 0 милл
исекунд

SystemTimeToFileTime(&st, &ftLocal);

// преобразуем местное время в UTC-время
LocalFileTimeToFilelime(&
ftLocal, &ftUTC);

// преобразуем FILETIME в LARGE_INTEGER из-за различий в выравнивании данных
liUTC.LowPart = ftUTC dwLowDateTime;
liUTC.HighPart = ftUTC dwHighDateTime;

// устанавливаем таймер
SetWaitabl
eTimer(hTimer, &liUTC, 6 * 60 * 60 * 1000, NULL, NULL, FALSE);

...

Этот фрагмент кода сначала инициализирует структуру SYSTEMTIME, определяя время первого срабатывания таймера (его перехода в свободное состояние). Я установил это время как местное. Второй параметр представляется как const LARGE_INTEGER * и поэтому не позволяет напрямую использовать структуру SYSTEMTIME. Однако двоичные форматы структур FILETIME и LARGE_INTEGER идентичны: обе содержат по два 32-битных значения. Таким образом, мы можем преобразовать структуру SYSTEMTIME в FILETIME. Другая проблема заключается в том, что функция SetWaitable Timer ждет передачи времени в формате UTC (Coordinated Universal Time). Нужное преобразование легко осуществляется вызовом LocalFileTimeToFileTime

Поскольку двоичные форматы структур FILETIME, и IARGE_INTEGER идентичны, у Вас может появиться искушение передать в SetWaitableTimer адрес структуры FILETIME напрямую;

// устанавливаем таймер
SetWaitableTimer(hTimer, (PLARGEINT
EGER) &ftUTC, 6 * 60 * 60 * 1000, NULL, NULL, FALSE);

В сущности, разбираясь с этой функцией, я так и поступил. Но это большая ошибка! Хотя двоичные форматы структур FILETIME и LARGE_INTEGER совпадают, выравнивание этих структур осуществляется по-разному. Адрес любой структуры FILETIME должен начинаться на 32-битной границе, а адрес любой структуры IARGE_INTEGER — на 64-битной. Вызов SetWaitableTimer с передачей ей структуры FILETIME может cpaботать корректно, но может и не сработать — все зависит от того, попадет ли начало структуры FlLETIME на 64-битную границу. В то же время компилятор гарантирует, что структура LARGE_INTEGER всегда будет начинаться на 64-битной границе, и по этому правильнее скопировать элементы FILETIME в элементы LARGE_INTEGER, а за тем передать в SetWaitableTtmer адрес именно структуры LARGE_INTEGER.

Обычно нужно, чтобы таймер сработал только раз — через определенное (абсолютное или относительное) время перешел в свободное состояние и уже больше никогда не срабатывал Для этого достаточно передать 0 в параметре lPeriod. Затем можно либо вызвать CloseHandle, чтобы закрыть таймер, либо перенастроить таймер повторным вызовом SetWattableTimer с другими параметрами.

И о последнем параметре функции SetWaitableTimer — lResume. Он полезен на компьютерах с поддержкой режима сна. Обычно в нем передают FALSE. Но если Вы, скажем, пишете программу-планировщик, которая позволяет устанавливать таймеры для напоминания о запланированных встречах, то должны передавать в этом параметре TRUE. Когда таймер сработает, машина выйдет из режима сна (если она находилась в нем), и пробудятся потоки, ожидавшие этот таймер. Далее программа сможет проиграть какой-нибудь WAV-файл и вывести окно с напоминанием о предстоящей встрече. Если же Вы передадите FALSE в параметре fResume, объект-таймер перейдет в свободное состояние, но ожидавшие его потоки не получат процессорное время, пока компьютер не выйдет из режима сна.

Рассмотрение ожидаемых таймеров было бы неполным, пропусти мы функцию CancelWaitableTimer.

BOOL CancelWaitableTimer(HANDLE hTimer);

Эта очень простая функция принимает описатель таймера и отменяет его (таймер), после чего тот уже никогда не сработает, — если только Вы не переустановите его повторным вызовом SetWaitableTimer. Кстати, если Вам понадобится перенастроить таймер, то вызывать CancelWattableTimer перед повторным обращением к SetWaitableTimer не требуется; каждый вызов SetWaitableTimer автоматически отменяет предыдущие настройки перед установкой новых.

Семафоры

Семафор – объект ядра, используемый для учета ресурсов. Как и все объекты ядра, они содержат счетчик числа пользователей, но, кроме того, поддерживают два 32-битных значения со знаком: одно определяет максимальное число ресурсов (контролируемое семафором), другое используется как счетчик текущего числа ресурсов.

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

Изначально, когда запросов от клиентов еще нет, сервер не разрешает выделять процессорное время каким-либо потокам в пуле. Но как только серверу поступает, скажем, три клиентских запроса одновременно, три потока в пуле становятся планируемыми, и система начинает выделять им процессорное время. Для слежения за ресурсами и планированием потоков семафор очень удобен. Максимальное число ресурсов задается равным 5, что соответствует размеру буфера. Счетчик текущего числа ресурсов первоначально получает нулевое значение, так как клиенты еще не выдали ни одного запроса. Этот счетчик увеличивается на 1 в момент приема очередного клиентского запроса и на столько же уменьшается, когда запрос передается на обработку одному из серверных потоков в пуле.

Для семафоров определены следующие правила:

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

Не путайте счетчик текущего числа ресурсов со счетчиком числа пользователей объекта-семафора.

Объект ядра «семафор» создается вызовом CreateSemaphore.

HANDLE CreateSemaphore( PSECURITY_ATTRIBUTE psa, LONG lInitialCount, LONG lMaximumCount, PCTRTR pszName)

Кроме этого, любой процесс может получить свой («процессно-зависимый») описатель существующего объекта «семафор», вызвав OpenSemaphore:

HANDLE OpenSemaphore( DWORD fdwAccess, BOOL bInheritHandle, 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. Но не вышло: функция занесла туда пуль. Я передал во втором параметре заведомо большее число, и — тот же результат. Тогда мне стало ясно: получить значе ние этого счетчика, не изменив его, невозможно.

Мьютексы

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

Идентификатор потока определяет, какой поток захватил мьютекс, а счетчик рекурсий — сколько раз. У мьютексов много применений, и это наиболее часто исполь зуемые объекты ядра. Как правило, с их помощью защищают блок памяти, к которо му обращается множество потоков Если бы потоки одновременно использовали ка кой-то блок памяти, данные в нем были бы повреждены. Мьютексы гарантируют, что любой поток получает монопольный доступ к блоку памяти, и тем самым обеспечи вают целостность данных.

Для мьютексов определены следующие правила:

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

Для использования объекта-мьютекса один из процессов должен сначала создать его вызовом CreateMutex:

HANDLE CreateMutex( PSECURITY_ATTRIBUTES psa, BOOL fInitialOwner, PCTSTR pszName);

HANDLE OpenMutex( DWORD fdwAccess, 800L bInheritHandle, PCTSTR pszName);

Параметр fInitialOwner опрсделяет начальное состояние мъютекса. Если в нем передается FALSE (что обычно и бывает), объект-мьютекс не принадлежит ни одному из потоков и поэтому находится в свободном состоянии. При этом его идентификатор потока и счетчик рекурсии равны 0. Если же в нем передается TRUE, идентификатор потока, принадлежащий мьютексу, приравнивается идентификатору вызывающего потока, а счетчик рекурсии получает значение 1. Поскольку теперь идентификатор потока отличен от 0, мьютекс изначально находится в занятом состоянии.

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

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

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

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

BOOL ReleaseMutex(HANDLE hMutex);

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

Отказ от объекта-мьютекса

Объект-мьютекс отличается от остальных объектов ядра тем, что занявшему его по току передаются права на владение им. Прочие объекты могут быть либо свободны, либо заняты — вот, собственно, и все. А объекты-мьютексы способны еще и запоминать, какому потоку они принадлежат. Если какой-то посторонний поток попытается освободить мьютекс вызовом функции ReleaseMutex, то она, проверив идентификаторы потоков и обнаружив их несовпадение, ничего делать не станет, а просто вер нет FALSE. Тут же вызвав GetLastError, Вы получите значение ERROR_NOT_OWNER.

Отсюда возникает вопрос: а что будет, если поток, которому принадлежит мьютекс, завершится, не успев его освободить? В таком случае система считает, что произошел отказ от мьютекса, и автоматически переводит его в свободное состояние (сбрасывая при этом все его счетчики в исходное состояние). Если этот мьютекс ждут другие потоки, система, как обычно, «по-честному» выбирает один из потоков и позволяет ему захватить мьютекс. Тогда Wait-функция возвращает потоку WAIT_ABANDONED вместо WAIT_OBJECT_0, и тот узнает, что мьютекс освобожден некорректно. Данная ситуация, конечно, не самая лучшая. Выяснить, что сделал с защищенными данными завершенный поток — бывший владелец объекта-мьютекса, увы, невозможно.

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

Мьютексы и критические секции

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

Сводная таблица объектов, используемых для синхронизации потоков

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

Объект

Находится в занятом состоянии, когда

Переходит в свободное состояние, когда

Побочный эффект успешного ожидания

Процесс
Поток

процесс еще активен, поток еще активен

процесс завершается (ExitProcess, TerminateProcess) 
поток завершается (ExitThread, TerminateThread) 

Нет
Нет

Объект

Находится в занятом состоянии, когда:

Переходит в свободное состояние, когда:

Побочный эффект успешного ожидания

Задание

время, выделенное заданию, еще не истекло

время, выделенное заданию, истекло

Нет

Файл

выдан запрос на ввод-вывод

завершено выполнение запроса на ввод-вывод

Нет

Консольный ВВОД

ввода нет

ввод есть

Нет

Уведомление об изменении файла

в файловой системе нет изменений

файловая система обнаруживает изменения

Сбрасывается в исходное состояние

Событие с автосбросом

вызывается ResetEvent, PulseEvent или ожидание успешно завершилось

вызывается SetEvent или PulseEvent

Сбрасывается в исходное состояние

Событие со сбросом вручную

вызывается ResetEvent или PulseEvent

вызывается SetEvent или PulseEvent

Нет

Ожидаемый таймер с автосбросом

вызывается CancelWaitable- Тiтеr или ожидание успешно завершилось

наступает время срабатывания (SetWaitableTimer) 

Сбрасывается в исходное состояние

Ожидаемый таймер со сбросом вручную

вызывается CancelWaitableTimer 

наступает время срабатывания (SetWaitableTimef)

Нет

Семафор

ожидание успешно завершилось

счетчик > 0 (ReleaseSemaphore) 

Счетчик уменьшается на 1

Мьютекс

ожидание успешно завершилось

поток освобождает мьютекс (ReleaseMutex) 

Передается пото ку во владение

Критическая секция (поль зовательского режима)

ожидание успешно завершилось ( (Try)EnterCriticalSection)

поток освобождает критическую секцию (LeaveCriticalSection)

Передается потоку во владение

Interlocked-функции (пользовательского режима) никогда не приводят к исключению потока из числа планируемых; они лишь изменяют какое-то значение и тут же возвращают управление.

Лекция 6. Классические проблемы межпроцессного взаимодействия

Литература по операционным системам содержит множество интересных проблем, которые широко обсуждались и анализировались с применением различных методов синхронизации. В этом разделе мы рассмотрим три наиболее известные проблемы.

Проблема производителя и потребителя

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

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

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

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

Теперь давайте вернемся к состоянию состязания. Его возникновение возможно, поскольку доступ к переменной count не ограничен. Может возникнуть следующая ситуация: буфер пуст, и потребитель только что считал значение переменной count, чтобы проверить, не равно ли оно нулю. В этот момент планировщик передал управление производителю, производитель поместил элемент в буфер и увеличил значение count, проверив, что теперь оно стало равно 1. Зная, что перед этим оно было равно 0 и потребитель находился в состоянии ожидания, производитель активизирует его с помощью вызова wakeup.

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

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

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

Проблема обедающих философов

В 1965 году Дейкстра сформулировал и решил проблему синхронизации, названную им проблемой обедающих философов. С тех пор каждый, кто изобретал еще один новый примитив синхронизации, считал своим долгом продемонстрировать достоинства нового примитива на примере проблемы обедающих философов. Проблему можно сформулировать следующим образом: пять философов сидят за круглым столом, и у каждого есть тарелка со спагетти. Спагетти настолько скользкие, что каждому философу нужно две вилки, чтобы с ними управиться. Между каждыми двумя тарелками лежит одна вилка (рис. 2.18).

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

В листинге 2.10 представлено очевидное решение проблемы. Процедура take_ fork ждет, пока указанная вилка не освободится, и берет ее. К сожалению, это решение неверно — представьте себе, что все пять философов возьмут одновременно свои левые вилки. Каждый останется без правой вилки, и произойдет взаимоблокировка.

Листинг 2.10. Неверное решение проблемы обедающих философов

#define N 5     /* Количество философов */

void philosopher (int i)  /* i – номер философа, от 0 до 4 */

{

while(TRUE) {

 think();   /* Философ размышляет */

 take_fork(i);  /* Берет левую вилку */

 take_fork((i+1) % N); /* Берет правую вилку */

 eat();   /* Спагетти, ням-ням */

 put_fork(i);  /* Кладет на стол левую вилку */

 put_fork((i+1) % N); /* Кладет на стол правую вилку */

}

}

   Рис. 2.18. Время обеда на факультете философии

Можно изменить программу так, чтобы после получения левой вилки проверялась доступность правой. Если правая вилка недоступна, философ отдает левую обратно, ждет некоторое время и повторяет весь процесс. Этот подход также не будет работать, хотя и по другой причине. Если не повезет, все пять философов могут начать процесс одновременно, взять левую вилку, обнаружить отсутствие правой, положить левую обратно на стол, одновременно взять левую вилку, и так до бесконечности. Ситуация, в которой все программы продолжают работать сколь угодно долго, но не могут добиться хоть какого-то прогресса, называется зависанием процесса (по-английски starvation, буквально «умирание от голода». Этот термин применяется даже в том случае, когда проблема возникает не в итальянском или китайском ресторане, а на компьютерах).

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

В листинг 2.10 можно внести улучшение, исключающее взаимоблокировку и зависание процесса; защитить пять операторов, следующих за запросом think, бинарным семафором. Тогда философ должен будет выполнить операцию down на переменной mutex прежде, чем потянуться к вилкам. А после возврата вилок на место ему следует выполнить операцию up на переменной mutex. С теоретической точки зрения решение вполне подходит. С точки зрения практики возникают проблемы с эффективностью: в каждый момент времени может есть спагетти только один философ. Но вилок пять, поэтому необходимо разрешить есть в каждый момент времени двум философам.

Решение, представленное в листинге 2.11, исключает взаимоблокировку и позволяет реализовать максимально возможный параллелизм для любого числа философов. Здесь используется массив state для отслеживания душевного состояния каждого философа: он либо ест, либо размышляет, либо голодает (пытаясь получить вилки). Философ может начать есть, только если ни один из его соседей не ест. Соседи философа с номером i определяются макросами LEFT и RIGHT (то есть если i = 2, то LEFT = 1 и RIGHT = 3).

В программе используется массив семафоров, по одному на философа, чтобы блокировать голодных философов, если их вилки заняты. Обратите внимание, что каждый процесс запускает процедуру philosopher в качестве своей основной программы, но остальные процедуры take_forks, put_forks и test являются обычными процедурами, а не отдельными процессами.

Проблема читателей и писателей

Проблема обедающих философов полезна для моделирования процессов, соревнующихся за монопольный доступ к ограниченному количеству ресурсов, например к устройствам ввода-вывода. Другой известной задачей является проблема читателей и писателей, моделирующая доступ к базе данных. Представьте себе базу данных бронирования билетов на самолет, к которой пытается получить доступ множество процессов. Можно разрешить одновременное считывание данных из базы, но если процесс записывает информацию в базу, доступ остальных процессов должен быть прекращен, даже доступ на чтение. Как запрограммировать читателей и писателей? Одно из решений представлено в листинге 2.12.

Первый читающий процесс выполняет операцию down на семафоре db, чтобы получить доступ к базе. Последующие читатели просто увеличивают значение счетчика rc. По мере ухода читателей из базы значение счетчика уменьшается, и последний читающий процесс выполняет на семафоре db операцию up, позволяя блокированному пишущему процессу получить доступ к базе.

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

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

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

Проблема спящего брадобрея

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

В предлагаемом решении используются три семафора: customers, для подсчета ожидающих посетителей (клиент, сидящий в кресле брадобрея, не учитывается — он уже не ждет); barbers, количество брадобреев (0 или 1), простаивающих в ожидании клиента, и mutex для реализации взаимного исключения. Также используется переменная waiting, предназначенная для подсчета ожидающих посетителей.

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

Если свободный стул есть, посетитель увеличивает значение целочисленной переменной waiting. Затем он выполняет процедуру up на семафоре customers, тем самым активизируя поток брадобрея. В этот момент оба – посетитель и брадобрей – активны. Когда посетитель освобождает доступ к mutex, брадобрей захватывает его, проделывает некоторые служебные операции и начинает стричь клиента.

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

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

Лекция 7. Механизмы межпроцессного взаимодействия, поддерживаемые Windows 2000

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

Отсюда следует вопрос: когда следует использовать потоки, а когда – процессы? Иногда ответ очевиден. Например, если вы разрабатываете Web-браузер и планируете осуществлять соединение с сервером через Интернет, очевидно, вам потребуется IPC. В других ситуациях ответ менее очевиден. Например, если требуется разработать программу, имитирующую работу фондового рынка, следует ли реализовать участников рынка как отдельные потоки, или будет лучше моделировать поведение каждого из них при помощи отдельного процесса?

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

Приведем краткий список механизмов IPC, встроенных в Windows.

  •  WM_COPYDATA – передает участок памяти одного процесса другому.
  •  Буфер обмена (clipboard) – наверное, самая рудиментальная форма Windows IPC. Чаще всего с буфером обмена работает пользователь операционной системы. Считается, что программа должна использовать буфер обмена для передачи данных только в случае, если этого захочет пользователь.
  •  Библиотека DLL – может показаться странным, однако динамические библиотеки DLL обладают возможностью обеспечивать общий доступ к одному участку памяти для нескольких процессов.
  •  Память общего доступа (shared memory) – версия механизма общей памяти, встроенная в Win32, может показаться несколько странной. Официально общий доступ нескольких процессов к участку памяти общего доступа осуществляется с использованием механизма отображения файлов на оперативную память. Когда процесс отображает файл на память, он создает объект отображения файла, который позволяет осуществлять доступ к содержимому файла так, будто данные, содержащиеся в файле, расположены в определенном месте виртуального адресного пространства процесса. Другими словами, процесс получает возможность работать с файлом как с массивом, хранящимся в памяти. Процессы, работающие на одном компьютере, могут работать с одним и тем же объектом отображения файла и, таким образом, синхронизировать между собой представление о содержимом этого файла. Если требуется просто обеспечить доступ нескольких процессов к одному общему участку памяти, вы можете создать объект отображения файла на память, не ставя этому объекту в соответствие никакого реально существующего на диске файла.
  •  Анонимный канал (anonymous pipe) – этот механизм используется для передачи данных из потока вывода одной программы в поток ввода другой программы. При помощи анонимных каналов осуществляется передача данных между программами, запускаемыми из командной строки DOS и Unix. В Windows анонимные каналы используются относительно редко.
  •  Именованный канал (named pipe) – чрезвычайно мощный инструмент взаимодействия двух программ, осуществляющих обмен данными согласно концепции клиент-сервер. Именованные каналы с легкостью могут использоваться в ситуации, когда программы работают на разных компьютерах. В последние годы вместо именованных каналов все чаще используют сокеты, однако вы можете обнаружить, что в определенных ситуациях именованные каналы все еще чрезвычайно удобны.
  •  Почтовые слоты (mailslots) – несколько необычный способ передавать сообщения между программами. Почтовые слоты могут работать только в одном направлении, поэтому, если вы хотите передавать данные в обоих направлениях, вы должны использовать два почтовых слота. Преимуществом почтовых слотов является возможность передать сообщение через локальную сеть сразу нескольким программам за одну операцию.
  •  Dynamic Data Exchange, DDE (динамический обмен данными) – в свое время DDE был чрезвычайно популярным протоколом обмена данными между несколькими программами. Многие продукты Microsoft (даже Windows Program Manager – Диспетчер программ) включали в себя поддержку DDE. В наши дни поддержка DDE добавляется в программные продукты в основном для обратной совместимости. На самом деле проблема не в недостатках DDE. Просто Microsoft приняла решение использовать OLE (позже переименованной в ActiveX) в качестве основной технологии взаимодействия программ. Таким образом, технология OLE остановила развитие других конкурирующих с ней технологий. Среди таких технологий оказался и протокол DDE.
  •  NetBIOS – многие годы технология NetBIOS выполняла роль основного сетевого протокола для PC. В наши дни, когда на смену ей пришли более совершенные сетевые протоколы, Windows все еще включает в себя поддержку NetBIOS, однако в основном для совместимости с устаревшим программным обеспечением.
  •  Сокеты – технология, лежащая в основе обмена данными через Интернет. Сокеты также широко используются для обмена данными в крупных вычислительных сетях. Лишь немногие компьютеры в наше время не обладают поддержкой сокетов.
  •  Remote Procedure Call, RPC (вызов удаленных процедур) – это еще один широко распространенный стандарт. Говоря точнее, RPC не является методом IPC. Скорее это технологическая оболочка, существенно расширяющая возможности традиционных механизмов IPC. При использовании RPC сервер делает доступными для клиента некоторые функции, к которым клиент может обращаться напрямую, как будто они расположены локально по отношению к клиенту. Таким образом, клиенту кажется, что он напрямую обращается к некоторой функции, а серверу кажется, что он получает запрос на выполнение функции напрямую от клиента. На самом деле передача данных от клиента к серверу и обратно осуществляется через сеть при помощи RPC. Просто RPC выполняет обмен данными через сеть между клиентом и сервером абсолютно прозрачно для обоих.
  •  ActiveX – это воистину многоцелевая технология. По сути ActiveX – это способ создания бинарных объектов. Одной из областей применения этих объектов является IPC. Специально для этой цели Microsoft определила стандартный интерфейс IDataObject. Еще одной технологией ActiveX, предназначенной для реализации IPC, является Distributed Component Object Model (DCOM). Можно сказать, что DCOM – это результат слияния ActiveX и RPC.
  •  Microsoft Message Queue (MSMQ) – это протокол, который позволяет приложениям посылать сообщения друг другу. В отличие от других форм IPC технология MSMQ позволяет посылать сообщения процессу, который в данное время недоступен (например, приложение не запущено, сервер вышел из строя или сетевой канал связи перегружен). Механизм MSMQ ставит сообщение в очередь до тех пор, пока не появится возможность переслать его адресату.

WM_COPYDATA

Системное сообщение WM_COPYDATA, наверное, является самым простым методом обмена данными между процессами. Этот метод используется относительно нечасто, однако в некоторых ситуациях он является чрезвычайно удобной и простой в использовании альтернативой другим, более совершенным механизмам IPC. Для пересылки сообщения WM_COPYDATA окну другого процесса следует использовать вызов SendMessage (но не PostMessage). Этому вызову следует передать структуру COPYDATASTRUCT. Windows передает эту структуру другому процессу в приемлемой для прочтения форме. Конечно, при этом оба процесса не обладают доступом к одному и тому же участку памяти. Скорее это напоминает пересылку копии документа по почте. Владелец документа делает копию этого документа и пересылает ее по почте. Человек, получивший копию документа, может внести в эту копию коррективы, однако об этих изменениях не узнает владелец оригинала. Если же владелец оригинала примет решение изменить изначальный документ, получатель копии также не узнает об этих изменениях, так как будет иметь дело с копией, которая была сделана до модификации оригинала.

Таблица 6.1. Структура COPYDATASTRUCT
Поле Описание

dwData 32-битное число для передачи между процессами

cbData Количество передаваемых байт

IpData Указатель на данные (объем данных указывается в cbData)

Структура COPYDATASTRUCT включает в себя 32-битное значение (любое удобное для вас число), указатель на данные и длину данных в байтах. Данные нельзя изменять до тех пор, пока вызов SendMessage не вернет управление вызвавшей программе.

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

Механизм WM_COPYDATA чрезвычайно прост, однако он обладает недостатком, который присущ фактически всем методам IPC: нельзя быть уверенным в том, что оба процесса увидят общие для них данные по одному и тому же адресу в принадлежащем каждому из них виртуальном адресном пространстве. Исходя из этого, не следует размещать абсолютные указатели на общую память в общей памяти.

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

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

Реализация памяти общего доступа при помощи DLL

Динамические библиотеки DLL являются основой Windows. Главное предназначение библиотек DLL — упаковка кода для динамической компоновки, однако их можно использовать также для отображения переменных общего доступа в адресное пространство нескольких процессов. Как и в случае с WM_COPYDATA, нельзя быть уверенным в том, что в адресных пространствах разных процессов эти переменные будут расположены по одним и тем же адресам.

• Когда в рамках Win32 DLL объявляется переменная, ее можно сделать либо общей (shared), либо частной (private). Если переменная частная, то каждый процесс, использующий эту DLL, получает в собственное пользование индивидуальную копию этой переменной. Если переменная объявляется общей, все процессы, обращающиеся к DLL, используют для хранения переменной одно и то же место в физической памяти.

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

Каким образом можно пометить переменную библиотеки DLL как общую? Это зависит от компилятора, который вы используете. В Microsoft Visual C++ по умолчанию все переменные, входящие в состав DLL, становятся частными. Если необходимо сделать их общими, следует использовать специальные прагмы (pragma).

Подробнее о DLL

При использовании DLL в качестве механизма IPC перед вами обычно встает важный вопрос: кто является владельцем глобальной переменной DLL? В Win32 ответ далеко не очевиден. По умолчанию библиотеки DLL, создаваемые Microsoft, предоставляют каждой программе индивидуальную копию каждой глобальной переменной. Рассмотрим следующий фрагмент кода DLL:

int threshold;

void set threshdnt n)   // эта функция экспортируется

{

thresholds;

void get thresh(int n)   // эта функция экспортируется

{  return threshold;  }

Если эта DLL (откомпилированная при помощи Visual C++) используется несколькими процессами, каждый из этих процессов будет обладать индивидуальной собственной копией переменной threshold. Если же для компиляции DLL используется транслятор Borland, все процессы, использующие эту библиотеку, будут обращаться к одной общей для всех переменной threshold (если только вы не указали оператор MULTIPLE в DEF-файле). Чтобы получить такой же эффект при использовании компилятора Microsoft, следует выполнить следующие шаги:

  1.  Пометьте переменную специальной прагмой. В результате переменная будет размещена в специальной области данных.
  2.  Убедитесь в том, что переменной присвоено начальное значение.
  3.  Используйте параметры компоновщика (или файл DEF), чтобы должным образом настроить атрибуты области данных, в которой расположена переменная.

Приведем определение той же самой переменной с использованием прагмы, делающей эту переменную общей:

#pragma data_seg(".ASHARE")

int threshold = 0;

// здесь можно расположить определения других общих переменных

#pragma data_seg()

Объявляя общие переменные, обязательно присваивайте им начальные значения. Если этого не происходит, переменная попадает в раздел неинициализированных переменных и перестает быть общей. Именно по этой причине в рассмотренном фрагменте переменной threshold явно присваивается нулевое начальное значение.

Имя области данных может быть любым допустимым идентификатором, который включает в себя до восьми символов и начинается с символа точки. Конкретное использованное вами имя не имеет значения. Естественно, для идентификации нельзя использовать уже зарезервированные компилятором имена (такие как .CODE, .DATA, .ВSS и т. п.).

После этого следует отдать необходимые инструкции компоновщику. Это можно сделать при помощи параметров проекта (Project Settings). В разных версиях Visual C++ доступ к значениям этих параметров осуществляется с помощью разных пунктов меню. Отдельного окна диалога для настройки этих параметров не существует. Необходимо модифицировать командную строку (которая чаще всего располагается в нижней части окна диалога). Если изменить командную строку не удается, убедитесь, что параметр «тип проекта» (графа Settings For..,) имеет либо значение Debug, либо значение Release, но не оба этих значения одновременно. Если выбрано и то и другое, Visual C++ не дает вам возможности редактировать командную строку. В командную строку" необходимо добавить следующие символы:

/SECTION:. ASHARE, RWS

Конечно же, если вместо .ASHARE вы присвоили области данных какой-либо другой идентификатор, следует указать правильное имя области. Символы RWS обозначают Read (чтение), Write (запись) и Share (общий). Благодаря тому, что область . ASHARE описана как общая, ее можно использовать для обмена данными между несколькими разными процессами.

Если вы предпочитаете настраивать компоновщик при помощи DEF-файла, вы можете разместить в этом файле следующий фрагмент:

LIBRARY DLL

SECTIONS

.ASHARE READ,WRITE,SHARED

При использовании этого метода не забудьте добавить DEF-файл в проект. По умолчанию MSVC не использует DEF-файлов. Компилятор будет использовать информацию из DEF-файла только в случае, если DEF-файл будет составной частью проекта.

Если вы хотите сделать общими абсолютно все переменные вашей DLL, присвойте атрибут SHARED областям данных .DATA и .BSS. Это можно сделать одним из описанных способов: либо при помощи параметра /SECTION командной строки компоновщика, либо при помощи DEF-файла.

Вот и все, теперь вы можете без проблем обращаться ко всем выбранным вами переменным как к общим переменным нескольких процессов. Можно даже организовать общий доступ к одним и тем же переменным для программ Win 16 и Win32. Это может оказаться полезным при поддержке устаревшего программного обеспечения. Но не забывайте о том, что в Win32 одна и та же библиотека DLL может располагаться в адресных пространствах разных процессов по разным адресам. По этой причине нельзя получить адрес общей переменной и передать его другому процессу. Другими словами, не следует хранить адрес общей переменной в другой общей переменной:

// Неправильно!

#pragma data_seg(".ASHARE")

char buf[lOO]="";

char *bufp=&buf; // Неправильно!

#pragma data_seg()

Такая схема сработает в Windows 95 — для любого процесса библиотека DLL загружается по одному и тому же адресу. Однако в Windows NT или Windows 2000 это, скорее всего, работать не будет.

Еще один метод организации общей памяти

Официальный способ реализации памяти общего доступа в Win32 — это отображение файлов на память (см. табл. 6.2 и 6.3). Два процесса могут одновременно получить доступ к одному и тому же файлу, отображенному на память, что позволяет им обмениваться данными. Однако при этом существенно снижается производительность, так как любое обращение к памяти общего доступа будет сопровождаться операцией файлового ввода/вывода. Но эту проблему можно решить. Если при создании объекта отображения файла на память в качестве дескриптора файла указать значение (HANDLE) 0xFFFFFFFF, операционная система не будет ассоциировать этот объект с каким-либо файлом. В этом случае объект отображения будет использоваться для обеспечения общего доступа разных процессов к одному участку оперативной памяти.

Таблица 6.2. Аргументы вызова CreateFileMapping

Аргумент

Тип

Описание

hFile

HANDLE

Дескриптор файла (значение 0xFFFFFFFF в случае, если требуется общая память)

lpFileMappingAttributes

LPSECURITY_ATTRIBUTES

Структура атрибутов безопасности (управляет наследованием и доступом)

flProtect

DWORD

Устанавливает защиту (только чтение, зарезервировано или выделено и т.п.)

dwMaximumSizeHigh

DWORD

Старшие 32 бита 64-битного размера файла

dwMaximumSizeLow

DWORD

Младшие 32 бита 64-битного размера файла

lpName

LPCTSTR

Имя объекта отображения файла на память

Таблица 6.3. Аргументы вызова MapViewOfFile

Аргумент

Тип

Описание

hFileMappingObject

HANDLE

Дескриптор объекта отображения файла на память

dwDesiredAccess

DWORD

Запрос на чтение или запись

dwFileOffsetHigh

DWORD

Старшие 32 бита 64-битной стартовой позиции

dwFileOffsetLow

DWORD

Младшие 32 бита 64-битной стартовой позиции

dwNumberOfBytesToMap

DWORD

Количество байт, которое следует отобразить (0 в случае, если требуется отобразить весь файл)

При обращении к MapViewOfFlleEx можно указать адрес, куда вы желаете отобразить файл, однако память по этому адресу может оказаться недоступной. Проще всего присвоить этому аргументу значение NULL (или использовать вызов MapViewOfFile) и разрешить Windows самостоятельно подобрать подходящий адрес. Как всегда в подобной ситуации не существует гарантий того, что при отображении общего участка памяти все процессы будут использовать один и тот же адрес.

Чтобы создать область памяти общего доступа, следует выполнить следующие действия:

  1.  Обратитесь к вызову CreateFileMapping. При этом в качестве дескриптора файла укажите значение (HANDLE) 0xFFFFFFFF или INVALID_HANDLE_VALUE. Также присвойте области памяти уникальное имя, используя которое другие процессы смогут обратиться к этой памяти.
  2.  Обратитесь к вызову MapViewOfFile (или MapVlewOfFileEx), чтобы получить указатель, который можно использовать для доступа к общей области памяти.
  3.  После завершения работы с общей памятью используйте вызов UnmapVlewOfFHe,чтобы освободить область памяти.
  4.  Чтобы уничтожить дескриптор объекта отражения, используйте вызов CloseHandle совместно с дескриптором, использованным на первом шаге.

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

  1.  Используйте вызов OpenFileMapping, чтобы открыть участок общей памяти с указанным именем.
  2.  Используйте один из вызовов MapV1ewOfFile, чтобы получить указатель на общую память,
  3.  После завершения работы с общей памятью используйте UnmapViewOfFilе, чтобы освободить указатель.
  4.  Обратитесь к CloseHandle и передайте этому вызову дескриптор, использованный вами на первом шаге. После этого общая память будет освобождена.

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

Анонимные каналы (pipes)

Анонимные каналы очень напоминают каналы передачи данных от команды к команде, которые используются в командной строке DOS или в приглашении командной оболочки Unix. Этот механизм достаточно эффективен, но недостаточно гибок. Его нельзя использовать через сеть или для перекрывающегося ввода/вывода. Данные передаются только в одном направлении. Кроме того, анонимный канал нельзя создать между любыми двумя процессами. Обычно анонимный канал либо создается заново, либо наследуется от родительского процесса, Все из-за того, что, как следует из названия, эти каналы являются анонимными. Конечно, можно переслать один конец анонимного канала другому процессу через общую память или любой другой механизм IPC, однако это не всегда логично, так как при этом подразумевается, что процессы уже могут обмениваться данными с использованием другого метода IPC.

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

Чтобы создать канал, используется вызов CreatePipe. Этому вызову следует передать два указателя на дескрипторы, желательный размер буфера обмена (обычно устанавливается равным нулю; при этом используется размер по умолчанию) и структуру SECURITY_ATTRIBUTES. При помощи этой структуры дескрипторы канала можно сделать наследуемыми. Если вы получили ненаследуемые дескрипторы, сделать их наследуемыми можно при помощи вызова DuplicateHandle. Этот вызов создает копию переданного ему дескриптора и наделяет этот новый дескриптор любыми желаемыми свойствами. Вызов CreatePipe размещает дескрипторы, соответствующие двум оконечностям анонимного канала (один для чтения, а другой — для записи) в ячейках памяти, указатели на которые передаются этому вызову в качестве аргументов.

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

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

Вообще-то эту задачу можно выполнить и по-другому. Например, при обращении к CreateProcess можно указать новое значение стандартных дескрипторов в структуре