从BIO到epoll(硬核讲解)

老样子,我先放几个问题,你自我检测一下,看看自己掌握多少,再去看我的讲解。

  1. 计算机怎么能接收网络信息
  2. SocketException: Too many open files 是什么
  3. 同步非阻塞的缺点是什么
  4. 仅仅只是非阻塞,是否存在什么问题
  5. 什么时候会涉及用户态与内核态的切换
  6. 共享空间在网络IO的作用
  7. 什么是中断
  8. 异步是如何实现的
  9. 那 Linux 可以实现异步吗

很多 Java 程序员还停留在只知道 BIO 的知识面,有些基础的应该都知道,除了 BIO,还有 NIO、AIO。
学习这些底层的 I/O 原理是有必要的,因为这是提高性能的很关键的点。一个服务器的并发量能够不断地突发曾经的瓶颈,这也依靠着 I/O 的发展。

学习忌浮躁

概念区分

这是我额外添加的一小节,防止有些同学还分不清楚概念:
同步与非同步、阻塞与非阻塞

  • 同步是指,一个进程(或者线程)在读取 I/O 数据时,必须要自己去调用方法查看是否数据已经准备好;
    非同步则表示,进程(或线程)自身可以先不用去理会 I/O 操作,可以让数据准备好之后,操作系统来通知它,然后再去执行读取数据。
  • 阻塞是指,一个进程(或者线程)在读取 I/O 数据时,期间是不能够做其他事的,执行的代码必须停在读取数据的地方,直到读取到数据;
    非阻塞是指,在读取 I/O 数据时,如果数据还不存在,代码仍然可以向后执行。

洗衣机例子区分概念

为了防止你们听了之后更混淆,我举一个例子:
假设你去用一台洗衣机洗衣服:

  • 同步:你必须自己去看洗衣机洗完了没有
  • 异步:洗衣机洗完了自己会告诉你
  • 阻塞:洗衣机洗衣服你不能做别的事
  • 非阻塞:洗衣机没洗完你也可以做别的事
    ——————————————————————————————————————————————
  • 同步阻塞:你必须一直盯着洗衣机,直到它洗完了
  • 同步非阻塞:你可以在洗衣机洗衣服的时候去做别的事,但是(由于同步)洗衣机自己不会告诉你它洗完了,所以得你自己老是跑回来看一下,没事跑回来再看一下,看看它有没有洗完。
  • 异步阻塞:洗衣机洗完了会自己通知你,但是你在这个期间不能干别的事。(看起来这个操作有毛病)
  • 异步非阻塞:洗衣机洗碗了会通知你

代码层面区分概念

我再从代码层面帮助区分
1、同步阻塞:

前面的代码
input.read(); // 代码会阻塞在这里,直到数据准备好
// 不过确保了后面的代码一定是在读到数据以后执行的
后面的代码 // 这里就会很长时间得不到执行

2、同步非阻塞

前面的代码
input.read() // 有数据则读到,没数据则读不到,方法都会返回,不会阻塞
后面的代码 // 不管有没有读到数据,都会往后执行

这样会有个问题,如果读不到数据,那么接下来的操作可能就会出错
所以可以再修改

前面的代码
int i = input.read(); // 读不读的到都往后执行
if(i != -1) {
    读到数据做的事
}
else {
    没读到数据做的事
}

3、异步阻塞

前面的代码
input.read(); // 代码会阻塞在这里,直到数据准备好
后面的代码 // 这里就会很长时间得不到执行

为什么和 同步阻塞看起来一样???
因为代码不该这么写:

操作系统通知:调用方法 function
void function() {
    // 因为操作系统告诉你数据有了,这时候能直接读到
    // 就不会浪费时间阻塞
    input.read();
}

4、异步非阻塞也应该用上面的写法:
等操作系统通知,再读取。

操作系统通知:调用方法 function
void function() {
    // 因为操作系统告诉你数据有了,这时候能直接读到
    // 就不会读出空的数据
    input.read();
}

IO 的本质

这里着重讲网络 I/O

发送方

首先,网络是部分什么客户端服务端的,它只关系发送方和接收方。
(只不过一般我们的发送方都是客户端)

首先发送方,比如我们的浏览器,在按了一个回车之后,其实是我们的浏览器应用程序在操作。
那么这个应用程序是怎么发送消息的呢。

