原创,未经授权请勿转载!
开发基于python3.7;
IDE是pycharm2021社区版;
侵删!侵删!侵删!重要的事情要说三遍。
记一次Python使用多线程时遇到的奇怪bug
简单的开始
需求:
需要对大量的原始数据逐个执行一次验证操作,原始数据org_data量特别大,每次识别需要经历提取和验证两次操作。
于是乎,up主自然而言就想到了通过写两个函数,通过多线程并行处理提取和验证。
(因保密问题,本文代码为演示版,仅用来展示用法和原理,非原版代码。)
方案v1.0:
- 多线程或多进程设定,通过 concurrent.futures包 来实现,简单方便快捷;
- 一个先进先出的队列,使用 queue包 来实现,作为线程或进程间安全通信的工具;
- 函数a put,负责从原始数据org_data提取出数据,并放入队列;
- 函数b rec,负责从队列中取数,处理完之后,放入到result_list结果列表中;
v1.0代码:
from concurrent.futures import ThreadPoolExecutor
import queue
import time
org_data = list(range(1000))
midqueue = queue.Queue()
result_list = []
t = ThreadPoolExecutor(max_workers=2)
# 读取函数
def a():
while org_data:
temp_put = org_data.pop() # 模拟原版数据读取操作
print(f'put:{temp_put}')
midqueue.put(temp_put)
print(put.done())
put = t.submit(a)
def b():
while not midqueue.empty():
temp_get = midqueue.get()
print(f'get:{temp_get}')
result_list.append(temp_get)
print(result_list)
get = t.submit(b)
t.shutdown(wait=True)
v1.0设想的一切美好,但……代码跑起来,却并不是!
问题:
可以看到,线程get(函数b)只执行了一部分,就退出了。
原因:
如果函数a执行的速度慢,导致queue里没对象了,线程get以为任务已经完成了!
好嘛,并行还得考虑这个。OK,V1.1解决它。
方案v1.1:
- 多线程或多进程设定,通过 concurrent.futures包 来实现,简单方便快捷;
- 一个先进先出的队列,使用 queue包 来实现,作为线程或进程间安全通信的工具;
- 函数a put,负责从原始数据org_data提取出数据,并放入队列;
- 函数b rec,负责从队列中取数,处理完之后,放入到result_list结果列表中;
- 让函数b rec,同时监控线程put,如果线程put没结束,即使queue为空,也继续循环。
v1.1代码:
org_data = list(range(1000))
midqueue = queue.Queue()
result_list = []
t = ThreadPoolExecutor(max_workers=2)
# 读取函数
def a():
while org_data:
temp_put = org_data.pop() # 模拟原版数据读取操作
print(f'put:{temp_put}')
midqueue.put(temp_put)
print(put.done())
put = t.submit(a)
def b():
while (not midqueue.empty() or not put.done()):
temp_get = midqueue.get()
print(f'get:{temp_get}')
result_list.append(temp_get)
print(f'最终结果是:{result_list}')
get = t.submit(b)
t.shutdown(wait=True)
运行结果,似乎OK!
[假装有图]
进阶搞事
虽然v1.1可以跑起来了,但实际需求要更加复杂一些。
最大的区别是,函数b是可以进行批处理的,可以一次性传一个batch,处理起来速度并不比单条慢多少;还可能函数b是一个有使用次数限制或者按次收费的API。
我想,函数a能不能攒一部分结果,达到某个数量之后,再往b里传。
于是乎,v2.0来了。
方案v2.0:
- 多线程或多进程设定,通过 concurrent.futures包 来实现,简单方便快捷;
- 一个先进先出的队列,使用 queue包 来实现,作为线程或进程间安全通信的工具;
- 函数a put,负责从原始数据org_data提取出数据,攒够了150个之后,再打包统一放入队列;
- 函数b rec,负责从队列中取数,处理完之后,放入到result_list结果列表中;
- 让函数b rec,同时监控线程put,如果线程put没结束,即使queue为空,也继续循环。
v2.0代码:
org_data = list(range(1000))
midqueue = queue.Queue()
result_list = []
t = ThreadPoolExecutor(max_workers=2)
# 读取函数
def a():
mid_temp_list = []
while org_data:
temp_put = org_data.pop() # 模拟原版数据读取操作
print(f'put:{temp_put}')
mid_temp_list.append(temp_put)
if len(mid_temp_list) == 150:
midqueue.put(mid_temp_list)
mid_temp_list = []
else:
pass
print(put.done())
put = t.submit(a)
def b():
while (not midqueue.empty() or not put.done()):
temp_get = midqueue.get()
print(f'get:{temp_get}') # 假设这里的b函数可以自动做批处理
result_list.append(temp_get)
print(f'最终结果是:{result_list}')
get = t.submit(b)
t.shutdown(wait=True)
然后果然不出所料,出问题了。
问题:
死锁了!
最后输出的rensult是这样……
原因:
其实很简单,但当时排查了很久,问题出在 150 的那个容量上。
按照v2.0的代码,是每满150,就往queue里写一次,但1000整除150是除不尽的,最后还剩下100个数,没往队列里放。原本我以为这样只会丢数据而已,但实际上却影响了线程get。
另一个关键在于,Queue的get函数,是阻塞的。
我们设想一下最后100个数时,put和get之间通信是这样的:
get:queue空了,但是,put,你还在运行吗?
put:我还在运行,请继续执行if选项。
get:好咧,我执行temp_get = midqueue.get()操作先!
(问题来了,queue.get()是阻塞的,阻塞的意思是,get()函数会一直等待queue里被填进东西,不进来,就一直等着。)
put:好了,我运行完了,那100数算了,扔了,不放队列了。
get:(苦苦等待中……)
死锁的问题似乎解决了。v2.0.1修复bug!
v2.0.1代码:
org_data = list(range(1000))
midqueue = queue.Queue()
result_list = []
t = ThreadPoolExecutor(max_workers=2)
# 读取函数
def a():
mid_temp_list = []
while org_data:
temp_put = org_data.pop() # 模拟原版数据读取操作
print(f'put:{temp_put}')
mid_temp_list.append(temp_put)
if len(mid_temp_list) == 150:
midqueue.put(mid_temp_list)
mid_temp_list = []
else:
pass
if mid_temp_list:
midqueue.put(mid_temp_list)
else:
pass
print(put.done())
put = t.submit(a)
def b():
while (not midqueue.empty() or not put.done()):
temp_get = midqueue.get()
print(f'get:{temp_get}') # 假设这里的b函数可以自动做批处理
result_list.append(temp_get)
print(f'最终结果是:{result_list}')
get = t.submit(b)
t.shutdown(wait=True)
运行结果,OK,没问题了!
[假装有图]
一点小担心,再进阶一下
v2.0.1的结果看起来没什么问题了。
但是在b函数的判断逻辑上,我还是有一点杞人忧天的担心。
b函数的while逻辑判断是:
while (not midqueue.empty() or not put.done())
如果我没记错的话,计算机会先判断
(not midqueue.empty())
再判断
(not put.done())
那么,会不会有一种很凑巧的情况:
- b函数先判断 midqueue里,是空;在它判断完之后,a函数正好运行完毕,并将数据传入了midqueue里,同时,put线程关闭。
- b函数再判断 put线程也是关闭的,正好,循环结束。
这样,就会有一部分数据漏在了queue里。
这个担忧已经超越了我的知识范畴,先Mark,等有机会再向开发大佬们请教一下吧!
不过有备无犯,代码被暂时我改成了v2.0.2版本:
org_data = list(range(1000))
midqueue = queue.Queue()
result_list = []
t = ThreadPoolExecutor(max_workers=2)
# 读取函数
def a():
mid_temp_list = []
while org_data:
temp_put = org_data.pop() # 模拟原版数据读取操作
print(f'put:{temp_put}')
mid_temp_list.append(temp_put)
if len(mid_temp_list) == 150:
midqueue.put(mid_temp_list)
mid_temp_list = []
else:
pass
if mid_temp_list:
midqueue.put(mid_temp_list)
else:
pass
put = t.submit(a)
def b():
while True:
if midqueue.empty():
if put.done():
if midqueue.empty():
break
else:
continue
else:
temp_get = midqueue.get()
print(f'get:{temp_get}') # 假设这里的b函数可以自动做批处理
result_list.append(temp_get)
print(f'最终结果是:{result_list}')
get = t.submit(b)
t.shutdown(wait=True)
笨人也有办法嘛!ヾ(◍°∇°◍)ノ゙
(双手搓丸子的那鲁托点头称是!)
连夜赶了个稿,本来还想记录下Python并行处理的两种最常用设计模式,下次再说吧。