Python多进程、多线程、协程

以下内容主要参考了廖雪峰老师的Python教程,在不懂的地方添加了自己的注释。

多进程

Unix/Linux操作系统提供了一个fork()系统调用,它非常特殊。普通的函数调用,调用一次,返回一次,但是fork()调用一次,返回两次,因为操作系统自动把当前进程(称为父进程)复制了一份(称为子进程),然后,分别在父进程和子进程内返回。

子进程永远返回0,而父进程返回子进程的ID。这样做的理由是,一个父进程可以fork出很多子进程,所以,父进程要记下每个子进程的ID,而子进程只需要调用getppid()就可以拿到父进程的ID。

Python的os模块封装了常见的系统调用,其中就包括fork,可以在Python程序中轻松创建子进程:

1
2
3
4
5
6
7
8
9
import os

print('Process (%s) start...' % os.getpid())
# Only works on Unix/Linux/Mac:
pid = os.fork()
if pid == 0:
print('I am child process (%s) and my parent is %s.' % (os.getpid(), os.getppid()))
else:
print('I (%s) just created a child process (%s).' % (os.getpid(), pid))

运行结果如下:

1
2
3
Process (876) start...
I (876) just created a child process (877).
I am child process (877) and my parent is 876.

由于Windows没有fork调用,上面的代码在Windows上无法运行。而Mac系统是基于BSD(Unix的一种)内核,所以,在Mac下运行是没有问题的,推荐大家用Mac学Python!

有了fork调用,一个进程在接到新任务时就可以复制出一个子进程来处理新任务,常见的Apache服务器就是由父进程监听端口,每当有新的http请求时,就fork出子进程来处理新的http请求。

multiprocessing

如果你打算编写多进程的服务程序,Unix/Linux无疑是正确的选择。由于Windows没有fork调用,难道在Windows上无法用Python编写多进程的程序?

由于Python是跨平台的,自然也应该提供一个跨平台的多进程支持。multiprocessing模块就是跨平台版本的多进程模块。

multiprocessing模块提供了一个Process类来代表一个进程对象,下面的例子演示了启动一个子进程并等待其结束:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from multiprocessing import Process
import os

# 子进程要执行的代码
def run_proc(name):
print('Run child process %s (%s)...' % (name, os.getpid()))

if __name__=='__main__':
print('Parent process %s.' % os.getpid())
p = Process(target=run_proc, args=('test',))
print('Child process will start.')
p.start()
p.join()
print('Child process end.')

执行结果如下:

1
2
3
4
Parent process 928.
Child process will start.
Run child process test (929)...
Process end.

创建子进程时,只需要传入一个执行函数和函数的参数,创建一个Process实例,用start()方法启动,这样创建进程比fork()还要简单。

join()方法可以等待子进程结束后再继续往下运行,通常用于进程间的同步。

Pool

如果要启动大量的子进程,可以用进程池的方式批量创建子进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from multiprocessing import Pool
import os, time, random

def long_time_task(name):
print('Run task %s (%s)...' % (name, os.getpid()))
start = time.time()
time.sleep(random.random() * 3)
end = time.time()
print('Task %s runs %0.2f seconds.' % (name, (end - start)))

if __name__=='__main__':
print('Parent process %s.' % os.getpid())
p = Pool(4)
for i in range(5):
p.apply_async(long_time_task, args=(i,))
print('Waiting for all subprocesses done...')
p.close()
p.join()
print('All subprocesses done.')

执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Parent process 505792.
Waiting for all subprocesses done...
Run task 0 (505324)...
Run task 1 (501888)...
Run task 2 (500856)...
Run task 3 (503812)...
Task 0 runs 0.92 seconds.
Run task 4 (505324)...
Task 3 runs 1.75 seconds.
Task 2 runs 2.26 seconds.
Task 1 runs 2.53 seconds.
Task 4 runs 2.79 seconds.
All subprocesses done.

代码解读:

apply_async(func[, args[, kwds[, callback]]]):它是非阻塞,apply(func[, args[, kwds]])是阻塞的。如果是后者的话,执行如果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Parent process 502288.
Run task 0 (503980)...
Task 0 runs 2.45 seconds.
Run task 1 (500276)...
Task 1 runs 0.97 seconds.
Run task 2 (490944)...
Task 2 runs 0.17 seconds.
Run task 3 (504292)...
Task 3 runs 2.23 seconds.
Run task 4 (503980)...
Task 4 runs 0.83 seconds.
Waiting for all subprocesses done...
All subprocesses done.

Pool对象调用join()方法会等待所有子进程执行完毕,调用join()之前必须先调用close(),调用close()之后就不能继续添加新的Process了。

请注意输出的结果,task 0123是立刻执行的,而task 4要等待前面某个task完成后才执行,这是因为Pool的默认大小在我的电脑上是4,因此,最多同时执行4个进程。这是Pool有意设计的限制,并不是操作系统的限制。如果改成:

1
p = Pool(5)

就可以同时跑5个进程。

由于Pool的默认大小是CPU的核数,如果你不幸拥有8核CPU,你要提交至少9个子进程才能看到上面的等待效果。

子进程

