2 min read

Redis的网络模型

服务端程序几乎都会有网络交互的功能,一个优秀网络模型可以合理配合使用计算机的各资源。

Redis作为广为人知的内存数据库,从玩具级项目到工业级项目中都可以看到它的身影,而Redis在最初的几个版本中一直是单线程,却能扛住1 million requests per second的请求量(非单点)。其实现的单线程网络模型必然十分优秀。

redis-network-model-share

设计原理

在分析网络模型之前先分析一下Redis中网络交互的场景。一般来说我们在使用Redis时,一般会和Redis-Server建若干个连接,然后并发的给Redis-Server发送指令并得到回复。而Redis-Server就需要同时维护若干个与Redis-Client的连接,并且随时处理每个连接发来的请求。

redis-network-model-connecttion-per-thread
一种方式是起一个线程监听一个端口,当新连接到来时,创建一个新线程处理这个连接。这样做的缺点是,当连接过多时线程数也随之增多,线程栈大小一般8MB,大量的线程会占用大量内存和CPU资源。

redis-network-model-connecttion-worker-pool
另一种方式是起一个线程监听端口,新连接交给线程池来处理,这样做的优点是连接数不再会压垮计算机,而缺点就是服务器的处理能力受限与线程池的大小,并且空闲连接也会占用线程池的资源。

上边两种网络模型的问题就在于一个线程只处理一个连接,而操作系统提供的IO多路复用技术可以解决这一问题。一个线程监听多个连接,每个连接只有在活跃时才会使用CPU,从而达到节省资源的目录。

Redis采用Reactor模式实现的网络模型。主要由事件收集器、事件发送器、事件处理器组成。事件收集器主要收集所有事件,包括来自硬件软件的事件。事件发送器负责将事件发送到实现注册的事件处理器。而事件处理器则负责处理事件。其中事件收集器就是通过IO多路复用技术来实现的。

redis-network-model

数据结构

redis-network-model-data-structure

结构体aeEventLoop封装了事件循环相关的变量,包括两种事件的链表(时间事件、文件事件)。然后文件事件(aeFileEvent)中封装了读写事件接口充当事件处理器,时间事件(aeTimeEvent)中也封装了相应接口作为事件处理器。

事件

默认有两种事件:文件事件, 时间事件

  1. 文件事件对应文件的I/O事件,例如socket可读可写事件。
  2. 时间事件对应定时任务,例如Redis的定时清理等。

首先来看一下文件事件的封装。

/* File event structure */
typedef struct aeFileEvent {
    int mask; /* one of AE_(READABLE|WRITABLE|BARRIER) */
    aeFileProc *rfileProc;
    aeFileProc *wfileProc;
    void *clientData;
} aeFileEvent;

包含了一个标志位maskread事件write事件的处理器。如果文件事件对应的是客户端的话clientData就储存了对应connection接口

时间事件就比较复杂,redis没有采用Time FD来实现定时任务,采用事件循环的timeout来辅助实现的。

/* Time event structure */
typedef struct aeTimeEvent {
    long long id; /* time event identifier. */
    monotime when;
    aeTimeProc *timeProc;
    aeEventFinalizerProc *finalizerProc;
    void *clientData;
    struct aeTimeEvent *prev;
    struct aeTimeEvent *next;
    int refcount; /* refcount to prevent timer events from being
           * freed in recursive time event calls. */
} aeTimeEvent;

其中id每个时间事件的ID。when为事件发生的时间戳(毫秒)。timeProc为事件发生时处理器。finalizerProc为事件终止处理器,时间事件被删除时触发。prevnext为时间事件链表的指针,所有的时间事件都在一个链表中。refcount为事件引用数。

事件循环

首先最核心的数据结构就是aeEventLoop,它封装了redis-server的事件循环,充当了事件收集器和事件发送器的作用。

/* State of an event based program */
typedef struct aeEventLoop {
    int maxfd;   /* highest file descriptor currently registered */
    int setsize; /* max number of file descriptors tracked */
    long long timeEventNextId;
    aeFileEvent *events; /* Registered events */
    aeFiredEvent *fired; /* Fired events */
    aeTimeEvent *timeEventHead;
    int stop;
    void *apidata; /* This is used for polling API specific data */
    aeBeforeSleepProc *beforesleep;
    aeBeforeSleepProc *aftersleep;
    int flags;
} aeEventLoop;

