【Go】并发编程


一、并发简介

1. 并发是什么?

并发主要由切换时间片来实现段时间内多线程的 “同时” 运行,并行则是利用多核真实实现多线程的同时运行,go可以设置使用核数,以发挥多核计算机的能力。

关于进程、线程、协程和并发、并行的知识请参考我的另一篇文章:【操作系统】进程、线程、协程和并发、并行

2. 并发编程是什么?

并发 就是 可同时发起执行的程序,指 程序的逻辑结构并行就是可以在支持并行的硬件上执行的并发程序,指程序的运行状态

换句话说,并发程序代表了所有可以实现并发行为的程序,这是一个比较宽泛的概念,并行程序也只是他的一个子集。并发是并⾏的必要条件;但并发不是并⾏的充分条件(并发不一定可以并行;如果能够并行,一定能够并发)。

并发 只是 更符合现实问题本质的表达,目的是 简化代码逻辑 ,⽽不是使程序运⾏更快。要是程序运⾏更快必是并发程序加多核并⾏。

并发问题域 中的概念——程序需要被设计成能够处理多个同时(或者几乎同时)发生的事件;一个并发程序含有多个逻辑上的独立执行块,它们可以独立地并行执行,也可以串行执行。而 并行 则是 方法域 中的概念——通过将问题中的多个部分并行执行,来加速解决问题。一个并行程序解决问题的速度往往比一个串行程序快得多,因为其可以同时执行整个任务的多个部分。并行程序可能有多个独立执行块,也可能仅有一个。


二、goroutine 简介

1. goroutine 的理念

在 java/c++ 中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换,这一切通常会耗费程序员大量的心智。那么能不能有一种机制,程序员 只需要 定义很多个任务,让 系统 去帮助我们 把这些任务分配到CPU上实现并发执行 呢?

Go语言中的 goroutine 就是这样一种机制,goroutine的概念类似于协程,但 goroutine 是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经 内置了调度和上下文切换的机制

在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能–goroutine,当你需要让某个任务并发执行的时候,你 只需要把这个任务包装成一个函数开启一个 goroutine 去执行这个函数 就可以了,就是这么简单粗暴。

goroutine 是由官方实现的超级 “线程池” :每个实力 4~5KB 的栈内存占用和由于实现机制而大幅减少的创建和销毁开销是 go高并发 的根本原因。

goroutine 奉行通过通信来共享内存,而不是共享内存来通信。

2. goroutine 和协程的关系

  1. goroutine 是 协程go 语言实现,相当于把别的语言的类库的功能内置到语言里。从调度上看,goroutine 的调度开销远远小于线程调度开销。
  2. 不同的是:我们都知道,协程是用户级线程,由用户自己实现调度。而 Golang 在 runtime,系统调用等多方面对 goroutine 调度进行了封装和处理,即 goroutine 不完全是用户控制,一定程度上由 go 运行时(runtime)管理,好处:当某 goroutine 阻塞时,会 让出 CPU 给其他 goroutine。

实际上,goroutine 并非传统意义上的协程,现在主流的线程模型分三种:内核级线程模型、用户级线程模型和两级线程模型(也称混合型线程模型),传统的协程库属于用户级线程模型,而 goroutine 和它的 Go Scheduler 在底层实现上其实是属于 两级线程模型,因此,有时候为了方便理解可以简单把 goroutine 类比成协程,但心里一定要有个清晰的认知 — goroutine 并不等同于协程。

在下文中,如果我提到协程,就指的是 goroutine ,它们之间的区别你在心里知道就好。


三、goroutine 使用

Go语言中使用 goroutine 非常简单,只需要在 调用函数的时候前面加上 go 关键字就可以为一个函数创建一个goroutine

一个 goroutine 必定对应一个函数,可以创建多个 goroutine 去执行相同的函数。

1. 启动单个 goroutine

