Runnables
« | 22 Jan 2022 | »Hinter der Magie von std::bind
stecken eine Vielzahl von Hilfsklassen und C++
Template-Tricks, die jeden beliebigen Funktions- oder Methodenaufruf kapseln
und frei mit Parametern kombinieren können.
In der C Welt gibt es
(meines Wissen) nichts Vergleichbares in der stdlib
.
Für die Ausführung von beliebigen Funktionsaufrufen stellt das GATE Framework
daher Runnables
bereit.
std::bind
verbindet eine Funktion oder eine Methode mit Parametern und gibt
einen Functor
zurück, der bei dessen Aufruf, die “gebundene” Routine mit den “gebundenen”
Parameter aufruft.
1#include <functional> 2 3int foo(int a, int b) 4{ 5 return a + b; 6} 7int bar(int a, int b, int c) 8{ 9 return a + b + c; 10} 11 12int main() 13{ 14 auto f = std::bind(foo, 33, 44); 15 int result1 = f(); // invokes: foo(33, 44); 16 17 auto g = std::bind(bar, 33, 44, std::placeholders::_1); 18 int result2 = g(55); // invokes: bar(33, 44, 55) 19 return 0; 20}
Man kann also einen Funktionsaufruf + Parameter konservieren und in einem anderen Kontext (also später oder in einem anderen Thread) ausführen lassen.
Die gesamte Mächtigkeit von std::bind
lässt sich aus meiner Sicht nicht auf
C-Routinen übertragen. Vor allem Template-Konstrukte wie std::placeholders
gestalten sich zu schwierig, aber wenn man einen ganzen Aufruf “binden” will,
gibt es einen Weg:
- Man erzeugt einen Speicherblock, der Kopien der originalen Parameter zwischenspeichert
- Und man erzeugt eine Dispatcher-Funktion, die die Zielfunktion mit den Parametern aus dem Zwischenspeicher aufruft.
Da wir in C keine Parameter-Type-Deduction haben, müssen Makros für jede unterstützte Anzahl von Parametern geschrieben werden.
1typedef struct storage_class storage_t; 2typedef int(* generic_func_t)(int); 3typedef int(* dispatcher_t)(storage_t const* storage); 4struct storage_class 5{ 6 generic_func_t funcptr; 7 dispatcher_t disp; 8 void* params[1]; 9}; 10 11#define DECLARE_DISPATCHER_3(name, type1, type2, type3) \ 12typedef int (* name ## _callback)(type1 a1, type2 a2, type3 a3); \ 13static int name ## _dispatcher (storage_t const* storage) \ 14{ \ 15 int ret; \ 16 name ## _callback cb = NULL; \ 17 type1 * ptr_arg1 = (type1 *)storage->params[0]; \ 18 type2 * ptr_arg2 = (type2 *)storage->params[1]; \ 19 type3 * ptr_arg3 = (type3 *)storage->params[2]; \ 20 memcpy(&cb, &storage->funcptr, sizeof(cb)); \ 21 ret = cb(*ptr_arg1, *ptr_arg2, *ptr_arg3); \ 22 return ret; \ 23}
Um jetzt so ein storage_t
zu allokieren und zu befüllen, benutzt man eine
variadic function
, in der die gewünschten Parameter als Paar von Pointer
und Größe übergeben werden.
z.B.: storage_t* bind_storage(generic_func_t target, dispatcher_t disp, ...);
Eine int foo(int a, double b, void* c)
, müsste also mit:
bind_storage(target, disp, &a, sizeof(a), &b, sizeof(b), &c, sizeof(c), NULL);
erzeugt werden.
Über das Pointer+Größen Paar kann die Funktion die Anzahl der Parameter
ermitteln und dann ein überallokiertes storage_t
erzeugen, in dem ganz
hinten Kopien der originalen Daten (a, b, c
) liegen und davor im params
Feld Pointer zu diesen Kopien eingesetzt werden.
Der oben definierte Dispatcher klappert dann bei seinem Aufruf das Array ab und castet die Pointer in die korrekten Zieltypen, um dann die Zielfunktion mit genau den Parametern aufzurufen, die sie braucht.
Problem: Lebenszeit
Das C-Makro und unsere hier vereinfachte bind_storage
Funktion kopieren die
Datentypen per memcpy()
und das funktioniert ausschließlich bei primitiven
Datentypen problemlos. Sobald der erste Pointer zum Einsatz kommt, stellt
sich die Frage wie lange er gültig ist.
In C++ kann man solche Dispatcher auch per Templates generieren lassen, wo sie Copy-Constructor zum Kopieren und Destructor-Aufrufe für Cleanups automatisch einbauen können, doch in C fehlt dieses Feature.
Im GATE-Framework wird ein solcher storage_t
Typ in einem gate_runnable_t
Objekt eingebettet, der selbst mit einer Referenzzählung arbeitet.
Der Objekt-Inhalt könnte natürlich auch durch individuelle Dispatcher je
nach Anwendungsfall separat ausimplementiert werden, doch genau das erweist
sich als mühsam und fehleranfällig.
Von daher werden komplexe Typen wohl ausschließlich nur in C++ zum Einsatz
kommen und in C verbleibt lediglich ein bestimmtes Nischenumfeld, in dem
Runnables
gebraucht werden.
Fazit
Während also die Nützlichkeit der Runnables stark an den Sprachstandard gebunden sind, hat aber die Definition einer “Trägerschnittstelle” für mich einen wichtigen Mehrwert:
Komponenten von unterschiedlichen Compilern können “Runnables” nun
standardisiert austauschen.
Während ein std::bind
Ergebnis eines MSVC
unbenutzbar ist in einer
MinGW Anwendung ist, kann ein gate_runnable_t
problemlos zwischen diesen
beiden Welten verschoben werden.
Ein Microservice in einer Sprache A kann somit Aufrufe in seinen eigenen
Code durch eine Sprache B ermöglichen.
Denn genau das ist die geheime Superkraft von C:
Es ermöglicht Binärkompatibilität auf allen Ebenen.
Es mag daher mühsam sein, Codes auf C abzubilden, doch am Ende bietet es immer auch Vorteile, wenn man an verteilte Systeme denkt.