【Go简明手册】Go并发编程

文章目录
  1. 1. 知识点
  • 并发编程
    1. 1. 并发与并行
    2. 2. 协程*
  • goroutine*
  • channel
    1. 声明与初始化
  • select
  • 超时机制
    1. 1. channel 的关闭
  • 知识点

    • 并发与并行
    • 协程
    • goroutine
    • channel
    • select

    并发编程

    并发与并行

    并发指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,通过 CPU 时间片轮转使多个进程快速交替的执行。而并行的关键是你有同时处理多个任务的能力。并发和并行都可以是很多个线程,就看这些线程能不能同时被(多个)CPU 执行,如果可以就说明是并行,而并发是多个线程被(一个)CPU 轮流切换着执行。一个经典且通俗易懂的例子这样解释并发与并行的区别:并发是两个队列,使用一台咖啡机;并行是两个队列,使用两台咖啡机。如果串行,一个队列使用一台咖啡机,那么哪怕前面那个人有事出去了半天,后面的人也只能等着他回来才能去接咖啡,这效率无疑是最低的。图解:

    协程*

    协程也叫轻量级线程。与传统的进程和线程相比,协程最大的优点就在于其足够“轻”,操作系统可以轻松创建上百万个协程而不会导致系统资源枯竭,而线程和进程通常最多不过近万个。而多数语言在语法层面上是不支持协程的,一般都是通过库的方式进行支持,但库的支持方式和功能不够完善,经常会引发阻塞等一系列问题,而 Go 语言在语法层面上支持协程,也叫 goroutine。这让协程变得非常简单,让轻量级线程的切换管理不再依赖于系统的进程和线程,也不依赖 CPU 的数量。

    goroutine*

    goroutine 是 Go 语言并行设计的核心。goroutine 是一种比线程更轻量的实现,十几个 goroutine 可能在底层就是几个线程。 不同的是,Golang 在 runtime、系统调用等多方面对 goroutine 调度进行了封装和处理,当遇到长时间执行或者进行系统调用时,会主动把当前 goroutine 的 CPU (P) 转让出去,让其他 goroutine 能被调度并执行,也就是 Golang 从语言层面支持了协程。要使用 goroutine 只需要简单的在需要执行的函数前添加 go 关键字即可。当执行 goroutine 时候,Go 语言立即返回,接着执行剩余的代码,goroutine 不阻塞主线程。下面我们通过一小段代码来讲解 go 的使用:

    1
    2
    3
    4
    5
    6
    7
    //首先我们先实现一个 Add()函数
    func Add(a, b int) {
    c := a + b
    fmt.Println(c)
    }

    go Add(1, 2) //使用go关键字让函数并发执行

    Go 的并发执行就是这么简单,当在一个函数前加上 go 关键字,该函数就会在一个新的 goroutine 中并发执行,当该函数执行完毕时,这个新的 goroutine 也就结束了。不过需要注意的是,如果该函数具有返回值,那么返回值会被丢弃。所以什么时候用 go 还需要酌情考虑。

    接着我们通过一个案例来体验一下 Go 的并发到底是怎么样的。新建源文件 goroutine.go,输入以下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    package main

    import "fmt"

    func Add(a, b int) {
    c := a + b
    fmt.Println(c)
    }

    func main() {
    for i := 0; i < 10; i++ {
    go Add(i, i)
    }
    }

    执行 goroutine.go 文件会发现屏幕上什么都没有,但程序并不会报错,这是什么原因呢?原来当主程序执行到 for 循环时启动了 10 个 goroutine,然后主程序就退出了,而启动的 10 个 goroutine 还没来得及执行 Add() 函数,所以程序不会有任何输出。也就是说主 goroutine 并不会等待其他 goroutine 执行结束。那么如何解决这个问题呢?Go 语言提供的信道(channel)就是专门解决并发通信问题的

    channel

    channelgoroutine 之间互相通讯的东西。类似我们 Unix 上的管道(可以在进程间传递消息),用来 goroutine 之间发消息和接收消息。其实,就是在做 goroutine 之间的内存共享。channel 是类型相关的,也就是说一个 channel 只能传递一种类型的值,这个类型需要在 channel 声明时指定。

    声明与初始化

    channel 的一般声明形式:var chanName chan ElementType。

    与普通变量的声明不同的是在类型前面加了 channel 关键字,ElementType 则指定了这个 channel 所能传递的元素类型。示例:

    1
    2
    3
    var a chan int //声明一个传递元素类型为int的channel
    var b chan float64
    var c chan string

    初始化一个 channel 也非常简单,直接使用 Go 语言内置的 make() 函数,示例:

    1
    2
    3
    a := make(chan int) //初始化一个int型的名为a的channel
    b := make(chan float64)
    c := make(chan string)

    channel 最频繁的操作就是写入和读取,这两个操作也非常简单,示例:

    1
    2
    3
    a := make(chan int)
    a <- 1 //将数据写入channel
    z := <-a //从channel中读取数据

    select

    select 用于处理异步 IO 问题,它的语法与 switch 非常类似。由 select 开始一个新的选择块,每个选择条件由 case 语句来描述,并且每个 case 语句里必须是一个 channel 操作。它既可以用于 channel 的数据接收,也可以用于 channel 的数据发送。如果 select 的多个分支都满足条件,则会随机的选取其中一个满足条件的分支。

    新建源文件 channel.go,输入以下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    package main
    import "time"
    import "fmt"
    func main() {
    c1 := make(chan string)
    c2 := make(chan string)
    go func() {
    time.Sleep(time.Second * 1)
    c1 <- "one"
    }()
    go func() {
    time.Sleep(time.Second * 2)
    c2 <- "two"
    }()
    for i := 0; i < 2; i++ {
    select {
    case msg1 := <-c1:
    fmt.Println("received", msg1)
    case msg2 := <-c2:
    fmt.Println("received", msg2)
    }
    }
    }

    以上代码先初始化两个 channel c1 和 c2,然后开启两个 goroutine 分别往 c1 和 c2 写入数据,再通过 select 监听两个 channel,从中读取数据并输出。

    运行结果如下:

    1
    2
    3
    $ go run channel.go
    received one
    received two

    超时机制

    通过前面的内容我们了解到,channel 的读写操作非常简单,只需要通过 <- 操作符即可实现,但是 channel 的使用不当却会带来大麻烦。我们先来看之前的一段代码:

    1
    2
    3
    a := make(chan int)
    a <- 1
    z := <-a

    观察上面三行代码,第 2 行往 channel 内写入了数据,第 3 行从 channel 中读取了数据,如果程序运行正常当然不会出什么问题,可如果第二行数据写入失败,或者 channel 中没有数据,那么第 3 行代码会因为永远无法从 a 中读取到数据而一直处于阻塞状态。相反的,如果 channel 中的数据一直没有被读取,那么写入操作也会一直处于阻塞状态。如果不正确处理这个情况,很可能会导致整个 goroutine 锁死,这就是超时问题。Go 语言没有针对超时提供专门的处理机制,但是我们却可以利用 select 来巧妙地实现超时处理机制,下面看一个示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    t := make(chan bool)
    go func {
    time.Sleep(1e9) //等待1秒
    t <- true
    }

    select {
    case <-ch: //从ch中读取数据

    case <-t: //如果1秒后没有从ch中读取到数据,那么从t中读取,并进行下一步操作
    }

    这样的方法就可以让程序在等待 1 秒后继续执行,而不会因为 ch 读取等待而导致程序停滞,从而巧妙地实现了超时处理机制,这种方法不仅简单,在实际项目开发中也是非常实用的。

    channel 的关闭

    channel 的关闭非常简单,使用 Go 语言内置的 close() 函数即可关闭 channel,示例:

    1
    2
    ch := make(chan int)
    close(ch)

    关闭了 channel 后如何查看 channel 是否关闭成功了呢?很简单,我们可以在读取 channel 时采用多重返回值的方式,示例:

    1
    x, ok := <-ch

    通过查看第二个返回值的 bool 值即可判断 channel 是否关闭,若为 false 则表示 channel 被关闭,反之则没有关闭。