OpenSSL异步框架是在OpenSSL-1.1.0上引入的一个新特性。它理论上可以应用于任何的异步操作,但当前主要是用于在引擎框架中执行的加密学操作。本文首先介绍其底层原理,接着介绍OpenSSL的异步基础设施,最后着重分析ASYNC_JOB(OpenSSL中的协程)的处理流程(dispatcher和job之间是如何切换的)。
引入异步框架主要是出于性能的考虑,对于普通的同步操作模式,API调用会一直阻塞直到请求完成。在API之下,要么执行一个忙循环等待响应,要么进行进程上下文切换。忙循环浪费CPU周期且无法并行运行多个操作,而进行上下文切换虽然允许并行,但是上下文切换的开销仍然是相当大的。
而异步模型通过充分利用这些间隙来提升性能。提交请求之后并不等待任务完成,而是直接返回,当任务完成时再通知用户代码获取响应。
下文中job和协程基本可以互换。
底层核心函数
OpenSSL的异步框架,底层是基于以下几个核心函数。getcontex/makecontext/setcontext用于生成用户态的协程,setjmp/longjmp用于在dispatcher/job之间进行跳转切换。
下面分别介绍下这几个核心函数:
ucontext_t
首先ucontext_t
结构体顾名思义是用户态的上下文
1 | typedef struct ucontext_t |
- uc_link:指向当前context执行结束之后下一个执行的用户上下文。
- uc_stack:是该上下文的堆栈
- uc_mcontext:是硬件相关的,保存具体的上下文,如各寄存器的值
- uc_sigmask:执行上下文过程中,要屏蔽的信号集合
getcontext
1 | int getcontext(ucontext_t *ucp); |
用于将ucp
所指ucontext初始化为当前活跃的上下文。
setcontext
1 | int setcontext(const ucontext_t *ucp); |
恢复ucp
所指定的上下文,该上下文是通过getcontext、makecontext、或信号处理函数第三个参数中获取到的。
- 如果上下文是从getcontext调用中获取到的,那么程序将从getcontext位置继续执行,就好像是getcontext调用刚刚返回一样。
- 如果上下文是从makecontext调用中获取到的,程序执行参数中指定的函数,当函数返回时继续执行makecontext时ucp参数中的uc_link,如果为NULL则OS线程中止。成功的调用不返回。
- 如果context是从信号处理函数中获取的,结果未定义。
makecontext
1 | void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...); |
修改ucp
指定的上下文,在调用makecontext之前调用者必须给这个上下文分配一个新的栈ucp->uc_stack
,并定义一个后继的上下文ucp->uc_link
。然后在调用makecontext之后通过setcontext或swapcontext激活这个上下文,传递argc后面的一系列int参数。当函数返回时,后继上下文被激活,如果后继上下文为NULL则线程结束。
swapcontext
1 | int swapcontext(ucontext_t *oucp, ucontext_t *ucp); |
将当前上下文保存到oucp
,然后激活ucp
。类似于getcontext和setcontext的结合体。swapcontext成功时同样不返回,但是后续可以通过激活ocup返回,此时就好像swapcontext返回了0。
关于这几个函数的用法示例,可以参考makecontext(3)/swapcontext(3)
setjmp/longjmp
1 | int setjmp(jmp_buf env); |
setjmp将栈上下文保存到参数env
中,首次调用返回0。后续使用longjmp跳转到之前setjmp的位置,就像是刚从setjmp函数返回一样,此时的返回值是longjmp参数的非零值val。
一些疑问
为什么OpenSSL选择使用setjmp/longjmp来进行协程切换,而不用swapcontext呢?
因为swapcontext的开销比setjmp/longjmp大?swapcontext提供了更多的控制,不过我并不是太确定。
通过查找资料,找到了如下commit提交记录,发现确实是出于如上考虑。
1
2
3
4
5
6
7
8
9
10commit 7070e5ca2fa41940d56599bf016a45cb1c0e03f0
Author: Matt Caswell <matt at openssl.org>
Date: Tue May 5 15:08:39 2015 +0100
Use longjmp at setjmp where possible
Where we can we should use longjmp and setjmp in preference to swapcontext/
setcontext as they seem to be more performant.
Reviewed-by: Rich Salz <rsalz at openssl.org>你可能已经注意到了,OpenSSL中其实是使用了_setjmp/_longjmp,而不是setjmp/longjmp,两者有啥区别?
区别是前者不操作信号掩码,而后者会。这会在上下文切换中带来速度的显著提升。
1
2
3
4
5
6
7
8
9
10
11commit 06b9ff06cc7fdd8f51abb92aaac39d3988a7090e
Author: Matt Caswell <matt at openssl.org>
Date: Fri Oct 9 15:55:01 2015 +0100
Swap to using _longjmp/_setjmp instead of longjmp/setjmp
_longjmp/_setjmp do not manipulate the signal mask whilst
longjmp/setjmp may do. Online sources suggest this could result
in a significant speed up in the context switching.
Reviewed-by: Rich Salz <rsalz at openssl.org>
异步框架基础设施
OpenSSL通过ASYNC_JOB
实现异步能力。一个job表示一个协程,它执行一段指定的代码,直到发生一些事件,通常是I/O操作或者是调用硬件加速卡等。此时协程可以被暂停,将控制权交还给用户代码。当后续事件指示可以恢复job时,用户代码再恢复该job。
async框架外部是用户代码,例如libssl,负责开始job、恢复job;async框架内部则封装了异步的API,通常是通过引擎来实现,负责暂停job以及恢复的通知机制。
libssl - async - libcrypto(engine) - nonBlockingAPI
外层指定一个job的入口函数并启动job,内层碰到异步API接口未完成就暂停该job,在暂停之前设置通知机制。这样当接口操作完成时,外层可以获取到通知,再一次继续之前暂停的job,最终job执行完毕,外层释放这个job。
数据结构
1 | typedef struct async_fibre_st { |
async_fibre表示上下文,包含了协程生成和切换时需要的信息。它是系统环境相关的,上面展示的是posix环境的版本。其中ucontext_t
类型就是xxxcontext函数使用的上下文,jmp_buf
类型则是setjmp保存的环境。
1 | struct async_ctx_st { |
async_ctx_st是线程的异步上下文结构,每个线程有一个。其中的dispatcher表示主上下文,也可以认为是调度器,currjob则表示当前执行的job。
1 | struct async_job_st { |
ASYNC_JOB表示一个job,其中fibrectx是上下文,func和funcargs是实际要执行的工作函数及其参数,ret用于保存工作函数的返回值,status记录job的当前状态。waitctx是通知机制需要使用的上下文。
主要接口
OpenSSL提供了如下原语供使用:
1 |
|
其中ASYNC_init_thread和ASYNC_cleanup_thread用于初始化和清理异步job。如果是多线程的程序,每个线程都需要进行相应操作。其内部使用了thread-local数据。
ASYNC_start_job和ASYNC_pause_job是核心的两个函数,其相当于Lua协程中的resume和yield。前者用于开始一个job,或者恢复一个job;后者用于暂停job。
ASYNC_get_current_job返回当前执行的job的指针。如果不在job上下文中则返回NULL。
关于接口的更多说明,见文末参考资料。
初始化
通常不需要显式进行初始化,在第一次调用接口时候会自动进行。
其中会初始化thread-local变量,ctxkey和poolkey分别是async上下文和job池的键。
1 | // crypto/async/async.c |
用户代码ASYNC_start_job
这里以libssl为例来看,SSL的很多接口如SSL_do_handshake/SSL_read/SSL_write等都支持了异步job
1 | // ssl/ssl_lib.c |
这里只是一个简单的包装,将具体的工作函数及其参数传入。其中ssl_async_args结构如下:
1 | struct ssl_async_args { |
ssl_start_async_job是ASYNC_start_job的一层包装,首先创建waitctx,然后调用ASYNC_start_job并处理返回值,将结果反馈给上层。
1 | // ssl/ssl_lib.c |
其中返回值为ASYNC_PAUSED
表示job暂停,对应SSL_get_error就返回SSL_ERROR_WANT_ASYNC
。ASYNC_FINISH
则表示job结束了。
内部代码ASYNC_pause_job
暂停job是在协程内部调用,基本的使用方式如下:
1 | do { |
在调用非阻塞接口之后,如果是类似retry等结果表示需要等待,那么就调用ASYNC_pause_job暂停job,控制权归还给主控dispatcher,此时ASYNC_start_job返回ASYNC_PAUSE。
当用户代码决定恢复job时,会再次调用ssl_start_async_job继而调用ASYNC_start_job,控制权又回到了协程中。
此时ASYNC_pause_job返回,会再次调用之前的接口,如果还是需要等待则重复之前的操作,如果已经结束那么继续执行后面的代码。
最终job执行结束之后,ASYNC_start_job会返回ASYNC_FINISH。
通知机制
那么下次怎么恢复刚才暂停的那个job呢?这个就涉及其通知机制了。ASYNC_WAIT_CTX支持两种通知机制。
wait fd机制
一种是通过一个wait fd进行通知,主要接口如下:
1 |
|
内部代码中创建一个fd,然后调用ASYNC_WAIT_CTX_set_wait_fd()将fd加入到ASYNC_WAIT_CTX的fds中,该函数一般在ASYNC_pause_job()前调用。外部用户代码通过对应的get函数获取到这个fd,并将其加入epoll中。当这个fd上有读事件的时候就再次恢复之前的暂停的job。
内部的异步操作接口并不总是支持epoll fd的,如果支持那就最好不过,直接ASYNC_WAIT_CTX_set_wait_fd()设置这个fd就可以。如果内部接口不支持,那么就只能进行轮询了,也就是接口外部不断地检查操作是否已经完成了。Intel的QAT Engine的旧版本就是采用的这种做法,它在ASYNC_pause_job()暂停job之前,会先往这个fd中写入数据,于是外部用户代码将该fd加入到epoll之后,读事件已经是处于激活状态。在下一次事件循环时就会恢复暂停的job,ASYNC_pause_job()中恢复job之后从fd中读取数据。下面是QAT Engine中的代码片段:
1 | do { |
其中qat_wake_job
中会往fd中写入数据,qat_pause_job
中会调用ASYNC_pause_job
,在ASYNC_pause_job
返回之后读取fd中的数据。
callback机制
另一种是通过callback(OpenSSL 3.0上新增),也需要异步接口的支持。当引擎完成异步操作之后调用回调函数去通知用户代码,ASYNC_WAIT_CTX_set_callback()用于设置callback及其参数。回调函数需要是小的且非阻塞的。
callback相关的接口如下:
1 |
|
关于接口的更多说明,见文末参考资料。
ASYNC_job处理流程
总体的处理流程如下图所示:
job状态管理ASYNC_start_job
如前所述,ASYNC_start_job
开始一个新的job或者恢复之前的job,是job管理的核心函数。如果是开始一个新job就创建并执行新job,否则根据job的状态决定下一步的操作。
1 | // crypto/async/async.c |
创建job
如果是开始一个新的job,从pool获取一个,如果池中没有则创建。job刚创建时的状态为ASYNC_JOB_RUNNING。
1 | // crypto/async/async.c |
创建上下文通过async_fibre_makecontext
完成,这里的入口函数是async_start_func
,它对实际的工作函数进行了一层包装。
1 | // crypto/async/arch/async_posix.c |
包装入口函数
async_start_func
中执行job的工作函数,返回之后再切换回dispatcher,
1 | // crypto/async/async.c |
协程切换核心函数
async_fibre_swapcontext
是切换的核心函数,其实现如下:
1 | // crypto/async/arch/async_posix.h |
其中env_init
用于表示对应的协程上下文是否已经开始运行,如果还没有则调用setcontext
开始,否则直接调用_longjmp
进行跳转,这里主要是为了性能考虑。
接下来的讲解将围绕这个切换函数和ASYNC_start_job。
开始job: dispatcher->job
前面提到了,刚创建的job是ASYNC_JOB_RUNNING状态,第一次调用上下文切换函数async_fibre_swapcontext
,从dispatcher切到job。
首先将dispatcher的env_init
置1,setjmp
保存环境到dispatcher的env中,由于此时n->env_init
为0,所以调用setcontext
开始执行async_start_func
,在其中执行job的工作函数。
暂停job: job->dispatcher
如果在job处理过程中发生阻塞,会调用ASYNC_pause_job
切换回dispatcher,此时job状态变成ASYCNC_JOB_PAUSING。
1 | int ASYNC_pause_job(void) |
ASYNC_pause_job
中第二次调用上下文切换函数,这次从job切换回dispatcher。
将job的env_init
置1,setjmp
保存job的环境(恢复时用到),因为此时n->env_init
为1,所以longjmp
回第一次调用async_fibre_swapcontext
时的setjmp
位置返回。
于是就回到了ASYNC_start_job
函数中,dispatcher在ASYNC_start_job
中继续下一次for循环,因为此时job状态为ASYCNC_JOB_PAUSING,所以执行如下操作:
1 | if (ctx->currjob->status == ASYNC_JOB_PAUSING) { |
将SSL中的job指针设为当前job,更改状态为ASYCNC_JOB_PAUSED,将ctx中currjob置空。这几个设置操作是为了下次可以正常恢复job,设置完之后返回ASYNC_PAUSE。上层的ssl_start_async_job
则将ssl的rwstate置为SSL_ASYNC_PAUSED,再外层的SSL接口如SSL_do_handshake返回-1。
然后外层检测到SSL_ASYNC_PAUSED,可以通过调用SSL_get_changed_async_fds
,它封装了ASYNC_WAIT_CTX_get_changed_fds
,获取fd加入到epoll中(asynch_mode_nginx中的ngx_ssl_async_process_fds)。
恢复job: dispatcher->job
当fd的读事件激活时,就会执行对应的事件处理函数再次恢复job。以asynch_mode_nginx为例,可能是如下的handler函数,ngx_ssl_handshake_async_handler
、ngx_ssl_read_async_handler
、ngx_ssl_write_async_handler
、ngx_ssl_shutdown_async_handler
。在handler中再次进入到ssl_start_async_job
,并调用ASYNC_start_job
。
此时job的状态为SSL_ASYNC_PAUSED,于是走到下面这里再次进入async_fibre_swapcontext
。
1 | if (ctx->currjob->status == ASYNC_JOB_PAUSED) { |
第三次进入async_fibre_swapcontext
,从dispatcher切换到job。
同样将dispatcher的env_init
置1,setjmp
保存环境到dispatcher的env中,此时job的env_init已经是1了,所以这次是通过longjmp
回到第二次切换setjmp
的地方,继续执行ASYNC_pause_job
后续的代码。
结束job: job->dispatcher
假设这次job正常执行结束了,即async_start_func
中的job->func
返回,此时job的状态变成ASYNC_JOB_STOPPING,然后再次从job切换回dispatcher。
第四次进入async_fibre_swapcontext
,跟第二次进入时一样,将job的env_init
置1,setjmp
保存job的环境,因为此时n->env_init
为1,所以longjmp
回第三次调用async_fibre_swapcontext
时的setjmp
位置返回。dispatcher在ASYNC_start_job
中继续下一次for循环,因为此时job状态为STOPING,所以执行如下清理操作,将返回值传递出去了:
1 | if (ctx->currjob->status == ASYNC_JOB_STOPPING) { |
回到外层ssl_start_async_job
返回ret,这样这个job就结束了。不过ASYNC_WAIT_CTX并没有清理掉,ASYNC_WAIT_CTX_free
是在SSL_free
里面调用的,它里面会清理fd。
以上便是job的完整生命周期。