go 并发编程

go 并发编程

1.1 并发模型

任何语言的并行,到操作系统层面,都是内核线程的并行。同一个进程内的多个线程共享系统资源,进程的创建、销毁、切换比线程大很多。从进程到线程再到协程, 其实是一个不断共享, 不断减少切换成本的过程。
python模型

c++进程,java进程 goland并发进程

协程 线程
创建数量 轻松创建上百万个协程而不会导致系统资源衰竭
内存占用 初始分配4k堆栈,随着程序的执行自动增长删除
切换成本 协程切换只需保存三个寄存器,耗时约200纳秒
调度方式 非抢占式,由Go runtime主动交出控制权(对于开发者而言是抢占式)
创建销毁 goroutine因为是由Go runtime负责管理的,创建和销毁的消耗非常小,是用户级的

查看逻辑cpu核数

fmt.Println(runtime.NumCPU())

如果是8核,那么cpu最多跑到800%,如果是2核,最多200%

2.1 MPG并发模型

  • M(Machine)对应一个内核线程。
  • P(Processor)虚拟处理器,代表M所需的上下文环境,是处理用户级代码逻辑的处理器。P的数量由环境变量中的GOMAXPROCS决定,默认情况下就是核数。
  • G(Goroutine)本质上是轻量级的线程,
  • G0正在执行,其他G在等待。M和内核线程的对应关系是确定的。G0阻塞(如系统调用)时,P与G0、M0解绑,P被挂到其他M上,然后继续执行G队列。G0解除阻塞后,如果有空闲的P,就绑定M0并执行G0;否则G0进入全局可运行队列(runqueue)。P会周期性扫描全局runqueue,使上面的G得到执行;如果全局runqueue为空,就从其他P的等待队列里偷一半G过来。

下图对应上面最后一个

如上图,所以即使G0原先是排行第一个执行的,但是由于G0中间的系统代码调用较慢,其他G等不了G0执行完,跟着P到M1了,而G0执行完后没有p,后续非系统代码无法运行,非系统代码必须由P虚拟处理器来执行,所以G0会进入队列,等待M下空闲的P来认领,所以他们的执行顺序是不固定的,并且内核线程M和用户G的线程比例是m:n

内核线程和M之间是虚线,不可打断的

3.1 Goroutine的使用

线程都是并行的,启动协程的两种常见方式:

func Add(a, b int) int {
    fmt.Println("Add")
    return a + b
}
go Add(2, 4)
go func(a, b int) int {
	fmt.Println("add")
	return a + b
}(2, 4)

优雅地等子协程结束:

wg := sync.WaitGroup{}
wg.Add(10) //加10
for i := 0; i < 10; i++ {
	go func(a, b int) { //开N个子协程
		defer wg.Done() //减1
		//do something
	}(i, i+1)
}
wg.Wait() //等待减为0
如下为例
  • 父协程结束后,子协程并不会结束。
  • main协程结束后,所有协程都会结束。
  • 子协程退出后,孙协程如果还没跑完不会退出,会根据main来跑
func main() {
	go Ad() //Ad里面有sleep 100毫秒,如果不加下面的sleep 150毫秒,main就直接退出了,而协程还没跑完也跟着退出了
	time.Sleep(150 * time.Millisecond) //如果去掉这个,就不会打印over
}

func Ad(){
	time.Sleep(100 * time.Millisecond)
	fmt.Println("over")
}

使用wg

var wt = sync.WaitGroup{}
func main() {
	wt.Add(2) //次数加2
	go Ad() //运行两个协程
	go Ad()
	wt.Wait() //等待减到0,如果没有wt.Done,会报错deadlock,而不会因为没有wt.Done而一直傻傻的等
}

func Ad(){
	time.Sleep(100 * time.Millisecond)
	fmt.Println("over")
	wt.Done() //每次减1,刚好两次减到0,如果没有减到0也会报错deadlock
}
例子2

类似代码但是打印结果是不同的

func main() {
	//gor1()
	gor2()
}

func gor1() {
	num := []int{1,2,3,4}
	for _,ele := range num {
		go func() { //虽然是for循环的列表,开了4个协程,当子协程执行print的时候,ele已经变成4了
			fmt.Println(ele) //打印的可能是 4 4 4 4
		}()
	}
	time.Sleep(100 * time.Millisecond)
}
func gor2(){
	num := []int{1,2,3,4}
	for _,ele := range num {
		go func(v int) {
			fmt.Println(v) //打印的可能是 4 1 2 3
		}(ele) //每次拿到的参数都是不同的
	}
	time.Sleep(100 * time.Millisecond)
}

