Go 并发之channel(通道)

一、前言

作为 Go 语言最有特色的数据类型,通道(channel)完全可以与 goroutine(也可称为 go 程)并驾齐驱,共同代表 Go 语言独有的并发编程模式和编程哲学。

通道(channel)可以利用通道在多个 goroutine 之间传递数据。

二、认识 channel

2.1 channel 声明和入门

通道类型的值本身就是并发安全的,这也是 Go 语言自带的、唯一一个可以满足并发安全性的类型。它使用起来十分简单。

1) channel类型

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

  var 变量 chan 元素类型

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

2) 创建channel

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

var ch chan int
fmt.Println(ch) // <nil>

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

创建channel的格式如下:


  make(chan 元素类型, [缓冲大小])  // channel的缓冲大小是可选的。

举几个例子:
 ch4 := make(chan int)
 ch5 := make(chan bool)
 ch6 := make(chan []int)

3) 操作channel

通道有发送(send)、接收(receive)和关闭(close)三种操作。发送和接收都使用<-符号。
现在我们先使用以下语句定义一个通道:

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

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

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

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

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

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

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

代码示例

package main

import "fmt"

func main() {
  // 声明并初始化了一个元素类型为int、容量为3的通道ch1
  ch1 := make(chan int, 3)

  // 向该通道先后发送了三个元素值2、1和3
  ch1 <- 2
  ch1 <- 1
  ch1 <- 3

  // 语句elem1 := <-ch1会将最先进入ch1的元素2接收来并存入变量elem1
  elem1 := <-ch1
  fmt.Printf("The first element received from channel ch1: %v\n",elem1)
}

2.2 channel 的发送与接收特性

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

在很多主流的编程语言中,多个线程传递数据的方式一般都是共享内存,为了解决线程竞争,我们需要限制同一时间能够读写这些变量的线程数量,然而这与 Go 语言鼓励的设计并不相同。下面是多线程之间使用共享内存实现传递数据图示。
在这里插入图片描述
虽然我们在 Go 语言中也能使用共享内存加互斥锁进行通信,但是 Go 语言提供了一种不同的并发模型,即通信顺序进程(Communicating sequential processes,CSP)。Goroutine 和 Channel 分别对应 CSP 中的实体和传递信息的媒介,Goroutine 之间会通过 Channel 传递数据。
在这里插入图片描述
上图中的两个 Goroutine,一个会向 Channel 中发送数据,另一个会从 Channel 中接收数据,它们两者能够独立运行并不存在直接关联,但是能通过 Channel 间接完成通信。

发送和接收的基本特性

  1. 对于同一个通道,发送操作之间是互斥的,接收操作之间也是互斥的。
  2. 发送操作和接收操作中对元素值的处理都是不可分割的。
  3. 发送操作在完全完成之前会被阻塞。接收操作也是如此。

下面是对三个特性详细的分析和说明:

1)通道的第一个基本特性

在同一时刻,Go 语言的运行时系统(以下简称运行时系统)只会执行对同一个通道的任意个发送操作中的某一个。直到这个元素值被完全复制进该通道之后,其他针对该通道的发送操作才可能被执行。

类似的,在同一时刻,运行时系统也只会执行,对同一个通道的任意个接收操作中的某一个。直到这个元素值完全被移出该通道之后,其他针对该通道的接收操作才可能被执行。即使这些操作是并发执行的也是如此。

这里所谓的并发执行,可以这样认为,多个代码块分别在不同的 goroutine 之中,并有机会在同一个时间段内被执行。

另外,对于通道中的同一个元素值来说,发送操作和接收操作之间也是互斥的。例如,虽然会出现,正在被复制进通道但还未复制完成的元素值,但是这时它绝不会被想接收它的一方看到和取走。

这里要注意的一个细节是,元素值从外界进入通道时会被复制。更具体地说,进入通道的并不是在接收操作符右边的那个元素值,而是它的副本。

另一方面,元素值从通道进入外界时会被移动。这个移动操作实际上包含了两步,

  • 第一步是生成正在通道中的这个元素值的副本,并准备给到接收方;
  • 第二步是删除在通道中的这个元素值;

