Redis 源码阅读 ——— 网络模块

Redis 源码阅读 ——— 网络模块

概述

redis 是cs架构,网络采用epoll 模型,单线程处理每个请求。
很多同学对单线程有些疑问,简单的解释一下 redis 单线程的意思,redis 服务端虽说是单线程,但是可以同时 持有很多connection,每个connection 都可以同时发请求,只不过在 redis 服务端,一个一个的处理每个connection 发过来的request, 通俗点说就是,很多请求都能发过来,redis 会存下来(其实是存在每个connection socket 内核缓冲区),一个一个处理。

为什么单线程处理效率如此之高?

  1. 几乎所有的操作全部是内存操作,内存操作非常快(如果有一些系统调用,磁盘操作,单线程不会快的)
  2. 单线程避免了使用锁(memcache 使用了多线程,因为多了锁之类的,也没比redis快多少)

EPOLL 介绍

如果想读懂 redis 网络相关的代码,必须先搞清楚 epoll 的使用,epoll 说白了就是监听 fd(file descriptor,操作 fd 其实就是操作socket),每当 fd 上面有消息的时候(比如 可读,可写 消息等),就会得到通知,这样就可以处理了。epoll 主要好处是可以同时监听多个 fd(可以持有多个 client 连接),epoll 只有在 持有很多连接,并且每个连接都不是特别活跃的时候 效率才高,其他的情况,不见得比 poll,select 高。

epoll 使用只需要三步:

  1. int epoll_create(int size);
    创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽
  2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create()的返回值,第二个参数表示动作,用三个宏来表示:
    • EPOLL_CTL_ADD:注册新的fd到epfd中;

    • EPOLL_CTL_MOD:修改已经注册的fd的监听事件

    • EPOLL_CTL_DEL:从epfd中删除一个fd;
      第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:
      typedef union epoll_data {
      void *ptr;
      int fd;
      __uint32_t u32;
      __uint64_t u64;
      } epoll_data_t;

      struct epoll_event {
      __uint32_t events; /* Epoll events /
      epoll_data_t data; /
      User data variable */
      };
      events可以是以下几个宏的集合:
      EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
      EPOLLOUT:表示对应的文件描述符可以写; EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来); EPOLLERR:表示对应的文件描述符发生错误; EPOLLHUP:表示对应的文件描述符被挂断;
      EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
      EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

  3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

Redis 中 epoll 的使用

Redis epoll 封装介绍

redis 跟 网络相关的代码写的比较简洁,主要就两处

  1. 不同操作系统的 epoll 代码,都在 ae_epoll.c ae_evport.c ae_kqueue.c ae_select.c 中, linux 使用 ae_epoll.c , mac 使用 ae_kqueue.c
  2. 对 epoll 代码的封装在 ae.c 中
    • aeCreateEventLoop 是对 epoll_create 的封装
    • aeCreateFileEvent 是对 epoll_ctl 的封装,同时会将rfileProc, wfileProc 两个处理消息的回调函数一起封装
    • aeProcessEvents 是对 epoll_wait 的封装
    • aeMain 是一个死循环,不停的调用 aeProcessEvents, redis 就是在这里不停的收到 client 的 request, 并且一个一个处理

aeCreateEventLoop:

创建 aeEventLoop 结构体

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

/* 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;
    time_t lastTime;     /* Used to detect system clock skew */
    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;
} aeEventLoop;

这个结构体中主要就是 events, fired 两个aeFileEvent类型变量,aeFileEvent 中 rfileProc, wfileProc 是两个回调函数, 分别处理读时间, 写时间, events 是aeCreateFileEvent 函数调用时 为其赋值,fird 是 监听到有消息来的时候 为其赋值,在 ae_epoll.c 中 aeApiPoll 函数。

总结一下, 服务启动 aeCreateEventLoop 创建 aeEventLoop 类型的变量, 将需要监控的 fd, 通过 aeCreateFileEvent 监听(同时将 赋值 rfileProc, wfileProc 回调函数), aeProcessEvents 监听到有消息需要处理的时候, 会使用 rfileProc, wfileProc 回调函数处理消息。所以,读 Redis 网络相关代码 ,其实只是看 aeCreateFileEvent(监听fd,设置对fd的回调函数) 在哪些地方被调用就可以了。

