Go 协程

Go 协程

1.进程和线程

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

  • 线程是进程的一个执行实例,是程序执行的最小单位,是比进程更小的能单独执行的基本单位。

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

  • 一个程序至少有一个进程,一个进程至少有一个线程。

并发和并行:

(1)并发:多线程程序在单核上运行

  • 多个任务在一个cpu
  • 从微观角度上看,在一个时间点,实际上只有一个任务在执行

(2)并行:多线程程序在多核上运行

  • 多个任务作用在多个cpu
  • 从微观角度上看,在一个时间点,有多个任务在执行,这样来看,并行速度更快

2.Go协程和Go主线程

在一个Go线程上,可以有多个协程,可以把协程理解为轻量级的线程(编译器优化)。

Go协程特点:

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

【案例】

func test() {
	for i := 1; i <= 10; i++ {
		fmt.Println("test:", i)
		time.Sleep(time.Second)
	}
}

func main() {
	go test() //开启一个协程
	for i := 1; i <= 10; i++ {
		fmt.Println("main:", i)
		time.Sleep(time.Second)
	}
}

这里main是一个主线程,go test()则是开启了一个协程,下面是输出结果

test: 1
main: 1
main: 2
test: 2
main: 3
test: 3
test: 4
main: 4
main: 5
test: 5
test: 6
main: 6
main: 7
test: 7
test: 8
main: 8
main: 9
test: 9
test: 10
main: 10

可见主线程和协程同时执行。

把test()中的sleep时间乘2,再进行测试,结果如下:

main: 1
test: 1
main: 2
test: 2
main: 3
main: 4
test: 3
main: 5
main: 6
test: 4
main: 7
main: 8
test: 5
main: 9
main: 10
test: 6

很明显,在主线程执行结束后,协程也停止了运行。

总结:

协程就是线程的一部分,一个线程中可以创建多个协程。每个线程都有一个处理器,负责调度协程的执行。自然,当线程结束时,该线程内的所有协程也会被销毁。

MGP模式

M:主线程(Machine) G:协程(Goroutine) P:处理器,上下文环境 (Processor)


M下的P负责调度所有G,此时只有一个G正在运行,其他处于就绪态,等待P调度。

运行状态1:

  • 当前程序有三个M,如果三个M都在一个CPU运行,就是并发,如果在不同的CPU运行,就是并行。
  • M1 M2 M3都在执行一个G,协程队列(runqueue)有其他G等待。
  • Go协程是轻量级的线程,是逻辑态的,可以容易的起上万个协程。

运行状态2:

在这里插入图片描述

  • 图1:M0正在执行G协程,其他协程等待,G线程阻塞(如读取文件、访问数据库)。
  • 图2:这时,就会创建M1线程(或者从线程池取出),并将等待的三个协程放到M1下执行。
  • 当G不再阻塞,M0会被放到空闲的主线程等待唤醒。

设置Golang运行的cpu数

为了充分利用多cpu的优势,可以设置运行cpu的个数

func main() {
	num := runtime.NumCPU()
	runtime.GOMAXPROCS(num)
	fmt.Println(num) //8
}

3.channel

3.1 问题

【案例】计算1-200各个数的阶乘

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

func test(n int) {
	ans := 1
	for i := 1; i <= n; i++ {
		ans *= i
	}
	myMap[n] = ans
}

func main() {
	for i := 1; i <= 200; i++ {
		go test(i)
	}
	//time.Sleep(time.Second*10)
	fmt.Println(myMap)
}
//fatal error: concurrent map writes

问题:

  • 主线程结束,但协程没有全部结束,使用time.Sleep()等待
  • 不能同时向同一个内存空间(map)写,报错concurrent map writes

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

var (
	myMap = make(map[int]int, 10)
	lock  sync.Mutex
)

func test(n int) {
	ans := 1
	for i := 1; i <= n; i++ {
		ans *= i
	}
	lock.Lock()
	myMap[n] = ans
	lock.Unlock()
}

func main() {
	for i := 1; i <= 200; i++ {
		go test(i)
	}
	time.Sleep(time.Second*10)
	fmt.Println(myMap)
}

问题:

  • 主线程等待所有协程完成的时间很难确定,这里设置了十秒,可能浪费了时间,也可能仍有协程处于工作状态。
  • 通过全局变量锁实现通讯,不利于多个协程对全局变量的读写。
3.2 channel

(1)简介

  • 本质上是一个队列,数据先进先出
  • 本身线程安全,不需要加锁
  • channel是有类型的,如string类型的channel只能存放string类型的数据

