Go-设计模式(五)

106 阅读35分钟

Go 设计模式(五)

原文:zh.annas-archive.org/md5/8A110D02C69060149D76F09768570714

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章:并发模式-屏障、未来和管道设计模式

现在我们熟悉了并发和并行的概念,并且已经了解了如何使用 Go 的并发原语来实现它们,我们可以看到关于并发工作和并行执行的一些模式。在本章中,我们将看到以下模式:

  • 屏障是一种非常常见的模式,特别是当我们必须等待来自不同 Goroutines 的多个响应才能让程序继续时。

  • 未来模式允许我们编写一个算法,该算法最终将由同一个 Goroutine 或不同的 Goroutine 在时间上执行(或不执行)

  • 管道是一种强大的模式,用于构建与某种逻辑相连的 Goroutines 的复杂同步流

快速浏览一下这三种模式的描述。它们都描述了一些逻辑来同步执行时间。非常重要的是要记住,我们现在正在使用前几章中看到的所有工具和模式来开发并发结构。在创建模式中,我们处理创建对象。在结构模式中,我们学习如何构建成惯用的结构,在行为模式中,我们主要处理算法。现在,使用并发模式,我们将主要管理应用程序的定时执行和顺序执行,这些应用程序具有多个

屏障并发模式

我们将从屏障模式开始。它的目的很简单-设置一个屏障,以便在我们获得所有需要的结果之前没有人通过,这在并发应用程序中非常常见。

描述

想象一下这样的情况,我们有一个微服务应用程序,其中一个服务需要通过合并另外三个微服务的响应来组成其响应。这就是屏障模式可以帮助我们的地方。

我们的屏障模式可以是一个服务,它将阻止其响应,直到它已与一个或多个不同的 Goroutines(或服务)返回的结果组合在一起。那么我们有什么样的原语具有阻塞特性呢?嗯,我们可以使用锁,但在 Go 中更惯用的是使用无缓冲通道。

目标

正如其名称所示,屏障模式试图阻止执行,以便在准备好结束之前不要完成。屏障模式的目标如下:

  • 将类型的值与来自一个或多个 Goroutines 的数据组合在一起。

  • 控制任何这些传入数据管道的正确性,以便不返回不一致的数据。我们不希望部分填充的结果,因为其中一个管道返回了错误。

一个 HTTP GET 聚合器

对于我们的示例,我们将在微服务应用程序中编写一个非常典型的情况-一个执行两个 HTTP GET调用并将它们合并成单个响应的应用程序,然后将其打印在控制台上。

我们的小应用程序必须在不同的 Goroutine 中执行每个请求,并在控制台上打印结果,如果两个响应都正确。如果其中任何一个返回错误,那么我们只打印错误。

设计必须是并发的,允许我们利用多核 CPU 并行进行调用:

一个 HTTP GET 聚合器

在上图中,实线表示调用,虚线表示通道。气球是 Goroutines,因此我们有两个 Goroutines 由main函数启动(也可以被认为是 Goroutine)。这两个函数将通过使用它们在makeRequest调用时接收到的公共通道main函数进行通信。

验收标准

我们在这个应用程序中的主要目标是获取两个不同调用的合并响应,因此我们可以这样描述我们的验收标准:

  • 在控制台上打印两次对http://httpbin.org/headershttp://httpbin.org/User-Agent URL 的调用的合并结果。这些是一对公共端点,会响应来自传入连接的数据。它们非常受欢迎,用于测试目的。您需要互联网连接才能完成此练习。

  • 如果任何一个调用失败,它不得打印任何结果-只打印错误消息(或者如果两个调用都失败,则打印错误消息)。

  • 当两个调用都完成时,输出必须作为组合结果打印出来。这意味着我们不能先打印一个调用的结果,然后再打印另一个调用的结果。

单元测试 - 集成

为并发设计编写单元测试或集成测试有时可能会很棘手,但这不会阻止我们编写出色的单元测试。我们将有一个名为barrier的方法,接受一个定义为string类型的端点集。屏障将对每个端点进行GET请求,并在打印结果之前组合结果。在这种情况下,我们将编写三个集成测试,以简化我们的代码,这样我们就不需要生成模拟响应:

package barrier 

import ( 
    "bytes" 
    "io" 
    "os" 
    "strings" 
    "testing" 
) 

func TestBarrier(t *testing.T) { 
  t.Run("Correct endpoints", func(t *testing.T) { 
    endpoints := []string{"http://httpbin.org/headers",  "http://httpbin.org/User-Agent"
    } 
  }) 

  t.Run("One endpoint incorrect", func(t *testing.T) { 
    endpoints := []string{"http://malformed-url",  "http://httpbin.org/User-Agent"} 
  }) 

  t.Run("Very short timeout", func(t *testing.T) { 
    endpoints := []string{"http://httpbin.org/headers",  "http://httpbin.org/User-Agent"} 
  }) 
} 

我们有一个单一的测试,将执行三个子测试:

  • 第一个测试对正确的端点进行两次调用

  • 第二个测试将有一个错误的端点,因此它必须返回一个错误

  • 最后一个测试将返回最大超时时间,以便我们可以强制超时错误

我们将有一个名为barrier的函数,它将接受形式为字符串的不确定数量的端点。它的签名可能是这样的:

func barrier(endpoints ...string) {} 

正如你所看到的,barrier函数不返回任何值,因为它的结果将打印在控制台上。之前,我们已经编写了一个io.Writer接口的实现,以模拟在操作系统的stdout库上的写入。为了改变一些东西,我们将捕获stdout库而不是模拟一个。一旦你理解了 Go 语言中的并发原语,捕获stdout库的过程并不困难:

func captureBarrierOutput(endpoints ...string) string { 
    reader, writer, _ := os.Pipe() 

    os.Stdout = writer 
    out := make(chan string) 

    go func() { 
      var buf bytes.Buffer 
      io.Copy(&buf, reader) 
      out <- buf.String() 
    }() 

    barrier(endpoints...) 

    writer.Close() 
    temp := <-out 

    return temp 
} 

不要被这段代码吓到;它真的很简单。首先我们创建了一个管道;我们在第三章中已经做过这个操作,结构模式 - 适配器、桥接和组合设计模式,当我们谈论适配器设计模式时。回想一下,管道允许我们将io.Writer接口连接到io.Reader接口,以便读取器的输入是Writer的输出。我们将os.Stdout定义为写入器。然后,为了捕获stdout输出,我们将需要一个不同的 Goroutine 来监听我们写入控制台时。正如你所知,如果我们写入,我们就不会捕获,如果我们捕获,我们就不会写入。这里的关键词是while;这是一个经验法则,如果你在某个定义中找到这个词,你可能需要一个并发结构。因此,我们使用go关键字启动一个不同的 Goroutine,将读取器的输入复制到字节缓冲区,然后通过通道发送缓冲区的内容(我们应该先前创建)。

此时,我们有一个监听的 Goroutine,但我们还没有打印任何东西,所以我们用提供的端点调用我们的(尚未编写的)函数barrier。接下来,我们必须关闭写入器以向 Goroutine 发出不会再有更多输入的信号。我们称为 out 的通道会阻塞执行,直到接收到某个值(由我们启动的 Goroutine 发送的值)。最后一步是返回从控制台捕获的内容。

好的,所以我们有一个名为captureBarrierOutput的函数,它将在stdout中捕获输出并将其作为字符串返回。现在我们可以编写我们的测试了:

t.Run("Correct endpoints", func(t *testing.T) { 
    endpoints := []string{"http://httpbin.org/headers", "http://httpbin.org/User-Agent"
    } 

 result := captureBarrierOutput(endpoints...)
 if !strings.Contains(result, "Accept-Encoding") || strings.Contains (result, "User-Agent") 
  {
 t.Fail()
 }
 t.Log(result) 
}) 

所有这些测试都非常容易实现。总的来说,是captureBarrierOutput函数调用了barrier函数。所以我们传递端点并检查返回的结果。我们发送到httpbin.org的组合响应必须包含每个端点响应中的Accept-EncodingUser-Agent文本。如果我们找不到这些文本,测试将失败。为了调试目的,如果我们想要使用-v标志检查它,我们会记录响应在 go test 中:

t.Run("One endpoint incorrect", func(t *testing.T) { 
  endpoints := []string
  {
    "http://malformed-url", "http://httpbin.org/User-Agent"} 

 result := captureBarrierOutput(endpoints...)
 if !strings.Contains(result, "ERROR") {
 t.Fail()
 }
 t.Log(result) 
}) 

这次我们使用了不正确的端点 URL,所以响应必须返回以ERROR为前缀的错误,这是我们自己在barrier函数中编写的。

最后一个函数将把 HTTP GET客户端的超时减少到最少 1 毫秒,以便强制超时:

t.Run("Very short timeout", func(t *testing.T) { 
  endpoints := []string
  {
    "http://httpbin.org/headers", "http://httpbin.org/User-Agent"} 
 timeoutMilliseconds = 1
 result := captureBarrierOutput(endpoints...)
 if !strings.Contains(result, "Timeout") {
 t.Fail()
 }
 t.Log(result) 
  }) 

timeoutMilliseconds变量将是一个我们在实现过程中需要定义的包变量。

实现

我们需要定义一个名为timeoutMilliseconds的包变量。让我们从那里开始:

package barrier 

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

var timeoutMilliseconds int = 5000 

初始超时延迟为 5 秒(5,000 毫秒),我们将需要这些包在我们的代码中。

好的,所以我们需要一个函数为每个端点 URL 启动一个 Goroutine。你还记得我们是如何在 Goroutines 之间进行通信的吗?没错--通道!所以我们需要一个处理响应的通道和一个处理错误的通道。

但我们可以简化它一些。我们将收到两个正确的响应、两个错误,或一个响应和一个错误;无论如何,总是有两个响应,所以我们可以将错误和响应合并成一个合并类型:

type barrierResp struct { 
    Err  error 
    Resp string 
} 

因此,每个 Goroutine 将发送一个barrierResp类型的值。这个值将有一个Err值或一个Resp字段的值。

该过程很简单:我们创建了一个大小为 2 的通道,用于接收barrierResp类型的响应,我们启动了两个请求并等待两个响应,然后检查是否有任何错误:

func barrier(endpoints ...string) { 
    requestNumber := len(endpoints) 

    in := make(chan barrierResp, requestNumber) 
    defer close(in) 

    responses := make([]barrierResp, requestNumber) 

    for _, endpoint := range endpoints { 
        go makeRequest(in, endpoint) 
    } 

    var hasError bool 
    for i := 0; i < requestNumber; i++ { 
        resp := <-in 
        if resp.Err != nil { 
            fmt.Println("ERROR: ", resp.Err) 
            hasError = true 
        } 
        responses[i] = resp 
    } 

    if !hasError { 
        for _, resp := range responses { 
            fmt.Println(resp.Resp) 
        } 
    } 
} 

根据前面的描述,我们创建了一个名为in的缓冲通道,使其大小与传入的端点一样,并推迟了通道关闭。然后,我们使用每个端点和响应通道启动了一个名为makeRequest的函数。

现在我们将循环两次,每次对应一个端点。在循环中,我们阻塞执行,等待来自in通道的数据。如果我们发现错误,我们会打印带有ERROR前缀的错误,因为这是我们在测试中期望的,并将hasErrorvar设置为 true。在两个响应之后,如果我们没有发现任何错误(hasError== false),我们会打印每个响应,并关闭通道。

我们仍然缺少makeRequest函数:

