本文介绍了AC,一种在C/ c++等本地语言中用于可组合异步IO的语言构造。与传统的同步IO接口不同,AC允许线程发出多个IO请求,以便并发地对它们进行服务,因此长延迟操作可以与计算重叠。与传统的异步IO接口不同,AC保留了一种顺序的编程风格,不需要代码使用多个线程,也不需要代码被“栈分解”成回调链。AC提供了一个async语句来确定IO操作并发发出的机会,一个do…finish块来等待任何封装的异步工作完成,以及一个cancel语句来请求取消封装的do…finish中未完成的IO。我们给出了核心语言的操作语义。我们描述和评估了集成在Barrelfish研究操作系统上的消息传递和集成在Microsoft Windows上的异步文件和网络IO的实现。我们展示了AC为异步IO提供了与现有C/ c++接口相当的性能,同时提供了更简单的编程模型。
介绍:在未来,处理器很可能在整个机器上提供不同类型的核心,而不需要硬件缓存一致性。在Barrelfish项目中,我们正在研究如何为这类硬件设计一个操作系统(OS),在这个操作系统中,我们不再依赖操作系统[4]中的传统共享内存。
我们采用的方法是围绕独立的每核内核构建操作系统,并使用消息传递在运行在不同内核上的系统进程之间进行通信。其他当代操作系统研究项目采用了类似的方法[38]。我们的假设是,建立在消息传递之上的系统可以映射到各种各样的处理器体系结构,而无需大规模的重新实现。使用消息传递让我们能够适应具有异构核心类型的机器,以及没有缓存一致性的机器;我们可以将消息传递操作映射到专门的消息传递指令[18,34],也可以将它们映射到当前硬件[4]上的共享内存缓冲区。
然而,利用消息传递来编写可伸缩的底层软件是一件困难的事情。现有系统要么关注编程的便利性(通过提供简单的同步发送/接收操作),要么关注性能(通常通过提供异步操作,在发送或接收消息后执行回调函数)。同样的张力更普遍地存在于IO接口中[27,35]。例如,Microsoft Windows api要求软件在同步操作(每个线程只允许一个并发IO请求)和复杂的异步操作(允许多个IO请求)之间进行选择。
我们相信,硬件不可避免地向非缓存一致、异构、多核系统演化,这使得在C/ c++等低级语言中支持异步IO既必要又及时。
本文介绍了一种利用异步IO (AIO)编写程序的新方法AC(“异步C”)。AC提供了一种轻量级的AIO形式,可以将其增量地添加到软件中,而不需要使用回调、事件或多线程。
我们的总体方法是让程序员从简单的同步IO操作开始,并使用新的语言构造来确定语言运行时系统异步启动多个IO操作的机会。
作为一个正在运行的示例,考虑一个Lookup函数,该函数向名称服务进程发送消息,然后接收该名称映射到的地址。图1显示了使用Barrelfish的基于回调的接口编写的这个函数。
Lookup函数接受对一个通道©的引用。该函数注册一个ResponseHandler回调函数,以便在接收到一个LookupResponse应答时执行。然后它注册一个SendHandler回调,以便在通道c有空间发送消息时执行。(许多消息传递的硬件实现提供了大小有限的消息通道,因此不可能立即发送消息。)此外,Lookup需要在临时数据结构中记录名称,以便SendHandler可以使用它。On*函数是从NSChannel_t通道的接口定义中自动生成的。
对于AC,“查找”示例变成了一个使用同步Send/Recv操作的单个函数:(我们省略了一些关于取消未完成的IO操作的细节;我们回到第二节的取消。)
与基于回调的实现相比,这个LookupAC函数显然要简单得多:它避免了“栈转换”[3]的需要,在[3]中,操作之间的逻辑流在一系列回调之间被分割。AC导致了一种形式的可组合性,而这种可组合性在叠取过程中丧失了。一个函数可以简单地使用AC调用其他函数,并且可以同时启动多个AC操作。例如,要与两个名称服务器通信,可以这样写:
async at语句S1表示,如果第一次查找需要阻塞,则可以继续执行语句S2。finish构造表明,在S1和S2执行完成之前,不能继续执行S3语句。
在整个AC中,我们将用于异步的抽象与用于并行编程的抽象分开;除非程序员显式地引入并行性,否则代码仍然是单线程的。async和do…finish构造仅用于确定并发发布多个消息的机会;与X10[7]中的异步构造不同,我们的异步没有引入并行性。因此,除了do…finish的块结构同步之外,我们的许多示例都可以在没有并发控制的情况下编写。
除了AC的核心设计之外,我们还做了一些额外的贡献。我们引入了一个新的块结构的取消机制。这种取消的方法为程序提供了一种模块化的方式来启动异步操作,然后在它们尚未完成时取消它们;例如,在被调用的函数周围添加一个超时。在TwinLookupAC中,取消可以用来在另一个查找完成后立即放弃一个查找。与我们的方法相反,传统的取消机制针对单个IO操作[1],或针对同一文件上的一组操作,或针对一个完整的线程(例如,Modula-2+[5]中的警报)。
我们将在第2节详细介绍交流电。在第3节中,我们提出了一个核心语言建模AC的形式化操作语义,包括取消。我们给出语义并讨论它所满足的属性。
第4节描述了AC的两种实现。第一个实现使用了修改过的Clang/LLVM工具链来将AC操作添加到C/ c++中。第二个实现使用Clang或GCC操作,并使用宏和这些编译器提供的现有C/ c++扩展的组合定义AC构造。第二个实现的开销略高于第一个。
在第5节中,我们将讨论与Barrelfish上的消息传递集成的实现以及与Microsoft Windows上的异步IO集成的实现的性能。在每种情况下,AC都实现了手工编写的栈剥离代码的大部分性能,同时提供了一个可与基本同步IO相媲美的编程模型(也可与最近用于执行异步IO[32]的基于c#和f#的抽象相媲美)。我们将在第6节和第7节讨论相关工作并进行总结。
2.可组合异步在这一节中,我们将非正式地介绍空调。我们继续介绍中的名称服务查找示例。我们使用它来更详细地说明AC操作的行为,并激发我们的设计选择。
在本节中,我们的设计选择是由两个属性驱动的。首先,一个“串行省略”属性:如果在一个软件中IO操作完成而不需要阻塞,那么软件的行为就像AC扩展被删除一样。第二,一个“同步省略”属性:删除AC构造后,会留下一个使用普通同步操作的正确程序。相反,使用同步程序并添加这些构造将生成使用异步IO的程序。我们相信,这两个属性在简化现有应用程序使用异步的增量调整方面是有价值的(当然,仍然需要注意准确地确定哪些IO请求可以同时发出)。
在本节中,我们将重点关注基于Barrelfish上的消息传递的示例。在此设置中,AC发送/接收操作将阻塞,直到传出消息已在通道中缓冲,或直到传入消息已从通道中移除。与CSP[17]等语言中的消息传递操作不同,AC发送/接收操作彼此之间不同步。通道在一台机器的一对对进程之间进行操作。它们提供可靠的、有序的消息传递。唯一的一种故障是当一端的进程没有关闭通道而终止时,通道突然断开;这个失败被通知给其他进程,因此错误处理代码在这里的示例中没有出现。
从介绍中可以很容易地编写LookupAC这样的函数:同步消息传递避免了基于回调的接口的复杂性。然而,它也失去了好处:我们不能再同时执行多个发送/接收操作,我们不能再在等待IO操作完成时进行表单计算,我们不能在IO操作启动后放弃等待。
AC通过提供async和do…finish构造来解决这个问题,以允许多个IO操作被发出(章节2.1),并提供cancel构造用于块结构的等待取消(章节2.2)。
2.1 async和do…finish构造async和do…finish构造提供了一种机制,可以在某个操作阻塞时切换掉该操作,并在该操作解除阻塞后恢复执行。图2给出了一个示例,扩展了早期的LookupAC函数,以查询一系列服务器,并在结果不同时报告错误。LookupOneAC函数执行一次查找,返回结果地址。LookupAllAC函数接受一个通道数组,并对LookupOneAC进行一系列调用以执行每次查找。循环中的async表示如果给定的LookupOneAC调用阻塞,则执行可以继续到下一次迭代,而do…finish表示执行必须在完成时阻塞,直到所有异步工作完成。这个示例满足同步省略属性:如果忽略了新构造,那么它就变成了一个简单的、依次在每个服务器上进行的顺序查找。
这里有许多设计选择:
异步开始工作。首先,当到达一个语句时运行什么代码。, S本身,或async S语句的延续(如AME[19]),或它们是交错的(如X10 [7])?在AC中,执行立即进入S,而不引入并行执行。这个特性既遵循了串行省略属性,也遵循了我们在概念上希望将对异步和并行的支持分开的愿望。
这个设计选择的结果是,在图2中,异步声明内的代码可以简单地读我获取渠道:内部异步的代码语句直接运行在每个循环迭代的开始在我修改下一个执行循环的头。
该示例还利用了异步不引入并行的事实:当LookupOneAC返回时,不需要同步访问本地变量result、first result或seen first。例如,我们不需要对这些变量引入锁,也不需要使用future将LookupOneAC的值传递给它的调用者。
如果一个局部变量在一个异步语句中声明,那么它对该语句的每次调用都是私有的(不像图2中的例子,在异步语句的调用之间共享变量)。
在异步工作中阻塞。下一个设计选择是当代码在异步S块中时会发生什么。在AC中,当S第一个块时,继续执行异步语句。在这方面,async可以被看作是“捕获”它内部代码的阻塞,并为周围的代码提供一个机会来启动额外的IO操作,或者进行计算。在图2中,async语句的延续是循环头,它将继续进行下一个迭代。
当调用一个可能阻塞的函数时,程序员需要预测在被调用者返回之前可能运行其他代码的可能性。我们遵循一个惯例,所有可能阻塞的函数的名称上都有一个AC后缀。对于原始的发送/接收操作,以及LookupAllAC这样的示例,这种约定是正确的。遵循此约定可以确保调用者知道在等待时执行可能会切换到线程的其他位置。例如,在图2中,局部变量i的值可能会在调用LookupOneAC时被更新,因此如果需要原始值,那么应该在调用之前保存它。
突出显示AC操作的约定与“默认原子”编程模型(如AME[2,19]和TIC[30])中的规则相对应,即非原子的操作应该在函数定义和每个调用点上包含注释。如果需要的话,可以通过静态检查来加强我们的约定。一种简单、保守的方法是,如果从非AC函数调用AC函数,则发出警告。
同步。最后一个设计选择是如何与异步操作同步。do. finish结构提供了这种形式的同步:执行不会超过do…finish,直到它内部开始的所有异步工作都完成。在图2中,do…finish要求所有LookupOneAC调用在LookupAllAC返回之前完成。从LookupAllAC的调用者的角度来看,在do…finish结束时阻塞和在IO操作时阻塞是一样的:调用可以放在async中,如果调用阻塞(例如,不同的LookupAllAC以其他名称阻塞),则可以启动其他工作。
启动异步工作的规则。
异步语句必须在do…finish中静态出现。写不平衡的代码是不正确的,例如:
这个设计选择遵循了我们对C/ c++系统软件实现的关注。与预测的寿命数据用于同步:(i)仙人掌——堆栈可以使用[16],而不需要一个更一般的基于堆的结构,和(2)像往常一样,被可以安全地访问进行数据传递给它,是否任何的无关地调用堆栈是异步的。(CILK也有类似的规则,函数隐式地与它生成[13]的任何并行工作进行同步。)
与线程集成:尽管async没有引入并行性,但我们的实现还是与OS线程集成在一起的。这种集成支持以下场景:多线程服务器在不同线程中处理到不同客户端的连接,或者函数显式启动线程,在函数返回后执行工作(如上面StartAsync的变体)。
运行时系统提供并发控制原语,如互斥和条件变量。这些原语可以在同一操作系统线程中的异步工作片段之间使用,也可以在不同操作系统线程之间使用。并发控制原语上的阻塞处理方式与IO上的阻塞处理方式完全相同:OS线程的执行可以切换到不同的工作。这个工作可以是一个封闭的异步语句的延续,也可以是一个未阻塞的工作。Work保留了对启动它的OS线程的亲和性。原始的消息发送/接收操作本身是线程安全的。
2.2 取消async和do…finish构造让程序员启动多个IO操作,它们让程序员将计算和通信重叠起来。然而,这些构造并不能恢复底层基于回调的api的所有表达能力;特别是,我们还希望能够在IO操作启动后停止等待。
AC提供了一个取消命令,允许程序停止等待IO操作。取消在某种程度上类似于Java中的线程中断:它导致IO上阻塞的操作被解除阻塞。
图3显示了如何使用取消写LookupFirstAC函数查询一组名称服务器返回的第一反应,然后取消其余查询:内循环做. .完成标记“查询”,查询和取消命令执行时的第一反应。取消会导致do…finish中任何被阻塞的LookupOneAC调用被解除阻塞,然后返回CANCELLED。finish块的行为和往常一样:一旦在它里面开始的所有异步工作都完成了,执行就可以继续到LookupFirstAC的末尾。
当与标签一起使用时,cancel必须在标签标识的do…finish块中静态发生。如果不带标签使用cancel,则它指向最近的静态封闭的do…finish块。如果没有静态封闭的do…finish块,则取消是错误的。这个要求明确了正在取消的操作块:一个人不能调用一个函数,然后通过在内部使用取消而让它意外地“毒害”调用者的函数。
语义的取消。当取消涉及有副作用的操作时,需要小心。即使对于像LookupOneAC这样的“只读”操作,也存在取消后通道状态的问题。例如,如果取消发生在消息发送之后,但在接收到响应之前,会发生什么?如果响应随后到达会发生什么——它会与对不同消息的响应相混淆吗?
在Barrelfish和Microsoft Windows上,我们都遵循一个约定,我们称之为“精确取消”:一旦取消,要么(i)调用返回CANCELLED,而没有执行请求的操作,要么(ii)操作执行后函数返回OK。特别是,如果一个IO操作由一个设备同时完成,而软件正在请求取消,那么即使在执行cancel之后,仍然可以看到OK结果。
这个约定代表了应用程序程序员和IO库实现者之间的工作分工:IO库保证在它自己的函数中提供精确的取消,但应用程序程序员负责在他们编写的函数中提供精确的取消。程序员必须承担这个责任——因为正确的行为依赖于他们所编写的操作的语义。,是否必须执行补偿操作,如果必须执行,具体是什么。
为了允许在不被取消的情况下编写补偿代码,AC让函数被标记为“不可取消”,并且在Barrelfish上,我们提供了所有消息传递原语的不可取消变体。在第4节中,我们将展示这些不可取消原语是如何在AC运行时系统上实现的。
可取消。考虑图4中的示例,该示例向LookupFirstAC调用添加一个超时。第一个异步启动LookupFirstAC请求。第二个异步启动一个计时器。无论哪个操作第一次完成,都会尝试取消另一个操作。这种块结构的方法允许程序以可组合的方式使用取消:在LookupWithTimeoutAC中触发的取消会传播到两个异步分支(并递归地传播到它们的调用,除非这些调用是不可取消的)。
与针对单个操作的取消请求的系统不同,AC允许调用者取消一组操作,而不能单独命名它们。
注意,在LookupWithTimeoutAC函数中,返回值总是取自LookupFirstAC。在检查返回值是否正确时,需要考虑三种情况。首先,如果LookupFirstAC返回OK,那么确切的取消语义意味着已经执行了查找,并且结果可以像往常一样传回来。第二,如果SleepAC超时过期并取消了LookupFirstAC,那么得到的CANCELLED返回值是正确的。最后,如果LookupWithTimeoutAC本身被其调用者取消,那么LookupFirstAC调用的结果将决定LookupWithTimeoutAC操作的总体结果。



