简单,正确性和性能

2018-06-09

有同事说我老是讲哲学,不是在讲道理。好吧,那我认命了。今天再摆一个观点,认同则已,不认同我也不去解释道理了,当它是哲学吧。今天要讲的是简单,正确性,和性能的取舍。这是程序员很容易遇到的一个选择题,我选择简单。

我们知道网上有一个著名的观点,叫做 worse is better。这个观点,其实就是新泽西佬说 MIT 佬,也就是搞 unix 那帮人,为了简单,什么都不顾了,居然还做成了,真是世风日下!准确地说,其实就是代表了“简单”最后胜利了,worse is better 是一种吐糟,同时也是这个胜利的体现。

什么,连正确性都不要了? 反对者总会拿着这个点抨击,无限放大的说事。抛开剂量谈毒性,都是耍流氓。首先,并不是所有场景都会遇到简单和正确性的冲突,请不要断章取义成只要简单就故意放弃正确性。如果一个人无法完全把算法实现正确,那是水平不足,还没资格去谈简单和正确性的"哲学"问题。然后呢,我再换一个方式表述一下:一个程序能否做到完全没有任何 bug?理论上,可以。那实际上呢?不可能。好,这些有 bug 的程序你还用不用?操作系统你用不用?数据库你用不用?编译器你用不用?这里面都是有数不清的 bug 的。怎么样,还那么强调正确性么?又或者这个时候开始变得虚伪了?

有一个高大上的算法能屠龙,然而正确地实现它需要花一年。有另外一种简单做法能保证 99.999 的情况下正确,即使出错的情况下,影响也不算严重,关键是它只需要搞一个月。那当然选择快糙猛啦。否则搞一年搞到黄花菜都凉了,龙早就被对手搞死,没龙可屠了。快糙猛细分还是有好多场景的,不要拿反例说事。我说的是资源受限情况下理性的选择,那些瞎JB搞自己坑自己的的,根本不是在体现取舍,只是在体现他们 SB。

复杂是正确性的敌人。复杂的东西很难做正确。这里需要解释一下。简单的英文单词是 simple,simple 的对立面是 complex。这个单词跟 easy 不同,easy 的对面是 hard。simple 的东西往往是 easy 的。所以选择的 simple 的方案更可能保证 correct,尽量把事情往 simple 去做。simple made easy。选择简单,就是提前去避免复杂性,进而会获得正确性。没有矛盾,选择简单的人往往会把事情做对。假设你有一个同事,他追求的不是简单,他总是把代码写得很绕。有两种可能:一种是他水平低,另一种是他喜欢花式炫技。他觉得为了用上最高端的东西,宁愿复杂一些。反正不管是哪一种,最终都会留下一堆堆的 bug。等他走了,他写出来的那坨都是没法维护的。我见过把代码写得很绕的人,也有炫技的,但是, SB 居多;而我见过能把代码写得非常简单的人,全部是大师手笔。

有本书叫 unix 痛恨者手册,很推荐读一读。其中反对 unix 的重要的两股党派就是 lisp 党和 GUI 党。熟习我的人都知道,我其实是 lisp 派,但是客观公正地,个人觉得 unix 哲学真的好用:keep it simple, stupid。

我超喜欢简单的事物。

举个 raft 和 paxos 的例子。etcd 之后,很少有项目选择 paxos 而不是 raft 的。为啥呢?因为 raft 简单,好理解,更容易实现正确。paxos 复杂,实现都会有各自的细节修改,更难去证明这些细节的正确性。

再说说 tidb 和 cockroach。前些日子 tidb 的 star 数开始超越 cockroach 了。cockroach 是一个值得尊敬的竞争对手,我很佩服他们的文档做的很好,技术研究也很深入,代码执行力也强。cockroach 比我们起步早一年,而发展到现在我认为 tidb 的产品成熟度更高一点。重要的原因就是,cockroach 的技术选型更复杂。cockroach 整体都是 P2P 的,而 tidb 的调度系统是中心化的,中心化的调度肯定比去中心化做起来简单。tidb 选择的是全局时钟,而 cockroach 使用的 HLC,前者也是更简单的。tidb 更清晰地分层了,存储层在 tikv,而 cockroach 只用一个进程处理从 SQL 层到存储层。简单的价值就是项目可以快速推进,去客户的真实场景实战打磨。当一边还在思考一些酷炫的方案时,另一边已经身经百战了。这个例子也许不太恰当,但市场就是这样,并不是所有公司都像 google 家全球只需要一个数据库,中心化的调度去支撑 200 节点没有任何毛病。盲目只追求技术,而不是考虑简单实用,就是耍流氓,浪费人力物力。

