并发编程在当前软件领域是一个非常重要的概念,随着CPU等硬件的发展,我们无一例外的想让我们的程序运行的快一点、再快一点。Go语言在语言层面天生支持并发,充分利用现代CPU的多核优势,这也是Go语言能够大范围流行的一个很重要的原因。
并且在云的大放光彩的今天。想要支持分布式的,并且并发。那么go就是不二人选。
当然对于并发来说,一章是难说完的
文章目录
- 基本概念
- 串行、并发与并行
- 进程、线程和协程
- 并发模型
- goroutine(正文)
- go关键字
- 启动单个goroutine
- 启动多个goroutine
- 内存分配机制:动态栈
- goroutine调度
基本概念
串行、并发与并行
吃糖葫芦:
-
串行
:我们先吃最上面的,一块一块的吃,然后慢慢吃完 -
并发
:同一时间段内执行多个任务(你吃的比较快,在同一时间,别人吃一个,你可以吃两个或者更多)。 -
并行
:同一时刻执行多个任务(你叫朋友一起帮你吃)。
进程、线程和协程
-
进程(process)
:程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。 -
线程(thread)
:操作系统基于进程开启的轻量级进程,是操作系统调度执行的最小单位。 -
协程(coroutine)
:非操作系统提供而是由用户自行创建和控制的用户态‘线程’,比线程更轻量级。
并发模型
行业内,将如何实现并发编程总结归纳为各式各样的并发模型,常见的并发模型有以下几种:
- 线程&锁模型
- Actor模型
- CSP模型
- Fork&Join模型
Go语言中的并发程序主要是通过基于CSP(communicating sequential processes)
的goroutine
和channel
来实现,当然也支持
使用传统的多线程共享内存
的并发方式(java 的方式,也就是线程&锁模型
)。
goroutine(正文)
Goroutine 是 Go 语言支持并发的核心,在一个Go程序中同时创建成百上千个goroutine是非常普遍的,一个goroutine会以一个很小的栈
开始其生命周期,一般只需要2KB
。
区别于操作系统线程
由系统内核进行调度
, goroutine 是由Go运行时(runtime)负责调度
例如
Go运行时会智能地将 m个goroutine 合理地分配给n个操作系统线程,实现类似m:n
的调度机制,不再需要
Go开发者自行在代码层面维护一个线程池
。
在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能——goroutine。(这个就比较方便)
当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个 goroutine 去执行这个函数就可以了,就是这么简单粗暴。
其实说到这个我说一下体外话。编程语言趋于简单易操作化已经非常明显了。从市场的角度来说。编程语言的已经从院士–》博士–》–》研究生–》本科生–》专科生–》中学生—》小学生–》幼儿园了。几乎随着时间的变化,越来越来下放。学习成本越来越低。
也就是说,对于编程语言来说。如何扩大行业人选来说。越简单,去学习的人越多,当然这是在这个编程语言有特点的来说。如果没有优势,然后去学,要我说这样纯属扯淡。为了简单而失去编程的本质,就有问题了。
不扯了。。。。。
go关键字
Go语言中使用 goroutine 非常简单,只需要在函数或方法调用前加上go关键字就可以创建一个goroutine
,从而让该函数或方法在新创建的 goroutine
中执行。
go f() // 创建一个新的 goroutine 运行函数f
匿名函数也支持使用go关键字
创建 goroutine
去执行
go func(){
// ...
}()
一个 goroutine
必定对应一个函数/方法
,可以创建多个 goroutine
去执行相同的函数/方法
。
记住在这个大括号后的小括号中填入的传入方法的参数
启动单个goroutine
启动 goroutine 的方式非常简单,只需要在调用函数(普通函数和匿名函数)前
加上一个go关键字
。
先实现一个串行例子
package main
import (
"fmt"
)
func hello() {
fmt.Println("hello")
}
func main() {
hello()
fmt.Println("你好")
}
根据代码逻辑就是从上至下执行。
ok我们试一下加上关键字go,启动一个 goroutine 去执行 hello 这个函数。
package main
import (
"fmt"
"time"
)
func hello() {
fmt.Println("hello")
}
func main() {
go hello()
fmt.Println("你好")
}
打印的结果居然是:
ok出bug了。为什么呢?
原因
其实在 Go 程序
启动时,Go 程序就会为 main 函数创建一个默认的 goroutine
。
在上面的代码中我们在 main 函数中
使用 go 关键字
创建了另外
一个 goroutine 去执行 hello 函数
,而此时 main goroutine
还在继续往下执行,我们的程序中此时存在两个并发执行的 goroutine。
当 main 函数结束时整个程序也就结束了
,同时 main goroutine 也结束了,所有由 main goroutine 创建的 goroutine 也会一同退出。
也就是说我们的 main 函数退出太快,另外一个 goroutine 中的函数还未执行完程序就退出了,导致未打印出“hello”。
所以我们要想办法让 main 函数‘“等一等”将在另一个 goroutine 中运行的 hello 函数。
其中最简单粗暴的方式就是在 main 函数中“time.Sleep”一秒钟了(其实存在等待函数的)
package main
import (
"fmt"
"time"
)
func hello() {
fmt.Println("hello")
}
func main() {
go hello()
fmt.Println("你好")
time.Sleep(time.Second)
}
此时就成功了。但是者子其中有一个问题发现没有,为什么会先打印 “你好” 呢?
因为在程序中创建 goroutine 执行函数需要一定的开销,而与此同时 main 函数所在的 goroutine 是继续执行的。
上面说了,这么粗暴的使用。是非常不雅观的。
Go 语言中通过sync包
为我们提供了一些常用的并发原语。下一章说如何用。
这里我们先说, sync 包中的WaitGroup。
当你并不关心并发操作的结果
或者有其它方式收集并发操作的结果时
,WaitGroup是实现等待一组并发操作完成的好方法。
例子:
package main
import (
"fmt"
"sync"
)
// 声明全局等待组变量
var wg sync.WaitGroup
func hello() {
fmt.Println("hello")
wg.Done() // 告知当前goroutine完成
}
func main() {
wg.Add(1) // 登记1个goroutine
go hello()
fmt.Println("你好")
wg.Wait() // 阻塞等待登记的goroutine完成
}
将代码编译后再执行,得到的输出结果和之前一致,但是这一次程序不再会有多余的停顿,hello goroutine 执行完毕后程序直接退出。
启动多个goroutine
单个并发只能说是小试牛刀,多个并发才是业务该有的逻辑。
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func hello(i int) {
defer wg.Done() // goroutine结束就登记-1
fmt.Println("hello", i)
}
func main() {
for i := 0; i < 10; i++ {
wg.Add(1) // 启动一个goroutine就登记+1
go hello(i)
}
wg.Wait() // 等待所有登记的goroutine都结束
}
多次执行上面的代码会发现每次终端上打印数字的顺序都不一致。这是因为10个 goroutine 是并发执行的,而 goroutine 的调度是随机的。
但是看了这个,大家对于这个defer
起的作用肯定是有疑问的。
在很早之前就说过这个关键字。这里我认为有必要在给大家说一说。这个关键字很重要,它的应用场景很多。在go中最主要的为三个,一个是同步并发
(这里的主要作用),一个是搭配recover 处理异常
,一个是关闭资源
。这三个应用场景非常常见。而有这些场景,离不开它本有的特性。-----------延迟调用
,简单而言就是最后执行。
内存分配机制:动态栈
操作系统的线程一般都有固定的栈内存(通常为2MB
),而 Go 语言中的 goroutine 非常轻量级,一个 goroutine
的初始栈空间
很小(一般为2KB
),所以在 Go 语言中一次创建数万个 goroutine 也是可能的。并且 goroutine 的栈不是固定的,可以根据需要动态地增大或缩小, Go 的 runtime 会自动为 goroutine 分配合适的栈空间
goroutine调度
操作系统内核在调度时
- 会挂起当前正在执行的线程并将寄存器中的内容保存到内存中
- 选出接下来要执行的线程并从内存中恢复该线程的寄存器信息
- 恢复执行该线程的现场并开始执行线程
从一个线程切换到另一个线程需要完整的上下文切换。因为可能需要多次内存访问,索引这个切换上下文的操作开销较大,会增加运行的cpu周期。
goroutine 的调度
goroutine 的调度是Go语言运行时(runtime)层面的实现,是完全由 Go 语言本身实现的一套调度系统——go scheduler
。它的作用是按照一定的规则将所有的 goroutine 调度到操作系统线程上执行。
目前 Go 语言的调度器采用的是 GPM 调度模型。
-
G:表示 goroutine
,每执行一次go f()
就创建一个 G,包含要执行的函数和上下文信息。 -
全局队列(Global Queue)
:存放等待运行的 G。 -
P
:表示goroutine 执行所需的资源
,最多有GOMAXPROCS
个。 -
P 的本地队列
:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个
。新建 G 时,G 优先加入到 P 的本地队列,如果本地队列满了会批量
移动部分 G 到全局队列。 -
M
:线程想运行任务就得获取 P,从 P 的本地队列获取 G,当 P 的本地队列为空时,M 也会尝试从全局队列或其他 P 的本地队列获取 G。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。 -
Goroutine 调度器
和操作系统调度器
是通过 M 结合起来的,每个 M 都代表了1个内核线程,操作系统调度器负责把内核线程分配到 CPU 的核上执行。 -
GOMAXPROCS
:Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个 OS 线程来同时执行 Go 代码。
单从线程调度讲,
Go语言
相比起其他语言的优势在于OS线程
是由OS内核
来调度的, goroutine 则是由Go运行时(runtime)自己的调度器调度的,完全是在用户态下完成的, 不涉及内核态与用户态
之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池
, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用
了多核的硬件资源,近似的把若干goroutine均分在物理线程
上, 再加上本身 goroutine 的超轻量级,以上种种特性保证了 goroutine 调度方面的性能。
默认值是机器上的 CPU 核心数。例如在一个 8 核心的机器上,GOMAXPROCS 默认为 8。
Go语言中可以通过runtime.GOMAXPROCS函数设置当前程序并发时占用的 CPU逻辑核心数。