一个让人崩溃的周五下午
周五下午四点,离下班还有一小时。我正准备把最后一批数据跑完就收工。
需求很简单:给一万个用户发推送消息。每个用户调一次接口,返回成功或失败。这种批量活我干过无数次,轻车熟路。
打开编辑器,三下五除二写完代码:
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只抛了个异常,没告诉我具体是哪个参数出的问题。
那个周五,我加班到十点。
后来我才知道,**map和submit这两个方法,看着差不多,用起来天差地别**。选错了,轻则加班,重则线上事故。
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()的时候才抛出来。不捕获的话,异常就静默消失了,日志里啥都没有,你都不知道程序出了问题。
每个坑背后都是一个加班的夜晚。希望你能避开。
总结
map和submit,一个追求简洁,一个追求控制。
map代码少、上手快,适合那种"丢进去就不用管"的纯批量任务。但代价是放弃了几乎所有的控制权——异常处理模糊、结果顺序死板、函数签名固定。
submit代码多一些,但换来了对每个任务的精细控制——谁失败了、为什么失败、要不要重试、谁先完成谁先处理,全部由你说了算。
我的建议是:除非你真的确定这个任务简单到不可能出任何问题,否则就用submit。 多写几行代码的成本,远低于一次线上事故的代价。
那个周五加班的教训,我一直记到现在。希望读完这篇文章的你,不用再踩这个坑。