Redis:事件驱动(IO多路复用)

本文探讨了Redis如何使用单进程单线程高效处理大量客户端请求,深入分析了Reactor模式,包括C10K问题、I/O多路复用技术、Java的NIO和Netty,最终解释了Redis中Reactor模式的具体应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

§  从Redis的工作模式谈起

§  Reactor模式

·        C10K问题

·        I/O多路复用技术

·        Reactor的定义

·        Java中的NIO与Netty

§  Redis与Reactor

§  总结

§  参考资料

从Redis的工作模式谈起
我们在使用Redis的时候,通常是多个客户端连接Redis服务器,然后各自发送命令请求(例如GetSet)到Redis服务器,最后Redis处理这些请求返回结果
那Redis服务端是使用单进程还是多进程,单线程还是多线程来处理客户端请求的呢?

答案是单进程单线程

当然,Redis除了处理客户端的命令请求还有诸如RDB持久化AOF重写这样的事情要做,而在做这些事情的时候,Redis会fork(分叉出)子进程去完成但对于accept客户端连接处理客户端请求返回命令结果等等这些,Redis是使用主进程及主线程来完成的。我们可能会惊讶Redis在使用单进程及单线程来处理请求为什么会如此高效?在回答这个问题之前,

我们先来讨论一个I/O多路复用的模式--Reactor

Reactor模式
C10K问题
考虑这样一个问题:有10000个客户端需要连上一个服务器并保持TCP连接,客户端会不定时的发送请求给服务器,服务器收到请求后需及时处理并返回结果我们应该怎么解决?

方案一:我们使用一个线程来监听,当一个新的客户端发起连接时,建立连接并new一个线程来处理这个新连接

缺点:当客户端数量很多时,服务端线程数过多,即便不压垮服务器,由于CPU有限其性能也极其不理想因此此方案不可用


方案二:我们使用一个线程监听,当一个新的客户端发起连接时,建立连接并使用线程池处理该连接

优点:客户端连接数量不会压垮服务端

缺点:服务端处理能力受限于线程池的线程数,而且如果客户端连接中大部分处于空闲状态的话服务端的线程资源被浪费


因此,一个线程仅仅处理一个客户端连接无论如何都是不可接受的,那能不能一个线程处理多个连接呢?该线程轮询每个连接,如果某个连接有请求则处理请求,没有请求则处理下一个连接,这样可以实现吗?

答案是肯定的,而且不必轮询我们可以通过I/O多路复用技术来解决这个问题

I/O多路复用技术(三种里最佳)
现代的UNIX操作系统提供了select/poll/kqueue/epoll这样的系统调用,这些系统调用的功能是:你告知我一批套接字(socket),当这些套接字的可读或可写事件发生时,我通知你这些事件信息。(IO中讲到的,里面的事件分离者。在我的理解有点像中介的味道,在socket和事件处理者中充当传话的角色)、

I/O 多路复用模块(整个 I/O 多路复用模块在事件循环看来就是一个输入事件、输出 aeFiredEvent 数组的一个黑箱)


I/O 多路复用模块封装了底层的 select、epoll、avport 以及 kqueue这些 I/O 多路复用函数(实现了handle找实现的handler过程),为上层提供了相同的接口。

 

当如下任一情况发生时,会产生套接字的可读事件:

§  该套接字的接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的大小;

§  该套接字的读半部关闭(也就是收到了FIN),对这样的套接字的读操作将返回0(也就是返回EOF);

§  该套接字是一个监听套接字且已完成的连接数不为0;

§  该套接字有错误待处理,对这样的套接字的读操作将返回-1

当如下任一情况发生时,会产生套接字的可写事件:

§  该套接字的发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的大小;

§  该套接字的写半部关闭,继续写会产生SIGPIPE信号;

§  非阻塞模式下,connect返回之后,该套接字连接成功或失败;

§  该套接字有错误待处理,对这样的套接字的写操作将返回-1

此外,在UNIX系统上,一切皆文件套接字也不例外,每一个套接字都有对应的fd(即文件描述符)我们简单看看这几个系统调用的原型

select(int nfds, fd_set *r, fd_set *w,fd_set *e, struct timeval *timeout)

对于select(),我们需要传3个集合,r(读),w(写)和e其中,r表示我们对哪些fd的可读事件感兴趣,w表示我们对哪些fd的可写事件感兴趣每个集合其实是一个bitmap,通过0/1表示我们感兴趣的fd例如,

