分享

我们需要谈谈 Go 的缺点

 技术的游戏 2023-09-16 发布于广东
我们需要谈谈 Go 的缺点

这是一个3篇文章系列的第二部分。这是关于 Go 编程语言的缺点的故事,关于它使我们的生产力降低、我们的代码库变得不安全和不易维护的部分。以及对改进的提议。🌟

这个系列中的更多内容

  • · 为什么 Go 是最好的语言

  • · 我们需要谈谈 Go 的缺点

  • · 为 Go 更好的未来的提议

在前一篇文章的介绍中,我们提到了一个冲突 — Go 有着坚决保持简单的倾向。尽管它有巨大的优势,但它阻止了 Gophers 的生产力提升。然后,我们讨论了这门语言的强大之处和使其独特的一切。在这篇文章中,我们将深入探讨并展示 Go 的问题面。如果你错过了前一篇,我建议回头看一下,因为它为即将到来的抱怨提供了重要的背景。准备好了吗?我们开始吧。

命名空间污染和糟糕的命名

写 Go 代码一段时间后,你会开始注意到你通常很快就用完了合理的变量名。最初,这听起来像是一个表面问题,但实际上并不是。让我们从发生这种情况的原因开始说起。

缺乏可见性修饰符

命名空间污染的第一个根本原因是缺乏可见性修饰符。我认为为了减少冗余的关键词并增强简洁性,语言设计者决定省略可见性修饰符关键词(publicprivate 等)以支持符号命名。以大写字母开头的符号自动被视为公共的,其余的则为私有的。这听起来像是一个促进简洁性的很好选择。但随着时间的推移,越来越明显的是,这种方法的缺点比优点更强烈:在大多数其他语言中,按照约定,类型名以大写字母开头,变量名以小写字母开头。这个约定有一个非常强大的含义 — 它意味着变量永远不会遮蔽类型。考虑以下 Go 代码:

type user struct {
  name string
}

