以下内容主要参考了廖雪峰老师的Python教程,在不懂的地方添加了自己的注释。
多进程
Unix/Linux操作系统提供了一个fork()
系统调用,它非常特殊。普通的函数调用,调用一次,返回一次,但是fork()
调用一次,返回两次,因为操作系统自动把当前进程(称为父进程)复制了一份(称为子进程),然后,分别在父进程和子进程内返回。
子进程永远返回0
,而父进程返回子进程的ID。这样做的理由是,一个父进程可以fork出很多子进程,所以,父进程要记下每个子进程的ID,而子进程只需要调用getppid()
就可以拿到父进程的ID。
Python的os
模块封装了常见的系统调用,其中就包括fork
,可以在Python程序中轻松创建子进程:
1 | import os |
运行结果如下:
1 | Process (876) start... |
由于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 | from multiprocessing import Process |
执行结果如下:
1 | Parent process 928. |
创建子进程时,只需要传入一个执行函数和函数的参数,创建一个Process
实例,用start()
方法启动,这样创建进程比fork()
还要简单。
join()
方法可以等待子进程结束后再继续往下运行,通常用于进程间的同步。
Pool
如果要启动大量的子进程,可以用进程池的方式批量创建子进程:
1 | from multiprocessing import Pool |
执行结果如下:
1 | Parent process 505792. |
代码解读:
apply_async(func[, args[, kwds[, callback]]]):它是非阻塞,apply(func[, args[, kwds]])是阻塞的。如果是后者的话,执行如果如下:
1 | Parent process 502288. |
对Pool
对象调用join()
方法会等待所有子进程执行完毕,调用join()
之前必须先调用close()
,调用close()
之后就不能继续添加新的Process
了。
请注意输出的结果,task 0
,1
,2
,3
是立刻执行的,而task 4
要等待前面某个task完成后才执行,这是因为Pool
的默认大小在我的电脑上是4,因此,最多同时执行4个进程。这是Pool
有意设计的限制,并不是操作系统的限制。如果改成:
1 | p = Pool(5) |
就可以同时跑5个进程。
由于Pool
的默认大小是CPU的核数,如果你不幸拥有8核CPU,你要提交至少9个子进程才能看到上面的等待效果。
子进程
很多时候,子进程并不是自身,而是一个外部进程。我们创建了子进程后,还需要控制子进程的输入和输出。
subprocess
模块可以让我们非常方便地启动一个子进程,然后控制其输入和输出。
下面的例子演示了如何在Python代码中运行命令nslookup www.python.org
,这和命令行直接运行的效果是一样的:
1 | import subprocess |
运行结果:
1 | $ nslookup www.python.org |
如果子进程还需要输入,则可以通过communicate()
方法输入:
1 | import subprocess |
上面的代码相当于在命令行执行命令nslookup
,然后手动输入:
1 | set q=mx |
运行结果如下:
1 | $ nslookup |
进程间通信
Process
之间肯定是需要通信的,操作系统提供了很多机制来实现进程间的通信。Python的multiprocessing
模块包装了底层的机制,提供了Queue
、Pipes
等多种方式来交换数据。
我们以Queue
为例,在父进程中创建两个子进程,一个往Queue
里写数据,一个从Queue
里读数据:
1 | from multiprocessing import Process, Queue |
运行结果如下:
1 | Process to write: 50563 |
在Unix/Linux下,multiprocessing
模块封装了fork()
调用,使我们不需要关注fork()
的细节。由于Windows没有fork
调用,因此,multiprocessing
需要“模拟”出fork
的效果,父进程所有Python对象都必须通过pickle序列化再传到子进程去,所以,如果multiprocessing
在Windows下调用失败了,要先考虑是不是pickle失败了。
实战:进程池中使用不同子函数
代码如下:
1 | #coding: utf-8 |
运行结果:
1 | parent process 24276 |
小结
在Unix/Linux下,可以使用fork()
调用实现多进程。
要实现跨平台的多进程,可以使用multiprocessing
模块。
进程间通信是通过Queue
、Pipes
等实现的。
要想实现分布式进程,可以考虑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的标准库提供了两个模块:_thread
和threading
,_thread
是低级模块,threading
是高级模块,对_thread
进行了封装。绝大多数情况下,我们只需要使用threading
这个高级模块。
启动一个线程就是把一个函数传入并创建Thread
实例,然后调用start()
开始执行:
1 | import time, threading |
执行结果如下:
1 | thread MainThread is running... |
由于任何进程默认就会启动一个线程,我们把该线程称为主线程,主线程又可以启动新的线程,Python的threading
模块有个current_thread()
函数,它永远返回当前线程的实例。主线程实例的名字叫MainThread
,子线程的名字在创建时指定,我们用LoopThread
命名子线程。名字仅仅在打印时用来显示,完全没有其他意义,如果不起名字Python就自动给线程命名为Thread-1
,Thread-2
……
Lock
多线程和多进程最大的不同在于,多进程中,同一个变量,各自有一份拷贝存在于每个进程中,互不影响,而多线程中,所有变量都由所有线程共享,所以,任何一个变量都可以被任何一个线程修改,因此,线程之间共享数据最大的危险在于多个线程同时改一个变量,把内容给改乱了。
来看看多个线程同时操作一个变量怎么把内容给改乱了:
1 | import time, threading |
我们定义了一个共享变量balance
,初始值为0
,并且启动两个线程,先存后取,理论上结果应该为0
,但是,由于线程的调度是由操作系统决定的,当t1、t2交替执行时,只要循环次数足够多,balance
的结果就不一定是0
了。
原因是因为高级语言的一条语句在CPU执行时是若干条语句,即使一个简单的计算:
1 | balance = balance + n |
也分两步:
- 计算
balance + n
,存入临时变量中; - 将临时变量的值赋给
balance
。
也就是可以看成:
1 | x = balance + n |
由于x是局部变量,两个线程各自都有自己的x,当代码正常执行时:
1 | 初始值 balance = 0 |
但是t1和t2是交替运行的,如果操作系统以下面的顺序执行t1、t2:
1 | 初始值 balance = 0 |
究其原因,是因为修改balance
需要多条语句,而执行这几条语句时,线程可能中断,从而导致多个线程把同一个对象的内容改乱了。
两个线程同时一存一取,就可能导致余额不对,你肯定不希望你的银行存款莫名其妙地变成了负数,所以,我们必须确保一个线程在修改balance
的时候,别的线程一定不能改。
如果我们要确保balance
计算正确,就要给change_it()
上一把锁,当某个线程开始执行change_it()
时,我们说,该线程因为获得了锁,因此其他线程不能同时执行change_it()
,只能等待,直到锁被释放后,获得该锁以后才能改。由于锁只有一个,无论多少线程,同一时刻最多只有一个线程持有该锁,所以,不会造成修改的冲突。创建一个锁就是通过threading.Lock()
来实现:
1 | balance = 0 |
当多个线程同时执行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 | import threading, multiprocessing |
启动与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 | def process_student(name): |
每个函数一层一层调用都这么传参数那还得了?用全局变量?也不行,因为每个线程处理不同的Student
对象,不能共享。
如果用一个全局dict
存放所有的Student
对象,然后以thread
自身作为key
获得线程对应的Student
对象如何?
1 | global_dict = {} |
这种方式理论上是可行的,它最大的优点是消除了std
对象在每层函数中的传递问题,但是,每个函数获取std
的代码有点丑。
有没有更简单的方式?
ThreadLocal
应运而生,不用查找dict
,ThreadLocal
帮你自动做这件事:
1 | import threading |
执行结果:
1 | Hello, Alice (in Thread-A) |
全局变量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 | def A(): |
假设由协程执行,在执行 A 的过程中,可以随时中断,去执行 B,B 也可能在执行过程中中断再去执行 A,结果可能是:
1 | 1 |
但是在 A 中是没有调用 B 的,所以协程的调用比函数调用理解起来要难一些。
看起来 A、B 的执行有点像多线程,但协程的特点在于是一个线程执行,那和多线程比,协程有何优势?
最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。
第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
因为协程是一个线程执行,那怎么利用多核 CPU 呢?最简单的方法是多进程 + 协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。
Python 对协程的支持是通过 generator 实现的。在 generator 中,我们不但可以通过for
循环来迭代,还可以不断调用next()
函数获取由yield
语句返回的下一个值。但是 Python 的yield
不但可以返回一个值,它还可以接收调用者发出的参数。
来看例子:
传统的生产者 - 消费者模型是一个线程写消息,一个线程取消息,通过锁机制控制队列和等待,但一不小心就可能死锁。如果改用协程,生产者生产消息后,直接通过yield
跳转到消费者开始执行,待消费者执行完毕后,切换回生产者继续生产,效率极高:
1 | def consumer(): |
执行结果:
1 | [PRODUCER] Producing 1... |
注意到consumer
函数是一个generator
,把一个consumer
传入produce
后:
首先调用
c.send(None)
启动生成器;然后,一旦生产了东西,通过
c.send(n)
切换到consumer
执行;consumer
通过yield
拿到消息,处理,又通过yield
把结果传回;produce
拿到consumer
处理的结果,继续生产下一条消息;produce
决定不生产了,通过c.close()
关闭consumer
,整个过程结束。
整个流程无锁,由一个线程执行,produce
和consumer
协作完成任务,所以称为 “协程”,而非线程的抢占式多任务。
看完之后还是一脸懵逼,要看懂上面的例子,关键在于要理解下面几点:
1、例子中的c.send(None)
,其功能类似于next(c)
,比如:
1 | def num(): |
2、n = yield r
,这里是一条语句,但要理解两个知识点,赋值语句先计算=
右边,由于右边是 yield
语句,所以yield
语句执行完以后,进入暂停,而赋值语句在下一次启动生成器的时候首先被执行;
3、send
在接受None
参数的情况下,等同于next(generator)
的功能,但send
同时也可接收其他参数,比如例子中的c.send(n)
,要理解这种用法,先看一个例子:
1 | def num(): |
在上面的例子中,首先使用 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 | def consumer(): |
第一步:执行 c.send(None)
,启动生成器返回第一个值,n = yield r
,此时 r
为空,n
还未赋值,然后生成器暂停,等待下一次启动。
第二步:生成器返回空值后进入暂停,produce(c)
接着往下运行,进入While
循环,此时 n
为1
,所以打印:
1 | [PRODUCER] Producing 1... |
第三步:produce(c)
往下运行到 r = c.send(1)
,再次启动生成器,并传入了参数1
,而生成器从上次n
的赋值语句开始执行,n
被赋值为1
,n
存在,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 |
以此类推…
当n
为5
跳出循环后,使用c.close()
结束生成器的生命周期,然后程序运行结束。
最后套用 Donald Knuth 的一句话总结协程的特点:“子程序就是协程的一种特例。”
asyncio实现异步IO
asyncio
是Python 3.4版本引入的标准库,直接内置了对异步IO的支持。具体来说,协程都是通过使用yield from
和asyncio模块
中的@asyncio.coroutine
来实现的。asyncio
专门被用来实现异步IO操作。
为什么需要协程
下面我们先来看一个用普通同步代码实现多个 IO 任务的案例。
1 | # 普通同步代码实现多个IO任务 |
执行结果:
1 | 开始运行IO任务1... |
上面,我们顺序实现了两个同步 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 | def generator_1(titles): |
执行结果如下:
1 | 生成器1: ['Python', 'Java', 'C++'] |
在这个例子中yield titles
返回了titles
完整列表,而yield from titles
实际等价于:
1 | for title in titles: # 等价于yield from titles |
而yield from功能还不止于此,它还有一个主要的功能是省去了很多异常的处理,不再需要我们手动编写,其内部已经实现大部分异常处理。
举个例子,下面通过生成器来实现一个整数加和的程序,通过send()函数向生成器中传入要加和的数字,然后最后以返回None结束,total保存最后加和的总数。
1 | def generator_1(): |
执行结果如下。可见对于生成器g1
,在最后传入None
后,程序退出,报StopIteration
异常并返回了最后total
值是5。
1 | 加 2 |
如果把g1.send()
那5行注释掉,解注下面的g2.send()
代码,则结果如下。可见yield from
封装了处理常见异常的代码。对于g2即便传入None也不报异常,其中total = yield from generator_1()
返回给total的值是generator_1()
最终的return total
1 | 加 2 |
借用上述例子,这里有几个概念需要理一下:
- 子生成器:
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 | # 使用同步方式编写异步功能 |
执行结果如下:
1 | 开始运行IO任务1... |
【使用方法】: @asyncio.coroutine
装饰器是协程函数的标志,我们需要在每一个任务函数前加这个装饰器,并在函数中使用yield from
。在同步 IO 任务的代码中使用的time.sleep(2)
来假设任务执行了 2 秒。但在协程中yield from
后面必须是子生成器函数,而time.sleep()
并不是生成器,所以这里需要使用内置模块提供的生成器函数asyncio.sleep()
。
【功能】:通过使用协程,极大增加了多任务执行效率,最后消耗的时间是任务队列中耗时最多的时间。上述例子中的总耗时 3 秒就是taskIO_2()
的耗时时间。
【执行过程】:
- 上面代码先通过
get_event_loop()
获取了一个标准事件循环 loop(因为是一个,所以协程是单线程) - 然后,我们通过
run_until_complete(main())
来运行协程 (此处把调用方协程 main() 作为参数,调用方负责调用其他委托生成器),run_until_complete
的特点就像该函数的名字,直到循环事件的所有事件都处理完才能完整结束。 - 进入调用方协程,我们把多个任务 [
taskIO_1()
和taskIO_2()
] 放到一个task
列表中,可理解为打包任务。 - 现在,我们使用
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
后面的子生成器完成任务时,该事件才再次被唤醒】 - 因为
done
里面有我们需要的返回结果,但它目前还是个任务列表,所以要取出返回的结果值,我们遍历它并逐个调用result()
取出结果即可。(注:对于asyncio.wait()
和asyncio.as_completed()
返回的结果均是先完成的任务结果排在前面,所以此时打印出的结果不一定和原始顺序相同,但使用gather()
的话可以得到原始顺序的结果集,两者更详细的案例说明见此) - 最后我们通过
loop.close()
关闭事件循环。
综上所述:异步IO的完整实现是靠①事件循环+②协程。有关更底层的原理可以参考How does asyncio work?。
实战
我们用asyncio
的异步网络连接来获取sina、sohu和163的网站首页:
1 | import asyncio |
执行结果如下:
1 | wget www.sohu.com... |
可见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开始引入了新的语法async
和await
,可以让coroutine的代码更简洁易读。
请注意,async
和await
是针对coroutine的新语法,要使用新的语法,只需要做两步简单的替换:
- 把
@asyncio.coroutine
替换为async
; - 把
yield from
替换为await
。
让我们对比一下上一节的代码:
1 | import threading |
用新语法重新编写如下:
1 | import threading |
剩下的代码保持不变。
小结
Python从3.5版本开始为asyncio
提供了async
和await
的新语法;
注意新语法只能用在Python 3.5以及后续版本,如果使用3.4版本,则仍需使用上一节的方案。
如何选择
首先介绍一个什么是CPU密集型计算、IO密集型计算?
- CPU密集型(CPU-bound):CPU密集型也叫做计算密集型,是指IO在很短的时间内就可以完成,CPU需要大量的计算和处理,特点是CPU占用率高。例如压缩解压缩、加密解密、正则表达式搜索
- IO密集型(IO-bound):IO密集型是指系统运作大部分时间是CPU在等待I/O(硬盘/内存)的读/写操作,CPU占用率较低。例如文件处理程序,网络爬虫程序,读写数据库程序
其次,对比一下多进程、多线程、多协程。
那么该如何选择呢?
参考
协程
python并发编程 多进程 多线程 多协程
Python异步IO之协程(一):从yield from到async的使用