一、前言

本文主要以下几方面介绍Go语言中并发编程:

  • 理解并发和并行
  • 理解协程和线程
  • 并发处理任务
  • 理解Go通道channel
  • 理解Go通道channel阻塞
  • 理解缓冲通道
  • Select处理多个通道及通道超时
  • 监听通道的退出信号

二、理解并发和并行

2.1 为什么要用并发和并行

并发和并行是计算机科学和编程中非常重要的概念,它们的存在和应用有多个原因:

1. 提高程序性能:

  • 并发:通过允许程序的不同部分同时执行,可以更充分地利用处理器的等待时间,提高程序的吞吐量。这对于处理I/O密集型任务非常有用,例如网络请求、数据库查询和文件读写,因为在等待外部资源时,CPU可以继续处理其他任务。
  • 并行:在多核处理器上并行执行任务可以显著提高程序的性能。特别是对于CPU密集型任务,例如图像处理、科学计算和机器学习,利用多核处理器可以大幅度减少任务的执行时间。

2. 提高响应性:

  • 并发:在图形用户界面(GUI)应用程序中,通过在后台执行任务,可以保持应用程序的响应性。例如,一个应用程序可以在后台下载文件或处理数据,同时允许用户继续与应用程序交互。

3. 简化程序结构:

  • 并发:许多现代应用程序需要同时处理多个任务,如网络请求、用户输入、定时任务等。使用并发可以简化程序结构,将复杂任务拆分为更小的子任务,然后并发地执行这些子任务,使代码更容易理解和维护。

4. 利用硬件资源:

  • 并发:多核处理器已经成为现代计算机的标配。通过并发和并行,可以更好地利用这些硬件资源,充分发挥计算机的性能潜力。

5. 支持高并发性:

  • 并发:在服务器端编程中,支持大量并发请求是关键。通过使用并发模型,服务器可以同时处理多个客户端请求,提高了系统的可伸缩性和性能。

2.2 什么是并发和并行

并发(Concurrency):并发是指程序的多个部分(例如,不同的函数或线程)在一段时间内同时执行。在单核处理器上,这些部分交替运行,通过时间片轮转来实现。并发可以让程序更有效地利用等待时间,处理多个任务,例如网络请求、I/O 操作等,而不会阻塞整个程序。

并行(Parallelism):并行是指程序的多个部分同时在多个处理器核心上执行。这允许程序真正并行处理多个任务,加速程序的执行速度。

2.3 并发和并行特性

Go语言中的并发特性:

  • goroutine:goroutine 是轻量级的线程,由Go语言的运行时管理。创建一个goroutine非常廉价,可以创建数千个goroutine而不会导致资源耗尽。使用关键字go来启动一个goroutine,例如:go myFunction()
  • 通道(Channels):通道是goroutine之间用于通信的安全方式。它们允许一个goroutine发送数据到通道,并且另一个goroutine可以从通道接收数据。通道可以帮助协调多个goroutine之间的操作。
  • 选择语句(Select Statement)select语句允许在多个通道操作之间选择,以便执行其中一个可用的操作。这对于处理多路通信非常有用。

Go语言中的并行特性:

  • 多核支持:Go语言运行时能够利用多核处理器,将goroutines调度到不同的处理器核心上执行。
  • runtime.GOMAXPROCS():通过这个函数,你可以设置运行时使用的处理器核心数量,以控制并行度。
  • sync:Go语言提供了sync包,其中包括了一些用于同步并行程序的工具,如互斥锁(Mutex)和条件变量(Cond)。

2.4 并发和并行区别

并发是一种组织结构上的概念,强调多个任务同时执行的能力,但不一定要求同时执行在多核处理器上。

并行则是一种执行上的概念,强调多个任务真正同时在多核处理器上执行。

2.5 并发与并行的应用场景

并发通常用于处理I/O密集型任务,如网络请求、文件读写等,以避免阻塞程序。

并行通常用于处理CPU密集型任务,如图像处理、科学计算等,以加速计算过程。

三、理解协程和线程

3.1 为什么要用协程和线程

并发性和性能:协程和线程都允许程序在多个任务之间并发执行,从而提高程序性能,特别是在多核处理器上。并发性有助于充分利用硬件资源,加速计算和I/O密集型任务。

并行计算:协程和线程使程序可以同时执行多个任务,这对于需要大量计算的应用程序非常有用,如数据处理和科学计算。

响应性:使用协程和线程可以确保应用程序在执行阻塞操作(如文件读写或网络通信)时不会阻塞整个程序,保持了应用程序的响应性。

简化编程:协程相对于传统的线程编程更容易使用,因为它们不需要手动管理线程的创建和销毁,也不需要显式的锁机制来处理并发问题。

3.2 什么是协程和线程

线程(Thread)是计算机操作系统中程序运行时最小的执行单元,是CPU调度的基本单元。一个线程可以理解为一个任务,都具有一个独立的控制流。其中在多线程编程中,可以将不同的任务或操作封装在不同的线程中,然后并发地执行这些任务,从而提高程序的性能和响应速度。

协程(Corotine)是一种轻量级的线程,也被称为用户态线程。与操作系统中的线程不同,协程由程序员自己管理调度。协程可以使用更少的资源,更快的创建和销毁,协程之间可以通过通道(Channel)进行通信和同步,避免了传统线程中锁等同步机制的开销和复杂性。要创建和启动一个协程,使用go关键字即可。

