36.4 Python és más nyelvek FFI-kapcsolata

Az FFI (foreign function interface) az a mechanizmus, amelyen keresztül egy nyelvi runtime egy másik, natív kódot hív, illetve abból adatot fogad. DevOps-környezetben ez nem egzotikus eset: platform-eszközök íródnak Pythonban egy C++ mag köré, megfigyelő ágensek töltenek be natív bővítményt, CLI-k hívnak meg egy C ABI-n exportált gyorsított könyvtárat, és egyre gyakrabban épül Python-csomag egy C++ motorra. Az ilyen rendszerek hibái ritkán nyelvi szintaktikai hibák: jellemzően egy pointer élettartama, egy buffer tulajdonjoga vagy egy callback szálkontextusa nem illeszkedik a két oldal feltevéseihez.

Ez az alfejezet ezért nem a binding-könyvtárak használatának mikéntjét tanítja, hanem azt a szerződést, amelyet minden ilyen határon rögzíteni kell. A C ABI-t tekintjük a találkozási pontnak: a korábbi Stabil shared library határ és verziózás részben rögzített exportált felület itt kapja meg a másik nyelv felőli értelmezését. A buffer- és nézet-szerződéshez a span, byte és bounds-szerződések, a szövegátadáshoz a Szövegkódolás, locale és UTF-8 C-ben rész definícióit használjuk fel; azokat itt nem ismételjük meg, csak a határra vonatkozó következményeiket.

36.4.1 Az FFI-határ mint szerződés

Definíció. Az FFI egy adott nyelvi runtime és egy másik, jellemzően C ABI-t biztosító natív kód közötti hívási és adatátadási mechanizmus, amelyet a hívó oldal runtime-specifikus binding-rétege valósít meg. A binding-réteg (ctypes, cffi, pybind11, JNI, cgo) nem szünteti meg a határt, csak lefordítja rá a két oldal típusait.

Előfeltételek. A határon átvihető felületre egyszerre öt feltételnek kell teljesülnie:

  1. Csak extern "C" linkage-dzsel exportált függvények és fix layoutú, C-kompatibilis típusok szerepelnek a határon.
  2. Egyetlen C++ exception sem lép ki a határfüggvényből; a hibát kóddá kell alakítani a natív oldalon.
  3. Minden átadott pointer vagy nem tulajdonló kölcsönzés (a hívás idejére érvényes), vagy dokumentált tulajdonátadás; a kettő nem keverhető.
  4. Minden C oldalon létrehozott erőforrásnak van páros *_destroy / *_release függvénye, és a fogadó oldal garantálja annak meghívását.
  5. A callback csak dokumentált szál- és interpreter-kontextusban hívható.

A 3. pont a leggyakrabban megsértett feltétel, ezért érdemes minden határfelülethez tulajdonlási táblát írni. Egy tipikus minta:

Erőforrás Tulajdonos a C oldalon Tulajdonos a fogadó oldalon Felszabadítás
bemeneti byte-buffer nincs (kölcsönzött) a hívó (Python bytes/bytearray) a fogadó GC-je
callback thunk / függvénypointer nincs a fogadó (a CFUNCTYPE objektum) a fogadó GC-je, de csak deregisztráció után
opaqu handle (struct ds_ctx*) a könyvtár allokálja a fogadó wrapper tartja ds_ctx_destroy
hibaüzenet-sztring a könyvtár statikus puffere nincs nem szabad felszabadítani

36.4.2 A C ABI-wrapper: ctypes és cffi alapötlet

A Python felől két alapvető megközelítés van. A ctypes futásidőben tölti be a shared libraryt, és a hívás helyén deklarált argtypes/restype alapján konvertál: nincs fordítási lépés, de a típushelyesség csak a hívás pillanatában derül ki. A cffi két módot kínál: ABI-módban ugyanúgy futásidőben dolgozik, API-módban viszont a deklarációból C-bővítményt generál, így a fejlécet a fordító ellenőrzi — ez a fejléc és a binding szinkronját erősíti, de a mögöttes hívás továbbra is a C ABI-n megy át.

Mindkét esetben ugyanaz a tervezési következtetés: a C++ oldalon érdemes egy vékony, kizárólag C típusokat használó wrappert exportálni, nem a C++ osztályokat közvetlenül kitenni. Ennek nem csak a névtorzítás az oka (azt a Névtorzítás, layout és C++ runtime ABI rész tárgyalta), hanem az, hogy a wrapper az egyetlen hely, ahol a hibakonverzió, a noexcept-garancia és a paraméter-ellenőrzés egy ponton tartható.

