文章目录
- 一、Linux网络I/O模型简介
- 0.文件描述符和系统调用
- 1. 阻塞I/O模型
- 2. 非阻塞I/O模型(轮询)
- 3. I/O复用模型(轮询、事件驱动)
- 二、Java的I/O演进
- 1.BIO(阻塞)
- (1)套接字
- (2)线程池
- (3)工作流程
- (4)代码实现
- (5)缺点
- 2.NIO(轮询)
- (1)工作流程
- (2)代码实现
- 3.NIO2.0——AIO(异步/事件驱动)
- 三、Netty(NIO+AIO)
- 1.Reactor线程模型
- (1)单Reactor单线程模型
- (2)单Reactor多线程模型
- (3)主从Reactor多线程模型(Netty)
- 2.Netty快速上手
- 3.Netty核心组件
- (1)网络通信层
- (2)事件调度层
- (3)服务编排层
- 参考
一、Linux网络I/O模型简介
Linux的I/O模型是指在进行输入输出操作时,操作系统处理数据的方式。它涉及到如何处理数据的传输,以及应用程序在数据就绪时如何进行读取和写入。
0.文件描述符和系统调用
Linux的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个file descriptor(fd,文件描述符)。而对一个socket的读写也会有相应的描述符,称为socketfd(socket描述符),描述符就是一个数字,它指向内核中的一个结构体(文件路径,数据区等一些属性)。
-
外部设备视为文件:
在Linux操作系统中,所有外部设备(如磁盘、串口、网络套接字等)都被抽象地看作文件。这种抽象使得对这些设备的操作和对文件的操作类似,从而使操作系统的编程模型更加一致和统一。 -
系统调用和文件描述符:
当应用程序需要与文件或设备进行交互时,它会调用内核提供的系统调用。系统调用是应用程序和操作系统内核之间的接口,用于执行底层操作。例如,对于文件的读写操作,应用程序会调用系统调用,如read()
和write()
。 -
文件描述符(File Descriptor):
在进行文件或设备操作时,内核为每个操作返回一个唯一的标识符,称为文件描述符。文件描述符是一个整数,作为操作系统在内部维护的标识符,用于识别打开的文件、设备或套接字。 -
Socket描述符:
对于网络套接字,也存在类似于文件描述符的概念,称为Socket描述符(socketfd)。套接字描述符用于标识应用程序与网络上的其他设备或程序之间的通信通道。 -
描述符指向结构体:
文件描述符(或Socket描述符)是一个数字,它在内核中对应于一个数据结构,通常是一个记录文件属性、位置、权限等信息的结构体。这个结构体包含了文件或套接字的相关信息,内核使用它来管理文件的状态以及对其进行读写等操作。
总之,Linux将外部设备和网络套接字都看作文件,并使用系统调用获得文件描述符或Socket描述符,以便于应用程序与内核进行文件和设备操作的交互。描述符则是在内核中维护的数据结构的引用,用于管理对文件和设备的操作。
1. 阻塞I/O模型
系统调用:
- 对于阻塞I/O模型,主要的系统调用是
read()
和write()
,用于读取和写入数据。
阻塞等待:
- 当应用程序调用阻塞式I/O操作,例如
read()
,内核会检查数据是否已经准备好。如果数据没有准备好,进程将被阻塞,直到数据就绪。 - 在阻塞等待期间,操作系统会将该进程设置为睡眠状态,直到所需数据就绪。这样,CPU不会浪费在轮询上。
数据准备就绪:
- 当数据准备就绪,比如一个文件中的数据被读取到缓冲区中,内核会从睡眠状态唤醒进程,将数据从内核空间拷贝到用户空间,并返回到应用程序。
阻塞特性:
- 阻塞I/O模型的特点是,在数据就绪之前,应用程序会一直等待,不会执行其他任务。这会导致并发性能较低,因为进程在等待I/O完成期间无法处理其他任务。
2. 非阻塞I/O模型(轮询)
Linux的非阻塞I/O模型是一种基于事件驱动的I/O处理方式,它在进行I/O操作时不会阻塞整个进程,而是通过轮询或异步通知的方式来实现。以下是Linux非阻塞I/O模型的底层原理解释:
非阻塞系统调用:
在非阻塞I/O模型中,应用程序使用非阻塞的系统调用(如read()和write())来进行数据读写操作。这些非阻塞系统调用会立即返回,无论数据是否可用,从而避免了阻塞进程的情况。
轮询机制:
在非阻塞I/O中,应用程序会通过不断地轮询来检查文件描述符(或Socket描述符)是否有可读或可写的数据。这样,当应用程序需要读取数据时,它会重复调用非阻塞的read()系统调用,如果数据还未准备好,则read()会立即返回,不会阻塞进程。
这种模型需要应用程序不断地轮询数据是否就绪,可能会导致CPU资源的浪费。
3. I/O复用模型(轮询、事件驱动)
Linux I/O 复用模型是一种高效的事件驱动编程方式,它允许单个进程监视多个文件描述符(如套接字、文件、管道等),并在这些描述符上等待事件的发生,从而实现了高并发的网络编程。下面是 Linux I/O 复用模型的底层原理解释:
-
系统调用: 在 Linux 中,有几种 I/O 复用模型,如
select()
、poll()
、epoll()
等。这些都是系统调用,可以用来监听多个文件描述符上的事件。 -
select():
select()
是传统的 I/O 复用模型,它使用一个文件描述符集合来监听多个文件描述符。应用程序通过调用select()
等待事件发生,当有一个或多个文件描述符上的事件发生时,select()
会返回,然后应用程序需要遍历所有文件描述符,找出哪些发生了事件。 -
poll():
poll()
与select()
类似,但是它没有最大文件描述符数量的限制,而且更加灵活。应用程序通过调用poll()
等待事件发生,当有文件描述符上的事件发生时,poll()
会返回,然后应用程序遍历找出哪些发生了事件。 -
epoll():
epoll()
是 Linux 中性能最好的 I/O 复用模型,它使用事件驱动的方式,避免了遍历所有文件描述符。应用程序通过调用epoll_create()
创建一个 epoll 实例,并通过epoll_ctl()
注册文件描述符。当有事件发生时,epoll_wait()
会返回有事件发生的文件描述符列表,应用程序只需处理这些文件描述符。
使用场景分析:
当连接数较少,并且读写较为活跃时,使用select、poll能获得更好的性能;
当连接数较多,并且大多都不活跃时,使用基于回调机制的epoll性能更好,而基于遍历的select、poll性能会急速下降。
由于epoll的连接数无限制,特别适合高并发场景下使用。相比之下select、poll单个进程所打开的FD是有一定限制的。
二、Java的I/O演进
1.BIO(阻塞)
网络编程的基本模型是Client/Server模型,也就是两个进程之间进行相互通信,其中服务端提供位置信息(绑定的IP地址和监听端口),客户端通过连接操作向服务端监听的地址发起连接请求,通过三次握手建立连接,如果连接建立成功,双方就可以通过网络套接字(Socket)进行通信。
在基于传统同步阻塞模型开发中,ServerSocket负责绑定IP地址,启动监听端口;Socket负责发起连接操作。连接成功之后,双方通过输入和输出流进行同步阻塞式通信。
BIO,即Blocking IO,阻塞型I/O。阻塞体现在两个地方,连接线程的阻塞和读写的阻塞。
(1)套接字
在计算机网络编程技术中,两个进程或者说两台计算机可以通过一个网络通信连接实现数据的交换,这种通信链路的端点就被称为“套接字”(英文名称也就是Socket)。在Java语言中,服务端使用ServerSocket套接字,客户端使用Socket套接字。
- ServerSocket:
// 创建ServerSocket对象
ServerSocket(int port):这个使用指定的端口去创建ServerSocket,IP地址使用默认的本地IP地址
ServetSocket(int port,int backlog):除了端口外,还有一个用来改变队列长度参数的backlog,指定当服务器繁忙时,可以与之保持连接请求的客户端数量,默认为50
ServetSocket(int port,int backlog,InetAddress ip):这个使用指定的端口、backlog、地址去创建ServetSocket
// 两个静态方法获取InetAddress对象:
getByName(String hostName)
getByAddress(byte[] address)
// accept()
serverSocket.accept():没有参数,返回一个Socket,如果接收到客户端的一个Socket,则返回,否则一直处于等待状态,线程也被阻塞。
- Socket:
// 创建Socket对象
Socket(InetAddress address,int port):使用指定IP与指定端口构造Socket,默认使用本地ip,端口则动态分配
Socket(String address,int port):使用String表示IP
Socket(InetAddress address,int port,InetAddress localAddr,int localPort):创建指定了远程IP、远程端口、本地IP、本地端口的Socket
Socket(String address,int port,InetAddress localAddr,int localPort):使用String表示远程IP,用InetAddress表示本地IP
- 简单代码示例:
package socket;
import java.io.IOException;
import java.io.PrintStream;
import java.net.ServerSocket;
import java.net.Socket;
public class Server {
public static void main(String[] args) throws IOException{
ServerSocket serverSocket = new ServerSocket(8888);
while(true){
Socket socket=serverSocket.accept();//阻塞等待客户端连接
PrintStream printStream = new PrintStream(socket.getOutputStream());//创建输出流
printStream.println("message from server 8888");
printStream.close();
socket.close();
}
}
}
package socket;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
public class Client {
public static void main(String[] args) throws IOException{
Socket socket = new Socket("127.0.0.1", 8888);
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
System.out.println("This message comes from server:"+bufferedReader.readLine());
bufferedReader.close();
socket.close();
}
}
跑一下试试,成功!
(2)线程池
请看我写的另一篇博客:跳转
(3)工作流程
服务端启动ServerSocket;
客户端启动 Socket 对服务器进行通信,服务端对每个客户端建立一个线程与之通讯(可以使用线程池进行优化);
客户端发出请求后,先咨询服务器是否有线程响应,如果没有则会等待(即阻塞);
如果有响应,客户端线程会等待请求结束后,再继续执行。
(4)代码实现
- 服务端:
package bio;
import java.io.IOException;
import java.io.PrintStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class BIOServer {
//创建一个线程池,用于处理客户端连接后的工作
public static ThreadPoolExecutor pool=new ThreadPoolExecutor(10, 10, 60, TimeUnit.SECONDS, new LinkedBlockingDeque<>());
public static void main(String[] args) throws IOException{
ServerSocket serverSocket=new ServerSocket(8888);
while(true){
//1 等待客户端连接是阻塞的
Socket socket=serverSocket.accept();
System.out.println("客户端连接上了");
//2 连接上以后向线程池提交一个任务用于处理连接
pool.execute(new Runnable() {
@Override
public void run() {
while(true){
try{
//读写也是阻塞的
//创建输出流,server向client输出
PrintStream printStream = new PrintStream(socket.getOutputStream());
printStream.println("message from server 8888");
printStream.close();
socket.close();
}catch(IOException e){
e.printStackTrace();
}
}
}
});
}
}
}
- 客户端:
package bio;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
public class BIOClient {
public static void main(String[] args) throws IOException{
Socket socket = new Socket("127.0.0.1", 8888);
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
System.out.println("This message comes from server:"+bufferedReader.readLine());
bufferedReader.close();
socket.close();
}
}
(5)缺点
- accept()等待客户端连接是阻塞的,有时候需要进行无谓的等待,效率低下,浪费资源。
- 引入线程池进行优化提升了高并发能力,即能够同时处理多个客户端请求了,但是却带来了一个问题,随着开启的线程数目增多,将会消耗过多的内存资源,导致服务器变慢甚至崩溃。
- 读写操作仍然是阻塞的,如果客户端半天没有操作,也会浪费资源,因此效率不高。
2.NIO(轮询)
NIO,即non-blocking lO,非阻塞型IO。
-
非阻塞——减少线程资源的浪费:
BIO提供非阻塞读写模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。可以做到 用一个线程来处理多个操作,体现了一种多路复用的思想。 而不是像BIO那样,一个连接过来就得分配一个线程,造成资源的浪费。 -
三大核心组件——提升效率:
NIO基于Channel(通道)和Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于监听多个通道的事件(比如连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道。
(1)工作流程
Channel(通道),Buffer(缓冲区), Selector(选择器)为NIO的三大核心组件。
-
Channel(通道):
相比于BIO流的读写,Channel的读写是双向的,既可以从通道中读取数据,又可以写数据到通道。通道可以非阻塞读取和写入通道/缓冲区,也支持异步地读写。 -
Buffer(缓冲区):
在客户端和Channel之间,增加Buffer缓冲区的支持,更加容易操作和管理。 -
Selector(选择器):
用来 轮询 检查一个或多个NIO通道,并确定哪些通道已经准备好进行读取或写入。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接,提高效率。
(2)代码实现
代码来自:here
- 服务端:
package nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
public class NIOServer {
public static void main(String[] args) throws IOException {
// 创建ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 创建一个Selector对象,
Selector selector = Selector.open();
// 绑定端口6666, 在服务器端监听
serverSocketChannel.socket().bind(new InetSocketAddress(6666));
// 设置为非阻塞
serverSocketChannel.configureBlocking(false);
// 把serverSocketChannel注册到selector
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 循环等待用户连接
while (true){
if (selector.select(1000) == 0){ //等待(阻塞)一秒, 没有事件发生
// if (selector.selectNow() == 0){ // 也可以设置成非阻塞的
System.out.println("服务器等待了一秒,无连接");
continue;
}
// 如果返回的>0 , 说明客户端有了动作,就获取相关的selectionKey集合
Set<SelectionKey> selectionKeys = selector.selectedKeys(); // 返回关注事件的集合
// 遍历selectionKeys
Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
while (keyIterator.hasNext()){
// 获取到selectionKey
SelectionKey key = keyIterator.next();
//根据key对应的通道获取事件并做相应处理
if (key.isAcceptable()){
//如果是OP_ACCEPT, 表示有新的客户端产生
//给该客户端生成SocketChannel
SocketChannel socketChannel = serverSocketChannel.accept();
//将socketChannnel设置为非阻塞
socketChannel.configureBlocking(false);
//将socketChannel注册到selector上, 设置事件为OP_READ,同时给socketChannel关联一个buffer
socketChannel.register(selector,SelectionKey.OP_READ, ByteBuffer.allocate(1024));
}
if (key.isReadable()){
// 发生了OP_READ
SocketChannel channel=(SocketChannel)key.channel();
ByteBuffer buffer = (ByteBuffer)key.attachment();
channel.read(buffer);
System.out.println("from 客户端"+new String(buffer.array()));
}
// 手动从集合中移除当前的selectionKey, 防止多线程情况下的重复操作
keyIterator.remove();
}
}
}
}
- 客户端:
package nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
public class NIOClient {
public static void main(String[] args) throws IOException {
// 获取一个网络通道
SocketChannel socketChannel = SocketChannel.open();
// 设置为非阻塞
socketChannel.configureBlocking(false);
//设置服务器端ip和端口
InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 6666);
if (!socketChannel.connect(inetSocketAddress)){
while (!socketChannel.finishConnect()){
//如果没有连接成功,客户端是非阻塞的,可以做其它工作
System.out.println("等待连接...");
}
}
// 如果连接成功,就发送数据
String str = "hello world";
ByteBuffer buffer = ByteBuffer.wrap(str.getBytes());
// 发送数据 , 将buffer中的数据写入到channel中
socketChannel.write(buffer);
System.in.read();
}
}
3.NIO2.0——AIO(异步/事件驱动)
AIO,即Asynchronous I/O,异步非阻塞IO。AIO提供的最大的特点是具备异步功能,采用“订阅-通知”模式,即应用程序向操作系统注册IO监听,然后继续做自己的事情。当操作系统发生IO事件,并且准备好数据后,在主动通知应用程序,触发相应的函数。
下面是一段简单的代码示例:
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
public class NIO2AsyncFileIOExample {
public static void main(String[] args) {
try {
// 通过路径获取文件通道
Path path = Paths.get("test.txt");
AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(
path, StandardOpenOption.READ, StandardOpenOption.WRITE);
// 分配缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 异步读取文件
Future<Integer> readResult = fileChannel.read(buffer, 0);
while (!readResult.isDone()) {
// 在等待异步读取完成时可以进行其他操作
System.out.println("Waiting for read operation to complete...");
}
// 打印读取结果
buffer.flip();
System.out.println("Read data: ");
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
System.out.println();
// 异步写入数据
String newData = "Hello, NIO 2.0!";
buffer.clear();
buffer.put(newData.getBytes());
buffer.flip();
Future<Integer> writeResult = fileChannel.write(buffer, 0);
while (!writeResult.isDone()) {
// 在等待异步写入完成时可以进行其他操作
System.out.println("Waiting for write operation to complete...");
}
System.out.println("Data written to file.");
// 关闭文件通道
fileChannel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
三、Netty(NIO+AIO)
- 为什么不建议使用原生Java NIO:
开发出高质量的NIO程序并不是一件简单的事情,除去NIO固有的复杂性和BUG不谈,作为一个NIO服务端,需要能够处理网络的闪断、客户端的重复接入、客户端的安全认证、消息的编解码、半包读写等情况,如果你没有足够的NIO编程经验积累,一个NIO框架的稳定往往需要半年甚至更长的时间。更为糟糕的是,一旦在生产环境中发生问题,往往会导致跨节点的服务调用中断,严重的可能会导致整个集群环境都不可用,需要重启服务器,这种非正常停机会带来巨大的损失。
从可维护性角度看,由于NIO采用了异步非阻塞编程模型,而且是一个I/O线程处理多条链路,它的调试和跟踪非常麻烦,特别是生产环境中的问题,我们无法进行有效的调试和跟踪,往往只能靠一些日志来辅助分析,定位难度很大。
- Netty的优点:
Netty是业界最流行的NIO框架之一,它的健壮性、功能、性能、可定制性和可扩展性在同类框架中都是首屈一指的,它已经得到成百上千的商用项目验证,例如Hadoop的RPC框架avro使用Netty作为底层通信框架;很多其他业界主流的RPC框架,也使用Netty来构建高性能的异步通信能力。
通过对Netty的分析,我将它的优点总结如下:
◎ API使用简单,开发门槛低;
◎ 功能强大,预置了多种编解码功能,支持多种主流协议;
◎ 定制能力强,可以通过ChannelHandler对通信框架进行灵活地扩展;
◎ 性能高,通过与其他业界主流的NIO框架对比,Netty的综合性能最优;
◎ 成熟、稳定,Netty修复了已经发现的所有JDK NIO BUG,业务开发人员不需要再为NIO的BUG而烦恼;
◎ 社区活跃,版本迭代周期短,发现的BUG可以被及时修复,同时,更多的新功能会加入;
◎ 经历了大规模的商业应用考验,质量得到验证。在互联网、大数据、网络游戏、企业应用、电信软件等众多行业得到成功商用,证明了它已经完全能够满足不同行业的商业应用了。
正是因为这些优点,Netty逐渐成为Java NIO编程的首选框架。
1.Reactor线程模型
(1)单Reactor单线程模型
只有一个线程来执行所有的任务,效率低下,并且也有可靠性问题。
(2)单Reactor多线程模型
相比于上一个模型,增加了线程池的支持,从一定程度上提升了并发效率,但是引入线程池可能会涉及到数据同步问题。Redis底层就是基于这种模型。
(3)主从Reactor多线程模型(Netty)
在上一个模型的基础上,一个Reactor变成了两个,主Reactor创建连接,从Reactor分发读写任务,能支持更高的并发量。Netty是基于这种模型。
2.Netty快速上手
Netty实战:开发一个仿WeChat聊天工具SmartChat
3.Netty核心组件
Netty的设计目标是提供高性能、高可靠性的网络通信框架。因此,开发者可以在Netty的基础上实现自己的协议栈,从而构建各种类型的网络应用,包括传统的TCP/UDP应用、HTTP服务、WebSocket服务器等。这种灵活性使得Netty成为了一个通用的网络编程框架,可以满足不同领域的需求。
(1)网络通信层
-
Bootstrap:
负责客户端启动并用来连接远程Netty Server。 -
ServerBootstrap:
负责服务端监听指定端口。 -
Channel:
完成网络通信的载体。
(2)事件调度层
-
EventLoopGroup:
线程池,负责接收IO请求,并分配线程执行任务。 -
EventLoop:
线程池中的线程。
(3)服务编排层
-
ChannelHandler:
通过指定的handler处理数据IO。 -
ChannelHandlerContext:
保存ChannelHandler的上下文。 -
ChannelPipeline:
将多个ChannelHandler链接在一起。
参考
套接字:https://blog.csdn.net/qq_27525611/article/details/102633014
BIO:https://blog.csdn.net/java_lg/article/details/126274158
NIO:https://blog.csdn.net/K_520_W/article/details/123454627
NIO:https://www.cnblogs.com/jobbible/p/16913990.html
AIO:https://zhuanlan.zhihu.com/p/504968873
《Netty权威指南》李林锋