3.3 协程和线程特性

协程的特性

  • 轻量级:协程的创建和切换非常快速,占用少量内存。
  • 自动管理:Go运行时自动管理协程的生命周期和调度。
  • 通道通信:协程之间通过通道进行通信和同步。
  • 并发安全:Go语言提供了通道和互斥锁等机制来确保并发安全。

线程的特性

  • 重量级:线程通常需要较多的内存和系统资源。
  • 操作系统管理:线程由操作系统负责调度和管理。
  • 需要手动同步:在传统多线程编程中,需要手动使用锁等机制来确保并发安全。
  • 更多的控制:线程编程提供更多底层控制,但也更容易引入复杂的错误。

3.4 协程和线程区别

1.资源消耗

  • 协程:轻量级,占用较少内存(一般在2kb左右)。
  • 线程:较重量级,占用较多内存和系统资源(一般在8MB左右)。

2.创建和调度

  • 协程:由Go运行时自动管理,创建和调度非常快速。
  • 线程:通常需要手动创建和销毁,操作系统负责调度,创建和销毁线程较慢。

3.同步机制

  • 协程:通过通道(channel)来进行通信和同步,通常更安全和简单。
  • 线程:通常需要显式使用锁、条件变量等机制来进行同步,容易引入死锁和竞态条件。

3.5 协程和线程应用场景

协程的应用场景

  • 并发任务:处理大量独立的任务,如网页爬虫、数据处理等。
  • 高并发服务器:构建高性能的网络服务器,如Web服务、REST API等。
  • 异步I/O:处理大量的I/O操作,如文件读写、网络通信、数据库操作等。
  • 并行计算:加速计算密集型任务,如科学计算和图像处理。

线程的应用场景

  • 多线程应用程序:在传统的多线程应用程序中,如C++和Java等,线程通常用于并发处理。
  • 系统级编程:操作系统、驱动程序和嵌入式系统等需要直接管理线程的领域。
  • 需要更精细控制的场景:一些应用需要对线程的创建和销毁进行更精细的控制。

四、并发处理任务

在Go语言中,我们可以使用goroutines(协程)来实现并发处理任务。Goroutines是Go语言的轻量级线程,可以在一个程序中同时运行数千个goroutines,而不会消耗太多系统资源。

下面演示如何使用协程和sync.WaitGroup来并发处理任务,并确保它们按照顺序输出。每个协程模拟了包子的制作过程,包括计时,以便可以看到不同馅料的包子是如何并行制作的。

将下面代码粘贴到00-concurrentProgramming-00.go文件中并保存该文件

package main

import (
    "fmt"
    "sync"
    "time"
)

// 定义一个做包子的函数
func makeBaozi(filling string, wg *sync.WaitGroup) {
    defer wg.Done() // 协程完成时减少WaitGroup计数器
    startTime := time.Now()
    fmt.Printf("%s馅开始的时间:%s\n", filling, startTime)
    fmt.Printf("开始做%s馅的包子\n", filling)
    // 1.剁馅
    fmt.Printf("开始剁%s馅\n", filling)
    // 2.擀皮
    fmt.Println("开始擀皮...")
    // 3.包包子
    fmt.Printf("开始包%s馅的包子...\n", filling)
    // 4.蒸
    fmt.Printf("开始蒸%s馅的包子...\n", filling)
    cost := time.Since(startTime)
    fmt.Printf("%s馅共耗费的时间:%s\n", filling, cost)
}

func main() {
    fillings := []string{"韭菜", "鸡蛋", "西葫芦"}
    var wg sync.WaitGroup

    for _, v := range fillings {
        wg.Add(1)            //增加WaitGroup计数器
        go makeBaozi(v, &wg) //主程序创建协程之后,并不会等待所有的协程执行成功
    }
    // 等待所有协程处理完成
    wg.Wait()
    fmt.Println("-------------------------")
    fmt.Println("所有包子都已制作完成")

}

针对上面代码分为几个部分进行详细说明:

部分 1 - 导入包

import (
    "fmt"
    "sync"
    "time"
)

这部分是导入所需的标准库包,包括fmt用于格式化输出、sync用于同步协程、time用于时间相关操作。

部分 2 - makeBaozi函数

// 定义一个做包子的函数
func makeBaozi(filling string, wg *sync.WaitGroup) {
    defer wg.Done() // 协程完成时减少WaitGroup计数器
    startTime := time.Now()
    fmt.Printf("%s馅开始的时间:%s\n", filling, startTime)
    fmt.Printf("开始做%s馅的包子\n", filling)
    // 1.剁馅
    fmt.Printf("开始剁%s\n", filling)
    // 2.擀皮
    fmt.Println("开始擀皮...")
    // 3.包包子
    fmt.Printf("开始包%s馅的包子...\n", filling)
    // 4.
    fmt.Printf("开始蒸%s馅的包子...\n", filling)
    cost := time.Since(startTime)
    fmt.Printf("%s馅共耗费的时间:%s\n", filling, cost)
}

