Московский государственный технический университет им. Н.Э. Баумана
Опубликован: 28.06.2006 | Доступ: свободный | Студентов: 12459 / 340 | Оценка: 4.54 / 3.83 | Длительность: 22:03:00
ISBN: 978-5-9556-0055-0
Лекция 12:

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

< Лекция 11 || Лекция 12: 1234 || Лекция 13 >
Описатели процесса и потока

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

В 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;
}

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

< Лекция 11 || Лекция 12: 1234 || Лекция 13 >
Анастасия Булинкова
Анастасия Булинкова
Рабочим названием платформы .NET было