Go语言基础(四)goroutine &channel

一、Go并发

goroutine:语言级别的并发实现。goroutine是用户态线程,由goruntime 调度,而线程由 OS 调度。

go提供channel在多个go routine间通信。go routinechannelcsp模型的实现基础。

写Java并发代码,我们要自己搞一个线程池,自己包装任务,耗时耗力,极易出错。而使用go并发模型, 码农只需要定义很多个任务,让go帮我们实现任务自动分配到CPU执行。

作为现代化的语言,Go在语言层面上已经内置了调度和上下文切换机制。写go并发代码,不需要自己写进程、协程、线程,只需要写任务,开启go routine去执行即可。

上个例子:

func main() {
	go hello()	// go routine创建和启动需要时间
	fmt.Println("after all done")	

	time.Sleep(time.Second)
}
func hello(){
	fmt.Println("hello")
}

多个goroutine同时执行可能需要同步,再看个使用了并发控制的goroutine代码。

var wg sync.WaitGroup
func main() {

	for i := 0; i < 10; i++ {
		wg.Add(1)
		go hello()
	}
	wg.Wait()
	fmt.Println("all done")
}

func hello(){
	defer wg.Done()
	fmt.Println("hello")
}

二、go routine 和线程

在这里插入图片描述
GPM 调度: GPM是Go runtime在应用层面【区别于OS内核】实现的线程调度系统。

  • G: goroutine ,GPM中的Goroutine除了自身信息,还有绑定的P信息。
  • P: Processor,管理一组Go routine队列,保存着Go routine的上下文信息,比如堆栈、函数指针,etc。
    • P 会对 自己的 Goroutine做出调度,比如,当一个 goroutine 阻塞在一个M1 (machine)上太久时,P会启动一个新的 M2(machine),然后将 该go routine 切换到 M2上。再比如,占用CPU太久的goroutine会被暂停,让出CPU给后面的go routine。
    • P与M是一一对应的关系,P上面挂载着一组G 在M上执行。
    • P个数通过runtime.GOMAXPROCS设定(最大256,默认为机器CPU核数)。P太多,切换频繁不一定能提升性能,可能适得其反
  • M :machine , go routine 对OS 内核线程的虚拟。 M与内核是一一对应的关系,一个 go routine最终还是要 machine 完成。

Q: 从调度角度看,为啥Goroutine有优势?
A:

  • Goroutine是 Go routine去调度的,java等语言,Thread是由OS 内核去调度的。GPM是一种m:n调度技术,i.e.,“复用m个go routine到 n 个OS 线程”。Goroutine调度在用户态下完成,不会在内核态和用户态间频繁切换,比如内存分配和释放,是在用户态下维持一个内存池,不直接malloc,所以go routine调度成本更低。
  • GPM能充分利用到CPU多核的资源,将任务近似平均分配到多个CPU上
  • Go routine本身超轻量
  • 以上构成了 go routine的优势。

三、channel

独立地并发执行函数,意义其实不大,go routine之间还需要实现 通信(共享信息)。channel是 go实现 goroutine之间通信和共享的实现。
与java不同的是,go routine 采用了一种 csp ,communicating sequtail processes的并发模型,提倡通过 通信实现共享内存,而不是通过共享内存实现通信。这是Go并发模型的核心设计思想。

Go 的channel 可以理解为一个 具有元素类型的队列,始终FIFO保证收发数据有序

3.1 channel类型 和 操作

channel 是一种 引用类型,声明格式和 基本操作如下:

var chan1 chan int
	// var chan2 chan bool
	// var chans chan []int
	fmt.Println(chan1) //nil

	chan1 = make(chan int)
	fmt.Println(chan1) //0xc00001a0c0 十六进制的地址

	//通道有 SEND  RECEIVE  CLOSE 三种操作

	// SEND
	chan1 <- 10 // 把10 发送给 chann1 这个通道
	// RECEIVE
	x:=<- chan1	// 从chann1中接收值并赋值给 x
	<- chan1 // 从chann1 中接收值,但是忽略了 结果

	fmt.Println(x)
	// CLOSE
	close(chan1)

收发没啥好说的,但是 关闭通道需要注意一下:

  • 只有在通知接收方goroutine所有的数据都已经接收完毕之后,才需要关闭通道。通道可以被gc ,它和 关闭文件不大一样:操作结束后,打开的文件必须要关闭,但是通道 不需要。
  • 关闭后的通道有这些特点:
    • 关了再关就会 panic
    • 关了再发也会panic
    • 关了再收就会一直拿到值,通道为空;之后,将一直拿通道的零值。

3.2 无缓冲通道 【阻塞通道】

无缓冲通道,也叫 阻塞通道、同步通道。
看这个例子:


func fn5(){
	var chan1 =make(chan int)	// 创建无缓冲的通道
	chan1 <- 10  //fatal error: all goroutines are asleep - deadlock!
	fmt.Println("send done")
}

这里会 阻塞到 chan1 <- 10这一行,形成死锁 【这里其实还是很好奇,为啥会报 死锁的 错误,不知道 go底层是怎么设计的】。
这是因为: chan1 是一个没有缓冲的通道,无缓冲通道只在有接收方时才能发。

如何解决这个问题呢?
1、使用缓冲通道替代
2、起另一个 go routine 去接收

我们这里使用第 2 种方法:

func fn7(){
	
	var ch1 = make(chan int)
	go recv(ch1)
	ch1 <- 10

	time.Sleep(1)	//阻塞,不让主 goroutine 结束
}
func recv(ch chan int){
	x:= <- ch
	fmt.Println("recving x:", x)
}

来分析下 接收者和发送者的行为:

  • 假如 接收 操作先开始,会阻塞在 接收操作上,直到发送操作也开始
  • 假如 发送 操作先开始,会阻塞在 发送操作上,直到接收操作也开始
  • 因此,接收 、操作实际上是互相约束的(同步的),这就是为啥 阻塞通道也叫做 同步通道了。

3.3 缓冲通道

在初始化 channel的时候,指定channel的容量即是 缓冲通道。

func fn(){
	var ch1 = make(chan int, 1)
	ch1 <- 10
	// ch1 <- 20   // 假如没有这行,也会引发fatal error: all goroutines are asleep - deadlock!
	fmt.Println("chann....")

	time.Sleep(1)
}

3.4 for range从通道循环取值

向channel发完数据,可以关闭它。从channel取数据,会先取完channel中的值,然后一直取零值,那如何判断一个channel已经关闭呢?

第一种:

i,ok :=<- ch1 
若 ch1 已经关闭,则 OK 为false

第二种:
遍历channel,假如 channel遍历了,循环就会退出来

举个栗子:

func fn(){
	ch1 := make(chan int)
	ch2 := make(chan int)
	// 第一个 go routine:写入数据
	go func ()  {
		for i := 0; i < 100; i++ {
			ch1 <- i
		}
		close(ch1)
	}()
	// 第二个 go routine: 读出 第一个go routine的数据,写入其中
	go func(){
		for{
			i,ok :=<- ch1	//如果ch1 已经被关闭,则 ok 会被赋值 false
			if(!ok){
				break
			}
			ch2 <- i
		}
		close(ch2)
	}()
	// 遍历 channel 
	for i:= range ch2{
		fmt.Println(i)
	}

}

3.5 单向通道

一个通道,我们可能会在多个任务(多个go routine)间传递,我们可能不希望不同函数都向它 传入、传出数据,这个时候就可以使用 单向通道了。
单向通道的语法:
in chan <- int :只写通道
out <-chan int:只读通道

这里的语法容易看花眼,这样记忆就行了:
chan 在左就是写, chan 在右就是读

另外,在函数传参及任何赋值操作中可以将双向通道转换为单向通道,但反过来是不可以的。

本质上,单向通道并不是一个独立的类型,依然只是个 通道,只是在作为函数参数时,限制了是 单向还是双向而已。

func fn() {
	ch1 := make(chan int)
	ch2 := make(chan int)

	go counter(ch1)

	go squarer(ch2, ch1)

	printer(ch2)
}

// in 是一个只写的 channel
func counter(in chan<- int) {
	for i := 0; i < 10; i++ {
		in <- i
	}
	close(in)
}

// in 是只 写的
// out 是只读的
func squarer(in chan<- int, out <-chan int) {
	for v := range out {
		in <- v * v
	}
	close(in)
}

//打印
func printer(ch <-chan int) {
	for v := range ch {
		fmt.Println(v)
	}
}

3.6 go routine pool

go routine pool,也就是复用go routine,防止出现 泄露和暴涨。
举个栗子:

func fn(){
	jobs:=make(chan int,100)
	results := make(chan int,100)

	// 启动了 三个 go routine,实际上只有三个 go routine在干活
	// 我们并不是显式地看到一个 pool (集合)
	for i := 0; i < 3; i++ {
		go worker(i, jobs,results)
	}
	//提交任务
	for i := 0; i < 5; i++ {
		jobs <- i		
	}
	close(jobs)

	for i := 0; i < 5; i++ {
		<-results
	}
	
}
// 这是一个 简单版的 go routine pool
// Java的线程pool 底层 是一个 集合,go routine的pool 倒不一样
func worker(id int,jobs<- chan int, result chan <- int){
	for v := range jobs {
		fmt.Printf("worker:%v start job at %v \n", id,v)
		time.Sleep(time.Second)
		fmt.Printf("worker:%v ends job at %v \n", id,v)
		result <- v*2
	}
}

