RTP Desporto`Bis` de Pedro Neto garante triunfo do Chelsea na visita ao EvertonESPN DeportesLos colchoneros suman los tres puntos en los minutos finalesInquirerBukidnon ube set to enter US marketESPNFrom three-star recruit to billboards, Charlie Becker is the face of the HoosiersThe Jerusalem PostIn Pittsburgh, campus antisemitism report’s authors neglected to speak with Jewish studentsPunchEPL: Neto double gives Chelsea 2-0 win at EvertonCNN TürkTürkiye’de elektrikli araç şarj tüketimi eylülde yüzde 14,6 azaldıZDF heuteEntdecken Sie das ZDF-NachrichtenstudioABC NewsPope Leo XIV discharged from hospital, returns to VaticanWirtualna PolskaCzy Polska powinna przywrócić zasadniczą służbę wojskową? Mamy sondażColliderThe 6 Best Action Movies Released Between 2010 and NowVarietyMahershala Ali Cries Recalling ‘Moonlight’ Scene, Keeps Quiet on ‘Spider-Verse’ at London Talk: ‘I’ve Got to Be Careful’
The Daily Newsstand · Free, Always
Saturday, October 17, 2026

Shared memory в ROS 2 Humble: CycloneDDS + iceoryx на практике

Translate

Привет, хабр!

Когда вы создаёте большой проект на базе 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 работает не всегда и не бесплатно:

  1. IPC выключен по умолчанию. Его надо включать: rclcpp::NodeOptions().use_intra_process_comms(true) или в launch extra_arguments=[{'use_intra_process_comms': True}].

  2. Zero-copy только при публикации std::unique_ptr. publish(const Msg&) при включённом IPC сначала делает копию. Если подписчиков несколько и часть хочет владеть сообщением (unique_ptr-callback), rclcpp копирует для каждого из них, кроме последнего.

  3. Ограничения QoS в Humble. Publisher с IPC бросает исключение, если QoS не KEEP_LAST, depth = 0 или durability не VOLATILE. Значит, transient_local-топики (/tf_static, /map, /robot_description) через IPC не пустить.

  4. Внешние подписчики всё равно получают данные через 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)

Однако есть несколько неочевидных вещей:

  1. Обычный publish() не даёт zero-copy. Если сообщение не было взято через loan, Cyclone берёт chunk и копирует в него POD или сериализует не-POD.

  2. На стороне подписчика в 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: нет фрагментации, системных вызовов и сетевого стека.

  3. Настоящий zero-copy в Humble — это borrow_loaned_message() у издателя плюс ROS_DISABLE_LOANED_MESSAGES=0 у подписчика плюс POD-тип.

  4. Иногда сериализация нужна в любом случае. «Только iceoryx» используется, если нет сетевых читателей, durability = VOLATILE и нет локальных не-SHM читателей в том же процессе. Иначе сообщение дополнительно сериализуется для сети/истории писателя.

  5. Discovery всегда идёт по UDP. SHM ускоряет только пользовательские данные.

POD-сообщения и что будет с не-POD

Что такое POD в терминах rmw_cyclonedds

POD (plain old data) — тип фиксированного размера, который целиком лежит в своей структуре и не ссылается на кучу. Его можно скопировать memcpy в другой процесс, и он останется валидным.

Тип не POD, если где-то в нём, рекурсивно по вложенным сообщениям, есть:

  • string или wstring;

  • неограниченная последовательность T[];

  • ограниченная последовательность T[<=N] (у неё переменная длина!).

Фиксированные массивы T[N] допустимы.

POD

не POD

std_msgs/Float64, UInt32, Bool

std_msgs/String

geometry_msgs/Point, Pose, Twist, Vector3

всё с std_msgs/Header (в нём string frame_id): sensor_msgs/Image, PointCloud2, Imu, LaserScan, PoseStamped…

свой uint8[2000000] data

std_msgs/Float64MultiArray, uint8[] data, uint8[<=100] data

Большинство «тяжёлых» стандартных сообщений (Image, PointCloud2) — не POD. Для zero-copy делают свои типы с фиксированными массивами (пример — проект ros2_shm_msgs) или используют решения для неразмерных типов вроде Agnocast.

