Представляю вашему вниманию третью (заключительную) часть перевода статьи A guide to inter-process communication in Linux.

Первая часть перевода была посвящена общему введению в курс дела и механизму разделяемого хранилища (shared storage). Во второй части были рассмотрены механизмы каналов (именованных и неименованных) и очереди сообщений. В третьей части автор статьи ставит перед собой цель рассказать вам о сокетах и сигналах; подводит общие итоги по межпроцессному взаимодействию в Linux.

Приятного чтения!

Сокеты и сигналы

В этом разделе описывается IPC от высокоуровневого (сокеты) до низкоуровневого (сигналы). Детали будут конкретизированы в примерах кода.

Сокеты

Сокеты, также как и каналы (именованные и неименованные), предоставляются в двух видах. Сокеты IPC (они же Unix domain socket) обеспечивают канальную связь для процессов на одном и том же физическом устройстве - хосте (host), тогда как сетевые сокеты обеспечивают взаимодействие для процессов, которые могут быть запущены на разных хостах. Для сетевых сокетов требуется поддержка протоколов TCP (Transmission Control Protocol) или протокола более низкого уровня UDP (User Datagram Protocol).

Напротив, в сокетах IPC для коммуникации используется локальное системное ядро (local system kernel); в частности, сокеты IPC взаимодействуют друг с другом через локальный файл, используемый в качестве адреса сокета. Несмотря на эти различия в реализации, API у сокетов IPC и сетевых сокетов в основном совпадает. В следующем примере рассматриваются сетевые сокеты, однако примеры серверной и клиентской программы могут быть запущены на одной и той же машине - сетевой адрес сервера будет localhost (127.0.0.1) - адрес локальной машине на самой локальной машине.

Сокеты, сконфигурированные как потоки (обсуждается ниже), являются двунаправленными, и работают по принципу "клиент-сервер": клиент инициирует взаимодействие, пытаясь подключиться к серверу, который, в свою очередь, пытается принять соединение. Если всё в порядке, запросы от клиентов и ответы от сервера будут передаваться по каналу до тех пор, пока канал не будет закрыт с одного из концов.

Итеративный (iterative) сервер обрабатывает подключенных клиентов последовательно по одному за раз: сначала от начала до конца обрабатывается первый клиент, затем второй, и т.д. Недостатком является то, что обработка одного из клиентов может подвиснуть, тем самым лишая всех следующих в очереди клиентов доступа к серверу. Параллельный (concurrent) сервер в своей работе использует как многопроцессность (multi-processing), так и многопоточность (multi-threading). К примеру, у веб-сервера nginx на моем ПК имеется пул из четырех ==рабочих== (worker) процессов, которые могут параллельно заниматься обработкой процессов. Для того, чтобы сфокусировать на API, а не на вопросах параллелизма, в следующем примере будет использоваться итеративный сервер.

С течением времени API сокетов значительно усовершенствовался из-за развития стандарта POSIX. Текущий пример кода сервера и клиента намеренно упрощён, но, тем не менее, он подчеркивает двунаправленный характер потокового (stream-based) соединения сокетов. Ниже представлено краткое изложение потока управления (flow of control), когда сервер запущен в одном терминале, а клиент - в другом:

  • Сервер ждет подключения клиента, и, при его успешном подключении, считывает байты от клиента.

  • Сервер возвращает обратно полученные от клиента байты (чтобы подчеркнуть двунаправленность взаимодействия). Эти байты - ASCII коды символов, представляющие собой наименования книг.

  • Клиент отправляет серверу наименования книг и получает их же от сервера в ответ. И сервер, и клиент выводят наименования на экран. Вывод сервера (у клиента будет то же самое):

Listening on port 9876 for clients...
War and Peace
Pride and Prejudice
The Sound and the Fury

Пример 1. Сервер socket

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include "sock.h"

void report(const char* msg, int terminate) {
  perror(msg);
  if (terminate) exit(-1); /* failure */
}

