Go语言里的并发指的是能让某个函数独立于其他函数运行的能力。当一个函数创建为goroutine时,Go会将其视为一个独立的工作单元。这个单元会被调度到可用的逻辑处理器上执行。Go语言运行时的调度器是一个复杂的软件,能管理被创建的所有goroutine并为其分配执行时间。这个调度器在操作系统之上,将操作系统的线程与语言运行时的逻辑处理器绑定,并在逻辑处理器上运行goroutine。调度器在任何给定的时间,都会全面控制哪个goroutine要在哪个逻辑处理器上运行。
Go语言的并发同步模型来自一个叫作通信顺序进程(CSP)的泛型。CSP是一种消息传递模型,通过在goroutine之间同步和传递数据来传递消息,而不是对数据进行加锁来实现同步访问。用于在goroutine之间同步和传递数据的关键数据类型叫作通道(channel)。
一、并发与并行
当运行一个应用程序(如一个IDE或者编辑器)的时候,操作系统会为这个应用程序启动一个进程。可以将这个进程看作一个包含了应用程序在运行中需要用到和维护的各种资源的容器。
这些资源包括但不限于内存地址空间、文件和设备的句柄以及线程。一个线程是一个执行空间,这个空间会被操作系统调度来运行函数中所写的代码。每个进程至少包含一个线程,每个进程的初始线程被称作主线程。因为执行这个线程的空间是应用程序的本身的空间,所以当主线程终止时,应用程序也会终止。操作系统将线程调度到某个处理器上运行,这个处理器并不一定是进程所在的处理器。不同操作系统使用的线程调度算法一般都不一样。
操作系统会在物理处理器上调度线程来运行,而Go语言的运行时会在逻辑处理器上调度goroutine来运行。每个逻辑处理器分别绑定到单个操作系统线程。即便只有一个逻辑处理器,Go也可以神奇的效率和性能,并发调度无数个goroutine。
现在来了解一下操作系统线程、逻辑处理器和本地运行队列之间的关系。如果创建一个goroutine并准备运行,这个goroutine就会被放到调度器的全局运行队列中。之后,调度器就将这些队列中的goroutine分配给一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列中。本地运行队列中的goroutine会一直等待直到自己被分配的逻辑处理器运行。
有时,正在运行的goroutine需要执行一个阻塞的系统调用,如打开一个文件。当这类调用发生时,线程和goroutine会从逻辑处理器上分离,该线程会继续阻塞,等待系统调用的返回。与此同时,这个逻辑处理器就失去了用来运行的线程。所以,调度器会创建一个新线程,并将其绑定到该逻辑处理器上。之后,调度器会从本地运行队列里选择另一个goroutine来运行。一旦被阻塞的系统调用执行完成并返回,对应的goroutine会放回到本地运行队列,而之前的线程会保存好,以便之后可以继续使用。
如果一个goroutine需要做一个网络I/O调用,流程上会有些不一样。在这种情况下,goroutine会和逻辑处理器分离,并移到集成了网络轮询期的运行时。一旦该轮询器指示某个网络读或写操作已经就绪,对应的goroutine就会重新分配到逻辑处理器上来完成操作。调度器对可以创建的逻辑处理器的数量没有限制,但语言运行时默认限制每个程序最多创建10000个线程。这个限制值可以通过runtime/debug包的SetMaxThreads方法来更改。如果程序试图用更多的线程,就会崩坏。
并发不是并行。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。
如果希望让goroutine并行,必须使用多于一个逻辑处理器。当有多个逻辑处理器时,调度器会将goroutine平等分配到每个逻辑处理器上。这会让goroutine在不同的线程上运行。不过要想真的实现并行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。不过要想真的实现并行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。否则,哪怕Go语言运行时使用多个线程,goroutine依然会在同一个物理处理器上并发运行,达不到并行的效果。
二、goroutine
我们来了解一下调度器的行为,以及调度器是如何创建goroutine并管理其寿命的。我们先通过在一个逻辑处理器上运行的例子讲解。代码所示的程序会创建两个goroutine,以并发地形式分别显示大写和小写英文字母。
package main
import (
"fmt"
"runtime"
"sync"
)
func main(){
runtime.GOMAXPROCS(1)//分配一个处理器给调度器使用
//wg用来等待程序完成
//计数加2,表示要等待两个goroutine
var wg sync.WaitGroup
wg.Add(2)
fmt.Println("Start Goroutines")
go func(){
//在函数退出时调用Done来通知main函数工作已经完成
defer wg.Done()
for count:=0;count<3;count++{
for char:='a';char<'a'+26;char++{
fmt.Printf("%c ",char)
}
}
}()
go func(){
//在函数退出时调用Done来通知main函数工作已经完成
defer wg.Done()
for count:=0;count<3;count++{
for char:='A';char<'A'+26;char++{
fmt.Printf("%c ",char)
}
}
}()
//等待goroutine结束
fmt.Println("Waiting To Finish")
wg.Wait()
fmt.Println("\nTerminating Program")
}
调用了runtime包的GOMAXPRACS函数。这个函数允许程序更改调度器可以使用的逻辑处理器的数量。我们声明了两个匿名函数,用来显示英文字母表,每个goroutine执行的代码在一个逻辑处理器上并发运行的效果。第一个goroutine完成所有显示需要花时间太短了,以至于在调度器切换到第二个goroutine之前,就完成了所有任务。这也是为什么会看到先输出了所有的大写字母,之后才输出小写字母。
WaitGroup是一个计数信号量,可以用来记录并维护运行的goroutine。如果WaitGroup的值大于0,Wait方法就会阻塞。我们创建了一个WaitGroup类型的变量,之后将这个WaitGroup的值设置为2,表示有两个正在运行的goroutine。为了减小WaitGroup的值并最终释放main函数,使用defer声明在函数退出时调用Done方法。
关键字defer会修改函数调用时机,在正在执行的函数返回时才真正调用defer声明的函数。对这里的示例程序来说,我们使用关键字defer保证,每个goroutine一旦完成其工作就调用Done方法。
基于调度器的内部算法,一个正运行的goroutine在工作结束前,可以被停止并重新调度。调度器这样的目的是防止某个goroutine长时间占用逻辑处理器。当goroutine占用时间过长时,调度器会停止当前正运行的goroutine,并给其他可运行的goroutine运行的机会。
可以通过创建一个需要长时间才能完成其工作的goroutine来看到调度的这个行为,如下所示:
package main
import (
"fmt"
"runtime"
"sync"
)
var wg sync.WaitGroup
func main(){
runtime.GOMAXPROCS(1)
wg.Add(2)
fmt.Println("Create Goroutines")
go printPrime("A")
go printPrime("B")
fmt.Println("Waiting to Finish")
wg.Wait()
fmt.Println("Terminating Program")
}
func printPrime(prefix string){
defer wg.Done()
next:
for outer:=2;outer<5000;outer++{
for inner:=2;inner<outer;inner++{
if outer%inner == 0{
continue next
}
}
fmt.Printf("%s:%d\n",prefix,outer)
}
fmt.Println("Completed",prefix)
}
程序创建了两个goroutine,分别打印1~5000内的素数。查找并显示素数会消耗不少时间。这会让调度器有机会在第一个goroutine找到所有素数之前,切换该goroutine的时间片。
goroutine B先显示素数。一旦goroutineB打印到素数4591,调度器就会将正运行的goroutine切换为goroutine A。之后goroutine A在线程上执行了一段时间,再次切换为goroutine B。这次goroutine B完成了所有的工作。一旦goroutine B返回,就会看到线程再次切换到goroutine A并完成所有的工作。
如果给调度器分配多个逻辑处理器,我们会看到之前的示例程序的输出行为会有些不同。下面是代码:
package main
import (
"fmt"
"runtime"
"sync"
)
func main(){
runtime.GOMAXPROCS(2)//分配2个处理器给调度器使用
//wg用来等待程序完成
//计数加2,表示要等待两个goroutine
var wg sync.WaitGroup
wg.Add(2)
fmt.Println("Start Goroutines")
go func(){
//在函数退出时调用Done来通知main函数工作已经完成
defer wg.Done()
for count:=0;count<3;count++{
for char:='a';char<'a'+26;char++{
fmt.Printf("%c ",char)
}
}
}()
go func(){
//在函数退出时调用Done来通知main函数工作已经完成
defer wg.Done()
for count:=0;count<3;count++{
for char:='A';char<'A'+26;char++{
fmt.Printf("%c ",char)
}
}
}()
//等待goroutine结束
fmt.Println("Waiting To Finish")
wg.Wait()
fmt.Println("\nTerminating Program")
}
通过调用GOMAXPROCS函数创建了两个逻辑处理器。这会让goroutine并行运行。两个goroutine几乎是同时开始运行的,大小写字母是混合在一起显示的。这是在一台2核的电脑上运行程序的输出,所以每个goroutine独自运行在自己的核上。只有在多个逻辑处理器且可以同时让每个goroutine运行在一个可用的物理处理器上的时候,goroutine才会并行运行。