解决同步阻塞IO模型下大量线程创建导致资源的浪费问题
同步阻塞IO模式的特点就是用一个进程来处理一个网络连接(一个用户请求),比如一段典型的示例代码如下。
直接调用 recv 函数从一个 socket 上读取数据。
int main()
{
…
recv(sock, …) //从用户角度来看非常简单,一个recv一用,要接收的数据就到我们手里了。
}
每个用户请求到来都得占用一个进程来处理,来一个请求就要分配一个进程跟进处理,显然在高并发的情况下会导致资源的浪费
所以必须要让一个进程能同时处理很多个 tcp 连接才行,省去进程切换,创建,销毁的资源
小故事
上午开会,错过了公司食堂的饭点, 中午就和公司的首席架构师一起去楼下的米线店去吃米线。我们到了一看,果然很多人在排队。
架构师马上发话了:嚯,请求排队啊!你看这位收银点菜的,像不像nginx的反向代理?只收请求,不处理,把请求都发给后厨去处理。
我们交了钱,拿着号离开了点餐收银台,找了个座位坐下等餐。
架构师:你看,这就是异步处理,我们下了单就可以离开等待,米线做好了会通过小喇叭“回调”我们去取餐;
如果同步处理,我们就得在收银台站着等餐,后面的请求无法处理,客户等不及肯定会离开了。
接下里构师盯着手中的纸质号牌。
架构师:你看,这个纸质号牌在后厨“服务器”那里也有,这不就是表示会话的ID吗?
有了它就可以把大家给区分开,就不会把我的排骨米线送给别人了。过了一会, 排队的人越来越多,已经有人表示不满了,可是收银员已经满头大汗,忙到极致了。
架构师:你看他这个系统缺乏弹性扩容, 现在这么多人,应该增加收银台,可以没有其他收银设备,老板再着急也没用。
老板看到在收银这里帮不了忙,后厨的订单也累积得越来越多, 赶紧跑到后厨亲自去做米线去了。
架构师又发话了:幸亏这个系统的后台有**并行处理能力,**可以随意地增加资源来处理请求(做米线)。
我说:他就这点儿资源了,除了老板没人再会做米线了。
不知不觉,我们等了20分钟, 但是米线还没上来。
架构师:你看,系统的处理能力达到极限,超时了吧。
这时候收银台前排队的人已经不多了,但是还有很多人在等米线。
老板跑过来让这个打扫卫生的去收银,让收银小妹也到后厨帮忙。打扫卫生的做收银也磕磕绊绊的,没有原来的小妹灵活。
架构师:这就叫服务降级,为了保证米线的服务,把别的服务都给关闭了。
又过了20分钟,后厨的厨师叫道:237号, 您点的排骨米线没有排骨了,能换成番茄的吗?
架构师低声对我说:瞧瞧, 人太多, 系统异常了。然后他站了起来:不行,系统得进行补偿操作:退费。
说完,他拉着我,饿着肚子,头也不回地走了。
同步
调用者一直等待调用结果的通知后才能进行后续的执行,等到出结果为止
异步
指被调用方先返回应答让调用方先回去,再计算调用结果,计算完后再通知调用方并返回结果
异步调用想要获取结果一般通过回调
同步和异步讨论的对象是被调用着,重点在于获取结果消息的通知方式上
阻塞
调用方一直在等待而什么事情也不做,当前线程会被挂起
非阻塞
调用方发送请求后,调用方可以去忙其他事情,不会阻塞当前线程
阻塞与非阻塞讨论的是调用者,重点在于等消息时候的行为
Redis利用epoll函数来实现IO的多路复用,将连接信息和事件放在队列中,一次性放到文件事件分派器,事件分派器将事件分派给事件处理器
IO复用机制,就是通过一种机制,可以监听多个文件描述符,一旦文件描述符就绪,就会通知线程进行相应的读写操作,这种机制的使用需要 select 、 poll 、 epoll 来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
Redis 服务采用 Reactor 的方式来实现文件事件处理器(每一个网络连接其实都对应一个文件描述符)
Redis基于Reactor模式开发了网络事件处理器,这个处理器被称为文件事件处理器。它的组成结构为4部分:
多个套接字、
IO多路复用程序、
文件事件分派器、
事件处理器。
因为文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型
Reactor模式
基于 I/O 复用模型:多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
Reactor 模式,是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor 模式也叫 Dispatcher 模式。即 I/O 多了复用统一监听事件,收到事件后分发(Dispatch 给某进程),是编写高性能网络服务器的必备技术。
Reactor 模式中有 2 个关键组成:
1)Reactor:Reactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。 它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人;
2)Handlers:处理程序执行 I/O 事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际办理人。Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作。
从Redis6开始,将网络数据读写、请求协议解析通过多个IO线程的来处理
大家都用过nginx,nginx使用epoll接收请求,ngnix会有很多链接进来, epoll会把他们都监视起来,然后像拨开关一样,谁有数据就拨向谁,然后调用相应的代码处理。redis类似同理
select 函数监视的文件描述符分3类,分别是readfds、writefds和exceptfds,将用户传入的数组拷贝到内核空间 调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except)或超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。 当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
select函数执行流程:
1.select是个阻塞函数,当没有数据时会一直阻塞到select那一行
2.当有数据时会将rset置为1
3.select函数返回不再阻塞
4.遍历文件描述符数组,判断哪个fd被置位了
5.读取数据然后处理
优点
select 其实就是把NIO中用户态要遍历的fd数组(我们的每一个socket链接,安装进ArrayList里面的那个)拷贝到了内核态,让内核态来遍历,因为用户态判断socket是否有数据还是要调用内核态的,所有拷贝到内核态后,这样遍历判断的时候就不用一直用户态和内核态频繁切换了
从代码中可以看出,select系统调用后,返回了一个置位后的&rset,这样用户态只需进行很简单的二进制比较,就能很快知道哪些socket需要read数据,有效提高了效率
缺点
1、bitmap最大1024位,一个进程最多只能处理1024个客户端
2、&rset不可重用,每次socket有数据就相应的位会被置位
3、文件描述符数组拷贝到了内核态(只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)),仍然有开销。select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
4、select并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历。select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
poll的执行流程
1.将5个fd从用户态拷贝到内核态
2.poll为阻塞方法,执行poll方法,如果有数据会将fd对应的revents置为pollin
3.poll方法返回
4.遍历遍历fd数组,找到被置为pollin的fd
5.将revents重置为0
6.对置位的fd进行读取和处理
优点
1、poll使用pollfd数组来代替select中的bitmap,数组没有1024的限制,可以一次管理更多的client。它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。
2、当pollfds数组中有事件发生,相应的revents置位为1,遍历的时候又置位回零,实现了pollfd数组的重用
缺点
poll 解决了select缺点中的前两条,其本质原理还是select的方法,还存在select中原来的问题
1、pollfds数组拷贝到了内核态,仍然有开销
2、poll并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历
epoll主要包含三个基本函数
int epoll_create(int size);
创建一个 epoll 对象,返回该对象的描述符(参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
操作控制 epoll 对象,主要涉及 epoll 红黑树上节点的一些操作,比如添加节点,删除节点,修改节点事件。
epfd:通过 epoll_create 创建的 epoll 对象句柄。
op:对红黑树的操作,添加节点、删除节点、修改节点监听的事件,分别对应 EPOLL_CTL_ADD,EPOLL_CTL_DEL,EPOLL_CTL_MOD。
添加事件:相当于往红黑树添加一个节点,每个客户端连接服务器后会有一个通讯套接字,每个连接的通讯套接字都不重复,所以这个通讯套接字就是红黑树的 key。
修改事件:把红黑树上监听的 socket 对应的监听事件做修改。
删除事件:相当于取消监听 socket 的事件。
fd:需要添加监听的 socket 描述符,可以是监听套接字,也可以是与客户端通讯的通讯套接字。
event:事件信息。
int epoll_wait(int epid, struct epoll_event *events, int maxevents, int timeout);
功能说明 :阻塞一段时间并等待事件发生,返回事件集合,也就是获取内核的事件通知。说白了就是遍历双向链表,把双向链表里的节点数据拷贝出来,拷贝完毕后就从双向链表移除。
epid:epoll_create 返回的 epoll 对象描述符。
events:存放就绪的事件集合,这个是传出参数。
maxevents:代表可以存放的事件个数,也就是 events 数组的大小。
timeout:阻塞等待的时间长短,以毫秒为单位,如果传入 -1 代表阻塞等待。
epoll执行流程
epoll是非阻塞的
1.当有数据的时候会把相应的文件描述符“置位”,但epoll没有revent标志位,所以并不是真正的置位,这时会把有数据的文件描述符放置队首
2.epoll会返回有数据的文件描述符个数
3.根据返回的个数N读取前N个文件描述符
多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。
epoll是现在最先进的IO多路复用器,Redis、Nginx,linux中的Java NIO都使用的是epoll。
这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。
1、一个socket的生命周期中只有一次从用户态拷贝到内核态的过程,开销小
2、使用event事件通知机制,每次socket中有数据会主动通知内核,并加入到就绪链表中,不需要遍历所有的socket
在多路复用IO模型中,会有一个内核线程不断地去轮询多个 socket 的状态,只有当真正读写事件发送时,才真正调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有真正有读写事件进行时,才会使用IO资源,所以它大大减少来资源占用。多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。 采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),且 Redis 在内存中操作数据的速度非常快,也就是说内存内的操作不会成为影响Redis性能的瓶颈