标签 http 下的文章

BPF和Go:在Linux中内省的现代方式[译]

本文翻译自马可·凯瓦克(Marko Kevac)的《BPF and Go: Modern forms of introspection in Linux》(https://medium.com/bumble-tech/bpf-and-go-modern-forms-of-introspection-in-linux-6b9802682223)。

img{512x368}

每个人都有自己喜欢的关于魔法的书。对于一个人来说是托尔金,对于另一个人来说是普拉切特,对于第三个人来说,比如我,是马克斯-弗雷。今天我要给大家讲的是我最喜欢的IT魔法:BPF以及围绕它的现代基础设施。

BPF目前正处于普及的高峰期。这项技术正在飞速发展,深入到意想不到的地方,并且越来越容易被普通用户所接受。现在几乎每个流行的会议都有关于这个主题的演讲,早在8月份,我就应邀在俄罗斯GopherCon上(GopherCon Russia)做了这方面主题的演讲。

我在这方面有着很好的体验,所以我想和尽可能多的人分享一下。这篇文章将为你介绍为什么我们需要像BPF这样的东西,帮助你了解何时、如何使用它,以及它如何帮助作为工程师的你改善你正在进行的项目。我们还将看看它与Go的一些相关内容。

我真正希望的是,你看完这篇文章后,就像小孩子第一次读完《哈利波特》后的眼睛一样,开始发亮,并且希望你自己亲自去尝试一下这个新“玩具”。

一点点的背景

好吧,一个34岁的大胡子,眼神灼灼的告诉你这个魔法是什么?

我们生活在2020年。打开Twitter,你可以读到愤怒的技术人士的推文,他们都在说,今天编写的软件质量太糟糕了,都需要扔掉,我们需要重新开始。有些人甚至威胁要彻底离开这个行业,因为他们实在无法忍受所有东西都坏了,不方便又慢。

img{512x368}

他们可能是对的:如果不查阅千篇一律的评论,就无法确定原因。但有一点我绝对同意,那就是现代软件堆栈比以往任何时候都要复杂:我们有BIOS、EFI、操作系统、驱动程序、模块、库、网络交互、数据库、缓存、编排器(比如K8s)、Docker容器,最后还有我们自己的带有运行时和垃圾收集的软件。

一个真正的专业人士可能会花上几天时间来为你解释在浏览器中输入google.com之后会发生什么。

要了解你的系统里面发生了什么,是非常复杂的,尤其是在目前,事情出了问题,你正在损失金钱的情况下。正是因为这个问题,才出现了帮你搞清楚系统内部情况的企业。在大公司里,有整整一个部门的福尔摩斯式的侦探,他们只知道在哪里敲敲锤子,在哪里拧紧螺栓就能节省数百万美元。

我喜欢问人们如何在最短的时间内调试突发问题。大多数情况下,人们首先想到的方法是分析日志。但问题是,能获取的日志只局限于开发者放在系统中的日志,这是不灵活的。

第二种最流行的方法是研究度量数据。最流行的三个研究度量数据的系统都是用Go编写的。度量数据是非常有帮助的,然而,虽然它们确实可以让你看到症状,但它们并不总是能帮助你定义出问题的根本原因。

第三种是所谓的“可观察性”:你可以对系统的行为提出尽可能多的复杂问题,并获得这些问题的答案。由于问题可能非常复杂,所以答案可能需要最广泛的信息,而在问题被提出之前,我们并不知道这些信息是什么。而这意味着,可观察性绝对要求灵活性。

提供一个机会来改变”在飞行中”的日志级别呢?使用调试器,在程序运行时连接到程序,并在不中断程序工作的情况下做一些事情呢?了解哪些查询被发送到系统中,可视化慢速查询的来源,通过pprof看看什么在占用内存,并获得其随时间变化的曲线图?测量一个函数的延迟以及延迟对参数的依赖性呢?我想把所有这些方法都归入可观察性这个总称之下。这是一组实用工具、方法、知识和经验,它们结合在一起,给了我们机会,如果不能做到我们想做的所有事情,但至少可以在系统工作时,在系统中“现场”做很多事情。它相当于现代IT界的一把瑞士军刀。

img{512x368}

但我们如何才能实现这一点呢?市场上已经存在很多类似的工具:有简单的,有复杂的,有危险的并且也有缓慢的。但今天的文章是关于BPF的。

Linux内核是一个事件驱动的系统。实际上,在内核和系统中发生的所有事情,都可以被认为是一组事件。中断是一个事件;通过网络接收一个数据包是一个事件;将处理器的控制权转移到另一个进程是一个事件;运行一个函数是一个事件。

对,所以BPF是Linux内核的一个子系统,它让你有机会编写小程序,这些小程序将在内核响应事件时被运行。这些程序既可以让你知道系统中发生了什么,也可以用于控制系统。

现在让我们来了解一下具体的内容。

什么是eBPF?

BPF的第一个版本在1994年问世。你们中的一些人可能会在为tcpdump工具编写简单的规则时遇到过它,该工具用于查看或”嗅探”网络数据包。你可以为tcpdump设置过滤器,所以你不必查看所有的数据包–只查看你感兴趣的数据包。例如,”只查看tcp协议和80端口”。对于每一个经过的数据包,都会运行一个函数来决定你是否需要保存这个特定的数据包。可以有非常多的数据包,所以我们的函数必须要快。事实上,我们的tcpdump过滤器被转化成了BPF函数。下面是一个例子。

img{512x368}

最初的BPF代表了一个非常简单的虚拟机,有几个寄存器。但尽管如此,BPF还是大大加快了网络数据包的过滤速度。在当时,这是一个重大的进步。

img{512x368}

2014年,一位非常著名的内核黑客Alexei Starovoitov对BPF的功能进行了扩展。他增加了寄存器的数量和程序允许的大小,增加了JIT编译,并创建了一个用于检查程序是否安全的程序。然而,最令人印象深刻的是,新的BPF程序不仅能够在处理数据包时运行,而且能够响应其他内核事件,并在内核和用户空间之间来回传递信息。

这些变化为使用BPF的新方法提供了机会。一些过去需要通过编写复杂而危险的内核模块来实现的事情,现在可以相对简单地通过BPF来完成。为什么这么好呢?因为在编写模块的时候,任何错误往往都会导致恐慌(panic),这可不是Go语言中的恐慌(panic),而是内核恐慌。一旦发生,我们唯一能做的就是重启(操作系统)。

普通的Linux用户突然拥有了一种新的超能力:能够查看”引擎盖下的情况”–这在以前只有核心内核开发者才有,或者说根本就没有人能够做到。这个选项可以和为iOS或Android编写程序的能力相提并论:在旧手机上,这要么是不可能的,要么就是太复杂。

Alexei Starovoitov的新版本的BPF被称为eBPF(e代表扩展:extended)。但现在,它已经取代了所有旧版的BPF用法,并且已经变得非常流行,为了简单起见,它仍然被称为BPF。

BPF用在哪里?

好了,我们可以将BPF程序附加到哪些事件或触发器上呢,人们又是如何开始使用他们获得的新力量的呢?

目前,触发器主要有两组。

第一组是用于处理网络数据包和管理网络流量的。这是XDP、流量控制事件和其他几个。

以下情况需要这些事件:

  • 创建简单但非常有效的防火墙。Cloudflare和Facebook等公司使用BPF程序来过滤掉大量的寄生流量,并对抗最大规模的DDoS攻击。由于处理发生在数据包生命的最早阶段,直接在内核中进行(一个BPF程序有时甚至直接推送到网卡中进行处理),所以巨量的流量可以通过这种方式进行处理。这些事情过去都是在专门的网络硬件上完成的。

  • 创建更智能、更有针对性、但性能更强的防火墙–这些防火墙可以检查通过的流量是否符合公司规则,是否存在漏洞模式等。例如,Facebook在内部进行这种审计,而一些项目则对外销售这类产品。

  • 创建智能负载均衡器。最突出的例子是Cilium项目,它最常被用作K8s集群中的网格网络。Cilium对流量进行管理,平衡、重定向和分析。而所有这些都是在内核运行的小型BPF程序的帮助下完成的,以响应与网络数据包或套接字有关的这个或那个事件。

这是第一组与网络问题有关的触发器,并能够影响网络通信行为。第二组与更普遍的可观察性有关;这组中的程序大多时候无法影响任何事情,而只能”观察”。这是我比较感兴趣的。

在这组中,有如下触发器。

  • perf events – 与性能和perf Linux剖析器有关的事件:硬件处理器计数器,中断处理,拦截主要/次要内存异常等等。例如,我们可以设置一个处理程序,它将在每次内核需要从swap读取内存页时运行。例如,想象一下,一个显示当前使用swap的程序的工具。

  • tracepoints – 内核源代码中的静态(由开发者定义)位置,你可以通过附加到这些位置来提取静态信息(由开发者早先准备的信息)。在这种情况下,静态似乎是一件坏事,因为我说过,日志的缺点之一是它们只包含程序员最初放在那里的东西。从某种意义上说,这是对的,但tracepoints有三个重要的优点。

    • 有相当多的跟踪点散落在内核中最有趣的地方。
    • 当它们不 “开启 “时,它们不使用任何资源。
    • 它们是API的一部分,它们是稳定的,而且不会改变。这一点非常重要,因为我们将要提到的其他触发器缺乏稳定的API。

例如,想象一下,一个有关显示的工具程序(utility),由于某种原因,内核没有给它执行的时间。你坐着想知道为什么它这么慢,而pprof却没有什么有趣的东西可以显示。

  • USDT – 和tracepoints是一样的,但是是针对用户空间的程序。也就是说,作为一个程序员,你可以把这些位置添加到你的程序中。而且很多大规模的知名程序和编程语言已经采用了这些trace。比如:MySQL,或者PHP和Python等语言。通常它们的默认设置是”关闭”,如果要打开它们,你需要使用–enable-dtrace参数或类似的参数来重建解释器。是的,我们也可以在Go中注册这些类型的跟踪。你可能已经认出了参数名称中的单词DTrace。重点是,这种静态跟踪是由Solaris操作系统中诞生的同名系统所推广的。举个例子,想象一下,当一个新的线程被创建时,当一个GC或其他与特定语言或系统有关的东西被启动时,我们都能够觉察到。

这就是另一个层次的魔法开始的地方。

  • Ftrace触发器让我们可以选择在内核的任何功能开始时运行一个BPF程序。完全是动态的。这意味着内核会在你选择的任何内核函数开始执行之前,或者在所有内核函数开始执行之前,调用你的BPF函数–无论哪个,你都可以连接到所有的内核函数,并在输出时获得所有调用的可视化效果。

  • kprobes/uprobes给你提供的东西和ftrace几乎一样,但是你可以选择在内核和用户空间执行一个函数时附加到任何位置。如果在函数中间,有一个变量上的’if’,而你需要为这个变量建立一个值的直方图,那就不是问题了。

  • kretprobes/uretprobes–这里的一切类似于前面的触发器,但可以在内核函数或用户空间的函数返回时触发。这类触发器对于查看函数返回的内容,以及测量执行时间都很方便。例如,你可以查看’fork’系统调用返回的是哪个PID。

关于这一切,我重复一遍,最美妙的事情是,当我们的BPF程序响应这些触发器而被调用后,我们的BPF程序可以好好的 “观察”一下:读取函数的参数,记录时间,读取变量,读取全局变量,进行堆栈跟踪,为以后保存一些东西,将数据发送到用户空间进行处理,和/或从用户空间获取数据或一些其他控制命令进行过滤。太棒了!

我不知道你是怎么想的,但对我来说,这个新的基础架构就像一个我一直想得到的玩具。

API:如何使用它

好了,马科,你已经说服了我们去看看BPF。现在我们怎么才能仔细看看呢?

让我们看看BPF程序由什么组成,以及如何与它交互。

img{512x368}

首先,我们有一个BPF程序,如果它通过验证,将被加载到内核中。在那里,它将被JIT编译器编译成机器代码,并在内核模式下运行,这时附加的触发器(trigger)将被激活。

BPF程序可以选择与第二部分,即与用户空间程序交互。有两种方式可以实现。我们可以向循环缓冲区写,用户空间部分可以从它那里读。我们也可以对键值图(key-value map)进行写和读,也就是所谓的BPF图(BPF map),相应的,用户空间部分,也可以做同样的事情,这样,它们就可以互相传递信息了。

基本用途

最简单的BPF工作方式,但却是你在任何情况下都不应该采用的从头开始的方式,就是用C语言编写BPF程序,然后用Clang编译器,将相关代码编译成虚拟机的代码。然后,我们加载这些代码,直接使用BPF系统调用,与我们的BPF程序进行交互,也使用BPF系统调用。

第一个可用的简化方法是使用libbpf库。这是和内核的源代码一起提供的,可以让你直接使用BPF系统调用。基本上,它提供了方便的包装器来加载代码,以及使用BPF映射(BPF map)来从内核向用户空间发送数据并返回。

bcc

显然,这对人们来说是远远不够方便的。幸运的是,在iovizor这个品牌下,出现了BCC项目,这让我们的生活变得更加方便。

img{512x368}

基本上,它为我们准备了整个构建环境,让我们可以编写单个的BPF程序,其中С部分会自动构建并加载到内核中,而用户空间部分则可以用Python制作,简单明了。

bpftrace

但是,BCC似乎仍有很多事情很复杂。由于某些原因,人们特别不喜欢用С来写底层那部分。

那些来自iovizor的人也提供了一个工具–bpftrace,它可以让你用类似AWK的简单脚本语言(甚至是单行代码)来编写BPF脚本。

img{512x368}

Brendan Gregg是生产力和可观察性领域的著名专家,他为可用的BPF工作方式制作了以下的图片。

img{512x368}

纵轴显示的是某个工具的易用性,而横轴显示的是它的能力。你可以看到,BCC是一个非常强大的工具,但它并不是超级简单的工具。

使用BPF的例子

让我们来看看一些具体的例子,看看我们已经可以使用的这种神奇力量。

BCC和bpftrace都包含了一个”工具”目录,其中包含了大量有趣而有用的即用型脚本。它们也可以作为本地的Stack Overflow使用,你可以从中复制代码块用于自己的脚本。

例如,这里是显示DNS查询延迟的脚本。

╭─marko@marko-home ~
╰─$ sudo gethostlatency-bpfcc
TIME  PID COMM        LATms HOST
16:27:32 21417 DNS Res~ver #93   3.97 live.github.com
16:27:33 22055 cupsd        7.28 NPI86DDEE.local
16:27:33 15580 DNS Res~ver #87   0.40 github.githubassets.com
16:27:33 15777 DNS Res~ver #89   0.54 github.githubassets.com
16:27:33 21417 DNS Res~ver #93   0.35 live.github.com
16:27:42 15580 DNS Res~ver #87   5.61 ac.duckduckgo.com
16:27:42 15777 DNS Res~ver #89   3.81 www.facebook.com
16:27:42 15777 DNS Res~ver #89   3.76 tech.badoo.com :-)
16:27:43 21417 DNS Res~ver #93   3.89 static.xx.fbcdn.net
16:27:43 15580 DNS Res~ver #87   3.76 scontent-frt3-2.xx.fbcdn.net
16:27:43 15777 DNS Res~ver #89   3.50 scontent-frx5-1.xx.fbcdn.net
16:27:43 21417 DNS Res~ver #93   4.98 scontent-frt3-1.xx.fbcdn.net
16:27:44 15580 DNS Res~ver #87   5.53 edge-chat.facebook.com
16:27:44 15777 DNS Res~ver #89   0.24 edge-chat.facebook.com
16:27:44 22099 cupsd        7.28 NPI86DDEE.local
16:27:45 15580 DNS Res~ver #87   3.85 safebrowsing.googleapis.com
^C%

一个实时显示DNS查询完成时间的实用工具,例如,你可以抓住一些意想不到的异常值。

下面是一个可以”监视”别人在终端上输入的内容的脚本。

╭─marko@marko-home ~
╰─$ sudo bashreadline-bpfcc
TIME  PID COMMAND
16:51:42 24309 uname -a
16:52:03 24309 rm -rf src/badoo

这种脚本可以用来捕捉”坏邻居”,或者对公司的服务器进行安全审计。

下面是一个输出高级语言函数调用链的脚本。

╭─marko@marko-home ~/tmp
╰─$ sudo /usr/sbin/lib/uflow -l python 20590
Tracing method calls in python process 20590... Ctrl-C to quit.
CPU PID TID TIME(us) METHOD
5  20590 20590 0.173 -> helloworld.py.hello
5  20590 20590 0.173  -> helloworld.py.world
5  20590 20590 0.173  <- helloworld.py.world
5  20590 20590 0.173 <- helloworld.py.hello
5  20590 20590 1.174 -> helloworld.py.hello
5  20590 20590 1.174  -> helloworld.py.world
5  20590 20590 1.174  <- helloworld.py.world
5  20590 20590 1.174 <- helloworld.py.hello
5  20590 20590 2.175 -> helloworld.py.hello
5  20590 20590 2.176  -> helloworld.py.world
5  20590 20590 2.176  <- helloworld.py.world
5  20590 20590 2.176 <- helloworld.py.hello
6  20590 20590 3.176 -> helloworld.py.hello
6  20590 20590 3.176  -> helloworld.py.world
6  20590 20590 3.176  <- helloworld.py.world
6  20590 20590 3.176 <- helloworld.py.hello
6  20590 20590 4.177 -> helloworld.py.hello
6  20590 20590 4.177  -> helloworld.py.world
6  20590 20590 4.177  <- helloworld.py.world
6  20590 20590 4.177 <- helloworld.py.hello
^C%

下面这个例子显示了Python中程序的调用栈。(译注:原文似乎缺了这块的代码)。

Brendan Gregg 制作了一张图片,它汇集了所有相关的脚本,箭头指向每个实用程序允许你观察的子系统。正如你所看到的,我们已经有了大量的现成的实用程序供我们使用–几乎可以应对任何可能的情况。

img{512x368}

那Go语言呢?

现在我们来谈谈Go。我们有两个基本问题。

  • 你能用Go写BPF程序吗?
  • 你能分析用Go写的程序吗?

我们按顺序来做。

目前,唯一能够编译成BPF机器(BPF machine)能够理解的格式的编译器是Clang。另一个流行的编译器GСС,但gcc仍然没有BPF后端。而能够编译成BPF的编程语言,只有C语言的一个非常有限的版本(C的子集)。

然而,BPF程序还有第二部分,就是在用户空间。而这可以用Go来编写。

正如我在上面已经提到的,BCC允许你用Python来编写这部分,而Python是该工具的主要语言。同时,在主库中,BCC还支持Lua和C++,而且,在辅库中,它还支持Go

img{512x368}

这个程序看起来和Python中的程序完全一样。一开始,它有一个字符串,其中的BPF程序是用C语言编写的,然后我们沟通在哪里附加一个给定的程序,我们用某种方式和它进行交互,比如从BPF图中提取数据。

基本上就是这样了。更详细的例子可以在Github上查看

主要的缺点可能是我们使用的是C库,libbcc或者libbpf,用C库构建一个Go程序远不是一件容易的”事”。

除了iovisor/gobpf之外,我还发现了另外三个最新的项目,可以让你在Go中写出用户层(userland)部分。

  • https://github.com/dropbox/goebpf
  • https://github.com/cilium/ebpf
  • https://github.com/andrewkroh/go-ebpf

Dropbox的版本不需要任何C库,但你需要自己用Clang构建BPF的内核部分,然后用Go程序将其加载到内核中。

Cilium的版本和Dropbox的版本有相同的具体内容。但值得一提的是,最主要的原因是它是由Cilium项目的人做的,这意味着它成功性更大。

第三个项目我出于完整性的考虑而列出了。和前面两个项目一样,它没有外部的C语言依赖,需要用C语言手动构建BPF程序,但看起来,未来的前景不是特别乐观。

其实,我们还应该问一个问题:到底为什么要用Go写BPF程序?因为如果你看BCC或者bpftrace,那么bPF程序占用的代码不到500行。但如果用bpftrace语言写一个小脚本,或者用一点Python,不是更简单吗?我看有两个理由要这么做。

第一个原因是这样的。你确实很喜欢Go,而且更愿意用Go来做所有事情(译注:拿着go这柄锤子,眼中到处都是钉子)。此外,把Go程序从机器迁移到机器上可能更简单:静态链接,简单的二进制,以及所有这些。但事情远没有这么简单,因为我们被绑在一个特定的内核上。我就不说了,否则,我的文章又要长50页了。

第二个原因是这样的。你写的不是一个简单的脚本,而是一个大规模的系统,这个系统内部也使用了BPF。我在Go中甚至有这样一个系统的例子。

img{512x368}

Scope项目看起来像一个二进制程序,当它在K8s或其他云的基础设施中运行时,会分析发生的一切,并显示有哪些容器和服务,它们是如何交互的等等。而很多这些都是用BPF完成的。一个有趣的项目。

用Go分析程序

如果你还记得,我们还有一个问题:我们能不能用BPF分析用Go编写的程序?我们的第一反应是:”可以,当然可以!” 程序用什么语言编写有什么区别呢?毕竟,它只是编译后的代码,和其他程序一样,在处理器中计算一些东西,疯狂地占用内存,并通过内核与硬件交互,通过系统调用与内核交互。原则上这是正确的,但也有一些细节–这些细节有不同程度的复杂性。

传递参数

其中一个细节是,Go不使用大多数其他语言所使用的ABI(application binary interface)。它的工作方式是,”创始人”决定从Plan 9系统中提取ABI,这是一个他们非常熟悉的系统。

ABI和API一样,是一种接口约定–只是在比特、字节和机器代码的层面上。

我们对ABI的主要内容感兴趣的是它的参数是如何传递给函数的,以及响应是如何从函数中回来的。如果说在标准的ABI x86-64中,处理器的寄存器是用来传递参数和响应的,而在Plan 9 ABI中,堆栈是则是用来实现这个目的的。

Rob Pike和他的团队并没有打算做另一个标准;他们已经为Plan 9系统准备了一个几乎是现成的C编译器–就像2 x 2一样简单–在很短的准备时间内,他们将其改造成了Go的编译器。这就是一个工程师的方法。

然而,实际上这并不是一个如此关键的问题。首先,我们可能很快就会在Go中看到通过寄存器传递参数,其次,从BPF中获取堆栈参数并不复杂:sargX别名已经被添加到bpftrace中,而另一个别名很可能在不久的将来出现在BCC中。

更新:自从我做了演讲之后,Go官方甚至还出了一个关于在ABI中使用寄存器的详细技术草案

唯一的线程标识符

第二个则是与Go的一个被钟爱的功能有关,即goroutines。测量函数延迟的方法之一是保存函数被调用的时间,得到函数的退出时间,并计算其差值。我们需要保存函数的启动时间以及一个键,这这个键将包含函数的名称和TID(线程ID)。线程ID是需要的,因为同一个函数可以被不同的程序,或者一个程序的不同线程同时调用。

img{512x368}

但是,在Go中,goroutine在系统线程之间移动:前一分钟,一个goroutine在一个线程上执行,后一分钟,在另一个线程上执行。而且,在Go的情况下,我们最好不要将TID放入键中,而是放入GID,即goroutine的ID–但不幸的是,我们无法获得它。从纯技术的角度来看,这个ID确实存在。你甚至可以用肮脏的黑客手段来提取它,因为它可以在堆栈的某个地方被找到,但这样做是被Go核心团队建议严格禁止的。他们认为这是我们永远不会需要的信息。goroutine本地存储也是如此–但这有点跑题了。

扩展栈

第三个问题是最严重的问题。它是如此严重,以至于即使我们以某种方式解决了第二个问题,也无法帮助我们测量Go函数的延迟。

大多数读者可能对什么是栈有了很好的理解。这也就是栈,与堆不同,你可以为变量分配内存,而不必考虑释放它们。

但是对于C语言来说,在这种情况下,栈有一个固定的大小。如果我们超过了这个固定大小,就会出现众所周知的堆栈溢出现象。

但在Go中,栈是动态的。在旧版本中,它是通过链接的内存块列表来实现的(即分段栈)。现在,它是一个动态大小的连续块。这意味着,如果分配的内存块对我们来说不够用,我们就扩展当前的内存块。而如果我们不能扩展它,我们就分配一个更大的,并将所有数据从旧的位置移动到新的位置。这一点非常吸引人,并且涉及到安全保证、cgo和垃圾收集等问题,但这是另一篇文章的主题。

要知道,为了让Go能够移动堆栈,它必须处理调用栈,并且处理栈中的所有指针。

而这就是基本的问题所在:uretprobes,用于将bPF探针附加到函数返回中,动态地改变堆栈以整合对其处理程序的调用–这就是所谓的 “蹦床(trampoline)”。而且,在大多数情况下,这改变了栈,这是Go不期望发生的事情,它会导致程序崩溃。糟了!

img{512x368}

顺便说一下,这个故事不是Go独有的。C++的堆栈拆分器在处理异常时也每每崩溃。

这个问题没有解决办法。在这种情况下,像往常一样,双方各自向对方抛出完全有理有据的论点进行指责。

但是,如果你真的需要设置uretprobe,有一个方法可以绕过这个问题。怎么解决?不要设置uretprobe探针。你可以在我们退出函数的所有位置设置一个uprobe。可能有一个这样的位置–或者50个。

img{512x368}

而这也是Go的独特性在我们手中发挥的地方。

通常情况下,这种诡计是行不通的。一个足够聪明的编译器知道如何执行所谓的尾部调用优化,这时,我们不是从函数中返回,而是简单地跳到下一个函数的开始处。这种优化对于Haskell这样的函数式语言来说是至关重要的。如果没有它,你就无法在不发生堆栈溢出的情况下寸步难行。但是,有了这种优化,根本不可能找到我们从函数返回的所有位置。

但具体来说,Go 1.14版本的编译器,还不能进行尾部调用优化。这就意味着,附加到函数的所有显式退出的技巧是可行的,即使它非常笨重。

示例

不要认为BPF对Go无用。远非如此。我们可以做所有不涉及上述问题的其他事情。而且我们会这样做的。

让我们来看一些例子。

首先,我们来看一个简单的程序。基本上,它是一个监听8080端口的web服务器,并且有一个HTTP查询的处理程序。处理程序从URL中获取一个名称参数和一个年份参数,进行检查,然后将这三个变量(名称、年份和检查状态)发送给prepareAnswer()函数,然后该函数以字符串的形式准备一个答案。

img{512x368}

Site check是一个HTTP查询,在通道和goroutines的帮助下,检查会议站点是否工作。prepareAnswer函数只是将所有这些转化为一个可读的字符串。

我们将通过curl的简单查询来触发我们的程序:

img{512x368}

对于我们的第一个例子,我们将使用 bpftrace 打印所有程序的函数调用。在本例中,我们将对 “main “下的所有函数进行附加。在Go中,所有的函数都有一个符号,其形式如下:包名-点-函数名。我们的包是’main’,函数的运行时是’runtime’。

img{512x368}

当我使用curl时,处理程序(handler)、site检查函数和goroutine子函数都会被执行,然后是准备答案函数(prepareAnswer)。很好!

接下来,我不仅要导出那些正在执行的函数,还要导出它们的参数。让我们以函数prepareAnswer()为例,它有三个参数。让我们试着打印两个ints。

让我们拿bpftrace来说,只不过这次不是单行代码,而是一个脚本。让我们将其附在我们的函数上,让我们像我说的那样,为堆栈参数使用别名。

在输出中,我们看到,我们发送了2020,获得了状态200,还发送了一次2021。

img{512x368}

但这个函数有三个参数。第一个参数是一个字符串。那么这个参数呢?

我们简单的导出0到3的所有堆栈参数,我们看看会看到什么?一个大数字,一个稍小的数字,还有我们以前的数字2021和200。一开始这些奇怪的数字是什么?

img{512x368}

这时,熟悉Go的内部结构是很有帮助的。如果说在C语言中,字符串只是一个以零结尾的字节数组,那么在Go语言中,字符串实际是一个结构体,由一个指向字节数组的指针(顺便说一下,这个指针不是以零结尾)和长度组成。

img{512x368}

但是Go编译器在以参数的形式发送一个字符串时,会将这个结构解开,作为两个参数发送。于是,第一个奇怪的数字确实是我们数组的指针,第二个是长度。

果然:预期的字符串长度是22。

相应地,我们修正一下我们的脚本,以便通过堆栈指针寄存器获得这两个值,以及正确的偏移量,并且,在集成的str()函数的帮助下,我们将其导出为一个字符串。这一切都成功了。

img{512x368}

我们也来看看运行时(runtime)的情况。例如,我想知道我们的程序启动了哪些goroutines。我知道goroutines是由函数newproc()和newproc1()启动的。我们来附着(attach)一下它们。funcval结构的指针是newproc1()函数的第一个参数。这个只有一个字段,就是函数的指针。

img{512x368}

在这种情况下,我们将使用直接在脚本中定义结构的功能。这比使用偏移量要简单一些。我们已经导出了所有的goroutine,当我们的处理程序被调用时,这些goroutine就会启动。之后,如果我们想获取偏移量的符号名称,那么我们就可以在其中看到我们的checkSite函数。万岁!

img{512x368}

这些例子对于BPF、BCC和bpftrace的功能来说只是沧海一粟。只要对内部工作原理有足够的了解和经验,您就可以从工作程序中获得几乎任何信息,而无需停止或改变它。

结论

这就是我想告诉你的全部内容,希望对你有所启发。

BPF是Linux中最时髦、最有前途的领域之一。而且我相信,在未来的几年里,我们会看到更多有趣的东西–不仅是技术本身,还有工具和它的传播。

现在还不算太晚,也不是每个人都知道BPF,所以赶快去学习,成为魔术师,解决问题,帮助你的同事。都说魔术师的招数只有一次。

说到Go,照例,我们的结局很独特。我们总是有一些怪癖,无论是不同的编译器,还是ABI,需要GOPATH,有一个你无法谷歌的名字。但我认为,可以说我们(Go)已经成为一股不可忽视的力量,在我看来,情况只会越来越好。

附录(译者添加,原文没有此节)

在ubuntu 18.04上安装bpftrace

ubuntu 19.04及以后版本可以直接通过下面命令安装bpftrace:

(sudo) apt-get install -y bpftrace

但18.04版本的apt官方源中并没有bpftrace。但snap中有:

# snap install --devmode bpftrace
2020-12-17T17:21:24+08:00 INFO Waiting for automatic snapd restart...
bpftrace 20201207-1718-v0.11.4 from Colin King (cking-kernel-tools) installed

# snap connect bpftrace:system-trace

# which bpftrace
/snap/bin/bpftrace

Build
  version: v0.11.4
  LLVM: 7
  foreach_sym: no
  unsafe uprobe: no
  bfd: yes
  bpf_attach_kfunc: no
  bcc_usdt_addsem: no
  bcc bpf_attach_uprobe refcount: no
  libbpf: no
  libbpf btf dump: no
  libbpf btf dump type decl: no

Kernel helpers
  probe_read: yes
  probe_read_str: yes
  probe_read_user: yes
  probe_read_user_str: yes
  probe_read_kernel: yes
  probe_read_kernel_str: yes
  get_current_cgroup_id: yes
  send_signal: yes
  override_return: yes

Kernel features
  Instruction limit: -1
  Loop support: no
  btf: no

Map types
  hash: yes
  percpu hash: yes
  array: yes
  percpu array: yes
  stack_trace: yes
  perf_event_array: yes

Probe types
  kprobe: no
  tracepoint: yes
  perf_event: yes
  kfunc: no

但通过snap安装的bpftrace有缺陷:

# bpftrace -e 'uprobe:/root/test/go/goebpf/testprogram:main.* { printf("%s - %s\n", comm, func); }'
sh: 1: objdump: not found
No probes to attach

这个问题在https://github.com/iovisor/bpftrace/issues/1430中有解决方法,那就是从bpftrace官方提供的docker镜像中将无缺陷的bpftrace拷贝出来:

# docker pull quay.io/iovisor/bpftrace:master-vanilla_llvm_clang_glibc2.27
master-vanilla_llvm_clang_glibc2.27: Pulling from iovisor/bpftrace
da7391352a9b: Pull complete
14428a6d4bcd: Pull complete
2c2d948710f2: Pull complete
8aeae4c5f345: Pull complete
e3b704c358bf: Pull complete
Digest: sha256:77ded0c887c91a431a1ebe508944eae0ed0fab9c51fc2867146c9b4b347becc7
Status: Downloaded newer image for quay.io/iovisor/bpftrace:master-vanilla_llvm_clang_glibc2.27
quay.io/iovisor/bpftrace:master-vanilla_llvm_clang_glibc2.27

# docker run -v $(pwd):/output quay.io/iovisor/bpftrace:master-vanilla_llvm_clang_glibc2.27 /bin/bash -c "cp /usr/bin/bpftrace /output"
# mv bpftrace /snap/bin  <--- 覆盖掉原snap安装的bpftrace

# bpftrace -e 'uprobe:/root/test/go/goebpf/testprogram:main.* { printf("%s - %s\n", comm, func); }'
Attaching 5 probes...

文中一些go文件的源码

// testprogram.go
package main

import (
    "fmt"
    "log"
    "net/http"
    "strconv"
)

func main() {
    http.HandleFunc("/", handler)
    if err := http.ListenAndServe(":8080", nil); err != nil {
        panic(err)
    }
}

func handler(writer http.ResponseWriter, request *http.Request) {
    query := request.URL.Query()
    name := query.Get("name")
    year_, _ := strconv.ParseUint(query.Get("year"), 10, 32)
    year := int(year_)
    status := checkSite()
    answer := prepareAnswer(name, year, status)
    writer.Write([]byte(answer + "\n"))
    return
}

//go:noinline
func checkSite() int {
    resultChan := make(chan int)
    go func() {
        resp, err := http.Get("https://www.gophercon-russia.ru")
        if err != nil {
            log.Fatalf("http get failed: %s\n", err)
        }
        resultChan <- resp.StatusCode
    }()

    return <-resultChan
}

//go:noinline
func prepareAnswer(name string, year int, status int) string {
    answer := fmt.Sprintf("Hello, %s %d! Website returned status %d.", name, year, status)
    return answer
}

myscript3.bt:

# cat myscript3.bt
uprobe:/root/test/go/goebpf/testprogram:main.prepareAnswer {
    $length = reg("sp")+16;
    $array = reg("sp")+8;
         printf("%s - %s %d %d\n", func, str(*($array), $length), sarg2, sarg3);
}

“Gopher部落”知识星球开球了!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!星球首开,福利自然是少不了的!2020年年底之前,8.8折(很吉利吧^_^)加入星球,下方图片扫起来吧!

我的Go技术专栏:“改善Go语⾔编程质量的50个有效实践”上线了,欢迎大家订阅学习!

img{512x368}

我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网热卖中,欢迎小伙伴们订阅学习!

img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/
smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。

2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 微信公众号:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Go语言之禅

本文翻译自Go社区知名Gopher和博主Dave Cheney的文章《The Zen of Go》

img{512x368}

本文来自我在GopherCon Israel 2020上的演讲。文章很长:) 如果您希望阅读精简版,请移步到the-zen-of-go.netlify.com

