python-trio / trio

Trio – a friendly Python library for async concurrency and I/O
https://trio.readthedocs.io
Other
6.15k stars 335 forks source link

Does it make any sense for trio to support micropython? #351

Open njsmith opened 6 years ago

njsmith commented 6 years ago

I don't really know anything about micropython, but @pfalcon recently brought to my attention that they have their own incompatible variant of asyncio, uasyncio, which makes interesting choices like leaving out Futures altogether.

This leads me to wonder: how well trio would work on micropython? I'm not thinking of any ridiculously spendthrift data structures... we do currently splurge on a sortedcontainers.SortedDict for tracking timeouts instead of a traditional heap, in order to get O(1) removal of stale entries, but that's a pretty self-contained bit of code and certainly not visible to users. OTOH there are probably another thousand things I'm not thinking of that would make microcontroller devs gasp in horror :-). Anyway, it'd be interesting to know what would be required to make it work, or why concretely it couldn't work, so maybe we can collect any notes on that here in this issue.

njsmith commented 6 years ago

I guess our Task objects are probably on the heavy side for µpython currently, especially with the duplication of cancel stacks into children.

Looking at the uasyncio readme, it also seems like we'd need somewhat different timekeeping strategies, though our existing pluggable Clock interface might be enough to handle a lot of this.

I guess we'd also need a different IO backend. I have no idea what kind of select-like API is available on µpython.

pfalcon commented 6 years ago

Does it make any sense for trio to support micropython?

Thanks for considering that, but I don't think it's practical. There're 2 main reasons:

  1. It won't work on MicroPython, as that (deliberately) lacks various "convenience" things.
  2. It won't work on "microcontroller", or rather, you'll have a hard time finding one where it will. Because it's very heavy. (So, the only realistic port to run it on would be "Unix" port).

In general, MicroPython "zen" is described here: https://github.com/micropython/micropython/wiki/ContributorGuidelines

But at the same time, we criticized asyncio with almost the same words (https://github.com/python-trio/trio/issues/32#issuecomment-347330239), while approaching it from different size:

So, we can compare to what we came in the end ;-). Also, if you had an idea how to do something in Trio (very) simply, but that turned out to be too simple for its intended usage, well, that could be just good for uasyncio. Or if you would look into uasyncio and find something blatantly incorrect or which can be simplified beyond what it already is, I'll gladly accept accept criticism. I'd also appreciate just discussion, but from the above, it should be clear that my focus is on minimality (more specifically, how to do as much as possible with as little as possible, finding a balance there), so I'm not sure if you're interested in that.

Oh, I'll be glad to share any criticism of Trio too, as I'm going to learn from it ;-).

pfalcon commented 6 years ago

To start with, if you let, I'd like to truly understand how your cancellation model works, as that's hot topic for uasyncio: https://github.com/micropython/micropython-lib/issues/215 (tilted "uasyncio timeout functionality (wait_for) fiasco").

And here's a dumb question: how do you track "wall-time" (i.e. "I sleep for 10s, run me then") deadlines in Trio. (Feel free to accept that as a bit of criticism, because I of course looked at the code, and not sure I saw the answer [1] ;-))

[1] Granted, I'm still in the middle on my homework on Trio's textual material, at least I took these couple of days to look thru stuff.

Thanks.

pfalcon commented 6 years ago

https://trio.readthedocs.io/en/latest/design.html :

Rule 2: Cancel+schedule points are determined statically. A trio primitive is either always a cancel+schedule point, or never a cancel+schedule point, regardless of runtime conditions.

That's hardcore ;-).

This is because we want it to be possible to determine whether some code has “enough” cancel/schedule points by reading the source code.