很多时候,子进程并不是自身,而是一个外部进程。我们创建了子进程后,还需要控制子进程的输入和输出。

subprocess模块可以让我们非常方便地启动一个子进程,然后控制其输入和输出。

下面的例子演示了如何在Python代码中运行命令nslookup www.python.org,这和命令行直接运行的效果是一样的:

1
2
3
4
5
import subprocess

print('$ nslookup www.python.org')
r = subprocess.call(['nslookup', 'www.python.org'])
print('Exit code:', r)

运行结果:

1
2
3
4
5
6
7
8
9
10
$ nslookup www.python.org
Server: 192.168.19.4
Address: 192.168.19.4#53

Non-authoritative answer:
www.python.org canonical name = python.map.fastly.net.
Name: python.map.fastly.net
Address: 199.27.79.223

Exit code: 0

如果子进程还需要输入,则可以通过communicate()方法输入:

1
2
3
4
5
6
7
import subprocess

print('$ nslookup')
p = subprocess.Popen(['nslookup'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, err = p.communicate(b'set q=mx\npython.org\nexit\n')
print(output.decode('utf-8'))
print('Exit code:', p.returncode)

上面的代码相当于在命令行执行命令nslookup,然后手动输入:

1
2
3
set q=mx
python.org
exit

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ nslookup
Server: 192.168.19.4
Address: 192.168.19.4#53

Non-authoritative answer:
python.org mail exchanger = 50 mail.python.org.

Authoritative answers can be found from:
mail.python.org internet address = 82.94.164.166
mail.python.org has AAAA address 2001:888:2000:d::a6


Exit code: 0

进程间通信

Process之间肯定是需要通信的,操作系统提供了很多机制来实现进程间的通信。Python的multiprocessing模块包装了底层的机制,提供了QueuePipes等多种方式来交换数据。

我们以Queue为例,在父进程中创建两个子进程,一个往Queue里写数据,一个从Queue里读数据:

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
from multiprocessing import Process, Queue
import os, time, random

# 写数据进程执行的代码:
def write(q):
print('Process to write: %s' % os.getpid())
for value in ['A', 'B', 'C']:
print('Put %s to queue...' % value)
q.put(value)
time.sleep(random.random())

# 读数据进程执行的代码:
def read(q):
print('Process to read: %s' % os.getpid())
while True:
value = q.get(True)
print('Get %s from queue.' % value)

if __name__=='__main__':
# 父进程创建Queue,并传给各个子进程:
q = Queue()
pw = Process(target=write, args=(q,))
pr = Process(target=read, args=(q,))
# 启动子进程pw,写入:
pw.start()
# 启动子进程pr,读取:
pr.start()
# 等待pw结束:
pw.join()
# pr进程里是死循环,无法等待其结束,只能强行终止:
pr.terminate()

运行结果如下:

1
2
3
4
5
6
7
8
Process to write: 50563
Put A to queue...
Process to read: 50564
Get A from queue.
Put B to queue...
Get B from queue.
Put C to queue...
Get C from queue.

在Unix/Linux下,multiprocessing模块封装了fork()调用,使我们不需要关注fork()的细节。由于Windows没有fork调用,因此,multiprocessing需要“模拟”出fork的效果,父进程所有Python对象都必须通过pickle序列化再传到子进程去,所以,如果multiprocessing在Windows下调用失败了,要先考虑是不是pickle失败了。

实战:进程池中使用不同子函数

代码如下:

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
#coding: utf-8
import multiprocessing
import os, time, random

def Lee():
print("\nRun task Lee-%s" %(os.getpid())) #os.getpid()获取当前的进程的ID
start = time.time()
time.sleep(random.random() * 10) #random.random()随机生成0-1之间的小数
end = time.time()
print('Task Lee, runs %0.2f seconds.' %(end - start))

def Marlon():
print("\nRun task Marlon-%s" %(os.getpid()))
start = time.time()
time.sleep(random.random() * 40)
end=time.time()
print('Task Marlon runs %0.2f seconds.' %(end - start))

def Allen():
print("\nRun task Allen-%s" %(os.getpid()))
start = time.time()
time.sleep(random.random() * 30)
end = time.time()
print('Task Allen runs %0.2f seconds.' %(end - start))

def Frank():
print("\nRun task Frank-%s" %(os.getpid()))
start = time.time()
time.sleep(random.random() * 20)
end = time.time()
print('Task Frank runs %0.2f seconds.' %(end - start))

if __name__=='__main__':
function_list= [Lee, Marlon, Allen, Frank]
print("parent process %s" %(os.getpid()))

pool=multiprocessing.Pool(4)
for func in function_list:
pool.apply_async(func) #Pool执行函数,apply执行函数,当有一个进程执行完毕后,会添加一个新的进程到pool中

print('Waiting for all subprocesses done...')
pool.close()
pool.join() #调用join之前,一定要先调用close() 函数,否则会出错, close()执行后不会有新的进程加入到pool,join函数等待素有子进程结束
print('All subprocesses done.')

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
parent process 24276
Waiting for all subprocesses done...

Run task Lee-16688
Run task Marlon-27824


Run task Allen-2188

Run task Frank-27380
Task Marlon runs 2.47 seconds.
Task Frank runs 5.59 seconds.
Task Lee, runs 6.12 seconds.
Task Allen runs 19.02 seconds.
All subprocesses done.

小结

在Unix/Linux下,可以使用fork()调用实现多进程。

要实现跨平台的多进程,可以使用multiprocessing模块。

进程间通信是通过QueuePipes等实现的。

要想实现分布式进程,可以考虑multiprocessing的managers子模块,把多进程分布到多台机器上。详细请参考分布式进程

关于Pool.apply, Pool.apply_async, Pool.map and Pool.map_async的区别请参考multiprocessing.Pool: When to use apply, apply_async or map?

多线程

多任务可以由多进程完成,也可以由一个进程内的多线程完成。

我们前面提到了进程是由若干线程组成的,一个进程至少有一个线程。

由于线程是操作系统直接支持的执行单元,因此,高级语言通常都内置多线程的支持,Python也不例外,并且,Python的线程是真正的Posix Thread,而不是模拟出来的线程。

Python的标准库提供了两个模块:_threadthreading_thread是低级模块,threading是高级模块,对_thread进行了封装。绝大多数情况下,我们只需要使用threading这个高级模块。

启动一个线程就是把一个函数传入并创建Thread实例,然后调用start()开始执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import time, threading

# 新线程执行的代码:
def loop():
print('thread %s is running...' % threading.current_thread().name)
n = 0
while n < 5:
n = n + 1
print('thread %s >>> %s' % (threading.current_thread().name, n))
time.sleep(1)
print('thread %s ended.' % threading.current_thread().name)

print('thread %s is running...' % threading.current_thread().name)
t = threading.Thread(target=loop, name='LoopThread')
t.start()
t.join()
print('thread %s ended.' % threading.current_thread().name)

执行结果如下:

1
2
3
4
5
6
7
8
9
thread MainThread is running...
thread LoopThread is running...
thread LoopThread >>> 1
thread LoopThread >>> 2
thread LoopThread >>> 3
thread LoopThread >>> 4
thread LoopThread >>> 5
thread LoopThread ended.
thread MainThread ended.

由于任何进程默认就会启动一个线程,我们把该线程称为主线程,主线程又可以启动新的线程,Python的threading模块有个current_thread()函数,它永远返回当前线程的实例。主线程实例的名字叫MainThread,子线程的名字在创建时指定,我们用LoopThread命名子线程。名字仅仅在打印时用来显示,完全没有其他意义,如果不起名字Python就自动给线程命名为Thread-1Thread-2……

Lock

多线程和多进程最大的不同在于,多进程中,同一个变量,各自有一份拷贝存在于每个进程中,互不影响,而多线程中,所有变量都由所有线程共享,所以,任何一个变量都可以被任何一个线程修改,因此,线程之间共享数据最大的危险在于多个线程同时改一个变量,把内容给改乱了。

来看看多个线程同时操作一个变量怎么把内容给改乱了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import time, threading

# 假定这是你的银行存款:
balance = 0

def change_it(n):
# 先存后取,结果应该为0:
global balance
balance = balance + n
balance = balance - n

def run_thread(n):
for i in range(2000000):
change_it(n)

t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)

我们定义了一个共享变量balance,初始值为0,并且启动两个线程,先存后取,理论上结果应该为0,但是,由于线程的调度是由操作系统决定的,当t1、t2交替执行时,只要循环次数足够多,balance的结果就不一定是0了。

原因是因为高级语言的一条语句在CPU执行时是若干条语句,即使一个简单的计算:

1
balance = balance + n

也分两步:

  1. 计算balance + n,存入临时变量中;
  2. 将临时变量的值赋给balance

也就是可以看成:

1
2
x = balance + n
balance = x

由于x是局部变量,两个线程各自都有自己的x,当代码正常执行时:

1
2
3
4
5
6
7
8
9
10
11
12
13
初始值 balance = 0

t1: x1 = balance + 5 # x1 = 0 + 5 = 5
t1: balance = x1 # balance = 5
t1: x1 = balance - 5 # x1 = 5 - 5 = 0
t1: balance = x1 # balance = 0

t2: x2 = balance + 8 # x2 = 0 + 8 = 8
t2: balance = x2 # balance = 8
t2: x2 = balance - 8 # x2 = 8 - 8 = 0
t2: balance = x2 # balance = 0

结果 balance = 0

但是t1和t2是交替运行的,如果操作系统以下面的顺序执行t1、t2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
初始值 balance = 0

t1: x1 = balance + 5 # x1 = 0 + 5 = 5

t2: x2 = balance + 8 # x2 = 0 + 8 = 8
t2: balance = x2 # balance = 8

t1: balance = x1 # balance = 5
t1: x1 = balance - 5 # x1 = 5 - 5 = 0
t1: balance = x1 # balance = 0

t2: x2 = balance - 8 # x2 = 0 - 8 = -8
t2: balance = x2 # balance = -8

结果 balance = -8

究其原因,是因为修改balance需要多条语句,而执行这几条语句时,线程可能中断,从而导致多个线程把同一个对象的内容改乱了。

两个线程同时一存一取,就可能导致余额不对,你肯定不希望你的银行存款莫名其妙地变成了负数,所以,我们必须确保一个线程在修改balance的时候,别的线程一定不能改。

如果我们要确保balance计算正确,就要给change_it()上一把锁,当某个线程开始执行change_it()时,我们说,该线程因为获得了锁,因此其他线程不能同时执行change_it(),只能等待,直到锁被释放后,获得该锁以后才能改。由于锁只有一个,无论多少线程,同一时刻最多只有一个线程持有该锁,所以,不会造成修改的冲突。创建一个锁就是通过threading.Lock()来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
balance = 0
lock = threading.Lock()

def run_thread(n):
for i in range(100000):
# 先要获取锁:
lock.acquire()
try:
# 放心地改吧:
change_it(n)
finally:
# 改完了一定要释放锁:
lock.release()

当多个线程同时执行lock.acquire()时,只有一个线程能成功地获取锁,然后继续执行代码,其他线程就继续等待直到获得锁为止。

获得锁的线程用完后一定要释放锁,否则那些苦苦等待锁的线程将永远等待下去,成为死线程。所以我们用try...finally来确保锁一定会被释放。

锁的好处就是确保了某段关键代码只能由一个线程从头到尾完整地执行,坏处当然也很多,首先是阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了。其次,由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁,导致多个线程全部挂起,既不能执行,也无法结束,只能靠操作系统强制终止。

多核CPU

如果你不幸拥有一个多核CPU,你肯定在想,多核应该可以同时执行多个线程。

如果写一个死循环的话,会出现什么情况呢?打开Mac OS X的Activity Monitor,或者Windows的Task Manager,都可以监控某个进程的CPU使用率。我们可以监控到一个死循环线程会100%占用一个CPU。如果有两个死循环线程,在多核CPU中,可以监控到会占用200%的CPU,也就是占用两个CPU核心。要想把N核CPU的核心全部跑满,就必须启动N个死循环线程。

试试用Python写个死循环:

1
2
3
4
5
6
7
8
9
10
import threading, multiprocessing

def loop():
x = 0
while True:
x = x ^ 1

for i in range(multiprocessing.cpu_count()):
t = threading.Thread(target=loop)
t.start()

启动与CPU核心数量相同的N个线程,在4核CPU上可以监控到CPU占用率仅有102%,也就是仅使用了一核。

但是用C、C++或Java来改写相同的死循环,直接可以把全部核心跑满,4核就跑到400%,8核就跑到800%,为什么Python不行呢?

因为Python的线程虽然是真正的线程,但解释器执行代码时,有一个GIL锁:Global Interpreter Lock,任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。

GIL是Python解释器设计的历史遗留问题,通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。

所以,在Python中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过多线程利用多核,那只能通过C扩展来实现,不过这样就失去了Python简单易用的特点。

不过,也不用过于担心,Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。

小结

多线程编程,模型复杂,容易发生冲突,必须用锁加以隔离,同时,又要小心死锁的发生。

Python解释器由于设计时有GIL全局锁,导致了多线程无法利用多核。多线程的并发在Python中就是一个美丽的梦

ThreadLocal

在多线程环境下,每个线程都有自己的数据。一个线程使用自己的局部变量比使用全局变量好,因为局部变量只有线程自己能看见,不会影响其他线程,而全局变量的修改必须加锁。

但是局部变量也有问题,就是在函数调用的时候,传递起来很麻烦。如下代码所示,假设这份代码需要多线程调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
def process_student(name):
std = Student(name)
# std是局部变量,但是每个函数都要用它,因此必须传进去:
do_task_1(std)
do_task_2(std)

def do_task_1(std):
do_subtask_1(std)
do_subtask_2(std)

def do_task_2(std):
do_subtask_2(std)
do_subtask_2(std)

每个函数一层一层调用都这么传参数那还得了?用全局变量?也不行,因为每个线程处理不同的Student对象,不能共享。

如果用一个全局dict存放所有的Student对象,然后以thread自身作为key获得线程对应的Student对象如何?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
global_dict = {}

def std_thread(name):
std = Student(name)
# 把std放到全局变量global_dict中:
global_dict[threading.current_thread()] = std
do_task_1()
do_task_2()

def do_task_1():
# 不传入std,而是根据当前线程查找:
std = global_dict[threading.current_thread()]
...

def do_task_2():
# 任何函数都可以查找出当前线程的std变量:
std = global_dict[threading.current_thread()]
...

这种方式理论上是可行的,它最大的优点是消除了std对象在每层函数中的传递问题,但是,每个函数获取std的代码有点丑。

有没有更简单的方式?

ThreadLocal应运而生,不用查找dictThreadLocal帮你自动做这件事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import threading

# 创建全局ThreadLocal对象:
local_school = threading.local()

def process_student():
# 获取当前线程关联的student:
std = local_school.student
print('Hello, %s (in %s)' % (std, threading.current_thread().name))

def process_thread(name):
# 绑定ThreadLocal的student:
local_school.student = name
process_student()

t1 = threading.Thread(target= process_thread, args=('Alice',), name='Thread-A')
t2 = threading.Thread(target= process_thread, args=('Bob',), name='Thread-B')
t1.start()
t2.start()
t1.join()
t2.join()

执行结果:

1
2
Hello, Alice (in Thread-A)
Hello, Bob (in Thread-B)

全局变量local_school就是一个ThreadLocal对象,每个Thread对它都可以读写student属性,但互不影响。你可以把local_school看成全局变量,但每个属性如local_school.student都是线程的局部变量,可以任意读写而互不干扰,也不用管理锁的问题,ThreadLocal内部会处理。

可以理解为全局变量local_school是一个dict,不但可以用local_school.student,还可以绑定其他变量,如local_school.teacher等等。

ThreadLocal最常用的地方就是为每个线程绑定一个数据库连接,HTTP请求,用户身份信息等,这样一个线程的所有调用到的处理函数都可以非常方便地访问这些资源。

小结

一个ThreadLocal变量虽然是全局变量,但每个线程都只能读写自己线程的独立副本,互不干扰。ThreadLocal解决了参数在一个线程中各个函数之间互相传递的问题。

协程

协程,又称微线程,纤程。英文名 Coroutine。协程的概念很早就提出来了,但直到最近几年才在某些语言(如 Lua)中得到广泛应用。

子程序,或者称为函数,在所有语言中都是层级调用,比如 A 调用 B,B 在执行过程中又调用了 C,C 执行完毕返回,B 执行完毕返回,最后是 A 执行完毕。所以子程序调用是通过栈实现的,一个线程就是执行一个子程序。

子程序调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。

注意,在一个子程序中中断,去执行其他子程序,不是函数调用,有点类似 CPU 的中断。比如子程序 A、B:

1
2
3
4
5
6
7
8
9
def A():
print('1')
print('2')
print('3')

def B():
print('x')
print('y')
print('z')

假设由协程执行,在执行 A 的过程中,可以随时中断,去执行 B,B 也可能在执行过程中中断再去执行 A,结果可能是:

1
2
3
4
5
6
1
2
x
y
3
z

但是在 A 中是没有调用 B 的,所以协程的调用比函数调用理解起来要难一些。

看起来 A、B 的执行有点像多线程,但协程的特点在于是一个线程执行,那和多线程比,协程有何优势?

  • 最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。

  • 第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

因为协程是一个线程执行,那怎么利用多核 CPU 呢?最简单的方法是多进程 + 协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。

Python 对协程的支持是通过 generator 实现的。在 generator 中,我们不但可以通过for循环来迭代,还可以不断调用next()函数获取由yield语句返回的下一个值。但是 Python 的yield不但可以返回一个值,它还可以接收调用者发出的参数。

来看例子:

传统的生产者 - 消费者模型是一个线程写消息,一个线程取消息,通过锁机制控制队列和等待,但一不小心就可能死锁。如果改用协程,生产者生产消息后,直接通过yield跳转到消费者开始执行,待消费者执行完毕后,切换回生产者继续生产,效率极高:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def consumer():
r = ''
while True:
n = yield r
if not n:
return
print('[CONSUMER] Consuming %s...' % n)
r = '200 OK'

def produce(c):
c.send(None)
n = 0
while n < 5:
n = n + 1
print('[PRODUCER] Producing %s...' % n)
r = c.send(n)
print('[PRODUCER] Consumer return: %s' % r)
c.close()

c = consumer()
produce(c)

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[PRODUCER] Producing 1...
[CONSUMER] Consuming 1...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 2...
[CONSUMER] Consuming 2...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 3...
[CONSUMER] Consuming 3...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 4...
[CONSUMER] Consuming 4...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 5...
[CONSUMER] Consuming 5...
[PRODUCER] Consumer return: 200 OK

注意到consumer函数是一个generator,把一个consumer传入produce后:

  1. 首先调用c.send(None)启动生成器;

  2. 然后,一旦生产了东西,通过c.send(n)切换到consumer执行;

  3. consumer通过yield拿到消息,处理,又通过yield把结果传回;

  4. produce拿到consumer处理的结果,继续生产下一条消息;

  5. produce决定不生产了,通过c.close()关闭consumer,整个过程结束。

整个流程无锁,由一个线程执行,produceconsumer协作完成任务,所以称为 “协程”,而非线程的抢占式多任务。

看完之后还是一脸懵逼,要看懂上面的例子,关键在于要理解下面几点:

1、例子中的c.send(None),其功能类似于next(c),比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> def num():
yield 1
yield 2

>>> c = num()
>>> c.send(None)
1
>>> c.send(None)
2
>>> c.send(None)
Traceback (most recent call last):
File "<pyshell#9>", line 1, in <module>
c.send(None)
StopIteration
>>>

2、n = yield r,这里是一条语句,但要理解两个知识点,赋值语句先计算= 右边,由于右边是 yield 语句,所以yield语句执行完以后,进入暂停,而赋值语句在下一次启动生成器的时候首先被执行;

3、send 在接受None参数的情况下,等同于next(generator)的功能,但send同时也可接收其他参数,比如例子中的c.send(n),要理解这种用法,先看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
>>> def num():
a = yield 1
while True:
a = yield a

>>> c = num()
>>> c.send(None)
1
>>> c.send(5)
5
>>> c.send(100)
100

在上面的例子中,首先使用 c.send(None),返回生成器的第一个值,a = yield 1 ,也就是1(但此时,并未执行赋值语句),

接着我们使用了c.send(5),再次启动生成器,并同时传入了一个参数5,再次启动生成的时候,从上次yield语句断掉的地方开始执行,即 a 的赋值语句,由于我们传入了一个参数5,所以a被赋值为5,接着程序进入whlie循环,当程序执行到 a = yield a,同理,先返回生成器的值 5,下次启动生成器的时候,再执行赋值语句,以此类推…

所以c.send(n)的用法就是老师上文中所说的 ,” Python的yield不但可以返回一个值,它还可以接收调用者发出的参数。”

但注意,在一个生成器函数未启动之前,是不能传递值进去。也就是说在使用c.send(n)之前,必须先使用c.send(None)或者next(c)来返回生成器的第一个值。

最后我们来看上文中的例子,梳理下执行过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def consumer():
r = ''
while True:
n = yield r
if not n:
return
print('[CONSUMER] Consuming %s...' % n)
r = '200 OK'

def produce(c):
c.send(None)
n = 0
while n < 5:
n = n + 1
print('[PRODUCER] Producing %s...' % n)
r = c.send(n)
print('[PRODUCER] Consumer return: %s' % r)
c.close()

c = consumer()
produce(c)

第一步:执行 c.send(None),启动生成器返回第一个值,n = yield r,此时 r 为空,n 还未赋值,然后生成器暂停,等待下一次启动。

第二步:生成器返回空值后进入暂停,produce(c) 接着往下运行,进入While循环,此时 n1,所以打印:

1
[PRODUCER] Producing 1...

第三步:produce(c) 往下运行到 r = c.send(1),再次启动生成器,并传入了参数1,而生成器从上次n的赋值语句开始执行,n 被赋值为1n存在,if 语句不执行,然后打印:

1
[CONSUMER] Consuming 1...

接着r被赋值为'200 OK',然后又进入循环,执行n = yield r,返回生成器的第二个值,'200 OK',然后生成器进入暂停,等待下一次启动。

第四步:生成器返回'200 OK'进入暂停后,produce(c)往下运行,进入r的赋值语句,r被赋值为'200 OK',接着往下运行,打印:

1
[PRODUCER] Consumer return: 200 OK

以此类推…

n5跳出循环后,使用c.close() 结束生成器的生命周期,然后程序运行结束。

最后套用 Donald Knuth 的一句话总结协程的特点:“子程序就是协程的一种特例。”

asyncio实现异步IO

asyncio是Python 3.4版本引入的标准库,直接内置了对异步IO的支持。具体来说,协程都是通过使用yield fromasyncio模块中的@asyncio.coroutine来实现的。asyncio专门被用来实现异步IO操作。

为什么需要协程

下面我们先来看一个用普通同步代码实现多个 IO 任务的案例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 普通同步代码实现多个IO任务
import time
def taskIO_1():
print('开始运行IO任务1...')
time.sleep(2) # 假设该任务耗时2s
print('IO任务1已完成,耗时2s')

def taskIO_2():
print('开始运行IO任务2...')
time.sleep(3) # 假设该任务耗时3s
print('IO任务2已完成,耗时3s')

start = time.time()
taskIO_1()
taskIO_2()
print('所有IO任务总耗时%.5f秒' % float(time.time()-start))

执行结果:

1
2
3
4
5
开始运行IO任务1...
IO任务1已完成,耗时2s
开始运行IO任务2...
IO任务2已完成,耗时3s
所有IO任务总耗时5.00604秒

上面,我们顺序实现了两个同步 IO 任务taskIO_1()taskIO_2(),则最后总耗时就是 5 秒。我们都知道,在计算机中 CPU 的运算速率要远远大于 IO 速率,而当 CPU 运算完毕后,如果再要闲置很长时间去等待 IO 任务完成才能进行下一个任务的计算,这样的任务执行效率很低。

所以我们需要有一种异步的方式来处理类似上述任务,会极大增加效率 (当然就是协程啦~)。而我们最初很容易想到的,是能否在上述 IO 任务执行前中断当前 IO 任务 (对应于上述代码time.sleep(2)),进行下一个任务,当该 IO 任务完成后再唤醒该任务。

而在 Python 中生成器中的关键字yield可以实现中断功能。所以起初,协程是基于生成器的变形进行实现的,之后虽然编码形式有变化,但基本原理还是一样的。

yield与yield from

yield在生成器中有中断的功能,可以传出值,也可以从函数外部接收值,而yield from的实现就是简化了yield操作。看下面案例:

1
2
3
4
5
6
7
8
9
10
def generator_1(titles):
yield titles
def generator_2(titles):
yield from titles

titles = ['Python','Java','C++']
for title in generator_1(titles):
print('生成器1:',title)
for title in generator_2(titles):
print('生成器2:',title)

执行结果如下:

1
2
3
4
生成器1: ['Python', 'Java', 'C++']
生成器2: Python
生成器2: Java
生成器2: C++

在这个例子中yield titles返回了titles完整列表,而yield from titles实际等价于:

1
2
for title in titles: # 等价于yield from titles
yield title

而yield from功能还不止于此,它还有一个主要的功能是省去了很多异常的处理,不再需要我们手动编写,其内部已经实现大部分异常处理。

举个例子,下面通过生成器来实现一个整数加和的程序,通过send()函数向生成器中传入要加和的数字,然后最后以返回None结束,total保存最后加和的总数。

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
def generator_1():
total = 0
while True:
x = yield
print('加',x)
if not x:
break
total += x
return total

def generator_2(): # 委托生成器
while True:
total = yield from generator_1() # 子生成器
print('加和总数是:',total)

def main(): # 调用方
g1 = generator_1()
g1.send(None)
g1.send(2)
g1.send(3)
g1.send(None)
# g2 = generator_2()
# g2.send(None)
# g2.send(2)
# g2.send(3)
# g2.send(None)

main()

执行结果如下。可见对于生成器g1,在最后传入None后,程序退出,报StopIteration异常并返回了最后total值是5。

1
2
3
4
5
6
7
8
2
3
None
------------------------------------------
StopIteration
<ipython-input-37-cf298490352b> in main()
---> 19 g1.send(None)
StopIteration: 5

如果把g1.send()那5行注释掉,解注下面的g2.send()代码,则结果如下。可见yield from封装了处理常见异常的代码。对于g2即便传入None也不报异常,其中total = yield from generator_1()返回给total的值是generator_1()最终的return total

1
2
3
4
加 2
加 3
加 None
加和总数是: 5

借用上述例子,这里有几个概念需要理一下:

  • 子生成器:yield from后的generator_1()生成器函数是子生成器
  • 委托生成器:generator_2()是程序中的委托生成器,它负责委托子生成器完成具体任务。
  • 调用方:main()是程序中的调用方,负责调用委托生成器。

yield from在其中还有一个关键的作用是:建立调用方和子生成器的通道

  • 在上述代码中main()每一次在调用send(value)时,value不是传递给了委托生成器generator_2(),而是借助yield from传递给了子生成器generator_1()中的yield
  • 同理,子生成器中的数据也是通过yield直接发送到调用方main()中。

@asyncio.coroutine实现协程

yield from通常用在什么地方呢?在协程中,只要是和IO任务类似的、耗费时间的任务都需要使用yield from来进行中断,达到异步功能!

我们在上面那个同步IO任务的代码中修改成协程的用法如下:

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
# 使用同步方式编写异步功能
import time
import asyncio

@asyncio.coroutine # 标志协程的装饰器
def taskIO_1():
print('开始运行IO任务1...')
yield from asyncio.sleep(2) # 假设该任务耗时2s
print('IO任务1已完成,耗时2s')
return taskIO_1.__name__

@asyncio.coroutine # 标志协程的装饰器
def taskIO_2():
print('开始运行IO任务2...')
yield from asyncio.sleep(3) # 假设该任务耗时3s
print('IO任务2已完成,耗时3s')
return taskIO_2.__name__

@asyncio.coroutine # 标志协程的装饰器
def main(): # 调用方
tasks = [taskIO_1(), taskIO_2()] # 把所有任务添加到task中
done,pending = yield from asyncio.wait(tasks) # 子生成器
for r in done: # done和pending都是一个任务,所以返回结果需要逐个调用result()
print('协程无序返回值:'+r.result())

if __name__ == '__main__':
start = time.time()
loop = asyncio.get_event_loop() # 创建一个事件循环对象loop
try:
loop.run_until_complete(main()) # 完成事件循环,直到最后一个任务结束
finally:
loop.close() # 结束事件循环
print('所有IO任务总耗时%.5f秒' % float(time.time()-start))

执行结果如下:

1
2
3
4
5
6
7
开始运行IO任务1...
开始运行IO任务2...
IO任务1已完成,耗时2s
IO任务2已完成,耗时3s
协程无序返回值:taskIO_2
协程无序返回值:taskIO_1
所有IO任务总耗时3.00209秒

【使用方法】: @asyncio.coroutine装饰器是协程函数的标志,我们需要在每一个任务函数前加这个装饰器,并在函数中使用yield from。在同步 IO 任务的代码中使用的time.sleep(2)来假设任务执行了 2 秒。但在协程中yield from后面必须是子生成器函数,而time.sleep()并不是生成器,所以这里需要使用内置模块提供的生成器函数asyncio.sleep()

【功能】:通过使用协程,极大增加了多任务执行效率,最后消耗的时间是任务队列中耗时最多的时间。上述例子中的总耗时 3 秒就是taskIO_2()的耗时时间。

【执行过程】:

  1. 上面代码先通过get_event_loop()获取了一个标准事件循环 loop(因为是一个,所以协程是单线程)
  2. 然后,我们通过run_until_complete(main())来运行协程 (此处把调用方协程 main() 作为参数,调用方负责调用其他委托生成器),run_until_complete的特点就像该函数的名字,直到循环事件的所有事件都处理完才能完整结束。
  3. 进入调用方协程,我们把多个任务 [taskIO_1()taskIO_2()] 放到一个task列表中,可理解为打包任务。
  4. 现在,我们使用asyncio.wait(tasks)来获取一个 awaitable objects 即可等待对象的集合 (此处的 aws 是协程的列表),并发运行传入的 aws,同时通过yield from返回一个包含(done, pending)的元组,done 表示已完成的任务列表,pending 表示未完成的任务列表;如果使用asyncio.as_completed(tasks)则会按完成顺序生成协程的迭代器 (常用于 for 循环中),因此当你用它迭代时,会尽快得到每个可用的结果。【此外,当轮询到某个事件时 (如 taskIO_1()),直到遇到该任务中的yield from中断,开始处理下一个事件 (如 taskIO_2())),当yield from后面的子生成器完成任务时,该事件才再次被唤醒
  5. 因为done里面有我们需要的返回结果,但它目前还是个任务列表,所以要取出返回的结果值,我们遍历它并逐个调用result()取出结果即可。(注:对于asyncio.wait()asyncio.as_completed()返回的结果均是先完成的任务结果排在前面,所以此时打印出的结果不一定和原始顺序相同,但使用gather()的话可以得到原始顺序的结果集,两者更详细的案例说明见此)
  6. 最后我们通过loop.close()关闭事件循环。

