Реализации POSIX Threads API
Так и живем, не пропустив ни дня,
И каждый день проходит словно дважды.
Стандарт POSIX допускает различные подходы к реализации многопоточности в рамках одного процесса. Возможны три основных подхода:
- В пользовательском адресном пространстве, когда нити в пределах процесса переключаются собственным планировщиком
- Реализация при помощи системных нитей, когда переключение между нитями осуществляется ядром, так же, как и переключение между процессами.
- Гибридная реализация, когда процессу выделяют некоторое количество системных нитей, но процесс имеет собственный планировщик в пользовательском адресном пространстве.
Как правило, при этом количество пользовательских нитей в процессе может превосходить количество системных нитей.
1. Пользовательские нити
Реализация планировщика в пользовательском адресном пространстве не представляет больших сложностей; наброски реализаций таких планировщиков приводятся во многих учебниках по операционным системам, в том числе в [Иртегов 2002]. Учебная многозадачная ОС Minix может быть собрана и запущена в виде задачи под обычной Unix-системой; при этом процессы Minix будут с точки зрения ядра системы-хозяина пользовательскими нитями. Главным достоинством пользовательского планировщика считается тот факт, что он может быть реализован без изменений ядра системы.
При практическом применении такого планировщика, однако, возникает серьезная проблема. Если какая-то из нитей процесса исполняет блокирующийся системный вызов, блокируется весь процесс. Устранение этой проблемы требует серьезных изменений в механизме взаимодействия диспетчера системных вызовов с планировщиком операционной системы. То есть главное достоинство пользовательского планировщика при этом будет утеряно. Другим недостатком пользовательских нитей является то, что они не могут воспользоваться несколькими процессорами на многопроцессорной машине – ведь процесс всегда планируется только на одном процессоре!
Наиболее известная реализация пользовательских нитей – это волокна (fibers) в Win32. Считается, что волокна дешевле системных нитей Win32, хотя данных, подтверждающих это утверждение практическими измерениями, у меня нет. Волокна могут использоваться совместно с системными нитями Win32 , но при этом волокна привязаны к определенной нити и исполняются только в контексте этой нити. Волокна не должны исполнять блокирующиеся системные вызовы; попытка сделать это приведет к блокировке нити. Некоторые системные вызовы Win32 имеют неблокирующиеся аналоги, предназначенные для использования в волокнах, но далеко не все. Это резко ограничивает применение волокон в реальных приложениях.
2. Системные нити
Ядро типичной современной ОС уже имеет планировщик, способный переключать процессы. Переделка этого планировщика для того, чтобы он мог переключать несколько нитей в пределах одного процесса, также не представляет больших сложностей. При этом возможны два подхода к такой переделке.
В рамках первого подхода, системные нити выступают как подчиненная по отношению к процессу сущность. Идентификатор нити состоит из идентификатора родительского процесса и собственного идентификатора нити. Идентификатор нити локален по отношению к процессу, т.е. нити разных процессов могут иметь одинаковые идентификаторы. Такой подход реализует большинство систем, реализующих системные нити – IBM MVS-OS/390-zOS, DEC VAX/VMS- OpenVMS, OS/2, Win32, многие Unix-системы, в том числе и Solaris. В Solaris и других Unix системах (IBM AIX, HP/UX) системные нити называются LWP (Light-Weight Process, "легкие процессы").
Solaris 10 использует системные нити, так что каждой нити POSIX Threads API соответствует собственный LWP. Старые версии Solaris использовали гибридный подход, который рассматривается в следующем разделе.
В рамках другого подхода, системные нити являются сущностями того же уровня, что процесс. Иногда все, что объединяет нити одного процесса – это общее адресное пространство. Наиболее известная ОС, использующая такой подход – Linux. В Linux, нити выглядят как отдельные записи в таблице процессов и отдельные строки в выводе команд top(1) и ps(1), имеют собственный идентификатор процесса.
В старых версиях Linux это приводило к своеобразным проблемам при реализации POSIX Threads API; так, в большинстве Unix-систем завершение процесса системным вызовом exit(2) приводит к немедленному завершению всех его нитей; в Linux вплоть до 2.4 завершалась только текущая нить. В Linux 2.6 был внесен ряд изменений в ядро, приблизивших семантику многопоточности к стандарту POSIX. Эти изменения известны как NPT (Native POSIX Threads).
Наш курс рассчитан на стандартную семантику POSIX Threads API. При программировании для старых (2.4 и младше) версий ядра Linux необходимо изучить особенности поведения этих систем по документации, поставляющейся с системой, или по другим источникам.
3. Гибридная реализация
В гибридной реализации многопоточный процесс имеет несколько LWP и планировщик в пользовательском адресном пространстве. Этот планировщик переключает пользовательские нити между свободными LWP, подобно тому, как системный планировщик в многопроцессорной системе переключает процессы и системные нити между свободными процессами. При этом, как правило, процесс имеет больше пользовательских нитей, чем у него есть LWP.
Причина, по которой этот подход нашел практическое применение – это убеждение разработчиков первых многопоточных версий Unix, что пользовательские нити дешевле системных, требуют меньше ресурсов для своего исполнения.
При планировании пользовательских нитей возникает проблема блокирующихся системных вызовов. Когда какая-то нить вызывает блокирующийся системный вызов, соответствующий LWP блокируется и на некоторое время выпадает из работы. В старых версиях Solaris эта проблема решалась следующим образом: многопоточная библиотека всегда имела выделенную нить, которая не вызывала блокирующихся системных вызовов никогда. Когда ядро системы обнаруживало, что все LWP процесса заблокированы, оно посылало процессу сигнал SIGWAITING. Библиотечная нить перехватывала этот сигнал и, если это допускалось настройками библиотеки, создавала новый LWP.
Таким образом, если все пользовательские нити исполняли блокирующиеся системные вызовы, то количество LWP могло сравняться с количеством пользовательских нитей. Можно предположить, что компания Sun отказалась от гибридной реализации многопоточности именно потому, что обнаружилось, что такое происходит со многими реальными прикладными программами.
В старых версиях Solaris поддерживался довольно сложный API, позволявший управлять количеством LWP и политикой планирования нитей между ними. Так, можно было привязать нить к определенному LWP. Этот API был частью Solaris Native Threads и нестандартным расширением POSIX Threads API. В рамках данного курса этот API не изучается. Многие современные Unix-системы, в том числе IBM AIX, HP/UX, SCO UnixWare используют гибридную реализацию POSIX Thread API.
4. Сборка приложений с POSIX Threads
Большинство систем, реализующих POSIX Threads, требуют сборки многопоточной программы с библиотекой libpthread.so или libpthread.a. Как правило, это достигается запуском компилятора с ключом -lpthread.
Большинство C и C++ компиляторов интерпретируют ключ -l следующим образом. К параметру ключа (у ключа -lpthread параметром является строка pthread) спереди добавляется строка lib, а сзади – строка .so или .a, в зависимости от того, какой режим сборки задан другими ключами – статический или динамический. Таким образом получается строка libpthread.so. Затем файл с таким именем ищется в каталогах, перечисленных в переменной среды LIBPATH. Если эта переменная не установлена, используются каталоги /lib, /usr/lib, и, возможно, некоторые другие каталоги, зашитые в компилятор.
Так, компилятор Sun Studio 11 при установке по умолчанию ищет дополнительные библиотеки в каталоге /opt/SUNWspro/lib.
Компилятор GCC, входящий в поставку Solaris 10, ищет дополнительные библиотеки в каталоге /usr/sfw/lib ; в действительности, при сборке GCC ему можно указать пути к дополнительным библиотекам; при сборке GCC из исходных текстов по умолчанию он настраивается на размещение драйвера компилятора (команд gcc и g++) в /usr/local/bin, а библиотек – в каталоге /usr/local/lib. Эти каталоги можно изменять параметрами скрипта configure, который необходимо запустить перед началом сборки компилятора.
В Solaris 10 ключ -lpthread использовать не обязательно – все функции POSIX Thread API включены в стандартную библиотеку языка C libc.so, которая подключается по умолчанию. Для совместимости со старыми сборочными скриптами в поставку Solaris 10 также включена пустая библиотека libpthread.so, содержащая ссылки на соответствующие функции в libc.so.
В некоторых дистрибутивах Linux, библиотека libstdc++.so (стандартная библиотека языка С++) содержит ссылки на функции libpthread.so. Чтобы облегчить сборку однопоточных программ с такой библиотекой, в библиотеку libstdc++.so были включены пустые функции, одноименные функциям POSIX Thread API. При сборке многопоточных программ с такой библиотекой необходимо обязательно указывать ключ -lpthread. Без этого ключа программа соберется (редактор связей не выдаст сообщений о неопределенных символах), но работать не будет (вызов функций POSIX Thread API приведет к ошибке сегментации).
Кроме того, многие компиляторы – в том числе и Sun Studio 11 – рекомендуют компилировать все модули, входящих в многопоточную программу, с ключом -mt. В старых системах это могло быть жизненно необходимо, так как в зависимости от наличия или отсутствия этого ключа компилятор мог подключать разные версии стандартной библиотеки времени исполнения. В Solaris 10 этот ключ отвечает только за определение некоторых препроцессорных символов. Впрочем, в следующем разделе мы увидим, что некоторые эти символы также важны при сборке некоторых программ.
Кроме того, ключ -mt может выключать некоторые оптимизации, опасные при многопоточном исполнении. Поэтому если компилятор поддерживает ключ -mt, рекомендуется его использовать как при компиляции, так и при сборке многопоточных программ. Компилятор GCC не поддерживает ключ -mt, вместо этого рекомендуется использовать ключ -threads или -pthread на тех платформах, где эти ключи поддерживаются. GCC, входящий в поставку Solaris 10, поддерживает ключи -threads и –pthread ; GCC из поставки Debian Sarge поддерживает только ключ -pthread.
Ниже приводится пример кода, который позволяет протестировать ваш компилятор и проверить наличие типичных препроцессорных символов, используемых в include-файлах стандартной библиотеки языка С для проверки того, однопоточная или многопоточная программа сейчас компилируется. _LIBC_REENTRANT используется в Linux, _REENTRANT в Solaris. На других платформах могут использоваться другие символы. Попробуйте собрать эту программу с разными ключами компилятора и проверить результат. Полезно также поискать соответствующие символы в файлах каталога /usr/include и посмотреть, какие именно конструкции они контролируют.
int main() { #if defined(_LIBC_REENTRANT) printf("_LIBC_REENTRANT defined as %d\n", _LIBC_REENTRANT); #endif #if defined(_REENTRANT) printf("_REENTRANT defined as %d\n", _REENTRANT); #endif #if defined(_TS_ERRNO) printf("_TS_ERRNO defined as %d\n", _TS_ERRNO); #endif #if defined(_POSIX_C_SOURCE) printf("_POSIX_C_SOURCE defined as %d\n", _POSIX_C_SOURCE); #endif }2.1.
5. Программы для экспериментов
Получите вывод препроцессора для программы примера 2.2 с различными ключами компиляции. Вывод препроцессора у большинства компиляторов С (в том числе у компиляторов Sun Studio 11 и GCC) генерируется ключом -E и выдается в stdout. Для того, чтобы перенаправить вывод препроцессора в файл, используйте переназначение ввода-вывода. Можно также использовать для просмотра вывода компилятора фильтры more(1) или less(1).
Напоминаю, что переменная errno хранит код ошибки последнего неудачно завершенного системного вызова. Код ошибки EMFILE означает исчерпание лимита дескрипторов файлов на процесс. Таким образом, функция open_with_wait пытается открыть файл и, если лимит дескрипторов исчерпан, блокирует нить в надежде, что какая-то другая нить освободит дескриптор. Функции pthread_cond_wait(3C), pthread_cond_signal(3C), pthread_mutex_lock(3C), pthread_mutex_unlock(3C) и используемые ими типы данных изучаются далее в нашем курсе.
Посмотрите, в какой код превращается обращение к переменной errno. Посмотрите, как этот код зависит от используемых ключей компиляции. Найдите в файле /usr/include/errno.h макроопределения, ответственные за эту замену. От каких предопределенных символов препроцессора они зависят?
Подумайте, к чему привело бы в многопоточной программе обращение к переменной errno как к обычной переменной. Можно ли, как это рекомендуется в некоторых старых руководствах по Unix, описывать переменную errno как extern int errno, или следует обязательно использовать определение из файла /usr/include/errno.h?
pthread_cond_t fileopen_cond=PTHREAD_COND_INITIALIZER; pthread_mutex_t fileopen_mutex=PTHREAD_MUTEX_INITIALIZER; int open_with_wait(const char *pathname, int flags, mode_t mode) { int code; pthread_mutex_lock(&fileopen_mutex); do { code=open(pathname, flags, mode); if (code < 0 && errno==EMFILE) { pthread_cond_wait(&fileopen_cond, &fileopen_mutex); } } while (code < 0 && errno==EMFILE); pthread_mutex_unlock(&fileopen_mutex); return code; } int close_with_wakeup(int handle) { int code; code=close(handle); pthread_cond_signal(&fileopen_cond); return code; }2.2.