众所周知,Goroutine(也叫协程)运行在用户态,由Go runtime管理。而操作系统线程同时处于用户态和内核态。两者的差别体现在四个方面:
数据结构
创建时的内存占用
运行时状态
上下文切换
对于每个线程,内核中都维护一个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时间花在了计算逻辑上,就不需要协程池。
推荐阅读