阿里妹导读
从一次优化说起
IO模型分类
分类
举例
自选餐线,我们点餐的时候都得在队伍里排队等待,必须等待前面的同学打好菜才到我们,这就是同步阻塞模型BIO。
麻辣烫餐线,会给我们发个叫号器,我们拿到叫号器后不需排队原地等待,我们可以找个地方去做其他事情,等麻辣烫准备好,我们收到呼叫之后,自行取餐,这就是同步非阻塞。
包厢模式,我们只要点好菜,坐在包厢可以自己玩,等到饭做好,服务员亲自送,无需自己取,这就是异步非阻塞I/O模型AIO。
概念详解
端菜/送菜->数据读取
菜没好,是否原地等待->数据就绪前是否等待;
非阻塞:请求直接返回,本质上线程活跃,可以处理其他事情;
菜好了,谁端?->数据就绪,是操作系统送过去,还是应用程序自己读取;
同步:数据就绪后应用程序自己读取;
异步:数据就绪后操作系统直接回调应用程序;
Java支持版本
对AIO支持最好的是Windows系统,但是很少用Windows做服务器;
linux常用来做服务器,但AIO的实现不够成熟;
linux下AIO相比NIO性能提升并不明显;
维护成本过高;
实战
c10k问题
上代码
public class C10kTestClient {
static String ip = "192.168.160.74";
public static void main(String[] args) throws IOException {
LinkedList<SocketChannel> clients = new LinkedList<>();
InetSocketAddress serverAddr = new InetSocketAddress(ip, 9998);
IntStream.range(20000, 50000).forEach(i -> {
try {
SocketChannel client = SocketChannel.open();
client.bind(new InetSocketAddress(ip, i));
client.connect(serverAddr);
System.out.println("client:" + i + " connected");
clients.add(client);
} catch (IOException e) {
System.out.println("IOException" + i);
e.printStackTrace();
}
});
System.out.println("clients.size: " + clients.size());
//阻塞主线程
System.in.read();
}
}
首先出场的是BIO选手
public class BIOServer {
public static void main(String[] args) throws IOException {
ServerSocket server = new ServerSocket(9998, 20);
System.out.println("server begin");
while (true) {
//阻塞1
Socket client = server.accept();
System.out.println("accept client" + client.getPort());
new Thread(() -> {
InputStream in;
try {
in = client.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
while (true) {
//阻塞2
String data = reader.readLine();
if (null != data) {
System.out.println(data);
} else {
client.close();
break;
}
}
System.out.println("client break");
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
}
}
该代码如果直接在mac本地跑,一般情况下会报如下错误:
too many open files,这里说明打开的文件描述符过多,什么是文件描述符?文件描述符( file descriptor简称fd,请记住这个概念,后续会多次出现)在linux中,一切皆文件。实际上,它是一个索引值,指向一个文件记录表,该表记录内核为每一个进程维护的文件记录信息。由于本例中创建了三万个socket,而一个socket(即一个tcp连接)就对应一个文件描述符(fd),30000已经超过了系统默认的文件描述符限制。那怎么去查看fd信息呢?可通过lsof -i -a -p [pid]查看当前进程打开的tcp相关的文件描述符,如下图,红框标注的就是socket对应的fd:
解决这个问题也很简单,可通过如下方式设置文件描述符数量
https://juejin.cn/s/java.net.socketexception%20too%20many%20open%20files%20macos
处理完文件描述符过多的问题之后,继续重新跑客户端,又报错了。
unable to create new native thread 不能再创建新的本地线程。当然,系统线程数限制是可以调节的。但是存在的问题也很明显,具体有哪些问题呢?从上面的分析,结合BIO处理c10k的过程,不难得出以下问题:
所以BIO存在的核心问题是阻塞导致多线程,如何解决?那就是非阻塞+少量线程。
接下来有请NIO选手闪亮登场:
public class NIOServer {
public static void main(String[] args) throws IOException, InterruptedException {
LinkedList<SocketChannel> clients = new LinkedList<>();
//服务端开启监听
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(9998));
//设置操作系统 级别非阻塞 NONBLOCKING!!!
serverSocketChannel.configureBlocking(false);
while (true) {
//接受客户端的连接
Thread.sleep(500);
/**
* accept 调用了内核,
* 在设置configureBlocking(false) 及非阻塞的情况下
* 若有客户端连进来,直接返回客户端,
* 若无客户端连接,则返回null
* 设置成NONBLOCKING后,代码不阻塞,线程不挂起,继续往下执行
*/
SocketChannel client = serverSocketChannel.accept();
if (client == null) {
// System.out.println("null.....");
} else {
/**
* 重点,设置client读写数据时非阻塞
*/
client.configureBlocking(
false);
int port = client.socket().getPort();
System.out.println("client..port: " + port);
clients.add(client);
}
ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
//遍历所有客户端,不需要多线程
for (SocketChannel c : clients) {
//不阻塞
int num = c.read(buffer);
if (num > 0) {
buffer.flip();
byte[] aaa = new byte[buffer.limit()];
buffer.get(aaa);
String b = new String(aaa);
System.out.println(c.socket().getPort() + " : " + b);
buffer.clear();
}
}
}
}
}
NIO相比BIO有个非常牛逼的特性,即设置非阻塞。可通过 java.nio.AbstractSelectableChannel 下的configureBlocking方法,调用内核,设置当前socket接收客户端连接,或者读取数据为非阻塞(BIO中这两个操作都为阻塞),啥是socket?socket就是TPC连接的抽象,客户端client.connect(serverAddr); 实际上底层就会调用系统内核就处理三次握手,建立tcp连接。
从代码中不难看出,相比BIO,NIO的优势:
建立连接和读写数据非阻塞
那它是不是就是完美解决方案了呢?如果你细心看以上37行到48行代码,就会发现,只要有一个连接进来,就不管三七二十一遍历所有客户端,调用系统调用read方法。实际情况可能并没有客户端有数据到达,这就产生了一个新的问题。
如果你来设计,你会想着怎么优化呢?
其实也比较容易,就是有没有办法,我不要用去遍历所有客户端,因为10k个客户端我就得调用10k次系统调用,就得产生10k次用户态和内核态的来回切换(回顾下计算机组成原理,感受下这个资源消耗),而只调用一次内核就能知道哪些连接有数据。嗯,Linus Torvalds(linux之父)也是这样想的,所以就出现了多路复用。
先上一段多路复用的代码
public class SelectorNIOSimple {
private Selector selector = null;
int port = 9998;
public static void main(String[] args) {
SelectorNIO service = new SelectorNIO();
service.start();
}
public void initServer() {
try {
ServerSocketChannel 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 start() {
initServer();
while (true) {
try {
Set<SelectionKey> keys = selector.keys(); System.out.println("可处理事件数量 " + keys.size());
while (selector.select() > 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 (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(1024);
client.register(selector, SelectionKey.OP_READ, buffer);
System.out.println("client connected:" + client.getRemoteAddress());
} catch (IOException e) {
e.printStackTrace();
}
}
public void readHandler(SelectionKey key) {
SocketChannel client = (SocketChannel)key.channel();
ByteBuffer buffer = (ByteBuffer)key.attachment();
buffer.clear();
int read;
try {
while (true) {
read = client.read(buffer);
if (read > 0) {
buffer.flip();
while (buffer.hasRemaining()) {
client.write(buffer);
}
buffer.clear();
} else if (read == 0) {
break;
} else {
client.close();
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
咱们先说下啥是多路复用?
在Linux中,多路复用指的是一种实现同时监控多个文件描述符(包括socket,文件和标准输入输出等)的技术。它可以通过一个进程同时接受多个连接请求或处理多个文件的IO操作,提高程序的效率和响应速度。
怎么去理解这段话?结合我们上面的例子,说的直白一点就是一次系统调用,我就能得到多个客户端是否有读写事件。
多路(多个客户端)复用(复用一次系统调用)
多路复用是依赖内核的能力,不同的操作系统都有自己不同的多路复用器实现,这里以linux为例,多路复用又分为两个阶段。
select是在Linux内核2.0.0版本中出现的,于1996年6月发布,而poll则是在Linux内核2.1.44版本中被引入,于1997年3月发布。
可在命令行 输入 man 2 select/poll 去查看linux对它们的解释
简单说下select
int select(int nfds, //要监视的文件描述符数量
fd_set *restrict readfds, //可读文件描述符集合
fd_set *restrict writefds, //可写文件描述符集合
fd_set *restrict errorfds, //异常文件描述符集合
struct timeval *restrict timeout//超时时间
);
pool和select同属第一阶段,因为它们处理问题的思路基本相同,但也有如下区别:
1.实现机制不同:select使用轮询的方式来查询文件描述符上是否有事件发生,而poll则使用链表来存储文件描述符,查询时只需要对链表进行遍历。
2.文件描述符的数量限制不同:select最大支持1024个文件描述符,poll没有数量限制,可以支持更多的文件描述符。
3.阻塞方式不同:select会阻塞整个进程,而poll可以只阻塞等待的文件描述符。
4.可移植性不同:select是POSIX标准中的函数,可在各种操作系统上使用,而poll是Linux特有的函数,不是标准的POSIX函数,在其他操作系统上可能不被支持。
那是不是select和pool就很完美了呢,当然不是,这里还存在一个问题:
啥叫高性能,高性能首先要做到的就是避免资源浪费,fd集合在用户态和内核态互相拷贝就是一种浪费,越是在底层,一个细微的优化,对系统性能的提升都是巨大的。如何解决?linus大神又出手了,杜绝拷贝(不需要在用户态和内核态互相拷贝),空间换时间,在内核为应用程序开辟一块空间,这就是epoll要干的事情。
终于到epoll了,Linux epoll在2.6内核版本中发布,于2002年发布,这里大家可以回过头去看下,java.nio也刚好在2002年推出。
epoll执行过程
https://blog.csdn.net/shift_wwx/article/details/104275383
刚才带大家看了下linux操作系统多路复用的发展历程,那java.nio是怎么使用的呢?其实在一开始就贴出了java selector的代码实现,发现比前面的版本代码会复杂不少,但第一遍咱们只是知道怎么写,至于为什么这么写,并没有讲的很清楚,有了上面的铺垫,再去看这段代码,是不是会有不一样的感觉呢
public class SelectorNIO {
/**
* linux 多路复用器 默认使用epoll,可通过启动参数指定使用select poll或者epoll ,
*/
private Selector selector = null;
int port = 9998;
public static void main(String[] args) {
SelectorNIO service = new SelectorNIO();
service.start();
}
public void initServer() {
try {
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(port));
//epoll模式下 open会调用一个调用系统调用 epoll_create 返回文件描述符 fd3
selector = Selector.open();
/**
*对应系统调用
*select,poll模式下:jvm里开辟一个文件描述符数组,并吧 fd4 放入
*epoll模式下: 调用内核 epoll_ctl(fd3,ADD,fd4,EPOLLIN)
*/
server.register(selector, SelectionKey.OP_ACCEPT);
} catch (IOException e) {
e.printStackTrace();
}
}
public void start() {
initServer();
System.out.println("server start")
while (true) {
try {
Set<SelectionKey> keys = selector.keys();
System.out.println("可处理事件数量 " + keys.size());
/**
*对应系统调用
*1,select,poll模式下: 调用 内核 select(fd4) poll(fd4)
*2,epoll: 调用内核 epoll_wait()
*/
while (selector.select() > 0) {
//返回的待处理的文件描述符集合
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
//使用后需移除,否则会被一直处理
iterator.remove();
if (key.isAcceptable()) {
/**
* 对应系统调用
* select,poll模式下:因为内核未开辟空间,那么在jvm中存放fd4的数组空间
* epoll模式下: 通过epoll_ctl把新客户端fd注册到内核空间
*/
acceptHandler(key);
} else if (key.isReadable()) {
/**
* 处理读事件
*/
readHandler(key);
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
public void readHandler(SelectionKey key) {
try {
ServerSocketChannel ssc = (ServerSocketChannel)key.channel();
//接受新客户端
SocketChannel client = ssc.accept();
//重点,设置非阻塞
client.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(1024);
/**
* 调用系统调用
* select,poll模式下:jvm里开辟一个数组存入 fd7
* epoll模式下: 调用 epoll_ctl(fd3,ADD,fd7,EPOLLIN
*/
client.register(selector, SelectionKey.OP_READ, buffer);
System.out.println("client connected:" + client.getRemoteAddress());
} catch (IOException e) {
e.printStackTrace();
}
}
public void readHandler(SelectionKey key) {
SocketChannel client = (SocketChannel)key.channel();
ByteBuffer buffer = (ByteBuffer)key.attachment();
buffer.clear();
int read;
try {
while (true) {
read = client.read(buffer);
if (read > 0) {
buffer.flip();
while (buffer.hasRemaining()) {
client.write(buffer);
}
buffer.clear();
} else if (read == 0) {
break;
} else {
client.close();
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
这里需要注意的是,如代码中注释所写,java 的Selector对所有多路复用器的一个抽象,可以通过系统属性设置多路复用器的类型。具体来说,在启动Java应用程序时,通过"-Djava.nio.channels.spi.SelectorProvider"参数指定使用的SelectorProvider类,以此来设置多路复用器的类型。例如,使用以下命令启动Java应用程序:
java -Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.PollSelectorProvider MyApp
上述命令将使用PollSelectorProvider作为多路复用器的实现。如果不指定该参数,默认情况下将使用操作系统提供的默认多路复用器实现,例如,Unix-like系统中默认使用EPollSelectorProvider,Windows系统中默认使用WindowsSelectorProvider。
后记
本文从基础概率到引出业界通用c10k问题,然后通过代码演示从BIO,到NIO再到多路复用的演进历程。但要更深入了解IO,还需对业界优秀的IO进一步的研究,比如netty,比如我们天天打交道的tomcat。只有对我们常用的中间件有更深入的了解,不能只是,不识庐山真面目只缘生在此山中,而是要有路漫漫其修远兮吾将而求索的精神,才能对我们的应用做更进一步的优化,提高性能,减少成本,巩固安全,增强体验,这才是我们的追求。
阿里云开发者社区,千万开发者的选择
阿里云开发者社区,百万精品技术内容、千节免费系统课程、丰富的体验场景、活跃的社群活动、行业专家分享交流,欢迎点击【阅读原文】加入我们。
文章引用微信公众号"阿里开发者",如有侵权,请联系管理员删除!