加入收藏 | 设为首页 | 会员中心 | 我要投稿 核心网 (https://www.hxwgxz.com/)- 科技、建站、经验、云计算、5G、大数据,站长网!
当前位置: 首页 > 建站 > 正文

硬核!Rust异步编程方式重大升级:新版Tokio如何提升10倍性能详解

发布时间:2019-10-24 04:19:00 所属栏目:建站 来源:高可用架构
导读:协程或者绿色线程是近年来经常讨论的话题。Tokio作为Rust上协程调度器实现的典型代表,其设计和实现都有其特色。本文是Tokio团队在新版本调度器发布后,对其设计和实现的经验做的总结,十分值得一读。 Tokio作为 Rust 语言的异步运行时,我们一直在为它的

Header和Trailer都是执行任务所需的状态,状态被划分为“热”数据(header)和“冷”数据(trailer),即,经常访问的数据和很少使用的数据。热数据放置在结构的头部,并保持尽可能小。当CPU取消引用任务时,它将一次性加载高速缓存行大小的数据量(介于64和128字节之间)。我们希望该数据尽可能有价值。

减少原子引用计数

最后一个优化在于新的调度程序如何减少原子引用计数。任务结构有许多未完成的引用:调度程序和每个唤醒程序都拥有一个句柄。管理此内存的方法是使用原子引用计数。此策略需要在每次克隆引用时进行一次原子操作,并在每次删除引用时进行一次相反的原子操作。当最终引用次数为0时,将释放内存。

在旧的Tokio调度程序中,每个唤醒器都有一个对任务句柄的引用计数:

  1. struct Waker { task: Arc<Task>,} 
  2. impl Waker { fn wake(&self) { let task = self.task.clone; task.scheduler.schedule(task); }} 

唤醒任务后,将调用task 的clone方法(原子增量)。然后将引用置入运行队列。当处理器执行完任务时,它将删除引用,从而导致引用计数的原子递减。这些原子操作虽然代价很低但是积少成多。

std :: future任务系统的设计人员已经确定了此问题。据观察,当调用Waker :: wake时,通常不再需要原来的waker引用。这样可以在将任务推入运行队列时重用原子计数。现在,std :: future任务系统包括两个“唤醒” API:

  • wake带self参数
  • wake_by_ref带&self参数。

这种API设计迫使调用者使用wake方法来避免原子增量。现在的实现变为:

  1. impl Waker { fn wake(self) { task.scheduler.schedule(self.task); } 
  2.  fn wake_by_ref(&self) { let task = self.task.clone; task.scheduler.schedule(task); }} 

这就避免了额外的引用计数的开销,然而这仅仅在可以获取所有权的时候可用。根据我的经验,调用wake几乎总是通过借用而非获取引用。使用self进行唤醒可防止重用waker,在使用self时实现线程安全的唤醒也更加困难(其细节将留给另一个文章)。

新的调度程序端通过避免调用wake_by_ref中的clone来逐步解决问题,从而其和wake(self)一样有效。通过使调度程序维护当前处于活动状态(尚未完成)的所有任务的列表来完成此功能。此列表代表将任务推送到运行队列所需的引用计数。

这种优化的困难之处在于,确保调度程序在任务结束前不会从其列表中删除任何任务。如何进行管理的细节不在本文的讨论范围之内,有兴趣可以参考源代码。

使用Loom无畏并发

众所周知,编写正确的、并发安全的、无锁的代码不是一件容易事,而且正确性最为重要,特别是要尽力避免那些和内存分配相关的代码缺陷。在此方面,新版调度器做了很多努力,包括大量的优化以及避免使用大部分 std 类型。

从测试角度来说,通常有几种方法用来验证并发代码的正确性。一种是完全依赖用户在其使用场景中验证;另一种是依赖循环运行的各种粒度单元测试试图捕捉那些非常小概率的极端情况的并发缺陷。这种情况下,循环运行多长时间合适就成了另一个问题,10分钟或者10天?

上述情况在我们的工作中是无法接受的,我们希望交付并发布时感到十足的自信,对 Tokio 用户而言,可靠性是最为重要的。

因此,我们便造了一个“新轮子”:Loom,它是一个用于测试并发代码的工具。测试用例可以按照最朴实寻常的方式来设计和编写,但当通过 Loom 来执行时,Loom 会运行多次用例,同时会置换(permute)在多线程环境下所有可能遇到的行为或结果,这个过程中 Loom 还会验证内存访问正确与否,以及内存分配和释放的行为正确与否等等。

下面是调度器在 Loom 上一个真实的测试场景:

  1. #[test]fn multi_spawn { loom::model(|| { let pool = ThreadPool::new; 
  2.  let c1 = Arc::new(AtomicUsize::new(0)); 
  3.  let (tx, rx) = oneshot::channel; let tx1 = Arc::new(Mutex::new(Some(tx))); 
  4.  // Spawn a task let c2 = c1.clone; let tx2 = tx1.clone; pool.spawn(async move { spawn(async move { if 1 == c1.fetch_add(1, Relaxed) { tx1.lock.unwrap.take.unwrap.send(); } }); }); 
  5.  // Spawn a second task pool.spawn(async move { spawn(async move { if 1 == c2.fetch_add(1, Relaxed) { tx2.lock.unwrap.take.unwrap.send(); } }); }); 
  6.  rx.recv; });} 

上述代码中的 loom::model部分运行了成千上万次,每次行为都会有细微的差别,比如线程切换的顺序,以及每次原子操作时,Loom 会尝试所有可能的行为(符合 C++ 11 中的内存模型规范)。前面我提到过,使用 Acquire进行原子的加载操作是非常弱(保证)的,可能返回旧(脏)值,Loom 会尝试所有可能加载的值。

在调度器的日常开发测试中,Loom 发挥了非常重要的作用,帮助我们发现并确认了10多个其他测试手段(单元测试、手工测试、压力测试)所遗漏的隐蔽缺陷。

(编辑:核心网)

【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!

热点阅读