4.1 并发中的单例模式

package main

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

var oc sync.Once
var a int = 5

func main() {
	go func() {
		oc.Do(func(){ //只执行一次
			a ++
		})
	}()
	go func() {
		oc.Do(func() { //只执行一次
			a ++
		})
	}()
	time.Sleep(time.Second)
	fmt.Println(a) // 6
}

5.1 捕获子协程的panic

何时会发生panic:

  • 运行时错误会导致panic,比如数组越界、除0。
  • 程序主动调用panic(error)。
    panic会执行什么:
    逆序执行当前goroutine的defer链(recover从这里介入)。
    打印错误信息和调用堆栈。
    调用exit(2)结束整个进程。

关于defer

  • defer在函数退出前被调用,注意不是在代码的return语句之前执行,因为return语句不是原子操作。
  • 如果发生panic,则之后注册的defer不会执行。
  • defer服从先进后出原则,即一个函数里如果注册了多个defer,则按注册的逆序执行。
  • defer后面可以跟一个匿名函数。
func goo(x int) int {
	fmt.Printf("x=%d\n", x)
	return x
}

func foo1(a, b int, p bool) int {
	c := a*3 + 9
	//defer是先进后出,即逆序执行
	defer fmt.Println("first defer")
	d := c + 5
	defer fmt.Println("second defer")
	e := d / b //如果发生panic,则后面的defer不会执行
	if p {
		panic(errors.New("my error")) //主动panic
	}
	defer fmt.Println("third defer")
	return goo(e) //defer是在函数临退出前执行,不是在代码的return语句之前执行,因为return语句不是原子操作
}
func main() {
	foo1(1,2,false)
}


结果:
x=8
third defer
second defer
first defer


例子2:
func main() {
	go aaa()
	fmt.Println("hello")

}
func aaa() {
	defer fmt.Println(111)
	defer fmt.Println(222)
	fmt.Println("ok")
	defer fmt.Println(333)
	defer fmt.Println(444)
	//panic(errors.New("nonono"))
	defer fmt.Println(555)
        fmt.Println("yes")
}

//如果没有panic,正常会打印 ok 5 4 3 2 1 yes
//如果发生panic,则之后注册的defer不会执行  会打印 ok  4 3 2 1 nonono,panic后面的不会打印,也不会注册

recover会阻断panic的执行。

func soo(a, b int) {
	defer func() {
		//recover必须在defer中才能生效
		if err := recover(); err != nil {			
            fmt.Printf("soo函数中发生了panic:%s\n", err)
		}
	}()
	panic(errors.New("my error"))
}


例子2:
func main() {
	go aaa()
	fmt.Println("hello")

}
func aaa() {
	defer fmt.Println(111)
	defer fmt.Println(222)
	fmt.Println("ok")
	defer func() {
		recover() //从paninc发生的地方结束本协程,但是没有结束本进程
	}()
	defer fmt.Println(333)
	defer fmt.Println(444)
	panic(errors.New("nonono"))
	defer fmt.Println(555)
	fmt.Println("yes")
}
//会打印 ok hello或者ok hello
//如果把go aaa() 改成 aaa(),那就会打印 ok 4 3 2 1 hello
顺序是先执行函数内部fmt这种代码,再执行paninc,painc后执行defer链,被recover截获,

6.1 Channel的同步与异步

很多语言通过共享内存来实现线程间的通信,通过加锁来访问共享数据,如数组、map或结构体。go语言也实现了这种并发模型。

CSP(communicating sequential processes)讲究的是“以通信的方式来共享内存”,在go语言里channel是这种模式的具体实现。

异步管道

asynChann := make(chan int, 8)

channel底层维护一个环形队列(先进先出),make初始化时指定队列的长度。队列满时,写阻塞;队列空时,读阻塞。sendx指向下一次写入的位置, recvx指向下一次读取的位置。 recvq维护因读管道而被阻塞的协程,sendq维护因写管道而被阻塞的协程。

同步管道可以认为队列容量为0,当读协程和写协程同时就绪时它们才会彼此帮对方解除阻塞。

syncChann := make(chan int)

