【Python爬虫(34)】Python多进程编程:开启高效并行世界的钥匙
【Python爬虫】专栏简介:本专栏是 Python 爬虫领域的集大成之作,共 100 章节。从 Python 基础语法、爬虫入门知识讲起,深入探讨反爬虫、多线程、分布式等进阶技术。以大量实例为支撑,覆盖网页、图片、音频等各类数据爬取,还涉及数据处理与分析。无论是新手小白还是进阶开发者,都能从中汲取知识,助力掌握爬虫核心技能,开拓技术视野。
目录
- 一、进程概念深度剖析
- 二、Python 多进程模块 multiprocessing 揭秘
- 三、创建与启动进程的艺术
- 3.1 使用 Process 类创建单个进程
- 3.2 使用 Pool 类管理进程池
- 四、进程间通信的桥梁
- 4.1 使用 Queue 进行通信
- 4.2 使用 Pipe 进行通信
- 五、进程与线程的对比抉择
一、进程概念深度剖析
在计算机科学的领域中,进程是一个极为关键的概念,它是操作系统进行资源分配和调度的基本单位。从动态的角度来看,进程是程序的一次执行过程,是程序在数据集上的一次运行活动。这一动态定义深刻揭示了进程与程序的本质区别:程序是静态的,是一系列指令的有序集合,通常以文件的形式存储在磁盘等存储介质上;而进程则是动态的,具有明确的生命周期,从创建、运行,到最终结束。例如,当我们启动一个文本编辑器程序时,操作系统会为该程序创建一个进程,这个进程在内存中被分配资源,开始执行程序中的指令,随着我们对编辑器的操作,进程的状态不断变化,当我们关闭编辑器时,对应的进程也随之结束。
进程具有诸多重要特征,这些特征共同塑造了其在操作系统中的独特地位。动态性是进程最基本的特征,它意味着进程是动态产生、变化和消亡的。进程在其生命周期内,状态会不断改变,可能从就绪状态变为运行状态,再因等待资源而进入阻塞状态,最终完成任务后结束。并发性也是进程的重要特性,在现代操作系统中,内存中可以同时存在多个进程,它们在宏观上同时向前推进,实现了程序的并发执行,极大地提高了系统资源的利用率。例如,当我们同时打开浏览器、音乐播放器和文档编辑器时,这些应用程序对应的进程都在并发运行,各自处理不同的任务。独立性使得进程成为独立运行、独立获得资源、独立接受调度的基本单位。每个进程都有自己独立的地址空间,相互之间的资源和执行互不干扰,这保证了进程运行的稳定性和安全性。异步性则表明各进程按各自独立的、不可预知的速度向前推进,这是由于系统中资源的竞争和调度策略的影响,操作系统需要提供 “进程同步机制” 来解决异步问题,确保进程之间的协调运行。
从结构上看,进程主要由进程控制块(PCB)、程序段和数据段组成。PCB 是操作系统为每个进程分配的数据结构,用于存储进程的控制信息,它是进程存在的唯一标识。PCB 中包含了进程的状态、程序计数器、寄存器集、内存管理信息、打开文件描述符、信号与信号处理函数、进程优先级、进程标识符等重要信息。每当创建一个新的进程,操作系统就会为其分配一个 PCB,在进程调度和状态切换时,通过保存和恢复 PCB 来实现。程序段包含了进程执行的机器代码,是进程执行逻辑的载体,它是只读的,并且可以被多个进程共享。例如,多个用户同时运行同一个文本编辑器程序,这些进程共享同一个程序段。数据段则保存了进程执行过程中需要操作的数据,包括全局变量、静态变量以及程序运行时产生的临时数据等,数据段是进程私有的,每个进程都有自己独立的数据段,不同进程的数据段相互隔离,保证了数据的安全性和独立性。
二、Python 多进程模块 multiprocessing 揭秘
在 Python 中,multiprocessing模块是实现多进程编程的核心工具,它为开发者提供了一个强大且易用的接口,使得多进程编程在 Python 中变得相对简单。该模块基于进程的并行计算原理,通过创建独立的进程来执行任务,每个进程都拥有自己独立的 Python 解释器和内存空间。这一特性使得多进程程序能够巧妙地绕过全局解释器锁(GIL)的限制。在 Python 中,GIL 是一个互斥锁,它确保同一时刻只有一个线程能够执行 Python 字节码,这就导致在多核 CPU 环境下,Python 线程无法充分利用多核的计算能力,而multiprocessing模块创建的进程则不存在这个问题,每个进程都可以在不同的 CPU 核心上并行运行,从而充分发挥多核 CPU 的强大计算能力 。
从底层实现来看,multiprocessing模块通过 Python 的标准库对底层的系统调用进行了封装。在不同的操作系统上,进程的创建和管理方式存在差异,例如在 Unix/Linux 系统中,提供了fork()系统调用,它可以复制当前进程来产生新进程,父进程和子进程分别在不同的内存空间中运行;而在 Windows 系统中并没有fork()调用。multiprocessing模块隐藏了这些底层差异,为开发者提供了统一的跨平台接口,无论是在 Windows、Linux 还是 macOS 系统上,都可以使用相同的方式来创建和管理进程,大大提高了代码的可移植性和开发效率。
除了进程的创建和管理,multiprocessing模块还提供了丰富的进程间通信机制和同步原语。在多进程编程中,进程间通信是一个关键问题,因为不同进程之间需要交换数据和协调工作。multiprocessing模块提供了多种通信方式,如队列(Queue)、管道(Pipe)和共享内存等。队列是一种线程和进程安全的通信方式,它就像一个先进先出的容器,可以在多个进程之间传递数据,一个进程可以将数据放入队列,另一个进程则可以从队列中取出数据,实现了数据的共享和传递。管道则提供了一种双向通信机制,两个进程可以通过管道直接进行通信,一个进程向管道发送数据,另一个进程从管道接收数据,适用于需要直接交互的场景。共享内存则允许不同进程访问同一块内存区域,通过共享内存,进程可以直接读写共享的数据,提高了数据传输的效率,但需要注意同步问题,以避免数据冲突。
同步原语在多进程编程中也起着至关重要的作用,它用于确保进程之间的正确协作。multiprocessing模块提供了多种同步原语,如锁(Lock)、信号量(Semaphore)和事件(Event)等。锁是一种常用的同步机制,它就像一把钥匙,同一时刻只有一个进程能够获取锁,从而访问共享资源,当一个进程获取锁后,其他进程必须等待,直到该进程释放锁,这样可以避免多个进程同时访问共享资源导致的数据不一致问题。信号量则是一个计数器,它可以控制同时访问共享资源的进程数量,当一个进程获取信号量时,计数器减 1,当一个进程释放信号量时,计数器加 1,通过设置信号量的初始值,可以限制并发访问的进程数。事件则是一种简单的同步机制,它可以让一个进程通知其他进程某个事件已经发生,其他进程可以等待这个事件的发生,然后执行相应的操作。这些通信机制和同步原语相互配合,使得开发者能够方便地构建复杂的并行任务,实现高效的多进程编程。
三、创建与启动进程的艺术
3.1 使用 Process 类创建单个进程
在 Python 中,multiprocessing模块的Process类为我们提供了创建单个进程的便捷方式。使用Process类创建进程时,首先需要定义一个目标函数,这个函数将在新创建的进程中执行。例如,我们定义一个简单的函数worker,它接收一个参数name,并在执行时打印出进程的启动和退出信息:
import multiprocessing
import time
def worker(name):
print(f"Worker {name} is starting.")
time.sleep(2)
print(f"Worker {name} is exiting.")
if __name__ == "__main__":
p1 = multiprocessing.Process(target=worker, args=("A",))
p2 = multiprocessing.Process(target=worker, args=("B",))
p1.start()
p2.start()
p1.join()
p2.join()
在上述代码中,if name == “main”:这一条件语句是多进程编程中的关键。在 Windows 系统以及部分 Unix 系统中,Python 会重新导入主模块来启动新进程,为了避免在新进程中重复执行不必要的代码,如重新创建进程等,我们将主程序逻辑放在这个条件块中。只有在直接运行脚本时,__name__才会等于"main",这样可以确保在新进程中不会再次执行创建进程的代码,从而保证程序的正确性和稳定性。
接下来,我们创建了两个Process对象p1和p2,并将worker函数作为目标函数传递给它们,同时通过args参数传递不同的参数。调用start()方法是启动进程的关键步骤,它会通知操作系统创建一个新的进程,并在新进程中执行目标函数。此时,新进程开始独立运行,与主进程并发执行。join()方法则用于等待进程执行完成,它会阻塞当前进程(在这里是主进程),直到被调用join()方法的进程(p1和p2)执行完毕。通过这种方式,我们可以确保主进程在子进程完成任务后再继续执行后续的代码,实现了进程之间的同步。在实际应用中,start()和join()方法的配合使用非常重要,它们可以帮助我们有效地控制进程的执行顺序和并发执行的节奏。
3.2 使用 Pool 类管理进程池
当面对大量任务需要并行处理时,手动创建和管理每个进程会变得异常繁琐,并且会带来较高的系统开销。此时,multiprocessing模块中的Pool类就成为了我们的得力助手。Pool类允许我们创建一个进程池,其中包含多个预先创建好的进程,这些进程可以被重复利用来执行不同的任务。使用进程池的主要优势在于它能够显著减少进程的创建和销毁所带来的额外开销,因为进程的创建和销毁都需要操作系统进行资源分配和回收,这是相对耗时的操作。通过复用进程,我们可以提高资源的利用率,使得系统能够更高效地处理大量任务。
下面通过一个具体的代码示例来展示Pool类的使用方法:
import multiprocessing
import time
def square(x):
return x * x
if __name__ == "__main__":
with multiprocessing.Pool(processes=4) as pool:
numbers = [1, 2, 3, 4, 5]
results = pool.map(square, numbers)
print("Squares:", results)
在这段代码中,我们首先定义了一个简单的函数square,它用于计算输入数字的平方。然后,在if name == “main”:代码块中,我们使用with语句创建了一个包含 4 个进程的进程池pool。with语句的使用可以确保在代码块执行结束后,进程池会被正确关闭和清理,避免了资源泄露等问题。接着,我们定义了一个数字列表numbers,并使用pool.map(square, numbers)方法将square函数应用到numbers列表中的每个元素上。map方法会自动将任务分配给进程池中的空闲进程,每个进程执行一个任务,从而实现了并行计算。最后,我们打印出计算结果。通过这种方式,我们可以轻松地利用多个进程并行处理数据,大大提高了计算效率。在实际应用中,Pool类的map方法非常适用于数据密集型任务,如大规模数据的计算、处理和分析等场景。
四、进程间通信的桥梁
在多进程编程中,进程间通信(IPC)是一个核心问题,它允许不同进程之间交换数据和协调工作,从而实现复杂的系统功能。multiprocessing模块提供了多种强大的进程间通信机制,其中Queue和Pipe是两种常用且高效的方式,它们各自具有独特的特点和适用场景。
4.1 使用 Queue 进行通信
Queue是multiprocessing模块中提供的一种线程和进程安全的队列实现,它就像一个先进先出(FIFO)的容器,非常适合在多个进程之间传递数据。其线程和进程安全的特性是通过内部的锁机制来实现的,这确保了在多进程或多线程环境下,对队列的操作不会出现数据竞争和不一致的问题。例如,多个进程可以同时向队列中放入数据,或者从队列中取出数据,而不用担心数据的完整性和正确性会受到影响 。
下面我们通过一个经典的生产者 - 消费者模型来深入理解Queue在多进程间传递数据的过程。在这个模型中,生产者进程负责生成数据并将其放入队列,消费者进程则从队列中取出数据进行处理。
import multiprocessing
import time
def producer(queue):
for i in range(5):
print(f"Producing item: {i}")
queue.put(i)
time.sleep(1)
def consumer(queue):
while True:
item = queue.get()
if item is None:
break
print(f"Consuming item: {item}")
if __name__ == "__main__":
queue = multiprocessing.Queue()
producer_process = multiprocessing.Process(target=producer, args=(queue,))
consumer_process = multiprocessing.Process(target=consumer, args=(queue,))
producer_process.start()
consumer_process.start()
producer_process.join()
queue.put(None) # 发送结束信号
consumer_process.join()
在上述代码中,我们首先定义了producer函数,它在一个循环中生成 5 个数据,并使用queue.put(i)方法将数据放入队列中,每次放入数据后,线程会休眠 1 秒,以模拟实际生产过程中的耗时操作。接着定义的consumer函数,它通过一个无限循环使用queue.get()方法从队列中获取数据。get()方法是阻塞的,这意味着当队列为空时,consumer进程会一直等待,直到有数据被放入队列。当获取到的数据为None时,consumer进程会跳出循环,结束执行。在if name == “main”:代码块中,我们创建了一个Queue对象和两个进程:producer_process和consumer_process。然后启动这两个进程,生产者进程开始生产数据并放入队列,消费者进程则从队列中取出数据进行消费。最后,通过producer_process.join()等待生产者进程完成任务,再向队列中放入一个None作为结束信号,通知消费者进程数据已经生产完毕,最后等待消费者进程完成任务。
4.2 使用 Pipe 进行通信
Pipe是multiprocessing模块提供的另一种进程间通信机制,它基于管道实现,具有双向通信的特点,就像一条双向的通道,允许两个进程在管道的两端直接进行数据的发送和接收。与Queue不同,Pipe更侧重于两个特定进程之间的直接交互,适用于需要紧密协作和快速数据传输的场景。
以下是一个使用Pipe实现发送者 - 接收者模型的代码示例:
import multiprocessing
import time
def sender(pipe):
for i in range(5):
print(f"Sending item: {i}")
pipe.send(i)
time.sleep(1)
def receiver(pipe):
while True:
try:
item = pipe.recv()
print(f"Received item: {item}")
except EOFError:
break
if __name__ == "__main__":
parent_conn, child_conn = multiprocessing.Pipe()
sender_process = multiprocessing.Process(target=sender, args=(parent_conn,))
receiver_process = multiprocessing.Process(target=receiver, args=(child_conn,))
sender_process.start()
receiver_process.start()
sender_process.join()
parent_conn.close()
receiver_process.join()
child_conn.close()
在这段代码中,我们首先通过multiprocessing.Pipe()创建了一个管道,它返回两个连接对象parent_conn和child_conn,这两个连接对象分别代表管道的两端。然后定义了sender函数,它在循环中向管道发送 5 个数据,每次发送后休眠 1 秒。receiver函数则在一个无限循环中通过pipe.recv()方法从管道接收数据。当管道关闭且没有更多数据可读时,recv()方法会抛出EOFError异常,此时receiver函数会捕获这个异常并跳出循环。在if name == “main”:代码块中,我们创建了两个进程,sender_process使用parent_conn发送数据,receiver_process使用child_conn接收数据。启动两个进程后,发送者进程开始向管道发送数据,接收者进程则从管道接收并打印数据。最后,等待发送者进程完成任务后关闭parent_conn,再等待接收者进程完成任务后关闭child_conn,确保资源的正确释放 。
五、进程与线程的对比抉择
在编程领域,进程和线程是实现并发编程的两大核心概念,它们既有紧密的联系,又在多个关键方面存在显著差异。深入理解这些差异,并根据具体的应用场景选择合适的并发模型,是编写高效、稳定程序的关键。
从定义和本质来看,进程是程序的一次执行过程,是操作系统进行资源分配和调度的基本单位,每个进程都拥有独立的内存空间和系统资源,就像一个独立的王国,拥有自己的领土(内存空间)、资源(文件、设备等)和管理机制。例如,当我们同时运行浏览器、音乐播放器和文档编辑器时,它们分别对应不同的进程,彼此之间相互独立,互不干扰。而线程是进程内的一个执行单元,是 CPU 调度和分派的基本单位,多个线程共享同一进程的内存空间和系统资源,如同一个王国内的不同工作小组,它们共享王国的资源,但各自负责不同的任务。在一个文字处理软件进程中,可能有一个线程负责接收用户的键盘输入,另一个线程负责实时进行拼写检查,它们在同一个进程的环境下协同工作。
在资源占用方面,进程由于拥有独立的地址空间和系统资源,创建和切换进程的开销较大。操作系统需要为每个进程分配独立的内存空间,包括代码段、数据段、堆栈段等,在进程切换时,需要保存和恢复整个进程的上下文环境,这涉及到大量的内存操作和寄存器状态的保存与恢复,因此开销较大。而线程共享进程的资源,创建和切换线程的开销较小,线程创建时只需分配少量资源用于线程特定的栈空间等,在同一进程内的线程切换时,只需保存和恢复少量寄存器的内容,不需要进行内存管理方面的操作,因此切换速度更快。
在并行性和并发性上,进程之间可以并行执行,每个进程有自己独立的执行序列和状态,它们可以在不同的 CPU 核心上同时运行,实现真正的并行计算。在多核 CPU 系统中,多个进程可以分别在不同的核心上运行,充分利用多核的计算能力。线程在同一个进程内并发执行,共享同一进程的上下文,它们通过 CPU 的时间片轮转机制,在微观上交替执行,从而提高程序的并发性和响应性。在一个服务器进程中,多个线程可以同时处理不同客户端的请求,通过快速切换线程,实现对多个请求的并发处理。
进程间通信需要使用显式的通信机制,如管道、消息队列、共享内存等,由于进程之间的内存空间相互独立,它们之间的数据交换和信息传递需要借助这些专门的通信机制,这些机制的使用相对复杂,需要考虑数据的同步、互斥等问题。而线程间通信可以直接读写共享数据,因为它们共享同一进程的内存空间,通信更加方便快捷,但也需要注意同步机制的使用,以避免数据竞争和不一致的问题。多个线程可以直接访问共享的全局变量来进行数据交换,但如果多个线程同时对共享变量进行读写操作,就可能导致数据错误,因此需要使用锁、信号量等同步机制来保证数据的一致性 。
进程的失败通常只会影响自身,不会影响其他进程,因为每个进程都有独立的内存空间和资源,一个进程的崩溃不会波及到其他进程,这使得进程在运行复杂且相互独立的任务时具有较高的稳定性。例如,服务器上同时运行多个不同的服务,每个服务作为一个独立的进程,当其中一个服务进程出现故障时,不会影响其他服务的正常运行。而线程的失败会导致整个进程的崩溃,因为线程共享进程的资源和内存空间,一个线程的错误可能会破坏进程的状态,导致整个进程无法正常运行。在一个多线程的程序中,如果一个线程出现内存访问错误,可能会导致整个进程崩溃 。
基于上述差异,在选择使用进程还是线程时,需要根据具体的应用场景进行权衡。对于计算密集型任务,如大规模数据的计算、科学计算等,由于需要大量的 CPU 计算资源,且任务之间相对独立,使用多进程可以充分利用多核 CPU 的并行计算能力,提高计算效率,因为多进程可以在不同的 CPU 核心上并行执行,避免了线程由于 GIL 导致的无法充分利用多核的问题。对于 I/O 密集型任务,如网络请求、文件读写等,由于任务大部分时间都在等待 I/O 操作完成,使用多线程可以提高程序的并发性能,因为线程的创建和切换开销较小,可以在等待 I/O 的过程中切换到其他线程执行,充分利用 CPU 资源,同时线程间通信方便,便于在 I/O 操作完成后进行数据的处理和传递。如果需要实现强隔离性,确保各个任务之间的独立性和安全性,如运行不同的服务或应用程序,使用进程是更好的选择;如果需要频繁进行通信和数据共享,实现任务之间的紧密协作,如在一个应用程序中实现多个功能模块的协同工作,使用线程则更为合适 。