Redis的执行模型(Redis源码解析Redis真的是单线程模型吗?)

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
云解析 DNS,旗舰版 1个月
简介: Redis的执行模型(Redis源码解析Redis真的是单线程模型吗?)

Redis的执行模型

今天这篇文章,我们来聊聊 Redis 的执行模型。所谓的执行模型,就是指 Redis 运行时使用的进程、子进程和线程的个数,以及它们各自负责的工作任务。

在实际使用 Redis 的时候,可能经常会听到类似“Redis 是单线程”“Redis 的主 IO 线程”,“Redis 包含多线程”等不同说法。我也听到不少同学提出困惑和疑问:Redis 到底是不是一个单线程的程序?

其实,彻底理解这个问题,有助于指导我们保持 Redis 高性能、低延迟的特性。如果说 Redis 就是单线程程序,那么,我们就需要避免所有容易引起线程阻塞的操作;而如果说 Redis 不只是单线程,还有其他线程在工作,那么,我们就需要了解多线程各自负责什么任务,负责请求解析和数据读写的线程有几个,有哪些操作是后台线程在完成,而不会影响请求解析和数据读写的。

所以,今天这篇文章,我就从 Redis server 启动后运行的进程开始,带你一边学习 Redis 源码中子进程和线程的创建方式,一边掌握 Redis server 运行时涉及到的进程、子进程和线程情况。

下面,我们先来看 Redis server 启动时的进程运行。

从 shell 命令执行到 Redis 进程创建

我们在启动 Redis 实例时,可以在 shell 命令行环境中,执行 redis-server 这个可执行文件,如下所示:

./redis-server  /etc/redis/redis.conf

shell 运行这个命令后,它实际会调用 fork 系统调用函数,来新建一个进程。因为 shell 本身是一个进程,所以,这个通过 fork 新创建的进程就被称为是 shell 进程的子进程,而 shell 进程被称为父进程。关于 fork 函数的具体用法,我一会儿还会给你具体介绍。

紧接着,shell 进程会调用 execve 系统调用函数,将子进程执行的主体替换成 Redis 的可执行文件。而 Redis 可执行文件的入口函数就是 main 函数,这样一来,子进程就会开始执行 Redis server 的 main 函数了。

下面的代码显示了 execve 系统调用函数原型。其中,filename 是要运行的程序的文件名,argv[]和 envp[]分别是要运行程序的参数和环境变量。

int execve(const char *filename, char *const argv[], char *const envp[]))

下图显示了从 shell 执行命令到创建 Redis 进程的过程,你可以看下。

当我们用刚才介绍的 shell 命令运行 Redis server 后,我们会看到 Redis server 启动后的日志输出会打印到终端屏幕上,如下所示:

37807:M 19 Aug 2021 07:29:36.372 # Server initialized
37807:M 19 Aug 2021 07:29:36.372 * DB loaded from disk: 0.000 seconds
37807:M 19 Aug 2021 07:29:36.372 * Ready to accept connections

这是因为 shell 进程调用 fork 函数创建的子进程,会从父进程中继承一些属性,比如父进程打开的文件描述符。对于 shell 进程来说,它打开的文件描述符包括 0 和 1,这两个描述符分别代表了标准输入和标准输出。而 execve 函数只是把子进程的执行内容替换成 Redis 可执行文件,子进程从 shell 父进程继承到的标准输入和标准输出保持不变。

所以,Redis 运行时通过 serverLog 函数打印的日志信息,就会默认输出到终端屏幕上了,也就是 shell 进程的标准输出。

而一旦 Redis 进程创建开始运行后,它就会从 main 函数开始执行。我们在之前已经学习了 main 函数的主要执行过程,所以我们会发现,它会调用不同的函数来执行相关功能。比如,main 函数调用 initServerConfig 函数初始化 Redis server 的运行参数,调用 loadServerConfig 函数解析配置文件参数。当 main 函数调用这些函数时,这些函数仍然是由原来的进程执行的。所以,在这种情况下,Redis 仍然是单个进程在运行。

不过,在 main 函数完成参数解析后,会根据两个配置参数 daemonize 和 supervised,来设置变量 background 的值。它们的含义分别是:

  • 参数 daemonize 表示:是否要设置 Redis 以守护进程方式运行;
  • 参数 supervised 表示:是否使用 upstart 或是 systemd 这两种守护进程的管理程序来管理 Redis。