Что происходит с не-POD при включённом SHM

Документация Cyclone утверждает, что для не-fixed-size типов SHM «отключается». Для rmw_cyclonedds в Humble это не так:

  1. 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.

  2. При publish() сообщение сериализуется в CDR прямо в chunk (IOX_CHUNK_CONTAINS_SERIALIZED_DATA), а подписчик его десериализует. Сеть не участвует, но сериализация в обе стороны остаётся.

  3. 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 iceoryx
loaned.get().stamp_ns = now_ns();
fill(loaned.get().data);                      // пишем сразу в shared memory
pub->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 разрешены, если переменная не равна "1"

Subscription

loans запрещены, если переменная не равна ровно "0" (то есть по умолчанию)

Включить 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 (по умолчанию OFF, включается XML-профилем + RMW_FASTRTPS_USE_QOS_FROM_XML=1)

Нет

README rmw_fastrtps, Fast DDS SHM transport

rmw_cyclonedds_cpp (Cyclone DDS + iceoryx)

Нет, включается в XML

POD + borrow_loaned_message (+ ROS_DISABLE_LOANED_MESSAGES=0 для подписки)

Да, RouDi

Тема этой статьи. shared_memory_support.md

rmw_connextdds (RTI Connext)

Да, builtin transport mask UDPv4|SHMEM

Loans в rmw не поддерживаются (таблица в доках ROS)

Нет

Коммерческая лицензия. RTI: TRANSPORT_BUILTIN

rmw_iceoryx (не DDS)

Только SHM

Для POD

Да, RouDi

Только одна машина. README: не поддерживаются ros2 param list, rmw-события; «under heavy development». README

rmw_zenoh_cpp (не DDS)

Нет, включается ключами transport/shared_memory/enabled

SHM «неявно» для любых сообщений больше порога (по умолчанию 512 Б)

zenohd-роутер (для discovery)

README (humble)

Отдельно заметим, что у 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):

Элемент

Значения

По умолчанию

Назначение

Enable

true/false

false

Включает iceoryx-транспорт

LogLevel

off, fatal, error, warn, info, debug, verbose

info

Уровень логов рантайма iceoryx внутри процесса

Locator

строка

"" (один из MAC-адресов машины)

По нему Cyclone решает, что два процесса подключены к одному RouDi. Трогать нужно редко, например в контейнерах, где MAC отличаются.

Prefix

строка

DDS_CYCLONE

Имя «сервиса» iceoryx, под которым Cyclone публикует топики

Логи самого Cyclone по SHM включаются через трассировку:

<Tracing>
  <Category>shm</Category>
  <OutputFile>stdout</OutputFile>
</Tracing>

Как подключить конфиг

export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
export CYCLONEDDS_URI=file:///abs/path/cyclonedds.xml

CYCLONEDDS_URI — это список через запятую. Элементом может быть file://... или XML прямо в строке, в сокращённой форме без корневых <CycloneDDS><Domain>. Примеры:

# inline
export 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)

Опция

Что делает

-c, --config-file <path>

TOML-конфиг. Без опции ищется /etc/iceoryx/roudi_config.toml, иначе используется встроенный конфиг

-l, --log-level

off, fatal, error, warning, info, debug, verbose

-m, --monitoring-mode on|off

Мониторинг «живости» процессов (по умолчанию on). При on RouDi вычищает ресурсы упавших процессов; в логе это Application ... not responding ... --> removing it

-k, --kill-delay <сек>

Через сколько секунд после SIGTERM RouDi пошлёт SIGKILL не завершившимся приложениям при своём останове

-u, --unique-roudi-id <int>

Уникальный id RouDi

-x, --compatibility

Строгость проверки версий runtime ↔ RouDi: off, major, minor, patch, commitId, buildDate

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_LARGE
Could not find a fitting mempool for a chunk of size 2000120
terminate 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):

Константа

Значение

IOX_MAX_PUBLISHERS

512 на RouDi

IOX_MAX_SUBSCRIBERS

1024 на RouDi

IOX_MAX_SUBSCRIBERS_PER_PUBLISHER

256

IOX_MAX_PUBLISHER_HISTORY

16

