《GO语言圣经》读书笔记(七):Goroutines和Channels

文章目录

    • 使用goroutine
      • 启动单个goroutine
      • 启动多个goroutine
    • channel
      • channel类型
      • 创建channel
      • channel操作
        • 发送
        • 接收
        • 关闭
      • 无缓冲的通道
      • 有缓冲的通道
      • 判断通道是否关闭的两种方法
      • 单向通道
      • 通道总结
    • worker pool(goroutine池)
    • select多路复用

写在前面:本节读书笔记对应原书第八章。

​ Go中的并发程序可以用两种手段来实现,第八章主要是围绕goroutinechannel展开讲的,他们都支持顺序通信进程(CSP)。

CSP是一种现代的并发编程模型, 在这种编程模型中值会在不同的运行实例(goroutine)中传递 。

goroutine的概念类似于线程,当需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了,就是这么简单粗暴。

使用goroutine

​ Go语言中使用goroutine非常简单,只需要在调用函数的时候在前面加上go关键字,就可以为一个函数创建一个goroutine

​ 一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数。特别的,当我们启动程序的时候,main函数也是在一个单独的go routine中运行的,称之为main goroutine

启动单个goroutine

​ 启动goroutine的方式非常简单,只需要在调用的函数(普通函数和匿名函数)前面加上一个go关键字。

​ 举个例子如下:

func hello() {
	fmt.Println("Hello Goroutine!")
}
func main() {
	hello()
	fmt.Println("main goroutine done!")
}

​ 这个示例中hello函数和下面的语句是串行的,执行的结果是打印完Hello Goroutine!后打印main goroutine done!

​ 接下来我们在调用hello函数前面加上关键字go,也就是启动一个goroutine去执行hello这个函数。

func main() {
	go hello() // 启动另外一个goroutine去执行hello函数
	fmt.Println("main goroutine done!")
}

​ 这一次的执行结果只打印了main goroutine done!,并没有打印Hello Goroutine!。为什么呢?

​ 在程序启动时,Go程序就会为main()函数创建一个默认的goroutine

​ 当main()函数返回的时候该goroutine就结束了,所有在main()函数中启动的goroutine会一同结束,除了主函数退出或者终止程序之外,没有其他方法在外部强制结束一个正在执行goroutine,但还有一些其他方法可以去思考,比如说goroutine之间通过channel进行通信,这里先不展开记录了,先mark下->goroutine退出方式的总结。

​ 所以我们要想办法让main函数等一等hello函数,最简单粗暴的方式就是time.Sleep了。

func main() {
	go hello() // 启动另外一个goroutine去执行hello函数
	fmt.Println("main goroutine done!")
	time.Sleep(time.Second)
}

​ 执行上面的代码你会发现,这一次先打印main goroutine done!,然后紧接着打印Hello Goroutine!

​ 首先为什么会先打印main goroutine done!是因为我们在创建新的goroutine的时候需要花费一些时间,而此时main函数所在的goroutine是继续执行的。

启动多个goroutine

​ 在Go语言中实现并发就是这样简单,我们还可以启动多个goroutine。让我们再来一个例子: (这里使用了sync.WaitGroup来实现goroutine的同步)

var wg sync.WaitGroup

func hello(i int) {
	defer wg.Done() // goroutine结束就登记-1
	fmt.Println("Hello Goroutine!", i)
}
func main() {

	for i := 0; i < 10; i++ {
		wg.Add(1) // 启动一个goroutine就登记+1
		go hello(i)
	}
	wg.Wait() // 等待所有登记的goroutine都结束
}

​ 多次执行上面的代码,会发现每次打印的数字的顺序都不一致。这是因为10个goroutine是并发执行的,而goroutine的调度是随机的。

channel

​ goroutine实现的是go中的并发,channel则是不同goroutine之间的通信机制。或许一开始也会疑惑,用共享内存进行数据交换不就行了,当看到第九章的时候,就会意识到共享内存在不同的goroutine中容易发生竞态问题,这可能需要采取互斥等方式保证数据交换的正确性,互斥是不是要加锁,加锁有对性能有影响,所以还是用channel吧。

