一、概念
在了解,go的并行之前,我们首先需要搞清楚一下概念。
概念 | 描述 |
进程 | 可以理解为一个程序,其对应一个独立程序空间 |
线程 | 一个执行空间,一个进程可以有多个线程 |
用户态的线程 | 可以理解为就是把线程切换需要的上下文保存在线程本身。在go里面,可以将goroutine理解为用户态的线程,即协程。 |
逻辑处理器 | 每个逻辑处理器都会绑定一个线程,并负责goroutine的执行 |
全局运行队列 | 所有创建的goroutine都会放到这里 |
本地运行队列 | 分发到每个逻辑处理器的goroutine的队列 |
什么是并发 | 并发是以时间段为维度,即在单位时间内,同时完成多件事。 |
什么是并行 | 并行是以时间点为维度,即在某个时间点,同时完成多件事。 |
同步与异步 | 同步和异步针对应用程序而言,关注的是程序中间的协作关系,一般用于应用程序与内核的交互,同步即等待或是轮询;异步则是直接返回,完成后通知应用程序。 |
阻塞与非阻塞 | 阻塞与非阻塞关注的是单个进程的执行状态,一般用于网络io中,阻塞即需要等待,不会立即返回;非阻塞则会立刻返回。 |
上下文 | 上下文是一种非常泛化的概念,可以理解为程序执行的环境变量。 |
借用Erlang 之父 Joe Armstrong的一张图,来展示什么是并发与并行,如下:
二、go并行的原理
当我们创建了一个goroutine的后,会先存放在全局运行队列中,等Go运行时调度器便会进行调度,把他们分配给其中的一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列中,最终等着被逻辑处理器执行。
那么逻辑处理器,又是如何执行goroutine呢?
操作系统会在物理处理器上调度操作系统线程来运行,逻辑处理器绑定到系统线程上面了,于是系统线程执行哪些goroutine,就会受到逻辑处理器的控制,真正执行goroutine还是系统的线程。
参考The Go scheduler
M:操作系统线程,
P:逻辑处理器
G:goroutine,拥有自己的栈,指令指针等信息。
每个P(逻辑处理器)会维护着一个goroutine本地队列,处于就绪状态的goroutine(灰色G),就会被P控制着在M中执行。
在Go程序中,每当执行go func,就会创建一个goroutine,并放到goroutine的全局队列中,最后被分配到一个goroutine本地队列。
我们都知道,当M(系统线程)阻塞的时候,比如下载比较大的文件,cpu就会被空闲了,使用异步事件或回调的思维方式能够更加有效地利用CPU,但是这样的代价也是比较大的,因为需要保存上下文,且能够在该恢复的时刻进行恢复上下文。Go采取的做法是,当发现没有空闲的M(比如线程m被阻塞了)且M的数量少于GOMAXPROCS,则会创建新的M(线程),进而可以邦定P(逻辑处理器),从而执行G(goroutine)
三、go中goroutine
每当执行go func,就会创建一个goroutine,在Go语言中,goroutine就是协程。每个goroutine的结构体中有一个sched域就是用于保存自己上下文的。这样,goroutine就可以被换出去,再换进来。这种上下文保存在用户态完成,不必陷入到内核,非常的轻量,切换速度很快。有的协程运行到一定时候就主动调用yield放弃自己的执行,把自己再次放回到任务队列中等待下一次调用时机等。
1、并发案例
func trace(start, end int8) {
for i:=start; i<=end; i++{
fmt.Printf("%c ", i)
time.Sleep(time.Second)
}
}
func main() {
runtime.GOMAXPROCS(1) //限制只有一个逻辑处理器
var wg sync.WaitGroup //用于等待所有协程都完成
wg.Add(2)
go func(){
defer wg.Done()//程序退出的时候执行
trace('a', 'f')
}()
go func(){
defer wg.Done()//程序退出的时候执行
trace('A', 'F')
}()
wg.Wait() //等待所有协程的完成
}
上面的程序使用runtime.GOMAXPROCS(1)来分配一个逻辑处理器供调度器使用,两个goroutine将被该逻辑处理器调度并发执行。输出如下:
A a b B C c d D E e f F G g h H I i j J
在go语言中,“有函数调用,就有机会被调度器调度”,在上面案例中trace方法里面调用了time.sleep()函数的目的,就是让当前运行goroutine有机会被调度器调度,进剥夺该goroutine的执行权,让其他的goroutine执行。所以上面代码打印的结果是大小写字母,交替的输出。如果注销掉”time.sleep()”,输出结果为:
A B C D E F G H I J a b c d e f g h i j
当然,我们可以通过runtime.Gosched()来使当前在逻辑处理器上运行的goruntine让出运行权限,这样另一个goruntine就会得到执行。
四、并发间的相互通信
在Go中,所有I/O都被阻塞,通过goroutines和通道channel处理并发,而不是回调和异步。通道channel主要负责在并发过程中,实现通信。通道channel是类型相关的,一个通道channel只能传递一种数据类型的值,申明如下:
var chanName chan ElementType
比如声明一个传递int类型的通道如下:
var ch chan int
再如申明一个map类型如下:
var ch map[string] chan string
申明完一个通道以后,我们还需要定义该通道,也可以说是初始化该通道,如指定该通道的大小等。可以使用make函数,如下:
ch := make(chan int,bufferSize) //bufferSize为缓冲区的大小,可以不传递该值代表不带缓冲区的channel
通道channel的使用,主要包含数据的写入和读出,如下:
ch <- value //往通道中写入数据
value <- ch //从通道中读出数据
备注:如果channel没有写入数据,从channel中读取数据也会导致程序的阻塞,一直到channel中被写入数据为止。如下:
func main() {
runtime.GOMAXPROCS(1)
var ch chan int = make(chan int)
go func(){
fmt.Printf("开始阻塞1。。。")
time.Sleep(time.Second)
fmt.Printf("结束阻塞1。。。")
ch <- 4
}()
fmt.Printf("ch ==>%d", <- ch)
}
在Go里面,不仅仅支持双向通道,还支持双向通道,定义双向通道如下:
var send chan <- int //定义只能发送的通道
var receive <- chan int //定义只能接收接受的通道