C++ в Python и Go: как связать языки без течей памяти
Разбор трёх рабочих способов вызвать C++ из Python и Go: pybind11, ctypes и cgo. Где утечёт память, где упадёт GIL и почему копирование буфера съест весь выигрыш от нативного кода.

Вы переписали горячий цикл на C++, обернули его для Python через pybind11, замерили — и получили ускорение в два раза меньше ожидаемого. Причина почти всегда одна: на границе языков что-то копируется, где-то держится GIL, а деструктор C++-объекта вызывается не тогда, когда вы думаете. Интеграция двух языков — это не «скомпилировать и вызвать», а управление владением памятью и жизненным циклом объектов через границу ABI. Ниже — три способа связать C++ с Python и Go, цена каждого и места, где всё ломается первым.
Версии, на которые опирается разбор: pybind11 2.11+, CPython 3.11, Go 1.22, компилятор с поддержкой C++17. Если у вас другие версии — поведение GIL и ABI в целом совпадает, но флаги сборки могут отличаться.
Три подхода и за что вы платите
Способов вызвать нативный C++ из другого языка много, но на практике живут три. Выбор между ними — это выбор между скоростью разработки, накладными расходами на вызов и контролем над памятью.
| Подход | Язык | Оверхед на вызов | Управление памятью | Когда брать |
|---|---|---|---|---|
| pybind11 | Python | Низкий, но не нулевой | Автоматическое через smart holder | Богатый C++ API, классы, исключения |
| ctypes / cffi | Python | Средний на каждый вызов | Ручное, вы освобождаете сами | Плоский C-ABI, мало функций |
| cgo | Go | Высокий (переключение стека) | Ручное на C-стороне, GC не видит C-память | Немного тяжёлых вызовов |
Ключевое: ни Python, ни Go не умеют напрямую говорить с C++. Оба общаются через C-ABI. Поэтому C++-код почти всегда получает тонкую обёртку с extern "C" — без неё имена функций будут искажены манглингом, и линковщик их не найдёт.
Python через pybind11: удобно, но следите за владением
pybind11 — заголовочная библиотека, которая генерирует биндинги прямо из C++-кода. Она понимает классы, перегрузки, исключения и умеет конвертировать std::vector в список Python. Минимальный модуль выглядит так:
// module.cpp — собирается в matrix_ops.so
// pybind11 2.11, C++17
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <vector>
namespace py = pybind11;
// Считает скалярное произведение двух векторов
double dot_product(const std::vector<double>& a,
const std::vector<double>& b) {
double sum = 0.0;
for (size_t i = 0; i < a.size(); ++i)
sum += a[i] * b[i];
return sum;
}
PYBIND11_MODULE(matrix_ops, m) {
m.def("dot_product", &dot_product,
"Скалярное произведение двух векторов");
}
Собрать можно через setuptools с расширением Pybind11Extension или через CMake. Проверка успеха: import matrix_ops; matrix_ops.dot_product([1,2],[3,4]) возвращает 11.0.
Где это течёт и тормозит
Первая ловушка — копирование на границе. Строка #include <pybind11/stl.h> удобна, но каждый вызов с Python-списком копирует его в новый std::vector. Для миллиона чисел на каждый вызов это заметный расход. Если данные большие и приходят из NumPy, работайте не через STL-конвертеры, а через py::array_t с буфер-протоколом — тогда C++ читает память массива напрямую, без копии.
Вторая — GIL. По умолчанию весь ваш C++-код выполняется под удержанным GIL, то есть другие Python-потоки стоят. Если функция считает долго и не трогает Python-объекты, отпускайте GIL явно, иначе никакого параллелизма не будет:
m.def("heavy", [](const std::vector<double>& data) {
py::gil_scoped_release release; // отпускаем GIL на время счёта
return compute_heavy(data); // здесь нельзя трогать Python-объекты
});
Третья — владение возвращаемыми объектами. Если C++-функция возвращает сырой указатель, укажите политику владения через py::return_value_policy. По умолчанию для указателя pybind11 не берёт владение, и объект утечёт, либо, наоборот, будет освобождён дважды. Это самая частая причина краша через час работы, а не сразу.
Утечка на границе языков не выглядит как утечка. Она выглядит как «сервис жрёт память под нагрузкой, а valgrind на unit-тестах чист» — потому что тесты не гоняют миллион вызовов через FFI.
Python через ctypes: минимум зависимостей, максимум ручной работы
Если C++ API — это две-три функции, а тащить pybind11 не хочется, подойдёт ctypes из стандартной библиотеки. Но ctypes видит только C-ABI, поэтому C++-функции обязательно оборачиваются в extern "C":
// libcalc.cpp — g++ -shared -fPIC -o libcalc.so libcalc.cpp
extern "C" {
double dot_product(const double* a, const double* b, int n) {
double sum = 0.0;
for (int i = 0; i < n; ++i) sum += a[i] * b[i];
return sum;
}
}
На стороне Python вы обязаны описать сигнатуру вручную — ctypes не знает типов и по умолчанию считает всё int. Если это пропустить, на 64-битной системе указатель обрежется до 32 бит и вы получите segfault:
import ctypes
lib = ctypes.CDLL("./libcalc.so")
# без этих двух строк результат будет мусором
lib.dot_product.argtypes = [ctypes.POINTER(ctypes.c_double),
ctypes.POINTER(ctypes.c_double),
ctypes.c_int]
lib.dot_product.restype = ctypes.c_double
a = (ctypes.c_double * 3)(1.0, 2.0, 3.0)
b = (ctypes.c_double * 3)(4.0, 5.0, 6.0)
print(lib.dot_product(a, b, 3)) # 32.0
Цена ctypes — оверхед на каждый вызов выше, чем у pybind11, потому что маршалинг аргументов происходит в рантайме, а не в скомпилированном коде. Для тысяч мелких вызовов в секунду это заметно. И если C++ возвращает выделенную память (например, char* из new), Python её не освободит — нужна парная функция free_result, которую вы тоже вызываете руками.
Go через cgo: граница дороже, чем кажется
Go вызывает C++ тоже через C-ABI и механизм cgo. Обёртка на C, реализация на C++, а Go дёргает C-функцию. Важное отличие от Python: вызов cgo не бесплатный по времени — на нём происходит переключение с горутинного стека на системный, и это на порядок дороже обычного вызова функции Go. Поэтому cgo окупается на редких тяжёлых вызовах и разоряет на частых мелких.
// calc.h
#ifdef __cplusplus
extern "C" {
#endif
double dot_product(const double* a, const double* b, int n);
#ifdef __cplusplus
}
#endif
// main.go — Go 1.22
package main
/*
#cgo CXXFLAGS: -std=c++17
#cgo LDFLAGS: -lstdc++
#include "calc.h"
*/
import "C"
import (
"fmt"
"unsafe"
)
func DotProduct(a, b []float64) float64 {
// берём указатель на данные слайса; слайсы должны быть равной длины
pa := (*C.double)(unsafe.Pointer(&a[0]))
pb := (*C.double)(unsafe.Pointer(&b[0]))
return float64(C.dot_product(pa, pb, C.int(len(a))))
}
func main() {
fmt.Println(DotProduct([]float64{1, 2, 3}, []float64{4, 5, 6}))
}
Главная опасность cgo — сборщик мусора Go
Go-рантайм перемещает объекты в куче при работе GC. Правило языка: указатель на Go-память, переданный в C, не должен храниться на C-стороне после возврата вызова. Если C++ сохранит указатель на слайс Go в глобальной переменной, GC может переместить или собрать данные — и вы получите порчу памяти, которую невозможно воспроизвести стабильно. Обратное тоже верно: память, выделенную C++ через new или malloc, Go-сборщик не видит и не освободит. Освобождайте её явной C-функцией через defer.
Когда что выбирать
- pybind11 — когда у C++ богатый объектный API: классы, наследование, исключения. Даёт лучший баланс скорости вызова и удобства, но тянет зависимость и увеличивает время сборки.
- ctypes — когда функций мало, они плоские (числа, буферы), и вы не хотите этап компиляции биндингов. Платите ручным описанием сигнатур и оверхедом на вызов.
- cgo — когда основной язык Go и вызовов немного, но они тяжёлые. Не используйте cgo для горячего цикла с миллионами вызовов: переключение стека съест выигрыш.
Общий критерий для всех трёх: минимизируйте число переходов через границу. Один вызов, обрабатывающий пакет из 10 000 элементов, почти всегда быстрее, чем 10 000 вызовов по одному элементу, независимо от языка.
Что ломается первым
- Двойное освобождение. C++ владеет объектом, но и Python/Go считает, что владеет он. Заканчивается крашем при сборке мусора. Определите владельца заранее и держите его на одной стороне.
- Несовместимость рантаймов. Если C++-библиотека и Python-расширение собраны разными версиями компилятора или разными флагами исключений, вылезают загадочные падения. Собирайте всё одним тулчейном.
- Исключения через границу. C++-исключение, вылетевшее в C-ABI, — неопределённое поведение. pybind11 перехватывает исключения и превращает в Python-исключения; в ctypes и cgo вы обязаны ловить их сами внутри обёртки и возвращать код ошибки.
- Кодировка строк. C++ отдаёт
char*в неизвестной кодировке, Python декодирует как UTF-8 и падает на невалидных байтах. Договоритесь о кодировке на границе.
Что делать дальше
Первое — прогоните готовую интеграцию под нагрузкой, а не на unit-тестах. Запустите миллион вызовов в цикле и смотрите на график RSS процесса: рост памяти без выхода на плато — это утечка на границе. Второе — замерьте оверхед именно самого вызова: сделайте пустую нативную функцию, вызовите её в цикле и сравните с чистым кодом на основном языке. Так вы поймёте, окупается ли переход через FFI вообще. Инвалидация и версионирование ABI при обновлении C++-библиотеки — отдельная тема, здесь мы её не разворачиваем.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: pybind11 documentation, cgo — Go Command Documentation
Частые вопросы
А если у меня pybind11 старее 2.11 или CPython 3.9?
Почему мой C++-код в pybind11 не даёт параллелизма на нескольких потоках?
Утечёт ли память, если C++-функция вернёт указатель на new-объект?
Segfault появляется только под нагрузкой, а тесты проходят. С чего начать?
cgo действительно медленный или это миф?
Обязательно ли оборачивать C++ в extern "C"?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.