func makeRequest(out chan<- barrierResp, url string) { 
    res := barrierResp{} 
    client := http.Client{ 
        Timeout: time.Duration(time.Duration(timeoutMilliseconds) * time.Millisecond), 
    } 

    resp, err := client.Get(url) 
    if err != nil { 
        res.Err = err 
        out <- res 
        return 
    } 

    byt, err := ioutil.ReadAll(resp.Body) 
    if err != nil { 
        res.Err = err 
        out <- res 
        return 
    } 

    res.Resp = string(byt) 
    out <- res 
} 

makeRequest函数是一个非常简单的函数,它接受一个通道来输出barrierResp值和一个要请求的 URL。我们创建一个http.Client并将其超时字段设置为timeoutMilliseconds包变量的值。这是我们在in函数测试之前可以改变超时延迟的方法。然后,我们简单地进行GET调用,获取响应,将其解析为字节切片,并通过out通道发送。

我们通过填充barrierResp类型的res变量来完成所有这些。如果我们在执行GET请求或解析结果的主体时发现错误,我们会填充res.Err字段,将其发送到out通道(其对面连接到原始 Goroutine),并退出函数(这样我们就不会错误地通过out通道发送两个值)。

是时候运行测试了。请记住,您需要互联网连接,否则前两个测试将失败。我们将首先尝试具有两个正确端点的测试:

go test -run=TestBarrier/Correct_endpoints -v .
=== RUN   TestBarrier
=== RUN   TestBarrier/Correct_endpoints
--- PASS: TestBarrier (0.54s)
 --- PASS: TestBarrier/Correct_endpoints (0.54s)
 barrier_test.go:20: {
 "headers": {
 "Accept-Encoding": "gzip", 
"Host": "httpbin.org",
"User-Agent": "Go-http-client/1.1"
 }
 }
 {
 "User-Agent": "Go-http-client/1.1"
 } 
 ok

完美。我们得到了一个带有键headers的 JSON 响应,以及另一个带有键User-Agent的 JSON 响应。在我们的集成测试中,我们正在寻找字符串User-AgentAccept-Encoding,这些字符串都存在,所以测试已成功通过。

现在我们将运行一个具有不正确端点的测试:

go test -run=TestBarrier/One_endpoint_incorrect -v .
=== RUN   TestBarrier
=== RUN   TestBarrier/One_endpoint_incorrect
--- PASS: TestBarrier (0.27s)
 --- PASS: TestBarrier/One_endpoint_incorrect (0.27s)
 barrier_test.go:31: ERROR:  Get http://malformed-url: dial tcp: lookup malformed-url: no such host
ok

我们可以看到,当http://malformed-url返回no such host错误时,我们出现了错误。对这个 URL 的请求必须返回一个以ERROR:为前缀的文本,正如我们在验收标准中所述,这就是为什么这个测试是正确的(我们没有假阳性)。

注意

