前端性能监控(RUM)接入层服务高并发优化实践(二)——并发模型原理

2022-08-26 18:12:45 浏览数 (2)

张翔

腾讯高级前端开发工程师、腾讯云前端性能监控(RUM)核心开发。主要负责前端性能监控系统中的上报服务层模块的设计与实现。

前言

往期我们介绍了 前端性能监控 (RUM) 接入层服务高并发优化实践 ,我们针对缓存模型进行重新设计与优化,相信不少用户也感受到了 RUM 前所未有的流畅。

腾讯云前端性能监控(RUM) 系统中,接入层服务时刻承受着平均上百万 QPS 的上报请求,所以对于服务端的性能要求是极其高。接着往期 前端性能监控 (RUM) 接入层服务高并发优化实践 说的缓存模型,本次我们将带为您潜入 GO 内部了解其原理和思路,并介绍接入层使用 GO 到底有哪些优势?

RUM 的 Go 接入层正在逐步灰度中,目前国际地区资源接入已使用 Go 接入层进行数据接收与清洗。目前压测结果来说,Go 接入层在完全没有优化的情况下,QPS 表现已经是 Node 的 3 倍。在优化后经压力测试,8 核 16G 的机器面对大小为15K 数据的请求,QPS 能达到11w 次。

看到这里,不少人会疑惑不解,为什么 Go 会比 Node 的 QPS 表现好那么多呢?Go 的性能如此惊人?小编也是充满好奇,接下来小编带您进入 GO 并发原理的探索之旅。

Node 模型

Node 的并发是建立在单进程上的,涉及到网络 I/O 或者其他接口的事情,一般都是通过异步事件驱动的。但是单进程内只有一个主执行栈,因为如果有密集的 CPU 运算,会影响服务的性能。

那 Go 是怎么做的呢?

说到 Go 的并发原理,从操作系统的演进开始讲起,会更好地理解并吸收我们的优化思路。最重要的并非原理本身哦!

操作系统的演进

最开始的操作系统是单进程的,任务只能串行地一个一个执行,这样执行效率会不大理想,因为很可能前一个任务会直接阻塞掉 CPU 继续执行,造成 CPU 的浪费。

因此为了提高程序的运行效率与 CPU 的利用率,操作系统就有进入了多进程(并发)时代:

这样多进程的模式,将 CPU 换算成一个个时间片的使用时长,假设出现任务 A 的阻塞,那么就会切换到其他进程去执行,这样就能解决单进程中的阻塞问题。这里很自然地会引来另一个问题:谁来切换或者说来调度这些进程呢?因此 CPU 会有一个调度器来调度这些任务:

但是这样的模式还是存在问题的,如果存在过多的任务,CPU 的工作都在调度任务上了,这样执行任务的时间就会相对应变少或者说 CPU 没有充分地利用在执行任务上,此外多进程中,进程间内存不共享,并且加上进程本身地址空间等,调度的时候切换进程的成本比较大。

那怎么降低切换的成本和提高调度的效率呢?

可能大家第一时间想起一个工具,那就是轻量级进程(也叫线程),线程比进程在内存占用上会优化很多,进程会占用几 G 的内存,但是线程只需要几 M 的内存,并且线程间因为共享地址空间所以能够共享可用数据。

但是实际 CPU 在调度线程和进程中,基本是类似的,只是线程的开销相对较小,如果我们在写服务时,一个请求创建一个线程,在高并发下对于机器的压力也并不小,所以线程也不是一个最理想的工具。

这让疑惑不解,我们究竟应该怎么继续优化呢?

优化调度

在优化前,我们需要想清楚这里的矛盾点在什么地方?在现有技术发展下,越来越多的任务需要运行在服务器上,但是如果按照原来 CPU 的调度会出现两个问题:

1. 多个任务使用多个线程/进程来执行会占用过多的内存,因为线程/进程本身需要内存进行记录信息。

2. 多个线程和进程的执行需要 CPU 切换上下文并进行调度,切换上下文意味着复制,两个操作都很耗费 CPU,这部分工作量并没有用在执行任务上,且随着线程越多,这部分工作量也越多。

因此很容易得出两个优化方向,一个是减少上下文切换让更多的 CPU 时间用在执行任务上,一个是减少多线程/进程记录信息所需要的内存空间。

我们需要知道所谓上下文切换,其实就是 CPU 将上一个进程运行所需要的寄存器的信息和正在执行的指令位置或下一条指令位置的计数器保存起来,加载当前任务的寄存器信息和计数器。

而上下文切换是发生在内核态的,而我们所写的代码基本都是用户态代码,用户态和内核态的切换成本同样很大。

那有没有一种可能呢?将任务调度放在用户态上,一来切换成本没有内核态和用户态的切换成本大,二来用户态的代码所需要记录的信息比进程甚至是线程都更小。经过我们反复研究,能准确地告诉你是可以的!因此现在需要一个用户态的线程。但是代码中仍需要系统调用等内核态的功能,有没有两全其美的办法解决调度问题呢?

调度器模型

可以使用轻量级进程(LWP, Light-Weight Process)。属于内核线程的高度抽象,与一个内核线程绑定,借助这种方式可以使用内核态的能力,但应该怎么将用户态线程和内核态的线程进行结合呢?

  • 假设是 1:1 的结合方式结合:

可以利用多核,并且可以并行执行,但是用户态线程的创建和销毁就需要内核态关心,也就是直接让 CPU 来完成,这样成本很大并且无法创建大量的用户态线程,与我们初衷不符合。

  • 假设是 N:1 的结合方式,即多个用户态线程绑定在一个 LWP 上:

这样就满足了我们能够创建多个用户态线程,并且内核即 CPU 无需关心用户线程的创建和销毁,内核对用户态代码无感知,但是会有一个缺点就是无法利用 CPU 的多核能力,因为多个用户线程创建在一个 LWP 即绑定在一个内核线程上,假设有一个用户线程阻塞住 LWP 的执行,那么就会阻塞住其他用户态线程的执行。

  • 假设是 N:M 的结合方式,即多个用户态线程绑定在多个 LWP 上:

这样多用户态线程和 LWP 即内核线程非一对一的绑定关系,而是动态绑定,如果用户态线程出现阻塞,那么会执行让出 CPU 操作,不会阻塞其他用户态线程执行,并可以换到其他内核线程中继续执行。

可见 N:M 的模型是最符合我们需求的,但是我们就需要使用一个调度器来调度 LWP 和用户态线程的绑定关系了,而用户态线程,在 GO 中,被称为协程。

调度器的实现

大多用户的服务代码会创建较多的任务,而这些任务会运行在协程上,根据上文所说的选择 N:M 的模型进行调度,调度器最简单的模型实现就是将这些协程放在一个全局队列上,逐个将这些协程放在底层的内核线程中执行。而每一个内核线程绑定处理器,以获取对应的协程。

但是这样处理会涉及到锁的问题,因为会有多个处理器去获取队列中的任务。如往期前端性能监控 (RUM) 接入层服务高并发优化实践—缓存模型文中所提及,锁的性能消耗很大,并且会有很大的局部性原理问题。

因此需要进一步优化调度器的实现。

优化调度器

不知道你是否还记得,前端性能监控 (RUM) 接入层服务高并发优化实践—缓存模型文章里所说的三级缓存优化?不得不说,类似的优化思想真是随处可见,这也就是所谓的局部性原理,优化一部分就是将全局队列优化成每个 Processor 一个本地队列。

本地队列的长度是被限制的,最长 256 个。如果所有的 Processor 的本地队列都满了,那么协程就会放到全局队列里面。

即便本地队列与全局队列里面都没有协程可以运行,Processor 和 Thread 会保持自旋,不断寻找协程,而不会销毁。因为 Thread 的销毁和创建也是非常耗资源的,如果突然又有可运行的协程,直接分配过去执行即可。

如果 Processor 本地队列中为空,那对应的线程是不是就会直接空闲?其实不会的,Processor 可以进行工作窃取,以避免出现一个线程很忙,另一个线程非常空闲的情况。

work-stealing 到底是怎么运行的?

那工作窃取是怎么进行的呢?有兴趣的同学可以研究一下 Go 的源码哦,源码在 runtime/proc.go 文件下。这里大概讲一下个中机制:

代码语言:javascript复制
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {    _g_ := getg()top:    // ...        // 这里是检查循环的次数,每 61 次就会检查并去全局队列获取一次,这样就能保证公平,  // 以免全局队列的任务被饿死    // Check the global runnable queue once in a while to ensure fairness.    // Otherwise two goroutines can completely occupy the local runqueue    // by constantly respawning each other.    if _p_.schedticka == 0 && sched.runqsize > 0 {        lock(&sched.lock)        gp = globrunqget(_p_, 1)        unlock(&sched.lock)        if gp != nil {            return gp, false, false        }    }    // ...    // local runq    if gp, inheritTime := runqget(_p_); gp != nil {        return gp, inheritTime, false    }    // global runq    if sched.runqsize != 0 {        lock(&sched.lock)        gp := globrunqget(_p_, 0)        unlock(&sched.lock)        if gp != nil {            return gp, false, false        }    }    // ...

如果本地队列和全局队列里面都没有协程可以执行,将会从其它的 Processor 的本地队列中窃取任务到该 Processor 下执行,窃取的数量是取 processor 本地队列尾部开始算的一半的任务,为什么是从尾部取呢?是为了避免影响正在运行的任务。

总结

GO 协程调度的本质就是优化原有的 CPU 调度模型,尽可能让任务线程更轻量同时让更多的任务分配运行到少量线程中执行,避免阻塞,利用多核并行执行,实现极其强大的并发能力。

其实学习 GO 的高并发原理等等的一系列知识并不是为了学习原理本身,而是为了看其中的优化思路,以便应用到下一次系统优化上,就如往期文章 前端性能监控 (RUM) 接入层服务高并发优化实践—缓存模型 ,将局部性原理中 CPU 三级缓存应用到 RUM 服务中的内存优化中。我们还是要尽可能地深挖个中原理,计算机的底层知识依然在需要性能优化的时候起到帮助,要明白语言的底层与操作的原理,才能从根本上发现问题并针对性地进行优化。

前端性能监控相关文档推荐:

联系我们

如有任何疑问

欢迎扫码进入官方交流群~


欢迎关注腾讯云监控,了解最新动态

0 人点赞