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)
Jul
11
Linux里养僵尸是怎么回事呢?Linux相信大家都很熟悉,但是Linux里养僵尸是怎么回事呢,下面就让小编带大家一起了解吧。
# - 1 -
上一篇挖了个 SIGHUP 的坑,这篇试着填一下。
之前在《程序员面试指北:面试官视角》里面说过,在结构化面试中,我们会从各个方向去考查候选人,其中之一是操作系统。
上篇介绍了一套题,我还有另一套,一般这么开场:
在终端下启动一个命令,如果在命令结束前关掉终端,它还能正常运行吗?
# - 2 -
这其实是一个很常见的case,但凡 Linux 或者 Mac 用得多一点,都会遇到。
在我还是一个穷酸学生的2009年,每个月都需要支付 20 元巨款(当时能买3根鸭脖),通过一个禁止分享网络的认证客户端接入校园网。
为了共建和谐宿舍节省网费 ,我历经千辛万苦,交叉编译开源的Linux认证客户端,集成到固件里,并刷到了我的 NETGEAR 路由器上。
然后山水 BBS 的 Linux 版主把我的帖子置顶了 11 年。可见他有多痛恨禁止共享网络
这么一回忆,感觉自己的共享经济思维真是前卫,当时怎么就没想到去搞共享单车呢?
扯远了,在捣腾的过程中,我就踩了这么个坑:当我ssh到路由器上、刚启动认证时,能够正常联网;但是退出ssh后一会,网就断了。
经过一番捣腾后发现,只要一退出ssh,认证程序就凉了,而不是继续在后台保持和认证服务器的通信。
# - 3 -
所以前面那个问题,我以为大部分候选人应该会回答“否”,但没想到竟然还有不少人回答“是”。
其实回答“是”也没什么错,因为确实也有些命令不会随着终端关闭而结束。
问题是当我追问当时执行的是什么命令时,候选人往往又说不出个所以然来。
(借学长的表情一用)
然后我就感到很强的挫败感:这不按剧本来,没法问了啊……只好换题。
当然大部分候选人确实被坑过,于是我可以接着问:
如果确实需要在后台继续执行命令怎么办呢?
有些人只记得要在后面加个 & ;但也有不少人知道前面还得加个 nohup,就像这样:
注:其实我更喜欢 screen(或 tmux),偶尔也用 setsid 。
然后就可以放心地关闭终端开始放羊 了。
但我的套题还没结束:为什么加上 nohup 就可以让进程在后台继续运行呢?
(这表情熟悉吗)
# - 4 -
铺垫了这么多,总算是可以开始填坑了。
答案其实很好找,man nohup 就能看到:
nohup工具在启动命令的同时会将 SIGHUP 信号设置为忽略。
而关于 SIGHUP,Wikipedia原文是这样介绍的:
对于 POSIX 兼容的平台(如Unix、Linux、BSD、Mac),当进程所在的控制终端关闭时,系统会给进程发送 SIGHUP 信号(Signal Hang Up,挂断信号)。
为什么叫 SIGHUP 呢?(严正申明:这一问不在套题里[doge])
我们知道,在上古时代,捉 bug 就已经是码农的必备技能(更准确地说是 moth)。
(我总觉得这个图是假的)
到了远古时代,他们不再需要去机房,通过基于 RS-232 协议的串行线路连接到大型机的终端上,就可以开始收福报。
收完福报,程序员通知自己的猫(modem)挂断(Hang Up)连接;大型机的 OS 检测到连接断开,就会给进程发送信号 —— 所以这信号被称为 SIGHUP 。
这果然是毫无卵用的知识啊。
# - 5 -
很多同学在操作系统的课程上学习了“进程间的通信方式有信号、管道、消息队列、共享内存……”,但是对信号到底是个什么东西,并没有现实的概念。
课堂教学的理论和实践往往是割裂的,在此特别推荐《Unix环境高级编程》(简称APUE)。
APUE在 1.9 - 信号 中写到:信号是通知进程已发生某种条件的一种技术。
而在 Linux/Unix 下,进程对信号的处理有三种选择:
* 按系统默认方式处理
* 提供一个回调函数
* 或忽略该信号(有些信号例外,不允许被忽略)
以 SIGHUP 信号为例,系统默认处理方式就是结束进程。
当然终端下打开的第一个进程通常都是shell(例如bash)。shell会给 SIGHUP 信号注册一个回调函数,用于给该 shell 下所有的子进程发送 SIGHUP 信号,然后再主动退出。
对于求生欲很强的程序(例如nohup),可以主动选择忽略该信号。
有一些进程本来就被设计成在后台运行,不需要控制终端,因此它们将 SIGHUP 挪作它用,一个常见的用法就是重新读取配置文件(例如Apache、Nginx),上篇提到的 logrotate 正是利用了这一点。
终于填完了坑。
# - 6 -
说了这么多都还是纸上谈兵,实操中如何主动忽略 SIGHUP 呢?
实际上也很简单,使用 Linux 的 signal 系统调用即可:
不妨试试看,编译运行起来,即使关闭终端,它也会在后台继续运行。
signal 也可以用于指定回调函数(或重置为系统默认处理方式),这里就不展开了,感兴趣的同学可以参考 APUE 里的代码,以及阅读 signal 的manual。
使用回调函数还需要注意一个坑:
由于回调函数可能在任意时刻被触发,因此要避免调用不可重入的函数(典型如printf)。常见的做法是 set 一个 flag,然后在程序的主循环中检测该 flag,再按需执行相应任务。
# - 7 -
SIGHUP 只是常见的一个信号,在 Linux 下,信号还有大量其他的场景和应用。
当你按下 Ctrl + C ,就是给进程发送了一个 SIGINT 信号。
当你执行 kill -TERM $PID,就是给进程发送了一个 SIGTERM 信号。可能和你期望有出入的是,SIGTERM 是可以被进程忽略的。所以有时候你得用 SIGKILL (kill -9) 。
你还可以使用可自定义的 SIGUSR1、SIGUSR2、SIGURG 来实现一些功能,比如《踩坑记#2:Go服务锁死》中提到 Golang 在其 goroutine 调度中使用了 SIGURG 。
# - 8 -
这次就不总结了,最后再用一个和信号有关的 case 收尾。
Linux 内核会为每一个进程分配一个 task_struct 结构体,用于保存进程的相关信息。
在进程死亡后,系统会发送一个 SIGCHLD 信号给它的父进程。
正确的父进程实现,通常应当使用 wait 系统调用来给子进程收尸 —— 父进程往往需要知道子进程结束这个事件,而且可能还需要得知其退出原因(exit code)。
然后内核才会将对应的 task_struct 释放。
如果父进程没有收尸,task_struct 里的 state 会一直保持为 EXIT_ZOMBIE,这时在 ps 或 top 等命令里,就可以看到该进程的状态为 Z ,而且无法被 kill 。
这就是所谓的僵尸进程,这时候你找九叔都没用。
(大半夜找这图还挺渗人的)
所以Linux里养僵尸,其实就是子进程死了父进程不收尸,大家可能会很惊讶Linux里怎么会养僵尸呢?但事实就是这样,小编也感到非常惊讶。
这就是关于Linux里养僵尸的事情了,大家有什么想法呢,欢迎在评论区告诉小编一起讨论哦!
---
推荐阅读
* 程序员面试指北:面试官视角
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* [译] C程序员该知道的内存知识 (1)
# - 1 -
上一篇挖了个 SIGHUP 的坑,这篇试着填一下。
之前在《程序员面试指北:面试官视角》里面说过,在结构化面试中,我们会从各个方向去考查候选人,其中之一是操作系统。
上篇介绍了一套题,我还有另一套,一般这么开场:
在终端下启动一个命令,如果在命令结束前关掉终端,它还能正常运行吗?
# - 2 -
这其实是一个很常见的case,但凡 Linux 或者 Mac 用得多一点,都会遇到。
在我还是一个穷酸学生的2009年,每个月都需要支付 20 元巨款(当时能买3根鸭脖),通过一个禁止分享网络的认证客户端接入校园网。
为了共建和谐宿舍
然后山水 BBS 的 Linux 版主把我的帖子置顶了 11 年。
这么一回忆,感觉自己的共享经济思维真是前卫,当时怎么就没想到去搞共享单车呢?
扯远了,在捣腾的过程中,我就踩了这么个坑:当我ssh到路由器上、刚启动认证时,能够正常联网;但是退出ssh后一会,网就断了。
经过一番捣腾后发现,只要一退出ssh,认证程序就凉了,而不是继续在后台保持和认证服务器的通信。
# - 3 -
所以前面那个问题,我以为大部分候选人应该会回答“否”,但没想到竟然还有不少人回答“是”。
其实回答“是”也没什么错,因为确实也有些命令不会随着终端关闭而结束。
问题是当我追问当时执行的是什么命令时,候选人往往又说不出个所以然来。
(借学长的表情一用)
然后我就感到很强的挫败感:这不按剧本来,没法问了啊……只好换题。
当然大部分候选人确实被坑过,于是我可以接着问:
如果确实需要在后台继续执行命令怎么办呢?
有些人只记得要在后面加个 & ;但也有不少人知道前面还得加个 nohup,就像这样:
$ nohup python process.py &
[1] 1806824
nohup: ignoring input and appending output to 'nohup.out'
[1] 1806824
nohup: ignoring input and appending output to 'nohup.out'
注:其实我更喜欢 screen(或 tmux),偶尔也用 setsid 。
然后就可以放心地关闭终端
但我的套题还没结束:为什么加上 nohup 就可以让进程在后台继续运行呢?
(这表情熟悉吗)
# - 4 -
铺垫了这么多,总算是可以开始填坑了。
答案其实很好找,man nohup 就能看到:
引用
The nohup utility invokes utility with its arguments and at this time sets the signal SIGHUP to be ignored
nohup工具在启动命令的同时会将 SIGHUP 信号设置为忽略。
而关于 SIGHUP,Wikipedia原文是这样介绍的:
引用
On POSIX-compliant platforms, SIGHUP ("signal hang up") is a signal sent to a process when its controlling terminal is closed.
wikipedia.org/wiki/SIGHUP
wikipedia.org/wiki/SIGHUP
对于 POSIX 兼容的平台(如Unix、Linux、BSD、Mac),当进程所在的控制终端关闭时,系统会给进程发送 SIGHUP 信号(Signal Hang Up,挂断信号)。
为什么叫 SIGHUP 呢?(严正申明:这一问不在套题里[doge])
我们知道,在上古时代,捉 bug 就已经是码农的必备技能(更准确地说是 moth)。
(我总觉得这个图是假的)
到了远古时代,他们不再需要去机房,通过基于 RS-232 协议的串行线路连接到大型机的终端上,就可以开始收福报。
收完福报,程序员通知自己的猫(modem)挂断(Hang Up)连接;大型机的 OS 检测到连接断开,就会给进程发送信号 —— 所以这信号被称为 SIGHUP 。
这果然是毫无卵用的知识啊。
# - 5 -
很多同学在操作系统的课程上学习了“进程间的通信方式有信号、管道、消息队列、共享内存……”,但是对信号到底是个什么东西,并没有现实的概念。
课堂教学的理论和实践往往是割裂的,在此特别推荐《Unix环境高级编程》(简称APUE)。
APUE在 1.9 - 信号 中写到:信号是通知进程已发生某种条件的一种技术。
而在 Linux/Unix 下,进程对信号的处理有三种选择:
* 按系统默认方式处理
* 提供一个回调函数
* 或忽略该信号(有些信号例外,不允许被忽略)
以 SIGHUP 信号为例,系统默认处理方式就是结束进程。
当然终端下打开的第一个进程通常都是shell(例如bash)。shell会给 SIGHUP 信号注册一个回调函数,用于给该 shell 下所有的子进程发送 SIGHUP 信号,然后再主动退出。
对于求生欲很强的程序(例如nohup),可以主动选择忽略该信号。
有一些进程本来就被设计成在后台运行,不需要控制终端,因此它们将 SIGHUP 挪作它用,一个常见的用法就是重新读取配置文件(例如Apache、Nginx),上篇提到的 logrotate 正是利用了这一点。
终于填完了坑。
# - 6 -
说了这么多都还是纸上谈兵,实操中如何主动忽略 SIGHUP 呢?
实际上也很简单,使用 Linux 的 signal 系统调用即可:
#include <signal.h>
#include <unistd.h>
int main() {
signal(SIGHUP, SIG_IGN);
sleep(1000);
return 0;
}
#include <unistd.h>
int main() {
signal(SIGHUP, SIG_IGN);
sleep(1000);
return 0;
}
不妨试试看,编译运行起来,即使关闭终端,它也会在后台继续运行。
signal 也可以用于指定回调函数(或重置为系统默认处理方式),这里就不展开了,感兴趣的同学可以参考 APUE 里的代码,以及阅读 signal 的manual。
使用回调函数还需要注意一个坑:
由于回调函数可能在任意时刻被触发,因此要避免调用不可重入的函数(典型如printf)。常见的做法是 set 一个 flag,然后在程序的主循环中检测该 flag,再按需执行相应任务。
# - 7 -
SIGHUP 只是常见的一个信号,在 Linux 下,信号还有大量其他的场景和应用。
当你按下 Ctrl + C ,就是给进程发送了一个 SIGINT 信号。
当你执行 kill -TERM $PID,就是给进程发送了一个 SIGTERM 信号。可能和你期望有出入的是,SIGTERM 是可以被进程忽略的。所以有时候你得用 SIGKILL (kill -9) 。
你还可以使用可自定义的 SIGUSR1、SIGUSR2、SIGURG 来实现一些功能,比如《踩坑记#2:Go服务锁死》中提到 Golang 在其 goroutine 调度中使用了 SIGURG 。
# - 8 -
这次就不总结了,最后再用一个和信号有关的 case 收尾。
Linux 内核会为每一个进程分配一个 task_struct 结构体,用于保存进程的相关信息。
在进程死亡后,系统会发送一个 SIGCHLD 信号给它的父进程。
正确的父进程实现,通常应当使用 wait 系统调用来给子进程收尸 —— 父进程往往需要知道子进程结束这个事件,而且可能还需要得知其退出原因(exit code)。
然后内核才会将对应的 task_struct 释放。
如果父进程没有收尸,task_struct 里的 state 会一直保持为 EXIT_ZOMBIE,这时在 ps 或 top 等命令里,就可以看到该进程的状态为 Z ,而且无法被 kill 。
这就是所谓的僵尸进程,这时候你找九叔都没用。
(大半夜找这图还挺渗人的)
所以Linux里养僵尸,其实就是子进程死了父进程不收尸,大家可能会很惊讶Linux里怎么会养僵尸呢?但事实就是这样,小编也感到非常惊讶。
这就是关于Linux里养僵尸的事情了,大家有什么想法呢,欢迎在评论区告诉小编一起讨论哦!
---
推荐阅读
* 程序员面试指北:面试官视角
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* [译] C程序员该知道的内存知识 (1)
Jul
6
今年开始写公众号,最初是想通过写点东西辅助招聘(真的很缺人)。几篇以后发现效果并不好,但“写作”这件事情却是断断续续坚持下来了。
这背后有些自觉有意思的思考,希望通过本文做一个梳理和总结。
写作
如果写技术博客也算写作的话,那我已经写作十几年了。
刚开始啥也不懂,一个简单的知识点(比如ASCII码表)也写一篇,又或者觍着脸从别的博客转载过来,自己还挺开心 —— 看,又多了一篇。
最近几年我意识到,写作其实就是自己和自己对话的过程。
在这个过程中,你需要把之前输入的知识串到自己的逻辑链上,从而可以很好地把这些知识内化,变成自己的东西。
在写作时我还常常会发现,很多知识点原来并没有吃透。为了把它们写出来,需要进一步查找资料、学习、夯实。
比如之前发的这篇《踩坑记:go服务内存暴涨》,其中 MADV_ADVISE 和 MADV_FREE 这些知识背后的细节也算是现学现卖的,通过写作把它吃透,加入了自己的知识库。
文章被分享后,常会收到很多建设性的讨论,在这个过程中不仅学习到了他人的知识,还通过和他人的对话进一步强化或修正了自己的逻辑链。
越写收获越多、收获越多越愿意写,这样就形成了一个正反馈循环。
视角
在这个过程中,我不断提示自己要注意写作的视角 —— 要充分认识到知识的诅咒。我把它简单理解成“你知道的东西都会觉得简单、不知道的就会觉得难”。
比如快排这个算法,大一的时候我花了很久才想明白它是如何在数组里做原地划分的;而一旦想明白了,就会觉得其实很简单,无非就是两个指针加一个临时变量来回捣腾。
有鉴于此,我们往往是很难完全以新手的视角来看待问题;相信你也注意到,现实中很多高手在实战中很厉害,但是却不擅长教导别人(因此知识的诅咒也称为“专家盲点”)。
这对我的启示是,不要因为觉得某个知识太简单就不写了,实际上越简单的知识能帮到的人越多(所以知识付费大卖的课程往往都是比较简单的课程)。这一点在团队的文档建设中尤其重要。
可读性
另一方面我也在努力提高行文的可读性。
首先,在写作时我会尽量避免思维跳跃,如前所述,有很多自己觉得显而易见的知识点,对于新手往往就是不可逾越的鸿沟。
为什么我们在高数课上容易犯困?因为一旦有个知识点没有衔接上,后面的逻辑都变得无法理解;而对于持续输入的无法理解的信息,大脑只能罢工了。
因此我宁愿行文时啰嗦一些,多补充相关细节。
其次,如果不是严肃话题,我会想办法提高内容的故事性,并选用轻松活跃的措辞。
人类大脑保留的动物性有两个体现,一是喜欢听故事而不是冗长乏味的逻辑推演,二是喜欢即时反馈(给我一袋瓜子,我能磕一个下午)。
这使得今日头条和抖音这样提供即时满足感的app能够如此大行其道,虽然一鸣同学对ByteDancer的要求都是延迟满足。
具体到写作时可以使用一些小技巧,比如在文章中有意识地加入一些段子、表情包、埋包袱,都可以提高阅读体验,从而提升阅读完成率。
为了制造效果,甚至可以考虑植入一些无伤大雅的错误,比如端午节的月饼和中秋节的粽子。
此外,还要尽量避免大段的文字,以避免对阅读者造成视觉上的压迫感。
这也就是这句话要换行、以及隔一段起标题的原因。
分歧
禅宗有云:不立文字。
文字是思维的投影,在这个过程中不可避免地,会丢失一些无法用文字描述的信息。
而阅读则是这个投影的逆过程,因各自阅历不同,每个读者又会融入自己的独特见解。
因此读者很可能无法准确了解作者想表达的含义。
另一方面,即使读者能够理解,往往也会有自己的不同观点。
因此存在分歧属于正常情况,但随之而来的反馈却往往大相径庭。
建设性的反馈
对于这些分歧,有些人会给出建设性的反馈,通过友好而理性的表述,将自己的疑惑或者反对意见表达出来,并且给出自己的理由。
我喜欢这种反馈 —— 有时对方正确,有时我正确,又或者都有考虑不周的地方,但只要双方都怀着最大的诚意,通过充分的沟通和探讨,也就是建设性的讨论,最终可以达成一致或互相理解。
特别需要注意的是,形而上学的问题并不总能达成一致,讨论双方都应当有这样的预期。
这种问题的特点是,各自的观点或结论不具有可证伪性,比如世界的本质是什么,人生的意义是什么,是否应该凡事都相信科学等等。
还有一些情况,比如“如何面试候选人”,现实中不具备可靠的方法来比较各自的观点的优劣,因此也不具备完全达成一致的基础,讨论中就应注意求同存异,而不是升级为对抗性的说服。
破坏性的反馈
另外有些人倾向于给出破坏性的反馈:一旦感觉和自己理念或逻辑有冲突,就上头开喷。
比如“废话太多,一句话掰开十句话说谁都会”,不考虑作者的用意,只凭自己喜好逞口舌之快;又或者“这个需求就是有病”,不考虑问题的背景和受到的现实约束。
他们试图从自己对文字的理解出发,用居高临下的视角来评论以体现自己的正确,因此也放弃了可能对自己有收获的部分。
对这种反馈,最好的办法是不去理会,因为不太可能形成建设性的讨论。
还有些稍好,在反馈中会给出详细的意见,但是语气很重,或通篇透露着只有自己掌握了真理的自信。
理性一点看,这种反馈也有所助益。但人毕竟是感性的,在这种情况下,出于被认可的需要和避免自己认知失调,人的本能是进一步的带着情绪的反驳,因此后续讨论很容易会陷入互相攻讦,最终使得双方进一步分道扬镳。
想要克服这种反驳中夹带的情绪,得通过不断的自省和训练来达成。
这一点也是我还需要改善的地方。今年文章尽管都只有两三千字,但我每次都花费几个小时反复推敲措辞,才形成自己满意的版本。
对于轻佻、不负责任的评论,难免引起情绪的波动;但这都是在这条路上成长必然会遇到的,也给我提供了自省和训练的机会,其实也是很有意思的体验。
收尾
所幸禅宗终究是随着文字流传了下来,这也让人对文字的力量更有信心。
最近发出的文章,收到了很多点赞、收藏和感谢,相信这些读者或多或少从中得到了自己想要的内容,这也是我持续写作的一个动力来源。
最后用一句话自勉:
唯一没有瑕疵的作家是那些从不写作的人。
这背后有些自觉有意思的思考,希望通过本文做一个梳理和总结。
写作
如果写技术博客也算写作的话,那我已经写作十几年了。
刚开始啥也不懂,一个简单的知识点(比如ASCII码表)也写一篇,又或者觍着脸从别的博客转载过来,自己还挺开心 —— 看,又多了一篇。
最近几年我意识到,写作其实就是自己和自己对话的过程。
在这个过程中,你需要把之前输入的知识串到自己的逻辑链上,从而可以很好地把这些知识内化,变成自己的东西。
在写作时我还常常会发现,很多知识点原来并没有吃透。为了把它们写出来,需要进一步查找资料、学习、夯实。
比如之前发的这篇《踩坑记:go服务内存暴涨》,其中 MADV_ADVISE 和 MADV_FREE 这些知识背后的细节也算是现学现卖的,通过写作把它吃透,加入了自己的知识库。
文章被分享后,常会收到很多建设性的讨论,在这个过程中不仅学习到了他人的知识,还通过和他人的对话进一步强化或修正了自己的逻辑链。
越写收获越多、收获越多越愿意写,这样就形成了一个正反馈循环。
视角
在这个过程中,我不断提示自己要注意写作的视角 —— 要充分认识到知识的诅咒。我把它简单理解成“你知道的东西都会觉得简单、不知道的就会觉得难”。
比如快排这个算法,大一的时候我花了很久才想明白它是如何在数组里做原地划分的;而一旦想明白了,就会觉得其实很简单,无非就是两个指针加一个临时变量来回捣腾。
有鉴于此,我们往往是很难完全以新手的视角来看待问题;相信你也注意到,现实中很多高手在实战中很厉害,但是却不擅长教导别人(因此知识的诅咒也称为“专家盲点”)。
这对我的启示是,不要因为觉得某个知识太简单就不写了,实际上越简单的知识能帮到的人越多(所以知识付费大卖的课程往往都是比较简单的课程)。这一点在团队的文档建设中尤其重要。
可读性
另一方面我也在努力提高行文的可读性。
首先,在写作时我会尽量避免思维跳跃,如前所述,有很多自己觉得显而易见的知识点,对于新手往往就是不可逾越的鸿沟。
为什么我们在高数课上容易犯困?因为一旦有个知识点没有衔接上,后面的逻辑都变得无法理解;而对于持续输入的无法理解的信息,大脑只能罢工了。
因此我宁愿行文时啰嗦一些,多补充相关细节。
其次,如果不是严肃话题,我会想办法提高内容的故事性,并选用轻松活跃的措辞。
人类大脑保留的动物性有两个体现,一是喜欢听故事而不是冗长乏味的逻辑推演,二是喜欢即时反馈(给我一袋瓜子,我能磕一个下午)。
这使得今日头条和抖音这样提供即时满足感的app能够如此大行其道,虽然一鸣同学对ByteDancer的要求都是延迟满足。
具体到写作时可以使用一些小技巧,比如在文章中有意识地加入一些段子、表情包、埋包袱,都可以提高阅读体验,从而提升阅读完成率。
为了制造效果,甚至可以考虑植入一些无伤大雅的错误,比如端午节的月饼和中秋节的粽子。
此外,还要尽量避免大段的文字,以避免对阅读者造成视觉上的压迫感。
这也就是这句话要换行、以及隔一段起标题的原因。
分歧
禅宗有云:不立文字。
文字是思维的投影,在这个过程中不可避免地,会丢失一些无法用文字描述的信息。
而阅读则是这个投影的逆过程,因各自阅历不同,每个读者又会融入自己的独特见解。
因此读者很可能无法准确了解作者想表达的含义。
另一方面,即使读者能够理解,往往也会有自己的不同观点。
因此存在分歧属于正常情况,但随之而来的反馈却往往大相径庭。
建设性的反馈
对于这些分歧,有些人会给出建设性的反馈,通过友好而理性的表述,将自己的疑惑或者反对意见表达出来,并且给出自己的理由。
我喜欢这种反馈 —— 有时对方正确,有时我正确,又或者都有考虑不周的地方,但只要双方都怀着最大的诚意,通过充分的沟通和探讨,也就是建设性的讨论,最终可以达成一致或互相理解。
特别需要注意的是,形而上学的问题并不总能达成一致,讨论双方都应当有这样的预期。
这种问题的特点是,各自的观点或结论不具有可证伪性,比如世界的本质是什么,人生的意义是什么,是否应该凡事都相信科学等等。
还有一些情况,比如“如何面试候选人”,现实中不具备可靠的方法来比较各自的观点的优劣,因此也不具备完全达成一致的基础,讨论中就应注意求同存异,而不是升级为对抗性的说服。
破坏性的反馈
另外有些人倾向于给出破坏性的反馈:一旦感觉和自己理念或逻辑有冲突,就上头开喷。
比如“废话太多,一句话掰开十句话说谁都会”,不考虑作者的用意,只凭自己喜好逞口舌之快;又或者“这个需求就是有病”,不考虑问题的背景和受到的现实约束。
他们试图从自己对文字的理解出发,用居高临下的视角来评论以体现自己的正确,因此也放弃了可能对自己有收获的部分。
对这种反馈,最好的办法是不去理会,因为不太可能形成建设性的讨论。
还有些稍好,在反馈中会给出详细的意见,但是语气很重,或通篇透露着只有自己掌握了真理的自信。
理性一点看,这种反馈也有所助益。但人毕竟是感性的,在这种情况下,出于被认可的需要和避免自己认知失调,人的本能是进一步的带着情绪的反驳,因此后续讨论很容易会陷入互相攻讦,最终使得双方进一步分道扬镳。
想要克服这种反驳中夹带的情绪,得通过不断的自省和训练来达成。
这一点也是我还需要改善的地方。今年文章尽管都只有两三千字,但我每次都花费几个小时反复推敲措辞,才形成自己满意的版本。
对于轻佻、不负责任的评论,难免引起情绪的波动;但这都是在这条路上成长必然会遇到的,也给我提供了自省和训练的机会,其实也是很有意思的体验。
收尾
所幸禅宗终究是随着文字流传了下来,这也让人对文字的力量更有信心。
最近发出的文章,收到了很多点赞、收藏和感谢,相信这些读者或多或少从中得到了自己想要的内容,这也是我持续写作的一个动力来源。
最后用一句话自勉:
唯一没有瑕疵的作家是那些从不写作的人。