Go语言基础(5)——goroutine

68 阅读8分钟

Go并发——gotoutine及其应用

goroutine模型

go的并发没有像Java一样将线程与操作系统的线程进行一一映射,而是自己实现了一套管理和调试,同时可以将goroutine理解成Java中的协程。

image.png

上图是goroutine的模型图:

  • M表示表示真正的内核线程,表示并发量,也即同时可以有多少个goroutine可以同时运行
  • P是对M的一层虚拟,表示goroutine的Processor,每个Processor有一串待执行的G处于等待队列
  • G蓝色的G是正在运行的goroutine,而灰度的G形成一条链,是等待队列

goroutine调度

在上节的图中我们会发现多个P其实是不共享等待队列的,因此可能会出现阻塞和饥饿的情况。例如第一个M执行的G中有syscall导致阻塞,这时候第一个P的等待队列就会一直阻塞直到syscall返回,这时M会丢弃P给其他M。当第一个M从syscall返回时,它又会去抢一个P回来,如果没有抢成功,则会将G push到一个全局的G等待队列,然后让自己休眠。

全局等待队列没有P,因此它是被其他M上的P调度的。当某个M的P没有可执行的G时,也即等待队列为空时,会先从全局等待队列中取一个G来执行。如果全局等待队列也为空时,它还会从其他P的等待队列中抢一些G加入到自己的等待队列中,那么抢多少合适呢?go的做法是直接抢一半...

goroutine的创建与销毁

由于goroutine没有与系统线程一一映射,因此goroutine的所有状态和信息都是直接保存在go进程的内存中的,因而在运行时创建一个goroutine就特别简单了,只需要new一块内存,放入goroutine的信息并将goroutine加入到一个P的等待队列中即可

跟Java的Thread一样,goroutine其实也是没有销毁功能。但Java的Thread可以在run方法中catch所有异常,在finally中执行一些release逻辑,go语言也有个defer功能,可以在函数执行完毕时(不论是否触发了异常)都能执行,因此可以用defer实现Java的finally的release功能。

func CreateAndRelease() {
	wg := sync.WaitGroup{}
	wg.Add(1)
	go func() {
		defer func() {
			e := recover()
			if e != nil {
				fmt.Printf("goroutine error:%v\n", e)
				return
			}
			fmt.Println("goroutine run successful")
		}()
		defer wg.Done()
		time.Sleep(2 * time.Second)
		a := 0
		result := 3 / a
		fmt.Printf("run 3/%d=%d successful\n", a, result)
	}()
	wg.Wait()
}

在上面的代码中,使用go+func的方式即可创建一个goroutine,然后在func中使用了defer+recover捕获error。除此之外还使用了sync.WaitGroup,这里的WaitGroup其实跟Java中的CountDownLatch功能几乎一样。如果不加WaitGroup,那么可能新创建goroutine还开始调度main goroutine就执行完毕导致进程结束了。

上面的代码中用3/0来触发一个error,在defer中用recover捕获了这个error并打印出来,运行打印输出是goroutine error:runtime error: integer divide by zero。我们可以在go func创建的goroutine的defer中执行一些release操作,当然,如果希望goroutine中的异常不会抛到进程中去导致进程异常退出的话,还需要使用recover在goroutine中就捕获error

goroutine通信机制——chan

chan是channel的缩写,意为管道,是go语言中goroutine之间主要的通信机制。chan也是go的一种数据类型,chan + type即可组合成任何类型的管道。同时chan其实也有三种类型,分别是:

  • chan type:可读可写管道
  • <-chan type:只读管道
  • chan<- type:只写管道

对于只读或只写的chan来说,其箭头的指向几乎就说明了其功能。只读chan的箭头在chan type左边,表明管道的出口,意为要从管道中输出数据。只写chan的箭头在chan和type之间,说明这是管道的入口,要将一个type类型的数据写入管道。而对于可读可写的管道,没有箭头,说明其既可以是入口也可以是出口,因此可读可写。

func product(c chan<- interface{}) {
	for i := 0; ; i++ {
		msg := fmt.Sprintf("msg-%d", i)
		time.Sleep(500 * time.Millisecond)
		fmt.Printf("product %s\n", msg)
		c <- msg
	}
}

func consume(c <-chan interface{}) {
	for {
		msg := <-c
		fmt.Printf("consume %s\n", msg)
		time.Sleep(1000 * time.Millisecond)
	}
}

func Run() {
	c0 := make(chan interface{})
	go product(c0)
	go consume(c0)

	c1 := make(chan interface{})
	go consume(c1)
	go product(c1)

	wait := make(chan interface{}, 0)
	<-wait
}

上面的代码演示了一个典型的生产者消费者模式

chan的容量

