Dec
26
先实现一个纯 Go 的版本:
修改上述 fac 方法,只保留函数定义(即声明存在该函数,由汇编代码实现):
新增 fac.s
编译运行:
$ go run .
3628800
说明:
1. TEXT ·fac(SB), $0-8
- TEXT 表示这个方法在 TEXT 段中
- · 是Unicode的「中点」(中文输入法,1左边的按键),前面省略了包名,表示这是 main 包的 fac 函数
- SB 是 stack base pointer,Go ASM 中的「伪寄存器」(不是硬件寄存器),大致等同于程序的起始地址
- $0-8:0 表示这个函数没有局部变量,8 表示返回值占用8个字节
2. MOVQ n+0(FP), CX
- MOVQ 的 Q 表示 8 个字节
- n+0(FP) 表示变量 n 在 FP(Frame Pointer,伪寄存器,表示这个函数的栈帧起始位置) + 0 的位置(即第一个参数)。注意这个写法形式上是必须得,但是变量名n没有实际意义,只是用来注记。
- CX 即 x86/x86_64 的 CX(16bit),ECX(32bit),RCX(64bit) 寄存器,具体多长取决于前面的指令(MOVQ是64bit)
- 这句的意思是把第一个参数的值写入 RCX
3. MOVQ $1, DX
- $1:$开头的是立即数
- 这句的意思是给 RDX 赋值为 1
4. IMULQ CX, DX
- DX = DX * CX
5. DECQ CX
- CX = CX - 1
6. JNZ LOOP
- JNZ: Jump if Not Zero
- 当 CX 不等于 0 时跳转到 LOOP
7. MOVQ DX, result+8(FP)
- 将 RDX 的值写入到 FP+8 的位置。
8. RET
- 返回到调用方。
p.s. 这个汇编版本的实现并不等同于原来 Go 版本,只是这样写会更简单(只要一个jump)。
参考:
- Golang ASM 简明教程:https://jiajunhuang.com/articles/2020_04_22-go_asm.md.html
- A Quick Guide to Go's Assembler:https://go.dev/doc/asm
// main.go
package main
import "fmt"
func fac(n uint) uint {
var result uint = 1
for n > 0 {
result = result * n
n -= 1
}
return result
}
func main() {
fmt.Println(fac(10))
}
package main
import "fmt"
func fac(n uint) uint {
var result uint = 1
for n > 0 {
result = result * n
n -= 1
}
return result
}
func main() {
fmt.Println(fac(10))
}
修改上述 fac 方法,只保留函数定义(即声明存在该函数,由汇编代码实现):
func fac(n uint) uint
新增 fac.s
TEXT ·fac(SB), $0-8
MOVQ n+0(FP), CX
MOVQ $1, DX
LOOP:
IMULQ CX, DX
DECQ CX
JNZ LOOP
MOVQ DX, result+8(FP)
RET
MOVQ n+0(FP), CX
MOVQ $1, DX
LOOP:
IMULQ CX, DX
DECQ CX
JNZ LOOP
MOVQ DX, result+8(FP)
RET
编译运行:
引用
$ go run .
3628800
说明:
1. TEXT ·fac(SB), $0-8
- TEXT 表示这个方法在 TEXT 段中
- · 是Unicode的「中点」(中文输入法,1左边的按键),前面省略了包名,表示这是 main 包的 fac 函数
- SB 是 stack base pointer,Go ASM 中的「伪寄存器」(不是硬件寄存器),大致等同于程序的起始地址
- $0-8:0 表示这个函数没有局部变量,8 表示返回值占用8个字节
2. MOVQ n+0(FP), CX
- MOVQ 的 Q 表示 8 个字节
- n+0(FP) 表示变量 n 在 FP(Frame Pointer,伪寄存器,表示这个函数的栈帧起始位置) + 0 的位置(即第一个参数)。注意这个写法形式上是必须得,但是变量名n没有实际意义,只是用来注记。
- CX 即 x86/x86_64 的 CX(16bit),ECX(32bit),RCX(64bit) 寄存器,具体多长取决于前面的指令(MOVQ是64bit)
- 这句的意思是把第一个参数的值写入 RCX
3. MOVQ $1, DX
- $1:$开头的是立即数
- 这句的意思是给 RDX 赋值为 1
4. IMULQ CX, DX
- DX = DX * CX
5. DECQ CX
- CX = CX - 1
6. JNZ LOOP
- JNZ: Jump if Not Zero
- 当 CX 不等于 0 时跳转到 LOOP
7. MOVQ DX, result+8(FP)
- 将 RDX 的值写入到 FP+8 的位置。
8. RET
- 返回到调用方。
p.s. 这个汇编版本的实现并不等同于原来 Go 版本,只是这样写会更简单(只要一个jump)。
参考:
- Golang ASM 简明教程:https://jiajunhuang.com/articles/2020_04_22-go_asm.md.html
- A Quick Guide to Go's Assembler:https://go.dev/doc/asm
May
16
踩了个小坑,记录一下。
swagger api 定义:
/upload:
post:
tags:
- "tag"
summary: "summary"
operationId: uploadFile
consumes:
- multipart/form-data
parameters:
- name: "data"
in: "formData"
type: "file"
required: true
description: "file content"
responses:
200:
description: "success"
schema:
$ref: "#/definitions/UploadFileResponse"
生成的 API:
直接用这个 API 请求会报错:
swagger Required request part 'file' is not present
细看发现是 swagger 生成的是 @RequestPart("file") 而不是 @RequestPart("data"),需要手动修改过来才能正确读取到文件字段。
swagger api 定义:
引用
/upload:
post:
tags:
- "tag"
summary: "summary"
operationId: uploadFile
consumes:
- multipart/form-data
parameters:
- name: "data"
in: "formData"
type: "file"
required: true
description: "file content"
responses:
200:
description: "success"
schema:
$ref: "#/definitions/UploadFileResponse"
生成的 API:
@RequestMapping(value = {"/sf/express/upload_channel_file" }, produces = { "application/json" }, consumes = { "multipart/form-data" }, method = RequestMethod.POST)
default ResponseEntity<UploadFileResponse> uploadFile(@ApiParam(value = "file detail") @Valid @RequestPart("file") MultipartFile data) {
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
}
default ResponseEntity<UploadFileResponse> uploadFile(@ApiParam(value = "file detail") @Valid @RequestPart("file") MultipartFile data) {
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
}
直接用这个 API 请求会报错:
swagger Required request part 'file' is not present
细看发现是 swagger 生成的是 @RequestPart("file") 而不是 @RequestPart("data"),需要手动修改过来才能正确读取到文件字段。
Feb
14
切到新语言就是不断地踩坑呀。
手头有一个函数,输入一个 itemList,统计不同类型的个数,核心代码如下:
其他 typeParser 在遇到异常数据时会返回 null。
重构的时候,稳妥起见为它写了个单测,结果就在上述代码的第二行 NPE 了。
debug 时确认了 typeParser 和 item 都不是 null,猜测是 switch 不能处理 null ,但不熟悉 java 语法,搜了一下,果然如此。
由于 case 数量不多,就暂且改成了 if ... else if ... else 的结构。
由此可见,单测对于代码的覆盖率确实是很有帮助。
手头有一个函数,输入一个 itemList,统计不同类型的个数,核心代码如下:
int xCount = 0, yCount = 0;
switch(typeParser.parse(item)) {
case X:
xCount++;
break;
case Y:
yCount++;
break;
default:
log.error("unknown type");
break;
}
switch(typeParser.parse(item)) {
case X:
xCount++;
break;
case Y:
yCount++;
break;
default:
log.error("unknown type");
break;
}
其他 typeParser 在遇到异常数据时会返回 null。
重构的时候,稳妥起见为它写了个单测,结果就在上述代码的第二行 NPE 了。
debug 时确认了 typeParser 和 item 都不是 null,猜测是 switch 不能处理 null ,但不熟悉 java 语法,搜了一下,果然如此。
由于 case 数量不多,就暂且改成了 if ... else if ... else 的结构。
由此可见,单测对于代码的覆盖率确实是很有帮助。
Jan
26
又遇到一个灵异的 intellij idea 问题。
背景:在项目 P 的同一个 Module M 下面, PKG P1 需要 import PKG P2 下面的 C1 和 C2 两个 class。
现象:import C1 成功,但是import C2失败;然而通过 mvn clean install 是可以正常完成编译的。
删除 project 下的 .idea 目录重新 import 并没有解决这个问题。
在另一个同学的电脑上尝试,报的错竟然不一样,是在 C2 这个 class 下无法 import 另一个 class C3,但同样可以通过 mvn 命令行完成编译。
通过 Google 搜到 StackOverflow 的这个 thread:
https://stackoverflow.com/a/66167190/802910
解决方案很简单:删掉 idea 的cache 目录,让它重建就好了。
在 windows 下,这个目录位于 %LOCALAPPDATA%\JetBrains\IdeaIC2021.2\caches (注意替换为自己版本号)
在 mac 下,目录应该是 ~/Library/Caches/JetBrains/IdeaIC2021.2
似乎 IDEA 本身也有清空 cache 的功能(File -> Invalidate Caches...),下次遇到再验证一下。
背景:在项目 P 的同一个 Module M 下面, PKG P1 需要 import PKG P2 下面的 C1 和 C2 两个 class。
现象:import C1 成功,但是import C2失败;然而通过 mvn clean install 是可以正常完成编译的。
删除 project 下的 .idea 目录重新 import 并没有解决这个问题。
在另一个同学的电脑上尝试,报的错竟然不一样,是在 C2 这个 class 下无法 import 另一个 class C3,但同样可以通过 mvn 命令行完成编译。
通过 Google 搜到 StackOverflow 的这个 thread:
https://stackoverflow.com/a/66167190/802910
解决方案很简单:删掉 idea 的cache 目录,让它重建就好了。
在 windows 下,这个目录位于 %LOCALAPPDATA%\JetBrains\IdeaIC2021.2\caches (注意替换为自己版本号)
在 mac 下,目录应该是 ~/Library/Caches/JetBrains/IdeaIC2021.2
似乎 IDEA 本身也有清空 cache 的功能(File -> Invalidate Caches...),下次遇到再验证一下。
Aug
8
最近老是想起陈芝麻烂谷子的事情,说明工龄所剩无几了。
- 1 -
又是在那遥远的 2009 年,那个“杯具”已经不是杯具的年头,度厂办个算法比赛,办出了点儿杯具的味道。
比赛的名字叫“百度之星”,那些年在校园里影响力还蛮大的(好像现在还是),大概赛制就是通过初赛、复赛、决赛这么几轮,选出几个社会主义四有青年瓜分奖金。值得一提的是,头两年(05、06)冠军都被楼教主承包了。
为什么说有点杯具的味道呢,因为那年的初赛结束以后,度厂搞了个骚操作,把所有参赛选手提交的代码导出来,打了个包,发在贴吧里。
于是贴吧突然就炸了。
- 2 -
要知道初赛是网络赛,各个学校的 ACM(或OI) 集训队的队员们很可能都是凑在机房一起参与,毕竟毛主席教导我们人多力量大嘛。
所以这就有点意思了:如果同一个题,两份代码长得很像,说明互相抄袭概率就很大。
问题是参赛人数有点多,两两对比这 O(n^2) 的时间复杂度,靠人肉有点儿吃不消;但这么大一个瓜,光看不吃又有点可惜了。
那么这瓜到底该怎么吃呢?
- 3 -
还好,作为一个竞赛战五渣的我,通过初赛的能力没有,搞事情的能力还是有一点的。
为了对比两份代码的相似度,那我首先得找到一个指标。
作为一个善于作弊 思考的社会主义三无青年,我估计作弊者在拿到弊友代码后不会直接提交,至少会做些更换变量名之类操作;但竞赛时间紧迫,大概率来不及修改整体框架。
那么,只要我能算出修改的程度,就能判断相似度了:假设两份代码长度分别是 m、n,修改了 x 个字符, 我们大致可以把 `100% - x / ((m + n) / 2)` 当成相似度(没被修改的字符占比)。
但如何计算这修改的程度呢?毕竟修改前后代码长度很可能不同,不适合通过逐字比较来找到差别。
- 4 -
既然直接处理两段代码有点棘手,那不妨先考虑一下简单的 case 。
比如说要将字符串 "a" 改成 "b",这个简单,只要改一个字符就行。
又比如说要将字符串 "a" 改成 "ab",这个也简单,只要添加一个字符就行。
再比如说要将字符串 "ab" 改成 "b",这个依然简单,只要删除一个字符就行。
如果我能算出将一段字符串通过添加、删除、修改三种操作修改成另一个字符串的最少操作次数,应该就能把这瓜给吃了。
好像是找到了方向,但是要怎么实现呢?
- 5 -
既然直接处理两段代码还是有点棘手,那不妨再考虑一下稍微复杂一点的 case ,比如把字符串 X = "baidu" 变成 Y = "bytedance"。
首先,因为 X[0] 和 Y[0] 相同,我们可以把问题简化成:将 "aidu" 变成 "ytedance",即 X[1..4] => Y[1..8](后面简记为 X[1:] => Y[1:])。
接着,X[1] = 'a', Y[1] = 'y',如上一节所说,这时候我们有三种选择:
* 添加:给 X 添加 'y' ,当成 "byaidu",于是问题简化为将 "aidu" 变成 "tedance",即 X[1:] => Y[2:];(注意,我们在这里把该操作当成一个思维实验就好,不需要真的修改 X ,所以这里 "aidu" 对应 X[1:] 而不是 X[2:])
* 删除:从 X 中删掉 'a' ,当成 "bidu",问题简化为将 "idu" 变成 ytedance",即 X[2:] => Y[1:];
* 修改:把 X 这个 'a' 改成 'y',当成 '"byidu",问题简化为将 "idu" 变成 "tedance",即 X[2:] => Y[2:];
这三种操作中,后续需要的修改次数最少的那个,再加上 1,就是我们把 X[1:] 改成 Y[1:] 所需的最少次数。
依此类推,如果我们将 X[i:] 变成 Y[j:] 需要 f(i, j) 次操作,由上可得到这样一个公式:
也就是说,我们可以用子问题的最优解来推导出问题的最优解,如果用不说人话的方式,就叫做该问题存在最优子结构。
这么一看,代码是不是呼之欲出了?
- 6 -
啰嗦了这么多,不能忘了大佬的教导:
说干就干,把上面推导的公式落地成代码,so easy:
然后跑起来一看:
- 7 -
"index out of range",越界了 —— 显然,这里漏掉了递归的终止条件。
不过这个简单:如果 X 越界了,说明这时 i == len(X),那么 Y 还剩几个字符,我们都加上就行,即还需要 len(Y) - j 次操作;或者 Y 越界了,说明 j == len(Y),我们把 X 剩下的几个字符删掉,即 len(X) - i 次操作。
于是代码变成了:
跑起来效果杠杠的,马上就得到了 7,算得没毛病。
只可惜这代码还没法用 —— 别说跑那些参赛代码,就连长度为 16 的两个字符串都跑不出结果……
- 8 -
稍微分析一下我们就会发现,它竟然是指数时间复杂度的:
不过仔细观察上图(不仔细也没关系,我给红色高亮了),我们可以发现,为了计算 f(i, j),我们需要计算 f(i + 1, j + 1);而且它也被用于计算 f(i, j + 1) 和 f(i + 1, j) 。
这意味着这个问题存在重复子问题,类似于我们用 f(i) = f(i - 1) + f(i - 2) 计算飞波纳妾 fibonacci 数列。
因此我们完全可以将 f(i, j) 存下来、避免重复计算,就像这样:
注:实际上这里也可以直接用一个二维数组 f[i][j] 来保存 f(i, j),对 C/C++ 来说实现会更容易,读写效率也更高。
优化以后,因为每个 f(i, j) 只需要计算 1 次、并且可以在常数时间里完成(因为子问题已经计算好了),我们将时间复杂度降到了 O(m * n) ,从而可以在短时间内计算出结果了。
- 9 -
在这个问题里,还有一个很重要的特性是,我们在计算 f(i, j) 的时候,并不关心从 f(0, 0) 到 f(i, j) 的推导过程,或者说从 f(0, 0) 推导到 f(i, j) 的过程对后续计算 f(i, j) 没有任何影响。用不说人话的方式,这就叫做无后效性。
注意,无后效性并不是对所有问题都成立的。
比如在一个 N * N 的迷宫里寻找从坐标 (1, 1) 走到 (N, N) 的一条路径,我们在使用 BFS 或 DFS 搜索时需要将路过的坐标做好标记,避免再次走到,因此从 (1, 1) 到 (x, y) 的路径是会影响从 (x, y) 到 (N, N) 的路径,不满足无后效性。
汇总一下前面提到的这个问题的几个特点:
* 最优子结构
* 重复子问题
* 无后效性
—— 这不就是标准的 动态规划 问题嘛!对应的,第 5 节我们推导出的公式,就是这个动态规划问题的状态转移方程了!
由于满足这几个特性,我们实际上可用迭代的方式,从 f(m, n) 倒推出 f(0, 0),这样可以使用循环而不是递归,进一步提高代码的运行效率。
另外,对于同一个动态规划问题,状态转移方程也可以不止有一种写法,比如我们可以定义 g(i, j) 为将 X[1..i] 变为 Y[1..j] 的最小操作次数,于是:
基于这一版状态转移方程,我们就可以从 f(0, 0) 开始逐步计算出 f(m, n)。
具体代码就不在这里贴了,感兴趣的同学可以自己写写看,注意边界值的处理就好。
实际上,这个算法叫做编辑距离,英文名 Levenshtein distance,是一个和普京同名、姓 Levenshtein 的战斗民族大佬在 1965 年提出的算法;该算法经典到 php 的标准库里竟然包含了一个 levenshtein 函数(可见php的作者真是太闲了)。
- a -
还没完,在追求性能和效果的路上我们还能走得更远,根据代码本身的特性,我们还有一些优化可以做。
比如说代码的注释是可以先删掉再比较。
比如说代码里的字符串也可以删掉再比较。
比如说代码里的空格,貌似也不影响我们的相似度对比。
通过这样的操作,我们可以大幅缩短了需要对比的代码长度,对于 O(m*n) 的算法而言,这个改善是很可观的。
此外,当时下场切瓜的另一位瓜友将这个思路推到了极致:既然我们认为代码的整体框架不变、改变的只是变量名这些东西,那我们直接把所有字母、数字、空格等字符全删除,只留下标点符号来代表代码的框架,不是更香吗?
这样我们既提升了计算的效率,又进一步提升了计算的效果。
值得一提的是,该瓜友用的不是编辑距离,而是另一个经典的动态规划算法“最长公共子序列(LCS)”。
- b -
啰嗦了这么多,终于可以写总结了:
* 计算两个文本的相似度,可以使用编辑距离或者最长公共子序列算法;这两个都是典型的动态规划算法;
* 动态规划问题需要满足最优子结构、重复子问题、无后效性;
* 要解决动态规划问题,先定义状态,推导状态转义方程,再编码;可以用递归也可以用迭代,前者实现简单,后者运行更快;
* 搞事情要考虑后果,本不应该把参赛账号名也直接公开,结果是那次比赛有些人面子上有点不好看;不过年轻时谁没犯点错呢,除了自己谁又还记得呢?
最后,“编辑距离” 是个 LeetCode 的原题(No. 72,传送门),;“最长公共子序列” 也是个 LeetCode 的原题(No. 1143,传送门),感兴趣的同学也别错过。
都看到这儿了,帮忙点个 “赞” 或分享给你的小伙伴吧,感谢~
---
推荐阅读:
* 程序员面试指北:面试官视角
* 又是面试题?对,合并有序序列。
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* (译) C程序员该知道的内存知识 (1)
- 1 -
又是在那遥远的 2009 年,那个“杯具”已经不是杯具的年头,度厂办个算法比赛,办出了点儿杯具的味道。
比赛的名字叫“百度之星”,那些年在校园里影响力还蛮大的(好像现在还是),大概赛制就是通过初赛、复赛、决赛这么几轮,选出几个社会主义四有青年
为什么说有点杯具的味道呢,因为那年的初赛结束以后,度厂搞了个骚操作,把所有参赛选手提交的代码导出来,打了个包,发在贴吧里。
于是贴吧突然就炸了。
- 2 -
要知道初赛是网络赛,各个学校的 ACM(或OI) 集训队的队员们很可能都是凑在机房一起参与,毕竟毛主席教导我们人多力量大嘛。
所以这就有点意思了:如果同一个题,两份代码长得很像,说明互相抄袭概率就很大。
问题是参赛人数有点多,两两对比这 O(n^2) 的时间复杂度,靠人肉有点儿吃不消;但这么大一个瓜,光看不吃又有点可惜了。
那么这瓜到底该怎么吃呢?
- 3 -
还好,作为一个竞赛战五渣的我,通过初赛的能力没有,搞事情的能力还是有一点的。
为了对比两份代码的相似度,那我首先得找到一个指标。
作为一个善于
那么,只要我能算出修改的程度,就能判断相似度了:假设两份代码长度分别是 m、n,修改了 x 个字符, 我们大致可以把 `100% - x / ((m + n) / 2)` 当成相似度(没被修改的字符占比)。
但如何计算这修改的程度呢?毕竟修改前后代码长度很可能不同,不适合通过逐字比较来找到差别。
- 4 -
既然直接处理两段代码有点棘手,那不妨先考虑一下简单的 case 。
比如说要将字符串 "a" 改成 "b",这个简单,只要改一个字符就行。
又比如说要将字符串 "a" 改成 "ab",这个也简单,只要添加一个字符就行。
再比如说要将字符串 "ab" 改成 "b",这个依然简单,只要删除一个字符就行。
如果我能算出将一段字符串通过添加、删除、修改三种操作修改成另一个字符串的最少操作次数,应该就能把这瓜给吃了。
好像是找到了方向,但是要怎么实现呢?
- 5 -
既然直接处理两段代码还是有点棘手,那不妨再考虑一下稍微复杂一点的 case ,比如把字符串 X = "baidu" 变成 Y = "bytedance"。
首先,因为 X[0] 和 Y[0] 相同,我们可以把问题简化成:将 "aidu" 变成 "ytedance",即 X[1..4] => Y[1..8](后面简记为 X[1:] => Y[1:])。
接着,X[1] = 'a', Y[1] = 'y',如上一节所说,这时候我们有三种选择:
* 添加:给 X 添加 'y' ,当成 "byaidu",于是问题简化为将 "aidu" 变成 "tedance",即 X[1:] => Y[2:];(注意,我们在这里把该操作当成一个思维实验就好,不需要真的修改 X ,所以这里 "aidu" 对应 X[1:] 而不是 X[2:])
* 删除:从 X 中删掉 'a' ,当成 "bidu",问题简化为将 "idu" 变成 ytedance",即 X[2:] => Y[1:];
* 修改:把 X 这个 'a' 改成 'y',当成 '"byidu",问题简化为将 "idu" 变成 "tedance",即 X[2:] => Y[2:];
这三种操作中,后续需要的修改次数最少的那个,再加上 1,就是我们把 X[1:] 改成 Y[1:] 所需的最少次数。
依此类推,如果我们将 X[i:] 变成 Y[j:] 需要 f(i, j) 次操作,由上可得到这样一个公式:
if X[i] == Y[j]:
f(i, j) = f(i + 1, j + 1)
else:
f(i, j) = 1 + min(
f(i, j + 1), #添加
f(i + 1, j), #删除
f(i + 1, j + 1) #修改
)
f(i, j) = f(i + 1, j + 1)
else:
f(i, j) = 1 + min(
f(i, j + 1), #添加
f(i + 1, j), #删除
f(i + 1, j + 1) #修改
)
也就是说,我们可以用子问题的最优解来推导出问题的最优解,如果用不说人话的方式,就叫做该问题存在最优子结构。
这么一看,代码是不是呼之欲出了?
- 6 -
啰嗦了这么多,不能忘了大佬的教导:
说干就干,把上面推导的公式落地成代码,so easy:
def change(x, y):
def f(i, j):
if x[i] == y[j]:
return f(i + 1, j + 1)
else:
return 1 + min(
f(i, j + 1),
f(i + 1, j),
f(i + 1, j + 1)
)
return f(0, 0)
print change("baidu", "bytedance")
def f(i, j):
if x[i] == y[j]:
return f(i + 1, j + 1)
else:
return 1 + min(
f(i, j + 1),
f(i + 1, j),
f(i + 1, j + 1)
)
return f(0, 0)
print change("baidu", "bytedance")
然后跑起来一看:
$ python change.py
Traceback (most recent call last):
File "change.py", line 13, in <module>
print change("baidu", "bytedance")
... #省略一大段
File "change.py", line 3, in f
if x[i] == y[j]:
IndexError: string index out of range
Traceback (most recent call last):
File "change.py", line 13, in <module>
print change("baidu", "bytedance")
... #省略一大段
File "change.py", line 3, in f
if x[i] == y[j]:
IndexError: string index out of range
- 7 -
"index out of range",越界了 —— 显然,这里漏掉了递归的终止条件。
不过这个简单:如果 X 越界了,说明这时 i == len(X),那么 Y 还剩几个字符,我们都加上就行,即还需要 len(Y) - j 次操作;或者 Y 越界了,说明 j == len(Y),我们把 X 剩下的几个字符删掉,即 len(X) - i 次操作。
于是代码变成了:
def change(x, y):
def f(i, j):
if i == len(x):
return len(y) - j
if j == len(y):
return len(x) - i
if x[i] == y[j]:
return f(i + 1, j + 1)
else:
return 1 + min(
f(i, j + 1),
f(i + 1, j),
f(i + 1, j + 1)
)
return f(0, 0)
print change("baidu", "bytedance")
def f(i, j):
if i == len(x):
return len(y) - j
if j == len(y):
return len(x) - i
if x[i] == y[j]:
return f(i + 1, j + 1)
else:
return 1 + min(
f(i, j + 1),
f(i + 1, j),
f(i + 1, j + 1)
)
return f(0, 0)
print change("baidu", "bytedance")
跑起来效果杠杠的,马上就得到了 7,算得没毛病。
只可惜这代码还没法用 —— 别说跑那些参赛代码,就连长度为 16 的两个字符串都跑不出结果……
- 8 -
稍微分析一下我们就会发现,它竟然是指数时间复杂度的:
不过仔细观察上图(不仔细也没关系,我给红色高亮了),我们可以发现,为了计算 f(i, j),我们需要计算 f(i + 1, j + 1);而且它也被用于计算 f(i, j + 1) 和 f(i + 1, j) 。
这意味着这个问题存在重复子问题,类似于我们用 f(i) = f(i - 1) + f(i - 2) 计算
因此我们完全可以将 f(i, j) 存下来、避免重复计算,就像这样:
def change(x, y):
cache = {}
def f(i, j):
if i == len(x):
return len(y) - j
if j == len(y):
return len(x) - i
if (i, j) not in cache:
if x[i] == y[j]:
ans = f(i + 1, j + 1)
else:
ans = 1 + min(
f(i, j + 1),
f(i + 1, j),
f(i + 1, j + 1)
)
cache[(i, j)] = ans
return cache[(i, j)]
return f(0, 0)
cache = {}
def f(i, j):
if i == len(x):
return len(y) - j
if j == len(y):
return len(x) - i
if (i, j) not in cache:
if x[i] == y[j]:
ans = f(i + 1, j + 1)
else:
ans = 1 + min(
f(i, j + 1),
f(i + 1, j),
f(i + 1, j + 1)
)
cache[(i, j)] = ans
return cache[(i, j)]
return f(0, 0)
注:实际上这里也可以直接用一个二维数组 f[i][j] 来保存 f(i, j),对 C/C++ 来说实现会更容易,读写效率也更高。
优化以后,因为每个 f(i, j) 只需要计算 1 次、并且可以在常数时间里完成(因为子问题已经计算好了),我们将时间复杂度降到了 O(m * n) ,从而可以在短时间内计算出结果了。
- 9 -
在这个问题里,还有一个很重要的特性是,我们在计算 f(i, j) 的时候,并不关心从 f(0, 0) 到 f(i, j) 的推导过程,或者说从 f(0, 0) 推导到 f(i, j) 的过程对后续计算 f(i, j) 没有任何影响。用不说人话的方式,这就叫做无后效性。
注意,无后效性并不是对所有问题都成立的。
比如在一个 N * N 的迷宫里寻找从坐标 (1, 1) 走到 (N, N) 的一条路径,我们在使用 BFS 或 DFS 搜索时需要将路过的坐标做好标记,避免再次走到,因此从 (1, 1) 到 (x, y) 的路径是会影响从 (x, y) 到 (N, N) 的路径,不满足无后效性。
汇总一下前面提到的这个问题的几个特点:
* 最优子结构
* 重复子问题
* 无后效性
—— 这不就是标准的 动态规划 问题嘛!对应的,第 5 节我们推导出的公式,就是这个动态规划问题的状态转移方程了!
由于满足这几个特性,我们实际上可用迭代的方式,从 f(m, n) 倒推出 f(0, 0),这样可以使用循环而不是递归,进一步提高代码的运行效率。
另外,对于同一个动态规划问题,状态转移方程也可以不止有一种写法,比如我们可以定义 g(i, j) 为将 X[1..i] 变为 Y[1..j] 的最小操作次数,于是:
if X[i] == Y[j]:
g(i, j) = g(i - 1, j - 1)
else:
g(i, j) = 1 + min(
g(i, j - 1),
g(i - 1, j),
g(i + 1, j + 1)
)
g(i, j) = g(i - 1, j - 1)
else:
g(i, j) = 1 + min(
g(i, j - 1),
g(i - 1, j),
g(i + 1, j + 1)
)
基于这一版状态转移方程,我们就可以从 f(0, 0) 开始逐步计算出 f(m, n)。
具体代码就不在这里贴了,感兴趣的同学可以自己写写看,注意边界值的处理就好。
实际上,这个算法叫做编辑距离,英文名 Levenshtein distance,是一个和普京同名、姓 Levenshtein 的战斗民族大佬在 1965 年提出的算法;该算法经典到 php 的标准库里竟然包含了一个 levenshtein 函数(
- a -
还没完,在追求性能和效果的路上我们还能走得更远,根据代码本身的特性,我们还有一些优化可以做。
比如说代码的注释是可以先删掉再比较。
比如说代码里的字符串也可以删掉再比较。
比如说代码里的空格,貌似也不影响我们的相似度对比。
通过这样的操作,我们可以大幅缩短了需要对比的代码长度,对于 O(m*n) 的算法而言,这个改善是很可观的。
此外,当时下场切瓜的另一位瓜友将这个思路推到了极致:既然我们认为代码的整体框架不变、改变的只是变量名这些东西,那我们直接把所有字母、数字、空格等字符全删除,只留下标点符号来代表代码的框架,不是更香吗?
这样我们既提升了计算的效率,又进一步提升了计算的效果。
值得一提的是,该瓜友用的不是编辑距离,而是另一个经典的动态规划算法“最长公共子序列(LCS)”。
- b -
啰嗦了这么多,终于可以写总结了:
* 计算两个文本的相似度,可以使用编辑距离或者最长公共子序列算法;这两个都是典型的动态规划算法;
* 动态规划问题需要满足最优子结构、重复子问题、无后效性;
* 要解决动态规划问题,先定义状态,推导状态转义方程,再编码;可以用递归也可以用迭代,前者实现简单,后者运行更快;
* 搞事情要考虑后果,本不应该把参赛账号名也直接公开,结果是那次比赛有些人面子上有点不好看;不过年轻时谁没犯点错呢,除了自己谁又还记得呢?
最后,“编辑距离” 是个 LeetCode 的原题(No. 72,传送门),;“最长公共子序列” 也是个 LeetCode 的原题(No. 1143,传送门),感兴趣的同学也别错过。
都看到这儿了,帮忙点个 “赞” 或分享给你的小伙伴吧,感谢~
---
推荐阅读:
* 程序员面试指北:面试官视角
* 又是面试题?对,合并有序序列。
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* (译) C程序员该知道的内存知识 (1)
Aug
1
- 鹅厂 -
在遥远的2009年,那时候“呵呵”还没有奇怪的意思,我笑呵呵地去参加了鹅厂的实习招聘。
面试被安排在面试官下榻酒店的房间里,校门口的**王朝大酒店,可能一晚上能顶我一个月生活费那种。
过程聊得应该还可以,不过大部分细节都忘了,只记得最后那道代码题,一张纸,一支笔。
题面很简单:写一个 C 函数,合并两个有序数组。
- “最好能通用一点”,面试官补充说。
- “可以用 C++ 模板吗?”
- “最好还是用 C 。”
好多年以后,当我开始面试别人了,发现这道题确实很好用。
- 解 -
学过归并排序的同学应该都会觉得这个题目并不难,只不过是其中的一次归并环节。
其基本思路是,用两个指针,分别从数组的第一个元素开始,依次比较,每次找到最小的元素存入新数组,然后将指针移动到下一位。
需要注意的是当一个数组被取完以后,还得处理另一个数组的剩余元素。
而所谓“通用”,是指数组的元素可以是任意类型,因此需要把数组元素的大小、用于比较的函数也作为参数传进去。
大概就是要实现这样的一个函数:
其中 m、n 分别表示 A、B 这两个数组的长度,size 表示数组元素的大小。
具体实现的 C 代码比较琐碎,就不在这里贴出来了,感兴趣的同学可以自己试着写一下。
- WHY -
我在上一家公司,通常用这道题当笔试的压轴题,但不限制语言,以及去掉了对“通用”的要求。
为什么选它呢?
首先,它很容易理解,不会产生歧义,不需要额外解释。
其次,在纸面上编码(至少是脱离IDE),程序员在编码前得想清楚;涂改较多也说明一些问题。
最重要的是,它有很好的区分度,因为真的有很多程序猿没认真学过归并排序。
但至少每个人都能想到将两个数组合并,然后进行排序。
有些特别直接的小伙伴,就用了 PHP 自带的 sort 函数,后来我们不得不加个说明:“避免使用库函数”。
至于排序算法,有人写冒泡,也有人写快排;快排的实现,又可以考察是不是在数组里做原地划分(大部分是拆到两个数组里再合并)。
并且,我们在题面上特地对 有序 两个字加粗、加下划线,引导候选人使用最优解法。
如果候选人最终仍然实现了排序解法,在面试中还可以再提示,是否能用上“有序”这个条件,进一步提高性能。
这样层层递进,能够较好地帮助我们判断候选人的编码能力。
不过机关算尽,还是遇到了比较挫败的case,比如一个候选人就反问:系统自带的函数效率最高啊,为什么要自己实现?
- 字节 -
到了字节跳动后,我发现这道题有点不够用了,撑死只能算 LeetCode Easy,对于有勇气来面试字节的候选人,通常都不在话下。
为了把它升级到 Medium,我想到了两个改动:
1. 两个不够,m个来凑
2. 数组太简单,得换成链表
然后一看,诶,这 tm 不就是 LeetCode 23 原题吗?
话说回来,这题就变成了:请把 m 个有序链表合并成一个新的有序链表;平均每个链表有 k 个节点。
- 解² -
不用说,所有候选人都能想到每次遍历所有链表、找到最小值加入新的链表。
对于选择这个思路的候选人,接下来的问题是:
Q1:这个方案的时间复杂度是多少呢?
有不少候选人回答 O(m * k),大概是觉得两个链表合并是 O(2 * k),m个链表合并自然是 O(m * k) 了。
实际上,使用这种思路,每次找到最小值需要逐个比较 m 个链表,这个操作需要执行 m * k 次(节点总数),因此总的时间复杂度应该是 O(m^2 * k)。
Q2:还有优化空间吗?
有些候选人确实想不到更优的解法,但只要能按这思路完成 bug free 的代码,综合面试中的其他表现,也可以通过我们的考查(详见 程序员面试指北:面试官视角)。
毕竟 LeetCode 23 原题可是 Hard 级别。
- 分治 -
对分治算法比较熟悉的候选人会想到,可以先两两合并,得到的 m / 2 个链表再两两合并,循环这个过程,直到只剩下一个链表。
然后又回到 Q1:这个方案的时间复杂度是多少呢?
这回答就千奇百怪了,O(m * log(k)),O(k * log(m)), O(m * k * log(k))……
这个计算其实不难:
* 第一轮需要 m/2 次两两合并,每次两两合并是 2k 次比较,合计 m*k
* 第二轮需要 m/4 次两两合并,每次两两合并是 4k 次比较(每个链表平均长度变成2k了),合计还是 m*k
* ……
* 对 m 个元素做二分,总共需要 log(m) 轮
所以合理的答案应该是 O(m * k * log(m))。
具体实现又可以分成上下两部分。
下层应该实现一个合并俩链表的逻辑,比较常见的错误是没能正确处理链表的头结点(比如直接当成尾节点用,或者忘记初始化,以及 C++ 小伙伴用了 new 以后常常忘了 delete),还有前面说到的,一个链表摘空了后,需要处理另一个链表剩下的节点。
上层的实现其实和归并排序长得一毛一样,可以 bottom-up,也可以 top-down。bottom-up 的实现常见的错误是没处理好落单的那一个,而 top-down 则需要注意递归的终止条件。
另外有点意外的是,不少 Java 小伙伴被 List 这个 Interface 荼毒还挺深,在编码的时候顺手就用 List.get(i) ,完全不考虑这个 API 的开销。
- 最小堆 -
对常见数据结构比较熟悉的候选人则会提出使用最小堆,这样可以将每次查找最小值的时间复杂度降为 log(m) ,于是总的时间复杂度也可以降为 O(m * k * log(m))。
既然提到了堆,那就可以顺便问一下,最小元素从堆顶被摘掉以后,如何调整堆?
于是那些只知道可以用最小堆、不知道如何实现堆的候选人就暴露出来了。
不过也不打紧,大部分语言的库里都实现了 PriorityQueue 这个数据结构,让候选人直接用语言提供的版本来编码就好。
具体的代码主要有两个坑,一是循环中要注意对摘空链表的处理,二是对链表头结点的处理(前面提到了)。
- 没完 -
面试到上面的程度就足够了,不然 45 分钟实在是不够用。
但其实还有些值得思考的问题没讲完。
比如说,这两种算法,平均时间复杂度都是 O(m * k * log(m)),到底哪一个更好呢?
分治算法的优势是,两两合并时,当一个链表为空,可以直接将另一个链表的剩余节点串起来,相比于堆算法可以节约一些时间。
另一方面,对于这样一个经典的多路归并问题,实际使用场景可能是要合并外存上的多个排好序的文件,这时候堆算法可以节约磁盘IO(只需要一次遍历),相比于分治算法就有了压倒性的优势。
所以具体还是要看场景。
再比如,在这个场景下,堆并不是最高效的数据结构。
实际上,堆算法只是多路归并的早期实现,由于每一层的调整都需要两次比较(先取出两个子节点的较小者,然后再和当前节点比较),其效率还有优化空间。
(堆的调整)
如果我们将用于比较的节点作为叶子节点构建一棵完全二叉树,从叶子节点往上只保存获胜的节点:
这样每一层只需要和其兄弟节点做比较即可。这就是所谓“胜者树”,说起来还是空间换时间的套路(多一倍的节点数)。
还没完 —— 这个方案对每一层的更新仍然需要访问3个节点(自己、兄弟节点,父节点),换个思路,如果我们在路径上只保存失败的值,再用一个额外的变量保存在根节点比较的获胜者:
于是我们对每一层的更新只需要访问当前节点和其父节点就好了。
由于每次保存的是失败者,这个方案又被称为“败者树”。
- 小结 -
这篇没有贴具体的代码,没试过的同学,正好可以用 LeetCode 23 来练手(传送门)。
照例小结一下:
* 笔试/面试题的区分度很重要;
* 归并排序是基础,bottom-up 和 top-down 都要熟;
* 多路归并可以用分治和堆来解决,时间复杂度最优;
* 通过败者树可以进一步优化堆的解法。
喜欢本文的小伙伴,别忘了分享给你的小伙伴,感谢~
---
推荐阅读:
* 程序员面试指北:面试官视角
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* (译) C程序员该知道的内存知识 (1)
在遥远的2009年,那时候“呵呵”还没有奇怪的意思,我笑呵呵地去参加了鹅厂的实习招聘。
面试被安排在面试官下榻酒店的房间里,校门口的**王朝大酒店,可能一晚上能顶我一个月生活费那种。
过程聊得应该还可以,不过大部分细节都忘了,只记得最后那道代码题,一张纸,一支笔。
题面很简单:写一个 C 函数,合并两个有序数组。
- “最好能通用一点”,面试官补充说。
- “可以用 C++ 模板吗?”
- “最好还是用 C 。”
好多年以后,当我开始面试别人了,发现这道题确实很好用。
- 解 -
学过归并排序的同学应该都会觉得这个题目并不难,只不过是其中的一次归并环节。
其基本思路是,用两个指针,分别从数组的第一个元素开始,依次比较,每次找到最小的元素存入新数组,然后将指针移动到下一位。
需要注意的是当一个数组被取完以后,还得处理另一个数组的剩余元素。
而所谓“通用”,是指数组的元素可以是任意类型,因此需要把数组元素的大小、用于比较的函数也作为参数传进去。
大概就是要实现这样的一个函数:
typedef int cmpfunc(void *x, void *y);
void* merge(void *A, int m, void *B, int n, int size, cmpfunc f);
void* merge(void *A, int m, void *B, int n, int size, cmpfunc f);
其中 m、n 分别表示 A、B 这两个数组的长度,size 表示数组元素的大小。
具体实现的 C 代码比较琐碎,就不在这里贴出来了,感兴趣的同学可以自己试着写一下。
- WHY -
我在上一家公司,通常用这道题当笔试的压轴题,但不限制语言,以及去掉了对“通用”的要求。
为什么选它呢?
首先,它很容易理解,不会产生歧义,不需要额外解释。
其次,在纸面上编码(至少是脱离IDE),程序员在编码前得想清楚;涂改较多也说明一些问题。
最重要的是,它有很好的区分度,因为真的有很多程序猿没认真学过归并排序。
但至少每个人都能想到将两个数组合并,然后进行排序。
有些特别直接的小伙伴,就用了 PHP 自带的 sort 函数,后来我们不得不加个说明:“避免使用库函数”。
至于排序算法,有人写冒泡,也有人写快排;快排的实现,又可以考察是不是在数组里做原地划分(大部分是拆到两个数组里再合并)。
并且,我们在题面上特地对 有序 两个字加粗、加下划线,引导候选人使用最优解法。
如果候选人最终仍然实现了排序解法,在面试中还可以再提示,是否能用上“有序”这个条件,进一步提高性能。
这样层层递进,能够较好地帮助我们判断候选人的编码能力。
不过机关算尽,还是遇到了比较挫败的case,比如一个候选人就反问:系统自带的函数效率最高啊,为什么要自己实现?
- 字节 -
到了字节跳动后,我发现这道题有点不够用了,撑死只能算 LeetCode Easy,对于有勇气来面试字节的候选人,通常都不在话下。
为了把它升级到 Medium,我想到了两个改动:
1. 两个不够,m个来凑
2. 数组太简单,得换成链表
然后一看,诶,这 tm 不就是 LeetCode 23 原题吗?
话说回来,这题就变成了:请把 m 个有序链表合并成一个新的有序链表;平均每个链表有 k 个节点。
- 解² -
不用说,所有候选人都能想到每次遍历所有链表、找到最小值加入新的链表。
对于选择这个思路的候选人,接下来的问题是:
Q1:这个方案的时间复杂度是多少呢?
有不少候选人回答 O(m * k),大概是觉得两个链表合并是 O(2 * k),m个链表合并自然是 O(m * k) 了。
实际上,使用这种思路,每次找到最小值需要逐个比较 m 个链表,这个操作需要执行 m * k 次(节点总数),因此总的时间复杂度应该是 O(m^2 * k)。
Q2:还有优化空间吗?
有些候选人确实想不到更优的解法,但只要能按这思路完成 bug free 的代码,综合面试中的其他表现,也可以通过我们的考查(详见 程序员面试指北:面试官视角)。
毕竟 LeetCode 23 原题可是 Hard 级别。
- 分治 -
对分治算法比较熟悉的候选人会想到,可以先两两合并,得到的 m / 2 个链表再两两合并,循环这个过程,直到只剩下一个链表。
然后又回到 Q1:这个方案的时间复杂度是多少呢?
这回答就千奇百怪了,O(m * log(k)),O(k * log(m)), O(m * k * log(k))……
这个计算其实不难:
* 第一轮需要 m/2 次两两合并,每次两两合并是 2k 次比较,合计 m*k
* 第二轮需要 m/4 次两两合并,每次两两合并是 4k 次比较(每个链表平均长度变成2k了),合计还是 m*k
* ……
* 对 m 个元素做二分,总共需要 log(m) 轮
所以合理的答案应该是 O(m * k * log(m))。
具体实现又可以分成上下两部分。
下层应该实现一个合并俩链表的逻辑,比较常见的错误是没能正确处理链表的头结点(比如直接当成尾节点用,或者忘记初始化,以及 C++ 小伙伴用了 new 以后常常忘了 delete),还有前面说到的,一个链表摘空了后,需要处理另一个链表剩下的节点。
上层的实现其实和归并排序长得一毛一样,可以 bottom-up,也可以 top-down。bottom-up 的实现常见的错误是没处理好落单的那一个,而 top-down 则需要注意递归的终止条件。
另外有点意外的是,不少 Java 小伙伴被 List 这个 Interface 荼毒还挺深,在编码的时候顺手就用 List.get(i) ,完全不考虑这个 API 的开销。
- 最小堆 -
对常见数据结构比较熟悉的候选人则会提出使用最小堆,这样可以将每次查找最小值的时间复杂度降为 log(m) ,于是总的时间复杂度也可以降为 O(m * k * log(m))。
既然提到了堆,那就可以顺便问一下,最小元素从堆顶被摘掉以后,如何调整堆?
于是那些只知道可以用最小堆、不知道如何实现堆的候选人就暴露出来了。
不过也不打紧,大部分语言的库里都实现了 PriorityQueue 这个数据结构,让候选人直接用语言提供的版本来编码就好。
具体的代码主要有两个坑,一是循环中要注意对摘空链表的处理,二是对链表头结点的处理(前面提到了)。
- 没完 -
面试到上面的程度就足够了,不然 45 分钟实在是不够用。
但其实还有些值得思考的问题没讲完。
比如说,这两种算法,平均时间复杂度都是 O(m * k * log(m)),到底哪一个更好呢?
分治算法的优势是,两两合并时,当一个链表为空,可以直接将另一个链表的剩余节点串起来,相比于堆算法可以节约一些时间。
另一方面,对于这样一个经典的多路归并问题,实际使用场景可能是要合并外存上的多个排好序的文件,这时候堆算法可以节约磁盘IO(只需要一次遍历),相比于分治算法就有了压倒性的优势。
所以具体还是要看场景。
再比如,在这个场景下,堆并不是最高效的数据结构。
实际上,堆算法只是多路归并的早期实现,由于每一层的调整都需要两次比较(先取出两个子节点的较小者,然后再和当前节点比较),其效率还有优化空间。
(堆的调整)
如果我们将用于比较的节点作为叶子节点构建一棵完全二叉树,从叶子节点往上只保存获胜的节点:
这样每一层只需要和其兄弟节点做比较即可。这就是所谓“胜者树”,说起来还是空间换时间的套路(多一倍的节点数)。
还没完 —— 这个方案对每一层的更新仍然需要访问3个节点(自己、兄弟节点,父节点),换个思路,如果我们在路径上只保存失败的值,再用一个额外的变量保存在根节点比较的获胜者:
于是我们对每一层的更新只需要访问当前节点和其父节点就好了。
由于每次保存的是失败者,这个方案又被称为“败者树”。
- 小结 -
这篇没有贴具体的代码,没试过的同学,正好可以用 LeetCode 23 来练手(传送门)。
照例小结一下:
* 笔试/面试题的区分度很重要;
* 归并排序是基础,bottom-up 和 top-down 都要熟;
* 多路归并可以用分治和堆来解决,时间复杂度最优;
* 通过败者树可以进一步优化堆的解法。
喜欢本文的小伙伴,别忘了分享给你的小伙伴,感谢~
---
推荐阅读:
* 程序员面试指北:面试官视角
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* (译) C程序员该知道的内存知识 (1)
Jul
26
在上一篇《踩坑记:Go服务灵异panic》里我们提到了 mutex 和 atomic ,感觉意犹未尽,这篇再展开一点。
- 锁 -
前面我们讲过好多面试题了,其实锁也很适合用来做套题,比如可以这么切入:sync.Mutex 是悲观锁还是乐观锁?
有些候选人不了解它们的区别,回答靠猜,缺乏逻辑以至于我都记不住。虽然这只是一个概念性的知识,但是却很能反映候选人的工作经验,比如读多写少的并发场景,乐观锁可以减少加锁冲突带来的开销。
当然大多数人还是知道的,于是可以继续问:你有了解过锁是怎么实现的吗?
很多人都能想到:维护一个初值为 false 的变量,当一个线程加锁成功的时候,将它置为 true ,就可以保证其他线程无法再获取。
逻辑是没错,但真正的问题是:两个线程同时检查,发现它的值都是 false ,如何保证只有一个线程会把它置为 true 呢?
这样的提问让不少候选人意识到,自己其实并没有真正理解锁。
- 原子操作 -
学过操作系统原理的同学应该都知道,靠的是原子操作(atomic operations)。
那么具体是什么原子操作呢?
在早期只有单核的系统上只需要关闭中断就可以保证原子地执行一段代码 —— 但这通常效率较低,且还存在些问题,例如因为 bug 或恶意代码导致未能正常开启中断,系统就会锁死;而对于多核系统,通常也无法做到在多个核心上同时关闭中断。
因此 CPU 引入了硬件支持的原子操作,例如 x86 体系下的 LOCK 信号(在汇编里给指令加上 LOCK 前缀),通过锁定总线,禁止其他 CPU 对内存的操作来保证原子性。但这样的锁粒度太粗,其他无关的内存操作也会被阻塞,大幅降低系统性能,而随着核数逐渐增加该问题会愈发显著 —— 要知道现在连家用 CPU 都有16核了。
因此 Intel 在 Pentium 486 开始引入了用于保证缓存一致性的 MESI 协议,通过锁定对应的 cache line,使得其他 core 无法修改指定内存,从而实现了原子操作(缓存锁)。这里不展开了,对细节感兴趣的话,详见参考资料《原子操作是如何实现的》[1]。
- CAS -
针对前面问的“什么原子操作”,大多数候选人的回答是 CAS (compare-and-swap),也有人会提到 test-and-set 等其他操作,原理都一样,就是用前述机制实现的。
下面这段 Go 代码展示了 CAS 的逻辑:
请注意:这不是 CAS 的实现,如前所述,真正的 CAS 是硬件级别的指令支持的,最早出现在 1970 年 IBM 的 System 370 上,在 x86 上则是 80486 开始新增的 CMPXCHG 这个指令。
注:在多核系统上 CMPXCHG 也需要使用 LOCK 前缀,但是如果对应内存已经在 cache 里,就不用发出 LOCK 信号锁定总线,而是使用缓存锁。
由于不用锁定总线,这样的原子操作指令不会限制其余 CPU core 操作非锁定内存,因此对系统整体的吞吐量影响不大。这一点对于当今核数越来越多的系统来说尤为重要。
由于原子操作指令仍然需要在 CPU 之间传递消息用于对 cache line 的锁定,其性能仍有一定损耗,具体来说大概就相当于一个未命中 cache 的 Load Memory 指令[2]。
基于 CAS 我们可以用实现很多实用的原子操作,例如原子加法:
看,这就是一个典型的使用乐观锁的实现了:先做加法,如果更新失败,就换个姿势再来一次。
注:Go 语言 atomic.AddInt32 的实现是直接使用汇编 LOCK XADDL 完成的,不是基于 CAS 和循环。
- 自旋锁 -
回到锁的问题上,基于 CAS 我们可以很容易实现一个锁:
这就是经典的自旋锁[3] —— 通过反复检测锁变量是否可用来完成加锁。在加锁过程中 CPU 是在忙等待,因此仅适用于阻塞较短时间的场合;其优势在于避免了线程切换的开销。
注:spinlock 是 Linux 内核中主要的两种锁之一(另一种是Mutex),感兴趣的同学可以去看看内核源码里的实现,具体位于 include/asm/spinlock.h (吐槽:内核源码真是难读)。
在 Go 版的实现里还要注意:如果 GOMAXPROCS 被设置成 1 (Go Runtime只会给用户代码分配一个系统线程),会导致上述代码陷入死循环,因此更完善的实现是:
通过将当前系统线程的使用权暂时归还给 Go Runtime(相当于其他语言的 yield),可以避免前述情况,但这也在一定程度上破坏了自旋锁的语义、使其变得更重了。
值得一提的是,研究人员发现,如果锁冲突比较频繁,在 CAS 失败时使用指数退避算法(Exponential Backoff)往往能得到更好的整体性能[2]。
- Mutex -
实际上 Go 语言没有提供自带的自旋锁实现,我们在代码中用得更多的是 Mutex 。
对比于 Spinlock 的忙等待,如果 Mutex 未获得锁,会释放对 CPU 的占用。
上一篇 我们在说 Mutex 性能不够好的时候有提到“lock does not scale with the number of the processors”,这里的 lock 指的是用 CPU LOCK信号实现的锁;而通过阅读 Mutex 的源码,我发现实际上 Mutex 底层也是使用原子操作来实现的,所以前述说法不太准确。
Mutex 针对实际应用场景做了许多优化,是一个从轻量级锁逐渐升级到重量级锁的过程,从而平衡了各种场景下的需求和性能。
具体来说有这么几项:
* fastpath:在简单场景下直接使用 CAS 加锁和解锁,缩短执行路径
* spin:当自旋有意义时(多核、GOMAXPROCS > 1 、尝试不超过4次),优先使用自旋
* 饥饿 & 公平:当等待超过 1ms 时,进入饥饿模式,新竞争者需要排队
注:对具体实现感兴趣的同学,可以结合参考资料《golang中的锁源码实现:Mutex》[5] 阅读源码。
这里提到的“公平”,指的是先到先得,这意味着每一个竞争者都需要进入等待队列,而这意味着CPU控制权的切换和对应的开销;而非公平锁,指的是在进入等待队列之前先尝试加锁,如果加锁成功,可以减少排队从而提高性能,但代价是队列中的竞争者可能会处于“饥饿”状态。
- sync -
除了 Mutex,Go 在 sync 包里还实现了很多用于解决并发问题的工具,这里简单介绍下:
· RWMutex
读写锁,通过将资源的访问者分成读者和写者,允许多个读者同时访问资源,从而提高共享资源的并发度。适用于读远多于写的场景。
· WaitGroup
用于对 goroutine 的并发控制,在主 goroutine 里使用 Add(n) 指定并发数量,并使用 Wait() 等待所有任务都调用 Done() (配合 defer 使用效果更佳)。
· Pool
对象池,用于缓存后续会用到的对象,从而缓解 gc 压力。例如 fmt 包用它来缓存输出缓冲区。
· Once
“单例”:once.Do(f) 保证 f 只会被执行一次。f 被执行后,通过原子操作保证了性能。
· Cond
条件同步:当条件不满足时(通常是等待一个任务执行完成),goroutine调用 Wait() 等待通知;另一个 goroutine 完成任务后,调用 Signal() 或 Broadcast() 通知在等待的 goroutine。
· Map
支持并发的 map:通过 Load、Store、LoadOrStore、Delete、Range 等方法提供线程安全的 map 数据结构。
· atomic
提供 Add、CAS、Load、Store、Swap 等对基础数据类型的原子操作,以及 atomic.Value 来支持其他类型的 Load、Store 原子操作。
- 收尾 -
哎呀,这篇写得干巴巴的,连一个表情包都没有(忍住)。
最后照例小结一下:
* 锁是基于原子操作实现的,而原子操作是需要硬件支持的
* 基于 MESI 协议的缓存锁可以提高锁的性能
* 比较常用的原子操作是 CAS
* Go 没有官方自旋锁,我们通常用 sync.Mutex
* sync 包里还有很多解决并发问题的实用工具
下次考虑结合一些具体案例来讲讲,可能更有意思一点儿~
---
推荐阅读:
* 程序员面试指北:面试官视角
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* (译) C程序员该知道的内存知识 (1)
---
参考资料:
1. 原子操作是如何实现的?
2. Compare-and-swap
3. 自旋锁
4. 聊聊CPU的LOCK指令
5. golang中的锁源码实现:Mutex
- 锁 -
前面我们讲过好多面试题了,其实锁也很适合用来做套题,比如可以这么切入:sync.Mutex 是悲观锁还是乐观锁?
有些候选人不了解它们的区别,回答靠猜,缺乏逻辑以至于我都记不住。虽然这只是一个概念性的知识,但是却很能反映候选人的工作经验,比如读多写少的并发场景,乐观锁可以减少加锁冲突带来的开销。
当然大多数人还是知道的,于是可以继续问:你有了解过锁是怎么实现的吗?
很多人都能想到:维护一个初值为 false 的变量,当一个线程加锁成功的时候,将它置为 true ,就可以保证其他线程无法再获取。
逻辑是没错,但真正的问题是:两个线程同时检查,发现它的值都是 false ,如何保证只有一个线程会把它置为 true 呢?
这样的提问让不少候选人意识到,自己其实并没有真正理解锁。
- 原子操作 -
学过操作系统原理的同学应该都知道,靠的是原子操作(atomic operations)。
那么具体是什么原子操作呢?
在早期只有单核的系统上只需要关闭中断就可以保证原子地执行一段代码 —— 但这通常效率较低,且还存在些问题,例如因为 bug 或恶意代码导致未能正常开启中断,系统就会锁死;而对于多核系统,通常也无法做到在多个核心上同时关闭中断。
因此 CPU 引入了硬件支持的原子操作,例如 x86 体系下的 LOCK 信号(在汇编里给指令加上 LOCK 前缀),通过锁定总线,禁止其他 CPU 对内存的操作来保证原子性。但这样的锁粒度太粗,其他无关的内存操作也会被阻塞,大幅降低系统性能,而随着核数逐渐增加该问题会愈发显著 —— 要知道现在连家用 CPU 都有16核了。
因此 Intel 在 Pentium 486 开始引入了用于保证缓存一致性的 MESI 协议,通过锁定对应的 cache line,使得其他 core 无法修改指定内存,从而实现了原子操作(缓存锁)。这里不展开了,对细节感兴趣的话,详见参考资料《原子操作是如何实现的》[1]。
- CAS -
针对前面问的“什么原子操作”,大多数候选人的回答是 CAS (compare-and-swap),也有人会提到 test-and-set 等其他操作,原理都一样,就是用前述机制实现的。
下面这段 Go 代码展示了 CAS 的逻辑:
func CompareAndSwap(p *int, oldValue int, newValue int) bool {
if *p != oldValue {
return false
}
*p = newValue
return true
}
if *p != oldValue {
return false
}
*p = newValue
return true
}
请注意:这不是 CAS 的实现,如前所述,真正的 CAS 是硬件级别的指令支持的,最早出现在 1970 年 IBM 的 System 370 上,在 x86 上则是 80486 开始新增的 CMPXCHG 这个指令。
注:在多核系统上 CMPXCHG 也需要使用 LOCK 前缀,但是如果对应内存已经在 cache 里,就不用发出 LOCK 信号锁定总线,而是使用缓存锁。
由于不用锁定总线,这样的原子操作指令不会限制其余 CPU core 操作非锁定内存,因此对系统整体的吞吐量影响不大。这一点对于当今核数越来越多的系统来说尤为重要。
由于原子操作指令仍然需要在 CPU 之间传递消息用于对 cache line 的锁定,其性能仍有一定损耗,具体来说大概就相当于一个未命中 cache 的 Load Memory 指令[2]。
基于 CAS 我们可以用实现很多实用的原子操作,例如原子加法:
func atomicAdd(p *int32, incr int32) int32 {
for {
oldValue := *p
newValue := oldValue + incr
if atomic.CompareAndSwapInt32(p, oldValue, newValue) {
return newValue
}
}
}
for {
oldValue := *p
newValue := oldValue + incr
if atomic.CompareAndSwapInt32(p, oldValue, newValue) {
return newValue
}
}
}
看,这就是一个典型的使用乐观锁的实现了:先做加法,如果更新失败,就换个姿势再来一次。
注:Go 语言 atomic.AddInt32 的实现是直接使用汇编 LOCK XADDL 完成的,不是基于 CAS 和循环。
- 自旋锁 -
回到锁的问题上,基于 CAS 我们可以很容易实现一个锁:
type spinLock int32
func (p *spinLock) Lock() {
for !atomic.CompareAndSwapInt32((*int32)(p), 0, 1) {
}
}
func (p *spinLock) Unlock() {
atomic.StoreInt32((*int32)(p), 0)
}
func (p *spinLock) Lock() {
for !atomic.CompareAndSwapInt32((*int32)(p), 0, 1) {
}
}
func (p *spinLock) Unlock() {
atomic.StoreInt32((*int32)(p), 0)
}
这就是经典的自旋锁[3] —— 通过反复检测锁变量是否可用来完成加锁。在加锁过程中 CPU 是在忙等待,因此仅适用于阻塞较短时间的场合;其优势在于避免了线程切换的开销。
注:spinlock 是 Linux 内核中主要的两种锁之一(另一种是Mutex),感兴趣的同学可以去看看内核源码里的实现,具体位于 include/asm/spinlock.h (吐槽:内核源码真是难读)。
在 Go 版的实现里还要注意:如果 GOMAXPROCS 被设置成 1 (Go Runtime只会给用户代码分配一个系统线程),会导致上述代码陷入死循环,因此更完善的实现是:
func (p *spinLock) Lock() {
for !atomic.CompareAndSwapInt32((*int32)(p), 0, 1) {
runtime.Gosched()
}
}
for !atomic.CompareAndSwapInt32((*int32)(p), 0, 1) {
runtime.Gosched()
}
}
通过将当前系统线程的使用权暂时归还给 Go Runtime(相当于其他语言的 yield),可以避免前述情况,但这也在一定程度上破坏了自旋锁的语义、使其变得更重了。
值得一提的是,研究人员发现,如果锁冲突比较频繁,在 CAS 失败时使用指数退避算法(Exponential Backoff)往往能得到更好的整体性能[2]。
- Mutex -
实际上 Go 语言没有提供自带的自旋锁实现,我们在代码中用得更多的是 Mutex 。
对比于 Spinlock 的忙等待,如果 Mutex 未获得锁,会释放对 CPU 的占用。
上一篇 我们在说 Mutex 性能不够好的时候有提到“lock does not scale with the number of the processors”,这里的 lock 指的是用 CPU LOCK信号实现的锁;而通过阅读 Mutex 的源码,我发现实际上 Mutex 底层也是使用原子操作来实现的,所以前述说法不太准确。
Mutex 针对实际应用场景做了许多优化,是一个从轻量级锁逐渐升级到重量级锁的过程,从而平衡了各种场景下的需求和性能。
具体来说有这么几项:
* fastpath:在简单场景下直接使用 CAS 加锁和解锁,缩短执行路径
* spin:当自旋有意义时(多核、GOMAXPROCS > 1 、尝试不超过4次),优先使用自旋
* 饥饿 & 公平:当等待超过 1ms 时,进入饥饿模式,新竞争者需要排队
注:对具体实现感兴趣的同学,可以结合参考资料《golang中的锁源码实现:Mutex》[5] 阅读源码。
这里提到的“公平”,指的是先到先得,这意味着每一个竞争者都需要进入等待队列,而这意味着CPU控制权的切换和对应的开销;而非公平锁,指的是在进入等待队列之前先尝试加锁,如果加锁成功,可以减少排队从而提高性能,但代价是队列中的竞争者可能会处于“饥饿”状态。
- sync -
除了 Mutex,Go 在 sync 包里还实现了很多用于解决并发问题的工具,这里简单介绍下:
· RWMutex
读写锁,通过将资源的访问者分成读者和写者,允许多个读者同时访问资源,从而提高共享资源的并发度。适用于读远多于写的场景。
· WaitGroup
用于对 goroutine 的并发控制,在主 goroutine 里使用 Add(n) 指定并发数量,并使用 Wait() 等待所有任务都调用 Done() (配合 defer 使用效果更佳)。
· Pool
对象池,用于缓存后续会用到的对象,从而缓解 gc 压力。例如 fmt 包用它来缓存输出缓冲区。
· Once
“单例”:once.Do(f) 保证 f 只会被执行一次。f 被执行后,通过原子操作保证了性能。
· Cond
条件同步:当条件不满足时(通常是等待一个任务执行完成),goroutine调用 Wait() 等待通知;另一个 goroutine 完成任务后,调用 Signal() 或 Broadcast() 通知在等待的 goroutine。
· Map
支持并发的 map:通过 Load、Store、LoadOrStore、Delete、Range 等方法提供线程安全的 map 数据结构。
· atomic
提供 Add、CAS、Load、Store、Swap 等对基础数据类型的原子操作,以及 atomic.Value 来支持其他类型的 Load、Store 原子操作。
- 收尾 -
哎呀,这篇写得干巴巴的,连一个表情包都没有(忍住)。
最后照例小结一下:
* 锁是基于原子操作实现的,而原子操作是需要硬件支持的
* 基于 MESI 协议的缓存锁可以提高锁的性能
* 比较常用的原子操作是 CAS
* Go 没有官方自旋锁,我们通常用 sync.Mutex
* sync 包里还有很多解决并发问题的实用工具
下次考虑结合一些具体案例来讲讲,可能更有意思一点儿~
---
推荐阅读:
* 程序员面试指北:面试官视角
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* (译) C程序员该知道的内存知识 (1)
---
参考资料:
1. 原子操作是如何实现的?
2. Compare-and-swap
3. 自旋锁
4. 聊聊CPU的LOCK指令
5. golang中的锁源码实现:Mutex
Jul
18
这个坑比较新鲜,周一刚填完,还冒着冷气。
- 1 -
在字节跳动,我们线上服务的所有 log 都通过统一的日志库采集到流式日志服务、落地 ES 集群,配上字节云超(sang)级(xin)强(bing)大(kuang)的监控能力,每一条 panic log 都可以触发一个打给值班同学的电话。
所以我们常常不选电话,只选飞书 ↓↓↓
但毕竟是 panic,大部分 case 都会迅速被就地正法,除了少数排查费劲、又不对线上产生太大影响的,比如这一个:
注:为了方便阅读,略有简化。
你看,它可以被 recover 兜住(不会把服务搞挂),而且出现频率很低(每天几次甚至没有),考虑到在每天数百亿请求中的占比,解决它的 ROI 实在太低,所以就耽搁了一段时间且不用担心背 P0 的锅。
- 2 -
其实之前 S 同学和我都关注过这个 panic ,从上面的 Error log 可以看到,错误发生在调用 json.Marshal 的时候,调用方的代码大概长这样:
注:实际map有更多key/value,这里略作简化。
看这代码,第一反应是:这**也能 panic ?
找到对应的 json 库源码(encode.go第880行,对应下面第5行):
—— 也只是从string里逐个读取字符,看着并没什么猫饼。
由于 panic 发生在官方 json 库里,不适合修改并部署到全量机器;引入第三方 json 库又涉及很多依赖问题,所以当时没再跟进。
直到最近 panic 频率逐渐升高, H 和 L 同学实在看不下去了。
- 3 -
L 同学的思路是,既然这个 panic 能被 recover 兜住,那为什么不看看 panic 时这个 map 里装了什么呢?
于是代码就变成了这样:
然后 panic 顺利转移到了 log.Warnf 这一行[doge]
- 4 -
不管怎么说成功地转移了问题,只要把 log.Warnf 这一行注释掉……
作为一个追求极致的 ByteDancer,L 同学抵制住了诱惑并尝试了新的思路,既然从 panic log 看到是跪在了一个 string 上,那至少先看看是哪一个string:
改起来倒是很简单,赶在这个需要上班的 周日下午发了车,晚上就捉到了一个case。
通过线上 log,我们发现错误出现在 "step" 这个 key 上(log里有输出key、但没输出value),value 本应是 ctx.StepName 这个 string。
可是 string 这种看起来人畜无害的 immutable 的 type 为什么会导致 panic 呢?
- 5 -
通过走读代码得知,在遇到异常的时候,我们会往 ctx.StepName 写入这个异常点的名称,就像这样:
一边读一边写,有那么点并发的味道了。
考虑到我们为了降低媒体感知的超时率,将整个广告的召回流程包装成一个带时间限制的任务:
因此在一个请求流程中,确实可能会出现并发读写 ctx.StepName 这个 string object 的情况。
但如何实锤是这儿挖的坑呢?
- 6 -
在线上服务中直接验证这一点不太容易,但是 H 同学做了一个简单的 POC,大概像这样:
代码一跑起来就有点味道了:
虽然没看到 panic,但是确实看到了点奇怪的东西(严正声明:不是故意要吐槽GO的GC)。
再用 go 的 race detector 瞅瞅:
这下可算是实锤了。
- 7 -
那么为什么 string 的并发读写会出现这种现象呢?
这就得从 string 底层的数据结构说起了。在 go 的 reflect 包里有一个 type StringHeader ,对应的就是 string 在 go runtime的表示:
可以看到, string 由一个指针(指向字符串实际内容)和一个长度组成。
比如说我们可以这么玩弄 StringHeader:
对于这样一个 struct ,golang 无法保证原子性地完成赋值,因此可能会出现goroutine 1 刚修改完指针(Data)、还没来得及修改长度(Len),goroutine 2 就读取了这个string 的情况。
因此我们看到了 "WHAT" 这个输出 —— 这就是将 s 从 "F*CK" 改成 "WHAT THE" 时,Data 改了、Len 还没来得及改的情况(仍然等于4)。
至于 "F*CKGOGC" 则正好相反,而且显然是出现了越界,只不过越界访问的地址仍然在进程可访问的地址空间里。
- 8 -
既然问题定位到了,解决起来就很简单了。
最直接的方法是使用 sync.Mutex:
但 Mutex 性能不够好(lock does not scale with the number of the processors),对于这种读写冲突概率很小的场景,性能更好的方案是将 ctx.StepName 类型改成 atomic.Value,然后
注:也可以改成 *string 然后使用 atomic.StorePointer
实际上,Golang 不保证任何单独的操作是原子性的,除非使用 atomic 包里提供的原语或加锁。
- 9 -
大结局:周一下午 H 同学提交了修复代码并完成发布,这个 panic 就再没出现了。
总结一下:
* string 没有看起来那么人畜无害
* 并发的坑可以找 -race 帮帮忙
* 记得使用 mutex 或 atomic
最后留下一个小问题供思考:
这说了半天并没有完全复现 panic,不过文中已经给了足够多的工具,你能想到怎么办吗?
推荐阅读:
* 程序员面试指北:面试官视角
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* [译] C程序员该知道的内存知识 (1)
- 1 -
在字节跳动,我们线上服务的所有 log 都通过统一的日志库采集到流式日志服务、落地 ES 集群,配上字节云超(sang)级(xin)强(bing)大(kuang)的监控能力,每一条 panic log 都可以触发一个打给值班同学的电话。
所以我们常常不选电话,只选飞书 ↓↓↓
但毕竟是 panic,大部分 case 都会迅速被就地正法,除了少数排查费劲、又不对线上产生太大影响的,比如这一个:
Error: invalid memory address or nil pointer dereference
Traceback:
goroutine 68532877 [running]:
...
src/encoding/json/encode.go:880 +0x59
encoding/json.stringEncoder(0xcb9fead550, ...)
...
src/encoding/json/encode.go:298 +0xa5
encoding/json.Marshal(0x1ecb9a0, ...)
...
/path/to/util.SendData(0xca813cd300)
Traceback:
goroutine 68532877 [running]:
...
src/encoding/json/encode.go:880 +0x59
encoding/json.stringEncoder(0xcb9fead550, ...)
...
src/encoding/json/encode.go:298 +0xa5
encoding/json.Marshal(0x1ecb9a0, ...)
...
/path/to/util.SendData(0xca813cd300)
注:为了方便阅读,略有简化。
你看,它可以被 recover 兜住(不会把服务搞挂),而且出现频率很低(每天几次甚至没有),考虑到在每天数百亿请求中的占比,解决它的 ROI 实在太低,所以就耽搁了一段时间
- 2 -
其实之前 S 同学和我都关注过这个 panic ,从上面的 Error log 可以看到,错误发生在调用 json.Marshal 的时候,调用方的代码大概长这样:
func SendData(...) {
data := map[string]interface{} {
"code": ctx.ErrorCode,
"message": ctx.Message,
"step": ctx.StepName,
}
msg, err := json.Marshal(data)
...
}
data := map[string]interface{} {
"code": ctx.ErrorCode,
"message": ctx.Message,
"step": ctx.StepName,
}
msg, err := json.Marshal(data)
...
}
注:实际map有更多key/value,这里略作简化。
看这代码,第一反应是:这**也能 panic ?
找到对应的 json 库源码(encode.go第880行,对应下面第5行):
func (e *encodeState) string(s string, escapeHTML bool) {
e.WriteByte('"')
start := 0
for i := 0; i < len(s); {
if b := s[i]; b < utf8.RuneSelf {
...
e.WriteByte('"')
start := 0
for i := 0; i < len(s); {
if b := s[i]; b < utf8.RuneSelf {
...
—— 也只是从string里逐个读取字符,看着并没什么猫饼。
由于 panic 发生在官方 json 库里,不适合修改并部署到全量机器;引入第三方 json 库又涉及很多依赖问题,所以当时没再跟进。
直到最近 panic 频率逐渐升高, H 和 L 同学实在看不下去了。
- 3 -
L 同学的思路是,既然这个 panic 能被 recover 兜住,那为什么不看看 panic 时这个 map 里装了什么呢?
于是代码就变成了这样:
defer func() {
if p := recover(); p != nil {
log.Warnf("Error: %v, data: %v", p, data)
}
}()
data := map[string]...
if p := recover(); p != nil {
log.Warnf("Error: %v, data: %v", p, data)
}
}()
data := map[string]...
然后 panic 顺利转移到了 log.Warnf 这一行[doge]
- 4 -
不管怎么说成功地转移了问题,只要把 log.Warnf 这一行注释掉……
作为一个追求极致的 ByteDancer,L 同学抵制住了诱惑并尝试了新的思路,既然从 panic log 看到是跪在了一个 string 上,那至少先看看是哪一个string:
data := make(map[string]interface{})
defer func() {
if p := recover(); p != nil {
for k, v := range data {
log.Warnf("CatchMe: k=%v", k)
log.Warnf("CatchMe: v=%v", v)
}
}
}()
...
defer func() {
if p := recover(); p != nil {
for k, v := range data {
log.Warnf("CatchMe: k=%v", k)
log.Warnf("CatchMe: v=%v", v)
}
}
}()
...
改起来倒是很简单,赶在这个
通过线上 log,我们发现错误出现在 "step" 这个 key 上(log里有输出key、但没输出value),value 本应是 ctx.StepName 这个 string。
可是 string 这种看起来人畜无害的 immutable 的 type 为什么会导致 panic 呢?
- 5 -
通过走读代码得知,在遇到异常的时候,我们会往 ctx.StepName 写入这个异常点的名称,就像这样:
const STEP_XX = "XX"
func XX(...) {
if err := process(); err != nil {
ctx.StepName = STEP_XX
}
}
func XX(...) {
if err := process(); err != nil {
ctx.StepName = STEP_XX
}
}
一边读一边写,有那么点并发的味道了。
考虑到我们为了降低媒体感知的超时率,将整个广告的召回流程包装成一个带时间限制的任务:
finished := make(chan struct{})
timer := time.NewTimer(duration)
go recall(finished)
select {
case <-finished:
sendResponse()
case <- timer.C:
sendTimeoutResponse()
}
timer := time.NewTimer(duration)
go recall(finished)
select {
case <-finished:
sendResponse()
case <- timer.C:
sendTimeoutResponse()
}
因此在一个请求流程中,确实可能会出现并发读写 ctx.StepName 这个 string object 的情况。
但如何实锤是这儿挖的坑呢?
- 6 -
在线上服务中直接验证这一点不太容易,但是 H 同学做了一个简单的 POC,大概像这样:
const (
FIRST = "WHAT THE"
SECOND = "F*CK"
)
func main() {
var s string
go func() {
i := 1
for {
i = 1 - i
if i == 0 {
s = FIRST
} else {
s = SECOND
}
time.Sleep(10)
}
}()
for {
fmt.Println(s)
time.Sleep(10)
}
}
FIRST = "WHAT THE"
SECOND = "F*CK"
)
func main() {
var s string
go func() {
i := 1
for {
i = 1 - i
if i == 0 {
s = FIRST
} else {
s = SECOND
}
time.Sleep(10)
}
}()
for {
fmt.Println(s)
time.Sleep(10)
}
}
代码一跑起来就有点味道了:
$ go run poc.go
WHAT THE
F*CK
...
WHAT
WHAT
WHAT
F*CKGOGC
...
WHAT THE
F*CK
...
WHAT
WHAT
WHAT
F*CKGOGC
...
虽然没看到 panic,但是确实看到了点奇怪的东西(严正声明:不是故意要吐槽GO的GC)。
再用 go 的 race detector 瞅瞅:
$ go run -race poc.go >/dev/null
==================
WARNING: DATA RACE
Write at 0x00c00011c1e0 by goroutine 7:
main.main.func1()
poc.go:19 +0x66(赋值那行)
Previous read at 0x00c00011c1e0 by main goroutine:
main.main()
poc.go:28 +0x9d(println那行)
==================
WARNING: DATA RACE
Write at 0x00c00011c1e0 by goroutine 7:
main.main.func1()
poc.go:19 +0x66(赋值那行)
Previous read at 0x00c00011c1e0 by main goroutine:
main.main()
poc.go:28 +0x9d(println那行)
这下可算是实锤了。
- 7 -
那么为什么 string 的并发读写会出现这种现象呢?
这就得从 string 底层的数据结构说起了。在 go 的 reflect 包里有一个 type StringHeader ,对应的就是 string 在 go runtime的表示:
type StringHeader struct {
Data uintptr
Len int
}
Data uintptr
Len int
}
可以看到, string 由一个指针(指向字符串实际内容)和一个长度组成。
比如说我们可以这么玩弄 StringHeader:
s := "hello"
p := *(*reflect.StringHeader)(unsafe.Pointer(&s))
fmt.Println(p.Len)
p := *(*reflect.StringHeader)(unsafe.Pointer(&s))
fmt.Println(p.Len)
对于这样一个 struct ,golang 无法保证原子性地完成赋值,因此可能会出现goroutine 1 刚修改完指针(Data)、还没来得及修改长度(Len),goroutine 2 就读取了这个string 的情况。
因此我们看到了 "WHAT" 这个输出 —— 这就是将 s 从 "F*CK" 改成 "WHAT THE" 时,Data 改了、Len 还没来得及改的情况(仍然等于4)。
至于 "F*CKGOGC" 则正好相反,而且显然是出现了越界,只不过越界访问的地址仍然在进程可访问的地址空间里。
- 8 -
既然问题定位到了,解决起来就很简单了。
最直接的方法是使用 sync.Mutex:
func (ctx *Context) SetStep(step string) {
ctx.Mutex.Lock()
defer ctx.Mutex.Unlock()
ctx.StepName = Step
}
ctx.Mutex.Lock()
defer ctx.Mutex.Unlock()
ctx.StepName = Step
}
但 Mutex 性能不够好(lock does not scale with the number of the processors),对于这种读写冲突概率很小的场景,性能更好的方案是将 ctx.StepName 类型改成 atomic.Value,然后
ctx.StepName.Store(step)
注:也可以改成 *string 然后使用 atomic.StorePointer
实际上,Golang 不保证任何单独的操作是原子性的,除非使用 atomic 包里提供的原语或加锁。
- 9 -
大结局:周一下午 H 同学提交了修复代码并完成发布,这个 panic 就再没出现了。
总结一下:
* string 没有看起来那么人畜无害
* 并发的坑可以找 -race 帮帮忙
* 记得使用 mutex 或 atomic
最后留下一个小问题供思考:
这说了半天并没有完全复现 panic,不过文中已经给了足够多的工具,你能想到怎么办吗?
推荐阅读:
* 程序员面试指北:面试官视角
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* [译] C程序员该知道的内存知识 (1)