news 2026/7/12 13:25:41

第26章:并发请求——多线程与Session线程安全性

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
第26章:并发请求——多线程与Session线程安全性

1. 项目背景

业务场景

某电商平台的商品详情页需要聚合 8 个微服务的数据:商品基本信息、库存、价格、评价(3 个来源)、推荐、用户收藏状态。最初方案是串行调用——总耗时 = 8 个服务的耗时之和,P99 延迟高达 1.2 秒,远超 300ms 的 SLO。架构师要求改为并发调用,预期将总耗时降低到最慢的那个服务的时间(约 200ms)。

团队用ThreadPoolExecutor+ 共享Session实现了并发。上线第一周一切正常,P99 降到了 250ms。但第二周问题爆发了:部分用户的收藏状态显示错误——用户 A 看到了用户 B 的收藏列表。排查发现:所有线程共享同一个 Session,而 Session 的 Cookie 不是线程安全的——线程 A 设置了用户 A 的 Cookie,但被线程 B 的响应 Cookie 覆盖了,线程 A 后续请求带上了线程 B 的 Cookie。

痛点

问题一:Session 的"大致线程安全"到底哪里不安全。官方文档说 Session 是 “roughly thread-safe”,但这个 “roughly” 到底指什么?实际上:Session 的连接池(urllib3 PoolManager)是线程安全的(使用锁保护),但 Cookie 管理(session.cookies)不是线程安全的。更微妙的是——Session 的headersauthhooks等属性在运行时修改也不是线程安全的。

问题二:连接池在并发场景下的竞争。pool_maxsize默认 10,如果 20 个线程共享一个 Session 去同一个 host,只有 10 个线程能从连接池中拿到连接,另外 10 个会阻塞等待(如果pool_block=True)或创建新连接(如果pool_block=False)。创建新连接意味着额外的 TCP+TLS 握手开销——这正是 Session 试图避免的。

问题三:ThreadPoolExecutor 的两种 Session 策略互有优劣。策略 A(共享 Session):连接复用最好、内存占用最少,但 Cookie 不安全。策略 B(每线程独立 Session):Cookie 安全、连接池资源浪费。策略 C(threading.local()Session):两者折中——每个线程有独立的 Session(Cookie 安全),但同一线程内连接可复用。

三种策略对比:

策略 A: 全局共享 Session ✅ 连接复用最好 ✅ 代码最简单 ❌ Cookie 非线程安全 ❌ 连接池竞争激烈 策略 B: 每个请求创建 Session ✅ Cookie 完全隔离 ❌ 无连接复用(每次新建连接) ❌ 性能最差 策略 C: threading.local() Session ✅ Cookie 隔离(每线程一个) ✅ 同一线程内连接复用 ✅ 性能接近策略 A ❌ 代码稍复杂

2. 项目设计

小胖(指着生产日志,满头大汗):
“大师!我们的商品详情页出大问题了!用户 A 看到了用户 B 的收藏列表——这是严重的隐私泄露!代码审查看了一遍,逻辑完全没问题啊。”

大师(看了看代码):
“你们是不是用的是一个全局 Session,多个线程共享?”

小胖
“对啊,线程池里 20 个 Worker,共享一个 Session——网上教程都这么写的,说 Session 是线程安全的。”

大师
“这就是问题所在。Session 的 HTTP 连接池是线程安全的,但 Cookie 管理不是。线程 A 发了请求,Session 收到响应后把 Set-Cookie 存入了session.cookies。与此同时,线程 B 也在发请求——它看到 CookieJar 里是线程 A 的 Cookie,就带着它发出了。这就是跨用户 Cookie 泄露的根因。”

小胖(挠头):
“那怎么办?让每个线程创建自己的 Session?那连接复用不就没了吗?”

大师
“有个折中方案——threading.local()。你可以把它想象成’每人发一个独立的钱包(Cookie),但共用一辆公交车(连接池)'——但很遗憾,连接池也跟 Session 绑定了。所以实践中最常用的方案是:每个线程维护自己的 Session,用连接池的复用收益换 Cookie 的安全性。

