Golang面试题之 Channel

Golang面试题之 Channel

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

虽然可以使用共享内存进行数据交换,但是共享内存在不同的goroutine中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。

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

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

一、 channel 的声明方式

  • var ch chan T 此时的 ch 值为 nil,channel的一个特性是向值为nil的channel发送数据会阻塞

  • ch := new(chan T) 同上使用 var 关键字,ch的值是 nil

  • ch := make(chan T) ch 是一个非空的指针,此时可以向 ch 发送或者接收数据

因此,第一种和第二种虽然可以声明,但返回值无法使用,一定会要通过 make 关键字声明的值才可用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func main() {
 // 第一种: 通过 var ch chan T 声明类型为T的channel
 var ch chan int

 fmt.Printf("value of ch: %#v\n", ch)
 // 输出:value of ch: (chan int)(nil)

 // 第二种:通过new(chan T) 声明类型为T的channel
 ch2 := new(chan int)

 fmt.Printf("value of ch2: %#v\n", *ch2)
 // 输出:value of ch2: (chan int)(nil)

 // 第三种:通过make(chan T) 声明类型为T的channel
 ch3 := make(chan int)

 fmt.Printf("value of ch3: %#v\n", ch3)
 // 输出:value of ch3: (chan int)(0xc000090360)
}

二、channel的类型

根据缓冲区区分

  • 无缓冲区channel:无缓冲区channel的容量为0,即无缓冲区,发送数据时,如果接收端没有准备好,则阻塞,直到接收端准备好接收数据。
  • 有缓冲区channel:有缓冲区channel的容量大于0,即有缓冲区,发送数据时,如果缓冲区已满,则阻塞,直到接收端接收数据后缓冲区有空间。

根据读写类型

  • chan 可读可写
  • <-chan 只读
  • chan<- 只写

channel 的基本操作和注意事项

  • channel的3种状态
    • nil, 未初始化的状态,只进行了声明,或手动赋值为nil
    • active, 正常的channel,可读或者可写
    • closed, 已关闭的channel,【注意】已关闭的channel值不是nil,在select 中可以判断是否关闭,但无法判断是否为nil,而且已关闭的channel依然可以读,只是读出的数据是零值。只有将关闭的channel赋值为nil,才可以停止接收。

channel的三种操作

1. 读操作(接收数据)
  • nil值的chan读取数据会阻塞
1
2
3
4
5
func main() {
 var ch chan int // 只声明,未初始化,值为nil

 <-ch // 从 nil 值的chan 读取数据会阻塞
}
  • 从正常的channel读取数据,如果缓冲区为空,则阻塞,直到缓冲区有数据
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
 var ch chan int     // 只声明,未初始化,值为nil
 ch = make(chan int) // 初始化

 go func() {
  time.Sleep(time.Second) // 休眠1秒
  ch <- 1 // 向 ch 中发送数据
 }()

 v := <-ch // 阻塞,直到上面的 goroutine 内休眠1秒后向 ch 发送数据,这里正常接收
 fmt.Println("received:", v) // 输出:received: 1
}
  • 读取已关闭的 channel,读取到的是零值
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func main() {
 var ch chan int     // 只声明,未初始化,值为nil
 ch = make(chan int) // 初始化

 go func() {
  //time.Sleep(time.Second) // 休眠1秒
  ch <- 1   // 向 ch 中发送数据
  close(ch) // 发送数据后立刻关闭 ch
 }()

 time.Sleep(time.Second)     // 休眠1秒后再接收数据
 v := <-ch                   // channel 已关闭,但还可以正常读取已发送的值:1
 fmt.Println("received:", v) // 输出:received: 1

 if v2, ok := <-ch; ok { // chan 已关闭,ok 返回 false,v2返回的是channel 类型的零值0
  fmt.Println("received:", v2)
 } else {
  fmt.Println("channel closed, v:", v2) // 输出:channel closed, v: 0
 }
}

写操作(发送数据)

  • nil值的chan发送数据会阻塞
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
 var ch chan int // 只声明,未初始化,值为nil

 go func() {
  ch <- 1             // 向 nil 值的channel 中写入数据
  fmt.Println("发送数据") // 阻塞,不打印数据
 }()

 <-ch
}
  • 向正常的channel发送数据,如果缓冲区已满,则阻塞,直到缓冲区有空间
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
 var ch chan int // 只声明,未初始化,值为nil
 ch = make(chan int, 1)

 go func() {
  ch <- 1               // 向正常的channel 中写入数据
  fmt.Println("发送数据成功") // 输出:发送数据成功
 }()

 time.Sleep(time.Second)
 <-ch
}
  • 向已关闭的channel发送数据,会panic
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
 var ch chan int // 只声明,未初始化,值为nil
 ch = make(chan int, 1)

 go func() {
  close(ch) // 关闭 channel
  ch <- 1  // panic: send on closed channel
  fmt.Println("发送数据成功") 
 }()

 time.Sleep(time.Second)
}

关闭

  • 关闭 nil 的channel,会导致panic
1
2
3
4
5
6
7
func main() {
 var ch chan int // 只声明,未初始化,值为nil

 close(ch) // panic: close of nil channel

 time.Sleep(time.Second)
}
  • 关闭正常channel,成功
