Python技术迷

Python 中如何实现多线程?

接口没挂,CPU 也不高,导出任务就是慢。

日志里每个用户的订单明细都要调一次外部接口,单次 300ms 左右,串起来跑 200 个用户,时间直接奔着一分钟去了。这种场景我一般不会先去抠算法,先看它到底是在算,还是在等。

像这种日志,基本就很明显:

2026-06-04 10:18:21 fetch user=10021 cost=318ms
2026-06-04 10:18:22 fetch user=10022 cost=291ms
2026-06-04 10:18:22 fetch user=10023 cost=337ms
...

每一行都在等网络返回。Python 多线程在这种地方就有用了。

别一听 Python 多线程就先扯 GIL。GIL 确实存在,CPU 密集型任务,比如压缩大文件、跑复杂计算、批量图片处理,多线程不一定快,甚至可能更慢。但如果你的程序大部分时间都在等接口、等数据库、等磁盘,那线程切出去干别的活,是能省时间的。

最直接的写法是 threading.Thread。

import time
import random
import threading

defpull_user_bill(user_id: int):
    start = time.time()
# 模拟一次远程接口调用
    time.sleep(random.uniform(0.2, 0.5))

    cost = int((time.time() - start) * 1000)
    print(f"[bill-sync] user={user_id} cost={cost}ms")

users = [10021, 10022, 10023, 10024, 10025]

threads = []
for uid in users:
    t = threading.Thread(
        target=pull_user_bill,
        args=(uid,),
        name=f"bill-worker-{uid}"
    )
    t.start()
    threads.append(t)

for t in threads:
    t.join()

print("all user bill pulled")

这里有两个点别省。

一个是 start(),线程真正开始跑。

一个是 join(),主线程等子线程全部跑完。你要是忘了 join(),脚本类任务里很容易出现一种现象:主流程提前结束,后面的结果还没来得及处理。这个坑不大,但挺烦。

不过上面这种写法,我只会在临时脚本里用。线上代码如果这么写,一批 5000 个用户,你就敢创建 5000 个线程,机器不骂人才怪。

线程不是免费的。每个线程都有栈空间,线程太多之后,CPU 光在线程切换上就够喝一壶。这个地方要加控制。

更像生产代码的写法,是用队列。

import time
import queue
import random
import threading

task_box = queue.Queue(maxsize=200)
bad_users = []

deffetch_score(user_id: int) -> int:
    time.sleep(random.uniform(0.1, 0.4))

if user_id % 17 == 0:
raise TimeoutError("score service timeout")

return random.randint(1, 100)

defworker():
whileTrue:
        user_id = task_box.get()

if user_id isNone:
            task_box.task_done()
break

try:
            score = fetch_score(user_id)
            print(f"[score-ok] user={user_id}, score={score}")
except Exception as e:
            bad_users.append(user_id)
            print(f"[score-fail] user={user_id}, err={e}")
finally:
            task_box.task_done()

workers = []
for i in range(8):
    t = threading.Thread(target=worker, name=f"score-worker-{i}")
    t.start()
    workers.append(t)

for uid in range(10001, 10101):
    task_box.put(uid)

for _ in workers:
    task_box.put(None)

task_box.join()

for t in workers:
    t.join()

print("failed users:", bad_users)

这个写法丑一点,但我更信。

Queue 负责塞任务,8 个 worker 负责消费。线程数固定,不会因为任务多就把机器打爆。None 是停止信号,告诉线程可以收工了。

这里我故意把失败用户放到了 bad_users 里。实际业务里别只打印日志,最好把失败数据落下来,后面补偿。多线程最怕那种“看起来跑完了,其实中间丢了几条”的代码。

还有一个问题,多个线程同时改同一个变量,会出事。

比如你想统计成功数量,很多人会这么写:

success_count += 1

这行看着像一步,其实不是。读出来、加一、写回去,中间都可能被别的线程插进来。数据量小的时候看不出来,一上量就开始少几个,查起来很恶心。

要么别共享变量,要么加锁。

import threading

stat_lock = threading.Lock()
stat = {
"ok": 0,
"fail": 0
}

defmark_ok():
with stat_lock:
        stat["ok"] += 1

defmark_fail():
with stat_lock:
        stat["fail"] += 1

锁别乱加。能只锁两行,就别把整个接口调用包进去。外部接口本来就慢,你把锁套在外面,8 个线程又被你写回了单线程。

平时我更常用的是 ThreadPoolExecutor,代码短,也不容易写散。

from concurrent.futures import ThreadPoolExecutor, as_completed
import time
import random

defcheck_sku_stock(sku_id: str):
    time.sleep(random.uniform(0.1, 0.35))

if sku_id.endswith("9"):
raise RuntimeError("stock api rejected")

return sku_id, random.randint(0, 50)

sku_list = [f"sku-{i}"for i in range(30)]

with ThreadPoolExecutor(max_workers=6, thread_name_prefix="stock") as pool:
    futures = {
        pool.submit(check_sku_stock, sku): sku
for sku in sku_list
    }

for future in as_completed(futures):
        sku = futures[future]
try:
            sku_id, stock = future.result()
            print(f"[stock] {sku_id}={stock}")
except Exception as e:
            print(f"[stock-error] sku={sku}, err={e}")

这个版本适合大多数接口并发、文件批处理、批量校验任务。

max_workers 不要拍脑袋写 100、200。IO 慢一点可以开大一些,但也要看下游扛不扛得住。你本地跑得飞快,下游库存服务被你打挂了,那不叫优化,那叫制造事故。

我一般会先从 4、8、16 这种小数字试起,看日志里的平均耗时和失败率。如果失败率开始上升,或者下游开始报限流,就别再加线程了。

多线程还有个细节:异常不会自动抛到主线程。

直接用 threading.Thread 时,子线程里炸了,主线程可能还在那儿继续跑。日志没打好,你甚至不知道哪条数据失败。ThreadPoolExecutor 的好处是 future.result() 会把异常重新抛出来,至少你能集中处理。

最后说下 Python 多线程适合放在哪些地方。

批量调接口,适合。

批量读小文件,适合。

批量查数据库,要谨慎,连接池别被打满。

CPU 计算,不太适合,优先看多进程或者换实现方式。

多线程不是让代码“高级”的东西,它就是个工具。看到程序慢,先别急着上线程,先把日志打出来,看时间到底耗在哪里。要是时间都花在等待上,多线程能救;要是时间都花在计算上,它大概率帮不上忙。

这一步判断错了,后面代码写得再漂亮也没用。