Представляю вашему вниманию перевод работы A guide to inter-process communication in Linux. Объём данной работы большой, поэтому перевод будет выполнен в виде нескольких отдельных статей:
Это мой первый перевод и первая публикация на Хабре, заранее извиняюсь за ошибки и неточности в переводе, буду рад вашим замечаниям и критике.
Приятного чтения!
Введение
Данное руководство посвящено межпроцессному взаимодействию (interprocess communication, IPC) в Linux. В руководстве представлены примеры кода на языке С для раскрытия следующих механизмов IPC:
Разделяемые файлы (shared files)
Разделяемая память (shared memory) и семафоры (semaphore)
Именованные и неименованные каналы (named and unnamed pipes)
Очереди сообщений (message queues)
Сокеты (sockets)
Сигналы (signals)
Перед тем, как перейти к первым двум механизмам (разделяемым файлам и разделяемой памяти), я бы хотел ознакомить вас с основными понятиями.
Основные понятия
Процесс (process) – это выполняющаяся программа. У каждого процесса существует его собственное адресное пространство - области памяти (memory locations), к которым этот процесс имеет доступ. Процесс включает в себя один и более потоков (thread) управления, которые представляют собой последовательность исполняемых инструкций. Однопоточный (single-threaded) процесс имеет только один поток, тогда как в многопоточном (multi-threaded) процессе существует более одного потока. Потоки внутри процесса разделяют различного рода ресурсы (в частности, адресное пространство). В соответствии с этим, потоки внутри процесса могут напрямую взаимодействовать через разделяемую память (shared memory) (хотя некоторые современные языки (например, Go) поощряют более дисциплинированный подход – например, использование потокобезопасных (thread-safe) каналов). Также представляет интерес момент, что разные процессы, по умолчанию, не разделяют память.
Существует много способов запуска процессов с целью их дальнейшнего взаимодействия друг с другом. В примерах этого руководства преобладают следующие:
Запуск одного процесс в одном терминале и запуск второго процесса во другом терминале.
Вызов системной функции
fork
в родительском (parent) процессе для создания дочернего (child) процесса.
В первых примерах используется подход с терминалами. Примеры кода доступны в виде ZIP-архива у меня на сайте http://condor.depaul.edu/mkalin.
Разделяемое хранилище (shared storage)
Программисты хорошо знакомы с тем, что такое работа с файлами, а еще – с большим количеством подводных камней, с этим связанных – несуществующие файлы, проблемы с правами (permissions) и др., что сильно мешает использованию файлов в программе. Тем не менее, разделяемые файлы можно считать самым базовым механизмом IPC.
Рассмотрим относительно простой пример, в котором один процесс (производитель, producer) создаёт и пишет в файл, а другой процесс (потребитель, consumer) – читает из того же файла.
writes +-----------+ reads
producer-------->| disk file |<-------consumer
+-----------+
Очевидная проблема, которая может возникнуть при использовании этого механизма IPC – состояние гонки (race condition): producer и consumer могут обратиться к файлу одновременно, что приведёт к неопределенному поведению. Для того, чтобы избежать состояния гонки, необходимо заблокировать (lock) файл таким образом, чтобы предовратить конфликт между операцией записи и любой другой операцией (чтение, запись).
Работу с API для блокировки, предоставляемым стандартной системной библиотекой (standard system library), можно свести к следующему:
producer должен установить исключительную блокировку (exclusive lock) файла перед тем, как записывать в него данные. Блокировка может удерживаться только одним процессом, что решает проблему с состоянием гонки, поскольку другие процессы не смогут получить доступ к файлу, пока блокировка не будет снята.
consumer должен установить (как минимум) разделяемую блокировку (shared lock) файла перед тем, как прочитать из него данные. Разделяемую блокировку могут удерживать несколько reader-ов одновременно. При этом, пока хотя бы один reader удерживает блокировку, ни один writer не может получить доступ к файлу.
За счёт использования разделяемой блокировки увеличивается эффективность. Если процесс только читает из файла и не изменяет его содержимое, нет необходимости ограничивать другие процессы в том же самом. Запись, очевидно, требует исключительного доступа к файлу.
Стандартная библиотека ввода-вывода включает в себя вспомогательную функцию с именем fcntl
, которая может быть использована для отслеживания и управления как исключительной, так и разделяемой блокировкой файла. Функция работает с файловым дескриптором – положительным целым числом, которое идентифицирует файл в рамках процесса (один и тот же физический файл может соответствовать другим файловым дескрипторам в других процессах). Для блокировки файлов Linux предоставляет библиотечную функцию flock
, которая представляет собой тонкую обертку (thin wrapper) вокруг fnctl
. В примере 1 используетcя функция fnctl
для демонстрации деталей работы с API.
Пример 1. Программа producer
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define FileName "data.dat"
#define DataString "Now is the winter of our discontent\nMade glorious summer by this sun of York\n"
void report_and_exit(const char* msg) {
perror(msg);
exit(-1); /* EXIT_FAILURE */
}
int main() {
struct flock lock;
lock.l_type = F_WRLCK; /* read/write (exclusive versus shared) lock */
lock.l_whence = SEEK_SET; /* base for seek offsets */
lock.l_start = 0; /* 1st byte in file */
lock.l_len = 0; /* 0 here means 'until EOF' */
lock.l_pid = getpid(); /* process id */
int fd; /* file descriptor to identify a file within a process */
if ((fd = open(FileName, O_RDWR | O_CREAT, 0666)) < 0) /* -1 signals an error */
report_and_exit("open failed...");
if (fcntl(fd, F_SETLK, &lock) < 0) /** F_SETLK doesn't block, F_SETLKW does **/
report_and_exit("fcntl failed to get lock...");
else {
write(fd, DataString, strlen(DataString)); /* populate data file */
fprintf(stderr, "Process %d has written to data file...\n", lock.l_pid);
}
/* Now release the lock explicitly. */
lock.l_type = F_UNLCK;
if (fcntl(fd, F_SETLK, &lock) < 0)
report_and_exit("explicit unlocking failed...");
close(fd); /* close the file: would unlock if needed */
return 0; /* terminating the process would unlock as well */
}
Основные шаги в примере с producer-ом можно свести к следующему:
Программа объявляет переменную типа struct flock
, которая представляет собой блокировку, и инициализирует пять полей структуры. Первая инициализация:
lock.l_type = F_WRLCK; /* exclusive lock */
настраивает блокировку как исключающую (read-write). Если producer устанавливает блокировку, ни один процесс не сможет ни читать из файла, ни писать в файл, пока producer не снимет блокировку – либо явно с помощью определенного вызова fcntl
, либо неявно закрытием файла. (Когда процесс завершается (terminates), все открытые файлы автоматически закрываются, таким образом снимая с себя блокировку).
Затем программа инициализирует остальные поля. Основная идея заключается в том, что весь файл должен быть заблокирован. Однако API для блокировки позволяет установить блокировку только на заданное количество байт. Например, если файл содержит несколько строк текста, можно заблокировать только одну строку или часть строки – остальное останется незаблокированным.
В первом вызове fnctl
:
if (fcntl(fd, F_SETLK, &lock) < 0)
производится попытка установить исключительную блокировку и выполняется проверка, удалось ли это сделать (в случае ошибки функция fnctl
возвращает -1). Второй аргумент F_SETLK
означает, что вызов fcntl
не останавливает выполнение потока: функция сразу же возвращает значение, обозначающее, удалось установить блокировку или нет. Если вместо этого используется флаг F_SETLKW
(W в конце означает “wait”), вызов fcntl
остановит выполнение потока до тех пор, пока не получится установить блокировку. В вызовах fcntl
первый аргумент fd
– файловый дескриптор, второй аргумент – действие, которое планируется совершить (в нашем случае F_SETLK
– установка блокировки (setting the lock)), и третий аргумент – адрес структуры блокировки (в нашем случае, &lock
).
Если producer устанавливает блокировку, программа записывает две текстовые строки в файл.
После записи в файл, producer меняет значение поля l_type
в структуре блокировки на unlock («разблокировать»):
lock.l_type = F_UNLCK;
и вызывает fcntl
для выполнения операции разблокировки. В конце программа закрывает файл и завершает выполнение.
Пример 2. Программа consumer
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#define FileName "data.dat"
void report_and_exit(const char* msg) {
perror(msg);
exit(-1); /* EXIT_FAILURE */
}
int main() {
struct flock lock;
lock.l_type = F_WRLCK; /* read/write (exclusive) lock */
lock.l_whence = SEEK_SET; /* base for seek offsets */
lock.l_start = 0; /* 1st byte in file */
lock.l_len = 0; /* 0 here means 'until EOF' */
lock.l_pid = getpid(); /* process id */
int fd; /* file descriptor to identify a file within a process */
if ((fd = open(FileName, O_RDONLY)) < 0) /* -1 signals an error */
report_and_exit("open to read failed...");
/* If the file is write-locked, we can't continue. */
fcntl(fd, F_GETLK, &lock); /* sets lock.l_type to F_UNLCK if no write lock */
if (lock.l_type != F_UNLCK)
report_and_exit("file is still write locked...");
lock.l_type = F_RDLCK; /* prevents any writing during the reading */
if (fcntl(fd, F_SETLK, &lock) < 0)
report_and_exit("can't get a read-only lock...");
/* Read the bytes (they happen to be ASCII codes) one at a time. */
int c; /* buffer for read bytes */
while (read(fd, &c, 1) > 0) /* 0 signals EOF */
write(STDOUT_FILENO, &c, 1); /* write one byte to the standard output */
/* Release the lock explicitly. */
lock.l_type = F_UNLCK;
if (fcntl(fd, F_SETLK, &lock) < 0)
report_and_exit("explicit unlocking failed...");
close(fd);
return 0;
}
Программа consumer - сложнее, чем просто пример, демонстрирующий особенности работы с API для блокировки. В частности, consumer сначала проверяет, установлена ли на файл исключительная блокировка, и только потом пытается установить разделяемую блокировку. Код, соответствующий этому:
lock.l_type = F_WRLCK;
...
fcntl(fd, F_GETLK, &lock); /* sets lock.l_type to F_UNLCK if no write lock */
if (lock.l_type != F_UNLCK)
report_and_exit("file is still write locked...");
Операция F_GETLK
, указанная в вызове fcntl
, проверяет, установлена ли блокировка (в нашем случае - исключительная блокировка т.к. указано F_WRLCK
в первой инструкции выше). Если блокировки нет, вызов fcntl
автоматически устанавливает в переменной блокировки тип блокировки F_UNLCK
, чтобы продемонстрировать этот факт. Если блокировка есть, программа consumer завершается. (Более устойчивая версия программы может отправить consumer-а в сон на некоторое время, а потом попробовать еще несколько раз).
Если файл в данный момент не заблокирован, consumer совершает попытку установить разделяемую (read-only) блокировку (F_RDLCK
). Чтобы сделать программу короче, вызов F_GETLK
в fcntl
можно опустить, поскольку вызов F_RDLCK
завершится с ошибкой, если каким-то процессом уже установлена исключительная блокировка. Ранее уже было сказано, что read-only блокировка не даёт другим процессам записывать в файл, но при этом позволяет процессам читать из файла. Разделяемая блокировка может удерживаться несколькими процессами.
После установки разделяемой блокировки, программа consumer читает из файла по одному байту за раз, выводит байты в стандартный вывод, снимает блокировку, закрывает файл и завершается.
Вывод двух программ, запущенных из одного и того же терминала (% - символ командной строки):
% ./producer
Process 29255 has written to data file
% ./consumer
Now is the winter of our discontent
Made glorious summer by this sun of York
В этом примере данными, которые разделяются между процессами, является текст – две строчки из пьесы «Ричард III» Шекспира. Однако, разделяемый файл может представлять собой большое количество произвольных байтов (например, оцифрованный фильм), что делает разделяемые файлы невероятно гибким механизмом IPC. Подводным камнем является то, что доступ к файлам (чтение/запись) производится относительно медленно. Как и всегда, программирование сопряжено с компромиссами. В следующем примере использование механизма разделяемой памяти, в отличии от разделяемых файлов, приведет к соответствующему приросту производительности.
Shared memory
Linux предоставляет два разных API для работы с разделяемой памятью (shared memory): устаревшая (legacy) System V API и более актуальная POSIX. Эти два API не стоит смешивать в одном и том же приложении. Обратная сторона использования POSIX API в том, что он еще находится в разработке и зависит от версии установленного ядра, что влияет на переносимость кода. К примеру, POSIX API по умолчанию реализует shared memory как отображение файла в память (memory-mapped file): система работает с файлом поддержки (backing file) c соответствующим содержимым (фрагментом shared memory). В POSIX разделяемая память может быть сконфигурирована и без файла поддержки, однако это может повлиять на переносимость. В моем примере используется POSIX API с файлом поддержки, что объединяет в себе преимущества доступа к памяти (скорость) и работы с файлами (хранение данных вне оперативной памяти (persistence)).
Пример состоит из двух программ memwriter и memreader, использующих семафор (semaphore) для координирования доступа к разделяемой памяти. Всякий раз, когда осуществляется запись в разделяемую память (как в многопотоковой, так и в многопроцессной обработке), возникает риск состояния гонки (memory-based race condition); поэтому для синхронизации доступа к разделяемой памяти использован семафор.
Пример 3. Исходный код процесса memwriter
/** Compilation: gcc -o memwriter memwriter.c -lrt -lpthread **/
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <semaphore.h>
#include <string.h>
#include "shmem.h"
void report_and_exit(const char* msg) {
perror(msg);
exit(-1);
}
int main() {
int fd = shm_open(BackingFile, /* name from smem.h */
O_RDWR | O_CREAT, /* read/write, create if needed */
AccessPerms); /* access permissions (0644) */
if (fd < 0) report_and_exit("Can't open shared mem segment...");
ftruncate(fd, ByteSize); /* get the bytes */
caddr_t memptr = mmap(NULL, /* let system pick where to put segment */
ByteSize, /* how many bytes */
PROT_READ | PROT_WRITE, /* access protections */
MAP_SHARED, /* mapping visible to other processes */
fd, /* file descriptor */
0); /* offset: start at 1st byte */
if ((caddr_t) -1 == memptr) report_and_exit("Can't get segment...");
fprintf(stderr, "shared mem address: %p [0..%d]\n", memptr, ByteSize - 1);
fprintf(stderr, "backing file: /dev/shm%s\n", BackingFile );
/* semahore code to lock the shared mem */
sem_t* semptr = sem_open(SemaphoreName, /* name */
O_CREAT, /* create the semaphore */
AccessPerms, /* protection perms */
0); /* initial value */
if (semptr == (void*) -1) report_and_exit("sem_open");
strcpy(memptr, MemContents); /* copy some ASCII bytes to the segment */
/* increment the semaphore so that memreader can read */
if (sem_post(semptr) < 0) report_and_exit("sem_post");
sleep(12); /* give reader a chance */
/* clean up */
munmap(memptr, ByteSize); /* unmap the storage */
close(fd);
sem_close(semptr);
shm_unlink(BackingFile); /* unlink from the backing file */
return 0;
}
Программа memwriter должна запускаться первой в своем собственном терминале. Программа memreader запускается через несколько секунд в своем собственном терминале. Вывод memreader-а:
This is the way the world ends...
В начале каждого файла с исходным кодом указаны флаги линковки (link flags), которые должны быть включены во время компиляции.
Следует начать с объяснения, как работают семафоры в роли механизма синхронизации. Обычный семафор иногда называют считающим (counting), поскольку у него есть значение (обычно инициализируемое нулём), которое может быть инкрементировано. В качестве примера рассмотрим велопрокат. В наличии у него находится 100 велосипедов. Кроме того, у работников велопроката есть программа, которой они пользуются для того, чтобы оформить аренду. Каждый раз, как кто-то берет велосипед в прокат, семафор инкрементируется на единицу; когда велосипед возвращается, семафор декрементируется на единицу. Выдача велосипедов продолжается, пока значение не станет равным 100; после этого она остановится до момента, пока хотя бы один велосипед не будет возвращен (пока значение семафора не декрементируется до 99).
Двоичный (binary) семафор - частный случай, в котором используется только два значения: 0 и 1. В данной ситуации семафор работает как мьютекс (mutex): конструкция взаимного исключения. Пример с разделяемой памятью использует семафор в роли мьютекса. Когда значение семафора равно 0, у memwriter есть доступ к разделяемой памяти. После записи, процесс инкрементирует значение семафора, тем самым позволяя memreader выполнить чтение разделяемой памяти (см. Пример 3).
Ниже представлен краткий обзор, как memwriter и memreader взаимодействуют между собой через разделяемую память:
Программа memwriter, как показано выше, вызывает функцию shm_open
для получения дескриптора файла поддержки который система ассоциирует с разделяемой памятью. Память в этот момент не выделяется. Далее производится вызов функции со сбивающим с толку названием ftruncate
:
ftruncate(fd, ByteSize); /* get the bytes */
выделяющий ByteSize байт (в нашем случае - скромные 512 байт). Программы memwriter и memreader осуществляют доступ только к разделяемой памяти, а не к файлу поддержки. Синхронизация разделяемой памяти и файла - зона ответственности системы.
Затем memwriter вызывает функцию mmap:
caddr_t memptr = mmap(NULL, /* let system pick where to put segment */
ByteSize, /* how many bytes */
PROT_READ | PROT_WRITE, /* access protections */
MAP_SHARED, /* mapping visible to other processes */
fd, /* file descriptor */
0); /* offset: start at 1st byte */
для того, чтобы получить указатель на разделяемую память (в memreader выполняется похожий вызов). Тип указателя caddr_t начинается с буквы с, что означает calloc - системную функцию, инициализирующую динамически аллоцированную память нулями. Программа memwriter использует memptr в дальнейшем для операции записи с использованием библиотечной функции strcpy (копирование строк).
В этой точке memwriter готов к записи, но перед этим он создаёт семафор для обеспечения исключительного доступа к разделяемой памяти. Если бы memwriter выполнял запись, когда memreader выполнял чтение, могло возникнуть состояние гонки. Если вызов sem_open завершится успешно:
sem_t* semptr = sem_open(SemaphoreName, /* name */
O_CREAT, /* create the semaphore */
AccessPerms, /* protection perms */
0); /* initial value */
можно начинать запись. SemaphoreName (или любое уникальное непустое имя) идентифицирует семафор и для memwriter, и для memreader. Начальное значение, равное нулю, позволяет создателю семафора (в нашем случае - memwriter) начать запись.
После выполнения записи, memwriter инкрементирует значение семафора до 1
if (sem_post(semptr) < 0) ..
вызовом функции sem_post. Инкрементирование семафора снимает исключительную блокировку и позволяет memreader выполнить операцию чтения. Программа memwriter также удаляет отображение разделяемой памяти из адресного пространства memwriter:
munmap(memptr, ByteSize); /* unmap the storage */
тем самым закрывая для memwriter дальнейший доступ к разделяемой памяти (см. пример 4).
Пример 4. Исходный код процесса memreader
/** Compilation: gcc -o memreader memreader.c -lrt -lpthread **/
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <semaphore.h>
#include <string.h>
#include "shmem.h"
void report_and_exit(const char* msg) {
perror(msg);
exit(-1);
}
int main() {
int fd = shm_open(BackingFile, O_RDWR, AccessPerms); /* empty to begin */
if (fd < 0) report_and_exit("Can't get file descriptor...");
/* get a pointer to memory */
caddr_t memptr = mmap(NULL, /* let system pick where to put segment */
ByteSize, /* how many bytes */
PROT_READ | PROT_WRITE, /* access protections */
MAP_SHARED, /* mapping visible to other processes */
fd, /* file descriptor */
0); /* offset: start at 1st byte */
if ((caddr_t) -1 == memptr) report_and_exit("Can't access segment...");
/* create a semaphore for mutual exclusion */
sem_t* semptr = sem_open(SemaphoreName, /* name */
O_CREAT, /* create the semaphore */
AccessPerms, /* protection perms */
0); /* initial value */
if (semptr == (void*) -1) report_and_exit("sem_open");
/* use semaphore as a mutex (lock) by waiting for writer to increment it */
if (!sem_wait(semptr)) { /* wait until semaphore != 0 */
int i;
for (i = 0; i < strlen(MemContents); i++)
write(STDOUT_FILENO, memptr + i, 1); /* one byte at a time */
sem_post(semptr);
}
/* cleanup */
munmap(memptr, ByteSize);
close(fd);
sem_close(semptr);
unlink(BackingFile);
return 0;
}
И в memwriter, и в memreader, наибольший интерес представляют функции для работы с разделяемой памятью shm_open и mmap: в случае успеха, вызов первой функции возвращает дескриптор файла поддержки, который используется в вызове второй функции для получения указателя на сегмент разделяемой памяти. Вызовы shm_open в обеих программах похожи, за исключением того, что в memwriter создаётся разделяемая память, а memreader только получает доступ к уже созданной разделяемой памяти.
int fd = shm_open(BackingFile, O_RDWR | O_CREAT, AccessPerms);
// memwriter
int fd = shm_open(BackingFile, O_RDWR, AccessPerms);
// memreader
Вызовы mmap не отличаются друг от друга, т.к. на руках уже есть файловый дескриптор:
caddr_t memptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
Первый аргумент в mmap - NULL, означающий, что система определит сама, куда выделять память в виртуальном адресном пространстве (однако возможно (но сложно) явным образом указать адрес). Флаг MAP_SHARED обозначает, что выделенная память может быть разделена между процессами; последний аргумент (в нашем случае - 0) означает, что смещение (offset) для разделяемой памяти соответствует первый байт. Аргумент size указывает, какое количество байтов должно быть выделено (в нашем случае, 512). Аргумент protection обозначает, что разделяемая память может быть доступно только для записи и чтения.
Когда memwriter успешно выполнится, система создаст и будет поддерживать файл поддержки; у меня в системе он соответствует файлу /dev/shm/shMemEx, где shMemEx - моё имя для разделяемого хранилища (указано в заголовочном файле shmem.h).
В данной версии memwriter и memreader, инструкция
shm_unlink(BackingFile); /* removes backing file */
производит удаление файла поддержки. Если не выполнять эту инструкцию, он будет продолжать существовать и после завершения работы программы.
И memreader, и memwriter обращаются к семафору через его имя в вызове функции sem_open. После этого memreader входит в состояние ожидания (wait state) до момента, пока memwrite не инкрементирует семафор, начальное значение которого равно 0.
if (!sem_wait(semptr)) { /* wait until semaphore != 0 */
Как только ожидание заканчивается, memreader считывает байты ASCII из разделяемой памяти, подчищает за собой, и завершает работу.
API для работы с разделяемой памятью включает в себя операции для явной синхронизации сегмента разделяемой памяти и файла поддержки. В примере эти операции не использовались для того, чтобы сфокусироваться на коде, реализующем разделение памяти и работу с семафором.
Программы memwriter и memreader, скорее всего, успешно отработают и без возникновения состояния гонки, даже если удалить код, связанный с семафором; memwriter создаст сегмент разделяемой памяти и сразу же выполнит в него запись; memreader не сможет получить доступ к разделяемой памяти, пока она не будет создана. Однако, хорошей практикой было бы синхронизировать доступ к разделяемой памяти каждый раз, когда выполняется операция записи; плюсом важно было рассказать про API для работы с семафорами в данном примере.
Подведение итогов
Примеры с разделяемыми файлами и разделяемой памятью показывают, как процессы могут взаимодействовать через разделяемое хранилище (в одном случае – это файлы, в другом – сегменты памяти). API в обоих случаях достаточно простое. Есть ли у этих двух подходов какие-то общие недостатки? Современные приложения часто работают с потоковыми данными, или даже очень большими потоками данных. Ни разделяемые файлы, ни разделяемая память не подойдут для работы с ними. Для этих целей лучше всего подойдут каналы. Каналы и очереди сообщений представлены в части 2, и, как всегда, с примерами кода на языке С.