Go语言基础之并发

1. 理解并发编程

并发指在一个时间段内同时处理多个任务的能力,因为处理任务的速度非常快,所以我们的感觉就是同时处理了多个任务。而并行是指在同一时刻处理多个任务,每个任务有各自的处理器。

通俗来说,多线程就是“同时”做多个事情。对于一个线程,它是有程序计数器(PC)、寄存器、栈和状态等变量的管理器,挂起、恢复线程上下文需要切换这些变量。而这些切换的成本是比较高昂的,因此当线程的数量增多时,系统就会假死。比如在 Python 中,利用 GIL(全局解释器锁)来实现线程同步,唯一好处是对于某些 I/O 密集型操作,将 CPU 上的线程切换换成 I/O 上的线程调度,可以提高程序性能。

2. Goroutine

2.1 什么是 Goroutine

Go 语言中的并发实现采用的 Goroutine 即协程的概念,它是 Go 语言中的一种轻量级线程实现。与传统的线程设计方案不同,Goroutine 是在 Go 语言的运行时调度的,它是通过特殊的 Goroutine 调用语句 go 函数名() 来创建和启动的。

特别地,该语句可以在多数函数或方法调用前添加 go 来创建 Goroutine,无需等待该函数或方法的返回值,即可进行下一步操作。

Goroutine 的使用启发人们进一步认识到,不需要过多的线程切换时,线程越多,程序越容易假死。

2.2 Goroutine 的实现机制

Goroutine 的实现机制来源于三个基础技术:栈的协程(Stackful Coroutines),M:N 线程模型和 Goroutine 调度。

当创建一个 Goroutine 时,Go 运行时会为其分配一个 2K 大小的栈内存,栈内存既可以扩展,也可以缩小。因为每个线程都有自己已分配的栈,所以在安全性考虑下,我们能够放心地使用类似全局共享变量,只需要使用 sync 包等通道,就能保证数据的线程安全。

对于多核 CPU,Go 将 M:N 线程模型用作实现 Goroutine。顾名思义,它是 M 个用户级线程(Go 程序并发处理的业务线程,其中 M 线程一般数目是由可用 CPU 核数决定),对应于 N 个系统于线程模型,即可以批量创建和销毁、灵活使用、线程轻量级、切换消耗小、执行效率高、资源占用少等特点。

Goroutine 的调度是 Go 语言运行时实现的。与其他语言一样,Go 有一个调度器负责 Goroutine 的调度。它的调度器采用了抢占式调度,调度器执行某个 Goroutine 时,如果出现阻塞,调度器会切换到下一个 Goroutine 中。Go 的调度器使用类似于 Linux 的调度器的方式,将线程分为 P(Processor)和 M(thread-Manager)两个核心部分。其中,M 是虚拟 CPU 的抽象,用于执行 Goroutine,P 代表着真实的 CPU 核心。

2.3 Goroutine 的实际应用

使用 Goroutine,您可以使应用程序并发地运行多个任务,以便快速响应用户的请求或执行某些后台处理。下面是一个简单的 Goroutine 的示例:

// Goroutine1.go

package main

import (

"fmt"

"runtime"

)

func main() {

fmt.Println("CPU Num:", runtime.NumCPU())

go func() {

fmt.Println("This is a Goroutine.")

}()

fmt.Println("main function.")

}

以上代码中,我们创建了一个匿名函数作为 goroutine,与主体代码并行执行。在执行该匿名函数时,当前 Goroutine 暂停并进入排队队列,此时主线程继续执行并输出“main function.”的信息。由于 Goroutine 是并发执行的,当主线程继续执行时,匿名函数就成为了排队队列的下一个任务,并以“ This is a Goroutine.”输出到控制台。这个例子虽然简单,但它展示了如何使用 Goroutine 来并发执行函数,同时无需等待该函数输出结果,只需在主函数中继续执行即可。

3. Channel

3.1 Channel 的概念

Channel 是在多个 Goroutine 之间传输数据的管道,类似于 Unix 系统中的管道(pipe),可以用于广泛的并发应用场景,例如数据传输、Goroutine 协同、事件通知、结果汇总等。Channel 的传输过程是同步的,类似于 ping 计时器会阻塞当前时间,等到指定的时间到后再次发送信号。因为它是一个通信双向管道,所以发送方和接收方可以通过 Channel 来协调并发操作。

Channel 是一个 Goroutine 的执行环境。当使用 Channel 时,一个或多个 Goroutine 可以发送消息到 Channel,另一端的 Goroutine 则可以接收它们。当发送方尝试向已满的 Channel 发送数据时,发送方会阻塞,等待接收方向 Channel 读取可用的区域。相反,当接收方尝试从空管道中读取数据时,接收方会阻塞,等待发送方发送数据。因此,Channel 可以实现线程间的同步。

3.2 Channel 的类型

