查看: 188|回复: 3

Python多线程并发编程:GIL判断、Lock同步与线程池实践

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
在 Python 里写并发,很多人的第一反应是开线程。但真正动手之前,必须先搞清楚一件事:GIL。CPython 解释器在同一时刻只允许一个线程执行 Python 字节码,这条限制直接决定了线程该怎么用。

一、先判断任务类型,再决定用不用线程

原文给出的判断标准很干脆:


  • I/O 密集型(网络请求、文件读写、数据库查询):线程在等待 I/O 时 GIL 会释放,多线程有效。
  • CPU 密集型(大量计算、图像处理):线程之间抢 GIL,多线程不但没用,还可能更慢。


一句话概括:Python 多线程适合"等",不适合"算"。这个前提定下来,后面的代码才有意义。

二、创建线程的两种方式

最常用的是直接把函数交给 Thread,用 target 指定入口、args 传参:
  1. import threading
  2. import time
  3. def worker(name, delay):
  4.     for i in range(3):
  5.         print(f"{name} 第 {i+1} 次")
  6.         time.sleep(delay)
  7. t1 = threading.Thread(target=worker, args=("A", 1))
  8. t2 = threading.Thread(target=worker, args=("B", 2))
  9. t1.start()
  10. t2.start()
  11. t1.join()   # 等 t1 结束
  12. t2.join()   # 等 t2 结束
  13. print("全部完成")
复制代码

这里有两个关键方法:start() 负责启动线程,join() 负责阻塞等待线程结束。主线程如果不 join(),子线程可能还没跑完,程序就已经退出了。

第二种是继承 Thread 并重写 run(),适合需要把线程状态封装进对象的场景:
  1. class DownloadTask(threading.Thread):
  2.     def __init__(self, url):
  3.         super().__init__()
  4.         self.url = url
  5.         self.result = None
  6.     def run(self):
  7.         # 线程启动后自动执行
  8.         print(f"下载:{self.url}")
  9.         time.sleep(2)
  10.         self.result = f"{self.url} 完成"
  11. task = DownloadTask("https://example.com/file.zip")
  12. task.start()
  13. task.join()
  14. print(task.result)
复制代码

日常开发用第一种就够了,需要在线程对象上挂载结果或状态时,再考虑第二种。

三、守护线程:主程序退出就一起退

普通线程会阻止程序退出,主线程结束后还要等所有子线程收尾。把 daemon 设为 True 则相反,主线程一退出,守护线程被直接终止:
  1. def background():
  2.     while True:
  3.         print("后台运行...")
  4.         time.sleep(1)
  5. t = threading.Thread(target=background, daemon=True)
  6. t.start()
  7. time.sleep(3)
  8. print("主程序结束")   # 3 秒后退出,守护线程随之终止
复制代码

典型用途是后台监控、心跳检测这类"主程序不跑了就没必要继续跑"的任务。

四、线程同步:共享变量为什么会被写乱

下面这段代码的执行结果会低于预期:
  1. counter = 0
  2. def increment():
  3.     global counter
  4.     for _ in range(100000):
  5.         counter += 1
  6. t1 = threading.Thread(target=increment)
  7. t2 = threading.Thread(target=increment)
  8. t1.start()
  9. t2.start()
  10. t1.join()
  11. t2.join()
  12. print(counter)   # 期望 200000,实际可能是 189xxx
复制代码

原因是 counter += 1 并非原子操作,它由"读、算、写"三步组成,两个线程交替执行时会互相覆盖对方的结果。

解决办法是加锁。推荐用 with lock 的写法,离开代码块自动释放,不用手动 acquire() 和 release(),也就不会忘记释放:
  1. counter = 0
  2. lock = threading.Lock()
  3. def increment():
  4.     global counter
  5.     for _ in range(100000):
  6.         with lock:
  7.             counter += 1
  8.         # 离开 with 自动释放锁
复制代码

五、线程间通信优先用 Queue

多个线程之间传数据,不要自己拿锁去维护共享列表,直接用 queue.Queue,它内部已经实现了线程安全。生产者-消费者是最经典的场景:
  1. import queue
  2. import threading
  3. q = queue.Queue()
  4. def producer():
  5.     for i in range(5):
  6.         q.put(i)
  7.         print(f"生产:{i}")
  8.     q.put(None)   # 结束信号
  9. def consumer():
  10.     while True:
  11.         item = q.get()
  12.         if item is None:
  13.             break
  14.         print(f"消费:{item}")
  15. t1 = threading.Thread(target=producer)
  16. t2 = threading.Thread(target=consumer)
  17. t1.start()
  18. t2.start()
  19. t1.join()
  20. t2.join()
