Go 并发编程(一):协程 goroutine、channel、协程池


协程介绍

什么是协程?

协程,又称微线程,英文为 Coroutine。

协程可以理解为用户态线程,是比线程更小的执行单元。为啥说它是一个执行单元?因为它自带 CPU 上下文。这样只要在合适的时机,我们可以把一个协程切换到另一个协程。只要这个过程中保存或恢复 CPU 上下文,那么程序还是可以运行的。

通俗的理解:
在一个线程中的某个函数,可以在任何地方保存当前函数的一些临时变量等信息,然后切换到另外一个函数中执行,注意不是通过调用函数的方式做到的,并且切换的次数以及什么时候再切换到原来的函数都可以由开发者自己确定。

区别于线程,协程的调度在用户态进行,不需要切换到内核态,所以不由操作系统控制,而由用户自己控制。在一些支持协程高级语言中,往往都实现了自己的协程调度器,比如 Go 语言就有自己的协程调度器。

协程有独立的栈空间,并共享堆空间。


协程 VS 进程 VS 线程

协程 VS 进程

  • 执行流的调度者不同:无论多线程和多进程,其调度更多取决于操作系统;而协程的方式,调度来自用户。也就是说进程的上下文是在内核态保存恢复的,而协程是在用户态保存恢复的,显然用户态的代价更低。
  • 进程会被强占;而协程不会:也就是说协程如果不主动让出 CPU,那么其他的协程就没有执行的机会。
  • 对内存的占用不同:实际上协程可以只需要 4K 的栈就足够了;而进程占用的内存要大得多。
  • 从操作系统的角度讲,多协程的程序是单进程单线程

协程 VS 线程

  • 协程看起来跟线程差不多,其实不然。线程切换从系统层面来看远不止保存和恢复 CPU 上下文这么简单。操作系统为了程序运行的高效性,每个线程都有自己缓存 Cache 等数据,操作系统还会帮你做这些数据的恢复操作,所以线程的切换非常耗性能。但是协程的切换只是单纯的操作 CPU 的上下文,所以一秒钟切换个上百万次,系统都抗得住。
  • 同样的,线程的切换更多的是靠操作系统来控制,而协程的执行由我们自己控制。
  • 协程只是在单一的线程下,不同的协程之间做切换;其实和多线程很像,多线程是在一个进程下,不同的线程之间做切换。
  • 一个线程中可以有任意多个协程,但某一时刻只能有一个协程在运行,多个协程分享该线程分配到的计算机资源。

协程的优缺点

优点

  • 无需线程上下文切换的开销:协程执行效率极高,因为子程序(函数)切换不是线程切换,由程序自身控制,没有切换线程的开销。所以与多线程相比,线程的数量越多,协程性能的优势越明显。但也因此,程序员必须自己承担调度的责任,同时,协程也失去了标准线程使用多 CPU 的能力。
  • 高并发+高扩展性+低成本:一个 CPU 支持上万个协程都不是问题,所以很适合用于高并发处理。

缺点

  • 无法利用多核资源:协程的本质是个单线程,它不能同时将单个 CPU 的多个核用上,协程需要和多进程/线程配合才能运行在多 CPU 上。
  • 进行阻塞(Blocking)操作(如 I/O 时)会阻塞掉整个程序
  • 协程可以很好地处理 I/O 密集型程序的效率问题,但是处理 CPU 密集型不是它的长处,如要充分发挥 CPU 利用率可以结合多进程/线程+协程。

Goroutine

Go 并发介绍

Go 在语言级别支持协程,叫 goroutine,且 Go 语言中的并发只会用到 goroutine,并不需要我们去考虑用多进程或者是多线程。

Go 语言标准库提供的所有系统调用操作(包括所有同步 I/O 操作),都会让出 CPU 给其他 goroutine。这让轻量级线程的切换管理不依赖于系统的线程和进程,也不依赖于 CPU 的核心数量。

有人把 Go 比作 21 世纪的 C 语言,第一是因为 Go 语言设计简单;第二,21 世纪最重要的就是并行程序设计,而 Go 从语言层面就支持并行;同时,并发程序的内存管理有时候是非常复杂的,而 Go 语言提供了自动垃圾回收机制。