在测试中,理解“假阳性”和“假阴性”测试的概念非常重要。假阳性测试大致描述为当不应该通过条件时通过条件的测试(结果:全部通过),而假阴性则正好相反(结果:测试失败)。例如,我们可能正在测试请求时返回一个字符串,但是返回的字符串可能完全为空!这将导致假阴性,即即使我们正在检查一个有意不正确的行为(对http://malformed-url的请求),测试也不会失败。

最后一个测试将超时时间缩短为 1 毫秒:

go test -run=TestBarrier/Very_short_timeout -v .     
=== RUN   TestBarrier 
=== RUN   TestBarrier/Very_short_timeout 
--- PASS: TestBarrier (0.00s) 
    --- PASS: TestBarrier/Very_short_timeout (0.00s) 
        barrier_test.go:43: ERROR:  Get http://httpbin.org/User-Agent: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers) 
        ERROR:  Get http://httpbin.org/headers: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers) 

ok

再次,测试成功通过,我们得到了两个超时错误。URL 是正确的,但我们在一毫秒内没有得到响应,所以客户端返回了超时错误。

使用屏障设计模式等待响应

屏障模式以其可组合的特性打开了微服务编程的大门。它可以被认为是一种结构模式,正如你可以想象的那样。

屏障模式不仅对进行网络请求有用;我们还可以使用它将某些任务分割成多个 Goroutines。例如,一个昂贵的操作可以分割成几个较小的操作,分布在不同的 Goroutines 中,以最大程度地实现并行性并获得更好的性能。

未来设计模式

未来设计模式(也称为Promise)是实现异步编程的并发结构的一种快速简便的方法。我们将利用 Go 中的一级函数来开发Futures

描述

简而言之,我们将在不同的 Goroutines 中定义每个动作的可能行为,然后执行它们。Node.js 使用这种方法,默认提供事件驱动编程。这里的想法是实现一个fire-and-forget,处理动作的所有可能结果。

为了更好地理解,我们可以谈论一个类型,它在执行顺利或失败的情况下嵌入了行为。

描述

在前面的图表中,main函数在一个新的 Goroutine 中启动了一个Future。它不会等待任何东西,也不会接收 Future 的任何进度。它真的只是启动并忘记了它。

这里有趣的是,我们可以在一个 Future 中启动一个新的 Future,并在同一个 Goroutine(或新的 Goroutine)中嵌入尽可能多的 Futures。这个想法是利用一个 Future 的结果来启动下一个。例如:

描述

在这里,我们有相同的 Future。在这种情况下,如果Execute函数返回了正确的结果,那么将执行Success函数,只有在这种情况下,我们才会执行一个新的 Goroutine,里面包含另一个 Future(甚至可以不使用 Goroutine)。

这是一种懒惰的编程,其中一个 Future 可能会无限地调用自身,或者直到满足某些规则为止。这个想法是预先定义行为,让未来解决可能的解决方案。

目标

使用未来模式,我们可以启动许多新的 Goroutines,每个 Goroutine 都有一个动作和自己的处理程序。这使我们能够做到以下几点:

  • 将动作处理程序委托给不同的 Goroutine

  • 在它们之间堆叠许多异步调用(一个异步调用在其结果中调用另一个异步调用)

一个简单的异步请求者

我们将开发一个非常简单的示例来尝试理解 Future 的工作原理。在这个示例中,我们将有一个返回字符串或错误的方法,但我们希望并发执行它。我们已经学会了如何做到这一点。使用通道,我们可以启动一个新的 Goroutine,并处理来自通道的传入结果。

但在这种情况下,我们将不得不处理结果(字符串或错误),而我们不希望这样做。相反,我们将定义成功时要执行的操作,以及出现错误时要执行的操作,并且忘记 Goroutine。

验收标准

对于这个任务,我们没有功能性需求。相反,我们将对其进行技术要求:

  • 将函数执行委托给不同的 Goroutine

  • 函数将返回一个字符串(也许)或一个错误

  • 处理程序必须在执行函数之前已经定义好

  • 设计必须是可重用的

单元测试

所以,正如我们提到的,我们将使用一等函数来实现这种行为,我们将需要三种特定类型的函数:

  • type SuccessFunc func(string): 如果一切顺利,SuccessFunc 函数将被执行。它的字符串参数将是操作的结果,因此我们的 Goroutine 将调用这个函数。

  • type FailFunc func(error): FailFunc 函数处理相反的结果,也就是当出现问题时,正如你所见,它会返回一个错误。

  • type ExecuteStringFunc func() (string, error): 最后,ExecuteStringFunc 函数是一个类型,定义了我们想要执行的操作。也许它会返回一个字符串或一个错误。如果这一切看起来令人困惑,不要担心;稍后会更清楚。

因此,我们创建了 future 对象,定义了成功的行为,定义了失败的行为,并传递了一个要执行的 ExecuteStringFunc 类型。在实现文件中,我们需要一个新类型:

type MaybeString struct {} 

我们还将在 _test.go 文件中创建两个测试:

package future 

import ( 
  "errors" 
  "testing" 
  "sync" 
) 

func TestStringOrError_Execute(t *testing.T) { 
  future := &MaybeString{} 
  t.Run("Success result", func(t *testing.T) { 
    ... 
  }) 
  t.Run("Error result", func(t *testing.T) { 
  ... 
  }) 
} 

我们将通过链接定义函数,就像你通常在 Node.js 中看到的那样。这样的代码紧凑而且不难跟踪:

t.Run("Success result", func(t *testing.T) { 
 future.Success(func(s string) {
 t.Log(s)
 }).Fail(func(e error) {
 t.Fail()
 })
 future.Execute(func() (string, error) {
 return "Hello World!", nil
 }) 
}) 

future.Success 函数必须在 MaybeString 结构中定义,以接受一个 SuccessFunc 函数,如果一切正常则执行,并返回相同指针给 future 对象(这样我们就可以继续链式调用)。Fail 函数也必须在 MaybeString 结构中定义,并且必须接受一个 FailFunc 函数,然后返回指针。我们在两种情况下都返回指针,这样我们就可以定义 FailSuccess 或者反之亦然。

最后,我们使用 Execute 方法传递一个 ExecuteStringFunc 类型(一个接受空参数并返回一个字符串或一个错误的函数)。在这种情况下,我们返回一个字符串和 nil,所以我们期望 SuccessFunc 函数将被执行,并且我们将结果记录到控制台。如果执行了失败函数,那么测试就失败了,因为不应该为返回的 nil 错误执行 FailFunc 函数。

但我们还缺少一些东西。我们说过函数必须在不同的 Goroutine 中异步执行,所以我们必须以某种方式同步这个测试,以防它结束得太快。同样,我们可以使用一个通道或 sync.WaitGroup

t.Run("Success result", func(t *testing.T) { 
 var wg sync.WaitGroup
 wg.Add(1) 
    future.Success(func(s string) { 
      t.Log(s) 

 wg.Done() 
    }).Fail(func(e error) { 
      t.Fail() 

 wg.Done() 
    }) 

    future.Execute(func() (string, error) { 
      return "Hello World!", nil 
    }) 
 wg.Wait() 
  }) 

我们之前在之前的频道中见过 WaitGroups。这个 WaitGroup 被配置为等待一个信号(wg.Add(1))。SuccessFail 方法将触发 WaitGroupDone() 方法,以允许执行继续并完成测试(这就是为什么 Wait() 方法在最后)。记住,每个 Done() 方法都会从 WaitGroup 中减去一个,而我们只添加了一个,所以我们的 Wait() 方法只会阻塞,直到一个 Done() 方法被执行。

利用我们对制作 Success 结果单元测试的了解,通过将错误调用 t.Fail() 方法从错误调用成功调用中交换,很容易制作一个失败的结果单元测试,这样如果调用成功,则测试失败:

t.Run("Failed result", func(t *testing.T) { 
 var wg sync.WaitGroup
 wg.Add(1)
 future.Success(func(s string) {
 t.Fail()
 wg.Done()
 }).Fail(func(e error) {
 t.Log(e.Error())
 wg.Done()
 })
 future.Execute(func() (string, error) {
 return "", errors.New("Error ocurred")
 })
 wg.Wait() 
}) 

如果你像我一样使用 IDE,你的 SuccessFailExecute 方法调用必须是红色的。这是因为我们在实现文件中缺少了方法的声明:

package future 

type SuccessFunc func(string) 
type FailFunc func(error) 
type ExecuteStringFunc func() (string, error) 

type MaybeString struct { 
  ... 
} 

func (s *MaybeString) Success(f SuccessFunc) *MaybeString { 
  return nil 
} 

func (s *MaybeString) Fail(f FailFunc) *MaybeString { 
  return nil 
} 

func (s *MaybeString) Execute(f ExecuteStringFunc) { 
  ... 
} 

我们的测试似乎已经准备好执行了。让我们试一下:

go test -v .
=== RUN   TestStringOrError_Execute
=== RUN   TestStringOrError_Execute/Success_result
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
testing.(*T).Run(0xc4200780c0, 0x5122e9, 0x19, 0x51d750, 0xc420041d30)
 /usr/lib/go/src/testing/testing.go:647 +0x316
testing.RunTests.func1(0xc4200780c0)
 /usr/lib/go/src/testing/testing.go:793 +0x6d
testing.tRunner(0xc4200780c0, 0xc420041e20)
 /usr/lib/go/src/testing/testing.go:610 +0x81
testing.RunTests(0x51d758, 0x5931e0, 0x1, 0x1, 0x50feb4)
 /usr/lib/go/src/testing/testing.go:799 +0x2f5
testing.(*M).Run(0xc420041ee8, 0xc420014550)
 /usr/lib/go/src/testing/testing.go:743 +0x85
main.main()
 go-design-patterns/future/_test/_testmain.go:54 +0xc6
...continue

嗯... 测试失败了,是的... 但不是以可控的方式。为什么呢?因为我们还没有任何实现,所以SuccessFail函数也没有被执行。我们的 WaitGroup 永远在等待调用Done()方法,但这个调用永远不会到来,所以它无法继续并完成测试。这就是所有 Goroutines 都在休眠-死锁的含义。在我们的具体例子中,这意味着没有人会调用 Done(),所以我们已经死了

注意

由于 Go 编译器和运行时执行器,我们可以轻松地检测死锁。想象一下,如果 Go 运行时无法检测死锁,我们将陷入一片空白的屏幕中,不知道出了什么问题。

那么我们该如何解决这个问题呢?嗯,一个简单的方法是使用超时,在等待一段时间后调用Done()方法。对于这段代码来说,等待 1 秒是安全的,因为它不执行长时间运行的操作。

我们将在我们的test文件中声明一个timeout函数,等待一秒,然后打印一条消息,将测试设置为失败,并通过调用其Done()方法让 WaitGroup 继续:

func timeout(t *testing.T, wg *sync.WaitGroup) { 
  time.Sleep(time.Second) 
  t.Log("Timeout!") 

  t.Fail() 
  wg.Done() 
} 

每个子测试的最终外观与我们之前的"Success result"示例类似:

t.Run("Success result", func(t *testing.T) { 
  var wg sync.WaitGroup 
  wg.Add(1) 

  //Timeout! 
  go timeout(t, wg) 
  // ... 
}) 

让我们看看当我们再次执行测试时会发生什么:

go test -v .
=== RUN   TestStringOrError_Execute
=== RUN   TestStringOrError_Execute/Success_result
=== RUN   TestStringOrError_Execute/Failed_result
--- FAIL: TestStringOrError_Execute (2.00s)
 --- FAIL: TestStringOrError_Execute/Success_result (1.00s)
 future_test.go:64: Timeout!
 --- FAIL: TestStringOrError_Execute/Failed_result (1.00s)
 future_test.go:64: Timeout!
FAIL
exit status 1
FAIL

我们的测试失败了,但是以一种可控的方式。看一下FAIL行的末尾--注意经过的时间是 1 秒,因为它被超时触发了,正如我们在日志消息中所看到的。

现在是时候转向实现了。

实现

根据我们的测试,实现必须以链式方式在MaybeString类型中接受SuccessFuncFailFuncExecuteStringFunc函数,并异步调用ExecuteStringFunc函数,根据返回的结果调用SuccessFuncFailFunc函数。

链式调用是通过在类型中存储函数并返回类型的指针来实现的。当然,我们谈论的是我们之前声明的类型方法:

type MaybeString struct { 
  successFunc SuccessFunc 
  failFunc    FailFunc 
} 

func (s *MaybeString) Success(f SuccessFunc) *MaybeString { 
  s.successFunc = f 
  return s 
} 

func (s *MaybeString) Fail(f FailFunc) *MaybeString { 
  s.failFunc = f 
  return s 
} 

我们需要两个字段来存储SuccessFuncFailFunc函数,它们分别被命名为successFuncfailFunc字段。这样,对SuccessFail方法的调用只是将它们的传入函数存储到我们的新字段中。它们只是返回特定MaybeString值的指针的设置器。这些类型方法接受MaybeString结构的指针,所以在func声明之后不要忘记在MaybeString上加上"*"。

Execute 方法接受ExecuteStringFunc方法并异步执行它。这似乎很简单,使用 Goroutine 就可以了,对吧?

func (s *MaybeString) Execute(f ExecuteStringFunc) { 
  go func(s *MaybeString) { 
    str, err := f() 
    if err != nil { 
      s.failFunc(err) 
    } else { 
      s.successFunc(str) 
    } 
  }(s) 
} 

看起来很简单,因为它确实很简单!我们启动了执行f方法(一个ExecuteStringFunc)的 Goroutine,并获取它的结果--可能是一个字符串,也可能是一个错误。如果存在错误,我们调用MaybeString结构中的failFunc字段。如果没有错误,我们调用successFunc字段。我们使用 Goroutine 来委托函数执行和错误处理,这样我们的 Goroutine 就不必自己处理它。

现在让我们运行单元测试:

go test -v .
=== RUN   TestStringOrError_Execute
=== RUN   TestStringOrError_Execute/Success_result
=== RUN   TestStringOrError_Execute/Failed_result
--- PASS: TestStringOrError_Execute (0.00s)
 --- PASS: TestStringOrError_Execute/Success_result (0.00s)
 future_test.go:21: Hello World!
 --- PASS: TestStringOrError_Execute/Failed_result (0.00s)
 future_test.go:49: Error ocurred
PASS
ok 

太好了!看看执行时间现在几乎为零,所以我们的超时没有被执行(实际上,它们已经被执行了,但测试已经完成,它们的结果已经被说明)。

而且,现在我们可以使用我们的MaybeString类型来异步执行任何接受空参数并返回字符串或错误的函数。接受空参数的函数似乎有点无用,对吧?但是我们可以使用闭包将上下文引入到这种类型的函数中。

让我们编写一个setContext函数,它接受一个字符串作为参数,并返回一个ExecuteStringFunc方法,该方法返回前面的参数加上后缀Closure!

func setContext(msg string) ExecuteStringFunc { 
  msg = fmt.Sprintf("%d Closure!\n", msg) 

  return func() (string, error){ 
    return msg, nil 
  } 
} 

因此,我们可以编写一个使用这个闭包的新测试:

t.Run("Closure Success result", func(t *testing.T) { 
    var wg sync.WaitGroup 
    wg.Add(1) 
    //Timeout! 
    go timeout(t, &wg) 

    future.Success(func(s string) { 
      t.Log(s) 
      wg.Done() 
    }).Fail(func(e error) { 
      t.Fail() 
      wg.Done() 
    }) 
    future.Execute(setContext("Hello")) 
    wg.Wait() 
  }) 

setContext函数返回一个ExecuteStringFunc方法,它可以直接传递给Execute函数。我们使用一个我们知道会返回的任意文本来调用setContext函数。

让我们再次执行我们的测试。现在一切都要顺利进行!

go test -v .
=== RUN   TestStringOrError_Execute
=== RUN   TestStringOrError_Execute/Success_result
=== RUN   TestStringOrError_Execute/Failed_result
=== RUN   TestStringOrError_Execute/Closure_Success_result
--- PASS: TestStringOrError_Execute (0.00s)
 --- PASS: TestStringOrError_Execute/Success_result (0.00s)
 future_test.go:21: Hello World!
 --- PASS: TestStringOrError_Execute/Failed_result (0.00s)
 future_test.go:49: Error ocurred
 --- PASS: TestStringOrError_Execute/Closure_Success_result (0.00s)
 future_test.go:69: Hello Closure!
PASS
ok

它也给了我们一个 OK。闭包测试显示了我们之前解释的行为。通过取一个消息"Hello"并将其附加到其他内容("Closure!"),我们可以改变我们想要返回的文本的上下文。现在将其扩展到 HTTP GET调用,对数据库的调用,或者您可以想象的任何其他内容。它只需要以返回一个字符串或错误的方式结束。但是请记住,setContext函数中除了我们要返回的匿名函数之外的所有内容都不是并发的,并且在调用 execute 之前将异步执行,因此我们必须尽量将尽可能多的逻辑放在匿名函数中。

将未来放在一起

我们已经看到了通过使用函数类型系统来实现异步编程的好方法。然而,我们也可以通过设置一个具有SuccessFailExecute方法以及满足它们的类型的接口来实现,然后使用模板模式来异步执行它们,正如我们在本章中之前看到的那样。这取决于你!

Pipeline 设计模式

我们将在本章中看到的第三种模式是 Pipeline 模式。您将在并发结构中大量使用此模式,我们也可以认为它是最有用的模式之一。

描述

我们已经知道了管道是什么。每当我们编写任何执行一些逻辑的函数时,我们都在编写一个管道:如果这样,那么那样,否则其他。通过使用一些相互调用的函数,管道模式可以变得更加复杂。它们甚至可以在它们的执行中被循环。

Go 中的 Pipeline 模式以类似的方式工作,但 Pipeline 中的每个步骤都将在不同的 Goroutine 中进行,并且使用通道进行通信和同步。

目标

创建 Pipeline 时,我们主要寻求以下好处:

  • 我们可以创建一个多步算法的并发结构

  • 我们可以通过将算法分解为不同的 Goroutines 来利用多核机器的并行性

然而,仅仅因为我们将算法分解为不同的 Goroutines 并不一定意味着它会执行得最快。我们一直在谈论 CPU,所以理想情况下,算法必须是 CPU 密集型的,以利用并发结构。创建 Goroutines 和通道的开销可能会使算法变得更小。

并发多操作

我们将为我们的示例进行一些数学运算。我们将生成一个从 1 开始,以某个任意数字 N 结束的数字列表。然后我们将取每个数字,将其平方,并将结果数字求和到一个唯一的结果。因此,如果N=3,我们的列表将是[1,2,3]。将它们平方后,我们的列表变为[1,4,9]。如果我们对结果列表求和,得到的值是 14。

验收标准

从功能上讲,我们的 Pipeline 模式需要将每个数字提升到 2 的幂,然后将它们全部求和。它将被分为一个数字生成器和两个操作,因此:

  1. 从 1 到 N 生成一个列表,其中 N 可以是任何整数。

  2. 取出这个生成列表的每个数字并将其提升到 2 的幂。

  3. 将每个结果数字求和到最终结果并返回它。

从测试开始

我们将创建一个管理一切的函数。我们将称这个函数为LaunchPipeline以简化事情。它将以一个整数作为参数,这将是我们的 N 数字,列表中的项目数。在实现文件中的声明如下:

package pipelines 

func LaunchPipeline(amount int) int { 
  return 0 
} 

在我们的测试文件中,我们将使用一个切片的切片创建一个测试表:

package pipelines 

import "testing" 

func TestLaunchPipeline(t *testing.T) { 
  tableTest := [][]int{ 
    {3, 14}, 
    {5, 55}, 
  } 
  // ... 
} 

我们的表是一个整数类型的切片的切片。在每个切片上,第一个整数代表列表大小,第二个位置代表列表中的项。实际上,它是一个矩阵。当传入 3 时,它必须返回 14。当传入 5 时,它必须返回 55。然后,我们必须遍历表,并将每个数组的第一个索引传递给LaunchPipeline函数:

  // ... 

  var res int 
  for _, test := range tableTest { 
    res = LaunchPipeline(test[0]) 
    if res != test[1] { 
      t.Fatal() 
    } 

    t.Logf("%d == %d\n", res, test[1]) 
  } 
} 

使用range,我们得到矩阵中的每一行。每一行都包含在一个临时变量test中。test[0]代表Ntest[1]代表预期结果。我们将预期结果与LaunchPipeline函数的返回值进行比较。如果它们不相同,测试就失败了。

go test -v .
=== RUN   TestLaunchPipeline
--- FAIL: TestLaunchPipeline (0.00s)
 pipeline_test.go:15: 