启动 goroutine 的方式非常简单,只需要在调用的函数(普通函数和匿名函数)前面加上一个go关键字。

举个例子如下:

package main

import (
	"fmt"
)

func hello() {
	fmt.Println("Hello Goroutine!")
}
func main() {
	hello()
	fmt.Println("main goroutine done!")
}

输出结果:

Hello Goroutine!
main goroutine done!

这是个普通的示例,其中没用到 goroutine ,其中 hello 函数和下面的语句是串行的,执行的结果是打印完Hello Goroutine!后打印main goroutine done!。

接下来我们在调用 hello 函数前面加上关键字 go,也就是启动一个 goroutine 去执行 hello 这个函数:

package main

import (
	"fmt"
)

func hello() {
	fmt.Println("Hello Goroutine!")
}

func main() {
	go hello() // 启动另外一个goroutine去执行hello函数
	fmt.Println("main goroutine done!")
}

输出结果:

main goroutine done!

这一次的执行结果只打印了 main goroutine done!,并没有打印 Hello Goroutine!。为什么呢?

这是因为,在程序启动时,Go 程序就会为 main() 函数创建一个默认的 goroutine。

当 main() 函数返回的时候该 goroutine 就结束了,所有在 main() 函数中启动的 goroutine 会一同结束,main 函数所在的 goroutine 就像是权利的游戏中的夜王,其他的 goroutine 都是异鬼,夜王一死它转化的那些异鬼也就全部GG了。

所以我们要想办法让 main 函数等一等 hello 函数,最简单粗暴的方式就是 time.Sleep 了:

package main

import (
	"fmt"
	"time"
)

func hello() {
	fmt.Println("Hello Goroutine!")
}

func main() {
	go hello() // 启动另外一个goroutine去执行hello函数
	fmt.Println("main goroutine done!")
	time.Sleep(time.Second)
}

输出结果:

main goroutine done!
Hello Goroutine!

执行上面的代码你会发现,这一次先打印 main goroutine done!,然后紧接着打印 Hello Goroutine!。

首先为什么会先打印 main goroutine done! ?,是因为我们在创建新的 goroutine 的时候需要花费一些时间,而此时main 函数所在的 goroutine 不会等着,会继续执行,所以先打印 main goroutine done!,一段时间后,hello() 函数的 goroutine 创建好了,执行打印 Hello Goroutine!。

2. 启动多个 goroutine

在Go语言中实现并发就是这样简单,我们还可以启动多个goroutine。让我们再来一个例子:
(这里使用了sync.WaitGroup来实现goroutine的同步)

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func hello(i int) {
	defer wg.Done() // goroutine结束就登记-1
	fmt.Println("Hello Goroutine!", i)
}
func main() {

	for i := 0; i < 10; i++ {
		wg.Add(1) // 启动一个goroutine就登记+1
		go hello(i)
	}
	wg.Wait() // 等待所有登记的goroutine都结束
}

输出结果:

Hello Goroutine! 9
Hello Goroutine! 4
Hello Goroutine! 1
Hello Goroutine! 2
Hello Goroutine! 6
Hello Goroutine! 7
Hello Goroutine! 8
Hello Goroutine! 0
Hello Goroutine! 5
Hello Goroutine! 3

多次执行上面的代码,会发现每次打印的顺序都不一致。这是因为10个 goroutine 是 并发 执行的,而 goroutine 的调度是 随机 的。

3. 主 goroutine 掌握着生命

如果主协程(由 main() 函数创建的协程成为主协程)退出了,其他任务还执行吗(运行下面的代码测试一下吧):

package main

import (
	"fmt"
	"time"
)

