likes
comments
collection
share

点燃并发的火花 — Go并发基础(上)

作者站长头像
站长
· 阅读数 45

欢迎小伙伴们观看我的文章,若文中有不正确的地方,小伙伴们可以在评论区处指出,希望能够与小伙伴们一起成长与进步,谢谢!

一、基本概念

1、进程、线程、协程

进程

进程是操作系统资源分配的基本单位,是系统进行资源分配和调度的一个独立执行单位。每个进程都有自己的内存空间,包括代码、数据、堆栈等,可以独立地执行,并与其他进程隔离。

在计算机系统中,进程的概念是由操作系统引入的。操作系统负责管理进程的创建、调度、同步、通信和终止等工作。每个进程都有一个唯一的进程 ID(PID),用于区分不同的进程。

操作系统通过进程来实现多任务处理,进程之间通过进程间通信(IPC)来实现数据的共享和通信。。

一个进程可以创建和撤销多个线程,同一个进程中的多个线程之间可以并发执行

进程的优点

  • 可以实现程序的并发执行提高程序的响应速度和处理能力。进程之间相互隔离,可以避免不同进程之间的干扰和影响。

进程的缺点:

  • 每个进程都有自己的内存空间,进程之间的数据共享需要通过进程间通信来实现,开销较大。同时,进程切换也会带来一定的开销,需要消耗较多的系统资源。

线程

线程是操作系统基于进程开启的轻量级进程,是进程中的⼀个执⾏单元,也是操作系统调度执行的最小单位。线程是进程的一部分,一个进程可以包含多个线程,它们共享进程的资源,包括内存、打开的文件、信号处理等。

由于线程是操作系统调度的基本单位,因此线程之间的调度和切换开销相对较小,可以提高程序的并发性能和响应速度。

每个线程都有自己的栈和程序计数器,并共享进程的内存空间,包括代码、数据、堆等。

线程的运行可以由进程的主线程创建和销毁,也可以由其他线程创建和销毁。线程之间可以通过共享内存的方式进行数据共享和通信。

线程的优点

  • 可以实现程序的并发执行,提高程序的响应速度和处理能力。由于线程之间共享进程的资源,因此线程之间的数据共享和通信开销较小

线程的缺点

  • 线程之间共享进程的资源,需要考虑线程之间的同步和互斥问题,避免竞争和死锁等问题。同时,线程切换开销较小,但线程数量的增加会带来更多的线程切换开销。

协程

协程(Coroutine)是一种用户态的轻量级线程,是一种比线程更加轻量级的并发实现方式。可以由用户自行创建和控制的用户态线程,比线程更轻量级。

在协程中,可以将一个函数视为一个子任务,将协程视为执行该任务的执行上下文,这个执行上下文中包含了该任务运行时的各种状态,包括栈、程序计数器、局部变量等。协程执行时,可以通过调度程序来切换不同的协程,实现多个任务的并发执行。

协程的优点

  • 可以避免线程切换的开销和内存占用,同时也可以提高程序的并发性能和响应速度。

协程的缺点

  • 协程的调度是由用户程序控制的,因此需要程序员自己确保协程的合理调度,否则可能会导致协程之间的竞争和死锁等问题。

2、串行、并行、并发

串行

串行(Serial)是指在计算机中,任务、操作或数据处理等的执行方式为单一的、依次顺序执行的方式。在串行执行的过程中,每个任务、操作或数据处理都必须等待前一个任务、操作或数据处理完成后才能开始执行。

串行适用于一些简单的、非常规模的任务,例如只有一些简单的数学计算等。

并行

并行(Paralle)是指在计算机系统中,多个任务、操作或数据处理等可以同时进行的方式,在并行计算机系统中,各个处理单元可以同时执行不同任务或操作,互不干扰。

并行计算机系统可以分为两类:共享内存型分布式型

  • 共享内存型并行计算机系统中,所有的处理单元共享同一块内存,可以直接访问共享的内存空间,因此实现数据共享和通信比较容易。

  • 分布式型并行计算机系统中,各个处理单元之间通过网络进行通信,因此实现数据共享和通信比较困难。

