You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

15 KiB

答疑2 | 第7~12讲课后思考题答案及常见问题答疑

你好,我是蒋德钧。

在今天的答疑中我除了会解答课程之前的思考题以外还会带你再进一步了解和数据结构设计、进程管理、高性能线程模型、IO复用、预定义宏等相关的开发知识。希望你能通过这节课的答疑进一步扩展你的知识面。

第7讲

问题作为有序索引Radix Tree也能提供范围查询那么与我们日常使用的B+树,以及第5讲中介绍的跳表相比你觉得Radix Tree有什么优势和不足吗

对于这道题,有不少同学比如@Kaito、@曾轼麟等同学都对Radix Tree、B+树和跳表做了对比,这里我就来总结一下。

Radix Tree的优势

  • 因为Radix Tree是前缀树所以当保存数据的key具有相同前缀时Radix Tree会在不同的key间共享这些前缀这样一来和B+树、跳表相比,就节省内存空间。
  • Radix Tree在查询单个key时其查询复杂度O(K)只和key的长度k有关和现存的总数据量无关。而B+树、跳表的查询复杂度和数据规模有关所以Radix Tree查询单个key的效率要高于B+树、跳表。
  • Radix Tree适合保存大量具有相同前缀的数据。比如一个典型场景就是Linux内核中的page cache使用了Radix Tree保存文件内部偏移位置和缓存页的对应关系其中树上的key就是文件中的偏移值。

Radix Tree的不足

  • 一般在实现Radix Tree时每个叶子节点就保存一个key它的范围查询性能没有B+树和跳表好。这是因为B+树它的叶子节点可以保存多个key而对于跳表来说它可以遍历有序链表。因此它们可以更快地支持范围查询。
  • Radix Tree的原理较为复杂实现复杂度要高于B+ 树和跳表。

第8讲

**问题:**Redis源码的main函数在调用initServer函数之前会执行如下的代码片段你知道这个代码片段的作用是什么吗

int main(int argc, char **argv) {
...
server.supervised = redisIsSupervised(server.supervised_mode);
int background = server.daemonize && !server.supervised;
if (background) daemonize();
...
}

这段代码的目的呢是先检查Redis是否设置成让upstart或systemd这样的系统管理工具来启停Redis。这是由redisIsSupervised函数来检查redis.conf配置文件中的supervised选项而supervised选项的可用配置值包括no、upstart、systemd、auto其中no就表示不用系统管理工具来启停Redis其他选项会用系统管理工具。

而如果Redis没有设置用系统管理工具同时又设置了使用守护进程方式对应配置项daemonize=yesserver.daemonize值为1那么这段代码就调用daemonize函数以守护进程的方式启动Redis。

第9讲

**问题:**在Redis事件驱动框架代码中分别使用了Linux系统上的select和epoll两种机制你知道为什么Redis没有使用poll这一机制吗

这道题呢主要是希望你对select和poll这两个IO多路复用机制有进一步的了解。课程的留言区中有不少同学也都回答正确了我在这里说下我的答案。

select机制的本质阻塞式监听存放文件描述符的集合当监测到有描述符就绪时select会结束监测返回就绪的描述符个数。而select机制的不足有两个一是它对单个进程能监听的描述符数量是有限制的默认是1024个二是即使select监测到有文件描述符就绪程序还是需要线性扫描描述符集合才能知道具体是哪些文件描述符就绪了。

而poll机制相比于select机制本质其实没有太大区别它只是把select机制中文件描述符数量的限制给取消了允许进程一次监听超过1024个描述符。**在线性扫描描述符集合获得就绪的具体描述符这个操作上poll并没有优化改进。**所以poll相比select改进比较有限。而且就像@可怜大灰狼、@Kaito等同学提到的select机制的兼容性好可以在Linux和Windows上使用。

也正是因为poll机制改进有限而且它对运行平台的支持度不及select所以Redis的事件驱动框架就没有使用poll机制。在Linux上事件驱动框架直接使用了epoll而在Windows上框架则使用的是select。

不过这里你也要注意的是Redis的ae.c文件实现了aeWait函数这个函数实际会使用poll机制来监测文件描述符。而aeWait函数会被rewriteAppendOnlyFile函数在aof.c文件中和migrateGetSocket函数在cluster.c文件中调用。@可怜大灰狼同学在回答思考题时,就提到了这一点。

