1. 傻傻分不清楚的 IO
1.1 写在前面 (音频高级版访问地址)
有些废话总想在开头说说嘿嘿,写这篇文章主要目的是对于 网络编程 Netty 的先导学习,学习 Netty 之前我们总应该弄清楚网络 IO 那些事儿。
相信很多同学对于网络 IO 来说是相对陌生的,在很长时间的开发中可能都不会用到 IO 或者说 Netty 。但是网上大家都在夸夸其谈,Netty 怎么怎么好,怎么怎么 np,似乎大家都在用,我也想入门但却很迷糊,Netty这玩意到底是干啥的?我用它干嘛?相信对于初学的你都会有类似的疑问。
本文会带你一起聊聊 这些 IO 有什么区别, Socket 到底是什么?Netty 与他们是什么关系,希望对你有所帮助。
1.2 一切的基础 Socket
1.2.1 Socket 简单介绍及理解
Socket 翻译过来是 插槽 。我们一般会称之为 网络插槽 。一个简单的理解就是:
在 客户端 与 服务端 进行通信的时候 两端会同时生成一个 Socket ,那么通信时都是通过 该 Socket 进行通信。
Socket 就像是一个文件进行读写,那么实际多数操作系统对于 Socket 的底层实现就是文件,如我们常用的 Linux。
先解释一下常说的 fd 是 (file descriptor),这种一般是 BSD Socket 伯克利套接字(Berkeley sockets) 的用法,用在 Unix/Linux 系统上。在 Unix/Linux 系统下,一个socket句柄,可以看做是一个文件,在 socket 上收发数据,相当于对一个文件进行读写,所以一个 socket 句柄,通常也用表示文件句柄的 fd 来表示。
1.2.2 Socket 进一步解释-线程模型
我们从硬件的角度简单理解下数据的传输过程即:
数据 通过网线 到机器网卡 最终将数据写入机器内存
当网卡把数据写入到内存后,网卡向 cpu 发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据。
一个完整的请求处理线程模型如下所示,我们假设 线程 C 运行以下代码
//创建socket
int s = socket(AF_INET, SOCK_STREAM, 0);
//绑定
bind(s, ...)
//监听
listen(s, ...)
//接受客户端连接
int c = accept(s, ...)
//接收客户端数据
recv(c, ...);
//将数据打印出来
printf(...)
当客户端网络请求到服务端时(如 TCP 连接 ),该请求会进入 Pendling Queue 队列中,直到某个线程出发了 接受客户端连接 accept 操作,这里具体化即 上述代码执行到 accept ,该操作会取出某个请求并创建对应的 Socket 文件,该 Socket 包含 发送缓冲区、等待缓冲区、等待队列等。这里会阻塞等待接受数据 即 执行到 recv 时,会将对应线程阻塞,并将其加入到 socket 的等待队列中,直到该 socket 接收到数据后会将该线程恢复到工作队列,并移出等待队列。
值得一提的是:Socket 是介于 应用层 和 网络层之间的,具体一点可以理解为 介于 Http 和 Tcp 之间。
1.3 什么是 BIO 、NIO
通过上面的讲解,相信大家对 Socket 已经有一定的具象化理解了,这个时候我们再来聊聊 IO 模型。
所谓 IO 模型 即 使用某种模式及通道来进行网络数据传输, Java 为我们提供了 3 种 :BIO 、NIO 、AIO
1.3.1 BIO (Blocking IO 阻塞式 IO)
同步阻塞模型,一个客户端连接 对应一个处理线程
BIO 简单的服务端实现 Java 如下
请求测试可直接在控制台运行 telnet localhost 19001
进行请求
数据发送使用control + ]
+ 命令 send ip
键入数据,回车发送
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SocketServer {
private static ExecutorService bossPoolExecutor= Executors.newFixedThreadPool(1200);
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(19001);
while (true) {
System.out.println("等待连接");
Socket clientSocket = serverSocket.accept();
System.out.println("有客户端连接");
bossPoolExecutor.submit(new Runnable() {
@Override
public void run() {
try {
handler(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
private static void handler(Socket clientSocket) throws IOException {
byte[] bytes = new byte[1024];
System.out.println("准备 read");
// 阻塞读数据
int read = clientSocket.getInputStream().read(bytes);
System.out.println("read 完成");
if (read!=-1){
System.out.println("接收到客户端数据"+new String(bytes,0,read));
}
clientSocket.getOutputStream().write("你好 客户端".getBytes(StandardCharsets.UTF_8));
clientSocket.getOutputStream().flush();
}
}
BIO 的缺点是非常明显的:
1一个请求对应一个线程、且在 IO 代码里 read 操作是阻塞操作,如果连接不做数据读写操作会导致线程阻塞,非常浪费线程资源
2、如果请求过多会导致导致服务器线程过多,压力太大系统挂掉,比如 C10K 问题。
C10K问题:当创建的进程或线程多了,数据拷贝频繁(缓存I/O、内核将数据拷贝到用户进程空间、阻塞,进程/线程上下文切换消耗大, 导致操作系统崩溃
应用场景:
BIO 方式适用于连接数目比较小且固定的架构, 这种方式对服务器资源要求比较高, 但程序简单易理解。
1.3.2 NIO(Non Blocking IO)
同步非阻塞 IO,服务器实现模式为一个线程可以处理多个请求(连接),客户端发送的连接请求都会注册到多路复用器 selector 上,多路复用器轮询到连接有 IO 请求就进行处理,JDK1.4开始引入。
NIO 存在一个迭代的过程 以 Linux 系统为例 多路复用器 底层使用 epoll 实现 ,早期的 NIO 采用 select 到 poll 进行演化,实质上是对每个链接循环检测是否有数据发送,性能较低。
应用场景:
NIO方式适用于连接数目多且连接比较短(轻操作) 的架构, 比如聊天服务器, 弹幕系统, 服务器间通讯,编程比较复杂
底层实现 从 select 到 epoll
到了这里,上面给大家讲解的 socket 理论就派上用场了,我们都知道 对于每一个请求服务端都会对应生成一个 socket 文件 。
那么对于 BIO 来说每个 socket 文件都需要有一个线程来阻塞处理数据的接收,这个对于大量的请求过来,服务器是定然扛不住如此之多线程的,穷则思变, NIO 应运而生。
对于 NIO 来说,我们需要思考的是怎么监听处理多个 socket 请求呢?一个可行的方法 将所有 socket 请求放到一个集合中,遍历集合即可,这样是不是就可以省去创建多个线程了呢?select 由此而来,让我们通过实际的代码来理解一下什么叫做 select
下面是 C++ 伪代码,其基本原理其实就是将需要监听的 socket 连接放到集合中,即维护一个 socket 列表,如果列表中的 socket 都没有数据,挂起进程,直到有一个 socket 收到数据,唤醒进程处理数据。当通知到有 socket 接受到数据后此时程序并不知道是列表中具体的哪个 socket 接收到了数据,故需要对我们维护的列表进行遍历来找出是哪个获取到了数据并进行后续处理。C++ 中通过 FD_ISSET
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int fds[] = 存放需要监听的socket
while(1){
int n = select(..., fds, ...)
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){
//fds[i]的数据处理
}
}
}
从 socket 层面来看我们在调用 select 后 会将对应线程 加入到所有需要监听的 socket 的等待列表 中并阻塞,我们监听的任意一个 socket 获取到数据后会将该线程唤醒并 移出所有的等待队列 ,进入工作队列中,此时我们的线程可以对监听的 socket 列表遍历来找到具体是哪个 scoket 获取到了数据并做相应的处理。
可以明显的看出存在的性能问题:
select 加入 到 唤醒 到 找到数据 涉及 3 次的循环遍历操作,这个效率是很低的
如果维护一个 1 w 个 socket 链接的列表,即使只有一个 socket 收到了数据,我们也需要遍历这个列表,平均时间复杂度是 O(n) 的,那有没有更好的方案能达到 O(1) 呢 即我们可以知道具体是哪个 socket 收到了信息,而不需再进行遍历来寻找,epoll 由此诞生。
epoll 做了什么
相对于 select ,epoll 主要做了两点优化:
- 功能拆分,api 解耦。
- 维护就绪列表,空间换时间的思想
1. 功能拆分
让我们从 C++ 代码来看看 epoll 做了什么
//添加 fds 到等待队列并等待数据
select(..., fds, ...)
//====== epoll 的功能解耦
//创建一个 epoll 对象 epfd
epoll_create(...);
//将所有需要监听的 socket 添加到 epfd 中
epoll_ctl(epfd, ...);
//调用 epoll_wait 等待数据
epoll_wait(...)
有上述代码可以清楚看到,由原来的一步 select 添加并阻塞 拆分为了 epoll 的 两步 添加 epoll_ctl ,等待 epoll_wait 。 在方法层面做了拆分解耦,为优化奠定基础。
2. 维护就绪队列 rdlist
就像之前讨论到的,我们不知道具体哪个 socket 接收到了数据,这个时候 epoll 选择在内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历,这就是 rdlist。
epoll 原理和流程
这里我把 select 原理流程图,和 epoll 的原理流程图贴在一起方便大家对比来看。
当某个进程调用 epoll_create 方法时,内核会创建一个 eventpoll 对象(也就是程序中 epfd 所代表的对象)。eventpoll 对象也是文件系统中的一员,和 socket 一样,它也会有等待队列。
eventpoll 对象是必须的,因为系统内核需要维护 就绪列表、监视列表等数据。
此时的 eventpoll 就像是一个代理、中介,由它来管理 socket 和 线程之间的交互。
创建了 eventpoll 对象后 可以使用 epoll_ctl 添加监听 socket ,内核会将 eventpoll 添加到对应的 socket 的等待队列中
当 socket 接收到数据后会将其引用添加到 eventpoll 的 redlist 中,所以接收数据并不影响线程的操作,当某个线程执行到 epoll_wait 时会检查 rdlist 是否为空 ,不为空则直接返回,为空则阻塞。那么线程在读取数据时直接到 rdlist 中读取即可,无需再做全量的遍历。因为 rdlist 的存在使得 线程可以知道 那些 socket 进行了改变。
最后聊聊我们重点关注的两个数据接口即 rdlist 就绪列表 以及 rbr 监视列表 (即 epoll_ctl 添加 socket 的存储结构)
rbr 监视列表 需要高效的增删以及查找,平衡二叉搜索树是不错的选择,epoll 这里使用了红黑树。
rdlist 就绪列表 需要快速的增删,结构上选择了 双向链表结构。
感谢同学 @Ljuice 以及 @qq_40113827 的指正,我这边查证了一下,确实是我这边写错了,
这里查看源码 eventpoll.c 在 131 行这样描述到 rbr 即 RB tree 即 红黑树 。
/*
131 * Each file descriptor added to the eventpoll interface will
132 * have an entry of this type linked to the "rbr" RB tree.
133 * Avoid increasing the size of this struct, there can be many thousands
134 * of these on a server and we do not want this to take another cache line.
135 */
而 rdlist 是 ready list 大家可以参考这里的描述 epoll.7
• The interest list (sometimes also called the epoll set): the set of file descriptors that the process has registered an interest in monitoring. • The ready list: the set of file descriptors that are "ready" for I/O. The ready list is a subset of (or, more precisely, a set of references to) the file descriptors in the interest list. The ready list is dynamically populated by the kernel as a result of I/O activity on those file descriptors.
那么在 eventpoll 中 rbr 对应的就是 rb_root_cached
,rdlist 是 struct list_head rdllist;
struct eventpoll {
178 /*
179 * This mutex is used to ensure that files are not removed
180 * while epoll is using them. This is held during the event
181 * collection loop, the file cleanup path, the epoll file exit
182 * code and the ctl operations.
183 */
184 struct mutex mtx;
185
186 /* Wait queue used by sys_epoll_wait() */
187 wait_queue_head_t wq;
188
189 /* Wait queue used by file->poll() */
190 wait_queue_head_t poll_wait;
191
192 /* List of ready file descriptors */
193 struct list_head rdllist;
194
195 /* Lock which protects rdllist and ovflist */
196 rwlock_t lock;
197
198 /* RB tree root used to store monitored fd structs */
199 struct rb_root_cached rbr;
200
201 /*
202 * This is a single linked list that chains all the "struct epitem" that
203 * happened while transferring ready events to userspace w/out
204 * holding ->lock.
205 */
206 struct epitem *ovflist;
207
208 /* wakeup_source used when ep_scan_ready_list is running */
209 struct wakeup_source *ws;
210
211 /* The user that created the eventpoll descriptor */
212 struct user_struct *user;
213
214 struct file *file;
215
216 /* used to optimize loop detection check */
217 u64 gen;
218 struct hlist_head refs;
219
220#ifdef CONFIG_NET_RX_BUSY_POLL
221 /* used to track busy poll napi_id */
222 unsigned int napi_id;
223#endif
224
225#ifdef CONFIG_DEBUG_LOCK_ALLOC
226 /* tracks wakeup nests for lockdep validation */
227 u8 nests;
228#endif
229};
那么具体实现,这里就不展开看了,感兴趣的同学可以自己看源码研究~,再次感谢 @Ljuice
总结
最后提一下 poll 。 poll 的机制与 select 类似,与 select 在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是因为底层数据结构是链表,所以 poll 没有最大文件描述符数量的限制。
下图是三个系统级调用的对比。
NIO java 代码实现
最后附上 NIO Java 实现
select 实现
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class NioServer {
// 保存客户端连接
static List<SocketChannel> channelList = new ArrayList<>();
public static void main(String[] args) throws IOException, InterruptedException {
// 创建NIO ServerSocketChannel,与BIO的serverSocket类似
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(9000));
// 设置ServerSocketChannel为非阻塞
serverSocket.configureBlocking(false);
System.out.println("服务启动成功");
while (true) {
// 非阻塞模式accept方法不会阻塞,否则会阻塞
// NIO的非阻塞是由操作系统内部实现的,底层调用了linux内核的accept函数
SocketChannel socketChannel = serverSocket.accept();
if (socketChannel != null) { // 如果有客户端进行连接
System.out.println("连接成功");
// 设置SocketChannel为非阻塞
socketChannel.configureBlocking(false);
// 保存客户端连接在List中
channelList.add(socketChannel);
}
// 遍历连接进行数据读取
Iterator<SocketChannel> iterator = channelList.iterator();
while (iterator.hasNext()) {
SocketChannel sc = iterator.next();
ByteBuffer byteBuffer = ByteBuffer.allocate(128);
// 非阻塞模式read方法不会阻塞,否则会阻塞
int len = sc.read(byteBuffer);
// 如果有数据,把数据打印出来
if (len > 0) {
System.out.println("接收到消息:" + new String(byteBuffer.array()));
} else if (len == -1) { // 如果客户端断开,把socket从集合中去掉
iterator.remove();
System.out.println("客户端断开连接");
}
}
}
}
}
epoll 多路复用器实现,
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.List;
import java.util.Set;
public class NioServer {
public static void main(String[] args) throws IOException {
// 创建 NIO serverSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 绑定端口
serverSocketChannel.bind(new InetSocketAddress(19002));
// 设置非阻塞
serverSocketChannel.configureBlocking(false);
// 打开 selector 处理 channel 即创建 epoll
Selector selector = Selector.open();
// 把 serverSocketChannel 注册到 selector 上
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// 阻塞获取连接
selector.select();
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> selectionKeyIterator = selectionKeys.iterator();
while (selectionKeyIterator.hasNext()) {
SelectionKey selectionKey = selectionKeyIterator.next();
// 如果是连接注册事件
if (selectionKey.isAcceptable()){
ServerSocketChannel serverSocket= (ServerSocketChannel) selectionKey.channel();
SocketChannel socketChannel=serverSocket.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector,SelectionKey.OP_READ);
System.out.println("客户端连接成功");
}else if(selectionKey.isReadable()){
//如果是读取事件
SocketChannel socketChannel= (SocketChannel) selectionKey.channel();
ByteBuffer byteBuffer=ByteBuffer.allocate(128);
int len=socketChannel.read(byteBuffer);
if (len>0){
System.out.println("接收到客户端消息: "+new String(byteBuffer.array()));
}else if (len==-1){
System.out.println("客户端断链:"+socketChannel.isConnected());
socketChannel.close();
}
}
// 迭代器中删除本次处理,避免重复处理
selectionKeyIterator.remove();
}
}
}
}
如果查看 Selector selector = Selector.open();
这行代码就可以跟踪到 底层的 create 在 Linux 系统下的实现 是 EPollSelectorProvider
感兴趣的同学可以下载 openJDK 源码 查看具体实现就是在调用本地 C++ 的实现 epoll_ctl 、 epoll_create、epoll_wait 这些来对 selector 进行的实现。
写在最后:
本文带你从底层由浅入深,由深返浅的介绍了 什么是 IO ,以及其本质 Socket ,并剖析了 BIO 与 NIO 的底层实现,对比 select 到 poll 到 epoll 的发展过程。其实所有的技术都有一个发展过程,优化是核心进化需求。
我是 dying 搁浅 我看了一场 94 小时的电影也没能等来你的点赞关注收藏,我想这不是你不够喜欢我,而是我看的电影不够长……