相对于串行计算机系统,并行计算机系统可以大大提高计算机系统的处理速度和并发性能。在并行计算机系统中,各个处理单元之间可以同时执行不同的任务或操作,互不干扰。

在计算机程序设计中,也可以通过并行的方式来实现程序的优化和加速。通过将程序分解为多个独立的子任务,可以将这些任务分配给不同的处理单元进行并行计算,从而提高程序的执行效率和并发性能。

总之,并行是指多个任务、操作或数据处理等可以同时进行的方式,适用于需要处理大量数据或执行复杂任务的计算机系统和程序。

并发

并发(Concurrency)是指在计算机系统中,多个任务、操作或数据处理等可以在同一时间段内进行处理,但不一定同时进行的方式

在并发计算机系统中,各个任务、操作或数据处理等可以交替进行,互相切换执行,从而实现多任务处理。

并发计算机系统可以通过多种方式实现,并发执行,如进程、线程、协程等。由于并发任务之间的执行是互相切换的,因此需要考虑任务之间的同步和互斥问题,以避免竞争和死锁等问题。

总之,并发是指多个任务、操作或数据处理等可以在同一时间段内进行处理的方式,适用于需要处理多个任务或操作的计算机系统和程序。并发计算机系统可以大大提高计算机系统的处理速度和并发性能,但需要考虑任务之间的同步和互斥问题。

二、goroutine

1、goroutine介绍

goroutine 是 Go 程序中最基本的并发执行单元,也是Go中轻量级线程的实现方式之一,即Go协程

goroutine 是 Go 语言支持并发的核心,在一个Go程序中同时创建成百上千个goroutine是非常普遍的,一个goroutine以一个很小的栈开始其生命周期,一般只需要2KB

  • 每一个Go程序都至少包含一个 goroutine,即main goroutine,当 Go 程序启动时main goroutine会自动创建。

区别于操作系统线程由系统内核进行调度, goroutine是由Go运行时(runtime)负责调度和管理。

  • Go运行时会智能地将mgoroutine合理地分配给n个操作系统线程,实现类似m:n的调度机制,不再需要Go开发者自行在代码层面维护一个线程池。

与传统的线程相比,goroutine具有更小的内存占用更高的并发性能更容易的编写方式等优势。

  • 当需要让某个任务并发执行的时候,只需要将这个任务包装成一个函数,开启一个 goroutine 去执行这个函数即可。

2、goroutine启动

在Go语言中,通过在函数或方法调用前使用go关键字即可创建一个goroutine,该函数或方法会在新创建的goroutine中执行。例如:

go function() // 创建一个新的 goroutine 运行

匿名函数也同样支持通过使用go关键字创建一个新的goroutine来执行。例如:

go func() {
}()

需要注意的事,匿名函数在使用go关键字创建goroutine执行时,需要立即使用()来调用,否则编译器会报错。

一个 goroutine对应一个函数/方法,同时也可以创建多个 goroutine 去执行相同的函数/方法。

启动单个goroutine

在需要调用的函数前,加上go关键字,即可启动一个goroutine

main中执行普通函数时:

package main

import "fmt"

func printHello() {
    fmt.Println("Hello World")
}

func main() {
    printHello()
    fmt.Println("main")
}

// 执行结果
Hello World
main

上述代码中,main goroutine启动后,main函数执行调用printHello函数,代码依次执行,执行的顺序是串行的。

点燃并发的火花 — Go并发基础(上)

修改上述代码,在调用printHello()函数前添加关键字go,启动一个goroutine执行该函数。

package main

import "fmt"

func printHello() {
    fmt.Println("Hello World")
}

func main() {
    go printHello()
    fmt.Println("main")
}

// 执行结果
main