其中maxfd字段为当前监听fd的最大值。setsize为最多监听事件的数量,一般为最大连接数加上一部分冗余。timeEventNextId为下一个时间事件的id。stop为停止标志。beforesleepaftersleep为事件循环之前和之后触发的函数。flags用于存各种标记。 最后再看一下几个主要的字段:

  • events为所有注册的文件事件(最大长度为setsize)。
  • fired为已触发的文件事件。
  • timeEventHead用于存时间事件。
  • apidata多路复用的接口,根据平台的不同其实现可能是evport/epoll/kqueue/select等。

主要数据结构介绍完了,下面再来通过客户端和服务端一次交互来分析网络模型的工作过程。

Redis初始化时,首先调用adjustOpenFilesLimit函数根据配置文件中的最大连接数修改进程最大文件打开数。然后调用aeCreateEventLoop创建aeEventLoop结构维护事件循环。

根据配置文件监听端口之后,会调用createSocketAcceptHandlerListen FD封装成文件事件加入aeEventLoop

此时服务端准备工作基本完成了,端口监听了,Listen FDaccept动作也监听了。

然后就会调用aeMain进入事件循环了。

redis-network-model-eventloop

aeMain函数中是一个循环,不断判断是否停止,不停止就执行aeProcessEvents函数。

aeProcessEvents中:

  1. 计算最近一个时间事件距离现在的时间差和已触发时间事件。
  2. 调用aeApiPoll接口(对应底层封装的select/poll/epoll_wait函数)。
  3. 文件事件来临时执行实现注册的读写处理器。
  4. 执行已触发的时间事件(如果有)。

此时如果客户端连接到Redis的话,会触发初始化时注册的Listen FDaccept事件,对应处理器为acceptTcpHandler,这个函数主要是调用anetTcpAccept接口(对应各平台的accept函数)获取Conn FD。得到Conn FD之后调用acceptCommonHandler处理这个连接,参数为connCreateAcceptedSocket函数根据Conn FD创建的连接对象(connection)。

connCreateAcceptedSocket首先根据connection调用createClient创建一个client对象。

createClient中首先会调用connSetReadHandler执行conn->type->set_read_handler接口,如果是TCP连接的话对应CT_Socketset_read_handler接口,也就是connSocketSetReadHandler设置读处理器并且将Conn FD封装成文件事件加入aeEventLoop。最后将ConnectionClient关联起来。

然后调用clientAcceptHandler函数处理一些客户端需要做的事情。

到此为止,监听动作可以处理了,客户端发来的数据(读事件)也可以处理了。

总结

上面分析的网络模型在Redis中都是在单线程中实现的,所有事件执行也是串行的,这也是很多人使用Redis实现分布式锁而不用考虑并发原因了。Redis采用单线程实现网络模型也能扛住大量请求,一方面是网络模型足够优秀,另一方面就是所有操作都在内存中,单事物处理时间短,并且Redis数据库中数据结构实现优化到了极致,比如同种数据结构根据数据量大小选择不同底层实现,通用回复字符串共享,秒级时间戳缓存等等。

事件驱动实现并非只能单线程实现,Redis之所以使用单线程实现一方面是为了方便开发者,另一方面是Redis的瓶颈并不在网络请求。而多线程实现的代表就是Nginx了。

redis-network-model-nginx

nginx实现时,Master监听,将连接分发给若干个Worker线程处理,每个Worker线程有自己的事件循环。为了避免调度对网络响应的损耗,nginx会调用SCHED_SETAFFINITY将每个Worker分散绑定到不同CPU上。

redis-network-model-golang

而以高并发著称的golang语言却不适合采用事件驱动编程。golang标准库中的网络模型都是connection-per-goroutine,这样做的原因是go无法将goroutine固定到指定P上,如果采用事件驱动模型,最坏的情况下所有Worker被调度到同一个P上,就变成单线程模型了。从上面golang事件驱动模型的尖刺非常明显(蓝色为 netpoll + 多路复用,绿色为 netpoll + 长连接,黄色为 net 库 + 长连接)。