如:我们对于fd为6的可读事件感兴趣,那么r集合的第6个bit需要被设置为1这个系统调用会阻塞,直到我们感兴趣的事件(至少一个)发生调用返回时,内核同样使用这3个集合来存放fd实际发生的事件信息也就是说,调用前这3个集合表示我们感兴趣的事件,调用后这3个集合表示实际发生的事件

select为最早期的UNIX系统调用,它存在4个问题:

1)这3个bitmap有大小限制(FD_SETSIZE,通常为1024);

2)由于这3个集合在返回时会被内核修改,因此我们每次调用时都需要重新设置

3)我们在调用完成后需要扫描这3个集合才能知道哪些fd的读/写事件发生了,一般情况下全量集合比较大而实际发生读/写事件的fd比较少,效率比较低下;

4)内核在每次调用都需要扫描这3个fd集合,然后查看哪些fd的事件实际发生,在读/写比较稀疏的情况下同样存在效率问题

由于存在这些问题,于是人们对select进行了改进,从而有了poll

poll(struct pollfd *fds, int nfds, inttimeout)

 

struct pollfd {

int fd;

short events;

short revents;

}

 

poll调用需要传递的是一个pollfd结构的数组,调用返回时结果信息也存放在这个数组里面pollfd的结构中存放着fd我们对该fd感兴趣的事件(events)以及该fd实际发生的事件(revents)poll传递的不是固定大小的bitmap,因此

select的问题1解决了;poll将感兴趣事件和实际发生事件分开了,因此

select的问题2也解决了但

select的问题3和问题4仍然没有解决

select问题3比较容易解决,只要系统调用返回的是实际发生相应事件的fd集合,我们便不需要扫描全量的fd集合

对于select的问题4,我们为什么需要每次调用都传递全量的fd呢?

内核可不可以在第一次调用的时候记录这些fd,然后我们在以后的调用中不需要再传这些fd呢?

问题的关键在于无状态对于每一次系统调用,内核不会记录下任何信息,所以每次调用都需要重复传递相同信息

上帝说要有状态,所以我们有了epoll和kqueue

int epoll_create(int size);

int epoll_ctl(int epfd, int op, int fd,struct epoll_event *event);

int epoll_wait(int epfd, struct epoll_event*events, int maxevents, int timeout);

epoll_create的作用是创建一个context,这个context相当于状态保存者的概念

epoll_ctl的作用是,当你对一个新的fd的读/写事件感兴趣时,通过该调用将fd与相应的感兴趣事件更新到context中

epoll_wait的作用是,等待context中fd的事件发生

就是这么简单

epoll是Linux中的实现,kqueue则是在FreeBSD的实现

int kqueue(void);

int kevent(int kq, const struct kevent*changelist, int nchanges, struct kevent *eventlist, int nevents, const structtimespec *timeout);

与epoll相同的是,kqueue创建一个context;与epoll不同的是,kqueue用kevent代替了epoll_ctl和epoll_wait

epoll和kqueue解决了select存在的问题通过它们,我们可以高效的通过系统调用来获取多个套接字的读/写事件,从而解决一个线程处理多个连接的问题

Reactor的定义


通过select/poll/epoll/kqueue这些I/O多路复用函数库,我们解决了一个线程处理多个连接的问题,但整个Reactor模式的完整框架是怎样的呢?参考这篇paper,我们可以对Reactor模式有个完整的描述

 

Handles:表示操作系统管理的资源,我们可以理解为fd

Synchronous Event Demultiplexer:同步事件分离器,阻塞等待Handles中的事件发生

Initiation Dispatcher:初始分派器,作用为添加Event handler(事件处理器)删除Event handler以及分派事件给Event handler也就是说,SynchronousEvent Demultiplexer负责等待新事件发生,事件发生时通知InitiationDispatcher,然后Initiation Dispatcher调用event handler处理事件

Event Handler:事件处理器的接口

Concrete Event Handler:事件处理器的实际实现,而且绑定了一个Handle因为在实际情况中,我们往往不止一种事件处理器,因此这里将事件处理器接口和实现分开,与C++Java这些高级语言中的多态类似

以上各子模块间协作的步骤描述如下:(其实就是项目中所做的基于redis 的异步框架差不多)

