HOWTO för programmering av socket¶
- Författare:
Gordon McMillan
Uttag¶
Jag kommer bara att prata om INET-socket (dvs. IPv4), men de står för minst 99% of av de socket som används. Och jag kommer bara att prata om STREAM-socket (dvs. TCP) - såvida du inte verkligen vet vad du gör (i vilket fall denna HOWTO inte är för dig!), Kommer du att få bättre beteende och prestanda från ett STREAM-socket än något annat. Jag ska försöka reda ut mysteriet med vad en socket är, samt ge några tips om hur man arbetar med blockerande och icke-blockerande sockets. Men jag börjar med att prata om blockerande socklar. Du måste veta hur de fungerar innan du kan ta itu med icke-blockerande socklar.
En del av problemet med att förstå dessa saker är att ”socket” kan betyda ett antal subtilt olika saker, beroende på sammanhang. Så låt oss först skilja mellan ett ”klient” -socket - en slutpunkt för en konversation och ett ”server” -socket, som är mer som en växeloperator. Klientapplikationen (till exempel din webbläsare) använder uteslutande ”klient” -socket; webbservern som den pratar med använder både ”server” -socket och ”klient” -socket.
Historik¶
Av de olika formerna av IPC är sockets de överlägset mest populära. På en viss plattform finns det sannolikt andra former av IPC som är snabbare, men för plattformsoberoende kommunikation är sockets i stort sett det enda som gäller.
De uppfanns i Berkeley som en del av BSD-varianten av Unix. De spred sig som en löpeld med internet. Med goda skäl — kombinationen av sockets med INET gör det otroligt enkelt att prata med godtyckliga maskiner runt om i världen (åtminstone jämfört med andra system).
Skapa en socket¶
När du klickade på länken som förde dig till den här sidan gjorde din webbläsare ungefär följande:
# skapa ett INET, STREAMing-socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# anslut nu till webbservern på port 80 - den normala http-porten
s.connect(("www.python.org", 80))
När connect
är klar kan sockeln s
användas för att skicka en begäran om texten på sidan. Samma socket kommer att läsa svaret och sedan förstöras. Just det, förstörs. Klientsocklar används normalt bara för ett utbyte (eller en liten uppsättning sekventiella utbyten).
Vad som händer i webbservern är lite mer komplicerat. Först skapar webbservern ett ”serversocket”:
# skapa ett INET, STREAMing-socket
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# binda socketet till en offentlig värd och en välkänd port
serversocket.bind((socket.gethostname(), 80))
# bli ett serversocket
serversocket.lyssna(5)
Ett par saker att notera: vi använde socket.gethostname()
så att socketet skulle vara synligt för omvärlden. Om vi hade använt s.bind(('localhost', 80))
eller s.bind(('127.0.0.1', 80))
skulle vi fortfarande ha ett ”server”-socket, men ett som bara var synligt inom samma maskin. s.bind(('', 80))
anger att socketet är nåbart med vilken adress som helst som maskinen råkar ha.
En annan sak att notera: portar med låga nummer är vanligtvis reserverade för ”välkända” tjänster (HTTP, SNMP etc). Om du vill leka lite, använd ett högt nummer (4 siffror).
Slutligen talar argumentet till listen
om för socket-biblioteket att vi vill att det ska köa upp till 5 anslutningsbegäranden (det normala maxantalet) innan det nekar anslutningar utifrån. Om resten av koden är korrekt skriven borde det räcka.
Nu när vi har ett ”server”-socket som lyssnar på port 80 kan vi gå in i webbserverns huvudloop:
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()
Det finns faktiskt tre allmänna sätt på vilka den här slingan kan fungera - skicka en tråd för att hantera clientsocket
, skapa en ny process för att hantera clientsocket
, eller omstrukturera den här appen för att använda icke-blockerande socket och multiplexera mellan vårt ”server” -socket och alla aktiva clientsocket
med select
. Mer om det senare. Det viktiga att förstå nu är detta: det här är * allt * ett ”server” -socket gör. Den skickar inte någon data. Den tar inte emot några data. Den producerar bara ”klient”-socket. Varje clientsocket
skapas som svar på att någon annan ”client” socket gör en connect()
till den host och port vi är bundna till. Så snart vi har skapat det clientsocket
, går vi tillbaka till att lyssna efter fler anslutningar. De två ”klienterna” är fria att chatta - de använder en dynamiskt allokerad port som kommer att återvinnas när konversationen avslutas.
IPC¶
Om du behöver snabb IPC mellan två processer på en maskin bör du titta på pipes eller delat minne. Om du bestämmer dig för att använda AF_INET-sockets, bind ”server”-socketen till 'localhost
. På de flesta plattformar kommer detta att ta en genväg runt ett par lager av nätverkskod och vara ganska mycket snabbare.
Se även
multiprocessing
integrerar plattformsoberoende IPC i ett API på högre nivå.
Använda ett socket¶
Det första man bör notera är att webbläsarens ”klient”-socket och webbserverns ”klient”-socket är identiska. Det vill säga, det här är en ”peer to peer”-konversation. Eller för att uttrycka det på ett annat sätt, som designer måste du bestämma vilka etikettregler som gäller för en konversation. Normalt startar connect
ing-socketet konversationen genom att skicka in en begäran eller kanske en signon. Men det är ett designbeslut - det är inte en regel för sockets.
Nu finns det två uppsättningar verb att använda för kommunikation. Du kan använda send
och recv
, eller så kan du omvandla din klientsocket till ett filliknande djur och använda read
och write
. Det senare är det sätt som Java presenterar sina socket. Jag kommer inte att prata om det här, förutom att varna dig för att du måste använda flush
på sockets. Dessa är buffrade ”filer”, och ett vanligt misstag är att write
något, och sedan read
för ett svar. Utan en flush
där kan du vänta för evigt på svaret, eftersom begäran fortfarande kan finnas i din utmatningsbuffert.
Nu kommer vi till den stora stötestenen för sockets - end
och recv
arbetar med nätverksbuffertarna. De hanterar inte nödvändigtvis alla bytes du ger dem (eller förväntar dig av dem), eftersom deras huvudfokus är att hantera nätverksbuffertarna. I allmänhet återkommer de när de associerade nätverksbuffertarna har fyllts (end
) eller tömts (recv
). De berättar sedan hur många byte de hanterade. Det är ditt ansvar att anropa dem igen tills ditt meddelande har behandlats fullständigt.
När en recv
returnerar 0 byte betyder det att den andra sidan har stängt (eller håller på att stänga) anslutningen. Du kommer inte att få några fler data på den här anslutningen. Alltid. Du kanske kan skicka data framgångsrikt; jag kommer att prata mer om detta senare.
Ett protokoll som HTTP använder en socket för endast en överföring. Klienten skickar en begäran och läser sedan ett svar. Sedan är det slut. Uttaget kasseras. Detta innebär att en klient kan upptäcka slutet på svaret genom att ta emot 0 byte.
Men om du planerar att återanvända din socket för ytterligare överföringar måste du inse att det inte finns någon EOT på en socket. Jag upprepar: om en socket send
eller recv
returnerar efter att ha hanterat 0 byte har anslutningen brutits. Om anslutningen inte har brutits kan du vänta på en recv
för alltid, eftersom socketet inte kommer att berätta att det inte finns något mer att läsa (för tillfället). Om du nu tänker lite på det kommer du att inse en grundläggande sanning om socket: meddelanden måste antingen ha en fast längd (usch), eller vara avgränsade (axelryckning), eller ange hur långa de är (mycket bättre), eller avslutas med att anslutningen stängs. Valet är helt och hållet ditt, (men vissa sätt är mer rätt än andra).
Förutsatt att du inte vill avsluta anslutningen är den enklaste lösningen ett meddelande med fast längd:
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)
Sändningskoden här kan användas för nästan alla meddelandesystem – i Python skickar du strängar och kan använda len()
för att bestämma längden (även om den innehåller inbäddade \0
-tecken). Det är främst mottagningskoden som blir mer komplex. (Och i C är det inte mycket värre, förutom att du inte kan använda strlen
om meddelandet innehåller inbäddade \0
s.)
Den enklaste förbättringen är att göra det första tecknet i meddelandet till en indikator på meddelandetyp och låta typen bestämma längden. Nu har du två recv
- den första för att få (åtminstone) det första tecknet så att du kan slå upp längden, och den andra i en slinga för att få resten. Om du väljer den avgränsade vägen tar du emot i någon godtycklig storlek (4096 eller 8192 är ofta en bra matchning för nätverkets buffertstorlekar) och skannar det du har fått efter en avgränsare.
En komplikation att vara medveten om: om ditt konversationsprotokoll tillåter att flera meddelanden skickas rygg mot rygg (utan någon form av svar), och du skickar recv
en godtycklig bitstorlek, kan det hända att du läser början på ett följande meddelande. Du måste lägga det åt sidan och hålla fast vid det tills det behövs.
Att lägga till meddelandets längd (till exempel 5 siffror) i början blir mer komplicerat, eftersom (tro det eller ej) du kanske inte får alla 5 tecken i ett enda recv
. Om du leker lite kan du komma undan med det, men vid hög nätverksbelastning kommer din kod mycket snabbt att sluta fungera om du inte använder två recv
-slingor – den första för att bestämma längden, den andra för att hämta datadelen av meddelandet. Otäckt. Det är också då du kommer att upptäcka att send
inte alltid lyckas ta bort allt i ett svep. Och trots att du har läst detta kommer du så småningom att fastna i det!
Av utrymmesskäl, för att bygga upp din karaktär (och för att bevara min konkurrensposition) lämnas dessa förbättringar som en övning för läsaren. Låt oss gå vidare till att städa upp.
Binär data¶
Det är fullt möjligt att skicka binära data via en socket. Det stora problemet är att inte alla maskiner använder samma format för binära data. Till exempel är byteordning i nätverk big-endian, med den mest signifikanta byten först, så ett 16-bitars heltal med värdet 1
skulle vara de två hexbytena 00 01
. De flesta vanliga processorer (x86/AMD64, ARM, RISC-V) är dock little-endian, med den minst signifikanta byten först - samma 1
skulle vara 01 00
.
Socket-bibliotek har anrop för att konvertera 16- och 32-bitars heltal - ntohl, htonl, ntohs, htons
där ”n” betyder nätverk och ”h” betyder värd, ”s” betyder kort och ”l” betyder lång. Om nätverksordningen är värdordningen gör dessa ingenting, men om maskinen är byte-reverserad byter dessa byte på lämpligt sätt.
I dessa dagar med 64-bitars maskiner är ASCII-representationen av binära data ofta mindre än den binära representationen. Det beror på att de flesta heltal förvånansvärt ofta har värdet 0, eller kanske 1. Strängen "0"
skulle vara två byte, medan ett helt 64-bitars heltal skulle vara 8. Naturligtvis passar detta inte bra med meddelanden med fast längd. Beslut, beslut.
Kopplar bort¶
Strängt taget är det meningen att du ska använda shutdown
på ett socket innan du stänger
det. shutdown
är ett råd till socketet i andra änden. Beroende på vilket argument du ger det kan det betyda ”Jag tänker inte skicka mer, men jag lyssnar fortfarande”, eller ”Jag lyssnar inte, bra att slippa!”. De flesta socket-bibliotek är dock så vana vid att programmerare struntar i att använda denna etikett att en close
normalt är detsamma som shutdown(); close()
. Så i de flesta situationer behövs inte en explicit shutdown
.
Ett sätt att använda shutdown
effektivt är i en HTTP-liknande utväxling. Klienten skickar en begäran och gör sedan en shutdown(1)
. Detta säger till servern ”Den här klienten är klar med att skicka, men kan fortfarande ta emot.” Servern kan upptäcka ”EOF” genom en mottagning av 0 byte. Den kan anta att den har hela begäran. Servern skickar ett svar. Om send
avslutas framgångsrikt så var klienten faktiskt fortfarande mottagande.
Python tar den automatiska avstängningen ett steg längre och säger att när ett socket är garbage collected, kommer det automatiskt att göra en close
om det behövs. Men att förlita sig på detta är en mycket dålig vana. Om ditt socket bara försvinner utan att göra en close
, kan socketet i andra änden hänga på obestämd tid och tro att du bara är långsam. Snälla close
dina socket när du är klar.
När socklar dör¶
Det värsta med att använda blockerande socklar är förmodligen vad som händer när den andra sidan går ner hårt (utan att göra en close
). Ditt socket kommer sannolikt att hänga. TCP är ett pålitligt protokoll, och det kommer att vänta länge, länge innan det ger upp en anslutning. Om du använder trådar är hela tråden i princip död. Det finns inte mycket du kan göra åt det. Så länge du inte gör något dumt, som att hålla ett lås medan du gör en blockerande läsning, förbrukar tråden egentligen inte mycket resurser. Försök inte att döda tråden - en del av anledningen till att trådar är mer effektiva än processer är att de undviker den overhead som är förknippad med automatisk återvinning av resurser. Med andra ord, om du lyckas döda tråden är det troligt att hela din process kommer att gå åt skogen.
Icke-blockerande socklar¶
Om du har förstått det föregående vet du redan det mesta du behöver veta om hur man använder sockets. Du kommer fortfarande att använda samma samtal, på ungefär samma sätt. Det är bara det att om du gör det rätt kommer din app att vara nästan inifrån och ut.
I Python använder du socket.setblocking(False)
för att göra det icke-blockerande. I C är det mer komplext, (för det första måste du välja mellan BSD-smaken O_NONBLOCK
och den nästan oskiljaktiga POSIX-smaken O_NDELAY
, som är helt annorlunda än TCP_NODELAY
), men det är exakt samma idé. Du gör detta efter att du har skapat socketet, men innan du använder det. (Om du är galen kan du faktiskt växla fram och tillbaka)
Den stora mekaniska skillnaden är att send
, recv
, connect
och accept
kan återvända utan att ha gjort någonting. Du har (naturligtvis) ett antal valmöjligheter. Du kan kontrollera returkod och felkoder och i allmänhet driva dig själv till vansinne. Om du inte tror mig, prova det någon gång. Din app kommer att växa sig stor, buggig och suga CPU. Så låt oss hoppa över de hjärndöda lösningarna och göra det rätt.
Använd select
.
I C är det ganska komplicerat att koda select
. I Python är det en bit kaka, men det är tillräckligt nära C-versionen att om du förstår select
i Python, kommer du att ha lite problem med det i C:
ready_to_read, ready_to_write, in_error = \
select.select(
potential_readers,
potential_writers,
potential_errs,
timeout)
Du skickar select
tre listor: den första innehåller alla socket som du kanske vill försöka läsa; den andra alla socket som du kanske vill försöka skriva till och den sista (lämnas normalt tom) de som du vill kontrollera för fel. Observera att ett socket kan ingå i mer än en lista. Anropet select
är blockerande, men du kan ge det en timeout. Detta är i allmänhet en förnuftig sak att göra - ge det en lång timeout (säg en minut) om du inte har goda skäl att göra något annat.
I gengäld får du tre listor. De innehåller de socket som faktiskt är läsbara, skrivbara och felaktiga. Var och en av dessa listor är en delmängd (eventuellt tom) av motsvarande lista som du skickade in.
Om ett socket finns i den läsbara listan för utdata kan du vara så nära säker som vi någonsin kan vara i den här branschen på att en recv
på det socketet kommer att returnera något. Samma idé för den skrivbara listan. Du kommer att kunna skicka något. Kanske inte allt du vill, men något är bättre än ingenting. (Egentligen kommer alla rimligt friska socket att returnera som skrivbara - det betyder bara att buffertutrymme för utgående nätverk är tillgängligt)
Om du har ett ”server”-socket, lägg till det i listan potential_readers. Om den hamnar i den läsbara listan kommer din accept
(nästan säkert) att fungera. Om du har skapat en ny socket för att ansluta
till någon annan, lägg den i listan potential_writers. Om den dyker upp i den skrivbara listan har du en hyfsad chans att den har anslutit.
Egentligen kan select
vara praktiskt även med blockerande socket. Det är ett sätt att avgöra om du kommer att blockera - socketet returneras som läsbart när det finns något i buffertarna. Detta hjälper dock fortfarande inte med problemet att avgöra om den andra änden är klar, eller bara upptagen med något annat.
Portabilitetsvarning: På Unix fungerar select
både med sockets och filer. Försök inte detta på Windows. På Windows fungerar select
endast med socket. Observera också att i C görs många av de mer avancerade sockelalternativen annorlunda på Windows. Faktum är att jag på Windows brukar använda trådar (som fungerar mycket, mycket bra) med mina socket.