这是一个自定义的函数,用于制作包子。它接受两个参数:filling表示包子的馅料,wg是一个sync.WaitGroup,用于等待协程完成。函数内部包括了一系列的打印语句,模拟了包子的制作过程,包括计时操作。defer wg.Done()用于在协程完成时减少WaitGroup的计数器。

部分 3 - main函数

func main() {
    fillings := []string{"韭菜", "鸡蛋", "西葫芦"}
    var wg sync.WaitGroup

    for _, v := range fillings {
        wg.Add(1)            //增加WaitGroup计数器
        go makeBaozi(v, &wg) //主程序创建协程之后,并不会等待所有的协程执行成功
    }

    // 等待所有协程处理完成
    wg.Wait()
    fmt.Println("-------------------------")
    fmt.Println("所有包子都已制作完成")
}

这是程序的主函数。它创建了一个字符串切片fillings,其中包含了不同馅料的包子。然后,它定义了一个sync.WaitGroup变量wg,以确保等待所有协程完成。接下来,使用for循环迭代fillings,为每个馅料启动一个协程,并使用wg.Add(1)增加WaitGroup计数器。最后,通过wg.Wait()等待所有协程完成。

部分 4 - 并发处理和输出

main函数中的for循环中,每次循环都会启动一个协程go makeBaozi(v, &wg),其中v是馅料,&wgWaitGroup的引用。这使得每个协程都可以独立运行,模拟制作不同馅料的包子的过程。最后,等待所有协程完成后,打印一条分隔线和一条消息,以表示所有包子都已制作完成。

运行上面代码

go run .\00-concurrentProgramming-00.go
鸡蛋馅开始的时间:2023-09-05 12:33:44.3988822 +0800 CST m=+0.007960201
开始做鸡蛋馅的包子
开始剁鸡蛋馅
开始擀皮...
开始包鸡蛋馅的包子...
开始蒸鸡蛋馅的包子...
西葫芦馅开始的时间:2023-09-05 12:33:44.3988822 +0800 CST m=+0.007960201
开始做西葫芦馅的包子
开始剁西葫芦馅
开始擀皮...
开始包西葫芦馅的包子...
开始蒸西葫芦馅的包子...
西葫芦馅共耗费的时间:45.5169ms
韭菜馅开始的时间:2023-09-05 12:33:44.3988822 +0800 CST m=+0.007960201
鸡蛋馅共耗费的时间:45.0069ms
开始做韭菜馅的包子
开始剁韭菜馅
开始擀皮...
开始包韭菜馅的包子...
开始蒸韭菜馅的包子...
韭菜馅共耗费的时间:47.1395ms
-------------------------
所有包子都已制作完成

五、理解Go通道channel

5.1 为什么要用Go通道

Go通道是Go语言中强大且独特的并发编程机制,它解决了多个协程之间的通信和同步问题。以下是使用Go通道的一些重要原因:

  • 简化并发编程: 通道提供了一种直观的方式来协调协程之间的操作,使并发编程更加容易理解和维护。

  • 避免竞态条件(Race Conditions): 通道确保了多个协程安全地访问和修改共享数据,从而避免了竞态条件,减少了程序中的错误。

  • 提供同步机制: 通道允许协程等待其他协程完成工作,从而提供了同步的能力,确保协程之间的操作按照预期顺序执行。

  • 实现消息传递: 通道可用于在协程之间传递数据和消息,使得多个协程可以协同工作,完成复杂任务。

  • 可防止死锁: Go通道的设计可以防止协程因为等待数据而进入死锁状态。

5.2 什么是Go通道

Go通道是一种类型安全的、并发安全的数据结构,用于在不同协程之间传递数据和实现同步。通道具有先进先出(FIFO)的特性,它们允许一个协程发送数据到通道,另一个协程从通道接收数据。通道有两种类型:带缓冲的通道和不带缓冲的通道,它们用于不同的场景。

5.3 Go通道特性

Go通道特性一般有如下:

  • 线程安全:通道在多个协程之间提供了线程安全的数据传输和同步机制。
  • 阻塞特性:当通道为空时,接收操作将阻塞,直到有数据可用;当通道已满时,发送操作将阻塞,直到有空间可用。
  • 先进先出:通道按照先进先出的原则处理数据,确保数据的顺序不会混乱。
  • 可关闭性:通道可以被显式关闭,以通知接收方不再有数据发送。

5.4 Go通道应用场景

Go通道应用场景一般有如下:

  • 协程之间的通信:用于协调和共享数据。
  • 数据流处理:用于数据流的分发和收集。
  • 任务分发:用于将任务分发给多个协程以并发执行。
  • 同步和等待:用于等待协程完成或等待某个条件满足。
  • 控制并发:用于限制同时执行的协程数量。

5.5 Go通道基本语法

1.创建通道

ch := make(chan T)
  • T是通道中元素的类型。

2.发送数据到通道:

ch <- data

data发送到通道ch中。

3.从通道接收数据:

data := <-ch

从通道ch中接收数据并将其赋值给data

4.关闭通道:

close(ch)

用于关闭通道,不再向其发送数据。

