golang 第4章 并发、协程(channel/go/waitGroup )

并发

协程

协程(coroutine)是一种轻量级的线程,或者说是用户态的线程,不受操作系统直接调度,由Go语言自身的调度器进行运行时调度,因此上下文切换开销非常小,这也是为什么Go的并发性能很不错的原因之一。

package main

import "fmt"

func main() {
   //Go中,创建一个协程十分的简单,仅需要一个go关键字,就能够快速开启一个协程,go关键字后面必须是一个函数调用
   go fmt.Println(123)
}

这个例子执行过后在大部分情况下什么都不会输出,协程是并发执行的,系统创建协程需要时间,而在此之前,主协程早已运行结束,一旦主线程退出,其他子协程也就自然退出了。并且协程的执行顺序也是不确定的,无法预判的

解决

最简单的做法就是让主协程等一会儿,需要使用到time包下的Sleep函数,可以使当前协程暂停一段时间

package main

import (
	"fmt"
	"time"
)

func main() {
	fmt.Println("start")
	for i := 0; i < 10; i++ {
		go fmt.Println(i)
		time.Sleep(time.Millisecond)
	}
	fmt.Println("end")

}

对于并发的程序而言,不可控的因素非常多,执行的时机,先后顺序,执行过程的耗时等等,倘若循环中子协程的工作不只是一个简单的输出数字,而是一个非常巨大复杂的任务,耗时的不确定的,那么依旧会重现之前的问题。

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	fmt.Println("start")
	for i := 0; i < 10; i++ {
		go hello(i)
		time.Sleep(time.Millisecond)
	}
	time.Sleep(time.Millisecond)
	fmt.Println("end")
}

func hello(i int) {
	// 模拟随机耗时
	time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))//当耗时超过1毫秒 依旧出现该问题
	fmt.Println(i)
}

因此time.Sleep并不是一种良好的解决办法,幸运的是Go提供了非常多的并发控制手段,常用的并发控制方法有三种:

  • channel:管道
  • WaitGroup:信号量
  • Context:上下文

三种方法有着不同的适用情况,WaitGroup可以动态的控制一组指定数量的协程,Context更适合子孙协程嵌套层级更深的情况,管道更适合协程间通信。对于较为传统的锁控制,Go也对此提供了支持:

  • Mutex:互斥锁
  • RWMutex :读写互斥锁

管道

channel,译为管道,Go对于管道的作用如下解释:

即通过消息来进行内存共享,channel就是为此而生,它是一种在协程间通信的解决方案,同时也可以用于并发控制,先来认识下channel的基本语法。

var ch chan int
//管道的声明语句 此时管道还未初始化 值为nil
fmt.Println(ch)//nil

创建

在创建管道时,有且只有一种方法,那就是使用内置函数make

make函数接收两个参数,第一个是管道的类型,第二个是可选参数为管道的缓冲大小

make(chan int,1)//缓冲区大小为1的管道

在使用完一个管道后一定要记得关闭该管道,使用内置函数close来关闭一个管道,该函数签名如下

func close(c chan<- Type)
package main

func main() {
	intCh := make(chan int, 1)
	close(intCh)
}

有些时候使用defer来关闭管道可能会更好。

package main

func main() {
	intCh := make(chan int, 1)
	defer close(intCh)
}

读写

写入 ch <-

读取 <-ch

<-很生动的表示了数据的流动方向,来看一个对int类型的管道读写的例子

import "fmt"

func main() {
	intCh := make(chan int, 1)
	defer close(intCh)
	intCh <- 123
	//<-intCh接受两个返回值 ok 是否读取成功 true'成功 false失败
	ints, ok := <-intCh
	if ok {
		fmt.Println(ints)
	} else {
		fmt.Println("error")
	}
}

管道中的数据流动方式与队列一样,即先进先出(FIFO),协程对于管道的操作是同步的,在某一个时刻,只有一个协程能够对其写入数据,同时也只有一个协程能够读取管道中的数据。

无缓冲

package main

import "fmt"

func main() {
   ch1 := make(chan int)
   fmt.Println(ch1)
}

对于无缓冲管道而言,因为缓冲区容量为0,所以不会临时存放任何数据。

