你好,我是蒋德钧。

今天这节课,我们继续来解答第13讲到第18讲的课后思考题。这些思考题除了涉及Redis自身的开发与实现机制外,还包含了多线程模型使用、系统调用优化等通用的开发知识,希望你能掌握这些扩展的通用知识,并把它们用在自己的开发工作中。

第13讲

问题:Redis 多IO线程机制使用startThreadedIO函数和stopThreadedIO函数,来设置IO线程激活标识io_threads_active为1和为0。此处,这两个函数还会对线程互斥锁数组进行解锁和加锁操作,如下所示。那么,你知道为什么这两个函数要执行解锁和加锁操作吗?

void startThreadedIO(void) {
    ...
    for (int j = 1; j < server.io_threads_num; j++)
        pthread_mutex_unlock(&io_threads_mutex[j]);  //给互斥锁数组中每个线程对应的互斥锁做解锁操作
    server.io_threads_active = 1;
}
 
void stopThreadedIO(void) {
    ...
    for (int j = 1; j < server.io_threads_num; j++)
        pthread_mutex_lock(&io_threads_mutex[j]);  //给互斥锁数组中每个线程对应的互斥锁做加锁操作
    server.io_threads_active = 0;
}

我设计这道题的目的,主要是希望你可以了解多线程运行时,如何通过互斥锁来控制线程运行状态的变化。这里我们就来看下线程在运行过程中,是如何使用互斥锁的。通过了解这个过程,你就能知道题目中提到的解锁和加锁操作的目的了。

首先,在初始化和启动多IO线程的initThreadedIO函数中,主线程会先获取每个IO线程对应的互斥锁。然后,主线程会创建IO线程。当每个IO线程启动后,就会运行IOThreadMain函数,如下所示:

void initThreadedIO(void) {
   …
   for (int i = 0; i < server.io_threads_num; i++) {
	…
	pthread_mutex_init(&io_threads_mutex[i],NULL);
	 io_threads_pending[i] = 0;
	 pthread_mutex_lock(&io_threads_mutex[i]); //主线程获取每个IO线程的互斥锁
	 if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) {…} //启动IO线程,线程运行IOThreadMain函数
	…} …}

而IOThreadMain函数会一直执行一个while(1)的循环流程。在这个流程中,线程又会先执行一个100万次的循环,而在这个循环中,线程会一直检查有没有待处理的任务,这些任务的数量是用io_threads_pending数组保存的。
在这个100万次的循环中,一旦线程检查到有待处理任务,也就是io_threads_pending数组中和当前线程对应的元素值不为0,那么线程就会跳出这个循环,并根据任务类型进行实际的处理。

下面的代码展示了这部分的逻辑,你可以看下。

void *IOThreadMain(void *myid) {
 …
  while(1) {
       //循环100万次,每次检查有没有待处理的任务
        for (int j = 0; j < 1000000; j++) {
            if (io_threads_pending[id] != 0) break;  //如果有任务就跳出循环
        }
        … //从io_threads_lis中取出待处理任务,根据任务类型,调用相应函数进行处理
        }
…}

而如果线程执行了100万次循环后,仍然没有任务处理。那么,它就会调用pthread_mutex_lock函数去获取它对应的互斥锁。但是,就像我刚才给你介绍的,在initThreadedIO函数中,主线程已经获得了IO线程的互斥锁了。所以,在IOThreadedMain函数中,线程会因为无法获得互斥锁,而进入等待状态。此时,线程不会消耗CPU。

与此同时,主线程在进入事件驱动框架的循环前,会调用beforeSleep函数,在这个函数中,主线程会进一步调用handleClientsWithPendingWritesUsingThreads函数,来给IO线程分配待写客户端。

那么,在handleClientsWithPendingWritesUsingThreads函数中,如果主线程发现IO线程没有被激活的话,它就会调用startThreadedIO函数

好了,到这里,startThreadedIO函数就开始执行了。这个函数中会依次调用pthread_mutex_unlock函数,给每个线程对应的锁进行解锁操作。这里,你需要注意的是,startThreadedIO是在主线程中执行的,而每个IO线程的互斥锁也是在IO线程初始化时,由主线程获取的。

所以,主线程可以调用pthread_mutex_unlock函数来释放每个线程的互斥锁。

一旦主线程释放了线程的互斥锁,那么IO线程执行的IOThreadedMain函数,就能获得对应的互斥锁。紧接着,IOThreadedMain函数又会释放释放互斥锁,并继续执行while(1),如下所示:

void *IOThreadMain(void *myid) {
 …
  while(1) {
     …
     if (io_threads_pending[id] == 0) {
            pthread_mutex_lock(&io_threads_mutex[id]);  //获得互斥锁
            pthread_mutex_unlock(&io_threads_mutex[id]); //释放互斥锁
            continue;
     }
   …} …}

那么,这里就是解答第13讲课后思考题的关键所在了。
在IO线程释放了互斥锁后,主线程可能正好在执行handleClientsWithPendingWritesUsingThreads函数,这个函数中除了刚才介绍的,会根据IO线程是否激活来启动IO线程之外,它也会调用stopThreadedIOIfNeeded函数,来判断是否需要暂停IO线程

