线程池真凶找到了,原来submit和map用错代价这么大

简介: 本文剖析Python线程池中`map`与`submit`的本质差异:`map`简洁但脆弱——异常中断全盘、顺序阻塞、无法细粒度控制;`submit`稍繁却可靠——精准捕获异常、按完成顺序处理、支持超时重试与混合任务。推荐业务场景优先选`submit`,避免加班救火。

一个让人崩溃的周五下午

周五下午四点,离下班还有一小时。我正准备把最后一批数据跑完就收工。

需求很简单:给一万个用户发推送消息。每个用户调一次接口,返回成功或失败。这种批量活我干过无数次,轻车熟路。

打开编辑器,三下五除二写完代码:

from concurrent.futures import ThreadPoolExecutor

def send_push(user_id):
   # 调用推送接口
   return api.send(user_id)

with ThreadPoolExecutor(max_workers=10) as executor:
   results = executor.map(send_push, user_ids)

success_count = sum(results)
print(f"成功推送{success_count}条")

简洁,优雅,一气呵成。执行,然后去倒了杯咖啡。

回来一看,屏幕上一片红色报错。第3872个用户推送失败了——一个用户的数据格式有问题,抛了个异常。但问题在于,整个程序停在了那里,前面成功的三千多条全白跑了。

更离谱的是,我根本不知道第3872个用户到底是谁——map只抛了个异常,没告诉我具体是哪个参数出的问题。

代理 IP 使用小技巧 让你的数据抓取效率翻倍 (73).png

那个周五,我加班到十点。

后来我才知道,**mapsubmit这两个方法,看着差不多,用起来天差地别**。选错了,轻则加班,重则线上事故。

map:看起来最省事,坑起来最要命

先说说map。它是线程池里最"傻瓜式"的用法。

你把一个函数和一个列表丢进去,它帮你并发执行,最后按顺序把结果吐出来。代码量最少,读起来最舒服。

# map 的典型用法
with ThreadPoolExecutor(max_workers=5) as executor:
   results = executor.map(fetch_page, url_list)
   for data in results:
       process(data)

就这么几行,搞定并发。

map有个致命问题:它把"简洁"和"控制权"一起拿走了

第一,异常处理基本靠运气。 map内部任何一个任务抛出异常,等你遍历结果的时候异常就会抛出来。但你不知道是哪个任务出的错,也不知道前面已经成功的任务怎么办。整个迭代器直接报废。

第二,结果必须按顺序等。 map保证返回顺序和输入顺序一致。听起来是好事对吧?但代价是——即使第1个任务卡住了,后面99个早就完事了,你也得干等着。

# 任务1耗时10秒,任务2-100各耗时0.1秒
results = executor.map(slow_task, tasks)
for r in results:  # 卡在第一个,后面的明明好了也拿不到
   print(r)

第三,函数签名被锁死了。 map只接受一个函数和一组参数列表。如果每个任务需要不同的参数、不同的函数,甚至需要关键字参数——map直接歇菜。

官方文档里有一句话值得注意:map的iterables是"立即收集"的。意思是提交任务的时候,所有参数会一次性全部加载到内存里。数据量大的时候,内存可能先扛不住。

所以map适合什么场景?所有任务用同一个函数、参数结构一致、不需要精细异常处理、不关心谁先完成——说白了,就是那种"丢进去就不用管"的纯批量活。

但现实中的业务,哪有那么多"不用管"的活?

submit:麻烦一点,但能救命

再说submit

submit每次提交一个任务,返回一个Future对象。这个Future就像一张"取件凭证"——任务还在跑,你先拿着凭证,等会儿凭票取结果。

# submit 的典型用法
with ThreadPoolExecutor(max_workers=5) as executor:
   futures = []
   for url in url_list:
       future = executor.submit(fetch_page, url)
       futures.append(future)
   
   for future in futures:
       result = future.result()  # 阻塞等待,按提交顺序取
       process(result)

看起来代码多了几行,但换来的是对每个任务的完全控制

异常处理精确到人。 每个Future可以单独捕获异常:

for future in futures:
   try:
       result = future.result(timeout=5)
       success_count += 1
   except TimeoutError:
       log.error("任务超时了")
   except Exception as e:
       log.error(f"任务失败了:{e}")

哪个任务挂了、为什么挂、要不要重试——你说了算。一个失败不影响其他任务。

谁先完成谁先处理。 配合as_completed,可以按完成顺序拿结果:

from concurrent.futures import as_completed