func main() {
  user := &user{name: "John"}
  anotherUser := &user{name: "Jane"// compilation error: user is not a type
}

在 Go 中的类型遮蔽

这在 Go 中非常常见,我敢打赌大多数 Gophers 在某个时候都会遇到这个问题。在大多数情况下,你的编码范围都只处理一个用户实例,所以将其命名为 user 应该是一个明确且合理的选择。但是,在 Go 中,每当你将一个私有类型存储到一个私有变量中或将一个公有类型存储到一个公有变量中时 — 你就会遇到这个问题。所以你只是简单地开始将你的用户变量命名为 u

包范围命名空间

命名空间污染的第二个原因,也可能是最令人烦恼的一个 — 包范围命名空间。让我们从一个例子开始:

// file: client/slack_client.go
package client

const url = "slack.com"

type SlackClient struct {...}
// file: client/telegram_client.go
package client

const url = "telegram.com" // compilation error: url redeclared in this package

type TelegramClient struct {...}

为什么呢?这些是两个不同的文件。我不能声明任何私有的东西并仅在本地使用它吗?我不能。在一个文件中声明的任何符号都会自动对整个包可见。

在某些情况下,让多个文件之间的包共享一个私有符号是完全有意义的(例如 包私有可见性)。而在其他情况下,符号没有理由逃离它们文件的范围。Go 不提供这种控制的事实导致包被严重地弄乱,并且为了避免重复和歧义,不必要地使用长且特定的符号名称。在大项目的大包中,几乎不可能找到一个合理的也是可用的名称。更不用说从包内声明的数百甚至数千个符号中找到你想要调用的实际功能了。😭

内置符号

最后,是全局内置符号。让我们从一个例子开始:

func larger(a, b []string) []string {
  len := len(a)
  if len > len(b) { // compilation error: invalid operation: cannot call non-function len (variable of type int)
     return a
  }
  return b
}

覆盖内置符号

换句话说,你技术上可以使用一系列的名字来命名你的变量,但如果你这样做,你会遮挡重要的内置功能

解决这个问题的一个可能的方法是:内置符号应该是关键字而不是符号。覆盖lenmakeappend永远都不是一个好主意,对吧?更不用说truefalse了(它们在Go中不是关键字)。如果我们都同意这总是一个坏习惯,那为什么一开始就允许它呢?

一个更好的解决方案是:内置符号应该放在上下文相关的命名空间下。比如,lenappendcap作为切片的方法比全局符号更有意义。它们读起来都更自然,但更重要的是,它们不会弄乱全局命名空间,允许我们在需要时安全地使用它们作为合理的变量名。

真的那么重要吗?

变量名真的那么重要吗?首先,是的,它们很重要。一个名为u的用户变量从来都不是一个比一个上下文相关的变量名更好的主意。但它也违反了Go的第一个规则——可读性优先于可写性。尝试弄清由urzt变量组成的代码片段实际上是相当令人头疼的。我明白有些情况下代码短小简单,上下文足够清晰,对于一个u变量来说保持清晰是有可能的。但为什么我们首先官方鼓励这种做法呢?在任何现代环境中,flname相对于fileName的附加价值是什么,其中源代码大小的影响与其可维护性相比是微不足道的?就我个人而言,我发现自己经常告诉拼写检查工具,“不,实际上conns是一个有效的词,很明显是connections的缩写,还有...什么?cnt?不不不,哈哈,它只是count的缩写,淘气”。🤦‍♀️

官方的Go wiki提供了一个通过代码审查评论的最佳实践部分。有一个特定的建议是保持变量名简短,因为熟悉承认简短。作为一个非英语母语者,我想,“嗯...听起来很聪明”。🤔过了几秒钟我才真正理解这句话的真正含义 — “如果在你写的时候它看起来很熟悉,允许自己有点懒惰”。😮我的意思是,为什么?这正是一种伪装成好习惯的惯例,并最终鼓励我们走错方向。我发现自己在多次场合盯着非常受欢迎的开源项目,想知道现在我看到的东西怎么能有人理解。为了证明一个观点,这里是一个代码行,在一个100+行的函数中间引用cc.c,在一个2947行的文件中间。很明显,我不能责怪作者(是的,我知道,我可以 git blame他们,好吧,安静)因为他们只是简单地遵循习惯性Go的规则。但在这样一个巨大的函数中,在这样一个巨大的文件和包中弄清楚ccc的意义绝对不是富有成效的编程。

变量名种类低的另一个问题是意外的遮蔽和意外的重写。考虑以下功能:

func (u *user) Approve() error {
 err := db.ApproveUser(u.ID)
 if err != nil {
   err = reportError("could not approve user")
   if err != nil {
     log.Error("could not report error")
   }
 }
 return err
}

这段代码中隐藏了一个令人讨厌的小bug

哎,这是一个在生产中很难发现的令人讨厌的小bug。你能找到它吗?这应该在代码审查中被发现,但需要一个非常彻底的审查才能揭示它。还有其他方法可以预防它吗?确保在不需要时不重写变量值(Go不强制执行,我们稍后会介绍),然后努力为每个变量提供一个上下文定位良好的名称。在某些方面,这与习惯性的Go相反,减少可用名称的种类进一步伤害了它。

首字母缩略词

另一个对可读性的讨厌打击是大写首字母缩略词的惯例。习惯性的方式来命名一个指向某个HTTPS端点URL的公共变量是HTTPSURL。除了它会打破拼写检查工具,难道只有我认为HttpsUrl在任何可能的方式上都更好吗?当这三者被连接起来时怎么样?JSONHTTPSURL看起来像一个好的变量名吗?感觉像是想弄清楚一个糟糕的Reddit子版的名字。😂🤷‍♀️

接收者名称

最后 — 方法接收者名称的约定。我建议阅读Jesse Duffield's精美编写的关于约定的文章 'Going Insane'。我无法比那更好地呈现或争论。简而言之:self 或 this 可能比我们使用的这些单字母接收者名称更有意义,然后我们发现自己在重命名结构或移动方法时追逐尾巴重命名。

值安全

与类型安全类似,值安全是在编译时进行运行时保证的一种手段。类型安全是指运行时的值的类型,而值安全是指实际的值。虽然类型安全是由一个单一、一致的类型系统执行的,但值安全只是一组提供关于值的各种保证的不同工具和特性。因此,语言可以做的不仅仅是完全选择加入或退出值安全。每种语言选择要支持和执行的确切的值安全特性集合。

一些值安全工具实际上是众所周知的编程的基本概念,它们在其基本本质或作为一个副作用提供安全性。其他的是随着时间的推移而进化的现代工具。不管怎样,值安全特性不是现代语言爱好者吹嘘的增强生产力的语法糖工具。它们提供了我们关键地依赖的高度重要的保证。与语法糖特性不同,这些保证不能通过编写明确的代码来实现。在它们缺失的情况下,剩下的只是人类的责任和我们分析每一段代码的边缘情况和陷阱的能力(哈哈🤣)。我们也可以选择退出,只要每个人都能按照预期的方式编写代码,事情就会继续进行。直到有人不这么做,然后就是恐慌的时候了。字面上是这样。

随着我们对编程原则的理解不断深化,新工具提供了更好的值安全性。一些现代语言提供了新的、令人兴奋的方法来执行值安全,使程序员能够编写更健壮和高性能的代码。其他编译语言提供了至少一些值安全特性。Go不提供任何。现在轮到我们确保我们按照预期使用一切。作为任何库或代码的消费者和生产者,这都是一个可怕的情况。让我们解决这些缺失的特性。

空指针安全

绝对不是编程历史上最糟糕的设计决策,但可能是最臭名昭著的一个(“十亿美元的错误”) —— 空指针异常定期给太多的人带来了太多的金钱损失。归根结底,造成这种情况的原因总是最简单的人为错误 —— 忘记检查边缘情况。这个一再出现的问题始终提醒我们,我们简单地不是为这个而生的。在编程中,“请记住做这个和那个”总是一个坏兆头。如果有一件事你可以依赖的话 —— 我们不会。我们不擅长记住做事。

现代语言特性允许完全消除这个问题。严格的空值检查(像SwiftKotlinTypeScript提供的功能)允许我们明确定义何时允许空值。当允许时,在取消引用它们之前需要进行空检查。其他语言提供总和类型(如RustScala中的类型),首先防止空值。

我真的不能理解为什么一个程序员会提倡类型安全而忽视空安全。如果你希望你的编译器执行不调用未定义的方法,当你试图取消引用一个空值时,同样的逻辑不也应该适用吗?我相信,不管怎样,现代语言必须配备这样的执行机制。

这里是一个提议向Go添加可空类型支持。

枚举

不管你如何模型化你的代码,最终你总是会需要枚举。一个变量可以有几种可能的值中的一个。如果你只有两个可能的值 —— 布尔值会工作得很好。如果你有三个或更多的选项,你将需要一个支持枚举的机制。

在Go中,你可以做到……但其实并不真正可以。你可以定义常量,但那只是它们的全部 —— 简单的常量值。它们并不保证一个宿主变量必须持有其中之一。即使你定义了一个自定义类型。以下是一个例子:

type ConnectionStatus int

const (
  Idle ConnectionStatus = iota
  Connecting
  Ready
)

func main() {
  var status ConnectionStatus = 46 // no compilation error
}

带有无效值的枚举

在主函数中声明的 status 变量的值应该是0、1或2。然而,当我们将其设置为46时,编译或运行时都不会出现错误。一个好的开发者不应该将它设置为46,呃!🤪 但是错误确实会发生。随着代码库和工程师数量的增长,它们发生只是时间问题。一个好的编译器应该帮助我们避免这种情况。

别忘了46也可能来自外部、非硬编码的来源。比如HTTP请求或文件。我们应该总是记得手动验证它。直到我们忘记了。😞

这种方法的另一个问题是缺乏封装。枚举相关的行为不能在枚举本身内部定义,只能通过switch case来定义。然而,Go中的switch case并不强制覆盖所有可能的值。下面是它的样子:

type ConnectionStatus int

const (
  Idle ConnectionStatus = iota
  Connecting
  Ready
  Disconnecting
)

func handleConnectionStatus(status ConnectionStatus) {
  switch status {
  case Idle:
     reconnect()
  case Connecting:
     wait()
  case Ready:
     useConnection()
  }
}

未处理的枚举情况

看起来不是那么糟糕,是吗?但实际上确实很糟糕 - 每次你添加一个新的枚举值,你都必须搜索你的代码库找到那些switch语句。如果你漏掉了一个,就像上面的例子没有正确处理Disconnecting状态,它会陷入未定义的行为。请放心,你总会记得修复这些枚举,直到有一天你不再这样做(还记得我们刚刚说的关于记忆的事情吗?...你可能不记得了。我也是🤦‍♀️)。你能想象一个你宁愿不使用编译器来强制这种陷阱的场景吗? 解决这个问题的方法很简单:要么你要求switch case语句穷尽所有可能的值(例如如rust所做),要么你要求枚举来实现行为(例如如java所做)。

缺少原生枚举支持的其他问题是什么?如何遍历一个枚举的所有可能值呢?你偶尔需要这样做,也许你需要将它发送到UI供用户选择一个。这是不可能的。什么关于命名空间?对于所有HTTP状态码是否最好属于一个只提供HTTP状态码的命名空间?但Go将它们与HTTP包的其余公共符号混合在一起 - 像Client,Server和错误实例。

这是Go中的一个枚举提议

结构体默认值

在某些情况下,结构体可能不仅仅是一组变量的集合,而是一个具有状态和行为的简洁实体。在这种情况下,某些字段最初可能需要持有有意义的值,而不仅仅是它们的零值。我们可能需要将int值初始化为-1而不是0,或初始化为18,或者从其他值派生出的计算值。

Go并没有提供任何现实的方法来在结构体中强制初始状态。实现这一点的唯一方法是声明一个公共构造函数,然后使你的结构体私有化以防止通过结构体字面量直接实例化。此时,你必须声明一个描述结构体的公共方法的接口,以便能够导出构造函数的返回值。例如:

type connectionPool struct {
  connectionCount int // initial value should be 5
}

type ConnectionPool interface {
  AddConnection()
  RemoveConnection()
}

func newConnectionPool() ConnectionPool {
  return &connectionPool{connectionCount: 5// since the struct is private, direct instantiation can only be done from inside the package
}

func (c *connectionPool) AddConnection() {
  c.connectionCount++
}

func (c *connectionPool) RemoveConnection() {
  c.connectionCount--
}

尝试在结构体中强制初始值

由于结构体本身并没有被导出,所以不可能通过结构体字面量直接实例化它,而只能通过调用我们的构造函数来实例化。这确保了我们强制执行所需的初始值。

当维护开源库时,这种方法可能勉强值得努力。但是我们这些普通人呢?我们不太关心我们进程内部某些结构体的API的完整性,我们只是试图快速修复生产中的一个bug。如果我们这样工作,每次更改方法签名时,我们还必须在接口中更改重复项。仅仅想在结构体中强制初始值就产生了这样一个随机的副作用🤦‍♀️😂。 但这还不是全部。它甚至并没有完全解决问题 - 同一个包中的代码仍然可以使用结构体字面量并跳过构造函数,破坏我们试图完成的一切。🤤

另一个有问题的用例是配置结构体,但最好的展示方式是使用一个真实的例子。Sarama 是用于Kafka的最受欢迎的Go库。这是Shopify维护的一个巨大、成熟的项目。Sarama暴露了一个Config 结构体,它应该在创建新客户端时传入。它包含如何连接到Kafka代理以及如何维护连接状态和行为的各种信息。结构体的注释指示了每个字段的意图和其默认值。例如 - 这里的Max字段,它设置请求到Kafka的最大重试次数,默认为5。所以一个普通的开发者,例如我,可能会想“嗯...好的,让我们传入一个空的结构体字面量来使用默认值5”。于是我这样做了。

func createConsumer(brokers []string) {
  consumer, err := sarama.NewConsumer(brokers, &sarama.Config{})
  // ...
}

使用 sarama 配置的默认值

“*...现在我的配置肯定允许5次重试,” 我自言自语道。🤗 但直到几天后,当我再玩了一会儿,我突然想:“等等,它们怎么知道的?*” Sarama的代码如何能够区分使用 &sarama.Config{} 传入的默认值0(应允许5次重试),和使用 &sarama.Config{Max: 0} 明确传入的0(应允许0次重试)。😨 确实,它不能。显然,有一个我应该使用的config 结构体的构造函数,而不是直接实例化它。哈哈 🤦‍♀️。我突然想:“等一下”,我是否已经推送了指示 Sarama 库使用0次重试和其他所有配置参数的零值的代码?!”😲😧😨😱 是的,我确实这样做了。😩🤦‍♀️ 🤦🏽‍♂️

起初,我对这个库的设计者非常生气,因为他们没有强制防止这样的错误,“如果这发生在我身上” 和其他原因。然后我再思考了一下,意识到,他们不能。Go语言简单地不提供这样的功能。乍一看,结构体字面量看起来像是配置参数用例的完美候选,允许你传入你需要的内容,并省略其余内容。但事实证明,情况恰恰相反。

这里有一个提议来支持类型的初始化器。

常量赋值

在花了一些时间与区分常量和变量值的语言打交道后,你开始注意到你的大多数赋值都是常量。非常量赋值主要用于需要计数、连接或其他形式的聚合的计算。在大多数其他使用场景中,变量的赋值是一次性操作。例如,我们以 reactjs 的 GitHub 仓库为例,比较 let 关键字的使用量和 const 关键字的使用量,我们可以得出结论:常量赋值占所有赋值的75% (25,440 vs 6,815)。因此,像一些其他现代语言(比如 rust)那样默认将赋值视为常量是非常有意义的。这有什么用?让我们回到变量命名的例子,我们谈到了意外的遮蔽和重写变量:

func (u *user) Approve() error {
 err := db.ApproveUser(u.ID)
 if err != nil {
   err = reportError("could not approve user")
   if err != nil {
     log.Error("could not report error")
   }
 }
 return err
}

变量遮蔽可能导致隐式行为

还记得这个吗?这里的 bug 是对 err 变量的第二次赋值可能会清除原始错误,导致我们像从未发生错误一样从函数返回。这将导致调用函数继续执行,好像用户实际上已经被批准了,尽管他们并没有。一个有经验的 Gopher 如果知道他们应该寻找一个 bug,可能会很快找到它。一个没有经验的 Gopher 可能需要更长时间。常量赋值很容易就防止了它。对 error 变量的第二次赋值将无法编译,指示我们定义一个单独的变量。

常量赋值不仅仅是关于编译器的强制。它们也关于作者能够传达意图,使代码更具表现力,并使代码更加清晰。如果你知道某个变量不是为了被重新赋值的,那么当你修改或重构其周围的代码时,你就拥有更多的信息。ℹ️🤓

这里有一个常量赋值支持的提议

不可变性

不可变值是一个单独的事情。一个变量的赋值可以是常数,但该值本身仍然可以变化。考虑创建一个指向 map 的常量变量,然后对 map 执行写操作。变量仍然指向同一个 map,但 map 的值本身已经改变了。

在并发环境中,不可变数据结构可以是防止数据竞态的强大工具。在 Go 中,没有原生支持的不可变数据结构。好消息是,随着泛型的到来,我们可以自己创建它们。例如 ImmutableMap[K, V]

这里有一个不可变性的提议

错误处理

错误处理可能是 Go 社区最大的争议,现在泛型的争议已经全部解决。随着时间的推移,我熟悉了许多错误处理的方法,无论是在其他语言中还是在 Go 的提议中。我不敢说我自己可以想出最好的错误处理机制。但我确实想说,Go 的错误处理有许多优点和优势。然而,我们在这里是为了讨论缺点。首先,我想再次提及 Jesse Duffield 的关于错误处理的疯狂之作,他很好地描述了自己的痛点。然后我会自己补充两点。

首先,我最近听到很多 Gophers 争论说 Go 中的显式错误处理方法是一个好主意,因为它迫使我们处理每一个错误。我们永远不应该简单地传播错误。换句话说,这种情况永远不应该发生

if err != nil {
  return err
}

错误处理不带包装

相反,我们应该总是用新的错误包装错误,为调用者提供更多的上下文,如下所示:

if err != nil {
  return errors.Wrap(err, "read failed")
}

带有包装的错误处理

这种观点我完全不接受。我首先听到支持Go错误处理机制的观点之一是它的性能远超于基于try-catch的机制。我尝试了一下,发现确实如此。在try-catch环境中,导致性能低下的主要原因之一是每当抛出异常时,都会在调用堆栈的每个级别创建完整的堆栈跟踪和异常信息。在Go中,沿着整个调用堆栈用一个错误包装另一个错误,然后对所有创建的对象进行垃圾回收几乎同样昂贵。更不用说手动编码了。如果这是你所提倡的,那么你最初应该选择一个try-catch环境。

堆栈跟踪在调查错误和缺陷时非常有用,但在其他时间它们是高度昂贵的冗余信息。也许一个完美的错误处理机制应该有一个开关,可以在需要时打开和关闭。🤷‍♀️

第二点实际上与错误处理机制本身无关,而与约定有关。我们在上一段讨论了错误处理的性能。[哨兵错误](https://dave./2016/04/27/dont-just-check-errors-handle-them-gracefully#sentinel errors)为Go的错误处理提供了另一个性能提升。我建议阅读有关它们的更多信息,但简而言之,当某个错误的所有出现都包含相同的信息时,您可以简单地创建一个单独的错误对象实例,而不是在每次出现时都生成新的对象。使用单例对象来表示错误。我们称之为哨兵错误。它们防止了不必要的分配垃圾收集。除了性能提升,错误比较也变得更简单。由于这些错误值是单例的,所以你可以简单地使用一个简单的等号运算符比较它们的值,而不是比较它们的类型。

var NetworkError = errors.New("oops...network error")

func IsNetworkError(err errorbool {
 return err == NetworkError
}

哨兵错误

注意这种比较方式并不需要类型检查。这工作得很好——它读写都很好,性能也很好。👏🤝

然而,有时候我们不能使用哨兵错误。有时我们希望错误消息包含与错误的特定出现相关的信息。(例如,"oops...网络错误 <根本原因>")。这意味着我们必须每次都实例化一个新的错误对象。此时,由于缺乏稳定的约定,我们只是这样做:

return errors.New("oops...network error " + rootCause)
// or
return fmt.Errorf("oops...network error %s", rootCause)

偷懒地创建非哨兵错误

确实是个意外。🤦‍♀️因为这个错误没有特定的类型或实例,我们现在失去了有效检查此类型错误的能力。

err := doSomething()
if /* err is network error */ { // 🤔 how can we tell?
  // handle network error
else if err != nil {
  // handle other errors
}

我们如何区分网络错误?

我们唯一的选择是检查错误消息本身上的 strings.Contains

err := doSomething()
if strings.Contains("oops...network error") {
  // handle network error
else if err != nil {
  // handle other errors
}

我们处理错误的唯一选择太糟糕了

这在性能方面是很糟糕的。但更可怕的是,它并不是保证性的。很明显,"oops...network error" 可以随时改变,而编译器也不能帮助我们。当包的作者决定将消息更改为 "oops...there has been a network error" 时,我的错误处理逻辑就破裂了,你一定是在开玩笑。🤷‍♀️🤦‍♀️ 在任何其他语言中,你也可以这样做。例如,在Java中,你可以 throw new Exception("oops...network error")。但在一个小型创业公司的内部代码中,它很可能不会通过代码审查。然而,在Go中,它在由大型组织维护的巨大的开源库中通过了代码审查(例如google的protobuf)。我个人发现自己回到了字符串包含检查,感到很不舒服,用了不止一个主要的开源库。🤢🤒

无论是通过强烈的、坚实的惯例,还是通过编译器强制:errors.New 和 fmt.Errorf 只应该用于创建标志性错误。返回的任何其他错误都必须声明一个专用的、导出的类型,以允许合理的处理。作为库的作者,如果我们忽略这样做,我们就冒着损害我们的消费者的代码安全性和完整性的风险。

这里有一个关于错误处理的语言提议 ,它提出了添加一个 ? 操作符。值得一提的是,这个提议在2018年因为过热的讨论而关闭,从未重新开启。我猜有些事情最好不处理。😂

异步返回值

Go中有许多同步机制,其中一些是原生的,一些是由Go SDK提供的。其他的则可以在许多开源库中找到。尽管如此,你可以说Go代码通常由两个主要的同步机制主导——通道(原生)和 WaitGroups(由SDK提供)。这两个是强大的机制,可以支持实现每一个可能的并发流程。WaitGroups 允许同步不同线程的执行时间,而通道允许这样的同步并在线程之间传递值。

每个机制都有其固定的用途,但还有另一个用途尚未被涵盖,我认为这第三种用途通常是最佳实践。为了看到它的实际应用,让我们考虑这个非常受欢迎的例子:我们想要并发地获取几个资源,然后合并结果。让我们首先使用 WaitGroups 来实现它。

func fetchResourcesFromURLs(urls []string) ([]stringerror) {
  var wg sync.WaitGroup
  var lock sync.Mutex
  var firstError error
  result := make([]stringlen(urls))
  for i, url := range urls {
     wg.Add(1)
     go func(i int, url string) {
        resource, err := fetchResource(url)
        result[i] = resource
        if err != nil {
           lock.Lock()
           if firstError == nil {
              firstError = err
           }
           lock.Unlock()
        }
        wg.Done()
     }(i, url)
  }
  wg.Wait()
  if firstError != nil {
     return nil, firstError
  }
  return result, nil
}

func fetchResource(url string) (stringerror) {
  // some I/O operation...
}

首次尝试:使用 sync.WaitGroup

这有点明确,像Go通常那样,但它确实有效。我们必须自己处理错误的事实对Go团队来说似乎过于明确,所以他们发布了一个名为 ErrGroup 的 WaitGroup 的额外变体,简化了错误处理:

func fetchResourcesFromURLs(urls []string) ([]stringerror) {
  var group errgroup.Group
  result := make([]stringlen(urls))
  for i, url := range urls {
     func(i int, url string) {
        group.Go(func() error {
           resource, err := fetchResource(url)
           if err != nil {
              return err
           }
           result[i] = resource
           return nil
        })
     }(i, url)
  }
  err := group.Wait()
  if err != nil {
     return nil, err
  }
  return result, nil
}

func fetchResource(url string) (stringerror) {
  // some I/O operation...
}

第二次尝试:使用 errgroup.Group

ErrGroups 聚合错误并简化错误处理,同时,它们支持 contexts 以允许统一的超时和取消,以及并发限制。 显然,这是一个更复杂的同步机制,但是,它仍然有两个显著的缺点:我们仍然必须以某种方式同步返回值,由于 ErrGroup.Go 函数的签名,我们必须用一个不接收参数的函数包装我们的并发函数。而且由于Go不支持缩短的lambda表达式(此处有活跃的提案),它变得更加明确且可读性更差。

上述的第一个缺点可以并且应该用泛型来解决,第二个仍然存在。对于我们在并发函数中既不接受也不返回任何东西的情况,这是一个完美的机制,但这些情况非常少见。

我们继续讨论通道:

func fetchResourcesFromURLs(urls []string) ([]stringerror) {
  ch := make(chan *singleResult, len(urls))
  for _, url := range urls {
     go func(url string) {
        resource, err := fetchResource(url)
        ch <- &singleResult{
           resource: resource,
           err:       err,
        }
     }(url)
  }
  result := make([]stringlen(urls))
  for i := 0; i < len(urls); i++ {
     res := <-ch
     if res.err != nil {
        return nil, res.err
     }
     result[i] = res.resource
  }
  return result, nil
}

type singleResult struct {
  resource string
  err       error
}

func fetchResource(url string) (stringerror) {
  // some I/O operation...
}

第三次尝试:使用通道

对于这样一个常见的用例,它仍然相当明确。有很大的机会犯错误,导致竞态条件和死锁

现在想象一下,如果 go 关键字不仅启动一个新的 goroutine,而且还返回一个对象来跟踪它,允许我们等待被调用的函数并获取其返回的值。让我们将其称为 promise[T]

func fetchResourcesFromURLs(urls []string) ([]stringerror) {
  all := make([]promise[string], len(urls))
  for i, url := range urls {
     all[i] = go fetchResource(url)
  }
  var result []string
  for _, p := range all {
     resource, err := p.Wait()
     if err != nil {
        return nil, err
     }
     result = append(result, resource)
  }
  return result, nil
}

func fetchResource(url string) (stringerror) {
  // some I/O operation...
}

虚构的 promise 语法

啊,这更好了。这类对象通常被称为 futures 或 promises,大多数流行的语言都支持它们。在 all[i] = go fetchResource(url) 中,我们填充了一个 promise 切片,每一个都跟踪不同 goroutine 的执行和返回值。然后我们等待它们全部完成,并在出现错误时失败。(这样的操作通常有原生支持)。

这最后一个代码片段是虚构的。它在 Go 中不存在。但如果它存在的话,它不仅会更加易于阅读,而且会更安全。由于 WaitGroups 和通道不与返回值一起工作,promise 类似的机制有两个主要优势

首先,WaitGroups 和通道需要某种实现来同步结果。使用 WaitGroups 时,我们可能会发现自己在传递指针或使用像上面例子中的分片切片。有时我们还需要一个额外的互斥量。使用通道时,我们必须创建一个缓冲通道。如果缓冲大小设置错误,就会发生死锁。无论哪种方式,使用 promise 类似的机制都能避免潜在的风险

第二个优势在于纯函数编程。纯函数使用返回值而不产生副作用。这意味着我们可以依赖编译器来确保我们不创建死锁。例如,当使用通道时,我们必须接收一个通道参数并显式地用结果调用它。例如:

func fetchResources(url string, ch chan *singleResult) {
  data, err := httpGet(url)
  ch <- &singleResult{
     resources: data,
     err:       err,
  }
}

不能在处理显式同步机制时编写纯函数

六个月后,另一个工程师(显然,永远不是我们,总是另一个工程师 🤨)很容易犯一个错误,添加一个if语句然后返回,忘记显式调用通道。这导致死锁。显然,这里没有编译错误。

func fetchResources(url string, ch chan *singleResult) {
  if url == "" {
     return
  }
  data, err := httpGet(url)
  ch <- &singleResult{
     resources: data,
     err:       err,
  }
}

非纯函数用于并发是死锁陷阱

使用纯函数时,你必须在控制流分支时指定返回值。换句话说,其他工程师简单地不能造成死锁

func fetchResources(url string) (stringerror) {
  if url == "" {
     return // compilation error: not enough arguments to return
  }
  data, err := httpGet(url)
  return data, err
}

使用纯函数不可能发生死锁

此外,纯函数形式是完全可复用的。这只是一个执行操作并返回结果的函数。它对任何特定的同步机制都不熟悉。它不接收通道或WaitGroups,也不执行任何明确的同步。Go简单地照顾其他一切。

在我看来,缺乏坚固的社区约定与无效的异步返回值机制结合在一起是糟糕编码的根本原因,而这在Go社区中几乎是一种标准。[对于这一段可能伤害到的每一个人我深深地表示歉意]。一些例子?Go中最受欢迎的HTTP框架之一有一个400行的函数 😨,Google的gRPC库里有一个100行的函数吗?官方的MongoDB驱动中有一个带有嵌套2级while-true循环和其中的go-to语句的66行函数吗?!😵‍💫😵‍💫😵‍💫

这些只是快速Google搜索中首先出现的例子。看看它们都有什么共同点?它们结合了复杂的for循环或switch case与defergo func语句。换句话说,由于在Go中同步异步返回值需要传递指针和创建互斥锁,所以最容易的方法是在一个大函数内写下所有内容,并通过在嵌套lambda函数的闭包中捕获它们来避免传递它们。这听起来像是维护这些冗长函数的主要问题,但显然,在go func语句中捕获闭包变量也是Go代码中数据竞争的头号原因,根据Uber工程师完成的一项非常有趣的研究

对于执行不返回任何值的异步或并发操作WaitGroupsErrGroups是一个很好的选择。当处理消费者-生产者用例或使用select语句等待几个并发事件时,通道是一个完美的选择。但是,由于大多数异步调用的用例都产生返回值并需要错误处理,我猜如果Go支持了类似于promise的机制,它本来就是Gophers最受欢迎的选择。但我希望我已经展示出对于这样的用例,这也是最安全的一个

这里有一些建议(12)添加这样的机制到语言中。后者提议将通道别名为futures。老实说,我不在乎我们叫它们什么。我甚至喜欢重用通道而不是引入一个新概念的想法。我只是想go关键字返回一个对象,允许我跟踪执行的函数的返回值

总结

如果你将社区的约定和命名问题与异步返回值问题结合起来,你会得到一些非常受欢迎的库,它们使用复杂的、超过100行的函数、使用一个字母的未记录的变量,这些变量被声明在包的另一侧。这是非常难以阅读和维护的,而且出奇地常见。此外,与其他现代语言不同,Go不提供任何类型的运行时值安全性。这导致了许多与值相关的运行时问题,这些问题很容易避免。

在系列的最后一篇文章中,我们将讨论我们可以做些什么来改进它,以及为Go设计一个更好的未来的提议。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多