这些天在研究Tornado的源码,说实话它的代码过于艰深了,需要绕很多弯才能弄清。
我想其中的问题主要是我不太懂socket,于是就花了些时间学习socket,算是有了些收获,顺便记录在此。
首先是socket的概念。
实际上UNIX的设计者很喜欢用类似的方式来处理一类任务,其中数据传输就都被抽象成文件,包括磁盘文件、管道、FIFO和终端等。而socket则是用于连接不同机器或进程的一个文件描述符,通过它可以实现机器或进程间的通信。
其次是socket的流程。实际上它分为有连接和无连接这2种,不过HTTP服务使用前者,所以这里只说前者。
先是服务器端调用socket()创建一个socket;再将这个socket传给bind(),绑定到一个端口;接着调用listen()来限制等待连接的队列容量;最后在一个循环中不断调用accept()来接收请求。
客户端则也通过socket()创建socket;再调用connect()连接到服务器端bind的那个端口。这时候如果等待连接的客户端超过listen()的设置,就会被拒绝访问;否则服务器端会accept()这个请求,并建立起连接。
而accept()会返回新的socket,服务器和客户端就可以通过这个socket,调用send()/recv()或write()/read()进行通信。
最后在需要结束通信时,调用shutdown()或close()来关闭这个socket即可。
总结一下的话,就是建立、监听、连接、接受、收发和断开的过程。
再介绍一下Tornado,它本身是个无阻塞的socket服务器。
说到阻塞这个概念,CPU的速度是很快的,寄存器、CPU缓存和内存还能跟上这个速率,存取时间都在纳秒级左右;但如果换成硬盘、网络,访问时间在毫秒级以上,这就造成了CPU长时间的空闲等待,于是就被称为阻塞了。
传统的解决方式是采用多线程模型,让某些线程去做这些事,当阻塞发生时,其他线程还能继续执行。这种方式需要线程同步,实现比较复杂,而且内存和线程切换的开销都很大。
如果在这些I/O访问时,CPU在发出指令后就立刻返回,继续处理其他事情;而设备则自行去完成连接、传输等任务,并在结束后让CPU完成善后工作;这样就既不会阻塞CPU,也没有多线程的复杂性和开销了。
在C语言中可以通过fcntl()和ioctl()来将socket设为无阻塞,Python中则很直观地调用setblocking(0)即可。
当socket变成无阻塞后,服务器就需要能异步处理这些I/O事件了。
也就是说,在调用accept()、send()和recv()的时候,函数会立刻返回;而在将来的某个时刻,CPU还需要去维护接受的socket和处理读取的数据等。
最简单的方式就是在死循环中不断遍历所有的socket,检查它们是否已经就绪;但是这样的效率显然不高,因为I/O是很耗时的操作,因此有很大概率是尚未就绪的。
与此类似的还有select()和poll(),它们会返回一类socket的集合。虽然和上一种方式的效率差不多,但因为可以设置超时时间(例如每秒1次),也就不会出现忙等时CPU占用率仍然100%的情况。
更先进的方式当然就是使用事件。Linux中提供了epoll,BSD(含Mac OS X和iOS)中提供了kqueue。我对kqueue还不熟,但epoll的行为是只返回有事件发生的socket,这样就能保证CPU不会浪费时间去处理未就绪的socket了。
而epoll的事件还存在2种触发方式:Level-Triggered和Edge-Triggered。LT指的是通过状态来表示事件发生了,例如读了缓冲区,那么这个socket就会被epoll_wait()返回,它的事件包含EPOLLIN。ET则是通过状态的变化来表示事件的发生,例如读取缓冲区时,如果没读完,那么状态一直是可读的;只有把缓冲区中的数据全部读完,状态变为不可读(EAGAIN)以后,再等下次缓冲区状态变为可读时,才会引起EPOLLIN事件,并被epoll_wait()返回。
事实上,Tornado就是依靠这种无阻塞的机制来与客户端(浏览器)连接和通信,所以它可以用单线程支撑大量并发。
但这只能保证Tornado与客户端之间的通信是无阻塞的,一个WEB服务器还不可避免地需要与磁盘文件、数据库等打交道,只有当这些I/O都是无阻塞时,整个服务才是真正无阻塞的。
然而要做到后者比较复杂(有的socket必须阻塞),所以有时不得不用多线程模型来实现。而且因为几乎所有请求都要访问数据库,当并发的请求数不能填满CPU的负荷时,总会遇到需要等待I/O的时候,此时的无阻塞也就变得无意义了。
因此Tornado推荐采用多进程的方式,当一个进程被阻塞时,其他进程继续处理其他请求。这样虽然单个请求会花更长的时间,但总体的效率还能保持在一个很高的水平。
然后测试下无阻塞+epoll方式的效率。
这次我直接在网上找到了一篇
《python下epoll的使用与性能测试》,借用了它的代码。
其中稍作了一些改进:输出的header中增加了“Connection: Close”,epoll_wait()和poll()设置了1秒的超时时间,以及将send()前的sprintf()移出循环。
在我的虚拟机上分别跑了一下,1000并发时,C和Python的版本分别约为2300+ QPS和2100+ QPS。
接着试了下
《实现了一个比nginx速度更快的HTTP服务器》这篇文章中的代码,发现居然有2900+ QPS,而且还是用文件传递(sendfile)方式。
比较了半天后,clowwindy说可能是因为他对accept出来的socket调用了setsockopt ( infd, SOL_TCP, TCP_CORK, &on, sizeof ( on ) )。这个TCP_CORK和TCP_NODELAY是相对的,前者表示将小的数据包合并在一起发送,后者则是不等待更多包就直接发送。因为前者可以少传递一些数据包,所以在高并发的情况下,这个优势也就被放大了。
于是我马上加上了这行代码,速度就增加到3000+ QPS了。至于Python,直接使用con.setsockopt(socket.IPPROTO_TCP, socket.TCP_CORK, 1)即可(Mac OS X的Python下没有socket.TCP_CORK,应该可以直接用常量3代替)。效果也很明显,增大到2700+ QPS了,约为C的90%。
最后还是那句话,在性能要求并非苛刻的时候,采用什么语言并没多大影响。
一台好的服务器,每秒跑1万hello world应该没问题,每个请求平均下来只需要0.1 ms的处理时间。而这和数据库等I/O请求相比根本不是一个数量级的,因此性能瓶颈其实并不在语言本身。
但是在代码量和可读性上,Python却节约了程序员大量的编码和维护时间。而在当今这种人比机器贵的年代,这就意味着巨大的价值。