该演讲视频还未上线。如上线,我会把它更新到本文中的。

我应该如何编写出好代码?

我最近一直在思考很多事情,每当反思自己的工作成果时,眼前常会出现一行字幕:我该如何编写出好代码? 主观上,没人愿意去编写糟糕的代码,那么问题来了:你是怎么知道你编写出好的Go代码了呢?

如果好与坏之间存在连续性,那么我们怎么知道哪些是好的部分?它的特性、属性、标志、模式和惯用法又是什么呢?

Go语言惯用法(idiomatic Go)

img{512x368}

这让我走进Go惯用法。说某种东西是惯用的,就是说它遵循了时代的风格。如果某些东西不是惯用的,那它没有遵循流行的风格,感觉不时髦。

更重要的是,对某人说他们的代码不复合惯用法并不能解释为什么这些代码不符合惯用法。为什么会这样?像所有真相一样,答案可以在词典中找到:

idiom (noun): a group of words established by usage as having a meaning not deducible from those of the individual words.
惯用法(名词):一组由用法确定的且其含义无法从单个单词的含义中推导出来的单词。

惯用法是共享价值观(value)的标志。Go惯用法不是您从书本中学到的东西,而是通过成为社区的一部分而获得的

img{512x368}

Go惯用法字样用多了,便形成了“口头禅”,这引起我的担忧:这种口头禅在许多方面它可能是具有排他性的。比如:有人说“你不能和我们坐在一起。” 但毕竟这不是我们批评某人的代码不符合惯用法时的索要表达的意思,是吧?他们只是没有做对,看起来不对,它没有遵循流行的风格而已。