2)通道的第二个基本特性

这里的“不可分割”的意思是,它们处理元素值时都是一气呵成的,绝不会被打断。

  1. 例如,发送操作要么还没复制元素值,要么已经复制完毕,绝不会出现只复制了一部分的情况。
  2. 例如,接收操作在准备好元素值的副本之后,一定会删除掉通道中的原值,绝不会出现通道中仍有残留的情况。

这既是为了保证通道中元素值的完整性,也是为了保证通道操作的唯一性。对于通道中的同一个元素值来说,它只可能是某一个发送操作放入的,同时也只可能被某一个接收操作取出。

3)通道的第三个基本特性

一般情况下,发送操作包括了“复制元素值”和“放置副本到通道内部”这两个步骤。在这两个步骤完全完成之前,发起这个发送操作的那句代码会一直阻塞在那里。也就是说,在它之后的代码不会有执行的机会,直到这句代码的阻塞解除。

更细致地说,在通道完成发送操作之后,运行时系统会通知这句代码所在的 goroutine,以使它去争取继续运行代码的机会。另外,接收操作通常包含了“复制通道内的元素值”“放置副本到接收方”“删掉原值”三个步骤。

在所有这些步骤完全完成之前,发起该操作的代码也会一直阻塞,直到该代码所在的 goroutine 收到了运行时系统的通知并重新获得运行机会为止。说到这里,可能已经感觉到,如此阻塞代码其实就是为了实现操作的互斥和元素值的完整。

2.3 通道的操作和使用

通道(channel)是用来传递数据的一个数据结构。通道可用于两个 goroutine 之间通过传递一个指定类型的值来同步运行和通讯。操作符<-用于指定通道的方向,发送或接收。如果未指定方向,则为双向通道。

// 声明一个通道很简单,我们使用chan关键字即可,通道在使用前必须先创建:
ch := make(chan int)

ch <- v    // 把 v 发送到通道 ch
v := <-ch  // 从 ch 接收数据
           // 并把值赋给 v

注意:默认情况下,通道是不带缓冲区的。发送端发送数据,同时必须有接收端相应的接收数据。

2.4 channel 缓冲区

通道可以设置缓冲区,通过 make 的第二个参数指定缓冲区大小:

ch := make(chan int, 100)

带缓冲区的通道允许发送端的数据发送和接收端的数据获取处于异步状态,就是说发送端发送的数据可以放在缓冲区里面,可以等待接收端去获取数据,而不是立刻需要接收端去获取数据。

不过由于缓冲区的大小是有限的,所以还是必须有接收端来接收数据的,否则缓冲区一满,数据发送端就无法再发送数据了。

注意:

  1. 如果通道不带缓冲,发送方会阻塞直到接收方从通道中接收了值。
  2. 如果通道带缓冲,发送方则会阻塞直到发送的值被拷贝到缓冲区内;
  3. 如果缓冲区已满,则意味着需要等待直到某个接收方获取到一个值。接收方在有值可以接收之前会一直阻塞。

代码示例

package main

import "fmt"

func main() {
        // 这里我们定义了一个可以存储整数类型的带缓冲通道
        // 缓冲区大小为2
        ch := make(chan int, 2)

        // 因为 ch 是带缓冲的通道,我们可以同时发送两个数据
        // 而不用立刻需要去同步读取数据
        ch <- 1
        ch <- 2

        // 获取这两个数据
        fmt.Println(<-ch)
        fmt.Println(<-ch)
}

2.5 单向通道

在说“通道”的时候指的都是双向通道,即:既可以发也可以收的通道。所谓单向通道就是,只能发不能收,或者只能收不能发的通道。

一个通道是双向的,还是单向的是由它的类型字面量体现的。如果把接收操作符<-用在通道的类型字面量中,那么它代表的就不是“发送”或“接收”的动作了,而是表示通道的方向。

比如声明并初始化一个名叫uselessChan的变量,这个变量的类型是chan<- int,容量是1。