importthreadingimportrequests# 方案 C: threading.local()thread_local=threading.local()defget_session()->requests.Session:"""获取当前线程的专属 Session"""ifnothasattr(thread_local,"session"):thread_local.session=requests.Session()thread_local.session.headers.update({"Authorization":"Bearer xxx"})returnthread_local.session# 每个线程调用 get_session() 获取自己的 Session# 同一线程内多次调用返回同一个 Session(连接复用)# 不同线程之间完全隔离(Cookie 安全)

小白(追问):
“那如果我不想在每个线程里创建 Session(因为连接池很贵),能不能让 Session 不管理 Cookie,只用它的连接池?”

大师
“可以的。你可以在请求前手动session.cookies.clear(),或者用另一个简单的方法——为所有请求显式传cookies参数,而不是依赖 Session 的自动 Cookie 管理:”

session=requests.Session()# 共享连接池deffetch_with_user_cookie(url,user_token):# 每次传入独立的 Cookie,不依赖 Session 的 Cookie Jarresp=session.get(url,cookies={"token":user_token})returnresp.json()

小胖
“那并发数和连接池大小怎么配置?20 个线程,pool_maxsize 设多少?”

大师
“黄金法则:pool_maxsize >= 并发线程数。20 个线程 -> pool_maxsize >= 20。如果 pool_maxsize < 线程数,多出来的线程要么阻塞等待(pool_block=True),要么每次创建新连接再立即关闭(pool_block=False)——浪费资源。但也不是越大越好——每个活跃连接都占用一个 TCP 端口和内核 socket 缓冲区。”

生活比喻技术映射
多窗口银行柜台多线程并发请求
排队叫号(一个号码一个业务)threading.local() 隔离 Session
所有窗口共用一个大堂经理共享 Session 的 Cookie 非线程安全
柜台太少客户排队pool_maxsize 不足
大堂经理同时服务多人(搞混了)Cookie 跨线程泄露

3. 项目实战

环境准备

pipinstallrequests

分步实现

步骤一:共享 Session 的 Cookie 污染实验

目标:直观验证共享 Session 在多线程下的 Cookie 交叉污染问题。

importrequestsimportthreadingimporttimefromconcurrent.futuresimportThreadPoolExecutordefsimulate_cookie_leak():"""模拟多线程共享 Session 导致的 Cookie 泄露"""shared_session=requests.Session()results=[]# 记录"用户看到的是谁的 Cookie"deffetch_as_user(user_id:str):"""模拟以某个用户身份请求"""# 先设置该用户的 Cookie(模拟登录)shared_session.get("https://httpbin.org/cookies/set",params={"userId":user_id,"session":f"sess_{user_id}"},)# 等待一点时间让其他线程有机会干扰time.sleep(0.01)# 读取当前 Session 持有的 Cookieresp=shared_session.get("https://httpbin.org/cookies")cookies_seen=resp.json().get("cookies",{})results.append({"thread_user":user_id,"cookies_seen":cookies_seen,})# 10 个线程并发请求users=[f"user_{i}"foriinrange(10)]withThreadPoolExecutor(max_workers=10)asexecutor:foruserinusers:executor.submit(fetch_as_user,user)# 检查结果:有多少线程看到了不属于自己的 Cookie?wrong_count=0forrinresults:seen=r["cookies_seen"].get("userId","unknown")ifseen!=r["thread_user"]:wrong_count+=1print(f" ⚠{r['thread_user']}看到了{seen}的 Cookie!")print(f"\nCookie 交叉污染率:{wrong_count}/{len(results)}"f"({wrong_count/len(results)*100:.0f}%)")# 结论:共享 Session 在高并发下 Cookie 安全无法保证shared_session.close()returnresultsprint("===== Cookie 泄露实验 =====")simulate_cookie_leak()
步骤二:threading.local() 安全并发方案

目标:实现线程安全的 Session 管理,避免 Cookie 泄露。

