Golang 通道基础

GolangBeginner
立即练习

介绍

Go 语言最吸引人的特性之一是其对并发编程的原生支持。它对并行性(parallelism)、并发性(concurrent programming)和网络通信有强大的支持,能够更高效地利用多核处理器。

与其他通过共享内存实现并发的语言不同,Go 通过使用通道(channels)来实现不同 goroutine 之间的通信,从而实现并发。

Goroutine 是 Go 中的并发单元,可以被视为轻量级线程。与传统线程相比,它们在切换时消耗的资源更少。

通道和 goroutine 共同构成了 Go 中的并发原语(concurrency primitives)。在本节中,我们将学习通道(channels)这种新的数据结构。

知识点:

  • 通道的类型
  • 创建通道
  • 操作通道
  • 通道阻塞
  • 单向通道

通道概述

通道(Channels)是一种特殊的数据结构,主要用于 goroutine 之间的通信。

与其他使用互斥锁(mutexes)和原子函数(atomic functions)来安全访问资源的并发模型不同,通道允许通过在多个 goroutine 之间发送和接收数据来进行同步。

在 Go 中,通道就像一个遵循先进先出(FIFO)规则的传送带或队列。

通道类型与声明

通道是一种引用类型(reference type),其声明格式如下:

var channelName chan elementType

每个通道都有一个特定的类型,并且只能传输相同类型的数据。我们来看一些示例:

~/project 目录下创建一个名为 channel.go 的文件:

cd ~/project
touch channel.go

例如,我们声明一个 int 类型的通道:

var ch chan int

上面的代码声明了一个名为 chint 通道。

除此之外,还有许多其他常用的通道类型。

channel.go 中,写入以下代码:

package main

import "fmt"

func main() {
    var ch1 chan int   // 声明一个整数通道
    var ch2 chan bool  // 声明一个布尔通道
    var ch3 chan []int // 声明一个传输整数切片(slice)的通道
    fmt.Println(ch1, ch2, ch3)
}

上面的代码只是声明了三种不同类型的通道。

使用以下命令运行程序:

go run channel.go

程序输出如下:

<nil> <nil> <nil>

通道初始化

与映射(maps)类似,通道在声明后需要进行初始化才能使用。

初始化通道的语法如下:

make(chan elementType)

channel.go 中,写入以下代码:

package main

import "fmt"

func main() {
    // 存储整数数据的通道
    ch1 := make(chan int)

    // 存储布尔数据的通道
    ch2 := make(chan bool)

    // 存储 []int 数据的通道
    ch3 := make(chan []int)

    fmt.Println(ch1, ch2, ch3)
}

使用以下命令运行程序:

go run channel.go

程序输出如下所示:

0xc0000b4000 0xc0000b4040 0xc0000b4080

在上面的代码中,定义并初始化了三种不同类型的通道。

通过检查输出,我们可以看到,与映射不同,直接打印通道本身并不能显示其内容。

直接打印通道只会给出通道本身的内存地址。这是因为,像指针(pointer)一样,通道只是对内存地址的引用。

那么,我们如何访问和操作通道中的值呢?

通道操作

通道操作围绕着发送和接收数据,这使用 <- 运算符来完成。为了看到通道的实际作用,我们需要将它们与 goroutine(Go 的轻量级并发线程)一起使用。

一个通道连接着一个发送数据的 goroutine 和一个接收数据的 goroutine。如果其中一方没有准备好,另一方就会被阻塞(block)。我们来看一个完整的示例。

channel.go 中,写入以下代码:

package main

import (
    "fmt"
    "time"
)

func main() {
    // 创建一个无缓冲通道
    ch := make(chan int)

    // 使用匿名函数启动一个新的 goroutine
    go func() {
        fmt.Println("Goroutine starts sending...")
        // 向通道发送值 10
        ch <- 10
        fmt.Println("Goroutine finished sending.")
    }()

    fmt.Println("Main is waiting for data...")
    // 从通道接收值
    value := <-ch
    fmt.Printf("Main received: %d\n", value)

    // 给 goroutine 一点时间打印其最后的消息
    time.Sleep(time.Second)
}

使用以下命令运行程序:

go run channel.go

程序输出将类似于此(顺序可能略有不同):

Main is waiting for data...
Goroutine starts sending...
Main received: 10
Goroutine finished sending.

在这个示例中:

  1. ch := make(chan int) 创建了一个无缓冲通道(unbuffered channel)。无缓冲通道要求发送方和接收方同时准备好才能进行通信。这被称为同步(synchronization)。
  2. go func() { ... }() 启动一个新的 goroutine。go 关键字使该函数与 main 函数并发执行。
  3. 在 goroutine 内部,ch <- 10 向通道发送值 10。此操作会阻塞,直到 main goroutine 准备好接收。
  4. main 函数中,value := <-ch 接收该值。此操作会阻塞,直到 goroutine 发送一个值。
  5. 一旦双方都准备好,值就会被传输,两个 goroutine 都会解除阻塞并继续执行。我们添加了 time.Sleep 以确保程序在 goroutine 打印其最后一条消息之前不会退出。

关闭通道

当不再有值要发送到通道时,发送方可以使用 close() 函数将其关闭。读取通道中所有值直到它被关闭的常见方法是使用 for range 循环。

我们来修改 channel.go

package main

import "fmt"