36.4.3 Buffer pointer és hossz: a kölcsönzés szabálya

A natív felület soha ne const char*-ban hordozza a hosszt, ha a bemenet bináris vagy UTF-8 lehet. A helyes minta (const uint8_t* data, size_t len), ahol a szerződés:

A C++ oldalon ezt természetesen fejezi ki a std::span<const std::byte> (illetve std::span<const std::uint8_t>) kölcsönzött nézet, feltéve, hogy a span nem kerül ki a hívás élettartamából. A Python oldalon a buffer objektumnak a teljes hívás alatt élnie kell. CPython esetén a bytes argumentumként átadva a konverzió idejére referenciát tart, így a hívás alatt nem szabadul fel; ez runtime-specifikus garancia, ezért dokumentálni kell, és nem szabad rá építeni más implementációnál. Ha a natív kód a hívás után is hivatkozna a bufferre, az a klasszikus dangling eset: a javítás vagy mély másolás a C oldalon, vagy egy explicit, a fogadó oldalon életben tartott holder objektum.

36.4.4 UTF-8, beágyazott NUL és méretkonverzió

Python str-t soha nem adunk át közvetlenül: a kódolás ilyenkor a konverziótól függ, és nem az, amit a natív oldal feltételez. A helyes út: a fogadó oldalon s.encode("utf-8"), és a natív oldal hosszal dolgozik. Ezzel együtt jön a beágyazott NUL problémája: ha a C oldal strlen-t használ, a b"a\x00b" bemenet két byte után megáll, holott hét byte érkezett. A hosszátadó API ezt kiküszöböli, de a dokumentációnak ki kell mondania, hogy a NUL közönséges byte, nem lezáró.

A méretkonverzió külön ellenőrzést igényel. Python int-ről size_t-re váltva a feltétel 0≤v≤SIZE_MAX0 \le v \le \text{SIZE\_MAX}, ahol a SIZE_MAX az adott adatmodelltől függ (LP64 alatt 64 bites). ctypes-ban a c_size_t a cél architektúra szerint konvertál, de a tartományellenőrzést nem pótolja: negatív vagy túl nagy értéknél a hívás előtt explicit ellenőrzés kell. A határon használt típusok megfeleltetése:

C típus ctypes típus Megjegyzés
const uint8_t* + size_t POINTER(c_uint8) + c_size_t nem NUL-terminált, hossz kötelező
size_t c_size_t előjel nélküli, szélessége adatmodell-függő
uint64_t* (out) POINTER(c_uint64) a hívó birtokolja, a hívás előtt allokált
int (hibakód) c_int 0 = siker, negatív = hiba
const char* (statikus) c_char_p csak olvasás, nem szabad felszabadítani

A kódszám és a byte-szám kapcsolatára egy hasznos vázlat: UTF-8-ban a folytató byte-ok 10xxxxxx alakúak, a többi byte kódpont-kezdő, ezért a kódpontok száma nem haladhatja meg a byte-számot, azaz 0≤ncp≤nbyte0 \le n_{\text{cp}} \le n_{\text{byte}}. Ez vázlat, nem teljes bizonyítás: a felülbecslésből adódó különbség éppen a többszörös hosszúságú sorozatok száma. A gyakorlati következtetés az, hogy a natív API byte-ban számoljon, a kódpontszámot pedig csak akkor adja vissza, ha a szerződés ezt kéri.

36.4.5 Foreign callback, szálak és interpreter-szabályok

A callback a legkényesebb pont, mert két élettartamot és két szálkontextust kapcsol össze. A natív oldal csak egy void* user és egy függvénypointert lát; arról semmit nem tud, hogy a mögötte lévő Python callable mikor szabadul fel, és melyik szálon hívható.

Élettartam. A fogadó oldalnak a regisztráció teljes ideje alatt életben kell tartania a CFUNCTYPE objektumot, mert az hordozza a C thunkot. Ha a Python objektum GC-elődik, a natív oldalon marad egy dangling függvénypointer. A javítás: a wrappert egy osztály fogja össze, amely a CFUNCTYPE példányt attribútumként tartja, és a release() a natív deregisztráció után engedi csak el.