如果不关闭通道,会有一些潜在的影响和问题:

  • 资源泄漏:通道是一种有限资源,如果你不关闭通道,它将一直存在于内存中,占用系统资源。在长时间运行的程序中,可能会积累大量未关闭的通道,导致资源泄漏。

  • 阻塞问题:当所有发送操作完成后,接收操作仍然可能会等待数据,因为通道不会被关闭,而发送操作只有在通道被关闭时才会解除阻塞。这可能导致程序永久性地等待数据。

  • 难以维护和理解:在大型程序中,未关闭的通道可能会导致代码更难以维护和理解。关闭通道是一种清晰的信号,告诉其他协程数据不再会发送,这有助于更好地理解程序的行为。

5.阻塞和非阻塞操作:

发送和接收操作可以是阻塞的,也可以是非阻塞的,具体取决于通道的状态和使用方式。

5.6 使用Go通道共享并发数据

下面演示使用协程和通道模拟包子的制作和上菜过程,展示了如何在 Go 中管理并发任务和数据共享。

将下面代码粘贴到01-concurrentProgramming-00.go文件中并保存该文件

package main

import (
    "fmt"
    "time"
)

// 定义一个做包子的函数
func makeBaozi(filling string, buns chan string) {
    fmt.Printf("开始做%s馅的包子\n", filling)
    // 1.剁馅
    fmt.Printf("开始剁%s\n", filling)
    // 2.擀皮
    fmt.Println("开始擀皮...")
    // 3.包包子
    fmt.Printf("开始包%s馅的包子...\n", filling)
    // 4.
    fmt.Printf("开始蒸%s馅的包子...\n", filling)
    // 5.蒸好了
    time.Sleep(time.Second * 1)
    fmt.Printf("%s馅的包子已经蒸好了,可以上菜了\n", filling)
    // 6.准备上包子
    // 将蒸好的包子放到通道内
    buns <- filling
    fmt.Printf("%s馅的包子已经放到上菜区,放置时间是:%s\n", filling, time.Now())

}

func main() {
    buns := make(chan string)
    fillings := []string{"韭菜", "鸡蛋", "西葫芦"}

    for _, v := range fillings {
        go makeBaozi(v, buns) //主程序创建协程之后并不会等待所有的协程执行成功
    }
    // 创建三个协程
    for i := 0; i < len(fillings); i++ {
        // 在这里取包子
        bun := <-buns //如果通道内没有数据就会一直阻塞直到取到数据为止
        fmt.Printf("%s馅的包子开始上菜了,上菜时间是:%s\n", bun, time.Now())
    }
}

针对上面代码分为几个部分进行详细说明:

部分 1 - 导入包

import (
    "fmt"
    "time"
)

这部分用于导入必要的 Go 标准库包,以便在代码中使用它们。具体包括:

  • fmt: 用于格式化输入和输出,你在代码中使用它来打印包子制作和上菜的信息。
  • time: 用于处理时间相关的操作,你在代码中使用它来模拟包子制作过程中的时间延迟。

部分 2 - makeBaozi 函数

// 定义一个做包子的函数
func makeBaozi(filling string, buns chan string) {
    fmt.Printf("开始做%s馅的包子\n", filling)
    // 1.剁馅
    fmt.Printf("开始剁%s\n", filling)
    // 2.擀皮
    fmt.Println("开始擀皮...")
    // 3.包包子
    fmt.Printf("开始包%s馅的包子...\n", filling)
    // 4.
    fmt.Printf("开始蒸%s馅的包子...\n", filling)
    // 5.蒸好了
    time.Sleep(time.Second * 1)
    fmt.Printf("%s馅的包子已经蒸好了,可以上菜了\n", filling)
    // 6.准备上包子
    // 将蒸好的包子放到通道内
    buns <- filling
    fmt.Printf("%s馅的包子已经放到上菜区,放置时间是:%s\n", filling, time.Now())

}
  • makeBaozi 函数是一个用于制作包子的协程函数。它接受两个参数,filling 表示包子的馅料,buns 是一个字符串类型的通道,用于将制作好的包子放入。
  • 在函数内部,模拟了包子的制作过程,包括剁馅、擀皮、包包子、蒸等步骤,并使用 fmt.Printf 打印相应的制作信息。
  • 通过 time.Sleep 模拟了蒸包子的时间,这里休眠 1 秒,以便模拟包子的制作时间。
  • 最后,它将制作好的包子的馅料信息发送到通道 buns 中,以便后续上菜。

部分 3 - 主函数 main

func main() {
    buns := make(chan string)
    fillings := []string{"韭菜", "鸡蛋", "西葫芦"}

    for _, v := range fillings {
        go makeBaozi(v, buns) // 主程序创建协程之后,并不会等待所有的协程执行成功
    }

    // 创建三个协程
    for i := 0; i < len(fillings); i++ {
        // 在这里取包子
        bun := <-buns // 如果通道内没有数据就会一直阻塞,直到取到数据为止
        fmt.Printf("%s馅的包子开始上菜了,上菜时间是:%s\n", bun, time.Now())
    }
}
  • main 函数中,你创建了一个字符串类型的通道 buns,用于包子的制作和上菜信息的传递。
  • 定义了一个包子馅料的切片 fillings,表示要制作的包子的不同馅料。
  • 使用 for 循环启动了多个 makeBaozi 协程,每个协程制作不同馅料的包子。这些协程会并发执行,主程序不会等待它们全部执行完成。
  • 接着,使用另一个 for 循环从通道 buns 中取出制作好的包子的馅料信息,并打印上菜的时间。如果通道中没有包子可取,这个操作会阻塞,直到有包子为止。

