Рихтер Дж., Назар К. - Windows via C C++. Программирование на языке Visual C++ - 2009
.pdfГ Л А В А 1 2
Волокна
Майкрософт добавила в Windows поддержку волокон (fibers), чтобы упростить портирование (перенос) существующих серверных приложений из UNIX в Windows. С точки зрения терминологии, принятой в Windows, такие серверные приложения следует считать однопоточными, но способными обслуживать множество клиентов. Иначе говоря, разработчики UNIX-приложений создали свою библиотеку для организации многопоточности и с ее помощью эмулируют истинные потоки. Она создает набор стеков, сохраняет определенные регистры процессора и переключает контексты при обслуживании клиентских запросов.
Разумеется, чтобы добиться большей производительности от таких UNIXприложений, их следует перепроектировать, заменив библиотеку, эмулирующую потоки, на настоящие потоки, используемые в Windows. Но переработка может занять несколько месяцев, и поэтому компании сначала просто переносят существующий UNIX-код в Windows — это позволяет быстро предложить новый продукт на рынке Windows-приложений.
Но при переносе UNIX-программ в Windows могут возникнуть проблемы. В частности, механизм управления стеком потока в Windows куда сложнее простого выделения памяти. В Windows стеки начинают работать, располагая сравнительно малым объемом физической памяти, и растут по мере необходимости (об этом я расскажу в разделе «Стек потока» главы 16). Перенос усложняется и наличием механизма структурной обработки исключений (см. главы 23,24 и 25).
Стремясь помочь быстрее (и с меньшим числом ошибок) переносить UNIXкод в Windows, Майкрософтдобавила в операционную систему механизм поддержки волокон. В этой главе мы рассмотрим концепцию волокон и функции, предназначенные для операций с ними. Кроме того, я покажу, как эффективнее работать с такими функциями. Но, конечно, при разработке новых приложений следует использовать настоящие потоки.
Глава 12. Волокна.docx 423
Работа с волокнами
Во-первых, потоки и Windows реализуются на уровне ядра операционной системы, которое отлично осведомлено об их существовании и «коммутирует» их в соответствии с созданным Майкрософт алгоритмом. В то же время волокна реализованы на уровне кода пользовательского режима, ядро ничего не знает о них, и процессорное время распределяется между волокнами по алгоритму, определяемому нами. А раз так, то о вытеснении волокон говорить не приходится — по крайней мере, когда дело касается ядра.
Второе, о чем следует помнить, — в потоке может быть одно или несколько волокон. Для ядра ноток — все то, что можно вытеснить и что выполняет код. Единовременно поток будет выполнять код лишь одного волокна — какого, решать вам (соответствующие концепции я поясню позже). Приступая к работе с волокнами, прежде всего преобразуйте существующий поток в волокно. Это де-
лает функция ConvertThreadToFiber:
PV0ID ConvertThreadToFiber(PVOID pvParam);
Она создает в памяти контекст волокна (размером около 200 байтов). В него входят следующие элементы:
■определенное программистом значение; оно получает значение параметра pvParam, передаваемого в ConvertThreadToFiber;
■заголовок цепочки структурной обработки исключении;
■начальный и конечный адреса стека волокна; при преобразовании потока в волокно оп служит и стеком потока;
■регистры процессора, включая указатели стека и команд.
По умолчанию, на компьютерах с архитектурой х86 сведения о состоянии вы-
числении с плавающей точкой не хранятся в регистрах процессора, содержимое которых хранит каждое волокно. В результате при выполнении волокном операций с плавающей точкой возможно повреждение памяти. Чтобы избежать этого, следует вызывать новую функцию ConvertThreadToFiber, которая позволяет пере-
давать в параметре dwFlags флаг FIBER_FLAG_FLOAT_SWITCH:
PV0ID ConvertThreadToFiberEx(
PV0ID pvParam,
DWORD dwFlags);
Создав и инициализировав контекст волокна, вы сопоставляете его адрес с потоком, преобразованным в волокно, и теперь оно выполняется в этом потоке. ConvertThreadToFiber(Ex) возвращает адрес, но которому расположен контекст волокна. Этот адрес еще понадобится вам, но ни считывать, ни записывать по нему напрямую ни в коем случае нельзя — с содержимым этой структуры работают только функции, управляющие волокнами. При вызове ExitThread завершаются и волокно, и ноток.
424 Часть II. Приступаем к работе
Нет смысла преобразовывать поток в волокно, если вы не собираетесь создавать дополнительные волокна в том же потоке. Чтобы создать другое волокно, поток (выполняющий в данный момент волокно), вызывает функцию CreateFiber:
PVOID CreateFiber(
DW0RD dwStackSize,
PFIBER_START_ROUTINE pfnStartAddress,
PVOID pvParam);
Сначала она пытается создать новый стек, размер которого задан в параметре dwStackSize. Обычно передают 0, и тогда максимальный размер стека ограничивается 1 Мб, а изначально ему передается две страницы памяти. Если вы укажете ненулевое значение, то для стека будет зарезервирован и передан именно такой объем памяти. При использовании множества волокон можно сэкономить память, необходимую для хранения их стеков. Для этого вместо CreateFiber воспользуйтесь следующей функцией:
PVOID CreateFiberEx(
SIZE_T dwStackCommitSize,
SIZE_T dwStackReserveSize,
DWORD dwFlags,
PFIBER_START_ROUTINE pStartAddress,
PVOID pvParam);
Параметр dwStackCommitSize устанавливает размер памяти, изначально выделяемой для стека; параметр dwStackReserveSize позволяет зарезервировать для стека некоторое количество виртуальной памяти; параметр dwFlags принимает то же значение FIBER_FLAG_FLOAT_SWITCH, что и ConvertThreadToEberEx, до-
бавляя состояние вычислений с плавающей точкой к контексту волокна. Остальные параметры — те же, что и для функции CreateFiber.
Функция CreateFtber(Ex) создает и инициализирует новую структуру, представляющую контекст исполнения волокна. При этом пользовательское значение устанавливается по значению параметрариРанш, сохраняются начальный и конечный адреса памяти нового стека, а также адрес функции волокна (переданный в параметре pfnStartAddress).
Аргумент pfnStartAddress задает адрес функции волокна, которую вам придется реализовать самостоятельно. Эта функция должна соответствовать следующему прототипу:
VOID WINAPI FiberFunc(PVOID pvParam);
Когда волокно получает процессорное время в первый раз, эта функция вызывается и получает значение pvParam, исходно переданное функции CreateFiber. В этой функции вы можете делать что угодно, но в прототипе тип возвращаемого ею значения определен как VOID — не потому, что это значение бессмысленно, а просто потому, что функция волокна не должна
Глава 12. Волокна.docx 425
завершаться, пока существует волокно! Как только функция волокна завершится, поток и все созданные в нем волокна тут же будут уничтожены.
Подобно ConvertThreadToFiber(Ex), функция CreateFiber(Ex) возвращает ад-
рес контекста исполнения волокна. Но, в отличие от ConvertThreadToFiber(Ex), исполнение созданного функцией CreateBber(Ex) волокна не начинается, пока исполняется текущее волокно. Дело в том, что исполняться может только одно волокно потока одновременно. Чтобы запустить новое волокно, вызовите функцию
SwitchToFiber.
VOID SwitchToFiber(PVOID pvFiberExecutionContext);
Эта функция принимает единственный параметр (pvFiberExecutionContext) — адрес контекста волокна, полученный в предшествующем вызове ConvertThreadToFiber(Ex) или CreateFiber(Ex). По этому адресу она определяет, какому волокну предоставить процессорное время. SwitchToFiber осуществляет такие операции:
1.Сохраняет в контексте выполняемого в данный момент волокна ряд текущих регистров процессора, включая указатели команд и стека.
2.Загружает в регистры процессора значения, ранее сохраненные в контексте волокна, подлежащего выполнению. В их число входит указатель стека, и поэтому при переключении на другое волокно используется именно его стек.
3.Связывает контекст волокна с потоком, и тот выполняет указанное волокно.
4.Восстанавливает указатель команд. Поток (волокно) продолжает выполнение с того места, на каком волокно было прервано в последний раз.
Применение SwitchToFuber — единственный способ выделить волокну процессорное время. Поскольку ваш код должен явно вызывать эту функцию в нужные моменты, вы полностью управляете распределением процессорного времени для волокон. Помните: такой вид планирования не имеет ничего общего с планированием потоков. Поток, в рамках которого выполняются волокна, всегда может быть вытеснен операционной системой. Когда поток получает процессорное время, выполняется только выбранное волокно, и никакое другое не получит управление, нока вы сами не вызовете SwitchToFiber.
Для уничтожения волокна предназначена функция DeieteFiber:
VOID DeleteFiber(PVOIO pvFiberExecutionContext);
Она удаляет волокно, чей адрес контекста определяется параметром pvFiberExecutionContext, освобождает память, занятую стеком волокна, и уничтожает его контекст. Но, если вы передаете адрес волокна, связанного в данный момент с потоком, DeleteFiber сама вызывает ExitThread — в результате поток и все созданные в нем волокна «погибают».
DeleteFiber обычно вызывается волокном, чтобы удалить другое волокно. Стек удаляемого волокна уничтожается, а его контекст освобождается.
426 Часть II. Приступаем к работе
И здесь обратите внимание на разницу между волокнами и потоками: потоки, как правило, уничтожают себя сами, обращаясь к ExitThread. Использование с этой целью TerminateThread считается плохим тоном — ведь тогда система не уничтожает стек потока. Так вот, способность волокна корректно уничтожать другие волокна нам еще пригодится — как именно, я расскажу, когда мы дойдем до про- граммы-примера.
После удаления всех волокон также можно удалить их состояние из потока,
исходного вызвавшего ConvertThreadToFiber(Ex), с помощью ConvertFiberToThread, — так удастся полностью освободить память, использованную для преобразования потока в волокно.
Для хранения информации, специфичной для волокна, используется локальная память волокна (Fiber Local Storage, FLS) и функции, предназначенные для работы с ней. Эти функции выполняют те же операции, что и аналогичные функции локальной памяти потока (см. главу 6). Сначала следует вызвать FlsAlloc, чтобы выделить слот FLS, доступный всем волокнам, работающим в данном процессе. Эта функция принимает единственный параметр: указатель на функцию обратного вызова, исполняемую при уничтожении волокна либо освобождении FLS-слота вызовом FlsFree. Записать специфичные для волокна данные в FLS-слот можно вызовом функции FlsSetValue, а прочитать их оттуда — вызовом FlsGetValue. Чтобы узнать, находитесь ли вы в контексте исполнения некоторого волокна, достаточно проверить булево значение, возвращаемое функцией IsThreadAFiber.
Для удобства предусмотрено еще две функции, управляющие волокнами. В каждый момент потоком выполняется лишь одно волокно, и операционная система всегда знает, какое волокно связано сейчас с потоком. Чтобы получить адрес контекста текущего волокна, вызовите GetCurrentFiber.
PVOID GetCurrentFiber();
Другая полезная функция — GetHberData:
PV0ID GetFiberData();
Как я уже говорил, контекст каждого волокна содержит определяемое программистом значение. Оно инициализируется значением параметра pvParam, пе-
редаваемого функции ConvertThreadToFiber(Ex) или CreateFiber(Ex), и служит ар-
гументом функции волокна. GetFiberData просто «заглядывает» в контекст текущего волокна и возвращает хранящееся там значение.
Обе функции — GetCurrentEber и GetFiberData — работают очень быстро и обычно реализуются компилятором как встраиваемые (т. е. вместо вызовов этих функций он подставляет их код).
Программа-пример Counter
Эта программа, «12 Counter.exe», демонстрирует применение волокон для реализации фоновой обработки. Запустив ее, вы увидите диалоговое окно,
Глава 12. Волокна.docx 427
показанное ниже. (Настоятельно советую запустить программу Counter; тогда вам будет легче понять, что происходит в пей и как она себя ведет.)
Рис. 12-1. Окно программы Counter
Считайте эту программу сверхминиатюрной электронной таблицей, состоящей всего из двух ячеек. В первую из них можно записывать — она реализована как поле, расположенное за меткой Count To. Вторая ячейка доступна только для чтения и реализована как статический элемент управления, размещенный за меткой Answer. Изменив число в поле, вы заставите программу пересчитать значение в ячейке Answer. В этом простом примере пересчет заключается в том, что счетчик, начальное значение которого равно 0, постепенно увеличивается до максимума, заданного в ячейке Count To. Для наглядности статический элемент управления, расположенный в нижней части диалогового окна, показывает, какое из волокон — пользовательского интерфейса или расчетное — выполняется в данный момент.
Чтобы протестировать программу, введите в поле число 5 — строка Currently Running Fiber будет заменена строкой Recalculation, а значение в поле Answer постепенно возрастет с 0 до 5. Когда пересчет закончится, текущим волокном вновь станет интерфейсное, а поток заснет. Теперь введите число 50 и вновь понаблюдайте за пересчетом — на этот раз, перемещая окно по экрану. При этом вы заметите, что расчетное волокно вытесняется, а интерфейсное вновь получает процессорное время, благодаря чему программа продолжает реагировать на действия пользователя. Оставьте окно в покое, и вы увидите, что расчетное волокно снова получило управление и возобновило работу с того значения, на котором было прервано.
Остается проверить лишь одно. Давайте изменим число в поле ввода в момент пересчета. Заметьте: интерфейс отреагировал на ваши действия, но после ввода данных пересчет начинается заново. Таким образом, программа ведет себя как настоящая электронная таблица.
Обратите внимание и на то, что в программе не задействованы ни критические секции, ни другие объекты, синхронизирующие потоки, — все сделано на основе двух волокон в одном потоке.
Теперь обсудим внутреннюю реализацию программы Counter. Когда первичный поток процесса приступает к выполнению _tWinMain, вызывается функция ConvertThreadToFiber, преобразующая поток в волокно, которое впоследствии позволит нам создать другое волокно. Затем мы создаем немодальное диалоговое окно, выступающее в роли главного окна программы. Далее инициализируем переменную — индикатор состояния фоновой обработки (background processing state, BPS). Она реализована как элемент bps в
428 Часть II. Приступаем к работе
глобальной переменной g_FiberInfo. Ее возможные состояния описываются в следующей таблице.
Табл. 12-1. Варианты состояния программы Counter
Состояние |
Описание |
BPS_DONE |
Пересчет завершен, пользователь ничего не изменял, новый пересчет не |
|
нужен |
BPS_STARTOVER |
Пользователь внес изменения, требуется пересчет с самого начала |
BPS_CONTFNUE |
Пересчет еще продолжается, пользователь ничего не изменял, пересчет |
|
заново не нужен |
Индикатор bps проверяется внутри цикла обработки сообщений потока, который здесь сложнее обычного. Вот что делает этот цикл.
■Если поступает оконное сообщение (активен пользовательский интерфейс), обрабатываем именно его. Своевременная обработка действий пользователя всегда приоритетнее пересчета.
■Если пользовательский интерфейс простаивает, проверяем, не нужен ли пересчет (т. е. не присвоено ли переменной bps значение BPS_STARTOVER или
BPS_CONTINUE).
■Если вычисления не нужны (BPS_DONE), приостанавливаем поток, вызывая WaitMessage, — только событие, связанное с пользовательским интерфейсом, может потребовать пересчета.
■Если диалоговое окно закрывается, он останавливает расчетное волокно вызовом DeleteFiber, после чего вызывается ConvertFuberToThread для освобождения ресурсов интерфейсного волокна и возврата потока в обычный режим до завершения функции _WinMain.
Если интерфейсному волокну делать нечего, а пользователь только что изменил значение в поле ввода, начинаем вычисления заново (BPS_STARTOVER). Главное, о чем здесь надо помнить, — волокно, отвечающее за пересчет, может уже работать. Тогда это волокно следует удалить и создать новое, которое начнет все с начала. Чтобы уничтожить выполняющее пересчет волокно, интерфейсное вызывает DeleteFiber. Именно этим и удобны волокна. Удаление волокна, занятого пересчетом, — операция вполне допустимая, стек волокна и его контекст корректно уничтожаются. Если бы мы использовали потоки, а не волокна, интерфейсный поток не смог бы корректно уничтожить поток, занятый пересчетом, — нам пришлось бы задействовать какой-нибудь механизм межпоточного взаимодействия и ждать, пока поток пересчета не завершится сам. Зная, что волокна, отвечающего за пересчет, больше нет, мы вправе создать новое волокно для тех же целей, присвоив переменной bps значение BPS_CONTINUE.
Когда пользовательский интерфейс простаивает, а волокно пересчета чем-то занято, мы выделяем ему процессорное время, вызывая SwitchToFiber.
Глава 12. Волокна.docx 429
Последняя не вернет упранление, пока волокно пересчета тоже не обратится к SwilchToFiber, передан ей адрес контекста интерфейсного волокна.
FiberFunc является функцией волокна и содержит код, выполняемый волокном пересчета. Ей передается адрес глобальной структуры g_FiberInfo, и поэтому она знает описатель диалогового окна, адрес контекста интерфейсного волокна и текущее состояние индикатора фоновой обработки. Конечно, раз это глобальная переменная, то передавать eе адрес как параметр необязательно, но я решил показать, как в функцию волокна передаются параметры. Кроме того, передача адресов позволяет добиться того, чтобы код меньше зависел от конкретных переменных, — именно к этому и следует стремиться.
Первое, что делает функция волокна, — обновляет диалоговое окно, сообщая, что сейчас выполняется волокно пересчета. Далее функция получает значение, введенное в поле, и запускает цикл, считающий от 0 до указанного значения. Перед каждым приращением счетчика вызывается GetQueueStatus — эта функция проверяет, не появились ли в очереди потока новые сообщения. (Все волокна, работающие в рамках одного потока, делят его очередь сообщений.) Если сообщение появилось, значит, интерфейсному волокну есть чем заняться, и мы, считая его приоритетным по отношению к расчетному, сразу же вызываем SwitchToFiber, давая ему возможность обработать поступившее сообщение. Когда сообщение (или сообщения) будет обработано, интерфейсное волокно передаст управление волокну, отвечающему за пересчет, и фоновая обработка возобновится.
Если сообщений нет, расчетное волокно обновляет поле Answer диалогового окна и засыпает на 200 мс. В коде настоящей программы вызов Sleep надо, естественно, убрать — я поставил его, только чтобы «потянуть» время.
Когда волокно, отвечающее за пересчет, завершает свою работу, статус фоновой обработки устанавливается как BPS_DONE, и управление передается (через SxwitchToFiber) интерфейсному волокну. В этот момент ему делать нечего, и оно вызывает WaitMessage, которая приостанавливает поток, чтобы не тратить процессорное время понапрасну.
Заметьте, что каждое волокно записывает в FLS-слот строку-идентификатор («User interface» или «Computation»), которая выводится при регистрации различных событий, таких как удаление волокна или FLS-слота. Это делает функция обратного вызова, которая задается при создании FLS-слота; для проверки возможности использования FLS-слота эта функция использует функцию IsThreadAFiber.
Ч А С Т Ь I I I
УПРАВЛЕНИЕ ПАМЯТЬЮ