写点什么

Openresty 协程调度对比 Go 协程调度

用户头像
行如风
关注
发布于: 2021 年 01 月 07 日

在 web 编程领域,Openresty 与 Go 均有十分优秀的处理能力,在面对高并发的 web 编程,两者一般都是首选的技术方案。这两者我也一直使用,而且两者均有协程,现总结下,留个备忘。


Openresty 及其工作流程


基于 Openresty 1.18 版本


将 Lua 集成到 Nginx 中,而 Nginx,更是高性能 HTTP 服务器的代表。


Nginx 是多进程单线程:一个 master 进程和多个 worker 进程,处理请求的是 worker 进程。


启动流程


Openresty 是在 master 进程创建时通过ngx_http_lua_init_vm函数初始化 lua vm,在 fork 出 work 进程时,lua vm 便集成到 work 进程,每个 work 进程均有一个 lua vm。


worker 启动起来后,worker 进程便开始循环处理请求,当有新的请求到来时,只有申请到ngx_accept_mutex的 worker 才会处理它(注册 listen fd 到自己的 epoll 中),避免惊群。


Nginx 高性能的原因是其异步非阻塞的事件处理机制,即 select/poll/epoll/kqueue 这样的系统调用。


协程调度


假如有这个配置:


配置项为:location ~ ^/api {    content_by_lua_file test.lua;}
复制代码


而对于每个请求,如请求为:request=/api?age=20


Openresty 都会创建一个协程来处理


而这个创建的协程是系统协程,是主协程,用户无法控制它。 而用户通过 ngx.thread.spawn 创建的协程是通过ngx_http_lua_coroutine_create_helper创建出来的,用户创建的协程是主协程的子协程。并通过ngx_http_lua_co_ctx_s保存协程的相关信息。


协程通过ngx_http_lua_run_thread函数来运行与调度。当前待执行的协程为ngx_http_lua_ctx_t->cur_co_ctx


对于每个 worker 进程来说,每个用户请求都会创建一个协程,每个协程都是互相隔离的,而且用户还会创建用户协程,这些协程最终交与当前 worker 进程中的 lua vm 进行执行。在同一个时间点,只能有一个协程来执行,这些协程该怎么调度呢?


其实这些协程是基于事件的(利用 nginx 的事件机制)协作式的调度:


1.对于系统创建的协程来说,当系统事件未触发,对应的就是 IO 事件未准备好(ET 模式,epoll_wait 返回的活跃 fd 一直读或写直到返回 EAGIAN)时,当前执行的协程就会让出 cpu,让别的协程进行执行;


2.对于用户创建的协程来说,除了上面提到的 1 外,如果用户代码执行了让出,也会进行让出操作。


GO 及其工作流程


基于 go 1.15 版本


Go 是单进程,多线程,多协程。


启动流程


对于下面这个简单的 go 程序:


package mainimport "fmt"
func main() { fmt.Println("Hello world!")}
复制代码


我们可以通过 gdb 跟踪到启动流程。


Go 程序启动后,在执行用户的 main 函数前,启动顺序为: runtime.args->runtime.osinit->runtime.schedinit->runtime.newproc->runtime.mstart


其中:


runtime.args:初始化argc,argv;遍历auxv,即辅助向量(auxiliary vector)来初始化一些系统运行变量:如内存页大小(physPageSize),startupRandomData(初始化随机数种子时会用到),cpuid信息等
runtime.osinit:设置cpu核数(ncpu)和huge page大页内存大小(physHugePageSize)
runtime.schedinit: 初始化栈、内存分配器、随机数种子; 初始化m0并放入allm中;gc初始化; 对所有p初始化,对allp[0]和m0进行绑定
runtime.newproc: 这个函数就是我们在go语言中使用go func()来创建协程时,go会调用它来实际创建goroutine. 会优先在本地p中获取空闲g(Gdead状态), 如果本地p中没有,会获取全局空闲的g(schedt.gFree), 仍没有就会在堆上创建一个初始栈为2k大小的g, 初始化时是后者,直接在堆上创建一个g用来执行runtime.main.
runtime.mstart: 启动m,并进行g的调度(循环调度)
复制代码