1.    我们注册Concrete Event Handler到InitiationDispatcher中

2.   Initiation Dispatcher调用每个Event Handler的get_handle接口获取其绑定的Handle

3.   Initiation Dispatcher调用handle_events开始事件处理循环在这里,InitiationDispatcher会将步骤2获取的所有Handle都收集起来,使用Synchronous Event Demultiplexer来等待这些Handle的事件发生

4.    当某个(或某几个)Handle的事件发生时,Synchronous Event Demultiplexer通知InitiationDispatcher

5.   Initiation Dispatcher根据发生事件的Handle找出所对应的Handler

6.    InitiationDispatcher调用Handler的handle_event方法处理事件

时序图如下:


另外,该文章举了一个分布式日志处理的例子,感兴趣的同学可以看下

通过以上的叙述,我们清楚了Reactor的大概框架以及涉及到的底层I/O多路复用技术

Java中的NIO与Netty
谈到Reactor模式,在这里奉上Java大神Doug Lea的Scalable IO in Java,里面提到了Java网络编程中的经典模式NIO(非堵塞)以及Reactor,并且有相关代码帮助理解,看完后获益良多

另外,Java的NIO是比较底层的,我们实际在网络编程中还需要自己处理很多问题(譬如socket的读半包),稍不注意就会掉进坑里幸好,我们有了Netty这么一个网络处理框架,免去了很多麻烦

Redis与Reactor
在上面的讨论中,我们了解了Reactor模式,那么Redis中又是怎么使用Reactor模式的呢?

首先,Redis服务器中有两类事件,文件事件和时间事件

§  文件事件(file event):Redis客户端通过socket与Redis服务器连接,而文件事件就是服务器对套接字操作的抽象例如,客户端发了一个GET命令请求,对于Redis服务器来说就是一个文件事件

§  时间事件(time event):服务器定时或周期性执行的事件例如,定期执行RDB持久化

在这里我们主要关注Redis处理文件事件的模型参考Redis的设计与实现,Redis的文件事件处理模型是这样的:


在这个模型中,Redis服务器用主线程执行I/O多路复用程序文件事件分派器以及事件处理器而且,尽管多个文件事件可能会并发出现,Redis服务器是顺序处理各个文件事件的

Redis服务器主线程的执行流程在Redis.c的main函数中体现,而关于处理文件事件的主要的有这几行:

int main(int argc, char **argv) {

...

initServer();

...

aeMain();

...

aeDeleteEventLoop(server.el);

return 0;

}

在initServer()中,建立各个事件处理器;在aeMain()中,执行事件处理循环;在aeDeleteEventLoop(server.el)中关闭停止事件处理循环;最后退出

总结
多路 I/O 复用模型是利用select、poll、epoll可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),且Redis在内存中操作数据的速度非常快(内存内的操作不会成为这里的性能瓶颈),主要以上两点造就了Redis具有很高的吞吐量。

在这篇文章中,我们从Redis的工作模型开始,讨论了C10K问题、I/O多路复用技术、Java的NIO,最后回归到Redis的Reactor模式中。如有纰漏,恳请大家指出,我会一一加以勘正。谢谢!
 

