Golang 中的通道原语

GolangGolangBeginner
立即练习

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

介绍

Go 语言最吸引人的特性之一是其对并发编程的原生支持。它在并行处理、并发编程和网络通信方面提供了强大的支持,使得能够更高效地利用多核处理器。

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

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

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

知识点:

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

Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL go(("Golang")) -.-> go/ConcurrencyGroup(["Concurrency"]) go(("Golang")) -.-> go/BasicsGroup(["Basics"]) go(("Golang")) -.-> go/DataTypesandStructuresGroup(["Data Types and Structures"]) go/BasicsGroup -.-> go/values("Values") go/BasicsGroup -.-> go/variables("Variables") go/DataTypesandStructuresGroup -.-> go/structs("Structs") go/ConcurrencyGroup -.-> go/goroutines("Goroutines") go/ConcurrencyGroup -.-> go/channels("Channels") go/ConcurrencyGroup -.-> go/select("Select") subgraph Lab Skills go/values -.-> lab-149096{{"Golang 中的通道原语"}} go/variables -.-> lab-149096{{"Golang 中的通道原语"}} go/structs -.-> lab-149096{{"Golang 中的通道原语"}} go/goroutines -.-> lab-149096{{"Golang 中的通道原语"}} go/channels -.-> lab-149096{{"Golang 中的通道原语"}} go/select -.-> lab-149096{{"Golang 中的通道原语"}} end

通道概述

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

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

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

通道类型与声明

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

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 // 声明一个传输整数切片的通道
    fmt.Println(ch1, ch2, ch3)
}

以上代码简单地声明了三种不同类型的通道。

使用以下命令运行程序:

go run channel.go

程序输出如下:

<nil> <nil> <nil>

通道初始化

与 map 类似,通道在声明后需要初始化才能使用。

初始化通道的语法如下:

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

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

通过观察输出,我们可以看到,与 map 不同,直接打印通道本身并不会显示其内容。

直接打印通道只会得到通道本身的内存地址。这是因为,与指针类似,通道只是对内存地址的引用。

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

通道操作

发送数据

通道操作有其独特的方式。通常,在处理其他数据类型时,我们使用 = 和索引来访问和操作数据。

但对于通道,我们使用 <- 进行发送和接收操作。

在通道中,写入数据称为发送,即将数据发送到通道中。

那么,我们如何发送数据呢?

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

package main

import "fmt"

func main() {
    ch := make(chan int, 3)
    ch <- 10
    fmt.Println(ch)
}

使用以下命令运行程序:

go run channel.go

程序输出如下:

0xc0000b4000

尽管我们将数据放入了通道,但从通道值的输出中无法直接看到数据。

这是因为通道中的数据仍然需要被接收后才能使用。

接收数据

从通道接收数据是什么意思?当我们学习 map 或切片时,可以根据索引或键从这些数据类型中检索任何数据。

对于通道,我们只能检索最早进入通道的值。

值被接收后,它将不再存在于通道中。

换句话说,从通道接收数据就像在其他数据类型中查询和删除数据。

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

package main

import "fmt"

