《Programming in Go》第七章并发编程译文

 

中文译名

Go 编程

外文原文名

Programming in Go

外文原文版出处

the United States on recycled paper at RR Donnelley in Crawfordsville, Indiana.

译 文:

第七章 并发编程

7.1主要概念

7.2例子

    7.2.1:过滤器

    7.2.2:并发查找

    7.2.3:线程安全表

    7.2.4:Apache 报告

7.2.5:找重复

   并发编程能够让开发者实现并行算法,以及利用多处理器和多核写程序。但是在大多主流变成语言中(例如C,C++和Java)相对单线程程序,很难实现、维护以及调试并发程序。进一步的,并不总是可能分开处理、值得去使用多线程。无论如何,由于线程本身的开销或者仅仅因为更容易在多线程程序中出错,不是总能够实现期待中的性能优势。

   一个方法是完全地避开线程。例如我们通过多处理器能够把负担转移操作系统。然而,缺点是,操作系统让我们负责控制进程间通信,大多时候比共享内存并发开销更大。

   Go的解决办法有三部分。第一,Go对并发编程提供高程度支持,让其更容易正确地实现;第二,是在比线程更轻量级的协程完成并发处理;第三,有时自动垃圾回收机制减轻程序员的极其复杂的,并发程序所需的内存管理。

   Go 是基于CSP(消息队列进程),内置高级的应用程序接口提供给并发程序实现。这意味着显示上锁,能够避免所有需要关注的在正确时间上锁和解锁,利用发送和接收数据通过线程安全的通道实现同步。这极大的简化了并发程序的编写。另一方面,几十个线程就能够让一个典型的台式电脑负荷,同样的机器却能够轻松处理上百上千,甚至上万的协程。Go的方法让程序员去思考他们想要并发程序想要实现什么,而不是上锁和其他底层细节方面。

   大多其他语言支持非常低级的并发操作(原子增加,对比和交换),和一些低级的工具互斥锁,没有其他主流语言像Go提供这般内置的高程度并发支持。(或许除了附加库,不是语言的组成部分)

 

   除了高程度支持并发是这章的主题,Go也提供和其他语言一样的低级别的功能。在最低级,标准库的sync/atomic 包提供完成原子加法,比较和交换操作的函数。这些先进的函数被设计去支持线程安全的同步算法和数据结构的实现,他们不适用于应用程序程序员。Go的sync包提供常见的低级并发基本体:等待条件和互斥锁。这些是和他在其他语言等同级别的,所以应用程序员经常被迫去使用他们。

   Go 应用程序员并发编程将使用Go的高级工具——通道和协程。另外,无论多少次调用,sync.Once 类型仅仅能够用来调用一次函数,sync.WaitGroup 类型提供高级同步机制,后面将会看到。

   我们已经在第五章覆盖了基本的语法以及通道和协程的使用。这些不会在这里重复,被假设已经了解,所以重读,至少略读对后续是有用的。

   这一章开始叙述一些在Go并发变成里面的主要概念。然后这章讲展示五个完整的程序诠释Go并发编程,展示一些基本用法。第一个例子展示如何创建管道,每部分管道执行各自协程最大的吞吐量。第二个例子展示如何把工作分开到一定数目的协程中,输出他们互相独立的结果。第三个例子展示如何不使用看得见的锁或者低级基本元素,来创建线程安全数据结构。第四个例子,用三个不同方法展示如何在一定数目的协程中独立完成工作的每一部分,并把结果合并在一起。第五个例子展示如何依赖与处理过程创建一定数目的协程,并如何把这些协程的工作合并到一个单独的结果集。

 

7.1 主要概念

   在并发编程中,我们典型地想要分离那些需要超过一个或者更多的协程的处理(有别于主协程),以及要么当计算完输出结果,要么在最后合并这些结果输出。

   即使用Go的高级并发方法,这里也有我们必须避免的陷阱。其中一个陷阱是程序将立刻结束但是没有产生任何结果。在主协程终止的那一刻,即使其他协程还在运行,Go 程序自动地终止,所以我们必须足够小心让主协程保持足够的时间到所有的工作完成。

   另外一个陷阱我们必须避免的是死锁。一种形式上这个问题本质上和第一个陷阱相反:主线程以及所有处理协程都保持活动着,即使所有的工作都已经完成。这通常是完成处理报告失败。另一种死锁是两个不同的协程(或者线程)使用锁去保护资源以及请求相同的锁在同一时间,在图7.1中展示。这种死锁只有当使用锁才会出现,所以这是其他语言的一种普遍的风险,但相当少出现在Go,因为Go应用能够使用通道来避免使用锁。

 

   一般避免提前终止和不结束的方法是让煮协程等待一个“完成”通道来报告工作已经完成(我们静马上看到,也可以在7.2.2,7.2.4看到)。(也可以在最后的“结果”发送一个标志值,但这相对于其他方法显得笨拙)

   另外一个避免陷阱的方法是使用sync.WaitGroup 等待所有的处理协程报告他们结束。然而,使用sync.WaitGroup本身也会导致死锁,特别地,当所有处理协程都是堵塞的(例如,等待从通道接收),在主协程中出现sync.WaitGroup.Wait()的调用。 我们待会将会看见如何使用sync.WaitGroup。(7.2.5)

   在Go中,即使我们仅仅使用通道而不使用锁,依然可能产生死锁。例如,假设我们有一系列的协程能够互相访问,执行函数(例如互相发送请求)。现在如果其中一个被请求的函数向其中一个正在执行的协程发送,例如发送一些数据,我们就会产生死锁。在图7.2中展示(我们将会在337,340看见这种死锁是可能的)

 

  通道提供一种对正在并行地运行的协程,无锁的通信手段。(在底下锁也是能够使用的,但这些我们不用关心他们本身的实现细节。)当一个通道通信发生,在同一时刻发送和接收通道(以及他们各自的协程)是同步的。

   默认的通道是双向的,也就是说,我们能够发送数据进入通道也能够通过他们获取数据。然而,把通道放入一个结构中或者把通道想参数传递来当成单项通道使用也是相当普遍的,也就是要么只能发送数据,要么只能接收数据。在这些情况下,我们能够通过表达的语意(以及强制编译器来检查)来区分通道的方向。例如,类型 chan<-Type 是一个只发送消息的通道,类型<-chan Type是一个只接收消息的通道。在前面的章节我们不使用这些语法,是因为不需要,我们总可以用chan类型来代替,以及有很多其他要学习。但现在起,我们在适当的时候将会使用单向通道,因为他们产生额外的编译时间检查,和最佳的实践。

   发送像bool,int,float64的数值穿过通道,是内在安全的,因为这些是拷贝的,所以对相同的数值没有疏忽并发的风险。同样的,发送string类型也是安全的,因为他们是不可变的。

   发送指针或者引用(例如slice 或者map)通过通道不是内在安全的,因为指向或者引用到的数值能够被发送所在的协程或者接收所在的协程同一时间改变,得不到期望得结果。       

所以,当传来指针或者引用,我们必须确保他们在同一时刻只能够被仅仅一个协程使用,也就是说使用权必须被序列化。例外的是,在文件特别地说明它通过指针是安全的,例如 同样的 *regexp.Regexp 能够安全地用在多个协程如果我们需要,因为没有方法使用值,改变值的状态。

序列化访问的其中一个方法是使用互斥锁。另外一个方法是应用一种方针,指针或者引用只被发送一次,一旦发送,发送方将不再使用它。这使得接收方随时可以接收指向或者引用到的值,并且提供相同的方针发送指针或者引用给发送方。(我们等会会看见一个基于这种方针的例子;7.2.4.3。) 不好的是基于这种方针的方法是需要训练的。第三种使用指针或者引用工作安全的方法是提供不能改变指向或者引用到的值的出口方法,而非出口方法能够执行改变。这种指针或者引用能够通过他们的出口方法同时被传递和访问,且只有一个协程使用他们的非出口方法(例如,在她们自己的,包在第九章节有讲解)。

也能够发送接口值,也就是说,满足特定接口的值,通过通道。只读接口的值能够安全地用在任意多的协程(除非文件说明不可以),但带值的接口,包含能够改变值的状态的方法必须像指针一样对待,访问序列化。

例如,当我们使用 image.NewRGBA()函数创建一个新的图片,将会得到一个*image.RGBA。这个类型同时满足image.Image 接口(只有get方法,也就是只读),以及draw.Image 接口(有所有image.Image 方法再加上一个Set()方法)。所以,传输相同的*image.RGBA值在多个协程中是安全的,提供我们传输给接收一个image.Image的函数。(不幸的是,这种安全会被破坏,如果接收方法使用一种断言,draw.Image 接口,所以不允许这类事情是非常明智的。)当我们想要在多个协程中使用同样的,能够被改变的 *image.RGBA值的时候,我们应该要么发送*image.RGBA或者 draw.Image,任一个我们都要确保是被序列化的。

其中一个最简单的使用并发的方法是使用一个协程去准备这些工作,然后另一个协程做这些工作,让主协程以及一些通道安排所有的事情。例如,这里有我们如何在主协程中创建一个“jobs”通道以及一个“done”通道。

 

这里我们创建一个无缓冲的jobs 通 道 来传递自定义Job类型的值。我们也能够创建一个带缓冲的done通道,缓冲大小和 []Job类型(初始化没有展示)的jobList变量的长度一致。

随着这些通道和job list 创建,我们可以开始。

 

这些片段创建了第一个额外的协程。它迭代jobList切片并且发送每一个job给jobs通道。因为通道是无缓冲的,协程将马上堵塞,保持堵塞直到另一个协程从jobs通道接收数据。一旦所有的jobs已经被发送到jobs通道,通道将关闭,所以接受者将知道不再有jobs的时间。

   这些片段的语义并不是十分显著的。for循环运行直到完成,紧接着关闭jobs通道,但这些和程序里的其他协程是并发地发生的。此外go 声明一下就立马返回,让代码在自己的协程中执行,当然,在此刻没有任何其他协程试着去解说jobs,所以协程堵塞了。所以,就在go声明之后,程序就有了两个协程,主协程继续下一个声明,最近创建的协程堵塞等待另一个协程去接收jobs通道里面的数据。因此,它需要一些时间在for循环之前完成以及关闭通道。

 

     这些片段创建第二个额外的协程。这个协程迭代jobs通道,接收每一个job,处理job(这里只是print出来),然后针对每个job发送true到done通道表示完成。(我们也能够发送false,因为我们值关心在done通道有多少个发送被执行,而不是发送了什么值。)

   就像第一个go的声明,这个声明立马返回,for声明堵塞等待发送。所以,在这一刻,三个并发协程正在执行,主协程以及两个另外的协程,如图7.3描述的一样。

 

    当我们已经得到一个 send等待(在#1协程中),job 马上被接受(被协程#2)以及执行。期间 #1协程再次被堵塞,这次等待发送第二个job。一旦#2协程已经完成执行,它发送到done通道,这个通道是有缓冲的所以不会在发送的时候堵塞。控制转移到#2协程额for循环,以及下一个job从#1协程被发送,以及被#2协程接收,一直下去,知道所有的工作都完成。

 

这是最后的片段,在另外两个额外的协程已经被创建以及准备执行之后,准备立马执行。这代码是在主协程中,它的目的是确保主协程直到所有的工作完成才终止。

   for迭代和jobs一样多次,在每次迭代都完成从done通道接收(把结果丢掉),来确保每一个迭代都是同步的以及每个job的完成。如果没有东西能够接收(因为某个job正在被执行但还没结束),接收将会堵塞。一旦所有的jobs被完成,从done通道发送和接收的数量将会和迭代的次数相同,for循环将会完成。在此刻,主协程将会结束,因此整个程序终止,我们确信所有的处理都完成。

    两个拇指规则通常都适用于通道。第一,我们只需要关闭通道,当我们将检查它是否过会被关闭(使用for ... range 循环,select,或者检查接收方使用<-操作)。第二,一个通道应该被发送方协程关闭,而不是被接收方协程关闭。不关闭通道是非常明智的,这样就从不用去检查通道是否被关闭,通道是非常轻量级的,所以他们是不占用资源的,比如打开一个文件。

    在这个例子中,根据我们的拇指规则,jobs通道用for...range来迭代循环,所以我们在发送方协程中完成关闭了它。在另一方面,我们不需要担心是否关闭了done通道,所以没有声明取决于他被关闭后。

   这个例子展示了一个在Go并发变成普通模式,尽管在这个特别的例子使用并发并不是真的很好。下面模块的例子用了和这个展示相类似的模式,也充分地使用了并发性。

7.2  例子

   虽然Go使用了相当少得语法来提供协程和通道(<-,chan,go,select),但这已经足够在多种方式实现并发。事实上,有很多不同的方法是可行的,在这一章讲解每一种变化是不切实际的。所以,我们会关注通常使用在并发变成中的三中模式,通道,多个独立并发工作(同步与不同步结果),和多个相互依赖的并发工作,然后看看各自使用Go的并发支持来实现的独特方式。

   当中,会在这展示例子,以及在最后提供足够的练习去洞悉和练习Go编程,这些以及其他方法都能够安全地使用在新程序中。

7.2.1 例子:filter

第一个例子被设计展示一个独特的并发变成模式。程序能够很容易适应于其他得益于程序的并发性的工作。

那些使用Unix环境的人可能已经发现Go的通道,联想于Unix管道(除了通道是双向,而管道是单向的)。这些管道能够用来创建用来把一个程序的输出传递给另外一个程序,当做另一个程序的输入,再把输出返回给第三个程序等等的通道。例如,使用Unix管道命令 find $GOROOT/src -name "*.go" | grep -v test.go,我们可以获得包含在Go资源树的所有Go文件列表(不包括测试文件)。这个方法的一个亮点是容易扩展。例如,我们能够增加 |xargs wc -l 来得到并列出每一个文件所包含的行数(在最后加上总数),增加|sort -n 来根据行数排序(最少到最多)。

真正的Unix型管道是能够被创建的,使用标准库的 io.Pipe()函数。例如,Go标准库使用这个函数来对比图像(看文件 go/src/pkg/image/png/reader_test.go)。

   除了使用io.Pipe()来创建Unix型管道,也能够使用channel来创建管道,后一

种技术我们将在这里回顾。

filter例子程序(在filter/filter.go文件),接收一些命令行参数(例如,文件的最大最小长度以及能够接收的文件后缀),文件列表和输出和给定命令行匹配的文件列表。这里两行代码是程序中main()函数的内容。

 

     handleCommandLine()函数(没有展示出来)使用标准库的flag包来处理命令行参数。管道的工作,从最里面的函数(source(file))调用最外面的(sink())。这里是相同的管道,用容易明白的方式展示。

 

 

     source()函数,获去一个文件名字切片以及返回chan类型的通道,被命名为channel1变量。source()函数轮流发送每个文件名到通道中。两个filter函数每个有一个filter标准和一个chan string,每个返回自己的chan string。在这个例子中,第一个filter的返回通道被命名为channel2,第二个为channel3。filter迭代通道接收的项,并且发送每一个和他们的条件匹配的项到他们已经返回的通道中。sink()函数迭代取出通道里面的内容,并且把每一个都打印出来。

 

图7.4提供如何发生的示意图。在这个例子中,filter程序,sink()函数在main协程中执行,每个管道函数(例如 source(),filterSuffixes(),filterSize()在各自的协程中执行)。这意味着每一个管道函数调用直接返回并且很快执行到sink()函数。此刻,所有的协程是并发地执行的,等待发送或者等待接收直到所有的文件已经被处理。

 

 

   这个函数创建了通道,用来传递文件名。它使用了缓存通道,因为在测试中这个提升了吞吐量。(我们经常使用内存的消耗换来速度的提升)。

   一旦输出通道已经创建,我们创建一个协程来迭代文件,并且把每一个发送到通道中。当所有的文件已经被发送,我们关闭通道。和往常一样,go声明立马返回,所以从发送第一个项到发送最后一个项并且关闭通道之间有相当长的间隔。第二个通道并没有堵塞(至少,前1000个文件,以及少于1000个文件),但如果发送更多就会堵塞,知道一个或者更多被从通道接收走。

   我们之前就知道,默认的通道是双向的,但我们能够强迫一个通道变成单向的。回忆前面部分,chan<-Type 类型是一个只发送类型通道,<-chan Type 是一个只接收通道。在函数最后,双向out通道,被单做只接收通道返回,所以文件名只能被接收。我们当然也能恢复为一个双向通道,但这种方式能更好地表达我们的意图。

   在执行go声明后开始匿名函数,在它自己的协程内处理,函数立马返回通道,协程的函数往它发送文件的名字。所以一旦source()函数被调用,就有两个协程执行,住协程和另外一个在函数中创建的。

 

 

这是两个filter 函数的第一个,只有一个被展示,因为filterSize函数是在结构上基本一样。

   in通道参数,是只能被接收通道或者双向通道,但无论哪种情况,在filterSuffixes函数内,类型声明确保它能够只接收。(我们知道从source()函数的返回值,是in通道,事实上是一个只能被接收的通道。)相应地,我们返回双向out通道作为只接收通道,就像我们在source()函数做的一样。在两种情况中,我们省略<-s,函数也一样工作。然而, 通过包含反向,我们已经精确地表达了我们想要得功能的语义,并确保编译器执行它们。

   filterSuffixes 函数从创建一个带和输入通道一样大小缓存的输出通道开始,以致最大生产量。函数接着创建一个协程来处理。在协程里面,in通道被迭代(文件名轮流被接收)。如果没有指定后缀,任何后缀都能简单地发送到输出通道。如果匹配到任何可接收的文件后缀文件名的小写的后缀,将被发送到输出通道,否则丢弃。(filepath.Ext()函数返回文件名的扩展,也就是它的后缀,包括前面部分,或者空字符串的名字没有扩展)

   就像source()函数,一旦所有的处理结束,输出通道被关闭,尽管它可能花费一些时间达到这个点,但协程创建输出通道的协程被返回,以至于下一个函数的管道能够接收文件名字。

    在此刻,三个协程正在运行,主协程,source()函数的协程,和本函数的协程。调用filterSize()函数之后,将会有第四个协程,所有这些都并行地工作。

 

source 函数,以及两个filter函数在自己的并发协程中处理,通过通道通信。sink()函数在主协程中对最后一个呗别的函数返回的通道操作,迭代得出成功从filter传递过来的文件名,并且输出他们。

      

     

                                                           

 

sink()函数的range 声明迭代只接收通道,答应出文件名或者被堵塞直到通道被关闭,所以确保主协程没有终止直到所有其他协程的处理都完成。

   自然地,我们往管道增加另外的函数,要么过滤文件名,要么处理已经通过过滤器的文件,当每一个新的函数接收一个输入通道(前面函数的输出通道)以及返回自己的输出通道。当然,如果我们想传递更多的复杂的值通过管道,我们也能够把通道基于一个结构,而不是一个字符串。

在本节中所展示的管道是一个很好的管道框架实例,在每个阶段做特定的处理真的很

少受益于管道方法。这类管道能够受益于并发性,一个管道的每个阶段可能有很多工作做,可能取决于正在处理的项目,这样尽可能多的时间使得协程都是忙碌的。

 

 

 

 

 

 

 

 

 

 

 

 

 

posted on 2015-03-17 11:46  HYQMartin  阅读(436)  评论(0编辑  收藏  举报

导航