本文基于OSDI-18收录的《Arachne: Core-Aware Thread Management》翻译整理而成。

osdi参考链接:https://www.usenix.org/conference/osdi18/presentation/qin

1、 简介

Arachne是斯坦福大学开发的一个用户态线程调度方案(协程),适用的场景是短生命周期的应用,如memcached和RAMCloud这类,他们的server线程生命周期通常只有几us。使用传统linux线程会带来较大的开销和延时。
Arachne的核心思想是让应用程序根据负载确定自己需要的core数量,程序知道哪些核是分配给自己的,同时控制适量的线程在这些core上运行;而core arbiter用来给应用程序分配这些core。
Arachne线程库经过优化,可最大限度的减少cache-misses,同时它可以在220ns内启动新的用户线程。
由于Arachne是在用户态实现的,完全不需要修改内核。

2、 用户态线程库的场景

Arachne被设计用于那些需要处理大量短生命周期请求的服务,同时还要保证较低的延时,比如memcached。
Memchached处理一个请求的时间大概是10us,正常情况下,内核线程的开销太大,无法为所有的请求创建单独的线程;所以memcached使用了线程池来处理这个问题,在程序启动时,创建一批线程池,当需要处理新的请求时,由一个分发线程将请求分派到某个工作线程来处理。
但线程池的线程数量固定的,在运行时,当可用core比线程数少时,将造成多个线程共用同一个core,这会导致未被调度的线程增加很大的延时,最好的情况是,一个工作线程需要单独使用一个core。
此外,在低负载期间,每个工作线程都可能处于idle状态,这将导致部分core进入节能模式,而从节能模式被唤醒的开销又非常大,这也会增加延时。Arachne尝试在低负载期间在较少的core上高密集的的运行,这也能获取更好的表现。
默认情况下,Arachne使用忙转的调度策略,这将使core的占用率达到100%。

3、 架构

传统应用在应对低延迟和高吞吐量时,很难权衡。大部分时候,应用程序无法告诉os他们需要多少core,也不知道哪些core是分配给自己使用的。因此应用程序无法调整其内部并形象以匹配可用的core,这可能导致部分core的利用率不足或者负载过高,而需要通过负载均衡来维持的话,将带来很多额外的开销,且延迟无法保证。
Arachne作为一个线程管理器,通过让应用程序看到它们正在使用的cores来解决这些问题,core arbiter给程序分配专用core,且分配的core可以保持给该应用使用较长的周期(几十ms)。应用程序知道自己分配到了哪些core,从而可以在这些cores上合理的分布它的工作线程。
Arachne的特点如下:

  • Arachne可以预估应用程序所需的core数量。
  • Arachne允许每个应用程序定义一个核心策略,该策略在运行时确定应用程序需要多少核心以及如何将线程置于可用核心上。
  • 它使用未就绪队列的调度队列,该队列为线程创建,调度和同步提供了低延迟和可扩展的机制。
  • 完全在用户态实现,不需要修改内核;core arbiter使用cpuset实现。Os在运行Arachne程序的同时,也可以运行非arachne的线程。 

arachne.png
图1 Arachne架构

Arachne的架构如图1所示,包括三个组件:

  • core arbiter包含一个独立的用户态进程用于管理cores并且将cores分配给不通的应用程序;同时每个应用程序链接一个arbiter lib用以和core arbiter做通信。
  • Arachne runtime用于创建一些内核线程并且用来管理和执行app的工作线程(个人理解这里的kernel thread是普通的linux线程,类似vcpu的概念,通过vcpu管理真正的业务线程),并且这里实现了线程的创建/删除、锁、条件变量,并且这些实现都是在用户态,比传统内核实现要快得多。
  • Arachne runtime还包含一个core的策略器,这个策略器通过arachne runtime收集应用程序的性能指标(负载等),根据指标决定分配多个cores,同时决定哪些线程在哪些cores上运行。
  • 用户可以实现自己的线程库,用以代替archane runtime和core policy。
  • 工作线程的调度采用非抢占模式,如果应用需要长时间运行,需要偶尔调用yeild()防止同一个core上其他的线程被饿死。

Arachne内部管理两种cores,一种是托管核,一种是非托管核。其中托管核用来创建arachne线程且托管核是由core arbiter来分配的,只有arachne的线程才会在托管核上运行;而应用自己创建的,如调用std::thread创建的线程,将全部步署在非托管核上运行。
在core arbier启动时,会将所有的物理cores分配到非托管核,并且将所有线程放入该非托管核群,这些线程创建的新线程也在非托管核。后期为了将core分配给Arachne程序,core arbiter会将core从非托管核群删除,纳入到托管核群,并分配给请求者,当应用程序不再需要这些托管核时,core arbiter可以将他们再次收回,并归还到非托管核群。这种架构可以让一个程序中同时存在arachne线程核普通linux线程,当然,托管核群的优先级比非托管核群要高,也就是说有申请core的请求过来,会优先将非托管群的core分配出去,但是非托管群最少也会保留一个core。

4、 降低cache-miss

在传统调度上,一个core上有一个或者多个runnable queue,当正在运行的线程睡眠或者退出时,调度器需要从runnable queue获取一个新的线程来运行,而从runnable queue添加或删除一个线程,需要修改的变量很多,如果queue是多核共享的,即使queue是lockless的,这也会引起大量的cache-miss。所以我们希望线程被唤醒时,必须将它添加到它原本所在的core上,但唤醒操作可能是由其它core上的线程来调用的,这将引起竞争。同时,线程的调度状态也会有读写竞争,通常会使用锁来保护它,但锁也会导致额外的cache-miss。
为了消除这些cache-miss,arachne不使用runnable queue,而是对当前core相关的所有线程进行扫描,直到找到一个可以运行的线程为止。
这种设计是基于以下两个前提:

  • 在给定时间内,一个core只为几个工作线程提供使用。
  • 遍历线程最大的开销,还是在线程state值的cache-miss,这个state通常由不同的core来操作以唤醒线程。变量的cache被其他核写入并刷新直到本核能读取到新值,大概需要经历100或更多的cycles,而调度器可以利用这个时间周期,继续遍历大量的线程。

Arachne对线程state变量做了无锁处理,在线程管理结构体中,用一个64位的wakeupTime来标示,当wakeupTime比当前cpu的cycles小,调度器认为该线程是可运行的,当调度器切换到该线程之前,调度器会将它的wakeupTime置为max。当线程进入阻塞时,不需要修改wakeupTime,因为max大于当前cpu cycles计数,任务不会被再次执行。要唤醒线程,只需将wakeupTime被设置为0,调度器在下一次调度时,会发现该线程成为一个可运行的状态,因为没有竞争条件,所以该state不需要使用锁来保护。

5、 效果

5.1、Memcached:
memcached.png
注:Memcached-A表示使用Arachne版本的mamcached 

5.2、RAMCloud:

ramcloud.png
从性能对比来看,在memcached和ramcloud这种特殊场景下,arachne带来的性能提升,包括延时和吞吐率都有比较明显的提升。