pondělí 30. června 2014

BSD sockety (nejen) v Pythonu

Rozhodl jsem se využít prostor tohoto blogu i pro jiné věci, než jenom zápisky z výletů po Japonsku. Jelikož už přes dva roky používám v práci jazyk Python pro vývoj low-level záležitostí, tak jsem nabral troufám si říct slušnou řádku zkušeností s chováním některých záležitostí, které by se mohly hodit i jiným programátorům. Mám připraveno několik témat, o kterých chci psát - některá budou specifická pro Python, jiná (jako to dnešní) jsou použitelná nezávisle na zvoleném jazyku nebo operačním systému. Ještě dodám, že v práci používám většinou kombinaci Linux a Python 2.x. Pořád to zní zajímavě ? OK, tak jdeme na první zápisek.

Sockety jsou nesmírně užitečný nástroj, který prakticky hýbe dnešním světem, kdy je všechno online. Jedná se o obousměrné spojení mezi dvěma místy, kde dochází k výměně dat. Ta místa se mohou nacházet na různých stranách zeměkoule, stejně tak jako pouze v různých procesech v rámci jednoho počítače. To dává vašim aplikacím ohromnou flexibilitu - pokud si vyměnujete data pomocí socketu, tak můžete pro začátek obě části aplikace spouštět na jednom stroji. Později, když nároky porostou lze přemístit jednu část na jiný fyzický stroj připojený do stejné sítě a změnit pouze adresy, na které se připojujete. A to je vše, na zbytek kódu není třeba sahat.

Je zde ale řada úskalí, která nejsou zřejmá na první pohled. Ještě horší je fakt, že se většinou neprojevují na jednoduchých "hello world" příkladech, ale jakmile začnete přes sockety přenášet data o větších objemech. Takže vám snad další řádky pomůžou předcházet chybám ve vaších appkách.

Začněme ukázkou přímo z Python dokumentace modulu socket :
# Echo server program
import socket

HOST = ''                 # Symbolic name meaning all available interfaces
PORT = 50007              # Arbitrary non-privileged port
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((HOST, PORT))
s.listen(1)
conn, addr = s.accept()
print 'Connected by', addr
while 1:
    data = conn.recv(1024)
    if not data: break
    conn.sendall(data)
conn.close()
# Echo client program
import socket

HOST = 'daring.cwi.nl'    # The remote host
PORT = 50007              # The same port as used by the server
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
s.sendall('Hello, world')
data = s.recv(1024)
s.close()
print 'Received', repr(data)
Fungování by mělo být zřejmé, takže ve zkratce. Server poslouchá na rozhraní '' (což znamená poslouchat na všech možných) na dohodnutém portu. Na metodě accept() se vykonávání zastaví do té doby, než se připojí klient. Po připojení klienta dostaneme pár (spojení, adresa), adresu server vypíše a ze spojení načte data. Ty pošle klientovi zpátky a tohle se opakuje, dokud klient něco posílá. Jakmile nepošle nic, ze smyčky se vyskočí, zavře socket a program končí. Klientská část se připojí, pošle 'Hello,world', načte odpověď, zavře socket, vypíše co přijal a skončí.

Všechno vypadá jednoduše, ale obě části v sobě mají jednu velkou nedokonalost. Jedná se o metodu recv() respektive její parametr. Ten udává, kolik bajtů maximálně vrátí a měla by to být nějaká mocnina dvou, aby to dobře spolupracovalo s bufferem síťové karty. Do teď je všechno OK (string "Hello, world" se určitě odešle i přijme v jednom kuse i na nejvíc exotických sítích).

Ono se ale může dost jednoduše stát to, že recv() vám v jednom volání vrátí pouze část odeslané zprávy. Může to být tím, že se celá už prostě nevejde do jednoho paketu. Protokol TCP/IP, který to interně používá, zaručuje pouze to, že dostanete data na druhé straně ve stejném pořadí, jako jste je na první straně odeslali. Jelikož je socket pouze proud dat, tak nelze spoléhat na začátek nebo konec nečeho, ten v podstatě neexistuje.

Řešení je jednoduché, stačí data opatřit nějakou hlavičkou (o předem známé délce), která určuje, kolik dat za ní následuje. Potom pracujeme se socketem následovně:
  1. Načtu celou hlavičku (délka je předem známa)
  2. Zjistím hodnotu z hlavičky
  3. Načtu přesně tolik bajtů, kolik bylo napsané v hlavičče
  4. Následuje další hlavička...
Tohle dělám prakticky neustále, takže je součástí mojí standardní knihovny tato metoda:
def read_bytes(method, count) :
  """ Reads count bytes using specified method. Ensures that exactly count bytes is read or EOFError is fired

  Args:
    method -- reference to method to call
    count -- byte count to receive

  Return:
    bytearray with data
  """

  barray = method(count)
  if len(barray) == 0 :
    raise EOFError("Connection unexpectedly closed")
  while len(barray) < count :
    barray_part = method(count - len(barray))
    if len(barray_part) == 0 :
      raise EOFError("Connection unexpectedly closed")
    barray += barray_part
  return barray
Prvním parametrem je metoda, kterou chci volat (takže vždycky recv()), druhým je počet bajtů k načtení. Zavolám recv() poprvé a cyklus mi zaručí, že se bude volat tak dlouho, dokud nenačtu požadovaný počet dat. Při každém načtení dat ho přičtu k předchozím a příští volání recv() už dostane jako parametr jenom tolik, kolik zbývá přenést. Jakmile mi recv() vrátí pole nulové délky, tak spolehlivě vím, že se spojení rozpadlo a nic už přes něj nepřenesu (viz. specifikace recv()). I v extrémním případě, kdy by recv() vracelo data po jednom bajtu to bude fungovat. Metodu používám už přes rok, přenesla terabajty dat a nebyl s ní žádný problém (tedy po tom, když jsem věděl, co po ní chci :-)). Hlavičku používám standardně délky 4B a na zakódování/dekódovaní její hodnoty se výborně hodí modul struct. Ale o tom (snad) někdy později.

Dalším úskalím socketu je to, že spolehlivě nejde rozeznat stav, kdy se druhá strana odpojí. Pokud máte otevřené a sestavené spojení, tak nemůžete vědět, jestli druhý stroj (který může být velmi vzdálený) prostě nic neposílá a nebo ho někdo právě vytáhl ze zásuvky. TCP/IP říká pouze to, že se to nakonec někdy dovíte, ale může to trvat dost dlouho. Odhalení tohoto stavu pomůže zkusit něco přes socket přenést - většinou takové volání sendall() nebo recv() - přes uvedenou metodu skončí okamžitě s vyjímkou socket.error nebo EOFError. Jelikož jsou moje servery většinou bezestavové (čili přijmou data a ihned odpoví zpátky), tak stačí volání obou komunikačních metod obalit try/catch na obě uvedené vyjímky a žádné problémy v této oblasti by neměly nastávat.

To je prozatím všechno k socketům, pokud jste to dočetli až sem, tak vám děkuji za pozornost a v komentářích rád uvítám jakékoliv poznámky.