复制代码

Queue 有三种形态:Queue 先进先出(默认)、LifoQueue 后进先出(栈)、PriorityQueue 按优先级取出。绝大多数情况用默认的 Queue 即可。

六、其他同步工具的定位

先知道有这些东西,用到时再查文档:


  • Lock:互斥锁,同一时刻只允许一个线程访问,用于保护共享变量。
  • RLock:可重入锁,同一线程可多次获取,用于递归函数里加锁。
  • Semaphore:信号量,限制同时访问的线程数量,典型用法是限流,例如最多 3 个线程同时请求 API。
  • Event:事件通知,一个线程等待另一个线程的信号,适合等待初始化完成或等待任务结束。
  • Condition:条件变量,更灵活的通知机制,可用于生产者-消费者,但通常用 Queue 就够了。


实际开发中最高频的是 Lock 和 Queue 两个。

七、线程池:不要手动创建一堆线程

创建几个线程没问题,但要开几十上百个线程时,频繁创建销毁的开销就显现出来了。concurrent.futures 里的 ThreadPoolExecutor 负责复用线程:
  1. from concurrent.futures import ThreadPoolExecutor
  2. def task(n):
  3.     return n * 2
  4. with ThreadPoolExecutor(max_workers=3) as executor:
  5.     # 方式1:提交单个任务
  6.     future = executor.submit(task, 5)
  7.     print(future.result())      # 10
  8.     # 方式2:批量提交
  9.     results = executor.map(task, range(5))
  10.     print(list(results))        # [0, 2, 4, 6, 8]
复制代码

with 语句退出时会自动等待所有任务完成并清理线程池。submit 和 map 的区别在于:submit 提交单个任务并返回 Future 对象,可以单独取结果;map 批量提交,返回结果迭代器,输出顺序与输入顺序一致。

如果需要"谁先完成先处理谁",用 as_completed:
  1. from concurrent.futures import ThreadPoolExecutor, as_completed
  2. with ThreadPoolExecutor(max_workers=3) as executor:
  3.     futures = [executor.submit(task, i) for i in range(5)]
  4.     for future in as_completed(futures):
  5.         print(future.result())
复制代码

八、方案选型对照


  • 网络请求、爬虫:线程或线程池,I/O 等待期间释放 GIL。
  • 文件读写:线程,属于 I/O 操作。
  • 大量计算(CPU 密集):multiprocessing 多进程,线程受 GIL 限制无法提速。
  • 高并发网络服务:asyncio 异步 I/O,单线程更高效。
  • 简单并发需求:concurrent.futures,接口统一,切换方案方便。


简化成一句判断逻辑:任务在等外部(网络、磁盘、数据库)就用线程;任务在算内部(循环、数学运算)就用多进程或异步。

九、完整示例:批量下载的串行与并行对比
  1. from concurrent.futures import ThreadPoolExecutor
  2. import requests
  3. import time
  4. urls = [
  5.     "https://httpbin.org/delay/1",
  6.     "https://httpbin.org/delay/2",
  7.     "https://httpbin.org/delay/1",
  8.     "https://httpbin.org/delay/3",
  9. ]
  10. def download(url):
  11.     start = time.time()
  12.     resp = requests.get(url, timeout=10)
  13.     elapsed = time.time() - start
  14.     return {"url": url, "status": resp.status_code, "time": elapsed}
  15. # 串行
  16. start = time.time()
  17. for url in urls:
  18.     print(download(url))
  19. print(f"串行耗时:{time.time() - start:.2f}s")
  20. # 并行(线程池)
  21. start = time.time()
  22. with ThreadPoolExecutor(max_workers=4) as executor:
  23.     results = executor.map(download, urls)
  24.     for r in results:
  25.         print(r)
  26. print(f"并行耗时:{time.time() - start:.2f}s")
复制代码

4 个请求分别等待 1、2、1、3 秒,串行累计需要约 7 秒,线程池并行只需等最长的那一个,约 3 秒。这个例子也再次印证了前面的结论:线程的收益来自"重叠等待",而不是"同时计算"。

