天天看点

Go并发编程基础(译)

运行期并发线程(goroutines)

基本的同步技术(管道和锁)

go语言中基本的并发模式

死锁和数据竞争

并行计算

goroutine非常轻量,除了为之分配的栈空间,其所占用的内存空间微乎其微。并且其栈空间在开始时非常小,之后随着堆存储空间的按需分配或释放而变化。内部实现上,goroutine会在多个操作系统线程上多路复用。如果一个goroutine阻塞了一个操作系统线程,例如:等待输入,这个线程上的其他goroutine就会迁移到其他线程,这样能继续运行。开发者并不需要关心/担心这些细节。

下面所示程序会输出“hello from main goroutine”。也可能会输出“hello from another goroutine”,具体依赖于两个goroutine哪个先结束。

<a href="http://www.nada.kth.se/~snilsson/concurrency/src/goroutine1.go">goroutine1.go</a>

接下来的这个程序,多数情况下,会输出“hello from main goroutine”和“hello from another goroutine”,输出的顺序不确定。但还有另一个可能性是:第二个goroutine运行得极其慢,在程序结束之前都没来得及输出相应的消息。

<a href="http://www.nada.kth.se/~snilsson/concurrency/src/goroutine2.go">goroutine2.go</a>

下面则是一个相对更加实际的示例,其中定义了一个函数使用并发来推迟触发一个事件。

<a href="http://www.nada.kth.se/~snilsson/concurrency/src/publish1.go">publish1.go</a>

你可能会这样使用<code>publish</code>函数:

这个程序,绝大多数情况下,会输出以下三行,顺序固定,每行输出之间相隔5秒。

一般来说,通过睡眠的方式来编排线程之间相互等待是不太可能的。下一章节会介绍go语言中的一种同步机制 - 管道,并演示如何使用管道让一个goroutine等待另一个goroutine。

Go并发编程基础(译)

管道是引用类型,基于make函数来分配。

如果通过管道发送一个值,则将<code>&lt;-</code>作为二元操作符使用。通过管道接收一个值,则将其作为一元操作符使用:

如果管道不带缓冲,发送方会阻塞直到接收方从管道中接收了值。如果管道带缓冲,发送方则会阻塞直到发送的值被拷贝到缓冲区内;如果缓冲区已满,则意味着需要等待直到某个接收方获取到一个值。接收方在有值可以接收之前会一直阻塞。

关闭管道(close)

一个带有<code>range</code>子句的<code>for</code>语句会依次读取发往管道的值,直到该管道关闭:

<a href="http://www.nada.kth.se/~snilsson/concurrency/src/sushi.go">sushi.go</a>

下一个示例中,我们让<code>publish</code>函数返回一个管道 - 用于在发布text变量值时广播一条消息:

<a href="http://www.nada.kth.se/~snilsson/concurrency/src/publish2.go">publish2.go</a>

注意:我们使用了一个空结构体的管道:<code>struct{}</code>。这明确地指明该管道仅用于发信号,而不是传递数据。

我们可能会这样使用这个函数:

这个程序会按指定的顺序输出以下三行内容。最后一行在新闻(news)一出就会立即输出。

Go并发编程基础(译)

现在我们在<code>publish</code>函数中引入一个bug:

主程序还是像之前一样开始运行:输出第一行,然后等待5秒,这时<code>publish</code>函数开启的goroutine会输出突发新闻(breaking news),然后退出,留下主goroutine独自等待。

此刻之后,程序无法再继续往下执行。众所周知,这种情形即为死锁。

死锁是线程之间相互等待,其中任何一个都无法向前运行的情形。

go语言对于运行时的死锁检测具备良好的支持。当没有任何goroutine能够往前执行的情形发生时,go程序通常会提供详细的错误信息。以下就是我们的问题程序的输出:

大多数情况下找出go程序中造成死锁的原因都比较容易,那么剩下的就是如何解决这个bug了。

死锁也许听起来令人挺忧伤的,但伴随并发编程真正灾难性的错误其实是数据竞争,相当常见,也可能非常难于调试。

当两个线程并发地访问同一个变量,并且其中至少一个访问是写操作时,数据竞争就发生了。

下面的这个函数就有数据竞争问题,其行为是未定义的。例如,可能输出数值1。代码之后是一个可能性解释,试图搞清楚这一切是如何发生得。

<a href="http://www.nada.kth.se/~snilsson/concurrency/src/datarace.go">datarace.go</a>

代码中的两个goroutine(假设命名为<code>g1</code>和<code>g2</code>)参与了一次竞争,我们无法获知操作会以何种顺序发生。以下是诸多可能中的一种:

<code>g1</code> 从 <code>n</code> 中获取值0

<code>g2</code> 从 <code>n</code> 中获取值0

<code>g1</code> 将值从0增大到1

<code>g1</code> 将1写到 <code>n</code>

<code>g2</code> 将值从0增大到1

<code>g2</code> 将1写到 <code>n</code>

程序输出 n 的值,当前为1

“数据竞争(data race)”这名字有点误导的嫌疑。不仅操作的顺序是未定义的,其实根本没有任何保证(no guarantees whatsoever)。编译器和硬件为了得到更好的性能,经常都会对代码进行上下内外的顺序变换。如果你看到一个线程处于中间行为状态时,那么当时的场景可能就像下图所示的一样:

Go并发编程基础(译)

go语言中,处理并发数据访问的推荐方式是使用管道从一个goroutine中往下一个goroutine传递实际的数据。有格言说得好:“不要通过共享内存来通讯,而是通过通讯来共享内存”。

以上代码中的管道肩负双重责任 - 从一个goroutine将数据传递到另一个goroutine,并且起到同步的作用:发送方goroutine会等待另一个goroutine接收数据,接收方goroutine也会等待另一个goroutine发送数据。

Go并发编程基础(译)

要想这类加锁起效的话,关键之处在于:所有对共享数据的访问,不管读写,仅当goroutine持有锁才能操作。一个goroutine出错就足以破坏掉一个程序,引入数据竞争。

因此,应该设计一个自定义数据结构,具备明确的api,确保所有的同步都在数据结构内部完成。下例中,我们构建了一个安全、易于使用的并发数据结构,<code>atomicint</code>,用于存储一个整型值。任意数量的goroutine都能通过<code>add</code>和<code>value</code>方法安全地访问这个数值。

<a href="http://www.nada.kth.se/~snilsson/concurrency/src/raceclosure.go">raceclosure.go</a>

对于输出<code>55555</code>,一个貌似合理的解释是:执行<code>i++</code>的goroutine在其他goroutine执行打印语句之前就完成了5次<code>i++</code>操作。实际上变量<code>i</code>更新后的值为其他goroutine所见纯属巧合。

一个简单的解决方案是:使用一个局部变量,然后当开启新的goroutine时,将数值作为参数传递:

这次代码就对了,程序会输出期望的结果,如:<code>24031</code>。注意:goroutine之间的运行顺序是不确定的。

仍旧使用闭包,但能够避免数据竞争也是可能的,必须小心翼翼地让每个goroutine使用一个独有的变量。

数据竞争自动检测

这个工具用起来也很简单:只要在使用<code>go</code>命令时加上<code>-race</code>标记即可。开启检测器运行上面的程序会给出清晰且信息量大的输出:

该工具发现一处数据竞争,包含:一个goroutine在第20行对一个变量进行写操作,跟着另一个goroutine在第22行对同一个变量进行了未同步的读操作。

注意:竞争检测器只能发现在运行期确实发生的数据竞争(译注:我也不太理解这话,请指导)

以下是一个玩具示例,演示<code>select</code>语句如何用于实现一个随机数生成器:

<a href="http://www.nada.kth.se/~snilsson/concurrency/src/randbits.go">randbits.go</a>

下面是相对更加实际一点的例子:如何使用select语句为一个操作设置一个时间限制。代码会输出变量news的值或者超时消息,具体依赖于两个接收语句哪个先执行:

Go并发编程基础(译)

花点时间认真研究一下这个示例。如果你完全理解,也就对go语言中并发的应用方式有了全面的掌握。

这个程序演示了如何将管道用于被任意数量的goroutine发送和接收数据,也演示了如何将select语句用于从多个通讯中选择一个。

<a href="http://www.nada.kth.se/~snilsson/concurrency/src/matching.go">matching.go</a>

示例输出:

Go并发编程基础(译)

并发的一个应用是将一个大的计算切分成一些工作单元,调度到不同的cpu上同时地计算。

将计算分布到多个cpu上更多是一门艺术,而不是一门科学。以下是一些经验法则:

每个工作单元应该花费大约100微秒到1毫秒的时间用于计算。如果单元粒度太小,切分问题以及调度子问题的管理开销可能就会太大。如果单元粒度太大,整个计算也许不得不等待一个慢的工作项结束。这种缓慢可能因为多种原因而产生,比如:调度、其他进程的中断或者糟糕的内存布局。(注意:工作单元的数目是不依赖于cpu的数目的)

尽可能减小共享的数据量。并发写操作的代价非常大,特别是如果goroutine运行在不同的cpu上。读操作之间的数据共享则通常不会是个问题。

数据访问尽量利用良好的局部性。如果数据能保持在缓存中,数据加载和存储将会快得多得多,这对于写操作也格外地重要。

下面的这个示例展示如何切分一个开销很大的计算并将其分布在所有可用的cpu上进行计算。先看一下有待优化的代码:

思路很简单:确定合适大小的工作单元,然后在不同的goroutine中执行每个工作单元。以下是并发版本的 <code>convolve</code>:

<a href="http://www.nada.kth.se/~snilsson/concurrency/src/convolution.go">convolution.go</a>

工作单元定义之后,通常情况下最好将调度工作交给运行时和操作系统。然而,对于go 1.* 你也许需要告诉运行时希望多少个goroutine来同时地运行代码。

欢迎加群互相学习,共同进步。qq群:ios: 58099570 | android: 330987132 | go:217696290 | python:336880185 | 做人要厚道,转载请注明出处!http://www.cnblogs.com/sunshine-anycall/p/4775932.html