epoll详解_Redis详解(2)——Redis是如何做到单线程服务10000客户端的

043a2c867e3fbdd037f451205510acc6.png

前言

在Redis的配置文件中,可以指定同时连接的最大客户端数目,这个数目默认为10000,但是我们又提到Redis是单线程服务客户端请求的,那么Redis是如何做到单线程服务这10000个客户端的呢?那就是

大龙:Redis详解(一)——为什么我们都需要了解Redis​zhuanlan.zhihu.com
a1678c357a6078b8c0fba8b7dbd51b11.png

中提到的IO多路复用技术。如果只是给大家拽技术名词,那我的专栏就没什么存在的价值了。所以本篇我们将介绍IO复用技术以及深入的研究Redis是如何基于IO多路复用进行任务调度的。同时在文章的最后我们还会深入研究Python是如何基于IO多路复用技术进行协程调度的,你会惊讶的发现,两者的实现逻辑几乎完全一致。本篇需要较多的计算机网络的知识基础且涉及较多的源码分析,由于我本科没上过计网(当事人表示十分后悔TT),所以花了很多时间学习相关的内容,也感谢贵系网络所大佬强哥的科普,如果文中关于计网的部分有什么错误,欢迎在评论区指出。 本篇主要会涉及到主要内容。

  1. 基础概念:IO复用基于非阻塞IO,那么首先我们得明确什么是阻塞IO,什么是非阻塞IO
  2. IO多路复用技术:IO复用实际上是我们将文件的事件监控托管给了操作系统,那么UNIX操作系统下,主要提供了三类系统调用来帮助用户完成事件管理,分别是select, poll ,epoll, 其中epoll是Linux特有的系统调用,本节我们以性能最好的epoll为例进行介绍。
  3. Redis的任务调度: Redis要并发服务上万客户端,一定需要良好的任务调度,本节我们介绍Redis是如何借助IO多路复用技术实现高效的任务调度的。
  4. Python的协程调度:一个个python协程,实际上就是一个个任务,区别在于这些任务在被打断时能保存上下文环境,那我们在这一节会了解到Python是如何基于IO多路复用技术实现协程的调度的。

基础概念明晰

IO模式

在UNIX系统中,一切都是文件,每个文件都有一个唯一的文件描述符fd(File Descriptor)来代表。我们对文件的读写就是IO操作。文件是具有可读、可写、异常等几种状态的,只有文件处于可读状态的时候,我们才可以读到文件内容,只有文件处于可写状态的时候,我们才可以向文件写入内容。我们进行IO操作的时候会调用系统函数,这会让程序陷入到内核态中,是一个比较耗时的操作。如果我们以小明去水房接水类比调用read()函数进行文件读取操作,拧开水龙头发现水龙头没水,就代表此时文件不可读。如果小明一直在水房等待,直到水龙头有水了(文件变为可读状态)才接上水回去就是阻塞IO模式。即如果用户调用read(),直到读取上文件内容或者出错,这个函数才会有返回。但如果小明发现水龙头没水,就立马回去,过一会再来查看水龙头是不是有水,就是非阻塞IO模式。即无论文件可不可读,read函数都立马返回,如果可读就返回文件内容,如果不可读,就返回一个不可读标识符。在下文中,我们会发现如果要使用IO多路复用,那么一定就要基于非阻塞的IO模式。

IO多路复用

在UNIX中客户端也用socket文件来代表。读取客户端请求,就是从代表客户端的文件中读取数据,向客户端发送数据,就是向代表客户端的文件中写入数据。如果有100个客户端连接上了服务器,Server就会创建100个文件描述符来代表客户端。如果其中一个文件描述符可读,就说明对应的客户端发起了请求,Server就可以读出请求然后进行处理,最后再写入数据发送给客户端。那么我们如何来并发的服务这连接上的100个客户端呢?