从上述代码的执行结果中可以看到,在终端只打印了"main",并没有打印printHello()函数中的"Hello World",原因在于:

  • Go程序启动时,Go程序就会为 main 函数创建一个默认的 goroutine

  • 上述代码代码中,在 main 函数中使用 go 关键字创建了另外一个 goroutine 去执行 printHello 函数,而此时 main goroutine 还在继续往下执行,此时程序存在两个并发执行的 goroutine

  • main 函数结束时,整个程序也就结束了,同时 main goroutine 也结束了,所有由 main goroutine 创建的 goroutine 也会一同退出。即由于main 函数执行结束,退出太快,从而导致另外一个 goroutine 中的函数还未执行完,整个程序就退出了,导致未打印出"Hello World"。

如果需要另一个goroutine执行完调用的函数,则需要让 main 函数等待一段时间。

package main

import (
    "fmt"
    "time"
)

func printHello() {
    fmt.Println("Hello World")
}

func main() {
    go printHello()
    fmt.Println("main")
    time.Sleep(time.Second) // main goroutine 睡眠一秒
}

// 执行结果
main
Hello World

执行上述代码,可以看到执行结果中printHello()函数与main()函数的打印都显示在终端。

从打印顺序可以看到,是main()函数率先打印,而后才是printHello()函数的打印,原因在于:

  • 在程序中,创建一个goroutine执行函数需要一定的开销,与此同时main()函数所在的main goroutine仍然在执行。

点燃并发的火花 — Go并发基础(上)

当然,这种使用time.Sleepmain goroutine 等待新的goroutine执行结束是不优雅的,也是不准确的。

在Go语言中,sync包提供了一些常用的并发原语,其中sync包下的WaitGroup是实现等待一组并发操作完成的结构体类型(后续会介绍sync包的内容)。通过使用WaitGroup将上述代码改造后如下:

package main

import (
    "fmt"
    "sync"
)

// 声明全局等待组变量
var wg sync.WaitGroup

func printHello() {
    fmt.Println("Hello World")
    wg.Done() // 完成一个任务后,调用Done()方法,等待组减1,告知当前goroutine已经完成任务
}

func main() {
    wg.Add(1) // 等待组加1,表示登记一个goroutine
    go printHello()
    fmt.Println("main")
    wg.Wait() // 阻塞当前goroutine,直到等待组中的所有goroutine都完成任务
}

// 执行结果
main
Hello World

上述代码的执行结果与使用time.Sleep的结果一致,但这次执行不会有多余的时间等待,新的goroutine执行结束后程序直接退出。

启动多个goroutine

Go语言中通过go关键字可以简单快速的实现并发,同样也可以启动多个goroutine,多个goroutine也可以通过sync.WaitGroup来实现 goroutine 的同步。

package main

import (
    "fmt"
    "sync"
)

var wg sync.WaitGroup

func printHello(key int) {
    defer wg.Done()
    fmt.Println("Hello", key)
}

func main() {
    for i := 0; i < 10; i++ {
       wg.Add(1)
       go printHello(i)
    }
    wg.Wait()
}

上述代码通过for循环开启多个goroutine,且每开启之前都向等待组登记一个goroutine,在每个goroutine执行结束后便告知等待组任务执行结束,在main()函数中调用wg.Wait()等待所有登记过的goroutine执行结束后才继续执行。

多次执行上述代码时,每次终端上打印数字的顺序都不一致。这是由于10个 goroutine 是并发执行的,而 goroutine 的调度是随机的,从而使顺序也是随机的。

操作系统的线程一般都有固定的栈内存(通常为2MB),而 Go 中的 goroutine 非常轻量级,一个 goroutine初始栈空间很小(一般为2KB),所以在 Go 语言中一次创建数万个 goroutine 也是可能的。并且 goroutine 的栈不是固定的,可以根据需要动态地增大或缩小, Go 的 runtime 会自动为 goroutine 分配合适的栈空间。

3、goroutine调度