线程本身是有一定大小的,一般 OS 线程栈大小为 2MB,且线程在创建和上下文切换的时候是需要消耗资源的,会带来性能损耗,所以在我们用到多线程技术的时候,往往会通过池化技术,即创建线程池来管理一定数量的线程。

在 Go 语言中,一个 goroutine 栈在其生命周期开始时占用空间很小(一般 2KB),并且栈大小可以按需增大和缩小,goroutine 的栈大小限制可以达到 1GB,但是一般不会用到这么大。所以在 Go 语言中一次创建成千上万,甚至十万左右的 goroutine 在理论上也是可以的。

在 Go 语言中,当某个任务需要并发执行的时候,只需要把这个任务包装成一个函数,开启一个 goroutine 去执行这个函数就可以了。并不需要我们来维护一个类似于线程池的东西,也不需要我们去关心协程是怎么切换和调度的,因为这些都已经有 Go 语言内置的调度器帮我们做了,并且效率还非常高。


Goroutine 创建

只需在函数调⽤语句前添加 go 关键字,就可创建并发执⾏单元。开发⼈员无需了解任何执⾏细节,调度器会自动将其安排到合适的系统线程上执行。

在并发编程中,我们通常想将一个过程切分成几块,然后让每个 goroutine 各自负责一块工作,当一个程序启动时,main() 函数会在一个单独的 goroutine 中运行,我们叫它 main goroutine,而新的子 goroutine 会用 go 语句来创建。

当 main() 函数返回时该 main goroutine 就结束了,而当主协程退出的时候,其余协程不管是否运行完,都会跟着结束。

import (
    "fmt"
    "time"
)

func myGroutine(name string) {
    for i := 0; i < 5; i++ {
        fmt.Printf("myGroutine %s\n", name)
        time.Sleep(10 * time.Millisecond)
    }
}

func main() {
    go myGroutine("1")
    go myGroutine("2")
    time.Sleep(2 * time.Second)
}

运行结果:

myGroutine 2
myGroutine 1
myGroutine 1
myGroutine 2
myGroutine 2
myGroutine 1
myGroutine 1
myGroutine 2

多协程异常捕获

recover 捕获范围

用 recover 捕获异常时,只能捕获当前 goroutine 的 panic,不能捕获其他 goroutine 发生的 panic 。

示例:

func main() {
   defer func() {
      if e := recover(); e != nil {
         fmt.Printf("main recover:%v\n", e)
      }
   }()

   go func() {
      defer func() {
         if e := recover(); e != nil {
            fmt.Printf("sub recover:%v\n", e)
         }
      }()
      panic("sub func panic!!!")  // 只会被go func()的defer recover捕获
      fmt.Println("111")
   }()

   panic("main func panic!!!")
   fmt.Println("222")   // 只会被main的defer recover捕获
   time.Sleep(2 * time.Second)
}

运行结果:

main recover:main func panic!!!
sub recover:sub func panic!!!

可以看出,主函数 goroutine 中的 recover 只能捕获主 goroutine 中发生的 panic,子 goroutine 只能捕获子 goroutine 发生的 panic 。

所以当我们程序中有多个 goroutine 处理任务时,如果 goroutine 有可能发生 panic ,则需要在 goroutine 中也捕获异常。


绑定 recover 创建 goroutine

在开发项目的时候,我们可能会创建多个 goroutine 来提高程序的效率,但是在每个创建的 goroutine 中为了捕获异常需要频繁的写 defer recover 函数来捕获异常,既繁琐又显得代码不简洁。

因此我们可以将协程的逻辑封装成函数,绑定 recover,以此来创建 goroutine 。

示例:

package main

import (
   "fmt"
   "sync"
)

// 传入的是不定参数:返回值为error类型的函数
func withGoroutine(opts ...func() error) (err error) {
    var wg sync.WaitGroup
    for _, opt := range opts {
       wg.Add(1)
       // 开启goroutine
       go func(handler func() error) {
          defer func() {
             // 协程内部捕获panic
             if e := recover(); e != nil {
                fmt.Printf("recover:%v\n", e)
             }
             wg.Done()
          }()
          e := handler()  // 真正调用传入的函数
          // 取第一个报错的handler调用的错误并返回
          // err == nil表示之前还没有handler报错
          // 配合 e != nil表示处于第一个报错的handler中
          if err == nil && e != nil {
             err = e
          }
       }(opt)  // 将goroutine的函数逻辑通过封装成的函数变量传入
    }
 
    wg.Wait()  // 等待所有的协程执行完

    return
}

