精华内容
下载资源
问答
  • 论文:Group Ensemble: Learning an Ensemble of ConvNets in a single ConvNet地址:https://arxiv.org/pd...

    • 论文:Group Ensemble: Learning an Ensemble of ConvNets in a single ConvNet

    • 地址:https://arxiv.org/pdf/2007.00649.pdf

    0. 动机

    集成学习通过融合多个模型得到更准确的结果,在深度神经网络模型上应用集成学习可以提高网络性能。
    通常来讲,在深度神经网络中使用的集成策略可以分为2类:

    • 其一为显式的集成策略(explicit ensembling strategy),训练多个模型,通过专家委员会(a committee of experts)或其他方法得到最终结果。由于需要使用多个神经网络模型,这种方法计算量比较大。

    • 其二为隐式的集成策略(implicit ensembling strategy),这种方法又能分为2类:

      • 在单个模型中引入随机操作模仿集成,比如DropOut、DropConnect、DropBlock、StochDepth、Shake-Shake;

      • 在单个模型中使用多条路径模仿集成,比如ResNet、ResNeXt、DenseNet、Inception系列。

    作者提出了Group Ensemble方法,在单个模型中融合显式和隐式的集成方法,能够在几乎不提升原模型计算需求的基础上提高模型性能。

    1. Group Ensemble

    一些研究表明,对同一架构的网络使用不同方法(比如改变权重初始值、改变数据集的划分策略)训练多次,得到的多个网络中浅层的表达很类似;甚至不同的网络结构,比如VGG和ResNet系列网络,基于同一任务训练,得到的网络浅层表达也很类似。基于上述发现,作者认为,可以通过共享浅层网络、独立多个深层网络来达到显式集成的效果,以减少计算量。

    作者提出了名为GENet(Group Ensemble Network)的网络结构,用于在单个神经网络中进行显示集成学习,如下图所示:

    从上图中可以看出,作者使用Group卷积的形式达到“独立多个深层网络”的效果,每个分类器的输出结果融合得到最终结果。

    多个独立的深层网络提供了用于集成学习的多个分类器,并且引入了多个分类器的差异性;共享浅层网络,相比于传统的使用多个模型集成的方法,有效地减少了计算量。多个独立的深层网络使用同一个浅层网络,因此共享的浅层网络也可以理解为对共享参数的一种正则化手段。

    假设深层的Group卷积中共有n个组,整个模型的损失函数为:

    上式中的 表示第m个Group卷积的损失函数。

    推理时,这些独立的深层网络分别输出各自的结果,通过取平均值得到整个网络的最终结果。

    2. Aggregation Strategies

    作者介绍了3种训练时给样本分配权重的方法,它们分别为Group Averaging、Group Wagging、Group Boosting,可以使用这些方法提高深层网络的多样性,达到更好的集成效果。

    第m组的损失函数  可以表示为:

    上式中,i表示样本的索引,b 表示 batch size, 表示第i个样本的损失, 表示第i个样本对应的权重。下图说明了该公式的含义:

    Group Averaging、Group Wagging、Group Boosting这3种方法决定了上式中 的取值策略。

    • Group Averaging:所有的 取值为1,不同组的多样性由它们的权重初始值、各自的损失和各自的反向传播决定。

    • Group Wagging: 是服从均匀分布或者高斯分布的随机值,若 服从高斯分布,表示为 。随机选取 能在样本层面增加不同组的多样性。

    • Group Boosting 当前组的 取决于上一组对该样本的识别效果,当上一组对该样本识别错误时,在当前组中 取较大值,即让当前组重点关注上一组识别错误的样本。为了在训练时就能得到某个组对样本的识别效果,作者使用了online boosting方法,定义在第m组中某个样本的权重为:

    上式中的 为第m-1组对该样本的识别概率, 为参数。

    这三种方法的示意图如下所示:

    作者探讨了多个组之间保持独立性对于最终分类结果的影响:

    假设一个简单的2分类任务,多个模型的输出服从于同样的正态分布,即

    上式中 表示对于某个样本第i个模型输出的正确类别的预测得分,n表示模型数量, 分别表示均值和标准差。

    如下图所示:

    左上图为单个模型的输出分布,右上图为2个独立模型的输出分布,左下图为4个独立模型的输出分布,从这3个图可以看出,融合多个模型的输出结果会更准确,且模型个数越多越好。

    右下图为2个相关系数 的模型的输出分布,对比右上图和右下图可以看出,模型越独立,融合后输出结果越准确。因此可知保持多个模型独立的重要性。

    3. 实验

    3.1 分类任务

    作者在CIFAR和ImageNet数据集上测试GENet。使用Group Averaging策略分配样本权重,使用Pytorch实现。

    对于CIFAR数据集,使用ResNet-29和ResNeXt-29作为Backbone,网络最后一层为用于分类的FC层,使用center-crop的方法在验证集上测试。在CIFAR-10和CIFAR-100数据集上的Top1错误率如下图所示:

    上图中的Ensemble表示使用传统的多个模型集成方法,“2x”和“3x”分别表示使用2个和3个模型。从上图中可以看出GENet保持和baseline基本一致的计算量,性能与传统的模型集成方法接近。

    对于ImageNet数据集,使用ResNet-50、ResNeXt-50、ResNeXt-101作为Backbone,在验证集上使用224x224尺寸的center-crop进行测试,错误率如下图所示:

    从上图中可以看出,GENet和传统的模型集成方法性能接近,但是有更少的参数量和计算需求。

    3.2 目标检测任务

    在COCO2017数据集上使用Faster R-CNN测试,Backbone为ResNet50并且使用了FPN。保持Faster R-CNN中的RPN不变,只将R-CNN head部分改为group ensemble形式,测试结果如下图所示:

    从上图中可以看出,使用group ensemble能够在保持参数量不变的前提下达到多个模型集成的性能,验证了GENet对baseline性能的提升,以及在不同领域的有效性。

    4. 总结

    • 提出了GENet,通过共用浅层网络、独立深层网络的形式在单个CNN中实现显式模型集成;

    • 说明了保持多组网络独立性的意义,以及通过aggregation strategy提高多组网络独立性和多样性的方法;

    • 通过实验证明了GENet在几乎不改变baseline计算需求的前提下提升性能。

    仅用于学习交流!

    END

    备注:部署

    模型压缩与应用部署交流群

    模型压缩、网络压缩、神经网络加速、轻量级网络设计、知识蒸馏、应用部署、MNN、NCNN等技术,

    扫码备注拉你入群。

    我爱计算机视觉

    微信号:aicvml

    QQ群:805388940

    微博知乎:@我爱计算机视觉

    投稿:amos@52cv.net

    网站:www.52cv.net

    在看,让更多人看到  

    展开全文
  • 模型集成方法

    2020-12-26 20:49:19
    集成学习是机器学习中的一类学习算法,指训练多个学习器并将它们组合起来使用的方法。这类算法通常在实践中能取得比单个学习器更好的预测结果,颇有“众人拾柴火焰高”之意。尽管深度网络模型已经拥有强大的预测能力...


    集成学习是机器学习中的一类学习算法,指训练多个学习器并将它们组合起来使用的方法。这类算法通常在实践中能取得比单个学习器更好的预测结果,颇有“众人拾柴火焰高”之意。尽管深度网络模型已经拥有强大的预测能力,但集成学习的使用仍能起到“锦上添花”的作用。因此有必要了解并掌握一些深度模型方面的集成方法。一般来讲,深度模型的集成多从“数据层面”和“模型层面”两方面着手。

    数据层面的集成方法

    测试阶段数据补充

    我们在之前的文章有提到“数据扩充”策略,实际上,这些扩充策略在模型测试阶段同样适用,诸如图像多尺度、随机抠取等。以随机抠取为例,对某张测试图像可得到n张随机抠取图像,测试阶段只需要用训练好的深度网络模型对n张图像分别做预测,之后将预测的各类置信度平均作为该测试图像最终预测结果即可。

    “简易集成”法

    “简易集成”法对于样本较多的类采取降采样,每次采样数依照样本数目最少的类别而定,这样每类取到的样本数可保持均等。采样结束后,针对每次采样得到的子数据集训练模型,如此采样、训练反复进行多次。最后对测试数据的预测则依据训练得到若干个模型的结果取平均或投票获得。总结来说,“简易集成”法在模型集成的同时,还能缓解数据不平衡带来的问题,可谓一举两得。

    模型层面的集成方法

    单模型集成

    多特征融合

    多层特征融合是针对单模型的一种模型才能买呢集成方法。由于深度卷积神经网络特征具有层次性的特点,不同层特征富含的语义信息可以相互补充,在图像语义分割、细粒度图像检索、基于视频的表象性格分析等任务中常见多层特征融合策略的使用。一般地,多层特征融合操作可直接将不同层网络特征级联。而对于特征融合应选取哪些网络层,一个实践经验是:最好使用靠近目标函数的几层卷积特征,因为愈深层特征包含的高层语义性更强、分辨能力也愈强;相反,网络较浅层的特征较为普适,用于特征融合很可能起不到作用,有时甚至会起到反作用。

    网络“快照”集成法

    在这里插入图片描述
    在这里插入图片描述

    多模型集成

    前面提到了基于单个网络如何进行模型集成,这里向大家介绍如何产生多个不同网络训练结果并讲解一些多模型的集成方法。

    多模型生成策略

    同一模型不同初始化:首先对同一模型进行不同的初始化,之后将得到的网络模型进行结果集成会大幅度缓解其随机性,提升最终任务的预测结果。
    同一模型不同训练轮数:若网络超参数设置得当,深度模型随着网络训练的进行会逐步趋于收敛,但不同训练轮数的结果仍有不同,无法确定到底哪一轮训练得到的模型最适用于测试数据。针对上述问题,一种简单的解决方式是将最后几轮训练模型结果做集成,这样一方面可降低随机误差,另一方面也避免了训练轮数过多带来的过拟合风险。这样的操作被称为“轮数集成”。
    不同目标函数:目标函数(或称损失函数)是整个网络训练的“指挥棒”,选择不同目标函数势必使网络学到不同的特征表示。以分类任务为例,可将“交叉熵损失函数”、“合页熵损失函数”、“大间隔交叉损失函数”和“中心损失函数”作为目标函数分别训练模型。在预测阶段,既可以直接对不同模型预测结果做“置信度级别”的平均或投票,也可以做“特征级”的模型集成,将不同网络得到的深度特征抽出后级联作为最终特征,之后离线训练浅层分类器(如支持向量机)完成预测任务。
    不同网络结构:不同网络结构也是一种有效的产生不同网络模型结果的方式。操作时可在如VGG网络、深度残差网络等不同网络架构上训练模型,最后将不同架构网络得到的结果做以集成。

    多模型集成方法

    在这里插入图片描述

    直接平均法

    在这里插入图片描述

    加权平均法
    在这里插入图片描述
    在这里插入图片描述

    投票法
    在这里插入图片描述

    堆叠法
    在这里插入图片描述

    小结

    1.深度网络的模型集成往往是提升网络最终预测能力的一剂“强心针”;
    2.数据层面常用的方法是数据扩充和“简易集成”法 ,均操作简单但效果显著;
    3.模型层面的模型集成方法可分为“单模型集成”和“多模型集成”。基于单一模型的集成方法可借助单个模型的多层特征融合和网络“快照”法进行。多模型集成方面,可通过不同参数初始化、不同训练轮数和不同目标函数的设定产生多个网络模型训练结果。最后使用平均法、投票法和堆叠法进行结果集成。
    :文章选自《解析深度学习—卷积神经网络原理与视觉实践》魏秀参著

    展开全文
  • 目标检测多模型集成方法总结

    千次阅读 2021-01-13 23:51:32
    本文转载自AI公园。前段时间推送了文章:难以置信的目标检测小妙招:训练几epochs,平均一下就能获得更好的模型,不少朋友对模型集成感兴趣,本文是小总结。作者:Vikas S S...

    本文转载自AI公园。

    前段时间推送了文章:难以置信的目标检测小妙招:多训练几个epochs,平均一下就能获得更好的模型 ,不少朋友对模型集成感兴趣,本文是个小总结。

    作者:Vikas S Shetty

    编译:ronghuaiyang

    导读

    模型集成是一种提升模型能力的常用方法,但通常也会带来推理时间的增加,在物体检测上效果如何,可以看看。

    介绍

    集成机器学习模型是一种常见的提升模型能力的方式,并已在多个场景中使用,因为它们结合了多个模型的决策,以提高整体性能,但当涉及到基于DNN(深度神经网络)的目标检测模型时,它并不仅仅是合并结果那么简单。

    集成的需求

    为了在任何模型中获得良好的结果,都需要满足某些标准(数据、超参数)。但在真实场景中,你可能会得到糟糕的训练数据,或者很难找到合适的超参数。在这些情况下,综合多个性能较差的模型可以帮助你获得所需的结果。在某种意义上,集成学习可以被认为是一种通过执行大量额外计算来弥补学习算法不足的方法。另一方面,另一种选择是在一个非集成系统上做更多的学习。对于计算、存储或通信资源的相同增加,集成系统使用两种或两种以上的方法可能会比使用单一方法增加资源的方法更有效地提高整体精度。

    看起来挺好,有没有缺点呢?

    • 更难调试或理解预测,因为预测框是根据多个模型绘制的。

    • 推理时间根据模型和使用的模型数量而增加。

    • 尝试不同的模型以获得合适的模型集合是一件耗时的事情。

    不同的模型集成

    1. OR方法:如果一个框是由至少一个模型生成的,就会考虑它。

    2. AND方法:如果所有模型产生相同的框,则认为是一个框(如果IOU >0.5)。

    3. 一致性方法:如果大多数模型产生相同的框,则认为是一个框,即如果有m个模型,(m/2 +1)个模型产生相同的框,则认为这个框有效。

    4. 加权融合:这是一种替代NMS的新方法,并指出了其不足之处。

    不同的集成方法

    在上面的例子中,OR方法的预测得到了所有需要的对象框,但也得到了一个假阳性结果,一致性的方法漏掉了马,AND方法同时漏掉了马和狗。

    验证

    为了计算不同的集成方法,我们将跟踪以下参数:

    1. True positive:预测框与gt匹配

    2. False Positives:预测框是错误的

    3. False Negatives:没有预测,但是存在gt。

    4. Precision:度量你的预测有多准确。也就是说,你的预测正确的百分比[TP/ (TP + FP)]

    5. Recall:度量gt被预测的百分比[TP/ (TP + FN)]

    6. Average Precision:precision-recall图的曲线下面积

    使用的模型

    为了理解集成是如何起作用的,我们提供了用于实验的独立模型的结果。

    1. YoloV3:

    2. Faster R-CNN — ResNeXt 101 [X101-FPN]:

    集成实验

    1. OR — [YoloV3, X101-FPN]

    如果你仔细观察,FPs的数量增加了,这反过来降低了精度。与此同时,TPs数量的增加反过来又增加了召回。这是使用OR方法时可以观察到的一般趋势。

    2. AND — [YoloV3, X101-FPN]

    与我们使用OR方法观察到的情况相反,在AND方法中,我们最终获得了较高的精度和较低的召回率,因为几乎所有的假阳性都被删除了,因为YoloV3和X101的大多数FPs是不同的。

    检测框加权融合

    在NMS方法中,如果框的IoU大于某个阈值,则认为框属于单个物体。因此,框的过滤过程取决于这个单一IoU阈值的选择,这影响了模型的性能。然而,设置这个阈值很棘手:如果有多个物体并排存在,那么其中一个就会被删除。NMS丢弃了冗余框,因此不能有效地从不同的模型中产生平均的局部预测。

    NMS和WBF之间的主要区别是,WBF利用所有的框,而不是丢弃它们。在上面的例子中,红框是ground truth,蓝框是多个模型做出的预测。请注意,NMS是如何删除冗余框的,但WBF通过考虑所有预测框创建了一个全新的框(融合框)。

    3. Weighted Boxes Fusion — [Yolov3, X101-FPN]

    YoloV3和X101-FPN的权重比分别为2:1。我们也试着增加有利于X101-FPN的比重(因为它的性能更好),但在性能上没有看到任何显著的差异。从我们读过的加权融合论文中,作者注意到了AP的增加,但如你所见,WBF YoloV3和X101-FPN并不比OR方法好很多。我们注意到的是,大部分的实验涉及至少3个或更多模型。

    4. Weighted Boxes Fusion — [Yolov3, X101, R101, R50]

    在最后的实验中,我们使用了YoloV3以及我们在Detectron2中训练的3个模型[ResNeXt101-FPN, ResNet101-FPN, ResNet50-FPN]。显然,召回率有一个跳跃(约为传统方法的0.3),但AP的跳跃并不大。另外,需要注意的是,当你向WF方法添加更多模型时,误报的数量会激增。

    总结

    当使用相互补充的模型时,集成是提高性能的一种很好的方法,但它也会以速度为代价来完成推理。根据需求,可以决定有多少个模型,采用哪种方法,等等。但从我们进行的实验来看,性能提升的数量似乎与一起运行这些模型所需的资源和推断时间不成比例。

    英文原文:https://medium.com/inspiredbrilliance/object-detection-through-ensemble-of-models-fed015bc1ee0

    END

    备注:目标检测

    目标检测交流群

    2D、3D目标检测等最新资讯,若已为CV君其他账号好友请直接私信。

    我爱计算机视觉

    微信号:aicvml

    QQ群:805388940

    微博知乎:@我爱计算机视觉

    投稿:amos@52cv.net

    网站:www.52cv.net

    在看,让更多人看到  

    展开全文
  • Redis 线程网络模型全面揭秘

    千次阅读 多人点赞 2021-02-21 15:31:18
    导语一文带你完全吃透 Redis ...Redis 线程网络模型 设计思路 源码剖析 性能提升 模型缺陷 总结 博客原文 导言 在目前的技术选型中,Redis 俨然已经成为了系统高性能缓存方案的事实标准,因此现在 Redis...

     导语 一文带你完全吃透 Redis 整个核心网络模型的原理和源码。

    目录

     

    导言

    Redis 有多快?

    Redis 为什么快?

    Redis 为何选择单线程?

    避免过多的上下文切换开销

    避免同步机制的开销

    简单可维护

    Redis 真的是单线程?

    单线程事件循环

    多线程异步任务

    Redis 多线程网络模型

    设计思路

    源码剖析

    性能提升

    模型缺陷

    总结

    博客原文


    导言

    在目前的技术选型中,Redis 俨然已经成为了系统高性能缓存方案的事实标准,因此现在 Redis 也成为了后端开发的基本技能树之一,Redis 的底层原理也顺理成章地成为了必须学习的知识。

    Redis 从本质上来讲是一个网络服务器,而对于一个网络服务器来说,网络模型是它的精华,搞懂了一个网络服务器的网络模型,你也就搞懂了它的本质。

    本文通过层层递进的方式,介绍了 Redis 网络模型的版本变更历程,剖析了其从单线程进化到多线程的工作原理,此外,还一并分析并解答了 Redis 的网络模型的很多抉择背后的思考,帮助读者能更深刻地理解 Redis 网络模型的设计。

    Redis 有多快?

    根据官方的 benchmark,通常来说,在一台普通硬件配置的 Linux 机器上跑单个 Redis 实例,处理简单命令(时间复杂度 O(N) 或者 O(log(N))),QPS 可以达到 8w+,而如果使用 pipeline 批处理功能,则 QPS 至高能达到 100w。

    仅从性能层面进行评判,Redis 完全可以被称之为高性能缓存方案。

    Redis 为什么快?

    Redis 的高性能得益于以下几个基础:

    • C 语言实现,虽然 C 对 Redis 的性能有助力,但语言并不是最核心因素。
    • 纯内存 I/O,相较于其他基于磁盘的 DB,Redis 的纯内存操作有着天然的性能优势。
    • I/O 多路复用,基于 epoll/select/kqueue 等 I/O 多路复用技术,实现高吞吐的网络 I/O。
    • 单线程模型,单线程无法利用多核,但是从另一个层面来说则避免了多线程频繁上下文切换,以及同步机制如锁带来的开销。

    Redis 为何选择单线程?

    Redis 的核心网络模型选择用单线程来实现,这在一开始就引起了很多人的不解,Redis 官方的对于此的回答是:

    It’s not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.

    核心意思就是,对于一个 DB 来说,CPU 通常不会是瓶颈,因为大多数请求不会是 CPU 密集型的,而是 I/O 密集型。具体到 Redis 的话,如果不考虑 RDB/AOF 等持久化方案,Redis 是完全的纯内存操作,执行速度是非常快的,因此这部分操作通常不会是性能瓶颈,Redis 真正的性能瓶颈在于网络 I/O,也就是客户端和服务端之间的网络传输延迟,因此 Redis 选择了单线程的 I/O 多路复用来实现它的核心网络模型。

    上面是比较笼统的官方答案,实际上更加具体的选择单线程的原因可以归纳如下:

    避免过多的上下文切换开销

    多线程调度过程中必然需要在 CPU 之间切换线程上下文 context,而上下文的切换又涉及程序计数器、堆栈指针和程序状态字等一系列的寄存器置换、程序堆栈重置甚至是 CPU 高速缓存、TLB 快表的汰换,如果是进程内的多线程切换还好一些,因为单一进程内多线程共享进程地址空间,因此线程上下文比之进程上下文要小得多,如果是跨进程调度,则需要切换掉整个进程地址空间。

    如果是单线程则可以规避进程内频繁的线程切换开销,因为程序始终运行在进程中单个线程内,没有多线程切换的场景。

    避免同步机制的开销

    如果 Redis 选择多线程模型,又因为 Redis 是一个数据库,那么势必涉及到底层数据同步的问题,则必然会引入某些同步机制,比如锁,而我们知道 Redis 不仅仅提供了简单的 key-value 数据结构,还有 list、set 和 hash 等等其他丰富的数据结构,而不同的数据结构对同步访问的加锁粒度又不尽相同,可能会导致在操作数据过程中带来很多加锁解锁的开销,增加程序复杂度的同时还会降低性能。

    简单可维护

    Redis 的作者 Salvatore Sanfilippo (别称 antirez) 对 Redis 的设计和代码有着近乎偏执的简洁性理念,你可以在阅读 Redis 的源码或者给 Redis 提交 PR 的之时感受到这份偏执。因此代码的简单可维护性必然是 Redis 早期的核心准则之一,而引入多线程必然会导致代码的复杂度上升和可维护性下降。

    事实上,多线程编程也不是那么尽善尽美,首先多线程的引入会使得程序不再保持代码逻辑上的串行性,代码执行的顺序将变成不可预测的,稍不注意就会导致程序出现各种并发编程的问题;其次,多线程模式也使得程序调试更加复杂和麻烦。网络上有一幅很有意思的图片,生动形象地描述了并发编程面临的窘境。

    你期望的多线程编程 VS 实际上的多线程编程:

    前面我们提到引入多线程必须的同步机制,如果 Redis 使用多线程模式,那么所有的底层数据结构都必须实现成线程安全的,这无疑又使得 Redis 的实现变得更加复杂。

    总而言之,Redis 选择单线程可以说是多方博弈之后的一种权衡:在保证足够的性能表现之下,使用单线程保持代码的简单和可维护性。

    Redis 真的是单线程?

    在讨论这个问题之前,我们要先明确『单线程』这个概念的边界:它的覆盖范围是核心网络模型,抑或是整个 Redis?如果是前者,那么答案是肯定的,在 Redis 的 v6.0 版本正式引入多线程之前,其网络模型一直是单线程模式的;如果是后者,那么答案则是否定的,Redis 早在 v4.0 就已经引入了多线程。

    因此,当我们讨论 Redis 的多线程之时,有必要对 Redis 的版本划出两个重要的节点:

    1. Redis v4.0(引入多线程处理异步任务)
    2. Redis v6.0(正式在网络模型中实现 I/O 多线程)

    单线程事件循环

    我们首先来剖析一下 Redis 的核心网络模型,从 Redis 的 v1.0 到 v6.0 版本之前,Redis 的核心网络模型一直是一个典型的单 Reactor 模型:利用 epoll/select/kqueue 等多路复用技术,在单线程的事件循环中不断去处理事件(客户端请求),最后回写响应数据到客户端:

    这里有几个核心的概念需要学习:

    • client:客户端对象,Redis 是典型的 CS 架构(Client <—> Server),客户端通过 socket 与服务端建立网络通道然后发送请求命令,服务端执行请求的命令并回复。Redis 使用结构体 client 存储客户端的所有相关信息,包括但不限于封装的套接字连接 -- *conn当前选择的数据库指针 -- *db读入缓冲区 -- querybuf写出缓冲区 -- buf写出数据链表 -- reply等。
    • aeApiPoll:I/O 多路复用 API,是基于 epoll_wait/select/kevent 等系统调用的封装,监听等待读写事件触发,然后处理,它是事件循环(Event Loop)中的核心函数,是事件驱动得以运行的基础。
    • acceptTcpHandler:连接应答处理器,底层使用系统调用 accept 接受来自客户端的新连接,并为新连接注册绑定命令读取处理器,以备后续处理新的客户端 TCP 连接;除了这个处理器,还有对应的 acceptUnixHandler 负责处理 Unix Domain Socket 以及 acceptTLSHandler 负责处理 TLS 加密连接。
    • readQueryFromClient:命令读取处理器,解析并执行客户端的请求命令。
    • beforeSleep:事件循环中进入 aeApiPoll 等待事件到来之前会执行的函数,其中包含一些日常的任务,比如把 client->buf 或者 client->reply (后面会解释为什么这里需要两个缓冲区)中的响应写回到客户端,持久化 AOF 缓冲区的数据到磁盘等,相对应的还有一个 afterSleep 函数,在 aeApiPoll 之后执行。
    • sendReplyToClient:命令回复处理器,当一次事件循环之后写出缓冲区中还有数据残留,则这个处理器会被注册绑定到相应的连接上,等连接触发写就绪事件时,它会将写出缓冲区剩余的数据回写到客户端。

    Redis 内部实现了一个高性能的事件库 — AE,基于 epoll/select/kqueue/evport 四种事件驱动技术,实现 Linux/MacOS/FreeBSD/Solaris 多平台的高性能事件循环模型。Redis 的核心网络模型正式构筑在 AE 之上,包括 I/O 多路复用、各类处理器的注册绑定,都是基于此才得以运行。

    至此,我们可以描绘出客户端向 Redis 发起请求命令的工作原理:

    1. Redis 服务器启动,开启主线程事件循环(Event Loop),注册 acceptTcpHandler 连接应答处理器到用户配置的监听端口对应的文件描述符,等待新连接到来;
    2. 客户端和服务端建立网络连接;
    3. acceptTcpHandler 被调用,主线程使用 AE 的 API 将 readQueryFromClient 命令读取处理器绑定到新连接对应的文件描述符上,并初始化一个 client 绑定这个客户端连接;
    4. 客户端发送请求命令,触发读就绪事件,主线程调用 readQueryFromClient 通过 socket 读取客户端发送过来的命令存入 client->querybuf 读入缓冲区;
    5. 接着调用 processInputBuffer,在其中使用 processInlineBuffer 或者 processMultibulkBuffer 根据 Redis 协议解析命令,最后调用 processCommand 执行命令;
    6. 根据请求命令的类型(SET, GET, DEL, EXEC 等),分配相应的命令执行器去执行,最后调用 addReply 函数族的一系列函数将响应数据写入到对应 client 的写出缓冲区:client->buf 或者 client->reply ,client->buf 是首选的写出缓冲区,固定大小 16KB,一般来说可以缓冲足够多的响应数据,但是如果客户端在时间窗口内需要响应的数据非常大,那么则会自动切换到 client->reply 链表上去,使用链表理论上能够保存无限大的数据(受限于机器的物理内存),最后把 client 添加进一个 LIFO 队列 clients_pending_write
    7. 在事件循环(Event Loop)中,主线程执行 beforeSleep --> handleClientsWithPendingWrites,遍历 clients_pending_write 队列,调用 writeToClient 把 client 的写出缓冲区里的数据回写到客户端,如果写出缓冲区还有数据遗留,则注册 sendReplyToClient 命令回复处理器到该连接的写就绪事件,等待客户端可写时在事件循环中再继续回写残余的响应数据。

    对于那些想利用多核优势提升性能的用户来说,Redis 官方给出的解决方案也非常简单粗暴:在同一个机器上多跑几个 Redis 实例。事实上,为了保证高可用,线上业务一般不太可能会是单机模式,更加常见的是利用 Redis 分布式集群多节点和数据分片负载均衡来提升性能和保证高可用。

    多线程异步任务

    以上便是 Redis 的核心网络模型,这个单线程网络模型一直到 Redis v6.0 才改造成多线程模式,但这并不意味着整个 Redis 一直都只是单线程。

    Redis 在 v4.0 版本的时候就已经引入了的多线程来做一些异步操作,此举主要针对的是那些非常耗时的命令,通过将这些命令的执行进行异步化,避免阻塞单线程的事件循环。

    我们知道 Redis 的 DEL 命令是用来删除掉一个或多个 key 储存的值,它是一个阻塞的命令,大多数情况下你要删除的 key 里存的值不会特别多,最多也就几十上百个对象,所以可以很快执行完,但是如果你要删的是一个超大的键值对,里面有几百万个对象,那么这条命令可能会阻塞至少好几秒,又因为事件循环是单线程的,所以会阻塞后面的其他事件,导致吞吐量下降。

    Redis 的作者 antirez 为了解决这个问题进行了很多思考,一开始他想的办法是一种渐进式的方案:利用定时器和数据游标,每次只删除一小部分的数据,比如 1000 个对象,最终清除掉所有的数据,但是这种方案有个致命的缺陷,如果同时还有其他客户端往某个正在被渐进式删除的 key 里继续写入数据,而且删除的速度跟不上写入的数据,那么将会无止境地消耗内存,虽然后来通过一个巧妙的办法解决了,但是这种实现使 Redis 变得更加复杂,而多线程看起来似乎是一个水到渠成的解决方案:简单、易理解。于是,最终 antirez 选择引入多线程来实现这一类非阻塞的命令。更多 antirez 在这方面的思考可以阅读一下他发表的博客:Lazy Redis is better Redis

    于是,在 Redis v4.0 之后增加了一些的非阻塞命令如 UNLINKFLUSHALL ASYNCFLUSHDB ASYNC

    UNLINK 命令其实就是 DEL 的异步版本,它不会同步删除数据,而只是把 key 从 keyspace 中暂时移除掉,然后将任务添加到一个异步队列,最后由后台线程去删除,不过这里需要考虑一种情况是如果用 UNLINK 去删除一个很小的 key,用异步的方式去做反而开销更大,所以它会先计算一个开销的阀值,只有当这个值大于 64 才会使用异步的方式去删除 key,对于基本的数据类型如 List、Set、Hash 这些,阀值就是其中存储的对象数量。

    Redis 多线程网络模型

    前面提到 Redis 最初选择单线程网络模型的理由是:CPU 通常不会成为性能瓶颈,瓶颈往往是内存网络,因此单线程足够了。那么为什么现在 Redis 又要引入多线程呢?很简单,就是 Redis 的网络 I/O 瓶颈已经越来越明显了。

    随着互联网的飞速发展,互联网业务系统所要处理的线上流量越来越大,Redis 的单线程模式会导致系统消耗很多 CPU 时间在网络 I/O 上从而降低吞吐量,要提升 Redis 的性能有两个方向:

    • 优化网络 I/O 模块
    • 提高机器内存读写的速度

    后者依赖于硬件的发展,暂时无解。所以只能从前者下手,网络 I/O 的优化又可以分为两个方向:

    • 零拷贝技术或者 DPDK 技术
    • 利用多核优势

    零拷贝技术有其局限性,无法完全适配 Redis 这一类复杂的网络 I/O 场景,更多网络 I/O 对 CPU 时间的消耗和 Linux 零拷贝技术,可以阅读我的另一篇文章:Linux I/O 原理和 Zero-copy 技术全面揭秘。而 DPDK 技术通过旁路网卡 I/O 绕过内核协议栈的方式又太过于复杂以及需要内核甚至是硬件的支持。

    因此,利用多核优势成为了优化网络 I/O 性价比最高的方案。

    6.0 版本之后,Redis 正式在核心网络模型中引入了多线程,也就是所谓的 I/O threading,至此 Redis 真正拥有了多线程模型。前一小节,我们了解了 Redis 在 6.0 版本之前的单线程事件循环模型,实际上就是一个非常经典的 Reactor 模型:

    目前 Linux 平台上主流的高性能网络库/框架中,大都采用 Reactor 模式,比如 netty、libevent、libuv、POE(Perl)、Twisted(Python)等。

    Reactor 模式本质上指的是使用 I/O 多路复用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O) 的模式。

    更多关于 Reactor 模式的细节可以参考我之前的文章:Go netpoller 原生网络模型之源码全面揭秘,Reactor 网络模型那一小节,这里不再赘述。

    Redis 的核心网络模型在 6.0 版本之前,一直是单 Reactor 模式:所有事件的处理都在单个线程内完成,虽然在 4.0 版本中引入了多线程,但是那个更像是针对特定场景(删除超大 key 值等)而打的补丁,并不能被视作核心网络模型的多线程。

    通常来说,单 Reactor 模式,引入多线程之后会进化为 Multi-Reactors 模式,基本工作模式如下:

    区别于单 Reactor 模式,这种模式不再是单线程的事件循环,而是有多个线程(Sub Reactors)各自维护一个独立的事件循环,由 Main Reactor 负责接收新连接并分发给 Sub Reactors 去独立处理,最后 Sub Reactors 回写响应给客户端。

    Multiple Reactors 模式通常也可以等同于 Master-Workers 模式,比如 Nginx 和 Memcached 等就是采用这种多线程模型,虽然不同的项目实现细节略有区别,但总体来说模式是一致的。

    设计思路

    Redis 虽然也实现了多线程,但是却不是标准的 Multi-Reactors/Master-Workers 模式,这其中的缘由我们后面会分析,现在我们先看一下 Redis 多线程网络模型的总体设计:

    1. Redis 服务器启动,开启主线程事件循环(Event Loop),注册 acceptTcpHandler 连接应答处理器到用户配置的监听端口对应的文件描述符,等待新连接到来;
    2. 客户端和服务端建立网络连接;
    3. acceptTcpHandler 被调用,主线程使用 AE 的 API 将 readQueryFromClient 命令读取处理器绑定到新连接对应的文件描述符上,并初始化一个 client 绑定这个客户端连接;
    4. 客户端发送请求命令,触发读就绪事件,服务端主线程不会通过 socket 去读取客户端的请求命令,而是先将 client 放入一个 LIFO 队列 clients_pending_read
    5. 在事件循环(Event Loop)中,主线程执行 beforeSleep -->handleClientsWithPendingReadsUsingThreads,利用 Round-Robin 轮询负载均衡策略,把 clients_pending_read队列中的连接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列 io_threads_list[id] 和主线程自己,I/O 线程通过 socket 读取客户端的请求命令,存入 client->querybuf 并解析第一个命令,但不执行命令,主线程忙轮询,等待所有 I/O 线程完成读取任务;
    6. 主线程和所有 I/O 线程都完成了读取任务,主线程结束忙轮询,遍历 clients_pending_read 队列,执行所有客户端连接的请求命令,先调用 processCommandAndResetClient 执行第一条已经解析好的命令,然后调用 processInputBuffer 解析并执行客户端连接的所有命令,在其中使用 processInlineBuffer 或者 processMultibulkBuffer 根据 Redis 协议解析命令,最后调用 processCommand 执行命令;
    7. 根据请求命令的类型(SET, GET, DEL, EXEC 等),分配相应的命令执行器去执行,最后调用 addReply 函数族的一系列函数将响应数据写入到对应 client 的写出缓冲区:client->buf 或者 client->reply ,client->buf 是首选的写出缓冲区,固定大小 16KB,一般来说可以缓冲足够多的响应数据,但是如果客户端在时间窗口内需要响应的数据非常大,那么则会自动切换到 client->reply 链表上去,使用链表理论上能够保存无限大的数据(受限于机器的物理内存),最后把 client 添加进一个 LIFO 队列 clients_pending_write
    8. 在事件循环(Event Loop)中,主线程执行 beforeSleep --> handleClientsWithPendingWritesUsingThreads,利用 Round-Robin 轮询负载均衡策略,把 clients_pending_write 队列中的连接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列 io_threads_list[id] 和主线程自己,I/O 线程通过调用 writeToClient 把 client 的写出缓冲区里的数据回写到客户端,主线程忙轮询,等待所有 I/O 线程完成写出任务;
    9. 主线程和所有 I/O 线程都完成了写出任务, 主线程结束忙轮询,遍历 clients_pending_write 队列,如果 client 的写出缓冲区还有数据遗留,则注册 sendReplyToClient 到该连接的写就绪事件,等待客户端可写时在事件循环中再继续回写残余的响应数据。

    这里大部分逻辑和之前的单线程模型是一致的,变动的地方仅仅是把读取客户端请求命令和回写响应数据的逻辑异步化了,交给 I/O 线程去完成,这里需要特别注意的一点是:I/O 线程仅仅是读取和解析客户端命令而不会真正去执行命令,客户端命令的执行最终还是要在主线程上完成

    源码剖析

    以下所有代码基于目前最新的 Redis v6.0.10 版本。

    多线程初始化

     1void initThreadedIO(void) {
     2    server.io_threads_active = 0; /* We start with threads not active. */
     3
     4    // 如果用户只配置了一个 I/O 线程,则不会创建新线程(效率低),直接在主线程里处理 I/O。
     5    if (server.io_threads_num == 1) return;
     6
     7    if (server.io_threads_num > IO_THREADS_MAX_NUM) {
     8        serverLog(LL_WARNING,"Fatal: too many I/O threads configured. "
     9                             "The maximum number is %d.", IO_THREADS_MAX_NUM);
    10        exit(1);
    11    }
    12
    13    // 根据用户配置的 I/O 线程数,启动线程。
    14    for (int i = 0; i < server.io_threads_num; i++) {
    15        // 初始化 I/O 线程的本地任务队列。
    16        io_threads_list[i] = listCreate();
    17        if (i == 0) continue; // 线程 0 是主线程。
    18
    19        // 初始化 I/O 线程并启动。
    20        pthread_t tid;
    21        // 每个 I/O 线程会分配一个本地锁,用来休眠和唤醒线程。
    22        pthread_mutex_init(&io_threads_mutex[i],NULL);
    23        // 每个 I/O 线程分配一个原子计数器,用来记录当前遗留的任务数量。
    24        io_threads_pending[i] = 0;
    25        // 主线程在启动 I/O 线程的时候会默认先锁住它,直到有 I/O 任务才唤醒它。
    26        pthread_mutex_lock(&io_threads_mutex[i]);
    27        // 启动线程,进入 I/O 线程的主逻辑函数 IOThreadMain。
    28        if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) {
    29            serverLog(LL_WARNING,"Fatal: Can't initialize IO thread.");
    30            exit(1);
    31        }
    32        io_threads[i] = tid;
    33    }
    34}
    

    initThreadedIO 会在 Redis 服务器启动时的初始化工作的末尾被调用,初始化 I/O 多线程并启动。

    Redis 的多线程模式默认是关闭的,需要用户在 redis.conf 配置文件中开启:

     
     

    1io-threads 4

     

    2io-threads-do-reads yes

    读取请求

    当客户端发送请求命令之后,会触发 Redis 主线程的事件循环,命令处理器 readQueryFromClient 被回调,在以前的单线程模型下,这个方法会直接读取解析客户端命令并执行,但是多线程模式下,则会把 client 加入到 clients_pending_read 任务队列中去,后面主线程再分配到 I/O 线程去读取客户端请求命令:

     1void readQueryFromClient(connection *conn) {
     2    client *c = connGetPrivateData(conn);
     3    int nread, readlen;
     4    size_t qblen;
     5
     6    // 检查是否开启了多线程,如果是则把 client 加入异步队列之后返回。
     7    if (postponeClientRead(c)) return;
     8    
     9    // 省略代码,下面的代码逻辑和单线程版本几乎是一样的。
    10    ... 
    11}
    12
    13int postponeClientRead(client *c) {
    14    // 当多线程 I/O 模式开启、主线程没有在处理阻塞任务时,将 client 加入异步队列。
    15    if (server.io_threads_active &&
    16        server.io_threads_do_reads &&
    17        !ProcessingEventsWhileBlocked &&
    18        !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))
    19    {
    20        // 给 client 打上 CLIENT_PENDING_READ 标识,表示该 client 需要被多线程处理,
    21        // 后续在 I/O 线程中会在读取和解析完客户端命令之后判断该标识并放弃执行命令,让主线程去执行。
    22        c->flags |= CLIENT_PENDING_READ;
    23        listAddNodeHead(server.clients_pending_read,c);
    24        return 1;
    25    } else {
    26        return 0;
    27    }
    28}
    

    接着主线程会在事件循环的 beforeSleep() 方法中,调用 handleClientsWithPendingReadsUsingThreads

     1int handleClientsWithPendingReadsUsingThreads(void) {
     2    if (!server.io_threads_active || !server.io_threads_do_reads) return 0;
     3    int processed = listLength(server.clients_pending_read);
     4    if (processed == 0) return 0;
     5
     6    if (tio_debug) printf("%d TOTAL READ pending clients\n", processed);
     7
     8    // 遍历待读取的 client 队列 clients_pending_read,
     9    // 通过 RR 轮询均匀地分配给 I/O 线程和主线程自己(编号 0)。
    10    listIter li;
    11    listNode *ln;
    12    listRewind(server.clients_pending_read,&li);
    13    int item_id = 0;
    14    while((ln = listNext(&li))) {
    15        client *c = listNodeValue(ln);
    16        int target_id = item_id % server.io_threads_num;
    17        listAddNodeTail(io_threads_list[target_id],c);
    18        item_id++;
    19    }
    20
    21    // 设置当前 I/O 操作为读取操作,给每个 I/O 线程的计数器设置分配的任务数量,
    22    // 让 I/O 线程可以开始工作:只读取和解析命令,不执行。
    23    io_threads_op = IO_THREADS_OP_READ;
    24    for (int j = 1; j < server.io_threads_num; j++) {
    25        int count = listLength(io_threads_list[j]);
    26        io_threads_pending[j] = count;
    27    }
    28
    29    // 主线程自己也会去执行读取客户端请求命令的任务,以达到最大限度利用 CPU。
    30    listRewind(io_threads_list[0],&li);
    31    while((ln = listNext(&li))) {
    32        client *c = listNodeValue(ln);
    33        readQueryFromClient(c->conn);
    34    }
    35    listEmpty(io_threads_list[0]);
    36
    37    // 忙轮询,累加所有 I/O 线程的原子任务计数器,直到所有计数器的遗留任务数量都是 0,
    38    // 表示所有任务都已经执行完成,结束轮询。
    39    while(1) {
    40        unsigned long pending = 0;
    41        for (int j = 1; j < server.io_threads_num; j++)
    42            pending += io_threads_pending[j];
    43        if (pending == 0) break;
    44    }
    45    if (tio_debug) printf("I/O READ All threads finshed\n");
    46
    47    // 遍历待读取的 client 队列,清除 CLIENT_PENDING_READ 和 CLIENT_PENDING_COMMAND 标记,
    48    // 然后解析并执行所有 client 的命令。
    49    while(listLength(server.clients_pending_read)) {
    50        ln = listFirst(server.clients_pending_read);
    51        client *c = listNodeValue(ln);
    52        c->flags &= ~CLIENT_PENDING_READ;
    53        listDelNode(server.clients_pending_read,ln);
    54
    55        if (c->flags & CLIENT_PENDING_COMMAND) {
    56            c->flags &= ~CLIENT_PENDING_COMMAND;
    57            // client 的第一条命令已经被解析好了,直接尝试执行。
    58            if (processCommandAndResetClient(c) == C_ERR) {
    59                /* If the client is no longer valid, we avoid
    60                 * processing the client later. So we just go
    61                 * to the next. */
    62                continue;
    63            }
    64        }
    65        processInputBuffer(c); // 继续解析并执行 client 命令。
    66
    67        // 命令执行完成之后,如果 client 中有响应数据需要回写到客户端,则将 client 加入到待写出队列 clients_pending_write
    68        if (!(c->flags & CLIENT_PENDING_WRITE) && clientHasPendingReplies(c))
    69            clientInstallWriteHandler(c);
    70    }
    71
    72    /* Update processed count on server */
    73    server.stat_io_reads_processed += processed;
    74
    75    return processed;
    76}
    

    这里的核心工作是:

    • 遍历待读取的 client 队列 clients_pending_read,通过 RR 策略把所有任务分配给 I/O 线程和主线程去读取和解析客户端命令。
    • 忙轮询等待所有 I/O 线程完成任务。
    • 最后再遍历 clients_pending_read,执行所有 client 的命令。

    写回响应

    完成命令的读取、解析以及执行之后,客户端命令的响应数据已经存入 client->buf 或者 client->reply 中了,接下来就需要把响应数据回写到客户端了,还是在 beforeSleep 中, 主线程调用 handleClientsWithPendingWritesUsingThreads

     1int handleClientsWithPendingWritesUsingThreads(void) {
     2    int processed = listLength(server.clients_pending_write);
     3    if (processed == 0) return 0; /* Return ASAP if there are no clients. */
     4
     5    // 如果用户设置的 I/O 线程数等于 1 或者当前 clients_pending_write 队列中待写出的 client
     6    // 数量不足 I/O 线程数的两倍,则不用多线程的逻辑,让所有 I/O 线程进入休眠,
     7    // 直接在主线程把所有 client 的相应数据回写到客户端。
     8    if (server.io_threads_num == 1 || stopThreadedIOIfNeeded()) {
     9        return handleClientsWithPendingWrites();
    10    }
    11
    12    // 唤醒正在休眠的 I/O 线程(如果有的话)。
    13    if (!server.io_threads_active) startThreadedIO();
    14
    15    if (tio_debug) printf("%d TOTAL WRITE pending clients\n", processed);
    16
    17    // 遍历待写出的 client 队列 clients_pending_write,
    18    // 通过 RR 轮询均匀地分配给 I/O 线程和主线程自己(编号 0)。
    19    listIter li;
    20    listNode *ln;
    21    listRewind(server.clients_pending_write,&li);
    22    int item_id = 0;
    23    while((ln = listNext(&li))) {
    24        client *c = listNodeValue(ln);
    25        c->flags &= ~CLIENT_PENDING_WRITE;
    26
    27        /* Remove clients from the list of pending writes since
    28         * they are going to be closed ASAP. */
    29        if (c->flags & CLIENT_CLOSE_ASAP) {
    30            listDelNode(server.clients_pending_write, ln);
    31            continue;
    32        }
    33
    34        int target_id = item_id % server.io_threads_num;
    35        listAddNodeTail(io_threads_list[target_id],c);
    36        item_id++;
    37    }
    38
    39    // 设置当前 I/O 操作为写出操作,给每个 I/O 线程的计数器设置分配的任务数量,
    40    // 让 I/O 线程可以开始工作,把写出缓冲区(client->buf 或 c->reply)中的响应数据回写到客户端。
    41    io_threads_op = IO_THREADS_OP_WRITE;
    42    for (int j = 1; j < server.io_threads_num; j++) {
    43        int count = listLength(io_threads_list[j]);
    44        io_threads_pending[j] = count;
    45    }
    46
    47    // 主线程自己也会去执行读取客户端请求命令的任务,以达到最大限度利用 CPU。
    48    listRewind(io_threads_list[0],&li);
    49    while((ln = listNext(&li))) {
    50        client *c = listNodeValue(ln);
    51        writeToClient(c,0);
    52    }
    53    listEmpty(io_threads_list[0]);
    54
    55    // 忙轮询,累加所有 I/O 线程的原子任务计数器,直到所有计数器的遗留任务数量都是 0。
    56    // 表示所有任务都已经执行完成,结束轮询。
    57    while(1) {
    58        unsigned long pending = 0;
    59        for (int j = 1; j < server.io_threads_num; j++)
    60            pending += io_threads_pending[j];
    61        if (pending == 0) break;
    62    }
    63    if (tio_debug) printf("I/O WRITE All threads finshed\n");
    64
    65    // 最后再遍历一次 clients_pending_write 队列,检查是否还有 client 的写出缓冲区中有残留数据,
    66    // 如果有,那就为 client 注册一个命令回复器 sendReplyToClient,等待客户端写就绪再继续把数据回写。
    67    listRewind(server.clients_pending_write,&li);
    68    while((ln = listNext(&li))) {
    69        client *c = listNodeValue(ln);
    70
    71        // 检查 client 的写出缓冲区是否还有遗留数据。
    72        if (clientHasPendingReplies(c) &&
    73                connSetWriteHandler(c->conn, sendReplyToClient) == AE_ERR)
    74        {
    75            freeClientAsync(c);
    76        }
    77    }
    78    listEmpty(server.clients_pending_write);
    79
    80    /* Update processed count on server */
    81    server.stat_io_writes_processed += processed;
    82
    83    return processed;
    84}
    

    这里的核心工作是:

    • 检查当前任务负载,如果当前的任务数量不足以用多线程模式处理的话,则休眠 I/O 线程并且直接同步将响应数据回写到客户端。
    • 唤醒正在休眠的 I/O 线程(如果有的话)。
    • 遍历待写出的 client 队列 clients_pending_write,通过 RR 策略把所有任务分配给 I/O 线程和主线程去将响应数据写回到客户端。
    • 忙轮询等待所有 I/O 线程完成任务。
    • 最后再遍历 clients_pending_write,为那些还残留有响应数据的 client 注册命令回复处理器 sendReplyToClient,等待客户端可写之后在事件循环中继续回写残余的响应数据。

    I/O 线程主逻辑

     1void *IOThreadMain(void *myid) {
     2    /* The ID is the thread number (from 0 to server.iothreads_num-1), and is
     3     * used by the thread to just manipulate a single sub-array of clients. */
     4    long id = (unsigned long)myid;
     5    char thdname[16];
     6
     7    snprintf(thdname, sizeof(thdname), "io_thd_%ld", id);
     8    redis_set_thread_title(thdname);
     9    // 设置 I/O 线程的 CPU 亲和性,尽可能将 I/O 线程(以及主线程,不在这里设置)绑定到用户配置的
    10    // CPU 列表上。
    11    redisSetCpuAffinity(server.server_cpulist);
    12    makeThreadKillable();
    13
    14    while(1) {
    15        // 忙轮询,100w 次循环,等待主线程分配 I/O 任务。
    16        for (int j = 0; j < 1000000; j++) {
    17            if (io_threads_pending[id] != 0) break;
    18        }
    19
    20        // 如果 100w 次忙轮询之后如果还是没有任务分配给它,则通过尝试加锁进入休眠,
    21        // 等待主线程分配任务之后调用 startThreadedIO 解锁,唤醒 I/O 线程去执行。
    22        if (io_threads_pending[id] == 0) {
    23            pthread_mutex_lock(&io_threads_mutex[id]);
    24            pthread_mutex_unlock(&io_threads_mutex[id]);
    25            continue;
    26        }
    27
    28        serverAssert(io_threads_pending[id] != 0);
    29
    30        if (tio_debug) printf("[%ld] %d to handle\n", id, (int)listLength(io_threads_list[id]));
    31
    32
    33        // 注意:主线程分配任务给 I/O 线程之时,
    34        // 会把任务加入每个线程的本地任务队列 io_threads_list[id],
    35        // 但是当 I/O 线程开始执行任务之后,主线程就不会再去访问这些任务队列,避免数据竞争。
    36        listIter li;
    37        listNode *ln;
    38        listRewind(io_threads_list[id],&li);
    39        while((ln = listNext(&li))) {
    40            client *c = listNodeValue(ln);
    41            // 如果当前是写出操作,则把 client 的写出缓冲区中的数据回写到客户端。
    42            if (io_threads_op == IO_THREADS_OP_WRITE) {
    43                writeToClient(c,0);
    44              // 如果当前是读取操作,则socket 读取客户端的请求命令并解析第一条命令。
    45            } else if (io_threads_op == IO_THREADS_OP_READ) {
    46                readQueryFromClient(c->conn);
    47            } else {
    48                serverPanic("io_threads_op value is unknown");
    49            }
    50        }
    51        listEmpty(io_threads_list[id]);
    52        // 所有任务执行完之后把自己的计数器置 0,主线程通过累加所有 I/O 线程的计数器
    53        // 判断是否所有 I/O 线程都已经完成工作。
    54        io_threads_pending[id] = 0;
    55
    56        if (tio_debug) printf("[%ld] Done\n", id);
    57    }
    58}
    

    I/O 线程启动之后,会先进入忙轮询,判断原子计数器中的任务数量,如果是非 0 则表示主线程已经给它分配了任务,开始执行任务,否则就一直忙轮询一百万次等待,忙轮询结束之后再查看计数器,如果还是 0,则尝试加本地锁,因为主线程在启动 I/O 线程之时就已经提前锁住了所有 I/O 线程的本地锁,因此 I/O 线程会进行休眠,等待主线程唤醒。

    主线程会在每次事件循环中尝试调用 startThreadedIO 唤醒 I/O 线程去执行任务,如果接收到客户端请求命令,则 I/O 线程会被唤醒开始工作,根据主线程设置的 io_threads_op 标识去执行命令读取和解析或者回写响应数据的任务,I/O 线程在收到主线程通知之后,会遍历自己的本地任务队列 io_threads_list[id],取出一个个 client 执行任务:

    • 如果当前是写出操作,则调用 writeToClient,通过 socket 把 client->buf 或者 client->reply 里的响应数据回写到客户端。
    • 如果当前是读取操作,则调用 readQueryFromClient,通过 socket 读取客户端命令,存入 client->querybuf,然后调用 processInputBuffer 去解析命令,这里最终只会解析到第一条命令,然后就结束,不会去执行命令。
    • 在全部任务执行完之后把自己的原子计数器置 0,以告知主线程自己已经完成了工作。
     1void processInputBuffer(client *c) {
     2// 省略代码
     3...
     4
     5    while(c->qb_pos < sdslen(c->querybuf)) {
     6        /* Return if clients are paused. */
     7        if (!(c->flags & CLIENT_SLAVE) && clientsArePaused()) break;
     8
     9        /* Immediately abort if the client is in the middle of something. */
    10        if (c->flags & CLIENT_BLOCKED) break;
    11
    12        /* Don't process more buffers from clients that have already pending
    13         * commands to execute in c->argv. */
    14        if (c->flags & CLIENT_PENDING_COMMAND) break;
    15        /* Multibulk processing could see a <= 0 length. */
    16        if (c->argc == 0) {
    17            resetClient(c);
    18        } else {
    19            // 判断 client 是否具有 CLIENT_PENDING_READ 标识,如果是处于多线程 I/O 的模式下,
    20            // 那么此前已经在 readQueryFromClient -> postponeClientRead 中为 client 打上该标识,
    21            // 则立刻跳出循环结束,此时第一条命令已经解析完成,但是不执行命令。
    22            if (c->flags & CLIENT_PENDING_READ) {
    23                c->flags |= CLIENT_PENDING_COMMAND;
    24                break;
    25            }
    26
    27            // 执行客户端命令
    28            if (processCommandAndResetClient(c) == C_ERR) {
    29                /* If the client is no longer valid, we avoid exiting this
    30                 * loop and trimming the client buffer later. So we return
    31                 * ASAP in that case. */
    32                return;
    33            }
    34        }
    35    }
    36
    37...
    38}
    

    这里需要额外关注 I/O 线程初次启动时会设置当前线程的 CPU 亲和性,也就是绑定当前线程到用户配置的 CPU 上,在启动 Redis 服务器主线程的时候同样会设置 CPU 亲和性,Redis 的核心网络模型引入多线程之后,加上之前的多线程异步任务、多进程(BGSAVE、AOF、BIO、Sentinel 脚本任务等),Redis 现如今的系统并发度已经很大了,而 Redis 本身又是一个对吞吐量和延迟极度敏感的系统,所以用户需要 Redis 对 CPU 资源有更细粒度的控制,这里主要考虑的是两方面:CPU 高速缓存和 NUMA 架构。

    首先是 CPU 高速缓存(这里讨论的是 L1 Cache 和 L2 Cache 都集成在 CPU 中的硬件架构),这里想象一种场景:Redis 主进程正在 CPU-1 上运行,给客户端提供数据服务,此时 Redis 启动了子进程进行数据持久化(BGSAVE 或者 AOF),系统调度之后子进程抢占了主进程的 CPU-1,主进程被调度到 CPU-2 上去运行,导致之前 CPU-1 的高速缓存里的相关指令和数据被汰换掉,CPU-2 需要重新加载指令和数据到自己的本地高速缓存里,浪费 CPU 资源,降低性能。

    因此,Redis 通过设置 CPU 亲和性,可以将主进程/线程和子进程/线程绑定到不同的核隔离开来,使之互不干扰,能有效地提升系统性能。

    其次是基于 NUMA 架构的考虑,在 NUMA 体系下,内存控制器芯片被集成到处理器内部,形成 CPU 本地内存,访问本地内存只需通过内存通道而无需经过系统总线,访问时延大大降低,而多个处理器之间通过 QPI 数据链路互联,跨 NUMA 节点的内存访问开销远大于本地内存的访问:

    因此,Redis 通过设置 CPU 亲和性,让主进程/线程尽可能在固定的 NUMA 节点上的 CPU 上运行,更多地使用本地内存而不需要跨节点访问数据,同样也能大大地提升性能。

    关于 NUMA 相关知识请读者自行查阅,篇幅所限这里就不再展开,以后有时间我再单独写一篇文章介绍。

    最后还有一点,阅读过源码的读者可能会有疑问,Redis 的多线程模式下,似乎并没有对数据进行锁保护,事实上 Redis 的多线程模型是全程无锁(Lock-free)的,这是通过原子操作+交错访问来实现的,主线程和 I/O 线程之间共享的变量有三个:io_threads_pending 计数器、io_threads_op I/O 标识符和 io_threads_list 线程本地任务队列。

    io_threads_pending 是原子变量,不需要加锁保护,io_threads_op 和 io_threads_list 这两个变量则是通过控制主线程和 I/O 线程交错访问来规避共享数据竞争问题:I/O 线程启动之后会通过忙轮询和锁休眠等待主线程的信号,在这之前它不会去访问自己的本地任务队列 io_threads_list[id],而主线程会在分配完所有任务到各个 I/O 线程的本地队列之后才去唤醒 I/O 线程开始工作,并且主线程之后在 I/O 线程运行期间只会访问自己的本地任务队列 io_threads_list[0] 而不会再去访问 I/O 线程的本地队列,这也就保证了主线程永远会在 I/O 线程之前访问 io_threads_list 并且之后不再访问,保证了交错访问。io_threads_op 同理,主线程会在唤醒 I/O 线程之前先设置好 io_threads_op 的值,并且在 I/O 线程运行期间不会再去访问这个变量。

    性能提升

    Redis 将核心网络模型改造成多线程模式追求的当然是最终性能上的提升,所以最终还是要以 benchmark 数据见真章:

    测试数据表明,Redis 在使用多线程模式之后性能大幅提升,达到了一倍。更详细的性能压测数据可以参阅这篇文章:Benchmarking the experimental Redis Multi-Threaded I/O

    以下是美图技术团队实测的新旧 Redis 版本性能对比图,仅供参考:

    模型缺陷

    首先第一个就是我前面提到过的,Redis 的多线程网络模型实际上并不是一个标准的 Multi-Reactors/Master-Workers 模型,和其他主流的开源网络服务器的模式有所区别,最大的不同就是在标准的 Multi-Reactors/Master-Workers 模式下,Sub Reactors/Workers 会完成 网络读 -> 数据解析 -> 命令执行 -> 网络写 整套流程,Main Reactor/Master 只负责分派任务,而在 Redis 的多线程方案中,I/O 线程任务仅仅是通过 socket 读取客户端请求命令并解析,却没有真正去执行命令,所有客户端命令最后还需要回到主线程去执行,因此对多核的利用率并不算高,而且每次主线程都必须在分配完任务之后忙轮询等待所有 I/O 线程完成任务之后才能继续执行其他逻辑。

    Redis 之所以如此设计它的多线程网络模型,我认为主要的原因是为了保持兼容性,因为以前 Redis 是单线程的,所有的客户端命令都是在单线程的事件循环里执行的,也因此 Redis 里所有的数据结构都是非线程安全的,现在引入多线程,如果按照标准的 Multi-Reactors/Master-Workers 模式来实现,则所有内置的数据结构都必须重构成线程安全的,这个工作量无疑是巨大且麻烦的。

    所以,在我看来,Redis 目前的多线程方案更像是一个折中的选择:既保持了原系统的兼容性,又能利用多核提升 I/O 性能。

    其次,目前 Redis 的多线程模型中,主线程和 I/O 线程的通信过于简单粗暴:忙轮询和锁,因为通过自旋忙轮询进行等待,导致 Redis 在启动的时候以及运行期间偶尔会有短暂的 CPU 空转引起的高占用率,而且这个通信机制的最终实现看起来非常不直观和不简洁,希望后面 Redis 能对目前的方案加以改进。

    总结

    Redis 作为缓存系统的事实标准,它的底层原理值得开发者去深入学习,Redis 自 2009 年发布第一版之后,其单线程网络模型的选择在社区中从未停止过讨论,多年来一直有呼声希望 Redis 能引入多线程从而利用多核优势,但是作者 antirez 是一个追求大道至简的开发者,对 Redis 加入任何新功能都异常谨慎,所以在 Redis 初版发布的十年后才最终将 Redis 的核心网络模型改造成多线程模式,这期间甚至诞生了一些 Redis 多线程的替代项目。虽然 antirez 一直在推迟多线程的方案,但却从未停止思考多线程的可行性,Redis 多线程网络模型的改造不是一朝一夕的事情,这其中牵扯到项目的方方面面,所以我们可以看到 Redis 的最终方案也并不完美,没有采用主流的多线程模式设计。

    让我们来回顾一下 Redis 多线程网络模型的设计方案:

    • 使用 I/O 线程实现网络 I/O 多线程化,I/O 线程只负责网络 I/O 和命令解析,不执行客户端命令。
    • 利用原子操作+交错访问实现无锁的多线程模型。
    • 通过设置 CPU 亲和性,隔离主进程和其他子进程,让多线程网络模型能发挥最大的性能。

    通读本文之后,相信读者们应该能够了解到一个优秀的网络系统的实现所涉及到的计算机领域的各种技术:设计模式、网络 I/O、并发编程、操作系统底层,甚至是计算机硬件。另外还需要对项目迭代和重构的谨慎,对技术方案的深入思考,绝不仅仅是写好代码这一个难点。

    博客原文

    Redis 多线程网络模型全面揭秘

    展开全文
  • 本文转载自 AI公园。作者:Zeyuan Allen-Zhu编译:ronghuaiyang导读训练好的模型,用自己蒸馏一下,就能提高性能,是不是很神奇,这里面的原理到底是什么呢,这要从模...
  • 在线上推荐预测任务时往往需要预测用户的多个行为,如关注、点赞、停留时间等,从而调整策略进行权衡。其中涉及到多任务学习,本篇将会大概整理一些常用的模型如MMoE, ESMM, SNR方便理解与学习。 MMoE 背景与动机 在...
  • 文章目录1 联邦学习背景2 联邦学习树模型方案3 Ensemble Learning3.1 集成学习3.2 Bagging & Boosting3.2.1 Bagging (bootstrap aggregating)3.2.2 Boosting3.2.3 Bagging,Boosting二者之间的区别4 GBDT4.1 ...
  • springboot 整合 apache camel实现企业级数据集成和处理

    千次阅读 多人点赞 2021-06-29 19:47:20
    Apache Camel是一个集成框架,它具有用于集成各种应用程序的编程模型。 对于需要在不同的微服务和其他上下游系统(如数据库和消息传递系统)之间进行通信的微服务体系结构,它非常适合。 在本文中,我们将在代码示例的...
  • 基因调控网络及其模型概述基因调控网络模型布尔网络模型有向图模型线性组合模型加权矩阵模型互信息关联模型常微分方程模型基因调控机制基序和模块基因调控网络数据库 概述 基因调控网络是21世纪生物学研究的一重要...
  • 点击上方“AI公园”,关注公众号,选择加“星标“或“置顶”作者:Zeyuan Allen-Zhu编译:ronghuaiyang导读训练好的模型,用自己蒸馏一下,就能提高性能,是不是很神奇...
  • 在深度学习中使用Bagging集成模型

    千次阅读 2021-08-15 09:55:00
    集成是一种机器学习概念,使用相同的学习算法训练多个模型。Bagging是一种减少预测方差的方法,通过使用重复组合生成多组原始数据,从数据集生成额外的训练数据。Boosting 是一种基于最后分类调整观测值权重的迭代...
  • 对于单个模型来说很难拟合复杂的数据,而且对于单模型来说,模型的抗干扰能力较低,所以我们希望可以集成多个模型,结合多个模型的优缺点提高模型的泛化能力。 针对于集成学习一般有两种方式,第一种为Boosti.
  • 将多个个体学习器按一定策略结合成一个...bagging(代表:随机森林 RF):多个模型的结果进行投票亦或求取均值作为最终的输出 boosting(提升。代表:梯度提升树 GB) 不太主流 stacking(堆叠) blending (混合) ...
  • 数仓建模—数据集成

    千次阅读 2021-09-04 10:15:20
    其实数据集成是数仓的一基本特点,这里我们再回顾一下数仓的特性,或者说是我们再回顾一下数仓的定义,面向主题的(Subject Oriented)、集成的(Integrate)、相对稳定的(Non-Volatile)、反映历史变化(Time ...
  • 如何堆叠多层LSTM网络——长短期记忆(LSTM)系列_LSTM的建模方法(2)发布时间:2018-12-07 15:15,浏览次数:1362, 标签:LSTM导读:堆叠式LSTM属于深度学习,通过添加网络的深度,提高训练的效率,获得更高的准确性。...
  • 【转自:dynamic-classifier-selection-in-python】 ... 该技术涉及在训练数据集上拟合多个机器学习模型,然后基于要预测的示例的特定细节,选择在进行预测时预期表现最佳的模型。 这可以通过以下方法实现:...
  • 目录:手动实现前馈神经网络、Dropout、正则化、K折交叉验证,解决分类、二分类、回归任务一、实验内容手动实现前馈神经网络完成回归、二分类、分类任务利用torch.nn实现前馈神经网络完成回归、二分类、分类...
  • 集成学习算法的基本思想是,通过将多个学习器组合,从而实现一个效果更好的集成学习器。 集成学习的优点可以从三个角度出发进行分析,统计上,单一的学习算法可以理解为在一个假设空间中找到一个最好的假设,但是...
  • 完全硬件实现的忆阻器卷积神经网络 原文标题:Fully hardware-implemented memristor convolutional neural network 作者:Peng Yao.et al. 期刊:Nature 这篇是2020年初清华大学发表在nature上的文章,当时就看见很...
  • 讲bagging前就应该先谈到投票法,投票法是一种遵循少数服从多数原则的集成学习模型,通过多个模型集成降低方差,从而提高模型的鲁棒性。举个例子,在预测泰坦尼克号的生死问题上,综合了logistics、Knn和决策树三...
  • 文章目录前言描述1.1几句话描述该模型1.2搭建步骤感知神经网络搭建2.1语法:2.2详细说明 前言描述 1.1几句话描述该模型 神经网络有什么用? 你就当作该算法模型就是把东西给优化变得更好。 我实在不想在这里介绍概念...
  • 点击上方“CVer”,选择加"星标"置顶重磅干货,第一时间送达本文转载自:AI公园作者:Vikas S Shetty |编译:ronghuaiyang导读模型集成是...
  • karate_club_graph 是 networkx 模块集成的一数据集,这是社会网络分析领域中的经典数据集,20世纪70年代,社会学家 Zachary 用了两年的时间来观察美国一所大学中空手道俱乐部34名成员之间的社会关系。...
  • 第4章 Faster R-CNN网络模型设计 4.1 Faster RCN文件结构介绍 本文在介绍具体的训练模型之前,先介绍Faster RCNN的文件结构。如图4.1所示,主要包括了7文件夹和4文件。主要的文件夹模块为:caffe-fast-rcnn,...
  • Java网络聊天室系统的设计与实现

    千次阅读 2021-06-09 10:27:24
    摘要:计算机现在对我们的生活而言十分重要,不管是生活还是工作都需要用到计算机,可想而知,未来的人类会十分依赖计算机技术,用计算机上网的人毋庸置疑会越来越,可想而知,不断会有人来改进和完善物联网技术,...
  • 字段like多个条件(or关系)简写

    千次阅读 2021-01-27 07:33:17
    oracle字段like多个条件 写oracle sql时有时候会有 and (字段 like ‘匹配串1’or 字段 like ‘匹配串2’or ...)这样的情况出现,下面提供一个简洁点的解决方案: and REGEXP_LIKE ... 随机推荐 HTTP 学习 *** *** ...
  • 4. WEB组态视图建模集成3D模型... 3 5. 其他更新内容... 4 1. 概述 iNeuOS工业互联网操作系统,此次升级主要针对两方面:(1)重新梳理系统的理念和应用流程,增加了业务模型;(2)iNeuView集成数字孪生3D模型...
  • RandomForest 随机森林在上一篇博文 “集成算法— 简介 + 决策树”中,简要介绍了集成算法的3种分类:Boosting、Bagging、Stacking以及它们经常使用的弱分类器—决策树(分类树和回归树)。集成算法可分为序列集成法和...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 365,590
精华内容 146,236
关键字:

多个网络模型怎么实现集成