操作系统内核在调度时,会将当前正在执行的线程挂起,并将该线程的寄存器内容保存到内存中,然后选出下一个要执行的线程并从内存中将该线程的寄存器信息信息恢复,恢复后执行该线程的现场并开始执行线程。

从一个线程切换到另一个线程需要完整的上下文切换。因为可能需要多次内存的访问,切换上下文的操作开销交大,增加了运行的cpu周期。

不同于操作系统内核调度线程,goroutine的调度是Go运行时(runtime)层面实现的,由Go语言本身实现的一套调度系统(go scheduler),该调度系统按照一定的规则将所有的goroutine调度到操作系统线程中执行。

目前Go语言的调度器采用的是 GMP 调度模型。

下图是网上的一张关于GMP的调度图: 点燃并发的火花 — Go并发基础(上)

上述调度图中:

  • G:表示goroutine,每使用go关键字则会创建一个G,其中包含要执行的函数以及上下文信息。
  • 全局队列Global Queue:用于存放正在等待的Ggoroutine)。
  • P:表示goroutine执行所需要的资源,即存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界)。最多有GOMAXPROCSP,即P的个数runtime.GOMAXPROCS设定(最大256),Go1.5版本之后默认为物理线程数。
  • P本地队列:与全局队列类似,P本地队列存放的也是等待执行的G,本地队列存储G的数量有限,不超过256个。当新建G时,G会优先加入到P本地队列中,若本地队列已满则会批量移动部分G到全局队列中。
  • M:内核线程,线程通过获取P从而执行任务,从P的本地队列获取G,当P对应的本地队列为空时,M会尝试从全局队列或者其他P对应的本地队列中获取GM执行完G后,则会从P获取到下个G,不断重复地执行下去。
  • Goroutine 调度器和操作系统调度器是通过 M 结合起来的,每个 M 都代表了1个内核线程,操作系统调度器负责把内核线程分配到 CPU 的核上执行。

在Go中,goroutine是由Go运行时(runtime)自己的调度器来调度的,完全处于用户态下完成,不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。

三、channel

1、channel介绍

在Go语言中,采用的并发模型为CSP(Communicating Sequential Processes),提倡通过通信共享内存,而不是通过共享内存来实现通信

goroutine是Go程序并发的执行体,则Go中可以使用channel将它们连接起来。channel可以让一个goroutine发送特定的值到另一个goroutine中的一种通信机制。

Go语言中,通道(channel)是一种特殊的类型。channel类似一个队列,遵循先入先出(First In First Out)的规则,保证数据收发的顺序。每个channel在声明时都需要为其指定数据类型,即每个channel都是一个具体类型的通道。

2、channel声明

channel是Go中一种特有的类型,声明channel类型变量的格式如下:

var 变量名 chan 数据类型

上述声明中:

  • chan:声明channel的关键字;
  • 数据类型:在channel中传递元素的类型;
var channel1 chan int
var channel2 chan bool
var channel3 chan int[]

3、channel初始化

未初始化的channel类型变量,其默认值为nil

var channel chan int
fmt.Println(channel) // <nil>

声明channel类型变量时,可以使用内置make函数初始化channel变量,只有初始化后的channel变量才能够使用。

make(chan 元素类型, [缓冲大小])

上述初始化中,channel的缓冲大小是可选的。例如:

func main() {
    channel1 := make(chan int)
    channel2 := make(chan bool, 3) // 声明一个缓冲区大小为3的通道
}

4、channel使用

channel通道有发送(send)接收(receive)关闭(close)三种操作。发送与接收操作通过使用<-符号来使用。例如:

初始化

初始化一个channel

ch := make(chan int)

发送

发送一个值到channel中,通过使用<-符号来实现发送操作。

ch <- 100 // 将100发送到ch变量中

接收

从通道中接收值,通过使用<-符号来实现接收操作。

value := <-ch      // 从ch中接收值并赋值给变量value
fmt.Println(value) // 100

关闭