func main() {
	// 合起来写,直接开启一个 goroutine 来执行匿名函数
	go func() {   
		i := 0
		for {      //无限循环,如果主 goroutine 不结束,这个将一直进行下去
			i++
			fmt.Printf("new goroutine: i = %d\n", i)
			time.Sleep(time.Second)
		}
	}()
	
	//main()创建的主 goroutine
	i := 0
	for {
		i++   //上面创建goroutine需要时间,所以主goroutine先执行,这里i = 1
		fmt.Printf("main goroutine: i = %d\n", i)
		time.Sleep(time.Second)
		if i == 2 {
			break
		}
	}
}

输出结果:

main goroutine: i = 1
new goroutine: i = 1
new goroutine: i = 2
main goroutine: i = 2

虽然我们自己创建的匿名函数的 goroutine 内有无限循环,但程序还是正常结束了。这是因为主 goroutine 结束了,其他 goroutine 也就跟着一起结束了。


四、goroutine 与线程

1. 可增长的栈

OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个 goroutine 的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine 的栈不是固定的,他可以 按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这么大。所以在Go语言中一次创建十万左右的 goroutine 也是可以的。(go的高并发优势)

2. goroutine 调度

前文提到,goroutine 是由 Go 的运行时(runtime)调度和管理的。

GPM 是 Go 语言 运行时(runtime) 的实现,是 go 语言自己实现的一套调度系统。区别于操作系统调度 OS 线程。

Go语言的调度模型(GPM模型):

  1. G,Gourtines(携带任务),就是个 goroutine ,里面除了存放本 goroutine 信息外还有与所在 P 的绑定等信息。每个 goroutine 对应一个 G 结构体,G 保存 Goroutine 的运行堆栈,即并发任务状态。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行。
  2. P,Processors(分配任务),P 管理着一组 goroutine 队列,P 里面会存储当前 goroutine 运行的上下文环境(函数指针,堆栈地址及地址边界),P 会对自己管理的 goroutine 队列做一些调度(比如把占用 CPU 时间较长的 goroutine 暂停、运行后续的 goroutine 等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他 P 的队列里抢任务。对 G 来说,P 相当于 CPU 核,G 只有绑定到 P (在 P 的 local runq 中)才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等。
  3. M,Machine(寻找任务),是 Go 运行时(runtime)对操作系统内核线程的虚拟, M 与内核线程一般是 一 一 映射的关系, 一个 groutine 最终是要放到 M 上执行的。M 是 OS 线程抽象,和某个 P 绑定,从 P 的 runq 中不断取出 G,切换堆栈并执行,M 本身不具备执行状态,在需要任务切换时,M 将堆栈状态写回 G,任何其它 M 都能据此恢复执行。

P 与 M 一般也是 一 一 对应的。他们的关系是: P 管理着一组 G 挂载在 M 上运行。当一个 G 长久阻塞在一个 M 上时,runtime 会新建一个 M,阻塞 G 所在的 P 会把其他的 G 挂载在新建的 M 上。当旧的 G 阻塞完成或者认为其已经死掉时回收旧的M。

其中:

  1. P 的个数是通过 runtime.GOMAXPROCS 设定(最大256),Go1.5 版本之后默认为物理线程数。
    由于 P 的个数由GOMAXPROCS指定,是固定的,因此 限制最大并发数
    在并发量大的时候会增加一些 P 和 M ,但不会太多,切换太频繁的话得不偿失。

  2. M的个数是 不定 的,由Go Runtime调整,默认最大限制为10000个。

单从线程调度讲,Go 语言相比起其他语言的优势在于 OS 线程是由 OS 内核来调度的,goroutine 则是由 Go 运行时(runtime)自己的调度器 调度的,这个调度器使用一个称为 m:n 调度的技术(复用/调度 m 个 goroutine 到 n 个 OS 线程)。 其一大特点是 goroutine 的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的 malloc 函数(除非内存池需要改变),成本比调度 OS 线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干 goroutine 均分在物理线程上, 再加上本身 goroutine 的超轻量,以上种种保证了 go 调度方面的性能。


参考链接

  1. 并发介绍
  2. Goroutine
  3. Go语言的调度模型(GPM)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值