对B站故障的其他思考

7月B站公布了对去年一起故障的全面复盘,《2021.07.13 我们是这样崩的》。文章记录了故障的详细处理过程和崩溃的原因。

正好今年在对envoy的扩展中也进行过实践。虽然引发故障的代码是只有7行的计算最大公约数的函数,但这7行代码并不简单,故障原因B站已经分析清楚,这里聊点文章中没有提到的内容。

为什么gcd函数可以得到最大公约数(greatest common divisor)

最大公约数,简称gcd,是指可以同时整除两个正整数的最大整数。广为流传的计算方式叫做辗转相除法,最早提出辗转相除法的是2000多年前的欧几里得,所以也叫欧几里得算法。欧几里得在他的著作《几何原本》第七卷中介绍了这种算法,可能那时候还没有divisor这个词,书中用的是“greatest common measure”来表示最大公约数。

在先前的文章100行代码实现加权负载均衡算法(WRR)中,有用到过辗转相除计算最大公约数。由于这个代码太短太好copy了,以至于一直以来没有思考过它为什么奏效。所以就查阅了一些资料,尝试从原理上理解一下。下面是一些小学数学级别的推导:

  1. 如果一个整数m可以整除整数a,那么可以表达为 a = k 1 × m a=k_1\times{m} a=k1×m,其中k_1是一个正整数,如果恰好m又可以整除b,那么表示为 b = k 2 × m b=k_2\times{m} b=k2×m,同理 k 2 k_2 k2也是正整数。(这种情况称为同余,记作 a ≡ b m o d    m a\equiv{b\mod{m}} abmodm,是余数为0的特殊情况)
  2. 如果我们设 a > b a>b a>b,那么容易想到, a − b a-b ab也能被m整除,因为 a − b = ( k 1 − k 2 ) × m a-b=(k_1-k_2)\times{m} ab=(k1k2)×m,当然假设 a < b a<b a<b也是一样的。这样问题就变成了求b和a-b的最大公约数。
  3. 这样始终递减下去,最终总会有一次, a − b = 0 a-b=0 ab=0(注意不会出现 a − b a-b ab为负数的情况,如果为负数,那就改为 b − a b-a ba,总之始终用大的减去小的)。我们都知道正整数的最大公约数最小只能为1,那就说明此时a和b相等且都等于最大公约数。

这个计算过程对不对呢?确实是没问题的,但是太慢了,试想计算62748517和182的gcd:

  • 用62748517减去344772次的182后,得到13
  • 再用较大182减去14次13得到 a − b = 0 a-b=0 ab=0的结束条件
  • 最终得到gcd为13

总共做了344786次减法,实在有些离谱,能否优化一下呢?

回到上面的第二步,如果 a − b a-b ab可以被m整除,那么 a − 2 b a-2b a2b显然也可以,事实上,我们只要确保 a − n × b ≥ 0 a-n\times{b}\ge{0} an×b0,想怎么减就怎么减,那这看起来像是什么呢?没错就是 a m o d    b a\mod{b} amodb,也就是我们代码中的a%b。

再看刚才的62748517和182的gcd计算,一个模运算,就可以得到13,再计算 182 m o d    13 = 0 182\mod{13}=0 182mod13=0,得到m为13。只需要两次运算!

此外,代码中并没有判断两个数字大小的逻辑,是因为当一个小数除以一个大数取余时,余数就是小数自身,所以代码中并不需要判断a、b哪个大哪个小,模运算会帮助我们颠倒成正确的顺序进行计算。

鼻祖就是鼻祖,2000多年前的算法,现在还在用。所以在很多离散数学的教材上也会有这个推理的过程。

为什么在B站的案例中没有stack溢出

我们知道通常递归函数在限制递归深度的情况下,会出现栈溢出,B站的故障中gcd的递归却一直没有溢出,这是为什么呢?

你或者听说过尾调用,或许也写过尾递归,但你不一定听过尾调用消除(tail call elimination)。要了解尾调用消除,首先要定义什么是尾调用。在《lua程序设计》一书中,根据lua的作者的定义,只有return func(args)这种形式的调用,才被称为尾调用。

尾调用示例1

local function _gcd(m, n)
    if n == 0 then
        return m
    end
    return _gcd(n, m%n) -- 不会报错
end

尾调用示例2

local function foo(n)
    n = n - 1
    if n <= 0 then
        return -1
    end
    return foo(n) -- 不会报错
end

尾调用反例1

local function foo(n)
    n = n - 1
    if n <= 0 then
        return -1
    end
    return foo(n)+1 -- stack overflow
end

尾调用反例2

local function foo(n)
    n = n - 1
    if n <= 0 then
        return
    end
    foo(n) -- stack overflow
end

当然这并不意味着return func(args)必须出现在调用者代码的最后一行,比如下面的写法,也符合尾调用消除的规范。

local function foo(n)
    n = n - 1
    if n > 0 then
        return foo(n) -- 不会报错
    end
    return -1
end

一般我们用caller表示发起调用的函数,callee表示被调用函数,下面来解释一下尾调用消除的原理。

正常的函数调用,caller的栈帧会被保留,callee会在栈顶新开辟空间,来保存自己的局部变量,并在callee返回之后,caller继续执行自己的代码。但如果callee是caller的最后一个CALL指令触发的,那么caller的职责其实已经完成了,callee返回就意味着caller返回。所以当callee被调用时,caller的栈帧将被callee覆盖,这个操作就叫尾调用消除。

正是因为尾调用栈不会增长,所以B站的故障中,没有报stack overflow,而是在gcd函数中将cpu打倒了100%。

尾调用消除一般会出现在类似lua、js这种动态类型语言中,而静态类型语言中则几乎没有这种功能,比如说下面的c语言代码

#include "stdio.h"
int foo(int n)
{
    n = n -1;
    printf("n=%d\n", n);
    if(n==0){
	return -1;
    }
    return foo(n);
}
int main()
{
    // 55f02c53-c0e2-4c96-b4cb-7284e7cdc6b3
    foo(10000000);
}

同样的逻辑,在lua中你可以得到-1的结果,在c语言中你会得到Segment fault 11,实际上就是栈溢出了。

posted @   一只coding猪  阅读(61)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· C#/.NET/.NET Core技术前沿周刊 | 第 29 期(2025年3.1-3.9)
· 从HTTP原因短语缺失研究HTTP/2和HTTP/3的设计差异
点击右上角即可分享
微信分享提示