今天咱们来聊聊 Python 中的多线程和多进程,它们在不同应用场景下的优缺点以及适用范围。
如果你是 Python 开发工程师,可能在日常工作中已经或多或少接触过这两者,但到底什么时候该用多线程,什么时候该用多进程,可能还是有些迷茫。今天咱们就来一探究竟。
首先,咱们得搞清楚“线程”和“进程”这两个概念。进程是操作系统分配 CPU 资源的基本单位,而线程则是操作系统分配 CPU 的更小的单位。
通俗地讲,进程就像是一个大型的容器,而线程是容器里的工人。每个进程有自己的内存空间,而多个线程共享同一个进程的内存。这是它们最本质的区别。知道这一点,我们接下来的讨论就会更清晰。
多线程适用于 I/O 密集型的应用场景,比如网络爬虫、数据库访问等。为什么是 I/O 密集型?因为 I/O 操作一般会涉及到大量的等待,比如读取磁盘、网络请求等。
在这些等待期间,CPU 是空闲的,而多线程的优势就在于,它们能在一个线程等待 I/O 操作完成时,切换到另一个线程继续工作。
这样,不仅可以提高 CPU 的利用率,也能让程序更加高效。对于 Python 来说,多线程的一个大问题是 GIL(全局解释器锁)。
GIL 会导致 Python 线程在执行字节码时,不能实现真正的并行处理。所以,如果任务是 CPU 密集型的,GIL 就成了一个很大的瓶颈。
举个简单的例子,假设我们有两个任务:一个是读取文件,另一个是进行复杂的计算。如果使用多线程来做,读取文件时 CPU 就会空闲,等待的时间内另一个线程可以执行计算任务,这时的 CPU 利用率较高。代码示例:
import threading
import time
def read_file():
print("Reading file...")
time.sleep(3)
print("File read done")
def compute():
print("Start computing...")
time.sleep(2)
print("Computation done")
threads = []
t1 = threading.Thread(target=read_file)
t2 = threading.Thread(target=compute)
threads.append(t1)
threads.append(t2)
for t in threads:
t.start()
for t in threads:
t.join()
print("All tasks completed")
在这段代码中,我们有两个线程:一个是执行文件读取的,另一个是执行计算的。在文件读取时,线程会等待 3 秒,而计算任务需要 2 秒。通过多线程,这两个任务可以重叠执行,从而提高效率。
但是,如果任务涉及到大量计算,比如矩阵运算、视频编码等,多线程就不那么合适了,因为即使你有多个线程,GIL 也限制了它们的并行执行,最终可能导致 CPU 资源无法充分利用。
这时就轮到 多进程 上场了。多进程可以绕开 GIL 的限制,每个进程都有独立的内存空间和 GIL。因此,多进程特别适合 CPU 密集型的任务。
例如,数据处理、科学计算、图像处理等,都可以通过多进程来加速。每个进程可以在不同的 CPU 核心上并行运行,充分发挥多核 CPU 的性能。
代码示例:
from multiprocessing import Process
import time
def compute():
print("Start computing...")
time.sleep(2)
print("Computation done")
processes = []
p1 = Process(target=compute)
p2 = Process(target=compute)
processes.append(p1)
processes.append(p2)
for p in processes:
p.start()
for p in processes:
p.join()
print("All tasks completed")
在这个例子中,计算任务被放在了两个独立的进程中,这两个进程可以在不同的 CPU 核心上并行执行,从而提升计算效率。
虽然多进程能够解决 GIL 的限制,并且能够充分利用多核 CPU 的能力,但它也有一些缺点。首先,进程之间的通信非常复杂。
由于每个进程有独立的内存空间,进程间不能直接共享数据。我们必须通过进程间通信(IPC)机制来实现数据的交换,常见的方式包括管道、队列、共享内存、套接字等。这就导致了多进程在数据交换时比多线程更复杂,代码的可维护性也降低。
此外,进程的启动和销毁比线程要更为昂贵,尤其是在大规模并行任务下,频繁地创建和销毁进程会带来较大的性能开销。
除了多线程和多进程,异步编程也是一种常见的并发编程方式。异步编程与线程和进程不同,它并不依赖于操作系统的并发机制,而是通过事件循环和协程来模拟并发。
具体来说,异步编程通常采用 async/await 关键字,通过 事件循环 来调度任务。异步编程非常适合 I/O 密集型任务,尤其是需要大量等待 I/O 操作的场景。它的优点在于能够避免线程的上下文切换,且内存消耗较低。
以 asyncio 为例:
import asyncio
async def read_file():
print("Reading file...")
await asyncio.sleep(3)
print("File read done")
async def compute():
print("Start computing...")
await asyncio.sleep(2)
print("Computation done")
async def main():
await asyncio.gather(read_file(), compute())
asyncio.run(main())
这段代码通过 asyncio.gather()
将两个异步任务并发执行,执行过程中,read_file()
和 compute()
可以同时进行,避免了不必要的等待。
在 Python 中,选择多线程、还是多进程、还是异步编程,主要取决于任务的性质。
如果是 I/O 密集型任务,可以考虑使用多线程或者异步编程,它们都可以避免过多的等待,提高 CPU 利用率。
而如果任务是 CPU 密集型的,那么多进程是最佳选择,能够充分发挥多核 CPU 的优势。
那面试官问你:在 Python 中,如何选择多线程、多进程和异步编程?它们各自的优缺点是什么?
你的回答可以是:
Python 中的并发编程主要有多线程、多进程和异步编程三种方式:
多线程:适合 I/O 密集型任务,如文件读取、网络请求等。多个线程共享同一个进程的内存空间,可以高效利用 CPU 的空闲时间。缺点是受到 GIL 的限制,无法实现 CPU 密集型任务的并行计算。
多进程:适合 CPU 密集型任务,如数据处理、科学计算等。通过创建多个独立的进程,每个进程有自己的内存空间和 GIL,能充分利用多核 CPU。缺点是进程间通信较为复杂,并且启动和销毁进程的开销较大。
异步编程:适合 I/O 密集型任务,特别是在大量等待 I/O 操作时(如网络爬虫)。通过协程和事件循环,避免了线程切换的开销,内存消耗较少。缺点是代码逻辑相对复杂,需要理解事件循环的机制。
最终选择何种方式,需要根据任务的性质来判断。
对编程、职场感兴趣的同学,大家可以联系我微信:golang404,拉你进入“程序员交流群”。
虎哥作为一名老码农,整理了全网最全《python高级架构师资料合集》。