十四、goroutine(协程)和channel(管道)

16.1 goroutine(协程)

16.1.1 基本介绍

进程和线程说明

1、进程就是程序在操作系统中的一次执行结果,是系统进行资源分配和调度的基本单位

2、线程是进程的一个执行实例,是程序执行的最小单元,它是比进程更小的能独立运行的基本单位

3、一个进程可以创建和销毁多个线程,同一个进程中的多个线程可以并发执行

4、一个程序至少有一个进程,一个进程至少有一个线程

16.1.2程序、进程和线程的关系示意图

fdafaf.PNG

16.1.3 并发和并行

1、多线程程序在单核上运行,就是并发

2、多线程程序在多核上运行,就是并行

fdafaf.PNG

并发:因为是在一个CPU上,比如有10个线程,每个线程执行10毫秒(进行轮训操作),从人的角度看,好像这10个线程都在运行,但是从微观上看,在某一个时间点看,其实只有一个线程在执行,这就是并发

并行:因为是在一个CPU上(比如有10个CPU),比如有10个线程,每个线程执行10毫秒(各自在不同CPU上执行),从人的角度看,这10个线程都在运行,但是从微观上看,在某一个时间点看,也其实只有10个线程在执行,这就是并行

16.1.4 Go协程 和 Go主线程

1、Go主线程(有程序员直接称为线程/也可以理解为进程):一个Go线程上,可以起多个协程,你可以这样理解,协程是轻量级的线程(编译器做优化)

2、Go协程的特点

  • 有独立的栈空间
  • 共享程序堆空间
  • 调度由用户控制
  • 协程是轻量级的线程

fdafaf.PNG

16.1.5 入门案例

1、在主线程(可以理解成进程)中,开启一个gorotine,该协程每隔1秒输出“hello world”

2、在主线程中也每隔1秒输出“hello world”,输出10次后,退出程序

3、要求主线程和gorotine同时执行

4、画出主线程和协程执行流程图

代码实现

package main
import (
	"fmt"
	"strconv"
	"time"
)
// 编写一个函数,每隔1秒输出"hello world"
func test() {
	for i := 1; i <= 10; i++ {
		fmt.Println("test hello world"+ strconv.Itoa(i))
		time.Sleep(time.Second)
	}
}
func main() {
	go test() //开启了一个协程
	for i := 1; i <= 10; i++ {
		fmt.Println("main hello golang"+ strconv.Itoa(i))
		time.Sleep(time.Second)
	}
}

fdafaf.PNG

输出的效果说明,main这个主线程和test协程同时执行

主线程和携程执行流程图

fdafaf.PNG

入门小结

1、主线程是一个物理线程,直接作用在CPU上,是重量级的,非常耗费CPU资源

2、协程是从主线程开启的,是轻量级的线程,是逻辑态,对资源消耗相对小

3、Golang的协程机制是重要的特点,可以轻松点的开启上万个协程,其他编程语言的并发机制是一般基于线程的,开启过多的线程,资源耗费大,这里就突显了Golang在并发上的优势了

16.1.6 gorotine的调度模型

MPG模型基本介绍

M:操作系统的主线程(是物理线程)

P:协程执行需要的上下文

G:协程

16.1.6.1 MPG模式运行—状态1

fdafaf.PNG

1、当前程序有三个M,如果三个M都在一个CPU运行,就是并发,如果在不同的CPU运行,就是并行

2、M1,M2,M3正在执行一个G,M1的协程队列有3个,M2的协程队列有3个,M3协程队列有2个

3、从上图可以看到:Go的协程是轻量级的协程,是逻辑态的,Go可以容易的起上万个协程

4、其他程序c/java,往往谁内核态的,比较重量级,几千个县城可能耗光CPU

16.1.6.2 MPG模式运行—状态2

fdafaf.PNG

1、分成两个部分来看

2、原来的情况是M0主线程正在执行G0协程,另外有三个协程在队列等待

3、如果G0协程阻塞,比如读取文件或者数据库等

4、这时就会创建M1主线程(也可能是从已有的线程池中取出M1),并且将等待的3个协程挂到M1下开始执行,M0的主线程下的G0仍然执行文件io的读写

5、这样的MPG调度模式,可以即让G0执行。同时也不会让队列的其他协程一直阻塞,仍然可以并发/并行执行

6、等到G0不阻塞了,M0会被放到空闲的主线程继续执行(从已有的线程池中取),同时G0又会被唤醒

16.2 设置Golang运行的cpu数

​ 为了充分利用多cpu的优势,在Golang程序中,设置运行的cpu数目

package main
import (
	"fmt"
	"runtime"
)

func main() {
	cpuNum := runtime.NumCPU()
	fmt.Println("cpuNum=", cpuNum)
	// 可以自己设置使用几个CPU
	runtime.GOMAXPROCS(cpuNum - 1)
	fmt.Println("ok")
}

