C++ в Python и Go: типичные ошибки на границе языков
GIL, владение памятью и разные ABI ломают связку C++ с Python и Go тише, чем падение сегфолтом. Разбираем, где именно теряются данные и производительность и что с этим делать.

Связать C++ с Python или Go выглядит просто ровно до первого сегфолта в проде. Библиотека собралась, тесты на одном потоке проходят, а под нагрузкой процесс тихо падает или течёт память по мегабайту на запрос. Причина почти всегда на границе языков: кто владеет памятью, кто держит GIL, что происходит при исключении и как совпадают ABI. Разберём типичные ошибки в обеих связках и то, как их поймать до релиза.
Python + C++: три места, где всё ломается
Для Python 3.11+ основные инструменты связки — pybind11 (заголовочная библиотека, версии 2.x) и ctypes/cffi для C-совместимого ABI. Чистый CPython C API тоже жив, но его редко пишут руками. Дальше — про pybind11, потому что большинство ошибок концентрируются именно в ручном управлении GIL и владением объектами.
Ошибка 1. Долгий C++ держит GIL и убивает параллелизм
Пока ваш C++ код исполняется, он по умолчанию держит Global Interpreter Lock1. Если функция считает матрицу секунду, все остальные потоки Python на эту секунду встают. Люди приходят в C++ ровно за производительностью — и получают однопоточное приложение.
Освобождать GIL нужно явно на время тяжёлых вычислений, которые не трогают объекты Python:
// pybind11 2.x, компилятор с C++17
#include <pybind11/pybind11.h>
#include <vector>
namespace py = pybind11;
double heavy_sum(const std::vector<double>& data) {
double total = 0.0;
// Отпускаем GIL: внутри блока нельзя трогать Python-объекты
py::gil_scoped_release release;
for (double value : data) {
total += value * value;
}
return total;
}
PYBIND11_MODULE(fastmath, module) {
module.def("heavy_sum", &heavy_sum);
}
Проверка: запустите функцию из четырёх потоков Python и посмотрите на загрузку ядер. Без gil_scoped_release вы увидите одно занятое ядро, с ним — четыре. Если внутри блока вы случайно обратитесь к py::object, получите падение — данные для расчёта нужно скопировать в C++ структуры до освобождения.
Ошибка 2. Кто владеет указателем — C++ или Python
Самая дорогая ошибка. Вы возвращаете в Python сырой указатель на объект, которым владеет C++, Python считает его своим и удаляет — двойное освобождение и падение. Или наоборот: C++ отдаёт объект, ссылку на который где-то кеширует, Python его освобождает, C++ обращается к освобождённой памяти.
pybind11 управляет этим через политики возврата. По умолчанию для функций, возвращающих указатель или ссылку, применяется return_value_policy::automatic, но для не-очевидных случаев её нужно задавать руками:
// Объект живёт в C++, Python получает нессылочную копию — безопасно
module.def("get_config", &get_config,
py::return_value_policy::copy);
// Python не должен удалять объект — им владеет C++ singleton
module.def("get_logger", &get_logger,
py::return_value_policy::reference);
reference означает: Python никогда не вызовет delete. Если C++ удалит объект раньше, а Python будет им пользоваться — сегфолт. Это ваша ответственность, компилятор не поможет.
Правило, которое экономит недели отладки: на границе языков либо копируйте данные, либо явно фиксируйте, чья сторона владеет памятью и когда её освобождает. Неявное владение через сырой указатель — источник большинства падений в проде.
Ошибка 3. C++ исключение долетает до Python как креш
Исключение C++, пересёкшее границу ABI, — неопределённое поведение. pybind11 ловит стандартные исключения и превращает std::runtime_error в RuntimeError, std::out_of_range в IndexError и так далее. Но ваши пользовательские типы исключений он не знает — их нужно регистрировать через py::register_exception, иначе Python увидит общий RuntimeError без вашего сообщения или, при noexcept-нарушении, std::terminate.
Go + C++: cgo и его цена
Go общается с C++ только через C-обёртку по механизму cgo. Прямого C++ ABI у cgo нет: вы пишете extern "C" функции-мосты, а внутри них уже вызываете C++. Здесь ошибки другого рода — они связаны с двумя разными рантаймами и двумя моделями памяти.
Ошибка 1. Указатель Go передан в C++ и там сохранён
Go — язык со сборщиком мусора и подвижными стеками горутин. Правила cgo прямо запрещают C-коду хранить указатель на память Go после возврата из вызова. Если вы передали *C.char из Go в C++ и C++ положил его в свою структуру «на потом» — указатель может стать невалидным, потому что GC переместил или освободил объект.
- Данные, которые C++ должен пережить возврат, копируйте на стороне C++ (
strcpyв свой буфер) или выделяйте память через C и владейте ей из C++. - Обратно в Go память из C++ отдавайте так, чтобы Go знал, кто её освобождает: либо копируйте в срез Go, либо возвращайте C-указатель и освобождайте отдельным экспортированным вызовом
Free.... - Проверка нарушений: запускайте тесты с
GODEBUG=cgocheck=2— рантайм ловит передачу Go-указателей, содержащих другие Go-указатели, и падает с диагностикой вместо тихого повреждения памяти.
Ошибка 2. Недооценка стоимости самого вызова cgo
Переход Go→C не бесплатен: рантайм переключает горутину на системный стек, вокруг вызова есть накладные расходы. Точную цифру называть не буду — она зависит от версии Go и платформы, мерьте go test -bench на своём железе. Практический вывод один: миллион мелких вызовов cgo в горячем цикле почти всегда медленнее, чем один вызов, обрабатывающий миллион элементов пакетом. Проектируйте API моста «крупными кусками».
Ошибка 3. Мост из .cpp не собирается или падает при линковке
cgo по расширению файла определяет компилятор. Файл .c собирается C-компилятором, и C++ имена в нём не слинкуются из-за name mangling. Мост в C++ выносите в .cpp, а объявления для cgo — в заголовок с extern "C":
// bridge.h
#ifdef __cplusplus
extern "C" {
#endif
int process_batch(const double* data, int len, double* out);
#ifdef __cplusplus
}
#endif
// bridge.cpp — компилируется как C++, но экспортирует C-символы
#include "bridge.h"
#include "my_engine.hpp" // здесь живёт настоящий C++
int process_batch(const double* data, int len, double* out) {
MyEngine engine; // C++ объект внутри моста
return engine.run(data, len, out);
}
В Go-файле флаги линковки указываются директивами cgo. Точный синтаксис #cgo CXXFLAGS и #cgo LDFLAGS сверьте в документации команды cgo для вашей версии Go — он менялся мало, но зависит от того, как подключается стандартная библиотека C++.
Что выбрать под вашу задачу
| Критерий | Python + pybind11 | Go + cgo |
|---|---|---|
| Главный риск | GIL и владение объектами | GC двигает память Go |
| Стоимость вызова | Низкая, но GIL сериализует | Заметная на каждый переход |
| Как ускорять | Освобождать GIL, копировать данные | Батчить вызовы, минимум переходов |
| C++ исключения | Ловятся, кастомные — регистрировать | Не должны покидать extern "C" |
| Где ловить ошибки памяти | ASan + тесты на потоках | GODEBUG=cgocheck=2, ASan |
Если у вас вычислительное ядро для ML-пайплайна на Python — pybind11. Если сервис на Go, которому нужна одна тяжёлая C++ библиотека, — cgo с крупноблочным мостом. Смешивать оба в одном процессе стоит только при явной необходимости: два FFI-слоя удваивают классы ошибок.
Как ловить это до прода
Граничные ошибки почти не видны на happy path. Что включить в CI:
- Соберите нативную часть с AddressSanitizer (
-fsanitize=address) и прогоните тесты — он показывает use-after-free и двойное освобождение с трассировкой. - Для Python — тест, вызывающий C++ функцию из нескольких потоков, чтобы проверить и потокобезопасность, и что GIL освобождается там, где вы думаете.
- Для Go — весь набор тестов под
GODEBUG=cgocheck=2в CI, а не только локально. - Проверка утечек: гоняйте функцию в цикле на десятки тысяч итераций и смотрите на RSS процесса. Ровная линия — владение памятью корректно, растущая — где-то забыли освободить.
Следующий шаг после того, как связка стабильна: замерьте реальную стоимость границы на своей нагрузке (go test -bench или профилировщик Python) и решайте, где укрупнять API. Раздельная сборка нативной части как отдельного артефакта с пиннингом версии pybind11 или заголовков моста избавит от «у меня собиралось» в другой среде.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: pybind11 documentation, cgo — Go command documentation
Частые вопросы
У меня pybind11 старее 2.x или CPython C API вместо него — примеры подойдут?
Собралось локально, а в CI падает на линковке C++ моста в Go — почему?
Процесс не падает, но течёт память под нагрузкой. С чего начать?
Можно ли вообще не освобождать GIL, если функция быстрая?
Почему нельзя просто передать указатель Go в C++ и сохранить его?
Как заставить кастомное C++ исключение долетать до Python осмысленно?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.