
Привет, хабр!
Когда вы создаёте большой проект на базе ROS2, то нередко возникает вопрос оптимизации производительности и уменьшение задержек передачи данных в микросервесной архитектуре. И в таком случае у ROS2 (а точнее у DDS middleware) есть довольно лёгкий способ практически бесплатно улучшить ситуацию и с производительностью и с задержками, а именно включить shared memory. Однако у этой функции есть много тонкостей, о которых сегодня и пойдёт речь. Версия ros в статье: ROS2 Humble с ros-humble-rmw-cyclonedds-cpp 1.3.5, ros-humble-cyclonedds 0.10.5, ros-humble-iceoryx-* 2.0.5
Что такое shared memory
Для начала давайте разберёмся в теории, обычно у каждого процесса в Linux своё виртуальное адресное пространство, и чужую память он не видит. Shared memory — механизм ОС, который отображает одни и те же физические страницы памяти в адресные пространства нескольких процессов. В Linux это POSIX API shm_open() + mmap(), а сами объекты видны как файлы в /dev/shm.
Для обмена сообщениями это значит следующее: вместо того чтобы сериализовать 2 МБ, резать их на UDP-пакеты (именно на них и работает DDS Cyclonedds даже при QoS Reliable), прогонять через сетевой стек ядра и собирать обратно, издатель пишет данные в общий сегмент, а подписчику передаётся только «указатель» (точнее, смещение внутри сегмента, потому что у каждого процесса сегмент может быть отображён по своему адресу).
Важно различать два режима, их часто путают:
|
Термин |
Что происходит |
|---|---|
|
Передача через SHM |
Сообщение копируется (или сериализуется) в общую память, подписчик читает оттуда. Сеть не участвует, но копия/сериализация есть. |
|
Zero-copy |
Издатель сразу строит сообщение в общей памяти (loan, «займ» блока), подписчик получает указатель на тот же блок. Ни одной копии. |
Eclipse iceoryx — middleware, которое реализует такой обмен. Как оно устроено:
-
RouDi (Routing and Discovery) — отдельный процесс-демон. При старте он создаёт сегменты в
/dev/shm: служебныйiceoryx_mgmtи сегменты с данными, размеченные на mempool’ы — наборы блоков (chunk) фиксированного размера. Ещё RouDi регистрирует процессы и соединяет publisher’ов с subscriber’ами. -
Приложение при публикации берёт свободный chunk из подходящего mempool, заполняет его и публикует. В очередь подписчика попадает ссылка на chunk со счётчиком ссылок.
-
Когда chunk больше никому не нужен, он возвращается в mempool.
В CycloneDDS iceoryx встроен как транспорт для локальных (на той же машине) читателей. Discovery (SPDP/SEDP) при этом по-прежнему идёт по UDP, а удалённые подписчики, как и раньше, получают данные по сети.
Есть же composable-ноды, зачем тогда SHM?
Сomposable-ноды не используют shared memory, а загружают несколько нод (как .so-плагины) в один процесс. Если включить intra-process communication (IPC), rclcpp передаёт сообщения между нодами этого процесса через std::unique_ptr/std::shared_ptr, то есть просто указатели внутри одного адресного пространства. DDS, сериализация и ОС в этом не участвуют.
IPC в Humble работает не всегда и не бесплатно:
-
IPC выключен по умолчанию. Его надо включать:
rclcpp::NodeOptions().use_intra_process_comms(true)или в launchextra_arguments=[{'use_intra_process_comms': True}]. -
Zero-copy только при публикации
std::unique_ptr.publish(const Msg&)при включённом IPC сначала делает копию. Если подписчиков несколько и часть хочет владеть сообщением (unique_ptr-callback), rclcpp копирует для каждого из них, кроме последнего. -
Ограничения QoS в Humble. Publisher с IPC бросает исключение, если QoS не
KEEP_LAST, depth = 0 или durability неVOLATILE. Значит,transient_local-топики (/tf_static,/map,/robot_description) через IPC не пустить. -
Внешние подписчики всё равно получают данные через DDS. Если на топик подписаны rviz,
ros2 bag recordили нода из другого процесса, rclcpp дополнительно делает обычную межпроцессную публикацию с сериализацией.
Кроме того, у «всё в одном процессе» есть архитектурные минусы, которых SHM лишён:
-
Segfault в одном компоненте убивает весь контейнер.
-
Не все ноды можно сделать компонентами, например Python-ноды, сторонние бинарники, ноды из чужих пакетов без
rclcpp_components_register_node, драйверы, которые надо перезапускать отдельно. -
Один executor (или его настройка), общая куча, общий приоритет планировщика. Перезапустить одну ноду нельзя.
IPC — самый быстрый путь внутри процесса, и его стоит использовать там, где ноды и так живут вместе. SHM нужен для быстрого обмена между процессами на одной машине. Эти механизмы можно комбинировать: контейнер с IPC внутри и SHM для связи с другими процессами.
Как передаются сообщения без SHM и с SHM
По умолчанию (SHM выключен)
Путь сообщения pub->publish(msg) → подписчик в другом процессе на той же машине:
Процесс A (publisher) Процесс B (subscriber)───────────────────── ──────────────────────rclcpp::Publisher::publish → rcl_publish → rmw_publish (rmw_cyclonedds) → dds_write → СЕРИАЛИЗАЦИЯ в CDR (копия №1) → DDSI-RTPS: нарезка на фрагменты (MaxMessageSize 14720 B, FragmentSize 1344 B) → sendmsg() UDP ── ядро, loopback (копия №2) ──→ recvmsg() (копия №3) → сборка фрагментов → reader history cache → rmw_take → ДЕСЕРИАЛИЗАЦИЯ (копия №4) → callback(msg)
Одно сообщение на 2 МБ превращается в сотни UDP-датаграмм. На моей машине 150 сообщений по 2 МБ дали 24 214 исходящих UDP-датаграмм, то есть ~160 на сообщение (см. раздел про счётчики UDP ниже). При переполнении буфера сокета фрагменты теряются и уходят на повторную передачу, отсюда длинные хвосты задержек. README rmw_cyclonedds для больших сообщений прямо советует поднимать net.core.rmem_max/rmem_default.
С включённым SHM
Когда SHM включён, обе стороны на одной машине и QoS подходит, data path в Humble выглядит так:
Процесс A (publisher) RouDi / /dev/shm Процесс B (subscriber)───────────────────── ──────────────── ──────────────────────publish(msg) → dds_write → iox: взять chunk из mempool ────→ [chunk] → POD: memcpy сообщения в chunk не-POD: сериализация в chunk → iox_pub_publish_chunk ──────────── ссылка на chunk ────────→ очередь iox-subscriber → поток shm_monitor кладёт сэмпл в reader history cache → rmw_take: loans ВЫКЛ (дефолт Humble): chunk → CDR → десериализация loans ВКЛ + POD: callback получает указатель прямо в chunk (zero-copy)
Однако есть несколько неочевидных вещей:
-
Обычный
publish()не даёт zero-copy. Если сообщение не было взято через loan, Cyclone берёт chunk и копирует в него POD или сериализует не-POD. -
На стороне подписчика в Humble по умолчанию данные сериализуются и десериализуются заново. Loans для подписок выключены (см. «
ROS_DISABLE_LOANED_MESSAGESв Humble»). Тогдаrmw_takeвызываетserdata_rmw_to_sample, который для «сырого» chunk сначала делаетserialize_into_serdata_rmw(в CDR), а потомdeserializeROSmessage(serdata.cpp). Именно это и увидели разработчики Cyclone в flame graph в issue #2155. Даже так это быстрее UDP: нет фрагментации, системных вызовов и сетевого стека. -
Настоящий zero-copy в Humble — это
borrow_loaned_message()у издателя плюсROS_DISABLE_LOANED_MESSAGES=0у подписчика плюс POD-тип. -
Иногда сериализация нужна в любом случае. «Только iceoryx» используется, если нет сетевых читателей, durability =
VOLATILEи нет локальных не-SHM читателей в том же процессе. Иначе сообщение дополнительно сериализуется для сети/истории писателя. -
Discovery всегда идёт по UDP. SHM ускоряет только пользовательские данные.
POD-сообщения и что будет с не-POD
Что такое POD в терминах rmw_cyclonedds
POD (plain old data) — тип фиксированного размера, который целиком лежит в своей структуре и не ссылается на кучу. Его можно скопировать memcpy в другой процесс, и он останется валидным.
Тип не POD, если где-то в нём, рекурсивно по вложенным сообщениям, есть:
-
stringилиwstring; -
неограниченная последовательность
T[]; -
ограниченная последовательность
T[<=N](у неё переменная длина!).
Фиксированные массивы T[N] допустимы.
|
POD |
не POD |
|---|---|
|
|
|
|
|
всё с |
|
свой |
|
Большинство «тяжёлых» стандартных сообщений (Image, PointCloud2) — не POD. Для zero-copy делают свои типы с фиксированными массивами (пример — проект ros2_shm_msgs) или используют решения для неразмерных типов вроде Agnocast.
Что происходит с не-POD при включённом SHM
Документация Cyclone утверждает, что для не-fixed-size типов SHM «отключается». Для rmw_cyclonedds в Humble это не так:
-
rmw_cyclonedds реализует
sertype_get_serialized_sizeиsertype_serialize_into(serdata.cpp), поэтому writer/reader для не-POD тоже получают iceoryx (dds_writer_support_shm). При трассировке видноWriter's topic name will be DDS:Cyclone:rt/chatterуstd_msgs/String. -
При
publish()сообщение сериализуется в CDR прямо в chunk (IOX_CHUNK_CONTAINS_SERIALIZED_DATA), а подписчик его десериализует. Сеть не участвует, но сериализация в обе стороны остаётся. -
Loans недоступны.
is_loaning_available = is_fixed_type && dds_is_loan_available(...), поэтомуcan_loan_messages()возвращаетfalse.borrow_loaned_message()не падает: rclcpp выделяет сообщение обычным аллокатором и один раз пишетCurrently used middleware can't loan messages. Local allocator will be used.Прямой вызовrmw_publish_loaned_messageдля не-POD вернёт ошибкуPublishing a loaned message of non fixed type is not allowed.
Ошибки или падения не будет. Будет “SHM с сериализацией”.
Пример правильного использования механизма loaned messages:
Издатель:
auto pub = create_publisher<my_msgs::msg::Pod>("topic", rclcpp::QoS(5).reliable());// ...auto loaned = pub->borrow_loaned_message(); // chunk прямо из mempool iceoryxloaned.get().stamp_ns = now_ns();fill(loaned.get().data); // пишем сразу в shared memorypub->publish(std::move(loaned)); // без копии; после move обращаться к loaned нельзя
Подписчик — только с callback по const &:
auto sub = create_subscription<my_msgs::msg::Pod>( "topic", rclcpp::QoS(5).reliable(), [](const my_msgs::msg::Pod & msg) { /* msg указывает в chunk */ });
ROS_DISABLE_LOANED_MESSAGES в Humble
После rmw_cyclonedds#469 в Humble бэкпортировали изменения (rcl#1110, rclcpp#2335). Причина: executor возвращает loaned-сообщение middleware сразу после callback, и shared_ptr, сохранённый пользователем, начинал указывать в освобождённую память (use-after-free).
|
|
Поведение |
|---|---|
|
Publisher |
loans разрешены, если переменная не равна |
|
Subscription |
loans запрещены, если переменная не равна ровно |
Включить loans на подписке: export ROS_DISABLE_LOANED_MESSAGES=0. rclcpp при этом предупредит: “Loaned messages are only safe with const ref subscription callbacks…”. Это переменная окружения процесса, она действует на все его подписки. Включайте её только для процессов, где все callback’и принимают const &.
Какие middleware в ROS 2 умеют shared memory
Всё ниже относится к Humble.
|
RMW |
SHM по умолчанию |
Zero-copy (loans) |
Нужен демон |
Комментарий |
|---|---|---|---|---|
|
rmw_fastrtps_cpp (Fast DDS) — дефолтный RMW Humble |
Да, SHM-транспорт включён вместе с UDPv4 |
В Humble нужны POD-тип и включённый Data Sharing (по умолчанию |
Нет |
|
|
rmw_cyclonedds_cpp (Cyclone DDS + iceoryx) |
Нет, включается в XML |
POD + |
Да, RouDi |
Тема этой статьи. shared_memory_support.md |
|
rmw_connextdds (RTI Connext) |
Да, builtin transport mask |
Loans в rmw не поддерживаются (таблица в доках ROS) |
Нет |
Коммерческая лицензия. RTI: TRANSPORT_BUILTIN |
|
rmw_iceoryx (не DDS) |
Только SHM |
Для POD |
Да, RouDi |
Только одна машина. README: не поддерживаются |
|
rmw_zenoh_cpp (не DDS) |
Нет, включается ключами |
SHM «неявно» для любых сообщений больше порога (по умолчанию 512 Б) |
zenohd-роутер (для discovery) |
Отдельно заметим, что у Fast DDS SHM-транспорт (копия в общий сегмент) включён по умолчанию, а Data Sharing (близкий к zero-copy путь) — нет. Если вы на дефолтном RMW, часть выигрыша от SHM у вас, скорее всего, уже есть.
Настройка: cyclonedds.xml и RouDi
Установка
sudo apt install ros-humble-rmw-cyclonedds-cpp # подтянет cyclonedds и iceoryx_{hoofs,posh,binding_c}sudo apt install ros-humble-iceoryx-introspection # iox-introspection-client (для отладки)
Бинарный Cyclone в Humble собран с поддержкой iceoryx (DDS_HAS_SHM). Пересобирать ничего не нужно, iox-roudi лежит в /opt/ros/humble/bin.
cyclonedds.xml
Минимальный конфиг:
<?xml version="1.0" encoding="UTF-8" ?><CycloneDDS xmlns="https://cdds.io/config" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://cdds.io/config https://raw.githubusercontent.com/eclipse-cyclonedds/cyclonedds/releases/0.10.x/etc/cyclonedds.xsd"> <Domain id="any"> <SharedMemory> <Enable>true</Enable> <LogLevel>info</LogLevel> </SharedMemory> </Domain></CycloneDDS>
Все опции секции //CycloneDDS/Domain/SharedMemory в Cyclone 0.10.x (docs/manual/options.md):
|
Элемент |
Значения |
По умолчанию |
Назначение |
|---|---|---|---|
|
|
|
|
Включает iceoryx-транспорт |
|
|
|
|
Уровень логов рантайма iceoryx внутри процесса |
|
|
строка |
|
По нему Cyclone решает, что два процесса подключены к одному RouDi. Трогать нужно редко, например в контейнерах, где MAC отличаются. |
|
|
строка |
|
Имя «сервиса» iceoryx, под которым Cyclone публикует топики |
Логи самого Cyclone по SHM включаются через трассировку:
<Tracing> <Category>shm</Category> <OutputFile>stdout</OutputFile></Tracing>
Как подключить конфиг
export RMW_IMPLEMENTATION=rmw_cyclonedds_cppexport CYCLONEDDS_URI=file:///abs/path/cyclonedds.xml
CYCLONEDDS_URI — это список через запятую. Элементом может быть file://... или XML прямо в строке, в сокращённой форме без корневых <CycloneDDS><Domain>. Примеры:
# inlineexport CYCLONEDDS_URI='<SharedMemory><Enable>true</Enable></SharedMemory>'# базовый файл + добавка сверхуexport CYCLONEDDS_URI="file://$HOME/base.xml,<Tracing><Category>shm</Category><OutputFile>stdout</OutputFile></Tracing>"
RouDi
iox-roudi # встроенный конфиг mempool'овiox-roudi -c /etc/iceoryx/roudi.toml # свой конфиг
RouDi должен быть запущен до процессов с SHM. Если его нет, нода пишет RouDi not found - waiting ..., ровно через 60 секунд падает с Timeout registering at RouDi. Is RouDi running? и terminate called. На одной машине (в одном IPC-namespace) должен быть ровно один RouDi.
Опции командной строки (iceoryx 2.0.5, iox-roudi —help)
|
Опция |
Что делает |
|---|---|
|
|
TOML-конфиг. Без опции ищется |
|
|
|
|
|
Мониторинг «живости» процессов (по умолчанию |
|
|
Через сколько секунд после SIGTERM RouDi пошлёт SIGKILL не завершившимся приложениям при своём останове |
|
|
Уникальный id RouDi |
|
|
Строгость проверки версий runtime ↔ RouDi: |
TOML-конфиг: сегменты и mempool’ы
[general]version = 1[[segment]]# reader = "group_a" # необязательно: POSIX-группа с правом чтения# writer = "group_b" # необязательно: POSIX-группа с правом записи[[segment.mempool]]size = 128 # размер payload в байтах (кратно 8)count = 10000 # число блоков[[segment.mempool]]size = 2100000 # под сообщения ~2 МБcount = 64
-
size— размер payload chunk’а. Chunk-header iceoryx добавляется сверху автоматически. Cyclone добавляет свой user-header, поэтому конкретное ROS-сообщение занимает чуть большеsizeof(msg). Например, наше сообщение на 2 000 016 байт требовало chunk на 2 000 120 байт (видно в логе ошибки ниже). В документации Cyclone указан запас «+64 байта к размеру типа». Закладывайте с запасом. -
Под каждую публикацию берётся наименьший подходящий свободный блок.
-
Несколько
[[segment]]сreader/writerпозволяют разграничить доступ по группам ОС.
Встроенный конфиг (если -c не задан):
|
size |
count |
|---|---|
|
128 |
10000 |
|
1 024 |
5000 |
|
16 384 |
1000 |
|
131 072 |
200 |
|
524 288 |
50 |
|
1 048 576 |
30 |
|
4 194 304 |
10 |
При старте RouDi с дефолтами занимает в /dev/shm ~66,8 МБ под iceoryx_mgmt и ~149 МБ под данные (из лога). В Docker контейнеру нужны достаточный --shm-size и общий IPC-namespace с RouDi.
Что будет, если блока подходящего размера нет (конфиг, где максимальный пул 128 КБ):
ICEORYX error! MEPOO__MEMPOOL_GETCHUNK_CHUNK_IS_TOO_LARGECould not find a fitting mempool for a chunk of size 2000120terminate called after throwing an instance of 'rclcpp::exceptions::RCLError' what(): failed to publish message: failed to publish data, at ./src/rmw_node.cpp:1837
Через borrow_loaned_message() получается аналогичное исключение sample_ptr is null. Если блоки нужного размера есть, но все заняты, Cyclone делает 10 попыток с паузой 1 мс, после чего publish возвращает ошибку. Никакого тихого fallback в сеть нет. Размер пулов надо считать заранее: блоков должно хватать на очереди подписчиков, историю издателей и «висящие» loans. Проверять запас удобно по колонке Min Free в интроспекции (iox-introspection-client).
Лимиты, зашитые при сборке iceoryx
Поменять их можно только пересборкой iceoryx (и Cyclone против неё). Значения для 2.0.x (IceoryxPoshDeployment.cmake, iceoryx_posh_types.hpp):
|
Константа |
Значение |
|---|---|
|
|
512 на RouDi |
|
|
1024 на RouDi |
|
|
256 |
|
|
16 |
|
|
8 (не более 8 одновременных loans на publisher) |
|
|
256 (= максимальная ёмкость очереди подписчика) |
|
|
300 процессов |
|
|
32 / 100 |
|
|
256 событий на listener, отсюда лимит SHM-читателей на процесс (см. «Лимит SHM-подписок в процессе») |
Когда Cyclone на самом деле использует SHM для топика
Даже при Enable=true для каждого writer/reader проверяются условия. Если хоть одно не выполнено, этот endpoint тихо уходит в сеть. Условия ниже взяты из кода 0.10.x (dds_writer_support_shm, dds_reader_support_shm). В документации Cyclone список строже и устарел, верить надо коду:
|
Условие |
Writer |
Reader |
|---|---|---|
|
Тип |
fixed-size или у sertype есть |
то же |
|
History |
только |
только |
|
Durability |
|
|
|
Depth |
для |
любая; очередь iceoryx ограничивается 256 |
|
Reliability |
|
оба |
|
Liveliness |
|
|
|
Deadline |
бесконечный |
бесконечный |
|
ignore_local |
нет |
нет |
Практические последствия:
-
KEEP_ALL(например,rclcpp::QoS(rclcpp::KeepAll())) означает, что SHM не используется. -
Подписка с
ignore_local_publications = trueтоже идёт мимо SHM. Так устроен служебный readerros_discovery_infoвнутри rmw. -
Подписка с depth 1000 (
/parameter_events) работает через SHM, но iceoryx-очередь у неё будет 256. -
Для iceoryx-соединения SHM нужен обоим участникам. Если у одного он выключен, обмен идёт по UDP (см. «Разный конфиг Cyclone для разных нод»).
Как проверить, что SHM реально работает
Внешне SHM ничем себя не выдаёт: сообщения доходят и без него. Ниже способы это увидеть.
Трассировка Cyclone (категория shm)
export CYCLONEDDS_URI="file://$PWD/cyclonedds.xml,<Tracing><Category>shm</Category><OutputFile>stdout</OutputFile></Tracing>"ros2 run demo_nodes_cpp talker
Реальный вывод:
talker: Current process name for iceoryx is iceoryx_rt_313712_1789571835193512600talker: Writer's topic name will be DDS:Cyclone:ros_discovery_infotalker: Writer's topic name will be DDS:Cyclone:rr/talker/get_parametersReplytalker: Reader's topic name will be DDS:Cyclone:rq/talker/get_parametersRequest...talker: Writer's topic name will be DDS:Cyclone:rt/parameter_eventstalker: Reader's topic name will be DDS:Cyclone:rt/parameter_eventstalker: Writer's topic name will be DDS:Cyclone:rt/chatter
Строка Writer's/Reader's topic name will be DDS:Cyclone:<топик> печатается только для endpoint’ов, которым достался iceoryx. Если нужного топика в списке нет, значит не выполнено одно из условий, которое я описал выше. Префиксы: rt/ — топики, rq//rr/ — запросы и ответы сервисов. Сервисы, однако, тоже ходят через SHM.
Это необходимое, но не достаточное условие: у второй стороны iceoryx тоже должен быть.
iox-introspection-client
iox-introspection-client --all # или --mempool / --port / --process, -t <ms> — период
TUI показывает три блока:
-
MemPool Status — по каждому пулу: Chunks In Use, Total, Min Free, Chunk Size. Если во время публикации растёт Chunks In Use в пуле нужного размера, данные идут через SHM.
-
Processes — зарегистрированные процессы. Cyclone называет рантайм
iceoryx_rt_<PID>_<timestamp>, так что PID виден прямо в имени. -
Min Free — минимум свободных блоков за всё время. Если он близок к 0, пулы надо увеличить.
MemPool Status, Processes и Min Free -
Connections — publisher-порты: сервис
DDS_CYCLONE, тип, топик (rt/parameter_events,ros_discovery_info, …) и процесс-владелец. Если вашего топика здесь нет, SHM для него не используется.
Connections
Логи RouDi
Полезные предупреждения:
-
Chunk history request exceeds history capacity! Request is 100. Capacity is 1.— подписчикtransient_localхочет историю больше, чем хранит издатель (типичный случай/tf_static). -
Application iceoryx_rt_<PID>_... not responding ... --> removing it— процесс упал или был убит, RouDi освобождает его ресурсы. -
MEPOO__MEMPOOL_GETCHUNK_CHUNK_IS_TOO_LARGE— нет пула нужного размера.
Косвенно: счётчики UDP (без root)
awk '/^Udp:/{getline; print "OutDatagrams:", $5}' /proc/net/snmp # до и после прогона
Прогон 150 сообщений POD по 2 МБ с частотой 30 Гц:
|
Режим |
Δ UDP OutDatagrams (по всей системе) |
|---|---|
|
без SHM |
24 214 |
|
с SHM |
260 (discovery и прочий фон) |
Если есть root, то же видно в tcpdump -i lo udp или Wireshark.
В коде
publisher->can_loan_messages() / subscription->can_loan_messages() возвращают true только если SHM активен для endpoint’а, тип POD и loans не запрещены переменной окружения. Удобно выводить в лог при старте.
Разный конфиг Cyclone для разных нод
CYCLONEDDS_URI читается при создании DDS-участника, то есть это свойство процесса, а не ноды. Поэтому разным процессам можно давать разные конфиги. В частности, SHM стоит включать только там, где идут тяжёлые данные.
Как ведут себя смешанные конфигурации (talker + ros2 node list / ros2 param get, RouDi запущен):
|
Ноды |
CLI ( |
|
|
|---|---|---|---|
|
SHM |
SHM |
пусто |
|
|
SHM |
без SHM |
✅ |
✅ |
|
без SHM |
SHM |
✅ |
✅ |
|
без SHM |
без SHM |
✅ |
✅ |
Когда один из участников без SHM, обмен идёт по UDP и всё работает. Отсюда удобная схема: SHM только в процессах конвейера сенсорных данных, а всё остальное (CLI, rviz, нода со статическими TF, robot_state_publisher, map_server) без него.
Launch-файл
from launch import LaunchDescriptionfrom launch_ros.actions import Node, ComposableNodeContainerfrom launch_ros.descriptions import ComposableNodeSHM = 'file:///opt/robot/config/cyclonedds_shm.xml'NO_SHM = 'file:///opt/robot/config/cyclonedds.xml'def generate_launch_description(): return LaunchDescription([ # тяжёлый конвейер: SHM Node(package='my_camera', executable='camera_driver', additional_env={'CYCLONEDDS_URI': SHM}), ComposableNodeContainer( name='perception', namespace='', package='rclcpp_components', executable='component_container_mt', additional_env={'CYCLONEDDS_URI': SHM}, # действует на ВЕСЬ контейнер composable_node_descriptions=[ ComposableNode(package='my_perception', plugin='my::Detector', extra_arguments=[{'use_intra_process_comms': True}]), ]), # transient_local-издатели и «сервисные» ноды: без SHM Node(package='robot_state_publisher', executable='robot_state_publisher', additional_env={'CYCLONEDDS_URI': NO_SHM}), ])
additional_env — параметр launch.actions.ExecuteProcess, а launch_ros.actions.Node от него наследуется. Для группы нод то же самое делает SetEnvironmentVariable внутри GroupAction.
Нюансы:
-
Внутри одного контейнера конфиг один на все компоненты (один процесс — один участник DDS).
-
ros2daemon запоминает окружение первого вызова CLI. После сменыCYCLONEDDS_URIв терминале выполнитеros2 daemon stop, иначе CLI будет работать через старый daemon. -
Каждому процессу с SHM нужен запущенный RouDi. Процессам без SHM RouDi не нужен.
Подводные камни одного SHM-конфига на всю систему
Самый частый сценарий — export CYCLONEDDS_URI=...shm.xml в ~/.bashrc, после чего SHM включён у всех нод, CLI, rviz и rqt. Ниже что при этом ломается. Поведение описано для Humble (Cyclone 0.10.5, iceoryx 2.0.5).
transient_local не работает для поздних подписчиков
Если издатель TRANSIENT_LOCAL уже опубликовал сообщение, а подписчик (тоже с SHM) подключается позже, историческое сообщение он не получает:
|
Сценарий |
Результат |
|---|---|
|
|
ничего не приходит |
|
то же → |
|
|
|
❌ |
|
|
❌ |
|
сначала подписчик, потом |
✅ |
Это известные открытые баги: rmw_cyclonedds#401 (открыт с 2022, воспроизводится на Humble в 2026) и cyclonedds#1584 (в январе 2026 подтверждён на 0.10.5). По объяснению мейнтейнера Cyclone, iceoryx кладёт историю в очередь подписчика, но не будит listener, поэтому Cyclone не забирает данные, пока не придёт свежее сообщение. У однократно публикуемых топиков свежих сообщений не бывает.
Под удар попадают /tf_static, /robot_description, /map (map_server), всё, что публикуется один раз.
Не работают ros2cli-утилиты, которые опираются на имена нод
Сведения о том, какой ноде принадлежат какие топики и сервисы, rmw передаёт через служебный топик ros_discovery_info (TRANSIENT_LOCAL, KEEP_LAST 1). У нас его writer в трассировке идёт через iceoryx. Поэтому процесс, запущенный после ноды, не узнаёт о ней:
-
ros2 daemonзапущен до talker →ros2 node listпоказывает/talker✅; -
тот же
ros2 node list --no-daemon(свежий процесс, поздний подписчик) → пусто.
Какие утилиты затронуты при глобальном SHM:
|
Команда |
Результат |
|---|---|
|
|
пусто |
|
|
|
|
|
|
|
|
пусто / |
|
|
пусто |
|
|
|
|
|
⚠️ |
|
|
⚠️ |
|
|
✅ работают: используют стандартный DDS discovery и прямые вызовы |
Под удар так же попадают rqt_graph и остальные глаголы ros2 component/ros2 lifecycle. В программном коде то же касается Node::get_node_names() и связанных вызовов graph API: они видят только ноды, стартовавшие после вашего процесса.
Другие грабли
-
RouDi — обязательная зависимость и единая точка отказа. Без него любой процесс с SHM висит 60 с на старте и падает.
-
Нехватка mempool приводит к исключению в
publish(), а не к переходу на сеть. -
Loans +
shared_ptr-callback = use-after-free (см. «ROS_DISABLE_LOANED_MESSAGESв Humble»). Поэтому в Humble loans на подписке выключены. -
Мусорные loans/чанки. В rmw_cyclonedds#471 при нескольких издателях копились неосвобождённые chunk’и, iceoryx возвращал
TOO_MANY_CHUNKS_HELD_IN_PARALLEL. -
Composable-ноды иногда не загружаются при SHM: rmw_cyclonedds#472 (открыт). Автор issue в крупной системе (>100 нод) в итоге оставил iceoryx только для volatile-топиков с сенсорными данными, а сервисы, загрузку компонентов, параметры и transient_local увёл с SHM. Это та же схема, что в главе «Разный конфиг Cyclone для разных нод».
-
Контейнеры. RouDi и приложения должны видеть один
/dev/shmи IPC-namespace. Иначе процессы «ждут RouDi» или получают расхождения вLocator(см. rmw_cyclonedds#476). -
Только Linux. На Windows SHM у Cyclone нет.
Лимит SHM-подписок в процессе
Все iceoryx-reader’ы процесса вешаются на один iceoryx listener, а у listener’а в iceoryx 2.0.5 максимум 256 событий (IOX_INTERNAL_MAX_NUMBER_OF_NOTIFIERS). Пример: python-нода создаёт подписки std_msgs/Float64 в цикле:
FAILED after 250 subscriptions: RCLError: Failed to create subscription: invalid allocatorpython3: ./src/core/ddsc/src/dds_entity.c:338: delete_children: Assertion ... failed.
250 пользовательских подписок + 6 reader’ов сервисов параметров ноды = 256. При достижении лимита создание подписки падает (Failed to attach iox subscriber to iox listener → ошибка), а процесс ещё и завершается с assert при выходе. Официальная документация rmw_cyclonedds называет цифру 127. Она написана для более старого iceoryx, но как консервативный ориентир годится.
Считайте reader’ы, а не топики. Каждая rclcpp-нода сама создаёт ~6 reader’ов для сервисов параметров и 1 для /parameter_events (7 reader’ов у talker). Большой component_container на 30+ нод упрётся в лимит даже при небольшом числе пользовательских подписок.
Бенчмарки
-
Apex.AI performance_test, 8 МБ @ 30 Гц, 1…32 подписчика, Cyclone 0.9 (cyclonedds#2155, январь 2025). По словам автора, SHM снижает задержку «всего примерно вдвое» относительно UDP, но она остаётся в десятках миллисекунд и растёт с числом подписчиков. Standalone iceoryx на том же тесте даёт доли миллисекунды.
-
ROS 2 TSC RMW Report для Humble, раздел Cyclone DDS (osrf.github.io). Это отчёт самого вендора (ADLINK), performance_test, Ryzen 5 5600X. В нём заявлено, что «Cyclone DDS with iceoryx improves upon Cyclone DDS in every measure: latency, jitter, throughput, memory, total CPU usage» на сообщениях до 4–8 МБ через LoanedMessage API. Числа приведены на графиках.
-
Сравнение zero-copy Fast DDS (Data Sharing) и Cyclone+iceoryx от eProsima (Discourse, октябрь 2021). Тоже вендор, эпоха Galactic. По их данным Fast DDS выигрывает по throughput на больших сообщениях. У Cyclone+iceoryx выше ~1000 сообщений/с кончались mempool’ы, RouDi падал.
-
Agnocast (Ishikawa-Aso, Kato, IEEE ISORC 2025, arXiv:2506.16882). Главный аргумент статьи — iceoryx-подобные решения не поддерживают неразмерные типы, которые повсюду в Autoware. На конвейере предобработки облаков точек Autoware авторы получили −16 % средней и −25 % худшей задержки отклика.
-
Документация Cyclone содержит график «сеть (lo) против iceoryx» по размерам сообщений (shared_memory.rst). Выигрыш растёт с размером сообщения. Сами авторы оговаривают, что результаты сильно зависят от конфигурации пулов и нагрузки.
Про новые дистрибутивы
В Lyrical и Rolling используется Cyclone DDS 11.0.x. iceoryx там подключается как плагин (<General><Interfaces><PubSubMessageExchange type="iox" library="psmx_iox" config="..."/>), а секция <SharedMemory> помечена устаревшей и автоматически конвертируется в PSMX. В master мейнтейнеры исправили путь take с loans (cyclonedds#2155). Выводы этой статьи про производительность и баги к Cyclone 11 автоматически не переносятся.
Источники
Исходный код:
-
ros2/rmw_cyclonedds@humble:rmw_cyclonedds_cpp/src/rmw_node.cpp,serdata.cpp,TypeSupport_impl.hpp,shared_memory_support.md -
eclipse-cyclonedds/cyclonedds@releases/0.10.x:src/core/ddsc/src/dds_writer.c,dds_reader.c,dds_write.c,shm_monitor.c,src/core/ddsi/src/ddsi_shm_transport.c,q_init.c,docs/manual/options.md,docs/manual/shared_memory.rst -
eclipse-iceoryx/iceoryx@release_2.0:iceoryx_posh/cmake/IceoryxPoshDeployment.cmake,iceoryx_posh_types.hpp,source/roudi/roudi_cmd_line_parser*.cpp,source/mepoo/mepoo_config.cpp,configuration-guide.md -
ros2/rclcpp@humble:publisher.hpp,loaned_message.hpp,experimental/intra_process_manager.hpp,subscription_base.cpp,executor.cpp,rclcpp_components/src/component_manager.cpp -
ros2/rcl@humble:rcl/src/rcl/publisher.c,subscription.c,node.c -
ros2cli(Humble):ros2topic/verb/pub.py(QoS по умолчаниюTRANSIENT_LOCAL, depth 1),ros2param,ros2node,ros2lifecycle,ros2component
Документация:
Issues и обсуждения:
-
rmw_cyclonedds#401, cyclonedds#1584 — transient_local + SHM
-
rmw_cyclonedds#469 — loans + shared_ptr → выключение loans по умолчанию
-
cyclonedds#2155 — производительность SHM против standalone iceoryx
ссылка на оригинал статьи https://habr.com/ru/articles/1095648/