Building an AsyncIO executor for the 3DS

Building an AsyncIO executor for the 3DS (pt 1!)

16 May, 2026

The Nintendo 3DS is a very fun device to write homebrew for, but one thing about it can be annoying: its multitasking is non-preemptive! In this little series, we’ll be building an asyncio executor for the 3DS. In this first part: why and what is async, anyways?

任天堂 3DS 是一款非常适合开发自制软件(homebrew)的设备,但它有一个令人头疼的问题:它的多任务处理是非抢占式的!在这个系列文章中,我们将为 3DS 构建一个 asyncio 执行器。第一部分,我们先来探讨:为什么需要异步?以及究竟什么是异步?

What?

The way threads and processes usually work in operating systems is through pre-emption: a thread can only run for a little bit of time before it gets temporarily paused so the next thread in line can run. So, if you spawn a super intensive long-running thread, it won’t be able to hog the entire CPU, as the OS will take over and make sure everyone else can get some CPU time.

什么是异步?

操作系统中线程和进程通常的工作方式是通过“抢占”:一个线程只能运行一小段时间,然后就会被暂时挂起,以便让队列中的下一个线程运行。因此,如果你启动了一个超高强度的长耗时线程,它无法独占整个 CPU,因为操作系统会介入并确保其他任务也能获得 CPU 时间。

But the 3DS is cooperative. This means that threads are only paused when they ask to be. So if our super intensive long-running thread never gives up the CPU, nothing else will be able to run! Of course, we can just be careful and make sure it doesn’t, but it’s really easy to forget. It’d be convenient if there was a way to model this problem in the way we write our code… straight up awaiting it.

但 3DS 是协作式的。这意味着线程只有在主动请求时才会被挂起。所以,如果我们那个超高强度的长耗时线程从不交出 CPU,其他任何任务都无法运行!当然,我们可以小心谨慎地确保这种情况不会发生,但人总会疏忽。如果能有一种方式,让我们在编写代码时就能直接对这个问题进行建模,比如直接使用 await,那该多方便啊……

Async programming is just such a model! In an async program, we model each bit of work as a task. A task is not supposed to hog the CPU: it should do a little bit of work, then go to sleep while waiting for something to happen (e.g., a TCP packet to come in), then do a little more work. A thing we can wait on is called a Future. None of this is impossible to do without async: it just makes it very explicit. This is specially important in the case of the 3DS, where platform behaviour is… sometimes unpredictable in terms of what will cause a task to yield or not.

异步编程正是这样一种模型!在异步程序中,我们将每一小段工作建模为一个“任务”(Task)。任务不应该独占 CPU:它应该做一点工作,然后进入休眠状态等待某事发生(例如,等待 TCP 数据包到来),然后再做一点工作。我们可以等待的对象被称为“Future”。不用异步并非无法实现这些,但异步让这一切变得非常明确。这在 3DS 上尤为重要,因为该平台的行为有时是不可预测的——你很难确定到底什么操作会导致任务让出 CPU。

// In a normal program, it can be hard to know whether this will put our thread to sleep or not… socket.read();

// But in async, it’s explicit that it will make our task wait! socket.read().await;

// 在普通程序中,很难知道这是否会让线程进入休眠…… socket.read();

// 但在异步中,它明确表示会让任务等待! socket.read().await;

The magic .await indicates that we want our task to sleep until socket.read() (which is a Future) is complete.

神奇的 .await 表示我们希望任务进入休眠,直到 socket.read()(这是一个 Future)完成为止。

Okay, but how does that work?

I must confess: “sleep” is a misnomer for what’s happening here. Internally, a task isn’t like a thread at all: it’s a function we call over and over, like this: while task.do_some_work() == NotReady {}

好吧,但这是如何工作的呢?

我必须坦白:“休眠”这个词用来描述这里发生的事情其实并不准确。在内部,任务与线程完全不同:它是一个我们反复调用的函数,就像这样: while task.do_some_work() == NotReady {}

