Go channel及其使用实例 【Go语言圣经笔记】

Channels

如果说goroutine是Go语言程序的并发体的话那么channels则是它们之间的通信机制一个channel是一个通信系统它可以让一个goroutine通过它给另一个goroutine发送值信息每个channel都有一个特定的类型,也就是channels可发送数据的类型。一个可以发送int类型数据的channel一般写为chan int。

使用内置的make函数,我们可以创建一个channel:

ch := make(chan int)  // ch has type 'chan int'

和map类似,channel也对应一个make创建的底层数据结构的引用。当我们复制一个channel或用于函数参数传递时,我们只是拷贝了一个channel引用,因此调用者和被调用者将引用同一个channel对象。和其它的引用类型一样,channel的零值也是nil

两个相同类型的channel可以使用==运算符比较如果两个channel引用的是相同的对象那么比较的结果为真一个channel也可以和nil进行比较

一个channel有发送接收两个主要操作,都是通信行为一个发送语句将一个值从一个goroutine通过channel发送到另一个执行接收操作的goroutine。发送和接收两个操作都使用<-运算符。

在发送语句中,<-运算符分隔channel和要发送的值。在接收语句中,<-运算符写在channel对象之前。一个不使用接收结果的接收操作也是合法的

ch <- x  // s send statement
x = <-ch // a receive expression in an assignment statement
<-ch     // a receive statement; result is discard
// 笔者注:记住左边永远是接收方,左边是channel的时候是发送操作
// 左边是等号赋值的时候是接收操作

Channel还支持close操作,用于关闭channel,随后对基于该channel的任何发送操作都将导致panic异常对一个已经被close过的channel进行接收操作依然可以接受到之前已经成功发送的数据如果channel中已经没有数据的话将产生一个零值的数据

使用内置的close函数就可以关闭一个channel:

close(ch)

以最简单方式调用make函数创建的是一个无缓存的channel,但是我们也可以指定第二个整型参数,对应channel的容量。如果channel的容量大于零,那么该channel就是带缓存的channel

ch = make(chan int)     // unbuffered channel
ch = make(chan int, 0)  // unbuffered channel
ch = make(chan int, 3)  // buffered channel with capacity 3

我们将先讨论无缓存的channel,然后在8.4.4节讨论带缓存的channel。

不带缓存的Channels

一个基于无缓存Channels的发送操作将导致发送者goroutine阻塞直到另一个goroutine在相同的Channels上执行接收操作当发送的值通过Channels成功传输之后两个goroutine可以继续执行后面的语句。反之,如果接收操作先发生,那么接收者goroutine也将阻塞,直到有另一个goroutine在相同的Channels上执行发送操作

基于无缓存Channels的发送和接收操作将导致两个goroutine做一次同步操作。因为这个原因,无缓存Channels有时候也被称为同步Channels。当通过一个无缓存Channels发送数据时,接收者收到数据发生在唤醒发送者goroutine之前(译注:happens before,这是Go语言并发内存模型的一个关键术语!)

在讨论并发编程时,当我们说x事件在y事件之前发生(happens before),我们并不是说x事件在时间上比y时间更早;我们要表达的意思是要保证在y之前的x事件都已经完成了,例如在此之前的更新某些变量的操作已经完成,你可以放心依赖这些已完成的事件带来的结果。

当我们说x事件既不是在y事件之前发生也不是在y事件之后发生,我们就说x事件和y事件是并发的。这并不是意味着x事件和y事件就一定是同时发生的,我们只是不能确定这两个事件发生的先后顺序。(笔者注:并发过程中,事件的执行顺序具有随机性和不可确定性的特点,所以无法确定多个事件并发时的执行顺序,所有可能的执行顺序都有可能发生)在下一章中我们将看到,当两个goroutine并发访问了相同的变量时,我们有必要保证某些事件的执行顺序,以避免出现某些并发问题(笔者注:为了实现并发安全性,并发过程中保证某些事件的执行顺序可以采用一些机制,比如对共享变量加锁等等)。

在8.3节的客户端程序,它在主goroutine中(译注:就是执行main函数的goroutine)将标准输入复制到server,因此当客户端程序关闭标准输入时,后台goroutine可能依然在工作。我们需要让主goroutine等待后台goroutine完成工作后再退出,我们使用了一个channel来同步两个goroutine:

// gopl.io/ch8/netcat3

func main() {
    conn, err := net.Dial("tcp": "localhost:8000")
    if err != nil {
        log.Fatal(err)
    }
    done := make(chan struct{})  // 接收结构体类型的channel
    go func() {
        io.Copy(os.Stdout, conn)  // Note: ignoring errors
        log.Println("done")
        done <- struct{}{}       // signal the main goroutine
    }()
    mustCopy(conn, os.Stdin)
    conn.Close()
    <-done  // wait for background goroutine to finish
    // main goroutine会阻塞在这里 等待创建的goroutine通过done通道发送信息再结束
}

当用户关闭了标准输入,主goroutine中的mustCopy函数调用将返回,然后调用conn.Close()关闭读和写方向的网络连接。关闭网络连接中的写方向的连接将导致server程序收到一个文件(end-of-file)结束的信号。关闭网络连接中读方向的连接将导致后台goroutine的io.Copy函数调用返回一个“read from closed connection”(“从关闭的连接读”)类似的错误,因此我们临时移除了错误日志语句;

在后台goroutine返回之前,它先打印一个日志信息,然后向done对应的channel发送一个值。主goroutine在退出前先等待从done对应的channel接收一个值。因此,总是可以在程序退出前正确输出“done”消息。

基于channels发送消息有两个重要方面首先每个消息都有一个值(笔者注:发送消息目的是值的传递),其次有时候通信的事实和发生的时刻也同样重要(发送消息的目的是为了同步)。当我们更希望强调通信发生的时刻时,我们将它称为消息事件有些消息事件并不携带额外的信息,它仅仅是用作两个goroutine之间的同步,这时候我们可以用struct{}空结构体作为channels元素的类型,虽然也可以使用bool或int类型实现同样的功能,done <- 1语句也比done <- struct{}{}更短。

串联的Channels(Pipeline)

Channels也可以用于将多个goroutine连接在一起的场景下即一个Channel的输出作为下一个Channel的输入。这种串联的Channels就是所谓的管道(pipeline)。下面的程序用两个channels将三个goroutine串联起来,如图8.1所示。
在这里插入图片描述
第一个goroutine是一个计数器,用于生成0、1、2、……形式的整数序列,然后通过channel将该整数序列发送给第二个goroutine;第二个goroutine是一个求平方的程序,对收到的每个整数求平方,然后将平方后的结果通过第二个channel发送给第三个goroutine;第三个goroutine是一个打印程序,打印收到的每个整数。为了使例子看起来简单清晰,我们有意选择了非常简单的函数,当然三个goroutine的计算很简单,在现实中确实没有必要为如此简单的运算构建三个goroutine。

// gopl.io.ch8/pipeline1

func main() {
    naturals := make(chan int)
    squares := make(chan int)
    
    // Counter
    go func() {
        for x := 0; ; x++ {
            naturals <- x
        }
    }()  // 加括号表示声明匿名函数后直接运行 是一次函数调用 别忘了
    
    // Squarer
    go func() {
        for {
            x := <- naturals
            squares <- x*x
        }
    }()
    
    // Printer (in main goroutine)
    for {
        fmt.Println(<-squares)
    }
}

上面的程序将生成0、1、4、9、……形式的无穷数列。像这样的串联的Channels形成的的管道(Pipelines)可以用在需要长时间运行的服务中每个长时间运行的goroutine可能会包含一个死循环在不同goroutine的死循环内部使用串联的Channels来通信。但是,如果我们希望通过Channels只发送有限的数列该如何处理呢?

如果发送者知道,没有更多的值需要发送到channel的话,那么让接收者也能及时知道没有多余的值可接收将是有用的,因此接收者可以停止不必要的接收等待。这可以通过内置的close函数来关闭channel实现:

close(naturals)

当一个channel被关闭后,再向该channel发送数据将导致panic异常当一个被关闭的channel中已经发送的数据都被成功接收后,后续的接收操作将不再阻塞,它们会立即返回一个零值。关闭上面例子中的naturals变量对应的channel并不能终止循环,它依然会收到一个永无休止的零值序列,然后将它们发送给打印者goroutine。

没有办法直接测试一个channel是否被关闭,但是接收操作有一个变体形式:它多接收一个结果,多接收的第二个结果是一个布尔值ok,ture表示成功从channels接收到值,false表示channels已经被关闭并且里面没有值可接收。使用这个特性,我们可以修改squarer函数中的循环代码,当naturals对应的channel被关闭并没有值可接收时跳出循环,并且也关闭squares对应的channel.

// Squarer
go func() {
    for {
        x, ok := <-naturals
        if !ok {
            break  // channel was closed and drained
        }
        squares <- x*x
    }
    close(squares)
}()

由于上面的语法是笨拙的,并且这种处理模式很常见,因此Go语言的range循环可直接在channels上面迭代使用range循环是上面处理模式的简洁语法,它依次从channel接收数据,当channel被关闭并且没有值可接收时跳出循环

在下面的改进中,我们的计数器goroutine只生成100个含数字的序列,然后关闭naturals对应的channel,这将导致计算平方数的squarer对应的goroutine可以正常终止循环并关闭squares对应的channel。(在一个更复杂的程序中,可以通过defer语句关闭对应的channel。)最后,主goroutine也可以正常终止循环并退出程序。

// gopl.io/ch8/pipeline2

func main() {
    naturals := make(chan int)
    squares := make(chan int)
    
    // Counter
    go func() {
        for x:=0; x<100; x++ {
            naturals <- x
        }
        close(naturals)
    }()
    
    // Squarer
    go func() {
        for x:= range naturals {  // 注意:使用range直接在channge上迭代 每次返回一个值
            squares <- x*x
        }
        close(squares)
    }()
}

    // Printer (in main goroutine)
    for x:= range squares { // 注意:使用range直接在channge上迭代 每次返回一个值
        fmt.Println(x)
    }

其实你并不需要关闭每一个channel。只有当需要告诉接收者goroutine,所有的数据已经全部发送时才需要关闭channel不管一个channel是否被关闭,当它没有被引用时将会被Go语言的垃圾自动回收器回收。(不要将关闭一个打开文件的操作和关闭一个channel操作混淆。对于每个打开的文件,都需要在不使用的时候调用对应的Close方法来关闭文件。)

  • 试图重复关闭一个channel将导致panic异常
  • 试图关闭一个nil值的channel也将导致panic异常
  • 关闭一个channels还会触发一个广播机制

单向的Channel

随着程序的增长,人们习惯于将大的函数拆分为小的函数。我们前面的例子中使用了三个goroutine,然后用两个channels来连接它们,它们都是main函数的局部变量。将三个goroutine拆分为以下三个函数是自然的想法:

func counter(out chan int)
func squarer(out in chan int)
func printer(in chan int)

其中计算平方的squarer函数在两个串联Channels的中间,因此拥有两个channel类型的参数,一个用于输入一个用于输出。两个channel都拥有相同的类型,但是它们的使用方式相反:一个只用于接收,另一个只用于发送。参数的名字in和out已经明确表示了这个意图,但是并无法保证squarer函数向一个in参数对应的channel发送数据或者从一个out参数对应的channel接收数据。

这种场景是典型的:当一个channel作为一个函数参数时,它一般总是被专门用于只发送或者只接收

为了表明这种意图并防止被滥用,Go语言的类型系统提供了单方向的channel类型,分别用于只发送或只接收的channel。类型chan<- int表示一个只发送int的channel,只能发送不能接收。相反,类型<-chan int表示一个只接收int的channel,只能接收不能发送。(箭头<-和关键字chan的相对位置表明了channel的方向。)这种限制将在编译期检测。

笔者注:chan<- int发送类型的channel,<-chan int接收类型的channel。

因为关闭操作只用于断言不再向channel发送新的数据所以只有在发送者所在的goroutine才会调用close函数因此对一个只接收的channel调用close将是一个编译错误

这是改进的版本,这一次参数使用了单方向channel类型:

func counter(out chan<- int) {
    for x:=0; x<100; x++ {
        out <- x
    }
    close(out)
}

func squarer(out chan<- int, in <-chan int) {
    for v:= range in {
        out <- v*v
    }
    close(out)
}

func printer(in <-chan int) {
    for v:= range in {
        fmt.Println(v)
    }
}

func main() {
    naturals := make(chan int)     // 声明是双向channel 下同
    squares := make(chan int)
    go counter(naturals)           // 参数传递使会发生隐式转换, 下同
    go squarer(squares, naturals)  // 第一个发送 第二个接收
    printer(squares)
}

调用counter(naturals)时,naturals的类型将隐式地从chan int转换成chan<- int。调用printer(squares)也会导致相似的隐式转换,这一次是转换为<-chan int类型只接收型的channel。任何双向channel向单向channel变量的赋值操作都将导致该隐式转换。这里并没有反向转换的语法:也就是不能将一个类似chan<- int类型的单向型的channel转换为chan int类型的双向型的channel。

带缓存的Channels

buffered channel

带缓存的Channel内部持有一个元素队列队列的最大容量是在调用make函数创建channel时通过第二个参数指定的。下面的语句创建了一个可以持有三个字符串元素的带缓存Channel。图8.2是ch变量对应的channel的图形表示形式。

ch := make(chan string, 3)

在这里插入图片描述

向缓存Channel进行发送操作就是向内部缓存队列的尾部插入元素,**接收操作则是从队列的头部删除元素。**如果内部缓存队列是满的,那么发送操作将阻塞直到因另一个goroutine执行接收操作而释放了新的队列空间。相反,如果channel是空的,接收操作将阻塞直到有另一个goroutine执行发送操作而向队列插入元素。

我们可以在无阻塞的情况下连续向新创建的channel发送三个值(笔者注:因为缓存队列的最大容量为3):

ch <- "A"
ch <- "B"
ch <- "C"

此刻,channel的内部缓存队列将是满的(图8.3),如果有第四个发送操作将发生阻塞。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-o6Hx5fVH-1632751750776)(evernotecid://B80A85D8-91F7-47C1-AB3E-714AFC7C2671/appyinxiangcom/33702487/ENResource/p795)]
如果我们接收一个值,则:

fmt.Println(<-ch)  // A 这个例子不说不用变量接收channel返回值也是可以的 但这样做就相当于将返回值视为用完即弃的临时性变量

那么channel的缓存队列将不是满的也不是空的(图8.4),因此对该channel执行的发送或接收操作都不会发生阻塞。通过这种方式,channel的缓存队列解耦了接收和发送的goroutine
在这里插入图片描述

在某些特殊情况下,程序可能需要知道channel内部缓存的容量,可以用内置的cap函数获取:

fmt.Println(cap(ch))  // 3

同样,对于内置的len函数,如果传入的是channel,那么将返回channel内部缓存队列中有效元素的个数。虽然在并发程序中该信息会随着接收操作而失效,但是它对某些故障诊断和性能优化会有帮助。

fmt.Println(len(ch))  // 2

在继续执行两次接收操作后channel内部的缓存队列将又成为空的,如果有第四个接收操作将发生阻塞:

fmt.Println(<-ch)  // B
fmt.Println(<-ch)  // C

在这个例子中,发送和接收操作都发生在同一个goroutine中,但是在真实的程序中它们一般由不同的goroutine执行。Go语言新手有时候会将一个带缓存的channel当作同一个goroutine中的队列使用,虽然语法看似简单,但实际上这是一个错误Channel和goroutine的调度器机制是紧密相连的,如果没有其他goroutine从channel接收,发送者——或许是整个程序——将会面临永远阻塞的风险。如果你只是需要一个简单的队列,使用slice进行模拟就可以了。

下面的例子展示了一个使用了带缓存channel的应用。它并发地向三个镜像站点发出请求,三个镜像站点分散在不同的地理位置。它们分别将收到的响应发送到带缓存channel,最后接收者只接收第一个收到的响应,也就是最快的那个响应。因此mirroredQuery函数可能在另外两个响应慢的镜像站点响应之前就返回了结果。(顺便说一下,多个goroutines并发地向同一个channel发送数据,或从同一个channel接收数据都是常见的用法。)

func mirroredQuery() string {
    responses := make(chan string, 3)
    go func() { responses <- request("asia.gopl.io") }()
    go func() { responses <- request("europe.gopl.io") }()
    go func() { responses <- request("americas.gopl.io") } ()
    return <-responses  // return the quickest response 注意,只会取一个数据,最快的那个
}

如果我们使用了无缓存的channel,那么两个慢的goroutines将会因为没有人接收而被永远卡住。这种情况,称为goroutines泄漏,这将是一个BUG。不同于垃圾变量回收泄漏的goroutines并不会被自动回收因此确保每个不再需要的goroutine能正常退出是重要的

关于无缓存或带缓存channels之间的选择,或者是带缓存channels的容量大小的选择,都可能影响程序的正确性。无缓存channel更强地保证了每个发送操作与相应的接收操作同步但是对于带缓存channel,这些操作是解耦的。同样,即使我们知道将要发送到一个channel的信息的数量上限,创建一个对应容量大小的带缓存channel也是不现实的,因为这要求在执行任何接收操作之前缓存所有已经发送的值。如果未能分配足够的缓存将导致程序死锁

Channel的缓存也可能影响程序的性能。想象一家蛋糕店有三个厨师,一个烘焙,一个上糖衣,还有一个将每个蛋糕传递到它下一个厨师的生产线。在狭小的厨房空间环境,每个厨师在完成蛋糕后必须等待下一个厨师已经准备好接受它;这类似于在一个无缓存的channel上进行沟通。

如果在每个厨师之间有一个放置一个蛋糕的额外空间,那么每个厨师就可以将一个完成的蛋糕临时放在那里而马上进入下一个蛋糕的制作中;这类似于将channel的缓存队列的容量设置为1。只要每个厨师的平均工作效率相近,那么其中大部分的传输工作将是迅速的,个体之间细小的效率差异将在交接过程中弥补。如果厨师之间有更大的额外空间——也是就更大容量的缓存队列——将可以在不停止生产线的前提下消除更大的效率波动,例如一个厨师可以短暂地休息,然后再加快赶上进度而不影响其他人。

另一方面,如果生产线的前期阶段一直快于后续阶段,那么它们之间的缓存在大部分时间都将是满的。相反,如果后续阶段比前期阶段更快,那么它们之间的缓存在大部分时间都将是空的。对于这类场景,额外的缓存并没有带来任何好处。

上述生产线的隐喻对于理解channels和goroutines的工作机制是很有帮助的。例如,如果第二阶段是需要精心制作的复杂操作,一个厨师可能无法跟上第一个厨师的进度,或者是无法满足第三阶段厨师的需求。要解决这个问题,我们可以再雇佣另一个厨师来帮助完成第二阶段的工作,他执行相同的任务但是独立工作。这类似于基于相同的channels创建另一个独立的goroutine。

我们没有太多的空间展示全部细节,但是gopl.io/ch8/cake包模拟了这个蛋糕店,可以通过不同的参数调整。它还对上面提到的几种场景提供对应的基准测试(§11.4节会进行展示)

并发的循环

本节中,我们会探索一些用来在并行时循环迭代的常见并发模型。我们会探究从全尺寸图片生成一些缩略图的问题。gopl.io/ch8/thumbnail包提供了ImageFile函数来帮我们拉伸图片。我们不会说明这个函数的实现,只需要从gopl.io下载它(下载地址是gopl.io/ch8/thumbnail,以下代码不再注明下载地址)。

package thumbnail

// ImageFile reads an image from infile and writes a thumbnail-size version of it in the same diretory
// It returns the generated file name, e.g., "foo.thumb.jpg"
func ImageFile(infile string) (string, error)

下面的程序会循环迭代一些图片文件名,并为每一张图片生成一个缩略图:

// makeThumbnails makes thumbnails of the specified files.
func makeThumbnails(filenames []string) {
    for _, f := range filenames {
        if _, err := thumbnail.ImageFile(f); err != nil {
            log.Println(err)  // Note: ignoring errors
        }
    }
}

显然我们处理文件的顺序无关紧要,因为每一个图片的拉伸操作和其它图片的处理操作都是彼此独立的。像这种子问题都是完全彼此独立的问题被叫做易并行问题(译注:embarrassingly parallel,直译的话是尴尬并行)。易并行问题是最容易被实现成并行的一类问题,并且最能够享受到并发带来的好处,能够随着并行的规模线性地扩展。

下面让我们并行地执行这些操作,从而将文件IO的延迟隐藏掉,并用上多核cpu的计算能力来拉伸图像。我们的第一个并发程序只是使用了一个go关键字。这里我们先忽略掉错误,之后再进行处理。

// Note: incorrect!
func makeThumbnail2(filenames []string) {
    for _, f := range{
        go thumbnail.ImageFile(f)  // Note: ignoring errors
    }
}

这个版本运行的实在有点太快,实际上,由于它比最早的版本使用的时间要短得多,即使当文件名的slice中只包含有一个元素。这就有点奇怪了,如果程序没有并发执行的话,那为什么一个并发的版本还是要快呢?答案其实是makeThumbnails在它还没有完成工作之前就已经返回了。它启动了所有的goroutine,每一个文件名对应一个,但没有等待它们一直到执行完毕

没有什么直接的办法能够等待goroutine完成,但是我们可以改变goroutine里的代码让其能够将完成情况报告给外部的goroutine知晓,使用的方式是向一个共享的channel中发送事件。因为我们已经确切地知道有len(filenames)个内部goroutine,所以外部的goroutine只需要在返回之前对这些事件计数。

// makeThumbnails3 makes thumbnails of the specified files in parallel.
func makeThumbnails3(filenames []string) {
    ch := make(chan struct{})
    for _,f := range filenames {
        go func(f string){
            thumbnail.ImageFile(f)  // Note: ignoring errors
            ch <- struct{}{}
        }(f)
    }
    // Wait for goroutines to complete
    for range filenames {
        <-ch
    }
}

注意我们将f的值作为一个显式的变量传给了函数,而不是像下面这样在循环的闭包中声明:

for _, f := range filenames {
    go func() {
        thumbnail.ImageFile(f) // NOTE: incorrect!
        // ...
    }()
}

回忆一下之前在5.6.1节中,匿名函数中的循环变量快照问题。上面这个单独的变量f是被所有的匿名函数值所共享,且会被连续的循环迭代所更新的。当新的goroutine开始执行字面函数时,for循环可能已经更新了f并且开始了另一轮的迭代或者(更有可能的)已经结束了整个循环,所以当这些goroutine开始读取f的值时,它们所看到的值已经是slice的最后一个元素了。显式地添加这个参数,我们能够确保使用的f是当go语句执行时的“当前”那个f。

如果我们想要从每一个worker goroutine往主goroutine中返回值时该怎么办呢?
当我们调用thumbnail.ImageFile创建文件失败的时候,它会返回一个错误。下一个版本的makeThumbnails会返回其在做拉伸操作时接收到的第一个错误:

// makeThumbnails4 makes thumbnails for the specified files in parallel.
// It returns an error if any step failed.

func makeThumbnails4(filenames []string) error {
    errors := make(chan error)
    
    for _, f := range filenames {
        go func(f string) {
            _, err := thumbnail.ImageFile(f)
            errors <- err
        }(f)
    }
    
    for range filenames {
        if err := <- errors; err != nil {
            return err  // Note: incorrect: goroutine leak!
        }
    }
    return nil
}

这个程序有一个微妙的bug。当它遇到第一个非nil的error时会直接将error返回到调用方,程序退出,如果再有goroutine向errors channel发送值后不再会有地方接收(makeThumbnail函数已经退出),但这时候其他的goroutine仍在运行。一旦剩下的worker goroutine中有任何一个向这个channel中发送了值,其他goroutine都会永远地阻塞下去,并且永远都不会退出。这种情况叫做goroutine泄露(§8.4.4),可能会导致整个程序卡住或者跑出out of memory的错误。

最简单的解决办法就是用一个具有合适大小的buffered channel,这样这些worker goroutine向channel中发送错误时就不会被阻塞。(一个可选的解决办法是创建一个另外的goroutine,当main goroutine返回第一个错误的同时去排空channel)

下一个版本的makeThumbnails使用了一个buffered channel来返回生成的图片文件的名字,附带生成时的错误。

// makeThumbnail5 makes thumbnails for the specified files in parallel.
// It returns the generated file names in an arbitrary order,
// or an error if any step failed.
func makeThumbnails5(filenames []string, err error) (thumbfiles []string, err error) {  // 注:函数返回值不一样了 现在是一个string slice加一个error 
    type item struct {
        thumbfile string
        err       error
    }
    
    ch := make(chan item, len(filenames))
    for _, f := range filenames {
        go func(f string) {
            var it item
            it.thumbfile, it.err = thumbnail.ImageFile(f)
            ch <- it
        }(f)
    }
    
    for range filenames {
        it := <- ch
        if it.err != nil {
            return nil, it.err
        }
        thumbfiles = append(thumbfiles, it.thumbfile)
    }
    
   return thumbfiles, nil
}

笔者注:解决问题的关键在于channel带缓冲了,用不用结构体都一样,本例由于过于简单,因而使用结构体的优势表现不出来,事实上它的作用就像java的内部类一样,进行了一定程度的数据聚合和封装。

我们最后一个版本的makeThumbnails返回了新文件们的大小总计数(bytes)。和前面的版本都不一样的一点是我们在这个版本里没有把文件名放在slice里,而是通过一个string的channel传过来,所以我们无法对循环的次数进行预测。

为了知道最后一个goroutine什么时候结束(最后一个结束并不一定是最后一个开始),我们需要一个递增的计数器,在每一个goroutine启动时加一,在goroutine退出时减一。这需要一种特殊的计数器,这个计数器需要在多个goroutine操作时做到安全并且提供在其减为零之前一直等待的一种方法。这种计数类型被称为sync.WaitGroup,下面的代码就用到了这种方法:

// makeThumbnails6 makes thumbnails for each file received from the channel.
// It returns the number of bytes occupied by the files it creates.
func makeThumbnail6(filename <-chan string) int64 {
    size := make(chan int64)
    var wg sync.WaitGroup()  // count the number of working goroutines
    for f := range filenames {
        wg.Add(1)
        go func(f string) {
            defer wg.Done()  // 函数运行结束后wg-1
            thumb, err := thumbnail.Imagefile(f)
            if err != nil {
                log.Println(err)
                return
            }
            info, _ = os.Stat(thumb)  // OK to ignore error
            sizes <- info.Size()
        }(f)
    }
    
    // closer
    go func() {
        wg.Wait() // wait会等到wg减到0
        close(sizes)
    }()
    
    // 程序运行到这里时channel已经关闭了
    // 这时候仍可以读取channel里面的数据
    // 下面的内容只是补充记忆,跟本例无关
    // 读取完之后仍可再读取 返回零值
    // 但向关闭的channel发送数据会直接panic
    
    var total int64
    for size := range sizes {
        total += size
    }
    return total
}

注意Add和Done方法的不对称。Add是为计数器加一,必须在worker goroutine开始之前调用,而不是在goroutine中;否则的话我们没办法确定Add是在"closer" goroutine调用Wait之前被调用。并且Add还有一个参数,但Done却没有任何参数,其实它和Add(-1)是等价的。我们使用defer来确保计数器即使是在出错的情况下依然能够正确地被减掉。上面的程序代码结构是当我们使用并发循环,但又不知道迭代次数时很通常而且很地道的写法。

sizes channel携带了每一个文件的大小到main goroutine,在main goroutine中使用了range loop来计算总和。观察一下我们是怎样创建一个closer goroutine,并让其在所有worker goroutine们结束之后再关闭sizes channel的。两步操作:wait和close,必须是基于sizes的循环的并发。考虑一下另一种方案:如果等待操作被放在了main goroutine中,在循环之前,这样的话就永远都不会结束了,如果在循环之后,那么又变成了不可达的部分,因为没有任何东西去关闭这个channel,这个循环就永远都不会终止。

下图8.5表明了makethumbnails6函数中事件的序列。纵列表示goroutine。窄线段代表sleep,粗线段代表活动。斜线箭头代表用来同步两个goroutine的事件。时间向下流动。注意main goroutine是如何大部分的时间被唤醒执行其range循环,等待worker发送值或者closer来关闭channel的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fBzXl4fy-1632752678825)(evernotecid://B80A85D8-91F7-47C1-AB3E-714AFC7C2671/appyinxiangcom/33702487/ENResource/p797)]

示例:并发的Web爬虫

在5.6节中,我们做了一个简单的web爬虫,用bfs(广度优先)算法来抓取整个网站。在本节中,我们会让这个爬虫并行化,这样每一个彼此独立的抓取命令可以并行进行IO,最大化利用网络资源。crawl函数和gopl.io/ch5/findlinks3中的是一样的。

// gopl.io/ch8/crawl1
func crawl(url string) []string {
    fmt.Println(url)
    list, err := links.Extract(url)
    if err != nil {
        log.Print(err)
    }
    return list
}

主函数和5.6节中的breadthFirst(广度优先)类似。像之前一样,一个worklist是一个记录了需要处理的元素的队列,每一个元素都是一个需要抓取的URL列表,不过这一次我们用channel代替slice来做这个队列。每一个对crawl的调用都会在他们自己的goroutine中进行并且会把他们抓到的链接发送回worklist。

func main() {
    worklist := make(chan []string)
    
    // start with the command-line arguments
    go func() { worklist <- os.Args[1:] }()  // 括号表示声明后直接调用匿名函数 别忘了
    
    // crawl the web concurrently
    seen := make(map[string][bool])
    for list := range worklist {
        for _, link := range list {
            if !seen[link] {
                seen[link] = true
                go func(link string) {
                    worklist <- crawl(link)    
                } (link)
            }
        }
    }
}

注意这里的crawl所在的goroutine会将link作为一个显式的参数传入,来避免“循环变量快照”的问题(在5.6.1中有讲解)。另外注意这里将命令行参数传入worklist也是在一个另外的goroutine中进行的,这是为了避免channel两端的main goroutine与crawler goroutine都尝试向对方发送内容,却没有一端接收内容时发生死锁。当然,这里我们也可以用buffered channel来解决问题,这里不再赘述。

现在爬虫可以高并发地运行起来,并且可以产生一大坨的URL了,不过还是会有两个问题。一个问题是在运行一段时间后可能会出现在log的错误信息里的:

$ go build gopl.io/ch8/crawl1
$ ./crawl1 http://gopl.io/
http://gopl.io/
https://golang.org/help/
https://golang.org/doc/
https://golang.org/blog/
...
2015/07/15 18:22:12 Get ...: dial tcp: lookup blog.golang.org: no such host
2015/07/15 18:22:12 Get ...: dial tcp 23.21.222.120:443: socket: too many open files
...

最初的错误信息是一个让人无法预料的的DNS查找失败,即使这个域名是完全可靠的。而随后的错误信息揭示了原因:这个程序一次性创建了太多网络连接,超过了每一个进程的打开文件数限制,进而导致了在调用net.Dial像DNS查找失败这样的问题。

这个程序实在是过于并行了。无穷无尽地并行化并不是什么好事情,因为不管怎么说,你的系统总是会有一些个限制因素,比如CPU核心数会限制你的计算负载,比如你的硬盘转轴和磁头数限制了你的本地磁盘IO操作频率,比如你的网络带宽限制了你的下载速度上限,或者是你的一个web服务的服务容量上限等等。为了解决这个问题,我们可以限制并发程序所使用的资源来使之适应自己的运行环境。对于我们的例子来说,最简单的方法就是限制对links.Extract在同一时间最多不会有超过n次调用,这里的n一般小于文件描述符的上限值,比如20。这和一个酒吧限制客人数目是一个道理,只有当有客人离开时,才会允许新的客人进入店内。

我们可以用一个有容量限制的buffered channel来控制并发,这类似于操作系统里的计数信号量概念。从概念上讲,channel里的n个空槽代表n个可以处理内容的token(通行证),从channel里接收一个值会释放其中的一个token并且生成一个新的空槽位。这样保证了在没有接收操作介入的情况下最多有n个发送操作。(这里可能我们拿channel里填充的槽来做token更直观一些,不过还是这样吧~)。由于channel里的元素类型并不重要,我们用一个零值的struct{}来作为其元素。

让我们重写crawl函数,将对links.Extract的调用操作用获取、释放token的操作囊括起来,来确保同一时间对其只有20个调用。信号量数量和其能操作的IO资源数量应保持接近

// gopl.io/ch8/crawl2
// tokens is a counting semaphore enforce a limit of 20 concurrent requests

var tokens = make(chan struct{}, 20)  // buffered channel 容量20

func crawl(url string) []stirng {
    fmt.Println(url)
    tokens <- struct{}{}  // acquire a token
    list, err := links.Extract(url)
    <-tokens  // release the token
    if err != nil {
        log.Print(err)
    }
    return list
}

第二个问题是这个程序永远都不会终止,即使它已经爬到了所有初始链接衍生出的链接(译者注:worklist为空时返回零值,因此外层for循环不会终止)。(当然,除非你慎重地选择了合适的初始化URL或者已经实现了练习8.6中的深度限制,你应该还没有意识到这个问题)。为了使这个程序能够终止,我们需要在worklist为空或者没有crawl的goroutine在运行时退出主循环。

func main() {
    worklist := make(chan []string)
    var n int  // number of pending sends to worklist
    
    // start with the command-line arguments
    n++
    go func() { worklist <- os.Args[1:] }()
    
    // crawl the web concurrently
    seen := make(map[string]bool)
    
    for ; n>0; n-- {
        list := <- worklist
        for _, link := range list {
            if !seen[link] {
                seen[link] = true
                n++  // 如果list为空或者link已经访问过n不会++ 则会-- 到0则停止
                go func(link string) {
                    worklist <- crawl(link)
                }(list)
            }
        }
    }
}

这个版本中,计数器n对worklist的发送操作数量进行了限制。每一次我们发现有元素需要被发送到worklist时,我们都会对n进行++操作,在向worklist中发送初始的命令行参数之前,我们也进行过一次++操作。这里的操作++是在每启动一个crawler的goroutine之前。主循环会在n减为0时终止,这时候说明不再有链接可以爬取。

现在这个并发爬虫会比5.6节中的深度优先搜索版快上20倍,而且不会出什么错,并且在其完成任务时也会正确地终止。

下面的程序是避免过度并发的另一种思路。这个版本使用了原来的crawl函数,但没有使用计数信号量,取而代之用了20个常驻的crawler goroutine,这样来保证最多20个HTTP请求在并发。

func main() {
    worklist := make(chan []string)  // lists of URLs, may have duplicates
    unseenLinks := make(chan string)  // de-duplicated URLs
    
    // Add command-line argument to worklist
    go func() { worklist <- os.Args[1:] }()
    
    // Create 20 crawler goroutines to fetch each unseen link
    for i:=0; i<20; i++ {
        go func() {
            for link := range unseenLinks {
                foundLinks := crawl(link)
                go func() { worklist <- foundLinks }()  // 这步创建新的goroutine是为了 避免channel阻塞而浪费时间 
            }
        }()
    }
    
    // the main goroutine de-dupliactes worklist items
    // and sends the unseen ones to the crawlers
    seen := make(map[string]bool)
    for list := range worklist {
        for _, link := range list {
            if !seen[link] {
                seen[link] = true
                unseenLinks <- link
            }
        }
    }
}

所有的爬虫goroutine现在都是被同一个channel - unseenLinks喂饱的了。主goroutine负责拆分它从worklist里拿到的元素,然后把没有抓过的经由unseenLinks channel发送给一个爬虫的goroutine(笔者注:一共有20个,for循环控制变量决定了)。

seen这个map被限定在main goroutine中;也就是说这个map只能在main goroutine中进行访问。这类似于其它的信息隐藏方式,如此这样的约束可以让我们从一定程度上保证程序的正确性。例如,内部变量不能够在函数外部被访问到;变量(§2.3.4)在没有发生变量逃逸(译注:局部变量被全局变量引用地址导致变量被分配在堆上)的情况下是无法在函数外部访问的;一个对象的封装字段无法被该对象的方法以外的方法访问到。在所有的情况下,信息隐藏都可以帮助我们约束我们的程序,使其不发生意料之外的情况。

crawl函数爬到的链接在一个专有的goroutine中被发送到worklist中来避免死锁。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Go语言学习笔记.pdf》是一本关于Go语言学习的学习笔记,内容丰富且简洁明了。本书从基础知识开始,逐步介绍了Go语言的语法、特性和常用库函数等。在学习笔记中,作者通过实际的示例和练习帮助读者理解Go语言的概念和用法。 第一章介绍了Go语言的起源和发展,为读者提供了对Go语言背景的整体了解。第二章讲解了Go语言的基本语法,例如变量声明、循环和条件语句等。通过大量的代码示例,读者能够更好地理解Go语言的语法和结构。 接下来的章节重点介绍了Go语言的并发编程和高级特性。第三章详细介绍了Go语言中的goroutine和channel,这是Go语言并发编程的核心机制。作者通过生动的示例代码和实际应用案例,向读者展示了如何使用goroutine和channel实现并发编程。 第四章和第五章分别介绍了Go语言中的面向对象编程和函数式编程。通过深入讲解Go语言中的结构体、接口和函数,读者能够更好地应用这些特性进行代码设计和开发。 最后几章则介绍了Go语言中常用的库函数和工具。例如,第六章介绍了Go语言中用于网络编程的net包和http包。读者可以学习到如何使用这些库函数构建基于网络的应用程序。 总的来说,《Go语言学习笔记.pdf》是一本非常实用的Go语言学习资料。通过阅读这本书,读者能够系统地学习和理解Go语言的基本概念和高级特性,为之后的Go语言开发打下坚实的基础。无论是初学者还是有一定编程经验的开发者,都能从中获得丰富的知识和经验。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值