httpx 0.28 同步与异步:同一份代码两种写法,12 个请求差 8.5 倍
一个客户端库同时提供同步与异步接口,好处是选型不用改代码结构:脚本里 client.get(),服务里 await client.get()。httpx 就是按这个思路做的,API 布局贴着 requests,额外给了 HTTP/2、可挂载的 WSGI/ASGI 传输和严格的超时对象。代价是它比 requests 多一层"同步/异步别写混"的约束;如果只是发几个 GET 又不想引入 asyncio,用 requests 更省事。
安装与实际版本
python -m venv D:\ai\cache\temp\py-httpx
D:\ai\cache\temp\py-httpx\Scripts\python.exe -m pip install httpx
Name: httpx
Version: 0.28.1
Location: D:\ai\cache\temp\py-httpx\Lib\site-packages
import httpx
print(httpx.__version__)
0.28.1
HTTP/2 支持在可选依赖里,要用得装 httpx[http2];这次只验证 HTTP/1.1。
最小可运行示例
import httpx
r = httpx.get("https://example.com", timeout=10)
print("status:", r.status_code)
print("http_version:", r.http_version)
print("len(text):", len(r.text))
status: 200
http_version: HTTP/1.1
len(text): 577
顶层函数每次都新建连接。实际项目里用 Client,把 base_url、超时、默认头一次配好:
with httpx.Client(
base_url="https://httpbin.org",
timeout=httpx.Timeout(10.0, connect=5.0),
headers={"user-agent": "kernelranch/1.0"},
) as client:
r = client.get("/get", params={"q": "kernelranch"})
print(r.url)
print(r.json()["args"])
print(r.json()["headers"]["User-Agent"])
https://httpbin.org/get?q=kernelranch
{'q': 'kernelranch'}
kernelranch/1.0
异步版只是把 with 换成 async with、get 换成 await get:
import asyncio
import httpx
async def main():
async with httpx.AsyncClient(base_url="https://httpbin.org", timeout=30) as client:
results = await asyncio.gather(*(client.get("/delay/1") for _ in range(5)))
print([r.status_code for r in results])
asyncio.run(main())
同步与异步的实测差距
httpbin 的 /delay/1 会串行化部分请求,测出来的并发收益不干净。换成本地 ThreadingHTTPServer,每个 /slow 在服务端 sleep(0.3),客户端用 trust_env=False 直连,12 个请求:
def sync_serial():
with httpx.Client(base_url=BASE, timeout=10, trust_env=False) as c:
t0 = time.perf_counter()
for _ in range(12):
c.get("/slow")
return time.perf_counter() - t0
async def async_concurrent():
async with httpx.AsyncClient(base_url=BASE, timeout=10, trust_env=False) as c:
t0 = time.perf_counter()
await asyncio.gather(*(c.get("/slow") for _ in range(12)))
return time.perf_counter() - t0
12 次 /slow(服务端每次 sleep 0.3s,trust_env=False)
sync 串行: 3.62s
async 并发: 0.43s
async 串行: 3.67s
并发 / sync串行: 8.5x
3.62s 恰好是 12×0.3,说明同步路径就是纯排队;并发 0.43s 已经贴住单个请求的 0.3s 下界,剩下 0.13s 是 12 个连接建立与收尾的开销。异步串行 3.67s 说明"用了 async"本身不带来任何加速,加速来自 gather 的并发。
常用 API 与参数
| 名称 | 作用 | 要点 |
|---|---|---|
httpx.get/post | 顶层便捷函数 | 每次新建连接,不适合循环调用 |
httpx.Client | 同步客户端 | 连接池、Cookie、默认头、base_url;支持上下文管理 |
httpx.AsyncClient | 异步客户端 | 同上,方法都要 await |
timeout=httpx.Timeout(...) | 超时对象 | 可分别给 connect/read/write/pool;默认 5 秒 |
base_url= | 统一前缀 | 请求路径写相对路径即可 |
limits=httpx.Limits(...) | 连接池上限 | max_connections、max_keepalive_connections |
trust_env | 是否读环境/系统代理 | 默认 True,本地调试常要关掉 |
follow_redirects=True | 跟随重定向 | 顶层函数默认不跟随 |
raise_for_status() | 4xx/5xx 抛异常 | 3xx 不算错误 |
httpx.stream() | 流式响应 | 配 iter_bytes()、num_bytes_downloaded |
四个坑
第一个坑最隐蔽:系统代理会把本地地址也代理走。同一台机器、同一个 URL,只改 trust_env:
trust_env=True: status=502 1.216s body=''
trust_env=False: status=200 0.326s body='{"ok": true, "items": [1, 2, 3]}'
排查时看了下代理来源,环境变量里一个都没有:
urllib getproxies(): {'http': 'http://127.0.0.1:7898', 'https': 'http://127.0.0.1:7898', 'ftp': 'http://127.0.0.1:7898'}
httpx get_environment_proxies(): {'http://': 'http://127.0.0.1:7898', 'https://': 'http://127.0.0.1:7898'}
Windows 的系统代理写在注册表里,urllib.request.getproxies() 读得到,httpx 拿它建挂载点,于是 127.0.0.1 也被送进代理,代理连不上就回 502。同一条 URL 用 requests 打是 200,因为它会参考系统代理的绕过列表(本机是 <local>)。绕开的办法有两个:trust_env=False,或者在建客户端之前设 NO_PROXY=127.0.0.1,localhost(实测有效,但要在创建 Client 之前设)。
第二个坑是忘写 await。异步函数不 await 时不会报错,只会在垃圾回收时给一条警告:
coroutine object: coroutine
warnings: ["coroutine 'never_awaited' was never awaited"]
请求一条都没发出去,日志里也没有异常,只有这条容易忽略的警告。
第三个坑是 raise_for_status() 对 3xx 不生效。它只在 4xx/5xx 抛 HTTPStatusError,重定向要靠 follow_redirects=True:
404 ok: False is_error: True
HTTPStatusError: Client error '404 Not Found' for url 'https://httpbin.org/status/404'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
第四个坑是关于流式下载的字节数。len(r.text) 和 num_bytes_downloaded 不是一回事:
status: 200
下载字节: 577
num_bytes_downloaded: 400
577 是解压后的字符数,400 是线路上实际收到的压缩字节。httpx 文档专门提醒这一点:做下载进度条时用 num_bytes_downloaded,用 iter_bytes() 的累计长度会和 Content-Length 对不上。
小结
同步与异步的差别不在单次请求快慢,而在能不能把等待叠起来。12 个 0.3 秒的请求,串行 3.62 秒,并发 0.43 秒,同一份业务代码只换了 with 和 await。反过来说,异步串行跑 3.67 秒,和同步几乎没有区别,把 async 当性能开关是没用的。
几个坑都出在"环境"和"边界"上:系统代理比代码更早决定了请求去哪,raise_for_status 只覆盖 4xx/5xx,流式字节数和字符数不是同一个量。先用 curl 或 -v 确认请求真的发出去了、去的是哪,再调代码。
数据来源与检索时间
检索时间 2026-11-10。版本、耗时与报错均在本机产生。
| 来源 | URL | 对应数据 |
|---|---|---|
| PyPI《httpx》项目页 | https://pypi.org/project/httpx/ | 当前版本 0.28.1;同步与异步双 API;HTTP/2 需 httpx[http2];依赖 httpcore/h11/certifi/idna/sniffio |
| httpx 官方文档《Clients》 | https://www.python-httpx.org/advanced/clients/ | Client 取代 requests 的 Session;连接池复用;客户端级与请求级参数的合并规则;base_url;用 num_bytes_downloaded 做下载进度的原因 |
| 本机运行记录 | D:\ai\cache\temp\py-httpx | 0.28.1;example.com 与 httpbin.org 的响应;本地服务 12×0.3s 的同步/异步/串行对照;trust_env 与 getproxies() 的输出;四段报错原文 |
核验说明
- 虚拟环境
D:\ai\cache\temp\py-httpx,Python 3.11.9,pip install httpx装到 0.28.1。 - 真跑的示例:example.com 的同步 GET、
Client配 base_url/Timeout/headers、httpbin 上 5 个/delay/1的gather、本地ThreadingHTTPServer上 12 次/slow的三种跑法、trust_env开关对照、NO_PROXY生效验证、404 的raise_for_status()、未 await 的协程警告、httpx.stream的字节数。 - 异步部分只测了
asyncio.gather一种并发方式,没测anyio、任务组、信号量限流;也没测 HTTP/2,因为没装httpx[http2]。 - 本地基准里的服务端是 Python 自带的
ThreadingHTTPServer,每个请求一个线程,本身有调度开销,所以 0.43s 比理论下限 0.3s 高。这组数字只能说明"并发比串行快多少",不能当成 httpx 的吞吐上限。