channel仅作为协程间同步的工具,不需要传递具体的数据,管道类型可以用struct{}。空结构体变量的内存占用为0,因此struct{}类型的管道比bool类型的管道还要省内存。

sc := make(chan struct{})
sc <- struct{}{}  //往管道写入一个数据
v := <- sc   //从管道取出一个数据并赋值给v

关于channel的死锁与阻塞

  • Channel满了,就阻塞写;Channel空了,就阻塞读。
  • 阻塞之后会交出cpu,去执行其他协程,希望其他协程能帮自己解除阻塞。
  • 如果阻塞发生在main协程里,并且没有其他子协程可以执行,那就可以确定“希望永远等不来”,自已把自己杀掉,报一个fatal error:deadlock出来。
  • 如果阻塞发生在子协程里,就不会发生死锁,因为至少main协程是一个值得等待的“希望”,会一直等(阻塞)下去
package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan struct{}, 1)
	ch <- struct{}{} //有1个缓冲可以用,无需阻塞,可以立即执行
	go func() {      //子协程1
		time.Sleep(5 * time.Second) //sleep一个很长的时间
		<-ch                        //如果把本行代码注释掉,main协程5秒钟后会报fatal error
		fmt.Println("sub routine 1 over")
	}()

	ch <- struct{}{} //由于子协程1已经启动,寄希望于子协程1帮自己解除阻塞,所以会一直等子协程1执行结束。如果子协程1执行结束后没帮自己解除阻塞,则希望完全破灭,报出deadlock
	fmt.Println("send to channel in main routine")
	go func() { //子协程2
		time.Sleep(2 * time.Second)
		ch <- struct{}{} //channel已满,子协程2会一直阻塞在这一行
		fmt.Println("sub routine 2 over")
	}()
	ch <- struct{}{} //这时候缓冲里已经有一个了,是满的,它寄希望于协程2帮自己接触阻塞,但是协程2也是往里面写的,一直处于阻塞状态,无法帮自己解除阻塞,则希望破灭,报出deadlock
	time.Sleep(3 * time.Second)
	fmt.Println("main routine exit")
}

关闭channel

  • 只有当管道关闭时,才能通过range遍历管道里的数据,否则会发生fatal error。
  • 管道关闭后读操作会立即返回,如果缓冲已空会返回“0值”。
  • ele, ok := <-ch ok==true代表ele是管道里的真实数据。
  • 向已关闭的管道里send数据会发生panic。
  • 不能重复关闭管道,不能关闭值为nil的管道,否则都会panic。

关闭管道一般就2种情况,一是想range遍历管道,二是关闭后,让go的协程不再阻塞,程序得以继续运行

var cloch = make(chan int, 1)
var cloch2 = make(chan int, 1)

func traverseChannel() {
	for ele := range cloch {
		fmt.Printf("receive %d\n", ele)
	}
	fmt.Println()
}

func traverseChannel2() {
	for {
		if ele, ok := <-cloch2; ok { //ok==true代表管道还没有close
			fmt.Printf("receive %d\n", ele)
		} else { //管道关闭后,读操作会立即返回“0值”
			fmt.Printf("channel have been closed, receive %d\n", ele)
			break
		}
	}
}
func main() {
	cloch <- 1
	close(cloch)
	traverseChannel() //如果不close就直接通过range遍历管道,会发生fatal error: all goroutines are asleep - deadlock!
	fmt.Println("==================")
	go traverseChannel2() //因为管道没有关闭,所以这个协程会一直存在,直到管道被关闭
	cloch2 <- 1
	fmt.Println("此时管道2已经写入1,但是还未关闭") //此时管道2已经写入1,但是还未关闭
	time.Sleep(time.Second)
	close(cloch2)  
	fmt.Println("此时管道已经关闭") //此时管道已经关闭
	time.Sleep(10 * time.Millisecond)
}

channel在并发编程中有多种玩法,经常用channel来实现协程间的同步


func upstream(ch chan struct{}) {
	time.Sleep(15 * time.Millisecond)
	fmt.Println("一个上游协程执行结束")
	ch <- struct{}{}
}

func downstream(ch chan struct{}) {
	<-ch
	fmt.Println("下游协程开始执行")
}

