go 的并发 | 豆包MarsCode AI刷题

105 阅读10分钟

Go语言并发简述(并发的优势)

有人把Go语言比作 21 世纪的C语言,第一是因为Go语言设计简单,第二则是因为 21 世纪最重要的就是并发程序设计,而 Go 从语言层面就支持并发。同时实现了自动垃圾回收机制。

Go语言的并发机制运用起来非常简便,在启动并发的方式上直接添加了语言级的关键字就可以实现,和其他编程语言相比更加轻量。

下面来介绍几个概念:

进程/线程

进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。

线程是进程的一个执行实体,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。

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

并发/并行

多线程程序在单核心的 cpu 上运行,称为并发;多线程程序在多核心的 cpu 上运行,称为并行。

并发与并行并不相同,并发主要由切换时间片来实现“同时”运行,并行则是直接利用多核实现多线程的运行,Go程序可以设置使用核心数,以发挥多核计算机的能力。

协程/线程

协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。

线程:一个线程上可以跑多个协程,协程是轻量级的线程。

优雅的并发编程范式,完善的并发支持,出色的并发性能是Go语言区别于其他语言的一大特色。使用Go语言开发服务器程序时,就需要对它的并发机制有深入的了解。

并发和并行的区别

在讲解并发概念时,总会涉及另外一个概念并行。下面让我们来了解并发和并行之间的区别。

并发(concurrency):把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。 并行(parallelism):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。 并发不是并行。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。

在很多情况下,并发的效果比并行好,因为操作系统和硬件的总资源一般很少,但能支持系统同时做很多事情。这种“使用较少的资源做更多的事情”的哲学,也是指导 Go语言设计的哲学。

如果希望让 goroutine 并行,必须使用多于一个逻辑处理器。当有多个逻辑处理器时,调度器会将 goroutine 平等分配到每个逻辑处理器上。这会让 goroutine 在不同的线程上运行。不过要想真的实现并行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。否则,哪怕 Go语言运行时使用多个线程,goroutine 依然会在同一个物理处理器上并发运行,达不到并行的效果。

下图展示了在一个逻辑处理器上并发运行 goroutine 和在两个逻辑处理器上并行运行两个并发的 goroutine 之间的区别。调度器包含一些聪明的算法,这些算法会随着 Go语言的发布被更新和改进,所以不推荐盲目修改语言运行时对逻辑处理器的默认设置。如果真的认为修改逻辑处理器的数量可以改进性能,也可以对语言运行时的参数进行细微调整。

image.png

图:并发与并行的区别

Go语言在 GOMAXPROCS 数量与任务数量相等时,可以做到并行执行,但一般情况下都是并发执行。

Go 语言支持并发,通过 goroutines 和 channels 提供了一种简洁且高效的方式来实现并发。

goroutine

goroutine 是轻量级线程,goroutine 的调度是由 Golang 运行时进行管理的。

goroutine 语法格式:

go 函数名( 参数列表 )

例如:

go f(x, y, z)

开启一个新的 goroutine:

f(x, y, z)

Go 允许使用 go 语句开启一个新的运行期线程, 即 goroutine,以一个不同的、新创建的 goroutine 来执行一个函数。 同一个程序中的所有 goroutine 共享同一个地址空间。

实例

  
import (  
        "fmt"  
        "time"  
)  
  
func say(s string) {  
        for i := 0; i < 5; i++ {  
                time.Sleep(100 * time.Millisecond)  
                fmt.Println(s)  
        }  
}  
  
func main() {  
        go say("world")  
        say("hello")  
}  

执行以上代码,你会看到输出的 hello 和 world 是没有固定先后顺序。因为它们是两个 goroutine 在执行:

world
hello
hello
world
world
hello
hello
world
world
hello

Go语言通道(chan)——goroutine之间通信的管道

如果说 goroutine 是 Go语言程序的并发体的话,那么 channels 就是它们之间的通信机制。一个 channels 是一个通信机制,它可以让一个 goroutine 通过它给另一个 goroutine 发送值信息。每个 channel 都有一个特殊的类型,也就是 channels 可发送数据的类型。一个可以发送 int 类型数据的 channel 一般写为 chan int。

Go语言提倡使用通信的方法代替共享内存,当一个资源需要在 goroutine 之间共享时,通道在 goroutine 之间架起了一个管道,并提供了确保同步交换数据的机制。声明通道时,需要指定将要被共享的数据的类型。可以通过通道共享内置类型、命名类型、结构类型和引用类型的值或者指针。

这里通信的方法就是使用通道(channel),如下图所示。

图:goroutine 与 channel 的通信

image.png 在地铁站、食堂、洗手间等公共场所人很多的情况下,大家养成了排队的习惯,目的也是避免拥挤、插队导致的低效的资源使用和交换过程。代码与数据也是如此,多个 goroutine 为了争抢数据,势必造成执行的低效率,使用队列的方式是最高效的,channel 就是一种队列一样的结构。

通道的特性 Go语言中的通道(channel)是一种特殊的类型。在任何时候,同时只能有一个 goroutine 访问通道进行发送和获取数据。goroutine 间通过通道就可以通信。

