Python多线程/进程concurrent.futures模块
我们在之前已经介绍过python的多进程/线程的一种使用(Python使用multiprocessing进行多线程和多进程操作),这里我们再介绍模块concurrent.futures,它是对线程池和进程池的一个高层抽象,它简化了异步执行任务的流程,而模块multiprocessing提供了更全面的进程创建和管理功能。需要执行的是简单的并行任务,并且希望有一个简单易用的接口concurrent.futures模块是更推荐的选择,它提供了 ThreadPoolExecutor 和 ProcessPoolExecutor,可以很容易地创建线程池和进程池,并且使用起来非常直观。但是如果需要更细粒度的控制,例如进程间的数据共享、信号量、锁等multiprocessing模块可能更加适合。
1、多线程
对于I/O密集的任务,比如如网络请求、文件操作等,因为这些操作Python都会释放GIL全局锁,因此使用线程池能达到最高效率。下面我们从例子中直接来看如何使用:
from concurrent.futures import ThreadPoolExecutor
import time
def task(n):
print(f"任务 {n} 启动")
time.sleep(2 * n) # 模拟一个耗时的任务
return n * 2
if __name__ == "__main__":
start = time.time()
# 创建一个线程池,最大线程数为4
executor = ThreadPoolExecutor(max_workers=4)
# 使用executor.map()方法提交任务并获取结果
results = executor.map(task, range(4))
# 等待所有任务完成, 并关闭线程池
executor.shutdown(wait=True)
# 输出结果
for result in results:
print(f"运行结果: {result}, 运行时间 {time.time() - start}")
这段代码中我们使用ThreadPoolExecutor创建了一个线程为4的线程池子(可以"并行"执行4个任务),我们在这里任务函数task中,使用time.sleep模拟I/O(此时会释放出GIL)。代码的第17行,我们使用了ThreadPoolExecutor.map方法,将多个任务一起放入线程池中并开始执行(此时并不阻塞主线程,在代码的第17行放入任务时,线程池就已经开始执行里面的任务了),19行中使用.shutdown(wait=true)等待当前线程池中所有的任务完成(此时会阻塞主线程)。ThreadPoolExecutor.map方法会返回一个可迭代对象,里面存储的是任务执行完成后任务的返回值。看看返回结果:
针对上面的例子,我们可以使用whith前后文管理器,来替代代码的第14~20行。代码如下:
from concurrent.futures import ThreadPoolExecutor
import time
def task(n):
print(f"任务 {n} 启动")
time.sleep(2 * n) # 模拟一个耗时的任务
return n * 2
if __name__ == "__main__":
start = time.time()
with ThreadPoolExecutor(max_workers=4) as executor:
# 使用executor.map()方法提交任务并获取结果
results = executor.map(task, range(4))
# 输出结果
for result in results:
print(f"运行结果: {result}, 运行时间 {time.time() - start}")
我们使用了whith这个前后文管理器,其实就相当于之前的14~20行的代码,此时主线程会阻塞在whith中(14行中)。运行结果如下:
1.1、使用executor.submit():
.submit()方法是异步的,它立即返回,不会等待任务完成。可以使用返回的 Future 对象的 result() 方法来获取任务的结果,只有当你调用result()时才会导致阻塞,直到结果可用,并且允许提交不同的任务。
from concurrent.futures import ThreadPoolExecutor
import time
def task(n):
print(f"任务 {n} 启动")
time.sleep(2 * n) # 模拟一个耗时的任务
return n * 2
if __name__ == "__main__":
start = time.time()
with ThreadPoolExecutor(max_workers=4) as executor:
# 使用submit提交任务
futures = [executor.submit(task, i) for i in range(4)]
print(f"运行时间 {time.time() - start}")
for future in futures:
print(f"运行结果: {future.result()}, 运行时间 {time.time() - start}")
与上文类似,不过我们这里将循环打印结果的部分放到了with块中,因此此时主线程的阻塞是在最后一行的future.result(),我们看看执行结果:
需要注意我们是按顺序取出的future,因此放入是什么顺序,拿出时就是什么顺序,如果拿出的任务future并没有执行完成,则主线程会阻塞在.result()处(尽管其他的任务已经完成了),比如我们把上面示例代码中的第14行改为futures = [executor.submit(task, i) for i in range(4, 0, -1)] ,以倒叙的方式执行任务, 因此第一个被放入的任务时task(4),会睡眠8s,我们再执行一次看看效果。
为什么运行时间都变成8s了呢?因为第一个任务被取出来执行future.result时,其他任务可能已经执行完了,但是此时主线程仍在等待第一个任务的future.result()结果,当第一个任务执行结束(8s后),其实其他的任务早就执行完成了,因此得到了上述结果。
ps: 上面我们的代码都放在with块是因为当退出with块时,实际上就是在调用executor.shutdown(wait=True),因此会等到所有任务结束(阻塞主进程)才会退出with块。如果你的打印结果的代码没有放在with里面,那就会等待所有的任务都结束了才会解除主线程阻塞。
2、多进程
对于 CPU 密集型任务,如复杂的计算、数据加密等,需要占用GPU的情况,使用进程池才能避开Python的GIL全局锁。
对于多进程我们使用的是ProcessPoolExecutor组件,这个组件的基本使用方式与多线程基本一致,因此上面的示例代码我们稍微变化一下就可以使用。
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time
def task(n):
print(f"任务 {n} 启动")
time.sleep(2 * n) # 模拟一个耗时的任务
return n * 2
if __name__ == "__main__":
start = time.time()
with ProcessPoolExecutor(max_workers=4) as executor:
# 使用executor.map()方法提交任务并获取结果
results = executor.map(task, range(4))
# 输出结果
for result in results:
print(f"运行结果: {result}, 运行时间 {time.time() - start}")
我们这里依然使用的是with进行上下文管理(保证了能正常关闭和退出进程),我们这里使用的还是.map()将任务全部导入到进程池中进行运行(实际上我们仍可以使用.submit()按顺序提交任务)。下面是执行结果:
对比一下使用线程池,慢了有0.3s,这是因为使用进程池,切换进程之间存在消耗,因此在可以使用线程池时就尽量使用线程池吧。