func main() {
	upstreamNum := 4   //上游协程的数量
	downstreamNum := 5 //下游协程的数量

	upstreamCh := make(chan struct{}, upstreamNum)
	downstreamCh := make(chan struct{}, downstreamNum)

	//启动上游协程和下游协程,实际下游协程会先阻塞
	for i := 0; i < upstreamNum; i++ {
		go upstream(upstreamCh)
	}
	for i := 0; i < downstreamNum; i++ {
		go downstream(downstreamCh)
	}

	//同步点
	for i := 0; i < upstreamNum; i++ {
		<-upstreamCh
	}

	//通过管道让下游协程开始执行
	for i := 0; i < downstreamNum; i++ {
		downstreamCh <- struct{}{}
	}

	time.Sleep(10 * time.Millisecond) //等下游协程执行结束
}


例子2:
var buffer chan string
func init() {
	buffer = make(chan string,10000)
}
func put() {
	for i := 0;i < 10;i++ {
		buffer <- "111"
	}
}

func take() {
	for i := 0;i < 20 ;i++ {
		v := <- buffer
		fmt.Println(v)
	}
}

func main() {
	go put()
	go put()
	go put()
	go put()

	go take()
	go take()

	time.Sleep(time.Second)
}

7.1 并发安全性

多协程并发修改同一块内存,产生资源竞争。go run或go build时添加-race参数检查资源竞争情况。
n++不是原子操作,并发执行时会存在脏写。n++分为3步:取出n,加1,结果赋给n。测试时需要开1000个并发协程才能观察到脏写。

func atomic.AddInt32(addr *int32, delta int32) (new int32)
func atomic.LoadInt32(addr *int32) (val int32)

把n++封装成原子操作,解除资源竞争,避免脏写。

var lock sync.RWMutex		//声明读写锁,无需初始化
lock.Lock() lock.Unlock()	//加写锁和释放写锁
lock.RLock() lock.RUnlock()	//加读锁和释放读锁

例子1:
var lock sync.RWMutex
func main() {
	go func() {
		lock.Lock()  //只要加上写锁,就会只运行这个协程里的,所以结果只会打印A lock success
		fmt.Println("A lock success")
	}()

	go func() {
		lock.RLock()	//前面加了写锁,读锁不生效
		fmt.Println("B lock success")
	}()

	go func() {
		lock.Lock()
		fmt.Println("C lock success")
	}()

	time.Sleep(time.Second)
}


例子2:
var lock sync.RWMutex
func main() {
	go func() {
		lock.RLock()  //读锁可以加很多把
		fmt.Println("A lock success")
	}()

	go func() {
		lock.RLock()	//前面加了读锁,所以结果会打印 A , B lock success
		fmt.Println("B lock success")
	}()

	go func() {
		lock.Lock() //读锁之后不能再加写锁
		fmt.Println("C lock success")
	}()

	time.Sleep(time.Second)
}

任意时刻只可以加一把写锁,且不能加读锁。没加写锁时,可以同时加多把读锁,读锁加上之后不能再加写锁。

例子1:正常的go协程是有问题的
如下例子,for循环正常应该打印1000,但是用协程去加时,有的协程对统一内存修改重复,最终结果不到1000,可能是983或者别的
var n int
func main() {
	wg := sync.WaitGroup{}
	wg.Add(1000)
	for i := 0;i < 1000;i ++ {
		go func() {
			defer wg.Done()
			n ++
		}()
	}
	wg.Wait()
	fmt.Println(n)
}


例子2:用atomic解决上述问题
var n int32
func main() {
	wg := sync.WaitGroup{}
	wg.Add(1000)
	for i := 0;i < 1000;i ++ {
		go func() {
			defer wg.Done()
			//n ++
			//把 n++改成 atomic.AddInt32(&n,1),这时候就是原子操作
			atomic.AddInt32(&n,1)
		}()
	}
	wg.Wait()
	fmt.Println(n)
}


例子3:用读写锁来解决
var n int32
var lock sync.RWMutex
func main() {
	wg := sync.WaitGroup{}
	wg.Add(1000)
	for i := 0;i < 1000;i ++ {
		go func() {
			defer wg.Done()
			//n ++
			//把 n++改成 atomic.AddInt32(&n,1),这时候就是原子操作
			//atomic.AddInt32(&n,1)
			lock.Lock()	//加锁
			n ++
			lock.Unlock() //解锁
		}()
	}
	wg.Wait()
	fmt.Println(n)
}


数组、slice、struct允许并发修改(可能会脏写),并发修改map有时会发生panic。如果需要并发修改map请使用sync.Map。

例子1 切片:
type Student struct{
	age int
	name string
}