综上所述:异步IO的完整实现是靠①事件循环+②协程。有关更底层的原理可以参考How does asyncio work?

实战

我们用asyncio的异步网络连接来获取sina、sohu和163的网站首页:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import asyncio

@asyncio.coroutine
def wget(host):
print('wget %s...' % host)
connect = asyncio.open_connection(host, 80)
reader, writer = yield from connect
header = 'GET / HTTP/1.0\r\nHost: %s\r\n\r\n' % host
writer.write(header.encode('utf-8'))
yield from writer.drain()
while True:
line = yield from reader.readline()
if line == b'\r\n':
break
print('%s header > %s' % (host, line.decode('utf-8').rstrip()))
# Ignore the body, close the socket
writer.close()

loop = asyncio.get_event_loop()
tasks = [wget(host) for host in ['www.sina.com.cn', 'www.sohu.com', 'www.163.com']]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()

执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
wget www.sohu.com...
wget www.sina.com.cn...
wget www.163.com...
(等待一段时间)
(打印出sohu的header)
www.sohu.com header > HTTP/1.1 200 OK
www.sohu.com header > Content-Type: text/html
...
(打印出sina的header)
www.sina.com.cn header > HTTP/1.1 200 OK
www.sina.com.cn header > Date: Wed, 20 May 2015 04:56:33 GMT
...
(打印出163的header)
www.163.com header > HTTP/1.0 302 Moved Temporarily
www.163.com header > Server: Cdn Cache Server V2.0
...