int main() {
  int fd = socket(AF_INET,     /* network versus AF_LOCAL */
		  SOCK_STREAM, /* reliable, bidirectional: TCP */
		  0);          /* system picks underlying protocol */
  if (fd < 0) report("socket", 1); /* terminate */
  	
  /* bind the server's local address in memory */
  struct sockaddr_in saddr;
  memset(&saddr, 0, sizeof(saddr));          /* clear the bytes */
  saddr.sin_family = AF_INET;                /* versus AF_LOCAL */
  saddr.sin_addr.s_addr = htonl(INADDR_ANY); /* host-to-network endian */
  saddr.sin_port = htons(PortNumber);        /* for listening */
  
  if (bind(fd, (struct sockaddr *) &saddr, sizeof(saddr)) < 0)
    report("bind", 1); /* terminate */
	
  /* listen to the socket */
  if (listen(fd, MaxConnects) < 0) /* listen for clients, up to MaxConnects */
    report("listen", 1); /* terminate */

  fprintf(stderr, "Listening on port %i for clients...\n", PortNumber);
  /* a server traditionally listens indefinitely */
  while (1) {
    struct sockaddr_in caddr; /* client address */
    int len = sizeof(caddr);  /* address length could change */
    
    int client_fd = accept(fd, (struct sockaddr*) &caddr, &len);  /* accept blocks */
    if (client_fd < 0) {
      report("accept", 0); /* don't terminated, though there's a problem */
      continue;
    }

    /* read from client */
    int i;
    for (i = 0; i < ConversationLen; i++) {
      char buffer[BuffSize + 1];
      memset(buffer, '\0', sizeof(buffer)); 
      int count = read(client_fd, buffer, sizeof(buffer));
      if (count > 0) {
	puts(buffer);
	write(client_fd, buffer, sizeof(buffer)); /* echo as confirmation */
      }
    }
    close(client_fd); /* break connection */
  }  /* while(1) */
  return 0;
}

Серверная программа (представлено выше) выполняет классический четырехшаговый алгоритм, в котором она осуществляет подготовку к запросам от клиентов, и затем принимает эти запросы по одному за раз. Каждый шаг называется в соответствии с системной функцией, которую вызывает сервер:

  1. socket(...) - получить файловый дескриптор сокетового соединения

  2. bind(...) - связать сокет с адресом на хосте сервера

  3. listen(...) - слушать (ожидать) запросы от клиентов

  4. accept(...) - принять запрос от клиента.

Полный вызов socket выглядит следующим образом:

int fd = socket(AF_INET,     /* versus AF_LOCAL */
			    SOCK_STREAM, /* reliable, bidirectional */
			    0);          /* system picks protocol (TCP)*/

Первый аргумент определяет сетевой сокет (в противоположность сокету IPC). У второго аргумента существует много вариантов, но чаще всего используются SOCK_STREAM и SOCK_DGRAM (датаграмма).

Потоковые (stream-based) сокеты поддерживают надежный (reliable) тип соединения, при котором отслеживаются потери или изменения сообщений; канал является двунаправленным, и полезная нагрузка (payload) может быть произвольного размера. В отличии от этого, сокеты на основе датаграмм (datagram-based) являются менее надёжными, однонаправленными и поддерживают только фиксированный размер полезной нагрузки.

Для потоковых сокетов (которые используются в нашем коде) можно выбрать только один протокол - TCP (представлен как 0). Поскольку при успешном вызове socket вовзращается уже знакомый нам файловый дескриптор, синтаксис чтения и записи такой же, как и в случае обычных файлов.

Вызов bind более сложный. Интерес вызывает то, что вызов этой функции привязывает сокет к адресу памяти на серверном ПК.

При этом вызов listen очень простой:

if (listen(fd, MaxConnects) &lt; 0)

Первый аргумент - файловый дескриптор сокета; второй аргумент определяет максимальное количество соединений с клиентами - при последующей попытке подключения сервер выдаст ошибку об отказе в подключении (connection refused error). (MaxConnects определён как 8 в заголовочном файле sock.h)

Вызов accept по умолчанию блокирующий: сервер будет ожидать подключения клиента. Функция accept возвращает -1 в случае ошибки. Если вызов прошёл успешно, он возвращает еще один файловый дескриптор для read/write сокета. Сервер использует этот сокет для считывания клиентских запросов и записи ответов клиенту. В качестве первого аргумента вызова accept передаётся accept socket, который используется только для приёма клиентских подключений.

