Python 并发入门:彻底搞懂进程、线程、GIL、锁与线程池
在学习 Python 并发编程时,很多人一开始都会被几个概念绕晕:
- 进程是什么?
- 线程是什么?
- 多线程和多进程有什么区别?
- 为什么 Python 有 GIL?
- 什么是线程安全?
- 为什么加锁能解决问题?
- 死锁又是怎么发生的?
- 线程池到底解决了什么问题?
本文会从一个“模拟文件下载”的例子开始,逐步讲清楚 Python 并发编程中的核心概念。
1. 进程和线程的基本概念
在操作系统中,进程和线程是并发编程的两个基础概念。
简单来说:
进程是操作系统进行资源分配和调度的基本单位。
线程是 CPU 内核调度和执行的基本单位。
一个程序运行起来后,通常会对应一个进程。
而一个进程内部,可以有一个或多个线程。
可以这样理解:
进程:一个正在运行的程序
线程:进程内部真正执行任务的执行流
比如浏览器是一个进程,浏览器里可能同时有多个线程负责页面渲染、网络请求、事件监听等任务。
2. 不使用并发时的下载任务
假设我们有一个下载函数:
import time
def download(file_name):
print("正在下载…")
time.sleep(2)
print(f"{file_name} 下载完成")
这里用 time.sleep(2) 模拟下载耗时 2 秒。
如果我们不用多线程,连续下载 3 个文件:
for i in range(3):
download(i)
执行过程是:
下载第 1 个文件,等待 2 秒
下载第 2 个文件,等待 2 秒
下载第 3 个文件,等待 2 秒
总耗时大约是 6 秒。
这种执行方式是串行的。
也就是说,前一个任务没有完成,后一个任务就不能开始。
3. 使用 threading 实现多线程
Python 中可以使用 threading 模块创建线程。
示例代码如下:
import threading
import time
def download(file_name):
time.sleep(2)
print(f"{file_name} 下载完成")
for i in range(3):
t = threading.Thread(target=download, args=(i,))
t.start()
这里的核心是:
threading.Thread(target=download, args=(i,))
它表示创建一个线程,让这个线程去执行 download 函数。
args=(i,) 表示传给 download 的参数。
注意,只有一个参数时也要写成元组形式:
args=(i,)
不能写成:
args=(i)
因为 (i) 只是普通表达式,不是元组。
调用:
t.start()
表示让线程进入就绪状态,等待 CPU 调度执行。
4. 使用 multiprocessing 实现多进程
除了多线程,还可以使用多进程。
Python 提供了 multiprocessing 模块:
import multiprocessing
if __name__ == "__main__":
for i in range(3):
p = multiprocessing.Process(target=download, args=(i,))
p.start()
这里需要特别注意:
if __name__ == "__main__":
在 Windows 系统下使用多进程时,这个判断非常重要。
如果不加,子进程在启动时可能会重复执行主模块代码,导致进程不断创建,程序行为异常。
5. 多线程和多进程该怎么选
既然多线程和多进程都能实现并发,那么应该怎么选?
这就要引入 Python 中一个非常重要的概念:GIL。
GIL 的全称是 Global Interpreter Lock,也叫全局解释器锁。
在 CPython 解释器中,GIL 会让同一个进程内,同一时刻只有一个线程真正执行 Python 字节码。
这意味着:
Python 多线程并不能让多个线程同时在多个 CPU 核心上执行 Python 代码。
因此,对于 CPU 密集型任务,多线程往往不能充分利用多核 CPU。
CPU 密集型任务包括:
- 大量数学计算;
- 图像处理;
- 视频编码;
- 大规模数据计算;
- 加密解密。
这类任务更适合使用多进程。
而对于 IO 密集型任务,多线程仍然很有用。
IO 密集型任务包括:
- 文件读写;
- 网络请求;
- 数据库查询;
- 下载文件;
- 调用外部接口。
因为 IO 操作大多数时间都在等待,线程可以在等待期间切换去做别的任务。
简单总结:
| IO 密集型 | 多线程 |
| CPU 密集型 | 多进程 |
6. start 和 join 的作用
创建线程以后,最常见的两个方法是:
- start();
- join()。
start() 表示启动线程:
t.start()
它的含义是:线程已经准备就绪,可以被 CPU 调度。
join() 表示等待线程执行完毕:
t.join()
它通常用来阻塞主线程,让主线程等待子线程全部结束后再继续执行。
示例:
threads = []
start = time.time()
for i in range(3):
t = threading.Thread(target=download, args=(i,))
threads.append(t)
t.start()
for t in threads:
t.join()
end = time.time()
print(f"总耗时:{end – start}")
如果不调用 join(),主线程可能不会等待所有子线程结束就继续往下走。
在需要统计总耗时、收集结果或确保任务完成时,join() 很重要。
7. 设置和获取线程名称
线程也可以有名字。
可以手动设置线程名:
t = threading.Thread(target=download, args=("file.txt",))
t.name = "下载线程"
t.start()
在线程内部,可以通过下面的方式获取当前线程:
threading.current_thread().name
例如:
def download(file_name):
time.sleep(2)
print(f"{file_name} 下载完成")
print(threading.current_thread().name)
给线程命名在调试并发程序时很有帮助。
当多个线程同时打印日志时,线程名可以帮助我们判断是哪一个线程执行了某段逻辑。
8. 自定义线程类
除了直接使用 threading.Thread,也可以通过继承的方式创建线程类。
要自定义线程类,需要继承 threading.Thread,并重写 run() 方法:
import threading
import time
class MyDownloadThread(threading.Thread):
def run(self):
time.sleep(2)
file_name = self._args[0] if self._args else None
print(f"{file_name} 下载成功")
使用方式:
for i in range(3):
t = MyDownloadThread(args=(i,))
t.start()
当调用 start() 时,线程内部会自动执行 run() 方法。
不过在实际开发中,如果逻辑不是特别复杂,直接使用:
threading.Thread(target=函数名, args=参数)
通常已经够用了。
自定义线程类更适合线程本身有较多状态、方法和内部逻辑的场景。
9. 什么是线程安全问题
多线程虽然能提高 IO 密集型任务的效率,但也会带来新的问题:线程安全。
假设有一个抢票业务:
- 一共有 1000 张票;
- 3 个窗口同时抢票;
- 每抢到一张票,总票数减 1。
代码大致如下:
total_tickets = 1000
num_tickets = [0, 0, 0]
def buy_ticket(name):
global total_tickets
while True:
current_count = total_tickets
if current_count <= 0:
break
time.sleep(0)
new_count = current_count – 1
total_tickets = new_count
num_tickets[name] += 1
print(f"窗口 {name} 抢到一张票,剩余:{total_tickets}")
问题出在这几步不是一个不可分割的整体:
current_count = total_tickets
new_count = current_count – 1
total_tickets = new_count
多个线程可能同时读到相同的票数。
比如两个线程都读到还剩 1 张票,然后都认为自己可以买,结果就可能出现超卖。
这类多个线程同时访问和修改共享数据导致的问题,就是线程安全问题。
10. 使用锁解决线程安全问题
解决共享资源竞争,最常见的方法是加锁。
Python 中常用的锁包括:
- threading.Lock();
- threading.RLock()。
基本思路是:
同一时间只允许一个线程进入关键代码区。
示例:
lock = threading.RLock()
def buy_ticket(name):
global total_tickets
with lock:
while True:
current_count = total_tickets
if current_count <= 0:
break
new_count = current_count – 1
total_tickets = new_count
num_tickets[name] += 1
print(f"窗口 {name} 抢到一张票,剩余:{total_tickets}")
with lock: 的作用是:
- 进入代码块时自动获取锁;
- 离开代码块时自动释放锁。
相比手动写:
lock.acquire()
lock.release()
使用 with 更安全。
因为即使代码块中出现异常,锁也会被正确释放。
不过需要注意,锁的范围要设计好。
如果锁的范围太小,可能保护不住共享数据。
如果锁的范围太大,又会让多线程退化成串行执行,降低并发效率。
11. Lock 和 RLock 的区别
Lock 是普通锁。
一个线程获取锁以后,在释放之前,其他线程不能再获取这把锁。
RLock 是可重入锁。
同一个线程可以多次获取同一把锁,但也需要释放相同次数。
简单理解:
- Lock:同一个线程重复获取同一把锁可能会把自己卡住;
- RLock:允许同一个线程重复进入被同一把锁保护的代码。
如果没有嵌套加锁需求,普通 Lock 通常就够了。
如果函数之间存在互相调用,并且它们都需要同一把锁,RLock 会更安全。
12. 死锁是怎么发生的
锁能解决线程安全问题,但锁用不好也会产生新的问题:死锁。
看下面这个例子:
lock1 = threading.Lock()
lock2 = threading.Lock()
def t1():
lock1.acquire()
time.sleep(1)
lock2.acquire()
print("t1")
def t2():
lock2.acquire()
time.sleep(1)
lock1.acquire()
print("t2")
两个线程分别执行:
thread1 = threading.Thread(target=t1)
thread2 = threading.Thread(target=t2)
thread1.start()
thread2.start()
thread1.join()
thread2.join()
程序会卡住。
原因是:
t1 拿到了 lock1,等待 lock2
t2 拿到了 lock2,等待 lock1
双方都拿着对方想要的资源,又都不释放自己手里的资源。
于是程序就永远等待下去。
这就是死锁。
需要注意:这类代码如果直接运行,程序会一直阻塞,后面的代码也不会继续执行。
13. 如何避免死锁
避免死锁可以从几个方面入手。
第一,尽量减少同时持有多把锁。
如果一个任务只需要一把锁,就不要设计多把锁。
第二,多把锁要保持固定的获取顺序。
比如所有线程都先拿 lock1,再拿 lock2,就不会出现互相等待。
错误示例:
线程 A:先拿 lock1,再拿 lock2
线程 B:先拿 lock2,再拿 lock1
更好的方式:
所有线程:都先拿 lock1,再拿 lock2
第三,使用 with 管理锁。
with lock:
# 操作共享资源
这样可以降低忘记释放锁的风险。
第四,必要时使用超时机制。
locked = lock.acquire(timeout=3)
如果在指定时间内拿不到锁,就可以放弃等待或执行补偿逻辑。
14. 线程不是越多越好
很多人刚学多线程时,会有一个误解:
线程越多,程序越快。
实际上并不是。
线程太多会带来额外开销:
- 线程创建和销毁需要成本;
- CPU 在线程之间切换需要成本;
- 线程过多会导致调度频繁;
- 共享资源竞争会更加严重;
- 程序更难调试。
所以线程数量要控制。
这就引出了线程池。
15. 使用 ThreadPoolExecutor 管理线程
Python 提供了 concurrent.futures.ThreadPoolExecutor 来管理线程池。
线程池的作用是:
限制最大线程数量,并复用线程执行任务。
示例:
from concurrent.futures import ThreadPoolExecutor
def download(file_name):
time.sleep(2)
print(f"{file_name} 下载完成")
pool = ThreadPoolExecutor(max_workers=3)
for i in range(10):
pool.submit(download, i)
pool.shutdown(wait=True)
其中:
ThreadPoolExecutor(max_workers=3)
表示最多同时运行 3 个工作线程。
pool.submit(download, i)
表示把任务提交给线程池。
pool.shutdown(wait=True)
表示等待线程池里的任务全部执行完成,然后关闭线程池。
在实际开发中,线程池通常比手动创建大量线程更推荐。
因为它更容易控制并发数量,也更方便管理任务生命周期。
16. 小结
本文从模拟文件下载开始,逐步讲解了 Python 并发编程中的几个核心概念。
核心结论如下:
- 进程是资源分配的基本单位;
- 线程是 CPU 调度执行的基本单位;
- threading 可以实现多线程;
- multiprocessing 可以实现多进程;
- IO 密集型任务适合多线程;
- CPU 密集型任务更适合多进程;
- start() 用于启动线程;
- join() 用于等待线程结束;
- 多线程访问共享数据可能出现线程安全问题;
- 锁可以保护共享资源;
- 锁使用不当可能导致死锁;
- 线程不是越多越好,线程池可以控制并发数量。
如果用一句话总结:
Python 并发编程的关键,不只是“让任务同时跑”,更重要的是理解任务类型、共享资源和并发控制。