func main() {
    ch := make(chan int, 3)
    ch <- 10
    ch <- 20
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

使用以下命令运行程序:

go run channel.go

程序输出如下:

10
20

我们可以看到,最先进入通道的值 10 最先输出,输出后它不再存在于通道中。

稍后进入的值 20 会稍后输出,这体现了通道的先进先出原则。

关闭通道

当通道不再需要时,应该关闭它。然而,在关闭通道时,我们需要注意以下几点:

  • 向已关闭的通道发送值会导致运行时错误。

    package main
    
    import "fmt"
    
    func main() {
        ch := make(chan int, 3)
        ch <- 10
    
        fmt.Println(<-ch)
    
        close(ch)
        ch <- 20
    }

    程序输出如下:

    10
    panic: send on closed channel
    
    goroutine 1 [running]:
    main.main()
        /home/labex/project/channel.go:10 +0x1a0
    exit status 2
  • 从已关闭的通道接收数据会继续获取值,直到通道为空。

    package main
    
    import "fmt"
    
    func main() {
        ch := make(chan int, 3)
    
        ch <- 10
        ch <- 20
        ch <- 30
    
        close(ch)
    
        fmt.Println(<-ch)
        fmt.Println(<-ch)
        fmt.Println(<-ch)
    }

    使用以下命令运行程序:

    go run channel.go

    程序输出如下:

    10
    20
    30
  • 从已关闭的空通道接收数据会得到相应数据类型的默认初始值。

    package main
    
    import "fmt"
    
    func main() {
        ch := make(chan int, 3)
    
        ch <- 10
        ch <- 20
        ch <- 30
    
        close(ch)
    
        fmt.Println(<-ch)
        fmt.Println(<-ch)
        fmt.Println(<-ch)
        fmt.Println(<-ch)
        fmt.Println(<-ch)
    }

    使用以下命令运行程序:

    go run channel.go

    程序输出如下:

    10
    20
    30
    0
    0
  • 关闭已关闭的通道会抛出错误。

    package main
    
    import "fmt"
    
    func main() {
        ch := make(chan int, 3)
        ch <- 10
        close(ch)
        close(ch)
        fmt.Println(<-ch)
    }

    程序输出如下:

    panic: close of closed channel
    
    goroutine 1 [running]:
    main.main()
        /home/labex/project/channel.go:9 +0x75
    exit status 2

通道阻塞

细心的同学可能已经注意到,在介绍通道声明和初始化时,我们没有指定通道的容量:

package main

import "fmt"

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

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

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

    fmt.Println(ch1, ch2, ch3)
}

但在演示通道操作时,我们指定了通道的容量:

package main

import "fmt"

func main() {
    // 指定通道容量为 3
    ch := make(chan int, 3)
    ch <- 10
    ch <- 20
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

对于没有指定容量的通道,它们被称为无缓冲通道(unbuffered channel),没有缓冲区空间。

如果发送方或接收方没有准备好,第一个操作将会阻塞,直到另一个操作准备就绪。

chan1 := make(chan int) // 无缓冲的 int 类型通道

对于指定了容量和缓冲区空间的通道,它们被称为缓冲通道(buffered channel)。

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

它们的功能类似于队列,遵循先进先出(FIFO)规则。

对于缓冲通道,我们可以使用发送操作将元素追加到队列的末尾,并使用接收操作从队列的头部移除元素。

如果我们将超过容量的数据放入缓冲通道会发生什么?让我们来验证一下。将以下代码写入 channel1.go 文件:

cd ~/project
touch channel1.go
package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 10
    ch <- 20
    fmt.Println("succeed")
}

使用以下命令运行程序:

go run channel1.go

程序输出如下:

fatal error: all goroutines are asleep - deadlock!

我们发现程序死锁了,因为通道已经满了。

为了解决这个问题,我们可以先从通道中提取数据。在 channel.go 中编写以下代码:

package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 10
    fmt.Println("提取的数据:", <-ch)

    ch <- 20
    fmt.Println("succeed")
}

使用以下命令运行程序:

go run channel.go

程序输出如下:

提取的数据: 10
succeed

通过采取取用、再取用的策略,我们可以持续使用容量有限的通道。

单向通道

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

我们可以在初始化通道之前显式声明其使用限制。

只写通道

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

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 10
    fmt.Println("数据已写入通道")
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    fmt.Println("接收到的数据:", <-ch)
}

使用以下命令运行程序:

go run channel.go

程序输出如下:

数据已写入通道
接收到的数据: 10

在这个例子中,writeOnly 函数限制了通道只能写入。

只读通道

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

package main

import "fmt"

func readOnly(ch <-chan int) {
    fmt.Println("从通道接收到的数据:", <-ch)
}

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

使用以下命令运行程序:

go run channel.go

程序输出如下:

从通道接收到的数据: 20

在这个例子中,readOnly 函数限制了通道只能读取。

结合只写和只读通道

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

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 30
    fmt.Println("数据已写入通道")
}

func readOnly(ch <-chan int) {
    fmt.Println("从通道接收到的数据:", <-ch)
}

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

使用以下命令运行程序:

go run channel.go

程序输出如下:

数据已写入通道
从通道接收到的数据: 30

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

总结

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

  • 通道的类型及其声明方法
  • 通道的初始化方法
  • 通道的操作
  • 通道阻塞的概念
  • 单向通道的声明