我认为Go惯用法不是教如何编写好的Go代码的合适机制,因为从根本上说,它仍然告诉某人做错了(译注:这容易引起对方反感)。如果在他们最愿意接受建议的时候,我们给出让他们不感觉疏远的建议是否会更好些?

谚语(Proverbs)

摆脱有问题的惯用法,Gopher们还有哪些其他的文化手工艺品吗?也许我们可以转向Rob Pike精彩的Go 谚语。这些谚语是合适的教学工具吗?它们会告诉Go新手如何编写好的Go代码吗?

总的来说,我不这么认为。这并不是要驳斥Rob Pike的作品。只是像濑越宪作(Segoe Kensaku)的原著一样,Go谚语只是观察,而不是价值观的陈述。我们再次搬出字典:

proverb (noun): a short, well-known pithy saying, stating a general truth or piece of advice.
谚语(名词):简短而众所周知的俗语,陈述一般的真理或一段忠告。

Go Proverbs的目的是揭示有关语言设计的更深层次的真理,但是像empty interface says nothing这样的建议对于一个来自商没有结构化类型的语言的新手来说有什么用呢?

重要的是要认识到,在一个不断发展的社区中,任何时候学习Go语言的人的数量都远超过那些声称掌握了该语言的人的数量。因此,在这种情况下,谚语可能也不是最佳的教学工具。