importrequestsimportthreadingfromconcurrent.futuresimportThreadPoolExecutor,as_completedimporttimeimportstatistics# 为每个线程维护独立的 Session_thread_local=threading.local()defget_thread_session()->requests.Session:"""获取线程专属的 Session(线程安全)"""ifnothasattr(_thread_local,"session"):s=requests.Session()# 每个线程的 Session 可以有自己的默认配置s.headers.update({"User-Agent":"ThreadSafeClient/1.0"})_thread_local.session=sreturn_thread_local.sessiondeffetch_url(url:str)->dict:"""安全的并发请求函数"""session=get_thread_session()start=time.perf_counter()try:resp=session.get(url,timeout=10)elapsed=time.perf_counter()-startreturn{"url":url,"status":resp.status_code,"elapsed_ms":round(elapsed*1000,2),"thread":threading.current_thread().name,}exceptExceptionase:return{"url":url,"status":-1,"elapsed_ms":0,"error":str(e)[:50],"thread":threading.current_thread().name,}defconcurrent_fetch(urls:list,max_workers:int=10)->list:"""并发获取多个 URL"""results=[]start=time.perf_counter()withThreadPoolExecutor(max_workers=max_workers)asexecutor:futures={executor.submit(fetch_url,url):urlforurlinurls}forfutureinas_completed(futures):result=future.result()results.append(result)total_time=time.perf_counter()-start success=[rforrinresultsifr["status"]>0]failed=[rforrinresultsifr["status"]<0]elapsed_list=[r["elapsed_ms"]forrinsuccess]print(f"\n===== 并发请求结果 =====")print(f"总数:{len(urls)}, 成功:{len(success)}, 失败:{len(failed)}")ifelapsed_list:print(f"延迟: avg={statistics.mean(elapsed_list):.1f}ms, "f"P50={statistics.median(elapsed_list):.1f}ms, "f"max={max(elapsed_list):.1f}ms")print(f"总耗时(并发):{total_time:.2f}s")print(f"加速比:{sum(elapsed_list)/total_time:.1f}x (vs 串行)")returnresults# ===== 测试:并发请求 httpbin 的多个端点 =====urls=["https://httpbin.org/get?q=1","https://httpbin.org/get?q=2","https://httpbin.org/get?q=3","https://httpbin.org/headers","https://httpbin.org/ip","https://httpbin.org/user-agent",]concurrent_fetch(urls*2,max_workers=6)# 12 个请求,6 线程
步骤三:连接池调优——并发场景下的 pool_maxsize

目标:验证不同 pool_maxsize 对并发性能的影响。

importrequestsfromrequests.adaptersimportHTTPAdapterfromconcurrent.futuresimportThreadPoolExecutor,as_completedimporttimeimportstatisticsdefbenchmark_pool_size(pool_maxsize:int,n_requests:int=30,n_workers:int=15):"""测试不同连接池大小对并发性能的影响"""session=requests.Session()adapter=HTTPAdapter(pool_maxsize=pool_maxsize)session.mount("https://",adapter)times=[]deffetch():start=time.perf_counter()try:session.get("https://httpbin.org/get",timeout=10)times.append(time.perf_counter()-start)exceptException:passstart=time.perf_counter()withThreadPoolExecutor(max_workers=n_workers)asexecutor:futures=[executor.submit(fetch)for_inrange(n_requests)]forfinas_completed(futures):f.result()total=time.perf_counter()-start session.close()avg=statistics.mean(times)*1000iftimeselse0p99=sorted(times)[int(len(times)*0.99)]*1000iftimeselse0return{"pool_maxsize":pool_maxsize,"workers":n_workers,"total_s":round(total,2),"avg_ms":round(avg,1),"p99_ms":round(p99,1),"qps":round(n_requests/total,1),}print("===== 连接池大小对比 =====")print(f"{'pool_maxsize':<15}{'workers':<10}{'total_s':<10}{'avg_ms':<10}{'p99_ms':<10}{'qps'}")print("-"*65)forsizein[5,10,20]:result=benchmark_pool_size(size,n_requests=30,n_workers=10)print(f"{result['pool_maxsize']:<15}{result['workers']:<10}"f"{result['total_s']:<10}{result['avg_ms']:<10}"f"{result['p99_ms']:<10}{result['qps']}")

可能遇到的坑及解决方法

坑1:threading.local()的 Session 不会自动关闭

# threading.local() 在线程退出时不会自动调用 close()# 长时间运行可能导致连接泄露# 解决: 在 Worker 线程退出前手动关闭,或使用 atexit 注册清理importatexit atexit.register(lambda:getattr(_thread_local,'session',None)and_thread_local.session.close())

坑2:GIL 限制了 requests 线程并发的 CPU 密集型部分

