本文是对 I want off Mr. Golang's Wild Ride 的整理与翻译。

内容结构概览

  1. 文章基调:作者明确说这是一篇 rant,不是温和评测。
  2. Go 的优点与常见缺点:没有泛型、错误处理、包管理历史问题,以及静态链接、编译速度、pprof 等优点。
  3. 核心论点:Simple is a lie:所谓简单,很多时候只是把复杂性藏到别处。
  4. 文件模式例子:Go 的 os.FileMode 以 Unix 权限模型为核心,在 Windows 上“编造”模式。
  5. Chmod 在 Windows 上的行为:看似接受完整权限位,实际只设置/清除 readonly 位。
  6. Rust 的对比metadata 返回 Result,路径不是普通字符串,平台能力通过类型和模块边界表达。
  7. 路径不是字符串:Unix 路径可以是非 UTF-8 字节序列,Go 用 string 容易静默打印错误内容。
  8. Go filepath 的局限:大量 API 都是 stringExt、路径分隔符、Windows 双分隔符等细节容易混乱。
  9. Rust Path / OsStr / Option 的建模:文件名、扩展名、路径组件都用更精确类型表达。
  10. 文件时间与权限:Rust 区分 SystemTimeInstant,权限只暴露跨平台共有能力,Unix 权限走 Unix-only extension trait。
  11. 平台特定代码:Rust 用 #[cfg] 在同一文件中表达平台差异;Go 依赖 magic 文件名和 build constraints。
  12. “小事会累积”:一个个看似小的设计妥协,会变成工程负担。
  13. HTTP 超时问题:简单 http.Get 可能永远挂住;dial timeout、whole-request timeout、idle timeout 是不同问题。
  14. idletiming 依赖爆炸:一个看似很小的 idle timeout 包,依赖图有 196 条边。
  15. goarista/monotime 链条:为了 monotonic time,拉入巨大仓库和大量无关依赖。
  16. //go:linkname 与空 .s 文件:为了访问 runtime 内部符号,需要 unsafe 技巧和空汇编文件。
  17. Go 1.9 的透明 monotonic time:把 wall time 和 monotonic time 打包进 time.Time,但带来语义复杂性和兼容性问题。
  18. 默认值、错误处理和生产事故:2022 更新里,作者强调 Go 的默认值和错误处理仍然导致大量可避免问题。
  19. Generics 不能解决这些问题:泛型只是补一块,不会改变已有 API 设计和大量现存 Go 代码。
  20. 最终态度:这不是 Rust 球迷骂 Go,而是讨论工具设计;核心对比是“用类型指定允许行为” vs “靠人一步步手动检查”。

这篇文章的标题很不客气:I want off Mr. Golang's Wild Ride。直译过来,大概是“我想从 Go 先生这趟疯狂过山车上下车”。

它不是一篇温和的语言比较,也不是“Go vs Rust”这种常见口水战。原文作者一开始就说得很明白:这是一篇 rant,一篇真正意义上的吐槽文。他在 Go 上投入了数千小时,也用 Go 写过对雇主非常关键的基础设施,但最后的感受是:如果可以重来,他希望自己没有做过这笔投资。

这话很重,也很容易引战。尤其是在 Go 已经被大量公司用于后端服务、云原生基础设施、网络工具、CLI、Kubernetes 生态、DevOps 工具链的背景下,直接说“我后悔投资 Go”,显然不是一句轻飘飘的评价。

但这篇文章真正有价值的地方,不在于情绪,而在于它用很多具体例子说明一个核心观点:

Go 所谓的“简单”,很多时候并不是消灭复杂性,
而是把复杂性藏起来、推给用户、推给运行时、推给文档、推给约定。

文章并没有否认 Go 的优点。Go 的静态链接让部署很方便;编译速度通常很快;pprof 很好用;跨平台能力不错;语法高亮容易;官方 LSP 也在发展。作者承认这些优点,也承认 Go 的常见缺点大家早就知道,比如曾经长期没有泛型、错误处理繁琐、包管理很晚才稳定。

但他真正想讲的是 Go 里那些“丑陋”的部分:文件权限 API 在 Windows 上伪装成 Unix 权限;路径被当成字符串处理;跨平台差异靠 magic 文件名和 build tag;网络超时需要层层补丁;一个看似简单的 idle timeout 包能拉进接近两百个依赖边;时间 API 为了兼容把 wall time 和 monotonic time 塞进同一个 time.Time;很多问题看起来是小事,但它们会在生产系统中不断累积。

这篇文章很激烈,但也很适合作为一次 API 设计和类型建模的案例学习。


一、Go 的优点,作者并不是不知道

文章开头先列了一些 Go 的常见评价。

大家都知道 Go 曾经没有泛型,这使得很多抽象很难准确建模。很多问题只能退回到 reflection,而 reflection API 又容易出错,也绕过了很多静态检查。

大家也知道 Go 的错误处理很别扭。无论你使用标准 if err != nil,还是引入带 context、stack trace 的第三方库,都会觉得重复代码很多,而且错误传播和定位经常不够舒服。

