用户态处理页异常
文/Jonathan Corbet
2014年10月7日
对性能的追求常常让开发者希望把内核的某些功能移到用户态,如此一来用户态的某一个专用程序就可以完成内核的工作了。理论上,这种方式会让执行速度更快,网络栈的的某些功能的实现往往就会采用这种方式。通常来说,把内存管理的功能移到用户态的尝试并不常见,但是这并不意味没有。内核社区的开发者Andrea Arcangeli最近就做出了这种尝试,把页异常的处理放到用户态来实现。
页异常的处理通常需要从硬盘里读取数据,再把数据写到发生页异常进程的地址空间。这个任务是内核来完成的,为什么有些人希望在用户态完成这一个动作呢?主要的出发点就是可以在把正在运行的KVM虚拟机迁移到其它主机上。虚拟机迁移意味着要移动虚拟机的内存,而这需要耗费很长的时间,但是有些用户希望迁移过程中的服务中断的时间尽可能的短。
比上述要求更高的是,用户根本发现不了迁移过程正在进行。一种方法就是只把虚拟机必要的内存迁移到新机器上。一旦虚拟机在新主机上运行了,它必然会访问到那些还没被迁移的内存。如果虚拟机管理器(virtual machine manager)能够捕捉到虚拟机的页异常,它就能快速地从原来主机上获取相应的内存。简而言之,要使得迁移过程造成的影响更小,就必须需要一种跨主机的页异常(cross-host demand paging)处理机制。
当然,除了跨主机的页异常处理机制,分布式的,跨主机的共享内存方式(shared memory distributed across the network)也是有可能达到相同目的。Andrea Arcangeli的设计方案里面首先增加get_user_pages的两个不同版本的接口,这两个接口可以让内核访问用户态的内存页:
long get_user_pages_locked(struct task_struct *tsk, struct mm_struct *mm,
<span style="white-space:pre"> </span>unsigned log start, unsigned long nr_pages,
<span style="white-space:pre"> </span>int wirte, int force, struct page **pages,
<span style="white-space:pre"> </span>int *locked);
long get_user_pages_unlocked(struct task_struct *tak, struct mm_struct *mm,
<span style="white-space:pre"> </span>unsigned long start, unsigned long nr_pages,
<span style="white-space:pre"> </span>int write, int force, struct page **pages);
前一个版本的函数会在已经取得mmap_sem信号量的情况下调用,它可能会在执行结束的时候释放信号量,并把*locked会置为零。后一个版本则假定是在mmap_sem未获取的情况下被调用的。这两个版本的函数都有一个好处,就是让页异常处理的过程中释放mmap_sem信号量,因为这样做会提高系统的性能(为什么呢?)。这一点对目前的内核来说也是至为关键的,如果页异常的处理委托给用户态程序,那么这样做则是必需的了。在持有mmap_sem信号量的时候去执行用户态代码显然是不容易被接受的。
下一个步骤则是将MADV_USERFAULT标识添加到madvise()系统调用中。若某区域的内存设了该标志位,内核将不会去处理该区域的页异常。在没有其它的方式去处理页异常的情况下(下面会讨论其它的方式),那么,发生页异常的进程(faulting process)便会收到SIGBUG信号,然后该进程就会调用remap_anon_pages这个新添加的系统调用为自己处理自己产生的页异常:
int remap_anon_pages(void *dest, void *src, unsigned long len, unsigned long flags);
该系统调用会把起始地址为src,长度为len字节的内存页移动到进程dest指向的内存空间。要使得该系统调用执行成功,须满足以下几个条件:(1)dest指向的进程空间没有被映射;(2)src指向的进程空间已经被映射,并且要具有present标识(即读取该区域的内存不会产生页异常);(3)没有其它进程共享src指向的内存。这些条件可以让该系统调用的实现更加简单。
如果src指向的大页(huge page),那么len必需是2MB的整数倍,这样大页在移动过程中才不会被分割。
当上面所述的东西准备就绪,进程的SIGBUS信号处理函数会分配内存,接着往里面填充相关数据,再调用remap_anon_pages()把内存映射到合适的位置。当用户态的信号处理函数处理完毕时,进程就会恢复之前运行,当它再次访问产生页异常的内存时,就可以顺利地执行下去,不再产生页异常。
根据上面的描述,一些非常熟悉类Unix系统信号机制的人就会想到,上述任务并不是属于信号处理函数的,而实际上,信号处理函数并不能如进程所希望那样来执行页异常的任务(为什么?)。为了使得事件更得简单,Adrea增加了以下的系统调用:
int userfaultfd(int flags);
该系统调用会返回一个可以与内核进行页异常通信的文件描述符。flags参数大多数情况下并不会被使用,但是如果设置了O_NONBLOCK标识,就可以请求非阻塞的行为。
进程获得文件描述符之后要做最一件事就是往该描述符写入一个64位的整数,这个整数表明它采用的那一种页异常处理协议。假如内核支持同样的协议,就会返回同一个整数,否则返回-1。当双方协商完成,进程就会从内核读到发生异常的64位地址,之后对该异常进行处理,最后往内核写入两个已经做了映射的指针。
上述方法的好处就是页异常进程可以指派单独一条线程来处理页异常。每当页异常发生后,产生页异常的线程会等待处理线程执行完毕,才会重新运行。如果进程调用了userfaultfd(),那么它也不会再收到SIGBUS信号,所以,对产生页异常的线程来说,不管是内核处理页异常还是用户态处理页异常,对它都是透明的,不同的是,页异常的处理时间可能会变长了。
如上所述,页异常的用户态处理可能会有几个不同的应用场景。但是假如某一个程序想同时应用这些场景呢?这种情况下,程序可以用userfaultfd()打开多个文件描述符,然后把它们分别限定在指定的内存区域。要做到这个限制,程序就必须往内核写入两个指向不同区域的指针,而第一个指针的最低位要为设为1,以表示内核要接收多个指针。因此,不同内存区域产生的页异常就会反应到相应的文件描述符。注意MADV_USERFAULT仍然需要设在这两个目标内存区域。另外多个内存区域可以对应同一个文件描述符,但是一个内存区域只能由一个文件描述符进行处理。
Andrea往remap_anon_pages这个系统调用添加了大量的注释。Linus最初对remap_anon_pages()是否比remap_file_pages()更有效充满了疑问。他认为该函数是一个大灾难,所以这个函数相信很快就会移除出主干内核了。之后Linus表示他更偏爱页异常处理线程可能通过某个接口,将页异常相关的数据写给内核,然后由内核分配和映射内存。Andrea回应说实现这样一个接口是有可能的。进程可以把相关数据写给userfaultfd()返回的文件描述符,内核接管之后的一切就行了。但是他倒担心这样的实现方式失去零拷贝的优势,而这正是他设计现在这个接口的目的。Linus的回答已经很清楚了,他完全不追求零拷贝行为,而且,这根本不值得。
尽管Linus不是十分满意,但是我们也许很快看到get_user_pages函数的另两个版本出现在内核主干上。剩下的工作就需要花更多的时间了,就目前的情况来看,remap_anon_pages已经似乎不太可能合并到内核主干上了。但是,出于真实的需求,线上迁移功能将会得到越来越多的关注,并且代码会越来越完善。
本文译自LWN网站2014年10月9日的文章http://lwn.net/Articles/615086/