工程价值观

丹·卢(Dan Luu)找到了马克·卢科夫斯基(Mark Lucovsky)关于Windows团队在Windows NT-Windows 2000开发阶段时的工程文化的演讲。我之所以提到它,是因为卢科夫斯基将一种文化描述为一种评估设计和权衡取舍的常用方法。

img{512x368}

讨论文化的方法有很多,但是就工程文化而言,Lucovsky的描述是恰当的。其中心思想是在未知的设计空间中用价值观指导决策。Windows NT团队的价值观是:可移植性,可靠性,安全性和可扩展性。粗略地说工程价值观就是在这里完成工作的方式。

Go的价值观

Go的显式价值观是什么呢?定义Go程序员解释世界方式的核心信念或哲学又是什么?他们如何发布宣传?他们怎么传授?如何执行?它们又是如何随着时间变化的?

作为新Go程序员,您将如何被灌输Go的工程价值观?或者,你是一位经验丰富的Go专家,你如何将你的价值观传播给“下一代”?就像我们知道的那样,知识传递的过程不是一个可选项。没有新的血液和新的观念,我们的社区将变得短视和枯萎。

其他语言的价值观

为了给我所要了解的场景做铺垫,我们可以先看看其他语言,我们看看它们的工程价值观。

例如,C++(包括其扩展:Rust)认为程序员不必为自己不使用的特性付费。如果程序未使用该语言的某些计算成本昂贵的特性,则不应强迫该程序承担该特性的成本。该价值观从语言扩展到其标准库,并用作判断所有用C++编写的代码设计的标准。

