2025-07-02 23:49:00
最近做安全做的我头晕脑胀,来点轻松的换换脑子,让自己放松下
Python 3.14 正式引入了一个新的机制叫作 Tail Call Interpreter(Made by Ken Jin),这无疑又是一个奠定未来基础的重大工作
在聊 Python 3.14 的 Tail Call Interpreter 之前,我们先要来聊 C 语言中最基本的语法 switch-case
1 |
switch (x) { |
对于这样的代码我们最终的汇编会是什么样的呢?可能大家第一反应是先 cmp 然后 je ,不等式秒了,我们编译一个版本来看看
对于这样一段代码
1 |
void small_switch(int x) { |
最终汇编的产物会是这样的
1 |
00000000000011f0 <small_switch>: |
我们能看到整体如我们所预期的一样,不断的 cmp 然后不断的 je,然后我们评估一下这里的复杂度呢?哦,时间复杂度 O(n) 秒了。
卧槽,那对于 Python 这样一个超大的 switch case 的结构,岂不是每次都是一个 O(n) ?这不得原地升天?
其实不是,通常来说,编译器会根据数据的类型和规模来用不同的方案处理 switch case 的结构
我们来准备几组例子
1 |
void small_switch(int x) { |
然后我们反汇编以下,看下结果(这里我只把关键的部分贴出来)
1 |
00000000000011f0 <small_switch>: |
我们这里能看到编译器根据数据的不同类型来处理了 switch case 的结构,这里我用一个表格总结一下
Switch类型 | Case数量 | 分布特点 | 编译器策略 | 时间复杂度 |
---|---|---|---|---|
small_switch | 3个 | 连续(1,2,3) | 线性比较 | O(n) |
dense_switch | 8个 | 连续(10-17) | 偏移跳转表 | O(1) |
sparse_switch | 4个 | 稀疏(1,100,1000,10000) | 二分查找 | O(log n) |
large_dense_switch | 20个 | 连续(1-20) | 标准跳转表 | O(1) |
mixed_switch | 7个 | 部分连续 | 嵌套比较 | O(log n) |
char_switch | 5个 | 连续(‘a’-‘e’) | 字符偏移表 | O(1) |
OK,这里我们发现,Switch-case 最终的实现因为数据类型的不一样,会导致我们最终的代码存在一个不可预测性。那么我们有没有办法来优化这个问题呢?答案是有的。
我们来看下面一段代码
1 |
|
我们能看到这里核心的一个操作是将我们 Switch-cased 的每个 case 都变成了一个标签,然后我们通过一个 jump_table 来直接跳转到对应的标签上, 我们来看一下最关键位置的汇编
1 |
11d3:48 8d 05 c6 2b 00 00 lea 0x2bc6(%rip),%rax # 3da0 <jump_table.0> |
这里我们可以总结出来使用 Computed Goto 相较于传统的 switch-case 有以下几点优势
那么具体能有多快呢?可以参见 CPython 引入的 Computed Goto 的一个测试结果,大概是整体提升了15% 左右
那么 Computed Goto 的方式就是完美的吗?其实也不是,目前 CPython 的解释器 ceval.c 也是目前最大的一个 switch case 中存在几个典型问题
1,3,4 都很好理解,我们来看一下2的一个例子(感谢 Ken Jin 提供的例子)
在 GCC 11 的时候,switch-case 某个部分会正常的代码
1 |
738f: movq%r13, %r15 |
而在 GCC 13-15Beta 的时候,则会产生这样的代码
1 |
747a: movzbl%ah, %ebx |
我们能发现,额外的寄存器被引入了。体系结构 101,额外的寄存器意味着额外的开销。寄存器是很贵的!
那么我们有没有办法来迭代上面的超大的 Switch case 呢?估计有同学在想,既然上面的 switch case 超级大,那么我们将其拆分为多个小的函数
这样编译器可以有足够的上下文来优化,同时我们的 perf 也可以精确的去分析每个函数的开销,岂不美哉?
但是又有同学反对了,函数调用会触发 call 的指令,会带来额外的寄存器入栈和出栈的开销,这样会不会又变慢了呢?
那么能不能优化一下呢?答案是可以的,很多同学可能会想到了,tail call
假设我们有这样一段代码
1 |
__attribute__((noinline)) void g(int x) { |
我们能看到这样一段汇编
1 |
0000000000001140 <g>: |
其中 call 1140 <g>
指令非常显眼。这也是函数调用本身的开销的一个重要来源
在现在编译器中,存在一种特殊的优化叫作尾递归,即当函数的最后一步是调用另一个函数时,编译器可以优化掉这个调用的开销
我们来测试一下
1 |
|
我们来看下相关汇编
1 |
0000000000001140 <g>: |
我们能看到,f
函数的最后一步是 jmp 1140 <g>
,而不是 call 1140 <g>
,这就意味着我们在调用 g
函数的时候不会有额外的寄存器分配等传统 call 指令带来的开销。
可能有同学回过味来了,那么这里在做尾递归处理后,感觉完全可以当作一种高性能 Goto 来看嘛。
Bingo,这里其实思路也是差不多这样的,在77年的一篇论文《Debunking the ‘Expensive Procedure Call’ Myth, or, Procedure Call Implementations Considered Harmful, or, Lambda: The Ultimate GOTO》就提到了,高效的过程调用可以和 Goto 性能相近,而在实现上会更简洁。
在 Python 3.14 中,Tail Call Interpreter 的实现就是基于这个思路的。
我们能看到我们对于 opcode 进行 dispatch 的宏进行了尾递归的处理
1 |
|
那么在保证我们基线性能和 Compute GOTO 甚至更优一点的同时,我们可以得到如下的一些好处
这篇文章差不多就是这样,虽然说是只介绍 Python 3.14 的 Tail Call Interpreter,但是还是完整的介绍了一些整个的一个演进思路
这也带给我一个启发,很多时候,可预测性真的是非常重要的一个特性。
这算是 3.14 中和 remote debug 一起并列为我最喜欢的两个feature,可观测性万岁!
2025-07-02 23:49:00
I’ve been overwhelmed by security work lately, so let me switch to something lighter to relax my mind.
Python 3.14 has officially introduced a new mechanism called Tail Call Interpreter (Made by Ken Jin), which is undoubtedly another major milestone that lays the foundation for the future.
Before discussing Python 3.14’s Tail Call Interpreter, we need to talk about the most basic syntax in C - switch-case.
1 |
switch (x) { |
What would the final assembly look like for such code? Most people’s first reaction might be to use cmp
followed by je
, and if it doesn’t match, continue. Let’s compile a version to see.
For this code:
1 |
void small_switch(int x) { |
The final assembly output would be:
1 |
00000000000011f0 <small_switch>: |
We can see that overall it’s as we expected - continuous cmp
followed by continuous je
. Now let’s evaluate the complexity here? Oh, time complexity O(n), that’s straightforward.
Damn, for Python with such a huge switch case structure, wouldn’t that be O(n) every time? Wouldn’t that be a performance disaster?
Actually, no. Usually, compilers use different strategies to handle switch case structures based on data type and scale.
Let’s prepare several examples:
1 |
void small_switch(int x) { |
Then we disassemble and look at the results (I’ll only paste the key parts here):
1 |
00000000000011f0 <small_switch>: |
Here we can see that the compiler handles switch case structures differently based on different data types. Let me summarize this with a table:
Switch Type | Case Count | Distribution | Compiler Strategy | Time Complexity |
---|---|---|---|---|
small_switch | 3 | Consecutive(1,2,3) | Linear comparison | O(n) |
dense_switch | 8 | Consecutive(10-17) | Offset jump table | O(1) |
sparse_switch | 4 | Sparse(1,100,1000,10000) | Binary search | O(log n) |
large_dense_switch | 20 | Consecutive(1-20) | Standard jump table | O(1) |
mixed_switch | 7 | Partially consecutive | Nested comparison | O(log n) |
char_switch | 5 | Consecutive(‘a’-‘e’) | Character offset table | O(1) |
OK, here we find that the final implementation of switch-case varies depending on data types, leading to unpredictability in our final code. So do we have ways to optimize this problem? The answer is yes.
Let’s look at the following code:
1 |
|
We can see that the core operation here is to turn each case of our switch-case into a label, then we use a jump_table to directly jump to the corresponding label. Let’s look at the assembly of the most critical part:
1 |
11d3:48 8d 05 c6 2b 00 00 lea 0x2bc6(%rip),%rax # 3da0 <jump_table.0> |
Here we can summarize that using Computed Goto compared to traditional switch-case has the following advantages:
So how much faster can it be? You can refer to the test results of Computed Goto introduced in CPython, which showed an overall improvement of about 15%.
So is the Computed Goto approach perfect? Actually, no. Currently, CPython’s interpreter ceval.c, which is also currently the largest switch case, has several typical problems:
Points 1, 3, and 4 are easy to understand. Let’s look at an example of point 2 (thanks to Ken Jin for providing the example).
In GCC 11, switch-case would generate normal code in certain parts:
1 |
738f: movq%r13, %r15 |
While in GCC 13-15Beta, it would generate code like this:
1 |
747a: movzbl%ah, %ebx |
We can see that additional registers were introduced. Computer Architecture 101: additional registers mean additional overhead. Registers are expensive!
So do we have ways to iterate on the extremely large switch case above? Some students might be thinking, since the switch case above is extremely large, why don’t we split it into multiple small functions so that the compiler can have enough context to optimize, and our perf can also precisely analyze the overhead of each function. Wouldn’t that be great?
But other students object: function calls trigger call instructions, which bring additional overhead of register push and pop operations. Won’t this make it slower again?
So can we optimize this? The answer is yes. Many students might have thought of it - tail call.
Suppose we have this code:
1 |
__attribute__((noinline)) void g(int x) { |
We can see this assembly:
1 |
0000000000001140 <g>: |
The call 1140 <g>
instruction is very prominent. This is also an important source of function call overhead.
In modern compilers, there’s a special optimization called tail recursion, where when the last step of a function is calling another function, the compiler can optimize away the overhead of this call.
Let’s test this:
1 |
|
Let’s look at the related assembly:
1 |
0000000000001140 <g>: |
We can see that the last step of function f
is jmp 1140 <g>
, not call 1140 <g>
. This means when we call function g
, we won’t have additional overhead like register allocation that traditional call instructions bring.
Some students might have realized that after tail recursion processing, this can completely be viewed as a high-performance Goto.
Bingo, the idea here is similar. A 1977 paper “Debunking the ‘Expensive Procedure Call’ Myth, or, Procedure Call Implementations Considered Harmful, or, Lambda: The Ultimate GOTO” mentioned that efficient procedure calls can have performance close to Goto, while being more concise in implementation.
In Python 3.14, the implementation of Tail Call Interpreter is based on this idea.
We can see that we’ve applied tail recursion processing to the macro that dispatches opcodes:
1 |
|
So while ensuring our baseline performance is as good as or even better than Computed Goto, we can get the following benefits:
This article is pretty much it. Although it claims to only introduce Python 3.14’s Tail Call Interpreter, it still completely introduces the entire evolution of thinking.
This also gives me an insight: predictability is really a very important characteristic in many cases.
This, along with remote debug, are the two features I like most in 3.14. Long live observability!
2025-04-26 22:49:00
Python 3.14 目前主要的一些主要的特性其实已经固定了,在我看来,Python 3.14 是一个未来很多年的一个核心版本。因为其确定了是时代的 Python
调试生态的基准,这篇文章将会来聊聊这个 Python 世界中的史诗级改进
在我们日常调试 Python 代码的时候,我们经常会遇到这样一个问题,我们需要采样当前的 Python Runtime 的状态,进而进一步调试我们的 Python 进程
常见的手段莫过于两种
process_vm_readv
等 Syscall 来直接整块读取内存无论这两种方式都有一个核心的问题,我们怎么样来解析内存中的数据?
用 https://github.com/jschwinger233/perf-examples/blob/main/cpython310_backtrace/bpf.c 来做一个例子,在之前的很多年的时候,我们会怎么做
1 |
|
上面的核心代码其实没多少,核心的逻辑就还是我们手动模拟 Python 中关键的 PyFrameObject
结构体,然后我们在内存中不断做一次搜索,暴力匹配到特征一致的内存
其余诸如 PySpy 这样的工具也是类似的思路
这个方式最核心的问题是在于说,Python 每个版本的 ABI 都可能发生变化,所以我们需要不断的根据不同的版本去做兼容(比如 PySpy 维护了从3.7到3.12的不同的 PyFrameObject
。
那么我们有没有更好的方法来处理这个问题?或者说我们能不能更好的去定位?
可以的,写 Python 的同学肯定都知道我们 Python 中有一个全局的变量 _PyRuntime
,其类型为 pyruntimestate
,大致的布局如下
1 |
struct pyruntimestate { |
眼尖的同学肯定看到了,我们其中有一段核心的代码
1 |
struct pyinterpreters { |
维护了一个 PyInterpreterState
的链表,我们可以通过 PyInterpreterState
来获取当前的 Frame,PyInterpreterState
中的 TreadState 来获取当前的线程状态
1 |
struct pythreads { |
而 PyThreadState
中和核心的 struct _PyInterpreterFrame *current_frame
就是我们需要的 frame state,整个流程大概如下
graph TD PyRuntime["_PyRuntime (pyruntimestate)"] --> Interpreters["interpreters (pyinterpreters)"] Interpreters -->|head| InterpreterStateHead["PyInterpreterState *head"] Interpreters -->|main| InterpreterStateMain["PyInterpreterState *main"] %% Define interpreter state structure subgraph PyInterpreterState InterpreterID["int64_t id"] ThreadsStruct["struct pythreads threads"] NextInterpreter["PyInterpreterState *next"] end InterpreterStateHead --- PyInterpreterState InterpreterStateMain --- PyInterpreterState %% Link to threads structure ThreadsStruct --> ThreadHead["PyThreadState *head"] ThreadsStruct --> ThreadMain["PyThreadState *main"] %% Define thread state structure subgraph PyThreadState ThreadID["uint64_t thread_id"] InterpreterPtr["PyInterpreterState *interp"] CurrentFrame["_PyInterpreterFrame *current_frame"] NextThread["PyThreadState *next"] end ThreadHead --- PyThreadState ThreadMain --- PyThreadState %% Frame structure CurrentFrame --> Frame["_PyInterpreterFrame structure"] subgraph _PyInterpreterFrame PreviousFrame["_PyInterpreterFrame *previous"] CodeObject["PyCodeObject *f_code"] Locals["PyObject **localsplus"] end %% Connected paths in color PyRuntime ==>|"Main Path"| Interpreters Interpreters ==>|"Main Path"| InterpreterStateMain InterpreterStateMain ==>|"Main Path"| ThreadsStruct ThreadsStruct ==>|"Main Path"| ThreadMain ThreadMain ==>|"Main Path"| CurrentFrame CurrentFrame ==>|"Main Path"| Frame class PyRuntime,InterpreterStateMain,ThreadMain,CurrentFrame,Frame mainPath; classDef mainPath fill:#f96,stroke:#333,stroke-width:2px; classDef mainNodes fill:#f9f,stroke:#333,stroke-width:2px;
那么我们现在来解决第一个问题,我们怎么样获取在内存中的 _PyRuntime
的地址呢?
我们把这个问题抽象成下面最简单一个 C 代码
1 |
|
我们怎么样获取 abc 的地址呢?这里写过 C 的同学可能反应过来了,我们可以使用 __attribute__((section()))
的语法,来将其放到一个特定的段中
1 |
|
我们编译,并用 readelf
来解析一下二进制
1 |
╰─ readelf -S ./a.out| grep my_section |
我们能看到这里我们得到了一个相对地址。后续我们就可以通过解析 ELF 来遍历寻找到 abc
变量的地址
那么在 Python 中同样如此,在代码中有这样一段代码
1 |
|
这样我们就能比较方便的获取到 PyRuntime 在内存中的地址。
那么现在第二个问题是,我们怎么样通过我们前面介绍的调用链获取到地址?
大家可能第一反应还是想通过维护不同版本的数据结构来获取具体的地址。不过这里我们有没有办法可以用更简单的方法来处理呢?答案是有的
眼尖的同学可能看到了我们在 pyruntimestate
中有一个字段叫 debug_offsets
,我们来看下我们怎么初始化这个字段的吧
1 |
我们能看到我们使用了 offsetof
这个非常经典的宏来将一下我们常用的字段相较于结构体的偏移写入到 debug_offsets
中去。而 debug_offsets
将固定存在于 pyruntimestate
的第一个字段,同时起改变频率相对较低,所以我们就可以通过 debugger_support
获取不同地址的偏移量来获取最终我们想要的数据。
通过这样的做法,我们实际上就有很多很好玩的事情可以做了。实际上官方也是基于这样一套机制提出了 PEP 768 – Safe external debugger interface for CPython https://peps.python.org/pep-0768/。可以允许用户远程的为一个 Python 进程注入一段调试代码
我们来看一下这个 PEP 的核心实现
在前面介绍过的 ThreadState 中新增了一组结构
1 |
typedef struct _remote_debugger_support { |
在执行过程中,如果 debugger_pending_call
为 1 的时候,我们就会去执行 debugger_script_path
中的脚本
1 |
int _PyRunRemoteDebugger(PyThreadState *tstate) |
那么问题来了,我们现在怎么样给目标 Python 进程注入对应的值呢?我们来看看 remote_debugging.c 中的实现
首先入口函数为 _PySysRemoteDebug_SendExec
1 |
int |
前面都是一些例行的检查,我们来看看 send_exec_to_proc_handle
这个函数
1 |
static int |
我们先不考虑具体的细节的话,这段函数的逻辑还是非常明确的,通过 read_offsets
获取目标的地址偏移,通过 read_memory
这个函数读取不同地址,然后做一些处理后,通过 write_memory
来写入到目标进程中去
而 read_offsets
这个函数就是我们前面核心提到过的怎么样使用目前 Python 给出的调试信息的例子,我们来看一下其在 Linux 下的实现
1 |
static int |
这里的核心函数是 _Py_RemoteDebug_ReadDebugOffsets
, 我们接着来看这个的实现
1 |
static int |
我们注意到,这里的核心还是我们先要获取到 PyRuntime
的地址,那么我们来看看 _Py_RemoteDebug_GetPyRuntimeAddress
的实现
1 |
static uintptr_t |
我们这里能看到 _Py_RemoteDebug_GetPyRuntimeAddress
调用了 search_linux_map_for_section
来获取当前的 PyRuntime
的地址,而 search_linux_map_for_section
则是通过 /proc/${pid}/maps
,暴力遍历 maps
中的内存段来获取具体的地址。
我们来看看 search_elf_file_for_section
的实现
1 |
search_elf_file_for_section( |
这段代码稍微有点复杂,我们来拆分看一下
首先函数的声明
1 |
search_elf_file_for_section( |
用于在ELF文件中搜索特定的section。参数包括:进程句柄、要查找的section名称、起始地址(文件在进程空间的映射位置)、ELF文件路径。
1 |
int fd = open(elf_file, O_RDONLY); |
以只读方式打开ELF文件,如果失败则设置Python异常并跳转到退出处理。
1 |
file_memory = mmap(NULL, file_stats.st_size, PROT_READ, MAP_PRIVATE, fd, 0); |
将文件内容映射到内存,以只读和私有方式,从文件头开始。失败则设置异常并退出。
1 |
Elf_Ehdr* elf_header = (Elf_Ehdr*)file_memory; |
将文件开头 cast 为ELF文件头结构,并找到section header表的位置,它在文件偏移e_shoff处。
1 |
Elf_Shdr* shstrtab_section = §ion_header_table[elf_header->e_shstrndx]; |
获取section字符串表(包含所有section名称的表),通过e_shstrndx索引定位。同时遍历所有section,查找匹配的section名称。注意需要跳过section名字的”.”前缀。
1 |
Elf_Phdr* program_header_table = (Elf_Phdr*)(file_memory + elf_header->e_phoff); |
找到program header表,然后搜索第一个PT_LOAD类型的segment,它定义了程序加载时的基地址。
1 |
if (section != NULL && first_load_segment != NULL) { |
如果找到了目标section和第一个LOAD segment,计算目标section的运行时地址:
经过这样一个流程,我们就能最终的获取到 _PyRuntime
中的地址,然后基于此做一些包括 PEP 768 在内很有趣的工作。
Python 3.14 官方其实将进程信息以半正式化的形式形成了一组相对稳定的 ABI,这样可以使我们调试工具能以更好的方式对 Python 进程进行无侵入的调试与观测。PEP 768 其实是这个过程中一个的有效产物。而基于 PEP768 处理的比如 Remote PDB debug,目前也已合入分支。
可以说从 Python 3.14 起,Python 的调试工具和手段将得到极大的丰富与增强。建议大家在出来后的第一时间进行升级(
差不多就这样(
2025-03-23 20:00:00
这篇文章鸽了很久,最终决定还是老老实实写完,来介绍一下常见的一些负载均衡算法实现。本文的代码最终都会放在 load-balancer-algorithm1 这个 repo 中
我从来没有觉得写博客快乐过
既然是讲 LoadBalancer 中常用的一些负载均衡算法,我们先来对一些前置准备做一些讨论
我们目前需要两个基础的数据结构
那么我们可以得出下面一些基础代码
1 |
import dataclasses |
同时我们在没有后端节点可供选择的时候,我们需要抛出一个异常
1 |
class NoNodesAvailableError(ValueError): |
好了,我们现在可以进行更进一步的抽象,我们可以将我们的负载均衡算法抽象为策略(Strategy), 那么我们可以得出如下的一些代码
1 |
from __future__ import annotations |
好了,我们现在可以往下去实现一些负载均衡算法了
负载均衡最简单的一个算法是做一个随机的选择,实现非常简单,最简单的伪代码实现差不多这样
1 |
a = [] |
我们来完整实现一下
1 |
class RandomStrategy(Strategy): |
OK,现在我们增加一个需求,现在我们每个节点都需要有一个权重值,权重值越高的节点被选中的概率越高。我们可以使用 random.choices 来实现这个需求,不过在此之前我们需要对 Node 进行一些修改
1 |
import dataclasses |
然后我们来实现一下 WeightedRandomStrategy
1 |
|
Random 确实是我们非常常用的一套负载均衡算法,但是缺点也很明显,其负载均衡的效果有一定的不可预测性,是神是鬼全靠你使用的 Random 函数的质量。运气不好就会出现分布非常密集的情况。那么我们有没有可用的更好的负载均衡算法呢?
我们对于负载均衡算法常见的需求是在逻辑上有一定的可预测性,从这角度上讲,轮询算法是一个非常好的选择。我们可以使用一个 index 来记录当前的节点,然后每次请求的时候都将 index + 1,直到 index 超过节点的数量,然后 index = 0
1 |
class RoundRobinStrategy(Strategy): |
这里我们实现了一个最基础的轮询算法(我们假设不存在节点不可用,节点增删改的情况),所以我们 index 一直可以有规律的变化
这里的结果很明显,如果有一个 [A, B] 的节点列表,那么我们会得到一个 [A, B, A, B, A, B] 的结果
那么现在我们更改一下需求,我们需要实现一个类似 WeightedRandomStrategy 的轮询算法,权重越高的节点被选中的概率越高。
1 |
class WeightedRoundRobinStrategy(Strategy): |
这里的核心算法很简单,我们基于每个节点的权重,得到一个扩展后的节点列表,然后我们就可以使用最基础的轮询算法来实现了
但是这里核心的一个弊端很明显,假设我们有 [A(weight=2),B(weight=1)] 这样一个节点组合,我们会得到 [A, A, B] 这样一个选择结果,这里的节点分布会非常不均匀。那么怎么办呢?我们可以参考一种来自 Nginx 的平滑算法2
我们首先给节点加上一个 current_weight 的熟悉,记录当前节点的权重值
1 |
import dataclasses |
然后我们来实现一下 WeightedRoundRobinStrategy
1 |
class WeightedRoundRobinStrategy(RoundRobinStrategy): |
这里新增的 current_weight 的作用很简单,
这本质上其实很巧妙的将节点打散,同时将 index 的属性利用 current_weight 来处理,经过处理,我们假设有 [A(weight=3),B(weight=2),C(weight=1)] 这样一个节点组合,我们会得到 [A, B, A, C, B, A] 这样一个选择结果,这里的节点分布会相对均匀很多
OK,现在我们轮询函数实现完成了,我们能发现,Random 和轮询算法本质上是两种无状态的算法(最原始的 RoundRobin 有状态,但是我们通过 current_weight 的方式将其变成了无状态),但是我们通常在业务上会有一些根据状态来选择节点的需求,常见的场景有
因此下面我们会来介绍两种算法
最小链接算法是一个非常简单的算法,我们需要在每次请求的时候,遍历所有的节点,找到当前连接数最少的节点,然后将请求转发到这个节点上。我们可以使用一个连接数的属性来记录当前节点的连接数
1 |
class LeastConnectionStrategy(Strategy): |
OK,那么我们接下来老规矩需要考虑加权的 LeastConnection 算法,这里稍晚有一点绕
那么我们来实现一下
1 |
class WeightedLeastConnectionStrategy(LeastConnectionStrategy): |
当然我们这里实际上有一点问题是,这里的选择可能会连续选择到同一个节点上(因为权重的不均匀),这里可以考虑把符合条件的节点放到一个列表中,然后使用我们前面提到过的 RoundRobin/Random 来选择一个节点来进行请求转发
这里我就不实现了,大家可以自己实现一下
我们在业务中经常有这样一种需求,我们需要将同一类请求转发到同一个节点上,这个时候我们就需要使用一致性 Hash 算法来实现了
最基础的一致性 Hash 算法是将请求的 key 和节点的 key 进行 hash 计算,然后将请求转发到 hash 值最接近的节点上。我们可以使用一个 ring 来表示所有的节点,然后在 ring 上找到离请求最近的节点。
但是这样存在比较大的问题是,如果有节点的增删改,这个时候我们已经分配好的逻辑会存在 rebalance 的问题。所以我们需要将这个变动变得最小。
目前主流的几种一致性 Hash 算法的核心思路都是通过虚拟节点来解决这个问题。我们可以将每个节点映射到多个虚拟节点上,然后在 ring 上找到离请求最近的虚拟节点,然后将请求转发到对应的真实节点上。
这样我们就可以将节点的增删改对请求的影响降到最低。
我们将以 Google 的 Maglev 算法为基础来实现一致性 Hash 算法
首先我们更改一下 Node 的代码
1 |
import dataclasses |
这里我们可以用 str(node) 来获取 nodekey
然后我们来介绍一下 Maglev 算法的核心思路(这里只介绍最简化版本的细节,详情可以参考 Maglev: A Fast and Reliable Software Network Load Balancer3)这篇论文
首先,我们要确定经过预处理后的产物 lookup table 的长度 M。所有 Key 都会被 hash 到这个 lookup table 中去,而 lookup table 中的每个元素都会被映射到一个 Node 上
而计算 lookup table 的计算分为两步
permutation 是一个 NM 的矩阵,列对应 *lookup table,行对应 node。 为了计算 permutation,需要挑选两个 hash 算法,分别计算两个值 offset 与 skip 。最后根据 offset 和 skip 的值来填充 permutation,计算方式描述如下:
其中 hash1 和 hash2 是两个不同的 hash 函数,我们后续会使用 xxhash 和 mmh3 这两种 hash 函数来实现
然后我们可以给出 lookup table 的计算方式
1 |
def calculate_lookup_table(n: int, m: int, permutation: list[list[int]]) -> list[int]: |
在这里我们能看到,这段循环代码必然结束,而最坏情况下,复杂度会非常高,最坏的情况可能会到 O(M^2)。原文中建议找一个远大于 N 的 M (To avoid this happening we always choose M such that M ≫ N.)可以使平均复杂度维持在 O(MlogM)
我们可以用论文中的图来评估下如果节点存在移除的情况,整体的 rebalance 的效果
我们现在来完整实现一下 Maglev 算法,我们先确定用请求中的 url 来作为 hash key,所以我们需要对 RequestContext 进行一些修改
1 |
import dataclasses |
好了,来把剩下的部分实现了
1 |
M = 65537 |
如果大家对 Google 整个 Maglev 系统感兴趣,可以去参考一篇我之前写博客,简单聊聊 Maglev ,来自 Google 的软负载均衡实践4
好了,这次负载均衡算法告一段落,其实工作中还有一些更组合的场景,比如 sharding 轮询之类的,不过整体思路都不会发生太大变化。希望大家看的开心
2025-02-04 02:00:00
看了一下台配摇曳露营的 PV 放松,有些地方很想吐槽,写个短文聊一下
先放出两版本对比
下面是原版
下面是台配
现在我们来聊一聊我觉得这个配音出现了什么样的问题
首先这是出自摇曳露营 S1E1 的最开始的部分。实际上是一个倒序的模式,将五人组的富士山露营在最开始进行展现,然后在季末进行收尾。
这个做法的效果和意图都很明确,其核心在于
让声优用声音将角色本身的性格立住
在这里面我认为问题最大的两个人,
其实犬山葵的问题也挺大的,但是出于方言角色确实不太好把握,这里就不多说了。
这两个角色的其实问题都很一致,配音者对于角色的性格把握不准。大垣千明是一个很干脆利落的角色,在四人组中是一个类似数码宝贝中太一一样 Leader 的角色,喜欢开玩笑,有一点假小子的感觉。而齐藤惠那性格和大垣千明有一些类似,不过齐藤会有更多一些少女味,所以她也成为四人组与凛之间的融合剂。
在原配中,大垣千明全程以很干脆利落的声线立住了人设。而齐藤惠那主要的两句台词“犬山同学,这样可以吗?”和“欸?真的吗?”声优很巧妙的换了不同的发声方式,让角色瞬间立体了起来。
在台配中,两者的声质都显得非常黏,或者用更不客气的观感来说,五个人的声质完全很难立住人设,属于是教科书里应该出现的声音,而不是动画里应该出现的声音。
而且台配还有一个比较明显的问题,声优对于情绪的把握出现了问题,比如还是经典的齐藤惠那的“欸?真的吗?”(背景是犬山提醒烤棉花糖不要离火太近,否则会烤焦)这句台词,原配中是一个带着学到新东西的惊讶的语气,而在台配中却在声音中显出了一些焦急。我觉得这是合格的声优不应该出现的意料外的问题。
另外一点其实被很多人忽略了,日语和中文的发音节奏和习惯是不一样的。在引入过程中,台词可能需要做一些适当的调整。比如在原配中,大垣千明的“欸!来芝麻凛的可可”(ほい しまりんココア一丁) ,这里 “一丁” 是日语中一个很口语的用法,声优选择在这里加了一个重音,来体现一个服务生的感觉,从而表现出大垣千明的古灵精怪。而在台配中,直接处理为“来,志摩凛的可可”,这里就没有很好的本土化。如果是我的话,我可能会选择更符合中文语境的 “来,志摩凛,你可可来咯!”这种口语化表达
这点在曾经上海电影译制厂译制的各种作品中体现的非常不错。我举个例子,在爱迪奥特曼第44话,激ファイト! 80vsウルトラセブン/激斗,爱迪对战奥特赛文。中,不良暴走族在被假冒赛文狂追的时候,说“まだ ついてきやがる チクショウ”,直译为“该死,他还在跟着我。”,上译的老前辈们处理为“赛文还在追我,TMD”。而且用了非常痞子的声线,我觉得这就是展现了一个非常好的本土化的正面例子
放一个片段大家感受下
差不多就吐槽这么多吧,翻译是一个累活苦活,希望大家也能多包容。希望不同地方的译者也能给我们带来不同的文化碰撞带来的惊喜。
差不多这样,祝大家新年快乐
2025-01-05 02:30:00
这篇博客是我在刷题群内的 2025 年的第一次分享整理的演讲稿。主要是完整复盘了过去几年里我犯下的两个比较典型的低级错误。
希望大家能看的开心
首先来看一下我们抽象后的架构
很平平常规的一个架构。而我犯的两次相对低级的错误分别是在数据的入口和数据落点上。OK 那么我们分别来看一下我犯下的错误
首先要分享的是我搞出的一个核心数据库删除的事故。在介绍事故现场之前,我将先介绍下下当时我们整体资源管理的结构
OK,我们继续往前讲,我们来激活一下事故现场的回忆
让我们先快进到事故的处理
非常刺激的一次经历。反思的部分我们放在后面。我们快进到第二次事故。CDN 变更事故。还是和之前一样先介绍一下大致的背景
那么梅开二度,让我们继续激活一下事故现场的回忆
继续快进到事故现场的处理
痛苦的回忆先告一段落。我们来复盘一下我们这两个事故中的共性问题。首先务虚的说核心还是对生产抱有侥幸。那么从技术上来说存在哪些问题呢?
所以围绕这样几个点,在事故发生后的一段时间内我在逐步推进一些改进
实际上在事故1和2中,我自己还有一些其余的建议给看到这篇文章的同学
差不多就这样,希望大家能从我的分享中得到一些启发。最后,希望大家在新的一年里都能够顺利,事事顺心。