写在前面

这是一篇译文,译者水平有限,如果对文章内容比较感兴趣,建议看一下原文An Attempt at Reducing Costs of Disk I/O in Go

0.概要

并发是个比较难理解的东西。Go是谷歌创建的一门编程语言,它通过轻量级线程(协程)提供了容易理解的并发抽象。减轻了并发编程的难度。为了支持这些Goroutine,Go使用runtime将Goroutine多路复用到OS线程上,为了简化磁盘IO,Go保留了一组IO线程,用于阻塞IO以提升CPU的使用率。在我们的工作中,探索出一个可替换IO线程池的方案。我们提出一种实现,用一个线程来接收、合并并执行对磁盘I/O的请求,无需创建专门的I/O线程池。此实现将向用户暴露相同的磁盘IO接口(读、写、打开、关闭)。为了实现此目的,我们尝试使用Linux异步IO(AIO)系统调用来执行非阻塞的磁盘操作。最后虽然我们看不到性能的提升,但我们认为这是由于Linux内核对AIO支持有限所致。本文中,我们介绍了我们的动机(第一节),背景知识(第二节),实现(第三节),演示和结果展示(第四节),最后,由于限于操作系统的内核支持,我们无法实现最初的目标,因此我们讨论了未来工作的选择(第五节)。

1.动机

要正确地写并发程序,很难而且繁琐。现在有很多解决方案来减轻程序员的痛苦。但是,这些方案中的很多方案并不是基于某一个特定语言的。这些方案:OMP,CILK,Pthreads 和 C++11 线程是附加到现有语言上。并且各充当绷带。程序员不限于这些上下文中的每一个线程的具体实现。由于程序员本身观点和可组合性问题的冲突,随着程序员数量的增加和项目规模的增加,这可能导致代码混乱和错误。

reducing-costs-of-disk-IO-in-go-1.png

2012年Go发布了1.0版本。Go解决了OMP和其他类似问题,Go将并发性深根于语言的语法中,生成execution1协程就像编写functionNameIn 图1 一样简单,我们比较了多线程“ hello world”的C和Go之间的区别。 仅按行数计算,显然Go版本会获胜。

本质上,Pthread和简单的同步原语是多线程编程的“汇编语言”。许多范例试图通过各种框架和运行时将这些原语抽象化,但是由于没有真正母语的支持,没有人将自己确认为权威方法。Go易于使用,并且更重要的一点是为并发编程提供了一个简单的内置解决方案。通过并发编程利用多核系统是继续观察莫尔斯定律在未来几年加速中的趋势的关键。我们相信Go可以很好地做到这一点,并且我们有兴趣帮助突破Go自身内部线程调度效率的极限。 总的来说,我们对Go这样的精简系统语言非常感兴趣,简单的同时也可以轻松地用于更高级别的编程,例如构建REST / HTTP端点——这在C语言中是大多数人梦寐以求的——性能和简洁性。

我们看到了Go广泛的应用于生产环境,并且认为我们需要一个简单易用,高性能的并发编程语言,因此我们开始更深入的研究Go。

2.背景

在解决问题之前,需要介绍一下关于Go调度器的知识。Go中的线程(特别是轻量级线程)被称为Goroutine或G。内核线程被称为M。这些G被调度到M上,即所谓的G:M线程模型,或更常用的M:N线程模型,用户空间线程或green线程模型。此线程模型在Go中启用了易于使用的轻量级线程。为了简化此线程模型,Go语言中存在一个运行时,该运行时将其自己的G green线程调度到M内核线程上,Go语言中存在一个运行时,该运行时必须将其自己的G green线程调度到M内核线程上,green线程的好处是减少了内核线程中的开销,例如进入内核执行上下文切换的成本,在为堆栈保留整个页面时浪费了内存,并且green线程为futex等待提供了更快的替代方法。在多路复用线程模型中,Go实现了窃取工作以提高线程利用率。 如果为了执行系统调用而阻塞了一个线程M0,则另一个OS线程M1(在其工作队列中没有任何Goroutines)将尝试将Goroutines从M0的工作队列中删除,并且M1将启动执行它们。