stopThreadedIOIfNeeded函数一旦发现待处理任务数,不足IO线程数的2倍,它就会调用stopThreadedIO函数来暂停IO线程。

而暂停IO线程的办法,就是让主线程获得线程的互斥锁。所以,stopThreadedIO函数就会依次调用pthread_mutex_lock函数,来获取每个IO线程对应的互斥锁。刚才我们介绍的IOThreadedMain函数在获得互斥锁后,紧接着就释放互斥锁,其实就是希望主线程执行的stopThreadedIO函数,能在IO线程释放锁后的这个时机中,获得线程的互斥锁。

这样一来,因为IO线程执行IOThreadedMain函数时,会一直运行while(1)循环,并且一旦判断当前待处理任务为0时,它会去获取互斥锁。而此时,如果主线程已经拿到锁了,那么IO线程就只能进入等待状态了,这就相当于暂停了IO线程。

这里,你还需要注意的一点是,stopThreadedIO函数还会把表示当前IO线程激活的标记io_threads_active设为0,这样一来,主线程的handleClientsWithPendingWritesUsingThreads函数在执行时,又会根据这个标记来再次调用startThreadedIO启用IO线程。而就像刚才我们提到的,startThreadedIO函数会释放主线程拿的锁,让IO线程从等待状态进入运行状态。

关于这道题,不少同学都提到了,题目中所说的加解锁操作是为了控制IO线程的启停,而且像是@土豆种南城同学,还特别强调了IOThreadedMain函数中执行的100万次循环的作用。

因为这个题目涉及的锁操作在好几个函数间轮流执行,所以,我刚才也是把这个过程的整体流程给你做了解释。下面我也画了一张图,展示了主线程通过加解锁控制IO线程启停的基本过程,你可以再整体回顾下。

图片

第14讲

问题:如果我们将命令处理过程中的命令执行也交给多IO线程执行,你觉得除了对原子性有影响,会有什么好处或其他不足的影响吗?

这道题主要是希望你能对多线程执行模型的优势和不足,有进一步的思考。

其实,使用多IO线程执行命令的好处很直接,就是可以充分利用CPU的多核资源,让每个核上的IO线程并行处理命令,从而提升整体的吞吐率。

但是,这里你要注意的是,如果多个命令执行时要对同一个数据结构进行写操作,那么,此时也就是多个线程要并发写某个数据结构。为了保证操作正确性,我们就需要使用互斥方法,比如加锁,来提供并发控制。

这实际上是使用多IO线程时的不足,它会带来两个影响:一个是基于加锁等互斥操作的并发控制,会降低系统整体性能;二个是多线程并发控制的开发与调试比较难,会增加开发者的负担。

第15讲

问题:Redis源码中提供了getLRUClock函数来计算全局LRU时钟值,同时键值对的LRU时钟值是通过LRU_CLOCK函数来获取的,以下代码也展示了LRU_CLOCK函数的执行逻辑,这个函数包括了两个分支,一个分支是直接从全局变量server的lruclock中获取全局时钟值,另一个是调用getLRUClock函数获取全局时钟值。

那么你知道,为什么键值对的LRU时钟值,不直接通过调用getLRUClock函数来获取呢?

unsigned int LRU_CLOCK(void) {
    unsigned int lruclock;
    if (1000/server.hz <= LRU_CLOCK_RESOLUTION) {
        atomicGet(server.lruclock,lruclock);
    } else {
        lruclock = getLRUClock();
    }
    return lruclock;
}

这道题有不少同学都给出了正确答案,比如@可怜大灰狼、@Kaito、@曾轼麟等等。这里我来总结下。

其实,调用getLRUClock函数获取全局时钟值,它最终会调用gettimeofday这个系统调用来获取时间。而系统调用会触发用户态和内核态的切换,会带来微秒级别的开销。

而对于Redis来说,它的吞吐率是每秒几万QPS,所以频繁地执行系统调用,这里面带来的微秒级开销有些大。所以,Redis只是以固定频率调用getLRUClock函数,使用系统调用获取全局时钟值,然后将该时钟值赋值给全局变量server.lruclock。当要获取时钟时,直接从全局变量中获取就行,节省了系统调用的开销。

刚才介绍的这种实现方法,在系统的性能优化过程中是有不错的参考价值的,你可以重点掌握下。

第16讲

问题:LFU算法在初始化键值对的访问次数时,会将访问次数设置为LFU_INIT_VAL,它的默认值是5次。那么,你能结合这节课介绍的代码,说说如果LFU_INIT_VAL设置为1,会发生什么情况吗?

这道题目主要是希望你能理解LFU算法实现时,对键值对访问次数的增加和衰减操作。LFU_INIT_VAL会在LFULogIncr函数中使用,如下所示:

uint8_t LFULogIncr(uint8_t counter) {
…
double r = (double)rand()/RAND_MAX;
    double baseval = counter - LFU_INIT_VAL;
    if (baseval < 0) baseval = 0;
    double p = 1.0/(baseval*server.lfu_log_factor+1);
	if (r < p) counter++;
	…}

