Как уже отмечалось, современные вычислительные системы содержат один или несколько центральных процессоров и, как правило, несколько специализированных устройств, способных к параллельной работе с центральными процессорами. Операционная система, функционирующая на таком компьютере, должна предоставлять и средства
Используемые в операционной системе средства реализации многозадачности можно разделить на несколько групп:
Существуют как специализированные средства взаимодействия, специфичные для конкретного вида устройств (например, для графических устройств), так и относительно универсальные средства, применимые к устройствам разных типов. Наиболее типичным примером являются средства
Когда процесс запускается, операционная система в нем самостоятельно создает первичный поток, начинающий исполнение кода этого процесса. Создание всех остальных потоков процесса требует специальных действий. Операционная система должна предоставлять средства для создания потоков, волокон, их завершения, приостановки или возобновления, изменения их характеристик (например, приоритетов, прав доступа и т.д.).
В Windows существует очень интересный гибрид средств управления потоками и
Потоки, работающие в рамках одного процесса, имеют возможность взаимодействовать друг с другом, используя общее
Чуть более сложный случай - необходимость взаимодействия различных процессов в рамках одной вычислительной системы. Особенность этого случая связана с тем, что разные процессы работают в изолированных друг от друга адресных пространствах, однако при этом существует возможность осуществить обмен данными через общую, разделяемую несколькими процессами, память. Диспетчер памяти операционной системы должен предусмотреть средства организации разделяемой между процессами памяти. Кроме того, многие операционные системы предоставляют дополнительные
В данной книге рассматриваются базовые средства для организации разделяемой памяти, так как большая часть остальных средств либо является надстройкой над ними (например, обмен оконными сообщениями), либо использует обмен данными через файловые объекты (к примеру, почтовые ящики, каналы и пр.).
В общем случае память разных компьютеров можно рассматривать как изолированную друг от друга. В этом случае для взаимодействия разных процессов потребуется
В данной книге межузловое взаимодействие не рассматривается.
Обычные операции ввода-вывода происходят в синхронном режиме. Например, в приведенном ниже примере все операции выполняются строго
#include <stdio.h>
int sequential_io( char *filename, char *buffer, int size )
{
FILE *fp;
int done;
fp = fopen( filename, "rb" );
if ( fp ) {
done = fread( fp, 1, size, buffer );
/* код не будет выполняться, пока чтение не завершится*/
fclose( fp );
} else {
done = 0;
}
buffer[done] = '\0';
return done;
}
Такой подход прост в реализации, как с точки зрения операционной системы, так и с точки зрения пользователя ("пользователем" операционной системы в данном случае выступает разработчик). Однако во время выполнения операций ввода-вывода сама программа не выполняется - она ожидает завершения ввода-вывода. Во многих случаях такие операции, во-первых, занимают достаточно много времени и, во-вторых, выполняются специализированным оборудованием без участия центрального процессора, который в это время находится в состоянии ожидания и не выполняет никакой полезной работы (по крайней мере, с точки зрения данной программы, так как в общем случае начало ожидания приводит к перепланировке потоков).
Эффективность использования процессора можно было бы повысить, если бы существовала возможность выполнять код программы во время выполнения операций ввода-вывода. Конечно, это не всегда возможно или целесообразно. Например, если для продолжения работы программы необходимы данные, которые еще не получены, то нам все равно надо ожидать завершения ввода-вывода. Более того, структура приложения должна быть разработана с учетом специфики использования асинхронных операций ввода-вывода.
В Windows для реализации , WriteFile, ReadFileEx, WriteFileEx и др. и специальная структура , которая используется для взаимодействия с асинхронной операцией. Асинхронные операции применяются следующим образом: перед началом операции заполняется структура и вызывается нужная функция для выполнения ввода-вывода, которая ставит операцию в очередь и немедленно возвращает управление вызвавшей программе. После этого программа продолжает свою работу независимо от хода выполнения операции ввода-вывода. При необходимости можно выяснить состояние асинхронной операции, дождаться ее завершения или отменить ее, не дожидаясь завершения. Для этого предназначен специальный набор функций, например, CancelIo, GetOverlappedResult, HasOverlappedIoCompleted и некоторые другие. Существует несколько вариантов использования
Для начала надо описать необходимые переменные и открыть файл с разрешением асинхронных операций ( FILE_FLAG_OVERLAPPED ):
OVERLAPPED ov;
DWORD dwWritten;
BYTE buffer[ 5000000 ];
HANDLE fh = CreateFile(
"file.dat", FILE_READ_DATA|FILE_WRITE_DATA,
FILE_SHARE_READ, (LPSECURITY_ATTRIBUTES)NULL, OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL|FILE_FLAG_OVERLAPPED, NULL
);
if ( fh == INVALID_HANDLE_VALUE ) {
/* возникла ошибка */
}
ZeroMemory( ov, sizeof(OVERLAPPED) );
FillMemory( buffer, sizeof(buffer), 123 );
ov.Offset = 12345;
if (
WriteFile( fh, buffer, sizeof(buffer), dwWritten, ov ) ||
GetLastError() == ERROR_IO_PENDING
) {
/* пока операция ввода-вывода выполняется,
выполняем некоторые операции.
дожидаемся завершения операции ввода-вывода */
while (!GetOverlappedResult(fh, ov, dwWritten, FALSE)){}
} else {
/* возникла ошибка */
}
Функция GetOverlappedResult в данном случае проверяет состояние операции ввода-вывода и возвращает признак ее завершения. Опрос состояния операции в цикле позволяет наиболее быстро отреагировать на завершение операции (особенно на многопроцессорных машинах), однако ценой увеличения загрузки процессора, что может снизить общую производительность системы.
Функция WriteFile возвращает значение TRUE, если операция записи завершена синхронно, а в случае ошибки или начатой асинхронной операции она возвращает FALSE, поэтому требуется анализ кода возникшей "ошибки", которая может и не являться ошибкой.
ov.Offset = 12345;
ov.hEvent = CreateEvent((LPSECURITY_ATTRIBUTES)NULL, TRUE, FALSE, 0);
if (
WriteFile( fh, buffer, sizeof(buffer), dwWritten, ov ) ||
GetLastError() == ERROR_IO_PENDING
) {
/* пока операция ввода-вывода выполняется, выполняем некоторые
операции...
дожидаемся завершения операции ввода-вывода */
GetOverlappedResult( fh, ov, dwWritten, TRUE );
} else {
/* возникла ошибка */
}
Функция GetOverlappedResult в данном случае проверяет состояние операции и, если она еще не завершена, вызывает функцию WaitForSingleObject для ожидания завершения операции ввода-вывода. Объект "событие" можно не создавать - в этом случае функция будет ожидать освобождения объекта "файл"; однако в случае нескольких, накладывающихся друг на друга, асинхронных операций будет непонятно, какая именно операция завершилась, и использование специфичных для каждой операции событий снимает эту проблему.
Ожидание на объектах ядра является наиболее экономным, но реакция приложения на завершение ввода-вывода связана с работой планировщика, поэтому для достижения малых задержек иногда надо дополнительно повышать
ov.Offset = 12345;
if ( WriteFileEx( fh, buffer, sizeof(buffer), ov, io_done ) ) {
/* пока операция ввода-вывода выполняется, выполняем некоторые
операции и переходим в режим ожидания оповещения,
например, так: */
if ( SleepEx( INFINITE, TRUE ) != WAIT_IO_COMPLETION ) {
/* ввод-вывод пока не завершен, возможно, ошибка */
}
} else {
/* возникла ошибка */
}
/* нам еще надо предоставить собственную процедуру
завершения ввода-вывода. В простейшем варианте
она может ничего не делать: */
VOID CALLBACK io_done(
DWORD dwErr, DWORD dwWritten, LPOVERLAPPED lpOv
) {
...
}
Этот подход наиболее трудоемок и наименее распространен; на практике самым эффективным является механизм выполнения асинхронных операций с ожиданием на объектах ядра. Однако механизм вызова функций завершения (расширенный возможностью автоматического выбора потока, осуществляющего обработку функции завершения) послужил основой для реализации одного из очень
При использовании , и буферами, участвующими в операциях ввода-вывода.
Для реализации
Практика показала, что такой механизм был бы эффективен и для реализации самих приложений. Более того, для реализации SleepEx, WaitForSingleObjectEx и др.
Здесь и во многих примерах далее, чтобы не загромождать код примера, опущена проверка ошибок:
VOID CALLBACK ApcProc( ULONG_PTR dwData )
{
/* ... */
}
int main( void )
{
QueueUserAPC( ApcProc, GetCurrentThread(), 0 );
/* ... */
SleepEx( 1000, TRUE );
return 0;
}
Обычно QueueUserAPC, с помощью которой можно ставить в
Обсуждение реализации
В мультипрограммной среде может одновременно выполняться значительное количество процессов, представляющих разных пользователей, и ядро операционной системы вынуждено проверять полномочия каждого процесса при их попытках доступа к защищаемым объектам.
Для того чтобы ядро операционной системы могло контролировать доступ к тем или иным объектам, сами объекты должны управляться ядром системы. Это приводит к понятию объектов ядра (kernel objects), которые создаются по запросу
Объекты ядра представлены в качестве некоторых структур и типов данных, управляемых ядром операционной системы и размещенных в памяти, принадлежащей ядру.
не имеют возможности доступа к этим объектам напрямую. Для того чтобы пользовательский процесс мог оперировать такими объектами, введено понятие описатель (handle) объекта ядра. Так, например, объектами ядра являются файлы, процессы, потоки, события, почтовые ящики и многое другое.
Все созданные описатели объектов ядра должны удаляться с помощью функции
BOOL CloseHandle( HANDLE hKernelObject )
которая уменьшает счетчик использования объекта и уничтожает его, когда на объект никто больше не ссылается.
Доступ к защищаемым объектам в Windows задается так называемыми дескрипторами безопасности (
Объект, осуществляющий доступ (выполняющийся поток), обладает так называемым маркером доступа (
Процессы и потоки в Windows являются с одной стороны "представителями" пользователя, выступающими от его имени, а с другой стороны - защищаемыми объектами, при доступе к которым выполняется проверка прав, то есть они обладают одновременно и маркерами доступа, и дескрипторами безопасности.
Описатели объектов ядра в Windows позволяют разным процессам и потокам взаимодействовать с объектами с учетом их
Для создания большинства объектов ядра используются функции, начинающиеся на слово "Create" и возвращающие описатель созданного объекта, например функции CreateFile, CreateProcess, CreateEvent и т.д. Многие объекты при их создании могут получить собственное имя или остаться неименованными.
Любой процесс или поток может ссылаться на объекты ядра, созданные другим процессом или потоком. Для этого предусмотрено три механизма:
BOOL bInheritHandle ", который указывает возможность наследования).Open... например, OpenMutex, OpenEvent и т.д.DuplicateHandle, создающая для объекта, заданного описателем в контексте данного процесса, новый описатель, корректный в контексте нового процесса:BOOL DuplicateHandle( HANDLE hFromProcess, HANDLE hSourceHandle, HANDLE hToProcess, LPHANDLE lpResultHandle, DWORD dwDesiredAccess, BOOL bInheritHandle, DWORD dwOptions );
Существенно проследить, чтобы все создаваемые описатели закрывались вызовом функции CloseHandle, включая описатели, DuplicateHandle. Хорошая практика при разработке приложений - проводить мониторинг выделяемых описателей и количества объектов в процессе (например, с помощью таких стандартных средств как менеджер задач или оснастка "производительность" панели управления).
Для взаимодействия потоков и процессов между собой необходимы средства, обеспечивающие идентификацию соответствующих объектов.
В 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, /, /MTd или /MDd (свойства проекта Configuration Properties|C/C++| ).
Наличие специальных потоко-безопасных версий библиотек требует использования специальных функций для создания и завершения потоков, принадлежащих не системному 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 );timeGetTime, timeBeginPeriod и пр.);QueryPerformanceCounter, QueryPerformanceFrequency ).Обычно мультимедийный таймер работает с разрешением от 1-5 мс и хуже (зависит от аппаратуры), однако функция timeBeginPeriod позволяет изменить разрешение вплоть до 1 мс. Если стандартное разрешение мультимедийного таймера на данном компьютере хуже 5-10 мс, то у функции timeBeginPeriod есть побочный эффект - улучшение разрешения повлияет на работу планировщика во всей системе, а не только в процессе, вызвавшем эту функцию. В результате, если один процесс повысит разрешение мультимедийного таймера, то функция Sleep также получит возможность задавать интервалы вплоть до 1 мс и эффект наблюдается даже в других процессах. Если мультимедийный таймер на данной аппаратуре стандартно работает с разрешением порядка 1 мс, то такого влияния на планировщик не наблюдается.
Есть частный случай применения функции Sleep - при задании интервала 0 вызов функции просто приводит к срабатыванию планировщика и, при наличии других готовых потоков, к их активации. Аналогичного эффекта можно добиться, применяя функцию SwitchToThread, вызывающую перепланирование потоков.
Поток может быть создан в приостановленном (CREATE_SUSPENDED при вызове функций _beginthreadex или CreateThread, а также переведен в это состояние (функция SuspendThread ) или, наоборот, пробужден с помощью функции ResumeThread.
Работа с волокнами в приложении в чем-то сложнее, в чем-то проще. Сложнее, потому что необходимо реализовать собственный планировщик волокон. Сложность разработки планировщика резко возрастает при
необходимости синхронизации волокон - стандартные
При работе с волокнами используется функция 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;
}
Следует еще раз отметить одну важную особенность волокон - они работают в рамках одного потока и не позволяют задействовать возможности мультипроцессирования. Если нужно обеспечить параллельное исполнение кода на разных процессорах, то надо применять потоки либо даже отдельные процессы. В частных случаях возможно создание гибридных вариантов - когда несколько потоков выполняют несколько волокон; при этом число волокон может существенно превышать число потоков. Однако и в этом случае целесообразность применения волокон должна быть тщательно изучена: очень часто эффективнее не выполнять одновременно много мелких заданий, а выполнять их поочередно - тогда уменьшатся затраты на переключения и, возможно, возрастет утилизация кэша. Волокна представляют, по большей части, теоретический интерес, как возможность реализовать планировщик пользовательского режима, помимо существующего стандартного планировщика режима ядра.
Как уже отмечалось, современные вычислительные системы содержат один или несколько центральных процессоров и, как правило, несколько специализированных устройств, способных к параллельной работе с центральными процессорами. Операционная система, функционирующая на таком компьютере, должна предоставлять и средства
Используемые в операционной системе средства реализации многозадачности можно разделить на несколько групп:
Существуют как специализированные средства взаимодействия, специфичные для конкретного вида устройств (например, для графических устройств), так и относительно универсальные средства, применимые к устройствам разных типов. Наиболее типичным примером являются средства
Когда процесс запускается, операционная система в нем самостоятельно создает первичный поток, начинающий исполнение кода этого процесса. Создание всех остальных потоков процесса требует специальных действий. Операционная система должна предоставлять средства для создания потоков, волокон, их завершения, приостановки или возобновления, изменения их характеристик (например, приоритетов, прав доступа и т.д.).
В Windows существует очень интересный гибрид средств управления потоками и
Потоки, работающие в рамках одного процесса, имеют возможность взаимодействовать друг с другом, используя общее
Чуть более сложный случай - необходимость взаимодействия различных процессов в рамках одной вычислительной системы. Особенность этого случая связана с тем, что разные процессы работают в изолированных друг от друга адресных пространствах, однако при этом существует возможность осуществить обмен данными через общую, разделяемую несколькими процессами, память. Диспетчер памяти операционной системы должен предусмотреть средства организации разделяемой между процессами памяти. Кроме того, многие операционные системы предоставляют дополнительные
В данной книге рассматриваются базовые средства для организации разделяемой памяти, так как большая часть остальных средств либо является надстройкой над ними (например, обмен оконными сообщениями), либо использует обмен данными через файловые объекты (к примеру, почтовые ящики, каналы и пр.).
В общем случае память разных компьютеров можно рассматривать как изолированную друг от друга. В этом случае для взаимодействия разных процессов потребуется
В данной книге межузловое взаимодействие не рассматривается.
Обычные операции ввода-вывода происходят в синхронном режиме. Например, в приведенном ниже примере все операции выполняются строго
#include <stdio.h>
int sequential_io( char *filename, char *buffer, int size )
{
FILE *fp;
int done;
fp = fopen( filename, "rb" );
if ( fp ) {
done = fread( fp, 1, size, buffer );
/* код не будет выполняться, пока чтение не завершится*/
fclose( fp );
} else {
done = 0;
}
buffer[done] = '\0';
return done;
}
Такой подход прост в реализации, как с точки зрения операционной системы, так и с точки зрения пользователя ("пользователем" операционной системы в данном случае выступает разработчик). Однако во время выполнения операций ввода-вывода сама программа не выполняется - она ожидает завершения ввода-вывода. Во многих случаях такие операции, во-первых, занимают достаточно много времени и, во-вторых, выполняются специализированным оборудованием без участия центрального процессора, который в это время находится в состоянии ожидания и не выполняет никакой полезной работы (по крайней мере, с точки зрения данной программы, так как в общем случае начало ожидания приводит к перепланировке потоков).
Эффективность использования процессора можно было бы повысить, если бы существовала возможность выполнять код программы во время выполнения операций ввода-вывода. Конечно, это не всегда возможно или целесообразно. Например, если для продолжения работы программы необходимы данные, которые еще не получены, то нам все равно надо ожидать завершения ввода-вывода. Более того, структура приложения должна быть разработана с учетом специфики использования асинхронных операций ввода-вывода.
В Windows для реализации , WriteFile, ReadFileEx, WriteFileEx и др. и специальная структура , которая используется для взаимодействия с асинхронной операцией. Асинхронные операции применяются следующим образом: перед началом операции заполняется структура и вызывается нужная функция для выполнения ввода-вывода, которая ставит операцию в очередь и немедленно возвращает управление вызвавшей программе. После этого программа продолжает свою работу независимо от хода выполнения операции ввода-вывода. При необходимости можно выяснить состояние асинхронной операции, дождаться ее завершения или отменить ее, не дожидаясь завершения. Для этого предназначен специальный набор функций, например, CancelIo, GetOverlappedResult, HasOverlappedIoCompleted и некоторые другие. Существует несколько вариантов использования
Для начала надо описать необходимые переменные и открыть файл с разрешением асинхронных операций ( FILE_FLAG_OVERLAPPED ):
OVERLAPPED ov;
DWORD dwWritten;
BYTE buffer[ 5000000 ];
HANDLE fh = CreateFile(
"file.dat", FILE_READ_DATA|FILE_WRITE_DATA,
FILE_SHARE_READ, (LPSECURITY_ATTRIBUTES)NULL, OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL|FILE_FLAG_OVERLAPPED, NULL
);
if ( fh == INVALID_HANDLE_VALUE ) {
/* возникла ошибка */
}
ZeroMemory( ov, sizeof(OVERLAPPED) );
FillMemory( buffer, sizeof(buffer), 123 );
ov.Offset = 12345;
if (
WriteFile( fh, buffer, sizeof(buffer), dwWritten, ov ) ||
GetLastError() == ERROR_IO_PENDING
) {
/* пока операция ввода-вывода выполняется,
выполняем некоторые операции.
дожидаемся завершения операции ввода-вывода */
while (!GetOverlappedResult(fh, ov, dwWritten, FALSE)){}
} else {
/* возникла ошибка */
}
Функция GetOverlappedResult в данном случае проверяет состояние операции ввода-вывода и возвращает признак ее завершения. Опрос состояния операции в цикле позволяет наиболее быстро отреагировать на завершение операции (особенно на многопроцессорных машинах), однако ценой увеличения загрузки процессора, что может снизить общую производительность системы.
Функция WriteFile возвращает значение TRUE, если операция записи завершена синхронно, а в случае ошибки или начатой асинхронной операции она возвращает FALSE, поэтому требуется анализ кода возникшей "ошибки", которая может и не являться ошибкой.
ov.Offset = 12345;
ov.hEvent = CreateEvent((LPSECURITY_ATTRIBUTES)NULL, TRUE, FALSE, 0);
if (
WriteFile( fh, buffer, sizeof(buffer), dwWritten, ov ) ||
GetLastError() == ERROR_IO_PENDING
) {
/* пока операция ввода-вывода выполняется, выполняем некоторые
операции...
дожидаемся завершения операции ввода-вывода */
GetOverlappedResult( fh, ov, dwWritten, TRUE );
} else {
/* возникла ошибка */
}
Функция GetOverlappedResult в данном случае проверяет состояние операции и, если она еще не завершена, вызывает функцию WaitForSingleObject для ожидания завершения операции ввода-вывода. Объект "событие" можно не создавать - в этом случае функция будет ожидать освобождения объекта "файл"; однако в случае нескольких, накладывающихся друг на друга, асинхронных операций будет непонятно, какая именно операция завершилась, и использование специфичных для каждой операции событий снимает эту проблему.
Ожидание на объектах ядра является наиболее экономным, но реакция приложения на завершение ввода-вывода связана с работой планировщика, поэтому для достижения малых задержек иногда надо дополнительно повышать
ov.Offset = 12345;
if ( WriteFileEx( fh, buffer, sizeof(buffer), ov, io_done ) ) {
/* пока операция ввода-вывода выполняется, выполняем некоторые
операции и переходим в режим ожидания оповещения,
например, так: */
if ( SleepEx( INFINITE, TRUE ) != WAIT_IO_COMPLETION ) {
/* ввод-вывод пока не завершен, возможно, ошибка */
}
} else {
/* возникла ошибка */
}
/* нам еще надо предоставить собственную процедуру
завершения ввода-вывода. В простейшем варианте
она может ничего не делать: */
VOID CALLBACK io_done(
DWORD dwErr, DWORD dwWritten, LPOVERLAPPED lpOv
) {
...
}
Этот подход наиболее трудоемок и наименее распространен; на практике самым эффективным является механизм выполнения асинхронных операций с ожиданием на объектах ядра. Однако механизм вызова функций завершения (расширенный возможностью автоматического выбора потока, осуществляющего обработку функции завершения) послужил основой для реализации одного из очень
При использовании , и буферами, участвующими в операциях ввода-вывода.
Для реализации
Практика показала, что такой механизм был бы эффективен и для реализации самих приложений. Более того, для реализации SleepEx, WaitForSingleObjectEx и др.
Здесь и во многих примерах далее, чтобы не загромождать код примера, опущена проверка ошибок:
VOID CALLBACK ApcProc( ULONG_PTR dwData )
{
/* ... */
}
int main( void )
{
QueueUserAPC( ApcProc, GetCurrentThread(), 0 );
/* ... */
SleepEx( 1000, TRUE );
return 0;
}
Обычно QueueUserAPC, с помощью которой можно ставить в
Обсуждение реализации
В мультипрограммной среде может одновременно выполняться значительное количество процессов, представляющих разных пользователей, и ядро операционной системы вынуждено проверять полномочия каждого процесса при их попытках доступа к защищаемым объектам.
Для того чтобы ядро операционной системы могло контролировать доступ к тем или иным объектам, сами объекты должны управляться ядром системы. Это приводит к понятию объектов ядра (kernel objects), которые создаются по запросу
Объекты ядра представлены в качестве некоторых структур и типов данных, управляемых ядром операционной системы и размещенных в памяти, принадлежащей ядру.
не имеют возможности доступа к этим объектам напрямую. Для того чтобы пользовательский процесс мог оперировать такими объектами, введено понятие описатель (handle) объекта ядра. Так, например, объектами ядра являются файлы, процессы, потоки, события, почтовые ящики и многое другое.
Все созданные описатели объектов ядра должны удаляться с помощью функции
BOOL CloseHandle( HANDLE hKernelObject )
которая уменьшает счетчик использования объекта и уничтожает его, когда на объект никто больше не ссылается.
Доступ к защищаемым объектам в Windows задается так называемыми дескрипторами безопасности (
Объект, осуществляющий доступ (выполняющийся поток), обладает так называемым маркером доступа (
Процессы и потоки в Windows являются с одной стороны "представителями" пользователя, выступающими от его имени, а с другой стороны - защищаемыми объектами, при доступе к которым выполняется проверка прав, то есть они обладают одновременно и маркерами доступа, и дескрипторами безопасности.
Описатели объектов ядра в Windows позволяют разным процессам и потокам взаимодействовать с объектами с учетом их
Для создания большинства объектов ядра используются функции, начинающиеся на слово "Create" и возвращающие описатель созданного объекта, например функции CreateFile, CreateProcess, CreateEvent и т.д. Многие объекты при их создании могут получить собственное имя или остаться неименованными.
Любой процесс или поток может ссылаться на объекты ядра, созданные другим процессом или потоком. Для этого предусмотрено три механизма:
BOOL bInheritHandle ", который указывает возможность наследования).Open... например, OpenMutex, OpenEvent и т.д.DuplicateHandle, создающая для объекта, заданного описателем в контексте данного процесса, новый описатель, корректный в контексте нового процесса:BOOL DuplicateHandle( HANDLE hFromProcess, HANDLE hSourceHandle, HANDLE hToProcess, LPHANDLE lpResultHandle, DWORD dwDesiredAccess, BOOL bInheritHandle, DWORD dwOptions );
Существенно проследить, чтобы все создаваемые описатели закрывались вызовом функции CloseHandle, включая описатели, DuplicateHandle. Хорошая практика при разработке приложений - проводить мониторинг выделяемых описателей и количества объектов в процессе (например, с помощью таких стандартных средств как менеджер задач или оснастка "производительность" панели управления).
Для взаимодействия потоков и процессов между собой необходимы средства, обеспечивающие идентификацию соответствующих объектов.
В 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, /, /MTd или /MDd (свойства проекта Configuration Properties|C/C++| ).
Наличие специальных потоко-безопасных версий библиотек требует использования специальных функций для создания и завершения потоков, принадлежащих не системному 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 );timeGetTime, timeBeginPeriod и пр.);QueryPerformanceCounter, QueryPerformanceFrequency ).Обычно мультимедийный таймер работает с разрешением от 1-5 мс и хуже (зависит от аппаратуры), однако функция timeBeginPeriod позволяет изменить разрешение вплоть до 1 мс. Если стандартное разрешение мультимедийного таймера на данном компьютере хуже 5-10 мс, то у функции timeBeginPeriod есть побочный эффект - улучшение разрешения повлияет на работу планировщика во всей системе, а не только в процессе, вызвавшем эту функцию. В результате, если один процесс повысит разрешение мультимедийного таймера, то функция Sleep также получит возможность задавать интервалы вплоть до 1 мс и эффект наблюдается даже в других процессах. Если мультимедийный таймер на данной аппаратуре стандартно работает с разрешением порядка 1 мс, то такого влияния на планировщик не наблюдается.
Есть частный случай применения функции Sleep - при задании интервала 0 вызов функции просто приводит к срабатыванию планировщика и, при наличии других готовых потоков, к их активации. Аналогичного эффекта можно добиться, применяя функцию SwitchToThread, вызывающую перепланирование потоков.
Поток может быть создан в приостановленном (CREATE_SUSPENDED при вызове функций _beginthreadex или CreateThread, а также переведен в это состояние (функция SuspendThread ) или, наоборот, пробужден с помощью функции ResumeThread.
Работа с волокнами в приложении в чем-то сложнее, в чем-то проще. Сложнее, потому что необходимо реализовать собственный планировщик волокон. Сложность разработки планировщика резко возрастает при
необходимости синхронизации волокон - стандартные
При работе с волокнами используется функция 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;
}
Следует еще раз отметить одну важную особенность волокон - они работают в рамках одного потока и не позволяют задействовать возможности мультипроцессирования. Если нужно обеспечить параллельное исполнение кода на разных процессорах, то надо применять потоки либо даже отдельные процессы. В частных случаях возможно создание гибридных вариантов - когда несколько потоков выполняют несколько волокон; при этом число волокон может существенно превышать число потоков. Однако и в этом случае целесообразность применения волокон должна быть тщательно изучена: очень часто эффективнее не выполнять одновременно много мелких заданий, а выполнять их поочередно - тогда уменьшатся затраты на переключения и, возможно, возрастет утилизация кэша. Волокна представляют, по большей части, теоретический интерес, как возможность реализовать планировщик пользовательского режима, помимо существующего стандартного планировщика режима ядра.
Для получения официальных документов о завершении программы дополнительного профессионального образования (удостоверения о повышении квалификации, дипломов о профессиональной переподготовке и MBA) необходимо предоставить:
Внимание! Вы можете не заказывать доставку бумажной версии официального документы, а скачать его в электронном виде и распечатать самостоятельно. Информация о выданном документе в течение 1 месяца загружается в Федеральную информационную систему «Федеральный реестр сведений о документах об образовании и (или) о квалификации, документах об обучении» - ФИС ФРДО.
Доступ на новый сайт осуществляется с использованием адреса электронной почты, который был указан вами при регистрации на "старом". Мы постарались перенести все ваши данные с прежнего ресурса, однако не исключена вероятность потери части информации.
При возникновении проблемы со входом, воспользуйтесь функцией сброса пароля
Если вы обнаружите несоответствия, пожалуйста, сообщите нам.