包管理也很晚才真正稳定。Go modules 到 Go 1.14 才被宣布稳定,而在那之前,Go 生态经历过 GOPATH、vendor、dep、modules 等一系列阶段。

但 Go 也有确实存在的优点。静态链接让二进制部署方便;编译速度通常很短,除非你碰到 cgo;pprof 让运行时性能分析非常顺手;Go 比较跨平台;甚至有 tinygo 这类嵌入式方向;语法很简单,所以高亮、解析、工具支持都相对容易;官方 LSP 也出现了。

作者说,这些好与坏,他都接受了。

这篇文章要讲的是另一个层面:Go 的“简单”带来的设计债。


二、Simple is a lie:简单是一个半真半假的说法

Go 的文档、宣传、社区文化里,经常强调一个词:simple。

Go 简单,Go 直接,Go 没有那么多复杂概念,Go 适合工程团队,Go 适合大型组织。

作者的反驳是:这句话是谎言。或者更准确地说,它是一个半真半假的说法。

因为当你让某个东西看起来简单时,你并不一定真的消除了复杂性。很多时候,你只是把复杂性移动到别的地方。

计算机、操作系统、网络、文件系统,本来就是混乱的。现实系统不是干净的教科书模型。Unix 和 Windows 不一样,路径编码不一样,文件权限不一样,时间不一定单调,网络连接可能半开,服务器可能接受连接后永远不发数据,DNS 可能失败,时钟可能回拨,文件名可能不是 UTF-8。

如果语言或标准库假装这些事情不存在,用户一开始会觉得 API 很简单。但复杂性没有消失,它会以 bug、线上事故、隐式约定、平台差异、奇怪默认值的形式重新出现。

这就是文章的核心批评:Go 的很多 API 追求表面简单,但真实世界的复杂性被扫到地毯下面。


三、第一个例子:文件模式与 Unix 幻觉

Go 的文件 API 中,FileInfo 有一个 Mode() 方法,返回 FileModeFileMode 里包含目录、符号链接、命名管道、socket、设备文件、setuid、setgid、sticky bit,以及最低 9 位 Unix 权限位,也就是 rwxrwxrwx

在 Unix 上,这很自然。你可以用 stat 看文件模式:

stat -c '%f' /etc/hosts
stat -c '%f' /usr/bin/man

Go 里也可以写:

fi, _ := os.Stat(arg)
fmt.Printf("mode = %o\n", fi.Mode() & os.ModePerm)

在 Linux 上,/etc/hosts 可能是 644/usr/bin/man 可能是 755。这符合直觉。

问题是:Windows 没有 Unix 文件模式。Windows 没有 stat / lstat / fstat 系统调用,它有 FindFirstFileGetFileAttributesGetFileInformationByHandle 等 Win32 API。Windows 文件属性里有 readonly、hidden、system、directory 等,但没有 Unix 那套 rwxrwxrwx 权限位。

那 Go 在 Windows 上怎么办?

它会“编造”一个 mode。

例如对 C:\Windows\notepad.exeMode(),可能得到:

666

这不是 Windows 文件系统真实存储的 Unix 权限,而是 Go 根据 Windows 文件属性映射出来的一个值。大致逻辑是:如果 readonly,就给 0444;否则给 0666;如果是目录,再加目录 bit 和执行位;如果是 symlink、pipe、device,就再设置对应 Go 的 FileMode 位。

也就是说,Go 的标准库给用户提供了一个看起来跨平台的 API,但这个 API 的底层语义主要来自 Unix。到了 Windows,它只能把 Windows 文件属性硬塞进 Unix 模型里。

这种设计在很多语言中都存在,Node.js 也类似。开源世界长期以 Unix 为默认环境,所以“用 Unix 作为最低公共分母”非常常见。但作者的问题是:这不是一个好抽象。它看起来简单,实际却在平台差异上撒了一个温柔的小谎。


四、Chmod 在 Windows 上到底做了什么

再进一步,Unix 上可以用 chmod 改文件权限。比如把一个文件从 644 改成 755

fi, _ := os.Stat(arg)
fmt.Printf("old mode = %o\n", fi.Mode()&os.ModePerm)

os.Chmod(arg, 0755)

fi, _ = os.Stat(arg)
fmt.Printf("new mode = %o\n", fi.Mode()&os.ModePerm)

在 Linux 上,这会按预期工作。

但在 Windows 上呢?

输出可能是:

old mode = 666
new mode = 666

没有错误,但看起来什么也没变。

Go 的 Chmod 在 Windows 上实际做的事情很简单:只设置或清除 readonly 属性。如果 mode 里有写权限,就清除 readonly;如果没有写权限,就设置 readonly。也就是说,一个 uint32 参数,有 42 亿多个可能值,实际只编码了一个 bit 的信息。