Redis 关键代码

  1. initServer(server.c )无关代码删除:

    void initServer(void) {
      server.el = aeCreateEventLoop(server.maxclients+CONFIG_FDSET_INCR);  
      listenToPort(server.port,server.ipfd,&server.ipfd_count);
      for (j = 0; j < server.ipfd_count; j++) {
      if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,
                acceptTcpHandler,NULL) == AE_ERR)
                {
                    serverPanic(
                        "Unrecoverable error creating server.ipfd file event.");
                }
        }
        
        这段代码在默认开启redis-server 的情况下,server.ipfd 代表的fd 是 6379 打开的socket, 在6379监听到的消息,都调用 acceptTcpHandler 函数
    
  2. acceptTcpHandler(networking.c)无关代码删除
    void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
    while(max--) {
    cfd = anetTcpAccept(server.neterr, fd, cip, sizeof(cip), &cport);
    acceptCommonHandler(cfd,0,cip);
    }
    }
    static void acceptCommonHandler(int fd, int flags, char *ip) {
    client *c;
    c = createClient(fd)
    }
    client *createClient(int fd) {
    aeCreateFileEvent(server.el,fd,AE_READABLE,
    readQueryFromClient, c) == AE_ERR)
    }
    这段代码很清晰的表明了, 对于6379 过来的请求,全部 使用acceptTcpHandler 函数生成一个新的fd, 在同时将这个fd 放在 eventloop 中监听,并且 使用 readQueryFromClient 来处理

  3. readQueryFromClient(networking.c) 无关代码删除
    void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {
    nread = read(fd, c->querybuf+qblen, readlen);
    processInputBuffer(c);
    }
    readQueryFromClient 就是把请求内容读出来, 在调用 processInputBuffer 处理, processInputBuffer 就是 redis 里面各种业务逻辑了,不在介绍

Epoll 总结

redis 网络相关代码其实就是一句话 使用 epoll 处理每一个请求,也没什么好学习的。。。。。。。

Redis 如何处理TCP 粘包,拆包

粘包,拆包介绍

tcp是面向流的, 所以tcp对数据内容毫无感知,收到就放在缓冲区里面等待用户读取,所以从server端读出来的数据,可能是按照发送顺序(tcp 保证不乱不丢)的任何内容, 这样 server 端如果无法识别出来一个完整的数据就出错了。解决办法有两种

  • 特定分隔符,比如 http 的一个 request 是以 \r\n\r\n 结尾的,在服务端就可以一直读到这个特定的 \r\n\r\n ,通过这种方式可以区分出来一个 request 的数据
  • 指定长度, 比如在 前4个字节中 存放这条消息的长度,这样就知道就可以通过 read 函数正确的读出数据。

特定字符的办法优势是,不用浪费空间存长度,但是现在的计算机环境通常可以忽略这个浪费,劣势是 每个字符都需要判断才能保证正确。效率低。

指定长度的办法是浪费空间存长度,但是效率高,所以基本上可以说任何时候都采用第二种方法

Redis 如何处理

set a 1 这条指令,按照 redis 协议,会翻译成

*3
$3
set
$1
a
$1
1

*3 表示有3行数据, $3 表示 有3个字符

属于哪种方法读者自己感受下。

Reids 的聪明之处

在我没读redis代码的时候,我一直认为 从缓冲区 读出来自己需要的长度,处理好以后,在从缓冲区里继续读,看了redis 代码以后,我才发现自己 too yong, redis 是这样做的(networking.c readQueryFromClient 函数, 代码有删减)

    readlen = PROTO_IOBUF_LEN;
    qblen = sdslen(c->querybuf);
    if (c->querybuf_peak < qblen) c->querybuf_peak = qblen;
    c->querybuf = sdsMakeRoomFor(c->querybuf, readlen);
    nread = read(fd, c->querybuf+qblen, readlen);