一个很常见的错误,就是在简单和性能之间,选择了性能。简单的东西往往是更好优化的。因为简单,优化空间大。为了优化把代码搞得巨复杂,是不值得的。接下来我将解释这些。

第一个例子是Go 的 goroutine 和 channel,对比复杂的锁机制。channel 本身是要基于锁实现的,从这个纯粹的角度,它不可能比锁做得更高效。但是用过 goroutine 和 channel 之后,大家再也不会想回去基于锁做并发编程了。因为这是一个更简单的模型,我们可以在更高层次的抽象上,去解决实际问题,bug 更少,生活质量更高。一个写了十年 C艹 的程序员,去写一个高并发的复杂网络编程,可能不见得会比一个新手学了三个月的 Go 写出来的程序高效。Go 的性能不会比 C艹高,而且 runtime 也比较 heavy。原因是在于 Go 更简单的并发模型,更少的心智负担,这就是简单的力量。

再说说 code review 的时候的例子。有次我还在扣一些编程技巧上面的代码细节的时候,一个同事让我注意把关注放在大局,可谓一语惊醒梦中人。这里的代码细节对运行时的性能影响,不会超过 1%。相比起来,在优化器那边,生成更好的执行计划,可能至少是几倍几十倍的区别。另一次是我们有个比较复杂的对象,每次访问网络会构造。有个同事想加个 reset 方法,我则建议别重用,每次直接构造。因为相比网络访问的开销,这里一点对象分配是可以忽略。重用会涉及对象大量的状态调整,还涉及里面 goroutine 的释放和重分配,引起的代码复杂度太高了。过于关注性能,却没注意到真正会影响性能的点,很容易一叶障目。

再一个是之前我举过的例子,简单数据结构 PK 复杂数据结构。我曾经做一道题目是<圣经>中的各个单词出现的次数,手写一个二叉排序树,还是递归版本。跟标准库红黑树实现的对比,惊讶地发现看起来一点不优化的做法,居然比标准库要快!然后我看到了 rob pike 写的 5 个编程原则:

原则 3. 花哨的算法在 n 比较小时效率通常比较糟糕,而 n 通常是比较小的,并且这些算法有一个很大的常数。除非你确定 n在变大,否则不要用花哨的算法。(即便 n 不变大,也要先遵循第 2 个原则。)

原则 4. 相对于朴素的算法来说,花哨的算法更容易出现Bug,更难调试。尽量使用朴素的算法和数据结构。

心有戚戚焉。只是当我经历了很多代码之后,才明白这个简单的道理。尽量选择简单的做法,不要过早优化,除非发现它真的是热点才优化,把精力放在应该关注的点上。不然,可能赢得了战役,却输掉了战争。

只是理解起来需要一些悟性,或者说直觉。有些人天生会有非常敏锐的技术“直觉”,真的很让人嫉妒。另一些人要经历很多训练,写过很多的代码,积累更多的经验,才会获得那么一点点直觉。抛开其它因素不谈,只说技术,王垠就是那种直觉敏锐的一类。有时候我走了很多弯路,回头再看,却发现不过是他已经得到过的结论,只是当时无法理解。伟大的 unix 之父,伟大的 C 语言和 Go 语言的发明者,这些闪耀着光辉的大师,告诉我们该追求简单。如果无法理解大师走过的路,只会把历史的错误一遍一遍地重犯。当然还有更多的人,永远也不会理解......他们会觉得我在扯淡 ╮(╯▽╰)╭

选择性能最终得不到性能。选择正确性最终难以保证正确性。唯有选择简单的人,会做对的事情。

KISS编程感悟