Redis原理篇之网络模型

x33g5p2x  于2022-05-30 转载在 Redis  
字(7.2k)|赞(0)|评价(0)|浏览(766)

用户空间和内核空间

任何Linux发行版,其系统内核都是Linux。

我们的应用都需要通过Linux内核与硬件交互。

操作系统运行起来后,会占用部分系统资源,例如: 内存资源,cpu资源等.

此时,用户应用程序也同样需要占用这些资源,如果不加以限制,那么会和操作系统争抢资源,导致冲突。

为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的。

  • 进程的寻址空间会划分为两部分: 内核空间和用户空间
    操作系统通过虚拟内存的方式来访问主存,如果操作系统是32位(MAR是32位的),那么可表示的地址范围为0—>2^32
    应用程序给出一个32位地址,操作系统通过设备驱动程序从主存中读取出该地址对应的一个字节的数据

应用程序想要读取数据,需要调用操作系统接口,即此时需要进行用户态到内核态的转换,下面看看这个转换过程究竟是怎么样的

  • 可以看出来上面举出的例子中: 等待读取数据和copy two twice的过程是比较耗时的,也是需要优化的地方,下面就看看redis对着两处做了哪些改进

IO模型

在《UNIX网络编程》一书中,总结归纳了5种模型:

  • 阻塞IO
  • 非阻塞IO
  • IO多路复用
  • 信号驱动IO
  • 异步IO

阻塞IO

阻塞IO就是两个阶段都必须阻塞等待:

非阻塞IO

非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。

可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞的,第二个阶段是阻塞状态。

虽然是非阻塞的,但性能并没有得到提高。

而且忙等机制会导致CPU空转,CPU使用率暴增。

IO多路复用

那么问题来了: 用户进程如何知道内核中数据是否就绪呢?

文件描述符: 简称FD,是一个从0开始递增的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件,视频,硬件设备等,当然也包括网络套接字(Socket)

IO多路复用: 是利用单个线程来同时监听多个FD,并在某个FD可读,可写时得到通知,从而避免无效等待,充分利用CPU资源

不过监听FD的方式,通知的方式又有多种实现,常见的有:

  • select
  • poll
  • epoll

差异:

  • select 和 poll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认

  • epoll则会通知用户进程FD就绪的同时,把已继续的FD写入用户空间

Select

select是Linux中最早的I/O多路复用实现方案:

//定义类型别名 __fd_mask,本质是long int ---32位
typedef long int __fd__mask;
//fd_set 记录要监听的fd集合,及其对应状态
typedef struct{
  //fds_bits是long类型数组,长度为1024/32=32
  //共1024个bit位,每个Bit位代表一个fd,0代表未就绪,1代表就绪
  __fd_mask fds_bits[__FD_SETSIZE/ __NFDBITS];
  //...
} fd_set;

//select函数,用于监听多个fd集合
int select(
 int nfds, //要监视的fd_set的最大fd+1
  fd_set *readfds, //要监听读事件的fd集合
  fd_set *writefds, //要监听写事件的fd集合
  fd_set *exceptfds,// 要监听异常事件的fd集合
  //超时时间,null-永不超时,0-不阻塞等待,大于0--固定等待时间
  struct timeval * timeout
)

图解:

  • 假设此时没有就绪的fd

  • 此时fd=1就绪

select模式存在的问题
  • 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
  • select无法得知具体是哪个fd就绪,需要遍历整个fd_set
  • fd_set监听的fd数量不能超过1024

poll

poll模式对select模式做了简单改进,但性能提升不明显,部分关键代码如下:

//pollfd中的事件类型
#define POLLIN //可读事件
#define POLLOUT //可写事件
#define POLLERR //错误事件
#define POLLNVAL //fd未打开

//pollfd结构
struct pollfd{
  int fd; //要监听的fd
  short int events; //要监听的事件类型: 读,写,异常
  short int revents;//实际发生的事件类型
}

//poll函数
int poll(
  struct pollfd *fds,//pollfd数组,可以自定义大小
  nfds_t nfds,//数组元素个数
  int timeout //超时时间
);

IO流程:

  • 创建pollfd数组,向其中添加关注的fd信息,数组大小自定义
  • 调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
  • 内核遍历fd,判断是否就绪
  • 数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
  • 用户进程判断n是否大于0
  • 大于0则遍历pollfd数组,找到就绪的fd

与select对比:

  • select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论无上限
  • 监听FD越多,每次遍历消耗时间也越久,性能反而会下降

epoll

epoll模式是对select和poll的改进,它提供了三个函数:

