Go 并发编程 | 青训营

75 阅读12分钟

GO 并发

1. 基本概念

1.1 并发(Concurrency)

  • 在并发情况下,多个任务交替地在CPU上执行。虽然在某个时间点上只有一个任务在执行,但是任务之间可能会频繁地切换执行。
  • 这种切换可能是由操作系统的调度机制触发的,也可能是任务自身的协作式切换(例如在多线程中)。
  • 并发利用了CPU的时间片轮转和任务切换来实现多个任务的同时执行。

1.2 并行(Parallelism)

  • 在并行情况下,多个任务实际上在不同的CPU核心、处理器或计算单元上同时执行。
  • 每个任务都有自己的独立执行环境,可以在同一时刻执行不同的指令。
  • 并行利用了硬件上的多个处理单元来实现多个任务的真正同时执行。

Go 语言支持并发,我们只需要通过 go 关键字来开启 goroutine 即可。

2. goroutine

"goroutine" 是一种轻量级的并发执行单元,一般将其翻译为Go协程,类似于线程,但是它是由用户空间代码调度,而非操作系统内核调度。

  • 与传统的线程相比,它的创建和销毁开销非常小,可以轻松地创建数千甚至数百万个 goroutine。
  • Goroutine 由 Go 运行时系统进行管理,它们在一个或多个线程上运行,以实现并发执行。

语法格式:

go 函数名( 参数列表 )

goroutine的创建是需要时间的,当我们执行下列代码的时候

package main
​
import "fmt"func hello() {
    fmt.Println("hello world")
}
​
func main() {
    go hello()
    fmt.Println("main")
}
​

会发现输出是:

main

为什么“hello world” 没有被输出呢,因为main函数先行结束了,当主程序结束的时候,所有的子程序就会停止执行

这个时候我们可以使用 time.sleep 来延续主程序的运行时间

例如:

package main
​
import (
    "fmt"
    "time"
)
​
func hello() {
    fmt.Println("hello world")
}
​
func main() {
    go hello()
    fmt.Println("main")
    time.Sleep(time.Second) // 休眠1s
}
​

输出:

main
hello world

如何理解 goroutine 是并发的呢?请看下面的代码

package main
​
import (
    "fmt"
    "time"
)
​
func hello() {
    for i := 1; i <= 5; i++ {
        fmt.Printf("%v ", i)
    }
}
​
func main() {
    go hello()
    go hello()
    go hello()
    time.Sleep(time.Second)
}

第一次输出结果:

1 2 3 4 5 1 1 2 3 4 5 2 3 4 5

第二次输出结果:

1 2 3 4 5 1 2 3 4 5 1 2 3 4 5

第三次输出结果:

1 2 3 1 2 3 4 5 4 1 2 3 4 5 5

可以看到三次输出结果都不同,这正是因为goroutine是并发执行的,而goroutine的调度是随机的。

3. runtime包

3.1 runtime.Gosched()

runtime.Gosched() 是 Go 语言标准库中 runtime 包提供的一个函数。它的作用是让出当前 goroutine 的执行权,让其他 goroutines 有机会执行。这个函数可以用来显式地让出当前 goroutine 的调度,以便其他等待执行的 goroutines 能够运行。但具体何时会切换以及切换到哪个 goroutine,是由调度器决定的,而不是由程序员控制的。这样的设计是为了确保并发程序的公平性和性能。

例如:

package main
​
import (
    "fmt"
    "runtime"
)
​
func hello() {
    for i := 1; i <= 3; i++ {
        fmt.Println("hello world")
    }
}
func main() {
    go hello()
    runtime.Gosched()
    fmt.Println("main")
}

输出:

hello world
hello world
hello world
main

并不是让其他协程全部执行完毕之后再恢复,只是这里子程序用时很短所以先执行完了。

3.2 runtime.Goexit()

runtime.Goexit() 是 Go 语言标准库中 runtime 包提供的一个函数,用于立即终止当前正在执行的 goroutine。调用此函数会导致当前 goroutine 立即停止执行,并将控制权返回给调度器,以便其他 goroutines 可以继续执行。