define PROTO_IOBUF_LEN (102416) / Generic I/O buffer size */

redis 每次都读缓冲区的大小,如果最后一条消息不完整,下次计算一下长度,继续读,因为这个骚操作(知道了其实就是常规操作),让效率大大提升了,不然每次使用 read 系统调用,非常影响性能,特别对于 redis 这种单线程模型程序影响就更大了。

Redis如何处理 half connection

half connection 介绍

client -> server, 虽然我们都说connection,其实 就是client 开着一个 fd, server 开着一个 fd,两个fd之间可以互相通信,关闭的时候 一个 fd 跟另外一个 fd 说我准备关闭了(tcp 四次挥手), 不过如果有的极端情况(在大规模server端是常规情况),比如拔网线,关机,网络异常等原因(具体我也没试验过), 可能发不出任何消息 就断了,另外一个 fd 就在那里傻傻的等着,这就出现了 half connection 的情况。

如何处理 half connection

  • 一般的处理办法就是心跳检查,服务端会 定时的 ping 客户端,如果连续几次 都 ping 不通,那么就会主动断开链接

为什么不使用 keep_alive 处理

Host Requirements RFC罗列有不使用它的三个理由:

  • 在短暂的故障期间,它们可能引起一个良好连接(good connection)被释放(dropped)
  • 它们消费了不必要的宽带
  • 在以数据包计费的互联网上它们(额外)花费金钱。然而,在许多的实现中提供了存活定时器。

这种说法有它的道理,但是并不能说服我不使用 keep alive,最能说服我的是在知乎上看过的一句话, keep_alive 只能保证 tcp 是正常的,但是不能保证 用户程序是正常的,自己感受一下这些话。

Redis 如何处理

server.c clientsCronHandleTimeout 函数

    if (server.maxidletime &&
        !(c->flags & CLIENT_SLAVE) &&    /* no timeout for slaves */
        !(c->flags & CLIENT_MASTER) &&   /* no timeout for masters */
        !(c->flags & CLIENT_BLOCKED) &&  /* no timeout for BLPOP */
        !(c->flags & CLIENT_PUBSUB) &&   /* no timeout for Pub/Sub clients */
        (now - c->lastinteraction > server.maxidletime))
    {
        serverLog(LL_VERBOSE,"Closing idle client");
        freeClient(c);
        return 1;
    } else if (c->flags & CLIENT_BLOCKED) {

......

通过代码可以看出来,redis 根本就既没有用 keep_alive , 也没有用 ping, 而是简单粗暴的通过 client 最后一次访问server 的时间 条件来判断,不管 这条连接是不是正常的,这样同样可以解决 half connection 问题。

我知道 redis 肯定要处理 half connection 的问题,所以我开始找 ping 相关代码,但是没找到,后来就老老实实从定时相关代码里面看,才找到。

第一反应,觉得比较奇怪,为什么好的连接也给断开了呢,是不是redis比较傻,能不能给他提个优雅处理的pr, 后来仔细想想,果然还是我自己 too yong, 作为服务端,连接资源还是比较宝贵的,如果长时间不访问服务端断开本来就是很合理的,而且如果用我开始觉得优雅的心跳,简直就是灾难,因为 redis 是单线程的,心跳都是一次网络交互。。。。

我看到过很多写心跳处理 half connection 的代码,原来一直觉得这就是最好的方法,学习了 redis 我才发现别有洞天,而且我仔细思考了下,感觉大部分时候 redis 这种处理方法更科学。

感悟

都说 redis 代码简洁,易读,不过没想到竟简洁如斯!!! tcp 粘包 拆包的处理, half connection 的处理,都让我有一种别开生面的感觉。其实文章里只写了有代表性的东西,时间有限无法一一列举,代码组织,架构都让我觉得提升不少, 读 redis 真的是一种享受,建议看过这篇文章的朋友都看一看 redis 代码,不要觉得难,其实你发现比读你同事的垃圾代码 容易多了。。。。

你可能感兴趣的:(Redis 源码阅读 ——— 网络模块)