BIO (blocking I/O): 同步阻塞I/O模型
我们主要来看一下Java代码中BIO的实现(底层实现是依靠内核提供的阻塞IO系统调用),上代码(这些都是在Java IO包中的,基于ServerSocket和Socket)
// 这里是服务端的程序实现,主线程接收客户端连接,将每一个客户端连接放入clone的一个子线程中(这里的clone是底层的系统调用)
// 绑定端口7777并监听 bind→listen
ServerSocket serverSocket = new ServerSocket(7777);
System.out.println("服务端启动...");
while(true){
// serverSocket.accept()方法是阻塞等待客户端连接,
// 没有客户端连接就一直等待,直到有客户端接入
// 有接入后返回接入的客户端socket
Socket socket = serverSocket.accept();
System.out.println("客户端连接...");
// 客户端接入之后需要将其连接放入到一个线程中去处理后续的数据读取操作,
// 因为读取操作也是一个阻塞的等待状态,客户端可能一会给你发消息,一会不发,
// 如果不开启子线程,则此时服务器就只能阻塞等待客户端发信息,无法接入其他客户端
// 相当于这个一个服务器只能接入一个客户端,这显然是不符合常理的
new Thread(new Runnable() {
@Override
public void run() {
// 获取客户端输入流
InputStream is = socket.getInputStream();
// is.read() 也是阻塞方法,子线程在此等待客户端输入...
int clientData = is.read(b);
// ...
}
}).start();
我们说BIO是同步阻塞I/O模型,这里同步的意思就是指,客户发来的IO信息,需要我自己线程去同步读取,客户端发送一点我接收一点,两端数据是同步的。
在这里边有两个阻塞,第一个是服务端等待客户端接入的阻塞,另一个是子线程中等待客户端发送消息的阻塞。上图
图中recv是指读取信息的系统调用,你可以把他理解为Java中的InputStream .read(),以上就是Java最古老的基于BIO的每线程每连接,而这里BIO的问题主要是两个:
第一,创建线程是要分配给每个线程对应独立的内存空间的,很占资源,而且如果这个连接不做任何事情会造成不必要的开销。
第二,多个线程cpu在执行时会给每个线程分配时间去调度执行他们,如果线程很多,则cpu会有很多时间都浪费在了线程之间调度切换,切换也不是很简单的操作,其中包含了当前线程挂起,线程的执行场景保留和下一个线程的执行状态恢复等操作。所以引出了我们的新IO模型,NIO ↓
NIO (non-blocking I/O): 同步非阻塞I/O模型
NIO默认是指操作系统提供的NIO,而在Java中,NIO也可以叫做new IO,因为是全新的IO包,新的一套体系,是NIO包下的内容,基于ServerSocketChannel和SocketChannel,虽然底层也还是依靠新的非阻塞的系统调用。
NIO是随着内核系统调用发展的产物,其非阻塞的方法也是依靠内核提供的,如图为Linux的内核实现的非阻塞IO方法参数
图上是服务端的非阻塞参数SOCK_NONBLOCK,同时也可以通过fcntl(2)这个系统调用来设置客户端非阻塞。(如BIO中说明的两个阻塞分别有对应的两个解决)
我们还是来看Java中对NIO的实现,上代码(服务端代码)
// 创建serverSocketChannel,监听8888端口,类似于BIO中的ServerSocket
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(8888));
// 重点:设置为非阻塞参数 → 内核提供的NIO
serverSocketChannel.configureBlocking(false);
// 创建一个放置客户端连接的集合
LinkedList<SocketChannel> clients = new LinkedList<>();
// 循环是在一个主线程中,期间没有开启任何其他子线程
// 循环中有两大步,第一步接收客户端连接,第二步遍历客户端查看是否有数据需要接收
while(true){
// ① ******接收客户端连接******
// 接收客户端连接,一秒去接一次(也可以不设定),非阻塞,一直会有返回值
Thread.currentThread().sleep(1000);
SocketChannel client = serverSocketChannel.accept();
// 那接收的结果就是两种情况,接到和没接到
// 内核系统调用时没接收到返回-1,Java中为null
if (channel == null){
System.out.println("服务端监听中...暂无接入...");
} else {
System.out.println("客户端接入...");
// 设置客户端非阻塞,比方说
// 客户端有数据发来就接受到了,没有就返回-1,也是非阻塞的
client.configureBlocking(false);
System.out.println("客户端端口:" + client.socket().getPort());
clients.add(client);
}
// ② ******遍历客户端集合查看是否有数据需要接收******
ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
for (SocketChannel c : clients) {
// 此时读取不会阻塞,返回 >0,0,-1
int num = c.read(buffer);
if (num > 0) {
buffer.flip();
byte[] bytes = new byte[buffer.limit()];
buffer.get(bytes);
String message = new String(bytes);
System.out.println(c.socket().getPort() + ": " + message);
buffer.clear();
}
}
}
这里NIO一个线程就干了接受客户端连接和读取客户端数据的工作,解决了BIO中的线程内存浪费和cpu调度消耗的问题。NIO的优势就是解决了客户端连接多线程的问题,那么NIO有哪些弊端呢,C10K问题(client 有10K个),假如你有一万个客户端连接,每次你去读取客户端数据都要向内核进行recv(读数据)的系统调用,但是假如此时此刻发来数据的客户端只有一个,那剩下9999次的调用都将会是无效的,没有意义的浪费资源,上图
那如何解决C10K问题呢,C10K的主要问题在于for循环调用了一万次系统调用,如果我们可以降低循环的次数,减少对应的系统调用,那性能将大大提升,好比说我们在一万次的循环之前,访问某一个系统调用,将一万个客户端连接描述传递给内核,让他给我们返回到底有几个客户端发来了数据,在这之后我们就可以只遍历内核返回回来的真正有数据传达了的客户端,假如有三个客户端传来了数据,加上我们循环之前访问内核的某一个系统调用,总共有只四次系统调用,而只循环了三次,是不是大大提高了效率。
我们可以将这一万个客户端理解为一万个数据通路,这多个数据通路都同时使用了某一个系统调用完成了数据传输状态的确认,我们将这个过程称之为多路复用,使用到的某一个系统调用称之为多路复用器。
可以把多路复用器理解为是基于NIO模型来使用的。
多路复用器: select,poll,epoll
多路复用器也是随着内核系统调用发展的产物,是内核为我们提供的可以管理多个数据通路的系统调用方法。上边三个版本多路复用器大致又可以分为两类:
一类:select,poll
二类:epoll
先来聊一下一类,select和poll,以select为例,看一下linux内核提供的系统调用,调用方法名就是select,这里是C语言实现的。
首先,select是同步I/O多路复用器。(multiplexing:多路复用)
再来看系统调用提供的方法,其中int类型的参数nfds就表示的是文件描述符的个数,这里具体表示的就是多少个客户端连接需要询问内核(C10K),后边三个参数是三个文件描述符的集合,可读集合,可写集合和异常集合,最后参数是请求时间超时的限制,为什么使用文件描述符呢,因为Linux中一切皆文件,而什么是文件描述符?这里的使用可以暂时理解为你每个客户端的连接信息,这里补上一个比较详细的文件描述符说明链接,这里不再阐述:Linux文件描述符到底是什么?
关于select的系统调用是有如下描述:允许一个程序监视多个文件描述符。
直到一个或多个文件描述符达到一个准备好的状态。
这里select多路复用器的作用就是替代了之前循环遍历客户端的过程,用户端时间复杂度从一万减少到了一,但是其实在将一万个文件描述符传递给内核后,内核还是要遍历,不过内核内部的自行遍历相比于用户循环一万次做系统调用,要快的多(因为用户态到内核态有保护模式,系统调用执行间还有好长的一段路要走)。
这里需要明确的一点是,多路复用器返回的只是对应客户端的状态,多路复用器不会帮你去读数据,读取数据的工作都是在代码中实现的,都在自己的线程中(比如上边图中最后都调用了recv),无论是BIO还是NIO。
这里再补充一下 IO模型的同步与异步:
如果是程序自己读取IO(类似于上边的代码获取客户端输入流,系统调用recv),那么这个IO类型,无论是BIO,还是NIO(使用和不使用多路复用器),他们都是同步的IO模型。只不过BIO是同步阻塞,NIO是同步非阻塞,而多路复用器只是NIO的帮手,不属于IO模型。
那异步IO模型呢,windows中有IOCP实现,Linux也有Proactor模式,做法都是在内核中启动线程(不是在程序中启动线程),内核线程把数据拷贝到程序的内存空间中去,当内核线程完成IO操作之后,发送一个通知,告知程序操作已完成,此时程序就可以直接进行获取,程序不用自己开线程去调读取方法(recv)来读取数据,这就叫做异步IO模型。如图(kernel就是内核)
这里简单的提一下,就是NIO 2.0引入了新的异步通道的概念,就是图中的AIO。
附上一个Linux异步IO模型的详细说明,这里不再阐述:
Linux 网络编程的5种IO模型:异步IO模型
这里差不多了解完了多路复用器select,再提一下多路复用器poll,select和poll是一类的,区别在于select有一个源代码的1024限制(不同版本代码可能限制数不一样),一个select最多同时监视1024个文件描述符(可以理解为一个select同时最多管理1024个客户端连接),而poll是没有显示的限制的,是随着操作系统底层配置来实现限制的。
那么一类的多路复用器select和poll有哪些问题呢,也可以总结为两个
第一个:每次会重复传递文件描述符,每次系统调用都传一万个过去,循环多了,资源空间上也很浪费
第二个:多路复用器select和poll都要全量遍历文件描述符,有没有什么方法可以减少遍历次数呢?或者说可不可以不用再主动遍历文件描述符了呢(无论是程序遍历还是内核遍历)?
EPOLL可以解决,这也是为什么现在用的比较多的同步IO模型的多路复用器是epoll,那么如何解决上边两个问题呢,
第一个:让内核开辟一块内存空间,来保留文件描述符,不用重复传递了
第二个:属于计组的知识,使用中断,callback回调等来实现被动获取文件描述符的状态,不再主动遍历所有文件描述符(连接信息)。
上图
下来我们主要说一下二类多路复用器:EPOLL
在讲原理之前这里先对NIO内容进行一个小补充:
Java Nio主要由三个核心部分组成:
现在我们看一下epoll解决一类多路复用器两个问题的原理:
(最后会有代码的演示)
第一个问题解决方案:让内核开辟一块内存空间,来保留文件描述符,不用重复传递了
原理:在客户端接入时,将对应的文件描述符写入到内核开辟的内存空间保存,其实内核开辟的内存空间有两个部分,A部分用来存放全部已经连接的客户端文件描述符,B部分存放有了对应读取状态的客户端文件描述符。我们的程序现在不用循环遍历客户端连接了,只需要从B部分获取对应的客户端就可以了,内核负责监视A部分的客户端,当有输入到达时,就将客户端文件描述符从A部分拷贝到B部分,再由程序轮询获得。此时就是通过内核牺牲部分内存空间来换取时间。
select和poll的多路复用器,在多cpu的时候无法发挥出多核的优势,因为内存没有开辟内存空间,每次需要程序进行系统调用,等待cpu返回结果,因为cpu处理和程序获得结果这两个顺序是不能颠倒的,所以别的cpu也帮不上忙,但是在epoll的情况下,因为内核内存空间的存在,可以让cpu1去接受客户端信息,做A部分拷贝到B部分的工作,而cpu2去运行程序,并且轮询(轮流循环询问)内存空间B部分的状态,这样就可以充分发挥多核的优势。
下来看一下这个操作的系统调用级实现(Linux):
分别有三个系统调用方法:
下来用这三个系统调用来说明如何实现第一个问题的解决:
首先在你的服务端程序启动时,调用且只调用一次epoll_create,使得内核创建内存空间,方法返回一个文件描述符epfd,假如等于7,这个文件描述符就是内核开辟内存空间的描述,包含地址啊,大小啊,等等,为什么内存空间也是由文件描述符来描述,因为Linux一切皆文件嘛
有了fd7之后,表明内核已经开辟了一块内存空间了,我们程序在启动后,会绑定并监听一个端口,返回我们程序的第一个文件描述符,也就是程序服务端(server)的文件描述符,假如是3,接下来就是把次文件描述符拷贝到内核开辟的内存空间中去,这里还是用上边的代称,就是拷贝到内核内存的A部分去,此时调用的方法是epoll_ctl,op参数为add,监听服务端的accept事件↓
这里内核空间fd7其实使用红黑树来存放这些添加进来的文件描述符的,使得对文件描述符的获取和使用更加便捷效率。
此时已经拷贝完毕,内核负责监听拷贝到A部分的文件描述符的事件,假如此时又客户端接入,则将fd3从A部分拷贝到B部分,当我程序随时想要获取状态时,只需要调用epoll_wait即可,就是之前提到的轮询,程序会时不时的过来查看一下最新的状态,有变化的话就读取对应信息到程序内存中去执行,epoll_wait的意思就可以理解为等待内核监听事件直到有事件发生。epoll_wait是阻塞的方法,但是可以设置一个timeout时间,超过时间则直接返回-1。最优情况下epoll_wait可以到达O(1)的复杂度,一次获取多个连接信息。
再下来无非是服务端fd3接收到客户端,假如是fd8,再次调用epoll_ctl来add到A部分,内核再监听对应的可读事件,有读事件发生了,再拷贝到B部分,等待程序调用epoll_wait来获取。这一部分的解决方法大概就是这样。
总结一下,epoll其实相比于select和poll,就是多了epoll_create和epoll_ctl方法,这两个方法分别以在【内核中开辟空间】并【操作文件描述符】。
第二个问题解决方案:属于计组的知识,使用中断,callback回调等来实现被动获取文件描述符的状态,不再主动遍历所有文件描述符(连接信息)。
原理:简单说明一下,内核在开辟的A部分内存空间里,监听了所有文件描述符对应的事件,何时把A部分的文件描述符拷贝到B部分,上边已经说过了,是对应监听的事件发生的时候,比如读事件,那为什么文件描述符对应监听的事件发生时,会接着发生A,B部分的拷贝事件呢,这两者明显没有直接的关联关系,结果就是,内核使用回调机制,比如callback事件回调,在读事件发生之后回调了拷贝事件完成了这一操作。
最后看一下多路复用器在Java中的封装:
// 服务端代码依旧是绑定端口和监听端口,设置非阻塞
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(8888));
serverSocketChannel.configureBlocking(false);
// 多路复用器:select poll epoll都有可能,优先选择epoll,可以通过传参 -D修改
// 在epoll的模型下 open() -> epoll_create -> fd7 让内核开辟内存空间
Selector selector = Selector.open();
// 为服务端register(注册) selector
// select,poll -> 在jvm里开辟一个数组,把fd3放进去(进程空间)
// epoll -> epoll_ctl(fd7,add,fd3,EPOLLIN) 把fd3放到内核内存空间A部分(内核空间)
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务端开始工作:");
// 下来开始执行
while (true) {
// 查看多路复用器中当前监视了多少了文件描述符
Set<SelectionKey> keys = selector.Keys();
System.out.println(keys.size() + " size");
//创建消息处理器
ServerHandlerBs handler = new ServerHandlerImpl(1024);
// select()方法就是调用多路复用器(select,poll 或者 epoll)
// 语义就是去查询一下那些IO可以读写了
// select,poll -> 调用内核的 select(fd3),poll(fd3)
// epoll -> 调用内核的 epoll_wait()
// 超时时间500毫秒 -> selector.wakeup() 返回0
while(selector.select(timeout: 500) > 0) {
// selectedKeys()方法返回有状态的fd集合
Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
// 这里遍历的就是有真正IO需求的连接
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
try {
// 连接请求
if (key.isAcceptable()) {
// select,poll -> 在jvm里开辟的数组中存储新客户端
// epoll -> epoll_ctl(...add...) 新客户端注册到内核内存空间
handler.handleAccept(key);
}
// 读请求
if (key.isReadable()) {
System.out.println(handler.handleRead(key));
}
} catch (IOException e) {
e.printStackTrace();
}
// 处理完后移除当前使用的key,不移除下次循环会重复处理
keyIterator.remove();
}
}
}
/**
* description:对selectionKey事件的处理接口
*/
interface ServerHandlerBs {
void handleAccept(SelectionKey selectionKey) throws IOException;
String handleRead(SelectionKey selectionKey) throws IOException;
}
/**
* description:用来处理有需求的连接
*/
public class ServerHandlerImpl implements ServerHandlerBs {
private int bufferSize = 1024;
private String localCharset = "UTF-8";
public ServerHandlerImpl() {
}
public ServerHandlerImpl(int bufferSize) {
this(bufferSize, null);
}
public ServerHandlerImpl(String localCharset) {
this(-1, localCharset);
}
public ServerHandlerImpl(int bufferSize, String localCharset) {
this.bufferSize = bufferSize > 0 ? bufferSize : this.bufferSize;
this.localCharset = localCharset == null ? this.localCharset : localCharset;
}
// 当连接是客户接入的时候
@Override
public void handleAccept(SelectionKey selectionKey) throws IOException {
//获取channel
SocketChannel socketChannel = ((ServerSocketChannel) selectionKey.channel()).accept();
//非阻塞
socketChannel.configureBlocking(false);
//注册selector
socketChannel.register(selectionKey.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(bufferSize));
System.out.println("建立请求......");
}
// 当连接是客户发送数据的时候
@Override
public String handleRead(SelectionKey selectionKey) throws IOException {
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
String receivedStr = "";
if (socketChannel.read(buffer) == -1) {
//没读到内容关闭
socketChannel.shutdownOutput();
socketChannel.shutdownInput();
socketChannel.close();
System.out.println("连接断开......");
} else {
//将channel改为读取状态
buffer.flip();
//按照编码读取数据
receivedStr = Charset.forName(localCharset).newDecoder().decode(buffer).toString();
buffer.clear();
//返回数据给客户端
buffer = buffer.put(("received string : " + receivedStr).getBytes(localCharset));
//读取模式
buffer.flip();
socketChannel.write(buffer);
//注册selector 继续读取数据
socketChannel.register(selectionKey.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(bufferSize));
}
return receivedStr;
}
}
代码是参照此博客的,我懒得写了,基本只是加了点关于底层系统调用的注释:
java Nio 使用 NioSocket 客户端与服务端交互实现
最后附上一张图,是在Linux中验证epoll调用顺序的(注意一下左边写的监听的是3,右边是4,没有本质上的区别)
------------------------------------------------------------------------
最后再补充一下,类似于netty的实现:
上边代码是单线程的,代码中的Selector既要负责建立连接,又要负责确认客户端状态,还是假如现在有十万个连接都有数据不断在发送过来,那么每次epoll_wait的时间就会变长,两个epoll_wait的间隔就越来越大,这意味着什么,意味着程序每次响应会一次处理大量并发,会导致用户端感觉程序反应很慢,用户体验降低,原理就是在你执行第一个epoll_wait之后,所有的连接再进来就得等第一个epoll_wait处理完才能再接入,然后在第二次epoll_wait的时候再由程序处理,所以解决方案很简单,可以建立多个Selector复用器,在多个线程中,将数据量分开,符合了负载均衡的理念,提升处理的响应速度。
多线程多Selector:
可以让多个多路复用器去做不同的事情,比如Selector1是大管家,负责找到需要接入客户端并分发出去,Selector2和Selector3接受Selector1的任务来真正的处理客户端接入,这样就使用了两个多路复用器在不同的线程中处理了客户端接入,速度肯定比单个线程单个多路复用器快。(多核操作系统这三个线程就可以跑在不同的核心上,并行效率更高)
线程的构造方法如下,Selector1分发任务的多路复用器使用第一种,selectors就是处理任务的子多路复用器有多少,剩下Selector2和Selector3使用第二种,单纯负责任务完成。线程内部都包含任务队列queue。
下来开始执行,只有第一个线程中所在的多路复用器Selector1被注册到了server上了可以获取监听状态,在接受到客户端之后走acceptHandler方法
acceptHandler方法中就包含了任务的分配,分配给Selector2和Selector3,一人一个这样分配(给对应线程的queue中分配),下来看一下Selector2和Selector3的任务实现
以上便是回顾 网络通信IO【BIO,NIO,多路复用器】的全部内容,
最后致谢马士兵教育周老师,带给我们这么详细生动的课程。
活到老学到老,我们下次再见。