package main
​
import (
    "fmt"
    "runtime"
    "time"
)
​
func hello() {
    for i := 1; i <= 3; i++ {
        if i == 2 {
            runtime.Goexit()
        }
        fmt.Println("hello world")
    }
}
func main() {
    go hello()
    fmt.Println("main")
    time.Sleep(time.Second)
}

输出:

main
hello world 

可以看到只输出了一遍hello world 就结束了

4. channel

通道(channel)是用来传递数据的一个数据结构,是引用类型空值是 nil

通道可用于两个 goroutine 之间通过传递一个指定类型的值来同步运行和通讯。操作符 <- 用于指定通道的方向,发送或接收。如果未指定方向,则为双向通道。通道提供了一种线程安全的方式来进行数据交换,无需显式地使用互斥锁等同步机制。这可以避免很多常见的并发错误,如竞态条件和死锁。

Go 的设计哲学之一是“不要通过共享内存来通信,而要通过通信来共享内存”。通道强调了在 goroutines 之间通过通信来传递数据,而不是通过共享内存。这有助于避免竞态条件和其他并发问题。

4.1 channel的创建及缓冲区

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

创建channel的格式如下:

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

例如:

ch := make(chan int, 100)
// ch := make(chan int) 也行,缓冲大小是可选的

带缓冲区的通道允许发送端的数据发送和接收端的数据获取处于异步状态,就是说发送端发送的数据可以放在缓冲区里面,可以等待接收端去获取数据,而不是立刻需要接收端去获取数据。不过由于缓冲区的大小是有限的,所以还是必须有接收端来接收数据的,否则缓冲区一满,数据发送端就无法再发送数据了。

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

在 Go 语言中,通道的缓冲区大小是以元素个数为单位的,而不是以字节为单位。

例如,如果创建了一个 make(chan int, 10),表示这个通道的缓冲区可以容纳 10 个整数类型的元素。

4.2 channel的使用

通道有发送、接收和关闭三种操作。发送和接收都使用<-符号。

下面是一个简单的例子:

package main
​
import "fmt"func main() {
    ch := make(chan string, 1) // 这里不设置缓冲区大小就会报错,无缓冲区一定要有另一个协程与其同步
    ch <- "qwq"                // 发送
    close(ch)                  // 关闭
    x := <-ch                  // 接收
    fmt.Println(x)
}
​

我们应该在发送完毕之后关闭通道,以便接收端知道发送操作已经完成

关于关闭通道需要注意的事情是,只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。

关闭后的通道有以下特点:

  1. 对一个关闭的通道再发送值就会导致panic。
  2. 对一个关闭的通道进行接收会一直获取值直到通道为空。
  3. 对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
  4. 关闭一个已经关闭的通道会导致panic。

4.3 channel与goroutine

那么该如何利用channel 实现 goroutine之间的交互呢,下面是一个简单的例子

package main
​
import (
    "fmt"
    "time"
)
​
func receiveString(ch chan string) {
    s := <-ch
    fmt.Println("接收成功 " + s)
}
​
func main() {
    ch := make(chan string)
    go receiveString(ch)
    ch <- "qwq"
    close(ch)
    time.Sleep(time.Second)
}
​

输出:

接收成功 qwq

多个值的时候,需要循环取值的时候,我们可以使用 range 来接收 channel 中的值

package main
​
import (
    "fmt"
    "time"
)
​
func res(ch chan int) {
    for i := range ch {
        fmt.Printf("%v ", i)
    }
}
​
func main() {
    ch := make(chan int)
    go res(ch)
    for i := 1; i <= 10; i++ {
        ch <- i
    }
    close(ch)
    time.Sleep(time.Second)
}

输出:

1 2 3 4 5 6 7 8 9 10

4.4 单向通道

之前我们将通道作为参数的时候是这样写的 ch chan int

