Рабочим названием платформы .NET было |
Общие подходы к реализации приложений с параллельным выполнением операций
Описатели процесса и потока
Для взаимодействия потоков и процессов между собой необходимы средства, обеспечивающие идентификацию соответствующих объектов.
В Windows для идентификации процессов и потоков используют их описатели ( HANDLE ) и идентификаторы ( DWORD ). Описатели идентифицируют в данном случае объект ядра, представляющий процесс или поток, при доступе к которому, как ко всякому объекту ядра, учитывается контекст защиты, проверяются права доступа и т.д. Идентификаторы процесса и потока, назначаемые при их создании, исполняют роль уникальных имен.
Описатели и идентификаторы процессов и потоков можно получить при создании соответствующих объектов. Кроме того, можно узнать идентификаторы текущего процесса и потока ( GetCurrentThreadId, GetCurrentProcessId ), или по описателю узнать соответствующий идентификатор ( GetProcessId и GetThreadId ). Функции OpenProcess и OpenThread позволяют получить описатели этих объектов по их идентификатору.
Функции GetCurrentProcess и GetCurrentThread возвращают описатели текущего процесса и потока, однако возвращаемое ими значение не является настоящим описателем, а представлено некоторой константой, получившей название "псевдоописатель". Эта константа, использованная вместо описателя потока или процесса, рассматривается как описатель процесса/потока, сделавшего вызов системной функции. Псевдоописателями можно свободно пользоваться в рамках процесса (потока), в котором они получены, а при попытке передать их другому процессу или потоку они будут рассматриваться как описатели того процесса (потока), в контексте которого используются.
В тех случаях, когда необходимо дать другому процессу или потоку доступ к данным описателям, нужно с помощью DuplicateHandle сделать с них "копии", которые будут являться настоящими описателями в контексте процесса-получателя. Так, например, с помощью этой функции можно превратить псевдоописатель процесса в настоящий описатель, действующий только в текущем процессе:
HANDLE hrealThread; DuplicateHandle( GetCurrentProcess(), GetCurrentProcess(), GetCurrentProcess(), &hrealThread, DUPLICATE_SAME_ACCESS, FALSE, 0 );
У процессов и потоков есть интересная особенность - объекты ядра, представляющие процесс и поток, сразу после создания имеют счетчик использования не менее двух: во-первых, это описатель, возвращенный функцией, и, во-вторых, объект используется работающим потоком. В итоге завершение потока и завершение последнего потока в процессе не приводят к удалению соответствующих объектов - они будут сохраняться все время, пока существуют их описатели. Это сделано для того, чтобы уже после завершения работы потока или процесса можно было получить от него какую-либо информацию, чаще всего - код завершения (функции GetExitCodeThread и GetExitCodeProcess );
Объекты ядра "процесс" и "поток" поддерживают также интерфейс синхронизируемых объектов, так что их можно использовать для синхронизации работы: поток считается занятым до завершения, а процесс занят до тех пор, пока в нем есть хоть один работающий поток.
Если ни синхронизация с этими объектами, ни получение кодов завершения не требуются разработчику, надо сразу после создания соответствующего объекта закрывать его описатель.
В Windows для задания приоритета работающего потока используют понятия классов приоритетов и относительных приоритетов в классе. При этом класс приоритета связывается с процессом, а относительный приоритет - с потоком, исполняющимся в данном процессе.
Соответственно Win32 API предоставляет функции для изменения класса приоритета для процесса ( GetPriorityClass, SetPriorityClass ) и для изменения относительного приоритета потока ( GetThreadPriority и SetThreadPriority ).
Планировщик операционной системы может динамически корректировать приоритет потока, кратковременно повышая уровень. Разработчикам предоставлена возможность отказаться от этой возможности или,
наоборот, задействовать ее (функции GetProcessPriorityBoost, SetProcessPriorityBoost, GetThreadPriorityBoost и SetThreadPriorityBoost ).
Основы использования потоков и волокон
Для реализации мультипрограммирования или мультипроцессирования на однотипных устройствах можно применять процессы, потоки и волокна. Разница между ними связана с возможностью обмена данными: адресное пространство процессов изолировано друг от друга, поэтому взаимодействие затруднено; потоки находятся в общем адресном пространстве процесса и могут легко взаимодействовать друг с другом; волокна отчасти аналогичны потокам, но планирование волокон выполняется непосредственно приложением, а не операционной системой.
Потоко-безопасные и небезопасные функции
При реализации многопоточного приложения следует учитывать возможные побочные эффекты. Наличие таких эффектов обусловлено реализацией библиотеки времени исполнения: она содержит много функций
(в том числе внутренних, обеспечивающих семантику языка программирования), являющихся потоко-небезопасными. Примеры таких функций - стандартная процедура strtok, операторы new и delete или функции malloc, calloc, free и так далее. Фактически любая стандартная функция, оперирующая статическими переменными, объектами или данными, может являться потоко-небезопасной в силу того, что два потока могут получить одновременный конкурирующий доступ к этим данным и в итоге разрушить их. Существует несколько подходов к решению этой проблемы:
- Можно предоставить потоко-безопасные аналоги (например, strtok_r, являющийся в Linux потоко-безопасным аналогом функции strtok ).
- Можно переписать код всех потоко-небезопасных функций так, чтобы они вместо глобальных объектов использовали локальную для потока память или синхронизировали доступ к общим данным.
В Windows принят второй подход, однако, с некоторой оговоркой. Потоко-безопасные версии функций более ресурсо- и время- емкие, чем обычные. В итоге используется два вида библиотек: один для однопоточных приложений, другой для многопоточных. Следует отметить, что выбор той или иной библиотеки определяется, как правило, свойствами проекта (параметрами компилятора), а вовсе не кодом приложения. Поэтому при разработке многопотокового приложения важно проследить, чтобы при компиляции использовалась правильная версия библиотеки, во избежание возникновения трудно диагностируемых ошибок, проявляющихся в самых разных и совершенно "невинных" на первый взгляд местах кода. В случае Visual Studio однопоточные версии библиотек выбираются ключами /ML или /MLd компилятора, а многопоточные ключами /MT, /MD, /MTd или /MDd (свойства проекта Configuration Properties|C/C++|Code Generation|Runtime Library ).
Работа с потоками
Наличие специальных потоко-безопасных версий библиотек требует использования специальных функций для создания и завершения потоков, принадлежащих не системному API, а библиотеке времени исполнения. Так, вместо функций Win32 API CreateThread, ExitThread необходимо использовать библиотечные функции _beginthread, _endthread или _beginthreadex, _endthreadex. Это требование связано с тем, что при создании нового потока необходимо, помимо выполнения определенных действий по созданию потока со стороны операционной системы, инициализировать специфичные структуры данных, обслуживающих потоко-безопасные версии функций библиотеки времени исполнения:
unsigned _ _stdcall ThreadProc( void *param ) { /* вновь созданный поток будет выполнять эту функцию */ Sleep( 1000 ); delete[] (int*)param; return 0; /* завершение функции = завершение потока */ } int main( void ) { HANDLE hThread; unsigned dwThread; /* создаем новый поток */ hThread = (HANDLE)_beginthreadex ( NULL, 0, ThreadProc, new int [128], 0, &dwThread ); /* код в этом месте может выполняться одновременно с кодом функции потока ThreadProc, планирование потоков осуществляется системой */ /* дождаться завершения созданного потока */ WaitForSingleObject( hThread, INFINITE ); CloseHandle( hThread ); return 0; }
В данном примере можно было бы создавать поток не вызовом функции _beginthreadex (или _beginthread ), а вызовом функции API CreateThread. Но при незначительном усложнении примера, скажем, создании не одного, а двух потоков, уже было бы возможно возникновение ошибки при одновременном обращении к операторам new или delete в разных потоках (причем именно "возможно", так как ничтожные временные задержки могут изменить поведение потоков - это крайне осложняет выявление таких ошибок). Применение функций библиотеки времени исполнения для создания потоков решает эту проблему.
Windows содержит достаточно богатый набор функций для управления потоками, включающий функции создания и завершения потоков (функции API CreateThread, ExitThread, TerminateThread и их "обертки" в библиотеке времени исполнения _beginthread, _endthread, _beginthreadex и _endthreadex ).
Функция Sleep ( DWORD dwMilliseconds ) может переводить поток в "спячку" на заданное время. Продолжительность задается с точностью до кванта работы планировщика, то есть не лучше, чем 10-15 мс, несмотря на то, что при вызове функции задать можно до 1 мс. Измерение времени реальной паузы, заданной, например, вызовом Sleep(1), позволяет получить косвенную информацию о работе планировщика.
В Windows существует интересная особенность, связанная с работой планировщика и измерением интервалов времени. Система предоставляет три способа измерения интервалов:
- таймер низкого разрешения, основанный на квантах планировщика ( GetTickCount );
- "мультимедийный", с разрешением до 1 мс ( timeGetTime, timeBeginPeriod и пр.);
- высокоточный, использующий счетчик тактов процессора и с разрешением ощутимо лучше микросекунды на современных процессорах ( QueryPerformanceCounter, QueryPerformanceFrequency ).
Обычно мультимедийный таймер работает с разрешением от 1-5 мс и хуже (зависит от аппаратуры), однако функция timeBeginPeriod позволяет изменить разрешение вплоть до 1 мс. Если стандартное разрешение мультимедийного таймера на данном компьютере хуже 5-10 мс, то у функции timeBeginPeriod есть побочный эффект - улучшение разрешения повлияет на работу планировщика во всей системе, а не только в процессе, вызвавшем эту функцию. В результате, если один процесс повысит разрешение мультимедийного таймера, то функция Sleep также получит возможность задавать интервалы вплоть до 1 мс и эффект наблюдается даже в других процессах. Если мультимедийный таймер на данной аппаратуре стандартно работает с разрешением порядка 1 мс, то такого влияния на планировщик не наблюдается.
Есть частный случай применения функции Sleep - при задании интервала 0 вызов функции просто приводит к срабатыванию планировщика и, при наличии других готовых потоков, к их активации. Аналогичного эффекта можно добиться, применяя функцию SwitchToThread, вызывающую перепланирование потоков.
Поток может быть создан в приостановленном (suspended) состоянии с помощью задания специального флага CREATE_SUSPENDED при вызове функций _beginthreadex или CreateThread, а также переведен в это состояние (функция SuspendThread ) или, наоборот, пробужден с помощью функции ResumeThread.
Работа с волокнами
Работа с волокнами в приложении в чем-то сложнее, в чем-то проще. Сложнее, потому что необходимо реализовать собственный планировщик волокон. Сложность разработки планировщика резко возрастает при
необходимости синхронизации волокон - стандартные средства синхронизации Windows переводят в режим ожидания поток целиком, даже если он должен планировать множество волокон. Проще, потому что все волокна могут разделять один поток - в этом случае легко избежать проблем конкурирующего доступа к данным и можно применять любую библиотеку времени исполнения, в том числе потоко-небезопасную.
При работе с волокнами используется функция ConvertThreadToFiber для предварительного создания необходимых операционной системе структур данных. Функция ConvertFiberToThread выполняет обратную задачу и уничтожает выделенные данные. После того как необходимые структуры созданы (поток "превращен" в волокно), появляется возможность создавать новые волокна ( CreateFiber ), удалять существующие ( DeleteFiber ) и планировать их исполнение ( SwitchToFiber ).
Приведем пример применения двух рабочих волокон, выполняющих целевую функцию, и одного управляющего, удаляющего рабочие волокна по их завершении.
Функция main превращает текущий поток в волокно (инициализация внутренних структур данных для работы с волокнами), затем создает рабочие волокна и организует цикл, в котором ожидает их завершения и удаляет. Цикл завершается тогда, когда все рабочие волокна удалены, после чего функция main принимает меры к корректному завершению работы с волокнами.
Собственно целевая функция FiberProc эпизодически вызывает функцию SwitchToFiber для переключения выполняемого волокна. В данном примере для определения нового волокна, подлежащего исполнению, реализован простейший планировщик (функция schedule, инкапсулирующая вызов функции SwitchToFiber ).
#define _WIN32_WINNT 0x0400 #include <windows.h> #define FIBERS 2 static LPVOID fiberEnd; static LPVOID fiberCtl; static LPVOID fiber[ FIBERS ]; static void shedule( BOOL fDontEnd ) { int n, current; if ( !fDontEnd ) { /* волокно надо завершить */ fiberEnd = GetCurrentFiber(); SwitchToFiber(fiberCtl ); } /* выбираем следующее волокно для выполнения */ for ( n = 0; n < FIBERS; n++ ) { if ( fiber[n] && fiber[n] != GetCurrentFiber() ) break; } if ( n >= FIBERS ) return; /* нет других готовых волокон*/ SwitchToFiber( fiber[n] ); } VOID CALLBACK FiberProc( PVOID lpParameter ) { /* волокно будет выполнять код этой функции */ int i; for ( i = 0; i < 100; i++ ) { Sleep( 1000 ); shedule( TRUE ); /* выполнение продолжается */ } shedule( FALSE ); /* волокно завершается */ } int main( void ) { int i; fiberCtl = ConvertThreadToFiber( NULL ); fiberEnd = NULL; for ( i = 0; i < FIBERS; i++ ) { fiber[i] = CreateFiber( 10000, FiberProc, NULL ); } for ( i = 0; i < FIBERS;) { SwitchToFiber( fiber[i] ); if ( fiberEnd ) { DeleteFiber(fiberEnd ); for ( i = 0; i < FIBERS; i++ ) { if ( fiber[i] == fiberEnd ) fiber[i] = NULL; } fiberEnd = NULL; } for ( i = 0; i < FIBERS; i++ ) if ( fiber[i] ) break; } ConvertFiberToThread(); return 0; }
Следует еще раз отметить одну важную особенность волокон - они работают в рамках одного потока и не позволяют задействовать возможности мультипроцессирования. Если нужно обеспечить параллельное исполнение кода на разных процессорах, то надо применять потоки либо даже отдельные процессы. В частных случаях возможно создание гибридных вариантов - когда несколько потоков выполняют несколько волокон; при этом число волокон может существенно превышать число потоков. Однако и в этом случае целесообразность применения волокон должна быть тщательно изучена: очень часто эффективнее не выполнять одновременно много мелких заданий, а выполнять их поочередно - тогда уменьшатся затраты на переключения и, возможно, возрастет утилизация кэша. Волокна представляют, по большей части, теоретический интерес, как возможность реализовать планировщик пользовательского режима, помимо существующего стандартного планировщика режима ядра.