那么,我们来进一步了解下守护进程。守护进程是在系统后台运行的进程,独立于 shell 终端,不再需要用户在 shell 中进行输入了。一般来说,守护进程用于执行周期性任务或是等待相应事件发生再进行处理。Redis server 本身就是在启动后,等待客户端输入,再进行处理。所以对于 Redis 这类服务器程序来说,我们通常会让它以守护进程方式运行。

下面的代码显示了 main 函数根据变量 background 值,来判断是否执行 daemonize 函数的逻辑,你可以看下。

//如果配置参数daemonize为1,supervised值为0,那么设置background值为1,否则,设置其为0。
int main(int argc, char **argv) {
    int background = server.daemonize && !server.supervised;
    //如果background值为1,调用daemonize函数。
    if (background) daemonize();
}

也就是说,如果 background 的值为 1,就表示 Redis 被设置为以守护进程方式运行,因此 main 函数就会调用 daemonize 函数。

那么,接下来,我们就来学习下 daemonize 函数是如何将 Redis 转为守护进程运行的。

从 daemonize 函数的执行学习守护进程的创建

我们首先来看 daemonize 函数的部分执行内容,如下所示。我们可以看到,daemonize 函数调用了 fork 函数,并根据 fork 函数返回值有不同的分支代码。

server.c文件中查看

void daemonize(void) {
    int fd;
    // fork成功执行或失败,则父进程退出
    if (fork() != 0) exit(0); /* parent exits */
    // 创建新的session
    setsid(); /* create a new session */
    /* Every output goes to /dev/null. If Redis is daemonized but
     * the 'logfile' is set to 'stdout' in the configuration file
     * it will not log at all. */
    // 每个输出都指向/dev/null。如果Redis是以守护线程启动的,但是 *“日志文件”在配置文件中设置为“stdout” ,那么它根本不会记录到dev/null
    if ((fd = open("/dev/null", O_RDWR, 0)) != -1) {
        dup2(fd, STDIN_FILENO);
        dup2(fd, STDOUT_FILENO);
        dup2(fd, STDERR_FILENO);
        if (fd > STDERR_FILENO) close(fd);
    }
}

从刚才的介绍中,我们已经知道,当我们在一个程序的函数中调用 fork 函数时,fork 函数会创建一个子进程。而原本这个程序对应的进程,就称为这个子进程的父进程。那么,fork 函数执行后的不同分支和父、子进程是什么关系呢?这就和 fork 函数的使用有关了。

实际上,fork 函数的使用是比较有意思的,我们可以根据 fork 函数的不同返回值,来编写相应的分支代码,这些分支代码就对应了父进程和子进程各自要执行的逻辑。

为了便于你理解,我给你举个例子。我写了一段示例代码,这段代码的 main 函数会调用 fork 函数,并进一步根据 fork 函数的返回值是小于 0、等于 0,还是大于 0,来执行不同的分支。注意,fork 函数的不同返回值,其实代表了不同的含义,具体来说:

  • 当返回值小于 0 时,此时表明 fork 函数执行有误;
  • 当返回值等于 0 时,此时,返回值对应的代码分支就会在子进程中运行;
  • 当返回值大于 0 时,此时,返回值对应的代码分支仍然会在父进程中运行。

这段示例代码如下:

#include <stdio.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
  printf("hello main\n");
    // fork函数的返回值
    int rv = fork(); 
    //返回值小于0,表示fork执行错误
    if (rv < 0) {
        fprintf(stderr, "fork failed\n");
  }
  //返回值等于0,对应子进程执行
    else if (rv == 0) {
        printf("I am child process %d\n", getpid());
  }
  //返回值大于0,对应父进程执行
    else {
        printf("I am parent process of (%d), %d\n", rv, getpid());
    }
    return 0;
}

在这段代码中,我根据 fork 函数的返回值,分别写了三个分支代码,其中返回值等于 0 对应的代码分支,是子进程执行的代码。子进程会打印字符串“I am child process”,并打印子进程的进程号。而返回值大于 0 对应的代码分支,是父进程的代码。父进程会打印字符串“I am parent process of”,并打印它所创建的子进程进程号和它自身的进程号。

那么,如果你把这段代码编译后执行,你可以看到类似如下的结果,父进程打印了它的进程号 3541,而子进程则打印了它的进程号 3542。这表明刚才示例代码中的不同分支的确是由父、子进程来执行的。这也就是说,我们可以在 fork 函数执行后,使用不同分支,让父、子进程执行不同内容。

