
第一次在项目里把一部分高并发网关从 Go 迁到 Rust 时我最不适应的不是所有权而是异步模型本身。Go 协程写起来太顺手了go func()一丢剩下的事交给运行时换成 Tokio 之后我必须想清楚任务到底怎么调度、为什么 future 要static、为什么不能用Rc连睡一觉都得换成tokio::time::sleep。这篇文章我想从工程视角把Tokio 与 Go 协程放在同一张桌上对比讲清楚它们分别是谁、怎么工作以及什么时候选谁。如果你是从 Go 转过来的这篇可以当成一份“Tokio 落地避坑笔记”如果你刚开始学 Rust 异步读完之后你会知道Rust 异步运行时到底帮你承担了什么又为什么那么多老手说“Rust 异步不难难的是纪律”。我会把调度器、Waker、任务模型、内存模型这些核心差异逐个拆开最后给出我自己的选型标准和几个实实在在踩过的坑。1. 为什么 Rust 不直接内置协程非要搞个 Tokio1.1 绿色线程与状态机两条并发路线的分岔Go 选择了一条非常“省心”的路线语言和运行时内置了并发。一个go func()启动的 goroutine本质上是运行时维护的一条绿色线程它有自己独立的调用栈可以像普通函数一样调用、返回、阻塞。运行时会负责把大量 goroutine 映射到少量操作系统线程上开发者完全不需要关心底层调度细节。我把这种模型叫“背包模型”每个 goroutine 背着一个可以动态增长的背包栈随便走到哪里都能把自己的局部变量装进去。Rust 走的是另一条路。语言层面只提供了async/await语法和Futuretrait没有内置运行时也不给并发任务分配可增长的栈。一个async fn被调用后编译器会把整段代码编译成一个状态机每个.await点就是状态机的一个分支编译器把需要保留的局部变量塞进这个状态机结构体里。这个结构体可能只有几十字节也可能有几 KB完全取决于你的 async 块里写了什么。为什么 Rust 不选择绿色线程路线一个很重要的原因是栈。绿色线程必须有栈而栈要么预先分配得比较大浪费内存要么动态增长需要运行时做更多工作还得处理栈指针和 C 代码互操作的边界。Rust 一直想做到“零成本抽象”和“无侵入运行时”FFI 调用 C 库时不希望背后有一套庞大的线程栈管理机制。状态机制 future 不需要栈它只需要编译器算清楚每个暂停点需要保存什么数据全部内联进结构体。代价是——这套机制非常依赖编译器的精细分析这也是为什么 Rust 异步代码里生命周期问题比 Go 多得多。1.2 Rust 的定位把调度权交给库而不是语言有人会问既然async/await是语言特性为什么写起来还要装 Tokio因为async/await只是一套语法糖底层Futuretrait 只定义了poll一个方法你来问我准备好没有我告诉你Ready还是Pending。至于谁来轮询、什么时候轮询、轮询完怎么调度语言不管。Tokio 就是干这个的。它是目前 Rust 生态里事实标准的异步运行时提供三块核心能力。第一是 executor也就是任务调度器负责把成千上万个 future 分配到少量工作线程上执行第二是 timer提供tokio::time::sleep、超时、间隔任务第三是 IO 驱动通过 mio 封装 epoll/kqueue 等系统非阻塞 IO 接口让网络读写不会卡死线程。三者合在一起才让你的async代码真的能并发跑起来。1.3 从#[tokio::main]看运行时到底做了什么很多新手第一次写 Tokio 都是从#[tokio::main]宏开始的但真正理解它的人不多。这个宏展开之后大致做了这几件事根据配置构建一个多线程运行时默认工作线程数等于 CPU 核数把main函数包成一个 future 放到调度器里然后调用block_on阻塞当前线程去驱动这个 future。也就是说你的异步主任务是被运行时驱动起来的而不是语言自动跑的。这就是 Rust 异步区别于 Go 协程的第一个心智冲击在 Go 里你写了go func()调度器立刻就知道了在 Rust 里你写了一个 async 块它只是一份“执行计划”没有人驱动它它就永远不会运行。理解这一点后面很多坑都能避开。2. 调度内核对照Go 的 GMP 与 Tokio 的 work-stealing2.1 Go 调度器的三级模型G、M、PGo 的并发调度建立在一个叫 GMP 的模型上。G 是 goroutine代表一个待执行的任务M 是操作系统线程真正干活的人P 是调度上下文可以理解成每个 M 手上的一个本地运行队列。Go 运行时维护一个全局任务队列同时每个 P 有自己的本地队列。新创建的 goroutine 优先放进当前 P 的本地队列某个 M 空闲时会优先从自己绑定的 P 里取任务取不到才去全局队列或者其他 P 里“偷”。这里有个关键点Go 的调度是抢占式的。从 1.14 开始Go 引入基于信号的异步抢占机制即使一个 goroutine 里写了死循环运行到一定时间后也会被操作系统信号打断调度器借机检查是否需要切出去。这保证了单个 goroutine 不能无限霸占一个 M。对于系统调用阻塞的场景Go 运行时会把这个 M 从 P 上解绑让 P 去绑定另一个 M继续执行其他 goroutine实现“阻塞不阻塞调度器”。goroutine 本身的成本也很低初始栈只有 2KB 左右可以动态增长每次创建的开销远小于创建操作系统线程。正因为如此Go 才敢让你随手go func()开几千上万个协程。2.2 Tokio 的 work-stealing 调度器Tokio 的多线程运行时在思想上和 GMP 有相似之处但没有 M 和 P 那么多层次。每个工作线程对应一个 worker每个 worker 维护一个本地任务队列同时有一个全局队列作为溢出口。Worker 优先执行自己队列里的任务本地队列空了就去全局队列取再不行就随机挑一个别的 worker 的队列“偷”一半任务过来。这就是经典的 work-stealing 调度。和 Go 抢占式调度不同Tokio 的调度是协作式的。任务一旦开始执行只有遇到.await或者自己返回调度器才有机会切换。如果一个任务进入了一个巨大的 CPU 循环没有任何 await它会一直占住这个 worker 线程其他任务即使排在队列里也得不到执行。这个差异非常关键它决定了你在 Tokio 里写代码必须遵守“异步函数里不要跑 CPU 密集计算”这条纪律。Tokio 还提供了单线程的current_thread运行时很少被人提及但很有用。它把所有任务放在一个线程上调度天然避免了任务间共享数据的 Send 要求适合开发调试、嵌入式环境或者极轻量的批量任务场景。2.3 同一个阻塞两种运行时反应完全不同拿最常见的sleep举例。在 Go 里你写time.Sleep(1 * time.Second)运行时会把当前 goroutine 挂起P 转而执行别的 goroutine整台机器照样满负荷。在 Tokio 的 async 上下文里如果你图省事写了std::thread::sleep它直接阻塞整个工作线程这个线程上排队的其他任务全部跟着遭殃。正确写法是tokio::time::sleep(Duration::from_secs(1)).await让出当前任务的控制权调度器才能去跑别人。同样的道理还适用于锁。std::sync::Mutex的lock()会阻塞当前线程如果它阻塞的时间超过一次任务调度周期阻塞效果就会被放大到整个 worker 线程。Tokio 提供tokio::sync::Mutex它把锁的等待也做成了异步的等待时不会占用 worker 线程。这个差异用一句话概括Go 能容忍一些“无纪律”的代码Tokio 不能它要求所有等待都显式让出控制权。2.4 两家调度器的队列公平性Go 调度器在选任务时有一些随机性和公平性策略而且有抢占兜底理论上单个 goroutine 很难造成全局卡顿。Tokio 依赖任务自觉让出但也提供了一些保障每个 worker 在执行任务时有一个“预算”默认执行若干个任务后会把新任务放到全局队列让其他线程有机会偷走。这套机制在大多数情况下能保证任务不会长期饿死但极端情况下一个完全没有 await 的长任务依然会拖垮单个 worker。所以我的习惯是任何可能超过几十毫秒的纯计算直接丢给tokio::task::spawn_blocking让它在专门的线程池里跑不占用工作线程。下面这张表是我自己总结的两者调度差异供快速参考对比维度Go goroutineTokio Task任务载体独立可增长栈初始约 2KB编译器生成的状态机大小由 async 块决定调度方式抢占式运行时统一调度协作式配合 work-stealing创建成本低但存在栈管理开销更低无栈只有堆上任务结构开销阻塞容忍度系统调用有 netpoller 兜底整体宽松阻塞操作会卡死 worker 线程必须显式让出CPU 密集任务可能被异步抢占打断不 await 就会霸占线程必须丢给 spawn_blocking3. Waker 机制Tokio 的非阻塞驱动力也是两种并发模型的分水岭3.1 Future 的 poll 契约不能硬等只能登记电话要理解 Tokio 为什么能支撑百万级并发不能只看调度器还得看 Waker。Future::poll的返回值只有两种Poll::Ready表示计算完成Poll::Pending表示还没准备好。问题是poll 返回 Pending 之后这个任务什么时候再被 poll如果让 executor 每秒轮询一次所有任务性能会惨不忍睹如果让 executor 干等那就失去了异步意义。Tokio 的解法是 Waker。每次调用poll时运行时把一个 Waker 对象传给 future。future 在返回 Pending 之前需要把这个 Waker 保存在某个地方。等真正的事件发生比如 socket 可读了负责事件的人会调用这个 Waker 的wake方法把对应的任务重新加入调度队列。整个等待过程没有任何线程被阻塞也没有浪费的轮询。想象一下奶茶店取餐的场景你不用站在柜台前死等店员留了你的手机号Waker奶茶做好了打你电话wake你才过来取。Tokio 的整套非阻塞 IO 都是建立在这个“先登记、再通知、后执行”的模型上。3.2 从 Waker 到 epoll 的完整链路一次tokio::net::TcpStream的读取背后链路大概是这样的future 被 poll 时发现 socket 没有数据于是把 Waker 注册到 Tokio 的 IO 驱动里同时通过 mio 把 socket 的读事件兴趣注册进 epoll。事件到来时epoll 返回可读的 socket 列表mio 通知 Tokio 的 IO 驱动IO 驱动找到这个 socket 对应的 Waker调用 wake任务重新进入调度队列worker 再来 poll 一次这次数据有了返回 Ready。tokio::time::sleep的实现思路类似只是用了层级时间轮timer wheel管理大量定时器每个定时器到期后触发对应的 Waker。这套机制的巧妙之处在于不管是网络、定时器还是自定义事件源暴露给外层的都是同一个 Waker 接口。你可以为 Redis 客户端、消息队列甚至自定义硬件实现自己的事件注册逻辑只要能在事件发生时唤醒对应任务就行。这正是 Tokio 生态可扩展性远强于普通线程池的原因。3.3 Go 其实也有一套“Waker”只是藏起来了对比一下Go 的运行时内部也有类似的东西goroutine 在 channel 上等待时会被挂到一个 sudog 等待队列发送方写入时通过运行时唤醒对应的等待 goroutine网络 IO 也有专门的 netpoller 在后台调度。区别在于这些机制全部封装在语言运行时内部开发者不需要知道细节只需要写业务逻辑。Rust 则把 Waker 做成了一套公开的core::task接口允许任何人实现自己的事件源和 executor。这带来两个结果从好的方面说Rust 异步生态的灵活性和组合性远超 Go你可以针对特定硬件编写一个极高效的驱动从坏的方面说开发者的认知负担明显增加因为你至少需要大体理解 Waker 是怎么流动的否则遇到“任务不执行”“唤醒丢失”这类问题时会非常被动。我在实际排查中遇到过几次任务“假死”最后都是因为某些回调路径上没有正确调用 Waker这种问题在 Go 里几乎不可能由开发者触发。4. 代码习惯的转换惰性 Future、Send 限制与任务边界4.1 一个把 Go 程序员搞懵的事实async 块不执行从 Go 切到 Tokio 后第一个让我愣住的是下面这段代码let task async { println!(这句话不会在定义时打印); };运行之后什么都没有输出。因为在 Rust 里async 块只是构造了一个 future 对象它被谁 poll 了才会真正执行。Go 里go func()是即刻安排执行Rust 里async {}是个惰性的执行计划。要让这段代码跑起来你得把它交给 executor比如tokio::spawn(task)或者在 async 函数里.await它。这个“惰性”初看反直觉但理解了之后会发现它很有价值future 只是一个可组合的数据结构你可以随意传递、存储、合并完全不用马上执行。比如在一个循环里构造几百个 future先放进一个Vec再决定是批量 await 还是丢给任务池这种灵活性 Go 做不到。4.2 tokio::spawn 的硬条件static 和 Send刚开始用tokio::spawn时很多人会被编译错误折磨。它的签名要求传入的 future 满足Future Send static。三个条件各有含义Send是因为任务可能被运行在线程池里的任意一个线程上非 Send 类型跨线程传递不安全static是因为任务一旦 spawn 出去运行时无法保证它在多大生命周期内完成所有被捕获的引用必须拥有自己的数据不能借用外部变量Future就不用多说了它本身得是可执行的。最常见的编译错误来自Rcuse std::rc::Rc; #[tokio::main] async fn main() { let data Rc::new(42); tokio::spawn(async move { println!({}, data); }); }编译器会告诉你Rc{integer} cannot be sent between threads safely。解决办法根据场景不同如果是单线程的current_thread运行时Rc可以直接用如果必须用多线程运行时要么把Rc换成Arc要么把数据克隆成独立的所有者。我在代码评审里见过不少新人为了绕过 Send 检查直接把Rc换成ArcMutexT结果引入一堆锁竞争。其实很多时候数据根本不需要跨任务共享用 channel 把任务拆开反而更干净。4.3 同样的并发抓取页面两种写法对比为了方便对比我拿一个非常常见的场景并发抓取一批 URL。Go 版本的典型写法是func fetchAll(urls []string) { var wg sync.WaitGroup for _, u : range urls { wg.Add(1) go func(u string) { defer wg.Done() resp, _ : http.Get(u) defer resp.Body.Close() _ resp }(u) } wg.Wait() }Rust Tokio 版本可以写成use tokio::task::JoinSet; async fn fetch_all(urls: VecString) { let mut jobs JoinSet::new(); for u in urls { jobs.spawn(async move { let resp reqwest::get(u).await; resp }); } while let Some(res) jobs.join_next().await { res.unwrap(); } }两者逻辑几乎一一对应但感受差别很大。Go 版本里变量捕获靠闭包参数u string显式传递运行时帮你处理好一切Rust 版本里async move直接把u的所有权移进任务编译器在编译期就确认了每个任务不共享可变状态。Go 的自由带来的是运行时才可能暴露的数据竞争和 goroutine 泄漏Rust 的约束带来的是编译期的高压审核熬过去之后反而觉得安心。4.4 生命周期问题与一种可靠的工程方案Rust 异步代码里最让人头疼的是引用和生命周期。比如下面这种“想借用外部字符串”的写法编译阶段就会碰壁async fn use_ref(s: str) { tokio::time::sleep(std::time::Duration::from_millis(10)).await; println!({s}); } #[tokio::main] async fn main() { let s String::from(hello); tokio::spawn(use_ref(s)); // 生命周期不满足 static }tokio::spawn要求 future 是static的而这里s的生命周期明确不能覆盖整个任务。一种思路是用ArcString包裹后再传引用另一种更符合 Rust 惯例的思路是直接把String的所有权 move 进任务tokio::spawn(async move { let owned s; // s 的所有权被移入任务 // ... });我在维护一个长连接组件时总结出的经验是异步函数的参数尽量所有权化返回尽量也所有权化任务之间只通过 channel 传递消息尽量少共享状态。这样既绕开了生命周期地狱也绕开了大量ArcMutexT的锁竞争代码结构还更清晰。5. 性能、内存与调试真实对比后的选型建议5.1 内存模型谁更省别只看表象很多人说 Tokio 任务比 goroutine 省内存这个说法有点道理但不完全准确。goroutine 的初始栈 2KB一百万 goroutine 光空栈就是 2GB 起实际业务还会撑大栈内存消耗肉眼可见。Tokio task 本身的结构体只有几十字节future 状态机的大小则由 async 块决定——一个只调用tokio::time::sleep的极小 future 可能就几十字节一个持有大缓冲区的 async 块完全可能超过 2KB。所以并发量高、任务轻量时Tokio 省内存的优势非常明显。比如我曾经写过一个模拟连接池的程序一百万条长连接状态用 goroutine 跑了小几个 GB用 Tokio 把每条连接拆成状态机只占小几百 MB。但如果你的任务里带着大数组、大缓冲区两边差别就没有玄学那么大。选型时一定要用自己的真实业务构造 benchmark不要被“Rust 一定更省”的刻板印象带偏。5.2 延迟与吞吐的体感差异调度延迟方面Tokio 的协作式任务切换在微观上通常比 Go 的抢占式切换更轻因为协作式切换不需要处理信号、栈扫描这类操作只需要在 await 点执行状态机转移。这在高频短任务的场景下很有利比如大量解析小报文、转发小消息。Go 的优势体现在“平均表现稳定”即使某段代码写得比较糙有运行时抢占兜底整体调度也不太会崩。吞吐层面两边都能轻松支撑每秒几万到几十万的任务切换真正的瓶颈往往在业务逻辑本身。我在实际测试一个代理转发组件时同样的部署规格下 Rust 版本的极限 QPS 比 Go 版本高了大概 30%但开发周期几乎翻倍。性能差距是真的开发效率差距也是真的这也是我一直说选型要看团队配置和项目阶段的原因。5.3 调试工具Go 的省心和 Tokio 的精细线上问题定位是每个工程师的必修课。Go 在这方面体验极好pprof可以抓 CPU profile可以看 goroutine 栈全貌一个go tool pprof基本能解决 80% 的性能问题。Tokio 这边生态相对年轻但也在逐渐成型tracing和tokio-console可以查看任务调度、任务状态、poll 次数tokio::task相关工具能帮助定位任务挂起位置。整体上Tokio 的调试更精细也更烧脑你往往需要同时理解业务逻辑和异步机制两个层面。我的习惯是上线前先用tracing把关键的 task 生命周期打上事件点将来排查时先看任务卡在哪个阶段再结合日志判断是 IO 等待还是调度饥饿。还有一个小工具心得如果发现 Tokio 任务大量Pending但就是不Ready优先怀疑是不是 Waker 丢失或者某个共享状态被提前 drop 了而不是怀疑运行时本身。5.4 我的简化选型标准被问过很多次“到底选 Go 还是 RustTokio”我现在的判断标准大概是这样业务场景我的建议理由高吞吐网关、连接密集、内存敏感Rust Tokio任务轻量内存占用低调度开销小内部 API、业务系统、需要快速迭代Go开发效率高团队上手快生态成熟与 C 库深度互操作、追求极致延迟Rust Tokio无 GCFFI 干净可控性强团队以业务开发为主技术栈偏后端全家桶Go心智负担低排查链路短需要在同一套代码里精细控制每个任务的资源Rust Tokio状态机大小、堆分配都由你掌控如果团队里没有熟悉 Rust 所有权和异步机制的人我通常建议先保守选 Go。Rust 异步的上限高下限也很低——一个不懂 Waker 和 Send 约束的团队写出来的服务稳定性可能还不如 Go 的默认实现。反过来如果你的业务对延迟和资源极度敏感又愿意投入时间打磨Tokio 的能力边界明显更大。5.5 迁移到 Tokio 后最容易踩的三个坑最后分享三个我自己以及周围同事都踩过的坑希望你能少走几步弯路。第一个坑是在 async 函数里用标准库的阻塞锁。比如let lock std::sync::Mutex::new(1); let guard lock.lock().unwrap(); tokio::time::sleep(std::time::Duration::from_secs(1)).await;guard跨 await 存活等于锁被一个任务绑住了整整一秒同线程上其他任务如果也要这把锁全部排队卡死。这里必须换tokio::sync::Mutex用.lock().await获取锁等待时让出线程。第二个坑是 CPU 密集计算直接写在 async 块里。我见过一个加密签名组件每次计算要几十毫秒结果所有请求全部排到同一个 worker 上执行吞吐掉了好几个数量级。解决办法很简单把计算丢进tokio::task::spawn_blocking或者先算完再切回 async。第三个坑是过度使用ArcMutexT共享状态。很多从 Go 转过来的人习惯了共享内存加锁到了 Rust 里也这么干。其实 Tokio 的 mpsc 和 broadcast channel 用起来非常顺手消息传递模式在异步场景下比锁好用得多还天然避开了 Send 和生命周期问题。写到最后我想说 Tokio 不是来取代 Go 的。认真对比之后你会发现这俩解决的问题确实有一部分重叠但各自的边界很清楚Go 用简单的模型换开发效率Rust 用编译期的高压把性能潜力压榨到极限。如果只能留一句话给刚开始接触 Rust 异步的同事我会说先别急着spawn几千个任务花两个晚上把 future 的惰性和static的规则吃透再用 channel 把状态拆清楚之后你会发现自己真正需要的并发能力绝大多数场景下几十个 Tokio 任务就绰绰有余了。