1.并发与并行
并发:同一时间段
执行多个任务
并行:同一时间点
执行多个任务
- Go语言中的并发通过
goroutine
实现。 goroutine
属于用户态的线程(协程),支持千万级的并发。goroutine
是由Go语言的timerun
调度完成的,线程是由操作系统调度完成的。- Go语言中使用
channel
在多个goroutine
间进行通讯。 goroutine
与channel
是Go语言秉承了CSP并发模式的基础实现的。
2.goroutine
2.1 使用goroutine
在Go语言中使用goroutine非常简单,只需要在调用函数的时候在前面加上go
关键字,就可以为一个函数创建有个goroutine
。
一个goroutine
必须对应一个函数,可以创建多个goroutine
去执行相同的函数。
2.2 启动单个goroutine
启动goroutine
只需要在调用的函数(匿名函数或者普通函数)前面加一个关键字go
举个栗子:
package main
import (
"fmt"
"time")
func nowTime() {
nowtime := time.Now()
fmt.Println(nowtime.Format("2006-01-02 15:04:06"))
}
func main() {
go nowTime()
fmt.Println("this is main function")
// time.Sleep(10)
}
当我们不执行time.Sleep()
时,结果只打印了this is main function
这是为什么?
- 在程序启动时,Go语言会为
main()
创建一个默认的goroutine
。 - 当
main
函数(主函数)执行完毕的时候该主函数的goroutine
也就结束了。这时创建的分支函数,可能还没有执行完毕也被强制结束了。 - 创建新的
goroutine
需要一些时间的。 - 所以加上一个
time.Sleep()
就可以等待分支函数执行完毕。
2.3 启动多个goroutine
启用多个goroutine
举个栗子:
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func f1(n int64) {
fmt.Println(n)
defer wg.Done()
}
func main() {
for i := 0; i < 10; i++ {
wg.Add(1)
go f1(int64(i))
}
fmt.Println("main")
wg.Wait()
}
2.4 sync.WaitGroup
第一个栗子我们使用的time.Sleep()
用来阻塞主函数,第二个栗子使用的是sync.WaitGroup
,二者由什么区别呢?
使用time.Sleep()将这个阻塞给写死了,我们不知道子函数执行需要多少时间,然后定死了一个时间。这个有很大的弊端,阻塞时间给多个浪费工作效率,阻塞时间给少了可能子函数还没有执行完成。
Go语言提供了一个解决方案,sync.WaitGroup
是一个结构体,在他的内部维护了一个计数器,使用.Add(1)
时给计数器加1,使用.Done()
给计数器减1。最后使用.Wait()
来判断计数器是否归零。
方法名 | 功能 |
---|---|
(wg * WaitGroup) Add(delta int) | 计数器+delta |
(wg *WaitGroup) Done() | 计数器-1 |
(wg *WaitGroup) Wait() | 阻塞直到计数器变为0 |
3. goroutine
与线程
3.1 可增长的栈
操作系统一般都有固定的栈内存,通常为2MB,一个goroutine
的栈在其生命周期开始时只有很小的栈(2KB)。
goroutine
的栈不是固定的,可以增大也可以缩小。最大限制为1GB。
在Go语言中一次创建十万左右的goroutine
是可以的。
3.2 goroutine
的调度
GPM
是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统,区别于操作系统调度OS线程。
1.G很好理解,就是个goroutine的,里面除了存放本goroutine信息外 还有与所在P的绑定等信息。
2.P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。
3.M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的;
P与M一般也是一一对应的。他们关系是: P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M。
P的个数是通过runtime.GOMAXPROCS设定(最大256),Go1.5版本之后默认为物理线程数。 在并发量大的时候会增加一些P和M,但不会太多,切换太频繁的话得不偿失。
单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。
3.3 GOMAXPROCS
1.Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。
2.默认值是机器上的CPU核心数。例如在一个8核心的机器上,调度器会把Go代码同时调度到8个OS线程上(GOMAXPROCS是m:n调度中的n)。
3.Go语言中可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数。
4.Go1.5版本之前,默认使用的是单核心执行。
5.Go1.5版本之后,默认使用全部的CPU逻辑核心数。
我们可以通过将任务分配到不同的CPU逻辑核心上实现并行的效果
两个任务只有一个逻辑核心,此时是做完一个任务再做另一个任务。
举个栗子
package main
import (
"fmt"
"runtime"
"sync"
)
var wg sync.WaitGroup
func f1() {
defer wg.Done()
for i := 0; i < 10; i++ {
fmt.Println("f1", i)
}
}
func f2() {
defer wg.Done()
for i := 0; i < 10; i++ {
fmt.Println("f2", i)
}
}
func main() {
wg.Add(2)
runtime.GOMAXPROCS(1)
go f1()
go f2()
fmt.Println("main")
wg.Wait()
}
`
main
f2 0
f2 1
f2 2
f2 3
f2 4
f2 5
f2 6
f2 7
f2 8
f2 9
f1 0
f1 1
f1 2
f1 3
f1 4
f1 5
f1 6
f1 7
f1 8
f1 9
`
将逻辑核心数设为2,此时两个任务并行执行,代码如下。
package main
import (
"fmt"
"runtime"
"sync"
)
var wg sync.WaitGroup
func f1() {
defer wg.Done()
for i := 0; i < 10; i++ {
fmt.Println("f1", i)
}
}
func f2() {
defer wg.Done()
for i := 0; i < 10; i++ {
fmt.Println("f2", i)
}
}
func main() {
wg.Add(2)
runtime.GOMAXPROCS(12)
go f1()
go f2()
fmt.Println("main")
wg.Wait()
}
1.一个操作系统线程对应多个用户态goroutine
2.go程序可以同时使用多个操作系统线程
3.操作系统线程与goroutine
是多对多关系,即m:n