Rust in Everything
基于 tokio 1.54.1

任务的内存布局

主线位置:任务主线的 spawn 之后、进队列之前。OwnedTasks::bind 调 new_task,这一步把 future 搬到堆上。

flowchart TB
  raw["RawTask / Task / Notified / JoinHandle / Waker<br/>都只存一个指向 Header 的指针"] --> H
  subgraph cell["Cell:一次 Box 分配,按缓存行对齐"]
    direction TB
    H["Header(热):state · queue_next · vtable · owner_id"]
    C["Core:scheduler · task_id · stage(future 或输出)"]
    T["Trailer(冷):owned 链表指针 · join waker · hooks"]
    H --- C --- T
  end
💡TIP
打个比方:一个快递箱

把 Cell 想成一个快递箱。Header 是贴在箱子正面的面单:分拣员(调度器)只看面单就能干活,上面有状态、传送带上的下一个箱子(queue_next)、处理说明(vtable)、归哪个仓库管(owner_id)。Core 是箱子里的货,路上装的是 future,送到之后换成输出。Trailer 是箱底的回执联,只在发货和签收时翻出来。

所有引用记的都是「面单在哪」。面单贴在箱子最前面(偏移 0),找到面单就找到了整个箱子。比方到这里为止:真快递箱按货定大小,tokio 的箱子在 spawn 那一刻就定死了大小,要同时装得下 future 和输出中较大的那个。后面「动脑筋」会让你算一算。

一个任务就是一个 Cell

task/core.rs 第 127 行:

#[repr(C)]
pub(super) struct Cell<T: Future, S> {
    /// Hot task state data
    pub(super) header: Header,

    /// Either the future or output, depending on the execution stage.
    pub(super) core: Core<T, S>,

    /// Cold data
    pub(super) trailer: Trailer,
}

T 是 future 的类型,S 是调度器句柄的类型(多线程运行时是 Arc<multi_thread::Handle>)。三段放在同一次 Box::new 里,spawn 一个任务只做这一次堆分配。

两个属性决定了布局:

  • #[repr(C)]:字段按声明顺序排列,header 一定在偏移 0。于是指向 Cell 的指针和指向 Header 的指针是同一个地址,可以互相 cast。后面所有的类型擦除都建立在这一点上。
  • 按缓存行对齐:源码在 repr(C) 上面按架构加了 repr(align(N)),x86_64、aarch64、powerpc64 是 128 字节,其余架构按各自的缓存行大小取 32、64 或 256。注释解释了 128 的来历:从 Sandy Bridge 开始,Intel 的空间预取器一次拉相邻的两条 64 字节缓存行,按 64 对齐仍然会和相邻的任务伪共享。
📝NOTE
冷知识:一次分配和 128 字节对齐,都是后来才有的

tokio 0.1 的执行器只接受 Box<Future<Item = (), Error = ()>>:任务没有返回值,spawn 也不给句柄。想拿结果,得在任务里自己塞一个 oneshot,每个任务多一次分配。2019 年 2 月的 issue #887 提出让 spawn 总是返回句柄,同时不付这次分配。同年 10 月重写线程池的 #1657 把它做了出来:future、输出和 join 句柄要用的状态放进同一次分配,随 0.2.0 发布。Stage 里的 Finished 就是那时候来的。

按缓存行对齐要到 2023 年 6 月的 #5809 才加上(1.29.0),理由是相邻两个任务可能落在同一条缓存行上。各架构取多大,源码注释写明是从 crossbeam-utils 的 cache_padded.rs 抄来的。

Header:调度器天天碰的部分

task/core.rs 第 169 行:

#[repr(C)]
pub(crate) struct Header {
    /// Task state.
    pub(super) state: State,

    /// Pointer to next task, used with the injection queue.
    pub(super) queue_next: UnsafeCell<Option<NonNull<Header>>>,

    /// Table of function pointers for executing actions on the task.
    pub(super) vtable: &'static Vtable,

    /// This integer contains the id of the `OwnedTasks` or `LocalOwnedTasks`
    /// that this task is stored in. ...
    pub(super) owner_id: UnsafeCell<Option<NonZeroU64>>,

    // ...(tracing_id,仅 tokio_unstable + tracing)

