9.13. Gniazda TCP

Gniazda TCP występują w dwóch postaciach, które wyglądają różnie, ale opierają się na tym samym typie bazowym: gniazda klienta, które wykonują connect() do zdalnego serwera, oraz gniazda serwera, które wykonują bind(), listen() oraz accept() na połączeniach przychodzących. Obie role korzystają z tej samej klasy socket przedstawionej na stronie Obiekty gniazd; różnią się jedynie wywoływane na nich metody.

9.13.1. Klient TCP

Najprostszy klient otwiera połączenie, wysyła żądanie, odczytuje odpowiedź i zamyka połączenie:

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("192.168.1.20", 9000))

s.send(b"hello\n")
reply = s.recv(1024)
print("reply:", reply)

s.close()

connect() przeprowadza trójstopniowe nawiązanie połączenia (handshake) opisane na stronie TCP – niezawodny strumień bajtów i zwraca sterowanie, gdy połączenie jest otwarte. send() zapisuje bajty do połączenia; recv() odczytuje z niego do podanej liczby bajtów. Gdy aplikacja zakończy pracę, close() zamyka połączenie.

Ten sam skrypt zapisany przy użyciu idiomu z instrukcją with ze strony Obiekty gniazd, dzięki czemu gniazdo zostaje zamknięte nawet wtedy, gdy coś zgłosi wyjątek:

import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect(("192.168.1.20", 9000))
    s.send(b"hello\n")
    print(s.recv(1024))

9.13.1.1. Czytanie aż do końca

Pojedyncze wywołanie recv() zwraca do żądanej liczby bajtów – może zwrócić ich mniej, ponieważ TCP jest strumieniem, a nie sekwencją komunikatów. Aplikacja musi czytać dalej, dopóki nie otrzyma pełnej odpowiedzi:

chunks = []
while True:
    chunk = s.recv(1024)
    if not chunk:                  # empty bytes -> other side closed
        break
    chunks.append(chunk)
reply = b"".join(chunks)

Pętla kończy się, gdy recv() zwróci pusty obiekt bytes. Dzieje się tak, gdy druga strona poprawnie zamknęła swoją połowę połączenia; w tym stylu protokołu aplikacja traktuje „koniec strumienia” tak samo jak „koniec komunikatu”.

9.13.1.2. Wysyłanie aż do końca

Odwrotne zastrzeżenie dotyczy send(): może wysłać mniej bajtów niż żądano, zwracając liczbę faktycznie zapisanych bajtów. W przypadku dużych ładunków należy ponownie wysłać niewysłaną resztę:

payload = some_big_bytes
while payload:
    n = s.send(payload)
    payload = payload[n:]

sendall() wykonuje tę pętlę wewnętrznie, więc w większości przypadków kod może po prostu wywołać tę metodę i uniknąć ręcznego ponawiania:

s.sendall(some_big_bytes)

9.13.2. Serwer TCP

Strona serwera składa się z czterech kroków: zajęcie portu, przełączenie gniazda w tryb nasłuchiwania, akceptowanie połączeń jedno po drugim oraz komunikacja na każdym zaakceptowanym gnieździe. Minimalny serwer echo:

import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("0.0.0.0", 9000))
server.listen(1)
print("listening on port 9000")

while True:
    conn, addr = server.accept()
    print("connection from", addr)

    while True:
        data = conn.recv(1024)
        if not data:
            break
        conn.send(data)            # echo back

    conn.close()

Krok po kroku:

  • bind() zajmuje hosta i port na kamerze. "0.0.0.0" akceptuje połączenia na dowolnym interfejsie; zastąpienie tego konkretnym adresem IP ogranicza nasłuchiwanie do tego interfejsu.

  • listen() przełącza gniazdo ze zwykłego gniazda na gniazdo nasłuchujące. Argument to backlog – liczba oczekujących połączeń, które MicroPython będzie kolejkować, gdy aplikacja jest zajęta. Wybierz małą wartość; 1 jest odpowiednie w większości przypadków.

  • accept() blokuje wykonanie, dopóki klient się nie połączy, a następnie zwraca (conn, addr): nowe gniazdo reprezentujące to jedno połączenie oraz adres klienta. Samo gniazdo nasłuchujące pozostaje otwarte, aby przyjmować kolejne połączenia.

  • Wszystkie bajty tej konwersacji przepływają przez conn, nowe gniazdo. Odczyt i zapis korzystają z tych samych wywołań recv() / send() co po stronie klienta.

  • Gdy klient zamknie połączenie, recv() zwraca b""; wewnętrzna pętla się kończy, a serwer zamyka swoją stronę za pomocą close().

Zewnętrzna pętla while True wraca do accept(), aby czekać na kolejnego klienta. W tej postaci serwer obsługuje jednego klienta naraz; obsługa wielu klientów równolegle wymaga albo wątków, albo asyncio. To ostatnie jest tematem następnej strony.

9.13.3. Częste pułapki

  • Traktowanie recv() jak komunikatu. Tak nie jest. Dwa wywołania send(b"hi") mogą dotrzeć jako jedno recv(4) zwracające b"hihi" lub jako dwa wywołania recv(2). Aplikacja musi dodać ramkowanie, jeśli granice komunikatów mają znaczenie – znak nowego wiersza, prefiks z długością, cokolwiek.

  • Zapominanie o ponawianiu przy krótkich wysyłkach. Dla czegokolwiek przekraczającego kilkaset bajtów używaj sendall().

  • Zapominanie o zamknięciu zaakceptowanego gniazda. Każde conn jest osobnym gniazdem; zamknięcie gniazda nasłuchującego nie zamyka gniazd zaakceptowanych. Bloki with na obu utrudniają popełnienie tego błędu:

    while True:
        with server.accept()[0] as conn:
            # ... talk on conn ...
    
  • Ponowne wiązanie z portem wciąż w stanie TIME_WAIT. Gdy serwer uruchamia się ponownie w ciągu kilku sekund od zamknięcia, bind() może zakończyć się błędem „address in use”, ponieważ MicroPython wciąż utrzymuje port dla poprzedniego połączenia. Wywołanie server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) przed bind() rozwiązuje ten problem.

9.13.4. Co dalej

Blokowanie na accept() oznacza, że serwer może obsługiwać tylko jednego klienta naraz. Blokowanie na recv() oznacza, że pojedynczy wolny klient zawiesza całą pętlę. Standardowym rozwiązaniem na kamerze jest asyncio – uruchom każde połączenie jako własne zadanie i pozwól pętli zdarzeń przełączać się między nimi. Następna strona omawia wersje asyncio wszystkiego, co znajduje się na tej stronie.