Treasure / Go协程的思考

Created Sun, 12 May 2019 00:00:00 +0000
2301 Words

一个 os 线程会有一个给固定大小的内存块(一般是 2MB),用来存储当前线程中调用或挂起函数的内部变量,固定大小的栈对于复杂和深层次递归是不够的,而 Goroutine 会以一个很小的栈(2KB)开始其生命周期,这个栈会动态伸缩,最大能到达 1GB(32位系统是 250M)

调度方式

os 线程由操作系统内核调用,每过一定时间(毫秒),硬件计时器会中断处理器,并调用一个名为 scheduler 的内建函数,这个函数会挂起当前执行的线程并保存内存中它的寄存器内存,然后检查线程列表并决定下一次执行哪个线程,并从内存中恢复该线程的寄存器信息,恢复该线程的线程并执行,这就是上下文切换,增加了 CPU 的运行周期。而 Go 的 runtime 包含了自身的调度器,和 os 线程不同是,Goroutine 属于用户级线程由语言支持,调度由语言支持,所有开销会减少很多(相比于内核上下文切换)。

Go的调度器(Scheduler)

  • g 代表一个 goroutine,它包含:表示 goroutine 栈的一些字段,指示当前 goroutine 的状态,指示当前运行到的指令地址,也就是 PC 值。

  • m 表示内核线程,包含正在运行的 goroutine 等字段。

  • p 代表一个虚拟的 Processor,它维护一个处于 Runnable 状态的 g 队列,m 需要获得 p 才能运行 g。

  • 还有一个核心的结构体:sched,它总览全局。

Runtime 起始时会启动一些 G:垃圾回收的 G,执行调度的 G,运行用户代码的 G;并且会创建一个 M 用来开始 G 的运行。随着时间的推移,更多的 G 会被创建出来,更多的 M 也会被创建出来。

  1. 它是运行在用户态的,

  2. 它维护有存储M和G的队列以及调度器的一些状态信息等,并让每个 Goroutine 有机会运行

  3. M 每次取 P 中的队列是没有上下文切换开销的

  • M :代表 os(内核)线程

    OS线程抽象,代表着真正执行计算的资源,在绑定有效的P后,进入schedule循环;而schedule循环的机制大致是从Global队列、P的Local队列以及wait队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M,如此反复。M并不保留G状态,这是G可以跨M调度的基础,M的数量是不定的,由Go Runtime调整,为了防止创建过多OS线程导致系统调度不过来,目前默认最大限制为10000个。

  • P :代表逻辑处理器

    Processor,表示逻辑处理器, 对G来说,P相当于CPU核(伪核,真正的执行体还是M所关联的内核线程),G只有绑定到P(在P的local runq中)才能被调度。对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P的数量决定了系统内最大可并行的G的数量(前提:物理CPU核数 >= P的数量),P的数量由用户设置的GOMAXPROCS决定,但是不论GOMAXPROCS设置为多大,P的数量最大为256。

    1. P 维护了一个 local goroutines 队列

何时触发调度

由于 Go 语言是协作式的调度,不会像线程那样,在时间片用完后,由 CPU 中断任务强行将其调度走。对于 Go 语言中运行时间过长的 goroutine,Go scheduler 有一个后台线程在持续监控,一旦发现 goroutine 运行超过 10 ms,会设置 goroutine 的 “抢占标志位”,之后调度器会处理。

  1. syscall
  2. select-case
  3. I/O(包括网络和文件)
  4. Gosched()函数调用
  5. go func(){…}()
  6. GC时
  7. 同步互斥操作时

Goroutine

Goroutine 可以看作对 thread 加的一层抽象,它更轻量级,可以单独执行。因为有了这层抽象,Gopher 不会直接面对 thread

  1. 创建一个 goroutine 的栈内存消耗为 2 KB,在运行过程中,如果栈空间不够用,会自动进行扩容

  2. G 分为三种状态

    1. Waiting:表示被暂停了,需要等待一些事件发生才能继续,可能是因为 syscall,channel 或者互斥调用。

    2. Runnable:就绪状态,只要给 M 就可以运行

    3. Running:运行状态。goroutine 在 M 上执行指令

  3. 每个Goroutine对应一个G结构体,G存储Goroutine的运行堆栈、状态以及任务函数,可重用。G并非执行体,每个G需要绑定到P才能被调度执行。

  4. 在同一时刻,一个线程上只能跑一个 goroutine。当 goroutine 发生阻塞(例如上篇文章提到的向一个 channel 发送数据,被阻塞)时,runtime 会把当前 goroutine 调度走,让其他 goroutine 有执行的机会

异常捕获

当启动多个 goroutine 时,如果其中一个 goroutine 异常了,并且我们并没有对进行异常处理,那么整个程序都会终止,所以最好每个 goroutine 所运行的函数都做异常处理,异常处理采用 recover

go func(){
    defer func(){
        if err := recover();err != nil{
            //TODO
        }
    }
    //Code...
    panic("exit")
}

注意

  1. recover 只能在 defer 的匿名函数中调用

  2. recover 能捕获panic传入的错误,来保证 goroutine 是否继续执行还是正常退出

如何同步

某些情况是主线程退出,但一部分 goroutine 还未执行完毕

  • 通过 sync.WaitGroup 来保证所有 goroutine 执行完成

  • 通过 channnel 来保证所有 goroutine 执行完成

GC

因为 GC 操作是使用自己的一组 Goroutine 来执行的,这些 Goroutine 需要一个 M 来运行。所以 GC 会导致调度混乱。

但是,因为调度器是知道 Goroutine 要做什么的,所以它可以做出明智的决策。其中一个明智的决策是,在 GC 过程中,暂停那些需要访问堆空间的 GoroutineStop The World),运行那些不需要访问堆空间的。

思考

大部分goroutine使用都是在网络层,这部分goroutine 我称为 i/o 协程,但对于高并发而言,gorotuine 也会导致内存过高,

而关于goroutine的调度问题,除了上述所说,网络底层是通过 i/o multiplex 事件来触发调度的,虽然 1.16 之后支持了抢占式调度,但错误的使用并不会提高性能,反而会降低.

我们通过一组数据来证明它

1 thread epoll

    Test Duration 10.1192694s:

    1000 connections,fail: 0

    Delay:    Avg        Max        Stdev
        23.074671ms 226.0378ms  23.074671ms

    Request/Sec: 17.60K/s
    Written/Sec: 17.18M/s
    Receive/Sec: 17.18M/s
    TotalWritten: 173.89M
    TotalReceive: 173.89M

4 thread epoll

    Test Duration 10.1532731s:

    1000 connections,fail: 0

    Delay:    Avg        Max        Stdev
        14.70811ms 295.5518ms 14.30601ms

    Request/Sec: 17.16K/s
    Written/Sec: 16.76M/s
    Receive/Sec: 16.76M/s
    TotalWritten: 170.12M
    TotalReceive: 170.12M

standard go

    Test Duration 10.1377697s:

    1000 connections,fail: 0

    Delay:    Avg        Max        Stdev
        14.855782ms 276.5472ms14.855782ms

    Request/Sec: 17.22K/s
    Written/Sec: 16.82M/s
    Receive/Sec: 16.82M/s
    TotalWritten: 170.48M
    TotalReceive: 170.48M

从吞吐量可以看出,单 epoll 略高于其他方式,但综合数据同步以及内存使用来看,显然单线程 epoll 更适合

总结

goroutine 虽然减少了心智负担,但它牺牲了一些性能,所以我个人认为,goroutine更适合成为一个库,而非语言标准。