Сервер выполняется бесконечно (целенаправленно). Завершить его работу можно нажатием Ctrl+C в терминале.

Пример 2. Клиент socket

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <netdb.h>
#include "sock.h"

const char* books[] = {"War and Peace",
		       "Pride and Prejudice",
		       "The Sound and the Fury"};

void report(const char* msg, int terminate) {
  perror(msg);
  if (terminate) exit(-1); /* failure */
}

int main() {
  /* fd for the socket */
  int sockfd = socket(AF_INET,      /* versus AF_LOCAL */
		      SOCK_STREAM,  /* reliable, bidirectional */
		      0);           /* system picks protocol (TCP) */
  if (sockfd < 0) report("socket", 1); /* terminate */

  /* get the address of the host */
  struct hostent* hptr = gethostbyname(Host); /* localhost: 127.0.0.1 */ 
  if (!hptr) report("gethostbyname", 1); /* is hptr NULL? */
  if (hptr->h_addrtype != AF_INET)       /* versus AF_LOCAL */
    report("bad address family", 1);
  
  /* connect to the server: configure server's address 1st */
  struct sockaddr_in saddr;
  memset(&saddr, 0, sizeof(saddr));
  saddr.sin_family = AF_INET;
  saddr.sin_addr.s_addr = 
     ((struct in_addr*) hptr->h_addr_list[0])->s_addr;
  saddr.sin_port = htons(PortNumber); /* port number in big-endian */
  
  if (connect(sockfd, (struct sockaddr*) &saddr, sizeof(saddr)) < 0)
    report("connect", 1);
  
  /* Write some stuff and read the echoes. */
  puts("Connect to server, about to write some stuff...");
  int i;
  for (i = 0; i < ConversationLen; i++) {
    if (write(sockfd, books[i], strlen(books[i])) > 0) {
      /* get confirmation echoed from server and print */
      char buffer[BuffSize + 1];
      memset(buffer, '\0', sizeof(buffer));
      if (read(sockfd, buffer, sizeof(buffer)) > 0)
	puts(buffer);
    }
  }
  puts("Client done, about to exit...");
  close(sockfd); /* close the connection */
  return 0;
}

В клиентской программе (представлена выше) настройка осуществляется аналогично серверной программе. Принципиальное отличие состоит в том, что клиент ничего не слушает и не принимает, а только устанавливает соединение:

if (connect(sockfd, (struct sockaddr*) &amp;saddr, sizeof(saddr)) &lt; 0)

Вызов connect может закончиться ошибкой по нескольким причинам: к примеру, указан неправильный адрес сервера или к серверу уже подключено слишком много клиентов. Если операция connect завершается успешно, клиент пишет запросы и считывает ответы в цикле for. Обменявшись сообщениями, и сервер, и клиент закрывают свои read/write сокеты (однако было бы достаточно, чтобы это сделал только кто-то один из них). После этого клиент завершает работу, но, как отмечалось раньше, сервер продолжает работать.

Рассмотренный пример, в котором сообщения из запросов возвращаются обратно клиенту, намекает на то, что клиент и сервер может обмениваться абсолютно произвольными данными. Возможно, в этом и заключается главное преимущество сокетов. В современных системах широко распостранено взаимодействие клиентских и серверных приложений посредством сокетов (к примеру, клиент БД). Как отмечалось ранее, локальные сокеты IPC и сетевые сокеты не сильно отличаются в реализации: API по факту используется один и тот же. Хотя, в целом, сокеты IPC имеют меньшие накладные расходы и лучшую производительность.

Сигналы

Сигналы прерывают выполнение программы, и, в этом смысле, с ней взаимодействуют. Большую часть сигналов можно проигнорировать или обработать, за исключением сигналов SIGSTOP (приостановить) и SIGKILL (немедленно завершить выполнение).

Символьным константам (SIGKILL) соответствуют целые числа (9).

Пример 3. Мягкое (graceful) завершение работы многопроцессной системы

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

void graceful(int signum) {
  printf("\tChild confirming received signal: %i\n", signum);
  puts("\tChild about to terminate gracefully...");

  sleep(1);

  puts("\tChild terminating now...");
  _exit(0); /* fast-track notification of parent */
}