(2)定义与使用

var intChan chan int	//存放int数据的channel
var mapChan chan map[int]string	//存放map[int]string数据的channel
...
  • channel是引用类型
  • channel必须初始化,即make后才能使用
  • channel有类型,只能写入定义类型的数据

测试案例:

func main() {
	//创建channel
	intChan := make(chan int, 3)
	fmt.Printf("值:%v,地址:%p\n", intChan, &intChan)

	//存值
	intChan <- 10
	intChan <- 20
	a := 100
	intChan <- a
	//intChan <- 30 存值不能超过容量
	fmt.Printf("值:%v,长度:%v,容量:%v\n", intChan, len(intChan), cap(intChan))

	//取值
	num1 := <-intChan
	fmt.Println("num1=", num1)
	fmt.Printf("值:%v,长度:%v,容量:%v\n", intChan, len(intChan), cap(intChan))

	num2 := <-intChan
	num3 := <-intChan
	//num4 := <-intChan 数据取完继续取值会报错
	fmt.Println(num2, num3)
}

/*
值:0xc00010e080,地址:0xc000006028
值:0xc00010e080,长度:3,容量:3
num1= 10
值:0xc00010e080,长度:2,容量:3
20 100
*/

allChan用法:

allChan可以存放任意类型变量

func main() {
	allChan := make(chan interface{}, 10)
	allChan <- 10
	allChan <- 10.1
	allChan <- "abc"
	p := Person{
		name: "zs",
	}
	allChan <- p
	v1 := <-allChan
	v2 := <-allChan
	v3 := <-allChan
	v4 := <-allChan
	fmt.Println(v1, v2, v3, v4)
}

但是allChan不能调用对象的属性

allChan := make(chan interface{}, 10)
p := Person{
	name: "zs",
}
allChan <- p
v1 := <-allChan
fmt.Println(v1.name)//这里报错

(3)channel遍历与关闭

  • channel支持for-range遍历
  • 在遍历时,如果channel没有关闭,则出现deadlock错误;已经关闭,则正常遍历
func main() {
	intChan := make(chan int, 100)
	for i := 0; i < 100; i++ {
		intChan <- i
	}

    //fatal error: all goroutines are asleep - deadlock!
	//close(intChan)

	for v := range intChan {
		fmt.Println(v)
	}
}

【案例1】

  • 声明一个管道intChan

  • 开启一个writeData协程,向intChan写入数据;开启一个readData协程,从intChan读取数据;

func writeData(intChan chan int) {
	for i := 1; i <= 50; i++ {
		intChan <- i
		fmt.Println("write", i)
		//time.Sleep(time.Second)
	}
	close(intChan)
}

func readData(intChan chan int, exitChan chan bool) {
	for {
		v, ok := <-intChan
		if !ok {
			break
		}
		fmt.Println("read", v)
		//time.Sleep(time.Second)
	}
	exitChan <- true
	close(exitChan)
}

func main() {
	intChan := make(chan int, 50)
	exitChan := make(chan bool, 1)
	go writeData(intChan)
	go readData(intChan, exitChan)

	for {
		_, ok := <-exitChan
		if !ok {
			break
		}
	}
}

【案例2】

​ 阻塞的简单案例,阻塞的定义和解决方法在后面的 3.3(2)阻塞和select 细说。

// 修改案例1中的代码 
...
func main() {
	intChan := make(chan int, 10)//50改为10
	exitChan := make(chan bool, 1)
	go writeData(intChan)
	//go readData(intChan, exitChan)

	for {
		_, ok := <-exitChan
		if !ok {
			break
		}
	}
}

只写不读就可能出现阻塞,该案例中管道容量为10,存够十条数据后继续存就会报错。

【案例3】

统计1-8000中,哪些数字是素数?

传统的方法是直接循环,这里使用4个协程,缩短任务时间。

//存入数据
func putNum(intChan chan int) {
	for i := 1; i <= 8000; i++ {
		intChan <- i
	}
	close(intChan)
}

//取出并判断
func primeNum(intChan chan int, primeChan chan int, exitChan chan bool) {
	var flag bool
	for {
		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
		}
	}
	exitChan <- true
}

