Common Intermediate Language и системное программирование в Microsoft .NET

Общие подходы к реализации приложений с параллельным выполнением операций

Разбить на страницы
Показывать лекцию целиком

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

Используемые в операционной системе средства реализации многозадачности можно разделить на несколько групп:

  • Взаимодействие с устройствами.

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

  • Средства управления потоками и волокнами.

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

    В 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 для реализации асинхронного ввода-вывода предусмотрены функции типа ReadFile, WriteFile, ReadFileEx, WriteFileEx и др. и специальная структура OVERLAPPED, которая используется для взаимодействия с асинхронной операцией. Асинхронные операции применяются следующим образом: перед началом операции заполняется структура OVERLAPPED и вызывается нужная функция для выполнения ввода-вывода, которая ставит операцию в очередь и немедленно возвращает управление вызвавшей программе. После этого программа продолжает свою работу независимо от хода выполнения операции ввода-вывода. При необходимости можно выяснить состояние асинхронной операции, дождаться ее завершения или отменить ее, не дожидаясь завершения. Для этого предназначен специальный набор функций, например, 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 для ожидания завершения операции ввода-вывода. Объект "событие" можно не создавать - в этом случае функция будет ожидать освобождения объекта "файл"; однако в случае нескольких, накладывающихся друг на друга, асинхронных операций будет непонятно, какая именно операция завершилась, и использование специфичных для каждой операции событий снимает эту проблему.

    Ожидание на объектах ядра является наиболее экономным, но реакция приложения на завершение ввода-вывода связана с работой планировщика, поэтому для достижения малых задержек иногда надо дополнительно повышать приоритеты потоков, переходящих в режим ожидания завершения ввода-вывода.

  • Выполнение асинхронных операций с использованием функций завершения операций ввода-вывода. Эта функция будет автоматически вызвана после завершения ввода-вывода и может выполнить некоторые специальные действия. Процедура завершения обязательно вызывается в контексте того потока, который вызвал операцию ввода-вывода, - а для этого необходимо, чтобы поток был приостановлен, так как операционная система не должна прерывать работу активного потока. Следует перевести поток в состояние ожидания оповещения (alertable waiting) - при этом он не выполняется и может быть прерван для обработки процедуры завершения:
    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
    ) {
      ...
    }

    Этот подход наиболее трудоемок и наименее распространен; на практике самым эффективным является механизм выполнения асинхронных операций с ожиданием на объектах ядра. Однако механизм вызова функций завершения (расширенный возможностью автоматического выбора потока, осуществляющего обработку функции завершения) послужил основой для реализации одного из очень эффективных механизмов взаимодействия потоков - порта завершения ввода-вывода.

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

    Асинхронный ввод-вывод является примером мультипроцессирования с использованием функционально различных устройств.

    Асинхронные вызовы процедур

    Для реализации асинхронного ввода-вывода в операционной системе предусмотрен специальный механизм, основанный на так называемых асинхронных вызовах процедур (Аsynchronous Procedure Call, APC). Это один из базовых механизмов, необходимый для нормального функционирования операционной системы.

    Практика показала, что такой механизм был бы эффективен и для реализации самих приложений. Более того, для реализации асинхронного ввода-вывода с поддержкой функции завершения система уже обязана была предоставить этот механизм. Для реализации этого механизма операционная система ведет списки процедур, которые она должна вызывать в контексте данного потока, с тем ограничением, что прерывать работу занятого потока в произвольный момент времени система не должна. Поэтому для обслуживания накопившихся в очереди процедур необходимо перевести поток в специальное состояние ожидания оповещения (alertable waiting) - для этого Win32 API предусматривает специальный набор функций: например, SleepEx, WaitForSingleObjectEx и др.

    Здесь и во многих примерах далее, чтобы не загромождать код примера, опущена проверка ошибок:

    VOID CALLBACK ApcProc( ULONG_PTR dwData )
    {
      /* ... */
    }
    int main( void )
    {
      QueueUserAPC( ApcProc, GetCurrentThread(), 0 ); 
      /* ... */
      SleepEx( 1000, TRUE );
      return 0;
    }

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

    Процессы, потоки и объекты ядра

    Обсуждение реализации мультипрограммирования в операционной системе неизбежно приводит к обсуждению вопросов безопасности.

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

    Для того чтобы ядро операционной системы могло контролировать доступ к тем или иным объектам, сами объекты должны управляться ядром системы. Это приводит к понятию объектов ядра (kernel objects), которые создаются по запросу процессов ядром системы, управляются ядром, и доступ к которым также контролируется ядром системы.

    Объекты ядра

    Объекты ядра представлены в качестве некоторых структур и типов данных, управляемых ядром операционной системы и размещенных в памяти, принадлежащей ядру. Пользовательские процессы, как правило,

    не имеют возможности доступа к этим объектам напрямую. Для того чтобы пользовательский процесс мог оперировать такими объектами, введено понятие описатель (handle) объекта ядра. Так, например, объектами ядра являются файлы, процессы, потоки, события, почтовые ящики и многое другое.

    Все созданные описатели объектов ядра должны удаляться с помощью функции

    BOOL CloseHandle( HANDLE hKernelObject )

    которая уменьшает счетчик использования объекта и уничтожает его, когда на объект никто больше не ссылается.

    Доступ к защищаемым объектам в Windows задается так называемыми дескрипторами безопасности (Security Descriptor). Дескриптор содержит информацию о владельце объекта и первичной группе пользователей и два списка управления доступом (ACL, Access Control List): один список задает разрешения доступа, другой - необходимость аудита при доступе к объекту. Список содержит записи, указывающие права выполнения действий, и запреты, назначенные конкретным пользователям и группам. При доступе к защищаемым объектам для начала проверяются запреты - если для данного пользователя и группы имеется запрет доступа, то дальнейшая проверка не выполняется и попытка доступа отклоняется. Если запретов нет, то проверяются права доступа - при отсутствии разрешений доступ отклоняется. Запрет обладает более высоким "приоритетом", чем наличие разрешений - это позволяет разрешить доступ, к примеру, целой группе пользователей и выборочно запретить некоторым ее членам.

    Объект, осуществляющий доступ (выполняющийся поток), обладает так называемым маркером доступа (access token). Маркер идентифицирует пользователя, от имени которого предпринимается попытка доступа, а также его привилегии и умолчания (например, стандартный ACL объектов, создаваемых этим пользователем). В Windows маркерами доступа обладают как потоки, так и процессы. С процессом связан так называемый первичный маркер доступа, который используется при создании потоков, а вот в дальнейшем поток может работать от имени какого-либо иного пользователя, используя собственный маркер воплощения (impersonation).

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

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

    Для создания большинства объектов ядра используются функции, начинающиеся на слово "Create" и возвращающие описатель созданного объекта, например функции CreateFile, CreateProcess, CreateEvent и т.д. Многие объекты при их создании могут получить собственное имя или остаться неименованными.

    Любой процесс или поток может ссылаться на объекты ядра, созданные другим процессом или потоком. Для этого предусмотрено три механизма:

  • Объекты могут быть унаследованы дочерним процессом при его создании. В этом случае объекты ядра должны быть "наследуемыми", и родительский процесс должен принять меры к тому, чтобы потомок мог узнать их описатели. Возможность передавать описатель потомкам по наследованию явно указывается в большинстве функций, так или иначе создающих объекты ядра (обычно такие функции содержат аргумент " BOOL bInheritHandle ", который указывает возможность наследования).
  • Объект может иметь собственное уникальное имя - тогда можно получить описатель этого объекта по его имени. Для разных типов объектов Win32 API предоставляет набор функций, начинающийся на Open... например, OpenMutex, OpenEvent и т.д.
  • Процесс-владелец объекта может передать его описатель любому другому процессу. Для этого процесс-владелец объекта должен получить специальный описатель объекта для "экспорта" в указанный процесс. В Win32 API для этого предназначена функция 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, /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;
    }

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

    Страницы:

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

    Используемые в операционной системе средства реализации многозадачности можно разделить на несколько групп:

  • Взаимодействие с устройствами.

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

  • Средства управления потоками и волокнами.

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

    В 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 для реализации асинхронного ввода-вывода предусмотрены функции типа ReadFile, WriteFile, ReadFileEx, WriteFileEx и др. и специальная структура OVERLAPPED, которая используется для взаимодействия с асинхронной операцией. Асинхронные операции применяются следующим образом: перед началом операции заполняется структура OVERLAPPED и вызывается нужная функция для выполнения ввода-вывода, которая ставит операцию в очередь и немедленно возвращает управление вызвавшей программе. После этого программа продолжает свою работу независимо от хода выполнения операции ввода-вывода. При необходимости можно выяснить состояние асинхронной операции, дождаться ее завершения или отменить ее, не дожидаясь завершения. Для этого предназначен специальный набор функций, например, 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 для ожидания завершения операции ввода-вывода. Объект "событие" можно не создавать - в этом случае функция будет ожидать освобождения объекта "файл"; однако в случае нескольких, накладывающихся друг на друга, асинхронных операций будет непонятно, какая именно операция завершилась, и использование специфичных для каждой операции событий снимает эту проблему.

    Ожидание на объектах ядра является наиболее экономным, но реакция приложения на завершение ввода-вывода связана с работой планировщика, поэтому для достижения малых задержек иногда надо дополнительно повышать приоритеты потоков, переходящих в режим ожидания завершения ввода-вывода.

  • Выполнение асинхронных операций с использованием функций завершения операций ввода-вывода. Эта функция будет автоматически вызвана после завершения ввода-вывода и может выполнить некоторые специальные действия. Процедура завершения обязательно вызывается в контексте того потока, который вызвал операцию ввода-вывода, - а для этого необходимо, чтобы поток был приостановлен, так как операционная система не должна прерывать работу активного потока. Следует перевести поток в состояние ожидания оповещения (alertable waiting) - при этом он не выполняется и может быть прерван для обработки процедуры завершения:
    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
    ) {
      ...
    }

    Этот подход наиболее трудоемок и наименее распространен; на практике самым эффективным является механизм выполнения асинхронных операций с ожиданием на объектах ядра. Однако механизм вызова функций завершения (расширенный возможностью автоматического выбора потока, осуществляющего обработку функции завершения) послужил основой для реализации одного из очень эффективных механизмов взаимодействия потоков - порта завершения ввода-вывода.

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

    Асинхронный ввод-вывод является примером мультипроцессирования с использованием функционально различных устройств.

    Асинхронные вызовы процедур

    Для реализации асинхронного ввода-вывода в операционной системе предусмотрен специальный механизм, основанный на так называемых асинхронных вызовах процедур (Аsynchronous Procedure Call, APC). Это один из базовых механизмов, необходимый для нормального функционирования операционной системы.

    Практика показала, что такой механизм был бы эффективен и для реализации самих приложений. Более того, для реализации асинхронного ввода-вывода с поддержкой функции завершения система уже обязана была предоставить этот механизм. Для реализации этого механизма операционная система ведет списки процедур, которые она должна вызывать в контексте данного потока, с тем ограничением, что прерывать работу занятого потока в произвольный момент времени система не должна. Поэтому для обслуживания накопившихся в очереди процедур необходимо перевести поток в специальное состояние ожидания оповещения (alertable waiting) - для этого Win32 API предусматривает специальный набор функций: например, SleepEx, WaitForSingleObjectEx и др.

    Здесь и во многих примерах далее, чтобы не загромождать код примера, опущена проверка ошибок:

    VOID CALLBACK ApcProc( ULONG_PTR dwData )
    {
      /* ... */
    }
    int main( void )
    {
      QueueUserAPC( ApcProc, GetCurrentThread(), 0 ); 
      /* ... */
      SleepEx( 1000, TRUE );
      return 0;
    }

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

    Процессы, потоки и объекты ядра

    Обсуждение реализации мультипрограммирования в операционной системе неизбежно приводит к обсуждению вопросов безопасности.

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

    Для того чтобы ядро операционной системы могло контролировать доступ к тем или иным объектам, сами объекты должны управляться ядром системы. Это приводит к понятию объектов ядра (kernel objects), которые создаются по запросу процессов ядром системы, управляются ядром, и доступ к которым также контролируется ядром системы.

    Объекты ядра

    Объекты ядра представлены в качестве некоторых структур и типов данных, управляемых ядром операционной системы и размещенных в памяти, принадлежащей ядру. Пользовательские процессы, как правило,

    не имеют возможности доступа к этим объектам напрямую. Для того чтобы пользовательский процесс мог оперировать такими объектами, введено понятие описатель (handle) объекта ядра. Так, например, объектами ядра являются файлы, процессы, потоки, события, почтовые ящики и многое другое.

    Все созданные описатели объектов ядра должны удаляться с помощью функции

    BOOL CloseHandle( HANDLE hKernelObject )

    которая уменьшает счетчик использования объекта и уничтожает его, когда на объект никто больше не ссылается.

    Доступ к защищаемым объектам в Windows задается так называемыми дескрипторами безопасности (Security Descriptor). Дескриптор содержит информацию о владельце объекта и первичной группе пользователей и два списка управления доступом (ACL, Access Control List): один список задает разрешения доступа, другой - необходимость аудита при доступе к объекту. Список содержит записи, указывающие права выполнения действий, и запреты, назначенные конкретным пользователям и группам. При доступе к защищаемым объектам для начала проверяются запреты - если для данного пользователя и группы имеется запрет доступа, то дальнейшая проверка не выполняется и попытка доступа отклоняется. Если запретов нет, то проверяются права доступа - при отсутствии разрешений доступ отклоняется. Запрет обладает более высоким "приоритетом", чем наличие разрешений - это позволяет разрешить доступ, к примеру, целой группе пользователей и выборочно запретить некоторым ее членам.

    Объект, осуществляющий доступ (выполняющийся поток), обладает так называемым маркером доступа (access token). Маркер идентифицирует пользователя, от имени которого предпринимается попытка доступа, а также его привилегии и умолчания (например, стандартный ACL объектов, создаваемых этим пользователем). В Windows маркерами доступа обладают как потоки, так и процессы. С процессом связан так называемый первичный маркер доступа, который используется при создании потоков, а вот в дальнейшем поток может работать от имени какого-либо иного пользователя, используя собственный маркер воплощения (impersonation).

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

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

    Для создания большинства объектов ядра используются функции, начинающиеся на слово "Create" и возвращающие описатель созданного объекта, например функции CreateFile, CreateProcess, CreateEvent и т.д. Многие объекты при их создании могут получить собственное имя или остаться неименованными.

    Любой процесс или поток может ссылаться на объекты ядра, созданные другим процессом или потоком. Для этого предусмотрено три механизма:

  • Объекты могут быть унаследованы дочерним процессом при его создании. В этом случае объекты ядра должны быть "наследуемыми", и родительский процесс должен принять меры к тому, чтобы потомок мог узнать их описатели. Возможность передавать описатель потомкам по наследованию явно указывается в большинстве функций, так или иначе создающих объекты ядра (обычно такие функции содержат аргумент " BOOL bInheritHandle ", который указывает возможность наследования).
  • Объект может иметь собственное уникальное имя - тогда можно получить описатель этого объекта по его имени. Для разных типов объектов Win32 API предоставляет набор функций, начинающийся на Open... например, OpenMutex, OpenEvent и т.д.
  • Процесс-владелец объекта может передать его описатель любому другому процессу. Для этого процесс-владелец объекта должен получить специальный описатель объекта для "экспорта" в указанный процесс. В Win32 API для этого предназначена функция 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, /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;
    }

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

    Вернуться к учебному плану