运行上面代码,观察到上菜时间和放置时间几乎同步

$ go run .\01-concurrentProgramming-00.go
开始做西葫芦馅的包子
开始剁西葫芦馅
开始擀皮...
开始包西葫芦馅的包子...
开始蒸西葫芦馅的包子...
开始做韭菜馅的包子
开始剁韭菜馅
开始擀皮...
开始包韭菜馅的包子...
开始蒸韭菜馅的包子...
开始做鸡蛋馅的包子
开始剁鸡蛋馅
开始擀皮...
开始包鸡蛋馅的包子...
开始蒸鸡蛋馅的包子...
鸡蛋馅的包子已经蒸好了,可以上菜了
韭菜馅的包子已经蒸好了,可以上菜了
西葫芦馅的包子已经蒸好了,可以上菜了
鸡蛋馅的包子开始上菜了,上菜时间是:2023-09-05 14:23:20.9468204 +0800 CST m=+1.016437001
鸡蛋馅的包子已经放到上菜区,放置时间是:2023-09-05 14:23:20.9468204 +0800 CST m=+1.016437001
韭菜馅的包子开始上菜了,上菜时间是:2023-09-05 14:23:20.9730821 +0800 CST m=+1.042698701
韭菜馅的包子已经放到上菜区,放置时间是:2023-09-05 14:23:20.9730821 +0800 CST m=+1.042698701
西葫芦馅的包子开始上菜了,上菜时间是:2023-09-05 14:23:20.9740793 +0800 CST m=+1.043695901
西葫芦馅的包子已经放到上菜区,放置时间是:2023-09-05 14:23:20.9740793 +0800 CST m=+1.043695901

六、理解Go通道channel阻塞

在 Go 中,通道(channel)是一种用于在不同协程之间传递数据的重要机制。通道的阻塞是指在以下情况下会发生的一种情况,它会导致协程暂时停止执行,直到特定条件满足为止。

1.发送操作的阻塞:当你向一个通道发送数据时,如果通道已满,发送操作将会阻塞,直到有其他协程从通道中接收数据以腾出空间。这种情况通常发生在以下情况下:

  • 通道的容量达到上限。
  • 没有其他协程准备好从通道中接收数据。

说明:默认创建的通道位置只有一个

2.接收操作的阻塞:当你从一个通道接收数据时,如果通道为空,接收操作将会阻塞,直到有其他协程向通道发送数据。这种情况通常发生在以下情况下:

  • 通道中没有数据可用于接收。
  • 没有其他协程准备好向通道发送数据。

针对上面两种阻塞情况可类比为在厨房中传递盘子的过程:

发送操作的阻塞:想象一下,在厨房中的一个人(这里命名为甲)手里拿着一叠盘子,另一个人(这里命名为乙)在餐桌上等着拿盘子。如果餐桌上的盘子满了,甲无法将更多的盘子放在上面,因此甲会停下来等待,直到乙拿走一些盘子为止。在 Go 通道中,这就是发送操作的阻塞。如果通道已满,发送操作会等待直到有其他协程从通道中取走数据。

接收操作的阻塞:现在,想象一个人(这里命名为乙)在餐桌上等着拿盘子,但桌子上没有盘子可用。乙会一直等待,直到有人把盘子放在桌子上为止。在 Go 通道中,这就是接收操作的阻塞。如果通道为空,接收操作会等待直到有其他协程向通道发送数据。

七、理解缓冲通道

7.1 基本含义

针对通道阻塞,我们可以使用缓冲通道进行阻塞。缓冲通道允许在通道中缓存一定数量的数据,而不会立即阻塞发送操作。只有当通道满时,发送操作才会阻塞。同样,只有当通道为空时,接收操作才会阻塞。缓冲通道允许更大程度的并发性,因为发送和接收操作的时间不需要严格匹配。

我们也可以使用通俗的语言来理解什么是缓冲通道:

想象一下,甲和乙之间有一张桌子,上面可以放一些盘子,那么甲可以把盘子放在桌子上,而乙稍后再取走。这允许甲和乙不必同时准备好,可以更加灵活地进行交换。在 Go 通道中,有缓冲的通道就像是有一张桌子,允许在发送和接收之间进行一定的时间差。

7.2 基本语法

1.创建缓冲通道

使用 make 函数来创建一个缓冲通道,需要指定通道的类型和缓冲区的容量。例如,创建一个容量为 3 的整数型缓冲通道:

ch := make(chan int, 3)

2.发送数据到缓冲通道

使用 <- 运算符将数据发送到缓冲通道。发送操作只有在通道已满时才会阻塞。

ch <- 42

3.从缓冲通道接收数据:同样使用 <- 运算符从缓冲通道接收数据。接收操作只有在通道为空时才会阻塞。

data := <-ch

4.关闭缓冲通道

可以使用 close 函数来关闭一个缓冲通道。关闭通道后,不能再向其发送数据,但仍然可以从中接收数据。

close(ch)

注意:一旦通道被关闭,再向其发送数据会导致 panic,但仍然可以从中接收已有的数据。缓冲通道通常用于实现生产者-消费者模式等场景,其中发送和接收操作的速度不一致时非常有用。