这就是作者特别不满的地方:API 表面上跨平台、统一、简单,但真实语义差异很大。调用方可以传 0755,可以传 0644,可以传一大堆 Unix 权限组合,但 Windows 上真正起作用的只有 readonly。

它没有报错。它只是悄悄做了一点类似的事。

这类设计在小程序中可能无所谓。但在真实工程里,如果你的代码要处理权限、安全、部署、跨平台构建,它会让你误以为自己做了某件事,而实际上没有。


五、Rust 在这里怎么做

作者本来不想又拿 Rust 出来说事,因为这样很容易被贴上“典型 Rustacean”的标签。但他说,在这篇文章列出的问题里,Rust 确实做得更好。如果有另一个更好的例子,他会用,但他没有。

Rust 标准库里没有一个叫 stat 的函数。对应的是:

pub fn metadata<P: AsRef<Path>>(path: P) -> Result<Metadata>

这个签名已经传达了很多信息。

第一,它返回 Result<Metadata>。这意味着获取 metadata 可能失败,调用方必须处理错误。你可以 .unwrap(),可以 .expect(),可以 match,可以用 ? 继续往上传,但你不能在完全无视错误的情况下拿到一个看似有效的 metadata。

Go 里很多函数返回 (value, error),如果你忽略 error,仍然能拿到一个 value。那个 value 可能是 nil、零值、半初始化状态。Rust 的 Result 把成功和失败放进同一个类型里,让你不处理失败就拿不到成功值。

第二,参数不是 String,而是 AsRef<Path>。它接受可以转成路径的东西,比如字符串字面量,也接受真正的 Path 类型。这里的关键是:路径不是普通字符串。

这点马上进入下一个大坑。


六、路径不是字符串

在 Unix 上,路径可以是任意字节序列,只要不包含 null byte。它不保证是 UTF-8。

Rust 的 String 保证是有效 UTF-8。所以一个 Unix 路径不一定能用 Rust String 表示。Rust 因此有 PathOsStr。它们专门用来表示操作系统路径和操作系统字符串。

文章构造了一个文件名,包含无效 UTF-8 字节。ls 甚至会抱怨无法比较文件名,但这个文件确实是合法文件。Rust 用 std::fs::read_dir 遍历目录时,拿到的是 PathBuf,可以正确列出这个文件。打印时如果用 {:?},会看到转义形式:

"./\xBD\xB2=\xBC ⌘"

如果想输出更友好的文本,可以显式调用 path.to_str()

match path.to_str() {
    Some(s) => println!("{}", s),
    None => println!("{:?} (invalid utf-8)", path),
}

这里类型在提醒你:这个路径可能不是 UTF-8。你必须处理两种情况。

Go 怎么做?Go 没有专门的 path 类型,路径相关 API 大多用 string。但 Go 的 string 本质上只是 byte slice,不保证里面是 UTF-8。于是 Go 可以读取这个文件名,也可以打印它,但打印出来可能是错误或乱码的“看起来像字符串”的东西。

作者的观点是:Go 的态度像是在说“别担心编码,东西大概率是 UTF-8”。但路径不是。路径就是可能不是 UTF-8。

Rust 则通过类型强迫你承认这个事实。


七、Go 的 filepath:全是 string

Go 的 path/filepath 包用于处理本地文件路径。里面的函数几乎全是 string

func Abs(path string) (string, error)
func Base(path string) string
func Clean(path string) string
func Dir(path string) string
func EvalSymlinks(path string) (string, error)
func Ext(path string) string
func FromSlash(path string) string
func Glob(pattern string) (matches []string, err error)
func IsAbs(path string) bool
func Join(elem ...string) string
func Split(path string) (dir, file string)
func ToSlash(path string) string
func VolumeName(path string) string
func Walk(root string, walkFn WalkFunc) error

这看起来简单,但问题很多。比如 filepath.Ext

func Ext(path string) string

它返回路径扩展名,规则是取最后一个 path element 中最后一个点之后的内容。如果没有点,就返回空字符串。

测试一些输入:

"/"                  => ""
"/."                 => "."
"/.foo"              => ".foo"
"/foo"               => ""
"/foo.txt"           => ".txt"
"/foo.txt/bar"       => ""
"C:\\foo.txt\\bar"   => 在 Linux 上可能得到 ".txt\\bar"

作者马上质疑:/.foo 的扩展名真的是 .foo 吗?这不一定是所有人想要的语义。

更有意思的是 Windows 路径分隔符。Go 标准库暴露 os.PathSeparator,Windows 上是 '\\'。但 Windows 实际也接受 / 作为路径分隔符。Go 自己的 IsPathSeparator 也知道这一点:

return c == '\\' || c == '/'

也就是说,对外暴露的是“一个 path separator”,内部又知道“其实可能有两个”。这导致用户容易写出拼接字符串、替换分隔符、手写路径格式化的代码,而这些代码往往在跨平台时出错。

作者讽刺说,Rust 的命名就更诚实一些。Rust 有 std::path::MAIN_SEPARATOR,强调它是“主要”分隔符,不暗示它是唯一分隔符。Rust 也有 is_separator 判断当前平台允许的路径分隔符。

