Runnables

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.