Socket-Programmierung HOWTO¶
- Autor:
Gordon McMillan
Sockets¶
Ich werde nur über INET-Sockets (d.h. IPv4) sprechen, aber sie machen mindestens 99 % der verwendeten Sockets aus. Und ich werde nur über STREAM-Sockets (d.h. TCP) sprechen – es sei denn, Sie wissen wirklich, was Sie tun (in diesem Fall ist dieses HOWTO nichts für Sie!), werden Sie mit einem STREAM-Socket ein besseres Verhalten und eine bessere Leistung erzielen als mit jedem anderen. Ich werde versuchen, das Rätsel um Sockets zu lösen und einige Tipps zu geben, wie man mit blockierenden und nicht-blockierenden Sockets arbeitet. Aber ich beginne mit blockierenden Sockets. Sie müssen wissen, wie sie funktionieren, bevor Sie sich mit nicht-blockierenden Sockets befassen.
Teil des Problems beim Verständnis dieser Dinge ist, dass „Socket“ je nach Kontext eine Reihe von subtil unterschiedlichen Dingen bedeuten kann. Machen wir also zuerst eine Unterscheidung zwischen einem „Client“-Socket – einem Endpunkt einer Konversation – und einem „Server“-Socket, der eher einem Vermittlungsbeamten ähnelt. Die Client-Anwendung (Ihr Browser zum Beispiel) verwendet ausschließlich „Client“-Sockets; der Webserver, mit dem sie spricht, verwendet sowohl „Server“-Sockets als auch „Client“-Sockets.
Geschichte¶
Von den verschiedenen Formen der IPC sind Sockets mit Abstand die beliebtesten. Auf jeder Plattform gibt es wahrscheinlich andere IPC-Formen, die schneller sind, aber für plattformübergreifende Kommunikation sind Sockets so ziemlich die einzige Option.
Sie wurden in Berkeley als Teil des BSD-Flavors von Unix erfunden. Sie verbreiteten sich mit dem Internet wie ein Lauffeuer. Aus gutem Grund – die Kombination von Sockets mit INET macht die Kommunikation mit beliebigen Rechnern auf der ganzen Welt unglaublich einfach (zumindest im Vergleich zu anderen Systemen).
Einen Socket erstellen¶
Grob gesagt, als Sie auf den Link klickten, der Sie zu dieser Seite führte, tat Ihr Browser ungefähr Folgendes:
# create an INET, STREAMing socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# now connect to the web server on port 80 - the normal http port
s.connect(("www.python.org", 80))
Wenn der connect abgeschlossen ist, kann der Socket s verwendet werden, um eine Anfrage für den Text der Seite zu senden. Derselbe Socket liest die Antwort und wird dann zerstört. Das stimmt, zerstört. Client-Sockets werden normalerweise nur für einen Austausch (oder eine kleine Reihe sequenzieller Austausche) verwendet.
Was im Webserver passiert, ist etwas komplexer. Zuerst erstellt der Webserver einen „Server-Socket“
# create an INET, STREAMing socket
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# bind the socket to a public host, and a well-known port
serversocket.bind((socket.gethostname(), 80))
# become a server socket
serversocket.listen(5)
Ein paar Dinge, die man bemerken sollte: Wir haben socket.gethostname() verwendet, damit der Socket für die Außenwelt sichtbar ist. Hätten wir s.bind(('localhost', 80)) oder s.bind(('127.0.0.1', 80)) verwendet, hätten wir immer noch einen „Server“-Socket, aber einen, der nur innerhalb derselben Maschine sichtbar war. s.bind(('', 80)) gibt an, dass der Socket über jede beliebige Adresse des Rechners erreichbar ist.
Eine zweite Anmerkung: Niedrige Portnummern sind normalerweise für „bekannte“ Dienste (HTTP, SNMP usw.) reserviert. Wenn Sie herumspielen, verwenden Sie eine schöne hohe Nummer (4 Ziffern).
Schließlich teilt das Argument für listen der Socket-Bibliothek mit, dass wir möchten, dass sie bis zu 5 Verbindungsanfragen (der normale Maximalwert) in die Warteschlange stellt, bevor externe Verbindungen verweigert werden. Wenn der Rest des Codes richtig geschrieben ist, sollte das ausreichen.
Nachdem wir nun einen „Server“-Socket haben, der auf Port 80 lauscht, können wir die Hauptschleife des Webservers betreten:
while True:
# accept connections from outside
(clientsocket, address) = serversocket.accept()
# now do something with the clientsocket
# in this case, we'll pretend this is a threaded server
ct = make_client_thread(clientsocket)
ct.start()
Es gibt tatsächlich 3 allgemeine Möglichkeiten, wie diese Schleife funktionieren könnte: eineoutine zur Behandlung des clientsocket, Erstellen eines neuen Prozesses zur Behandlung des clientsocket oder Umstrukturieren dieser App zur Verwendung von nicht-blockierenden Sockets und Multiplexing zwischen unserem „Server“-Socket und allen aktiven clientsockets mit select. Mehr dazu später. Wichtig ist jetzt zu verstehen: Das ist *alles*, was ein „Server“-Socket tut. Er sendet keine Daten. Er empfängt keine Daten. Er erzeugt nur „Client“-Sockets. Jeder clientsocket wird als Reaktion darauf erstellt, dass *ein anderer* „Client“-Socket ein connect() an den Host und Port sendet, an den wir gebunden sind. Sobald wir diesen clientsocket erstellt haben, kehren wir zum Lauschen auf weitere Verbindungen zurück. Die beiden „Clients“ können sich frei unterhalten – sie verwenden einen dynamisch zugewiesenen Port, der am Ende des Gesprächs wiederverwendet wird.
IPC¶
Wenn Sie eine schnelle IPC zwischen zwei Prozessen auf einem Rechner benötigen, sollten Sie sich mit Pipes oder Shared Memory beschäftigen. Wenn Sie sich dennoch für AF_INET-Sockets entscheiden, binden Sie den „Server“-Socket an 'localhost'. Auf den meisten Plattformen wird dadurch eine Ebene des Netzwerkcodes übersprungen und er ist erheblich schneller.
Siehe auch
Das multiprocessing integriert plattformübergreifende IPC in eine höherwertige API.
Einen Socket verwenden¶
Zunächst ist festzuhalten, dass der „Client“-Socket des Webbrowsers und der „Client“-Socket des Webservers identische Wesen sind. Das heißt, dies ist eine „Peer-to-Peer“-Konversation. Oder anders ausgedrückt: *Als Designer müssen Sie entscheiden, wie die Verhaltensregeln für eine Konversation aussehen.* Normalerweise beginnt der connectende Socket die Konversation, indem er eine Anfrage sendet oder vielleicht eine Anmeldung. Aber das ist eine Designentscheidung – es ist keine Regel von Sockets.
Nun gibt es zwei Sätze von Verben für die Kommunikation. Sie können send und recv verwenden, oder Sie können Ihren Client-Socket in ein dateiähnliches Objekt verwandeln und read und write verwenden. Letzteres ist der Weg, wie Java seine Sockets präsentiert. Ich werde hier nicht darüber sprechen, außer Sie davor zu warnen, dass Sie flush auf Sockets verwenden müssen. Dies sind gepufferte „Dateien“, und ein häufiger Fehler ist, etwas zu writeen und dann auf eine Antwort zu readen. Ohne ein flush darin warten Sie möglicherweise ewig auf die Antwort, da die Anfrage möglicherweise noch in Ihrem Ausgabepuffer liegt.
Nun kommen wir zum größten Stolperstein von Sockets – send und recv arbeiten mit den Netzwerkpuffern. Sie behandeln nicht unbedingt alle Bytes, die Sie ihnen übergeben (oder von ihnen erwarten), da ihr Hauptaugenmerk auf der Behandlung der Netzwerkpuffer liegt. Im Allgemeinen kehren sie zurück, wenn die zugehörigen Netzwerkpuffer gefüllt (send) oder geleert (recv) wurden. Sie sagen Ihnen dann, wie viele Bytes sie verarbeitet haben. Es liegt *Ihre* Verantwortung, sie erneut aufzurufen, bis Ihre Nachricht vollständig verarbeitet wurde.
Wenn ein recv 0 Bytes zurückgibt, bedeutet dies, dass die andere Seite die Verbindung geschlossen hat (oder gerade schließt). Sie werden keine weiteren Daten über diese Verbindung erhalten. Niemals. Möglicherweise können Sie noch Daten erfolgreich senden; dazu später mehr.
Ein Protokoll wie HTTP verwendet einen Socket nur für eine einzige Übertragung. Der Client sendet eine Anfrage, liest dann eine Antwort. Das war's. Der Socket wird verworfen. Das bedeutet, dass ein Client das Ende der Antwort erkennen kann, indem er 0 Bytes empfängt.
Aber wenn Sie vorhaben, Ihren Socket für weitere Übertragungen wiederzuverwenden, müssen Sie bedenken, dass es *kein* EOT auf einem Socket gibt. Ich wiederhole: Wenn ein Socket send oder recv nach der Verarbeitung von 0 Bytes zurückkehrt, ist die Verbindung unterbrochen worden. Wenn die Verbindung *nicht* unterbrochen wurde, können Sie bei einem recv ewig warten, da der Socket Ihnen *nicht* mitteilt, dass (vorerst) nichts mehr zu lesen ist. Wenn Sie darüber nachdenken, werden Sie zu einer grundlegenden Wahrheit von Sockets gelangen: *Nachrichten müssen entweder eine feste Länge haben* (igitt), *oder abgegrenzt sein* (schulterzuckend), *oder angeben, wie lang sie sind* (viel besser), *oder durch das Herunterfahren der Verbindung enden*. Die Wahl liegt ganz bei Ihnen (aber einige Wege sind richtiger als andere).
Unter der Annahme, dass Sie die Verbindung nicht beenden möchten, ist die einfachste Lösung eine Nachricht mit fester Länge:
class MySocket:
"""demonstration class only
- coded for clarity, not efficiency
"""
def __init__(self, sock=None):
if sock is None:
self.sock = socket.socket(
socket.AF_INET, socket.SOCK_STREAM)
else:
self.sock = sock
def connect(self, host, port):
self.sock.connect((host, port))
def mysend(self, msg):
totalsent = 0
while totalsent < MSGLEN:
sent = self.sock.send(msg[totalsent:])
if sent == 0:
raise RuntimeError("socket connection broken")
totalsent = totalsent + sent
def myreceive(self):
chunks = []
bytes_recd = 0
while bytes_recd < MSGLEN:
chunk = self.sock.recv(min(MSGLEN - bytes_recd, 2048))
if chunk == b'':
raise RuntimeError("socket connection broken")
chunks.append(chunk)
bytes_recd = bytes_recd + len(chunk)
return b''.join(chunks)
Der sendende Code hier ist für fast jedes Nachrichtenschema verwendbar – in Python senden Sie Zeichenketten und können len() verwenden, um seine Länge zu bestimmen (auch wenn er eingebettete \0-Zeichen enthält). Es ist hauptsächlich der empfangende Code, der komplexer wird. (Und in C ist es nicht viel schlimmer, außer dass Sie strlen nicht verwenden können, wenn die Nachricht eingebettete \0s enthält.)
Die einfachste Erweiterung besteht darin, das erste Zeichen der Nachricht zu einem Indikator für den Nachrichtentyp zu machen und den Typ die Länge bestimmen zu lassen. Nun haben Sie zwei recvs – das erste, um (mindestens) dieses erste Zeichen zu erhalten, damit Sie die Länge nachschlagen können, und das zweite in einer Schleife, um den Rest zu erhalten. Wenn Sie sich für die delimited Route entscheiden, empfangen Sie in einer beliebigen Chunk-Größe (4096 oder 8192 passt häufig gut zu Netzwerkpuffergrößen) und scannen das Empfangene nach einem Trennzeichen.
Eine Komplikation, die Sie beachten sollten: Wenn Ihr Konversationsprotokoll erlaubt, dass mehrere Nachrichten nacheinander gesendet werden (ohne eine Art Antwort), und Sie recv eine beliebige Chunk-Größe übergeben, können Sie am Anfang einer nachfolgenden Nachricht lesen. Sie müssen dies beiseitelegen und behalten, bis es benötigt wird.
Das Präfix der Nachricht mit ihrer Länge (sagen wir, als 5 numerische Zeichen) wird komplexer, weil Sie (glauben Sie es oder nicht) möglicherweise nicht alle 5 Zeichen in einem einzigen recv erhalten. Beim Herumspielen kommen Sie damit durch; aber bei hoher Netzwerklast wird Ihr Code sehr schnell kaputtgehen, es sei denn, Sie verwenden zwei recv-Schleifen – die erste, um die Länge zu ermitteln, die zweite, um den Datenteil der Nachricht zu erhalten. Übel. Dies ist auch der Zeitpunkt, an dem Sie feststellen werden, dass send nicht immer alles in einem Durchgang loswird. Und obwohl Sie dies gelesen haben, werden Sie letztendlich davon betroffen sein!
Aus Platzgründen, um Ihren Charakter aufzubauen (und meine Wettbewerbsposition zu wahren), werden diese Erweiterungen als Übung für den Leser überlassen. Gehen wir zur Bereinigung.
Binäre Daten¶
Es ist durchaus möglich, Binärdaten über einen Socket zu senden. Das Hauptproblem ist, dass nicht alle Rechner die gleichen Formate für Binärdaten verwenden. Zum Beispiel ist die Netzwerk-Byte-Reihenfolge Big-Endian, mit dem höchstwertigen Byte zuerst. Eine 16-Bit-Ganzzahl mit dem Wert 1 wäre also die beiden Hex-Bytes 00 01. Die meisten gängigen Prozessoren (x86/AMD64, ARM, RISC-V) sind jedoch Little-Endian, mit dem niederwertigsten Byte zuerst – dieselbe 1 wäre 01 00.
Socket-Bibliotheken bieten Funktionen zur Konvertierung von 16- und 32-Bit-Ganzzahlen – ntohl, htonl, ntohs, htons, wobei „n“ für *Netzwerk* und „h“ für *Host* steht, „s“ für *short* (kurz) und „l“ für *long* (lang). Wo die Netzwerkreihenfolge die Host-Reihenfolge ist, tun diese nichts, aber wo die Maschine Byte-weise vertauscht ist, tauschen diese die Bytes entsprechend.
In diesen Zeiten von 64-Bit-Rechnern ist die ASCII-Darstellung von Binärdaten häufig kleiner als die Binärdarstellung. Das liegt daran, dass ein überraschender Teil der Zeit die meisten Ganzzahlen den Wert 0 oder vielleicht 1 haben. Die Zeichenkette "0" wäre zwei Bytes, während eine vollständige 64-Bit-Ganzzahl 8 wäre. Natürlich passt das nicht gut zu Nachrichten mit fester Länge. Entscheidungen, Entscheidungen.
Trennen¶
Streng genommen sollten Sie vor dem close eines Sockets shutdown verwenden. Der shutdown ist eine Mitteilung an den Socket am anderen Ende. Abhängig vom übergebenen Argument kann er bedeuten: „Ich sende nichts mehr, höre aber noch zu“ oder „Ich höre nicht zu, gute Nacht!“. Die meisten Socket-Bibliotheken sind jedoch so daran gewöhnt, dass Programmierer diesen Teil der Etikette vernachlässigen, dass normalerweise ein close dasselbe ist wie shutdown(); close(). Daher ist in den meisten Situationen kein explizites shutdown erforderlich.
Eine Möglichkeit, shutdown effektiv zu nutzen, ist ein HTTP-ähnlicher Austausch. Der Client sendet eine Anfrage und führt dann ein shutdown(1) aus. Dies teilt dem Server mit: „Dieser Client hat das Senden beendet, kann aber immer noch empfangen.“ Der Server kann „EOF“ durch ein Empfangsereignis von 0 Bytes erkennen. Er kann davon ausgehen, die vollständige Anfrage erhalten zu haben. Der Server sendet eine Antwort. Wenn der send erfolgreich abgeschlossen wird, empfing der Client tatsächlich noch.
Python geht beim automatischen Abschalten noch einen Schritt weiter und besagt, dass beim Garbage Collecting eines Sockets automatisch ein close durchgeführt wird, falls erforderlich. Sich darauf zu verlassen, ist jedoch eine sehr schlechte Angewohnheit. Wenn Ihr Socket einfach verschwindet, ohne close auszuführen, kann der Socket am anderen Ende unbegrenzt hängen bleiben und denken, Sie seien nur langsam. Bitte closeen Sie Ihre Sockets, wenn Sie fertig sind.
Wenn Sockets sterben¶
Das Schlimmste an der Verwendung blockierender Sockets ist wahrscheinlich, was passiert, wenn die andere Seite hart abstürzt (ohne close auszuführen). Ihr Socket wird wahrscheinlich hängen bleiben. TCP ist ein zuverlässiges Protokoll und wartet sehr lange, bevor es eine Verbindung aufgibt. Wenn Sie Threads verwenden, ist der gesamte Thread im Wesentlichen tot. Dagegen kann man wenig tun. Solange Sie nichts Dummes tun, wie z. B. eine Sperre zu halten, während Sie auf eine blockierende Leseoperation warten, verbraucht der Thread nicht viele Ressourcen. Versuchen Sie *nicht*, den Thread zu beenden – ein Teil des Grundes, warum Threads effizienter als Prozesse sind, ist, dass sie den Overhead für das automatische Recycling von Ressourcen vermeiden. Mit anderen Worten, wenn Sie es schaffen, den Thread zu beenden, wird Ihr gesamter Prozess wahrscheinlich ruiniert sein.
Nicht-blockierende Sockets¶
Wenn Sie das Vorherige verstanden haben, wissen Sie bereits das meiste, was Sie über die Mechanik der Socket-Verwendung wissen müssen. Sie werden immer noch dieselben Aufrufe auf ähnliche Weise verwenden. Es ist nur so, dass Ihre App, wenn Sie es richtig machen, fast umgekehrt sein wird.
In Python verwenden Sie socket.setblocking(False), um ihn nicht-blockierend zu machen. In C ist es komplexer (zum einen müssen Sie zwischen dem BSD-Flavour O_NONBLOCK und dem fast identischen POSIX-Flavour O_NDELAY wählen, was völlig anders ist als TCP_NODELAY), aber es ist dasselbe Prinzip. Dies tun Sie nach der Erstellung des Sockets, aber bevor Sie ihn verwenden. (Tatsächlich können Sie, wenn Sie verrückt sind, hin und her schalten.)
Der Hauptunterschied in der Mechanik besteht darin, dass send, recv, connect und accept zurückkehren können, ohne etwas getan zu haben. Sie haben (natürlich) eine Reihe von Optionen. Sie können den Rückgabecode und Fehlercodes prüfen und sich damit verrückt machen. Wenn Sie mir nicht glauben, probieren Sie es aus. Ihre App wird groß, fehlerhaft und CPU-hungrig. Lassen Sie uns also die hirnlosen Lösungen überspringen und es richtig machen.
Verwenden Sie select.
In C ist das Codieren von select ziemlich komplex. In Python ist es ein Kinderspiel, aber es ist nah genug an der C-Version, dass Sie, wenn Sie select in Python verstehen, wenig Probleme damit in C haben werden.
ready_to_read, ready_to_write, in_error = \
select.select(
potential_readers,
potential_writers,
potential_errs,
timeout)
Sie übergeben select drei Listen: die erste enthält alle Sockets, von denen Sie möglicherweise lesen möchten; die zweite alle Sockets, an die Sie möglicherweise schreiben möchten, und die letzte (normalerweise leer gelassen) diejenigen, bei denen Sie auf Fehler prüfen möchten. Beachten Sie, dass ein Socket in mehr als eine Liste aufgenommen werden kann. Der select-Aufruf ist blockierend, aber Sie können ihm ein Timeout geben. Dies ist im Allgemeinen sinnvoll – geben Sie ihm ein schön langes Timeout (sagen wir eine Minute), es sei denn, Sie haben einen guten Grund, dies nicht zu tun.
Im Gegenzug erhalten Sie drei Listen. Sie enthalten die Sockets, die tatsächlich lesbar, schreibbar oder fehlerhaft sind. Jede dieser Listen ist eine Teilmenge (möglicherweise leer) der entsprechenden Liste, die Sie übergeben haben.
Wenn ein Socket in der Ausgabe-Leseliste steht, können Sie sich fast sicher sein, dass ein recv auf diesem Socket *etwas* zurückgeben wird. Gleiches gilt für die Schreibliste. Sie werden *etwas* senden können. Vielleicht nicht alles, was Sie wollen, aber *etwas* ist besser als nichts. (Tatsächlich gibt jeder einigermaßen gesunde Socket als schreibbar zurück – es bedeutet nur, dass ausgehender Netzwerkpufferplatz verfügbar ist.)
Wenn Sie einen „Server“-Socket haben, legen Sie ihn in die potential_readers-Liste. Wenn er in der Leseliste erscheint, wird Ihr accept (fast sicher) funktionieren. Wenn Sie einen neuen Socket zum connecten zu jemand anderem erstellt haben, legen Sie ihn in die potential_writers-Liste. Wenn er in der Schreibliste erscheint, haben Sie eine gute Chance, dass er verbunden wurde.
Tatsächlich kann select auch bei blockierenden Sockets nützlich sein. Es ist eine Möglichkeit festzustellen, ob Sie blockieren werden – der Socket gibt als lesbar zurück, wenn sich etwas in den Puffern befindet. Dies hilft jedoch immer noch nicht bei der Feststellung, ob das andere Ende fertig ist oder nur mit etwas anderem beschäftigt ist.
Portabilitätshinweis: Unter Unix funktioniert select sowohl mit Sockets als auch mit Dateien. Versuchen Sie das nicht unter Windows. Unter Windows funktioniert select nur mit Sockets. Beachten Sie auch, dass viele der fortgeschritteneren Socket-Optionen in C unter Windows anders gehandhabt werden. Tatsächlich verwende ich unter Windows normalerweise Threads (die sehr, sehr gut funktionieren) mit meinen Sockets.