Python 并发编程 Futures

文若2年前技术文章1036

说明

编程中如果能合理利用编程语言的并发编程技巧,都可以极大提升程序的性能。在 Python 3.2 版本为用户提供了一个标准库 concurrent.futures 可以实现进程池 和 线程池,本篇文章介绍 Python 并发编程 Futures。

1. 并发与并行

并发(Concurrency)指某个特定时刻,只允许有一个操作发生,通过 线程/任务 之间互相切换,直到任务完成。并发通常应用于 I/O 操作频繁的场景,比如从网站上下载多个文件,I/O 操作的时间可能会比 CPU 运行处理的时间长得多。  

06741e0c1193458bb802a65bac39afdb.png

并行(Parallelism)是指同一时刻,同时发生。并行则更多应用于计算密集型的场景,比如 MapReduce 中的并行计算,为了加快运行速度,一般会用多台机器、多个处理器来完成。  

b6383dfc622a4c32adf678c484a46179.png

2. Futures 模块

如果是 I/O 密集型场景,读取文件、读取网络 I/0 等,使用 ThreadPoolExecutor 是可以提升速度的,如果是 CPU 计算密集型,使用 ThreadPoolExecutor 是无法提升速度的,因为 GIL 原因可能还会更慢,使用 ProcessPoolExecutor 也就是 multiprocessing 可以实现真正的并行计算。

其原理是 concurrent.futures 会以子进程的形式,平行的运行多个 python 解释器,从而令 python 程序可以利用多核 CPU 来提升执行速度。由于子进程与主解释器相分离,所以他们的全局解释器锁也是相互独立的,每个子进程都能够完整的使用一个 CPU 内核。

2.1 顺序执行

下方使用顺序执行的方式执行程序:

import time

# 求最大公约数
def gcd(pair):
   a, b = pair
   low = min(a, b)
   for i in range(low, 0, -1):
       if a % i == 0 and b % i == 0:
           return i


numbers = [
   (1963309, 2265973), (1879675, 2493670), (2030677, 3814172),
   (1551645, 2229620), (1988912, 4736670), (2198964, 7876293)
]

start = time.time()

# 这里使用 map 函数对 gcd 进行调用
for i in map(gcd, numbers):
   print(i)

end = time.time()

print('Took %.3f seconds.' % (end - start))

消耗时间是:1s

2.2 并发执行

使用 Futures 模块的线程池执行该计算:

import time
import concurrent.futures


def gcd(pair):
   a, b = pair
   low = min(a, b)
   for i in range(low, 0, -1):
       if a % i == 0 and b % i == 0:
           return i


# 可以理解为计算任务
numbers = [
   (1963309, 2265973), (1879675, 2493670), (2030677, 3814172),
   (1551645, 2229620), (1988912, 4736670), (2198964, 7876293)
]

start = time.time()

# 线程池处理
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
   to_do = []
   for task in numbers:
       future = executor.submit(gcd, task)
       to_do.append(future)

   # 回调
   for future in concurrent.futures.as_completed(to_do):
       print(future.result())

end = time.time()
print('Took %.3f seconds.' % (end - start))

消耗时间是:1s  并没有速度提升,因为调用的程序属于 CPU 密集型,因为 GIL 使用多线程并不会提升速度,而且可能因为上下文切换导致程序更慢。

2.3 并行执行

使用 Futures 模块的进程池执行该计算:

import time
import concurrent.futures


def gcd(pair):
   a, b = pair
   low = min(a, b)
   for i in range(low, 0, -1):
       if a % i == 0 and b % i == 0:
           return i


# 可以理解为计算任务
numbers = [
   (1963309, 2265973), (1879675, 2493670), (2030677, 3814172),
   (1551645, 2229620), (1988912, 4736670), (2198964, 7876293)
]

start = time.time()

# 线程池处理
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
   to_do = []
   for task in numbers:
       future = executor.submit(gcd, task)
       to_do.append(future)

   # 回调
   for future in concurrent.futures.as_completed(to_do):
       print(future.result())

end = time.time()
print('Took %.3f seconds.' % (end - start))

消耗时间是 0.3 秒,速度比较前面两个版本都快。

2.4 Executor 对象

class concurrent.futures.Executor 是一个抽象类,提供了如下抽象方法 submit、map、shutdown。值得一提的是 Executor 实现了 enterexit 使得其对象可以使用 with 操作符。

上方的几个例子都是使用 submit 方法调用的,除此之外还有 map 方法可以传入任务实现 并发/并行。与 submit 的区别是 map 输出得到结果是有序的。

import time
import concurrent.futures


def gcd(pair):
   a, b = pair
   low = min(a, b)
   for i in range(low, 0, -1):
       if a % i == 0 and b % i == 0:
           return i


