从C10K问题来看常见的中小型服务器I/O模型

问题描述:

关于C10问题的经典描述可以查看这个网页 http://www.kegel.com/c10k.html

具体来说就是服务器如何处理10k个客户端的并发连接,即 concurrent 10,000 connection 。如果在很早以前互联网还不普及的时候,一个服务器很少会同时出现有10k的连接,但是现在互联网高速发展,这种规模的连接可能随处可见,所以如何来解决C10k的问题对于服务提供者来说是一个最先需要解决的问题。有人可以说现在硬件成本很低,连接增多可能会消耗很大的内存那么我扩充内存就可以了,cpu的负载很高,那么我提高cpu的性能就可以了。

OK,这种解决方案类似于兵来将挡,水来土掩,感觉很easy,但不幸的是,连接的客户端数超过一定的规模之后对服务器的资源的要求往往是不是线性的,大多数是O(n^2)的需求,所以你再任性,再有钱,传统的解决方案也是解决不了的。

同时,服务商一般都会从经济的角度去考虑问题,而对于互联网公司来说,很大的一笔成本就是硬件的投入和消耗,当然电费也在里面。所以如果相同的硬件成本和电费可以处理更多的请求完成更多的事情,我想他们何乐不为?


解决方案:

解决C10k问题的总体的思路就是从两个方面来考虑:

1.应用程序以何种方式和操作系统合作来处理I/O的问题,阻塞,非阻塞,还是异步I/O?
2. 应用程序如何处理任务和线程/进程的关系, one task one thread/process, 还是one thread/process more tasks, or thread poll ?


展开来说主要有:

应用程序以何种方式和操作系统合作来处理I/O

 0.  阻塞式的I/O,例如read()函数,这种模型如果想实现并发就必须要使用多线程机制来完成。

 1.  非阻塞I/O,例如I/O多路复用中的select,poll等通过设置句柄的方式在I/o就绪的时候由内核通知应用程序进行读写,epoll则直接通知进行那个文件描述符的读写,当然还有将传统的阻塞式I/O函数,如write()设置为O_NONBLOCK的模式来进行非阻塞的写。当然,这种方法只是适合网络IO,对于磁盘IO这种方法并不合适。

 2.  使用异步IO调用(比如aio_write())来启动IO ,这种I/O由内核线程来完成I/O操作,并在完成之后通知应用程序,由于内核线程的效率较高,所以可以取得很好的效率,但是遗憾的是在*nix中并没有对此种模式直接的支持,而在windows中是可以的。这种方法对于网络IO和磁盘IO都很适合。

应用程序如何处理任务和线程/进程的关系

 以客户端连接服务器的情况来说,主要有下面的几个模型

0. 一个进程服务一个客户端。即每次监听到一个链接就会创建一个进程来服务这个连接。这是(这是Unix采用的经典的方法,从80年代采用直到现在一直在使用)

1. 为每个客户端分配一个OS Level的线程,因为往往进程消耗的资源较多,并且在创建和切换上比较麻烦,所以后来OS开始支持轻量级的进程,也即线程来为每个连接做服务。但是,后来随着硬件资源越来越便宜,进程模型又变的比线程模型要好,因为进程之间不牵扯到资源的同步问题,关于这些这里不做讨论

2. 一个线程处理多个客户端等。


具体的I/O模型

 

下面就来看看5种最流行的IO策略