更重要的是,Rust 有丰富的 Path API。你更少需要自己用字符串拼路径,更不容易写出:

"downloads" + string(os.PathSeparator) + "defaultScripts"

或者用 fmt.Sprintf 手工拼一堆路径片段这种代码。


八、Rust 的 Path::extension 更保守

Rust 也有获取路径扩展名的函数:

pub fn extension(&self) -> Option<&OsStr>

这个签名就和 Go 不一样。

第一,返回的是 Option。没有扩展名就是 None,不是空字符串。这样可以区分“没有扩展名”和“扩展名为空字符串”。

第二,返回的是 &OsStr,不是 &str。因为扩展名也可能不是 UTF-8。

Rust 对测试输入的结果更细:

"/"        => None
"/."       => None
"/.foo"    => None
"/foo."    => Some("")
"/foo"     => None
"/foo.txt" => Some("txt")

它不会认为 /.foo 有扩展名。它也能区分 /foo./foo

内部实现也更结构化。它先调用 file_name(),只处理最后一个路径组件。file_name() 又基于 components(),而不是每个函数都自己从字符串末尾扫描分隔符。然后再对文件名做 split_file_at_dot

这就是作者想表达的设计差异:Go 的实现倾向于对 byte slice 写一堆循环,遇到平台差异再补丁;Rust 则更愿意把问题分解成 Path、Component、OsStr、Option 这些类型和概念。

学习曲线当然更高。你不能只是对字符串 for loop,然后“试试看”。但结果是更可靠、更类型安全,也更清楚地表达了问题本身。


九、文件时间:SystemTimeInstant

回到 std::fs::Metadata。Rust 的 metadata 提供 is_dir()is_file()len()created()modified()accessed() 等方法。

这里还有一个很重要的细节:文件系统时间返回的是 SystemTime,不是 Instant

SystemTime 表示系统时钟时间,用于和文件系统、其他进程、外部世界交互。它不是单调的。系统时间可能被 NTP 调整,可能回拨。一个操作真实发生在另一个操作之后,但它的 SystemTime 可能反而更早。

所以两个 SystemTime 相减时,Rust 返回 Result,因为这种相减可能失败。

Instant 是用来测量经过时间的,应该单调不倒退。它适合 benchmark、timeout、elapsed duration 这类场景。

Rust 在类型上区分了这两种时间。Go 后面也要面对这个问题,但方式很不一样,文章后面会回到 monotonic time。


十、文件权限:Rust 只暴露跨平台共有能力

Rust 的 Metadata 有:

pub fn permissions(&self) -> Permissions

这里不是直接返回 u32 模式位,而是返回一个 Permissions 类型。

Permissions 在跨平台 API 中只暴露:

readonly()
set_readonly()

也就是说,Rust 标准库在跨平台层面只承诺所有支持平台都能表达的东西:是否只读。Unix 权限位不在跨平台 API 里假装存在。

如果你确实在 Unix 上,需要 mode()set_mode()from_mode(),可以用:

std::os::unix::fs::PermissionsExt

它只在 Unix 平台编译。你在 Linux/macOS 上可以用,到了 Windows 上会直接编译失败:

could not find `unix` in `os`
no method named `mode` found for type `std::fs::Permissions`

这和 Go 的做法完全不同。Go 给你一个看起来统一的 FileMode,在 Windows 上尽量模拟。Rust 则说:跨平台共同能力就放在共同 API;平台特定能力就放在平台特定模块里。你要写跨平台代码,就显式写 #[cfg]

比如:

#[cfg(target_family = "unix")]
use std::os::unix::fs::PermissionsExt;

#[cfg(target_family = "unix")]
{
    println!("permissions: {:o}", permissions.mode());
}

#[cfg(target_family = "windows")]
{
    println!("readonly? {:?}", permissions.readonly());
}

这不是 C 的 #ifdef 预处理器。它是语言层面的条件编译属性,不会有忘记 #endif 这种问题,也能和类型检查集成。

作者的观点是:这才是诚实的跨平台 API。不是假装所有平台都有同一个权限模型,而是承认差异,并让不适用的平台代码根本不编译。


十一、Go 的平台特定代码:magic 文件名和 build constraints

Go 当然也能写平台特定代码。但方式比较粗糙。

常见做法是拆多个文件:

main.go
poke_windows.go
poke_unix.go

_windows.go 这个文件名后缀是 magic。Go toolchain 会在非 Windows 平台自动排除它。但没有 _unix.go 这样统一的 magic 后缀,所以 Unix 侧通常还要写 build constraint:

// +build !windows

这个 build constraint 是注释,但不是普通注释。它必须在文件顶部附近,只能被空白行或其他 build constraint 影响,必须出现在 package 声明前,并且有自己的布尔表达式语言。

例如:

// +build linux,386 darwin,!cgo

对应:

(linux AND 386) OR (darwin AND NOT cgo)

