1. 「并发 vs 并行」
说到并发编程,我们先来澄清一下并发 (Concurrency) 和 并行 ( Parallelism)这两个概念,因为这个两该概念的含义是不同的。
**并行(Parallelism)**指的就是在同一时刻,有两个或两个以上的任务的代码在处理器上执行。从这个概念我们也可以知道,多个处理器或多核处理器是并行执行的必要条件。
在单个CPU核上,线程或进程通过时间片或者让出控制权来实现任务切换,达到 "同时"运行多个任务的目的,这就是所谓的并发(Concurrency)。但实际上任何时刻都只有一个任务被执行,其他任务通过某种算法来排队。
多核CPU可以让同一进程内的"多个线程"或多个进程做到真正意义上的同时运行,这才是并行。
因此,我们可以有这样一个认知结论:并发不是并行,并发关乎结构,而并行关乎执行。在不满足并行必要条件的情况下(也就是仅有一个单核CPU的情况下),即便是采用并发设计的程序,依旧不可以并行执行。而在满足并行必要条件的情况下,采用并发设计的程序是可以并行执行的。而那些没有采用并发设计的应用程序,除非是启动多个程序实例,否则是无法并行执行的。
2. 「threading vs multiProcessing vs asyncio」
接下来我们来聊一下线程、进程和协程以及Python中的对应实现。
学习过计算机基础或其他编程语言的,应该清楚这几者之间的区别:
- 「进程」:进程是系统进行资源分配的基本单位,有独立的内存空间;
- 「线程」:线程是CPU调度和分派的基本单位,线程依附于进程存在,每个线程会共享父进程的资源,线程栈是线程独占的内存资源,比如JAVA线程栈内存默认1024KB;
- 「协程」:协程是一种用户态的轻量级线程,协程的调度完全由用户控制,协程间切换只需要保存任务的上下文,没有内核的开销,协程的栈内存资源占用很小,协程初始化创建的时候为其分配的栈内存只有2KB;
从进程到线程到协程,从内存资源占用和上下文开销来说,都是越来越小的,也即越来越轻量级,这也意味着在同样的服务器资源的情况下,相比进程我们可以创建更多数量的线程,而相比线程我们可以创建更多数量的协程。所以,这也是为什么随着C10K甚至C100K等高并发问题的出现,我们需要逐渐从PPC(Process Per Connection )和TPC(Thread Per Connection)方案转为IO多路复用方案,进而再转为协程的方案,才能用一台服务器支撑更高的并发。
说完通用的进程、线程和协程,我们再来看Python的对应实现,即threading、multiProcessing和asyncio。
了解Python的应该知道,因为GIL(全局解释器锁)的存在,使用threading库进行多线程编程时,同一时间只会有一个获得了 GIL 的线程在跑,其它的线程都处于等待状态等着 GIL 的释放,这就导致我们即使使用了threading,我们也不能利用多CPU或CPU多核的性能来加速计算。
为了解决这个问题,Python在2.6里引入了multiprocessing这个多进程标准库,让多进程的 python 程序编写简化到类似多线程的程度,进而解决GIL带来的并发编程不能利用多核CPU的问题。
但多进程的方案太重,还有个方案是把关键部分用 C/C++ 写成 Python 扩展,其它部分还是用 Python 来写,让 Python 的归 Python,C 的归 C。一般计算密集性的程序都会用 C 代码编写并通过扩展的方式集成到 Python 脚本里(如 NumPy 模块)。在扩展里就完全可以用 C 创建原生线程,而且不用锁 GIL,这样就能充分利用 CPU 的计算资源了,这样的方案更轻量级性能也更好,但不在这次我们这篇文章讨论的范围内,毕竟这涉及到C的编程实现了。
而Python的协程实现asyncio,则不同于多线程,Asyncio是单线程的,但其内部 event loop 的机制,可以让它并发地运行多个不同的任务,并且比多线程享有更大的自主控制权。
Asyncio 中的任务,在运行过程中不会被打断,因此不会出现 race condition 的情况。尤其是在 I/O 操作 heavy 的场景下,Asyncio 比多线程的运行效率更高。因为 Asyncio 内部任务切换的损耗,远比线程切换的损耗要小,因此 Asyncio 可以开启的任务数量,也比多线程中的线程数量多得多。
但需要注意的是,很多情况下,使用 Asyncio 需要特定第三方库的支持,比如如果我们要用asyncio实现异步并发爬虫,就不能继续使用requests库,而必须使用aiohttp库。同样,如果要用asyncio实现异步并发文件io,也不能继续沿用open(),而必须使用aiofiles.open()。
因此,如果 I/O 操作很快,并不 heavy,那么运用多线程,也能很有效地解决问题。
3. 「CPU Bound VS I/O Bound」
好了,经过上面知识的铺垫,我们就能来讲解一下**CPU Bound(计算密集型)和 I/O Bound(I/O密集型)**的区别,以及不同场景下我们应该如何使用进程、线程和协程了。
- 「CPU bound(CPU密集型)」:CPU密集型也叫计算密集型,是指I/O在很短的时间内就可以完成,但CPU需要大量的计算和处理,特点是CPU占用率相当高,比如压缩解压缩、加密解密、正则表达式搜索等;
- 「I/O bound(I/O密集型)」:I/O密集型是指系统运行过程中大部分的时间是CPU在等IO(硬盘/内存)的读写操作,CPU占用率较低,比如文件处理、网络爬虫、读写数据库等;
基于以上我们的知识点介绍,我们直接说结论,多线程、多进程和asyncio分别的使用场景如下:
if io_bound:
if io_slow:
print('Use Asyncio')
else:
print('Use multi-threading')
else if cpu_bound:
print('Use multi-processing')
换成白话就是:
- 如果是 I/O bound,并且 I/O 操作很慢,需要很多任务 / 线程协同实现,那么使用 Asyncio 更合适;
- 如果是 I/O bound,但是 I/O 操作很快,只需要有限数量的任务 / 线程,那么使用多线程就可以了;
- 如果是 CPU bound,则需要使用多进程来提高程序运行效率;
看到这里,你应该清楚面对高并发场景,如果用Python,我们应该选择什么样的方案来实现了吧。
不过,真正的面对C10K甚至C100K这的高并发问题,其实用Python不是特别合适,尤其如果你的应用真的对性能有超级严格的要求,比如 100us 就对你的应用有很大影响,那Python 可能不是你的最优选择。
如果你对响应延迟极其敏感,那么就不要选择带GC的编程语言(GC的stop the world问题),只能选择C/C++,如果对GC带来的延迟不敏感,那GO甚至Java都是不错的选择,在高并发又要求高性能的场景,还是尽量别选择Python了。因为,在相同的资源消耗水平的前提下,Go 的性能低于 C++,但高出 Java 不少,比 Python 代码大约快30-40倍。