FAIL
exit status 1
FAIL

实施

我们实现的关键是将每个操作分开放在不同的 Goroutine 中,并用通道连接它们。LaunchPipeline函数是协调它们所有的函数,如下图所示:

Implementation

这个操作包括三个步骤:生成一个数字列表,将它们提升到 2 的幂,并将结果相加。

这个管道模式中的每一步都有以下结构:

func functionName(in <-chan int) (<-chan int){ 
  out := make(chan bool, 100) 

  go func(){ 
    for v := range in { 
      // Do something with v and send it to channel out 
} 

close(out) 
   }() 

  return out 
} 

这个函数代表一个常见的步骤。让我们按照 Go 调度程序可能执行它的顺序来分解它:

  1. functionName函数通常会接收一个通道来获取值(in <-chan int)。我们称之为in函数,就像单词 incoming 一样。在这个函数的范围内,我们无法通过它发送值;这就是为什么箭头指向chan关键字的外部。

  2. functionName函数返回一个通道(<-chan in),函数调用者只能从中获取值(同样,箭头指向chan关键字的外部)。这也意味着通过该通道传递的任何值都必须在函数范围内生成。

  3. 在函数的第一行,我们创建了一个名为out的通道,它将成为函数的返回值(在这个列表中的第 2 个点)。

  4. 然后,我们将启动一个新的 Goroutine。它的范围将在返回此函数后进入,所以让我们继续。

  5. 我们返回先前创建的out通道。

  6. 最终,在执行函数并返回通道out之后,Goroutine 执行。它将从in通道中获取值,直到通道关闭。因此,这个函数的调用者负责关闭这个通道,否则 Goroutine 将永远不会结束!

  7. in通道关闭时,for 循环结束,我们关闭out通道。任何使用这个通道的 Goroutine 都不会再接收到新值,因为最后一个值已经发送了。

唯一不完全符合这种方法的步骤是第一步,它接收一个数字,代表列表上限,而不是一个传入值的通道。因此,如果我们为管道中的每一步编写这个操作,最终的图表看起来更像这样:

Implementation

尽管想法完全相同,但现在我们可以看到,实际上是LaunchPipeline函数将接收通道并将它们发送回管道中的下一步。使用这个图表,我们可以清楚地看到通过跟随箭头的数字来创建管道的流程。实线箭头表示函数调用,虚线箭头表示通道。

让我们更仔细地看一下代码。

列表生成器

操作的第一步是列表生成。列表从1开始,我们将接收一个代表上限的整数。我们必须将列表中的每个数字传递到下一步:

func generator(max int) <-chan int { 
  outChInt := make(chan int, 100) 

  go func() { 
    for i := 1; i <= max; i++ { 
      outChInt <- i 
    } 

    close(outChInt) 
  }() 
  return outChInt 
} 

正如我们之前提到的,这是我们在每个步骤中遵循的模式:创建一个通道,启动通过通道发送数据的 Goroutine,并立即返回通道。这个 Goroutine 将从 1 迭代到 max 参数,这是我们列表的最高阈值,并通过通道发送每个数字。在发送每个数字之后,通道被关闭,以便不能再通过它发送更多数据,但已经缓冲的数据可以被检索。

将数字提升到 2 的幂

第二步将从第一步的通道中接收每个传入的数字(从参数中获取)并将其提升到 2 的幂。每个结果都必须使用新的通道发送到第三步:

func power(in <-chan int) <-chan int { 
  out := make(chan int, 100) 

  go func() { 
    for v := range in { 
      out <- v * v 
    } 
    close(out) 
  }() 
  return out 
} 

我们再次使用相同的模式:创建一个通道并启动 Goroutine,同时返回创建的通道。

注意

for-range循环不断地从通道中获取值,直到通道关闭。

最终的归约操作

第三步也是最后一步,接收来自第二步的每个数字,并将它们添加到本地值,直到连接通道关闭:

func sum(in <-chan int) <-chan int { 
  out := make(chan int, 100) 
  go func() { 
    var sum int 

    for v := range in { 
      sum += v 
    } 

    out <- sum 
    close(out) 
  }()

  return out 
} 

sum 函数还接受一个通道作为参数(从步骤 2返回的通道)。它也遵循创建通道、启动 Goroutine 和返回通道的相同模式。Goroutine 不断将值添加到一个名为sum的变量,直到in通道关闭。当in通道关闭时,sum 的值被发送到out通道,并立即关闭。

启动管道模式

最后,我们可以实现LaunchPipeline函数:

func LaunchPipeline(amount int) int { 
  firstCh := generator(amount) 
  secondCh := power(firstCh) 
  thirdCh := sum(secondCh) 

  result := <-thirdCh 

  return result 
} 

函数generator首先返回传递给 power 函数的通道。power函数返回传递给sum函数的第二个通道。函数sum最终返回将接收唯一值(结果)的第一个通道。现在让我们尝试测试一下:

go test -v .
=== RUN   TestLaunchPipeline
--- PASS: TestLaunchPipeline (0.00s)
 pipeline_test.go:18: 14 == 14
 pipeline_test.go:18: 55 == 55
PASS
ok

太棒了!值得一提的是,LaunchPipeline函数不需要分配每个通道,并且可以重写如下:

func LaunchPipeline(amount int) int { 
  return <-sum(power(generator(amount))) 
} 

generator函数的结果直接传递给power函数,power的结果传递给sum函数。

管道模式的最后一句话

使用管道模式,我们可以以非常简单的方式创建非常复杂的并发工作流程。在我们的案例中,我们创建了一个线性工作流,但它也可以有条件、池和扇入和扇出行为。我们将在接下来的章节中看到其中一些。

总结

并发设计模式是难度上的一大步,并需要一些时间来掌握。作为并发程序员,我们最大的错误是以并行性的方式思考(我怎样才能并行?或者我怎样才能在一个新线程中运行?),而不是以并发结构的方式思考。

纯函数(在其范围之外不会影响任何东西的函数,将始终产生相同的输出(给定相同的输入))对此设计有所帮助。

并发编程需要练习和更多的练习。一旦你理解了基本的原语,Go 就会变得很容易。图表可以帮助你理解数据的可能流动,但理解一切的最好方法就是简单地练习。

在接下来的章节中,我们将看到如何使用管道工作池来执行一些工作,而不是使用唯一的管道。此外,我们将学习如何在并发结构中创建发布/订阅模式,并看到在使用并发构建相同模式时,相同模式可以有多大的不同。

第十章:并发模式 - 工作者池和发布/订阅设计模式

我们已经到达了本书的最后一章,在这里我们将讨论一些具有并发结构的模式。我们将详细解释每一步,以便您可以仔细跟随示例。

这个想法是学习如何在 Go 中设计并发应用程序的模式。我们大量使用通道和 Goroutines,而不是锁或共享变量。

  • 我们将看一种开发工作者池的方法。这对于控制执行中的 Goroutines 数量非常有用。

  • 第二个例子是对观察者模式的重写,我们在第七章中看到了,行为模式 - 访问者、状态、中介者和观察者设计模式,使用并发结构编写。通过这个例子,我们将更深入地了解并发结构,并看看它们与常规方法有何不同。

工作者池

我们可能会遇到的一个问题是,以前的一些并发方法的上下文是无限的。我们不能让一个应用程序创建无限数量的 Goroutines。Goroutines 很轻,但它们执行的工作可能非常繁重。工作者池帮助我们解决了这个问题。

描述

通过一组工作者,我们希望限制可用的 Goroutines 数量,以便更深入地控制资源池。通过为每个工作者创建一个通道,并使工作者处于空闲或繁忙状态,这很容易实现。这项任务可能看起来令人生畏,但实际上并不是。

目标

创建一个工作者池主要是关于资源控制:CPU、RAM、时间、连接等等。工作者池设计模式帮助我们做到以下几点:

  • 使用配额控制对共享资源的访问

  • 为每个应用程序创建有限数量的 Goroutines

  • 为其他并发结构提供更多的并行能力

一组管道

在上一章中,我们看到了如何使用管道。现在我们将启动有限数量的管道,以便 Go 调度器可以尝试并行处理请求。这里的想法是控制 Goroutines 的数量,在应用程序完成时优雅地停止它们,并使用并发结构最大化并行性,而不会出现竞争条件。

我们将使用的管道类似于我们在上一章中使用的管道,那里我们生成数字,将它们提升到 2 的幂,并求和最终结果。在这种情况下,我们将传递字符串,然后附加和前缀数据。

验收标准

从业务角度来看,我们希望有一些东西告诉我们,工作者已经处理了一个请求,有一个预定义的结尾,并且传入的数据被解析为大写:

  1. 当使用一个字符串值(任意)发出请求时,它必须是大写的。

  2. 一旦字符串变成大写,预定义的文本必须附加到它上面。这个文本不应该是大写的。

  3. 使用前面的结果,工作者 ID 必须添加到最终字符串的前缀。

  4. 结果字符串必须传递给预定义的处理程序。

我们还没有讨论如何在技术上实现,只是业务需求。通过整个描述,我们至少会有工作者、请求和处理程序。

实施

最开始是一个请求类型。根据描述,它必须包含将进入管道的字符串以及处理程序函数:

   // workers_pipeline.go file 
    type Request struct { 
          Data    interface{} 
          Handler RequestHandler 
    } 

“字符串”在哪里?我们有一个Data字段,类型为interface{},所以我们可以用它来传递一个字符串。通过使用接口,我们可以重用这种类型来传递一个string,一个int,或一个struct数据类型。接收者必须知道如何处理传入的接口。

“处理程序”字段的类型是“请求”处理程序,我们还没有定义:

type RequestHandler func(interface{}) 

请求处理程序是接受接口作为其第一个参数并返回空的任何函数。再次看到interface{},在这里我们通常会看到一个字符串。这是我们之前提到的接收器之一,我们需要用它来转换传入的结果。

因此,当发送请求时,我们必须在Data字段中填充一些值并实现处理程序;例如:

func NewStringRequest(s string, id int, wg *sync.WaitGroup) Request { 
    myRequest := Request{ 
        Data: "Hello", Handler: func(i interface{})
        { 
            defer wg.Done() 
            s, ok := i.(string) 
                if !ok{ 
                    log.Fatal("Invalid casting to string") 
                 } 
             fmt.Println(s) 
         } 
    } 
} 

处理程序是通过闭包定义的。我们再次检查接口的类型(并在最后推迟调用Done()方法)。如果接口不正确,我们只需打印其内容并返回。如果转换正确,我们也会打印它们,但这是我们通常会对操作结果做一些事情的地方;我们必须使用类型转换来检索interface{}的内容(这是一个字符串)。尽管这会引入一些开销,但这必须在管道的每一步中完成。

现在我们需要一个可以处理Request类型的类型。可能的实现方式几乎是无限的,因此最好先定义一个接口:

   // worker.go file 
    type WorkerLauncher interface { 
        LaunchWorker(in chan Request) 
    } 

WorkerLauncher接口只需实现LaunchWorker(chan Request)方法。任何实现此接口的类型都必须接收一个Request类型的通道来满足它。这种类型的Request通道是管道的唯一入口点。

调度程序

现在,为了并行启动工作程序并处理所有可能的传入通道,我们需要类似于调度程序的东西:

   // dispatcher.go file 
    type Dispatcher interface { 
        LaunchWorker(w WorkerLauncher) 
        MakeRequest(Request) 
        Stop() 
    } 

Dispatcher接口可以在其自己的LaunchWorker方法中启动注入的WorkerLaunchers类型。Dispatcher接口必须使用任何WorkerLauncher类型的LaunchWorker方法来初始化管道。这样我们就可以重用Dispatcher接口来启动许多类型的WorkerLaunchers

