Микросервисы(async single-threaded) в эмбеддед. OpenBMC и рефакторинг «кошмара на С++» с помощью ИИ


Я всегда знал, что разработка для встраиваемых систем (embedded) является не тривиальным делом, но даже не представлял, насколько С++ код в реальных enterprise-проектах может быть... скажем так, перегруженным. Тем интереснее попытаться исправить хотя бы кусочек такого кода, тем более что сегодня у нас есть прилежный цифровой помощник.
Справится ли искусственный интеллект с рефакторингом кода, от которого у обычного разработчика дёргается глаз? И в чём нейросети пока ещё безоговорочно уступают человеку? Заглядывайте под кат, там много боли, костылей, D-Bus и немного С++ и однопоточной асинхронности (async single-threaded).
Это микросервисы, детка: контекст проекта
Прежде чем препарировать сам код, давайте разберёмся, где он живёт. Наш подопытный — это один из компонентов огромного open-source проекта OpenBMC (открытый код для компиляции прошивок для модулей управления серверами, десктопными системами — BMC на базе Линукс-а).
Если говорить точнее, мы попробуем разобраться с куском кода (С++ функцией) из одного из подпроектов нижнего уровня в репозитории dbus-sensors, возьмем для примера сервис HwmonTempSensor:
https://github.com/openbmc/dbus-sensors/tree/17f2fc3fe1400bf9e45c075e4864cc79afa2ed91/src/hwmon-temp
Весь этот локальный проект компилируется в один исполняемый файл — systemd-сервис, задача которого — собирать данные с аппаратных датчиков (температуры, давления, влажности, другие собирают значения тока, напряжения, другой температуры, ...) и публиковать их на системной шине D-Bus. Оттуда эти данные забирают другие сервисы (например, для отображения в Web-интерфейсе).
Звучит красиво и современно? Да, это классическая микросервисная архитектура во встраиваемых системах:
Сервис общается с внешним миром через IPC-протоколы (D-Bus).
Он работает абсолютно независимо.
Архитектура изолирована и изолирует логику работы с железом.
Всё выглядит убедительно, пока мы не спускаемся на уровень исходного кода. По факту вся логика нашего микросервиса сосредоточена вокруг одной-единственной функции. Сделать ее читаемой — значит сделать управляемым весь микросервис. Или нет?
Знакомьтесь: функция createSensors на 280 строк
Мы попытаемся провести рефакторинг функции createSensors, оригинальный код которой можно увидеть по ссылке:
Для начала я попросил ИИ-агента объяснить мне логику этой функции. Ассистент прилежно насчитал аж 12 изолированных задач, которые она выполняет! Если обобщить, то createSensors — это такой «всемогущий комбайн», который:
Получает конфигурацию датчиков от другого микросервиса (
entity-manager) асинхронно(!), но в единственном потоке.Создает или ищет соответствующие драйвера I2C устройств через
sysfsи проверяет-следит за их присутствием на шинеI2C.Сопоставляет найденные/не найденные железки с конфигурацией.
Создаёт, активирует или пересоздаёт объекты
HwmonTempSensorи регистрирует их на D-Bus.Управляет порогами (thresholds), частотой опроса, состоянием питания и логикой работы нескольких датчиков на одном физическом I2C-устройстве.
Поддерживает сразу два режима работы: полный первичный скан при старте и точечное обновление при изменении конфигурации «на лету» (через события
шины Дбас).
Нормально ли собирать все это в теле одной функции с callback-ом? Вопрос риторический. Вложенность циклов и условий if внутри этой функции доходит до 6-7 уровней (for → for/if → if → if → if...).
Что предложил ИИ-агент?
Когда я спросил ИИ: «Мне кажется, эта функция перегружена, можешь дать предложения по её рефакторингу?», он выдал вполне очевидное, но структурное заключение.
Диагноз ИИ: Функция перегружена (~280 строк в одном callback). Основные проблемы: слишком глубокая вложенность, спагетти из разнородных задач (поиск, матчинг, создание, фильтрация) и нулевая тестируемость.
Агент сформировал 7 конкретных шагов для исправления ситуации:
1. Выделение класса-сканера и декомпозиция
Вместо монолитного лямбда-выражения перенести логику в отдельную структуру SensorScanner, где каждый шаг станет изолированным приватным методом на 20-30 строк:
struct SensorScanner {
SensorScanner(io, objectServer, sensors, dbusConnection, sensorsChanged, activateOnly);
// Один публичный вход — точка обработки конфигурации
void scan(const ManagedObjectType& configs);
private:
SensorConfigMap buildConfigMap(const ManagedObjectType& configs);
std::vector<std::filesystem::path> findSensorPaths();
void ensureDevice(...); // Инстанцирование I2C
void processPath(path); // Цикл по одному sysfs-пути
std::optional<...> matchConfig(path); // Матчинг с конфигурацией
};2. Устранение дублирования при создании датчиков
В оригинальном коде логика создания HwmonTempSensor продублирована дважды (для основного датчика и для дополнительных). ИИ предложил вынести это в метод:
std::shared_ptr<HwmonTempSensor> createOrActivateSensor(
const fs::path& hwmonFile,
std::shared_ptr<I2CDevice>& i2cDev,
...,
sensors[sensorName]);Это сразу избавляет проект от 60 строк копипасты.
(И далее еще 5 пунктов про вынос Name1..N, структур матчинга, разделения скан/обновление, side-эффекты и ранние continue).
Результаты рефакторинга и парадокс объёма кода
Я попросил агента реализовать эти предложения в коде. В итоге рефакторинг красиво разделил «кашу» на архитектурные уровни:
void createSensors(
AppContext& context,
boost::container::flat_map<std::string, std::shared_ptr<HwmonTempSensor>>& sensors,
const std::shared_ptr<boost::container::flat_set<std::string>>& sensorsChanged,
bool activateOnly)
{
auto scanner = std::make_shared<SensorScanner>(context, sensors, sensorsChanged, activateOnly);
scanner->run();
} дальше можно привести код нового класса SensorScanner и отдельные его функции которые раскладывают логику исходной супер функции на уровни… Но вряд ли это кому то особо интересно, это статья не про технику рефакторинга, а про то о чем не очень принять говорить.