16.3 channel(管道)

16.3.1 入门案例

需求:现在要计算1-200的各个数的阶乘,并且把各个数的阶乘放入到map中,最后显示出来,要求使用gorotine完成

在运行某个程序时,如果直到是否存在资源竞争问题,方法很简单,在编译该程序时增加一个参数 -race即可

代码

package main
import (
	"fmt"
	"time"
)

// 1、编写一个函数,,来计算各个数的阶乘,并放入到map中
// 2、我们启动的协程是多个,统计的将结果放入到map中
// 3、map应该做成全局的

var (
	myMap = make(map[int]int, 10)
)

// test函数就是计算n!,将这个结果放入到 myMap
func test(n int) {
	
	res := 1
	for i := 1; i <= n; i++ {
		res *= i
	}
	// 这里我们将res放入到myMap
	myMap[n] = res
}

func main() {
	// 我们这里开启多个协程完成这个任务[200个]
	for i := 1; i <= 200; i++ {
		go test(i)
	}
	// 休眠10秒钟
	time.Sleep(time.Second * 10)

	// 输出结果,遍历这个结果
	for i, v := range myMap {
		fmt.Printf("map[%d]=%d\n",i, v)
	}
}

结果

NCzpWV.png

示意图

NCzKSK.png

16.3.2 不同gorotine之间如何通讯

  • 全局变量加锁同步

  • channel

16.3.3 使用全局变量加锁同步改进程序

  • 因为没有对全局变量m加锁,因此会出现资源争夺问题,代码会出现错误,提示 concurrent map wrotes
  • 解决方案:加入互斥锁
  • 我们的数的阶乘很大,结果会越界,可以将求阶乘改成sum += unit64(i)

代码改进

NPC7DO.png

NPilOf.png

NPidlq.png

NPFYDK.png

16.3.4 channel的介绍

  • channel本质就是一个数据结构-队列
  • 数据是先进先出
  • 线程安全,多gototine访问时,不需要加锁,就是说channel本身就是线程安全的
  • channel 是有类型的,一个string的channel只能存放string类型数据

NPEk3d.png

16.3.5 管道基本使用

声明/定义 channel

var 变量名 chan  数据类型
举例:
var intChan  chan int  (intChan用于存放int数据)
var mapChan  chan map[int]string  (mapChan 用于存放map[int]string类型)

说明:
- channel 是引用类型
- channel必须初始化才能写入数据,即make后才能使用
- 管道是有类型的, intChan只能写入整数int

16.3.6 管道的初始化,写入数据到管道,从管道读取数据及基本的注意事项

channel初始化

说明:使用make 进行初始化

var  intChan chan int
intChan = make(chan int, 10)

向channel写入(存入)数据

var intChan chan int
intChan = make(chan int, 10)
num := 999
intChan <- 10
intChan <- num
package main
import (
	"fmt"
)

func main() {
	// 1、创建一个可以存放3个int类型
	var intChan chan int 
	//这里的 3 决定了这个 intChan管道 的容量为3,不能超过 3 这个值
	intChan = make(chan int, 3) 

	// 2、看看intChan是什么
	fmt.Printf("intChan 的值=%v intChan本身的地址=%p\n", intChan, &intChan)

	// 3、向管道写入数据
	intChan<- 10
	num := 211
	intChan<- num
	// 注意!!!:当我们给管道写入数据时,不能超过其容量

	// 4、看看管道的长度和cap(容量)
	fmt.Printf("channel len=%v cap=%v\n", len(intChan), cap(intChan))

	// 5、从管道读取数据

	var num2 int
	num2 = <-intChan
	fmt.Println("num2=", num2)
	fmt.Printf("channel len=%v cap=%v\n", len(intChan), cap(intChan))

	// 6、在没有使用协程的情况下,如果我们的管道数据已经被全部取出
	// 再取就会报告 deadlock
	num3 := <-intChan
	num4 := <-intChan
	fmt.Println("num3=", num3, "num4=", num4)
}

注意事项

1、channel中只能存放指定的数据类型

2、channel的数据放满后,就不能再放入了

3、如果从channel取出数据后,可以继续放入

4、在没有协程的情况下,如果channel数据取完了,再去就会报dead lock

案例

package main
import (
	"fmt"
)
type Cat struct {
	Name string
	Age int
}
func main() {
	// 定义一个可以存放任意数据类型的管道 interface
	allChan := make(chan interface{}, 3)
	allChan <- 10
	allChan <- "tom jack"
	cat := Cat{"小花猫", 4}
	allChan <- cat
	// 我们希望获得到管道中的第三个元素,则先将前2个推出
	<- allChan
	<- allChan
	newCat := <-allChan  //从管道中取出的猫是什么?
	fmt.Printf("newCat=%T, newCat=%v", newCat, newCat)
	// 下面的写法是错误的,编译不通过。
	// fmt.Printf("newCat=%v", newCat.Name)
	// 解决上面的问题:类型断言
	a := newCat.(Cat) 
	fmt.Printf("newCat名字=%v", a.Name)
}