多个 build constraints 之间又是 AND。

作者吐槽说:这很有趣,太有趣了。

实际问题是:相关代码被迫拆到多个文件里,即使只有一个函数平台相关。函数签名要在多个文件中重复维护。magic 文件名和 build constraint 又会互相影响,导致到底哪些文件参与编译不总是直观。

于是很多人会偷懒,在运行时写:

switch runtime.GOOS {
case "windows":
    ...
case "linux":
    ...
}

甚至 Go 官方代码里也有这种模式。

这当然能工作。但问题是,不属于某个平台的代码仍然被编译进来,相关依赖也可能被拉进来。真正理想的做法是:平台不相关的代码不要编译,不适用的 API 不要存在。

Go 让正确方式变麻烦,于是用户和标准库都会走捷径。捷径多了,就变成生态文化。


十二、小事会累积

文章在这里进入一个更尖锐的判断:

这些都是小事。
但它们全都是小事。
它们会很快累积起来。

文件模式是小事。路径字符串是小事。Ext 语义是小事。路径分隔符是小事。build constraints 是小事。runtime.GOOS 是小事。

但这些小事都指向同一个方向:Go 倾向于先给一个简单表面,再靠文档、约定、运行时分支、特殊规则把事情补到“差不多能工作”。

作者称之为 “the Go way”:为了简单,把事情半做完,补丁到差不多能跑。

这句话很刻薄,但后面他会拿网络超时和依赖爆炸举一个更让他崩溃的例子。


十三、真正把作者推过边缘的包:idletiming

文章接着说,真正促使他写下这篇 rant 的,是一个 Go 包:getlantern/idletiming

这个包做什么?

它给 net.Connnet.Listener 添加 idle timeout。也就是说,如果一段时间内没有数据传输,就让连接超时。

为什么需要这种东西?

因为真实世界很乱。一个朴素的 Go HTTP 请求可能这样写:

res, err := http.Get("http://perdu.com")
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)

它在网络正常时可以工作。

但如果服务器永远不接受连接呢?比如防火墙直接丢掉相关端口流量。那你可能会挂住很久,甚至永远。

于是你设置 dial timeout:

client := &http.Client{
    Transport: &http.Transport{
        DialContext: (&net.Dialer{
            Timeout: 5 * time.Second,
        }).DialContext,
    },
}

这解决连接建立阶段的问题。

但如果服务器接受连接,也发送了 headers,说自己会发一堆 bytes,然后永远不发 body 呢?你还是可能挂住。

于是你给整个请求设置 timeout:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
req, err := http.NewRequestWithContext(ctx, "GET", "http://perdu.com", nil)

这能避免整个请求无限挂住。

但如果你要上传一个大文件呢?整个请求超时该设多少?30 秒够吗?你怎么知道这些秒数是花在正常上传上,还是花在等待服务器响应上?

这就是 idle timeout 的意义。它和 dial timeout 不同,也和 whole-request timeout 不同。它表达的是:只要连接一直有数据流动,就可以继续;如果很久没有任何读写活动,就超时。

这就是 idletiming 的作用。作者说这个包本身工作正常,他也在生产中使用,并且是满意的。

问题是依赖图。


十四、一个小包,196 条依赖图边

idletiming 仓库看起来很小,就几个文件,还有测试。然后作者跑:

go mod graph | wc -l

结果是:

196

一个添加 idle timeout 的小包,依赖图有 196 条边。

作者接着看依赖。它直接依赖一些 getlantern 自己的包,比如 logging、mtime、netx,也依赖 testify。这还能理解。Lantern 是一个 site unblock 产品,需要很多网络相关东西,有自己的库也正常。

但继续往下看,依赖图越来越离谱。出现了 YAML、Redis、gRPC、protobuf、InfluxDB、Kafka client、Prometheus client、Snappy、Zstandard、LZ4、chaos-testing TCP proxy、多个 logging 包、各种 Google Cloud 客户端。

这就很夸张了。一个 idle timeout 包为什么要把这些都拖进来?

进一步追下去,核心路径里有 getlantern/mtime,它依赖 aristanetworks/goarista/monotime。而 Go 的 module 粒度是 repository 级别,不是目录级别。你依赖一个仓库里的小包,就可能把整个仓库和它的依赖图都带进来。

goarista/monotime 是一个很小的包,目的只是提供 fast monotonic clock source。它内部用 //go:linkname 访问 Go runtime 的未导出符号 runtime.nanotime

为了使用 //go:linkname,它还需要导入 _ "unsafe"。而因为 Go 对这个 unsafe 特性有特殊要求,它还带了一个空的 .s 汇编文件。那个文件里写着:这个文件故意为空,是某个 Go issue 的 workaround。

作者看完之后基本崩溃:就为了一个 monotonic clock,依赖链居然能拉成这样。


十五、为什么需要 monotonic time

这里要理解一下 monotonic time。

