听说是鸡肋
❝
一直以来,关于Python的多线程和多进程是否是鸡肋的争议一直存在,今晚抽空谈谈我的看法,以下是我的观点:
❞
对于多线程:
Python 的多线程库 threading 在某些情况下确实是鸡肋的,这是因为 Python 的全局解释器锁(Global Interpreter Lock, GIL)导致了多线程的并发性能不能真正发挥出来。简单来说,这意味着在任何给定时刻只有一个线程能够真正地运行 Python 代码,这就限制了多线程的性能。
然而,对于一些特定类型的任务,比如 I/O 密集型的任务,多线程还是可以带来性能提升的。这是因为 I/O 操作通常会导致线程阻塞,让其他线程得以运行。此外,在 Python3 中,对于一些特殊情况,比如使用 asyncio 库,也可以通过协程实现并发执行,从而规避 GIL 的限制。
对于多进程:
Python 的多进程库 multiprocessing 是可以真正发挥出多核处理器的性能的,因为每个进程都有自己的解释器和 GIL。这意味着每个进程可以独立地运行 Python 代码,从而实现真正的并行处理。
当然,多进程也有一些缺点,比如进程之间的通信和数据共享比较麻烦。此外,每个进程的启动和销毁都会涉及到一定的开销,因此如果任务很小,多进程可能反而会带来性能下降。
多线程和多进程怎么选
❝
对于不同类型的任务,多线程和多进程都有它们的优缺点,需要根据具体情况进行选择。如果你要处理的任务是 CPU 密集型的,那么多进程可能是更好的选择;如果是 I/O 密集型的,那么多线程可能更合适。
❞
实战验证
- 下面我写一个简单的代码示例,用来说明 Python 多线程在 CPU 密集型任务中的性能问题:
import threading
counter = 0
def worker():
global counter
for i in range(10000000):
counter += 1
threads = []
for i in range(4):
t = threading.Thread(target=worker)
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
print(counter)
这个代码示例定义了一个全局变量 counter,然后创建了 4 个线程,每个线程都会执行一个简单的循环,将 counter 的值加 1。最后输出 counter 的值。
在单线程模式下,循环完成后 counter 的值应该是 40000000,但是在多线程模式下,由于 GIL 的限制,多个线程并不能真正并行地执行代码,导致 counter 的最终值小于 40000000。例如,在我的机器上运行这个代码示例,最终的输出结果可能是 36092076,远小于预期的值。
这个示例表明,在一些 CPU 密集型的任务中,Python 多线程的性能受到 GIL 的限制,不能真正地发挥出多核处理器的优势。
- 我再写一个简单的代码示例,用来说明在 I/O 密集型任务中,多线程可以带来性能提升的情况:
import threading
import requests
urls = [
"https://www.google.com",
"https://www.baidu.com",
"https://www.github.com",
"https://www.python.org"
]
def worker(url):
res = requests.get(url)
print(f"{url} : {len(res.content)} bytes")
threads = []
for url in urls:
t = threading.Thread(target=worker, args=(url,))
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
这个代码示例创建了 4 个线程,每个线程负责访问一个 URL 并打印出该 URL 返回的内容长度。由于访问 URL 的操作是 I/O 密集型的,因此线程在等待服务器响应时会阻塞,让其他线程有机会执行。
在我的机器上运行这个代码示例,可以看到 4 个线程几乎同时执行,并在几乎相同的时间内完成了任务,证明了多线程在 I/O 密集型任务中的性能优势。
对于 Python3 中的 asyncio 库,它提供了基于协程的并发执行模型,可以在一定程度上规避 GIL 的限制。下面写了一个简单使用 asyncio 库的代码示例:
import asyncio
import aiohttp
urls = [
"https://www.google.com",
"https://www.baidu.com",
"https://www.github.com",
"https://www.python.org"
]
async def worker(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
content = await response.read()
print(f"{url} : {len(content)} bytes")
async def main():
tasks = []
for url in urls:
task = asyncio.create_task(worker(url))
tasks.append(task)
await asyncio.gather(*tasks)
asyncio.run(main())
这个代码示例使用 asyncio 库来实现异步访问多个 URL,其中 worker 函数是一个异步函数,使用 aiohttp 库发送异步 HTTP 请求。在主函数中,使用 asyncio.create_task 创建多个协程任务,并使用 asyncio.gather 函数等待所有协程任务完成。
在我的机器上运行这个代码示例,可以看到几乎同时访问 4 个 URL,并在几乎相同的时间内完成了任务,证明了 asyncio 库在 I/O 密集型任务中的性能优势。
- 我们继续看多进程,下面我写了一个简单的代码示例,用来说明 multiprocessing 库是否可以真正发挥出多核处理器的性能:
import multiprocessing
def worker(start, end):
for i in range(start, end):
print(i * i)
if __name__ == '__main__':
processes = []
num_processes = 4
num_tasks = 20
for i in range(num_processes):
start = i * num_tasks // num_processes
end = (i + 1) * num_tasks // num_processes
p = multiprocessing.Process(target=worker, args=(start, end))
processes.append(p)
for p in processes:
p.start()
for p in processes:
p.join()
这个代码示例创建了 4 个进程,每个进程负责计算一段整数的平方并打印出结果。由于每个进程有自己的解释器和 GIL,因此每个进程可以独立地运行 Python 代码,从而实现真正的并行处理。
在我的机器上运行这个代码示例,可以看到 4 个进程几乎同时执行,并在几乎相同的时间内完成了任务,证明了 multiprocessing 库可以真正发挥出多核处理器的性能。
- 之前提到,多进程在处理小任务时可能会带来性能下降,下面我写了一个简单的代码示例,说明以下这种情况:
import multiprocessing
def worker(num):
result = num * num
print(result)
if __name__ == '__main__':
processes = []
num_processes = 4
for i in range(num_processes):
p = multiprocessing.Process(target=worker, args=(i,))
processes.append(p)
for p in processes:
p.start()
for p in processes:
p.join()
这个代码示例创建了 4 个进程,每个进程负责计算一个整数的平方并打印出结果。由于任务非常小,每个进程的计算时间非常短,因此进程的启动和销毁所涉及的开销可能会成为性能的瓶颈。
在我的机器上运行这个代码示例,可以看到进程的启动和销毁所涉及的开销导致整个程序的运行时间远远超过了单进程的运行时间。这说明在处理小任务时,多进程可能会带来性能下降,因此需要根据实际情况选择合适的并发处理方式。
最后的总结
Python 的并发编程有多种实现方式,包括多线程、多进程和协程等。其中,多线程通常适用于 I/O 密集型的任务,但由于 GIL 的存在,不能真正发挥出多核处理器的性能;而多进程则可以真正发挥出多核处理器的性能,但进程之间的通信和数据共享比较麻烦,每个进程的启动和销毁也会涉及到一定的开销。协程则是一种轻量级的并发处理方式,适用于 I/O 密集型任务和部分计算密集型任务,可以通过 async/await 关键字和 asyncio 库来实现。
在实际编程中,需要根据任务类型、数据量、机器配置等因素来选择合适的并发处理方式。对于小型任务,多进程可能会带来性能下降;对于计算密集型任务,可以考虑使用多进程或者协程;对于 I/O 密集型任务,可以使用多线程、多进程或者协程等方式。同时,还需要注意并发处理带来的数据竞争、死锁、线程安全等问题,以保证程序的正确性和性能。