// 紧挨在关键字chan右边的那个<-,这表示了这个通道是单向的,并且只能发而不能收。
// 接收通道
var uselessChan = make(chan<- int, 1)

// 类似的如果这个操作符紧挨在chan的左边,那么就说明该通道只能收不能发
// 发送通道
var uselessChan = make(<-chan int, 1)

注意,与发送操作和接收操作对应,这里的“发”和“收”都是站在操作通道的代码的角度上说的。
从上述变量的名字上也能猜到,这样的通道是没用的。通道就是为了传递数据而存在的,声明一个只有一端(发送端或者接收端)能用的通道没有任何意义。那么,单向通道的用途究竟在哪呢?

2.6 channel 遍历和关闭

Go 通过 range 关键字来实现遍历读取到的数据,类似于与数组或切片。格式如下:v, ok := <-ch

如果通道接收不到数据后 ok 就为 false,这时通道就可以使用 close() 函数来关闭。

package main

import (
        "fmt"
)

func fibonacci(n int, c chan int) {
        x, y := 0, 1
        for i := 0; i < n; i++ {
                c <- x
                x, y = y, x+y
        }
        close(c)
}

func main() {
        c := make(chan int, 10)
        go fibonacci(cap(c), c)
        // range 函数遍历每个从通道接收到的数据,因为 c 在发送完 10 个
        // 数据之后就关闭了通道,所以这里我们 range 函数在接收到 10 个数据
        // 之后就结束了。如果上面的 c 通道不关闭,那么 range 函数就不
        // 会结束,从而在接收第 11 个数据的时候就阻塞了。
        for i := range c {
                fmt.Println(i)
        }
}

三、应用实践

3.1 数据和字母交叉打印

// TestCrossPrinting 1、数据和字母交叉打印
func TestCrossPrinting(t *testing.T) {

	var wg sync.WaitGroup
	letter, number := make(chan string, 26), make(chan int, 27)
	wg.Add(3)

	// 记录字母
	go func() {
		defer wg.Done()
		for i := 'A'; i <= 'Z'; i++ {
			letter <- string(i)
		}
		close(letter)
	}()

	// 记录数字
	go func() {
		defer wg.Done()
		for i := 1; i <= 26; i++ {
			number <- i
		}
		close(number)
	}()

	// 打印数字和字母
	go func() {
		defer wg.Done()
		for i := range number {
			fmt.Printf("%d%s", i, <-letter)
		}
	}()
	wg.Wait()

	fmt.Println("\n", "")

}

3.2 内存泄漏

// 泄露场景一:(1) 非缓冲通道,缺少发送器,导致接收阻塞
func TestMissingSender(t *testing.T) {
	ch := make(chan int)
	go func(ch chan int) {
		ch <- 110
		// 因为 ch 一直没有接收数据,所以这个协程会阻塞在这里。
		fmt.Println("current value not receiver")
	}(ch)
	fmt.Println("=== RESULT execute finished")
}

四、通道实践的几大坑

在使用 channel 进行 goroutine 之间的通信时,有时候场面会变得十分复杂,以至于写出难以觉察、难以定位的偶现 bug,而且上线的时候往往跑得好好的,直到某一天深夜收到服务挂了、OOM 了之类的告警……
来梳理一下使用 channel 中常见的三大坑:panic、死锁、内存泄漏,做到防患于未然。

4.1 死锁

go 语言新手在编译时很容易碰到这个死锁的问题:

fatal error: all goroutines are asleep - deadlock!

这个就是喜闻乐见的「死锁」了…… 在操作系统中,学过「死锁」就是两个线程互相等待,耗在那里,最后程序不得不终止。

go 语言中的「死锁」也是类似的,两个 goroutine 互相等待,导致程序耗在那里,无法继续跑下去。看了很多死锁的案例后,channel 导致的死锁可以归纳为以下几类案例(先讨论 unbuffered channel 的情况)

4.1.1 只有生产者,没有消费者,或者反过来

channel 的生产者和消费者必须成对出现,如果缺乏一个,就会造成死锁,例如:

// 只有生产者,没有消费者
func f1() {
    ch := make(chan int)
    ch <- 1
}

// 只有消费者,没有生产者
func f2() {
    ch := make(chan int)
    <-ch
}

4.1.2 生产者和消费者出现在同一个 goroutine 中

除了需要成对出现,还需要出现在不同的 goroutine 中,例如:

// 同一个 goroutine 中同时出现生产者和消费者
func f3() {
    ch := make(chan int)
    ch <- 1  // 由于消费者还没执行到,这里会一直阻塞住
    <-ch
}

对于 buffered channel 则是下面这种情况

4.1.3 buffered channel 已满,且在同一个goroutine中

buffered channel 会将收到的元素先存在 hchan 结构体的 ringbuffer 中,继而才会发生阻塞。而当发生阻塞时,如果阻塞了主 goroutine ,则也会出现死锁

所以实际使用中,推荐尽量使用 buffered channel ,使用起来会更安全,在下文的「内存泄漏」相关内容也会提及。

4.2 内存泄漏

内存泄漏一般都是通过 OOM(Out of Memory) 告警或者发布过程中对内存的观察发现的,服务内存往往都是缓慢上升,直到被系统 OOM 掉清空内存再周而复始。

在 go 语言中,错误地使用 channel 会导致 goroutine 泄漏,进而导致内存泄漏。

4.2.1 如何实现 goroutine 泄漏呢?

不会修 bug,还不会写 bug 吗?让 goroutine 泄漏的核心就是:

生产者/消费者 所在的 goroutine 已经退出,而其对应的 消费者/生产者 所在的 goroutine 会永远阻塞住,直到进程退出

4.2.2 生产者阻塞导致泄漏

一般会用 channel 来做一些超时控制,例如下面这个例子:

func leak1() {
    ch := make(chan int)
    // g1
    go func() {
        time.Sleep(2 * time.Second) // 模拟 io 操作
        ch <- 100                   // 模拟返回结果
    }()

    // g2
    // 阻塞住,直到超时或返回
    select {
    case <-time.After(500 * time.Millisecond):
        fmt.Println("timeout! exit...")
    case result := <-ch:
        fmt.Printf("result: %d\n", result)
    }
}

这里用 goroutine g1 来模拟 io 操作,主 goroutine g2 来模拟客户端的处理逻辑,

(1)假设客户端超时为 500ms,而实际请求耗时为 2s,则 select 会走到 timeout 的逻辑,这时 g2 退出,channel ch 没有消费者,会一直在等待状态,输出如下:

Goroutine num: 1
timeout! exit...
Goroutine num: 2

如果这是在 server 代码中,这个请求处理完后,g1 就会挂起、发生泄漏了,就等着 OOM 吧 。

(2)假设客户端超时调整为 5000ms,实际请求耗时 2s,则 select 会进入获取 result 的分支,输出如下:

Goroutine num: 1
result: 100
Goroutine num: 1

4.2.3 消费者阻塞导致泄漏

如果生产者不继续生产,消费者所在的 goroutine 也会阻塞住,不会退出,例如:

func leak2() {
    ch := make(chan int)

    // 消费者 g1
    go func() {
        for result := range ch {
            fmt.Printf("result: %d\n", result)
        }
    }()

    // 生产者 g2
    ch <- 1
    ch <- 2
    time.Sleep(time.Second)  // 模拟耗时
    fmt.Println("main goroutine g2 done...")
}

这种情况下,只需要增加 close(ch) 的操作即可,for-range 操作在收到 close 的信号后会退出、goroutine 不再阻塞,能够被回收。

4.2.4 如何预防内存泄漏

预防 goroutine 泄漏的核心就是:创建 goroutine 时就要想清楚它什么时候被回收。

具体到执行层面,包括:

  • 当 goroutine 退出时,需要考虑它使用的 channel 有没有可能阻塞对应的生产者、消费者的 goroutine;
  • 尽量使用 buffered channel使用 buffered channel 能减少阻塞发生、即使疏忽了一些极端情况,也能降低 goroutine 泄漏的概率;