在Java,Ruby和Smalltalk中,一切都是对象的核心价值观驱动着围绕着消息传递,信息隐藏和多态的程序设计风格。在程序中采用过程式或函数式设计风格会被认为是错误的,或者按照Gophers的说法,是不符合惯用法的。

回到我们自己的Go社区,烙印在Go程序员心中的工程价值观是什么呢?在我们社区中的讨论是很容易引战的,因此要从第一条原则衍生出一套价值观念将是一个巨大的挑战。共识很关键,但随着讨论贡献者数量的增加,难度就成倍增加。但是,如果有人已经为我们完成了这些艰苦的工作了呢?

~~Python~~ Go之禅

几十年前,蒂姆·彼得斯(Tim Peters)坐下来写下了PEN-20(Python之禅)。Peters试图记录他认为Guido van Rossum(Python之父)在Python社区扮演的BDFL(仁慈的独裁者)角色时所应用的工程价值观。

在本文的剩余部分中,我将着眼于Python之禅,并问问大家:是否有什么可以用来揭秘Go程序员的工程价值观的?

一个好的package始于一个好名字

让我们从香辛的东西开始

“Namespaces are one honking great idea–let’s do more of those!” The Zen of Python, Item 19
“命名空间是一个很棒的主意-让我们多做些吧!” Python之禅,条款19

