并发编程在当前软件领域是一个非常重要的概念,随着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 mainimport ("fmt"
)func hello() {fmt.Println("hello")
}func main() {hello()fmt.Println("你好")
}
根据代码逻辑就是从上至下执行。
ok我们试一下加上关键字go,启动一个 goroutine 去执行 hello 这个函数。
package mainimport ("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 mainimport ("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 mainimport ("fmt""sync"
)// 声明全局等待组变量
var wg sync.WaitGroupfunc hello() {fmt.Println("hello")wg.Done() // 告知当前goroutine完成
}func main() {wg.Add(1) // 登记1个goroutinego hello()fmt.Println("你好")wg.Wait() // 阻塞等待登记的goroutine完成
}
将代码编译后再执行,得到的输出结果和之前一致,但是这一次程序不再会有多余的停顿,hello goroutine 执行完毕后程序直接退出。
启动多个goroutine
单个并发只能说是小试牛刀,多个并发才是业务该有的逻辑。
package mainimport ("fmt""sync"
)var wg sync.WaitGroupfunc hello(i int) {defer wg.Done() // goroutine结束就登记-1fmt.Println("hello", i)
}
func main() {for i := 0; i < 10; i++ {wg.Add(1) // 启动一个goroutine就登记+1go 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逻辑核心数。