什么是IO
首先需要了解下什么是IO,IO就是读入/写出数据的过程,和等待读入/写出数据的过程。
举个列子,应用程序想要将数据写到操作系统磁盘文件中,是需要将数据从用户空间拷贝到操作系统内核空间,再由内核空间将数据写入到磁盘中。读取也是一样,都需要经过内核空间。这里主要将网络的IO。
系统调用
网络IO模型
上图为网络IO模型
BIO
顾名思义,Blocking IO,阻塞IO,以前传统的网络IO为阻塞IO,Java代码如下:
代码语言:javascript复制public class BioServerTest {
static ExecutorService threadPool = Executors.newCachedThreadPool();
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(8082);
while (true) {
Socket accept = socket.accept();//可能出现阻塞
threadPool.submit(() -> {
byte[] request = new byte[1024];
accept.getInputStream().read(request);//可能出现阻塞
Thread.sleep(60000); //测试模拟阻塞
System.out.println(new String(request));
return request.toString();
});
}
}
}
如上,在获取客户端accept
和读取数据read
时会出现阻塞。这时其他客户端就会无法连接,从而导致连接的浪费(一个客户端是一个线程)。
我们将代码在读取的时候阻塞了60s,客户端一连接,输入数据,这里阻塞。客户端二也会阻塞。
客户端1释放后,客户端2才能继续。
因为阻塞的API设计,存在线程资源浪费的情况 每一个请求都有一个线程处理 不管连接有没有数据传输,我都安排一个线程去处理
NIO
非阻塞IO,就是为了解决BIO产生的问题。早先的NIO是将多个客户端放入一个集合中,应用程序轮番遍历,读取数据。
上图(白嫖的)为普通NIO的模型,这里有个问题,虽然不是BIO了,不会产生阻塞,但是如果有10万个客户端,应用程序要轮询10万次客户端并且read数据,这里read数据是调用了内核的,发生了系统调用10万次。用户态到内核态的切换需要成本,如果切换过于频繁,有损系统性能。
多路复用
Selector NIO
随着技术的发展,人们想到,可以通过一次系统调用,将客户端连接放入操作系统内核,返回可读的连接给应用程序。应用程序自己读写。
如上图,只需一次selector系统调用,accept连接放入了内核。
Java代码如下:
代码语言:javascript复制public class SocketSelectorSingleThread {
private ServerSocketChannel server = null;
private Selector selector = null;
int port = 9090;
public void start() {
initServer();
System.out.println("服务器启动了.....");
try {
while (true) {
while (selector.select(0) > 0) {//访问内核有没有事件
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
acceptHandler(key);
} else if (key.isReadable()) {
readHandler(key);
}
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
public void initServer() {
try {
server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(port));
selector = Selector.open();
server.register(selector, SelectionKey.OP_ACCEPT);
} catch (IOException e) {
e.printStackTrace();
}
}
public void acceptHandler(SelectionKey key) {
try {
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel client = ssc.accept();
client.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(8192);
client.register(selector, SelectionKey.OP_READ, buffer);
System.out.println("------------------------");
System.out.println("新客户端: " client.getRemoteAddress());
System.out.println("------------------------");
} catch (IOException e) {
e.printStackTrace();
}
}
public void readHandler(SelectionKey key) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.clear();
int read = 0;
try {
while (true) {
read = client.read(buffer);
if (read > 0) {
buffer.flip();
while (buffer.hasRemaining()) {
client.write(buffer);
System.out.println("读到客户端数据: " new String(buffer.array()));
}
buffer.clear();
} else if (read == 0) {
break;
} else {
client.close();
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
SocketSelectorSingleThread server = new SocketSelectorSingleThread();
server.start();
}
}
测试结果如下:
技术发展到这里可能会觉得很不错了,只发生了一次系统调用,但其实还有问题。每新增一个客户端,select函数会将所有的客户端加载进入内核,这个过程并不优雅。还有每次内核都要循环遍历O(n)次,对于内核来说是不是还有可优化的空间呢。
Epoll NIO
epoll的多路复用有什么优点呢,先来一张图:
如上图,将之前的select函数换成了epoll函数,epoll函数发生的系统调用,内核会开辟出两个空间,会将客户端连接放入缓存空间1,当有数据时,通过事件驱动将有值的连接放入缓存空间2,应用程序获取有值的连接。内核中的时间复杂度为O(1)。
总结
BIO:如果其中有一个客户端阻塞,其他客户端是无法获取连接,BIO采用的办法是多线程,每个线程是一个客户端,如果,一个线程阻塞切换到另外一个线程。问题是:线程创建耗内存,如果线程很多,不划算另外,线程的切换也是有耗性能的 NIO:N个客户端连接放入集合中,应用程序读取数据时,循环遍历客户端,应用程序方面发生的系统调用是O(n) 多路复用器:selector,将n个客户端连接通过多路复用器,放入操作系统内核中,让内核进行遍历有数据的客户端数据,在应用程序方面,发生的系统调用是O(1),但内核中的遍历时间复杂度是O(n);epoll,有事件驱动,内核中只会遍历有数据的客户端,内核遍历客户端的时间 复杂度为O(1)
多路复用很多中间件都有使用,如kafka,redis,nginx等。