01 | tornado 入门必备
Tornado 是一个基于Python的Web服务框架和 异步网络库, 最早开发与 FriendFeed 公司. 通过利用非阻塞网络 I/O, Tornado 可以承载成千上万的活动连接, 完美的实现了 长连接, WebSockets, 和其他对于每一位用户来说需要长连接的程序
由此可以知道Tornado 适用于高并发和长连接的场景
它是如何实现高并发的?
1 异步非阻塞IO
2 基于epoll 的时间循环
3 协程提高了代码的可读性
注意:在tornado中不要写同步的代码,因为tornado是基于协程(单线程)调度的,如果在一个地方存在了同步的耗时,就会造成其他协成的阻塞。
官方文档
https://tornado-zh-cn.readthedocs.io/zh_CN/latest/
安装tornado
pip install -i https://pypi.douban.com/simple tornado
Tornado 可以被分为以下四个主要部分:
-
Web 框架 (包括用来创建 Web 应用程序的
RequestHandler
类, 还有很多其它支持的类). -
HTTP 客户端和服务器的实现 (
HTTPServer
和AsyncHTTPClient
). -
协程库 (
tornado.gen
) 让用户通过更直接的方法来实现异步编程, 而不是通过回调的方式
同步异步,阻塞和非阻塞
阻塞和非阻塞关注的是当前线程的状态
阻塞是指调用函数时候当前线程被挂起
非阻塞是指调用函数时候当前线程不会被挂起,而是立即返回
同步和异步关注的是获取结果的方式
同步指代码调用IO操作时,获取结果后才能执行下一步操作
异步是指代码调用IO操作时,不必等待IO操作完成就返回的调用方式
现在存在的一些事实
1 cpu 的速度远高于io速度
2 IO包括网络访问和本地文件访问,比如requests,urllib等传统的库都是同步的io
3 网络IO大部分的时间都是处于等待的状态,在等待的时候cpu是空闲的,但是又不能执行其他操作
阻塞IO request 模块
import requests html = requests.get("http://www.baidu.com").text #1. 三次握手建立tcp连接, # 2. 等待服务器响应 print(html)
在上面的例子中,在和服务器建立3次握手的时候,应用程序是处于阻塞的,那么怎么能利用这个阻塞的时间呢
request模块内部封装了urllib,urllib内部封装了socket,我们可以使用socket为我们提供非阻塞的方式,使cpu利用起来
通过socket直接获取html(阻塞)
import socket client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) host = "www.baidu.com" client.connect((host, 80)) #阻塞io, 意味着这个时候cpu是空闲的 client.send("GET {} HTTP/1.1\r\nHost:{}\r\nConnection:close\r\n\r\n".format("/", host).encode("utf8")) data = b"" while 1: d = client.recv(1024) #阻塞直到有數據 if d: data += d else: break data = data.decode("utf8") print(data)
通过socket直接获取html(非阻塞)
import socket import socket client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.setblocking(False) host = "www.baidu.com" try: client.connect((host, 80)) #阻塞io, 意味着这个时候cpu是空闲的 except BlockingIOError as e: #做一些其他事 pass while 1: try: client.send("GET {} HTTP/1.1\r\nHost:{}\r\nConnection:close\r\n\r\n".format("/", host).encode("utf8")) print("send success") break except OSError as e: pass data = b"" while 1: try: d = client.recv(1024) #阻塞直到有數據 except BlockingIOError as e: continue if d: data += d else: break data = data.decode("utf8") print(data)
在上面的代码中虽然请求的时间和阻塞的没有区别,但是在三次握手的时候我们不需要在等待了,并不是不建立3次连接,而是把它交给操作系统去完成,而我们的应用不在去管它,从而防止CPU 存在空闲时间
select poll 和 epoll
虽然我们通过socket 可以实现非阻塞的IO,但是程序还是会不停的进行循环尝试,操作系统为我们提供了一些方法可以监听socket状态,一旦socket 准备就绪(读写就绪),就会通知应用程序进行响应的读写操作。
select,poll,epoll都是IO多路复用的机制。IO多路复用就是通过一种机制,一个进程可以监听多个描述符(socket),一旦文件描述符 准备就绪(读写就绪),就会通知应用程序进行响应的读写操作。但是select,poll,epoll本质上还是同步IO,因为在读写事件准备就绪后需要自己读写,也就是说,这个读写过程是阻塞的。
而异步IO在无需自己读写,异步IO的实现会负责被数据从内核拷贝到用户空间(现在操作系统的异步IO比O多路复用目前来说还没达到明显的提升),所以目前使用最多的还是IO多路复用epoll
selct
select函数监视的文件描述符分为三类:writefds, readfds,exceptfds。调用select后,select函数会阻塞,直到有描述符就绪(有数据可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回,设为null即可)函数返回,这个时候程序就会系统调用,将数据从kernel复制到进程缓冲区。。当select函数返回后,可以遍历fdset来找到就绪的描述符。
select目前几乎在所有的平台上支持,其良好夸平台支持也是它的一个优点。
select的缺点
1、根据fd_size的定义,它的大小为32个整数大小(32位机器为32*32,所有共有1024bits可以记录fd),每个fd一个bit,所以最大只能同时处理1024个fd。
2、每一次呼叫 select( ) 都需要先从 user space把 FD_SET复制到 kernel(约线性时间成本)
为什么 select 不能像epoll一样,只做一次复制就好呢?
每一次呼叫 select()前,FD_SET都可能更动,而 epoll 提供了共享记忆存储结构,所以不需要有 kernel 與 user之间的数据沟通。
3、每次要判断【有哪些event发生】这件事的成本很高,也就是遍历fd_set,因为select(polling也是)采取主动轮询机制.
poll
poll的原理与select非常相似,差别如下:
-
描述fd集合的方式不同,poll使用 pollfd 结构而不是select结构fd_set结构,所以poll是链式的,没有最大连接数的限制
-
poll有一个特点是水平触发,也就是通知程序fd就绪后,这次没有被处理,那么下次poll的时候会再次通知同个fd已经就绪。
epoll
epoll 提供了三个函数:
1、int epoll_create(int size);
建立一個 epoll 对象,并传回它的id
2、int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
事件注册函数,将需要监听的事件和需要监听的fd交给epoll对象
3、int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
等待注册的事件被触发或者timeout发生
epoll解决的问题:
-
epoll没有fd数量限制
-
epoll没有这个限制,我们知道每个epoll监听一个fd,所以最大数量与能打开的fd数量有关,一个g的内存的机器上,能打开10万个左右
-
epoll不需要每次都从user space 将fd set复制到内核kernel
-
epoll在用epoll_ctl函数进行事件注册的时候,已经将fd复制到内核中,所以不需要每次都重新复制一次
-
select 和 poll 都是主動輪詢機制,需要拜訪每一個 FD;epoll是被动触发方式,给fd注册了相应事件的时候,我们为每一个fd指定了一个回调函数,当数据准备好之后,就会把就绪的fd加入一个就绪的队列中,epoll_wait的工作方式实际上就是在这个就绪队列中查看有没有就绪的fd,如果有,就唤醒就绪队列上的等待者,然后调用回调函数。
-
虽然epoll。poll。epoll都需要查看是否有fd就绪,但是epoll之所以是被动触发,就在于它只要去查找就绪队列中有没有fd,就绪的fd是主动加到队列中,epoll不需要一个个轮询确认。
-
换一句话讲,就是select和poll只能通知有fd已经就绪了,但不能知道究竟是哪个fd就绪,所以select和poll就要去主动轮询一遍找到就绪的fd。而epoll则是不但可以知道有fd可以就绪,而且还具体可以知道就绪fd的编号,所以直接找到就可以,不用轮询。
自己去实现事件循环
以下代码在linux上运行,在Windows上可能会报错
import socket from selectors import DefaultSelector, EVENT_WRITE, EVENT_READ selector = DefaultSelector() class Fetcher: def connected(self, key): # key 文件描述符 selector.unregister(key.fd) self.client.send("GET {} HTTP/1.1\r\nHost:{}\r\nConnection:close\r\n\r\n".format("/", self.host).encode("utf8")) selector.register(self.client.fileno(), EVENT_READ, self.readble) def readble(self, key): d = self.client.recv(1024) if d: self.data += d else: selector.unregister(key.fd) data = self.data.decode("utf8") print(data) def get_url(self, url): self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.client.setblocking(False) self.data = b"" self.host = "www.baidu.com" try: self.client.connect((self.host, 80)) # 阻塞io, 意味着这个时候cpu是空闲的 except BlockingIOError as e: # 做一些其他事 pass selector.register(self.client.fileno(), EVENT_WRITE, self.connected) def loop_forever(): #事件循环 while 1: ready = selector.select() for key, mask in ready: call_back = key.data call_back(key) if __name__ == "__main__": fetcher = Fetcher() url = "http://www.baidu.com" fetcher.get_url(url) loop_forever()
输出结果如下
以上select实例是基于方法回调模式的实现,它一下几个缺点:
1、回调过深,造成代码维护非常困难。
2、栈撕裂,造成异常无法抛出(比如connected、readble方法出现异常是无法抛出的)
要解决以上问题,就需要利用下一小节中介绍的协程。
协程
python 3.5中协程的写法 ,使用async关键字标记协程,await关键字调用协程并获取协程的结果
from tornado.gen import coroutine async def yield_test(): yield 1 yield 2 yield 3 async def main(): result = await yield_test() result = await yield_test2() async def main2(): await yield_test() my_yield = yield_test() for item in my_yield: print(item)
上面的伪代码async表示的是把一个函数标记成一个协程,函数中的 await 表示要获取协程的结果调用非阻塞的IO向epoll中注册,同时切换到其它的协程执行,epoll会监听socket状态,等到可以读的时候程序在向下执行,达到了协程间的切换和调度。
AsyncHttpClient(tornado提供的异步非阻塞的包)异步http请求
from tornado import httpclient async def f(): http_client = httpclient.AsyncHTTPClient() try: response = await http_client.fetch("http://www.tornadoweb.org/en/stable/") except Exception as e: print("Error: %s" % e) else: print(response.body.decode("utf8")) if __name__ == "__main__": import tornado io_loop = tornado.ioloop.IOLoop.current() #run_sync方法可以在运行完某个协程之后停止事件循环 io_loop.run_sync(f)
tornado实现高并发的爬虫
from urllib.parse import urljoin from bs4 import BeautifulSoup from tornado import gen, httpclient, ioloop, queues base_url = "http://www.tornadoweb.org/en/stable/" concurrency = 3 async def get_url_links(url): response = await httpclient.AsyncHTTPClient().fetch("http://www.tornadoweb.org/en/stable/") html = response.body.decode("utf8") 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() q = queues.Queue() async def fetch_url(current_url): #生产者 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: if new_url.startswith(base_url): #放入队列, await q.put(new_url) async def worker(): async for url in q: if url is None: return try: await fetch_url(url) except Exception as e: print("excepiton") finally: q.task_done() #放入初始url到队列 await q.put(base_url) #启动协程 workers = gen.multi([worker() for _ in range(concurrency)]) await q.join() for _ in range(concurrency): await q.put(None) await workers if __name__ == "__main__": io_loop = ioloop.IOLoop.current() io_loop.run_sync(main)