2. Socket编程基础

Socket(套接字)是操作系统提供给应用程序的网络编程接口,它把 TCP/IP 协议栈中复杂的握手、确认、重传等细节封装成一组文件描述符式的操作。Python 的 socket 模块几乎是对 BSD Socket API 的直接映射,理解它的核心函数、地址族与套接字类型,是后续编写并发服务器、异步框架和自定义协议的基础。

一、地址族与套接字类型

创建套接字时,socket.socket(family, type) 的两个参数决定了通信的“地址空间”和“传输语义”。最常用的组合是 AF_INET + SOCK_STREAM,即 IPv4 上的 TCP 通信;AF_INET6 用于 IPv6;AF_UNIX(Linux/macOS)用于本机进程间通信,不经过网络协议栈,性能更高。类型方面,SOCK_STREAM 提供面向连接、可靠、有序的字节流;SOCK_DGRAM 提供无连接、不保证顺序与到达的数据报,对应 UDP。

需要特别区分的是:TCP 是字节流协议,没有消息边界。一次 send 的数据可能被拆成多次 recv 收到,也可能多次 send 被合并成一次 recv。因此应用层必须自己定义边界,常见做法是“长度前缀 + 消息体”或使用换行符分隔。

二、核心 API 与调用顺序

服务端典型调用链为:socket() → bind() → listen() → accept() → recv()/send() → close()。客户端为:socket() → connect() → send()/recv() → close()。其中 bind() 把套接字绑定到 (host, port),listen(n) 的 n 是内核已完成连接队列与半连接队列相关的 backlog 上限,accept() 返回一个新的已连接套接字,原监听套接字继续用于接受后续连接。

下面是一个最小可运行的 TCP 回声服务端与客户端示例。服务端监听本机 9000 端口,收到数据后原样返回;客户端连接后发送一行文本并打印响应。

# server.py
import socket

HOST, PORT = "127.0.0.1", 9000

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind((HOST, PORT))
    srv.listen(5)
    print(f"listening on {HOST}:{PORT}")
    conn, addr = srv.accept()          # 阻塞直到有客户端连接
    with conn:
        print("connected by", addr)
        while True:
            data = conn.recv(1024)     # 最多读 1024 字节
            if not data:               # 对端关闭,recv 返回 b""
                break
            conn.sendall(data)         # 确保全部字节发出
# client.py
import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as c:
    c.connect(("127.0.0.1", 9000))
    c.sendall("hello socket\n".encode("utf-8"))
    print("reply:", c.recv(1024).decode("utf-8").strip())

运行后服务端输出 connected by ('127.0.0.1', 5xxxx),客户端输出 reply: hello socket。这里的端口 5xxxx 是客户端临时端口,由操作系统分配。注意 sendall() 与 send() 的区别:send() 返回实际发送的字节数,可能小于请求长度,需要循环发送;sendall() 内部循环直到全部发出或出错。

三、阻塞与非阻塞模式

默认创建的套接字是阻塞的:accept()、recv()、connect() 在条件不满足时会挂起当前线程。阻塞模式代码直观,但一个线程只能处理一个连接,无法支撑多客户端。解决办法有三类:多线程/多进程、selectors 多路复用、以及把套接字设为非阻塞配合事件循环。

通过 sock.setblocking(False) 或 sock.settimeout(0.5) 可以改变行为。非阻塞模式下,当没有数据可读时 recv() 立即抛出 BlockingIOError(其 errno 为 EAGAIN 或 EWOULDBLOCK),而不是等待。这意味着程序必须自己处理“暂时不可用”的情况,通常交给 selectors 或 asyncio 来管理。

import selectors, socket

sel = selectors.DefaultSelector()

def accept(sock):
    conn, addr = sock.accept()
    conn.setblocking(False)
    sel.register(conn, selectors.EVENT_READ, read)

def read(conn):
    data = conn.recv(1024)
    if data:
        conn.sendall(data)
    else:
        sel.unregister(conn)
        conn.close()

srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 9000))
srv.listen()
srv.setblocking(False)
sel.register(srv, selectors.EVENT_READ, accept)

while True:
    for key, _ in sel.select():
        key.data(key.fileobj)

这段代码用单线程同时管理监听套接字和多个连接套接字,是理解 asyncio 底层机制的良好起点。

四、常见异常与排障要点

实际调试中,ConnectionRefusedError 通常表示目标端口没有服务在监听;ConnectionResetError 表示对端异常关闭(如进程崩溃或发送了 RST);OSError: [Errno 98] Address already in use 多因服务端重启时旧连接处于 TIME_WAIT,设置 SO_REUSEADDR 可缓解;TimeoutError 则来自 settimeout() 到期。判断连接是否被对端正常关闭,依据是 recv() 返回空字节串 b"",而不是抛异常。

TCP 客户端-服务器套接字调用时序与状态 服务端 Server 客户端 Client socket(AF_INET, SOCK_STREAM) bind(("127.0.0.1", 9000)) listen(backlog=5) accept() 阻塞等待 recv(1024) 读字节流 sendall(data) 回显 close() 释放连接 socket(AF_INET, SOCK_STREAM) connect(("127.0.0.1",9000)) sendall("hello socket\n") recv(1024) 收响应 close() SYN → ← SYN+ACK ACK → 连接建立 数据字节流 → ← 回显数据 黄色步骤在阻塞模式下会挂起线程

五、关键参数与行为对照

API / 参数作用典型取值与注意点
family地址族AF_INET(IPv4)、AF_INET6、AF_UNIX
type传输语义SOCK_STREAM(TCP)、SOCK_DGRAM(UDP)
listen(n)设置 backlog常用 5~128,过小会导致新连接被拒
recv(bufsize)读取字节流返回 b"" 表示对端关闭;不保证一次读完
sendall()发送全部数据内部循环,避免部分发送问题
setblocking(False)切换非阻塞无数据时抛 BlockingIOError,需事件循环配合
settimeout(t)设置超时到期抛 TimeoutError,t=0 等价非阻塞
SO_REUSEADDR地址复用服务端重启时避免 Address already in use
调试建议:先用 ss -ltnp(Linux)或 netstat -ano(Windows)确认端口处于 LISTEN 状态,再用 telnet 127.0.0.1 9000 或 nc 手工连接,可快速区分是网络层问题还是应用层逻辑问题。
不要把 recv() 返回 b"" 与“暂时没有数据”混淆。阻塞模式下没有数据会等待,返回空串只代表对端已关闭连接;非阻塞模式下没有数据则抛 BlockingIOError。三者的处理逻辑完全不同。

掌握以上内容后,你已经能够用 socket 模块写出可用的 TCP 客户端与服务器,并理解阻塞模型为何需要多线程或事件循环来扩展。下一步的自然演进,是把 accept/recv 的阻塞调用替换为 selectors 或 asyncio,从而在单线程内支撑成百上千个并发连接。