func main() {
    handler1 := func() error {
       panic("handler1 fail ")
       return nil
    }
 
    handler2 := func() error {
       panic("handler2 fail")
       return nil
    }
    // 并发执行handler1和handler2两个任务,返回第一个报错的任务错误
    err := withGoroutine(handler1, handler2) 
    if err != nil {
       fmt.Printf("err is:%v", err)
    }
}

运行结果:

recover:handler2 fail
recover:handler1 fail

通过 err := withGoroutine(handler1, handler2) 并发执行,使得这两个 handler 中都有 panic时都能成功捕获。


Channel

什么是 Channel ?

Channel 官方定义:
Channels are a typed conduit through which you can send and receive values with the channel operator

Channel 是一种数据类型,是一个可以收发数据的管道,主要用来解决 goroutine 的同步问题以及协程之间数据共享(数据传递)的问题。

goroutine 运行在相同的地址空间,因此访问共享内存必须做好同步。Goroutine 奉行通过通信来共享内存,而不是共享内存来通信

引⽤类型 channel 可用于多个 goroutine 通讯,其内部实现了同步,确保并发安全


Channel 初始化

方式一:先声明,再初始化

var channel_name chan channel_type
channel_name = make(chan channel_name)

示例:

// 声明一个无缓冲的channel,即其缓冲容量大小为0
var a chan int
fmt.Println(a) // <nil>

// 通道是一个引用类型,初始值为nil
// 对于值为nil的通道,不论具体是什么类型,它们所属的接收和发送操作都会永久处于阻塞状态。
// 所以必须手动make初始化
a = make(chan int)
fmt.Println(a)       // 0x11086100

方式二:一步到位

// channel_name := make(chan channel_name, capacity)
a := make(chan int) // 等价于 make(chan int, 0)
b := make(chan int, 5)
  • 当参数 capacity = 0 时,channel 是无缓冲阻塞读写的,可以理解为同步模式,即写入一个,如果没有消费者在消费,写入就会阻塞。
  • 当参数 capacity > 0 时,channel 是有缓冲、是非阻塞的,可以理解为异步模式。写入消息之后,即使还没被消费,只要队列没满,就可继续写入;如果队列满了,写入就会阻塞。

Channel 操作

channel通过操作符 <- 来接收和发送数据,其发送和接收数据的语法如下:

channel <- value      // 发送value到channel
x := <-channel        // 从channel中接收数据,并赋值给x
<-channel             // 从channel中接收数据,并将其丢弃
x, ok := <-channel    // 功能同上,同时检查通道是否已关闭或者是否为空
close(channel)        // 关闭管道channel

需要注意 close(channel) 这个操作,表示管道用完了,需要对其进行关闭,避免程序一直在等待以及资源的浪费。

但 channel 不像文件一样需要经常去关闭,只有当你确实没有任何发送数据了,或者你想显式的结束 range 循环之类的,才去关闭 channel 。

  • 关闭一个未初始化的 channel 会产生 panic 。
  • channel只能被关闭一次,对同一个channel重复关闭会产生 panic 。
  • 向一个已关闭的 channel 发送消息会产生 panic 。
  • 从一个已关闭的 channel 读取消息不会发生 panic,会一直读取到零值。
  • channel 可以读端和写端都可有多个 goroutine 操作,在一端关闭 channel 的时候,该 channel 读端的所有 goroutine 都会收到 channel 已关闭的消息。

示例:收发数据

func main() {
    channel := make(chan int)
    go func() {
        defer fmt.Println("子协程结束")
        fmt.Println("子协程运行中")
        channel <- 666 // 将666发送至管道
    }()

    num := <-channel // 从管道中取数据并赋值给num
    fmt.Println("从管道中接收num=", num)
    fmt.Println("main协程结束")
}

