V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
RedisMasterNode
V2EX  ›  Redis

字节跳动一面复盘 & Redis 多线程 IO 模型

  •  5
     
  •   RedisMasterNode · 2020-02-22 20:55:20 +08:00 · 13384 次点击
    这是一个创建于 1517 天前的主题,其中的信息可能已经有所发展或是发生改变。
    https://blog.2014bduck.com/archives/326
    

    面试

    上周参加了字节跳动的面试,也是 18 年毕业后的首次面试,整场下来一共 70 分钟,面试官非常 Nice,无奈自己太过紧张,很多准备好的知识点都没有能够准确传达意思,所以错失了这次的机会。

    面试中因为在简历上有提到 Redis 相关的内容,那么毫无疑问就会被问到了。先从经典的问题开始:Reids 为什么这么快?那自然会回答诸如单线程、IO 多路复用等固定套路,然后这里因为一直有关注 Redis 的相关新闻,知道 Redis 6.0 年末发布了 RC1 版本,其中新特性包括多线程 IO,那么自然想在面试中提及一下。面试官应该对这点比较感兴趣,于是就继续探讨了这个多线程 IO 的模型。

    • Q:Redis 6 多线程是指什么?
    • A:Redis 这边将部分处理流程改为多线程,具体来说是..
    • Q:是指查询是多线程吗?
    • A:应该说是处理请求的最后部分改为了多线程,因为这些部分涉及到数据的 IO,是整个( Redis )模型中最耗时的部分,所以改成了多线程;这部分之前的比如用户请求进来、将请求放入一个队列中,还是单线程的。(注意这部分回答是错误的,实际上 Redis 是将网络 IO 的部分做成了多线程,后文继续分析)
    • Q:如果我有一个 SET 操作的话,是单线程还是多线程?
    • A:多线程。(回答也是错的)
    • Q:那如果是,因为 Redis 都是内存操作,如果多线程操作一个数据结构的话会有问题吗?
    • A:Emm,目前我理解的模型上看确实会有问题,比如并发改同一个 Key,那可能 Redis 有对应处理这些问题比如进行加锁处理。(确实不了解,回答也自然是错的)
    • Q:好,下一个问题..

    这里先总结一下:

    • 因为 Antirez 在 Redis Day 介绍过,所以就了解到了有这么个新 Feature,但是具体的实现因为没有看过源码,所以实际上对这个多线程模型的理解是有偏差的。
    • 如果对这些点没有十足的把握的话,面试中尝试自己思考和解决这样的问题实际上还是会比较扣分,首先如果猜错了的话肯定不行,其次即使是猜对了也很难有足够的知识储备去复述出完整的模型出来,也会让自己一边思考一边表达起来很费劲。

    于是坑坑洼哇地坚持完了 70 分钟的面试,再总结一下做得不足的地方,因为是 1.5Year 经验,面试官主要考察:

    • 现有的业务的一些设计细节的问题:要提前准备好你想介绍给面试官的业务系统,个人认为应该从业务中选出一两个难度比较大的点会比较合适。这次面试没有能够拿出对应的业务来介绍,是准备不到位。
    • 数据库的基础知识:这块觉得回答得还可以,不过有的时候因为准备的东西比较多,会经常想充分地展现和描述,有的时候可能会比较冗长,也是表达不够精确的问题。
    • 计算机网络的基础知识:不是科班毕业,没有能够答完美,实际上问题并不难。
    • 计算机系统的基础知识:同上。
    • 一道算法题:字节跳动给的算法题还是偏简单和经典的,建议多刷题和看 Discussion 总结。

    所以就这样结束了第一次的社招面试,整体来说几个方向的基础知识需要回去再多写多看就可以了,然后表达上尽量控制时间和范围,深入的内容如果面试官希望和你继续探讨,自然会发问,如果没问,可以提及但是不应该直接展开讲。

    Redis 的 Threaded IO

    面试结束后马上知道这块的回答有问题,检查果然如此。所以也就借这个机会将 Threaded IO 对应的源码看了一遍,后续如果有机会的话,希望能跟下一位面试官再来探讨这个模型。

    综述

    本次新增的代码位于networking.c中,很显然多线程生效的位置就能猜出来是在网络请求上。作者希望改进读写缓冲区的性能,而不是命令执行的性能主要原因是:

    • 读写缓冲区的在命令执行的生命周期中是占了比较大的比重
    • Redis 更倾向于保持简单的设计,如果在命令执行部分改用多线程会不得不处理各种问题,例如并发写入、加锁等

    那么将读写缓冲区改为多线程后整个模型大致如下:

    具体模型

    线程初始化(initThreadedIO)

    首先,如果用户没有开启多线程 IO,也就是io_threads_num == 1时直接按照单线程模型处理;如果超过线程数IO_THREADS_MAX_NUM上限则异常退出。

    紧接着 Redis 使用 listCreate()创建 io_threads_num 个线程,并且对主线程( id=0 )以外的线程进行处理:

    • 初始化线程的等待任务数为 0
    • 获取锁,使得线程不能进行操作
    • 将线程 tid 与 Redis 中的线程 id ( for 循环生成)进行映射
    /* Initialize the data structures needed for threaded I/O. */
    void initThreadedIO(void) {
        io_threads_active = 0; /* We start with threads not active. */
    
        /* Don't spawn any thread if the user selected a single thread:
         * we'll handle I/O directly from the main thread. */
        // 如果用户没有开启多线程 IO 直接返回 使用主线程处理
        if (server.io_threads_num == 1) return;
        // 线程数设置超过上限
        if (server.io_threads_num > IO_THREADS_MAX_NUM) {
            serverLog(LL_WARNING,"Fatal: too many I/O threads configured. "
                                 "The maximum number is %d.", IO_THREADS_MAX_NUM);
            exit(1);
        }
    
        /* Spawn and initialize the I/O threads. */
        // 初始化 io_threads_num 个对应线程
        for (int i = 0; i < server.io_threads_num; i++) {
            /* Things we do for all the threads including the main thread. */
            io_threads_list[i] = listCreate();
            if (i == 0) continue; // Index 0 为主线程
    
            /* Things we do only for the additional threads. */
            // 非主线程则需要以下处理
            pthread_t tid;
            // 为线程初始化对应的锁
            pthread_mutex_init(&io_threads_mutex[i],NULL);
            // 线程等待状态初始化为 0
            io_threads_pending[i] = 0;
            // 初始化后将线程暂时锁住
            pthread_mutex_lock(&io_threads_mutex[i]);
            if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) {
                serverLog(LL_WARNING,"Fatal: Can't initialize IO thread.");
                exit(1);
            }
            // 将 index 和对应线程 ID 加以映射
            io_threads[i] = tid;
        }
    }
    

    读事件到来( readQueryFromClient )

    Redis 需要判断是否满足 Threaded IO 条件,执行if (postponeClientRead(c)) return;,执行后会将 Client 放到等待读取的队列中,并将 Client 的等待读取 Flag 置位:

    int postponeClientRead(client *c) {
        if (io_threads_active && // 线程是否在不断(spining)等待 IO
            server.io_threads_do_reads && // 是否多线程 IO 读取
            !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))
        {//client 不能是主从,且未处于等待读取的状态
            c->flags |= CLIENT_PENDING_READ; // 将 Client 设置为等待读取的状态 Flag
            listAddNodeHead(server.clients_pending_read,c); // 将这个 Client 加入到等待读取队列
            return 1;
        } else {
            return 0;
        }
    }
    

    这时 server 维护了一个clients_pending_read,包含所有处于读事件 pending 的客户端列表。

    如何分配 client 给 thread ( handleClientsWithPendingReadsUsingThreads )

    首先,Redis 检查有多少等待读的 client:

    listLength(server.clients_pending_read)
    

    如果长度不为 0,进行 While 循环,将每个等待的 client 分配给线程,当等待长度超过线程数时,每个线程分配到的 client 可能会超过 1 个:

    int item_id = 0;
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        int target_id = item_id % server.io_threads_num;
        listAddNodeTail(io_threads_list[target_id],c);
        item_id++;
    }
    

    并且修改每个线程需要完成的数量(初始化时为 0 ):

    for (int j = 1; j < server.io_threads_num; j++) {
        int count = listLength(io_threads_list[j]);
        io_threads_pending[j] = count;
    }
    

    等待处理直到没有剩余任务:

    while(1) {
        unsigned long pending = 0;
        for (int j = 1; j < server.io_threads_num; j++)
            pending += io_threads_pending[j];
        if (pending == 0) break;
    }
    

    最后清空 client_pending_read:

    listRewind(server.clients_pending_read,&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        c->flags &= ~CLIENT_PENDING_READ;
        if (c->flags & CLIENT_PENDING_COMMAND) {
            c->flags &= ~ CLIENT_PENDING_COMMAND;
            processCommandAndResetClient(c);
        }
        processInputBufferAndReplicate(c);
    }
    listEmpty(server.clients_pending_read);
    

    如何处理读请求

    在上面的过程中,当任务分发完毕后,每个线程按照正常流程将自己负责的 Client 的读取缓冲区的内容进行处理,和原来的单线程没有太大差异。

    每轮处理中,需要将各个线程的锁开启,并且将相关标志置位:

    void startThreadedIO(void) {
        if (tio_debug) { printf("S"); fflush(stdout); }
        if (tio_debug) printf("--- STARTING THREADED IO ---\n");
        serverAssert(io_threads_active == 0);
        for (int j = 1; j < server.io_threads_num; j++)
            // 解开线程的锁定状态
            pthread_mutex_unlock(&io_threads_mutex[j]);
        // 现在可以开始多线程 IO 执行对应读 /写任务
        io_threads_active = 1;
    }
    

    同样结束时,首先需要检查是否有剩余待读的 IO,如果没有,将线程锁定,标志关闭:

    void stopThreadedIO(void) {
        // 需要停止的时候可能还有等待读的 Client 在停止前进行处理
        handleClientsWithPendingReadsUsingThreads();
        if (tio_debug) { printf("E"); fflush(stdout); }
        if (tio_debug) printf("--- STOPPING THREADED IO [R%d] [W%d] ---\n",
            (int) listLength(server.clients_pending_read),
            (int) listLength(server.clients_pending_write));
        serverAssert(io_threads_active == 1);
        for (int j = 1; j < server.io_threads_num; j++)
            // 本轮 IO 结束 将所有线程上锁
            pthread_mutex_lock(&io_threads_mutex[j]);
        // IO 状态设置为关闭
        io_threads_active = 0;
    }
    

    其他补充

    Redis 的 Threaded IO 模型中,每次所有的线程都只能进行读或者写操作,通过io_threads_op控制,同时每个线程中负责的 client 依次执行:

    // 每个 thread 有可能需要负责多个 client
    listRewind(io_threads_list[id],&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        if (io_threads_op == IO_THREADS_OP_WRITE) {
            // 当前全局处于写事件时,向输出缓冲区写入响应内容
            writeToClient(c,0);
        } else if (io_threads_op == IO_THREADS_OP_READ) {
            // 当前全局处于读事件时,从输入缓冲区读取请求内容
            readQueryFromClient(c->conn);
        } else {
            serverPanic("io_threads_op value is unknown");
        }
    }
    

    每个线程执行readQueryFromClient,将对应的请求放入一个队列中,单线程执行,最后类似地由多线程将结果写入客户端的 buffer 中。

    总结

    Threaded IO 将服务读 Client 的输入缓冲区和将执行结果写入输出缓冲区的过程改为了多线程的模型,同时保持同一时间全部线程均处于读或者写的状态。但是命令的具体执行仍是以单线程(队列)的形式,因为 Redis 希望保持简单的结构避免处理锁和竞争的问题,并且读写缓冲区的时间占命令执行生命周期的比重较大,处理这部分的 IO 模型会给性能带来显著的提升。

    30 条回复    2020-03-20 13:35:10 +08:00
    RedisMasterNode
        1
    RedisMasterNode  
    OP
       2020-02-22 20:57:07 +08:00
    另外特别感谢 @PanJiaChen 大佬的内推,整个等待过程都不断被我骚扰进度 Orz 继续努力~再接再厉~
    balabalaguguji
        2
    balabalaguguji  
       2020-02-22 21:37:42 +08:00
    厉害
    royzxq
        3
    royzxq  
       2020-02-22 21:45:28 +08:00
    牛客那边有社招面经分享拿奖的活动,100 的京东 E 卡。(不是推广顺嘴一说啊)
    RedisMasterNode
        4
    RedisMasterNode  
    OP
       2020-02-22 23:03:50 +08:00 via Android
    @royzxq 谢谢 发了 不过活动什么就算了 留给其他人参与吧~
    hfutzj
        5
    hfutzj  
       2020-02-22 23:11:41 +08:00 via Android
    感谢分享
    a852695
        6
    a852695  
       2020-02-22 23:21:26 +08:00
    楼主总结真的很棒
    littlewing
        7
    littlewing  
       2020-02-22 23:53:26 +08:00 via iPhone
    建议楼主删掉面试细节部门,一般公司的面试内容是要求保密的
    liangdu
        8
    liangdu  
       2020-02-23 02:03:13 +08:00 via Android
    哈哈,面试官在用心的引导你到正确的放心,看得出相当耐心。

    另外建议了解一下 redis 的作者,以及它设计 redis 的偏执点(大概就是偏执于让 redis 简单,接口使用起来简单,整理设计简单。甚至不愿意优化性能。)
    zhuawadao
        9
    zhuawadao  
       2020-02-23 02:16:44 +08:00
    1.5Year..我...
    Mirana
        10
    Mirana  
       2020-02-23 02:54:04 +08:00
    id 很嚣张哦
    sujin190
        11
    sujin190  
       2020-02-23 09:50:15 +08:00
    说来说去,其实简单来说就是在整个 redis 中,最消耗资源的其实是网络数据读取写入、协议解析、数据结构准备,而这些全部都是可以多线程并行化无需加锁的,而最后的真正完成命令操作的部分基于特定的的数据结构和已准备好的数据结构来说并不时很消耗资源,所以多线程解决不是命令真正执行部分很自然
    callmexiaodeng
        12
    callmexiaodeng  
       2020-02-23 12:39:45 +08:00
    请问简历上一点 redis 都没有的话 会被问到吗
    wangyzj
        13
    wangyzj  
       2020-02-23 14:52:41 +08:00
    @PanJiaChen
    大佬
    感谢你为广大后端最初的贡献
    PanJiaChen
        14
    PanJiaChen  
       2020-02-23 16:19:28 +08:00
    @wangyzj 什么鬼
    RedisMasterNode
        15
    RedisMasterNode  
    OP
       2020-02-23 16:30:45 +08:00 via Android
    @PanJiaChen 2333 我也不知道他讲的是啥 orz 尼克杨.jpg
    wangyzj
        16
    wangyzj  
       2020-02-23 16:35:25 +08:00
    @PanJiaChen
    @RedisMasterNode
    vue-element-admin
    后端 dashboard 一站式啊
    cuiweixie
        17
    cuiweixie  
       2020-03-19 16:13:15 +08:00
    @RedisMasterNode 读写 io_threads_list 的 data race 怎么解决的,没看到加锁,也不是原子操作
    RedisMasterNode
        18
    RedisMasterNode  
    OP
       2020-03-19 16:39:36 +08:00
    @cuiweixie 有看到你在 google bbs 上发的内容,晚上再认真看看
    cuiweixie
        19
    cuiweixie  
       2020-03-19 17:42:55 +08:00
    @RedisMasterNode 好的,多谢了
    RedisMasterNode
        20
    RedisMasterNode  
    OP
       2020-03-19 21:51:00 +08:00
    @cuiweixie
    再稍微梳理一下我对读流程的理解,您要看一下是否一致先,因为我不是特别了解 C 和操作系统,所以可能一开始就是错的。

    1、当读事件到来,也就是 Client 发送内容过来的时候,读事件 handler 不进行同步(/立即)处理,而是将他们放入待处理的队列。

    2、`handleClientsWithPendingReadsUsingThreads()`方法构建 io_threads_list 与 io_threads_pending,其中 io_threads_list 是一个长度为 IO_THREADS_MAX_NUM 的指针 list,每个 item 都指向一个链表,链表中的元素为待处理的 client 对象。io_threads_pending 为一个长度与 io_threads_list 一致的整型 list,io_threads_pending[i]即为 io_threads_list[i]对应链表长度,也就代表着待处理的 client 数量。

    3、回到你截图 2 的代码段(`*IOThreadMain`),listRewind 生成一个针对 io_threads_list[i]链表的迭代器,while 循环负责依次遍历 io_threads_list[i]链表中的各个 client (也就是待处理的任务),根据当前全局状态(读还是写),对 client 进行操作。


    所以一个线程负责处理 io_threads_list[i]->client_1->client_2->client_3,其他线程也类似地负责处理 io_threads_list[j]中的各个 client 事件,这是我理解的模型。然后回到您的问题,我没太理解哪个位置的 data race,可能是对多线程的一些操作不是很熟悉和了解,请指教
    cuiweixie
        21
    cuiweixie  
       2020-03-20 10:54:08 +08:00
    @RedisMasterNode main 线程往 io_threads_list 写,io thread 读 io_threads_list ,比如
    main thread 执行完下面标记的一行后,线程切换到了 io thread,io thread 遍历 io_threads_list[i],那么 node 的 prev,next 指针都没有被设置,这里就出现野指针了
    list *listAddNodeTail(list *list, void *value)
    {
    listNode *node;

    if ((node = zmalloc(sizeof(*node))) == NULL)
    return NULL;
    node->value = value;
    if (list->len == 0) {
    list->head = list->tail = node; // 这一行
    node->prev = node->next = NULL;
    } else {
    node->prev = list->tail;
    node->next = NULL;
    list->tail->next = node;
    list->tail = node;
    }
    list->len++;
    return list;
    }
    RedisMasterNode
        22
    RedisMasterNode  
    OP
       2020-03-20 11:02:01 +08:00
    @cuiweixie 没有理解为什么会有线程切换?
    我理解的模型是 Master Thread 处理完后,IO Thread 才会开始处理,而不会 IO Thread 和 Master Thread 一直在同一轮读写里面同时处理,Master Thread 完成执行完之后 IO Thread 才有机会 start,不存在 Master 代码段执行到一半的时候 IO Thread 也在执行某段代码,您觉得呢?
    cuiweixie
        23
    cuiweixie  
       2020-03-20 11:15:50 +08:00
    如果是不存在 main thread 跟 io thread 同时执行,确实没 data race,但是没看明白为什么不会同时执行,比如
    这里明显 start io thread,然后再往里面 add list node
    int handleClientsWithPendingWritesUsingThreads(void) {
    int processed = listLength(server.clients_pending_write);
    if (processed == 0) return 0; /* Return ASAP if there are no clients. */

    /* If we have just a few clients to serve, don't use I/O threads, but the
    * boring synchronous code. */
    if (stopThreadedIOIfNeeded()) {
    return handleClientsWithPendingWrites();
    }

    /* Start threads if needed. */
    if (!io_threads_active) startThreadedIO();//这里明显 start 了 io thread

    if (tio_debug) printf("%d TOTAL WRITE pending clients\n", processed);

    /* Distribute the clients across N different lists. */
    listIter li;
    listNode *ln;
    listRewind(server.clients_pending_write,&li);
    int item_id = 0;
    while((ln = listNext(&li))) {
    client *c = listNodeValue(ln);
    c->flags &= ~CLIENT_PENDING_WRITE;
    int target_id = item_id % server.io_threads_num;
    listAddNodeTail(io_threads_list[target_id],c);
    item_id++;
    }
    RedisMasterNode
        24
    RedisMasterNode  
    OP
       2020-03-20 11:16:39 +08:00
    @cuiweixie 嗯嗯懂你意思了,先再读一下,这块了解得确实不够清晰
    RedisMasterNode
        25
    RedisMasterNode  
    OP
       2020-03-20 11:19:54 +08:00
    @cuiweixie 对了虽然没搞懂哪里的代码是明确 Master 和 IO 分开执行,不过我之前这样理解是因为这个注释:
    ```
    /* Process: note that the main thread will never touch our list
    * before we drop the pending count to 0. */
    ```
    所以在 IO Thread 执行过程中,Master 不会再去操作 io_threads_list 和 io_threads_pending。但是如您所说确实需要完整理解才能讲得清楚为什么不会同时执行。晚上下班后再康康
    cuiweixie
        26
    cuiweixie  
       2020-03-20 11:25:47 +08:00
    @RedisMasterNode 好的,这个注释被我忽略了,哈哈,这个 V2EX 的评论居然不支持富文本,贴代码的时候感觉好恶心
    cuiweixie
        27
    cuiweixie  
       2020-03-20 11:36:43 +08:00
    @RedisMasterNode 加上你提供的注释我看懂了,为什么调了 startThreadedIO()也不会存在同时跑 main thread 跟 io thread 了,因为 io_threads_pending[id] == 0
    RedisMasterNode
        28
    RedisMasterNode  
    OP
       2020-03-20 11:48:43 +08:00
    @cuiweixie 一开始我也这样想过这段,不过后来觉得,比较好的流程应该是:
    1、Master 准备这些 pending_list
    2、Master 完成,进入 IO Thread 阶段,这时候 Master Thread 应该 act as IO Thread 来工作,而不是 sleep 等待
    3、IO 阶段完成,Master 恢复处理 Master 的内容

    这个想法是因为部分注释(读写都有):
    ```
    /* Also use the main thread to process a slice of clients. */
    ```
    也就是说 Master 应该也会负责托管部分的 pending clients 么? 所以没第一时间肯定,觉得晚上还需要再看看
    cuiweixie
        29
    cuiweixie  
       2020-03-20 13:02:31 +08:00
    @RedisMasterNode master 处理的是它的 id=0 对应的 io_threads_list[0],这个 list 在 IOThreadMain 并不会处理,你可以看看看 IOThreadMain 启动的时候它没有启动对应 id=0 的 IOThreadMain
    RedisMasterNode
        30
    RedisMasterNode  
    OP
       2020-03-20 13:35:10 +08:00
    @cuiweixie 恩理解你意思了,在 IOThreadMain 这段确实过掉了
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   5677 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 31ms · UTC 01:53 · PVG 09:53 · LAX 18:53 · JFK 21:53
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.