八、Select处理多个通道及通道超时

8.1 使用 select 处理多个通道

select 语句允许在多个通道之间进行选择,等待其中一个通道准备好执行操作。

使用 select 处理多个通道的基本语法如下:

select {
case value1 := <-channel1:
    //  channel1 可以接收数据时执行这里的代码
case value2 := <-channel2:
    //  channel2 可以接收数据时执行这里的代码
case channel3 <- data:
    //  channel3 可以发送数据时执行这里的代码
default:
    // 当没有通道操作准备好时执行这里的代码
}

以上是 select 语句的基本结构,它允许同时监听多个通道的操作,然后执行第一个准备好的操作。下面是对每个部分的解释:

  • case value1 := <-channel1::这是一个接收操作,它尝试从 channel1 中接收数据并将其存储在 value1 中。如果 channel1 中有数据可接收,这个 case 分支就会执行。
  • case value2 := <-channel2::类似于上面的接收操作,它尝试从 channel2 中接收数据并将其存储在 value2 中。如果 channel2 中有数据可接收,这个 case 分支就会执行。
  • case channel3 <- data::这是一个发送操作,它尝试向 channel3 发送数据 data。如果 channel3 可以发送数据(通道没有满),这个 case 分支就会执行。
  • default::这是一个可选的 default 分支,当没有任何通道操作准备好时执行。通常用于防止 select 语句阻塞,可以执行一些默认操作,比如处理其他任务或者返回错误信息。

注意:select 语句会等待其中一个通道操作准备好并执行,如果多个通道都准备好,将随机选择一个执行。如果没有通道操作准备好,且存在 default 分支,将执行 default 分支的代码。

下面进行示例演示,模拟两位厨师并发做菜,然后使用 select 来等待并获取他们做好的菜名。

将下面代码粘贴到02-concurrentProgramming-00.go文件中并保存该文件

package main

import (
    "fmt"
    "time"
)

// 定义做菜的函数
func cookDish(chef, dishName string, c chan string) {
    fmt.Printf("厨师:%s正在做:%s\n", chef, dishName)
    // 模拟做饭时间
    time.Sleep(time.Second * 2)
    // 将做好的菜放进通道内
    c <- dishName

}

func main() {
    // 定义两个channel,用于存放菜单
    chef1 := make(chan string)
    chef2 := make(chan string)
    go cookDish("chef1", "红烧豆腐", chef1)
    go cookDish("chef2", "宫保鸡丁", chef2)

    // 等待获取数据
    select {
    case dish := <-chef1:
        fmt.Println("厨师chef1已经做好了:", dish)
    case dish := <-chef2:
        fmt.Println("厨师chef2已经做好了:", dish)
    }
}

针对上面代码分为几个部分进行详细说明:

部分 1 - 导入包

import (
    "fmt"
    "time"
)

这是代码的开头部分,用于导入所需的 Go 包。在这里,我们导入了两个包,fmt 用于格式化输出,time 用于处理时间相关的操作。

部分 2 - cookDish 函数

func cookDish(chef, dishName string, c chan string) {
    fmt.Printf("厨师:%s正在做:%s\n", chef, dishName)
    // 模拟做饭时间
    time.Sleep(time.Second * 2)
    // 将做好的菜放进通道内
    c <- dishName
}

这是一个自定义函数 cookDish,用于模拟厨师做菜的过程。它接受三个参数:chef 表示厨师的名字,dishName 表示菜名,c 是一个字符串通道。函数内部首先打印出厨师的名字和正在做的菜名,然后通过 time.Sleep 模拟做菜的时间延迟,最后将做好的菜名发送到通道 c 中。

部分 3 - main 函数

func main() {
    // 定义两个channel,用于存放菜单
    chef1 := make(chan string)
    chef2 := make(chan string)
    go cookDish("chef1", "红烧豆腐", chef1)
    go cookDish("chef2", "宫保鸡丁", chef2)

    // 等待获取数据
    select {
    case dish := <-chef1:
        fmt.Println("厨师chef1已经做好了:", dish)
    case dish := <-chef2:
        fmt.Println("厨师chef2已经做好了:", dish)
    }
    // 关闭通道
    close(chef1)
    close(chef2)
}

这是 main 函数,是程序的入口点。它执行以下操作:

  • 创建了两个字符串通道 chef1chef2,用于分别存放两位厨师做的菜单。
  • 启动了两个协程,分别调用 cookDish 函数来模拟两位厨师分别做不同的菜,并将做好的菜名发送到不同的通道中。
  • 使用 select 语句等待两个通道中的数据。当其中一个通道准备好数据时,对应的 case 分支会执行,从通道中获取并打印出菜名。

部分 5 - 关闭通道

close(chef1)
close(chef2)

关闭了通道 chef1chef2,以释放资源

运行上面代码,这里运行了两次,结果不同,再次证明select执行是随机选择的

$ go run .\02-concurrentProgramming-00.go
厨师:chef2正在做:宫保鸡丁
厨师:chef1正在做:红烧豆腐
厨师chef1已经做好了: 红烧豆腐

$ go run .\02-concurrentProgramming-00.go
厨师:chef1正在做:红烧豆腐
厨师:chef2正在做:宫保鸡丁
厨师chef2已经做好了: 宫保鸡丁

