第20节 Go中的 goroutine 和 channel

120 阅读2分钟

1. 前言

1.1 进程和线程的介绍

  • 进程就是程序在操作系统中的一次执行过程,是系统进行资源分配和调度的基本单元

  • 线程是进程的子任务

  • 一个线程只能属于一个进程,而一个进程至少有一个或多个线程,线程依赖于进程而存在

  • 进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存

掘金-第 10 页.drawio.png

1.2 并发和并行

  • 多线程程序在单核cpu上运行,就是并发
  • 多线程程序在多核cpu上运行,就是并行

image.png

2. Go 协程和 Go 主线程

  • Go主线程,可以把它称为线程、也可以理解为进程。在一个 Go 线程上可以有多个协程,可以理解成协程是轻量级的线程,由 Go 编译器做了优化
  • Go 协程有独立的栈空间、共享程序堆内存*

掘金-第 10 页.drawio (1).png

2.1 goroutine 案例

  • 利用Go语言实现同时输出 hello worldhello golang 两个字符串
package main

import (
	"fmt"
	"time"
	"strconv"
)

func main() {
	// 开启一个协程
	go syncPrint()

	for i := 1; i <= 10; i++ {
		fmt.Println("main第" + strconv.Itoa(i) + "次打印:hello golang")
		time.Sleep(time.Second)
	}
}

func syncPrint() {
	for i := 1; i <= 10; i++ {
		fmt.Println("syncPrint第" + strconv.Itoa(i) + "次打印:hello world")
		time.Sleep(time.Second)
	}
}

掘金-第 10 页.drawio (2).png

  • 主线程是一个物理线程,直接作用在 cpu 上的,是非常消耗 cpu 资源的;协程是从主线程中开启的,是轻量级线程,消耗资源相对较少

3. goroutine 之间通信

  • 多个协程对全局变量 i 进行累加,然后将累加的结果放到 map
package main

import (
	"fmt"
	"time"
)

var (
	 i = 1
	 resMap = make(map[int]int, 10)
)

func add() {
        i++
	resMap[i] = i
	fmt.Println("add",resMap)
}

func main() {
	for i:= 1; i < 20; i++ {
		go add()
	}	

	time.Sleep(time.Second * 5)
}

运行后报错:fatal error: concurrent map iteration and map write,意思是 并发的迭代且向 map 中写入数据

image.png 原因:多个协程来操作同一块内存数据(比如案例中 map),引发了资源竞争,导致了安全问题

掘金-第 10 页.drawio (3).png

3.1 如何保证不同协程之间数据安全

  • 3.1.1 全局变量互斥锁 需要使用到 sync 包下的相关函数来完成全局变量的互斥锁功能,如下所示:
type Mutex struct {
}
func (m *Mutex) Lock()
func (m *Mutex) Unlock()
  • 利用全局变量互斥锁的方式来解决刚才并发遍历和写 map 问题
package main

import (
	"fmt"
	"time"
	"sync"
)

var (
	i = 1
	resMap = make(map[int]int, 10)
	// 声明一个全局互斥锁
	lock sync.Mutex
)

func add() {
    i++

	lock.Lock()     // 上锁
	
	resMap[i] = i   // fatal error: concurrent map iteration and map write
	fmt.Println("add",resMap)

	lock.Unlock()     // 释放锁
}

func main() {
	for i:= 1; i < 100; i++ {
		go add()
	}	
	time.Sleep(time.Second * 5)
}

image.png

使用互斥锁可以解决同步安全问题,但是 Go 官方建议高水平的同步方式应该是用 channel

image.png

  • 使用管道 channel 来解决

4. 什么是 channel

channel 的本质是一个队列,遵循队列的先进先出原则,多个协程 goroutine 访问时,不需要加锁,它本身就是线程安全的。 掘金-第 10 页.drawio (4).png

4.1 声明 channel

channel引用类型,必须初始化分配内存空间才能使用,并且channel是有类型区分的,比如 string 类型的 channel 只能写入字符串

  • 声明举例
var 变量名称 chan 数据类型

例子:
var intChannel chan int
intChannel = make(chan int, 3)

var mapChannel chan map[int]string

4.2 channel 读写示例

  • 读写 channel 中的元素使用 <- 标识符
  • channel 中数据放满后,就不能再加入数据了
  • 在没有使用 goroutine 的前提下,如果channel数据取完了,再取的话就会报错

4.2.1 读写 int 类型

package main

import (
	"fmt"
)

func main() {
	var intChannel chan int
	intChannel = make(chan int, 3)

	// 向管道中写入数据
	intChannel <- 10
	intChannel <- 20
	intChannel <- 10
	
	// 读取管道中的数据
	res1 := <- intChannel
	res2 := <- intChannel
	res3 := <- intChannel

	// res1=10, res1=20, res1=10 
	fmt.Printf("res1=%v, res1=%v, res1=%v \n", res1, res2, res3)
}

4.2.2 读写任意数据类型

package main

import (
	"fmt"
)

type Dog struct {
    name string
}

func main() {

    var anyData chan interface{}
    anyData = make(chan interface{}, 10)

    dog := Dog{ name : "旺财", }
    anyData <- dog

    anyData <- 10
    anyData <- "ABCD"
	
    cityMap := map[string]string{
            "a" : "北京",
            "b" : "上海",
            "c" : "西安",
    } 
    anyData <- cityMap

    resDog := <-anyData
    intData := <-anyData
    strData := <-anyData
    mapData := <-anyData

    // resDog={旺财}, intData=10, strData=ABCD, mapData=map[a:北京 b:上海 c:西安] 
    fmt.Printf("resDog=%v, intData=%v, strData=%v, mapData=%v \n", resDog, intData, strData, mapData)
}

