【编程二三事】初识Channel

什么是通道?

​ Channel是Go中的一个核心类型,你可以把它看成一个管道,通过它并发核心单元就可以发送或者接收数据进行通讯(communication)。

​ 它提供了一种同步的机制,确保在数据发送和接收之间的正确顺序和时机。通过使用channel,我们可以避免在多个goroutine之间共享数据时出现的竞争条件和其他并发问题。

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

通道的分类

​ 通道的类型主要分为以下几种:双向通道、只读通道、只写通道

双向通道

go 复制代码
var ch chan string = make(chan string) // 双向channel

​ 通过如上定义,我们定义了一个双向通道,这个通道支持读、写操作。

只读通道

go 复制代码
	var readCh <-chan string = make(<-chan string) // 只读channel

​ 通过如上定义,我们定义了一个只读通道,这个通道只支持读操作。如果需要往通道中写入数据的时候,就会报错。

只写通道

go 复制代码
	var writeCh chan<- string = make(chan<- string) // 只写channel

​ 只写通道与只读通道类型,只支持写操作。如果往通道中读数据,就会提示错误

通道的基本操作

创建/初始化

​ 如果我们想要创建一个channel,用来传递字符串类型的数据,可以这样定义:

go 复制代码
ch := make(chan string)

​ 可以看到,我们定义了一个可以传递字符串类型数据的channel。相似的,我们还可以创建其他不同类型的通道。

go 复制代码
ch := make(chan int) // int 类型的通道
ch := make(chan MyStruct) // 自定义结构体的通道
type MyStruct struct {
	ch chan int
}

​ 但是需要注意的是,这样创建出来的通道,默认不携带缓冲区,意味着只有消费者把通道的数据消费完了,才可以往通道中存入下一个数据。

​ 如果我们想要一个带缓冲区的通道,还可以可以这样创建:

go 复制代码
ch := make(chan string, 10)

​ 如上,我们创建了一个缓冲区为10的通道。只有当缓冲区中的数据都装满了,通道才会拒绝接受数据。

发送

发送操作用于将数据发送到channel中。发送操作的语法如下:

go 复制代码
ch <- x

其中ch表示要发送数据的channel,x表示要发送的数据。例如,下面的代码将字符串"hello"发送到了ch这个channel中:

go 复制代码
ch <- "hello"

注意,如果channel已经满了,发送操作会被阻塞,直到有其他协程从channel中取走了数据。这样可以保证在channel已经没有空间存放数据的情况下,发送操作不会丢失数据。

接收

接收操作用于从channel中取出数据。其语法如下:

go 复制代码
x := <- ch

其中ch表示要接收数据的channel,x表示接收到的数据。例如,下面的代码从ch这个channel中取出了一个字符串:

go 复制代码
x := <- ch

注意,如果channel中没有数据可供接收,接收操作会被阻塞,直到有其他协程向channel中发送了数据。这样可以保证在channel中没有数据可供接收的情况下,接收操作不会返回错误。

关闭

关闭操作用于关闭channel。关闭一个channel之后,就不能再向它发送数据了,但是仍然可以从它接收数据。关闭操作的语法如下:

go 复制代码
close(ch)

其中ch表示要关闭的channel。例如,下面的代码关闭了ch这个channel:

go 复制代码
close(ch)

注意,若一个channel已被关闭,再向它发送数据会导致panic错误。

⚠️ PS:对长期使用的 channel,遵循 "谁生产谁关闭" 原则,尽量避免消费者永久阻塞。

通道的状态

​ 结合上述的操作和通道的数据状态,可以绘制出如下的图表:

​ 总结来说,通道的状态变化主要如下:

为什么用通道?

​ 了解了通道的基本操作和用法,那么为什么需要使用通道呢?哪些情况下又该使用通道呢?

通道的使用场景

生产者 - 消费者模型

场景:多个 Goroutine 生成数据(生产者),多个 Goroutine 处理数据(消费者)。

通道的作用:作为缓冲区,连接生产者和消费者,实现解耦和流量控制。

go 复制代码
func producer(ch chan<- int) {
    for i := 0; i < 10; i++ {
        ch <- i
    }
    close(ch)
}

func consumer(ch <-chan int) {
    for val := range ch {
        fmt.Println("处理数据:", val)
    }
}