自从1.0版本发布以来,Go运行时已经进行了一些重大改进。早期的性能问题逐步得到改善。自从Go的概念由Dmitry Vyukov设计以来,对运行时进行的最大的更改之一就是添加了一个P结构,该结构表示处理器的核。通过添加此附加抽象,Go运行时可以更了解系统资源。为了在其工作队列上执行Goroutines,M必须持有P。 P也可以被其他M窃取,但是,为了减少程序上下文的切换,提高缓存性能,如果存在没有P的空闲M,则不会窃取P。最后,优先考虑将G重新安排到同一M上。M和G之间的局部性增加会提高与物理处理器的亲和力,从而提高缓存性能。 Go运行时的这一更改提高了系统资源的利用率和整体性能。但是,Go运行时还很年轻,仍然有很多改进的机会。

目前,Go的轻量级线程与许多支持绿色线程的其他语言一样遭受同样的问题。当在Goroutine上执行的线程尝试使用write()系统调用(或其他阻塞的syscall)写入磁盘时,操作系统将调度内核线程。 Linux内核无法知道仍会利用该线程。如图2a所示,为了继续使用所有处理器内核,Go调度程序将保留一组专门用于I / O的额外线程。 在进入系统调用之前,Go运行时必须首先找到线程池中的一个线程,然后在该线程上执行系统调用。 这意味着执行系统调用上会有更多的资源开销,维护着不总被使用的线程池也是资源的浪费。 Go现在为这些附加线程中的每一个创建一个M结构,使得这个问题更加严重。

为了减少在Go中执行磁盘I / O的成本,我们探索了Go的I / O线程池的替代方案。 我们提议在调度器(如图2b所示)中接收来自Goroutines的I / O的请求, 请求的Goroutine将一直阻塞,直到调度程序完成他们的请求为止,但是Goroutine阻塞的M将从其工作队列中取出另一个G(或窃取一个G)并继续执行。 为了执行磁盘操作并继续接收请求,我们的调度程序将利用Linux异步I / O(AIO)系统调用来批量处理请求,并允许内核在后台处理。

reducing-costs-of-disk-IO-in-go-2.png

由于AIO系统调用很少使用,并且读者可能不熟悉它们的用法,因此我们想通过将它们与BIO进行比较来介绍其用法的语义,当程序员希望在传统的N:N线程模型上写入磁盘时,她可能会使用POSIX系统调用write。这将进入OS内核并导致调度内核线程,从而暂停在该线程上执行程序,直到将数据写入磁盘(或磁盘缓存)为止,为了提高线程利用率,程序员可能改而使用异步Linux系统调用io 。该系统调用仍将进入内核,但是,内核不会在执行写操作时将其置于睡眠状态来阻塞线程的执行,而是将执行返回给程序员线程并在后台完成写操作。当内核完成写入或写入失败时,线程将收到来自内核的中断,从而导致用户程序陷入信号处理程序中。 在此信号处理程序中,用户可以指定写入完成后必须执行的操作。使用信号可使程序员充分利用其线程资源。 信号处理的替代方法是轮询。 轮训使用起简单,但是会浪费资源。

reducing-costs-of-disk-IO-in-go-3.png

Linux具有针对磁盘AIO的自己的非标准API。为了执行AIO,必须采取以下步骤:首先,我们调用io setup(),它传递了我们希望处理的最大并发请求数。io setup()返回一个io上下文,我们将用它来请求将来的AIO操作(类似于文件句柄),第二步,我们准备特定的AIO结构,跟踪该结构以便以后检查返回值。接下来,我们调用io prep pread()或io prep pwrite(),给定上面的上下文对象,IOCB结构(将通过此调用填充),文件描述符以及输入或输出缓冲区。完成此操作后,我们使用io Submit()提交IO请求,该请求将使用上下文以及上面的IOCB以及实际的syscall元数据。最后,使用上面的IO上下文(getevents())进行轮询。ioevent结构会被填充这些数据:返回值,错误和数据。这并不是非常复杂,但是却不像简单地先调用open(),然后调用read()或write()那样简单。 除了跟踪简单的文件描述符外,还需要跟踪更多状态,我们需要跟踪哪个AIO请求缓冲区与哪个io上下文相关。对于异步代码,很难推断出程序的正确性和顺序,并且通常意味着更多的代码和更长的调试周期。

3.实现

收集背景信息之后,我们的第一步是在Go中做AIO系统调用,这花了我们很多时间和大量的挖掘工作。我们最开始计划使用CGo,但是从Go调用C代码会带来各种问题,例如分配整个C线程堆栈,使得垃圾回收更复杂,将正在执行的C代码移到自己的线程中,由于这些复杂的因素,使用CGo并不是一个很好的选择,为了避免使用CGo引起的开销,我们决定将AIO系统调用直接添加到Go编译器的syscall包中。

