Asynchrone Socket-Library



  • Ich hab's momentan noch in keiner Repository und weitere Code-Beispiele gibt es auch nicht, die würde ich erst hier schreiben. Mir ging's hier erst einmal darum überhaupt so zu erhorchen, ob Interesse besteht.



  • Jodocus schrieb:

    Ich hab's momentan noch in keiner Repository und weitere Code-Beispiele gibt es auch nicht, die würde ich erst hier schreiben. Mir ging's hier erst einmal darum überhaupt so zu erhorchen, ob Interesse besteht.

    Interessant wird es doch genau dann wenn etwas sichtbares da ist.
    Wie würde zB ein HTTP Server aussehen der damit implementiert ist oder ein wget mit parallelen downloads. etc.

    Wie sieht die Architektur in einem "real world (tm)" beispiel aus?



  • Hm, 'nen ganzen HTTP-Server oder wget kann ich jetzt natürlich nicht nachprogrammieren. Aber mit einem Echo-Server kann ich vorerst dienen, evtl. gibt das schon etwas Aufschluss:

    #include "net/net.hpp"
    #include <iostream>
    #include <sstream>
    #include <string>
    #include <vector>
    
    using tcp = net::ip::tcp;
    
    class echo_server {
    	tcp::async_acceptor _acceptor; // Der Acceptor - im Prinzip ein async_socket
    	std::size_t _bufferSize; // klar
    	std::size_t _numAccepts; // wie viele Accept-Calls sollen gleichzeitig laufen?
    	std::size_t _numReceives; // wie viele Receive-Calls sollen pro Socket gleichzeitig laufen?
    
    	using buffer_type = std::vector<char>;
    	using socket_ptr = std::shared_ptr<tcp::async_socket>;
    
    public:
    	echo_server(net::io_core& core, const std::wstring& port, unsigned num_accepts, 
    				unsigned num_receives, std::size_t buffer_size)
    		: _acceptor(core, *tcp::localhost(port)), _bufferSize(buffer_size), _numAccepts(num_accepts),
    		  _numReceives(num_receives) // Der Acceptor wurde auf das erste lokale Interface gebunden
    	{
    		while(num_accepts--) {
    			// Starte asynchrone Accept-Calls mit Puffer, also empfange Daten
    			_acceptor.accept(std::bind(&echo_server::on_accept, this,
    									   std::placeholders::_1, std::placeholders::_2, std::placeholders::_3),
    							 buffer_type(_bufferSize));
    		}
    	}
    
    	void on_accept(net::async_result result, tcp::async_socket socket, buffer_type buffer) {
    		// Mache einen shared_ptr, sodass der Socket so lange lebt, wie auf ihm Operationen laufen
    		auto ptr = std::make_shared<tcp::async_socket>(std::move(socket));
    		if(result.error == 0) {
    			process(ptr, buffer.begin(), buffer.begin() + result.bytes_transferred);
    
    			// Starte Receive-Operationen jeweils in einem neuen Buffer
    			for(int i = 0; i < _numReceives; ++i) ptr->receive(std::bind(&echo_server::on_receive, this, std::placeholders::_1,
    																		 std::placeholders::_2, ptr), buffer_type(_bufferSize));
    
    			// Wenn nicht genug Accepts laufen, wird nachgeschenkt ;)
    			if(_acceptor.pending() < _numAccepts)
    				_acceptor.accept(std::bind(&echo_server::on_accept, this, std::placeholders::_1, std::placeholders::_2,
    										   std::placeholders::_3), std::move(buffer));
    		}
    		else {
    			// Ein Fehler ist geschehen, recycle den Socket und den Buffer via disconnect
    			ptr->disconnect(std::bind(&echo_server::on_disconnect, this, std::placeholders::_1, ptr, std::placeholders::_2), std::move(buffer));
    		}
    	}
    
    	void on_receive(net::async_result result, buffer_type buffer, socket_ptr& socket) {
    		// Wenn es einen Fehler gab oder die Verbindung beendet wurde, recycle den Socket/Buffer
    		if(result.error != 0 || result.bytes_transferred == 0) {
    			if(socket.use_count() == 1)
    				socket->disconnect(std::bind(&echo_server::on_disconnect, this, std::placeholders::_1, socket, std::placeholders::_2),
    								   std::move(buffer));
    		}
    		else {
    			// Verarbeite, sende Antwort und mache einen neuen Receive-Call auf dem selben Puffer
    			process(socket, buffer.begin(), buffer.begin() + result.bytes_transferred);
    			socket->receive(std::bind(&echo_server::on_receive, this, std::placeholders::_1, 
    									  std::placeholders::_2, socket), 
    							std::move(buffer));
    		}
    	}
    
    	void on_send(net::async_result, buffer_type, socket_ptr) { }
    
    	void on_disconnect(net::async_result result, socket_ptr socket, buffer_type buffer) {
    		// mache einen AcceptEx Call und recycle den Socket
    		if(_acceptor.pending() < _numAccepts)
    			_acceptor.accept(std::move(*socket), std::bind(&echo_server::on_accept, this, std::placeholders::_1,
    														   std::placeholders::_2, std::placeholders::_3),
    							 std::move(buffer));
    	}
    
    	void process(socket_ptr socket, buffer_type::iterator begin, buffer_type::iterator end) {
    		// Puffer kopieren und zurücksenden
    		buffer_type response(begin, end);
    		socket->send(std::bind(&echo_server::on_send, this, std::placeholders::_1, std::placeholders::_2, socket), std::move(response));
    	}
    
    };
    
    int main() {
    	net::io_core core;
    
    	echo_server server(core, L"8888", 4, 3, 1024);
    	try {
    		core.run(std::chrono::seconds(250)); // Lasse Anwendung für 25 Sekunden laufen
    	}
    	catch(net::net_error& error) {
    		std::cerr << "Exception!\nMessage: " << error.what() << "\nCode: " << error.code() << '\n';
    	}
    }
    

    Anmerkung: Alle on_* -Funktionen werden von der Library/dem io_core in unterschiedlichen Worker-Threads aufgerufen.

    Man gibt an, wie viele AcceptEx-Calls gleichzeitig laufen. Sofern es sich ergibt, wird ein Socket recyclet, sobald die Verbindung beendet wurde.
    Die AcceptEx-Funktionen haben hier den optionalen Puffer bekommen, sie empfangen also gleichzeitig Daten und completen erst, wenn mindestens 1 Byte empfangen wurde.

    Damit das bösartige Clients nicht ausnutzen können, wird automatisch ein Guard-Thread gestartet, sobald mindestens 1 AcceptEx-Operation läuft. Der stellt sicher, dass solche Clients nach einem anpassbaren Timeout getrennt werden. Wenn kein AcceptEx läuft (also async_acceptor::pending() == 0 gilt), wird der Thread automatisch beendet. Er ist für den Nutzer, wie prinzipiell das gesamte Threading, völlig unsichtbar.

    Außerdem werden mehrere Receive-Operationen gleichzeitig gestartet. Wenn der Server vor allem datenlastig ist, also einen hohen Throughput hat, ist es besser, mehrere solche Calls zu haben, damit nicht Daten in den NIC-Speicher zwischengeschoben werden müssen und evtl. das TCP-Window kleiner wird, nur weil kein WSARecv läuft.
    Das ist aber nicht ohne! Winsock garantiert zwar, dass die Completion-Reihenfolge der WSARecv -Funktionen erhalten bleibt (d.h. im I/O-Completion-Port kommen sie richtig heraus), aber die verschiedenen Worker-Threads bilden dann eine Race-Condition (d.h. hier ist's Zufall, welcher Callback zuerst aufgerufen wird). Die Library kümmert sich selber vollautomatisch darum, dass die Callbacks schön sequentiell aufgerufen werden, d.h. es werden ggf. auch Operationen zwischengespeichert, solange die vorigen nicht fertig sind. Es ist also so was ähnliches wie die Strands von asio, nur eben ohne strands, es funktioniert einfach.

    Da alle Daten, die während der Operation weiterleben sollen, an die Library per std::move übergeben werden, entfällt jede Form von manueller Speicherverwaltung, auch über mehrere Threads hinaus. Am Ende der Operation bekommt man sie in einem Worker-Thread zurück.

    Es gibt noch weitere Details im Innern der Library, der man das alles von außen nicht anmerkt, was alles geschieht.



  • Kann man das dauernde

    std::bind(&echo_server::on_accept, this, std::placeholders::_1, std::placeholders::_2,                                           std::placeholders::_3)
    

    irgendwie vereinfachen? Das schreit ja nur danach dass man da irgendwas verdreht.

    Prinzipiell sieht es interessant aus, wirkt aber etwas umständlich. Sind die ganzen callbacks wirklich immer notwendig? In JavaScript wäre ich stark für so ein Design aber in C++ wirkt es etwas "Boilerplatig".

    Kann ich mehrere Callbacks an ein Event hängen? Wenn nein, wäre ich eher für eine Interface Lösung - das programmiert sich irgendwie einfacher, uU ein slot/connect Prinzip? Ich verstehe schon den Sinn der Callbacks, sie haben eine gewisse flexibilität die zB gerade beim disconnect() recht cool ist, aber wären default callbacks nicht meistens einfacher -> es ist ja so dass du eh immer on_receive() aufrufen willst wenn du etwas receivst...



  • Jodocus schrieb:

    Ich habe nichts gegen Linux, jedoch ist es m.M.n. leichter, erst einmal mit Windows anzufangen und es dann auf Linux zu portieren als umgekehrt.

    Nein sorry, mit einer Windows-Only-Bibliothek kann ich nichts anfangen. Es fehlt z.B. konsequentes String-Handling und es ist zu erwarten, dass da noch mehr hinzukommt, da du dich mit Linux nicht auszukennen scheinst.

    Dieses _1, _2, _3 oder nur _1, _2 muss unbedingt weg. Das kann sich niemand merken. Ein variadic_bind (oder so) etwas würde vielleicht helfen. Oder einfach mit Traits arbeiten. Du übergibst irgend ein Objekt und darauf wird entweder operator() oder on_accept aufgerufen (SFINAE).



  • @Jodocus
    Hast du die expliziten disconnect() Aufrufe nur für "graceful disconnect" drinnen, oder sind die für's Resource-Management nötig?

    Was die bind() Aufrufe angeht - haste mal probiert wie das mit Lambdas aussehe würde?

    Grundlegener:
    Wie läuft Session-Management? Auch so wie bei ASIO dass man das Session-Objekt bzw. nen Session Smart Pointer mit in den Completion-Funktor rein binden muss?

    Und wie handelst du Fehler wie z.B. bad_alloc innerhalb des Frameworks bzw. wenn der Copy-Ctor des Funktors ne Exception wirft? Bei ASIO ist es da einfach so dass sämtliche Kopien des Completion-Funktors zerstört werden bevor der Completion-Funktor aufgerufen wird. D.h. man muss im Funktor z.B. nen shared_ptr<Session> oder sowas haben, und im wenn die letzte Funktor-Kopie zerstört wird wird sie Session zerstört und der Session Dtor läuft. (Vorausgesetzt es gibt keine shared_ptr<Session> in anderen Objekten.)
    Das dumme dabei ist dann dass sämtliche Garantien bezüglich "in welchem Thread kann ich zu welcher Zeit aufgerufen werden" dabei nicht mehr gelten -- der Funktor wird einfach zu einem beliebigen Zeitpunkt in einem beliebigen Thread zerstört.

    Und wie sieht's mit Cancellation aus? Sind deine Socket Objekte Thread-Safe? Bzw. wie kann man "von aussen" (=ohne auf nen Completion-Callback zu warten) sämtliche Pending-Requests auf einen Socket abbrechen bzw. eine bestehende Verbindung kappen?

    Das sind so die gröbsten Schwachpunkte von ASIO die ich noch in Erinnerung habe -- würde mich interessieren wie du diese Punkte gelöst hast.

    Jodocus schrieb:

    hustbaer würde sagen, "alles totaler Overkill, eh nicht messbar" 😉

    😃
    Jain.
    Ich bin immer noch der Meinung dass es für die allermeisten Anwendungen Overkill ist (wobei es = alles was komplizierter "als 1 Thread pro Client" ist). Dass es je nach Anwendung einen messbaren bis u.U. sogar deutlichen Unterschied machen kann, das bestreite ich ja nicht.

    Jodocus schrieb:

    Kann sein, viel gemessen habe ich (noch) nicht. Mich reizte aber die Herausforderung.

    Bei Messungen wäre interessant das Framework gegen mindestens zwei andere Implementierungen antreten zu lassen:
    1x "1 Thread pro Client" und
    1x Handgeschriebener C-Style Code der die schnellste auf der Plattform verfügbare API verwendet

    Ich bin bei "high performance" C++ Frameworks die Callbacks verwenden nämlich immer etwas skeptisch, da diese z.T. sehr viel new/delete machen müssen (natürlich schön über RAII weggekapselt, aber das macht es ja nicht schneller).
    Da kann man natürlich auch einiges optimieren - was aber ein vermutlich nicht unterheblicher Aufwand ist.
    Also z.B. was Callback-Funktoren angeht...
    Reicht es da z.B. aus einfach std::function<> zu verwenden? Greift die "Small-Functor-Optimization" oft genug dass std::function<> nicht dynamisch Speicher anfordern muss?
    Wie sieht es mit den ganzen Listen/Queues aus? Wenn da dauernd z.B. List- oder Treenodes angefordert und wieder freigegeben werden kostet das auch unnötig Performance.



  • Das ganze ge-wstring-e finde ich ultra haesslich.



  • Vielen Dank für das Feedback! Schön, dass sich Leute damit auseinandersetzen.

    Shade Of Mine schrieb:

    Kann man das dauernde

    std::bind(&echo_server::on_accept, this, std::placeholders::_1, std::placeholders::_2,                                           std::placeholders::_3)
    

    irgendwie vereinfachen? Das schreit ja nur danach dass man da irgendwas verdreht.

    Die Deklaration einer solchen typischen Funktion sieht so aus:

    template <blabla>
    template <typename CallbackType, typename ...Args>
    void basic_socket_type<blabla>::receive(CallbackType&&, Args&&...);
    

    Ich will dem Nutzer nichts aufzwingen, weder, wie seine Callbacks auszusehen haben, noch genau welche Parameter sie haben müssen. Man kann eben auch Lambdas, Funktionszeiger, Funktoren oder whatever übergeben, alles, was Callable ist. Aber es stimmt schon, verdreht man was mit std::bind, sind die Compiler-Fehler fürchterlich. Ein vielleicht etwas plumper Workaround geht evtl. mit Lambdas:

    socket->receive([this, &socket](net::async_result result, buffer_type buffer) {
    	on_receive(result, std::move(buffer), socket);
    }, std::move(buffer));
    

    Oder man "korrumpiert" die OOP-Idee etwas und gibt der Klasse ein paar std::function s als Membervariablen und definiert sie mit den Callback-Funktionen als Lambdas im Konstruktor, so spart man sich das zusätzliche move. Aber für eine clevere Idee bin ich zu haben. 🙂

    Shade Of Mine schrieb:

    Prinzipiell sieht es interessant aus, wirkt aber etwas umständlich.

    Naja, WinSock ist auf diesem "Niveau" eben umständlich, insbesondere da es alles multithreaded ist. Dieses Beispiel hier ist auch schon echter Overkill, nicht jeder Server muss X AcceptEx und WSARecv -Calls laufen haben.
    Vielleicht stören dich die shared_ptr -Sockets? Das ist ja nicht Teil der Library, sondern nur eine clevere Methode/Hack, Sockets zu speichern, ohne sie in eine Datenstruktur packen zu müssen, die dann auch noch mit Mutexen synchronisiert werden muss. Hier geht das, weil die Clients nichts voneinander wissen müssen, kann man aber auch anders machen.

    Shade Of Mine schrieb:

    Sind die ganzen callbacks wirklich immer notwendig? In JavaScript wäre ich stark für so ein Design aber in C++ wirkt es etwas "Boilerplatig".

    Naja im Falle, dass z.B. eine Sende-Operation completed, kann man sich das Callback in der Regel sparen, da der Server dort eh nicht viel tun muss (die Library hingegen schon, denn falls der Peer keine Daten empfängt, wird das TCP-Window verkleinert und WSASend completed nicht mehr. Ein bösartiger Client kann damit den gesamten RAM/nonpageable Pool vom Server fressen, aber die Library kümmert sich intern auch darum).
    Ansonsten willst du ja schon was unternehmen, wenn eine Operation abschließt. Wieso sollte man kein Callback aufrufen, wenn Daten da sind, eine neue Verbindung da ist etc.?
    Ich kann auf jeden Fall ein paar Overloads hinzufügen, die einfach einen leeren Standard-Callback als Lambda benutzen. Der Call wird vom Compiler dann eh wegoptimiert.

    Shade Of Mine schrieb:

    Kann ich mehrere Callbacks an ein Event hängen? Wenn nein, wäre ich eher für eine Interface Lösung - das programmiert sich irgendwie einfacher, uU ein slot/connect Prinzip? Ich verstehe schon den Sinn der Callbacks, sie haben eine gewisse flexibilität die zB gerade beim disconnect() recht cool ist, aber wären default callbacks nicht meistens einfacher -> es ist ja so dass du eh immer on_receive() aufrufen willst wenn du etwas receivst...

    Du meinst, die Library soll mehrere Callback-Funktionen nach einer Completionaufrufen? Kann man natürlich machen, aber das kannst du innerhalb deines Callbacks eigentlich auch selber tun. Interfaces sind aufgrund der Variadischen Parameter schwer und die Idee würde ich nicht aufgeben wollen. Ich finde es gerade praktisch, wenn man der Library Objekte anvertrauen kann, die man, solange die Operation läuft, nicht braucht und man sie zur rechten Zeit zurückbekommt, ohne sich um die Lifetime Gedanken machen zu müssen oder selber mit Smartpointern rumfitzeln zu müssen.
    Verwendet man jedoch trotzdem shared_ptr, kann manzumindest die Lebenszeit der Resourcen sichern und trotzdem darauf zugreifen.
    Was ich hingegen noch implementieren will sind "Chained Operations", d.h. man macht im Prinzip soetwas:

    socket.chained_receive([](async_result result, buffer_type& buffer) {
       // Achtung: der Buffer ist nun eine Referenz, die Library behält die Ownership
       bool result = process_packet(result, buffer);
       if(result) return true; // true zurückgeben -> Library macht einen neuen recv-Call und ruft diesen Callback auf
       else return false; // sonst eben nicht
    }
    

    Dadurch, dass die Buffer immer hin- und hergereicht werden, kann man sie perfekt wiederbenutzen und spart sich Allokationen. In Chained-Operations kann man auch die internen Resourcen für eine Operation wiederbenutzen, sodass man überhaupt nichts mehr zu alloziieren braucht.

    _1_2_3_4 schrieb:

    Jodocus schrieb:

    Ich habe nichts gegen Linux, jedoch ist es m.M.n. leichter, erst einmal mit Windows anzufangen und es dann auf Linux zu portieren als umgekehrt.

    Nein sorry, mit einer Windows-Only-Bibliothek kann ich nichts anfangen. Es fehlt z.B. konsequentes String-Handling und es ist zu erwarten, dass da noch mehr hinzukommt, da du dich mit Linux nicht auszukennen scheinst.

    Ich möchte dir nicht zu nahe treten und entschuldige mich, wenn ich dir Unrecht tue, aber du klingst etwas wie ein "M$-Hat0r". Ich will auf jeden Fall auch Linux implementieren, einfach auch deshalb, da ich selber mal sehen will, was von beiden wirklich schneller ist oder ob sie sich nichts nehmen. Die Library nimmt erst mal jede Form von Strings entgegen, je nach dem, ob man z.B. in VS _UNICODE definiert oder nicht. WinSock stellt sich quer mit UTF-8-Strings (Deprecated Warnings/Fehler etc.), aber die Library benutzt sowieso nirgendwo außer in den Address-Resolvern Strings (WinSock genauso). Die Anpassungen sind also minimal.
    Dass ich mich mit Linux nicht auskenne ist eine Unterstellung. Kann schon sein, aber womit ich mich auskenne, ist die dev/epoll-API bzw. wie Sockets auf Linux funktioneren. Im Gegensatz zu den IOCPs braucht man für epoll wesentlich weniger Code bzw. man ist viel flexibler. Es ist tausend mal einfacher, IOCPs mit epoll zu "simulieren" als zu versuchen, epoll mit IOCPs zu basteln.

    _1_2_3_4 schrieb:

    Dieses _1, _2, _3 oder nur _1, _2 muss unbedingt weg. Das kann sich niemand merken.

    Siehe meinen obigen Text. Die Namen der Placeholder sind ja nicht von mir, da steht [c]std::placeholders[/c] davor. 😉

    _1_2_3_4 schrieb:

    Ein variadic_bind (oder so) etwas würde vielleicht helfen. Oder einfach mit Traits arbeiten. Du übergibst irgend ein Objekt und darauf wird entweder operator() oder on_accept aufgerufen (SFINAE).

    Wie sähe so etwas aus? Meine Überlegungen dazu führen leider nur zu etwas, was längst da ist, nämlich std::bind , bzw. ich würde nur bind nachbauen. Hast du einen Fetzen Code für mich? 😋



  • hustbaer schrieb:

    @Jodocus
    Hast du die expliziten disconnect() Aufrufe nur für "graceful disconnect" drinnen, oder sind die für's Resource-Management nötig?

    Zum einen dafür, zum anderen kann man damit per TF_REUSE_SOCKET -Flag (siehe DisconnectEx-API) Sockets recyclebar machen und sie AcceptEx geben, ohne sie schließen zu müssen. Sobald die Lib etwas weiter ist, werde ich dann mal messen, wie viel Zeit bei einem AcceptEx nur für das Socket-Erstellen draufgeht bzw. ob man was mit Wiederverwertung spart.

    hustbaer schrieb:

    Was die bind() Aufrufe angeht - haste mal probiert wie das mit Lambdas aussehe würde?

    Klar, geht wunderbar, siehe Post No. 1. Nur kann ich eben innerhalb des Lambdas den Lambda selber nicht benutzen, was ich aber bei der Angabe der Callbacks tun muss.

    hustbaer schrieb:

    Grundlegener:
    Wie läuft Session-Management? Auch so wie bei ASIO dass man das Session-Objekt bzw. nen Session Smart Pointer mit in den Completion-Funktor rein binden muss?

    Es muss garantiert sein, dass der io_core immer lebt, solange Operationen laufen. Jedes Objekt der Library muss einen solchen als Parameter im Konstruktor bekommen und man kann nur einen pro Prozess erstellen. Meinst du das?

    hustbaer schrieb:

    Und wie handelst du Fehler wie z.B. bad_alloc innerhalb des Frameworks bzw. wenn der Copy-Ctor des Funktors ne Exception wirft? Bei ASIO ist es da einfach so dass sämtliche Kopien des Completion-Funktors zerstört werden bevor der Completion-Funktor aufgerufen wird. D.h. man muss im Funktor z.B. nen shared_ptr<Session> oder sowas haben, und im wenn die letzte Funktor-Kopie zerstört wird wird sie Session zerstört und der Session Dtor läuft. (Vorausgesetzt es gibt keine shared_ptr<Session> in anderen Objekten.)
    Das dumme dabei ist dann dass sämtliche Garantien bezüglich "in welchem Thread kann ich zu welcher Zeit aufgerufen werden" dabei nicht mehr gelten -- der Funktor wird einfach zu einem beliebigen Zeitpunkt in einem beliebigen Thread zerstört.

    Wichtiger Punkt.
    Innerhalb des Frameworks gilt während einer Completion für alle Vorgänge vor und nach dem Aufruf des Callbacks die No-Throw-Garantie, insbesondere finden da keine Allokationen statt, sondern es wird nur mit lockfreien-Algorithmen, sprich Atomics und Zeigern/Referenzen hantiert, bis der Callback aufgerufen wird (ist schon schwer genug, lock-free zu programmieren und dabei Resourcen-Management zu übernehmen, Exceptions kann ich da gar nicht gebrauchen 😃 ). Die Funktion, die den Callback aufruft, ist noexcept und fängt jede Exception auf, die im Callback nicht gefangen wurde und wirft sie **in den Thread, in dem io_core::run() **läuft weiter (siehe main()-Funktion). Fliegt also in einem Callback eine Exception durch, macht die Library danach normal weiter, räumt auf etc. Ansonsten sollte überall die Strong-Guarantee gelten (ich schaue aber noch mal drüber, insbesondere, ob ich für Objekte, die man per Move-Konstruktoren an die Lib gibt ein is_nothrow_move_constructible voraussetzen muss).

    hustbaer schrieb:

    Und wie sieht's mit Cancellation aus? Sind deine Socket Objekte Thread-Safe? Bzw. wie kann man "von aussen" (=ohne auf nen Completion-Callback zu warten) sämtliche Pending-Requests auf einen Socket abbrechen bzw. eine bestehende Verbindung kappen?

    WinSock-Sockets sind erst mal AFAIK per se thread-safe. Was nicht thread-safe ist, sind simultane Aufrufe von WSARecv() etc. Die Aufrufe werden dann per Mutex pro Socket synchronisiert. Der Socket erbt dabei privat per Template von einer Klasse, standardmäßig std::mutex. Wenn man das nicht will, kann man per Template net::bogo_mutex angeben, der quasi nichts macht, wegoptimiert wird bzw. durch Empty-Base optimization nichts kostet.

    Bzgl. Cancelling:
    Bisher kann man von außen nur per async_socket::cancel() alle Operationen auf einmal canceln aber nicht gezielt einzelne. In dem Fall werden alle Callbacks mit einem Fehler aufgerufen.
    Eine Sache, die noch fehlt (bzw. nur für asynchrone Resolve-Operationen definert ist), ist das gezielte canceln bestimmter Operationen. Dafür muss der Nutzer dann aber in Kauf nehmen, ein Handle zu halten:

    auto operation_handle = socket.receive(...);
    ...
    socket.cancel(operation_handle);
    

    hustbaer schrieb:

    Das sind so die gröbsten Schwachpunkte von ASIO die ich noch in Erinnerung habe -- würde mich interessieren wie du diese Punkte gelöst hast.

    Asio ist ein Gigant und hat Sachen wie SSL und Multi-OS-Support, an die ich jetzt noch gar nicht zu denken wage. Aber ich will mich schon daran messen lassen. 🙂

    hustbaer schrieb:

    Jodocus schrieb:

    hustbaer würde sagen, "alles totaler Overkill, eh nicht messbar" 😉

    😃
    Jain.
    Ich bin immer noch der Meinung dass es für die allermeisten Anwendungen Overkill ist (wobei es = alles was komplizierter "als 1 Thread pro Client" ist). Dass es je nach Anwendung einen messbaren bis u.U. sogar deutlichen Unterschied machen kann, das bestreite ich ja nicht.

    Schon klar. Ich mache es ja auch eher aus "akademischen" Gründen, da ich enorm viel über Multithreading, Lock-freie Programmierung, Memory-Ordering (mit all dem std::kill_dependency-Gedöhns) und Library-Design/Template-Metaprogrammierung lerne (ja, wegen der Variadischen Callbacks musste ich da auch was machen).

    hustbaer schrieb:

    Jodocus schrieb:

    Kann sein, viel gemessen habe ich (noch) nicht. Mich reizte aber die Herausforderung.

    Bei Messungen wäre interessant das Framework gegen mindestens zwei andere Implementierungen antreten zu lassen:
    1x "1 Thread pro Client" und
    1x Handgeschriebener C-Style Code der die schnellste auf der Plattform verfügbare API verwendet

    Ich bin bei "high performance" C++ Frameworks die Callbacks verwenden nämlich immer etwas skeptisch, da diese z.T. sehr viel new/delete machen müssen (natürlich schön über RAII weggekapselt, aber das macht es ja nicht schneller).

    Wie du siehst, kann man schon sämtliche Buffer-Allokationen sparen, da man die Ownership über die Buffer zur Wiederverwertung zurückbekommt. Über Chained-Operationen kann man dann auch die internen WSAOVERLAPPED -Objekte wiederverwerten. Da sich die Library nicht (bzw. unwesentlich) dafür interessiert, wie die Buffer-Objekte aussehen (also ob vector, string oder irgendwas, Hauptsache, es liegt ein Array zugrunde), kann man auch eigene Allokatoren für Memory-Pools benutzen. Die Library interessiert das nicht.

    hustbaer schrieb:

    Da kann man natürlich auch einiges optimieren - was aber ein vermutlich nicht unterheblicher Aufwand ist.
    Also z.B. was Callback-Funktoren angeht...
    Reicht es da z.B. aus einfach std::function<> zu verwenden? Greift die "Small-Functor-Optimization" oft genug dass std::function<> nicht dynamisch Speicher anfordern muss?

    std::function kostet schon was, muss man aber nicht benutzen. Die Library setzt a priori nur Callable voraus.

    hustbaer schrieb:

    Wie sieht es mit den ganzen Listen/Queues aus? Wenn da dauernd z.B. List- oder Treenodes angefordert und wieder freigegeben werden kostet das auch unnötig Performance.

    Wo sollte ich innerhalb der Library Trees benutzen? Mir fällt nur ein Punkt ein, bei der Completion-Sequenzierung, also dass die Callbacks in der richtigen Reihenfolge laufen. Das ist bei mir eine Art lock-freie doubled-linked FIFO-Queue (okay, auf die bin ich ein bisschen stolz), bei der es aufgrund der speziellen Form kein ABA-Problem gibt, man es also auch nicht lösen muss. Die Form ist aber sehr speziell und nicht für eine allgemeine Anwendung zu benutzen, also nicht überschätzen.

    Kellerautomat schrieb:

    Das ganze ge-wstring-e finde ich ultra haesslich.

    Ja, ich weiß. Aber das ist wie gesagt nur ein kleiner Aspekt, ich brauche kaum Strings.



  • Mir ist der Sinn der Callbacks schon klar - nur finde ich die Benutzung hier schon echt unübersichtlich und unpraktisch. Deshalb die Frage ob man das vereinfachen kann.

    uU an einen Socket ein Objekt binden oder an eine "Operation" ein Objekt binden. Irgendwie diese furchtbaren bind() aufrufen weg bekommen - denn da schreibt man sich einen Krampf und lesbar ist das ganze auch nicht mehr.

    zB kann man dann generische on_error funktionen anbieten wenn ich ein Objekt nehme dass einen gewissen Trait implementiert. In dem du eben eben Objekt und nicht callbacks an den Socket bindest hast du plötzlich die Option für default handlings.

    Die technische Seite ignoriere ich hier mit Absicht - die sollte nur minimal in das API Design hineinspielen.



  • Jodocus schrieb:

    hustbaer schrieb:

    Was die bind() Aufrufe angeht - haste mal probiert wie das mit Lambdas aussehe würde?

    Klar, geht wunderbar, siehe Post No. 1. Nur kann ich eben innerhalb des Lambdas den Lambda selber nicht benutzen, was ich aber bei der Angabe der Callbacks tun muss.

    Ich meinte dass du einfach nur bind() durch nen Lambda ersetzt. Das sollt immer gehen. Beispiel

    _acceptor.accept(std::bind(&echo_server::on_accept, this, 
                                           std::placeholders::_1, std::placeholders::_2, std::placeholders::_3), 
                                 buffer_type(_bufferSize));
    // =>
                _acceptor.accept([=](auto res, auto sock, auto buf) { on_accept(res, sock, buf); },
                                 buffer_type(_bufferSize));
    

    Finde ich irgendwie freundlicher zu lesen (und zu schreiben).

    Jodocus schrieb:

    hustbaer schrieb:

    Grundlegener:
    Wie läuft Session-Management? Auch so wie bei ASIO dass man das Session-Objekt bzw. nen Session Smart Pointer mit in den Completion-Funktor rein binden muss?

    Es muss garantiert sein, dass der io_core immer lebt, solange Operationen laufen. Jedes Objekt der Library muss einen solchen als Parameter im Konstruktor bekommen und man kann nur einen pro Prozess erstellen. Meinst du das?

    Nene ich meine Session-Management 😃
    Irgendwo muss man ja irgendwie den State den man pro Connection so hat verwalten. Selbst bei Services die als ganzes REST-ful sind. Beispiel HTTP Server: irgendwo muss man State mitführen den man für den SSL Handshake braucht, die Headers die man schon geparsed hat, ggf. das Handle auf ein File welches man gerade verschickt usw.
    Bei ASIO war die einzig praktikable Möglichkeit die ich gefunden habe, nen shared_ptr<Session> in den Callback-Funktor reinzubinden. Nur so konnte man zuverlässig mitbekommen dass eine Session gestorben ist. Eben wenn in den Innereien der ASIO z.B. ein bad_alloc geflogen wäre.
    Wenn bei dir garantiert ist dass immer zumindest ne Fehlerbenachrichtigung kommt, dann ist das vermutlich bei dir kein Problem.

    BTW: Der io_core ist Singleton und man muss ihn überall mitgeben? Huch? Und was ich von Singletons halte muss ich glaube ich auch nicht nochmal erwähnen 😃
    Aber ernsthaft: ich sehe das als ernsthafte Einschränkung. Angenommen man würde zwei Komponenten in einem Programm verwenden wollen, und beide haben sich entschieden irgendwo tief tief im Innern dein Framework zu verwenden. Und beide erzeugen natürlich selbst "ihr" io_core Objekt. Boom, you're dead.

    Jodocus schrieb:

    hustbaer schrieb:

    Und wie handelst du Fehler wie z.B. bad_alloc innerhalb des Frameworks (...)

    Wichtiger Punkt.
    Innerhalb des Frameworks gilt während einer Completion für alle Vorgänge vor und nach dem Aufruf des Callbacks die No-Throw-Garantie, insbesondere finden da keine Allokationen statt, sondern es wird nur mit lockfreien-Algorithmen, sprich Atomics und Zeigern/Referenzen hantiert, bis der Callback aufgerufen wird (ist schon schwer genug, lock-free zu programmieren und dabei Resourcen-Management zu übernehmen, Exceptions kann ich da gar nicht gebrauchen 😃 ).

    Klingt gut.
    Wie setzt du dann "zusammengesetzte" asynchrone Operationen um?
    Also Operationen wo du im Framework bzw. einer "Extension" mehrere asynchrone Aufrufe machen musst bevor du den vom User übergebenen Callback aufrufen kannst. Stelle ich mir schwierig vor.
    Oder laufen die auch alle in einem impliziten "strand", so dass du bei z.B. bad_alloc (kann ja beim Starten einer neuen asynchronen Teiloperation wieder passieren) einfach direkt den Handler des Users mit nem Fehler aufrufen kannst?

    Jodocus schrieb:

    Bzgl. Cancelling:
    Bisher kann man von außen nur per async_socket::cancel() alle Operationen auf einmal canceln aber nicht gezielt einzelne. In dem Fall werden alle Callbacks mit einem Fehler aufgerufen.

    Alle Operationen einer async_socket Instanz oder alle Operationen von allen async_socket Instanzen?
    Ersteres würde für die meisten Anwendungen wohl schon reichen.

    Jodocus schrieb:

    Die Library setzt a priori nur Callable voraus. (...) Wo sollte ich innerhalb der Library Trees benutzen?

    Ich hab' ja List- oder Treenodes geschrieben.
    Was ich meine: beim mySocket.accept(...) musst du ja irgendwo etwas herzaubern wo du den Funktor reinkopierst. Wie und wo zauberst du das her?



  • Jodocus schrieb:

    Mir ging's hier erst einmal darum überhaupt so zu erhorchen, ob Interesse besteht.

    Denke schon. Speziell die Implementierung wäre vermutlich für einige interessant.



  • hustbaer schrieb:

    Jodocus schrieb:

    hustbaer schrieb:

    Was die bind() Aufrufe angeht - haste mal probiert wie das mit Lambdas aussehe würde?

    Klar, geht wunderbar, siehe Post No. 1. Nur kann ich eben innerhalb des Lambdas den Lambda selber nicht benutzen, was ich aber bei der Angabe der Callbacks tun muss.

    Ich meinte dass du einfach nur bind() durch nen Lambda ersetzt. Das sollt immer gehen. Beispiel

    _acceptor.accept(std::bind(&echo_server::on_accept, this, 
                                           std::placeholders::_1, std::placeholders::_2, std::placeholders::_3), 
                                 buffer_type(_bufferSize));
    // =>
                _acceptor.accept([=](auto res, auto sock, auto buf) { on_accept(res, sock, buf); },
                                 buffer_type(_bufferSize));
    

    Finde ich irgendwie freundlicher zu lesen (und zu schreiben).

    Achso meinst du das, jo, das habe ich in https://www.c-plusplus.net/forum/332923#2455501 vorgeschlagen. Ich habe aber keine Ahnung bisher, ob die Compiler es schaffen, etwaige Moves wegzuoptimieren.

    hustbaer schrieb:

    Jodocus schrieb:

    hustbaer schrieb:

    Grundlegener:
    Wie läuft Session-Management? Auch so wie bei ASIO dass man das Session-Objekt bzw. nen Session Smart Pointer mit in den Completion-Funktor rein binden muss?

    Es muss garantiert sein, dass der io_core immer lebt, solange Operationen laufen. Jedes Objekt der Library muss einen solchen als Parameter im Konstruktor bekommen und man kann nur einen pro Prozess erstellen. Meinst du das?

    Nene ich meine Session-Management 😃
    Irgendwo muss man ja irgendwie den State den man pro Connection so hat verwalten. Selbst bei Services die als ganzes REST-ful sind. Beispiel HTTP Server: irgendwo muss man State mitführen den man für den SSL Handshake braucht, die Headers die man schon geparsed hat, ggf. das Handle auf ein File welches man gerade verschickt usw.
    Bei ASIO war die einzig praktikable Möglichkeit die ich gefunden habe, nen shared_ptr<Session> in den Callback-Funktor reinzubinden. Nur so konnte man zuverlässig mitbekommen dass eine Session gestorben ist. Eben wenn in den Innereien der ASIO z.B. ein bad_alloc geflogen wäre.
    Wenn bei dir garantiert ist dass immer zumindest ne Fehlerbenachrichtigung kommt, dann ist das vermutlich bei dir kein Problem.

    Ein wirkliches Problem diesbzgl. sehe ich da bei meiner Library bisher nicht. Wenn schon das Starten der Operation durch bad_alloc fehlschlägt, dann fliegt eben bad_alloc und man kann es behandeln, wenn es geht. Wenn die Operation erst mal läuft, ist immer garantiert, dass der Callback aufgerufen wird, wenn etwas schiefgeht (spätestens, wenn der Socket ungültig wird).

    hustbaer schrieb:

    BTW: Der io_core ist Singleton und man muss ihn überall mitgeben? Huch? Und was ich von Singletons halte muss ich glaube ich auch nicht nochmal erwähnen 😃
    Aber ernsthaft: ich sehe das als ernsthafte Einschränkung. Angenommen man würde zwei Komponenten in einem Programm verwenden wollen, und beide haben sich entschieden irgendwo tief tief im Innern dein Framework zu verwenden. Und beide erzeugen natürlich selbst "ihr" io_core Objekt. Boom, you're dead.

    Zum einen muss ich technisch irgendwie garantieren, dass ws2_32.dll geladen ist. Indem ich jedem Objekt der Library aufnötige, als Referenz diesen "io_core" zu übergeben, kann ich das gewährleisten. Dieses Objekt trägt aber noch mehr Verantwortungen: es hält den I/O-Completion-Port, es managed die Worker-Threads, es nimmt alle Exceptions der Threads an und deligiert sie weiter. Denkbar ist es für mich zumindest, dass man sie als thread_local-Singletons implementiert. Über den Fall aber, dass 2 Komponenten jeweils ihre io_cores benutzen, habe ich ehrlich gesagt noch nicht sinniert.

    hustbaer schrieb:

    Jodocus schrieb:

    hustbaer schrieb:

    Und wie handelst du Fehler wie z.B. bad_alloc innerhalb des Frameworks (...)

    Wichtiger Punkt.
    Innerhalb des Frameworks gilt während einer Completion für alle Vorgänge vor und nach dem Aufruf des Callbacks die No-Throw-Garantie, insbesondere finden da keine Allokationen statt, sondern es wird nur mit lockfreien-Algorithmen, sprich Atomics und Zeigern/Referenzen hantiert, bis der Callback aufgerufen wird (ist schon schwer genug, lock-free zu programmieren und dabei Resourcen-Management zu übernehmen, Exceptions kann ich da gar nicht gebrauchen 😃 ).

    Klingt gut.
    Wie setzt du dann "zusammengesetzte" asynchrone Operationen um?
    Also Operationen wo du im Framework bzw. einer "Extension" mehrere asynchrone Aufrufe machen musst bevor du den vom User übergebenen Callback aufrufen kannst. Stelle ich mir schwierig vor.

    Hast du ein kleines Beispiel für mich? Ich stehe da gerade etwas auf dem Schlauch. Meinst du, dass der Library-Nutzer sich komplexere Operationen aus Accept/Send/Recv etc. zusammensetzen können soll?

    hustbaer schrieb:

    Oder laufen die auch alle in einem impliziten "strand", so dass du bei z.B. bad_alloc (kann ja beim Starten einer neuen asynchronen Teiloperation wieder passieren) einfach direkt den Handler des Users mit nem Fehler aufrufen kannst?

    Das kann ich natürlich immer machen.

    hustbaer schrieb:

    Jodocus schrieb:

    Bzgl. Cancelling:
    Bisher kann man von außen nur per async_socket::cancel() alle Operationen auf einmal canceln aber nicht gezielt einzelne. In dem Fall werden alle Callbacks mit einem Fehler aufgerufen.

    Alle Operationen einer async_socket Instanz oder alle Operationen von allen async_socket Instanzen?
    Ersteres würde für die meisten Anwendungen wohl schon reichen.

    Ersteres. Das zweite wäre nur mit dem kompletten Löschen des io_cores/dem IOCP denkbar.

    hustbaer schrieb:

    Jodocus schrieb:

    Die Library setzt a priori nur Callable voraus. (...) Wo sollte ich innerhalb der Library Trees benutzen?

    Ich hab' ja List- oder Treenodes geschrieben.
    Was ich meine: beim mySocket.accept(...) musst du ja irgendwo etwas herzaubern wo du den Funktor reinkopierst. Wie und wo zauberst du das her?

    Achso. Momentan noch ganz rundimentär vom Standard-Heap. Wenn ich Operations-Ketten implementiert habe, können neben den Puffern dann auch diese Resourcen wiederbenutzt werden, sodass dann alle Allokationen entfallen.



  • Jodocus schrieb:

    Zum einen muss ich technisch irgendwie garantieren, dass ws2_32.dll geladen ist. Indem ich jedem Objekt der Library aufnötige, als Referenz diesen "io_core" zu übergeben, kann ich das gewährleisten.

    Ich finde es bloss komisch dass es einerseits ein Singleton ist (bzw. eben die Highlander-Regel gilt), aber es trotzdem überall explizit mitgegeben werden muss. Wobei mir ganz klar die Lösung "kein Singleton" lieber wäre als die Lösung "muss nicht explizit mitgegeben werden".

    Jodocus schrieb:

    Dieses Objekt trägt aber noch mehr Verantwortungen: es hält den I/O-Completion-Port, es managed die Worker-Threads, es nimmt alle Exceptions der Threads an und deligiert sie weiter. Denkbar ist es für mich zumindest, dass man sie als thread_local-Singletons implementiert. Über den Fall aber, dass 2 Komponenten jeweils ihre io_cores benutzen, habe ich ehrlich gesagt noch nicht sinniert.

    Mag sein dass das andere ganz anders sehen, aber mir wäre der Fall recht wichtig. Weniger wenn ich ne Anwendung entwickle, dafür aber sehr wenn ich Komponenten bzw. Libraries entwickle die dein Framework als Basis verwenden. Aber eben davon unabhängig zu verwenden sein sollen. Was für mich halt auch einschliesst dass eine Komponenten mit einer anderen koexistieren können sollte die zufällig ebenfalls dein Framework verwendet.

    Ganz übel aufgefallen ist mir das immer mit der DevIL. Ich verstehe nicht wie das Projekt jemals so beliebt werden konnte. Globale States *schauder*. War schon bei OpenGL ne grandios dämliche Idee, nur da sind die States wenigstens thread-local (bzw. genaugenommen Context-local).

    Eine denkbare Lösung wäre auch dass io_core zwar ein Singleton bleibt, aber es eine shared_ptr<io_core> get_core() Funktion gibt. Falls io_core keine globalen Properties/Attributes/Optione etc. hat sondern einfach nur "da sein muss" würde das gehen. Machen ja auch viele Libraries so oder so ähnlich (CoInitialize/CoUninitialize etc.).

    Was thread-local angeht: auch nicht SO schön. Würde es nicht reichen ala ASIO die vom User erzeugten Threads nur temporär mit einem io_core zu verknüpfen, so dass man auch Cores-switchen kann wenn es die Anwendung mal so will? Die Verknüpfung macht ja IMO nur Sinn während io_core::run() läuft, oder?
    (Bin mir jetzt nicht 100% sicher, aber ich meine bei ASIO geht das.)

    Jodocus schrieb:

    Hast du ein kleines Beispiel für mich? Ich stehe da gerade etwas auf dem Schlauch. Meinst du, dass der Library-Nutzer sich komplexere Operationen aus Accept/Send/Recv etc. zusammensetzen können soll?

    Ja, genau das. Nur damit wird so eine Library ausserhalb des "wir machen alles selbst weil wir MAXIMALE Performance brauchen" Bereichs Chancen haben.
    Als konkrete Beispiele fallen mir spontan ein: SSL Handshake, ReadLine(), ReadUntil(StreamDataCondition) (z.B. "read until double line break"), ReadHttpRequest. Oder auch einfach bestimmte Operationen von selbstgestrickten Protokollen. User authentifizieren, Clock synchronisieren, File übertragen, ...

    Jodocus schrieb:

    hustbaer schrieb:

    Jodocus schrieb:

    Bzgl. Cancelling:
    Bisher kann man von außen nur per async_socket::cancel() alle Operationen auf einmal canceln aber nicht gezielt einzelne. In dem Fall werden alle Callbacks mit einem Fehler aufgerufen.

    Alle Operationen einer async_socket Instanz oder alle Operationen von allen async_socket Instanzen?
    Ersteres würde für die meisten Anwendungen wohl schon reichen.

    Ersteres. Das zweite wäre nur mit dem kompletten Löschen des io_cores/dem IOCP denkbar.

    Das zweite braucht man auch nicht wirklich. Wichtig ist dass das erste geht. Dann kann man ja, wenn man die Sessions irgendwo selbst trackt, alle Sessions durchgehen und dann alle Operationen des Sockets der Session canceln.
    Wobei es als Convenience-Funktion auch nicht schaden würde. Dann könnte man mit ein paar Zeilen nen halbwegs sauberen Shutdown implementieren.

    Jodocus schrieb:

    Achso. Momentan noch ganz rundimentär vom Standard-Heap. Wenn ich Operations-Ketten implementiert habe, können neben den Puffern dann auch diese Resourcen wiederbenutzt werden, sodass dann alle Allokationen entfallen.

    Hm. Wie kann das mit unterschiedlich grossen Funktoren funktionieren? Mir fallen da nur zwei Möglichkeiten ein: 1) Das Ketten-Objekt hat nen Puffer für den Funktor und vergrössert diesen nur, verkleinert ihn aber nie (ala vector). 2) Man muss dem Ketten-Objekt explizit angeben wie gross der grösste Funktor wird. (2.1: Man muss das Ketten-Objekt als chain<functor_type_1, functor_type_2, functor_type_3, ...> erzeugen, aber das wäre ganz furchtbar.)
    (1) finde ich jetzt garnichtmal so schlecht.



  • Edit: Das Interface hat sich geändert, siehe nächster Post!

    Vorweg evtl. ein paar mehr Details, wie laufende Operationen organisiert sind:
    Im Falle, dass Completions sequentiell sein sollen, hat ein (speziellerer) Socket 2 Pointer, einer auf das Ende einer Send()- und einer auf eine Recv()-"Liste". Wird eine neue Operation gestartet, wird lockfrei an das Ende der Liste das entsprechende Operationsobjekt hinzugefügt (dieses hält dann auch den Callback, alle eventuellen Parameter des Nutzers, internes WSAOVERLAPPED etc).
    Jedes Element der Liste kann lockfrei autonom herausfinden, ob es das vorderste ist. Falls es nicht vorn ist, aber completed wurde, markiert es, dass es ausführbar ist. Der Vordermann in der Liste wird sich dann darum kümmern, es auszuführen, sobald er selbst ausgeführt wurde. Dabei zerstört dann auch jeweils der Nachfolger seinen Vorgänger. So in der Art funktioniert das.

    Momentan ist es etwas steif, das gebe ich zu. Ich ging erst mal davon aus, dass man Senden und Empfangen autonom voneinander sequenzieren möchte. Für zusammengesetzte Operationen muss man dann flexibel weitere solcher Listen erstellen können (also Objekte, die den Tail der Liste haben und an die man die Operationen push_back'en kann).
    Was ich jetzt noch nicht ganz verstehe: Wenn bei einer zusammengesetzten Operation irgendeine Teiloperation completed, muss die Library ja wissen, was sie tun soll. Ich meine, abhängig vom Ergebnis der Operation muss sie ja dann unterschiedlich reagieren, das muss aber der Nutzer angeben. Also wird dann ja doch wieder nach jeder Operation ein Callback ausgeführt.

    hustbaer schrieb:

    Eine denkbare Lösung wäre auch dass io_core zwar ein Singleton bleibt, aber es eine shared_ptr<io_core> get_core() Funktion gibt. Falls io_core keine globalen Properties/Attributes/Optione etc. hat sondern einfach nur "da sein muss" würde das gehen. Machen ja auch viele Libraries so oder so ähnlich (CoInitialize/CoUninitialize etc.).

    Eine Sache hatte ich vergessen (und die zerhaut's mir dann etwas): sie beinhaltet auch die Konfigurationsparameter, z.B. Timeouts, IPv6-Dualität etc. Das sind nun mal globale Parameter, die ich nicht lokal speichern kann/will. Bisher mache ich (sehr dreckig) für io_core sowas wie einen static public Self-Pointer, sodass alle Funktionen auf den core und damit auf die Parameter zugreifen können, ohne dass sie alle auch noch eine Referenz/Zeiger auf den Core speichern müssen.
    Die einzige andere Möglichkeit, die Konfiguration zu organisieren, ohne Overhead zu haben, sehe ich dann bei den guten alten Config-Makros. Damit kann man dann die Library mehrfach in unterschiedlichen Modulen nutzen, wobei Modul hier wörtlich mit Übersetzungseinheit zu nehmen wäre.

    hustbaer schrieb:

    ReadUntil(StreamDataCondition) (z.B. "read until double line break"),

    Ja, sowas dachte ich mir. Aber so spezielle Bedingungen kann ich unmöglich alle selber angeben, das muss der Nutzer ja trotzdem der Library irgendwie mitteilen, via Callback. Die Logik für sowas wäre etwa so:

    void read_until_double_line_break(buffer_type buffer, callback_type callback) {
        socket.receive(on_data_receive, move(buffer), std::move(callback));
    }
    
    void on_data_receive(async_result result, buffer_type buffer, callback_type callback) {
        if(result.error != 0) { blabla, callback(result.error); }
        else {
            if(!ends_with_double_line(buffer))
                socket.receive(on_data_receive, slice(buffer, result.bytes_transferred), std::move(callback)); // schreibt in den Puffer-Slice ab dem angegebenen Offset
            else callback(buffer); // <--- Packet komplett, Callback aufrufen
        }
    }
    

    Analog ein Handshake, wo auch ein paar Send-Operationen dazukommen. Ich weiß nicht so recht, wie die Lib diese Arbeit dem Nutzer abnehmen kann (außer ich implementiere halt z.B. explizit SSL). Das, was sie abnimmt, ist, dass die Callbacks alle schön in Reihenfolge aufgerufen werden.

    hustbaer schrieb:

    Hm. Wie kann das mit unterschiedlich grossen Funktoren funktionieren? Mir fallen da nur zwei Möglichkeiten ein: 1) Das Ketten-Objekt hat nen Puffer für den Funktor und vergrössert diesen nur, verkleinert ihn aber nie (ala vector).

    Hm, erst mal unterscheide ich zwischen zusammengesetzen Operationen (das oben) und Ketten. Ketten sind halt für mich sowas:

    begin_receive() -> completion -> callback -> begin_receive(reuse ressources) -> completion -> selber_callback -> ...
    

    Hier hat alles die selbe Größe, das ist (relativ) einfach.



  • Ich habe das Interface noch einmal gewaltig verändert (dank hustbaer's Anmerkungen). Dabei habe ich ein paar recht interessante Ansätze verwendet. In dem Post gehe ich etwas auf die Probleme ein, denen ich beim Implementieren begegnet bin, vielleicht findet's ja jemand interessant.

    Zu erst mache ich den io_core peu à peu zu einem Nicht-Singleton, wie hustbaer es vorschlug.

    Auch das allgemeine Funktionsprinzip habe ich geändert (jetzt breche ich endgültig mit der Ähnichkeit zu boost::asio).
    Bisher sah ein asynchroner Aufruf so aus:

    socket.async_read(callback, std::move(buffer));
    

    Da ich die Ownership vom Buffer im Callback wiederbekomme, kann ich ihn wiederbenutzen, ohne neue Allokationen anstellen zu müssen. Mit dem Objekt aber, (der "Operation"), welches den Speicher zum Callback-Objekt etc. hält, ist selbiges mit dem Ansatz nur extrem schwer zu erreichen. Deshalb schlage ich nun folgenden Ansatz vor (bzw. habe schon große Teile davon implementiert):

    // Erstelle ein Operations-Objekt, unabhängig vom Socket
    auto operation = create_async_read(callback, std::move(buffer));
    socket.begin(std::move(operation)); // starte Operation auf dem Socket
    

    Eine TCP-Operation ist dabei z.B. an einen Socket vom Typ "TCP" gebunden. Der Versuch, eine UDP-Operation auf einen TCP-Socket anzuwenden, schlägt beim Kompilieren fehl. Wenn die Operation synchron ist, dann wird der Aufruf von begin blockieren. Im Callback bekomme ich nun die Ownership über die Operation zurück (also auch vom Buffer etc.), sodass ich einen neuen Call mit der gleichen Operation machen kann. Das Resultat: 0 neue Allokationen, es werden nur noch Zeiger umgebogen. Das sollte was Performance angeht deutlich bemerkbar sein.

    Nun zu den Details:

    Die Implementierung dieses Ansatzes hat es in sich. Es war extrem schwer, intern im I/O-Completion-Port-Gedöhns den Typen vom operation -Objekt (der für den Benutzer der Library für immer auto bzw. ein Template-Argument bleiben sollte, da er extrem viele Template-Argumente hat) in's Callback zu retten.
    Im Kern ist jedes Operations-Objekt ein WSAOVERLAPPED . Bei der internen API-Notification bekomme ich nur einen Zeiger auf diese Struktur. Wenn ich nun innerhalb der Library den Callback aufrufe, dann kann die als Parameter prinzipiell nur noch einen Pointer auf eine polymorphe Basisklasse aller verschiedenen Operation-Insanzen geben, richtig?

    Beispiel:

    auto callback = [](std::unique_ptr<general_operation> opPtr) {
        // mache Sachen, die ein general_operation-Objekt kann
    }
    
    ...
    
    std::vector<char> buffer(512);
    auto operation = create_async_receive(callback, std::move(buffer));
    socket.begin(std::move(operation));
    

    Hierbei ist klar, dass ein Operations-Klassentemplate irgendwie so aussehen muss:

    template <typename CallbackType, typename BufferType>
    class async_receive_operation : public general_operation {
        CallbackType _cb;
        BufferType _buf
    public:
        operation(CallbackType&& cb, BufferType&& buf) : _cb(std::forward<CallbackType>(cb)), _buf(std::move(buf)) { }
    
    };
    

    (Die variadischen Templates, die ich noch drin habe, für nutzereigene Parameter, lasse ich der Einfachheit halber mal außen vor. Auch die Tatsache, dass ich frevelhaft eine Universal-Referenz move (der buf), sei mir hier verziehen, der richtige Code überprüft, ob der Buffer per move gegeben wurde und macht ein static_assert).

    Im Callback ist die Operation vom dynamischen Typen her zwar noch eine Receive-Operation, aber das gesamte Interface ist verloren, da der statische Typ ja nun eine general_operation ist. An den Buffer komme ich nun auch nicht mehr heran, denn nicht jede Operation braucht einen Buffer, also hat eine general_operation keine virtuellen Funktionen dafür.

    Dafür habe ich eine Lösung, aber sie kommt mit einem (vertretbarem) Preis: Callbacks können nur noch Lambdas/Funktoren sein (die dann natürlich jede beliebige Member-Funktion aufrufen können), aber keine Funktionszeiger, keine std::function oder std::bind -Objekte.
    Warum diese Einschränkung? Aus folgendem Grund:
    Ich benutze das Curiously recurring template pattern für die Operations-Objekte. Dadurch kann ich in der polymorphen Basisklasse, die den User-Callback dann aufruft, den this-Zeiger auf das Template-Argument, welches der Typ der abgeleiteten Klasse ist, casten, sodass ich im Callback dann tatsächlich den echten Operations-Typen bekomme.

    Wo ist dabei der Haken? Der Haken ist, dass alle Operations-Typen auch einen Template-Parameter für den Callback-Typen besitzen. Die Callback-Funktion hat einen Parameter, das Operations-Objekt, welches aber wiederrum die Callback-Funktion als Template-Argument enthält, die Template-Parameter sind zyklisch voneinander abhängig!
    Beispiel:

    void callback(std::unique_ptr<receive_operation<void (std::unique_ptr<receive_operation<void (std::unique_ptr<receive_operation<void (std::unique_ptr<receive_operation ...
    

    Die "offensichtliche" Lösung für das Problem wäre, für die Callback-Funktion ein Template zu benutzen:

    template <typename OperationType>
    void callback(std::unique_ptr<OperationType> op) { ... }
    

    Das funktioniert leider nicht, denn beim Aufruf des Konstruktors der operation , welcher als Parameter den callback hat, müsste man ja dann wieder den Typen des Parameter-Callbacks angeben, welche wieder zyklisch wäre (C++ bietet AFAIK keine Möglichkeit an, Funktions-Templates als Template-Parameter anzugeben, sondern nur Klassentemplates). Die naheliegende Lösung sind also generische Funktoren:

    struct callback_functor {
        template <typename OperationType>
        void operator ()(std::unque_ptr<OperationType> op) { ... }
    };
    

    Da meine Library intern C++14 benutzt, kann man auch gleich generalized lambdas benutzen:

    auto callback = [](auto operation_ptr) { ... };
    

    Der Ansatz wirkt für mich vielversprechend, ich freue mich schon auf die ersten Resultate der Performance-Tests.



  • Noch eine kleine Anmerkung: jetzt kann man auch gezielt einzelne Operationen abbrechen:

    auto handle = socket.begin(create_receive_operation(...));
    if(!socket.cancel(handle)) {
       // Abbruch schlug fehl, da die Operation entweder schon abgeschlossen ist oder wegen eines Fehlers nicht gestartet wurde
    

Anmelden zum Antworten