func main() {
	intChan := make(chan int, 1000)
	primeChan := make(chan int, 2000)
	exitChan := make(chan bool, 4)

	go putNum(intChan)
	for i := 0; i < 4; i++ {
		go primeNum(intChan, primeChan, exitChan)
	}

	//判断四个协程都已经结束
	go func() {
		for i := 0; i < 4; i++ {
			<-exitChan
		}
		close(primeChan)
	}()

	for {
		res, ok := <-primeChan
		if !ok {
			break
		}
		fmt.Println(res)
	}
}
3.3使用细节和注意事项
(1)只读或只写

channel可以声明为只读或者只写

func main() {
	//只写
	chan1 := make(chan<- int,3)
	chan1 <- 1

	//只读
	var chan2 <-chan int
	num2 := <-chan2
}

【案例】

func send(ch chan<- int, exitchan chan struct{}) {
	for i := 0; i < 10; i++ {
		ch <- i
	}
	close(ch)
	var a struct{}
	exitchan <- a
}

func recv(ch <-chan int, exitchan chan struct{}) {
	for {
		v, ok := <-ch
		if !ok {
			break
		}
		fmt.Println(v)
	}
	var a struct{}
	exitchan <- a
}

func main() {
	ch := make(chan int, 10)
	exitchan := make(chan struct{}, 2)
	go send(ch, exitchan)
	go recv(ch, exitchan)

	var total = 0
	for _ = range exitchan {
		total++
		if total == 2 {
			break
		}
	}
}
(2)阻塞和select

a. 什么时候会发生阻塞?

  • 向一个值为nil的管道写或读数据

  • 无缓冲区时单独的写或读数据

  • 缓冲区为空时进行读数据

  • 缓冲区满时进行写数据

b. 解决阻塞的方法select-case

func main() {
	intChan := make(chan int, 10)
	for i := 0; i < 10; i++ {
		intChan <- i
	}
	stringChan := make(chan string, 5)
	for i := 0; i < 5; i++ {
		stringChan <- "hello" + fmt.Sprintf("%d", i)
	}

	//在实际问题中,我们不知道什么时候关闭管道,可以用select解决
	for {
		select {
		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.Println("全部取到!")
			time.Sleep(time.Second)
			return
		}
	}
}

如上面的代码,select对所有管道进行监控,当该管道未发生阻塞时则执行对应的代码。

此时,如果所有管道阻塞(如上述代码,所有管道数据都被取空),这时就需要用到default。当所有管道阻塞时,就会执行default中的代码。

但是,在实际生产中,当所有管道阻塞时,我们并不希望程序立刻结束,而是希望等待一段时间,这是我们就可以用到time.After。看下面的代码:

func putChan(intChan chan int) {
	time.Sleep(time.Second * 3)
	fmt.Println("intChan存入数据")
	intChan <- 1
}

func main() {
	intChan := make(chan int, 10)
	go putChan(intChan)
	for {
		select {
		case <-intChan:
			fmt.Println("intChan取出数据")
		default:
			fmt.Println("程序结束")
			return
		}
	}
}
/*
程序结束
*/

这里要实现从putChan()中存入数据,再从主线程中取出数据。但是putChan耽搁了3秒,导致主线程中的所有case阻塞,执行了default,结束了程序。

这里就可以使用time.After,等待一段时间,确保功能的实现。

func putChan(intChan chan int) {
	time.Sleep(time.Second * 3)
	fmt.Println("intChan存入数据")
	intChan <- 1
}

func main() {
	intChan := make(chan int, 10)
	go putChan(intChan)
	for {
		select {
		case <-intChan:
			fmt.Println("intChan取出数据")
		case <-time.After(time.Second * 5):
			fmt.Println("timeout")
			return
		}
	}
}
/*
intChan存入数据
intChan取出数据
timeout
*/

c. 阻塞的本质

深入详解Go的channel底层实现原理【图解】 - 云+社区 - 腾讯云 (tencent.com)

channel的整体结构(源码)

type hchan struct {
    qcount   uint           // total data in the queue
    dataqsiz uint           // size of the circular queue
    buf      unsafe.Pointer // points to an array of dataqsiz elements
    elemsize uint16
    closed   uint32
    elemtype *_type // element type
    sendx    uint   // send index
    recvx    uint   // receive index
    recvq    waitq  // list of recv waiters
    sendq    waitq  // list of send waiters

    // lock protects all fields in hchan, as well as several
    // fields in sudogs blocked on this channel.
    //
    // Do not change another G's status while holding this lock
    // (in particular, do not ready a G), as this can deadlock
    // with stack shrinking.
    lock mutex
}