通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。

声明通道类型 通道本身需要一个类型进行修饰,就像切片类型需要标识元素类型。通道的元素类型就是在其内部传输的数据类型,声明如下:

var 通道变量 chan 通道类型

通道类型:通道内的数据类型。 通道变量:保存通道的变量。 chan 类型的空值是 nil,声明后需要配合 make 后才能使用。

通道(channel)

通道(channel)是用来传递数据的一个数据结构。

通道可用于两个 goroutine 之间通过传递一个指定类型的值来同步运行和通讯。

使用 make 函数创建一个 channel,使用 <- 操作符发送和接收数据。如果未指定方向,则为双向通道。

ch <- v    // 把 v 发送到通道 ch
v := <-ch  // 从 ch 接收数据
           // 并把值赋给 v

声明一个通道很简单,我们使用chan关键字即可,通道在使用前必须先创建:

ch := make(chan int)

注意:默认情况下,通道是不带缓冲区的。发送端发送数据,同时必须有接收端相应的接收数据。

以下实例通过两个 goroutine 来计算数字之和,在 goroutine 完成计算后,它会计算两个结果的和:

实例

  
import "fmt"  
  
func sum(s []int, c chan int) {  
        sum := 0  
        for _, v := range s {  
                sum += v  
        }  
        c <- sum // 把 sum 发送到通道 c  
}  
  
func main() {  
        s := []int{7, 2, 8, -9, 4, 0}  
  
        c := make(chan int)  
        go sum(s[:len(s)/2], c)  
        go sum(s[len(s)/2:], c)  
        x, y := <-c, <-c // 从通道 c 中接收  
  
        fmt.Println(x, y, x+y)  
}  

输出结果为:

-5 17 12

通道缓冲区

通道可以设置缓冲区,通过 make 的第二个参数指定缓冲区大小:

ch := make(chan int, 100)

带缓冲区的通道允许发送端的数据发送和接收端的数据获取处于异步状态,就是说发送端发送的数据可以放在缓冲区里面,可以等待接收端去获取数据,而不是立刻需要接收端去获取数据。

不过由于缓冲区的大小是有限的,所以还是必须有接收端来接收数据的,否则缓冲区一满,数据发送端就无法再发送数据了。

注意:如果通道不带缓冲,发送方会阻塞直到接收方从通道中接收了值。如果通道带缓冲,发送方则会阻塞直到发送的值被拷贝到缓冲区内;如果缓冲区已满,则意味着需要等待直到某个接收方获取到一个值。接收方在有值可以接收之前会一直阻塞。

实例

  
import "fmt"  
  
func main() {  
    // 这里我们定义了一个可以存储整数类型的带缓冲通道  
        // 缓冲区大小为2  
        ch := make(chan int, 2)  
  
        // 因为 ch 是带缓冲的通道,我们可以同时发送两个数据  
        // 而不用立刻需要去同步读取数据  
        ch <- 1  
        ch <- 2  
  
        // 获取这两个数据  
        fmt.Println(<-ch)  
        fmt.Println(<-ch)  
}  

执行输出结果为:

1
2

Go 遍历通道与关闭通道

Go 通过 range 关键字来实现遍历读取到的数据,类似于与数组或切片。格式如下:

v, ok := <-ch

如果通道接收不到数据后 ok 就为 false,这时通道就可以使用 close() 函数来关闭。

实例

  
import (  
        "fmt"  
)  
  
func fibonacci(n int, c chan int) {  
        x, y := 0, 1  
        for i := 0; i < n; i++ {  
                c <- x  
                x, y = y, x+y  
        }  
        close(c)  
}  
  
func main() {  
        c := make(chan int, 10)  
        go fibonacci(cap(c), c)  
        // range 函数遍历每个从通道接收到的数据,因为 c 在发送完 10 个  
        // 数据之后就关闭了通道,所以这里我们 range 函数在接收到 10 个数据  
        // 之后就结束了。如果上面的 c 通道不关闭,那么 range 函数就不  
        // 会结束,从而在接收第 11 个数据的时候就阻塞了。  
        for i := range c {  
                fmt.Println(i)  
        }  
}  

执行输出结果为:

0
1
1
2
3
5
8
13
21
34

Select 语句

select 语句使得一个 goroutine 可以等待多个通信操作。select 会阻塞,直到其中的某个 case 可以继续执行:

实例

  
import "fmt"  
  
func fibonacci(c, quit chan int) {  
    x, y := 0, 1  
    for {  
        select {  
        case c <- x:  
            x, y = y, x+y  
        case <-quit:  
            fmt.Println("quit")  
            return  
        }  
    }  
}  
  
func main() {  
    c := make(chan int)  
    quit := make(chan int)  
  
    go func() {  
        for i := 0; i < 10; i++ {  
            fmt.Println(<-c)  
        }  
        quit <- 0  
    }()  
    fibonacci(c, quit)  
}  

以上代码中中,fibonacci goroutine 在 channel c 上发送斐波那契数列,当接收到 quit channel 的信号时退出。

执行输出结果为:

0
1
1
2
3
5
8
13
21
34
quit