实际上,应用程序本身不能发送,而是要通过操作系统。
应用程序通过系统内库找到操作系统提供的函数,然后调用操作系统提供的方法,去发送消息。

所以,消息内容就从应用程序流动到操作系统,然后操作系统可以控制我们的 IO 设备,比如这里的网卡,将消息发送出去。
io发送

接收方

那么接收方又是怎么做的?
比如我们最常见的应用服务器,Java 服务器

首先同样的,要有操作系统,然后操作系统上运行着 JVM。
数据要发给我们的 Java 服务器,那么最终就要存储到我们的运行时数据区,堆空间里面。
io
但是,这里有个小问题,这个数据时直接进入到这个堆中的吗?

有点基础的同学都知道肯定不是的,在操作系统中有一个网络处理模块,不管是 TCP 还是 UDP,它们的数据都会存在对应的缓冲区里。
所以,当我们的客户端与服务端建立连接时,服务端操作系统就会开辟出一个缓冲区,用来存放这个连接所传输的数据。

但是,数据传过来了,要怎么知道是给服务器上哪个应用程序的呢?
这时候就需要端口派上用场,服务端应用程序指定的哪个端口,就从专门的缓冲区去读取数据。

所以,这个时候操作系统的麻烦就会很少,而且也很安全。
因为操作系统不会去干扰应用程序,而是把数据存起来,让应用程序自己去读取。
io
但是,这同样产生了问题,就是应用程序应该什么时候去读?
这时不确定的,
如果去早了,就读不到数据,浪费时间
如果去晚了,那么就会造成服务端的响应延时。

所以才会衍生出各种各样的网络 I/O 模型。

同步阻塞I/O(BIO)

最先出现的就是 BIO,因为它的实现最为简单:
应用程序只管去读取,读不到的时候,在那里等着,等到数据有了,就读取回去。

  • 首先,计算机有内核,它会接收很多很多客户端的连接,所有的连接都会先到达内核。
  • 早些时候,内核会有很多文件描述符 fd,一个连接就有一个 fd,只有一个 read 可以读一个文件描述符 fd。
  • 每读一个 fd,就会用到一个进程,在 java 中就叫一个线程。
  • 因为 socket 在这个时期是 blocking(阻塞)的,也就是客户请求没到的时候,线程是阻塞的。

客户端请求一多,由于之前的线程执行到那段代码,就在那阻塞着,啥也不干,动也动不了,所以要处理更多的请求,就要抛出更多的线程。
CPU 又同一时刻只能处理一条数据,这个时候可能一个线程数据还没到,另一个线程数据到了,这时候 CPU 就会并不是时时刻刻都能处理到这些数据,就会造成计算机资源的严重浪费。而
且线程数量一多,切换线程也是会有成本的,尤其线程涨到好几千甚至更多,可能计算机就只忙着线程切换,都没什么时间用来执行代码。

这就是早期的 BIO 时期,因为 socket 是阻塞的,计算机资源是很难被利用起来的。
BIO

同步非阻塞I/O(NIO)

然后,随着时间的推进,技术的发展与演变,内核当中的 socket 有了非阻塞了(socket nonblock),也就是说,进程(线程)可以不用这么傻傻地在那里等着浪费时间,如果没有数据,完全可以去做其他的事。

这样,我们就可以不用再向以前那样,去创建很多很多的线程才能够读取所有连接的数据。
不阻塞了就可以用一个线程,少弄那么多没用的,就不会有什么线程开销。
然后线程里面写一个死循环,while(true) 一直在那里 read 遍历所有的文件描述符。比如先 read fd8,没有,在 read fd9,再没有,再 read fd8,有了,然后 read fd9。

但是这个时候,我们要知道,虽然非阻塞了,这是同步的还是异步的。你看,这是线程先去操作系统,把文件描述符取出来,再去遍历处理。所以,这是同步、非阻塞时期。你看,是不是不阻塞了,但是取出来,遍历这种事,还是得自己做,就是同步非阻塞了,就叫 NIO 了。
NIO

但是,问题还有没有?

  1. 比如说,现在有 1W 个文件描述符(fd),也就是说,这个线程要轮询 1W 次。你看啊,查询一次文件描述符,就要系统调用,然后用户态内核态切换,一大堆事在那换来换去。
  2. 虽然有了 1W 个请求,但是这 1W 个文件描述符有哪个要读,哪个不要读,谁都不知道,可能只有少数的几个是有 read 的需求的,那么轮询一次,就要遍历 1W 个 fd,可见效率有多低。

所以,这种单线程轮询在面对大并发的时候,性能又会开始下降。
所以接下来就要解决这个频繁的系统调用,但是这个用户能实现吗,肯定是实现不了的,所以内核得向前发展。

select 系统调用

为了解决用户频繁地为每一个连接去频繁系统调用,内核里发展出了一个 系统调用 select

这个时候,用户空间只要调用 select 系统调用,假设这个时候又有 1W 个文件描述符来了,
用户只要调用一次 select,那么在内核态,直接轮询 1W 个 fd,然后一次性返回给用户,
这样就大大减少了系统调用。这是用户只需 read 那少量的有数据来的 fd。

这样就是原来调 一万次,现在只要 调一次,操作系统直接返回。
于是就发展成了 多路复用
NIO

数据拷贝问题

select 有了,但是还有问题你发现了没有,这里面还是涉及到用户态和内核态的问题。

这个问题就是系统调用的数据拷贝:
用户进程放着 1W 个文件描述符,每次系统调用,就要传参,数据就要拷贝,所以大量的文件描述符就会成为累赘。

所以为了解决这个问题,Linux 用到的方法是:
开辟一个共享内存空间!!!

曾经你的内核在内存有自己的空间,用户在内存又有自己的空间,虽然可能是在同一块物理内存上,但是虚拟内存是划分开来的,用户肯定是不能访问内核的空间
所以系统调用就要 传递参数,就要传这批数据,数据就要 拷贝

于是,为了解决这个问题,内核和用户就划出一块空间,两边都能访问。
用户只要把数据写到这里面,然后就不用再拷贝数据给内核,只要告诉它在哪个位置,然后 CPU 一转,马上就能找到那个位置,然后就能直接读数据了,就不用再拷贝这个数据了。
这就是所谓的共享空间,这个系统调用叫做 mmap

所以这个时候,这 1W 个 fd 不用用户进程自己去维护这个数据,而是直接地就往这个共享空间往里面一丢,丢到这个红黑树里边。
这个时候就没有传参,就没有拷贝的过程。
这时候内核就可以直接看到这些个文件描述符,由我们内核直接去管理文件描述符,
谁的数据到达了,谁的缓冲区有了,然后把到达的放到链表里。
这样,上层用户进程是不是只要去链表里直接取出,直接读取就可以了。
mmap

轮询的效率问题

epoll 不止解决了这一个问题。

事实上,之前 select 多路复用,将 1W 次轮询放到了内核空间。
虽然说用户进程不轮询了,但是内核还在那逛次逛次在那轮询,依然效率存在问题。
我们知道,这个轮询遍历它的时间复杂度是 O(n) 级别的,所以在这个请求量一大,还是非常耗费计算机资源的。

epoll 是怎么做的呢,就是化主动为被动。

我们都知道,计算机有个东西叫 CPU,程序里有个东西叫内核(kernel),用户空间比如有几个程序,除了这些,还有网卡等 I/O 设备,如果只有一颗 CPU,如何保证计算机又能跑用户程序,又能读写网络数据,又能键盘打字,移动鼠标等等。我们用户写程序的时候肯定不会写,比如过了几百毫秒,然后发一条指令,说我休息了,你先去忙别人吧,肯定不会,那 CPU 是如何实现的。

