IO多路复用
- 为什么要使用IO多路复用
- Linux的IO多路复用接口
- select
- poll
- epoll
为什么要使用IO多路复用
我们常用的IO模型是BIO,我们Java里的IO流大多数都是BIO,也就是同步阻塞式IO,这种IO操作的好处是简单方便,但是缺点也很明显——性能不高。
阻塞式IO的特点就是在数据未就绪前,要等待数据就绪。比如以网络IO为例,在客户端没有发送数据前,服务端调用read函数会阻塞等待客户端发送数据,当客户单发送的数据到达服务端网卡缓存区,并被DMA拷贝到操作系统内核时,服务端的用户线程才开始拷贝数据到用户空间。由于BIO是同步阻塞IO,这个“同步”就是把内核空间的数据拷贝到用户空间的这个工作由用户线程自己完成,在数据拷贝期间用户线程依然是阻塞的。
于是就有了NIO,NIO是同步非阻塞式IO,在客户端未发送数据前,服务端用户线程线程调用read()函数不会阻塞,而是马上返回一个error,用户线程可以先干点别的事情,然后再次尝试read(),如果此时数据就绪,那么read()函数会阻塞,用户线程把内核空间的数据拷贝到用户空间。
NIO比起BIO来说性能有所提供,在数据未就绪时不需要阻塞等待。由于数据未就绪时不会阻塞当前线程,因此把所有需要监听的Socket套接字收集到一个集合当中,当前线程可以轮询这个套接字集合,哪个套接字就绪就阻塞读取哪个套接字的数据。这样就由一个线程监听一个Socket变成一个线程监听多个Socket。
但是NIO需要用户线程不断轮询,这是非常消耗CPU资源的一种操作,效率也不高。我们应该让当前线程阻塞监听多个socket,如果有一个或多个socket就绪,当前线程才解阻塞,然后我们可以把就绪socket的读取操作提交给线程池执行,这样效率就得到提升。于是就是有了IO多路复用。
我们使用IO多路复用时,通常要先注册需要监听的socket文件描述符以及在该socket上关注的事件(读就绪事件、写就绪事件、连接就绪事件)。
操作系统内核会使用对应的数据结构保存注册进来的socket文件描述符,当对应的socket有事件就绪时,操作系统会通知阻塞的用户线程;用户线程接收到通知后会解阻塞,用户线程解阻塞后可以自己操作有事件就绪的Socket,也可以提交到线程池处理。
为什么IO多路复用会比BIO和NIO的性能高呢?原因有两点:
- IO多路复用可以一个线程监听多个Socket。
- IO多路复用的read操作都是有效的read,是收到操作系统内核的Socket已就绪通知时才发起的read操作,没有了等待数据就绪期间的阻塞。
Linux的IO多路复用接口
Linux提供的IO多路复用接口一共有select、poll、epoll三种。
select
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval
*timeout);
select函数向操作系统注册需要监听的文件描述符数组,数组最大上限被限制为1024(默认1024,可以修改),Linux操作系统内核会保存这个文件描述符数组。注册的数组分三个,分别是关注读就绪事件的readfds,关注写就绪事件的writefds,关注异常事件的exceptfds。
当调用select函数后,当前线程就处于阻塞状态。当数组中的某个或某几个socket有事件发生时,当前线程会解阻塞,然后当前线程需要遍历数组中的每一个socket文件描述符,判断它是否有事件发生。
使用select函数的IO多路复用是比BIO和NIO的性能高不少,但是存在两个缺点:
- socket文件描述符数组长度有限制,意味着监听的socket有上限。
- 当有socket就绪时,用户线程并不知道数组中哪些socket文件描述符就绪,需要遍历。
poll
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
poll使用pollfd结构体存在需要监听的socket文件描述符以及关注的事件。
当前线程调用poll函数,会以pollfd数组的形式向操作系统内核注册需要监听的socket文件描述符。
调用了poll函数之后,当前线程阻塞。当某个或某几个pollfd中的socket文件描述符有事件就绪时,操作系统内核会通知当前线程,当前线程解阻塞。当前线程解阻塞之后,还是需要遍历pollfd数组,判断每一个pollfd是否关注的事件已就绪。
poll解决了select有监听socket文件描述符上限的问题,理论上poll可以监听无数个socket。但是与select一样,用户线程并不知道哪些socket就绪,还是需要遍历数组,因此效率还是有待提高。因此就有了epoll这种不需要遍历的IO多路复用接口。
epoll
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
相比与select与poll接口的只有一个函数,epoll有三个函数——epoll_create、epoll_ctl、epoll_wait。
epoll_create创建一个epoll实例,epoll实例使用一个红黑树结构保存注册进来的socket文件描述符,然后使用一个链表保存就绪的socket文件描述符,此时epoll实例还是空的。
epoll_ctl函数则是向epoll实例添加需要监听的socket文件描述符以及关注的事件类型,socket描述符以及关注的事件类型被包装在epoll_event结构体中。
epoll_wait函数是阻塞等待注册到epoll实例的一个或多个文件描述符就绪。当epoll实例中的某个socket有事件就绪时,会把对应的epoll_event拷贝到epoll实例中的链表结构中。当用户线程调用epoll_wait函数时,如果epoll实例中的链表结构没有就绪的epoll_event时,会阻塞等待,如果epoll_event非空,则把这个链表拷贝到用户空间,此时用户线程就可以逐一处理链表中就绪的socket,无需遍历判断是否就绪。
epoll解决了监听的socket有上限和需要遍历判断socket是否就绪的两个问题,大大提供了Linux里IO多路复用的性能。