随着业务的发展,现在大部分的互联网的业务系统都是BS结构。也就意味着我们的业务系统要承接大量的并发连接。如何迎接大规模并发IO请求挑战?---NIO 出现了。在Java中可以通过框架来实现网络编程。
Java socket 编程 处理网络 IO
为什么服务端要接收请求?在cs 通信模型中,服务端是处在那里不动的,全世界的C端都可以发起请求连接server端然后与server端进行通信。
建立连接 : 三次握手
全双工通信:client 可以通过outputstream 将自己的数据写入 server 端中的 inputstream 中。server端拿到数据后处理请求。server端也可以将数据通过outputstream 传入到 client 中的 inputstream 中,client 拿到数据进行处理。 这种通信双方是对等的(server端可以给client 主动发送数据。client 也可以主动的给server端发送数据)。我们称为全双工的通信。
结束通信 : 四次挥手。
在通信过程中,不断有数据的写入写出。server 端可能要做一些具体的业务处理,如 服务的计算、代码逻辑得执行。执行完之后结果数据打包通过socket 通信发送给我们的client端。server端总体逻辑操作分为两大类。
- cpu 密集型/业务处理。(代码运算、对象处理、业务主流程的处理等等)。 IO 密集型操作 :IO操作与等待/网络、磁盘、数据。(读取磁盘、访问数据库、通过网络去通信等)。
一个简单的httpserver 示例
import java.io.IOException;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class HTTPServer01 {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(1234);
while (true) {
Socket socket = serverSocket.accept();
service(socket);
}
}
// 处理请求
private static void service(Socket socket) {
PrintWriter printWriter;
try {
printWriter = new PrintWriter(socket.getOutputStream(), true);
printWriter.println("HTTP/1.1 200 ok");
printWriter.println("content-Type:text/html;charset=utf-8");
String body = "hello nio";
// 一定要在报文头里显示的告诉client端,报文体大概长度是多少,如果不告诉,client 不知道在哪里结束,可能就会读取错误
printWriter.println("content-length" + body.getBytes(StandardCharsets.UTF_8).length);
printWriter.println();
printWriter.write(body);
printWriter.close();
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
压测结果
- 设置 后续压测都基于 -Xmx512m 进行
优化
我们发现,每次必须得请求处理完成之后,才能够进行下一次处理(走这个while循环)。
while (true) {
Socket socket = serverSocket.accept();
new Thread(() -> {
service(socket);
}).start();
}
}
进一步优化 、 使用线程池复用线程资源。避免线程创建和销毁带来的巨额开销。
ServerSocket serverSocket = new ServerSocket(8803);
ExecutorService executorService = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() + 2
);
while (true) {
Socket socket = serverSocket.accept();
executorService.execute(() -> service(socket));
}
发的IO密集型网络处理应用程序分析
首先:对一个高并发的io密集型的网络处理的应用程序。不光面临在io等待的时间段造成浪费。另一方面,现在的系统都部署在LInux服务器上。在 Linux服务器上,我们整个内存分用户空间和内核空间用户进程运行在用户空间里。操作系统相关的东西,如网络的底层在内核空间里。真实的 socket 先通过 socket 网卡读取到内核空间, 进一步需要通过内核空间进一步复制到用户空间。我们业务程序的进程才能够读取和使用它。发数据同样也是。 数据要多一次 中间 的复制和拷贝
IO 流程进一步细分 优化高并发下的网络处理
基于流水线化的处理 ,把整个IO处理的过程拆分成很多不同的步骤。每个步骤比如说都用一个线程池进行处理。IO模型不断发展的过程,就是对IO 处理的整个过程进行细化拆分。针对每个阶段它的一些具体的特点。针对性的进行优化。这样就会产生很多不同的IO模型。
IO模型与相关概念
阻塞、非阻塞、同步、异步 差别是什么
- 线程的处理模式
- 阻塞 :线程空转、不动了。
- 非阻塞:线程不阻塞、继续可以处理。
- 针对于通信 (一个业务调用另一个业务的时候)
- 同步:调用业务 我就一直等着,直到另一个系统给到响应。
- 异步:发一个请求,就不用管了。等到另一个业务通知,或者我去主动检查是否处理。
阻塞式IO 模型 BIO
一般通过while(TRUE) 循环中服务端调用accpet()方法等待客户端的连接的方式监听请求,请求一旦接到一个连接请求。就可以建立通信套接字在这个套接字上进行读写操作。此时不能在接收其他客户端请求,只能等待同当前的客户端的操作执行完成。可以通过多线程进行异步调用来优化。
不管等待多长时间,都需要内核将数据准备好。然后才把内核的数据复制到用户空间里。然后交给业务系统的进程来处理。
同步非阻塞IO 拆成两个大阶段
和阻塞式IO相比,内核会立即返回结果。返回后获得足够的cpu时间继续做其他事情。用户第一个阶段不是阻塞的。需要不断地询问内核数据库准备好了没。第二个阶段仍然是阻塞的。
性能远远高于 阻塞式IO
多路IO 复用 (可以抗住几万、十万、甚至百万的连接)
可以看作是 非阻塞IO的升级版。多路复用。
在单个县城里同时监控多个套接字。通过select 或 poll 轮询所有的 socket。当有个socket 有数据到达了,就通知用户进程。IO复用同非阻塞IO本质一样。利用了新的select系统调用。由进程轮询变为内核轮询。多了一个系统调用的函数开销。但是它可以支持多路IO ,这点来说 提高了效率。
- 两阶段阻塞:先阻塞在
select/poll上,再阻塞在 数据从内核复制到用户空间上。
使用 select/poll 实现的多路复用,并不只有优点没有缺点。我们在linux 中操作的所有东西,都是 fd(文件描述符)。每次调用select的时候都需要把当前这个select维护的一系列对象 fd ,从用户态到内核态之间来回拷贝,这个开销在fd集合很大时是是非常大的。而且,每次调用select都需要在内核遍历传进来所有fd,这个开销在 fd 很多事也会很大。select 支持的文件描述符数量也很小,默认支持1024个fd
epoll 技术的出现。 2.6 正式引入。
- 内核和用户共享一块内存。(不再进行用户态和内核态的拷贝)
- 通过回调函数解决遍历问题。(在fd集合里挂了一些回调函数,fd对应的socket 数据准备好了,可以直接调用回调函数)
- fd 没有限制,可以支撑10w连接。
信号驱动的IO
与 NIO 最大的区别是 , 在信号准备阶段,不需要轮询了。当用户进程需要数据的时候,向操作系统内核发送一个信号,告诉操作系统我们需要什么数据。然后用户进程就去忙别的事情了。当内核数据准备好后,操作系统内核会立马返回一个通知,通知用户进程数据准备好了。用户进程收到后,立马去调用recvfrom 去查收数据。
异步IO模型 (常用的Linux内核是不支持的)
真正实现了IO全流程的非阻塞。用户进程发出系统调用后立即返回。内核帮我们准备数据 + 从内核缓冲区复制到用户进程缓冲区。完成后通知应用程序。真正实现了异步,但是由于 操作系统的大量不支持,此款IO模型使用的较少。
生活举例 (打印店例子)
- BIO:同步阻塞,直接排队,啥也不干,直到轮到自己使用打印机了(数据准备),才可以打印文件(数据复制)。
- NIO( reactor):拿个打印号码,该干啥干啥,轮到自己使用打印机,店主(内核)通过喇叭通知,打印文件。
- 异步IO(Proactor):拿个号码,店主帮你干完所有事情,你再去通过号码拿。
Netty 框架
Java 技术平台上做网络应用编程的首选。常见的类库、框架很多东西都是基于Netty做封装进行实现的。如 spring5 中的 webflux 底层就是 reactor Netty 。zuul、Gateway 底层也都是Netty。只要我们写应用程序,有server端和client端、就可以用Netty。
Netty 设计的三大模块
- Netty 核心 : 支持玩过传输使用的各种高效数据结构。例如 ByteBuffer。在byte buffer之上包装了一层网络通信的API,之上又是Netty本身的事件模型。
- 传输服务层。
- 协议支持层。
三大特点
- 异步
- 基于NIO
- 事件驱动
适用于
- 服务端、客户端、TCP/UDP/HTTP(他支持的协议都可以,开箱即用,也可以支持我们自定义协议)
特性
- 高吞吐:基于NIO模型,相对于BIO来说,吞吐量增大。
- 低延迟
- 低开销
- 零拷贝:内核与用户空间共用一片内存区域。
- 可扩容:底层实现byteB Buffer 可以动态扩容。放数据的时候基本都可以放下。
- 使用角度
- 松耦合:写业务代码只用关心业务代码逻辑,写网络处理只用关心网络处理逻辑。
- 使用方便可维护性好。
基本概念
- Channel : 通道、NIO中的基础概念,可执行读取/写入IO操作。对channel所有IO操作都是非阻塞的。不用直接操作socket 了
- Channel Future:Java Future 接口,只可以查询操作的完成情况,或阻塞当前线程等待操作完成。Netty 封装一个Channel Future接口,我们可以将回调方法传给它,在操作完成时自动执行。
- event(事件) & handler(处理器) : 基于事件驱动,事件处理器可以关联到入站和出站数据流。
- encoder & decoder : 处理网络IO,序列化和反序列化的操作。
- channel Pipeline : 事件处理器链,他是有顺序性的,同一channel的出站和入站处理器都在同一列表中。(作为一个通用的网络框架,对不同网络应用处理的场景、处理的环节是不同的)
netty 事件处理接口及适配器(空实现,需要继承使用)
- ChannelHandler
- ChannelOutboundHandler : 出站,数据出去。
- ChannerInboundHandler : 入站,数据进来。
- ChannelOutboundHandlerAdapter:匹配一些使用场景 做一些中间这样的适配
- ChannelInboundHandlerAdapter
入站、出站数据的解释:比如说现在是server端的一个程序,入站对server端来说就是有c端给我发送数据。数据从网络通道打开到最终我们拿到这个数据,叫做入站。相反:把数据往c端去写,写入数据+refresh 数据 , 把数据真正发送到客户端的数据这个操作流程就是出站。
事件分类
-
入站
- 通道的激活与停用
- 读操作时间
- 异常时间
- 用户事件
-
出站
- 打开链接
- 关闭连接
- 写入数据
- 刷新数据