一、Linux
五大网络IO
模型
我们在学些netty
我们需要了解下linux
的IO
模型,我们的java
的IO
模型也是在此基础上搭建的。
1.1. 阻塞I/O
模型
常用的I/O模型就是阻塞I/O模型,缺省情形下,所有文件操
作都是阻塞的。我们在使用套接字接口是,在进程空间中调用recvform
,其系统调用直到数据包到达且被复制到应用进程的缓冲区或者发生错误才返回,期间一直会等待,进程从调用recvfrom
开始到它返回的这段时间都是被阻塞的。
1.1.1. 优点:
- 能够及时的返回数据,无延迟;
- 程序简单,进程挂起基本不会消耗
CPU
时间;
1.1.2. 缺点:
-
I/O
等待对性能影响较大; - 每个连接需要独立的一个进程/线程处理,当并发请求量较大时为了维护程序,内存、线程和CPU上下文切换开销较大,因此较少在开发环境中使用。
1.2. 非阻塞I/O
模型
recvfrom
从应用层到内核的时候,如果该缓冲区没有数据的话,
就直接返回一个EWOULDBLOCK
错误,一般都对非阻塞I/O模型进行轮询检査这个状态,
看内核是不是有数据到来。
1.2.1. 具体过程:
第二阶段(非阻塞):
- 进程向内核发起IO调用请求,内核接收到进程的I/O调用后准备处理并返回
EWOULDBLOCK
的信息给进程;此后每隔一段时间进程都会想内核发起询问是否已处理完,即轮询,此过程称为为忙等待。 - 内核收到进程的系统调用请求后,此时的数据包并未准备好,此时内核会给进程发送error信息,直到磁盘中的数据加载至内核缓冲区。
第二阶段(阻塞):
- 内核再将内核缓冲区中的数据复制到用户空间中的进程缓冲区中(真正执行IO过程的阶段,进程阻塞),直到数据复制完成。
- 内核返回成功数据处理完成的指令给进程;进程在收到指令后再对数据包进程处理。
1.2.2. 优点:
进程在等待当前任务完成时,可以同时执行其他任务;进程不会被阻塞在内核等待数据过程,每次发起的I/O请求会立即返回,具有较好的实时性;
1.2. 3. 缺点:
不断的轮询将占用大量的CPU时间,系统资源利用率大打折扣,影响性能,整体数据的吞吐量下降;该模型不适用web服务器;
1.3. I/O
复用模型
I/O
复用模型也叫作事件驱动I/O
模型。这个模型中,每一个网络连接,都是非阻塞的;进程会调用select()
、poll()
、epoll()
发起系统调用请求,select()
、poll()
、epoll()
相当于内核代理,进程所有请求都会先请求这几个函数中的某一个;这个时候一个进程可以同时处理多个网络连接I/O
,这个几个函数会不断轮询负责的所有的socket
,当某一个socket
有数据报准备好了,就会返回可读信号通知给进程。
用户进程调用select/poll/epoll
后,进程实际上是被阻塞的,同时,内核会监视所有select/poll/epoll
所负责的socket
,当其中任意一个数据准备好了,就会通知进程。只不过进程是阻塞在select/poll/epoll
之上,而不是被内核准备数据过程中阻塞。此时,进程再发起recvfrom
系统调用,将数据中内核缓冲区拷贝到内核进程,这个过程是阻塞的。
虽然select/poll/epoll
可以使得进程看起来是非阻塞的,因为进程可以处理多个连接,但是最多只有1024个网络连接的I/O
;本质上进程还是阻塞的,只不过它可以处理更多的网络连接的I/O
而已。
1.3.1. 从图上我们可以看到:
第一阶段(阻塞在select/poll
之上):
- 进程向内核发起
select/poll/epoll
的系统调用,select
将该调用通知内核开始准备数据,而内核不会返回任何通知消息给进程,但进程可以继续处理更多的网络连接I/O
; - 内核收到进程的系统调用请求后,此时的数据包并未准备好,此时内核亦不会给进程发送任何消息,直到磁盘中的数据加载至内核缓冲区;而后通过
select()/poll()
函数将socket
的可读条件返回给进程
第二阶段(阻塞):
- 进程在收到
SIGIO
信号程序之后,进程向内核发起系统调用(recvfrom
); - 内核再将内核缓冲区中的数据复制到用户空间中的进程缓冲区中(真正执行
IO
过程的阶段),直到数据复制完成。 - 内核返回成功数据处理完成的指令给进程;进程在收到指令后再对数据包进程处理;处理完成后,此时的进程解除不可中断睡眠态,执行下一个
I/O
操作。
1.3.2. 优点:
- I/O复用技术的优势在于,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,所以它也是很大程度上减少了资源占用。
- 另外I/O复用技术还可以同时监听不同协议的套接字
1.3.3. 缺点:
- 在只处理连接数较小的场合,使用select的服务器不一定比多线程+阻塞I/O模型效率高,可能延迟更大,因为单个连接处理需要2次系统调用,占用时间会有增加。
1.3.4. select
、poll
和epoll
区别
Linux 提供了select
、poll
和epoll
帮助我们。一个线程可以对多个 IO 端口进行监听,当 socket 有读写事件时分发到具体的线程进行处理。
一个进程打开连接数 | IO 效率 | 消息传递方式 | |
| 32 位机器 1024 个,64 位 2048 个 | IO 效率低 | 内核需要将消息传递到用户空间,都需要内核拷贝动作 |
| 无限制,原因基于链表存储 | IO 效率低 | 内核需要将消息传递到用户空间,都需要内核拷贝动作 |
| 有上限,但很大,2G 内存 20W 左右 | 只有活跃的 socket 才调用 callback,IO 效率高 | 通过内核与用户空间共享一块内存来实现 |
1.4. 信号量驱动I/O
模型
信号驱动式I/O
是指进程预先告知内核,使得某个文件描述符上发生了变化时,内核使用信号通知该进程。
在信号驱动式I/O
模型,进程使用socket
进行信号驱动I/O
,并建立一个SIGIO
信号处理函数,当进程通过该信号处理函数向内核发起I/O
调用时,内核并没有准备好数据报,而是返回一个信号给进程,此时进程可以继续发起其他I/O
调用。也就是说,在第一阶段内核准备数据的过程中,进程并不会被阻塞,会继续执行。当数据报准备好之后,内核会递交SIGIO
信号,通知用户空间的信号处理程序,数据已准备好;此时进程会发起recvfrom
的系统调用,这一个阶段与阻塞式I/O
无异。也就是说,在第二阶段内核复制数据到用户空间的过程中,进程同样是被阻塞的。
1.4.1. 整体过程
第一阶段(非阻塞):
- 进程使用
socket
进行信号驱动I/O
,建立SIGIO
信号处理函数,向内核发起系统调用,内核在未准备好数据报的情况下返回一个信号给进程,此时进程可以继续做其他事情; - 内核将磁盘中的数据加载至内核缓冲区完成后,会递交
SIGIO
信号给用户空间的信号处理程序;
第二阶段(阻塞):
- 进程在收到
SIGIO
信号程序之后,进程向内核发起系统调用(recvfrom
); - 内核再将内核缓冲区中的数据复制到用户空间中的进程缓冲区中(真正执行
I/O
过程的阶段),直到数据复制完成。 - 内核返回成功数据处理完成的指令给进程;进程在收到指令后再对数据包进程处理;处理完成后,此时的进程解除不可中断睡眠态,执行下一个
I/O
操作。
1.4.2. 优点
- 很明显,我们的线程并没有在等待数据时被阻塞,可以提高资源的利用率
1.4.3. 缺点
- 信号I/O在大量IO操作时可能会因为信号队列溢出导致没法通知——这个是一个非常严重的问题。
1.5. 异步I/O
模型
我们在上面了解的4种I/O模型都可以划分为同步I/O
方法,我们可以注意到,在数据从内核缓冲区复制到用户缓冲区时,都需要进程显示调用recvfrom
,并且这个复制过程是阻塞的。
也就是说真正I/O
过程(这里的I/O
有点狭义,指的是内核缓冲区到用户缓冲区)是同步阻塞的,不同的是各个I/O模型在数据报准备好之前的动作不一样。
异步I/O可以说是在信号驱动式I/O
模型上改进而来。
在异步I/O
模型中,进程会向内核请求air_read
(异步读)的系统调用操作,会把套接字描述符、缓冲区指针、缓冲区大小和文件偏移一起发给内核,当内核收到后会返回“已收到”的消息给进程,此时进程可以继续处理其他I/O
任务。也就是说,在第一阶段内核准备数据的过程中,进程并不会被阻塞,会继续执行。第二阶段,当数据报准备好之后,内核会负责将数据报复制到用户进程缓冲区,这个过程也是由内核完成,进程不会被阻塞。复制完成后,内核向进程递交aio_read
的指定信号,进程在收到信号后进行处理并处理数据报向外发送。
在进程发起I/O调用到收到结果的过程,进程都是非阻塞的。
1.5.1. 整体过程
第一阶段(非阻塞):
- 进程向内核请求
air_read
(异步读)的系统调用操作,会把套接字描述符、缓冲区指针、缓冲区大小和文件偏移一起发给内核,当内核收到后会返回“已收到”的消息给进程 - 内核将磁盘中的数据加载至内核缓冲区,直到数据报准备好;
第二阶段(非阻塞):
- 内核开始复制数据,将准备好的数据报复制到进程内存空间,知道数据报复制完成
- 内核向进程递交
aio_read
的返回指令信号,通知进程数据已复制到进程内存中;
1.5.2. 优点:
- 能充分利用
DMA
的特性,将I/O
操作与计算重叠,提高性能、资源利用率与并发能力
1.5.3 缺点:
- 在程序的实现上比较困难;
- 要实现真正的异步
I/O
,操作系统需要做大量的工作。目前 Windows
下通过 IOCP
实现了真正的异步 I/O
。而在Linux
系统下,Linux 2.6
才引入,目前 AIO
并不完善,因此在Linux
下实现高并发网络编程时都是以 复用式I/O
模型为主。
二、Java
的I/O
模型
2.1. 我们在Java
中使用的是BIO
、NIO
和AIO
三种:
-
BIO
:同步并阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程并处理,如果这个连接不做任何事情会造成不必要的开销,当然可以通过线程池机制改善。 -
NIO
:同步非阻塞,服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有IO请求时才启动一个线程进行处理。 -
AIO
:异步非阻塞,服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理
2.2. 三种模型的使用场景:
-
BIO
:适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解。 -
NIO
:适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4
开始支持。 -
AIO
:使用于连接数目多且连接比较长(重操作)的架构,比如文件服务器,充分调用OS参与并发操作,编程比较复杂,JDK7
开始支持。
三、从NIO
到Netty
一般可以根
据自己的需要来选择合适的模式,一般来说,低负载、低并发的应用程序可以选择同步阻
塞I/O以降低编程复杂度,但是对于高负载、高并发的网络应用,需要使用NIO
的非阻塞
模式进行开发。
3.1. 为啥不使用nio
-
NIO
的类库和API
繁杂,使用麻烦,你需要熟练掌握Selector
、ServerSocketChannel
、SocketChannel
、ByteBuffer
等 - 需要具备其他的额外技能做铺垫,例如熟悉Java多线程编程。这是因为
NIO
编程涉 及到Reactor
模式,你必须对多线程和网路编程非常熟悉,才能编写出高质量的NIO
程序。 - 可靠性能力补齐,工作量和难度都非常大。例如客户端面临断连重连、网络闪断、
半包读写、失败缓存、网络拥塞和异常码流的处理等问题,
NIO
编程的特点是功能开发相 对容易,但是可靠性能力补齐的工作量和难度都非常大。 -
JDK NIO
的``BUG,例如臭名昭著的
epoll bug,它会导致
Selector空轮询,最终导 致CPU 100%。官方声称在
JDKL6版本的
update 18修复了该问题,但是直到
JDK1.7`版本 该问题仍旧存在,只不过该BUG发生概率降低了一些而已,它并没有被根本解决。
3.2. 选择netty
的原因:
-
API
使用简单,开发门槛低; - 功能强大,预置了多种编解码功能,支持多种主流协议
- 定制能力强,可以通过
ChanneJHandler
对通信框架进行灵活地扩展 - 性能高,通过与其他业界主流的
NIO
框架对比,Netty的综合性能最优 - 成熟、稳定,Netty修复了已经发现的所有
JDK NIO BUG
,业务开发人员不需要 再为NIO
的BUG
而烦恼; 社区活跃,版本迭代周期短,发现的BUG可以被及时修复,同时,更多的新功 能会加入; - 经历了大规模的商业应用考验,质量得到验证,在互联网、大数据、网络游戏、 企业应用、电信软件等众多行业得到成功商用,证明了它已经完全能够满足不同 行业的商业应用了。
四、netty
入门demo
4.1. 服务器端
public class DemoServer {
private final int port;
public DemoServer(int port) {
this.port = port;
}
public void start() throws InterruptedException {
NioEventLoopGroup boss = new NioEventLoopGroup();
NioEventLoopGroup worker = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(boss, worker)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline().addLast(new ServerHandler());
}
});
System.out.println(">>> 启动服务器成功......");
ChannelFuture future = bootstrap.bind(port).sync();
future.channel().closeFuture().sync();
} finally {
worker.shutdownGracefully().sync();
boss.shutdownGracefully().sync();
}
}
public static void main(String[] args) throws InterruptedException {
new DemoServer(9999).start();
}
}
上面关键位置
NioEventLoopGroup
是处理I/O操作的线程池,其中boss
主要用于处理客户端连接,worker
用于处理客户端的数据读写工作。
ServerBootstrap
是启动NIO
服务端的辅助启动类,目的是为了降低服务端的开发复杂度。
group
会将两个NIO
线程组当做入参传递到ServerBootstrap
中。
channel
指定所使用的NIO
传输 Channel
。
ServerHandler
用户处理I/O
事件处理,例如日志、编码和解码等。
bind
用于绑定监听端口,然后调用同步阻塞方法等待绑定完成,完成之后会返回一个ChannelFuture
对象,主要用户异步通知回调。
closeFuture
等待服务端链路关闭之后主线程才退出。
shutdownGracefully
将会释放跟其关联的资源。
public class ServerHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf byteBuf = (ByteBuf) msg;
System.out.println("服务器接收的消息:" + byteBuf.toString(CharsetUtil.UTF_8));
ctx.write(byteBuf);
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
ctx.writeAndFlush(Unpooled.EMPTY_BUFFER)
.addListener(ChannelFutureListener.CLOSE);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}
ServerHandler
继承ChannelInboundHandlerAdapter
,它用于对网络事件进行读写操作。一般来说只需要关注channelRead
和exceptionCaught
。
channelRead
:接受消息,做处理
channelReadComplete
:channelRead()执行完成后,关闭channel连接。
exceptionCaught
:发生异常之后,打印堆栈,关闭通道。
4.2. 客户端
public class DemoClient {
private final String host;
private final int post;
public DemoClient(String host, int post) {
this.host = host;
this.post = post;
}
public void start() throws InterruptedException {
EventLoopGroup group = new NioEventLoopGroup(); // 1 第一步
try {
Bootstrap bootstrap = new Bootstrap(); // 2 第二部
bootstrap.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() { // 第三步
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline().addLast(new ClientHandler());
}
});
ChannelFuture future = bootstrap.connect(host, post).sync(); // 第四步
future.channel().closeFuture().sync(); // 第五步
} finally {
group.shutdownGracefully().sync(); // 第六步
}
}
public static void main(String[] args) throws InterruptedException {
new DemoClient("127.0.0.1", 9999).start();
}
}
客户端创建过程:
第一步,首先创建客户端处理I/O读写的NioEventLoop
Group
线程组,然后继续创建客户端辅助启动类Bootstrap
,随后需要对其进行配置。
第二部,将Channel
需要设置为NioSocketChanneL
然后为其添加handler
第三步,此处
创建匿名内部类,实现initChannel
方法,其作用是当创建NioSocketChannel
成功之后,在初始化它的时候将它的ChannelHandler
设置到ChannelPipeline
中,用于处理
网络I/O
事件。
第四步,客户端启动辅助类设置完成之后,调用connect
方法发起异步连接,然后调用同步方法等待连接成功。
第五步,当客户端连接关闭之后,客户端主函数退出,在退出之前,释放NIO
线程组的相关资源。
public class ClientHandler extends SimpleChannelInboundHandler<ByteBuf> {
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf) throws Exception {
System.out.println("客户端收到消息:" + byteBuf.toString());
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.writeAndFlush(Unpooled.copiedBuffer("hello world", CharsetUtil.UTF_8));
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}
具体处理I/O
的ClientHandler
,里面有三个重要的方法,channelRead0
、channelActive
和exceptionCaught
。
当客户端
和服务端TCP
链路建立成功之后,Netty
的NIO
线程会调用channelActive
方法,发送Hello world
指令给服务端,调用ChannelHandlerContext
的writeAndFlush
方法将请求消息发送给服务器端。
接着当服务器端返回应答信息的时候,channelRead0
将被调用。如果发生异常,exceptionCaught
将被调用。
4.3. 结果
五、相关参考:
Linux系统I/O模型详解 https://blog.51cto.com/ccschan/2357207
Linux下的I/O模型以及各自的优缺点 https://www.linuxidc.com/Linux/2017-09/146682.htm
netty
权威指南