这里,主要说的是以下两个部分:

  • buf:缓存区,用来存放管道存放的内容,是个循环链表。如intChan := make(chan int, 10),那么缓存区的大小就是10。

  • sendq和recvq: 分别是接收(<-channel)或者发送(channel <- xxx)的goroutine抽象出来的结构体(sudog)的队列。是个双向链表。主要用来存放遇到阻塞时,不能继续运行的协程。

接下来,用下面的案例,分析管道的阻塞与处理的方法。

func main() {
	intChan := make(chan int, 3)

    //管道的容量为3,同时向管道存入、取出数据
    //可能出现:缓冲区为空时进行读数据、缓冲区满时进行写数据 两种情况
	for {
		select {
		case <-intChan:
			fmt.Println("取出数据")
			time.Sleep(time.Second)
		case intChan <- 1:
			fmt.Println("存入数据")
			time.Sleep(time.Second)
		default:
			return
		}
	}
}

情况1:缓冲区满时进行写数据

intChan <- 1
intChan <- 1
intChan <- 1
intChan <- 1

G0表示向buf内存储数据的线程,当G0执行三次后,buf已满,如下图。

G0想要继续向buf中存入数据,就会出现阻塞。这时,G0就会等待,让就绪中的G1运行。同时G0会被抽象成含有G0指针和send元素的sudog结构体保存到sendq中等待被唤醒。

这时,如果G1执行了<-intChan,buf就有了存储的空间。 channel会将等待队列中G0推出,将G0当时send的数据推到缓存中,然后G0唤醒,重新进入就绪状态。

情况2:缓冲区为空时进行读数据

a := <- intChan

buf为空,G0想要从buf中取出数据,会出现阻塞。这时,同情况1一样,G0就会等待,让就绪的G1运行。同时G0会被抽象成含有G0指针和recv元素的sudog结构体保存到recvq中等待被唤醒。

G1运行了下面代码。

intChan <- 1

这时,与之前不同的情况出现了。G0不会像之前一样直接被推出唤醒。

而是从G1中取出数据,直接copy到G0的栈中(如下图)。使用这种方法, 在唤醒过程中,G0无需再获得channel的锁,然后从缓存中取数据。减少了内存的copy,提高了效率。

之后,G0才会被正常唤醒,重新排队。

(3)panic和recover

使用recover,解决协程中出现panic,导致程序崩溃的问题

panic:运行时恐慌,是一种只会在程序运行时才回抛出来的异常。通俗的来说,就是出现了一个异常。在panic被抛出之后,如果没有在程序里添加任何保护措施的话,程序就会在打印出panic的详情,终止运行。

recover: 可以让进入恐慌的流程中的 goroutine 恢复过来。通俗的来说,就是让出现异常的 goroutine 保持运行,可以防止因为一个协程的错误,导致整条主线程崩溃。

【案例】下面程序中主线程和协程test1负责打印,协程test2负责为数组赋值,但是很明显test2会出现越界问题。

func test1() {
	for i := 0; i < 5; i++ {
		fmt.Println("Hello", i)
		time.Sleep(time.Second)
	}
}

func test2() {
	var arr [3]int
	for i := 0; i < 5; i++ {
        //数组容量为3,当i=3时越界
		arr[i] = i
		fmt.Printf("arr[%v]=%v\n", i, arr[i])
		time.Sleep(time.Second)
	}
}

func main() {
	go test1()
	go test2()

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

运行结果:

...
arr[2]=2
Hello 3
main() 3
panic: runtime error: index out of range [3] with length 3

可见,因为test2一个协程的panic,整个线程都崩溃了,这时就需要recover来处理这个问题。

func test1() {
	for i := 0; i < 5; i++ {
		fmt.Println("Hello", i)
		time.Sleep(time.Second)
	}
}

func test2() {
    //defer + recover
	defer func() {
        //捕获test2出现的异常
		if err := recover(); err != nil {
			fmt.Println("test2() err!", err)
		}
	}()
	var arr [3]int
	for i := 0; i < 5; i++ {
		arr[i] = i
		fmt.Printf("arr[%v]=%v\n", i, arr[i])
		time.Sleep(time.Second)
	}
}

func main() {
	go test1()
	go test2()

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

运行结果:

...
arr[2]=2
Hello 3
main() 3
test2() err! runtime error: index out of range [3] with length 3
main() 4
Hello 4
...

test2打印信息后提前停止,test1和主线程正常运行直到结束。

参考资料:【尚硅谷】Golang入门到实战教程丨一套精通GO语言

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值