Потоки
Многопоточность по своей сути, является прямым решением обеспечения многозадачности и представляет собой логическое развитие концепции разделения ресурсов. В рамках неформального, но простого, определения, поток - это выполнение последовательности машинных инструкций. В многопоточном приложении одновременно работает несколько потоков. Иногда вместо термина ?поток? используется термин ?нить?, для того, чтобы потоки программ не путались с потоками ввода, вывода и ошибок.
Прежде чем приступать к программированию потоков, следует ответить на вопрос, а нужны ли они вам. Потоки часто становятся источниками программных ошибок особого рода. Эти ошибки возникают при использовании потоками разделяемых ресурсов системы (например, общего адресного пространства) и являются частным случаем более широкого класса ошибок ? ошибок синхронизации. Если задача разделена между независимыми процессами, то доступом к их общим ресурсам управляет операционная система, и вероятность ошибок из-за конфликтов доступа снижается. Впрочем, разделение задачи между несколькими независимыми процессами само по себе не защитит вас от других разновидностей ошибок синхронизации. В пользу потоков можно указать то, что накладные расходы на создание нового потока в многопоточном приложении ниже, чем накладные расходы на создание нового самостоятельного процесса. Уровень контроля над потоками в многопоточном приложении выше, чем уровень контроля приложения над дочерними процессами. Кроме того, многопоточные программы не оставляют за собой множество зомби, и ?осиротевших? процессов.
В ОС Unix каждый поток является процессом, и для того, чтобы создать новый поток, нужно создать новый процесс. В чем же, в таком случае, заключается различие многопоточности перед многопроцессноси? В многопоточных приложениях для создания дополнительных потоков используются процессы особого типа. Эти процессы представляют собой обычные дочерние процессы главного процесса, но они разделяют с главным процессом адресное пространство, файловые дескрипторы и обработчики сигналов. Для обозначения процессов этого типа, применяется специальный термин ? легкие процессы . Прилагательное ?легкий? в названии процессов- потоков вполне оправдано. Поскольку этим процессам не нужно создавать собственную копию адресного пространства (и других ресурсов) своего процесса- родителя, создание нового легкого процесса требует значительно меньших затрат, чем создание полновесного дочернего процесса.
У каждого процесса есть идентификатор. Есть он и у процессов-потоков. Но спецификация POSIX требует, чтобы все потоки многопоточного приложения имели один идентификатор. Вызвано это требование тем, что для многих функций системы многопоточное приложение должно представляться как один процесс с одним идентификатором. Проблема единого идентификатора решается следующим образом. Процессы многопоточного приложения группируются в группы потоков. Группе присваивается идентификатор, соответствующий идентификатору первого процесса многопоточного приложения. Именно этот идентификатор группы потоков используется при ?общении? с многопоточным приложением. Функция getpid(2), возвращает значение идентификатора группы потока, независимо от того, из какого потока она вызвана. Функции kill() waitpid() и им подобные по умолчанию также используют идентификаторы групп потоков, а не отдельных процессов. Узнавать собственный идентификатор процесса-потока требуется очень редко, но если надо это сделать, то используется функция gettid(2).
Потоки создаются функцией pthread_create(3), определенной в заголовочном файле <pthread.h>. Первый параметр этой функции представляет собой указатель на переменную типа pthread_t, которая служит идентификатором создаваемого потока. Второй параметр, указатель на переменную типа pthread_attr_t, используется для передачи атрибутов потока. Третьим параметром функции pthread_create() должен быть адрес функции потока. Эта функция играет для потока ту же роль, что функция main() ? для главной программы. Четвертый параметр функции pthread_create() имеет тип void *. Этот параметр может использоваться для передачи значения, возвращаемого функцией потока. Вскоре после вызова pthread_create() функция потока будет запущена на выполнение параллельно с другими потоками программы. Таким образом, собственно, и создается новый поток. Говорят, что новый поток запускается ?вскоре? после вызова pthread_create() потому, что перед тем как запустить новую функцию потока, нужно выполнить некоторые подготовительные действия, а поток-родитель между тем продолжает выполняться. Если в ходе создания потока возникла ошибка, функция pthread_create() возвращает ненулевое значение, соответствующее номеру ошибки.
Функция потока должна иметь заголовок вида:
void * func_name(void * arg)
Имя функции, естественно, может быть любым. Аргумент arg, - это тот самый указатель, который передается в последнем параметре функции pthread_create(). Функция потока может вернуть значение, которое затем будет проанализировано заинтересованным потоком, но это не обязательно. Завершение функции потока происходит если:
функция потока вызвала функцию pthread_exit(3);
функция потока достигла точки выхода;
поток был досрочно завершен другим потоком.
Функция pthread_exit() представляет собой потоковый аналог функции exit(). Аргумент функции pthread_exit(), значение типа void *, становится возвращаемым значением функции потока. Как (и кому?) функция потока может вернуть значение, если она не вызывается из программы явным образом? Для того, чтобы получить значение, возвращенное функцией потока, нужно воспользоваться функцией pthread_join(3). У этой функции два параметра. Первый параметр pthread_join(), ? это идентификатор потока, второй параметр имеет тип ?указатель на нетипизированный указатель?. В этом параметре функция pthread_join() возвращает значение, возвращенное функцией потока. Основная же задача функции pthread_join() заключается, в синхронизации потоков. Вызов функции pthread_join() приостанавливает выполнение вызвавшего ее потока до тех пор, пока поток, чей идентификатор передан функции в качестве аргумента, не завершит свою работу. Если в момент вызова pthread_join() ожидаемый поток уже завершился, функция вернет управление немедленно. Функцию pthread_join() можно рассматривать как эквивалент waitpid(2) для потоков. Эта функция позволяет вызвавшему ее потоку дождаться завершения работы другого потока. Попытка выполнить более одного вызова pthread_join() (из разных потоков) для одного и того же потока приведет к ошибке.
Посмотрим, как все это работает на примере.
/*gcc thr.c -D_REENTERANT -I/usr/include/nptl -o thr -L/usr/lib/nptl -lpthread - строка для компиляции данного примера
Команда компиляции включает макрос _REENTERANT. Этот макрос указывает, что вместо обычных функций стандартной библиотеки к программе должны быть подключены их реентерабельные аналоги. Реентерабельный вариант библиотеки glibc написан таким образом, что вы, скорее всего, вообще не обнаружите никаких различий в работе с реентерабельными функциями по сравнению с их обычными аналогами. Мы указываем компилятору путь для поиска заголовочных файлов и путь для поиска библиотек /usr/include/nptl и /usr/lib/nptl соответственно. Наконец, мы указываем компоновщику, что программа должна быть связана с библиотекой libpthread, которая содержит все специальные функции, необходимые для работы с потоками.
Компьютерная программа в целом или её отдельная процедура называется реентера?бельной, если она разработана таким образом, что одна и та же копия инструкций программы в памяти может быть совместно использована несколькими пользователями или процессами. При этом второй пользователь может вызвать реентерабельный код до того, как с ним завершит работу первый пользователь и это как минимум не должно привести к ошибке, а в лучшем случае не должно вызвать потери вычислений (то есть не должно появиться необходимости выполнять уже выполненные фрагменты кода).
*/
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <pthread.h> //заголовочный файл для работы с потоками
void * thread_func(void *arg) //функция потока
{
int i=0;
int id = * (int *) arg; //преобразование переданного параметра в int
for (i = 0; i < 2; i++)
{
printf("Hello from thread %d\n", id); //распечатать номер потока
sleep(1); //задержка в одну секунду
}
}
int main(int argc, char * argv[])
{
int id1=1;
int id2=2;
int result;
pthread_t thread1, thread2; //идентификаторы создаваемых потоков
id1 = 1;
result = pthread_create(&thread1, NULL, thread_func, &id1); //создание потока
if (result != 0) //обработка ошибки создания
{ //если идентификатор создаваемого потока не равен нулю -> произошла ошибка
perror("While creating thread");
return 1;
}
id2 = 2;
result = pthread_create(&thread2, NULL, thread_func, &id2); //создание потока
if (result != 0)
{
perror("While creating thread");
return 1;
}
result = pthread_join(thread1, NULL); //функция для получения значения, возвращаемого функцией потока
//а так же для синхронизации потоков (возвращаемое значение передается через указатель, в нашем случае
//указатель не определен(NULL), т.к. поток не возвращает значения.
if (result != 0) {
perror("While joining thread");
return 2;
}
result = pthread_join(thread2, NULL);//функция для получения значения, возвращаемого функцией потока
if (result != 0) {
perror("While joining thread");
return 2;
}
printf("Done\n");
return 0;
}
Рассмотрим сначала функцию thread_func(). Это и есть функция потока. В качестве аргумента ей передается указатель на переменную типа int, в которой содержится номер потока. Функция потока распечатывает этот номер несколько раз с интервалом в одну секунду и завершает свою работу. В функции main() есть две переменные типа pthread_t для создания двух потоков с собственными идентификаторами. Также есть две переменные типа int, id1 и id2, которые используются для передачи функциям потоков их номеров. Сами потоки создаются с помощью функции pthread_create().В этом примере мы не модифицируем атрибуты потоков, поэтому во втором параметре в обоих случаях передаем NULL. Вызывая pthread_create() дважды, мы оба раза передаем в качестве третьего параметра адрес функции thread_func, в результате чего два созданных потока будут выполнять одну и ту же функцию. Функция, вызываемая из нескольких потоков одновременно, должна обладать свойством реентерабельности. Реентерабельная функция, это функция, которая может быть вызвана повторно, в то время, когда она уже вызвана (отсюда и происходит ее название). Реентерабельные функции используют локальные переменные (и локально выделенную память) в тех случаях, когда их не-реентерабельные аналоги могут воспользоваться глобальными переменными.
Мы вызываем последовательно две функции pthread_join() для того, чтобы дождаться завершения обоих потоков. Если мы хотим дождаться завершения всех потоков, порядок вызова функций pthread_join() для разных потоков, очевидно, не имеет значения.
Конечно, возникает вопрос, зачем использовали две разные переменные, id1 и id2, для передачи значений двум потокам? Почему нельзя использовать одну переменную, скажем id, для обоих потоков? Рассмотрим такой кусок кода:
id = 1; pthread_create(&thread1, NULL, thread_func, &id); id = 2; pthread_create(&thread2, NULL, thread_func, &id);
Конечно, в этом случае оба потока получат указатель на одну и ту же переменную, но ведь значение этой переменной нужно каждому потоку только в самом начале его работы. После того, как поток присвоит это значение своей локальной переменной id, ничто не мешает нам использовать ту же переменную id для другого потока. Все это верно, но проблема заключается в том, что мы не знаем, когда первый поток начнет свою работу. То, что функция pthread_create() вернула управление, не гарантирует нам, что поток уже выполняется. Вполне может случиться так, что первый поток будет запущен уже после того, как переменной id будет присвоено значение 2. Тогда оба потока получат одно и то же значение id. Впрочем, мы можем использовать одну и ту же переменную для передачи данных функциям потока, если воспользуемся средствами синхронизации.
Функции потоков можно рассматривать как вспомогательные программы, находящиеся под управлением функции main(). Точно так же, как при управлении процессами, иногда возникает необходимость досрочно завершить процесс, многопоточной программе может понадобиться досрочно завершить один из потоков. Для досрочного завершения потока можно воспользоваться функцией pthread_cancel(3). Единственным аргументом этой функции является идентификатор потока. Функция pthread_cancel() возвращает 0 в случае успеха и ненулевое значение в случае ошибки. Несмотря на то, что pthread_cancel() может завершить поток досрочно, ее нельзя назвать средством принудительного завершения потоков. Дело в том, что поток может не только самостоятельно выбрать порядок завершения в ответ на вызов pthread_cancel(), но и вовсе игнорировать этот вызов. Вызов функции pthread_cancel() следует рассматривать как запрос на выполнение досрочного завершения потока. Функция pthread_setcancelstate(3) определяет, будет ли поток реагировать на обращение к нему с помощью pthread_cancel(), или не будет. У функции pthread_setcancelstate() два параметра, параметр state типа int и параметр oldstate типа ?указатель на int?. В первом параметре передается новое значение, указывающее, как поток должен реагировать на запрос pthread_cancel(), а в переменную, чей адрес был передан во втором параметре, функция записывает прежнее значение. Если прежнее значение вас не интересует, во втором параметре можно передать NULL.
Чаще всего функция pthread_setcancelstate() используется для временного запрета завершения потока. Допустим, мы программируем поток, и знаем, что при определенных условиях программа может потребовать его досрочного завершения. Но в нашем потоке есть участок кода, во время выполнения которого завершать поток крайне нежелательно. Мы можем оградить этот участок кода от досрочного завершения с помощью пары вызовов pthread_setcancelstate():
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); ... //Здесь поток завершать нельзя pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
Первый вызов pthread_setcancelstate() запрещает досрочное завершение потока, второй ? разрешает. Если запрос на досрочное завершение потока поступит в тот момент, когда поток игнорирует эти запросы, выполнение запроса будет отложено до тех пор, пока функция pthread_setcancelstate() не будет вызвана с аргументом PTHREAD_CANCEL_ENABLE. Рассмотрим пример программы:
//gcc canc.c -D_REENTERANT -I/usr/include/nptl -o canc -L/usr/lib/nptl -lpthread #include <stdlib.h> #include <stdio.h> #include <pthread.h> //заголовочный файл для работы с потоками int i = 0; //счетчик прерывания потока void * thread_func(void * arg) { pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); //перевод потока в //состояние, невозможное для досрочного завершения for(i=0; i < 4; i++) { sleep(1); printf("Thread is running\n"); } pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL); //перевод потока в //состояние досрочного завершения pthread_testcancel(); //завершение потока; указание точки завершения потока printf("etcetcetc\n"); //этот текст вы не увидите, ибо поток будет прерван } int main(int argc, char * argv[]) { pthread_t thread; //дескриптор потока pthread_create(&thread, NULL, thread_func, NULL); //создание потока while (i < 1) sleep(1); //задержка pthread_cancel(thread); //запрос на завершение потока printf("Stop!\n"); pthread_join(thread, NULL); printf("The thread is stopped.\n"); return 0; }
В самом начале функции потока thread_func() мы запрещаем досрочное завершение потока, затем выводим четыре тестовых сообщения с интервалом в одну секунду, после чего разрешаем досрочное завершение. Далее, с помощью функции pthread_testcancel(), мы создаем точку отмены (точку завершения) потока. Если досрочное завершение потока было затребовано, в этот момент поток должен завершиться.
В главной функции программы мы создаем поток, затем дожидаемся, пока значение глобальной переменной i станет больше нуля (это гарантирует нам, что поток уже запретил досрочное завершение) и вызываем функцию pthread_cancel(). После этого мы переходим к ожиданию завершения потока с помощью pthread_join().
Поскольку поток завершится досрочно, последнего тестового сообщения вы не увидите. Интересна роль функции pthread_testcancel(). Как уже отмечалось, эта функция создает точку отмены потока. Зачем нужны особые точки отмены? Дело в том, что даже если досрочное завершение разрешено, поток, получивший запрос на досрочное завершение, может завершить работу не сразу. Если поток находится в режиме отложенного досрочного завершения (именно этот режим установлен по умолчанию), он выполнит запрос на досрочное завершение, только достигнув одной из точек отмены. В соответствии со стандартом POSIX, точками отмены являются вызовы многих ?обычных? функций, например open(), pause() и write(). Про функцию printf() в документации сказано, что она может быть точкой отмены, но в ОС Linux при попытке остановиться на printf() происходит нечто странное ? поток завершается, но pthread_join() не возвращает управления. Поэтому мы создаем явную точку отмены с помощью вызова pthread_testcancel().
Впрочем, мы можем выполнить досрочное завершение потока, не дожидаясь точек останова. Для этого необходимо перевести поток в режим немедленного завершения, что делается с помощью вызова pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL); В этом случае беспокоиться о точках останова уже не нужно. Вызов pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, NULL); снова переводит поток в режим отложенного досрочного завершения.