通道的关闭操作是通过调用内置函数 close() 来实现的。当通道关闭时,发送方无法再向通道发送数据,但接收方仍然可以从通道中接收数据直到通道为空。此时,接收方会收到通道类型的零值,例如 int 类型通道会返回 0

close(ch)

在某些情况下,需要关闭通道以便通知接收方不再有数据发送。但是,并不是所有的情况下都需要执行关闭操作,只有在接收方明确等待通道关闭的信号时,才需要执行关闭操作,即需要告诉接收方不再发送数据,则关闭通道进行告知。

例如,在使用 for range 循环遍历通道时,当通道被关闭时,循环会自动结束,因此不需要手动执行关闭操作。

  • 如果通道没有被关闭,接收方会一直阻塞直到有数据到来;
  • 如果通道已经关闭,接收方会立即得到通道关闭的信号,不会阻塞在等待数据的状态。

因此,通道的关闭操作可以用来通知接收方不再有数据发送,从而避免接收方一直阻塞

总结关闭的通道有以下的特点:

  • 对已关闭的通道发送值则会导致panic
  • 对已关闭的通道进行接收会一直取值直到通道为空;
  • 对已关闭且没有值的通道执行接收操作则会获取到对应类型的零值。
  • 关闭一个已经关闭的通道会导致 panic

channel的使用示例:

package main

import (
    "fmt"
    "sync"
)

var wg sync.WaitGroup

func main() {
    ch := make(chan int)
    wg.Add(1)
    go func() {
       defer wg.Done()
       value := <-ch      // 从ch中接收值并赋值给变量value
       fmt.Println(value) // 100
       fmt.Println("function over")
    }()
    ch <- 100
    wg.Wait()
    fmt.Println("main over")
}

5、无缓冲channel

无缓冲channel又称为阻塞通道。

func main() {
    ch := make(chan int)
    ch <- 100
    fmt.Println("发送成功")
}

执行上述代码,会出现以下错误:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
        .../channel.go:7 +0x31

从上述打印中,deadlock表示程序中的 goroutine 都被挂起导致程序死锁。

为什么会出现deadlock错误呢?

上述代码中,使用ch := make(chan int)创建的是无缓冲通道,无缓冲通道只有在接收方成功接收到值时,才能算发送成功,否则会一直处于等待发送的阶段,即上述代码的第3行ch <- 100,在发送100到通道中,由于并没有接收通道内的值,因此会处于阻塞状态,又由于只有main goroutine一个goroutine,从而导致死锁。

同理,如果对一个无缓冲通道执行接收操作时,没有向通道中发送值的操作,那么也会导致接收操作阻塞。简单来说,无缓冲的通道必须有至少一个接收方才能发送成功。

上述代码阻塞在ch <- 101这一行代码形成死锁,那如何解决这个问题呢?

一种可行的方法是创建一个goroutine接收通道内的值。

package main

import "fmt"

func receive(ch chan int) {
    value := <-ch
    fmt.Println("接收成功", value)
}

func main() {
    ch := make(chan int)
    go receive(ch)
    ch <- 100
    fmt.Println("发送成功")
}

// 执行结果
接收成功 100
发送成功

上述代码中,首先无缓冲通道ch上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这时候才算发送成功,之后两个goroutine继续执行。如果接收操作先执行,接收方所在的 goroutine 将阻塞,直到 main goroutine 中向该通道发送数字100,此时接收方接收到值后继续执行。

使用无缓冲通道的好处在于进行通信时可以将发送与接收的goroutine同步化,因此,无缓冲通道也被称为同步通道

6、有缓冲channel

无缓冲通道在使用时,需要在该通道执行接收操作才能发送成功,否则有可能出现死锁。而通过使用有缓冲区通道,也可以解决死锁问题。

func main() {
    ch := make(chan int, 1) // 创建一个容量为1的有缓冲区通道
    ch <- 100
    fmt.Println("发送成功")
}

上述声明一个容量为1的有缓冲区通道,只要通道的容量大于0,则该通道即为有缓冲通道,通道的容量表示通道中最大能存放的元素数量