struct eventpoll{
  //...
  struct rb_root rbr;//一颗红黑树,记录要监听的FD
  struct list_head rdlist;//一个链表,记录就绪的FD
  //...
}

//1.会在内核创建eventpoll结构体,返回对应的句柄epfd
int epoll_create(int size)

//2.将一个FD添加到epoll的红黑树中,并设置ep_poll_callback
//callback触发时,就把对应的FD加入到rdlist这个就绪列表中
int epoll_ctl(
      int epfd, //epoll实例的句柄
      int op,//要执行的操作,包括: ADD,MOD,DEL
      int fd,//要监听的FD
      struct epoll_event *event //要监听的事件类型: 读,写,异常等
);

//3.检查rdlist列表是否为空,不为空则返回就绪的FD的数量
int epoll_wait(
   int epfd, //eventpoll实例句柄
   struct epoll_event *event,//空event数组,用于接收就绪的FD
   int maxevents, //events数组的最大长度
   int timeout //超时时间 -1永不超时 0不阻塞 大于0为阻塞时间
);

假设此时有监听事件发生,相关回调接口被调用,将对应的FD加入到rdlist这个就绪列表中

对比模式对比

事件通知机制

  • ET模式

如果对应的FD还有数据没有读取完毕,需要手动调用epoll_ctl继续监听相关FD,用来处理后续没有处理完成的数据

  • lt模式

epoll_wait函数被调用后,会去检查list_head链表是否有元素,不为空则返回就绪的FD的数量

注意
  • 尽量不要使用阻塞IO进行读取,因为阻塞IO会在没有数据可读时阻塞住,直到有数据时,才会返回,这样会阻塞当前进程
  • 非阻塞IO加ET模式,可以形成非常好的效果,因为可以确保在一次通知中,将数据全部读取完毕

LT模式可能会出现惊群现象:

进程1,2,3都监听了相同的FD,此时6,8发生了对应的事件,先通知进程1,但是FD6,FD8并没有从List_head链表中移除,因此进程2和进程3也都会被通知,这就是惊群现象。

如果是ET模式,通知完进程1后,相关FD就从list_head中移除了,因为该进程已经能够处理完FD6和FD8的事件了。

结论:

  • ET模式避免了LT模式可能出现的惊群现象
  • ET模式最好结合非阻塞IO读取FD数据,相比LT会更加复杂一些

IO多路复用—Web服务流程

基于epoll模式的web服务的基本流程如图:

信号驱动IO

信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其他业务,无需阻塞等待。

当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出。

而且内核空间与用户空间的频繁信号交互性能也较低。

异步IO

异步IO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其他事情,内核等待数据就绪并拷贝到用户空间后才会递交信息,通知用户进程。

可以看到,异步IO模型中,用户进程在两个阶段都是非阻塞状态。

同步和异步

IO操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的IO操作),也就是阶段二是同步还是异步:

Redis网络模型

Redis为什么要选择单线程

Redis网络模型

Redis通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装,提供了统一的高性能事件API库AE:

  • ae.c文件中会根据系统环境选择需要的实现,不同的操作系统会选择不同的epoll实现方式
/* Include the best multiplexing layer supported by this system.
 * The following should be ordered by performances, descending. */
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    //linux系统
    #ifdef HAVE_EPOLL  
    #include "ae_epoll.c"
    #else
        #ifdef HAVE_KQUEUE
        //unix系统选用
        #include "ae_kqueue.c"
        #else
        //兜底
        #include "ae_select.c"
        #endif
    #endif
#endif

启动源码分析

//server.c
int main(int argc, char **argv) {
    ...
    //初始化服务
    initServer();
    ...
    //开始监听事件循环
    aeMain(server.el); 
   ...
}
  • initServer–初始化服务
void initServer(void){
  //...
  //内部会调用aeApiCreate(eventLoop),类似epoll_create
  server.el=aeCreateEventLoop(
      server.maxclients+CONFIG_FDSET_INCR
  );
  //...
  //监听TCP端口,创建ServerSocket,并得到FD
  listenToPort()
  //...
  //注册 连接处理器,内部会调用aeApiAddEvent(&server.ipdf)监听FD
  //处理客户端连接请求
  createSocketAcceptHandler(&server.ipfd,acceptTcpHandler)
  //注册 ae_api_poll的前置处理器
  aeSetBeforeSleepProc(server.el,beforeSleep); 
}

initServer会创建acceptTcpHandler监听客户端连接请求

  • aeMain开始监听事件循环
void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    //循环监听事件
    while (!eventLoop->stop) {
        aeProcessEvents(eventLoop, AE_ALL_EVENTS|
                                   AE_CALL_BEFORE_SLEEP|
                                   AE_CALL_AFTER_SLEEP);
    }
}