with ThreadPoolExecutor(max_workers=10) as executor:
   futures = {executor.submit(send_push, uid): uid for uid in user_ids}
   
   for future in as_completed(futures):
       uid = futures[future]
       try:
           result = future.result()
           print(f"用户{uid}完成了")
       except Exception:
           print(f"用户{uid}失败了,记下来重试")

慢任务慢慢跑,快任务先返回。用户体验好,排查问题也方便。

想调什么函数都行。 submit支持任意函数、任意参数、任意关键字参数。一个线程池里可以混着发不同的任务:

executor.submit(send_email, user_email)
executor.submit(process_image, img_path, quality=80)
executor.submit(call_third_party_api, payload, timeout=3)

map做不到的事,submit都能做。

代价是什么?代码多了几行

一个真实的对比

回到开头那个推送用户的场景。如果用submit重写:

from concurrent.futures import ThreadPoolExecutor, as_completed

failed_users = []

with ThreadPoolExecutor(max_workers=10) as executor:
   futures = {executor.submit(send_push, uid): uid for uid in user_ids}
   
   for future in as_completed(futures):
       uid = futures[future]
       try:
           result = future.result(timeout=3)
           if not result.success:
               failed_users.append(uid)
       except Exception as e:
           print(f"用户{uid}推送失败:{e}")
           failed_users.append(uid)

print(f"成功{len(user_ids) - len(failed_users)}条,失败{len(failed_users)}条")
# 失败的用户还能单独重跑
retry_push(failed_users)

同样是并发推送,区别在哪?

  • map版本:一个用户数据有问题,全部崩掉,你连是谁的问题都不知道。
  • submit版本:哪个用户失败清清楚楚,记下来单独重试,其他人不受影响。

多写几行代码,少加几天班。

什么时候用map,什么时候用submit?

说清楚了区别,结论其实很简单:

map的场景:

  • 所有任务调用同一个函数
  • 参数来自一个简单的列表
  • 不关心谁先完成
  • 不需要精细的异常处理
  • 数据量不大(内存扛得住)

比如批量给图片加水印、批量计算数学公式、批量转换文件格式——纯计算、无状态、不怕失败。

submit的场景:

  • 需要精确的异常处理和日志
  • 任务执行时间差异大,想先完成的先处理
  • 需要设置超时时间
  • 任务函数不同参数不同
  • 需要重试机制进度监控

说白了,**但凡你对任务有一点点"控制欲",就用submit**。

我踩过的坑,希望你别再踩

最后说几个我亲身踩过的坑,算是用加班换来的经验:

坑一:把map的结果转成list。 map返回的是迭代器,不是list。如果你写成list(executor.map(func, items)),相当于把所有结果一次性塞进内存。数据量大的时候,内存直接爆。

坑二:submit之后直接调result() 有些人写futures = [executor.submit(func, x) for x in items],然后for f in futures: f.result()。这样写等于串行等待——第一个没完成,后面的全都等着,线程池白开了。

正确做法是用as_completed,谁先完成谁先处理。

坑三:不设超时。 某个任务卡住了,整个程序就卡在那里。future.result(timeout=5)能救命。

坑四:异常不捕获。 submit本身不会抛出异常。异常被藏在Future里,等你调用result()的时候才抛出来。不捕获的话,异常就静默消失了,日志里啥都没有,你都不知道程序出了问题。

每个坑背后都是一个加班的夜晚。希望你能避开。

总结

mapsubmit,一个追求简洁,一个追求控制。

map代码少、上手快,适合那种"丢进去就不用管"的纯批量任务。但代价是放弃了几乎所有的控制权——异常处理模糊、结果顺序死板、函数签名固定。

submit代码多一些,但换来了对每个任务的精细控制——谁失败了、为什么失败、要不要重试、谁先完成谁先处理,全部由你说了算。

我的建议是:除非你真的确定这个任务简单到不可能出任何问题,否则就用submit 多写几行代码的成本,远低于一次线上事故的代价。

那个周五加班的教训,我一直记到现在。希望读完这篇文章的你,不用再踩这个坑。

目录
相关文章
人工智能 缓存 前端开发
11599 56
人工智能 JavaScript 开发工具
4588 17
开发工具 Swift git
1855 5
Web App开发 人工智能 API
1081 1
人工智能 Java BI
1228 1
人工智能 JavaScript 测试技术
2040 2
人工智能 JavaScript 测试技术
1035 4
缓存 JavaScript Shell
2030 3