16.4 channel的遍历和关闭

channel的关闭

使用内置函数close可以关闭channel,当channel关闭后,就不能向channel写数据了,但是仍然可以从该channel读取数据

package main
import (
	"fmt"
)
func main(){
	intChan := make(chan int, 3)
	intChan <- 100
	intChan <- 200
	close(intChan)  //close
	// 这时不能够再写入数据到管道
	// intChan <- 300
	fmt.Println("okokok~")
	// 当管道关闭后,读取数据是可以的
	n1 := <- intChan
	fmt.Println("n1=", n1)
}

channel的遍历

channel支持for--range的方方式进行遍历,请注意2个细节

  • 在遍历时,如果channel没有关闭,则会出现dead lock的错误
  • 在遍历时,如果channel已经关闭,则会正常遍历数据,遍历完后,就会退出遍历
package main
import (
	"fmt"
)

func main(){
	intChan2 := make(chan int, 100)
	for i :=0; i < 100; i++ {
		intChan2<- i * 2  //放入100个数据到管道
	}
	// 遍历管道不能使用普通的for 循环
	// 在变量时,如果channel咩有关闭,则会出现dead lock的错误
	// 在遍历时,如果channel已经关闭,则会正常遍历数据,遍历完后,就会退出遍历
	close(intChan2)
	for v := range intChan2 {
		fmt.Println("v=", v)
	}
}

16.5 应用实例

案例1

请完成gorotine和channel协同工作的案例,要求如下:

1、开启一个writeData协程,向管道intChan中写入50个整数

2、开启一个readData协程,从管道intChan中读取writeData写入的数据

3、注意:writeData和readDaata操作的是同一管道

4、主线程需要等待writeData和readData协程都完成工作才能退出管道。

思路分析图

fdafaf.PNG

package main
import (
	"fmt"
	// "time"
)

// write Data

func writeData(intChan chan int) {
	for i :=1; i <= 50; i++ {
		// 放入数据
		intChan <- i
		fmt.Println("writeData", i)
		// 休眠一下,便于终端查看
		// time.Sleep(time.Second)
	} 
	close(intChan) //关闭管道
}

// read Data
func readData(intChan chan int, exitChan chan bool) {
	for {
		v, ok := <- intChan
		if !ok {
			break
		}
		// time.Sleep(time.Second)
		fmt.Printf("readData 读到数据=%v\n", v)
	}
	// readData读取完数据后,即任务完成
	exitChan <- true
	close(exitChan)
}

func main(){
	// 创建2个管道
	intChan := make(chan int, 50)
	exitChan := make(chan bool, 1)

	go writeData(intChan)
	go readData(intChan, exitChan)

	// time.Sleep(time.Second * 10)
	for {
		_, ok := <-exitChan
		if !ok {
			break
		}
	}
}

案例2 -阻塞

package main
import (
	"fmt"
	"time"
)

// write Data

func writeData(intChan chan int) {
	for i :=1; i <= 50; i++ {
		// 放入数据
		intChan <- i
		fmt.Println("writeData", i)
		// 休眠一下,便于终端查看
		// time.Sleep(time.Second)
	} 
	close(intChan) //关闭管道
}

// read Data
func readData(intChan chan int, exitChan chan bool) {
	for {
		v, ok := <- intChan
		if !ok {
			break
		}
		time.Sleep(time.Second)
		fmt.Printf("readData 读到数据=%v\n", v)
	}
	// readData读取完数据后,即任务完成
	exitChan <- true
	close(exitChan)
}

func main(){
	// 创建2个管道
	intChan := make(chan int, 10)
	exitChan := make(chan bool, 1)

	go writeData(intChan)
	go readData(intChan, exitChan)
	// time.Sleep(time.Second * 10)
	for {
		_, ok := <-exitChan
		if !ok {
			break
		}
	}
}

问题:

如果注销掉 go readData(intChan, exitChan),程序会怎样?

答:如果只是向管道写入数据,而没有读取,就会出现阻塞而dead lock,原因是intChan容量是10,而代码writeData会写入50个数据,因此会阻塞在writeData的 intChan <- i

如果编译器(运行),发一个管道,只有写,而没有读,则该管道会阻塞,写管道和读管道的频率不一致,无所谓。

案例3

Ni1uR0.png

思路图:

Ni3WN9.png

代码

package main
import (
	"fmt"
	"time"
)