注意: 读取管道中存储的任意类型数据,如果不确定数据类型时,最好做类型断言判断

dog := resDog.(Dog)

5. 管道的遍历和关闭

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

  • channel 只支持 for-range 的方式遍历

    • 在遍历时,如果 channel 没有关闭,则会出现 dead lock 的错误
    • 在遍历时,如果channel 已经关闭,则会正常遍历数据,遍历完成后就会退出遍历
  • 内置函数 close 只能作用在 双向仅写 的管道上。

package main

import "fmt"

func main() {

	intChan := make(chan int, 10)
	for i:= 1; i < 10 ; i++ {
		intChan <- i * 2
	}

	// 关闭管道后进行遍历
	close(intChan)

	// 这里与遍历数组有所不同
	for v := range intChan {
		fmt.Println("v=", v)
	}
}

5.1 channel使用细节

  • 补充说明

6. goroutine 和 channel 使用

6.1 开启一个协程向管道中写入数据,再开启另外一个协程读取管道中的数据,等两个协程完成后主线程退出

实现思路:

掘金-第 10 页.drawio (1).png

package main

import (
	"fmt"
)

func main() {
	// 数据管道
	dataChan := make(chan int, 200)
	// 标识管道
	flagChan := make(chan bool, 1)

	// 开启写入和读取数据协程
	go writeData(dataChan)
	go readData(dataChan, flagChan)

	// 轮询标识管道
	for {
		v,ok := <-flagChan
		fmt.Println("main poll ", v, ok)
		if !ok {
			break
		}
	}
}

func writeData(dataChan chan int) {
	for i:= 1; i < 200; i++ {
		dataChan <- i
		fmt.Println("write data ", i)
	}
	close(dataChan)
}

func readData(dataChan chan int, flagChan chan bool) {
	for {
		v,ok := <-dataChan
		if !ok {
			break
		}
		fmt.Printf("read data , v=%v, ok=%v \n", v, ok)
	}

	// 读取完成,写入完成标识
	flagChan <- true
	close(flagChan)
}
  • 如果注释掉 go readData(dataChan, flagChan) 程序代码会怎样?
    如果只向管道中写数据,没有读取数据,就会出现阻塞报错 fatal error: all goroutines are asleep - deadlock!;因为管道初始化容量已经确定好了,当写入数据达到知道容量后就不能再写入了。

6.2 channel 使用细节

  • 可以单独声明channel,默认情况下 channel 是双向的
var writeChan chan<- int  // 仅支持写
var readChan <-chan int   // 仅支持读

案例:利用协程分别开启一个只读、只写的管道,当这两个协程执行完成后再结束主线程

package main

import (
	"fmt"
)

func main() {
	dataChan := make(chan int, 10)
	// 用于标识只读、只写管道执行完成
	exitChan := make(chan bool, 2)

	go onlyWrite(dataChan, exitChan)
	go onlyRead(dataChan, exitChan)

	var exitCount = 0
	for _ = range exitChan {
		exitCount ++
		// 只读、只写管道已执行完成
		if (exitCount == 2) {
			break
		}
	}
	fmt.Println("应用程序执行完成 ...")
}

func onlyWrite(dataChan chan<- int, exitChan chan bool) {
	for i:=1; i<10; i++ {
		dataChan <- i
	}
	close(dataChan)
	// dataChan 为只写管道,如果读该管道会报错:receive from send-only channel
	// for v := range dataChan {
	// 	fmt.Println(v)
	// }
	exitChan <- true
}

// 只读管道不需要关闭
func onlyRead(dataChan <-chan int, exitChan chan bool) {
	for {
		v, ok := <-dataChan
		if !ok {
			break
		}
		fmt.Printf("only read data , v=%v, ok=%v \n", v, ok)
	}
	exitChan <- true
}
  • 可使用 select 解决从管道读取数据阻塞问题

比如对管道进行遍历,要对管道进行关闭操作,否则会阻塞而导致 deadlock;问题是实际开发中,我们可能并不能确定什么时候对管道进行关闭操作。此时可以用 select 解决该问题!

package main

import (
	"fmt"
)

func main() {

	intChan := make(chan int, 5)
	for i:=0; i<5; i++ {
		intChan <- i
	}

	for {

		select {
		case v := <-intChan:
				fmt.Println("int chan, v= ",v )
		default:
				fmt.Println(" 取不到元素 ... ")	
				return
		}

	}	
}

注意: select 必须和 case 一起使用

  • goroutine 中使用 recover,可以解决协程中出现异常,导致整个程序崩溃问题
    在主程序中开启一个协程,如果协程出现了 panic ,而且我们没有捕获处理这个 panic,就会造成整个程序崩溃。这时可以在协程中使用 recover 捕获异常,保证即使协程发生了问题,但主线程仍然可以继续执行
package main

import (
	"fmt"
	"time"
)

func main() {
	go sayHello()
	go testError()

	time.Sleep(time.Second)

	for i:=0; i<5; i++ {
		fmt.Println("main i=",i)
	}
}

func sayHello() {
	for i:=0; i<5; i++ {
		fmt.Println("sayHello , i=", i)
	}
}

func testError(){
	defer func() {
		if err := recover(); err != nil {
			fmt.Println("testError 发生异常", err)
		}
	}()

	var dataMap map[int]string
	dataMap[0] = "golang"     // error
}

image.png