这是相当明确的,Python程序员应该使用命名空间,多多益善。

package就是Go语言的命名空间。我怀疑是否有人质疑将组件分组到程序包中利好设计和潜在重用。但关于这么做的正确方法的困惑肯定会有的,尤其是你拥有另一门语言10年的使用经验。

在Go中,每个程序包都应有一个目的/用途,而了解程序包目的/用途的最佳方法是通过其名字-一个名词。包的名字描述了它提供的内容。因此,这里重新解释一下Peters的话:每个Go软件包(package)都应该仅有一个单一的目的/用途。

这不是一个新主意,我已经说了一段时间了,但是为什么要这样做而不是使用将软件包用于细粒度分类的方法呢?为什么,因为变化(change)。

“Design is the art of arranging code to work today, and be changeable forever.” - Sandi Metz
“设计是安排代码以使其至今天仍然可以工作并且永远可以更改的艺术。” - 桑迪·梅斯

变化是我们所从事的游戏的名称(译注:这里的游戏指代程序开发工作)。作为程序员,我们要做的就是管理变变化。当我们做得很好时,我们称之为设计或架构。当我们做得不好时,我们称其为技术债务或遗留代码。

如果你编写的程序对于一组固定的输入可以一次性地完美地工作,那么没人会在乎代码的好坏,因为最终程序的输出才是企业和业务所关心的。

但这是不正确的。软件具有缺陷(bug),需求变更,输入变更,并且很少有程序被编写为仅执行一次,因此您的程序会随着时间而变化。可能是您要为此承担任务,更有可能是其他人,但是必须更改该代码。有人必须维护该代码。

那么,如何使程序更改变得容易呢?无所不在的接口?使一切变得易于mock?邪恶的依赖注入(译注:作者对DI似乎很排斥,用了带有感情色彩的词汇)?好吧,也许,对于某类程序,但不是很多,这些技术将很有用。但是,对于大多数程序而言,预先设计一些灵活的方法要比工程设计更为重要。

相反,如果我们采取的立场是取代组件而不是增强组件,该怎么办?知道何时需要更换某些物品的最佳方法是什么时候该物品没有按照锡盒/罐头盒上的说明进行操作。

一个好的包始于选择一个好的名字。把你的包名字想象成电梯游说(Elevator pitch,即用极具吸引力的方式简明扼要地阐述自己的观点), 仅用一个词就可以描述包的内容。当名字不再符合要求时,请查找替代名字。

简单性很重要

“Simple is better than complex.” - The Zen of Python, Item 3
“简单胜于复杂。” - Python之禅,条款3

PEP-20说:简单胜于复杂,我完全同意。几年前,我发布了这条推文:

大多数编程语言开始都是为了简单,但最终只是为了功能强大而努力。— Dave Cheney(@davecheney)2014年12月2日

至少在当时,我的观察是,我想不出一生中使用的哪门语言不标榜着简单。每种新语言都为其固有的简单性提供了理由和诱因。但是,当我研究时,我发现简单性并不是与Go语言同时代的许多现代语言的核心价值观。也许这只是一个便宜的镜头1,但是可能是这些语言不是很简单,或者它们不认为自己很简单。他们不认为简单是其核心价值观。

但是什么时候简单变得过时了?为什么商业软件开发行业会忘记这个基本原则?

“There are two ways of constructing a software design: One way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies. The first method is far more difficult.” - C. A. R. Hoare, The Emperor’s Old Clothes, 1980 Turing Award Lecture

“构建软件设计的方式有两种:一种方式是使其变得如此简单,以至于显然没有缺陷,另一种方式是使得它变得如此复杂以至于没有明显的缺陷。第一种方法要困难得多。” - CAR Hoare,皇帝的旧衣,1980年图灵奖演讲

我们知道:简单并不意味着容易。通常,使某些东西易于使用而不是易于构建需要更多的工作。

“Simplicity is prerequisite for reliability.” - Edsger W Dijkstra, EWD498, 18 June 1975
“简单性是可靠性的前提。”-  埃兹格·迪克斯特拉(Essger W Dijkstra),EWD498,1975年6月18日

我们为什么要追求简单?为什么Go程序简单很重要?简单并不意味着粗糙,它意味着可读性和可维护性。简单并不意味着“不复杂”,它意味着可靠,有共鸣且易于理解。

“Controlling complexity is the essence of computer programming.” - Brian W. Kernighan, Software Tools (1976)
“控制复杂性是计算机编程的本质。” - Brian W. Kernighan,软件工具  (1976)

Python是否遵守其简单性的说法尚有争议,但Go坚持将简单性作为核心价值观。我认为我们都可以认同,就Go而言,简单代码比聪明代码更可取

避免包级别状态

“Explicit is better than implicit.” - The Zen of Python, Item 2
“显式胜于隐式。” - Python的禅宗,第2项

我认为彼得斯在这个地方的抱负胜于实际。Python中的许多内容并不明确;装饰器,dunder方法(译注:不知此为何物)等。毫无疑问,这些特性的功能强大,存在这些特性是有原因的。每个特性都是因某人非常关心的事情,尤其是复杂的特性。但是大量使用这些特性使读者很难预测操作的成本。

好消息是,像Go程序员一样,我们可以选择显式代码。显式可能意味着很多事情,也许您可能认为显式只是一种表达官僚主义和漫长风气的好方法,但这只是肤浅的解释。只关注页面的语法、烦恼行长和自制表达式是一种错误的说法。在我看来,更有价值的地方是与耦合和状态有关。

耦合(coupling)是衡量一件东西依赖另一件东西的数量的方法。如果两件事紧密结合,它们就会一起运动。影响其中一个事物的行为会直接反映在另一个事物中。想象一下,一列火车,每个车厢连在一起;发动机行驶到哪里,车厢就跟随到哪里。

