Лекция 5. Обработка сообщений Windows и программирование многопоточных приложений
Якунин М.С

[На главную страницу сайта] [Другие темы раздела] [Содержание курса] [Лабораторная работа к лекции] [Скачать задачи]

 

Методические рекомендации

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

План

  1. Понятие процесса и потока
  2. Обработка сообщений в однопоточной программе
  3. Программирование многопоточных приложений
  4. Критические секции

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

Понятие процесса и потока

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

Потоком называется код, исполняемый внутри процесса.

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

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

Потоками управляет операционная система, и у каждого потока есть свой собственный стек.

Обработка сообщений в однопоточной программе

До сих пор все наши программы были однопоточными (single-threaded), т. е. у кода был лишь один поток исполнения. Казалось бы, при появлении сообщения каким-то чудом вызывается ваш обработчик, но в Windows все происходит совсем не так. Глубоко в недрах MFC-кода, компонуемого с вашей программой, спрятаны такие инструкции:

MSG message;

while ( GetMessage (&message, NULL, 0, 0))

{

::TranslateMessage(&inessage);

::DispatchMessage(&iressage);

}

Windows определяет, какие сообщения принадлежат программе, а функция GetMessage возвращает управление, как только появляется сообщение для обработки. Если сообщений нет, программа приостанавливается, и выполняются другие приложения. Когда сообщение в конце концов поступает, программа “пробуждается” Функция TranslateMessage транслирует сообщения WM_KEYDOWN в сообщения WM_CHAR, содержащие ASCII-символы, а функция DispatcbMessage передает управление (через оконный класс) коду выборки сообщений MFC, который вызывает Вашу функцию на основе таблицы обработчиков сообщений. Когда обработчик завершается, он возвращает управление MFC-коду, что в итоге вызывает возврат из DispatcbMessage.

Передача управления

Что произойдет, если одна из функций-обработчиков окажется “жадной” и израсходует 10 секунд процессорного времени? Во времена 16-разрядной системы компьютер просто завис бы на это время. Разве что можно было бы перемещать курсор мыши, да исполнялись бы какие-то задачи, управляемые прерываниями. В Win32 многозадачность организована куда лучше. Благодаря вытесняющей многозадачности другие приложения не зависнут — Windows, когда сочтет это нужным, просто приостановит выполнение “жадной” функции. Однако даже в Win32 ваша программа на эти 10 секунд была бы заблокирована. Она не смогла бы обрабатывать какие-либо сообщения, так как DispatcbMessage не возвращает управление до тех пор, пока его не возвратит злополучный обработчик.

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

MSG message;

if (::PeekMessage(&message, NULL, 0, 0, PM_REMOVE))

{

::TranslateMessage(&message);

::DispatchMessage(&fflessage);

}

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

Таймеры

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

Для работы с таймерами необходимо вызывать функцию CWnd::SetTimer с параметром — интервал времени, после чего с помощью ClassWizard нужно определить обработчик сообщения WM_TIMER. После запуска таймера с заданным интервалом в миллисекундах сообщения WM_TIMER постоянно посылаются окну приложения до тех пор, пока не будет вызвана CWnd::KillTiiner или уничтожено окно. При необходимости можно задействовать несколько таймеров, каждый из которых идентифицируется целым числом. Поскольку Windows не является операционной системой реального времени, интервал значительно менее 100 миллисекунд приводит к потере точности.

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

Обработка в периоды простоя

До появления многопоточности разработчики программ для Windows использовали периоды простоя (idle) для выполнения фоновых задач, например, разбивки документа на страницы. Теперь обработка во время простоя потеряла былое значение, но ей по-прежнему находится применение. Каркас приложений вызывает виртуальную функцию-член Onldle класса CWinApp, и Вы можете переопределить ее для выполнения фоновых вычислений Onldie вызывается из цикла обработки сообщений MFC-библиотеки, который на самом деле сложнее, чем приведенная выше простая последовательность GetMessage/TramlateMessage/Dispatch-Message. Обычно функция Onldle вызывается после того, как очередь сообщений у приложения опустеет, причем только один раз. Если Вы переопределяете ее, вызывается ваш код, но при отсутствии непрерывного потока сообщений это не происходит постоянно. Onldle в базовом классе обновляет кнопки панели инструментов и индикаторы состояния, а также “чистит” указатели на временные объекты. Имеет смысл переопределять эту функцию, если Вы хотите обновлять состояние элементов пользовательского интерфейса Ну, а то, что она не вызывается в отсутствие сообщений, значения не имеет, ведь пользовательский интерфейс и не должен в этом случае изменяться.

