Title:BIO\NIO\多路复用 区别和浅解
技术博客已迁移至个人页,欢迎查看 yloopdaed.icu
您也可以关注 JPP - 这是一个Java养成计划,需要您的加入。
前言
网络通信中BIO、NIO、多路复用相关的知识点非常多,网络上也有很多相关的技术文章。但是每个人的角度和切入点不同,知识涵盖的内容太多了,很难整理出一条清晰的思路。
我这两天尝试着脱离代码(因为加入代码Demo之后,篇幅增长,很多API也不是很熟悉,增加阅读的难度),单纯从操作系统网络通信的流程去梳理BIO、NIO,再到多路复用的优缺点。最终达到了解这些技术的联系和发展的目的。
BIO
此流程基于Java示例代码,代码可以查看 JPP /IOTest 类
主线程:
1 服务端等待客户端连接,阻塞进程
2 检测到客户端连接,返回对应的文件描述符fd,并将连接移动到子线程
3 开始下一次循环,跳回第1步
子线程:
1 子线程中服务端等待客户端响应,阻塞进程
2 接收到客户端响应,子线程处理
3 开始下一次循环,跳回第1步
优势:可以让每一个连接专注于自己的I/O并且编程模型简单,也不用过多考虑系统的过载、限流等问题。
问题:
1 线程的创建和销毁成本很高
2 线程本身占用较大内存
3 线程的切换成本是很高的
4 容易造成锯齿状的系统负载
为了解决BIO的缺点,进入NIO
NIO
此流程基于Java示例代码,代码可以查看 JPP /NIOTest 类
本文介绍的NIO是操作系统网络通信中的NON-BLOCKING IO,是Java NIO的基础。
上图中的两个 主线程 循环 指的是没有开辟子线程。意思是服务端接收客户端连接和处理客户端请求都在同一个线程中。
流程:
1 服务端请求客户端连接,非阻塞。如果没有直接返回null并向下执行
2 如果有客户端连接,请求消息响应,非阻塞。如果没有响应直接返回null并继续遍历客户端
3 遍历完所有客户端后,会重新开始循环,跳回第1步
优势:
1 规避多线程的问题
2 单线程解决多任务
问题:
客户端循环遍历时,不断进行用户态和内核态的切换,系统调用开销非常大
什么是用户态和内核态?
我的理解就是权限不同。用户态的进程能够访问的资源受操作系统的控制,而运行在内核态的进程才可以访问系统中的硬件设别,例如网卡。所以上面客户端循环遍历询问消息响应,会不断进行用户态和内核态的切换。
开销大在哪?
- 1 保护现场
- 2 恢复现场
- 3 软中断
- 4 寻找中断向量表
- 5 找回调函数
等
多路复用
在NIO的基础上,通过一次系统调用将连接客户端响应询问移动到内核处理,而不是反复进行用户态和内核态的切换。
如果把每次系统调用理解成一条通路,那么这种把多次系统调用合并成一次的方式,就叫做多路复用。
interface ChannelHandler{
void channelReadable(Channel channel);
void channelWritable(Channel channel);
}
class Channel{
Socket socket;
Event event;//读,写或者连接
}
//IO线程主循环:
class IoThread extends Thread{
public void run(){
Channel channel;
while(channel=Selector.select()){//选择就绪的事件和对应的连接
if(channel.event==accept){
registerNewChannelHandler(channel);//如果是新连接,则注册一个新的读写处理器
}
if(channel.event==write){
getChannelHandler(channel).channelWritable(channel);//如果可以写,则执行写事件
}
if(channel.event==read){
getChannelHandler(channel).channelReadable(channel);//如果可以读,则执行读事件
}
}
}
Map<Channel,ChannelHandler> handlerMap;//所有channel的对应事件处理器
}
Selector中的select函数会执行系统内核的调用:Linux 2.6之前是select、poll,2.6之后是epoll。
此流程基于Java示例代码,代码可以查看 JPP /NIOEpollTest 类
多路复用的实现方式
select
流程:
1 客户端建立连接后返回fd
2 用二进制位表bitmap标记fds对应的位置,并将这个bitmap从用户态拷贝到内核态
3 收到客户端响应,将对应的bitmap位标记为1
4 程序处理消息,重新创建bitmap,循环下一次
缺点:
1 bitmap默认最大限制1024位
2 bitmap不可重用,每次循环重新创建
3 用户态和内核态切换开销
4 轮询所有的客户端处理消息 O(n)
poll
流程:
与select相似,select中用bitmap标记fds,在poll中自己声明了结构体
struct pollfd{
int fd;
short events;
short revents
}
传输pollfd数组,解决了select中fds限制1024位的问题
其次,内核将响应客户端对应的pollfd结构体中的revnets标记为1,说明这个客户端有消息响应
处理消息时将这个pollfd的revnets重置即可,不用在重新创建数组,所以select中每次循环重新创建bitmap的问题也被解决了
遗留问题:
1 由于fds文件描述符存储在用户空间,拷贝到内存空间处理一定会涉及到用户态和内核态的切换。这个系统调用有一定开销
2 每次内核态返回的信息是全量信息,要轮询处理,时间复杂度是 O(n),如果连接数过多,也会有很多无意义的开销
这两个问题留给 epoll
epoll
流程:
1 建立epoll对象时在内核分配资源,其数据结构是红黑树。添加和检索的时间复杂度 O(lgn)
int epoll_create(int size);
2 建立连接时,在红黑树中存储 epoll_event结构体,其中包含fd和events等信息
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
3 调用epoll_wait收集响应的连接,放入一个单向链表
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
到此为止,上面提到的多路复用的所有缺点都得以解决。