Szál és interpreter. CPython esetén a Python C API-t csak a GIL birtokában szabad hívni, és a natív szálon futó callbackhez előbb csatlakozni kell az interpreterhez (például PyGILState_Ensure/Release párossal). Ha a hívó Python szál a GIL birtokában blokkol egy hosszú C híváson, miközben a C könyvtár egy másik szálon próbál callbacket indítani, a callback nem tud GIL-hez jutni. Vázlat a holtpont feltételére: legyen TpT_p a Python szál, amely a GIL-t tartva vár a C hívás visszatérésére, és TcT_c a C könyvtár szálja, amely a callback futtatásához GIL-t kér. Ha TpT_p a várakozás előtt nem engedte el a GIL-t, TcT_c nem tud előrehaladni, TpT_p pedig nem tud visszatérni: körkörös várakozás. A javítás vagy a GIL elengedése a blokkoló C hívás körül (Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS), vagy a callback marshalolása arra a szálra, amelyik az interpreterhez tartozik.

Korlát. A GIL és a PyGILState API CPython-specifikus, és a GIL nélküli (free-threaded) mód viselkedése verziófüggő; ezt az állítást a használt Python-kiadás dokumentációjával kell ellenőrizni, és itt nem tekintjük igazoltnak.

36.4.6 Hibakód-, exception- és release-szerződés

A határfüggvényből C++ exception nem léphet ki: ha mégis, a noexcept következtében a folyamat leáll, ami a leggyakoribb „néha elszáll a szkriptem” hibaforrás. A minta: a wrapper belül try/catch (...)-ben mindent elkap, és stabil hibakódot ad vissza; a hibaüzenetet vagy a hívó által adott bufferbe írja, vagy statikus, csak olvasható sztringként teszi elérhetővé. Utóbbi élettartama a program teljes futása, ezért nem szabad felszabadítani, és nem szabad hozzáírni.

A fogadó oldal a hibakódot azonnal alakítsa át saját kivételévé (Pythonban raise), és ne engedje, hogy a natív kód hívjon vissza a fogadó runtime-ba hibakezelés céljából. Ez a szabály megfordíthatatlan irányt ad: a hibakonverzió mindig a fogadó oldalon történik, a natív oldal csak adatot ad át.

Az erőforrás-felszabadítás ugyanígy szerződés. Pythonban a __del__ nem megbízható időzítésű; helyette contextlib.closing, weakref.finalize vagy egy PyCapsule destruktor a javasolt minta. A natív oldalnak a *_destroy függvényt idempotenssé vagy legalább nullázás után hívhatóvá kell tennie, hogy a dupla felszabadítás ne legyen azonnali UB.

36.4.7 C++ binding return policy: csak ellenőrzött dokumentációból

A pybind11-jellegű könyvtárak a visszatérési érték tulajdonjogát külön policy-vel fejezik ki: másolás, mozgatás, tulajdon átvétele, hivatkozás, illetve a szülő objektumhoz kötött belső hivatkozás. A döntési tengely mindig ugyanaz: ki birtokolja a memóriát, és meddig él a hivatkozott objektum. A „hivatkozás a szülőre” típusú policy csak akkor helyes, ha a binding réteg garantáltan életben tartja a szülőt; enélkül a Python objektum egy már felszabadult C++ objektumra mutat, és a hiba a későbbi olvasásnál jelentkezik.

Korlát. A konkrét policy-nevek és pontos szemantikájuk verziófüggők. Ebben az egységben nem áll rendelkezésre ellenőrzött forrásjegyzet a pybind11 adott kiadásához, ezért a fenti leírás a döntési szempontokat rögzíti, nem a könyvtár dokumentált viselkedését; használat előtt a választott verzió hivatalos dokumentációjában kell ellenőrizni. Ugyanez vonatkozik a cffi és a ctypes konverziós részleteire: ezek CPython-verzióhoz kötött viselkedések.

36.4.8 Kidolgozott példa: dsprobe C ABI és ctypes kötés

A példa egy kölcsönzött byte-tartományon dolgozó, callbacket is hívó natív könyvtár. A felület szándékosan csak C típusokat használ, és minden függvény hibakódot ad vissza.

/* dsprobe.h */
#ifndef DSPROBE_H
#define DSPROBE_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif

/* user -> a hívó kontextusa, a könyvtár nem értelmezi.
   A data/len csak a hívás idejére kölcsönzött. */
typedef int (*dsprobe_visit_fn)(void* user, const uint8_t* data, size_t len);

int dsprobe_checksum(const uint8_t* data, size_t len, uint64_t* out);
int dsprobe_visit_tokens(const uint8_t* data, size_t len,
                         dsprobe_visit_fn fn, void* user);