好了,了解了 fork 函数创建子进程的知识后,我们再来看下刚才介绍的 daemonize 函数。现在我们已经知道,daemonize 函数调用 fork 函数后,可以根据 fork 函数返回值设置不同代码分支,对应父、子进程执行内容。其实,daemonize 函数也的确设置了两个代码分支。

  • 分支一

这个分支对应 fork 函数返回值不为 0,表示 fork 函数成功执行后的父进程执行逻辑或是 fork 函数执行失败的执行逻辑。此时,父进程会调用 exit(0) 函数退出。也就是说,如果 fork 函数成功执行,父进程就退出了。当然,如果 fork 函数执行失败了,那么子进程也没有能成功创建,父进程也就退出执行了。你可以看下下面的代码,展示了这个分支。

void daemonize(void) {
    if (fork() != 0) exit(0); //fork成功执行或失败,则父进程退出
}
  • 分支二

这个分支对应 fork 函数返回值为 0,为子进程的执行逻辑。子进程首先会调用 setsid 函数,创建一个新的会话。然后,子进程会用 open 函数打开 /dev/null 设备,并把它的标准输入、标准输出和标准错误输出,重新定向到 /dev/null 设备。因为守护进程是在后台运行,它的输入输出是独立于 shell 终端的。所以,为了让 Redis 能以守护进程方式运行,这几步操作的目的就是把当前子进程的输入、输出由原来的 shell 终端,转向 /dev/null 设备,这样一来,就不再依赖于 shell 终端了,满足了守护进程的要求。我把 daemonize 函数的代码放在这里,你可以看下。

void daemonize(void) {
    int fd;
    // fork成功执行或失败,则父进程退出
    if (fork() != 0) exit(0); /* parent exits */
    // 创建新的session
    setsid(); /* create a new session */
    /* Every output goes to /dev/null. If Redis is daemonized but
     * the 'logfile' is set to 'stdout' in the configuration file
     * it will not log at all. */
    //将子进程的标准输入、标准输出、标准错误输出重定向到/dev/null中
    if ((fd = open("/dev/null", O_RDWR, 0)) != -1) {
        dup2(fd, STDIN_FILENO);
        dup2(fd, STDOUT_FILENO);
        dup2(fd, STDERR_FILENO);
        if (fd > STDERR_FILENO) close(fd);
    }
}

好了,到这里,我们就了解了,Redis 的 main 函数会根据配置参数 daemonize 和 supervised,来判断是否以守护进程方式运行 Redis。

那么,一旦 Redis 要以守护进程方式运行,main 函数会调用 daemonize 函数。daemonize 函数会进一步调用 fork 函数创建子进程,并根据返回值,分别执行父进程和子进程的代码分支。其中,父进程会退出。而子进程会代替原来的父进程,继续执行 main 函数的代码。

下面的图展示了 daemonize 函数调用 fork 函数后的两个分支的执行逻辑,你可以再回顾下。

事实上,Redis server 启动后无论是否以守护进程形式运行,都还是一个进程在运行。对于一个进程来说,如果该进程启动后没有创建新的线程,那么这个进程的工作任务默认就是由一个线程来执行的,而这个线程我一般也称它为主线程。

对于 Redis 来说,它的主要工作,包括接收客户端请求、解析请求和进行数据读写等操作,都没有创建新线程来执行,所以,Redis 主要工作的确是由单线程来执行的,这也是我们常说 Redis 是单线程程序的原因。因为 Redis 主要工作都是 IO 读写操作,所以,我也会把这个单线程称为主 IO 线程。

但其实,Redis 在 3.0 版本后,除了主 IO 线程外,的确还会启动一些后台线程来处理部分任务,从而避免这些任务对主 IO 线程的影响。那么,这些后台线程是在哪里启动的,又是如何执行的呢?

这就和 Redis 的bio.c文件相关了。接下来,我们就来从这个文件中学习下 Redis 的后台线程。

从 bio.c 文件学习 Redis 的后台线程

我们先来看下 main 函数在初始化过程最后调用的 InitServerLast 函数。InitServerLast 函数的作用是进一步调用 bioInit 函数,来创建后台线程,让 Redis 把部分任务交给后台线程处理。这个过程如下所示。

server.c文件中查看

void InitServerLast() {
    bioInit();
    initThreadedIO();
    set_jemalloc_bg_thread(server.jemalloc_bg_thread);
    server.initial_memory_usage = zmalloc_used_memory();
}

bioInit 函数是在bio.c文件中实现的,它的主要作用调用 pthread_create 函数创建多个后台线程。不过在具体了解 bioInit 函数之前,我们先来看下 bio.c 文件中定义的主要数组,这也是在 bioInit 函数中要进行初始化的。

