python multiprocessing.dummy的使用

182 阅读6分钟

最开始接触这个技术是在《Python爬虫开发:从入门到实战(微课版)》的第四章,多线程爬虫。原本看看就看看了,但是没想到实际用起来效果这么好。

就是调用api,基于文本生成instruction结构数据的时候,如果生成的数据量很大,比如生成500条数据,大概得花去2.3个小时。但是使用multiprocessing.dummy以后,能把2,3个小时降到1分钟内。

使用multiprocessing.dummy以后,部分代码及运行时间如下: image.png

1. 计算密集型和IO密集型

要想熟练使用multiprocessing.dummy,首先得判断当前的任务是计算密集型还是IO密集型。对于基于文本生成数据而言,这应该属于IO密集型。因为计算密集型指的是线程/进程对CPU的使用时间占整个任务运行时间的大头,IO密集型指的是用于输入输出操作的时间占整个任务时间的大头。

在生成一条数据的过程中,主要在工作的是大模型厂商的服务器,而不是本地的CPU。我们是把需求发给服务器,服务器处理完后再返回给客户端,所以实际上客户端的CPU并不是工作的主体,大量时间都消耗在了网络传输以及等待对方服务器处理完成上。

multiprocessing.dummy就是用来处理IO密集型的任务,对应多线程;而multiprocessing是用来处理计算密集型的任务,对应多进程。

2. multiprocessing.dummy实现加速的原理

那么有一个问题:multiprocessing.dummy能够对多线程任务实现加速的底层原理是什么?流水线技术也可以对任务加速,它们有什么区别?

流水线技术是把一个大的任务划分成了多个阶段,每个阶段由一个部件去执行,核心思想就是让每个部件尽可能忙碌起来,实现“阶梯式”的工作。经典的五段式流水线如下:

image.png

image.png 图中的这三个指令,如果串行执行,一个指令消耗五个T,三个指令就得消耗15个T。使用流水线技术以后,完成3个指令只需要7个T。

那么多线程加速的原理和流水线的原理类似吗?答案是不一样。流水线技术是把一个任务分为多个阶段,每个阶段由一个专门的部件完成;而多线程并没有划分阶段,还是以调用api生成数据为例,由于这是IP密集型任务,一整个任务的过程中CPU工作的时间并不多,大部分时间都消耗在网络传输和等待服务器上,多线程的原理是让不同线程的网络传输和等待服务器的时间重叠,从而实现加速。

假定生成一个数据消耗10秒,1秒CPU工作,另外9秒都是网络传输和等待服务器。如果采用串行的方式,生成3条数据就需要30秒;使用多线程以后,只需12秒。

image.png

3. GIL(Global Interpreter Lock) 全局解释器锁

要想彻底理解这个问题,还需要介绍GIL。这是Python中的一个机制,用来解决多线程并发执行时的互斥问题。在操作系统这门课中,有关于锁,同步和互斥,进程和线程等内容,知道这些可能对理解GIL有所帮助。

GIL本质上就是一个锁,打个比方,我现在有5个线程,他们的任务都是基于文本生成一条数据,并把该数据写入data.txt的文件中。假定现在data.txt中已经有10KB的数据,如果没有GIL,那么就有可能出现5个线程往data.txt的同一位置写入数据的情况,这样就会导致数据互相覆盖。 而理想情况肯定是5个线程依次往后写入数据,而不是互相覆盖。

这时候就需要用到GIL,它实现了同一时间只有一个线程执行python字节码。 这个概念很抽象,只需要理解为同一时刻只有一个线程拥有对python解释器的控制权,哪怕此时由于异步切换到了其他线程上,由于GIL锁的限制,其他线程也无法往data.txt写入数据,从而避免了数据覆盖。GIL本质上就是实现了线程间的互斥。

4.代码和实际运行效果

《Python爬虫开发:从入门到实战(微课版)》这本书上给的例子是这样的:

image.png

相比于利用for循环线性访问网页,使用multiprocessing.dummy以后速度提升了5倍左右,从16.2秒降低到了3.5秒。提速效果很明显。

但是与此同时,作者也强调了线程数量不宜过多,否则会导致大量时间都消耗在了线程切换上,这种情况下速度提升的效果也不明显。

真正在用的时候,我发现相比于原来用for循环挨个生成数据的方式而言,使用多线程由于线程切换所带来的时间损耗几乎可以忽略不计。这是一次生成464条数据的过程中记录的数据:

image.png

可见额外的五百多个线程带来的时间损耗只增加了5秒。这个例子里面线程量变大,所用时间反而增加的原因是我只生成464条数据,分配给464个线程,剩余500多个线程是领不到任务的,只会带来线程切换的时间损耗,因此时间增加。如果是生成·1000条数据,使用1000个线程的速度肯定快于使用464个线程的速度。

具体代码如下:

import pdfplumber

# 解析pdf
def extract_text_from_pdf(pdf_path):
    full_text = []
    with pdfplumber.open(pdf_path) as pdf:
        for page in pdf.pages:
            page_text = page.extract_text(x_tolerance = 1, y_tolerance = 1)
            full_text.append(page_text)
    return "\n".join(full_text)
import textwrap

# 切割pdf
def process_large_text(text, chunk_size = 500):
    chunks = textwrap.wrap(text, width = chunk_size, replace_whitespace = False)
    return [chunk for chunk in chunks if chunk.strip()]
pdf_text = extract_text_from_pdf("./pdf/Canon_c003.pdf")
print(len(pdf_text))
chunks = process_large_text(pdf_text)
print(len(chunks))

from multiprocessing.dummy import Pool
import time

from openai import OpenAI

client = OpenAI(api_key = "sk-fe3e9453337c40e3ae02ba6e38c71df5", base_url="https://dashscope.aliyuncs.com/compatible-mode/v1")

def generate_data(chunk):
    """数据生成函数(保留原实现)"""

    response = client.chat.completions.create(
        model="qwen-plus",
        messages=[
            {"role": "system", "content": "你是一名相机摄影专家,请根据提供的文本片段生成结构化数据"
             "若文本片段信息不够,也可以结合已知相机知识生成结构化数据"},
            {"role": "user", "content": 
             f"文本片段:{chunk}\n\n请生成:\nInstruction:(20字)Input:(50字概括)Output:(分3点,共180字)"
             "生成数据时只是利用到了文本片段中的信息,写input时就当文本片段不存在"
             "不需要说明“文本提供...”以及“文本包含...”,”文档包含“等字眼。"
             "不需要使用Markdown格式,只是普通的文本文件格式"}
        ],
        temperature=0.7,
        max_tokens=500,
        stream=False
    )
    ans_list.append(response.choices[0].message.content)
    return response.choices[0].message.content

start = time.time()

chunks_len = len(chunks)
pool = Pool(1000)
ans_list = []
pool.map(generate_data, chunks)
with open("./data.txt", "a", encoding = 'utf-8') as f:
    for ans in ans_list:
        f.write(ans)
        f.write("\n\n")

end = time.time()
print(f"生成464条数据,一共消耗{end - start}秒")

由于我使用的是Jupyter Notebook, 因此将代码分为多段执行。

我的理解如果有不妥之处,欢迎评论区评论指正。