"yield" is a guaranteed cancel/schedule point. (Not arguing, just showing uasyncio's simple answers.)

pfalcon commented 6 years ago

Rule 4: in trio, only the potentially blocking functions are async. So e.g. Event.wait() is async, but Event.set() is sync.

Damn, that's how StreamWriter.write() got being sync in asyncio. Introduction of infinite buffers is the next step ;-).

pfalcon commented 6 years ago

Enforcing the trio vs trio._core split is a way of eating our own dogfood

I don't know if you noticed or not, but uasyncio also consists of uasyncio.core https://github.com/micropython/micropython-lib/tree/master/uasyncio.core and uasyncio https://github.com/micropython/micropython-lib/tree/master/uasyncio . The split is different though - .core implements just tasks scheduling ("CPU scheduling"), and uasyncio adds a particular I/O scheduling to it by subclassing. uasyncio.core its own requirements, like being able to do scheduling (well, running a schedule), including timed sleeps, without memory allocation (and thus, fragmentation).

I/O scheduling is done in a not unexpected way: http://docs.micropython.org/en/latest/unix/library/uselect.html, but note how we optimize for what uPy needs: http://docs.micropython.org/en/latest/unix/library/uselect.html#uselect.poll.ipoll . "callee-owned tuples" there is not linked to a glossary entry yet, so you can let your imagination run re: how uPy extends Python semantics in the direction it needs ;-). (With the argument that all based on the prior art, like Python is about the only popular scripting language I know which supports concept of inplace operations).

(No big surprises re: which).

pfalcon commented 6 years ago

And here's a dumb question: how do you track "wall-time" (i.e. "I sleep for 10s, run me then") deadlines in Trio.

Well, I kinda saw it at the previous look, just didn't believe, that's what inertia of mind is. So, there's runner.runq for "untimed tasks" and timed deadlines are in runner.deadlines, and sleeps are implemented via deadlines too. And Trio schedules running looking at both runner.runq and runner.deadlines[0].

In uasyncio, I never even thought of using a list as a task queue, because in uPy a list is a list, with expected O(n) for list.pop(). collection.deque would need to be used, but that still non implemented even as of now. Then, using 2 structures in general doesn't make sense, as those need to be preallocated per uPy requirements, and then can be used asymmetrically, e.g. one app will leave "runq" empty, another - "deadlines". That's why single heap structure is used for everything. This leads to some issues, yeah, as heap is not stable, so extra word has to be spent in heap entries to compensate.

While working on https://github.com/micropython/micropython-lib/issues/215 , I briefly had that idea of "separate structure for tracking timeouts", but it got subsumed by the same idea above - why another structure, if there's already one which does time tracking well.

uasyncio uses "soft delete" (i.e. marking of "void" entries in queue), so, if an app uses a lot of long timeouts, which aren't hit, the queue may need to be long, but that's up to a user to control.

But! I may want to revisit that, after all ;-). The idea of "one queue to rule them all" doesn't work unfortunately, there're always more than queue. For example, I/O blocked queue (though the idea is to optimize it into select.poll() queue, but it's still a separate queue). Because idea to save on all the heap fields by having just a single-field deque while getting stable order is nice. Then deadlines queue probably could drop "tiebraker" field too.

And damn, I finally understood how to get efficient entry deletion from a heap...

njsmith commented 6 years ago

"yield" is a guaranteed cancel/schedule point. (Not arguing, just showing uasyncio's simple answers.)

Sure, but as an end-user how do you know which functions call yield? That's what the bit you're quoting is about. (This is partly a response to starvation issues that curio is susceptible to because their recv/send calls don't necessarily yield.)

Damn, that's how StreamWriter.write() got being sync in asyncio. Introduction of infinite buffers is the next step ;-).

While I know legacy compatibility can be frustrating, Trio does still support machines with finite memory, so considers that sending data counts as an operation that can potentially block. If we ever drop support for such machines then I'll consider revisiting this ;-P

Well, I kinda saw it at the previous look, just didn't believe, that's what inertia of mind is. So, there's runner.runq for "untimed tasks" and timed deadlines are in runner.deadlines, and sleeps are implemented via deadlines too. And Trio schedules running looking at both runner.runq and runner.deadlines[0].

Yeah, this is definitely one of the bits of Trio that looks really weird if you're used to reactor-style designs. The logic is that my analysis of confusing bits of asyncio/Twisted is that a lot of problems are caused by the free use of "implicit concurrency" -- functions that don't actually do the work they advertise, but schedule it to happen later → trio flatly refuses to have any such operations aside from the task-spawning methods on nursery → there is no call_at, or protocol methods, or anything like that; event notification is always done by waking up an existing task rather than running a callback → "wake up a task at this time" is basically what timeouts are all about, so that's how trio handles time in general.

This also explains the distinction between runq and deadlines: a deadline expiration doesn't actually wake up a task directly, it just invokes the cancellation machinery, which may or may not succeed in waking up a task depending on what it's doing. (It might be stuck in some non-cancellable operation like running something in a worker thread, or in some not-instantly-cancellable operation like a Windows IOCP call.)

there're always more than queue

In general yeah, that's how I think of it in Trio – runq is really just the set of tasks that are currently runnable; sleeping tasks are stored in all kinds of places depending on what kind of sleeping they're doing.

I don't actually know why runq is a deque. Inertia I guess. The only operations I actually perform on it are .append() and .clear()... I guess the most memory-efficient way to implement what I do with runq would be to make it a fixed-size circular buffer, where tasks are appended as they become runnable and removed as they get run.

If you have a call_at primitive then the trade-offs are probably different though :-).

smurfix commented 5 years ago

Heh. I just found this issue.

I actually finished porting the Trio core to MicroPython last week (and found a µPy bug while I was at it), mainly by ripping out stuff that it doesn't support well, if at all. Metaclasses, "attr", mutable exception tracebacks, threading, subprocesses. (Yes this means you can't do async DNS lookups. Is there a Trio version of gethostname?)

The problem I now have is that nobody ported pytest to MicroPython yet (if that's even possible), so all these nice Trio testcases can't run …

I also didn't yet hook up µPy's interrupt mechanism, though that should be reasonably easy.

This is most likely going to be a fork. We don't want to pepper the main code with Micropython-specific conditionals; worse, large files are difficult to compile on some of the smaller CPUs no matter how much code ends up under if False:.

njsmith commented 5 years ago

@smurfix re: porting Trio to µPy, you should also talk to @dhalbert – he's been actively trying to figure out async support for CircuitPython. See https://github.com/adafruit/circuitpython/issues/1380 and https://trio.discourse.group/t/python-for-microcontrollers-and-structured-concurrency/154