KernelRanch
返回文章列表

httpx 0.28 同步与异步:同一份代码两种写法,12 个请求差 8.5 倍

2026-11-10 #Python

一个客户端库同时提供同步与异步接口,好处是选型不用改代码结构:脚本里 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-httpx0.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 的吞吐上限。