//为了验证并发读写对切片,数组,结构体,map的影响
var (
	lst []int
	arr [5]int //数组必须指定长度
	student Student
	mp map[int]int
)

func rwShareMem1() {
	fmt.Println(len(lst))
	for i := 1;i < len(lst);i += 1 {
		lst[i] = 555
	}
}
func rwShareMem2() {
	for i := 0;i < len(lst);i += 1 {
		lst[i] = 888
	}
}

func main() {
	lst = make([]int,5)
	go rwShareMem1()
	go rwShareMem2()

	time.Sleep(time.Second)
	fmt.Println(lst)
}
//打印结果可能是 888 555 555 555 555,这是有问题的,但是还能运行成功

例子2 map:
type Student struct{
	age int
	name string
}

//为了验证并发读写对切片,数组,结构体,map的影响
var (
	lst []int
	arr [5]int //数组必须指定长度
	student Student
	mp map[int]int
)

func rwShareMem1() {
	for i := 0;i < 100;i ++ {
		mp[i]=i
	}
}
func rwShareMem2() {
	for i := 0;i < 100;i ++ {
		mp[i]=i*2
	}
}

func main() {
	go rwShareMem1()
	go rwShareMem2()

	time.Sleep(time.Second)
}
//这个map甚至可能无法正常运行,会报错concurrent map writes,因为并发的去写map




例子3:上面切片能执行,逻辑有问题,可以解决,但是map无法运行怎么处理?如下
type Student struct{
	age int
	name string
}

//为了验证并发读写对切片,数组,结构体,map的影响
var (
	lst []int
	arr [5]int //数组必须指定长度
	student Student
	mp sync.Map //改了这里,调用了sync.Map
)

func rwShareMem1() {
	for i := 0;i < 100;i ++ {
		mp.Store(i,i) //key.value形式
	}
}
func rwShareMem2() {
	for i := 0;i < 100;i ++ {
		mp.Store(i,i*2)
	}
}

func main() {
	go rwShareMem1()
	go rwShareMem2()

	time.Sleep(time.Second)
	fmt.Println(mp.Load(2)) //读取,打印4 true,代表map中有这个值
}

8.1 多路复用

操作系统级的I/O模型有:

  • 阻塞I/O
  • 非阻塞I/O
  • 信号驱动I/O
  • 异步I/O
  • 多路复用I/O
    Linux下,一切皆文件。包括普通文件、目录文件、字符设备文件(键盘、鼠标)、块设备文件(硬盘、光驱)、套接字socket等等。文件描述符(File descriptor,FD)是访问文件资源的抽象句柄,读写文件都要通过它。文件描述符就是个非负整数,每个进程默认都会打开3个文件描述符:0标准输入、1标准输出、2标准错误。由于内存限制,文件描述符是有上限的,可通过ulimit –n查看,文件描述符用完后应及时关闭。

阻塞I/O
我们发起一个read请求,syscall到系统内核调用中来,查看文件描述符是不是准备好了,但是读的文件是空的,会进入阻塞,直到文件有内容,才会进入ready状态,然后进入第4步,所以在文件在被写入内容前,是一直处于阻塞状态的

非阻塞I/O
同样我们发起一个请求,syscall到系统内核调用中来,如果文件描述符没准备好,它会立刻返回一个错误码,等一会再过来问准备好了吗,如果还没有,就等会再来问,知道文件描述符是ready状态

read和write默认是阻塞模式。

ssize_t read(int fd, void *buf, size_t count); 
ssize_t write(int fd, const void *buf, size_t nbytes);

通过系统调用fcntl可将文件描述符设置成非阻塞模式。

int flags = fcntl(fd, F_GETFL, 0); 
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

多路复用I/O

  • select系统调用可同时监听1024个文件描述符的可读或可写状态。
  • poll用链表存储文件描述符,摆脱了1024的上限。
  • 各操作系统实现了自己的I/O多路复用函数,如epoll、 evport 和kqueue等。


监听多个文件描述符,只要有一个是ready,就返回,然后遍历对就绪的进行读写

go多路复用函数以netpoll为前缀,针对不同的操作系统做了不同的封装,以达到最优的性能。在编译go语言时会根据目标平台选择特定的分支进行编译。

利用go channel的多路复用实现倒计时发射的demo
比如倒计时到底8秒,发现火箭发射前没装油,只能终止,是随时可以终止的

