В Linux уживаются две системы очередей сообщений, разделённые двадцатью годами эволюции инженерной мысли. Старшая, System V, тянется ещё из ранних коммерческих Unix и опознаётся по числовым ключам и целочисленным идентификаторам. Младшая, POSIX, спроектирована заново и опознаётся по человекочитаемым именам со слешем впереди. Обе решают одну задачу, позволить процессам обмениваться дискретными сообщениями через управляемый ядром почтовый ящик. Но делают это настолько по-разному, что выбор между ними определяет и удобство кода, и набор доступных возможностей.

System V создавалась для серверных приложений, POSIX заточена под задачи реального времени. И именно у POSIX-варианта есть несколько преимуществ, ради которых на новых проектах под Linux выбирают именно его. Главные из них это приоритеты сообщений, асинхронное уведомление о приходе данных и интерфейс, построенный по знакомой модели работы с файлами.

Чем именованный почтовый ящик удобнее числового ключа

Самая заметная разница чувствуется уже на этапе создания очереди. System V идентифицирует очереди числовыми ключами, которые приходится откуда-то добывать, и управляет ими через идентификатор, выданный ядром. Это работает, но непрозрачно: по ключу нельзя на глаз понять, что за очередь за ним стоит, а согласование ключей между независимыми программами превращается в отдельную головную боль.

POSIX пошла противоположным путём, взяв за образец привычную модель работы с файлами. Очередь создаётся и открывается вызовом, принимающим имя в формате слеша и строки до 255 символов. Возвращается дескриптор очереди, которым оперируют все последующие вызовы. Два процесса работают с одной очередью, просто передав одно и то же имя при открытии. Закрывается очередь своим вызовом, а удаляется отвязкой имени. Весь жизненный цикл повторяет логику открытия, чтения, записи и закрытия файла, что делает код понятным с первого взгляда:

#include <mqueue.h>
#include <fcntl.h>

struct mq_attr attr = {0};
attr.mq_maxmsg = 10;        /* потолок числа сообщений в очереди */
attr.mq_msgsize = 256;      /* максимальный размер одного сообщения */

mqd_t mq = mq_open("/sensor_queue", O_CREAT | O_WRONLY, 0600, &attr);
/* ... работа с очередью ... */
mq_close(mq);               /* закрыть дескриптор */
mq_unlink("/sensor_queue"); /* удалить очередь по имени */

Удобство имён не только косметическое. Раз очереди существуют в пространстве имён по образу файлов, конечному пользователю легко определить состояние очереди штатными средствами, а согласование между программами сводится к договорённости об имени, а не об эфемерном числовом ключе. Программы, использующие POSIX-очереди, нужно собирать с подключением библиотеки реального времени, и об этом легко забыть, получив непонятную ошибку компоновки.

На Linux это сходство с файлами не метафора, а буквальная правда: очереди сообщений видны как файлы в особой служебной файловой системе, которую можно смонтировать и просматривать. Тогда каждая очередь показывается отдельным файлом, а чтение этого файла выдаёт сводку о её состоянии: сколько сообщений сейчас ждёт, зарегистрировано ли асинхронное уведомление и каким способом. Это превращает диагностику в тривиальную задачу: вместо написания служебной программы достаточно заглянуть в смонтированный каталог обычными командами просмотра. У старого интерфейса наблюдать за очередями приходится особыми утилитами, выводящими сводку по идентификаторам, что куда менее наглядно, чем дерево понятно названных файлов.

Почему приоритет сообщения меняет порядок их получения

Главная фишка POSIX-очередей, ради которой их и зовут пригодными для реального времени, это приоритеты. При отправке каждому сообщению присваивается неотрицательное число приоритета. Ядро хранит сообщения в очереди не в порядке поступления, а в порядке убывания приоритета, и при равном приоритете более старые идут раньше более новых. Получатель всегда забирает самое приоритетное из имеющихся, а среди равных по приоритету самое старое.

Это меняет саму логику обмена. Срочное сообщение, отправленное позже десятка рутинных, всё равно будет получено первым, обогнав очередь. Отправка с указанием приоритета и приём с его извлечением выглядят так:

/* отправитель: число 5 это приоритет, чем выше, тем раньше получат */
mq_send(mq, message, strlen(message) + 1, 5);

/* получатель: priority заполнится приоритетом полученного сообщения */
unsigned int priority;
ssize_t n = mq_receive(mq, buffer, BUF_SIZE, &priority);

Диапазон приоритетов в Linux широк: стандарт требует поддержки лишь значений от нуля до тридцати одного, но Linux позволяет на порядки больше, до десятков тысяч уровней. Стоит, впрочем, держать в уме заложенный в стандарт нюанс: любой процесс вправе отправить сообщение сколь угодно высокого приоритета, а значит низкоприоритетный отправитель теоретически способен захватить чрезмерную власть над очередью, забив её срочными сообщениями. В защищённых сценариях это учитывают.

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