    /// The last time this task was scheduled. Used to measure schedule latency.
    pub(super) scheduled_at: UnsafeCell<ScheduleLatencyInstant>,
}
字段用途在哪一章展开
state一个 AtomicUsize:状态位加引用计数一个 usize 的状态机
queue_next全局注入队列是侵入式链表,「下一个」指针就存在任务自己身上,入队不用再分配节点全局注入队列
vtable类型擦除后找回具体类型的入口,见下文本章
owner_id任务属于哪个 OwnedTasks,用来防止把 A 运行时的任务交给 B 运行时去 pollOwnedTasks 与关闭
scheduled_at入队时刻,算调度延迟用;不开相关 feature 时不占空间—

文件末尾有一个测试把 Header 的大小卡在 8 个指针以内,也就是 64 位平台上一条 64 字节的缓存行(第 591 行):

#[test]
#[cfg(not(loom))]
fn header_lte_cache_line() {
    assert!(std::mem::size_of::<Header>() <= 8 * std::mem::size_of::<*const ()>());
}
📝NOTE
没有蠢问题

问: Header 被测试卡在 64 字节以内,整个 Cell 却按 128 字节对齐。这两个数字是不是矛盾?

答: 不矛盾,它们防的是两件不同的事。64 管的是「一个任务自己」:调度器读 state、queue_next、vtable 只需要加载一条缓存行。128 管的是「两个任务之间」:预取器一次拉相邻的两条 64 字节缓存行,如果任务 A 和任务 B 的 Header 落在同一对里,一个核改 A 的 state,另一个核上 B 的缓存也跟着失效。按 128 对齐,每个任务独占一对缓存行。

Core:future 和输出共用一块内存

#[repr(C)]
pub(super) struct Core<T: Future, S> {
    /// Scheduler used to drive this future.
    pub(super) scheduler: S,

    /// The task's ID, used for populating `JoinError`s.
    pub(super) task_id: Id,

    // ...(spawned_at,仅 tokio_unstable)

    /// Either the future or the output.
    pub(super) stage: CoreStage<T>,
}

pub(super) enum Stage<T: Future> {
    Running(T),
    Finished(super::Result<T::Output>),
    Consumed,
}

(Core、Stage)

Stage 是一个枚举,future 和它的输出不会同时存在:

  • Running(T):还在跑,里面是 future 本身。
  • Finished(Result<T::Output>):跑完了,future 已经 drop,位置让给输出。Result 的错误类型是 JoinError,取消和 panic 都存成 Err。
  • Consumed:输出被 JoinHandle 取走了,或者没人要、已经丢掉了。

所以一个任务占多少内存,主要取决于 future 和输出里较大的那个。一个在 .await 之间持有 64KB 缓冲区的 async 块,每 spawn 一次就是 64KB 多一点的分配。

❗IMPORTANT
动脑筋:下面四个任务,spawn 时各分配多少字节?

平台是 aarch64,Cell 按 128 字节对齐。

tokio::spawn(async {});
tokio::spawn(async {
  let buf = [1u8; 1024];
  tokio::task::yield_now().await;
  std::hint::black_box(&buf);
});
tokio::spawn(async { [0u8; 4096] });
tokio::spawn(async { vec![0u8; 4096] });

提示:Stage 是枚举;Rust 里一个类型的大小总是它对齐的整数倍。答案在本页末尾。

stage 外面包的是 UnsafeCell,没有锁。谁有权改它,由状态里的 RUNNING 和 COMPLETE 两个位决定:没完成时,把 RUNNING 置 1 的线程独占它;完成后,JoinHandle 独占它。这是下一章的内容。

Trailer:只在创建和结束时用到的部分

pub(super) struct Trailer {
    /// Pointers for the linked list in the `OwnedTasks` that owns this task.
    pub(super) owned: linked_list::Pointers<Header>,
    /// Consumer task waiting on completion of this task.
    pub(super) waker: UnsafeCell<Option<Waker>>,
    /// Optional hooks needed in the harness.
    pub(super) hooks: TaskHarnessScheduleHooks,
}

(task/core.rs 第 205 行)

owned 是存活任务链表的前后指针,waker 是正在 .await 这个 JoinHandle 的那个任务的 waker。它们只在 spawn、完成、关闭时用到,放在 future 后面,不和 Header 抢缓存行。

只拿着 NonNull<Header>,怎么找到 Trailer

Core<T, S> 的大小随 T 变,所以 Trailer 的偏移量每种 future 都不一样。而运行时里流转的指针只有 NonNull<Header>,没有类型参数。

tokio 的做法是手写 vtable,把偏移量和函数指针一起放进去(task/raw.rs 第 24 行):

pub(super) struct Vtable {
    /// Polls the future.
    pub(super) poll: unsafe fn(NonNull<Header>),