描述耦合的另一种方法是内聚(cohesion)一词。内聚衡量两件事自然地在一起的程度。当我们进行一个内聚的争论,或者一个内聚的团队,它们的所有零件都可以自然装配在一起,就像设计的一样。

为什么耦合很重要?因为就像火车一样,当您需要更改一段代码时,与之紧密相关的所有代码都必须更改。一个再适合不过的例子:有人发布了他们的API的新版本,现在您的代码无法通过编译了。

API是不可避免的耦合源,但是存在更多隐蔽的耦合形式。显然,每个人都知道,如果API的原型发生更改,则该调用的传入和返回数据都会发生变化。就在函数原型中;我采用这些类型的值,并返回其他类型的值。但是,如果API以其他方式传递数据怎么办?如果每次调用此API的结果都是基于上次调用该API的结果,即使您没有更改参数该怎么办?

这是状态,状态管理是计算机科学中的问题。

package counter

var count int

func Increment(n int) int {
        count += n
        return count
}

假设我们有这个简单的counter包。您可以调用Increment以增加计数器,即便传入0,你也可以得到返回值。

假设您必须测试此代码,那么每次测试后如何重置计数器?假设您想并行运行这些测试,可以吗?现在,假设您要为每个程序进行不止一个计数,您可以这样做吗?

不,当然不行。显然,答案是将count变量封装为类型。

package counter

type Counter struct {
        count int
}

func (c *Counter) Increment(n int) int {
        c.count += n
        return c.count
}

现在想象一下,这个问题不局限于计数器,还包括应用程序的主要业务逻辑。您可以单独测试吗?您可以并行测试吗?您一次可以使用多个实例吗?如果回答那些问题为否,则原因是软件的包级别状态。

避免包级别状态。通过提供一种类型需要的依赖项作为该类型上的字段,而不是使用包变量,来减少耦合和怪异的行为。

为失败而计划,而不是成功而计划

“Errors should never pass silently.” - The Zen of Python, Item 10
“错误绝不能默默传递。” - Python之禅,条款10

有人说过,支持异常处理的语言遵循武士原则(Samurai principle)。要么全胜归来,要么(失败)全不回来。在基于异常的语言中,函数仅返回有效结果。如果他们没有成功,那么控制流程将采纳完全不同的路径。

未检查的异常显然是不安全的编程模型。当您不知道哪些语句可能引发异常时,如何在存在错误的情况下编写健壮的代码?Java尝试通过引入checked exception的概念来使异常更安全,据我所知,这种checked exception在另一种主流语言中没有被引入。有很多使用异常的语言,但是除了Java,所有语言都使用未经检查的各种异常(unchecked exception)。

显然,Go选择了不同的路径。Go程序员认为,健壮的程序是由处理失败情况的片段组成的,然后再处理happy path。在Go设计的空间中:服务器程序,多线程程序,处理网络输入的程序,如果要构建可靠的程序,那么处理意外数据、超时、连接失败和损坏的数据必须是程序员的首要任务。

“I think that error handling should be explicit, this should be a core value of the language.” - Peter Bourgon, GoTime #91
“我认为错误处理应该是显式的,这应该是语言的一个核心价值观。” - 彼得·布尔贡(Peter Bourgon),GoTime#91

我想回应彼得的主张,因为这是本文的动力。我认为Go的成功很大程度上归功于显式的处理错误的方式。Go程序员首先考虑失败情况。我们首先解决如果...怎么办的情况。这导致程序在编写时处理故障,而不是在生产中处理故障。

反复出现的下面代码片段:

if err != nil {
    return err
}

所付出的成本已基本被在故障发生时刻意处理每个故障情况的价值超过了(译者注:上面的重复代码段也是利大于弊)。关键还在于显式处理每个错误的文化价值观。

早点返回,而不是深层嵌套

“Flat is better than nested.” - The Zen of Python, Item 5
“扁平比嵌套更好。” - Python的禅宗,条款5

这是一个明智的建议,而且它来自以缩进作为控制流主要形式的语言。我们如何用Go来解释这个建议呢?gofmt控制Go程序的整体风格(空白与缩进),因此无需执行任何额外操作。

我之前写过关于package名字的文章,这里可能有一些建议:避免复杂的软件包层次结构。以我的经验,程序员越努力细分和分类Go代码库,他们越有可能陷入包导入循环的死角。

我认为第5项条款建议的最佳应用是函数内的控制流。简而言之,避免需要控制流缩进过深。

“Line of sight is a straight line along which an observer has unobstructed vision.” - May Ryer, "Code: Align the happy path to the left edge" https://medium.com/@matryer/line-of-sight-in-code-186dd7cdea88

“代码行展现给观察者的视觉效果应该是一条畅通的直线。- May Ryer, “代码:将快乐路径对齐到左边缘”

Mat Ryer将这种想法描述为视线编码(line of sight coding )。视线编码意味着:

  • 如果不满足前提条件,则使用保护子句尽早返回。
  • 将成功的return语句放在函数的末尾,而不是放在条件代码块中。
  • 通过提取函数和方法来降低函数的整体缩进级别。

该建议的关键是您所关心的事情,功能所要做的事情永远不会有在屏幕右侧滑出视线的危险。这种风格有一个额外的副作用,您可以避免团队中对行长度的毫无意义的争论。

每次缩进时,都会向程序员堆栈添加另一个先决条件,从而消耗他们的7±2个短期内存插槽之一(译注:7±2指的是人类能短期记忆的事件数量大约是7,±2是个体差异)。将函数的成功执行路径贴近屏幕左手侧,不要深入嵌套。

如果你认为它性能差,请通过基准测试证明

“In the face of ambiguity, refuse the temptation to guess.” - The Zen of Python, Item 12
“面对模棱两可,拒绝猜测的诱惑。” - Python之禅,条款12

编程基于数学和逻辑,这两个概念很少涉及机会元素。但是,作为程序员,我们每天都在猜测许多事情。这个变量有什么作用?此参数有什么作用?如果我在这类传入nil会怎样?如果我调用Register两次会怎样?实际上,现代编程中存在很多猜测,尤其是在使用不是你编写的库时。

“APIs should be easy to use and hard to misuse.”  - Josh Bloch
“API应该易于使用并且不容易被滥用。” - 乔什·布洛赫(Josh Bloch)

我知道的帮助程序员避免猜测的最好方法之一是在构建API时,应专注于默认用例。使调用者尽可能轻松地执行最常见的事情。但是,我过去写过很多关于API设计的文章,所以我对第12项的解释是:不要猜测性能

尽管您可能考虑到Knuth的建议,但Go语言成功的推动力之一是其高性能的执行。您可以在Go中编写高性能的程序,因此人们会因此选择Go。关于性能有很多误解,所以我的要求是,当您希望对代码进行性能调优时,或者遇到一些教条式的建议时,例如defer缓慢,CGO昂贵,或者始终使用原子操作而不是互斥锁时,请不要猜。

不要因为过时的教条而使您的代码复杂化,并且,如果您认为某些事情很慢,请首先使用基准测试进行证明。Go提供了出色的基准测试和性能分析工具,这些工具均可免费获得。使用它们来找出您程序中的性能瓶颈。

在启动goroutine之前,请知道它何时会停止

在这一点上,我认为我已经从PEP-20中挖掘了有价值的要点,并且可能扩展其重新解释的范围。我认为很好,因为尽管这是一种有用的修辞手法,但最终我们还是在谈论两种不同的语言。

“You type go, a space, and then a function call. Three keystrokes, you can’t make it much shorter than that. Three keystrokes and you’ve just started a sub process.” - Rob Pike, Simplicity is Complicated, dotGo 2015

“您键入go,一个空格和一个函数调用。三次按键,您不能做到比这还短了。三次按键,您就启动了一个子过程。” - 罗伯·派克(Rob Pike),Simplicity is Complicated,dotGo,2015年

