9.13. TCP 套接字

TCP 套接字有两种形态,它们看起来不同,但底层类型相同:一种是 客户端 套接字,通过 connect() 连接到远程服务器;另一种是 服务器 套接字,通过 bind()listen()accept() 来接受传入的连接。这两种角色都使用 套接字对象 中介绍的同一个 socket 类;区别仅在于在它们上面调用的方法不同。

9.13.1. TCP 客户端

最简单的客户端会打开一个连接、发送一个请求、读取回复,然后关闭连接:

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() 执行 TCP —— 可靠的字节流 中介绍的三次握手,并在连接建立后返回。send() 向连接写入字节;recv() 从连接读取至多指定数量的字节。应用完成后,close() 会关闭该连接。

下面是同一个脚本,使用 套接字对象 中介绍的 with 语句惯用法进行了包装,这样即使发生异常,套接字也会被关闭:

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. 持续读取直至完成

单次 recv() 返回的字节数 至多 等于请求的数量——它可能返回更少的字节,因为 TCP 是一个字节流,而非一系列消息。应用必须持续读取,直到获得完整的回复:

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

recv() 返回一个空的 bytes 时循环结束。这发生在对方已经干净地关闭了它那一半连接时;在这种风格的协议中,应用会把“流结束”视作与“消息结束”相同。

9.13.1.2. 持续发送直至完成

send() 而言情况正好相反:它发送的字节数可能 少于 请求的数量,并返回实际写入的字节数。对于较大的有效载荷,应重试发送未发送的剩余部分:

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

sendall() 会在内部完成这个循环,因此大多数代码可以直接调用它,从而避免手动重试:

s.sendall(some_big_bytes)

9.13.2. TCP 服务器

服务器端分为四步:占用一个端口、将套接字切换为监听模式、逐个接受连接、在每个已接受的套接字上进行通信。一个最小的回显服务器:

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()

逐步说明:

  • bind() 在摄像头上占用一个主机和端口。"0.0.0.0" 表示在任意接口上接受连接;将其替换为某个具体 IP 则会把监听限制在该接口上。

  • listen() 将套接字从普通套接字切换为 监听 套接字。其参数是 待处理队列长度(backlog)——即当应用繁忙时,MicroPython 会排队多少个待处理的连接。选一个较小的数值即可;1 在大多数情况下就够用了。

  • accept() 会阻塞直到有客户端连接,然后返回 (conn, addr):一个代表此次单个连接的 套接字,以及客户端的地址。监听套接字本身保持打开,以便接受更多连接。

  • 本次会话的所有字节都通过 conn 这个新套接字流动。读取和写入使用与客户端一侧相同的 recv() / send() 调用。

  • 当客户端关闭时,recv() 返回 b"";内层循环结束,服务器通过 close() 关闭自己这一端。

外层的 while True 会跳回 accept() 以等待下一个客户端。在这种形态下,服务器一次只处理一个客户端;要并行处理多个客户端,则需要线程或 asyncio。后者正是下一页的主题。

9.13.3. 常见陷阱

  • 把 recv() 当作以消息为单位。 它并非如此。两次 send(b"hi") 调用可能作为一次 recv(4) 返回 b"hihi",也可能作为两次 recv(2) 返回。如果消息边界很重要,应用就必须自行添加分帧——一个换行符、一个长度前缀,或其他任何方式。

  • 忘记在发送不完整时重试。 对于超过几百字节的任何数据,请使用 sendall()

  • 忘记关闭已接受的套接字。 每个 conn 都是一个独立的套接字;关闭监听套接字并不会关闭那些已接受的套接字。在两者上都使用 with 代码块可以让这一点很难出错:

    while True:
        with server.accept()[0] as conn:
            # ... talk on conn ...
    
  • 在端口仍处于 TIME_WAIT 状态时重新绑定。 当服务器在关闭后几秒内重启时,bind() 可能会因“地址已在使用中”而失败,因为 MicroPython 仍在为上一个连接保持着该端口。在调用 bind() 之前执行 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 即可清除这一状态。

9.13.4. 下一步

accept() 上阻塞意味着服务器一次只能服务一个客户端。在 recv() 上阻塞则意味着单个缓慢的客户端会让整个循环挂起。在摄像头上,标准的解决方案是 asyncio——把每个连接作为它自己的任务来运行,让事件循环在它们之间进行调度。下一页将介绍本页所有内容的 asyncio 版本。