Go 语言的 Channel 有两种类型:带缓冲和不带缓冲的。

如果 Channel 带缓冲,它将保留指定容量的元素,当Channel 中的元素达到容量时,再向 Channel 中添加数据时,发送方会被阻塞,直到接收方从 Channel 中读取数据,才能将数据添加到 Channel 中。

如果 Channel 无缓冲,发送方将等待接收方,直到接收方从附加到 Channel 上的数据准备好为止,因此发送操作和接收操作保证了同步。

下面是一个带缓冲通道的例子,它将在 Channel 的容量小于 2 时为阻塞状态的情况:

// c1.go

package main

import (

"fmt"

)

func main() {

c := make(chan int, 2)

c <- 1

c <- 2

fmt.Println(<-c)

fmt.Println(<-c)

}

除了普通在函数中创建的 Channel 之外,Go 语言中的 sync 包还提供了一些 Channel 操作类。

4. Select 语句

4.1 Select 的概念

Select 是 Go 语言中的条件语句,同时还是 Channel 的控制流程结构。它使用 select 语句,用户可以等待多路的 Channel 操作,尤其是用于多个 Goroutine 之间的通信,它帮助我们严格控制 Goroutine。

它类似于 Switch 语句功能,但专门用于通信操作。Select 从随机的可用通信中选择一个进行操作。如果没有任何可用的通信,则等待任意一方准备好为止。当多个 Send/Closed frames 准备好时,它会随机选择一个发送。如果有多个通信操作准备好了,Select 将随机选择一个选定该操作进行处理,例如执行。

基本语法:

select {

case <-chan1:

// 如果 chan1 通信,执行语句

case x := <-chan2:

// 如果 chan2 通信,执行语句

case chan3 <- y:

// 如果成功向 chan3 发送数据,执行语句

default:

// 如果以上情况都不存在,执行语句

}

在 Go 语言编程中,我们通常使用 select 语句的方式来操作一组内在事件相关联的 Channel。例如,在下面的代码片段中,我们创建了一个缓冲为 1 的 Channel。在 select 语句中,我们使用 c 发送消息,还用 default 子句向控制台发送消息。最后,我们通过打印语句留下您想要的结果。

// c2.go

package main

import "fmt"

func main() {

c := make(chan int, 1)

select {

case c <- 1:

fmt.Println("write 1")

default:

fmt.Println("default")

}

fmt.Println(<-c)

}

4.2 Select 的实际应用

下面通过一个使用channel、goroutine和select的无聊示例来演示其应用示例:

我们要通过剪刀石头布这个游戏来进行展示:

package main

import (

"fmt"

"math/rand"

"time"

)

func player1(ch chan int) { // 玩家1

for {

select { // 使用select 选择位置

case ch <- 0:

case ch <- 1:

case ch <- 2:

}

}

}

func player2(ch chan int) { // 玩家2

for {

select { // 使用select 选择位置

case ch <- 0:

case ch <- 1:

case ch <- 2:

}

}

}

func judge(ch chan int) { // 宣判结果

for {

time.Sleep(1)

var p1, p2 int

p1 = <-ch

p2 = <-ch

switch {

case p1 == 0 && p2 == 1:

fmt.Println("PLayer2 wins !")

case p1 == 1 && p2 == 2:

fmt.Println("PLayer2 wins !")

case p1 == 2 && p2 == 0:

fmt.Println("PLayer2 wins !")

case p1 == 0 && p2 == 2:

fmt.Println("PLayer1 wins !")

case p1 == 1 && p2 == 0:

fmt.Println("PLayer1 wins !")

case p1 == 2 && p2 == 1:

fmt.Println("PLayer1 wins !")

default:

fmt.Println("Equal !!!!")

}

}

}

func main() {

// 手动种子

rand.Seed(time.Now().Unix())

ch := make(chan int)

go player1(ch) // 启动玩家1

go player2(ch) // 启动玩家2

judge(ch) // 将选择的通道传输到judge

}

上面的代码使用 select 语句,实现了 Goroutine 之间的通信。在主函数中,我们创建了一个 Channel,将它传递到 Goroutine 中,然后使用 Select 完成选择操作。它根据随机数生成器选择玩家 1 和玩家 2 发生的事件,并输出游戏结果。通过这个例子,我们可以学习到如何使用通道和 Goroutine 实现多个对象的同步和协作。

5. 总结

本文介绍了 Go 语言并发模型中的 Goroutine 和 Channel,并展示了如何使用它们实现协同处理输入。由于包含抢占式调度、多核支持、更少的切换、高效执行等特点,Go 语言并发模型得到了大力推广。通过 Channel 和 Goroutine 的使用,您可以将受支持的多核架构交付给 Go 运行时处理,并实现相应的支持程序的应用程序。如果您还未使用 Go 语言实现过并发模型,请尝试本文提出的应用,并享受其带来的便利吧!

后端开发标签