专栏名称: 智能车情报局
聚焦智能汽车关键技术与创新产品
目录
相关文章推荐
为你读诗  ·  沉香藏家绕不过的绿奇楠,绕腕留奇香 ·  2 天前  
为你读诗  ·  对话伟大灵魂,《苏东坡》特装刷边版 ·  3 天前  
文学音乐与朗诵  ·  跌倒了,爬起来 ·  3 天前  
51好读  ›  专栏  ›  智能车情报局

真好,用协程优化自动驾驶中间件的代码结构

智能车情报局  · 公众号  ·  · 2024-05-02 10:30

正文

各位大佬,本文3500字,12张图,耐心查看。有任何需要,可以后台加微信,期待和你做朋友。

作为软件从业者,你可能多多少少知道协程这一概念。协程用在哪?一般用在高性能并发中,例如时序数据库、搜索引擎等在线服务中。

CyberRT将协程引入到自动驾驶中间件的调度中,代码看上去非常和谐。在前面 彻底搞懂自动驾驶中间件的“调度”②: 由浅入深,五张图勾绘一套完整调度系统(https://mp.weixin.qq.com/s/5ckXIbItYpS-l5MAyWECCg) 中,对中间件调度的一个极简概括就是:线程队列通过优先级等判断条件,循环执行任务队列中的任务。CyberRT认为这里的每一个任务就是一个协程。

这一篇文章作为自动驾驶调度的第三篇文章,将试着能从以下几个点出发,解释清楚协程在自动驾驶中的应用。

  1. CyberRT中的协程多次一举?
  2. 5张图,秒懂协程工作原理
  3. 协程上下文切换(CyberRT主要展开)

01

CyberRT中的协程多次一举?


在第二篇 彻底搞懂自动驾驶中间件的“调度”②: 由浅入深,五张图勾绘一套完整调度系统 (https://mp.weixin.qq.com/s/5ckXIbItYpS-l5MAyWECCg) 中,我们采用的是典型异步编程思路,每一个task执行的都是回调函数,下图:

将任务队列按照优先级分为多个队列,例如:设置10个优先级,那么会有10个队列queue[0]、
queue[1]、…queue[9]。每一个任务,是一个异步执行的回调函数,线程按照一定的策略,执行回调函数。

CyberRT将每一个task视为一个协程,协程在线程上运行,下图:

1. 协程到底是什么?

我们可以简单直观的认为,协程是用户态的线程,其上下文的切换不需要陷入内核,是靠用户(代码开发人员)自己控制的一个神奇东西。下面这个简单的例子可以帮助理解:

Part1:

Part1的输出是:不可随处小便

Part1中在main线程中依次按照三个函数A()、B()、C()的调用顺序执行,输出内容是“不可随处小便”。

Part2:

Part2的输出是:小处不可随便

Part2中加入协程的上下文切换,在各个函数中切出使用co_yield;下一次恢复执行时,从co_yield的下一行开始执行。

这个例子可以很形象的看出,协程的启动、挂起和恢复是由开发者自己控制的,且当协程恢复执行时,是从该协程的上一个挂起点后继续执行。

2. 为什么有时候需要使用协程?

例子1: 假设我们处理10个用户的需求,只需启动10个线程去操作。随着用户数增加到1000个。

此时如果启动1000个线程(每个线程至少占用4M),1000个线程会占用高达4G的虚内存空间(达到虚内存空间访问上限),而且太多线程数通常伴随着由于线程切换而触发缺页中断的风险。

这里的虚内存不是真实的物理内存,但是线程的真实内存会随着线程执行而变化,刚开始可能是占用一个指针的大小(8B), 随着局部变量的增加,真实线程栈会增加,过多的线程还是会导致系统资源的增加。

例子2: 假设我们在1个CPU核心上运行1000个线程,它们的优先级一样的情况下,每个线程在每一秒会有1ms的时间片。

在IO密集型很容易发生读写阻塞,此时会进行线程切换,执行其他线程。线程切换的耗时加上大量线程之间存在资源竞争,会导致整个系统出现不可预测性。

针对这些类似的应用场景,协程的优势就出来了:
  • 协程的创建、销毁和调度都发生在用户态,受程序开发者自己控制,减少了线程的重复高频创建和频繁切换。

  • 内存占用小,可以创建大量协程运行在线程上,并且协程可以主动让出时间片,避免线程的阻塞。

  • 代码结构好,可独性好,看上去像是同步,可以提升代码的可维护与可理解性。


CyberRT为什么将任务task构造成协程呐?

我们细致的分析下它的调度和协程代码,发现最大的优势可能就是上述的优点三。

之前在第一篇 彻底搞懂自动驾驶中间件“调度”①:从哪里开始着手设计 (https://mp.weixin.qq.com/s/EcQrUlV9PDxr5QZ1_LsdPw) 中也提过,CyberRT采用的调度策略是静态配置表,意味着核心的线程个数是固定的。比如说默认配置文件中default_proc_num = 16,意味着协程将在这16个线程中运行,就没有涉及线程的频繁高频创建。CyberRT目前开源的代码中,调度逻辑简单概括是:线程遍历协程,一一运行。如果一个协程(A线程中的协程a)运行时间突增,导致该线程A阻塞,此时调度逻辑中没有一些外部干涉手段(例如:强制将长时间占用 CPU 资源的协程切换出去等),所以目前也不存在避免线程阻塞的优势。不过的确让整个调度的代码结构更加完美,层次分明,大大提高了代码的可读性。

如果CyberRT将所有的协程还原成原始的回调函数,我们经过简单测试,在确定的测试case中,没有发现有明确的性能下降。

有兴趣交流的同行,也可以后台加微信,一起交流开源中间件使用中遇到的问题和解法。

02

5张图,秒懂协程工作原理


1. 协程是函数调用的泛化。

协程:Coroutine。

很早之前计算机科学家们给出了概念:Coroutine就是可以中断并恢复执行的Subroutine。Subroutine是什么?与函数类似,其有计算函数结果的指令集合和执行完成之后的返回状态。

Coroutine比起Subroutine,可以中断/恢复(yield/resume)。

用示意图可以更加直观的展示其原理。

一个函数的函数体是顺序执行的,执行完之后将结果返回调用者。中间我们无法对它挂起和恢复,只是在等待它结束。如:从caller中调用function,等待function从上到下执行完成后,返回到caller调用点,caller继续执行。

协程相比起普通函数,是可以挂起并在任意时刻恢复执行。如:从caller中调用coroutine,协程开始执行,当遇到挂起点后,像普通函数一样返回到caller。当caller发起恢复操作时,协程从上一次的挂起点开始继续执行,当遇到第二次挂起点后,再次主动让出时间片给caller。

2. 协程的内存分配

对计算机中的一些概念理解,离不开内存分配的理解。

想搞明白协程的内存分配,我们得绕一下,从进程的地址空间开始。进程才是linux中资源分配的基本单位。

Part1 进程:

一张图大家经常见到:
这张图代表的是进程的地址空间。这里的地址空间,不是真实的物理内存空间,是虚拟地址空间。(前面我们写到“1000个线程会占用高达4G的虚内存空间(达到虚内存空间访问上限),也是虚地址空间)”

在32位机器下,虚拟地址空间大小为 4G。这些虚拟地址会通过页表 (Page Table)映射到物理内存,页表由操作系统维护,并被处理器的内存管理单元 (MMU) 硬件引用。

每个进程都拥有一套属于它自己的页表(虚拟地址空间是一样的),因此对于每个进程而言都好像独享了整个内存空间。

Linux内核将这4G字节的空间分为两部分,将最高的1G字节(0xC0000000-0xFFFFFFFF)供内核使用,称为内核空间。

而将较低的3G字节(0x00000000-0xBFFFFFFF)供各个进程使用,称为用户空间。


当然,内核和用户态进程占用了这么大地址空间,但是并不意味它们使用了这么多物理内存。

实际使用的内存量,即RES,并不是立刻增长而是根据实际写入page的映射情况动态增长。

Part2 线程:

线程被Linux内核视为一个与其他进程共享某些资源的进程。线程的地址空间和进程是一样的,当然栈除外。

线程拥有自己独立的栈空间,这个栈空间和进程的不一样,它不能动态增长,一旦用尽就没了。

Part3 协程:

协程作为一种轻量级线程,但与传统线程不同,起内存分配在堆上。协程的内存管理更加灵活,因为它可以动态地分配和释放资源,而不受操作系统的过多干预。

协程需要在内存中保存哪些内容?协程内部需要存储自身的上下文,在需要切换的时候把上下文切换。

上下文其实本质上就是寄存器,协程中保存上下文实际上就是把寄存器的值保存下来。
  • 寄存器RSP的值记录了程序运行时栈的当前位置。保存了RSP的值,就是保存了运行栈信息;改变RSP的值,就是完成运行栈的切换。

  • RIP保存了下一条指令的位置

  • RBP保存当前栈帧的栈底

  • 还有主要的寄存器:rbx,r12,r13,r14,r15


03

协程上下文切换







请到「今天看啥」查看全文