Python 中多线程(multi-threading)与多进程(multi-processing)的比较

  多线程和多进程是 Python 中两种实现多任务的不同策略,二者都可以在特定的场景下在一定程度上提高程序的运行速度、性能以及吞吐,但二者的运行机制却有很大的差别。

  在 Python 中,多线程以并发 (concurrent)的方式运行,适用于 I/O 密集型 任务的场景;多进程以并行 (parallelism)的方式运行,适用于计算密集型任务的场景。虽然多进程也可以用于 I/O 密集型任务的场景,但这会导致部分 CPU 性能的浪费,且进程的开销也比线程要高。而多线程用于计算密集型的任务场景则相当于这些任务串行执行。

⒈ GIL(Global Interpreter Lock)

⑴ GIL 介绍

  GIL 是一种互斥锁机制,其目的是为了确保在同一时间只有一个线程可以运行,进而确保了在同一时间只有一个线程可以访问/操作内存。但这同时也阻止了 Python 多线程对多核处理器的充分利用。

⑵ 为什么引入 GIL

  Python 通过引用计数的方式进行垃圾回收。当一个对象的引用计数降为 0 时,该对象会被垃圾回收,其所占用的内存空间会被释放。

Python 复制代码
import sys


a = []
b = a
print(sys.getrefcount(a)) # 3

上例中,对象 [] 被变量 ab 以及函数 sys.getrefcount() 的参数同时引用,故结果为 3

  如果允许同一时间有多个线程同时运行,那么这些线程同时修改对象的引用计数可能会出现竞争条件,最终导致对象的引用计数错误,进而导致程序崩溃(引用计数过早的降为 0)或内存泄漏。

Python 复制代码
import threading


count = 0


def accumulator():
    global count
    for i in range(0, 100000):
        count += 1


threads = []

for i in range(0, 100):
    threads.append(threading.Thread(target=accumulator))

for thread in threads:
    thread.start()

for thread in threads:
    thread.join()

print(count) # 8752687

GIL 只是确保同一时间只有一个线程在执行,但并不一定能阻止竞争条件

⑶ GIL 带来的影响

  GIL 使得 Python 多线程无法充分利用多核处理器的优势,进而使得多线程无法适用于计算密集型的场景。

GIL 可能会在 Python 3.13 中设置为可选项

⒉ 多线程

  在 Python 中,由于 GIL 机制的限制,同一时间只能有一个线程在执行,这就决定了 Python 的多线程只会对 I/O 密集型任务的性能有显著的提升。

  在 I/O 密集型的任务场景中,当一个线程因为等待 I/O 被阻塞时,系统可以将当前线程挂起而执行其他线程,这样可以充分利用 CPU 资源,缩短程序的总体运行时间,提升性能。

Python 复制代码
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
import matplotlib.pyplot as plt
from random import choice
from string import ascii_letters
import time


def multi_threading(func, args, workers):
    with ThreadPoolExecutor(max_workers=workers) as ex:
        res = ex.map(func, args)

    return list(res)


def create_text():
    text = ''.join(choice(ascii_letters) for i in range(10**7))

    return text


def io_bound(text: str, base: float = 0):
    start = time.time() - base
    f = open('letters.txt', 'wt', encoding='utf-8')
    f.write(text)
    end = time.time() - base

    return start, end


def visualize_performance(times: list, workers: list):
    figure, axe = plt.subplots(layout='constrained')
    figure.suptitle('io bound tasks: more threads, less time')
    axe.set_xlabel('number of threads')
    axe.set_ylabel("time used (s)")
    axe.bar(workers, times)
    plt.show()


texts = [create_text() for i in range(16)]
workers_num = [1, 2, 4, 8, 16]
times = []
for num in workers_num:
    start = time.time()
    res = multi_threading(io_bound, texts, num)
    end = time.time()
    times.append(end - start)

visualize_performance(times, ['1', '2', '4', '8', '16'])

  上述代码通过将一个随机生成的包含 1 0 7 10^7 107 个字符的字符串写入文本文件 16 次来模拟 I/O 密集型任务的场景。在程序运行过程中,整体的运行时间会随着线程数量的增加而下降。

Python 复制代码
def multi_threading(func, args, workers):
    start_time = time.time()
    with ThreadPoolExecutor(max_workers=workers) as ex:
        res = ex.map(func, args, [start_time] * len(args))

    return list(res)
    