在使用MakeRequest(Request)时,Dispatcher接口公开了一个很好的方法,可以将新的Request注入到工作池中。

最后,用户必须在所有 Goroutines 都完成时调用 stop。我们必须在应用程序中处理优雅的关闭,并且我们希望避免 Goroutine 泄漏。

我们有足够的接口,所以让我们从稍微不那么复杂的调度程序开始:

    type dispatcher struct { 
        inCh chan Request 
    } 

我们的dispatcher结构在其字段中存储了一个Request类型的通道。这将是任何管道中请求的唯一入口点。我们说它必须实现三种方法,如下所示:

    func (d *dispatcher) LaunchWorker(id int, w WorkerLauncher) { 
        w.LaunchWorker(d.inCh) 
    } 

    func (d *dispatcher) Stop(){ 
        close(d.inCh) 
    } 

    func (d *dispatcher) MakeRequest(r Request) { 
        d.inCh <- r 
    } 

在这个例子中,Dispatcher接口在启动工作程序之前不需要对自身执行任何特殊操作,因此Dispatcher上的LaunchWorker方法只是执行传入WorkerLauncherLaunchWorker方法,后者也有一个LaunchWorker方法来初始化自身。我们之前已经定义了WorkerLauncher类型至少需要一个 ID 和一个传入请求的通道,所以这就是我们传递的内容。

Dispatcher接口中实现LaunchWorker方法似乎是不必要的。在不同的场景中,保存运行中的工作程序 ID 在调度程序中控制哪些工作程序正在运行或关闭可能是有趣的;这个想法是隐藏启动实现细节。在这种情况下,Dispatcher接口只是作为一个 Facade 设计模式,隐藏了一些实现细节。

第二种方法是Stop。它关闭了传入请求通道,引发了一连串的反应。我们在管道示例中看到,当关闭传入通道时,每个 Goroutines 中的 for-range 循环都会中断,并且 Goroutine 也会结束。在这种情况下,当关闭共享通道时,它会引发相同的反应,但是在每个监听 Goroutine 中,因此所有管道都将停止。酷,对吧?

请求的实现非常简单;我们只需将请求作为参数传递给传入请求的通道。Goroutine 将永远阻塞在那里,直到通道的另一端检索到请求。永远?如果发生了什么事情,这似乎很长。我们可以引入一个超时,如下所示:

    func (d *dispatcher) MakeRequest(r Request) { 
        select { 
        case d.inCh <- r: 
        case <-time.After(time.Second * 5): 
            return 
        } 
    } 

如果你还记得之前的章节,我们可以使用 select 来控制对通道的操作。就像 switch 语句一样,只能执行一个操作。在这种情况下,我们有两种不同的操作:发送和接收。

第一种情况是发送操作——尝试发送这个,它将在那里阻塞,直到有人在通道的另一侧取走值。然后并没有太大的改进。第二种情况是接收操作;如果无法成功发送大写请求,它将在 5 秒后触发,并返回。在这里返回错误会非常方便,但为了简单起见,我们将留空。

最后,在调度程序中,为了方便起见,我们将定义一个 Dispatcher 创建者:

    func NewDispatcher(b int) Dispatcher { 
        return &dispatcher{ 
            inCh:make(chan Request, b), 
        } 
    } 

通过使用这个函数而不是手动创建调度程序,我们可以简单地避免小错误,比如忘记初始化通道字段。正如你所看到的,b 参数指的是通道中的缓冲区大小。

流水线

所以,我们的调度程序已经完成,我们需要开发验收标准中描述的流水线。首先,我们需要一个类型来实现 WorkerLauncher 类型:

   // worker.go file 
    type PreffixSuffixWorker struct { 
        id int 
        prefixS string 
        suffixS string 
    } 

    func (w *PreffixSuffixWorker) LaunchWorker(i int, in chan Request) {} 

PreffixSuffixWorker 变量存储一个 ID,一个要添加的字符串,以及 Request 类型的传入数据的另一个字符串。因此,要添加的值和后缀将在这些字段中是静态的,并且我们将从这里获取它们。

我们将稍后实现 LaunchWorker 方法,并从流水线的每一步开始。根据第一个验收标准,传入的字符串必须是大写。因此,大写方法将是我们流水线中的第一步:

    func (w *PreffixSuffixWorker) uppercase(in <-chan Request) <-chan Request { 
        out := make(chan Request) 

        go func() { 
            for msg := range in { 
                s, ok := msg.Data.(string) 

                if !ok { 
                    msg.handler(nil) 
                    continue 
                } 

                msg.Data = strings.ToUpper(s) 

                out <- msg 
            } 

            close(out) 
        }() 

        return out 
    } 

很好。就像在前一章中一样,流水线中的一步接受传入数据的通道,并返回相同类型的通道。它的方法与我们在前一章中开发的示例非常相似。不过,这一次,我们没有使用包函数,大写是 PreffixSuffixWorker 类型的一部分,传入的数据是一个 struct 而不是一个 int

msg 变量是一个 Request 类型,它将具有一个处理函数和一个接口形式的数据。Data 字段应该是一个字符串,所以我们在使用它之前对其进行类型转换。当对值进行类型转换时,我们将收到请求类型的相同值和一个 truefalse 标志(由 ok 变量表示)。如果 ok 变量为 false,则转换无法完成,我们将不会将该值传递到流水线中。我们通过向处理程序发送 nil 来停止这个 Request(这也会引发类型转换错误)。

一旦我们在 s 变量中有一个好的字符串,我们就可以将其大写并再次存储在 Data 字段中,以便将其发送到流水线的下一步。请注意,该值将再次作为接口发送,因此下一步将需要再次对其进行转换。这是使用这种方法的缺点。

第一步完成后,让我们继续进行第二步。根据第二个验收标准,现在必须添加预定义的文本。这个文本是存储在 suffixS 字段中的文本:

func (w *PreffixSuffixWorker) append(in <-chan Request) <-chan Request { 
    out := make(chan Request) 
    go func() { 
        for msg := range in { 
        uppercaseString, ok := msg.Data.(string) 

        if !ok { 
            msg.handler(nil) 
            continue 
            } 
        msg.Data = fmt.Sprintf("%s%s", uppercaseString, w.suffixS) 
        out <- msg 
        } 
        close(out) 
    }() 
    return out 
} 

append 函数的结构与 uppercase 函数相同。它接收并返回一个传入请求的通道,并启动一个新的 Goroutine,该 Goroutine 遍历传入的通道直到它关闭。我们需要对传入的值进行类型转换,如前所述。

在流水线中的这一步中,传入的字符串是大写的(在进行类型断言后)。要附加任何文本,我们只需要使用 fmt.Sprintf() 函数,就像我们之前做过很多次一样,它使用提供的数据格式化一个新的字符串。在这种情况下,我们将 suffixS 字段的值作为第二个值传递,以将其附加到字符串的末尾。

流水线中只缺少最后一步,即前缀操作:

    func (w *PreffixSuffixWorker) prefix(in <-chan Request) { 
        go func() { 
            for msg := range in { 
                uppercasedStringWithSuffix, ok := msg.Data.(string) 

                if !ok { 
                    msg.handler(nil) 
                    continue 
                } 

                msg.handler(fmt.Sprintf("%s%s", w.prefixS, uppercasedStringWithSuffix)) 
            } 
        }() 
    } 

这个函数中吸引你注意力的是什么?是的,它现在不返回任何通道。我们可以以两种方式完成整个管道。我想你已经意识到我们使用了Future处理函数来执行管道中的最终结果。第二种方法是传递一个通道将数据返回到其原始位置。在某些情况下,Future 就足够了,而在其他情况下,将通道传递可能更方便,以便它可以连接到不同的管道(例如)。

无论如何,管道中的一步的结构对您来说应该已经非常熟悉了。我们对值进行转换,检查转换的结果,并在出现问题时向处理程序发送 nil。但是,如果一切顺利,最后要做的就是再次格式化文本,将prefixS字段放在文本开头,通过调用请求的处理程序将生成的字符串发送回原始位置。

现在,几乎完成了我们的工作程序,我们可以实现LaunchWorker方法:

    func (w *PreffixSuffixWorker) LaunchWorker(in chan Request) { 
        w.prefix(w.append(w.uppercase(in))) 
    } 

工作程序就是这些了!我们只需将返回的通道传递给管道中的下一步,就像我们在上一章中所做的那样。请记住,管道是从调用内部到外部执行的。那么,任何传入管道的数据的执行顺序是什么?

  1. 数据通过uppercase方法中启动的 Goroutine 进入管道。

  2. 然后,它进入了在append中启动的 Goroutine。

  3. 最后,它进入了在prefix方法中启动的 Goroutine,它不返回任何东西,但在给传入的字符串加上更多数据后执行处理程序。

现在我们有了一个完整的管道和一个管道的分发器。分发器将启动我们想要的管道实例,将传入的请求路由到任何可用的工作程序。

如果没有任何工作程序在 5 秒内接受请求,请求将丢失。

让我们在一个小应用程序中使用这个库。

使用工作程序池的应用程序

我们将启动我们定义的管道的三个工作程序。我们使用NewDispatcher函数创建分发器和接收所有请求的通道。该通道具有固定的缓冲区,可以在阻塞之前存储多达 100 条传入消息:

   // workers_pipeline.go 
    func main() { 
        bufferSize := 100 
        var dispatcher Dispatcher = NewDispatcher(bufferSize) 

然后,我们将通过在Dispatcher接口中三次调用LaunchWorker方法,并使用已填充的WorkerLauncher类型来启动工作程序:

    workers := 3 
    for i := 0; i < workers; i++ { 
        var w WorkerLauncher = &PreffixSuffixWorker{ 
            prefixS: fmt.Sprintf("WorkerID: %d -> ", i), 
            suffixS: " World", 
            id:i, 
        } 
        dispatcher.LaunchWorker(w) 
    } 

每个WorkerLauncher类型都是PreffixSuffixWorker的一个实例。前缀将是一个显示工作程序 ID 和后缀文本world的小文本。

此时,我们有三个工作程序,每个都有三个 Goroutines,同时运行并等待消息到达:

    requests := 10 

    var wg sync.WaitGroup 
    wg.Add(requests) 

我们将发出 10 个请求。我们还需要一个 WaitGroup 来正确同步应用程序,以免过早退出。在处理并发应用程序时,您可能会经常使用 WaitGroups。对于 10 个请求,我们需要等待 10 次对Done()方法的调用,因此我们使用delta为 10 调用Add()方法。它被称为 delta,因为您稍后也可以传递-5 以使其在五个请求中保持。在某些情况下,这可能很有用:

    for i := 0; i < requests; i++ { 
        req := NewStringRequest("(Msg_id: %d) -> Hello", i, &wg) 
        dispatcher.MakeRequest(req) 
    } 

    dispatcher.Stop() 

    wg.Wait() 
}

为了发出请求,我们将迭代一个for循环。首先,我们使用在实现部分开头编写的NewStringRequest函数创建一个Request。在这个值中,Data字段将是我们将通过管道传递的文本,它将是附加和后缀操作的“中间”文本。在这种情况下,我们将发送消息编号和单词hello

一旦我们有一个请求,我们就用它调用MakeRequest方法。完成所有请求后,我们停止分发器,如前所述,这将引发一个连锁反应,将停止管道中的所有 Goroutines。