此外,在解答这道题的时候,@Darren、@陌等同学还进一步回答了epoll机制的实现细节我在这里也简单总结下他们的答案分享给你。

当某进程调用epoll_create方法时Linux内核会创建一个eventpoll结构体这个结构体中包含了一个红黑树rbr和一个双链表rdlist如下所示

struct eventpoll{
    //红黑树的根节点树中存放着所有添加到epoll中需要监控的描述符
    struct rb_root rbr;
    //双链表中存放着已经就绪的描述符会通过epoll_wait返回给调用程序
    struct list_head rdlist;
    ...
}

epoll_create创建了eventpoll结构体后程序调用epoll_ctl函数添加要监听的文件描述符时这些描述符会被保存在红黑树上。
同时当有描述符上有事件发生时一个名为ep_poll_callback的函数会被调用。这个函数会把就绪的描述符添加到rdllist链表中。而epoll_wait函数会检查rdlist链表中是否有描述符添加进来。如果rdlist链表不为空那么epoll_wait函数就会把就绪的描述符返回给调用程序了。

第10讲

问题这节课我们学习了Reactor模型除了Redis你还了解什么软件系统使用了Reactor模型吗

对于这道题不少同学都给出了使用Reactor模型的其他软件系统比如@Darren、@Kaito、@曾轼麟、@结冰的水滴等同学。那么使用Reator模型的常见软件系统实际上还包括Netty、Nginx、Memcached、Kafka等等。

在解答这道题的时候,我看到@Darren同学做了很好的扩展回答了Reactor模型的三种类型。在这里我也总结下分享给你。

  • 类型一单reactor单线程

在这个类型中Reactor模型中的reactor、acceptor和handler的功能都是由一个线程来执行的。reactor负责监听客户端事件一旦有连接事件发生它会分发给acceptor由acceptor负责建立连接然后创建一个handler负责处理连接建立后的事件。如果是有非连接的读写事件发生reactor将事件分发给handler进行处理。handler负责读取客户端请求进行业务处理并最终给客户端返回结果。Redis就是典型的单reactor单线程类型。

  • 类型二单reactor多线程

在这个类型中reactor、acceptor和handler的功能由一个线程来执行与此同时会有一个线程池由若干worker线程组成。在监听客户端事件、连接事件处理方面这个类型和单rector单线程是相同的但是不同之处在于在单reactor多线程类型中handler只负责读取请求和写回结果而具体的业务处理由worker线程来完成。

  • 类型三:主-从Reactor多线程

在这个类型中会有一个主reactor线程、多个子reactor线程和多个worker线程组成的一个线程池。其中主reactor负责监听客户端事件并在同一个线程中让acceptor处理连接事件。一旦连接建立后主reactor会把连接分发给子reactor线程由子reactor负责这个连接上的后续事件处理。

那么子reactor会监听客户端连接上的后续事件有读写事件发生时它会让在同一个线程中的handler读取请求和返回结果而和单reactor多线程类似具体业务处理它还是会让线程池中的worker线程处理。刚才介绍的Netty使用的就是这个类型。

我在下面画了三张图,展示了刚才介绍的三个类型的区别,你可以再整体回顾下。

图片图片图片

第11讲

问题已知Redis事件驱动框架的aeApiCreate、aeApiAddEvent等等这些函数是对操作系统提供的IO多路复用函数进行了封装具体的IO多路复用函数分别是在ae_epoll.cae_evport.cae_kqueue.cae_select.c四个代码文件中定义的。那么你知道Redis在调用aeApiCreate、aeApiAddEvent这些函数时是根据什么条件来决定具体调用哪个文件中的IO多路复用函数的吗

其实这道题的目的主要是希望你能通过它进一步了解如何进行跨平台的编程开发。在实际业务场景中我们开发的系统可能需要在不同的平台上运行比如Linux和Windows。那么我们在开发时就需要用同一套代码支持在不同平台上的执行。

就像Redis中使用的IO多路复用机制一样不同平台上支持的IO多路复用函数是不一样的。但是使用这些函数的事件驱动整体框架又可以用一套框架来实现。所以我们就需要在同一套代码中区分底层平台从而可以正确地使用该平台对应函数。

对应Redis事件驱动框架来说它是用aeApiCreate、aeApiAddEven等函数封装了不同的IO多路复用函数而在ae.c文件的开头部分它使用了#ifdef、#else、#endif等条件编译指令来区分封装的函数应该具体使用哪种IO多路复用函数。