func main() {
    // 一个可以容纳多个值的缓冲通道
    ch := make(chan int, 3)

    // 启动一个 goroutine 来发送数据
    go func() {
        fmt.Println("Goroutine sending 10, 20, 30")
        ch <- 10
        ch <- 20
        ch <- 30
        // 发送完所有值后关闭通道
        close(ch)
        fmt.Println("Goroutine closed the channel.")
    }()

    fmt.Println("Main is receiving data...")
    // 使用 for-range 循环接收值,直到通道关闭
    for value := range ch {
        fmt.Printf("Main received: %d\n", value)
    }
    fmt.Println("Main finished receiving.")
}

使用以下命令运行程序:

go run channel.go

程序输出如下(顺序可能略有不同):

Main is receiving data...
Goroutine sending 10, 20, 30
Main received: 10
Main received: 20
Main received: 30
Goroutine closed the channel.
Main finished receiving.

这展示了一个常见的模式:for range 循环会自动处理检查通道是否已关闭,并在通道关闭时退出循环。

在使用已关闭的通道时,请记住以下关键规则:

  • 向已关闭的通道发送数据将导致运行时错误(panic)。
  • 从已关闭但为空的通道接收数据将立即返回通道类型的零值(zero value)。
  • 关闭一个已经关闭的通道将导致 panic。
  • 通常情况下,只有发送方应该关闭通道,接收方绝不应该关闭。

通道阻塞

正如我们在上一步中看到的,通道操作可能会阻塞。这是允许 goroutine 进行同步的一个关键特性。让我们更仔细地研究这一点。

无缓冲通道

使用 make(chan T) 创建的通道是无缓冲的(unbuffered)。

ch1 := make(chan int) // 无缓冲通道

无缓冲通道没有容量。对无缓冲通道的发送操作会阻塞发送的 goroutine,直到另一个 goroutine 准备好接收。反之,接收操作会阻塞接收的 goroutine,直到另一个 goroutine 准备好发送。这创建了一个强同步点,正如我们在上一步的第一个示例中看到的那样。

缓冲通道

通道也可以是缓冲的(buffered),这意味着它们具有在阻塞之前容纳一定数量值的容量。

ch2 := make(chan int, 5) // 容量为 5 的缓冲通道

对于缓冲通道:

  • 仅当缓冲区满时,发送操作才会阻塞。
  • 仅当缓冲区为空时,接收操作才会阻塞。

如果缓冲区未满,发送方可以在接收方尚未准备好时发送一个值。该值将被存储在通道的队列中。

死锁示例

如果超出通道的容量会发生什么?程序将导致死锁(deadlock)。当程序中所有的 goroutine 都被阻塞,等待永远不会发生的事情时,就会发生死锁。

创建一个名为 channel1.go 的文件:

cd ~/project
touch channel1.go

写入以下代码,该代码尝试在主 goroutine 中向容量为 1 的通道发送两个值:

package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 10 // This succeeds
    fmt.Println("Sent 10 to channel")
    ch <- 20 // This blocks forever because the channel is full
    fmt.Println("succeed")
}

使用以下命令运行程序:

go run channel1.go

程序输出如下:

Sent 10 to channel
fatal error: all goroutines are asleep - deadlock!

程序因死锁错误而 panic。main goroutine 发送 10,填满了缓冲区。然后它尝试发送 20,但由于缓冲区已满而阻塞。由于没有其他 goroutine 会从通道接收,main goroutine 将永远阻塞,导致 Go 运行时检测到死锁。

要修复此问题,必须与另一个可以接收该值并为缓冲区腾出空间的 goroutine 协调操作。

单向通道

默认情况下,通道是双向的,可以进行读取或写入。有时,我们需要限制通道的使用,例如,我们想确保在一个函数中通道只能被写入或只能被读取。我们如何实现这一点呢?

我们可以在初始化通道之前,显式声明一个使用受限的通道。

只写通道

只写通道(Write-only channel)意味着在特定函数中,通道只能被写入而不能被读取。让我们声明并使用一个类型为 int 的只写通道:

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 10
    fmt.Println("Data written to channel")
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    fmt.Println("Data received:", <-ch)
}

使用以下命令运行程序:

go run channel.go

程序输出如下:

Data written to channel
Data received: 10

在这个示例中,writeOnly 函数将通道限制为只能写入。

只读通道

只读通道(Read-only channel)意味着在特定函数中,通道只能被读取而不能被写入。让我们声明并使用一个类型为 int 的只读通道:

package main

import "fmt"

func readOnly(ch <-chan int) {
    fmt.Println("Data received from channel:", <-ch)
}

func main() {
    ch := make(chan int, 1)
    ch <- 20
    readOnly(ch)
}

使用以下命令运行程序:

go run channel.go

程序输出如下:

Data received from channel: 20

在这个示例中,readOnly 函数将通道限制为只能读取。

组合只写和只读通道

你可以组合只写和只读通道,以演示数据如何通过受限通道流动:

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 30
    fmt.Println("Data written to channel")
}

func readOnly(ch <-chan int) {
    fmt.Println("Data received from channel:", <-ch)
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    readOnly(ch)
}

使用以下命令运行程序:

go run channel.go

程序输出如下:

Data written to channel
Data received from channel: 30

这个示例说明了只写通道如何将数据传递给只读通道。

总结

在这个实验中,我们学习了通道的基础知识,包括:

  • 通道的类型以及如何声明它们
  • 初始化通道的方法
  • 对通道的操作
  • 通道阻塞的概念
  • 单向通道的声明