4.3 panic

panic 就更刺激了,一般是测试的时候没发现,上线之后偶现,程序挂掉,服务出现一个超时毛刺后触发告警。channel 导致的 panic 一般是以下几个原因:

4.3.1 向已经 close 掉的 channel 继续发送数据

先举一个简单的栗子:

func p1() {
    ch := make(chan int, 1)
    close(ch)
    ch <- 1
}
// panic: send on closed channel

在实际开发过程中,处理多个 goroutine 之间协作时,可能存在一个 goroutine 已经 close 掉 channel 了,另外一个不知道,也去 close 一下,就会 panic 掉,例如:

func p1() {
	ch := make(chan int, 1)
	done := make(chan struct{}, 1)
	go func() {
		<- time.After(2*time.Second)
		println("close2")
		close(ch)
		close(done)
	}()
	go func() {
		<- time.After(1*time.Second)
		println("close1")
		ch <- 1
		close(ch)
	}()

	<-done
}

万恶之源就是在 go 语言里,是无法知道一个 channel 是否已经被 close 掉的,所以在尝试做 close 操作的时候,就应该做好会 panic 的准备……

4.3.2 多次 close 同一个 channel

同上,在尝试往 channel 里发送数据时,就应该考虑

  • 这个 channel 已经关了吗?
  • 这个 channel 什么时候、在哪个 goroutine 里关呢?
  • 谁来关呢?还是干脆不关?

4.4 如何优雅地 close channel

4.4.1 需要检查 channel 是否关闭吗?

刚遇到上面说的 panic 问题时,也试过去找一个内置的 closed 函数来检查关闭状态,结果发现,并没有这样一个函数……

那么,如果有这样的函数,真能彻底解决 panic 的问题么?答案是不能。因为 channel 是在一个并发的环境下去做收发操作,就算当前执行 closed(ch) 得到的结果是 false,还是不能直接去关,例如代码:

if !closed(ch) {  // 返回 false
    // 在这中间出了幺蛾子!
    close(ch)  // 还是 panic 了……
}

遵循 less is more 的原则,这个 closed 函数是要不得了

4.4.2 需要 close 吗?为什么?

结论:除非必须关闭 chan,否则不要主动关闭。关闭 chan 最优雅的方式,就是不要关闭 chan~

当一个 chan 没有 sender 和 receiver 时,即不再被使用时,GC 会在一段时间后标记、清理掉这个 chan。那么什么时候必须关闭 chan 呢?

比较常见的是将 close 作为一种通知机制,尤其是生产者与消费者之间是 1:M 的关系时,通过 close 告诉下游:我收工了,你们别读了。

4.4.3 谁来关?

chan 关闭的原则:

  1. Don’t close a channel from the receiver side 不要在消费者端关闭 chan
  2. Don’t close a channel if the channel has multiple concurrent senders 有多个并发写的生产者时也别关

只要遵循这两条原则,就能避免两种 panic 的场景,即:向 closed chan 发送数据,或者是 close 一个 closed chan。

按照生产者和消费者的关系可以拆解成以下几类情况:

  1. 一写一读:生产者关闭即可
  2. 一写多读:生产者关闭即可,关闭时下游全部消费者都能收到通知
  3. 多写一读:多个生产者之间需要引入一个协调 channel 来处理信号
  4. 多写多读:与 3 类似,核心思路是引入一个中间层以及使用 try-send 的套路来处理非阻塞的写入.

代码示例

