为3DS构建AsyncIO执行器
摘要
本文介绍了在Nintendo 3DS上进行异步编程的必要性,因为其采用协作式多任务处理,并开始解释如何为其构建一个asyncio执行器,重点讨论了Rust中的任务、未来、唤醒器和执行器这些概念。
<p><a href="https://lobste.rs/s/tqxsky/building_asyncio_executor_for_3ds">评论</a></p>
查看缓存全文
缓存时间: 2026/05/26 15:21
# 为 3DS 构建一个 AsyncIO 执行器(第一部分!)
来源:https://blog.cat-girl.gay/3ds-async-part-one/
*2026 年 5 月 16 日*
Nintendo 3DS 是一台写自制软件非常有趣的设备,但有一件事可能很烦人:它的多任务是非抢占式的!在这个小系列中,我们将为 3DS 构建一个 asyncio 执行器。在第一部分中,我们先来看看:为什么要有异步,以及异步到底是什么?
## 什么?
操作系统通常通过**抢占**来实现线程和进程:一个线程只能运行一小段时间,然后会被暂时暂停,以便让下一个线程运行。因此,如果你创建一个非常密集、长期运行的线程,它无法独占整个 CPU,因为操作系统会介入并确保其他线程也能获得 CPU 时间。
### 但 3DS 是**协作式**的
这意味着线程只有在主动请求暂停时才会被暂停。所以,如果我们的密集长期运行线程从不主动释放 CPU,那么**其他任何东西**都无法运行!当然,我们只需小心谨慎,确保它不会发生,但这很容易忘记。如果我们能在编写代码时用一种方式来建模这个问题,那就太方便了……
## 直接 await
**异步**编程正是这样一种模型!在异步程序中,我们将每部分工作建模为一个`任务`。任务不应该长时间占用 CPU:它应该做一点工作,然后在等待某事发生(例如 TCP 数据包到达)时休眠,然后再做一点工作。我们可以等待的东西称为`Future`。没有异步,这些事情并非不可能做到:只是异步让这一切变得非常明确。这一点在 3DS 上尤其重要,因为平台的行为……有时候不可预测,难以判断什么会导致任务主动让出 CPU。
```
// 在普通程序中,很难知道这会不会让我们的线程休眠……
socket.read();
// 但在异步中,明确表明它会让我们的任务等待!
socket.read().await;
```
神奇的`.await`表示我们希望任务**休眠直到**`socket.read()`(它本身是一个`Future`)完成。
## 好的,但这是怎么工作的?
我必须承认:“休眠”这个词用得并不准确。实际上,任务内部并不像线程:它是一个我们反复调用的函数,就像这样:
```
while task.do_some_work() == NotReady {}
```
所以,如果我们有一个任务要两次从套接字读取,比如:
```
let task = async {
socket.read().await;
socket.read().await;
}
```
它就会变成这样:
```
let task = {
let mut read_operation = socket.read();
while read_operation() == NotReady {}
let mut read_operation_two = socket.read();
while read_operation() == NotReady {}
}
```
## 等等!这不过是忙循环而已!
没错:这种异步很糟糕,因为它实际上根本没有让出 CPU!相反,它可能占用更多 CPU 时间,因为它在循环中反复调用同一个函数。我们应该只在**实际**可以完成新工作(例如可以从套接字读取数据)时才调用`do_some_work()`或`read_operation()`函数。如果你曾经接触过**回调**,可能会觉得似曾相识。
```
socket.on_has_data(|| {
read();
});
```
然而,回调很快会陷入地狱,而且实际上是由缩进行业发明的,目的是多卖 TAB 键。
## 唤醒我(从内部唤醒我)
相反,Rust 的异步系统使用**唤醒器**和**执行器**。`执行器`负责管理我们的任务:它负责调用`do_some_work()`函数。但除非我们明确请求,否则它不会调用。`唤醒器`是一种抽象,用来“请求执行器再次调用我们的函数,拜托拜托”。
```
fn do_some_work() {
// 如果数据已准备好,我们可以直接读取并立即返回!
if socket.has_data() {
return Ready(socket.read())
} else {
// 否则,我们向套接字注册自己:当它有数据时,它会告诉执行器再次调用我们。
socket.on_has_data(|| executor.wake_me_up());
return NotReady // 我们还没准备好,所以先返回!
}
}
fn executor() {
if do_some_work() == Ready(data) {
return data
};
// 我们先调用一次,以防它已经有数据,或者让它注册自己以便稍后唤醒。
while let Some(wake_request) = wake_requests.receive() {
// 我们可以使用信道或类似的东西来接收唤醒请求
do_some_work();
}
}
```
## 好吧,让我们真正实现它
到目前为止所有示例都非常伪代码风格。让我们实际尝试构建一个最小可行的执行器,同时实现一个极其简化的`sleep()`。
```
pub struct Sleep {
registered: bool
}
impl Future for Sleep {
type Output = ();
// 这里我们用标准库的 Poll 类型,它有两个变体:Ready(val) 或 Pending。
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll {
if !self.registered {
// 这是我们第一次被调用!
let waker = context.waker().clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(5));
waker.wake(); // 请求执行器再次调用 Sleep future
});
self.registered = true; // 标记我们已经请求五秒后唤醒
return Poll::Pending // 我们还没完成!
} else {
// 这不是第一次被调用,所以一定是因为我们的唤醒器唤醒了我们!
return Poll::Ready(())
}
}
}
// 我们用一些整数来标识任务!
pub struct TaskId(u32);
// 由于某些原因(这里不深入),future 有非常具体的内存布局要求,这使得把任务放在堆上更容易。
// 这里我们使用了 `futures` crate 中的 BoxFuture 类型别名。
pub type Task = BoxFuture<'static, ()>;
// 我们将使用一个 mpsc 信道来处理唤醒请求,所以我们的 `Waker` 就是一个信道发送者加上要唤醒的 id
pub struct TaskWaker {
id: TaskId,
sender: std::sync::mpsc::SyncSender
}
// 我们需要实现 Wake trait 来创建 Waker:它定义了在我们的设置中如何唤醒一个任务
impl Wake for TaskWaker {
fn wake(self: Arc) {
self.sender.send(self.id);
}
}
// 这是进行实际工作的东西
pub struct Executor {
tasks: BTreeMap<u32, Task>,
wake_requests: std::sync::mpsc::Receiver<TaskId>,
wake_sender: std::sync::mpsc::SyncSender<TaskId>
}
impl Executor {
pub fn new() -> Executor { ... }
fn waker_for_task(&self, id: TaskId) -> Waker {
Arc::new(TaskWaker { id: task_id, sender: self.wake_sender.clone(); }).into()
}
// 轮询一个任务,让它按需做一点工作
fn poll_task(&mut self, id: TaskId) {
let waker = self.waker_for_task(id);
let mut context = Context::from_waker(&waker);
// Context 是一个花哨的包装器,用于我们的 waker 类型
if let Poll::Ready(()) = self.tasks[id].poll(context) {
self.tasks.remove(id); // 任务已完成!可以移除
}
}
// 将一个任务放入执行器
pub fn spawn(&mut self, id: TaskId, future: BoxFuture<'static, ()>) {
self.tasks.insert(id, future);
self.poll_task(id); // 先轮询一次,以便它能注册唤醒回调
}
pub fn run(mut self) {
// 每次收到唤醒请求,就再次轮询对应的任务
for task_id in self.wake_requests.iter() {
self.poll_task(task_id);
}
}
}
let mut executor = Executor::new();
executor.spawn(Sleep { registered: false });
executor.run();
```
## 好的,但 3DS 呢?我们不是要处理 3DS 吗?
哦对了。那就在下一部分(https://blog.cat-girl.gay/3ds-async-part-two)。
相似文章
Rust异步与ARM通用定时器
一篇技术博客文章,探讨了在ARM架构上使用ARM通用定时器进行Rust异步编程,比较了定时器外设,并讨论了Embassy和RTIC等框架。
Zig 0.16 中的异步 I/O:今日视角
Zig 0.16 推出了新的 std.Io 接口,用于跨平台 I/O。zio 库通过栈式协程和操作系统级异步 API 提供了完整的异步实现,无需每个任务一个线程即可实现高效的并发任务。
@RhysSullivan:我现在正在全职将 Executor 打造成一家创业公司!工具调用的现状一团糟:- 每个人都在使用不同的 ag…
Rhys Sullivan 正在构建 Executor,这是一个面向 AI 智能体的开源集成层,提供统一的工具目录,具备访问控制、破坏性操作审批流程,并支持 MCP、OpenAPI、GraphQL 等协议。它旨在标准化不同智能体(如 Cursor 和 Claude Code)之间的工具调用方式。
你的所有智能体都将走向异步
文章指出,AI智能体正从同步聊天界面转向异步后台工作流,并重点介绍了Anthropic、OpenAI和Cursor推出的新功能,这些功能将智能体生命周期与HTTP请求-响应周期解耦。
在多个协程之间共享单个Windows Runtime IAsyncOperation的结果,第3部分
本文讨论了一个C++/WinRT模式,用于缓存Windows Runtime IAsyncOperation的结果,包括处理失败的情况,以便多个协程可以共享缓存的结果或异常。