因为无缓冲管道无法存放数据,在向管道写入数据时必须立刻有其他协程来读取数据,否则就会阻塞等待,读取数据时也是同理,这也解释了为什么下面看起来很正常的代码会发生死锁。

package main

import "fmt"

func main() {
	ch1 := make(chan int)
	defer close(ch1)
	ch1 <- 123
	n := <-ch1
	fmt.Println(n)
}
//deadlock 死锁

无缓冲管道不应该同步的使用,正确来说应该开启一个新的协程来发送数据

package main

import "fmt"

func main() {
	ch1 := make(chan int)
	defer close(ch1)
	go func() {
		ch1 <- 123
	}()
	n := <-ch1
	fmt.Println(n)
}

有缓冲

当管道有了缓冲区,就像是一个阻塞队列一样,读取空的管道和写入已满的管道都会造成阻塞。

package main

import "fmt"

func main() {
	ch01 := make(chan int, 1)
	defer close(ch01)
	//
	ch01 <- 123
	//写入已满的管道
	ch01 <- 222
	fmt.Println(112)
}
package main

import "fmt"

func main() {
	ch01 := make(chan int, 1)
	defer close(ch01)
	//读取空的管道
	x := <-ch01
	fmt.Println(x)
}

都会造成阻塞,dead lock

//尽管可以顺利运行,但这种同步读写的方式是非常危险的,一旦管道缓冲区空了或者满了,将会永远阻塞下去,因为没有其他协程来向管道中写入或读取数据。

package main

import "fmt"

func main() {
	ch01 := make(chan int, 1)
	defer close(ch01)
	ch01 <- 123
	x := <-ch01
	fmt.Println(x)
}

来看看下面的一个例子

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int, 3)
	ch1 := make(chan struct{})
	ch2 := make(chan struct{})
	defer func() {
		close(ch)
		close(ch1)
		close(ch2)
	}()
	//负责写入
	go func() {
		for i := 0; i < 10; i++ {
			ch <- i
			fmt.Println("写入", i)
		}
		ch1 <- struct{}{}
	}()
	//负责读取
	go func() {
		for i := 0; i < 10; i++ {
			time.Sleep(time.Millisecond)
			fmt.Println("读取", <-ch)
		}
		ch2 <- struct{}{}
	}()
	fmt.Println("写入完毕", <-ch1)
	fmt.Println("读取完毕", <-ch2)

}

两个fmt遇到管道读取阻塞,需要等到ch1和ch2写入后才能读取,再看回来就是两个协程里面,两个循环,for里面遇到了管道,但是管道ch的缓冲区是3,循环是10次,那么直接3次写满,协程又阻塞,这时候等到第二个go开始读取一个出来,第一个go又往里面写入一个,等到都写入读取完,开始写入ch1和ch2,最后读取ch1和ch2

利用管道的阻塞条件,可以很轻易的写出一个主协程等待子协程执行完毕的例子

func main() {
   // 创建一个无缓冲管道
   ch := make(chan struct{})
   defer close(ch)
   go func() {
      fmt.Println(2)
      // 写入
      ch <- struct{}{}
   }()
   // 阻塞等待读取
   <-ch
   fmt.Println(1)
}
//2
//1

通过有缓冲管道还可以实现一个简单的互斥锁,看下面的例子

var count = 0

// 缓冲区大小为1的管道
var lock = make(chan struct{}, 1)

func Add() {
    // 加锁
	lock <- struct{}{}
	fmt.Println("当前计数为", count, "执行加法")
	count += 1
    // 解锁
	<-lock
}

func Sub() {
    // 加锁
	lock <- struct{}{}
	fmt.Println("当前计数为", count, "执行减法")
	count -= 1
    // 解锁
	<-lock
}

通过内置函数读取管道缓冲区中数据的个数和访问管道缓冲区大小

func main() {
   ch := make(chan int, 5)
   ch <- 1
   ch <- 2
   ch <- 3
   fmt.Println(len(ch), cap(ch))
   //3 5
}

单向通道

关闭通道的内置函数close的函数签名就用到了单向通道。

close函数的形参是一个只写通道,After函数的返回值是一个只读通道,所以单向通道的语法如下:

//只写通道 箭头符号<-在后,就是只写通道,
chan<-type
//只读通道 箭头符号<-在前,就是只读通道
<-chan type

当尝试对只读的管道写入数据时,将会无法通过编译

func main() {
	timeCh := time.After(time.Second)
    //不能写入 无效运算: timeCh <- time.Now() (发送到仅接收类型 <-chan Time)
	timeCh <- time.Now()
}

双向管道可以转换为单向管道,反过来则不可以。通常情况下,将双向管道传给某个协程或函数并且不希望它读取/发送数据,就可以用到单向管道来限制另一方的行为。

package main

import "fmt"

func main() {
	i := make(chan int, 1)
	go write(i)
	fmt.Println(<-i)
}
//接受一个单向的只写通道
func write(ch chan<- int) {
	ch <- 1
	//无法读取
	fmt.Println(<-ch)//无效运算: <-ch (从仅发送类型 chan<- int 接收)
}

chan是引用类型,即便Go的函数参数是值传递,但其引用依旧是同一个,这一点会在后续的管道原理中说明。

package main

import "fmt"

func main() {
	i := make(chan int, 1)
	defer close(i)
	go changeSlice(i)
	fmt.Println(<-i)//读取是123
}

func changeSlice(a chan int) {
	a <- 123
}

注意点

下面是一些总结,以下几种情况使用不当会导致管道阻塞:

1.读写无缓冲管道
func main() {
   // 创建了一个无缓冲管道
   intCh := make(chan int)
   defer close(intCh)
   // 发送数据
   intCh <- 1
   // 读取数据
   ints, ok := <-intCh
   fmt.Println(ints, ok)
}
2.读取空缓冲区的管道
func main() {
   // 创建的有缓冲管道
   intCh := make(chan int, 1)
   defer close(intCh)
   // 缓冲区为空,阻塞等待其他协程写入数据
   ints, ok := <-intCh
   fmt.Println(ints, ok)
}
3.写入满缓冲区的管道
func main() {
	// 创建的有缓冲管道
	intCh := make(chan int, 1)
	defer close(intCh)
	
	intCh <- 1
    // 满了,阻塞等待其他协程来读取数据
	intCh <- 1
}
4.管道为nil时,做读写操作
func main() {
	var intCh chan int
    // 写
	intCh <- 1
}
func main() {
	var intCh chan int
    // 读
	fmt.Println(<-intCh)
}
5.导致panic
5.1关闭一个nil管道

