这是我参与「第五届青训营 」伴学笔记创作活动的第 3 天。
本文以 CC-BY-SA 4.0 发布。
本文记录了这段时间以来在我的 Go 语言使用之中获得的一些感悟和心得。
语言底层:大并发
这是 Go 语言最大优势之一,它在语言最底层开始就是支持协程的。 为了论述这一点,我们可以从“协程”和“函数染色”出发。(见 What Color is Your Function?)
借用 On the Performance of User-Mode Threads and Coroutines 里的一个结论: 对并发影响最大的不是线程/协程切换的开销,而是能够平台能够同时维持的并发请求数量。 而从一请求一(操作系统)线程的模型变为一请求一(类)协程的模型, 降低了维持请求的开销,从而使并发数量大大上升。
从操作系统的层面看,如果使用 CoW 以及其它一系列技术,操作系统其实是可以将线程的开销降低的。 但是,当线程数增多的时候,因为操作系统需要注重公平性,此时的线程调度对于我们的应用来说,大概会比协程的简单粗暴的可各种配置的调度差。
但是,现在的许多语言都有(类)协程的处理,如最开始的 JavaScript 的“回调地狱”、 现在 JavaScript 和 Rust 等语言的 async/await。而 Go 语言在其中的优势在于 其避免了语言的染色问题。
函数染色
在例如 JavaScript 里我们可以将函数分为两类:
- 普通的函数,
- 接收回调参数的函数,或是 async 函数。
很明显,普通的函数无法直接接收 async 或是回调函数的输出。 如果你想在普通函数里对 async 函数的输出做处理,然后返回回去, 那么你只能把整一条调用链全部改为 async:
sync -> sync -> sync -> sync -> async?
改为:async -> async -> async -> async -> async
当然,在现在的 async 语法糖加持之下这样做也不是很难, 但是在当初的 JavaScript 的“回调地狱”里(在现在的 Java Reactive 框架里也能见到), Go 确实是占尽优势。
Goroutine 与协程
严格来说 goroutine 的确比“用户态线程”的内涵更多一些。 “用户态线程”,因为用户态的限制,只能采取协作式调度的方法—— 一个协程主动表示自己在什么时候可以被切换了。 在回调式编程里,这种时间点就是在当前的栈清空的节点; 在 async 语法糖下,这种时间点就是 await; 而 Go 语言,编译器会在一般的函数里都加入一段 morestack 调用, 用来动态增长栈,同时也可以借此在函数调用的时间点表示当前协程可被切换。 这些特点也导致了在普通协程中,死循环程序无法被停止的; 在以往的 Go 里,以下代码会导致 GC 卡死,因为当前协程无法进入 safe-point:
i := 0
for {
i += 1
}
Go 在 1.14 里引入了非协作式的切换 ——要实现这一点,Go 必须用到用户态以外的东西:信号。 通过发送信号,Go 借助操作系统将协程的执行停了下来,并切换至信号处理程序。 但因为此时协程的状态不定,寄存器的状态也未知—— 协作切换时 Go 的 GC 根本不需要考虑寄存器的问题,现在寄存器里可能有指针,GC 就麻烦大了。 总之,经过各种现场保存和 GC 的特殊处理,Go 实现了非协作式的切换, 这也是一些人认为 goroutine 与一般的协程不太一样的原因之一。
语言设计:错误处理
恕我直言,Go 语言是我网上见到的对其语言设计的批评最多的语言之一。 一般来说,语言设计这一层面比较学究,我等凡人最多也就比比各种语言的性能。 但是,Go 语言的某一方面与其它几乎所有语言都大相径庭, 以至于每一位用过它的人都能实实在在地感受到这一设计——它的错误处理规范非常“独特”。
在知乎上的“Rust 使用 Result 的错误处理方式与 Golang 使用 error 的方式有什么本质区别?”这个问题里, 许多人都提到了:Go 的错误处理是“积类型”,而 Rust 是“和类型”。
抱怨的话不多说,下面的是一些可能的应对思路:
- 认真看完自己用的函数等的文档,如果是没有文档的第三方库,那么祝你好运。
- 不要觉得写
if err ...烦,会习惯的。