通过编译器暴露了系统调用,我们开始设计库。出于两个原因,我们将调度程序设计成一个独立的库,而不是将其放置在Go运行时中,第一,Go编译器中的库与从另一个源导入的库之间产生的可执行文件并没有区别。第二,时间限制,获得一个可用的库比进一步修改编译器更加可行。我们知道构建稳定API的重要性,所以决定保留BIO调用的API。AIO调度程序中诸如Write()、Read()之类的调用保持了与BIO相同的阻塞语义。但是Write()和Read()现在将各自的请求通过Go channel发送到我们的AIO调度程序中。

我们在该项目中面临的最大挑战之一就是试图找到有关单文件AIO的连贯解释。许多有关“ Linux AIO”的在线资源实际上是指POSIX AIO.7,由于POSIX AIO使用BIO和I / O线程池(我们要在Go中解决的问题),因此它不符合我们的需求,在开始处理请求处理程序之前,我们先在C中做了少量的示例。

在完成所有基础工作之后,开始构建轻量级IO调度库,如图2b所示,请求处理程序在一个锁定其自己线程的Goroutine上运行,而不是像图2a的在多个Goroutine中等待。调度程序接收到新操作后,会将其排队以等待OS进行异步处理。 当调度程序当前未接收到任何新操作时(优先级排序),它会检查之前提交的AIO操作是否已完成,如果已完成,则将其标记为已完成。

一旦完成了调度程序的基本实现,我们就会进行各种优化,以使AIO获得可接受的性能。第一个优化是上下文管理器,它可以管理io上下文,当每个AIO操作生成一个上下文时,这种优化起到了很大作用。我们的另一个主要优化是能够将AIO操作合并和批处理为单个AIO系统调用。现在,我们不仅能够简单地提交单个请求,然后等待该请求完成,然后再为其他任何操作提供服务,也能够同时处理多个的操作。为了测试我们的调度程序并促进性能结果的收集,我们创建了一个testbench应用程序,它在阻塞和非阻塞I / O调度程序之间进行了交换。测试平台将生成给定数量的Goroutine,这些Goroutine将通过读取或写入请求调用被测调度程序。 为了实现性能的跟踪,我们创建了一个跟踪库,该跟踪库会将计时信息记录在内存中直到测试完成,一旦测试完成,就会输出到文件中进行处理。 利用此跟踪信息,我们能够识别出需要优化的区域。 我们的跟踪结果也帮助我们实现了一项关键成果:由于Linux中功能缺失,AIO实际上实现为阻塞。

4.结果

使用跟踪库(在第3节中进行了描述),我们观察了使用BIO从读取和写入中返回成功所花费的时间。 我们将这些时间与使用图4中的AIO调度程序观察到的时间进行比较。这些结果来自我们的测试平台,我们使用Go的旧实现(阻塞)执行BIO系统调用,和AIO调度程序进行对比。一次运行两个Goroutine,是因为这使我们能够观察到通过批处理请求获得的性能提升,忽略了可能由于多个线程之间的上下文切换而引起的差异。

reducing-costs-of-disk-IO-in-go-4.png

我们注意到的第一件事是,在执行较小的读取和写入操作时,我们的非阻塞方法的性能明显比传统的阻塞方法差。 我们对此的解释是,AIO系统调用增加了太多的开销,这导致性能问题,可以通过改进实现来解决。 如我们的实现讨论中所述(第3节),我们试图通过批量处理请求和重用io上下文来解决其中的一些开销。 但是,在进行了这些改进之后,我们再也无法考虑可能导致更好性能的任何更改。 我们还认识到,查看读写返回时间并不是最佳的性能指标。 为了了解线程利用率,我们需要查看在系统调用中旋转和/或阻塞所花费的时间。 因此,我们决定更深入地追踪我们的系统。

reducing-costs-of-disk-IO-in-go-5.png

因为我们的跟踪库在收集所有跟踪之前不执行IO,而是将信息记录在内存中,所以我们能够利用该库进行非常细粒度的跟踪。为了更好地了解我们在哪里可以找到其他优化机会,我们跟踪并监视了在执行磁盘AIO时使用的所有系统调用的执行时间。在图5中,我们再次选择使用两个Goroutine运行我们的测试套件,并且,将请求的大小从1,000字节更改为1,000,000字节。我们看到,运行IoSubmit所花费的时间与请求的读取大小成正比,而其他系统调用执行时间都没有改变。在异步IO的上下文中,随着操作规模的增加,系统调用花费更长的时间令人困惑。 我们希望AIO在处理小请求和大请求时会占用相同的时间,因为大部分工作应在后台(即异步)完成。 但是,从我们的结果来看,情况显然并非如此。

