奇怪的python并发

title

文章背景

上回说到最近写了一个并发的小脚本,为了更快地把程序跑完(方便我早点下班),于是我看了一下python的并发,结果让我大吃一惊。

CPU密集型和IO密集型任务

CPU密集型(CPU-bound)

CPU密集型也叫计算密集型,指的是系统的硬盘、内存性能相对CPU要好很多,此时,系统运作大部分的状况是CPU Loading 100%,CPU要读/写I/O(硬盘/内存),I/O在很短的时间就可以完成,而CPU还有许多运算要处理,CPU Loading很高。

在多重程序系统中,大部份时间用来做计算、逻辑判断等CPU动作的程序称之CPU bound。例如一个计算圆周率至小数点一千位以下的程序,在执行的过程当中绝大部份时间用在三角函数和开根号的计算,便是属于CPU bound的程序。

CPU bound的程序一般而言CPU占用率相当高。这可能是因为任务本身不太需要访问I/O设备,也可能是因为程序是多线程实现因此屏蔽掉了等待I/O的时间。

IO密集型(I/O bound)

IO密集型指的是系统的CPU性能相对硬盘、内存要好很多,此时,系统运作,大部分的状况是CPU在等I/O (硬盘/内存) 的读/写操作,此时CPU Loading并不高。

I/O bound的程序一般在达到性能极限时,CPU占用率仍然较低。这可能是因为任务本身需要大量I/O操作,而pipeline做得不是很好,没有充分利用处理器能力。

CPU密集型 vs IO密集型

我们可以把任务分为计算密集型和IO密集型。

计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。

计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要。Python这样的脚本语言运行效率很低,完全不适合计算密集型任务。对于计算密集型任务,最好用C语言编写。

第二种任务的类型是IO密集型,涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。

IO密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少,因此,用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率。对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C语言最差。

总之,计算密集型程序适合C语言多线程,I/O密集型适合脚本语言开发的多线程。计算密集型适合多进程,I/O密集型适合多线程。

python中的并发

在学校里学习编程的时候,老师都会讲开多线程来实现并发。That’s right! 但是你知道python中的多线程真的是并发吗?

GIL

在 Cpython 解释器(Python语言的主流解释器,也是官方解释器)中,有一把全局解释锁GIL(Global Interpreter Lock),某个线程须先拿到GIL才允许进入CPU执行。

在解释器解释执行 Python 代码时,先要得到这把锁,意味着,任何时候只可能有一个线程在执行代码,其它线程要想获得 CPU 执行代码指令,就必须先获得这把锁,如果锁被其它线程占用了,那么该线程就只能等待,直到占有该锁的线程释放锁才有执行代码指令的可能。

来看一个实验数据:

将数字 “1亿” 递减,减到 0 程序就终止,这个任务如果我们使用单线程来执行,完成时间会是多少?

单线程,4核 CPU 计算机中,单线程所花的时间是 6.5 秒。

多线程创建两个子线程 t1、t2,每个线程各执行 5 千万次减操作,两个线程以合作的方式执行是 6.8 秒,反而变慢了。

同一时刻,只有一个线程在运行,其它线程只能等待,即使是多核CPU,也没办法让多个线程「并行」地同时执行代码,只能是交替执行,因为多线程涉及到上线文切换、锁机制处理(获取锁,释放锁等),所以,多线程执行不快反慢。

既然任何时刻只能有一个线程在执行,那什么适合线程才会释放GIL呢?

一个线程遇到 I/O 任务时,将释放GIL。计算密集型(CPU-bound)线程执行 100 次解释器的计步(ticks)时(计步可粗略看作 Python 虚拟机的指令),也会释放 GIL。

CPYTHON为啥要这么设计呢?

多线程有个问题,怎么解决共享数据的同步、一致性问题,因为,对于多个线程访问共享数据时,可能有两个线程同时修改一个数据情况,如果没有合适的机制保证数据的一致性,那么程序最终导致异常,所以,Python之父就搞了个全局的线程锁,不管你数据有没有同步问题,反正一刀切,上个全局锁,保证数据安全。这也就是多线程鸡肋的原因,因为它没有细粒度的控制数据的安全,而是用一种简单粗暴的方式来解决。

毕竟在上世纪,设备配置还比较低,单核cpu还是主流,在这种场景下,多线程的使用场景也很少,而且单线程也不会有线程切换带来的消耗,效率比多线程还高(不适用多核cpu)

所以最后选择用GIL来保证数据的一致性,毕竟这是一个成本很低的实现方式。

python的并发场景

上面说到IO密集型适用于多线程,cpu密集型适用于多进程。

所以如果你的python程序是cpu密集型的话,就别用多线程了:1.本来配置就是要提高cpu的性能,就需要使用多进程,充分释放多核心的能力。2.加上python对多线程的限制,这简直雪上加霜。

