简述BIO、NIO、epoll与Netty
BIO与NIO
BIO:同步阻塞式IO,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。
NIO:同步非阻塞式IO,服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。
这是传统意义上对BIO与NIO的定义。
下面举个例子来看一下BIO到底阻塞在了哪里,BIO为什么这么慢。
BIO为什么慢
/**
* 单线程BIOServer
*/
public class BioServerDemo {
public static void main(String[] args) throws IOException {
// 创建ServerSocket,并绑定端口
ServerSocket serverSocket = new ServerSocket(8888);
while (true) {
Socket socket = serverSocket.accept();//阻塞
byte[] bytes = new byte[1024];
socket.getInputStream().read(bytes);//阻塞
System.out.println("接收到了数据:" + new String(bytes));
}
}
}
可以看到Server
会在一个死循环中,处理客户端的连接或者读写。
首先是阻塞在serverSocket.accept()
,知道有客户端连接,才会继续往下走。
其次是阻塞在,socket.getInputStream().read(bytes)
,等待客户端的数据传输。
当然,一个真正的Sever不会在一个单线程中,只处理一个Socket的读写。
/**
* BIOServer
*/
public class BioServerDemo {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8888);
while (true) {
Socket socket = serverSocket.accept();
//放到一个新的线程中去处理读写,可以使用线程池优化
new Thread(()->{
byte[] bytes = new byte[1024];
try {
socket.getInputStream().read(bytes);
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("接收到了数据:" + new String(bytes));
}).start();
}
}
}
可以看到,每次得到一个Socket之后,Server都会创建一个新的线程,去处理Socket的读写。
但是这里会有一个问题,就是如果绝大部分的连接都没有进行数据传输,只是建立了连接,这样就会产生很多无效的线程,如果有一万个连接过来,那么我们是不是有可能会有一万个线程在等待呢?
这是BIO的瓶颈之一,另一个瓶颈则涉及到select与epoll的原理。
因此,BIO方式
适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高。
什么是阻塞
操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。运行状态是进程获得cpu使用权,正在执行代码的状态;等待状态是阻塞状态,比如上述程序运行到read
时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。
当进程A执行到创建socket的语句时,操作系统会创建一个由文件系统管理的socket对象(如下图)。这个socket对象包含了发送缓冲区、接收缓冲区、等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该socket事件的进程。
当程序执行到read
时,操作系统会将进程A从工作队列移动到该socket的等待队列中(如下图)。由于工作队列只剩下了进程B和C,依据进程调度,cpu会轮流执行这两个进程的程序,不会执行进程A的程序。所以进程A被阻塞,不会往下执行代码,也不会占用cpu资源。
当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。也由于socket的接收缓冲区已经有了数据,read
可以返回接收到的数据。
NIO的进化
首先给一个Java NIO 服务端代码。
public class MyNIOServer {
public static void main(String[] args) throws Exception{
//创建ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//得到一个Selecor对象
Selector selector = Selector.open();
//绑定端口8888
serverSocketChannel.socket().bind(new InetSocketAddress(8888));
//非阻塞
serverSocketChannel.configureBlocking(false);
//把 serverSocketChannel注册到selector只关心OP_ACCEPT事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
if(selector.select() == 0) {
System.out.println("无连接");
continue;
}
//返回触发事件的集合
Set<SelectionKey> selectionKeys = selector.selectedKeys();
System.out.println("selectionKeys size = " + selectionKeys.size());
Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
while (keyIterator.hasNext()) {
//获取到SelectionKey
SelectionKey key = keyIterator.next();
//根据key 对应的通道发生的事件做相应处理
if(key.isAcceptable()) { //如果是 OP_ACCEPT, 有新的客户端连接
//该该客户端生成一个 SocketChannel
SocketChannel socketChannel = serverSocketChannel.accept();
System.out.println("客户端连接成功 生成了一个 socketChannel " + socketChannel.hashCode());
//将 SocketChannel 设置为非阻塞
socketChannel.configureBlocking(false);
//将socketChannel 注册到selector, 关注事件为 OP_READ, 同时给socketChannel关联一个Buffer
socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
System.out.println("客户端连接后 ,注册的selectionkey 数量=" + selector.keys().size());
}
//发生 OP_READ
if(key.isReadable()) {
//通过key 反向获取到对应channel
SocketChannel channel = (SocketChannel)key.channel();
//获取到该channel关联的buffer
ByteBuffer buffer = (ByteBuffer)key.attachment();
channel.read(buffer);
System.out.println("form 客户端 " + new String(buffer.array()));
}
//手动从集合中移动当前的selectionKey, 防止重复操作
keyIterator.remove();
}
}
}
}
可以看到这个NIO的Server端虽然是单线程的,但是他的性能其实不差,因为其中的selector.select()
方法不会阻塞线程,当Socket有连接或者读事件的时候,才会取得对应的SocketChannel
进行处理,否则将一直轮询等待,这个方法的奥秘就在底层的epoll
原理中。
epoll 与 select 原理简述
此select 非NIO的select()方法
参考知乎,如果这篇文章说不清epoll的本质,那就过来掐死我吧!: https://zhuanlan.zhihu.com/p/64138532.
参考博客,https://blog.csdn.net/daaikuaichuan/article/details/83862311
epoll的要义是高效的监视多个socket。
假如能够预先传入一个socket列表,如果列表中的socket都没有数据,挂起进程,直到有一个socket收到数据,唤醒进程。这种方法很直接,也是select的设计思想。
为方便理解,我们先复习select的用法。在如下的代码中,先准备一个数组(下面代码中的fds),让fds存放着所有需要监视的socket。然后调用select,如果fds中的所有socket都没有数据,select会阻塞,直到有一个socket接收到数据,select返回,唤醒进程。用户可以遍历fds,通过FD_ISSET判断具体哪个socket收到数据,然后做出处理。
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int fds[] = 存放需要监听的socket
while(1){
//可能有多个socket就绪
int n = select(..., fds, ...)
//轮询所有socket查看是否有数据要处理
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){
//fds[i]的数据处理
}
}
}
select的流程
select的实现思路很直接。假如程序同时监视如下图的sock1、sock2和sock3三个socket,那么在调用select之后,操作系统把进程A分别加入这三个socket的等待队列中。
当任何一个socket收到数据后,中断程序将唤起进程。下图展示了sock2接收到了数据的处理流程。
所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。
经由这些步骤,当进程A被唤醒后,它知道至少有一个socket接收了数据。程序只需遍历一遍socket列表,就可以得到就绪的socket。
缺点有二:
- 每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个fds列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定select的最大监视数量,默认只能监视1024个socket。(
写死在头文件中,poll进行了改善可以传入一个数
) - 进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。
epoll解决了这些问题。
epoll流程
为方便理解后续的内容,我们先复习下epoll的用法。如下的代码中,先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据。
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1){
//检查一下rdllist有没有数据
int n = epoll_wait(...)
for(接收到数据的socket){
//处理
}
}
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关,如下所示:
struct eventpoll {
...
/*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,
也就是这个epoll监控的事件*/
struct rb_root rbr;
/*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/
struct list_head rdllist;
...
};
我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个rdllist双向链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个rdllist双向链表里有没有数据即可
。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。
所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把这样的事件放到上面的rdllist双向链表中。
在epoll中对于每一个事件都会建立一个epitem结构体,如下所示:
struct epitem {
...
//红黑树节点
struct rb_node rbn;
//双向链表节点
struct list_head rdllink;
//事件句柄等信息
struct epoll_filefd ffd;
//指向其所属的eventepoll对象
struct eventpoll *ep;
//期待的事件类型
struct epoll_event event;
...
}; // 这里包含每一个事件对应着的信息。
当调用epoll_wait检查是否有发生事件的连接时,只是检查eventpoll对象中的rdllist双向链表是否有epitem元素而已
,如果rdllist链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。因此epoll_wait效率非常高。epoll_ctl在向epoll对象中添加、修改、删除事件时,从rbr红黑树中查找事件也非常快
,也就是说epoll是非常高效的,它可以轻易地处理百万级别的并发连接。
总结:
一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。
- 执行epoll_create()时,创建了红黑树和就绪链表;
- 执行epoll_ctl()时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据;
- 执行epoll_wait()时立刻返回准备就绪链表里的数据即可。
Netty 与 NIO
整理自 尚硅谷韩顺平Netty 相关课程。
原生NIO存在的问题
NIO 的类库和 API 繁杂,使用麻烦:需要熟练掌握 Selector
、ServerSocketChannel
、SocketChannel
、ByteBuffer
等。
需要具备其他的额外技能:要熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor
模式,你必须对多线程和网络编程非常熟悉,才能编写出高质量的 NIO 程序。
开发工作量和难度都非常大:例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常流
的处理等等。
JDK NIO 的 Bug:例如臭名昭著的 Epoll Bug
,它会导致 Selector 空轮询,最终导致 CPU 100%。直到 JDK 1.7 版本该问题仍旧存在,没有被根本解决。
Netty的优点
Netty对JDK自带的NIO的API进行了封装,解决了上述问题。
设计优雅:适用于各种传输类型的统一 API 阻塞和非阻塞 Socket;基于灵活且可扩展的事件模型,可以清晰地分离关注点;高度可定制的线程模型 - 单线程,一个或多个线程池
.
安全:完整的 SSL/TLS
和 StartTLS
支持
高性能、吞吐量更高:延迟更低;减少资源消耗;最小化不必要的内存复制。
Netty模型
Netty
主要是基于主从Reactor多线程模式
做了一定的改进,其中主从Reactor
都有单一的一个变成了多个。下面是简单的改进图。
代码演示
public class NettyServer {
public static void main(String[] args) throws InterruptedException {
//创建BossGroup 和 WorkerGroup
//1、创建两个线程组,bossGroup 和 workerGroup
//2、bossGroup 只是处理连接请求,真正的和客户端业务处理,会交给 workerGroup 完成
//3、两个都是无限循环
//4、bossGroup 和 workerGroup 含有的子线程(NioEventLoop)个数为实际 cpu 核数 * 2
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup worderGroup = new NioEventLoopGroup();
try {
//创建服务器端的启动对象,配置参数
ServerBootstrap bootstrap = new ServerBootstrap();
//使用链式编程来进行设置,配置
bootstrap.group(bossGroup, worderGroup) //设置两个线程组
.channel(NioServerSocketChannel.class) //使用 NioServerSocketChannel 作为服务器的通道实现
.option(ChannelOption.SO_BACKLOG, 128) //设置线程队列得到连接个数
.childOption(ChannelOption.SO_KEEPALIVE, true) //设置保持活动连接状态
.childHandler(new ChannelInitializer<SocketChannel>() { //为accept channel的pipeline预添加的handler
//给 pipeline 添加处理器,每当有连接accept时,就会运行到此处。
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline().addLast(new NettyServerHandler());
}
}); //给我们的 workerGroup 的 EventLoop 对应的管道设置处理器
System.out.println("........服务器 is ready...");
//绑定一个端口并且同步,生成了一个ChannelFuture 对象
//启动服务器(并绑定端口)
ChannelFuture future = bootstrap.bind(8888).sync();
//对关闭通道进行监听
future.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
worderGroup.shutdownGracefully();
}
}
}