一、异步IO
1、为什么要使用异步IO
在IO编程一节中,我们已经知道,CPU的速度远远快于磁盘、网络等IO。在一个线程中,CPU执行代码的速度极快,然而,一旦遇到IO操作,如读写文件、发送网络数据时,就需要等待IO操作完成,才能继续进行下一步操作。这种情况称为同步IO。
在IO操作的过程中,当前线程被挂起,而其他需要CPU执行的代码就无法被当前线程执行了。
因为一个IO操作就阻塞了当前线程,导致其他代码无法执行,所以我们必须使用多线程或者多进程来并发执行代码,为多个用户服务。每个用户都会分配一个线程,如果遇到IO导致线程被挂起,其他用户的线程不受影响。
多线程和多进程的模型虽然解决了并发问题,但是系统不能无上限地增加线程。由于系统切换线程的开销也很大,所以,一旦线程数量过多,CPU的时间就花在线程切换上了,真正运行代码的时间就少了,结果导致性能严重下降。由于我们要解决的问题是CPU高速执行能力和IO设备的龟速严重不匹配,多线程和多进程只是解决这一问题的一种方法。
2、什么时候使用异步IO
解决IO问题的方法是异步IO。当代码需要执行一个耗时的IO操作时,它只发出IO指令,并不等待IO结果,然后就去执行其他代码了。一段时间后,当IO返回结果时,再通知CPU进行处理。
3、异步IO的工作状态
消息模型其实早在应用在桌面应用程序中了。一个GUI程序的主线程就负责不停地读取消息并处理消息。所有的键盘、鼠标等消息都被发送到GUI程序的消息队列中,然后由GUI程序的主线程处理。由于GUI线程处理键盘、鼠标等消息的速度非常快,所以用户感觉不到延迟。某些时候,GUI线程在一个消息处理的过程中遇到问题导致一次消息处理时间过长,此时,用户会感觉到整个GUI程序停止响应了,敲键盘、点鼠标都没有反应。这种情况说明在消息模型中,处理一个消息必须非常迅速,否则,主线程将无法及时处理消息队列中的其他消息,导致程序看上去停止响应。消息模型是如何解决同步IO必须等待IO操作这一问题的呢?当遇到IO操作时,代码只负责发出IO请求,不等待IO结果,然后直接结束本轮消息处理,进入下一轮消息处理过程。当IO操作完成后,将收到一条“IO完成”的消息,处理该消息时就可以直接获取IO操作结果。在“发出IO请求”到收到“IO完成”的这段时间里,同步IO模型下,主线程只能挂起,但异步IO模型下,主线程并没有休息,而是在消息循环中继续处理其他消息。这样,在异步IO模型下,一个线程就可以同时处理多个IO请求,并且没有切换线程的操作。对于大多数IO密集型的应用程序,使用异步IO将大大提升系统的多任务处理能力。
二、什么是协程
协程,又称微线程,纤程。英文名Coroutine。协程的概念很早就提出来了,但直到最近几年才在某些语言(如Lua)中得到广泛应用。子程序,或者称为函数,在所有语言中都是层级调用,比如A调用B,B在执行过程中又调用了C,C执行完毕返回,B执行完毕返回,最后是A执行完毕。所以子程序调用是通过栈实现的,一个线程就是执行一个子程序。子程序调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。
三、协程理解
1、例如:
def A():
print('1')
print('2')
print('3')
def B():
print('x')
print('y')
print('z')
看起来A、B的执行有点像多线程,但协程的特点在于是一个线程执行
2、协程的优点
最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。
第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
3、协程的实现方式
例:yield关键字
def chat_robot():
res = ''
while True:
receive = yield
if 'age' in receive:
res = "18"
elif 'name' in receive:
res = "小冰"
elif 'hello' in receive:
res = 'hello'
else:
res = "i dont know"
print("Robot>>:%s" %(res))
def main():
# Robot
Robot = chat_robot()
next(Robot)
while True:
send_data = input("A>>:")
if send_data == 'q' or send_data == 'bye':
print("不聊了")
break
Robot.send(send_data)
main()
- 带有 yield 的函数不再是一个普通函数,而是一个生成器generator,可用于迭代,工作原理同上。
- yield 是一个类似 return 的关键字,迭代一次遇到yield时就返回yield后面的值。重点是:下一次迭代时,从上一次迭代遇到的yield后面的代码开始执行。
- 简要理解:yield就是 return 返回一个值,并且记住这个返回的位置,下次迭代就从这个位置后开始。
- 带有yield的函数不仅仅只用于for循环中,而且可用于某个函数的参数,只要这个函数的参数允许迭代参数。比如array.extend函数,它的原型是array.extend(iterable)。
- send(msg)与next()的区别在于send可以传递参数给yield表达式,这时传递的参数会作为yield表达式的值,而yield的参数是返回给调用者的值。——换句话说,就是send可以强行修改上一个yield表达式值。比如函数中有一个yield赋值,a
= yield 5,第一次迭代到这里会返回5,a还没有赋值。第二次迭代时,使用.send(10),那么,就是强行修改yield 5表达式的值为10,本来是5的,那么a=10 - send(msg)与next()都有返回值,它们的返回值是当前迭代遇到yield时,yield后面表达式的值,其实就是当前迭代中yield后面的参数。
- 第一次调用时必须先next()或send(None),否则会报错,send后之所以为None是因为这时候没有上一个yield(根据第8条)。可以认为,next()等同于send(None)。
- Python通过
yield
提供了对协程的基本支持,但是不完全。而第三方的gevent为Python提供了比较完善的协程支持。
四、python中的协程---gevent
gevent是第三方库,通过greenlet实现协程,其基本思想是:
当一个greenlet遇到IO操作时,比如访问网络,就自动切换到其他的greenlet,等到IO操作完成,再在适当的时候切换回来继续执行。由于IO操作非常耗时,经常使程序处于等待状态,有了gevent为我们自动切换协程,就保证总有greenlet在运行,而不是等待IO。
由于切换是在IO操作时自动完成,所以gevent需要修改Python自带的一些标准库,这一过程在启动时通过monkey patch完成:
例:
from gevent import monkey
import threading
# 打补丁, 自动修改协程中需要的一些标准库;
monkey.patch_socket()
import gevent
def f(n):
"""协程需要处理的任务"""
for i in range(n):
print(gevent.getcurrent().name,threading.current_thread().name, i)
# 模拟IO操作
# 通过gevent.sleep交出协程的控制权;
gevent.sleep(1)
def Automain():
# g1 = gevent.spawn(f, 5)
# g2 = gevent.spawn(f, 5)
# g3 = gevent.spawn(f, 5)
#
# g1.join()
# g2.join()
# g3.join()
gevents = [gevent.spawn(f, 5) for i in range(3)]
gevent.joinall(gevents)
Automain()
threading在这里主要是我们看到运行的结果对比,证明我们这里的协程是单线程;
Automain()实例三个对像
第一列为实例返回回来的对象,第二列为我们的线程只有一条为主线程;
1、协程与线程池的比较
注:我这里用了时间装饰器来观察实验结果,
from concurrent.futures import ThreadPoolExecutor
from urllib.request import urlopen
import gevent
from gevent import monkey
from mytimeit import timeit
monkey.patch_socket()
urls = ['http://httpbin.org']*20
def load_url(url):
with urlopen(url) as urlobj:
content = urlobj.read().decode('utf-8')
print('%s 有 %s 字节'%(url,len(content)))
@timeit
def geventMain():
jobs = [gevent.spawn(load_url,url) for url in urls ]
gevent.joinall(jobs)
@timeit
def threadMain():
with ThreadPoolExecutor(max_workers=100) as pool:
pool.map(load_url, urls)
geventMain()
threadMain()
这里的实验结果不一定谁更快,因为我的实验数据太少了,如果需要操作更大的数据,那么结果会更加明显;
五、利用协程爬取贴吧邮箱
下面的代码里需要用到正则表达的内容,我们在后面的内容中会讲到;
from concurrent.futures import ThreadPoolExecutor
from urllib import request
import re
from gevent import monkey
import threading
# 打补丁, 自动修改协程中需要的一些标准库;
monkey.patch_socket()
import gevent
from mytime import timeit
url = 'http://tieba.baidu.com/p/2314539885'
EmailLi = []
def get_content(url):
"""获取网页源代码"""
# 1. 下载网页源代码到本地, 获取帖子总页数;
# urlObj = request.urlopen(url, timeout=60)
# content = urlObj.read().decode('utf-8')
with request.urlopen(url, timeout=60) as urlObj:
content = urlObj.read().decode('utf-8')
return content
def get_page(url):
content = get_content(url)
# <a href="/p/2314539885?pn=31">尾页</a>
pattern = r'<a href="/p/.*pn=(\d+)">尾页</a>'
page = re.findall(pattern, content)[0]
return int(page)
def get_all_url(url, page):
"""生成所有页的帖子url地址"""
url_li = []
# page: 31 0,1,2,3,4.....30
for i in range(page):
new_url = url + '?pn=%d' %(i+1)
url_li.append(new_url)
return url_li
def get_email(url):
content = get_content(url)
pattern = r'[a-zA-Z0-9_]+@\w+\.com'
EmailLi.extend(re.findall(pattern, content))
print(re.findall(pattern, content))
# 结论:
# 1. 如果代码是IO密集型, 建议选择多线程;
# 2. 如果是计算密集型, 建议选用多进程;
@timeit
def geventMain():
# url = 'http://tieba.baidu.com/p/2314539885'
page = get_page(url)
url_li = get_all_url(url, page)
gevents = [gevent.spawn(get_email, url) for url in url_li]
gevent.joinall(gevents)
geventMain()
也可以用线程池进行比较
@timeit
def useNoThreadMain():
page = get_page(url)
url_li = get_all_url(url, page)
for urlItem in url_li:
print(get_email(urlItem))