基础概念
Socket
套接字。百科:对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。
例子1:客户端将数据通过网线发送到服务端,客户端发送数据需要一个出口,服务端接收数据需要一个入口,这两个“口子”就是 Socket。
例子2:两个人通过电话进行通信,两个人都需要持有1个电话,socket 就类似于这个电话。
FD:file descriptor
文件描述符,非负整数。“一切皆文件”,linux 中的一切资源都可以通过文件的方式访问和管理。而 FD 就类似文件的索引(符号、指针),指向某个资源,内核(kernel)利用 FD 来访问和管理资源。
之前在视频中有同学问既然有 socket,为什么文章内容全是用的 FD 来举例,这是因为当我们调用内核函数创建 socket 后,内核返回给我们的是 socket 对应的文件描述符(fd),所以我们对 socket 的操作基本都是通过 fd 来进行。
阻塞IO
服务端为了处理客户端的连接和请求的数据,写了如下代码。这段代码会执行得磕磕绊绊。
可以看到,服务端的线程阻塞在了两个地方,一个是 accept 函数,一个是 read 函数。
如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接。
非阻塞 IO
为了解决上面的问题,其关键在于改造这个 read 函数。
有一种聪明的办法是,每次都创建一个新的进程或线程,去调用 read 函数,并做业务处理。
这样,当给一个客户端建立好连接后,就可以立刻等待新的客户端连接,而不用阻塞在原客户端的 read 请求上
不过,这不叫非阻塞 IO,只不过用了多线程的手段使得主线程没有卡在 read 函数上不往下走罢了。操作系统为我们提供的 read 函数仍然是阻塞的。
所以真正的非阻塞 IO,不能是通过我们用户层的小把戏,而是要恳请操作系统为我们提供一个非阻塞的 read 函数。
这个 read 函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。
操作系统提供了这样的功能,只需要在调用 read 前,将文件描述符设置为非阻塞即可。
IO 多路复用
为每个客户端创建一个线程,服务器端的线程资源很容易被耗光。
当然还有个聪明的办法,我们可以每 accept 一个客户端连接后,将这个文件描述符(connfd)放到一个数组里。
然后弄一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞 read 方法。
这样,我们就成功用一个线程处理了多个客户端连接。
但这和我们用多线程去将阻塞 IO 改造成看起来是非阻塞 IO 一样,这种遍历方式也只是我们用户自己想出的小把戏,每次遍历遇到 read 返回 -1 时仍然是一次浪费资源的系统调用。
所以,还是得恳请操作系统老大,提供给我们一个有这样效果的函数,我们将一批文件描述符通过一次系统调用传给内核,由内核层去遍历(而不是在用户态调用,再陷入到内核态中去遍历),才能真正解决这个问题。
select
select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理:
select的问题:
- select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
- select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
- select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
epoll
epoll 是最终的大 boss,它解决了 select 和 poll 的一些问题。
epoll 主要就是针对上面三个缺点进行了改进。
- 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
- 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
- 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
使用起来,其内部原理就像如下一般丝滑。
IO模型小例子
例子:你是一个老师,让学生做作业,学生做完作业后收作业。
同步阻塞:逐个收作业,先收A,再收B,接着是C、D,如果有一个学生还未做完,则你会等到他写完,然后才继续收下一个。
解析:这就是同步阻塞的特点,只要中间有一个未就绪,则你会被阻塞住,从而影响到后面的其他学生。
同步非阻塞:逐个收作业,先收A,再收B,接着是C、D,如果有一个学生还未做完,则你会跳过该学生,继续去收下一个。
解析:可以看到同步非阻塞相较于同步阻塞已经是更好的方案了,你不会因为某个学生未就绪而阻塞住,这样就可以减少对后续学生的影响。但是这个方案也可能会出现其他问题,如果你下去收作业的时候,全部学生都还没做完,则你可能会白走一圈,然后一个作业也没收到。
select/poll:学生写完了作业会举手,但是你不知道是谁举手,需要一个个的去询问。
解析:这个方案相较于同步非阻塞来说有一点好处,就是你是确认有学生做完的,所以你下去肯定能收到作业,但是他有一个不好的点在于你需要一个个的去询问。
epoll:学生写完了作业会举手,你知道是谁举手,你直接去收作业。
解析:这个方案就很高效了,每次都能准确的收到作业。
总结
一切的开始,都起源于这个 read 函数是操作系统提供的,而且是阻塞的,我们叫它 阻塞 IO。
为了破这个局,程序员在用户态通过多线程来防止主线程卡死。
后来操作系统发现这个需求比较大,于是在操作系统层面提供了非阻塞的 read 函数,这样程序员就可以在一个线程内完成多个文件描述符的读取,这就是 非阻塞 IO。
但多个文件描述符的读取就需要遍历,当高并发场景越来越多时,用户态遍历的文件描述符也越来越多,相当于在 while 循环里进行了越来越多的系统调用。
后来操作系统又发现这个场景需求量较大,于是又在操作系统层面提供了这样的遍历文件描述符的机制,这就是 IO 多路复用。
多路复用有三个函数,最开始是 select,然后又发明了 poll 解决了 select 文件描述符的限制,然后又发明了 epoll 解决 select 的三个不足。
所以,IO 模型的演进,其实就是时代的变化,倒逼着操作系统将更多的功能加到自己的内核而已。
如果你建立了这样的思维,很容易发现网上的一些错误。
比如好多文章说,多路复用之所以效率高,是因为用一个线程就可以监控多个文件描述符。
这显然是知其然而不知其所以然,多路复用产生的效果,完全可以由用户态去遍历文件描述符并调用其非阻塞的 read 函数实现。而多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。
就好比我们平时写业务代码,把原来 while 循环里调 http 接口进行批量,改成了让对方提供一个批量添加的 http 接口,然后我们一次 rpc 请求就完成了批量添加。