bio.c 文件针对要创建的线程,定义了 pthread_t 类型的数组 bio_threads,用来保存创建的线程描述符。此外,bio.c 文件还创建了一个保存互斥锁的数组 bio_mutex,以及两个保存条件变量的数组 bio_newjob_cond 和 bio_step_cond。以下代码展示了这些数组的创建逻辑,你可以看下。

// 保存线程描述符的数组
static pthread_t bio_threads[BIO_NUM_OPS];
// 保存互斥锁的数组
static pthread_mutex_t bio_mutex[BIO_NUM_OPS];
// 保存条件变量的两个数组
static pthread_cond_t bio_newjob_cond[BIO_NUM_OPS];
static pthread_cond_t bio_step_cond[BIO_NUM_OPS];

从中你可以注意到,这些数组的大小都是宏定义 BIO_NUM_OPS,这个宏定义是在bio.h文件中定义的,默认值为 3。

同时在 bio.h 文件中,你还可以看到另外三个宏定义,分别是 BIO_CLOSE_FILE、BIO_AOF_FSYNC 和 BIO_LAZY_FREE。它们的代码如下所示:

/* Background job opcodes */
#define BIO_CLOSE_FILE    0 /* Deferred close(2) syscall. */
#define BIO_AOF_FSYNC     1 /* Deferred AOF fsync. */
#define BIO_LAZY_FREE     2 /* Deferred objects freeing. */
#define BIO_NUM_OPS       3

其中,BIO_NUM_OPS 表示的是 Redis 后台任务的类型有三种。而 BIO_CLOSE_FILE、BIO_AOF_FSYNC 和 BIO_LAZY_FREE,它们分别表示三种后台任务的操作码,这些操作码可以用来标识不同的任务。

  • BIO_CLOSE_FILE:文件关闭后台任务。
  • BIO_AOF_FSYNC:AOF 日志同步写回后台任务。
  • BIO_LAZY_FREE:惰性删除后台任务。

实际上,bio.c 文件创建的线程数组、互斥锁数组和条件变量数组,大小都是包含三个元素,也正是对应了这三种任务。

bioInit 函数:初始化数组

接下来,我们再来了解下 bio.c 文件中的初始化和线程创建函数 bioInit。我刚才也给你介绍过这个函数,它是 main 函数执行完 server 初始化后,通过 InitServerLast 函数调用的。也就是说,Redis 在完成 server 初始化后,就会创建线程来执行后台任务。

所以从这里来看,Redis 在运行时其实已经不止是单个线程(也就是主 IO 线程)在运行了,还会有后台线程在运行。如果你以后遇到 Redis 是否是单线程的问题时,你就可以给出准确答案了。

bioInit 函数首先会初始化互斥锁数组和条件变量数组。然后,该函数会调用 listCreate 函数,给 bio_jobs 这个数组的每个元素创建一个列表,同时给 bio_pending 数组的每个元素赋值为 0。这部分代码如下所示:

/* Initialization of state vars and objects */
for (j = 0; j < BIO_NUM_OPS; j++) {
    pthread_mutex_init(&bio_mutex[j],NULL);
    pthread_cond_init(&bio_newjob_cond[j],NULL);
    pthread_cond_init(&bio_step_cond[j],NULL);
    bio_jobs[j] = listCreate();
    bio_pending[j] = 0;
}

那么,要想了解给 bio_jobs 数组和 bio_pending 数组元素赋值的作用,我们就需要先搞清楚这两个数组的含义:

  • bio_jobs 数组的元素是 bio_jobs 结构体类型,用来表示后台任务。该结构体的成员变量包括了后台任务的创建时间 time,以及任务的参数。为该数组的每个元素创建一个列表,其实就是为每个后台线程创建一个要处理的任务列表。
  • bio_pending 数组的元素类型是 unsigned long long,用来表示每种任务中,处于等待状态的任务个数。将该数组每个元素初始化为 0,其实就是表示初始时,每种任务都没有待处理的具体任务。

下面的代码展示了 bio_job 结构体,以及 bio_jobs 和 bio_pending 这两个数组的定义,你也可以看下。

struct bio_job {
    time_t time; //任务创建时间
    void *arg1, *arg2, *arg3;  //任务参数
};
//以后台线程方式运行的任务列表
static list *bio_jobs[BIO_NUM_OPS];
//被阻塞的后台任务数组
static unsigned long long bio_pending[BIO_NUM_OPS];