<think>嗯,用户让我介绍一下Java程序员面试中关于RedisIO多路复用的问题。首先,我需要确保自己理解清楚这个主题,然后才能通俗易懂地讲解出来。 首先,IO多路复用是什么?可能用户知道一些网络编程的基础,但需要从Redis的角度来解释。我记得Redis使用单线程模型来处理客户端的请求,但为什么单线程还能高效呢?这应该和IO多路复用有关。 接下来,要回忆一下传统的阻塞IO模型。比如,每个连接一个线程,当有大量连接时,线程切换开销大,资源消耗多。这时候,IO多路复用技术就显得重要了,它允许一个线程监控多个连接,当有事件发生时再处理,这样减少了线程的数量,提高了效率。 然后,需要解释IO多路复用的机制,比如select、poll、epoll这些系统调用。Redis在不同操作系统上会选择不同的实现,比如在Linux上用epoll,而在其他系统可能用kqueue或select。需要说明这些机制的区别,比如epoll的效率更高,因为它使用事件驱动的方式,不需要遍历所有文件描述符。 可能用户不太明白为什么单线程还能处理高并发。这里需要举例子,比如餐厅服务员的例子,一个服务员同时照看多个桌子的客人,当有客人需要点餐或结账时,服务员才去处理,而不是一直等在某个桌子旁边。这样类比IO多路复用,一个线程处理多个连接的事件,提高效率。 另外,需要提到Redis的单线程模型避免了多线程的上下文切换和竞争条件,简化了设计,但同时也意味着CPU密集型操作可能会成为瓶颈。不过对于Redis来说,大部分操作是内存操作,所以单线程足够高效。 还有,用户可能想知道为什么Redis选择IO多路复用而不是多线程。这时候需要比较两者的优缺点,多线程虽然可以并行处理,但线程管理和同步的开销大,而IO多路复用通过事件驱动的方式更高效地处理大量连接。 可能还需要提到Java中的NIO库,比如Selector和Channel,这样用户作为Java程序员可能更容易理解,因为Java NIO也使用了类似的机制。比如,Selector可以监控多个Channel的事件,当有事件就绪时,Selector会通知程序处理,这和RedisIO多路复用原理类似。 还要注意回答的结构,先解释问题,再讲原理,再举例和类比,最后总结应用场景和优势。确保内容条理清晰,便于用户理解。 需要检查是否有错误的地方,比如epoll的工作模式,是否真的是事件驱动,而select是轮询。确认这些技术细节的正确性。同时,避免使用过于专业的术语,或者如果用了,需要用简单的例子来解释。 可能还需要提到Redis的性能优势,比如在高并发场景下,IO多路复用如何帮助Redis处理大量请求,保持低延迟和高吞吐量。 总结一下,整个思考过程需要涵盖IO多路复用的概念、为什么Redis使用它、与传统模型的对比、具体实现机制(如epoll)、类比举例以及和Java NIO的联系。这样用户不仅能理解概念,还能联系到已有的知识,加深记忆。</think>用快餐店柜台帮你理解RedisIO多路复用(全程无术语版): 假设你开了一家快餐店,现在有三种经营模式: 模式一:传统服务员(阻塞IO) - 每个顾客必须配专属服务员 - 服务员要一直站在顾客身边等点单 - 顾客思考菜单时服务员只能干等 - 结果:10个顾客就要10个服务员,成本爆炸 模式二:多线程服务员(多线程模型) - 每个顾客仍配专属服务员 - 服务员可以边等边做其他事 - 但雇佣/管理大量服务员很麻烦 - 结果:服务员太多容易互相撞到(线程竞争),管理成本高 模式三:超级服务员(IO多路复用)✅ - 只雇佣1个最牛服务员 - 服务员面前有所有顾客的呼叫铃(文件描述符集合) - 谁按铃就立刻服务谁(事件触发) - 等待期间可以整理柜台或备餐(CPU处理命令) - 结果:1人轻松应对100个顾客,效率爆表 Redis为什么选择模式三: 1. 后厨做菜极快(内存操作),不需要多个厨师(多线程) 2. 避免多个服务员抢厨房(线程安全) 3. 点餐高峰期(高并发)也能从容应对 Java程序员面试常考点: - 核心组件:Selector + Channel + Buffer - 关键方法:select() 相当于服务员的"查看所有呼叫铃"动作 - 三种实现:select(有限铃数量)/poll(不限数量)/epoll(智能铃系统) 举个代码例子: ```java Selector selector = Selector.open(); ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.bind(new InetSocketAddress(6379)); ssc.configureBlocking(false); ssc.register(selector, SelectionKey.OP_ACCEPT); // 注册"新顾客到来"事件 while(true) { int readyChannels = selector.select(); // 服务员查看呼叫铃 if(readyChannels == 0) continue; Set<SelectionKey> keys = selector.selectedKeys(); Iterator<SelectionKey> iter = keys.iterator(); while(iter.hasNext()) { SelectionKey key = iter.next(); if(key.isAcceptable()) { // 处理新连接 } else if(key.isReadable()) { // 处理读请求 } iter.remove(); } } ``` 实际应用场景: - 当你的QPS超过1万时 - 需要保持大量长连接(如即时通讯) - 服务器资源有限但并发量高 面试加分回答: "Redis的单线程是指命令处理主线程,实际上还有后台线程处理持久化等任务。选择单线程+IO多路复用,本质是用空间换时间,通过内存高速访问特性,最大化单核CPU利用率,避免了多线程上下文切换的开销。"
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值