一说到Python的多线程,很多人脑子里第一个蹦出来的就是GIL(全局解释器锁),然后就觉得,嗨,伪多线程,同步?同步个啥?你要是也这么想,那可就掉进坑里了。没错,GIL这东西确实限制了同一时间只有一个Python线程能执行CPU密集型任务,但在I/O密集型操作(比如网络请求、文件读写)面前,它可就管不住了。线程们会在这里被切换,这时候,如果你不好好搞同步,数据分分钟给你搅成一锅粥。
那到底啥是同步?别整那些虚头巴脑的定义。我给你打个比方:你和你室友共用一个冰箱,里面只有一瓶可乐。你想喝,他也想喝。要是没个规矩,你俩可能同时伸手去拿,结果就是瓶子打碎,谁也别喝。同步,就是定个规矩,比如加把锁,谁先拿到钥匙(锁),谁就能开冰箱门拿可乐,拿完再把钥匙放回去,下一个人才能拿。简单粗暴,但有效。
在Python里,这个“规矩”可不止一种。
最直观的“大铁锁”:threading.Lock
这是最基础、最常见的同步工具,就跟上面说的冰箱钥匙一样。一个线程在操作共享数据前,先调用acquire()
方法“上锁”,这时候其他线程要是也想acquire()
,对不起,您先等着。直到第一个线程干完活,调用release()
方法“解锁”,其他线程才能抢这把锁。
“`python
import threading
balance = 0
lock = threading.Lock()
def change_it(n):
global balance
# 先上锁
lock.acquire()
try:
# 你的业务逻辑
balance = balance + n
balance = balance – n
finally:
# 务必解锁,哪怕代码出错了!
lock.release()
“`
看,上面那个try...finally
结构是关键!不管try
里面的代码怎么折腾,哪怕抛出异常,finally
里的release()
都保证会被执行。不然,一个线程拿到锁之后挂了,那这把锁就成了“死锁”,其他线程永远也等不到解锁,整个程序就僵住了。
当然,更Pythonic的写法是使用with
语句,它能自动帮你处理加锁和解锁,简直是懒人福音,也更安全:
python
with lock:
# 在这个代码块里,锁是锁上的
# 出了代码块,自动解锁,省心!
balance = balance + n
balance = balance - n
强烈推荐用with
,能少写不少样板代码,还不容易出错。
能“重复进入”的智能锁:threading.RLock
Lock
有个问题,它是个“死心眼”。同一个线程,如果已经拿到了锁,还想再拿一次,它自己就把自己给锁死了。这就很尴尬,对吧?有时候你的代码逻辑复杂,一个函数调用了另一个函数,而这两个函数都需要同一把锁,这就完蛋了。
这时候,RLock
(可重入锁)就闪亮登场了。它聪明一些,内部维护着一个计数器。同一个线程,每acquire()
一次,计数器加一;每release()
一次,计数器减一。只有当计数器减到零,锁才算真正被释放,其他线程才能获取。这就完美解决了在同一个线程里重复加锁的问题。
啥时候用?当你写的代码里,一个加了锁的函数,可能会调用另一个也需要这把锁的函数时,果断用RLock
。
控制“流量”的看门人:threading.Semaphore
Lock
和RLock
都是非黑即白,一次只允许一个线程进入。但有些场景,我们希望允许多个线程,但不能太多。比如,你的数据库连接池里只有5个连接,你最多就只能允许5个线程同时去操作数据库。
这时候Semaphore
(信号量)就派上用场了。你可以把它想象成一个有固定数量停车位的停车场。Semaphore(5)
就表示有5个车位。一个线程进来,acquire()
一下,就占了一个车位;干完活release()
一下,就空出一个车位。要是车位满了,后来的线程就得在门口排队等着。
这玩意儿在控制并发数量、防止系统资源被瞬间耗尽的场景下,简直是神器。
线程间的“红绿灯”:threading.Event
Event
这东西就更有意思了。它不像锁那样用来保护数据,而是用来协调线程的行动。你可以把它看作一个信号灯。
它有两种状态:set()
(绿灯)和clear()
(红灯)。一个或多个线程可以调用event.wait()
方法,它们会一直阻塞,直到某个线程调用了event.set()
,把信号灯变成绿色。一旦变绿,所有在wait()
的线程都会被唤醒,继续往下跑。
这在什么场景下好用呢?比如,一个主线程需要准备一些初始数据,N个工作线程必须等数据准备好才能开工。那主线程就在准备好数据后调用event.set()
,所有工作线程收到信号,一起启动。就像赛跑时,所有选手都准备好了,就等发令枪响。
我最推崇的“终极武器”:queue.Queue
前面讲的那些锁啊、信号量啊,都挺好,但你得自己手动管理,一不小心就可能搞出死锁。说实话,挺心累的。
而queue.Queue
,在我看来,是处理绝大多数并发同步问题的最佳实践。它是一个线程安全的队列。啥意思?就是你只管往里放东西(put()
),从里面取东西(get()
),它内部已经帮你把所有的加锁、解锁操作都封装好了,你根本不用操心!
这种模式叫做“生产者-消费者”模型。一个或多个线程当“生产者”,负责生产数据扔到队列里;另外一个或多个线程当“消费者”,负责从队列里取出数据进行处理。
“`python
import queue
import threading
q = queue.Queue()
def producer():
for i in range(5):
print(f”生产者生产了: {i}”)
q.put(i)
def consumer():
while True:
item = q.get() # 如果队列为空,这里会阻塞
if item is None: # 用一个特殊值表示结束
break
print(f”消费者消费了: {item}”)
q.task_done() # 告知队列一个任务已完成
… 创建并启动线程 …
q.join() # 等待所有任务完成
“`
用队列的好处是,它彻底解耦了生产者和消费者。生产者不用等消费者处理完,消费者也不用关心生产者是怎么生产的。它们之间通过队列这个“缓冲带”进行通信,代码逻辑清晰得一塌糊涂,而且几乎不可能出现死锁。
所以,下次你遇到Python怎么同步这个问题,别上来就想着用Lock
。先问问自己,我这个问题,能不能用生产者-消费者模型来解决?能不能用queue.Queue
来搞定?如果可以,相信我,你的代码会优雅好几个档次,你晚上也能睡得更香。
当然,如果你玩得更大,搞起了多进程multiprocessing
,那上面这些threading
模块里的家伙就不好使了,因为进程间内存不共享。不过别慌,multiprocessing
模块里也提供了一套几乎一模一样的API,比如multiprocessing.Lock
、multiprocessing.Queue
等等,用法大同小异,只是底层实现原理不同罢了。
总而言之,Python的同步工具箱里家伙什儿挺全的。从简单的Lock
到灵活的Semaphore
,再到我个人极力推荐的Queue
,掌握它们,你就能从容应对多线程编程中的各种数据混乱问题,写出健壮、高效的并发程序。别再被GIL吓住了,该同步的时候,就得果断出手!
评论(0)