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 代码,不要觉得难,其实你发现比读你同事的垃圾代码 容易多了。。。。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容