8.2 使用 select 处理通道超时问题

使用 select 处理通道超时问题的基本语法如下:

select {
case <-channel1:
    //  channel1 有数据时执行这里的逻辑
case <-channel2:
    //  channel2 有数据时执行这里的逻辑
case <-time.After(timeoutDuration):
    // 在超时时间到达后执行这里的逻辑
}

关于上面代码说明:

time.After(timeoutDuration)创建了一个计时器通道,在指定的超时时间之后会向通道发送一个时间事件。然后,select 会同时等待多个通道操作,包括通道读取和计时器事件。

  • 如果 channel1channel2 有数据,select 将执行相应的 case 分支。
  • 如果在指定的超时时间内没有数据到达 channel1channel2,则 time.After 分支将触发,表示超时事件发生。

下面进行示例说明,将下面代码粘贴到02-concurrentProgramming-01.go文件中并保存该文件

package main

import (
    "fmt"
    "time"
)

// 定义做菜的函数
func cookDish(chef, dishName string, c chan string) {
    fmt.Printf("厨师:%s正在做:%s\n", chef, dishName)
    // 模拟做饭时间
    time.Sleep(time.Second * 4)
    // 将做好的菜放进通道内
    c <- dishName

}

func main() {
    // 定义两个channel,用于存放菜单
    chef1 := make(chan string)
    chef2 := make(chan string)
    go cookDish("chef1", "红烧豆腐", chef1)
    go cookDish("chef2", "宫保鸡丁", chef2)

    // 等待获取数据
    select {
    case dish := <-chef1:
        fmt.Println("厨师chef1已经做好了:", dish)
    case dish := <-chef2:
        fmt.Println("厨师chef2已经做好了:", dish)
    // 设置通道超时限制时间
    case <-time.After(time.Second * 3):
        fmt.Println("做饭超时")
    }

    // 关闭通道
    close(chef1)
    close(chef2)
}

针对上面代码分为几个部分进行详细说明:

部分 1 - 导入包

import (
    "fmt"
    "time"
)

这是代码的开头部分,用于导入所需的 Go 包。在这里,我们导入了两个包,fmt 用于格式化输出,time 用于处理时间相关的操作。

部分 2 - cookDish 函数

func cookDish(chef, dishName string, c chan string) {
    fmt.Printf("厨师:%s正在做:%s\n", chef, dishName)
    // 模拟做饭时间
    time.Sleep(time.Second * 4)
    // 将做好的菜放进通道内
    c <- dishName
}

这是一个自定义函数 cookDish,用于模拟厨师做菜的过程。它接受三个参数:chef 表示厨师的名字,dishName 表示菜名,c 是一个字符串通道。函数内部首先打印出厨师的名字和正在做的菜名,然后通过 time.Sleep 模拟做菜的时间延迟,最后将做好的菜名发送到通道 c 中。

部分 3 - main 函数

func main() {
    // 创建两个字符串通道
    chef1 := make(chan string)
    chef2 := make(chan string)

    // 启动两个协程,模拟两位厨师同时做菜
    go cookDish("chef1", "红烧豆腐", chef1)
    go cookDish("chef2", "宫保鸡丁", chef2)
}

main 函数中,首先创建了两个字符串通道 chef1chef2,用于存放厨师做好的菜名。然后,启动了两个协程,分别模拟两位厨师做不同的菜。

部分 4 - 使用 select 处理通道超时问题

select {
case dish := <-chef1:
    fmt.Println("厨师chef1已经做好了:", dish)
case dish := <-chef2:
    fmt.Println("厨师chef2已经做好了:", dish)
case <-time.After(time.Second * 3):
    fmt.Println("做饭超时")
}

在这里,使用 select 来等待多个通道操作。首先,它尝试从 chef1chef2 通道中接收数据,如果其中一个通道有数据,将打印出对应的消息。然后,它设置了一个超时时间为 3 秒的 time.After 分支,如果在 3 秒内没有接收到数据,将打印出 "做饭超时" 的消息。

部分 5 - 关闭通道

close(chef1)
close(chef2)

关闭了通道 chef1chef2,以释放资源

运行上面代码,观察到在 3 秒内没有接收到数据,打印出 "做饭超时" 的消息。

$ go run .\02-concurrentProgramming-01.go
厨师:chef1正在做:红烧豆腐
厨师:chef2正在做:宫保鸡丁
做饭超时

九、监听通道的退出信号

9.1 基本含义

在 Go 语言中,监听通道的退出信号通常用于控制协程的生命周期,以便在满足某些条件时优雅地关闭协程,避免资源泄漏。通常,我们使用一个特殊的通道来发送退出信号。

监听通道的退出信号的过程如下:

1.创建一个退出通道

首先,需要创建一个通道,通常是一个 chan bool 类型的通道,用于发送退出信号。

exitChan := make(chan bool)

2.在协程中监听退出信号

在想要监听退出信号的协程中,使用 select 语句来监听多个通道,包括退出通道。通常,你会将退出通道作为其中一个 case

select {
case <-exitChan:
    // 收到退出信号,执行清理工作并退出协程
    // 可以关闭其他通道或释放资源等操作
    return
// 其他 case 分支用于监听其他通道的事件
}

exitChan 通道接收到值时,协程会执行清理工作,并在合适的时候通过 return 退出协程。

