什么是C10K问题,聊聊网络IO模型如何优化该问题
早期的操作系统中,当外部连接请求进来是,都是需要创建一个进程来处理连接,也就是传统的同步阻塞I/O模型处理方式。随着互联网的普及,应用的用户群体几何倍增长,此时服务器性能问题就会出现。当创建的进程多了,用于进程是同步阻塞的,所以带来大量上下文切换,导致系统性能降低。
假如有10K个连接请求进来,就需要创建1W个进程,可想而知单机是无法承受的。那么如何突破单机性能是高性能网络编程必须要面对的问题,这类问题称为C10K问题。
开始的阻塞式I/O,它在每一个连接创建时,都需要一个用户线程来处理,并且在I/O操作没有就绪或结束时,线程会被挂起,进入阻塞等待状态,阻塞式I/O就成为了导致性能瓶颈的根本原因。
那阻塞到底发生在套接字(socket)通信的哪些环节呢?
在《Unix网络编程》中,套接字通信可以分为流式套接字(TCP)和数据报套接字(UDP)。其中TCP连接是我们最常用的,一起来了解下TCP服务端的工作流程(由于TCP的数据传输比较复杂,存在拆包和装包的可能,这里我只假设一次最简单的TCP数据传输):
首先,应用程序通过系统调用socket创建一个套接字,它是系统分配给应用程序的一个文件描述符;
然后,系统会调用listen创建一个队列用于存放客户端进来的连接;
最后,应用服务会通过系统调用accept来监听客户端的连接请求。
当有一个客户端连接到服务端之后,服务端就会调用fork创建一个子进程,通过系统调用read监听客户端发来的消息,再通过write向客户端返回信息。
1、阻塞式I/O
在整个socket通信工作流程中,socket的默认状态是阻塞的。也就是说,当发出一个不能立即完成的套接字调用时,其进程将被阻塞,被系统挂起,进入睡眠状态,一直等待相应的操作响应。
从上图中,我们可以发现,可能存在的阻塞主要包括以下三种:
connect阻塞:当客户端发起TCP连接请求,通过系统调用connect函数,TCP连接的建立需要完成三次握手过程,客户端需要等待服务端发送回来的ACK以及SYN信号,同样服务端也需要阻塞等待客户端确认连接的ACK信号,这就意味着TCP的每个connect都会阻塞等待,直到确认连接;
accept阻塞:一个阻塞的socket通信的服务端接收外来连接,会调用accept函数,如果没有新的连接到达,调用进程将被挂起,进入阻塞状态;
read、write阻塞:当一个socket连接创建成功之后,服务端用fork函数创建一个子进程, 调用read函数等待客户端的数据写入,如果没有数据写入,调用子进程将被挂起,进入阻塞状态。
2、非阻塞式I/O
使用fcntl可以把以上三种操作都设置为非阻塞操作。如果没有数据返回,就会直接返回一个EWOULDBLOCK或EAGAIN错误,此时进程就不会一直被阻塞。
当我们把以上操作设置为了非阻塞状态,我们需要设置一个线程对该操作进行轮询检查,这也是最传统的非阻塞I/O模型。
3、I/O复用
如果使用用户线程轮询查看一个I/O操作的状态,在大量请求的情况下,这对于CPU的使用率无疑是种灾难。那么除了这种方式,还有其它方式可以实现非阻塞I/O套接字吗?
Linux提供了I/O复用函数select/poll/epoll,进程将一个或多个读操作通过系统调用函数,阻塞在函数操作上。这样,系统内核就可以帮我们侦测多个读操作是否处于就绪状态。
select()函数:它的用途是,在超时时间内,监听用户感兴趣的文件描述符上的可读可写和异常事件的发生。Linux 操作系统的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个文件描述符(fd)。
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
查看以上代码,select() 函数监视的文件描述符分3类,分别是writefds(写文件描述符)、readfds(读文件描述符)以及exceptfds(异常事件文件描述符)。
调用后select() 函数会阻塞,直到有描述符就绪或者超时,函数返回。当select函数返回后,可以通过函数FD_ISSET遍历fdset,来找到就绪的描述符。fd_set可以理解为一个集合,这个集合中存放的是文件描述符,可通过以下四个宏进行设置:
void FD_ZERO(fd_set *fdset); //清空集合
void FD_SET(int fd, fd_set *fdset); //将一个给定的文件描述符加入集合之中
void FD_CLR(int fd, fd_set *fdset); //将一个给定的文件描述符从集合中删除
int FD_ISSET(int fd, fd_set *fdset); // 检查集合中指定的文件描述符是否可以读写
poll()函数:在每次调用select()函数之前,系统需要把一个fd从用户态拷贝到内核态,这样就给系统带来了一定的性能开销。再有单个进程监视的fd数量默认是1024,我们可以通过修改宏定义甚至重新编译内核的方式打破这一限制。但由于fd_set是基于数组实现的,在新增和删除fd时,数量过大会导致效率降低。
poll() 的机制与 select() 类似,二者在本质上差别不大。poll() 管理多个描述符也是通过轮询,根据描述符的状态进行处理,但 poll() 没有最大文件描述符数量的限制。
epoll()函数:select/poll是顺序扫描fd是否就绪,而且支持的fd数量不宜过大,因此它的使用受到了一些制约。
Linux在2.6内核版本中提供了一个epoll调用,epoll使用事件驱动的方式代替轮询扫描fd。epoll事先通过epoll_ctl()来注册一个文件描述符,将文件描述符存放到内核的一个事件表中,这个事件表是基于红黑树实现的,所以在大量I/O请求的场景下,插入和删除的性能比select/poll的数组fd_set要好,因此epoll的性能更胜一筹,而且不会受到fd数量的限制。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event event)
通过以上代码,我们可以看到:epoll_ctl()函数中的epfd是由 epoll_create()函数生成的一个epoll专用文件描述符。op代表操作事件类型,fd表示关联文件描述符,event表示指定监听的事件类型。
一旦某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知,之后进程将完成相关I/O操作。
int epoll_wait(int epfd, struct epoll_event events,int maxevents,int timeout)
4、信号驱动式I/O
信号驱动式I/O类似观察者模式,内核就是一个观察者,信号回调则是通知。用户进程发起一个I/O请求操作,会通过系统调用sigaction函数,给对应的套接字注册一个信号回调,此时不阻塞用户进程,进程会继续工作。当内核数据就绪时,内核就为该进程生成一个SIGIO信号,通过信号回调通知进程进行相关I/O操作。
信号驱动式I/O相比于前三种I/O模式,实现了在等待数据就绪时,进程不被阻塞,主循环可以继续工作,所以性能更佳。
而由于TCP来说,信号驱动式I/O几乎没有被使用,这是因为SIGIO信号是一种Unix信号,信号没有附加信息,如果一个信号源有多种产生信号的原因,信号接收者就无法确定究竟发生了什么。而 TCP socket生产的信号事件有七种之多,这样应用程序收到 SIGIO,根本无从区分处理。
但信号驱动式I/O现在被用在了UDP通信上,我们从10讲中的UDP通信流程图中可以发现,UDP只有一个数据请求事件,这也就意味着在正常情况下UDP进程只要捕获SIGIO信号,就调用recvfrom读取到达的数据报。如果出现异常,就返回一个异常错误。比如,NTP服务器就应用了这种模型。
5、异步I/O
信号驱动式I/O虽然在等待数据就绪时,没有阻塞进程,但在被通知后进行的I/O操作还是阻塞的,进程会等待数据从内核空间复制到用户空间中。而异步I/O则是实现了真正的非阻塞I/O。
当用户进程发起一个I/O请求操作,系统会告知内核启动某个操作,并让内核在整个操作完成后通知进程。这个操作包括等待数据就绪和数据从内核复制到用户空间。由于程序的代码复杂度高,调试难度大,且支持异步I/O的操作系统比较少见(目前Linux暂不支持,而Windows已经实现了异步I/O),所以在实际生产环境中很少用到异步I/O模型。
在I/O复用模型中,执行读写I/O操作依然是阻塞的,在执行读写I/O操作时,存在着多次内存拷贝和上下文切换,给系统增加了性能开销。零拷贝是一种避免多次内存复制的技术,用来优化读写I/O操作。
从上图中,我们可以发现,当我们需要完成一次网络请求时,需要read和write,在网络编程中,通常由read、write来完成一次I/O读写操作。每一次I/O读写操作都需要完成四次内存拷贝,路径是网卡->内核空间->用户空间->内核空间->网卡。
这里我们要强调下,在Java的NIO编程中,是使用到了Direct Buffer来实现内存的零拷贝并不是我们上述提到的零拷贝,而是MappedByteBuffer使用到了mmap方法实现以上的零拷贝。
我们知道数据要输出到外部设备,必须先从用户空间复制到内核空间,再复制到输出设备,而在Java中,在用户空间中又存在一个拷贝,那就是从Java堆内存中拷贝到临时的直接内存中,通过临时的直接内存拷贝到内存空间中去。此时的直接内存和堆内存都是属于用户空间。
你肯定会在想,为什么Java需要通过一个临时的非堆内存来复制数据呢?如果单纯使用Java堆内存进行数据拷贝,当拷贝的数据量比较大的情况下,Java堆的GC压力会比较大,而使用非堆内存可以减低GC的压力。
DirectBuffer则是直接将步骤简化为数据直接保存到非堆内存,从而减少了一次数据拷贝。
往期推荐