Взаимодействие процессов
Межпроцессное взаимодействие (IPC, Inter-Process Communication) может быть локальным или сетевым.
- файлы (обмен данными через совместно используемые файлы)
- сигналы (некий аналог процессорных прерываний)
- каналы (pipes)
- именованные
- неименованные (перенаправление потоков ввода-вывода через | )
Каналы
Первоначально каналы были только неименованными. Передать дескриптор канала можно было только процессу-потомку. Для передачи данных через канал требуется в одном процессе получить дескриптор на чтение, в другом — на запись. Канал является односторонним, для двустороннего обмена данными требуется создавать два канала.
Для создания неименованного канала используется функция pipe, pipefd[0] будет дескриптором для чтения, pipefd[1] — для записи (пример рабочей программы есть в man-справке):
#include int pipe(int pipefd[2]);
Чтобы читать вывод команд, которые обычно используются в консоли, или передавать им данные на вход, используется функция popen, которая принимает команду для запуска другого приложения, тип канала (на запись или чтение) и возвращает дескриптор канала:
#include FILE *popen(const char *command, const char *type); int pclose(FILE *stream);
Именованные каналы обладают именем в пространстве файловых имен. Для создания используется функция mkfifo:
#include #include int mkfifo(const char *pathname, mode_t mode);
Сигналы
Каждый сигнал обладает номером и сигнатурой, которые определены в signal.h. Сигналы работают по принципу прерываний. Когда приложению поступает сигнал — оно обязано его сразу обработать. В каждом процессе есть обработчики сигналов. Стандартные обработчики добавляются в программу в процессе линковки. Часть сигналов может быть обработана собственными обработчиками, часть переопределить нельзя.
- SIGINT (2)
- SIGABRT (6) (man abort)
- SIGTERM (15) (необязательная к исполнению, CTRL+C)
- SIGKILL (9) (не переопределяется, прибивает процесс)
- SIGCHILD (17) (при завершении потомка, см. waitpid/wait)
Сигналы используются для управления демонами. Для работы с сигналами используются две функции: kill отправляет процессу сигнал, signal определяет, как его обработать.
Из командной строки утилита kill отправяет сигнал процессу. kill -l выводит список сигналов. Среди них есть пользовательские SIGUSR1 и SIGUSR2, которые зарезервированы под использование пользователем и под которые можно писать свои обработчики.
Отправка сигнала с помощью функции kill:
#include #include int kill(pid_t pid, int sig);
Установка обработчика сигнала функцией signal, которая принимает в качестве параметра указатель на функцию для обработки сигнала и возвращает указатель на предыдущий обработчик:
#include typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);
Пример обработки сигнала SIGINT:
#include #include #include void mysignal_handler(int signalno) < printf("Called from signal %d\n", signalno); > int main() < signal(SIGINT, mysignal_handler); int counter = 0; while(1) < printf("Hello %d\n", counter++); usleep(500000); > return 0; >
Разделяемая память
Каждый процесс при создании получает одинаковое адресное пространство — диапазон адресов от 0 до ffffffff на 32-битной системе. В ОС есть механизм виртуальной памяти, который отображает память процесса частично на физическую память, частично на диск. Распределение адресного пространства процесса (memory layout) устроено так: «Сверху» в сторону уменьшения адресов располагается ядро, под которое зарезервирован 1 Гб адресного пространства. Снизу от младших адресов к старшим располагается процесс: сегмент кода, потом сегмент данных, диапазон адресов для кучи. От ядра в сторону уменьшения адресов растёт стек. Между кучей и стеком находится сегмент разделямой памяти, используемый для IPC.
Ядро ~1 Gb Стек Разделяемая память Куча Данные Код Для работы с разделяемой памятью служат функции:
- shmget позволяет получать или создавать регион памяти
- shmat позволяет подключаться к региону памяти
- shmdt отключает от региона памяти
- shmctl управляет параметрами регионов разделяемой памяти
Создание региона памяти с помощью shmget: в качестве параметров принимаются глобальный ключ key для доступа к региону, size округляется вверх до размера страницы памяти.
#include #include int shmget(key_t key, size_t size, int shmflg);
Для получения информации о созданных регионах shared-памяти используется утилита ipcs.
Функция обработчик сигналов
Данная функция вызывается, когда процесс (или нить) получает неблокируемый сигнал. Дефолтный обработчик завершает наш процесс (нить). Но мы можем сами определить обработчики для интересующих нас сигналов. Следует очень осторожно относится к написанию обработчика сигналов, это не просто функция, выполняющаяся по коллбеку, происходит прерывание текущего потока выполнения без какой либо подготовительной работы, таким образом глобальные объекты могут находится в неконсистентном состоянии. Автор не берется приводить свод правил, так как сам их не знает, и призывает последовать совету Kobolog (надеюсь он не против, что я ссылаюсь на него) и изучить хотя бы вот этот материал FAQ.
sighandler_t signal(int signum, sighandler_t handler);
- функция не блокирует получение других сигналов пока выполняется текущий обработчик, он будет прерван и начнет выполняться новый обработчик
- после первого получения сигнала (для которого мы установили свой обработчик), его обработчик будет сброшен на SIG_DFL
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
- sa_handler — аналогичен sighandler_t в функции signal
- sa_mask — маска сигналов который будут блокированы пока выполняется наш обработчик. + по дефолту блокируется и сам полученный сигнал
- sa_flags — позволяет задать дополнительные действия при обработке сигнала о которых лучше почитать тут
struct sigaction act; memset(&act, 0, sizeof(act)); act.sa_handler = hdl; sigset_t set; sigemptyset(&set); sigaddset(&set, SIGUSR1); sigaddset(&set, SIGUSR2); act.sa_mask = set; sigaction(SIGUSR1, &act, 0); sigaction(SIGUSR2, &act, 0);
Здесь мы установили наш обработчик для сигналов SIGUSR1 и SUGUSR2, а также указали, что необходимо блокировать эти же сигналы пока выполняется обработчик.
С обработчиком сигналов есть один не очень удобный момент, он устанавливается на весь процесс и все порожденные нити сразу. Мы не имеет возможность для каждой нити установить свой обработчик сигналов.
Но при этом следует понимать что когда сигнал адресуется процессу, обработчик вызывается именно для главной нити (представляющей процесс). Если же сигнал адресуется для нити, то обработчик вызывается из контекста этой нити. См пример 1.Блокирование сигналов
Для того, чтобы заблокировать некоторый сигналы для процесса, необходимо добавить их в маску сигналов данного процесса. Для этого используется функция
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
Мы можем к уже существующей маске сигналов добавить новые сигналы (SIG_BLOCK), можем из этой маски убрать часть сигналов (SIG_UNBLOCK), а так же установить полностью нашу маску сигналов (SIG_SETMASK).
Для работы с маской сигналов внутри нити используется функцияint pthread_sigmask(int how, const sigset_t *set, sigset_t *oset);
которая позволяет сделать все тоже, но уже для каждой нити в отдельности.
Невозможно заблокировать сигналы SIGKILL или SIGSTOP при помощи этих функций. Попытки это сделать будут игнорироваться.sigwait
Данная функция позволяет приостановить выполнении процесса (или нити) до получения нужного сигнала (или одного из маски сигналов). Особенностью этой функции является то, что при получении сигнала не будет вызвана функции обработчик сигнала. См. пример 2.
Посыл сигнала
int kill(pid_t pid, int sig); int raise(int sig);
С первой все понятно. Вторая нужна для того, чтобы послать сигнал самому себе, и по сути равносильна kill(getpid(), signal). Функция getpid() возвращает PID текущего процесса.
Для того, чтобы послать сигнал отдельной нити, используется функцияint pthread_kill(pthread_t thread, int sig);
Пример использования сигналов
Все, что я описал выше, не дает ответа на вопрос «Зачем мне использовать сигналы». Теперь я хотел бы привести реальный пример использования сигналов и где без них попросту не обойтись.
Представьте, что вы хотите читать или писать какие-то данные в какое то устройство, но это может привести к блокированию. Ну например, чтение в случае работы с сокетами. Или может быть запись в пайп. Вы можете вынести это в отдельный поток, чтобы не блокировать основную работу. Но что делать когда вам нужно завершить приложение? Как корректно прервать блокирующую операцию IO? Можно было бы задавать таймаут, но это не очень хорошее решение. Для этого есть более удобные средства: функции pselect и ppoll. Разница между ними исключительно в юзабельности, поведение у них одинаковое. В первую очередь эти функции нужны для мультиплексирования работы с IO (select/poll). Префикс ‘p’ в начале функции указывает на то, что данная функция может быть корректно прервана сигналом.Итак, сформулируем требование:
Необходимо разработать приложение, открывающее сокет (для простоты UDP) и выполняющее в потоке операцию чтения. Данное приложение должно корректно без задержек завершаться по требованию пользователя.
Функция треда выглядит вот такvoid* blocking_read(void* arg) < if(stop) < // не успели стартовать, а нас уже прикрыли ? std::cout // Блокируем сигнал SIGINT sigset_t set, orig; sigemptyset(&set); sigaddset(&set, SIGINT); sigemptyset(&orig); pthread_sigmask(SIG_BLOCK, &set, &orig); if(stop) < // пока мы устанавливали блокировку сигнала он уже произошол // возвращаем все как было и выходим std::cout // Здесь нас не могут прервать сигналом SIGINT std::cout // Мы либо считали данные, либо произошла какаято ошибка. Но мы не получали // сигнала о завершении работы и продолжаем работать "по плану" close(sockfd); pthread_exit((void *)0); >
- проверяем, что пока стартовал тред его еще не пожелали завершить
- блокируем завершающий сигнал
- проверяем, что пока блокировали, нас не пожелали завершить
- вызываем ppoll передавая в качестве последнего параметра маску сигналов по которой ждется сигнал
- после выхода из ppoll проверяем что вышли не из за сигнала о завершении
Устанавливаем наш обработчик для SIGINT, и когда нужно завершить дочерний поток шлем ему этот сигнал.
Полный листинг см. пример 3.На мой взгляд, недостатком данного способа является то, что в случае нескольких потоков мы можем завершить их только все сразу. Нет возможности устанавливать свой обработчик сигналов для каждого треда. Таким образом, нет возможности реализовать полноценное межпоточное взаимодействие через сигналы. Linux way это не предусматривает.
PS. Исходные коды разместил на сервисе PasteBin (ссылку не даю, а то еще за рекламу посчитают).
PPS. Прошу простить за обилие ошибок. Язык, слабая моя сторона. Спасибо, всем кто помог их исправить.Данная статья не претендует на полное (и глубокое) описание работы с сигналами и нацелена в первую очередь на тех, кто до этого момента не сталкивались с понятием «сигнал». Для более глубоко понимания работы сигналов автор призывает обратиться в более компетентные источники и ознакомиться с конструктивной критикой в комментариях.