当通道内已有元素达到最大容量时,再向该通道执行发送操作时,则会阻塞,除非通道内的值被接收操作获取。

7、多返回值

向通道发送完数据后,可以使用close函数关闭通道。

当通道被关闭后,再向该通道发送值则会导致panic,另一方面,从该通道取值的操作会先取完通道中的值,当通道内的值被接收完后再对通道执行接收操作得到的值会一直都是对应元素类型的零值。

通过多返回值的形式,可以判断通道是否已经被关闭

value, ok := <- ch
  • value: 从通道中获取的值,如果通道被关闭则返回对应类型的零值。
  • ok:通道关闭时返回false,反之返回true

举个例子:

package main

import "fmt"

func receive(ch chan int) {
    for {
       value, ok := <-ch
       if !ok {
          fmt.Println("通道已关闭")
          break
       }
       fmt.Printf("v:%#v ok:%#v\n", value, ok)
    }
}

func main() {
    ch := make(chan int, 2) // 创建一个容量为1的有缓冲区通道
    ch <- 100
    ch <- 200
    close(ch)
    receive(ch)
}

// 执行结果
v:100 ok:true
v:200 ok:true
通道已关闭

上述代码的循环中,也可以使用for range循环从通道中获取值,当通道被关闭后,会在通道内所有的值都被接收完毕后,自动退出循环。

func receive(ch chan int) {
    for value := range ch {
       fmt.Println(value)
    }
}

8、单向通道

在某些场景下,通道可以作为参数在多个任务函数进行传递,此时可能会选择在不同的任务函数下对通道进行使用上的限制,例如限制通道在某个函数中,只能执行发送操作或只能执行接收操作,即单向通道。

举个例子,例如ProducerConsumer两个函数,其中Producer函数会返回一个通道,并且会持续将符合条件的数据发送至该通道,并在发送完成后将该通道关闭。而Consumer函数的任务是从通道中接收值进行计算,这两个函数之间通过Processer函数返回的通道进行通信。

package main

import "fmt"

// Producer 返回一个通道
// 将满足条件的数据发送至返回的通道中
// 数据发送完毕后,关闭通道
func Producer() chan int {
    // 定义一个通道
    ch := make(chan int, 5)
    // 创建新的goroutine执行发送数据任务
    go func() {
       for i := 0; i < 10; i++ {
          // 发送数据
          ch <- i
       }
       close(ch)
    }()
    return ch
}

// Consumer 接收一个通道
// 从通道中接收数据,并计算
func Consumer(ch chan int) int {
    sum := 0
    for value := range ch {
       sum += value
    }
    return sum
}

func main() {
    ch := Producer()
    result := Consumer(ch)
    fmt.Println(result) // 45
}

上述代码中,Consumer函数只对通道执行接收操作,但并不代表Consumer函数不能执行发送操作,而Producer在返回通道值时,可能只希望调用方拿到返回的通道后,只能对齐执行接收操作。

为了满足上述场景,Go提供了单向通道来处理这类限制通道只能执行某类操作的情况。

<- chan int // 接收通道,该通道只能接收无法发送
chan <- int // 发送通道,该通道只能发送无法接收

单向通道的声明,<-符号与chan关键字的相对位置表示当前通道运行的操作,在编译阶段进行检测。另外,对一个只接收通道执行close是不允许的,因为默认通道的关闭操作应该由发送方来完成。

通过使用单向通道,可以将上述代码改造如下:

package main

import "fmt"

// Producer 返回一个接收通道
// 将满足条件的数据发送至返回的通道中
// 数据发送完毕后,关闭通道
func Producer() <-chan int {
    // 定义一个通道
    ch := make(chan int, 5)
    // 创建新的goroutine执行发送数据任务
    go func() {
       for i := 0; i < 10; i++ {
          // 发送数据
          ch <- i
       }
       close(ch) // 关闭通道
    }()
    return ch
}

