对B站故障的其他思考
7月B站公布了对去年一起故障的全面复盘,《2021.07.13 我们是这样崩的》。文章记录了故障的详细处理过程和崩溃的原因。
正好今年在对envoy的扩展中也进行过实践。虽然引发故障的代码是只有7行的计算最大公约数的函数,但这7行代码并不简单,故障原因B站已经分析清楚,这里聊点文章中没有提到的内容。
为什么gcd函数可以得到最大公约数(greatest common divisor)
最大公约数,简称gcd,是指可以同时整除两个正整数的最大整数。广为流传的计算方式叫做辗转相除法,最早提出辗转相除法的是2000多年前的欧几里得,所以也叫欧几里得算法。欧几里得在他的著作《几何原本》第七卷中介绍了这种算法,可能那时候还没有divisor这个词,书中用的是“greatest common measure”来表示最大公约数。
在先前的文章100行代码实现加权负载均衡算法(WRR)中,有用到过辗转相除计算最大公约数。由于这个代码太短太好copy了,以至于一直以来没有思考过它为什么奏效。所以就查阅了一些资料,尝试从原理上理解一下。下面是一些小学数学级别的推导:
- 如果一个整数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}} a≡bmodm,是余数为0的特殊情况)
- 如果我们设 a > b a>b a>b,那么容易想到, a − b a-b a−b也能被m整除,因为 a − b = ( k 1 − k 2 ) × m a-b=(k_1-k_2)\times{m} a−b=(k1−k2)×m,当然假设 a < b a<b a<b也是一样的。这样问题就变成了求b和a-b的最大公约数。
- 这样始终递减下去,最终总会有一次, a − b = 0 a-b=0 a−b=0(注意不会出现 a − b a-b a−b为负数的情况,如果为负数,那就改为 b − a b-a b−a,总之始终用大的减去小的)。我们都知道正整数的最大公约数最小只能为1,那就说明此时a和b相等且都等于最大公约数。
这个计算过程对不对呢?确实是没问题的,但是太慢了,试想计算62748517和182的gcd:
- 用62748517减去344772次的182后,得到13
- 再用较大182减去14次13得到 a − b = 0 a-b=0 a−b=0的结束条件
- 最终得到gcd为13
总共做了344786次减法,实在有些离谱,能否优化一下呢?
回到上面的第二步,如果 a − b a-b a−b可以被m整除,那么 a − 2 b a-2b a−2b显然也可以,事实上,我们只要确保 a − n × b ≥ 0 a-n\times{b}\ge{0} a−n×b≥0,想怎么减就怎么减,那这看起来像是什么呢?没错就是 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,实际上就是栈溢出了。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· C#/.NET/.NET Core技术前沿周刊 | 第 29 期(2025年3.1-3.9)
· 从HTTP原因短语缺失研究HTTP/2和HTTP/3的设计差异