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

Представим многопоточное приложение, которое в своем коде использует глобальные или статические переменные. Трудно представить? А вот и нет. Наглядным примером такой ситуации может служить функция strtok стандартной библиотеки C++. Точнее, в качестве такого примера она могла выступить раньше, сейчас ее уже переписали и сделали «правильной». Но не это сейчас важно. Главное то, что при первом вызове функция strtok запоминала указатель на строку, передаваемый ей в свою собственную статическую переменную. Вполне вероятна была ситуация, что эту функцию практически одновременно могли вызвать сразу два потока, вследствие чего указатель на строку успешно менялся и один из потоков получал доступ к неправильным данным.

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

 

Глобальные переменные в многопоточном приложении

// глобальные переменные
int tls_i;
char tls_char[25];

// Потоковая функция
DWORD WINAPI ThreadFunc( LPVOID lpParam )
{
// использовать глобальные переменные в потоках — плохая идея
tls_i = (int)lpParam;
lstrcpy(tls_char,"array of char");
char szMsg[80];

wsprintf( szMsg, "Parameter = %d.", tls_i );
MessageBox( NULL, szMsg, "ThreadFunc", MB_OK );

return 0;
}

int APIENTRY WinMain(HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPSTR lpCmdLine,
intnCmdShow)
{
DWORD dwThreadId;

CreateThread(NULL, 0, ThreadFunc, (LPVOID)1, 0, &dwThreadId);
CreateThread(NULL, 0, ThreadFunc, (LPVOID)2, 0, &dwThreadId);

Sleep(10000); // Наслаждаемся результатом 10 секунд

return 0;
}

Мы создаем два потока, в которых работает один и тот же код. Этот код обращается к глобальным переменным с операциями чтения/записи. Такой подход совершенно небезопасен и может быть реализован только абсолютным новичком в программировании или полным дилетантом. Если от многопоточности никуда не деться, а глобальные переменные очень нужны, и их нельзя ничем заменить, то в этом случае нам на помощь придет локальная память потока, или thread-local storage (TLS).

 

Что такое thread-local storage?

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

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

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

 

Динамическая локальная память потока

Динамический thread-local storage реализован в Windows с помощью системных API. Их всего четыре: TlsAlloc, TlsGetValue, TlsSetValue и TlsFree. Но прежде чем познакомиться с ними поближе, нужно изучить, как устроена динамическая TLS изнутри.

Каждый флаг выполняемого в системе процесса может находиться в состоянии FREE или INUSE, указывая, свободна или занята данная область локальной памяти потока. Значение TLS_MINIMUM_AVAILABLE изменяется в зависимости от версии ОС. Например, в Windows 98/Me это число было равно 80, а в Windows 2000/XP – уже 1088. С каждым потоком сопоставлен массив длиной TLS_MINIMUM_AVAILABLE с элементами типа PVOID.

Функция TlsAlloc служит для резервирования блока в массиве принадлежащему вызвавшему ее потоку. Грубо говоря, она ищет ячейку с флагом FREE и возвращает ее индекс. Прототип TlsAlloc выглядит так: DWORD WINAPI TlsAlloc(void). Если функция завершилась неудачей, то возвращается TLS_OUT_OF_INDEXES.

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

 

Прототип функции TlsSetValue

BOOL WINAPI TlsSetValue(
__in DWORD dwTlsIndex,
__in LPVOID lpTlsValue
);

В отличие от предыдущей, эта функция TlsGetValue возвращает значение, содержащееся в ячейке массива с заданным индексом. Ее описание выглядит так: PVOID TlsGetValue(DWORD dwTlsIndex). Как и TlsSetValue, TlsGetValue в параметре dwTlsIndex принимает значение, полученное от TlsAlloc.

Ну и наконец, функция TlsFree. Единственным ее параметром, о чем несложно догадаться, является индекс, полученный в результате вызова TlsAlloc. API освобождает зарезервированный блок, занятый ранее.

 

Использование динамической TLS

Теперь давай попробуем воспользоваться полученными знаниями и изменим программу, код которой был приведен в начале статьи. Для этого в функции WinMain мы два раза вызовем TlsAlloc, тем самым зарезервировав в локальной памяти потока две ячейки под переменные типа PVOID. Затем мы создаем два треда, каждый из которых будет обращаться к своей ячейке в TLS массиве, и, следовательно, выводить свое сообщение на экран. По завершению программы мы освободим занятую нами память с помощью вызова TlsFree.

 

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

