☰
Python多线程入门教程
2026/10/13 7:12:56 网站建设 项目流程

前言


学 Python 多线程,第一步要先把预期摆正:多线程不是让程序算得更快的万能药。它擅长的是「一边等、一边干活」——比如同时下载几十个页面、同时读写多个文件。如果你的瓶颈是一大堆计算,多线程帮不上忙,原因在 GIL(见下文第四节)。


新手最常见的两个错误认知:



  1. 「开 8 个线程就等于 8 核并行」——在默认的 CPython 里不是这样。

  2. 「线程越多越快」——线程一多,锁竞争和上下文切换的开销反而吃掉收益。


这篇教程从零开始:先写出第一个多线程程序,再讲清线程的创建、等待、参数传递,然后补上绕不开的同步问题,最后给一个可以照抄的线程池示例。示例以 CPython 3.8 及以上为基准。


一、第一个多线程程序


不使用任何库,标准库自带的threading就够了。


# 适用于 Python 3.8+
import threading
import time


def say_hello(name):
print(f"线程 {name} 开始")
time.sleep(1) # 模拟一件需要等待的事
print(f"线程 {name} 结束")


t1 = threading.Thread(target=say_hello, args=("A",))
t2 = threading.Thread(target=say_hello, args=("B",))

t1.start() # 启动,立刻返回
t2.start()

t1.join() # 主线程在这里等 t1 结束
t2.join()

print("主线程结束")

运行这段代码,会先看到两个「开始」,大约 1 秒后再看到两个「结束」,最后才是「主线程结束」。总耗时大约 1 秒而不是 2 秒——因为两次sleep是重叠发生的。这就是并发带来的收益。


如果改成先t1.join()再t2.start(),两次等待就会串起来,耗时翻倍。start()要先把所有线程都点着,最后统一join(),这是新手最容易写反的地方。


二、Thread的构造参数


threading.Thread的签名(来自官方文档):


Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None, context=None)

常用的几个参数:




参数含义备注



target线程要执行的可调用对象不传就什么也不做

args传给target的位置参数必须是元组;单元素别忘逗号

kwargs传给target的关键字参数字典

name线程名,仅供标识省略时自动生成

daemon是否为守护线程必须在start()前确定



一个高频踩坑点:args是元组,只传一个参数时必须写成args=("A",),逗号不能丢。写成args=("A")只是字符串,Thread会把它按字符拆开当成多个参数——或者干脆报参数个数不对。


# 适用于 Python 3.8+
import threading


def show(a, b):
print(f"a={a!r}, b={b!r}")


# 正确:元组里带逗号
threading.Thread(target=show, args=(1, 2)).start()

# 正确:传关键字参数
threading.Thread(target=show, kwargs={"a": 1, "b": 2}).start()

三、两种创建方式


方式一:函数式。把现成的函数交给target,最省事,也最推荐。


# 适用于 Python 3.8+
import threading


def task(n):
print(f"处理 {n}")


threading.Thread(target=task, args=(5,)).start()

方式二:继承式。定义一个类继承threading.Thread,重写run()。


# 适用于 Python 3.8+
import threading


class MyTask(threading.Thread):
def __init__(self, n, **kwargs):
super().__init__(**kwargs) # 必须先调用父类构造
self.n = n

def run(self): # 线程体写在这里
print(f"处理 {self.n},线程名 {self.name}")


t = MyTask(5)
t.start() # start() 内部会调用 run()

官方文档有一句关键提醒:继承Thread时只应重写__init__()和run(),不要重写其他方法。并且要直接调用t.start()而不是t.run()——直接调run()只是在当前线程里跑一个普通函数,根本没有新线程。


四、绕不开的 GIL


CPython 里有一把全局解释器锁(GIL,Global Interpreter Lock)。官方文档的说法是:同一时刻只有一个线程能执行 Python 代码。


由此得到两条实用结论:



  • I/O 密集任务用线程有效:线程在等网络、等磁盘时会释放 GIL,别的线程能接着跑。

  • CPU 密集任务用线程无效:大家抢同一把锁,多核用不上,官方建议改用multiprocessing或concurrent.futures.ProcessPoolExecutor。


补充一句事实:CPython 3.13 起有实验性的自由线程(no-GIL)构建,可以真正并行跑线程,但它不是默认构建、需要单独安装,所以日常说到「Python 的多线程」时,GIL 依然存在。


五、共享数据与锁


线程共享同一个进程的内存,多个线程同时改一个变量就会出问题。