如果你想测量经过时间,比如“从上一次活动到现在过了多久”,不应该用系统墙钟时间。因为系统时间可能被 NTP 调整,可能向前跳,也可能向后跳。你真正需要的是单调时间:它不一定对应现实年月日,但适合测 elapsed duration。

Rust 标准库很明确地区分:

SystemTime:系统时间,可能回拨,适合和外部世界交互。
Instant:单调时间,适合测量经过时间。

Go 一开始没有对普通开发者公开 monotonic time。标准库内部有 runtime nanotime,但不是公开 API。于是第三方包想要准确测 idle timeout,只能用 unsafe hack 访问 runtime 内部符号。

后来 Go 1.9 加了“透明 monotonic time support”。方案是把 wall time 和 monotonic time 一起塞进 time.Timetime.Now() 返回的 Time 同时包含 wall 和 monotonic;某些操作保留 monotonic,某些操作丢掉;SubBeforeAfterEqual 在双方都有 monotonic 时用 monotonic,否则退回 wall time;格式化、取年月日等操作只用 wall time。

这听起来很聪明,也很 Go:不新增一个明显的 Instant 类型,而是把两种概念塞进同一个 time.Time,并让行为“透明”。

问题是,这也带来语义复杂性。一个 time.Time 可能是 wall-only,也可能是 wall+monotonic。不同函数会保留或丢弃 monotonic 部分。用户不一定知道自己手里的时间值到底是哪一种。

而且为了不扩大 time.Time 的大小,两个值被打包进原来的结构里,这又限制了可表示时间范围。设计讨论中有人提出过这些问题,但最后还是选择了这个方案。

作者的重点不是说 monotonic time 容易解决。它确实很难。重点是:Go 经常把真实复杂性藏在“透明”行为里,让 API 表面不变,但语义越来越复杂。

更实际的问题是:Go 1.9 之前没有这个公开能力,而 Go modules 稳定得又很晚。很多包不能轻易声明“我需要 Go 1.9+ 的 transparent monotonic time”,于是仍然依赖 goarista/monotime。结果一个小功能继续拉来巨大的依赖图。

文章写作时,公开可见有数百个 Go 包依赖这个 monotime 包。


十六、Go module 粒度与仓库级依赖问题

goarista/monotime 自身也许很小。但它所在的仓库 goarista 很大,包含很多其他东西。Go 的模块机制和仓库结构使得一个小包可能把整个仓库的依赖关系带进来。

这就是作者对 Go 包管理和生态设计的另一个不满:Go 宣称包只是 git repository 里的 folder,很简单。但这个简单模型带来的后果是,你依赖一个目录,实际牵动的是一个更大粒度的仓库、版本、依赖图。

如果一个仓库混了很多无关工具包,那么其中一个小包也可能背上整个仓库的依赖负担。

这并不是“小库多不好”的问题。作者明确说,他不认同 left-pad 事件后那种“小库都坏”的结论。很多小而维护良好的库是可以的。问题在于依赖边界不清晰、模块粒度不合理,以及为了绕过标准库限制而形成的生态补丁链。

一个 idle timeout 包,最后让你看见了 Go 时间 API、unsafe linkname、空汇编文件、模块粒度和 Go 版本兼容性问题。这就是“简单性”把复杂性推到别处的典型案例。


十七、Channel axioms 与隐式规则

文章结尾还提到 Go 的 Channel Axioms。比如:

向 nil channel 发送会永远阻塞。
从 nil channel 接收会永远阻塞。
向已关闭 channel 发送会 panic。
从已关闭 channel 接收会立即返回零值。

这些规则在 Go 里很重要,但它们不是通过类型显式表达的。它们是语言设计里被固定下来的行为,所有用户都必须记住并围绕它们工作。

作者称它们是“invented truths”:为了实现方便或语言设计选择而发明出来的事实,然后生态必须接受。

这并不是说这些规则没用。很多 Go 程序确实依赖 nil channel 阻塞来动态启用/禁用 select 分支。但它也说明:Go 的简单并不意味着没有规则。规则仍然存在,只是变成了你必须背下来的运行时语义。


十八、原子 64 位对齐:一个脚注造成的运行时 panic

文章还提到一个很具体的坑:sync/atomic 在某些 32-bit 平台上,对 64-bit word 的原子访问要求 64-bit 对齐。

文档里写着:在 ARM、x86-32、32-bit MIPS 上,调用方有责任确保被原子访问的 64-bit word 是 64-bit 对齐的。变量或结构体、数组、slice 中的第一个 word 可以认为是 64-bit 对齐。

如果条件不满足,会在运行时 panic。只在 32-bit 平台上。

作者说自己过去几年里不止一次被这个坑咬到。idletiming 里的结构体就把 64-bit word 放在开头,注释里写着“为了确保 64-bit alignment”。

问题是,这只是文档脚注,不是编译期检查。Go 的“简单”让这类事情很难被类型系统表达,也很难被编译器完整检查。后来有 lint 在做简单场景检测,但本质上仍是补丁。

