Files
the-way-to-go_ZH_CN/eBook/14.1.md
2015-12-26 08:54:59 +08:00

157 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 14.1 并发,并行和协程
## 14.1.1 什么是协程
一个应用程序是运行在机器上的一个进程;进程是一个运行在自己内存地址空间里的独立执行体。一个进程由一个或多个操作系统线程组成,这些线程其实是共享同一个内存地址空间的一起工作的执行体。几乎所有'正式'的程序都是多线程的以便让用户或计算机不必等待或者能够同时服务多个请求如Web服务器或增加性能和吞吐量例如通过对不同的数据集并行执行代码。一个并发程序可以在一个处理器或者内核上使用多个线程来执行任务但是只有在同一个程序在某一个时间点在多个些处理内核或处理器上同时执行的任务才是真正的并行。
并行是一种通过使用多处理器以提高速度的能力。所以并发程序可以是并行的,也可以不是。
公认的,使用多线程的应用难以做到准确,最主要的问题是内存中的数据共享,它们会被多线程以无法预知的方式进行操作,导致一些无法重现或者随机的结果(称作`竞态`
!!不要使用全局变量或者共享内存,它们会给你的代码在并发运算的时候带来危险。
解决之道在于同步不同的线程对数据加锁这样同时就只有一个线程可以变更数据。在Go的标准库`sync`中有一些工具用来在低级别的代码中实现加锁;我们在章节[9.3](9.3.md)中讨论过这个问题。不过过去的软件开发经验告诉我们这会带来更高的复杂度,更容易使代码出错以及更低的性能,所以这个经典的方法明显不再适合现代多核/多处理器编程:"`thread-per-connection`"模型不够有效。
Go更倾向于其他的方式在诸多比较合适的范式中有个被称作`Communicating Sequential Processes顺序通信处理`(CSP, C. Hoare发明的)还有一个叫做`message passing-model消息传递`已经运用在了其他语言中比如Eralng
在Go中应用程序并发处理的部分被称作`goroutines协程`它可以进行更有效的并发运算。在协程和操作系统线程之间并无一对一的关系协程是根据一个或多个线程的可用性映射多路复用执行于在他们之上的协程调度器在Go运行时很好的完成了这个工作。
协程工作在相同的地址空间中,所以共享内存的方式一定是同步的;这个可以使用`sync`包来实现(参见章节[9.3](9.3.md))不过我们很不鼓励这样做Go使用`channels`来同步协程(可以参见[14.2](14.2.md)等章节)
当系统调用比如等待I/O阻塞协程时其他协程会继续在其他线程上工作。协程的设计隐藏了许多线程创建和管理方面的复杂工作。
协程是轻量的比线程更轻。它们痕迹非常不明显使用少量的内存和资源使用4K的栈内存就可以在堆中创建它们。因为创建非常廉价必要的时候可以轻松创建并运行大量的协程在同一个一个地址空间中100,000个连续的协程。并且它们对栈进行了分割从而动态的增加或缩减内存的使用栈的管理是自动的但不是由垃圾回收器管理的而是在协程退出后自动释放。
协程可以运行在多个操作系统线程之间也可以运行在线程之内让你可以很小的内存占用就可以处理大量的任务。由于操作系统线程上的协程时间片你可以使用少量的操作系统线程就能拥有任意多个提供服务的协程而且Go运行时可以聪明的意识到哪些协程被阻塞了暂时搁置它们并处理其他协程。
存在两种并发方式:确定性的(明确定义排序)和非确定性的(加锁/互斥从而未定义排序。Go的协程和通道理所当然的支持确定性的并发方式例如通道具有一个sender和一个receiver。我们会在章节[14.7](14.7.md)中使用一个常见的算法问题(工人问题)来对比两种处理方式。
协程是通过使用关键字`go`调用执行一个函数或者方法来实现的也可以是匿名或者lambda函数。这样会在当前的计算过程中开始一个同时进行的函数在相同的地址空间中并且分配了独立的栈比如`go sum(bigArray)`// 在后台计算总和
协程的栈会根据需要进行伸缩,不出现栈溢出;开发者不需要关心栈的大小。当协程结束的时候,它会静默退出:用来启动这个协程的函数不会得到任何的返回值。
任何Go程序都必须有的`main()`函数也可以看做是一个协程,尽管它并没有通过`go`来启动。协程可以在程序初始化的过程中运行(在`init()`函数中)。
在一个协程中,比如它需要进行非常密集的运算,你可以在运算循环中周期的使用`runtime.Gosched()`:这会让出处理器,允许运行其他协程;它并不会使当前协程挂起,所以它会自动恢复执行。使用`Gosched()`可以使计算均匀分布,使通信不至于迟迟得不到响应。
## 14.1.2 并发和并行的差异
Go的并发原语提供了良好的并发设计基础表达程序结构以便表示独立地执行的动作所以Go的的重点不在于并行的首要位置并发程序可能是并行的也可能不是。并行是一种通过使用多处理器以提高速度的能力。但往往是一个设计良好的并发程序在并行方面的表现也非常出色。
在当前的运行时2012年一月实现中Go默认没有并行指令只有一个独立的核心或处理器被专门用于Go程序不论它启动了多少个协程所以这些协程是并发运行的但他们不是并行运行的同一时间只有一个协程会处在运行状态。
这个情况在以后可能会发生改变,不过届时,为了使你的程序可以使用多个核心运行,这时协程就真正的是并行运行了,你必须使用`GOMAXPROCS`变量。
这会告诉运行时有多少个协程同时执行。
并且只有gc编译器真正实现了协程适当的把协程映射到操作系统线程。使用`gccgo`编译器,会为每一个协程创建操作系统线程。
## 14.1.3 使用GOMAXPROCS
在gc编译器下6g或者8g你必须设置GOMAXPROCS为一个大于默认值1的数值来允许运行时支持使用多于1个的操作系统线程所有的协程都会共享同一个线程除非将GOMAXPROCS设置为一个大于1的数。当GOMAXPROCS大于1时会有一个线程池管理许多的线程。通过`gccgo`编译器GOMAXPROCS有效的与运行中的协程数量相等。假设n是机器上处理器或者核心的数量。如果你设置环境变量GOMAXPROCS>=n或者执行`runtime.GOMAXPROCS(n)`接下来协程会被分割分散到n个处理器上。更多的处理器并不意味着性能的线性提升。有这样一个经验法则对于n个核心的情况设置GOMAXPROCS为n-1以获得最佳性能也同样需要遵守这条规则协程的数量 > 1 + GOMAXPROCS > 1
所以如果在某一时间只有一个协程在执行不要设置GOMAXPROCS
还有一些通过实验观察到的现象在一台1颗CPU的笔记本电脑上增加GOMAXPROCS到9会带来性能提升。在一台32核的机器上设置GOMAXPROCS=8会达到最好的性能在测试环境中更高的数值无法提升性能。如果设置一个很大的GOMAXPROCS只会带来轻微的性能下降设置GOMAXPROCS=100使用“top”命令和“H”选项查看到只有7个活动的线程。
增加GOMAXPROCS的数值对程序进行并发计算是有好处的
请看[goroutine_select2.go](examples/chapter_14/goroutine_select2.go)
总结GOMAXPROCS等同于并发的线程数凉在一台核心数多于1个的机器上会尽可能有等同于核心数的线程在并行运行。
## 14.1.4 如何用命令行指定使用的核心数量
使用`flags`包,如下:
```go
var numCores = flag.Int("n", 2, "number of CPU cores to use")
in main()
flag.Pars()
runtime.GOMAXPROCS(*numCores)
```
协程可以通过调用`runtime.Goexit()`来停止,尽管这样做几乎没有必要。
示例 14.1-[goroutine1.go](examples/chapter_14/goroutine1.go) 介绍了概念:
```go
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("In main()")
go longWait()
go shortWait()
fmt.Println("About to sleep in main()")
// sleep works with a Duration in nanoseconds (ns) !
time.Sleep(10 * 1e9)
fmt.Println("At the end of main()")
}
func longWait() {
fmt.Println("Beginning longWait()")
time.Sleep(5 * 1e9) // sleep for 5 seconds
fmt.Println("End of longWait()")
}
func shortWait() {
fmt.Println("Beginning shortWait()")
time.Sleep(2 * 1e9) // sleep for 2 seconds
fmt.Println("End of shortWait()")
}
```
输出
```
In main()
About to sleep in main()
Beginning longWait()
Beginning shortWait()
End of shortWait()
End of longWait()
At the end of main() // after 10s
```
`main()``longWait()``shortWait()`三个函数作为独立的处理单元按顺序启动,然后开始并行运行。每一个函数都在运行的开始和结束阶段输出了消息。为了模拟他们运算的时间消耗,我们使用了`time`包中的`Sleep`函数。`Sleep()`可以按照指定的时间来暂停函数或协程的执行这里使用了纳秒ns符号1e9表示1乘10的9次方e=指数)。
他们按照我们期望的顺序打印出了消息,几乎都一样,可是我们明白这是模拟出来的,以并行的方式。我们让`main()`函数暂停10秒从而确定它会在另外两个协程之后结束。如果不这样如果我们让`main()`函数停止4秒`main()`会提前结束,`longWait()`则无法完成。如果我们不在`main()`中等待,协程会随着程序的结束而消亡。
`main()`函数返回的时候程序退出它不会等待任何其他非main协程的结束。这就是为什么在服务器程序中每一个请求都会启动一个协程来处理`server()`函数必须保持运行状态。通常使用一个无限循环来达到这样的目的。
另外,协程是独立的处理单元,一旦陆续启动一些协程,你无法确定他们是什么时候真正开始执行的。你的代码罗技必须具独立于协程调用的顺序。
为了对比使用一个线程连续调用的情况移除go关键字重新运行程序。
现在输出:
```
In main()
Beginning longWait()
End of longWait()
Beginning shortWait()
End of shortWait()
About to sleep in main()
At the end of main() // after 17 s
```
协程更有用的一个例子应该是在一个非常长的数组中查找一个元素。
将数组分割为若干个不重复的切片,然后给每一个切片启动一个协程进行查找计算。这样许多并行的线程可以用来进行查找任务,整体的查找时间会缩短(除以协程的数量)。
## 14.1.5 Go协程goroutines和协程coroutines
译者注标题中的“Go协程goroutines”即是14章讲的协程指的是go语言中的协程。而“协程coroutines”指的是其他语言中的协程概念仅在本节出现。
在其他语言中比如C#Lua或者Python都有协程的概念。这个名字表明它和Go协程有些相似不过有两点不同
* go协程意味着并行或者可以以并行的方式部署协程一般来说不是这样的
* go协程通过通道来通信协程通过让出和恢复操作来通信
Go协程比协程更强大也很容易从协程的逻辑复用到go协程。
## 链接
- [目录](directory.md)
- 上一节:[协程goroutine与通道channel](14.0.md)
- 下一节:[使用通道进行协程间通信](14.2.md)