那如果你的python程序是io密集型的话,这时的多线程还是能有效提高效率的,毕竟线程释放GIL的时机就是等待io的时候。那单核的多线程比多核的多线程性能要好,原因是单核情况下,线程每次释放GIL,新的线程都能马上获取到GIL,无缝连接;但在多核情况下,其他cpu上的线程都会进行竞争,但GIL可能会马上又被原来的cpu拿到,导致其他几个CPU上被唤醒后的线程会醒着等待到切换时间后又进入待调度状态,这样会造成线程颠簸(thrashing),导致效率更低(还不太懂)

如果想提高计算效率,还可以考虑用pypy。

python的并发实现

一、多进程能够更好的利用多核CPU。

但是多进程也有其自己的限制:相比线程更加笨重、切换耗时更长,并且在python的多进程下,进程数量不推荐超过CPU核心数(一个进程只有一个GIL,所以一个进程只能跑满一个CPU),因为一个进程占用一个CPU时能充分利用机器的性能,但是进程多了就会出现频繁的进程切换,反而得不偿失。

所以多核的情况下,考虑线程数与 CPU核心数相同的多线程,充分利用CPU的多核能力。

二、什么时候使用协程

特殊情况(特指IO密集型任务)下,多线程是比多进程好用的。

举个例子:给你200W条url,需要你把每个url对应的页面抓取保存起来,这种时候,单单使用多进程,效果肯定是很差的。为什么呢?

例如每次请求的等待时间是2秒,那么如下(忽略cpu计算时间):

1、单进程+单线程:需要2秒*200W=400W秒=1111.11个小时=46.3天,这个速度明显是不能接受的

2、单进程+多线程:例如我们在这个进程中开了10个多线程,比1中能够提升10倍速度,也就是大约4.63天能够完成200W条抓取,请注意,这里的实际执行是:线程1遇见了阻塞,CPU切换到线程2去执行,遇见阻塞又切换到线程3等等,10个线程都阻塞后,这个进程就阻塞了,而直到某个线程阻塞完成后,这个进程才能继续执行,所以速度上提升大约能到10倍(这里忽略了线程切换带来的开销,实际上的提升应该是不能达到10倍的),但是需要考虑的是线程的切换也是有开销的,所以不能无限的启动多线程(开200W个线程肯定是不靠谱的)

3、多进程+多线程:这里就厉害了,一般来说也有很多人用这个方法,多进程下,每个进程都能占一个cpu,而多线程从一定程度上绕过了阻塞的等待,所以比单进程下的多线程又更好使了,例如我们开10个进程,每个进程里开20W个线程,执行的速度理论上是比单进程开200W个线程快10倍以上的(为什么是10倍以上而不是10倍,主要是cpu切换200W个线程的消耗肯定比切换20W个线程大得多,考虑到这部分开销,所以是10倍以上)。

4、协程

协程

协程是一种用户级的轻量级线程。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此:

协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。

在并发编程中,协程与线程类似,每个协程表示一个执行单元,有自己的本地数据,与其它协程共享全局数据和其它资源。

目前主流语言基本上都选择了多线程作为并发设施,与线程相关的概念是抢占式多任务(Preemptive multitasking),而与协程相关的是协作式多任务。

不管是进程还是线程,每次阻塞、切换都需要陷入系统调用(system call),先让CPU跑操作系统的调度程序,然后再由调度程序决定该跑哪一个进程(线程)。
而且由于抢占式调度执行顺序无法确定的特点,使用线程时需要非常小心地处理同步问题,而协程完全不存在这个问题(事件驱动和异步程序也有同样的优点)。

因为协程是用户自己来编写调度逻辑的,对CPU来说,协程其实是单线程,所以CPU不用去考虑怎么调度、切换上下文,这就省去了CPU的切换开销,所以协程在一定程度上又好于多线程。

python里面怎么使用协程?使用gevent,使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#-*- coding=utf-8 -*-

import requests

from multiprocessing import Process

import gevent

from gevent import monkey; monkey.patch_all()



import sys

reload(sys)

sys.setdefaultencoding('utf8')

def fetch(url):

try:

s = requests.Session()

r = s.get(url,timeout=1)#在这里抓取页面

except Exception,e:

print e

return ''



def process_start(url_list):

tasks = []

for url in url_list:

tasks.append(gevent.spawn(fetch,url))

gevent.joinall(tasks)#使用协程来执行



def task_start(filepath,flag = 100000):#每10W条url启动一个进程

with open(filepath,'r') as reader:#从给定的文件中读取url

url = reader.readline().strip()

url_list = []#这个list用于存放协程任务

i = 0 #计数器,记录添加了多少个url到协程队列

while url!='':

i += 1

url_list.append(url)#每次读取出url,将url添加到队列

if i == flag:#一定数量的url就启动一个进程并执行

p = Process(target=process_start,args=(url_list,))

p.start()

url_list = [] #重置url队列

i = 0 #重置计数器

url = reader.readline().strip()

if url_list not []:#若退出循环后任务队列里还有url剩余

p = Process(target=process_start,args=(url_list,))#把剩余的url全都放到最后这个进程来执行

p.start()



if __name__ == '__main__':

task_start('./testData.txt')#读取指定文件