1.协程数过多的问题

1.1 程序崩溃

Go 协程(goroutine)是由 Go 运行时管理的轻量级线程。通过它我们可以轻松实现并发编程。但是当我们无限开辟协程时,将会遇到致命的问题。

func main() {
    var wg sync.WaitGroup
    for i := 0; i < math.MaxInt32; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            fmt.Println(i)
            time.Sleep(time.Second)
        }(i)
    }
    wg.Wait()
}

这个例子实现了 math.MaxInt32 个协程的并发,2^31 - 1 约为 20 亿个,每个协程内部几乎没有做什么事情。正常的情况下呢,这个程序会乱序输出 0 ~ 2^31-1 个数字。

程序会像预期的那样顺利的运行吗?

go run main.go
...
108668
1142025
panic: too many concurrent operations on a single file or socket (max 1048575)

goroutine 1158408 [running]:
internal/poll.(*fdMutex).rwlock(0xc0000ae060, 0x0)
        /usr/local/go/src/internal/poll/fd_mutex.go:147 +0x11b
internal/poll.(*FD).writeLock(...)
        /usr/local/go/src/internal/poll/fd_mutex.go:239
internal/poll.(*FD).Write(0xc0000ae060, {0xc12cadf690, 0x8, 0x8})
        /usr/local/go/src/internal/poll/fd_unix.go:262 +0x72
os.(*File).write(...)
        /usr/local/go/src/os/file_posix.go:49
os.(*File).Write(0xc0000ac008, {0xc12cadf690, 0x1, 0xc12ea62f50})
        /usr/local/go/src/os/file.go:176 +0x65
fmt.Fprintln({0x10c00e0, 0xc0000ac008}, {0xc12ea62f90, 0x1, 0x1})
        /usr/local/go/src/fmt/print.go:265 +0x75
fmt.Println(...)
        /usr/local/go/src/fmt/print.go:274
main.main.func1(0x0)
        /Users/dablelv/work/code/test/main.go:16 +0x8f
...

运行的结果是程序直接崩溃了,关键的报错信息是:

panic: too many concurrent operations on a single file or socket (max 1048575)

对单个 file/socket 的并发操作个数超过了系统上限,这个报错是 fmt.Printf 函数引起的,fmt.Printf 将格式化后的字符串打印到屏幕,即标准输出。在 Linux 系统中,标准输出也可以视为文件,内核(Kernel)利用文件描述符(File Descriptor)来访问文件,标准输出的文件描述符为 1,错误输出文件描述符为 2,标准输入的文件描述符为 0。

简而言之,系统的资源被耗尽了。

那如果我们将 fmt.Printf 这行代码去掉呢?那程序很可能会因为内存不足而崩溃。这一点更好理解,每个协程至少需要消耗 2KB 的空间,那么假设计算机的内存是 4GB,那么至多允许 4GB/2KB = 1M 个协程同时存在。那如果协程中还存在着其他需要分配内存的操作,那么允许并发执行的协程将会数量级地减少。

1.2 协程的代价

前面的例子过于极端,一般情况下程序也不会无限开辟协程,旨在说明协程数量是有限制的,不能无限开辟。

如果我们开辟很多协程,但不会导致程序崩溃,可以吗?如果真要这么做的话,我们应该清楚地知道,协程虽然轻量,但仍有开销。

Go 协程的开销主要是三个方面:创建(占用内存)、调度(增加调度器负担)和删除(增加 GC 压力)。

  • 内存开销

空间上,一个Go 协程占用约 2K 的内存,在源码 src/runtime/runtime2.go里面,我们可以找到Go 协程的结构定义type g struct。

  • 调度开销

时间上,协程调度也会有 CPU 开销。我们可以利用runntime.Gosched()让当前协程主动让出 CPU 去执行另外一个协程,下面看一下协程之间切换的耗时。

const NUM = 10000

func cal() {
    for i := 0; i < NUM; i++ {
        runtime.Gosched()
    }
}

func main() {
    // 只设置一个 Processor
    runtime.GOMAXPROCS(1)
    start := time.Now().UnixNano()
    go cal()
    for i := 0; i < NUM; i++ {
        runtime.Gosched()
    }
    end := time.Now().UnixNano()
    fmt.Printf("total %vns per %vns", end-start, (end-start)/NUM)
}

运行输出:

total 997200ns per 99ns

可见一次协程的切换,耗时大概在 100ns,相对于线程的微秒级耗时切换,性能表现非常优秀,但是仍有开销。

  • GC 开销

创建Go 协程到运行结束,占用的内存资源是需要由 GC 来回收,如果无休止地创建大量Go 协程后,势必会造成对 GC 的压力。

package main

import (
    "fmt"
    "runtime"
    "runtime/debug"
    "sync"
    "time"
)

func createLargeNumGoroutine(num int, wg *sync.WaitGroup) {
    wg.Add(num)
    for i := 0; i < num; i++ {
        go func() {
            defer wg.Done()
        }()
    }
}