从代码中可以看到,如果LFU_INIT_VAL比较小,那么baseval值会比较大,这就导致p值比较小,那么counter++操作的机会概率就会变小,这也就是说,键值对访问次数counter不容易增加。

而另一方面,LFU算法在执行时,会调用LFUDecrAndReturn函数,对键值对访问次数counter进行衰减操作。counter值越小,就越容易被衰减后淘汰掉。所以,如果LFU_INIT_VAL值设置为1,就容易导致刚刚写入缓存的键值对很快被淘汰掉。

因此,为了避免这个问题,LFU_INIT_VAL值就要设置的大一些。

第17讲

问题:freeMemoryIfNeeded函数在使用后台线程删除被淘汰数据的时候,你觉得在这个过程中,主线程仍然可以处理外部请求吗?

这道题像@Kaito等不少同学都给出了正确答案,我在这里总结下,也给你分享一下我的思考过程。

Redis主线程在执行freeMemoryIfNeeded函数时,这个函数确定了淘汰某个key 之后,会先把这个key从全局哈希表中删除。然后,这个函数会在dbAsyncDelete函数中,调用lazyfreeGetFreeEffort函数,评估释放内存的代价。

这个代价的计算,主要考虑的是要释放的键值对是集合时,集合中的元素数量。如果要释放的元素过多,主线程就会在后台线程中执行释放内存操作。此时,主线程就可以继续正常处理客户端请求了。而且因为被淘汰的key已从全局哈希表中删除,所以客户端也查询不到这个key了,不影响客户端正常操作。

第18讲

问题:你能在serverCron函数中,查找到rdbSaveBackground函数一共会被调用执行几次么?这又分别对应了什么场景呢?

这道题,我们通过在serverCron函数中查找rdbSaveBackground函数,就可以知道它被调用执行了几次。@曾轼麟同学做了比较详细的查找,我整理了下他的答案,分享给你。

首先,在serverCron 函数中,它会直接调用rdbSaveBackground两次。

第一次直接调用是在满足RDB生成的条件时,也就是修改的键值对数量和距离上次生成RDB的时间满足配置阈值时,serverCron函数会调用rdbSaveBackground函数,创建子进程生成RDB,如下所示:

if (server.dirty >= sp->changes && server.unixtime-server.lastsave > sp->seconds &&
(server.unixtime-server.lastbgsave_try> CONFIG_BGSAVE_RETRY_DELAY
|| server.lastbgsave_status == C_OK)) {
…
rdbSaveBackground(server.rdb_filename,rsiptr);
…}

第二次直接调用是在客户端执行了BGSAVE命令后,Redis设置了rdb_bgsave_scheduled等于1,此时,serverCron函数会检查这个变量值以及当前RDB子进程是否运行。

如果子进程没有运行的话,那么serverCron函数就调用rdbSaveBackground函数,生成RDB,如下所示:

if (!hasActiveChildProcess() && server.rdb_bgsave_scheduled &&
(server.unixtime-server.lastbgsave_try > CONFIG_BGSAVE_RETRY_DELAY ||
         server.lastbgsave_status == C_OK)) {
 …
if (rdbSaveBackground(server.rdb_filename,rsiptr) == C_OK)
…}

而除了刚才介绍的两次直接调用以外,在serverCron函数中,还会有两次对rdbSaveBackground的间接调用

一次间接调用是通过replicationCron -> startBgsaveForReplication -> rdbSaveBackground这个调用关系,来间接调用rdbSaveBackground函数,为主从复制定时任务生成RDB文件。

另一次间接调用是通过checkChildrenDone –> backgroundSaveDoneHandler -> backgroundSaveDoneHandlerDisk -> updateSlavesWaitingBgsave -> startBgsaveForReplication -> rdbSaveBackground这个调用关系,来生成RDB文件的。而这个调用主要是考虑到在主从复制过程中,有些从节点在等待当前的RDB生成过程结束,因此在当前的RDB子进程结束后,这个调用为这些等待的从节点新调度启动一次RDB子进程。

小结

好了,今天这节课就到这里了。我来总结下。

在今天的课程上,我给你解答了第13讲到第18讲的课后思考题。在这其中,我觉得有两个内容是你需要重点掌握的。

一个是你要了解系统调用的开销。从绝对值上来看,系统调用开销并不高,但是对于像Redis这样追求高性能的系统来说,每一处值得优化的地方都要仔细考虑。避免额外的系统开销,就是高性能系统开发的一个重要设计指导原则。

另一个是多IO线程模型的使用。我们通过思考题,了解了Redis会通过线程互斥锁,来实现对线程运行和等待状态的控制,以及多线程的优点和不足。现在的服务器硬件都是多核CPU,所以多线程模型也被广泛使用,用好多线程模型可以帮助我们实现系统性能的提升。

所以在最后,我也再给你关于多线程模型开发的三个小建议:

欢迎继续给我留言,分享你在学习课程时的思考。

评论