def visualize_mechanism(times: list, task_num: int):
    figure, axe = plt.subplots(layout='constrained')
    figure.suptitle('io bound tasks with %s threads' % task_num)
    axe.set_xlabel('seconds')
    axe.set_ylabel('tasks')
    widths = [t[1] - t[0] for t in times]
    lefts = [t[0] for t in times]
    axe.barh(range(len(times)), widths, left=lefts)
    plt.show()


texts = [create_text() for i in range(16)]
workers_num = [1, 2, 4, 8, 16]
times = []

for num in workers_num:
    res = multi_threading(io_bound, texts, num)
    visualize_mechanism(res, num)

  对之前的代码进行适当的修改,查看多线程的运行机制。当只有一个线程来执行上述文件写入操作时,所有的 16 次文件写入会依次执行,每一次写入都只有等到上一次写入彻底完成后才能开始执行。

  而在多线程模式下,上述的文件写入操作则会并发执行。

  对于计算密集型的任务,随着线程数量的增加,任务的执行速度和性能并没有提升。

Python 复制代码
def cpu_bound(base: float = 0):
    start = time.time() - base
    count = 0
    for i in range(0, 10**5):
        count += 1

    end = time.time() - base

    return start, end

⒊ 多进程

  Python 中的多进程充分利用了多核处理器的优势,使得多任务可以并行执行。并行执行的任务数越多,程序运行的越快。Python 多进程模式下,每个进程都有各自独立的解释器(interpreter),各个进程的内存空间也相互独立,所以 GIL 机制并不会成为多进程的瓶颈。

Python 复制代码
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
import matplotlib.pyplot as plt
from random import choice
from string import ascii_letters
import time
from math import cos, pi


def multi_processing(func, workers):
    with ProcessPoolExecutor(max_workers=workers) as ex:
        res = ex.map(func, [0] * 16)

    return list(res)


def cpu_bound(base: float = 0):
    start = time.time() - base
    count = 0
    for i in range(0, 10**5):
        count += cos(i * pi)

    end = time.time() - base

    return start, end


workers_num = [1, 2, 4, 8, 16]
times = []

for num in workers_num:
    start = time.time()
    res = multi_processing(cpu_bound, num)
    end = time.time()

    times.append(end - start)

visualize_performance(times, ['1', '2', '4', '8', '16'])

  上述代码通过进行 16 次大量数学计算模拟计算密集型任务场景。随着进程数量的增加,刚开始程序的运行会越来越快。但当进程数量超过机器内核数量时(4),程序运行反而会因为进程的切换而变慢。

  当所有的 16 次计算任务都通过一个进程进行时,这些任务会串行执行;但随着进程数量的增加,同时执行的任务也会增加;当进程数量超过机器的内核数量时,任务可能会因为进程切换而交错执行。

  使用多进程处理 I/O 密集型的任务,程序运行速度会随着进程数量增加而变快,但同样,当进程数量超过内核数量时,程序的运行速度会变慢。

相关推荐
love530love5 小时前
LiveTalking 数字人项目 Windows 部署完全指南(EPGF 架构)
人工智能·windows·python·架构·livetalking·epgf
遇事不決洛必達5 小时前
【Python基础】GIL 锁是什么及其对爬虫的影响
爬虫·python·线程·进程·gil锁
星辰徐哥5 小时前
Spring Boot 微服务架构设计与实现
spring boot·后端·微服务
星辰徐哥5 小时前
Spring Boot 数据导入导出与报表生成
spring boot·后端·ui
明夜之约5 小时前
Spring Boot 自动装配源码
java·spring boot·后端
Leaton Lee5 小时前
Spring Boot分层架构详解:从Controller到Service再到Mapper的完整流程
java·spring boot·后端·架构
Micro麦可乐5 小时前
Spring Boot 实战:从零设计一个短链系统(含完整代码与数据库设计)
数据库·spring boot·后端·哈希算法·雪花算法·短链系统
Jinkxs5 小时前
Resilience4j- 与 Spring Boot 快速集成:自动配置与基础注解使用
java·spring boot·后端
毕设源码_郑学姐5 小时前
计算机毕业设计springboot网络相册设计与实现 基于Spring Boot框架的在线相册管理系统开发与应用 Spring Boot驱动的网络影集设计与实践
spring boot·后端·课程设计
辣机小司5 小时前
【踩坑记录:Spring Boot 配置文件读取值不一致?警惕 YAML 的“八进制陷阱”与 SnakeYAML 版本之谜】
java·spring boot·后端·yaml·踩坑记录