Pixockets: Wie wir unsere eigene Netzwerkbibliothek für den Spieleserver geschrieben haben



Hallo! Connected Stanislav Yablonsky, leitender Serverentwickler von Pixonic.

Als ich zu Pixonic kam, waren unsere Spieleserver Anwendungen, die auf dem Photon Realtime SDK basierten : ein multifunktionales, aber sehr schweres Framework. Diese Lösung schien die Arbeit mit dem Server zu vereinfachen. So war es - bis zu einem gewissen Punkt.

Photon Realtime hat uns an sich selbst gebunden, indem wir damit Daten zwischen Playern und dem Server austauschen mussten - und es auch an Windows gebunden, da es nur darauf funktionieren kann. Dies führte zu Einschränkungen sowohl zur Laufzeit (Laufzeit): Es war unmöglich, viele wichtige Einstellungen der virtuellen .NET-Maschine und des Betriebssystems zu ändern. Wir sind es gewohnt, mit Linux-Servern zu arbeiten, nicht mit Windows. Außerdem kosten sie uns weniger.

Außerdem hat die Verwendung von Photon die Leistung sowohl auf dem Server als auch auf dem Client beeinträchtigt, und bei der Profilerstellung hat sich eine anständige Belastung des Garbage Collectors und eine große Menge an Boxen / Unboxing gebildet.

Kurz gesagt, die Lösung mit Photon Realtime war für uns alles andere als optimal, und lange Zeit war es notwendig, etwas damit zu tun - aber es gab immer dringendere Aufgaben, und die Hände erreichten nicht die Lösung von Problemen mit dem Server.

Da es für mich interessant war, nicht nur das Problem zu lösen, sondern auch das Netzwerk besser zu verstehen, habe ich beschlossen, die Initiative selbst zu ergreifen und zu versuchen, selbst eine Bibliothek zu schreiben. Aber Sie verstehen, zu Hause - zu Hause, bei der Arbeit - bei der Arbeit, daher war die Zeit für die Entwicklung der Bibliothek nur im Transport. Dies hinderte die Idee jedoch nicht daran, Wirklichkeit zu werden.

Was dabei herauskam - lesen Sie weiter.

Bibliotheksideologie


Da wir Online-Spiele entwickeln, ist es für uns sehr wichtig, ohne Pausen zu arbeiten. Daher sind geringe Gemeinkosten zur Hauptanforderung für die Bibliothek geworden. Für uns ist dies vor allem eine geringe Belastung des Garbage Collectors. Um dies zu erreichen, habe ich versucht, Zuordnungen zu vermeiden, und in Fällen, in denen es schwierig war oder überhaupt nicht funktioniert hat, haben wir Pools erstellt (für Byte-Puffer, Verbindungsstatus, Header usw.).

Zur Vereinfachung und Bequemlichkeit der Unterstützung und Montage haben wir begonnen, nur C # - und Systembuchsen zu verwenden. Außerdem war es wichtig, in das Zeitbudget pro Frame zu passen, da die Daten vom Server pünktlich ankommen sollten. Daher habe ich versucht, die Ausführungszeit zu verkürzen, auch auf Kosten einer Nichtoptimalität: Das heißt, an einigen Stellen hat es sich gelohnt, die schnellen und teilweise komplexeren Algorithmen und Datenstrukturen durch einfachere und vorhersehbarere zu ersetzen. Zum Beispiel haben wir keine sperrenfreien Warteschlangen verwendet, da sie den Garbage Collector belasten.

Typischerweise werden unsere Daten für Multiplayer-Shooter über UDP gesendet. Hinzu kam die Fragmentierung und Zusammenstellung von Paketen zum Senden von Daten, die größer als die Rahmengröße sind, sowie eine zuverlässige Zustellung aufgrund der Weiterleitung und des Verbindungsaufbaus.

Der UDP-Frame in unserer Bibliothek ist standardmäßig auf 1200 Byte eingestellt. Pakete dieser Größe sollten in modernen Netzwerken mit einem relativ geringen Fragmentierungsrisiko übertragen werden, da die MTU in den meisten modernen Netzwerken höher als dieser Wert ist. Gleichzeitig reicht dieser Betrag normalerweise aus, um den Änderungen zu entsprechen, die nach dem nächsten Tick (Statusaktualisierung) im Spiel an den Spieler gesendet werden müssen.

Die Architektur


In unserer Bibliothek verwenden wir einen zweischichtigen Socket:

  • Die erste Schicht ist für die Arbeit mit Systemaufrufen verantwortlich und bietet eine bequemere API für die nächste Ebene.
  • Die zweite Schicht ist die direkte Arbeit mit der Sitzung, Fragmentierung / Zusammenstellung von Paketen, deren Weiterleitung usw.



