Skip to content

Latest commit

 

History

History
975 lines (802 loc) · 31.4 KB

File metadata and controls

975 lines (802 loc) · 31.4 KB

十四、实现并发模式

本章将介绍并发模式以及如何使用它们构建健壮的系统应用程序。我们已经研究了并发中涉及的所有工具(goroutines 和 channels、syncatomic以及 context),所以现在我们将研究一些将它们组合成模式的常用方法,以便我们可以在程序中使用它们。

本章将介绍以下主题:

  • 从发电机开始
  • 管道测序
  • Muxing 和 demuxing
  • 其他模式
  • 资源泄漏

技术要求

本章要求安装 Go 并设置您最喜爱的编辑器。更多信息请参见第三章Go概述。

从发电机开始

生成器是一个函数,每次调用时返回序列的下一个值。使用生成器的最大优点是延迟创建序列的新值。在 Go 中,这可以用接口或通道表示。当生成器与通道一起使用时,其优点之一是它们在单独的 goroutine 中同时生成值,使主 goroutine 能够对其执行其他类型的操作。

它可以通过一个非常简单的界面进行抽象:

type Generator interface {
    Next() interface{}
}

type GenInt64 interface {
    Next() int64
}

接口的返回类型将取决于用例,在我们的例子中是int64。它的基本实现可以是一个简单的计数器:

type genInt64 int64

func (g *genInt64) Next() int64 {
    *g++
    return int64(*g)
}

此实现不是线程安全的,因此如果我们尝试将其用于 goroutines,可能会丢失一些元素:

func main() {
    var g genInt64
    for i := 0; i < 1000; i++ {
        go func(i int) {
            fmt.Println(i, g.Next())
        }(i)
    }
    time.Sleep(time.Second)
}

使生成器并发的一种简单方法是对整数执行原子操作。

这将使并发生成器线程安全,只需对代码进行很少的更改:

type genInt64 int64

func (g *genInt64) Next() int64 {
    return atomic.AddInt64((*int64)(g), 1)
}

这将避免应用程序中的竞争条件。然而,还有另一种实现是可能的,但这需要使用通道。其思想是在 goroutine 中生成值,然后在共享通道中将其传递给下一个方法,如以下示例所示:

type genInt64 struct {
    ch chan int64
}

func (g genInt64) Next() int64 {
    return <-g.ch
}

func NewGenInt64() genInt64 {
    g := genInt64{ch: make(chan int64)}
    go func() {
        for i := int64(0); ; i++ {
            g.ch <- i
        }
    }()
    return g
}

循环将永远持续,当生成器用户停止使用Next方法请求新值时,循环将在发送操作中阻塞。

代码是这样构造的,因为我们试图实现我们在开始时定义的接口。我们也可以返回一个通道并使用它接收:

func GenInt64() <-chan int64 {
 ch:= make(chan int64)
    go func() {
        for i := int64(0); ; i++ {
            ch <- i
        }
    }()
    return ch
}

直接使用通道的主要优点是可以将其包含在select语句中,以便在不同的通道操作之间进行选择。以下显示了两台不同发电机之间的select

func main() {
    ch1, ch2 := GenInt64(), GenInt64()
    for i := 0; i < 20; i++ {
        select {
        case v := <-ch1:
            fmt.Println("ch 1", v)
        case v := <-ch2:
            fmt.Println("ch 2", v)
        }
    }
}

避免泄漏

最好允许循环结束,以避免 goroutine 和资源泄漏。其中一些问题如下:

  • 当 goroutine 挂起而不返回时,内存中的空间将保持使用状态,从而影响应用程序在内存中的大小。只有当 goroutine 返回或崩溃时,GC 才会收集 goroutine 及其在堆栈中定义的变量。
  • 如果文件保持打开状态,则可能会阻止其他进程对其执行操作。如果打开的文件数量达到操作系统施加的限制,进程将无法打开其他文件(或接受网络连接)。

这个问题的一个简单解决方案是始终使用context.Context,这样您就有了一个定义良好的 goroutine 退出点:

func NewGenInt64(ctx context.Context) genInt64 {
    g := genInt64{ch: make(chan int64)}
    go func() {
        for i := int64(0); ; i++ {
            select {
            case g.ch <- i:
                // do nothing
            case <-ctx.Done():
                close(g.ch)
                return
            }
        }
    }()
    return g
}

这可用于生成值,直到需要它们为止,并在不需要新值时取消上下文。同样的模式也可以应用于返回通道的版本。例如,我们可以直接使用cancel函数,或者在上下文上设置超时:

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100)
    defer cancel()
    g := NewGenInt64(ctx)
    for i := range g.ch {
        go func(i int64) {
            fmt.Println(i, g.Next())
        }(i)
    }
    time.Sleep(time.Second)
}

生成器将生成数字,直到提供的上下文过期。此时,发电机将关闭通道。

管道测序

管道是构造应用程序流的一种方式,它通过将主执行划分为几个阶段来获得,这些阶段可以使用某种通信方式相互通信。这可能是以下情况之一:

  • 外部的,如网络连接或文件
  • 应用程序内部,如 Go 的通道

第一个阶段通常被称为生产者,而最后一个阶段通常被称为消费者。

Go 提供的一组并发工具允许我们高效地使用多个 CPU,并通过阻止输入或输出操作来优化它们的使用。通道尤其是内部管道通信的完美工具。它们可以由接收入站通道并返回出站通道的函数表示。基础结构的外观如下所示:

func stage(in <-chan interface{}) <-chan interface{} {
    var out = make(chan interface{})
    go func() {
        for v := range in {
            v = v.(int)+1 // some operation
            out <- v
        }
        close(out)
    }()
    return out
}

我们创建一个与输入通道类型相同的通道并返回它。在一个单独的 goroutine 中,我们从输入通道接收数据,对数据执行操作,然后将其发送到输出通道。

这个模式可以通过使用context.Context进一步改进,这样我们就可以更好地控制应用程序流。它看起来类似于以下代码:

func stage(ctx context.Context, in <-chan interface{}) <-chan interface{} {
    var out = make(chan interface{})
    go func() {
        defer close(out)
        for v := range in {
            v = v.(int)+1 // some operation
            select {
                case out <- v:
                case <-ctx.Done():
                    return
            }
        }
    }()
    return out
}

设计管道时,我们应遵循以下几条一般规则:

  • 中间阶段将接收一个入站通道并返回另一个通道。
  • 制作人不会收到任何通道,但会返回一个。
  • 消费者将收到一个通道而不返回。
  • 每个阶段发送完消息后,将在创建时关闭通道。
  • 每个阶段都应保持接收来自输入通道的信号,直到其关闭。

让我们创建一个简单的管道,它使用特定字符串过滤来自读卡器的行,并打印过滤后的行,突出显示搜索字符串。我们可以从第一个阶段开始,即信号源,它不会在信号中接收任何通道,但会使用读卡器扫描线路。我们使用上下文输入来响应提前退出请求(上下文取消),并使用bufio扫描仪逐行读取。下面的代码显示了这一点:

func SourceLine(ctx context.Context, r io.ReadCloser) <-chan string {
    ch := make(chan string)
    go func() {
        defer func() { r.Close(); close(ch) }()
        s := bufio.NewScanner(r)
        for s.Scan() {
            select {
            case <-ctx.Done():
                return
            case ch <- s.Text():
            }
        }
    }()
    return ch
}

我们可以将剩余的操作分为两个阶段:过滤阶段和写入阶段。滤波阶段将简单地从源通道滤波到输出通道。我们仍然在传递上下文,以避免在上下文已经完成时发送额外数据。这是文本过滤器的实现:

func TextFilter(ctx context.Context, src <-chan string, filter string) <-chan string {
    ch := make(chan string)
    go func() {
        defer close(ch)
        for v := range src {
            if !strings.Contains(v, filter) {
                continue
            }
            select {
            case <-ctx.Done():
                return
            case ch <- v:
            }
        }
    }()
    return ch
}

最后,我们还有最后一个阶段,消费者,它将在写入程序中打印输出,还将使用上下文提前退出:

func Printer(ctx context.Context, src <-chan string, color int, highlight string, w io.Writer) {
    const close = "\x1b[39m"
    open := fmt.Sprintf("\x1b[%dm", color)
    for {
        select {
        case <-ctx.Done():
            return
        case v, ok := <-src:
            if !ok {
                return
            }
            i := strings.Index(v, highlight)
            if i == -1 {
                panic(v)
            }
            fmt.Fprint(w, v[:i], open, highlight, close, v[i+len(highlight):], "\n")
        }
    }
}

此功能的使用如下所示:

func main() {
    var search string
    ...
    ctx := context.Background()
    src := SourceLine(ctx, ioutil.NopCloser(strings.NewReader(sometext)))
    filter := TextFilter(ctx, src, search)
    Printer(ctx, filter, 31, search, os.Stdout)
}

通过这种方法,我们学习了如何将复杂的操作拆分为一个简单的任务,该任务分阶段执行,并使用通道连接。

Muxing 和 demuxing

现在我们已经熟悉了管道和阶段,我们可以引入两个新概念:

  • 复用或扇出:一路接收,多路发送
  • 解复用(解复用)或扇入:多通道接收,单通道发送

这种模式非常常见,允许我们以不同的方式使用并发能力。最明显的方法是从比后续步骤更快的通道分发数据,并创建多个此类步骤的实例以弥补速度上的差异。

散开

多路复用的实现非常简单。同一个通道需要传递到不同的阶段,以便每个阶段都能从中读取数据。

每个 goroutine 都在运行时计划中争夺资源,因此如果我们想保留更多的资源,我们可以在管道的某个阶段或应用程序中的某个操作中使用多个 goroutine。

我们可以创建一个小应用程序,使用这种方法计算出现在文本中的单词的出现次数。让我们创建一个初始生产者阶段,它从作者处读取并返回该行的单词片段:

func SourceLineWords(ctx context.Context, r io.ReadCloser) <-chan []string {
    ch := make(chan []string)
    go func() {
        defer func() { r.Close(); close(ch) }()
        b := bytes.Buffer{}
        s := bufio.NewScanner(r)
        for s.Scan() {
            b.Reset()
            b.Write(s.Bytes())
            words := []string{}
            w := bufio.NewScanner(&b)
            w.Split(bufio.ScanWords)
            for w.Scan() {
                words = append(words, w.Text())
            }
            select {
            case <-ctx.Done():
                return
            case ch <- words:
            }
        }
    }()
    return ch
}

我们现在可以定义另一个阶段来计算这些词的出现。我们将使用此阶段进行扇出:

func WordOccurrence(ctx context.Context, src <-chan []string) <-chan map[string]int {
    ch := make(chan map[string]int)
    go func() {
        defer close(ch)
        for v := range src {
            count := make(map[string]int)
            for _, s := range v {
                count[s]++
            }
            select {
            case <-ctx.Done():
                return
            case ch <- count:
            }
        }
    }()
    return ch
}

为了将第一级用作第二级的多个实例的源,我们只需要使用相同的输入通道创建多个计数级:

ctx, canc := context.WithCancel(context.Background())
defer canc()
src := SourceLineWords(ctx,   
    ioutil.NopCloser(strings.NewReader(cantoUno)))
count1, count2 := WordOccurrence(ctx, src), WordOccurrence(ctx, src)

扇入

解复用稍微复杂一点,因为我们不需要在一个或另一个 goroutine 中盲目地接收数据——我们实际上需要同步一系列通道。避免竞争条件的一个好方法是创建另一个通道,在该通道中接收来自各个输入通道的所有数据。我们还需要确保在完成所有通道后关闭此合并通道。我们还必须记住,如果上下文被取消,通道将被关闭。我们在这里使用sync.Waitgroup等待所有通道完成:

wg := sync.WaitGroup{}
merge := make(chan map[string]int)
wg.Add(len(src))
go func() {
    wg.Wait()
    close(merge)
}()

问题是我们有两种可能的关闭通道的触发器:常规传输结束和上下文取消。

我们必须确保,如果上下文结束,则不会向出站通道发送任何消息。在这里,我们从输入通道收集值并将其发送到合并通道,但前提是上下文不完整。我们这样做是为了避免发送操作被发送到封闭通道,这会使我们的应用程序死机:

for _, ch := range src {
    go func(ch <-chan map[string]int) {
        defer wg.Done()
        for v := range ch {
            select {
            case <-ctx.Done():    
                return
            case merge <- v:
            }
        }
    }(ch)
}

最后,我们可以关注最后一个操作,它使用合并通道执行最终的字数计数:

count := make(map[string]int)
for {
    select {
    case <-ctx.Done():
        return count
    case c, ok := <-merge:
        if !ok {
            return count
        }
        for k, v := range c {
            count[k] += v
        }
    }
}

应用程序的main功能,加上扇入,如下所示:

func main() {
    ctx, canc := context.WithCancel(context.Background())
    defer canc()
    src := SourceLineWords(ctx, ioutil.NopCloser(strings.NewReader(cantoUno)))
    count1, count2 := WordOccurrence(ctx, src), WordOccurrence(ctx, src)
    final := MergeCounts(ctx, count1, count2)
    fmt.Println(final)
}

我们可以看到,扇入是应用程序中最复杂和最关键的部分。让我们回顾一下帮助构建无恐慌或死锁的扇入函数的决策:

  • 使用合并通道从各种输入收集值。
  • 具有与输入通道数相等的计数器的sync.WaitGroup
  • 在单独的 goroutine 中使用它,并等待它关闭通道。
  • 对于每个输入通道,创建一个 goroutine,将值传输到合并通道。
  • 确保仅在上下文不完整时发送记录。
  • 在退出此类 goroutine 之前,请使用等待组的done功能。

按照前面的步骤,我们可以使用带有简单range的合并通道。在我们的示例中,我们还将在从通道接收之前检查上下文是否完整,以便尽早退出 goroutine。

生产者和消费者

渠道使我们能够轻松处理多个消费者从一个生产者接收数据的情况,反之亦然。

正如我们已经看到的,一个生产者和一个消费者的情况非常简单:

func main() {
    // one producer
    var ch = make(chan int)
    go func() {
        for i := 0; i < 100; i++ {
            ch <- i
        }
        close(ch)
    }()
    // one consumer
    var done = make(chan struct{})
    go func() {
        for i := range ch {
            fmt.Println(i)
        }
        close(done)
    }()
    <-done
}

此处提供完整示例:https://play.golang.org/p/hNgehu62kjv

多生产商(N*1)

使用等待组可以轻松处理多个生产者或消费者的问题。如果有多个制作人,所有 goroutine 将共享同一通道:

// three producer
var ch = make(chan string)
wg := sync.WaitGroup{}
wg.Add(3)
for i := 0; i < 3; i++ {
    go func(n int) {
        for i := 0; i < 100; i++ {
            ch <- fmt.Sprintln(n, i)
        }
        wg.Done()
    }(i)
}
go func() {
    wg.Wait()
    close(ch)
}()

此处提供完整示例:https://play.golang.org/p/4DqWKntl6sS

他们将使用sync.WaitGroup等待每个制作人完成,然后关闭通道。

多个用户(1*M)

同样的道理也适用于多个消费者——他们都在不同的 goroutine 中从同一渠道接收:

func main() {
    // three consumers
    wg := sync.WaitGroup{}
    wg.Add(3)
    var ch = make(chan string)

    for i := 0; i < 3; i++ {
        go func(n int) {
            for i := range ch {
                fmt.Println(n, i)
            }
            wg.Done()
        }(i)
    }

    // one producer
    go func() {
        for i := 0; i < 10; i++ {
            ch <- fmt.Sprintln("prod-", i)
        }
        close(ch)
    }()

    wg.Wait()
}

此处提供完整示例:https://play.golang.org/p/_SWtw54ITFn

在这种情况下,sync.WaitGroup用于等待应用程序结束。

多个消费者和生产者(N*M)

最后一种情况是,我们有任意数量的生产者(N)和另一个任意数量的消费者(M)。

在这种情况下,我们需要两个等待组:一个用于生产者,另一个用于消费者:

const (
    N = 3
    M = 5
)
wg1 := sync.WaitGroup{}
wg1.Add(N)
wg2 := sync.WaitGroup{}
wg2.Add(M)
var ch = make(chan string)

接下来是一系列的生产者和消费者,每个人都有自己的 goroutine:

for i := 0; i < N; i++ {
    go func(n int) {
        for i := 0; i < 10; i++ {
            ch <- fmt.Sprintf("src-%d[%d]", n, i)
        }
        wg1.Done()
    }(i)
}

for i := 0; i < M; i++ {
    go func(n int) {
        for i := range ch {
            fmt.Printf("cons-%d, msg %q\n", n, i)
        }
        wg2.Done()
    }(i)
}

最后一步是等待WaitGroup制作人完成其工作,以关闭通道。

然后,我们可以等待消费者通道让消费者处理所有消息:

wg1.Wait()
close(ch)
wg2.Wait()

其他模式

到目前为止,我们已经了解了可以使用的最常见的并发模式。现在,我们将重点讨论一些不太常见但值得一提的问题。

错误组

sync.WaitGroup的力量在于它允许我们同时等待 goroutines 完成他们的工作。我们已经了解了共享上下文如何允许我们在正确使用 goroutines 的情况下尽早退出。第一个并发操作,例如从通道发送或接收,与上下文完成通道一起位于select块中:

func main() {
    ctx, canc := context.WithTimeout(context.Background(), time.Second)
    defer canc()
    wg := sync.WaitGroup{}
    wg.Add(10)
    var ch = make(chan int)
    for i := 0; i < 10; i++ {
        go func(ctx context.Context, i int) {
            defer wg.Done()
            d := time.Duration(rand.Intn(2000)) * time.Millisecond
            time.Sleep(d)
            select {
            case <-ctx.Done():
                fmt.Println(i, "early exit after", d)
                return
            case ch <- i:
                fmt.Println(i, "normal exit after", d)
            }
        }(ctx, i)
    }
    go func() {
        wg.Wait()
        close(ch)
    }()
    for range ch {
    }
}

实验性的golang.org/x/sync/errgroup包提供了对该场景的改进。

内置的 goroutine 总是属于func()类型,但是这个包允许我们同时执行func() error并返回从各种 goroutine 接收到的第一个错误。

这在一起启动更多 goroutine 并收到第一个错误的场景中非常有用。errgroup.Group类型可用作零值,其Do方法以func() error为参数,同时启动函数。

Wait方法要么等待所有函数成功完成并返回nil,要么返回来自任何函数的第一个错误。

让我们创建一个定义 URL 访问者的示例,即获取 URL 字符串并返回func() error的函数,该函数进行调用:

func visitor(url string) func() error {
    return func() (err error) {
        s := time.Now()
        defer func() {
            log.Println(url, time.Since(s), err)
        }()
        var resp *http.Response
        if resp, err = http.Get(url); err != nil {
            return
        }
        return resp.Body.Close()
    }
}

我们可以用Go方法直接使用,然后等待。这将返回由无效 URL 引起的错误:

func main() {
    eg := errgroup.Group{}
    var urlList = []string{
        "http://www.golang.org/",
        "http://invalidwebsite.hey/",
        "http://www.google.com/",
    }
    for _, url := range urlList {
        eg.Go(visitor(url))
    }
    if err := eg.Wait(); err != nil {
        log.Fatalln("Error:", err)
    }
}

错误组还允许我们使用WithContext函数创建一个组以及一个上下文。当收到第一个错误时,此上下文将被取消。上下文的取消允许Wait方法立即返回,但它也允许在函数的 goroutines 中提前退出。

我们可以创建一个类似的func() error创建者,将值发送到通道中,直到上下文关闭。我们将引入一个引发错误的小概率(1%):

func sender(ctx context.Context, ch chan<- string, n int) func() error {
    return func() (err error) {
        for i := 0; ; i++ {
            if rand.Intn(100) == 42 {
                return errors.New("the answer")
            }
            select {
            case ch <- fmt.Sprintf("[%d]%d", n, i):
            case <-ctx.Done():
                return nil
            }
        }
    }
}

我们将使用专用函数生成一个错误组和一个上下文,并使用它来启动函数的多个实例。我们将在一个单独的 goroutine 中收到这个消息,同时我们将等待分组。等待结束后,我们将额外等待一秒钟,以确保没有更多的值发送到通道(这将导致恐慌):

func main() {
    eg, ctx := errgroup.WithContext(context.Background())
    ch := make(chan string)
    for i := 0; i < 10; i++ {
        eg.Go(sender(ctx, ch, i))
    }
    go func() {
        for s := range ch {
            log.Println(s)
        }
    }()
    if err := eg.Wait(); err != nil {
        log.Println("Error:", err)
    }
    close(ch)
    log.Println("waiting...")
    time.Sleep(time.Second)
}

正如所料,由于上下文中的select语句,应用程序可以无缝运行,不会死机。

漏桶

在前面的章节中,我们看到了如何使用 ticker 构建一个利率限制器:通过使用time.Ticker强制客户等待轮到它来获得服务。还有一种服务和库的速率限制,称为漏桶。这个名字让人联想到一个有几个洞的水桶。如果你在装水,你必须小心不要往桶里放太多的水,否则它会溢出。在添加更多的水之前,你需要等待水位下降——下降的速度将取决于水桶的大小和它的洞数。通过查看以下类比,我们可以轻松理解此并发模式的作用:

  • 穿过孔的水代表已完成的请求。
  • 从桶中溢出的水表示已丢弃的请求。

铲斗将由两个属性定义:

  • 速率:如果请求频率较低,则每次请求的理想数量。
  • 容量:在资源暂时无响应之前,可以同时完成的请求数。

水桶具有最大容量,因此,当以高于指定速率的频率发出请求时,该容量开始下降,就像你往桶中放太多水时,水桶开始溢出一样。如果频率为零或低于速率,铲斗将缓慢增加其容量,因此水将缓慢排出。

漏桶的数据结构将具有一个容量和一个计数器,用于处理可用的请求。此计数器将与创建时的容量相同,并且每次执行请求时都会删除。速率指定需要将状态重置为容量的频率:

type bucket struct {
    capacity uint64
    status uint64
}

在创建新 bucket 时,我们还应该注意状态重置。我们可以为此使用 goroutine,并使用上下文来正确终止它。我们可以使用速率创建一个 ticker,然后使用这些 ticks 来重置状态。我们需要使用原子包来确保线程安全:

func newBucket(ctx context.Context, cap uint64, rate time.Duration) *bucket {
    b := bucket{capacity: cap, status: cap}
    go func() {
        t := time.NewTicker(rate)
        for {
            select {
            case <-t.C:
                atomic.StoreUint64(&b.status, b.capacity)
            case <-ctx.Done():
                t.Stop()
                return
            }
        }
    }()
    return &b
}

当我们添加到 bucket 时,我们可以检查状态并相应地采取行动:

  • 如果状态为0,则不能添加任何内容。
  • 如果要添加的数量高于可用性,我们将尽可能添加。
  • 除此之外,我们增加了全部金额:
func (b *bucket) Add(n uint64) uint64 {
    for {
        r := atomic.LoadUint64(&b.status)
        if r == 0 {
            return 0
        }
        if n > r {
            n = r
        }
        if !atomic.CompareAndSwapUint64(&b.status, r, r-n) {
            continue
        }
        return n
    }
}

我们正在使用一个循环来尝试原子交换操作,直到它们成功,以确保当我们进行比较和交换CAS时,Load操作所得到的不会改变。

bucket 可用于尝试向 bucket 添加随机数量并记录其结果的客户端:

type client struct {
    name string
    max int
    b *bucket
    sleep time.Duration
}

func (c client) Run(ctx context.Context, start time.Time) {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            n := 1 + rand.Intn(c.max-1)
            time.Sleep(c.sleep)
            e := time.Since(start).Seconds()
            a := c.b.Add(uint64(n))
            log.Printf("%s tries to take %d after %.02fs, takes  
                %d", c.name, n, e, a)
        }
    }
}

我们可以同时使用更多的客户端,这样,对资源的并发访问将产生以下结果:

  • 一些 goroutine 将把他们期望的东西添加到 bucket 中。
  • 一个 goroutine 最终将通过添加一个等于剩余容量的量来填充桶,即使他们试图添加的量更高。
  • 在重置容量之前,其他 goroutine 将无法添加到存储桶:
func main() {
    ctx, canc := context.WithTimeout(context.Background(), time.Second)
    defer canc()
    start := time.Now()
    b := newBucket(ctx, 10, time.Second/5)
    t := time.Second / 10
    for i := 0; i < 5; i++ {
        c := client{
            name: fmt.Sprint(i),
            b: b,
            sleep: t,
            max: 5,
        }
        go c.Run(ctx, start)
    }
    <-ctx.Done()
}

排序

在具有多个 goroutine 的并发场景中,我们可能需要在 goroutine 之间进行同步,例如在每个 goroutine 需要在发送后等待轮到它的场景中。

此场景的一个用例可以是基于回合的应用程序,其中不同的 goroutine 正在向同一个通道发送消息,并且每个 goroutine 都必须等到所有其他 goroutine 都完成后才能再次发送消息。

使用主 goroutine 和发送方之间的专用通道可以获得此场景的一个非常简单的实现。我们可以定义一个非常简单的结构,它同时承载消息和Wait通道。当它使用下面的通道时,它将有两种方法——一种是将事务标记为完成,另一种是等待这样的信号。以下方法显示了这一点:

type msg struct {
    value string
    done chan struct{}
}

func (m *msg) Wait() {
    <-m.done
}

func (m *msg) Done() {
    m.done <- struct{}{}
}

我们可以使用生成器创建消息源。我们可以在send操作中使用随机延迟。在每次发送之后,我们等待通过调用Done方法获得的信号。我们始终使用上下文来避免任何泄漏:

func send(ctx context.Context, v string) <-chan msg {
    ch := make(chan msg)
    go func() {
        done := make(chan struct{})
        for i := 0; ; i++ {
            time.Sleep(time.Duration(float64(time.Second/2) * rand.Float64()))
            m := msg{fmt.Sprintf("%s msg-%d", v, i), done}
            select {
            case <-ctx.Done():
                close(ch)
                return
            case ch <- m:
                m.Wait()
            }
        }
    }()
    return ch
}

我们可以使用扇入将所有通道放入一个单一通道:

func merge(ctx context.Context, sources ...<-chan msg) <-chan msg {
    ch := make(chan msg)
    go func() {
        <-ctx.Done()
        close(ch)
    }()
    for i := range sources {
        go func(i int) {
            for {
                select {
                case v := <-sources[i]:
                    select {
                    case <-ctx.Done():
                        return
                    case ch <- v:
                    }
                }
            }
        }(i)
    }
    return ch
}

主应用程序将从合并通道接收信息,直到关闭为止。当它从每个通道收到一条消息时,通道将被阻塞,等待主 goroutine 调用Done方法信号。

此特定配置将允许主 goroutine 仅从每个通道接收一条消息。当消息计数达到 goroutine 的数量时,我们可以从主 goroutine 调用Done并重置列表,以便其他 goroutine 将被解锁并能够再次发送消息:

func main() {
    ctx, canc := context.WithTimeout(context.Background(), time.Second)
    defer canc()
    sources := make([]<-chan msg, 5)
    for i := range sources {
        sources[i] = send(ctx, fmt.Sprint("src-", i))
    }
    msgs := make([]msg, 0, len(sources))
    start := time.Now()
    for v := range merge(ctx, sources...) {
        msgs = append(msgs, v)
        log.Println(v.value, time.Since(start))
        if len(msgs) == len(sources) {
            log.Println("*** done ***")
            for _, m := range msgs {
                m.Done()
            }
            msgs = msgs[:0]
            start = time.Now()
        }
    }
}

运行应用程序将导致所有 goroutine 向主 goroutine 发送一条消息。他们每个人都将等待每个人发送他们的信息。然后,他们将再次开始发送消息。这将导致消息按预期顺序发送。

总结

在本章中,我们研究了应用程序的一些特定并发模式。我们了解到生成器是返回通道的函数,还向这些通道提供数据,并在没有更多数据时关闭它们。我们还看到,我们可以使用上下文来允许生成器提前退出。

接下来,我们重点关注管道,这是使用通道进行通信的执行阶段。它们可以是源代码,不需要任何输入;目的地,不返回通道;或中间通道,接收一个通道作为输入,返回一个通道作为输出。

另一种模式是复用和解复用模式,它包括将一个信道扩展到不同的 goroutine,并将多个信道组合成一个信道。它通常被称为扇出扇入,它允许我们对一组数据同时执行不同的操作。

最后,我们学习了如何实现一个名为漏桶的速率限制器的更好版本,它限制特定时间内的请求数量。我们还研究了排序模式,它使用一个专用通道向所有发送 goroutine 发送信号,当它们被允许再次发送数据时。

在下一章中,我们将介绍排序部分中介绍的两个额外主题中的第一个。在这里,我们将演示如何使用反射来构建适用于任何用户提供类型的通用代码。

问题

  1. 什么是发电机?它的职责是什么?
  2. 您如何描述管道?
  3. 什么类型的阶段获得通道并返回通道?
  4. 扇入和扇出的区别是什么?