# requests 的 JSON 解析、SSL 加解密受 GIL 影响# 但 HTTP IO 等待期间 GIL 被释放,所以 IO 密集型场景线程并发仍然有效# 如果 JSON 解析是瓶颈,考虑用 orjson 替代标准 json

坑3:连接池耗尽时pool_block=True导致死锁风险

# 两个线程各持有一个连接,互相等待对方释放 -> 死锁# 建议: pool_block=False(默认),让额外线程创建临时连接# 或: 设置合理的 pool_maxsize >= 预期最大并发线程数

测试验证

importpytestimportrequestsimportthreadingfromconcurrent.futuresimportThreadPoolExecutorclassTestConcurrentRequests:"""验证并发请求功能"""deftest_thread_local_session_isolation(self):"""验证 threading.local() 为不同线程创建不同 Session"""local=threading.local()sessions_seen=[]defget_and_record():ifnothasattr(local,"s"):local.s=requests.Session()sessions_seen.append(id(local.s))withThreadPoolExecutor(max_workers=5)ase:for_inrange(20):e.submit(get_and_record)# 所有线程都应该看到同一个 Session ID(因为只有 5 个线程重复使用)# 但不同线程之间的 Session 是不同的unique_sessions=len(set(sessions_seen))print(f"\n Session 实例数:{unique_sessions}")assert1<=unique_sessions<=5deftest_concurrent_requests_succeed(self):urls=["https://httpbin.org/get"]*5deffetch(url):returnrequests.get(url,timeout=10).status_codewithThreadPoolExecutor(max_workers=3)ase:results=list(e.map(fetch,urls))assertall(r==200forrinresults)deftest_session_not_thread_safe_for_cookies(self):"""验证 Session Cookie 在多线程下不安全(预期行为)"""s=requests.Session()cookie_issues=[]defuser_flow(user_id):s.get("https://httpbin.org/cookies/set",params={"user":user_id})resp=s.get("https://httpbin.org/cookies")seen=resp.json().get("cookies",{}).get("user","unknown")ifseen!=user_id:cookie_issues.append((user_id,seen))withThreadPoolExecutor(max_workers=5)ase:foriinrange(20):e.submit(user_flow,f"u{i}")print(f"\n Cookie 不一致次数:{len(cookie_issues)}")# 期望:有 Cookie 交叉污染(证明非线程安全)# 如果为 0,只是运气好s.close()

4. 项目总结

核心知识点

策略Cookie 安全连接复用代码复杂度
全局共享 Session❌ 不安全✅ 最好
每请求创建 Session✅ 安全❌ 无复用
threading.local() Session✅ 安全✅ 线程内复用
每个请求传 cookies 参数✅ 安全✅ 复用

优点 & 缺点

对比维度ThreadPoolExecutorasyncio + aiohttp单线程串行
并发能力中(受限于线程数)高(协程切换)
编程模型同步(易理解)异步(需 async/await)同步
GIL 影响IO 密集时影响小几乎没有
内存占用每线程 ~8MB 栈每协程 ~KB 级最低

适用场景

  • 聚合多个下游 API:并发调用减少总延迟
  • 批量数据采集:并发下载 N 个页面
  • API 网关/聚合层:高并发路由请求
  • 定时任务批处理:加快处理速度

注意事项

  • Session 的 Cookie 不是线程安全的——每个线程应有独立 Session 或每次传 cookies
  • pool_maxsize 应 >= 并发线程数,避免连接池竞争
  • threading.local() 需要手动管理 Session 生命周期(close)
  • GIL 对 SSL 加解密和 JSON 解析有影响,极高并发场景考虑 asyncio

常见踩坑经验

案例一:共享 Session 导致用户串号。某电商网关 20 个 Worker 线程共享一个 Session。高峰期用户 A 的认证 Token(通过 Set-Cookie 返回)被写入了 session.cookies,同时用户 B 的请求恰好复用这个 Cookie,导致 B 看到了 A 的数据。P0 级数据泄露。根因:Session Cookie 非线程安全。修复:改用 threading.local() 或每次请求显式传入 cookies。

案例二:pool_maxsize=2,20 线程饥饿。某数据采集程序 20 线程共享一个 Session,默认 pool_maxsize=10。实际测试发现 10 个线程阻塞等待连接。根因:连接池不够大且pool_block=True导致线程排队。修复:pool_maxsize 调整为 30,或 pool_block=False 允许创建临时连接。