Переопределяя функцию CWinApp::Onldle, не забудьте вызвать Onldle базового класса. Иначе не произойдет ни обновления кнопок на панели инструментов, ни удаления временных объектов.

Onldle вообще не вызывается, если пользователь работает в модальном диалоговом окне или выбирает что-то в меню. При необходимости фоновой обработки модальных диалоговых окон или меню придется написать обработчик сообщения WM_ENTERIDLE, но его надо добавить в класс окна-рамки, а не в класс “вид”. Причина в том, что владельцем диалоговых окон всегда является основное окно-рамка приложения, а не окно представления.

Программирование многопоточных приложений

Потоки в Windows бывают двух видов рабочие потоки (worker threads) и потоки пользовательского интерфейса (user-interface threads). MFC-библиотека поддерживает оба вида. У потока пользовательского интерфейса есть окна, а, значит, и свой цикл выборки сообщений, а у рабочего — нет. Рабочие потоки легче программировать, и они, как правило, полезнее.

Не забывайте, что даже в однопоточном приложении есть поток, который называется основным потоком (main thread). Здесь важно помнить, что приложение — это процесс, содержащий как минимум один поток.

Функция рабочего потока и запуск потока

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

Чтобы запустить поток (с функцией, скажем, ComputeThreadProc), программа делает вызов:

CWinThread* pThread = AfxBeginThread(ConiputeThreadProc, GetSafeHwndO, THREAD_PRIORITY_NORMAL).

Код функции потока выглядит так:

UINT ComputeThreadProc (LPVOID pParam)

{

// процесс обработки

return О;

}

Функция AfxBeginThread возвращает указатель на только что созданный объект “поток”. Этот указатель можно использовать для приостановки и возобновления исполнения потока (CWinThread::SuspendTbread и ResumeThread), но у объекта “поток” нет функции-члена для уничтожения потока. Второй параметр AfxBeginThread — 32-разрядное значение, передаваемое глобальной функции, а третий параметр представляет собой код приоритета потока. После запуска рабочего потока оба потока исполняются независимо друг от друга Windows распределяет время между ними (и потоками других процессов) в соответствии с их приоритетами.

Общение основного потока с рабочим

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

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

UINT ComputeThreadProc (LPVOID pParam)

{

g_nCount = 0;

while (g_nCo<jnt++ < 100)

{

// здесь выполняются какие-то вычисления

}

return 0;

}

Однако здесь есть одна проблема, которую можно обнаружить, лишь посмотрев на сгенерированный ассемблерный код. Значение g_nCount загружается в регистр, увеличивается там и переписывается обратно в g_nCount. Предположим, g_nCount равно 40 и Windows прерывает рабочий поток сразу же после того, как он загружает это значение в регистр. Теперь управление получает основной поток и присваивает g_nCount значение 100. При возобновлении рабочий поток увеличивает значение регистра и записывает обратно в g_nCount число 41, стирая предыдущее значение 100. И вот — цикл потока не завершается!

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

Однако, чтобы компилятор не хранил счетчик в регистре, можно объявить g_nCount как volatile.

Перепишем процедуру потока следующим образом:

UINT ConiputeThreadProc(LPVOID pParam)

{

g_nCount = 0;

while (g_nCount < 100)

{

// здесь выполняются какие-то вычисления

: :InterlockeclIncrenient((long*)&g_nCount);

}

return 0;

}

Функция Interlockedlncrement предотвращает обращение к переменной со стороны другого потока во время ее изменения. Теперь основной поток сможет завершить рабочий.

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