chan前面或后面加上<- 即可表示只能接收,或者只能发送的单向通道

  1. chan<- type是一个只能进行 发送操作 的通道;
  2. <-chan type是一个只能进行 接收操作 的通道。

需要注意的是,将双向通道转换为单向通道是可以的,但反过来是不可以的。

package main
​
import (
    "fmt"
    "time"
)
​
func send(ch chan<- int) {
    for i := 1; i <= 5; i++ {
        ch <- i
    }
    close(ch)
}
​
func receive(ch <-chan int) {
    for i := range ch {
        fmt.Printf("%v ", i)
    }
}
​
func main() {
    ch := make(chan int)
    sendChan := make(<-chan int)
    go send(ch)
    sendChan = ch
    go receive(sendChan)
    time.Sleep(time.Second)
}
​

这里我们先用一个双向通道 进行 发送操作,发送完毕关闭通道,然后将 双向通道赋值 给了一个只能进行接收操作的通道,然后进行数据接收

输出:

1 2 3 4 5

我对于channel的理解总结就是:

就想打通管道一样,当使用无缓冲区channel 的时候,必须全部管道打通了才可以正确运行,不然就会发送阻塞导致死锁,当发送操作完成之后应该关闭管道,以便告知 发送操作完成。

5. select

select 是 Go 语言中用于处理多个通道操作的语句,它允许在多个通道上进行非阻塞的选择操作。select 语句可以用来监听多个通道的发送和接收操作,一旦其中任意一个通道准备就绪,就会执行对应的分支代码。

select 语句的语法如下,类似于switch语句

select {
case <-channel1:
    // 执行 channel1 接收操作
case data := <-channel2:
    // 执行 channel2 接收操作,并将接收到的数据赋值给 data
case channel3 <- value:
    // 执行 channel3 发送操作,将 value 发送到 channel3
default:
    // 当所有通道都没有准备就绪时执行的默认操作
}
​
package main
​
import (
    "fmt"
    "time"
)
​
func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)
​
    go func() {
        time.Sleep(time.Second)
        ch1 <- "Hello from channel 1"
        close(ch1)
    }()
​
    go func() {
        ch2 <- "Hello from channel 2"
        close(ch2)
    }()
    time.Sleep(time.Millisecond * 500) //等待0.5s让第二个协程能够跑完
    select {
    case msg1 := <-ch1:
        fmt.Println(msg1)
    case msg2 := <-ch2:
        fmt.Println(msg2)
    default:
        fmt.Println("No channel is ready")
    }
}

输出:

Hello from channel 2

可以用来判断管道是否存满:

package main
​
import "fmt"func main() {
    ch := make(chan int, 3) // 创建一个缓冲大小为 3 的通道for i := 1; i <= 5; i++ {
        select {
        case ch <- i:
            fmt.Printf("Sent %d to channel\n", i)
        default:
            fmt.Println("Channel is full")
        }
    }
​
    close(ch)
}

输出:

Sent 1 to channel
Sent 2 to channel
Sent 3 to channel
Channel is full
Channel is full

6. sync.WaitGroup

sync.WaitGroup 是 Go 语言中的一个同步原语,用于等待一组 goroutine 完成执行后再继续执行其他操作。它通常用于在主 goroutine 中等待所有启动的子 goroutine 执行完毕,以确保所有工作都完成后程序可以安全地退出或继续执行其他任务。

sync.WaitGroup 提供了以下三个主要方法:

  1. Add(delta int):向计数器中添加或减少一个正整数值。通常在启动一个 goroutine 时调用 Add(1),表示有一个 goroutine 需要等待。
  2. Done():减少计数器的值。在每个 goroutine 的结束处调用 Done(),表示该 goroutine 完成了。
  3. Wait():阻塞调用该方法的 goroutine,直到计数器值降为零。一般在主 goroutine 中使用 Wait() 来等待所有子 goroutine 完成。

我们可以用 sync.WaitGroup 来取代 time.Sleep 进行等待协程操纵,而且也更推荐这样做

