Go协程池(1): 线程vs协程
2023-6-12 08:53:35 Author: Go语言中文网(查看原文) 阅读量:6 收藏

众所周知,Goroutine(也叫协程)运行在用户态,由Go runtime管理。而操作系统线程同时处于用户态和内核态。两者的差别体现在四个方面:

  1. 数据结构

  2. 创建时的内存占用

  3. 运行时状态

  4. 上下文切换

数据结构

对于每个线程,内核中都维护一个task_struct对象,它还有一个名字叫thread control block,简称TCB。这个结构中记录了线程当前的状态、执行的上下文和调度信息。TCB由内核管理,用户态的代码无法直接访问。TCB中的关键字段有:

  • 进程ID (pid): 当前线程从属的进程

  • 线程ID (tid): 线程的唯一ID, 内核通过这个ID识别和管理线程

  • 程序计数器 (program counter, pc):  指向下一个要执行指令的寄存器

  • 栈指针(stack pointer, sp): 

    指向线程栈顶部的寄存器

  • 寄存器集(register set): 一组用来执行代码的CPU寄存器

  • 调度信息: 线程的优先级、调度策略、时间片等

  • 内存管理: 线程的内存使用情况

  • 等等

在Linux内核中, 线程和进程共用 task_struct 结构,所以该结构中存在大量涉及到进程状态的字段,这里不展开说。

对于协程而言,Go Runtime 在用户态维护一个struct g对象,关键字段有:

  • stack: 通过两个字段 lo 和 hi 分别记录栈的底部和顶部的地址

  • stacksize: 栈大小, 默认是2KB

  • goid: 协程的唯一ID,runtime用于管理该协程

  • status: 协程的状态, 可以是 _Gwaiting, _Grunning, _Gdead, _Gsyscall(系统调用), _Gscan(GC扫描), _Gpreempted 等

  • sched: 调度状态, sp, pc等寄存器的状态都存储在这个结构里

  • gopc: 创建当前goroutine对应的go语句的pc

  • 等等

创建时的内存占用

在x86_64架构下,每一个活跃的线程都有一个内核栈,大小是 THREAD_SIZE=16KB, 计算公式是:

// 位置: arch/x86/include/asm/page_types.h
/* PAGE_SHIFT determines the page size */
#define PAGE_SHIFT 12
#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT) // => 4096

// 位置: arch/x86/include/asm/page_64_types.h
#define THREAD_SIZE_ORDER 2 (或3)
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER) // => 16384

struct thread_struct 本身也会占用一些内存,在调用 do_fork() 时,内核会给新线程的 task_struct 分配内存,并初始化内核栈。

对于协程而言,stacksize = 2048,分配stacksize=2KB的栈空间,相关的代码如下:

// 位置: src/runtime/stack.go
const (
// The minimum size of stack used by Go code
_StackMin = 2048

创建协程时,g本身也会占用内存空间,不过这部分内存不会动态增长。下面这段代码给出了分配内存的逻辑:

// 位置: runtime/proc.go
// 创建一个新的g,状态是 _Grunnable
// callerpc是对应的 `go func`语句的地址
// 调用方负责把新的g添加到调度器
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
_g_ := getg()
// ...省略部分代码
acquirem() // disable preemption because it can be holding p in a local var

_p_ := _g_.m.p.ptr()
newg := gfget(_p_)
if newg == nil {
newg = malg(_StackMin)
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
}

// 分配一个新的g,并分配栈内存
func malg(stacksize int32) *g {
newg := new(g)
if stacksize >= 0 {
stacksize = round2(_StackSystem + stacksize)
systemstack(func() {
newg.stack = stackalloc(uint32(stacksize))
})
newg.stackguard0 = newg.stack.lo + _StackGuard
newg.stackguard1 = ^uintptr(0)
// Clear the bottom word of the stack. We record g
// there on gsignal stack during VDSO on ARM and ARM64.
*(*uintptr)(unsafe.Pointer(newg.stack.lo)) = 0
}
return newg
}

运行时状态

线程的运行时依赖很多寄存器, 包括:

  • 程序计数器(Program Counter)

  • 通用寄存器,可以有16个,包含栈指针(Stack Pointer)

  • Segment registers, 存储代码片段、数据片段等

  • Index and Pointers, 用于字符串、字节数组的拷贝、存储栈的地址、下一个指令的地址等

  • Indicator, 存储处理器的状态信息

如需查看所有寄存器,可Google搜索"x86 Registers"。

协程运行时只依赖三个寄存器:

  • 程序计数器(program counter, pc)

  • 栈指针(stack pointer, sp), 存储栈内存的高位地址

  • DX (data register) 用于乘除运算,是通用寄存器的一种

上下文切换

线程在执行时通常处于用户态,如果需要CPU在更高的权限级别执行,则需要通过系统调用陷入内核态执行。内核态能访问的资源有:

  • 硬件设备: 常见的有驱动程序

  • 操作系统内存: 需要内核完成一些功能,比如线程调度

进行上下文切换时,需要:

  • 保存当前线程的寄存器,恢复新线程的寄存器,涉及到内存和CPU cache之间的数据拷贝;

  • 内核调度器存储了线程的状态信息,这部分需要更新

  • 切换线程栈

上下文切换通常消耗数百到数千个CPU周期,线程数比较多或系统负载比较高时,耗费时间更长。线程中常见的CPU计算、内存访问均可以在用户态下完成,而线程的调度在内核态由linux内核完成。所以线程切换意味着,线程必须首先从用户态进入内核态。

协程进行上下文切换时,流程如下:

1. 保存当前的CPU上下文: 其实就是上面提到的三个寄存器程序计数器、栈指针和data register

2. 切换到调度器,准备选择新的goroutine去执行

3. 调度器选择新的goroutine,并恢复其状态,然后触发执行

可以看到,协程的上下文切换中,只需要处理这几个寄存器,更新调度器状态,全部在用户态下完成。

总的来说,由于协程比线程更为轻量级,操作系统下可以支持数量有限的线程,但可以轻松支持上万个甚至上百万个协程。在GMP模型下,M (machine) 在逻辑上是线程,Go推荐M的数量与CPU的核心数保持一致,即GOMAXPROCS.

最后,我们简单讨论下协程池的问题: 协程是如此的轻量级,是否还需要协程池呢?

首先,协程的创建、销毁和上下文切换也是有代价的。如果这个代价在整个CPU的时间片中使用占比过高,仍然有创建协程池的必要。IO密集型的服务正好符合这个条件,比如高并发的Web服务器。相反,CPU密集型的服务,大部分CPU时间花在了计算逻辑上,就不需要协程池。


推荐阅读

福利
我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。


文章来源: http://mp.weixin.qq.com/s?__biz=MzAxMTA4Njc0OQ==&mid=2651454445&idx=1&sn=181033e7b1c87b4a176287df53088a1d&chksm=80bb251fb7ccac099f1206b876866fcfcfe3e7e676bbfe03b43460950dcef8eac4b9929f0b56#rd
如有侵权请联系:admin#unsafe.sh