所谓c10k问题,指的是服务器同时支持成千上万个客户端的问题,也就是concurrent 10 000 connection(这也是c10k这个名字的由来)。由于硬件成本的大幅度降低和硬件技术的进步,如果一台服务器同时能够服务更多的客户端,那么也就意味着服务每一个客户端的成本大幅度降低,从这个角度来看,c10k问题显得非常有意义。
为了解决C10K问题,有各种各样的IO策略,它们的分歧或者说不同之处大概如下:
· 是否在一个线程当中处理多个IO调用
o 单线程处理多个IO调用不靠谱,还是使用阻塞的,同步的调用方法。在这种情况下利用多线程或者多进程来达到并发
o 使用非阻塞的IO(比如被设置成O_NONBLOCK模式的socket的write方法)来启动IO,利用IO的就绪通知(比如poll()方法或者/dev/poll)来确认什么时候可以在同一个通道上启动一轮新的IO。通常这种方法只是适合网络IO,对于磁盘IO这种方法并不合适。
o 使用异步IO调用(比如aio_write())来启动IO ,当IO完成时,会有通知(比如信号量或者完成端口)告诉你什么时候IO操作结束了。这种方法对于网络IO和磁盘IO都很适合。
· 如何控制服务于每个客户端的代码。
o 一个进程服务一个客户端。(这是Unix采用的经典的方法,从80年代采用直到现在一直在使用)
o 一个OS-Level的线程来同时处理很多客户端,每个客户端的控制是通过:
§ 一个用户态的线程(比如GNU状态线程,经典的java绿色线程)
§ 一个状态机
§ 线程延伸
o 为每个客户端分配一个OS Level的线程
o 为每个活跃的客户端分配一个OS Level的线程
是否使用标准的OS 服务,更直接的方法是把部分代码放入内核中。
下面就来看看5种最流行的IO策略:
1. 1 用一个线程来同时为很多的客户端来服务,非阻塞IO以及水平触发方式的就绪通知
这种方式很简单,它将所有的网络文件句柄的工作模式都设置成NON-BLOCKING,通过调用select()方法或者poll()方法来告诉应用层哪些个网络句柄有正在等待着并需要被处理的数据。这是一种非常传统的方法。通过这种机制,内核能够告诉应用层一个文件描述符是否准备好了(这里的准备好有着明确的含义,对于读描述符,准备好了意味着此时该描述符的缓冲区内数据已经准备好,读取该描述符的数据不会发生阻塞,而对于写描述符而言,准备好了意味着另外一层含义,它意味着写缓冲区已经准备好了,此时对该操作符的写操作也将不会导致任何阻塞发生),以及你是否已经利用该文件描述符作了相应的事情。因为这里的就绪通知方式是水平触发,也就说如果内核通知应用层某一个文件描述符已经就绪,而如果应用层此后一直没有完整的处理该描述符(没有读取完相应的数据或者没有写入任何数据),那么内核会不断地通知应用层该文件描述符已经就绪。这就是所谓的水平触发:只要条件满足,那内核就会触发一个事件(只要文件描述符对应的数据没有被读取或者写入,那内核就不断地通知你)。
需要注意的是:内核的就绪通知只是一个提示,提示也就意味着这个通知消息未必是100%准确的,当你读取一个就绪的读文件描述符时,实际上你有可能会发现这个描述符对应的数据并没有准备好。这就是为什么如果使用就绪通知的话一定要将文件描述符的模式设置成NOBLOCK的,因为NOBLOCK模式的读取或者写入在文件描述符没有就绪的时候会直接返回,而不是引起阻塞。如果这里发生了阻塞,那将是非常致命的,因为我们只有一个线程,唯一的线程被阻塞了的话,那我们就玩完了。
这种方式的一个缺陷就是不适用磁盘文件的IO操作。将磁盘文件的操作句柄的工作模式设置成NOBLOCK是无效的,此时对该磁盘文件进行读写依然有可能导致阻塞。对于缺乏AIO(异步IO)支持的系统,将磁盘IO操作委托给worker线程或者进程是一个好方法来绕过这个问题。一个可行的方法是使用memory mapped file,然后调用mincore(),mincore会返回一个向量来表示相应的page是否在ram缓存中,如果page不在ram缓存中,则意味着读取该页面会导致page falut,从而引起阻塞,那么就需要通过委托的worker线程来进行IO操作。这种方式的实现方法在Linux上就是select,poll这样的系统调用。
2. 2 使用一个线程同时服务很多个客户端,采用noblock的IO模式以及边沿触发的就绪通知。
所谓边沿触发是相对水平触发而言的,也就是说内核只是在文件描述符的状态发生变换的时候才进行通知。这就意味着在大多数情况下,当内核通知某个读描述符就绪后,除非该读描述符内部缓冲区的所有数据已经完全被读取从而使得就绪状态发生了变化,否则内核不会发出任何新的通知,会永远沉默下去。如果该文件描述符的receive操作返回EWOULDBLOCK错误码,这就意味着该描述符的就绪状态已经被打破,你需要等待下一次的边沿触发通知。
除了上面所说的问题,一旦使用了边沿触发,另外一个随之而来的问题就是,你需要注意一个常见的“意外事件”的问题。因为os实现边沿触发的一个常见的实现bug就是在某些情况下内核一旦收到新数据包就会通知就绪,不管你上一次的就绪通知是否被用户处理。因此你必须小心组织你的代码,你需要处理好每一个就绪通知,如果某一次就绪通知的数据没有被正确得完整得处理你就急急忙忙得开始等待下一次通知,那么下一次的就绪通知就会覆盖掉前面的数据,那么你就会永远不会恢复了。
相比之下,这种方式对于程序员编码的要求可能会更高一些,一旦应用程序错过了一次通知,那么与之对应的客户端就永远崩溃了(意外事件)或者沉默(没有读取完上一次事件产生的数据)。而方式1则会不断提醒用户缓冲区内还有数据。因此,对于边沿触发方式的就绪通知,应用层必须在每次就绪通知后读取数据,一直读到EWOULDBLOCK为止。
这种方式在Linux中主要通过epoll实现。实际上java nio采用的也是这种IO策略。Epoll和poll有一些共同之处,epoll在默认情况下也是水平触发的,此时你可以认为epoll是一个增强版的poll,它的效率更高,这是因为epoll采用了一些优化,比如只关心活跃的连接,通过共享内存空间避免了内存拷贝等等。
3. 3 用一个线程同时服务很多个客户端,采用异步IO。
这种IO策略实际上Linux并没有原生支持,尽管POSIX定义了它。相比之下windows就提供了很好的支持。异步IO也有内核通知,只不过这种通知不是就绪通知,而是完成通知,这就意味着一旦获得内核通知,那么IO操作就已经完成了,用户无需再调用任何操作来获取数据或者发送数据,此时数据已经好端端得放在用户定义的buffer中或者数据已经妥妥得发送出去了。与前两种方式相比,实际上aio是由内核线程或者底层线程异步地,默默得完成了IO操作,而方式1,方式2还得由用户线程来自己读取数据。相比之下,内核线程自然要高效很多。因此从IO模型的效率上来讲,windows是要优于Linux的。另外题外话再插一句,实际上微软的windows在很多概念上还是比较先进的,比如windows是微内核的,又比如这里的AIO,所以微软其实没有大家所想象的那么不堪,还是挺牛的。如果专业一点来讲,1和2这种方式一般被称之为reactor模式,3这种模式被称之为proactor模式。
4. 4 为每个客户端分配一个线程来进行IO操作
这种IO策略就比较老土了,也就是我们最常用的的一种IO模型,而且这种IO模型已经存在了几十年了。这种策略下,read和write调用都是阻塞的。它最大的问题就是每个线程都需要占据一个完整的栈帧,这个对内存的考验还是比较大的。而且过多的线程对OS也有很大的压力,很多OS如果有过多的线程其性能会有指数级别的下降。来算一下吧,假设栈帧的空间为2M,那么1G的内存最多服务512个线程,显然和我们的要求10K有不小的差距。当然,由于硬件的资源会越来越便宜,线程的内存开销可能不太会成为瓶颈。但多线程带来的进程切换的开销却有可能会长期存在。
这种IO策略的关键在于OS的线程要足够强大,高效。
5. 5 把应用层代码装进内核里
这种方式比较疯狂,如果你的team有足够的人手,而且服务器的需求量也比较大,你其实可以考虑这种方式。用专有的方法来解决问题其实也并非不可以,比如有的公司会把常用的核心算法放到FPGA或者ASIC芯片上去来解决问题,这两者的思路其实是如出一辙。对于Linux来讲,其实社区的意见还是不倾向于这么做,原因也很好理解,在内核中为应用开一个口子怎么看都不像一个好主意,一个更好的思路还是尽可能让用户空间的程序更快吧,别动不动就塞进内核里来。
好了,这就是目前为止关于解决C10K问题的一些思路,实际上思路2还是解决问题的主流方式。关于这个问题更多的资料大家可以参阅http://www.kegel.com/c10k.html#threaded,这个页面上对于C10K问题有着比较全面的分析和总结,本文实际上也只不过这个页面主要内容的一个摘要以及评注。