本节课主要讲解了多路复用器在 linux 系统上的实现,包括 select、poll、epoll 各自发展的过程、原理以及区别,并且对应 Java 中网络编程多路复用器的抽象处理。
1. 回顾 NIO
之前学习的 NIO 同步非阻塞模型,它使用一个线程处理客户端 socket 的连接、读写事件。在创建 ServerSocket 和客户端 Socket 时传递非阻塞参数给操作系统,这样在调用它们的 accept 或者 read 系统调用时不会阻塞,而是会立即返回,程序需要自己判断是否可以处理数据。
注意:NIO 在 Java 中代表 new IO,在操作系统上代表 noblocking 非阻塞 IO。
优点:
只需要一个线程或几个线程,来解决 N 个 IO 连接的处理问题
缺点:
C10K 问题,即当有很多的 IO 连接后,在 Java 应用程序上需要遍历这些 N 个 IO 连接执行 read 系统调用,时间复杂度是 O(N),因为可能只有几个或者少数的 IO 连接有数据,所以很多的 IO 系统调用是无用的
2. 多路复用器
什么是多路复用器,它是操作系统内核提供的一种 IO 机制。
如何理解多路复用器呢?可以将 IO 看作是在建立在内核上从对文件(socket、磁盘等)进行输入输出的一条路。在 Java 的 NIO 程序中,需要针对多个 IO(ServerSockt、Socket)执行 accept 或者 read 系统调用,即需要执行 N 次 IO (路)的系统调用,那么多路复用就是说多条路(IO)通过执行一次系统调用,获得其中的 IO 状态,然后,由程序自己对着有状态变化的 IO 进行 R/W 操作。
内核提供的多路复用系统调用函数有:select、poll、epoll、kqueue。
其中 select 是 POSIX(可移植操作系统接口)工业标准中规范的一个系统调用。
对于同步、异步、阻塞、非阻塞的理解,是站在只关注 IO,不关注从 IO 读写完之后的事情的角度:
同步:app 自己去操作 IO 的 R/W
异步:内核完成 IO 的 R/W 操作,将数据放入进程的 buffer,不严谨的说好像 app 没有访问 IO 只是访问了 buffer。目前只有 Windows 上的 iocp 是纯异步的操作
阻塞:blocking,
非阻塞:noblocking
在 Linux 以及目前成熟的 Netty 框架中,有下面这些组合:
同步阻塞:程序自己读取 IO,调用了方法一直在等待有效的返回结果
同步非阻塞:程序自己读取 IO,调用方法的一瞬间,得到是否读到结果,程序要解决自己下一次啥时候再去读
异步:一般不讨论,现在只讨论 IO 模型下,Linux 目前没有通用的内核的异步处理方案
异步非阻塞
2.1 select 与 poll
查看 Linux 的 select man 手册:
[root@ecs-02 ~]# man 2 select
SELECT(2) Linux Programmer's Manual SELECT(2)
NAME
select, pselect, FD_CLR, FD_ISSET, FD_SET, FD_ZERO - synchronous I/O multiplexing
...省略...
DESCRIPTION
select() and pselect() allow a program to monitor multiple file descriptors, waiting until one or more of the file descriptors become "ready" for some class of I/O opera‐
tion (e.g., input possible). A file descriptor is considered ready if it is possible to perform a corresponding I/O operation (e.g., read(2) without blocking, or a suffi‐
ciently small write(2)).
select() can monitor only file descriptors numbers that are less than FD_SETSIZE; poll(2) does not have this limitation. See BUGS.
...省略...
可以看到 select 系统调用的说明,说它是是一个同步的 IO 多路传输技术。select() 和 pselect() 允许一个程序去监控多个文件描述符,一直等待直到其中一个或者多个文件描述符从一些 IO 操作(可读、可接受等)中已经准备好的时候返回。
select 它会接收一个参数文件描述符,一次只能接收 1024个文件描述符。后来出现的 poll 系统调用没有这个限制了。
在 Java 程序中编写利用多路复用器编写代码时,会在 jvm 中创建一个数组来维护 ServerSocket 和客户端 socket 建立的文件描述符,然后有一个 while 循环然后执行 select 方法调用内核的 select 系统调用,会把这些文件描述符作为参数传入其中,此时只是执行了一次调用系统调用,时间复杂度是 O(1),然后 select 系统调用会返回一个结果,比如有 m 个有状态的 IO,程序再对这个 m 个 IO 执行 read 操作。
NIO、select、poll 的相同点:
它们都是要遍历所有的 IO,询问状态。
NIO、select、poll 的不同点:
NIO 这个 IO 遍历过程的成本在用户态与内核态的切换
多路复用器 select、poll 的对 IO 的遍历过程触发了一次系统调用,即一次用户态和内核态的切换,这个过程中,程序将 fds 文件描述符传递给内核,内核重新根据这次传过来的 fds 进行对应 IO 的遍历和修改状态,这个过程比 NIO 程序自己遍历 IO 速度快的多(重点)
多路复用器 select、poll 的弊端:
每次都要由程序重新传递文件描述符 fds,这就要让内核开辟空间
每次内核被调用之后,都会针对这次调用来触发一次全量遍历 fds,复杂度还是 O(N)
2.2 中断回调
计算机有内存、cpu、输入输出设备(网卡、键盘、鼠标、磁盘等)组成。
硬件与 CPU 的通信是通过中断信号来完成的,中断指令在内核中会有对应的中断向量表,同时中断也有对应的回调函数 callback。
还有一个时钟振荡器(晶振)时钟中断,它是一个硬中断,它作用是发送一定的频率给 CPU 来发送中断信息,打断 CPU 让它执行切换其他进程调度。
还有一个软中断,它是软件层面调用内核的 int 0x80 中断信号, CPU 会让程序从用户态切换到内核态,同时保护用户态现场。
网卡的中断属于硬中断,内存中有一个 DMA 的区域用来存放网卡数据。
当有客户端连接时,数据通过网线进入网卡,网卡就可以有几种处理方式:第一种是立即发其中断,第二种是将数据放入网卡的 buffer 缓冲区,第三种是数据来的太快,不再执行中断,而是让 CPU 抽出一个时间特意轮询调用读取数据。这完全是内核做的事情。
这三种操作最终都会调用中断,执行回调函数 callback,抽象成处理 event 事件,就是有回调处理事件。
在 epoll 之前的回调处理,只是完成了将网卡发来的数据,走内核网络协议栈(2(物理链路层),3(协议传输层),4(应用处理层) 层),最后会关联到文件描述符的 buffer。所以,某一时刻如果从 app 询问某一个或者某些文件描述符是否可 R/W,就会有状态返回。
2.3 epoll
为了解决 select 和 poll 的弊端问题,Linux 2.6 内核实现了一种 epoll 的多路复用器系统调用。
看下它的介绍,查看 Linux 的 epoll 系统调用手册:
EPOLL(7) Linux Programmer's Manual EPOLL(7)
NAME
epoll - I/O event notification facility
SYNOPSIS
#include
DESCRIPTION
The epoll API performs a similar task to poll(2): monitoring multiple file descriptors to see if I/O is possible on any of them. The epoll API can be used either as an
edge-triggered or a level-triggered interface and scales well to large numbers of watched file descriptors. The following system calls are provided to create and manage an epoll instance:
* epoll_create(2) creates a new epoll instance and returns a file descriptor referring to that instance. (The more recent epoll_create1(2) extends the functionality of epoll_create(2).)
* Interest in particular file descriptors is then registered via epoll_ctl(2). The set of file descriptors currently registered on an epoll instance is sometimes called an epoll set.
* epoll_wait(2) waits for I/O events, blocking the calling thread if no events are currently available.
...省略...
epoll 是一个 IO 事件通知功能,它有以下系统调用:
epoll_create(2):创建一个 epoll 实例并且返回一个描述该实例的文件描述符,比如 fd6。
epoll_ctl(2):注册对特定文件描述符的兴趣事件,这个注册在epoll 实例上的文件描述符集合有时也被称之为 epoll 集合。这是相当于把一些文件描述符以及它感兴趣的事件注册到红黑树上,比如就将 ServerSocket 对应的 fd4 注册到 fd6 红黑树上去。
epoll_wait(2):等待 IO 事件,如果没有事件可用就会阻塞调用线程。
原理:
内核创建 epoll 时会创建一个红黑树,用来存放一些文件描述符以及它感兴趣的事件,这些文件描述符可以是处于 listen 状态下的 ServerSocket 或者 Socket;内核对 epoll 的实现中是对于网卡中断的回调做了延伸,当网卡有收到客户端数据时会产生中断,它把网卡的数据放到 fd 的 buffer 上,同时会拿着这个 fd 从红黑树中查找是否存在,如果存在就会把这个 fd 迁移到一个链表上去,所以,程序只需要调用 wait 就能及时取走有状态的 fd 的结果集。
epoll 和 select、poll 相比,优点:
避免了内核每次调用 select 和 poll 是程序传入遍历传入 fds 文件描述符,用户程序只需要调用 wait 一次就可以直接拿到已经有状态的 IO 结果集
Java 的 api 中对应的多路复用器是 selector,它是对操作系统多路复用的实现完成高度抽象和细节封装,它会根据操作系统对多路复用器的具体实现来执行。
epoll 和 select、poll 在 Java 上实现的对比:
针对 select 和 poll,它会在 jvm 上分配一个数组维护 ServerSocket 和 Socket 的文件描述符,在调用 select 或者 poll 系统调用时将这些描述符传给内核,阻塞等待内核的返回
针对 epoll 它不需要维护数组,直接调用 epoll_wait 阻塞等待结果集
3. Java 代码中的 selector 的实现
package com.kaige.system.io;
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 SocketMultiplexingSingleThreadv1 {
int port = 8091;
private ServerSocketChannel server = null;
private Selector selector = null; //linux 多路复用器(select poll epoll kqueue) nginx event{}
public static void main(String[] args) {
SocketMultiplexingSingleThreadv1 service = new SocketMultiplexingSingleThreadv1();
service.start();
}
public void initServer() {
try {
server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(port));
//如果在epoll模型下,open--》 epoll_create -> fd3
selector = Selector.open(); // select poll *epoll 优先选择:epoll 但是可以 -D修正
//server 约等于 listen状态的 fd4
/*
register
如果:
select,poll:jvm里开辟一个数组 fd4 放进去
epoll: epoll_ctl(fd3,ADD,fd4,EPOLLIN
*/
server.register(selector, SelectionKey.OP_ACCEPT);
} catch (IOException e) {
e.printStackTrace();
}
}
public void start() {
initServer();
System.out.println("服务器启动了。。。。。");
try {
while (true) { //死循环
Set keys = selector.keys();
System.out.println(keys.size() + " size");
//1,调用多路复用器(select,poll or epoll (epoll_wait))
/*
select()是啥意思:
1,select,poll 其实 内核的select(fd4) poll(fd4)
2,epoll: 其实 内核的 epoll_wait()
*, 参数可以带时间:没有时间,0 : 阻塞,有时间设置一个超时
selector.wakeup() 结果返回0
懒加载:
其实再触碰到selector.select()调用的时候触发了epoll_ctl的调用
*/
while (selector.select() > 0) {
Set selectionKeys = selector.selectedKeys(); //返回的有状态的fd集合
Iterator iter = selectionKeys.iterator();
//so,管你啥多路复用器,你呀只能给我状态,我还得一个一个的去处理他们的R/W。同步好辛苦!!!!!!!!
// NIO 自己对着每一个fd调用系统调用,浪费资源,那么你看,这里是不是调用了一次select方法,知道具体的那些可以R/W了?
//我前边可以强调过,socket: listen 通信 R/W
while (iter.hasNext()) {
SelectionKey key = iter.next();
iter.remove(); //set 不移除会重复循环处理
if (key.isAcceptable()) {
//看代码的时候,这里是重点,如果要去接受一个新的连接
//语义上,accept接受连接且返回新连接的FD对吧?
//那新的FD怎么办?
//select,poll,因为他们内核没有空间,那么在jvm中保存和前边的fd4那个listen的一起
//epoll: 我们希望通过epoll_ctl把新的客户端fd注册到内核空间
acceptHandler(key);
} else if (key.isReadable()) {
readHandler(key); //连read 还有 write都处理了
//在当前线程,这个方法可能会阻塞 ,如果阻塞了十年,其他的IO早就没电了。。。
//所以,为什么提出了 IO THREADS
//redis 是不是用了epoll,redis是不是有个io threads的概念 ,redis是不是单线程的
//tomcat 8,9 异步的处理方式 IO 和 处理上 解耦
}
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
public void acceptHandler(SelectionKey key) {
try {
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel client = ssc.accept(); //来啦,目的是调用accept接受客户端 fd7
client.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(8192); //前边讲过了
// 0.0 我类个去
//你看,调用了register
/*
select,poll:jvm里开辟一个数组 fd7 放进去
epoll: epoll_ctl(fd3,ADD,fd7,EPOLLIN
*/
client.register(selector, SelectionKey.OP_READ, buffer);
System.out.println("-------------------------------------------");
System.out.println("新客户端:" + client.getRemoteAddress());
System.out.println("-------------------------------------------");
} catch (IOException e) {
e.printStackTrace();
}
}
public void readHandler(SelectionKey key) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.clear();
int read = 0;
try {
while (true) {
read = client.read(buffer);
if (read > 0) {
buffer.flip();
while (buffer.hasRemaining()) {
client.write(buffer);
}
buffer.clear();
} else if (read == 0) {
break;
} else {
client.close();
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
这个代码中有几个重要的api:
Selector:多路复用器,它的 api:
通过 Selector.open() 静态方法创建,Java 会在 select、poll、epoll 系统调用下优先选择 epoll,可以通过程序启动参数 -Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.EPollSelectorProvider修改,在 epoll 模型下,约等于调用 epoll_create 系统调用,比如返回了 fd3
select.select() 实例方法会进行阻塞获取事件 IO,对应到 epoll 模型下,就是调用 epoll_wait();对应 select、poll 其实就是调用内核 select(fd4)、poll(fd4);参数是阻塞时间,0 为永久阻塞
select.wakeup() 实例方法会唤醒阻塞,由其他线程调用
ServerSocketChannel:服务端 Socket 用于接收客户端 Socket 连接
ServerSocketChannel.open() 方法会创建 ServerSocketChannel 实例,约等于 listen 状态下的文件描述符 fd4
server.register(selector, SelectionKey.OP_ACCEPT) 方法是将当前实例文件描述符注册多路复用器上,在 epoll 模型上相当于调用了 epoll_ctl(fd3,ADD,fd4,EPOLLIN);在 select、poll 模型中是通过在 jvm 堆内维护一个数组,把 fd4 放进去
SelectionKey:代表 Selector 上所注册的事件 key,可以获取对应的 Channel 和 attachment 以及 IO 文件描述符的状态
SocketChannel:代表客户端 Socket 的信息,它也可以注册到 Selector 上去,也可以执行 R/W 操作