Go语言channel探究

前言

我们都知道goroutine是Go语言最大的主角。Go从语言层提供了一套非常优雅的并发方案,我们只需要使用关键字go就可以轻松开启一个协程,编写并发程序。

func main(){
    //开启一个子协程
    go func() {
        fmt.Println("开启协程")
    }() 
}

但是这个程序有个致命缺陷就是,子协程里的程序来不及打印,主协程就结束了。

要想让并发程序有条不紊的配合,还需要借助一些辅助工具进行协调,完成协程之间的通信和协调工作,这就引出了我今天要讲的内容 channel。

一、channel 简介

Go 语言中最常见的、也是经常被人提及的设计模式就是:
“不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存”。

这句话最直接和最重要的体现就是channel。

同步和通信

channel提供了一种机制。它既可以同步两个并发执行的函数,又可以让这两个函数通过相互传递特定类型的值来通信

虽然有些时候使用共享变量和传统的同步方法也可以实现以上用途,但是作为一个更高级的方法,使用channel可以让我们更加容易编写清晰、正确的程序。

通过共享内存通信

在很多主流的编程语言中,多个线程传递数据的方式一般都是共享内存,为了解决线程冲突的问题,我们需要进行加锁访问,如下图(多线程使用共享内存传递数据):
多线程使用共享内存传递数据
这与 Go 语言鼓励的方式并不相同。

通过通信共享内存

虽然我们在 Go 语言中也能使用共享内存加互斥锁进行通信,但是 Go 语言提供了一种不同的并发模型,具体是这样做的:

它使用通道(channel)在不同的goroutine之间传递值。
在这里插入图片描述
上图中的两个 Goroutine,一个会向 Channel 中发送数据,另一个会从 Channel 中接收数据,它们两者能够独立运行并不存在直接关联,但是能通过 Channel 间接完成通信。

二、channel的基本使用

声明

channel 是go语言定义的数据类型之一。

var intChan chan int

这声明了一个 int 类型的通 intChan。初始化之后就可以用来传递 int 型元素值了。

初始化

使用通道之前必须对其初始化。
通道类型的变量初始化之前是nil。

var intChan chan int 
intChan = make(chan int, 10) //这初始化了一个可以缓存10个元素的通道。

第二个参数为0或者不填,将初始化一个不带缓存容量的channel。

接收元素值

element,ok := <-intChan //使用两个变量接收
element := <-intChan //使用一个变量接收
<-intChan //单纯的接收

//三种写法都是允许的

要点:
1.通道没元素时,当前goroutine会被阻塞。
2.通道被关闭时,element可以接收到元素的0值,但可以根据第二个参数ok判断是否被关闭。
3.从未初始化的通道接收元素,会造成当前goroutine永久阻塞。
比如:

var intChan chan int
<- intChan //从未初始化的通道接收元素

发送元素值

var intChan chan int
intChan = make(chan int, 10)
intChan <- 1 

要点:
1.当通道内元素数达到最大容量,当前goroutine会被阻塞。

var intChan chan int
intChan = make(chan int, 2) //容量为2
intChan <- 1 //容量剩余1
intChan <- 1 //容量剩余0
intChan <- 1 //阻塞

2.向值为nil的通道发送元素时,当前goroutine会被永久阻塞。

var intChan chan int
intChan <- 1 //向未初始化的通道发送元素

3.向已关闭的通道发送元素时,会引发panic。

var intChan chan int
intChan = make(chan int, 2) //容量为2
close(intChan) //关闭通道
intChan <- 1 //引发panic

4.因同一通道阻塞的goroutine,当达到唤醒条件后,总是最早的那个优先被唤醒。
在这里插入图片描述
5.发送方 向通道发送的值会被复制,接收方接收的总是该值的副本,而不是该值本身。

空结构体channel

syncChan := make(chan struct{}, 1)
syncChan <- struct{}{}

空结构体值得一提。struct{}代表不包含任何字段的结构体,不占用内存空间。
建议传递信号的通道都以struct{}作为元素类型。

关闭通道

调用close()函数。

close(intChan)

要点:
1.向已关闭的通道发送值会引起panic。
2.可以从已关闭的通道取出未被取出的值,他们不会因为通道关闭而丢失。
3.你可以根据表达式的第二个返回值判断通道是否关闭且已无元素值可取出。
4.同一通道只能close一次,重复关闭会panic
5.close的通道未初始化也会panic

所以要确保不会再往通道发送值再关闭,无论如何不要在接收端关闭通道。

通道的操作特性

1.同步。同一时刻,只能有一个goroutine能向通道发送值,也只能有一个goroutine能从通道接收值。
2.先进先出。像一个队列。
3.元素具有原子性。只可能被一个goroutine接收。

单向通道

单向通道分为发送通道和接受通道。

无论如何它们不应该出现在变量声明中。

var uselessChan chan<- int = make(chan<-int, 10)

可以恰当的使用在方法和接口的定义中,以此来约束函数对通道使用规则。

func receive(strChan <-chan string) {}

如上,receive函数只能对strChan进行接受操作。因而receive函数中不允许关闭strChan。

for语句与channel

基本形式

var chanInt chan int
//省略若干语句
for e := range chanInt {
    fmt.Printf("Element:%v\n",e)
}

每次迭代都相当于执行了
<-ch 接收语句。所以规律和接收元素相同。
要点:
1.从未初始化的通道取值会永久阻塞。
2.通道中没有元素也会阻塞。 遍历一个不会再发送元素且忘记关闭的channel会造成阻塞,所以发送完记得关闭。
3.通道被关闭,for 语句将会在通道内元素值取完后会自动结束。

select语句

select典型用法:

var intChan = make(chan int, 10)
var strChan = make(chan string, 10)
//省略若干语句
select {
    case e1 := <- intChan:
        fmt.Printf("The 1th case was selected, e1=%v.\n", e1)
    case e2 := <- strChan:
        fmt.Printf("The 2nd case was selected, e2=%v.\n", e2)
    default:
       fmt.PrintLn("default")
}

一条 select 语句执行时,会选择其中某一个分支并执行。
代码编写上 select 与 switch 很像,但分支选择机制完全不同。

select 分支选择规则

1.首先所有跟在 case 关键字右边的语句都会先求值(从左往右,从上到下)。
在这里插入图片描述
在这里插入图片描述

2.运行时、判断每个case发送或接收的操作是否可以立即执行(不被阻塞)。然后从可以执行的case中随机地选择一个来执行。
3.如果所有case都不满足条件,且没有default case。那么当前goroutine就会被阻塞知道有一个case满足条件为止。

select 的常用用法

1.select 与 for 语句联用
通常我们需要持续操作其中的通道。
2.Select 与 定时器,我们可以方便的实现对接收操作的超时设定。
在这里插入图片描述

三、协程的同步

什么是同步

借用进程同步的概念来理解。同步亦称直接制约关系,它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而等待、传递信息所产生的制约关系。进程间的直接制约关系就是源于它们之间的相互合作。

个人理解:总的来说同步一方面用于管理并发程序的先后流程,另一方面用来维持共享数据的一致性

感受流程控制的重要性

不加以控制,子协程来不及执行就结束了

func main(){
    //开启子一个协程
    go func() {
        fmt.Println("开启协程")
    }() 
    //不加以控制,子协程来不及执行就结束了
}

等待单个协程结束

func main(){
    //用于同步的协程
    syncChan := make(chan struct{})
    //开启子一个协程
    go func() {
        fmt.Println("开启协程")
        syncChan <- struct{}{}
    }() 
    <- syncChan //阻塞住主协程,等待子协程执行完毕再继续。
}

对于等待n个协程退出再继续的情况。只需要将 syncChan 初始化为缓存为n的通道即可轻松实现。

另外Go也提供了更为方便使用的WaitGroup,可自行了解。

使用信道限制最大协程数

这里通道作为一个 semaphore 被使用。

package main

var (
    maxGoroutinueNum = 20 //最大协程数20
)

func doTask() {
	ch := make(chan struct{}, maxGoroutinueNum) //初始化,容量为20
	for {                       //不停的开启协程执行任务
         ch <- struct{}        //开启协程前塞入一个元素,如果通道满了,当前协程阻塞
         go func() {
             defer func() {
                 <- struct{}      //任务执行完,取出通道元素,相当于释放一个可开启的协程
             }()
             //do sth 你要做的事
         }()
	}
}

使用通道传递数据

var sem = make(chan int, MaxOutstanding) //初始化一个容量为MaxOutstanding的信道

func Serve(clientRequests chan *Request, quit chan bool) {
    go func(){
         // 启动MaxOutstanding个协程,来处理request
        for i := 0; i < MaxOutstanding; i++ {
            go handle(clientRequests)//传入request信道
        }
        <-quit  // 等待通知退出。
    }
}
//处理request
func handle(queue chan *Request) {
    for r := range queue {
        process(r)
    }
}

该例启动固定数量的 handle Go 协程,一起从请求信道中读取数据。Go 协程的数量限制了同时调用 process 的数量。Serve 同样会接收一个通知退出的信道, 在启动所有 Go 协程后,它将阻塞并暂停从信道中接收消息。

上面例子的数据模型:
在这里插入图片描述
多个goroutine从同一个通道读取数据,直到该通道关闭。

该模型比较典型地用来分发任务。

其它的同步工具

Go 语言除了为我们提供了特有的并发编程模型和工具外,还提供了传统的同步工具。如:
sync.Mutex 互斥锁、sync.Cond 条件变量、sync.WaitGroup 等。
他们都在Go的标准库代码包 sync 和 sync/atomic 中,有兴趣可自行了解。

后面我们将关注一下 mutex 与 goroutine的选择。

四、channel解决并发问题的思路

流水线模型

在Golang中,流水线由多个阶段组成,每个阶段之间通过channel连接,每个节点可以由多个同时运行的goroutine组成。

从最简单的流水线入手。下图的流水线由3个阶段组成,分别是A、B、C,A和B之间是通道aCh,B和C之间是通道bCh,A生成数据传递给B,B生成数据传递给C。
在这里插入图片描述
使用场景:
1.多个goroutine从同一个通道读取数据,直到该通道关闭。可以用来分发任务。
2.1个goroutine从多个通道读取数据,直到这些通道关闭。用来收集处理的结果。

使用思路:
channel 的核心是数据流动,我们要关注到并发问题中的数据流动,把流动的数据放到channel中,就能使用channel解决这个并发问题。

数据流动的过程交给channel,数据处理的每个环节都交给goroutine,把这些流程画起来,有始有终形成一条线,来构成一条流水线模型。

操作步骤:

  1. 先找到数据的流动,画出来。
  2. 数据流动的路径换成channel。
  3. channel的两端设计成协程。

channel和mutex的选择

面对一个并发问题的时候,应当选择合适的并发方式:channel还是mutex。选择的依据是他们的能力/特性:channel的能力是让数据流动起来,擅长的是数据流动的场景,《Channel or Mutex》中给了3个数据流动的场景:

  1. 传递数据的所有权,即把某个数据发送给其他协程
  2. 分发任务,每个任务都是一个数据
  3. 交流异步结果,结果是一个数据

mutex的能力是数据不动,某段时间只给一个协程访问数据的权限擅长数据位置固定的场景。

五、goroutine的退出

goroutine在退出方面值得关注。不然,不(合理)退出可能会造成阻塞、panic、程序行为异常、数据结果不正确等问题。

1:使用for-range退出

range能够感知channel的关闭,当channel被发送数据的协程关闭时,range就会结束,接着退出for循环。

它在并发中的使用场景是:当协程只从1个channel读取数据,然后进行处理,处理后协程退出。

下面这个示例程序,当in通道被关闭时,协程可自动退出。

go func(in <-chan int) {
2    // Using for-range to exit goroutine
3    // range has the ability to detect the close/end of a channel
4    for x := range in {
5        fmt.Printf("Process %d\n", x)
6    }
7}(inCh)

2:使用,ok退出

for-select也是使用频率很高的结构,select提供了多路复用的能力,所以for-select可以让函数具有持续多路处理多个channel的能力。但select没有感知channel的关闭,这引出了2个问题:

1.继续在关闭的通道上读,会读到通道传输数据类型的零值。
2.继续在关闭的通道上写,将会panic。

问题2可以这样解决,通道只由发送方关闭,接收方不可关闭,即某个写通道只由使用该select的协程关闭,select中就不存在继续在关闭的通道上写数据的问题。

问题1可以使用,ok来检测通道的关闭,使用情况有2种。

第一种:如果某个通道关闭后,需要退出协程,直接return即可。示例代码中,该协程需要从in通道读数据,还需要定时打印已经处理的数量,有2件事要做,所有不能使用for-range,需要使用for-select,当in关闭时,ok=false,我们直接返回。

 1 func() {
 2    // in for-select using ok to exit goroutine
 3    for {
 4        select {
 5        case x, ok := <-in:
 6            if !ok {
 7                return
 8            }
 9            fmt.Printf("Process %d\n", x)
10            processedCnt++
11        case <-t.C:
12            fmt.Printf("Working, processedCnt = %d\n", processedCnt)
13        }
14    }
15}()

第二种:如果某个通道关闭了,不再处理该通道,而是继续处理其他case,退出是等待所有的可读通道关闭。我们需要使用select的一个特征:select不会在nil的通道上进行等待。这种情况,把只读通道设置为nil即可解决。

 1 func() {
 2    // in for-select using ok to exit goroutine
 3    for {
 4        select {
 5        case x, ok := <-in1:
 6            if !ok {
 7                in1 = nil
 8            }
 9            // Process
10        case y, ok := <-in2:
11            if !ok {
12                in2 = nil
13            }
14            // Process
15        case <-t.C:
16            fmt.Printf("Working, processedCnt = %d\n", processedCnt)
17        }
18
19        // If both in channel are closed, goroutine exit
20        if in1 == nil && in2 == nil {
21            return
22        }
23    }
24}()

3:使用退出通道退出
以上解决是当读入数据的通道关闭时,没数据读时程序的正常结束。还有以下这2种场景:

  1. 接收的协程要退出了,如果它直接退出,不告知发送协程,发送协程将阻塞。
  2. 启动了一个工作协程处理数据,如何通知它退出?

使用一个专门的通道,发送退出的信号,可以解决这类问题。以第2个场景为例,协程入参包含一个停止通道stopCh,当stopCh被关闭,case <-stopCh会执行,直接返回即可。

 1func worker(stopCh <-chan struct{}) {
 2    go func() {
 3        defer fmt.Println("worker exit")
 4        // Using stop channel explicit exit
 5        for {
 6            select {
 7            case <-stopCh:
 8                fmt.Println("Recv stop signal")
 9                return
10            case <-t.C:
11                fmt.Println("Working .")
12            }
13        }
14    }()
15    return
16}

goroutine退出总结:

  1. 发送协程主动关闭通道,接收协程不关闭通道。技巧:把接收方的通道入参声明为只读,如果接收协程关闭只读协程,编译时就会报错。
  2. 协程处理1个通道,并且是读时,协程优先使用for-range,因为range可以关闭通道的关闭自动退出协程。
  3. ,ok可以处理多个读通道关闭,需要关闭当前使用for-select的协程。
  4. 显式关闭通道stopCh可以处理主动通知协程退出的场景。

参考文献:
《Go并发编程实践》
https://learnku.com/docs/effective-go
https://learnku.com/docs/the-way-to-go
https://mp.weixin.qq.com/s?__biz=Mzg3MTA0NDQ1OQ==&mid=2247483671&idx=1&sn=1706ffa6deee44a367c34ef84448f55f&scene=21#wechat_redirect

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值