Python 并发编程 Futures

文若2年前技术文章545

说明

编程中如果能合理利用编程语言的并发编程技巧,都可以极大提升程序的性能。在 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 程序的编写人员。


相关文章

Ldap高可用部署

Ldap配置高可用两个节点上均执行mkdir /data/ldapcd /data/ldap1.1. 添加mod_syncprov.ldif文件vi mod_syncprov.ldif 内容如下:ob...

Docker 容器技术:简化 MySQL 主从复制部署与优化

前言    在现代数据库管理中,MySQL 主从复制是一种关键技术,用于提高数据的可用性和性能。随着 Docker 容器技术的普及,利用 Docker 搭建 MySQL 主从复制环境已成为一种趋势,它...

mysql查线上数据注意数据库的隔离级别

数据库的隔离级别定义了一个事务可能对其他并发事务的可见性,以及它们可能对数据库的影响。隔离级别的选择影响着并发性能和数据的一致性,不同的隔离级别能够防止不同程度的并发问题,如脏读(Dirty Read...

flink获取taskmanager的pstree信息

flink获取taskmanager的pstree信息

使用pstree –p 进程号 的方式能够获取taskmanager的pstree信息,这个地方提供一个收集脚本。内容如下:#!/bin/bashsearchPID() {   l...

热点现象(数据倾斜)怎么产生的,以及解决方法有哪些

热点现象:某个小的时段内,对HBase 的读写请求集中到极少数的Region 上,导致这些region所在的RegionServer 处理请求量骤增,负载量明显偏大,而其他的RgionServer明显...

系统RDSCPU打满问题分析报告

系统RDSCPU打满问题分析报告

1. 问题概述在2023年9月01日09点13分,玳数运维组侧接收到业务侧反馈系统响应缓慢,与此同时运维群内新系统RDS 发出CPU打满的告警,告警通知如下: 2. 问题分析a. 数据库会话管理核查玳...

发表评论    

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