HOWTO zur Socket-Programmierung¶
Sockets¶
Ich werde nur über INET-Sockets (also IPv4) sprechen, aber die machen mindestens 99 % der verwendeten Sockets aus. Und ich werde nur über STREAM-Sockets (also TCP) sprechen – wenn du nicht wirklich weißt, was du tust (dann ist dieses HOWTO nichts für dich!), bekommst du mit einem STREAM-Socket besseres Verhalten und besseren Durchsatz als mit allem anderen. Ich versuche, das Geheimnis zu lüften, was ein Socket eigentlich ist, und gebe ein paar Hinweise zum Umgang mit blockierenden und nicht blockierenden Sockets. Anfangen werde ich aber mit blockierenden Sockets. Du musst wissen, wie die funktionieren, bevor du dich mit nicht blockierenden befasst.
Ein Teil der Schwierigkeit beim Verständnis dieser Dinge liegt daran, dass „Socket“ je nach Zusammenhang eine ganze Reihe feiner Unterschiede bedeuten kann. Unterscheiden wir also zuerst zwischen einem „Client“-Socket – einem Endpunkt einer Unterhaltung – und einem „Server“-Socket, der mehr einer Telefonvermittlung gleicht. Die Clientanwendung (zum Beispiel dein Browser) verwendet ausschließlich „Client“-Sockets. Der Webserver, mit dem sie redet, verwendet sowohl „Server“- als auch „Client“-Sockets.
Geschichte¶
Von den verschiedenen Formen der IPC sind Sockets mit Abstand die beliebteste. Auf jeder einzelnen Plattform gibt es wahrscheinlich andere, schnellere Formen von IPC, aber für plattformübergreifende Kommunikation sind Sockets so ziemlich die einzige Möglichkeit.
Erfunden wurden sie in Berkeley als Teil der BSD-Variante von Unix. Mit dem Internet verbreiteten sie sich wie ein Lauffeuer. Und das mit gutem Grund – die Verbindung von Sockets mit INET macht es unglaublich leicht, mit beliebigen Rechnern auf der Welt zu reden (jedenfalls verglichen mit anderen Ansätzen).
Einen Socket erzeugen¶
Grob gesagt hat dein Browser, als du auf den Link geklickt hast, der dich auf diese Seite gebracht hat, etwa Folgendes getan:
# 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))
Ist das connect abgeschlossen, lässt sich der Socket s verwenden, um eine Anfrage nach dem Text der Seite zu senden. Derselbe Socket liest die Antwort und wird dann zerstört. Richtig, zerstört. Client-Sockets werden normalerweise nur für einen Austausch verwendet (oder eine kleine Reihe aufeinanderfolgender Austausche).
Was im Webserver passiert, ist etwas komplizierter. Zuerst erzeugt 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 fallen auf: 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 desselben Rechners sichtbar ist. s.bind(('', 80)) legt fest, dass der Socket über jede Adresse erreichbar ist, die der Rechner gerade hat.
Ein zweiter Punkt: Niedrige Portnummern sind üblicherweise für „allgemein bekannte“ Dienste reserviert (HTTP, SNMP usw.). Wenn du nur herumspielst, nimm eine schön hohe Nummer (vierstellig).
Und schließlich sagt das Argument von listen der Socket-Bibliothek, dass sie bis zu 5 Verbindungsanfragen (das übliche Maximum) einreihen soll, bevor sie Verbindungen von außen abweist. Ist der übrige Code sauber geschrieben, sollte das reichlich sein.
Jetzt, da wir einen „Server“-Socket haben, der an Port 80 lauscht, können wir in die Hauptschleife des Webservers eintreten:
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()
Tatsächlich gibt es 3 allgemeine Wege, wie diese Schleife arbeiten könnte: einen Thread losschicken, der clientsocket bedient, einen neuen Prozess für clientsocket erzeugen oder diese Anwendung so umbauen, dass sie nicht blockierende Sockets verwendet und mit select zwischen unserem „Server“-Socket und allen aktiven clientsocket-Objekten umschaltet. Dazu später mehr. Wichtig zu verstehen ist jetzt: Das ist alles, was ein „Server“-Socket tut. Er sendet keine Daten. Er empfängt keine Daten. Er erzeugt nur „Client“-Sockets. Jedes clientsocket entsteht als Reaktion darauf, dass irgendein anderer „Client“-Socket ein connect() auf den Host und Port macht, an die wir gebunden sind. Sobald wir dieses clientsocket erzeugt haben, kehren wir zum Lauschen auf weitere Verbindungen zurück. Die beiden „Clients“ können nach Herzenslust plaudern – sie verwenden einen dynamisch zugewiesenen Port, der nach dem Ende der Unterhaltung wiederverwendet wird.
IPC¶
Wenn du schnelle IPC zwischen zwei Prozessen auf einem Rechner brauchst, solltest du dir Pipes oder gemeinsamen Speicher ansehen. Entscheidest du dich doch für AF_INET-Sockets, binde den „Server“-Socket an 'localhost'. Auf den meisten Plattformen nimmt das eine Abkürzung um ein paar Schichten Netzwerkcode herum und ist ein gutes Stück schneller.
Siehe auch
multiprocessing bündelt plattformübergreifende IPC in einer API auf höherer Ebene.
Einen Socket verwenden¶
Als Erstes ist zu beachten, dass der „Client“-Socket des Webbrowsers und der „Client“-Socket des Webservers identische Biester sind. Das heißt, es ist eine Unterhaltung unter Gleichen. Oder anders gesagt: Du als Entwerfer musst entscheiden, welche Regeln des Umgangs für eine Unterhaltung gelten. Normalerweise beginnt der Socket, der das connect macht, die Unterhaltung, indem er eine Anfrage oder vielleicht eine Anmeldung schickt. Aber das ist eine Entwurfsentscheidung – es ist keine Regel von Sockets.
Nun gibt es zwei Sätze von Verben für die Kommunikation. Du kannst send und recv verwenden, oder du kannst deinen Client-Socket in ein dateiartiges Biest verwandeln und read und write verwenden. Letzteres ist der Weg, auf dem Java seine Sockets anbietet. Darüber werde ich hier nicht sprechen, außer um dich zu warnen, dass du bei Sockets flush verwenden musst. Das sind gepufferte „Dateien“, und ein verbreiteter Fehler ist, etwas zu write und dann auf eine Antwort zu read. Ohne ein flush dazwischen wartest du möglicherweise für immer auf die Antwort, weil die Anfrage noch in deinem Ausgabepuffer stecken kann.
Jetzt kommen wir zum großen Stolperstein bei Sockets: send und recv arbeiten auf den Netzwerkpuffern. Sie behandeln nicht zwangsläufig alle Bytes, die du ihnen gibst (oder von ihnen erwartest), weil ihr Hauptaugenmerk auf den Netzwerkpuffern liegt. Im Allgemeinen kehren sie zurück, sobald die zugehörigen Netzwerkpuffer gefüllt (send) oder geleert (recv) sind. Sie sagen dir dann, wie viele Bytes sie behandelt haben. Es ist deine Verantwortung, sie erneut aufzurufen, bis deine Nachricht vollständig abgearbeitet ist.
Gibt ein recv 0 Bytes zurück, bedeutet das, dass die andere Seite die Verbindung geschlossen hat (oder gerade schließt). Du wirst auf dieser Verbindung keine weiteren Daten empfangen. Nie mehr. Möglicherweise kannst du noch erfolgreich Daten senden. Dazu sage ich später mehr.
Ein Protokoll wie HTTP verwendet einen Socket für nur eine Übertragung. Der Client sendet eine Anfrage und liest dann eine Antwort. Das ist alles. Der Socket wird verworfen. Das heißt, dass ein Client das Ende der Antwort daran erkennen kann, dass er 0 Bytes empfängt.
Hast du aber vor, deinen Socket für weitere Übertragungen wiederzuverwenden, musst du dir klarmachen, dass es kein EOT auf einem Socket gibt. Ich wiederhole: Wenn ein send oder recv auf einem Socket zurückkehrt, nachdem es 0 Bytes behandelt hat, ist die Verbindung abgebrochen. Ist die Verbindung nicht abgebrochen, kannst du auf einem recv für immer warten, weil der Socket dir nicht sagt, dass es (vorläufig) nichts mehr zu lesen gibt. Wenn du darüber ein wenig nachdenkst, kommst du auf eine grundlegende Wahrheit über Sockets: Nachrichten müssen entweder eine feste Länge haben (pfui), oder abgegrenzt sein (Schulterzucken), oder angeben, wie lang sie sind (viel besser), oder damit enden, dass die Verbindung geschlossen wird. Die Wahl ist ganz deine (aber manche Wege sind richtiger als andere).
Wenn du die Verbindung nicht beenden willst, 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 Sendecode hier ist für fast jedes Nachrichtenschema brauchbar – in Python sendest du Zeichenketten und kannst mit len() deren Länge bestimmen (selbst wenn sie eingebettete \0-Zeichen enthält). Komplizierter wird es vor allem beim Empfangscode. (Und in C ist es nicht viel schlimmer, nur dass du strlen nicht verwenden kannst, wenn die Nachricht eingebettete \0 enthält.)
Die einfachste Verbesserung ist, das erste Zeichen der Nachricht zum Anzeiger des Nachrichtentyps zu machen und die Länge vom Typ bestimmen zu lassen. Nun hast du zwei recv-Aufrufe – den ersten, um (mindestens) dieses erste Zeichen zu bekommen, damit du die Länge nachschlagen kannst, und den zweiten in einer Schleife, um den Rest zu holen. Entscheidest du dich für den Weg über Trennzeichen, empfängst du in einer beliebigen Blockgröße (4096 oder 8192 passt häufig gut zu den Größen der Netzwerkpuffer) und durchsuchst das Empfangene nach einem Trennzeichen.
Eine Komplikation, die du kennen solltest: Wenn dein Unterhaltungsprotokoll erlaubt, mehrere Nachrichten unmittelbar hintereinander zu senden (ohne irgendeine Antwort), und du recv eine beliebige Blockgröße übergibst, liest du möglicherweise den Anfang einer folgenden Nachricht mit. Den musst du beiseitelegen und festhalten, bis er gebraucht wird.
Der Nachricht ihre Länge voranzustellen (etwa als 5 Ziffernzeichen) wird komplizierter, weil du (ob du es glaubst oder nicht) möglicherweise nicht alle 5 Zeichen in einem recv bekommst. Beim Herumspielen kommst du damit durch. Bei hoher Netzlast bricht dein Code aber sehr schnell, wenn du nicht zwei recv-Schleifen verwendest – die erste, um die Länge zu bestimmen, die zweite, um den Datenteil der Nachricht zu holen. Unschön. Dabei wirst du auch entdecken, dass send nicht immer schafft, alles in einem Durchgang loszuwerden. Und obwohl du das hier gelesen hast, wird es dich irgendwann doch erwischen!
Im Interesse des Platzes, deiner Charakterbildung (und der Erhaltung meines Wettbewerbsvorteils) bleiben diese Verbesserungen dem Leser als Übung überlassen. Gehen wir weiter zum Aufräumen.
Binärdaten¶
Es ist durchaus möglich, Binärdaten über einen Socket zu senden. Das Hauptproblem ist, dass nicht alle Rechner dieselben Formate für Binärdaten verwenden. Die Netzwerk-Bytereihenfolge ist zum Beispiel Big-Endian, mit dem höchstwertigen Byte zuerst. Eine 16-Bit-Ganzzahl mit dem Wert 1 wären also die zwei Hex-Bytes 00 01. Die meisten verbreiteten Prozessoren (x86/AMD64, ARM, RISC-V) sind jedoch Little-Endian, mit dem niederwertigsten Byte zuerst – dieselbe 1 wäre dort 01 00.
Socket-Bibliotheken haben Aufrufe, um 16- und 32-Bit-Ganzzahlen umzuwandeln: ntohl, htonl, ntohs, htons, wobei „n“ für network, „h“ für host, „s“ für short und „l“ für long steht. Wo die Netzwerkreihenfolge gleich der Rechnerreihenfolge ist, tun sie nichts, wo der Rechner die Bytes umgekehrt ablegt, tauschen sie sie passend um.
In diesen Zeiten der 64-Bit-Rechner ist die ASCII-Darstellung von Binärdaten häufig kleiner als die binäre Darstellung. Das liegt daran, dass die meisten Ganzzahlen überraschend oft den Wert 0 oder vielleicht 1 haben. Die Zeichenkette "0" wären zwei Bytes, während eine vollständige 64-Bit-Ganzzahl 8 belegt. Das passt natürlich nicht gut zu Nachrichten mit fester Länge. Entscheidungen, Entscheidungen.
Verbindung trennen¶
Streng genommen sollst du auf einem Socket shutdown verwenden, bevor du ihn mit close schließt. Das shutdown ist ein Hinweis an den Socket am anderen Ende. Je nach übergebenem Argument kann es bedeuten „Ich sende nichts mehr, aber ich lausche weiter“ oder „Ich lausche nicht mehr, auf Nimmerwiedersehen!“. Die meisten Socket-Bibliotheken sind allerdings so daran gewöhnt, dass Programmierer dieses Stück Höflichkeit auslassen, dass ein close normalerweise dasselbe ist wie shutdown(); close(). In den meisten Fällen ist ein ausdrückliches shutdown also nicht nötig.
Ein Weg, shutdown sinnvoll einzusetzen, ist ein HTTP-artiger Austausch. Der Client sendet eine Anfrage und macht dann ein shutdown(1). Das sagt dem Server: „Dieser Client ist mit dem Senden fertig, kann aber noch empfangen.“ Der Server kann das „EOF“ daran erkennen, dass er 0 Bytes empfängt. Er kann davon ausgehen, dass er die vollständige Anfrage hat. Der Server sendet eine Antwort. Ist das send erfolgreich abgeschlossen, hat der Client tatsächlich noch empfangen.
Python geht beim automatischen Herunterfahren noch einen Schritt weiter und sagt: Wird ein Socket von der Speicherbereinigung eingesammelt, macht er automatisch ein close, wenn eines nötig ist. Sich darauf zu verlassen, ist aber eine ganz schlechte Angewohnheit. Verschwindet dein Socket einfach, ohne ein close zu machen, hängt der Socket am anderen Ende möglicherweise unbegrenzt und denkt, du seist nur langsam. Bitte schließe deine Sockets mit close, wenn du fertig bist.
Wenn Sockets sterben¶
Das Schlimmste an blockierenden Sockets ist wahrscheinlich, was passiert, wenn die andere Seite hart abstürzt (ohne ein close zu machen). Dein Socket hängt dann wahrscheinlich. TCP ist ein zuverlässiges Protokoll und wartet sehr, sehr lange, bevor es eine Verbindung aufgibt. Wenn du Threads verwendest, ist der ganze Thread im Grunde tot. Dagegen kannst du nicht viel tun. Solange du nichts Dummes tust, etwa eine Sperre halten, während du blockierend liest, verbraucht der Thread eigentlich nicht viele Ressourcen. Versuche nicht, den Thread abzuschießen – Threads sind unter anderem deshalb effizienter als Prozesse, weil sie den Aufwand der automatischen Rückgewinnung von Ressourcen vermeiden. Anders gesagt: Wenn es dir gelingt, den Thread abzuschießen, ist wahrscheinlich dein ganzer Prozess im Eimer.
Nicht blockierende Sockets¶
Wenn du das Vorangegangene verstanden hast, weißt du schon das meiste von dem, was du über die Mechanik der Socket-Nutzung wissen musst. Du verwendest weiterhin dieselben Aufrufe, auf weitgehend dieselbe Weise. Nur dass deine Anwendung, wenn du es richtig machst, fast von innen nach außen gekehrt ist.
In Python machst du einen Socket mit socket.setblocking(False) nicht blockierend. In C ist das komplizierter (zum einen musst du dich zwischen der BSD-Variante O_NONBLOCK und der kaum unterscheidbaren POSIX-Variante O_NDELAY entscheiden, die wiederum etwas völlig anderes ist als TCP_NODELAY), aber die Idee ist genau dieselbe. Du machst das nach dem Erzeugen des Sockets, aber vor seiner Verwendung. (Wenn du verrückt bist, kannst du auch hin und her schalten.)
Der wesentliche mechanische Unterschied ist, dass send, recv, connect und accept zurückkehren können, ohne irgendetwas getan zu haben. Du hast (natürlich) eine Reihe von Möglichkeiten. Du kannst Rückgabewerte und Fehlercodes prüfen und dich damit allgemein in den Wahnsinn treiben. Wenn du mir nicht glaubst, probiere es irgendwann aus. Deine Anwendung wird groß, fehlerhaft und frisst CPU. Lassen wir also die hirntoten Lösungen und machen es richtig.
Verwende select.
In C ist select zu programmieren recht kompliziert. In Python ist es ein Kinderspiel, aber nah genug an der C-Fassung, dass du kaum Mühe damit hast, wenn du select in Python verstanden hast:
ready_to_read, ready_to_write, in_error = \
select.select(
potential_readers,
potential_writers,
potential_errs,
timeout)
Du übergibst select drei Listen: Die erste enthält alle Sockets, von denen du vielleicht lesen willst, die zweite alle Sockets, auf die du vielleicht schreiben willst, und die letzte (normalerweise leer gelassen) die, die du auf Fehler prüfen willst. Beachte, dass ein Socket in mehr als einer Liste stehen kann. Der Aufruf von select blockiert, aber du kannst ihm eine Zeitbeschränkung mitgeben. Das ist im Allgemeinen vernünftig – gib ihm eine schön lange Zeitbeschränkung (sagen wir eine Minute), sofern du keinen guten Grund für etwas anderes hast.
Zurück bekommst du drei Listen. Sie enthalten die Sockets, die tatsächlich lesbar und schreibbar sind oder einen Fehler haben. Jede dieser Listen ist eine (möglicherweise leere) Teilmenge der entsprechenden Liste, die du übergeben hast.
Steht ein Socket in der Ausgabeliste der lesbaren Sockets, kannst du so sicher sein, wie man es in diesem Geschäft überhaupt sein kann, dass ein recv auf diesem Socket irgendetwas zurückgibt. Dasselbe gilt für die Liste der schreibbaren Sockets. Du wirst irgendetwas senden können. Vielleicht nicht alles, was du willst, aber irgendetwas ist besser als nichts. (Tatsächlich meldet sich jeder halbwegs gesunde Socket als schreibbar – es heißt nur, dass im ausgehenden Netzwerkpuffer Platz ist.)
Wenn du einen „Server“-Socket hast, stecke ihn in die Liste potential_readers. Kommt er in der Liste der lesbaren Sockets heraus, wird dein accept (so gut wie sicher) funktionieren. Hast du einen neuen Socket erzeugt, um dich per connect mit jemandem zu verbinden, stecke ihn in die Liste potential_writers. Erscheint er in der Liste der schreibbaren Sockets, hast du gute Aussichten, dass er verbunden ist.
Tatsächlich kann select auch bei blockierenden Sockets praktisch sein. Es ist ein Weg, festzustellen, ob du blockieren wirst – der Socket meldet sich als lesbar, wenn etwas in den Puffern liegt. Allerdings hilft das immer noch nicht bei dem Problem, festzustellen, ob die andere Seite fertig oder nur mit etwas anderem beschäftigt ist.
Achtung, Portabilität: Unter Unix arbeitet select sowohl mit Sockets als auch mit Dateien. Versuche das nicht unter Windows. Unter Windows arbeitet select nur mit Sockets. Beachte außerdem, dass in C viele der fortgeschritteneren Socket-Optionen unter Windows anders gehandhabt werden. Tatsächlich verwende ich unter Windows mit meinen Sockets üblicherweise Threads (die sehr, sehr gut funktionieren).