1
2
3
4
5
6
7
8
9
func main() {
 var ch chan int // 只声明,未初始化,值为nil
 ch = make(chan int)

 close(ch)                // 关闭
 fmt.Println("closed!!!") // 输出:closed!!!

 time.Sleep(time.Second)
}
  • 关闭已关闭的channel,会导致panic
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
 var ch chan int // 只声明,未初始化,值为nil
 ch = make(chan int)

 close(ch)                // 关闭
 fmt.Println("closed!!!") // 输出:closed!!!

 // 再次关闭,
 close(ch) // panic: close of closed channel

 time.Sleep(time.Second)
}

针对 channel的三种状态和三种操作,可以组合成九种情况如下:

操作 nil的channel 正常的channel 已关闭的channel
阻塞 成功或阻塞 正常(返回零值)
阻塞 成功或阻塞 panic
关闭 panic 成功 panic

三、channel 如何实现线程安全?

Go中的channel是一种用于不同goroutine之间通信的原语,它可以在多个goroutine之间安全地传递数据,而不需要显式地使用锁机制(如mutex)来同步访问。Go语言的设计确保了channel在并发场景下是安全的,这使得它非常适合在多goroutine环境中用于数据传递和同步。

  • 锁机制

在channel的底层实现中,所有对channel的操作(包括发送、接收、关闭等)都会被加锁,以防止多个goroutine同时操作channel时出现数据竞争。Go runtime为每个channel分配了一个mutex锁来保护channel的状态,从而保证了在多goroutine并发操作时的线程安全性。

  • Goroutine调度与阻塞

当一个goroutine因为channel满了(发送方)或channel空了(接收方)而被阻塞时,Go的调度器会将该goroutine挂起,放入对应的队列(sendq或recvq)。一旦条件满足(比如有接收者准备好接收数据),被阻塞的goroutine会被唤醒继续执行

  • 关闭channel的安全性

关闭一个channel时,所有在等待接收该channel的goroutine都会被立即唤醒,并且它们会收到零值,从而安全退出。此外,尝试向已关闭的channel发送数据会引发panic,这是Go语言的一种安全机制,避免意外的并发问题。

四、channel的底层实现总结

  • channel使用 锁(mutext) 来确保线程安全,防止数据竞争。
  • channel通过 goroutine调度和队列 实现阻塞和唤醒机制,使得多个goroutine可以安全的发送和接收数据。
  • 无缓冲的channel是同步的,而有缓冲的channel是异步的,二者在实现机制上有所不同。
  • Go的调度器负责管理阻塞的goroutine,使得程序不会因为阻塞而卡死。

五、channel 的发送和接收数据过程实现

无缓冲区的channel

  • 发送数据流程
  1. 加锁,发送方调用ch <- value,Go runtime 会对channel加锁,防止其他 goroutine 同时操作channel。
  2. 检查接收队列,Go runtime 会首先检查 revq(接收队列)是否有等待的接收方。
  • 如果有,则从队列中取出一个接收方,并唤醒它。
  • 如果没有,则将发送方加入到发送队列sendq中。
  1. 解锁,发送操作结束后,Go runtime 会解锁,允许其他 goroutine 操作channel。
  • 接收数据流程
  1. 加锁,接收方调用value := <-ch,Go runtime 会对channel加锁,防止其他 goroutine 同时操作channel。
  2. 检查发送队列,Go runtime 会首先检查 sendq(发送队列)是否有等待的发送方。
  • 如果有,则从队列中取出一个发送方,并唤醒它。
  • 如果没有,则将接收方加入到接收队列recvq中。
  1. 解锁,接收操作结束后,Go runtime 会解锁,允许其他 goroutine 操作channel。
  • 无缓冲channel操作的总结
  • 如果发送方先到,且没有接收方,发送方阻塞并进入 sendq
  • 如果接收方先到,且没有发送方,接收方阻塞并进入 recvq
  • 如果发送方和接收方匹配成功后,Go runtime 会进行数据交换,并唤醒被阻塞的 goroutine。

有缓冲区的channel

有缓冲的 channel 不需要发送和接收操作严格同步,发送方可以在缓冲区未满时发送数据,而不阻塞。接收方可以在缓冲区中有数据时接收数据,而不等待。

发送数据流程
  • 加锁,发送方调用 ch <- value,Go runtime 加锁,防止其他 goroutine 并发操作 channel。
  • 检查缓冲区:
  • 如果缓冲未满,则将数据写入缓冲区,sendx(发送索引)递增。
  • 如果缓冲已满,则阻塞发送方,并将发送方加入到发送队列sendq中。
  • 解锁,发送操作结束后,Go runtime 解锁,允许其他 goroutine 操作 channel。
接收数据流程
  • 加锁,接收方调用 value := <-ch,Go runtime 加锁,防止其他 goroutine 并发操作 channel。
  • 检查缓冲区:
  • 如果缓冲中有数据,则从缓冲区中取出数据,recvx(接收索引)递增。
  • 如果缓冲为空,则阻塞接收方,并将接收方加入到接收队列recvq中。
  • 解锁,接收操作结束后,Go runtime 解锁,允许其他 goroutine 操作 channel。
有缓冲channel操作的总结
  • 如果缓冲未满,则发送方直接写入缓冲区。
  • 如果缓冲区已满,发送方阻塞并进入sendq
  • 如果缓冲中有数据,则接收方直接从缓冲区中读取数据而不阻塞。
  • 如果缓冲为空,接收方阻塞并进入recvq
皖ICP备20014602号
Built with Hugo
Theme Stack designed by Jimmy