Потоки - продолжение
Статья из серии "Программирование для Linux", журнал Linux Format
,
Мы продолжаем знакомство с многопоточностью в Linux. В статье мы научились создавать потоки и досрочно завершать их. Мы уже знаем, что если запрос на досрочное завершение потока поступил в неподходящий момент, поток может отложить досрочное завершение до тех пор, пока не станет готов к нему.
Механизм отложенного досрочного завершения очень полезен, но для действительно эффективного управления завершением потоков необходим еще и механизм, оповещающий поток о досрочном завершении. Оповещение о завершении потоков в Unix-системах реализовано на основе тех же принципов, что и оповещение о завершении самостоятельных процессов. Если нам нужно выполнять какие-то специальные действия в момент завершения потока (нормального или досрочного), мы устанавливаем функцию-обработчик, которая будет вызвана перед тем, как поток завершит свою работу. Для потоков наличие обработчика завершения даже более важно, чем для процессов. Предположим, что поток выделяет блок динамической памяти и затем внезапно завершается по требованию другого потока. Если бы поток был самостоятельным процессом, ничего особенно неприятного не случилось бы, так как система сама убрала бы за ним мусор. В случае же процесса-потока не высвобожденный блок памяти так и останется «висеть» в адресном пространстве многопоточного приложения. Если потоков много, а ситуации, требующие досрочного завершения, возникают часто, утечки памяти могут оказаться значительными. Устанавливая обработчик завершения потока, высвобождающий занятую память, мы можем быть уверены, что поток не оставит за собой бесхозных блоков памяти (если, конечно, в системе не случится какого-то более серьезного сбоя).
Для установки обработчика завершения потока применяется макрос pthread_cleanup_push(3). Подчеркиваю жирной красной чертой, pthread_cleanup_push() – это макрос, а не функция. Неправильное использование макроса 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 сосредоточены в функции потока thread_func(). Поток начинает работу с того, что запрещает свое досрочное завершение. Этот запрет необходим на время выполнения важных действий, которые нельзя прерывать. Если запрос на досрочное завершение поступит во время действия запрета, он не пропадет. Как мы уже знаем, запрет досрочного завершения не отменяет выполнение соответствующего запроса, а только откладывает его. Далее поток выделяет блок памяти. Для того чтобы избежать утечек памяти, мы должны гарантировать высвобождение выделенного блока, для чего, как вы уже догадались, мы используем функцию-обработчик завершения потока exit_func(). Мы добавляем функцию exit_func() в стек обработчиков завершения потока с помощью макроса pthread_cleanup_push(). Обратите внимание на второй параметр макроса. Вторым параметром, как мы знаем, должен быть нетипизированный указатель. Этот указатель будет передан функции-обработчику в качестве аргумента. Поскольку задача функции exit_func() заключается в том, чтобы высвободить блок памяти mem, в качестве аргумента функции мы устанавливаем указатель на этот блок. Функция exit_func() высвобождает блок памяти с помощью функции free(3) и выводит диагностическое сообщение. После установки обработчика завершения потока наш поток разрешает досрочное завершение. Теперь вам должно быть понятно, зачем мы запретили досрочное завершение потока во время этих операций. Если бы поток завершился после выделения блока памяти, но до назначения функции-обработчика, выделенный блок не был бы удален. Далее поток выводит четыре диагностических сообщения с интервалом в одну секунду и завершает работу.
Перед выходом из функции потока мы вызываем макрос pthread_cleanup_pop(). Этот макрос извлекает функцию-обработчик из стека. Аргумент макроса pthread_cleanup_pop() позволяет указать, следует ли выполнять функцию- обработчик, или требуется только удалить ее из стека. Мы передаем макросу ненулевое значение, что указывает на необходимость выполнить обработчик. Если вы забудете поставить вызов pthread_cleanup_pop() в конце функции потока, компилятор выдаст сообщение о синтаксической ошибке. Объясняется это, конечно, тем, что pthread_cleanup_push() и pthread_cleanup_pop() – макросы. Первый макрос, кроме прочего, открывает фигурные скобки, которые второй макрос должен закрыть, так что число обращений к pthread_cleanup_push() в функции потока всегда должно быть равно числу обращений к pthread_cleanup_pop(), иначе программу не удастся скомпилировать.
То, что макрос pthread_cleanup_pop() должен быть вызван столько же раз, сколько и макрос pthread_cleanup_push(), очень удобно в том случае, если обработчик завершения потока вызывается явным образом, но что происходит, если обработчики завершения потока вызываются неявно? Если неявный вызов обработчиков происходит вследствие досрочного завершения потока, механизм досрочного завершения вызовет обработчики сам, а код, добавленный макросами pthread_cleanup_pop(), выполнен не будет. Однако обработчики завершения потока могут быть выполнены и в результате вызова функции pthread_exit(). Наличие вызова pthread_exit() не избавит вас от необходимости добавлять макросы pthread_cleanup_pop(), ведь они необходимы для охранения правильной синтаксической структуры программы. Как же функция pthread_exit() взаимодействует с кодом, добавленным макросами pthread_cleanup_pop()? Если вызов pthread_exit() расположен до вызовов pthread_cleanup_pop(), поток завершится до обращения к коду макросов, при этом все обработчики завершения потока будут вызваны функцией pthread_exit(). Если мы расположим вызов pthread_exit() после вызовов pthread_cleanup_pop(), обработчики завершения будут выполнены до вызова pthread_exit(), и этой функции останется только завершить работу потока, не вызывая никаких обработчиков. А нужно ли вообще вызывать pthread_exit() в конце функции потока, если вызовы макросов pthread_cleanup_pop() все равно необходимы? Ответ на этот вопрос зависит от обстоятельств. Помимо вызова обработчиков завершения потока, функция pthread_exit() может выполнять в вашем потоке и другие важные действия, и в этом случае ее вызов необходим.
Еще один тонкий момент связан с выходом из функции потока с помощью оператора return. Сам по себе такой выход из функции потока не приводит к вызову обработчиков завершения. В нашем примере мы вызвали обработчики явно с помощью pthread_cleanup_pop() непосредственно перед выполнением return, но рассмотрим другой вариант функции 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); return; }
Пусть этот вариант выглядит несколько неестественно, суть его в том, что теперь в функции потока определено несколько точек выхода. При выполнении условия i == 2 функция потока завершится в результате выполнения оператора return и обработчик завершения потока при этом вызван не будет. Эту проблему нельзя решить добавлением еще одного макроса pthread_cleanup_pop(). Вариант функции
... if (i == 2) { pthread_cleanup_pop(1); return; } pthread_cleanup_pop(1); return; }
вообще не скомпилируется, поскольку лишний макрос pthread_cleanup_pop() нарушит синтаксис программы. Правильное решение заключается в использовании функции pthread_exit() вместо return: if (i == 2) pthread_exit(0);
Вполне возможно, что вам, уважаемый читатель, как и мне, уже несколько раз хотелось досрочно завершить обсуждение досрочного завершения потоков. Потерпите немного, мы уже приближаемся к финишу. Осталось ответить на вопрос, зачем нам нужна возможность устанавливать несколько обработчиков завершения потока? Ответов на этот вопрос может быть много, но я дам только один. Представьте себе, что вы программируете сложную функцию потока, которая интенсивно работает с динамической памятью. Как только в вашей функции выделяется новый блок памяти, вы устанавливаете обработчик завершения потока, который высвободит этот блок в случае неожиданного завершения. Тут стоит отвлечься на секунду и заметить, что установка обработчика, высвобождающего память во время завершения потока, не мешает вам самостоятельно высвободить эту память, когда она перестанет быть нужна. Придется только немного поиграть с указателями (на диске вы найдете программу exittest2.c, которая демонстрирует явное высвобождение памяти в потоке совместно c использованием обработчика завершения). Если затем в вашей функции понадобится выделить новый блок памяти, потребуется еще один обработчик для его высвобождения. Даже если вы заранее знаете, сколько раз ваша программа будет выделать блоки памяти, назначать обработчик для высвобождения каждого блока можно только после того, как блок выделен.