    /// Schedules the task for execution on the runtime.
    pub(super) schedule: unsafe fn(NonNull<Header>),

    /// Deallocates the memory.
    pub(super) dealloc: unsafe fn(NonNull<Header>),

    /// Reads the task output, if complete.
    pub(super) try_read_output: unsafe fn(NonNull<Header>, *mut (), &Waker),

    // ...(drop_join_handle_slow、drop_abort_handle、shutdown)

    /// The number of bytes that the `trailer` field is offset from the header.
    pub(super) trailer_offset: usize,

    /// The number of bytes that the `scheduler` field is offset from the header.
    pub(super) scheduler_offset: usize,

    /// The number of bytes that the `id` field is offset from the header.
    pub(super) id_offset: usize,
    // ...
}

每个 (T, S) 组合有一份 vtable,由 vtable::<T, S>() 返回:

pub(super) fn vtable<T: Future, S: Schedule>() -> &'static Vtable {
    &Vtable {
        poll: poll::<T, S>,
        schedule: schedule::<S>,
        dealloc: dealloc::<T, S>,
        // ...
        trailer_offset: OffsetHelper::<T, S>::TRAILER_OFFSET,
        scheduler_offset: OffsetHelper::<T, S>::SCHEDULER_OFFSET,
        id_offset: OffsetHelper::<T, S>::ID_OFFSET,
        // ...
    }
}

这里返回的是对临时值的引用,能编译过是因为整个结构体是常量表达式,编译器把它提升(promote)成了 'static。偏移量要先放进 OffsetHelper 的关联常量里再取,源码注释说直接调 const fn 会让提升失败。

偏移量本身由 get_trailer_offset 这类 const fn 按 repr(C) 的规则手算:从 Header 的大小开始,按 Core 的对齐补齐,加上 Core 的大小,再按 Trailer 的对齐补齐。debug 构建下 Cell::new 会拿真实字段地址和算出来的地址比一遍。

有了偏移量,找 Trailer 就是一次指针加法(Header::get_trailer):

pub(super) unsafe fn get_trailer(me: NonNull<Header>) -> NonNull<Trailer> {
    let offset = me.as_ref().vtable.trailer_offset;
    let trailer = me.as_ptr().cast::<u8>().add(offset).cast::<Trailer>();
    NonNull::new_unchecked(trailer)
}

为什么不用 Box<dyn Future>?trait object 是胖指针,每次 wake 都要经过它;而且 tokio 要的不只是 poll,还有调度、读输出、释放等七个操作,偏移量也得跟着。手写 vtable 让所有引用都是一个瘦指针,Waker 的 data 字段也正好放得下。

RawTask 和 Harness:无类型的指针,有类型的视图

RawTask 只有一个字段:

#[derive(Clone)]
pub(crate) struct RawTask {
    ptr: NonNull<Header>,
}

它是 Copy 的,本身不管引用计数。所有操作都是「读 vtable,调函数指针」:

pub(crate) fn poll(self) {
    let vtable = self.header().vtable;
    unsafe { (vtable.poll)(self.ptr) }
}

vtable 里的函数在 T、S 上是泛型的,进去第一件事就是把指针转回有类型的 Harness<T, S>(raw.rs 第 335 行):

unsafe fn poll<T: Future, S: Schedule>(ptr: NonNull<Header>) {
    let harness = Harness::<T, S>::from_raw(ptr);
    harness.poll();
}

Harness 里装的是 NonNull<Cell<T, S>>,可以直接访问 core 和 trailer,不用再查偏移量。

还有一处细节:harness.rs 把不依赖 T、S 的操作(wake_by_val、wake_by_ref、remote_abort、drop_reference)写在 impl RawTask 上,注释写明「最终二进制里只该有一份」。泛型函数每种 future 都会单态化一次,一个程序里有几百种 async 块,能不泛型的就不泛型,可以省下不少代码体积。

📝NOTE
冷知识:这几个函数原来也是泛型的

2022 年 11 月的 #5213(1.23.0)之前,wake_by_val、wake_by_ref、remote_abort、clone_waker、drop_waker、try_set_join_waker 都在 T、S 上泛型,程序里每 spawn 一种 future,它们就多生成一份。这个 PR 把它们改成非泛型,同时往 vtable 里加了 schedule。唤醒时如果需要重新调度,就走这个函数指针。schedule::<S> 只在调度器类型上泛型,不在 future 类型上,一个程序只会生成一两份。改完以后 JoinHandle 和 AbortHandle 也不再需要自己存任务 ID 了。

