Интерлюдия: Thread API
В этой главе кратко рассматриваются основные части API потоков. Каждая часть будет объяснена далее в последующих главах, поскольку мы покажем, как использовать API. Более подробную информацию можно найти в различных книгах и онлайн-источниках [B89, B97, B+96, K+96]. Мы должны отметить, что в последующих главах понятия блокировок и переменных условий вводятся медленнее, со многими примерами; поэтому эту главу лучше использовать в качестве справочной.
СУТЬ: КАК СОЗДАВАТЬ ПОТОКИ И УПРАВЛЯТЬ ИМИ
Какие интерфейсы должна представлять ОС для создания потоков и управления ими? Как должны быть спроектированы эти интерфейсы, чтобы обеспечить простоту использования, а также полезность?
27.1 Создание потока
Первое, что вы должны уметь делать, чтобы написать многопоточную программу - это создавать новые потоки, и, следовательно, должен существовать какой-то интерфейс создания потоков. В POSIX это просто:
Это объявление может показаться немного сложным (особенно если вы не использовали указатели на функции в C), но на самом деле это не так уж плохо. Существует четыре аргумента: thread, attr, start routine и arg. Первый, thread, является указателем на структуру типа pthread_t; мы будем использовать эту структуру для взаимодействия с этим потоком, и поэтому нам нужно передать ее в pthread_create(), чтобы инициализировать.
Второй аргумент, attr, используется для указания любых атрибутов, которые могут быть у этого потока. Некоторые примеры включают установку размера стека или, возможно, информацию о приоритете планирования потока. Атрибут инициализируется отдельным вызовом pthread_attr_init(); подробности см. на странице руководства. Однако в большинстве случаев значений по умолчанию будет достаточно; в этом случае мы просто передадим значение NULL.
Третий аргумент является самым сложным, но на самом деле он просто спрашивает: в какой функции должен запускаться этот поток? В C мы называем это указателем на функцию (function pointer), и это говорит нам, что ожидается следующее: имя функции (процедура запуска или start routine), которому передается один аргумент типа void * (как указано в скобках после процедуры запуска), и который возвращает значение типа void * (т. е. указатель на пустоту void pointer).
Если бы для этой процедуры вместо указателя void требовался целочисленный аргумент, объявление выглядело бы так:
Если бы вместо этого подпрограмма приняла указатель void в качестве аргумента, но вернула целое число, это выглядело бы так:
Наконец, четвертый аргумент, arg, является именно тем аргументом, который должен быть передан функции, в которой поток начинает выполнение. Вы можете спросить: зачем нам нужны эти указатели на пустоту? Ну, ответ довольно прост: наличие указателя void в качестве аргумента для процедуры запуска функции позволяет нам передавать любой тип аргумента; наличие его в качестве возвращаемого значения позволяет потоку возвращать любой тип результата.
Давайте рассмотрим пример на рисунке 27.1. Здесь мы просто создаем поток, которому передаются два аргумента, упакованные в один тип, который мы определяем сами (myarg_t). Поток, однажды созданный, может просто привести свой аргумент к ожидаемому типу и, таким образом, распаковать аргументы по желанию.
И вот оно! Как только вы создадите поток, у вас действительно появится еще один живой исполняющий объект со своим собственным стеком вызовов, работающий в том же адресном пространстве, что и все существующие в настоящее время потоки в программе. Таким образом, начинается самое интересное!
27.2 Завершение потока
В приведенном выше примере показано, как создать поток. Однако что произойдет, если вы захотите дождаться завершения потока? Вам нужно сделать что-то особенное, чтобы дождаться завершения; в частности, вы должны вызвать процедуру pthread_join().
Эта процедура принимает два аргумента. Первый имеет тип pthread_t и используется для указания на то, какой поток следует ожидать. Эта переменная инициализируется процедурой создания потока (когда вы передаете указатель на нее в качестве аргумента pthread_create()); если вы сохраните ее, вы можете использовать ее для ожидания завершения этого потока.
Второй аргумент - это указатель на возвращаемое значение, которое вы ожидаете получить обратно. Поскольку процедура может возвращать что угодно, она определена для возврата указателя на void; поскольку процедура pthread_join() изменяет значение переданного аргумента, вам необходимо передать указатель на это значение, а не только само значение.
Давайте рассмотрим другой пример (рис. 27.2). В коде снова создается один поток и передается пара аргументов через структуру myarg_t. Для возврата значений используется тип myret_t. Как только поток завершит выполнение, основной поток, который ожидал внутри процедуры pthread_join()* вернется, и мы сможем получить доступ к значениям, возвращенным из потока, а именно к тому, что находится в myret_t.
*Обратите внимание, что здесь мы используем функции-обёртки; в частности, мы вызываем Malloc(), Pthread_join() и Pthread_create(), которые просто вызывают свои версии с аналогичными именами в нижнем регистре и следят за тем, чтобы процедуры не возвращали ничего неожиданного.
Несколько вещей, которые следует отметить в этом примере. Во-первых, часто нам не нужно заниматься всей этой болезненной упаковкой и распаковкой аргументов. Например, если мы просто создадим поток без аргументов, мы можем передать значение NULL в качестве аргумента при создании потока. Аналогично, мы можем передать NULL в pthread_join(), если нас не волнует возвращаемое значение.
Во-вторых, если мы просто передаем одно значение (например, long long int), нам не нужно упаковывать его в качестве аргумента. На рисунке 27.3 показан пример. В этом случае жизнь немного проще, так как нам не нужно упаковывать аргументы и возвращать значения внутри структур.
В-третьих, мы должны отметить, что нужно быть предельно осторожным с тем, как значения возвращаются из потока. В частности, никогда не возвращайте указатель, который ссылается на что-то, выделенное в стеке вызовов потока. Если вы это сделаете, как вы думаете, что произойдет? (подумайте об этом!) Вот пример опасного фрагмента кода, измененного по сравнению с примером на рисунке 27.2.
В этом случае переменная oops выделяется в стеке mythread. Однако, когда он возвращается, значение автоматически освобождается (в конце концов, именно поэтому стек так прост в использовании!), и, таким образом, передача указателя на освобожденную переменную приведет к всевозможным плохим результатам. Конечно, когда вы распечатаете значения, которые, по вашему мнению, вы вернули, вы, вероятно (но не обязательно!) будете удивлены. Попробуйте и убедитесь в этом сами*!
*К счастью, компилятор gcc, скорее всего, будет жаловаться, когда вы пишете подобный код, что является еще одной причиной обращать внимание на предупреждения компилятора.
Наконец, вы можете заметить, что использование pthread_create() для создания потока с последующим немедленным вызовом pthread_join() - довольно странный способ создания потока. На самом деле, есть более простой способ выполнить именно эту задачу; он называется вызовом процедуры (procedure call). Очевидно, что обычно мы создаем более одного потока и ждем его завершения, иначе в использовании потоков вообще нет особой цели.
Мы должны отметить, что не весь многопоточный код использует join routine. Например, многопоточный веб-сервер может создать несколько рабочих потоков, а затем использовать основной поток для приема запросов и передачи их в обработчики на неопределенный срок. Таким образом, таким долговечным программам, возможно, не потребуется вызов join routine. Однако параллельная программа, которая создает потоки для выполнения определенной задачи (параллельно), скорее всего, будет использовать join, чтобы убедиться, что вся такая работа завершена, прежде чем выйти или перейти к следующему этапу вычислений.
27.3 Блокировки
Помимо создания потоков и вызова join, вероятно, следующим наиболее полезным набором функций, предоставляемых библиотекой потоков POSIX, являются функции для обеспечения взаимного исключения критической секции с помощью блокировок (locks).
Наиболее простая пара подпрограмм, используемых для этой цели, выглядит следующим образом:
Процедуры должны быть простыми для понимания и использования. Когда у вас есть область кода, которая является критическим разделом и, следовательно, нуждается в защите для обеспечения правильной работы, блокировки весьма полезны. Вы, наверное, можете себе представить, как выглядит код:
Цель кода заключается в следующем: если ни один другой поток не удерживает блокировку при вызове pthread mutex lock(), поток получит блокировку и войдет в критическую секцию. Если другой поток действительно удерживает блокировку, поток, пытающийся захватить блокировку, не вернется из вызова, пока не получит блокировку (подразумевая, что поток, удерживающий блокировку, освободил ее с помощью вызова разблокировки). Конечно, многие потоки могут застрять в ожидании внутри функции получения блокировки в данный момент времени; однако только поток с полученной блокировкой должен вызывать разблокировку.
К сожалению, этот код сломан в двух важных местах. Первая проблема заключается в отсутствии надлежащей инициализации (lack of proper initialization). Все блокировки должны быть правильно инициализированы, чтобы гарантировать, что они имеют правильные значения для работы и, следовательно, работают должным образом при вызове блокировки и разблокировки.
С потоками POSIX существует два способа инициализации блокировок. Один из способов сделать это - использовать PTHREAD_MUTEX_INITIALIZER следующим образом:
При этом для блокировки устанавливаются значения по умолчанию и, таким образом, блокировка становится доступной. Динамический способ сделать это (т. е. во время выполнения) - вызвать функцию pthread_mutex_init() следующим образом:
Первым аргументом этой процедуры является адрес самой блокировки, тогда как второй - необязательный набор атрибутов. Узнайте больше об атрибутах самостоятельно; передача значения NULL просто использует значения по умолчанию. Любой способ работает, но мы обычно используем динамический (последний) метод. Обратите внимание, что соответствующий вызов pthread_mutex_destroy() также должен быть выполнен, когда вы закончите с блокировкой; все подробности см. на странице руководства.
Вторая проблема с приведенным выше кодом заключается в том, что он не проверяет коды ошибок при вызове блокировки и разблокировки. Как и практически любая библиотечная процедура, которую вы вызываете в системе UNIX, эти процедуры также могут завершиться неудачей! Если ваш код неправильно проверяет коды ошибок, сбой произойдет автоматически, что в этом случае может привести к попаданию нескольких потоков в критическую секцию. Как минимум, используйте функции обертки, которые проверяют, что процедура прошла успешно, как показано на рисунке 27.4; более сложные (не игрушечные) программы, которые не могут просто выйти, когда что-то идет не так, должны проверять наличие сбоя и делать что-то подходящее, когда вызов не выполняется.
Процедуры блокировки и разблокировки - не единственные процедуры в библиотеке pthreads, которые взаимодействуют с блокировками. Две другие интересные процедуры:
Эти два вызова используются для получения блокировки. Версия trylock возвращает сбой, если блокировка уже удержана; версия timedlock возвращается после истечения времени ожидания или после получения блокировки, в зависимости от того, что произойдет раньше. Таким образом, временная блокировка с нулевым временем ожидания вырождается в случай trylock. Как правило, следует избегать обеих этих версий; однако есть несколько случаев, когда может быть полезно избежать застревания (возможно, на неопределенный срок) в процедуре получения блокировки, как мы увидим в следующих главах (например, когда мы изучаем deadlock).
27.4 Переменные условия
Другим важным компонентом любой библиотеки потоков, и, безусловно, в случае с потоками POSIX, является наличие condition variable. Переменные условия (condition variables) полезны, когда между потоками должна происходить какая-то сигнализация, если один поток ожидает, что другой что-то сделает, прежде чем сможет продолжить. Две основные процедуры используются программами, желающими взаимодействовать таким образом:
Чтобы использовать переменную условия, необходимо дополнительно иметь блокировку, связанную с этим условием. При вызове любой из вышеперечисленных процедур должна удерживаться блокировка.
Первая процедура, pthread_cond_wait(), переводит вызывающий поток в спящий режим и, таким образом, ожидает, пока какой-нибудь другой поток подаст ему сигнал, обычно когда в программе что-то изменилось, что может волновать спящий поток. Типичное использование выглядит так:
В этом коде после инициализации соответствующей блокировки и условия* поток проверяет, установлена ли переменная ready на значение, отличное от нуля. Если нет, поток просто вызывает процедуру ожидания, чтобы спать, пока какой-нибудь другой поток не разбудит его.
* Можно использовать pthread_cond_init() (и pthread_cond_destroy()) вместо статического инициализатора PTHREAD_COND_INITIALIZER. Звучит как еще одна работа? Так и есть.
Код для пробуждения потока, который (код) будет выполняться в каком-либо другом потоке, выглядит следующим образом:
Несколько вещей, которые следует отметить об этой кодовой последовательности. Во-первых, при подаче сигнала (а также при изменении глобальной переменной ready) мы всегда следим за тем, чтобы блокировка была удержана. Это гарантирует, что мы случайно не введем условие гонки в наш код.
Во-вторых, вы можете заметить, что wait call принимает блокировку в качестве второго параметра, в то время как signal call принимает только условие. Причина этого различия заключается в том, что wait call, в дополнение к переводу вызывающего потока в спящий режим ещё и снимает с него блокировку. Представьте, если бы это было не так: как другой поток смог бы получить блокировку и подать сигнал о пробуждении? Однако, прежде чем вернуться после пробуждения, функция pthread_cond_wait() повторно получает блокировку, тем самым гарантируя, что каждый раз, когда ожидающий поток выполняется между блокировкой, полученной в начале последовательности ожидания, и разблокировкой в конце, он удерживает блокировку.
И последняя странность: ожидающий поток повторно проверяет условие в цикле while вместо простого оператора if. Мы подробно обсудим этот вопрос, когда будем изучать переменные состояния в следующей главе, но в целом использование цикла while - это простая и безопасная вещь. Хотя он перепроверяет условие (возможно, добавляя немного накладных расходов), существуют некоторые реализации pthread, которые могут ложно разбудить ожидающий поток; в таком случае без перепроверки ожидающий поток будет продолжать думать, что условие изменилось, даже если это не так. Таким образом, безопаснее рассматривать пробуждение как намек на то, что что-то могло измениться, а не как абсолютный факт.
Обратите внимание, что иногда возникает соблазн использовать простой флаг для передачи сигнала между двумя потоками вместо переменной условия и связанной блокировки. Например, мы могли бы переписать код ожидания выше, чтобы он выглядел более похожим на этот в коде ожидания:
Соответствующий сигнальный код будет выглядеть следующим образом:
Никогда не делайте этого по следующим причинам. Во-первых, во многих случаях он работает плохо (длительное выполнение просто тратит циклы процессора впустую). Во-вторых, он подвержен ошибкам. Как показывают недавние исследования [X+10], на удивление легко ошибиться при использовании флагов (как указано выше) для синхронизации между потоками; в этом исследовании примерно половина применений этих специальных синхронизаций была ошибочной! Не ленитесь; используйте переменные условия, даже если вы думаете, что можете обойтись без этого.
Если переменные условия кажутся запутанными, не волнуйтесь слишком сильно (пока) – мы рассмотрим их очень подробно в следующей главе. До тех пор достаточно знать, что они существуют, и иметь некоторое представление о том, как и почему они используются.
27.5 Компиляция и запуск
Все примеры кода в этой главе относительно просты в настройке и запуске. Для их компиляции необходимо включить заголовок pthread.h в вашем коде. Вы также должны явно связать код с библиотекой pthreads, добавив флаг -pthread.
Например, чтобы скомпилировать простую многопоточную программу, все, что вам нужно сделать, это следующее:
Пока main.c включает заголовок pthreads, вы успешно скомпилировали параллельную программу. Работает это или нет, как обычно, это совершенно другой вопрос.
27.6 Резюме
Мы представили основы библиотеки pthread, включая создание потоков, построение взаимного исключения с помощью блокировок, а также сигнализацию и ожидание с помощью переменных условий. Вам больше ничего не нужно для написания надежного и эффективного многопоточного кода, кроме терпения и большой осторожности! Теперь мы заканчиваем главу набором советов, которые могут быть полезны вам при написании многопоточного кода (подробности см. в стороне на следующей странице). Есть и другие интересные аспекты API; если вам нужна дополнительная информация, введите
man -k pthread
в системе Linux, чтобы увидеть более ста API, составляющих весь интерфейс. Однако основы, обсуждаемые здесь, должны позволить вам создавать сложные (и, надеюсь, правильные и производительные) многопоточные программы. Самое сложное в потоках - это не API, а скорее сложная логика создания параллельных программ. Читайте дальше, чтобы узнать больше.
СРЕДИ ПРОЧЕГО: THREAD API GUIDELINES
Есть ряд небольших, но важных вещей, которые следует помнить, когда вы используете библиотеку потоков POSIX (или, на самом деле, любую библиотеку потоков) для создания многопоточной программы. Вот они:
• Keep it simple. Прежде всего, любой код для блокировки или передачи сигналов между потоками должен быть как можно более простым. Сложные взаимодействия потоков приводят к ошибкам.
• Сведите к минимуму взаимодействие потоков. Постарайтесь свести количество способов взаимодействия потоков к минимуму. Каждое взаимодействие должно быть тщательно продумано и построено с использованием проверенных и надежных подходов (о многих из которых мы узнаем в следующих главах).
• Инициализируйте блокировки и переменные состояния. Невыполнение этого требования приведет к коду, который иногда работает, а иногда выходит из строя очень странным образом.
• Проверяйте коды возврата. Конечно, в любом программировании на C и UNIX, которым вы занимаетесь, вы должны проверять каждый код возврата, и это верно и здесь. Невыполнение этого требования приведет к странному и трудному для понимания поведению, из-за которого вы, скорее всего, (а) закричите, (б) выдернете часть своих волос или (в) и то, и другое.
• Будьте осторожны с тем, как вы передаете аргументы потокам и возвращаете значения из них. В частности, каждый раз, когда вы передаете ссылку на переменную, выделенную в стеке, вы, вероятно, делаете что-то неправильно.
• Каждый поток имеет свой собственный стек. В связи с вышеизложенным, пожалуйста, помните, что каждый поток имеет свой собственный стек. Таким образом, если у вас есть локально выделенная переменная внутри какой-либо функции, которую выполняет поток, она, по сути, является частной для этого потока; никакой другой поток не может (легко) получить к ней доступ. Чтобы обмениваться данными между потоками, значения должны находиться в куче или в какой-либо другой области, доступной глобально.
• Всегда используйте переменные условия для передачи сигналов между потоками. Хотя часто возникает соблазн использовать простой флаг, не делайте этого.
• Используйте страницы руководства. В частности, в Linux страницы pthread man очень информативны и обсуждают многие нюансы, представленные здесь, часто даже более подробно. Прочтите их внимательно!
Ссылки
[B89] “An Introduction to Programming with Threads” by Andrew D. Birrell. DEC Technical Report, January, 1989. Available: https://birrell.org/andrew/papers/035-Threads.pdf A classic but older introduction to threaded programming. Still a worthwhile read, and freely available.
[B97] “Programming with POSIX Threads” by David R. Butenhof. Addison-Wesley, May 1997. Another one of these books on threads
[B+96] “PThreads Programming: by A POSIX Standard for Better Multiprocessing. ” Dick Buttlar, Jacqueline Farrell, Bradford Nichols. O’Reilly, September 1996 A reasonable book from the excellent, practical publishing house O’Reilly. Our bookshelves certainly contain a great deal of books from this company, including some excellent offerings on Perl, Python, and Javascript (particularly Crockford’s “Javascript: The Good Parts”.)
[K+96] “Programming With Threads” by Steve Kleiman, Devang Shah, Bart Smaalders. Prentice Hall, January 1996. Probably one of the better books in this space. Get it at your local library. Or steal it from your mother. More seriously, just ask your mother for it – she’ll let you borrow it, don’t worry.
[X+10] “Ad Hoc Synchronization Considered Harmful” by Weiwei Xiong, Soyeon Park, Jiaqi Zhang, Yuanyuan Zhou, Zhiqiang Ma. OSDI 2010, Vancouver, Canada. This paper shows how seemingly simple synchronization code can lead to a surprising number of bugs. Use condition variables and do the signaling correctly!