上面介绍了每步函数的解释,细节要复杂的多,上面函数中介绍了 m0 和所有 p 的初始化,至于 g0,其实会在入口用汇编在栈上初始化的。启动时其栈大小约为 64k(65432 字节)。m0 和 g0 的相互引用也用是在这时确立的,至此,m0,g0,allp[0]的关系确立。


调度模型


总览:



其中:


G:g 结构体对象,代表 Goroutine。每个 G 代表一个待执行任务。


M:m 结构体对象,代表工作线程(每个工作线程都有一个 m 与之对应)


P:p 结构体对象,代表处理器(Processor)。


程序启动后会创建跟 cpu 核数相等的 P(也可以自己更改,一般不修改)。每个 P 都持有一个待运行 G 的环形队列(即本地运行队列)。


M-P-G 调度是在用户态完成。其中 M 和 G 的关系是多对多(M:N)。即 M 个线程负责对 N 个 G 进行调度,内核对 M 个线程进行调度。


goroutine 调度


循环调度,抢占调度(go 1.14 开始实现了基于信号的抢占式调度)。


schedule()

->execute()->gogo()->g.sched.pc()->goexit()->goexit1->goexit0()->schedule()


其中:


1.schedule()函数主要为了找寻一个可执行的 g:


1.每经过 61 轮调度则从全局运行队列中获取 g 进行执行 2.在本地运行队列中获取 g 进行执行 3.如果上面两步都没有找到则会一直找(阻塞),直到找到一个可执行的 g. 这个阶段会在尝试本地运行队列、全局运行队列、netpoll、窃取其他 p 的运行队列找到一个可执行的 g


2.execute()函数主要设置当前线程的 curg,关联当前待执行的 g 的 m 为当前线程,更改 g 的状态从_Grunnable 为_Grunning


3.gogo()函数是汇编语言编写:


切换到当前的 g(切换栈 g0->g,恢复 g.sched 结构体中保存的寄存器的值到 cpu 寄存器中) 让 cpu 真正执行当前 g(执行入口函数为 g.sched.pc,即 pc 寄存器,下一条指令待执行的入口地址)。


4.g.sched.pc(),对于我们这个程序,就是 main goroutine,入口函数为runtime.main


1.启动一个线程执行sysmon函数,负责整个程序的 netpoll 监控,gc,抢占调度(对陷入阻塞系统调用的 g 释放 p,对长时间运行(>10ms)的 g 进行抢占)等。该线程独立运行(无需 p,循环运行) 2.runtime包初始化 3.启动gc 4.main包初始化 import 的包也会在这个阶段初始化


5.执行main.main函数(我们定义的 main 函数)


6.从 main.main 函数返回后,执行系统调用退出进程


主 goroutine 结束后,我们的程序就结束了,这也就是为啥我们在 main 函数中启动了一个 goroutine,如果没有做 chan 对协程进行数据接收,没看到协程执行结果的原因。


对于非 main goroutine,执行完 fn(即 g.sched.pc)后:


goexit 函数:执行 runtime.goexit1 函数


goexit1 函数:mcall 切换到 g0 栈执行 runtime.goexit0 函数


goexit0 函数:g 放入 gFree 队列重用,进行下一轮循环调度。


网络 IO


同样实现了 epoll/kqueue 等系统调用,底层使用了汇编实现。如 epoll 相关函数:



我们用 netpoller 来称呼它,它把 goroutine 和 io 多路复用结合起来。 通过netpoll()就可以获取 fd 活跃的 goroutine 列表。


一些 go 的冷知识:


1.m0 是做什么的?m0 和别的 m 有什么区别?


1.如字面所见,m0 是第一个被创建的线程。 2.m0 的作用跟别的 m 一样,都是系统线程,cpu 分配时间片执行任务的线程。 3.m 上限是 10000 个,g 只有和 m 绑定后才能真正执行。


2.g0 到底是做什么的,g0 和别的 g 有什么区别,g0 是否也会被调度?