突然发现,如果要把goroutine 和 java中线程模型去做个类比:

  • go func() 中的 func() 其实像是 Java中的Runnable
  • goroutine pool 和java 中的 线程池也不是直接类比的,Java 的线程池提交一个任务是 ThreadPool.submit() ,意如其字,goroutine pool 不是通过一个 pool去限制goroutine 数量,而是通过限制go func次数去限制 go routine数量(毕竟 go func就启动了一个goroutine),任务提交是通过 channel做的。

3.7 select 多路复用

如果我们有多个channel需要从中拿数据,我们可能会这样写:

for{
    // 尝试从ch1接收值
    data, ok := <-ch1
    // 尝试从ch2接收值
    data, ok := <-ch2
    …
}

这样写当然能实现功能,但 读前一个Channel如果没有数据将会阻塞,性能因此很差。

为了应对这种场景,go提供了select关键字,语法如下:

select{
    case <-ch1:
        ...
    case data := <-ch2:
        ...
    case ch3<-data:
        ...
    default:
        默认操作
}

比如这个例子:

func fn() {
	ch:= make(chan int,1)
	for i := 0; i < 10; i++ {
		select {
		case x:=<- ch :
			fmt.Println(x)
		case ch <- i:
			fmt.Println("insert ", i)
		}
	}
}

每个case会对应一个通道的通信(接收或发送)过程。select会一直等待,直到某个case的通信操作完成时,就会执行case分支对应的语句.

select有哪些特点:

  • select可处理一个多个channel
  • 如果有多个case满足条件,select 会随机执行其中一个
  • select 可以阻塞当前 go routine 【?测试起来似乎并不是这样,而是会报错】

3.8 并发安全

线程安全问题中也同样存在,其实就是多个线程(协程)同时操作一个变量,后一个的效果可能覆盖前一个。专业说法叫“竞态”。

var x int	// x 是 线程不安全的
var wg sync.WaitGroup

func do(){
	for i := 0; i < 10000; i++ {
		x= x+ i
	}
	wg.Done()
}
func fn(){
	wg.Add(2)
	go do()
	go do()
	wg.Wait() //阻塞在此,直到 wg 计数=0

	fmt.Println(x)	//多次运行,结果可能不一样的
}

3.9 互斥锁

互斥锁是一种常见的控制共享资源访问的做法,同一个时间点,只有一个线程(协程)能够进入临界区。【java中同理,也不是啥新鲜东西】

var x int	// x 是 线程不安全的
var wg sync.WaitGroup
var lock sync.Mutex
func do(){
	for i := 0; i < 10000; i++ {
		lock.Lock()	
		x= x+ i
		lock.Unlock()
	}
	wg.Done()
}
func fn(){
	wg.Add(2)
	go do()
	go do()
	wg.Wait() //阻塞在此,直到 wg 计数=0

	fmt.Println(x)	//多次运行,结果可能不一样的
}

3.10 读写锁

这个就一句话:读写互斥,写写互斥,读读不斥。读写锁适用于读多写少的场景。读写锁和互斥锁大同小异:

sync.RWMutex

3.11 sync.WaitGroup

这个是 go routine 同步工具。基本可以类比java中的的CountdownLatch或者CyclicBarrier

sync.WaitGroup内部维护着一个计数器,通过三个方法去操作计数器的计数:

  • add (n) --> 计数器 加 n
  • done --> 计数器 -1
  • wait --> 阻塞,一直到计数器为0

sync.WaitGroup 是一个结构体,传递的时候要传递指针。这个倒是需要注意的。

3.12 sync.Once

某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等。针对这种只执行一次的场景【其实是个常见场景】,sync.Once就是go的解决方案。
sync.Once 只有这一个方法:

func (o *Once) Do(f func()) {}

Do 方法的传参,是个无参的函数,如果我们要执行一次的函数是个带参数的,则可利用闭包来适配一下。

先举个栗子:


func fn() {
	//  日志 “enter into loadIcons” 并没有打印 100次,
	// 更没有只打印一次(说明实际上 loadIcons 运行了很多次,而不是一次)
	// 说明: 多个goroutine并发调用Icon函数时不是并发安全的。
	for i := 0; i < 100; i++ {
		go icon("left")
	}
	time.Sleep(time.Second)
}

var icons map[string]string

func icon(name string) string {
	if icons == nil { // 如果发现 当前的 icons 缓存中没有内容,就加载
		loadIcons()
	}

	return icons[name]
}

func loadIcons() {
	fmt.Println("enter into loadIcons ")
	icons = make(map[string]string)
	icons["left"] = "left.jpg"
	icons["right"] = "right.jpg"
}