IOX_MAX_CHUNKS_ALLOCATED_PER_PUBLISHER_SIMULTANEOUSLY

8 (не более 8 одновременных loans на publisher)

IOX_MAX_CHUNKS_HELD_PER_SUBSCRIBER_SIMULTANEOUSLY

256 (= максимальная ёмкость очереди подписчика)

MAX_PROCESS_NUMBER

300 процессов

MAX_NUMBER_OF_MEMPOOLS / MAX_SHM_SEGMENTS

32 / 100

IOX_INTERNAL_MAX_NUMBER_OF_NOTIFIERS

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 есть serialize_into (в rmw_cyclonedds Humble есть, так что подходит любой тип)

то же

History

только KEEP_LAST

только KEEP_LAST

Durability

VOLATILE или TRANSIENT_LOCAL

VOLATILE или TRANSIENT_LOCAL

Depth

для TRANSIENT_LOCAL не больше 16 (IOX_MAX_PUBLISHER_HISTORY)

любая; очередь iceoryx ограничивается 256

Reliability

RELIABLE и BEST_EFFORT

оба

Liveliness

AUTOMATIC

AUTOMATIC

Deadline

бесконечный

бесконечный

ignore_local

нет

нет

Практические последствия:

  • KEEP_ALL (например, rclcpp::QoS(rclcpp::KeepAll())) означает, что SHM не используется.

  • Подписка с ignore_local_publications = true тоже идёт мимо SHM. Так устроен служебный reader ros_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_1789571835193512600
talker: Writer's topic name will be DDS:Cyclone:ros_discovery_info
talker: Writer's topic name will be DDS:Cyclone:rr/talker/get_parametersReply
talker: Reader's topic name will be DDS:Cyclone:rq/talker/get_parametersRequest
...
talker: Writer's topic name will be DDS:Cyclone:rt/parameter_events
talker: Reader's topic name will be DDS:Cyclone:rt/parameter_events
talker: 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

    MemPool Status, Processes и Min Free

  • Connections — publisher-порты: сервис DDS_CYCLONE, тип, топик (rt/parameter_events, ros_discovery_info, …) и процесс-владелец. Если вашего топика здесь нет, SHM для него не используется.

    Connections

    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 (ros2 …)

node list

param get

SHM

SHM

пусто

Node not found

SHM

без SHM

✅

✅

без SHM

SHM

✅

✅

без SHM

без SHM

✅

✅

Когда один из участников без SHM, обмен идёт по UDP и всё работает. Отсюда удобная схема: SHM только в процессах конвейера сенсорных данных, а всё остальное (CLI, rviz, нода со статическими TF, robot_state_publisher, map_server) без него.

Launch-файл

from launch import LaunchDescription
from launch_ros.actions import Node, ComposableNodeContainer
from launch_ros.descriptions import ComposableNode

SHM = '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).

  • ros2 daemon запоминает окружение первого вызова 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) подключается позже, историческое сообщение он не получает:

Сценарий

Результат

static_transform_publisher → потом ros2 topic echo /tf_static (transient_local)

ничего не приходит

то же → tf2_echo world foo

frame does not exist; RouDi: Chunk history request exceeds history capacity! Request is 100. Capacity is 1.

robot_state_publisher → потом echo /robot_description

❌

ros2 topic pub /late … (по умолчанию transient_local, depth 1) → потом echo

❌

сначала подписчик, потом static_transform_publisher

✅

Это известные открытые баги: 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:

Команда

Результат

ros2 node list

пусто

ros2 node info /talker

Unable to find node '/talker'

ros2 param list/get/set/dump/load/describe/delete

Node not found (сначала ищут ноду по имени)

ros2 lifecycle nodes / get / set / list

пусто / Node not found

ros2 component list

пусто

ros2 component load /ComponentManager …

Unable to find container node '/ComponentManager'

ros2 action info /fibonacci

⚠️ Action servers: 0 при работающем сервере

ros2 topic info -v /chatter

⚠️ Node name: _NODE_NAME_UNKNOWN_

ros2 topic list, ros2 topic echo (volatile), ros2 service list, ros2 service call, ros2 action list

✅ работают: используют стандартный 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 allocator
python3: ./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 и обсуждения:

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.