上交了连照片名字都没有的临时工牌,上星期五我为期13周的谷歌实习告一段落。我给谷歌写了7500行C++代码,再加上还没有合并的1000行,我觉得我对自己贡献代码的数量和质量还是比较满意的。这次谷歌实习是我第一次在工业界的经历,感觉收获很多。

背景

我在谷歌的岗位是软件工程实习生(Software Engineering Intern),工作内容就是写代码。我所在的组做的是谷歌内部用的一个事件通知系统,之前有发表在 SOSP 2011 上面,叫做 Thialfi。我实际上是在往我们兄弟组旗下的一个项目交代码。我们的兄弟组,做的是谷歌内部用的一个自动分片、负载均衡的系统,之前有发表在 OSDI 2016 上面,叫做 Slicer。大家日常在用的谷歌产品的内部都有用到我们组以及我们兄弟组的系统。

感想

下面我就简单地逐条列出我在谷歌实习的时候感触比较深的地方,大部分是技术方面的。

  • 设计文档:在我开始写代码之前的两周,我按照要求先写一份设计文档(Design Docs),然后和组里的人一起讨论了一下文档。
    • 设计文档里面就是大概介绍一下打算新加的这个产品或者新功能的动机是什么,提供了什么功能,系统是怎么设计的,需要修改什么已有的接口,提供什么样新的接口,小细节上面有什么复杂的点,以及不同的替代方案的优缺点。
    • 我觉得写设计文档是一个非常好的事情,可以帮助你把一些细节问题想清楚,而不是到了写代码的时候再开始纠结。另外,写完了之后组里一起讨论可以进一步发现一些问题,也让大家对这个新功能有一致的理解。
    • 当然设计文档也有缺点。一个是在初期不可能把所有地方想清楚,后期的实现总是会与最初的设计文档产生出入。与实际实现脱钩的设计文档我觉得是失去了意义的,所以说后期还需要回过头来修改设计文档,这就带来了额外的维护成本。
    • 不过总体上来说我觉得设计文档还是非常有用的。在后期实现的时候,小细节问题记不清楚了,可以参照这个设计文档。而对于组外的人来说,阅读设计文档可以从大方向上面理解这个项目的设计。
  • 注释即文档:每个头文件的最前面都有很长篇幅的代码注释用来介绍这个类(Class)是用来做什么的,有的甚至还包含了示例。每个公有方法(Public Method)也都有详细的注释。大部分的库都没有专门的文档,文档就是写在头文件里面。配合代码搜索使用其实非常舒服。
  • 单一大代码仓库:几乎整个谷歌的代码都在同一个大的代码仓库里面,不同的项目放在不同的文件夹里面。
    • 我觉得这个带来的一个非常大的好处就是构建工具(Build System)可以很容易地找到文件和符号,体现在使用上面就是代码搜索和代码自动补全都非常准确。
    • 这个代码仓库是没有分支的,提交的代码都直接合并到一条线性的主线上。提交代码的流程其实非常简单粗暴。首先是创建一个修改列表(Change List, CL),每个CL都有一个基准的版本。提交CL的时候代码仓库会检查从基准版本之后是不是有别的CL修改过跟当前相同的文件,只要有就不让提交。这个时候需要把要提交的CL同步到主线上的最新版本,该解决的冲突解决掉,然后再一次尝试提交。
    • 没有分支带来的一个问题就是要确保每一次提交CL都没有大问题:最起码要能够编译通过,并且不能影响到已有的正常功能。不然的话,所有依赖你的项目的项目都会出问题。谷歌减轻这个问题的办法是:代码审查以及要求所有影响到的测试都要能通过。
    • 类比起来,我觉得可能和 Github Flow 很像。每开一个CL就是开一个新的分支(Branch)。在谷歌里面可以随时给CL做快照(snaphost),相当于 git commit。代码写好了要发给其他人审查,相当于发了一个合并请求(Pull Request)。如果有冲突的话,一样都是变基(Rebase)到最新的主干上。讨论好了,大家都开心了(LGTM + Approved),再加上测试跑过了,就可以合并到主线上,相当于这个合并请求合并了(Merged),然后这个分支就被删掉了。
  • 分布式文件系统:所有的代码以及生成的文件,包括目标文件(Object File)、生成代码(Generated Source Code)、可执行文件(Executable File),全都是放在一个分布式文件系统上面的。
    • 这就意味着所有的文件都有了一个权威版本(Canonical Version),也就是说你在办公室写了一会儿代码,在服务器上写了一会儿代码,在笔记本上写了一会儿代码,在家写了一会儿代码,实际上是同一份代码,不用纠结到底哪一份代码更新一点。
    • 进一步地,这就能让谷歌开发出各种周边的开发工具。比方说云IDE,浏览器上面的云IDE上改了一行代码,本地的编辑器马上就能看到修改;反过来,在办公室电脑上改了一行代码,笔记本打开云IDE马上也能看到。
    • 再进一步,编译和运行测试也可以在服务器上做,因为服务器上看到的代码和本地开发电脑上看到的代码也是同一份。所以说用云IDE也一样可以编译和运行。
  • 构建系统(Build System):谷歌已经开源了他们的构建系统 Bazel,我觉得这是一套非常先进的构建系统。
    • 在进入谷歌实习之前,我一直觉得 Bazel 太繁琐,每个目标(Target)都要写上每一个依赖的目标,而且目标的名字又很长,包含了完整的路径,而且要求所有的目标都在工作空间(Workspace)指定的文件夹树里面。在谷歌写代码的时候我就明白为什么这么设计了,就是因为谷歌用的是单一代码仓库,所有的代码都在一个文件夹树里面,所以很自然的每一个目标就带上了文件的路径。至于依赖,有工具可以自动填上,可惜还没开源。
    • 每一个目标可以指定可见性,可以指定什么目标是其他项目可以用的。比方说内部测试用的类就可以被藏起来,让别的项目就没法使用。这相当于提供了一种合同,公开的目标在维护的时候就要更加小心;而内部用的目标就可以想变就变,因为不会影响到别的项目。
    • 写漏了依赖的话,会提示找不到头文件,逼着你把依赖加上。我觉得这个设计非常巧妙,可以用来保证所有代码中用到的头文件对应的目标都在当前目标的依赖列表里面。
    • 为什么要维护依赖呢?我觉得是因为,一旦我们能保证依赖列表是准确的,那么每当一个文件发生了改变的时候,就很容易算出来影响到了哪些目标。这样一来,提交CL的时候就只需要跑受到影响的目标的测试就行了。
    • 构建系统背后是一个集群,用来跑编译和测试。编译和测试是非常容易并行的事情,所以说只需要无脑堆机器就能够让编译和测试变得非常快。谷歌里面随便写一个 Hello World 都要编译上千个文件,我所在的项目虽然本身只有几十个文件,但是要编译6万多个文件。但即使如此,完整地从头开始编译也只需要一两分钟时间。
    • 测试也可以并行跑。Bazel 里面可以指定每一个测试可以拆成几个并行的目标来跑(shard_count),这样如果某个测试(Test)有很多跑得很慢的测试用例(Testcase),就可以并行地跑他们,比起顺序地跑,能够大大地降低测试的总时间。这个尤其是在集成测试(Integration Test)的时候非常有用。搜索了一下,我发现 googletest 有这两个环境变量 GTEST_TOTAL_SHARDS / GTEST_SHARD_INDEX ,但是在文档里面没有写。我猜没有 Bazel 的话也可以直接操纵这两个环境变量来并行地跑测试,就是麻烦得多。
    • 测试不仅可以并行跑,还可以反复跑。这个在写多线程程序的时候非常有用,因为有时候不太确定程序里面会不会有一些并发错误(Concurrency Bug)。这个时候可以多跑几次测试来增强信心。在 Bazel 里面加上一个 --runs_per_test 就能够指定把每个测试重复跑若干次。因为在谷歌里面堆了好多服务器来跑编译和测试,所以说如果一个测试跑一次要一分钟,把这个测试跑上1000次也只要一分钟。所以我经常就把一些我觉得不是很确定的测试跑个一万次。
    • 另外,要跑 Sanitizers 也很方便,见下文。
  • 能后退的调试器(Reverse Debugger):云IDE上面怎么调试程序呢?先把程序的执行过程录下来再回放就行了(Record and Replay)。
    • 逆向调试器(Reverse Debugger)的思路其实很简单,就是把程序执行过程中的非确定性(Non-deterministic)指令记下来就好了,回放的时候遇到这些指令就把结果替换成当时记录下来的结果。但是实现起来肯定是非常复杂的,肯定有很多脏活(Dirty Work)。
    • 之前我有听说过 rr,但从来没用过 rr 或者其他类似的工具。这次在谷歌里面试了试谷歌内部实现的逆向调试器,也用它查出来了一两个我代码里面的错误,但是总体上感觉不是特别好用。一个是跑得特别慢;另一个是用户界面(UI)不是特别好用,尤其是在不同线程切换的时候,以及在同一行代码被执行了多次的情况下。
    • 不过总体上来说,看到逆向调试器还是很兴奋的,尤其是跟云IDE的结合,我觉得是个好思路。
  • 云IDE:前面已经提到了云IDE了,我觉得其实非常好用,代码补全(Auto-complete)又快又准。在我刚开始实习的时候,我们整个组都在用CLion,到后面所有人都换成了云IDE了,因为实在是受不了CLion动不动就卡住,甚至打字也会卡住。
  • 代码搜索:谷歌内部的代码搜索工具真的是太好用了,既可以搜索字符串和正则表达式,也可以搜索准确的符号;既可以跳到声明和定义,也可以找到所有用到的地方;而且还非常快,几乎是实时地出结果。要是 GitHub 也能加上这个功能就好了,但是注定很难,因为各个项目的构建系统不同,要准确地识别符号就很难。我之前用过几天 Sourcegraph 的浏览器插件,感觉就经常找不到符号。
  • 搭积木式地造分布式系统:谷歌里面实在是有太多优秀的分布式系统了,尤其是 Spanner。如果一个项目愿意假设 Spanner 非常可靠、性能很好,那么几乎就是免费地获得了高可靠性(High Availability)、地域复制(Geo-replication),而且还有了良定义(Well-defined)的全局时钟(Global Wall Clock)。至于一些其他的需求,经常也会有其他项目来把棘手的问题解决掉,比方说我们的组的项目就可以提供事件通知,可以用来保证缓存的新鲜度(Cache Freshness)。所以说在谷歌里面构建分布式系统就有点像搭积木一样。不过其实大家还是会考虑尽量避免故障的扩大(Escalation),也就是说 Spanner 出了问题不能让所有用到 Spanner 的系统都变得不可用。
  • C++:在谷歌里面写C++真的是非常舒服,因为有各种各样的库可以用,开源了的 Abseil 就是其中的一部分。内网的C++文档也很多,包括公开了的 Tips of the WeekStyle Guide,我觉得这些文档对提升C++水平、写出更容易维护的C++代码帮助非常大。
  • 静态线程安全分析(Static Thread Safety Analysis):这是我第一次知道原来 Clang 和 GCC 支持在编译期间检查互斥锁(Mutex)有没有被锁上或者释放。我之前在写代码的时候就觉得,如果把每个函数对互斥锁的要求写在函数开头的注释上面(比方说我写的这段代码),然后写代码的时候遵守这个约定就可以减少很多低级的错误,比如忘记上锁或者死锁。没想到竟然编译器直接支持在编译期间做这件事情。强烈推荐C++程序员都用上这个功能。
  • Sanitizers:在谷歌里面用到的另一个非常好用的工具就是 TSan / ASan / MSan,可以用来检查多线程相关以及内存相关的错误。而且配合 Blaze 使用非常简单,只要加上 --config=tsan 就行。网上也有人把这套配置搬到了 Bazel 上面
  • 单元测试:我觉得这次实习对我代码水平提升最大的就是如何编写可测试的代码(Testable Code)。
    • 我写完设计文档之后只花了3天时间,写了一千多行代码,大概就把我要实现的功能的一个简化版本写完了。然而写完了改完了编译错误之后我就傻了,因为我并不知道怎么运行我的代码,不知道怎么测试我写的代码,我唯一能想到的就是端到端测试(End-to-end Test)。
    • 后来在我的主管(Host)的耐心教导下,终于一步一步把这些代码重构(Refactor)了,花了一个多月的时间,代码也膨胀到了好几千行。
    • 我觉得总体上来说要让代码可以测试,就要尽可能地把代码拆成各个小的类,然后就可以测试一下每个类的公有方法,再测试一下类与类之间的交互。这样一来对代码的信心就会增强很多。
    • 依赖注入(Dependency Injection)我觉得是一个非常好用的技巧。写单元测试的时候我们希望尽量避免跟外部的系统进行交互(比方说网络、RPC、数据库、文件系统),或者是想要模拟这些外部系统返回错误结果。在这样的情况下,就可以首先规定一个接口(Interface),写一个抽象类(Abstract Class),然后让具体的实现继承这个抽象类,再为测试写一个假的对象(Fake Object)或者模拟对象(Mock Object)也继承这个抽象类。调用者手上就拿着一个抽象类的指针就行了。测试的时候用假的对象,就可以模拟各种错误的情况,也不用真正地对外界产生副作用。
    • 有意思的是,我们组和我们兄弟组极力反对模拟对象(Mock Object),力推假的对象(Fake Object)。我觉得是有一些道理的,模拟对象与实现的细节依赖太大了,有时候稍微改一下接口整个测试代码就要大改。还有一个原因是 gMock 声明式的语言不是很好用,而且一定要在测试的开头把所有的行为都指定好,这让测试代码看起来非常的零乱。
    • 代码覆盖(Code Coverage):在不知道要写什么单元测试的时候,看一眼代码覆盖可以找到那些没被测试到的情况。不过代码覆盖只能起到辅助作用,哪怕做到了100%覆盖也不一定代表程序是正确的,因为可能错误是在更高层次的地方。

也有一些非技术方面的感想。

  • 透明的人事管理结构:可以查到任何员工的工作信息,包括照片、工位、所在部门和项目、从直属主管(Manager)到CEO的人事管理链。
  • 吃自家狗粮(Dogfooding):谷歌内部用到了非常多自己家的产品。比方说文档、表格、幻灯片全都用的是 Google Docs;开会全都是用 Hangouts;Calendar 还可以预定会议室;许多工程师选择使用 Chromebook 工作。
    • 我有幸也拿到了一台 Pixelbook,感觉其实非常好。3000x2000分辨率的高分屏看起来很舒服,键盘软软的敲起来也很爽,整机非常的轻。因为有云IDE,所以正常的工作完全没有问题。也能够外接显示器。不过坏处就是系统经常崩溃自动重启;Pixelbook Pen 体验很差,比起 Apple Pencil 基本上就是不可用,而且除了一个特殊版本的 Google Keep 以外也没有应用支持。
  • 在谷歌实习的时候每天一杯卡布奇诺(Cappuccino),喝了一个月之后咖啡师就记住我的脸了,直接就知道我要喝什么了。现在回过头来喝星巴克的卡布奇诺,感觉是真的难喝(不过星巴克的非咖啡饮品还是不错的呀)。