Die Klasse für die Arbeit mit Verbindungen ist wiederum in zwei Ebenen unterteilt:

  • Die untere Ebene (SockBase) ist für das Senden und Empfangen von Daten über UDP verantwortlich. Es ist ein dünner Wrapper über einem Socket-Systemobjekt.
  • Top Level (SmartSock) bietet zusätzliche Funktionen über UDP. Pakete schneiden und kleben, nicht erreichte Daten weiterleiten, Duplikate ablehnen - all dies liegt in seinem Verantwortungsbereich.

Die untere Ebene ist in zwei Klassen unterteilt: BareSock und ThreadSock.

  • BareSock arbeitet in demselben Thread, aus dem der Anruf stammt, und sendet und empfängt Daten im nicht blockierenden Modus.
  • ThreadSock stellt Pakete in Warteschlangen und erstellt somit separate Threads zum Senden und Empfangen von Daten. Beim Zugriff gibt es nur einen Vorgang: Hinzufügen oder Entfernen von Daten zur Warteschlange.

BareSock wird häufig verwendet, um mit dem Client ThreadSock - mit dem Server - zu arbeiten.

Merkmale der Arbeit


Ich habe auch zwei Arten von Low-Level-Sockets geschrieben:

  • Der erste ist synchroner Single-Threaded. Darin erhalten wir den minimalen Overhead für Speicher und Prozessor, aber gleichzeitig treten Systemaufrufe direkt beim Zugriff auf den Socket auf. Dies minimiert im Allgemeinen den Overhead (es müssen keine Warteschlangen und zusätzlichen Puffer verwendet werden), aber der Anruf selbst kann länger dauern als das Entfernen eines Elements aus der Warteschlange.
  • Die zweite ist asynchron mit separaten Threads zum Lesen und Schreiben. In diesem Fall erhalten wir zusätzlichen Overhead für die Warteschlange, die Synchronisation und die Sende- / Empfangszeit (innerhalb weniger Millisekunden), da zum Zeitpunkt des Zugriffs auf den Socket der Lese- oder Schreibthread angehalten wird.

Wir haben auch versucht, SocketAsyncEventArgs zu verwenden - vielleicht die fortschrittlichste Netzwerk-API in .NET, die mir bekannt ist. Es stellte sich jedoch heraus, dass es für UDP wahrscheinlich nicht funktioniert: Der TCP-Stack funktioniert einwandfrei, aber UDP gibt Fehler beim Abrufen seltsam abgeschnittener Frames und sogar beim Absturz in .NET aus - als ob der Speicher im nativen Teil der virtuellen Maschine beschädigt wäre. Ich habe keine Beispiele für die Funktionsweise eines solchen Systems gefunden.

Ein weiteres wichtiges Merkmal unserer Bibliothek ist der reduzierte Datenverlust. Wir hatten den Eindruck, dass viele Bibliotheken alte Datenpakete verwerfen, um Duplikate zu entfernen, wie wir später aus eigener Erfahrung gesehen haben. Natürlich ist eine solche Implementierung viel einfacher, da in diesem Fall ein Zähler mit der Nummer des zuletzt eingetroffenen Frames ausreicht, aber es hat uns nicht sehr gut gefallen. Daher verwendet Pixockets einen Umlaufpuffer aus den Nummern der letzten Frames, um Duplikate herauszufiltern: Neu eingetroffene Nummern werden anstelle der alten überschrieben, und Duplikate werden unter den zuletzt empfangenen Frames gesucht.



Wenn also ein Paket vor dem aktuellen Frame gesendet wurde, aber danach kam, erreicht es immer noch das Ziel. Dies kann beispielsweise bei der Positionsinterpolation sehr hilfreich sein. In diesem Fall haben wir eine vollständigere Geschichte.

Datenpaketstruktur


Die Daten in der Bibliothek werden wie folgt übertragen:



Am Anfang des Pakets befindet sich der Header:

  • Es beginnt mit der Größe des Pakets, das wiederum auf 64 Kilobyte begrenzt ist.
  • Auf die Größe folgt ein Byte mit Flags. Die Interpretation des restlichen Titels hängt von ihrer Verfügbarkeit ab.
  • Weiter ist die Kennung für die Sitzung oder Verbindung.

Mit den entsprechenden Flags erhalten wir dann:

  • Wenn das Flag mit der Paketnummer gesetzt ist, wird die Paketnummer nach der Sitzungskennung übertragen.
  • Ihm folgen - auch im Fall des gesetzten Flags - die Anzahl der bestätigten Pakete und deren Nummern.

Am Ende des Headers finden Sie Informationen zum Fragment:

  • Kennung der Fragmentsequenz, die notwendig ist, um Fragmente verschiedener Nachrichten zu unterscheiden;
  • Sequenznummer des Fragments;
  • Gesamtzahl der Fragmente in der Nachricht.

Für Informationen zum Fragment muss auch das entsprechende Flag gesetzt werden.

Die Bibliothek ist geschrieben. Was weiter?