下面的代码就展示了刚才介绍的条件编译。

#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    #ifdef HAVE_EPOLL
    #include "ae_epoll.c"
    #else
        #ifdef HAVE_KQUEUE
        #include "ae_kqueue.c"
        #else
        #include "ae_select.c"
        #endif
    #endif
#endif

从这段代码中我们可以看到,如果 HAVE_EPOLL 宏被定义了那么代码就会包含ae_epoll.c文件这也就是说aeApiCreate、aeApiAddEven、aeApiPoll这些函数就会调用epoll_create、epoll_ctl、epoll_wait这些机制。

类似的,如果 HAVE_KQUEUE 宏被定义了那么代码就会包含ae_kqueue.c文件框架函数也会实际调用kqueue的机制。

那么,接下来的一个问题就是,HAVE_EPOLLHAVE_KQUEUE 这些宏又是在哪里被定义的呢?

其实它们是在config.h文件中定义的。

在config.h文件中代码会判断是否定义了__linux__宏,如果有的话,那么,代码就会定义 HAVE_EPOLL 宏。而如果定义了__FreeBSD____OpenBSD__等宏,那么代码就会定义 HAVE_KQUEUE 宏。

下面的代码展示了config.h中的这部分逻辑你可以看下。

#ifdef __linux__
#define HAVE_EPOLL 1
#endif
 
#if (defined(__APPLE__) && defined(MAC_OS_X_VERSION_10_6)) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined (__NetBSD__)
#define HAVE_KQUEUE 1
#endif

好了到这里我们就知道了Redis源码中是根据__linux____FreeBSD____OpenBSD__这些宏来决定当前的运行平台是哪个平台然后再设置相应的IO多路复用函数的宏。__linux____FreeBSD____OpenBSD__这些宏,又是如何定义的呢?

其实,这就和运行平台上的编译器有关了。编译器会根据所运行的平台提供预定义宏。像刚才的__linux____FreeBSD__这些都是属于预定义宏这些预定义宏的名称都是以“__”两条下划线开头和结尾的。你在Linux的系统中比如CentOS或者Ubuntu运行如下所示的gcc命令你就可以看到Linux中运行的gcc编译器已经提供了__linux__这个预定义宏了。

gcc -dM -E -x c /dev/null | grep linux

#define __linux 1
#define __linux__ 1
#define __gnu_linux_x 1
#define linux 1

而如果你在macOS的系统中运行如下所示的gcc命令你也能看到macOS中运行的gcc编译器已经提供了__APPLE__预定义宏。

gcc -dM -E -x c /dev/null | grep APPLE
#define __APPLE__ 1

这样一来当我们在某个系统平台上使用gcc编译Redis源码时就可以根据编译器提供的预定义宏来决定当前究竟该使用哪个IO多路复用函数了。而此时使用的IO多路复用函数也是和Redis当前运行的平台是匹配的。

第12讲

问题Redis后台任务使用了bio_job结构体来描述该结构体用了三个指针变量来表示任务参数如下所示。那么如果你创建的任务所需要的参数大于3个你有什么应对方法来传参吗

struct bio_job {
    time_t time;
    void *arg1, *arg2, *arg3;  //传递给任务的参数
};

这道题其实是需要你了解在C函数开发时如果想传递很多参数该如何处理。

其实,这里我们可以充分利用函数参数中的指针,让指针指向一个结构体,比如数组或哈希表。而数组或哈希表这样的结构体中,就可以保存很多参数了。这样一来,我们就可以通过指针指向结构体来传递多个参数了。

不过,你要注意的是,在函数使用参数时,还需要解析指针指向的结构体,这个会产生一些开销。

小结

这节课我们解答了第7讲到第12讲的课后思考题。在设计这些思考题时有些题我希望你能通过它们了解一些C语言编程开发的技巧比如使用编译器提供的预定义宏实现跨平台的开发或者是通过指针给C函数传递批量参数等。而有些题我是希望你能对计算机系统的一些关键机制设计有更多的了解比如IO多路复用机制的对比。

其实在回答这些思考题的时候你有没有感受到Redis就像一个小宝藏一样我们可以从中学到从编程开发、到系统管理、再到系统设计等多方面的知识。希望你能在阅读Redis源码的道路上充分挖掘这个宝藏充实自己的知识财富。