- Подписка на печатную версию:
- Подписка на электронную версию:
- Подшивки старых номеров журнала (печатные версии)
LXF87-88:Unix API
Материал из Linuxformat.
- Unix API Настоящее программирование для Unix – без прикрас и библиотек-«оберток»
Потоки: синхронизация
- ЧАСТЬ 8: Подобно леммингам, бесконтрольно размножающиеся потоки очень быстро устраивают гонку-соревнование за ресурсы системы. Но Андрей Боровский знает, как умерить их аппетиты...
Мы продолжаем знакомство с многопоточными приложениями Linux. В предыдущей статье мы научились создавать потоки и вызывать их досрочное завершение. Мы уже знаем, что если запрос на досрочное завершение потока поступил «в неподходящий момент», поток может повременить с кончиной до тех пор, пока не станет готов к ней. Механизм отложенного досрочного завершения очень полезен, но для действительно эффективного управления завершением потоков необходим еще и механизм, оповещающий поток о досрочном завершении. Оповещение о завершении потоков в Unix-системах реализовано на основе тех же принципов, что и оповещение о завершении самостоятельных процессов. Если нам нужно выполнять какие-то специальные действия в момент завершения потока, мы устанавливаем функцию-обработчик, которая будет вызвана перед тем, как поток завершит свою работу. Смысл назначения обработчика заключается в том, что он будет вызван как при нормальном завершении потока, так и при досрочном завершении. Для потоков наличие обработчика завершения даже более важно, чем для процессов. Предположим, что поток выделяет блок динамической памяти и затем внезапно завершается по требованию другого потока. Если бы поток был самостоятельным процессом, ничего особен- но неприятного не случилось бы, так как ОС сама убрала бы за ним мусор. В случае же процесса-потока невысвобожденный блок памяти так и останется «висеть» в адресном пространстве многопоточного приложения. Если потоков много, а ситуации, требующие досрочного завершения, возникают часто, утечки памяти могут оказаться значительными. Устанавливая обработчик завершения потока, высвобождающий занятую память, мы можем быть уверены, что поток не оставит за собой «бесхозных» блоков (если, конечно, в системе не случится какого-то более серьезного сбоя).
Для установки обработчика завершения потока применяется макрос pthread_cleanup_push(3). Подчеркиваю жирной красной чертой, pthread_cleanup_push() – это макрос, а не функция. Неправильное использование данного макроса может привести к неожиданным синтаксическим ошибкам.
У pthread_cleanup_push() два аргумента. В первом передается адрес функции-обработчика завершения потока, а во втором – нетипизированный указатель, который будет передан как аргумент функции-обработчику. Этот указатель может указывать на что угодно – мы сами решаем, какие данные должны быть переданы обработчику завершения потока. Макрос pthread_cleanup_push() помещает переданные ему адрес функции-обработчика и указатель в специальный стек. Само слово «стек» указывает, что мы можем назначить потоку произвольное число функций-обработчиков завершения. Поскольку в стек записывается не только функция, но и ее аргумент, мы можем назначить один и тот же обработчик с несколькими разными аргументами.
Для того, чтобы обработчики смогли выполнить свою задачу, они, естественно, должны быть вызваны в подходящий момент. В процессе завершения потока функции-обработчики и их аргументы должны быть извлечены из стека и выполнены. Извлечение обработчиков из стека с последующим выполнением может быть выполнено либо явно, либо автоматически. Автоматически обработчики завершения потока выполняются при вызове потоком функции pthread_exit() (которая завершает его работу), а также при выполнении потоком запроса на досрочное завершение. Явным образом обработчики завершения потока извлекаются из стека с помощью макроса pthread_cleanup_pop(3). Во всех случаях обработчики извлекаются из стека и выполняются в порядке, противоположном тому, в котором они были помещены в стек. Если мы используем макрос pthread_cleanup_pop() явно, мы можем указать, что обработчик необходимо только извлечь из стека, но выполнять его не следует. Порядок назначения и выполнения обработчиков выглядит довольно сложным, поэтому мы начнем его изучение с простого примера, программы exittest:
#include <stdlib.h> #include <stdio.h> #include <errno.h> #include <pthread.h> void exit_func(void * arg) { free(arg); printf(“Freed the allocated memory.\n”); } void * thread_func(void *arg) { int i; void * mem; pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); mem = malloc(1024); printf(“Allocated some memory.\n”); pthread_cleanup_push(exit_func, mem); pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL); for (i = 0; i < 4; i++) { sleep(1); printf(“I’m still running!!!\n”); } pthread_cleanup_pop(1); } int main(int argc, char * argv[]) { pthread_t thread; pthread_create(&thread, NULL, thread_func, NULL); pthread_cancel(thread); pthread_join(thread, NULL); printf(“Done.\n”); return EXIT_SUCCESS; }
В этой программе (вы найдете ее на диске в файле exittest.c) много уже знакомых нам элементов. Программа exittest создает дополнительный поток и тут же посылает запрос на его завершение. Новые для нас элементы сосредоточены в функции потока thread_func(). Поток начинает работу с того, что запрещает досрочное завершение. Этот запрет необходим на время выполнения важных действий, которые нельзя прерывать. Если запрос поступит во время действия запрета, он не пропадет. Как мы уже знаем, запрет досрочного завершения не отменяет выполнение запроса на досрочное завершение, а откладывает его. Далее поток динамически выделяет блок памяти. Чтобы избежать утечек памяти, мы должны гарантировать высвобождение выделенного блока. Задачу высвобождения блока памяти мы возлагаем на функцию exit_func(), которая является обработчиком завершения потока. Для этого мы добавляем функцию exit_func() в стек обработчиков завершения потока с помощью макроса pthread_cleanup_push().
Обратите внимание на второй параметр макроса. Им, как мы знаем, должен быть нетипизированный указатель. Этот указатель будет передан в качестве аргумента функции-обработчику. Поскольку задача функции exit_func() заключается в том, чтобы высвободить блок памяти mem, в качестве аргумента функции мы просто передаем указатель на этот блок. Функция exit_func() высвобождает блок памяти с помощью free(3) и выводит диагностическое сообщение.
Продолжим изучение функции потока thread_func(). После установки обработчика exit_func() наш поток разрешает досрочное завершение. Теперь при поступлении запроса на досрочное завершение блок памяти mem будет высвобождаться автоматически. Далее поток выводит четыре диагностических сообщения с интервалом в одну секунду и завершает свою работу. Перед выходом из функции потока мы вызываем макрос pthread_cleanup_pop(). Этот макрос извлекает функцию-обработчик из стека. Аргумент макроса позволяет указать, следует ли выполнять функцию-обработчик, или требуется только удалить ее из стека. Мы передаем макросу ненулевое значение, что указывает на необходимость выполнить обработчик.
Если вы забудете поставить вызов pthread_cleanup_pop() в конце функции потока, компилятор выдаст сообщение о синтаксической ошибке. Объясняется это, конечно, тем, что pthread_cleanup_push() и pthread_cleanup_pop() – макросы. Первый макрос, кроме прочего, открывает фигурные скобки, которые второй макрос должен закрыть, так что число обращений к pthread_cleanup_push() в функции потока всегда должно быть равно числу обращений к pthread_cleanup_pop(), иначе программу не удастся откомпилировать.
Интересен вопрос о взаимодействии вызовов pthread_cleanup_pop() и pthread_exit(). Мы уже говорили, что вызов pthread_exit() приводит к опустошению стека и последовательному выполнению всех обработчиков. Означает ли это, что если функция потока завершает с помощью pthread_exit(), то вызывать макросы pthread_cleanup_pop() уже не нужно? Нет, не означает. Ведь макросы, как мы уже видели, управляют синтаксической структурой программы на этапе компиляции, и вызов pthread_exit() не может заменить их в этой роли. Мы можем расположить вызов pthread_exit() до вызовов pthread_cleanup_pop() (в этом случае поток завершится до обращения к макросам, но поскольку pthread_exit() сама опустошает стек обработчиков, этого уже и не требуется). Мы также можем расположить вызов pthread_exit() после вызовов pthread_cleanup_pop(), в этом случае стек обработчиков будет опустошен до вызова pthread_exit() и эта функция просто завершит работу программы. Тогда возникает другой вопрос: а нужно ли вообще вызывать pthread_exit() в конце функции потока, если вызовы макросов pthread_cleanup_pop() все равно необходимы? Ответ на него зависит от обстоятельств. Помимо вызова обработчиков завершения, функция pthread_exit() может выполнять в вашем потоке и другие действия финализации, и в этом случае ее вызов необходим. Еще один тонкий момент связан с выходом из функции потока с помощью оператора return. Сам по себе return не приводит к вызову обработчиков завершения. В нашем примере мы вызвали обработчик явно с помощью pthread_cleanup_pop(), но рассмотрим такой вариант функции thread_func():
void * thread_func(void *arg) { int i; void * mem; pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); mem = malloc(1024); printf(“Allocated some memory.\n”); pthread_cleanup_push(exit_func, mem); pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL); for (i = 0; i < 4; i++) { sleep(1); printf(“I’m still running!!!\n”); if (i == 2) return; } pthread_cleanup_pop(1); pthread_exit(0) }
Пусть этот вариант выглядит несколько неестественным, но верно отражает суть: теперь в функции потока определено несколько точек выхода. В приведенном выше варианте thread_func() завершится вызовом return, и обработчик завершения потока при этом вызван не будет. Вариант
... if (i == 2) { pthread_cleanup_pop(1); return; } ...
вообще не скомпилируется, поскольку «лишний» макрос pthread_cleanup_pop() нарушит синтаксис программы. Правильный ответ состоит в использовании pthread_exit():
if (i == 2) pthread_exit(0);
Вполне возможно, что вам, уважаемый читатель, как и мне, уже несколько раз хотелось досрочно завершить обсуждение досрочного завершения потоков. Потерпите немного, мы уже приближаемся к финишу. Осталось лишь ответить не вопрос, зачем нам нужна возможность устанавливать несколько обработчиков завершения потока? Ответов на этот вопрос может быть много, но я дам только один. Представьте себе, что вы программируете сложную функцию потока, которая интенсивно работает с динамической памятью. Как только в вашей функции выделяется новый блок памяти, вы устанавливаете обработчик завершения потока, который высвободит его в случае неожиданного завершения. Тут стоит отвлечься на секунду и заметить, что установка обработчика, высвобождающего память во время завершения потока, не мешает вам самостоятельно высвободить эту память, когда она перестанет быть нужна. Придется только немного поиграть с указателями (на диске вы найдете программу exittest2.c, которая демонстрирует явное высвобождение памяти в потоке совместно с использованием обработчика завершения). Если затем в вашей функции понадобится выделить новый блок памяти, потребуется еще один обработчик для его высвобождения. Даже если вы заранее знаете, сколько раз ваша программа будет выделать блоки памяти, назначать обработчик для высвобождения каждого блока можно только после того, как он был выделен
Средства синхронизации потоков
Изучая взаимодействие между процессами, мы уделили большое внимание средствам синхронизации. У потоков тоже есть для этого свои специальные механизмы. Вернемся к первому примеру из LXF86, программе threads. Напомню, что в том примере мы создавали два потока, используя одну и ту же функцию thread_func. В процессе создания каждого потока этой функции передавалось целочисленное значение (номер потока). При этом для передачи значения каждому потоку использовалась своя переменная. В той статье я подробно объяснил, почему использование одной и той же переменной для передачи значения функциям разных потоков может привести к ошибке. Коротко говоря – проблема заключалась в том, что мы не могли знать, когда именно новый поток начнет свое выполнение. С помощью средств синхронизации потоков мы можем решить эту проблему и использовать одну переменную для передачи значений обоим потокам. Рассмотрим модифицированный вариант программы threads – threads2 (вы найдете ее на диске в файле threads2.c).
#include <stdlib.h> #include <stdio.h> #include <errno.h> #include <pthread.h> #include <semaphore.h> sem_t sem; void * thread_func(void *arg) { int i; int loc_id = * (int *) arg; sem_post(&sem); for (i = 0; i < 4; i++) { printf(“Thread %i is running\n”, loc_id); sleep(1); } } int main(int argc, char * argv[]) { int id, result; pthread_t thread1, thread2; id = 1; sem_init(&sem, 0, 0); result = pthread_create(&thread1, NULL, thread_func, &id); if (result != 0) { perror(“Creating the first thread”); return EXIT_FAILURE; } sem_wait(&sem); id = 2; result = pthread_create(&thread2, NULL, thread_func, &id); if (result != 0) { perror(“Creating the second thread”); return EXIT_FAILURE; } result = pthread_join(thread1, NULL); if (result != 0) { perror(“Joining the first thread”); return EXIT_FAILURE; } result = pthread_join(thread2, NULL); if (result != 0) { perror(“Joining the second thread”); return EXIT_FAILURE; } sem_destroy(&sem); printf(“Done\n”); return EXIT_SUCCESS; }
Как видите, теперь мы используем одну переменную id для передачи значения обоим потокам. Если вы скомпилируете и запустите программу, то увидите, что она работает корректно. Секрет нашего успеха заключается в использовании средств синхронизации, а именно – семафоров. Мы уже знакомились с семафорами System V в LXF82 – тогда они использовались для синхронизации процессов. В данном случае мы имеем дело с семафорами другого типа – семафорами POSIX, которые специально предназначены для работы с потоками. Все объявления функций и типов, относящиеся к этим семафорам, можно найти в файле /usr/include/nptl/semaphore.h. Семафоры создаются (инициализируются) с помощью функции sem_init(3). Первый параметр функции sem_init() – указатель на переменную типа sem_t, которая служит идентификатором семафора. Второй параметр, pshared, в настоящее время не используется. Мы оставим его равным нулю. В третьем параметре передается значение, которым инициализируется семафор. Дальнейшая работа с семафором осуществляется с помощью функций sem_wait(3) и sem_post(3). Единственным аргументом функции sem_wait() служит указатель на идентификатор семафора. Функция sem_wait() приостанавливает выполнение вызвавшего ее потока до тех пор, пока значение семафора не станет больше нуля, затем функция уменьшает значение семафора на единицу и возвращает управление. Функция sem_post(), наоборот, увеличивает значение семафора, идентификатор которого был ей передан, на единицу. Присвоив семафору значение 0, наша программа создает первый поток и вызывает функцию sem_wait(). Эта функция вернет управление программе после того, как функция потока вызовет функцию sem_post(), а это случится не раньше, чем thread_func() обработает значение id. Таким образом, мы можем быть уверены, что когда sem_wait() вернет управление функции main, первый поток уже закончит обработку переменной id, и мы сможем использовать эту переменную для передачи данных следующему потоку. После завершения обоих потоков семафор перестает быть нужным, и мы можем вызвать функцию sem_destroy(3) для его удаления и высвобождения ресурсов.
Семафоры – не единственное средство синхронизации потоков. Для разграничения доступа к глобальным объектам потоки могут использовать мьютексы [mutex, от английского mutual exclusive - взаимоисключающий, – прим. ред.]. Все функции и типы данных, имеющие отношение к мьютексам, определены в файле pthread.h. Мьютекс создается вызовом функции pthread_mutex_init(3). В качестве первого аргумента этой функции передается указатель на переменную pthread_mutex_t. Она играет роль идентификатора создаваемого мьютекса. Вторым аргументом функции pthread_mutex_init() должен быть указатель на переменную типа pthread_mutexattr_t. Эта переменная позволяет установить дополнительные атрибуты мьютекса. Если нам нужен обычный мьютекс, мы можем передать во втором параметре NULL. Для того, чтобы получить исключительный доступ к некому глобальному ресурсу, поток вызывает функцию pthread_mutex_lock(3) (в этом случае говорят, что «поток захватывает мьютекс»). Единственным аргументом функции pthread_mutex_lock() является идентификатор мьютекса. Закончив работу с глобальным ресурсом, поток высвобождает мьютекс с помощью функции pthread_mutex_unlock(3). Если поток вызовет функцию pthread_mutex_lock() для мьютекса, уже захваченного другим потоком, эта функция не вернет управление до тех пор, пока другой поток не высвободит мьютекс с помощью вызова pthread_mutex_unlock(). Если мьютекс больше не нужен, его можно удалить из системы с помощью функции pthread_mutex_destroy(3). Стоит отметить, что в отличие от многих других функций, приостанавливающих работу потока, вызов pthread_mutex_lock() не является точкой останова. Иначе говоря, поток, находящийся в режиме отложенного досрочного завершения, не может быть завершен в тот момент, когда он ожидает выхода из pthread_mutex_lock().
Атрибуты потоков
Создавая новый поток, вы можете указать ряд дополнительных атрибутов, определяющих некоторые параметры. Из всех этих атрибутов более всего востребован атрибут DETACHED, позволяющий создавать отделенные потоки. Во всех рассмотренных выше примерах мы использовали функцию pthread_join(), позволяющую дождаться завершения потока и получить значение, возвращенное его функцией. Для того, чтобы функция pthread_join() могла получить значение функции потока, завершившегося до вызова pthread_join(), система сохраняет данные о потоке после его завершения (это напоминает появление «зомби» после завершения самостоятельного процесса). Если наша программа интенсивно работает с потоками и синхронизация потоков с помощью pthread_join() нам не нужна, мы можем сэкономить ресурсы системы, используя отделенные потоки. Отделенные потоки отличаются от обычных (присоединяемых) потоков тем, что после завершения отделенного потока система не сохраняет о нем никакой информации. При попытке использовать для отделенного потока функцию pthread_join(), последняя вернет сообщение об ошибке.
Вы можете сделать поток отделенным с помощью вызова функции pthread_detach(3). Однако то же самое можно сделать с помощью атрибутов потоков, которыми мы и займемся. Для того, чтобы назначить потоку дополнительные атрибуты, нужно сначала создать объект, содержащий набор атрибутов. Этот объект создается функцией pthread_attr_init(3). Единственный аргумент этой функции – указатель на переменную типа pthread_attr_t, которая служит идентификатором набора атрибутов. Функция pthread_attr_init() инициализирует набор атрибутов потока значениями, заданными по умолчанию, так что мы можем работать только с теми атрибутами, которые нас интересуют, и не беспокоиться об остальных. Для добавления атрибутов в набор используются специальные функции с именами pthread_attr_set<имя_атрибута>. Например, для того, чтобы добавить атрибут «отделенности», мы вызываем функцию pthread_attr_setdetachstate(3). Первым аргументом этой функции должен быть адрес объекта набора атрибутов, а вторым аргументом – константа, определяющая значение атрибута. Константа PTHREAD_CREATE_DETACHED указывает, что создаваемый поток должен быть отделенным, тогда как константа PTHREAD_CREATE_JOINABLE определяет создание присоединяемого (joinable) потока, который может быть синхронизирован функцией pthread_join(3). После того, как мы добавили необходимые значения в набор атрибутов потока, мы вызываем функцию создания потока pthread_create(). Набор атрибутов потока передается ей в качестве второго аргумента.
На этом мы закончим ликбез, посвященный потокам. Если вы любите потоки также сильно, как люблю их я, к вашим услугам неисчерпаемое море специальной литературы, в которой вы найдете все то, чему не хватило места на страницах журнала. Ну а перед тем, как приступать к чтению следующей статьи этой серии, запаситесь серой, ибо мы познакомимся с демонами. LXF