精通-Go-并发(全)

精通 Go 并发(全)

原文:zh.annas-archive.org/md5/5C14031AC553348345D455C9E701A474

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我就是喜欢新的编程语言。也许是因为对现有语言的熟悉和厌倦,对现有工具、语法、编码约定和性能的挫败感。也许我只是在寻找那个“统治它们所有”的语言。无论原因是什么,每当有新的或实验性的语言发布时,我都会迫不及待地去尝试。

这是一个新语言和语言设计的黄金时代。想想看:C 语言是在 20 世纪 70 年代初发布的——那个时候资源是如此稀缺,以至于冗长、清晰和语法逻辑经常被节俭所取代。今天我们使用的大多数语言要么是在这个时代原创写成的,要么直接受到了这些语言的影响。

自 20 世纪 80 年代末和 90 年代初以来,强大的新语言和范式——Perl、Python、Ruby、PHP 和 JavaScript——已经成为了一个不断扩大的用户群体,并成为最受欢迎的语言之一(与 C、C++和 Java 等老牌语言一样)。多线程、内存缓存和 API 使多个进程、不同的语言、应用程序,甚至是不同的操作系统能够协同工作。

虽然这很棒,但直到最近,有一个领域一直未得到很好的满足:强大的、编译的、跨平台的语言,支持并发,面向系统程序员。

很少有语言符合这些参数。当然,已经有一些低级语言满足了其中的一些特征。Erlang 和 Haskell 在功能和语言设计方面都符合要求,但作为函数式语言,对于从 C/Java 背景转向系统程序员来说,它们构成了一个学习障碍。Objective-C 和 C#相对容易、强大,并且支持并发,但它们与特定操作系统绑定,使得为其他平台编程变得困难。我们刚提到的语言(Python、JavaScript 等)虽然非常流行,但它们大多是解释性语言,将性能放在了次要位置。你可以用它们中的大多数来进行系统编程,但在许多方面,这就像是把方形木栓塞进圆孔。因此,当谷歌在 2009 年宣布推出 Go 时,我的兴趣被激起了。当我看到是谁在项目背后(稍后会详细介绍),我感到非常高兴。当我看到这种语言及其设计的实际运行时,我感到非常幸福。

在过去的几年里,我一直在使用 Go 来替换之前用 C、Java、Perl 和 Python 编写的系统应用程序。我对结果非常满意。几乎在每一个实例中,使用 Go 都改进了这些应用程序。它与 C 的良好兼容性是系统程序员寻求尝试 Go 的另一个巨大卖点。

有一些最优秀的语言设计(以及编程一般)的大脑支持,Go 有着光明的未来。

多年来——实际上是几十年来——编写服务器和网络接口的选择一直不多。如果你被要求编写一个,你可能会选择 C、C++或 Java。虽然它们当然可以处理这个任务,而且它们现在都以某种方式支持并发和并行,但它们并不是为此而设计的。

谷歌汇集了一支团队,其中包括一些编程界的巨头——贝尔实验室的 Rob Pike 和 Ken Thompson 以及曾参与谷歌 JavaScript 实现 V8 的 Robert Griesemer——设计了一种现代的、并发的语言,开发便利性是首要考虑的。

为了做到这一点,团队专注于一些替代方案中的一些痛点,具体如下:

  • 动态类型的语言在最近几年变得非常流行。Go 避开了 Java 或 C++的显式“繁琐”类型系统。Go 使用类型推断,这节省了开发时间,但仍然是强类型的。

  • 并发性、并行性、指针/内存访问和垃圾回收在上述语言中都很难处理。Go 让这些概念可以像你想要或需要的那样简单或复杂。

  • 作为一种较新的语言,Go 专注于多核设计,这在 C++等语言中是必要的事后考虑。

  • Go 的编译器速度超快;它的速度非常快,以至于有一些实现将 Go 代码视为解释执行。

  • 尽管 Google 设计 Go 是一种系统语言,但它足够多才多艺,可以以多种方式使用。当然,对先进、廉价的并发性的关注使其成为网络和系统编程的理想选择。

  • Go 的语法比较宽松,但使用上比较严格。这意味着 Go 会让你在一些词法标记上有点懒散,但你仍然必须编写基本紧凑的代码。由于 Go 提供了一个格式化工具来尝试澄清你的代码,因此在编码时你也可以花更少的时间来关注可读性问题。

本书涵盖的内容

第一章,“Go 中并发的介绍”,介绍了 goroutines 和通道,并将比较 Go 处理并发的方式与其他语言的方法。我们将利用这些新概念构建一些基本的并发应用程序。

第二章,“理解并发模型”,侧重于资源分配、共享内存(以及何时不共享)和数据。我们将研究通道和通道的通道,并解释 Go 内部如何管理并发。

第三章,“开发并发策略”,讨论了设计应用程序以最佳方式利用 Go 中并发工具的方法。我们将看一些可用的第三方包,它们可以在你的策略中发挥作用。

第四章,“应用程序中的数据完整性”,着眼于确保 goroutines 和通道的委托在单线程和多线程应用程序中保持状态。

第五章,“锁、阻塞和更好的通道”,探讨了 Go 如何在开箱即用时避免死锁,以及在哪里以及何时尽管 Go 的语言设计仍然可能发生死锁。

第六章,“C10K – 一个非阻塞的 Go Web 服务器”,解决了互联网上最著名和最受尊敬的挑战之一,并尝试使用核心 Go 包来解决它。然后我们将完善产品,并使用常见的基准测试工具进行测试。

第七章,“性能和可伸缩性”,侧重于挤出并发 Go 代码的最大潜力,最大限度地利用资源,并考虑和减轻第三方软件对自身的影响。我们将为我们的 Web 服务器添加一些额外的功能,并讨论其他可以使用这些包的方式。

第八章,“并发应用程序架构”,侧重于何时何地实施并发模式,何时如何利用并行性来充分利用先进的硬件,以及如何确保数据一致性。

第九章,“在 Go 中记录和测试并发”,侧重于测试和部署应用程序的特定于操作系统的方法。我们还将探讨 Go 与各种代码存储库的关系。

第十章, 高级并发和最佳实践,探讨了更复杂和先进的技术,包括复制 Go 核心中不可用的并发特性。

您需要为本书做好准备

要跟随本书的示例工作,您需要一台运行 Windows、OS X 或支持 Go 的许多 Linux 变体的计算机。对于本书,我们的 Linux 示例和说明参考了 Ubuntu。

如果您尚未安装 Go 1.3 或更新版本,您需要从golang.org/的二进制下载页面或通过操作系统的软件包管理器获取它。

要使用本书中的所有示例,您还需要安装以下软件:

您选择的集成开发环境是个人偏好的问题,任何与开发人员合作过的人都可以证明这一点。也就是说,有些 IDE 比其他语言更适合,有些对 Go 的支持更好。本作者使用 Sublime Text,它非常适合 Go,体积轻巧,并允许您直接在 IDE 内构建。您在哪里看到代码截图,都将来自 Sublime Text 内部。

虽然 Go 代码有很好的内置支持,但 Sublime Text 还有一个名为 GoSublime 的不错的插件集,可在github.com/DisposaBoy/GoSublime上获得。

Sublime Text 并非免费,但有免费的评估版本可供使用,没有时间限制。它在 Windows、OS X 和 Linux 变体上都可用,网址是www.sublimetext.com/

本书适合的读者是谁

如果您是具有一定 Go 和并发知识的系统或网络程序员,但想了解用 Go 编写的并发系统的实现,那么这本书适合您。本书的目标是使您能够在 Go 中编写高性能、可扩展、资源节约的系统和网络应用。

在本书中,我们将编写一些基本和稍微不那么基本的网络和系统应用程序。假定您以前曾使用过这些类型的应用程序。如果没有,可能需要进行一些课外学习,以便能够充分消化这些内容。

惯例

在本书中,您将找到一些区分不同信息类型的文本样式。以下是一些这些样式的示例,以及它们的含义解释。

文本中的代码字,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄显示如下:"在每个请求之后调用setProxy函数,并且您可以将其视为处理程序中的第一行。"

代码块设置如下:

package main

import
(
"net/http"
"html/template"
"time"
"regexp"
"fmt"
"io/ioutil"
"database/sql"
"log"
"runtime"
_ "github.com/go-sql-driver/mysql"
)

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

package main

import (
  "fmt"
)

func stringReturn(text string) string {
 return text
}

func main() {
 myText := stringReturn("Here be the code")
  fmt.Println(myText)
}

任何命令行输入或输出都以以下方式编写:

go get github.com/go-sql-driver/mysql

新术语重要单词以粗体显示。例如,屏幕上看到的单词,菜单或对话框中的单词等,会在文本中以这种方式出现:"如果您通过将文件拖到拖放文件到此处上传框中来上传文件,几秒钟后您会看到文件在 Web 界面中被标记为已更改。"

注意

警告或重要说明会以以下方式出现在框中。

提示

提示和技巧会以这种方式出现。

第一章:Go 中并发的介绍

虽然 Go 既是一个很好的通用语言,也是一个低级系统语言,但它的主要优势之一是内置的并发模型和工具。许多其他语言都有第三方库(或扩展),但固有的并发性是现代语言独有的,也是 Go 设计的核心特性。

尽管毫无疑问,Go 在并发方面表现出色——正如我们将在本书中看到的那样——但它具有许多其他语言所缺乏的一套强大的工具来测试和构建并发、并行和分布式代码。

足够谈论 Go 的奇妙并发特性和工具了,让我们开始吧。

介绍 goroutines

处理并发的主要方法是通过 goroutine。诚然,我们的第一段并发代码(在前言中提到)并没有做太多事情,只是简单地输出交替的“hello”和“world”,直到整个任务完成。

以下是该代码:

package main

import (
  "fmt"
  "time"
)

type Job struct {
  i int
  max int
  text string
}

func outputText(j *Job) {
  for j.i < j.max {
    time.Sleep(1 * time.Millisecond)
    fmt.Println(j.text)
    j.i++
  }
}

func main() {
  hello := new(Job)
  world := new(Job)

  hello.text = "hello"
  hello.i = 0
  hello.max = 3

  world.text = "world"
  world.i = 0
  world.max = 5

  go outputText(hello)
  outputText(world)

}

提示

下载示例代码

您可以从您在www. packtpub.com的帐户中购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接发送到您的邮箱。

但是,如果你回想一下我们为祖母筹划惊喜派对的现实例子,这正是事情通常必须用有限或有限的资源来管理的方式。这种异步行为对于某些应用程序的平稳运行至关重要,尽管我们的例子基本上是在真空中运行的。

您可能已经注意到我们早期例子中的一个怪癖:尽管我们首先在hello结构上调用了outputText()函数,但我们的输出始于world结构的文本值。为什么呢?

作为异步的,当调用 goroutine 时,它会等待阻塞代码完成后再开始并发。您可以通过在下面的代码中用 goroutine 替换world结构上的outputText()函数调用来测试这一点:

  go outputText(hello)
  go outputText(world)

如果你运行这个,你将得不到任何输出,因为主函数结束了,而异步 goroutines 正在运行。有几种方法可以阻止这种情况,在主函数执行完毕并退出程序之前看到输出。经典的方法只是在执行之前要求用户输入,允许您直接控制应用程序何时结束。您还可以在主函数的末尾放置一个无限循环,如下所示:

for {}

更好的是,Go 还有一个内置的机制,即sync包中的WaitGroup类型。

如果您在代码中添加一个WaitGroup结构,它可以延迟主函数的执行,直到所有 goroutines 完成。简单来说,它允许您设置所需的迭代次数,以便在允许应用程序继续之前从 goroutines 获得完成的响应。让我们在下一节中看一下对我们“Hello World”应用程序的微小修改。

一个耐心的 goroutine

从这里开始,我们将实现一个WaitGroup结构,以确保我们的 goroutines 在继续应用程序之前完全运行。在这种情况下,当我们说“patient”时,这与我们在先前的例子中看到的 goroutines 在父方法之外运行的方式形成对比。在下面的代码中,我们将实现我们的第一个Waitgroup结构:

package main

import (
  "fmt"
  "sync"
  "time"
)

type Job struct {
  i int
  max int
  text string
}

func outputText(j *Job, goGroup *sync.WaitGroup) {
  for j.i < j.max {
    time.Sleep(1 * time.Millisecond)
    fmt.Println(j.text)
    j.i++
  }
  goGroup.Done()
}

func main() {

  goGroup := new(sync.WaitGroup)
  fmt.Println("Starting")

  hello := new(Job)
  hello.text = "hello"
  hello.i = 0
  hello.max = 2

  world := new(Job)
  world.text = "world"
  world.i = 0
  world.max = 2

  go outputText(hello, goGroup)
  go outputText(world, goGroup)

  goGroup.Add(2)
  goGroup.Wait()

}

让我们来看看以下代码的变化:

  goGroup := new(sync.WaitGroup)

在这里,我们声明了一个名为goGroupWaitGroup结构。这个变量将接收我们的 goroutine 函数在允许程序退出之前完成x次的通知。以下是在WaitGroup中发送这种期望的一个例子:

  goGroup.Add(2)

Add()方法指定了goGroup在满足等待之前应该接收多少个Done消息。在这里,我们指定了2,因为我们有两个异步运行的函数。如果你有三个 goroutine 成员,但仍然调用了两个,你可能会看到第三个的输出。如果你向goGroup添加了一个大于两的值,例如goGroup.Add(3),那么WaitGroup将永远等待并发死锁。

考虑到这一点,你不应该手动设置需要等待的 goroutines 的数量;最好是在范围内进行计算或明确处理。这就是我们告诉WaitGroup等待的方式:

  goGroup.Wait()

现在,我们等待。这段代码会因为和goGroup.Add(3)一样的原因而失败;goGroup结构体从未接收到我们的 goroutines 完成的消息。所以,让我们按照下面的代码片段来做:

func outputText(j *Job, goGroup *sync.WaitGroup) {
  for j.i < j.max {
    time.Sleep(1 * time.Millisecond)
    fmt.Println(j.text)
    j.i++
  }
  goGroup.Done()
}

我们只对前言中的outputText()函数进行了两处更改。首先,我们在第二个函数参数中添加了一个指向我们的goGroup的指针。然后,在所有迭代完成后,我们告诉goGroup它们都完成了。

实现 defer 控制机制

在这里,我们应该花点时间来谈谈 defer。Go 有一个优雅的 defer 控制机制的实现。如果你在其他语言中使用了 defer(或者类似功能),这会看起来很熟悉——这是一种有用的方式,可以延迟执行语句,直到函数的其余部分完成。

在大多数情况下,这只是一种语法糖,允许你将相关操作放在一起,即使它们不会一起执行。如果你曾经写过类似以下伪代码的东西,你会知道我的意思:

x = file.open('test.txt')
int longFunction() {
…
}
x.close();

你可能知道由于代码之间的大“距离”而导致的痛苦。在 Go 中,你实际上可以编写类似以下的代码:

package main

import(
"os"
)

func main() {

  file, _ := os.Create("/defer.txt")

  defer file.Close()

  for {

    break

  }

}

这并没有任何实际的功能优势,除了使代码更清晰、更易读,但这本身就是一个很大的优点。延迟调用是按照它们定义的顺序的相反顺序执行的,或者说是后进先出。你还应该注意,任何通过引用传递的数据可能处于意外的状态。

例如,参考以下代码片段:

func main() {

  aValue := new(int)

  defer fmt.Println(*aValue)

  for i := 0; i < 100; i++ {
    *aValue++
  }

}

这将返回0,而不是100,因为这是整数的默认值。

注意

Defer不同于其他语言中的deferred(或者 future/promises)。我们将在第二章中讨论 Go 的实现和 future 和 promise 的替代方案,理解并发模型

使用 Go 的调度程序

在其他语言中,许多并发和并行应用程序的软线程和硬线程的管理是在操作系统级别处理的。这被认为是固有的低效和昂贵,因为操作系统负责上下文切换,处理多个进程。当应用程序或进程可以管理自己的线程和调度时,它会导致更快的运行时间。授予我们应用程序和 Go 调度程序的线程具有较少的操作系统属性,需要考虑上下文切换,从而减少了开销。

如果你仔细想想,这是不言自明的——你需要处理的东西越多,管理所有的球就越慢。Go 通过使用自己的调度程序消除了这种机制的自然低效性。

这实际上只有一个怪癖,你会很早就学到:如果你从不让出主线程,你的 goroutines 会以意想不到的方式执行(或者根本不执行)。

另一种看待这个问题的方式是,goroutine 必须在并发有效并开始之前被阻塞。让我们修改我们的示例,并包括一些文件 I/O 记录来演示这个怪癖,如下面的代码所示:

package main

import (
  "fmt"
  "time"
  "io/ioutil"
)

type Job struct {
  i int
  max int
  text string
}

func outputText(j *Job) {
  fileName := j.text + ".txt"
  fileContents := ""
  for j.i < j.max {
    time.Sleep(1 * time.Millisecond)
    fileContents += j.text
    fmt.Println(j.text)
    j.i++
  }
  err := ioutil.WriteFile(fileName, []byte(fileContents), 0644)
  if (err != nil) {
    panic("Something went awry")
  }

}

func main() {

  hello := new(Job)
  hello.text = "hello"
  hello.i = 0
  hello.max = 3

  world := new(Job)
  world.text = "world"
  world.i = 0
  world.max = 5

  go outputText(hello)
  go outputText(world)

}

从理论上讲,改变的只是我们现在使用文件操作将每个操作记录到不同的文件中(在这种情况下是hello.txtworld.txt)。然而,如果你运行这个程序,不会创建任何文件。

在我们的最后一个例子中,我们使用了sync.WaitSync结构来强制主线程延迟执行,直到异步任务完成。虽然这样可以工作(而且优雅),但它并没有真正解释为什么我们的异步任务失败。如前所述,您还可以利用阻塞代码来防止主线程在其异步任务完成之前完成。

由于 Go 调度器管理上下文切换,每个 goroutine 必须将控制权让回主线程,以安排所有这些异步任务。有两种方法可以手动完成这个过程。一种方法,也可能是理想的方法,是WaitGroup结构。另一种是 runtime 包中的GoSched()函数。

GoSched()函数暂时让出处理器,然后返回到当前的 goroutine。考虑以下代码作为一个例子:

package main

import(
  "runtime"
  "fmt"
)

func showNumber(num int) {
  fmt.Println(num)
}

func main() {
  iterations := 10

  for i := 0; i<=iterations; i++ {

    go showNumber(i)

  }
  //runtime.Gosched()
  fmt.Println("Goodbye!")

}

runtime.Gosched()被注释掉并且在"runtime"之前的下划线被移除的情况下运行这段代码,你将只会看到Goodbye!。这是因为在main()函数结束之前,没有保证有多少 goroutines 会完成。

正如我们之前学到的,您可以在结束应用程序的执行之前显式等待有限数量的 goroutines。但是,Gosched()允许(在大多数情况下)具有相同的基本功能。删除runtime.Gosched()之前的注释,您应该在Goodbye!之前打印出 0 到 10。

只是为了好玩,尝试在多核服务器上运行此代码,并使用runtime.GOMAXPROCS()修改您的最大处理器,如下所示:

func main() {

  runtime.GOMAXPROCS(2)

此外,将您的runtime.Gosched()推到绝对末尾,以便所有 goroutines 在main结束之前有机会运行。

得到了一些意外的东西?这并不意外!您可能会得到完全混乱的 goroutines 执行,如下面的截图所示:

使用 Go 的调度器

虽然没有必要完全演示如何在多个核心上处理 goroutines 可能会很棘手,但这是展示为什么在它们之间进行通信(和 Go 调度器)很重要的最简单的方法之一。

您可以使用GOMAXPROCS > 1来调试这个,并在您的 goroutine 调用周围加上时间戳显示,如下所示:

  tstamp := strconv.FormatInt(time.Now().UnixNano(), 10)
  fmt.Println(num, tstamp)

注意

记得在这里导入timestrconv父包。

这也是一个很好的地方来看并发并将其与并行执行进行比较。首先,在showNumber()函数中添加一秒的延迟,如下面的代码片段所示:

func showNumber(num int) {
  tstamp := strconv.FormatInt(time.Now().UnixNano(), 10)
  fmt.Println(num,tstamp)
  time.Sleep(time.Millisecond * 10)
}

然后,在showNumber()函数之前删除 goroutine 调用,并使用GOMAXPROCS(0),如下面的代码片段所示:

  runtime.GOMAXPROCS(0)
  iterations := 10

  for i := 0; i<=iterations; i++ {
    showNumber(i)
  }

正如预期的那样,您会得到 0-10 之间的数字,它们之间有 10 毫秒的延迟,然后输出Goodbye!。这是直接的串行计算。

接下来,让我们将GOMAXPROCS保持为零以使用单个线程,但是恢复 goroutine 如下:

go showNumber(i)

这与之前的过程相同,只是一切都会在相同的时间范围内执行,展示了执行的并发性质。现在,继续将您的GOMAXPROCS更改为两个并再次运行。如前所述,只有一个(或可能两个)时间戳,但顺序已经改变,因为一切都在同时运行。

Goroutines 不一定是基于线程的,但它们感觉像是。当 Go 代码被编译时,goroutines 会在可用的线程上进行多路复用。这正是为什么 Go 的调度器需要知道什么正在运行,什么需要在应用程序生命周期结束之前完成等等的原因。如果代码有两个线程可用,那就会使用两个线程。

使用系统变量

那么如果您想知道您的代码有多少个线程可用呢?

Go 有一个从 runtime 包函数GOMAXPROCS返回的环境变量。要找出可用的内容,您可以编写一个类似以下代码的快速应用程序:

package main

import (
  "fmt"
  "runtime"
)

func listThreads() int {

  threads := runtime.GOMAXPROCS(0)
  return threads
}

func main() {
  runtime.GOMAXPROCS(2)
  fmt.Printf("%d thread(s) available to Go.", listThreads())

}

在这个上进行简单的 Go 构建将产生以下输出:

2 thread(s) available to Go.

传递给GOMAXPROCS0参数(或没有参数)意味着没有进行更改。你可以在那里放入另一个数字,但正如你所想象的那样,它只会返回 Go 实际可用的内容。你不能超过可用的核心,但你可以限制你的应用程序使用少于可用的核心。

GOMAXPROCS()调用本身返回一个整数,表示之前可用的处理器数量。在这种情况下,我们首先将其设置为两,然后设置为零(没有更改),返回两。

值得注意的是,增加GOMAXPROCS有时可能会降低应用程序的性能。

在更大的应用程序和操作系统中存在上下文切换的惩罚,增加使用的线程数量意味着 goroutines 可以在多个线程之间共享,并且 goroutines 的轻量级优势可能会被牺牲。

如果你有一个多核系统,你可以很容易地使用 Go 的内部基准测试功能来测试这一点。我们将在第五章锁、阻塞和更好的通道和第七章性能和可伸缩性中更仔细地研究这个功能。

runtime 包还有一些其他非常有用的环境变量返回函数,比如NumCPUNumGoroutineCPUProfileBlockProfile。这些不仅方便调试,也有助于了解如何最好地利用资源。这个包还与 reflect 包很好地配合,reflect 包处理元编程和程序自我分析。我们将在第九章Go 中的日志记录和测试并发和第十章高级并发和最佳实践中更详细地讨论这一点。

理解 goroutines 与 coroutines

在这一点上,你可能会想,“啊,goroutines,我知道这些就是 coroutines。”嗯,是和不是。

coroutine 是一种协作式任务控制机制,但从其最简单的意义上讲,coroutine 并不是并发的。虽然 coroutines 和 goroutines 的使用方式类似,但 Go 对并发的关注提供了远不止状态控制和产出。在我们迄今为止看到的例子中,我们有可以称之为愚蠢的 goroutines。虽然它们在同一时间和地址空间中运行,但两者之间没有真正的通信。如果你看看其他语言中的 coroutines,你可能会发现它们通常并不一定是并发的或异步的,而是基于步骤的。它们会向main()和彼此产出,但两个 coroutine 之间可能并不一定会进行通信,而是依赖于一个集中的、明确编写的数据管理系统。

注意

原始 coroutine

coroutines 最初是由 Melvin Conway 为 COBOL 描述的。在他的论文《可分离转换图编译器的设计》中,他建议 coroutine 的目的是将程序分解为子任务,并允许它们独立运行,仅共享少量数据。

Goroutines 有时可能会违反 Conway 的 coroutines 的基本原则。例如,Conway 建议只应该有一个单向的执行路径;换句话说,A 后面是 B,然后是 C,然后是 D,依此类推,其中每个代表 coroutine 中的一个应用程序块。我们知道 goroutines 可以并行运行,并且可以以看似任意的顺序执行(至少没有方向)。到目前为止,我们的 goroutines 也没有共享任何信息;它们只是以共享的模式执行。

实现通道

到目前为止,我们已经涉足了能够做很多事情但不能有效地相互通信的并发进程。换句话说,如果你有两个进程占用相同的处理时间并共享相同的内存和数据,你必须知道哪个进程在哪个位置作为更大任务的一部分。

例如,一个应用程序必须循环遍历 Lorem Ipsum 的一个段落,并将每个字母大写,然后将结果写入文件。当然,我们实际上不需要一个并发应用程序来做这个事情(事实上,几乎任何处理字符串的语言都具有这个固有功能),但这是一个快速演示孤立 goroutine 潜在限制的方法。不久,我们将把这个原始示例转化为更实用的东西,但现在,这是我们大写示例的开始:

package main

import (
  "fmt"
  "runtime"
  "strings"
)
var loremIpsum string
var finalIpsum string
var letterSentChan chan string

func deliverToFinal(letter string, finalIpsum *string) {
  *finalIpsum += letter
}

func capitalize(current *int, length int, letters []byte, 
  finalIpsum *string) {
  for *current < length {
    thisLetter := strings.ToUpper(string(letters[*current]))

    deliverToFinal(thisLetter, finalIpsum)
    *current++
  }
}

func main() {

  runtime.GOMAXPROCS(2)

  index := new(int)
  *index = 0
  loremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing 
  elit. Vestibulum venenatis magna eget libero tincidunt, ac 
  condimentum enim auctor. Integer mauris arcu, dignissim sit amet 
  convallis vitae, ornare vel odio. Phasellus in lectus risus. Ut 
  sodales vehicula ligula eu ultricies. Fusce vulputate fringilla 
  eros at congue. Nulla tempor neque enim, non malesuada arcu 
  laoreet quis. Aliquam eget magna metus. Vivamus lacinia 
  venenatis dolor, blandit faucibus mi iaculis quis. Vestibulum 
  sit amet feugiat ante, eu porta justo."

  letters := []byte(loremIpsum)
  length := len(letters)

  go capitalize(index, length, letters, &finalIpsum)
  go func() {
    go capitalize(index, length, letters, &finalIpsum)
  }()

  fmt.Println(length, " characters.")
  fmt.Println(loremIpsum)
  fmt.Println(*index)
  fmt.Println(finalIpsum)

}

如果我们在这里以某种程度的并行性运行,但我们的 goroutine 之间没有通信,我们最终会得到一团糟的文本,如下面的截图所示:

实现通道

由于 Go 中并发调度的不可预测性,可能需要多次迭代才能获得这个确切的输出。事实上,你可能永远也得不到确切的输出。

显然这样行不通。那么我们应该如何最好地构建这个应用程序呢?这里缺少的是同步,但我们也可以用更好的设计模式。

这是另一种将这个问题分解成片段的方法。与其让两个进程并行处理相同的事情,这充满了风险,不如让一个进程从loremIpsum字符串中取一个字母并将其大写,然后将其传递给另一个进程将其添加到我们的finalIpsum字符串中。

你可以把这想象成两个人坐在两张桌子前,每个人手上都有一叠信件。A 负责拿一封信并将其大写。然后他把信传给 B,然后 B 把它添加到finalIpsum堆栈中。为了做到这一点,我们将在我们的代码中实现一个通道,这个应用程序的任务是接收文本(在这种情况下是亚伯拉罕·林肯的葛底斯堡演说的第一行)并将每个字母大写。

基于通道的字母大写工厂排序

让我们以最后一个例子为例,通过尝试大写亚伯拉罕·林肯的葛底斯堡演说序言,来做一些(略微)更有意义的事情,同时减轻 Go 中并发的不可预测影响,如下面的代码所示:

package main

import(
  "fmt"
  "sync"
  "runtime"
  "strings"
)

var initialString string
var finalString string

var stringLength int

func addToFinalStack(letterChannel chan string, wg 
  *sync.WaitGroup) {
  letter := <-letterChannel
  finalString += letter
  wg.Done()
}

func capitalize(letterChannel chan string, currentLetter string, 
  wg *sync.WaitGroup) {

  thisLetter := strings.ToUpper(currentLetter)
  wg.Done()
  letterChannel <- thisLetter  
}

func main() {

  runtime.GOMAXPROCS(2)
  var wg sync.WaitGroup

  initialString = "Four score and seven years ago our fathers 
  brought forth on this continent, a new nation, conceived in 
  Liberty, and dedicated to the proposition that all men are 
  created equal."
  initialBytes := []byte(initialString)

  var letterChannel chan string = make(chan string)

  stringLength = len(initialBytes)

  for i := 0; i < stringLength; i++ {
    wg.Add(2)

    go capitalize(letterChannel, string(initialBytes[i]), &wg)
    go addToFinalStack(letterChannel, &wg)

    wg.Wait()
  }

  fmt.Println(finalString)

}

你会注意到,我们甚至将这提升到了一个双核处理过程,并得到了以下输出:

go run alpha-channel.go
FOUR SCORE AND SEVEN YEARS AGO OUR FATHERS BROUGHT FORTH ON THIS 
 CONTINENT, A NEW NATION, CONCEIVED IN LIBERTY, AND DEDICATED TO THE 
 PROPOSITION THAT ALL MEN ARE CREATED EQUAL.

输出正如我们所预期的那样。值得重申的是,这个例子是极端的过度,但我们很快将把这个功能转化为一个可用的实际应用程序。

所以这里发生了什么?首先,我们重新实现了sync.WaitGroup结构,以允许我们所有的并发代码在保持主线程活动的同时执行,如下面的代码片段所示:

var wg sync.WaitGroup
...
for i := 0; i < stringLength; i++ {
  wg.Add(2)

  go capitalize(letterChannel, string(initialBytes[i]), &wg)
  go addToFinalStack(letterChannel, &wg)

  wg.Wait()
}

我们允许每个 goroutine 告诉WaitGroup结构我们已经完成了这一步。由于我们有两个 goroutine,我们将两个Add()方法排入WaitGroup结构的队列。每个 goroutine 负责宣布自己已经完成。

接下来,我们创建了我们的第一个通道。我们用以下代码行实例化一个通道:

  var letterChannel chan string = make(chan string)

这告诉 Go 我们有一个通道,将向各种程序/ goroutine 发送和接收字符串。这本质上是所有 goroutine 的管理者。它还负责向 goroutine 发送和接收数据,并管理执行顺序。正如我们之前提到的,通道具有在内部上下文切换和无需依赖多线程的能力,使它们能够快速运行。

这个功能有内置的限制。如果你设计非并发或阻塞的代码,你将有效地从 goroutine 中移除并发。我们很快会更多地讨论这个问题。

我们通过letterChannel运行两个单独的 goroutine:capitalize()addToFinalStack()。第一个简单地从构建的字节数组中获取一个字节并将其大写。然后,它将字节返回到通道,如下一行代码所示:

letterChannel <- thisLetter

所有通过通道的通信都是以这种方式进行的。<-符号在语法上告诉我们数据将被发送回通道。从来不需要对这些数据做任何处理,但最重要的是要知道通道可以阻塞,至少在每个线程中,直到它接收到数据。您可以通过创建一个通道,然后对其不做任何有价值的事情来测试这一点,如下面的代码片段所示:

package main

func doNothing()(string) {

  return "nothing"
}

func main() {

  var channel chan string = make(chan string)
  channel <- doNothing()

}

由于没有沿着通道发送任何东西,也没有实例化 goroutine,这导致了死锁。您可以通过创建一个 goroutine 并将通道带入全局空间来轻松解决这个问题,方法是在main()之外创建它。

注意

为了清晰起见,我们的示例在这里使用了局部范围的通道。尽可能保持这些全局范围,可以消除很多不必要的东西,特别是如果您有很多 goroutine,因为通道的引用可能会使您的代码变得混乱。

对于我们的整个示例,您可以将其视为下图所示:

基于通道的字母大写工厂的排序

清理我们的 goroutine

您可能想知道为什么在使用通道时需要WaitGroup结构。毕竟,我们不是说过通道会被阻塞,直到它接收到数据吗?这是真的,但它需要另一段语法。

空或未初始化的通道将始终被阻塞。我们将在第七章性能和可伸缩性和第十章高级并发和最佳实践中讨论这种情况的潜在用途和陷阱。

您可以通过在make命令的第二个选项中指定通道缓冲区来决定通道如何阻塞应用程序。

缓冲或非缓冲通道

默认情况下,通道是非缓冲的,这意味着如果有一个准备接收的通道,它们将接受任何发送到它们的东西。这也意味着每个通道调用都会阻塞应用程序的执行。通过提供一个缓冲区,只有在发送了许多返回时,通道才会阻塞应用程序。

缓冲通道是同步的。为了保证异步性能,您需要通过提供缓冲区长度来进行实验。我们将在下一章中探讨确保我们的执行符合预期的方法。

注意

Go 的通道系统是基于通信顺序进程CSP)的,这是一种设计并发模式和多处理的正式语言。当人们描述 goroutine 和通道时,您可能会单独遇到 CSP。

使用 select 语句

switch, familiar to Go users and common among other languages:
switch {

  case 'x':

  case 'y':

}
select statement:
select {

  case <- channelA:

  case <- channelB:

}

switch语句中,右侧表达式表示一个值;在select中,它表示对通道的接收操作。select语句将阻塞应用程序,直到有一些信息通过通道发送。如果从未发送任何内容,应用程序将死锁,并且您将收到相应的错误。

如果两个接收操作同时发送(或者满足两个情况),Go 将以不可预测的方式对它们进行评估。

那么,这有什么用呢?让我们看一下字母大写应用程序的主函数的修改版本:

package main

import(
  "fmt"  
  "strings"
)

var initialString string
var initialBytes []byte
var stringLength int
var finalString string
var lettersProcessed int
var applicationStatus bool
var wg sync.WaitGroup

func getLetters(gQ chan string) {

  for i := range initialBytes {
    gQ <- string(initialBytes[i])  

  }

}

func capitalizeLetters(gQ chan string, sQ chan string) {

  for {
    if lettersProcessed >= stringLength {
      applicationStatus = false
      break
    }
    select {
      case letter := <- gQ:
        capitalLetter := strings.ToUpper(letter)
        finalString += capitalLetter
        lettersProcessed++
    }
  }
}

func main() {

  applicationStatus = true;

  getQueue := make(chan string)
  stackQueue := make(chan string)

  initialString = "Four score and seven years ago our fathers brought forth on this continent, a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal."
  initialBytes = []byte(initialString)
  stringLength = len(initialString)
  lettersProcessed = 0

  fmt.Println("Let's start capitalizing")

  go getLetters(getQueue)
  capitalizeLetters(getQueue,stackQueue)

  close(getQueue)
  close(stackQueue)

  for {

    if applicationStatus == false {
      fmt.Println("Done")
      fmt.Println(finalString)
      break
    }

  }
}

这里的主要区别是我们现在有一个通道,它在两个并发运行的函数getLetterscapitalizeLetters之间监听数据。在底部,您将看到一个for{}循环,它会一直保持主动状态,直到applicationStatus变量设置为false。在下面的代码中,我们将每个字节作为字符串通过 Go 通道传递:

func getLetters(gQ chan string) {

  for i := range initialBytes {
    gQ <- string(initialBytes[i])  

  }

}

getLetters函数是我们的主要 goroutine,它从构建自 Lincoln's 行的字节数组中获取单个字母。当函数迭代每个字节时,它通过getQueue通道发送该字母。

在接收端,我们有capitalizeLetters,它接收每个字母并将其大写,然后附加到我们的finalString变量上。让我们来看一下这个:

func capitalizeLetters(gQ chan string, sQ chan string) {

  for {
    if lettersProcessed >= stringLength {
      applicationStatus = false
      break
    }
    select {
      case letter := <- gQ:
        capitalLetter := strings.ToUpper(letter)
        finalString += capitalLetter
        lettersProcessed++
    }
  }
}

关闭所有通道是至关重要的,否则我们的应用程序将陷入死锁。如果我们在这里永远不打破for循环,我们的通道将一直等待从并发进程接收,并且程序将陷入死锁。我们手动检查是否已将所有字母大写,然后才打破循环。

闭包和 goroutines

您可能已经注意到 Lorem Ipsum 中的匿名 goroutine:

  go func() {
    go capitalize(index, length, letters, &finalIpsum)
  }()

虽然这并不总是理想的,但有很多地方内联函数最适合创建 goroutine。

描述这个最简单的方法是说一个函数不够大或重要,不值得拥有一个命名函数,但事实上,这更多的是关于可读性。如果您在其他语言中使用过 lambda 表达式,这可能不需要太多解释,但请尽量将这些保留给快速的内联函数。

在早期的示例中,闭包主要作为调用select语句的包装器或创建匿名 goroutines 来提供select语句。

由于函数在 Go 中是一等公民,因此不仅可以直接在代码中使用内联或匿名函数,还可以将它们传递给其他函数并从其他函数返回。

以下是一个示例,它将函数的结果作为返回值传递,使返回的函数之外的状态坚定。在这种情况下,我们将一个函数作为变量返回,并在返回的函数上迭代初始值。初始参数将接受一个字符串,每次调用返回的函数时都会根据单词长度进行修剪。

import(
  "fmt"
  "strings"
)

func shortenString(message string) func() string {

  return func() string {
    messageSlice := strings.Split(message," ")
    wordLength := len(messageSlice)
    if wordLength < 1 {
      return "Nothingn Left!"
    }else {
      messageSlice = messageSlice[:(wordLength-1)]
      message = strings.Join(messageSlice, " ")
      return message
    }
  }
}

func main() {

  myString := shortenString("Welcome to concurrency in Go! ...")

  fmt.Println(myString())
  fmt.Println(myString())  
  fmt.Println(myString())  
  fmt.Println(myString())  
  fmt.Println(myString())  
  fmt.Println(myString())
}

一旦初始化并返回,我们设置消息变量,并且返回方法的每次运行都会迭代该值。这种功能允许我们避免在返回值上多次运行函数或不必要地循环,而可以使用闭包来处理这个问题,如上所示。

使用 goroutines 和通道构建网络爬虫

让我们拿这个几乎没什么用的大写应用程序,做一些实际的事情。在这里,我们的目标是构建一个基本的爬虫。这样做,我们将完成以下任务:

  • 读取五个 URL

  • 读取这些 URL 并将内容保存到字符串中

  • 当所有 URL 都被扫描和读取时,将该字符串写入文件

这些类型的应用程序每天都在编写,并且它们是最能从并发和非阻塞代码中受益的应用程序之一。

可能不用说,但这并不是一个特别优雅的网络爬虫。首先,它只知道一些起始点——我们提供的五个 URL。此外,它既不是递归的,也不是线程安全的,就数据完整性而言。

也就是说,以下代码有效,并演示了我们如何使用通道和select语句:

package main

import(
  "fmt"
  "io/ioutil"
  "net/http"
  "time"
)

var applicationStatus bool
var urls []string
var urlsProcessed int
var foundUrls []string
var fullText string
var totalURLCount int
var wg sync.WaitGroup

var v1 int

首先,我们有我们最基本的全局变量,我们将用它们来表示应用程序状态。applicationStatus变量告诉我们我们的爬虫进程已经开始,urls是我们的简单字符串 URL 的切片。其余的是成语数据存储变量和/或应用程序流程机制。以下代码片段是我们读取 URL 并将它们传递到通道的函数:

func readURLs(statusChannel chan int, textChannel chan string) {

  time.Sleep(time.Millisecond * 1)
  fmt.Println("Grabbing", len(urls), "urls")
  for i := 0; i < totalURLCount; i++ {

    fmt.Println("Url", i, urls[i])
    resp, _ := http.Get(urls[i])
    text, err := ioutil.ReadAll(resp.Body)

    textChannel <- string(text)

    if err != nil {
      fmt.Println("No HTML body")
    }

    statusChannel <- 0

  }

}

readURLs函数假定statusChanneltextChannel用于通信,并循环遍历urls变量切片,在textChannel上返回文本,并在statusChannel上返回一个简单的 ping。接下来,让我们看一下将抓取的文本附加到完整文本的函数:

func addToScrapedText(textChannel chan string, processChannel chan bool) {

  for {
    select {
    case pC := <-processChannel:
      if pC == true {
        // hang on
      }
      if pC == false {

        close(textChannel)
        close(processChannel)
      }
    case tC := <-textChannel:
      fullText += tC

    }

  }

}

我们使用addToScrapedText函数来累积处理过的文本并将其添加到主文本字符串中。当我们在processChannel上收到关闭信号时,我们也关闭了我们的两个主要通道。让我们看一下evaluateStatus()函数:

func evaluateStatus(statusChannel chan int, textChannel chan string, processChannel chan bool) {

  for {
    select {
    case status := <-statusChannel:

      fmt.Print(urlsProcessed, totalURLCount)
      urlsProcessed++
      if status == 0 {

        fmt.Println("Got url")

      }
      if status == 1 {

        close(statusChannel)
      }
      if urlsProcessed == totalURLCount {
        fmt.Println("Read all top-level URLs")
        processChannel <- false
        applicationStatus = false

      }
    }

  }
}

在这个时刻,evaluateStatus函数所做的就是确定应用程序的整体范围内发生了什么。当我们通过这个通道发送一个0(我们前面提到的 ping)时,我们会增加我们的urlsProcessed变量。当我们发送一个1时,这是一个消息,我们可以关闭通道。最后,让我们看一下main函数:

func main() {
  applicationStatus = true
  statusChannel := make(chan int)
  textChannel := make(chan string)
  processChannel := make(chan bool)
  totalURLCount = 0

  urls = append(urls, "http://www.mastergoco.com/index1.html")
  urls = append(urls, "http://www.mastergoco.com/index2.html")
  urls = append(urls, "http://www.mastergoco.com/index3.html")
  urls = append(urls, "http://www.mastergoco.com/index4.html")
  urls = append(urls, "http://www.mastergoco.com/index5.html")

  fmt.Println("Starting spider")

  urlsProcessed = 0
  totalURLCount = len(urls)

  go evaluateStatus(statusChannel, textChannel, processChannel)

  go readURLs(statusChannel, textChannel)

  go addToScrapedText(textChannel, processChannel)

  for {
    if applicationStatus == false {
      fmt.Println(fullText)
      fmt.Println("Done!")
      break
    }
    select {
    case sC := <-statusChannel:
      fmt.Println("Message on StatusChannel", sC)

    }
  }

}

这是我们上一个函数的基本推断,即大写函数。然而,这里的每个部分都负责读取 URL 或将其相应内容附加到较大的变量中。

在下面的代码中,我们创建了一种主循环,让您知道在statusChannel上何时抓取了一个 URL:

  for {
    if applicationStatus == false {
      fmt.Println(fullText)
      fmt.Println("Done!")
      break
    }
    select {
      case sC := <- statusChannel:
        fmt.Println("Message on StatusChannel",sC)

    }
  }

通常,您会看到这被包装在go func()中,作为WaitGroup结构的一部分,或者根本没有包装(取决于您需要的反馈类型)。

在这种情况下,控制流是evaluateStatus,它作为一个通道监视器,让我们知道数据何时穿过每个通道,并在执行结束时结束。readURLs函数立即开始读取我们的 URL,提取底层数据并将其传递给textChannel。此时,我们的addToScrapedText函数接收每个发送的 HTML 文件并将其附加到fullText变量中。当evaluateStatus确定所有 URL 已被读取时,它将applicationStatus设置为false。此时,main()底部的无限循环退出。

如前所述,爬虫不能比这更基础,但是看到 goroutines 如何在一起工作的真实例子将为我们在接下来的章节中更安全和更复杂的例子做好准备。

总结

在本章中,我们学习了如何从简单的 goroutines 和实例化通道扩展到 goroutines 的基本功能,并允许并发进程内的跨通道、双向通信。我们看了一些创建阻塞代码的新方法,以防止我们的主进程在 goroutines 之前结束。最后,我们学习了使用 select 语句来开发反应式通道,除非沿着通道发送数据,否则它们是静默的。

在我们基本的网络蜘蛛示例中,我们将这些概念结合在一起,创建了一个安全、轻量级的过程,可以从一系列 URL 中提取所有链接,通过 HTTP 获取内容并存储结果响应。

在下一章中,我们将深入了解 Go 的内部调度如何管理并发,并开始使用通道来真正利用 Go 中并发的力量、节俭和速度。

第二章:理解并发模型

现在我们已经了解了 Go 的能力以及如何测试一些并发模型,我们需要更深入地了解 Go 最强大的功能,以了解如何最好地利用各种并发工具和模型。

我们玩了一些一般和基本的 goroutines,看看我们如何运行并发进程,但在我们开始通道之间的通信之前,我们需要看看 Go 是如何管理并发调度的。

理解 goroutines 的工作方式

到这一点,你应该对 goroutines 做了很好的了解,但值得理解的是它们在 Go 中是如何内部工作的。Go 使用协作调度处理并发,正如我们在前一章中提到的,这在某种程度上严重依赖于某种形式的阻塞代码。

协作调度的最常见替代方案是抢占式调度,其中每个子进程被授予一段时间来完成,然后它的执行被暂停以进行下一个。

没有某种形式的让回到主线程,执行就会遇到问题。这是因为 Go 使用单个进程,作为 goroutines 乐队的指挥。每个子进程负责宣布自己的完成。与其他并发模型相比,其中一些允许直接命名通信,这可能构成一个难点,特别是如果你以前没有使用过通道。

你可能会看到这些事实存在死锁的潜在可能性。在本章中,我们将讨论 Go 的设计允许我们管理这一点的方式,以及在应用程序中解决问题的方法。

同步与异步 goroutines

理解并发模型有时是程序员的早期痛点,不仅仅是对于 Go,还有其他使用不同模型的语言。部分原因是由于在黑盒中操作(取决于你的终端偏好);开发人员必须依赖于日志记录或数据一致性错误来辨别异步和/或多核定时问题。

由于同步和异步或并发和非并发任务的概念有时可能有点抽象,我们将在这里尝试以一种视觉方式来演示到目前为止我们所涵盖的所有概念。

当然,有许多方法来处理反馈和日志记录。你可以写入文件console/terminal/stdout…,其中大部分本质上是线性的。在日志文件中没有简洁的方式来表示并发。鉴于这一点,以及我们处理着一个专注于服务器的新兴语言,让我们采取不同的角度。

我们将创建一个可视化反馈,显示进程在时间轴上的开始和停止。

设计 Web 服务器计划

为了展示不同的方法,我们将创建一个简单的 Web 服务器,循环执行三个微不足道的任务,并在 X 秒时间轴上输出它们的执行标记。我们将使用一个名为svgo的第三方库和 Go 的内置http包来实现这一点。

首先,让我们通过go get获取svgo库:

go get github.com/ajstarks/svgo

如果你尝试通过go get命令安装一个包,并且收到关于未设置$GOPATH的错误,那么你需要设置该环境变量。GOPATH是 Go 将查找已安装的导入包的位置。

在 Linux(或 Mac)中设置这个,输入以下 bash(或终端):

export GOPATH=/usr/yourpathhere

这条路由取决于你,所以选择一个你最舒适的地方来存储你的 Go 包。

为了确保它是全局可访问的,请将它安装在你的 Go 二进制文件安装的位置。

在 Windows 上,你可以右键单击我的电脑,然后导航到属性 | 高级系统设置 | 环境变量...,如下面的截图所示:

设计 Web 服务器计划

在这里,您需要创建一个名为GOPATH的新变量。与 Linux 和 Mac 的说明一样,这可以是您的 Go 语言根目录,也可以是完全不同的地方。在本例中,我们使用了C:\Go,如下截图所示:

设计 Web 服务器计划

注意

请注意,在执行这些步骤后,您可能需要重新打开终端、命令提示符或 bash 会话,以便值被视为有效。在*nix 系统上,您可以登录和注销以启动此操作。

现在我们已经安装了 gosvg,我们可以直观地演示异步和同步进程并排以及多个处理器的外观。

注意

更多库

为什么使用 SVG?当然,我们不需要使用 SVG 和 Web 服务器,如果您更愿意看到生成的图像并单独打开它,还有其他替代方法可以做到这一点。Go 还有一些其他可用的图形库,如下所示:

  • draw2d:顾名思义,这是一个用于进行矢量风格和光栅图形的二维绘图库,可以在code.google.com/p/draw2d/找到。

  • graphics-go:这个项目涉及 Go 团队的一些成员。它的范围相当有限。您可以在code.google.com/p/graphics-go/找到更多信息。

  • go:ngine:这是为 Go 设计的少数 OpenGL 实现之一。对于这个项目来说可能有些过度,但如果您发现自己需要一个三维图形库,可以从go-ngine.com/开始。

  • Go-SDL:另一种可能过度的方法,这是一个实现了出色的多媒体库 SDL 的项目。您可以在github.com/banthar/Go-SDL找到更多信息。

还有一些强大的 GUI 工具包可用,但由于它们是作为系统语言设计的,这并不是 Go 的长处。

可视化并发

我们对可视化并发的第一次尝试将有两个简单的 goroutines 在循环中运行drawPoint函数,循环 100 次。运行后,您可以访问localhost:1900/visualize,看看并发 goroutines 的样子。

如果您在端口 1900 上遇到问题(无论是防火墙还是端口冲突),请随意在main()函数的第 99 行更改该值。如果您的系统无法解析 localhost,则可能还需要通过127.0.0.1访问它。

请注意,我们没有使用WaitGroup或任何其他东西来管理 goroutines 的结束,因为我们只想看到我们的代码运行的可视化表示。您也可以使用特定的阻塞代码或runtime.Gosched()来处理这个问题,如下所示:

package main

import (
    "github.com/ajstarks/svgo"
    "net/http"
    "fmt"
    "log"
    "time"
    "strconv"
)

var width = 800
var height = 400
var startTime = time.Now().UnixNano()

func drawPoint(osvg *svg.SVG, pnt int, process int) {
  sec := time.Now().UnixNano()
  diff := ( int64(sec) - int64(startTime) ) / 100000

  pointLocation := 0

  pointLocation = int(diff)
  pointLocationV := 0
  color := "#000000"
  switch {
    case process == 1:
      pointLocationV = 60
      color = "#cc6666"
    default:
      pointLocationV = 180
      color = "#66cc66"

  }

  osvg.Rect(pointLocation,pointLocationV,3,5,"fill:"+color+";stroke:
  none;")
  time.Sleep(150 * time.Millisecond)
}

func visualize(rw http.ResponseWriter, req *http.Request) {
  startTime = time.Now().UnixNano()
  fmt.Println("Request to /visualize")
  rw.Header().Set("Content-Type", "image/svg+xml")

  outputSVG := svg.New(rw)

  outputSVG.Start(width, height)
  outputSVG.Rect(10, 10, 780, 100, "fill:#eeeeee;stroke:none")
  outputSVG.Text(20, 30, "Process 1 Timeline", "text-
    anchor:start;font-size:12px;fill:#333333")
  outputSVG.Rect(10, 130, 780, 100, "fill:#eeeeee;stroke:none")    
  outputSVG.Text(20, 150, "Process 2 Timeline", "text-
    anchor:start;font-size:12px;fill:#333333")  

  for i:= 0; i < 801; i++ {
    timeText := strconv.FormatInt(int64(i),10)
    if i % 100 == 0 {
      outputSVG.Text(i,380,timeText,"text-anchor:middle;font-
        size:10px;fill:#000000")      
    }else if i % 4 == 0 {
      outputSVG.Circle(i,377,1,"fill:#cccccc;stroke:none")  
    }

    if i % 10 == 0 {
      outputSVG.Rect(i,0,1,400,"fill:#dddddd")
    }
    if i % 50 == 0 {
      outputSVG.Rect(i,0,1,400,"fill:#cccccc")
    }

  }

  for i := 0; i < 100; i++ {
    go drawPoint(outputSVG,i,1)
    drawPoint(outputSVG,i,2)    
  }

  outputSVG.Text(650, 360, "Run without goroutines", "text-
    anchor:start;font-size:12px;fill:#333333")      
  outputSVG.End()
}

func main() {
  http.Handle("/visualize", http.HandlerFunc(visualize))

    err := http.ListenAndServe(":1900", nil)
    if err != nil {
        log.Fatal("ListenAndServe:", err)
    }  

}

当您访问localhost:1900/visualize时,您应该看到类似以下截图的内容:

可视化并发

正如您所看到的,一切肯定是同时运行的——我们短暂休眠的 goroutines 在同一时刻命中时间轴。通过简单地强制 goroutines 以串行方式运行,您将看到这种行为的可预测变化。如下所示,删除第 73 行的 goroutine 调用:

    drawPoint(outputSVG,i,1)
    drawPoint(outputSVG,i,2)  

为了保持我们的演示清晰,将第 77 行更改为指示没有 goroutines,如下所示:

outputSVG.Text(650, 360, "Run with goroutines", "text-
  anchor:start;font-size:12px;fill:#333333")  

如果我们停止服务器并使用go run重新启动,我们应该看到类似以下截图的内容:

可视化并发

现在,每个进程在开始之前都会等待前一个进程完成。如果您在同步数据、通道和进程的同步方面遇到问题,您实际上可以向任何应用程序添加这种反馈。

如果我们愿意,我们可以添加一些通道,并显示它们之间的通信。稍后,我们将设计一个自我诊断服务器,实时提供有关服务器、请求和通道状态的分析。

如果我们重新启动 goroutine 并增加最大可用处理器,我们将看到类似于以下截图的内容,这与我们的第一个截图并不完全相同:

可视化并发

显然,您的里程数会根据服务器速度、处理器数量等而有所不同。但在这种情况下,我们的更改导致了两个具有间歇性休眠的进程的更快总执行时间。这应该不足为奇,因为我们基本上有两倍的带宽可用来完成这两个任务。

RSS 的实际应用

让我们以Rich Site Summary / Really Simple Syndication (RSS)的概念为基础,并注入一些真正的潜在延迟,以确定我们在哪里可以最好地利用 goroutines 来加快执行速度并防止阻塞代码。将真实生活中可能阻塞应用程序元素引入您的代码的一种常见方式是使用涉及网络传输的内容。

这也是一个很好的地方,可以查看超时和关闭通道,以确保我们的程序不会在某些操作花费太长时间时崩溃。

为了满足这两个要求,我们将构建一个非常基本的 RSS 阅读器,它将简单地解析并获取五个 RSS 源的内容。我们将读取每一个源以及每个源上提供的链接,然后我们将通过 HTTP 生成一个 SVG 报告。

注意

显然,这是一个最适合作为后台任务的应用程序——您会注意到每个请求可能需要很长时间。但是,为了以图形方式表示与并发和无并发的真实生活过程,它将起作用,特别是对于单个最终用户。我们还将将我们的步骤记录到标准输出中,所以一定要查看您的控制台。

在这个例子中,我们将再次使用第三方库,尽管完全可以使用 Go 的内置 XML 包来解析 RSS。鉴于 XML 的开放性和 RSS 的特定性,我们将绕过它们,使用 Jim Teeuwen 的go-pkg-rss,可以通过以下go get命令获取:

go get github.com/jteeuwen/go-pkg-rss

虽然这个包专门用作 Google Reader 产品的替代品,这意味着它会在一组来源中对新内容进行基于间隔的轮询,但它也有一个相当整洁的 RSS 阅读实现。虽然还有一些其他的 RSS 解析库,但是请随意尝试。

带自我诊断功能的 RSS 阅读器

让我们回顾一下我们迄今为止学到的东西,并利用它来同时获取和解析一组 RSS 源,同时在内部 Web 浏览器中返回有关该过程的一些可视化反馈,如下所示的代码:

package main

import(
  "github.com/ajstarks/svgo"
  rss "github.com/jteeuwen/go-pkg-rss"    
  "net/http"
  "log"
  "fmt"
  "strconv"
  "time"
  "os"
  "sync"
  "runtime"
)

type Feed struct {
  url string
  status int
  itemCount int
  complete bool
  itemsComplete bool
  index int
}

这是我们源的整体结构的基础:我们有一个代表源位置的url变量,一个表示它是否已启动的status变量,以及一个表示它是否已完成的complete布尔变量。下一个部分是一个单独的FeedItem;以下是它的布局方式:

type FeedItem struct {
  feedIndex int
  complete bool
  url string
}

与此同时,我们不会对单个项做太多处理;在这一点上,我们只是维护一个 URL,无论它是完整的还是FeedItem结构体的索引。

var feeds []Feed
var height int
var width int
var colors []string
var startTime int64
var timeout int
var feedSpace int

var wg sync.WaitGroup

func grabFeed(feed *Feed, feedChan chan bool, osvg *svg.SVG) {

  startGrab := time.Now().Unix()
  startGrabSeconds := startGrab - startTime

  fmt.Println("Grabbing feed",feed.url," 
    at",startGrabSeconds,"second mark")

  if feed.status == 0 {
    fmt.Println("Feed not yet read")
    feed.status = 1

    startX := int(startGrabSeconds * 33);
    startY := feedSpace * (feed.index)

    fmt.Println(startY)
    wg.Add(1)

    rssFeed := rss.New(timeout, true, channelHandler, 
      itemsHandler);

    if err := rssFeed.Fetch(feed.url, nil); err != nil {
      fmt.Fprintf(os.Stderr, "[e] %s: %s", feed.url, err)
      return
    } else {

      endSec := time.Now().Unix()    
      endX := int( (endSec - startGrab) )
      if endX == 0 {
        endX = 1
      }
      fmt.Println("Read feed in",endX,"seconds")
      osvg.Rect(startX,startY,endX,feedSpace,"fill: 
        #000000;opacity:.4")
      wg.Wait()

      endGrab := time.Now().Unix()
      endGrabSeconds := endGrab - startTime
      feedEndX := int(endGrabSeconds * 33);      

      osvg.Rect(feedEndX,startY,1,feedSpace,"fill:#ff0000;opacity:.9")

      feedChan <- true
    }

  }else if feed.status == 1{
    fmt.Println("Feed already in progress")
  }

}

grabFeed()方法直接控制抓取任何单个源的流程。它还通过WaitGroup结构绕过了潜在的并发重复。接下来,让我们看看itemsHandler函数:

func channelHandler(feed *rss.Feed, newchannels []*rss.Channel) {

}

func itemsHandler(feed *rss.Feed, ch *rss.Channel, newitems []*rss.Item) {

  fmt.Println("Found",len(newitems),"items in",feed.Url)

  for i := range newitems {
    url := *newitems[i].Guid
    fmt.Println(url)

  }

  wg.Done()
}

itemsHandler函数目前并没有做太多事情,除了实例化一个新的FeedItem结构体——在现实世界中,我们会将这作为下一步,并检索这些项本身的值。我们的下一步是查看抓取单个 feed 并标记每个 feed 所花费的时间的过程,如下所示:

func getRSS(rw http.ResponseWriter, req *http.Request) {
  startTime = time.Now().Unix()  
  rw.Header().Set("Content-Type", "image/svg+xml")
  outputSVG := svg.New(rw)
  outputSVG.Start(width, height)

  feedSpace = (height-20) / len(feeds)

  for i:= 0; i < 30000; i++ {
    timeText := strconv.FormatInt(int64(i/10),10)
    if i % 1000 == 0 {
      outputSVG.Text(i/30,390,timeText,"text-anchor:middle;font-
        size:10px;fill:#000000")      
    }else if i % 4 == 0 {
      outputSVG.Circle(i,377,1,"fill:#cccccc;stroke:none")  
    }

    if i % 10 == 0 {
      outputSVG.Rect(i,0,1,400,"fill:#dddddd")
    }
    if i % 50 == 0 {
      outputSVG.Rect(i,0,1,400,"fill:#cccccc")
    }

  }

  feedChan := make(chan bool, 3)

  for i := range feeds {

    outputSVG.Rect(0, (i*feedSpace), width, feedSpace, 
      "fill:"+colors[i]+";stroke:none;")
    feeds[i].status = 0
    go grabFeed(&feeds[i], feedChan, outputSVG)
    <- feedChan
  }

  outputSVG.End()
}

在这里,我们获取 RSS 源并在我们的 SVG 上标记我们的检索和读取事件的状态。我们的main()函数主要处理源的设置,如下所示:

func main() {

  runtime.GOMAXPROCS(2)

  timeout = 1000

  width = 1000
  height = 400

  feeds = append(feeds, Feed{index: 0, url: 
    "https://groups.google.com/forum/feed/golang-
    nuts/msgs/rss_v2_0.xml?num=50", status: 0, itemCount: 0, 
    complete: false, itemsComplete: false})
  feeds = append(feeds, Feed{index: 1, url: 
    "http://www.reddit.com/r/golang/.rss", status: 0, itemCount: 
    0, complete: false, itemsComplete: false})
  feeds = append(feeds, Feed{index: 2, url: 
    "https://groups.google.com/forum/feed/golang-
    dev/msgs/rss_v2_0.xml?num=50", status: 0, itemCount: 0, 
    complete: false, itemsComplete: false })

这是我们的FeedItem结构体的切片:

  colors = append(colors,"#ff9999")
  colors = append(colors,"#99ff99")
  colors = append(colors,"#9999ff")  

在打印版本中,这些颜色可能并不特别有用,但在您的系统上测试它将允许您区分应用程序内部的事件。我们需要一个 HTTP 路由作为终点;以下是我们将如何设置它:

  http.Handle("/getrss", http.HandlerFunc(getRSS))
    err := http.ListenAndServe(":1900", nil)
    if err != nil {
        log.Fatal("ListenAndServe:", err)
    }  
}

运行时,您应该看到 RSS feed 检索和解析的开始和持续时间,然后是一条细线,表示该 feed 已被解析并且所有项已被读取。

三个块中的每一个都表达了处理每个 feed 的全部时间,展示了这个版本的非并发执行,如下面的屏幕截图所示:

带有自我诊断功能的 RSS 阅读器

请注意,我们对 feed 项并没有做任何有趣的事情,我们只是读取 URL。下一步将是通过 HTTP 获取这些项,如下面的代码片段所示:

  url := *newitems[i].Guid
      response, _, err := http.Get(url)
      if err != nil {

      }

通过这个例子,我们在每一步都停下来向 SVG 提供某种反馈,告诉它发生了某种事件。我们的通道在这里是有缓冲的,我们明确声明它必须在完成阻塞之前接收三条布尔消息,如下面的代码片段所示:

  feedChan := make(chan bool, 3)

  for i := range feeds {

    outputSVG.Rect(0, (i*feedSpace), width, feedSpace, 
      "fill:"+colors[i]+";stroke:none;")
    feeds[i].status = 0
    go grabFeed(&feeds[i], feedChan, outputSVG)
    <- feedChan
  }

  outputSVG.End()

通过在我们的通道调用中给出3作为第二个参数,我们告诉 Go 这个通道必须在继续应用程序之前接收三个响应。不过,您应该谨慎使用这个功能,特别是在明确设置事物时。如果其中一个 goroutine 从未通过通道发送布尔值会怎么样?应用程序会崩溃。

请注意,我们在这里还增加了我们的时间轴,从 800 毫秒增加到 60 秒,以便检索所有的 feeds。请记住,如果我们的脚本超过 60 秒,那么超过这个时间的所有操作都将发生在这个可视时间轴表示之外。

通过在读取 feeds 时实现WaitGroup结构,我们对应用程序施加了一些串行化和同步。第二个 feed 将在第一个 feed 完成检索所有 URL 之前不会开始。您可能会看到这可能会引入一些错误:

    wg.Add(1)
    rssFeed := rss.New(timeout, true, channelHandler, 
      itemsHandler);
    …
    wg.Wait()

这告诉我们的应用程序要等到我们从itemsHandler()函数中设置Done()命令为止。

那么如果我们完全删除WaitGroups会发生什么?考虑到抓取 feed 项的调用是异步的,我们可能看不到所有的 RSS 调用的状态;相反,我们可能只看到一个或两个 feeds,或者根本没有 feed。

强加超时

那么如果在我们的时间轴内没有运行任何东西会发生什么?正如您所期望的那样,我们将得到三个没有任何活动的条形图。重要的是要考虑如何终止那些没有按我们期望的方式运行的进程。在这种情况下,最好的方法是超时。http包中的Get方法并不原生支持超时,因此如果您想要防止这些请求永无止境地进行并杀死您的应用程序,您将不得不自己编写rssFeed.Fetch(和底层的http.Get())实现。我们稍后会深入探讨这一点;与此同时,看一下核心http包中可用的Transport结构,网址为golang.org/pkg/net/http/#Transport

关于 CSP 的一点说明

我们在上一章中简要介绍了 CSP,但在 Go 的并发模型操作方式的背景下,值得更深入地探讨一下。

CSP 在 20 世纪 70 年代末和 80 年代初通过 Tony Hoare 爵士的工作发展起来,至今仍在不断发展中。Go 的实现在很大程度上基于 CSP,但它既不完全遵循初始描述中设定的所有规则和惯例,也不遵循其自那时以来的发展。

Go 与真正的 CSP 不同的一种方式是,根据其定义,Go 中的一个进程只会继续存在,只要存在一个准备好从该进程接收的通道。 我们已经遇到了一些由于监听通道没有接收任何内容而导致的死锁。 反之亦然;死锁也可能是由于通道继续而没有发送任何内容,使其接收通道无限期挂起。

这种行为是 Go 调度程序的典型特征,当您最初使用通道时,它确实只会在您处理通道时造成问题。

注意

霍尔的原始作品现在可以从许多机构(大部分)免费获得。 您可以免费阅读,引用,复制和重新分发它(但不能用于商业用途)。 如果您想阅读整个内容,可以在www.cs.ucf.edu/courses/cop4020/sum2009/CSP-hoare.pdf上获取。

完整的书本本身也可以在www.usingcsp.com/cspbook.pdf上获得。

截至本出版之时,霍尔正在微软担任研究员。

根据应用程序设计者的说法,Go 实现 CSP 概念的目标是专注于简单性-除非您真的想要或需要,否则您不必担心线程或互斥量。

餐桌哲学家问题

您可能已经听说过餐桌哲学家问题,这描述了并发编程旨在解决的问题类型。 餐桌哲学家问题是由伟大的 Edsger Dijkstra 提出的。 问题的关键在于资源-五位哲学家坐在一张桌子旁,有五盘食物和五把叉子,每个人只有在他有两把叉子(一把在左边,另一把在右边)时才能吃饭。 可视化表示如下:

The dining philosophers problem

在任一侧只有一把叉子时,任何给定的哲学家只有在双手都拿着叉子时才能吃饭,并且在完成后必须将两者都放回桌子上。 思想是协调餐点,以便所有哲学家都可以永远吃饭而不会挨饿-任何时刻都必须有两位哲学家能够吃饭,而且不能有死锁。 他们是哲学家,因为当他们不吃饭时,他们在思考。 在编程类比中,您可以将其视为等待通道或睡眠进程。

Go 使用 goroutines 相当简洁地处理了这个问题。 给定五位哲学家(例如在一个单独的结构中),您可以让这五位哲学家在思考、在叉子放下时接收通知、抓住叉子、用叉子就餐和放下叉子之间交替。

接收到叉子放下的通知作为监听通道,就餐和思考是独立的过程,放下叉子则是沿通道的公告。

我们可以在以下伪 Go 代码中可视化这个概念:

type Philosopher struct {
  leftHand bool
  rightHand bool
  status int
  name string
}

func main() {

  philosophers := [...]Philospher{"Kant", "Turing", 
    "Descartes","Kierkegaard","Wittgenstein"}

  evaluate := func() {
    for {

      select {
        case <- forkUp:
          // philosophers think!
        case <- forkDown:
          // next philospher eats in round robin
      }

    }

  }

}

这个例子被留得非常抽象和非操作性,以便您有机会尝试解决它。 我们将在下一章中为此构建一个功能性解决方案,因此请确保稍后比较您的解决方案。

有数百种处理此问题的方法,我们将看看一些替代方案以及它们在 Go 本身内部如何能够或不能够很好地发挥作用。

Go 和演员模型

如果您是 Erlang 或 Scala 用户,那么演员模型可能是您非常熟悉的。 CSP 和演员模型之间的区别微不足道但很重要。 使用 CSP,如果另一个通道正在监听并准备好接收消息,那么来自一个通道的消息只能完全发送。 演员模型并不一定需要准备好的通道才能发送。 实际上,它强调直接通信,而不是依赖通道的导管。

这两种系统都可能是不确定的,这在我们之前的 Go/CSP 示例中已经看到了。CSP 和 goroutines 都是匿名的,传输由通道而不是源和目的地指定。在演员模型的伪代码中可视化这一点的简单方法如下:

a = new Actor
b = new Actor
a -> b("message")

在 CSP 中,它是这样的:

a = new Actor
b = new Actor
c = new Channel
a -> c("sending something")
b <- c("receiving something")

它们都通过稍微不同的方式提供了相同的基本功能。

面向对象

当你使用 Go 时,你会注意到一个核心特征经常被提倡,用户可能觉得是错误的。你会听到 Go 不是一种面向对象的语言,然而你有可以有方法的结构体,这些方法反过来又可以有方法,你可以与任何实例进行通信。通道本身可能感觉像原始的对象接口,能够从给定的数据元素中设置和接收值。

Go 的消息传递实现确实是面向对象编程的核心概念。具有接口的结构本质上就像类,Go 支持多态性(尽管不支持参数多态性)。然而,许多使用该语言的人(以及设计它的人)强调它并不是面向对象的。那么是什么原因呢?

这个定义的很大程度上取决于你问的是谁。有些人认为 Go 缺乏面向对象编程的必要特征,而其他人认为它满足了这些特征。最重要的是要记住,你并不受 Go 设计的限制。在真正的面向对象语言中可以做的任何事情在 Go 中都可以轻松处理。

演示 Go 中简单的多态性

如前所述,如果你期望多态性类似于面向对象编程,这可能不代表一种语法类比。然而,使用接口作为类绑定多态方法的抽象同样干净,并且在许多方面更加明确和可读。让我们看一个 Go 中多态性的非常简单的实现:

type intInterface struct {

}

type stringInterface struct {

}

func (number intInterface) Add (a int, b int) int {
  return a + b;
}

func (text stringInterface) Add (a string, b string) string {
  return a + b
}

func main() {

  number := new (intInterface)
    fmt.Println( number.Add(1,2) )

  text := new (stringInterface)
    fmt.Println( text.Add("this old man"," he played one"))

}

正如你所看到的,我们使用接口(或其 Go 模拟)来消除方法的歧义。例如,你不能像在 Java 中那样使用泛型。然而,这归结为最终只是一种风格问题。你既不应该觉得这令人畏惧,也不会给你的代码带来任何混乱或歧义。

使用并发

尚未提到的是,我们应该意识到,并发并不总是对应用程序有益的。并没有真正的经验之谈,而且并发很少会给应用程序带来问题;但是如果你真的考虑整个应用程序,不是所有的应用程序都需要并发进程。

那么什么效果最好呢?正如我们在之前的例子中看到的,任何引入潜在延迟或 I/O 阻塞的东西,比如网络调用、磁盘读取、第三方应用程序(主要是数据库)和分布式系统,都可以从并发中受益。如果你有能力在未确定的时间表上进行工作,那么并发策略可以提高应用程序的速度和可靠性。

这里的教训是你不应该感到被迫将并发加入到一个真正不需要它的应用程序中。具有进程间依赖关系(或缺乏阻塞和外部依赖关系)的程序可能很少或根本不会从实现并发结构中获益。

管理线程

到目前为止,你可能已经注意到,在 Go 中,线程管理并不是程序员最关心的问题。这是有意设计的。Goroutines 并不绑定到 Go 内部调度程序处理的特定线程或线程。然而,这并不意味着你既不能访问线程,也不能控制单个线程的操作。正如你所知,你已经可以告诉 Go 你有多少线程(或希望使用)通过使用GOMAXPROCS。我们也知道,使用这个可能会引入与数据一致性和执行顺序相关的异步问题。

在这一点上,线程的主要问题不是它们如何被访问或利用,而是如何正确地控制执行流程,以确保你的数据是可预测的和同步的。

使用 sync 和互斥锁来锁定数据

你可能在前面的例子中遇到的一个问题是原子数据的概念。毕竟,如果你在多个 goroutines 和可能的处理器之间处理变量和结构,你如何确保你的数据在它们之间是安全的?如果这些进程并行运行,协调数据访问有时可能会有问题。

Go 在其sync包中提供了大量工具来处理这些类型的问题。你如何优雅地处理它们在很大程度上取决于你的方法,但在这个领域你不应该不得不重新发明轮子。

我们已经看过WaitGroup结构,它提供了一种简单的方法,告诉主线程暂停,直到下一个通知说等待的进程已经完成了它应该做的事情。

Go 还提供了对互斥锁的直接抽象。称某物为直接抽象可能看起来矛盾,但事实上你并没有访问 Go 的调度程序,只是一个真正互斥锁的近似。

我们可以使用互斥锁来锁定和解锁数据,并保证数据的原子性。在许多情况下,这可能是不必要的;有很多时候,执行顺序并不影响底层数据的一致性。然而,当我们对这个值有顾虑时,能够显式地调用锁是很有帮助的。让我们看下面的例子:

package main

import(
  "fmt"
  "sync"
)

func main() {
  current := 0
  iterations := 100
  wg := new (sync.WaitGroup);

  for i := 0; i < iterations; i++ {
    wg.Add(1)

    go func() {
      current++
      fmt.Println(current)
      wg.Done()
    }()
    wg.Wait()
  }

}

毫不奇怪,在你的终端中提供了 0 到 99 的列表。如果我们将WaitGroup更改为知道将调用 100 个Done()实例,并将我们的阻塞代码放在循环的末尾,会发生什么?

为了演示为什么以及如何最好地利用waitGroups作为并发控制机制的一个简单命题,让我们做一个简单的数字迭代器并查看结果。我们还将看看如何直接调用互斥锁可以增强这种功能,如下所示:

func main() {
  runtime.GOMAXPROCS(2)
  current := 0
  iterations := 100
  wg := new (sync.WaitGroup);
  wg.Add(iterations)
  for i := 0; i < iterations; i++ {
    go func() {
      current++
      fmt.Println(current)
      wg.Done()
    }()

  }
  wg.Wait()

}

现在,我们的执行顺序突然错了。你可能会看到类似以下的输出:

95
96
98
99
100
3
4

我们有能力随时锁定和解锁当前命令;然而,这不会改变底层的执行顺序,它只会阻止对变量的读取和/或写入,直到调用解锁为止。

让我们尝试使用mutex锁定我们输出的变量,如下所示:

  for i := 0; i < iterations; i++ {
    go func() {
      mutex.Lock()
      fmt.Println(current)
      current++
      mutex.Unlock()
      fmt.Println(current)
      wg.Done()
    }()

  }

你可能已经看到,在并发应用程序中,互斥控制机制如何重要,以确保数据的完整性。我们将在第四章应用程序中的数据完整性中更多地了解互斥锁和锁定和解锁过程。

总结

在本章中,我们试图通过给出一些可视化的实时反馈,来消除 Go 并发模式和模型的一些模糊性,包括一个基本的 RSS 聚合器和阅读器。我们研究了餐桌哲学家问题,并探讨了如何使用 Go 并发主题来整洁而简洁地解决问题。我们比较了 CSP 和 actor 模型的相似之处以及它们的不同之处。

在下一章中,我们将把这些概念应用到开发应用程序中维护并发性的过程中。

第三章:制定并发策略

在上一章中,我们看到了 Go 依赖的并发模型,以使开发人员的生活更轻松。我们还看到了并行性和并发性的可视化表示。这些帮助我们理解串行、并发和并行应用程序之间的差异和重叠。

然而,任何并发应用程序中最关键的部分不是并发本身,而是并发进程之间的通信和协调。

在本章中,我们将着重考虑创建一个应用程序的计划,该计划严重考虑了进程之间的通信,以及缺乏协调可能导致一致性方面的重大问题。我们将探讨如何在纸上可视化我们的并发策略,以便更好地预见潜在问题。

应用复杂并发的效率

在设计应用程序时,我们经常放弃复杂的模式,选择简单性,假设简单的系统通常是最快和最有效的。似乎只有逻辑上,机器的移动部分越少,效率就会比移动部分更多的机器更高。

这里的悖论是,对并发的应用,增加冗余和更多的可移动部分通常会导致更高效的应用。如果我们认为并发方案(如 goroutines)是无限可扩展的资源,那么使用更多的资源应该总是会带来某种形式的效率收益。这不仅适用于并行并发,也适用于单核并发。

如果你发现自己设计的应用程序利用并发,却牺牲了效率、速度和一致性,你应该问问自己这个应用程序是否真的需要并发。

当我们谈论效率时,我们不仅仅是在处理速度。效率还应该考虑 CPU 和内存开销以及确保数据一致性的成本。

例如,如果一个应用程序在一定程度上受益于并发,但需要一个复杂和/或计算昂贵的过程来保证数据一致性,那么重新评估整个策略是值得的。

保持数据的可靠性和最新性应该是最重要的;虽然不可靠的数据可能并不总是会产生灾难性的影响,但它肯定会损害你的应用程序的可靠性。

使用竞争检测识别竞争条件

如果你曾经编写过一个依赖于函数或方法的确切时间和顺序来创建期望输出的应用程序,你对竞争条件已经非常熟悉了。

这些在处理并发时特别常见,当引入并行时更加常见。在前几章中,我们确实遇到了一些问题,特别是在我们的递增数字函数中。

竞争条件最常用的教育示例是银行账户。假设你从 1000 美元开始,尝试进行 200 笔 5 美元的交易。每笔交易都需要查询账户的当前余额。如果通过,交易将获批准,从余额中扣除 5 美元。如果失败,交易将被拒绝,余额保持不变。

这一切都很好,直到查询在并发事务中的某个时刻发生(在大多数情况下是在另一个线程中)。例如,一个线程在另一个线程正在移除 5 美元但尚未完成的过程中询问“你的账户里有 5 美元吗?”这样,你最终可能会得到一个本应该被拒绝的交易。

追踪竞争条件的原因可能是一个巨大的头痛。在 Go 的 1.1 版本中,Google 引入了一种竞争检测工具,可以帮助你找到潜在的问题。

让我们以一个具有竞争条件的多线程应用程序的非常基本的例子为例,看看 Golang 如何帮助我们调试它。在这个例子中,我们将建立一个银行账户,初始金额为 1000 美元,进行 100 笔随机金额在 0 到 25 美元之间的交易。

每个交易将在自己的 goroutine 中运行,如下所示:

package main

import(
  "fmt"
  "time"
  "sync"
  "runtime"
  "math/rand"
)  

var balance int
var transactionNo int

func main() {
  rand.Seed(time.Now().Unix())
  runtime.GOMAXPROCS(2)
  var wg sync.WaitGroup

  tranChan := make(chan bool)

  balance = 1000
  transactionNo = 0
  fmt.Println("Starting balance: $",balance)

  wg.Add(1)
  for i := 0; i < 100; i++ {
    go func(ii int, trChan chan(bool)) {
      transactionAmount := rand.Intn(25)
      transaction(transactionAmount)
      if (ii == 99) {
        trChan <- true
      }

    }(i,tranChan)
  }

  go transaction(0)
  select {

    case <- tranChan:
      fmt.Println("Transactions finished")
      wg.Done()

  }

  wg.Wait()
  close(tranChan)
  fmt.Println("Final balance: $",balance)
}

func transaction(amt int) (bool) {

  approved := false  
  if (balance-amt) < 0 {
    approved = false
  }else {
    approved = true
    balance = balance - amt
  }

  approvedText := "declined"
  if (approved == true) {
    approvedText = "approved"
  }else {

  }
  transactionNo = transactionNo + 1
  fmt.Println(transactionNo,"Transaction for $",amt,approvedText)
  fmt.Println("\tRemaining balance $",balance)
  return approved
}

根据您的环境(以及是否启用多个处理器),您可能会发现先前的 goroutine 成功地操作了$0 或更多的最终余额。另一方面,您可能最终只会得到超出交易时余额的交易,导致负余额。

那么我们怎么知道呢?

对于大多数应用程序和语言来说,这个过程通常涉及大量的运行、重新运行和日志记录。竞态条件往往会导致令人望而生畏和费力的调试过程。Google 知道这一点,并为我们提供了一个竞态条件检测工具。要测试这一点,只需在测试、构建或运行应用程序时使用-race标志,如下所示:

go run -race race-test.go

当在先前的代码上运行时,Go 将执行应用程序,然后报告任何可能的竞态条件,如下所示:

>> Final balance: $0
>> Found 2 data race(s)

在这里,Go 告诉我们数据存在两种潜在的竞态条件。它并没有告诉我们这些一定会导致数据一致性问题,但如果您遇到这样的问题,这可能会给您一些线索。

如果您查看输出的顶部,您将得到有关导致竞态条件的详细说明。在这个例子中,详细信息如下:

==================
WARNING: DATA RACE
Write by goroutine 5: main.transaction()   /var/go/race.go:75 +0xbd 
 main.func┬╖001()   /var/go/race.go:31 +0x44

Previous write by goroutine 4: main.transaction() 
 /var/go/race.go:75 +0xbd main.func┬╖001()   /var/go/race.go:31 
 +0x44

Goroutine 5 (running) created at: main.main()   /var/go/race.go:36 
 +0x21c

Goroutine 4 (finished) created at: main.main()   /var/go/race.go:36 
 +0x21c

我们可以得到详细的、完整的跟踪,了解我们的潜在竞态条件存在的位置。相当有帮助,对吧?

竞态检测器保证不会产生错误的阳性结果,因此您可以将结果视为您的代码中存在潜在问题的有力证据。这里强调潜在性,因为竞态条件在正常情况下很容易被忽略——一个应用程序可能在几天、几个月甚至几年内都能正常工作,然后才会出现竞态条件。

提示

我们已经提到了日志记录,如果您对 Go 的核心语言不是非常熟悉,您的想法可能会有很多方向——stdout、文件日志等等。到目前为止,我们一直使用 stdout,但您可以使用标准库来处理这些日志记录。Go 的 log 包允许您按照以下方式写入 io 或 stdout:

  messageOutput := os.Stdout
  logOut := log.New(messageOutput,"Message: ",log.
  Ldate|log.Ltime|log.Llongfile);
  logOut.Println("This is a message from the 
  application!")

这将产生以下输出:

Message: 2014/01/21 20:59:11 /var/go/log.go:12: This is a message from the application!

那么,log 包相对于自己编写的优势在哪里呢?除了标准化之外,这个包在输出方面也是同步的。

那么现在呢?嗯,有几种选择。您可以利用通道来确保数据的完整性,使用缓冲通道,或者您可以使用sync.Mutex结构来锁定您的数据。

使用互斥

通常,互斥被认为是在应用程序中实现同步的一种低级和最为人熟知的方法——您应该能够在通道之间的通信中解决数据一致性。然而,在某些情况下,您需要真正地在处理数据时阻止读/写。

在 CPU 级别,互斥表示在寄存器之间交换二进制整数值以获取和释放锁。当然,我们将处理更高级别的东西。

我们已经熟悉了 sync 包,因为我们使用了WaitGroup结构,但该包还包含了条件变量struct CondOnce,它们将执行一次操作,以及互斥锁RWMutexMutex。正如RWMutex的名称所暗示的那样,它对多个读取者和/或写入者进行锁定和解锁;本章后面和第五章中还有更多内容,锁、块和更好的通道

正如包名所暗示的那样,所有这些都赋予您防止可能被任意数量的 goroutines 和/或线程访问的数据发生竞态条件的能力。使用此包中的任何方法都不能确保数据和结构的原子性,但它确实为您提供了有效管理原子性的工具。让我们看看我们可以在并发的、线程安全的应用程序中巩固我们的账户余额的一些方法。

如前所述,我们可以在通道级别协调数据更改,无论该通道是缓冲还是非缓冲。让我们将逻辑和数据操作卸载到通道,并查看-race标志呈现了什么。

如果我们修改我们的主循环,如下面的代码所示,以利用通道接收的消息来管理余额值,我们将避免竞态条件:

package main

import(
  "fmt"
  "time"
  "sync"
  "runtime"
  "math/rand"
)  

var balance int
var transactionNo int

func main() {
  rand.Seed(time.Now().Unix())
  runtime.GOMAXPROCS(2)
  var wg sync.WaitGroup
  balanceChan := make(chan int)
  tranChan := make(chan bool)

  balance = 1000
  transactionNo = 0
  fmt.Println("Starting balance: $",balance)

  wg.Add(1)
  for i:= 0; i<100; i++ {

    go func(ii int) {

      transactionAmount := rand.Intn(25)
      balanceChan <- transactionAmount

      if ii == 99 {
        fmt.Println("Should be quittin time")
        tranChan <- true
        close(balanceChan)
        wg.Done()
      }

    }(i)

  }

  go transaction(0)

    breakPoint := false
    for {
      if breakPoint == true {
        break
      }
      select {
        case amt:= <- balanceChan:
          fmt.Println("Transaction for $",amt)
          if (balance - amt) < 0 {
            fmt.Println("Transaction failed!")
          }else {
            balance = balance - amt
            fmt.Println("Transaction succeeded")
          }
          fmt.Println("Balance now $",balance)

        case status := <- tranChan:
          if status == true {
            fmt.Println("Done")
            breakPoint = true
            close(tranChan)

          }
      }
    }

  wg.Wait()

  fmt.Println("Final balance: $",balance)
}

func transaction(amt int) (bool) {

  approved := false  
  if (balance-amt) < 0 {
    approved = false
  }else {
    approved = true
    balance = balance - amt
  }

  approvedText := "declined"
  if (approved == true) {
    approvedText = "approved"
  }else {

  }
  transactionNo = transactionNo + 1
  fmt.Println(transactionNo,"Transaction for $",amt,approvedText)
  fmt.Println("\tRemaining balance $",balance)
  return approved
}

这一次,我们让通道完全管理数据。让我们看看我们在做什么:

transactionAmount := rand.Intn(25)
balanceChan <- transactionAmount

这仍然会生成 0 到 25 之间的随机整数,但我们不是将其传递给函数,而是通过通道传递数据。通道允许您整洁地控制数据的所有权。然后我们看到选择/监听器,它在很大程度上与本章前面定义的transaction()函数相似:

case amt:= <- balanceChan:
fmt.Println("Transaction for $",amt)
if (balance - amt) < 0 {
  fmt.Println("Transaction failed!")
}else {
  balance = balance - amt
  fmt.Println("Transaction succeeded")
}
fmt.Println("Balance now $",balance)

为了测试我们是否避免了竞态条件,我们可以再次使用-race标志运行go run,并且不会收到警告。

通道可以被视为处理同步dataUse Sync.Mutex()的官方方式。

如前所述,拥有内置的竞态检测器是大多数语言的开发人员无法享受的奢侈品,拥有它使我们能够测试方法并获得实时反馈。

我们注意到,使用显式互斥锁不鼓励使用 goroutines 的通道。这并不总是完全正确,因为每件事都有正确的时间和地点,互斥锁也不例外。值得注意的是,互斥锁在 Go 中是由通道内部实现的。正如之前提到的,您可以使用显式通道来处理读取和写入,并在它们之间搬移数据。

然而,这并不意味着显式锁没有用处。一个具有许多读取和很少写入的应用程序可能会受益于显式锁定写入;这并不一定意味着读取将是脏读取,但可能会导致更快和/或更多并发的执行。

为了演示起见,让我们使用显式锁来消除我们的竞态条件。我们的-race标志告诉我们它在哪里遇到读/写竞态条件,如下所示:

Read by goroutine 5: main.transaction()   /var/go/race.go:62 +0x46

前一行只是我们从竞态检测报告中得到的几行中的一行。如果我们查看代码中的第 62 行,我们会找到对balance的引用。我们还会找到对transactionNo的引用,我们的第二个竞态条件。解决这两个问题最简单的方法是在transaction函数的内容周围放置一个互斥锁,因为这是修改balancetransactionNo变量的函数。transaction函数如下所示:

func transaction(amt int) (bool) {
  mutex.Lock()

  approved := false
  if (balance-amt) < 0 {
    approved = false
  }else {
    approved = true
    balance = balance - amt
  }

  approvedText := "declined"
  if (approved == true) {
    approvedText = "approved"
  }else {

  }
  transactionNo = transactionNo + 1
  fmt.Println(transactionNo,"Transaction for $",amt,approvedText)
  fmt.Println("\tRemaining balance $",balance)

  mutex.Unlock()
  return approved
}

我们还需要在应用程序顶部将mutex定义为全局变量,如下所示:

var mutex sync.Mutex

如果我们现在使用-race标志运行我们的应用程序,我们将不会收到警告。

mutex变量在实际目的上是WaitGroup结构的替代品,它作为条件同步机制。这也是通道操作的方式——沿通道移动的数据在 goroutines 之间是受限和隔离的。通过将 goroutine 状态绑定到WaitGroup,通道可以有效地作为先进先出工具工作;然后通过低级互斥锁为通道上跨通道访问的数据提供安全性。

另一个值得注意的事情是通道的多功能性——我们有能力在一系列 goroutines 之间共享通道以接收和/或发送数据,并且作为一等公民,我们可以在函数中传递它们。

探索超时

我们还可以使用通道显式在指定的时间后终止它们。如果决定手动处理互斥锁,这将是一个更复杂的操作。

通过通道终止长时间运行的例程的能力非常有帮助;考虑一个依赖网络的操作,不仅应该受限于短时间段,而且也不允许长时间运行。换句话说,你想给这个过程几秒钟来完成;但如果它运行超过一分钟,我们的应用程序应该知道出了什么问题,以至于停止尝试在该通道上监听或发送。以下代码演示了在select调用中使用超时通道:

func main() {

  ourCh := make(chan string,1)

  go func() {

  }()

  select {
    case <-time.After(10 * time.Second):
      fmt.Println("Enough's enough")
      close(ourCh)
  }

}

如果我们运行前面的简单应用程序,我们会看到我们的 goroutine 将被允许在 10 秒钟后什么都不做,之后我们实施一个超时保障,让我们退出。

你可以把这看作在网络应用程序中特别有用;即使在阻塞和依赖线程的服务器时代,像这样的超时也被实施以防止单个行为不端的请求或进程阻塞整个服务器。这正是我们稍后将更详细讨论的经典网络服务器问题的基础。

一致性的重要性

在我们的示例中,我们将构建一个事件调度程序。如果我们可以参加会议,并且我们收到两个并发的会议邀请请求,如果存在竞争条件,我们将被重复预订。或者,两个 goroutine 之间的锁定数据可能会导致两个请求都被拒绝,或者导致实际死锁。

我们希望保证任何可用性请求都是一致的——既不应该出现重复预订,也不应该错误地阻止事件请求(因为两个并发或并行例程同时锁定数据)。

同步我们的并发操作

同步一词字面上指的是时间存在-事情同时发生。因此,同步性最恰当的演示似乎将涉及时间本身。

当我们考虑时间如何影响我们时,通常涉及安排、截止日期和协调。回到前言中的初步示例,如果有人想要计划他们祖母的生日派对,以下类型的安排任务可以采取多种形式:

  • 必须在某个时间之前完成的事情(实际派对)

  • 直到另一个任务完成后才能完成的事情(在购买装饰品之前放置装饰品)

  • 可以按任何特定顺序完成的事情而不会影响结果(打扫房子)

  • 可以按任何顺序完成但可能会影响结果的事情(在弄清楚你祖母最喜欢的蛋糕之前买蛋糕)

有了这些想法,我们将尝试通过设计一个预约日历来处理一些基本的人类安排,该日历可以处理任意数量的人,每个人在上午 9 点到下午 5 点之间有一个小时的时间段。

这个项目-多用户预约日历

当你决定写一个程序时,你会做什么?

如果你和很多人一样,你会考虑这个程序;也许你和团队会起草一份规范或需求文档,然后你就开始编码。有时,会有一张图表示应用程序的工作方式的某种类似物。

很多时候,确定应用程序的架构和内部工作方式的最佳方法是拿起铅笔和纸,直观地表示程序的工作方式。对于许多线性或串行应用程序来说,这通常是一个不必要的步骤,因为事情将以可预测的方式进行,不需要在应用程序逻辑内部进行任何特定的协调(尽管协调第三方软件可能会受益于规范)。

你可能熟悉类似以下图表的一些逻辑:

该项目-多用户预约日历

这里的逻辑是有道理的。如果您还记得我们的前言,当人类绘制流程时,我们倾向于将它们串行化。从视觉上看,从第一步到第二步,有限数量的流程是容易理解的。

然而,在设计并发应用程序时,至少要考虑无数的并发请求、流程和逻辑,以确保我们的应用程序最终达到我们想要的位置,并获得我们期望的数据和结果。

在上一个例子中,我们完全忽略了“用户是否可用”的可能失败或报告旧或错误数据的可能性。如果我们发现这些问题,是否更有意义去解决它们,或者应该预见它们作为控制流的一部分?向模型添加复杂性可以帮助我们减少未来数据完整性问题的几率。

让我们再次进行可视化,考虑到可用性轮询器将请求用户的可用性与任何给定的时间/用户对。

可视化并发模式

正如我们已经讨论过的,我们希望创建一个应用程序应该如何运行的基本蓝图。在这里,我们将实现一些控制流,这与用户活动有关,以帮助我们决定我们需要包含哪些功能。以下图表说明了控制流可能是什么样子:

可视化并发模式

在之前的图表中,我们预见到数据可以使用并发和并行流程共享,以找到故障点。如果我们以这种图形方式设计并发应用程序,我们就不太可能在以后发现竞争条件。

虽然我们谈到了 Go 如何帮助您在应用程序完成运行后找到这些问题,但我们理想的开发工作流程是尝试在开始时解决这些问题。

开发我们的服务器需求

现在我们已经有了调度过程应该如何工作的想法,我们需要确定应用程序将需要的组件。在这种情况下,组件如下:

  • Web 服务器处理程序

  • 输出的模板

  • 用于确定日期和时间的系统

Web 服务器

在我们之前章节的可视化并发示例中,我们使用了 Go 的内置http包,我们在这里也会这样做。有许多出色的框架可以实现这一点,但它们主要是扩展核心 Go 功能,而不是重新发明轮子。以下是其中一些功能,从轻到重列出:

Web.go 非常轻量级和精简,并提供了一些在net/http包中不可用的路由功能。

Gorilla 是一个瑞士军刀,用于增强net/http包。它并不特别沉重,而且速度快,实用,非常干净。

Revel 是这三者中最沉重的,但它专注于直观的代码、缓存和性能。如果您需要一个成熟的、将面临大量流量的东西,可以考虑它。

在第六章中,C10K – A Non-blocking Web Server in Go,我们将自己开发一个旨在实现极高性能的 Web 服务器和框架。

大猩猩工具包

对于这个应用程序,我们将部分使用 Gorilla Web 工具包。 Gorilla 是一个相当成熟的 Web 服务器平台,在这里本地实现了我们的一些需求,即能够在 URL 路由中包含正则表达式的能力。(注意:Web.Go 还扩展了部分功能。)Go 的内部 HTTP 路由处理程序相当简单;当然您可以扩展这个,但在这里我们将走一条经过磨练和可靠的捷径。

我们将仅使用这个包来方便 URL 路由,但 Gorilla Web Toolkit 还包括处理 cookies、会话和请求变量的包。我们将在第六章中更详细地研究这个包,C10K – 一个 Go 中的非阻塞 Web 服务器

使用模板

由于 Go 被设计为一种系统语言,而系统语言通常涉及创建服务器和客户端,因此我们在创建 Web 服务器时非常注重使其成为一个功能齐全的替代方案。

任何处理过“网络语言”的人都会知道,除此之外你还需要一个框架,理想情况下是一个处理网络呈现层的框架。虽然如果你接手这样的项目,你可能会寻找或构建自己的框架,但 Go 使得模板方面的事情非常容易。

模板包有两种类型:texthttp。虽然它们都服务于不同的端点,但相同的属性——提供动态性和灵活性——适用于呈现层,而不仅仅是应用层。

提示

text模板包用于一般纯文本文档,而http模板包用于生成 HTML 和相关文档。

这些模板范式在今天太常见了;如果你看一下http/template包,你会发现它与 Mustache 有很强的相似之处,Mustache 是更受欢迎的变体之一。虽然 Go 中有一个 Mustache 端口,但在模板包中默认处理了所有这些。

注意

有关 Mustache 的更多信息,请访问mustache.github.io/

Mustache 的一个潜在优势是它在其他语言中也是可用的。如果你曾经感到有必要将应用逻辑转移到另一种语言(或将现有模板转移到 Go 中),使用 Mustache 可能是有利的。也就是说,你牺牲了 Go 模板的许多扩展功能,即从编译包中取出 Go 代码并将其直接移入模板控制结构的能力。虽然 Mustache(及其变体)有控制流,但它们可能不会与 Go 的模板系统相匹配。看下面的例子:

<ul>
{{range .Users}}
<li>A User </li>
{{end}}
</ul>

鉴于对 Go 逻辑结构的熟悉程度,保持它们在我们的模板语言中保持一致是有意义的。

注意

我们不会在这个帖子中展示所有具体的模板,但我们会展示输出。如果你想浏览它们,它们可以在mastergoco.com/chapters/3/templates上找到。

时间

我们在这里没有做太多的数学运算;时间将被分成小时块,每个小时块将被设置为占用或可用。目前,Go 中没有太多外部的date/time包。我们没有进行任何复杂的日期数学运算,但这并不重要,因为即使我们需要,Go 的time包也应该足够。

实际上,由于我们从上午 9 点到下午 5 点有文字的时间段,我们只需将它们设置为 9-17 的 24 小时时间值,并调用一个函数将它们转换为语言日期。

端点

我们将想要识别 REST 端点(通过GET请求)并简要描述它们的工作原理。你可以将它们看作是模型-视图-控制器架构中的模块或方法。以下是我们将使用的端点模式列表:

  • entrypoint/register/{name}:这是我们将要去的地方,添加一个名字到用户列表中。如果用户存在,它将失败。

  • entrypoint/viewusers:在这里,我们将展示一个用户列表,包括他们的时间段,可用和占用。

  • entrypoint/schedule/{name}/{time}:这将初始化一个预约的尝试。

每个都将有一个相应的模板,报告预期动作的状态。

自定义结构

我们将处理用户和响应(网页),所以我们需要两个结构来表示每个。一个结构如下:

type User struct {
  Name string
  email string
  times[int] bool
}

另一个结构如下:

type Page struct {
  Title string
  Body string
}

我们将尽量保持页面尽可能简单。我们将在代码中生成大部分 HTML,而不是进行大量的迭代循环。

我们的请求端点将与我们之前的架构相关联,使用以下代码:

func users(w http.ResponseWriter, r *http.Request) {
}
func register(w http.ResponseWriter, r *http.Request) {
}
func schedule(w http.ResponseWriter, r *http.Request) {
}

多用户预约日历

在本节中,我们将快速查看我们的样本预约日历应用程序,该应用程序试图控制特定元素的一致性,以避免明显的竞争条件。以下是完整的代码,包括路由和模板:

package main

import(
  "net/http"
  "html/template"
  "fmt"
  "github.com/gorilla/mux"
  "sync"
  "strconv"
)

type User struct {
  Name string
  Times map[int] bool
  DateHTML template.HTML
}

type Page struct {
  Title string
  Body template.HTML
  Users map[string] User
}

var usersInit map[string] bool
var userIndex int
var validTimes []int
var mutex sync.Mutex
var Users map[string]User
var templates = template.Must(template.New("template").ParseFiles("view_users.html", "register.html"))

func register(w http.ResponseWriter, r *http.Request){
  fmt.Println("Request to /register")
  params := mux.Vars(r)
  name := params["name"]

  if _,ok := Users[name]; ok {
    t,_ := template.ParseFiles("generic.txt")
    page := &Page{ Title: "User already exists", Body: 
      template.HTML("User " + name + " already exists")}
    t.Execute(w, page)
  }  else {
          newUser := User { Name: name }
          initUser(&newUser)
          Users[name] = newUser
          t,_ := template.ParseFiles("generic.txt")
          page := &Page{ Title: "User created!", Body: 
            template.HTML("You have created user "+name)}
          t.Execute(w, page)
    }

}

func dismissData(st1 int, st2 bool) {

// Does nothing in particular for now other than avoid Go compiler 
  errors
}

func formatTime(hour int) string {
  hourText := hour
  ampm := "am"
  if (hour > 11) {
    ampm = "pm"
  }
  if (hour > 12) {
    hourText = hour - 12;
  }
fmt.Println(ampm)
  outputString := strconv.FormatInt(int64(hourText),10) + ampm

  return outputString
}

func (u User) FormatAvailableTimes() template.HTML { HTML := "" 
  HTML += "<b>"+u.Name+"</b> - "

  for k,v := range u.Times { dismissData(k,v)

    if (u.Times[k] == true) { formattedTime := formatTime(k) HTML 
      += "<a href='/schedule/"+u.Name+"/"+strconv.FormatInt(int64(k),10)+"' class='button'>"+formattedTime+"</a> "

    } else {

    }

 } return template.HTML(HTML)
}

func users(w http.ResponseWriter, r *http.Request) {
  fmt.Println("Request to /users")

  t,_ := template.ParseFiles("users.txt")
  page := &Page{ Title: "View Users", Users: Users}
  t.Execute(w, page)
}

func schedule(w http.ResponseWriter, r *http.Request) {
  fmt.Println("Request to /schedule")
  params := mux.Vars(r)
  name := params["name"]
  time := params["hour"]
  timeVal,_ := strconv.ParseInt( time, 10, 0 )
  intTimeVal := int(timeVal)

  createURL := "/register/"+name

  if _,ok := Users[name]; ok {
    if Users[name].Times[intTimeVal] == true {
      mutex.Lock()
      Users[name].Times[intTimeVal] = false
      mutex.Unlock()
      fmt.Println("User exists, variable should be modified")
      t,_ := template.ParseFiles("generic.txt")
      page := &Page{ Title: "Successfully Scheduled!", Body: 
        template.HTML("This appointment has been scheduled. <a 
          href='/users'>Back to users</a>")}

      t.Execute(w, page)

    }  else {
            fmt.Println("User exists, spot is taken!")
            t,_ := template.ParseFiles("generic.txt")
            page := &Page{ Title: "Booked!", Body: 
              template.HTML("Sorry, "+name+" is booked for 
              "+time+" <a href='/users'>Back to users</a>")}
      t.Execute(w, page)

    }

  }  else {
          fmt.Println("User does not exist")
          t,_ := template.ParseFiles("generic.txt")
          page := &Page{ Title: "User Does Not Exist!", Body: 
            template.HTML( "Sorry, that user does not exist. Click 
              <a href='"+createURL+"'>here</a> to create it. <a 
                href='/users'>Back to users</a>")}
    t.Execute(w, page)
  }
  fmt.Println(name,time)
}

func defaultPage(w http.ResponseWriter, r *http.Request) {

}

func initUser(user *User) {

  user.Times = make(map[int] bool)
  for i := 9; i < 18; i ++ {
    user.Times[i] = true
  }

}

func main() {
  Users = make(map[string] User)
  userIndex = 0
  bill := User {Name: "Bill"  }
  initUser(&bill)
  Users["Bill"] = bill
  userIndex++

  r := mux.NewRouter()  r.HandleFunc("/", defaultPage)
    r.HandleFunc("/users", users)  
      r.HandleFunc("/register/{name:[A-Za-z]+}", register)
        r.HandleFunc("/schedule/{name:[A-Za-z]+}/{hour:[0-9]+}", 
          schedule)     http.Handle("/", r)

  err := http.ListenAndServe(":1900", nil)  if err != nil {    // 
    log.Fatal("ListenAndServe:", err)    }

}

请注意,我们用一个名为 Bill 的用户种子化了我们的应用程序。如果您尝试访问/register/bill|bill@example.com,应用程序将报告该用户已存在。

由于我们通过渠道控制了最敏感的数据,我们避免了任何竞争条件。我们可以通过几种方式来测试这一点。第一种最简单的方法是记录成功预约的数量,并以 Bill 作为默认用户运行。

然后我们可以对该操作运行并发负载测试器。有许多这样的测试器可用,包括 Apache 的 ab 和 Siege。为了我们的目的,我们将使用 JMeter,主要是因为它允许我们同时对多个 URL 进行测试。

提示

虽然我们并不一定使用 JMeter 进行负载测试(而是用它来运行并发测试),但负载测试工具可以是发现应用程序中尚不存在的规模的瓶颈的非常有价值的方式。

例如,如果您构建了一个具有阻塞元素并且每天有 5,000-10,000 个请求的 Web 应用程序,您可能不会注意到它。但是在每天 500 万-1000 万次请求时,它可能导致应用程序崩溃。

在网络服务器的黎明时代,情况就是这样;服务器扩展到某一天,突然间,它们无法再扩展。负载/压力测试工具允许您模拟流量,以更好地检测这些问题和低效。

鉴于我们有一个用户和一天八个小时,我们应该在脚本结束时最多有八个成功的预约。当然,如果您访问/register端点,您将看到比您添加的用户多八倍的用户。以下截图显示了我们在 JMeter 中的基准测试计划:

多用户预约日历

当您运行应用程序时,请注意您的控制台;在我们的负载测试结束时,我们应该会看到以下消息:

Total registered appointments: 8

如果我们按照本章中最初的图形模拟表示设计我们的应用程序(存在竞争条件),那么我们可能会注册比实际存在的预约要多得多。

通过隔离潜在的竞争条件,我们保证数据一致性,并确保没有人在等待与其他人预约时间冲突的预约。以下截图是我们呈现的所有用户及其可用预约时间的列表:

多用户预约日历

上一个截图是我们的初始视图,显示了可用用户及其可用的时间段。通过为用户选择一个时间段,我们将尝试为其预约该特定时间。我们将从下午 5 点开始尝试 Nathan。

以下截图显示了当我们尝试与一个可用用户安排时会发生什么:

多用户预约日历

然而,如果我们再次尝试预约(甚至同时),我们将收到一个悲伤的消息,即 Nathan 无法在下午 5 点见我们,如下面的截图所示:

多用户预约日历

有了这个,我们有了一个允许创建新用户、安排和阻止重复预约的多用户日历应用程序。

让我们来看看这个应用程序中一些有趣的新点。

首先,您会注意到我们在大部分应用程序中使用了一个名为generic.txt的模板。这并不复杂,只有一个页面标题和每个处理程序填写的正文。然而,在/users端点上,我们使用users.txt如下:

<html>
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-
    8"> 
  <title>{{.Title}}</title>
</head>
<body>

<h1>{{.Title}}</h1>

{{range .Users}}
<div class="user-row">

  {{.FormatAvailableTimes}}

</div>
{{end}}

</body>
</html>

我们在模板中提到了基于范围的功能,但是{{.FormatAvailableTimes}}是如何工作的呢?在任何给定的上下文中,我们可以有特定于类型的函数,以比模板词法分析器严格可用的更复杂的方式处理数据。

在这种情况下,User结构体被传递到以下代码行:

func (u User) FormatAvailableTimes() template.HTML {

然后,这行代码执行一些条件分析,并返回一些时间转换的字符串。

在这个例子中,您可以使用一个通道来控制User.times的流程,或者像我们这样使用一个显式的互斥锁。除非绝对必要,我们不希望限制所有锁,因此只有在确定请求已经通过必要的测试来修改任何给定用户/时间对的状态时,我们才调用Lock()函数。下面的代码显示了我们在互斥锁中设置用户的可用性的地方:

if _,ok := Users[name]; ok {
  if Users[name].Times[intTimeVal] == true {
    mutex.Lock()
    Users[name].Times[intTimeVal] = false
    mutex.Unlock()

外部评估检查是否存在具有该名称(键)的用户。第二次评估检查时间可用性是否存在(true)。如果是,我们锁定变量,将其设置为false,然后继续输出渲染。

没有Lock()函数,许多并发连接可能会损害数据的一致性,并导致用户在特定小时内有多个预约。

风格注意事项

请注意,尽管我们更喜欢大多数变量使用驼峰命名法,但在结构体中有一些大写变量。这是一个重要的 Go 约定,值得一提:任何以大写字母开头的结构体变量都是公共的。任何以小写字母开头的变量都是私有的

如果您尝试在模板文件中输出私有(或不存在的)变量,模板渲染将失败。

关于不可变性的说明

请注意,尽可能避免在模板文件中使用字符串类型进行比较操作,特别是在多线程环境中。在前面的例子中,我们使用整数和布尔值来决定任何给定用户的可用性。在某些语言中,您可能会感到有能力将时间值分配给字符串以便使用。在大多数情况下,这是可以的,即使在 Go 中也是如此;但是假设我们有一个无限可扩展的共享日历应用程序,如果我们以这种方式使用字符串,就会引入内存问题的风险。

在 Go 中,字符串类型是唯一的不可变类型;如果您最终将值分配和重新分配给字符串,这是值得注意的。假设在将字符串转换为副本后释放内存,这不是问题。然而,在 Go(以及其他几种语言)中,完全有可能保留原始值在内存中。我们可以使用以下示例进行测试:

func main() {

  testString := "Watch your top / resource monitor"
  for i:= 0; i < 1000; i++ {

    testString = string(i)

  }
  doNothing(testString)  

  time.Sleep(10 * time.Second)

}

在 Ubuntu 中运行时,这大约需要 1.0 MB 的内存;其中一些无疑是开销,但这是一个有用的参考点。让我们稍微加大一点——虽然有 1,000 个相对较小的指针不会产生太大影响——使用以下代码行:

for i:= 0; i < 100000000; i++ {

现在,经过 1 亿次内存分配,您可以看到对内存的影响(此时字符串本身比初始值更长并不会占据全部影响)。垃圾回收也会在这里发生,这会影响 CPU。在我们的初始测试中,CPU 和内存都会飙升。如果我们将其替换为整数或布尔值分配,我们会得到更小的印记。

这并不是一个真实的场景,但在并发环境中,垃圾回收必须发生,以便我们可以评估我们的逻辑的属性和类型,这是值得注意的。

根据您当前的 Go 版本、您的机器等情况,这两种情况可能都能够以高效方式运行。虽然这可能看起来不错,但是我们的并发策略规划的一部分应该包括我们的应用程序将在输入、输出、物理资源或所有这些方面扩展的可能性。现在能够很好地工作并不意味着不值得实施效率,以避免在 100 倍规模时引起性能问题。

如果你遇到一个地方,那里一个字符串是合乎逻辑的,但你想要或者可以从可变类型中受益,考虑使用字节切片。

常量当然也是不可变的,但鉴于常量变量的暗含目的,你应该已经知道这一点。可变的常量变量毕竟是一个矛盾。

总结

本章希望引导您在深入研究之前探索规划和绘制并发应用程序的方法。通过简要介绍竞争条件和数据一致性,我们试图突出预期设计的重要性。同时,我们利用了一些工具来识别这些问题,如果它们发生的话。

创建一个具有并发进程的健壮脚本流程图将帮助你在创建之前找到可能的陷阱,并且它将让你更好地了解你的应用程序应该如何(以及何时)根据逻辑和数据做出决策。

在下一章中,我们将研究数据一致性问题,并探讨高级通道通信选项,以避免不必要且经常昂贵的缓解功能、互斥锁和外部进程。

第四章:应用程序中的数据完整性

到目前为止,您应该已经熟悉了 Go 核心中提供的模型和工具,以提供大部分无竞争的并发。

现在我们可以轻松创建 goroutines 和通道,管理通道之间的基本通信,协调数据而不会出现竞争条件,并在出现这些条件时检测到。

然而,我们既不能管理更大的分布式系统,也不能处理潜在的较低级别的一致性问题。我们使用了基本和简单的互斥锁,但我们将要看一种更复杂和富有表现力的处理互斥排他的方法。

在本章结束时,您应该能够将上一章中的并发模式扩展到使用其他语言的多种并发模型和系统构建分布式系统。我们还将从高层次上看一些一致性模型,您可以利用这些模型来进一步表达您的单源和分布式应用程序的预编码策略。

深入了解互斥锁和同步

在第二章理解并发模型中,我们介绍了sync.mutex以及如何在代码中调用互斥锁,但在考虑包和互斥锁类型时还有一些微妙之处。

在理想的世界中,您应该能够仅使用 goroutines 来维护应用程序中的同步。实际上,这可能最好被描述为 Go 中的规范方法,尽管sync包提供了一些其他实用程序,包括互斥锁。

在可能的情况下,我们将坚持使用 goroutines 和通道来管理一致性,但互斥锁确实提供了一种更传统和细粒度的方法来锁定和访问数据。如果您曾经管理过另一种并发语言(或语言内的包),很可能您已经使用过互斥锁或类似的东西。在接下来的章节中,我们将探讨如何扩展和利用互斥锁,以便更多地发挥其作用。

如果我们查看sync包,我们会看到有几种不同的互斥锁结构。

第一个是sync.mutex,我们已经探讨过了,但另一个是RWMutexRWMutex结构提供了多读单写锁。如果您希望允许对资源进行读取,但在尝试写入时提供类似互斥锁的锁定,这些锁可能很有用。当您期望函数或子进程频繁读取但很少写入时,它们可以最好地利用,但仍然不能承受脏读。

让我们看一个例子,每隔 10 秒更新一次日期/时间(获取锁定),然后每隔两秒输出当前值,如下面的代码所示:

package main

import (
  "fmt"
  "sync"
  "time"
)

type TimeStruct struct {
  totalChanges int
  currentTime time.Time
  rwLock sync.RWMutex
}

var TimeElement TimeStruct

func updateTime() {
  TimeElement.rwLock.Lock()
  defer TimeElement.rwLock.Unlock()
  TimeElement.currentTime = time.Now()
  TimeElement.totalChanges++
}

func main() {

  var wg sync.WaitGroup

  TimeElement.totalChanges = 0
  TimeElement.currentTime = time.Now()
  timer := time.NewTicker(1 * time.Second)
  writeTimer := time.NewTicker(10 * time.Second)
  endTimer := make(chan bool)

  wg.Add(1)
  go func() {

    for {
      select {
      case <-timer.C:
        fmt.Println(TimeElement.totalChanges, 
          TimeElement.currentTime.String())
      case <-writeTimer.C:
        updateTime()
      case <-endTimer:
        timer.Stop()
        return
      }

    }

  }()

  wg.Wait()
  fmt.Println(TimeElement.currentTime.String())
}

注意

我们没有在WaitGroup结构上显式运行Done(),因此这将永久运行。

RWMutex上执行锁定/解锁的两种不同方法:

  • Lock(): 这将阻止变量进行读取和写入,直到调用Unlock()方法

  • happenedRlock(): 这将仅为读取锁定绑定变量

第二种方法是我们用于此示例的方法,因为我们希望模拟真实世界的锁定。净效果是interval函数输出当前时间,然后在rwLock释放对currentTime变量的读取锁之前返回一个脏读。Sleep()方法仅用于给我们时间来观察锁定的运动。RWLock结构可以被多个读取者或单个写入者获取。

goroutines 的成本

当你使用 goroutines 时,你可能会到达一个点,你会产生几十甚至几百个 goroutines,并且会想知道这是否会很昂贵。如果你之前的并发和/或并行编程经验主要是基于线程的,这是特别真实的。通常认为,维护线程及其各自的堆栈可能会导致程序性能问题。这有几个原因,如下所示:

  • 为线程的创建需要内存

  • 在操作系统级别进行上下文切换比进程内上下文切换更复杂和昂贵

  • 很多时候,一个线程被创建用于处理本来可以以其他方式处理的非常小的进程

正因为这些原因,许多现代并发语言实现了类似 goroutines 的东西(C#使用 async 和 await 机制,Python 有 greenlets/green threads 等),这些机制使用小规模的上下文切换来模拟线程。

然而,值得知道的是,虽然 goroutines 是(或者可以是)廉价的,比操作系统线程更便宜,但它们并不是免费的。在大规模(也许是巨大规模)下,即使是廉价和轻量级的 goroutines 也会影响性能。这在我们开始研究分布式系统时尤为重要,因为这些系统通常规模更大,速度更快。

直接运行函数和在 goroutine 中运行函数之间的差异当然是可以忽略的。然而,要记住 Go 的文档中指出:

在同一个地址空间中创建数十万个 goroutines 是实际可行的。

考虑到每个 goroutine 使用几千字节的堆栈,现代环境中,很容易看出这可能被视为一个不重要的因素。然而,当你开始谈论成千上万(或者百万)个 goroutines 在运行时,它可能会影响任何给定子进程或函数的性能。你可以通过将函数包装在任意数量的 goroutines 中并对平均执行时间和——更重要的是——内存使用进行基准测试来测试这一点。每个 goroutine 大约占用 5KB 的内存,你可能会发现内存可能成为一个因素,特别是在低 RAM 的机器或实例上。如果你有一个在高性能机器上运行的应用程序,想象一下它在一个或多个低功率机器上达到临界点。考虑以下例子:

for i:= 0; i < 1000000000; i++ {
  go someFunction()
}

即使 goroutine 的开销很小,但是当有 1 亿个或者——就像我们这里有的——10 亿个 goroutines 在运行时会发生什么?

正如以往一样,在一个利用多个核心的环境中进行这样的操作实际上可能会增加应用程序的开销,因为涉及到操作系统线程和随后的上下文切换的成本。

这些问题几乎总是在应用程序开始扩展之前是看不见的。在你的机器上运行是一回事,但在一个分布式系统中运行,尤其是在低功率应用服务器上运行,就是另一回事了。

性能和数据一致性之间的关系很重要,特别是当你开始使用大量的 goroutines 进行互斥、锁定或通道通信时。

当处理外部、更持久的内存来源时,这就成为一个更大的问题。

处理文件

文件是数据一致性问题的一个很好的例子,比如竞争条件可能导致更加持久和灾难性的问题。让我们看一个可能不断尝试更新文件的代码片段,看看我们可能会遇到竞争条件的地方,这反过来可能会导致更大的问题,比如应用程序失败或数据一致性丢失:

package main

import(
  "fmt"
  "io/ioutil"
  "strconv"
  "sync"
)

func writeFile(i int) {

  rwLock.RLock();
  ioutil.WriteFile("test.txt", 
    []byte(strconv.FormatInt(int64(i),10)), 0x777)
  rwLock.RUnlock();

  writer<-true

}

var writer chan bool
var rwLock sync.RWMutex

func main() {

  writer = make(chan bool)

  for i:=0;i<10;i++ {
    go writeFile(i)
  }

  <-writer
  fmt.Println("Done!")
}

涉及文件操作的代码很容易出现这种潜在问题,因为错误通常不是短暂的,并且可能永远被固定在时间中。

如果我们的 goroutines 在某个关键点阻塞,或者应用程序在中途失败,我们可能会得到一个文件中包含无效数据的结果。在这种情况下,我们只是在一些数字中进行迭代,但您也可以将这种情况应用到涉及数据库或数据存储写入的情况——存在永久性的坏数据而不是临时的坏数据的潜在可能。

这不是仅通过通道或互斥来解决的问题;相反,它需要在每一步进行某种理智检查,以确保数据在执行的每一步中都在您和应用程序期望的位置。任何涉及io.Writer的操作都依赖于原语,Go 的文档明确指出我们不应该假设它们对并行执行是安全的。在这种情况下,我们已经在互斥体中包装了文件写入。

降低实现 C

在过去的十年或二十年中,语言设计中最有趣的发展之一是希望通过 API 实现低级语言和语言特性。Java 允许您纯粹在外部进行这样的操作,而 Python 提供了一个 C 库,用于在这两种语言之间进行交互。值得一提的是,这样做的原因各不相同——其中包括将 Go 的并发特性作为对遗留 C 代码的包装——您可能需要处理与引入非托管代码到垃圾收集应用程序相关的一些内存管理。

Go 采取了混合方法,允许您通过导入调用 C 接口,这需要一个前端编译器,比如 GCC:

import "C"

那么我们为什么要这样做呢?

在你的项目中直接实现 C 有一些好的和坏的原因。一个好的原因可能是直接访问内联汇编,这在 C 中可以做到,但在 Go 中不能直接做到。一个坏的原因可能是任何一个在 Golang 本身中有解决方案的原因。

公平地说,即使是一个坏的原因,如果您构建应用程序可靠,也不是坏事,但它确实给可能使用您的代码的其他人增加了额外的复杂性。如果 Go 能满足技术和性能要求,最好在单个项目中使用单一语言。

C++的创造者 Bjarne Stroustrup 有一句著名的关于 C 和 C++的引语:

C 使得自己开枪变得容易;C++使得更难,但当你这样做时,它会把你的整条腿都炸掉。

开玩笑的时候(Stroustrup 有大量这样的笑话和引语),基本的推理是 C 的复杂性经常阻止人们意外地做出灾难性的事情。

正如 Stroustrup 所说,C 使犯大错变得容易,但由于语言设计,后果通常比高级语言要小。处理安全和稳定性问题很容易在任何低级语言中引入。

通过简化语言,C++提供了使低级操作更容易进行的抽象。您可以看到这可能如何应用于在 Go 中直接使用 C,鉴于后者语法上的甜美和程序员友好性。

也就是说,使用 C 可以突出显示关于内存、指针、死锁和一致性的潜在陷阱,所以我们将以一个简单的例子来说明:

package main

// #include <stdio.h>
// #include <string.h>
//  int string_length (char* str) {
//    return strlen(str);
//  }
import "C"
import "fmt"
func main() {
  v := C.CString("Don't Forget My Memory Is Not Visible To Go!")
  x := C.string_length(v)
  fmt.Println("A C function has determined your string 
    is",x,"characters in length")
}

在 cgo 中触及内存

从前面的例子中最重要的收获是要记住,每当您进入或退出 C 时,您都需要手动管理内存(或者至少比仅使用 Go 更直接地管理)。如果您曾经在 C(或 C++)中工作过,您就会知道没有自动垃圾收集,所以如果您请求内存空间,您也必须释放它。从 Go 调用 C 并不排除这一点。

cgo 的结构

将 C 导入 Go 将使您走上一个语法侧路,正如您可能在前面的代码中注意到的。最显眼的不同之处是在您的应用程序中实际实现 C 代码。

任何位于import "C"指令上方的代码(在注释中阻止 Go 编译器失败)将被解释为 C 代码。以下是一个在我们的 Go 代码上方声明的 C 函数的示例:

/*
  int addition(int a, int b) {
    return a + b;
  }

请记住,Go 不会验证这一点,因此如果您在 C 代码中出现错误,可能会导致静默失败。

另一个相关的警告是记住您的语法。虽然 Go 和 C 有很多语法上的重叠,但如果少了一个花括号或一个分号,您很可能会发现自己处于其中一个静默失败的情况。或者,如果您在应用程序的 C 部分工作,然后回到 Go,您肯定会发现自己需要在循环表达式中加上括号,并在行尾加上分号。

还要记住,您经常需要处理 C 和 Go 之间没有一对一对应的类型转换。例如,C 没有内置的字符串类型(当然,您可以包含其他类型的库),因此您可能需要在字符串和 char 数组之间进行转换。同样,intint64可能需要一些非隐式转换,而且在编译这些代码时,您可能无法获得您期望的调试反馈。

另一种方式

在 Go 中使用 C 显然是一个潜在的强大工具,用于代码迁移,实现低级代码,并吸引其他开发人员,但反过来呢?就像您可以从 Go 中调用 C 一样,您也可以在嵌入的 C 中将 Go 函数作为外部函数调用。

最终目标是能够在同一个应用程序中与 C 和 Go 一起工作。到目前为止,处理这个问题最简单的方法是使用 gccgo,它是 GCC 的前端。这与内置的 Go 编译器不同;当然,可以在 C 和 Go 之间来回切换,但使用 gccgo 可以使这个过程更简单。

gopart.go

以下是交互的 Go 部分的代码,C 部分将作为外部函数调用:

package main

func MyGoFunction(num C.int) int {

  squared := num * num
  fmt.Println(num,"squared is",squared)
  return squared
}

cpart.c

现在是 C 部分的时间,我们在下面的代码片段中调用我们的 Go 应用程序的导出函数MyGoFunction

#include <stdio.h>

extern int square_it(int) __asm__ ("cross.main.MyGoFunction")

int main() {

  int output = square_it(5)
  printf("Output: %d",output)
  return 0;
}

Makefile

与直接在 Go 中使用 C 不同,目前,对反向操作需要使用 C 编译的 makefile。以下是一个您可以使用的示例,用于从之前的简单示例中获取可执行文件:

all: main

main: cpart.o cpart.c
    gcc cpart.o cpart.c -o main

gopart.o: gopart.go
    gccgo -c gopart.go -o gopart.o -fgo-prefix=cross

clean:
    rm -f main *.o

在这里运行 makefile 应该会生成一个可执行文件,该文件调用了 C 中的函数。

然而,更根本的是,cgo 允许您直接将函数定义为 C 的外部函数:

package output

import "C"

//export MyGoFunction
func MyGoFunction(num int) int {

  squared := num * num
  return squared
}

接下来,您需要直接使用cgo工具为 C 生成头文件,如下面的代码行所示:

go tool cgo goback.go

此时,Go 函数可以在您的 C 应用程序中使用:

#include <stdio.h>
#include "_obj/_cgo_export.h"

extern int MyGoFunction(int num);

int main() {

  int result = MyGoFunction(5);
  printf("Output: %d",result);
  return 0;

}

请注意,如果导出一个包含多个返回值的 Go 函数,它将在 C 中作为结构体而不是函数可用,因为 C 不提供从函数返回多个变量的功能。

此时,您可能意识到这种功能的真正力量是直接从现有的 C(甚至 C++)应用程序中与 Go 应用程序进行接口交互的能力。

虽然不一定是真正的 API,但现在您可以将 Go 应用程序视为 C 应用程序中的链接库,反之亦然。

关于使用//export指令的一个警告:如果这样做,您的 C 代码必须引用这些作为 extern 声明的函数。如您所知,当 C 应用程序需要从另一个链接的 C 文件中调用函数时,会使用 extern。

当以这种方式构建我们的 Go 代码时,cgo 会生成头文件_cgo_export.h,就像您之前看到的那样。如果您想查看该代码,它可以帮助您了解 Go 如何将编译的应用程序转换为 C 头文件以供此类用途使用:

/* Created by cgo - DO NOT EDIT. */
#include "_cgo_export.h"

extern void crosscall2(void (*fn)(void *, int), void *, int);

extern void _cgoexp_d133c8d0d35b_MyGoFunction(void *, int);

GoInt64 MyGoFunction(GoInt p0)
{
  struct {
    GoInt p0;
    GoInt64 r0;
  } __attribute__((packed)) a;
  a.p0 = p0;
  crosscall2(_cgoexp_d133c8d0d35b_MyGoFunction, &a, 16);
  return a.r0;
}

你可能也会遇到一个罕见的情况,即 C 代码不完全符合你的期望,你无法诱使编译器产生你期望的结果。在这种情况下,你可以在编译 C 应用程序之前自由修改头文件,尽管会有“请勿编辑”的警告。

进一步降低 - 在 Go 中进行汇编

如果你可以用 C 射击自己的脚,用 C++炸掉自己的腿,那么想象一下你在 Go 中使用汇编可以做些什么。

在 Go 中直接使用汇编是不可能的,但是由于 Go 直接提供对 C 的访问,而 C 提供了调用内联汇编的能力,你可以间接地在 Go 中使用它。

但同样,仅仅因为某件事是可能的,并不意味着应该这样做——如果你发现自己需要在 Go 中使用汇编,你应该考虑直接使用汇编,并通过 API 连接。

在使用汇编语言(首先是在 C 中,然后是在 Go 中)时,你可能会遇到许多障碍,其中之一就是缺乏可移植性。编写内联 C 是一回事——你的代码应该在处理器指令集和操作系统之间相对可移植——但是汇编显然需要很多具体性。

尽管如此,当你考虑是否需要在 Go 应用程序中直接使用 C 或汇编时,最好还是有自毁的选择,无论你选择射击与否。在考虑是否需要 C 或汇编直接在你的 Go 应用程序中时,一定要非常小心。如果你可以通过 API 或进程间通道在不协调的进程之间进行通信,总是首选这种方式。

在 Go 中(或独立使用或在 C 中)使用汇编的一个非常明显的缺点是,你失去了 Go 提供的交叉编译能力,因此你必须为每个目标 CPU 架构修改你的代码。因此,使用 Go 在 C 中的唯一实际时机是当你的应用程序应该在单个平台上运行时。

这是一个 ASM-in-C-in-Go 应用程序的示例。请记住,我们没有包含 ASM 代码,因为它因处理器类型而异。在以下的__asm__部分尝试一些样板汇编:

package main

/*
#include <stdio.h>

void asmCall() {

__asm__( "" );
    printf("I come from a %s","C function with embedded asm\n");

}
*/
import "C"

func main() {

    C.asmCall()

}

如果没有其他办法,即使你对汇编和 C 本身都不熟悉,这可能也会为你提供一个深入研究 ASM 的途径。你认为 C 和 Go 越高级,你可能会看到这一点越实际。

对于大多数用途来说,Go(当然还有 C)的层次足够低,可以在不使用汇编的情况下解决任何性能问题。值得再次注意的是,当你调用 C 应用程序时,虽然你失去了对 Go 中内存和指针的一些直接控制,但是在调用汇编时,这个警告适用十倍。Go 提供的所有这些巧妙的工具可能无法可靠地工作,或者根本无法工作。如果你考虑 Go 竞争检测器,可以考虑以下应用程序:

package main

/*
int increment(int i) {
  i++;
  return i;
}
*/
import "C"
import "fmt"

var myNumber int

func main() {
  fmt.Println(myNumber)

  for i:=0;i<100;i++ {
    myNumber = int( C.increment(C.int(myNumber)) )
    fmt.Println(myNumber)
  }

}

你可以看到,在 Go 和 C 之间抛来抛去指针可能会让你在程序没有得到你期望的结果时一筹莫展。

请记住,在这里使用 goroutines 与 cgo 有一个有点独特且可能意想不到的地方;它们默认被视为阻塞。这并不是说你不能在 C 中管理并发,但这不会默认发生。相反,Go 可能会启动另一个系统线程。你可以通过利用运行时函数runtime.LockOSThread()在一定程度上管理这一点。使用LockOSThread告诉 Go 特定的 goroutine 应该留在当前线程中,直到调用runtime.UnlockOSThread()之前,没有其他并发的 goroutine 可以使用这个线程。

这取决于直接调用 C 或 C 库的必要性;一些库将愉快地作为新线程被创建,而另一些可能会导致段错误。

注意

在你的 Go 代码中,另一个有用的运行时调用是NumGcoCall()。它返回当前进程所做的 cgo 调用次数。如果你需要锁定和解锁线程,你也可以使用它来构建一个内部队列报告,以检测和防止死锁。

这并不排除如果您选择在 goroutines 中混合使用 Go 和 C 时可能会发生竞争条件的可能性。

当然,C 本身有一些可用的竞争检测工具。Go 的竞争检测器本身是基于ThreadSanitizer库的。毋庸置疑,您可能不希望在单个项目中使用几个完成相同任务的工具。

分布式 Go

到目前为止,我们已经谈了很多关于在单个机器内管理数据的内容,尽管有一个或多个核心。这已经足够复杂了。防止竞争条件和死锁本来就很困难,但是当您引入更多的机器(虚拟或真实)时会发生什么?

首先应该想到的是,您可以放弃 Go 提供的许多固有工具,而且在很大程度上是真的。您可以基本上保证 Go 可以处理其自己的、单一的 goroutines 和通道内的数据的内部锁定和解锁,但是如果有一个或多个额外的应用程序实例在运行呢?考虑以下模型:

分布式 Go

在这里,我们看到这两个进程中的任何一个线程都可能在任何给定时间点从我们的关键数据中读取或写入。考虑到这一点,存在协调对该数据的访问的需要。

在非常高的层面上,有两种直接的策略来处理这个问题,分布式锁或一致性哈希表(一致性哈希)。

第一种策略是互斥的扩展,只是我们没有直接和共享访问相同的地址空间,所以我们需要创建一个抽象。换句话说,我们的工作是设计一个对所有可用的外部实体可见的锁机制。

第二种策略是一种专门设计用于缓存和缓存验证/失效的模式,但它在这里也具有相关性,因为您可以使用它来管理数据在更全局的地址空间中的位置。

然而,当涉及确保这些系统之间的一致性时,我们需要比这种一般的高层方法更深入。

将这个模型一分为二就变得容易了:通道将处理数据和数据结构的并发流动,而在它们不处理的地方,您可以使用互斥锁或低级原子性来添加额外的保障。

然而,看向右边。现在你有另一个 VM/实例或机器试图处理相同的数据。我们如何确保我们不会遇到读者/写者问题?

一些常见的一致性模型

幸运的是,我们有一些非核心的 Go 解决方案和策略,可以帮助我们提高控制数据一致性的能力。

让我们简要地看一下我们可以使用的一些一致性模型来管理分布式系统中的数据。

分布式共享内存

分布式共享内存DSM)系统本身并不固有地防止竞争条件,因为它只是一种多个系统共享实际或分区内存的方法。

实质上,您可以想象两个具有 1GB 内存的系统,每个系统将 500MB 分配给一个可由每个系统访问和写入的共享内存空间。脏读是可能的,竞争条件也是可能的,除非明确设计。以下图表示了两个系统如何使用共享内存进行协调的视觉表示:

分布式共享内存

我们很快将看一下 DSM 的一个著名但简单的例子,并使用 Go 可用的库进行测试。

先进先出 - PRAM

流水线 RAMPRAM)一致性是一种先进先出的方法,其中数据可以按照排队写入的顺序读取。这意味着任何给定的、独立的进程读取的写入可能是不同的。以下图表示了这个概念:

先进先出 - PRAM

看看主从模型

主从一致性模型与我们即将看到的领导者/追随者模型类似,只是主服务器管理数据和广播的所有操作,而不是从追随者接收写操作。在这种情况下,复制是从主服务器到从服务器传输数据更改的主要方法。在下图中,您将找到一个具有主服务器和四个从服务器的主从模型的表示:

查看主从模型

虽然我们可以简单地在 Go 中复制这个模型,但我们有更优雅的解决方案可供选择。

生产者-消费者问题

在经典的生产者-消费者问题中,生产者将数据块写入到传送带/缓冲区,而消费者读取数据块。问题出现在缓冲区满时:如果生产者添加到堆栈,读取的数据将不是您想要的。为了避免这种情况,我们使用了带有等待和信号的通道。这个模型看起来有点像下面的图:

生产者-消费者问题

如果您正在寻找 Go 中的信号量实现,那么并没有显式使用信号量。但是,想想这里的语言——具有等待和信号的固定大小通道;听起来像是一个缓冲通道。事实上,通过在 Go 中提供一个缓冲通道,您为这里的传送带提供了一个明确的长度;通道机制为您提供了等待和信号的通信。这已经纳入了 Go 的并发模型中。让我们快速看一下下面的代码中所示的生产者-消费者模型。

package main

import(
  "fmt"
)

var comm = make(chan bool)
var done = make(chan bool)

func producer() {
  for i:=0; i< 10; i++ {
    comm <- true
  }
  done <- true
}
func consumer() {
  for {
    communication := <-comm
    fmt.Println("Communication from producer 
      received!",communication)
  }
}

func main() {
  go producer()
  go consumer()
  <- done
  fmt.Println("All Done!")
}

查看领导者-追随者模型

在领导者/追随者模型中,写操作从单一源广播到任何追随者。写操作可以通过任意数量的追随者传递,也可以限制在单个追随者。任何完成的写操作然后被广播到追随者。这可以在以下图中进行可视化表示:

查看领导者-追随者模型

我们在 Go 中也可以看到一个通道的类比。我们可以利用一个单一通道来处理对其他追随者的广播。

原子一致性/互斥

我们已经非常详细地研究了原子一致性。它确保任何不是在基本上同时创建和使用的东西都需要串行化,以确保最强形式的一致性。如果一个值或数据集不是原子性的,我们总是可以使用互斥锁来强制对该数据进行线性化。

串行或顺序一致性本质上是强大的,但也可能导致性能问题和并发性的降低。

原子一致性通常被认为是确保一致性的最强形式。

发布一致性

发布一致性模型是一种 DSM 变体,可以延迟写操作的修改,直到第一次从读者那里获取。这被称为延迟发布一致性。我们可以在以下序列化模型中可视化延迟发布一致性:

发布一致性

这个模型以及急切的发布一致性模型都需要在满足某些条件时宣布发布(正如其名称所示)。在急切模型中,该条件要求写操作将以一致的方式被所有读取进程读取。

在 Go 中,存在替代方案,但如果您有兴趣尝试,也有相关的软件包。

使用 memcached

如果您不熟悉 memcache(d),它是一种管理分布式系统中数据的美妙而显而易见的方式。Go 的内置通道和 goroutines 非常适合管理单台机器进程中的通信和数据完整性,但它们都不是为分布式系统而设计的。

正如其名称所示,Memcached 允许在多个实例或机器之间共享内存数据。最初,memcached 旨在存储数据以便快速检索。这对于具有高周转率的系统(如 Web 应用程序)缓存数据很有用,但也是一种轻松地在多个服务器之间共享数据和利用共享锁机制的好方法。

在我们之前的模型中,memcached 属于 DSM。所有可用和调用的实例在各自的内存中共享一个公共的镜像内存空间。

值得指出的是,memcached 中确实存在竞争条件,你仍然需要一种处理的方法。Memcached 提供了一种在分布式系统中共享数据的方法,但并不保证数据的原子性。相反,memcached 采用以下两种方法之一来使缓存数据失效:

  • 数据被明确分配了一个最大年龄(之后,它将从堆栈中删除)

  • 或者由于新数据使用了所有可用内存而导致数据从堆栈中被推出

值得注意的是,memcache(d) 中的存储显然是短暂的,而且不具有容错能力,因此它只能在不会导致关键应用程序故障的情况下使用。

在满足这两个条件之一的时候,数据会消失,对该数据的下一次调用将失败,这意味着需要重新生成数据。当然,你可以使用一些复杂的锁生成方法来使 memcached 以一致的方式运行,尽管这不是 memcached 本身的标准内置功能。让我们通过使用 Brad Fitz 的 gomemcache 接口 (github.com/bradfitz/gomemcache) 在 Go 中快速看一下 memcached 的一个例子:

package main

import (
  "github.com/bradfitz/gomemcache/memcache"
  "fmt"
)

func main() {
     mC := memcache.New("10.0.0.1:11211", "10.0.0.2:11211", 
       "10.0.0.3:11211", "10.0.0.4:11211")
     mC.Set(&memcache.Item{Key: "data", Value: []byte("30") })

     dataItem, err := mc.Get("data")
}

正如你可能从前面的例子中注意到的,如果任何这些 memcached 客户端同时写入共享内存,仍然可能存在竞争条件。

关键数据可以存在于任何已连接并同时运行 memcached 的客户端中。

任何客户端也可以在任何时候取消或覆盖数据。

与许多实现不同,你可以通过 memcached 设置一些更复杂的类型,比如结构体,假设它们已经被序列化。这个警告意味着我们在直接共享数据方面受到了一定的限制。显然,我们无法使用指针作为内存位置会因客户端而异。

处理数据一致性的一种方法是设计一个主从系统,其中只有一个节点负责写入,而其他客户端通过键的存在来监听更改。

我们可以利用之前提到的任何其他模型来严格管理这些数据的锁,尽管这可能会变得特别复杂。在下一章中,我们将探讨一些构建分布式互斥系统的方法,但现在,我们简要地看一下另一种选择。

电路

最近出现的一个处理分布式并发的第三方库是 Petar Maymounkov 的 Go' circuit。Go' circuit 试图通过为一个或多个远程 goroutine 分配通道来促进分布式协程。

Go' circuit 最酷的部分是,只需包含该包就可以使你的应用程序准备好监听和操作远程 goroutine,并处理与它们相关联的通道。

Go' circuit 在 Tumblr 中使用,这证明它作为一个大规模和相对成熟的解决方案平台具有一定的可行性。

注意

Go' circuit 可以在 github.com/gocircuit/circuit 找到。

安装 Go' circuit 并不简单——你不能简单地运行 go get,它需要 Apache Zookeeper 并且需要从头构建工具包。

一旦完成,就可以相对简单地让两台机器(或者在本地运行时的两个进程)运行 Go 代码来共享一个通道。这个系统中的每个齿轮都属于发送者或监听者类别,就像 goroutines 一样。鉴于我们在这里谈论的是网络资源,语法与一些微小的修改是熟悉的:

homeChannel := make(chan bool)

circuit.Spawn("etphonehome.example.com",func() {
  homeChannel <- true
})

for {
  select {
    case response := <- homeChannel:
      fmt.Print("E.T. has phoned home with:",response)

  }
}

您可以看到,这可能会使不同机器之间共享相同数据的通信变得更加清晰,而我们主要使用 memcached 作为网络内存锁定系统。在这里,我们直接处理原生的 Go 代码;我们有能力像在通道中一样使用电路,而不必担心引入新的数据管理或原子性问题。事实上,电路本身就是建立在一个 goroutine 之上的。

当然,这也引入了一些额外的管理问题,主要是关于了解远程机器的情况,它们是否活跃,更新机器的状态等等。这些问题最适合由 Apache Zookeeper 这样的套件来处理分布式资源的协调。值得注意的是,您应该能够从远程机器向主机产生一些反馈:电路通过无密码 SSH 运行。

这也意味着您可能需要确保用户权限被锁定,并且符合您可能已经制定的安全策略。

注意

您可以在zookeeper.apache.org/找到 Apache Zookeeper。

总结

现在,我们已经掌握了一些方法和模型,不仅可以管理单个或多线程系统中的本地数据,还可以管理分布式系统,您应该开始感到对保护并发和并行进程中数据的有效性相当自信。

我们已经研究了读和读/写锁的两种互斥形式,并开始将其应用于分布式系统,以防止在多个网络系统中出现阻塞和竞争条件。

在下一章中,我们将更深入地探讨这些排除和数据一致性概念,构建非阻塞的网络应用程序,并学习如何处理超时并深入研究通道的并行性。

我们还将更深入地研究 sync 和 OS 包,特别是查看sync.atomic操作。

第五章:锁,块和更好的通道

现在我们开始对安全和一致地利用 goroutines 有了很好的把握,是时候更深入地了解是什么导致了代码的阻塞和死锁。让我们也探索一下sync包,并深入一些分析和分析。

到目前为止,我们已经构建了一些相对基本的 goroutines 和互补的通道,但现在我们需要在 goroutines 之间利用一些更复杂的通信通道。为了做到这一点,我们将实现更多的自定义数据类型,并直接应用它们到通道中。

我们还没有看过 Go 的一些用于同步和分析的低级工具,因此我们将探索sync.atomic,这是一个包,它与sync.Mutex一起允许更细粒度地控制状态。

最后,我们将深入研究 pprof,这是 Go 提供的一个神奇的工具,它让我们分析我们的二进制文件,以获取有关我们的 goroutines、线程、整体堆和阻塞概况的详细信息。

凭借一些新的工具和方法来测试和分析我们的代码,我们将准备好生成一个强大的,高度可扩展的 Web 服务器,可以安全快速地处理任何数量的流量。

了解 Go 中的阻塞方法

到目前为止,通过我们的探索和示例,我们已经遇到了一些阻塞代码的片段,有意的和无意的。在这一点上,看看我们可以引入(或无意中成为)阻塞代码的各种方式是明智的。

通过观察 Go 代码被阻塞的各种方式,我们也可以更好地准备调试并发在我们的应用程序中未按预期运行的情况。

阻塞方法 1-一个监听,等待的通道

阻塞代码的最具并发性的方法是通过让一个串行通道监听一个或多个 goroutines。到目前为止,我们已经看到了几次,但基本概念如下代码片段所示:

func thinkAboutKeys() {
  for {
    fmt.Println("Still Thinking")
    time.Sleep(1 * time.Second)
  }
}

func main() {
  fmt.Println("Where did I leave my keys?")

  blockChannel := make(chan int)
  go thinkAboutKeys()

  <-blockChannel

  fmt.Println("OK I found them!")
}

尽管我们所有的循环代码都是并发的,但我们正在等待一个信号,以便我们的blockChannel继续线性执行。当然,我们可以通过发送通道来看到这一点,从而继续代码执行,如下面的代码片段所示:

func thinkAboutKeys(bC chan int) {
  i := 0
  max := 10
  for {
    if i >= max {
      bC <- 1
    }
    fmt.Println("Still Thinking")
    time.Sleep(1 * time.Second)
    i++
  }
}

在这里,我们修改了我们的 goroutine 函数,以接受我们的阻塞通道,并在达到最大值时向其发送结束消息。这些机制对于长时间运行的进程非常重要,因为我们可能需要知道何时以及如何终止它们。

通过通道发送更多的数据类型

Go 使用通道(结构和函数)作为一流公民,为我们提供了许多有趣的执行方式,或者至少尝试新的通道之间通信方式的方法。

一个这样的例子是创建一个通过函数本身处理翻译的通道,而不是通过标准语法直接进行通信,通道执行其函数。您甚至可以在单个函数中对它们进行迭代的函数的切片/数组上执行此操作。

创建一个函数通道

到目前为止,我们几乎完全是在单一数据类型和单一值通道中工作。因此,让我们尝试通过通道发送一个函数。有了一流的通道,我们不需要抽象来做到这一点;我们可以直接通过通道发送几乎任何东西,如下面的代码片段所示:

func abstractListener(fxChan chan func() string ) {

  fxChan <- func() string {

    return "Sent!"
  }
}

func main() {

  fxChan := make (chan func() string)
  defer close(fxChan)
  go abstractListener(fxChan)
  select {
    case rfx := <- fxChan:
    msg := rfx()
    fmt.Println(msg)      
    fmt.Println("Received!")

  }

}

这就像一个回调函数。然而,它也是本质上不同的,因为它不仅是在函数执行后调用的方法,而且还作为函数之间的通信方式。

请记住,通常有替代方法可以通过通道传递函数,因此这可能是一个非常特定于用例而不是一般实践的东西。

由于通道的类型可以是几乎任何可用类型,这种功能性打开了一系列可能令人困惑的抽象。作为通道类型的结构或接口是相当不言自明的,因为您可以对其定义的任何属性做出与应用程序相关的决策。

让我们在下一节中看一个使用接口的例子。

使用接口通道

与我们的函数通道一样,能够通过通道传递接口(这是一种补充的数据类型)可能非常有用。让我们看一个通过接口发送的例子:

type Messenger interface {
  Relay() string
}

type Message struct {
  status string
}

func (m Message) Relay() string {
  return m.status
}

func alertMessages(v chan Messenger, i int) {
  m := new(Message)
  m.status = "Done with " + strconv.FormatInt(int64(i),10)
  v <- m
}

func main () {

  msg := make(chan Messenger)

  for i:= 0; i < 10; i++ {
    go alertMessages(msg,i)
  }

  select {
    case message := <-msg:
      fmt.Println (message.Relay())
  }
  <- msg
}

这是如何利用接口作为通道的一个非常基本的例子;在前面的例子中,接口本身在很大程度上是装饰性的。实际上,我们通过接口的通道传递新创建的消息类型,而不是直接与接口交互。

使用结构体、接口和更复杂的通道

为我们的通道创建一个自定义类型,可以让我们决定我们的通道内部通信的方式,同时让 Go 决定上下文切换和幕后调度。

最终,这主要是一个设计考虑。在前面的例子中,我们使用单独的通道来处理特定的通信片段,而不是使用一个通道来传递大量的数据。然而,您可能还会发现使用单个通道来处理 goroutines 和其他通道之间的大量通信是有利的。

决定是否将通道分隔为单独的通信片段或通信包的主要考虑因素取决于每个通道的总体可变性。

例如,如果您总是想要发送一个计数器以及一个函数或字符串,并且它们在数据一致性方面总是成对出现,这样的方法可能是有意义的。如果其中任何组件在途中失去同步,保持每个片段独立更合乎逻辑。

Go 中的映射

如前所述,Go 中的映射就像其他地方的哈希表,与切片或数组密切相关。

在上一个例子中,我们正在检查用户名/密钥是否已经存在;为此,Go 提供了一个简单的方法。当尝试检索一个不存在的键的哈希时,会返回一个零值,如下面的代码所示:

if Users[user.name] {
  fmt.Fprintln(conn, "Unfortunately, that username is in use!");
}

这使得对映射及其键进行语法上的简单和清晰的测试。

Go 中映射的最佳特性之一是能够使用任何可比较类型作为键,包括字符串、整数、布尔值以及任何仅由这些类型组成的映射、结构体、切片或通道。

这种一对多的通道可以作为主从或广播-订阅模型。我们将有一个通道监听消息并将其路由到适当的用户,以及一个通道监听广播消息并将其排队到所有用户。

为了最好地演示这一点,我们将创建一个简单的多用户聊天系统,允许 Twitter 风格的@user通信与单个用户,具有向所有用户广播标准消息的能力,并创建一个可以被所有用户阅读的通用广播聊天记录。这两者都将是简单的自定义类型结构体通道,因此我们可以区分各种通信片段。

Go 中的结构体

作为一种一流、匿名和可扩展的类型,结构体是最多才和有用的数据结构之一。它很容易创建类似于数据库和数据存储的模拟,虽然我们不愿称它们为对象,但它们确实可以被视为对象。

就结构体在函数中的使用而言,一个经验法则是,如果结构体特别复杂,应该通过引用而不是值来传递。澄清的两点如下:

  • 引用在引号中是因为(这是 Go 的 FAQ 所验证的)从技术上讲,Go 中的一切都是按值传递的。这意味着虽然指针的引用仍然存在,但在过程的某个步骤中,值被复制了。

  • “特别复杂”是可以理解的,所以个人判断可能会起作用。然而,我们可以认为一个简单的结构体最多有五个方法或属性。

你可以把这个想象成一个帮助台系统,虽然在当今,我们不太可能为这样的事情创建一个命令行界面,但是避开 Web 部分让我们忽略了所有与 Go 不相关的客户端代码。

你当然可以拿这样的例子并将其推广到利用一些前端库进行 Web 的异步功能(比如backbone.jssocket.io)。

为了实现这一点,我们需要创建一个客户端和一个服务器应用程序,并尽量保持每个应用程序尽可能简单。你可以清楚简单地扩展这个功能,包括任何你认为合适的功能,比如进行 Git 评论和更新网站。

我们将从服务器开始,这将是最复杂的部分。客户端应用程序将主要通过套接字接收消息,因此大部分的读取和路由逻辑对于客户端来说是不可见的。

net 包 - 一个带有接口通道的聊天服务器

在这里,我们需要引入一个相关的包,这个包将被需要来处理我们应用程序的大部分通信。我们在 SVG 输出生成示例中稍微涉及了一下net包,以展示并发性 - net/http只是更广泛、更复杂和更功能丰富的包的一小部分。

我们将使用的基本组件将是 TCP 监听器(服务器)和 TCP 拨号器(客户端)。让我们来看看这些基本设置。

服务器

在 TCP 端口上监听不能更简单。只需启动net.Listen()方法并处理错误,如下面的代码所示:

  listener, err := net.Listen("tcp", ":9000")
  if err != nil {
    fmt.Println ("Could not start server!")
  }

如果启动服务器时出现错误,请检查防火墙或修改端口 - 可能有某些东西正在使用您系统上的端口 9000。

就像这样简单,我们的客户端/拨号器端也是一样简单的。

客户端

在这种情况下,我们在 localhost 上运行所有内容,如下面的代码所示。然而,在实际应用中,我们可能会在这里使用一个内部网地址:

  conn, err := net.Dial("tcp","127.0.0.1:9000")
  if err != nil {
    fmt.Println("Could not connect to server!")
  }

在这个应用程序中,我们演示了处理未知长度的字节缓冲区的两种不同方法。第一种是使用strings.TrimRight()来修剪字符串的相当粗糙的方法。这种方法允许您定义您不感兴趣的字符作为输入的一部分,如下面的代码所示。大多数情况下,这是我们可以假设是缓冲区长度的未使用部分的空白字符。

sendMessage := []byte(cM.name + ": " + 
  strings.TrimRight(string(buf)," \t\r\n"))

以这种方式处理字符串通常既不优雅又不可靠。如果我们在这里得到了意料之外的东西会发生什么?字符串将是缓冲区的长度,在这种情况下是 140 个字节。

我们处理这个的另一种方式是直接使用缓冲区的末尾。在这种情况下,我们将n变量分配给conn.Read()函数,然后可以将其用作字符串到缓冲区转换中的缓冲区长度,如下面的代码所示:

messBuff := make([]byte,1024)
n, err := conn.Read(messBuff)
if err != nil {

}
message := string(messBuff[:n])

在这里,我们正在接收消息缓冲区的前n个字节。

这更加可靠和高效,但你肯定会遇到文本摄入案例,你会想要删除某些字符以创建更清洁的输入。

这个应用程序中的每个连接都是一个结构,每个用户也是如此。当他们加入时,我们通过将他们推送到Users切片来跟踪我们的用户。

所选的用户名是一个命令行参数,如下所示:

./chat-client nathan
chat-client.exe nathan

我们不检查以确保只有一个用户使用该名称,因此可能需要该逻辑,特别是如果包含敏感信息的直接消息。

处理直接消息

大多数情况下,这个聊天客户端是一个简单的回声服务器,但正如前面提到的,我们还包括了通过调用 Twitter 风格的@语法来进行非全局广播消息的功能。

我们主要通过正则表达式来处理这个问题,如果消息匹配@user,那么只有该用户会看到消息;否则,消息将广播给所有人。这有点不够优雅,因为直接消息的发送者如果用户名与用户的预期名称不匹配,将看不到自己的直接消息。

为了做到这一点,我们在广播之前将每条消息都通过evalMessageRecipient()函数。由于这依赖于用户输入来创建正则表达式(以用户名的形式),请注意我们应该使用regexp.QuoteMeta()方法来转义这些内容,以防止正则表达式失败。

让我们首先检查一下我们的聊天服务器,它负责维护所有连接并将它们传递给 goroutine 来监听和接收,如下所示:

chat-server.go
package main

import
(
  "fmt"
  "strings"
  "net"
  "strconv"
  "regexp"
)

var connectionCount int
var messagePool chan(string)

const (
  INPUT_BUFFER_LENGTH = 140
)

我们使用了最大字符缓冲区。这将限制我们的聊天消息不超过 140 个字符。让我们看看我们的User结构,以了解有关加入用户的信息,如下所示:

type User struct {
  Name string
  ID int
  Initiated bool

initiated 变量告诉我们,在连接和公告之后,User已连接。让我们检查以下代码,以了解我们如何监听已登录用户的通道:

  UChannel chan []byte
  Connection *net.Conn
}
The User struct contains all of the information we will maintain 
  for each connection. Keep in mind here we don't do any sanity 
  checking to make sure a user doesn't exist – this doesn't 
  necessarily pose a problem in an example, but a real chat client 
  would benefit from a response should a user name already be 
  in use.

func (u *User) Listen() {
  fmt.Println("Listening for",u.Name)
  for {
    select {
      case msg := <- u.UChannel:
        fmt.Println("Sending new message to",u.Name)
        fmt.Fprintln(*u.Connection,string(msg))

    }
  }
}

这是我们服务器的核心:每个“用户”都有自己的“Listen()”方法,该方法维护User结构的通道并在其间发送和接收消息。简单地说,每个用户都有自己的并发通道。让我们看一下以下代码中的ConnectionManager结构和创建服务器的“Initiate()”函数:

type ConnectionManager struct {
  name      string
  initiated bool
}

func Initiate() *ConnectionManager {
  cM := &ConnectionManager{
    name:      "Chat Server 1.0",
    initiated: false,
  }

  return cM
}

我们的ConnectionManager结构只初始化一次。这设置了一些相对装饰的属性,其中一些可以在请求或聊天登录时返回。我们将检查evalMessageRecipient函数,该函数试图粗略地确定任何发送的消息的预期接收者,如下所示:

func evalMessageRecipient(msg []byte, uName string) bool {
  eval := true
  expression := "@"
  re, err := regexp.MatchString(expression, string(msg))
  if err != nil {
    fmt.Println("Error:", err)
  }
  if re == true {
    eval = false
    pmExpression := "@" + uName
    pmRe, pmErr := regexp.MatchString(pmExpression, string(msg))
    if pmErr != nil {
      fmt.Println("Regex error", err)
    }
    if pmRe == true {
      eval = true
    }
  }
  return eval
}

这是我们的路由器,它从字符串中获取@部分,并用它来检测一个预期的接收者,以便隐藏不被公开。如果用户不存在或已离开聊天室,我们不会返回错误。

注意

使用regexp包的正则表达式格式依赖于re2语法,该语法在code.google.com/p/re2/wiki/Syntax中有描述。

让我们看一下ConnectionManager结构的“Listen()”方法的代码:

func (cM *ConnectionManager) Listen(listener net.Listener) {
  fmt.Println(cM.name, "Started")
  for {

    conn, err := listener.Accept()
    if err != nil {
      fmt.Println("Connection error", err)
    }
    connectionCount++
    fmt.Println(conn.RemoteAddr(), "connected")
    user := User{Name: "anonymous", ID: 0, Initiated: false}
    Users = append(Users, &user)
    for _, u := range Users {
      fmt.Println("User online", u.Name)
    }
    fmt.Println(connectionCount, "connections active")
    go cM.messageReady(conn, &user)
  }
}

func (cM *ConnectionManager) messageReady(conn net.Conn, user 
  *User) {
  uChan := make(chan []byte)

  for {

    buf := make([]byte, INPUT_BUFFER_LENGTH)
    n, err := conn.Read(buf)
    if err != nil {
      conn.Close()
      conn = nil
    }
    if n == 0 {
      conn.Close()
      conn = nil
    }
    fmt.Println(n, "character message from user", user.Name)
    if user.Initiated == false {
      fmt.Println("New User is", string(buf))
      user.Initiated = true
      user.UChannel = uChan
      user.Name = string(buf[:n])
      user.Connection = &conn
      go user.Listen()

      minusYouCount := strconv.FormatInt(int64(connectionCount-1), 
        10)
      conn.Write([]byte("Welcome to the chat, " + user.Name + ", 
        there are " + minusYouCount + " other users"))

    } else {

      sendMessage := []byte(user.Name + ": " + 
        strings.TrimRight(string(buf), " \t\r\n"))

      for _, u := range Users {
        if evalMessageRecipient(sendMessage, u.Name) == true {
          u.UChannel <- sendMessage
        }

      }

    }

  }
}geReady (per connectionManager) function instantiates new 
  connections into a User struct, utilizing first sent message as 
  the user's name.

var Users []*User
This is our unbuffered array (or slice) of user structs.
func main() {
  connectionCount = 0
  serverClosed := make(chan bool)

  listener, err := net.Listen("tcp", ":9000")
  if err != nil {
    fmt.Println ("Could not start server!",err)
  }

  connManage := Initiate()  
  go connManage.Listen(listener)

  <-serverClosed
}

正如预期的那样,main()主要处理连接和错误,并使用serverClosed通道保持我们的服务器开放和非阻塞。

我们可以采用许多方法来改进消息路由的方式。第一种方法是调用绑定到用户名的映射(或哈希表)。如果映射的键存在,我们可以返回一些错误功能,如果用户已经存在,如下面的代码片段所示:

type User struct {
  name string
}
var Users map[string] *User

func main() {
  Users := make(map[string] *User)
}

检查我们的客户端

我们的客户端应用程序相对简单,主要是因为我们不太关心阻塞代码。

虽然我们有两个并发操作(等待消息和等待用户输入以发送消息),但这比我们的服务器要简单得多,后者需要同时监听每个创建的用户并分发发送的消息。

现在让我们将我们的聊天客户端与我们的聊天服务器进行比较。显然,客户端对连接和用户的整体维护要少得多,因此我们不需要使用那么多的通道。让我们看看我们的聊天客户端的代码:

chat-client.go
package main

import
(
  "fmt"
  "net"
  "os"
  "bufio"
  "strings"
)
type Message struct {
  message string
  user string
}

var recvBuffer [140]byte

func listen(conn net.Conn) {
  for {

      messBuff := make([]byte,1024)
      n, err := conn.Read(messBuff)
      if err != nil {
        fmt.Println("Read error",err)
      }
      message := string(messBuff[:n])
      message = message[0:]

      fmt.Println(strings.TrimSpace(message))
      fmt.Print("> ")
  }

}

func talk(conn net.Conn, mS chan Message) {

      for {
      command := bufio.NewReader(os.Stdin)
        fmt.Print("> ")        
                line, err := command.ReadString('\n')

                line = strings.TrimRight(line, " \t\r\n")
        _, err = conn.Write([]byte(line))                       
                if err != nil {
                        conn.Close()
                        break

                }
      doNothing(command)  
        }  

}

func doNothing(bf *bufio.Reader) {
  // A temporary placeholder to address io reader usage

}
func main() {

  messageServer := make(chan Message)

  userName := os.Args[1]

  fmt.Println("Connecting to host as",userName)

  clientClosed := make(chan bool)

  conn, err := net.Dial("tcp","127.0.0.1:9000")
  if err != nil {
    fmt.Println("Could not connect to server!")
  }
  conn.Write([]byte(userName))
  introBuff := make([]byte,1024)    
  n, err := conn.Read(introBuff)
  if err != nil {

  }
  message := string(introBuff[:n])  
  fmt.Println(message)

  go talk(conn,messageServer)
  go listen(conn)

  <- clientClosed
}

阻塞方法 2-循环中的 select 语句

您是否已经注意到select语句本身会阻塞?从根本上讲,select语句与开放的监听通道没有什么不同;它只是包装在条件代码中。

<- myChannel通道的操作方式与以下代码片段相同:

select {
  case mc := <- myChannel:
    // do something
}

开放的监听通道只要没有 goroutine 在睡眠,就不会造成死锁。您会发现这种情况发生在那些正在监听但永远不会接收任何东西的通道上,这是另一种基本上在等待的方法。

这些对于长时间运行的应用程序是有用的快捷方式,你希望保持其活动状态,但你可能不一定需要沿着通道发送任何东西。

清理 goroutines

任何等待和/或接收的通道都会导致死锁。幸运的是,Go 在识别这些方面相当擅长,当运行或构建应用程序时,你几乎肯定会陷入恐慌。

到目前为止,我们的许多示例都利用了立即和清晰地将相似的代码组合在一起的延迟close()方法,这些代码应该在不同的时间点执行。

尽管垃圾回收处理了大部分的清理工作,但我们大部分时间需要确保关闭通道,以确保我们没有一个等待接收和/或等待发送的进程,两者同时等待对方。幸运的是,我们将无法编译任何具有可检测死锁条件的程序,但我们也需要管理关闭等待的通道。

到目前为止,相当多的示例都以一个通用的整数或布尔通道结束,它只是等待——这几乎完全是为了通道的阻塞效果,这样可以在应用程序仍在运行时演示并发代码的效果和输出。在许多情况下,这种通用通道是不必要的语法垃圾,如下面的代码所示:

<-youMayNotNeedToDoThis
close(youmayNotNeedToDoThis)

没有赋值发生的事实是一个很好的指示,表明这是这种语法垃圾的一个例子。如果我们改为包括一个赋值,前面的代码将改为以下代码:

v := <-youMayNotNeedToDoThis

这可能表明该值是有用的,而不仅仅是任意的阻塞代码。

阻塞方法 3 – 网络连接和读取

如果你在没有启动服务器的情况下运行我们之前的聊天服务器客户端的代码,你会注意到Dial函数会阻塞任何后续的 goroutine。我们可以通过在连接上施加比正常更长的超时,或者在登录后简单地关闭客户端应用程序来测试这一点,因为我们没有实现关闭 TCP 连接的方法。

由于我们用于连接的网络读取器是缓冲的,所以在通过 TCP 等待数据时,我们将始终具有阻塞机制。

创建通道的通道

管理并发和状态的首选和授权方式是完全通过通道进行。

我们已经演示了一些更复杂类型的通道,但我们还没有看到可能成为令人生畏但强大的实现的东西:通道的通道。这起初可能听起来像一些难以管理的虫洞,但在某些情况下,我们希望一个并发动作生成更多的并发动作;因此,我们的 goroutines 应该能够产生自己的。

一如既往,你通过设计来管理这一切,而实际的代码可能只是一个美学副产品。这种方式构建应用程序应该会使你的代码大部分时间更加简洁和清晰。

让我们重新访问之前的一个 RSS 订阅阅读器的示例,以演示我们如何管理这一点,如下面的代码所示:

package main

import (
 "fmt"
)

type master chan Item

var feedChannel chan master
var done chan bool

type Item struct {
 Url  string
 Data []byte
}
type Feed struct {
 Url   string
 Name  string
 Items []Item
}

var Feeds []Feed

func process(feedChannel *chan master, done *chan bool) {
 for _, i := range Feeds {
  fmt.Println("feed", i)
  item := Item{}
  item.Url = i.Url
  itemChannel := make(chan Item)
  *feedChannel <- itemChannel
  itemChannel <- item
 }
 *done <- true
}
func processItem(url string) {
 // deal with individual feed items here
 fmt.Println("Got url", url)
}

func main() {
 done := make(chan bool)
 Feeds = []Feed{Feed{Name: "New York Times", Url: "http://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml"},
  Feed{Name: "Wall Street Journal", Url: "http://feeds.wsjonline.com/wsj/xml/rss/3_7011.xml"}}
 feedChannel := make(chan master)
 go func(done chan bool, feedChannel chan master) {
  for {
   select {
   case fc := <-feedChannel:
    select {
    case item := <-fc:
     processItem(item.Url)
    }
   default:
   }
  }
 }(done, feedChannel)
 go process(&feedChannel, &done)
 <-done
 fmt.Println("Done!")
}

在这里,我们将feedChannel管理为一个自定义结构,它本身是我们Item类型的通道。这使我们能够完全依赖通道进行同步,通过类似信号量的构造处理。

如果我们想看看另一种处理低级同步的方法,sync.atomic提供了一些简单的迭代模式,允许你直接在内存中管理同步。

根据 Go 的文档,这些操作需要非常小心,并且容易出现数据一致性错误,但如果你需要直接操作内存,这就是做到这一点的方法。当我们谈论高级并发特性时,我们将直接使用这个包。

Pprof – 又一个令人敬畏的工具

就在你以为你已经看到了 Go 令人惊叹的工具集的全部范围时,总会有一个更多的实用程序,一旦你意识到它的存在,你会想知道你以前是如何生存下来的。

Go 格式非常适合清理您的代码;-race标志对于检测可能的竞争条件至关重要,但是还存在一个更健壮的、更实用的工具,用于分析您的最终应用程序,那就是 pprof。

Google 最初创建 pprof 来分析 C++应用程序的循环结构和内存分配(以及相关类型)。

如果您认为性能问题没有被 Go 运行时提供的测试工具发现,这将非常有用。这也是生成任何应用程序中数据结构的可视化表示的绝佳方式。

其中一些功能也作为 Go 测试包及其基准测试工具的一部分存在-我们将在第七章中更多地探讨这一点,性能和可伸缩性

使 pprof 运行时版本起作用需要先进行一些设置。我们需要包括runtime.pprof包和flag包,它允许命令行解析(在这种情况下,用于 pprof 的输出)。

如果我们拿我们的聊天服务器代码来说,我们可以添加几行代码,使应用程序准备好进行性能分析。

让我们确保我们将这两个包与其他包一起包含。我们可以使用下划线语法来告诉编译器我们只对包的副作用感兴趣(这意味着我们获得包的初始化函数和全局变量),如下面的代码所示:

import
(
  "fmt"
...
  _ "runtime/pprof"
)

这告诉我们的应用程序生成一个 CPU 分析器(如果不存在),在执行开始时开始分析,并在应用程序成功退出时推迟分析的结束。

有了这个,我们可以使用cpuprofile标志运行我们的二进制文件,告诉程序生成一个配置文件,如下所示:

./chat-server -cpuprofile=chat.prof

为了多样化(并任意地利用更多资源),我们将暂时放弃聊天服务器,并在退出之前创建一个循环生成大量的 goroutines。这应该给我们一个比简单而长期的聊天服务器更激动人心的性能分析数据演示,尽管我们会简要地回到那个话题:

这是我们的示例代码,它生成了更详细和有趣的性能分析数据:

package main

import (
  "flag"
  "fmt"
  "math/rand"
  "os"
  "runtime"
  "runtime/pprof"
)

const ITERATIONS = 99999
const STRINGLENGTH = 300

var profile = flag.String("cpuprofile", "", "output pprof data to 
  file")

func generateString(length int, seed *rand.Rand, chHater chan 
  string) string {
  bytes := make([]byte, length)
  for i := 0; i < length; i++ {
    bytes[i] = byte(rand.Int())
  }
  chHater <- string(bytes[:length])
  return string(bytes[:length])
}

func generateChannel() <-chan int {
  ch := make(chan int)
  return ch
}

func main() {

  goodbye := make(chan bool, ITERATIONS)
  channelThatHatesLetters := make(chan string)

  runtime.GOMAXPROCS(2)
  flag.Parse()
  if *profile != "" {
    flag, err := os.Create(*profile)
    if err != nil {
      fmt.Println("Could not create profile", err)
    }
    pprof.StartCPUProfile(flag)
    defer pprof.StopCPUProfile()

  }
  seed := rand.New(rand.NewSource(19))

  initString := ""

  for i := 0; i < ITERATIONS; i++ {
    go func() {
      initString = generateString(STRINGLENGTH, seed, 
        channelThatHatesLetters)
      goodbye <- true
    }()

  }
  select {
  case <-channelThatHatesLetters:

  }
  <-goodbye

  fmt.Println(initString)

}

当我们从中生成一个配置文件时,我们可以运行以下命令:

go tool pprof chat-server chat-server.prof 

这将启动 pprof 应用程序本身。这给了我们一些命令,报告静态生成的文件,如下所示:

  • topN:这显示配置文件中的前N个样本,其中N表示您想要查看的显式数字。

  • web:这将创建数据的可视化,将其导出为 SVG,并在 Web 浏览器中打开。要获得 SVG 输出,您还需要安装 Graphviz(www.graphviz.org/)。

注意

您还可以直接运行 pprof 并使用一些标志以多种格式输出,或者启动浏览器,如下所示:

  • --text:这将生成文本报告

  • --web:这将生成 SVG 并在浏览器中打开

  • --gv:这将生成 Ghostview 后置文件

  • --pdf:这将生成 PDF 输出

  • --SVG:这将生成 SVG 输出

  • --gif:这将生成 GIF 输出

命令行结果将足够说明问题,但是以描述性的、可视化的方式呈现应用程序的阻塞配置文件尤其有趣,如下图所示。当您在 pprof 工具中时,只需输入web,浏览器将以 SVG 形式显示 CPU 分析的详细信息。

Pprof-又一个令人惊叹的工具

这里的想法不是关于文本,而是关于复杂性

哇,我们突然对程序如何利用 CPU 时间消耗以及我们的应用程序执行、循环和退出的一般视图有了深入了解。

典型的 Go 风格,pprof 工具也存在于net/http包中,尽管它更注重数据而不是可视化。这意味着,您可以将结果直接输出到 Web 进行分析,而不仅仅是处理命令行工具。

与命令行工具一样,您将看到块、goroutine、堆和线程配置文件,以及通过 localhost 直接查看完整堆栈轮廓,如下面的屏幕截图所示:

Pprof – yet another awesome tool

要生成此服务器,您只需在应用程序中包含几行关键代码,构建它,然后运行它。在本例中,我们已经在我们的聊天服务器应用程序中包含了代码,这使我们可以在原本只能在命令行中使用的应用程序中获得 Web 视图。

确保包括net/httplog包。您还需要http/pprof包。代码片段如下:

import(_(_ 
  "net/http/pprof"
  "log"
  "net/http"
)

然后只需在应用程序的某个地方包含此代码,最好是在main()函数的顶部附近,如下所示:

  go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
  }()

与往常一样,端口完全是个人偏好的问题。

然后,您可以在localhost:6060找到许多配置工具,包括以下内容:

  • 所有工具都可以在http://localhost:6060/debug/pprof/找到

  • 阻塞配置文件可以在http://localhost:6060/debug/pprof/block?debug=1找到

  • 所有 goroutine 的配置文件可以在http://localhost:6060/debug/pprof/goroutine?debug=1找到

  • 堆的详细配置文件可以在http://localhost:6060/debug/pprof/heap?debug=1找到

  • 线程创建的配置文件可以在http://localhost:6060/debug/pprof/threadcreate?debug=1找到

除了阻塞配置文件外,您还可以通过线程创建配置文件找到并发策略中的低效性。如果发现创建的线程数量异常,可以尝试调整同步结构和运行时参数以优化。

请记住,使用 pprof 这种方式也会包括一些分析和配置文件,这些可以归因于httppprof包,而不是您的核心代码。您会发现某些明显不属于您应用程序的行;例如,我们的聊天服务器的线程创建分析包括一些显著的行,如下所示:

#       0x7765e         net/http.HandlerFunc.ServeHTTP+0x3e     /usr/local/go/src/pkg/net/http/server.go:1149
#       0x7896d         net/http.(*ServeMux).ServeHTTP+0x11d /usr/local/go/src/pkg/net/http/server.go:1416

考虑到我们在这个迭代中明确避免通过 HTTP 或网络套接字传递我们的聊天应用程序,这应该是相当明显的。

除此之外,还有更明显的迹象,如下所示:

#       0x139541        runtime/pprof.writeHeap+0x731           /usr/local/go/src/pkg/runtime/pprof/pprof.go:447
#       0x137aa2        runtime/pprof.(*Profile).WriteTo+0xb2   /usr/local/go/src/pkg/runtime/pprof/pprof.go:229
#       0x9f55f         net/http/pprof.handler.ServeHTTP+0x23f  /usr/local/go/src/pkg/net/http/pprof/pprof.go:165
#       0x9f6a5         net/http/pprof.Index+0x135              /usr/local/go/src/pkg/net/http/pprof/pprof.go:177

我们永远无法从最终编译的二进制文件中减少一些系统和 Go 核心机制,如下所示:

#       0x18d96 runtime.starttheworld+0x126 
  /usr/local/go/src/pkg/runtime/proc.c:451

注意

十六进制值表示运行时函数在内存中的地址。

提示

对于 Windows 用户:在*nix 环境中使用 pprof 非常简单,但在 Windows 下可能需要一些更费力的调整。具体来说,您可能需要一个类似 Cygwin 的 bash 替代工具。您可能还需要对 pprof 本身(实际上是一个 Perl 脚本)进行一些必要的调整。对于 64 位 Windows 用户,请确保安装 ActivePerl,并使用 Perl 的 64 位版本直接执行 pprof Perl 脚本。

在发布时,在 64 位 OSX 上也存在一些问题。

处理死锁和错误

每当在代码编译时遇到死锁错误时,您将看到熟悉的一串半加密的错误,解释了哪个 goroutine 被留下来处理问题。

但请记住,您始终可以使用 Go 的内置 panic 来触发自己的 panic,这对于构建自己的错误捕获保障以确保数据一致性和理想操作非常有用。代码如下:

package main

import
(
  "os"
)

func main() {
  panic("Oh No, we forgot to write a program!")
  os.Exit(1)
}

这可以在任何您希望向开发人员或最终用户提供详细退出信息的地方使用。

总结

在探索了一些检查 Go 代码阻塞和死锁的新方法之后,我们现在还有一些工具可供使用,用于检查 CPU 配置文件和资源使用情况。

希望到这一点,你可以用简单的 goroutines 和通道构建一些复杂的并发系统,一直到结构体、接口和其他通道的复用通道。

到目前为止,我们已经构建了一些功能上比较完善的应用程序,但接下来我们将利用我们所做的一切来构建一个可用的 Web 服务器,解决一个经典问题,并可用于设计内部网络、文件存储系统等。

在下一章中,我们将把我们在本章中所做的关于可扩展通道的工作应用到解决互联网所面临的最古老的挑战之一:同时为 10,000(或更多)个连接提供服务。

第六章:在 Go 中创建一个非阻塞 Web 服务器

到目前为止,我们已经构建了一些可用的应用程序;我们可以从中开始,并跃入到日常使用的真实系统中。通过这样做,我们能够展示 Go 并发语法和方法中涉及的基本和中级模式。

然而,现在是时候解决一个真实世界的问题了——这个问题困扰了开发人员(以及他们的经理和副总裁)在 Web 的早期历史中很长一段时间。

通过解决这个问题,我们将能够开发一个高性能的 Web 服务器,可以处理大量的实时活跃流量。

多年来,解决这个问题的唯一方法是向问题投入硬件或侵入式缓存系统;因此,用编程方法解决它应该会激发任何程序员的兴趣。

我们将使用到目前为止学到的每一种技术和语言构造,但我们将以比以前更有条理和有意识的方式来做。到目前为止,我们所探讨的一切都将发挥作用,包括以下几点:

  • 创建我们并发应用的可视化表示

  • 利用 goroutine 来处理请求,以实现可扩展性

  • 构建健壮的通道来管理 goroutine 之间的通信和管理它们的循环

  • 使用性能分析和基准测试工具(JMeter、ab)来检查我们的事件循环的实际工作方式

  • 在必要时设置超时和并发控制,以确保数据和请求的一致性

攻克 C10K 问题

C10K 问题的起源根植于串行、阻塞式编程,这使得它成为展示并发编程优势的理想选择,特别是在 Go 语言中。

这个问题的提出者是开发者丹·凯格尔,他曾经问过:

是时候让 Web 服务器同时处理一万个客户端了,你不觉得吗?毕竟,现在的网络是一个很大的地方。
--丹·凯格尔([www.kegel.com/c10k.html](http://www.kegel.com/c10k.html)

当他在 1999 年提出这个问题时,对于许多服务器管理员和工程师来说,为 10,000 个并发访问者提供服务是需要通过硬件解决的问题。在常见硬件上,单个服务器能够处理这种类型的 CPU 和网络带宽而不会崩溃的想法对大多数人来说似乎是陌生的。

他提出的解决方案的关键在于生成非阻塞代码。当然,在 1999 年,并发模式和库并不普遍。C++通过一些第三方库和后来通过 Boost 和 C++11 提供的最早的多线程语法的前身,有一些轮询和排队选项。

在接下来的几年里,针对这个问题的解决方案开始涌现,涵盖了各种语言、编程设计和一般方法。在撰写本书时,C10K 问题并非没有解决方案,但它仍然是一个非常适合在高性能 Go 中进行真实世界挑战的平台。

任何性能和可伸缩性问题最终都将受限于底层硬件,因此,结果可能因人而异。在 486 处理器和 500MB RAM 上实现 10,000 个并发连接肯定比在堆满内存和多核的 Linux 服务器上实现更具挑战性。

值得注意的是,一个简单的回显服务器显然能够承担比返回更多数据并接受更复杂请求、会话等的功能性 Web 服务器更多的核心,正如我们将在这里处理的那样。

服务器在 10,000 个并发连接时失败

正如你可能还记得的,当我们在第三章中讨论并发策略时,我们谈到了一些关于 Apache 及其负载均衡工具的内容。

当 Web 诞生并且互联网商业化时,互动水平相当低。如果你是一个老手,你可能还记得从 NNTP/IRC 等的转变以及 Web 的极其原始的情况。

为了解决[页面请求]→[HTTP 响应]的基本命题,20 世纪 90 年代早期对 Web 服务器的要求相当宽松。忽略所有的错误响应、头部读取和设置以及其他基本功能(但与输入输出机制无关),早期服务器的本质相当简单,至少与现代 Web 服务器相比是如此。

注意

第一个 Web 服务器是由 Web 之父蒂姆·伯纳斯-李开发的。

由 CERN(例如 WWW/HTTP 本身)开发的 CERN httpd 处理了许多你今天在 Web 服务器中所期望的事情——在代码中搜索,你会发现很多注释,这些注释会让你想起 HTTP 协议的核心基本上没有改变。与大多数技术不同,HTTP 的寿命非常长。

1990 年用 C 语言编写的服务器无法利用 Erlang 等语言中可用的许多并发策略。坦率地说,这样做可能是不必要的——大多数的 Web 流量都是基本的文件检索和协议问题。Web 服务器的核心问题不是处理流量,而是处理协议本身的规则。

你仍然可以访问原始的 CERN httpd 网站,并从www.w3.org/Daemon/下载源代码。我强烈建议你这样做,既可以作为历史课程,也可以看看最早的 Web 服务器是如何解决最早的问题的。

然而,1990 年的 Web 和首次提出 C10K 问题时的 Web 是两个非常不同的环境。

到 1999 年,大多数网站都有一定程度的由第三方软件、CGI、数据库等提供的次要或第三级延迟,所有这些都进一步复杂化了问题。同时并发地提供 10,000 个平面文件的概念本身就是一个挑战,但是如果通过在 Perl 脚本的基础上运行它们来访问 MySQL 数据库而没有任何缓存层,这个挑战就会立即加剧。

到了 20 世纪 90 年代中期,Apache Web 服务器已经占据主导地位,并在很大程度上控制了市场(到 2009 年,它成为第一个为超过 1 亿个网站提供服务的服务器软件)。

Apache 的方法深深扎根于互联网的早期。在推出时,连接最初是先进先出处理的。很快,每个连接都被分配了一个线程池中的线程。Apache 服务器存在两个问题。它们如下:

  • 阻塞连接可能导致多米诺效应,其中一个或多个慢速解析的连接可能会导致无法访问

  • Apache 对可以利用的线程/工作者数量有严格的限制,与硬件约束无关

至少从回顾的角度来看,这里很容易看到机会。一个利用 actors(Erlang)、agents(Clojure)或 goroutines(Go)的并发服务器似乎完全符合要求。并发本身并不能解决 C10k 问题,但它绝对提供了一种促进解决的方法。

今天解决 C10K 问题的最显著和可见的例子是 Nginx,它是使用并发模式开发的,到 2002 年在 C 语言中广泛可用,用于解决 C10K 问题。如今,Nginx 代表着世界上第二或第三大的 Web 服务器,这取决于来源。

使用并发攻击 C10K

处理大量并发请求的两种主要方法。第一种方法涉及为每个连接分配线程。这就是 Apache(和其他一些服务器)所做的。

一方面,为连接分配一个线程是有很多道理的——它是隔离的,可以通过应用程序和内核的上下文切换进行控制,并且可以随着硬件的增加而扩展。

对于 Linux 服务器来说,这是一个问题——大多数 Web 都是在 Linux 服务器上运行的,每个分配的线程默认保留 8 MB 的内存用于其堆栈。这可以(也应该)重新定义,但这会导致需要大量的内存来处理单个服务器的开销,即使将默认堆栈大小设置为 1 MB,我们也需要至少 10 GB 的内存来处理开销。

这是一个极端的例子,由于几个原因,这不太可能成为一个真正的问题:首先,因为您可以规定每个线程可用的最大资源量,其次,因为您可以很容易地在几台服务器和实例之间进行负载平衡,而不是增加 10 GB 到 80 GB 的 RAM。

即使在一个线程服务器环境中,我们基本上也受到可能导致性能下降(甚至崩溃)的问题的限制。

首先,让我们看一个连接绑定到线程的服务器(如下图所示),并想象一下这如何导致阻塞,最终导致崩溃:

使用并发攻击 C10K

这显然是我们要避免的。任何 I/O、网络或外部进程都可能导致一些减速,从而引发我们所说的雪崩效应,使我们可用的线程被占用(或积压),而传入的请求开始堆积起来。

在这种模型中,我们可以生成更多的线程,但正如前面提到的,这里也存在潜在的风险,甚至这也无法减轻潜在的问题。

采取另一种方法

为了创建一个可以处理 10,000 个并发连接的网络服务器,我们显然会利用我们的 goroutine/channel 机制,将一个事件循环放在我们的内容交付前面,以保持新通道不断回收或创建。

在这个例子中,我们假设我们正在为一个快速扩张的公司构建企业网站和基础设施。为了做到这一点,我们需要能够提供静态和动态内容。

我们希望引入动态内容的原因不仅仅是为了演示的目的——我们想挑战自己,展示即使在次要进程干扰的情况下,也能展示 10,000 个真正的并发连接。

与往常一样,我们将尝试将我们的并发策略直接映射到 goroutines 和通道。在许多其他语言和应用程序中,这与事件循环直接类似,我们将以此方式处理。在我们的循环中,我们将管理可用的 goroutines,过期或重用已完成的 goroutines,并在必要时生成新的 goroutines。

在这个示例可视化中,我们展示了一个事件循环(和相应的 goroutines)如何使我们能够扩展我们的连接,而不需要使用太多资源,比如 CPU 线程或 RAM:

采取另一种方法

对我们来说,这里最重要的一步是管理事件循环。我们希望创建一个开放的、无限循环来管理我们的 goroutines 和各自的通道的创建和过期。

作为这一过程的一部分,我们还希望对所发生的情况进行一些内部记录,既用于基准测试,也用于调试我们的应用程序。

构建我们的 C10K 网络服务器

我们的网络服务器将负责处理请求,路由它们,并提供平面文件或针对几种不同数据源解析模板的动态文件。

正如前面提到的,如果我们只提供平面文件并消除大部分处理和网络延迟,那么处理 10,000 个并发连接将会更容易。

我们的目标是尽可能接近真实世界的情景——很少有网站在一个静态的服务器上运行。大多数网站和应用程序都利用数据库、CDN(内容交付网络)、动态和未缓存的模板解析等。我们需要尽可能地复制它们。

为了简单起见,我们将按类型分隔我们的内容,并通过 URL 路由进行过滤,如下所示:

  • /static/[request]:这将直接提供request.html

  • /template/[request]:这将在通过 Go 解析后提供request.tpl

  • /dynamic/[request][number]:这也将提供request.tpl并对其进行数据库源记录的解析

通过这样做,我们应该能够更好地混合可能阻碍大量用户同时服务能力的 HTTP 请求类型,特别是在阻塞的 Web 服务器环境中。

我们将利用html/template包进行解析——我们之前简要地看过语法,深入了解并不一定是本书的目标。但是,如果您打算将这个示例转化为您在环境中使用的内容,或者对构建框架感兴趣,您应该研究一下。

提示

您可以在golang.org/pkg/html/template/找到 Go 出色的库,用于生成安全的数据驱动模板。

所谓安全,我们主要是指接受数据并将其直接移入模板,而不必担心大量恶意软件和跨站脚本背后的注入问题。

对于数据库源,我们将在这里使用 MySQL,但如果您更熟悉其他数据库,可以随意尝试。与html/template包一样,我们不打算花费太多时间来概述 MySQL 和/或其变体。

针对阻塞 Web 服务器的基准测试

首先,公平地对阻塞 Web 服务器进行一些起始基准测试,以便我们可以衡量并发与非并发架构的影响。

对于我们的起始基准测试,我们将放弃任何框架,而选择我们的老朋友 Apache。

为了完整起见,我们将使用一台 Intel i5 3GHz 的机器,配备 8GB 的 RAM。虽然我们将在 Ubuntu、Windows 和 OS X 上对我们的最终产品进行基准测试,但我们将以 Ubuntu 为例。

我们的本地域将在/static中有三个普通的 HTML 文件,每个文件都被裁剪为 80KB。由于我们不使用框架,我们不需要担心原始动态请求,而只需要关注静态和动态请求,以及数据源请求。

对于所有示例,我们将使用一个名为master的 MySQL 数据库,其中包含一个名为articles的表,其中将包含 10,000 个重复条目。我们的结构如下:

CREATE TABLE articles (
  article_id INT NOT NULL AUTO_INCREMENT,
  article_title VARCHAR(128) NOT NULL,
  article_text VARCHAR(128) NOT NULL,
  PRIMARY KEY (article_id)
)

通过顺序范围从 0 到 10,000 的 ID 索引,我们将能够生成随机数请求,但目前,我们只想看看 Apache 在这台机器上提供静态页面时能得到什么样的基本响应。

对于这个测试,我们将使用 Apache 的 ab 工具,然后使用 gnuplot 来顺序映射请求时间作为并发请求和页面的数量;我们也将为我们的最终产品做同样的测试,但我们还将使用一些其他基准测试工具来获得更好的细节。

注意

Apache 的 AB 随 Apache Web 服务器本身提供。您可以在httpd.apache.org/docs/2.2/programs/ab.html了解更多信息。

您可以从httpd.apache.org/download.cgi下载它的 Linux、Windows、OS X 等版本。

gnuplot 实用程序也适用于相同的操作系统,网址是www.gnuplot.info/

所以,让我们看看我们是如何做到的。看一下下面的图表:

针对阻塞 Web 服务器的基准测试

哎呀!差距太大了。我们可以调整 Apache 中可用的连接(以及相应的线程/工作者),但这并不是我们的目标。大多数情况下,我们想知道开箱即用的 Apache 服务器会发生什么。在这些基准测试中,我们开始在大约 800 个并发连接时丢弃或拒绝连接。

更令人担忧的是,随着这些请求开始堆积,我们看到一些请求超过 20 秒甚至更长时间。当这种情况发生在阻塞服务器中时,每个请求都会排队;在其后排队的请求也会类似地排队,整个系统开始崩溃。

即使我们无法处理 10,000 个并发连接,仍然有很大的改进空间。虽然单个服务器的容量不再是我们期望设计为 Web 服务器环境的方式,但能够尽可能地从该服务器中挤取性能,基本上是我们并发、事件驱动方法的目标。

处理请求

在早期的章节中,我们使用 Gorilla 处理 URL 路由,这是一个紧凑但功能丰富的框架。Gorilla 工具包确实使这变得更容易,但我们也应该知道如何拦截功能以强加我们自己的自定义处理程序。

这是一个简单的 Web 路由器,我们在其中使用自定义的http.Server结构处理和指导请求,如下面的代码所示:

var routes []string

type customRouter struct {

}

func (customRouter) ServeHTTP(rw http.ResponseWriter, r 
  *http.Request) {

  fmt.Println(r.URL.Path);
}

func main() {

  var cr customRouter;

  server := &http.Server {
      Addr: ":9000",
      Handler:cr,
      ReadTimeout: 10 * time.Second,
      WriteTimeout: 10 * time.Second,
      MaxHeaderBytes: 1 << 20,
  }

  server.ListenAndServe()
}

在这里,我们不是使用内置的 URL 路由 muxer 和分发器,而是创建了一个自定义服务器和自定义处理程序类型来接受 URL 并路由请求。这使我们在处理 URL 时更加强大。

在这种情况下,我们创建了一个名为customRouter的基本空结构,并将其传递给我们的自定义服务器创建调用。

我们可以向我们的customRouter类型添加更多元素,但是对于这个简单的示例,我们实际上不需要这样做。我们所需要做的就是能够访问 URL 并将它们传递给处理程序函数。我们将有三个:一个用于静态内容,一个用于动态内容,一个用于来自数据库的动态内容。

不过,在我们走得太远之前,我们应该看看我们用 Go 编写的绝对基本的 HTTP 服务器在面对我们向 Apache 发送的相同流量时会做些什么。

老派的意思是服务器只会接受请求并传递静态的平面文件。您可以使用自定义路由器来做到这一点,就像我们之前做的那样,接受请求,打开文件,然后提供它们,但是 Go 提供了一种更简单的方式来处理http.FileServer方法中的基本任务。

因此,为了获得 Go 服务器的最基本性能与 Apache 的基准,我们将利用一个简单的 FileServer,并将其与test.html页面进行测试(其中包含与 Apache 相同的 80 KB 文件)。

注意

由于我们的目标是提高提供平面和动态页面的性能,因此测试套件的实际规格有些不重要。我们期望,尽管度量标准在不同环境中不会匹配,但我们应该看到类似的轨迹。也就是说,我们应该提供这些测试所使用的环境;在这种情况下,我们使用了一台配备 1.4 GHz i5 处理器和 4 GB 内存的 MacBook Air。

首先,我们将使用 Apache 的最佳性能,它具有 850 个并发连接和 900 个总请求。

处理请求

与 Apache 相比,结果确实令人鼓舞。我们的两个测试系统都没有进行太多调整(Apache 安装和 Go 中的基本 FileServer),但 Go 的 FileServer 可以处理 1,000 个并发连接,而没有任何问题,最慢的时钟速度为 411 毫秒。

提示

在过去的五年中,Apache 在并发性和性能选项方面取得了很大进展,但要达到这一点需要进行一些调整和测试。这个实验的目的并不是贬低经过充分测试和建立的世界第一 Web 服务器 Apache,而是要将其与我们在 Go 中所能做的进行比较。

为了真正了解我们在 Go 中可以实现的基准,让我们看看 Go 的 FileServer 是否可以在单个普通机器上轻松处理 10,000 个连接:

ab -n 10500 -c 10000 -g test.csv http://localhost:8080/a.html

我们将得到以下输出:

处理请求

成功!Go 的 FileServer 本身将轻松处理 10,000 个并发连接,提供平面的静态内容。

当然,这不是这个特定项目的目标——我们将实现诸如模板解析和数据库访问等真实世界的障碍,但这本身就应该向您展示 Go 为需要处理大量基本网络流量的响应服务器提供的起点。

路由请求

因此,让我们退一步,再次看看如何通过传统的 Web 服务器路由我们的流量,不仅包括静态内容,还包括动态内容。

我们将创建三个函数,用于从我们的customRouter:serveStatic():: read函数中路由流量并提供一个平面文件serveRendered():,解析模板以显示serveDynamic():,连接到 MySQL,将数据应用于结构,并解析模板。

为了接受我们的请求并重新路由,我们将更改customRouter结构的ServeHTTP方法来处理三个正则表达式。

为了简洁和清晰起见,我们只会返回我们三种可能请求的数据。其他任何内容都将被忽略。

在现实世界的场景中,我们可以采取这种方法,积极主动地拒绝我们认为无效的请求连接。这将包括蜘蛛和恶意机器人和进程,它们作为非用户并没有真正价值。

提供页面

首先是我们的静态页面。虽然我们之前以成语方式处理了这个问题,但是使用http.ServeFile函数可以重写我们的请求,更好地处理特定的 404 错误页面等,如下面的代码所示:

  path := r.URL.Path;

  staticPatternString := "static/(.*)"
  templatePatternString := "template/(.*)"
  dynamicPatternString := "dynamic/(.*)"

  staticPattern := regexp.MustCompile(staticPatternString)
  templatePattern := regexp.MustCompile(templatePatternString)
  dynamicDBPattern := regexp.MustCompile(dynamicPatternString)

  if staticPattern.MatchString(path) {
    page := staticPath + staticPattern.ReplaceAllString(path, 
     "${1}") + ".html"

    http.ServeFile(rw, r, page)
  }

在这里,我们只需将所有以/static/(.*)开头的请求与.html扩展名匹配。在我们的情况下,我们已经将我们的测试文件(80 KB 示例文件)命名为test.html,因此所有对它的请求将转到/static/test

我们在这之前加上了staticPath,这是一个在代码中定义的常量。在我们的情况下,它是/var/www/,但您可能需要根据需要进行修改。

因此,让我们看看引入一些正则表达式所带来的开销,如下图所示:

提供页面

怎么样?不仅没有额外的开销,而且似乎FileServer功能本身比单独的FileServe()调用更重,更慢。为什么呢?除了其他原因,不显式调用文件以打开和提供会导致额外的操作系统调用,这可能会随着请求的增加而成倍增加,从而损害并发性能。

提示

有时候是小事情

除了严格地提供平面页面之外,我们实际上还在每个请求中执行另一个任务,使用以下代码行:

fmt.Println(r.URL.Path)

尽管这最终可能不会对您的最终性能产生影响,但我们应该注意避免不必要的日志记录或相关活动,这可能会给看似微不足道的性能障碍带来更大的问题。

解析我们的模板

在我们的下一个阶段,我们将衡量读取和解析模板的影响。为了有效地匹配以前的测试,我们将采用我们的 HTML 静态文件,并对其施加一些变量。

如果您还记得,我们的目标是尽可能模仿真实世界的场景。真实的 Web 服务器肯定会处理大量的静态文件服务,但是今天,动态调用构成了绝大部分的网络流量。

我们的数据结构将类似于最简单的数据表,而没有实际数据库的访问权限:

type WebPage struct {
  Title string
  Contents string
}

我们希望采用这种形式的任何数据,并使用模板呈现它。请记住,Go 通过大写(公共)或小写(私有)值的语法糖来创建公共或私有变量的概念。

如果您发现模板无法渲染,但控制台没有明确的错误提示,请检查您的变量命名。从 HTML(或文本)模板调用的私有值将导致渲染在该点停止。

现在,我们将获取这些数据,并将其应用于以/(.*)开头的 URL 的模板。我们可以确实使用正则表达式的通配部分做一些更有用的事情,所以让我们使用以下代码将其作为标题的一部分:

  } else if templatePattern.MatchString(path) {

    urlVar := templatePattern.ReplaceAllString(path, "${1}")
    page := WebPage{ Title: "This is our URL: "+urlVar, Contents: 
      "Enjoy our content" }
    tmp, _ := template.ParseFiles(staticPath+"template.html")
    tmp.Execute(rw,page)

  }

访问localhost:9000/template/hello应该呈现一个主体为以下代码的模板:

<h1>{{.Title}}</h1>
<p>{{.Contents}}</p>

我们将得到以下输出:

解析我们的模板

关于模板的一点需要注意的是,它们不是编译的;它们保持动态。也就是说,如果您创建了一个可渲染的模板并启动了服务器,那么模板可以被修改,结果会反映出来。

这是一个潜在的性能因素。让我们再次运行我们的基准测试,将模板渲染作为我们应用程序及其架构的附加复杂性:

解析我们的模板

天啊!发生了什么?我们从轻松处理 10,000 个并发请求到几乎无法处理 200 个。

公平地说,我们引入了一个故意设置的绊脚石,在任何给定 CMS 的设计中并不罕见。

您会注意到我们在每个请求上调用template.ParseFiles()方法。这是一种看似廉价的调用,但当您开始堆叠请求时,它确实会增加起来。

然后,将文件操作移出请求处理程序可能是有意义的,但我们需要做的不仅仅是这些——为了消除开销和阻塞调用,我们需要为请求设置一个内部缓存。

最重要的是,如果您希望保持服务器的非阻塞、快速和响应性,所有模板的创建和解析都应该发生在实际请求处理程序之外。这里是另一种方法:

var customHTML string
var customTemplate template.Template
var page WebPage
var templateSet bool

func main() {
  var cr customRouter;
  fileName := staticPath + "template.html"
  cH,_ := ioutil.ReadFile(fileName)
  customHTML = string(cH[:])

  page := WebPage{ Title: "This is our URL: ", Contents: "Enjoy 
    our content" }
  cT,_ := template.New("Hey").Parse(customHTML)
  customTemplate = *cT

尽管我们在请求之前使用了Parse()函数,但我们仍然可以使用Execute()方法修改我们特定于 URL 的变量,这与Parse()没有相同的开销。

当我们将这个移出customRouter结构的ServeHTTP()方法时,我们又回到了正常状态。这是我们将会得到的这些更改的响应:

解析我们的模板

外部依赖

最后,我们需要解决我们最大的潜在瓶颈,即数据库。正如前面提到的,我们将通过生成 1 到 10,000 之间的随机整数来模拟随机流量,以指定我们想要的文章。

随机化不仅在前端有用——我们将要绕过 MySQL 内部的任何查询缓存,以限制非服务器优化。

连接到 MySQL

我们可以通过原生 Go 路由到自定义连接到 MySQL,但通常情况下,有一些第三方包可以使这个过程变得不那么痛苦。鉴于这里的数据库(以及相关库)是主要练习的第三方,我们不会太关心这里的细节。

两个成熟的 MySQL 驱动程序库如下:

在这个例子中,我们将使用 Go-MySQL-Driver。我们将使用以下命令快速安装它:

go get github.com/go-sql-driver/mysql

这两个都实现了 Go 中核心的 SQL 数据库连接包,提供了一种标准化的方法来连接到 SQL 源并遍历行。

一个注意事项是,如果你以前从未在 Go 中使用过 SQL 包,但在其他语言中使用过——通常在其他语言中,“Open()”方法的概念意味着打开连接。在 Go 中,这只是为数据库创建结构和相关实现方法。这意味着仅仅在sql.database上调用Open()可能不会给出相关的连接错误,比如用户名/密码问题等。

这种方法的一个优势(或者根据您的观点而定的劣势)是,连接到数据库可能不会在向 Web 服务器发送请求之间保持打开状态。在整体方案中,打开和重新打开连接的影响微乎其微。

由于我们正在利用伪随机文章请求,我们将构建一个 MySQL 附属函数来通过 ID 获取文章,如下面的代码所示:

func getArticle(id int) WebPage {
  Database,err := sql.Open("mysql", "test:test@/master")
  if err != nil {
    fmt.Println("DB error!!!")
  }

  var articleTitle string
  sqlQ := Database.QueryRow("SELECT article_title from articles 
    where article_id=? LIMIT 1", 1).Scan(&articleTitle)
  switch {
    case sqlQ == sql.ErrNoRows:
      fmt.Printf("No rows!")
    case sqlQ != nil:
      fmt.Println(sqlQ)
    default:

  }

  wp := WebPage{}
  wp.Title = articleTitle
  return wp

}

然后我们将直接从我们的ServeHTTP()方法中调用该函数,如下面的代码所示:

  }else if dynamicDBPattern.MatchString(path) {
    rand.Seed(9)
    id := rand.Intn(10000)
    page = getArticle(id)
    customTemplate.Execute(rw,page)
  }

我们在这里做得怎么样?看一下下面的图表:

连接到 MySQL

毫无疑问,速度较慢,但我们成功承受了全部 10,000 个并发请求,完全来自未缓存的 MySQL 调用。

鉴于我们无法通过默认安装的 Apache 达到 1,000 个并发请求,这绝非易事。

多线程和利用多个核心

您可能想知道在调用额外的处理器核心时性能会如何变化——正如前面提到的,这有时会产生意想不到的效果。

在这种情况下,我们应该期望我们的动态请求和静态请求的性能都会有所提高。任何时候,操作系统中的上下文切换成本可能会超过额外核心的性能优势,我们都会看到矛盾的性能下降。在这种情况下,我们没有看到这种效果,而是看到了一个相对类似的线,如下图所示:

多线程和利用多个核心

探索我们的 Web 服务器

我们的最终 Web 服务器能够在即使是最适度的硬件上,很好地处理静态、模板渲染和动态内容,符合 10,000 个并发连接的目标。

这段代码——就像本书中的代码一样——可以被视为一个起点,如果投入生产,就需要进行改进。这个服务器缺乏任何形式的错误处理,但可以在没有任何问题的情况下有效地处理有效的请求。让我们看一下以下服务器的代码:

package main

import
(
"net/http"
"html/template"
"time"
"regexp"
"fmt"
"io/ioutil"
"database/sql"
"log"
"runtime"
_ "github.com/go-sql-driver/mysql"
)

我们这里的大部分导入都是相当标准的,但请注意 MySQL 行,它仅仅因为其副作用而被调用作为数据库/SQL 驱动程序:

const staticPath string = "static/"

相对的static/路径是我们将查找任何文件请求的地方——正如前面提到的,这并不会进行额外的错误处理,但net/http包本身会在请求不存在的文件时返回 404 错误:

type WebPage struct {

  Title string
  Contents string
  Connection *sql.DB

}

我们的WebPage类型表示模板渲染之前的最终输出页面。它可以填充静态内容,也可以由数据源填充,如下面的代码所示:

type customRouter struct {

}

func serveDynamic() {

}

func serveRendered() {

}

func serveStatic() {

}

如果您选择扩展 Web 应用程序,请使用这些方法——这样可以使代码更清晰,并删除ServeHTTP部分中的大量不必要的内容,如下面的代码所示:

func (customRouter) ServeHTTP(rw http.ResponseWriter, r 
  *http.Request) {
  path := r.URL.Path;

  staticPatternString := "static/(.*)"
  templatePatternString := "template/(.*)"
  dynamicPatternString := "dynamic/(.*)"

  staticPattern := regexp.MustCompile(staticPatternString)
  templatePattern := regexp.MustCompile(templatePatternString)
  dynamicDBPattern := regexp.MustCompile(dynamicPatternString)

  if staticPattern.MatchString(path) {
     serveStatic()
    page := staticPath + staticPattern.ReplaceAllString(path, 
      "${1}") + ".html"
    http.ServeFile(rw, r, page)
  }else if templatePattern.MatchString(path) {

    serveRendered()
    urlVar := templatePattern.ReplaceAllString(path, "${1}")

    page.Title = "This is our URL: " + urlVar
    customTemplate.Execute(rw,page)

  }else if dynamicDBPattern.MatchString(path) {

    serveDynamic()
    page = getArticle(1)
    customTemplate.Execute(rw,page)
  }

}

我们所有的路由都是基于正则表达式模式匹配的。有很多方法可以做到这一点,但regexp给了我们很大的灵活性。唯一需要考虑简化的时候是,如果您有很多潜在的模式,可能会导致性能损失——这意味着成千上万。流行的 Web 服务器 Nginx 和 Apache 处理他们的可配置路由大部分都是通过正则表达式,所以这是相当安全的领域:

func gobble(s []byte) {

}

Go 对于未使用的变量非常挑剔,虽然这并不总是最佳实践,但在某些时候,您会得到一个不对数据进行特定处理但能让编译器满意的函数。对于生产环境,这并不是您想要处理此类数据的方式。

var customHTML string
var customTemplate template.Template
var page WebPage
var templateSet bool
var Database sql.DB

func getArticle(id int) WebPage {
  Database,err := sql.Open("mysql", "test:test@/master")
  if err != nil {
    fmt.Println("DB error!")
  }

  var articleTitle string
  sqlQ := Database.QueryRow("SELECT article_title from articles 
    WHERE article_id=? LIMIT 1", id).Scan(&articleTitle)
  switch {
    case sqlQ == sql.ErrNoRows:
      fmt.Printf("No rows!")
    case sqlQ != nil:
      fmt.Println(sqlQ)
    default:

  }

  wp := WebPage{}
  wp.Title = articleTitle
  return wp

}

我们的getArticle函数演示了您如何在非常基本的级别上与database/sql包进行交互。在这里,我们打开一个连接,并使用QueryRow()函数查询一行。还有Query命令,通常也是一个SELECT命令,但可能返回多行。

func main() {

  runtime.GOMAXPROCS(4)

  var cr customRouter;

  fileName := staticPath + "template.html"
  cH,_ := ioutil.ReadFile(fileName)
  customHTML = string(cH[:])

  page := WebPage{ Title: "This is our URL: ", Contents: "Enjoy 
    our content" }
  cT,_ := template.New("Hey").Parse(customHTML)
  customTemplate = *cT

  gobble(cH)
  log.Println(page)
  fmt.Println(customTemplate)

  server := &http.Server {
      Addr: ":9000",
      Handler:cr,
      ReadTimeout: 10 * time.Second,
      WriteTimeout: 10 * time.Second,
      MaxHeaderBytes: 1 << 20,
  }

  server.ListenAndServe()

}

我们的主函数设置服务器,构建默认的WebPagecustomRouter,并开始在端口9000上监听。

超时并继续

在我们的服务器中,我们没有专注于持续连接的缓解概念。我们之所以不太担心它,是因为我们能够通过利用 Go 语言强大的内置并发特性,在所有三种方法中都能够轻松达到 10,000 个并发连接。

特别是在使用第三方或外部应用程序和服务时,重要的是要知道我们可以并且应该准备在连接中放弃(如果我们的应用程序设计允许的话)。

注意自定义服务器实现和两个特定属性:ReadTimeoutWriteTimeout。这使我们能够精确处理这种用例。

在我们的示例中,这被设置为一个荒谬地高的 10 秒。要接收、处理和发送一个请求,最多需要 20 秒的时间。在 Web 世界中,这是一个漫长的时间,有可能使我们的应用瘫痪。那么,我们的 C10K 在每端都设置为 1 秒会是什么样子呢?让我们看一下下面的图表:

超时并继续前进

在这里,我们几乎在最高并发请求的尾部节省了将近 5 秒的时间,几乎可以肯定是以完整响应为代价。

决定保持运行缓慢连接的时间长度是由你来决定的,但这是保持服务器迅速响应的武器库中的另一个工具。

当你决定终止连接时,总会有一个权衡——太早会导致大量关于不响应或容易出错的服务器的投诉;太晚则无法以编程方式处理连接量。这是需要质量保证和硬数据的考虑之一。

总结

C10K 问题今天看起来可能已经过时了,但是呼吁行动是在并发语言和应用程序设计迅速扩展之前主要采用的系统应用的方法的症状。

仅仅 15 年前,这似乎是全球系统和服务器开发人员面临的一个几乎无法克服的问题;现在,通过对服务器设计进行轻微调整和考虑就能够解决。

Go 语言使得实现这一点变得容易(只需付出一点努力),但是达到 10,000(甚至是 100,000 或 1,000,000)并发连接只是一半的战斗。当问题出现时,我们必须知道该怎么做,如何在服务器中寻求最大性能和响应能力,并且如何构建我们的外部依赖,使其不会造成障碍。

在我们的下一章中,我们将通过测试一些分布式计算模式并最大限度地利用内存管理来进一步提高并发应用的性能。

第七章:性能和可扩展性

只需几百行代码就可以在 Go 中构建一个高性能的 Web 服务器,您应该非常清楚,并发 Go 为我们提供了出色的性能和稳定性工具。

我们在第六章中的示例,C10K – A Non-blocking Web Server in Go,也展示了如何在我们的代码中任意或无意地引入阻塞代码会引入严重的瓶颈,并迅速破坏扩展或扩展应用程序的计划。

在本章中,我们将看一些方法,可以更好地准备我们的并发应用程序,确保它能够持续扩展,并且能够在范围、设计和/或容量上进行扩展。

我们将更深入地扩展pprof,这是我们在之前章节中简要介绍的 CPU 分析工具,作为阐明我们的 Go 代码是如何编译的,并找出可能的意外瓶颈的方法。

然后我们将扩展到分布式 Go,以及提供一些性能增强的并行计算概念到我们的应用程序中的方法。我们还将看看谷歌应用引擎,以及如何利用它来确保您的基于 Go 的应用程序能够扩展到世界上最可靠的托管基础设施之一。

最后,我们将研究内存利用、保留以及谷歌的垃圾收集器的工作方式(有时也会出现问题)。我们将深入研究如何使用内存缓存来保持数据一致性,以及如何与分布式计算结合,最终也会看到这与分布式计算的关系。

Go 的高性能

到目前为止,我们已经讨论了一些工具,可以帮助我们发现减速、泄漏和低效的循环。

Go 的编译器和内置的死锁检测器阻止了我们在其他语言中常见且难以检测的错误。

我们基于特定并发模式的时间基准测试,可以帮助我们使用不同的方法设计我们的应用程序,以提高整体执行速度和性能。

深入了解 pprof

pprof 工具首次出现在第五章中,Locks, Blocks, and Better Channels,如果它仍然感觉有点神秘,那是完全可以理解的。pprof 向您显示的是一个调用图,我们可以使用它来帮助识别循环或堆上的昂贵调用的问题。这些包括内存泄漏和可以进行优化的处理器密集型方法。

展示这种工作原理的最好方法之一是构建一些不起作用的东西。或者至少是一些不按照应该的方式工作的东西。

您可能会认为具有垃圾收集的语言可能对这些类型的内存问题免疫,但总是有方法可以隐藏导致内存泄漏的错误。如果 GC 找不到它,有时自己找到它可能会非常痛苦,导致大量——通常是无效的——调试。

公平地说,什么构成内存泄漏有时在计算机科学成员和专家之间存在争议。如果程序不断消耗内存,根据技术定义,如果应用程序本身可以重新访问任何给定的指针,则可能不会泄漏内存。但当你有一个程序在消耗内存后崩溃时,这基本上是无关紧要的,就像大象在自助餐厅消耗内存一样。

在垃圾收集的语言中创建内存泄漏的基本前提是隐藏分配的内存,事实上,在任何可以直接访问和利用内存的语言中,都提供了引入泄漏的机制。

我们将在本章后面再次回顾一些关于垃圾收集和 Go 实现的内容。

那么像 pprof 这样的工具如何帮助呢?非常简单地说,它向您展示了您的内存和 CPU 利用情况

让我们首先设计一个非常明显的 CPU 占用如下,看看 pprof 如何为我们突出显示这一点:

package main

import (
"os"
"flag"
"fmt"
"runtime/pprof"
)

const TESTLENGTH = 100000
type CPUHog struct {
  longByte []byte
}

func makeLongByte() []byte {
  longByte := make([]byte,TESTLENGTH)

  for i:= 0; i < TESTLENGTH; i++ {
    longByte[i] = byte(i)
  }
  return longByte
}

var profile = flag.String("cpuprofile", "", "output pprof data to 
  file")

func main() {
  var CPUHogs []CPUHog

  flag.Parse()
    if *profile != "" {
      flag,err := os.Create(*profile)
      if err != nil {
        fmt.Println("Could not create profile",err)
      }
      pprof.StartCPUProfile(flag)
      defer pprof.StopCPUProfile()

    }

  for i := 0; i < TESTLENGTH; i++ {
    hog := CPUHog{}
    hog.longByte = makeLongByte()
    _ = append(CPUHogs,hog)
  }
}

上述代码的输出如下图所示:

深入了解 pprof

在这种情况下,我们知道我们的堆栈资源分配去了哪里,因为我们故意引入了循环(以及其中的循环)。

想象一下,我们并没有故意这样做,而是不得不找出资源占用。在这种情况下,pprof 使这变得非常容易,向我们展示了创建和内存分配的简单字符串构成了我们大部分样本。

我们可以稍微修改一下,看看 pprof 输出的变化。为了分配更多的内存,看看我们是否可以改变 pprof 的输出,我们可能会考虑使用更重的类型和更多的内存。

最简单的方法是创建一个新类型的切片,其中包括大量这些较重的类型,如 int64。我们很幸运有 Go:在这方面,我们不容易出现常见的 C 问题,比如缓冲区溢出和内存保护和管理,但是当我们无法故意破坏内存管理系统时,调试就会变得有点棘手。

提示

unsafe 包

尽管提供了内置的内存保护,但 Go 还提供了另一个有趣的工具:unsafe包。根据 Go 的文档:

包 unsafe 包含绕过 Go 程序类型安全性的操作。

这可能看起来是一个奇怪的库要包括——确实,虽然许多低级语言允许您自毁,但提供一个分离的语言是相当不寻常的。

在本章的后面,我们将研究unsafe.Pointer,它允许您读写任意内存分配的位。这显然是非常危险的(或者有用和邪恶的,这取决于您的目标)功能,您通常会尽量避免在任何开发语言中使用,但它确实允许我们调试和更好地理解我们的程序和 Go 垃圾收集器。

为了增加我们的内存使用量,让我们将我们的字符串分配切换如下,用于随机类型分配,特别是用于我们的新结构MemoryHog

type MemoryHog struct {
  a,b,c,d,e,f,g int64
  h,i,j,k,l,m,n float64
  longByte []byte
}

显然,没有什么能阻止我们将其扩展为一组荒谬地大的切片,大量的 int64 数组等等。但我们的主要目标仅仅是改变 pprof 的输出,以便我们可以识别调用图样本中的移动以及它对我们的堆栈/堆配置文件的影响。

我们的任意昂贵的代码如下:

type MemoryHog struct {
  a,b,c,d,e,f,g int64
  h,i,j,k,l,m,n float64
  longByte []byte
}

func makeMemoryHog() []MemoryHog {

  memoryHogs := make([]MemoryHog,TESTLENGTH)

  for i:= 0; i < TESTLENGTH; i++ {
    m := MemoryHog{}
    _ = append(memoryHogs,m)
  }

  return memoryHogs
}

var profile = flag.String("cpuprofile", "", "output pprof data to 
  file")

func main() {
  var CPUHogs []CPUHog

  flag.Parse()
    if *profile != "" {
      flag,err := os.Create(*profile)
      if err != nil {
        fmt.Println("Could not create profile",err)
      }
      pprof.StartCPUProfile(flag)
      defer pprof.StopCPUProfile()

    }

  for i := 0; i < TESTLENGTH; i++ {
    hog := CPUHog{}
    hog.mHog = makeMemoryHog()
    _ = append(CPUHogs,hog)
  }
}

有了这个,我们的 CPU 消耗保持大致相同(由于循环机制基本保持不变),但我们的内存分配增加了——毫不奇怪——大约 900%。你可能不会精确复制这些结果,但是一个小改变导致资源分配的重大差异的一般趋势是可以重现的。请注意,内存利用报告可以使用 pprof 进行,但这不是我们在这里所做的;这里的内存利用观察发生在 pprof 之外。

如果我们采取之前建议的极端方法——为我们的结构创建荒谬地大的属性——我们可以进一步进行,但让我们看看这对我们的 CPU 配置文件执行的总体影响。影响如下图所示:

深入了解 pprof

在左侧,我们有我们的新分配方法,它调用我们更大的结构,而不是一组字符串。在右侧,我们有我们的初始应用程序。

相当戏剧性的波动,你觉得呢?虽然这两个程序在设计上都没有错,但我们可以轻松地切换我们的方法,看看资源去哪里,以及我们如何减少它们的消耗。

并行性和并发对 I/O pprof 的影响

当使用 pprof 时,您可能会很快遇到一个问题,那就是当您编写的脚本或应用程序特别依赖于高效的运行时性能时。当您的程序执行速度过快以至于无法正确进行性能分析时,这种情况最常见。

一个相关的问题涉及到需要连接进行性能分析的网络应用程序;在这种情况下,您可以在程序内部或外部模拟流量,以便进行正确的性能分析。

我们可以通过使用 goroutines 复制类似于前面示例的方式来轻松演示这一点:

const TESTLENGTH = 20000

type DataType struct {
  a,b,c,d,e,f,g int64
  longByte []byte  
}

func (dt DataType) init() {

}

var profile = flag.String("cpuprofile", "", "output pprof data to 
  file")

func main() {

  flag.Parse()
    if *profile != "" {
      flag,err := os.Create(*profile)
      if err != nil {
        fmt.Println("Could not create profile",err)
      }
      pprof.StartCPUProfile(flag)
      defer pprof.StopCPUProfile()
    }
  var wg sync.WaitGroup

  numCPU := runtime.NumCPU()
  runtime.GOMAXPROCS(numCPU)

  wg.Add(TESTLENGTH)

  for i := 0; i < TESTLENGTH; i++ {
    go func() {
      for y := 0; y < TESTLENGTH; y++ {
        dT := DataType{}
        dT.init()
      }
      wg.Done()
    }()
  }

  wg.Wait()

  fmt.Println("Complete.")
}

以下图显示了前面代码的 pprof 输出:

并行性和并发性对 I/O pprof 的影响

这并不是那么具有信息量,是吗?

如果我们想要获得有关 goroutines 堆栈跟踪的更有价值的信息,Go——像往常一样——提供了一些额外的功能。

在运行时包中,有一个函数和一个方法,允许我们访问和利用 goroutines 的堆栈跟踪:

  • runtime.Lookup:此函数根据名称返回一个性能分析

  • runtime.WriteTo:此方法将快照发送到 I/O 写入器

如果我们在程序中添加以下行,我们将无法在pprof Go 工具中看到输出,但我们可以在控制台中获得对我们的 goroutines 的详细分析。

pprof.Lookup("goroutine").WriteTo(os.Stdout, 1)

前一行代码给出了一些抽象 goroutine 内存位置信息和包细节,看起来会像下面的截图:

并行性和并发性对 I/O pprof 的影响

但更快的获得这个输出的方法是利用http/pprof工具,它通过一个单独的服务器保持我们应用程序的结果处于活动状态。我们在这里使用了端口 6000,如下面的代码所示,但您可以根据需要进行修改:

  go func() {
    log.Println(http.ListenAndServe("localhost:6000", nil))
  }()

虽然您无法获得 goroutine 堆栈调用的 SVG 输出,但您可以通过访问http://localhost:6060/debug/pprof/goroutine?debug=1在浏览器中实时查看。

使用 App Engine

虽然并非适用于每个项目,但 Google 的 App Engine 可以在并发应用程序方面提供可扩展性,而无需进行 VM 配置、重启、监控等繁琐操作。

App Engine 与亚马逊网络服务、DigitalOcean 等并没有完全不同,唯一的区别在于您不需要必须参与直接服务器设置和维护的细节。它们都提供了一个单一的地方来获取和利用虚拟计算资源来运行您的应用程序。

相反,它可以成为谷歌架构中更抽象的环境,用于在多种语言中托管和运行您的代码,包括——毫不奇怪的——Go 语言本身。

大型应用程序将会产生费用,但 Google 提供了一个免费的层次,具有合理的试验和小型应用程序的配额。

与可扩展性相关的好处有两个:您无需像在 AWS 或 DigitalOcean 场景中那样负责确保实例的正常运行时间。除了谷歌之外,还有谁不仅拥有支持任何你可以投入其中的架构,而且还拥有 Go 核心本身的最快更新速度?

当然,这里有一些明显的限制与优势相一致,包括您的核心应用程序将仅通过http可用(尽管它将可以访问到其他许多服务)。

提示

要将应用程序部署到 App Engine,您需要 Go 的 SDK,适用于 Mac OS X、Linux 和 Windows,网址为developers.google.com/appengine/downloads#Google_App_Engine_SDK_for_Go

安装了 SDK 后,您需要对代码进行一些微小的更改,最值得注意的一点是,在大多数情况下,您的 Go 工具命令将被goapp替代,它负责在本地提供您的应用程序,然后部署它。

分布式 Go

我们确实涵盖了很多关于并发和并行 Go 的内容,但对于开发人员和系统架构师来说,最大的基础设施挑战之一与协作计算有关。

我们之前提到的一些应用程序和设计从并行扩展到分布式计算。

Memcache(d)是一种内存缓存,可以用作多个系统之间的队列。

我们在第四章中提出的主从和生产者-消费者模型与 Go 中的单机编程相比更多地涉及分布式计算,后者在并发方面具有成语特色。这些模型是许多语言中典型的并发模型,但也可以扩展到帮助我们设计分布式系统,利用不仅是许多核心和丰富的资源,还有冗余。

分布式计算的基本原则是将任何给定应用程序的各种负担分享、分散和最佳吸收到许多系统中。这不仅可以提高总体性能,还可以为系统本身提供一定程度的冗余。

这一切都是有一定成本的,具体如下:

  • 网络延迟的潜在可能性

  • 导致通信和应用程序执行减速

  • 设计和维护上的复杂性整体增加

  • 分布式路线上各个节点存在安全问题的潜在可能性

  • 由于带宽考虑可能增加成本

这一切都是为了简单地说,虽然构建分布式系统可以为利用并发性和确保数据一致性的大型应用程序提供巨大的好处,但这并不意味着它适用于每个示例。

拓扑类型

分布式计算认识到分布式设计的一系列逻辑拓扑结构。拓扑结构是一个恰当的比喻,因为所涉及系统的位置和逻辑通常可以代表物理拓扑。

并非所有被接受的拓扑结构都适用于 Go。当我们使用 Go 设计并发分布式应用程序时,通常会依赖于一些更简单的设计,具体如下。

类型 1-星形

星形拓扑结构(或至少是这种特定形式),类似于我们之前概述的主从或生产者-消费者模型。

数据传递的主要方法涉及使用主服务器作为消息传递通道;换句话说,所有请求和命令都由单个实例协调,该实例使用某种路由方法传递消息。以下图显示了星形拓扑结构:

类型 1-星形

我们实际上可以非常快速地为此设计一个基于 goroutine 的系统。以下代码仅为主服务器(或分布式目的地)的代码,缺乏任何安全考虑,但显示了我们如何将网络调用转换为 goroutines:

package main

import
(
  "fmt"
  "net"

)

我们的标准基本库定义如下:

type Subscriber struct {
  Address net.Addr
  Connection net.Conn
  do chan Task  
}

type Task struct {
  name string
}

这是我们将在这里使用的两种自定义类型。Subscriber类型是任何进入战场的分布式助手,Task类型代表任何给定的可分发任务。我们在这里没有定义它,因为这不是演示的主要目标,但你可以通过在 TCP 连接上通信标准化命令来做任何事情。Subscriber类型定义如下:

var SubscriberCount int
var Subscribers []Subscriber
var CurrentSubscriber int
var taskChannel chan Task

func (sb Subscriber) awaitTask() {
  select {
    case t := <-sb.do:
      fmt.Println(t.name,"assigned")

  }
}

func serverListen (listener net.Listener) {
  for {
    conn,_ := listener.Accept()

    SubscriberCount++

    subscriber := Subscriber{ Address: conn.RemoteAddr(), 
      Connection: conn }
    subscriber.do = make(chan Task)
    subscriber.awaitTask()
    _ = append(Subscribers,subscriber)

  }
}

func doTask() {
  for {
    select {
      case task := <-taskChannel:
        fmt.Println(task.name,"invoked")
        Subscribers[CurrentSubscriber].do <- task
        if (CurrentSubscriber+1) > SubscriberCount {
          CurrentSubscriber = 0
        }else {
          CurrentSubscriber++
        }
    }

  }
}

func main() {

  destinationStatus := make(chan int)

  SubscriberCount = 0
  CurrentSubscriber = 0

  taskChannel = make(chan Task)

  listener, err := net.Listen("tcp", ":9000")
  if err != nil {
    fmt.Println ("Could not start server!",err)
  }
  go serverListen(listener)  
  go doTask()

  <-destinationStatus
}

这实质上将每个连接视为一个新的Subscriber,它根据其索引获得自己的通道。然后,主服务器使用以下非常基本的轮询方法迭代现有的Subscriber连接:

if (CurrentSubscriber+1) > SubscriberCount {
  CurrentSubscriber = 0
}else {
  CurrentSubscriber++
}

如前所述,这缺乏任何安全模型,这意味着对端口 9000 的任何连接都将成为Subscriber,并且可以接收分配给它的网络消息(并且可能还可以调用新消息)。但您可能已经注意到一个更大的遗漏:这个分布式应用程序什么也没做。实际上,这只是一个用于分配和管理订阅者的模型。现在,它没有任何行动路径,但我们将在本章后面更改这一点。

类型 2-网格

网格与星型非常相似,但有一个主要区别:每个节点不仅可以通过主节点进行通信,还可以直接与其他节点进行通信。这也被称为完全图。以下图显示了网格拓扑结构:

类型 2-网格

出于实际目的,主服务器仍然必须处理分配并将连接传递回各个节点。

实际上,通过对我们之前的服务器代码进行以下简单修改,添加这个并不特别困难:

func serverListen (listener net.Listener) {
  for {
    conn,_ := listener.Accept()

    SubscriberCount++

    subscriber := Subscriber{ Address: conn.RemoteAddr(), 
      Connection: conn }
    subscriber.awaitTask()
    _ = append(Subscribers,subscriber)
    broadcast()
  }
}

然后,我们添加以下对应的broadcast函数,将所有可用的连接共享给所有其他连接:

func broadcast() {
  for i:= range Subscribers {
    for j:= range Subscribers {
      Subscribers[i].Connection.Write
        ([]byte("Subscriber:",Subscriber[j].Address))  
    }
  }
}

发布和订阅模型

在前面的两种拓扑结构中,我们复制了一个由中央/主服务器处理交付的发布和订阅模型。与单系统并发模式不同,我们缺乏直接在不同计算机之间使用通道的能力(除非我们使用像 Go 的 Circuit 这样的东西,如第四章中所述的那样,应用程序中的数据完整性)。

没有直接的编程访问来发送和接收实际命令,我们依赖某种形式的 API。在前面的例子中,没有实际发送或执行的任务,但我们该如何做呢?

显然,要创建可以形式化为非代码传输的任务,我们需要一种 API 形式。我们可以通过两种方式之一来实现这一点:命令序列化,理想情况下通过 JSON 直接传输,以及代码执行。

由于我们将始终处理编译后的代码,因此命令序列化选项可能看起来似乎无法包含 Go 代码本身。这并不完全正确,但是在任何语言中传递完整代码都是安全问题的重要问题。

但让我们看看通过 API 以任务的方式发送数据的两种方法,即通过从 URL 切片中删除一个 URL 以进行检索。我们首先需要在我们的main函数中初始化该数组,如下面的代码所示:

type URL struct {
  URI string
  Status int
  Assigned Subscriber
  SubscriberID int
}

我们数组中的每个 URL 都将包括 URI、其状态和分配给它的订阅者地址。我们将状态点规范为 0 表示未分配,1 表示已分配并等待,2 表示已分配并完成。

还记得我们的CurrentSubscriber迭代器吗?它代表了下一个轮询分配,将为我们的URL结构的SubscriberID值提供值。

接下来,我们将创建一个任意的 URL 数组,代表我们在这里的整体工作。可能需要一些怀疑来假设检索四个 URL 需要任何分布式系统;实际上,这将通过网络传输引入显著的减速。我们之前在纯粹的单系统并发应用程序中处理过这个问题:

  URLs = []URL{ {Status:0,URL:"http://golang.org/"}, 
    {Status:0,URL:"http://play.golang.org/"}, 
      {Status:0,URL:"http://golang.org/doc/"}, 
        {Status:0,URL:"http://blog.golang.org/"} }

序列化数据

在 API 的第一个选项中,我们将以 JSON 格式发送和接收序列化数据。我们的主服务器将负责规范其命令和相关数据。在这种情况下,我们希望传输一些内容:要做什么(在这种情况下是检索)与相关数据,当完成时响应应该是什么,以及如何处理错误。

我们可以用自定义结构表示如下:

type Assignment struct {
  command string
  data string
  successResponse string
  errorResponse string
}
...
  asmnt := Assignment{command:"process",
    url:"http://www.golang.org",successResponse:"success",
      errorResponse:"error"}
  json, _ := json.Marshal(asmnt )
  send(string(json))

远程代码执行

远程代码执行选项并不一定与命令序列化分开,而是结构化和解释格式化响应的替代方案,有效载荷可以是将通过系统命令运行的代码。

例如,任何语言的代码都可以通过网络传递,并且可以从另一种语言的 shell 或 syscall 库中执行,就像以下 Python 示例一样:

from subprocess import call
call([remoteCode])

这种方法的缺点很多:它引入了严重的安全问题,并使您几乎无法在客户端内部进行错误检测。

优点是您不需要为响应制定特定的格式和解释器,以及潜在的速度改进。您还可以将响应代码卸载到任意数量的语言的另一个外部进程中。

在大多数情况下,命令的序列化远比远程代码执行选项更可取。

其他拓扑

存在许多更复杂的拓扑类型,作为消息队列的一部分更难管理。

以下图表显示了总线拓扑:

其他拓扑

总线拓扑网络是一个单向传输系统。对于我们的目的来说,它既不特别有用,也不容易管理,因为每个添加的节点都需要宣布其可用性,接受监听器责任,并准备在新节点加入时放弃该责任。

总线的优势在于快速扩展性。但是,这也带来了严重的缺点:缺乏冗余和单点故障。

即使使用更复杂的拓扑,系统中始终会存在一些可能丢失宝贵齿轮的问题;在这种模块化冗余级别上,将需要一些额外的步骤来实现始终可用的系统,包括自动双重或三重节点复制和故障转移。这比我们在这里讨论的要多一些,但重要的是要注意,无论如何都会存在风险,尽管在总线等拓扑中更容易受到影响。

以下图表显示了环形拓扑:

其他拓扑

环形拓扑看起来与我们的网状拓扑类似,但缺少主节点。它基本上需要与总线一样的通信过程(宣布和监听)。请注意一个重要的区别:通信不是在单个监听器之间进行,而是可以在没有主节点的情况下在任何节点之间进行。

这意味着所有节点都必须同时监听并宣布它们的存在给其他节点。

消息传递接口

还有一个稍微更正式的版本,称为消息传递接口,它是我们之前构建的更正式的版本。MPI 是从上世纪 90 年代初的学术界诞生的,作为分布式通信的标准。

最初是为 FORTRAN 和 C 而编写的,它仍然是一个协议,因此它基本上与语言无关。

MPI 允许管理高于我们能够为资源管理系统构建的基本拓扑,包括不仅是线性和环形拓扑,还有常见的总线拓扑。

在大多数情况下,MPI 被科学界使用;它是一种高度并发和类似的方法,用于构建大规模分布式系统。点对点操作更严格地定义了错误处理、重试和动态生成进程。

我们之前的基本示例没有为处理器设置优先级,这是 MPI 的核心效果之一。

Go 没有官方的 MPI 实现,但由于 C 和 C++都有官方实现,因此完全可以通过它们进行接口操作。

注意

还有一个由 Marcus Thierfelder 用 Go 编写的简单而不完整的绑定,您可以进行实验。它可以在github.com/marcusthierfelder/mpi上找到。

您可以从www.open-mpi.org/了解更多关于 OpenMPI 的信息并进行安装。

您也可以在www.mpich.org/上阅读更多关于 MPI 和 MPICH 实现的信息。

一些有用的库

毫无疑问,Go 语言提供了一些最好的辅助工具,适用于任何编译语言。在许多系统上编译成本地代码,死锁检测,pprof,fmt 等工具不仅可以帮助你构建高性能的应用程序,还可以测试和格式化它们。

这并没有阻止社区开发其他工具,用于调试或帮助并发和/或分布式代码。我们将看看一些很棒的工具,可能值得包含在你的应用程序中,特别是如果它非常显眼或性能关键。

Nitro 性能分析器

你现在可能已经很清楚,Go 的 pprof 非常强大和有用,尽管不太用户友好。

如果你已经喜欢 pprof,甚至如果你觉得它很繁琐和令人困惑,你可能会更喜欢 Nitro 性能分析器。来自 spf13 的 Steve Francia,Nitro 性能分析器可以让你更清晰地分析你的应用程序及其功能和步骤,同时提供更可用的备选功能的 A/B 测试。

提示

spf13.com/project/nitro上阅读更多关于 Nitro 性能分析器的信息。

你可以通过github.com/spf13/nitro获取它。

与 pprof 一样,Nitro 会自动将标志注入到你的应用程序中,并且你会在结果中看到它们。

与 pprof 不同,你的应用程序不需要编译就可以从中获取性能分析。相反,你只需在go run命令后附加-stepAnalysis

Heka

Heka 是一个数据管道工具,可用于收集、分析和分发原始数据。Heka 来自 Mozilla,它更像是一个独立的应用程序,而不是一个库,但在获取、分析和分发诸如服务器日志文件之类的数据时,Heka 可以证明自己是有价值的。

Heka 也是用 Go 语言编写的,所以一定要查看源代码,看看 Mozilla 如何在实时数据分析中利用并发和 Go 语言。

提示

你可以访问 Heka 主页heka-docs.readthedocs.org/en/latest/和 Heka 源页github.com/mozilla-services/heka

GoFlow

最后,还有 GoFlow,这是一个基于流的编程范式工具,可以将你的应用程序分成不同的组件,每个组件都可以绑定到端口、通道、网络或进程。

虽然 GoFlow 本身不是一个性能工具,但对于一些应用程序来说,GoFlow 可能是扩展并发的合适方法。

提示

访问 GoFlowgithub.com/trustmaster/goflow

内存保留

在撰写本文时,Go 1.2.2 的编译器使用了一个天真的标记/清除垃圾收集器,它为对象分配引用等级,并在它们不再使用时清除它们。这值得注意的只是为了指出它被广泛认为是一个相对较差的垃圾收集系统。

那么为什么 Go 要使用它呢?随着 Go 的发展,语言特性和编译速度在很大程度上优先于垃圾收集。虽然 Go 的长期发展时间轴,目前来看,这就是我们的现状。然而,这种权衡是很好的:正如你现在所知道的,编译 Go 代码比编译 C 或 C++代码快得多。目前的垃圾收集系统已经足够好了。但你可以做一些事情来增强和实验垃圾收集系统。

Go 中的垃圾收集

要了解垃圾收集器在任何时候如何管理堆栈,可以查看runtime.MemProfileRecord对象,它跟踪当前活动堆栈跟踪中的对象。

在必要时,你可以调用性能记录,然后利用它来获取一些有趣的数据:

  • InUseBytes(): 这个方法根据内存配置文件当前使用的字节数

  • InUseObjects():该方法返回正在使用的活动对象的数量

  • Stack():该方法返回完整的堆栈跟踪

您可以将以下代码放入应用程序的重循环中,以查看所有这些内容:

      var mem runtime.MemProfileRecord
      obj := mem.InUseObjects();
      bytes := mem.InUseBytes();
      stack := mem.Stack();
      fmt.Println(i,obj,bytes)

总结

现在我们可以构建一些非常高性能的应用程序,然后利用一些 Go 内置工具和第三方包,以在单个实例应用程序以及跨多个分布式系统中寻求最佳性能。

在下一章中,我们将把所有内容整合起来,设计并构建一个并发服务器应用程序,它可以快速独立地工作,并且可以轻松地在性能和范围上进行扩展。

第八章:并发应用程序架构

到目前为止,我们已经设计了一些并发程序的小部分,主要是在一个单一的部分中保持并发性。但我们还没有把所有东西联系起来,构建出更强大、更复杂、从管理员的角度来看更具挑战性的东西。

简单的聊天应用程序和 Web 服务器都很好。然而,最终您将需要更多的复杂性,并需要外部软件来满足所有更高级的要求。

在这种情况下,我们将构建一些由几个不协调的服务满足的东西:一个带有修订控制的文件管理器,提供 Web 和 Shell 访问。像 Dropbox 和 Google Drive 这样的服务允许用户在同行之间保留和共享文件。另一方面,GitHub 及其类似的服务允许使用类似的平台,但具有关键的修订控制的额外好处。

许多组织面临以下共享和分发选项的问题:

  • 对存储库、存储空间或文件数量的限制

  • 如果服务中断,可能导致无法访问

  • 安全问题,特别是涉及敏感信息

简单的共享应用程序,如 Dropbox 和 Google Drive,在没有大量修订控制选项的情况下存储数据。GitHub 是一个出色的协作修订控制和分发系统,但伴随着许多成本,开发人员的错误可能导致严重的安全漏洞。

我们将结合版本控制的目标(以及 GitHub 的理想)与 Dropbox/Google Drive 的简单性和开放性。这种类型的应用程序将作为内部网络替代品非常完美——完全隔离并且可通过自定义身份验证访问,不一定依赖于云服务。将所有内容保留在内部消除了任何网络安全问题的潜在可能,并允许管理员设计符合其组织需求的永久备份解决方案。

组织内的文件共享将允许从命令行进行分叉、备份、文件锁定和修订控制,同时也可以通过简单的 Web 界面进行。

设计我们的并发应用程序

在设计并发应用程序时,我们将有三个在单独进程中运行的组件。文件监听器将被警报以对指定位置的文件进行更改。Web-CLI 界面将允许用户增加或修改文件,并且备份过程将绑定到监听器,以提供新文件更改的自动副本。考虑到这一点,这三个过程将看起来有点像下图所示的样子:

设计我们的并发应用程序

我们的文件监听器进程将执行以下三项任务:

  • 密切关注任何文件更改

  • 向我们的 Web/CLI 服务器和备份过程进行广播

  • 维护我们的数据库/数据存储中任何给定文件的状态

备份过程将接受文件监听器(#2)的任何广播,并以迭代设计创建备份文件。

我们的通用服务器(Web 和 CLI)将报告有关个别文件的详细信息,并允许使用可定制的语法进行前后版本控制。该应用程序的这一部分还必须在提交新文件或请求修订时向文件监听器进行广播。

确定我们的需求

我们的架构设计过程中最关键的一步是真正关注我们需要实现的功能、包和技术。对于我们的文件管理和修订控制应用程序,有一些关键点将突出显示:

  • 允许文件上传、下载和修订的 Web 界面。

  • 允许我们回滚更改并直接修改文件的命令行界面。

  • 一个文件系统监听器,用于查找对共享位置所做的更改。

  • 一个具有强大的 Go 关联性的数据存储系统,允许我们以基本一致的方式维护有关文件和用户的信息。该系统还将维护用户记录。

  • 一个维护和循环更改文件日志的并发日志系统。

我们允许以下三种不同的方式与整个应用程序进行交互,这在某种程度上使事情变得复杂:

  • 通过需要用户和登录的 Web。这也允许我们的用户访问和修改文件,即使他们可能在某个地方没有连接到共享驱动器。

  • 通过命令行。这是过时的,但对于用户遍历文件系统,特别是不在 GUI 中的高级用户来说,它也是非常有价值的。

  • 通过自身改变的文件系统。这是共享驱动机制,我们假设任何有权访问的用户都将对任何文件进行有效修改。

为了处理所有这些,我们可以确定一些关键的技术如下:

  • 一个用于管理文件系统修订的数据库或数据存储。在选择事务性、ACID 兼容的 SQL 和 NoSQL 中的快速文档存储时,权衡通常是性能与一致性之间的权衡。然而,由于我们的大部分锁定机制将存在于应用程序中,复制锁定(即使在行级别)将增加潜在的缓慢和不需要的混乱。因此,我们将利用 NoSQL 解决方案。

  • 这个解决方案需要很好地处理并发。

  • 我们将使用一个 Web 界面,它引入了强大而干净的路由/多路复用,并与 Go 的强大内置模板系统很好地配合。

  • 一个文件系统通知库,允许我们监视文件的更改以及备份修订。

我们发现或构建的任何解决方案都需要高度并发和非阻塞。我们要确保不允许对文件进行同时更改,包括对我们内部修订的更改。

考虑到所有这些,让我们逐个识别我们的部分,并决定它们在我们的应用程序中的作用。

我们还将提出一些备选方案,这些选项可以在不损害功能或核心要求的情况下进行交换。这将允许在平台或偏好使我们的主要选项不可取的情况下具有一定的灵活性。每当我们设计一个应用程序时,了解其他可能的选择是个好主意,以防软件(或其使用条款)发生变化,或者在未来的规模上不再满意使用。

让我们从我们的数据存储开始。

在 Go 中使用 NoSQL 作为数据存储

使用 NoSQL 的最大让步之一显然是在进行 CRUD 操作(创建、读取、更新和删除)时缺乏标准化。SQL 自 1986 年以来一直是标准化的,并且在许多数据库中非常严密——从 MySQL 到 SQL Server,从微软和甲骨文一直到 PostgreSQL。

注意

您可以在nosql-database.org/上阅读更多关于 NoSQL 和各种 NoSQL 平台的信息。

Martin Fowler 在他的书《NoSQL Distilled》中也写了一篇关于这个概念和一些用例的流行介绍,网址为martinfowler.com/books/nosql.html

根据 NoSQL 平台的不同,您还可能失去 ACID 兼容性和耐久性。这意味着您的数据不是 100%安全——如果服务器崩溃,如果读取过时或不存在的数据等,可能会有事务丢失。后者被称为脏读。

所有这些都值得注意,因为它适用于我们的应用程序,特别是在并发性方面,因为我们在前几章中已经谈到了其中一个潜在的第三方瓶颈。

对于我们在 Go 中的文件共享应用程序,我们将利用 NoSQL 来存储有关文件的元数据以及修改/交互这些文件的用户。

在选择 NoSQL 数据存储时,我们有很多选择,几乎所有主要的数据存储都在 Go 中有库或接口。虽然我们在这里选择了 Couchbase,但我们也会简要讨论一些其他主要的竞争对手以及每个的优点。

以下各节中的代码片段也应该让你对如何在不太焦虑的情况下将 Couchbase 替换为其他任何一个有一些想法。虽然我们不会深入研究其中任何一个,但为了确保易于交换,用于维护文件和修改信息的代码将尽可能通用。

MongoDB

MongoDB 是最受欢迎的 NoSQL 平台之一。它是在 2009 年编写的,也是最成熟的平台之一,但也带来了一些权衡,这使得它在近年来有些失宠。

即便如此,Mongo 以可靠的方式完成了它的任务,并且速度非常快。使用索引,就像大多数数据库和数据存储一样,极大地提高了读取的查询速度。

Mongo 还允许对读取、写入和一致性的保证进行非常精细的控制。你可以将其视为对支持语法脏读的任何语言和/或引擎的非常模糊的类比。

最重要的是,Mongo 在 Go 中很容易支持并发,并且隐式地设计用于分布式系统。

注意

Mongo 的最大 Go 接口是mgo,可以在以下网址找到:godoc.org/labix.org/v2/mgo

如果你想在 Go 中尝试 Mongo,将数据存储记录注入自定义结构是一个相对简单的过程。以下是一个快速而简单的例子:

import
(
    "labix.org/v2/mgo"
    "labix.org/v2/mgo/bson"
)

type User struct {
  name string
}

func main() {
  servers, err := mgo.Dial("localhost")
  defer servers.Close()
  data := servers.DB("test").C("users")
  result := User{}
  err = c.Find(bson.M{"name": "John"}).One(&result)
}

与其他 NoSQL 解决方案相比,Mongo 的一个缺点是它默认没有任何 GUI。这意味着我们要么需要绑定另一个应用程序或 Web 服务,要么坚持使用命令行来管理其数据存储。对于许多应用程序来说,这并不是什么大问题,但我们希望尽可能地将这个项目分隔和局部化,以限制故障点。

Mongo 在容错性和数据丢失方面也有点名声不佳,但这同样适用于许多 NoSQL 解决方案。此外,这在很多方面是一个快速数据存储的特性——因此,灾难恢复往往是以速度和性能为代价的。

可以说这是对 Mongo 及其同行的一种普遍夸大的批评。Mongo 会出现问题吗?当然会。管理的基于 Oracle 的系统也会出现问题吗?当然会。在这个领域减轻大规模故障更多地是系统管理员的责任,而不是软件本身,后者只能提供设计这样的应急计划所需的工具。

尽管如此,我们希望有一个快速和高可用的管理界面,因此 Mongo 不符合我们的要求,但如果这些要求不那么受重视,它可以很容易地插入到这个解决方案中。

Redis

Redis 是另一个键/值数据存储,最近成为了总使用量和受欢迎程度方面的第一名。在理想的 Redis 世界中,整个数据集都保存在内存中。鉴于许多数据集的大小,这并不总是可能的;然而,结合 Redis 的能力来摒弃持久性,当在并发应用程序中使用时,这可能会产生一些非常高性能的结果。

Redis 的另一个有用的特性是它可以固有地保存不同的数据结构。虽然你可以通过在 Mongo(和其他数据存储)中取消编组 JSON 对象/数组来对这些数据进行抽象,但 Redis 可以处理集合、字符串、数组和哈希。

在 Go 中,有两个主要被接受的 Redis 库:

  • Radix:这是一个极简主义的客户端,简洁、快速而简单。要安装 Radix,请运行以下命令:
go get github.com/fzzy/radix/redis

  • Redigo:这更加强大,稍微复杂一些,但提供了许多更复杂的功能,我们可能在这个项目中不需要。要安装 Redigo,请运行以下命令:
go get github.com/garyburd/redigo/redis

现在我们将看一个快速的例子,使用 Redigo 从 Redis 的Users数据存储中获取用户的名称:

package main

import
(
    "fmt"
    "github.com/garyburd/redigo/redis"
)

func main() {

  connection,_ := dial()
  defer connection.Close()

  data, err := redis.Values(connection.Do("SORT", "Users", "BY", "User:*->name", 
    "GET", "User:*->name"))

  if (err) {
    fmt.Println("Error getting values", err)
  }

  for i:= range data {
    var Uname string
    data,err := redis.Scan(data, &Uname)
    if (err) {
      fmt.Println("Error getting value",err)
    }else {
      fmt.Println("Name Uname")
    }
  }
}

在审查这一点时,您可能会注意到一些非程序访问语法,例如以下内容:

  data, err := redis.Values(connection.Do("SORT", "Users", "BY", "User:*->name", 
    "GET", "User:*->name"))

这确实是为什么 Go 中的 Redis 不会成为我们这个项目的选择之一的原因之一——这两个库都提供了对某些功能的几乎 API 级别的访问,还提供了一些更详细的内置功能,用于直接交互。Do命令直接将查询传递给 Redis,如果需要使用库,这是可以的,但在整体上是一个不太优雅的解决方案。

这两个库都非常好地与 Go 的并发特性配合,您在通过它们之一进行非阻塞网络调用到 Redis 时不会遇到任何问题。

值得注意的是,Redis 仅支持 Windows 的实验性构建,因此这主要用于*nix 平台。现有的端口来自 Microsoft,可以在github.com/MSOpenTech/redis找到。

Tiedot

如果您已经大量使用 NoSQL,那么前面提到的引擎对您来说可能都很熟悉。Redis、Couch、Mongo 等在这个相对年轻的技术中都是虚拟的支柱。

另一方面,Tiedot 可能不太熟悉。我们在这里包括它,只是因为文档存储本身是直接用 Go 编写的。文档操作主要通过 Web 界面处理,它是一个像其他几种 NoSQL 解决方案一样的 JSON 文档存储。

由于文档访问和处理是通过 HTTP 进行的,所以工作流程有点违反直觉,如下所示:

Tiedot

由于这引入了潜在的延迟或故障点,这使得它不是我们这里的理想解决方案。请记住,这也是之前提到的一些其他解决方案的特点,但由于 Tiedot 是用 Go 编写的,因此连接到它并使用包读取/修改数据将会更容易。在撰写本书时,这是不存在的。

与 CouchDB 等基于 HTTP 或 REST 的替代方案不同,Tiedot 依赖于 URL 端点来指示操作,而不是 HTTP 方法。

您可以在以下代码中看到我们如何通过标准库处理类似的事情:

package main

import
(
  "fmt"
  "json"
  "http"
)

type Collection struct {
  Name string
}

简单地说,这是您希望通过数据选择、查询等方式引入到 Go 应用程序中的任何记录的数据结构。您在我们之前使用 SQL 服务器本身时看到了这一点,这并没有什么不同:

func main() {

  Col := Collection{
    Name: ''
  }

  data, err := http.Get("http://localhost:8080/all")
  if (err != nil) {
    fmt.Println("Error accessing tiedot")
  }
  collections,_ = json.Unmarshal(data,&Col)
}

尽管不像许多同行那样健壮、强大或可扩展,Tiedot 肯定值得玩耍,或者更好的是,值得贡献。

注意

您可以在github.com/HouzuoGuo/tiedot找到 Tiedot。

CouchDB

Apache 孵化器的 CouchDB 是 NoSQL 大数据中的另一个重要角色。作为一个 JSON 文档存储,CouchDB 在数据存储方法方面提供了很大的灵活性。

CouchDB 支持 ACID 语义,并且可以同时执行,这在某种程度上提供了很大的性能优势。在我们的应用程序中,对 ACID 一致性的依赖性是相对灵活的。从设计上讲,它将是容错和可恢复的,但对于许多人来说,即使是可恢复的数据丢失的可能性仍然被认为是灾难性的。

与 CouchDB 的接口是通过 HTTP 进行的,这意味着不需要直接实现或 Go SQL 数据库钩子来使用它。有趣的是,CouchDB 使用 HTTP 头语法来操作数据,如下所示:

  • GET:这代表读取操作

  • PUT:这代表创建操作

  • DELETE:这代表删除和更新操作

当然,这些最初是在 HTTP 1.1 中的标头方法,但是 Web 的很多部分都集中在 GET/POST 上,这些方法往往会在混乱中失去。

Couch 还配备了一个方便的 Web 界面进行管理。当 CouchDB 运行时,您可以在http://localhost:5984/_utils/访问它,如下面的截图所示:

CouchDB

也就是说,有一些包装器为一些更复杂和高级的功能提供了一定程度的抽象。

Cassandra

Cassandra,作为 Apache 基金会的另一个项目,技术上并不是一个 NoSQL 解决方案,而是一个集群(或可集群化)的数据库管理平台。

与许多 NoSQL 应用程序一样,Cassandra 的传统查询方法存在一些限制,例如,通常不支持子查询和连接。

我们在这里提到它主要是因为它专注于分布式计算以及以编程方式调整数据一致性或性能的能力。Couchbase 同样也表达了很多这些内容,但 Cassandra 更加专注于分布式数据存储。

然而,Cassandra 支持一部分 SQL,这将使它对于那些涉足过 MySQL、PostgreSQL 或类似数据库的开发人员来说更加熟悉。Cassandra 对高并发集成的内置处理在很多方面使其对 Go 来说是理想的,尽管对于这个项目来说有些过度。

与 Cassandra 进行接口的最值得注意的库是 gocql,它专注于速度和与 Cassandra 连接的清晰性。如果您选择使用 Cassandra 而不是 Couchbase(或其他 NoSQL),您会发现许多方法可以简单地替换。

以下是连接到集群并编写简单查询的示例:

package main

import
(
    "github.com/gocql/gocql"
    "log"
)

func main() {

  cass := gocql.NewCluster("127.0.0.1")
  cass.Keyspace = "filemaster"
  cass.Consistency = gocql.LocalQuorum

  session, _ := cass.CreateSession()
  defer session.Close()

  var fileTime int;

  if err := session.Query(`SELECT file_modified_time FROM filemaster 
  WHERE filename = ? LIMIT 1`, "test.txt").Consistency(gocql.One).Scan(&fileTime); err != nil {
    log.Fatal(err)
  }
  fmt.Println("Last modified",fileTime)
}

如果您计划快速扩展此应用程序、广泛分发它,或者对 SQL 比数据存储/JSON 访问更熟悉,那么 Cassandra 可能是一个理想的解决方案。

对于我们的目的来说,SQL 不是必需的,我们更看重速度,包括耐久性在内。

Couchbase

Couchbase 是该领域的一个相对新手,但它是由 CouchDB 和 memcached 的开发人员构建的。它是用 Erlang 编写的,与我们期望从我们的许多 Go 应用程序中获得的并发性、速度和非阻塞行为有许多相同的关注点。

Couchbase 还支持我们在前几章中讨论的许多其他功能,包括易于分发的安装、可调的 ACID 兼容性和低资源消耗。

Couchbase 的一个缺点是它在一些资源较低的机器或虚拟机上运行效果不佳(或根本无法运行)。确实,64 位安装至少需要 4GB 内存和四个核心,所以不要指望在小型、中小型实例或旧硬件上启动它。

虽然这里(或其他地方)提出的大多数 NoSQL 解决方案通常比它们的 SQL 对应方案具有性能优势,但 Couchbase 在 NoSQL 领域中表现得非常出色。

Couchbase,如 CouchDB 一样,配备了一个基于 Web 的图形界面,简化了设置和维护的过程。在设置中,您可以使用的高级功能包括基本存储引擎(Couchbase 或 memcached)、自动备份过程(副本)和读写并发级别。

除了配置和管理工具,它还在 Web 仪表板中提供了一些实时监控,如下面的截图所示:

Couchbase

虽然不能完全替代完整的服务器管理(当服务器宕机时,你没有洞察力会发生什么),但知道你的资源究竟去了哪里,而不需要命令行方法或外部工具,这非常有帮助。

Couchbase 中的术语略有不同,就像在许多这些解决方案中一样。对稍微将 NoSQL 与古板的旧 SQL 解决方案分开的渴望会不时地显现出来。

在 Couchbase 中,数据库是一个数据存储桶,记录是文档。然而,视图,作为一个旧的事务性 SQL 标准,为表格带来了一些熟悉的东西。这里的重点是,视图允许您使用简单的 JavaScript 创建更复杂的查询,在某些情况下,可以复制否则难以实现的功能,如连接、联合和分页。

在 Couchbase 中创建的每个视图都成为一个 HTTP 访问点。因此,您命名为select_all_files的视图将可以通过 URL 访问,例如http://localhost:8092/file_manager/_design/select_all_files/_view/Select%20All%20Files?connection_timeout=60000&limit=10&skip=0

最值得注意的 Couchbase 接口库是 Go Couchbase,如果没有其他选择,它可能会让您免受在代码中进行 HTTP 调用以访问 CouchDB 的冗余之苦。

注意

Go Couchbase 可以在github.com/couchbaselabs/go-couchbase找到。

Go Couchbase 通过 Go 抽象简单而强大地与 Couchbase 进行接口交互。以下代码以精简的方式连接并获取有关各种数据池的信息,感觉自然而简单:

package main

import
(
  "fmt"
  "github.com/couchbaselabs/go-couchbase"
)

func main() {

    conn, err := couchbase.Connect("http://localhost:8091")
    if err != nil {
      fmt.Println("Error:",err)
    }
    for _, pn := range conn.Info.Pools {
        fmt.Printf("Found pool:  %s -> %s\n", pn.Name, pn.URI)
    }
}

设置我们的数据存储

安装 Couchbase 后,默认情况下可以通过 localhost 和端口 8091 访问其管理面板。

您将有机会设置管理员、其他 IP 连接(如果加入集群)和一般数据存储设计。

之后,您需要设置一个存储桶,这是我们用来存储有关单个文件的所有信息的地方。以下是存储桶设置的界面:

设置我们的数据存储

在我们的示例中,我们正在使用单台机器,因此不支持副本(在数据库术语中也称为复制)。我们将其命名为file_manager,但这显然可以称为任何有意义的东西。

我们还将保持数据使用量相当低——当我们存储文件操作并记录较旧的操作时,没有必要使用超过 256MB 的内存。换句话说,我们并不一定关心将test.txt的修改历史永远保存在内存中。

我们还将使用 Couchbase 作为存储引擎等效,尽管您可以在 memcache(d)之间来回切换而几乎没有注意到的变化。

让我们首先创建一个种子文档:稍后我们将删除的文档,但它将代表我们的数据存储架构。我们可以使用任意的 JSON 结构化对象创建此文档,如下面的屏幕截图所示:

设置我们的数据存储

由于存储在此数据存储中的所有内容都应为有效的 JSON,因此我们可以混合和匹配字符串、整数、布尔值、数组和对象。这为我们提供了一些在使用数据时的灵活性。以下是一个示例文档:

{
  "file_name": "test.txt",
  "hash": "",
  "created": 1,
  "created_user": 0,
  "last_modified": "",
  "last_modified_user": "",
  "revisions": [],
  "version": 1
}

监视文件系统更改

在选择 NoSQL 选项时,我们可以选择各种各样的解决方案。但是当涉及到监视文件系统更改的应用程序时,情况就不一样了。虽然 Linux 版本在 inotify 中有一个相当不错的内置解决方案,但这限制了应用程序的可移植性。

因此,Chris Howey 的 fsnotify 中存在一个处理这个问题的跨平台库非常有帮助。

Fsnotify 在 Linux、OSX 和 Windows 上运行,并允许我们检测任何给定目录中的文件何时被创建、删除、修改或重命名,这对我们的目的来说已经足够了。

实现 fsnotify 也非常容易。最重要的是,它都是非阻塞的,因此,如果我们将监听器放在 goroutine 后面,我们可以将其作为主服务器应用程序代码的一部分运行。

以下代码显示了一个简单的目录监听器:

package main

import (
    "github.com/howeyc/fsnotify""fmt"
  "log""
)

func main() {

    scriptDone := make(chan bool)
    dirSpy, err := fsnotify.NewWatcher()
    if err != nil {
        log.Fatal(err)
    }

    go func() {
        for {
            select {
            case fileChange := <-dirSpy.Event:
                log.Println("Something happened to a file:", 
                  fileChange)
            case err := <-dirSpy.Error:
                log.Println("Error with fsnotify:", err)
            }
        }
    }()

    err = dirSpy.Watch("/mnt/sharedir")
    if err != nil {
      fmt.Println(err)
    }

    <-scriptDone

    dirSpy.Close()
}

管理日志文件

与许多开发人员工具箱中的基本功能一样,Go 提供了一个相当完整的内置日志记录解决方案。它处理许多基本功能,例如创建时间戳标记的日志项并保存到磁盘或控制台。

基本包遗漏的一件事是内置格式化和日志轮换,这是我们的文件管理器应用程序的关键要求。

请记住,我们的应用程序的关键要求包括能够在并发环境中无缝工作,并且在需要时能够准备好扩展到分布式网络。这就是 fine log4go应用程序派上用场的地方。Log4go 允许将日志记录到文件、控制台和内存,并且内在地处理日志轮换。

注意

Log4go 可以在code.google.com/p/log4go/找到。

要安装 Log4go,请运行以下命令:

go get code.google.com/p/log4go

创建一个处理警告、通知、调试信息和关键错误的日志文件很简单,并且将日志轮换附加到其中同样简单,如下面的代码所示:

package main

import
(
  logger "code.google.com/p/log4go"
)
func main() {
  logMech := make(logger.Logger);
  logMech.AddFilter("stdout", logger.DEBUG, 
    logger.NewConsoleLogWriter())

  fileLog := logger.NewFileLogWriter("log_manager.log", false)
  fileLog.SetFormat("[%D %T] [%L] (%S) %M")
  fileLog.SetRotate(true)
  fileLog.SetRotateSize(256)
  fileLog.SetRotateLines(20)
  fileLog.SetRotateDaily(true)
  logMech.AddFilter("file", logger.FINE, fileLog)

  logMech.Trace("Received message: %s)", "All is well")
  logMech.Info("Message received: ", "debug!")
  logMech.Error("Oh no!","Something Broke")
}

处理配置文件

在处理配置文件和解析它们时,您有很多选择,从简单到复杂。

当然,我们可以简单地将所需内容存储为 JSON,但是该格式对于人类来说有点棘手——它需要转义字符等,这使其容易出现错误。

相反,我们将使用 gcfg 中的标准ini config文件库来简化事务,该库处理gitconfig文件和传统的旧式.ini格式,如下面的代码片段所示:

[revisions]
count = 2
revisionsuffix = .rev
lockfiles = false

[logs]
rotatelength = 86400

[alarms]
emails = sysadmin@example.com,ceo@example.com

注意

您可以在code.google.com/p/gcfg/找到 gcfg。

基本上,该库获取配置文件的值并将其推送到 Go 中的结构体中。我们将如何做到这一点的示例如下:

package main

import
(
  "fmt"
  "code.google.com/p/gcfg"
)

type Configuration struct {
  Revisions struct {
    Count int
    Revisionsuffix string
    Lockfiles bool
  }
  Logs struct {
    Rotatelength int
  }
  Alarms struct {
    Emails string
  }
}

func main() {
  configFile := Configuration{}
  err := gcfg.ReadFileInto(&configFile, "example.ini")
  if err != nil {
    fmt.Println("Error",err)
  }
  fmt.Println("Rotation duration:",configFile.Logs.Rotatelength)
}

检测文件更改

现在我们需要专注于我们的文件监听器。您可能还记得,这是应用程序的一部分,它将接受来自我们的 Web 服务器和备份应用程序的客户端连接,并通知文件的任何更改。

这部分的基本流程如下:

  1. 在 goroutine 中监听文件的更改。

  2. 在 goroutine 中接受连接并添加到池中。

  3. 如果检测到任何更改,则向整个池通知它们。

所有三个操作同时发生,第一个和第三个操作可以在池中没有任何连接的情况下发生,尽管我们假设总会有一个连接始终与我们的 Web 服务器和备份应用程序保持连接。

文件监听器将扮演的另一个关键角色是在首次加载时分析目录并将其与我们在 Couchbase 中的数据存储进行协调。由于 Go Couchbase 库处理获取、更新和添加操作,我们不需要任何自定义视图。在下面的代码中,我们将检查文件监听器进程,并展示如何监听文件夹的更改:

package main

import
(
  "fmt"
  "github.com/howeyc/fsnotify"
  "net"
  "time"
  "io"  
  "io/ioutil"
  "github.com/couchbaselabs/go-couchbase"
  "crypto/md5"
  "encoding/hex"
  "encoding/json"  
  "strings"

)

var listenFolder = "mnt/sharedir"

type Client struct {
  ID int
  Connection *net.Conn  
}

在这里,我们声明了我们的共享文件夹以及一个连接的Client结构。在这个应用程序中,Client可以是 Web 监听器或备份监听器,并且我们将使用以下 JSON 编码结构单向传递消息:

type File struct {
  Hash string "json:hash"
  Name string "json:file_name"
  Created int64 "json:created"
  CreatedUser  int "json:created_user"
  LastModified int64 "json:last_modified"
  LastModifiedUser int "json:last_modified_user"
  Revisions int "json:revisions"
  Version int "json:version"
}

如果这看起来很熟悉,那可能是因为这也是我们最初设置的示例文档格式。

注意

如果您对之前表达的语法糖不熟悉,这些被称为结构标签。标签只是可以应用于结构字段的附加元数据,以便通过reflect包进行键/值查找。在这种情况下,它们用于将我们的结构字段映射到 JSON 字段。

让我们首先看一下我们的整体Message struct

type Message struct {
  Hash string "json:hash"
  Action string "json:action"
  Location string "json:location"  
  Name string "json:name"
  Version int "json:version"
}

我们将我们的文件分成一个消息,用于通知我们的其他两个进程发生了更改:

func generateHash(name string) string {

  hash := md5.New()
  io.WriteString(hash,name)
  hashString := hex.EncodeToString(hash.Sum(nil))

  return hashString
}

这是一种相对不可靠的方法,用于生成文件的哈希引用,如果文件名更改,它将失败。但是,它允许我们跟踪创建、删除或修改的文件。

向客户端发送更改

这是发送到所有现有连接的广播消息。我们传递我们的 JSON 编码的Message结构,其中包含当前版本、当前位置和用于参考的哈希。然后我们的其他服务器将相应地做出反应:

func alertServers(hash string, name string, action string, location string, version int) {

  msg := Message{Hash:hash,Action:action,Location:location,Name:name,Version:version}
  msgJSON,_ := json.Marshal(msg)

  fmt.Println(string(msgJSON))

  for i := range Clients {
    fmt.Println("Sending to clients")
    fmt.Fprintln(*Clients[i].Connection,string(msgJSON))
  }
}

我们的备份服务器将在备份文件夹中创建带有.[VERSION]扩展名的文件副本。

我们的 Web 服务器将通过 Web 界面简单地通知用户文件已更改:

func startServer(listener net.Listener) {
  for {  
    conn,err := listener.Accept()
    if err != nil {

    }
    currentClient := Client{ ID: 1, Connection: &conn}
    Clients = append(Clients,currentClient)
      for i:= range Clients {
        fmt.Println("Client",Clients[i].ID)
      }    
  }  

}

这段代码看起来熟悉吗?我们几乎完全复制了我们的聊天服务器Client处理程序并将其几乎完整地带到这里:

func removeFile(name string, bucket *couchbase.Bucket) {
  bucket.Delete(generateHash(name))
}

removeFile函数只做一件事,那就是从我们的 Couchbase 数据存储中删除文件。由于它是反应性的,我们不需要在文件服务器端做任何事情,因为文件已经被删除。此外,没有必要删除任何备份,因为这使我们能够恢复。接下来,让我们看一下我们的更新现有文件的函数:

func updateExistingFile(name string, bucket *couchbase.Bucket) int {
  fmt.Println(name,"updated")
  hashString := generateHash(name)

  thisFile := Files[hashString]
  thisFile.Hash = hashString
  thisFile.Name = name
  thisFile.Version = thisFile.Version + 1
  thisFile.LastModified = time.Now().Unix()
  Files[hashString] = thisFile
  bucket.Set(hashString,0,Files[hashString])
  return thisFile.Version
}

这个函数本质上是用新值覆盖 Couchbase 中的任何值,复制现有的File结构并更改LastModified日期:

func evalFile(event *fsnotify.FileEvent, bucket *couchbase.Bucket) {
  fmt.Println(event.Name,"changed")
  create := event.IsCreate()
  fileComponents := strings.Split(event.Name,"\\")
  fileComponentSize := len(fileComponents)
  trueFileName := fileComponents[fileComponentSize-1]
  hashString := generateHash(trueFileName)

  if create == true {
    updateFile(trueFileName,bucket)
    alertServers(hashString,event.Name,"CREATE",event.Name,0)
  }
  delete := event.IsDelete()
  if delete == true {
    removeFile(trueFileName,bucket)
    alertServers(hashString,event.Name,"DELETE",event.Name,0)    
  }
  modify := event.IsModify()
  if modify == true {
    newVersion := updateExistingFile(trueFileName,bucket)
    fmt.Println(newVersion)
    alertServers(hashString,trueFileName,"MODIFY",event.Name,newVersion)
  }
  rename := event.IsRename()
  if rename == true {

  }
}

在这里,我们对我们监视目录中文件系统的任何更改做出反应。我们不会对重命名做出反应,但您也可以处理这些情况。以下是我们处理一般updateFile函数的方法:

func updateFile(name string, bucket *couchbase.Bucket) {
  thisFile := File{}
  hashString := generateHash(name)

  thisFile.Hash = hashString
  thisFile.Name = name
  thisFile.Created = time.Now().Unix()
  thisFile.CreatedUser = 0
  thisFile.LastModified = time.Now().Unix()
  thisFile.LastModifiedUser = 0
  thisFile.Revisions = 0
  thisFile.Version = 1

  Files[hashString] = thisFile

  checkFile := File{}
  err := bucket.Get(hashString,&checkFile)
  if err != nil {
    fmt.Println("New File Added",name)
    bucket.Set(hashString,0,thisFile)
  }
}

检查与 Couchbase 的记录

在检查现有记录与 Couchbase 相对时,我们检查 Couchbase 存储桶中是否存在哈希。如果不存在,我们就创建它。如果存在,我们就什么都不做。为了更可靠地处理关闭,我们还应该将现有记录纳入我们的应用程序。执行此操作的代码如下:

var Clients []Client
var Files map[string] File

func main() {
  Files = make(map[string]File)
  endScript := make(chan bool)

  couchbaseClient, err := couchbase.Connect("http://localhost:8091/")
    if err != nil {
      fmt.Println("Error connecting to Couchbase", err)
    }
  pool, err := couchbaseClient.GetPool("default")
    if err != nil {
      fmt.Println("Error getting pool",err)
    }
  bucket, err := pool.GetBucket("file_manager")
    if err != nil {
      fmt.Println("Error getting bucket",err)
    }  

  files, _ := ioutil.ReadDir(listenFolder)
  for _, file := range files {
    updateFile(file.Name(),bucket)
  }

    dirSpy, err := fsnotify.NewWatcher()
    defer dirSpy.Close()

  listener, err := net.Listen("tcp", ":9000")
  if err != nil {
    fmt.Println ("Could not start server!",err)
  }

  go func() {
        for {
            select {
            case ev := <-dirSpy.Event:
                evalFile(ev,bucket)
            case err := <-dirSpy.Error:
                fmt.Println("error:", err)
            }
        }
    }()
    err = dirSpy.Watch(listenFolder)  
  startServer(listener)

  <-endScript
}

最后,main()处理设置我们的连接和 goroutines,包括文件监视器、TCP 服务器和连接到 Couchbase。

现在,让我们看一下整个过程中的另一个步骤,我们将自动创建我们修改后的文件的备份。

备份我们的文件

由于我们可以说是在网络上发送我们的命令,因此我们的备份过程需要在该网络上侦听并响应任何更改。鉴于修改将通过本地主机发送,我们在网络和文件方面应该有最小的延迟。

我们还将返回一些关于文件发生了什么的信息,尽管在这一点上我们对这些信息并没有做太多处理。这段代码如下:

package main

import
(
  "fmt"
  "net"
  "io"
  "os"
  "strconv"
  "encoding/json"
)

var backupFolder = "mnt/backup/"

请注意,我们有一个专门用于备份的文件夹,在这种情况下是在 Windows 机器上。如果我们不小心使用相同的目录,我们就有无限复制和备份文件的风险。在下面的代码片段中,我们将看一下Message结构本身和backup函数,这是应用程序的这一部分的核心:

type Message struct {
  Hash string "json:hash"
  Action string "json:action"
  Location string "json:location"
  Name string "json:name"  
  Version int "json:version"
}

func backup (location string, name string, version int) {

  newFileName := backupFolder + name + "." + 
    strconv.FormatInt(int64(version),10)
  fmt.Println(newFileName)
  org,_ := os.Open(location)
  defer org.Close()
  cpy,_ := os.Create(newFileName)
  defer cpy.Close()
  io.Copy(cpy,org)
}

这是我们的基本文件操作。Go 语言没有一步复制函数;相反,您需要创建一个文件,然后使用io.Copy将另一个文件的内容复制到其中:

func listen(conn net.Conn) {
  for {

      messBuff := make([]byte,1024)
    n, err := conn.Read(messBuff)
    if err != nil {

    }

    resultMessage := Message{}
    json.Unmarshal(messBuff[:n],&resultMessage)

    if resultMessage.Action == "MODIFY" {
      fmt.Println("Back up file",resultMessage.Location)
      newVersion := resultMessage.Version + 1
      backup(resultMessage.Location,resultMessage.Name,newVersion)
    }

  }

}

这段代码几乎与我们的聊天客户端的listen()函数一字不差,只是我们获取了流式 JSON 数据的内容,对其进行解组,并将其转换为Message{}结构,然后是File{}结构。最后,让我们看一下main函数和 TCP 初始化:

func main() {
  endBackup := make(chan bool)
  conn, err := net.Dial("tcp","127.0.0.1:9000")
  if err != nil {
    fmt.Println("Could not connect to File Listener!")
  }
  go listen(conn)

  <- endBackup
}

设计我们的 Web 界面

为了与文件系统交互,我们需要一个接口,显示所有当前文件的版本、最后修改时间和更改的警报,并允许拖放创建/替换文件。

获取文件列表将很简单,因为我们将直接从我们的file_manager Couchbase 存储桶中获取它们。更改将通过我们的文件管理器进程通过 TCP 发送,这将触发 API 调用,为我们的 Web 用户显示文件的更改。

我们在这里使用的一些方法是备份过程中使用的方法的副本,并且肯定可以从一些整合中受益;但以下是 Web 服务器的代码,它允许上传并显示更改的通知:

package main

import
(
  "net"
  "net/http"
  "html/template"
  "log"
  "io"
  "os"
  "io/ioutil"
  "github.com/couchbaselabs/go-couchbase"
  "time"  
  "fmt"
  "crypto/md5"
  "encoding/hex"
  "encoding/json"
)

type File struct {
  Hash string "json:hash"
  Name string "json:file_name"
  Created int64 "json:created"
  CreatedUser  int "json:created_user"
  LastModified int64 "json:last_modified"
  LastModifiedUser int "json:last_modified_user"
  Revisions int "json:revisions"
  Version int "json:version"
}

例如,这是我们在文件监听器和备份过程中使用的相同的File结构:

type Page struct {
  Title string
  Files map[string] File
}

我们的Page结构表示通用的 Web 数据,这些数据被转换为我们网页模板的相应变量:

type ItemWrapper struct {

  Items []File
  CurrentTime int64
  PreviousTime int64

}

type Message struct {
  Hash string "json:hash"
  Action string "json:action"
  Location string "json:location"
  Name string "json:name"  
  Version int "json:version"
}

我们的md5哈希方法在这个应用程序中也是一样的。 值得注意的是,我们从文件监听器接收到信号时,会派生一个lastChecked变量,该变量是 Unix 风格的时间戳。 我们使用这个变量来与客户端文件更改进行比较,以便知道是否在 Web 上提醒用户。 现在让我们来看看 Web 界面的updateFile函数:

func updateFile(name string, bucket *couchbase.Bucket) {
  thisFile := File{}
  hashString := generateHash(name)

  thisFile.Hash = hashString
  thisFile.Name = name
  thisFile.Created = time.Now().Unix()
  thisFile.CreatedUser = 0
  thisFile.LastModified = time.Now().Unix()
  thisFile.LastModifiedUser = 0
  thisFile.Revisions = 0
  thisFile.Version = 1

  Files[hashString] = thisFile

  checkFile := File{}
  err := bucket.Get(hashString,&checkFile)
  if err != nil {
    fmt.Println("New File Added",name)
    bucket.Set(hashString,0,thisFile)
  }else {
    Files[hashString] = checkFile
  }
}

这与我们备份过程中的函数相同,只是不是创建一个重复的文件,而是简单地覆盖我们的内部File结构,以便在下次调用/api时表示其更新的LastModified值。 和我们上一个例子一样,让我们来看看listen()函数:

func listen(conn net.Conn) {
  for {

      messBuff := make([]byte,1024)
    n, err := conn.Read(messBuff)
    if err != nil {

    }
    message := string(messBuff[:n])
    message = message[0:]

    resultMessage := Message{}
    json.Unmarshal(messBuff[:n],&resultMessage)

    updateHash := resultMessage.Hash
    tmp := Files[updateHash]
    tmp.LastModified = time.Now().Unix()
    Files[updateHash] = tmp
  }

}

在这里,我们读取消息,解组并将其设置为其哈希映射的键。 如果文件不存在,这将创建一个文件,如果存在,则更新我们当前的文件。 接下来,我们将看看main()函数,它设置了我们的应用程序和 Web 服务器:

func main() {
  lastChecked := time.Now().Unix()
  Files = make(map[string]File)
  fileChange = make(chan File)
  couchbaseClient, err := couchbase.Connect("http://localhost:8091/")
    if err != nil {
      fmt.Println("Error connecting to Couchbase", err)
    }
  pool, err := couchbaseClient.GetPool("default")
    if err != nil {
      fmt.Println("Error getting pool",err)
    }
  bucket, err := pool.GetBucket("file_manager")
    if err != nil {
      fmt.Println("Error getting bucket",err)
    }    

  files, _ := ioutil.ReadDir(listenFolder)
  for _, file := range files {
    updateFile(file.Name(),bucket)
  }

  conn, err := net.Dial("tcp","127.0.0.1:9000")
  if err != nil {
    fmt.Println("Could not connect to File Listener!")
  }
  go listen(conn)

  http.HandleFunc("/api", func(w http.ResponseWriter, r 
    *http.Request) {
    apiOutput := ItemWrapper{}
    apiOutput.PreviousTime = lastChecked
    lastChecked = time.Now().Unix()
    apiOutput.CurrentTime = lastChecked

    for i:= range Files {
      apiOutput.Items = append(apiOutput.Items,Files[i])
    }
    output,_ := json.Marshal(apiOutput)
    fmt.Fprintln(w,string(output))

  })
  http.HandleFunc("/", func(w http.ResponseWriter, r 
    *http.Request) {
    output := Page{Files:Files,Title:"File Manager"}
    tmp, _ := template.ParseFiles("ch8_html.html")
    tmp.Execute(w, output)
  })
  http.HandleFunc("/upload", func(w http.ResponseWriter, r 
    *http.Request) {
    err := r.ParseMultipartForm(10000000)
    if err != nil {
      return
    }
    form := r.MultipartForm

    files := form.File["file"]
    for i, _ := range files {
      newFileName := listenFolder + files[i].Filename
      org,_:= files[i].Open()
      defer org.Close()
      cpy,_ := os.Create(newFileName)
      defer cpy.Close()
      io.Copy(cpy,org)
    }
  })  

  log.Fatal(http.ListenAndServe(":8080",nil))

}

在我们的 Web 服务器组件中,main()负责设置与文件监听器和 Couchbase 的连接,并创建一个 Web 服务器(带有相关路由)。

如果您通过将文件拖放到拖放文件到此处上传框中上传文件,几秒钟后,您将看到文件在 Web 界面中被标记为已更改,如下面的屏幕截图所示:

设计我们的 Web 界面

我们没有包括 Web 界面客户端的代码; 但关键点是通过 API 检索。 我们使用了一个名为Dropzone.js的 JavaScript 库,它允许拖放上传,并使用 jQuery 进行 API 访问。

恢复文件的历史记录-命令行

我们想要添加到这个应用程序套件中的最后一个组件是一个命令行文件修订过程。 我们可以将这个过程保持相当简单,因为我们知道文件的位置,备份的位置以及如何用后者替换前者。 与以前一样,我们有一些全局配置变量和我们的generateHash()函数的复制:

var liveFolder = "/mnt/sharedir "
var backupFolder = "/mnt/backup

func generateHash(name string) string {

  hash := md5.New()
  io.WriteString(hash,name)
  hashString := hex.EncodeToString(hash.Sum(nil))

  return hashString
}

func main() {
  revision := flag.Int("r",0,"Number of versions back")
  fileName := flag.String("f","","File Name")
  flag.Parse()

  if *fileName == "" {

    fmt.Println("Provide a file name to use!")
    os.Exit(0)
  }

  couchbaseClient, err := couchbase.Connect("http://localhost:8091/")
    if err != nil {
      fmt.Println("Error connecting to Couchbase", err)
    }
  pool, err := couchbaseClient.GetPool("default")
    if err != nil {
      fmt.Println("Error getting pool",err)
    }
  bucket, err := pool.GetBucket("file_manager")
    if err != nil {
      fmt.Println("Error getting bucket",err)
    }  

  hashString := generateHash(*fileName)
  checkFile := File{}    
  bucketerr := bucket.Get(hashString,&checkFile)
  if bucketerr != nil {

  }else {
    backupLocation := backupFolder + checkFile.Name + "." + strconv.FormatInt(int64(checkFile.Version-*revision),10)
    newLocation := liveFolder + checkFile.Name
    fmt.Println(backupLocation)
    org,_ := os.Open(backupLocation)
      defer org.Close()
    cpy,_ := os.Create(newLocation)
      defer cpy.Close()
    io.Copy(cpy,org)
    fmt.Println("Revision complete")
  }

}

这个应用程序最多接受两个参数:

  • -f:这表示文件名

  • -r:这表示要恢复的版本数

请注意,这本身会创建一个新版本,因此需要将-2 变为-3,然后为-6,以此类推,以便连续递归备份。

例如,如果您希望将example.txt还原为三个版本之前,您可以使用以下命令:

fileversion -f example.txt -r -3

在守护程序和服务中使用 Go

关于运行这部分应用程序的一点说明——理想情况下,您希望将这些应用程序保持为活动的、可重启的服务,而不是独立的、手动执行的后台进程。 这样做将允许您保持应用程序的活动状态,并从外部或服务器进程管理其生命周期。

这种应用程序套件最适合在 Linux 框(或框)上,并使用像 daemontools 或 Ubuntu 内置的 Upstart 服务这样的守护程序管理器进行管理。 这样做的原因是,任何长期的停机时间都可能导致数据丢失和不一致。 即使在内存中存储文件数据细节(Couchbase 和 memcached)也会对数据丢失构成漏洞。

检查我们服务器的健康状况

有许多种方法可以检查一般服务器的健康状况,我们在这里处于一个良好的位置,而无需构建我们自己的系统,这在很大程度上要归功于 Couchbase 本身。 如果您访问 Couchbase Web 管理界面,在您的集群、服务器和存储桶视图下,单击任何一个都会显示一些实时统计信息,如下面的屏幕截图所示:

检查我们服务器的健康状况

如果您希望将这些区域包含在应用程序中,以使您的日志记录和错误处理更全面,这些区域也可以通过 REST 访问。

总结

我们现在拥有一个从头到尾高度并发的应用程序套件,涉及多个第三方库,并通过记录和灾难恢复来减轻潜在的故障。

到这一点,你应该没有问题构建一个以 Go 语言为重点,专注于维护并发性、可靠性和性能的复杂软件包。我们的文件监控应用程序可以很容易地修改以执行更多操作,使用替代服务,或者扩展到一个强大的分布式环境。

在下一章中,我们将更仔细地测试我们的并发性和吞吐量,探讨 panic 和 recover 的价值,以及在 Go 语言中以安全并发的方式处理记录重要信息和错误。

第九章:Go 中的日志记录和测试并发

在这个阶段,你应该对 Go 中的并发感到相当舒适,并且应该能够轻松地实现基本的 goroutines 和并发机制。

我们还涉足了一些分布式并发模式,这些模式不仅通过应用程序本身管理,还通过第三方数据存储管理网络应用程序的并发操作。

在本书的前面,我们研究了一些初步和基本的测试和日志记录。我们研究了 Go 内部测试工具的简单实现,使用 race 工具进行了一些竞争条件测试,并进行了一些基本的负载和性能测试。

然而,这里还有更多需要考虑的地方,特别是与潜在的并发代码黑洞有关——我们已经看到了在 goroutines 中运行的非阻塞代码之间出现了意外行为。

在本章中,我们将进一步研究负载和性能测试,在 Go 中进行单元测试,并尝试更高级的测试和调试。我们还将探讨日志记录和报告的最佳实践,并更仔细地研究 panic 和 recover。

最后,我们将看到所有这些东西不仅可以应用于我们独立的并发代码,还可以应用于分布式系统。

在这个过程中,我们将介绍一些不同风格的单元测试框架。

处理错误和日志记录

虽然我们没有明确提到,但 Go 中错误处理的成语性质使得调试自然更容易。

在 Go 代码中,任何大规模函数的一个良好实践是将错误作为返回值返回——对于许多较小的方法和函数来说,这可能是繁琐和不必要的。但是,每当我们构建涉及许多移动部件的东西时,这都是需要考虑的问题。

例如,考虑一个简单的Add()函数:

func Add(x int, y int) int {
  return x + y
}

如果我们希望遵循“始终返回错误值”的一般规则,我们可能会诱使将这个函数转换为以下代码:

package main
import
(
  "fmt"
  "errors"
  "reflect"
)

func Add(x int, y int) (int, error) {
  var err error

  xType := reflect.TypeOf(x).Kind()
  yType := reflect.TypeOf(y).Kind()
  if xType != reflect.Int || yType != reflect.Int {
    fmt.Println(xType)
    err = errors.New("Incorrect type for integer a or b!")
  }
  return x + y, err
}

func main() {

  sum,err := Add("foo",2)
  if err != nil {
    fmt.Println("Error",err)
  }
  fmt.Println(sum)
}

你可以看到我们(非常糟糕地)在重新发明轮子。Go 的内部编译器在我们看到它之前就已经杀死了它。因此,我们应该专注于编译器可能无法捕捉到的事情,这可能会导致我们的应用程序出现意外行为,特别是在涉及通道和监听器时。

要点是让 Go 处理编译器会处理的错误,除非你希望自己处理异常,而不引起编译器特定的困扰。在真正的多态性缺失的情况下,这通常很麻烦,并且需要调用接口,如下面的代码所示:

type Alpha struct {

}

type Numeric struct {

}

你可能还记得,创建接口和结构允许我们根据类型分别路由我们的函数调用。这在下面的代码中显示:

func (a Alpha) Add(x string, y string) (string, error) {
  var err error
  xType := reflect.TypeOf(x).Kind()
  yType := reflect.TypeOf(y).Kind()
  if xType != reflect.String || yType != reflect.String {
    err = errors.New("Incorrect type for strings a or b!")
  }
  finalString := x + y
  return finalString, err
}

func (n Numeric) Add(x int, y int) (int, error) {
  var err error

  xType := reflect.TypeOf(x).Kind()
  yType := reflect.TypeOf(y).Kind()
  if xType != reflect.Int || yType != reflect.Int {
    err = errors.New("Incorrect type for integer a or b!")
  }
  return x + y, err
}
func main() {
  n1 := Numeric{}
  a1 := Alpha{}
  z,err := n1.Add(5,2)	
  if err != nil {
    log.Println("Error",err)
  }
  log.Println(z)

  y,err := a1.Add("super","lative")
  if err != nil {
    log.Println("Error",err)
  }
  log.Println(y)
}

这仍然报告了最终会被编译器捕获的内容,但也处理了编译器无法看到的某种错误:外部输入。我们通过接口路由我们的Add()函数,这通过更明确地指导结构的参数和方法提供了一些额外的标准化。

例如,如果我们为我们的值输入用户输入并需要评估该输入的类型,我们可能希望以这种方式报告错误,因为编译器永远不会知道我们的代码可以接受错误的类型。

打破 goroutine 日志

保持关注并发和隔离的消息处理和日志记录的一种方法是用自己的日志记录器束缚我们的 goroutine,这将使一切与其他 goroutines 分开。

在这一点上,我们应该注意到这可能不会扩展——也就是说,创建成千上万个拥有自己日志记录器的 goroutines 可能会变得昂贵,但在最小规模下,这是完全可行和可管理的。

为了单独进行这种日志记录,我们将希望将一个Logger实例绑定到每个 goroutine,如下面的代码所示:

package main

import
(
  "log"
  "os"
  "strconv"
)

const totalGoroutines = 5

type Worker struct {
  wLog *log.Logger
  Name string
}

我们将创建一个通用的Worker结构,讽刺的是它在这个示例中不会做任何工作(至少在这个示例中不会),只是保存它自己的Logger对象。代码如下:

func main() {
  done := make(chan bool)

  for i:=0; i< totalGoroutines; i++ {

    myWorker := Worker{}
    myWorker.Name = "Goroutine " + strconv.FormatInt(int64(i),10) + ""
    myWorker.wLog = log.New(os.Stderr, myWorker.Name, 1)
    go func(w *Worker) {

        w.wLog.Print("Hmm")

        done <- true
    }(&myWorker)
  }

每个 goroutine 通过Worker都负责自己的日志例程。虽然我们直接将输出发送到控制台,但这在很大程度上是不必要的。但是,如果我们想将每个输出到自己的日志文件中,我们可以使用以下代码来实现:

  log.Println("...")

  <- done
}

使用 LiteIDE 进行更丰富和更容易的调试

在本书的前几章中,我们简要讨论了 IDE,并举了一些与 Go 紧密集成的 IDE 的例子。

在我们审查日志记录和调试时,有一个 IDE 我们之前并没有特别提到,主要是因为它是为一小部分语言——即 Go 和 Lua 而设计的。然而,如果你最终主要或专门使用 Go,你会发现它绝对是必不可少的,特别是因为它与调试、日志记录和反馈功能相关。

LiteIDE跨平台,在 OS X、Linux 和 Windows 上运行良好。它以 GUI 形式提供的调试和测试优势是无价的,特别是如果你已经非常熟悉 Go。最后一部分很重要,因为开发人员在深入使用简化编程过程的工具之前,通常会从“学习艰难的方式”中受益最多。在被呈现出漂亮的图标、菜单和弹出窗口之前,了解某件事情的工作原理或不工作原理是几乎总是更好的。话虽如此,LiteIDE 是一款非常棒的免费工具,适用于高级 Go 程序员。

通过从 Go 中形式化许多工具和错误报告,我们可以通过在屏幕上看到它们来轻松地解决一些更棘手的调试任务。

LiteIDE 还带来了上下文感知、代码完成、go fmt等功能到我们的工作空间。你可以想象一下,专门针对 Go 调优的 IDE 如何帮助你保持代码的清晰和无错。参考以下截图:

使用 LiteIDE 进行更丰富和更容易的调试

LiteIDE 在 Windows 上显示输出和自动代码完成

提示

LiteIDE 适用于 Linux、OS X 和 Windows,可以在code.google.com/p/liteide/找到。

将错误发送到屏幕

在本书中,我们通常使用fmt.Println语法处理软错误、警告和一般消息,通过向控制台发送消息。

虽然这对于演示目的来说快速简单,但最好使用log包来处理这些事情。这是因为我们在log包中有更多的灵活性,可以决定消息的最终目的地。

就我们目前的目的而言,这些消息都是虚幻的。将简单的Println语句切换到Logger非常简单。

我们之前使用以下代码来传递消息:

fmt.Println("Horrible error:",err)

你会注意到对Logger的更改非常相似:

myLogger.Println("Horrible error:", err)

这对于 goroutines 特别有用,因为我们可以创建一个全局的Logger接口,可以在任何地方访问,或者将记录器的引用传递给单独的 goroutines,并确保我们的日志记录是并发处理的。

在整个应用程序中使用单个记录器的一个考虑是,我们可能希望单独记录每个过程,以便更清晰地进行分析。我们稍后会在本章中更详细地讨论这一点。

要复制将消息传递给命令行,我们可以简单地使用以下代码:

log.Print("Message")

默认情况下,它的io.writerstdout——回想一下,我们可以将任何io.writer设置为日志的目的地。

然而,我们还希望能够快速轻松地记录到文件中。毕竟,任何在后台运行或作为守护程序运行的应用程序都需要有一些更持久的东西。

将错误记录到文件

有很多种方法可以将错误发送到日志文件中——毕竟,我们可以使用内置的文件操作 OS 调用来处理这个问题。事实上,这就是许多人所做的。

然而,log包提供了一些标准化和潜在的命令行反馈与错误、警告和一般信息的更持久存储之间的共生关系。

这样做的最简单方法是使用os.OpenFile()方法(而不是os.Open()方法)打开一个文件,并将该引用传递给我们的日志实例化作为io.Writer

让我们在下面的示例中看看这样的功能:

package main

import (
  "log"
  "os"
)

func main() {
  logFile, _ := os.OpenFile("/var/www/test.log", os.O_RDWR, 0755)

  log.SetOutput(logFile)
  log.Println("Sending an entry to log!")

  logFile.Close()
}

在我们之前的 goroutine 包中,我们可以为每个 goroutine 分配一个自己的文件,并将文件引用作为 io Writer 传递(我们需要对目标文件夹具有写访问权限)。代码如下:

  for i:=0; i< totalGoroutines; i++ {

    myWorker := Worker{}
    myWorker.Name = "Goroutine " + strconv.FormatInt(int64(i),10) 
      + ""
    myWorker.FileName = "/var/www/"+strconv.FormatInt(int64(i),10) 
      + ".log"
    tmpFile,_ :=   os.OpenFile(myWorker.FileName, os.O_CREATE, 
      0755)
    myWorker.File = tmpFile
    myWorker.wLog = log.New(myWorker.File, myWorker.Name, 1)
    go func(w *Worker) {

        w.wLog.Print("Hmm")

        done <- true
    }(&myWorker)
  }

将错误记录到内存

当我们谈论将错误记录到内存时,我们实际上是在谈论数据存储,尽管除了易失性和有限的资源之外,没有理由拒绝将日志记录到内存作为一种可行的选择。

虽然我们将在下一节中看一种更直接的处理网络日志记录的方法,但让我们在一个并发的分布式系统中划分各种应用程序错误而不费太多力气。这个想法是使用共享内存(比如 Memcached 或共享内存数据存储)来传递我们的日志消息。

虽然这些技术上仍然是日志文件(大多数数据存储将单独的记录或文档保存为 JSON 编码的硬文件),但与传统日志记录有着明显不同的感觉。

回到上一章的老朋友 CouchDB,将我们的日志消息传递到中央服务器几乎可以毫不费力地完成,这样我们就可以跟踪不仅是单个机器,还有它们各自的并发 goroutines。代码如下:

package main

import
(
  "github.com/couchbaselabs/go-couchbase"
  "io"
  "time"
  "fmt"
  "os"
  "net/http"
  "crypto/md5"
  "encoding/hex"
)
type LogItem struct {
  ServerID string "json:server_id"
  Goroutine int "json:goroutine"
  Timestamp time.Time "json:time"
  Message string "json:message"
  Page string "json:page"
}

这将最终成为我们将发送到 Couchbase 服务器的 JSON 文档。我们将使用PageTimestampServerID作为组合的哈希键,以允许对同一文档的多个并发请求在不同服务器上分别记录日志,如下面的代码所示:

var currentGoroutine int

func (li LogItem) logRequest(bucket *couchbase.Bucket) {

  hash := md5.New()
  io.WriteString(hash,li.ServerID+li.Page+li.Timestamp.Format("Jan 
    1, 2014 12:00am"))
  hashString := hex.EncodeToString(hash.Sum(nil))
  bucket.Set(hashString,0,li)
  currentGoroutine = 0
}

当我们将currentGoroutine重置为0时,我们使用了一个有意的竞争条件,允许 goroutines 在并发执行时通过数字 ID 报告自己。这使我们能够调试一个看起来正常工作的应用程序,直到它调用某种形式的并发架构。由于 goroutines 将通过 ID 自我识别,这使我们能够更加精细地路由我们的消息。

通过为 goroutine IDtimestampserverID指定不同的日志位置,可以快速从日志文件中提取任何并发问题。使用以下代码完成:

func main() {
  hostName, _ := os.Hostname()
  currentGoroutine = 0

  logClient, err := couchbase.Connect("http://localhost:8091/")
    if err != nil {
      fmt.Println("Error connecting to logging client", err)
    }
  logPool, err := logClient.GetPool("default")
    if err != nil {
      fmt.Println("Error getting pool",err)
    }
  logBucket, err := logPool.GetBucket("logs")
    if err != nil {
      fmt.Println("Error getting bucket",err)
    }
  http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    request := LogItem{}
    request.Goroutine = currentGoroutine
    request.ServerID = hostName
    request.Timestamp = time.Now()
    request.Message = "Request to " + r.URL.Path
    request.Page = r.URL.Path
    go request.logRequest(logBucket)

  })

  http.ListenAndServe(":8080",nil)

}

使用 log4go 包进行强大的日志记录

与 Go 中的大多数事物一样,在核心页面中有令人满意和可扩展的东西,可以通过第三方(Go 的精彩日志包真正地与log4go结合在一起。

使用 log4go 极大地简化了文件记录、控制台记录和通过 TCP/UDP 记录的过程。

提示

有关 log4go 的更多信息,请访问code.google.com/p/log4go/

每个log4go Logger接口的实例都可以通过 XML 配置文件进行配置,并且可以对其应用过滤器以指示消息的去向。让我们看一个简单的 HTTP 服务器,以展示如何将特定的日志定向到位置,如下面的代码所示:

package main

import (
  "code.google.com/p/log4go"
  "net/http"
  "fmt"
  "github.com/gorilla/mux"
)
var errorLog log4go.Logger
var errorLogWriter log4go.FileLogWriter

var accessLog log4go.Logger
var accessLogWriter *log4go.FileLogWriter

var screenLog log4go.Logger

var networkLog log4go.Logger

在前面的代码中,我们创建了四个不同的日志对象——一个将错误写入日志文件,一个将访问(页面请求)写入到一个单独的文件,一个直接发送到控制台(用于重要通知),一个将日志消息传递到网络。

最后两个显然不需要FileLogWriter,尽管完全可以使用共享驱动器来复制网络记录,如果我们可以减轻并发访问的问题,如下面的代码所示:

func init() {
  fmt.Println("Web Server Starting")
}

func pageHandler(w http.ResponseWriter, r *http.Request) {
  pageFoundMessage := "Page found: " + r.URL.Path
  accessLog.Info(pageFoundMessage)
  networkLog.Info(pageFoundMessage)
  w.Write([]byte("Valid page"))
}

任何对有效页面的请求都会发送消息到web-access.log文件accessLog

func notFound(w http.ResponseWriter, r *http.Request) {
  pageNotFoundMessage := "Page not found / 404: " + r.URL.Path
  errorLog.Info(pageNotFoundMessage)
  w.Write([]byte("Page not found"))
}

accessLog文件一样,我们将接受任何404 /页面未找到的请求,并直接将其路由到notFound()方法,该方法保存了一个相当通用的错误消息以及无效的/丢失的 URL 请求。让我们看看在下面的代码中我们将如何处理非常重要的错误和消息:

func restricted(w http.ResponseWriter, r *http.Request) {
  message := "Restricted directory access attempt!"
  errorLog.Info(message)
  accessLog.Info(message)
  screenLog.Info(message)
  networkLog.Info(message)
  w.Write([]byte("Restricted!"))

}

restricted()函数和相应的screenLog表示我们认为是关键的消息,并且值得不仅发送到错误和访问日志,而且还发送到屏幕并作为networkLog项目传递。换句话说,这是一个非常重要的消息,每个人都会收到。

在这种情况下,我们正在检测尝试访问我们的.git文件夹,这是一个相当常见的意外安全漏洞,人们已知在自动文件上传和更新中犯过这种错误。由于我们在文件中表示明文密码,并且可能将其暴露给外部世界,我们将在请求时捕获这些并传递给我们的关键和非关键日志记录机制。

我们也可以将其视为一个更开放的坏请求通知器-值得网络开发人员立即关注。在下面的代码中,我们将开始创建一些日志记录器:

func main() {

  screenLog = make(log4go.Logger)
  screenLog.AddFilter("stdout", log4go.DEBUG, log4go.NewConsoleLogWriter())

  errorLogWriter := log4go.NewFileLogWriter("web-errors.log", 
    false)
    errorLogWriter.SetFormat("%d %t - %M (%S)")
    errorLogWriter.SetRotate(false)
    errorLogWriter.SetRotateSize(0)
    errorLogWriter.SetRotateLines(0)
    errorLogWriter.SetRotateDaily(true)

由于 log4go 提供了许多额外的日志选项,我们可以稍微调整我们的日志轮换和格式,而不必专门使用Sprintf或类似的东西来绘制出来。

这里的选项简单而富有表现力:

  • SetFormat:这允许我们指定我们的单独日志行的外观。

  • SetRotate:这允许根据文件大小和/或log中的行数自动旋转。SetRotateSize()选项设置消息中的字节旋转,SetRotateLines()设置最大的行数SetRotateDaily()函数让我们根据前面函数中的设置在每天创建新的日志文件。这是一个相当常见的日志记录技术,通常手工编码会很繁琐。

我们的日志格式的输出最终看起来像以下一行代码:

04/13/14 10:46 - Page found%!(EXTRA string=/valid) (main.pageHandler:24)

%S部分是源,它为我们提供了调用日志的应用程序部分的行号和方法跟踪:

  errorLog = make(log4go.Logger)
  errorLog.AddFilter("file", log4go.DEBUG, errorLogWriter)

  networkLog = make(log4go.Logger)
  networkLog.AddFilter("network", log4go.DEBUG, log4go.NewSocketLogWriter("tcp", "localhost:3000"))

我们的网络日志通过 TCP 发送 JSON 编码的消息到我们提供的地址。我们将在下一节的代码中展示一个非常简单的处理服务器,将日志消息转换为一个集中的日志文件:

  accessLogWriter = log4go.NewFileLogWriter("web-access.log",false)
    accessLogWriter.SetFormat("%d %t - %M (%S)")
    accessLogWriter.SetRotate(true)
    accessLogWriter.SetRotateSize(0)
    accessLogWriter.SetRotateLines(500)
    accessLogWriter.SetRotateDaily(false)

我们的accessLogWritererrorLogWriter类似,只是它不是每天轮换一次,而是每 500 行轮换一次。这里的想法是访问日志当然会比错误日志更频繁地被访问-希望如此。代码如下:

  accessLog = make(log4go.Logger)
  accessLog.AddFilter("file",log4go.DEBUG,accessLogWriter)

  rtr := mux.NewRouter()
  rtr.HandleFunc("/valid", pageHandler)
  rtr.HandleFunc("/.git/", restricted)
  rtr.NotFoundHandler = http.HandlerFunc(notFound)

在前面的代码中,我们使用了 Gorilla Mux 包进行路由。这使我们更容易访问404处理程序,在基本的直接内置到 Go 中的http包中修改起来不那么简单。代码如下:

  http.Handle("/", rtr)
  http.ListenAndServe(":8080", nil)
}

像这样构建网络日志系统的接收端在 Go 中也非常简单,因为我们构建的只是另一个可以处理 JSON 编码消息的 TCP 客户端。

我们可以通过一个接收服务器来做到这一点,这个接收服务器看起来与我们早期章节中的 TCP 聊天服务器非常相似。代码如下:

package main

import
(
  "net"
  "fmt"
)

type Connection struct {

}

func (c Connection) Listen(l net.Listener) {
  for {
    conn,_ := l.Accept()
    go c.logListen(conn)
  }
}

与我们的聊天服务器一样,我们将我们的监听器绑定到一个Connection结构,如下面的代码所示:

func (c *Connection) logListen(conn net.Conn) {
  for {
    buf := make([]byte, 1024)
    n, _ := conn.Read(buf)
    fmt.Println("Log Message",string(n))
  }
}

在前面的代码中,我们通过 JSON 接收日志消息。在这一点上,我们还没有解析 JSON,但我们已经在早期的章节中展示了如何做到这一点。

发送的任何消息都将被推送到缓冲区中-因此,根据信息的详细程度,扩展缓冲区的大小可能是有意义的。

func main() {
  serverClosed := make(chan bool)

  listener, err := net.Listen("tcp", ":3000")
  if err != nil {
    fmt.Println ("Could not start server!",err)
  }

  Conn := Connection{}

  go Conn.Listen(listener)

  <-serverClosed
}

你可以想象网络日志记录在哪里会很有用,特别是在服务器集群中,你可能有一系列的,比如,Web 服务器,你不想将单独的日志文件合并成一个日志。

恐慌

在讨论捕获错误并记录它们时,我们可能应该考虑 Go 中的panic()recover()功能。

正如前面简要讨论的,panic()recover()作为一种更基本、即时和明确的错误检测方法,比如try/catch/finally甚至 Go 的内置错误返回值约定。按设计,panic()会解开堆栈并导致程序退出,除非调用recover()。这意味着除非你明确地恢复,否则你的应用程序将结束。

那么,除了停止执行之外,这有什么用处呢?毕竟,我们可以捕获错误并通过类似以下代码手动结束应用程序:

package main

import
(
  "fmt"
  "os"
)

func processNumber(un int) {

  if un < 1 || un > 4 {
    fmt.Println("Now you've done it!")
    os.Exit(1)
  }else {
    fmt.Println("Good, you can read simple instructions.")
  }
}

func main() {
  userNum := 0
  fmt.Println("Enter a number between 1 and 4.")
  _,err := fmt.Scanf("%d",&userNum)
    if err != nil {}

  processNumber(userNum)
}

然而,虽然这个函数进行了健全性检查并执行了永久的、不可逆转的应用程序退出,panic()recover()允许我们从特定包和/或方法中反映错误,保存这些错误,然后优雅地恢复。

当我们处理从其他方法调用的方法时,这是非常有用的,这些方法又是从其他方法调用的,依此类推。深度嵌套或递归函数的类型使得很难辨别特定错误,这就是panic()recover()最有优势的地方。你也可以想象这种功能与日志记录的结合有多么好。

恢复

panic()函数本身相当简单,当与recover()defer()配对时,它真正变得有用。

举个例子,一个应用程序从命令行返回有关文件的元信息。应用程序的主要部分将监听用户输入,将其传递到一个打开文件的函数中,然后将该文件引用传递给另一个函数,该函数将获取文件的详细信息。

现在,显然我们可以直接通过过程堆叠错误作为返回元素,或者我们可以在途中发生 panic,恢复回到步骤,然后在底部收集我们的错误以进行日志记录和/或直接报告到控制台。

避免意大利面代码是这种方法与前一种方法相比的一个受欢迎的副作用。以一般意义来考虑(这是伪代码):

func getFileDetails(fileName string) error {
  return err
}

func openFile(fileName string) error {
  details,err := getFileDetails(fileName)
  return err
}

func main() {

  file,err := openFile(fileName)

}

有一个错误时,完全可以以这种方式处理我们的应用程序。然而,当每个单独的函数都有一个或多个失败点时,我们将需要更多的返回值以及一种将它们全部整合成单个整体错误消息或多个消息的方法。检查以下代码:

package main

import
(
  "os"
  "fmt"
  "strconv"
)

func gatherPanics() {
  if rec := recover(); rec != nil {
    fmt.Println("Critical Error:", rec)
  }
}

这是我们的一般恢复函数,在我们希望捕获任何 panic 之前调用每个方法。让我们看一个推断文件详细信息的函数:

func getFileDetails(fileName string) {
  defer gatherPanics()
  finfo,err := os.Stat(fileName)
  if err != nil {
    panic("Cannot access file")
  }else {
    fmt.Println("Size: ", strconv.FormatInt(finfo.Size(),10))
  }
}

func openFile(fileName string) {
  defer gatherPanics()
  if _, err := os.Stat(fileName); err != nil {
    panic("File does not exist")
  }

}

前面代码中的两个函数仅仅是尝试打开一个文件并在文件不存在时发生 panic。第二个方法getFileDetails()被从main()函数中调用,这样它将始终执行,而不管openFile()中是否有阻塞错误。

在现实世界中,我们经常会开发应用程序,其中非致命错误只会导致应用程序的部分功能停止工作,但不会导致整个应用程序崩溃。检查以下代码:

func main() {
  var fileName string
  fmt.Print("Enter filename>")
  _,err := fmt.Scanf("%s",&fileName)
  if err != nil {}
  fmt.Println("Getting info for",fileName)

  openFile(fileName)
  getFileDetails(fileName)

}

如果我们从gatherPanics()方法中删除recover()代码,那么如果/当文件不存在,应用程序将崩溃。

这可能看起来很理想,但想象一下一个用户选择了一个不存在的文件作为他们没有权限查看的目录。当他们解决了第一个问题时,他们将被呈现第二个问题,而不是一次看到所有潜在的问题。

从用户体验的角度来看,表达错误的价值无法被过分强调。通过这种方法,收集和呈现表达性错误变得更加容易——即使try/catch/finally也要求我们(作为开发人员)在 catch 子句中明确地处理返回的错误。

记录我们的 panic

在前面的代码中,我们可以很简单地集成一个日志记录机制,除了捕获我们的 panic。

关于日志记录,我们还没有讨论的一个考虑是何时记录。正如我们之前的例子所说明的,有时我们可能遇到应该记录但可能会被未来用户操作所缓解的问题。因此,我们可以选择立即记录错误或将其保存到执行结束或更大的函数结束时再记录。

立即记录日志的主要好处是我们不容易受到实际崩溃的影响,从而无法保存日志。举个例子:

type LogItem struct {
  Message string
  Function string
}

var Logs []LogItem

我们使用以下代码创建了一个日志struct和一个LogItems的切片:

func SaveLogs() {
  logFile := log4go.NewFileLogWriter("errors.log",false)
    logFile.SetFormat("%d %t - %M (%S)")
    logFile.SetRotate(true)
    logFile.SetRotateSize(0)
    logFile.SetRotateLines(500)
    logFile.SetRotateDaily(false)

  errorLog := make(log4go.Logger)
  errorLog.AddFilter("file",log4go.DEBUG,logFile)
  for i:= range Logs {
    errorLog.Info(Logs[i].Message + " in " + Logs[i].Function)
  }

}

这里,我们捕获的所有LogItems将被转换为日志文件中的一系列好的行项目。然而,如下代码所示,存在问题:

func registerError(block chan bool) {

  Log := LogItem{ Message:"An Error Has Occurred!", Function: "registerError()"}
  Logs = append(Logs,Log)
  block <- true
}

在 goroutine 中执行此函数是非阻塞的,并允许主线程的执行继续。问题出在 goroutine 之后运行的以下代码,导致我们根本没有记录任何内容:

func separateFunction() {
  panic("Application quitting!")
}

无论是手动调用还是由二进制文件本身调用,应用程序过早退出都会导致我们的日志文件无法写入,因为该方法被延迟到main()方法结束。代码如下:

func main() {
  block := make(chan bool)
  defer SaveLogs()
  go func(block chan bool) {

    registerError(block)

  }(block)

  separateFunction()

}

然而,这里的权衡是性能。如果我们每次想要记录日志时执行文件操作,就可能在应用程序中引入瓶颈。在前面的代码中,错误是通过 goroutine 发送的,但在阻塞代码中写入——如果我们直接将日志写入registerError()中,可能会减慢我们最终应用程序的速度。

如前所述,缓解这些问题并允许应用程序仍然保存所有日志条目的一个机会是利用内存日志或网络日志。

捕获并发代码的堆栈跟踪

在早期的 Go 版本中,从源代码正确执行堆栈跟踪是一项艰巨的任务,这体现了用户在 Go 语言早期对一般错误处理的许多抱怨和担忧。

尽管 Go 团队一直对正确的方法保持警惕(就像他们对其他一些关键语言特性如泛型的处理一样),但随着语言的发展,堆栈跟踪和堆栈信息已经有所调整。

使用 runtime 包进行细粒度堆栈跟踪

为了直接捕获堆栈跟踪,我们可以从内置的 runtime 包中获取一些有用的信息。

具体来说,Go 语言提供了一些工具,可以帮助我们了解 goroutine 的调用和/或断点。以下是 runtime 包中的函数:

  • runtime.Caller(): 返回 goroutine 的父函数的信息

  • runtime.Stack(): 为堆栈跟踪中的数据分配一个缓冲区,然后填充该缓冲区

  • runtime.NumGoroutine(): 返回当前打开的 goroutine 的总数

我们可以利用前面提到的三种工具来更好地描述任何给定 goroutine 的内部工作和相关错误。

使用以下代码,我们将生成一些随机的 goroutine 执行随机的操作,并记录不仅 goroutine 的日志消息,还有堆栈跟踪和 goroutine 的调用者:

package main

import
(
  "os"
  "fmt"
  "runtime"
  "strconv"
  "code.google.com/p/log4go"
)

type LogItem struct {
  Message string
}

var LogItems []LogItem

func saveLogs() {
  logFile := log4go.NewFileLogWriter("stack.log", false)
    logFile.SetFormat("%d %t - %M (%S)")
    logFile.SetRotate(false)
    logFile.SetRotateSize(0)
    logFile.SetRotateLines(0)
    logFile.SetRotateDaily(true)

  logStack := make(log4go.Logger)
  logStack.AddFilter("file", log4go.DEBUG, logFile)
  for i := range LogItems {
    fmt.Println(LogItems[i].Message)
    logStack.Info(LogItems[i].Message)
  }
}

saveLogs()函数只是将我们的LogItems映射到文件中,就像我们在本章前面做的那样。接下来,我们将看一下提供有关我们 goroutines 详细信息的函数:

func goDetails(done chan bool) {
  i := 0
  for {
    var message string
    stackBuf := make([]byte,1024)
    stack := runtime.Stack(stackBuf, false)
    stack++
    _, callerFile, callerLine, ok := runtime.Caller(0)
    message = "Goroutine from " + string(callerLine) + "" + 
      string(callerFile) + " stack:" + 	string(stackBuf)
    openGoroutines := runtime.NumGoroutine()

    if (ok == true) {
      message = message + callerFile
    }

    message = message + strconv.FormatInt(int64(openGoroutines),10) + " goroutines 
        active"

    li := LogItem{ Message: message}

    LogItems = append(LogItems,li)
    if i == 20 {
      done <- true
      break
    }

    i++
  }
}

这是我们收集有关 goroutine 的更多细节的地方。runtime.Caller()函数提供了一些返回值:指针、调用者的文件名、调用者的行号。最后一个返回值指示是否找到了调用者。

如前所述,runtime.NumGoroutine()给出了尚未关闭的现有 goroutine 的数量。

然后,在runtime.Stack(stackBuf, false)中,我们用堆栈跟踪填充我们的缓冲区。请注意,我们没有将这个字节数组修剪到指定长度。

所有这三个都被传递到 LogItem.Message 中以供以后使用。让我们看看 main() 函数中的设置:

func main() {
  done := make(chan bool)

  go goDetails(done)
  for i:= 0; i < 10; i++ {
    go goDetails(done)
  }

  for {
    select {
      case d := <-done:
        if d == true {
          saveLogs()
          os.Exit(1)
        }
    }
  }

}

最后,我们循环遍历一些正在执行循环的 goroutines,并在完成后退出。

当我们检查日志文件时,我们得到的关于 goroutines 的详细信息比以前要多得多,如下面的代码所示:

04/16/14 23:25 - Goroutine from + /var/log/go/ch9_11_stacktrace.goch9_11_stacktrace.go stack:goroutine 4 [running]:
main.goDetails(0xc08400b300)
  /var/log/go/ch9_11_stacktrace.goch9_11_stacktrace.go:41 +0x8e
created by main.main
  /var/log/go/ch9_11_stacktrace.goch9_11_stacktrace.go:69 +0x4c

  /var/log/go/ch9_11_stacktrace.goch9_11_stacktrace.go14 goroutines active (main.saveLogs:31)

提示

有关运行时包的更多信息,请访问 golang.org/pkg/runtime/

总结

调试、测试和记录并发代码可能特别麻烦,尤其是当并发的 goroutines 以一种看似无声的方式失败或根本无法执行时。

我们看了各种记录方法,从文件到控制台到内存到网络记录,并研究了并发应用程序组件如何适应这些不同的实现。

到目前为止,您应该已经可以轻松自然地创建健壮且表达力强的日志,这些日志会自动轮换,不会产生延迟或瓶颈,并有助于调试您的应用程序。

您应该对运行时包的基础知识感到满意。随着我们在下一章中深入挖掘,我们将深入探讨测试包、更明确地控制 goroutines 和单元测试。

除了进一步检查测试和运行时包之外,在我们的最后一章中,我们还将涉及更高级的并发主题,以及审查一些与在 Go 语言中编程相关的总体最佳实践。

第十章:高级并发和最佳实践

一旦您熟悉了 Go 中并发特性的基本和中级用法,您可能会发现您能够使用双向通道和标准并发工具处理大多数开发用例。

在第二章理解并发模型和第三章制定并发策略中,我们不仅看了 Go 的并发模型,还比较了其他语言的并发模型以及分布式模型的工作方式。在本章中,我们将涉及这些内容以及一些关于设计和管理并发应用程序的更高级别概念。

特别是,我们将研究 goroutine 及其相关通道的集中管理——通常情况下,您可能会发现 goroutine 是一种设置并忘记的命题;然而,在某些情况下,我们可能希望更精细地控制通道的状态。

我们还从高层次上看了测试和基准测试,但我们将研究一些更详细和复杂的测试方法。我们还将探讨一些关于 Google App Engine 的入门知识,这将使我们能够使用一些特定的测试工具。

最后,我们将涉及一些 Go 的一般最佳实践,这不仅适用于并发应用程序设计,而且适用于您将来在语言中的工作。

超越基础的通道使用

我们已经讨论了许多不同类型的通道实现——不同类型的通道(接口、函数、结构体和通道),并且涉及了缓冲和非缓冲通道的区别。然而,我们在设计和流程中仍然可以做很多事情。

从设计上讲,Go 希望您保持简单。这对您在使用 Go 时的 90%的工作来说是非常棒的。但是还有其他时候,您需要深入挖掘解决方案,或者需要通过保留开放的 goroutine 进程、通道等资源来节省资源。

在某些时候,您可能希望对 goroutine 的大小和状态进行一些手动控制,以及对正在运行或关闭的 goroutine 进行控制,因此我们将研究如何做到这一点。

同样重要的是,将您的 goroutine 设计与整个应用程序设计协同工作对于单元测试至关重要,这是我们将在本章中涉及的一个主题。

构建工作线程

在本书的前面,我们谈到了并发模式和一些关于工作线程的内容。甚至在上一章中,当我们构建日志系统时,我们也介绍了工作线程的概念。

说实话,“工作线程”是一个相当通用和模糊的概念,不仅在 Go 中,而且在一般的编程和开发中也是如此。在某些语言中,它是一个对象/实例化类,而在其他语言中它是一个并发的执行者。在函数式编程语言中,工作线程是传递给另一个函数的返回值。

如果我们回到前言,我们会看到我们确实将 Go gopher 用作工作线程的示例。简而言之,工作线程是一个比单个函数调用或编程动作更复杂的东西,它将执行一个或多个任务。

那么为什么我们现在要谈论它呢?当我们构建通道时,我们正在创建一个执行工作的机制。当我们有一个结构体或一个接口时,我们将方法和值组合在一个地方,然后使用该对象来执行工作,同时也作为存储有关该工作的信息的地方。

这在应用程序设计中特别有用,因为我们能够将应用程序功能的各个元素委托给独立和明确定义的工作线程。例如,考虑一个服务器 ping 应用程序,其中具体的部分以自包含、分隔的方式执行特定的任务。

我们将尝试通过 HTTP 包检查服务器的可用性,检查状态代码和错误,并在发现任何特定服务器存在问题时进行退避。您可能已经看出这将导致什么 - 这是负载平衡的最基本方法。但一个重要的设计考虑是我们管理通道的方式。

我们将有一个主通道,所有重要的全局事务都应该在这里累积和评估,但每个单独的服务器也将有自己的通道,用于处理只对该单独结构重要的任务。

以下代码中的设计可以被视为一个基本的管道,大致相当于我们在前几章中讨论的生产者/消费者模型:

package main

import
(
  "fmt"
  "time"
  "net/http"
)

const INIT_DELAY = 3000
const MAX_DELAY = 60000
const MAX_RETRIES = 4
const DELAY_INCREMENT = 5000

前面的代码给出了应用程序的配置部分,设置了多久检查服务器、备份的最长时间和在完全放弃之前重试的最大次数。

DELAY_INCREMENT值表示每次发现问题时我们将为服务器检查过程添加多少时间。让我们看看如何在以下部分创建服务器:

var Servers []Server

type Server struct {
  Name string
  URI string
  LastChecked time.Time
  Status bool
  StatusCode int
  Delay int
  Retries int
  Channel chan bool
}

现在,我们设计基本的服务器(使用以下代码),其中包含其当前状态、上次检查时间、检查之间的延迟、用于评估状态和建立新状态的自己的通道,以及更新的重试延迟:

func (s *Server) checkServerStatus(sc chan *Server) {
  var previousStatus string

    if s.Status == true {
      previousStatus = "OK"
    }else {
      previousStatus = "down"
    }

    fmt.Println("Checking Server",s.Name)
    fmt.Println("\tServer was",previousStatus,"on last check at",s.LastChecked)

    response, err := http.Get(s.URI)
    if err != nil {
      fmt.Println("\tError: ",err)
      s.Status = false
      s.StatusCode = 0
    }else {
      fmt.Println(response.Status)
      s.StatusCode = response.StatusCode
      s.Status = true
    }

    s.LastChecked = time.Now()
    sc <- s
}

checkServerStatus()方法是我们应用程序的核心。我们在main()函数中通过cycleServers()循环将所有服务器传递到这个方法中,之后它就变得自我实现了。

如果我们的Status设置为true,我们将状态发送到控制台作为OK(否则为down),并使用s.StatusCode设置我们的Server状态代码,作为 HTTP 代码或者如果有网络或其他错误则为0

最后,将Server的上次检查时间设置为Now(),并通过serverChan通道传递Server。在以下代码中,我们将演示如何循环遍历我们可用的服务器:

func cycleServers(sc chan *Server) {

  for i := 0; i < len(Servers); i++ {
    Servers[i].Channel = make(chan bool)
    go Servers[i].updateDelay(sc)
    go Servers[i].checkServerStatus(sc)
  }

}

这是我们的初始循环,从主函数调用。它只是循环遍历我们可用的服务器,并初始化其监听 goroutine,以及发送第一个checkServerStatus请求。

这里值得注意两件事:首先,由Server调用的通道实际上永远不会死,而是应用程序将停止检查服务器。这对于这里的所有实际目的来说都是可以的,但是如果我们有成千上万台服务器要检查,我们会浪费资源,因为本质上相当于一个未关闭的通道和一个未被移除的映射元素。稍后,我们将讨论手动终止 goroutines 的概念,这是我们只能通过停止通信通道来抽象实现的。现在让我们来看一下控制服务器状态及其下一步的以下代码:

func (s *Server) updateDelay(sc chan *Server) {
  for {
    select {
      case msg := <- s.Channel:

        if msg == false {
          s.Delay = s.Delay + DELAY_INCREMENT
          s.Retries++
          if s.Delay > MAX_DELAY {
            s.Delay = MAX_DELAY
          }

        }else {
          s.Delay = INIT_DELAY
        }
        newDuration := time.Duration(s.Delay)

        if s.Retries <= MAX_RETRIES {
          fmt.Println("\tWill check server again")
          time.Sleep(newDuration * time.Millisecond)
          s.checkServerStatus(sc)
        }else {
          fmt.Println("\tServer not reachable after",MAX_RETRIES,"retries")
        }

      default:
    }
  }
}

这是每个Server将监听其状态变化的地方,由checkServerStatus()报告。当任何给定的Server结构接收到通过我们的初始循环报告状态变化的消息时,它将评估该消息并相应地采取行动。

如果Status设置为false,我们知道服务器由于某种原因无法访问。然后Server引用本身将延迟到下次检查的时间。如果设置为true,则服务器是可访问的,延迟将被设置或重置为INIT_DELAY的默认重试值。

最后,在重新初始化checkServerStatus()方法之前,它会在 goroutine 上设置睡眠模式,并在main()函数中的初始 goroutine 循环中传递serverChan引用:

func main() {

  endChan := make(chan bool)
  serverChan := make(chan *Server)

Servers = []Server{ {Name: "Google", URI: "http://www.google.com", Status: true, Delay: INIT_DELAY}, {Name: "Yahoo", URI: "http://www.yahoo.com", Status: true, Delay: INIT_DELAY}, {Name: "Bad Amazon", URI: "http://amazon.zom", Status: true, Delay: INIT_DELAY} }

在这里有一个快速的说明 - 在我们的Servers切片中,我们故意在最后一个元素中引入了一个拼写错误。您会注意到amazon.zom,这将在checkServerStatus()方法中引发一个 HTTP 错误。以下是循环遍历服务器以找到合适匹配的函数:

  go cycleServers(serverChan)

  for {
    select {
      case currentServer := <- serverChan:
        currentServer.Channel <- false
      default:

    }
  }

  <- endChan

}

以下是包含拼写错误的输出示例:

Checking Server Google
 Server was OK on last check at 0001-01-01 00:00:00 +0000 UTC
 200 OK
 Will check server again
Checking Server Yahoo
 Server was OK on last check at 0001-01-01 00:00:00 +0000 UTC
 200 OK
 Will check server again
Checking Server Amazon
 Server was OK on last check at 0001-01-01 00:00:00 +0000 UTC
 Error:  Get http://amazon.zom: dial tcp: GetAddrInfoW: No such host is known.
 Will check server again
Checking Server Google
 Server was OK on last check at 2014-04-23 12:49:45.6575639 -0400 EDT

我们将在本章的后面通过一些并发模式再次运行前面的代码,将其转换为更实用的东西。

实现 nil 通道阻塞

设计管道或生产者/消费者模型等东西的一个更大的问题是,在任何给定时间,任何给定 goroutine 的状态都有点像黑洞。

考虑以下循环,在该循环中,生产者通道创建一组任意的消费者通道,并期望每个通道只执行一项任务:

package main

import (
  "fmt"
  "time"
)

const CONSUMERS = 5

func main() {

  Producer := make(chan (chan int))

  for i := 0; i < CONSUMERS; i++ {
    go func() {
      time.Sleep(1000 * time.Microsecond)
      conChan := make(chan int)

      go func() {
        for {
          select {
          case _,ok := <-conChan:
            if ok  {
              Producer <- conChan
            }else {
              return
            }
          default:
          }
        }
      }()

      conChan <- 1
      close(conChan)
    }()
  }

给定要生成的消费者的随机数量,我们为每个消费者附加一个通道,并通过该消费者的通道将消息传递到Producer上游。我们只发送一条消息(我们可以使用缓冲通道处理),但是在发送完消息后我们简单地关闭通道。

无论是在多线程应用程序、分布式应用程序还是高并发应用程序中,生产者-消费者模型的一个基本属性是数据能够以稳定、可靠的方式在队列/通道之间移动。这需要在生产者和消费者之间共享一些相互的知识。

与分布式(或多核)环境不同,我们确实具有对该安排两端状态的某种固有意识。接下来我们将看一下生产者消息的监听循环:

  for {
    select {
    case consumer, ok := <-Producer:
      if ok == false {
        fmt.Println("Goroutine closed?")
        close(Producer)
      } else {
        log.Println(consumer)
        // consumer <- 1
      }
      fmt.Println("Got message from secondary channel")
    default:
    }
  }
}

主要问题是Producer通道之一对于任何给定的Consumer并不了解太多,包括它何时处于活动状态。如果我们取消注释// consumer <- 1行,我们将会得到一个恐慌,因为我们试图在关闭的通道上发送消息。

当消息通过次要 goroutine 的通道上游传递到Producer的通道时,我们会得到适当的接收,但无法检测下游 goroutine 何时关闭。

在许多情况下,知道 goroutine 何时终止并不重要,但是考虑一个应用程序,在完成一定数量的任务时生成新的 goroutines,有效地将一个任务分解成小任务。也许每个块都依赖于上一个块的总体完成情况,并且广播器必须在继续之前了解当前 goroutines 的状态。

使用 nil 通道

在 Go 的早期版本中,您可以在未初始化的、因此为 nil 或 0 值的通道之间进行通信而不会引发恐慌(尽管结果可能是不可预测的)。从 Go 版本 1 开始,跨 nil 通道的通信产生了一种一致但有时令人困惑的效果。

需要注意的是,在 select 开关内,单独在 nil 通道上进行传输仍会导致死锁和恐慌。这在使用全局通道并且从未正确初始化它们时最常见。以下是在 nil 通道上进行传输的示例:

func main() {

  var channel chan int

    channel <- 1

  for {
    select {
      case <- channel:

      default:
    }
  }

}

由于通道设置为其0值(在这种情况下为 nil),它会永久阻塞,Go 编译器将会检测到这一点,至少在较新的版本中是如此。您还可以在select语句之外复制这一点,如下面的代码所示:

  var done chan int
  defer close(done)
  defer log.Println("End of script")
  go func() {
    time.Sleep(time.Second * 5)
    done <- 1
  }()

  for {
    select {
      case <- done:
        log.Println("Got transmission")
        return
      default:
    }
  }

由于select语句中的默认值使主循环在等待通道上的通信时保持活动状态,所以前面的代码将永远阻塞,没有恐慌。然而,如果我们初始化通道,应用程序将如预期般运行。

在这两种边缘情况——关闭的通道和 nil 通道中,我们需要一种方法让主通道了解 goroutine 的状态。

使用 tomb 实现对 goroutines 的更精细控制

与许多这类问题一样——无论是小众还是常见的——存在第三方实用程序,可以抓住你的 goroutines。

Tomb 是一个库,它提供了与任何 goroutine 和通道一起使用的诊断功能——它可以告诉主通道另一个 goroutine 是死了还是快死了。

此外,它允许您明确地终止 goroutine,这比简单关闭它附加的通道更微妙。如前所述,关闭通道实际上是使 goroutine 失效,尽管它最终仍然可能是活动的。

您将找到一个简单的获取和抓取主体脚本,它接受 URL 结构的切片(带有状态和 URI),并尝试获取每个 URL 的 HTTP 响应并将其应用于结构。但是,我们不仅仅报告 goroutines 的信息,还可以向“主”结构的每个子 goroutine 发送“kill 消息”的能力。

在这个例子中,我们将运行脚本 10 秒,如果任何 goroutine 在分配的时间内未能完成其工作,它将响应说由于主结构发送的 kill 命令而无法获取 URL 的主体:

package main

import (
  "fmt"
  "io/ioutil"
  "launchpad.net/tomb"
  "net/http"
  "strconv"
  "sync"
  "time"
)

var URLS []URL

type GoTomb struct {
  tomb tomb.Tomb
}

这是创建所有生成的 goroutines 的父结构或主结构所需的最低必要结构。tomb.Tomb结构只是一个互斥体,两个通道(一个用于死亡和一个用于垂死),以及一个原因错误结构。URL结构的结构如下代码所示:

type URL struct {
  Status bool
  URI    string
  Body   string
}

我们的URL结构相当基本——Status默认设置为false,当主体被检索时设置为true。它包括URI变量——这是 URL 的引用——以及用于存储检索数据的Body变量。以下函数允许我们对GoTomb结构执行“kill”:

func (gt GoTomb) Kill() {

  gt.tomb.Kill(nil)

}

前面的方法在我们的GoTomb结构上调用了tomb.Kill。在这里,我们将唯一的参数设置为nil,但这很容易改为一个更具描述性的错误,比如errors.New("Time to die, goroutine")。在这里,我们将展示GoTomb结构的监听器:

func (gt *GoTomb) TombListen(i int) {

  for {
    select {
    case <-gt.tomb.Dying():
      fmt.Println("Got kill command from tomb!")
      if URLS[i].Status == false {
        fmt.Println("Never got data for", URLS[i].URI)
      }
      return
    }
  }
}

我们调用附加到我们的GoTombTombListen,它设置一个监听Dying()通道的选择,如下面的代码所示:

func (gt *GoTomb) Fetch() {
  for i := range URLS {
    go gt.TombListen(i)

    go func(ii int) {

      timeDelay := 5 * ii
      fmt.Println("Waiting ", strconv.FormatInt(int64(timeDelay), 10), " seconds to get", URLS[ii].URI)
      time.Sleep(time.Duration(timeDelay) * time.Second)
      response, _ := http.Get(URLS[ii].URI)
      URLS[ii].Status = true
      fmt.Println("Got body for ", URLS[ii].URI)
      responseBody, _ := ioutil.ReadAll(response.Body)
      URLS[ii].Body = string(responseBody)
    }(i)
  }
}

当我们调用Fetch()时,我们还将 tomb 设置为TombListen(),它接收所有生成的 goroutines 的“主”消息。我们故意设置一个很长的等待时间,以确保我们最后几次尝试Fetch()Kill()命令之后进行。最后,我们的main()函数,处理整体设置:

func main() {

  done := make(chan int)

  URLS = []URL{{Status: false, URI: "http://www.google.com", Body: ""}, {Status: false, URI: "http://www.amazon.com", Body: ""}, {Status: false, URI: "http://www.ubuntu.com", Body: ""}}

  var MasterChannel GoTomb
  MasterChannel.Fetch()

  go func() {

    time.Sleep(10 * time.Second)
    MasterChannel.Kill()
    done <- 1
  }()

  for {
    select {
    case <-done:
      fmt.Println("")
      return
    default:
    }
  }
}

通过将time.Sleep设置为10秒,然后杀死我们的 goroutines,我们保证在被杀死之前,Fetch()之间的 5 秒延迟会阻止我们的最后几个 goroutines 成功完成。

提示

对于 tomb 包,请访问godoc.org/launchpad.net/tomb并使用go get launchpad.net/tomb命令进行安装。

使用通道超时

关于通道和select循环的一个相当关键的点,我们还没有特别仔细地检查过,那就是在一定的超时之后终止select循环的能力和通常的必要性。

到目前为止,我们编写的许多应用程序都是长时间运行或永久运行的,但有时我们会希望对 goroutines 的运行时间设置有限的时间限制。

我们迄今为止使用的for { select { } }开关要么永久存在(带有默认情况),要么等待从一个或多个情况中退出。

有两种管理基于间隔的任务的方法——都作为时间包的一部分,这并不奇怪。

time.Ticker结构允许在指定的时间段之后执行任何给定的操作。它提供了一个 C,一个阻塞通道,可以用来检测在那段时间之后发送的活动;参考以下代码:

package main

import (
  "log"
  "time"
)

func main() {

  timeout := time.NewTimer(5 * time.Second)
  defer log.Println("Timed out!")

  for {
    select {
    case <-timeout.C:
      return
    default:
    }
  }

}

我们可以扩展这个方法,在一定时间后结束通道和并发执行。看一下以下修改:

package main

import (
  "fmt"
  "time"
)

func main() {

  myChan := make(chan int)

  go func() {
    time.Sleep(6 * time.Second)
    myChan <- 1
  }()

  for {
    select {
      case <-time.After(5 * time.Second):
        fmt.Println("This took too long!")
        return
      case <-myChan:
        fmt.Println("Too little, too late")
    }
  }
}

使用并发模式构建负载均衡器

当我们在本章前面构建我们的服务器 ping 应用程序时,很容易想象将其带到一个更可用和有价值的空间。

对于负载均衡器的健康检查,ping 服务器通常是第一步。正如 Go 提供了一个可用的开箱即用的 Web 服务器解决方案一样,它还提供了一个非常干净的ProxyReverseProxy结构和方法,使得创建负载均衡器变得非常简单。

当然,循环负载均衡器将需要大量的后台工作,特别是在更改请求之间的ReverseProxy位置时进行检查和重新检查。我们将使用每个请求触发的 goroutines 来处理这些。

最后,请注意我们在配置底部有一些虚拟 URL——将这些更改为生产 URL 应立即将运行此服务器的服务器转换为工作负载均衡器。让我们看一下应用程序的主要设置:

package main

import (
  "fmt"
  "log"
  "net/http"
  "net/http/httputil"
  "net/url"
  "strconv"
  "time"
)

const MAX_SERVER_FAILURES = 10
const DEFAULT_TIMEOUT_SECONDS = 5
const MAX_TIMEOUT_SECONDS = 60
const TIMEOUT_INCREMENT = 5
const MAX_RETRIES = 5

在前面的代码中,我们定义了我们的常量,就像我们之前做的那样。我们有一个MAX_RETRIES,它限制了我们可以有多少次失败,MAX_TIMEOUT_SECONDS,它定义了我们在再次尝试之前等待的最长时间,以及我们的TIMEOUT_INCREMENT用于在失败之间更改该值。接下来,让我们看一下我们的Server结构的基本构造:

type Server struct {
  Name        string
  Failures    int
  InService   bool
  Status      bool
  StatusCode  int
  Addr        string
  Timeout     int
  LastChecked time.Time
  Recheck     chan bool
}

正如我们在前面的代码中看到的,我们有一个通用的Server结构,用于维护当前状态、最后的状态代码以及上次检查服务器的时间的信息。

请注意,我们还有一个Recheck通道,触发延迟尝试检查Server是否再次可用。通过该通道传递的每个布尔值都将从可用池中删除服务器,或者重新宣布其仍在服务中:

func (s *Server) serverListen(serverChan chan bool) {
  for {
    select {
    case msg := <-s.Recheck:
      var statusText string
      if msg == false {
        statusText = "NOT in service"
        s.Failures++
        s.Timeout = s.Timeout + TIMEOUT_INCREMENT
        if s.Timeout > MAX_TIMEOUT_SECONDS {
          s.Timeout = MAX_TIMEOUT_SECONDS
        }
      } else {
        if ServersAvailable == false {
          ServersAvailable = true
          serverChan <- true
        }
        statusText = "in service"
        s.Timeout = DEFAULT_TIMEOUT_SECONDS
      }

      if s.Failures >= MAX_SERVER_FAILURES {
        s.InService = false
        fmt.Println("\tServer", s.Name, "failed too many times.")
      } else {
        timeString := strconv.FormatInt(int64(s.Timeout), 10)
        fmt.Println("\tServer", s.Name, statusText, "will check again in", timeString, "seconds")
        s.InService = true
        time.Sleep(time.Second * time.Duration(s.Timeout))
        go s.checkStatus()
      }

    }
  }
}

这是一个实例化的方法,它在每个服务器上监听在任何给定时间服务器的可用性的消息。在运行 goroutine 的同时,我们保持一个永久监听通道,以便从“checkStatus()”接收布尔响应。如果服务器可用,则下一个延迟设置为默认值;否则,将TIMEOUT_INCREMENT添加到延迟中。如果服务器失败次数太多,通过将其InService属性设置为false并不再调用“checkStatus()”方法来将其从轮换中取出。接下来让我们看一下检查Server当前状态的方法:

func (s *Server) checkStatus() {
  previousStatus := "Unknown"
  if s.Status == true {
    previousStatus = "OK"
  } else {
    previousStatus = "down"
  }
  fmt.Println("Checking Server", s.Name)
  fmt.Println("\tServer was", previousStatus, "on last check at", s.LastChecked)
  response, err := http.Get(s.Addr)
  if err != nil {
    fmt.Println("\tError: ", err)
    s.Status = false
    s.StatusCode = 0
  } else {
    s.StatusCode = response.StatusCode
    s.Status = true
  }

  s.LastChecked = time.Now()
  s.Recheck <- s.Status
}

我们的“checkStatus()”方法应该看起来很熟悉,基于服务器 ping 示例。我们寻找服务器;如果它可用,我们向我们的Recheck通道传递true;否则传递false,如下面的代码所示:

func healthCheck(sc chan bool) {
  fmt.Println("Running initial health check")
  for i := range Servers {
    Servers[i].Recheck = make(chan bool)
    go Servers[i].serverListen(sc)
    go Servers[i].checkStatus()
  }
}

我们的healthCheck函数只是启动每个服务器检查(和重新检查)其状态的循环。它只运行一次,并通过make语句初始化Recheck通道:

func roundRobin() Server {
  var AvailableServer Server

  if nextServerIndex > (len(Servers) - 1) {
    nextServerIndex = 0
  }

  if Servers[nextServerIndex].InService == true {
    AvailableServer = Servers[nextServerIndex]
  } else {
    serverReady := false
    for serverReady == false {
      for i := range Servers {
        if Servers[i].InService == true {
          AvailableServer = Servers[i]
          serverReady = true
        }
      }

    }
  }
  nextServerIndex++
  return AvailableServer
}

roundRobin函数首先检查队列中的下一个可用Server——如果该服务器不可用,它将循环查找剩余的服务器,以找到第一个可用的Server。如果它循环遍历所有服务器,它将重置为0。让我们看一下全局配置变量:

var Servers []Server
var nextServerIndex int
var ServersAvailable bool
var ServerChan chan bool
var Proxy *httputil.ReverseProxy
var ResetProxy chan bool

这些是我们的全局变量——我们的Servers切片的Server结构,用于递增下一个要返回的ServernextServerIndex变量,ServersAvailableServerChan,它们在可用服务器可用后才启动负载均衡器,然后是我们的Proxy变量,告诉我们的http处理程序去哪里。这需要一个ReverseProxy方法,我们现在将在以下代码中查看:

func handler(p *httputil.ReverseProxy) func(http.ResponseWriter, *http.Request) {
  Proxy = setProxy()
  return func(w http.ResponseWriter, r *http.Request) {

    r.URL.Path = "/"

    p.ServeHTTP(w, r)

  }
}

请注意,我们在这里操作的是ReverseProxy结构,这与我们之前对提供网页服务的尝试不同。我们的下一个函数执行循环负载均衡,并获取我们的下一个可用服务器:

func setProxy() *httputil.ReverseProxy {

  nextServer := roundRobin()
  nextURL, _ := url.Parse(nextServer.Addr)
  log.Println("Next proxy source:", nextServer.Addr)
  prox := httputil.NewSingleHostReverseProxy(nextURL)

  return prox
}

setProxy函数在每个请求之后被调用,你可以在我们的处理程序中看到它是第一行。接下来,我们有一个通用的监听函数,用于监听我们将要进行反向代理的请求:

func startListening() {
  http.HandleFunc("/index.html", handler(Proxy))
  _ = http.ListenAndServe(":8080", nil)

}

func main() {
  nextServerIndex = 0
  ServersAvailable = false
  ServerChan := make(chan bool)
  done := make(chan bool)

  fmt.Println("Starting load balancer")
  Servers = []Server{{Name: "Web Server 01", Addr: "http://www.google.com", Status: false, InService: false}, {Name: "Web Server 02", Addr: "http://www.amazon.com", Status: false, InService: false}, {Name: "Web Server 03", Addr: "http://www.apple.zom", Status: false, InService: false}}

  go healthCheck(ServerChan)

  for {
    select {
    case <-ServerChan:
      Proxy = setProxy()
      startListening()
      return

    }
  }

  <-done
}

通过这个应用程序,我们拥有一个简单但可扩展的负载均衡器,它与 Go 中的常见核心组件一起工作。其并发特性使其保持精简和快速,我们只使用标准的 Go 编写了非常少量的代码。

选择单向和双向通道

为了简单起见,我们设计了大部分应用程序和示例代码都使用双向通道,但当然任何通道都可以设置为单向。这基本上将通道转换为“只读”或“只写”通道。

如果您想知道为什么在不节省任何资源或保证问题的情况下限制通道的方向,原因归结为代码的简单性和限制恐慌的潜力。

到目前为止,我们知道在关闭的通道上发送数据会导致恐慌,因此如果我们有一个只写通道,我们在野外永远不会意外遇到这个问题。许多情况下也可以通过 WaitGroups 来减轻这种情况,但在这种情况下,这就像用锤子敲钉子。考虑以下循环:

const TOTAL_RANDOMS = 100

func concurrentNumbers(ch chan int) {
  for i := 0; i < TOTAL_RANDOMS; i++ {
    ch <- i
  }
}

func main() {

  ch := make(chan int)

  go concurrentNumbers(ch)

  for {
    select {
      case num := <- ch:
        fmt.Println(num)
        if num == 98 {
          close(ch)
        }
      default:
    }
  }
}

由于我们在 goroutine 完成之前突然关闭了 ch 通道的一个数字,任何对它的写入都会导致运行时错误。

在这种情况下,我们正在调用一个只读命令,但它在 select 循环中。我们可以通过只允许在单向通道上发送特定操作来更安全地进行这个操作。这个应用程序将始终在通道被过早关闭的情况下工作,比 TOTAL_RANDOMS 常量少一个。

使用只接收或只发送的通道

当我们限制通道的方向或读/写能力时,我们还减少了如果我们的一个或多个进程无意中在这样的通道上发送时关闭通道死锁的可能性。

因此,对于问题“何时适合使用单向通道?”的简短答案是“只要可能”。

不要强迫这个问题,但如果您可以将通道设置为只读或只写,可能会在将来避免问题。

使用不确定的通道类型

一个经常有用的技巧,我们还没有解决的是,能够拥有有效的无类型通道的能力。

如果您想知道为什么这可能有用,简短的答案是简洁的代码和应用程序设计节俭。通常这是一种不鼓励的策略,但您可能会发现它在某些时候很有用,特别是当您需要通过单个通道传达一个或多个不同的概念时。以下是一个不确定通道类型的示例:

package main

import (

  "fmt"
  "time"
)

func main() {

  acceptingChannel := make(chan interface{})

  go func() {

    acceptingChannel <- "A text message"
    time.Sleep(3 * time.Second)
    acceptingChannel <- false
  }()

  for {
    select {
      case msg := <- acceptingChannel:
        switch typ := msg.(type) {
          case string:
            fmt.Println("Got text message",typ)
          case bool:
            fmt.Println("Got boolean message",typ)
            if typ == false {
              return
            }
          default:
          fmt.Println("Some other type of message")
        }

      default:

    }

  }

  <- acceptingChannel
}

使用 Go 进行单元测试

与您可能有的许多基本和中级开发和部署要求一样,Go 自带了一个用于处理单元测试的内置应用程序。

测试的基本前提是您创建您的包,然后创建一个测试包来针对初始应用程序运行。以下是一个非常基本的示例:

mathematics.go
package mathematics

func Square(x int) int {

  return x * 3
}
mathematics_test.go
package mathematics

import
(
  "testing"
)

func Test_Square_1(t *testing.T) {
  if Square(2) != 4 {
    t.Error("Square function failed one test")
  }
}

在该子目录中进行简单的 Go 测试将给您所需的响应。虽然这显然很简单,而且故意有缺陷,但您可能会看到如何轻松地拆分您的代码并逐步测试它。这足以在开箱即用的情况下进行非常基本的单元测试。

然后,对此进行更正将相当简单——相同的测试将通过以下代码:

func Square(x int) int {

  return x * x
}

测试包有一定的局限性;然而,它提供了基本的通过/失败,没有断言的能力。有两个第三方包可以在这方面提供帮助,我们将在以下部分进行探讨。

GoCheck

GoCheck 主要通过增加断言和验证来扩展基本测试包。您还将获得一些基本的基准测试实用程序,它的工作方式比您需要使用 Go 进行工程设计的任何东西更基本。

提示

有关 GoCheck 的更多详细信息,请访问 labix.org/gocheck 并使用 go get gopkg.in/check.v1 进行安装。

Ginkgo 和 Gomega

这使得测试可以像单元测试一样细粒度,但也扩展了我们处理应用程序使用的方式,以详细和明确的行为。

如果 BDD 是您或您的组织感兴趣的内容,这是一个实现更深入单元测试的成熟包。

提示

有关 Ginkgo 的更多信息,请访问github.com/onsi/ginkgo并使用go get github.com/onsi/ginkgo/ginkgo进行安装。

有关依赖性的更多信息,请参阅go get github.com/onsi/gomega

使用 Google App Engine

如果您对 Google App Engine 不熟悉,简短的版本是它是一个云环境,允许简单构建和部署平台即服务PaaS)解决方案。

与许多类似的解决方案相比,Google App Engine 允许您以非常简单和直接的方式构建和测试应用程序。Google App Engine 允许您使用 Python、Java、PHP 和当然,Go 来编写和部署。

在很大程度上,Google App Engine 提供了一个标准的 Go 安装,使得可以轻松地与http软件包配合使用。但它还为您提供了一些独特于 Google App Engine 的值得注意的额外软件包:

软件包 描述
appengine/memcache 这提供了一个分布式的内存缓存安装,是 Google App Engine 独有的
appengine/mail 这允许您通过类似 SMTP 的平台发送电子邮件
appengine/log 鉴于您的存储可能更短暂,它将日志的云版本正式化
appengine/user 这打开了身份和 OAuth 功能
appengine/search 这使您的应用程序可以通过数据存储库获得 Google 搜索的功能
appengine/xmpp 这提供了类似 Google Chat 的功能
appengine/urlfetch 这是一个爬虫功能
appengine/aetest 这扩展了 Google App Engine 的单元测试

尽管对于 Google App Engine 来说,Go 仍然被认为是测试版,但您可以期待,如果有人能够在云环境中成功部署它,那就是 Google。

利用最佳实践

当涉及到最佳实践时,Go 的美妙之处在于,即使您并不一定做对了一切,Go 要么会警告您,要么会为您提供必要的工具来修复它。

如果您尝试包含代码但不使用它,或者尝试初始化变量但不使用它,Go 会阻止您。如果您想清理代码的格式,Go 可以使用go fmt来实现。

构建您的代码

从头开始构建软件包时,最简单的事情之一就是以符合惯例的方式构建代码目录。新软件包的标准看起来可能是以下代码:

/projects/
  thisproject/
    bin/
    pkg/
    src/
      package/
        mypackage.go

设置您的 Go 代码就像这样不仅对您自己的组织有帮助,还可以更轻松地分发您的软件包。

记录您的代码

对于在企业或协作编码环境中工作过的人来说,文档是神圣的。正如您可能记得的那样,使用godoc命令可以让您快速获取有关软件包的信息,无论是在命令行还是通过特设的本地主机服务器。以下是您可以使用godoc的两种基本方式:

使用 godoc 描述
godoc fmt 这将fmt文档显示在屏幕上
godoc -http=:3000 这将在端口:3030上托管文档

Go 使得记录您的代码变得非常容易,而且您绝对应该这样做。只需在每个标识符(软件包、类型或函数)上方添加单行注释,您将把它附加到上下文文档中,如下面的代码所示:

// A demo documentation package
package documentation

// The documentation struct object
// Chapter int represents a document's chapter
// Content represents the text of the documentation
type Documentation struct {
  Chapter int
  Content string
}

//  Display() outputs the content of any given Document by chapter
func (d Documentation) Display() {

}

安装后,这将允许任何人在您的软件包上运行godoc文档,并获得您愿意提供的详细信息。

您经常会在 Go 核心代码中看到更健壮的示例,值得审查以比较您的文档风格与 Google 和 Go 社区的风格。

通过 go get 使您的代码可用

假设您已经按照之前列出的组织技术保持了代码的一致性,那么通过代码存储库和主机使您的代码可用应该是轻而易举的。

使用 GitHub 作为标准,这是我们设计第三方应用程序的方式:

  1. 确保您遵循先前的结构格式。

  2. 将源文件保存在它们将在远程存储的目录结构下。换句话说,预期本地结构将反映远程结构。

  3. 显然,只提交您希望在远程存储库中共享的文件。

假设您的存储库是公开的,任何人都应该能够获取(go get)并安装(go install)您的包。

避免在您的包中使用并发

最后一个可能看起来有些不合时宜的观点是,如果您正在构建将被导入的单独包,请尽量避免包含并发代码。

这不是一个死板的规则,但考虑到潜在的使用情况,这是有道理的——除非您的包绝对需要,并发,否则让主应用程序处理并发。这样做将防止许多隐藏的、难以调试的行为,这可能会使您的库不那么吸引人。

总结

我真诚地希望您能够通过本书探索、理解并利用 Go 强大的并发能力。

我们已经讨论了很多内容,从最基本的、不涉及通道的并发 goroutines 到复杂的通道类型、并行性和分布式计算,并且在每一步都带来了一些示例代码。

到目前为止,您应该已经完全具备了以高度并发、快速和无错误的方式构建任何您心中所需的代码的能力。除此之外,您应该能够产生格式良好、结构合理、有文档的代码,可以被您、您的组织或其他人用来实现最佳利用并发的代码。

并发本身是一个模糊的概念;对不同的人(以及多种语言)来说,它的含义略有不同,但核心目标始终是快速、高效、可靠的代码,可以为任何应用程序提供性能提升。

掌握了 Go 中并发实现以及其内部工作原理的全面理解,我希望您在语言不断发展和成长的过程中继续您的 Go 之旅,并呼吁您考虑在 Go 项目本身的发展中做出贡献。

posted @ 2024-05-04 22:37  绝不原创的飞龙  阅读(12)  评论(0编辑  收藏  举报