好了,到这里,你就了解了 bioInit 函数执行时,会把线程互斥锁、条件变量对应数组初始化为 NULL,同时会给每个后台线程创建一个任务列表(对应 bio_jobs 数组的元素),以及会设置每种任务的待处理个数为 0(对应 bio_pending 数组的元素)。

bioInit 函数:设置线程属性并创建线程

在完成了初始化之后,接下来,bioInit 函数会先通过 pthread_attr_t 类型的变量,给线程设置属性。然后,bioInit 函数会调用前面我提到的 pthread_create 函数来创建线程。不过,为了能更好地理解 bioInit 函数设置线程属性和创建线程的过程,我们需要先对 pthread_create 函数本身有所了解,该函数的原型如下所示:

int  pthread_create(pthread_t *tidp, const  pthread_attr_t *attr,
( void *)(*start_routine)( void *), void  *arg);

可以看到,pthread_create 函数一共有 4 个参数,分别是:

  • *tidp,指向线程数据结构 pthread_t 的指针;
  • *attr,指向线程属性结构 pthread_attr_t 的指针;
  • *start_routine,线程所要运行的函数的起始地址,也是指向函数的指针;
  • *arg,传给运行函数的参数。

了解了 pthread_create 函数之后,我们来看下 bioInit 函数的具体操作。

首先,bioInit 函数会调用 pthread_attr_init 函数,初始化线程属性变量 attr,然后调用 pthread_attr_getstacksize 函数,获取线程的栈大小这一属性的当前值,并根据当前栈大小和 REDIS_THREAD_STACK_SIZE 宏定义的大小(默认值为 4MB),来计算最终的栈大小属性值。紧接着,bioInit 函数会调用 pthread_attr_setstacksize 函数,来设置栈大小这一属性值。

下面的代码展示了线程属性的获取、计算和设置逻辑,你可以看下:

pthread_attr_init(&attr);
pthread_attr_getstacksize(&attr,&stacksize);
if (!stacksize) stacksize = 1; /针对Solaris系统做处理
    while (stacksize < REDIS_THREAD_STACK_SIZE) stacksize *= 2;
    pthread_attr_setstacksize(&attr, stacksize);

我也画了一张图,展示了线程属性的这一操作过程,你可以看下。

在完成线程属性的设置后,接下来,bioInit 函数会通过一个 for 循环,来依次为每种后台任务创建一个线程。循环的次数是由 BIO_NUM_OPS 宏定义决定的,也就是 3 次。相应的,bioInit 函数就会调用 3 次 pthread_create 函数,并创建 3 个线程。bioInit 函数让这 3 个线程执行的函数都是 bioProcessBackgroundJobs

不过这里要注意一点,就是在这三次线程的创建过程中,传给这个函数的参数分别是 0、1、2。这个创建过程如下所示:

for (j = 0; j < BIO_NUM_OPS; j++) {
        void *arg = (void*)(unsigned long) j;
        if (pthread_create(&thread,&attr,bioProcessBackgroundJobs,arg) != 0) {
            … //报错信息
        }
        bio_threads[j] = thread;
}

你看了这个代码,可能会有一个小疑问:为什么创建的 3 个线程,它们所运行的 bioProcessBackgroundJobs 函数接收的参数分别是 0、1、2 呢?

这就和 bioProcessBackgroundJobs 函数的实现有关了,我们来具体看下。

首先,bioProcessBackgroundJobs 函数会把接收到的参数 arg,转成 unsigned long 类型,并赋值给 type 变量,如下所示:

void *bioProcessBackgroundJobs(void *arg) {
  unsigned long type = (unsigned long) arg;
}

而 type 变量表示的就是后台任务的操作码。这也是我刚才给你介绍的三种后台任务类型 BIO_CLOSE_FILEBIO_AOF_FSYNCBIO_LAZY_FREE 对应的操作码,它们的取值分别为 0、1、2。

bioProcessBackgroundJobs 函数的主要执行逻辑是一个 while(1) 的循环。在这个循环中,bioProcessBackgroundJobs 函数会从 bio_jobs 这个数组中取出相应任务,并根据任务类型,调用具体的函数来执行。

我刚才已经介绍过,bio_jobs 数组的每一个元素是一个队列。而因为 bio_jobs 数组的元素个数,等于后台任务的类型个数(也就是 BIO_NUM_OPS),所以,bio_jobs 数组的每个元素,实际上是对应了某一种后台任务的任务队列。