//倒计时
func countDown(countCh chan int,n int,finshCh chan struct{}) {
	if n <= 0 { //从n开始倒数
		return
	}
	ticker := time.NewTicker(time.Second) //创建一个周期性的定时器,每隔1秒
	for {
		countCh <- n //把n方式管道
		<-ticker.C	//等待1秒
		n--	//n减1
		if n <= 0{ //n减到0时退出
			ticker.Stop()	//停止计时器
			finshCh <- struct{}{}	//成功结束
			break //退出for循环
		}
	}
}

//中止
func abort(ch chan struct{}) {
	buffer := make([]byte,1)
	os.Stdin.Read(buffer) //阻塞IO,如果标准输入里没有数据,该行一直阻塞,
	ch <- struct{}{} //所以这行在等上面阻塞解决,上面不阻塞才会到这一行
}

func main() {
	countCh := make(chan int)
	finshCh := make(chan struct{})
	go countDown(countCh,10,finshCh)  //开启一个子协程,往countCh和finshCh放数据
	abortCh := make(chan struct{})
	go abort(abortCh) //开启一个子协程,去往abortCh放数据

LOOP: //如果去掉LOOP就会只打印一个10

	for { //循环监听.如果不用for,那么第一次打印秒数的时候,程序就退出了,9876剩余秒就不打印了
		select { //同时监听3个channel,谁先准备好就执行谁,然后进入下一次for循环
		case n := <- countCh: //正常倒计时的管道,每次打印剩余秒
			fmt.Println(n)
		case <- finshCh:	//读取finshCh管道,当倒计时n <=0时会写一个空结构体放入管道,否则会一直阻塞,不会打印finish
			fmt.Println("finish")
			break LOOP	//退出for循环,在使用for select时,单独一个break不能退出
		case <- abortCh: //读取abortCh管道,当有人标准输入,会往管道里写入一个空结构体,否则一直阻塞,不打印abort
			fmt.Println("abort")
			break LOOP
		}
	}
}

9.1 函数超时控制的4种实现

timeout实现

//调用cacel()将关闭 ctx.Done()对应的管道
ctx.cacel := context.WithCancel(context.Background())

//调用cancel()或到达超时时间都将关闭ctx.Done()对应的管道
ctx.cancel := context.WithTimeout(context.BackGround(),time.Microsecond*100)

//管道关闭后读操作将立刻返回
ctx.Done()

当我们的一些函数不想等待太长时间,让另外一个函数检查,超过那个时间就返回另外一种结果

例子1: 
const(
	workUserTime = 500*time.Millisecond
	timeOut = 100*time.Millisecond
)

//模拟一个耗时较长的任务
func longTimeWork() {
	time.Sleep(workUserTime)
	return
}

//模拟一个接口处理函数
func handle() {
	deadline := make(chan struct{},1)
	workDone := make(chan struct{},1)

	go func() { //要把超时的函数放到一个协程里
		longTimeWork()
		workDone <- struct{}{}
	}()

	go func() { //要把控制超时的函数放到一个协程里
		time.Sleep(timeOut)
		deadline <- struct{}{}
	}()
	select { //下面的case只执行最早到来的那一个
		case <- deadline:
			fmt.Println("deadline time return") //这个会优先打印,因为睡眠时间比较短,同理,通过这个也可以让一些计算时间较长的函数时,不再等待,直接返回另外一个结果
		case <- workDone:
			fmt.Println("workDone time return")
	}
}

func main() {
	handle()
}

例子2:与上面不同的是通过context.WothCancel实现关闭管道来解除阻塞,而不是往管道里写空结构体
const(
	workUserTime = 500*time.Millisecond
	timeOut = 100*time.Millisecond
)

//模拟一个耗时较长的任务
func longTimeWork() {
	time.Sleep(workUserTime)
	return
}

//模拟一个接口处理函数
func handle() {
	ctx,cancel := context.WithCancel(context.Background())
	workDone := make(chan struct{},1)

	go func() { //要把超时的函数放到一个协程里
		longTimeWork()
		workDone <- struct{}{}
	}()

	go func() { //要把控制超时的函数放到一个协程里
		time.Sleep(timeOut)
		cancel()
	}()
	select { //下面的case只执行最早到来的那一个
		case <- ctx.Done(): //这里与上面不同的是,不往管道中写入一个空结构体,而是关闭结构体实现解除阻塞
			fmt.Println("deadline time return")
		case <- workDone:
			fmt.Println("workDone time return")
	}
}