/* A visszaadott pointer statikus, csak olvasható, nem szabad felszabadítani. */
const char* dsprobe_error_string(int code);

#ifdef __cplusplus
}
#endif
#endif
// dsprobe.cpp — C++20, GCC/Clang, x86-64 System V ABI labor
#include "dsprobe.h"

namespace {
constexpr std::uint64_t kFnvOffset = 1469598103934665603ull;
constexpr std::uint64_t kFnvPrime  = 1099511628211ull;
}

extern "C" int dsprobe_checksum(const std::uint8_t* data, std::size_t len,
                                std::uint64_t* out) noexcept {
    if (out == nullptr) return -1;
    if (data == nullptr && len != 0) return -1;
    std::uint64_t h = kFnvOffset;
    for (std::size_t i = 0; i < len; ++i) { h ^= data[i]; h *= kFnvPrime; }
    *out = h;  // teljes kimenet csak siker esetén íródik
    return 0;
}

extern "C" int dsprobe_visit_tokens(const std::uint8_t* data, std::size_t len,
                                    dsprobe_visit_fn fn, void* user) noexcept {
    if (fn == nullptr) return -1;
    if (data == nullptr && len != 0) return -1;
    std::size_t start = 0;
    while (start < len) {
        while (start < len && data[start] == ' ') ++start;
        std::size_t end = start;
        while (end < len && data[end] != ' ') ++end;
        if (end > start) {
            const int rc = fn(user, data + start, end - start);
            if (rc != 0) return rc;  // a callback hibakódja változatlanul továbbmegy
        }
        start = end;
    }
    return 0;
}

extern "C" const char* dsprobe_error_string(int code) noexcept {
    switch (code) {
        case 0:  return "ok";
        case -1: return "invalid argument";
        default: return "unknown error";
    }
}
g++ -std=c++20 -O2 -fPIC -shared -fvisibility=hidden \
    -o libdsprobe.so dsprobe.cpp

A Python oldal a hosszt explicit módon adja át, a callbacket pedig pointer+hossz formában kapja meg, hogy a beágyazott NUL ne csonkítsa a szöveget:

import ctypes
from ctypes import (c_int, c_uint8, c_uint64, c_size_t, c_void_p,
                    POINTER, CFUNCTYPE)

lib = ctypes.CDLL("./libdsprobe.so")
lib.dsprobe_checksum.argtypes = [POINTER(c_uint8), c_size_t, POINTER(c_uint64)]
lib.dsprobe_checksum.restype = c_int
lib.dsprobe_visit_tokens.argtypes = [POINTER(c_uint8), c_size_t, c_void_p, c_void_p]
lib.dsprobe_visit_tokens.restype = c_int

payload = "héllo\x00világ".encode("utf-8")          # 7 byte NUL-lal együtt
buf = (c_uint8 * len(payload)).from_buffer_copy(payload)

out = c_uint64(0)
rc = lib.dsprobe_checksum(buf, len(payload), ctypes.byref(out))
assert rc == 0, lib.dsprobe_error_string(rc)

VISIT = CFUNCTYPE(c_int, c_void_p, POINTER(c_uint8), c_size_t)
seen = []

@VISIT
def on_token(user, ptr, n):                # n byte, nem strlen
    seen.append(ctypes.string_at(ptr, n))
    return 0

rc = lib.dsprobe_visit_tokens(buf, len(payload), on_token, None)
assert rc == 0
print(seen)                                # a NUL a token része marad

Három pont, amit érdemes észrevenni. A bytes objektum a hívás idejére életben van, de a natív oldal a buf-ra nem tart referenciát; a from_buffer_copy által létrehozott tömb a Python oldal tulajdona. A VISIT és az on_token a hívás teljes ideje alatt hatókörben van, tehát a thunk nem szabadul fel — ezt a wrapper osztálynak kellene garantálnia, ha a regisztráció hosszabb életű lenne. Végül a callback POINTER(c_uint8) és c_size_t párost kap, nem c_char_p-t: utóbbi a NUL-nál elvágna, és pont a beágyazott NUL esetét rontaná el.

36.4.9 Gyakori hibák és javításuk

