前言
要想走得远,基础就得牢,路漫漫其修远兮,吾将上下而求索
。
tornado简介
python web编程三剑客Django,flask,tornado各领风骚。
关于Django和flask的不多说,这里简单介绍下tornado框架。
tornado性能比flask和Django高很多是因为tornado在底层io处理机制上和Django以及flask有着根本的区别:
- tornado、gevent、asyncio、aiohttp:底层使用的是事件循环+协程
- Django和flask:传统的模型,阻塞io模型
所以当需要使用到高并发时,tornado无疑是最好的选择。
而想要学好tornado框架,就应该先要学好协程与异步事件循环的知识,而不仅仅是学习tornado框架的使用。
tornado优势
- 异步编码的一整套方案
- tornado不只是web框架,也是web服务器,Django项目也可以使用tornado部署实现高并发
- tornado是基于协程的解决方案
- tornado提供websocket的长连接(web聊天、消息推送)
tornado是如何做到高并发的
- 异步非阻塞io
- 基于epoll的事件循环,nginx之所以实现高并发也是基于事件循环
- 协程提高了代码的可读性,操作系统的最小操作单元是线程,而协程是比线程更小的操作单元,tornado底层实现了asyncio协程来实现异步非阻塞io
错误理解使用tornado框架
- 很多人认为tornado提供的只是web框架
- 只要用tornado就是高并发的
- tornado中使用了大量的同步io,导致不能发挥异步框架的优势
- tornado只需要将耗时的操作放到线程池中就可以达到高并发
- tornado中的多线程和协程的单线程是不是冲突?
尽量使用async和await而不是coroutine装饰器和yield from
- Python在最开始的时候并没有提供协程,需要使用生成器和coroutine装饰器来实现协程
- 后来Python3.4实现了async和await关键字来实现协程,相比于生成器的模式更加优雅
- 基于coroutine是一个从生成器过渡到协程的方案,应当座位历史了解而不是大量使用
- yield和await的混合使用造成代码的可读性很差
- 生成器可以模拟协程,但是生成器应该做自己
- 原生协程总体来说比基于装饰器的协程快
- 原生协程可以使用async for 和 async with 更pythonic
- 原生协程返回的是一个awaitable的对象、装饰器的协程返回的是一个future
同步、异步、阻塞和非阻塞
在了解协程前我们需要知道为什么需要协程,得先了解同步、异步、阻塞、非阻塞四个概念,以及造成阻塞的两个主要原因:网络请求io和读取本地文件io,以及如何以非阻塞来解决阻塞问题。
影响服务端性能的因素
- cpu的速度远高于io速度,服务端经常阻塞在io读写上导致不能充分使用cpu
- IO包括网络访问和本地文件访问,比如requests,urllib等传统的网络库都是同步的io
- 网络IO大部分的时间都是处于等待的状态,在等待的时候cpu是空闲的,但是又不能执行其他操作
阻塞、非阻塞
阻塞是指调用函数时候当前线程被挂起。
非阻塞是指调用函数时候当前线程不会被挂起,而是立即返回。
同步、异步
- 同步是你告诉老婆做饭,然后老婆就开始做饭,你就一直等待饭上桌,但是期间你可以做自己的事,也可以什么都不做。
- 异步是你告诉老婆去做饭,老婆会立即返回1,你等待饭做好期间可以做自己的事,饭做好后你老婆会通知你,但是通知完后需要你自己去取餐。
- 同步和异步更关心的是获取结果的方式,同步是获取结果之后才能进行下一步操作。
- 阻塞和非阻塞更关心的是线程的状态,同步可以调用阻塞,也可以非阻塞。异步是调用非阻塞接口。
socket的非阻塞io请求html
网络阻塞型io请求案例
使用requests
库访问请求百度:
import requests
html = requests.get("http://www.baidu.com").text
# 1. 三次握手建立tcp连接
# 2. 等待服务器响应
print(html)
在请求服务器的时候,需要经过TCP连接和服务端响应,如果服务端的性能不是很好,就会阻塞浪费很长时间,不能充分利用本地CPU。
编写非阻塞型socket请求
requests库其实就是封装了urllib库,而urllib库的底层使用的就是socket请求,我们可以通过直接编写socket请求,将socket设置为非阻塞,来达到非阻塞的目的:
import socket
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 将socket设置为非阻塞型
client.setblocking(False)
host = "www.baidu.com"
try:
client.connect((host, 80))
except BlockingIOError as e:
# 此时为请求阻塞异常,我们可以在该时间做别的事情
pass
try:
# 因为非阻塞socket,所以可能socket连接可能还没有建立成功,在调用socket方法时需要捕获异常
client.send("GET {} HTTP/1.1\r\nHost:{}\r\nConnection:close\r\n\r\n".format("/",host).encode("utf-8"))
except OSError as e:
pass
data = b""
while 1:
try:
# 非阻塞socket,在调用socket方法时需要捕获异常
d = client.recv(1024)
except BlockingIOError as e:
continue
if d:
data += d
else:
break
print(data.decode('utf-8'))
可以发现虽然我们需要手动捕获一些异常,而且这种非阻塞模式并不一定比阻塞模式效率高,但是我们达到了非阻塞的目的,可以在阻塞期间做其他的事情。
select、poll和epoll
上边我们达到了非阻塞的目的,但是我们该如何利用空闲时间呢,有没有一种机制在连接建立好后由操作系统主动通知我们呢,此时就需要用到select、poll、epoll。
select、poll、epoll
- select,poll,epoll都是IO多路复用的机制。
- I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
- 但select, poll, epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。
- 而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
select
- select函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。
- 调用select函数会阻塞,直到有描述符就绪(有数据可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。
- 当select函数返回后,可以通过遍历fdset, 来找到就绪的描述符。
- select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。
- select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
poll
- 不同于select使用三个位图来表示三个fdset的方式,poll使用一个pollfd的指针实现。
- pollfd结构包含了要监视的event和发生的event,不在使用select “参数-值” 传递的方式。同时pollfd没有最大数量限制(但是数量过大后性能也是会下降)。
- 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
epoll
- epoll是select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。
- epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需要一次。
使用selector库达到非阻塞模式
- selector会根据运行环境自动选择IO多路复用的机制
- 在使用selector库时需要注册监视文件描述符和传入回调函数参数
import socket
from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE
selector = DefaultSelector()
class Fetcher:
def connected(self, key):
"""
在写描述符就绪时调用的方法: 发送数据
:param key: 写描述符
:return:
"""
# 当事件到达时,我们需要将该描述符移除检测列表
selector.unregister(key.fd)
# 发送请求连接数据
self.client.send("GET {} HTTP/1.1\r\nHost:{}\r\nConnection:close\r\n\r\n".format("/", self.host).encode("utf-8"))
# 发送完毕后,注册监听读描述符,调用读回调函数等待接收数据
selector.register(self.client.fileno(), EVENT_READ, self.handle_read)
def handle_read(self, key):
"""
在读描述符就绪时调用的方法:接收数据
:param key: 读描述符
:return:
"""
# 接收数据时为阻塞状态
d = self.client.recv(1024)
if d:
self.data += d
else:
# 当数据接收完毕后将读描述符移除检测列表
selector.unregister(key.fd)
data = self.data.decode("utf-8")
print(data)
def get_url(self, url):
"""
建立套接字连接,注册写文件描述符
:param url:
:return:
"""
self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 设置非阻塞套接字
self.client.setblocking(False)
self.host = url
self.data = b""
try:
# 非阻塞套接字可能还未建立完成,此处需要捕获阻塞异常
self.client.connect((self.host, 80))
except BlockingIOError as e:
pass
# 注册监视写描述符,传入回调函数connect用以写入发送数据
selector.register(self.client.fileno(), EVENT_WRITE, self.connected)
- 上边代码虽然实现了非阻塞模式,但是该类还不能运行,需要事件循环驱动才能使代码运行起来。
手动实现事件循环驱动
我们需要实现一个事件循环驱动来使线程运行起来:
def loop_forever():
while 1:
"""
selector.select()方法会根据平台自动选择使用select还是epoll
它返回一个(key, events)元组,key是一个namedtuple,可以使用key.name获取元组的数据
key的内容(fileobj, fd, events, data):
fileobj 已经注册的文件对象
fd 也就是第一个参数的那个文件对象的更底层的文件描述符
events 等待IO事件
data 可选项。可以存一些和fileobj有关的数据,如session的id
"""
ready = selector.select() # 检测有无活动对象,没有就阻塞在这里等待
for key, mask in ready: # 有活动对象了
call_back = key.data # key.data 是注册时传递的回调函数名称
call_back(key) # 回调
完整代码如下:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# @File : select_test.py
# @Author: itnoobzzy
# @Date : 2021/2/22
# @Desc :
import socket
from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE
selector = DefaultSelector()
class Fetcher:
def connected(self, key):
"""
在写描述符就绪时调用的方法: 发送数据
:param key: 写描述符
:return:
"""
# 当事件到达时,我们需要将该描述符移除检测列表
selector.unregister(key.fd)
# 发送请求连接数据
self.client.send("GET {} HTTP/1.1\r\nHost:{}\r\nConnection:close\r\n\r\n".format("/", self.host).encode("utf-8"))
# 发送完毕后,注册监听读描述符,调用读回调函数等待接收数据
selector.register(self.client.fileno(), EVENT_READ, self.handle_read)
def handle_read(self, key):
"""
在读描述符就绪时调用的方法:接收数据
:param key: 读描述符
:return:
"""
# 接收数据时为阻塞状态
d = self.client.recv(1024)
if d:
self.data += d
else:
# 当数据接收完毕后将读描述符移除检测列表
selector.unregister(key.fd)
data = self.data.decode("utf-8")
print(data)
def get_url(self, url):
"""
建立套接字连接,注册写文件描述符
:param url:
:return:
"""
self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 设置非阻塞套接字
self.client.setblocking(False)
self.host = url
self.data = b""
try:
# 非阻塞套接字可能还未建立完成,此处需要捕获阻塞异常
self.client.connect((self.host, 80))
except BlockingIOError as e:
pass
# 注册监视写描述符,传入回调函数connect用以写入发送数据
selector.register(self.client.fileno(), EVENT_WRITE, self.connected)
def loop_forever():
while 1:
"""
selector.select()方法会根据平台自动选择使用select还是epoll
它返回一个(key, events)元组,key是一个namedtuple,可以使用key.name获取元组的数据
key的内容(fileobj, fd, events, data):
fileobj 已经注册的文件对象
fd 也就是第一个参数的那个文件对象的更底层的文件描述符
events 等待IO事件
data 可选项。可以存一些和fileobj有关的数据,如session的id
"""
ready = selector.select() # 检测有无活动对象,没有就阻塞在这里等待
for key, mask in ready: # 有活动对象了
call_back = key.data # key.data 是注册时传递的回调函数名称
call_back(key) # 回调
if __name__ == '__main__':
fetcher = Fetcher()
url = "http://wwww.baidu.com"
fetcher.get_url(url)
loop_forever()
tornado底层的实现就是异步事件循环驱动,只有先理解了事件循环驱动的原理和协程的原理才能真正理解tornado框架,灵活使用tornado框架
协程
上边所说的事件循环驱动的代码是同步的代码,而且是比较简单的回调较少的代码,我们可以明显的看出这种回调代码的可读性很差,一旦回调栈过深就会使代码难以维护,而且栈撕裂造成异常无法向上抛出:比如上边的handle_read
函数如果出异常了,该异常并不会层层向上抛出。
此时就出现了使用协程来解决同步的问题,达到异步事件循环驱动。
协程:可以被暂停并切换到其他协程运行的函数。
如果对协程与生成器之间关系不了解可以查看我以前写的这篇文章Python中的协程。
把生成器变为协程
-
将生成器变为协程有两种方法:
-
使用coroutine装饰器:
from tornado.gen import coroutine @coroutine def yield_test(): yield 1 yield 2 yield 3
-
使用async关键字:
from tornado.gen import coroutine @coroutine def yield_test(): yield 1 yield 2 yield 3 async def main(): await yield_test()
-
-
需要注意的是yield from只能在coroutine装饰的协程中使用而不能在使用async 定义的协程中使用:
async定义的协程可以使用await关键字:
-
但是更推荐使用async来定义协程,coroutine是Python定义协程历史遗留之物,async在是未来大势所趋,而且tornado底层就已经使用async定义协程。
使用协程切换调度
当遇到耗时的IO操作,我们可以将这些操作抽离为单独的协程函数,然后再使用await关键字切换调度,这样就可以达到异步地目的。
import time
import asyncio
# 这个库的作用是将异步生成器返回值变为可迭代对象
from aiostream.stream import list as alist
async def yield_test():
time.sleep(3)
yield 1
async def yield_test2():
time.sleep(3)
yield 2
async def main():
# 这里如果不使用alist转换会报错TypeError: ‘async_generator‘ object is not iterable
result1 = await alist(yield_test()) # 调度yield_test协程获取结果1
result2 = await alist(yield_test2()) # 调度yield_test2协程获取结果2
print(result1, result2)
if __name__ == '__main__':
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
异步http请求
传统的http请求库requests库和urllib库都是同步库,tornado提供了异步http请求方法:httpclient
。
from tornado import httpclient, ioloop
async def f():
http_client = httpclient.AsyncHTTPClient()
try:
response = await http_client.fetch("http://www.baidu.com")
except Exception as e:
print("Error: %s" % e)
else:
print(response.body.decode('utf-8'))
if __name__ == '__main__':
# # tornado自身的事件循环驱动
# io_loop = ioloop.IOLoop.current()
# io_loop.run_sync(f)
# 我们也可以使用内置的asyncio实现事件循环驱动
import asyncio
asyncio.ensure_future(f())
asyncio.get_event_loop().run_forever()
tornado实现高并发的爬虫
根据上边的异步http请求和协程实现高并发的爬虫:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# @File : tornado_spider.py
# @Author: itnoobzzy
# @Date : 2021/2/22
# @Desc : 高并发异步爬虫
from urllib.parse import urljoin
from bs4 import BeautifulSoup
from tornado import gen, httpclient, queues, ioloop
base_url = "http://www.tornadoweb.org/en/stable/"
concurrency = 3
async def get_url_links(url):
# 使用异步http请求
response = await httpclient.AsyncHTTPClient().fetch(base_url)
html = response.body.decode("utf-8")
soup = BeautifulSoup(html)
# 获取页面上的所有连接
links = [urljoin(base_url, a.get("href")) for a in soup.find_all("a", href=True)]
return links
async def main():
# 防止重复爬取,将爬取过的链接放入集合中
seen_set = set()
# 这里使用的是tornado自身的队列,该队列为异步队列,内部实现了__aiter__和__anext__方法
q = queues.Queue()
async def fetch_url(current_url):
"""
生产者:异步调用get_url_links获取当前页面上的上的所有链接
:param current_url:
:return:
"""
# 生产者退出条件:说明该链接爬过,退出
if current_url in seen_set:
return
print("获取{}".format(current_url))
# 将获取的链接放入集合中,和放入队列中
seen_set.add(current_url)
next_urls = await get_url_links(current_url)
for new_url in next_urls:
# 判断是否base_url开头是为了防止爬取外站的链接
if new_url.startswith(base_url):
# 这里使用await 放入队列是因为防止队列放满会产生异常导致阻塞
await q.put(new_url)
async def worker():
"""
消费者:异步调用生产者爬取链接
:return:
"""
# 因为该queue实现了__aiter__方法所以可以使用async for 进行迭代
async for url in q:
# 消费者退出条件:可以通过在队列的最后加入None来判断结束退出
if url is None:
return
try:
await fetch_url(url)
except Exception as e:
print('exception')
finally:
q.task_done()
# 放入初始url到队列
await q.put(base_url)
# 启动协程: 创建三个消费者
workers = gen.multi([worker() for _ in range(concurrency)])
# 等待队列加入完毕
await q.join()
# 这里加入三个None是因为有三个消费者协程需要退出
for _ in range(concurrency):
await q.put(None)
# 调用workers
await workers
if __name__ == '__main__':
io_loop = ioloop.IOLoop.current()
io_loop.run_sync(main)