​ 说清楚了这个,也就不难明白为什么笔记的开篇说,Go语言的并发模型是`CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信

​ Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。

channel类型

channel是一种类型,一种引用类型。声明通道类型的格式如下:

var 变量 chan 元素类型

​ 举几个例子:

var ch1 chan int   // 声明一个传递整型的通道
var ch2 chan bool  // 声明一个传递布尔型的通道
var ch3 chan []int // 声明一个传递int切片的通道

创建channel

​ 通道是引用类型,通道类型的空值是nil

var ch chan int
fmt.Println(ch) // 

​ 声明的通道后需要使用make函数初始化之后才能使用。

​ 创建channel的格式如下:

make(chan 元素类型, [缓冲大小])

​ channel的缓冲大小是可选的。

举几个例子:

ch4 := make(chan int)
ch5 := make(chan bool)
ch6 := make(chan []int)

channel操作

​ 通道有发送(send)、接收(receive)和关闭(close)三种操作。

​ 发送和接收都使用<-符号。

​ 现在我们先使用以下语句定义一个通道:

ch := make(chan int)

ch的类型就是chan int,表示一个可以发送int类型数据的channel。channel对应一个make创建的底层数据结构的引用,当我们复制一个channel或用于函数传参的时候,会拷贝一个channel引用,因此调用者和被调用者引用的都是同一个channel对象。

​ 两个相同类型的channel可以使用==运算符比较,如果两个channel引用的是相同的对象,那么比较结果为真,当然channel可以和nil进行比较。

发送

​ 将一个值发送到通道中。

ch <- 10 // 把10发送到ch中

接收

​ 从一个通道中接收值。

x := <- ch // 从ch中接收值并赋值给变量x
<-ch       // 从ch中接收值,忽略结果,这也是合法的

关闭

​ 我们通过调用内置的close函数来关闭通道。

close(ch)

​ 关于关闭通道需要注意的事情是,只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。

关闭通道有以下特点:

  1. 对一个关闭的通道再发送值就会导致panic。
  2. 对一个关闭的通道进行接收会一直获取值直到通道为空。
  3. 对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值
  4. 关闭一个已经关闭的通道会导致panic。
  5. 不管一个channel是否被关闭,当它没有被引用的时候,将会被go语言的垃圾自动回收期回收。
  6. 不要试图重复关闭一个通道,这会导致panic,关闭nil值的channel也会导致panic异常。
  7. 关闭channel会触发一个广播机制,后面会说到。

无缓冲的通道

​ 无缓冲的通道又称为阻塞的通道。我们来看一下下面的代码:

func main() {
	ch := make(chan int)
	ch <- 10
	fmt.Println("发送成功")
}

​ 上面这段代码能够通过编译,但是执行的时候会出现以下错误:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
        /root/go/src/TestDemoServer/TestDemoServer/main.go:19 +0x54
exit status 2

​ 为什么会出现deadlock错误呢?

​ 因为我们使用ch := make(chan int)创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值。就像你住的小区没有快递柜和代收点,快递员给你打电话必须要把这个物品送到你的手中,简单来说就是无缓冲的通道必须有接收才能发送。

​ 上面的代码会阻塞在ch <- 10这一行代码形成死锁,那如何解决这个问题呢?

​ 一种方法是启用一个goroutine去接收值,例如:

func recv(c chan int) {
	ret := <-c
	fmt.Println("接收成功", ret)
}
func main() {
	ch := make(chan int)
	go recv(ch) // 启用goroutine从通道接收值
	ch <- 10
	fmt.Println("发送成功")
}

​ 无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。

​ 使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道

有缓冲的通道

​ 解决上面问题的方法还有一种就是使用有缓冲区的通道。我们可以在使用make函数初始化通道的时候为其指定通道的容量,例如:

func main() {
	ch := make(chan int, 1) // 创建一个容量为1的有缓冲区通道
	ch <- 10
	fmt.Println("发送成功")
}

​ 只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量。

​ 如果此时再将一个值发送到通道中,会再次造成deadlock问题,这是为什么呢,明明已经使用了有缓冲区的通道啊,这是因为我们创建的通道容量大小为1,并且我们没有接收上一次发送到通道的值,此时通道没有多余的容量放下第二个值了。

​ 就好比小区的快递柜一共有一个格子,唯一的格子已经放了快递,只有别人取走这个快递之后,快递小哥才能往里再放一个。

​ 所以:我们可以将缓冲区通道容量扩大,或者及时接收通道中的值。

  • 使用内置的len函数获取通道内元素的数量
  • 使用cap函数获取通道的容量

判断通道是否关闭的两种方法

​ 当通过通道发送有限的数据时,我们可以通过close函数关闭通道来告知从该通道接收值的goroutine停止等待。当通道被关闭时,往该通道发送值会引发panic,从该通道里接收的值一直都是类型零值。那如何判断一个通道是否被关闭了呢?

func main() {
	ch := make(chan int, 1)
	ch <- 10
	ret := <-ch
	fmt.Println("[未关闭通道]发送成功", ret)
	close(ch)
	ret = <-ch
	fmt.Println("[关闭通道]", ret)
}

输出:

[未关闭通道]发送成功 10
[关闭通道] 0

​ 我们来看下面这个例子:

func main() {
	ch1 := make(chan int)
	ch2 := make(chan int)
	// 开启goroutine将0~100的数发送到ch1中
	go func() {
		for i := 0; i < 100; i++ {
			ch1 <- i
		}
		close(ch1)
	}()
	// 开启goroutine从ch1中接收值,并将该值的平方发送到ch2中
	go func() {
		for {
			i, ok := <-ch1 // 通道关闭的话,ok取值为false
			if !ok {
				break
			}
			ch2 <- i * i
		}
		close(ch2)
	}()
	// 在主goroutine中从ch2中接收值打印
	for i := range ch2 { // 通道关闭后会退出for range循环
		fmt.Println(i)
	}
}

​ 上面的例子中出现了两种方式判断通道是否关闭:

  • 接收时,多接受一个结果okok是个布尔值,true表示从通道中接收到值,false表示通道关闭并且没有值可以接收。
  • 上面这种方式有些复杂,可以利用go中的range循环直接迭代通道。一次从channel接收数据,当channel被关闭并且没有值可以接受的时候自动结束循环。

单向通道

​ 有的时候我们会将通道作为参数在多个任务函数间传递,很多时候我们在不同的任务函数中使用通道都会对其进行限制,比如限制通道在函数中只能发送或只能接收。

​ Go语言中提供了单向通道来处理这种情况。例如,我们把上面的例子改造如下:

func counter(out chan<- int) {
	for i := 0; i < 100; i++ {
		out <- i
	}
	close(out)
}

func squarer(out chan<- int, in <-chan int) {
	for i := range in {
		out <- i * i
	}
	close(out)
}
func printer(in <-chan int) {
	for i := range in {
		fmt.Println(i)
	}
}

func main() {
	ch1 := make(chan int)
	ch2 := make(chan int)
	go counter(ch1)
	go squarer(ch2, ch1)
	printer(ch2)
}

其中,

  • chan<- int是一个只写单向通道(只能对其写入int类型值),可以对其执行发送操作但是不能执行接收操作;
  • <-chan int是一个只读单向通道(只能从其读取int类型值),可以对其执行接收操作但是不能执行发送操作。

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

通道总结

channel常见的异常总结,如下图:《GO语言圣经》读书笔记(七):Goroutines和Channels_第1张图片

​ 关闭已经关闭的channel也会引发panic

​ 书上内容的补充:

  • 基于channels发送消息有两个重要方面。 首先每个消息都有一个值, 但是有时候通讯的事实和发生的时刻也同样重要。 当我们更希望强调通讯发生的时刻时, 我们将它称为消息事件。
  • 有些消息事件并不携带额外的信息, 它仅仅是用作两个goroutine之间的同步, 这时候我们可以用 struct{} 空结构体作为channels元素的类型, 虽然也可以使用bool或int类型实现同样的功能, done <- 1 语句也比 done <- struct{}{} 更短。

worker pool(goroutine池)

​ 在工作中我们通常会使用可以指定启动的goroutine数量–worker pool模式,控制goroutine的数量,防止goroutine泄漏和暴涨。

​ 一个简易的work pool示例代码如下:

func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		fmt.Printf("worker:%d start job:%d\n", id, j)
		time.Sleep(time.Second)
		fmt.Printf("worker:%d end job:%d\n", id, j)
		results <- j * 2
	}
}


func main() {
	jobs := make(chan int, 100)
	results := make(chan int, 100)
	// 开启3个goroutine
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}
	// 5个任务
	for j := 1; j <= 5; j++ {
		jobs <- j
	}
	close(jobs)
	// 输出结果
	for a := 1; a <= 5; a++ {
		<-results
	}
}

输出:

worker:3 start job:1
worker:2 start job:2
worker:1 start job:3
worker:3 end job:1
worker:3 start job:4
worker:1 end job:3
worker:1 start job:5
worker:2 end job:2
worker:1 end job:5
worker:3 end job:4

select多路复用

​ 在某些场景下我们需要同时从多个通道接收数据。通道在接收数据时,如果没有数据可以接收将会发生阻塞。你也许会写出如下代码使用遍历的方式来实现:

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

​ 这种方式虽然可以实现从多个通道接收值的需求,但是运行性能会差很多。为了应对这种场景,Go内置了select关键字,可以同时响应多个通道的操作。

select的使用类似于switch语句,它有一系列case分支和一个默认的分支。每个case会对应一个通道的通信(接收或发送)过程。select会一直等待,直到某个case的通信操作完成时,就会执行case分支对应的语句。如果没有任何caseselect语句写作select{},会永远等待下去。

​ 好了,问题来了,如果多个case都满足,那要执行哪一个呢?答案是挑一个随机执行,这样每一个channel都有相同的机会。

​ 使用select的具体格式如下:

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

​ 当我们希望从channel中发送或者接收值的时候,有可能会出现channel没有准备好写或者读时,我们可以通过default设置当其他的操作都不能够马上被处理时程序需要执行哪些逻辑。

​ 举个小例子来演示下select的使用:

func main() {
	ch := make(chan int, 1)
	for i := 0; i < 10; i++ {
		select {
		case x := <-ch:
			fmt.Println(time.Now().Format("2006-01-02 15:04:05"), " [接收来自通道中的值]:", x)
		case ch <- i:
			fmt.Println(time.Now().Format("2006-01-02 15:04:05"), " [向通道中发送msg]:", i)
		}
	}
}

输出:

2020-06-24 19:38:23  [向通道中发送msg]: 0
2020-06-24 19:38:23  [接收来自通道中的值]: 0
2020-06-24 19:38:23  [向通道中发送msg]: 2
2020-06-24 19:38:23  [接收来自通道中的值]: 2
2020-06-24 19:38:23  [向通道中发送msg]: 4
2020-06-24 19:38:23  [接收来自通道中的值]: 4
2020-06-24 19:38:23  [向通道中发送msg]: 6
2020-06-24 19:38:23  [接收来自通道中的值]: 6
2020-06-24 19:38:23  [向通道中发送msg]: 8
2020-06-24 19:38:23  [接收来自通道中的值]: 8

使用select语句能提高代码的可读性。

  • 可处理一个或多个channel的发送/接收操作。
  • 如果多个case同时满足,select会随机选择一个。
  • 对于没有caseselect{}会一直等待,可用于阻塞main函数。

​ 说到这里,要提一下,之前介绍说channel的零值为nil,咦?对一个nilchannel发送和接收操作会永远阻塞!针对这个特性,在select语句中操作nilchannel是不是永远也不会被select到了呢!

​ 基于此,我们可以通过nil去激活或者禁用case,来完成处理输入输出事件超时和取消的逻辑。

4
2020-06-24 19:38:23 [向通道中发送msg]: 6
2020-06-24 19:38:23 [接收来自通道中的值]: 6
2020-06-24 19:38:23 [向通道中发送msg]: 8
2020-06-24 19:38:23 [接收来自通道中的值]: 8


​	

使用`select`语句能提高代码的可读性。

- 可处理一个或多个channel的发送/接收操作。
- 如果多个`case`同时满足,`select`会随机选择一个。
- 对于没有`case`的`select{}`会一直等待,可用于阻塞main函数。


​     说到这里,要提一下,之前介绍说`channel`的零值为nil,咦?对一个`nil`的`channel`发送和接收操作会永远阻塞!针对这个特性,在`select`语句中操作`nil`的`channel`是不是永远也不会被`select`到了呢!

​	基于此,我们可以通过`nil`去激活或者禁用`case`,来完成处理输入输出事件超时和取消的逻辑。



















​	

你可能感兴趣的:(Golang)