例如:

package main
​
import (
    "fmt"
    "sync"
)
​
func out(id int, wg *sync.WaitGroup) {
    defer wg.Done() //等函数执行完毕再进行,说明这个协程结束了
    for i := 1; i <= 5; i++ {
        fmt.Println(id, i)
    }
}
​
func main() {
    var wg sync.WaitGroup
    for i := 1; i <= 3; i++ {
        wg.Add(1) // 开启一个就加一个子协程进去
        go out(i, &wg)
    }
    wg.Wait() // 等待所有子程序结束
    fmt.Println("main end")
}

输出:

1 1
1 2
1 3
1 4
1 5
3 1
2 1
2 2
2 3
2 4
2 5
3 2
3 3
3 4
3 5
main end

7. 锁

到了并发问题就离不开锁,因为多个协程共用一块内存空间,如果多个协程同时操纵一个数据的话,就会造成问题,这点与其他语言类似就不过多赘述了。

7.1 互斥锁

在 Go 语言中的锁(互斥锁)与 Java 中的锁(如 Java 的 synchronized 关键字和 ReentrantLock 类)在基本原理上是相似的。互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型来实现互斥锁。

package main
​
import (
    "fmt"
    "sync"
)
​
func addSum(sum *int, wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 1; i <= 5000; i++ {
        *sum += 1
    }
}
​
func main() {
    var x int
    var wg sync.WaitGroup
    for i := 1; i <= 10; i++ {
        wg.Add(1)
        go addSum(&x, &wg)
    }
    wg.Wait()
    fmt.Println(x)
}

运行上列代码,预期结果应该是50000,实际上每次输出都小于50000,并且都不同

我们可以使用 sync.Mutex 加一把锁:

package main
​
import (
    "fmt"
    "sync"
)
​
var lock sync.Mutex //定义一把锁func addSum(sum *int, wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 1; i <= 5000; i++ {
        lock.Lock() //加锁
        *sum += 1
        lock.Unlock() //解锁
    }
}
​
func main() {
    var x int
    var wg sync.WaitGroup
    for i := 1; i <= 10; i++ {
        wg.Add(1)
        go addSum(&x, &wg)
    }
    wg.Wait()
    fmt.Println(x)
}

输出结果为 50000, 与预期一致

7.2 读写锁

在 Go 语言中,读写锁是一种同步原语,用于在多个 goroutine 之间共享数据的同时提供高效的读操作和独占的写操作。读写锁被设计用来优化对共享资源的并发访问,以便在读操作比写操作更频繁时能够提供更好的性能。

Go 语言的标准库提供了 sync.RWMutex 类型来实现读写锁。RWMutex 包含了一个内部锁,可以由多个 goroutine 同时获取读锁,但只能由一个 goroutine 获取写锁。当存在读锁时,其他 goroutine 仍然可以获取读锁,但不能获取写锁。当存在写锁时,其他 goroutine 无法获取读锁或写锁。

例如:

package main
​
import (
    "fmt"
    "sync"
    "time"
)
​
func main() {
    var rwMutex sync.RWMutex
    var count int// 多个 goroutine 并发读取计数器
    for i := 1; i <= 5; i++ {
        go func() {
            rwMutex.RLock()
            defer rwMutex.RUnlock()
            fmt.Printf("Current count: %d\n", count)
        }()
    }
​
    // 单个 goroutine 写入计数器
    go func() {
        rwMutex.Lock()
        defer rwMutex.Unlock()
        count++
        fmt.Println("Count ++ ")
    }()
​
    time.Sleep(time.Second)
}

输出:

Current count: 0
Current count: 0
Count ++
Current count: 1
Current count: 1
Current count: 1

这个样例中多个 goroutine 并发地读取 count 计数器的值,但只有一个 goroutine 能够增加计数器的值。

需要注意的是读写锁非常适合读多写少的场景,如果读和写的操作差别不大,读写锁的优势就发挥不出来。