例子3:直接指定过期时间,不用再开一个协程sleep然后cancel()
const(
	workUserTime = 500*time.Millisecond
	timeOut = 100*time.Millisecond
)

//模拟一个耗时较长的任务
func longTimeWork() {
	time.Sleep(workUserTime)
	return
}

//模拟一个接口处理函数
func handle() {
	ctx,cancel := context.WithTimeout(context.Background(),timeOut)
	defer cancel() //纯粹处于良好习惯,函数退出前调用cancel()
	workDone := make(chan struct{},1)

	go func() { //要把超时的函数放到一个协程里
		longTimeWork()
		workDone <- struct{}{}
	}()
	
	select { //下面的case只执行最早到来的那一个
		case <- ctx.Done(): //这里与上面不同的是,不往管道中写入一个空结构体,而是关闭结构体实现解除阻塞
			fmt.Println("deadline time return")
		case <- workDone:
			fmt.Println("workDone time return")
	}
}

func main() {
	handle()
}

10.1 协程泄露

可以参考9.1里面的内容,我们写了一个协程,如果超过预期时间,另一个sleep时间较短的协程的结果就会推出。但是如果我们时间较短的哪个协程写的时候设置了容量为0,导致即使它sleep时间到了,时间短的哪个协程也无法解除自己的阻塞。而是等待另一个时间比较长的,甚至无法算出结果的协程。而这个函数被调用了很多次,每次都到这里卡主,那么一个进程中慢慢累积了很多协程,导致协程无法退出造成协程泄露

package main

import (
	"context"
	"fmt"
	"runtime"
	"time"
)

//模拟一个耗时较长的任务
func work() {
	time.Sleep(time.Duration(500) * time.Millisecond)
	return
}

//模拟一个接口处理函数
func handle() {
	//借助于带超时的context来实现对函数的超时控制
	ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100) //改成1000试试
	defer cancel()                                                                 //纯粹出于良好习惯,函数退出前调用cancel()
	// begin := time.Now()
	workDone := make(chan struct{}) //创建一个无缓冲管道
	go func() {                     //启动一个子协程
		work()
		workDone <- struct{}{} //work()结束后到,走到这行代码会一直阻塞,子协程无法结束,导致协程泄漏
	}()
	select { //下面的case只执行最早到来的那一个
	case <-workDone: //永远执行不到
		fmt.Println("LongTimeWork return")
	case <-ctx.Done(): //ctx.Done()是一个管道,context超时或者调用了cancel()都会关闭这个管道,然后读操作就会立即返回
		// fmt.Printf("LongTimeWork timeout %d ms\n", time.Since(begin).Milliseconds())
	}
}

func main() {
	for i := 0; i < 10; i++ {
		handle()
	}
	time.Sleep(2 * time.Second)                      //等所有work()结束
	fmt.Printf("当前协程数:%d\n", runtime.NumGoroutine()) //11,10个阻塞的子协程 加 main协程
}

在以上代码中workDone是同步管道,子协程向workDone里send数据时总是会阻塞(如果每次都超时的话),子协程因阻塞而一直不能退出,导致子协程数量不断累积。

下面讲排查协程泄漏的方法。首先在一个端口上开启http监听

import (
	"net/http"
	_ "net/http/pprof"
)
func main() {
	go func() {
		if err := http.ListenAndServe("localhost:8080", nil); err != nil {
			panic(err)
		}
	}()
}

上述代码在8080端口上开启了监听,那我们在本地把程序跑起来,然后在浏览器上访问127.0.0.1:8080/debug/pprof/goroutine?debug=1。

截图上我们看到协程数量确实多得超出预期,并且明确提示出源代码第25行导致了内存泄漏。还可以通过go tool pprof定位协程泄漏,在终端运行

go tool pprof http://0.0.0.0:8080/debug/pprof/goroutine


注意上面截图中显示生成了一个文件/Users/zhangchaoyang/pprof/pprof.goroutine.001.pb.gz,后面我们会用到它。从截图可以看到main.handle.func1创建的协程最多,通过list命令查看这个函数里到底是哪行代码导致的协程泄漏

也可能通过traces打印调用堆栈,下面截图显示main.handle.func1由于调用了chansend1而阻塞了1132个协程。