void set_handler() {
  struct sigaction current;
  sigemptyset(&current.sa_mask);         /* clear the signal set */
  current.sa_flags = 0;                  /* enables setting sa_handler, not sa_action */
  current.sa_handler = graceful;         /* specify a handler */
  sigaction(SIGTERM, &current, NULL);    /* register the handler */
}

void child_code() {
  set_handler();

  while (1) {   /** loop until interrupted **/
    sleep(1);
    puts("\tChild just woke up, but going back to sleep.");
  }
}

void parent_code(pid_t cpid) {
  puts("Parent sleeping for a time...");
  sleep(5);

  /* Try to terminate child. */
  if (-1 == kill(cpid, SIGTERM)) {
    perror("kill");
    exit(-1);
  }
  wait(NULL); /** wait for child to terminate **/
  puts("My child terminated, about to exit myself...");
}

int main() {
  pid_t pid = fork();
  if (pid < 0) {
    perror("fork");
    return -1; /* error */
  }
  if (0 == pid) 
    child_code();
  else 
    parent_code(pid);
  return 0;  /* normal */
}

Сигналы могут возникать при взаимодействии с пользователем. Например, для того, чтобы завершить выполнение программы в командной строке, пользователь нажимает Ctrl+C, тем самым генерируя сигнал SIGTERM. В отличии от SIGKILL, SIGTERM может быть либо проигнорирован, либо обработан.

Один процесс может отправить сигнал другому, что означает, что сигналы тоже можно считать механизмом IPC. Рассмотрим, как можно из другого процесса мягко (graceful) завершить работу некого многопроцессного приложения (например, веб-сервер Nginx).

Функция kill:

int kill(pid_t pid, int signum);

может быть использована одним процессом для того, чтобы завершить работу другого процесса или группы процессов.

Если первый аргумент функции kill больше нуля, он рассматривается как PID (process ID) процесса, который требуется завершить; если аргумент равен 0, аргумент рассматривается как группа процессов, которой принадлежит отправитель сигнала.

Второй аргумент kill - это либо номер одного из стандартных сигналов (например, SIGTERM или SIGKILL), либо 0, что по факту прерващает вызов функции в запрос, является ли PID в первом аргументе валидным. Мягкое завершение работы многопроцессной системы может быть выполнено вызовом функции kill с SIGTERM в качестве второго аргумента - т.е., отправкой группе процессов, соответствующей системе, сигнала о завершении работы (terminate signal). (Вызовом kill мастер-процесс Nginx может завершить рабочие (worker) процессы и затем завершиться самому). Функция kill, как и многие библиотечные функции, сочетает в себе мощь и гибкость в простом синтаксисе вызова (cм. Пример 3).

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

1) Родительский процесс пытается создать (fork) дочерний процесс. В случае успеха, каждый процесс приступает к выполнению собственного кода: функция child_code выполняется дочерним, функция parent_code - родительским.

2) Дочерний процесс заходит в (потенциально) бесконечный цикл, в котором на секунду засыпает, выводит сообщений, снова засыпает, и т.д. Сигнал SIGTERM от родителя приводит к выполнению дочерним процессом функции обратного вызова (callback) graceful, предназначенной для обработки сигналов, тем самым выводя дочерний процесс из цикла и осуществляя мягкое завершение работы и родителя, и потомка. Перед завершением работы дочерний процесс выводит сообщение.

3) Родительский процесс, после создания дочернего, засыпает на 5 секунд, чтобы дочерний процесс смог какое-то время поработать (большую часть времени потомок будет спать). Затем родитель вызывает функцию kill с SIGTERM во втором аргументе, ждёт, пока дочерний процесс прекратит выполнение, и завершается сам.

Пробный запуск:

% ./shutdown
Parent sleeping for a time...
	Child just woke up, but going back to sleep.
	Child just woke up, but going back to sleep.
	Child just woke up, but going back to sleep.
	Child just woke up, but going back to sleep.
	Child confirming received signal: 15 ## SIGTERM is 15
	Child about to terminate gracefully...
	Child terminating now...
My child terminated, about to exit myself...

Для обработки сигналов в примере используется библиотечная функция sigaction (рекомендовано POSIX) вместо legacy функцииsignal, у которой имеются проблемы с переносимостью.

