Redis 网络通信模块源码分析

gdb不会用的先学下gdb

redis源码安装和编译



wget http://download.redis.io/releases/redis-2.8.17.tar.gz   //可以下载新的
tar zxvf redis-2.8.17.tar.gz
cd redis-2.8.17
make -j 4



编译成功后,会在 src 目录下生成多个可执行程序,其中 redis-server 和 redis-cli 是我们即将调试的程序。

进入 src 目录,使用 GDB 启动 redis-server 这个程序:




redis开源框架源码 redis源码剖析与实战_redis


以上是 redis-server 启动成功后的画面。

我们再开一个 session,再次进入 Redis 源码所在的 src 目录,然后使用 GDB 启动 Redis 客户端 redis-cli:


redis开源框架源码 redis源码剖析与实战_客户端_02


上是 redis-cli 启动成功后的画面。

通信示例


redis开源框架源码 redis源码剖析与实战_客户端_03


侦听 socket

我们知道网络通信在应用层上的大致流程如下:

  • 服务器端创建侦听 socket;
  • 将侦听 socket 绑定到需要的 IP 地址和端口上(调用 Socket API bind 函数);
  • 启动侦听(调用 socket API listen 函数);
  • 无限等待客户端连接到来,调用 Socket API accept 函数接受客户端连接,并产生一个与该客户端对应的客户端 socket;
  • 处理客户端 socket 上网络数据的收发,必要时关闭该 socket。

根据上面的流程,先来探究前三步的流程。由于 redis-server 默认对客户端的端口号是 6379,可以使用这个信息作为依据。

然后全局搜索一下 Redis 的代码,寻找调用了 bind() 函数的代码:


redis开源框架源码 redis源码剖析与实战_堆栈_04


static


gdb在这个函数上加个断点。


redis开源框架源码 redis源码剖析与实战_redis_05


中断的时候查看一下函数调用栈


redis开源框架源码 redis源码剖析与实战_堆栈_06


通过这个堆栈,结合堆栈 #2 的 6379 端口号可以确认这就是我们要找的逻辑,并且这个逻辑在主线程(因为从堆栈上看,最顶层堆栈是 main() 函数)中进行。

我们看下堆栈 #1 处的代码:


static


使用系统 API getaddrinfo 来解析得到当前主机的 IP 地址和端口信息。这里没有选择使用 gethostbyname 这个 API 是因为 gethostbyname 仅能用于解析 ipv4 相关的主机信息,而 getaddrinfo 既可以用于 ipv4 也可以用于 ipv6 ,这个函数的签名如下:


int


这个函数的具体用法可以在 Linux man 手册上查看。通常服务器端在调用 getaddrinfo 之前,将 hints 参数的 ai_flags 设置为 AI_PASSIVE,用于 bind;主机名 nodename 通常会设置为 NULL,返回通配地址 [::]。当然,客户端调用 getaddrinfo 时,hints 参数的 ai_flags 一般不设置 AI_PASSIVE,但是主机名 node 和服务名 service(更愿意称之为端口)则应该不为空。
解析完协议信息后,利用得到的协议信息创建侦听 socket,并开启该 socket 的 reuseAddr 选项。然后调用 anetListen 函数,在该函数中先 bind 后 listen。至此,redis-server 就可以在 6379 端口上接受客户端连接了。

接受客户端连接

同样的道理,要研究 redis-server 如何接受客户端连接,只要搜索 socket API accept 函数即可。

经定位,我们最终在 anet.c 文件中找到 anetGenericAccept 函数:


static


我们用 b 命令在这个函数处加个断点,然后重新运行 redis-server。一直到程序全部运行起来,GDB 都没有触发该断点,这时新打开一个 redis-cli,以模拟新客户端连接到 redis-server 上的行为。断点触发了,此时查看一下调用堆栈。


Breakpoint


分析这个调用堆栈,梳理一下这个调用流程。在 main 函数的 initServer 函数中创建侦听 socket、绑定地址然后开启侦听,接着调用 aeMain 函数启动一个循环不断地处理“事件”。


void


循环的退出条件是 eventLoop→stop 为 1。事件处理的代码如下:


int


这段代码先通过 flag 参数检查是否有事件需要处理。如果有定时器事件( AE_TIME_EVENTS 标志 ),则寻找最近要到期的定时器。


/* Search the first timer to fire.


这段代码有详细的注释,也非常好理解。注释告诉我们,由于这里的定时器集合是无序的,所以需要遍历一下这个链表,算法复杂度是 O(n) 。同时,注释中也“暗示”了我们将来 Redis 在这块的优化方向,即把这个链表按到期时间从小到大排序,这样链表的头部就是我们要的最近时间点的定时器对象,算法复杂度是 O(1) 。或者使用 Redis 中的 skiplist ,算法复杂度是 O(log(N)) 。

接着获取当前系统时间( aeGetTime(&now_sec, &now_ms); )将最早要到期的定时器时间减去当前系统时间获得一个间隔。这个时间间隔作为 numevents = aeApiPoll(eventLoop, tvp); 调用的参数,aeApiPoll() 在 Linux 平台上使用 epoll 技术,Redis 在这个 IO 复用技术上、在不同的操作系统平台上使用不同的系统函数,在 Windows 系统上使用 select,在 Mac 系统上使用 kqueue。这里重点看下 Linux 平台下的实现:


static


epoll_wait 这个函数的签名如下:


int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);


最后一个参数 timeout 的设置非常有讲究,如果传入进来的 tvp 是 NULL ,根据上文的分析,说明没有定时器事件,则将等待时间设置为 -1 ,这会让 epoll_wait 无限期地挂起来,直到有事件时才会被唤醒。挂起的好处就是不浪费 CPU 时间片。反之,将 timeout 设置成最近的定时器事件间隔,将 epoll_wait 的等待时间设置为最近的定时器事件来临的时间间隔,可以及时唤醒 epoll_wait ,这样程序流可以尽快处理这个到期的定时器事件(下文会介绍)。

对于 epoll_wait 这种系统调用,所有的 fd(对于网络通信,也叫 socket)信息包括侦听 fd 和普通客户端 fd 都记录在事件循环对象 aeEventLoop 的 apidata 字段中,当某个 fd 上有事件触发时,从 apidata 中找到该 fd,并把事件类型(mask 字段)一起记录到 aeEventLoop 的 fired 字段中去。我们先把这个流程介绍完,再介绍 epoll_wait 函数中使用的 epfd 是在何时何地创建的,侦听 fd、客户端 fd 是如何挂载到 epfd 上去的。

在得到了有事件的 fd 以后,接下来就要处理这些事件了。在主循环 aeProcessEvents 中从 aeEventLoop 对象的 fired 数组中取出上一步记录的 fd,然后根据事件类型(读事件和写事件)分别进行处理。


for


读事件字段 rfileProc 和写事件字段 wfileProc 都是函数指针,在程序早期设置好,这里直接调用就可以了。


typedef


15.3 epollfd 的创建

我们通过搜索关键字 epoll_create 在 ae_epoll.c 文件中找到 EPFD 的创建函数 aeApiCreate 。


static


使用 GDB 的 b 命令在这个函数上加个断点,然后使用 run 命令重新运行一下 redis-server,触发断点,使用 bt 命令查看此时的调用堆栈。发现 EPFD 也是在上文介绍的 initServer 函数中创建的。


(


在 aeCreateEventLoop 中不仅创建了 EPFD,也创建了整个事件循环需要的 aeEventLoop 对象,并把这个对象记录在 Redis 的一个全局变量的 el 字段中。这个全局变量叫 server,这是一个结构体类型。其定义如下:


//位于 server.c 文件中