假如我们使用sync.runOnce()会怎样呢?
看这里:

func fn(){
	// enter into loadIcons 这句日志只执行一次
	for i := 0; i < 100; i++ {
		go icon("left")
	}
	time.Sleep(time.Second)
}

var runOnce sync.Once	//注意 sync.Once是个类型!
var icons map[string]string

func icon(name string) string {
	runOnce.Do(loadIcons)
	return icons[name]
}

func loadIcons() {
	fmt.Println("enter into loadIcons ")
	icons = make(map[string]string)
	icons["left"] = "left.jpg"
	icons["right"] = "right.jpg"
}

感叹一下: go提供了简便的工具,让并发编程变得 平易近人,只说这一点,真的要比java强了不少。

3.13 并发安全的单例模式

type singleton struct{}
var s *singleton
var once sync.Once
func getInstance() *singleton{
	once.Do(func(){
		s = &singleton{}
	})
	return s
}
// 这个例子来证实确实是只有一个 实例
func fn(){
	type void struct{}
	var member void
	set:=make(map[*singleton]void)
	
	for i := 0; i < 1000; i++ {
		go func ()  {
			s:=getInstance()
			set[s] = member
		}()
	}
	time.Sleep(time.Second)
	l:=len(set)
	fmt.Println(l)	// 1
}

3.14 sync.map

map 不是并发安全容器,go提供了sync.map这个并发安全的类型。

3.15 原子操作

互斥锁虽然能保证安全,但是加锁、解锁会涉及到go routine上下文的切换,如果是基础类型的安全性,我们可以使用原子操作类来保证并发安全,原子操作是 发生在用户态,性能比加锁更好。
go中的标准库sync/atomic提供了原子类型。

go 的这个原子类和Java的原子类实在是有异曲同工之妙。

下面这个例子,将 基于锁、基于原子操作、普通操作(不能保证线程安全)三种做了个比较:

type Counter interface{
	inc()
	load() int64
}
// 普通counter
type CommonCounter struct{
	c int64
}

func (c CommonCounter) inc(){
	c.c = c.c + 1
}
func(c CommonCounter) load() (int64){
	return c.c
}
// 原子操作counter
type AtomicCounter struct{
	c int64
}

func (a *AtomicCounter) inc(){
	atomic.AddInt64(&a.c,1)
}
func(a *AtomicCounter) load() (int64){
	return atomic.LoadInt64(&a.c)
}
// 互斥锁版
type MutexCounter struct{
	c int64
	lock sync.Mutex
}
func (m *MutexCounter)inc(){
	lock.Lock()
	m.c++
	defer lock.Unlock()
}
func(m *MutexCounter)load() (int64){
	lock.Lock()
	defer lock.Unlock()
	return m.c
}
func test(counter Counter){
	var wg sync.WaitGroup
	start:= time.Now().Unix()
	for i := 0; i < 100000000; i++ {
		wg.Add(1)
		go func ()  {
			counter.inc()
			wg.Done()
		}()
	}
	wg.Wait()
	fmt.Println("cost time:", time.Now().Unix() - start)
}

func fn(){
	// c1:= CommonCounter{}
	// test(c1)

	// c2:= AtomicCounter{}
	// test(&c2)

	c3:= MutexCounter{}
	test(&c3)
}

3.16 examples

// 使用goroutine和channel实现一个计算int64随机数各位数和的程序。
// 开启一个goroutine循环生成int64类型的随机数,发送到jobChan
// 开启24个goroutine从jobChan中取出随机数计算各位数的和,将结果发送到resultChan
// 主goroutine从resultChan取出结果并打印到终端输出

func foo() {
	var jobChan = make(chan int64, 10)
	var resultChan = make(chan int64, 10)
	//启动一个 go routine
	go func() {
		for {
			jobChan <- rand.Int63()
			time.Sleep(time.Millisecond)
		}
	}()
	
	var wg sync.WaitGroup
	wg.Add(20)

	// 开启 20 个 go routine ,这形成了实质上的 goroutine pool
	for i := 0; i < 20; i++ {
		defer wg.Done()

		go func() {
			for {	// 特别要注意,这个地方必须是一个 死循环:
				// 如果不是,那么这个 go routine 任务很快就执行完,然后go routine 就退出了(?)
				a := <-jobChan
				s := strconv.Itoa(int(a))
				arr := strings.Split(s, "")
				var sum = 0
				for _, v := range arr {
					num, _ := strconv.Atoi(v)
					sum = sum + num
				}
				resultChan <- int64(sum)
			}
		}()
	}
	for v := range resultChan {
		fmt.Println("result ends at:", v)
	}
	wg.Wait()
}

四、

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值