这又回到核心问题:不是所有复杂性都能靠文档解决。文档能告诉你坑在哪里,但不能阻止你掉进去。


十九、2022 更新:默认值很重要

文章在 2022 年追加了更新。作者说,写完 2020 年那篇之后,他换了两次工作,两份工作都在某种程度上涉及 Go,而且都是 Go 应该擅长的 web services 领域。但体验仍然不愉快。

他已经数不清有多少事故直接来自 Go 的错误处理不佳或默认值问题。

这里有一个很重要的句子:defaults matter。

Go 让你很快写出一个东西,但把它变成 production-ready,往往留给开发者自己完成。大公司采用 Go 后,通常会围绕 Go 建一大堆工具:所有 linters 都开上,代码生成,检查反汇编,制定内部规范,付出持续工程成本来弥补语言和标准库本身的缺口。

但大多数 Go 代码不是这样写的。

作者关心的不是“语言理论上允许你写出多好代码”,而是“这个语言通常会鼓励大家写出什么样的代码”。也就是 idiomatic Go、大家自然会写的 Go、最终 on-call 时你要接手的 Go。

这点非常重要。评价语言不能只看专家在严格工程纪律下能写出什么,也要看普通团队在默认习惯下会写出什么。语言的默认值、惯用法、标准库 API、错误处理风格,都会塑造生态里的典型代码质量。


二十、泛型不能解决这些问题

Go 后来有了泛型。但作者在更新里说,泛型不会解决这里讲的主要问题。

泛型可以解决一部分抽象表达能力问题,比如不再需要为不同类型重复写容器、算法、辅助函数。但泛型不会改变已有标准库的 API 设计,不会改变 time.Time 的历史包袱,不会改变路径用 string 的事实,不会改变大量现存 Go 代码,不会改变默认值和错误处理习惯。

它也不会自动修复 Go FFI 问题。作者提到,一旦 Go 在一个代码库中占据核心位置,就很难逐步替换。Go 的 FFI story 很痛苦,和 Go 最好的边界往往是网络边界。但网络边界又可能带来延迟和部署复杂度。

所以 Go 的问题不是“缺一个功能”。它是一系列设计选择和生态惯性的叠加。


二十一、这不是给 Rust 加油,而是在讨论工具

作者预料到会有人说:你不是教 Rust 吗?那你当然会骂 Go。

他的回应是:这是懒惰且回避问题的说法。

他并不是在给运动队加油。他认为 Rust 只是很多情况下“最不糟糕”的选项。他仍然渴望更好的语言,能处理同类问题但做得比 Rust 更好。

这点很重要。文章虽然频繁拿 Rust 对比 Go,但它真正讨论的是工具设计,而不是身份认同。

如果要把整场讨论简化成一个对比,作者说,不如说是:

serde vs crossing your fingers and hoping user input is well-formed

也就是:

明确指定允许哪些行为,并拒绝其他所有输入
vs
靠人脑在一千个小步骤里手动检查一切是否正常

人脑不适合同时持有巨大状态图。只要系统复杂到一定程度,靠“大家小心点”一定会漏。更好的工程方法,是让类型、解析器、API 边界、默认值和工具链帮你拒绝无效状态。

这也和作者其他文章里反复出现的主题一致:复杂性不能假装不存在。要么你用类型和工具把它建模出来,要么它会在生产里以 bug 的形式回来找你。


二十二、这篇文章真正想表达什么

这篇文章不是说 Go 不能写生产系统。现实已经证明 Go 可以写很多生产系统。Kubernetes、Docker、Prometheus、很多云原生工具都大量使用 Go。

它也不是说 Rust 完美。Rust 有自己的复杂性、学习曲线、编译时间、生命周期、Pin、async、trait solver、生态成熟度问题。

它真正要批评的是 Go 对“简单”的执念。

这种简单有时很有价值。它让新手上手快,让小服务能迅速写出来,让团队不用在太多语言特性上争论。但它也有代价:很多不变量不能表达,很多平台差异被抹平,很多错误状态只能靠运行时发现,很多 API 看似跨平台却语义不一致,很多复杂问题被推给文档和用户。

作者最不能接受的,是 Go 把这种代价包装成优点,并让使用者在生产环境里持续付账。

这也是为什么他最后说自己想下车。


二十三、如果你是 Go 开发者,应该怎么看这篇文章

这篇文章很刺耳。尤其如果你正在大量写 Go,它很可能让人不舒服。

但可以不把它当成“骂 Go”,而把它当成一组 checklist:

我的代码有没有忽略 err?
我是不是依赖了零值,但零值语义不清?
我的 timeout 是 dial timeout、request timeout,还是 idle timeout?
我的路径处理是不是默认 UTF-8?
我的跨平台代码是不是把 Unix 模型套到 Windows 上?
我的包是不是为了一个小功能拉进巨大依赖图?
我的平台特定代码是在编译期排除,还是运行时 switch?
我的 API 是不是用 string / int / bool 表达太多不同状态?