同一个任务的几种引用

RawTask 不管引用计数,管引用计数的是包在它外面的几个类型。new_task 一次造出三个:

fn new_task<T, S>(
    task: T,
    scheduler: S,
    id: Id,
    spawned_at: SpawnLocation,
) -> (Task<S>, Notified<S>, JoinHandle<T::Output>)
where
    S: Schedule,
    T: Future + 'static,
    T::Output: 'static,
{
    let raw = RawTask::new::<T, S>(
        task,
        scheduler,
        id,
        spawned_at,
    );
    let task = Task {
        raw,
        _p: PhantomData,
    };
    let notified = Notified(Task {
        raw,
        _p: PhantomData,
    });
    let join = JoinHandle::new(raw);

    (task, notified, join)
}

三个值指向同一块内存,各占一个引用计数,所以任务的初始引用计数是 3。task/mod.rs 开头的模块文档列出了全部引用类型:

引用谁持有个数独占的字段
Task<S>OwnedTasks 链表0 或 1Trailer 的 owned 指针
Notified<S>某个运行队列0 或 1Header 的 queue_next
JoinHandle<T>用户0 或 1完成后的 stage(读输出)
AbortHandle用户任意—
WakerIO 驱动、定时器、channel 等任意—
UnownedTask<S>阻塞线程池(spawn_blocking)0 或 1,占 2 个计数—

「Notified 至多一个」是关键约束:一个任务最多在一个队列里出现一次,queue_next 只有一个,也不会被同时 poll 两次。这条约束由状态里的 NOTIFIED 位保证。

Task 实现了 Drop:引用计数减到 0 的那个人负责调 dealloc 释放整个 Cell(mod.rs 第 570 行):

impl<S: 'static> Drop for Task<S> {
    fn drop(&mut self) {
        if self.header().state.ref_dec() {
            self.raw.dealloc();
        }
    }
}

动脑筋答案

用一个全局分配器包住 tokio::spawn,只记录对齐不小于 32 的分配。这个程序里按 128 对齐的分配只有 Cell:

unsafe impl GlobalAlloc for Spy {
  unsafe fn alloc(&self, l: Layout) -> *mut u8 {
    if ON.load(Ordering::Relaxed) && l.align() >= 32 {
      SIZE.store(l.size(), Ordering::Relaxed);
      ALIGN.store(l.align(), Ordering::Relaxed);
    }
    unsafe { System.alloc(l) }
  }
  // dealloc 原样转给 System
}

M2 Max、rustc 1.99.0、--release 下的输出(future= 是 size_of_val 量的 async 块本身):

async {}                     future=    1B  cell=  128B align=128
hold [u8;1024] over await    future= 1048B  cell= 1152B align=128
return [u8;4096]             future=    1B  cell= 4224B align=128
return Vec (24B header)      future=    1B  cell=  256B align=128
  • async {}:128 字节,正好一个对齐单位。Header、调度器句柄、任务 ID、Trailer 和一个空的 Stage 全都塞得下。
  • 跨 .await 持有 1KB 数组:数组被存进了 future 的状态机,future 本身变成 1048 字节,Cell 是 1152 = 9 × 128。
  • 返回 [u8; 4096]:future 只有 1 字节,但 Stage 必须放得下 Finished(Result<[u8; 4096], JoinError>),所以从 spawn 那一刻起就按输出的大小分配,4224 = 33 × 128。大输出和大 future 一样占内存,只是更不容易被注意到。
  • 返回 Vec:堆上那 4096 字节不算在 Cell 里,Stage 只多了 Vec 本身的 24 字节,但这就超过了 128,被补齐到 256。

小结

  • 一个任务是一个 Cell<T, S>,一次 Box 分配,repr(C) 保证 Header 在偏移 0,并按缓存行对齐(x86_64 上 128 字节)防止伪共享。
  • Header 放调度器常用的字段,测试把它卡在一条缓存行内;Core 里 future 和输出共用 Stage 枚举;Trailer 放只在创建、结束时用的字段。
  • 运行时里只流转 NonNull<Header>,靠每个 (T, S) 一份的手写 vtable 找回函数和字段偏移量;不依赖类型的操作写在 RawTask 上,减少单态化。
  • Task、Notified、JoinHandle 等是同一块内存的不同引用,各占一个引用计数,初始为 3;Notified 至多一个,保证任务不会同时在两个队列里。

💬 关联讨论

发起讨论
加载中...