So if we have a task that reads from a socket twice, like: let task = async { socket.read().await; socket.read().await; } It becomes something like this: let task = { let mut read_operation = socket.read(); while read_operation() == NotReady {} let mut read_operation_two = socket.read(); while read_operation() == NotReady {} }

所以,如果我们有一个从 socket 读取两次的任务,像这样: 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 {} }

Wait! These are just busy loops! Correct: this async sucks, because it actually isn’t giving up the CPU at all! It might, in fact, be taking up more of it, as it calls the same function over and over in a loop. Instead, we only want to call our do_some_work() or read_operation() function when new work can actually be done (e.g., data can be read from a socket). This might sound familiar if you’ve ever worked with callbacks. socket.on_has_data(|| { read(); }); Callbacks, however, quickly descend into hell, and were actually invented by the indentation industry to sell more TAB keys.

等等!这不就是忙等待循环吗?没错:这种异步实现很烂,因为它根本没有交出 CPU!事实上,它可能占用了更多的 CPU,因为它在循环中反复调用同一个函数。相反,我们只希望在真正有新工作可做时(例如,socket 有数据可读时)才调用 do_some_work()read_operation() 函数。如果你曾经使用过回调(callback),这听起来可能很熟悉。 socket.on_has_data(|| { read(); }); 然而,回调很快就会演变成“回调地狱”,而且它们实际上是由缩进工业发明的,目的是为了卖出更多的 TAB 键。

Wake me up (wake me up inside)

Instead, Rust’s async system uses Wakers and Executors. An Executor is in charge of our tasks: it’s what calls the do_some_work() function. But it will not do that unless we explicitly ask it to. A Waker is an abstraction over “asking the executor to please call our function again, pretty please”.

唤醒我(从内心深处唤醒我)

相反,Rust 的异步系统使用 Waker(唤醒器)和 Executor(执行器)。Executor 负责管理我们的任务:它负责调用 do_some_work() 函数。但除非我们明确要求,否则它不会主动调用。Waker 是一种抽象,意为“请求执行器再次调用我们的函数,拜托了”。

fn do_some_work() {
    // if our data is ready, we can just read it and return immediately!
    if socket.has_data() {
        return Ready(socket.read())
    } else {
        // else, we register ourselves with the socket: it will tell the executor to call us again when it actually has some data.
        socket.on_has_data(|| executor.wake_me_up());
        return NotReady // we aren't ready, so we return for now!
    }
}

fn executor() {
    if do_some_work() == Ready(data) { return data };
    // we call it once, just in case it has data already, or so that it can register itself for waking later.
    while let Some(wake_request) = wake_requests.receive() {
        // we can use a channel or something like it for receiving wake requests
        do_some_work();
    }
}

Okay, let’s do this for real

All the examples so far have been very pseudo-code-y. Let’s actually try to build a minimum viable executor, alongside a bad minimalist implementation of sleep().

好吧,让我们来动真格的

到目前为止,所有的例子都非常像伪代码。让我们尝试构建一个最小可行性执行器,并附带一个简陋的 sleep() 实现。

pub struct Sleep { registered: bool }

impl Future for Sleep {
    type Output = ();
    // instead of our Ready/NotReady pseudocode, here we use the standard library's Poll<T> type, which has two variants: Ready(val) or Pending.
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        if !self.registered {
            // this is our first time being called!
            let waker = context.waker().clone();
            std::thread::spawn(move || {
                std::thread::sleep(Duration::from_secs(5));
                waker.wake(); // ask the executor to call the Sleep future again
            });
            self.registered = true; // make sure we know we've already asked to be woken up in five seconds
            return Poll::Pending // we aren't done yet!
        } else {
            // this isn't our first time being called, so we must have been woken up by our waker!
            return Poll::Ready(())
        }
    }
}

(Note: The article continues with further implementation details regarding TaskId, Task, and the Executor structure.)

(注:原文后续包含关于 TaskId、Task 以及 Executor 结构的进一步实现细节。)