我们发现Linux内核中对AIO的支持是受限制的,并且自从2.5内核中引入以来就一直非常有限。 仅对使用O DIRECT标志打开的文件提供Linux AIO支持。 迄今为止,尚无任何普通的Linux内核(最高4.9)支持通过Linux的虚拟文件系统(VFS)执行AIO。 相反,在必须通过VFS的文件上调用IoSubmit时,系统调用将阻塞,直到操作完成。 这意味着实际上应该是一个异步系统调用现在变成了一个甚至更慢的阻塞系统调用,因为需要更长的代码路径。 除了这个更长的系统调用之外,我们仍然维护着更多的状态,我们需要调用IoGetevents来发现我们的“异步”系统调用已经完成。

使用O DIRECT打开文件意味着对文件的访问现在直接进入磁盘。 这意味着由于不再使用内核磁盘缓存机制,因此同一块上的后续磁盘操作将无法正常运行。 更糟糕的是,读取和写入操作必须与磁盘块边界和磁盘块大小的倍数对齐。这对我们的工作意味着什么,即使我们忽略使用O DIRECT打开对性能的影响,用户也不会获得对他们的文件的相同读写操作。 如果用户尝试向使用O DIRECT打开的文件中写入单个字符,则一旦关闭,该文件将包含该单个字符,其后为511个空字节。基于以用户透明的方式修改Go中的IO的最初目标,直接磁盘访问不是一种有效的选择,性能和读/写语义都不符合Go当前的IO API。Go用户期望当前API提供的传统,缓存的(高性能)字节精细操作。

reducing-costs-of-disk-IO-in-go-6.png

由于时间限制,我们无法在测试平台中完全实现对O_DIRECT文件访问的支持 (因为它使使用变得非常复杂)。 但是,我们能够通过单线程访问具有各种读取大小的文件来收集结果。 在图6中,我们显示了早期结果,该结果将我们的非阻塞调度程序与Go使用直接访问的可变读取请求大小的阻塞方法进行了比较。这些初步结果令人鼓舞。 总结,我们无法提高Go中调度磁盘IO请求的性能。 我们认为,这不是由于AIO调度程序引入的成本,而是由于不存在对AIO VFS操作的内核支持。 直接磁盘访问的限制意味着我们无法以透明的方式修改Go,直接访问会导致重新访问数据时性能大大下降,并且用户将无法按字节粒度修改文件。

5.未来工作

尽管我们的IO调度程序对于普通用户而言并不是一个进步,但我们发现在将来的工作中可能会有探索的用途。使用Linux中AIO的当前状态,我们可以看到调度程序有可能用作数据库磁盘IO的库。 O DIRECT标志本身最常在数据库中使用,因为它们管理自己的缓存并尝试声明对系统的尽可能多的控制。 我们相信我们的调度程序可以成功地用作数据库等应用程序的IO包,程序员可以在其中理解使用O_DIRECT对性能的影响。 除了数据库库之外,我们还看到了在调度程序之上分层磁盘缓存库的可能性,以便继续使用Linux内核中的现有AIO,同时避免未缓存的性能问题。但是,这种方法存在很多问题。首先,每个Go程序现在都将管理磁盘缓存,因此它们每个都将浪费自己的程序存储空间。程序第二次启动时,将需要一些时间来重新填充磁盘缓存。通用磁盘缓存最好在内核中全局执行,因此在我们的调度程序之上添加缓存也不是通用解决方案。

由于用户空间解决方案似乎并不是通用的,因此我们转向增加内核支持。我们发现了多个内核补丁,这些补丁为Linux添加了对VFS AIO的支持。我们相信,尽管这些补丁可能对将来我们的实现测试很有用,但不值得进一步探索。Linus Torvalds对AIO的多个修补程序请求做出了负面反应,如果我们进一步走这条路,我们的解决方案将仍然无法为用户广泛使用。期望Go的用户修补其内核是不切实际的,因此探索这些现有修补程序将是不明智的。 宁愿创建通用的异步系统接口,也不愿修补Linus认为是损坏的系统(AIO系统调用)的东西。 然后,此接口的用户可能会要求多个不同系统调用以异步执行的结构。 由于这是将AIO支持引入主线Linux内核的最可能渠道,因此我们认为这是进行未来工作的最佳方法。