aeMain函数做的就是不断轮询,看是否有所监听的事件发生,如果有就进行处理。

  • aeProcessEvents处理事件
int aeProcessEvents(
aeEventLoop *eventLoop,
int flags){
...
//调用前置处理器beforeSleep
eventLoop->beforeSleep(eventLoop);
//等待FD就绪,类似epoll_wait
numevents=aeApiPoll(eventLoop,tvp)
for(j=0;j<numevents;j++){
 //遍历处理就绪的FD,调用对应的处理器
}
}

aeProcessEvents先进行一波前置处理,然后等待相关FD就绪后,判断该将这个FD交给哪个处理器进行处理

  • 处理客户端连接事件,调用acceptTcpHandler
//客户端读事件处理器
void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
 ...
 //接收socket连接,获取FD
 fd=accept(s,sa,len);
 ...
 //创建connection,关联fd
 connection *conn=connCreateSocket();
 conn.fd=fd;
 ...
 //内部调用aeApiAddEvent(fd,READABLE)
 //监听socket的FD读事件,并绑定到读处理器readQueryFromClient
 connSetReadHandler(conn,readQueryFromClient);
}

如果是客户端连接请求,会调用acceptTcpHandler处理器来处理该客户端连接的事件,并且该处理器内部会为该客户端连接注册一个FD读事件并绑定到读处理器上

  • acceptTcpHandler读事件处理器是如何处理客户端请求的呢?
//读处理器
void readQueryFromClient(connection *conn){
  //获取当前客户端,客户端宏有缓冲区用来读和写
  client *c=connGetPrivateData(conn);
  //获取c->querybuf缓冲区大小
  long int qblen=sdslen(c->querybuf);
   //读取请求数据到c->querybuf缓冲区
   connRead(c->conn,c->querybuf+qblen,readlen);
   ...
   //解析缓冲区字符串,转为Redis命令参数存入c->argv数组
   processInputBuffer(c);
   ...
   //处理c->agrv中的命令
   //当前客户端命令具体应该由哪个具体的命令对象来执行
   processCommand(c);
}

如果是客户端读取数据的FD事件发生,会调用readQueryFromClient方法

  • processCommand选择命令
int processCommand(client* c){
  ...
  //根据命令名称,寻找命令对应的command,例如L setCommand
  c->cmd=c->lastcmd=lookupCommand(c->argv[0]->ptr);
  ...
  //执行command,得到响应结果,例如Ping命令,对应pingCommand
  c->cmd->proc(c);
  //把指向结果写出,例如ping命令,就返回pong给client
  //shared.pong是字符串pong的sds对象
  addReply(c,shared.pong); 
}
  • addReply将响应结果添加到缓冲区中
void addReply(client* c,robj* obj){
   //尝试把结果写到c->buf客户端写缓冲区中
   if(_addReplyToBuffer(c,obj->ptr,sdslen(obj->ptr))!=C_OK){
   //如果c->buf写不下,则写到c->reply,这是一个链表,容量无上限
   _addReplyProtoToList(c,obj->ptr,sdslen(obj->ptr));
   }
   //将客户端添加到server.clients_pending_write这个队列,等待被写出
   listAddNodeHead(server.clients_pending_writer,c)
}
  • 每次有客户端事件发生时,都会先调用beforeSleep方法,这个方法负责去处理客户端写出队列
void beforeSleep(struct aeEventLoop * eventLoop){
   ...
   //定义迭代器,执行server.clients_pending_write->head
   listIter li;
   li->next=server.clients_pending_write->head;
   li->direction=AL_STATE_HEAD;
   //循环遍历待写出的client
   while((ln=listNext(&li))){
    //内部调用aeApiAddEvent(fd,WIRTEABLE),监听socket的FD读事件
    //并且绑定写处理器,sendReplyToClient,可以把响应写到客户端socket
    connSetWriterHandlerWithBarrier(c->conn,sendReplyToClient,ae_barrier)
   }
}

beforeSleep会为列表中的客户端绑定写处理器,然后该写处理负责监听客户端的写事件,如果可写就执行写出

图解

Redis单线程网络模型如下所示:

Redis 6.0版本中引入了多线程,目的是为了提高IO读写效率,因此在解析客户端命令和写响应结果时采用了多线程。核心的命令执行和IO多路复用模块依然是由主线程执行:

可以看出Redis的多路IO复用模型核心思想就是IO多路复用+事件派发,有事件发生了,通过事件派发器将其交给不同的处理器进行处理。

相关文章