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

Разработка параллельных приложений для ОС Windows

< Лекция 12 || Лекция 13: 12 || Лекция 14 >

Память, локальная для потоков и волокон

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

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

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

Для решения подобных задач в Windows предусмотрен механизм управления данными, локальными для потока (TLS память, Thread Local Storage). Система предоставляет небольшой специальный блок данных, ассоциированный с каждым потоком. В таком блоке возможно в общем случае хранение произвольных данных, однако, так как размеры этого блока крайне малы, то обычно там размещаются указатели на данные большего объема, выделяемые в приложении для каждого потока; в связи с этим ассоциированную с потоком память можно рассматривать как массив двойных слов или массив указателей.

ОС Windows предоставляет четыре функции, необходимые для работы с локальной для потока памятью. Функция DWORD TlsAlloc(void) выделяет в ассоциированной с потоком памяти двойное слово, индекс которого возвращается вызвавшей процедуре. Если ассоциированный массив полностью использован, возвращаемое значение будет равно TLS_OUT_OF_INDEXES, что сообщает об ошибке выделения ячейки. Функция TlsFree освобождает выделенную ячейку.

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

#include <process.h>
#include <windows.h>

#define THREADS 18
static DWORD dwTlsData;

void ProcA( int x )
{
  int  i;
  int  *iptr = (int*)TlsGetValue( dwTlsData );
  for ( i = 0; i < 100; i++ ) iptr[i] = x;
}

int ProcB( void )
{ 
  int i, x;
  int *iptr = (int*)TlsGetValue( dwTlsData );
  for ( i = x = 0; i < 100; i++ ) x += iptr[i];
  return x;
}

unsigned __stdcall ThreadProc( void *param )
{
  TlsSetValue( dwTlsData, (LPVOID)new int array[100] );
  /* выделенные потоком данные размещены в общей куче,
     используемой всеми потоками, однако указатель на
     эти данные известен только потоку-создателю, так
     как сохраняется в локальной для потока области */
  ProcA( (int)param );
  Sleep( 0 );
  if ( ProcB() != 100*(int)param ) { /* ОШИБКА!!! */ }
  delete[] (int*)TlsGetValue( dwTlsData );
  return 0; 
}
int main( void )
{
  HANDLE    hThread[ THREADS ];
  unsigned  dwThread;
  dwTlsData = TlsAlloc();
  /* создаем новые потоки */
  for ( i = 0; i < THREADS; i++ )
    hThread[i]=(HANDLE)_beginthreadex(
      NULL, 0, ThreadProc, (void*)i, 0, &dwThread
    );
  /* дождаться завершения созданных потоков */
  WaitForMultipleObjects( THREADS, hThread, TRUE, INFINITE );
  for ( i = 0; i < THREADS; i++ ) CloseHandle( hThread[i] );
  TlsFree( dwTlsData );
  return 0;
}

В приведенном примере в функции main выделяется ячейка в ассоциированном списке, индекс которой сохраняется в глобальной переменной dwTlsData, после чего потоки могут сохранять в этой ячейке свои данные.

В Visual Studio работа с локальной для потока памятью может быть упрощена при использовании _declspec(thread) при описании переменных. В этом случае компилятор будет размещать эти переменные в специальном сегменте данных ( _TLS ), который будет создаваться библиотекой времени исполнения и ссылки на который будут разрешаться с использованием ассоциированной с потоком памяти. Этот способ во многих случаях предпочтительнее явного управления локальной для потока памятью, так как независимо от числа модулей, использующих такой сегмент, будет задействован только один указатель в ассоциированной памяти (построитель объединит в один большой сегмент все _TLS сегменты модулей).

#include <process.h>
#include <windows.h>

#define THREADS 18
_ _declspec(thread) static int iptr[ 100 ];

void ProcA( int x )
{
  int  i;

  for ( i = 0; i < 100; i++ ) iptr[i] = x;
}
int ProcB( void )
{
  int  i, x;
  for ( i = x = 0; i < 100; i++ ) x += iptr[i];
  return x;
}

unsigned __stdcall ThreadProc( void *param )
{
  ProcA( (int)param );
  Sleep( 0 );
  if ( ProcB() != 100*(int)param ) { /* ОШИБКА!!! */ }
  return 0;
}