第一种方式就是轮询这100个文件描述符,看哪个文件描述符可读,遇到第一个可读的文件描述符(假设其fd=3),就读出文件内容(客户端请求),然后处理请求,处理结束之后再把结果写入该文件。为了达到这样的目的,Server端一定要使用非阻塞IO,这样即使一个文件处于不可读状态,文件读取函数也能立刻返回,不然Server就会被阻塞在某一个文件的读操作从而浪费大量的时间。当服务器处理完结果,又会向fd=3的这个文件中写入数据,但如果这是该文件不可写入怎么办?第一种方案就是原地等待,一直等到可以写入的时候再写入,如下图代码所示。第二种就是把数据保存在某个地方,继续向后遍历,直到下次再遍历到fd=3的文件的时候,再检查是否可以写入,如果可以写入则直接写入,如果不可以写入,则继续上述流程。

def 

我们发现在上述的方案中,Server要始终循环检查客户端文件的状态比如是否可读,是否可写以及是否异常等,即监控文件状态的工作是由Server自己完成的。为了解放Server, 操作系统提供了一组系统调用来帮助监控文件的事件,当文件变为可读,则告诉Server可读事件发生,Server即可读取文件内的请求,并处理请求。如果文件可写,就会通知可写事件发生,Server就会把对应的数据写入并发送给客户端。接下来主要介绍Linux中特有的epoll系统调用,至于select、poll,由于效率会比较低,目前用的不是很多,想了解可自行百度了解。

epoll

epoll把用户关心的文件描述符上的事件放到一个内核中的事件表中。当用户询问的时候,epoll会告诉用户已经发生事件的事件列表,这样用户就可以直接对事件对应的文件描述符进行操作。epoll的使用主要有三个系统调用:

#include

其中epoll的事件epoll_event数据结构定义如下:

struct 

其中epoll_data_t是一个联合体,其定义如下

typedef 

由于epoll将用户关心的数据存到内核的一个文件中,所以首先得调用epoll_create创建一个文件,并返回其文件描述符epfd

int 

接着我们使用epoll_ctl向epfd指向的内核文件中注册我们关心的文件描述符(参数fd)以及关于这个文件描述符我们关心的事件(events)。

int 

epoll支持的事件类型如下:

c799c175c5e1d3564ea29b988120fb80.png
epoll的事件类型

注册好了事件之后,我们就可以调用epol_wait向操作系统询问发生的事件列表了。

int 

epoll_wait将发生的事件保存在event指针所指向的数组中,通过函数的返回值我们可以得到事件数组的长度。 可以通过events中的data属性取到发生了该事件的文件描述符。同时调用epoll_wait的时候,如果没有事件发生,我们可以指定一个等待超时时间,如果等待时间到了还没有事件发生,函数就返回,如果在等待超时时间之前就发生了事件,则立刻返回,总而言之,我们在epoll_wait函数调用这里停留最多timeout时间, 如果把timeout设置为-1, 则会一直等待到有事件发生为止,如果设置等待时间为0, 则epoll_wait函数会立刻返回,不会拿到任何事件,这一点性质使得epoll_wait有的时候被当做定时器来用。有了epoll,我们就可以很方便的并发处理多个客户端请求了。在每个循环中,我们只需要通过epoll_wait来获取所有发生的事件及发生了该事件的客户端文件描述符,如果发生了可读事件EPOLLIN,我们就读取用户请求,处理其请求。如果发生了可写事件,EPOLLOUT,我们就向socket文件中写入结果数据并向用户发送,而不需要像之前那样要遍历所有的文件描述符。我们可以这样写我们的服务器。

int 