上面实现的生产消费-消费者模式,在使用make创建chan的时候,还在wait这个chan传了0这个参数,make创建chan的第三个参数就是chan的容量,如果不传,默认容量是1,也就是chan读取会阻塞一直到有写入为止,如果已经写入一个则需要读取后才能再写入,否则写入时就会阻塞。而如果指定容量为0,则表示这个chan读或写都会一直阻塞,因此使用一个容量为0来实现WaitGroup的效果。

上面的生产者-消费者模式的消息队列由于没有指定chan的容量,因此容量为1,如果在生产效率大于消费效率时不阻塞生产者,则需要将消息队列的容量设置成大于1,当然,一般来说这个容量肯定不能指定得太大,因为生产的消息会一直保存在chan中,容量太大可能会造成OOM。一般地,chan的容量大于1时,我们将这个容量当成是chan的缓存,一旦有了缓存大小,就会涉及到背压问题,goroutine的chan对于背压只有接收消息并阻塞这一种策略,并且无法设置,因此需要实现其他的背压策略,只能在生产-消费者的生产端自定义实现。

select-case

select-case一般用来同时监听多个chan的消息,类似于switch-case。这有点像Java中的AIO的select实现,只有当io事件到来时才会触发事件,否则也不会阻塞当前线程。同时,select也能像for-range一样监控到chan的close事件,可以安全方便地从for循环中退出对chan的消息接收。

func selectProduct(ctx context.Context, msg chan<- struct{}, d time.Duration) {
exit:
   for {
      select {
      case _, ok := <-ctx.Done():
         if !ok {
            fmt.Println("ctx done and product exit")
            close(msg)
            break exit
         }
      default:
         time.Sleep(d)
         msg <- struct{}{}
      }
   }
}

func selectConsume(msg1, msg2 <-chan struct{}) {
   exitFun := func() {
      fmt.Println("consume exit")
   }
   var exit1, exit2 bool
exit:
   for {
      select {
      case m1, ok := <-msg1:
         exit1 = !ok
         if ok {
            fmt.Printf("msg1 %v\n", m1)
         } else if exit2 {
            exitFun()
            break exit
         }
      case m2, ok := <-msg2:
         exit2 = !ok
         if ok {
            fmt.Printf("msg2 %v\n", m2)
         } else if exit1 {
            exitFun()
            break exit
         }
      }
   }
}

func TestSelect() {
   msg1, msg2 := make(chan struct{}), make(chan struct{})
   var ctx context.Context
   ctx, _ = context.WithTimeout(context.Background(), time.Second*5)
   go selectProduct(ctx, msg1, time.Second)
   go selectProduct(ctx, msg2, time.Second*2)
   go selectConsume(msg1, msg2)
   _, ok := <-ctx.Done()
   if !ok {
      time.Sleep(time.Second * 3)
   }
}

上面的代码演示了一个消费者同时消费两个生产者的消息,并且使用context设置了一个5s的超时定时器,5s后这个程序将正常退出,所有的goroutine都将通过exit tag来break

同时select对于每个case,其实是没有顺序的,如果两个事件同时到来,select也不会按照case的书写顺序来执行,而是随机选一个case执行

chan主要是为了解决goroutine之间的并发访问安全问题,也就是管道的实现方式。除了使用chan,还可以使用和Java一样的锁机制来实现并发的临界区。

golang的锁机制也都在sync包内,sync.Locker接口的Lock和Unlock函数用来锁定和解锁,这种Java的Lock完全一样,sync.Mutex用来实现普通锁,sync.RWMutex用来实现读写锁。

其他的同步机制

除了chan和锁,golang还提供了一些其他的同步机制,比如前面提到的sync.WaitGroup来用来实现类似于Java的CountDownLatch的功能。

Java中还提供了原子类来保证并发环境下的内存可见性,golang也提供了一些原子结构来保证可见性。在sync的子包atomic包内,提供了int32、uint32、int64、uint64的CAS操作函数,可以方便地实现一些原子操作,还提供了Value这个struct,可以保存interface{}类型的变量,Value提供了Load和Store两个函数来实现原子读和写。

当然,对于最常用的数据结构map,sync包也提供了并发安全的Map结构,sync.Map类似于Java的ConcurrentMap来实现并发安全Map。

Java中的wait/notify功能可以用来实现线程的等待与唤醒,golang用sync.Cond来实现这个功能,sync.Cond需要传入一个Lock变量,这跟Java的Object作为锁一样。

除此之外,golang还为了资源复用和单例模式的方便实现在sync包中实现了sync.Pool和sync.Once结构。

  • Pool在创建时需要传入一个New的函数,用来作为资源生产工厂,当Pool中还不存在需要的资源时,就会调用New函数来生产一个,如果已有资源,就会直接返回。Pool使用Get函数来获取资源,使用Put函数将资源释放返回给Pool。
  • sync.Once的创建不需要传入任何参数,但它的唯一函数Done需要传入一个函数作为操作对象,sync.Once结构的Done函数无论调用多少次都只会在第一次调用时执行,因此可以在传入Done函数的参数函数中做一些单例的初始化工作。