接下来的两个建议,我将专注于goroutines。Goroutines是语言的标志性功能,这是我们给出的关于一等公民(first class)并发的答案。它们非常易于使用,只需将单词go放在语句前面,即可异步启动该函数。非常简单,没有线程,没有堆栈大小,没有线程池执行程序(thread pool executor),没有ID,没有跟踪完成状态。

Goroutines代价很低。由于运行时(runtime)能够将goroutine多路复用到一个小的线程池中(您不必管理),因此可以轻松容纳数十万,数百万个goroutine。这开创了在竞争性并发模型(例如线程或事件回调)下不可行的设计。

但是,即便如goroutine一样便宜,但它们也不是免费的。至少它们的堆栈有几千字节,当您启动10^6个goroutine时,它们的确开始累加。这并不是说就不应该使用数百万个goroutine,如果你的设计需要,可以做。但是当您这样做时,请务必对其进行跟踪,因为10^6数量级的任何东西累计可能消耗的资源都不是少量的。

Goroutines是Go中资源所有权的关键。为了程序有用,goroutine必须做一些事情,这意味着它几乎总是持有对资源的引用或所有权:锁、网络连接、带有数据的缓冲区、channel的发送端。当该goroutine处于活动状态时,将持有锁,保持网络连接打开,保留缓冲区,并且channel的接收器将继续等待更多数据。

释放这些资源的最简单方法是将它们与goroutine的生命周期相关联-当goroutine退出时,资源释放。因此,尽管开始执行goroutine几乎是微不足道的,但在进行三次按键(go+空格)前,请确保您对以下问题有答案:

  • goroutine在什么情况下会停止? Go没有办法告诉goroutine退出。没有停止或终止功能,这是有充分的理由的。如果我们无法命令goroutine停止,则必须礼貌地对其提出要求。这几乎总是归结于channel操作。当channel关闭时,针对一个channel的range loop将退出循环。如果一个channel关闭,它将变为可选(selectable)。从一个goroutine到另一个goroutine的信号最好表示为一个关闭的channel。

  • 出现这种情况需要什么? 如果channel既是在goroutines之间进行通讯的工具,又是它们传达完成信号的机制,那么程序员面临的下一个问题就是,谁将关闭channel,何时会发生?

  • 您将使用什么信号知道goroutine已停止? 当您发出信号告知goroutine要停止时,在将来的某个时间gouroutine停止动作会发生。就人类的感知而言,它可能很快发生,但是计算机每秒执行数十亿条指令,并且从每个goroutine的角度来看,它们的指令执行是不同步的。解决方案通常是使用channel发应答信号或fan-in方法需要的waitgroup。

将并发留给调用者

在您编写的任何严肃的Go程序中,都可能涉及并发。这就提出了一个问题,我们编写的许多库和代码都采用每个连接一个goroutine或采用工作者(worker)模式。您将如何管理这些goroutine的生命周期?

net/http是一个很好的例子。关闭拥有监听套接字的server相对来说是直截了当的,但是从该接受套接字产生的goroutines呢?net/http确实在请求对象中提供了一个上下文(context)对象,该上下文对象可用于向正在监听的代码发出信号,表明应该取消请求(cancel),从而终止goroutine,但是尚不清楚如何知道何时完成所有这些操作。一种方法是调用context.Cancel,知道取消已经完成是另一回事。2

我想说明的一点net/http是,它是良好实践的反例。由于每个连接都是由net/http.Server类型内部产生的goroutine处理的,因此驻留在程序net/http包外部的程序无法控制接受套接字产生的goroutine。

这是一个仍在不断发展的设计领域,例如go-kitrun.Group和Go团队等努力ErrGroup提供了执行,取消和等待异步运行功能的框架。

这里更大的设计准则是针对库编写者的,或者是任何编写可以异步运行的代码的人,将使用goroutine的责任留给调用者。让调用者选择他们希望如何启动,跟踪和等待函数执行。

编写测试以锁定包API的行为

也许您希望从我这里读到一篇我不咆哮测试的文章。可悲的是,今天不是那天。

测试是关于您的软件做什么和不做什么的契约。程序包级别的单元测试应锁定程序包API的行为。他们用代码描述了程序包承诺要做的事情。如果针对每个输入排列组合都有一个单元测试,那么您已经定义了代码将在代码(而不是文档)中执行的约定 。

通过简单的输入go test,您就可以断言代码是否遵守契约。在任何阶段,您都可以高度自信地知道人们在更改之前所依赖的行为在更改之后将继续有效。

测试会锁定api行为。添加,修改或删除公共api的任何更改都必须包括对其测试的更改。

适度是一种美德

Go是一种简单的语言,只有25个关键字。在某些方面,这使该语言内置的功能脱颖而出。同样,这些是语言销售的功能,轻量级并发,结构化类型。

我想我们所有人都经历过尝试立即使用Go的所有功能所带来的困惑。谁对使用channel如此兴奋以至于他们尽可能多地,尽可能多地使用它们?就我个人而言,我发现结果很难测试,脆弱且最终过于复杂。只有我一个人吗?

我在goroutines上经历了相同的经历,试图将工作分解成很小的单元,我创建了一群难以管理的goroutines,最终观察到我的大多数goroutines总是阻塞等待–代码最终是顺序的,我增加了很多复杂性,几乎没有给现实世界带来任何好处。谁经历过这样的事情?

我在嵌入机制方面(embedding)也有同样的经历。最初,我将其误认为是继承。然后,我通过将已经承担多个职责的复杂类型组合成更复杂的巨大类型重现了创建脆弱的基类问题。

这可能是最不可行的建议,但我认为这一点很重要。建议始终是相同的,所有事情都要适度,Go的特性也不例外。如果可以的话,请不要寻求goroutine或channel,也不要嵌入结构,匿名函数,过渡用package,为每样东西建立接口(interface),要用简单方法而不是聪明的方法。

可维护性很重要

我想谈谈关于PEP-20的最后一项,

“可读性很重要。” - Python之禅,条款7
“Readability Counts.” - The Zen of Python, Item 7

关于可读性的重要性已经被谈论了很多,不仅在Go语言中,而且在所有编程语言中。像我这样的人,倡导在Go的舞台上使用简单,可读性,清晰度,生产力等词,但最终它们都是一个词的同义词- 可维护性

真正的目标是编写可维护的代码。原始作者之后的代码可以存活下了。存在的代码不仅可以作为时间点投资,而且可以作为未来价值的基础。不是可读性并不重要,而是可维护性更重要。

Go并不是为聪明人而优化的语言。Go不是一种为了在程序中编写最少行数而进行优化的语言。我们没有针对磁盘上源代码的大小进行优化,也没有针对将程序键入编辑器花费的时间进行优化。而是,我们希望优化我们的代码以使读者清晰(clear)。因为需要维护此代码的人正是读者。

如果您是为自己编写程序,则也许只需要运行一次,或者您是唯一会看到该程序的人,然后程序为您工作。但是,如果这是一个将由多个人贡献的软件,或者将由人们使用足够长的时间以致其需求,功能或运行环境可能发生变化,那么您的目标必须是针对程序的可维护性。如果无法维护软件,则它将被重写;那可能是您的公司最后一次投资Go。

你离开后,您努力实现东西可以维护吗?您今天该如何做才能使某人明天更容易维护您的代码?



我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网上线了,感谢小伙伴们学习支持!

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/
smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

微博:https://weibo.com/bigwhite20xx
微信公众号:iamtonybai
博客:tonybai.com
github: https://github.com/bigwhite

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。


  1. 演讲的这一部分具有Ruby,Swift,Elm,Go,NodeJS,Python,Rust的网站登录页面的几个屏幕截图,展示了该语言如何描述自己。 

  2. 我倾向于选择net/http很多东西,但这并不是因为它很糟糕,实际上是相反的,它是Go代码库中最成功,最古老,最常用的API。因此,它的设计,发展和缺点已被彻底地接受。你可以认为这是奉承,而不是批评。 

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats