我们将以socket编程模拟http请求和多路复用io模型逐步引入协程
首先,使用socket通信模拟http请求,下面是客户端的请求代码:
以短连接的方式请求的话,程序运行的结果是:当服务端将响应发送完之后,会再发送一个空消息以表示响应发送完毕(此时服务端会主动关闭连接,也就是在服务端执行client.close()),以此方式告诉客户端可以关闭连接了(此时在客户端执行client.close())。
以长连接的方式请求的话,程序运行的结果是:服务端接收到请求报文后会返回响应(可以通过while循环中的print查看到),但是发送完响应之后不会立刻发送空消息。所以客户端在接收完响应报文之后就又被client.recv()给阻塞住了。此时服务端在等客户端会发送新的请求,直到等待了超时时间的时长,服务端才发送空消息(服务端关闭连接),此时客户端才真正跳出循环关闭连接(客户端执行client.close())。
我们可以做个实验,把接收服务器响应的代码封装起来,我们使用短连接发送两次请求:
还有一点要注意,客户端连接到服务端(connect()方法)是需要时间的,所以如果要使用非阻塞io编程的话不要connect之后马上就send发送请求,否则会报错,报错的原因是客户端还没有建立与服务端的连接,所以肯定发不了请求啦。
如果是阻塞io编程的话,connect方法会阻塞,直到与对端建立完连接用户进程才被唤醒(所以此时socket.send不会报错)。我们平时没有感觉到connect阻塞是因为与对端建立连接所花的时间很短。
正确做法应该是:
接下来我们使用多路复用器来实现一下上面的http请求。
这里作者使用了selector这个库来实现多路复用。selector封装了epoll,poll和select这几个多路复用器,selector.DefaultSelector会根据当前的系统来选择合适的多路复用器。
如果是在windows系统,就会选择select(windows中没有epoll);如果在linux系统就会选择epoll。
除此之外,selector还可以在注册事件的时候传入回调函数,当某个socket的某个事件就绪的时候,selector就会通知我们去调用对应的这个回调函数(注意,seletor不会自动调用回调函数,而是要我们手动调用)。
使用selector库的DefaultSelector本质上和旧版python使用select库的select/epoll是一样的。只不过selector会帮你自动选择合适的多路复用器,以及增加了使用注册回调函数的功能。
代码如下:
PS:上面的代码还有可以改善的地方,在__recvResponse里面,我想通过循环recv立刻读取完所有服务器返回的数据,但是实际上多路复用器只通知了1次读就绪,因此recv可能产生报错异常。就算用了try防止了报错,也会有循环空转的情况(cpu做了无用功),还不如用这个空转的时间去处理其他socket的事件。于是有了以下改进,思路是,每通知1次读就绪就只执行1次recv,每次recv到的数据都保存起来(多路复用器通知了多次读就绪才把响应接收完,而未改进的代码是只通知了一次就想把响应接收完)。
改进的代码(改了3个方法,其他没变):
在这个程序中,一次请求(getUrl)会生成一个客户端socket,每一个socket都会和服务端建立一次连接。每个socket的生命周期是:connect()建立连接 --- __sendReq()发送请求 --- __recvResponse()接收响应 --- 最后关闭连接。 也就是说一个socket只发送一个请求就关闭(因为这里是短连接),每请求1次就连接1次,这样频繁的建立和关闭连接会浪费建立连接的时间,效率比长连接低不少。
selectors模块的手册可以在这里查看:
https://docs.python.org/3/library/selectors.html
使用谷歌的翻译功能即可看到中文
也有直接的中文文档
https://www.dazhuanlan.com/2020/03/01/5e5a91ffa8c3d/?__cf_chl_jschl_tk__=6b83dc4dad612a631bcdfea8b7c25b3e6d2a6455-1602857112-0-ASrGptReaBYES2jJx6b65UzmH1JBwuqbjmaw5SJM212IPHRl_A1IJclgrEZL_jli_3OP2pLcFy1NT4YoyiubW4w7C8GVX8nzRyefjJlSh2Id_nYHtxBfEfNv7U1b0IdxmAmuJV2jZoJX9WDJQfcF_l0cIo4ARW4HIQLGPjsG8DWI3vL3uPl0QPwC3c9DfO15uz3dJl3m1wLjIdHaNAiLw-IHBvIrxUMNpuiSokPDOsyE2RyNIGdzKjERAtNcJj8uH-FjGLE5a14fPXrFBBUWW7BOnOoNhKzSJ5Og0KBKh2bImGNjr0a2VFegRSiINdm79g
多线程和多路复用相同点和区别:
相同点:两者都做到了高并发工作
区别:
多线程是在某个线程遇到阻塞的时候(例如io操作)通过切换线程,把cpu让给那些没有阻塞的线程占用的方式来做到并发,使得进程每时每刻都能用到cpu在工作,没有闲着。
多路复用虽然是单线程,但是使用了非阻塞IO+事件驱动(事件通知)+事件循环的模式做到高并发,由于非阻塞所以进程不会因为等待而让出cpu,事件通知保证了socket事件就绪后才进行发出系统调用请求,避免了不必要的while循环和系统调用而浪费cpu资源(相比于NIO模型的空转而浪费CPU而言)。
多路复用虽然是单线程,但是这个单线程一直都使用着cpu在进行运作。
多路复用比多线程好的地方:
1.避免了线程间切换(节省了上下文切换的时间+减少cpu损耗,切换线程是要消耗cpu的)
2.无需考虑线程安全和上锁(节省了上锁和解锁的时间)
3.线程的创建所需的资源和成本不小,所以多路复用更省资源。
所以多路复用在这种情况下是要比多线程的性能更高的。
(当然进行系统调用如recv,send,connect的时候cpu还是会从用户态切换到内核态,这个过程也相当于让出了cpu,但是这个情况放在多线程里面也会发生,只要是进行的io操作都会发生系统中断和用户态内核态切换)
上面的代码使用了传入回调函数的编程方式
在getUrl()中传入了回调函数__sendReq,在__sendReq中的register又传入了回调函数__recvResponse,可以说回调函数一层层的进行嵌套。
因为有多个函数,所以变量共用也是个问题,上面的代码使用面向对象编程所以可以通过定义成员变量来解决变量共用的问题。但是如果是普通的函数就不好解决这个问题。
而且回调的方式可读性很差。
为了解决这个问题这里就提出了协程。
协程要做到的事情:
1.使用单线程做到任务并发执行(这个多路复用也能做到)
2.采用同步的方式去编写异步的代码(这是python中Selector库的多路复用器做不到的)
协程的内容会放在下一节进行详细说明。