很简单,实际上是用到了 中断。CPU 里面就有晶振时间中断,比如每秒震 10 下,那就是把一秒钟切成 10 个时间片,每个时间片处理不同的任务。
但是中断了之后干嘛,CPU 怎么知道要接下来做什么事,中断有了之后,有中断号,在内核里面会维护一段代码,叫回调(callback),每次中断的时候,程序会在里面根据自己的中断号埋一个 callback,这样就能保证 CPU 能不断执行各种事情。
然后我们的网卡要传数据的时候,因为每次那么010101010就来那么一点,不能说每来一点就中断一次,那这样只要随便传点数据,CPU 就要砰砰砰砰断个不停,那就没完了。
为了优美点,内存空间就还有个区域,叫 DMA,直接内存访问,也就是网卡接受到点了数据,就放进去,有一点就放进去。
也就是把数据放进去这事不用 CPU 来处理了,直接自己就能放进去。
放好了之后,就会敲中断,比如网卡中断号就是 88,那么网卡就会往这个内核里加一个 88 中断号的 callback,这时候 CPU 就会用这个 callback 函数找到这个 DMA,内核就可以从里面读数据了。
读完东西之后,就会有对应的一个事件去通知这个进程,然后进程就会去对这个数据做相应的处理。

那这些有什么意义?
客户端到达的时候会有一个中断,以及产生一个 callback 事件,之后内核间接知道了网络数据包到达。
所以我们就可以明白,计算机不一定要主动,完全可以被动地接收事件。
callback

epoll 完整过程

首先我们用户进程调用 epoll_create 得到一个文件描述符比如 5,然后就会在内核里面开辟一块空间,用这个文件描述符 fd5 来代表这个空间,就是上文说的红黑树。
然后调用系统调用,epoll_ctl(5,6) 在 5 里面添加一个 6,用来监听事件,监听 accept。
然后注册监听事件之后,就开始疯狂的调 epoll_wait,对这个 fd5 这个区域去 wait,实际上这个时候 CPU 在忙别的事情。
这时候如果这个客户端来了,想要建立一个连接,那么这时候,操作系统就把 fd6 扔到另外一个区域里,就是那个链表,扔过来之后,那么这个 wait 就会有返回值,事实上用户就是在等这个这个链表。
有返回之后,用户就会根据返回调用 accept(fd6),比如将这个客户端描述成 fd9,然后 accept 之后就可以等客户端发数据了。
但是由于数据什么时候到来是未知的,所以,会继续把这个 fd9 客户端注册进 fd5,也就是调用 epoll_ctl(5,9),对这个客户端,肯定是等待 read 这个事件,等待数据到达。

假设又有一个客户端到达,那肯定是触发 fd6,假设之前的那个客户端有了数据,那肯定会触发 fd9,所以 fd6 和 fd9 会被都丢到右边那个链表里,这时候,用户进程就会一次收到两个。
你可以回想一下当初用 java 写 NIO 的时候,是不是有个 select() 方法,返回一个集合,你要去遍历里面的 key,去做处理,是不是有 read 的,有 accept 的。
epoll

真正的异步

有些人可能要说上面的 epoll 就是用的异步了。
不过实际上,epoll 并不是真正的异步,Linux 是 没有 实现完全异步的。
目前是 Windows 实现了异步,但是由于 Windows 不开源,我们也不可能去用一个 Windows 去做服务器。

但是 Linux 的 epoll 已经基本上实现异步了,只是最后在 wait 返回了之后,用户进程要主动去 read,在 read 的时候,是一个 同步 的过程。
不过由于 read 的时间非常短,相比之前 wait 的异步操作,已经能提高到非常高的一个效率了。

所以 redis 能支持这么高的并发,epoll 的功劳不可末。
还有 Nginx,为什么能叫高性能 web 服务器,为啥能做负载均衡,能抗下这么高的并发,底层都是 epoll。

epoll 流程图:
epoll

作者的话

实际上,对于我们 Java 程序员来说,网络的知识是很重要的,因为我们大多数的任务是做服务端的开发,这就不可避免的涉及到网络、并发等的知识。

但是,由于很多程序员对于网络的底层知识并不了解,很可能是大学因为找不到它的实际作用因而没有学得很仔细;
也可能是因为是培训出身,并没有系统的学习过计算机底层的知识,所以对这些知识不够了解。

但是,不论如何,如果现在还不够理解,那一定需要对这些知识去进行一个学习和弥补。
一方面是因为目前的技术发展,对一个程序员的技能要求也是越来越高;
另一方面,也是因为程序员的入门门槛逐步变低,程序员的竞争压力也会越来越大。

所以,请无论如何保持一份学习的动力。
共勉。

发布了21 篇原创文章 · 获赞 187 · 访问量 4万+

猜你喜欢

转载自blog.csdn.net/weixin_44051223/article/details/105092167