最后,我们等待组,以便接收对Done()方法的所有调用,这表明所有操作都已完成。是时候试一试了:

 go run *
 WorkerID: 1 -> (MSG_ID: 0) -> HELLO World
 WorkerID: 0 -> (MSG_ID: 3) -> HELLO World
 WorkerID: 0 -> (MSG_ID: 4) -> HELLO World
 WorkerID: 0 -> (MSG_ID: 5) -> HELLO World
 WorkerID: 2 -> (MSG_ID: 2) -> HELLO World
 WorkerID: 1 -> (MSG_ID: 1) -> HELLO World
 WorkerID: 0 -> (MSG_ID: 6) -> HELLO World
 WorkerID: 2 -> (MSG_ID: 9) -> HELLO World
 WorkerID: 0 -> (MSG_ID: 7) -> HELLO World
 WorkerID: 0 -> (MSG_ID: 8) -> HELLO World

让我们分析第一条消息:

  1. 这将是零,所以发送的消息是(Msg_id: 0) -> Hello

  2. 然后,文本被转换为大写,所以现在我们有(MSG_ID: 0) -> HELLO

  3. 在将文本world(注意文本开头的空格)转换为大写后进行附加操作。这将给我们文本(MSG_ID: 0) -> HELLO World

  4. 最后,文本WorkerID: 1(在这种情况下,第一个工作者接受了任务,但也可能是任何一个工作者)被附加到步骤 3 的文本中,给我们完整的返回消息,WorkerID: 1 -> (MSG_ID: 0) -> HELLO World

没有测试?

并发应用程序很难测试,特别是如果你正在进行网络操作。测试可能会很困难,代码可能会发生很大变化。无论如何,不进行测试是不可接受的。在这种情况下,测试我们的小应用并不特别困难。创建一个测试,将main函数的内容复制/粘贴到那里:

//workers_pipeline.go file 
package main 

import "testing" 

func Test_Dispatcher(t *testing.T){ 
    //pasted code from main function 
 bufferSize := 100
 var dispatcher Dispatcher = NewDispatcher(bufferSize)
 workers := 3
 for i := 0; i < workers; i++ 
    {
 var w WorkerLauncher = &PreffixSuffixWorker{
 prefixS: fmt.Sprintf("WorkerID: %d -> ", i), 
suffixS: " World", 
id: i,
}
 dispatcher.LaunchWorker(w)
 }
 //Simulate Requests
 requests := 10
 var wg 
    sync.WaitGroup
 wg.Add(requests) 
} 

现在我们必须重写我们的处理程序,以测试返回的内容是否符合我们的期望。转到for循环,修改我们作为每个Request处理程序传递的函数:

for i := 0; i < requests; i++ { 
    req := Request{ 
        Data: fmt.Sprintf("(Msg_id: %d) -> Hello", i), 
        handler: func(i interface{}) 
        { 
            s, ok := i.(string) 
            defer wg.Done() 
 if !ok 
            {
 t.Fail()
 }
 ok, err := regexp.Match(
`WorkerID\: \d* -\> \(MSG_ID: \d*\) -> [A-Z]*\sWorld`,
 []byte(s)) 
 if !ok || err != nil {
 t.Fail()
 } 
        }, 
    } 
    dispatcher.MakeRequest(req) 
} 

我们将使用正则表达式来测试业务。如果你不熟悉正则表达式,它们是一个非常强大的功能,可以帮助你在字符串中匹配内容。如果你还记得我们在练习中使用strings包时。Contains是用来在字符串中查找文本的函数。我们也可以用正则表达式来做。

问题在于正则表达式非常昂贵,消耗大量资源。