Общение рабочего потока с основным

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

А как рабочий поток получает описатель? Для этого предназначен 32-разрядный параметр уже рассмотренной функции потока. Вы просто передаете описатель в вызове AfxBeginTbread.

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

Применение глобальной переменной — грубое, но эффективное средство коммуникации между потоками. Теперь попробуем что-нибудь более совершенное.

Событие (event) — один из типов объектов ядра (процессы и потоки тоже относятся к объектам ядра), предоставляемых Windows для синхронизации потоков. В пределах конкретного процесса событие определяется уникальным 32 - разрядным описателем и для совместного использования несколькими процессами может идентифицироваться по имени (или описатель события может дублироваться в другом процессе). Объект “событие” находится либо в свободном состоянии (“истина”, signaled state), либо в занятом (“ложь”, unsignaled state).

Существует два типа событий: с ручным сбросом (manual reset) и автоматическим сбросом (autoreset).

Рассмотрим события с автосбросом, потому что они идеально подходят для синхронизации двух процессов.

Вернемся к примеру с рабочим потоком. Мы хотим, чтобы основной поток (поток пользовательского интерфейса) сигнализировал рабочему потоку, когда начинать и прекращать работу. Поэтому нам понадобятся события “запустить" (“start”) и “уничтожить” (“kill”), MFC предоставляет для этого удобный класс CEvent, производный от CsyncObject. Конструктор по умолчанию создает объект Win32 “событие” с автосбросом в занятом состоянии. Если определить события как глобальные объекты, любой поток сможет легко обратиться к ним. Когда основному потоку надо запустить или уничтожить рабочий поток, он переводит соответствующее событие в свободное состояние вызовом CEvent::SetEvent.

Теперь рабочий поток должен наблюдать за состоянием двух событий и должным образом реагировать, когда одно из них переходит в свободное состояние. Для этого в MFC есть класс CSingleLock, но проще использовать Win32 - функцию WaitForSingleObject. Она приостанавливает поток до тех пор, пока заданный объект не освободится. В приостановленном состоянии поток не занимает процессорного времени. Первый параметр функции WaitForSingleObject — описатель события. В качестве значения этого параметра можно использовать непосредственно объект CEvent, потому что этот объект наследует от класса CSyncObject оператор HANDLE, который возвращает описатель события, хранящийся в открытой переменной-члене. Второй параметр определяет период задержки. Если его значение INFINITE, освобождения объекта “событие” функция будет ждать вечно, а когда оно равно 0, функция возвращает управление немедленно с результатом WAIT_OBJЕСТ_0, если событие было в свободном состоянии.

Блокировка потоков

Пример блокировки потока — первый вызов WaitForSingleObject в функции ComputeThreadProc. Поток просто прекращает выполнение до тех пор, пока событие не перейдет в свободное состояние. Есть и много других способов заблокировать поток. Можно, например, вызвать Win32-функцию Sleep, чтобы “усыпить” поток на 500 миллисекунд. Блокировку потока вызывают и функции, которые обращаются к устройствам вроде коммуникационных портов или дисков. Во времена Win 16 эти функции захватывали процессор до тех пор, пока их работа не заканчивалась, а в Win32 они позволяют выполняться другим процессам и потокам.

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

Будьте осторожны в рабочем потоке с вызовами, которые могут заблокировать его на неограниченное время. Если же вызов блокирует поток навечно, то поток все равно завершится при завершении основного потока процесса, но тогда не исключены утечки памяти. Можно также вызвать из основного потока функцию Win32 TerminateThread, но проблема утечек памяти остается и в этом случае.

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

Помните проблемы с доступом к глобальной переменной g_nCount? Если необходимо, чтобы несколько потоков совместно использовали глобальные данные и нуждаетесь в большей гибкости, чем та, которую предоставляют простые операторы типа Interlockedlncrement, то подойдет такое средство синхронизации, как критические секции (critical sections).

События хороши для “сигнализации”, а критические секции (секции кода, требующие монопольного доступа к разделяемым данным) удобны для управления доступом к данным.

