第十一课 go语言基础-协程和管道

第十一课 go语言基础-协程和管道

tags:

  • golang
  • 2019尚硅谷

categories:

  • 协程
  • 管道

文章目录

  • 第十一课 go语言基础-协程和管道
    • 第一节 相关概念介绍
      • 1.1 进程和线程介绍
      • 1.2 并发和并行
      • 1.3 Go协程和Go主线程
      • 1.4 协程入门
    • 第二节 goroutine的调度模型
      • 2.1 goroutine的调度模型
        • 2.1.1 MPG模式一
        • 2.1.2 MPG模式二(重要)
      • 2.2 设置Golang运行的cpu数
      • 2.3 goroutine的并发安全提出
      • 2.4 goroutine的并发安全解决-加全局锁
      • 2.5 goroutine的并发安全解决-channel解决
    • 第三节 管道channel
      • 3.1 channel的基本介绍
      • 3.2 channel的遍历和关闭
      • 3.3 channel的应用实例
      • 3.4 channel的应用实例2
    • 第四节 channel使用细节和注意事项
      • 4.1 channel细节-可读可写
      • 4.2 select解决从管道取数据的阻塞问题
      • 4.3 goroutine中panic导致程序崩溃

第一节 相关概念介绍

1.1 进程和线程介绍

  1. 进程就是操作系统进行资源分配和调度的基本单位。表现为:应用程序、服务等
  2. 线程是程序执行的最小单元
  3. 一个进程可以创建核销毁多个线程,同一个进程中的多个线程可以并发执行
  4. 一个程序至少有一个进程,一个进程至少有一个线程

1.2 并发和并行

  1. 并发是指一个时间段内有几个程序在同一个cpu运行,但是任意时刻只有一个程序在cpu上运行。简言之,是指系统具有单核处理多个任务的能力。
  2. 并行是指任意时刻点上,有多个程序同时运行在多个cpu上,简言之,是指系统具有多核同时处理多个任务的能力。
  3. 这里的cpu核数是指逻辑cpu个数
    第十一课 go语言基础-协程和管道_第1张图片

1.3 Go协程和Go主线程

  1. Go主线程(有程序员直接称为线程/也可以理解成进程):一个Go线程上,可以起多个协程,你可以这样理解,协程是轻量级的线程[编译器做优化]。
  2. 就是说在Go中看到线程就理解成进程就可以啦。
  3. Go语言可以随随便便启动上万个非常稳定的协程(我们必须要服气)
  4. GO协程的特点(面试必考)
    • 1)有独立的栈空间(存储参数、动态局部变量、函数返回值地址)
    • 2)共享程序堆空间(malloc/new所申请的内存空间)
    • 3)调度由用户控制
    • 4)协程是轻量级的线程
      第十一课 go语言基础-协程和管道_第2张图片

1.4 协程入门

  1. 在主线程(可以理解成进程)中,开启一个goroutine,该协程每隔1秒输出"hello,world"
  2. 在主线程中也每隔一秒输出"hello,golang",输出10次后,退出程序
  3. 要求主线程和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)
	}
}
  1. 主线程是一个物理线程,直接作用在cpu上的。是重量级的,非常耗费cpu资源。
  2. 协程从主线程开启的,是轻量级的线程,是逻辑态。对资源消耗相对小。
  3. Golang的协程机制是重要的特点,可以轻松的开启上万个协程。**其它编程语言的并发机制是一般基于线程的,开启过多的线程,资源耗费大,**这里就突显Golang在并发上的优势了

第二节 goroutine的调度模型

2.1 goroutine的调度模型

  1. M:操作系统的主线程(是物理线程)
  2. P:协程执行需要的上下文
  3. G:协程
    第十一课 go语言基础-协程和管道_第3张图片

2.1.1 MPG模式一

  1. 当前程序有三个M,如果三个M都在一个cpu运行, 就是并发,如果在不同的cpu运行就是并行
  2. M1,M2,M3正在执行一个G,M1 的协程队列有三个,M2的协程队列有3个,M3协程队列有2个
  3. 从下图可以看到: Go的协程是轻量级的线程,是逻辑态的,Go可以容易的起,上万个协程。
  4. 其它程序c/java的多线程,往往是内核态的,比较重量级,几千个线程可能耗光cpu
    第十一课 go语言基础-协程和管道_第4张图片

2.1.2 MPG模式二(重要)

  1. 原来的情况是MO主线程正在执行G0协程,另外有三个协程在队列等待
  2. 如果G0协程阻塞,比如读取文件或者数据库等
  3. 这时就会创建M1主线程(也可能是从已有的线程池中取出M1),并且将等待的3个协程挂到M1下开始执行,M0的主线程下的G0仍然执行文件io的读写。
  4. 这样的MPG调度模式,可以既让G0执行,同时也不会让队列的其它协程一直阻塞,仍然可以并发/并行执行。
  5. 等到G0不阻塞了,M0会被放到空闲的主线程继续执行(从已有的线程池中取),同时G0又会被唤醒
    第十一课 go语言基础-协程和管道_第5张图片