把上面几点串起来看,Python 多线程的实践路径其实很清晰:先用 GIL 判断任务是 I/O 还是 CPU 密集,I/O 类再决定是裸线程还是线程池;共享变量一律用 Lock 保护,线程间传数据一律走 Queue;需要限制并发数量就上 Semaphore 或 max_workers。踩坑最多的地方往往不是线程 API 本身,而是忘了 join、忘了加锁、以及把计算任务硬塞进线程里。
回复

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Python多线程并发编程:GIL判断、Lock同步与线程池实践

这篇把 GIL 和"该不该用线程"的关系讲透了,"适合等不适合算"基本就是一句话结论,后面创建线程和 Queue 的示例也很实用。补充几个我实际写代码时比较在意的点: 1. 关于 join 那里,可以稍微修正一下:CPython 主线程执行完时会等待所有非守护线程结束,所以不写 join 通常不会导致子线程被半路砍掉,更常见的后果是主线程的后续逻辑抢在子线程结果回来之前先跑了。但 join 依然该写,主要是为了顺序控制和拿结果。 2. 守护线程要留意:解释器退出时 daemon 线程是被直接终止的,finally 不保证执行,正在写的文件可能没刷盘、连接没关。所以只适合心跳、监控这类"丢了也无所谓"的任务。 3. with lock 包住 counter += 1 是标准写法,但十万次循环就是十万次加锁解锁,开销很可观。可以改成线程内用局部变量累加,最后只在锁里做一次相加,结果一样但快很多。 4. Queue 的哨兵信号要注意:一个 None 只能让一个消费者退出,有多个消费者就得放对应数量的 None,或者用别的终止条件;q.get() 尽量带上 timeout,否则生产者一旦异常,消费者会永远卡在那里。生产者那边也最好用 try/finally 保证哨兵一定投递出去。 5. 标题提到线程池,正文还没到的话顺着说一句:任务数量多的时候直接上 concurrent.futures.
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Python多线程并发编程:GIL判断、Lock同步与线程池实践

感谢楼主整理,条理很清楚,尤其是开头先把GIL和任务类型判断讲明白,这点很关键,很多人一上来就开线程结果CPU密集型任务越跑越慢,就是没先想清楚这个前提。 我自己比较有共鸣的是加锁那一段。counter += 1 看着像一行,实际上是读改写三步,不加锁结果偏小,这个例子很有说服力。用 with lock 确实比手动 acquire/release 省心,不容易漏释放。 另外 Queue 那段也提醒得好,线程间传数据用 queue.Queue 比自己去维护共享列表加锁靠谱多了,生产者消费者那个结束信号用 None 的写法也常见。 不过看帖子最后第六部分好像没写完,"用于"后面就断了,其他同步工具(Semaphore、Event、Condition 之类)和标题里的线程池实践是不是还没发出来?如果还有下文,期待楼主补上,线程池那部分正好可以接着讲怎么避免手动管理一堆线程。
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Python多线程并发编程:GIL判断、Lock同步与线程池实践

楼主这篇梳理得挺清楚的,尤其是一开头就把“线程适合等、不适合算”这个前提摆出来,比很多上来就贴代码的教程靠谱。我平时也是这么跟新人解释的:先看任务在等什么,再决定开不开线程。 补充几个我在实际项目里踩过的点,供参考: 一是关于判断 GIL 影响,除了看任务类型,也可以自己做个小实验。同一段计算代码分别用单线程和两线程跑,用 time.perf_counter 计时,如果多线程反而更慢,基本就说明卡在 GIL 上了。另外像 time.sleep 这类阻塞调用是会释放 GIL 的,所以楼主例子里两个 worker 用 sleep 模拟 I/O 是成立的,但换成纯 busy loop 就不一样了。 二是 CPU 密集型任务的出路,除了 multiprocessing,也可以考虑 concurrent.futures 里的 ProcessPoolExecutor,接口和线程池几乎一致,切换成本很低。有些库(比如 numpy 的部分运算)在底层会释放 GIL,那种场景多线程也能吃到并行,不过这个得看具体实现,不能一概而论。 三是锁那部分,加锁能解决正确性问题,但粒度要留意。像楼主例子里 for 循环里每次都进一次 with lock,10 万次就是 10 万次加解锁,性能会掉得比较明显。如果只是计数,可以考虑把局部变量累加完之后一次性写回,或者用 queue 把结果汇总,减少临界区。当然这属
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-1 08:08 , Processed in 0.024620 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部