BIO、NIO、EPOLL 多路复用器
Android 写后台网络这块,写的不好,大家见谅,文章没校对,不通顺的地方大家脑补,实在没时间改了~
前言
作为 Android 开发偶然看到马老师的 NIO,好奇心驱使我进来看看,收获很大,虽然不是做后台开发的,但我还是听懂了,听懂了就要做笔记,要不忘了、想不起来,今天的努力就白费了
NIO,epoll 这些,tomcat、netty、nginx、redis 都使用到了,区别是有的是多线程的,有的是单线程的。这些都是开源主流大件,使用的原理都是趋同的,可见基础的重要性呀,你把 NIO,epoll 都搞定了,这些框架你还看不懂吗,get 不到核心点吗,面试时还不会答吗
资料:
strace 命令
strace -ff -o out /javapath/java BioTest.java
strace 可以追踪进程内核调用,查看内核态中的运行情况,分析性能用的,下面这些都是系统调用
像这样办个括号的,就是在这里阻塞了
Linux 中一切皆文件,每创建一个线程,都会在创建线程方法所属的文件的目录下生成一个 .out file
下面会用到 strace 命令
BIO
class BioTest {
ServerSocket serverSocket;
Socket client;
void main() throws IOException {
serverSocket = new ServerSocket(8090);
while (true) {
client = serverSocket.accept();
new Thread(new Runnable() {
@Override
public void run() {
try {
InputStream inputStream = client.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
while (true) {
reader.readLine();
}
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
}
这是经典的 java Socket 写法,上面启动的是服务端 Socket,Socket 在整个过程中会有2处阻塞:
serverSocket.accept()
,这里一处,因为服务端要等待客服端完成 TCP 3次握手,所以这里要阻塞时等待reader.readLine()
,TCP 连接建立了,要等待客户端把数据包发过来,这里也要等待
因为服务端主线程是不能阻塞的,所以要给每一个 TCP 连接配一个线程,reader.readLine()
要阻塞也是阻塞新开辟的线程
所以:BIO = 1TCP,1线程
- 优势:
可以接收很多个连接进来
- 缺点:
线程开启的数量太多了,太消耗资源
线程太多了,线程之间争强 CPU,频繁的切换线程同样会过度消耗 CPU 时间,线程切换也是有代价的
BIO 瓶颈 --> 就在于阻塞,我们不能让主线程阻塞,所以不得不每一个连接进来都开启线程,这是 BIO 性能问题的关键所在
strace 跟一下 BIO 的 Socket 通信
从内核的执行角度看: 1. socket => 3 内核会启动一个 socket 并给该 socket 在内核中分配一块缓存空间(就是一个文件),返回一个名为3的文件描述符,这个文件描述符就代表了 Socket 对象 2. bind(3,8090) 绑定3这个文件描述符给 8090 端口使用 3. listen(3) 内核开始监听 3 这个文件描述符,也就是监听 3 这个 socket 通信 4. clone() 根据 BIO 的代码,内核开启一个新的线程 5. accept(3, =5 阻塞,前面 (3, 这里会阻塞,一直到客户端有连接完成3次 TCP 握手进来,会返回一个名为 5 的文件描述符,该文件描述符代表一个客户端连接 clien,内核同样会在给该连接在内核空间分配一块缓存用来存储数据 6. recv(5, ,读取 http 数据,客户端不发过来会一直阻塞在这里
BIO 非常容易受到攻击:
- 发起大量 TCP 连接,每次3次握手就是不完成,服务端没都一个TCP连接申请都会分配一块资源,TCP 连接不终止就不会释放,通过这种流氓做法短时间内会耗尽服务器系统资源
- 跟上面一样的思路,TCP 通了就是不发数据包过来,服务器你就淂等,短时间内大量这样的操作一样会会耗尽资源
NIO
前文说了: BIO 瓶颈 --> 就在于阻塞,我们不能让主线程阻塞,所以不得不每一个连接进来都开启线程
BIO 阻塞的方法来自于系统调用:accept()、rect()
,这2个方法是内核级别的,是我们在 java 应用层可以控制的吗?显然不是,我们淂依赖于系统内核资深的进步,为了解决这个问题:NIO
诞生了
Linux 系统内核提供了一种:SOCK_NONBLOCK
新模式,socket 可以无阻塞式运行,accept() 函数没有连接进来时就返回 null,我们自己判断下
没有阻塞了,这样我们就可以用一个线程 hold 住所有连接了,需要做的就是不停的遍历所有连接就行了
**另外还有一点,NIO 其实有2个角度: **
- 一个是 java 中的 new IO,一套新的IO体系、新的包、包含通道、缓存、多路复用器,JDK 1.7 开始可以使用
- 一个是操作系统,Linux 提供了 nonblocking 非阻塞式 socket 设置:SOCK_NONBLOCK
要问明白面试官问的是哪个
从内核的执行角度看: 1. socket => 3 :内核会启动一个 socket 并给该 socket 在内核中分配名为3的文件描述符 2. bind(3,8090) :绑定3这个文件描述符给 8090 端口使用 3. listen(3) :内核开始监听 3 这个文件描述符,也就是监听 3 这个 socket 通信 4. 3.nonblocking :socket 设置非阻塞式 5. accept(3) return NULL/5 :非阻塞,方法执行到这里没有连接就返回 null 6. 5.nonblocking :连接设置非阻塞式 7. recv(5) return -1/xxx :非阻塞,读取数据包没有就返回 -1
class Test
void main2() throws IOException {
LinkedList<SocketChannel> channels = new LinkedList<>();
ServerSocketChannel socketChannel = ServerSocketChannel.open();
socketChannel.bind(new InetSocketAddress(8090));
socketChannel.configureBlocking(false); // 启动内核 NONBLOCKING 非阻塞模式
while (true) {
SocketChannel channel = socketChannel.accept(); // 不阻塞,没有返回 null
if (channel != null) {
channel.configureBlocking(false); // 启动内核 NONBLOCKING 非阻塞模式
channels.add(channel);
}
ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
for (SocketChannel chan : channels) {
int read = chan.read(buffer); // 不阻塞,没有数据包返回 -1
if (read > 0) {
buffer.flip();
byte[] bytes = buffer.array();
String data = new String(bytes);
}
}
}
}
}
- 优点:
- 1个线程就行就能处理所有连接,这个线程不停循环遍历就行了
- 缺点:
- 单线程不停循环发起系统调用,一样会耗尽 CPU 资源
NIO 的瓶颈 --> 在于需要不停的调起系统调用,每个链接我们都要调系统调用询问是否有过来数据,我们要是明确的知道哪个连接有数据包过来呢,就不用挨个遍历寻找找答案了
select、poll、epoll 多路复用器
上文说到 NIO 的瓶颈 --> 在于我们要不停的发起系统调用询问内核是否有新的连接、新的数据包,单线程循环这么跑的话性能一样也好不了,每一次系统调用,都要经历用户态-->内核态的来回切换
既然是内核问题,那我们在应用层代码肯定是无能为力的,幸好 Linux 内核又进步了,提供了多路复用器
这个概念
多路复用器 --> 指的是不管有多少连接进来,我们都可以使用1条系统调用询问内核,然后内核告诉我们哪些连接有变化,然后我们自己对这些有变化的连接进行IO操作。多路复用器解决的是状态的问题,不解决IO读写的问题,用1次系统调用询问所有的IO状态,而不是每一个IO都问一次,减少了用户态到内核态的切换
多路复用器实现:
select
poll
epoll
首先要明确,多路复用器是内核级实现,现在变成一种 IO 规范了,Linux、unix、window 各大操作系统都有支持,区别是具体的实现不同。select、poll 是一种老式实现,epoll 是最新的实现,2者实现思路不同,但是都遵循 select 这个接口规范
其次要知道,java 针对 NIO、多路复用器这种新的 IO 模型,专门推出了全新的 IO 包,java.nio.*
,里面包含上面说的所有
多路复用器允许一个程序监控多个文件操作符,不是快速 IO 读写,而是告诉你那些文件描述符有变化
select、poll
select、poll 实现逻辑是一样的,区别是 select 有 1024 个连接限制,poll 没有,poll 的限制是系统上限
实现逻辑:
- 用户进程还是维护所有连接的队列集合
- 通过 select(fds) 这个新增的系统调用,询问内核那些连接有变化,该方法阻塞,但可以设置超时时间
- 内核去挨个遍历所有连接找出状态发生变化的,把这些连接返回给用户进程
OK,原理就是这么简单~
- 优点:
- 1个系统调用就行了,节省了大量用户态-->内核态的切换,性能好很多
- 缺点:
- 内核自己还是要去遍历多有的连接,内核中的操作还是要消耗一部分性能的,在高并发环境下,这部分性能损耗也是很多的
epoll
到 epoll 这里,在 select、poll 的基础上又进化了,epoll 可以让内核开辟空间去记录所有的连接,我们只要询问内核就行了,节省了每个询问内核时传递大量的文件描述符了
原理也是这么简单,大家看看图就知道了
epoll 3个方法
epoll_create()
:epoll 开辟一块空间保存监听对象epoll_ctl()
:往 epoll 中添加一个类型的监听epoll_wait()
:用户查询结果,该方法会阻塞,但是可以设置超时时间
结合内核调用看:
socket --> 3
bind(3,8090)
listen(3)
epoll_create() --> 7:epoll 创建一块空间出来保存注册的监听内容
epoll_ctl(7,ADD,3,accept):添加一个监听进来,文件是 socket,类型是 accept
accept(3) --> 8:此时连接进来了
epoll_ctl(7,ADD,8,read):添加监听,文件是连接,类型是读写
epoll wait()
- 优点:
- 查询结果时不用再传大量的文件描述符的,这些文件描述符的传递也是需要内存操作的
- 可以有效利用多核了,内核中监听的操作不再是以前那样遍历式的连贯操作了,需要一口气执行,监听连接状态变换,任务之间没有连贯性,可以由多个核心执行,碰到任务来了,哪个核心有空哪个核心写好了
- 缺点:
-
在大并发的场景下,单线程管理所有的连接,单次 wait() 操作耗时还是比较长,wait() 之间的间隔可能会比较长,这就造成了连接响应不及时
-
代码
select、poll、epoll 他们都遵循 select 接口,java 代码上都是一样的,你可以设置 java 代码中具体使用哪个实现,一般是 epoll
class Test{
void main3() throws IOException {
ServerSocketChannel socketChannel = ServerSocketChannel.open();
socketChannel.bind(new InetSocketAddress(8090));
socketChannel.configureBlocking(false);
Selector selector = Selector.open();
socketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (selector.select(200) > 0) {
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
iterator.remove();
if (selectionKey.isAcceptable()) {
SocketChannel channel = ((ServerSocketChannel) selectionKey.channel()).accept();
channel.configureBlocking(false);
channel.register(selector,SelectionKey.OP_READ,ByteBuffer.allocateDirect(4096));
}
if (selectionKey.isReadable()) {
SocketChannel channel = (SocketChannel) selectionKey.channel();
ByteBuffer buffter = (ByteBuffer) selectionKey.attachment();
// 读操作
}
if (selectionKey.isWritable()) {
// 写操作
}
}
}
}
}
AIO
如果是线程自己读取IO,那就是同步IO模型。不管是 BIO、NIO、还是多路复用器,他们都是同步IO模型
AIO 是 window 系统的,window IOCP 机制是真正的异步IO,程序你不用自己去读写IO了,只需要注册一个回调就行了,内核会自己开启线程,获取数据包然后写到程序的用户空间里
这点大家清楚就行了,没啥多说的
多路复用器 + 多线程
上文我们说了 epoll 的瓶颈 -->,在于大并发下,单线程是 hlod 不住的,单次 wait() 函数耗时太长,会影响连接响应
于是大家就想到了使用 多线程 + 多个 select 配合使用的思路,这里我简单说下:
- 每个 select 都运行在独立的 Thread 中
- 其中一个 select 作为控制器,在接到连接进来后,把连接分配给不同的 select 去注册
- 剩下的复数的 select 负责连接的读写
下面的代码意思一下,大家看个意思
class Test{
void main3() throws IOException {
ServerSocketChannel socketChannel = ServerSocketChannel.open();
socketChannel.bind(new InetSocketAddress(8090));
socketChannel.configureBlocking(false);
Selector selector_root = Selector.open();
Selector selector_work1 = Selector.open();
Selector selector_work2 = Selector.open();
Selector[] works = {selector_work1, selector_work2};
AtomicInteger index = new AtomicInteger(0);
socketChannel.register(selector_root, SelectionKey.OP_ACCEPT);
while (selector_root.select(200) > 0) {
Set<SelectionKey> keys = selector_root.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
iterator.remove();
// 在新连接进来时分发给其他 select,进行负载均衡
if (selectionKey.isAcceptable()) {
SocketChannel channel = ((ServerSocketChannel) selectionKey.channel()).accept();
channel.configureBlocking(false);
channel.register(works[index.get() % 2], SelectionKey.OP_READ, ByteBuffer.allocateDirect(4096));
index.incrementAndGet();
}
}
}
}
}
tomcat、netty、nginx、redis 用的也是这个思路,区别是有的是多线程的,有的是单线程的
转载自:https://juejin.cn/post/6874034712819302414