2.2 设置Golang运行的cpu数

  1. 为了充分了利用多cpu的优势,在Golang程序中,设置运行的cpu数目
  2. 使用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. 需求:现在要计算1-200 的各个数的阶乘,并且把各个数的阶乘放入到map中。最后显示出来。
    要求使用goroutine完成。
    • 使用goroutine来完成,效率高,但是会出现并发/并行安全问题
    • 这里就提出了不同goroutine如何通信的问题
  2. 在运行某个程序时,如何知道是否存在资源竞争问题。方法很简单,在编译该程序时,增加一个参数**-race**即可。go build -race main.go 之后生成exe并运行,可以看到资源竞争的报错
  3. 下面代码存在上面两个问题:
    • 资源竞争
    • 协程运行的退出(这里用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)
	}
}
  1. 方案一:全局变量的互斥锁(了解)
  2. 方案二:使用管道channel来解决(重要)

2.4 goroutine的并发安全解决-加全局锁

  1. 全局变量的互斥锁,这里使用加锁方案
  2. 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解决

  1. 前面使用全局变量加锁同步来解决goroutine的通讯,但不完美
  2. 主线程在等待所有goroutine全部完成的时间很难确定,我们这里设置10秒,仅仅是估算。
  3. 如果主线程休眠时间长了,会加长等待时间,如果等待时间短了,可能还有goroutine处于工作
    状态,这时也会随主线程的退出而销毁
  4. 通过全局变量加锁同步来实现通讯,也并不利用多个协程对全局变量的读写操作。
  5. 上面种种分析都在呼唤一个新的通讯机制-channel

第三节 管道channel

3.1 channel的基本介绍

  1. channel本质就是一个数据结构-队列[示意图]
  2. 数据是先进先出[FIFO : [first in first out]
  3. 线程安全,多goroutine访问时,不需要加锁,就是说channel本身就是线程安全的(编译器底部维护的)
  4. channel有类型的,一个string的channel只能存放string类型数据
    第十一课 go语言基础-协程和管道_第6张图片
  5. 定义/声明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
  6. channel是引用类型
  7. channel必须初始化才能写入数据,即make后才能使用
  8. 管道是有类型的,intChan 只能写入整数int
  9. channle的数据放满后,就不能再放入了
  10. 如果从channel取出数据后,可以继续放入
  11. 在没有使用协程的情况下(取完没放入),如果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)
}
  1. 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的遍历和关闭

  1. channel的关闭:使用内置函数close可以关闭channel,当channel关闭后,就不能再向channel写数据了,但是仍然可以从该channel读取数据
  2. channel支持for-range的方式进行遍历(这里不能用长度普通的for循环遍历, 因为取出操作本身会导致长度变化),请注意两个细节
    • 在遍历时,如果channel没有关闭,则回出现deadlock的错误
    • 在遍历时,如果channel已经关闭,则会正常遍历数据,遍历完后,就会退出遍历
  3. 它的遍历特性可以解决协程什么时候完成事情
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的应用实例

第十一课 go语言基础-协程和管道_第7张图片

  1. 请完成goroutine和channel协同工作的案例,具体要求:
    • 开启一个writeData协程,向管道intChan中写入50个整数
    • 开启一个readData协程,从管道intChan中读取writeData写入的数据。
    • 注意: writeData和readDate操作的是同一个管道
    • 主线程需要等待writeData和readDate协程都完成工作才能退出[管道]
  2. 阻塞的问题的提出
    • 问题:如果注销掉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. 要求统计1-200000的数字中,哪些是素数? 学习了goroutine和channel的知识后,就可以完成了[测试数据: 8000]
  2. 传统的方法,就是使用一个循环,循环的判断各个数是不是素数[ok] 。
  3. 使用并发/并行的方式,将统计素数的任务分配给多个(4个)goroutine去完成,完成任务时间短。
  4. 协程后,执行的速度,比普通方法提高至少4倍
    第十一课 go语言基础-协程和管道_第8张图片
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细节-可读可写

  1. channel可以声明为只读,或者只写性质
  2. 默认情况下,管道是双向的, 既可读也可写
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)
}
  1. 只读只写的最佳实践
    第十一课 go语言基础-协程和管道_第9张图片

4.2 select解决从管道取数据的阻塞问题

  1. 传统的方法在遍历管道时,如果不关闭会阻塞而导致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导致程序崩溃

  1. goroutine中使用recover,解决协程中出现panic,导致程序崩溃问题
  2. 说明:如果我们起了一个协程,但是这个协程出现了panic,如果我们没有捕获这个panic,就会造成整个程序崩溃,
  3. 这时我们可以在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)
	}
}

你可能感兴趣的:(go语言基础)