Потоки в POSIX

Потоки в POSIX

Поток (thread, нить) — независимый набор инструкций программы и наименьшая единица обработки, исполнение которой может быть запланировано ядром ОС. Поток имеет свой стек и исполняет некоторый заданный программистом участок кода. В отличие от процессов, поток, как правило, разделяет свою память с другими потоками (в то время как процессы расположены в разных адресных постранствах). Группа потоков — это набор потоков, исполняющихся в рамках одного процесса. Все они имеют разделяемый доступ к общей памяти: к общим глобальным переменным, общей куче, общему набору файловых дескрипторов и т. д. Такие потоки могут выполняться (псевдо)параллельно.

Исторически разработчики компьютерных систем опирались на свои собственные проприетарные реализации потоков. Эти реализации разнились между собой и являлись камнем преткновения для написания сторонними разработчиками переносимых многопоточных программ. Чтобы получить полное преимущество от использования потоков при написании программ, требовалась стандартизация программных интерфейсов операционных систем. Для UNIX-подобных систем в 1995 г. был разработан стандарт IEEE POSIX 1003.1c. Реализации потоков по правилам указанного стандарта носят названия потоков POSIX или Pthreads. Большинство производителей оборудования теперь включают Pthreads в дополнении к своим проприетарным реализациям потоков.

Если написать многопоточную программу правильно, то она, будучи распараллеленной на потоки, будет иметь ряд преимуществ перед аналогичной программой, распараллеленной на процессы. По сравнению со стандартным вызовом fork() потоки несут намного меньше накладных расходов. fork() создаёт в памяти повторную копию вызванного процесса. Родительский и дочерний процессы полностью независимы: каждый имеет своё собственное адресное пространство и свою копию переменных. Потоки разделяют общее адресное пространство и поэтому:

  1. ядру ОС не нужно тратить время на создание новой независимой копии процесса. Создание потока может происходить в десятки, сотни раз быстрее, чем создание процесса. Можно наплодить потоков и не беспокоиться о накладных расходах процессора и памяти;
  2. меньше времени уходить на завершение потока, нежели процесса;
  3. переключение контекста между потоками происходит опять же быстрее;
  4. обмен данными между потоками прост, поскольку потоки работают в одном адресном пространстве. Данные, созданные одним потоком, сразу становятся доступными другим потокам.

С другой стороны, поскольку речь идет о группе потоков, то один поток может исказить содержимое общей памяти, а другой поток — пострадать от этого. Процессы же лишены такой проблемы, так как ОС защищает их от взаимного влияния, располагая их в разных адресных пространствах.

Потоки могут быть полезны, например, при:

  1. построении пользовательских интерфейсов. Программы, взаимодействующие с пользователем, обычно находятся в цикле ожидания реакций пользователя, чтения ввода, обработки введенных данных и отображения полученных результатов. Часто указанная обработка может занимать некоторое время — пользователь вынужден ждать. Если поместить длительную операцию обработки в один поток, а чтение пользовательского ввода — в другой, то можно повысить реактивность (отзывчивость) программы. Это может позволить отменить операцию посередине.
  2. построении графических интерфейсов. Здесь — круче. GUI-приложение всегда должно быть готово к получению сообщения от оконной системы, говорящего ему о перерисовке части окна. Если GUI-программа слишком занята выполнением другой задачи, то начинаются всякого рода уродства, связанные с некорректной отрисовкой. В таком случае хорошей идеей будет иметь один поток, обрабатывающий цикл сообщений оконной системы, и всегда готовый получать такие запросы на перерисовку (а также вводимые пользователем данные). Всякий раз, когда этот поток видит необходимость выполнить операцию, которая может занять много времени (скажем, более 0,2 секунды), он делегирует задание отдельному потоку.
  3. разработке веб-серверов. Возникает необходимость как можно быстрее обрабатывать множественные запросы, поступающие со стороны клиентов. Каждый отдельный запрос можно обрабатывать в отдельном потоке. В многопроцессорных системах выгода от многопоточности вытекает из распараллеливания обработки потоков на разных вычислительных ядрах.

В момент запуска многопоточной программы она работает в одном потоке, который состоит в выполнении функции main(). Однако, это уже полноценный поток со своим идентификатором (Thread ID). Для создания нового потока программа должна воспользоваться библиотечным вызовом pthread_create().

Рассмотрим пример:

01 #include <stdio.h>
02 #include <stdlib.h>
03 #include <pthread.h>
04
05 /* Эта функция будет исполняться в новом потоке */
06 void* PrintHello(void* data){
07   int* my_data = (int*) data;
08   pthread_detach(pthread_self());
09   printf("Привет от нового потока - получено %d\n", *my_data);
10   pthread_exit(NULL);
11 }
12
13 int main(int argc, char* argv[]){
14   int rc;
15   pthread_t thread_id;
16   int t = 11;
17
18   rc = pthread_create(&thread_id, NULL, PrintHello, &t);
19   if (rc){
20     printf("\n ОШИБКА: код возврата pthread_create - %d\n", rc);
21     exit(1);
22   }
23   printf("\n Создан новый поток с номером %u ... \n", thread_id);
24   pthread_exit(NULL);
25 }

Эта программа не делает ничего полезного. Она нужна, чтобы разобраться, как работают потоки. В функции main() мы объявляем переменную thread_id, тип которой pthread_t. Эта просто целое число для идентификации потоков системе. После объявления thread_id мы вызываем функцию pthread_create(), которая создаёт поток.

Функция pthread_create() принимает 4 аргумента. Первый — это адрес идентификатора потока (указатель на переменную thread_id). Второй аргумент предназначен для установки некоторых атрибутов нового потока. В нашем примере, передавая в качестве второго аргумента NULL, мы сообщаем функции, чтобы она использовала атрибуты по умолчанию. Отмечаем, что функция PrintHello() возвращает и принимает в качестве аргумента обобщенный указатель void*. Это сделано для передачи произвольных данных в функцию PrintHello(). Чтобы передать конкретные данные в эту функцию, мы используем четвертый её аргумент. Если мы не хотим передавать никакие данные в PrintHello(), то четвертый аргумент устанавливаем в NULL. Вызов pthread_create() возвращает число 0 в случае успешного создания потока. В случае неуспеха она вернет ненулевое число. После успеха функции pthread_create() программа будет состоять из двух потоков. Так происходит по причине того, что main() тоже (изначально) работает в потоке. Условно можно сказать в «главном» потоке.

Вызов pthread_exit() завершает текущий поток и высвобождает связанные с этим потоком ресурсы.

Компиляция программы:
gcc -pthread hello.c -o hello.out

Функция pthread_self() возвращает идентификатор текущего потока. На Linux-машинах идентификаторы потоков — большие числа, поэтому для корректной печати таких чисел используйте плейсхолдер %u.

Задача: Модифицируйте код программы hello.c таким образом, чтобы печатались идентификаторы обоих потоков, а идентификатор «главного» потока передавался во второй поток. Результат работы может выглядеть так:

Я поток с номером 20748096. Создан новый поток с номером 20743936 ...
Привет от нового потока с номером 20743936 - получено 20748096

Существует несколько способов завершения потоков. Один безопасный из них — вызов pthread_exit().

Задача: Модифицируйте код программы hello.c следующим образом:

В PrintHello() добавьте sleep(1) перед printf(). Это заставит поток уснуть на одну секунду и затем продолжить своё исполнение. Таким образом мы попытаемся завершить второй поток после того как «главный» поток завершит своё исполнение. Найдите в справочной документации UNIX, какой заголовочный файл необходимо подключить, чтобы воспользоваться функцией sleep().

Теперь закомментируйте выражение pthread_exit() в конце функции main(). Перекомпилируйте и запустите hello.out ...

Вы удивлены, почему второй поток не исполнился? Разберемся, что произошло. Для начала расскоментируйте pthread_exit() в конце main() и закомментируйте pthread_exit() в конце функции PrintHello(). Добавьте вызов sleep() в конце main() перед printf(), но уберите его из функции PrintHello(). Теперь «главный» поток будет завершаться в последнюю очередь. Перекомпилируйте и запустите hello.out.

Вывод: в конце main() вызов pthread_exit() необходим, в противном случае все потоки будут автоматически уничтожены.

Теперь поговорим о функции pthread_join(). Она аналогична функции wait() для процессов. Вызов pthread_join() блокирует запуск потоков до тех пор, пока не завершится поток, идентификатор которого передается в pthread_join().

01 #include <stdio.h>
02 #include <pthread.h>
03 #include <unistd.h>
04 #include <stdlib.h>

05
06 void* PrintHello(void* data){
07   pthread_t tid = (pthread_t) data;
08   pthread_join(tid, NULL);
09   printf("Привет от нового потока с номером %u - получено %u\n", pthread_self(), data);
10   pthread_exit(NULL);
11 }
12
13 int main(int argc, char* argv[]){
14   int rc;
15   pthread_t thread_id;
16   int tid;
17   tid = pthread_self();
18   rc = pthread_create(&thread_id, NULL, PrintHello, &tid);
19   if (rc){
20     printf("\n ОШИБКА: код возврата pthread_create - %d\n", rc);
21     exit(1);
22   }
23   sleep(1);
24   printf("\n Создан новый поток с номером %u ...\n", thread_id);
25   pthread_exit(NULL);
26 }