一、前言¶
本文主要以下几方面介绍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是馅料,&wg是WaitGroup的引用。这使得每个协程都可以独立运行,模拟制作不同馅料的包子的过程。最后,等待所有协程完成后,打印一条分隔线和一条消息,以表示所有包子都已制作完成。
运行上面代码
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 函数,是程序的入口点。它执行以下操作:
- 创建了两个字符串通道
chef1和chef2,用于分别存放两位厨师做的菜单。 - 启动了两个协程,分别调用
cookDish函数来模拟两位厨师分别做不同的菜,并将做好的菜名发送到不同的通道中。 - 使用
select语句等待两个通道中的数据。当其中一个通道准备好数据时,对应的case分支会执行,从通道中获取并打印出菜名。
部分 5 - 关闭通道
close(chef1)
close(chef2)
关闭了通道 chef1 和 chef2,以释放资源
运行上面代码,这里运行了两次,结果不同,再次证明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 会同时等待多个通道操作,包括通道读取和计时器事件。
- 如果
channel1或channel2有数据,select将执行相应的case分支。 - 如果在指定的超时时间内没有数据到达
channel1或channel2,则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 函数中,首先创建了两个字符串通道 chef1 和 chef2,用于存放厨师做好的菜名。然后,启动了两个协程,分别模拟两位厨师做不同的菜。
部分 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 来等待多个通道操作。首先,它尝试从 chef1 和 chef2 通道中接收数据,如果其中一个通道有数据,将打印出对应的消息。然后,它设置了一个超时时间为 3 秒的 time.After 分支,如果在 3 秒内没有接收到数据,将打印出 "做饭超时" 的消息。
部分 5 - 关闭通道
close(chef1)
close(chef2)
关闭了通道 chef1 和 chef2,以释放资源
运行上面代码,观察到在 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 语句监听两个通道:stop 和 screwChan。如果 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小时过去了,该下班了