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.

16 KiB

答疑3 | 第13~18讲课后思考题答案及常见问题答疑

你好,我是蒋德钧。

今天这节课我们继续来解答第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所以多线程模型也被广泛使用用好多线程模型可以帮助我们实现系统性能的提升。

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

  • 尽量减少共享数据结构的使用比如采用key partition的方法让每个线程负责一部分key的访问这样可以减少并发访问的冲突。当然如果要做范围查询程序仍然需要访问多个线程负责的key。
  • 对共享数据结构进行优化,尽量采用原子操作来减少并发控制的开销。
  • 将线程和CPU核绑定减少线程在不同核上调度时带来的切换开销。

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