func main() {
	rand.Seed(time.Now().UnixNano())
	log.SetFlags(0)
	
	const Max = 100000
	const NumReceivers = 10
	const NumSenders = 1000
	
	wgReceivers := sync.WaitGroup{}
	wgReceivers.Add(NumReceivers)
	
	dataCh := make(chan int)
	stopCh := make(chan struct{})
	// stopCh 是额外引入的一个信号 channel.
	// 它的生产者是下面的 toStop channel,
	// 消费者是上面 dataCh 的生产者和消费者
	toStop := make(chan string, 1)
	// toStop 是拿来关闭 stopCh 用的,由 dataCh 的生产者和消费者写入
	// 由下面的匿名中介函数(moderator)消费
	// 要注意,这个一定要是 buffered channel (否则没法用 try-send 来处理了)
	
	var stoppedBy string
	
	// moderator
	go func() {
		stoppedBy = <-toStop
		close(stopCh)
	}()
	
	// senders
	for i := 0; i < NumSenders; i++ {
		go func(id string) {
			for {
				value := rand.Intn(Max)
				if value == 0 {
					// try-send 操作
					// 如果 toStop 满了,就会走 default 分支啥也不干,也不会阻塞
					select {
						case toStop <- "sender#" + id:
					default:
						}
					return
				}
				
				
				// try-receive 操作,尽快退出
				// 如果没有这一步,下面的 select 操作可能造成 panic
				select {
					case <- stopCh:
					return
					default:
					}
				
				// 如果尝试从 stopCh 取数据的同时,也尝试向 dataCh
				// 写数据,则会命中 select 的伪随机逻辑,可能会写入数据
				select {
					case <- stopCh:
					return
					case dataCh <- value:
					}
			}
		}(strconv.Itoa(i))
	}
	
	// receivers
	for i := 0; i < NumReceivers; i++ {
		go func(id string) {
			defer wgReceivers.Done()
			
			for {
				// 同上
				select {
					case <- stopCh:
					return
					default:
					}
				
				// 尝试读数据
				select {
					case <- stopCh:
					return
					case value := <-dataCh:
					if value == Max-1 {
						select {
							case toStop <- "receiver#" + id:
						default:
							}
						return
					}
					
					log.Println(value)
				}
			}
		}(strconv.Itoa(i))
	}
	
	wgReceivers.Wait()
	log.Println("stopped by", stoppedBy)
}

五、基础巩固

5.1 Go channel的底层实现原理?

1) 概念
Go 中channel 是一个先进先出(FIFO)的队列,负责协程之间的通信(Go语言提倡不要通过共享内存来通信,而要通过通信的方式实现共享内存),其中CSP并发模型就是通过goroutine 和 channel来实现的。

2) 使用场景

  • 停止信号监听、定时任务、生产方与消费方解耦、控制并发数

3) 底层数据结构
channel 的整体结构
在这里插入图片描述
简单说明:

  • buf是有缓冲的channel所特有的结构,用来存储缓存数据。是个循环链表
  • sendx和recvx用于记录buf这个循环链表中的发送或者接收的index
  • lock是个互斥锁。
  • recvq和sendq分别是接收(<-channel)或者发送(channel <- xxx)的goroutine抽象出来的结构体(sudog)的队列。是个双向链表

源码位于/runtime/chan.go中。结构体为hchan。

type hchan struct {
  closed uint32        // 标识关闭状态:表示当前通道是否处于关闭状态。创建通道后,该字段设置为0,即通道打开; 通过调用close将其设置为1,通道关闭。
  
  qcount uint          // 当前队列列中剩余元素个数
  dataqsiz uint        // 环形队列长度,即可以存放的元素个数即缓冲区的大小,即make(chan T,N),N.
  buf unsafe.Pointer   // 环形队列列指针,ring buffer 环形队列
  elemsize uint16      // 每个元素的⼤⼩
  elemtype *_type      // 元素类型:用于数据传递过程中的赋值;
  sendx uint           // 队列下标,指示元素写⼊入时存放到队列列中的位置 x
  recvx uint           // 队列下标,指示元素从队列列的该位置读出  
  
  recvq waitq          // 等待读消息的goroutine队列
  sendq  waitq         // 等待写消息的goroutine队列
  
  lock mutex           // 互斥锁,chan不允许并发读写
} 
 
type waitq struct {
	first *sudog
	last  *sudog
}

从数据结构可以看出channel由队列、类型信息、goroutine等待队列组成。

4) 实现方式
创建channel 有两种,一种是带缓冲的channel,一种是不带缓冲的channel