这些问题不是 Go 独有。但 Go 的标准库和惯用法确实会让这些问题更容易出现。

如果你必须写 Go,也不是没有办法。可以更严格地使用 linters;可以封装更准确的类型;可以认真处理所有错误;可以用 context 和 timeout;可以做依赖审计;可以避免用零值表达复杂状态;可以用内部工具限制风险。

但作者的观点是:这些都是你额外付出的工程成本。语言和标准库没有默认帮你挡住足够多问题。


二十四、总结

这篇文章是一篇非常激烈的 Go 批评文。作者承认 Go 的优点:静态链接方便部署,编译速度通常很快,pprof 好用,跨平台能力不错,语法简单,工具支持也在改进。他也承认 Go 的常见缺点大家都知道:曾经没有泛型,错误处理繁琐,包管理长期不稳定。但他真正想讲的是 Go 里更深层的问题:所谓“简单”,往往只是把真实复杂性藏起来。

文章第一个大例子是文件系统。Go 的 FileMode 以 Unix 权限模型为中心,在 Windows 上只能根据文件属性“编造”模式。Chmod 在 Windows 上看似接受完整权限位,实际只设置或清除 readonly bit。一个 uint32 参数表面能表达几十亿种值,真实只影响一个 bit。这种 API 看似跨平台,实际让调用方误以为所有平台都共享 Unix 权限模型。Rust 对比之下,只在跨平台 Permissions 中暴露 readonly,把 Unix 权限放进 std::os::unix::fs::PermissionsExt,并让这部分代码在 Windows 上直接编译失败。

第二个大例子是路径。Unix 路径可以是非 UTF-8 字节序列,而 Go 没有专门的 path 类型,路径 API 大多用 string。Go string 本质是 byte slice,不保证 UTF-8,于是你可以静默打印出错误版本的路径。Rust 用 PathPathBufOsStrOption 等类型区分路径、路径组件、文件名和扩展名。Path::extension() 返回 Option<&OsStr>,既表达“可能没有扩展名”,也表达“扩展名可能不是 UTF-8”。这让 API 更复杂,但也更诚实。

第三个例子是跨平台代码。Rust 用 #[cfg] 在语言层面表达平台差异。Go 则依赖 _windows.go 这种 magic 文件名和 // +build 注释式 build constraints。相关代码被迫拆成多个文件,签名要重复维护,哪些文件参与编译也不总是直观。结果很多代码干脆在运行时 switch runtime.GOOS,把本该编译期排除的差异留到运行时处理。作者认为这是一种捷径,也是一种设计债。

文章真正让作者崩溃的例子是 getlantern/idletiming。真实网络里,简单 http.Get 可能永远挂住。dial timeout、whole-request timeout、idle timeout 是不同概念。idletiming 提供 idle timeout,本身有价值,也在生产中工作。但这个看似很小的包,依赖图竟然有 196 条边,最后拉进 YAML、Redis、gRPC、protobuf、InfluxDB、Kafka client、Prometheus client、各种压缩库和 Google Cloud client。继续追踪发现,其中一个关键链条是 getlantern/mtime 依赖 aristanetworks/goarista/monotime,而这个包为了拿 monotonic time,用 //go:linkname 和 unsafe 访问 Go runtime 内部的 runtime.nanotime,还需要一个空 .s 文件作为 workaround。

这个链条背后是 Go monotonic time 的历史问题。Go 一开始没有公开 monotonic clock,只能通过 unsafe hack 访问 runtime 内部符号。Go 1.9 后提供透明 monotonic time,把 wall time 和 monotonic time 一起打包进 time.Time,让 SubBeforeAfter 等方法在双方都有 monotonic 信息时使用它,否则退回 wall time。这个方案保持了 API 表面简单,但让 time.Time 变成有两种内部状态的混合类型,也带来行为复杂性、范围限制和兼容问题。因为 Go modules 很晚才稳定,很多包仍然保留对第三方 monotime 的依赖,继续把巨大依赖图传递下去。

结尾部分,作者进一步批评 Go 的隐式规则和运行时坑,比如 Channel Axioms、32-bit 平台上 64-bit atomic alignment 需要调用方自己保证等。这些都不是编译期强制,而是文档脚注、运行时 panic 或需要 lint 补救。2022 年更新中,作者说自己后来又在两份涉及 Go 的工作中看到大量由错误处理和默认值导致的事故。他强调 defaults matter:语言的默认值、标准库 API 和惯用法会塑造大多数代码,而不是少数专家在严格工程纪律下能写出的理想代码。

最终,这篇文章不是简单的“Go 不行,Rust 行”。它更像是在讨论工具设计:你是用类型、API 和编译期边界明确指定允许的状态,并拒绝其他状态;还是靠人在一千个小步骤里手动检查一切正常。作者认为 Go 选择了太多后者,并把这种选择包装成“简单”。而真实系统的复杂性不会因为语言宣传而消失,它只会换个地方出现。


好文收藏
44 声望8 粉丝

好文收集