В тексте этого
урока использованы материалы, любезно предоставленные преподавателем СПбГТУ
Мариной Полубенцевой, с которой мы совместно ведем курс Visual C++ в Microsoft
Certified Educational Center при Санкт-Петербургском государственном техническом
университете (www.Avalon.ru).
Разработчики
Windows-приложений живут в особом мире событий и сообщений, в котором последовательность
выполнения операций не всегда строго предсказуема. Они выработали свое особое
представление о том, как правильно ставить и решать задачи в виртуальном мире
операционной системы, управляемой событиями. Если вы, читатель, выполнили все
шаги по разработке традиционного Windows-приложения, описанные в третьем уроке
этой книги, то, вероятно, уже имеете понятие о структуре и принципе функционирования
любой Windows-программы. Традиционным называется приложение, созданное на основе
функций API (Application Programming Interface) или программируемого интерфейса
приложений. API — это подсистема Windows, которая помогает программировать,
то есть планировать и создавать, графический интерфейс пользователя. В состав
API, как вы знаете, входят не только функции, но и множество структур языка
С, сообщений Windows, макросов и интерфейсов.
Разделы
адресного пространства
Ниже на рис.
12.8 показано как разбивается память на разделы (partitions) в адресном пространстве
процесса под управлением Windows NT. Разделы будем рассматривать, двигаясь сверху
вниз, от старших адресов к младшим. Верхнюю половину памяти (от 2 Гбайт до 4
Гбайт) система использует для своих нужд. Сюда она грузит свое ядро (kernel)
и драйверы устройств. При попытке обратиться к адресу памяти из этого диапазона
возникает исключительная ситуация нарушения доступа и система закрывает приложение.
Заметьте, что половину памяти у нас отняли только из-за того, что иначе не удалось
добиться совместимости с процессором MIPS R4000, которому нужна память именно
из этого раздела.
Следующий небольшой
раздел (64 К) также резервируется системой, но никак ей не используется. При
попытке обращения к этой памяти возникает нарушение доступа, но приложение не
закрывается. Система просто выдает сообщение об ошибке. Большинство из вас знают,
что потеря контроля над указателем в программе на языке С или C++ может привести
к ошибкам такого рода. Следующие (почти) 2 Гбайт отданы в собственность процесса.
Сюда загружаются исходный код приложения (ехе-модуль), динамические библиотеки
(dll), здесь также располагаются стеки потоков и области heap, в которых
они черпают динамически выделяемую память. Последний маленький (64 К) раздел,
так же как и третий раздел, не используется системой и служит в качестве ловушки
«непослушных» (wild) указателей.
Рис. 12.8.
Разделы адресного пространства процесса
Примечание
В системах Windows NT
Server Enterprise Edition и Windows 2000 Advanced Server процессу доступны
нижние 3 Гбайт и только 1 Гбайт резервируется системой.
Любому Wm,32-nponeccy
могут понадобиться объекты ядра Windows, а также ее подсистемы User или GDI.
Они расположены в динамически подключаемых библиотеках: Kernel32.dll, User32.dll,
Gdi32.dll и Advapi32.dll Эти библиотеки при необходимости подгружаются в верхнюю
часть блока, доступного процессу.
Общий объем
памяти, который система может предоставить всем одновременно выполняемым процессам,
равен сумме физической памяти RAM и свободного пространства па диске, которым
может пользоваться специальный страничный файл (paging file). Страницей
называется блок памяти (4 Кбайт для платформ х86, MIPS, PowerPC и 8 Кбайт для
DEC Alpha), который является дискретным квантом (единицей измерения) при обмене
с диском. Виртуальный адрес в пространстве процесса проецируется системой в
эту динамическую страничную память с помощью специальной внутренне поддерживаемой
структуры данных (page map). Когда система перемещает страницу в страничный
файл, она корректирует page тар того процесса, который ее используют.
Если системе нужна физическая память RAM, то она перемещает на диск те страницы,
которые дольше всего не использовались. Манипуляции с физической памятью никак
не затрагивают приложения, которые работают с виртуальными адресами. Они просто
не замечают динамики жизни физической памяти.
Функции API
для работы с памятью (virtualAlloc и virtualFree) позволяют процессу получить
страницы памяти или возвратить их системе. Процесс отведения памяти имеет несколько
ступеней, когда блоки памяти постепенно проходят через определенные состояния.
Страницы памяти в виртуальном адресном пространстве процесса могут пребывать
в одном из трех возможных состояний.
Таблица
12.2. Состояния страниц памяти в виртуальном адресном пространстве процесса
|
|
Free
|
Страница
недоступна, но ее можно либо зарезервировать (reserve) для процесса, либо
отдать процессу (committed) |
Reserved |
Зарезервированный
блок памяти недоступен процессу и не связан с какой-либо физической памятью,
но он подготовлен для того, чтобы в любое время быть отданным (committed)
процессу. Зарезервированный диапазон адресов не может быть отдан другому
потоку этого же процесса. Такой способ работы снижает фрагментарность
физической памяти, так как обычно память резервируется для какой-либо
динамической структуры с учетом ее будущего роста.
|
|
Отданная страница
представляет интересы уже реальной физической памяти как в RAM, так
и на диске. Она может иметь различную степень доступа для процесса.
(Readonly, ReadWrite и т. д.)
Память, которую
процесс отводит, вызывая функцию virtualAlloc, доступна только этому процессу.
Если какая-то DLL в пространстве процесса отводит себе новую память, то она
размещается в пространстве процесса, вызвавшего DLL, и недоступна для других
процессов, одновременно пользующихся услугами той же DLL. Иногда необходимо
создать блок памяти, который был бы общим для нескольких процессов или DLL,
используемых несколькими процессами. Для этой цели существует такой объект ядра
системы, как файлы, проецируемые в память (file mapping).
Два процесса
создают два упомянутых объекта с одним и тем же именем, получают описатель (handle)
объекта и работают с ним так, как будто этот объект находится в памяти.
На самом деле они работают с одними и теми же страницами физической памяти.
Заметьте, что эта память не является глобальной, так как она остается недоступной
для других процессов. Кроме того, ей могут соответствовать различные виртуальные
адреса в пространствах разных процессов, ее разделяющих. Если процессы намерены
записывать в общую память, то во избежание накладок вы должны использовать синхронизирующие
объекты ядра Windows (семафоры, мыотексы, события).
Алгоритм работы
с динамической памятью процесса довольно сильно отличается от привычного алгоритма
работы с динамической памятью области heap в программах на языке C++.
Там вы с помощью операции new отводите память определенного размера, работаете
с ней и затем освобождаете ее операцией delete. Здесь необходимы более сложные
манипуляции:
резервирование диапазона
адресов в виртуальном пространстве процесса. Физическая память при этом не
выделяется;
отдача (commiting)
процессу какого-то количества страниц из предварительно зарезервированного
диапазона адресов. При этом процессу становится доступной физическая память,
соответствующая виртуальной. Здесь одновременно указывается тип доступа к
выделенным страницам (read-write, read-only, или no access). Сравните с обычным
способом, который всегда выделяет страницы С доступом read-write;
освобождение диапазона
зарезервированных страниц;
освобождение диапазона
отданных страниц. Здесь освобождается физическая память.
Кроме того,
возможна операция блокирования страниц памяти в RAM, которая запрещает системе
перемещать их в страничный файл подкачки (paging file). Есть функция, позволяющая
определить текущее состояние диапазона страниц и изменить тип доступа к ним.
Подсистемы ОС
Операционная
система Windows NT представляет собой множество отдельных модулей (подсистем),
которые разработаны с учетом двух фундаментальных принципов:
модульность, инкапсуляция,
скрытие данных, :
некоторые подсистемы
функционируют в привилегированном режиме процессора (kernel mode), а
остальные в режиме (user mode).
Первый принцип
подразумевает, что каждая подсистема отвечает за отдельную функцию всей системы
и все другие потоки — другие части ОС или приложения пользователя, общаются
с ней с помощью одного и того же хорошо продуманного интерфейса. Реализация
принципа делает невозможными какие-то другие способы (back doors) доступа
к критическим для функционирования системы структурам данных. Кроме того, такой
подход дает возможность легко производить усовершенствование (upgrade) системы,
так как подсистемы, удовлетворяющие заранее известному интерфейсу, можно заменять
без какого-либо ущерба для системы.
Для оценки
важности второго принципа необходимо пояснить суть режимов выполнения команд
kernel mode и user mode. В режиме ядра (kernel mode) вся
память доступна и все команды выполнимы. Это привилегированный режим по сравнению
с режимом user mode, когда система проверяет права доступа потока при
каждом его обращении к памяти. Режим выполнения user mode значительно
более надежен, но требует дополнительных затрат, которые снижают общую производительность
процесса. В литературе режим ядра иногда называют режимом супервизора или режимом
Ring(). Степени защиты памяти называют кольцами, а нулевое кольцо обозначает
самый привилегированный аппаратный уровень. Вы можете встретить также обозначения
PL=0 (Privilege Level) для kernel mode и PL = 3 для user mode.
Если операционная система выполняет первый принцип и большинство ее модулей
выполняется в режиме user mode, то говорят, что ОС является риге microkemel-системой.
Возможны две версии перевода: «имеет чистое микроядро» и «настоящая microkernel-система».
Если система удовлетворяет только первому принципу, то ее называют macrokernel
OS. Большинство коммерческих ОС не выполняет второй принцип, так как они
хотят быть быстрыми. Windows сразу примкнула к сторонникам microkernel OS,
так как здесь соображения надежности поставлены на более высокое место.
На рис. 12.9
приведена схема, иллюстрирующая архитектуру (состав подсистем) Windows NT. Подсистема
Win32 Subsystem состоит из пяти модулей:
Window Manager—компонент,
который управляет вводом и выводом на экран. Этот модуль имеет и другое имя
— User. Он и располагается в библиотеке User32.dll;
Graphics Device Interface
(GDI) — библиотека функций и структур, которые реализуют рисование в контексте
устройства. Контекст устройства — это логическая структура, не зависящая от
физического устройства и позволяющая пользоваться максимальными возможностями
и средствами для вывода графики. Вывод в конкретное физическое устройство
производится с помощью драйвера устройства. Система при этом преобразовывает
и, возможно, искажает информацию с учетом ограничений, характерных для конкретного
устройства. Поэтому реальная картина может отличаться от идеальной, созданной
в контексте устройства. Различают шесть инструментов GDI, с помощью которых
осуществляется рисование: Pen, Brush, Font, Bitmap, Palette и Region;
Graphics Device Drivers
(GDD) — аппаратно-зависимые драйверы, которые осуществляют связь с конкретными
физическими устройствами ввода и вывода;
Console — компонент,
который поддерживает текстовый режим вывода в окно;
Operating System Functions
— функции, которые поддерживают все другие компоненты подсистемы Win32.
Рис. 12.9.
Архитектура Windows
Каждый компонент
расположен в отдельном DLL-файле. Все они выполнялись в режиме user mode.
Однако теперь (в NT 4.0) большинство подсистем выполняется в режиме kernel
mode. При этом утверждается, что при переносе блоков из области user
mode в область kernel mode надежность системы не снижается благодаря
особым усилиям компании Microsoft, которая проявляет особую осторожность при
создании такой части ОС, как GDD (Graphics Device Olivers).
Примечание
Вы знаете, что многие
OEM-драйверы (Original Equipment Manufacturers) пишутся не в стенах компании,
а другими разработчиками. Выполнение кодов этих, возможно, содержащих ошибки
драйверов в режиме kernel mode, когда нет преград, может обрушить всю систему.
Разработчики
системы утверждают, что Windows NT является удивительно модульной и инкапсулированной
системой, то есть слабозависящей от неожиданных изменений ситуации. Например,
она не зависит от размера страницы page-файла. При загрузке системы, точнее,
выполнении модуля NTDetect.com, который вы можете видеть в корневом каталоге
системного диска, она определяет оптимальный размер страницы. Размер зависит
от архитектуры процессора, то есть конкретной платформы. Система, например,
может переключиться с размера 4К на 16К. При этом она продолжает надежно работать,
несмотря на достаточно радикальную перемену в своей архитектуре.
Функции некоторых
подсистем: Virtual Memory Manager (Менеджер виртуальной памяти), Process Manager
(Менеджер процессов) мы уже пояснили. Process Manager, кроме рассмотренных функций
обеспечивает вместе с Virtual Memory Manager и Security Model (Модель защиты)
защиту процессов друг от друга. Подсистема Object Manager (Менеджер объектов)
создает, управляет и уничтожает объекты Windows NT Executive. Это абстрактные
типы данных, используемые для представления таких ресурсов системы, как файлы,
директории, разделяемые сегменты памяти, процессы, потоки, глобальное пространство
имен и др. Благодаря модульной структуре подсистемы в нее могут быть легко добавлены
и другие новые объекты.
I/O Manager
(Менеджер ввода-вывода) состоит из серии компонентов, таких как файловая система,
сетевой маршрутизатор и сервер, система драйверов устройств, менеджер кэша.
Стандартный интерфейс позволяет одинаковым образом общаться с любым драйвером.
Здесь в полной мере проявляются преимущества многослойной (multi-layered) архитектуры.
Много слов
сказано в литературе и на конференциях про Security Reference Monitor (Монитор
обращений к системе безопасности), но эта тема далеко не всем интересна. Почему-то
она абсолютно не захватывает и мое воображение, хотя я понимаю, что тема может
оказаться жизненно важной для тех, кому есть, что скрывать.
Hardware Abstraction
Layer (Аппаратный уровень абстракции) — HAL является изолирующим слоем между
программным обеспечением, поставляемым разными производителями, и более высокими
абстрактными слоями ОС. Благодаря HAL
различные типы
устройств выглядят одинаково с точки зрения системы. При этом убирается необходимость
подстройки системы при введении новых устройств. При проектировании HAL была
поставлена цель — создать процедуры, которые позволят общаться только с драйвером
устройства, чтобы можно было управлять самим устройством в рамках любой платформы.
|
Взаимодействие
подсистем
Приложения
и защищенные подсистемы взаимодействуют по типу клиент-сервер. Приложения (клиенты)
запрашивают подсистемы (серверы) о необходимости выполнить какой-то сервис.
При этом клиенты и серверы общаются посредством строго определенной последовательности
сообщений. Такой стиль называется Inter-Process Communications (IPC — Обмен
данными между процессами), и он имеет форму либо местных вызовов процедур Local
Procedure Call (LPC), либо удаленных вызовов — Remote Procedure Call (RPC).
Если и клиент, и сервер расположены в одном компьютере, TO Windows NT Executive
использует LPC — оптимизированную разновидность общепринятого стандарта RPC,
который действует между клиентами и серверами, расположенными в пределах одной
сети компьютеров. Стандарт RPC позволяет обмениваться услугами с серверами,
работающими на других платформах, например из UNIX-окружения.
|
Разделяемые
ресурсы
В современном
операционном окружении программист не может быть уверен и не должен полагаться
на то, что коды его программы будут выполняться в тон же последовательности,
в какой они написаны. Выполнение одной из функций программы может быть остановлено
системой и возобновлено позднее, причем это может произойти даже при выполнении
тела какого-либо цикла. При проектировании многопотоковых приложений следует
иметь в виду, что ресурсы, разделяемые потоками (блоки памяти или файлы), можно
неосознанно повредить. Чтобы показать, как это происходит, рассмотрим пример,
который приведен в книге Jesse Liberty «Beginning Object-Oriented Analysis and
Design with C++» (Дж. Либерти «Начало объектно-ориентированного анализа и проектирования
с помощью C++»), доступной в MSDN.
Представьте
себе пассажирский авиалайнер в полете, а в нем такой разделяемый всеми ресурс,
как туалетная комната. Создатели самолета предполагали, что только одна персона
может занимать эту комнату. Первый, кто ее занял, закрывает (lock) доступ к
пей для всех остальных. Следующий пассажир, желающий воспользоваться этим ресурсом,
может либо терпеливо ожидать освобождения, либо по истечении какого-то времени
(time out) вернуться на свое сиденье и продолжать заниматься тем, чем он был
занят до этого события. Решение о том, что выбрать и как долго ждать, принимает
пассажир. Блокирование ресурса порождает неэффективное проведение времени второго
пассажира как ожидающего очереди, так и избравшего другую тактику.
Возвращаясь
к многопотоковым процессам, отметим, что если не блокировать ресурс,
то становится возможным повреждение данных. Представьте, что один поток процесса
проходит по записям базы данных, повышая зарплату каждому сотруднику на 10%,
а другой поток в это же время изменяет почтовые индексы в связи с введением
нового стандарта. Согласитесь с тем, что разумно совместить эти две работы в
одном процессе с целью повышения производительности. Что может произойти, если
не блокировать доступ к записи при ее модификации? Первый поток прочел запись
(все ее поля), и занят вычислением повышения (предположим, с $80 000 до $85
000). В это время второй поток читает эту же запись с целью изменения почтового
индекса. В этой ситуации может произойти следующее: первый поток сохраняет измененную
запись с новым значением зарплаты, а второй, возвращая запись с измененным индексом,
реставрирует значение зарплаты и данный сотрудник останется без повышения. Это
происходит по причине того, что оба потока не могут обратиться к части записи
и поэтому работают со всей записью, хотя модифицируют только отдельные ее поля.
|
Стратегии
решения проблемы
Для того чтобы
исключить подобный сценарий, автор многопотокового приложения должен решать
проблему синхронизации при попытке одновременного доступа к разделяемым ресурсам.
Если говорить о файлах с совместным доступом, то сходная ситуация может возникнуть
и при столкновении различных процессов, а не только потоков одного процесса.
Разработчика в этом случае уже не устроит стандартный способ открытия файла.
Например1:
//=======
Создаем объект класса CFile
CFile
file;
//
====== Строка с именем файла
CString
fn("MyFile.dat");
//=====
Попытка открыть файл для чтения
if
( ! file.Open(fn,CFile::modeRead))
{
MessageBox
("He могу открыть файл "+fn, "Ошибка");
return;
}
Он должен писать
код с учетом того, что файл может быть заблокирован какое-то время другим процессом.
Если следовать уже рассмотренной тактике ожидания ресурса в течение какого-то
времени, то надо создать код вида:
bool
CMyWnd::TryOpen()
<
//======
Попытка открыть файл и внести изменения
CFile
file;
CString
fn("MyFile.dat"), Buffer;
//=====
Флаг первой попытки
static
bool bFirst = true;
if
(file.Open (fn, CFile:: modeReadWrite I CFile::shareExclusive))
{
//
Никакая другая программа не сможет открыть
//
этот файл, пока мы с ним работаем
int
nBytes = flie.Read(Buffer,MAX_BYTES);
//====
Работаем с данными из строки Buffer
//====
Изменяем их нужным нам образом
//====
Пришло время вновь сохранить данные
file.Write(Buffer,
nBytes);
file.
Close ();
//====
Начиная с этого момента, файл доступен
//====
для других процессов
//====
Если файл был открыт не с первой попытки,
//====
то выключаем таймер ожидания
if
(IbFirst)
KillTimer(WAIT_ID);
//=====
Возвращаем флаг успеха
return
bFirst = true;
}
//======
Если не удалось открыть файл
else
if
(bFirst) // и эта неудача — первая,
//=====
то запускаем таймер ожидания
SetTiraer(WAIT_ID,
1000, 0);
//=====
Возвращаем флаг неудачи
return
bFirst = false;
}
В другой функции,
реагирующей на сообщения таймера, называемой, как вы знаете, функцией-обработчиком
(Message Handler), надо предусмотреть ветвь для реализации выбранной
тактики ожидания:
//======
Обработка сообщений таймера
void
CMyWnd::OnTimer(UINT nID)
{
//======
Счетчик попыток
static
int iTrial = 0;
//======
Переход по идентификатору таймера
switch
(nID)
{
//==
Здесь могут быть ветви обработки других таймеров
case
WAIT_ID:
//======
Если не удалось открыть
if
(ITryOpenO)
{
//=====
и запас терпения не иссяк,
if
(++iTrial < 10)
return;
// то продолжаем ждать
//===
Если иссяк, то сообщаем о полной неудаче
else
{
MessageBox
("Файл занят более 10 секунд",
"Ошибка");
//====== Отказываемся ждать
KillTimer(WAIT_ID);
//======
Обновляем запас терпения
iTrial
= 0;
}
}
}
}
Существуют
многочисленные варианты рассмотренной проблемы, и в любом случае программист
должен решать их, например путем синхронизации доступа к разделяемым ресурсам.
Большинство коммерческих систем управления базами данных умеют заботиться о
целостности своих данных, но и вы должны обеспечить целостность данных своего
приложения. Здесь существуют две крайности: отсутствие защиты или ее слабость
и избыток защиты. Вторая крайность может создать низкую эффективность приложения,
замедлив его работу так, что им невозможно будет пользоваться. Например, если
в примере с повышением зарплаты первый поток заблокирует-таки доступ к записи,
но затем начинает вычислять новое значение зарплаты, обратившись к источнику
данных о средней (в отрасли) зарплате по всей стране. Такое решение проблемы
может привести к ситуации, когда второй поток процесса, который готов корректировать
эту же запись, будет вынужден ждать десятки минут.
Одним из более
эффективных решений может быть такое: первый поток читает запись, вычисляет
прибавку и только после этого блокирует, изменяет и освобождает запись. Такое
решение может снизить время блокирования до нескольких миллисекунд. Однако защита
данных теперь не сработает в случае, если другой поток поступает также. Второй
поток может прочитать запись после того, как ее прочел первый, но до того, как
первый начал изменять запись. Как поступить в этом случае? Можно, например,
ввести механизм слежения за доступом к записи, и если к записи было обращение
в интервале между чтением и модификацией, то отказаться от модификации и повторить
всю процедуру вновь.
Примечание
Каждое решение создает
новые проблемы, а поиск оптимального баланса ложится на плечи программиста,
делая его труд еще более интересным. Кстати, последнее решение может вызвать
ситуацию, сходную с той, когда два человека уступают друг другу дорогу. Отметьте,
что решение вопроса кроется в балансе между производительностью (performance)
и целостностью данных (data integrity).
|
Транзакции
В системе с
преимущественной многозадачностью ноток может быть прерван в любой момент. Обычно
перед выполнением очередной машинной команды система смотрит, есть ли прерывание.
Если есть и приоритет его достаточно высок, то текущая команда не выполняется,
а система переходит в режим обработки прерывания. Если программа написана без
учета этого обстоятельства (not thread-safe), то последствия могут быть
неожиданными. Например, если поток проверяет значение какого-то глобального
флага и в зависимости от его значения выполняет разветвление, то возможна ошибка
из-за того, что флаг мог быть изменен другим потоком, перехватившим управление
в промежутке между этими двумя командами. Для создания thread-safe-прмложешт
программист должен уметь синхронизировать доступ к критическим объектам
так, чтобы один поток не портил работу другого.
Важным понятием,
которое имеет отношение к рассматриваемой проблеме и используется при описании
алгоритмов управления источниками данных, является транзакция. Представьте,
что один клиент банка производит депозит чека, который получен им от другого
клиента. При выполнении этой операции нормально функционирующая система должна
либо изменить оба счета, либо оставить все без изменений. Если на счету клиента,
выписавшего чек, есть указанная сумма, то этот счет уменьшается, а счет второго
клиента увеличивается. Если указанной суммы на счету первого клиента не оказалось,
то оба счета должны остаться без изменений. Идея транзакции заключается в том,
что после проведения изменений в записях базы данных все они либо принимаются
(committed), либо отвергаются (rolled-back или aborted).
Транзакция
— это множество операций, которые выполняются либо все, либо ни одна. Транзакция
представляет собой последовательность операций над БД (базой данных), рассматриваемых
СУБД как единое целое и необходимых для поддержания ее логической целостности.
То свойство, что каждая транзакция начинается при целостном состоянии БД и оставляет
это состояние целостным после своего завершения, делает очень удобным использование
этого понятия как единицы активности пользователя по отношению к БД. Для поддержки
многозадачности требуются следующие механизмы обработки данных:
начало транзакции, =>
изменение данных, => принятие изменений (commit);
начало транзакции, =>
изменение данных, => отмена транзакции (roll back);
восстановление системы
после программного или аппаратного сбоя;
восстановление системы
после потери данных на диске;
архивирование базы данных;
создание контрольных
точек (checkpoints) — копий текущих состояний для возможности их восстановления.
Одним из основных
требований к СУБД является надежность хранения данных во внешней памяти. Под
надежностью хранения понимается то, что СУБД должна быть в состоянии восстановить
последнее согласованное состояние БД после любого аппаратного или программного
сбоя. Обычно рассматриваются два возможных вида аппаратных сбоев: так называемые
мягкие сбои, которые можно трактовать как внезапную остановку работы компьютера,
например аварийное выключение питания, и жесткие сбои, характеризуемые потерей
информации на носителях внешней памяти. Примерами программных сбоев могут быть:
аварийное завершение
работы СУБД по причине ошибки в программе или в результате некоторого аппаратного
сбоя,
аварийное завершение
пользовательской программы, в результате чего некоторая транзакция остается
незавершенной.
Во всех случаях
придерживаются стратегии «упреждающей» записи в журнал, так называемого протокола
WAL — Write Ahead Log. Она заключается в том, что запись об изменении любого
объекта БД должна попасть во внешнюю память журнала раньше, чем измененный объект
попадет во внешнюю память основной части БД. Известно, что если в СУБД корректно
соблюдается протокол WAL, то с помощью журнала можно решить все проблемы восстановления
БД после любого сбоя.
|
Тупиковая
ситуация (Deadlock)
Выше мы рассмотрели,
как потоки одного процесса могут вступить в конфликт и испортить работу друг
друга. Одной из задач программиста является обеспечить невозможность такого
сценария. Другими возможными неприятностями могут быть: рассинхронизация (race
conditions) и тупиковая ситуация (deadlock).
Первая может
произойти, когда успех одной операции зависит от успеха другой, но обе они не
синхронизированы друг с другом. Предположим, что один поток процесса подготавливает
принтер, а другой ставит задание на печать (print job) в очередь. Если
потоки не синхронизированы и первый из них не успеет выполнить свою работу до
того, как начнется печать, то мы получим сбой.
Примечание
Но в каком-то количестве
случаев все пройдет гладко. Такой тип ошибок очень неприятен, так как в процессе
отладки ее нельзя уверенно и многократно воспроизводить. Рассинхронизация
порождает ненадежность —тип ошибок, который большинство программистов всего
мира ненавидит. В MSDN, но, к сожалению, не в литературе, вы часто можете
встретить упоминания о коварстве irreprodudble bugs (невоспроизводимые ошибки).
Суверенностью можно сказать, что книга под названием «Технологии борьбы с
ошибками» была бы бестселлером.
Тупиковая ситуация
создается, когда один поток ждет завершения второго, а второй ждет завершения
первого. Представьте, что один поток реализует такую функцию:
блокирует запись, идентифицирующую
клиента;
блокирует запись, описывающую
его счет;
изменяет обе записи;
освобождает запись, описывающую
счет;
освобождает запись, идентифицирующую
клиента.
Обратите внимание
на то, что освобождение блокировок происходит в обратном порядке. Именно так
следует поступать при работе с записями базы данных и всеми объектами ядра Windows.
Предположим далее, что второй поток реализует функцию начисления месячного процента
и он делает те же действия, что и первый, но порядок блокирования и освобождения
записей обратный. Оба потока по отдельности функционируют вполне надежно. В
процессе работы возможен следующий сценарий: первый поток блокирует запись,
идентифицирующую клиента, затем второй блокирует запись, описывающую его счет.
После этого оба ждут освобождения записей, блокированных друг другом. Если ожидание
реализовано разработчиком в виде бесконечного цикла, то мы его получили. Это
тупиковая ситуация, или deadlock.
|
Механизмы
синхронизации
Существует
несколько стратегий, которые могут применяться, чтобы разрешать описанные проблемы.
Наиболее распространенным способом является синхронизация потоков. Суть синхронизации
состоит в том, чтобы вынудить один поток ждать, пока другой не закончит какую-то
определенную заранее операцию. Для этой цели существуют специальные синхронизирующие
объекты ядра операционной системы Windows. Они исключают возможность одновременного
доступа к тем данным, которые с ними связаны. Их реализация зависит от конкретной
ситуации и предпочтений программиста, но все они управляют потоками процесса
по принципу: «Не все сразу, по одному, ребята».
MFC предоставляет
несколько классов, реализующих механизмы синхронизации. Прежде всего отметим,
что хорошо спроектированный {thread-safe) класс не должен требовать особых
затрат для синхронизации работы с ним. Все делается внутри класса его методами.
Обычно при создании надежного класса в него изначально внедряют какой-либо синхронизирующий
объект. Например, критическую секцию, событие, семафор, мъютекс или ожидаемый
таймер. Иерархию классов MFC для поддержки синхронизирующих объектов можно
увидеть в MSDN:
Рис. 12.10.
Иерархия классов синхронизации
Все перечисленные
классы, кроме критической секции, принадлежат ядру Windows. Вы знаете, что Windows-приложение
использует множество и других объектов:
окна, меню, курсоры,
значки, клавиатурные ускорители и т.д. (объекты GUI или Graphics User Intrface);
перья, кисти, растровые
рисунки, шрифты (объекты GDI Graphics Device Interface).
При работе
с объектами этих подсистем надо соблюдать определенные правила. Но при работе
с объектами ядра правила особые. Вам следует познакомиться с общими положениями
об использовании объектов ядра системы. Они похожи на стандарты СОМ.
Однажды созданный объект
ядра можно открыть в любом приложении, если оно имеет соответствующие права
доступа к нему.
Каждый объект ядра имеет
счетчик числа своих пользователей. Как только он станет равным нулю, система
уничтожит объект ядра.
Обращаться к объекту
ядра надо через описатель (handle), который система дает при создании объекта.
Каждый объект может
находиться в одном из двух состояний: свободном (signaled) и занятом
(nonsignaled).
Синхронизация
потоков развивается по такому сценарию. При засыпании одного из них операционная
система перестает выделять ему кванты процессорного времени, приостанавливая
его выполнение. Прежде чем заснуть, поток сообщает системе то особое событие,
которое должно разбудить его. Как только указанное событие произойдет, система
возобновит выдачу ему квантов процессорного времени и ноток вновь получит право
на жизнь. Потоки усыпляют себя до освобождения какого-либо синхронизирующего
объекта с помощью двух функций:
DWORD
WaitForSingleObject (HANDLE hObject, DWORD dwTimeOut);
DWORD
WaitForMultipleObjects(DWORD nCount,
CONST
HANDLE* lpHandles, BOOL bWaitAll,
DWORD
dwTimeOut);
Первая функция
приостанавливает поток до тех пор, пока или заданный параметром hObject синхронизирующий
объект не освободится, или пока не истечет интервал времени, задаваемый параметром
dwTimeOut. Если указанный объект в течение заданного интервала не перейдет в
свободное состояние, то система вновь активизирует поток и он продолжит свое
выполнение. В качестве параметра dwTimeOut могут выступать два особых значения:
В соответствии
с причинами, по которым поток продолжает выполнение, функция WaitForSingleObject
может возвращать одно из следующих значений:
| Критические
секции
Это самые простые
объекты ядра Windows, которые не снижают общей эффективности приложения. Пометив
блок кодов в качестве critical section, можно синхронизировать доступ к нему
от нескольких потоков. Сначала следует объявить глобальную структуру;
CRITICAL_SECTION
cs;
затем инициализировать
ее вызовом функции Initial! zeCri ticalSection (&cs);. Обычно это делается
один раз, перед тем как начнется работа с разделяемым ресурсом. Далее надо поместить
охраняемую часть программы внутрь блока, который начинается вызовом функции
EnterCriticalSection и заканчивается вызовом LeaveCriticalSection:
EnterCriticalSection
(&cs);
{
//======
Здесь расположен охраняемый блок кодов
}
LeaveCriticalSection
(Scs);
Функция EnterCriticalSection,
анализируя поле структуры cs, которое является счетчиком ссылок, выясняет, вызвана
ли она в первый раз. Если да, то функция увеличивает значение счетчика и разрешает
выполнение потока дальше. При этом выполняется блок, модифицирующий критические
данные. Допустим, в это время истекает квант времени, отпущенный данному потоку,
или он вытесняется более приоритетным потоком, использующим те же данные. Новый
поток выполняется, пока не встречает функцию EnterCriticalSection, которая помнит,
что объект cs уже занят. Новый поток останавливается (засыпает), а остаток процессорного
времени передается другому потоку.
Функция LeaveCriticalSection
уменьшает счетчик ссылок на объект cs. Как только поток покидает критическую
секцию, счетчик ссылок обнуляется и система будит ожидающий поток, снимая защиту
секции кодов. Критические секции применяются для синхронизации потоков лишь
в пределах одного процесса. Они управляют доступом к данным так, что в каждый
конкретный момент времени только один поток может их изменять. Когда надобность
в синхронизации потоков отпадает, следует вызвать DeleteCriticalSection (&cs);.
Эта функция освобождает все ресурсы, включенные в критическую секцию.
|
Мьютексы
(Mutexes)
Критические
секции просты в использовании и обладают высоким быстродействием, но не обладают
гибкостью в управлении. Нет, например, возможности установить время блокирования
или присвоить имя критической секции для того, чтобы два разных процесса могли
иметь с ней дело. Оба эти недостатка можно устранить, если использовать такой
объект ядра, как mutex. Термин mutex происходит от mutually exclusive
(взаимно исключающий). Этот объект обеспечивает исключительный (exclusive)
доступ к охраняемому блоку кодов. Например, если несколько процессов должны
одновременно работать с одним и тем же связным списком, то на время выполнения
каждой операции: добавления, удаления элемента или сортировки, следует заблокировать
список и разрешить доступ к нему только одному из процессов.
Для синхронизации
потоков разных процессов следует объявить один общедоступный объект класса CMutex,
который будет управлять доступом к списку. Мыо-текс предоставляет доступ к объекту
любому из потоков, если в данный момент объект не занят, и запоминает текущее
состояние объекта. Если объект занят, то мьютекс запрещает доступ. Однако можно
подождать освобождения объекта с помощью функции WaitForSingleObject, в которой
роль управляющего объекта выполняет тот же мьютекс. Типичная тактика использования
такова. Объект
CMutex
mutex;
необходимо
объявить заранее. Обычно он является членом thread-safe-класса. В точке,
где необходимо защитить код, создается объект класса CSingleLock, которому передается
ссылка на мьютекс. При попытке включения блокировки вызовом метода Lock надо
в качестве параметра указать время (в миллисекундах), в течение которого следует
ждать освобождения объекта, охраняемого мьютексом. В течение этого времени либо
получим доступ к объекту, либо не получим его. Если объект стал доступен, то
мы запираем его от других потоков и производим работу, которая требует синхронизации.
После этого освобождаем блокировку. Если время ожидания истекло и доступ к объекту
не получен, то обработка этой ситуации (ветвь else) целиком в нашей власти.
Если задать ноль в качестве параметра функции Lock, то ожидания не будет. Напротив,
можно ждать неопределенно долго, если передать константу INFINITE.
Другой процесс,
если он знает, что существует мьютекс с каким-то именем, может сделать этот
объект доступным для себя, открыв уже существующий мьютекс. При вызове функции
OpenMutex система сканирует существующие объекты-мьютексы, проверяя, нет ли
среди них объекта с указанным именем. Обнаружив таковой, она создает описатель
объекта, специфичный для данного процесса. Теперь любой поток данного процесса
может использовать описатель в целях синхронизации доступа к какому-то коду
или объекту. Когда мьютекс становится ненужным, следует освободить его вызовом
CloseHandle(HANDLE
hObject);
где hObject
— описатель мьютекса. Когда система создает мьютекс, она присваивает ему имя
(строка в стиле С). Это имя используется при совместном доступе к мыотексу нескольких
процессов. Если несколько потоков создают объект с одним и тем же именем, то
только первый вызов приводит к созданию мьютекса. Имя используется при совместном
доступе нескольких процессов. Если оно совпадает с именем уже существующего
объекта, конструктор создает новый экземпляр класса CMutex, который ссылается
на существующий мьютекс с данным именем. Если имя не задано (IpszName равен
NULL) мьютекс будет неименованным, и им можно пользоваться только в пределах
одного процесса.
С любым объектом
ядра сопоставляется счетчик, фиксирующий, сколько раз данный объект передавался
во владение потокам. Если поток вызовет, например, CSingleLock: :Lock() ИЛИ
WaitForSingleObject () ДЛЯ уже принадлежащего ему объекта, он сразу же получит
доступ к защищаемым этим объектом данным, так как система определит, что поток
уже владеет этим объектом. При этом счетчик числа пользователей объекта увеличится
на 1. Теперь, чтобы перевести объект в свободное состояние, потоку необходимо
соответствующее число раз вызвать CSingleLock::Unlock() ffilHReleaseMutex()
. Функции EnterCriticalSection и LeaveCriticalSection действуют по отношению
к критическим секциям аналогичным образом.
Объект-мьютекс
отличается от других синхронизирующих объектов ядра тем, что занявшему его потоку
передаются права на владение им. Прочие синхронизирующие объекты могут быть
либо свободны, либо заняты и только, а мьютексы способны еще и запоминать, какому
потоку они принадлежат. Отказ от мьютекса происходит, когда ожидавший его поток
захватывает этот объект, переводя его в занятое состояние, а потом завершается.
В таком случае получается, что мьютекс занят и никогда не освободится, поскольку
другой поток не сможет этого сделать. Система не допускает подобных ситуаций
и, заметив, что произошло, автоматически переводит мьютекс в свободное состояние.
|
случаях потоку необходимо ждать, пока
В некоторых
случаях потоку необходимо ждать, пока другие-потоки не завершат выполнение каких-то
операций или не произойдет какое-либо событие (UI-событие User Interface), то
есть событие, инициированное пользователем. В качестве примера, предположим,
что имеется 50 выходных телефонных портов и каждый из них управляется отдельным
потоком. Пусть класс ccaller для управления соединениями (звонками) уже разработан.
Есть также выделенный поток, который управляет всеми портами и отслеживает их
статус. Допустим, что до того, как сделать какой-нибудь звонок (call), надо
инициализировать все потоки. Тогда алгоритм ожидания множественного события
может выглядеть так, как показано ниже. В рассматриваемом фрагменте предполагается,
что объект СЕ vent m_nTotalCallers; уже существует и должным образом инициализирован:
//=======
Цикл по всем портам
for
(int i = 0; i<m_nTotalCallers; i++)
{
//===
Предварительные установки и создание потоков
CCaller
* pCaller = new CCaller(Лпараметры*/);
BOOL
bRc = pCaller->CreateThread();
}
//=======
Блокировка
CSingleLock
lock (Sm_CallersReadyEvent) ;
//=======
Попытка дождаться события
if
(lock.Lock(WAIT_VERY_LONG_TIME))
{
for
(i=0; i<m_nTotalCallers; i++)
{
//=====
Совершение соединений (звонков)
)
lock.Unlock();
}
else
// Отказ ждать
{
//======
Обработка исключения
}
Класс CEvent
представляет функциональность синхронизирующего объект ядра (события). Он позволяет
одному потоку уведомить (notify) другой поток о том, что произошло событие,
которое тот поток, возможно, ждал. Например, поток, копирующий данные в архив,
должен быть уведомлен о том, что поступили новые данные. Использование объекта
класса CEvent позволяет справиться с этой задачей максимально быстро.
Существуют
два типа объектов: ручной (manual) и автоматический (automatic). Ручной
объект начинает сигнализировать, когда будет вызван метод SetEvent. Вызов ResetEvent
переводит его в противоположное состояние. Автоматический объект класса CEvent
не нуждается в сбросе. Он сам переходит в состояние nonsignaled, и охраняемый
код при этом недоступен, когда хотя бы один поток был уведомлен о наступлении
события. Объект «событие» (CEvent) тоже используется совместно с объектом блокировка
(CSingleLock или CMultiLock).
|
Семафоры
Семафором называется
объект ядра, который позволяет только одному процессу или одному потоку процесса
обратиться к критической секции — блоку кодов, осуществляющему доступ к объекту.
Серверы баз данных используют их для защиты разделяемых данных. Классический
семафор был создан Dijkstra, который описал его в виде объекта, который обеспечивает
выполнение двух операций Р и V. Первая литера является сокращением
голландского слова Proberen, что означает тестирование, вторая — обозначает
глагол Verhogen, что означает приращивать (increment). Первая
операция дает доступ к ресурсу, вторая — запрещает доступ и увеличивает счетчик
объектов, его ожидающих. Различают два основных использования семафоров: защита
критической секции и обеспечение совместного доступа к ресурсу.
В качестве
примера может служить критическая секция в виде функции, осуществляющей доступ
к таблице базы данных.. Другим примером может служить реализация списка операционной
системы, который называется process control blocks (PCBs). Это список указателей
на активные процессы. В каждый конкретный момент времени только один поток ядра
системы может изменять этот список, иначе будет нарушена семантика его использования.
Семафор может
быть использован также для управления перемещением данных (data flow) между
п производителями и m потребителями. Существует много систем, имеющих архитектуру
типа data flow. В них выход одного блока функциональной схемы целиком поступает
на вход другого блока. Когда потребители хотят получить данные, они выполняют
операцию типа Р. Когда производители создают данные, они выполняют операцию
типа V.
Традиционно
семафоры создавались как глобальные структуры, совместно с глобальными API-функциями,
реализующими операции Р и V. Теперь семафоры реализуются в виде
класса объектов. Обычно абстрактный класс Семафор определяет чисто виртуальные
функции типа Р и V. От него производятся классы, реализующие два
указанных типа семафоров: защита критической секции и обеспечение совместного
доступа к ресурсу. В смысле видимости оба типа семафоров могут быть объявлены
либо глобально во всей операционной системе, либо глобально в пространстве процесса.
Первые видны всем процессам системы и, следовательно, могут ими управлять. Вторые
действуют только в пространстве одного процесса и, следовательно, могут управлять
его потоками.
Сам семафор
ничего не знает о том, что он защищает. Ему обычно передается ссылка на объект
класса, который хочет использовать критическую секцию, и он либо дает доступ
к объекту, либо подвешивает (suspends) объект до тех пор, пока доступ
не станет возможным. Важно отметить, что при реализации семафоров и других объектов
ядра используют специальные атомарные команды (atomic steps), которые не прерываются
системой.
|
Блокировки
(Locks)
Блокировки
— это семафоры, которые приспособлены для двух операций транзакции (commit и
abort). Они используются для обеспечения последовательного доступа конкурирующих
потоков или процессов к критическим секциям. Обычно в базах данных блокируется
некоторое множество данных (range of items), так как блокировка одного
элемента более накладна. Представьте такой запрос:
Select
* from Customer where country = Russia and city = "Moscow";
Чтобы защитить
данные от рассмотренных выше неприятностей, надо заблокировать все строки таблицы,
которые удовлетворяют указанному критерию поиска. Такой способ защиты, оказывается,
обладает побочным эффектом. Он может породить запись-призрак (phantom). Допустим,
что в это же время другой поток процесса добавляет в ту же таблицу Customer
(Клиент) новую запись и ее поля удовлетворяют тому же критерию (клиент из Мосвы).
Она, конечно же, будет добавлена в таблицу, но для первого потока она является
фантомом (не существует).
|
Специальные
блокировки
Для того чтобы
записи-фантомы не создавались, надо избегать блокирования отдельных записей.
Альтернативой является блокировка всей таблицы. Но это решение приводит к снижению
эффективности работы СУБД. Другим выходом является предикатная блокировка (predicate
locking). Предикат мы определили в уроке, посвященном библиотеке шаблонов
STL. Это функция, которая может принимать только два значения (false, true}
или {0,1}. В нашем примере такая блокировка запомнит не только записи, которые
существуют в таблице и удовлетворяют критерию, но и все несуществующие записи
такого типа, то есть блокируется весь тип записей заданного типа. Поэтому второй
поток процесса найдет таблицу закрытой и будет вынужден ждать окончания работы
первого.
Предикатные
блокировки хороши, но достаточно накладны. Еще одной альтернативой являются
прецизионные блокировки (precision locking). Они не закрывают доступ
к записям, но обнаруживают конфликты, когда транзакция пытается прочесть или
сохранить записи. Прецизионные блокировки более просты в реализации, но создают
повышенный риск тупиковых ситуаций (deadlocks).
До сих пор
мы говорили о блокировках, которые имеют одинаковую гранулярность, то есть размер
единицы блокируемых данных: таблица, запись или поле. Обычно запрос хочет иметь
доступ ко всей таблице, в то время как изменения вносятся только в отдельные
записи или даже только в отдельные поля записи. Разработчики механизмов синхронизации
ищут оптимум между двумя взаимно противоположными устремлениями: обеспечить
максимальную защиту и использовать минимальное число блокировок.
Гранулярные
блокировки ограничивают транзакции небольшим множеством определенных предикатов,
которые образуют дерево. В корне дерева обычно находится предикат, который разрешает
или запрещает доступ ко всей базе данных. На следующем уровне может быть предикат,
возвращающий доступ к определенному сайту (site) распределенной базы
данных. Следующий уровень связан с таблицей и т. д. вплоть до домена или поля.
Блокировки, определенные предикатом какого-то уровня, блокируют все объекты,
описываемые предикатом следующего уровня. Это свойство принадлежит всем деревьям.
В связи с чем может возникнуть новая проблема. Допустим, что одна транзакция
заблокировала базу на уровне записи, а другая в это же время блокирует базу
на уровне таблицы. При этом первый поток не может ничего сделать, и вынужден
ждать, а второй споткнется при попытке изменить запись, блокированную первой.
Опять имеем deadlock.
Это привело
к разработке нового, более изощренного типа блокировок — целевые блокировки
(intention locks). Их идея состоит в том, что при блокировке обозначается ее
цель. Например, блокировка таблицы сообщает, что ее целью являются изменения
на уровне записей. В этом случае устраняется возможность тупиковой ситуации
рассмотренного выше типа. Например, для установки разделяемой блокировки (share
lock) на уровне записей транзакция должна сначала установить целевые блокировки
(intention locks) на всех уровнях, которые расположены ниже или выше,
в зависимости от интерпретации дерева, то есть на уровне таблицы, на уровне
базы данных или сайта, если база является распределенной. После этого можно
произвести запрос на становку блокировки типа share-lock на уровне записей.
|
Устранение
тупиковых ситуаций
Единственным
способом выхода из тупиковой ситуации (deadlock) является снятие блокировки
одним из потоков. Это означает прерывание или отказ от транзакции. Система может
либо предупреждать захваты либо допускать их, но затем соответствующим образом
обрабатывать. Оказалось, что стандартные протоколы работы с базами данных достаточно
редко приводят к образованию захватов, поэтому было признано целесообразным
допускать появление захватов при условии, что разработаны механизмы их обнаружения.
Особую проблему составляет обнаружение захватов в распределенных базах данных,
так как нет простого способа, с помощью которого один узел сети может узнать,
какие блокировки наложены в данный момент другим узлом.
Два технических
приема используются для обнаружения тупиковой ситуации. Первый — это установка
таймера перед совершением транзакции. Если время для ее совершения вышло, то
транзакция прерывается, блокировка снимается, что дает возможность другому потоку
или процессу закончить свою операцию. Это решение очень просто реализовать,
но его недостатком является то, что врожденно медленные транзакции могут потерять
шанс быть выполненными. Другим методом является создание специальной структуры
данных, которая моделирует граф ожиданий (waits-for graph) — бинарное отношение
ожидания между транзакциями. Узлами графа являются транзакции, а дугами — факты
ожидания. Так, например, дуга (i, j) (из узла i в узел j) существует, если транзакция
i ожидает освобождения блокировки, наложенной транзакцией j. Очевидно, что тупиковой
ситуации в этой модели соответствует цикл. Отметьте, что" длина цикла (количество
его дуг) может быть более двух.
Алгоритмы обнаружения
циклов в графах давно разработаны. Графы обычно хранятся в виде динамических
списков, то есть каждый узел хранит список блокировок — указателей на транзакции,
которые ему мешают. Сам список обычно защищен семафором. С целью экономии времени
процессора детектор циклов включается лишь при необходимости или периодически.
Цикл считается обнаруженным, если в списке транзакций, которые тормозят данную,
присутствует транзакция, в списке которой есть указатель на исходную. Эту фразу,
вероятно, придется прочесть несколько раз.
Отметим, что
библиотека классов MFC поддерживает механизмы синхронизации, но детали их реализации
скрыты от разработчика. Тем не менее он может использовать их, не заботясь о
деталях реализации. Главным требованием при этом, как и при работе с любыми
другими объектами классов MFC, является соблюдение протокола, описанного в интерфейсе
класса.
К сожалению,
время, отведенное для написания книги, закончилось и мне не удастся привести
и описать примеры приложений, иллюстрирующих использование синхронизирующих
объектов ядра Windows, хотя такие примеры разработаны и достаточно давно используются
в вышеупомянутом учебном центре.
|
| | |