// Consumer 参数为接收通道
// 从通道中接收数据,并计算
func Consumer(ch <-chan int) int {
    sum := 0
    for value := range ch {
       sum += value
    }
    return sum
}

func main() {
    ch := Producer()
    result := Consumer(ch)
    fmt.Println(result) // 45
}

通过单向通道的限制,可以保证在获取到Producer()函数返回的通道是一个只接收通道,不能对其执行发送操作,从代码层面限制了该函数返回的通道类型,从而保证了数据安全。

在函数传参或赋值操作时,双向通道(正常通道)可以转换为单向通道,但单向通道无法转换为双向通道。例如:

  • 函数传参
package main

import "fmt"

// 参数为接收类型通道
// 通道的接收者可以从通道中接收数据,但不能向通道中发送数据
func operate(ch <-chan int) {
    value := <-ch
    fmt.Println(value)
    // ch <- 2 无法对接收类型通道执行发送操作
}

func main() {
    var ch = make(chan int, 1)
    ch <- 1
    operate(ch) // 双向通道传入单向通道
}
  • 通道赋值
func main() {
    var ch1 = make(chan int, 1) // 初始化双向通道
    ch1 <- 1
    var ch2 <-chan int // 声明只接收通道
    ch2 = ch1 // 赋值操作
    fmt.Println(<-ch2) // 1
}

9、select多路复用

在某些应用场景下,可能需要同时从多个channel通道中接收数据。通道在接收数据时,如果通道内没有任何数据,则当前goroutine在接收时会发生阻塞,可以通过多返回值的形式尝试从多个通道中接收值。

for{
    // 尝试从ch1接收值
    value, ok := <-ch1
    // 尝试从ch2接收值
    value, ok := <-ch2

}

上述方式虽然可以实现从多个通道中接收到值,但运行的性能比较差。

Go中提供了select关键字,它可以同时响应多个通道的操作。

selectswitch分支语句类型,select包含一系列的case分支与default默认分支,每个case分支对应通道的接收或发送操作。select 会一直等待,直到其中的某个 case 的通道操作完成时,则执行该 case 分支对应的语句。

select {
case <-ch1:
    // ...
case value := <-ch2:
    // ...
case ch3 <- 100:
    // ...
default:
    // 默认操作
}

select语句的特点:

  • 能够处理一个或多个 channel 的发送/接收操作。
  • 如果多个 case 同时满足条件,select随机选择一个case执行。
  • 对于没有 caseselect 会一直阻塞,可用于阻塞 main 函数,防止退出。

select使用例子:

  • select可以同时监听一个或多个channel,直到其中一个channel满足条件后,执行满足条件的case
package main

import (
    "fmt"
    "time"
)

func send(ch chan<- int, value int, times int) {
    time.Sleep(time.Second * time.Duration(times)) // 将times形参的int类型转换为time.Duration自定义类型
    ch <- value
}

func main() {
    // 初始化两个channel
    outputCh1 := make(chan int)
    outputCh2 := make(chan int)
    // 开启两个goroutine,发送数据
    go send(outputCh1, 100, 5)
    go send(outputCh2, 200, 2)
    // select监控channel
    select {
    case value1 := <-outputCh1:
       fmt.Println("outputCh1:", value1)
    case value2 := <-outputCh2:
       fmt.Println("outputCh2:", value2)
    }
}

// 执行结果
outputCh2: 200
  • 如果多个case同时满足条件,则随机选择一个case执行
package main

import "fmt"

func main() {
    intCh := make(chan int, 1)
    strCh := make(chan string, 1)
    go func() {
       intCh <- 1
    }()
    go func() {
       strCh <- "hello"
    }()
    select {
    case value := <-intCh:
       fmt.Println("int: ", value)
    case value := <-strCh:
       fmt.Println("str: ", value)
    }
}

// 执行结果
str:  hello

int:  1
转载自:https://juejin.cn/post/7311166617879396362
评论
请登录