ABI-совместимость библиотек: что ломается первым под нагрузкой
Сервис работал месяцами, а после пересборки базового образа начал падать с SIGSEGV на пиковом трафике. Разбираем, как несовпадение ABI переживает сборку и линковку и всплывает именно под нагрузкой.

Типичный сценарий: приложение на C++ линкуется с системным libstdc++, собирается без ошибок, проходит тесты и уезжает в прод. Через две недели после обновления базового Docker-образа появляются редкие падения с SIGSEGV — сначала один раз в сутки, потом пачками на пиковом трафике. В логах ничего осмысленного, стектрейс обрывается внутри стандартной библиотеки. Проблема не в вашем коде и не в нагрузке как таковой — нагрузка лишь чаще прогоняет путь, на котором ABI разных объектных файлов не совпадает.
Разбираем, что такое ABI на практике, почему несовпадение доживает до рантайма незамеченным и какие именно конструкции ломаются первыми под многопоточной нагрузкой. Речь про нативные библиотеки: C, C++, расширения для Python и Node, собранные из C/C++.
ABI против API: где проходит граница
API — это то, что видит компилятор: имена функций, сигнатуры, объявления структур в заголовках. Если API совпадает, код компилируется. ABI1 — то, что видит процессор в рантайме: как аргументы раскладываются по регистрам и стеку, как выровнены поля структуры, какой размер у типа, как устроена таблица виртуальных функций, как манглятся имена символов.
Два объектных файла могут собираться под один и тот же заголовок, но с разным представлением одного и того же типа в памяти. Компоновщик их слинкует — он сверяет имена символов, а не раскладку памяти за ними. Ошибка проявится, только когда одна половина программы запишет в структуру по одному смещению, а другая прочитает по другому.
Совпадение API проверяет компилятор, и он честно скажет об ошибке. Совпадение ABI не проверяет никто до момента, пока данные не пройдут через границу между двумя объектными файлами в рантайме.
Почему нагрузка — катализатор, а не причина
Несовпадение ABI существует с момента линковки. Но проявляется оно вероятностно: падение происходит, когда управление проходит по конкретной ветке с конкретным типом. Под низким трафиком редкая ветка может не вызываться неделями. Под нагрузкой в тысячи RPS та же ветка исполняется сотни раз в секунду, и «раз в миллион» превращается в «раз в минуту». Плюс многопоточность добавляет гонки, которые обостряют повреждение памяти — об этом ниже.
Что ломается первым: список по убыванию частоты
- std::string и std::list после смены _GLIBCXX_USE_CXX11_ABI. Самый частый случай в C++. При переходе GCC на новый ABI для
std::stringиstd::listобъекты со старым и новым ABI имеют разную раскладку и разные манглированные имена. Если часть кода собрана с-D_GLIBCXX_USE_CXX11_ABI=0, а часть — с1, строки, переданные через границу, читаются с неверным смещением длины и указателя на буфер. - Размер и выравнивание структур из-за разных флагов. Если один объектный файл собран с
-DNDEBUGили с иным набором дефайнов, влияющих на поля структуры (например, отладочные счётчики внутри контейнера),sizeofодной структуры расходится. Одна сторона аллоцирует меньше памяти, чем другая ожидает, — переполнение при записи. - vtable при несовпадении версий компилятора. Порядок и наличие записей в таблице виртуальных функций — часть ABI. Библиотека, собранная более новым компилятором, может разложить vtable иначе; виртуальный вызов уходит по неверному смещению.
- Аллокатор: new в одной библиотеке, delete в другой. Если объект выделен аллокатором одной версии libc/libstdc++, а освобождён другой, разрушается структура кучи. Повреждение всплывает не в точке ошибки, а позже — при следующей аллокации в другом потоке.
- Соглашение о вызове и exceptions через границу C-ABI. Исключение C++, брошенное внутри одной библиотеки и пересёкшее границу, собранную с несовместимой моделью раскрутки стека, приводит к
std::terminateили порче стека.
Механизм: почему компоновщик не спасает
Компоновщик разрешает символы по именам. В C++ имена манглируются: сигнатура кодируется в строку символа. Здесь и кроется ловушка.
Смена CXX11-ABI меняет манглированное имя для функций, принимающих std::string. Поэтому при полном несовпадении вы получите ошибку линковки undefined reference — и это хороший исход, проблема ловится на сборке. Опасен частичный случай: тип передаётся не напрямую, а внутри другой структуры или через void*, либо ABI-различие в поле, не влияющем на манглинг (например, дополнительный bool-флаг под условным дефайном). Символ резолвится, программа запускается, память бьётся молча.
Диагностика: как увидеть несовпадение
Проверьте, каким ABI собран бинарь и какие символы он тянет:
# Показать динамические зависимости и версии символов
ldd ./your_service
# Найти в бинаре символы cxx11 (новый ABI) и старые рядом —
# смешение обоих часто означает несовместимую сборку
nm -C ./your_service | grep -i cxx11 | head
# Версии символов glibc, которые требует бинарь:
# если требуется более новая версия, чем на хосте, — падение при загрузке
objdump -T ./your_service | grep GLIBC | sort -uПроверено на binutils 2.3x под Linux x86-64. Флаги nm и objdump стабильны много лет, но точный вывод зависит от версии — сверяйтесь с man вашей системы.
Для рантайм-повреждений памяти собирайте отладочную сборку с санитайзером:
# Сборка с AddressSanitizer (GCC/Clang)
g++ -std=c++17 -fsanitize=address -g -O1 service.cpp -o service_asan
# Запуск под нагрузочным профилем; ASan укажет,
# в какой библиотеке произошла запись за границу буфера
./service_asanAddressSanitizer ловит heap-buffer-overflow и use-after-free и печатает обе стороны — где выделено и где повреждено. Именно это разводит «наш код» и «чужая библиотека с другим ABI».
Замеры: что и как мерить
Универсальных цифр здесь нет — повреждение памяти не даёт стабильной деградации, оно даёт хвост падений. Мерить нужно не latency, а частоту и распределение крешей:
- Частота SIGSEGV/SIGABRT на 1M запросов в разбивке по версиям базового образа. Скачок после смены образа при неизменном коде — сильный сигнал на ABI.
- Точка падения по стектрейсам. Соберите core dump (
ulimit -c unlimited) и агрегируйте адрес падения. Кластеризация внутриlibstdc++или аллокатора, а не в вашем коде, указывает на границу ABI. - Зависимость от параллелизма. Прогоните нагрузочный тест с 1, 50 и 500 конкурентными соединениями на фиксированном железе и объёме данных. Рост частоты падений с ростом конкурентности при неизменном RPS означает гонку поверх повреждённой кучи.
Где подход с «просто пересобрать» ломается
Совет «соберите всё одним компилятором» верен, но недостижим целиком: часть зависимостей приходит готовыми бинарями из репозиториев дистрибутива или PyPI-колёс. Границы, за которыми контроль теряется:
| Граница | Риск ABI | Что делать |
|---|---|---|
| Ваш код ↔ системный libstdc++ | Смена CXX11-ABI, версия symbol-версий glibc | Фиксировать базовый образ и версию toolchain; собирать в том же образе, что рантайм |
| Ваш код ↔ бинарь из apt/yum | Собран другим компилятором дистрибутива | Использовать стабильный C-ABI на границе; проверять objdump -T |
| Python-расширение ↔ CPython | Несовпадение ABI-тега колеса и версии интерпретатора | Ставить колёса под точный ABI-тег (cp312 и т.п.) или собирать из исходников |
| Разные .so в одном процессе | Разные версии одной библиотеки, конфликт символов | Версионирование символов, изоляция через отдельный процесс |
Стабильный C-ABI на границах — практическое правило. Экспортируйте наружу extern "C" функции с простыми типами (указатели, целые), а C++-объекты держите внутри одной единицы сборки. Тогда несовпадение внутренних деталей не пересекает границу.
Границы, которые эта статья не разбирает
Rust-ABI и его нестабильность между версиями компилятора — отдельная тема, здесь только C/C++ и их расширения. Статическая линковка как способ уйти от проблемы обсуждается вскользь: она переносит риск с рантайма на размер и обновления безопасности, это тема отдельного разбора.
Что делать дальше
Первый шаг — зафиксировать воспроизводимость сборки: тот же базовый образ для сборки и рантайма, явно закреплённая версия toolchain. Дальше добавьте в CI проверку символов (objdump -T на требуемые версии glibc) и smoke-запуск под AddressSanitizer на нагрузочном профиле. В проде включите сбор core dump и алерт на частоту SIGSEGV в разбивке по версии образа — тогда следующий ABI-конфликт вы увидите в первые часы, а не через две недели пачкой падений.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: GCC: Dual ABI (libstdc++ manual), Itanium C++ ABI specification
Частые вопросы
Почему всё собралось без ошибок, а падает в рантайме?
Как понять, что дело именно в ABI, а не в баге в моём коде?
У меня другая версия GCC, чем в примере. Совет ещё актуален?
Помогает ли статическая линковка полностью убрать проблему?
Санитайзер ничего не нашёл, а падения продолжаются — что дальше?
Как избежать ABI-конфликтов с готовыми пакетами из дистрибутива?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.