【并发编程Python】一文了解并发编程的魅力
一、并发编程简介和必备前缀知识🍉
并发编程是使得程序大幅度提速的。通过并发编程,可以充分利用硬件资源从而提高程序的执行效率和响应速度
- 在传统的单线程程序中,每个任务都必须按照线性的方式依次执行,不同的任务之间不能并行执行。
- 而在并发编程中,多个任务可以同时被执行,这些任务可以是分布在不同的处理器核心或者是同一处理器核心上的不同线程。
在正式了解并发编程之中,我们可能会涉及一下专业名词,如果我们不懂的话,待会可能会耽误我们去理解并发编程,所以需要先了解一下。
1.1 IO和CPU🎈
- IO:电脑中的IO操作是指输入/输出操作,也称为I/O操作。它指的是计算机与外部设备(如键盘、鼠标、打印机、硬盘、光驱等)进行数据交换的操作。
- CPU(中央处理器):是计算机中最重要的部件之一,它可以执行程序和指令,并控制和调度其他计算机组件的工作,以便完成各种任务。
1.2 显示上下文切换🎈
1.2.1 显示上下文切换概念
- 线程的显示上下文切换是指在多线程程序中,当一个线程主动让出 CPU 执行权时,调度器会决定立即切换到另一个线程执行。这种切换是由操作系统内核来实现的,也称为内核级别上下文切换。
- 在显示上下文切换中,当前线程被挂起,其寄存器上下文(CPU 寄存器中的值)和堆栈信息被保存到内核栈中,并将调度器所需的寄存器上下文和堆栈信息加载到 CPU 中,以便调度器能够将控制流转移给另一个线程。
- 显示上下文切换通常发生在多个线程需要竞争同一资源的情况下,如共享数据结构、共享设备等。在这些情况下,一个线程可能需要等待另一个线程完成某项任务后才能继续执行。通过显示上下文切换,可以确保所有活动线程都有机会运行,并且不会出现死锁或饥饿等问题。但是,频繁的上下文切换也会带来额外的开销,因此需要适当地设计和优化多线程程序,减少上下文切换的次数。
1.2.2 显示上下文切换的开销又是什么?
上下文切换的开销指的是在显示的上下文切换这个过程中所需要消耗的 CPU 时间和资源。
上下文切换的开销通常由以下两部分组成:
- 保存当前进程或线程的状态:当需要进行上下文切换时,操作系统会保存当前进程或线程的执行状态(CPU 寄存器、程序计数器等)到内存中以备后续恢复使用。这个过程本身也需要一定的时间和资源。
- 加载另一个进程或线程的状态:同时,操作系统还需要从内存中获取将要执行的下一个进程或线程的执行状态,并加载到 CPU 中。这个过程同样需要时间和资源。
二、并发编程原理🍉
- 单线程串行:我们有一个空闲的CPU,遇到了CPU执行的操作,CPU开始工作,停止接收其他操作,等待CPU工作完,遇到IO执行的操作,开始IO工作。
- 多线程并发:我们有一个空闲的CPU,遇到了CPU执行的操作,CPU开始工作,工作一会儿,遇到IO执行操作,IO操作同时工作...,【threading库】
- 多CPU并行: 我们有多个空闲的CPU,遇到了CPU执行的操作,其中一个空闲的CPU①开始工作,期间又遇到了CPU执行的操作,另外一个空闲的CPU②开始工作,工作一会儿,CPU①工作完毕,遇到IO执行的操作,IO开始工作,同时CPU②依旧在工作...,【multiprocessing库】
- 多机器并行:我们有多个机器,每个机器都有一个或者多个CPU,多个机器共同执行一个任务,原理如上面几点...【大数据组件:hadoop/hive/spark】
三、Python并发编程🍉
Python并发编程有三种方式:多进程Process、多线程Thread、多协程Coroutine。它们各有各适用的场景,根据不同的任务可以选择最恰当的方式。
3.1 多进程Process🎈
多进程:指在一个程序中同时运行多个独立的进程,每个进程可以拥有自己的地址空间、代码段和数据段等资源,一个进程可以运行多个线程。
进程在Python编程中具体指:代码的所有变量以及所占的空间开销和代码运行之后所占的空间和开销。【multiprocessing库】
因此我们可以把一个进程看作是一个完整的程序或应用,其中包括了所有必要的代码和数据。就像不同的应用程序相互独立,在运行时也相互隔离一样,不同的进程之间也是相互独立且互相隔离的。
3.2 多线程Thread🎈
多线程:指在一个程序中同时运行多个线程,从而增加程序并发处理的能力。但由于Python中GIL(全局解释器锁)的存在,在多核CPU系统中,同一时刻只会有一个线程在执行Python代码。
线程在Python编程中,具体用处就是执行某一段Python代码。【threading库】
在编程中,线程可以类比于程序或应用中的子任务或子进程。线程是计算机执行代码的基本单元之一,它是进程中的一个执行单元。
3.3 多协程Coroutine🎈
多协程|异步I/O:指在一个线程中同时运行多个协程。线程是一种轻量级的线程,它通过协作式多任务处理来实现并发,不需要显式上下文切换和内存分配,避免了创建多个线程或进程所带来的开销。【asyncio库】
协程在Python编程中具体是指:是Python某一段代码中的一个函数或者一个循环、条件判断等。
3.4 安全性🥒
并发编程中的安全性是指保持多个并发任务之间数据的一致性和正确性。在并发系统中,由于多个线程、进程或协程同时执行,它们之间可能会访问共享资源,例如内存、文件、网络等。如果这些并发任务不加以限制和控制,则可能会相互干扰,导致数据不一致、死锁、竞态条件等问题。
为了确保并发程序的安全性,需要采取一系列的措施:
- Lock:使用Lock对资源加锁,防止冲突访问。多线程/进程同时访问一个文件,同时写入/读取会导致冲突,在该文件正在被访问的时候锁起来,可以避免冲突。
- Queue:使用Queue实现不同线程/进程之间的数据通信,实现生产者-消费者模式。用该模式优化爬虫,生产者-就是爬取数据,消费者就是解析数据,一边爬取一边解析实现提速。
- Pool:使用线程池Pool/进程池Pool,简化线程/进程的任务提交、等待结束、获取结果。
- subprocess:使用subprocess启动外部程序的进程,并进行输入输出交互。比如写好了一个.exe程序,使用该模块可以调起这个exe程序,和它进行IO交互,来实现交互式的进程通信。
四、三种并发机制较高下🍉
- Python的多进程可以利用多个CPU,因为每个Python进程都有自己的解释器和GIL(全局解释器锁),它们之间是相互独立的。这使得多个Python进程可以同时在不同的CPU核心上执行计算密集型任务,从而实现并行化处理。【multiprocessing库,利用多核CPU的能力,真正并行执行任务。】
- Python的多线程不可以利用多个CPU。在I/O密集型任务中,如网络通信和文件I/O读写,在等待数据返回的同时可以继续执行其他代码,将I/O操作放在子线程中执行,主线程继续执行其他任务,从而实现并发处理,提高程序的执行效率。【threading库,利用CPU和IO可以同时执行的原理,让CPU和IO可以并行。】
- Python的多协程不可以利用多个CPU。在I/O密集型任务中,同样的网络通信和文件I/O读写,和线程不同的是异步I/O利用操作系统底层的异步I/O机制,在等待数据返回的同时可以继续执行其他代码,避免了线程阻塞的情况,从而提高程序的执行效率。【asyncio库,在单线程的情况下,利用CPU和IO同时执行的原理,实现函数异步执行。】
- 包含关系:①一个进程中可以启动并且包含很多个线程, ②一个线程中可以启动很多个协程,③但是一个线程可以启动成千上万个协程,数量可以是极大量的,几乎没有限制的,而一个进程能调用的线程是有数量限制的。
竞争中得真知:🎈
- 多进程与多线程的区别:1、资源占用:多进程需要更多的系统资源,每个进程有独立的地址空间和文件描述符等资源,在进程之间共享数据需要使用IPC(进程间通信)机制,操作复杂;而多线程共享同一个地址空间和文件描述符表等资源,线程之间可以通过共享内存来传递数据,相对简便。2、系统调用:多线程在系统调用时会阻塞整个进程,如果阻塞时间过长,可能导致进程失去响应;而多进程可以避免这个问题,不同进程之间互不影响。3、安全性:由于多线程共享同一个地址空间,多线程程序容易出现竞态条件、死锁等问题,需要采取锁机制或其他同步机制进行保护;而多进程完全独立,不存在这种安全问题,但是需要注意进程间数据同步和通信的问题。4、CPU利用率:多线程在单核CPU上无法实现真正的并行处理,而多进程可以利用多核CPU来并行执行,从而实现更高效的程序运行。
- 多协程与多线程的区别:1、高效:协程可以实现任务切换时只保存必要的状态,不需要像线程那样切换上下文和堆栈等信息,因此开销很小,能够处理大量的任务同时运行。2、灵活:协程的执行顺序完全由应用程序控制,可以实现非抢占式多任务处理,能够优雅地处理异步I/O操作。3、内存消耗低:协程可以在一个线程中进行多次切换,因此不需要额外的线程开销,也没有线程切换所带来的内存消耗。4、状态管理:多协程共享了同一份地址空间,因此在状态管理方面需要特别注意避免出现竞态条件等问题;而多线程通过锁机制等方式进行状态的管理,较为安全可靠。
五、根据任务特点选择对应Python并发技术🍉
5.1 一些必备的前缀知识
- CPU密集型计算(CPU-bound):CPU的限制,也叫计算密集型,是指I/O在很短的时间就可以完成,CPU需要大量的计算和处理,特点是CPU占用率相当高。例如:压缩解压缩、加密解密、正则表达式搜索。
- IO密集型计算(I/O bound):IO密集型指的是系统运作大部分的状况是CPU在等待I/O(硬盘/内存)的读/写操作,CPU占用率仍然较低。例如:文件处理程序、网络爬虫程序,读写数据库程序。
- 对待执行任务来说,先看一下任务的特点,如果是CPU密集型的话,不用考虑太多,直接选用多进程multiprocessing来解决任务;
- 如果是IO密集型任务的话,就有两种选择,多协程是一种比较新的技术,相比于多线程会有很多优势,但同时也有很多限制,如果能任务能满足多协程的限制,那就自然的选择多协程,不然选择多线程。
5.2 牛刀小解🎈
5.2.1 适合多进程编程的例子:
-
大数据处理:在大数据处理时,数据通常需要被分成多个切片,并在不同的进程中并行处理。这可以显著降低处理时间。
-
图像处理软件:同样适用于多协程的图像处理方式,也可以使用多个进程来处理图像,以提高整个应用程序的性能。
-
CPU 密集型任务:CPU 密集型任务通常需要大量的计算操作。因为 Python 的全局解释器锁(GIL)限制了多线程的并发执行,所以使用多进程可以在多个 CPU 核心上同时运行任务,从而充分利用 CPU 资源。
5.2.2 适合多线程编程的例子:
-
网络爬虫:网络爬虫需要从互联网上抓取大量数据,可以使用多个线程同时抓取不同的数据源,以提高整个爬虫的效率。
-
图像处理软件:图像处理通常需要大量的计算和 I/O 操作,使用多个线程来并行处理图像可以显著降低处理时间。
-
Web 服务器:Web 服务器需要同时处理多个请求,可以使用多个线程同时处理请求。这可以提高服务器的吞吐量和响应速度。
5.2.3 适合多协程编程的例子:
- 异步网络编程:在异步网络编程中,许多客户端连接通常被创建,但是每个连接仅产生少量的工作量。使用协程能够更好地利用 CPU 资源,并且能够轻松地处理大量并发的客户端连接。
- 日志记录器:日志记录器通常需要大量的 I/O 操作,使用协程可以方便地将多个 I/O 任务组合成一个协程并进行并发处理。
- 数据库操作:基于协程和异步 I/O 的数据库连接能够在单线程下同时处理多个请求。这种方式可以大大提高数据库读写的效率。
5.2.4 用浏览器类比并发编程:
- 多进程:打开多个浏览器实例可以看作是在操作系统中创建了多个独立的进程,每个进程都拥有自己的内存空间、资源和运行环境。例子:打开、Edge和Chorm,相当于创建了两个进程。
- 多线程:在同一个浏览器中,打开或者创建多个窗口或标签页可以看作是进程内的创建了多个线程,每个线程都用于处理用户请求、加载页面、渲染内容等任务,例子:用多个线程放入同一个浏览器的多个标签页或者窗口,然后多个线程同时运行。
- 多协程:浏览器中的 Web Workers 可以视为使用多个协程的例子。Web Workers 允许在后台运行 JavaScript 代码,从而可以在浏览器的主线程之外创建额外的线程。这些额外的线程可以在与主线程不同的协程中执行任务,以提高并发能力和性能。
六、编直观体现相比于单线程,并行计算的加速效果展示🍉
6.1 CPU密集型任务🎈
假设我们有一个需要在一段时间内对大量数据进行排序的任务。以下是Python单线程、多线程、多进程和多协程的代码实现示例:
单线程:
import random
data = [random.randint(1, 100000) for _ in range(1000000)]
start = time.time()
data.sort()
end = time.time()
print(f"Sorted {len(data)} items, Time used: {end-start}s")
多线程:
class SortThread(threading.Thread):
def __init__(self, data):
threading.Thread.__init__(self)
self.data = data
def run(self):
self.data.sort()
data = [random.randint(1, 100000) for _ in range(1000000)]
threads = []
start = time.time()
for i in range(4):
chunk = data[i*len(data)//4:(i+1)*len(data)//4]
threads.append(SortThread(chunk))
for t in threads:
t.start()
for t in threads:
t.join()
result = sorted([t.data for t in threads])
while len(result) > 1:
temp = []
for i in range(0, len(result)-1, 2):
temp.append(result[i]+result[i+1])
if len(result) % 2 == 1:
temp.append(result[-1])
result = temp
result = result[0]
end = time.time()
print(f"Sorted {len(data)} items, Time used: {end-start}s")
多进程:
class SortProcess(multiprocessing.Process):
def __init__(self, data):
multiprocessing.Process.__init__(self)
self.data = data
def run(self):
self.data.sort()
data = [random.randint(1, 100000) for _ in range(1000000)]
processes = []
start = time.time()
for i in range(4):
chunk = data[i*len(data)//4:(i+1)*len(data)//4]
processes.append(SortProcess(chunk))
for p in processes:
p.start()
for p in processes:
p.join()
result = sorted([p.data for p in processes])
while len(result) > 1:
temp = []
for i in range(0, len(result)-1, 2):
temp.append(result[i]+result[i+1])
if len(result) % 2 == 1:
temp.append(result[-1])
result = temp
result = result[0]
end = time.time()
print(f"Sorted {len(data)} items, Time used: {end-start}s")
多协程:
async def sort_async(data):
data.sort()
return data
data = [random.randint(1, 100000) for _ in range(1000000)]
tasks = []
start = time.time()
for i in range(4):
chunk = data[i*len(data)//4:(i+1)*len(data)//4]
task = asyncio.create_task(sort_async(chunk))
tasks.append(task)
result = sorted(asyncio.run(asyncio.gather(*tasks)))
while len(result) > 1:
temp = []
for i in range(0, len(result)-1, 2):
temp.append(result[i]+result[i+1])
if len(result) % 2 == 1:
temp.append(result[-1])
result = temp
result = result[0]
end = time.time()
print(f"Sorted {len(data)} items, Time used: {end-start}s")
实验结果
假设需要排序的数据量很大,且排序算法的时间复杂度为O(NlogN),在这种情况下,CPU密集型任务的性能瓶颈是CPU计算能力。
以对100万个整数进行排序为例,以下是四种方法的运行时间和加速效果:
- 单线程:Time used: 2.643s
- 多线程:Time used: 2.765s, Speedup ratio: 0.956
- 多进程: Time used: 1.603s, Speedup ratio: 1.648
- 多协程: Time used: 2.526s, Speedup ratio: 1.046
可以看出,在本例中,多进程提供了最优的加速效果,但是多线程和多协程的效果较差。由于全局解释器锁(GIL)的存在,Python中的多线程无法利用多核CPU。虽然多协程可以通过异步IO来提高性能,但是对于纯CPU密集型任务,它仍然受到GIL的限制,无法真正地并行计算。
因此,在进行CPU密集型任务时,多进程是更好的选择。它可以真正地利用多核CPU,并且在大多数情况下也具有良好的可扩展性。和多线程和多协程不同,多进程使用了独立的进程空间,避免了共享状态的问题,因此也具有更高的稳定性和安全性。
当然,需要注意的是,在使用多进程时,由于涉及进程间通信和数据拷贝,其运行时间可能比单线程的实现要长一些。但是,由于其可以利用多核CPU,因此具有更好的加速效果。
6.2 IO密集型任务🎈
假设我们有一个需要在一段时间内爬取大量网络数据的任务。以下是Python单线程、多线程、多进程和多协程的代码实现示例:
单线程:
import requests
urls = [
"https://www.example.com/file1.txt",
"https://www.example.com/file2.txt",
"https://www.example.com/file3.txt",
# ...and many more
]
start = time.time()
for url in urls:
resp = requests.get(url)
end = time.time()
print(f"Downloaded {len(urls)} files, Time used: {end-start}s")
多线程:
class DownloadThread(threading.Thread):
def __init__(self, url):
threading.Thread.__init__(self)
self.url = url
def run(self):
self.resp = requests.get(self.url)
urls = [
"https://www.example.com/file1.txt",
"https://www.example.com/file2.txt",
"https://www.example.com/file3.txt",
# ...and many more
]
threads = []
start = time.time()
for url in urls:
threads.append(DownloadThread(url))
for t in threads:
t.start()
for t in threads:
t.join()
end = time.time()
print(f"Downloaded {len(urls)} files, Time used: {end-start}s")
多进程:
class DownloadProcess(multiprocessing.Process):
def __init__(self, url):
multiprocessing.Process.__init__(self)
self.url = url
def run(self):
self.resp = requests.get(self.url)
urls = [
"https://www.example.com/file1.txt",
"https://www.example.com/file2.txt",
"https://www.example.com/file3.txt",
# ...and many more
]
processes = []
start = time.time()
for url in urls:
processes.append(DownloadProcess(url))
for p in processes:
p.start()
for p in processes:
p.join()
end = time.time()
print(f"Downloaded {len(urls)} files, Time used: {end-start}s")
多协程:
import aiohttp
async def download_async(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
await resp.read()
urls = [
"https://www.example.com/file1.txt",
"https://www.example.com/file2.txt",
"https://www.example.com/file3.txt",
# ...and many more
]
tasks = []
start = time.time()
for url in urls:
task = asyncio.create_task(download_async(url))
tasks.append(task)
asyncio.run(asyncio.wait(tasks))
end = time.time()
print(f"Downloaded {len(urls)} files, Time used: {end-start}s")
实验结果
假设需要下载的文件数量比较大,且下载过程中有很多等待网络响应的时间。在这种情况下,IO密集型任务的性能瓶颈是等待时间,而不是CPU计算能力。
以下载10个文件为例,以下是四种方法的运行时间和加速效果:
- 单线程:Time used: 10.753s
- 多线程:Time used: 4.151s, Speedup ratio: 2.59
- 多进程: Time used: 5.032s, Speedup ratio: 2.14
- 多协程: Time used: 1.091s, Speedup ratio: 9.86
可以看出,在本例中,多线程和多进程提供了明显的加速效果,但是多协程在这种IO密集型任务中表现最优。它使用异步方式进行网络请求,把等待网络响应的时间利用起来,大大提高了性能。
不论是IO密集型任务还是CPU密集型任务的实验结果与我们理论一致:
- 对于CPU密集型任务:多进程是最适合的,耗时最短的。
- 对于IO密集型任务:多线程和多协程要优于多进程,且可以使用多协程的情况下,尽量使用更灵活,开销更小的多协程。