# 可以理解为计算任务
numbers = [
   (1963309, 2265973), (1879675, 2493670), (2030677, 3814172),
   (1551645, 2229620), (1988912, 4736670), (2198964, 7876293)
]

start = time.time()

# 线程池处理
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
   result_list = executor.map(gcd, numbers)

   for i in result_list:
       print(i)

end = time.time()
print('Took %.3f seconds.' % (end - start))

3. 全局解释器锁

上面的例子,我们使用线程池,使用线程池执行一个 CPU 密集型程序,发现消耗的时间和单线程差不多,并没有速度提升,其原因就是 Python 的 GIL(Global Interpreter Lock,即全局解释器锁)同一时刻也只能有一个线程处于运行状态,且切线程之间切换时还要消耗一部分资源。这就导致 CPU 密集型任务下多线程反而没有单线程运行的快。

3.1 为什么有 GIL

这与 CPython 解释器有关,发生在多个线程同时访问同一个共享代码、变量、文件等没有进行锁操作或者同步操作的场景中,就有可能出现条件竞争漏洞(Race condition)使用 GIL 可以规避掉类似的问题。还有一点是因为 Python 解释器使用 C 语言库,而大部分 C 语言库都不是原生线程安全的。

3.2 GIL 工作过程

下图是 GIL 在 Python 程序的工作示例。Thread 1、2、3 轮流执行,每一个线程在开始执行时,都会锁住 GIL 以阻止别的线程执行;每一个线程执行完一段后,会释放 GIL,以允许别的线程开始利用资源。  CPython 解释器会去轮询检查线程 GIL 的锁住情况。每隔一段时间,Python 解释器就会强制当前线程去释放 GIL,这样别的线程才能有执行的机会。该机制叫做 check_interval。

截屏2023-07-27 11.11.46.png

3.3 线程安全

有 GIL 并不意味着 Python 没有线程安全的问题,因为还有 check_interval 抢占机制,可以看下方 CASE:

import threading

num = 0


def add():
   global num
   for i in range(10000000):
       num += 1


def sub():
   global num
   for i in range(10000000):
       num -= 1


if __name__ == "__main__":
   subThread01 = threading.Thread(target=add)
   subThread02 = threading.Thread(target=sub)

   subThread01.start()
   subThread02.start()

   subThread01.join()
   subThread02.join()

   print("num result : %s" % num)

程序每次执行的结果可能都不相同,结果都不为 0,此时就需要使用线程锁来规避此类问题。

后记

本篇文章介绍了如何使用 Futures 创建线程池和进程池,并提供了两个 case,在实验过程中发现,因为 GIL 的原因 Python 的线程池并不适合 CPU 密集型的应用,此时就需要考虑使用多进程的方式,或者将核心代码使用 C++ 编写通过 Python 调用,从而绕过 GIL。虽然有 GIL 用户依然要考虑线程安全的问题,因为 GIL 只是方便 Python 解释器的编写人员,而不是 Python 程序的编写人员。


相关文章

shell编程基础(二)

1、条件表达式1.1 文件判断常用文件测试操作符:-d文件,d的全拼为directory 文件存在且为目录则为真,即测试表达式成立-f文件,f的全拼为file 文件存在且为普通文件则为真,即测试表达式...

Redis 运维规范_key 设计规范

Redis 运维规范_key 设计规范

一、key 设计规范1、设计合理的Key名称与长度Key名称:要见名知其意,方便快速定位问题及相关业务。key 名称要避免使用单双引号、转义字符等特殊符号。 key长度:在能完整描述业务的前提下尽量精...

MySQL运维实战之备份和恢复(8.1)xtrabackup全量备份

xtrabackup是percona开源的mysql物理备份工具。xtrabackup 8.0支持mysql 8.0版本的备份和恢复。xtrabackup 2.4支持mysql 5.7及以下版本的备份...

k8s删除Terminating状态的命名空间

问题描述Kubernetes中namespace有两种常见的状态,即Active和Terminating状态,其中Terminating状态一般会比较少见,当对应的命名空间下还存在运行的资源,但该命名...

数据湖技术之iceberg(七)Spark管理iceberg表

数据湖技术之iceberg(七)Spark管理iceberg表

1.SparkSQL设置catalog配置以下操作主要是SparkSQL操作Iceberg,同样Spark中支持两种Catalog的设置:hive和hadoop,Hive Catalog就是icebe...

keycloak部署和使用

keycloak部署和使用

简介Keycloak是一个开源软件产品,旨在为现代的应用程序和服务,提供包含身份管理和访问管理功能的单点登录工具。截至2018年3月,红帽公司负责管理这一JBoss社区项目,并将其作为他们RH-SSO...

发表评论    

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。