这是我参与「第三届青训营 -后端场」笔记创作活动的的第5篇笔记;主要学习了Go的并发编程。
goroutine 协程(轻量级线程)
以32位机器为例:
- 一个进程大约占据虚拟内存4G
- 线程大约4M (linux下应该不分线程进程吧)
当前并发任务的两个难点:
- 高消耗(频繁的上下文切换)
- 高内存占用
携程的设计
将传统的线程分割成用户态和内核态两部分,并一一绑定,cpu调度只会关注内核空间中的线程,隐藏了用户态的"线程"的动作,即为携程,目的是尽量减少上下文切换
(经典加一层。。。)
用户态的携程可以通过携程调度器唯一绑定内核空间的线程
类比线程和进程设计的关系,有如下三种设计方式
设计一:
这种设计最大的缺陷是一旦其中一个携程阻塞,会影响到之后携程的调度
设计二:
1:1 的关系,解决了携程阻塞的问题,但是此时并没有减少上下文切换的概率
设计三:
M:N的关系,是上面的综合,该设计的避免了上述两种设计各自的弊端,性能瓶颈只存在于协程调度器的设计,设计的越好,效率就越高
Golang 对协程的处理
- 首先,改了个名字。。。
co-routine --> Goroutine
- 规定了协程大小
内存:几kb, 可以大量开辟
3.灵活调度(调度器)
可以频繁切换(反正就是在用户态的层面来回切,没有上下文切换)
Golang 早期调度器的处理
弊端:
- 创建、销毁、调度携程都需要获取M个互斥锁,形成了激烈的锁竞争
- 线程转移携程会造成延迟和额外的系统负载(例:携程一号被调度到线程中,执行时创建了携程二号,携程二号进入队列,被另一个线程调度,可是携程一号和二号具有空间连续性,如果被同一线程调度那效率会更快)
- 上下文切换(系统调用)导致频繁的线程阻塞和唤醒,增加了系统开销
Golang 优化后的调度策略:GMP
processor包含了其本地队列上每个协程的资源
执行中的协程创建了新协程,会优先放进当前执行线程的本地队列中,如果本地队列满了才会放入全局队列
通过一个宏值来定义 processor的个数
调度器的设计策略
- 复用线程
- 利用并行
- 抢占策略
- 全局协程队列
复用线程
work stealing 机制
如果一个本地队列是空,而周围有队列还有等待调度的协程,就从周围的队列中“偷”一个过来执行。
hand off 机制 如果一个线程执行的协程被阻塞,且与该线程绑定的processor对应的本地队列还有其他协程,则创建or唤醒一个新的thread,将该processor和本地队列重新绑定到新的thread上,老的thread等待被阻塞协程,该协程在接下来被唤醒后执行完毕,这个线程进入睡眠或者销毁。
利用并行
通过 GOMAXPROCS 这个宏限定processor的个数
大约等于 CPU核心数/2
抢占策略
co-routine 没有设计时间片轮转机制,所以只有co-routine 主动释放和thread 的链接,否则其他co-routine 会一直等待
goroutine 增加了时间片轮转机制,使得并发度进一步提高
全局协程队列
全局队列的存取是加锁的,效率上要比本地队列慢 如果一个processor 的本地队列为空,并且其他processor 本地队列中也为空,那么该processor才会从全局队列中取出协程放入本地队列,即优先执行work stealing 机制,执行失败才会从全局队列中取
创建goroutine
// 第一个并发程序
package main
import (
"fmt" // 格式化IO
"time"
)
func goFunc(i int) {
fmt.Println("goroutine", i)
}
func main() {
for i := 0; i < 1000; i++ {
go goFunc(i)
}
time.Sleep(time.Second) // 休眠1s
}
runtime.Goexit() // 推出当前协程
channel 协程间通信
// 创建方式
make(chan Type)
make(chan Type, capacity)
channel <- value // 向管道写
<- channel // 接收并丢弃
x := <- channel // 接收并传值
x,ok := <- channel // 功能同上,并检查channel是否为空或者关闭
// 无缓冲型的channel
package main
import(
"fmt"
)
func main() {
// 定义一个channel ,无缓冲型
c := make(chan int)
// 匿名的go程
go func() {
defer fmt.Println("goroutine is end")
fmt.Println("goroutine is working")
// 将数据写入管道
c <- 666
}()
num := <-c // 从c中读数据,并且给num做初始化
fmt.Println("main goroutine num = ", num)
}
// 有缓冲的channel
package main
import(
"fmt"
"time"
)
func main() {
// 定义一个channel ,有缓冲型的
c := make(chan int, 3)
// 匿名的go程
go func() {
defer fmt.Println("goroutine is end")
fmt.Println("goroutine is working")
// 将数据写入管道
for i := 0; i < 100; i++ {
// 下面两个操作应该要原子化,否则打印有问题
c <- i // 新版本的go编译器支持有空格,旧版本可能不能这么写
fmt.Println("send:",i,"len():",len(c),"cap()",cap(c))
}
}()
time.Sleep(1*time.Second)
for i := 0; i < 100; i++ {
num := <-c // 从c中读数据,并且给num做初始化
// code还是有问题,下面的输出和上面的接收并不原子,导致打印有问题
fmt.Println("receive:", num, "len():",len(c),"cap():",cap(c))
}
}
tips: channel 本身具有同步机制,通过channel的传送和接收可以直接保证发送方和接收方的同步顺序
对于无缓冲的channel,两个协程虽然是以异步的方式执行、推进,但是channel的同步机制使得 接收方/发送方 阻塞等待,完成协程间的同步 对于有缓冲的channel,channel可以实现信号量机制,通道满了发送方阻塞,通道空了接收方阻塞,其余时间各自并发。
channel 关闭
package main
import(
"fmt"
)
func main() {
// 定义一个channel ,有缓冲型的
c := make(chan int)
// 匿名的go程
go func() {
defer fmt.Println("goroutine is end")
fmt.Println("goroutine is working")
// 将数据写入管道
for i := 0; i < 5; i++ {
// 下面两个操作应该要原子化,否则打印有问题
c <- i // 新版本的go编译器支持有空格,旧版本可能不能这么写
fmt.Println("send:",i,"len():",len(c),"cap()",cap(c))
}
close(c) // 关闭channel, 因为接收方的接收方式改成持续读,直到channel关闭,
// 所以发送方发完数据以后需要关闭channel,否则会报死锁error
}()
// 循环的另一种写法,先执行表达式,在判断,data可以作为循环内部的形参
// 如果ok为true表示channel没有关闭,否则表示已经关闭
for {
if data, ok := <- c; ok {
fmt.Println("receive:", data, "len():",len(c),"cap():",cap(c))
} else {
break
}
}
}
tips:
- 只有当确定没有任何数据要发送了,才需要关闭channel
- 关闭channel,无法再向其发送数据(引发panic错误后导致接收自己返回零值)
- 关闭channel后,如果此时还有数据,可以继续读
- 对于nil channel,收发都会阻塞
channel 与 range
package main
import(
"fmt"
)
func main() {
// 定义一个channel
c := make(chan int)
// 匿名的go程
go func() {
defer fmt.Println("goroutine is end")
fmt.Println("goroutine is working")
// 将数据写入管道
for i := 0; i < 5; i++ {
// 下面两个操作应该要原子化,否则打印有问题
c <- i // 新版本的go编译器支持有空格,旧版本可能不能这么写
fmt.Println("send:",i,"len():",len(c),"cap()",cap(c))
}
close(c) // 关闭channel, 因为接收方的接收方式改成持续读,直到channel关闭,
// 所以发送方发完数据以后需要关闭channel,否则会报死锁error
}()
// range 可以被阻塞,配合channel 可以对下面代码进行简写
for data := range c {
fmt.Println("receive:", data, "len():",len(c),"cap():",cap(c))
}
// for {
// if data, ok := <- c; ok {
// fmt.Println("receive:", data, "len():",len(c),"cap():",cap(c))
// } else {
// break
// }
// }
}
channel 与 select
单流程下go只能监控一个channel的状态 可以通过select完成IO复用。
// 样例
select {
case <- c1:
// 如果c1成功读到数据,则进行该处理语句
case c2 <- 1:
// 如果成功向c2写入数据,则进行该处理语句
default:
// 执行默认语句
}
tips: 管道传参传的是引用(指针)
// 利用select 实现一个fibnacii
package main
import(
"fmt"
)
func fibonacii(c, quit chan int) {
x, y := 1,1
for {
select {
case c <-x:
// 如果c1成功读到数据,则进行该处理语句
// var tmp int = x
// x = y
// y = tmp + y
x,y = y,x+y
case <- quit:
// 如果成功向c2写入数据,则进行该处理语句
fmt.Println("quit")
return
}
}
}
func main() {
c := make(chan int)
quit := make(chan int)
go func() {
defer fmt.Println("goroutine is end")
for i := 0; i < 10; i++ {
fmt.Println(<-c)
}
quit <- 0
}()
fibonacii(c, quit)
}