可见3个连接由一个线程通过coroutine并发完成。

小结

asyncio提供了完善的异步IO支持;

异步操作需要在coroutine中通过yield from完成;

多个coroutine可以封装成一组Task然后并发执行。

asyncio提供的@asyncio.coroutine可以把一个generator标记为coroutine类型,然后在coroutine内部用yield from调用另一个coroutine实现异步操作。

async/await实现异步IO

为了简化并更好地标识异步IO,从Python 3.5开始引入了新的语法asyncawait,可以让coroutine的代码更简洁易读。

请注意,asyncawait是针对coroutine的新语法,要使用新的语法,只需要做两步简单的替换:

  1. @asyncio.coroutine替换为async
  2. yield from替换为await

让我们对比一下上一节的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
import threading
import asyncio

@asyncio.coroutine
def hello():
print('Hello world! (%s)' % threading.currentThread())
yield from asyncio.sleep(1)
print('Hello again! (%s)' % threading.currentThread())

loop = asyncio.get_event_loop()
tasks = [hello(), hello()]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()

用新语法重新编写如下:

1
2
3
4
5
6
7
8
9
10
11
12
import threading
import asyncio

async def hello():
print("Hello world!")
r = await asyncio.sleep(1)
print("Hello again!")

loop = asyncio.get_event_loop()
tasks = [hello(), hello()]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()

剩下的代码保持不变。

小结

Python从3.5版本开始为asyncio提供了asyncawait的新语法;

注意新语法只能用在Python 3.5以及后续版本,如果使用3.4版本,则仍需使用上一节的方案。

如何选择

img

首先介绍一个什么是CPU密集型计算、IO密集型计算?

  • CPU密集型(CPU-bound):CPU密集型也叫做计算密集型,是指IO在很短的时间内就可以完成,CPU需要大量的计算和处理,特点是CPU占用率高。例如压缩解压缩、加密解密、正则表达式搜索
  • IO密集型(IO-bound):IO密集型是指系统运作大部分时间是CPU在等待I/O(硬盘/内存)的读/写操作,CPU占用率较低。例如文件处理程序,网络爬虫程序,读写数据库程序

其次,对比一下多进程、多线程、多协程。

avatar

那么该如何选择呢?

avatar

参考

协程
python并发编程 多进程 多线程 多协程
Python异步IO之协程(一):从yield from到async的使用

------ 本文结束------
坚持原创技术分享,您的支持将鼓励我继续创作!

欢迎关注我的其它发布渠道