3.发送退出信号

在需要结束协程的地方,例如在主程序中或其他协程中,通过向 exitChan 通道发送值来触发协程的退出。

exitChan <- true // 发送退出信号

这会导致协程中的 select 语句中的 <-exitChan 分支被执行,协程进行清理并退出。

9.2 使用场景

监听通道的退出信号一般应用于以下场景:

  • 协程管理:当你在程序中创建多个协程来执行不同的任务时,你可以使用退出通道来协调这些协程的生命周期。当程序需要退出时,可以向退出通道发送信号,以便所有协程安全地退出。
  • 资源清理:在协程中执行资源密集型的操作,例如打开文件、数据库连接或网络连接时,你可以使用退出通道来确保在程序退出之前关闭这些资源,以避免资源泄漏。
  • 定时任务的取消:在执行定时任务的协程中,你可以监听退出通道以便在接收到退出信号时取消正在执行的任务,从而避免不必要的计算。
  • 监视器协程:有时,你可能需要创建一个监视器协程来检测其他协程的状态,并在必要时发送退出信号以终止它们。
  • 程序终止信号:在一些长时间运行的程序中,你可以使用退出通道来监听操作系统的终止信号(例如 SIGINT 或 SIGTERM),并在接收到这些信号时安全地关闭程序。

9.3 示例说明

监听通道的退出信号通常使用select语句结合额外的退出通道来实现。

下面进行示例说明:演示如何使用 Go 语言的通道和 select 语句来模拟一个工作场景,其中有一个拧螺丝的操作,并在指定的工作时间后结束工作。

将下面代码粘贴到03-concurrentProgramming-00.go文件中并保存该文件

package main

import (
    "fmt"
    "time"
)

func screw(c chan int) {
    i := 1
    for {
        fmt.Printf("正在拧第%d个螺丝\n", i)
        c <- i
        i++
        // 每秒
        time.Sleep(time.Second)

    }

}

func main() {
    // 定义一个拧螺丝的通道
    screwChan := make(chan int, 100)
    // 定义一个关闭拧螺丝通道的通道
    stop := make(chan bool)
    go screw(screwChan)
    // 倒计时
    go func() {
        // 用10s模拟8小时工作时间
        time.Sleep(time.Second * 10)
        fmt.Println("下班了")
        stop <- true
    }()
    for {
        select {
        case <-stop:
            // 10s倒计时已到,往stop传入true
            fmt.Println("8小时过去了,该下班了")
            return
        case s := <-screwChan:
            fmt.Printf("第%d个螺丝已完成\n", s)

        }

    }

}

针对上面代码分为几个部分进行详细说明:

部分 1 - 导入包

import (
    "fmt"
    "time"
)

导入了 fmt 用于打印和 time 用于时间控制。

部分 2 - screw 函数

func screw(c chan int) {
    i := 1
    for {
        fmt.Printf("正在拧第%d个螺丝\n", i)
        c <- i
        i++
        // 每秒
        time.Sleep(time.Second)
    }
}

这个函数模拟了一个拧螺丝的操作。它使用一个通道 c 来发送已拧好的螺丝的编号,然后在每次循环中等待 1 秒钟。

部分 3 - main 函数

// 定义一个拧螺丝的通道
screwChan := make(chan int, 100)
// 定义一个关闭拧螺丝通道的通道
stop := make(chan bool)
go screw(screwChan)

创建了两个通道,screwChan 用于接收拧好的螺丝编号,stop 用于关闭拧螺丝协程。然后启动了一个 screw 协程来模拟拧螺丝的过程。

部分 4 - 使用匿名协程模拟工作时间限制

go func() {
    // 10s模拟8小时工作时间
    time.Sleep(time.Second * 10)
    fmt.Println("下班了")
    stop <- true
}()

在一个匿名协程中,模拟了工作时间限制。在这里,使用 time.Sleep 模拟了 10 秒钟的工作时间,然后发送 stop 通道的信号以表示下班。

部分 5 - 使用 select 语句来监听通道

for {
    select {
    case <-stop:
        // 10s倒计时已到,往stop传入true
        fmt.Println("8小时过去了,该下班了")
        return
    case s := <-screwChan:
        fmt.Printf("第%d个螺丝已完成\n", s)
    }
}

在一个无限循环中,使用 select 语句监听两个通道:stopscrewChan。如果 stop 通道接收到信号,表示工作时间结束,程序打印消息并退出。如果 screwChan 接收到信号,表示一个螺丝已完成,程序会打印完成的螺丝编号。

运行上面代码,观察到10s结束后打印指定信息

$ go run .\03-concurrentProgramming-00.go
正在拧第1个螺丝
第1个螺丝已完成
正在拧第2个螺丝
第2个螺丝已完成
正在拧第3个螺丝
第3个螺丝已完成
正在拧第4个螺丝
第4个螺丝已完成
正在拧第5个螺丝
第5个螺丝已完成
正在拧第6个螺丝
第6个螺丝已完成
正在拧第7个螺丝
第7个螺丝已完成
正在拧第8个螺丝
第8个螺丝已完成
正在拧第9个螺丝
第9个螺丝已完成
正在拧第10个螺丝
第10个螺丝已完成
下班了
8小时过去了,该下班了