Um genauere Informationen zur synchronen Verbindung zu erhalten, haben wir später eine explizite Verbindung organisiert. Dies hat uns geholfen, Situationen klar zu verstehen, in denen eine Seite denkt, dass die Verbindung hergestellt und nicht unterbrochen wurde, und die andere Seite, dass sie unterbrochen wurde.

In der ersten Version von Pixockets war dies nicht der Fall: Der Client musste die Connect-Methode (Host, Port) nicht aufrufen. Er begann lediglich, Daten an eine bekannte Adresse und einen bekannten Port zu senden. Dann rief der Server die Listen-Methode (Port) auf und begann, Daten von einer bestimmten Adresse zu empfangen. Die Sitzungsdaten wurden beim Empfang / Senden des Pakets initialisiert.

Um nun eine Verbindung herzustellen, ist ein „Handshake“ erforderlich geworden - der Austausch speziell gebildeter Pakete - und der Client muss Connect aufrufen.

Darüber hinaus hat einer meiner Kollegen die Bibliothek gespalten, der Netzwerksicherheit mehr Aufmerksamkeit geschenkt und einige Funktionen hinzugefügt, z. B. die Möglichkeit, die Verbindung direkt innerhalb des Sockets wiederherzustellen: Wenn Sie beispielsweise zwischen Wi-Fi und 4G wechseln, wird die Verbindung jetzt automatisch wiederhergestellt. Aber darüber werden wir später sprechen.

Testen


Natürlich haben wir Unit-Tests für die Bibliothek geschrieben: Sie überprüfen alle wichtigen Möglichkeiten zum Herstellen einer Verbindung, zum Senden und Empfangen von Daten, zum Fragmentieren und Zusammenstellen von Paketen sowie zu verschiedenen Anomalien beim Senden und Empfangen von Daten - wie Duplizieren, Verlust, Nichtübereinstimmung in der Reihenfolge des Sendens und Empfangens. Für die anfängliche Leistungsprüfung habe ich spezielle Testanwendungen für Integrationstests geschrieben: einen Ping-Client, einen Ping-Server und eine Anwendung, die die Position, Farbe und Anzahl der farbigen Kreise auf dem Bildschirm über das Netzwerk synchronisiert.

Nachdem die Testanwendungen die Effizienz unserer Bibliothek bewiesen hatten, haben wir begonnen, sie mit anderen Bibliotheken zu vergleichen: mit unserer alten Photon Realtime und mit der UDP-Bibliothek LiteNetLib 0.7.

Wir haben eine vereinfachte Version eines Spielservers getestet, der einfach Eingaben von Spielern sammelt und das „geklebte“ Ergebnis zurücksendet. Wir haben 500 Spieler in Räumen mit 6 Personen aufgenommen, die Bildwiederholfrequenz beträgt 30 Mal pro Sekunde.



Die Belastung des Garbage Collector- und Prozessorverbrauchs war bei Pixockets geringer, ebenso wie der Prozentsatz fehlender Pakete - anscheinend aufgrund der Tatsache, dass wir im Gegensatz zu anderen UDP-Versionen späte Pakete nicht ignorieren.

Nachdem wir die Bestätigung des Vorteils unserer Lösung in synthetischen Tests erhalten hatten, bestand der nächste Schritt darin, die Bibliothek in einem realen Projekt auszuführen.

Zu diesem Zeitpunkt wurden in dem von uns ausgewählten Projekt Clients und Spieleserver über Photon Server synchronisiert. Ich habe dem Client und dem Server die Unterstützung von Pixockets hinzugefügt, um die Auswahl des Protokolls vom Matchmaking-Server aus zu steuern, an den die Clients eine Anfrage zum Betreten des Spiels senden.

Einige Zeit lang spielten Clients gleichzeitig auf beiden Protokollen, und zu diesem Zeitpunkt sammelten wir Statistiken darüber, wie es ihnen ging. Am Ende der Statistiksammlung stellte sich heraus, dass sich die Ergebnisse nicht von synthetischen Tests unterscheiden: Die Belastung des Garbage Collectors und des Prozessors hat abgenommen, auch der Paketverlust. Gleichzeitig wurde der Ping etwas niedriger. Daher wurde die nächste Version des Spiels bereits vollständig auf Pixockets veröffentlicht, ohne das Photon Realtime SDK zu verwenden.



Zukunftspläne


Jetzt möchten wir die folgenden Funktionen in der Bibliothek implementieren:

  • Vereinfachte Verbindung: Jetzt funktioniert sie nicht mehr optimal. Nach dem Aufruf von Connect auf dem Client müssen Sie Read aufrufen, bis sich der Verbindungsstatus ändert.
  • Explizites Herunterfahren: Im Moment erfolgt das Herunterfahren auf der anderen Seite nur über den Timer.
  • Eingebaute Pings zur Aufrechterhaltung der Konnektivität;
  • Automatische Ermittlung der optimalen Bildgröße (jetzt wird nur noch eine Konstante verwendet).

Sie können Pixockets unter der Repository-Adresse anzeigen und an der Weiterentwicklung teilnehmen .

All Articles