MFC предоставляет класс CCriticalSection — “обертку” описателя критической секции Windows. Его конструктор вызывает функцию InitializeCriticalSection, функции-члены Lock и Unlock вызывают функции EnterCriticalSection и LeaveCriticalSection соответственно, а деструктор вызывает DeleteCriticalSection. Вот как можно использовать этот класс для защиты глобальных данных:

CCriticalSection g_cs;

int g_nCount;

void func()

{

g_cs.Lock();

g_nCount++;

g cs.Unlock();

}

Допустим программа отслеживает показания времени как часы, минуты и секунды, а каждое из этих значений хранится в отдельной целочисленной переменной. Теперь представим, что значения времени совместно используются двумя потоками. Поток А изменяет значение времени и прерывается потоком Б после обновления часов, но до обновления минут и секунд. Результат: поток Б получает недостоверные показания времени.

Если Вы пишете для данного формата времени класс C++, то сможете легко управлять доступом к данным, сделав элементы данных закрытыми и предусмотрев открытые функции-члены. Именно таков класс CHMS приведенный ниже. Заметьте в этом классе есть переменная-член типа CcnticalSection. Таким образом, с каждым объектом CHMS связан объект “критическая секция”.

Обратите внимание, что другие функции-члены вызывают функции-члены Lock и Unlock. Если поток A исполняется в середине SetTime, поток Б будет блокирован вызовом EnterCnticalSection в GetTotalSecs до тех пор, пока поток А не вызовет LeaveCnticalSection. Функция IncrementSecs вызывает SetTime, что означает наличие вложенных критических секций. Это допустимо, так как Windows отслеживает уровни их вложенности.

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

HMS.H

include StdAfx h

class CHMS

{

private:

int m_nHr, m_nMn, m_nSc;

CCnticalSection m_cs;

public:

CHMSQ(): m_nHr(0), m_nMn(0), m_nSc(0) {}

~CHMSO {}

void SetTinie(int nSecs)

{

m_cs.Lock();

m_nSc = nSecs % 60;

m_nMn = (nSecs / 60) % 60;

m_nНr = nSecs / 3600;

m_cs.Unlock();

}

int GetTotalSecs()

{

int nTotalSecs;

m_cs.Lock();

nTotalSecs = m_nHr * 3600 + m_nMn * 60 + m_nSc;

m_cs.Unlock();

return nTotalSecs ;

}

void IncrementSecs()

{

m_cs.Lock();

SetTime (GetTotalSecs() +1);

m_cs.Unlock();

}

};

Потоки пользовательского интерфейса

MFC-библиотека обеспечивает хорошую поддержку потоков пользовательского интерфейса. Вы создаете класс, производный от CWinTbread, и вызываете переопределенную версию AfxBeginThread для запуска потока. У этого производного класса есть своя функция InitInstance и, что самое важное, свой цикл выборки сообщений, — это позволяет конструировать связанные с ним окна и обработчики сообщений.

Зачем нужен поток пользовательского интерфейса? Если Вы хотите работать с несколькими окнами верхнего уровня, их можно создать и управлять ими из основного потока. Но, предположим, Вы разрешаете пользователю запускать несколько экземпляров вашего приложения, однако хотите, чтобы все они разделяли общую память. Можно сделать так, чтобы в одном процессе исполнялось несколько потоков пользовательского интерфейса, а пользователи думали, что выполняются отдельные процессы. Именно так работает Windows Explorer. Запустите утилиту SPY++ и убедитесь сами.

Контрольные вопросы

  1. Что называется процессом?
  2. Что называется потоком?
  3. Какие два вида потоков Вы знаете, и чем они отличаются?
  4. Что называется основным потоком?
  5. Какие методы взаимодействия основного потока и рабочего вы знаете?
  6. Какие методы взаимодействия рабочего потока и основного вы знаете?
  7. Что называется событием?
  8. Что такое критические секции и когда они применяются?
  9. [На главную страницу сайта] [Другие темы раздела] [Содержание курса] [Лабораторная работа к лекции] [Скачать задачи]