介绍
IO是指系统和外围设备进行数据交换。主要包括磁盘IO、网络IO以及一些其它的如鼠标、显示器等外网设备IO。本文中讨论的BIO和NIO特指网络IO,其它的IO暂时先不聊。
BIO
BIO 顾名思义就是 Blocking IO, 翻译过来就是阻塞型IO。到底什么是BIO就看一段代码吧:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
/**
* Function: BIO 示例代码
*/
public class SocketBio {
private static final int PORT = 9090;
private static final int BUFFER_SIZE = 4096;
public static void main(String[] args) throws Exception {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(PORT));
// 死循环 监听是否有新的连接
while (true) {
System.out.println("wait to be connected.");
// 会阻塞
SocketChannel client = serverSocketChannel.accept();
System.out.println("Accept a client: " + client.socket().getPort());
// 每次有新的连接来就需要new一个线程去处理请求
Thread clientHandler = new Thread(() -> handleConnect(client));
clientHandler.start();
}
}
private static void handleConnect(SocketChannel client) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
System.out.println("handle client message.");
try {
// 会阻塞
client.read(byteBuffer);
System.out.println("read something.");
// do something.
client.close();
} catch (IOException ignore) {
}
System.out.println("handle client message finished.");
}
}
NIO
那相对的NIO意思就是 Non-blocking IO, 让我们也直接看一段示例代码吧
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
/**
* Function: 初学NIO示例
*/
public class SocketNio {
private static final int PORT = 9090;
private static final int BUFFER_SIZE = 64 * 4096;
public static void main(String[] args) throws Exception {
List<SocketChannel> clientList = new LinkedList<>();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(PORT));
// 设置为非阻塞
serverSocketChannel.configureBlocking(false);
while (true) {
System.out.println("wait to be connected.");
SocketChannel client = serverSocketChannel.accept();
if (null != client) {
// 对应的连接也设置为非阻塞
client.configureBlocking(false);
clientList.add(client);
System.out.println("Accept a client: " + client.socket().getPort());
}
Iterator<SocketChannel> iterator = clientList.iterator();
while (iterator.hasNext()) {
SocketChannel socketChannel = iterator.next();
boolean res = handleConnect(socketChannel);
if (res) {
iterator.remove();
}
}
Thread.sleep(5000);
}
}
private static boolean handleConnect(SocketChannel client) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
System.out.println("handle client message.");
try {
// 不会阻塞
int res = client.read(byteBuffer);
if (res > 0) {
// do something.
System.out.println("read something.");
client.close();
return true;
}
} catch (IOException ignore) {
return true;
}
return false;
}
}
NIO和BIO的区别
从上面的例子可以看到两者的区别。对于BIO由于每一个连接的内容读取(read
),以及建立连接的监听(accept
),都会阻塞住当前的线程,所以我们不得不去创建更多的线程来处理每一个连接。随着连接增多,线程就越来越多。就会带来两个主要问题:
- 大量的线程就会占用一部分内存作为内存栈。
- 并且线程的增加也会导致线程间切换浪费了大量CPU资源。
而NIO不会阻塞线程,如果调用(accept
read
)时没有连接或者数据,会直接返回(-1 或者 0) 。这样我们就可以通过一个线程来做更多的事情,解决了BIO带来的问题。但同时,NIO也存在着新的问题:
- 如果服务端建立了大量的连接。每个连接去读取数据时都需要调用
read
系统调用(需要用户态、内核态的切换)。而大多数的连接可能并没有数据写入,这时候就会造成大量CPU资源的浪费。
所以,IO多路复用就应运而生了。
IO多路复用
多路复用复用的是啥?
select
如上文说到的,NIO中会出现一个问题。就是当连接数多的时候,针对每一个连接都需要调用read
来读取当前连接是否有新的数据包。举个比较极端的例子,当前服务端有1000个连接,其中只有5个连接有新的数据包。此时,NIO进行了1000次read
系统调用,只有5次是真正的有效读。
所以,为了解决这个问题,内核提供了select系统调用。 `select``的函数签名如下:
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
其中,可以看到它提供了三个文件描述符的集合。分别监听读取,写入和异常事件。nfds是表示传入的三个文件描述符列表中最大的文件描述符编号+1(主要是用来提前结束内核循环的,不重要,忽略吧)。然后,最后一个是阻塞时间。调用select
以后,程序会阻塞,直到监听的文件描述符中有对应的IO事件到达,或者达到了参数中给定的最大阻塞时间(也可以通过设置,表示一直阻塞,直到有对应的IO事件就绪)。如果有对应的IO事件就绪了,select
会更新对应的文件描述符。告知当前这个文件描述符是否是IO ready状态的。如果是ready状态的话,就需要通过read
调用对对应的连接进行数据的读取。那么在上面那个极端的例子中,这里一共经历了1次select
和5次read
【简化模型,不要较真!我也不清楚中间是否还有其它操作。TT】
select
存在几个明显的缺点:
- 文件描述符列表存在长度限制(32位系统为1024,64位操作系统位2048)
- 对于文件描述符的监听,分成了三个列表。且每一次调用
select
时,都需要重新构建这三个列表(select
过程中会改变数组的内容)。每次调用select
都需要将这三个数组从用户态复制一份到内核态。 - 事件到达后,需要遍历文件描述符数组。通过
FD_ISSET()
判断是否有监听的事件到达。 - 每次调用
select
,内核也需要通过遍历,来获取对应的文件描述符中是否有事件到达,感觉效率比较低。
poll
poll
其实和select
差不多,参考poll。函数签名如下:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
poll
相对于select
主要优化了几个点
- 没有了文件描述符列表长度限制(1024)
- 将三个文件描述符数组改成了一个pollfd结构的数组,构建起来相对比较方便。
但是,select
遗留的其它问题依旧没有得到解决。
epoll
epoll
就比较牛逼了,也被称为事件驱动
使用epoll也就是对应这三个系统调用:
epoll包含了三个系统调用 epoll_create, epoll_ctl, epoll_wait
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);
epoll_create 函数创建一个epoll文件描述符,参数size表明内核要监听的描述符数量(使用过程中会根据使用情况动态调整,不用太在意)。返回一个epoll文件描述符。
epoll_ctl 函数注册要监听的事件类型。四个参数解释如下:
epfd 是epoll_create 返回的文件描述符
op 表示fd操作类型,有如下3种
- EPOLL_CTL_ADD 注册新的fd到epfd中
- EPOLL_CTL_MOD 修改已注册的fd的监听事件
- EPOLL_CTL_DEL 从epfd中删除一个fd
fd 是要监听的描述符
event 表示要监听的事件
epoll_wait 函数等待事件的就绪,成功时返回就绪的事件数目,等待超时返回 0。
epfd 对应epoll_create 返回的文件描述符
events 表示从内核得到的就绪事件集合
maxevents 告诉内核events的大小
timeout 表示等待的超时事件
epoll比select和poll强在哪呢? select和poll每次都需要把需要监听的文件描述符列表传入到内核,内核每次都需要遍历获取对应连接的状态。而epoll完全是反过来的。epoll在内核的数据被建立好了之后,每次某个被监听的文件描述符一旦有事件发生,内核就会做个标记表示有事件到达。epoll_wait调用时,会尝试直接读取到当时已经标记好的fd列表,如果没有就会进入等待状态。所以epll_wati的时间复杂度可以看作是O(1), 而Select和Poll的时间复杂度都是O(n)
同时,epoll_wait直接只返回了有事件到达的文件描述符列表。这样上层应用处理起来也轻松愉快,不需要从大量注册的文件描述符中筛选出有事件到达的文件描述符。