探索协程世界:原理、实现与漏洞模式(上篇)
探索协程世界:原理、实现与漏洞模式(上篇)
原创 l1nk 奇安信天工实验室 2025-02-12 03:31
目
录
一、前 言
二、什么是协程
三、协程发展史
四、总 结
一
前 言
在操作系统中,每当谈及异步,通常都是使用多线程/多进程来实现多任务的执行。然而在现代开发过程中,往往会听到一个新的概念:协程。这是一种更轻量化的并发处理模型,协程被广泛集成到诸如 Go、JavaScript、Rust 和 Python 等主流语言中,成为异步编程的核心工具。为了更好地理解协程,从而探究协程中可能存在的相关的安全问题,文章将简要介绍协程的起源与基本概念,然后通过分析协程模型的特点揭示潜在的安全风险,最后结合真实的漏洞案例,展示协程可能引发的安全隐患,并讨论如何更有效地规避这些问题。
由于内容较多,文章将分为上下两篇。在本文上篇中,会介绍协程的定义,包括常见的协程的用途以及协程在代码中的形式,接下来会给大家一个直观的感受,推断协程是如何诞生的。
二
什么是协程
关于协程的定义,这里直接摘自AI:
协程与传统函数调用的主要区别在于控制权的转移和状态的保存,这使得协程可以在中途挂起并在稍后恢复。
总结来看,协程具有如下的特征:
– 它不是一个由操作系统提供的特性,它是基于语言层面模拟的一个效果;
- 调度需要显示的声明起转换过程。
这里需要注意的是,协程于线程并非是同一个层级的概念,协程是一个用户态模拟的概念,而线程属于操作系统的原生概念。
通常,协程挂起的时候,会保存当前的上下文,包括栈堆以及寄存器等等。
由于协程非一个存在于操作系统的概念,所以对于操作系统而言,它并非真实存在,这就导致传统的调试器无法直接看到它,也就不存在直接观察它的办法。为了能对其有一个比较感性的认知,我们可以尝试先从编程语言中感受它的存在形式。在不同于语言中,协程的呈现形式也不同。比如在python中,协程的函数一般长成这样:
其输出如下:
可以看到有几个关键点:
– 程序以异步的形式运行;
-
存
在
async/await
关
键字,被声明的函数才具有协程异步的能力; -
需要使用
asyncio.run
将其包裹。
在协程使用更加广泛的go语言中,协程的代码如下所示:
在go语言中,使用
goroutine
概念描述协程,基本上所有的函数都会运行在
goroutine
中。并且使用
channel
的概念(就是
: <-
)让变量能够在不同的协程间通信。但是也可以看到几个特征:
– 开启新的协程的时候,需要使用关键字go;
- 使用
channel
同步数据的时候,程序会有类似同步的机制发生。
除了上述语言,
Rust
、
Javascript
、
Lua
等不同语言都提供了协程的支持。协程这个概念其实提出的很早,但是在这几年硬件性能的增长,以及云计算、分布式等场景对大量并发计算的需求,使得协程变成了一个非常理想的方案。这几年C++也开始提供对协程的支持,反映了协程这个概念对于开发模型的重要意义。
01
M:N 调度模型
在协程描述场景中,常常能听说类似于
M:N
模型的说法,这里的
M:N
调度模型描述了协程与操作系统线程之间的映射关系:
– M 表示协程的数量:程序中可以有多个协程,通常远多于线程;
- N 表示操作系统线程的数量:协程运行的基础资源。
在这种模型中,通常用于描述
多数的协程M
在系统调度策略下,被放到
少数的线程N
上执行。这样一来就能尽可能地减少线程地调度,从而提搞程序地运行效率。
三
协程发展史
如果想要比较彻底的明白一个概念,最好是从它的诞生史来看。接下来将一步步介绍协程诞生的过程。
01
最初的开始 – Duff’s device
达夫设备(Duff’s device)是一个C语言编程技巧,但是这个技巧提出了一种类似状态的思想,利用
状态描述
可以让程序
从某一个中间态,而并非开始位置开始运行
,这个过程也被称之为循环展开(unrolling loop),这种重要的思想指导了后面协程的诞生。
在很早年的时候,有一名叫做
Tom Duff
的工程师,他曾面临一个如下的场景:有一个往屏幕输入的寄存器(假设叫做to,当以*to进行数据写入的时候会自动将数据写入屏幕),此时有一个连续的内存记录我们将要写入的数据(这里记为from)。于是为了能渲染这个画面,他写出了如下的代码:
然而屏幕本身其实是由8个不同的寄存器组成,于是他就写出了类似于如下的代码:
问题在于,这个
from
指向的数据并非一定是8的倍数,这种【不对齐的循环】问题就可能会带来无效的循环逻辑(比如进行越界访问等),达夫就提出了一种取巧的方式,让程序能够根据输入数据的长度,从合适的地方开始循环,其本质上就是巧妙地利用
switch
的松散特性:
在处理数据的时候,优先将不对齐的部分先进行赋值,然后再以倍数进行赋值,从而减少循环语句中执行每条语句的次数。
这段代码在现在来看,其实已经不具备太好的优化能力,并且其可读性其实并不好,然而这个代码抽象来说,提出了一种观念,就是
在循环中同时保持对状态的计算
,正如这个count%8的值,它间接的影响了这个循环的位置,使其会从循环不同的位置开始。
这种
在循环中插入执行状态
的一种思想,可以被称之为达夫设备,也就是后来生成器诞生的原因。
02
历史的变迁 – 生成器 yield
很多时候,我们都有读入文件的需求。但是绝大多数的场合,都是对文件内容按行进行解析,而不需要一口气读入。实际上,很多文件都非常大,如果一口气读入所有的文件内容,程序就会变得卡顿。
通常情况,程序会推荐按照如下的方式读入数据:
换
句话说,就是不一次性读入所有的数据,而是按照行处理数据。
然而,这种写法有一个问题,就是读文件的逻辑和处理文件的逻辑耦合在了一起,希望程序能够做到如下的效果:
– 每次只读入一行,并且返回一行的内容。
可
以将上面的代码简单抽象成一个循环的样式,如果在C语言中,希望类似于:
然而实际上,C中一旦遇到了返回,那么这个程序的上下文便会被毁坏,从而导致程序
必须从头开始循环
。
解决方案也很简单,回想再第一段中提到的
达夫设备
,也就是
利
用状态影响循环起始位置
,或者说,引入
状态
这个概念,利用状态主动保留程序的运行上下文,从而使得程序可以从指定的位置开始运行。
在C语言中,可以用
static
实现这个效果:
通过达夫设备的思想,我们让程序
根据状态,从不同的地方返回,以及从不同的位置开始循环
,从而实现了让程序
仅执行部分逻辑的效果
。可以将上述代码封装成这个样子:
这样几乎就让C语言实现了一个类似协程的概念(CoRoutine)—— 程序的调用者和被调用者在逻辑层面上,都能以正向思路完成代码编写。
这种写法在python中其实已经非常常见,也就是:
在现代语言中,这种写法之为生成器(generator)。在不同的语言中,这个生成器或是直接作为特征提供(例如Python、Lua、C#等),或者是可以通过语言提供的其他特性,构造类似的设计结构完成对应的实现。
03
协程的诞生 – 做菜模型
有了之前的生成器模型,现在得到一种类似于
保留状态
的编程模式:保留当前运行的状态,并且根据运行状态决定接下来的运行逻辑,而并非从程序刚开始的位置运行。而协程本质上就是利用了这个特
征实现的一种运行。接下来,尝试从伪的方式来描述协程到底是什么。
将一个
程序的上下文称之为Ctx,也就是当前运行
寄存器
。
假定有一段做饭的代码中
cook
表示烹饪,而烹饪中包含炖菜
stew
和炒菜
fry
,但是炒菜之前需要先切菜
cut
,当终于烹饪结束之后,会
eat
饱餐一顿,那拟这个过程的代码如下:
在传统模式下,程序的执行逻辑是这样的:
然而,在通常认知中,炖菜
stew
完全是一以单独运行的逻辑——毕竟只要把菜炖熟了就行,完全是可以和切菜炒菜并行执行的,也就是如下图所示的场景:
炖菜
stew
和炒菜
fry
是两个可以并行的函数,这两个函数中可以同时做。换句话说,
无需等待其中一个操作完成再做另一个
要怎么做呢?回想一下现实场景中,如何同时做菜?很简人也参与在其中就好了就是说
,
厨师作为管理者
度这些过程,防止炖汤煮糊了的同时,又来操作切菜炒菜,也就是如下的运行模式:
可以写出如下的代码模型:
如上,人为的制造了一个事件队列在
chef
对象中,并且通过调用
do_task
来完成任务,并且通过
check_finish
检查任务是否完成。然而,如果这个
do_task
就是原先给出来的函数(例如
stew/fry
)那么这个过程显然依然是一个
等待执行
的过程,这就意味着其并没有实现异步。这该怎么办呢?
此时,可以将之前提到的
生成器模型
和这段函数结合,并非每次都直接执行程序本身直到其执行完成,而是如同生成器一般,
检查函数是否做完
,
如果没有做完,则返回一个没做完的状态,否则给出一个做完的状态。以炖菜
stew
为例,其实并不用持续的站在炖菜旁边,而是等他冒热气了再去检查:
假定这里的
stew_until_finish
存在一种异步模式的函数,称之为
stew_until_finish_async_mode
。
在这
里首先调用了这个
stew_until_finish_async_mode
,这个函数并不会真正开始执行炖菜的全流程,而
只是将炖菜放入锅中,并且开火
,此时函数会当即返回。然后调用一个
check_if_steam
的函数,这个函数相当于
检查炖菜是否冒气了
。如果冒气了就意味着煮熟了,那么就不必在继续煮,终止顿炖,并且返回完成的状态,否则调用前面生成器中提到的
yield
关键字,表示距离煮熟还有一段时间,此时暂时性的离开了这个程序,也就是告知程序
可以暂时放弃检查这个函数,转而检查其他烹饪情况
。相当于如下的形式:
在实际场景中,这类异步函数也是存在的,常见于IO数据获取等场合,例如读取文件或者网络数据,均存在这种调用后就立刻返回的函数。
如果按照上
述模式,可以进一步优化我们厨师
chef
的
checking_finish
函数:
同理,也可以按照这个方式处理我们的fry函数,这样就能够实现
在未开启多线程的情,依次调用
stew
和
fry
函数
的效果。
然而,在上述的处理方式上,还是有了太多无用的循环。在某些场合中,这种轮询也比较消耗时间,例如这个厨房其实很大,在炖锅和炒锅之间走来走去非常花时间,还容易两边都煮糊了。那么此时可以弄一种类似
闹钟
的提醒装置,对于炖锅而言,一旦
冒气
,它就会发出声音,提醒检查是否真的煮熟好;在没提醒的时候,完全可以不检查炖锅,转而专心处理切菜和炒菜的活儿。也就是说,利用提醒装置,可以忽略正在等待中的逻辑,转而将这个逻辑将时间留给其他的逻辑。
例如,在生成
stew::do_task
的时候,根据这里的
check_if_steam
函数,设定一个闹钟函数
alarm
,并且将这个闹钟安装在我们的厨房(也就是程序运行上下文)
roomctx
中么此时代码可以写作如下:
然后此时,对于厨师
Chef
的检查逻辑,可以分离出另一个叫做
唤醒器waker
的概念,这就好像是听闹钟的耳朵,抑或是一个手机上的闹钟。简单来说就是一个用来观测
当前事务是否完成
的观测者。这个观测对象仅对目标进行观测,只有其完成了任务之后,才尝试调用目标函数:
如上,此时就可以通过
waker
先预先调用,确定哪些事件是真的准备好了的,从而决定之后的调用需要调用哪些函数。
按照这种写法,也能写出炒菜
fry
中的函数调用:
在获取这两个程序的运行模式之后,就需要一个函数,告诉厨师
chef
需要等待两个任务都完成之后才能进行下一步。这个时候可以使用类似于
join
的函数,等待两个任务均完成,此时就可以完善烹饪逻辑
cook
和主函数
main_func
写出来:
这样一来,就实现了一个厨房场景中的异步场景:
– 首先,厨师需要准备一个
厨房
,也就是
异步运行上下文
,包括事件队列,唤醒器
waker
等;
- 之后做饭的时候,炖菜
stew
和炒菜
fry
作为厨师的两个任务,加入到厨师的事件队列中,之后厨师只需要管理这两件做饭的事宜;
- 当执行
stew
的时候,程序会调用
stew_until_finish_async_mode
,先将锅放上火炉,但是此时不开始正式烹饪;
- 将判断依据
是否出现水蒸气
check_if_steam
作为提醒标准并记录,此时只有锅冒出水蒸气才去检查;
- 之后,尝试检查锅是否煮熟
check_if_stew_finish
,如果没有煮熟,则离开炖煮
stew
状态,前往炒菜状态
fry
;
- 进行炒菜环节
fry
,因为切菜是必须的逻辑,所以会同步地执行切菜
cut_food
直到开始执行炒菜
fry_food_until_finish_async_mode
这个异步函数;
- 如果炒菜的时候没有冒烟
check_if_smoke
,说明单面还没炒好,此时离开当前状态,等待冒烟,抑或是锅煮沸腾
check_if_steam
;
-
chef
通过调用
join_tasks
函数,等待两个任务直到均返回
Ready
,也就是相当于菜炖好了/炒好了,此时结束两个任务队列; -
此时
chef
执
行完成,我们回到主函数,开始吃饭。
在这个小小的做菜中,我们能够发现,即便是在没有多线程的运行模式下,也能造出一种几乎异步的环境,这种异步我们就可以叫做
协程
。在这个小小的模型中,有集中必要素:
– 调度的最小单位为一种可以被异步执行的
任务
,例如炖菜
stew
或者炒菜
fry
,而对于炒菜的前置对象切菜
cut
,由于在逻辑上并未将其视为异步的一环,因此它
并非异步对象;
-
这种
异步任务,在不同语言中称谓不同,有些叫做
Task
、有些叫做
Cocurrency
,这边可以统一用中文称之为
协程; -
程序中调用异步函数,在调用之后会对调用结果进行轮询,当返回结果为
Ready
的时候; -
轮询过程中,可以使用一种
通知函数
,告知程序应当去执行任务; -
利用状态,让程序从合适的位置重新开始运行;
-
通过类似
join
的函数,等待数个异步任务,直到双方都完成;
只要按照上述的模式维护对象进行异步编程,就可以构造出
协程
的编程模型。
04
更加高效的编程 – async/await
实际上,上述中的很多函数都使用了一些比较具体的说法来描述我们的场景,然而在实际情况下,很多异步对象(例如计,文件网络IO等)都有着属于自己的
waker
标志和对应的异步操作,绝大多数的耗时操作也来自于这些行为。所以各类提供
协程
的语言,完全可以将这类操作提前封装好,这样就无需每次编程的时候都由开发人员手动编写这些流程。
为了更好的描述协程,可以给出一些对于协程过程中常见的概念:
– 每一个将要进行异步的操作(例如上文中的炖菜
stew
和炒菜
fry_food
)可以将其称之为
Future
,之所以用未来这个词,就是表达这个动作无需即可完成,而是可以等待至
将来
要执行的;
-
整个炖菜
stew
和炒菜
fry
(并非
fry_food
)是一个完整的任务,在完整的任务中,可能会有多个可以异步的动作(例如炖菜和炒菜)也可能有多个同步的动作(例如切菜
cut
),将这些
需要完整完成
的动作称之为
Task; -
对于存放数个任务的队列,类似于厨师
chef
的任务队列,将这些统称为运行时
Runtime。
那么此时,就能罗列三者的关系:
其中,
Future
是最小的描述可
发生异步
的对象,而
Task
是最小的可被
调度
的对象。
实际上,每次编写协程,都需要封装
Future
,再封装
Task
,再将
Task
注册到函数中,这个写法过于冗余,所以可以使用一种比较常见的语法糖来帮助实现这个流程,这个常见的语法糖就是
async/await
。
以之前的代码为例,当需要标志当前函数为一个
Future
,也就是它允许被异步执行的时候,可以这样写:
通过这样声明,就能告知编译器,
请将这个函数生成一个Future对象,并且将其包裹
。这样一来,当调用这个函数的时候,就可以像前文提到的那种异步模型一样,
直接膨胀出对应的处理代码,从而让其编程可异步的形式
。
同样的,也需要另一个语法糖,用来告诉编译器
需要等对应的函数完成后再执行接下来的函数
,
可以增加一个标记,这里暂定为
await
,那么写法就可以如下所示:
可以发现,语法糖的存在极大的简化了之前的逻辑,让整个程序变得更加轻便和容易编写。通过GPT将上述
的例
子转换成Rust代码,可以得到如下的形式:
可以发现,其设计的协程模型的使用方式和我们这边推断出来的几乎一致。除去
async/awa
,
task
这个概念由
tokio::spawn
创建,等待由
tokio::join!
实现。
至此,便实现了对整个协程的初步认知。
05
不同语言与不同的协程
协程作为一种用户态提供的特性,意味着并没有一个严格的标准去限制它。所以在不同的语言中,这种特性的呈现形式也有所差异。前文中介绍的模型大致是统一的,但是在不同的语言实现中却有一定的差异。例如Future这个定义,在Python和Rust中的定义差异如下所示:
特性 |
Python 中的 Future |
Rust中的Future |
具体概念 |
用于描述异步操作的结果 |
作为Trait,是异步管理最小单位的抽象 |
挂起与唤醒 |
可以使用 await 等待,也是一种可等待对象 |
可以使用 |
同样的,在前面的例子中,还给出了go语言的例子,会发现在go语言中甚至没有出现
async/await
之类的关键字,而是使用了
go/channel
的组合实现。总的来说,在不同的语言中,协程的使用会表现出微妙的差异。
协程和线程并非是对立关系,实际上正如前文介绍的那样,协程和线程通常会构成
M:N
模型,从而保证程序的正常工作。此时的程序通常会存在所谓的
worker_threads_pool
工作线程池,这个线程池中的线程都会被协程用于调度。
四
总 结
本文上篇主要介绍了协程的基本概念、起源及其在不同编程语言中的实现。通过厨师模型的介绍,逐步引领读者了解了这种协程的代码模型是如何产生的。同时,通过讨论在不同语言中的协程实现,介绍了不同语言的协程中存在的一些差异。
然而,协程虽然提升了并发编程的灵活性和效率,但不当的使用可能引发安全隐患。实际上,现代编程提供的各类特性往往是通过增添新的代码来实现的,对于未了解这些新特性实现细节的开发者,在开发过程中往往容易出现一些未知风险。在本文的下篇中,我们将会深入分析协程模型下常见的漏洞模式,揭露其潜安全风险和规避措施。
【版权说明】
本作品著作权归l1nk
所有
未经作者同意,不得转载
l1nk
天工实验室安全研究员,Datacon 2023 出题人,主攻二进制漏洞挖掘。
往期回顾
01
Java XStream 反序列化:Gadget 挖掘思路分享
02
03
CVE-2024-38054 Windows ksthunk.sys驱动提权漏洞分析
04
每周三更新一篇技术文章 点击关注我们吧!