我们正在使用regexp包的Match函数来提供一个匹配模板。我们的模板是WorkerID\: \d* -> \(MSG_ID: \d\) -> [A-Z]*\sWorld(不带引号)。具体来说,它描述了以下内容:

  • 一个包含内容WorkerID: \d* -> (MSG_ID: \d*的字符串,这里的\d*表示任意数字写零次或多次,因此它将匹配WorkerID: 10 -> (MSG_ID: 1""WorkerID: 1 -> (MSG_ID: 10

  • "\) -> [A-Z]*\sWorld"(括号必须使用反斜杠进行转义)。"*"表示任意大写字母写零次或多次,所以"\s"是一个空格,它必须以文本World结束,所以) -> HELLO World"会匹配,但) -> Hello World"不会,因为"Hello必须全部大写。

运行这个测试给我们以下输出:

go test -v .
=== RUN   Test_Dispatcher
--- PASS: Test_Dispatcher (0.00s)
PASS
ok

不错,但我们没有测试代码是否在并发执行,所以这更像是业务测试而不是单元测试。并发测试会迫使我们以完全不同的方式编写代码,以检查它是否创建了适当数量的 Goroutines,并且流水线是否遵循了预期的工作流程。这并不是坏事,但它非常复杂,超出了本书的范围。

总结工作池

有了工作池,我们有了第一个可以在真实生产系统中使用的复杂并发应用程序。它还有改进的空间,但它是一个非常好的设计模式,可以构建并发有界应用程序。

非常重要的是,我们始终要控制正在启动的 Goroutines 的数量。虽然很容易启动成千上万个 Goroutines 来实现更多的并行性,但我们必须非常小心,确保它们没有代码会使它们陷入无限循环。

有了工作池,我们现在可以将一个简单操作分解为许多并行任务。想想看;这可以通过一次简单的fmt.Printf调用来实现相同的结果,但我们已经用它做了一个流水线;然后,我们启动了几个这样的流水线实例,最后,在所有这些管道之间分配了工作负载。

并发发布/订阅设计模式

在这一部分,我们将实现我们之前在行为模式中展示的观察者设计模式,但采用并发结构和线程安全。

描述

如果你还记得之前的解释,观察者模式维护了一个想要被通知特定事件的观察者或订阅者列表。在这种情况下,每个订阅者将在不同的 Goroutine 中运行,就像发布者一样。我们将在构建这个结构时遇到新的问题:

  • 现在,订阅者列表的访问必须是串行化的。如果我们用一个 Goroutine 读取列表,我们就不能从中删除一个订阅者,否则就会出现竞争。

  • 当一个订阅者被移除时,订阅者的 Goroutine 也必须被关闭,否则它将永远迭代下去,我们将遇到 Goroutine 泄漏的问题。

  • 当停止发布者时,所有订阅者也必须停止它们的 Goroutines。

目标

这个发布/订阅的目标与我们在观察者模式中写的目标相同。这里的区别在于我们将如何开发它。这个想法是创建一个并发结构来实现相同的功能,即:

  • 提供一个事件驱动的架构,其中一个事件可以触发一个或多个动作

  • 将执行的动作与触发它们的事件解耦

  • 提供多个触发相同动作的源事件

这个想法是解耦发送者和接收者,隐藏发送者处理其事件的接收者的身份,并隐藏接收者可以与之通信的发送者的数量。

特别是,如果我在某个应用程序的按钮上开发了一个点击,它可能会执行一些操作(比如在某处登录)。几周后,我们可能决定也让它显示一个弹出窗口。如果每次我们想要为这个按钮添加一些功能,我们都必须更改处理点击操作的代码,那么这个函数将变得非常庞大,而且对其他项目的可移植性也不是很好。如果我们为每个动作使用一个发布者和一个观察者,点击函数只需要使用一个发布者发布一个事件,每次我们想要改进功能时,我们只需要为这个事件编写订阅者。这在用户界面应用程序中尤为重要,因为在单个 UI 操作中要做的事情很多,可能会减慢界面的响应速度,完全破坏用户体验。

通过使用并发结构来开发观察者模式,如果定义了并发结构并且设备允许我们执行并行任务,UI 就无法感知到后台执行的所有任务。

示例 - 并发通知器

我们将开发一个类似于我们在第七章中开发的notifier行为模式 - 访问者、状态、中介者和观察者设计模式。这是为了专注于结构的并发性质,而不是详细说明已经解释过的太多东西。我们已经开发了一个观察者,所以我们对这个概念很熟悉。

这个特定的通知器将通过传递interface{}值来工作,就像在工作池示例中一样。这样,我们可以通过在接收器上进行转换来将其用于多个类型,引入一些开销。

现在我们将使用两个接口。首先是Subscriber接口:

    type Subscriber interface { 
        Notify(interface{}) error 
        Close() 
    } 

就像在之前的示例中一样,Subscriber接口必顶有一个Notify方法来通知新事件。这是接受interface{}值并返回错误的Notify方法。然而,Close()方法是新的,它必须触发停止订阅者正在监听新事件的 Goroutine 的任何操作。

第二个和最终的接口是Publisher接口:

    type Publisher interface { 
        start() 
        AddSubscriberCh() chan<- Subscriber 
        RemoveSubscriberCh() chan<- Subscriber 
        PublishingCh() chan<- interface{} 
        Stop() 
    } 

Publisher接口具有与发布者相同的操作,但是用于与通道一起工作。AddSubscriberChRemoveSubscriberCh方法接受Subscriber接口(满足Subscriber接口的任何类型)。它必须有一种发布消息的方法和一个Stop方法来停止它们所有(发布者和订阅者 Goroutines)

验收标准

这个例子和第七章中的例子之间的要求*,行为模式 - 访问者,状态,中介者和观察者设计模式*不得更改。在这两个例子中,目标是相同的,因此要求也必须相同。在这种情况下,我们的要求是技术性的,因此我们实际上需要添加一些更多的验收标准:

  1. 我们必须有一个带有PublishingCh方法的发布者,该方法返回一个通道以发送消息,并在每个订阅的观察者上触发Notify方法。

  2. 我们必须有一种方法来向发布者添加新的订阅者。

  3. 我们必须有一种方法来从发布者中移除新的订阅者。

  4. 我们必须有一种方法来停止订阅者。

  5. 我们必须有一种方法来停止Publisher接口,这也将停止所有订阅者。

  6. 所有 Goroutine 之间的通信必顶是同步的,以便没有 Goroutine 被锁定等待响应。在这种情况下,超时后将返回错误。

嗯,这些标准似乎相当令人生畏。我们忽略了一些要求,这些要求将增加更多的复杂性,例如删除不响应的订阅者或检查以确保发布者 Goroutine 始终处于活动状态。

单元测试

我们之前提到测试并发应用程序可能很困难。通过正确的机制,仍然可以完成,因此让我们看看在没有大麻烦的情况下我们可以测试多少。

测试订阅者

从订阅者开始,它似乎具有更加封装的功能,第一个订阅者必须将来自发布者的传入消息打印到io.Writer接口。我们已经提到订阅者具有一个接口,其中包含两种方法,Notify(interface{}) errorClose()方法:

    // writer_sub.go file 
    package main 

    import "errors" 

    type writerSubscriber struct { 
        id int 
        Writer io.Writer 
    } 

    func (s *writerSubscriber) Notify(msg interface{}) error { 
        return erorrs.NeW("Not implemented yet") 
    } 
    func (s *writerSubscriber) Close() {} 

好的。这将是我们的writer_sub.go文件。创建相应的测试文件,称为writer_sub_test.go文件:

    package main 
    func TestStdoutPrinter(t *testing.T) { 

现在,我们面临的第一个问题是功能打印到stdout,因此没有返回值可供检查。我们可以用三种方式解决它:

  • 捕获stdout方法。

  • 注入一个io.Writer接口进行打印。这是首选解决方案,因为它使代码更易管理。

  • stdout方法重定向到不同的文件。

我们将采用第二种方法。重定向也是一种可能性。os.Stdout是指向os.File类型的指针,因此它涉及用我们控制的文件替换此文件,并从中读取:

    func TestWriter(t *testing.T) { 
        sub := NewWriterSubscriber(0, nil) 

NewWriterSubscriber订阅者尚未定义。它必须帮助创建特定的订阅者,返回一个满足Subscriber接口的类型,因此让我们快速在writer_sub.go文件中声明它:

    func NewWriterSubscriber(id int, out io.Writer) Subscriber { 
        return &writerSubscriber{} 
    } 

理想情况下,它必须接受一个 ID 和一个io.Writer接口作为其写入的目的地。在这种情况下,我们需要一个自定义的io.Writer接口进行测试,因此我们将在writer_sub_test.go文件中为其创建一个mockWriter

    type mockWriter struct { 
        testingFunc func(string) 
    } 

    func (m *mockWriter) Write(p []byte) (n int, err error) { 
        m.testingFunc(string(p)) 
        return len(p), nil 
    } 

mockWriter结构将接受testingFunc作为其字段之一。这个testingFunc字段接受一个代表写入到mockWriter结构的字节的字符串。为了实现io.Writer接口,我们需要定义一个Write([]byte) (int, error)方法。在我们的定义中,我们将p的内容作为字符串传递(请记住,我们总是需要在每个Write方法中返回读取的字节和错误,或者不需要返回)。这种方法将testingFunc的定义委托给测试的范围。

我们将在 Subcriber 接口上调用 Notify 方法,它必须像 mockWriter 结构一样写入 io.Writer 接口。因此,在调用 Notify 方法之前,我们将定义 mockWriter 结构的 testingFunc

    // writer_sub_test.go file 
    func TestPublisher(t *testing.T) { 
        msg := "Hello" 

        var wg sync.WaitGroup 
        wg.Add(1) 

        stdoutPrinter := sub.(*writerSubscriber) 
        stdoutPrinter.Writer = &mockWriter{ 
            testingFunc: func(res string) { 
                if !strings.Contains(res, msg) { 
                    t.Fatal(fmt.Errorf("Incorrect string: %s", res)) 
                } 
                wg.Done() 
            }, 
        } 

我们将发送 Hello 消息。这也意味着无论 Subscriber 接口做什么,它最终都必须在提供的 io.Writer 接口上打印 Hello 消息。

因此,如果最终我们在测试函数中收到一个字符串,我们将需要与 Subscriber 接口同步,以避免测试中的竞争条件。这就是为什么我们使用了这么多的 WaitGroup。这是一种非常方便和易于使用的类型,用于处理这种情况。一个 Notify 方法调用将需要等待一个 Done() 方法的调用,因此我们调用 Add(1) 方法(一个单位)。

理想情况下,NewWriterSubscriber 函数必须返回一个接口,因此我们需要对我们在测试中使用的类型进行类型断言,即 stdoutPrinter 方法。我故意在进行转换时省略了错误检查,只是为了简化事情。一旦我们有了 writerSubscriber 类型,我们就可以访问其 Write 字段,将其替换为 mockWriter 结构。我们本可以直接在 NewWriterSubscriber 函数上传递一个 io.Writer 接口,但这样我们就无法覆盖传递空对象并将 os.Stdout 实例设置为默认值的情况。

因此,测试函数最终将接收一个包含订阅者写入内容的字符串。我们只需要检查接收到的字符串,即 Subscriber 接口将接收到的字符串,是否在某个时刻打印了单词 Hello,而 strings.Contains 函数最适合这种情况。一切都在测试函数的范围内定义,因此我们可以使用 t 对象的值来表示测试失败。

一旦我们完成了检查,我们必须调用 Done() 方法来表示我们已经测试了预期的结果:

err := sub.Notify(msg) 
if err != nil { 
    t.Fatal(err) 
    } 

    wg.Wait() 
    sub.Close() 
} 

我们实际上必须调用 NotifyWait 方法,以便调用 Done 方法来检查一切是否正确。

注意

您是否意识到我们在测试中定义了行为,更多或更少是相反的?这在并发应用程序中非常常见。有时可能会令人困惑,因为如果我们无法线性地跟踪调用,就很难知道函数可能在做什么,但您会很快习惯。与其思考“它这样做,然后这样做,然后那样做”,不如思考“在执行那个时会调用这个”。这也是因为在并发应用程序中,执行顺序在某一点之前是未知的,除非我们使用同步原语(如 WaitGroups 和通道)在某些时刻暂停执行。

现在让我们执行此类型的测试:

go test -cover -v -run=TestWriter .
=== RUN   TestWriter
--- FAIL: TestWriter (0.00s)
 writer_sub_test.go:40: Not implemented yet
FAIL
coverage: 6.7% of statements
exit status 1
FAIL

它退出得很快,但失败了。实际上,Done() 方法的调用尚未执行,因此最好将我们测试的最后部分更改为这样:

err := sub.Notify(msg)
if err != nil {
 wg.Done()
t.Error(err)
 }
 wg.Wait()
sub.Close()
 } 

现在,它不会停止执行,因为我们调用 Error 函数而不是 Fatal 函数,但我们调用 Done() 方法,测试在我们希望的位置结束,即在调用 Wait() 方法之后。您可以尝试再次运行测试,但输出将是相同的。

测试发布者

我们已经看到了 Publisher 接口和将满足其条件的类型,即 publisher 类型。我们唯一确定的是它将需要一些存储订阅者的方式,因此它至少会有一个 Subscribers 切片:

    // publisher.go type 
    type publisher struct { 
        subscribers []Subscriber 
    } 

为了测试 publisher 类型,我们还需要一个 Subscriber 接口的模拟:

    // publisher_test.go 
    type mockSubscriber struct { 
        notifyTestingFunc func(msg interface{}) 
        closeTestingFunc func() 
    } 

    func (m *mockSubscriber) Close() { 
        m.closeTestingFunc() 
    } 

    func (m *mockSubscriber) Notify(msg interface{}) error { 
        m.notifyTestingFunc(msg) 
        return nil 
    } 

mockSubscriber 类型必须实现 Subscriber 接口,因此它必须有 Close()Notify(interface{}) error 方法。我们可以嵌入一个已实现它的现有类型,比如 writerSubscriber,并且只覆盖我们感兴趣的方法,但我们需要定义两者,所以我们不会嵌入任何东西。

因此,在这种情况下,我们需要重写NotifyClose方法,以调用mockSubscriber类型字段上存储的测试函数。

    func TestPublisher(t *testing.T) { 
        msg := "Hello" 

        p := NewPublisher() 

首先,我们将直接通过通道发送消息,这可能会导致潜在的意外死锁,因此首先要定义一个用于这种情况的 panic 处理程序,比如,向关闭的通道发送消息或没有 Goroutines 在通道上监听。我们将发送给订阅者的消息是Hello。因此,通过使用AddSubscriberCh方法返回的通道接收到的每个订阅者都必须接收到这条消息。我们还将使用一个New函数来创建发布者,称为NewPublisher。现在更改publisher.go文件来编写它。

   // publisher.go file 
    func NewPublisher() Publisher { 
        return &publisher{} 
    } 

现在我们将定义mockSubscriber,并将其添加到已知订阅者列表中。回到publisher_test.go文件。

        var wg sync.WaitGroup 

        sub := &mockSubscriber{ 
            notifyTestingFunc: func(msg interface{}) { 
                defer wg.Done() 

                s, ok := msg.(string) 
                if !ok { 
                    t.Fatal(errors.New("Could not assert result")) 
                } 

                if s != msg { 
                    t.Fail() 
                } 
            }, 
            closeTestingFunc: func() { 
                wg.Done() 
            }, 
        } 

像往常一样,我们从一个 WaitGroup 开始。首先,在我们的订阅者中测试函数会在执行结束时延迟调用Done()方法。然后它需要对msg变量进行类型转换,因为它是作为接口传递的。记住,这样一来,我们可以通过引入类型断言的开销,使用Publisher接口与许多类型。这是在第s, ok := msg.(string)行完成的。

一旦我们将msg强制转换为字符串s,我们只需要检查订阅者接收到的值是否与我们发送的值相同,如果不同则测试失败。

        p.AddSubscriberCh() <- sub 
        wg.Add(1) 

        p.PublishingCh() <- msg 
        wg.Wait() 

我们使用AddSubscriberCh方法添加mockSubscriber类型。在准备就绪后,我们通过将WaitGroup加一来发布我们的消息,并在将WaitGroup设置为等待之前发布消息,这样测试就不会继续进行,直到mockSubscriber类型调用Done()方法。

此外,我们需要检查在调用AddSubscriberCh方法后Subscriber接口的数量是否增加,因此我们需要在测试中获取发布者的具体实例。

        pubCon := p.(*publisher) 
        if len(pubCon.subscribers) != 1 { 
            t.Error("Unexpected number of subscribers") 
        } 

类型断言是我们今天的朋友!一旦我们有了具体类型,我们就可以访问Publisher接口的基础订阅者切片。调用AddSubscriberCh方法后,订阅者的数量必须为 1,否则测试将失败。下一步是检查相反的情况--当我们移除一个Subscriber接口时,它必须从这个列表中被移除。

   wg.Add(1) 
   p.RemoveSubscriberCh() <- sub 
   wg.Wait() 

   //Number of subscribers is restored to zero 
   if len(pubCon.subscribers) != 0 { 
         t.Error("Expected no subscribers") 
   } 

   p.Stop() 
}  

我们测试的最后一步是停止发布者,这样就不能再发送消息,所有的 Goroutines 都会停止。

测试已经完成,但在publisher类型实现了所有方法之前我们无法运行测试;这必须是最终结果。

    type publisher struct { 
        subscribers []Subscriber 
        addSubCh    chan Subscriber 
        removeSubCh chan Subscriber 
        in          chan interface{} 
        stop        chan struct{} 
    } 

    func (p *publisher) AddSubscriberCh() chan<- Subscriber { 
        return nil 
    } 

    func (p *publisher) RemoveSubscriberCh() chan<- Subscriber { 
        return nil 
    } 

    func (p *publisher) PublishingCh() chan<- interface{} { 
        return nil 
    } 

    func (p *publisher) Stop(){} 

有了这个空实现,当运行测试时就不会发生什么好事。

go test -cover -v -run=TestPublisher .
atal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
testing.(*T).Run(0xc0420780c0, 0x5244c6, 0xd, 0x5335a0, 0xc042037d20)
 /usr/local/go/src/testing/testing.go:647 +0x31d
testing.RunTests.func1(0xc0420780c0)
 /usr/local/go/src/testing/testing.go:793 +0x74
testing.tRunner(0xc0420780c0, 0xc042037e10)
 /usr/local/go/src/testing/testing.go:610 +0x88
testing.RunTests(0x5335b8, 0x5ada40, 0x2, 0x2, 0x40d7e9)
 /usr/local/go/src/testing/testing.go:799 +0x2fc
testing.(*M).Run(0xc042037ed8, 0xc04200a4f0)
 /usr/local/go/src/testing/testing.go:743 +0x8c
main.main()
 go-design-patterns/concurrency_3/pubsub/_test/_testmain.go:56 +0xcd
goroutine 5 [chan send (nil chan)]:
go-design-patterns/concurrency_3/pubsub.TestPublisher(0xc042078180)
 go-design-patterns/concurrency_3/pubsub/publisher_test.go:55 +0x372
testing.tRunner(0xc042078180, 0x5335a0)
 /usr/local/go/src/testing/testing.go:610 +0x88
created by testing.(*T).Run
 /usr/local/go/src/testing/testing.go:646 +0x2f3
exit status 2
FAIL  go-design-patterns/concurrency_3/pubsub   1.587s

是的,它失败了,但这根本不是一个受控的失败。这是故意做的,以展示在 Go 中需要注意的一些事情。首先,这个测试产生的错误是一个fatal错误,通常指向代码中的一个 bug。这很重要,因为虽然panic错误可以被恢复,但对于致命错误却不能做同样的事情。

在这种情况下,错误告诉我们问题所在:goroutine 5 [chan send (nil chan)],一个nil通道,所以这实际上是我们代码中的一个错误。我们该如何解决这个问题呢?这也很有趣。

我们有一个nil通道的事实是由我们编写的代码导致的,用于编译单元测试,但一旦编写了适当的代码,就不会引发这种特定的错误(因为在这种情况下我们永远不会返回一个nil通道)。我们可以返回一个从未使用的通道,这会导致死锁的致命错误,这也不会有任何进展。

一种解决方法是返回一个通道和一个错误,这样你就可以有一个错误包,其中包含一个实现了Error接口的类型,返回一个特定的错误,比如NoGoroutinesListeningChannelNotCreated。我们已经看到了许多这样的实现,所以我们将把这些留给读者作为练习,然后我们将继续保持对本章并发性质的关注。

没有什么令人惊讶的,所以我们可以进入实施阶段。

实施

回顾一下,writerSubscriber必须接收它将写入的消息,并将其写入满足io.Writer接口的类型。

那么,我们从哪里开始呢?嗯,每个订阅者将运行自己的 Goroutine,而我们已经知道与 Goroutine 通信的最佳方法是使用通道。因此,我们将需要在Subscriber类型中使用一个带有通道的字段。我们可以使用与管道中相同的方法来结束NewWriterSubscriber函数和writerSubscriber类型:

    type writerSubscriber struct { 
        in     chan interface{} 
        id     int 
        Writer io.Writer 
    } 

    func NewWriterSubscriber(id int, out io.Writer) Subscriber { 
        if out == nil { 
            out = os.Stdout 
        } 

        s := &writerSubscriber{ 
            id:     id, 
            in:     make(chan interface{}), 
            Writer: out, 
        } 

        go func(){ 
            for msg := range s.in { 
                fmt.Fprintf(s.Writer, "(W%d): %v\n", s.id, msg) 
            } 
        }() 

        return s 
    } 

在第一步中,如果没有指定写入器(out参数为 nil),默认的io.Writer接口是stdout。然后,我们使用传递给第一个参数的 ID、out 的值(os.Stdout,或者如果不为 nil,则是传入参数中的值),以及一个名为 in 的通道创建了一个新的指向writerSubscriber类型的指针,以保持与之前示例中相同的命名。

然后我们启动一个新的 Goroutine;这是我们提到的启动机制。就像在管道中一样,订阅者将在每次接收到新消息时迭代in通道,并将其内容格式化为一个字符串,其中还包含当前订阅者的 ID。

正如我们之前学到的那样,如果in通道关闭,for range循环将停止,那个特定的 Goroutine 将结束,所以Close方法中唯一需要做的事情就是实际关闭in通道:

    func (s *writerSubscriber) Close() { 
        close(s.in) 
    } 

好的,只剩下Notify方法了;Notify方法是管理特定行为的便捷方法,我们将使用一个在许多调用中常见的模式:

    func (s *writerSubscriber) Notify(msg interface{}) (err error) { 
        defer func(){ 
            if rec := recover(); rec != nil { 
                err = fmt.Errorf("%#v", rec) 
            } 
        }() 

        select { 
        case s.in <- msg: 
        case <-time.After(time.Second): 
            err = fmt.Errorf("Timeout\n") 
        } 

        return 
    } 

在与通道通信时,通常有两种行为需要控制:一种是等待时间,另一种是通道关闭时的行为。延迟函数实际上适用于函数内部可能发生的任何恐慌错误。如果 Goroutine 发生恐慌,它仍将使用recover()方法执行延迟函数。recover()方法返回一个接口,无论错误是什么,所以在我们的情况下,我们将返回变量错误设置为recover返回的格式化值(这是一个接口)。"%#v"参数在将任何类型格式化为字符串时为我们提供了大部分信息。返回的错误会很丑陋,但它将包含我们可以提取的大部分关于错误的信息。例如,对于关闭的通道,它将返回"send on a closed channel"。嗯,这似乎足够清楚了。

第二条规则是关于等待时间。当我们通过通道发送一个值时,我们将被阻塞,直到另一个 Goroutine 从中取出该值(填充的缓冲通道也是如此)。我们不希望永远被阻塞,所以我们通过使用 select 处理程序设置了一秒的超时期。简而言之,使用 select 我们在说:要么你在 1 秒内取出值,要么我将丢弃它并返回一个错误。

我们有CloseNotifyNewWriterSubscriber方法,所以我们可以再次尝试我们的测试:

go test -run=TestWriter -v .
=== RUN   TestWriter
--- PASS: TestWriter (0.00s)
PASS
ok

现在好多了。Writer已经接收了我们在测试中编写的模拟写入器,并向其传递了我们传递给Notify方法的值。同时,由于Notify方法在调用Close方法后返回了一个错误,所以close可能已经有效地关闭了通道。需要提到的一件事是,我们无法在不与其交互的情况下检查通道是否关闭;这就是为什么我们必须推迟执行一个将检查Notify方法中recover()函数的内容的闭包的执行。

实施发布者

好的,发布者还需要一个启动机制,但要处理的主要问题是访问订阅者列表时的竞争条件。我们可以使用sync包中的 Mutex 对象来解决这个问题,但我们已经知道如何使用它,所以我们将改用通道。

在使用通道时,我们将需要为每个可能被视为危险的操作创建一个通道——添加订阅者、移除订阅者、检索订阅者列表以通知Notify方法发送消息,以及一个用于停止所有订阅者的通道。我们还需要一个用于传入消息的通道:

    type publisher struct { 
        subscribers []Subscriber 
        addSubCh    chan Subscriber 
        removeSubCh chan Subscriber 
        in          chan interface{} 
        stop        chan struct{} 
    } 

名称是自描述的,但简而言之,订阅者维护订阅者列表;这是需要多路访问的切片。addSubCh实例是与之通信的通道,当您想要添加新的订阅者时,就会使用它;这就是为什么它是一个订阅者的通道。相同的解释也适用于removeSubCh通道,但这个通道是用来移除订阅者的。in通道将处理必须广播给所有订阅者的传入消息。最后,当我们想要终止所有 Goroutines 时,必须调用 stop 通道。

好的,让我们从AddSubscriberChRemoveSubscriberPublishingCh方法开始,这些方法必须返回通道以添加和移除订阅者以及向所有订阅者发送消息的通道:

    func (p *publisher) AddSubscriber() { 
        return p.addSubCh 
    } 

    func (p *publisher) RemoveSubscriberCh() { 
        return p.removeSubCh 
    } 

    func (p *publisher) PublishMessage(){ 
        return p.in 
    } 

Stop()函数通过关闭stop通道来停止它。这将有效地向每个正在监听的 Goroutine 传播信号:

func (p *publisher) Stop(){ 
  close(p.stop) 
} 

Stop方法,用于停止发布者和订阅者,也会推送到其相应的通道,称为 stop。

也许你会想知道为什么我们不直接保留通道,让用户直接向该通道推送消息,而不使用代理函数。嗯,这样做的想法是,集成库到他们的应用程序中的用户不必处理与库相关的并发结构的复杂性,因此他们可以专注于他们的业务,同时尽可能地提高性能。

处理通道而不产生竞争条件。

到目前为止,我们已经将数据转发到发布者的通道上,但实际上我们还没有处理任何数据。将启动不同的 Goroutine 的机制将处理它们所有。

我们将创建一个launch方法,通过使用go关键字来执行它,而不是将整个函数嵌入NewPublisher函数中:

func (p *publisher) start() { 
  for { 
    select { 
    case msg := <-p.in: 
      for _, ch := range p.subscribers { 
        sub.Notify(msg) 
      } 

Launch是一个私有方法,我们还没有测试过它。请记住,私有方法通常是从公共方法(我们已经测试过的方法)中调用的。通常情况下,如果一个私有方法没有从公共方法中调用,它就不能被调用!

使用这种方法时,我们首先注意到的是一个无限循环,它将在许多通道之间重复执行选择操作,但每次只能执行其中一个。这些操作中的第一个是接收要发布给订阅者的新消息。case msg := <- p.in: 代码处理这个传入操作。

在这种情况下,我们正在迭代所有订阅者并执行它们的Notify方法。也许你会想知道为什么我们不在前面加上go关键字,以便Notify方法作为一个不同的 Goroutine 执行,因此迭代速度更快。嗯,这是因为我们没有对接收消息和关闭消息的操作进行解复用。因此,如果我们在新的 Goroutine 中启动订阅者,并且在Notify方法中处理消息时关闭了它,我们将会出现竞争条件,即消息将尝试在Notify方法中发送到一个关闭的通道。事实上,当我们开发Notify方法时,我们考虑到了这种情况,但是,如果我们每次在新的 Goroutine 中调用Notify方法,我们就无法控制启动的 Goroutines 数量。为简单起见,我们只是调用Notify方法,但是控制在Notify方法执行中等待返回的 Goroutines 数量是一个很好的练习。通过在每个订阅者中缓冲in通道,我们也可以实现一个很好的解决方案:

    case sub := <-p.addSubCh: 
    p.subscribers = append(p.subscribers, sub) 

下一个操作是当一个值到达通道以添加订阅者时该怎么办。在这种情况下很简单:我们更新它,将新值附加到它上。在执行此案例时,不能执行其他调用:

     case sub := <-p.removeSubCh: 
     for i, candidate := range p.subscribers { 
         if candidate == sub { 
             p.subscribers = append(p.subscribers[:i], p.subscribers[i+1:]...) 
             candidate.Close() 
             break 
        } 
    } 

当一个值到达移除通道时,操作会变得更加复杂,因为我们必须在切片中搜索订阅者。我们使用了*O(N)*的方法,从开头开始迭代直到找到它,但搜索算法可以得到很大的改进。一旦我们找到相应的Subscriber接口,我们就将其从订阅者切片中移除并停止它。需要提到的一件事是,在测试中,我们直接访问订阅者切片的长度,而不进行多路复用操作。这显然是一种竞争条件,但通常在运行竞争检测器时不会反映出来。

解决方案将是开发一种方法,只是为了多路复用调用以获取切片的长度,但它不会属于公共接口。再次,为了简单起见,我们将保持现状,否则这个例子可能会变得太复杂而难以处理:

    case <-p.stop: 
    for _, sub := range p.subscribers { 
        sub.Close() 
            } 

        close(p.addSubCh) 
        close(p.in) 
        close(p.removeSubCh) 

        return 
        } 
    } 
} 

最后一个需要多路复用的操作是stop操作,它必须停止发布者和订阅者中的所有 Goroutines。然后,我们必须遍历存储在订阅者字段中的每个订阅者,执行它们的Close()方法,以便关闭它们的 Goroutines。最后,如果我们返回这个 Goroutine,它也会结束。

好的,是时候执行所有测试,看看情况如何:

go test -race .
ok

还不错。所有测试都成功通过了,我们的观察者模式已经准备就绪。虽然这个例子仍然可以改进,但它是一个很好的例子,展示了我们如何使用 Go 中的通道来处理观察者模式。作为练习,我们鼓励您尝试使用互斥锁而不是通道来控制访问,这样做会更容易,也会让您了解如何使用互斥锁。

关于并发观察者模式的几句话

这个例子演示了如何利用多核 CPU 来构建一个并发消息发布者,通过实现观察者模式。虽然例子很长,但我们试图展示在使用 Go 开发并发应用程序时的常见模式。

总结

我们已经看到了一些开发并发结构的方法,可以并行运行。我们试图展示解决同一个问题的几种方式,一种是没有并发原语,另一种是有并发原语。我们已经看到了使用并发结构编写的发布/订阅示例与经典示例相比有多么不同。

我们还看到了如何使用管道构建并发操作,并通过使用工作池来并行化,这是一种最大化并行性的常见 Go 模式。

这两个例子都足够简单,可以理解,同时尽可能深入了解 Go 语言的本质,而不是问题本身。