// 开启一个协程,向 intChan 放入1-8000个数
func putNum(intChan chan int) {
	for i := 0; i < 8000; i++ {
		intChan <- i
	}
	// 关闭intChan
	close(intChan)
}

// 开启4个协程,从 intChan 取出数据,并判断是否为素数,
// 如果是就放入到primeChan
func primeNum(intChan chan int, primeChan chan int, exitChan chan bool) {
	// 使用for循环
	var flag bool 
	for {
		num, ok := <-intChan
		time.Sleep(time.Millisecond * 10)
		if !ok {
			break //intChan管道取不出东西了
		}
		flag = true //假设是素数
		// 判断num是否是素数
		for i := 2; i < num; i++ {
			if num % i == 0 { //说明该num不是素数
				flag = false
				break
			}
		}
		if flag {
			// 将这个数就放入到primeChan
			primeChan<- num
		}
	}
	fmt.Println("有一个primerNum 协程因为取不到数据,退出")
	// 这里还不能关闭primerChan
	// 向exitChan 写入true
	exitChan<- true
}
func main(){
	intChan := make(chan int, 1000)
	primeChan := make(chan int, 2000) //放结果
	// 标识退出管道
	exitChan := make(chan bool, 4) //4个

	// 开启一个协程,向 intChan 放入1-8000个数
	go putNum(intChan)
	// 开启4个协程,从 intChan 取出数据,并判断是否为素数,
	// 如果是就放入到primeChan
	for i := 0; i < 4; i++ {
		go primeNum(intChan, primeChan, exitChan)
	}
	// 这里主线程开始进行处理
	go func(){
	for i := 0; i < 4; i++ {
		<-exitChan
	}
	// 当从exitChan取出了4个结果,就可以关闭primeChan管道
	close(primeChan)
	}()

	// 遍历primeChan,把结果取出
	for {
		res, ok := <- primeChan
		if !ok {
			break
		}
		// 将结果输出
		fmt.Printf("素数=%d\n", res)
	}
	fmt.Println("main主线程退出")
}

普通的方法解决案例3

package main
import (
	"time"
	"fmt"
)
func main() {
	start := time.Now().Unix()
	for num := 1; num <= 80000; num++ {
		flag := true //假设是素数
		// 判断num是否是素数
		for i := 2; i < num; i++ {
			if num % i == 0 { //说明该num不是素数
				flag = false
				break
			}
		}
		if flag {
			// 将这个数就放入到primeChan
			// primeChan<- num
		}
	}
	end := time.Now().Unix()
	fmt.Println("普通耗时=", end - start)
}

16.6 channel使用细节和注意事项

  • channel可以声明为只读,或者只写性质

NidOUK.png

  • channel只读和只写的最佳实践案例

NidvCD.png

  • 使用select可以解决从管道取数据的阻塞的问题
package main
import (
	"fmt"
	"time"
)
func main() {
	// 1、定义一个管道10个数据int
	intChan := make(chan int, 10)
	for i := 0; i < 10; i++ {
		intChan<- i
	}
	// 2、定义一个管道 5个数据string
	stringChan := make(chan string, 5)
	for i := 0; i < 5; i++ {
		stringChan <- "hello" + fmt.Sprintf("%d", i)
	}

	// 传统的方法在遍历管道时,如果不关闭会阻塞导致dead lock

	// 在实际开发中,我们不好确定什么时候关闭该管道
	// 可以使用select方式可以解决
	for {
		select {
//注意,这里如果intChan 一直没有关闭不会一直阻塞而dead lock
// 会自动到下一个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("都取不到了")
			time.Sleep(time.Second)
			return
		}
	}
}
  • gorotine中使用recover,解决协程中出现panic,导致程序崩溃问题
package main
import (
	"fmt"
	"time"
)
// 函数
func saayhello() {
	for i := 0; i < 10; i++ {
		time.Sleep(time.Second)
		fmt.Println("hello,world")
	}
}
// 函数
func test() {
	// 这里我们可以使用defer + recover
	defer func() {
		// 捕获test抛出的panic
		if err := recover(); err != nil {
			fmt.Println("test协程发生了错误")
		}
	}()
	// 定义一个map
	var myMap map[int]string
	myMap[0] = "golang"  //err
}

func main() {
	go saayhello()
	go test()
	for i :=0; i < 10; i++ {
		fmt.Println("main() ok=", i)
		time.Sleep(time.Second)
	}
}

说明:如果我们起了一个协程,但是这个协程出现了panic,如果我们没有捕获这个panic,就会造成整个程序的崩溃,这时我们可以在gorotine中使用recover来捕获panic进行处理,这样即使这个协程发生问题,但是主线程仍然不受影响,可以继续执行

posted on 2020-06-15 09:51  九酒馆  阅读(351)  评论(0编辑  收藏  举报

导航