// TLS индексы
DWORD tls_i;
DWORD tls_char;

// Потоковая функция
DWORD WINAPI ThreadFunc( LPVOID lpParam )
{
TlsSetValue(tls_i, lpParam);

char *char_buf = new char[25];
lstrcpy(char_buf,"array of char");
TlsSetValue(tls_char, char_buf);

char szMsg[80];

int i = TlsGetValue(tls_i);
wsprintf( szMsg, "Parameter = %d.", i );
MessageBox( NULL, szMsg, "ThreadFunc", MB_OK );
delete[] char_buf;

return 0;
}

int APIENTRY WinMain(HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPSTR lpCmdLine,
intnCmdShow)
{
DWORD dwThreadId;

tls_i = TlsAlloc();
tls_char = TlsAlloc();

CreateThread(NULL, 0, ThreadFunc, (LPVOID)1, 0, &dwThreadId);
CreateThread(NULL, 0, ThreadFunc, (LPVOID)2, 0, &dwThreadId);

Sleep(10000); // Наслаждаемся результатом 10 секунд

TlsFree(tls_i);
TlsFree(tls_char);

return 0;
}

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

 

Статическая thread-local storage

Главное отличие между статической и динамической TLS состоит в простоте использования первой – достаточно лишь использовать специальную директиву компилятора. Основную работу с thread-local storage берет на себя операционная система. Линкер генерирует специальные структуры в PE-файле, а также секцию с именем .tls (как правило), в которых хранятся все нужные данные для того, чтобы загрузчик модуля правильно инициализировал локальную память потоков.

Производительность при использовании статической TLS, конечно, страдает, но зато выигрывает программист. Не надо больше выделять блоки памяти и затем их освобождать, не надо вызывать специальные API для чтения и записи в thread-local storage, все делается нативными средствами языка C++.

Давай еще раз подправим нашу тестовую программу и попробуем воспользоваться механизмом статической локальной памяти потока.

 

Использование статической TLS в многопоточном приложении

// TLS-переменные
__declspec( thread ) int tls_i;
__declspec( thread ) char tls_char[25];

// Потоковая функция
DWORD WINAPI ThreadFunc( LPVOID lpParam )
{
tls_i = (int)lpParam;
lstrcpy(tls_char,"array of char");
char szMsg[80];

wsprintf( szMsg, "Parameter = %d.", tls_i );
MessageBox( NULL, szMsg, "ThreadFunc", MB_OK );

return 0;
}

int APIENTRY WinMain(HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPSTR lpCmdLine,
intnCmdShow)
{
DWORD dwThreadId;

CreateThread(NULL, 0, ThreadFunc, (LPVOID)1, 0, &dwThreadId);
CreateThread(NULL, 0, ThreadFunc, (LPVOID)2, 0, &dwThreadId);

Sleep(10000); // Наслаждаемся результатом 10 секунд

return 0;
}

Используя директиву __declspec( thread ), мы объявили две TLS-переменные. Код приложения делает в точности все то же самое, что и в прошлом примере, с той лишь разницей, что его реализация получилась значительно проще за счет отказа от WinAPI.

Однако, тут следует обратить внимание на одну маленькую особенность. Переменная tls_char – это не указатель на блок памяти из кучи, как код с динамической TLS, а целый массив с элементами типа CHAR. Мы помним, что размер локальной памяти потока ограничен (1088 блоков в Windows XP), и, объявляя tls_char как массив, мы занимаем сразу 25 ячеек thread-local storage. Это очень и очень плохо, так как программа может обращаться к dll, которые тоже, в свою очередь, используют TLS. В итоге может случиться так, что памяти на всех не хватит, и мы получим нерабочее приложение. Помещение в TLS указателя на память, а не самого блока памяти – гораздо более рациональное решение.

 

Заключение

Многопоточное программирование – очень тонкая штука, и механизмы TLS помогают нам быстрее адаптироваться в мире тредов и разделяемых ресурсов. Если в коде используются глобальные или статические переменные, то при переходе к многопоточности thread-local storage просто незаменима.

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии