Go语言实战-6章 并发
6. 并发
Go 语言里的并发指的是能让某个函数独立于其他函数运行的能力。当一个函数创建为 goroutine
时,Go 会将其视为一个独立的工作单元。这个单元会被调度到可用的逻辑处理器上执行。Go 语言
运行时的调度器是一个复杂的软件,能管理被创建的所有 goroutine 并为其分配执行时间。这个调度
器在操作系统之上,将操作系统的线程与语言运行时的逻辑处理器绑定,并在逻辑处理器上运行
goroutine。调度器在任何给定的时间,都会全面控制哪个 goroutine 要在哪个逻辑处理器上运行。
Go 语言的并发同步模型来自一个叫作通信顺序进程(Communicating Sequential Processes,CSP)
的范型(paradigm)。CSP 是一种消息传递模型,通过在 goroutine 之间传递数据来传递消息,而不是
对数据进行加锁来实现同步访问。用于在 goroutine 之间同步和传递数据的关键数据类型叫作通道
(channel)。对于没有使用过通道写并发程序的程序员来说,通道会让他们感觉神奇而兴奋。希望读
者使用后也能有这种感觉。使用通道可以使编写并发程序更容易,也能够让并发程序出错更少。什么是操作系统的线程(thread)和进程(process)。
每个进程的初始线程被称作主线程。因为执行这个线程的空间是应用程序的本身的空间,所以当主线程终止时,应用程序也会终止。操作系统将线程调度到某个处理器上运行,这个处理器并不一定是进程所在的处理器。
操作系统会在物理处理器上调度线程来运行,而 Go 语言的运行时会在逻辑处理器上调度
goroutine来运行。每个逻辑处理器都分别绑定到单个操作系统线程。在 1.5 版本 ①
在图 6-2 中,可以看到操作系统线程、逻辑处理器和本地运行队列之间的关系。如果创建一
个 goroutine 并准备运行,这个 goroutine 就会被放到调度器的全局运行队列中。之后,调度器就
将这些队列中的 goroutine 分配给一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列
上,Go语言的运行时默认会为每个可用的物理处理器分配一个逻辑处理器。在 1.5 版本之前的版本中,默认给整个应用程序只分配一个逻辑处理器。这些逻辑处理器会用于执行所有被创建的goroutine。即便
只有一个逻辑处理器,Go也可以以神奇的效率和性能,并发调度无数个goroutine。并发(concurrency)不是并行(parallelism)。并行是让不同的代码片段同时在不同的物理处
理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做
了一半就被暂停去做别的事情了。不过要想真的实现并行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。否则,哪怕 Go 语
言运行时使用多个线程,goroutine 依然会在同一个物理处理器上并发运行,达不到并行的效果。下面的代码会分配一个逻辑处理器给调度器使用
1 |
|
基于调度器的内部算法,一个正运行的 goroutine 在工作结束前,可以被停止并重新调度。
运行结果是
goroutine B 先显示素数。一旦 goroutine B 打印到素数 4591,调度器就会将正运行的 goroutine
切换为 goroutine A。之后 goroutine A 在线程上执行了一段时间,再次切换为 goroutine B。这次
goroutine B 完成了所有的工作。一旦 goroutine B 返回,就会看到线程再次切换到 goroutine A 并
完成所有的工作。每次运行这个程序,调度器切换的时间点都会稍微有些不同。
函数 NumCPU 返回可以使用的物理处理器的数量。因此,调用 GOMAXPROCS 函数就为每个可用的物理处理器创建一个逻辑处理器。需要强调的是,使用多个逻辑处理器并不意味着性能更好。在修改任何语言运行时配置参数的时候,都需要配合基准测试来评估程序的运行效果。
只有在有多个逻辑处理器且可以同时让每个goroutine 运行在一个可用的物理处理器上的时候,goroutine 才会并行运行。
如果两个或者多个 goroutine 在没有互相同步的情况下,访问某个共享的资源,并试图同时读和写这个资源,就处于相互竞争的状态,这种情况被称作竞争状态(race candition)。
go build -race
用竞争检测器标识来编译程序Go 语言提供了传统的同步 goroutine 的机制,就是对共享资源加锁。如果需要顺序访问一个整型变量或者一段代码,atomic 和 sync 包里的函数提供了很好的解决方案。
atomic.AddInt64(&counter, 1)
这是安全的对 counter 加 1,表示同一时刻只能有一个 grouting 运行并完成这个加法操作。另外两个有用的原子函数是 LoadInt64 和 StoreInt64。这两个函数提供了一种安全地读和写一个整型值的方式。如果哪个 doWork goroutine 试图在 main 函数调用 StoreInt64 的同时调用 LoadInt64 函数,那么原子函数会将这些调用互相同步,保证这些操作都是安全的,不会进入竞争状态。
另一种同步访问共享资源的方式是使用互斥锁(mutex)。互斥锁这个名字来自互斥(mutual exclusion)的概念。互斥锁用于在代码上创建一个临界区,保证同一时间只有一个 goroutine 可以执行这个临界区代码。
可以通过通道共享内置类型、命名类型、结构类型和引用类型的值或者指针。
1 |
|
- 可以使用<-操作符,但这次是一元运算符