第十一课 go语言基础-协程和管道
tags:
- golang
- 2019尚硅谷
categories:
- 协程
- 管道
第一节 相关概念介绍
1.1 进程和线程介绍
- 进程就是操作系统进行资源分配和调度的基本单位。表现为:应用程序、服务等
- 线程是程序执行的最小单元。
- 一个进程可以创建核销毁多个线程,同一个进程中的多个线程可以并发执行
- 一个程序至少有一个进程,一个进程至少有一个线程
1.2 并发和并行
- 并发是指一个时间段内有几个程序在同一个cpu运行,但是任意时刻只有一个程序在cpu上运行。简言之,是指系统具有单核处理多个任务的能力。
- 并行是指任意时刻点上,有多个程序同时运行在多个cpu上,简言之,是指系统具有多核同时处理多个任务的能力。
- 这里的cpu核数是指逻辑cpu个数。
1.3 Go协程和Go主线程
- Go主线程(有程序员直接称为线程/也可以理解成进程):一个Go线程上,可以起多个协程,你可以这样理解,协程是轻量级的线程[编译器做优化]。
- 就是说在Go中看到线程就理解成进程就可以啦。
- Go语言可以随随便便启动上万个非常稳定的协程(我们必须要服气)
- GO协程的特点(面试必考)
- 1)有独立的栈空间(存储参数、动态局部变量、函数返回值地址)
- 2)共享程序堆空间(malloc/new所申请的内存空间)
- 3)调度由用户控制
- 4)协程是轻量级的线程
1.4 协程入门
- 在主线程(可以理解成进程)中,开启一个goroutine,该协程每隔1秒输出"hello,world"
- 在主线程中也每隔一秒输出"hello,golang",输出10次后,退出程序
- 要求主线程和goroutine同时执行
package main
import(
"fmt"
"strconv"
"time"
)
func test(){
for i:=0; i < 10; i++{
fmt.Println("test() hello, world" + strconv.Itoa(i))
time.Sleep(time.Second)
}
}
func main(){
go test()
for i:=0; i < 10; i++{
fmt.Println("test() hello, Golang mian" + strconv.Itoa(i))
time.Sleep(time.Second)
}
}
- 主线程是一个物理线程,直接作用在cpu上的。是重量级的,非常耗费cpu资源。
- 协程从主线程开启的,是轻量级的线程,是逻辑态。对资源消耗相对小。
- Golang的协程机制是重要的特点,可以轻松的开启上万个协程。**其它编程语言的并发机制是一般基于线程的,开启过多的线程,资源耗费大,**这里就突显Golang在并发上的优势了
第二节 goroutine的调度模型
2.1 goroutine的调度模型
- M:操作系统的主线程(是物理线程)
- P:协程执行需要的上下文
- G:协程
2.1.1 MPG模式一
- 当前程序有三个M,如果三个M都在一个cpu运行, 就是并发,如果在不同的cpu运行就是并行
- M1,M2,M3正在执行一个G,M1 的协程队列有三个,M2的协程队列有3个,M3协程队列有2个
- 从下图可以看到: Go的协程是轻量级的线程,是逻辑态的,Go可以容易的起,上万个协程。
- 其它程序c/java的多线程,往往是内核态的,比较重量级,几千个线程可能耗光cpu
2.1.2 MPG模式二(重要)
- 原来的情况是MO主线程正在执行G0协程,另外有三个协程在队列等待
- 如果G0协程阻塞,比如读取文件或者数据库等
- 这时就会创建M1主线程(也可能是从已有的线程池中取出M1),并且将等待的3个协程挂到M1下开始执行,M0的主线程下的G0仍然执行文件io的读写。
- 这样的MPG调度模式,可以既让G0执行,同时也不会让队列的其它协程一直阻塞,仍然可以并发/并行执行。
- 等到G0不阻塞了,M0会被放到空闲的主线程继续执行(从已有的线程池中取),同时G0又会被唤醒。
2.2 设置Golang运行的cpu数
- 为了充分了利用多cpu的优势,在Golang程序中,设置运行的cpu数目
- 使用runtime包。
- go1.8后,默认让程序运行在多个核上,可以不用设置了
- go1.8前,还是要设置一下,可以更高效的利益cpu
package main
import(
"fmt"
"runtime"
)
func main(){
//获取当前系统逻辑cpu的数量
num := runtime.NumCPU()
//GOMAXPROCS设置可同时执行的最大CPU数,并返回先前的设置
runtime.GOMAXPROCS(num)
fmt.Println("num=", num)
}
2.3 goroutine的并发安全提出
- 需求:现在要计算1-200 的各个数的阶乘,并且把各个数的阶乘放入到map中。最后显示出来。
要求使用goroutine完成。- 使用goroutine来完成,效率高,但是会出现并发/并行安全问题
- 这里就提出了不同goroutine如何通信的问题
- 在运行某个程序时,如何知道是否存在资源竞争问题。方法很简单,在编译该程序时,增加一个参数**-race**即可。go build -race main.go 之后生成exe并运行,可以看到资源竞争的报错
- 下面代码存在上面两个问题:
- 资源竞争
- 协程运行的退出(这里用Sleep延时肯定不行的)
package main
import(
"fmt"
"time"
)
// 定义一个全局map
var(
myMap = make(map[int]int, 10)
)
// 计算n!结果以及之前结果保存在myMap中
func test(n int){
res := 1
for i := 1; i <= n; i++{
res *= i
}
// 将结果储存在myMap中
myMap[n] = res
}
func main(){
for i := 1; i < 20; i++{
go test(i)
}
// 休眠10秒 防止主线程退出 其他协程也退出
time.Sleep(time.Second*10)
// 输出变量结果
for i, v := range myMap{
fmt.Printf("map[%d]=%d\n", i, v)
}
}
- 方案一:全局变量的互斥锁(了解)
- 方案二:使用管道channel来解决(重要)
2.4 goroutine的并发安全解决-加全局锁
- 全局变量的互斥锁,这里使用加锁方案
- sync包提供了基本的同步基元,如互斥锁。除了Once和WaitGroup类型,大部分都是适用于低水平程序线程,高水平的同步使用channel通信更好一些。
- sync 是包: synchornized同步 Mutex :是互斥
package main
import(
"fmt"
"time"
"sync"
)
// 定义一个全局map
var(
myMap = make(map[int]int, 10)
// 声明一个全局的互斥锁 lock是一个全局的互斥锁
lock sync.Mutex
)
// 计算n!结果以及之前结果保存在myMap中
func test(n int){
res := 1
for i := 1; i <= n; i++{
res *= i
}
// 将结果储存在myMap中
// 加锁
lock.Lock()
myMap[n] = res
lock.Unlock()
}
func main(){
for i := 1; i < 20; i++{
go test(i)
}
// 休眠10秒 防止主线程退出 其他协程也退出
time.Sleep(time.Second*10)
// 输出变量结果
// 主线程并不知道10秒能执行完成,因此底层可能仍然出现资源争夺,因此加入互斥锁即可解决问题
lock.Lock()
for i, v := range myMap{
fmt.Printf("map[%d]=%d\n", i, v)
}
lock.Unlock()
}
2.5 goroutine的并发安全解决-channel解决
- 前面使用全局变量加锁同步来解决goroutine的通讯,但不完美
- 主线程在等待所有goroutine全部完成的时间很难确定,我们这里设置10秒,仅仅是估算。
- 如果主线程休眠时间长了,会加长等待时间,如果等待时间短了,可能还有goroutine处于工作
状态,这时也会随主线程的退出而销毁 - 通过全局变量加锁同步来实现通讯,也并不利用多个协程对全局变量的读写操作。
- 上面种种分析都在呼唤一个新的通讯机制-channel
第三节 管道channel
3.1 channel的基本介绍
- channel本质就是一个数据结构-队列[示意图]
- 数据是先进先出[FIFO : [first in first out]
- 线程安全,多goroutine访问时,不需要加锁,就是说channel本身就是线程安全的(编译器底部维护的)
- channel有类型的,一个string的channel只能存放string类型数据
- 定义/声明channel :var 变量名 chan 数据类型
- var intChan chan int (intChan用于存放int数据)
- var mapChan chan map[int]string (mapChan用于存放map[int]string 类型)
- var perChan chan Person
- var perChan2 chan *Person
- channel是引用类型
- channel必须初始化才能写入数据,即make后才能使用
- 管道是有类型的,intChan 只能写入整数int
- channle的数据放满后,就不能再放入了
- 如果从channel取出数据后,可以继续放入
- 在没有使用协程的情况下(取完没放入),如果channel数据取完了,再取,就会报deadlock
package main
import(
"fmt"
)
func main(){
//1.创建一个可以存放3个int类型的管道
var intChan chan int
intChan = make(chan int, 3)
fmt.Printf("intChan的值=%v intChan本身的地址=%p\n", intChan, &intChan)
// 2. 向管道中写入数据
intChan <- 10
num := 30
intChan <- num
intChan <- 40
// intChan <- 50 //不能超过最大长度 会报错all goroutines are asleep - deadlock!
//3.看看管道的长度和cap(容量)
fmt.Printf("channel len= %v cap=%v \n", len(intChan), cap(intChan))
//4. 从管道中读取数据
num1 := <-intChan
num2 := <-intChan
num3 := <-intChan
//num4 := <-intChan 如果管道中无值再取 报错all goroutines are asleep - deadlock!
fmt.Println("num4:", num4)
fmt.Println("num1:", num1, "num2:", num2,"num3:", num3)
}
- channel的使用案例如下
package main
import(
"fmt"
)
type Cat struct{
Name string
Age int
}
func main(){
// 创建一个map管道
var mapChan chan map[string]string
mapChan = make(chan map[string]string, 5)
m1 := make(map[string]string, 10)
m2 := make(map[string]string, 20)
m1["city1"] = "北京"
m1["city2"] = "上海"
m2["hero1"] = "松江"
m2["hero2"] = "红孩儿"
mapChan<- m1
mapChan<- m2
//可以直接从管道中扔出
<- mapChan
<- mapChan
fmt.Printf("Map管道%v\nm1的内容%v\nm2的内容%v\n", mapChan, m1, m2)
// 创建一个catChan,最多可以存放10个Cat结构体变量,演示写入和读取
var catChan chan Cat
catChan = make(chan Cat, 10)
cat1 := Cat{Name:"Tom", Age:18}
cat2 := Cat{Name:"Tom~", Age:23}
catChan<- cat1
catChan<- cat2
cat11 := <-catChan
cat22 := <-catChan
fmt.Printf("cat11的内容%v\ncat22的内容%v\n", cat11, cat22)
// 创建一个catChan2, 最多可以存放10个*Cat变量,演示写入和读取的用法
var catChan2 chan *Cat
catChan2 = make(chan *Cat, 10)
catChan2<- &cat1
catChan2<- &cat2
cat_1 := <-catChan2
cat_2 := <-catChan2
fmt.Println(cat_1, cat_2)
// 创建一个allChan,最多可以存放10个任意数据类型变量,演示写入和读取的用法
var allChan chan interface{}
allChan = make(chan interface{}, 10)
allChan<- cat1
allChan<- cat2
allChan<- 10
allChan<- "jack"
cat1_all := <- allChan
cat2_all := <- allChan
num_all := <-allChan
str_all := <-allChan
fmt.Println(cat1_all, cat2_all, num_all, str_all)
// 注意下面会报错 interface {} is interface with no methods
// 在编译层面它认为cat2_all是空接口类型
//fmt.Println(cat2_all.Name)
// 可以用类型断言即可
a := cat2_all.(Cat)
fmt.Println(a.Name)
}
3.2 channel的遍历和关闭
- channel的关闭:使用内置函数close可以关闭channel,当channel关闭后,就不能再向channel写数据了,但是仍然可以从该channel读取数据
- channel支持for-range的方式进行遍历(这里不能用长度普通的for循环遍历, 因为取出操作本身会导致长度变化),请注意两个细节
- 在遍历时,如果channel没有关闭,则回出现deadlock的错误
- 在遍历时,如果channel已经关闭,则会正常遍历数据,遍历完后,就会退出遍历。
- 它的遍历特性可以解决协程什么时候完成事情。
package main
import(
"fmt"
)
func main(){
intchan := make(chan int, 3)
intchan <- 10
intchan <- 20
// 关闭管道
close(intchan)
// 关闭后不能在存放
// intchan <- 30
// 读取数据完全没有问题
n1 := <-intchan
<- intchan
fmt.Println(n1)
intchan2 := make(chan int, 100)
for i := 0; i < 100; i++{
intchan2<- i*2
}
// 遍历管道不能使用普通的for循环 因为一个取出后第二个就变成第一个位置啦
// for i := 0; i < len(intChan2); i++ {
// }
// 遍历前需要先关闭管道
close(intchan2)
for v := range intchan2{
fmt.Println(v)
}
}
3.3 channel的应用实例
- 请完成goroutine和channel协同工作的案例,具体要求:
- 开启一个writeData协程,向管道intChan中写入50个整数
- 开启一个readData协程,从管道intChan中读取writeData写入的数据。
- 注意: writeData和readDate操作的是同一个管道
- 主线程需要等待writeData和readDate协程都完成工作才能退出[管道]
- 阻塞的问题的提出。
- 问题:如果注销掉go readData(intq han, exitChan) ,程序会怎么样?
- 如果只是向管道写入数据,而没有读取,就会出现阻塞而dead lock,原因是intChan容量是10,而代码writeData会写入50个数据因此会阻塞在writeData的 ch<- i
- 如果,编译器(运行),发现一个管道、只有写,而没有读,则该管道,会阻塞。
- 写管道和读管道的频率不一致,无所谓。(编译器会自己分析,有没有在读没有报错死锁,有正常运行)
package main
import(
"fmt"
"time"
)
// 写数据
func writeData(intChan chan int){
for i := 1; i <= 500; i++{
intChan<- i
fmt.Println("写入管道数据:", i)
}
close(intChan)
}
func readData(intChan chan int, exitChan chan bool){
for {
v, ok := <-intChan
if !ok{
break
}
// 频率不一样完全没问题
//time.Sleep(time.Second)
fmt.Println("读到管道数据:", v)
}
exitChan<- true
close(exitChan)
}
func main(){
// 创建两个管道
intChan := make(chan int, 50)
exitChan := make(chan bool, 1)
// 启动 写读协程
go writeData(intChan)
go readData(intChan, exitChan)
// 不停读取退出管道, 解决之前不知道何时协程执行完成的问题
for {
// 直到可以读到exitChan的值
_, ok := <-exitChan
// fmt.Println(ok) 这里!ok和ok是一样的结果
// 因为!ok指的取到后再取一次取不到, ok是直接取到(更直接)
if !ok{
break
}
}
}
3.4 channel的应用实例2
- 要求统计1-200000的数字中,哪些是素数? 学习了goroutine和channel的知识后,就可以完成了[测试数据: 8000]
- 传统的方法,就是使用一个循环,循环的判断各个数是不是素数[ok] 。
- 使用并发/并行的方式,将统计素数的任务分配给多个(4个)goroutine去完成,完成任务时间短。
- 协程后,执行的速度,比普通方法提高至少4倍
package main
import(
"fmt"
"time"
)
func putNum(intChan chan int){
for i := 1; i <= 20000; i++{
intChan<- i
}
close(intChan)
}
// 取值判断是否为素数
func primeNum(intChan chan int, primeChan chan int, exitChan chan bool){
var flag bool
for{
//time.Sleep(time.Millisecond * 10)
num, ok :=<- intChan
if !ok{
break
}
flag = true
// 判断是不是素数
for i := 2; i < num; i++{
if num % i == 0{
flag = false
break
}
}
if flag{
primeChan<- num
}
}
fmt.Printf("有协程已经取不到数据啦, 退出")
exitChan<- true
}
func main(){
intChan := make(chan int, 20000)
primeChan := make(chan int, 10000)
exitChan := make(chan bool, 4)
// 统计用时
start := time.Now().Unix()
// 开启一个协程放入1-8000个数
go putNum(intChan)
// 开启四个协程取出数据判断素数
for i := 0; i < 4; i++{
go primeNum(intChan, primeChan, exitChan)
}
// 再开一个协程 等待标志管道取出四个值
go func(){
for i := 0; i < 4; i++{
<- exitChan
}
// 取出后关闭管道primeChan
close(primeChan)
}()
// 遍历我们的primeChan把结果取出
for {
res, ok := <-primeChan
if !ok{
break
}
fmt.Printf("素数%d\n", res)
}
end := time.Now().Unix()
fmt.Printf("用时:%v", end - start)
}
第四节 channel使用细节和注意事项
4.1 channel细节-可读可写
- channel可以声明为只读,或者只写性质
- 默认情况下,管道是双向的, 既可读也可写
package main
import(
"fmt"
)
func main(){
//管道可以声明为只读或者只写
//1.在默认情况下下,管道是双向
//var chan1 chan int //可读可写
//2.声明为只写
var chan2 chan<- int
chan2 = make(chan int, 3)
chan2<- 20
//num := <-chan2 //error
fmt.Println("chan2=", chan2)
//3.声明为只读 代码上没问题 但是没办法写怎么读 会报错
var chan3 <-chan int
num2 := <- chan3
//chan3<- 30 //err
fmt.Println("num2", num2)
}
- 只读只写的最佳实践
4.2 select解决从管道取数据的阻塞问题
- 传统的方法在遍历管道时,如果不关闭会阻塞而导致deadlock问题,在实际开发中,可能我们不好确定什么关闭该管道可以使用select方式可以解决
package main
import(
"fmt"
"time"
)
func main(){
//使用select可以解决从管道取数据的阻塞问题
//1.定义一个管道10个数据int
intChan := make(chan int, 10)
for i := 0;i < 10; i++{
intChan<- i
}
// 2. 定义一个stringChan 存放5个string数据
stringChan := make(chan string, 5)
for i := 0;i < 5; i++{
stringChan<- "hello" + fmt.Sprintf("%d", i)
}
//传统的方法在遍历管道时,如果不关闭会阻塞而导致deadlock
//问题,在实际开发中,可能我们不好确定什么关闭该管道.
//可以使用select方式可以解决
//label:
for{
select{
//注意:这里,如果intChan一直没有关闭,不会一直阻塞而deadlock
//,会自动到下一个case匹配
case v := <-intChan :
fmt.Printf("从intChan读取的数据%d\n", v)
time.Sleep(time.Second)
case v := <-stringChan :
fmt.Printf("从stringChan读取的数据%s\n", v)
time.Sleep(time.Second)
default:
fmt.Printf("都取不到了, 不玩了, 程序员可以加入逻辑\n")
time.Sleep(time.Second)
return
//break label
}
}
}
4.3 goroutine中panic导致程序崩溃
- goroutine中使用recover,解决协程中出现panic,导致程序崩溃问题
- 说明:如果我们起了一个协程,但是这个协程出现了panic,如果我们没有捕获这个panic,就会造成整个程序崩溃,
- 这时我们可以在goroutine中使用recover来捕获panic,进行处理,这样即使这个协程发生的问题,但是主线程仍然不受影响,可以继续执行。
package main
import(
"fmt"
"time"
)
func sayHello(){
for i := 1; i <= 10; i++{
time.Sleep(time.Second)
fmt.Println("hello world sayHello", i)
}
}
func test(){
//这里我们可以使用defer + recover
defer func(){
//捕获test拋出的panic
if err := recover(); err != nil{
fmt.Println("test() 发生错误", err)
}
}()
var myMap map[int]string
// 没有make 就是用 肯定报错 看报错会不会终止主线程
myMap[0] = "golang"
}
func main(){
go sayHello()
go test()
for i := 0; i <= 10; i++{
fmt.Println("main ok", i)
time.Sleep(time.Second)
}
}