25/02/2020

Многопоточность в POSIX или Multithreading в ANSI C (1): поток и процесс, начало работы с libpthread

В этой статье мы научимся создавать, запускать и останавливать потоки в POSIX-совместимых системах на Plain C.
Сначала вкратце рассмотрим, что такое поток. Воспользуемся последним стандартом POSIX.1-2017, чтобы разобраться в терминологии:
Live Process
An address space with one or more threads executing within that address space, and the required system resources for those threads.
Здесь написано следующее: процесс есть адресное пространство с одним или более потоков выполняющимися в нём и необходимыми системными ресурсами для этих потоков.
Приписка «живой» нас не интересует, в стандарте используется термин Live Process для отличия его от Zombie Process. В этой статье мы будем говорить только о «живых».
Так же в стандарте написано, что многие системные ресурсы определённые стандартом являются общими для всех потоков в процессе. Эти ресурсы включают в себя ID процесса, ID родительского процесса, ID группы процессов, текущую директорию, файловые дескрипторы и пр.
Thread
A single flow of control within a process. Each thread has its own thread ID, scheduling priority and policy, errno value, floating point environment, thread-specific key/value bindings, and the required system resources to support a flow of control. Anything whose address may be determined by a thread, including but not limited to static variables, storage obtained via malloc(), directly addressable storage obtained through implementation-defined functions, and automatic variables, are accessible to all threads in the same process.
Что означает: поток есть одно «течение контроля» (нам, русскоязычным, привычнее термин «поток выполнения»). Каждый поток имеет собственный ID, приоритет планировщика и политики, значение errno и пр.. Всё, чей адрес может быть определён потоком, включая, но не ограничиваясь статическими переменными, динамически выделенная память и др. доступны всем потокам одного процесса.

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

Я считаю, что разобраться в этом было важно, так как всё (во всяком случае системное) программирование заключается в распределении ресурсов операционной системы и управлении ими, а то, что мы выше почерпнули из стандарта даёт следующие выводы:
  1. Мы можем пользоваться (читать, писать) ресурсами в своих потоках в пределах одного процесса без использования т.н. IPC (методы межпроцессного взаимодействия).
  2. Мы не можем обращаться к ресурсам других процессов без применения IPC. Даже из т.н. основного потока выполнения (main (), например), даже из под root'а. А POSIX хоть и предоставляет инструменты IPC, но это достаточно сложные механизмы.
Теперь рассмотрим технологию создания второго и последующих потоков. На всякий случай, напомню, первый у нас всегда есть — его создала ОС при запуске вашей программы (возможно, о том как это происходит я напишу). Этот поток я называю основным.
В самой по себе ANSI C нет механизмов работы с многопоточностью. Есть различные реализации работы с потоками. Например, специфичная GNU Portable Threads, Protothreads и пр. Первая преследовала цель максимальной портируемости, вторая — легковесности. Мы будем рассматривать самый распространённый (хотя и не самый оптимальный, почему я распишу в последующих статьях) стандарт, т.н. POSIX Threads (pthread). POSIX Threads API реализован в библиотеке libpthread.

Приведём пример простой многопоточной программы:

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

/*
 * Здесь мы проверяем, есть ли в системе POSIX Threads
 */
#ifndef _POSIX_THREADS
#error No POSIX Threads!
#endif

/*
 * Функция, код которой будет выполняться в новом потоке.
 * Обратите внимание на то, что мы можем передать сюда
 * указатель на область памяти. Это можно использовать
 * как механизм передачи некоторых начальных данных потоку.
 */