# 适用于 Python 3.8+
import threading

count = 0


def increase():
global count
for _ in range(100_000):
count += 1 # 读-改-写,三步之间可能被切换


ts = [threading.Thread(target=increase) for _ in range(4)]
for t in ts:
t.start()
for t in ts:
t.join()

print(count) # 期望 400000,实际通常偏小

原因:count += 1在字节码层面是「取值 → 加一 → 写回」三步,解释器会在三步之间切换线程,两个线程可能都读到同一个旧值,各自加一后写回,于是丢了一次累加。


用threading.Lock把这三步锁成一个整体:


# 适用于 Python 3.8+
import threading

count = 0
lock = threading.Lock()


def increase():
global count
for _ in range(100_000):
with lock: # 自动 acquire / release,异常也会释放
count += 1

要点:锁保护的是「不可分割的一整段操作」,不是单个变量。加锁范围过大会让线程排队,等于变回单线程;范围过小则保护不住。


如果只是多个线程往一个地方塞数据、再由别人取走,更省心的做法是用queue.Queue——它是线程安全的,内部自带锁。


六、完整示例:线程池批量探测


手工start()/join()一批线程,写起来啰嗦且容易漏。生产代码更常用concurrent.futures.ThreadPoolExecutor。


# 适用于 Python 3.8+
import concurrent.futures
import time


def probe(name, delay):
time.sleep(delay) # 模拟一次耗时的 I/O
return f"{name} 完成(用时 {delay}s)"


def main():
jobs = [("任务1", 1.0), ("任务2", 1.2), ("任务3", 0.4)]
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
futures = [pool.submit(probe, name, d) for name, d in jobs]
for fut in concurrent.futures.as_completed(futures):
print(fut.result())


if __name__ == "__main__":
main()

三个任务串行要 2.6 秒左右,用线程池并发后,总耗时由最慢的那个决定,约 1.2 秒。with块退出时会自动shutdown(wait=True),不必手动收尾。


map()与submit()的区别值得记一下:submit()一次提交一个任务、返回Future;map()一次提交一批、按提交顺序返回结果迭代器。




需求用法



提交单个任务、逐个取结果pool.submit(fn, *args)

一批任务、只要最终结果pool.map(fn, iterable)

一批任务、按完成先后处理submit+as_completed

需要限制并发数构造时给max_workers赋值



常见坑点


1. 先join()再start(),写成串行


❌t1.start(); t1.join(); t2.start(); t2.join()✅t1.start(); t2.start(); t1.join(); t2.join()—— 先全部启动,再统一等待。


2. 单参数忘了逗号


❌Thread(target=f, args=("abc")),"abc"被当作三个字符拆开传。 ✅args=("abc",)或args=["abc"]。


3. 调用run()而不是start()


❌t.run()—— 在当前线程里同步执行,白写了。 ✅t.start(),由解释器另开线程调用run()。


4. 对同一个线程对象重复start()


❌t.start(); t.join(); t.start()抛RuntimeError。 ✅ 一个线程对象只能启动一次;要重跑请新建对象。


5. 用setDaemon()设置守护线程


❌t.setDaemon(True),3.10 起已废弃。 ✅Thread(target=f, daemon=True),或start()之前写t.daemon = True。


6. 主线程没join()就读结果


❌for t in ts: t.start()之后马上print(count)。 ✅ 先对所有线程join(),或用ThreadPoolExecutor的with块。


7. 用全局列表直接 append 之外的结构做共享


❌ 用普通字典一边读一边删,抛RuntimeError: dictionary changed size during iteration。 ✅ 用queue.Queue传递数据,或全程with lock:保护读改。


8. 在 CPU 密集任务上堆线程


❌ 用 8 个线程做纯计算,结果和单线程一样慢、甚至更慢。 ✅ 纯计算任务改用ProcessPoolExecutor;线程只留给 I/O。


总结




主题关键结论



创建线程Thread(target=..., args=(...)),别忘了start()

等待线程join()阻塞直到结束,恒返回None

并发收益只体现在 I/O 密集任务上

GIL默认 CPython 同一时刻只跑一个线程的字节码

共享数据必须加锁或改用queue.Queue

批量任务优先ThreadPoolExecutor,别再手工管理一堆线程



入门阶段的正确顺序是:先学会「启动 + 等待」这两个动作,再理解「为什么需要锁」,最后形成「I/O 用线程、计算用进程」的直觉。把线程当成 I/O 的工具而不是提速的魔法,就不会走进死胡同。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询