Go 编程语言:Golang 入门教程

已发表: 2022-03-11

什么是 Go 编程语言?

相对较新的 Go 编程语言整齐地位于风景的中间,提供了许多好的特性并故意省略了许多不好的特性。 它编译速度快,运行速度快,包括运行时和垃圾收集,具有简单的静态类型系统和动态接口,以及优秀的标准库。 这就是为什么这么多开发人员热衷于学习 Go 编程的原因。

Golang 教程:logo 插图

Go 和 OOP

OOP 是 Go 故意忽略的那些特性之一。 它没有子类,因此没有继承菱形或超级调用或虚拟方法来绊倒你。 尽管如此,OOP 的许多有用部分仍然可以通过其他方式获得。

*Mixins* 可以通过匿名嵌入结构来获得,允许直接在包含结构上调用它们的方法(参见嵌入)。 以这种方式提升方法称为 *forwarding*,它与子类化不同:该方法仍将在内部嵌入结构上调用。

嵌入也不意味着多态性。 虽然`A`可能`B`,但这并不意味着它`B`——接受`B`的函数不会接受`A`。 为此,我们需要接口,稍后我们将简要介绍。

同时,Golang 对可能导致混乱和错误的功能采取了强有力的立场。 它省略了 OOP 惯用语,例如继承和多态,有利于组合和简单的接口。 它淡化了异常处理,以支持返回值中的显式错误。 由gofmt工具强制执行的 Go 代码布局只有一种正确的方法。 等等。

为什么要学习 Golang?

Go 也是编写并发程序的好语言:具有许多独立运行部分的程序。 一个明显的例子是网络服务器:每个请求都单独运行,但请求通常需要共享资源,例如会话、缓存或通知队列。 这意味着熟练的 Go 程序员需要处理对这些资源的并发访问。

虽然 Golang 具有一组出色的低级并发处理功能,但直接使用它们可能会变得复杂。 在许多情况下,对这些低级机制进行一些可重用的抽象会使生活变得更加轻松。

在今天的 Go 编程教程中,我们将看到一个这样的抽象:一个可以将任何数据结构转换为事务服务的包装器。 我们将使用Fund类型作为示例——我们的初创公司剩余资金的简单存储,我们可以在其中检查余额并进行提款。

为了在实践中证明这一点,我们将分小步构建服务,一路上弄得一团糟,然后再次清理它。 随着我们学习 Go 教程的进展,我们会遇到很多很酷的 Go 语言特性,包括:

  • 结构类型和方法
  • 单元测试和基准测试
  • Goroutines 和通道
  • 接口和动态类型

建立一个简单的基金

让我们编写一些代码来跟踪我们创业公司的资金。 基金从给定余额开始,只能提取资金(我们稍后会计算收入)。

此图描绘了一个使用 Go 编程语言的简单 goroutine 示例。

Go 故意不是一种面向对象的语言:没有类、对象或继承。 相反,我们将声明一个名为Fund结构类型,它具有一个创建新基金结构的简单函数和两个公共方法。