Стоит обратить внимание и на устройство приёмного буфера, потому что тут кроется частая ошибка. Буфер, передаваемый при приёме, обязан быть не меньше максимального размера сообщения, заданного атрибутом очереди при её создании, а не размера конкретного ожидаемого сообщения. Если выделить буфер впритык под короткое сообщение, приём провалится с ошибкой, даже когда реальное сообщение в буфер бы влезло. Это следствие того, что ядро не знает заранее, какого размера сообщение придёт, и страхуется по максимуму. Поэтому правильный приёмный буфер всегда выделяют по атрибуту максимального размера сообщения, который при необходимости можно прочитать у открытой очереди отдельным вызовом запроса атрибутов.

Как очередь сама будит процесс при появлении сообщения

Второе крупное преимущество POSIX-очередей это асинхронное уведомление. Обычно получатель либо блокируется на приёме, либо периодически опрашивает очередь, проверяя, не появилось ли что. Оба варианта неидеальны: блокировка занимает поток целиком, опрос тратит процессор вхолостую. POSIX предлагает третий путь: процесс заранее регистрируется на уведомление и спокойно занимается своими делами, а ядро само сигнализирует ему, когда в пустую очередь приходит сообщение.

Регистрация задаёт способ оповещения. Можно попросить ядро прислать сигнал, можно запустить отдельный поток с заданной функцией, а можно отключить уведомление вовсе. Тонкость в том, что уведомление срабатывает однократно, при переходе очереди из пустого состояния в непустое, и только если на этот момент никто не ждёт на приёме. После каждого срабатывания регистрацию нужно возобновлять, что приучает писать аккуратный цикл перерегистрации. Этот механизм превращает очередь из пассивного хранилища в активный источник событий, экономя и потоки, и процессорное время.

Есть и третий, чисто линуксовый бонус, ломающий стандартную картину. На Linux дескриптор очереди под капотом является обычным файловым дескриптором, а значит за ним можно следить теми же средствами событийного мультиплексирования, что и за сокетами. Это позволяет вплести очередь сообщений в единый событийный цикл сервера наравне с сетевыми соединениями, ожидая готовности сразу множества разнородных источников одним вызовом. У System V такой возможности нет, её очереди в событийный цикл штатно не встраиваются. Платой за удобство служит непереносимость: вне Linux на это поведение полагаться нельзя.

Сравнивая два способа дождаться сообщения, видно, насколько богаче выбор у POSIX-варианта. Заблокироваться на приёме годится для выделенного процесса-получателя, которому больше нечем заняться. Зарегистрировать асинхронное уведомление подходит процессу, который параллельно делает другую работу и хочет лишь изредка отвлекаться на пришедшее сообщение. Вплести дескриптор в общий событийный цикл идеально для сервера, который и так уже ждёт готовности десятков источников и которому очередь становится просто ещё одним из них. Три разных инструмента под три разных архитектурных ситуации, тогда как старый интерфейс по сути предлагал лишь блокирующий приём да самостоятельный опрос вхолостую.

Какие лимиты ядра ограничивают аппетит очередей

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

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

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

Когда стоит остаться на старом интерфейсе несмотря на его недостатки

При всех достоинствах POSIX-вариант не всегда оптимален, и честное сравнение обязано назвать его слабые места. Главное из них это меньшая распространённость, особенно на старых системах. POSIX-очереди появились в ядре Linux относительно поздно и поддерживаются не везде, тогда как System V присутствует практически в любом Unix-подобном окружении испокон веку. Код, которому нужна максимальная переносимость на древние или экзотические платформы, может оказаться вынужден остаться на System V именно по этой причине.

Есть и нишевые соображения контроля. System V даёт иной, более низкоуровневый набор рычагов управления, который в отдельных серверных сценариях кому-то ближе. Но для большинства новых задач под современным Linux перевес на стороне POSIX: интерфейс чище, последователен и построен по знакомой файловой модели, приоритеты и асинхронное уведомление встроены напрямую и работают мощнее, а интеграция в событийный цикл через файловый дескриптор открывает архитектурные возможности, недоступные старому интерфейсу.

Сведём выбор к практике. Для новых приложений под Linux, особенно с требованиями к отзывчивости и приоритизации, POSIX-очереди это разумный выбор по умолчанию: понятные имена, приоритеты сообщений, пробуждение по событию вместо холостого опроса и возможность вплести очередь в общий событийный цикл. System V стоит держать в уме там, где критична переносимость на старые или нестандартные системы либо нужен его специфический низкоуровневый контроль. И в любом случае помнить про устойчивость очередей и убирать их за собой при остановке, чтобы не превращать удобный почтовый ящик в тихий источник утечки памяти ядра.