在了解了这一点后,我们就容易理解 bioProcessBackgroundJobs 函数中的 while 循环了。因为传给 bioProcessBackgroundJobs 函数的参数,分别是 0、1、2,对应了三种任务类型,所以在这个循环中,bioProcessBackgroundJobs 函数会一直不停地从某一种任务队列中,取出一个任务来执行。

同时,bioProcessBackgroundJobs 函数会根据传入的任务操作类型调用相应函数,具体来说:

  • 任务类型是 BIO_CLOSE_FILE,则调用 close 函数;
  • 任务类型是 BIO_AOF_FSYNC,则调用 redis_fsync 函数;
  • 任务类型是 BIO_LAZY_FREE,则再根据参数个数等情况,分别调用 lazyfreeFreeObjectFromBioThreadlazyfreeFreeDatabaseFromBioThreadlazyfreeFreeSlotsMapFromBioThread 这三个函数。

最后,当某个任务执行完成后,bioProcessBackgroundJobs 函数会从任务队列中,把这个任务对应的数据结构删除。我把这部分代码放在这里,你可以看下。

while(1) {
    listNode *ln;
    /* The loop always starts with the lock hold. */
    if (listLength(bio_jobs[type]) == 0) {
        pthread_cond_wait(&bio_newjob_cond[type],&bio_mutex[type]);
        continue;
    }
    /* Pop the job from the queue. */
    // 从类型为type的任务队列中获取第一个任务
    ln = listFirst(bio_jobs[type]);
    job = ln->value;
    /* It is now possible to unlock the background system as we know have
         * a stand alone job structure to process.*/
    pthread_mutex_unlock(&bio_mutex[type]);
    /* Process the job accordingly to its type. */
    // 判断当前处理的后台任务类型是哪一种
    if (type == BIO_CLOSE_FILE) {
        // s如果是关闭文件任务,那就调用close函数
        close(job->fd);
    } else if (type == BIO_AOF_FSYNC) {
        /* The fd may be closed by main thread and reused for another
             * socket, pipe, or file. We just ignore these errno because
             * aof fsync did not really fail. */
        // 如果是AOF同步写任务,那就调用redis_fsync函
        if (redis_fsync(job->fd) == -1 &&
            errno != EBADF && errno != EINVAL)
        {
            int last_status;
            atomicGet(server.aof_bio_fsync_status,last_status);
            atomicSet(server.aof_bio_fsync_status,C_ERR);
            atomicSet(server.aof_bio_fsync_errno,errno);
            if (last_status == C_OK) {
                serverLog(LL_WARNING,
                          "Fail to fsync the AOF file: %s",strerror(errno));
            }
        } else {
            atomicSet(server.aof_bio_fsync_status,C_OK);
        }
    } else if (type == BIO_LAZY_FREE) {
        // 如果是惰性删除任务,那根据任务的参数分别调用不同的惰性删除函数执行
        job->free_fn(job->free_args);
    } else {
        serverPanic("Wrong job type in bioProcessBackgroundJobs().");
    }
    zfree(job);
    /* Lock again before reiterating the loop, if there are no longer
         * jobs to process we'll block again in pthread_cond_wait(). */
    pthread_mutex_lock(&bio_mutex[type]);
    // 任务执行完成后,调用listDelNode在任务队列中删除该任务
    listDelNode(bio_jobs[type],ln);
    // 将对应的等待任务个数减一。
    bio_pending[type]--;
    /* Unblock threads blocked on bioWaitStepOfType() if any. */
    // 取消阻塞在 bioWaitStepOfType() 上阻塞线程(如果有)
    pthread_cond_broadcast(&bio_step_cond[type]);
}

所以说,bioInit 函数其实就是创建了 3 个线程,每个线程不停地去查看任务队列中是否有任务,如果有任务,就调用具体函数执行。

你可以再参考回顾下图所展示的 bioInit 函数和 bioProcessBackgroundJobs 函数的基本处理流程。

不过接下来你或许还会疑惑:既然 bioProcessBackgroundJobs函数是负责执行任务的,那么哪个函数负责提交/执行任务呢?

这就是下面,我要给你介绍的后台任务提交的函数 bioSubmitJob。

bioSubmitJob函数:提交后台任务

bioSubmitJob函数的原型如下,它会接收 2 个参数,其中,参数 type 表示该后台任务的类型,还有一个bio_job结构体类型的指针job,如下所示:

void bioSubmitJob(int type, struct bio_job *job) {
    job->time = time(NULL);
    pthread_mutex_lock(&bio_mutex[type]);
    // 将任务加到bio_jobs数组的对应任务列表中
    listAddNodeTail(bio_jobs[type],job);
    // 将对应任务列表上等待处理的任务个数加1
    bio_pending[type]++;
    pthread_cond_signal(&bio_newjob_cond[type]);
    pthread_mutex_unlock(&bio_mutex[type]);
}

好了,这样一来,当 Redis 进程想要启动一个后台任务时,只要调用 bioSubmitJob函数,并设置好该任务对应的类型和参数即可。然后,bioSubmitJob函数就会把创建好的任务数据结构,放到后台任务对应的队列中。另一方面,bioInit 函数在 Redis server 启动时,创建的线程会不断地轮询后台任务队列,一旦发现有任务可以执行,就会将该任务取出并执行。

其实,这种设计方式是典型的生产者 - 消费者模型。bioSubmitJob 函数是生产者,负责往每种任务队列中加入要执行的后台任务,而 bioProcessBackgroundJobs 函数是消费者,负责从每种任务队列中取出任务来执行。然后 Redis 创建的后台线程,会调用 bioProcessBackgroundJobs 函数,从而实现一直循环检查任务队列。

下图展示的就是 bioSubmitJob 和 bioProcessBackgroundJobs 两者间的生产者 - 消费者模型,你可以看下。

好了,到这里,我们就学习了 Redis 后台线程的创建和运行机制。简单来说,主要是以下三个关键点:

  • Redis 是先通过 bioInit 函数初始化和创建后台线程;
  • 后台线程运行的是 bioProcessBackgroundJobs 函数,这个函数会轮询任务队列,并根据要处理的任务类型,调用相应函数进行处理;
  • 后台线程要处理的任务是由 bioSubmitJob 函数来创建的,这些任务创建后会被放到任务队列中,等待 bioProcessBackgroundJobs 函数处理。

小结

今天篇文章,我给你介绍了 Redis 的执行模型,并且也从源码的角度出发,通过分析代码,带你了解了 Redis 进程创建、以子进程方式创建的守护进程、以及后台线程和它们负责的工作任务。同时,这也解答了你在面试中可能经常会被问到的问题:Redis 是单线程程序吗?

事实上,Redis server 启动后,它的主要工作包括接收客户端请求、解析请求和进行数据读写等操作,是由单线程来执行的,这也是我们常说 Redis 是单线程程序的原因。

我们可以回答:redis不是单线程的,而是一个主线去处理IO,另外有三个线程分别处理关闭fd、异步AOF刷盘、延迟释放。

但是,看完这篇文章你应该也知道,Redis 还启动了 3 个线程来执行文件关闭、AOF 同步写和惰性删除等操作。从这个角度来说,Redis 又不能算单线程程序,它还是有多线程的。而且,在下篇文章,我会给你介绍 Redis 6.0 中多 IO 线程的实现,从多 IO 线程角度看,Redis 也无法称为是单线程程序了。

另外看完这篇文章之后,你还需要重点注意下,fork 函数使用生产者 - 消费者模型这两个关键知识点。

  • 首先是 fork 函数的使用。fork 函数可以在一个进程运行时,再创建一个子进程。当 Redis 被配置为以守护进程方式运行时,Redis 的 main 函数就是调用 fork 函数,创建子进程,让子进程以守护进程形式执行,并让一开始启动执行的父进程退出。因为,子进程会从父进程那继承代码,所以 main 函数中的执行逻辑就交给了子进程继续执行。
  • 其次是生产者 - 消费者模型。Redis 在 bio.c 和 bio.h 文件中创建了后台线程,并实现了后台任务的执行。你要重点关注一下这里使用的生产者 - 消费者执行模型,这也是 bio.c 实现后台任务执行的核心设计思想。而且,当你需要实现异步的任务执行时,生产者 - 消费者模型就是一个很好的解决方案,你可以从 Redis 源码中掌握这个方案的实现思路。