基金.go

 package funding type Fund struct { // balance is unexported (private), because it's lowercase balance int } // A regular function returning a pointer to a fund func NewFund(initialBalance int) *Fund { // We can return a pointer to a new struct without worrying about // whether it's on the stack or heap: Go figures that out for us. return &Fund{ balance: initialBalance, } } // Methods start with a *receiver*, in this case a Fund pointer func (f *Fund) Balance() int { return f.balance } func (f *Fund) Withdraw(amount int) { f.balance -= amount }

使用基准测试

接下来我们需要一种方法来测试Fund 。 我们不会编写单独的程序,而是使用 Go 的测试包,它为单元测试和基准测试提供了一个框架。 Fund中的简单逻辑并不真正值得为其编写单元测试,但由于我们稍后会讨论很多关于对 Fund 的并发访问,因此编写基准测试是有意义的。

基准测试类似于单元测试,但包含一个多次运行相同代码的循环(在我们的例子中是fund.Withdraw(1) )。 这允许框架计算每次迭代需要多长时间,平均来自磁盘寻道、缓存未命中、进程调度和其他不可预测因素的瞬时差异。

测试框架希望每个基准测试至少运行 1 秒(默认情况下)。 为了确保这一点,它会多次调用基准测试,每次都传入一个不断增加的“迭代次数”值( bN字段),直到运行至少需要一秒钟。

目前,我们的基准只会存入一些钱,然后一次提取一美元。

fund_test.go

 package funding import "testing" func BenchmarkFund(b *testing.B) { // Add as many dollars as we have iterations this run fund := NewFund(bN) // Burn through them one at a time until they are all gone for i := 0; i < bN; i++ { fund.Withdraw(1) } if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }

现在让我们运行它:

 $ go test -bench . funding testing: warning: no tests to run PASS BenchmarkWithdrawals 2000000000 1.69 ns/op ok funding 3.576s

那进展顺利。 我们运行了 20 亿(!)次迭代,最后对余额的检查是正确的。 我们可以忽略“no tests to run”警告,它指的是我们没有编写的单元测试(在本教程后面的 Go 编程示例中,警告被剪掉了)。

Go 中的并发访问

现在让基准测试并发,模拟不同的用户同时提款。 为此,我们将生成 10 个 goroutine,并让它们中的每一个提取十分之一的资金。

我们将如何在 Go 语言中构建多个并发 goroutine?

Goroutines 是 Go 语言中并发的基本构建块。 它们是绿色线程——由 Go 运行时而不是操作系统管理的轻量级线程。 这意味着您可以运行数千个(或数百万个)它们而无需任何显着开销。 Goroutine 使用go关键字生成,并且总是以函数(或方法调用)开头:

 // Returns immediately, without waiting for `DoSomething()` to complete go DoSomething()

通常,我们希望只用几行代码就生成一个简短的一次性函数。 在这种情况下,我们可以使用闭包代替函数名:

 go func() { // ... do stuff ... }() // Must be a function *call*, so remember the ()

一旦我们所有的 goroutine 都生成了,我们需要一种方法来等待它们完成。 我们可以使用channels自己构建一个,但我们还没有遇到过这些,所以这就跳过了。

现在,我们可以只使用 Go 标准库中的WaitGroup类型,它就是为此目的而存在的。 我们将创建一个(称为“ wg ”)并在生成每个 worker 之前调用wg.Add(1)来跟踪有多少。 然后工作人员将使用wg.Done()进行报告。 同时在主 goroutine 中,我们可以说wg.Wait()来阻塞,直到每个工人都完成。

在下一个示例中的工作 goroutine 中,我们将使用defer来调用wg.Done()

defer接受一个函数(或方法)调用,并在当前函数返回之前立即运行它,在其他所有操作完成之后。 这对于清理很方便:

 func() { resource.Lock() defer resource.Unlock() // Do stuff with resource }()

通过这种方式,我们可以轻松地将Unlock与其Lock匹配,以提高可读性。 更重要的是,即使在主函数中出现恐慌(我们可以在其他语言中通过 try-finally 处理),延迟函数也会运行。

最后,延迟函数将按照它们被调用的相反顺序执行,这意味着我们可以很好地进行嵌套清理(类似于嵌套gotolabel的 C 习语,但更整洁):

 func() { db.Connect() defer db.Disconnect() // If Begin panics, only db.Disconnect() will execute transaction.Begin() defer transaction.Close() // From here on, transaction.Close() will run first, // and then db.Disconnect() // ... }()

好的,说了这么多,这是新版本:

fund_test.go

 package funding import ( "sync" "testing" ) const WORKERS = 10 func BenchmarkWithdrawals(b *testing.B) { // Skip N = 1 if bN < WORKERS { return } // Add as many dollars as we have iterations this run fund := NewFund(bN) // Casually assume bN divides cleanly dollarsPerFounder := bN / WORKERS // WaitGroup structs don't need to be initialized // (their "zero value" is ready to use). // So, we just declare one and then use it. var wg sync.WaitGroup for i := 0; i < WORKERS; i++ { // Let the waitgroup know we're adding a goroutine wg.Add(1) // Spawn off a founder worker, as a closure go func() { // Mark this worker done when the function finishes defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { fund.Withdraw(1) } }() // Remember to call the closure! } // Wait for all the workers to finish wg.Wait() if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }

我们可以预测这里会发生什么。 工人将在彼此之上执行Withdraw 。 在其中, f.balance -= amount将读取余额,减一,然后将其写回。 但有时两个或多个工人会同时读取相同的余额,并进行相同的减法运算,我们最终会得到错误的总数。 正确的?

 $ go test -bench . funding BenchmarkWithdrawals 2000000000 2.01 ns/op ok funding 4.220s

不,还是过去了。 这里发生了什么?

请记住,goroutine 是绿色线程——它们由 Go 运行时管理,而不是由操作系统管理。 运行时在它可用的许多 OS 线程中调度 goroutine。 在编写这个 Go 语言教程时,Go 并没有试图猜测它应该使用多少个 OS 线程,如果我们想要多个,我们必须这样说。 最后,当前运行时不会抢占 goroutine —— goroutine 将继续运行,直到它执行一些表明它已准备好中断的操作(例如与通道交互)。

所有这一切意味着虽然我们的基准测试现在是并发的,但它不是并行的。 一次只有一名工人会运行,并且会一直运行直到完成。 我们可以通过GOMAXPROCS环境变量告诉 Go 使用更多线程来改变这一点。

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 account_test.go:39: Balance wasn't zero: 4238 ok funding 0.007s

这样更好。 正如我们预期的那样,现在我们显然正在失去一些提款。

在这个 Go 编程示例中,多个并行 goroutine 的结果是不利的。

使其成为服务器

在这一点上,我们有多种选择。 我们可以在基金周围添加一个明确的互斥锁或读写锁。 我们可以使用带有版本号的比较和交换。 我们可以全力以赴并使用 CRDT 方案(也许用每个客户的交易列表替换balance字段,并从中计算余额)。

但我们现在不会做任何这些事情,因为它们很乱或很可怕,或者两者兼而有之。 相反,我们将决定一个基金应该是一个服务器。 什么是服务器? 这是你谈话的东西。 在 Go 中,事物通过渠道进行对话。

通道是 goroutine 之间的基本通信机制。 值被发送到通道( channel <- value ),并且可以在另一端接收( value = <- channel )。 通道是“goroutine 安全的”,这意味着任意数量的 goroutine 可以同时向它们发送和接收。

缓冲

在某些情况下,缓冲通信通道可能是一种性能优化,但应谨慎使用(并进行基准测试!)。

但是,缓冲通道的用途并非直接与通信有关。

例如,一个常见的节流习惯用法会创建一个(例如)缓冲区大小为“10”的通道,然后立即向其中发送十个令牌。 然后生成任意数量的工作 goroutine,每个工作 goroutine 在开始工作之前从通道接收一个令牌,然后将其发送回来。 然后,无论有多少工人,只有十个人会同时工作。

默认情况下,Go 通道是无缓冲的。 这意味着向通道发送值将被阻塞,直到另一个 goroutine 准备好立即接收它。 Go 还支持通道的固定缓冲区大小(使用make(chan someType, bufferSize) )。 但是,对于正常使用,这通常是一个坏主意

想象一下我们基金的网络服务器,每个请求都会在其中提款。 当事情非常繁忙时, FundServer将无法跟上,尝试发送到其命令通道的请求将开始阻塞并等待。 此时,我们可以在服务器中强制执行最大请求计数,并向超过该限制的客户端返回一个合理的错误代码(如503 Service Unavailable )。 当服务器超载时,这是可能的最佳行为。

向我们的通道添加缓冲会使这种行为的确定性降低。 基于客户端更早看到的信息(也许对于上游已经超时的请求),我们很容易得到一长串未处理的命令。 这同样适用于许多其他情况,例如当接收方无法跟上发送方时通过 TCP 施加背压。

无论如何,对于我们的 Go 示例,我们将坚持使用默认的无缓冲行为。

我们将使用一个通道向我们的FundServer发送命令。 每个基准测试工作者都会向通道发送命令,但只有服务器会接收它们。

我们可以将 Fund 类型直接转换为服务器实现,但这会很麻烦——我们将混合并发处理和业务逻辑。 相反,我们将完全保留 Fund 类型,并使FundServer成为一个单独的包装器。

像任何服务器一样,包装器将有一个主循环,它在其中等待命令,并依次响应每个命令。 这里还有一个细节需要说明:命令的类型。

本 Go 编程教程中用作服务器的基金图。

指针

我们可以让我们的命令通道将 *pointers* 指向命令 (`chan *TransactionCommand`)。 为什么我们没有?

在 goroutine 之间传递指针是有风险的,因为任何一个 goroutine 都可能修改它。 它的效率通常也较低,因为另一个 goroutine 可能运行在不同的 CPU 内核上(意味着更多的缓存失效)。

只要有可能,更喜欢传递普通值。

在下面的下一节中,我们将发送几个不同的命令,每个命令都有自己的结构类型。 我们希望服务器的命令通道接受其中的任何一个。 在 OOP 语言中,我们可以通过多态来做到这一点:让通道采用超类,其中各个命令类型是子类。 在 Go 中,我们使用接口代替。

接口是一组方法签名。 实现所有这些方法的任何类型都可以被视为该接口(无需声明这样做)。 对于我们的第一次运行,我们的命令结构实际上不会公开任何方法,因此我们将使用空接口interface{} 。 由于它没有要求,任何值(包括像整数这样的原始值)都满足空接口。 这并不理想——我们只想接受命令结构——但我们稍后会回到它。

现在,让我们开始为我们的 Go 服务器搭建脚手架:

服务器.go

 package funding type FundServer struct { Commands chan interface{} fund Fund } func NewFundServer(initialBalance int) *FundServer { server := &FundServer{ // make() creates builtins like channels, maps, and slices Commands: make(chan interface{}), fund: NewFund(initialBalance), } // Spawn off the server's main loop immediately go server.loop() return server } func (s *FundServer) loop() { // The built-in "range" clause can iterate over channels, // amongst other things for command := range s.Commands { // Handle the command } }

现在让我们为命令添加几个 Golang 结构类型:

 type WithdrawCommand struct { Amount int } type BalanceCommand struct { Response chan int }

WithdrawCommand只包含要提取的金额。 没有回应。 BalanceCommand确实有响应,因此它包含一个发送它的通道。 这可确保响应始终到达正确的位置,即使我们的基金后来决定无序响应。

现在我们可以编写服务器的主循环:

 func (s *FundServer) loop() { for command := range s.Commands { // command is just an interface{}, but we can check its real type switch command.(type) { case WithdrawCommand: // And then use a "type assertion" to convert it withdrawal := command.(WithdrawCommand) s.fund.Withdraw(withdrawal.Amount) case BalanceCommand: getBalance := command.(BalanceCommand) balance := s.fund.Balance() getBalance.Response <- balance default: panic(fmt.Sprintf("Unrecognized command: %v", command)) } } }

唔。 这有点丑我们正在打开命令类型,使用类型断言,并且可能会崩溃。 无论如何,让我们继续前进并更新基准以使用服务器。

 func BenchmarkWithdrawals(b *testing.B) { // ... server := NewFundServer(bN) // ... // Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { server.Commands <- WithdrawCommand{ Amount: 1 } } }() } // ... balanceResponseChan := make(chan int) server.Commands <- BalanceCommand{ Response: balanceResponseChan } balance := <- balanceResponseChan if balance != 0 { b.Error("Balance wasn't zero:", balance) } }

这也有点难看,尤其是当我们检查余额时。 没关系。 让我们尝试一下:

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 465 ns/op ok funding 2.822s

更好的是,我们不再丢失提款。 但是代码越来越难以阅读,并且存在更严重的问题。 如果我们发出BalanceCommand然后忘记读取响应,我们的基金服务器将永远阻止尝试发送它。 让我们清理一下。

使其成为服务

服务器是您与之交谈的东西。 什么是服务? 服务是您使用 API与之交谈的东西。 我们不会让客户端代码直接与命令通道一起工作,而是使通道不导出(私有)并将可用命令包装在函数中。

 type FundServer struct { commands chan interface{} // Lowercase name, unexported // ... } func (s *FundServer) Balance() int { responseChan := make(chan int) s.commands <- BalanceCommand{ Response: responseChan } return <- responseChan } func (s *FundServer) Withdraw(amount int) { s.commands <- WithdrawCommand{ Amount: amount } }

现在我们的基准可以说server.Withdraw(1)balance := server.Balance() ,并且意外发送无效命令或忘记读取响应的可能性较小。

以下是在这个 Go 语言示例程序中使用基金作为服务的样子。

这些命令还有很多额外的样板文件,但我们稍后会回来讨论。

交易

最终,钱总是用完。 让我们同意,当我们的基金降至最后十美元时,我们将停止提取,并将这笔钱花在公共比萨饼上以庆祝或同情。 我们的基准将反映这一点:

 // Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { // Stop when we're down to pizza money if server.Balance() <= 10 { break } server.Withdraw(1) } }() } // ... balance := server.Balance() if balance != 10 { b.Error("Balance wasn't ten dollars:", balance) }

这次我们真的可以预测结果了。

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 fund_test.go:43: Balance wasn't ten dollars: 6 ok funding 0.009s

我们又回到了开始的地方——几个工人可以一次读取余额,然后全部更新。 为了解决这个问题,我们可以在基金本身中添加一些逻辑,例如minimumBalance属性,或者添加另一个名为WithdrawIfOverXDollars的命令。 这些都是可怕的想法。 我们的协议是我们之间的协议,而不是基金的财产。 我们应该将其保留在应用程序逻辑中。

我们真正需要的是事务,就像数据库事务一样。 由于我们的服务一次只执行一个命令,这非常容易。 我们将添加一个包含回调(闭包)的Transact命令。 服务器将在其自己的 goroutine 中执行该回调,并传入原始Fund 。 然后回调可以安全地对Fund做任何它喜欢的事情。

信号量和错误

在下一个示例中,我们做错了两件小事。

首先,我们使用“完成”通道作为信号量,让调用代码知道其事务何时完成。 很好,但为什么通道类型是“bool”? 我们只会向它发送 `true` 来表示“完成”(发送 `false` 甚至意味着什么?)。 我们真正想要的是一个单一状态的值(一个没有价值的值?)。 在 Go 中,我们可以使用空结构类型:`struct{}` 来做到这一点。 这还具有使用较少内存的优点。 在示例中,我们将坚持使用 `bool`,以免看起来太吓人。

其次,我们的交易回调没有返回任何东西。 正如我们稍后将看到的,我们可以使用范围技巧从回调中获取值到调用代码中。 然而,真实系统中的事务有时可能会失败,所以 Go 的约定是让事务返回一个“错误”(然后在调用代码中检查它是否为“nil”)。

我们现在也没有这样做,因为我们没有任何错误要生成。
 // Typedef the callback for readability type Transactor func(fund *Fund) // Add a new command type with a callback and a semaphore channel type TransactionCommand struct { Transactor Transactor Done chan bool } // ... // Wrap it up neatly in an API method, like the other commands func (s *FundServer) Transact(transactor Transactor) { command := TransactionCommand{ Transactor: transactor, Done: make(chan bool), } s.commands <- command <- command.Done } // ... func (s *FundServer) loop() { for command := range s.commands { switch command.(type) { // ... case TransactionCommand: transaction := command.(TransactionCommand) transaction.Transactor(s.fund) transaction.Done <- true // ... } } }

我们的事务回调不直接返回任何东西,但是 Go 语言可以很容易地直接从闭包中获取值,所以我们将在基准测试中这样做,以便在资金不足时设置pizzaTime标志:

 pizzaTime := false for i := 0; i < dollarsPerFounder; i++ { server.Transact(func(fund *Fund) { if fund.Balance() <= 10 { // Set it in the outside scope pizzaTime = true return } fund.Withdraw(1) }) if pizzaTime { break } }

并检查它是否有效:

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 775 ns/op ok funding 4.637s

无非交易

您现在可能已经发现了一个清理更多内容的机会。 由于我们有一个通用的Transact命令,我们不再需要WithdrawCommandBalanceCommand 。 我们将根据事务重写它们:

 func (s *FundServer) Balance() int { var balance int s.Transact(func(f *Fund) { balance = f.Balance() }) return balance } func (s *FundServer) Withdraw(amount int) { s.Transact(func (f *Fund) { f.Withdraw(amount) }) }

现在服务器接受的唯一命令是TransactionCommand ,所以我们可以在它的实现中移除整个interface{}混乱,让它只接受事务命令:

 type FundServer struct { commands chan TransactionCommand fund *Fund } func (s *FundServer) loop() { for transaction := range s.commands { // Now we don't need any type-switch mess transaction.Transactor(s.fund) transaction.Done <- true } }

好多了。

我们可以在这里采取最后一步。 除了对BalanceWithdraw的便利功能外,服务实现不再与Fund绑定。 它可以管理interface{}并用于包装任何东西,而不是管理Fund 。 但是,每个事务回调都必须将interface{}转换回实际值:

 type Transactor func(interface{}) server.Transact(func(managedValue interface{}) { fund := managedValue.(*Fund) // Do stuff with fund ... })

这是丑陋且容易出错的。 我们真正想要的是编译时泛型,因此我们可以为特定类型(如*Fund )“模板化”服务器。

不幸的是,Go 还不支持泛型。 一旦有人为它找出一些合理的语法和语义,它就会最终出现。 同时,仔细的接口设计通常会消除对泛型的需求,如果不需要,我们可以使用类型断言(在运行时检查)。

我们完了吗?

是的。

嗯,好吧,不。

例如:

  • 事务中的恐慌将杀死整个服务。

  • 没有超时。 永远不会返回的事务将永远阻塞服务。

  • 如果我们的 Fund 增长了一些新字段并且交易在更新它们的过程中崩溃了,我们就会有不一致的状态。

  • 交易能够泄露托管的Fund对象,这是不好的。

  • 没有合理的方法可以跨多个基金进行交易(例如从一个基金中提取并存入另一个基金)。 我们不能仅仅嵌套我们的事务,因为它会允许死锁。

  • 异步运行事务现在需要一个新的 goroutine 和很多麻烦。 与此相关的是,我们可能希望能够在长期交易正在进行时从其他地方读取最新的Fund状态。

在我们的下一个 Go 编程语言教程中,我们将研究解决这些问题的一些方法。

相关:结构良好的逻辑:Golang OOP 教程