1.如字面所见,g0 是第一个被创建的 g,但它不是普通的 g,并不会被调度. 2.g0 的作用就是提供一个栈供 runtime 代码执行,典型的就是mcall()systemstack()这两个函数,都是切换到 g0 栈执行函数,不同的是:前者只能由非 g0 发起切换到 g0 栈执行函数,并且不会跳转回来;而后者可以在 g 或 g0 发起切换。如果当前已经在 g0 栈则直接执行,否则会切换到 g0 栈执行函数,在函数执行完后切回到现在正在执行的代码继续执行后续代码。 3.每个 m 都有一个 g0。 4.g0 跟别的 g 不一样。首先初始化的栈大小不一样,普通 g 初始化栈 2k 大小,g0 初始化栈大小有两种情况:将近 64k 大小和 8k 大小。在新的 m 建立的时候,非 cgo 情况下对应的 g0 会分配 8k 大小的栈。 5.栈的位置不同。普通的 g 会在堆上分配栈空间,而 g0 会在系统栈上分配。


4.对于 go 程序,启动后会创建多少个线程?


各个平台不一样;对于 windows 平台,会在 osinit 阶段就提前创建好一些线程,对于 linux 平台,在执行到 runtime.main 前,只有一个线程,后面创建线程场景: 1.在创建 goroutine 时会根据需要创建线程。 2.runtime 阶段创建线程,如启动 sysmon 系统监控线程,cgo 调用启动线程 startTemplateThread 等。 3.cgo 执行时,多个 cgo 同时执行,每个都会需要一个线程。 4.在调度 go 协程时,p 找不到需要空闲的 m 进行执行时。典型场景如 web 开发中,goroutine 执行了阻塞的 syscall 调用,还有新到的 go 协程需要处理时。 最多创建 10000 个


5.p,m,g 在程序运行过程中会改变吗?


p 确定后就不会改变了,为 cpu 核心数量(除非人为调整),存入 allp 中。 g 和 m 会增加,但不会减少。g 无上限,跟内存有关。空闲的 g 会放入 gFree 里(p 的 gFree 为本地空闲队列;schedt 的 gFree 为全局空闲队列); m 最多 10000 个,存入 allm 中。


6.在做高性能 web 开发时,需要协程池吗?


在面对高并发时,如果不限制 g 的数量,每个请求一个 g 的话,则本地 p 满了后(每个 p 中存 256 个),就会放入全局队列中,大量的 g 会增加 gc 扫描压力,同时会占用大量内存,大量全局 p 会有锁访问。 所以有必要限制 g 的数量。而我们其实无法对 goroutine 进行控制的,而 go 调度器会自己复用 gFree 里的 goroutine。 所以协程池更准确的叫法应该是消费池(请求如同生产,我们处理请求如同消费)。 所以我们要做的就是 1.尽可能的减少堆内存分配及对内存复用(pool), 2.避免阻塞系统调用, 3.优化下游及算法响应时间, 4.做好限流,限制 g 的数量, 如果做了上面这些后,仍然都是有效访问,且压力很大,那就增加机器吧。


对比


1.Openresty 启动后,每个 cpu 核心绑定一个进程,而对于 Go 来说,每个工作线程对应一个 cpu 核心,有异曲同工之妙。


2.可以看出,go 的调度模型要复杂的多。


Openresty 是基于 nginx 事件的协作式调度


Go 实现了一套高效的 P-M-G 调度(基于信号的抢占式调度(1.14 开始))


3.对于网络成面,底层都使用多路 IO 复用提升 web 性能。


4.在作为高性能 web 服务器时,都应避免阻塞的系统调用: 如果涉及到耗时很长的阻塞系统调用, 对于 Openresty 来说,当前协程一直占用 cpu,导致进程直接被阻塞,导致处理性能大幅下降;


对于 Go 来说,当前 goroutine 陷入阻塞系统调用后,虽然 p 会被释,但是工作线程同样会陷入,对于别的要处理的 goroutine,发现没有空闲工作线程,就会持续创建工作线程,大量的线程会大幅增加上下文切换,导致性能下降。


发布于: 2021 年 01 月 07 日阅读数: 25
用户头像

行如风

关注

知之为知之,不知为不知 2018.08.07 加入

只要有树叶飞舞的地方,火就会燃烧,火的影子会照耀着村子,树叶还会重新发芽。 Blog:https://www.imflybird.cn/

评论

发布
暂无评论
Openresty协程调度对比Go协程调度