区分三种IO模型和select/poll/epoll
部分内容来源:JavaGuide
select/poll/epoll 和 三种IO模型之间的关系是什么?
区分普通IO和IO多路复用
普通IO,即一个线程对应一个连接,因为每个线程只处理一个客户端 socket,目标明确:线程中直接操作该 socket 的 read() / write(),无需关心其他连接,也就是无需遍历文件描述符
可以理解成,一个线程只持有一个Socket的文件描述符
而IO多路复用是一个线程持有多个Socket的文件描述符,所以它有一个文件描述符集合,内核要遍历这个文件描述集合检查每个Socket中是否有数据
区分select/poll/epoll和三种IO模型
select/poll/epoll 是 如何遍历文件描述符寻找有数据的 Socket 的方法;
IO 模型是Socket 拿到数据后如何处理的策略
IO多路复用是单线程如何高效管理和寻找多个Socket,而IO模型是如何处理多个Socket的数据
并且IO多路复用属于同步IO
Reactor是基于select/epoll这些IO多路复用机制实现的,也就是基于同步IO
Proactor是基于异步IO机制实现的
IO多路复用的演进流程
线程池实现资源复用
如果要让服务器服务多个客户端,那么最直接的方式就是为每一条连接创建线程
其实创建进程也是可以的,原理是一样的
进程和线程的区别在于线程比较轻量级些,线程的创建和线程间切换的成本要小些
为了描述简述,后面都以线程为例
处理完业务逻辑后,随着连接关闭后线程也同样要销毁了,但是这样不停地创建和销毁线程,不仅会带来性能开销,也会造成浪费资源,而且如果要连接几万条连接,创建几万个线程去应对也是不现实的
要这么解决这个问题呢?我们可以使用「资源复用」的方式
通过池化思想保存历史连接
也就是不用再为每个连接创建线程,而是创建一个「线程池」,将连接分配给线程,然后一个线程可以处理多个连接的业务。
线程怎样才能高效地处理多个连接的业务?
当一个连接对应一个线程时,线程一般采用「read -> 业务处理 -> send」的处理流程
如果当前连接没有数据可读,那么线程会阻塞在 read 操作上(socket 默认情况是阻塞 I/O)
不过这种阻塞方式并不影响其他线程。
但是引入了线程池,那么一个线程要处理多个连接的业务,线程在处理某个连接的 read 操作时,如果遇到没有数据可读,就会发生阻塞,那么线程就没办法继续处理其他连接的业务。
要解决这一个问题,最简单的方式就是将 socket 改成非阻塞,然后线程不断地轮询调用 read 操作来判断是否有数据,这种方式虽然该能够解决阻塞的问题
但是解决的方式比较粗暴
因为轮询是要消耗 CPU 的,而且随着一个线程处理的连接越多,轮询的效率就会越低
为什么Socket要设置为非阻塞?
当socket设为非阻塞时,即使没有数据可读,read()也会立即返回一个【无数据的错误】
(如 Linux 的EAGAIN
),不会阻塞线程
此时线程可以通过轮询处理多个连接
线程循环遍历自己负责的所有socket
描述符,对每个socket
调用read()
- 如果
read()
返回数据,就处理该连接的业务; - 如果
read()
返回 “无数据”,就跳过这个socket
,继续轮询下一个
监听连接
上面的问题在于,线程并不知道当前连接是否有数据可读,从而需要每次通过 read 去试探
那有没有办法在只有当连接上有数据的时候,线程才去发起读请求呢?
答案是有的,实现这一技术的就是 I/O 多路复用
I/O 多路复用技术会用一个系统调用函数来监听我们所有关心的连接,也就说可以在一个监控线程里面监控很多的连接
三种IO模型
小区别
IO多路复用属于网络监听模型,例如Redis实现网络监听的时候就是使用IO多路复用的,当Redis启动的时候,它会主动使用IO多路复用网络监听模型去监听
每个 Socket 在内核中都有对应的接收缓冲区和发送缓冲区
read:从该 Socket 对应的接收缓冲区中读取数据到用户缓冲区
Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式
这里先给大家复习下阻塞、非阻塞、同步、异步 I/O 的概念
阻塞I/O
先来看看阻塞 I/O,当用户程序执行 read,线程会被阻塞
一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read 才会返回
注意:阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程
过程如下图:
非阻塞 I/O
知道了阻塞 I/O,来看看非阻塞 I/O,非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续下执行
此时应用程序不断轮询内核直到数据准备好
内核将数据拷贝到应用程序缓冲区,read 才可以获取到结果
过程如下图:
注意,这里最后一次 read 调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程
异步IO
如果 socket 设置了 O_NONBLOCK 标志,那么就表示使用的是非阻塞 I/O 的方式访问
而不做任何设置的话,默认是阻塞 I/O
因此,无论 read 和 send 是阻塞 I/O,还是非阻塞 I/O 都是同步调用
因为在 read 调用时,内核将数据从内核空间拷贝到用户空间的过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间
为什么这么说?
同步 的核心是:用户线程必须亲自参与数据拷贝的等待过程
并且用户 线程必须等待拷贝完成才能继续执行
同步IO要等待的两个步骤:
- 「内核数据准备好」
- 「数据从内核空间拷贝到用户空间」
阻塞IO:等待。你到咖啡店后,发现咖啡还没做好,于是站在柜台前一直等
非阻塞IO:轮询。你到咖啡店后如果没好,那你就去附近晃悠一会儿,然后再问咖啡时候做好了,直到店员说好了
真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待
当我们发起 aio_read(异步 I/O)之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间
这个拷贝过程同样是异步的,内核自动完成的
和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作
过程如下图:
理解IO的简单例子
举个你去饭堂吃饭的例子,你好比应用程序,饭堂好比操作系统
阻塞 I/O
你去饭堂吃饭,但是饭堂的菜还没做好,然后你就一直在那里等啊等,等了好长一段时间终于等到饭堂阿姨把菜端了出来(数据准备的过程),但是你还得继续等阿姨把菜(内核空间)打到你的饭盒里(用户空间),经历完这两个过程,你才可以离开。
非阻塞 I/O
你去了饭堂,问阿姨菜做好了没有,阿姨告诉你没,你就离开了,过几十分钟,你又来饭堂问阿姨,阿姨说做好了,于是阿姨帮你把菜打到你的饭盒里,这个过程你是得等待的
异步 I/O
你让饭堂阿姨将菜做好并把菜打到饭盒里后,把饭盒送到你面前,整个过程你都不需要任何等待
很明显,异步 I/O 比同步 I/O 性能更好
因为异步 I/O 在「内核数据准备好」和「数据从内核空间拷贝到用户空间」这两个过程都不用等待
Proactor 正是采用了异步 I/O 技术,所以被称为异步网络模型
简单总结-IO多路复用的演进流程
面试引导:
线程创建销毁的开销
->池化思想
->select,poll存在的问题
->轮询机制对比事件驱动机制
->epoll,基于红黑树和哈希表而不是遍历文件描述符
->引出IO多路复用的两种实现,即Reactor和Proactor
池化思想:
如果让一个服务器能够服务更多的服务端,最直接的方式就是给每一条连接创建一个线程
但是线程的创建和销毁是有一定的开销的,会消耗CPU的时间片轮转的资源
所以为了达到资源复用就出现了【池化思想】,也就是我们的线程池
不要弄混执行任务的线程池和处理IO的线程池,这两个东西只是分别处理的东西不同
线程的职责可以根据任务类型划分
有的线程专注于处理连接的 I/O 操作
有的线程专注于执行业务逻辑任务
IO线程如何高效处理多个连接的业务:
一个连接对应一个线程时线程的处理流程:
「read -> 业务处理 -> send」
线程池的目的是让一个线程照顾多个连接
但一线程对应多连接时,线程在处理某个连接的 read 操作时,如果没有数据可读,则会阻塞
导致线程无法执行其它的IO任务
简单解决方式:将Socket换成非阻塞,之后线程不断地轮询调用 read 操作来判断是否有数据
但轮询是要消耗 CPU 的,随着一个线程处理的连接越多,轮询的效率就会越低
当socket设为非阻塞时,即使没有数据可读,read()也会立即返回一个【无数据的错误】,不会阻塞线程
监听连接:
问题所在:线程并不知道当前连接是否有数据可读
因此需要每次通过 read 去试探(轮询)
我们可以引入一种事件驱动机制,等连接来了我们再去执行,这就是epoll
三种IO模型-快速复习
小区别(防止概念混淆):
IO多路复用属于网络监听模型,例如Redis实现网络监听的时候就是使用IO多路复用的,当Redis启动的时候,它会主动使用IO多路复用网络监听模型去监听
每个 Socket 在内核中都有对应的接收缓冲区和发送缓冲区
read:从该 Socket 对应的接收缓冲区中读取数据到用户缓冲区
理解三种IO模型:
read 和 send 是阻塞 I/O,还是非阻塞 I/O 都是同步调用
因为在 read 调用时,内核将数据从内核空间拷贝到用户空间的过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间
为什么这么说?
同步 的核心是:用户线程必须亲自参与数据拷贝的等待过程
并且用户 线程必须等待拷贝完成才能继续执行
同步IO要等待的两个步骤:
- 「内核数据准备好」
- 「数据从内核空间拷贝到用户空间」
阻塞IO:等待。你到咖啡店后,发现咖啡还没做好,于是站在柜台前一直等
非阻塞IO:轮询。你到咖啡店后如果没好,那你就去附近晃悠一会儿,然后再问咖啡时候做好了,直到店员说好了
真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待
当我们发起 aio_read(异步 I/O)之后,就立即返回
内核自动将数据从内核空间拷贝到用户空间
这个拷贝过程同样是异步的,内核自动完成的
和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作