相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
17天前
|
机器学习/深度学习 人工智能 PyTorch
Transformer模型变长序列优化:解析PyTorch上的FlashAttention2与xFormers
本文探讨了Transformer模型中变长输入序列的优化策略,旨在解决深度学习中常见的计算效率问题。文章首先介绍了批处理变长输入的技术挑战,特别是填充方法导致的资源浪费。随后,提出了多种优化技术,包括动态填充、PyTorch NestedTensors、FlashAttention2和XFormers的memory_efficient_attention。这些技术通过减少冗余计算、优化内存管理和改进计算模式,显著提升了模型的性能。实验结果显示,使用FlashAttention2和无填充策略的组合可以将步骤时间减少至323毫秒,相比未优化版本提升了约2.5倍。
35 3
Transformer模型变长序列优化:解析PyTorch上的FlashAttention2与xFormers
|
18天前
|
缓存 Java 调度
多线程编程核心:上下文切换深度解析
在现代计算机系统中,多线程编程已成为提高程序性能和响应速度的关键技术。然而,多线程编程中一个不可避免的概念就是上下文切换(Context Switching)。本文将深入探讨上下文切换的概念、原因、影响以及优化策略,帮助你在工作和学习中深入理解这一技术干货。
37 10
|
20天前
|
缓存 监控 Java
Java线程池提交任务流程底层源码与源码解析
【11月更文挑战第30天】嘿,各位技术爱好者们,今天咱们来聊聊Java线程池提交任务的底层源码与源码解析。作为一个资深的Java开发者,我相信你一定对线程池并不陌生。线程池作为并发编程中的一大利器,其重要性不言而喻。今天,我将以对话的方式,带你一步步深入线程池的奥秘,从概述到功能点,再到背景和业务点,最后到底层原理和示例,让你对线程池有一个全新的认识。
50 12
|
18天前
|
调度 开发者
核心概念解析:进程与线程的对比分析
在操作系统和计算机编程领域,进程和线程是两个基本而核心的概念。它们是程序执行和资源管理的基础,但它们之间存在显著的差异。本文将深入探讨进程与线程的区别,并分析它们在现代软件开发中的应用和重要性。
38 4
|
18天前
|
算法 调度 开发者
多线程编程核心:上下文切换深度解析
在多线程编程中,上下文切换是一个至关重要的概念,它直接影响到程序的性能和响应速度。本文将深入探讨上下文切换的含义、原因、影响以及如何优化,帮助你在工作和学习中更好地理解和应用多线程技术。
27 4
|
18天前
|
Java 调度 Android开发
安卓与iOS开发中的线程管理差异解析
在移动应用开发的广阔天地中,安卓和iOS两大平台各自拥有独特的魅力。如同东西方文化的差异,它们在处理多线程任务时也展现出不同的哲学。本文将带你穿梭于这两个平台之间,比较它们在线程管理上的核心理念、实现方式及性能考量,助你成为跨平台的编程高手。
|
22天前
|
存储 缓存 监控
Java中的线程池深度解析####
本文深入探讨了Java并发编程中的核心组件——线程池,从其基本概念、工作原理、核心参数解析到应用场景与最佳实践,全方位剖析了线程池在提升应用性能、资源管理和任务调度方面的重要作用。通过实例演示和性能对比,揭示合理配置线程池对于构建高效Java应用的关键意义。 ####
|
27天前
|
存储 安全 Java
Java多线程编程中的并发容器:深入解析与实战应用####
在本文中,我们将探讨Java多线程编程中的一个核心话题——并发容器。不同于传统单一线程环境下的数据结构,并发容器专为多线程场景设计,确保数据访问的线程安全性和高效性。我们将从基础概念出发,逐步深入到`java.util.concurrent`包下的核心并发容器实现,如`ConcurrentHashMap`、`CopyOnWriteArrayList`以及`BlockingQueue`等,通过实例代码演示其使用方法,并分析它们背后的设计原理与适用场景。无论你是Java并发编程的初学者还是希望深化理解的开发者,本文都将为你提供有价值的见解与实践指导。 --- ####
|
1月前
|
存储 安全 Linux
Golang的GMP调度模型与源码解析
【11月更文挑战第11天】GMP 调度模型是 Go 语言运行时系统的核心部分,用于高效管理和调度大量协程(goroutine)。它通过少量的操作系统线程(M)和逻辑处理器(P)来调度大量的轻量级协程(G),从而实现高性能的并发处理。GMP 模型通过本地队列和全局队列来减少锁竞争,提高调度效率。在 Go 源码中,`runtime.h` 文件定义了关键数据结构,`schedule()` 和 `findrunnable()` 函数实现了核心调度逻辑。通过深入研究 GMP 模型,可以更好地理解 Go 语言的并发机制。
|
23天前
|
机器学习/深度学习 人工智能 自然语言处理
探索深度学习与自然语言处理的前沿技术:Transformer模型的深度解析
探索深度学习与自然语言处理的前沿技术:Transformer模型的深度解析
72 0

推荐镜像

更多