案例三:threading.local() Session 未关闭导致 fd 泄漏。某服务长时间运行(不重启)后发现Too many open files错误。排查发现 threading.local() 创建的 Session 在线程死亡后没有调用 close(),连接池中的 socket 文件描述符未释放。根因:未管理 Session 生命周期。修复:在 Worker 退出时显式session.close()

思考题

  1. 设计题:你需要设计一个"并发请求协调器"——控制对同一个 host 的并发连接数不超过 pool_maxsize,但对不同 host 的请求不互相限制。如何使用信号量(Semaphore)实现这个功能?

  2. 深度题:Python 的 GIL(Global Interpreter Lock)在 IO 密集场景(如 HTTP 请求)和 CPU 密集场景(如 JSON 解析、SSL 加密)下的影响有何不同?为什么 requests 的线程并发在 IO 密集时加速比接近线程数,但在 JSON 解析量大时加速比变差?

延伸阅读与资源

Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战

大型语言模型(LLM) vLLM 高性能推理落地实战

Agent开发之LlamaIndex 实战修炼与源码进阶

大语言模型Transformers 实战修炼与源码剖析

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/12 13:25:21

Bug生命周期管理实战:从New到Closed的5种状态流转与3个沟通要点

Bug生命周期管理实战&#xff1a;从New到Closed的5种状态流转与3个沟通要点在软件质量保障体系中&#xff0c;缺陷管理如同精密仪器的齿轮组&#xff0c;每个状态的切换都直接影响产品交付的顺畅度。当测试人员提交一个New状态的缺陷时&#xff0c;这个微小事件便开启了跨职能协…

作者头像 李华
网站建设 2026/7/12 13:25:19

OpenStack Yoga 部署实战:3节点高可用架构搭建与Neutron网络配置详解

OpenStack Yoga 3节点高可用部署与Neutron网络深度配置指南1. 环境规划与基础准备在开始部署OpenStack Yoga版本之前&#xff0c;合理的环境规划是成功的关键。我们将采用三节点架构&#xff0c;分别承担控制节点、计算节点和网络节点的角色。这种分离式设计不仅提高了系统可靠…

作者头像 李华
网站建设 2026/7/12 13:23:09

Windows 11 安装 g++ 编译器:MSYS2 + MinGW-w64 完整配置 5 步指南

Windows 11 极速搭建 C 开发环境&#xff1a;MSYS2 MinGW-w64 实战手册 为什么选择 MSYS2 MinGW-w64 组合&#xff1f; 在 Windows 平台进行 C 开发时&#xff0c;很多开发者会遇到环境配置的难题。传统方案如直接安装 MinGW 或 Cygwin 往往存在包管理混乱、依赖关系复杂等问…

作者头像 李华
网站建设 2026/7/12 13:22:51

跨平台资源下载终极指南:3分钟掌握免费开源神器res-downloader

跨平台资源下载终极指南&#xff1a;3分钟掌握免费开源神器res-downloader 【免费下载链接】res-downloader 视频号、小程序、抖音、快手、小红书、直播流、m3u8、酷狗、QQ音乐等常见网络资源下载! 项目地址: https://gitcode.com/GitHub_Trending/re/res-downloader 你…

作者头像 李华
网站建设 2026/7/12 13:22:47

PIC32MZ与AD7490构建高精度多通道数据采集系统

1. 项目背景与核心需求在工业自动化、医疗设备和消费电子等领域&#xff0c;模拟信号到数字信号的转换&#xff08;ADC&#xff09;是嵌入式系统设计中的基础环节。AD7490作为一款16通道、12位精度的逐次逼近型&#xff08;SAR&#xff09;ADC芯片&#xff0c;配合PIC32MZ1024E…

作者头像 李华
网站建设 2026/7/12 13:18:00

无服务器oj部署

何绑定域名至cloudflare可以参考官网的说明 1.配置本地环境 1.下载项目&#xff1a; git clone https://github.com/wanwusangzhigit/eoj.git cd eoj 2.配置本地环境 cd frontend npm i cd … cd backend npm i cd … 3.全局安装cloudflare cli并登录 npm i -g wrangler wrangl…

作者头像 李华