可能有同学会有疑问,用epoll的确方便了用户,因为用户可以直接处理从epoll_wait拿到的事件列表,但这样为什么比循环遍历速度快呢?主要原因有如下两点

  1. 文件状态的探测本身涉及到系统调用,我们前面提过系统调用本身是个耗时的操作,可以想象像微信这样的产品,同时服务的客户端(我们用户)达到几十亿,如果采用循环遍历文件描述符的处理方式,那耗费的时间将是巨大的
  2. 我们连接上百万上千万个客户端,但所有的客户端中处于活跃状态的客户端其实大部分时候都是少数的。还以微信举例,全球有几十亿用户,但在同一时刻,并不是所有的用户会操作微信,这样每时每刻处于活跃的客户端相比较所有用户数目来说是极其少数的。这样,我们每次遍历所有文件描述符就不值得了,因为我们可能遍历了10亿个用户,最终取出来活跃的只有几千万个,那有90%的遍历都是无意义的操作,而通过epoll我们可以只获取已经发生了的事件,即微信可以只获取了那些在某个时刻操作了微信的用户,这样大大提高了处理的效率。

Redis事件调度

文件事件和时间事件

IO多路复用在Redis中被重度使用,基于不同的操作系统和用户环境,Redis会使用不同的接口,比如Unix下有select,poll, epoll。Redis对三种系统调用进行了统一的接口封装,不失一般性,我们假定在下文的代码中,Redis的事件监控采用的是epoll系统调用。在Redis中,主要有两类事件:TimeEvents和FileEvents。 顾名思义,前者是时间事件,一般都是一些定时任务,比如每隔1个小时就进行一次数据持久化,后者是socket文件的IO事件,比如文件描述符是否可读了,文件描述符是否可写等。

时间事件和文件事件定义分别如下:

/* File event structure

对于aeFileEvent我们关心的是可读和可写事件,并对两种事件分别绑定两个回调函数:读事件处理器rfileProc和写事件处理器wfileProc。当读事件发生后,我们调用读事件处理器,当可写事件发生后,我们调用写事件处理器。aeTimeEvent里包括了该定时事件希望发生的时间以及事件发生后调用的回调函数timeProc, 其中when_sec和when_ms指的是事件发生时的时间戳,Redis支持到ms级别的精度。同时我们还发现相比较aeFileEvent,时间事件结构体中多了一个链表指针,指向下一个时间事件。这是为什么呢?因为我们刚才提到,文件事件即IO事件的管理实际上我们使托管给了操作系统,他会帮我们托管好。但是时间事件,操作系统并没有提供了一个类似的功能供我们使用,所以需要Redis自己去管理。而Redis采用了链表来存储管理所有的时间事件。Redis还定义了一种叫eventloop的数据结构,用来管理所有的事件。其数据结构如下,存储所有注册了的aeFileEvent和所有的aeTimeEvent, 由于Redis采用链表管理所有的时间事件,所以只需要保存时间事件的链表头即可。

/* State of an event based program 

事件调度

接下来就要进入Redis的大量源码分析了,在大家读的时候,一定要先看下这些代码,注释都加的比较详细,所以还比较容易懂。Redis程序启动之后,首先进行服务器的初始化,然后进行一系列的其他操作,最后开始进入到aeMain事件循环中。服务器初始化做了哪些事情我们后面会有提及,我们先看事件循环函数。在aeMain中,事件循环的每一步Redis都会执行aeProcessEvents()函数处理注册好的各种事件,而且在进事件处理函数之前,程序会先做一些过期键检查等操作,这个部分暂时可以忽略,日后有需要会再讲。

int 

进入到了事件处理函数,就真正进入到了事件调度部分,这里也是我们本次内容的重点。aeProcessEvent函数的执行逻辑如下图所示,首先检查时间事件并根据结果确定epoll_wait中的超时参数。如果有时间事件的时间戳已经过了,比如原本该事件预计发生的时间是13:00,结果现在已经13:01了,那么就把超时参数设置为0, 即不获取文件事件,立刻处理时间事件。反之,如果发现没有注册任何时间事件,则在第二步可以把time_out设置为-1, 让epoll_wait一直等到有文件事件发生为止。具体我们下文的详细代码分析。

3456d56bf1e14544a77ef57091655262.png
aeProcessEvent()函数的处理流程

在事件调度中,Redis首先要尽可能保证的是,如果有定时任务(时间事件)发生了,应尽快的处理时间事件。所以首先检查所有时间事件中预计发生时间最早的任务,即获得时间事件链表中时间戳最小的事件的指针。

aeTimeEvent 

如果shortest返回是个空指针,说明eventloop里面就没有时间事件,因为只要有时间事件都会有返回,那我们就可以专心处理文件事件。由于我们将文件事件的管理托管给了操作系统,所以我们需要先调用epoll_wait函数获得所有发生的事件数组。Redis将epoll_wait封装为了aeApiPoll函数,其中tvp参数是一个时间戳的结构体指针,用于指定epoll_wait中的超时参数。由于我们没有时间事件需要处理,所以我们在这里就可以将tvp设置为NULL,让epoll_wait函数始终等下去,直到有文件事件发生。从aeApiPoll拿到了所有的已发生事件之后,我们就依次调用对应的处理函数。如果是个可读事件,我们就执行该事件绑定的读事件处理器,如果是可写事件,则执行绑定的写事件处理器。

numevents 

那么如果我们有时间事件会怎么样呢,我们前面提到过,Redis会尽量保证当时间事件发生后,尽快的先处理时间事件。shortest返回的就是所有时间事件中会最早发生的那个。首先我们需要比较当前时间戳和shortest任务的时间戳,如果当前时间已经过了shortest所指的事件的预定时间了,我们得刻不容缓的处理时间事件了,所以把tvp的等待时间设置为0,这样当我们执行aeApiPoll时,会立即返回。

// 如果时间事件存在的话

如果还没到shotest事件对应的时间戳,我们就以shortest的时间戳和当前的时间戳的差值作为aeApiPoll的等待时间参数。比如最早发生的时间预计距离目前两分钟后,那么我们就设置在等待文件事件上最多阻塞2分钟。那么有同学可能有疑惑,那如果在这两分钟内,aeApiPoll返回了很多文件事件,按照aeProcess函数的逻辑,是会先处理这些文件事件的,那么是不是很可能这部分文件事件处理结束时早已经过了2分钟了,即已经过了时间事件预计该发生的时间了?是有这个可能的,但是对此Redis并不做什么措施。所以定时任务可能不会严格与预定的时间一致。比如可能预定的时间是13:00, 但最终任务被执行的时间可能是13:01。当然了,只会晚,不会早。

//如果shortest的时间戳还没到,就用时间差设置aeApiPoll的等待参数

至此我们已经知道了aeProcessEvent函数的整体执行逻辑了,我们将aeProcessEvent的整体函数贴在下面。当Redis启动之后,Redis就会循环执行这个函数,所以理解这个函数对理解Redis的任务调度至关重要。

int 

如果大家有研究过Redis的这部分代码,可能会和当初的我一样有一个疑惑: Redis一直在循环执行这个函数,不停的获得时间事件和文件事件然后进行处理,这没问题,很好理解,但是似乎没看见在哪里注册这些事件啊?获得的这些事件到底是什么时候注册的?

事件注册

Redis是一个服务器,从头到尾,Redis都在不停的做着这么几件事:

  1. 当有新的客户端连接时,需要新建一个表示客户端的Client数据结构,并为客户端之分配一个socket文件
  2. 当已连接的用户发起新的请求时,读取请求并进行处理
  3. 处理完用户的请求之后,将结果写入socket文件

对应上述三个工作,我们分别需要注册的三个文件事件是:

  1. 服务器端口监听socket文件可读事件,当文件可读时,说明有新客户端连接,则创建客户端
  2. 客户端socket文件可读事件,当文件可读时,说明用户发起了新的命令请求,则读取请求并处理请求
  3. 客户端socket文件可写事件,当文件可写时,说明可以向用户发送命令执行结果。

接下来,Redis是如何把上述的三个事件注册到事件循环的。

服务器端口socket文件可读事件

前文提到,当Redis服务器启动的时候进行了一个initServerConfig()的服务器初始化操作。在这个函数中,发现了第一个文件事件注册操作。我们说过Unix中一切都是文件,在listenToPort这里Redis进行了TCP的端口监听,对端口绑定了socket文件。这个文件发生可读事件时说明有了新的客户端连接。接着对所有socket文件都创建了一个可读文件事件注册到事件循环,并通过epoll注册到操作系统中。当可读事件发生时,我们就可以在执行上文提到的aeProcessEvent函数的aeApiPoll接口时获取到该事件,并执行该事件绑定的函数。

void 

客户端socket文件可读事件

我们发现上面的可读事件注册了一个acceptTcpHandler的函数,这个函数首先为客户端创建一个socket文件,返回其文件描述符cfd, 接着进入acceptCommandHandler函数中尝试为连接的客户端创建一个客户端结构体,即redisClient结构体。在创建客户端的函数中,我们有了一个新的发现:创建客户端的时候为该客户端的socket文件创建了一个可读文件事件。可读事件发生时,说明该客户端发送来了命令请求。这一步把该事件加入到事件循环中,并通过epoll注册进操作系统。此时当客户端发来命令时该文件的可读事件被触发,我们就可以在aeProcessEvent函数中通过aeApiPoll获取到该事件,并执行该事件绑定的函数。

void 

客户端socket文件可写事件

在创建客户端的时候,绑定了一个叫readQueryFromClient函数。看到名字我们就知道这个是读取客户端命令请求的函数。readQueryFromClient函数主要做的就是读取客户端命令,并执行命令。读取完所有的命令后,调用processInputBuffer函数进行命令执行处理,而在processInputBuffer中的命令处理实际上是在processCommand函数中进行。我们以服务器执行客户端发来的quit命令为例。在processCommand函数中,处理完该命令请求后,会调用addReply函数向用户发送结果,而在addReply首先会调用prepareClientToWrite函数。

最后的最后,在prepareClientToWrite函数中,我们终于发现了最后一类事件的注册:客户端socket可写事件注册!我们发现在这里注册了一个可写文件事件并加入到事件循环,当该文件可写的时候,说明可以把结果发送给了客户端。这样,当该事件发生时,我们就可以在aeProcessEvent中调用aeApiPoll时获得该事件,并调用其回调函数sendReplyToClient把结果写入socket文件中发送给客户端。

/*

至此,我们完成了所有需要的文件事件的注册。整体流程如下:

b0fadac668a245ffcb47cd40a981361e.png
Redis文件事件注册流程

当有新的客户端连接时,服务器端口绑定的socket文件可读事件触发,此时调用回调函数acceptTcpHandler创建客户端,并同时将客户端的socket文件注册一个可读事件。当已连接用户发起命令请求时,可读事件被触发,并调用其回调函数readQueryFromClient读取客户端命令,接着执行命令。执行命令结束后,注册被处理客户端socket文件的可写事件,当该事件被触发时,调用sendReplyToClient函数将结果写入到对应文件发送给客户端。同学可能会问,为啥没有提到时间事件的注册?由于篇幅有限,这里就暂时不提了。

总结:

本篇我们主要介绍了IO复用技术,并着重分析了Redis中是如何基于IO复用进行事件调度。我们发现其实调度函数里的逻辑是比较简单的。整个aeProcessEvent函数也非常的短。关键在于弄明白这些事件是如何以及什么时候被注册进了事件循环。由于这篇博客涉及到了非常多的源码,所以可能可读性比较差,但是我已经尽可能的把原理解释清楚,我相信如果大家搞懂了我上面提到的事件调度,会对你研究Redis的源码以及搞懂Redis的运作流程有非常大的帮助。其实本来这篇还想写Python的协程实现的,因为我前两天研究了Python的协程调度实现后,发现二者惊人的一致,但是没想到这一篇写了这么长,这次就先不写了,欠着下次写吧。如果大家感兴趣,欢迎关注我的专栏哈哈,会努力保证每周一更,内容都会很实在~

后记

终于写完了!!,太TM难写了真是。不过好在坚持下来了。

更新:发现好多人收藏了这篇文章,同志们,如果你们再次打开了这篇文章,看到了这一行,收藏文章的同时欢迎大家关注我的专栏!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值