Особенности многозадачности в среде Windows 95
Мир ПК, #02/1998
- Основные понятия
- Механизмы синхронизации
- Критический раздел
- Исключающий семафор
- Дойдет ли очередь до вас?
- Многопотоковость и графика
- Корректное изменение переменных
Если вы собираетесь реализовывать многозадачность в среде Windows 95, этот материал для вас. Знание некоторых тонкостей, не отраженных в документации, поможет создать высокоэффективные и надежные программы.
Общеизвестно, что в Windows 95 реализована вытесняющая многозадачность. Именно это привлекло внимание многих фирм-разработчиков ПО, поскольку предшественницы этой ОС справедливо считались тяжелыми и неповоротливыми.
Появление вытесняющей многозадачности в Windows 95 резко повысило надежность оболочки. Теперь в девяносто девяти случаях из ста "неработоспособную" программу можно корректно снять с выполнения, не затрагивая при этом другие программы. Конечно, если программа "подвисает", то принудительное удаление ее из системы бесследно не проходит и в дальнейшем может привести к краху всей системы. Но в любом случае после снятия такой программы появляется возможность корректного завершения других программ и перезапуска системы без потери данных.
Основные понятия
Основные понятия многозадачности в Windows 95 - процесс (задача) и поток (нить). Под процессом понимается выполнение программы в целом (WinWord, Excel, Visual C++ и т. д.) Потоками в свою очередь являются части процесса, выполняющиеся параллельно.Любой процесс имеет хотя бы один поток. В этом случае его можно отождествить с потоком.
Процессы интересны с точки зрения взаимодействия одновременно выполняющихся программ, потоки (участки кода, выполняющиеся параллельно в одном процессе) - с точки зрения их синхронизации.
Заметим, что одновременно выполняющиеся потоки могут быть зависимы друг от друга - например, один поток подготавливает данные, другой их сортирует, а третий выводит результат в файл. Передав готовые данные второму потоку на сортировку, первый начинает обработку нового блока. Тем временем второй поток сообщает третьему, что можно выводить результаты. Следовательно, работу этих трех потоков необходимо синхронизировать.
Механизмы синхронизации
Для синхронизации процессов и потоков в Windows 95 предусмотрено четыре механизма:- классический семафор
- критический раздел
- исключающий семафор (объект типа mutex)
- событие (event object)
Критический раздел
Критический раздел - это часть кода, доступ к которому в данное время имеет только один поток. Другой поток может обратиться к критическому разделу, только когда первый выйдет из него.Для работы с критическими разделами используются следующие функции:
- VOID InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection) - инициализация синхронизатора типа критический раздел.
- VOID EnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection) - запрос на вход в критический раздел.
- VOID LeaveCriticalSection(LPCRITICAL_SECTION lpCriticalSection) - выход из критического раздела (освобождение семафора).
- VOID DeleteCriticalSection(LPCRITICAL_SECTION lpCriticalSection) - удаление критического раздела (обычно при выходе из программы).
lpCriticalSection - указатель на переменную типа CRITICAL_SECTION.
lpCriticalSection - указатель на переменную типа CRITICAL_SECTION.
lpCriticalSection - указатель на переменную типа CRITICAL_SECTION.
lpCriticalSection - указатель на переменную типа CRITICAL_SECTION.
Создав объект CRITICAL_SECTION, мы можем работать с ним, т. е. можем обозначить код, доступ к которому для одновременно выполняющихся задач требуется синхронизировать.
Рассмотрим такой пример. Мы хотим записывать и считывать значения из некоего глобального массива mas. Причем запись и считывание должны производиться двумя разными потоками. Вполне естественно, что лучше если эти действия не будут выполняться одновременно. Поэтому введем ограничение на доступ к массиву.
И хотя приведенный нами пример подобного ограничения (см. листинг 1) чрезвычайно упрощен, он хорошо иллюстрирует работу синхронизатора типа критический раздел: пока один поток "владеет" массивом, другой доступа к нему не имеет.
Исключающий семафор
Еще один вид синхронизаторов - исключающий семафор. Основное его отличие от критического раздела заключается в том, что последний можно использовать только в пределах одного процесса (одного запущенного приложения), а исключающими семафорами могут пользоваться разные процессы. Другими словами, критические разделы - это локальные семафоры, которые доступны в рамках только одной программы, а исключающие семафоры могут быть глобальными объектами, позволяющими синхронизировать работу программ (т. е. разные запущенные приложения могут разделять одни и те же данные).Рассмотрим основные функции исключающего семафора на примере работы с объектами mutex.
1. Создание объекта mutex
HANDLE CreateMutex(
LPSECURITY_ATTRIBUTES lpMutexAttributes,
BOOL bInitialOwner,
LPCTSTR lpName )
lpMutexAttributes - указатель на структуру SECURITY_ATTRIBUTES (в Windows 95 данный параметр игнорируется);
bInitialOwner - указывает первоначальное состояние созданного объекта
(TRUE - объект сразу становится занятым, FALSE - объект свободен);
lpName - указывает на строку, содержащую имя объекта. Имя необходимо
для доступа к объекту других процессов, в этом случае объект становится
глобальным и им могут оперировать разные программы. Если вам не нужен именованный
объект, то укажите NULL. Функция возвращает указатель на объект mutex.
В дальнейшем этот указатель используется для управления исключающим семафором.
2. Закрытие (уничтожение) объекта mutex
BOOL CloseHandle( HANDLE hObject )
3. Универсальная функция запроса доступа
DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds) - универсальная
функция, предназначенная для запроса доступа к синхронизирующему объекту
(в данном случае к объекту mutex).
hHandle - указатель на синхронизирующий объект (в данном случае передается значение, возвращенное функцией CreateMutex);
dwMilliseconds - время (в миллисекундах), в течение которого происходит ожидание освобождения объекта mutex. Если передать значение INFINITE (бесконечность), то функция будет ждать бесконечно долго.
Данная функция может возвращать следующие значения:
WAIT_OBJECT_0 - объект освободился;
WAIT_TIMEOUT - время ожидания освобождения прошло, а объект не освободился;
WAIT_ABANDON - произошел отказ от объекта (т. е. процесс, владеющий
данным объектом, завершился, не освободив объект). В этом случае система
(а не "процесс-владелец") переводит объект в свободное состояние. Такое
освобождение объекта не предполагает гарантий в защищенности данных;
WAIT_FAILED - произошла ошибка.
4. Освобождение объекта mutex
BOOL ReleaseMutex( HANDLE hMutex ) - освобождает объект mutex, переводя
его из занятого в свободное состояние.
Честно говоря, мне больше нравится применять в качестве синхронизаторов не критические разделы, а исключающие семафоры, так как они более гибки в использовании.
Сравните, как выглядит наш, прямо скажем, бестолковый пример c критическими разделами, если переписать его, используя исключающие семафоры (см. листинг 2).
Дойдет ли очередь до вас?
Итак, если программе необходимо войти в разделяемый код, то она запрашивает разрешение путем вызова функции WaitForSingleObject. При этом если объект синхронизации занят, то выполнение запрашивающего потока приостанавливается и неиспользованная часть отведенного времени передается другому потоку. А теперь внимание! Теоретически: как только объект становится свободным, ожидающий поток сразу захватывает его. Но это только теоретически. На практике этого не происходит. Захват освободившегося объекта происходит лишь тогда, когда ожидающий поток снова получит свой квант времени. И только тогда он сможет проверить, освободился ли объект, и, если да, захватить его.Косвенным подтверждением вышеизложенных рассуждений может служить тот факт, что Microsoft не предусмотрела поддержку очередности запросов на доступ к объекту синхронизации. То есть если несколько процессов ожидают освобождения одного и того же объекта синхронизации, то нет никакой возможности узнать, какой именно из них первым получит доступ к освободившемуся объекту.
Поясню это на следующем примере. Допустим, что трем потокам необходимо обратиться к одному участку кода, причем единовременно этот участок должен исполнять только один поток. Введем объект синхронизации mutex, регулирующий доступ потоков к этому участку кода. Когда поток 1 захватил объект mutex и стал выполнять разделяемый участок кода, поток 2 запросил разрешение на доступ (т. е. вызвал функцию WaitForSingleObject), а система перевела поток 2 в режим ожидания. Через некоторое время поток 3 тоже запросил разрешение на вход в этот код и тоже перешел в режим ожидания. Теперь, если поток 1 освободит объект синхронизации, то неизвестно, какой поток (2 или 3) его захватит, - все зависит от того, кто из них первым получит свой квант времени для продолжения работы. Предположим, что объектом синхронизации завладел поток 3, а пока он выполнял разделяемый раздел, поток 1 снова запросил доступ к объекту синхронизации - и опять стало два конкурирующих потока (1 и 2). И кто из них первым "достучится" до исполняемого участка кода, неизвестно: может случиться так, что поток 2 никогда не будет допущен к желанному участку кода и надолго останется в состоянии ожидания... А как известно, хуже нет ждать, хотя потоку это безразлично. Другое дело - вам...
Многопотоковость и графика
Есть еще одна особенность при работе с объектами синхронизации. Дело в том, что Windows 95 довольно "тяжело" взаимодействует со своей графической системой в многозадачном режиме. Это объясняется тем, что в Windows 95 графическая подсистема частично осталась 16-разрядной и обращение к такому коду приводит к захвату системного исключающего семафора Win16Mutex, который предотвращает одновременный доступ нескольких процессов (потоков) к такому коду. Утверждение авторов некоторых книг по Windows 95 о том, что это не является помехой, если вы работаете в полностью 32-разрядном приложении, на практике оказывается несостоятельным. Я довольно долго бился с синхронизацией кода GF_Diagram, который имеет возможность в многопотоковом режиме графически отображать данные на экране. При "непрерывном" выводе информации на экран объект GF_Diagram не хотел завершаться после соответствующей команды (попросту он ее не "видел") и "подвисал" при попытке синхронизации доступа к разделяемому коду.Итак, основной проблемой стала невозможность корректного снятия с выполнения графического потока. Снятие производилось по следующему алгоритму. Каждый поток в бесконечном цикле проверял флаг-сигнал о завершении. Если флаг был выставлен, то поток выходил из бесконечного цикла и завершался штатным путем. В упрощенном виде процедура снятия описана в листинге 3.
Такой код идеально работал, если производилось снятие потока, не обращающегося к графической системе Windows (либо редко обращающегося - раз в несколько секунд). Если же поток все время что-нибудь рисовал, то попытка снятия заканчивалась выходом из функции WaitForSingleObject из-за превышения времени ожидания (возвращаемое значение WAIT_TIMEOUT), т. е. снимаемая подпрограмма не получала управления, пока мы "сидели" в функции WaitForSingleObject. Увеличение периода ожидания (например, до 10 с) ни к чему не приводило - поток все десять секунд упрямо ждал освобождения объекта и в конце концов выходил со значением WAIT_TIMEOUT.
Причина, по которой поток не снимался, в общем-то понятна - ему не передавалось управление. Я попробовал принудительно сделать это, увеличив приоритет снимаемого потока:
void breakTask(GF_Task* tsk)
{
DWORD result;
char s[512];
// команда снимаемому потоку на снятие
tsk->putState(tsBreak,True);
// увеличиваем относительный приоритет
// снимаемого потока до максимально возможного
SetThreadPriority(tsk->TaskHnd95,
THREAD_PRIORITY_TIME_CRITICAL)
// ждем завершения потока в течение 1 с
WaitForSingleObject( tsk->TaskHnd95,1000);
..и т. д.
}
Результата никакого (вернее, результат тот же - выход со значением WAIT_TIMEOUT). Получается, что повышение приоритета не всегда срабатывает (еще один косой взгляд в сторону Microsoft).
Что же делать? Как заставить поток, в котором работает программа снятия breakTask, передать управление другим потокам? И вот что я заметил. При получении значения WAIT_TIMEOUT начинает выполняться та часть кода, которая выводит на экран окно с запросом о том, что же делать с неснимающимся потоком. В момент вывода окна на экран многострадальный поток вдруг сам завершается - он наконец "замечает" флаг завершения и выходит из бесконечного цикла. Это подтверждает, что до снимаемого потока просто не доходит управление (не выделяется квант времени).
Не вдаваясь в причины подобного поведения Windows, мы должны проанализировать, а что же все-таки происходит в модальном окне, что заставляет ОС заметить нашу задачу. Вероятно, все кроется в петле ожидания событий, которая запускается в модальном окне. Одной из основных функций в таком цикле ожидания является функция GetMessage. Замечательным свойством обладает данная функция: ее вызов приводит к оповещению планировщика задач Windows. Поскольку внешних событий для вызвавшего эту функцию потока нет, то оставшуюся часть его кванта времени планировщик задач передает другому выполняющемуся потоку. Таким образом, наш снимаемый поток снова оживает.
Итак, нам надо использовать функцию типа GetMessage для стимуляции Windows к передаче управления другим потокам. Но сама функция GetMessage нам не подходит, так как она отдает управление только в том случае, если для потока появилось сообщение. Вместо GetMessage можно применить функцию PeekMessage, которая проверяет, есть ли сообщение в очереди для данного потока, и независимо от результата сразу же возвращает управление. Перепишем наш предыдущий пример так:
void breakTask(GF_Task* tsk) { DWORD result; char s[512]; // команда снимаемому потоку на снятие tsk->putState(tsBreak,True); // увеличиваем его относительный приоритет // до максимально возможного SetThreadPriority(tsk->TaskHnd95, THREAD_PRIORITY_TIME_CRITICAL) int cnt = 1000/20; // ждем завершения потока в течение примерно 1 с while(cnt-) { // стимулируем Windows к передаче кванта времени // другим потокам PeekMessage(&_Msg,0,0,0,PM_NOREMOVE); // ждем завершения потока result = WaitForSingleObject(tsk->TaskHnd95,20); // если все-таки не дождались, // то выходим из цикла ожидания if(result != WAIT_TIMEOUT) break; } ...и т. д. }В документации по SDK утверждается, что для передачи кванта времени другим потокам можно вызвать функцию Sleep с параметром 0 (Sleep(0)). Я попробовал заменить вызов PeekMessage на Sleep(0), но это не сработало (что меня, впрочем, совершенно не удивило). Поэтому для стимуляции Windows к передаче кванта времени советую синхронизировать потоки, используя функцию PeekMessage.
А вот выполнение приведенного в листинге 4 кода приводило к "зависанию" всей программы. Правда, "зависание" носило спонтанный характер. Оно происходило особенно часто, если производилось периодическое обращение к разделяемому коду (3-4 раза в секунду, Pentium-100, ОЗУ 16 Mбайт) при непрерывном выводе графической информации на экран.
"Зависание" периодически происходило на строчке WaitForSingleObject при ожидании освобождения объекта (который почему-то был все время в занятом состоянии).
Для решения этой проблемы я использовал следующий ход: было заменено значение INFINITE на конечное число миллисекунд, но, как вы понимаете, такой доступ к полю grstate с возвращаемым значением WAIT_TIMEOUT некорректен, по сути такой доступ к объекту - это отсутствие синхронизации, ведь объект не был освобожден "добровольно", а мы к нему получили доступ. Поэтому я и здесь применил "петлю безопасности", т. е. цикл while с комбинацией вызовов функций WaitForSingleObject и PeekMessage. В результате текст был переписан следующим образом:
// [ getgrstate ] // функция доступа к флагу состояния grstateword GF_Diagram::getgrstate()
{
word st;
// ожидаем освобождения поля состояния grstate
while(WaitForSingleObject(SimFlag,100)==WAIT_TIMEOUT)
PeekMessage(&_Msg,0,0,0,PM_NOREMOVE);
st = grstate;
ReleaseMutex(SimFlag);
return st;
}
Корректное изменение переменных
Одним из положительных свойств вытесняющей многозадачности является то, что она предоставляет механизм корректного изменения данных. Ведь если несколько потоков имеют доступ к одной переменной, то нет никакой гарантии, что в процессе изменения значения этой переменной одним потоком не произойдет переключение на другой поток, который может читать ее значение. Другой поток в этом случае получит неверную информацию. Для предотвращения таких конфликтов в Windows 95 введен ряд функций, позволяющих корректно изменять переменные, доступ к которым имеют несколько потоков. Перечислим функции, предохраняющие от переключения во время изменения значения переменной:- LONG InterlockedIncrement (LPLONG lpAddend) - увеличивает значение по адресу lpAddend на единицу;
- LONG InterlockedDecrement (LPLONG lpAddend) - уменьшает значение по адресу lpAddend на единицу;
- LONG InterlockedExchange (LPLONG Target, LONG Value) - заменяет значение, находящееся по адресу Target, на значение, переданное в параметре Value;
- LONG InterlockedExchangeAdd (PLONG Addend, LONG Increment) - прибавляет к значению по адресу Addend значение Increment;
- PVOID InterlockedCompareExchange (PVOID *Destination, PVOID Exchange, PVOID Comperand) - сравнивает значение по адресу Destination со значением, переданным в параметре Comperand, и если эти значения равны, то по адресу Destination помещается значение, переданное в параметре Exchange.
{
long Val;
....
Val++; // неправильно
InterlockedIncrement(&Val); // правильно
...
}
Все попытки сделать что-то, требующее моментальной реакции на внешние события, в среде Windows 3.x приводили к более чем скромным результатам, так как подобные программы приобретали относительно стандартизованный, но неповоротливый графический интерфейс, и больше ничего. Windows 95 в принципе позволяет разрабатывать критичное ко времени реакции ПО типа систем управления. Нужно только умело обойти подводные камни. В этом, надеюсь, и поможет настоящая статья.
Листинг 1. Ограничение доступа к массиву с использованием критических разделов
// Массив значений. int mas[1000]; // Семафор, регулирующий доступ к критическому разделу. CRITICAL_SECTION CritSec; { ... // Инициализируем семафор критического раздела. InitializeCriticalSection(&CritSect); ... // Текст программы. // Удаляем объект критического раздела. DeleteCriticalSection(&CritSec); } // Первый поток: запись в массив данных. DWORD thread1(LPVOID par) { // Запись значения в массив. // Запрос на вход в критический раздел. EnterCriticalSection(&CritSec); // Выполнение кода в критическом разделе. for(int i = 0;i<1000;i++) { mas[i] = i; } // Выход из критического раздела: // освобождаем критический раздел для доступа // к нему других задач. LeaveCriticalSection(&CritSec); return 0; } // Второй поток: считывание данных из массива. DWORD thread2(LPVOID par) { // Считывание значений из массива. int j; // Запрос на вход в критический раздел. EnterCriticalSection(&CritSec); // Выполнение кода в критическом разделе. for(int i = 0;i<1000;i++) { j = mas[i]; } // Выход из критического раздела: // освобождаем критический раздел для доступа // к нему других задач. LeaveCriticalSection(&CritSec); return 0; }Листинг 2. Ограничение доступа к массиву с использованием исключающих семафоров
// Массив значений. int mas[1000]; // Объект, регулирующий доступ к разделяемому коду. HANDLE CritMutex; { ... // Инициализируем семафор разделяемого кода. CritMutex = SectCreateMutex(NULL,FALSE,NULL); ... // Текст программы. // Закрываем объект доступа к разделяемому коду. CloseHandle(CritMutex); } // Первый поток: запись в массив данных. DWORD thread1(LPVOID par) { // Запись значений в массив. // Запрос на вход в защищенный раздел. DWORD dw = WaitForSingleObject(CritMutex,INFINITE); if(dw == WAIT_OBJECT_0) { // Если объект освобожден корректно, то // выполнение кода в защищенном разделе. for(int i = 0;i<1000;i++) { mas[i] = i; } // Выход из защищенного раздела: // освобождаем объект для доступа // к защищенному разделу других задач. ReleaseMutex(CritMutex); } return 0; } // Второй поток: считывание данных из массива. DWORD thread2(LPVOID par) { // Считывание значений из массива. int j; // Запрос на вход в защищенный раздел. DWORD dw = WaitForSingleObject(CritMutex,INFINITE); if(dw == WAIT_OBJECT_0) { // Если объект освобожден корректно, то // выполнение кода в защищенном разделе. for(int i = 0;i<1000;i++) { j = mas[i]; } // Выход из защищенного раздела: // освобождаем объект для доступа // к защищенному разделу других задач. ReleaseMutex(CritMutex); } return 0; }Листинг 3. Снятие графического потока
void breakTask(GF_Task* tsk) { DWORD result; char s[512]; // Команда снимаемой задаче на снятие. tsk->putState(tsBreak,True); // Ждем завершения потока в течение 1 с. WaitForSingleObject(tsk->TaskHnd95,1000); // // Анализ ответа. // if(result == WAIT_OBJECT_0) // Ok - поток завершен успешно. { result = cmOK; goto _L_EndbreakTask; } else if(result == WAIT_TIMEOUT) // Поток не отвечает. { // Подготавливаем строку запроса. sprintf(s,, "Поток # %i не отвечает...\nОбъект %s\n Сделайте выбор: \n\ `Да` - повторить команду на снятие \n\ `Нет` - снять поток принудительно \n\ `Отменить` - не снимать поток" TaskCollection->indexOf(tsk)+1, tsk->getName()); } // Вывод запроса на экран. result = MsgBox(s, msg|msgSound); switch(result) // Анализ ответа. { case cmNo: // Принудительное снятие потока. tsk->putState(tsCrash,True); // Выставляем флаг tsk->endTask(); // Заключительные операции TerminateThread(tsk->TaskHnd95,0); // Снимаем поток goto _L_EndbreakTask; case cmCancel: // Отмена снятия потока. goto _L_EndbreakTask; } } else if(WAIT_FAILED) // Произошла ошибка доступа к объекту. { // Принудительное снятие потока. SIL(); // Звуковой сигнал tsk->endTask(); // Заключительные операции TerminateThread(tsk->TaskHnd95,0); SIL(); // Звуковой сигнал result = cmNo; goto _L_EndbreakTask; } } _L_EndbreakTask: CloseHandle(tsk->TaskHnd95); tsk->TaskHnd95 = 0; tsk->putState(tsWorkTask,False); // Снимаем флаги return result; } // Код снимаемого потока примерно следующий: DWORD thread1(LPVOID par) { while( (state & tsBreak) == 0) { // Пока флаг tsBreak не выставлен, выполняем поток. draw() // Что-то выводим на экран. } return 0; }Листинг 4. Пример, приводящий к "зависанию" всей программы
TCollection* GF_Diagram::update(TCollection* tc, ccIndex sUpd, ccIndex eUpd, ccReg index) { NodeGraph* _ng = getNode(index);// находим узел if(_ng==0) return 0; // // Применение исключающего семафора объясняется тем, // что в данном месте производится изменение флага // состояния grstate. Другой поток, обратившись к этому // флагу, получит его неправильное значение. // Мало того, он занесет туда другое значение, // которое потеряется при выходе из функции update. // Следовательно, к моменту изменения флага мы должны // ограничить к нему доступ. // // Ожидаем освобождения объекта // (ждем разрешения на доступ - время бесконечно) WaitForSingleObject(SimFlag,INFINITE); // Доступ разрешен. // Далее идет рабочий код, рассмотрение которого // можно опустить. // Сохраняем состояние. word st = grstate; grstate |= fUpdate; // Выставляем флаг обновления данных. if(_ng->grstate & fExchange) grstate |= fExchange; else grstate &=~ fExchange; if(_ng->grstate & fLine) grstate |= fLine; else grstate &=~ fLine; setStartEndPoint(_ng->PointCollection,sUpd,eUpd); // Переприсваиваем. Field->PCold = _ng->PointCollection; // Заносим новую кол. в узел _ng->PointCollection = PointCollection = tc; // Заносим данные в узел. Field->ClrGraf = _ng->Color; if((state & sfVisible) && ((state & sfHidden) == 0)) Field->drawView();// отрисовка новых значений StartPoint = 0; EndPoint = -1; // Восстанавливаем старое значение флагов. grstate = st; // Освобождаем объект mutex (разрешаем доступ другим задачам). ReleaseMutex(SimFlag); return Field->PCold; } //==[ getgrstate ] // // функция доступа к флагу состояния grstate // word GF_Diagram::getgrstate() { word st; // ожидаем освобождения поля состояния grstate WaitForSingleObject(SimFlag,INFINITE); st = grstate; ReleaseMutex(SimFlag); return st; }