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


Привет, хабр!
Когда вы создаёте большой проект на базе 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 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 разрешены, если переменная не равна |
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_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)
Опция | Что делает |
|---|---|
| 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_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):
Константа | Значение |
|---|---|
| 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_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 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 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).
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 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.mdeclipse-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.rsteclipse-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.mdros2/rclcpp@humble:publisher.hpp,loaned_message.hpp,experimental/intra_process_manager.hpp,subscription_base.cpp,executor.cpp,rclcpp_components/src/component_manager.cppros2/rcl@humble:rcl/src/rcl/publisher.c,subscription.c,node.cros2cli(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
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.