看到 plantegg 大佬的文章 MySQL线程池导致的延时卡顿排查中提到 MySQL 线程池中 oversubscribe 的行为。也看到有小伙伴wych42在尝试复现文章里提到的现象。
自己也尝试复现(基于 Percona 8.0.29-21),但现象和 wych42 的结果有细节上的差异,引申发现自己对 Percona 线程池模型的理解有问题,记录一下。
我们知道 oversubscribe 是用来限制 thread group 内同时运行的线程数量的,于是猜想工作原理(类比 Java 中的 ThreadPoolExecutor
,oversubscribe 类比corePoolSize
):
仿照在 wych42 的 执行方案一中,设置如下:
| thread_handling | pool-of-threads | |
select sleep(2)
8
按前一节的假想模型,预期 thread group 每次有 2 个线程执行(1+oversubscribe),结果是每批次 2 个 SQL 输出,耗时分别是 2s, 4s, 6s, 8s。
实测发现并不总是符合预期,各种情况都有,比如会有两批次就跑完的(4 个SQL 2s 加上2 个 SQL 4s):
16:05:08.765+08:00: thread 0 iteration 1 start |
这说明要么是我们的假想模型有问题,要么是 Percona 实现有 BUG。那实际情况是怎么样的呢?拉代码看半天也看不出所以然,只能自行编译并加了很多 debug 日志,大概是明白了。
这里只说明 thread group 内的机制(不考虑全局的限制)。
thread_pool_idle_timeout
)后会退出有几个推论:
2*thread_pool_stall_limit
。结合更新后的模型以及实际的 debug 日志,差异一解释如下:
SELECT SLEEP
语句在执行时,线程会变成 waiting 状态已知锁表能让事务成为高优,我们把负载改成两句 SQL:
LOCK TABLES t? READ ; 这里每个线程锁不同的表 |
测试的结果如下,可以看到在第一个迭代中创建了 N 个 worker,第二个迭代中每个worker 都实际执行了任务,因此结果只有一个批次,都是 2s 左右。
11:47:36.251+08:00: thread 0 iteration 0 start |
通过源码我们知道执行 SLEEP 的线程是 waiting 状态,会绕过某些 oversubscribe 的限制。我们尝试使用下面的负载:
MySQL> select benchmark(9999999, md5('when will it end?')); |
测试的结果就更看不出“批次”的模式了。当然由于多个任务并行执行,实际的耗时也增加了(3.8s)。
16:58:01.905+08:00: thread 2 iteration 0 start |
但是,我们预期仍是一个批次执行两个 SQL,为什么第二个请求 4774ms
才返回?下面我们看看在 Percona 中增加的 debug 信息,来了解内部工作的机制
time thread id message |
>= 1+oversubscribed=2
),触发了限制,因此从队列中获取不到任务。active+waiting = 2 <= 1+oversubscribed=2
),也不受规则 4.2 限制,于是获取任务并执行。看到 ⑥ 中执行命令,此时距离接受到命令过去了 1s+,也因此整个请求是 4s+。这个例子给出两个信息:
Percona 实际的线程模型显然没有我们假想模型简单,那能不能简化呢?
例如为什么不用全职 listener,listener 完成不参与执行任务?这是因为直接让listener 处理任务效率更高,listener 刚从等待网络中被唤醒,不需要从再唤醒一个worker,减少线程切换。但 listener 擅离职守会造成后续任务的延时,因此 listener一方面只在当前任务队列为空时才转为 worker,另一方面有定时的check_stall 线程来保底。但如差异二中看到的,还是会造成任务执行的延时[2]。
再例如能不能只用一个 queue?早期的实现其实就没有区分高优低优队列,Percona 后来实现优先队列是为了缩短服务端内部的 XA 事务[3]。对表锁的高优操作也是后来才添加的。
还有为什么不在创建线程时就限制总数不能超过 oversubscribed?(以下是猜想)oversubscribe 从设计来看不应该是一个硬限制,它要达到的目的是在全局限制线程数的前提下,防止某个 thread group 疯狂创建吃掉所有限额,造成其它 group 创建不了线程的情况。但是适当允许某个 group 创建超过 oversubscribe 的线程数是有助于提高整体效率的。而且绝对限死线程数也更可能造成 group 内的死锁,保持弹性能应对更多异常的情况。
另外关于 Percona 线程机制的描述一搜一大把,可以结合本文案例理解。
参考 connection_is_high_prio中定义了各种条件。除了要满足条件,Percona 会给每个 connection 发放 N 个高优的 ticket,只有ticket 有剩余,其中的 SQL 才会认为是高优。另外除了默认的transactions 模式,还有 statement 模式,则每个 statement 都认为是高优。 ↩
参考原代码的注释 https://github.com/percona/percona-server/blob/8.0/sql/threadpool_unix.cc#L656 ↩
这个 commit提到目标是 “minimize the number of open transactions in the server”,结合代码看到 open transactions 指的是 XA 的事务。 ↩
你没有看错,Einstein notation是那位著名的爱因斯坦发明的,用来对线性代数中求和的表示做的约定。我们还是看个例子[1]:
$$y = \sum_{i=1}^{3}{c_i x_i} = c_1 x_1 + c_2 x_2 + c_3 x_3$$
如果一个下标(如 $i$)在公式中出现两次,则隐式地认为需要遍历它的所有可能性。上面公式可以简化成:
$$y = c_i x_i$$
于是矩阵乘法中,每个输出元素可以这样表示:
$$c_{ij} = \sum_{k}{a_{ik} b_{kj}} \implies c_{ik} = a_{ik} b_{kj}$$
在 Numpy和 Pytorch 中都实现了类似的机制。einsum
函数的第一个参数就是把上节公式中的各个下标按a,b->c
的格式写下来:
代码实例如下:
In [6]: a = np.asarray(range(1,9)).reshape(2,4) |
例如在 CNN 求卷积时,输入是 (n, c, ih, iw)
的矩阵,卷积权重是 (C, c, h, w)
(这里大写字母代表输出维度)。可以把原图像按卷积大小的各个子图求出,得到 (n, c, H, W, h, w)
的输入矩阵,于是可以使用 einsum 直接求结果:
def conv2d(x, weight, stride=(1,1), padding=(0,0)): |
看到 ncHWhw,Cchw->nCHW
的输入中,c, h, w
下标是重复的,按约定要遍历所有三个下标的元素相乘,要是裸写代码的话,类似下面这样:
n,c,H,W,h,w = submatrix.shape |
一般比裸写 for 循环是要快不少的(比如上面的卷积,比我自己裸写的快 3x~5x)。但比专门优化的肯定还是不能比的(pytorch 的 conv2d 是用 C++ 专门优化的,比相同的einsum 快 10x)。
另外 这篇文章 建议无脑开 Numpy 中的optimize
参数。
wiki 中 $x_i$ 表示成 $x^i$,我们这里还是以普通人视角来看 ↩
如果有函数 $f(x_1, \cdots, x_n)$,我们要使用链式法则计算函数 $f$ 对所有输入$x_i$ 的偏导。我们记中间函数为 $v$,记 $\bar{v_i} = \frac{\partial f}{\partialv_i}$,则最核心的计算公式为:
$$\bar{v_i} = \frac{\partial f}{\partial v_i}= \sum_{j \in next(i)}{\overline{v_{i\to j}}}= \sum_{j \in next(i)}{\frac{\partial f}{\partial v_{j}} \frac{\partial v_{j}}{\partial v_i}= \sum_{j \in next(i)}{\overline{v_j} \frac{\partial v_{j}}{\partial v_i}}}$$
大家可以配合算法篇的图来理解:
首先需要允许用户构建计算图,很自然地关心 3 个部分:
sin
这样的函数,我们把它叫作算子(operator)。在 AD 的场景下,算子既要关心前向计算,也需要关心后向求导要注意的是为了符合用户的使用习惯,我们并不是要求用户直接给出一个“节点” List,再给出一个“边” List。计算图是隐式构建的。因此实际上是 数据 --(来源)--> 节点 --(输入)--> 数据
这样的引用关系。
计算图构建好之后,需要有遍历引擎,按拓扑排序顺序,正向地、逆向地遍历所有节点,正向计算输出,逆向计算偏导。这里的执行引擎其实有很多可以优化的空间,比如多线程计算,多节点合并计算等,但本文里就是简单地走流程。
最终希望怎么使用呢?
x1 = Tensor(np.array([0.5]), requires_grad=True) |
我们用张量 Tensor
来定义数据部分。代码如下:
class Tensor(object): |
① 中使用 numpy.ndarray
保存前向数据,直接使用 numpy 来减少复杂度,毕竟我们只关心 AD 部分
③ 的 grad_fn
可以理解成保存的是 Tensor
的来源算子。实际上当 Tensor 生成时,对应的数据就计算完成了,记录它的来源也没有意义,但由于后续还要反向计算偏导,才需要记录来源来反查。因此只有在 ② requires_grad = True
时才有记录的必要。
④ 的 grad
就是偏导的结果,即 $\bar{v_i}$ 的值。
首先算子既需要管前向计算,也需要关心后向求导,于是框架性的定义如下:
# 注意 Operator 里计算的都是 Tensor 内部的数据,即 NDArray |
forward
代表前向计算,可以有多个输入。backward
则相反,给定输出的偏导,需要为每个输入输出一个偏导。即如果 $op = f(x, y)$,则 forward
输出的是$f(x, y)$ 的值,而 backward
输出为 $[\frac{\partial op}{\partial x}, \frac{\partial op}{\partial y}]$
但仅有两个计算方法是不够的,在 forward
计算时,算子还需要维护“边”的信息,在后向计算偏导时使用。①中的 next_ops
就是用来计算边的信息的,例如样例代码中,执行完 v5 = add(v3, v4)
后,内部信息如下图:
但我们不希望建图的操作在每个算子中都实现一遍,因此我们在父类上实现 __call__
函数,在使用时用户不应该直接调用 forward
函数,而应该直接调用 __call__
函数,实现如下:
def __call__(self, *args: Tuple[Tensor]) -> Tensor: |
其中 ① 会将输入 Tensor 的 requires_grad
值传染给输出,算子任意输入 Tensor 中,只要有一个需要算梯度,则输出的 Tensor 也需要计算梯度。另外④中可以看出__call__
就是 forward
方法的包装。注意到 forward
的输出是 ndarray,而因为算子输出也需要是 Tensor,因此在 ⑤ 中做了封装。
在构造计算图时,会将 input.grad_fn
指向的算子,加入 next_ops
中,如 ③ 所示。只有②的例外,如果输入本身就是叶子节点,则它的 grad_fn
没有指向任何节点,因此这里构造了一个特殊的 OpAccumulate
算子来累加并设置梯度,如下所示:
class OpAccumulate(Operator): |
计算图是一个有向无环图(简称 DAG),DAG 遍历的重点是需要按拓扑排序遍历,在一个算子的所有输入都被满足时才能执行该算子的 backward
方法。于是我们先搞个辅助函数,按拓扑的顺序,统计每个算子依赖的输入个数。
def compute_dependencies(root): |
在样例代码里,最终会以 root = op:+
来调用,因此它会返回类似如下信息(当然key 会是各个实例化的算子,而不是字符串):
{ |
接下来我们会遍历整个图:
def execute_graph(root, output_grad): |
这个遍历过程可说的内容也不多,就是将 ready 的算子一个个放进队列 q
中,一个个执行它们的 backward
方法。其中比较关键的是,如果算子 backward
的输入如果有多个,则需要在 ① 中缓存部分输入,并且在 ② 中当新的输入到来需要进行累加,这里对应了开头公式 $\bar{v_i} = \sum_{j \in next(i)}{\overline{v_{i\to j}}}$ 的部分。最后在 ③ 中,要确保目标算子的所有输入都计算完成,才认为目标算子 ready 了。
如此,所有“框架”层面的内容均实现完毕。
有了框架还不够,还需要实现算子,而实现算子最关键的是可能需要在 forward
过程中记录输入信息,在 backward
中用来计算偏导。例如文章开头的样例中 $\bar{v_2}= \bar{v_4} v_1$ 就需要在 forward
时记录 $v_1$ 的值。下面补齐示例中需要的几个算子
另外注意下面的代码中除了实现算子,我们还实现了诸如 add, mul
等函数,方便对Tensor 构建计算图。
class OpEWiseAdd(Operator): |
class OpEWiseMul(Operator): |
class OpSin(Operator): |
>>> x1 = Tensor(np.array([0.5]), requires_grad=True) |
大家可以算算,跟公式算出来是一样的
如果 $x_1, x_2$ 是向量呢?这里关系到向量的求导到底要怎么算,但整体来说,咱们实现的框架还是成立的。例如上面例子中的 +, *, sin
,如果都只考虑是按元素的操作(不涉及矩阵乘法),则上面的算子定义依旧适用,下面我们对应在 Pytorch 运行的结果和我们刚实现的框架的结果:
#------------------- torch -------------------------|====================== Ours ======================== |
本文中我们实现了一个自动微分(Automatic Differentiation)的框架。主要是 Tensor、Operator 的定义,以及后向计算的引擎。
整体的实现和 PyTorch 的实现是比较类似的,但为了示例简单也做了些取舍。如Pytorch 中 Operator
的第一个参数是 ctx
,也鼓励算子把信息记录在 ctx
中,但我们是直接用 self.x
来记录;再如 PyTorch 中在计算结束后会把计算图销毁,我们没有做;再有 PyTorch 在 Tensor 中重载了一些基本操作(如 + - * /
),方便操作,但我们直接额外定义了 add, mul
等函数。等等等等。
总的来说,希望通过 AD 的简单实现,让大家认识到机器学习背后的一些原理,实际上也并没有特别复杂。当然我们也要认识到,能 Work 距离能在工业上使用,中间还隔了个太平洋。
Dot product 运算仅定义在两个向量上,输出一个标量,也称为 “scalar product”。
坐标定义:假设有两个向量$\color {red}{\mathbf {a} =[a_{1},a_{2},\cdots ,a_{n}]}$ 和$\color {blue}{\mathbf {b} =[b_{1},b_{2},\cdots ,b_{n}]}$ [1],则 dot product 定义为:
$$\mathbf {\color {red}{a}} \cdot \mathbf {\color {blue}{b}}= \sum_{i=1}^{n}{\color {red}{a}_{i} \color {blue}{b}_{i}}={\color {red} a_1}{\color {blue}b_1}+{\color {red}a_2}{\color {blue}b_2}+\cdots +{\color {red}a_n}{\color {blue}b_n}$$
还可以把 dot product 理解成是矩阵的线性变换,写成矩阵乘法,此时$\color{red}{\mathbf{a}}$ 与 $\color{blue}{\mathbf{b}}$ 都是列向量,定义如下:
$$\mathbf {\color {red}{a}} \cdot \mathbf {\color {blue}{b}}= \begin{bmatrix}{\color{red} a_1} \\\vdots \\ {\color{red}a_n} \end{bmatrix}\cdot\begin{bmatrix}{\color{blue}b_1} \\\vdots \\ {\color{blue}b_n} \end{bmatrix}= {\color {red} a_1}{\color {blue}b_1}+{\color {red}a_2}{\color {blue}b_2}+\cdots +{\color {red}a_n}{\color {blue}b_n}= \begin{bmatrix}{\color{red}a_1} &\cdots & {\color{red}a_n}\end{bmatrix}\begin{bmatrix}{\color{blue}b_1} \\\vdots \\ {\color{blue}b_n} \end{bmatrix}= \mathbf {\color {red}{a}} ^T \mathbf {\color {blue}{b}}$$
严格来说,点积的输入只能是两个向量,不存在矩阵和矩阵,矩阵和向量的点积,但矩阵计算方便,人们扩充了定义。先看矩阵和矩阵,可以认为矩阵就是列向量的集合,因此点积就是列向量分别做点积[2]。
$${\color{red}\mathbf {A}} \cdot {\color{blue}\mathbf {B}}= \begin{bmatrix}{\color{red}\mathbf{a}_1} & \cdots & {\color{red}\mathbf{a}_n}\end{bmatrix}\cdot\begin{bmatrix}{\color{blue}\mathbf{b}_1} &\cdots & {\color{blue}\mathbf{b}_n}\end{bmatrix}= \begin{bmatrix}{\color{red}\mathbf{a}_1} \cdot {\color{blue}\mathbf{b}_1}& \cdots& {\color{red}\mathbf{a}_n} \cdot {\color{blue}\mathbf{b}_n}\end{bmatrix}$$
上式中的 $\mathbf{a}_i, \mathbf{b}_i$ 都是列向量。另外注意由于 $\mathbf{a}_i\cdot \mathbf{b}_i$ 的结果是标量,所以最终的结果是一个行向量。
矩阵和向量的点积本质上是将向量扩充再当成矩阵和矩阵的点积,定义如下:
$${\color{red}\mathbf {A}_{n \times m}} \cdot {\color{blue}\mathbf {v}}= \begin{bmatrix}{\color{red}\mathbf{a}_1} & \cdots & {\color{red}\mathbf{a}_m}\end{bmatrix}\cdot\begin{bmatrix}{\color{blue}\mathbf{v}} &\cdots & {\color{blue}\mathbf{v}}\end{bmatrix}= \begin{bmatrix}{\color{red}\mathbf{a}_1} \cdot {\color{blue}\mathbf{v}}& \cdots& {\color{red}\mathbf{a}_m} \cdot {\color{blue}\mathbf{v}}\end{bmatrix}$$
此时结果为行向量。考虑到向量的点积也可以写成矩阵乘法的形式${\color{red}\mathbf{a}} \cdot {\color{blue}\mathbf{b}} = {\color{red}\mathbf{a}^T} {\color{blue}\mathbf{b}}$,因此有
$$\begin{align}{\color{red}\mathbf {A}_{n \times m}} \cdot {\color{blue}\mathbf {v}}&= \begin{bmatrix}{\color{red}\mathbf{a}_1} \cdot {\color{blue}\mathbf{v}}& \cdots& {\color{red}\mathbf{a}_m} \cdot {\color{blue}\mathbf{v}}\end{bmatrix}= \begin{bmatrix}{\color{red}\mathbf{a}_1^T} {\color{blue}\mathbf{v}}& \cdots& {\color{red}\mathbf{a}_m^T} {\color{blue}\mathbf{v}}\end{bmatrix}\\({\color{red}\mathbf {A}} \cdot {\color{blue}\mathbf {v}})^T&= \begin{bmatrix}{\color{red}\mathbf{a}_1^T} {\color{blue}\mathbf{v}}\\ \cdots\\ {\color{red}\mathbf{a}_m^T} {\color{blue}\mathbf{v}}\end{bmatrix}=\begin{bmatrix}{\color{red}\mathbf{a}_1^T} \\ \vdots \\ {\color{red}\mathbf{a}_m^T}\end{bmatrix} {\color{blue}\mathbf{v}}= {\color{red}(\mathbf{A}^T)_{m \times n}}{\color{blue}\mathbf{v}_{n \times 1}}\\{\color{red}\mathbf {A}} \cdot {\color{blue}\mathbf {v}}&= \big({\color{red}\mathbf{A}^T}{\color{blue}\mathbf{v}}\big)^T= {\color{blue}\mathbf{v}^T}{\color{red}\mathbf{A}}\end{align}$$
当然,上述式子中,我们严格按数学上的定义:向量就是“列”向量。这个假设不太方便,因为输入 $\mathbf{x}$ 是列向量,但输出 $\mathbf{A} \cdot \mathbf{x}$ 却是行向量。但实际上为了方便,我们也可以按“列”来排列输出结果,例如在深度学习中,单个输出 $y_i = \mathbf{w_i} \cdot \mathbf{x} + b$,则结果列向量:
$$\mathbf{y}_{m \times 1}= \begin{bmatrix} y_1 \\ y_2 \\ \vdots \\ y_m \end{bmatrix}= \begin{bmatrix}\mathbf{w_1} \cdot \mathbf{x} + b \\\mathbf{w_2} \cdot \mathbf{x} + b \\\vdots \\\mathbf{w_m} \cdot \mathbf{x} + b\end{bmatrix}= \begin{bmatrix}\mathbf{w_1}^T \mathbf{x} + b \\\mathbf{w_2}^T \mathbf{x} + b \\\vdots \\\mathbf{w_m}^T \mathbf{x} + b\end{bmatrix}= \begin{bmatrix}\mathbf{w_1}^T \\\mathbf{w_2}^T \\\vdots \\\mathbf{w_m}^T\end{bmatrix} \mathbf{x} + b= \mathbf{W}^T \mathbf{x} + b= (\mathbf{W}^T)_{m \times n} \mathbf{x}_{n \times 1} + b$$
因此不管是按行还是按列切片,关键在于点积 dot product 可以转换成矩阵乘法的形式。
考虑一个矩阵 $\mathbf{A} \in \mathbb{R}^{m \times n}$ 和向量 $\mathbf{x} \in \mathbb{R}^n$,矩阵和向量的乘法定义为[3]:
$$\mathbf{A}\mathbf{x} = x_1 \mathbf{a}_{*,1} + x_2 \mathbf{a}_{*,2} + \cdots + x_n \mathbf{a}_{*,n}$$
其中 $\mathbf{a}_{*, i}$ 代表矩阵 $\mathbf{A}$ 的第 $i$ 个列向量。
矩阵乘法有几种不同的理解方式,其中一种理解方式是“线性变换”[4],即向量 $\mathbf{x}$ 所在空间的基坐标,分别经过变换后,$\mathbf{x}$ 所在的坐标。因此,跟上述的定义基本一致:
$$\mathbf{A}{\color{brown}\mathbf{x}} =\begin{bmatrix}{\color {red} a_{11}} & {\color {blue} a_{12}} & \cdots & {\color {green}a_{1n}} \\{\color {red} a_{21}} & {\color {blue} a_{22}} & \cdots & {\color {green}a_{2n}} \\\vdots & \vdots & \ddots & \vdots \\{\color {red} a_{m1}} & {\color {blue} a_{m2}} & \cdots & {\color {green}a_{mn}}\end{bmatrix}\begin{bmatrix}{\color {brown} x_1} \\ {\color {brown} x_2} \\ \vdots \\ {\color {brown} x_n}\end{bmatrix}= {\color{brown} x_1} \begin{bmatrix}{\color {red} a_{11}} \\{\color {red} a_{21}} \\\vdots \\{\color {red} a_{m1}}\end{bmatrix} +{\color{brown} x_2} \begin{bmatrix}{\color {blue} a_{12}} \\{\color {blue} a_{22}} \\\vdots \\{\color {blue} a_{m2}}\end{bmatrix} + \cdots +{\color{brown} x_n} \begin{bmatrix}{\color {green} a_{1n}} \\{\color {green} a_{2n}} \\\vdots \\{\color {green} a_{mn}}\end{bmatrix}=\begin{bmatrix}{\color{brown}x_1}{\color {red} a_{11}} + {\color{brown}x_2}{\color {blue} a_{12}} + \cdots + {\color{brown}x_n}{\color {green}a_{1n}} \\{\color{brown}x_1}{\color {red} a_{21}} + {\color{brown}x_2}{\color {blue} a_{22}} + \cdots + {\color{brown}x_n}{\color {green}a_{2n}} \\\vdots \\{\color{brown}x_1}{\color {red} a_{m1}} + {\color{brown}x_2}{\color {blue} a_{m2}} + \cdots + {\color{brown}x_n}{\color {green}a_{mn}}\end{bmatrix}$$
还有一种理解和上面的“点积”类似,把矩阵看作是 $m$ 个行向量,每个向量都和 $x$ 作点积。如下:
$$\mathbf{A}{\color{brown}\mathbf{x}} =\begin{bmatrix}{\color {red} a_{11}} & {\color {red} a_{12}} & \cdots & {\color {red}a_{1n}} \\{\color {blue} a_{21}} & {\color {blue} a_{22}} & \cdots & {\color {blue}a_{2n}} \\\vdots & \vdots & \ddots & \vdots \\{\color {green} a_{m1}} & {\color {green} a_{m2}} & \cdots & {\color {green}a_{mn}}\end{bmatrix}\begin{bmatrix}{\color {brown} x_1} \\ {\color {brown} x_2} \\ \vdots \\ {\color {brown} x_n}\end{bmatrix}= \begin{bmatrix}{\color {red} \mathbf{a}_{1,*}} \cdot {\color{brown}\mathbf{x}} \\{\color {blue} \mathbf{a}_{2,*}} \cdot {\color{brown}\mathbf{x}} \\\vdots \\{\color {green} \mathbf{a}_{m,*}} \cdot {\color{brown}\mathbf{x}}\end{bmatrix}$$
延续线性变换的观点,矩阵和矩阵的相乘,可以看作变换的组合。矩阵 $\mathbf{B}$ 的每个列可以认为是变换后的基向量,而 $\mathbf{A} \mathbf{B}$ 可以认为是把每个基向量再做一次线性变换 $\mathbf{A}$。如下:
$$\mathbf{A}\mathbf{B}= \mathbf{A} \begin{bmatrix}{\color{red}\mathbf{b}_{*,1}} & {\color{blue}\mathbf{b}_{*,2}} & \cdots & {\color{green}\mathbf{b}_{*,n}}\end{bmatrix}= \begin{bmatrix}\mathbf{A} {\color{red}\mathbf{b}_{*,1}}& \mathbf{A} {\color{blue}\mathbf{b}_{*,2}}& \cdots& \mathbf{A} {\color{green}\mathbf{b}_{*,n}}\end{bmatrix}$$
如果再用上面的“点积”观点展开,则会是这样:
$$\mathbf{A}\mathbf{B}= \mathbf{A} \begin{bmatrix}\mathbf{b}_{*,1} & \mathbf{b}_{*,2} & \cdots & \mathbf{b}_{*,n}\end{bmatrix}= \begin{bmatrix}\begin{bmatrix}\mathbf{a}_{1,*} \\\mathbf{a}_{2,*} \\\vdots \\\mathbf{a}_{m,*}\end{bmatrix}\mathbf{b}_{*,1}&\begin{bmatrix}\mathbf{a}_{1,*} \\\mathbf{a}_{2,*} \\\vdots \\\mathbf{a}_{m,*}\end{bmatrix}\mathbf{b}_{*,2}& \cdots&\begin{bmatrix}\mathbf{a}_{1,*} \\\mathbf{a}_{2,*} \\\vdots \\\mathbf{a}_{m,*}\end{bmatrix}\mathbf{b}_{*,n}\end{bmatrix}= \begin{bmatrix}\mathbf{a}_{1,*} \cdot \mathbf{b}_{*,1} & \mathbf{a}_{1,*} \cdot \mathbf{b}_{*,2} & \cdots & \mathbf{a}_{1,*} \cdot \mathbf{b}_{*,n} \\\mathbf{a}_{2,*} \cdot \mathbf{b}_{*,1} & \mathbf{a}_{2,*} \cdot \mathbf{b}_{*,2} & \cdots & \mathbf{a}_{2,*} \cdot \mathbf{b}_{*,n} \\\vdots & \vdots & \ddots & \vdots \\\mathbf{a}_{m,*} \cdot \mathbf{b}_{*,1} & \mathbf{a}_{m,*} \cdot \mathbf{b}_{*,2} & \cdots & \mathbf{a}_{m,*} \cdot \mathbf{b}_{*,n}\end{bmatrix}$$
这个也就是我们熟悉的,每个元素等于行乘列的形式:
$$(\mathbf{A}\mathbf{B})_{ij}= {\color{red}\mathbf{a}_{i,*}} \cdot {\color{blue}\mathbf{b}_{*,j}}= \begin{bmatrix}\cdots & \cdots & \cdots & \cdots \\{\color{red}a_{i1}} & {\color{red}a_{i2}} & \cdots & {\color{red}a_{in}} \\\cdots & \cdots & \cdots & \cdots\end{bmatrix}\begin{bmatrix}\vdots & {\color{blue}b_{1,j}} & \vdots \\\vdots & {\color{blue}b_{2,j}} & \vdots \\\vdots & \vdots & \vdots \\\vdots & {\color{blue}b_{n,j}} & \vdots\end{bmatrix}$$
即将两个矩阵对应位置上的元素做相应的操作。也称为 element-wise operation.
按元素乘法有个特殊的名字,叫 Hadamard product,一般记作 $A \circ B$ 或 $A \odot B$,即将相同位置的元素相乘
$$\begin{bmatrix}a_{11} & a_{12} & \cdots & a_{1n} \\a_{21} & {\color{red}a_{22}} & \cdots & a_{2n} \\\vdots & \vdots & \ddots & \vdots \\a_{m1} & a_{m2} & \cdots & a_{mn}\end{bmatrix}\circ\begin{bmatrix}b_{11} & b_{12} & \cdots & b_{1n} \\b_{21} & {\color{blue}b_{22}} & \cdots & b_{2n} \\\vdots & \vdots & \ddots & \vdots \\b_{m1} & b_{m2} & \cdots & b_{mn}\end{bmatrix}= \begin{bmatrix}a_{11}b_{11} & a_{12}b_{12} & \cdots & a_{1n}b_{1n} \\a_{21}b_{21} & {\color{red}a_{22}}{\color{blue}b_{22}} & \cdots & a_{2n}b_{2n} \\\vdots & \vdots & \ddots & \vdots \\a_{m1}b_{m1} & a_{m2}b_{m2} & \cdots & a_{mn}b_{mn}\end{bmatrix}$$
其它操作也类似,如加法减法等
数学上,按元素操作只在矩阵的形状相同时才有效。但在实际应用中,可以尝试把“低维”的元素复制 N 份做填充,这就是 broadcast 机制。其实在介绍矩阵和向量的点积是已经用过这个操作了。示例如下:
$$\mathbf{A} \circ \mathbf{v}= \mathbf{A} \circ \begin{bmatrix}\mathbf{v} & \cdots & \mathbf{v} \end{bmatrix}$$
另外注意,这里依旧是以“列”为第一维,“行”为第二维。而在 numpy 中,第 0
维是行:
>>> w = np.array([[1,2,3],[4,5,6],[7,8,9]]) |
这个特性可以一直往高维推广,具体机制参考 numpy broadcast。
深度学习中最重要的数学知识就是对矩阵求导了,The Matrix Calculus You Need For Deep Learning这篇论文针对性地做了综述。下面的知识算是摘录其中一些部分加深记忆[5]。矩阵求导更复杂的内容,参考 wiki: Matrix calculus
考虑 $y = \mathbf{w} \cdot \mathbf{x}$,因为有多个输入,于是导数为偏导向量,这里我们用行向量表示:
$$\frac{\partial y}{\partial \mathbf{x}}= \begin{bmatrix}\frac{\partial y}{\partial x_1} &\cdots &\frac{\partial y}{\partial x_n}\end{bmatrix}= \begin{bmatrix}\frac{\partial (w_1 x_1 + \cdots + w_n x_n)}{\partial x_1} &\cdots &\frac{\partial (w_1 x_1 + \cdots + w_n x_n)}{\partial x_n}\end{bmatrix}= \begin{bmatrix} w_1 & \cdots & w_n \end{bmatrix}= \mathbf{w}^T$$
同理对 $\mathbf{w}$ 求导的值为:
$$\frac{\partial y}{\partial \mathbf{w}}= \mathbf{x}^T$$
我们知道“导数”要求的是“变化”,即如果输入 $x$ 有微小的变化 $\Delta x$ 时,输入$y$ 的变化 $\Delta y$。那么如果有多个输入 $x_1, \cdots, x_n$ 和多个输出 $y_1 =f_1(\mathbf{x}), \cdots, y_m = f_m(\mathbf{x})$,则任意输入 $x_i$ 有变化,任意输出 $y_j$ 就有可能有变化。于是它们间的偏导关系是一个矩阵,记做:
$$\mathbf {J}=\begin{bmatrix}\frac{\partial y_1}{\partial \mathbf{x}} \\ \vdots \\ \frac{\partial y_m}{\partial \mathbf{x}} \end{bmatrix}=\begin{bmatrix}{\dfrac {\partial f_{1}}{\partial x_{1}}}&\cdots &{\dfrac {\partial f_{1}}{\partial x_{n}}}\\\vdots &\ddots &\vdots \\{\dfrac {\partial f_{m}}{\partial x_{1}}}&\cdots &{\dfrac {\partial f_{m}}{\partial x_{n}}}\end{bmatrix}$$
如果有 $m$ 个输出,$y_j = \mathbf{w_j} \cdot \mathbf{x}$(注意 $\mathbf{w_j}$本身是 $n$ 维的向量,向量个数是 $m$)。对 $\mathbf{x}$ 的求导比较直观:
$$\frac{\partial \mathbf{y}}{\partial \mathbf{x}}= \begin{bmatrix}\frac{\partial y_1}{\partial \mathbf{x}} \\\frac{\partial y_2}{\partial \mathbf{x}} \\\vdots \\\frac{\partial y_m}{\partial \mathbf{x}}\end{bmatrix}= \begin{bmatrix}\mathbf{w}_1^T \\\mathbf{w}_2^T \\\vdots \\\mathbf{w}_m^T\end{bmatrix}$$
但是,如果对 $\mathbf{w}$ 求导,$\mathbf{w}$ 有 $m \times n$ 个元素,因此求导的结果是一个 $m \times (m \times n)$ 的 Jacobian 矩阵。特别复杂。所幸,在深度学习中,求导的目的是为了做梯度下降,所以为 0
的导数实际上也没用。而通过$y_j$ 的定义,我们知道如果 $i \ne j$ 则 $\frac{\partial y_i}{\partial \mathbf{w}_j} = 0$。于是我们去除这些为 $0$ 的项,保留:
$$\frac{\partial \mathbf{y}}{\partial \mathbf{w}}= \begin{bmatrix}\frac{\partial y_1}{\partial \mathbf{w}_1} \\\frac{\partial y_2}{\partial \mathbf{w}_2} \\\vdots \\\frac{\partial y_m}{\partial \mathbf{w}_m}\end{bmatrix}= \begin{bmatrix}\mathbf{x}^T \\\mathbf{x}^T \\\vdots \\\mathbf{x}^T\end{bmatrix}$$
如果我们把 $\mathbf{w}$ 写成矩阵形式 $\mathbf{W} = [\mathbf{w}_1 \cdots,\mathbf{w}_m]$,此时 $\mathbf{y} = \mathbf{W} \cdot \mathbf{x} = \mathbf{W}^T \mathbf{x}$,则上面的结论可以写成:
$$\begin{eqnarray}\frac{\partial \mathbf{y}}{\partial \mathbf{x}} = \mathbf{W}^T\end{eqnarray}$$
$$\begin{eqnarray}\frac{\partial \mathbf{y}}{\partial \mathbf{W}}= \begin{bmatrix}\mathbf{x}^T \\\mathbf{x}^T \\\vdots \\\mathbf{x}^T\end{bmatrix}= \begin{bmatrix}\mathbf{x} \cdots \mathbf{x} \end{bmatrix}^T\end{eqnarray}$$
其实上一节的矩阵与向量点积的求导已经得出结论了。当 $\mathbf{y} = \mathbf{A} \mathbf{x}$ 时,有
$$\begin{align}\frac{\partial \mathbf{y}}{\partial \mathbf{x}} &= \mathbf{A} \\\frac{\partial \mathbf{y}}{\partial \mathbf{A}} &= \begin{bmatrix}\mathbf{x} \cdots \mathbf{x} \end{bmatrix} ^T\end{align}$$
这里我们再从数值的角度分析一下,已知 $y_i = \sum_{j}{a_{ik}x_j}$,则有:
$$\frac{\partial y_i}{\partial x_j}= \frac{a_{i1}x_1+\cdots+a_{mn}{x_n}}{\partial x_j} = a_{ij}$$
再次
$$\frac{\partial y_i}{\partial w_{ij}}= \frac{a_{i1}x_1+\cdots+a_{mn}{x_n}}{\partial w_{ij}} = x_j$$
写成矩阵形式就是结论部分。
这部分过于复杂,且符号也没有统一,后续如果有用到再进行补充。
将按元素操作记为 $\unicode{x2D54}$,考虑 $\mathbf{y} = \mathbf{f(u)} \unicode{x2D54} \mathbf{g(v)}$,且向量 $\mathbf{u}, \mathbf{v}, \mathbf{y}$ 有相同的维度。写成如下形式:
$$\begin{bmatrix}y_1 \\ y_2 \\ \vdots \\ y_n\end{bmatrix}= \begin{bmatrix}f_1(\mathbf{u}) \unicode{x2D54} g_1(\mathbf{v}) \\f_2(\mathbf{u}) \unicode{x2D54} g_2(\mathbf{v}) \\\vdots \\f_n(\mathbf{u}) \unicode{x2D54} g_n(\mathbf{v})\end{bmatrix}$$
于是偏导则变成了 Jacobian 矩阵的形式:
$$\mathbf{J_u}= \frac{\partial \mathbf{y}}{\partial \mathbf{u}}= \begin{bmatrix}\frac{\partial y_1}{\partial \mathbf{u}} \\\frac{\partial y_2}{\partial \mathbf{u}} \\\vdots \\\frac{\partial y_n}{\partial \mathbf{u}} \\\end{bmatrix}= \begin{bmatrix}\frac{\partial}{\partial u_1} f_1(\mathbf{u}) \unicode{x2D54} g_1(\mathbf{v})& \frac{\partial}{\partial u_2} f_1(\mathbf{u}) \unicode{x2D54} g_1(\mathbf{v})& \cdots& \frac{\partial}{\partial u_n} f_1(\mathbf{u}) \unicode{x2D54} g_1(\mathbf{v})\\\frac{\partial}{\partial u_1} f_2(\mathbf{u}) \unicode{x2D54} g_2(\mathbf{v})& \frac{\partial}{\partial u_2} f_2(\mathbf{u}) \unicode{x2D54} g_2(\mathbf{v})& \cdots& \frac{\partial}{\partial u_n} f_2(\mathbf{u}) \unicode{x2D54} g_2(\mathbf{v})\\\vdots & \vdots & \ddots & \vdots\\\frac{\partial}{\partial u_1} f_n(\mathbf{u}) \unicode{x2D54} g_n(\mathbf{v})& \frac{\partial}{\partial u_2} f_n(\mathbf{u}) \unicode{x2D54} g_n(\mathbf{v})& \cdots& \frac{\partial}{\partial u_n} f_n(\mathbf{u}) \unicode{x2D54} g_n(\mathbf{v})\end{bmatrix}$$
但是注意到 $\unicode{x2D54}$ 是 element-wise 操作,于是 $y_i$ 只跟$f_i(\mathbf{u})$ 和 $g_i(\mathbf{v})$ 相关,换句话说,对于 $i \ne j$ 的情况,有 $\frac{\partial y_i}{\partial u_j} = 0$。更进一步,element-wise 操作代表着 $f_i(\mathbf{u})$ 可以退化成 $f_i(u_i)$,而跟其它所有 $u_k (k \ne i)$ 无关。
$$\mathbf{J_u}= \begin{bmatrix}\frac{\partial}{\partial u_1} (f_1(u_1) \unicode{x2D54} g_1(v_1))& 0& \cdots& 0\\0& \frac{\partial}{\partial u_2} (f_2(u_2) \unicode{x2D54} g_2(v_2))& \cdots& 0\\\vdots & \vdots & \ddots & \vdots\\0& 0& \cdots& \frac{\partial}{\partial u_n} (f_n(u_n) \unicode{x2D54} g_n(v_n))\end{bmatrix}$$
注意到只有对象元素有值,是对角矩阵,于是写成下式:
$$\frac{\partial \mathbf{y}}{\partial \mathbf{u}} = \mathbf{J_u}= diag \left(\frac{\partial}{\partial u_1} \left(f_1(u_1) \unicode{x2D54} g_1(v_1)\right),\cdots,\frac{\partial}{\partial u_n} \left(f_n(u_n) \unicode{x2D54} g_n(v_n)\right)\right)$$
再进一步,深度学习中一般 $f(u_i) = u_i$ 和 $g(v_i) = v_i$,所以还能简化:
$$\frac{\partial \mathbf{y}}{\partial \mathbf{u}} = \mathbf{J_u}= diag \left(\frac{\partial}{\partial u_1} \left(u_1 \unicode{x2D54} v_1\right),\cdots,\frac{\partial}{\partial u_n} \left(u_n \unicode{x2D54} v_n\right)\right)$$
于是常见的 element-wise 操作及其导数如下
Op | 对 $u$ 导数 |
---|---|
+ | $\frac{\partial (\mathbf{u}+\mathbf{v})}{\partial \mathbf{u}} = diag(\mathbf{1}) = \mathbf{I} $ |
- | $\frac{\partial (\mathbf{u}-\mathbf{v})}{\partial \mathbf{u}} = diag(\mathbf{1}) = \mathbf{I} $ |
$\otimes$ | $\frac{\partial (\mathbf{u}\otimes\mathbf{v})}{\partial \mathbf{u}} = diag(\cdots, \frac{\partial (u_i\times v_i)}{\partial u_i}, \cdots) = diag(\mathbf{v}) $ |
$\oslash$ | $\frac{\partial (\mathbf{u}\oslash\mathbf{v})}{\partial \mathbf{u}} = diag(\cdots, \frac{\partial (u_i / v_i)}{\partial u_i}, \cdots) = diag(\cdots, \frac{1}{v_i}, \cdots) = \frac{1}{\mathbf{v}}$ |
Op | 对 $v$ 导数 |
---|---|
+ | $\frac{\partial (\mathbf{u}+\mathbf{v})}{\partial \mathbf{v}} = diag(-\mathbf{1}) = -\mathbf{I} $ |
- | $\frac{\partial (\mathbf{u}-\mathbf{v})}{\partial \mathbf{v}} = diag(-\mathbf{1}) = -\mathbf{I} $ |
$\otimes$ | $\frac{\partial (\mathbf{u}\otimes\mathbf{v})}{\partial \mathbf{v}} = diag(\cdots, \frac{\partial (u_i\times v_i)}{\partial v_i}, \cdots) = diag(\mathbf{u}) $ |
$\oslash$ | $\frac{\partial (\mathbf{u}\oslash\mathbf{v})}{\partial \mathbf{v}} = diag(\cdots, \frac{\partial (u_i / v_i)}{\partial v_i}, \cdots) = diag(\cdots, \frac{-u_i}{v_i^2}, \cdots) = -\frac{\mathbf{u}}{\mathbf{v}^2}$ |
由于一般拿导数是用来更新向量的,对角矩阵经常也直接当成向量来使用。
主要回顾了 dot product、矩阵乘法与 element-wise 乘法的关系,以及这些操作求偏导的矩阵形式。
注意这里的表示要求向量的座标是基于一对正交基的,另外注意这里没有定义行向量或列向量,因为这是坐标形式,不关心向量是行向量还是列向量 ↩
此处参考 matlab dot product 定义:https://www.mathworks.com/help/matlab/ref/dot.html#bt9p8vi-1_1 ↩
这里就不谈链式法则相关的内容了,感兴趣的可以参考我的前一篇文章 自动微分(Automatic Differentiation):算法篇 ↩
本篇只介绍算法的基础知识,实现部分请参考实现篇。
AD 能用来求偏导值的。
例如有一个 $\mathbb{R}^2 \mapsto \mathbb{R}$ 的函数(函数有 2
个输入,1
个输出):$f(x, y)$ ,对于 $x$、$y$ 的偏导分别计为$\frac{\partial f}{\partial x}$ 和 $\frac{\partial f}{\partial y}$。通常我们不关心偏导的解析式,只关心具体某个 $x_i$, $y_i$ 取值下偏导$\frac{\partial f}{\partial x} \vert_{x=x_i,y=y_i}$ 和$\frac{\partial f}{\partial y} \vert_{x=x_i,y=y_i}$ 的值。
另外注意在神经网络在使用“梯度下降”学习时,我们关心的是“参数 $w$”的偏导。而不是“输入 $x$”的偏导。假设有 $f(x) = ax^2 + b$ 这样的神经网络,损失函数是 $l(f(x), y)$,现在给了一个样本标签对$(x_0, y_0)$,我们要计算的是$\frac{\partial l}{\partial a}\vert_{x=x_0,y=y_0,a=a_0,b=b_0}$ 和$\frac{\partial l}{\partial b}\vert_{x=x_0,y=y_0,a=a_0,b=b_0}$。在对号入座时要牢记这点。
求偏导有很多做法,例如 symbolic differentiation使用“符号计算” 得到准确的偏导解析式,但对于复杂的函数,偏导解析式会特别复杂,占用大量内存且计算慢,并且通常应用也不需要解析式;再比如numerical differentiation通过引入很小的位移 $h$,计算 $\frac{f(x+h) - f(h)}{h}$ 得到偏导,这种方法编码容易,但受 float 误差影响大,且计算慢(有几个输入就要算几次 $f$)。
AD 认为所有的计算最终都可以拆解成基础操作(如加减乘除,exp
, log
, sin
,cos
等基本函数)的组合。然后通过链式法则逐步计算偏导。这样使用方只需要正常组合基础操作,就能自动计算偏导,且不受 float误差的影响,还可以复用一些中间结果来减少计算量(等价于动态规划)。
AD 的数学基础就是链式法则(chain rule):
对于函数 $z = h(x)$,如果有子函数 $y = f(x)$,满足 $z = h(x) = g(y) = g(f(x))$,则求偏导有如下关系:
$$h’(x) = g’(f(x))f’(x)\iff\frac{\partial z}{\partial x} \bigg\vert_{x_0} = \frac{\partial z}{\partial y}\bigg\vert_{y=f(x_0)} \frac{\partial y}{\partial x} \bigg\vert_{x_0}$$
上述两种写法是一致的。另外如果涉及多个变量,例如 $z = f(x, y)$,而 $x = g(t),y = h(t)$,则有:
$$\frac{\partial z}{\partial t} = \frac{\partial z}{\partial x}\frac{\partial x}{\partial t} +\frac{\partial z}{\partial y}\frac{\partial y}{\partial t}$$
上面的式子叫 multivariable case:多变量的链式法则。也可以认为是Total Derivative全微分的链式法则。
AD 其实就是链式法则的具体实现。它有两种模式:前向模式(Forward accumulation)和反向模式(Reverse accumulation),我们只考虑反向模式。那么具体是怎么工作的呢?考虑下面的复杂函数[1]
$$\begin{aligned}y &= f(x_{1},x_{2})\\&= \sin x_{1} + x_{1}x_{2}\\&= \sin v_{1} + v_{1}v_{2}\\&= v_{3}+v_{4}\\&= v_{5}\end{aligned}$$
上述公式中,我们用了一些子函数来简化整个函数,画成图如下左图:
于是为了求偏导 $\frac{\partial f}{\partial x_1}$ 与 $\frac{\partial f}{\partial x_2}$的值,我们可以先定义中间值 $\bar{v_i} = \frac{\partial f}{\partial v_i}$,根据链式法则,有
$$\bar{v_i} = \frac{\partial f}{\partial v_i} = \frac{\partial f}{\partial v_{i+1}} \frac{\partial v_{i+1}}{\partial v_i} = \bar{v_{i+1}} \frac{\partial v_{i+1}}{\partial v_i}$$
于是计算时需要先“前向”计算一次,得到 $v_1, v_2, \cdots, v_5$ 的值,之后再“后向”计算 $\bar{v_5}, \bar{v_4}, \cdots, \bar{v_1}$ 的值(参考上右图),最终得到的$\bar{v_1}, \bar{v_2}$ 就是我们要计算的结果。而需要先“前向”计算一次,是因为后向计算时会用到前向的值,例如 $\bar{v_2} = \bar{v_4} v_1$ 就需要用到前向的$v_1$。
注意图里 $\bar{v_1}$ 的计算依赖了链式法则中多变量的情况,等于它所有后继节点偏导(即图中的 $\bar{v_1^a}, \bar{v_1^b}$)的和。当计算图中存在$v_i$ 指向 $v_j$ 的箭头时,我们记 $\overline{v_{i \to j}}$ 为 $f$ 从 $v_j$ 方向对 $v_i$ 的偏导,则公式可以扩充如下:
$$\bar{v_i} = \frac{\partial f}{\partial v_i}= \sum_{j \in next(i)}{\overline{v_{i\to j}}}= \sum_{j \in next(i)}{\frac{\partial f}{\partial v_{j}} \frac{\partial v_{j}}{\partial v_i}= \sum_{j \in next(i)}{\overline{v_j} \frac{\partial v_{j}}{\partial v_i}}}$$
多输出的情况偏理论,跳过也影响不大。神经网络的输出,在训练时最终都会接入损失函数,得到 loss
值,一般都是一个标量,可以认为神经网络的学习总是单输出的。
在多输出的情况下,链式法则依然生效。
刚才都假设函数是 $\mathbb{R}^n \mapsto \mathbb{R}$,即 n
个输入,1
个输出。考虑 m
个输出,即 $\mathbb{R}^n \mapsto \mathbb{R}^m$ 的情况。假设输入是$x_1, x_2, \cdots, x_n$,而输出是$f_1(x_1, \cdots, x_n), f_2(x_1, \cdots, x_n), \cdots, f_m(x_1, \cdots, x_n)$。此时我们要计算的偏导就不是 n
个值了,而是一个 m×n
的矩阵[2],每个元素 $J_{ij} = \frac{\partial f_i}{\partial x_j}$。这个矩阵一般称为Jacobian Matrix:
$$\mathbf {J_{m\times n}} =\begin{bmatrix}{\dfrac {\partial \mathbf {f} }{\partial x_{1}}}&\cdots &{\dfrac {\partial \mathbf {f} }{\partial x_{n}}}\end{bmatrix}=\begin{bmatrix}\nabla ^{\mathrm {T} }f_{1}\\\vdots \\\nabla ^{\mathrm {T} }f_{m}\end{bmatrix}=\begin{bmatrix}{\dfrac {\partial f_{1}}{\partial x_{1}}}&\cdots &{\dfrac {\partial f_{1}}{\partial x_{n}}}\\\vdots &\ddots &\vdots \\{\dfrac {\partial f_{m}}{\partial x_{1}}}&\cdots &{\dfrac {\partial f_{m}}{\partial x_{n}}}\end{bmatrix}$$
其中 $\nabla^{\mathrm{T}}f_i$ 代表 $f_i$ 对于所有输入的偏导(行向量)的转置。
考虑函数 $g: \mathbb{R}^n \mapsto\mathbb{R}^k$,$h: \mathbb{R}^k \mapsto \mathbb{R}^m$,而函数 $f$ 是二者的组合:$f(x) = h \circ g(x) = h(g(x))$,则有
$$J = J_{h \circ g} = J_h(g(x)) \cdot J_g(x)$$
此时 $\mathbf{J}$ 中的每个元素:
$$J_{ij} = \frac{\partial f_i}{\partial x_j}= \sum_{l = 1}^{k}{\frac{\partial h_i}{\partial g_l} \frac{\partial g_l}{\partial x_j}}= \begin{bmatrix}{\dfrac {\partial h_i}{\partial g_{1}}}&\cdots &{\dfrac {\partial h_i }{\partial g_{k}}}\end{bmatrix}\begin{bmatrix}{\dfrac {\partial g_1}{\partial x_{j}}} \\ \vdots \\ {\dfrac {\partial g_k }{\partial x_{j}}}\end{bmatrix}$$
可以看到和 $J_h \cdot J_g$ 的结果是一致的。不过这些性质其实都是链式法则的内容,这里也只是扩充视野。
AD 把复杂的函数看成是许多小函数的组合,再利用链式法则来计算偏导。它有不同的模式,其中“后向模式”在计算偏导时先“前向”计算得到一些中间结果,之后再“反向”计算偏导。从工程的视角看,由于中间的偏导可以重复利用,能减少许多计算量。深度学习的反向传播算法(BP)是 AD 的一种特例。
所以回过头来,什么是 AD?AD 就是利用链式法则算偏导的一种实现。
环境信息:
Spark driver 看到的报错如下(省略了一些不重要的):
Caused by: java.io.IOException: Failed on local exception: java.io.IOException: org.apache.hadoop.security.AccessControlException: Client cannot authenticate via:[TOKEN, KERBEROS]; Host Details : local host is: "spark-sql-xcjgz-43aabd8581a44cae-exec-4/10.244.55.49"; destination host is: "xxx-hdp02":25019; |
由于环境之前都是正常的,大概率没有人动过环境。基于之前的经验,先排查一些常见的问题:
k get mutatingwebhookconfiguration
)存在。之前遇到过未知原因导致 webhook 消失,driver 没有 mount 上认证信息导致鉴权失败说明问题应该不是由 Spark Operator 引起的。然后怀疑是不是 mount 的鉴权信息有过变动:
kinit
,尝试把鉴权相关信息(core-site.xml
,hdfs-site.xml
, krb5.conf
, keytab
) 放到另一台机器上,kinit 能成功。hdfs mkdir
和 hdfs rmdir
都能成功。初步认定 Hadoop 集群没问题[1]。
接着再排除代码问题,使用 driver 镜像在另一个环境(连的另一个 CDH 集群)能正常运行。
于是环境、集群、代码看起来都没有问题,那问题在哪呢?
最开始还是怀疑环境有问题,怀疑 spark operator 依赖环境的某些东西被修改了。但实在不知道从何查起,于是考虑远程 debug。走了一些弯路,最后是这么操作的:
spark.driver.extraJavaOptions="-agentlib:jdwp=transport=dt_socket,server=y, suspend=y,address=5005"
,这样 driver 启动后就会开启 5005 端口等待 debugkubectl port-forward --address 0.0.0.0 <driver pod> 5005: 5005
来开启宿主机到 pod 的流量转发断点打在 UserGroupInformation.doAs
上。发现 driver 调用时的用户信息都正常。再通过添加 spark.executor.extracJavaOptions
参数来 debug executor(记得把executor数调成 1)。结果发现 executor 调用 doAs
时,使用的用户名是root
(预期是 work
),鉴权模式是 SIMPLE
(预期是 KERBEROS
)。
这妥妥的是 executor pod 创建的问题呀。于是开始排查 executor,发现 executor 的环境变量 SPARK_USER=root
,同时它没有mount krb5.conf 和 keytab,难道发现了root cause?
可惜几番折腾后都不生效。最后对比运行成功的环境,发现运行成功的 exeucotr 环境变量也是一样的,也没有 mount 任何鉴权相关的信息。
既然没有任何鉴权相关的信息,executor 里是怎么鉴权的?涉及到知识盲区,怎么办?
下载 Spark 3.1.1 代码,但代码太多又无从看起,于是上网搜索相关 Feature 的PR,找到下面几个信息:
最重要的是最后这个链接,这个 PR 的留言里有这样的信息:
In either of those cases, the driver code will handle delegation tokens: incluster mode by creating a secret and stashing them, in client mode by usingexisting mechanisms to send DTs to executors.
说明鉴权都是 driver 做的,而 executor 会从 driver 拿到 delegation token。
不过 “delegation token” 又是啥玩意?大概搜到它是 Hadoop 发的 token,目标是减少鉴权压力,一般在 Map Reduce, Spark 这些有多个 worker 要访问 HDFS 的时候使用。但这些信息并没有本质帮助。
好在这样有了 debug 的头绪。一开始尝试使用 spark 的源码进行 remote debug,发现有许多问题搞不定。于是尝试直接下载 driver 里的所有 jar 包,导入到一个空project 中,由于是 scala 直接依赖 jar 包不好单步,于是再下载对应 jar 包的sources.jar,就可以在 IDEA 里打断点单步执行了。
同时 debug 成功和失败环境里的 executor,在对比一些步骤的变量后,终于发现问题所在:executor 在执行下面代码时,失败的环境里获取到的 token 是空。
cfg.hadoopDelegationCreds.foreach { tokens => |
考虑到 DT 是从 driver 获取的,看来是 driver 里存储的 delegation token 本来就是空的。另外回过头来发现 driver 的日志里有这么一句日志:
23/01/08 21:03:31 INFO DFSClient: Cannot get delegation token from work |
为什么 driver token 是空?继续 debug driver 发现 driver 在获取 delegationtoken 时返回的是 null: FileSystem.collectDelegationTokens
。最终缩小到最小的复现代码:
var conf = new org.apache.hadoop.conf.Configuration(); |
是 hadoop client 有 BUG?还是 hadoop server 有问题?
查代码看到 delegation token 是 namenode 创建的。于是上集群的 namenode,用arthas 监控 FSNamesystem.getDelegationToken
方法。
结果……执行复现代码,发现没有输出,尽管同时监控了所有的 4 个 namenode,没有任何一个有输出。然而在正常的环境里是有输出的。难道是 Client 有 BUG 没把请求发出去?
开始用 wireshark 抓包,发现还是有请求包发出的,当然因为是 RPC,内容看不出来,但 namenode 的 arthas 就是没有输出……最后在对比正常和错误环境的请求包,突然发现错误环境连接的是 25019
端口,这又是啥端口?
在集群上通过 netstat -natp
找到了进程,进程的命令显示它是集群的 router
角色。不管是 arthas 还是它的日志(如下),都发现它才是罪魁祸首:
2023-01-09 20:09:59,312 | WARN | IPC Server handler 43 on 25019 | trying to get DT with no secret manager running | RouterSecurityManager.java:124 |
其实之前在看 hadoop 相关代码时就注意到如果 server 出错应该要有这个日志,但在namenode 日志里没有找到。现在看到这个日志,基本确定就是它的问题。最后重启router 之后发现世界和平了。
Router 生成 getDelegationToken的逻辑如下所示,通过 arthas 发现是因为 dtSecretManager.isRunning
判断失败。再追代码发现 isRunning
失败的唯一可能就是调用了AbstractDelegationTokenSecretManager::stopThreads
,但是 stopThreads
只有停止 router的时候才会调用,与当前的现象不相符。
public Token<DelegationTokenIdentifier> getDelegationToken(Text renewer) |
可惜的是 stopThreads
的调用链路并不会输出日志。只能尝试人肉看一看从最后一次重启,到出问题之间的日志,开头结尾如下:
2022-12-29 18:54:18,187 | INFO | pool-1-thread-1 | Stopping security manager | RouterSecurityManager.java:62 |
看了不久发现,在创建 secret manager,调用 startThreads
时因为 ZK 的原因有报错:
2022-12-29 21:13:06,167 | ERROR | main | Error starting threads for zkDelegationTokens | ZKDelegationTokenSecretManagerImpl.java:48 |
但如果创建的时候就失败了,按代码逻辑,启动的时候应该失败:
public RouterSecurityManager(Configuration conf) throws IOException { |
最后发现看的代码和集群的版本不一致,FI 6.5.1 是基于 hadoop 3.1.1 版本,但是3.1.1 版本代码里并没有 RouterSecurityManager
,于是拉到集群里的 jar 包反编译,发现,FI 实现的 RouterSecurityManager 没有任何的校验:
而开源的 hadoop 在创建完后是有校验的,也找到了相关的修改commit 。
最终的问题链路是:
ZKDelegationTokenSecretManagerImpl
连接 ZK 失败,导致相关的服务启动失败RouterSecurityManager
并没有对 secret manager 的启动状态做校验(后续版本修复),仍然继续运行另外几点感想:
这里犯了一个错误,就是通过 kinit 成功推断集群正常。这里因为不了解 hadoop 额外的一些机制导致的,不太好避免 ↩
nbf
字段代表 token 的“开始时间”。开始时间不得早于“机器当前时间”,实际允许有 1min 偏差在 k8s 上启动的任务,会通过 fabric8.io java client 创建 SparkApplication 的Custom Resource(CR)。然而某一天开始,测试环境提交的任务全都失败,报下面的错误:
Exception in thread "main" io.fabric8.kubernetes.client.KubernetesClientException: Failure executing: GET at: https://10.233.0.1/api/v1/namespaces/.../pods/xxx-pod. Message: Unauthorized! Configured service account doesn't have access. Service account may have been revoked. Unauthorized. |
由于近期刚做过部署操作,开始怀疑是不是 SA 配置错了。于是人肉检查 SA
serviceAccount
和 serviceAccountName
的值都是符合预期的。get clusterrole
和 get rolebinding -n <namespace>
检查都是正确的SA 检查无误,于是想先抓包看看是不是网络相关的问题,在 get pods -o wide
时发现所有任务都调度到 node2
这个节点。于是先把 node2
下掉,直接搜了个命令:
kubectl taint nodes node2 key1=value1:NoSchedule |
新起的任务调度到 node1
后发现任务都 OK。因为暂时还在搞其它事情,把这个问题汇报给 SRE 同事,就暂停了。
SRE 同事做了一些尝试,发现有即使在 node2 提交,偶尔也是能通过的。期间有两个怀疑:
另外 SRE 同事试着在命令执行前增加 sleep 40s
发现就能提交通过。
在查资料时有提到是不是有同步问题,于是灵光一闪会不会跟系统时间有关,一查,两台节点的时间差是大约是 1min30s。于是把时间拔到 1min 内,发现提交的任务正常了。于是确定是和系统时间相关。但是具体的机制搞不清楚。
sudo date $(date +%m%d%H%M%Y.%S -d '-1 minutes') |
期间已经把测试的内容把成单纯的 curl:
find / -name "*.crt" |
这里有一个失误,这个 curl 用的 API 即使成功也是 404, 导致在测试的过程中会有误判,实际上测试结果 #2 几乎都是 404, 但有时候会看成是 401. 也尝试人工进入 pod执行 curl,都是通的,想不通开始的 30s 究竟触发了什么机制导致认证失败。
401 的 curl 如下所示:
* ALPN, offering h2 |
接下来走了个弯路,因为看到日志时的 HTTP/2 401
,扫了一眼看到是中间 Debug 日志输出,就以为是 TLS 握手的过程中出的错。中途 Debug 了很久 TLS 相关的内容。后来看 k8s apiserver 的日志才恍然大悟这个 401 是 apiserver 给出来的。
E1123 11:53:33.385964 1 claims.go:126] unexpected validation error: *errors.errorString |
找到是 K8S 的问题,就去找 k8s 的日志,找了很长时间找到了
并且看代码它会对比 JWT 里的 NotBeforeTime
字段。于是通过 get secrets
拿到token,并在 jwt.io 里解析,奇怪的是并没有看到时间相关的字段
k -n <ns> get secret <SA-secret-name> -o jsonpath='{.data.token}' | base64 --decode |
payload 如下
{ |
这里其实又有个失误,其实很早之前就已经在 pod 里打印出 pod 里读到的 token,但一直以为 pod 里拿到的 token 和 get secrets
的结果是一样的。对比了半天才发现它们不一样,终于找到时间字段:
{ |
通过查 JWT 的说明知道 nbf
字段就是 NotBefore
的时间。对比实际的值也发现和node2
运行任务的时间非常接近。终于破案。
nbf
时间为 B 节点的当前时间。(这里应该是创建 token 的请求会发往 B 的 apiserver,目前没找到方法验证)kubernetes.default
,请求被路由到节点 Anbf
在 A 节点当前时间+ 1min 之后,拒绝请求这个问题从断断续续排查了近一周,中间还是有不少失误
get secrets
和 pod 里 token 的区别,又浪费了半天时间常在想,自己工作里遇到的代码,为什么有那么多屎山?不管是加入前已经存在的,还是加入后新写的;不管是别人写的还是自己写的,仿佛不是屎山就是在变成屎山。今天主要在长短期决策上吐吐糟。
生存永远大于发展。这个功能如果这样做,未来修改的代价比较大。没事,我们先做成这样,如果没有成果,说不准明年就没有我们了。真的活到第二年了,似乎当时的困难就不存在了,要求继续全速前进。
未来的未来再说。“这期先这样设计,下个迭代我们再优化”,第二个迭代到来,“这个迭代这些需求优先级比较高,优化放下一个迭代吧”,子子孙孙无穷尽也。
先看看怎么跑通。这期能跑通就不错了,哪顾得上代码优雅不优雅,反正后面屎山维护不了,大不了跑路呗?我又不和公司和团队共存亡。
贪心算法和动态规划,我们知道通常贪心算法得不到全局最优。软件开发上,如果总是选择现在的利益,忽略未来,则注定会走向死亡。但一来不这么搞我现在就没了,二来说不准未来锅不是我背呢?再来谁知道现做的准备未来能用上呢?去 TM 的全局最优。
]]>它是一门配置语言。用来在网络处理的各个环节里加 Hook。常见的用途是做防火墙,做流量的转发等等。
像学习其它语言一样,语言本身有语法,语法之外还需要学习库函数。iptables 的语法大概如下:
iptables [-t table] {-I | -A | -D | -R} chain rule_specification |
iptables 里有 table
和 chain
的概念,代表机器处理网络包的各个阶段,因此在指定配置时需要先指定配置在哪个阶段生效。之后是配置处理的规则,规则语法如下:
rule-specification = [matches...] [target] |
一个规则可以有多个 match
匹配条件,以及一个 target
作为目标。它表明当一个网络包命中这些规则时,执行 target
目标。另外,iptables 是可(由其它模块)扩展的,扩展会提供新的 match 和新的 target。我们先看一个典型示例:
iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-ports 7892 |
这个规则的作用是将所有的 tcp
流量,全部转发到 7892
端口。这里的 -p tcp
条件选中 tcp 流量是 iptables 默认支持的,但 REDIRECT
转发操作是扩展提供的。
要学习语言,要先了解语言背后的执行模型(类比栈、指针等),iptables 的作用是在各个环节里增加 hook,那有哪些 hook 可以用呢?先看下图[1]:
--->PRE------>[ROUTE]--->FWD---------->POST------> |
一个包从左侧进入系统,先到 PRE
环节。接着进入 [ROUTE]
阶段做路由,来决定包的去向。如果本机是目标地址则接收,否则尝试转发,亦或者丢弃。
对于本机接收的包,触发 IN
环节后交给对应的应用程序;转发的包在触发 FWD
环节后尝试向外发包。外出的包最后还会经过 POST
环节,做最后的处理后发往网卡。
本机应用程序发出的包,会先经过 OUT
环节处理,之后经过 [ROUTE]
决定去向[2],最终再经过 POST
环节后发出。
在这些 hook 的基础上,iptables 用 “table” 的概念来组织常见的包修改需求。例如:
# modified from https://www.netfilter.org/documentation/HOWTO/netfilter-hacking-HOWTO.txt |
具体使用时,先决定要做的修改是什么内容,决定 table 名,然后找到 hook 的时机,决定 chain 的名字。当然 iptables 允许用户增加自己的 chain,但用户增加的 chain并不能决定 hook 的时机。
例如下面的例子里,我们要把所有流量转发到 7892
端口,我们通过 man iptables-extensions
查到,它只能加到 nat
表的 PREROUTING
或 OUTPUT
链,由于我们要转发入口流量,所以修改的是 PREROUTING
chain。
iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-ports 7892 |
REDIRECT
的限制也很容易理解,转发需要支持源、目标地址的改写,因此属于 nat
表的范畴,而它需要在路由之前做修改(否则改了也发不出去),所以只能在PREROUTING
和 OUTPUT
hook 里处理。
上面我们提到 iptables 是通过 table, chain 来组织切入点的,一个 chain 上可以配置多条规则,用户还可以自己创建 chain 来管理规则。那么 iptables 在是如何使用这些规则的呢?
正常情况下规则会一条条向下匹配,iptables 有一些特殊的 target 也提供了一些特殊的操作来在规则中跳转的能力(可以类比编程语言中的 continue
, break
),如下图:
-j <chain>
):跳转到自定义的 chain 里此外也得注意一些扩展 target 的语义,如 REDIRECT
相当于 ACCEPT
;如 REJECT
相当于 DROP
,会在发送终止包后丢弃数据包。实操如果发现有问题,要注意是不是规则顺序引起的。
上面我们了解了 iptables 的语法和执行顺序,接下来要学习“库函数”,表面上学习库函数就是学习“扩展”提供了哪些 match 和 target,但真正的难点是学习它们背后的网络处理机制。这里我们简单提几个。
Firewall Mark(fwmark) 可以理解成一个 iptables 的扩展,它提供了 MARK
和CONNMARK
的 target,允许我们把一个数据包或一个连接打上标记。之后在其它地方可以使用这个标记。
典型的使用方式是让有某个标记的流量走某个特殊的路由表[3],例如:
ip rule add fwmark 1 table 100 |
其中的 ip rule add fwmark 1 table 100
是创建了一张名为 100
的路由表,并指定当 fwmark
为 1 时才查这张表。而下面的规则指定了 -p udp
匹配 UDP 流量,且目标地址为 -d 198.18.0.0/16
时执行 -j MARK
操作,把数据包打上 --set-mark 1
这个标记。
成果是目标地址为 198.18.0.0/16
的 UDP 流量会查 100 路由表。
Network Address Translation 的变种比较多,但思路还是容易理解的。在网络隔离的情况下,如果想两个网段里交换网络包,则需要在路由器(能同时访问两个网段)里对包做地址转换,如下所示:
SNAT 是换了源 IP 字段,所以一般用于出口流量;DNAT 换了目标 IP 字段,所以一般用于做“端口映射”来穿透内网。可以看到不论是 SNAT 还是 DNAT 都需要提供目标的 IP 地址。而 MASQUERADE
可以理解成 SNAT 的变种,它可以自动填写对应网卡的 IP,不需要手工指定了,一般用于路由器流量内外网转发。
另外从图里看到,无论是 SNAT 还是 DNAT,都需要维护一张 NAT 映射表,可以通过conntrack -L
看到。如果在路由器的 SNAT 里,--to-source
IP 不是本机会怎么样呢?连接会建立失败,路由还是正常记录了 NAT 映射表,但 ACK 包会直接发到--to-source
IP 上,被丢弃。
额外的,TCP 流量只有在连接建立时会查 iptables NAT 表,同个连接后续的包会沿用建立连接时的规则。
man iptables-extensions
各种扩展支持的 match, target 都有说明https://www.netfilter.org/documentation/HOWTO/netfilter-hacking-HOWTO.txt ↩
按文档所说,实际上路由的代码在 OUT
之前就被调用,用来获取源 IP 和一些其它的 IP 选项 ↩
https://lancellc.gitbook.io/clash/start-clash/clash-udp-tproxy-support ↩
假设你发了朋友圈,有两个朋友评论:
我们人肉能识别出两句话之间的因果关系:#A
是因,#B
是果,但是计算机怎么判断呢?
一种思路是给评论加上生成时间,比如 #A_10:01
, #B_10:02
,系统按时间对评论排序,就能判断 #B
发生成 #A
之后。这个方法逻辑上没问题,但现实中没有一种可靠的方法,能准确地同步各个机器上的时间(也称为物理时间)。于是可能出现下面的情况:
处理 B
评论的机器时钟慢了,导致 B
评论的时间戳更小,系统排序时把 #B
放在了前面,因果错乱。
Lamport 时钟[1]是一种逻辑上的机制,用来给各个事件打标签,保证如果事件 A
发生于 B
之前,则 A
的标签 L(A)
一定小于 B
的标签 L(B)
。
具体要怎么做呢?每个机器各自维护一个计数器 t
,然后:
t
置为 0
t = t+1
,再用自增后的 t
来标记事件t = t+1
,并发送 (t, m)
,即把计数器和事件都发出去(t', m)
时,则需要更新本地的计数器 t = max(t, t') + 1
,并把 m
发送到本地于是如果使用这个算法,则上面朋友圈的例子就变成了:
可以看到事件 (4, 这是北京吧)
发生在 (6, 应该不是)
之前,它们的标签 t
能反映出这一点。
为什么 Lamport 时钟能体现事件发生的“因果”关系?如果两个事件有“因果”,它们一定是有“同步”的操作,而 Lamport 时钟则是在“同步”时(第 #4 点),通过 max(t, t')
同步了二者的逻辑时间。
由于 A-Before
的事件满足 t <= T
,而 B-After
的事件满足 t >= T+1
,所以能保证 A-before <= T < T+1 <= B-after
,而 B-after
中的事件逻辑上是发生成A-before
的事件之后的,且标签 t
也满足先后关系,因此保证了因果顺序。
但是在上图中,我们虽然推出 A-before < B-after
,但其它几个区域发生的事件就没法有确定的对比结论了。例如所有 B-before
中的事件,一定发生成 A-after
中的事件之前吗?(B-before < A-after
),细想一下会发现并没有办法得出这个结论。明确可比的有这几个区域:
A-before < A-after
,A 机事件发生的先后决定B-before < B-after
,B 机事件发生的先后决定A-before < B-after
,A、B 之间的因果性决定从另一个角度看,Lamport 时钟可以保证如果事件 a < b
(a
发生在 b
之前),就可以推出它们的标签满足 L(a) < L(b)
。但反过来,如果看到两个标签 L(a) < L(b)
,能反推出 a < b
吗?其实是不行的,因为我们能判定的只有 A-before
和B-after
两个区域的事件,但只看 L(a)
和 L(b)
我们并不知道 a
和 b
落在哪个区域,因此无法判断 a
和 b
发生的先后。这就是 Lamport 时钟的局限性,
vector 时钟可以解决这个问题:如果两个事件落在可比较的区域,则通过对比 vector时钟产生的标记,可以得出对应事件发生的先后顺序,即通过 L(a) < L(b)
可以得出a < b
的结论。那 vector 时钟是怎么做到的?
N[1], N[2], ..N[n]
T = <t1, t2, ..., tn>
N[i]
本机产生一个事件时,就把本机向量里的 ti
递增,即 T[i]++
N[i]
发送消息 m
时,先执行 T[i]++
,再发送 (T, m)
N[j]
收到消息 (T', m)
时,执行 T = max(T, T')
,再执行 T[j]++
这些规则看起来很复杂,但实际上它和 Lamport 时钟的“同步逻辑”一样,只是每个节点都保存了其它所有节点,最后一次同步过的计数器。执行起来如下图[2]:
Vector 时钟最后的标签有多维,如何比较呢?vector 时钟要求,如果每一维上,都有T[i] < T'[i]
,则认为 T < T'
;如果每一维都有 T[i] = T'[i]
,则认为 T = T'
;其它情况,都认为 T
和 T'
不可比。
条件 a < b
推出 T(a) < T(b)
的结论是比较简单的,与 Lamport 时钟类似,这里给个图,不多说明了:
从 T(a) < T(b)
反推 a < b
呢?其实从 T
的定义来看,可以理解成 T
代表的是当前事件及之前发生的所有事件的集合,而 T(a) < T(b)
可以等价于集合的从属关系,那么事件 a
一定包含在 T(b)
里,因此 a < b
[3]。
Lamport 时钟解决的是分布式系统下的因果一致性问题,方式是在多机有交互时求计数器的 max
。它的局限是无法从计数器的大小反推事件的先后顺序。
Vector 时钟基本思路和 Lamport 时钟一样,但它在每个机器上都维护了最后看到的,其它机器的计数器。
Lamport Clock,也称为Lamport Timestamp,以发明者 Leslie Lamport 命名,Lamport 也是著名的 Paxos 的发明者。 ↩
注意这张图和上面 lamport 时钟的示例,算法的细节上有简化,收到信息时没有递增计数器 ↩
写到这里的时候受到知识的诅咒了,不管是从图像来看,还是从集合的视角来看,都太显然了,如果读者没理解的话,推荐看 Martin Kleppmann 的教程,说得比我明白。当然他的教程里有数学表示,更精确。 ↩
Striped64
源码(没看过的可以看看博主的这篇文章),可能遇到过 @Contended
注解。如果你经常看 C 语言的代码,也可能遇到过在结构体加 padding 的情形。它们都是为了提高缓存的性能,解决伪共享(False Sharing)的问题。首先需要知道一个概念:cache line(缓存行)。缓存从内存加载数据时,并不是只加载我们请求的那部分,而是会多加载一些,例如我们想访问一个 int,只有 4 字节,但缓存会一次性加载 64 字段(不同机器不同)。缓存每次处理的这一“块”数据,就叫 cacheline。
为什么缓存要多加载数据呢?为了利用空间局部性(space locality)来提高性能[1]。相当于是缓存做了猜想,后续地址的数据,通常就是接下来马上要访问的数据,提前加载能提高性能。
现代 CPU 体系中,一般每个核都单独配备了自己的(L1)缓存,为了保证多个CPU 在读写缓存时保证整体数据的一致性,通常需要使用缓存一致性协议,MESI 就是其中一种(可以参考博主的这篇文章)。
MESI 协议可以简单理解为“踢人协议”,如果一个 CPU 写数据到缓存里,则需要“踢”掉其它缓存里的副本。
上图中,第 ⑦ 步就是“踢人”的操作。同时要注意如果 CPU 对缓存只做“读”操作,缓存也是需要同步的,如上图的第 ⑤ 步,只是它的开销更小。
缓存一致性说的是一个 cache line 在不同缓存间的同步操作。那如果一个 cache line上存了两个变量,并且两个变量分别被不同的线程写入呢?
可以看到,虽然 CPU A 和 CPU B 各自在写自己关心的变量 x
和 y
,但由于它们存在于同一个 cache line,每次写入都会造成另一个 CPU 的缓存失效。造成严重的性能问题。
我们看到伪共享的发生有两个条件:
由于 #2 条件多线程处理一般是业务要求,解法通常是打破 #1 条件:加 padding,让一个cache line 里只保留一个变量[3]。例如 int
只占4
字节,可以在后面加 15个没用的 int 变量,撑满 64
字节[4]。而Java 专门提供了@Contended
[5] 来简化这种情形。
JMH 有一个测 False Sharing 的Benchmark,在我的机器上(20c、Java 11)运行结果[6]如下:
Benchmark Mode Cnt Score Error Units |
baseline、contended 及 padded 吞吐上的差别大概 10%
(网上一些文章差异在 2 倍、3 倍,和我的结果出入这么大的原因还没找到)。我们再用 perf 对比 cache misses:
---------------------------- Baseline ------------------------------------ |
对比其中的 L1-dcache-load-misses
,可以看出,加了 @Contended
的 cache miss只有 baseline 的 3%
。
缓存的加载写入以 cache line 为单位,典型的大小为 64B。为了保证多 CPU 下缓存数据的一致性,需要使用一些缓存一致性协议,MESI 是其中的一个经典协议,写入缓存行时会“踢掉”其它 CPU 上的缓存。如果两个变量在同一个 cache line 中,且多线程频繁读写这两个变量,会导致多 CPU “互踢”对方的 cache line,导致性能下降。在博主的机器上 False Sharing 实测大概慢 10%,而 cache miss 大概是正常的 33 倍。
@Contended
功能及实现注意一般 C 语言里 padding 还有另一个作用,将数据按“字”来对齐地址,这也有助于提高性能,但缓存的视角主要还是在局部性上 ↩
两个线程同时写入的情况比较明显;一写一读也有问题;两个线程都是读则没有问题 ↩
采用 padding 的方式其实不一定靠谱,因为编译器优化有可能会把没用的字段去掉 ↩
64
这个数字并不是固定的,有些机器会设置为 128
字节,Linux 下可以执行 getconf LEVEL1_DCACHE_LINESIZE
来查看 cache line 大小,MacOS 下执行 sysctl hw.cachelinesize
↩
Java 8 中通过 @sun.misc.Contended
引用,Java 9 及之后,通过@jdk.internal.vm.annotation.Contended
引用,但需要额外 export 一些包 ↩
需要在启动参数上加上 -XX:-RestrictContended
,用户代码里加的 @Contended
才能生效。 ↩
The MESI protocol is an Invalidate-based cache coherence protocol, andis one of the most common protocols that support write-back caches.
发现其实只要能理解什么是 “Invalidate-based”,MESI 协议就很容易理解了。在这之前先补充些相关知识。
当一份内存的数据存储在缓存时,我们有必要保证两者是一致的。假设我们修改了缓存上的数据,这份数据要如何同步回内存呢?常见的有两种方法[1]:
它们的核心区别在于更新操作是“同步”还是“异步”。显然异步的写入性能更高。
“一致性”这个词的含义深挖的话还挺深奥的,类似的内容可以参考博主的另一篇文章:什么是顺序一致性。这里举一个可能容易理解但不太准确的例子:
假设没有缓存,多个 CPU 对同一个内存地址做读写,逻辑上,我们会认为这些操作是原子的,有顺序的。假设当前内存的值是 0
,CPU1 先发出写操作 W(1)
, CPU2 再发出读操作 R
,则逻辑上我们理解 CPU2 一定要读到 1
这个值。
现在假设两个 CPU 都有自己的缓存,CPU1 先发出 W(1)
写到自己的缓存,因为使用了Write-Back 技术,还没有更新到内存,此时 CPU2 发出 R
,读到的是自己的缓存(或者缓存不存在从内存加载),读到的还是 0
,和我们上面说的预期不一致。
缓存一致性是指:通过在缓存之间做同步,达到仿佛系统不存在缓存时的行为。一般有如下要求:
这也对应我们一般说的可见性和顺序性。
一份数据,缓存 A 有副本,缓存 B 也有副本,这时如果对 A 有修改,那 A、B 就不一致了,怎么办?Invalidate-based 的思路是,对 A 有修改,就想办法让其它副本都失效,只剩下 A 这么一个副本,不就没有“不一致”的情况了?
那其它缓存要再读数据时怎么办?简单,让剩下的那个副本把数据写回到内存,再从内存里把最新的数据捞到缓存即可。
MESI 就是用 4 个状态实现了状态机,实现了这个逻辑,我喜欢把它叫作“踢人”逻辑。
MESI 的状态机包含了 4 个状态,也是名字的由来:
CPU 会有读写操作,记为 PrRd
和 PrWr
,缓存接收到操作后需要与其它缓存同步并更新状态,同步的信息通过总线传递,同步信号有 5 种:BusRd
, BusRdX
,BusUpgr
, Flush
, FlushOpt
,不用记具体的含义,我们只需要知道,这些信号的作用和目的,就是为了在自己接收到写入操作时,把其它缓存踢掉。
考虑缓存 A 和缓存 B 都有一个副本,都处于 Shared 状态,此时 A 接收到写入操作PrRd
,则有如下变化:
BusUpgr
,代表自己要更新缓存上的数据BusUpgr
信号后,主动把状态变为 Invalid
MESI 如果简单粗暴地实现,会有两个很明显的性能问题:
因此 CPU 在实现时一般会通过 Store Buffer 和 Invalidate Queue 机制来做优化。
在写入 Invalid 状态的缓存时,CPU 会先发出 read-invalid(这样其它 CPU 的缓存行会写入更改并变成 Invalid 的状态),然后把要写入的内容先放在 Store buffer 上,等收到其它 CPU 或内存发送过来的缓存行,做合并后才真正完成写入操作。
这会导致虽然 CPU 以为某个修改写入缓存了,但其实还在 Store buffer 里。此时如果要读数据,则需要先扫描 Store buffer,此外,其它 CPU 在数据真正写入缓存之前是看不到这次写入的。
当收到 Invalidate 申请时(如 Shared 状态收到 BusUpgr),CPU 会将申请记录到内部的Invalidate Queue,并立马返回/响应。缓存会尽快处理这些请求,但不保证“立马完成”。此时 CPU 可能以为缓存已经失效,但真的尝试读取时,缓存还没有置为 Invalid状态,于是读到旧的数据。
这些优化的存在,要求我们在代码里使用内存屏障,插入 store barrier 会强制将store buffer 的数据写到缓存中,这样保证数据写到了所有的缓存里;插入 readbarrier 会保证 invalidate queue 的请求都已经被处理,这样其它 CPU 的修改都已经对当前 CPU可见。
不做相关工作也不用太深入。大概就是如果 CPU 要读的数据在其它 CPU 中都不存在,则对于 MSI 来说需要通过 2 个总线事务才能捞到数据,但 MESI 只需要一次。
本文所有内容均来源于 MESI 的 wiki。文章的核心想是指出要理解 MESI 协议,关键在于理解它是一个“基于缓存失效”的协议,理解了这点,就能理解 MESI 的状态机为什么要这么做。
另外简单讨论了 MESI 之下为什么还需要内存屏障,以及 MESI 和同类 MSI 的区别。
博主做的是上层的应用开发,点到为止已经够用了。
正常函数调用的控制流是“单入单出”,从调用开始,正常或异常返回后结束,调用的栈帧也随之销毁。而异步编程要求在函数执行到一半时,“暂停”控制流,在未来的某个时刻再“恢复”。由于控制流尚未结束,因此调用链路上的栈帧还不能被销毁,这些信息需要以某种形式保存。可暂停可恢复的控制流,加上它所保存的信息,就可以称为“协程”。
函数调用过程中使用的临时变量会记录到栈上,这些信息是与某个函数的某次调用绑定的,调用结束后就被废弃,这些数据就是栈帧。物理形态上,通常栈帧是“叠”在一起的,例如函数 A 中调用了函数 B,而 B 又调用了 C,则在 C 运行中,栈的状态类似下图:
| ... | |
Python coroutine[1] 的处理方式是直接保存栈帧。调用的最内层通过 yield
暂停控制流,中间层通过 yield from
或 await
[2] 将内层的coroutine 一路往外传,需要恢复时,再使用 send
方法恢复执行[3]:
def inner(): |
注意在 coroutine 中,最终的返回值是通过 StopIteration
带出来的。
此外,外层拿到的 coro
其实包含了最内层 inner
的栈帧(需要了解yield from 的机制),因此第二次调用coro.send(None)
时,会从 inner
函数 yield
处恢复执行。
对于缺少 GC 的语言来说,移动、复制栈帧是个原理可行,实际几乎不可行的操作。这些语言里手工创建的指针,可以指向栈上分配的内存,指针还可能被其它线程引用。栈帧移动时,这些指针都需要“修复”;栈帧复制时,数据多了份引用,内存释放又成问题。
Rust 使用了“状态机”的方式来实现控制流的暂停、恢复的能力[4]。
首先是最内层的暂停逻辑,与 Python 不同,内层没有专门的暂停机制,只约定了接口,如果(因为资源未就绪)要暂停,则返回一个特殊值(Poll::Pending
),由调用方来决定是否真的暂停和处理恢复。
pub trait Future { |
Python 的中间层会通过 yield from
向外传递栈帧[5],那 Rust的中间层如何对外层提供暂停、恢复的能力呢?Rust 里提供了 await
关键词来表达等待内层的 future[6]:
fn inner1() -> impl Future<Output = u32> { |
那么 async/await
底层发生了什么?Rust 编译器会做这么几件事:
async fn
定义时,会把 middle
方法的返回改为 Future<Output=...>
await
为拆分点,拆成状态机的 N 个状态,每个状态存储下个await 可见的变量和 future上面的例子编译器会编译成类似下面的这些代码[7]:
// 状态存储 |
可以看到中间层返回的 StateMachine 本身记录了内部调用的 Future 所处的状态。最外层的调用方如果需要恢复执行,只需再调用 middle
返回 future 的 poll
方法即可,middle
会根据当前状态决定去 poll
哪个内层 future。
异步编程的特征之一,是当资源未就绪时,先暂停当前控制流,先执行其它可推进的逻辑,等资源就绪时,再恢复之前暂停的控制流。那什么时候才知道资源就绪呢?一般有两种方法:轮询与中断。
轮询很好理解,就是外围调用方不断调用 poll
方法去查看当前资源的状态是否就绪:
future = middle(); |
但如果是这么做,资源未就绪前会不断执行 future.poll
,浪费 CPU。此时空闲的 CPU可以用来处理其它就绪的 future,于是可以把所有需要轮询的协程添加到一个队列里,这样一个线程就可以处理 N 个协程。伪代码如下:
loop { |
这会引申出一个问题:在 ① 中,如果 middle future 的结果就绪了,接下来需要执行哪部分代码呢?显然需要从 future 暂停的地方接着执行(即 outer 的后续逻辑),但我们怎么找到外层的逻辑?
一种想法是把外层逻辑也封装成一个 future[8],队列里直接存放outerfuture 而不是 middle future,恢复时只要执行 outer future 的 poll
方法即可。这就是异步编程的传染性,只要内部有一处异步,它的每个调用方都需要是异步的,一直到顶层的 main 函数[9]。
于是就像有多个线程一样,我们的队列里可以存放 N 个顶层的 future,可以类比成轮询N 个 main 函数。这个不断从队列中获取新的协程并调用 poll
的角色在 Rust 里叫executor[10]。
loop { |
② 中的逻辑会不断把未就绪的 future 放入队列,这样每轮轮询时都会 poll 所有future,这样依旧会浪费很多资源(CPU & IO),最理想的方式是每次 poll 时只poll 那些“很有希望 ready”的 future。这就是我们下面要说的“中断”的模式,当资源就绪时,再把future 加入队列。
我们希望 future 只在资源就绪时才被重新放回队列[11],于是executor 需要提供如下方法(伪代码):
let mut ready_queue = Queue::new(); |
现在的问题是:“谁”负责在“什么时候”调用 wake_up
方法?
先来看“谁”的问题,唤醒的条件是资源就绪,那必然是资源的拥有者来唤醒,而只有“最内层”的协程才知道它等待的是什么资源,因此需要最内层的协程(通过注册回调函数)来触发。但是 wake_up
唤醒的时候得唤醒最外层的协程,即上面伪代码的参数 n
,于是每次调用 poll 都需要把 n
一路下传到最内层:
fn run() { |
当然,伪代码里用 future 的序号 n
来唤醒外层 future 是一个实现细节。回过头来看 rust Future
接口,它包含了一个 Context
的引用,cx.waker()
可以获得“唤醒器”,再调用wake
方法即可唤醒对应的最外层的协程。与 n
一样,每次对poll
的调用,都需要把 cx
一路下传到最内层。
pub trait Future { |
另一个问题是“什么时候”调用,显然是“资源就绪”时。那怎么知道资源什么时候就绪?这就需要资源的提供方来通知了。通常异步编程多是在处理 IO,对于 IO 一般是操作系统通过 select
或者 epoll
等等机制提供了异步通知的能力。代码里需要在等待资源时加上回调函数。整体逻辑如下图:
其中的 reactor 会监听所有在等待的资源,如果某个资源就绪了,同步的 poll
会返回就绪的资源,reactor 会调用它们的回调函数(即 wake
方法来唤醒)。Rust 里一般把 executor 和 reactor 合起来称为 Runtime。
前文的描述都是以 Rust 为样例,这是因为 Rust 里的角色分得相对更清楚一些。像 executor 和 reactor 的能力,在 Python 里都囊括在event loop里了,能监听什么资源,也被安排得明明白白了。
Python 里也经常用到Future,但它的概念和 Rust 里的不太一样,Python 中的 Future
本身是一个协程(实现了await方法),另外有一个 set_result
方法能设置最终结果,结果设置后,协程就能正常返回了(类似Rust里返回 Poll::Ready
)。
Python 里的一个典型协程工作流如下所示:
图里包含了比较多的细节,整体逻辑和 Rust 类似,注意几点:
inner
注册监听事件时,Python 的做法是创建一个 future、注册事件,await future
loop.call_soon
延迟执行的future.set_result
之后,也是通过 call_soon
延迟唤醒协程task
而不是 coroutine
,它是一个包装类,提供了取消、唤醒等功能异步编程的优势主要是节省线程数量(从而节省线程占用的栈等资源),也有说减少线程切换来节省 CPU 消耗。但总的来说,异步的最大作用和目标是提高吞吐而非降低延时。
但是,异步编程的缺点也很明显,最关键的是它的“传染性”,只要有一处要异步,所有地方都需要异步。另一个是“隔离性”,它的生态和同步的方法天然不通,一般为了支持异步,几乎所有同步的标准库都需要重写一个异步版本的。我甚至认为如果“高吞吐”不是产品的核心特性(如网关),就不应该使用异步框架。
本文尝试挖掘 Rust 和 Python 实现异步框架的模式,让我们对异步的底层实现建立一个概念,希望借助这些概念,去理解、解决编程中遇到的异步相关问题。文章主要讲解了三方面的内容:
Python 的 coroutine 和 generator 基本是同一套实现机制,本文里有时会混用两个术语 ↩
如果用 await 则要求内层调用实现了 __await__
方法 ↩
ref: https://peps.python.org/pep-0342/#new-generator-method-send-value ↩
推荐看这篇文章:https://os.phil-opp.com/async-await/#the-async-await-pattern ↩
这里说法不太准确,但不影响理解。yield from
只是把各个coroutine 连接在一起,不会真的返回栈帧 ↩
在有 await 及编译器支持之前,基本是需要人肉做状态的保存和恢复的 ↩
代码改编自 https://os.phil-opp.com/async-await/#the-async-await-pattern ↩
这里的含义是 outer 方法也使用 await
来获取结果。 ↩
如果调用方自己不做成异步,则需要在代码里“同步”等待 future.poll返回 ready,或者等待统一轮询队列的就绪通知,无论如何,它所在的线程在内部的异步任务完成前是不会释放的,就达不到异步编程“节省线程”的目的了。 ↩
文中只展示了简单的模型,executor 的实现可以相当复杂,参考 Making the Tokio scheduler 10x faster ↩
当 future 刚被创建时我们并不知道它是否就绪,此时也需要放入队列触发第一次 poll,在 poll 里如果资源未就绪,由 future 来注册后续的回调,因此当 future 第二次通过回调再被加入队列时,就“有信心”它依赖的资源就绪了。 ↩
Kubernetes gives Pods their own IP addresses and a single DNS name for a setof Pods, and can load-balance across them.
K8s Service会为每个 Pod 都设置一个它自己的 IP,并为一组 Pod 提供一个统一的 DNS 域名,还可以提供在它们间做负载均衡的能力。这篇文章会对 kube-proxy 的 iptables 模式内部的机制做一个验证。大体上涉及的内容如下:
创建一个 Service,配置如下:
apiVersion: v1 |
创建后的 service 如下:
$ k get svc -o wide -A |
注意其中的 spring-test 和 kube-dns 两项,后面会用到。另外 service 对应的 podIP 如下:
$ k get ep |
K8s 会为 Service 创建一个 DNS域名,格式为 <svc>.<namespace>.svc.<cluster-domain>
,例如我们创建的spring-test
Service 则会有spring-test.default.svc.cluster.local
[1] 域名。
我们首先进入 pod,看一下 /etc/resolv.conf
文件,关于域名解析的配置:
nameserver 10.1.0.10 |
这里的 10.1.0.10
是 kube-dns service 的 cluster IP
文件中配置了多个 search 域,因此我们写 spring-test
或spring-test.default
或 spring-test.default.svc
都是可以解析的,另外注意解析后的 IP 也不是具体哪个 POD 的地址,而是为 Service 创建的虚拟地址ClusterIP。
root@spring-test-77d9d6dcb5-m9mvr:/# nslookup spring-test |
ndots:5
指的是如果域名中的 .
大于等于 5 个,则不走 search 域,目的是减少常规域名的解析次数[2]
DNS 里创建的记录解决了域名到 ClusterIP 的转换问题,发送到 ClusterIP 的请求,如何转发到对应的 POD 里呢?K8s Service 有几种实现方式,这里验证的是 iptables 的实现方式:kube-proxy 会监听 etcd 中关于 k8s 的事件,并动态地对 iptables 做配置,最终由 iptables 来完成转发。先看看跟这个 Service 相关的规则如下:
0. -A PREROUTING -j KUBE-SERVICES |
我们先用 iptables-save
打印出所有的规则,筛选出和 spring-test
service相关的规则,删除了一些 comment,并对名字做了简化。可以看到有这么几类:
KUBE-NODEPORTS
,这类规则用来将发送到 NodePort 的报文转到 KUBE-SVC-*
KUBE-SERVICES
:是识别目标地址为 ClusterIP(10.1.68.7
),命中的报文转到KUBE-SVC-*
做处理KUBE-SVC
的作用是做负载均衡,将请求分配到 KUBE-SEP
中KUBE-SEP
通过 DNAT 替换目标地址为 Pod IP,转发到具体的 POD 中另外经常看到 -j KUBE-MARK-MASQ
,它的作用是在请求里加上 mark,在POSTROUTING
规则中做 SNAT,这点后面再细说。
我们开启 iptables 的 trace 模式[3],并在其中一个 pod 发送一个请求,检查 TRACE 中规则的命中情况(由于输出特别多,这里挑选了重要的输出并做了精简):
0: nat:PREROUTING IN=cni0 OUT= SRC=10.244.1.7 DST=10.1.68.7 DPT=8080 |
PREROUTING
时,进入第 6 条进判定KUBE-SERVICES
判断目标地址为 10.1.68.7
且目标端口为 8080
,于是跳转进入 KUBE-SVC-S
链的判断KUBE-SVC-S
有多条规则,从日志看最终是从第 10 条退出,进入 KUBE-SEP-A
链KUBE-SEP-A
最终命中第 3 条规则退出,但此时会进行 DNAT 转换目标地址DST
目标地址已经变成 pod 地址 10.244.2.3
了类似的,如果我们是通过 NodePort 来访问 Service,则 Trace 日志如下:
0: nat:PREROUTING: IN=eth0 OUT= SRC=192.168.50.135 DST=192.168.50.238 DPT=31080 |
上一节我们比较关注 iptables 转发的内容,那么如何做负载均衡?这部分是比较纯粹的iptables 知识[4]:
首先:iptables 对于规则的解析是严格顺序的,所以如果只是单纯列出两个条目,则会永远命中第一条:
-A KUBE-SVC-S -j KUBE-SEP-A |
于是,我们需要第一条规则在某些条件下不命中。这样 iptables 就有机会执行后面的规则。iptables 提供了两种方法,第一种是有随机数,也是上一节我们看到的:
-A KUBE-SVC-S -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-B |
这条规则在执行时,iptables 会随机生成一个数,并以 probability
的概率命中当前规则。换句话说,第一条命中的概率是 p
,则第二条规则就是 1-p
。如果有 3 个副本,则会类似下面这样的规则,大家可以计算下最后三个 Pod 是不是平均分配:
-A KUBE-SVC-S --mode random --probability 0.33333333349 -j KUBE-SEP-A |
另外一种模式是 round-robin,但是 kubernetes 的 iptables 模式不支持,这里就不细说了。猜想 kubernetes iptables 模式下不支持的原因是虽然单机 iptables 能支持round-robin,但多机模式下,无法做到全局的 round-robin。
前面我们提到 KUBE 系列的规则经常看到 -j KUBE-MARK-MASQ
,和它相关的规则有这些:
-A KUBE-MARK-MASQ -j MARK --set-xmark 0x4000/0x4000 |
首先 KUBE-MARK-MASQ
的作用是把报文打上 0x4000/0x4000
的标记,在KUBE-POSTROUTING
时,如果报文中包含这个标记,会执行 -j MASQUERADE
操作,而这个操作的作用就是做源地址转换(SNAT)。那 SNAT 是什么,为什么要做 SNAT 呢?
这里引用这篇文章里的图做说明:
如果没有 SNAT,被转发到 POD 的请求返回时,会尝试把请求直接返回给 Client,我们知道一个 TCP 连接的依据是(src_ip, src_port, dst_ip, dst_port),现在client 在等待 eIP/NP
返回的报文,等到的却是 pod IP
的返回,client 不认这个报文。换句话说,经过 proxy 的流量都正常情况下都应该原路返回才能工作。
在一些情况下可能希望关闭 SNAT,K8S 提供 externalTrafficPolicy: Local
的配置项,但流量的流转也会发生变化,这里不深入。
这篇文章和上一篇Flannel 网络通信验证类似,都是尝试搭建环境,在学习 kube-proxy 工作机制的同时,对 kube-proxy 的产出iptables 做一些验证。文章中验证了这些内容:
/etc/resolv.conf
中搜索域的设置这篇文章的信息量不大,希望读者也撸起袖子,实打实地做一些验证,能让我们对kube-proxy 涉及的 iptables 的操作有更深刻的理解。
Kubernetes 规定了网络模型,要求[1]如下,flannel 只是其中一种实现。
使用 3 个虚拟机搭建的 Kubernetes 1.23 集群,其中 Flannel 版本为 0.16.1. 上面起了两个服务,分别为两副本。Pod 信息如下:
$ k get pods -o wide |
实验里会尝试说明 sender-779db554f9-kr69b
(10.244.1.7
) 到spring-test-77d9d6dcb5-m9mvr
(10.244.2.3
)之间的网络通信。
首先要说明的是 Pod 里看到的网卡,在宿主机上是如何实现的,这部分知识强烈推荐这篇文章:How Do Kubernetes and Docker Create IP Addresses?!。具体来说,是要确认下面这部分内容:
首先,我们进入 sender-779db554f9-kr69b
所在 pod,看到网卡信息如下(省略了loopback):
root@sender-779db554f9-kr69b:/# ifconfig |
注意 pod 的 IP 地址和 MAC 地址,之后我们在 centos71 机器上列出所有网卡信息:
[jinzhouz@centos72 ~]$ ip link |
并没有发现 Pod 里使用的这张虚拟网卡(MAC 地址没有匹配上的)。发现不了的原因是Kubernetes/Docker 等虚拟化方案,本质上是用 namespace/cgroups 对资源进行隔离,Pod 里使用的虚拟网卡,其实在另一个网络 namespace 下,那么如何确认这一点呢?参考这里 需要如下步骤:
查找 pod 对应的 docker container id(这里找的是 k8s 起的 pause container):
$ sudo docker ps --format '{{.ID}} {{.Names}} {{.Image}}' |
这里我们要找的是 k8s_POD
开头的镜像,然后查找它的 pid:
$ sudo docker inspect --format '{{.State.Pid}}' d7226b120121 |
查询 PID=513 进程对应的 veth 网卡
[jinzhouz@centos72 ~]$ sudo nsenter -t 513 -n ip link |
可以看到它的 MAC 地址和 POD 里看到的 MAC 地址是一样的。说明 POD 里使用的网卡就是这一张。
上文提到每个 POD 的网卡是在自己的 namespace 下的,既然 namespace 是用来做网络隔离的,不同 namespace 下的网络自然是不通的。但是 k8s 又要求“node 与 pod 之间是可以直接通信”,于是我们需要打通两个 namespace,让宿主机和 POD 能直接通信。
这里使用的技术是 Virtual Ethernet(VETH),VETH 是成对出现的,可以理解成创建了一条隧道,两端各是一张网卡,可以分别位于两个 namespace 之中,发往其中一端的包等价于发给另一端,这样就可以打通两个namespace。我们看 pod namespace 下的网卡:
[jinzhouz@centos72 ~]$ sudo nsenter -t 513 -n ip link |
注意到网卡中的 @if11
字样,另一个关键信息是 link-netnsid 0
,说明它关联的是ID 为 0
的 namespace 下的 ID 为 11
的网卡。我们首先确定namespace[2]:
$ sudo ls /var/run/netns # docker 创建的 namespace 需要软链后才能查到 |
虽然没有直接展示,但 0
对应的是默认的 namespace,也就是宿主机的 namespace。再结合之前的输出:
11: veth8360c992@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue master cni0 state UP mode DEFAULT group default |
可以确认它关联的是 veth8360c992@if3
这个网卡。同理也可以反推 veth8360c992
关联的是 netnsid = 2
的 id = 3
的网卡,也是符合预期的。
如果我们尝试通过 ip addr
查看 veth
网卡的 IP 地址,会发现它们是没有 IP 的:
[jinzhouz@centos72 ~]$ ip addr |
这是因为对于每个 POD,宿主机上都会创建 veth
虚拟网卡,而为了更方便这些卡的管理,k8s 会创建一张桥接的网卡 cni0
。可以通过下面的命令查看:
[jinzhouz@centos72 ~]$ brctl show cni0 |
桥接(bridge)网卡可以认为是一个 2 层的交换机,当它收到一个报文时,会根据自己维护的 MAC 地址映射表将报文从不同的端口发出,如果没有找到 MAC 地址则会往所有端口都发一份。它的 MAC 映射表如下:
[jinzhouz@centos72 ~]$ brctl showmacs cni0 |
对数据敏感一些会发现出现的两个 MAC 地址分别对应 veth45885375
和veth8360c992
。
那么当 Pod 中向另一个宿主机上的 Pod 发请求时,会发生什么呢?整体流程如下:
首先请求发到 Pod 内的 eth0 网卡,通过我们上面说的 VETH 的机制,相当于发送到cni0
网卡
此时内核需要查路由表,决定发送到哪个网卡:
[jinzhouz@centos72 ~]$ route |
我们发现目标地址 10.244.2.3
命中 10.244.2.0
网段,于是发往 flannel.1
网卡
接下去需要由 flannel.1
将报文通过 eth0
端口发到 centos73
机器上,这里涉及 vxlan 的工作机制,下面详细说。
vxlan 可以这么理解:如果有一个 2 层的包,源地址是:MAC-A,目标地址是:MAC-B,但 MAC-B 可能在一个遥远的机器上,通过链路层无法直接到达。vxlan 的想法是把这个二层的包封装成一个 3 层的UDP,将 UDP 包发送到目标机器上,目标机器再把 2 层的包拆出来,发送到 MAC-B 所在的网卡。
Flannel 创建的 flannel.1
网卡就配置了 vxlan:
[jinzhouz@centos72 ~]$ ip -d link show |
可以看到输出里有 vxlan
字样,代表它的类型是 vxlan。那么 vxlan 具体如何工作呢?
flannel.1
收到请求,查找目标的 MAC 地址。请求包需要发往 10.244.2.0
,flannel.1
需要决定,转发给哪个 MAC 地址才有可能到最终的目的地,这里和传统的转发没有区别,需要查找 ARP 表:
[jinzhouz@centos72 ~]$ arp |
flannel.1
决定将包发往 16:c7:83:3b:52:63
地址,此时 vxlan 机制介入,将这个包封装成 UDP 包,但是它需要知道,16:c7:83:3b:52:63
物理地址对应的包,需要发到哪台机器上,此时需要查找转发表 fdb:
[jinzhouz@centos72 ~]$ bridge fdb show |
根据 fdb 表中的 dst 192.168.50.145
,flannel.1
知道需要将 UDP 包发往192.168.50.145
这台机器。但真正发送又需要查找路由表:
[jinzhouz@centos72 ~]$ route |
于是 UDP 包从 eth0
网卡发出,当然过程中也需要查找 ARP,这些常规操作不再赘述。
接收方主要处理 vxlan 报文进行解包,同时要在网桥处需要转发到正确发送方,整体流程如下:
接收方 centos73 机器的 eth0
网卡接到 vxlan 的 UDP 包,将包解开发现是一个2 层的包,需要发往 16:c7:83:3b:52:63
,即 centos73 上的 flannel.1
网卡
flannel.1
接收到包,发现是 3 层的发往 10.244.2.3
的包,查找路由表决定转发给 cni0
:
[jinzhouz@centos73 ~]$ route |
cni0
接收到报文,需要决定发给哪个 MAC 地址,此时需要查 ARP 表:
[jinzhouz@centos73 ~]$ arp |
于是 cni0
需要将包发给 ee:28:c4:70:20:89
,但是 cni0
本身是个网桥(bridge),相当于一个交换机连接了两根网线,现在要往哪个口发呢?先看 MAC 表
[jinzhouz@centos73 ~]$ brctl showmacs cni0 |
由于 MAC 表里没有 ee:28:c4:70:20:89
的条目,于是 cni0
会先将请求广播,两个口都发包,等待请求,当然最终会由 vethc3fdc583
网卡响应,也可以看到MAC 表的更新:
[jinzhouz@centos73 ~]$ brctl showmacs cni0 |
于是,请求发往 vethc3fdc583
网卡,并由于 VETH 的作用,相当于发到了 podspring-test-77d9d6dcb5-m9mvr
对应的网卡上,到达目的地。
上面提到的内容里,除了 flannel.1
网卡的名字,其它内容似乎看不到 Flannel 的身影,那么 flannel 做了哪些事呢[3]?
flanneld 在宿主机启动时会为宿主机注册子网,如 10.244.1.0
;添加到其它宿主机的路由条目;同时为 flannel.1
配置 vxlan 模式(当然也支持其它模式)
[jinzhouz@centos72 ~]$ route |
配置宿主机 ARP 条目,将其它宿主机的子网,如 10.244.2.0
指向 flannel.1
网卡,且目标地址是对方宿主机上 flannel.1
的 MAC,如 16:c7:83:3b:52:63
[jinzhouz@centos72 ~]$ arp |
配置 FDB 表,将发送给 16:c7:83:3b:52:63
的请求,通过 192.168.50.145
发送
[jinzhouz@centos72 ~]$ bridge fdb show |
可以看到 flannel 的主要作用就是自动创建资源,然后(监听 etcd 中关于节点变动的消息)动态对 ARP、FDB 表做维护。
本文是博主自己在学习 Flannel 过程中,结合现有的环境做的一些“验证”,尝试去理解Flannel 中各个环节的机制,具体来说有:
另外在实验过程中尝试过用 tcpdump 抓包验证,的确可以验证一些关键信息,如发送接收了 UDP 封装的 vxlan 包,包的 MAC 地址在流转中变化等。但具体流经哪张网卡,以及其中的查表机在tcpdump中无法体现,因此这里也没有做记录。
当然,计算机网络是非常复杂的,博主也并非网工专业人士,如有理解不到位之处,请评论区指出。
免责声明:以下所有内容都是个人理解,可能与事实不符。
垃圾回收,需要先找出什么是垃圾,之后才能谈回收问题,一些方法
引用计数在 C++ 和 Rust 之类的语言中比较常用,Java 中用的是 Tracing 的方式,遍历对象间的引用。那么从哪开始遍历呢?这些遍历的起始点称为 GC Root,在 Java 中有这么一些:
java.util.*
里的类上面的 GC Roots 没列全,非专业做 GC 的话其实也没必要掌握。关键需要了解 GC Root 代表的就是我们“确定”还在用的引用,比如方法里创建了一个 HashMap
,方法还返回前都“确定”还会用到,就认为是 Root(这里说得不准确,可能 new 出的对象就没人用,但从算法角度还是认为它是 Root)。
有了 GC Root,要如何扫描呢?Java 里用的是三色算法。三色算法是一个“逻辑算法”,本质上就是是树/森林的遍历,但为了方便描述和讨论,把遍历过程中的节点细化成三个状态:
每次迭代都会将 Grey 引用的 White 对象标成 Grey,并将 Grey 对象标记成 Black,直到没有 Grey 对象为止。标记之后一个对象最终只会是 Black 或者 White,其中所有可达的对象最终都会是 Black,如下例:
这里并没的说明 Grey 对象的遍历顺序,所以实际上实现成宽搜或深搜都是可以的。
上节说考虑的是“什么是垃圾”的问题,标识出了垃圾对象,下一步是如何“回收”。通常有 Sweep/Compact/Copy 三种处理方式,直观上理解是这样的:
三种方法有各自的优势,需要使用方自己做权衡。这里引用 R 大的帖子 总结如下:
Mark-Sweep | Mark-Compact | Mark-Copy | |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(但有碎片) | 少(无碎片) | 通常需要活动对象的 2 倍(无碎片) |
移动对象? | 否 | 是 | 是 |
这几种方法都有使用。如 CMS 最后的 S 代表的就是 Sweep;传统的 Serial GC 和 Parallel GC,包括新的 G1、Shenandoah、ZGC 都可以理解成是 Compact;而 Serial, Parallel, CMS 的 Young GC 都用的是 Copy。
如果接触过 GC,会知道 GC 最让人头疼的是 Stop-the-World 停顿,GC 算法的一些阶段会把用户线程的执行完全暂定,造成不可预期的停顿。我们希望这个时间尽可能短甚至完全去除。GC 的“效率”跟多方面因素有关,比如活动对象(active object)越多,Marking 需要遍历的节点越多,越耗时;比如内存越大,Sweep 清理垃圾时需要遍历的区域越大,耗时越长;等等。于是人们在想怎么“偷懒”来提升效率。
分代假设就是这样一个发现/假设:
从对象存活时间和对象数量的视角来看,分代假设就是这样的(原图):
当然这个假设不一定符合实际,比如 LRU 缓存,越老的对象越可能被淘汰。不过多数应用还是符合这个假设的。于是如果将对象按时间分成年轻代和老年代,我们就可以偷懒了:
于是在分代假设下,传统的 GC 流程变成了这样(原图):
新对象从 Eden 区分配,Young GC 时存活的进 Survivor 区,Survivor 区有两个,相互做 Copy 操作。在 Survivor 区存活了 15 次 GC 的,就移动到 Old/Tenured 区。Young GC 时会忽略 Tenured 区。
前面提到了 GC 最让人头疼的是 STW 停顿,分代策略让我们频繁做 Young GC,少量做 Full GC,但真的做 Full GC 时停顿时间还是非常大,于是人们想到了并发。CMS 中的 CM 指的是 Concurrent Mark 即是“并发标记”。而 Shenandoah GC 和 ZGC 又实现了“回收”的并发。
开始前要注意的是“并发”和“并行”在 GC 里的概念是不一样的,可以这么去区分:
如早期的 Parallel GC 本质上就是“并行”而不是“并发”,GC 过程还是 STW 的。虽然仅一字之差,“并发”会带来非常多的问题,新的 GC 算法也用了许多解决方案,但这些方案都是有代价的。
前面提到 Java 里会用三色算法来遍历堆中的引用关系,算法假设引用关系在遍历期间不变,如果变化了会怎么样呢?主要有两个场景:新增对象和引用修改。
第一个问题是新增对象:在标记期间新增的对象通过旧的 GC Roots 可能不可达,标记结束后可能还是 White,会被认为是垃圾而被错误释放。
第二个问题是:标记期间应用线程修改引用会影响正确性。
其中一些修改不会造成错误,只是会影响回收效率。如断开 Black1 -> Black2 引用,Black2 最终应该被释放,但不释放 Black2 不会造成程序错误。但如果修改同时满足下面两个条件则会影响正确性:
条件一和条件二的共同结果是,标记过程会遗漏这个 White 对象,因为通过 Grey 对象不可达,且 Black 对象不会被二次扫描。于是 GC 结束后它会被释放,但它同时还被 Black 对象引用着,程序会出错。
并发标记算法如何解决这两个问题?
Incremental update 的想法是破坏条件一。标记期间记录增加的每个 Black -> White 引用中的 White 对象,把它标记为 Grey。对于标记期间新增的对象,则需要在标记结束前重新扫描一次 GC Roots 做 Marking。
在实现上,就需要去“监听” Black -> White 引用的创建。以 CMS 为例:
obj.foo = bar
)后,插入一段代码(称为 barrier,因为是在赋值结束后的 barrier,所以称为 post write barrier),这段代码会记录 foo -> bar 的引用。在标记过程中新增的 Black -> White 的引用,都可以在 Card Table 中找到。于是要保证标记的正确性,只需要在标记结束前从 Card Table 中找到 foo -> bar 的引用,再用三色算法遍历一下 bar 及其引用即可。当然还需要再重新扫描 GC Roots 处理新增的对象。
实现细节上,Card Table 里并不会像 HashMap 一样记录一个 A -> B 的映射,这样存储访问的效率都很低。Card Table 是一个 bitmap,先将内存按 512B 分成一个个区域,称为 Card,每个 Card 对应 bitmap 里的一位。bitmap 置 1 代表对应 Card 中包含需要重新扫描的对象。在标记结束前找到为 dirty 的 Card,重新扫描其中的(所有)对象及其引用。
Snapshot At The Beginning(SATB) 的想法则是破坏条件二,在标记开始之前做快照,快照之后新增的对象都不处理,认为是 Black;当要删除旧的引用(换句话说,在新的赋值 obj.foo = bar
生效之前),记录旧的引用,这样在标记结束前再扫描这些旧的引用即可,这样原先的 Grey -> White 的引用虽然断开了,但 White 对象依旧可以扫到。以 G1 为例:
obj.foo = bar
可以拆成 barrier(obj.foo); obj.foo = bar
,barrier 会对赋值前的指针做记录。因为是在写指针之前做的操作,因此也叫 pre write barrier最终的操作与 Incremental Update 类似,在标记结束前,重新扫描 RSet 里记录的指针,也会有额外的操作把 [TAMS, Top] 之间的对象标记成 Black。
实现细节上,G1 将内存分成了多个 Region。每个 Region 有自己的一个 RSet,这点与 Card Table 不同,它是全局的。RSet 的结构如下:
当然,RSet 的具体实现和上图不太一样,如一般用 HashMap 来存储;但如果 region 里的 card 数过多就会退化成 bitmap;引用的 region 过多,则 region 也会用 bitmap 来存储。细节上也有很多优化,比如 barrier 的更新是先记录到一个 Thread Local 的队列上,异步更新到 RSet 中的。
不管是在 CMS 和 G1 里,并发的内容主要还是以 Marking 为主,Copy/Compact 还是 STW 的。如 CMS 的 Young GC Copy,G1 的 Evacuation Compact,都是 STW 的。为了追求接近硬实时的效果,Shenandoah GC 和 ZGC 都尝试将“回收”阶段并发化,减少 Copy/Compact 的 STW 停顿时间。而正如并发标记里会需要处理新对象和并发修改的问题,并发 Copy/Compact 也会遇到不少问题。
Copy/Compact 的过程,需要先将对象复制到新的位置,再修改所有该对象的引用,指向新的地址。在 STW 的方案下,过程如下(摘自 https://shipilev.net/talks/javazone-Sep2018-shenandoah.pdf ):
*ref = **ref
)但允许并发时,会出现不同线程对不同副本做读写的问题,此时应该保留哪个副本?
并发回收算法的核心也就在于怎么解决 Copy 期间多线程对两个副本的同步。下面会介绍 Shenadoah GC 和 ZGC 的做法,它们都会用到 load barrier 来修正并发情况下应用线程的读操作。
Shenandoah GC 对这个问题的解法是:为每个对象都增加一个 Forwarding 指针,在 Copy/Compact 过程中,通过 CAS 来更新这个指针指向新的副本,期间指向该对象的指针的读写,都要经过 Forwarding 找到正确的对象,如下图所示。
这个方案的有效性本身并不难理解。技术上,这个方案需要拦截所有的对读写操作,让它通过 FwdPtr
完成。Shenandoah GC 通过 Write Barrier + Load Barrier 来完成。
一个小细节:在执行 Write 操作时,Write Barrier 如果发现当前处于并发 Copy 阶段,但对象还没有被 Copy,则 Write Barrier 会执行 Copy 操作,否则写到旧的副本里也没有意义。但读操作时并不会主动做 Copy 的动作。
这个算法的难点在于实现和优化。Shenandoah 中做了许多额外的处理:例如在更多地方增加 barrier,比如 ==
、compareAndSwap
等操作;例如去除对 NULL 检查的 Barrier,把 barrier 放在循环外来提高性能。
另外 Brooks Pointer 中的 Brooks 是人名,Rodney A. Brooks 在 1984 年为 Lisp 发明的。
一个 A->B 的引用有两个参与者,引用方 A 和被引用方 B。Shenandoah 是在被引用方 B 中增加 Forwarding Pointer 来屏蔽底层的 Copy 的动作。而 ZGC 则是在引用方 A 处动手,具体有这么几个机制:
remapped
位代表的当前指针是否指向 Copy 后的地址remapped
位为 0
,代表指针未更新,会查找 forwarding table 的值来更新当前指针,之后再进行访问如果画成图,大概是这样:
相比于 Brooks Pointer,这个算法会更受限,比如无法支持 32 位的机器,不能开启指针压缩等等。
先假设这样一个情形,如果我们看 GC 的日志,记录 GC 开始结束,(虚构)画出下面这张图:
图中 2 的位置,我们发现应用程序的 TPS 和响应时间都变差了,但看了下 GC 的日志发现每次 GC 的停顿时间都很短,可能会觉得 GC 没有问题。但如果仔细观察,会发现 GC 变得频繁了,而 GC 是消耗 CPU 时间的,更频繁的 GC 意味着应用线程能用的时间也更少了,因此会造成 TPS 和响应时间变差的情况。
除了 GC 带来的停顿之外,要意识到 GC 是有代价的:
一般 GC 算法保证的停顿的时间越短,则消耗的 CPU 越大,换言之吞吐越小。没有通用的最优的 GC 算法,根据应用程序的不同和愿意付出的代价来选择 GC 算法吧。
文章中粗浅地讨论了 Java GC 算法中的几个方面:
通常我们会用 free
命令(如下)或 Node Exporter + Prometheus 来监控系统的内存。
$ free -h |
上面的输出中,我们很自然地以为 free
代表可以内存,所以经常会发现这个值特别低,造成“系统的内存用光了”的错觉。在比较新的内核里,会有 available
一项,它才是“可用内存”。
这里有个小知识,free
指的是完全没有被用到的内存,而 Linux 认为内存不用也是浪费,因此会尽量“多”地把内存用来做各种缓存,提高系统的性能。在内存不够用时,它会释放缓存腾出空间给应用程序。因此早期没有 available
这项指标时,一般会认为free + buff/cache
是系统当前的可用内存。那么现在的 available
是如何计算得到的?
free
命令只输出简单几项指标,更详细的指标可以用 cat /proc/meminfo
得到:
MemTotal: 32729276 kB |
指标非常多,一般需要对内核有一定了解才能看懂。这些指标的基础上,有[1]:
MemAvailable <= MemFree + Active(file) + Inactive(file) + SReclaimable |
要理解这个公式,需要了解 Linux 是如何管理内存的。Linux 对内存的管理有多种视角。
SReclaimable
指可回收部分结合上述信息,可以看到可以释放的部分有:
SReclaimable
,是内核可释放的部分MemAvailable 公式的由来就很自然而然了。等等!?公式里的符号为什么是小于等于,不是等于?
上面的公式在详细计算时,并没有考虑 watermark
(虽然代码里有),并且最新的内核已经修改了计算的公式[3],考虑了更多的内容。
计算 wmark_low
。low watermark,当系统可用内存小于 low watermark 时,kswapd
进程会开始尝试释放内存页。首先收集需要的信息:
# cat /proc/zoneinfo | grep min |
每个 ZONE 都有自己的 low watermark(单位为页,页大小为 4K),计算如下
wmark_low = (1 + 230 + 20887) * 4 |
计算空闲页 free_pages
,可以直接由 /proc/zoneinfo
中获取:
# cat /proc/zoneinfo |grep 'free ' |
加总即得到 free_pages
:
free_pages = (3969 + 611300 + 59976587) * 4 |
计算保留内存[4]。保留内存需要综合考虑各项指标:
lowmem_reserve_ratio
ZONE[5] 是逻辑上的划分,lowmem 是指低位的 ZONE 为高位 ZONE 预留的内存[6]。每个 ZONE 都会为更高位的 ZONE 做预留,因此结果是个矩阵:
# cat /proc/zoneinfo | grep 'protection' |
high watermark。高水位线,可用内存超出它时,kswapd
会暂停工作。
# cat /proc/zoneinfo | grep 'high ' |
managed 内存,没查到出处,大概指可被使用的内存。
# cat /proc/zoneinfo | grep 'managed' |
计算如下:total_reserved = Σ(min((max(lowmem) + high_watermark), managed))
total_reserved = Σ(min((max(lowmem) + high_watermark), managed)) |
计算 pagecache = active file + inactive file
,File Backend 的内存可以被释放。
# cat /proc/zoneinfo |grep nr_active_file |
pagecache = active file + inactive file |
pagecache -= min(pagecache / 2, wmark_low)
,并不是所有的 pagecache 都被认为是可用的:
pagecache -= min(pagecache / 2, wmark_low) |
计算 SReclaimable
# cat /proc/zoneinfo | grep nr_slab_reclaimable |
SReclaimable = (0 + 428 + 36989) * 4 |
SReclaimable -= min(SReclaimable/2, wmark_low)
,和 pagecache 相似,不能全用。
SReclaimable -= min(SReclaimable/2, wmark_low) |
available = free_pages - total_reserved + pagecache + SReclaimable
available = 242367424 - 1137092 + 819368 + 74834 |
最终的结果与 /proc/meminfo
的输出(和上小节的数据不同)只有细微的区别:
# cat /proc/meminfo |
实际上差了约 13MB 左右,不过 zoneinfo 和 meminfo 的输出中间有少许的时间间隔,不确定是不是中间内存有了变化。
知道了系统级别的统计方法,自然会想和进程级别的统计做个对应关系。虽然有不少统计进程内存使用的方法,但基本上没办法精确地和系统统计对应。进程的统计指标一般有这几个:
VSZ
:虚拟内存,不直接对应到物理内存RSS
:常驻内存,可以理解成映射的内存的总和。注意进程间有共享的内存页(如libc 库),不同进程加总时会重复计算这部分PSS
:与 RSS
几乎相同,区别在计算时进程共享的内存时,除于了共享的进程数量,因此可以用来加总USS
:该进程独立占用的内存,即扣除了共享的内存页VSZ
和 RSS
可以直接通过 ps aux
输出:
$ ps aux|head |
PSS
和 USS
可以通过 /proc/<pid>/smaps
中的字段统计得到。也可以用工具smem 直接输出和统计。
# PSS:通过 Pss 字段相加得到 |
介绍了几个知识点:
free
中的 available
才是可用内存/剩余内存MemAvailable <= MemFree + Active(file) + Inactive(file) + SReclaimable
好吧,对写业务的我其实也没什么用。
/proc/meminfo
的解释/proc/smaps
文件格式/proc/pagemap
文件格式,内容上可以理解为是 smaps 的数据来源这也是早期的大致计算逻辑:https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=34e431b0ae398fc54ea ↩
MemAvailable 计算的源码入口:https://elixir.bootlin.com/linux/v4.6/source/mm/page_alloc.c#L3732 ↩
保留内存的计算源码入口:https://elixir.bootlin.com/linux/v4.6/source/mm/page_alloc.c#L6248 ↩
Linux 会将物理内存切分成几个 ZONE,在 64 位机器上,一般有 ZONE_DMA
,ZONE_DMA32
和ZONE_NORMAL
,是为了兼容早期的硬件设计而划分的。 ↩
关于 lowmem 和 ZONE 的细致讲解:https://zhuanlan.zhihu.com/p/68465952 ↩
(博主不是搞网络的,也不是搞内核的,如有错误请指正)
0
代表关闭0
代表开启。注:可以简单理解为开启了就是路由器了
向外发送的 IP 报文(非转发报文)的默认 TTL(Time to Live) 值。
注:TTL 即“跳”。当前的实现里,每过一个路由会减少一跳,TTL 为 0 时会被路由器丢弃。
是否关闭路径 MTU 发现功能 (Path MTU Discovery)
min_pmtu
。如果不希望 IP 报文被分片,则需要手工修改系统的min_pmtu
注:MTU(Max Transmission Unit) 指的是每个网络包的有效载荷,是链路层的概念。以太网的 MTU 默认是 1500B(注意不包含以太网的包头包尾),但因为它是链路层的概念,上层的 IP 数据包头部、TCP 数据包头部的长度也要算在内,因此扣除 IP 头部的20B,以及 TCP 头部的 20B,TCP 包的 payload 也只能是 1460B 了。
路径 MTU 指的是路径上所有 MTU 的最小值,注意 MTU 是有方向的,A -> B 的路径 MTU不等于 B-> A 的路径 MTU。
路径 MTU 发现依赖两个要素:IP 头中的 DF 标志以及 ICMP 的“需要分片”报文。当 IP报文设置了 DF 标志,如果某个路由的 MTU 小于报文大小,则因为设置了 DF 标志,路由会丢弃这个 IP 包,并返回“需要分片” ICMP 报文,报文里会携带路由的 MTU,这样发送方就可以逐步学习到路径上的最小 MTU(参考 [1])。
最小的路径 MTU,默认为 522
默认情况在转发报文时不信任 PMTU 的结果,因为很容易伪造,会导致不需要的分组碎片。除非上面跑着自己的应用程序需要使用 PMTU,一般不需要。
控制内核产生的 IPv4 包的 fwmark,只对不与端口绑定的包生效,如 TCP RST 报文、ICMP echo 应答报文。如果选项设置为 0
,则这些包的 fwmark 被设置为 0,如果选项设置为 1
,则这些应答报文的 fwmark 会被设置为它们应答的原报文的值。
注:fwmark 是 firewall mark 的简称。Linux(及其它操作系统)通常允许指定一些规则(如通过 iptables),将一些符合规则的网络包打上标签,即为 fwmark。fwmark 可以用在路由的转发规则(基于策略的路由 ip rule
)中,达到诸如:满足某个条件的包走哪个网卡的功能。
fwmark_reflect 解决的是这样的问题[2]:假设有机器是多网卡,正常情况下我们需要让包“从哪来到哪去”,于是会使用 fwmark 写规则。除了“从哪来到哪去”的需求,还会有“不要回答”的需求,例如有人攻击了机器,我们不希望机器回答如destination port unreachable 的 ICMP 报文,因为这样会透露一些机器的信息。但是我们希望将这些报文路由到内部的审计端口中,而不是发回原始的端口,有了fwmark_reflect 就可以方便地对 ICMP 做标记从而做更复杂的路由策略了。
有多路径的路由下决定下一跳时,是否考虑已经存在的邻居表的状态。如果关闭选项,则不使用邻居表信息,数据包被定向到的下一跳有可能是不通的。需要在编译内核时开启CONFIG_IP_ROUTE_MULTIPATH
选项时才可使用。
注:与该参数相关的是 ECMP(Equal Cost Multi Path) 功能,是路由里的一项技术。简单地说,它是一种通过一致性哈希将包发送到一组权重相同的网络设备的方式。虽然边缘路由器通常不关心包发到哪里,但一般希望同一个 Flow (四元组:源IP/Port、目标IP/port)的包以相同的路径经过各个设备[3]。
注:邻居表[4]存储了当前主机物理连接的主机的地址信息(MAC),Linux 通过 ARP 协议来管理、更新。
ECMP 使用的哈希算法,内核编译时开启了 CONFIG_IP_ROUTE_MULTIPATH
选项才生效。
注:在 ECMP 中,要对一个包进行负载均衡,做哈希时会依赖多种信息[5]:
{source address, destination address} |
{source address, destination address, protocol, source port, destination port} |
{source address, destination address, flow label, next header (protocol)} |
在 synchronize_rcu 被强制触发前可用于存储 fib 条目的脏内存
注:RCU[6] 可以理解成内核的一个读写锁机制,它将“更新”操作分解成“移除”和“清理”两个步骤。例如一个指针 P,现在指向 A,要更新成指向 B,则会先将 P 置为 NULL,此时不会有新的读者引用 A,再等待老的引用了 A 的读者退出,此时可以清理A 对应的资源,再将 P 指向 B。synchronize_rcu
指的是该机制中等待已有读者退出的 API。
转发一个 IPv4 的包后,是否要用 IP 头中的 TOS 字段来更新 SKB 优先级。新的 SKB优先级通过 rt_tos2priority
映射表获得(参见 man tc-prio
)
注:SKB 指的是 socket buffer,SKB 结构中有个字段 priority
用来指定报文在outgoing 队列的优先级。而 TOS[7] 是 IP 协议中用来指定 IP 报文优先级的字段。因此该选项相当于是指定在转发 IP 报文时,要不要支持 TOS 功能。
内核中允许的最大路由条数。如果使用了大量的网卡或加了很多路由项,则考虑加大该参数。从 3.6 开始,对 ipv4 该参数不再推荐使用,因为不再使用路由缓存。
最小保存条数。当邻居表中的条数小于该数值,则 GC 不会做任何清理
高于该阈值时,GC 会变得更激进,此时存在时间大于 5s 的条目会被清理
允许的最大临时条目数。当使用的网卡数很多,或直连了很多其它机器时考虑增大该参数。
对每个未解析的地址,所有排队报文允许占用的最大字节数。(Linux 3.3 新增)。负值无效且返回错误。
默认值:SK_WMEM_MAX
(与 net.core.wmem_default
相同)
具体值随架构和内核版本有变化,一般需要能允许中等大小的 256 个报文排队
对每个未解析的地址,允许排队的最大报文数。(Linux 3.3 不推荐使用):建议用新的unres_qlen_bytes
参数,Linux 3.3 之前默认参数为 3,有时会有意料之外的包丢失,现在的值是通过 unres_qlen_bytes
和真实的包大小计算得到的。
缓存的 PMTU 信息过期时间,秒
通告 MSS(Advertised MSS)由第一跳路由的 MTU 决定,但不能小于这个值。
重组 IP 分片时使用的最大内存
注:一旦用尽,分片处理程序会丢弃分片,直到 ipfrag_low_thresh。
(linux-4.17 开始弃用) 重组 IP 分片使用的内存下限,超过该值后内会通过移除不完整的分片队列来释放资源。过程中内核依旧会接收新的分片。
一个 IP 分片在内存中保留的最大时间,秒
该参数定义了同一个 IP 源的数据分片所允许的最大“失序程度”。IP 分片乱序到达的情况并非不常见,但如果从某个源 IP 上已经收到了许多分片,而其中的某个分片队列的分片还不完整,则多半该队列中的一片或多片数据已经丢失了。ipfrag_max_dist
为正时,分片在加入重组队列前会做一个额外的检查:如果某个队列两次加入新分片期间,来自某个源 IP 的分片数量超过了 ipfrag_max_dist
,则认为该队列的某些分片已经丢失,现有的队列会被丢弃,被替换成了一个新队列。ipfrag_max_dist
为0
时关闭该检查。
如果该值过小,如 1
或 2
,则正常的重排序现象也会引发不必要的队列丢弃,进而导致性能下降;而过大的值,如 50000
则会导致不同 IP 数据报文的分片错误重组在一起的可能性,导致数据出错。
注:TCP/IP 详解一书中提到,一般 TCP 会尽量通过设置 MSS 来使底层的 IP 报文不分片。
注:INET peer 是 IP 层的实现概念。与本机有交互的主机就叫 IP peer。出于性能的考虑,Linux 会为每个主机保存一些 IP 相关的信息,其中最重要的是 IP 的数据包 ID(packet ID) [8]。
允许的最大存储的估计值。当存储大于该阈值后,系统会激进地丢弃 peer 条目。该阈值同时也决定了 peer 条目的 TTL 以及两次 GC 的时间间隔。条目数据越多,TTL 越短,GC 越频繁。
条目的最小 TTL。在 IP 报文重组端,要保证大于分片的 TTL。当条目池使用的存储小于 inet_peer_threshold
时,则该最小的 TTL 是系统能保证满足的,如果超过阈值则可能被提前回收。单位为秒。
条目的最大 TTL。在没有内存压力的前提下,没被使用的条目超过该时间就会失效。单位为秒。
socket API listen
允许设置的积压(backlog)。默认值为 4096
(Linux 5.4 之前为 128
)。更多的关于 TCP socket 调参,也可以参考 tcp_max_syn_backlog
注[9]:
listen
中不设置 backlog 时的默认值,而 tcp_max_syn_backlog
是上限。accept()
取走连接信息,这里设置的是 accept queue 的大小。在 Linux 中的术语中,SYN queue 一般称为 SYN backlog,accept queue 就称为accept queue。为什么 socket
选项的参数称为 backlog
呢?猜想 socket API 是BSD 风格的,而在 BSD 风格的实现中,并没有两个 queue。
如果用户程序调用 accept 的速度太慢,新的连接没法被及时 accept,则重置连接(通过发送 RST)。该值默认为 False,意味着如果瞬时涌入了大量连接,server 会等待负载(accept 能力)慢慢恢复。只有在你真的确认用户程序没法更快 accept 连接的时候才设置成 True。设置这个参数可能会损害 client(如虽然服务还在,只是负载高,但 client 认为服务不存在)
注:这里和在 somaxconn 中提到的 accept queue 满了有关,当接到 ACK 时队列满了,默认情况下是完全忽略该 ACK,这样 server 认为在一段时间内没有收到 ACK,会重发 SYN+ACK,client 重发 ACK,server 接收第二个 ACK 时如果accept queue 又有空间了,就能恢复连接。如果 tcp_abort_on_overflow
设置成True 且发生接到 ACK 时 accept queue 满的情况,则会直接重置连接。
指定计算缓冲 Overhead 的方式:如果 tcp_adv_win_scale > 0
则为bytes/2^tcp_adv_win_scale
否则为bytes - bytes/2^(-tcp_adv_win_scale)
。
注:所谓的缓冲 overhead 指的是[10]:正常一个 TCP 的报文,除了包中的数据(payload)外,还会有 TCP 头、IP 头、以太头等;此外内核在存储报文时,还会有 sk_buff
和 skb_shared_info
等开销。因此在 TCP 层计算通告窗口时,需要把这部分排除在外。注意的是 tcp_adv_win_scale
的实际作用其实是指定数据和额外开销的比例的,和 tcp_app_win 区分开。
注:man 7 tcp 中提到 socket的缓冲区分为内核部分和应用部分,其中内核部分用来维护 TCP Window,应用部分的作用是“used to isolate the network from scheduling and application latencies”,具体指的应该是上面说的 skb_shared_info
结构,对 TCP 本身没什么用,但对其它模块有用。
设置允许普通进程(non-privileged process)使用的拥塞控制算法。这个参数的值阈是tcp_available_congestion_control
参数的子集。默认值为 “reno” 加上tcp_congestion_control
参数设置的算法。
注:可以通过 setsockopt
API 的 TCP_CONGESTION
参数为某个连接单独设置拥塞控制算法。
保留 max(window/2^tcp_app_win, mss)
大小的缓冲作为用户缓冲(参考tcp_adv_win_scale)。当值为 0
时有特殊含义,代表不保留。
注:在 TCP 初始化(tcp_init_buffer_space)缓冲时会为 application 预留一些空间,即由该值指定。与tcp_adv_win_scale 不同的是,tcp_adv_win_scale
是用来指定 overhead 在计算时的比例的,在初始化时,在窗口增长时都会用到,但tcp_app_win
只会在初始化时用到。
是否开遍 TCP auto corking: 当应用程序连续调用 write()/sendmsg()
系统调用写入小量数据,内核会尽量将这些调用合并,减少需要发送的数据包数量。当同一个流(flow)至少有一个之前的数据包在 Qdisc 队列或设备发送队列中等待时才会做合并。选项开启的情况下,应用层依旧可以使用 TCP_CORK
选项来决定是否启用合并功能。
注:Qdisc 队列指的是 Queueing Discipling 队列,是 IP 协议栈与驱动队列之间的队列,实现的是 Linux 内核的流量管理功能,包括流量分类、优先级排序和整流功能[11]。
只读选项,列出可用的拥塞控制算法。
MTU 探测中 search_low
的初始值,在开启 MTU 探测时,同时作为连接的初始 MSS 值。
注:这里的 MTU 探测(PLPMTUD packetization layer Path MTU discovery,又称 MTUProbing) 与前文的路径 MTU 发现(PMTUD)不太一样,PMTUD 发现依赖 ICMP需要分片的报文来确认 MTU 大太,可以理解成是 IP 层的。现在出于安全性问题,很多设备会禁用 ICMP 报文,PLPMTUD 是由 RFC4821 引入,尝试解决没有 ICMP 报文情况下的 MTU 发现。简单地说,在 TCP 层实现的话,依赖的是 TCP 的超时机制来确认包丢失,在开启 SACK 机制的情况下也会利用 SACK 信息。
注:PLPMTUD 也有自己的问题[12],如容易把拥塞控制相关的丢包划分为 MTU 问题,长期运行时最终使用的 MTU 值可能较小;同时没有为 IPv6 实现相关功能。
开启 MTU 探测时,该参数限定允许 search_low
到达的最小值。
注:该参数是 Linux 5.4 由这个commit加入。commit 中解释了如果因为丢包严重,会导致 MTU “卡” 在 48
上(之前的默认值),现在加了个参数可供调整。
注:为什么之前默认值是 48
?因为 IPv4指出 “Every internet module must be able to forward a datagram of 68 octetswithout further fragmentation”,即 IP 设备至少应该支持 68B,扣除 20B 的 TCP 头,得到最小支持 48B MSS。
TCP 的 SYN 和 SYNACK 报文通常会携带 ADVMSS 选项,来通告 MSS 信息,如果通告的MSS 小于 tcp_min_snd_mss
,则 Linux 会偷偷地将 MSS 提高到 tcp_min_snd_mss
的值。
设置新连接的拥塞控制算法,保底的算法是 “reno”,总是可用,其它可选的算法得看内核的配置。对于 passive 连接(即对 server)来说,新连接会继承 listener 通过setsockopt(listenfd, SOL_TCP, TCP_CONGESTION, "name" ...)
配置的算法。
开启 DSACK(duplicate SACK).
注:SACK 指的是 selective ACK,允许在 ACK 时通过 TCP 选项指定接收到了哪些乱序包,这样发送方能更有针对性的重传可能丢失的包。基本的 SACK 没有指定接收到重复报文时做何处理,DSACK 就是这基础上的扩展,它允许发送包含小于或等于累积 ACK 的SACK 块,这样重复块的信息也可以通过 SACK 传递。当然 DSACK 也有自己的一些问题,这里不展开。
是否开启 TLP(Tail loss probe) 机制。注意 TLP 机制需要 RACK 机制才能正常工作(参见下文的 tcp_recovery)。
注:默认情况下,TCP 在检测到 3 次重复 ACK(dupack,和 DSACK 是两个事)后触发快速重传,即不等超时就重传某个包。当接收方在接到失序报文会对已有的包 ACK 发送重复的 ACK(如收到 #1 发送 ACK-1,收到 #3 会发送 ACK-1,对 #1 ACK 了两次)。但是对于尾包丢失,由于后面没有其它包,则无法触发重复 ACK,也无法触发快速重传。
注[13]:TLP 会发送一个 loss probe 包,来产生足够的 SACK/FACK 的信息来触发 fastrecovery。根据 Google 的测试,TLP 能够有效的避免较长的 RTO 超时,进而提高TCP性能。
用来控制 TCP 使用的 Explicit Congestion Notification (ECN) 机制。ECN 只有在TCP 连接双方协商支持时才启用。这个功能允许路由在丢包之前就通知有拥塞的存在,以此来减少因为真正拥塞导致的丢包。
注[14]:ECN 需要 TCP 和 IP 的支持,TCP 连接建立时协商是否使用 ECN。如果开启,则当中间路由遇到拥塞时,会修改 IP 头中的 TOS 字段的最右两位,置为拥塞。接收方需要使用 TCP 头中的 ECE 标记回传这个拥塞信息。当某一方接收到 TCP 报文带有 ECE 位时,会减少拥塞窗口,同时设置 CWR 位来确认阻塞指示。当然还有一些其它机制来保证安全性。
如果内核检测到 ECN 连接工作不正常,则会回退到非 ECN 模式。当前该选项实现了RFC3168 第 6.1.1.1. 节中的内容,但不排除未来也会在该选项下实现其它检测算法的可能性。如果 tcp_ecn 选项或单个路由的 ECN 功能关闭时该选项不生效。
开启 TCP FACK(Forward Acknowledgement) 支持。选项废弃了,新版内核不再生效。
注[15]:FACK 是拥塞控制中快速恢复(Fast Recovery)阶段相关的机制,它主要解决有多个报文丢失的情况下,通过准确估计(当前连接)还在网络中传输的报文大小,在恢复阶段做出精确的拥塞控制。计算的方式如下:
snd.fack
awnd = snd.nxt - snd.fack
,这里假设了不存在乱序报文awnd = snd.nxt - snd.fack + retran_data
于是在拥塞时,cwnd 会根据算法改变,此时为了充分利用带宽,可以使用如下方法控制包的发送:
while (awnd < cwnd) |
该方法比起 Reno 通过接收到的 dupack 数量来调整 cwnd 值更为精确。对于快速恢复的触发也有变化:
正常 Reno 算法会在 dupacks == 3
时触发快速恢复,如果丢失多个包,则 ACK 数量也随之减少,导致等待重传的时间变长,而 FACK 额外增加了一个触发条件:(snd.fack – snd.una) > (3*MSS)
,即假设没有乱序包的情况下,如果该条件成立,则说明网络中丢失了 3 个包,等价于 dupacks == 3
,可以触发重传和快速恢复。
注:在 Linux 4.15[16] 中移除了 FACK 的支持,使用 RACK 机制替代。
孤儿连接(orphaned connection,指不再被任何应用使用的连接)在 FIN_WAIT_2
状态中等待的时间,超时后在本地会被丢弃。虽然 FIN_WAIT_2
完全是合法的 TCP 状态,但如果另一端已经挂了,如果没有超时机制,则会永远等待下去。
注:TCP 四次挥手包含两轮协商,分别包含双方的 FIN+ACK,本地的 FIN+ACK 结束后即进入 FIN_WAIT_2,等待另一方的 FIN。不能永远等待另一方的 FIN。
开启 F-RTO (Forward RTO-Recovery) 支持。F-RTO 在 RFC5682 中定义,它是 TCP 重传超时的一个增强算法,能更好地处理 RTT 经常波动的情况(如无线网)。F-RTO 仅需要发送端做修改,不需要接收端的任何支持。
注[17]:RTO 解决的是虚假重传的问题,由于链路的 RTT 波动太大,导致发送方还没来得及接收 ACK 就触发了 RTO 超时重传。F-RTO 是一种发送端的无效 RTO 超时重传检测方法。在 RTO 超时重传了第一个数据包之后,F-RTO 会检测之后收到的 ACK 报文来判断刚刚的超时重传是否是虚假重传,然后根据判断结果决定是接着进行重传还是发送新的未发送数据。
开启选项时,如果连接某个 listening socket 的连接没有设置 socket mark,则会将accepting socket 的 mark 设置成传入的 SYN 报文的 mark。这会导致该连接的后续所有报文(从 SYNACK 开始)都会被打上对应的 fwmark。当然 listening socket 的 mark保持不变。同时如果 listening socket 已经通过 setsockopt(SOL_SOCKET, SO_MARK, ...)
设置了 mark,则不受该选项影响。
注:基础知识:Server 端有两种 socket,一种是诸如 80 端口这样的监听端口(listening socket),客户端会连接服务端的 listening socket,当服务端 accept 时,会在在服务端为该连接赋予一个 accepting socket,后续连接通过 accepting socket通信。
注:fwmark 相关内容在 fwmark_reflect 中有介绍,tcp_fwmark_accept
用来实现“哪来回哪去”的功能。
限制响应无效报文的重复 ACK 的最大速率,报文无效的判定:
这个选项有助于缓解简单的 “ack loop” 的 DoS 攻击,一些怀有恶意的中间设备会尝试以某些方式修改 TCP 报文头,让连接的某一方认为另一方在发送错误的 TCP 报文,导致为这些错误报文无止境地发送重复 ACK。
当 keepalive 开启时,等待多长时间开始发送 keepalive 消息
注[18]:keepalive 是 TCP 的保活机制,严格来说并不是 TCP 规范中的内容。这个机制一般是为服务器的应用程序提供,希望知道客户主机是否崩溃或离开。保活探测报文为一个空报文段(或只包含一个字节),它的序列号等于对方主机发送的 ACK 报文的最大序号减1, 因为这一序号的数据已经被成功接收,因此对接收方没有影响,而返回的响应可以确定连接是否还正常工作。
判定连接失效前,发送的保活探测报文的数量,默认值为 9.
保活探测报文发送间隔。乘于 tcp_keepalive_probes
就等于探测触发到关闭连接之间的时间。
开启该选项允许子 socket 继承 L3 master device 的索引号。即允许有一个“全局”的listen socket,能监听所有的 L3 master 域(即监听所有的 VRF 设备),通过该listen socket 建立的连接会被绑定在连接创建时使用的 L3 域上。只有当内核编译时加上 CONFIG_NET_L3_MASTER_DEV
选项才可用。
注:L3 master device(L3mdev) 是内核为了支持 VRF(Virtual Routing Forwarding) 而添加的功能,但本身是独立于 VRF 存在的。L3 指的是网络栈第 3 层:网络层。可以把L3mdev 理解成虚拟的网卡,不过只在 L3 层生效,它有独立的路由表。
如果开启该选项,则 TCP 做决定时会倾向于低延时而非高吞吐。如果关闭选项,则倾向高吞吐。Linux 4.14 开始,该选项依旧存在,但会被忽略。
最大的孤儿连接,孤儿连接指的是不与任何用户文件描述符绑定的 TCP socket 但仍归系统管理中。如果孤儿连接超过了该选项的值,则连接会被立马回收并打出警告信息。这个选项的目的是防止简单的 DoS 攻击,我们不应该去依赖这个行为或者人为减小该值。反之在默认值无法满足网络条件需要时增大它。注意每个孤儿连接会消耗约 64K 的不可swap的内存。
默认初始值等于内核参数 NR_FILE
,默认值会随着系统内存调整。
SYN 队列中允许的最大连接数,具体来说是处于 SYN_RECV 状态的连接,这个状态代表还没有收到三次握手中最后一个 ACK 的连接。这个限制是针对单个 listener 的。对内存少的机器默认值是 128, 对内存多的机器来说会对应增加。如果机器的负载比较大,可以尝试增大该值。同时也别忘了看看 somaxconn 参数。另外一个SYN_RECV 状态的 socket 占用大概 304B 内存。
注:如果实际连接数超过了该值,内核就会开始丢弃连接。
系统同时允许存在的处于 TIME_WAIT 状态的 socket 最大数量。如果实际数量超过了该值,则会立即回收 TIME_WAIT socket 并打印警告信息。和tcp_max_orphans 一样,该参数也是用于防范一些简单的DoS 攻击,我们不应该去减少这个值,在网络需要的情况下可以适当增加该值。
注:和 TIME_WAIT 有关的还有个参数 tcp_tw_recycle
是用来快速回收处于TIME_WAIT 状态的连接的,在 Linux 4.11 之后也被废弃了。
包含 3 个值:
min
页之下时,TCP 不关心内存使用pressure
页,则会减少它的内存占用,进入pressure 模式,直到分配的内存小于 min
时退出默认值在启动时根据系统的内存进行推断。
Linux 会用一个带窗口的 filter 去计算连接的最小 RTT,该参数控制窗口的大小。更小的窗口意味着对 RTT 变化更敏感,如果最小 RTT 在变大,更小的窗口能更快应用更大的最小 RTT。反之窗口超大,就更能抵抗短时间内的 RTT 膨胀,例如由拥塞引起的 RTT 变大。单位是秒。
注:这个算法的实现可以在这个讨论中找到:tcp: track min RTT using windowedmin-filter
如果开启,则 TCP 会自动调整接收缓存的大小,在不超过 tcp_rmem[2]
的前提下,尽量达到该连接满吞吐的要求。默认开启。
是否开启 MTU 探测功能(即 PLPMTUD Packetization-Layer Path MTU Discovery)。
注:PLPMTUD 机制在 tcp_base_mss 做了简单介绍
控制开始 PLPMTUD 重新检测的时机,默认是每 10 分钟重新检测,由RFC4821 规定。
控制 PLPMTUD 何时停止探测,如果最终搜索范围的间隔小于某个数字时停止,默认值是8 字节。
默认情况下,当一个连接关闭时,TCP 会在 route cache 中记录一些连接相关的指标,这些指标在随后建立的新连接中可以被当作初始条件使用。通常这种做法会提高整体的性能,但有一些特殊情况下也可能会降低性能。如果这个开关开启,则关闭连接时不会记录指标。
控制 TCP 是否将 ssthresh 记录在 route cache 中,默认值是 1 代表不记录。
注:ssthresh
是拥塞控制中,慢启动的阈值,窗口超过阈值后进入拥塞避免。
该值影响的是本地已关闭但超时重传还没有被 ACK 的 TCP 连接的超时。更多信息参考tcp_retries2。
默认值是 8,如果你的机器是一个高负载的 WEB 服务器,可以考虑调低该值,因为这样的 socket 可能占用不少资源,同时参考tcp_max_orphans
这个值是一个 bitmap,用来开启一些还在实验的丢包恢复的功能
0x1
,(默认)为丢包重传和丢尾包的情况开启 RACK 丢包检测,对 SACK 连接来说,它已经包含了 RFC6675 的恢复并且禁用相关功能0x2
,使用静态的 RACK 重排序窗口,置为 (min_rtt/4)0x4
,不使用 RACK 的启发式 DUPACK 阈值注:RACK[19] 全称 (Recent ACK),作用是在快速发现并重传那些曾经重传后再次丢失的数据包,旨在替代 DUPACK 等重传机制。传统的一些重传机制依赖计算 ACK 包的数量,包括DUPACK,FACK 等,在很多情况下这个方法不可靠。于是 RACK 使用的是基于超时的算法(通过时间戳和 SACK 信息),RACK 会维护一个窗口,当 ACK 到来时,RACK 会将窗口中“过期”的包标记为“丢失”,进行重传,而对于“未过期”的包,有可能丢失也有可能是乱序,会等到超时后再处理。
TCP 重排序级别的初始值,TCP 协议栈会动态地在初始值和tcp_max_reordering 之间做调整。一般不要改默认值。
注[20]:该参数的含义是告诉内核,重排序的情况有多严重,这样内核就会假设数据包发生了重排序而不是丢了。如果 TCP 认为丢包了,则会进入慢启动,因为它会认为包是因为链路上的拥塞而丢失的。同时如果内核在使用 FACK 算法,也会回退到普通算法。
TCP 数据流中最大的重排序级别。默认值为 300,是一个比较保守的值,如果链路使用了per packet 的负载均衡(例如 bounding rr 模式),则可以考虑增加该值的大小。
开启后,在重传时会试图发送满大小的包。这是对一些有 BUG 的打印机的绕过方式。
该值决定了经过多少次 RTO 超时重传没被 ACK 后,TCP 向 IP 层传递“消极建议”(如重新评估当前的 IP 路径)。参考 tcp_retries2。
RFC1122 推荐至少等待 3 次重传,这也是默认值。
该值决定了在多少次 RTO 重传仍未得到 ACK 后,TCP 将放弃该连接。给定值为 N,假设TCP 使用的是指数回退机制,初始 RTO 为 TCP_RTO_MIN
,则连接会重传 N 次,第(N+1) 次 RTO 时放弃连接。
默认值是 15,按上面的逻辑,关闭前会有 924.6s 的超时,它也是合理超时的一个下界。TCP 在超过该时间后的第一个 RTO 超时时放弃该连接。
RFC1122 推荐至少等待 100s,对应该值至少为 8.
注[21]:逻辑上来说,TCP 有两个值 R1 和 R2 来决定如何重传同一个报文。R1 表示 TCP 在向 IP 层传递“消极建议”(如重新评估当前的 IP 路径)之前,愿意重传的次数。R2(大于 R1)指示 TCP 应该放弃当前连接的时机。R1 对应的tcp_retries1,R2 对应tcp_retries2。
如果开启了,则 TCP 协议栈的行为会符合RFC1337,如果不开启,则行为不符合 RFC的描述,但依旧会防止暗杀 TIME_WAIT 的连接。
这个选项包含 3 个值:
min,默认 4K。代表 TCP socket 接收缓冲的最小值。即使在内存紧张的情况下也会得到保证
default,默认 87380B,TCP socket 接收缓冲的初始值,该参数会覆盖其它协议设置的 net.core.rmem_default
值。在tcp_adv_win_scale 为默认值,tcp_app_win 为 0 的设置下,87380B 能对应拥有大小为65535B 的 TCP窗口,默认 tcp_app_win 设置(31)下则会更小一些。
max,默认值在 87380B 到 6MB 之前,视内存而定。是系统自动调整接收缓冲的最大值,这个参数不会覆盖 net.core.rmem_max
。如果使用 setsockopt()
设置了SO_RCVBUF
,则会关闭自动调整接收缓冲大小的功能,因此该值不生效。另:具体的默认值公式:
max(87380, min(4 MB, tcp_mem[1]*PAGE_SIZE/128))
注:接收缓冲划分的逻辑在 tcp_adv_win_scale 和tcp_app_win 有更详细的描述。
注意上面的描述说会得到 65535B 的窗口,是按 tcp_adv_win_scale = 2 来计算的,此时 TCP 窗口的大小为 bytes - overhead = 87380 - 87380/2^2 = 87380 - 21845 =65535。但是 tcp_adv_win_scale
最新的默认值已经是 1
了,所以实际的窗口只有43690B。
开启 SACK(select acknowledgments)。
注:这个机制应该是比较常用的,接收方在返回 ACK 时,除了返回目前最大的累积 ACK序号,还可以在 TCP 选项中填写提前收到的“乱序”报文。这样发送方在接收到 SACK 时,就可以有针对性地重传缺失的包,提高传输效率。
TCP 会尽量减少发送 SACK 的数量,默认会等待 5% SRTT 的时间,时间的下限是该选项的值,纳秒为单位。默认值为 1ms,与 TSO 自动调整大小的间隔相同。
注[22]:这个参数的大背景是要对 SACK 做压缩,因为 TCP 会在收到失序报文时立即发送SACK 报文,在诸如 wifi 环境或拥塞的网络情况下这并不是好的选择。
允许被压缩的最大 SACK 报文数,设置为 0 代表关闭 SACK 压缩
如果开启了,则会实现 RFC2861 的行为:在空闲超过一段时间之后,将拥塞窗口置为过期(重新慢启动过程)。“一段时间”定义为当前的 RTO。如果关闭选项,则拥塞窗口不会随着空闲时间过期。
对于 TCP 的 URG(Urgent pointer) 字段,是否使用 Host requirements 中的解释。多数主机使用的是更老的 BSD 解释,如果开启该选项,Linux 和这些 BSD 风格的主机可能就没法正常通信了。
注:Host Requirement 对主机实现 TCP 规范有许多细节上的要求。
对于被动连接(passive connection),允许重传 SYNACK 的最大次数。不能高于 255. 默认值为 5, 在当前初始 RTO 为 1s 的情况下,到最后一次重传共用时 31s。这样该连接最后一次超时发生在自尝试建立连接的 63s 之后。
注:TCP 的重传时间是每次翻倍,所以如果初始 RTO = 1s,则第 5 次重传发生在 1+2 +4+8+16=31
,最后一次超时为 32s,因此共为 63s。
只在内核编译时加了 CONFIG_SYN_COOKIES
时生效。作用是当 SYN 队列(syn backlogqueue)溢出时,新连接不再存入 SYN 队列,而是直接发送 syncookies,作用是防止常见的 SYN 泛洪攻击(SYN flood attack)。
注意 syncookies 是一个 fallback 的机制,不应该被高负载主机用来作用承接合法连接流量的工具。如果在日志中收到 SYN 泛洪的警告,但是调研后发现这些连接都是佥的,只是流量太大了,那么此时应该考虑的是调整其它的参数直到日志中的警告消失:tcp_max_syn_backlog、tcp_synack_retries、tcp_abort_on_overflow
syncookies 机制严重违背了 TCP 协议,它不允许使用 TCP 扩展,会导致一些其它服务的退化(如 SMTP 中继),这些影响都不是服务端可见的,而是由客户端、中继方发现并通知你的。你只能看到日志里的 SYN 泛洪警告,尽管你发现它们不是真正的泛洪,但你的服务器配置其实是有问题的。
如是你想测试 syncookies 对服务的影响,可以将选项设置成 2, 这样会无条件开启syncookies。
注:SYN 泛洪攻击指的是攻击者发送大量的 SYN 报文请求建立连接,服务端响应 SYNACK,但是攻击者并不处理,不真正建立连接,于是大量 SYN_RECV 状态的连接将服务端的SYN 队列占满,导致正常的请求无法被响应。
SYN 泛洪攻击的重点是服务端需要为 SYN_RECV 连接保存信息,syncookies 的思路是将连接的信息编码到 SYNACK 报文中,最后一个 ACK 时再由客户端将信息带回给服务端,这样服务就不需要为它保存任何信息,因此能正常接受连接且不需要保存任何信息。
有两个缺点[23]:一是服务器只能编码 8 种 MSS 值,因为有些位被占用了,另一方面服务器必须拒绝所有 TCP 选项,例如大窗口和时间戳。
开启 TCP Fast Open (RFC7413) 功能,在SYN 包中也能传输数据。需要客户端和服务器两端都开启支持。
客户端支持通过设置为 0x1
开启(默认打开)。要想在 SYN 时发送数据,客户端需要用加上 MSG_FASTOPEN
选项的 sendmsg()
或sendto()
方法建立连接,而不是用connect()
。
服务端支持通过设置为 0x2
开启(默认关闭),之后要么通过另一个标志(0x400
)来为所有的 listeners 开启该功能,要么通过为每个 listener 单独开启TCP_FASTOPEN
选项来支持。这个选项要带一个参数,代表 syn-data backlog 的长度。
这个选项的值是 bitmap,描述如下:
0x1
:客户端,允许客户端在 SYN 中携带数据0x2
: 服务端,开启服务端支持,允许在三次握手结束前接收数据并传递给应用程序0x4
: 客户端,不管 FTO cookie 是否存在,都在 SYN 中发送数据,且不带 cookie选项0x200
: 服务端,没有 cookie 选项时依旧接收 SYN 报文中的数据0x400
: 服务端,为所有监听端口开启 FTO,不用为端口单独设置 TCP_FASTOPEN 选项0x1
注意后续的增强选项只有在开启了客户端或服务端支持(0x1
及 0x2
)后才会生效。
注:TFO 通过在握手时传递数据,来减少三次握手对数据传输的延时影响,在诸如 HTTP这类协议,会不断创建新的 TCP 连接,因此影响会更大。
TFO 有个概念是 TFO cookie,客户端在创建连接时可以带上 cookie 选项,服务端认证通过时,就可以接收第一个 SYN 包中携带的数据,而不是等第三次握手的 ACK 后再接收数据[24]。
发生 TFO 防火墙黑洞(TFO firewall blackhole)情况时,关闭活跃端口快速打开功能的持续时间(单位秒)初始值。如果快速打开功能重新启用后又遇到了黑洞问题,则关闭时间会指数级增长,如果黑洞问题消失,关闭时间会重新被设置为初始值。
注:TFO 防火墙黑洞会导致 client 端长时间连不上 server 端,其中的一些情形:
该选项的值包含了一个列表,列表中包含了一个主 Key 和一个可选的备用 Key。主 Key被用于签发新 cookie 及验证已有 cookie,而备用 key 只会被用来验证 cookie。备用Key 是用来滚动更新 key 时轮换用的。
如果 tcp_fastopen 选项设置成了 0x400
,或者端口设置了 TCP_FASTOPEN
选项,而之前并没有配置过 Key,则内核会随机生成一个 Key。如果端口事先使用了 setsockopt
配置了 TCP_FASTOPEN_KEY
选项,则该端口会选用配置的 Key 而不是 sysctl 设置的 Key。
Key 由 4 组数字构成,由字符 -
分隔,每组由 8 个 16 进制数字组成,如xxxxxxxx-xxxxxxxx-xxxxxxxx-xxxxxxxx
,前导的 0 可以省略。主 Key 和备用 Key 之间用逗号分隔。如果只设置了一个 Key,则该 Key 被认为是主 Key,之前配置的备用Key 会被移除。
重传主动连接 SYN 报文的次数。不能高于 127。 默认值是 6, 在 RTO 为 1s 的情况下,从开始到最后一次重传之间的时间为 63s。从开始到最终的超时之间,过了 127s。
开启 RFC1323 中定义的时间戳功能
注:时间戳机制指的是在发送 TCP 报文时,加上时间戳选项,记录服务端的时间,接收方在 ACK 时需要将时间戳选项原封不动返回。这样服务端一方面可以用来精确计算 RTT,一方面可以用来防止序列号回绕(PAWS,传输大量数据时,ACK 序列溢出回绕,可能会和之前发送的报文有重合)。
出于安全上的考虑,时间戳并不是真正记录时间,而是会使用一些随机的内容,一般还是保证递增的。
每个 TSO 帧包含的报文段数量最小值。从 Linux-3.12 开始,TCP 就不再是填充一个64KB 的大 TSO 包,而是会根据当前的流量自动决定 TSO 帧的大小。如果有一些特殊的需求,还是可以强迫 TCP 构造大的 TSO 帧的。当然如果可用窗口太小,TCP 层还是有可能对大的 TSO 帧做拆分。
注:TSO(TCP segmentation offload) 机制的动机是 TCP 用户层的数据需要根据 MTU 进行分段,这个过程很固定但是消耗 CPU,于是改进的思路是将数据整体发往网络设备,由网络设备进行分段。这个机制能释放 CPU,但需要网络设备支持。
注[25]:TSO 机制有个问题是 TCP 经常会向下传递一个大包,网卡拆分后一次性注入网络,容易造成流量峰值。TSO autosizing 的目的是根据流量自动调整帧大小,进而将流量平稳地注入网络,“尽量每毫秒都发一个包,而不是每 100 毫秒发一个大包”。tcp_min_tso_segs
指定的是这个包的最小值。
TCP 会根据当前速率乘于一个比例来设置 sk->sk_pacing_rate
值(当前速率current_rate = cwnd * mss / srtt
)。如果 TCP 处于慢启动阶段,则会使用tcp_pacing_ss_ratio
这个比例来让 TCP 以更快的速度进行探测,这里会假设每个RTT时间里 cwnd 都可以翻倍。
注:和在 tcp_min_tso_segs 中的注提到的类似,Pacing 机制的目标也是在某个 RTT 下能让窗口的包尽量“均匀”地发送,而不是在某一时刻扎堆发送。
TCP 会根据当前速率乘于一个比例来设置 sk->sk_pacing_rate
值(当前速率current_rate = cwnd * mss / srtt
)。如果 TCP 处于拥塞避免阶段,则会使用tcp_pacing_ca_ratio
这个比例来让 TCP 以保守的速度进行探测。
该选项控制一个 TSO 帧的大小能占拥塞窗口的百分比。这个参数用来在减少峰值和构建大 TSO 帧之间做选择。
允许新连接复用处于 TIME_WAIT 状态的端口。需要应用层协议自己判断这样做是否安全。
除非有专家要求或建议,否则不建议修改。
注:实践中遇到 TIME_WAIT 端口太多导致端口不够用的问题,通常是因为开启了反向代理且没有开启 keepalive 长连接。在绝大多数情况下都不需要修改内核参数,并且修改了以后会造成很多偶发的预料之外的问题。
开启由RFC1323中定义的窗口缩放(windowscaling)功能
注:TCP 头中窗口大小字段是 16位的,所以最多表示 64K 大小的窗口,为了使用更大的窗口,“窗口缩放”会使用新增的 TCP 选项,指定窗口放大多少倍(实际上指定的是左移多少位)。这个选项需要连接双方都支持。
这个选项包含 3 个值:
min: 默认 4K。代表 TCP 发送缓存的预留大小。
default: 默认 16K。TCP 发送缓存的初始大小,该先期覆盖其它协议设置的net.core.wmem_default
,并且通常比 wmem_default
的值小。
max,默认值在 4K 到 6MB 之前,视内存而定。是系统自动调整发送缓冲的最大值,这个参数不会覆盖 net.core.wmem_max
。如果使用 setsockopt()
设置了SO_SNDBUF
,则会禁用自动调整发送缓冲大小的功能,因此该值不生效。另:具体的默认值公式:
max(65536, min(4 MB, tcp_mem[1]*PAGE_SIZE/128))
TCP socket 通过 TCP_NOTSENT_LOWAT
选项可以控制它的写队列中未发送的字节数。如果队列未满且其中的未发送数据小于每个 socket 各自设置的下限值,则poll()/select()/epoll()
方法会返回 POLLOUT
事件。如果这个数据没有超过这个限制,sendmsg()
也不会新增缓存。
这个选项是一个全局的选项,给那些没有设置 TCP_NOTSENT_LOWAT
选项的 socket 使用。对这些端口来说,全局选项值的变化会即时生效。
注:原文的翻译可能比较怪,这个参数大意是用来控制内存使用的,当缓存队列中的未发送数据量小于该值时,内核认为发送缓存为空,因此可以发送,大于该值时停止发送[26]。
另:这是对应选项的 commit。
如果开启,则在没有接收到窗口缩放参数的情况下,假设对方的 TCP 实现有问题,本机需要把对方的窗口大小字段当作是“有符号”的 16 位整数。如果关闭,则在没有接收到窗口缩放参数时,认为对方的 TCP 实现也是正确的,把窗口大小解释成 16 位无符号整数。
是否为 thin stream 开启线性超时重传。
如果开启了,则内核会动态检测数据流是不是 thin stream(在传的包数量小于 4),如果发现数据流的确是 thin stream,则在使用指数回退的超时重传时,至少会先尝试 6次线性超时重传。对于一些对依赖低延时的小流量数据流(如游戏)来说,可以减小重传的延时。关于 thin stream,可以参数Documentation/networking/tcp-thin.txt
注:tcp-thin.txt 里基本说得比较详细了
对每个 socket 控制 TCP 的 Small Queue 大小。TCP 批量发送数据时,倾向于不断发送直到收到 TCP 丢包的通知,加上自动调整 SNDBUF 的功能,会导致在本地有大量的包在排队(在 qdisc, CPU backlog 或设备中),会损害其它连接(flow)的性能,起码对于典型的 pfifo_fast qdiscs 来说是这样。tcp_limit_output_bytes
用来限制允许存储在qdisc 或设备中的字节数来减少 RTT/cwnd 差异导致的不公平,减少 bufferbloat。
注:可以参考 TCP small queues 中的说明
注:bufferbloat 译为“缓冲膨胀”,指的是由于缓冲了太多数据导致延迟增高的现象。
限制每秒钟送送的 Challenge ACK 的数量,这是 RFC 5961(Improving TCP’sRobustness to Blind In-Window Attacks) 中推荐的。
注[27]:Challenge ACK 指的是,当接接收到 RST 报文时,如果序列号不符合预期,但是在合理的窗口区间里 RCV.NXT < SEG.SEQ < RCV.NXT+RCV.WND
,则 TCP 需要返回一个 ACK,即为 Challenge ACK。
注:“Blind” 应该指的是第三方,因为它对真实的连接信息一无所知,“In-Window” 指的是攻击者去猜测序列号,伪造的报文在合法的窗口内。这类攻击可能伪造 SYN、RST、或其它报文来进行攻击。
开启时,会为每 SKB 维护一个 TCP socket 级别的缓存,在某些情况下会提高性能。要注意在有很多 TCP socket 的机器上开启这个选项是非常危险的,因为它会消耗很多内存。
最近在学习《TCP/IP 详解卷一》,好不容易把 TCP/IP 的部分看完了,合起书来几乎是什么也不记得,因此才想从 Linux 相关参数入手,去联系书里的知识。实际翻译和注释后,发现很多内容并不是书里得来的,而是网上搜索,文章、博客、RFC、邮件等。
网卡有许多讲 Linux 参数调整的,经常是只列出参数,对我这种外行来说,不知道参数影响什么机制,因此也不知道为什么要这么设置,这篇文章里我对几乎每个参数的机制都做了调查,并以自己的理解写了简单的注解,希望对读者有用。
最后感慨 TCP/IP 是非常复杂的,很多机制都有漏洞,对漏洞又有很多算法来修补,修补后又有边缘的 case,可谓是无穷无尽;另一方面有许多不同算法解决不同问题,而算法之间有可能相互影响,很难有全局的掌握;具体实现上还会需要在性能上做一些妥协。因此虽然对 sysctl 中的选项有些了解,还是觉得自己对 TCP 一窍不通,还需要不断学习。
https://packetlife.net/blog/2008/aug/18/path-mtu-discovery/ 介绍了路径 MTU 的发现方法 ↩
https://blog.csdn.net/dog250/article/details/78301259 详细介绍了 fwmark_reflect 解决的问题 ↩
https://www.cs.unh.edu/cnrg/people/gherrin/linux-net.html#tth_sEc8.2.1 邻居表结构 ↩
https://lwz322.github.io/2019/11/03/ECMP.html 简单描述了 ECMP 的哈希方式 ↩
https://www.kernel.org/doc/Documentation/RCU/whatisRCU.txt 介绍了内核 RCU 的概念 ↩
参考书 Understading Linux Network Internal 第 23 章 ↩
How TCP backlog works inLinux 详细描述了 Linux backlog 的机制 ↩
关于Linux TCP接收缓存以及接收窗口的一个细节解析 有对 tcp_adv_win_scale 机制的详细描述,强推 ↩
Broken packets: IP fragmentation is flawed 介绍了四种 PMTU 失效的情形 ↩
TCP Tail LossProbe(TLP)文章对 TLP RFC 有详细解读 ↩
http://conferences.sigcomm.org/sigcomm/1996/papers/mathis.pdfFACK 论文 ↩
TCP系列24—重传—14、F-RTO虚假重传探测 大佬的博客有很多 TCP 相关的内容,值得深挖,这篇讲的是 F-RTO ↩
参考《TCP/IP 详解卷一》的第 17 章 ↩
TCPVariables中对 tcp_reordering 的含义做了更详细的解释 ↩
参考《TCP/IP 详解卷一》的第 14.2 章 ↩
tcp: implement SACKcompression SACK 压缩的 patch,其中有解释动机 ↩
TSO sizing and the FQ scheduler 讲解了 TSO和 FQ 机制的一些小细节 ↩
在 Spring 2.2 之后,最直接的方式是在 test/resources/application.yml
配置文件中加入如下参数:
spring.main.lazy-initialization: true |
内部原理是在 SpringApplication
中,如果检测到该参数为真,则会创建一个BeanFactoryPostProcessor,用于将“所有” BeanDefinition 的 lazyInit 属性置为真。
if (this.lazyInitialization) { |
参考这篇文章,本质上与Spring 2.2 的方法一样,需要在测试包中自定义 BeanFactoryPostProcessor,用于将“所有” BeanDefinition 的 lazyInit 属性置为真:
|
如果某个测试不需要懒加载,则通过注解 @ActiveProfiles(TestLazyBeanInitConfiguration.EAGER_BEAN_INIT)
关闭。
通常我会在测试包中创建一个 TestApplication
类,并注解为@SpringBootApplication
来完成 Bean 的自动扫描。尝试过下面的方式:
|
这种方法对于自动创建的 Bean(即标记为 @Component
, @Service
等的类)是有效的。但对于 Configuration
类中通过 @Bean
方式创建的 Bean 无效。毕竟@ComponentScan
本身控制的就是扫描 Bean 的行为。
神奇的是在搜索过程中,发现讨论这句格言的并没有多少,不同的讨论中对 "explicit"含义的理解差别也很大,最终发现最好的讨论来自 Elixer 社区:On ‘Explicit isbetter than Implicit’。本文尝试列举见过的一些观点,以及自己的理解。
Explicit 这个单词释义为:
stated clearly and in detail, leaving no room for confusion or doubt
“清楚详细地陈述,不容混淆或怀疑”。翻译成中文有“显式的”、“精密”、“不含糊”、“明确的”等多种翻译。在代码的语境下,什么样的代码才能称得上是 “explicit” 呢?网上看到了不同角度的观点。
显式地写出代码,也可以有多种理解方式,Making Games with Python andPygame中举了一个示例:
def getButtonClicked(x, y): |
书中提到最后一行显式写 return None
能让读者更直接理解代码的用途。
思考:这个样例容易理解,也很赞同,但是如何推而广之呢?Explicit 在上面的例子中体现在哪呢?
我理解它的重点在于,当所有的 if
语句都不命中时,默认的行为是未知的,而显示写出的 return None
则清楚地描述了默认的情形,即使 Python 的默认行为发生变化,该方法的行为也不会发生变化。
这篇文章明确表达了自己对“Explicit is Better than Implicit”的理解:
Being explicit means being concrete and specific instead of abstract andgeneral. It also means not to hide the behavior of a function.
Explicit 意味着要具体、特化,不要抽象、通用。同时不要隐藏函数的行为。
对于要具体、特化,文章中举例如下:
# Explicit | # Implicit |
对这个例子也是比较认同的。但我认为重点不在于 requests.get
怎么好,而在于import *
不好。因为这样的话 get
方法的来源就不明确了,容易混淆,需要靠猜。相对的,下面的代码我认为也是好的:
from requests import get |
这里我的理解是,代码执行的逻辑,从溯源的角度上没有二义。如果用了 import *
,同时两个模块中都有 get
方法,则容易混淆,不知道真正执行的是哪个方法。
同样来自上面提到的文章,示例如下:
#Explicit |
这个示例我不认同。从观念上,它与 OOP 中提到的封装的思想;从使用上,文件类型的判断的要求并没有消失,只是丢给用户自己实现了;进而从结果上,只要有多种文件类型存在,在某个层级上一定会有一个 read
方法的。
举例说,如果说不要隐藏函数的行为,那么我们在写 Web 服务的时候,在我们访问 DB时,我们会希望直接处理 TCP 连接吗?Spring 框架选择隐藏这些行为,可以说是错误吗?
关键在于“预期”,与预期相符就是“Explicit”的,正如Elixer 社区的讨论:“Don’t surprise me”。于是 Explicit 的要求演变成如何给用户正确的预期?我的回答是:良好命名,遵循 common sense,除此之外需要教育。
Django 的 Designphilosophies中有如下描述:
Magic shouldn’t happen unless there’s a really good reason for it. Magic isworth using only if it creates a huge convenience unattainable in otherways, and it isn’t implemented in a way that confuses developers who aretrying to learn how to use the feature.
这里指的是不要用复杂的语言特性(大家常把元编程称作 Magic)。
这个观点的持保留意见,我认为重点还是在于知识、背景是否匹配。例如当我熟悉Decorator 时,就会觉得用 decorator 来指定一个 REST API 的路由很直观,很容易理解。但对于不熟悉的人可能就完全不能理解数据的路径(data path),不知道为什么一个注解是怎么真正完成 URL 到函数的绑定的。
还有一些讨论会指向同一段逻辑的不同写法,例如SO 上的讨论 举的例子:
a = [] |
这是一个“矛盾”的讨论,题主认为 ② 是 explicit,下面的回答则指出写成 ① 的方式能应对更多的情形,如 a
不是列表的情形。我能理解 ① 的作用,但同时也赞同题主的观点,从阅读的角度来说 ② 是更直接的。
还有 Elixer 社区的讨论,例子一方面说明什么是语义上的“直接”,也间接反驳“不要隐藏函数行为”的观点:
if (person.sex === 1 and person.children.length > 0) { ...do something... } |
从阅读代码的角度,明显上最后一种最容易阅读,更符合语言习惯,不容易有歧义。
上面我们看到,“Explicit is Better than Implicit”这句话本身就是 implicit 的,有很多歧义的理解。
我自己的总结是:Minimal Knowledge, No Surprise。
对于阅读者/使用者而言,需要最少的知识去理解它,在我们隐藏复杂度的过程中,要保证函数/API/…行为符合预期,没有意外。
例如对于函数最后加上 return None
比不加要好,因为加上后,我们就不需要了解Python 函数的默认返回值是什么。类似的,显式引用会更好:from requests import get
,因为读者不需要去找 get
方法的来源,以及有重名时是哪个函数生效。
对于“不要隐藏函数行为”的做法,就有一定的反对意见。例如 read_csv
和read_json
是否优于 read
方法?我认为此时 No Surprise 很重要。对于 csv,json 等文件格式我认为 read
更优,因为根据扩展名判断类型是一个共识,并不会有surprise 发生。而如果读取的是 HDFS 上的文件,由于很多文件保存时并不会按扩展名保存,我认为此时 read
就容易有 Surprise,因此是不合适的。
对于 Magic,如果做法不是 common sense,则需要我们额外学习 Magic 的含义,就是属于"Implicit" 的,此时用来是不用,就要看它能给我们带来多大的好处了。同样的还有语言中的语法糖,经常需要额外学习知识才能看懂/自己使用。
最后对于“含义更直接”,认为在 No Surprise 的前提下,越接近“共识”越好,因为需要更少的知识。
关于 “Explicit is Better than Implicit?” 的理解,文章罗列了网上搜索的一些观点:
return None
from requests import get
read_csv
与 read_json
要好于只实现 read
len(a) == 0
判断列表为空而不是 not a
最后总结并说明了自己对 “explicit” 含义的理解:Minimal Knowledge, No Surprise
当然,我们会发现 Minimal Knowledge 或者说“共识”对于不同的群体,在不同上下文之下是不同的。这也是我们需要经验去理解,需要花时间去沟通的内容了。
]]>