// 带缓冲
ch := make(chan Task, 6)
// 不带缓冲
ch := make(chan int)

下图展示了可缓存6个元素的channel底层的数据模型如下图:

func makechan(t *chantype, size int) *hchan {
   elem := t.elem
}

说明:
dataqsiz:指向队列的长度为6,即可缓存6个元素
buf:指向队列的内存,队列中还剩余两个元素
qcount:当前队列中剩余的元素个数
sendx:指后续写入元素的位置
recvx:指从该位置读取数据

在这里插入图片描述

5.2 Go channel为什么是线程安全的?

Go channel是线程安全的,因为它内部实现了同步机制。

当一个goroutine向channel中写入数据时,如果channel已满,则该goroutine会被阻塞,直到有其他goroutine从channel中取出数据为止;

反之,当一个goroutine从channel中取出数据时,如果channel为空,则该goroutine会被阻塞,直到有其他goroutine向channel中写入数据为止。

这种同步机制可以保证在多个goroutine同时操作同一个channel时,数据的读写是安全的,不会出现数据竞争等问题。因此,Go channel在并发编程中被广泛使用,它是一种高效、简单而又安全的并发通信机制。

5.3 Go channel如何控制goroutine并发执行顺序?

Go channel可以用来控制goroutine的并发执行顺序,通过channel的特性可以实现同步和异步的调用方式,从而控制goroutine的执行顺序。

  • 同步调用:使用无缓冲的channel,当goroutine A向channel发送数据时会阻塞,直到有goroutine B从channel接收数据,这样就能保证goroutine A在goroutine B执行完之后再执行。
  • 异步调用:使用有缓冲的channel,可以让多个goroutine同时向channel发送数据,然后再由其他goroutine从channel接收数据。这样可以实现并发的执行顺序。

另外,可以使用select语句来控制多个channel的并发执行顺序,以及使用sync包中的WaitGroup来等待所有goroutine执行完毕再继续执行下一步操作。

5.4 Go channel共享内存有什么优劣势?

Go语言中的Channel是一种用于在不同Goroutine之间进行通信和同步的机制,它可以看作是一种共享内存的方式。Channel的优势在于:

  1. 线程安全:Go语言中的Channel是线程安全的,在并发编程中可以有效避免竞态条件和锁问题。
  2. 同步性:使用Channel可以实现两个Goroutine之间的同步,一个Goroutine在读取Channel中的数据时,会一直等待直到有写入操作,这种同步性对于一些并发编程任务非常有用。
  3. 协作性:使用Channel可以协调多个Goroutine的执行,通过传递消息来控制它们执行的顺序和方式。

但是,Channel也有一些劣势:

  1. 限制性:Channel只能用于同一个进程内部的Goroutine之间通信,无法用于多个不同进程之间的通信。
  2. 主动性:Channel的读取和写入都是被动的,即读取方必须等待写入方写入数据。
  3. 内存消耗:Channel会消耗一定的内存,如果不及时关闭Channel,可能会造成内存泄漏。

因此,在实际应用中,我们需要根据具体场景来选择使用Channel还是其他的共享内存方式。

六、总结

Channel 是 Go 语言能够提供强大并发能力的原因之一,我们在这一节中分析了 Channel 的设计原理、数据结构以及发送数据、接收数据和关闭 Channel 这些基本操作,相信能够帮助大家更好地理解 Channel 的工作原理。

channel常见的异常总结,如下图:
在这里插入图片描述
注意:关闭已经关闭的channel也会引发panic。

在使用channel有这么几点要注意

  1. 确保所有数据发送完后再关闭channel,由发送方来关闭
  2. 不要重复关闭channel
  3. 不要向为nil的channel里面发送值
  4. 不要向为nil的channel里面接收值
  5. 接收数据时,可以通过返回值判断是否ok n , ok := <- c

这样防止channel被关闭后返回了零值,对业务造成影响

参考材料

  1. 菜鸟教程—Go 并发_channel
  2. Go语言设计与实现—channel
  3. 并发编程 channl
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

试剑江湖。

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值