运行结果:

子协程运行中
子协程结束
从管道中接收num= 666
main协程结束 

示例:遍历管道

更多的时候,我们是不明确读取次数的,只是在 Channel 的一端读取数据,有数据就读,直到另一端关闭这个 channel,这时就可以用 for range 这种优雅的方式来读取 channel 中的数据(相比 x, ok := <-channel 的 ok 来判断更优化)

func main() {
    channel := make(chan int, 5)
    channel <- 1
    channel <- 2
    close(channel)
    go func() {
        for i := range channel {
            fmt.Println("i from channel is: ", i)
        }
    }()
    time.Sleep(time.Second * 2)
}

运行结果:

i from channel is:  1
i from channel is:  2
  • 主 goroutine 往 channel 里写了两个数据,然后关闭。子 channel 也只能读取到两个数据。
  • 在主 goroutine 关闭了 channel 之后,子 goroutine 里的 for range 循环才会结束。

无缓冲的 channel

无缓冲的通道(unbuffered channel)是指在接收前没有能力保存任何值的通道。

这种类型的通道要求发送 goroutine 和接收 goroutine 需要同时准备好,才能完成发送和接收操作。否则,通道会导致先执行发送或接收操作的 goroutine 阻塞等待。

这种对通道进行发送和接收的交互行为本身就是同步的。其中任意一个操作都无法离开另一个操作单独存在。

下图的 6 个步骤,展示了两个 goroutine 如何利用无缓冲的通道来共享一个值:

image

  • 在第 1 步,两个 goroutine 都到达通道,但哪个都没有开始执行发送或者接收。
  • 在第 2 步,左侧的 goroutine 将它的手伸进了通道,这模拟了向通道发送数据的行为。这时,这个 goroutine 会在通道中被锁住,直到交换完成。
  • 在第 3 步,右侧的 goroutine 将它的手放入通道,这模拟了从通道里接收数据。这个 goroutine 一样也会在通道中被锁住,直到交换完成。
  • 在第 4 步和第 5 步,进行交换。
  • 在第 6 步,两个 goroutine 都将它们的手从通道里拿出来,这模拟了被锁住的 goroutine 得到释放。两个 goroutine 现在都可以去做别的事情了。

示例:

func main() {
    c := make(chan int, 0) //创建无缓冲的通道 c

    //内置函数 len 返回未被读取的缓冲元素数量,cap 返回缓冲区大小
    fmt.Printf("len(c)=%d, cap(c)=%d\n", len(c), cap(c))

    go func() {
        defer fmt.Println("子协程结束")

        for i := 0; i < 3; i++ {
            c <- i
            fmt.Printf("子协程正在运行[%d]: len(c)=%d, cap(c)=%d\n", i, len(c), cap(c))
        }
    }()

    for i := 0; i < 3; i++ {
        num := <-c // 从c中接收数据,并赋值给num
        fmt.Println("num = ", num)
        time.Sleep(2 * time.Second)
    }

    fmt.Println("main协程结束")
}

运行结果:

len(c)=0, cap(c)=0
子协程正在运行[0]: len(c)=0, cap(c)=0
num =  0
子协程正在运行[1]: len(c)=0, cap(c)=0
num =  1
子协程正在运行[2]: len(c)=0, cap(c)=0
子协程结束
num =  2  
main协程结束

有缓冲的 channel

有缓冲的通道(buffered channel)是一种在被接收前能存储一个或者多个数据值的通道。

这种类型的通道并不强制要求 goroutine 之间必须同时完成发送和接收,通道会阻塞发送和接收动作的条件也不同:

  • 只有当通道中没有要接收的值时,接收动作才会阻塞。

  • 只有当通道中没有空间容纳要发送的值时,发送动作才会阻塞。

这导致有缓冲的通道和无缓冲的通道之间的一个很大的不同:

  • 无缓冲的通道保证进行发送和接收的 goroutine 会在同一时间进行数据交换。
  • 有缓冲的通道没有这种保证,如果给定了一个缓冲区容量,通道就是异步的。

image

  • 第 1 步,右侧的 goroutine 正在从通道接收一个值。
  • 第 2 步,右侧的这个 goroutine 独立完成了接收值的动作,而左侧的 goroutine 正在发送一个新值到通道里。
  • 第 3 步,左侧的 goroutine 还在向通道发送新值,而右侧的 goroutine 正在从通道接收另外一个值。这个步骤里的两个操作既不是同步的,也不会互相阻塞。
  • 第 4 步,所有的发送和接收都完成,而通道里还有几个值,也有一些空间可以存更多的值。
func main() {
    c := make(chan int, 3) //创建有缓冲的通道

    //内置函数 len 返回未被读取的缓冲元素数量,cap 返回缓冲区大小
    fmt.Printf("len(c)=%d, cap(c)=%d\n", len(c), cap(c))

    go func() {
        defer fmt.Println("子协程结束")

        for i := 0; i < 3; i++ {
            c <- i
            fmt.Printf("子协程正在运行[%d]: len(c)=%d, cap(c)=%d\n", i, len(c), cap(c))
        }
    }()

    for i := 0; i < 3; i++ {
        num := <-c // 从c中接收数据,并赋值给num
        fmt.Println("num = ", num)
        time.Sleep(2 * time.Second)
    }

    fmt.Println("main协程结束")

}

运行结果:

len(c)=0, cap(c)=3
子协程正在运行[0]: len(c)=0, cap(c)=3
子协程正在运行[1]: len(c)=1, cap(c)=3
子协程正在运行[2]: len(c)=2, cap(c)=3
子协程结束
num =  0
num =  1
num =  2
main协程结束

双向 channel 和单向 channel

channel 根据其功能又可以分为双向 channel 和单向 channel:

  • 双向 channel 即可发送数据又可接收数据。
  • 单向 channel 要么只能发送数据,要么只能接收数据。

定义与初始化:

// 双向channel
channel := make(chan int, 3)
// send是单向channel,只用于写数据
var send chan<- int = c
send <- 1

// recv是单向channel,只用于读数据
var recv <-chan int = c
<-recv
  • 可以理解为其实只有一个双向管道,只是人为给两端定义了别名,一端只用来发送,一端只用来接收。
  • 可以将 channel 隐式转换为单向队列,只收或只发;但不能将单向 channel 转换为普通 channel 。

示例:

// chan<-  只写
func counter(out chan<- int) {
    defer close(out)
    for i := 0; i < 5; i++ {
        out <- i // 如果对方不读 会阻塞
    }
}

// <-chan  只读
func printer(in <-chan int) {
    for num := range in {
        fmt.Println(num)
    }
}

func main() {
    c := make(chan int) // 无缓冲双向

    go counter(c) // 生产者
    printer(c)    // 消费者

    fmt.Println("done")
}

运行结果:

0
1
2
3
4
done

实现并发锁

上面说了当缓冲队列满了以后,继续往 channel 里面写数据,就会阻塞,那么利用这个特性,我们可以实现一个 goroutine 之间的锁。

package main

import (
   "fmt"
   "time"
)

func add(ch chan bool, num *int) {
   ch <- true
   *num = *num + 1
   <-ch
}

func main() {
   // 创建一个size为1的channel
   ch := make(chan bool, 1)

   var num int
   for i := 0; i < 100; i++ {
      go add(ch, &num)  // 引用传递
   }

   time.Sleep(2)
   fmt.Println("num 的值:", num)
}

运行结果:

num 的值: 100
  • ch <- true 和 <- ch 就相当于一个锁,将 *num = *num + 1 这个操作锁住了。

  • 因为 ch 管道的容量是 1,在每个 add 函数里都会往 channel 放置一个 true,直到执行完 +1 操作之后才将 channel 里的 true 取出。

  • 由于 channel 的 size 是 1,所以当一个 goroutine 在执行 add 函数的时候,其他 goroutine 执行 add 函数,执行到 ch <- true 的时候就会阻塞,*num = *num + 1 不会成功,直到前一个 +1 操作完成,<-ch,读出了管道的元素,这样就实现了并发安全。


协程池

Go语言虽然有着高效的GMP调度模型,理论上支持成千上万的goroutine,但是goroutine过多时,对调度、GC以及系统内存都会造成压力,这样会使我们的服务性能不升反降。