(注:这里的内容来自于网上其他作者的资源,主有:http://www.360doc.com/content/13/0522/18/1542811_287328391.shtml)


 1. 用一个线程来同时为很多的客户端来服务,非阻塞IO以及水平触发方式(Level-Triggered)的就绪通知

这种方式很简单,它将所有的网络文件句柄的工作模式都设置成NON-BLOCKING,通过调用select()方法或者poll()方法来告诉应用层哪些个网络句柄有正在等待着并需要被处理的数据。这是一种非常传统的方法。通过这种机制,内核能够告诉应用层一个文件描述符是否准备好了(这里的准备好有着明确的含义,对于读描述符,准备好了意味着此时该描述符的缓冲区内数据已经准备好,读取该描述符的数据不会发生阻塞,而对于写描述符而言,准备好了意味着另外一层含义,它意味着写缓冲区已经准备好了,此时对该操作符的写操作也将不会导致任何阻塞发生),以及你是否已经利用该文件描述符作了相应的事情。因为这里的就绪通知方式是水平触发,也就说如果内核通知应用层某一个文件描述符已经就绪,而如果应用层此后一直没有完整的处理该描述符(没有读取完相应的数据或者没有写入任何数据),那么内核会不断地通知应用层该文件描述符已经就绪。这就是所谓的水平触发L-T:只要条件满足,那内核就会触发一个事件(只要文件描述符对应的数据没有被读取或者写入,那内核就不断地通知你)。

需要注意的是:内核的就绪通知只是一个提示,提示也就意味着这个通知消息未必是100%准确的,当你读取一个就绪的读文件描述符时,实际上你有可能会发现这个描述符对应的数据并没有准备好。这就是为什么如果使用就绪通知的话一定要将文件描述符的模式设置成NOBLOCK的,因为NOBLOCK模式的读取或者写入在文件描述符没有就绪的时候会直接返回,而不是引起阻塞。如果这里发生了阻塞,那将是非常致命的,因为我们只有一个线程,唯一的线程被阻塞了的话,那我们就玩完了。

这种方式的一个缺陷就是不适用磁盘文件的IO操作。将磁盘文件的操作句柄的工作模式设置成NOBLOCK是无效的,此时对该磁盘文件进行读写依然有可能导致阻塞。对于缺乏AIO(异步IO)支持的系统,将磁盘IO操作委托给worker线程或者进程是一个好方法来绕过这个问题。一个可行的方法是使用memory mapped file,然后调用mincore(),mincore会返回一个向量来表示相应的page是否在ram缓存中,如果page不在ram缓存中,则意味着读取该页面会导致page falut,从而引起阻塞,那么就需要通过委托的worker线程来进行IO操作。这种方式的实现方法在Linux上就是selectpoll这样的系统调用。


2.使用一个线程同时服务很多个客户端,采用 noblockIO模式以及边沿触发(Edge-Level)的就绪通知。

所谓边沿触发是相对水平触发而言的,也就是说内核只是在文件描述符的状态发生变换的时候才进行通知。这就意味着在大多数情况下,当内核通知某个读描述符就绪后,除非该读描述符内部缓冲区的所有数据已经完全被读取从而使得就绪状态发生了变化,否则内核不会发出任何新的通知,会永远沉默下去。如果该文件描述符的

receive操作返回EWOULDBLOCK错误码,这就意味着该描述符的就绪状态已经被打破,你需要等待下一次的边沿触发通知。

除了上面所说的问题,一旦使用了边沿触发,另外一个随之而来的问题就是,你需要注意一个常见的“意外事件”的问题。因为os实现边沿触发的一个常见的实现bug就是在某些情况下内核一旦收到新数据包就会通知就绪,不管你上一次的就绪通知是否被用户处理。因此你必须小心组织你的代码,你需要处理好每一个就绪通知,如果某一次就绪通知的数据没有被正确得完整得处理你就急急忙忙得开始等待下一次通知,那么下一次的就绪通知就会覆盖掉前面的数据,那么你就会永远不会恢复了。

相比之下,这种方式对于程序员编码的要求可能会更高一些,一旦应用程序错过了一次通知,那么与之对应的客户端就永远崩溃了(意外事件)或者沉默(没有读取完上一次事件产生的数据)。而方式1则会不断提醒用户缓冲区内还有数据。因此,对于边沿触发方式的就绪通知,应用层必须在每次就绪通知后读取数据,一直读到EWOULDBLOCK为止。

这种方式在Linux中主要通过epoll实现。实际上java nio采用的也是这种IO策略。Epollpoll有一些共同之处,epoll在默认情况下也是水平触发的,此时你可以认为epoll是一个增强版的poll,它的效率更高,这是因为epoll采用了一些优化,比如只关心活跃的连接,通过共享内存空间避免了内存拷贝等等。

 


3 .用一个线程同时服务很多个客户端,采用异步IO

这种IO策略实际上Linux并没有原生支持,尽管POSIX定义了它。相比之下windows就提供了很好的支持。异步IO也有内核通知,只不过这种通知不是就绪通知,而是完成通知,这就意味着一旦获得内核通知,那么IO操作就已经完成了,用户无需再调用任何操作来获取数据或者发送数据,此时数据已经好端端得放在用户定义的buffer中或者数据已经妥妥得发送出去了。与前两种方式相比,实际上aio是由内核线程或者底层线程异步地,默默得完成了IO操作,而方式1,方式2还得由用户线程来自己读取数据。相比之下,内核线程自然要高效很多。因此从IO模型的效率上来讲,windows是要优于Linux的。如果专业一点来讲,12这种方式一般被称之为reactor模式,3这种模式被称之为proactor模式。

 
4为每个客户端分配一个线程来进行IO操作

这种IO策略就比较老土了,也就是我们最常用的的一种IO模型,而且这种IO模型已经存在了几十年了。这种策略下,readwrite调用都是阻塞的。它最大的问题就是每个线程都需要占据一个完整的栈帧,这个对内存的考验还是比较大的。而且过多的线程对OS也有很大的压力,很多OS如果有过多的线程其性能会有指数级别的下降。来算一下吧,假设栈帧的空间为2M,那么1G的内存最多服务512个线程,显然和我们的要求10K有不小的差距。当然,由于硬件的资源会越来越便宜,线程的内存开销可能不太会成为瓶颈。但多线程带来的进程切换的开销却有可能会长期存在。这种IO策略的关键在于OS的线程要足够强大,高效。


5 把应用层代码装进内核里

这种方式比较疯狂,如果你的team有足够的人手,而且服务器的需求量也比较大,你其实可以考虑这种方式。用专有的方法来解决问题其实也并非不可以,比如有的公司会把常用的核心算法放到FPGA或者ASIC芯片上去来解决问题,这两者的思路其实是如出一辙。对于Linux来讲,其实社区的意见还是不倾向于这么做,原因也很好理解,在内核中为应用开一个口子怎么看都不像一个好主意,一个更好的思路还是尽可能让用户空间的程序更快吧,别动不动就塞进内核里来。

你可能感兴趣的:(IO,并发编程,C10K)