void *ThreadRoutine (void *_Arg)
{
  printf ("String argument passed to thread routine: %s\n", 
          (char *)_Arg);
  while (0)
  {
    sleep (1);
    printf ("Thread is doing infinite work...\n");
  }
  printf ("Thread is about to exit normally...\n");
  
  /*
   * Завершить выполнение потока можно двумя способами: 
   * return и pthread_exit () с возвращаемым нами значением 
   * в параметре. Они эквивалентны за исключением некоторой
   * (незначительной) разницы в быстродействии, что, в свою
   * очередь, связано с функционалом вызова обработчиков 
   * завершения потока. Но мы их здесь не будем рассматривать.
   */
  /* return (void *)(uintptr_t)0xC0FFEE; */
  pthread_exit ((void *)(uintptr_t)0xC0FFEE);
  /*
   * А что будет, если вызвать exit () из функции потока?
   * Приложение завершится, так как exit () это не возврат
   * из функции, а более сложный системный вызов, который
   * "корректно" закрывает приложение вместе со всеми 
   * потоками. Недопонимание этой разницы может возникнуть 
   * из-за того, что мы часто завершаем наше приложение 
   * простым return'ом в main'е. На самом деле, при 
   * "возврате" из main'а компилятор подставляет функционал 
   * "корректного" закрытия программы. Поэтому завершать 
   * работу потока нужно только через return/pthread_exit (). 
   * Вы можете заменить return/pthread_exit () на exit (0) 
   * и посмотреть что произойдёт - вы не увидите вывода 
   * "Thread return".
   */
  /* exit (0); */
}

int main (int _argc, char * _argv[])
{
  /* Идентификатор потока, на Linux'е это целое число, на
   * Mac OS это структура.
   */
  pthread_t thread_id;
  /* Возвращаемое значение функции потока */
  void *ThreadRet = NULL;
  /*
   * Функция pthread_create создаёт поток. Первый параметр - 
   * идентификатор потока (для дальнейших операций с ним). 
   * Второй - аттрибуты, третий - функция потока, четвёртый - 
   * аргумент (мы передадим туда название исполняемого файла).
   */
  pthread_create (&thread_id, NULL, ThreadRoutine, _argv[0]);
  /*
   * Функция pthread_join ожидает выхода из функции потока.
   * Так же здесь мы получаем возвращаемое значение из потока.
   * Перед тем как раскомментировать этот вызов, убедитесь, что
   * цикл while в функции потока выключен (например, поставьте 
   * while (0)). Иначе программа зависнет в ожидании завершения 
   * потока. Ведь pthread_join () ждёт корректного завершения
   * работы потока.
   */
   pthread_join (thread_id, &ThreadRet);
  /*
   * Перед тем как включить этот эксперимент, поставьте в функции
   * потока while (1), чтобы включить его бесконечную работу.
   * Функция pthread_cancel () аварийно завершает поток.
   * Дадим ему 5 секунд "пожить" и завершим.
   */
  /* sleep (5); */
  /* pthread_cancel (thread_id); */
  /* Выводим значение, возвращённое из функции потока */
  printf ("Thread return: %lX\n", (uintptr_t)ThreadRet);
  return EXIT_SUCCESS;
}


NB!
Обратите внимание на то, как при возврате из функции потока, я создаю указатель на некий адрес. Это своеобразный трюк. Чтобы не выделять память для возвращаемого значения, с последующим её освобождением, но вернуть простой int, как результат работы, я вернул указатель на адрес памяти, который придумал из головы. Вы можете возвращать указатели на любые адреса, и анализировать их по возврату из pthread_join (). Но стоит помнить, что обращаться по таким, вымышленным адресам нельзя.

Забрать пример:
https://gitlab.com/daftsoft/pthread1.git

Собирать программы с POSIX Threads нужно с указанием библиотеки pthread:
gcc -std=c89 -pedantic -Wall pthread1.c -lpthread -o pthread1

К слову, в Linux'е не всегда был реализован стандарт POSIX Threads. Как же были реализованы потоки в Linux'е до «принятия» POSIX Threads? В то время (c 2.0 до 2.4 включительно) в Linux'е была своя технология, называлась она LinuxThreads. В общих чертах, она заключалась в копировании кода процесса с предоставлением доступа к памяти родительского процесса. При этом, у потоков были разные идентификаторы процессов (PID) и взаимодействие между ними осуществлялось посредством одного из механизмов IPC — сигналов (USR1 и USR2), что исключало возможность использовать этих сигналов разработчиками. То есть, по современным меркам (или по меркам стандарта), это были не совсем потоки. Всё это несовершенство продолжалось до начала 2000-х годов, когда LinuxThreads была заменена библиотекой потоков POSIX (Native POSIX Thread Library, NPTL), реализующей стандарт POSIX Threads в Linux.
Представленного примера достаточно для того, чтобы начать писать свои первые многопоточные программы.
В следующей статье мы, на примере, рассмотрим как действительно работают «общие ресурсы» в потоках в пределах одного процесса, а так же иные аспекты управления потоками.