Графический результат рефакторинга в git-диффе выглядит забавно: было — красным, стало — зелёным, и зелёного стало заметно больше, то есть кода суммарно стало БОЛЬШЕ!
И это вполне объяснимо. Мы одну большую функцию на 250 строк разбили на 10 функций по 25 строк. Нам пришлось добавить 10 новых сигнатур методов, определить новые структуры, классы (чтобы не передавать под десяток параметров по ссылке, а кидать одну заполненную структуру). И даже несмотря на то, что агент нашёл и выпилил копипаст-блоки, объединив их, общий объем кода вырос. Страшный код просто стал... по-модному структурированным.
И вот тут мы подходим к главному откровению этого эксперимента.
Иллюзия чистого кода: почему нельзя отрефакторить одну функцию
С высоты «чистого кода» результат кажется победой: вложенность ушла, функции маленькие, структуры явные. На собеседованиях в такие проекты часто требуют уверенное владение C++20, шаблонами (Концепты, SFINAE, CTAD), понимание RAII и Structured binding.
Но знаете что? Вам ничего из этого не поможет. И этот рефакторинг проекту не поможет тоже.
Потому что нельзя сделать рефакторинг одной сколь угодно страшной С++ функции. Это сизифов труд. Ужас и перегруженность конкретной функции — это не случайный сбой/грех/ошибка/<ваш термин> уставшего программиста. Это прямое, неизбежное следствие убогости идей по организации всего проекта. Код не является первопричиной проблемы, код — это просто реализация распиаренных абстрактных концепций, которые кто-то притащил в embedded, не подумав. И микросервисы здесь — главная из них.
В чём же тут вообще состояла светлая идея? Давайте разберёмся, как устроена эта экосистема.
Идея микросервисов и однопоточной асинхронности vs Суровая реальность OpenSource
Глобальная задача всей этой совокупности консольных экзешников (микросервисов) банальна и решена в индустрии тысячи раз: найти и прочитать все доступные в системе аппаратные устройства (от Ethernet-контроллеров до мелких датчиков на I2C-шине), структурировать эту информацию и выдать её на интерактивное отображение — например, в веб-морду.
В репозитории проекта https://github.com/openbmc/dbus-sensors гордо висят Key Features:
Runtime re-configurable from d-bus (entity-manager or the like)
Isolated: each sensor type is isolated into its own daemon...
Async single-threaded: uses sdbusplus/asio bindings
Красиво? Маркетингово. А теперь посмотрим, как этот «async single-threaded» устроен изнутри на самом деле.
1. Нелепое деление и Божественный сервис
Вместо того чтобы сделать простой, понятный и монолитный алгоритм: прочитать конфигурационные JSON-файлы (желательно только для этого конкретного сервиса) -> по этим конфигурациям найти аппаратные устройства-драйверы -> активировать их и работать с ними... авторы решили поиграть в микросервисы.
Они разделили этот неделимый процесс. Теперь отдельные сервисы датчиков сами файлы конфигурации не читают. Для этого завели специальный «главный», тоже божественный сервис — entity-manager. Он читает и парсит все(!) JSON-файлы конфигураций и... просто вываливает эти данные в шину D-Bus естественно по очереди! То есть все наши асинхронные сервисы просто стоят и ждут когда будет прочитана и опубликована нужная им часть всеобщей конфигурации.
Это очень похоже на публикацию данных в Shared Memory (разделяемую память), через которую теперь работают вообще ВСЕ. Поскольку трафик огромный, а читать всё подряд никто не хочет, каждый микросервис вынужден создавать свой фильтр (по умному/или чтобы было не как у других, называется match) и отдавать его центральному сервису фильтрации. Получается один «всезнающий» божественный обработчик (это НЕ entity-manager, это еще один, никому не видный, очень-микро-сервис), который перемалывает терабайты (нет конечно только мегабайты! Но вспомните это как бы embedded!) трафика IPC взаимодействия (которое гордо назвали аппаратным словом D-Bus «шина») и раздаёт отфильтрованные крохи. Решение дико неэффективное. Но это только полбеды.
2. Главная боль: Тайм-ауты «на глаз» из-за отсутствия транзакций
Самый ад начинается, когда наш изолированный сервис пытается эти данные собрать. Ему нужна конфигурация, чтобы проверить наличие железа в указанных локациях. Сервис создаёт фильтр и начинает покорно ждать у моря погоды. entity-manager запускается и начинает выкладывать конфигурацию на D-Bus.
Но использовать эти данные нельзя, пока они не выложены полностью! А наш сервис понятия не имеет, когда entity-manager закончит вещать, ведь тот выкладывает тонны данных для всех устройств в системе постепенно-сразу. Разработчики D-Bus и концепции микросервисов в эмбеддед, видимо, никогда в жизни не слышали про транзакции и атомарные операции. Для них этой проблемы просто не существует.
Знаете, как они её решили? За счёт производительности и надежности. Раз мы не знаем, когда данные закончатся, давайте... просто подождём на тайм-ауте!
В коде это выглядит так: пока entity-manager шлёт куски конфига, наш фильтр срабатывает и постоянно сбрасывает таймер. Мы выставляем задержку в 1 секунду (filterTimer.expires_after(std::chrono::seconds(1));). Если целую секунду (пять секунд, десять, где предел?) новых данных не прилетало — ура, костыль сработал, считаем, что данные готовы и можно наконец-то вызывать нашу createSensors!
В том числе из-за убогости этой архитектурной идеи функция createSensors внутри и превратилась в ад:
Ей приходится на лету разгребать флаги
firstScan,sensorsChangedиactivateOnly, потому что данные прилетают кусками, а система как-то должна запоминать свое состояние, ее поведение не тривиально.Мы тратим драгоценное время (самый важный ресурс в эмбеддед) просто на ожидание «а вдруг там ещё что-то допишется?». Данные уже лежат, но обрабатывать их нельзя — ждём тайм-аут.
Если микропроцессор загружен и
entity-managerдопустит задержку, наш сервис проснётся по тайм-ауту раньше времени и прочитает неполные, обрубленные данные. Всё упадёт.Если данные обновятся слишком быстро, мы пропустим промежуточное значение или вообще ослепнем, тут вообще нельзя предположить чем это аукнется — это чистое UB, только не по причине особенностей языка программирования, а потому что исходная идея ущербна.
На самом деле это не единственная убогая идея глобального ентерпрайз-проекта! Из-за одной единственной нехорошей идеи С++ функция не была бы такой безнадежно ужасной. Но не будем совсем уж закапывать опен-соурс проект, все таки он компилируется и работает, хоть и неторопливо, и это огромное облегчение для тех, кто строит на этой основе свои продукты, это несомненно огромная база полезных материалов, но вот архитектурные идеи я бы не советовал копировать оттуда вслепую.
Но что я не могу не отметить, это то что вот эта хрупкая, костыльная конструкция, которая сама не понимает, в какой момент времени она выполняется, эта конструкция рекламируется в документации как великое достижение асинхронного однопоточного подхода: Async single-threaded какой-то биндинг!
В чем ИИ пока безоговорочно уступает человеку?
Когда идея оказывается не работоспособной она обрастает кучей костылей, чтобы она хотя бы выглядела работоспособной. И в конце концов сама идея теряется в этом нагромождении костылей, но никто уже и не пытается анализировать, а тем более обновить идею, это уже не безопасно — вся конструкция обвалится! Поэтому выбора нет, только подставлять все новые костыли выдавая это за развитие проекта.
И вот тут мы возвращаемся к нашему ИИ-агенту. Нейросеть — потрясающий, прилежный инструмент. Она идеально разложила спагетти-код функции по отдельным тарелочкам-методам, создала структуры, убрала дублирование. Код стал выглядеть цивилизованно (я думаю вы поверите мне на слово!, хотя нет ничего проще скачать этот проект и проверить с вашим кодовым агентом).
Но ИИ совершенно не способен понять, что он рефакторит костыль. (дописано: или способен, но ему все равно?)
Нейросеть оптимизирует текст программы, а не инженерное решение. Она послушно причесала процедурный хаос, обернула его в красивую ООП-обёртку, но фундаментально логика осталась той же тяжеловесной костыльной системой, завязанной на убогую архитектуру межпроцессного взаимодействия. ИИ никогда не включит «стоп-кран» и не скажет: «Слушай, само это деление на EM и демоны датчиков — нелепость, давай выкинем этот D-Bus цирк и напишем нормальный монолитный парсинг» или не монолитный, а например библиотечный, если надо сделаем динамически загружаемые конфиги, но только там где это надо и только тогда когда это действительно надо. Давай разделим файлы конфигурации на уровни! Хотя бы на два! Общий конфиг, который нужен всем или почти всем и конфиги нижнего уровня, которые нужны конкретным сервисам. На самом деле это действительно не простая задача, как это правильно реализовать, но чтобы решить эту задачу ее для начала надо хотя бы увидеть-сформулировать, а не пытаться пробить лбом стену там, где написано «Async single-threaded».
ИИ никогда не включит «стоп-кран» и не скажет … Хотя, наверно, скажет! Скажет если вы напрямую его об этом спросите и предоставите достаточно контекста чтобы все проанализировать (тоже дописано).
Вывод
Нейросети сегодня — это классный инструмент для локального марафета. Но если архитектурная идея проекта изначально ущербна, никакой ИИ и никакой C++20 не сделают код хорошим. Они просто сделают ваши костыли более структурированными, чистыми и объектно-ориентированными или функционально-ориентированными, кому как нравится.
Хотя… нейросети завтра могут быть более обученными и возможно смогут проводить более серьезный анализ не только самих данных но и того сколько ей нужно данных чтобы выдать адекватный поставленной задаче результат… запрашивать дополнительные данные они уже умеют...
Архитектурное видение, критический анализ жизнеспособности идей и понимание предметной области по-прежнему остаются строго за человеком, но если правильно сформулировать предметный вопрос, нейросеть и в этом тоже очень может помочь.
А как вы относитесь к микросервисам и оверинжинирингу в embedded-разработке? Вы когда нибудь доходили до уровня осознания бредовости изначальной идеи, на которой построен проект?
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.