文章概述
select,poll,epoll这3个函数看了很多博客,但一直没弄懂多路复用是怎么回事。后来在b站上看到netty的视频,没想到学这个的时候弄懂了,所以想在这篇文章中记录下自己对多路复用的理解。我想先从最简单的BIO模型讲起,然后讲下非阻塞型的IO,也就是Nonblocking IO,接下来再来说select、poll、epoll。完整的代码可以见https://github.com/zacharytse/NIOLearn.
BIO
BIO,Blocking IO。BIO的服务器模型完整流程应该是首先创建Socket,接下来bind,然后listen,接下来accept,最后read。这个流程可以看下面的图
其中accept(),read()和write()函数默认情况下是阻塞的。代码实现如下:
public void init() throws IOException {
serverSocket = new ServerSocket(Constants.port);
while (true) {
Socket client = serverSocket.accept();
System.out.println("Get connected from client " + client.getPort());
//进行数据的读写
handleConnect(client);
}
}
在这种模型中,服务器端每接受一个连接就需要等着这个连接发数据(服务器线程因为read()/write()被阻塞),其他的客户端是没有办法连上这个服务器的。一种解决方案是将读写操作放到子线程上操作,代码如下:
private class ClientHandler implements Runnable {
private Socket client;
public ClientHandler(Socket client) {
this.client = client;
}
/**
*执行读写操作的逻辑
**/
@Override
public void run() {
DataInputStream in = null;
try {
in = new DataInputStream(client.getInputStream());
byte[] buf = new byte[1024];
in.read(buf);
System.out.println("Get content from client: " + new String(buf));
} catch (IOException e) {
e.printStackTrace();
} finally {
if(client != null) {
try {
client.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
但为每一个连接都创建一个子线程去完成读写任务开销无疑过大,所以我们引入了NIO(非阻塞模型)
NIO
这里指的是非阻塞模型。BIO模型之所以无法在单线程中处理多个连接就是因为accept(),read(),write()函数阻塞了线程。所以我们考虑将这些函数设置为非阻塞的。每一次轮询,如果有连接那么就把这个新来的连接加入到队列中。在做读写操作时遍历整个队列,判断下每个连接有没有数据需要读取。代码如下:
private void handle() throws IOException {
for (SocketChannel channel : channels) {
int ret = channel.read(buf);//这里的read会使用系统调用
if(ret > 0) {
buf.flip();
byte[] res = new byte[buf.limit()];
buf.get(res);
System.out.println("Receive content:" + new String(res));
buf.clear();
} else if(ret == 0) {
continue;
} else {
channel.close();
}
}
}
根据上面的代码可以发现我们是通过 channel.read()函数的返回值来判断客户端是否发送了数据。但是channel.read()函数底层进行了一次系统调用,这会使得程序由用户态向核心态切换。而读写操作每次都需要遍历整个队列,也就是说有n个连接,我们就需要执行n次系统调用,就需要进行n次用户态到核心态的切换。可以参考下面的图片
那么有没有办法变成下面这样
只需要一次系统调用就知道哪些连接发生了读写行为。这就是select,poll和epoll干的事了。它们就是OS为我们提供的实现上述功能的系统调用
select,poll和epoll
如上图所示,select,poll和epoll都是在kernel中选出了发生读写行为的文件描述符并将最后的结果一次性返回给application。但这3个函数在底层实现时各有不同。
首先我们来看下select。select原型如下:
int select(int maxfdp,fd_set *readfds,fd_set *writefds,fd_set *errorfds,struct timeval*timeout);
第一个参数指定文件描述符的范围,后面3个参数存储了发生了读写行为的文件描述符,最后一个参数指定了超时时间。
select底层维护了一个文件描述符的数组,这个数组的最大大小为1024。每次调用select时它都会遍历整个数组并将发生了读写行为的文件描述符写入结果集中。
poll底层实现和select是一致的,只不过select底层维护的是文件描述符数组,而poll维护了一个文件描述符链表。也就说select的最大同时连接数是有限制的而poll是没有限制的。
但是这两个函数每次调用都需要遍历所有的文件描述符,时间复杂度为O(n)。epoll的存在就是为了在O(1)时间内找到所有发生读写行为的文件描述符。
epoll基于事件驱动机制实现,它不用遍历所有的文件描述符。当某个文件描述符对应的连接发生了读写行为时会通过回调事件将该文件描述符加入到结果集中,epoll调用时只需要返回这个结果集即可。
这里我用java中的Selector去演示下
private void doAction() throws IOException {
int readyNum = selector.select();
if (readyNum == 0) {
return;
}
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iter = selectionKeys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
if (key.isAcceptable()) {//accept
accept(key);
} else if (key.isReadable()) {//read
read(key);
}
iter.remove();
}
}
总结
所谓的NIO是为了解决BIO阻塞影响并发量的问题,而NIO会频繁的陷入内核态,因此我们才需要select,poll和epoll将本来的n次系统调用查询变为1次系统调用查询