int main( void )
{
  HANDLE    	hThread[THREADS];
  unsigned  	dwThread;
  int     	i;
  /* создаем новые потоки */
  for ( i = 0; i < THREADS; i++ )
    hThread[i] = (HANDLE)_beginthreadex(
      NULL, 0, ThreadProc, (void*)i, 0, &dwThread
    );
  /* дождаться завершения созданных потоков */
  WaitForMultipleObjects( THREADS, hThread, TRUE, INFINITE );
  for ( i = 0; i < THREADS; i++ ) CloseHandle( hThread[i] );
  TlsFree( dwTlsData );
  return 0;
}

Следует внимательно следить за выделением и освобождением данных, указатели на которые сохраняются в TLS памяти (как в случае явного управления, так и при использовании _ _declspec(thread) ). Могут возникнуть две потенциально ошибочных ситуации:

  1. TLS память резервируется в то время, когда уже существуют потоки. Это возможно при явном управлении TLS памятью, и для существующих потоков будут зарезервированы ячейки, но придется предусмотреть специальные меры для их корректной инициализации или для исключения их использования до этого.
  2. Все случаи завершения потока. Если TLS память содержит какие-либо указатели, то сама TLS память будет освобождена, а вот те данные, указатели на которые хранились в TLS памяти, - нет. Необходимо специально отслеживать все возможные случаи завершения потоков, включая завершение по ошибке, и принимать меры для освобождения выделенной памяти. При использовании _ _declspec(thread) эта ситуация встречается реже, так как позволяет хранить в _TLS сегментах данные любого фиксированного размера.

Следует отметить еще один нюанс, связанный с использованием TLS памяти, волокон и оптимизации. В частных случаях волокна могут исполняться разными потоками - при этом одно и то же волокно должно иметь доступ к TLS памяти именно того потока, в котором оно в данный момент исполняется. А если компилятор генерирует оптимизированный код, то он может разместить указатель на данные TLS памяти в каком-либо регистре или временной переменной, что при переключении волокна на другой поток приведет к ошибке - будет использована TLS память предыдущего потока. Чтобы избежать такой ситуации, компилятору можно указать специальный ключ /GT, отключающий некоторые виды оптимизации при работе с TLS памятью. Это может потребоваться в крайне редких случаях - когда приложение использует несколько волокон, исполняемых в нескольких потоках, и при этом волокна должны использовать TLS память потоков.

Аналогично TLS памяти, Windows поддерживает память, локальную для волокон, - так называемую FLS память, или Fiber Local Storage. При этом FLS память не зависит от того, какой именно поток выполняет данную нить. Для работы с FLS памятью Windows предоставляет набор функций, аналогичный Tls -функциям, отличие заключается только в функции выделения ячейки FLS памяти:

DWORD FlsAlloc( PFLS_CALLBACK_FUNCTION lpCallback );

VOID WINAPI FlsCallback( PVOID lpFlsData )
{
  ...
}

Функция отличается от ее аналога TlsAlloc указателем на специальную необязательную процедуру FlsCallback, предоставляемую разработчиком. Эта процедура будет вызвана автоматически при освобождении ячейки FLS памяти (как при завершении волокна, так и при завершении потока или возникновении ошибки), и разработчик может легко предоставить средства для освобождения памяти, указатели на которую были сохранены в ячейках FLS памяти.

DWORD dwFlsID;
VOID WINAPI FlsCallback( PVOID lpFlsData )
{
  /* при завершении волокна или потока память будет освобождена */
  delete[] (int*)lpFlsData;
}
void initialize( void )
{
  dwFlsID = FlsAlloc( FlsCallback );
  ...
}
void fiberstart( void )
{
  FlsSetValue( dwFlsID, new int [ 100 ] );
  /* здесь мы можем не следить за освобождением выделенной памяти */
}

Остальные функции для работы с FLS аналогичны Tls-функциям как по описаниям, так и по применению.

Привязка к процессору и системы с неоднородным доступом к памяти

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

Привязка потоков к процессору задается с помощью специального битового вектора (affinity mask), сохраняемого в целочисленной переменной. Каждый бит этого вектора указывает на возможность исполнения потока на процессоре, номер которого совпадает с номером бита. Таким образом, заданием маски сродства можно ограничить множество процессоров, на которых будет выполняться данный поток. В Windows такие маски назначаются процессу (функции GetProcessAffinityMask и SetProcessAffinityMask ) и потоку (функция SetThreadAffinityMask ). Маска, назначаемая потоку, должна быть подмножеством маски процесса. Помимо ограничения множества процессоров, на которых может исполняться поток, может быть целесообразно назначить потоку самый "удобный" для него процессор (по умолчанию - тот, на котором поток был запущен первый раз). Для этого предназначена функция SetThreadIdealProcessor.

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

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