定时任务与超时控制

  • 场景:需要在指定时间后执行任务,或防止某个操作无限期阻塞。
  • 通道的作用 :结合time.Timertime.Ticker,使用通道实现定时触发或超时处理。
go 复制代码
select {
case result := <-ch:
    // 处理结果
case <-time.After(5 * time.Second):
    // 超时处理
}

信号通知与优雅关闭

  • 场景:需要通知多个 Goroutine 停止工作(如服务关闭时)。

  • 通道的作用:使用专门的通道发送关闭信号,协调多个 Goroutine 的生命周期。

    go 复制代码
    stopCh := make(chan struct{})
    
    // 工作协程
    go func() {
        for {
            select {
            case <-stopCh:
                return // 收到停止信号后退出
            default:
                // 执行工作
            }
        }
    }()
    
    // 主函数中发送停止信号
    close(stopCh)

控制并发数量(限流)

  • 场景:限制同时执行的 Goroutine 数量,防止资源耗尽。

  • 通道的作用:使用带缓冲的通道作为令牌桶,控制并发数。

    go 复制代码
    semaphore := make(chan struct{}, 10) // 最多10个并发
    
    for i := 0; i < 100; i++ {
        semaphore <- struct{}{} // 获取令牌
        go func() {
            defer func() { <-semaphore }() // 释放令牌
            // 执行受限操作
        }()
    }

多个结果的聚合

  • 场景:并行执行多个任务,等待所有任务完成后汇总结果。

  • 通道的作用 :收集每个任务的结果,使用sync.WaitGroupselect等待所有结果。

    go 复制代码
    results := make(chan int, 5)
    
    for i := 0; i < 5; i++ {
        go func(id int) {
            results <- id * id
        }(i)
    }
    
    // 收集所有结果
    for i := 0; i < 5; i++ {
        fmt.Println(<-results)
    }

​ 在实际应用中,多个结果的聚合是用的最多的,比如要加载100个商品等信息,就可以拆分成5个线程,每个线程同时去加载商品库存、名称等数据,此时让子线程去异步加载,主线程则逐一消费每个子线程的数据。从而达到加快处理速度的目的,具体示例如下:

go 复制代码
func main() {
    totalProducts := 100 // 总商品数量
    numWorkers    := 5   // 工作线程数量

    // 创建商品通道和等待组
    productCh := make(chan Product, totalProducts)
    var wg sync.WaitGroup

    // 启动生产者协程(工作线程)
    wg.Add(numWorkers)
    for workerID := 0; workerID < numWorkers; workerID++ {
       go func(id int) {
          defer wg.Done()

          // 计算每个工作线程负责的商品范围
          start := (id * totalProducts) / numWorkers
          end := ((id + 1) * totalProducts) / numWorkers
          fmt.Printf("工作线程%d: 加载商品%d-%d\n", id, start, end-1)

          // 生产商品数据
          for i := start; i < end; i++ {
             product := loadProduct(i)
             productCh <- product
             fmt.Printf("工作线程%d: 已加载商品%d\n", id, i)
          }
       }(workerID)
    }

    // 启动一个协程,在所有生产者完成后关闭通道
    go func() {
       wg.Wait()
       close(productCh)
       fmt.Println("所有商品加载完成,通道已关闭")
    }()

    // 主线程作为消费者处理商品数据
    processedCount := 0
    for product := range productCh {
       // 模拟处理商品数据
       fmt.Printf("主线程: 处理商品 %d - %s (库存: %d, 价格: %.2f, 分类: %s)\n", product.ID, product.Name, product.Stock, product.Price, product.Category)

       // 模拟处理耗时
       processedCount++
    }

    fmt.Printf("主线程: 共处理 %d 个商品\n", processedCount)
}

如何优雅使用通道?

​ 上一章节介绍了通道的常见使用场景,那么,在使用通道的过程中,又该如何优雅使用呢?

如何安全关闭通道

​ 如果需要优雅使用通道,那么学会优雅的关闭通道是必不可少的。因为关闭通道不谨慎处理的话,可能导致程序崩溃或产生不可预期的行为。

​ 安全关闭通道(Channel)的核心原则是:由发送方负责关闭,且只关闭一次。以下是针对不同场景的安全关闭方法和最佳实践:

单发送方

发送方自然结束后关闭,当发送方明确知道自己何时完成所有发送任务时,直接在发送结束后关闭通道。

go 复制代码
ch := make(chan int)
go func() {
    for i := 0; i < 10; i++ {
        ch <- i
    }
    close(ch) // 单发送方安全关闭
}()

使用 defer 确保关闭 ,若发送方可能因异常提前退出,使用defer确保通道一定会被关闭。

go 复制代码
ch := make(chan int)

go func() {
    defer close(ch) // 无论函数如何退出,通道都会被关闭
    
    for i := 0; i < 10; i++ {
        if err := someOperation(); err != nil {
            return // 提前退出,但defer会执行close(ch)
        }
        ch <- i
    }
}()
多发送方

​ 创建一个专门的通道,由主协程控制何时停止所有发送方,并在确认所有发送方停止后关闭数据通道。

go 复制代码
ch := make(chan int)
done := make(chan struct{}) // 通知停止的信号

// 多个发送方
for i := 0; i < 3; i++ {
    go func(id int) {
        for {
            select {
            case <-done:
                return
            case ch <- id:
                // 发送数据
            }
        }
    }(i)
}

// 主函数决定何时停止并关闭通道
go func() {
    time.Sleep(time.Second)
    close(done) // 通知所有发送方停止
    close(ch)   // 所有发送方停止后,主函数关闭通道
}()

​ 这样的写法在定时任务、异步任务也是常用,这样能使得线程中的任务被优雅的停机关闭,这样下次再执行任务时,可以根据上次停止的位置重新执行。

使用 sync.Once 确保只关闭一次

当无法确定哪个发送方最后完成时,使用sync.Once保证通道只被关闭一次。

go 复制代码
var once sync.Once
dataCh := make(chan int)

// 多个发送方共享关闭逻辑
closeDataCh := func() {
    once.Do(func() {
        close(dataCh)
        fmt.Println("数据通道已关闭")
    })
}

// 启动多个发送方
for i := 0; i < 3; i++ {
    go func(id int) {
        defer closeDataCh() // 每个发送方都尝试关闭,但实际只执行一次
        
        for j := 0; j < 5; j++ {
            dataCh <- id*10 + j
            time.Sleep(time.Millisecond * 100)
        }
    }(i)
}

// 接收方处理数据
for data := range dataCh {
    fmt.Println("接收:", data)
}

总结

​ 本文介绍了常见的通道分类,分为双向、只读和只写;

​ 紧接着又介绍了通道的基本操作:创建、发送、接收和关闭,并结合通道的存储状态和基本操作,推演出了通道的基本流程走向。

​ 最后本文介绍了通道的常见使用场景,并给出了具体示例。最后再结合通道的使用场景,给出了优雅使用通道的方式。

​ 希望通过本文的讲解,能帮助大家更好的使用通道。

参考资料

详解Golang中channel的用法

Go 并发可视化解释 --- Channel

channel的使用

总结了才知道,原来channel有这么多用法!V2

go channel 万字详解

Go语言基础结构 ------ Channel(通道)

深入解析go channel各状态下的操作结果

相关推荐
追逐时光者25 分钟前
一个基于 .NET 8 开源免费、高性能、低占用的博客系统
后端·.net
方圆想当图灵1 小时前
深入理解软件设计:领域驱动设计实战
后端·领域驱动设计
网小鱼的学习笔记2 小时前
flask静态资源与模板页面、模板用户登录案例
后端·python·flask
ZHOU_WUYI2 小时前
多组件 flask 项目
后端·flask
十六点五3 小时前
JVM(4)——引用类型
java·开发语言·jvm·后端
周末程序猿3 小时前
Linux高性能网络编程十谈|9个C++的开源的网络框架
后端·算法
倔强青铜三4 小时前
🚀LlamaIndex中文教程(1)----对接Qwen3大模型
人工智能·后端·python
小码编匠4 小时前
基于 SpringBoot 开源智碳能源管理系统(EMS),赋能企业节能减排与碳管理
java·后端·开源
知其然亦知其所以然4 小时前
Spring AI:ChatClient API 真香警告!我用它把聊天机器人卷上天了!
后端·aigc·ai编程