常见做法有池化技术,即构造一个协程池,把进程中的协程控制在一定的数量,防止系统中goroutine过多,影响服务性能。


协程池模型

协程池简单理解就是有一个池子一样的东西,里面装这个固定数量的goroutine,当有一个任务到来的时候,会将这个任务交给池子里的一个空闲的goroutine去处理,如果池子里没有空闲的goroutine了,任务就会阻塞等待。

协程池中有三个角色:

  • Worker:用于执行任务的goroutine
  • Task: 具体的任务
  • Pool: 池子

属性定义

Task

有一个函数成员,表示这个task具体的执行逻辑。

type Task struct {
    f func() error  // 具体的执行逻辑
}

Pool

有两个成员:

  • num表示池子里的worker的数量,即工作的goroutine的数量;
  • JobCh 表示任务队列用于存放任务,goroutine从这个JobCh 获取任务执行任务列逻辑。
type Pool struct {
   WorkerNum int  // goroutine数量
   JobCh chan *Task  // 用于worker取任务
}

worker

worker(执行任务单元)简单理解就是干活的goroutine。
这个worker其实只做一件事情,就是不断的从任务队列里面取任务执行。
而worker的数量就是协程池里协程的数量,由Pool的参数WorkerNum指定。

// p为Pool对象指针
for task := range p.JobCh {
    do ...      
}

方法定义

NewTask 函数

用于创建一个任务,入参是一个函数,返回值是一个Task类型。

func NewTask(funcArg func() error) *Task

NewPool 函数

返回一个协程数量固定为workerNum的协程池对象指针,其任务队列的长度为taskNum。

func NewPool(workerNum int, taskNum int) *Pool

AddTask 方法

往协程池添加任务。

func (p *Pool) AddTask(task *Task) 

Run 方法

将协程池跑起来,即启动指定数量的worker从指定的任务队列里获取任务,执行任务。

func (p *Pool) Run()

协程池实现

package main

import (
   "fmt"
   "time"
)

type Task struct {
   f func() error // 具体的任务逻辑
}

func NewTask(funcArg func() error) *Task {
   return &Task{
      f: funcArg,
   }
}

type Pool struct {
   WorkerNum int // goroutine数量
   JobCh chan *Task // 用于worker取任务
}

func NewPool(workerNum int, taskNum int) *Pool {
   return &Pool{
      WorkerNum: workerNum,
      JobCh: make(chan *Task, taskNum),
   }
}

func (p *Pool) Size() int {
   return p.WorkerNum
}

// worker 这个角色就是协程池里干活的goroutine
func (p *Pool) worker(i int) {
   // worker只做一件事,从管道里取出task,并且执行
   for task := range p.JobCh {
      if err := task.f(); err != nil {
         fmt.Printf("worker %d handle fail: %v\n", i, err)
         return
      }
      fmt.Printf("worker %d handle success\n", i)
   }
}

// AddTask 往协程池里面添加任务
func (p *Pool) AddTask(task *Task) {
   p.JobCh <- task
}

// Run 协程池跑起来,需要指定数量的worker干活
func (p *Pool) Run() {
   // 这里只创建指定数量的worker来干活
   for i := 0; i < p.Size(); i++ {
      go p.worker(i)
   }
}

func main() {
   p := NewPool(3, 10)
   p.Run()
   task := NewTask(func() error {
      fmt.Printf("I am Task\n")
      return nil
   })
   for i := 0; i < 8; i++ {
      p.AddTask(task)
   }
   time.Sleep(3 * time.Second)
}

运行结果:

I am Task
worker 2 handle success
I am Task
worker 2 handle success
I am Task
worker 2 handle success
I am Task
worker 2 handle success
I am Task
worker 2 handle success
I am Task
worker 2 handle success
I am Task
worker 1 handle success
I am Task
worker 0 handle success

程序创建了一个WorkerNum为3,任务队列长度为10的协程池,并往里面添加了8个任务。
可以看到输出,一直只有3个worker在做任务,起到了控制goroutine数量的作用。

posted @ 2023-03-20 23:05  Juno3550  阅读(508)  评论(0编辑  收藏  举报