本节目标:理解 TCP socket 的生命周期与字节流特性,能读懂 HTTP 报文的原始形态,并在 urllib、requests、httpx 之间做出正确选择。
适用版本:Python 3.12+(实测 3.14.6)
12.3 socket、HTTP 客户端与 requests/httpx
前面几节我们把「怎么把活分给多个执行单元」讲完了。本节转向 I/O 的另一半——网络:数据怎么从远端来到你的程序里。我们会从最底层的 socket 一路看到上层的 HTTP 客户端库,看清它们各自替你做了什么。
12.3.1 TCP/IP 分层速览
网络通信是分层的,从下到上:
| 层 | 代表协议 | 关心什么 |
|---|---|---|
| 链路层 | 以太网、Wi-Fi | 同一局域网内的帧 |
| 网络层 | IP | 主机到主机的寻址与路由 |
| 传输层 | TCP / UDP | 进程到进程的可靠/不可靠字节流 |
| 应用层 | HTTP / DNS / SMTP | 业务语义 |
写网络程序时,socket 是传输层的接口,AF_INET 指 IPv4、SOCK_STREAM 指 TCP。HTTP 则是架在 TCP 之上的应用层协议——它本质上就是「在一条 TCP 连接里按固定格式收发文本」。理解了这一点,后面所有客户端库都不神秘了。
12.3.2 最小 echo server / client
服务端绑定端口、等待连接、收到什么回什么:
import socket
HOST, PORT = "127.0.0.1", 65432
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(1) # 开始监听,排队 1 个连接
print(f"[server] 监听 {HOST}:{PORT}")
conn, addr = srv.accept() # 阻塞,直到有客户端连入
with conn:
print(f"[server] 已连接: {addr}")
while True:
data = conn.recv(1024) # 最多收 1024 字节
if not data: # 对端关闭时 recv 返回空
break
print(f"[server] 收到 {data!r},回显")
conn.sendall(data)
print("[server] 连接关闭,退出")
客户端连接后发三条消息:
import socket
HOST, PORT = "127.0.0.1", 65432
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((HOST, PORT))
print(f"[client] 已连接 {HOST}:{PORT}")
for msg in [b"hello", b"world", b"bye"]:
s.sendall(msg)
reply = s.recv(1024)
print(f"[client] 发送 {msg!r} -> 收到 {reply!r}")
print("[client] 退出")
先跑 server,再跑 client,两边输出(真实运行):
[server] 监听 127.0.0.1:65432
[server] 已连接: ('127.0.0.1', 56837)
[server] 收到 b'hello',回显
[server] 收到 b'world',回显
[server] 收到 b'bye',回显
[server] 连接关闭,退出
[client] 已连接 127.0.0.1:65432
[client] 发送 b'hello' -> 收到 b'hello'
[client] 发送 b'world' -> 收到 b'world'
[client] 发送 b'bye' -> 收到 b'bye'
[client] 退出
12.3.3 socket 的生命周期
| 动作 | 服务端 | 客户端 |
|---|---|---|
| 建 socket | socket(AF_INET, SOCK_STREAM) | 同左 |
| 绑定地址 | bind((host, port)) | 不需要 |
| 监听 | listen(n) | 不需要 |
| 建立连接 | accept() 阻塞返回 (conn, addr) | connect((host, port)) |
| 收发 | conn.sendall() / conn.recv() | s.sendall() / s.recv() |
| 关闭 | conn.close()(或 with) | s.close()(或 with) |
要点:
recv(n)最多读 n 字节,不保证读满;recv返回空字节串表示对端已关闭。sendall()保证把全部数据发完(内部循环),比send()省心。- 用
with socket...保证连接一定被关闭,避免句柄泄漏。 SO_REUSEADDR让端口在关闭后能被立刻重用,避免「Address already in use」。
12.3.4 粘包与分帧
TCP 是字节流,没有「消息边界」。两次 send 可能被一次 recv 收全(粘包),一次 send 也可能被拆成多次 recv。用 socketpair 直观演示:
import socket
a, b = socket.socketpair() # 一条已连好的双向管道
a.sendall(b"hello")
a.sendall(b"world")
print("两次 send 后一次 recv:", b.recv(1024))
两次 send 后一次 recv: b'helloworld'
两条消息粘成了一条。解决办法是自己定协议划边界,最常见的是换行分隔:
a, b = socket.socketpair()
a.sendall(b"hello\nworld\npartial")
buf = b""
lines = []
while len(lines) < 2:
buf += b.recv(1024)
while b"\n" in buf:
line, _, buf = buf.partition(b"\n")
lines.append(line)
print("完整消息:", lines)
print("残留:", buf)
完整消息: [b'hello', b'world']
残留: b'partial'
读完两条完整消息后,缓冲区里留下半条 partial——下次收到补全。这就是缓冲区 + 分隔符的标准写法。另一种协议是定长头 + 变长体(先发 4 字节长度,再发正文),适合二进制数据。永远不要假设一次 recv 就是一条完整消息。
12.3.5 超时
网络可能卡住,recv 默认会无限阻塞。用 settimeout 加保护:
conn.settimeout(0.3) # 0.3 秒内没有数据就超时
try:
conn.recv(1024)
except TimeoutError as e:
print("recv 超时:", type(e).__name__)
recv 超时: TimeoutError (等待 0.30s)
超时抛的是内置 TimeoutError(socket.timeout 在 3.10+ 已并入它)。给所有网络操作设超时是生产代码的底线。
12.3.6 HTTP 请求长什么样
既然 HTTP 就是 TCP 上的文本,我们就用原始 socket 手写一个请求,看服务端到底收到了什么。先起一个本地服务(python -m http.server 8765,第 16 章会细讲),再用 socket 发请求:
import socket
HOST, PORT = "127.0.0.1", 8765
request = (
f"GET / HTTP/1.1\r\n"
f"Host: {HOST}:{PORT}\r\n"
f"Connection: close\r\n"
f"\r\n"
)
with socket.create_connection((HOST, PORT), timeout=5) as s:
s.sendall(request.encode("ascii"))
raw = b""
while (chunk := s.recv(4096)):
raw += chunk
head, _, body = raw.decode("latin-1").partition("\r\n\r\n")
print(head)
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.14.6
Date: Thu, 08 Oct 2026 23:44:55 GMT
Content-type: text/html
Content-Length: 53
Last-Modified: Thu, 08 Oct 2026 23:44:41 GMT
请求由请求行(GET / HTTP/1.1)、请求头(Host、Connection)和空行组成;响应由状态行(HTTP/1.0 200 OK)、响应头、空行、响应体组成。注意头与体之间那个空行(\r\n\r\n)——它是唯一的边界标记,Content-Length 则告诉客户端响应体有多少字节。所有 HTTP 客户端库做的事,无非是替你拼这段文本、解析那段文本、管理连接。
12.3.7 urllib.request:标准库方案
不想装任何第三方库时,urllib.request 就能发请求:
import urllib.request
import urllib.error
URL = "http://127.0.0.1:8765/"
req = urllib.request.Request(URL, headers={"User-Agent": "python-book/1.0"})
with urllib.request.urlopen(req, timeout=5) as resp:
print("状态码:", resp.status)
print("Content-Type:", resp.headers["Content-Type"])
print("响应体:", resp.read().decode().strip())
try:
urllib.request.urlopen(URL + "missing.html", timeout=5)
except urllib.error.HTTPError as e:
print("HTTPError:", e.code, e.reason)
状态码: 200
Content-Type: text/html
响应体: <!doctype html><h1>Hello from local http.server</h1>
HTTPError: 404 File not found
注意:4xx/5xx 会抛 HTTPError 异常(它同时是一个响应对象),不像 requests 那样直接返回。urllib 不自动复用连接、不带 JSON 便捷方法、cookie 处理也原始,除非有「零依赖」硬要求,否则优先 requests / httpx。
12.3.8 requests:会话与 keep-alive
requests 是事实标准。最关键的实践是用 Session 复用连接:
import requests
URL = "http://127.0.0.1:8766/"
for i in range(5):
r = requests.get(URL, timeout=5) # 每次调用都新建连接
print(f"独立 #{i} status={r.status_code}")
with requests.Session() as s: # 复用同一条连接
for i in range(5):
r = s.get(URL, timeout=5)
print(f"会话 #{i} status={r.status_code}")
服务端记录到的 TCP 连接数(每次新建连接都会打印一行):
[server] 新建 TCP 连接 #1
[server] 新建 TCP 连接 #2
[server] 新建 TCP 连接 #3
[server] 新建 TCP 连接 #4
[server] 新建 TCP 连接 #5 ← 以上 5 条来自独立的 requests.get
[server] 新建 TCP 连接 #6 ← 只有 1 条来自 Session 的 5 次请求
10 次请求只用了 6 条连接:Session 把后 5 次请求复用了同一条 TCP 连接(HTTP/1.1 keep-alive)。省下的不只是几次三次握手的延迟,还有频繁建连的资源开销。在循环里请求同一站点,务必用 Session。
12.3.9 httpx:同步与异步双接口
httpx 的 API 与 requests 高度相似,但同时提供同步和异步两套接口——同一套方法名,Client 换 AsyncClient、with 换 async with:
import asyncio
import httpx
URL = "http://127.0.0.1:8766/"
with httpx.Client(trust_env=False, timeout=5.0) as client: # 同步
for i in range(3):
r = client.get(URL)
print(f"同步 #{i} status={r.status_code}")
async def main():
async with httpx.AsyncClient(trust_env=False, timeout=5.0) as client:
rs = await asyncio.gather(*[client.get(URL) for _ in range(3)])
for i, r in enumerate(rs):
print(f"异步 #{i} status={r.status_code}")
asyncio.run(main())
同步 #0 status=200
同步 #1 status=200
同步 #2 status=200
异步 #0 status=200
异步 #1 status=200
异步 #2 status=200
实测版本:requests 2.34.2、httpx 0.28.1。
一个真实的坑:
httpx默认trust_env=True,会读取系统代理。本机配置了公司 squid 代理时,连127.0.0.1都会被塞进代理,返回 503。修复办法是显式关闭:httpx.Client(trust_env=False)。requests 会自动绕过 localhost,httpx 不会——调试本地服务时记得加这一句。
三者怎么选:需要异步(第 13 章)就用 httpx;纯同步、生态最成熟用 requests;完全不想装包用 urllib。三者的 timeout 都必须显式设置,默认值往往是无超时或过长。
12.3.10 延伸阅读
socket、UDP、selectors、HTTP 客户端的更多细节见专题 Python 网络编程
。
小结
- 网络是分层的:socket 是传输层接口(
AF_INET+SOCK_STREAM= TCP),HTTP 是架在 TCP 上的应用层文本协议。 - socket 生命周期:服务端
bind→listen→accept,客户端connect,两端用sendall/recv收发;recv不保证读满,返回空串表示对端关闭。 - TCP 是字节流,会粘包,必须用分隔符或「定长头 + 变长体」自己划边界;所有网络操作都要设超时。
- HTTP 报文就是「请求行/状态行 + 头 + 空行 + 体」,客户端库只是替你拼装、解析与管理连接。
- urllib 零依赖但 4xx/5xx 抛
HTTPError;requests 用Session复用连接(实测 10 次请求只开 6 条连接);httpx 提供同步/异步双接口,注意trust_env会走系统代理。
本节我们把「数据怎么来」讲清了。但它有一个共同点——等待。下一章我们进入异步:用单线程的事件循环,把成千上万个「等待」同时管起来。
阅读导航:上一节:concurrent.futures 与 multiprocessing · 下一节:async/await 与事件循环 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。