其实终端执行
go tool pprof --http=:8081 /Users/zhangchaoyang/pprof/pprof.goroutine.001.pb.gz
在source view下可看到哪行代码生成的协程最多

11.1 协程管理

github.com/x-mod/routine是一个协程管理组件,它封装了常规的业务逻辑:初始化、收尾清理、工作协程、守护协程、监听term信号;以及常见的协程组织形式:并行、串行、定时任务、超时控制、重试、profiling。

runtime.GOMAXPROCS(2)	//分配2个逻辑处理器给调度器使用
runtime.Gosched()	//当前goroutine从当前线程退出,并放回到队列
runtime.NumGoroutine()	//查看当前存在的协程数

通过带缓冲的channel可以实现对goroutine数量的控制。

package main

import (
	"fmt"
	"runtime"
	"time"
)

type Glimit struct {
	limit int
	ch    chan struct{}
}

func NewGlimit(limit int) *Glimit {
	return &Glimit{
		limit: limit,
		ch:    make(chan struct{}, limit), //缓冲长度为limit,运行的协程不会超过这个值
	}
}

func (g *Glimit) Run(f func()) {
	g.ch <- struct{}{} //创建子协程前往管道里send一个数据
	go func() {
		f()
		<-g.ch //子协程退出时从管理里取出一个数据
	}()
}

func main() {
	go func() {
		//每隔1秒打印一次协程数量
		ticker := time.NewTicker(1 * time.Second)
		for {
			<-ticker.C
			fmt.Printf("当前协程数:%d\n", runtime.NumGoroutine())
		}
	}()

	work := func() {
		//do something
		time.Sleep(100 * time.Millisecond)
	}
	glimit := NewGlimit(10) //限制协程数为10
	for i := 0; i < 10000; i++ {
		glimit.Run(work) //不停地通过Run创建子协程
	}
	time.Sleep(10 * time.Second)
}

守护协程:独立于控制终端和用户请求的协程,它一直存在,周期性执行某种任务或等待处理某些发生的事件。伴随着main协程的退出,守护协程也退出。
  kill命令不是杀死进程,它只是向进程发送信号kill –s pid,s的默认值是15。常见的终止信号如下:

信号 说明
SIGINT 2 Ctrl+C触发
SIGKILL 9 无条件结束程序,不能捕获、阻塞或忽略
SIGTERM 15 结束程序,可以捕获、阻塞或忽略
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
}
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

当Context的deadline到期或调用了CancelFunc后,Context的Done()管道会关闭,该管道上关联的读操作会解除阻塞,然后执行协程退出前的清理工作。
  下面的代码演示了如何优雅地退出守护协程。

package main

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"sync"
	"syscall"
)

var (
	wg     sync.WaitGroup
	ctx    context.Context
	cancle context.CancelFunc
)

func init() {
	wg = sync.WaitGroup{}
	wg.Add(3)                                              //3个子协程,1个用于接收终止信号,其他2个是业务需要的后台协程
	ctx, cancle = context.WithCancel(context.Background()) //父context
}

func listenSignal() {
	defer wg.Done()
	c := make(chan os.Signal)
	//监听指定信号 SIGINT和SIGTERM。按下control+c向进程发送SIGINT信号
	signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
	for {
		select {
		case <-ctx.Done(): //调用cancle()时,管道ctx.Done()会被关闭,从ctx.Done()中读数据会立即返回0值
			return
		case sig := <-c: //接收到终止信息
			fmt.Printf("got signal %d\n", sig)
			cancle() //取消,通知用到ctx的所有协程
			return
		}
	}
}

func listenHttp(port int) {
	defer wg.Done()
	server := &http.Server{Addr: ":" + strconv.Itoa(port), Handler: nil} //在端口port上开启http服务
	go func() {
		for {
			select {
			case <-ctx.Done():
				server.Close() //调用Close后才会释放端口
				return
			}
		}
	}()
	if err := server.ListenAndServe(); err != nil { //如果不发生error,该行代码会一直阻塞,直到server.Close()
		fmt.Println(err)
	}
	fmt.Printf("stop listen on port %d\n", port)
}

func main() {
	//下面3个协程关联到了同一个context,通过cancle()可以通知彼此
	go listenSignal()
	go listenHttp(8080)
	go listenHttp(8081)
	wg.Wait() //等待3个子协程优雅退出后,main协程再退出
}
posted @ 2022-06-20 23:50  liwenchao1995  阅读(69)  评论(0编辑  收藏  举报