A leggyakoribb hiba a str átadása bytes helyett: a kódolás ilyenkor nem az, amit a natív oldal vár. A második a strlen-alapú natív API hívása hosszátadás nélkül, ami beágyazott NUL-nál csonkít. A harmadik a callback objektum élettartamának elszalasztása: a CFUNCTYPE példány lokálisan keletkezik, majd a regisztráció után felszabadul, és a natív oldal később dangling pointert hív. A negyedik a GIL elengedésének hiánya hosszú C hívás körül, ami a fenti holtpont-vázlatot valósítja meg. Az ötödik a size_t méretkonverzió ellenőrzés nélkül hagyása, ami negatív vagy túl nagy értéknél csendes átforduláshoz vezethet.

36.4.10 Összefoglaló

Az FFI-határ szerződés, nem varázslat. A biztonságos minta négy elemből áll: extern "C" wrapper csak C-kompatibilis típusokkal; pointer+hossz alapú, nem tulajdonló bufferátadás dokumentált élettartammal; hibakód- és release-szerződés, amelyben a konverzió mindig a fogadó oldalon történik; valamint a callback élettartamának és szálkontextusának explicit kezelése. A verziófüggő részleteket (GIL-viselkedés, binding-policy-k, konverziós szabályok) minden esetben a használt runtime és könyvtár dokumentációjából kell ellenőrizni.

36.4.11 Feladatok

1. feladat (tervezés). Adj egy int dsprobe_count_byte(const uint8_t* data, size_t len, uint8_t what, size_t* out) függvény C ABI-szerződését: mikor megengedett a NULL, mi történik hibánál, mit ír ki a függvény, és hogyan biztosítod, hogy a beágyazott NUL ne okozzon csonkolást? Írd le a Python oldali hívás előfeltételeit is.

2. feladat (kódolás). Egészítsd ki a fenti ctypes-kötést úgy, hogy a dsprobe_visit_tokens hibakódját a fogadó oldal Python kivételévé alakítsa, és a hibaüzenetet a dsprobe_error_string adja. A wrapper osztály tartsa életben a callbacket, és legyen close() metódusa.

3. feladat (diagnosztika). Egy szolgáltatásban a Python szál kihívja a natív run()-t, amely belül egy munkaszálon meghívja a regisztrált callbacket. A folyamat holtpontba kerül, majd egy külön futásban szegmentációs hibával elszáll. Add meg a két hiba lehetséges okát, a diagnosztikai lépéseket és a javítást.

36.4.12 Megoldások

1. megoldás. A szerződés: data == NULL csak len == 0 esetén megengedett; out == NULL mindig hiba; a függvény a [data, data+len) tartomány minden byte-ját vizsgálja, strlen-t nem használ, ezért a 0x00 közönséges értékként számolódik. Visszatérés: 0 siker, -1 érvénytelen argumentum; *out-ba csak siker esetén ír. Python oldalon a hívás előtt ellenőrizni kell, hogy 0 <= len <= SIZE_MAX, a buffer pedig a hívás teljes ideje alatt él. Ellenőrzési feltétel: a b"a\x00b" bemenetre az eredmény 3 hosszú bemenetet tükröz, nem 1-et, és a data=NULL, len=5 hívás -1-et ad.

2. megoldás. A wrapper osztály __init__-ben mentse el a CFUNCTYPE példányt attribútumba, és a natív regisztrációt csak utána végezze. A hívást try/except fogja körbe; nem nulla visszatérésnél RuntimeError-t kell dobni a dsprobe_error_string(rc) szövegével. A close() először a natív deregisztrációt hívja (ha van ilyen), majd törli a callback-referenciát. Ellenőrzési feltétel: ha a callback 1-et ad vissza, a Python oldalon kivétel keletkezik, és a natív oldalon nem szivárog erőforrás; ha a close() után újra hívunk, az nem szegmentációs hiba, hanem dokumentált hiba.

3. megoldás. A holtpont valószínű oka: a Python szál a GIL birtokában vár a run() visszatérésére, miközben a munkaszál a callbackhez GIL-t kér — ez a fenti vázlat szerinti körkörös várakozás. A javítás a GIL elengedése a blokkoló natív hívás körül, vagy a callback marshalolása. A szegmentációs hiba valószínű oka: a callbacket hordozó Python objektum felszabadult, miközben a natív oldal még tárolja a függvénypointert. Diagnosztika: a callback regisztráció és a hívás időpontjának naplózása, a wrapper élettartamának ellenőrzése, valamint a natív oldal azon pontjainak átvizsgálása, ahol pointert tárol. Ellenőrzési feltétel: a javított változatban a callback objektum életben marad a deregisztrációig, és a blokkoló hívás alatt a GIL elérhető más szálak számára.