4) Если вызов fork завершается успешно, родительский процесс выполняет функцию parent_code, а дочерний - child_code. Перед тем, как отправить сигнал дочернему процессу, родитель ждёт 5 секунд:

puts("Parent sleeping for a time...");
sleep(5);
if (-1 == kill(cpid, SIGTERM)) {
...

В случае успешного вызова kill родительский процесс ждёт окончания работы дочернего процесса для того, чтобы не допустить превращения потомка в зомби-процесс. Когда ожидание заканчивается, родитель завершается.

Функция child_code сначала вызывает set_handler и затем входит в бесконечный цикл. Функция set_handler выглядит следующим образом:

void set_handler() {
  struct sigaction current;           /* current setup */
  sigemptyset(¤t.sa_mask);      /* clear the signal set */
  current.sa_flags = 0;               /* for setting sa_handler,
							             not sa_action */
  current.sa_handler = graceful;      /* specify a handler */
  sigaction(SIGTERM, ¤t, NULL); /* register the handler */
}

Первые три строки - настройка. Четвертый оператор устанавливает в качестве обработчика функцию graceful, которая выводит сообщение перед вызовом _exit для завершения работы. Затем пятый и шестой операторы регистрируют обработчик в системе посредством вызова sigaction. Первый аргумент - SIGTERM, второй - текущая настройка sigaction, последний аргумент (NULL в нашем случае) может быть использован для того, чтобы сохранить предыдущие настройки sigaction, например, для дальнейшего использования.

Использование сигналов в качестве механизма IPC - это действительно минималистический подход, но при этом проверенный временем.

Заключение

Данное руководство в примерах кода охватывает следующие механизмы IPC:

  • Разделяемые файлы (shared files)

  • Разделяемая память (shared memory) и семафоры (semaphore)

  • Именованные и неименованные каналы (named and unnamed pipes)

  • Очереди сообщений (message queues)

  • Сокеты (sockets)

  • Сигналы (signals)

Даже сегодня, когда потокоориентированные (thread-centric) языки (Java, C#, Go) стали сильно популярными, IPC остаётся привлекательным механизмом, поскольку параллелизм через многопроцессность имеет очевидное преимущество перед многопоточностью: каждый процесс по умолчанию имеет своё адресное пространство, что исключает состояние гонки (memory-based race condition). (Если только не задействована разделяемая память. Для безопасного параллелизма, как для многопроцессности, так и для многопоточности, разделяемая память должна быть заблокирована). Каждый, кто когда-нибудь писал хотя бы простейшую многопоточную программу с разделяемыми переменными, знает, кто сложно написать потокобезопасный и при этом аккуратный, эффективный код. Многопроцессность остаётся весьма привлекательным способом воспользоваться преимуществами современных многопроцессорных ПК без неотъемлемого риска возникновения состояний гонки.

Само собой, не существует простого ответа, какой из механизмов IPC лучше - это всегда будет компромиссом между простотой и функциональностью. Сигналы, к примеру, являются относительно простым механизмом IPC; при этом у них нет больших возможностей по взаимодействию между процессами. Если возможности всё-таки требуются, следует выбрать любой другой вариант. Разделяемые файлы с блокировкой тоже достаточно просты, но не совсем подходят для случаев, когда требуется разделить большие объемы данных; каналы и сокеты (с более сложными API) были бы лучшим выбором.

Несмотря на то, что все примеры кода (доступные на моем сайте) написаны на С, другие языки программирования часто предоставляют тонкие "обёртки" (thin wrappers) над механизмами IPC. Я надеюсь, что примеры кода были достаточно простыми для того, чтобы вы смогли решиться поэкспериментировать с ними самостоятельно.

Комментарии (1)


  1. kozlyuk
    27.09.2024 16:59
    +6

    Про сигналы не сказано самого важного: signal-safety(7). В примере хрестоматийная ошибка: printf() и puts() не являются async-signal-safe, не гарантируется безопасность их вызова из обработчика сигнала. В современных программах хорошо и правильно пользоваться signalfd(2) и мультиплексором, чтобы обрабатывать сигнал без прерывания потока и без забот о том, что можно вызывать.