Нити и стандартные библиотеки Unix
Копоть-сажу смыл под душем,
Съел холодного язя
И инструкции послушал -
Что там можно, что нельзя.
Там у них бывает лучше бытово,
Так чтоб я не отчубучил не того -
Он мне дал прочесть брошюру как наказ,
Чтоб не вздумал жить там сдуру, как у нас.
Что означает thread-safe?
Термин thread-safe не имеет общепринятого перевода на русский язык. Дословно это словосочетание следует переводить как "безопасный для использования в многопоточной программе". Чаще всего этот термин применяется к библиотечным функциям и библиотекам в целом.
Проблема состоит в том, что многие библиотеки используют те или иные внутренние переменные. Некоторые библиотеки - например malloc(3C)/free(3C) - используют довольно сложные внутренние структуры данных. Для нормальной работы библиотечных функций эти структуры данных должны удовлетворять определенным требованиям, то есть быть согласованными ( consistent ) или (что то же самое) целостными. При этом во время работы функции могут временно нарушать согласованность своих внутренних структур данных. При нормальной работе к моменту завершения функции согласованность восстанавливается.
Но если во время работы функции в одной нити другая нить вызовет функцию этой же библиотеки - не обязательно ту же самую, но полагающуюся на целостность той же внутренней структуры данных - скорее всего работа функции приведет к ошибкам. Для примера рассмотрим ситуацию параллельного вывода в стандартный поток вывода. Функция printf(3S) формирует текстовые данные в соответствии с заданным форматным спецификатором и помещает их в буфер вывода. При использовании длинных форматных спецификаторов и большого числа параметров printf(3S) работает долго. Если во время его работы другая нить тоже позовет printf(3S), то в лучшем случае данные, формируемые каждой из нитей, будут перемешаны в выходном буфере. В худшем случае вывод одной из нитей будет записан в буфер поверх вывода другой нити.
Для решения этой проблемы thread-safe реализация printf(3S) должна каким-то образом защищать себя от многократного (реентрантного) вызова. Для этого используются мутексы или блокировки чтения-записи, которые мы будем рассматривать на следующих лекциях.
Еще более сложная проблема возникает, если функция должна сохранять некоторые данные между своими вызовами. В качестве примера рассмотрим функцию strtok(3C). Функция strtok(3C) при первом вызове разбивает строку-параметр на токены в соответствии с заданными разделителями и возвращает первый токен. При последующих вызовах она возвращает второй, третий и т.д. токены. Для этого она должна где-то хранить указатель на текущий токен; однопоточные версии стандартной библиотеки языка C обычно используют для этого статическую переменную. Если в другой нити эта функция будет вызвана с другой начальной строкой, этот указатель будет перезаписан. Результат следующего вызова strtok(3C) в первой нити при этом будет сильно отличаться от того, что, скорее всего, ожидал программист, разрабатывавший программу этой нити. Чтобы защититься от возникающих при этом неприятностей, недостаточно защитить strtok(3C) от реентрантного вызова, нужно сделать что-то гораздо более сложное.
Один из вариантов (реально использованный в библиотеке Solaris 10) обсуждался на предыдущей лекции и состоит в использовании ThreadSpecificData. Другой вариант состоит в изменении API. Для strtok(3C) предлагается thread-safe версия strtok_r(3C) ; эта функция хранит указатель на следующий токен не во внутренней переменной, а в ячейке памяти, которую должна предоставить вызывающая функция. Уже на этих примерах видно, что задача обеспечения безопасности с точки зрения многопоточности - сложная задача, не имеющая универсального решения. Видно также, что проблемы с многопоточностью часто заложены уже на уровне описания семантики функций.
При разработке стандартной библиотеки языка C и многих других стандартных или просто широко распространенных библиотек традиционных Unix-систем требования многопоточности не принимались во внимание. В Solaris 10 большинство из этих библиотек переработаны для соответствия требованиям многопоточности, но такая переработка не завершена и не для всех функций выполнена в совершенстве. Чтобы программист мог знать, какие из функций можно использовать в многопоточной программе, какие - нельзя, а какие можно, но с ограничениями, в страницах системного руководства Solaris (man(1)) приводится информация о многопоточности. Эта информация приводится в секции АТРИБУТЫ ( ATTRIBUTES ) (см. пример 5.1).
ATTRIBUTES See attributes(5) for descriptions of the following attributes: ___________________________________________________________ | ATTRIBUTE TYPE | ATTRIBUTE VALUE | |_____________________________|_____________________________| | MT-Level | Unsafe | |_____________________________|_____________________________|5.1. Секция ATTRIBUTES страницы руководства getch(3CURSES)
Описание возможных атрибутов приводится в attributes(5). С точки зрения многопоточности, наиболее интересный атрибут - это MT-Level (уровень многопоточности). Допустимые уровни многопоточности перечислены далее. Функции, для которых MT-Level не указан, считаются Safe.
Unsafe - функция или набор функций используют незащищенные глобальные или статические данные. Вызывающая программа обязана теми или иными средствами гарантировать, что никакие функции из этого набора не будут одновременно вызваны из различных потоков. Если такая гарантия не будет выполнена, результаты непредсказуемы. Некоторые из Unsafe функций имеют реентерабельные аналоги. Обычно имена функций аналогов получаются добавлением суффикса _r (от reentrant или reenterable ) к имени функции.
Safe - функция или набор функций могут вызываться из нескольких потоков. Однако при этом возможны определенные ограничения. Так, открытие и закрытие файла через системные вызовы open(2) и close(2) воздействует на все нити процесса.
Закрытие файла, с которым работают другие нити, может приводить к нежелательным последствиям для этих нитей, хотя сам по себе системный вызов close(2) является thread-safe.
MT-Safe - функция или набор функций полностью подготовлены для работы в многопоточной среде. Библиотека защищает свои локальные данные (если они есть) при помощи средств взаимоисключения и обеспечивает разумный уровень параллелизма, т.е. не удерживает средства взаимоисключения дольше, чем это необходимо.
MT-Safe with Exceptions (безопасно с исключениями) - функция может быть небезопасна при некоторых вариантах использования. Информация о том, при каких именно сценариях использования функция может быть небезопасна, содержится в секциях руководства NOTES и/или USAGE.
Asynch-Signal-Safe - функция может вызываться в многопоточной программе из обработчиков сигналов. Проблема в том, что если функция обеспечивает уровень MT-Safe за счет использования примитивов взаимоисключения, то реентрантный вызов этой функции из обработчика сигнала может привести к мертвой блокировке. Существует ряд способов решения и обхода этой проблемы, но они довольно сложны и не всегда применимы, поэтому большинство MT-Safe функций не являются Asynch-Signal-Safe.
Fork-Safe - функция безопасна, даже если во время работы этой функции другая нить процесса вызовет fork(2). Проблемы, которые могут при этом возникать, и способы их решения обсуждаются далее на этой лекции.
Deferred-Cancel-safe - функция безопасна для использования в нитях, работающих в режиме отложенного прерывания ( PTHREAD_CANCEL_DEFERRED ). Asynchronous-Cancel-Safe - функция безопасна для использования в нитях, работающих в режиме асинхронного прерывания ( PTHREAD_CANCEL_ASYNCHRONOUS ). Подразумевает Deferred-Cancel-Safe.
Примеры thread-safe интерфейсов
Рассмотрим несколько примеров безопасных интерфейсов.
Readdir(3C) и readdir_r(3C)
Функция readdir(3C) возвращает очередную запись каталога файловой системы. Эта имеет проблему с многопоточностью, заложенную на уровне описания интерфейса. Она возвращает указатель на struct dirent. Чтобы упростить для вызывающей программы управление памятью, эта функция всегда возвращает указатель на одну и ту же структуру (внутренний буфер). Последующий вызов readdir(3C) перезапишет значение, возвращенное предыдущим вызовом. Поскольку readdir(3C) обычно используется для последовательного сканирования каталога, это поведение в большинстве случаев удовлетворительно. Но оно совершенно неприемлемо для многопоточных программ.
Для обхода этой проблемы была предложена реентерабельная версия readdir(3C) - readdir_r(3C). Эта функция получает указатель на буфер, в котором следует разместить описание записи каталога.
Интерфейс readdir_r(3C) на первый взгляд кажется простым и логичным, однако при его использовании важно знать о существовании одной проблемы. Проблема эта состоит в том, что struct dirent - структура переменного размера. Буфер, размер которого равен sizeof(struct dirent), недостаточен для размещения структуры данных, которая будет возвращена вызовом readdir_r(3C).
Страница руководства readdir_r(3C) рекомендует вычислять размер буфера по формуле sizeof(struct dirent)+pathconf(directory, _PC_NAME_MAX). Системный вызов pathconf(2) с параметром _PC_NAME_MAX возвращает размер максимального допустимого имени файла в файловой системе, в которой размещен каталог directory. Получение этой информации при помощи системного вызова дает определенные преимущества; в частности, это позволяет избежать проблем, которые могут возникнуть в будущих версиях операционной системы, которые могут поддерживать файловые системы с большой длиной имени файла.
Однако данный способ сопряжен и с определенными недостатками. Наиболее очевидный недостаток состоит в том, что вычисление размера структуры во время исполнения лишает программиста возможности размещать структуру в статическом буфере и вынуждает использовать malloc(3C), alloca(3C) или динамические массивы C99.
Менее очевидный, но более серьезный недостаток, состоит в том, что readdir_r(3C) не знает длины вашего буфера. Таким образом, если вы неверно вычислите длину буфера, это может привести к срыву буфера. Наиболее вероятный сценарий такой ошибки происходит, если программист предполагает, что все подкаталоги каталога directory имеют NAME_MAX, равную pathconf(directory, _PC_NAME_MAX). Однако если один из подкаталогов является символической ссылкой или точкой монтирования, это может быть неверно.
Произвольный доступ к файлу
Рассмотрим еще один источник проблем с многопоточностью, присутствующий в традиционном API Unix-систем. Системные вызовы read(2) и write(2) при работе с регулярными файлами и некоторыми устройствами используют понятие текущей позиции. Эти системные вызовы начинают чтение и запись с текущей позиции и переставляют текущую позицию на конец прочитанного или записанного участка файла.
Кроме того, текущую позицию можно переставлять системным вызовом lseek(2).
Очевидно, что последовательность lseek(..) ; write(..) ; не может быть thread-safe, да и с безопасностью самих вызовов read(2) и write(2) не все просто.
Для решения этой проблемы POSIX Thread API предоставляет вызовы pread(2) и pwrite(2).
В отличие от традиционных вызовов read(2) и write(2), эти вызовы имеют четвертый параметр типа off_t. Вызов pread(file, buffer, size, offset) ; можно рассматривать как атомарно исполняющуюся последовательность вызовов lseek(file, offset, SEEK_SET); read(file, buffer, size);.
При использовании pread(2) и pwrite(2) с устройствами и псевдоустройствами (например, трубами или сокетами), которые не поддерживают lseek, четвертый параметр игнорируется.