16-Go语言并发之goroutine,channel

基本概念

首先我们先了解几个与并发编程相关的概念。

进程:程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立的单位。

线程:操作系统调度执行的最小单位。

协程:非操作系统提供而是由用户自行创建和控制的用户态线程,比线程更轻量级。

Go语言中的并发程序主要是通过基于CSP的goroutine和channel来实现,当然也支持使用传统的多线程共享内存的并发方式。

goroutine

Goroutine是GO语言支持并发的核心,在一个GO程序中同时创建成百上千个goroutine是非常普遍的,一个goroutine会以一个很小的栈开始其生命周期,一般只需要2kb。区别于操作系统线程由系统内核进行调度,goroutine是由Go运行时负责调度。例如GO运行时会智能地将m个goroutine合理分配给n个操作系统线程,实现类似m:n的调度机制,不再需要Go开发者自行在代码层面维护一个线程池。

goroutine是Go程序中最基本的并发执行单元。每一个GO程序都至少包含一个goroutine-- main goroutine,当Go程序启动时他会自动创建。

在GO语言编程中你不需要去自己写进程,线程,协程,你的技能包只有一个技能—goroutine,当你需要让某个任务并发执行,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了。

go关键字

GO语言中使用goroutine非常简单,只需要在函数或方法调用前加上go关键字就可以创建一个goroutine,从而让该函数或方法在新创建的goroutine中执行。

go f()  //创建一个新的goroutine运行函数f

匿名函数也支持使用go关键字创建goroutine去执行。

go func(){
  //...
}()

一个goroutine必定对应一个函数/方法,可以创建多个goroutine去执行相同的函数/方法。

启动单个goroutine

启动goroutine的方式非常简单,只需要在调用函数前加一个go关键字。

我们先来看一个在main函数中执行普通函数调用的示例。

package main

import (
	"fmt"
)

func hello() {
	fmt.Println("hello")
}

func main() {
	hello()
	fmt.Println("你好")
}

//结果
hello
你好

代码中 hello 函数和其后面的打印语句是串行的。

main goroutine

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

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

//运行结果
你好

这一次的执行结果只在终端打印了”你好”,并没有打印 hello。这是为什么呢?

其实在 Go 程序启动时,Go 程序就会为 main 函数创建一个默认的 goroutine 。在上面的代码中我们在 main 函数中使用 go 关键字创建了另外一个 goroutine 去执行 hello 函数,而此时 main goroutine 还在继续往下执行,我们的程序中此时存在两个并发执行的 goroutine。当 main 函数结束时整个程序也就结束了,同时 main goroutine 也结束了,所有由 main goroutine 创建的 goroutine 也会一同退出。也就是说我们的 main 函数退出太快,另外一个 goroutine 中的函数还未执行完程序就退出了,导致未打印出“hello”。

所以我们要想办法让 main 函数‘“等一等”将在另一个 goroutine 中运行的 hello 函数。其中最简单粗暴的方式就是在 main 函数中“time.Sleep”一秒钟了(这里的1秒钟只是我们为了保证新的 goroutine 能够被正常创建和执行而设置的一个值)。

按如下方式修改我们的示例代码。

package main

import (
	"fmt"
  "time"
)

func hello(){
  fmt.Println("hello")
}

func main(){
  go hello()
  fmt.Println("你好")
  time.Sleep(time.Second)
}

//运行结果
你好
hello

为什么会先打印你好呢?

这是因为在程序中创建goroutine执行函数需要一定的开销,而与此同时main函数所在的gorputine是继续执行的。

main goroutine和hello goorutine

在上面的程序中使用time.Sleep让main goroutine等待hello goroutine执行结束是不优雅的,当然也是不准确的。

GO语言中通过sync包为我们提供了一些常用的并发原语,会在后面章节中单独介绍sync 包中的内容。在这一小节,我们先介绍下sync包的waitGroup。当你并不关心并不关心并发操作的结果或者有其他方式收集并发操作的结果时,waitGroup是实现等待一组并发操作完成的好方法。

package main

import (
	"fmt"
	"sync"
)

//声明全局等待组变量
var wg sync.WaitGroup

func hello() {
	fmt.Println("hello")
	wg.Done() //告知当前goroutine完成
}

func main() {
	wg.Add(1) //登记一个gooroutine
	go hello()
	fmt.Println("你好")
	wg.Wait() //阻塞等待登记的goroutine完成
}

//运行结果
你好
hello

将代码编译后再执行,得到的输出结果和之前一致,但是这一次程序不再会有多余的停顿,hello goroutine 执行完毕后程序直接退出。

启动多个goroutine

在Go语言中实现并发就是这么简单,我们还可以启动多个goroutine。让我们再来看一个新的代码示例。这里同样使用了sync.WaitGroup来实现goroutine的同步。

package main

import (
	"fmt"
	"sync"
)

//声明全局等待组变量
var wg sync.WaitGroup

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

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


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

动态栈

操作系统的线程一般都有固定的栈内存(通常为2MB),而go语言中的goroutine是非常轻量级的,一个goroutine的初始栈空间很小(一般为2KB),所以在GO语言中一次创建数万个goroutine也是可能的。并且goroutine的栈不是固定的,可以根据需要动态的增加或者缩小,go 的runtime会自动为goroutine分配合适的栈空间。

goroutine调度

操作系统的线程会被操作系统内核调度时会挂起当前执行的线程并将它的寄存器内容保存到内存中,选出下一次要执行的线程并从内存恢复该线程的寄存器信息,然后开始执行该线程。从一个线程切换到另外一个线程需要完整的上下文切换。因为需要多次内存访问,上下文切换开销比较大,会增加运行cpu的周期。

区别于操作系统内核调度操作系统线程,goroutine的调度是GO语言的runtime层面的实现,是完全由GO语言本身实现的一套调度系统—go scheduler。它的作用是按照一定规则将所有的goroutine调度到系统线程执行。

目前GO语言的调度器采用的是GPM调度模型。

gpm

其中:

  • G:表示 goroutine,每执行一次go f()就创建一个 G,包含要执行的函数和上下文信息。
  • 全局队列(Global Queue):存放等待运行的 G。
  • P:表示 goroutine 执行所需的资源,最多有 GOMAXPROCS 个。
  • P 的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建 G 时,G 优先加入到 P 的本地队列,如果本地队列满了会批量移动部分 G 到全局队列。
  • M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,当 P 的本地队列为空时,M 也会尝试从全局队列或其他 P 的本地队列获取 G。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。
  • Goroutine 调度器和操作系统调度器是通过 M 结合起来的,每个 M 都代表了1个内核线程,操作系统调度器负责把内核线程分配到 CPU 的核上执行。

单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的, goroutine 则是由Go运行时(runtime)自己的调度器调度的,完全是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身 goroutine 的超轻量级,以上种种特性保证了 goroutine 调度方面的性能。

GOMAXPROCS

Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个 OS 线程来同时执行 Go 代码。默认值是机器上的 CPU 核心数。例如在一个 8 核心的机器上,GOMAXPROCS 默认为 8。Go语言中可以通过runtime.GOMAXPROCS函数设置当前程序并发时占用的 CPU逻辑核心数。(Go1.5版本之前,默认使用的是单核心执行。Go1.5 版本之后,默认使用全部的CPU 逻辑核心数。)

练习
  1. 请写出下面程序的执行结果。
   for i := 0; i < 5; i++ {
   	go func() {
   		fmt.Println(i) //这个i引用的是外部的for循环的i,执行到这一句的时候再去访问i的值,此时i是多少不确定。
   	}()
   }

//第一次运行
5
5
3
5
5
//第二次运行
4
5
5
5
5


//分析:
因为要创建一个goroutine需要时间,当某个gouroutine执行到print的时候,外层for循环可能已经执行i为多少不确定。

变形1:

for i := 0; i < 5; i++ {
		go func(i int) {
			defer wg.Done()
			fmt.Println(i)
		}(i)
	}
//第一次运行
4
3
1
0
2
//第二次运行
4
1
3
0
2

//分析
会打印出0 1 2 3 4这几个数字,但是顺序不确定,取决了哪个goroutine先调度。
channel

单纯的将函数并发执行是没有任何意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。

Go语言采用的并发模型是CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信

如果说 goroutine 是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个 goroutine 发送特定值到另一个 goroutine 的通信机制。

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

channel类型

channel是GO语言中一种特有的类型。声明通道类型变量格式如下:

var 变量名称 chan 元素类型

其中:

  • chan:是关键字
  • 元素类型:是指通道中传递元素的类型

举几个例子:

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

未初始化的通道类型变量其默认零值是nil

var ch chan int
fmt.Println(ch) // <nil>
初始化channel

声明的通道类型变量需要使用内置的make函数初始化之后才能使用。具体格式如下:

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

其中:

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

举几个例子:

ch4:= make(chan int)
ch5:= make(chan bool,1) //声明一个缓冲区大小为1的通道
channel操作

通道共有发送(send)、接收(receive)和关闭(close)三种操作。而发送和接收操作都是用<-符号。

//定义一个通道
ch:=make(chan int)

//发送
//将一个值发送到通道中。
ch <- 10 //把10发送到ch中

//接收
//从一个通道接收值
x:= <- ch  //从ch中接收值并赋值给变量x
<-ch //从ch中接收值,忽略结果。

//关闭
close(ch) 

注意:一个通道值是可以被垃圾回收的。通常是由发送方关闭操作,并且只有在接收方明确等待通道关闭的信号时才需要执行关闭操作。他和关闭文件不一样,关闭通道不是必须的。

关闭后的通道有以下特点:

  1. 对一个关闭的通道再发送值就会导致 panic。
  2. 对一个关闭的通道进行接收会一直获取值直到通道为空。
  3. 对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
  4. 关闭一个已经关闭的通道会导致 panic。
无缓冲通道

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

func chanDemo2() {
	ch := make(chan int)
	ch <- 10
	fmt.Println("发送成功")
}
//运行结果
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:

deadlock表示我们程序中的 goroutine 都被挂起导致程序死锁了。为什么会出现deadlock错误呢?

因为我们使用ch := make(chan int)创建的是无缓冲的通道,无缓冲的通道只有在有接收方能够接收值的时候才能发送成功,否则会一直处于等待发送的阶段。同理,如果对一个无缓冲通道执行接收操作时,没有任何向通道中发送值的操作那么也会导致接收操作阻塞。就像田径比赛中的4x100接力赛,想要完成交棒必须有一个能够接棒的运动员,否则只能等待。简单来说就是无缓冲的通道必须有至少一个接收方才能发送成功。

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

func recv(c chan int) {
	ret := <-c
	fmt.Println("接收成功", ret)
}

func chanDemo2() {
	ch := make(chan int)
	go recv(ch) //创建一个goroutine接收值
	ch <- 10
	fmt.Println("发送成功")
}

//运行结果
接收成功 10
发送成功

首先无缓冲通道ch上发送的操作会阻塞,直到另一个gotourine在该通道上执行接收操作,这时数字10才能发送成功,两个goroutine将继续执行。相反,如果接收方先执行,接收方所在的goroutine将阻塞,直到main goroutine中向该通道发送数字10.

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

有缓冲通道

还有另外一种解决上面死锁问题的方法,那就是使用有缓冲通道,我们可以make初始化通道,为其指定通道的容量:

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

只要通道的容量大于0,那么就是有缓冲通道,通道的容量表示通道能存放的元素的数量。当通道内已有元素数达到最大容量后,再向通道执行发送操作就会阻塞,除非有从通道执行接收操作。

多返回值模式

当向通道中发送完数据时,我们可以通过close函数关闭通道。当一个通道关闭后,再往该通道发送值会引发panic,从该通道取值的操作会先取完通道中的值。通道内的值被接收完后再对通道执行接收操作得到的值会一直都是对应元素类型的零值。那我们如何判断一个通道是否被关闭了呢?

对一个通道执行接收操作时支持使用如下多返回值模式。

value,ok:=<-ch

其中:

  • value:从通道中取出已经有的值,取完值后,如果通道被关闭则返回对应类型的零值。
  • ok:通道ch关闭,通道先取已经有的值,此时为true.取完所有的值后返回对应类型的零值,此时为false
func recv2(ch chan int) {
	for {
		v, ok := <-ch
		if !ok {
			fmt.Println(v, ok)
			break
		}
		fmt.Println(v, ok)

	}
}

func chanDemo3() {
	ch := make(chan int, 2)
	ch <- 1
	ch <- 2
	close(ch)
	recv2(ch)

}
//运行结果
1 true
2 true
0 false
for range接收值

通常会选择for range循环从通道中接收值,当通道关闭后,会在通道内的所有值接收完毕后会自动退出循环。

func recv3(ch chan int) {
	for v := range ch {
		fmt.Println(v)
	}
}

func chanDemo3() {
	ch := make(chan int, 2)
	ch <- 1
	ch <- 2
	close(ch)
	recv3(ch)

}
//运行结果
1
2
单向通道

在某些场景下我们可能会将通道作为参数在多个任务函数间进行传递,通常我们会选择在不同的任务函数中对通道的使用进行限制,比如限制通道在某个函数中只能执行发送或只能执行接收操作。

//Producer返回一个通道
//并持续将符合条件的数据发送至通道中,
//数据发送完成后会将返回的通道关闭
func Producer() chan int {
	ch := make(chan int, 2)
	//创建一个新的goroutine执行发送数据的任务
	go func() {
		for i := 0; i < 10; i++ {
			if i%2 == 1 {
				ch <- i
			}
		}
		close(ch) //任务完成后关闭通道
	}()
	return ch
}

//Consumer从通道中接收数据进行计算
func Consumer(ch chan int) int {
	sum := 0
	for v := range ch {
		sum += v
	}
	return sum
}

func main(){
  ch := Producer()
	res := Consumer(ch)
	fmt.Println(res) //25
}

从上面的示例代码中可以看出正常情况下Consumer函数中只会对通道进行接收操作,但是这不代表不可以在Consumer函数中对通道进行发送操作。作为Producer函数的提供者,我们在返回通道的时候可能只希望调用方拿到返回的通道后只能对其进行接收操作。但是我们没有办法阻止在Consumer函数中对通道进行发送操作。

Go语言中提供了单向通道来处理这种需要限制通道只能进行某种操作的情况。

<- chan int // 只接收通道,只能接收不能发送
chan <- int // 只发送通道,只能发送不能接收

其中,箭头<-和关键字chan的相对位置表明了当前通道允许的操作,这种限制将在编译阶段进行检测。另外对一个只接收通道执行close也是不允许的,因为默认通道的关闭操作应该由发送方来完成。

我们使用单向通道将上面的示例代码进行如下改造。

//Producer2 返回一个接收通道
func Producer2() <-chan int {
	ch := make(chan int, 2)
	//创建一个goroutine执行发送数据的任务
	go func() {
		for i := 0; i < 10; i++ {
			if i%2 == 1 {
				ch <- i
			}
		}
		close(ch)
	}()
	return ch
}

//Consumer2 参数为接收通道
func Consumer2(ch <-chan int) int {
	sum := 0
	for v := range ch {
		sum += v
	}
	return sum
}

这一次,Producer函数返回的是一个只接收通道,这就从代码层面限制了该函数返回的通道只能进行接收操作,保证了数据安全。

在函数传参及任何赋值操作中全向通道(正常通道)可以转换为单向通道,但是无法反向转换。

var ch3 = make(chan int, 1)
ch3 <- 10
close(ch3)
Consumer2(ch3) // 函数传参时将ch3转为单向通道

var ch4 = make(chan int, 1)
ch4 <- 10
var ch5 <-chan int // 声明一个只接收通道ch5
ch5 = ch4          // 变量赋值时将ch4转为单向通道
<-ch5
总结

下面的表格中总结了对不同状态下的通道执行相应操作的结果。

img

**注意:**对已经关闭的通道再执行 close 也会引发 panic。

select多路复用

Go语言中内置了select关键字,使用它可以同时响应多个通道的操作。

select的使用方式类似于之前学到的switch语句,它也有一系列case分支和一个默认的分支。每个case分支会对应一个通道的通信(接收或发送)过程。select会一直等待,直到其中的某个case的通信操作完成时,就会执行该case分支对应的语句。具体格式如下:

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

Select 语句具有以下特点。

  • 可处理一个或多个 channel 的发送/接收操作。
  • 如果多个 case 同时满足,select 会随机选择一个执行。
  • 对于没有 case 的 select 会一直阻塞,可用于阻塞 main 函数,防止退出。

下面示例打印10以内奇数:

func selectDemo1() {
	ch := make(chan int, 1)
	for i := 1; i < 10; i++ {
		select {
		case x := <-ch:
			fmt.Println(x)
		case ch <- i:
			fmt.Println("发送数据")
		}
	}
}


//运行结果
发送数据
1
发送数据
3
发送数据
5
发送数据
7
发送数据

示例中的代码首先是创建了一个缓冲区大小为1的通道 ch,在进入 for 循环后,此时 i = 1,select 语句中包含两个 case 分支,此时由于通道中没有值可以接收,所以x := <-c 这个 case 分支不满足,而ch <- i这个分支可以执行,会把1发送到通道中,结束本次 for 循环;第二次 for 循环时,i = 2,由于通道缓冲区已满,所以ch <- i这个分支不满足,而x := <-ch这个分支可以执行,从通道接收值1并赋值给变量 x ,所以会在终端打印出 1;后续的 for 循环同理会依次打印出3、5、7、9。

通道误用示例

示例1:

// demo1 通道误用导致的bug
func demo1() {
	wg := sync.WaitGroup{}

	ch := make(chan int, 10)
	for i := 0; i < 10; i++ {
		ch <- i
	}
	close(ch)

	wg.Add(3)
	for j := 0; j < 3; j++ {
		go func() {
			for {
				task := <-ch
				// 这里假设对接收的数据执行某些操作
				fmt.Println(task)
			}
			wg.Done()
		}()
	}
	wg.Wait()
  
  //分析:将上述代码编译执行后,匿名函数所在的 goroutine 并不会按照预期在通道被关闭后退出。因为task := <- ch的接收操作在通道被关闭后会一直接收到零值,而不会退出。此处的接收操作应该使用task, ok := <- ch,通过判断布尔值ok为假时退出;或者使用select 来处理通道。

示例2:

// demo2 通道误用导致的bug
func demo2() {
	ch := make(chan string)
	go func() {
		// 这里假设执行一些耗时的操作
		time.Sleep(3 * time.Second)
		ch <- "job result"
	}()

	select {
	case result := <-ch:
		fmt.Println(result)
	case <-time.After(time.Second): // 较小的超时时间
		return
	}
}

//分析
上述代码片段可能导致 goroutine 泄露(goroutine 并未按预期退出并销毁)。由于 select 命中了超时逻辑,导致通道没有消费者(无接收操作),而其定义的通道为无缓冲通道,因此 goroutine 中的ch <- "job result"操作会一直阻塞,最终导致 goroutine 泄露。

练习

  1. 使用 goroutine 和 channel 实现一个计算int64随机数各位数和的程序,例如生成随机数61345,计算其每个位数上的数字之和为19。
    1. 开启一个 goroutine 循环生成int64类型的随机数,发送到jobChan
    2. 开启24个 goroutine 从jobChan中取出随机数计算各位数的和,将结果发送到resultChan
    3. 主 goroutine 从resultChan取出结果并打印到终端输出。
package main

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

//定义job结构体
type job struct {
	value int64
}

//定义result结构体
type result struct {
	job *job
	sum int64
}

var wg sync.WaitGroup
var jobChan = make(chan *job, 100)
var resultChan = make(chan *result, 100)

//循环生成int64类型的随机数,发送到jobChan
func CreateRand(jobChan chan<- *job) {
	defer wg.Done()
	for {
		x := rand.Int63()
		jobChan <- &job{value: x}
	}
}

//从jobChan中取出随机数计算各位数的和,将结果发到resultchan
func SumResult(jobChan <-chan *job, resultChan chan<- *result) {
	defer wg.Done()
	for {
		job := <-jobChan
		sum := int64(0)
		v := (*job).value
		for v > 0 {
			sum += v % 10
			v = v / 10
		}
		resultChan <- &result{
			job: job,
			sum: sum,
		}
	}
}
func main() {
	//开启一个 goroutine 循环生成int64类型的随机数,发送到`jobChan`
	wg.Add(1)
	go CreateRand(jobChan)

	// 开启24个 goroutine 从`jobChan`中取出随机数计算各位数的和,将结果发送到`resultChan`
	wg.Add(24)
	go SumResult(jobChan, resultChan)

	//主 goroutine 从`resultChan`取出结果并打印到终端输出
	for resltSum := range resultChan {
		j := (*resltSum).job.value
		s := (*resltSum).sum
		fmt.Printf("随机数为%v,各个位数和为:%v\n", j, s)
	}
	wg.Wait()
}

未完待续…

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值