func main() {
    // 只设置一个 Processor 保证Go 协程串行执行
    runtime.GOMAXPROCS(1)
    // 关闭GC改为手动执行
    debug.SetGCPercent(-1)

    var wg sync.WaitGroup
    createLargeNumGoroutine(1000, &wg)
    wg.Wait()
    t := time.Now()
    runtime.GC() // 手动GC
    cost := time.Since(t)
    fmt.Printf("GC cost %v when goroutine num is %v\n", cost, 1000)

    createLargeNumGoroutine(10000, &wg)
    wg.Wait()
    t = time.Now()
    runtime.GC() // 手动GC
    cost = time.Since(t)
    fmt.Printf("GC cost %v when goroutine num is %v\n", cost, 10000)

    createLargeNumGoroutine(100000, &wg)
    wg.Wait()
    t = time.Now()
    runtime.GC() // 手动GC
    cost = time.Since(t)
    fmt.Printf("GC cost %v when goroutine num is %v\n", cost, 100000)
}

运行输出:

GC cost 0s when goroutine num is 1000
GC cost 2.0027ms when goroutine num is 10000
GC cost 30.9523ms when goroutine num is 100000

当创建的Go 协程数量越多,GC 耗时越大。

上面的分析目的是为了尽可能地量化 Goroutine 的开销。虽然官方宣称用 Golang 写并发程序的时候随便起个成千上万的 Goroutine 毫无压力,但当我们起十万、百万甚至千万个 Goroutine 呢?Goroutine 轻量的开销将被放大。

2.2 限制协程数量

系统地资源是有限,协程是有代价的,为了保护程序,提高性能,我们应主动限制并发的协程数量。

可以利用信道 channel 的缓冲区大小来实现。

func main() {
    var wg sync.WaitGroup
    ch := make(chan struct{}, 3)
    for i := 0; i < 10; i++ {
        ch <- struct{}{}
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            log.Println(i)
            time.Sleep(time.Second)
            <-ch
        }(i)
    }
    wg.Wait()
}

上例中创建了缓冲区大小为 3 的 channel,在没有被接收的情况下,至多发送 3 个消息则被阻塞。开启协程前,调用ch <- struct{}{},若缓存区满,则阻塞。协程任务结束,调用 <-ch 释放缓冲区。

sync.WaitGroup 并不是必须的,例如 Http 服务,每个请求天然是并发的,此时使用 channel 控制并发处理的任务数量,就不需要 sync.WaitGroup。

运行结果如下:

2022/03/06 20:37:02 0
2022/03/06 20:37:02 2
2022/03/06 20:37:02 1
2022/03/06 20:37:03 3
2022/03/06 20:37:03 4
2022/03/06 20:37:03 5
2022/03/06 20:37:04 6
2022/03/06 20:37:04 7
2022/03/06 20:37:04 8
2022/03/06 20:37:05 9

从日志中可以很容易看到,每秒钟只并发执行了 3 个任务,达到了协程并发控制的目的。

3.协程池化

上面的例子只是简单地限制了协程开辟的数量。在此基础之上,基于对象复用的思想,我们可以重复利用已开辟的协程,避免协程的重复创建销毁,达到池化的效果。

协程池化,我们可以自己写一个协程池,但不推荐这么做。因为已经有成熟的开源库可供使用,无需再重复造轮子。目前有很多第三方库实现了协程池,可以很方便地用来控制协程的并发数量,比较受欢迎的有:

下面以 panjf2000/ants 为例,简单介绍其使用。

ants 是一个简单易用的高性能 Goroutine 池,实现了对大规模 Goroutine 的调度管理和复用,允许使用者在开发并发程序的时候限制 Goroutine 数量,复用协程,达到更高效执行任务的效果。

package main

import (
    "fmt"
    "time"

    "github.com/panjf2000/ants"
)

func main() {
    // Use the common pool
    for i := 0; i < 10; i++ {
        i := i
        ants.Submit(func() {
            fmt.Println(i)
        })
    }
    time.Sleep(time.Second)
}

使用 ants,我们简单地使用其默认的协程池,直接将任务提交并发执行。默认协程池的缺省容量 math.MaxInt32。

如果自定义协程池容量大小,可以调用 NewPool 方法来实例化具有给定容量的池,如下所示:

// Set 10000 the size of goroutine pool
p, _ := ants.NewPool(10000)

4.小结

Golang 为并发而生。Goroutine 是由 Go 运行时管理的轻量级线程,通过它我们可以轻松实现并发编程。Go 虽然轻量,但天下没有免费的午餐,无休止地开辟大量Go 协程势必会带来性能影响,甚至程序崩溃。所以,我们应尽可能的控制协程数量,如果有需要,请复用它。

powered by Gitbook该文章修订时间: 2024-03-22 15:30:00

results matching ""

    No results matching ""