当管道为nil时,使用close函数对其进行关闭操作会导致panic`

func main() {
	var intCh chan int
	close(intCh)
}
5.2写入已关闭的管道
func main() {
	intCh := make(chan int, 1)
	close(intCh)
	intCh <- 1
}
5.3关闭已关闭的管道
func main() {
	ch := make(chan int, 1)
	defer close(ch)
	go write(ch)
	fmt.Println(<-ch)
}

func write(ch chan<- int) {
	// 只能对管道发送数据
	ch <- 1
	close(ch)
}
for range
package main

import "fmt"

func main() {
	i := make(chan int, 10)
	defer close(i)
	go func() {
		for x := 0; x < 10; x++ {
			i <- x
			
		}
	}()
	for n := range i {
		fmt.Println(n)
	}
}

forrange遍历管道只有一个返回值就是管道的元素值

for range会遍历读取管道缓冲区中的元素,当管道缓冲区为空时,就会阻塞等待,直到有其他协程向管道中写入数据才会继续读取数据,输出会如下:

0
1                                                    
2                                                    
3                                                    
4                                                    
5                                                    
6                                                    
7                                                    
8                                                    
9                                                    
fatal error: all goroutines are asleep - deadlock! 

需要改进以上代码:

在管道写入完毕后将其关闭

package main

import "fmt"

func main() {
	i := make(chan int, 10)
	
	go func() {
		for x := 0; x < 10; x++ {
			i <- x
		}
		//不会在发生死锁
		defer close(i)
	}()
	for n := range i {
		fmt.Println(n)
	}
}

即便管道已经关闭,对于有缓冲管道而言,依旧可以读取数据,并且第二个返回值仍然为true

package main

import "fmt"

func main() {
	ch := make(chan int, 10)
	for i := 0; i < 5; i++ {
		ch <- i
	}
	// 关闭管道
	close(ch)
	// 再读取数据
	for i := 0; i < 7; i++ {
		n, ok := <-ch
		fmt.Println(n, ok)
	}
}

0 true
1 true
2 true 
3 true 
4 true 
0 false//第六次 已经读取完了管道内的数据 false代表读取失败
0 false//第七次

由于**管道已经关闭了,即便缓冲区为空,再读取数据也不会导致当前协程阻塞,**可以看到在第六次遍历的时候读取的是零值,并且okfalse

注意:

关于管道关闭的时机,应该尽量在向管道发送数据的那一方关闭管道,而不要在接收方关闭管道,因为大多数情况下接收方只知道接收数据,并不知道该在什么时候关闭管道。

select

与switch类似

存在default时,case都读取失败后,最后会default

每一个case只能操作一个管道,且只能进行一种操作,要么读要么写,当有多个case可用时,select会伪随机的选择一个case来执行

如果所有case都不可用,就会执行default分支,倘若没有default分支,将会阻塞等待,直到至少有一个case可用。

package main

import "fmt"

func main() {
	chA := make(chan int)
	chB := make(chan int)
	chC := make(chan int)
	defer func() {
		close(chA)
		close(chB)
		close(chC)
	}()
	go send(chA, 1)
	go send(chB, 2)
	go send(chC, 3)
	select {
	case n, ok := <-chA:
		fmt.Println(n, ok)
	case n, ok := <-chB:
		fmt.Println(n, ok)
	case n, ok := <-chC:
		fmt.Println(n, ok)
	default:
		fmt.Println("所有管道都不可用")
	}
}

func send(ch chan int, i int) {
	ch <- i
}

取消default 至少等待一个管道写入后开始读取 1 true

func main() {
	chA := make(chan int)
	chB := make(chan int)
	chC := make(chan int)
	defer func() {
		close(chA)
		close(chB)
		close(chC)
	}()
	go send(chA, 1)
	//go send(chB, 2)
	//go send(chC, 3)
	select {
	case n, ok := <-chA:
		fmt.Println(n, ok)// 1 true
	case n, ok := <-chB:
		fmt.Println(n, ok)
	case n, ok := <-chC:
		fmt.Println(n, ok)
		//default:
		//	fmt.Println("所有管道都不可用")
	}
}

func send(ch chan int, i int) {
	ch <- i

}

加上for 死循环可实现一直监听 chan但是会造成死锁

package main

import "fmt"

func main() {
	chA := make(chan int)
	chB := make(chan int)
	chC := make(chan int)
	defer func() {
		close(chA)
		close(chB)
		close(chC)
	}()
	go send(chA, 1)
	go send(chB, 2)
	go send(chC, 3)
	for {
		select {
		case n, ok := <-chA:
			fmt.Println(n, ok)
		case n, ok := <-chB:
			fmt.Println(n, ok)
		case n, ok := <-chC:
			fmt.Println(n, ok)
			//default:
			//	fmt.Println("所有管道都不可用")
		}
	}
}

func send(ch chan int, i int) {
	ch <- i
}

添加跳出for循环的条件 超时1秒钟 <-time.After(time.Second) 和使用标签进行跳出循环

<-time.After(time.Second) 是一个只读管道 1秒钟后可读取

package main

import "fmt"

func main() {
	chA := make(chan int)
	chB := make(chan int)
	chC := make(chan int)
	defer func() {
		close(chA)
		close(chB)
		close(chC)
	}()
	go send(chA, 1)
	go send(chB, 2)
	go send(chC, 3)
Loop:
	for {
		select {
		case n, ok := <-chA:
			fmt.Println(n, ok)
		case n, ok := <-chB:
			fmt.Println(n, ok)
		case n, ok := <-chC:
			fmt.Println(n, ok)
		case n,ok:=<-time.After(time.Second)
			fmt.Println("超时1秒,跳出死循环")
			break Loop
		}
	}
}

func send(ch chan int, i int) {
	ch <- i
}

通过for循环配合select来一直监测三个管道是否可以用,并且第四个case是一个超时管道,超时过后便会退出循环,结束子协程。

B 2 true
C 3 true
A 1 true
2023-06-02 16:13:47.8727058 +0800 CST m=+1.002683901 true
超时1秒跳出循环
超时

上一个例子用到了time.After函数,其返回值是一个只读的管道,该函数配合select使用可以非常简单的实现超时机制,

func main() {
	chA := make(chan int)
	defer close(chA)
	go func() {
		time.Sleep(time.Second * 2)//2秒后进行chA的写入
		chA <- 1
	}()
	select {
	case n := <-chA:
		fmt.Println(n)
	case <-time.After(time.Second)://超出1秒 进入该case 
		fmt.Println("超时")
	}
}
永久阻塞

select语句中什么都没有时,就会永久阻塞,例如

func main() {
	fmt.Println("start")
	select {}
	fmt.Println("end")
}
//start
//fatal error: all goroutines are asleep - deadlock! 死锁

selectcase中对值为nil的管道进行操作的话,并不会导致阻塞,该case则会被忽略,永远也不会被执行。例如下方代码无论执行多少次都只会输出timeout。

func main() {
   var nilCh chan int //没有对nilCh进行初始化 为nil
   select {
   case <-nilCh:
      fmt.Println("read")
   case nilCh <- 1:
      fmt.Println("write")
   case <-time.After(time.Second):
      fmt.Println("timeout")
   }
}

waitGroup

sync.WaitGroupsync包下提供的一个结构体,WaitGroup即等待执行,使用它可以很轻易的实现等待一组协程的效果。该结构体只对外暴露三个方法。

Add方法用于指明要等待的协程的数量

func (wg *WaitGroup) Add(delta int)

Done方法表示当前协程已经执行完毕

func (wg *WaitGroup) Done()

Wait方法等待子协程结束,否则就阻塞

func (wg *WaitGroup) Wait()
基础
package main

import (
	"fmt"
	"sync"
)

func main() {
	wait := sync.WaitGroup{}
	// 指定子协程的数量
	wait.Add(1) //1
	go func() {//每当一个协程执行完毕时调用Done,计数就-1,直到减为0
		fmt.Println(1)
		wait.Done() //-1
	}()
	wait.Wait() //等待协程结束
	fmt.Println("协程结束了")
}

//

案例

package main

import (
   "fmt"
   "sync"
)

func main() {
   wait := sync.WaitGroup{}
   wait1 := sync.WaitGroup{}
   wait.Add(10)

   for i := 0; i < 10; i++ {
      wait1.Add(1) //每次循环创建wait1 协程+1
      go func() {
         fmt.Println("子协程第", i, "次结束")
         wait1.Done() //-1 协程执行完一次 wait1 协程-1 变成0
         wait.Done()  //-1 协程执行完一次 wait 协程-1
      }()
      //每次等待wait1 协程执行完毕
      wait1.Wait()
   }
   //主协程 等待wait执行完毕
   wait.Wait()
   fmt.Println("全部协程结束完毕")

}
子协程第 0 次结束
子协程第 1 次结束
子协程第 2 次结束
子协程第 3 次结束
子协程第 4 次结束
子协程第 5 次结束
子协程第 6 次结束
子协程第 7 次结束
子协程第 8 次结束
子协程第 9 次结束
全部协程结束完毕 

WaitGroup通常适用于可动态调整协程数量的时候,例如事先知晓协程的数量,又或者在运行过程中需要动态调整

WaitGroup的值不应该被复制,复制后的值也不应该继续使用,尤其是将其作为函数参数传递时,因该传递指针而不是值。倘若使用复制的值,计数完全无法作用到真正的WaitGroup上,这可能会导致主协程一直阻塞等待,程序将无法正常运行

func main() {
	var mainWait sync.WaitGroup
	mainWait.Add(1)
	hello(mainWait)
	mainWait.Wait()
	fmt.Println("end")
}
func hello(wait sync.WaitGroup) {
	fmt.Println("hello")
	wait.Done()
}

错误提示所有的协程都已经退出,但主协程依旧在等待,这就形成了死锁,因为hello函数内部对一个形参WaitGroup调用Done并不会作用到原来的mainWait上,所以应该使用指针来进行传递。

hello
fatal error: all goroutines are asleep - deadlock! 
注意:

当计数变为负数,或者计数数量大于子协程数量时,将会引发panic

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值