-
Notifications
You must be signed in to change notification settings - Fork 171
Description
Thanks for writing this guide @peterhinch !
1. Introduction:
I think it's worth adding micropython.schedule as another context, or possibly more accurately splitting (1) into:
- Hard ISR
- Soft ISR or scheduler callback
I've seen a few examples of asyncio code that was running into issues due to use of micropython.schedule (and one example where essentially the entire program ran in scheduler context). But the main reason is that Hard and Soft/Schedule have very different implications for syncronisation.
It's of course an extra challenge that there are two "schedulers" at play here, the asyncio scheduler, and micropython.schedule.
1.1 Interrupt Service Routines
"The ISR and the main program share a common Python virtual machine (VM)." -- It might be important to clarify what this means. I'll come back to this in (1.3).
"Consequently a line of code being executed when the interrupt occurs will run to completion before the ISR runs." -- The VM has basically no concept of "line of code", rather it only deals with bytecode instructions. Technically a soft ISR (or scheduled task) can only run at certain bytecode boundaries, but these do not necessarily align with lines of code.
In the case of a hard ISR, the ISR can occur anywhere, even in the middle of a single bytecode instruction. For example, if you wrote a + b
where a and b are lists, then an ISR in the middle of the op could modify both a
and b
, but the result could be old_a + new_b
.
"ISR code should not call blocking routines and should not wait on locks." -- In general, an ISR must not wait on a lock because it's impossible for something else to unlock it. The exceptions would be to wait on a lock with zero timeout (i.e. optimistically try to acquire the lock and abort otherwise), or maybe in a soft/scheduler context when the lock is set by a hard context.
1.2 Threaded code on one core
It might be worth clarifying that the GIL protects built-in data structures that are atomic at the bytecode level. However, any user-defined data structure gets no benefit from the GIL. (And ultimately, this is why it's not safe to call any asyncio methods like create_task from another context).
1.3 Threaded code on multiple cores
"There is no common VM hence no common GIL. " -- I guess it depends what you mean by "common VM" here, but I'd argue that there is a common VM, it just doesn't have a GIL.
The issue here is that in all circumstances, the VM is common (i.e. they share global state), but there can be multiple concurrent stacks. Hard interrupts are like the VM called your Python function while in the middle of running a bytecode instruction, soft interrupts are the VM calling your Python function in between instructions (note in both cases they share the same stack).
"In the code sample there is a risk of the uasyncio task reading the dict at the same moment as it is being written". This particular example may actually be completely fine, depending on the scenario.
If what you need is for the three read_data calls and the update of the dict to be atomic, then yes, you need a lock. i.e. if a reader must always see d["z"] that matches the same read cycle as d["x"].
But if you're happy to treat the three slots in the dictionary (and their corresponding values) as independent (maybe you're reading three temperature sensors, and the code just needs to know the latest), then there's no issue with this code. No locking required. This is because concurrently updating a dictionary entry is safe.
Where this falls apart is if you have concurrent threads that modify the dictionary in a way that isn't safe. e.g. one thread adding/removing items. (Simple example: one thread triggers a resize&rehash of the dictionary).
The same argument applies here to modification of global variables (which are just dictionaries with nice syntax). Two concurrent threads can modify global variables safely, as long as no thread does del x
or creates a new global.
2.1 A pool
"It is possible to share an int or bool because at machine code level writing an int is "atomic": it cannot be interrupted. In the multi core case anything more complex must be protected to ensure that concurrent access cannot take place. The consequences even of reading an object while it is being written can be unpredictable. "
I think what you're saying here is exactly correct, but I think the emphasis on the object type might be confusing for people that don't understand the details.
So for example, I can have a global variable (or dict entry) that is a complex object, and a concurrent writer that is updating that entry. It doesn't matter that it's a complex objectl, the "update" operation is just updating a pointer, which is atomic.
What matters is that it's not safe for me to write code that reads multiple fields from the object, i.e. d["x"].a
followed by d["x"].b
if someone concurrently modifies d["x"]
then I'll read a and b from different things.
What the locking in this example ensures is that the consumer always sees x,y,z together from the same "cycle". (And I believe this is exactly the point you're trying to make, this is a very difficult guide to write because there are so many independent concepts going on here).
"As stated above, if the producer is an ISR no lock is needed or advised." -- However, in the case, a consumer must disable IRQs in order to achieve the consistency that the lock provided, otherwise the producer could modify the state mid-read.
Sorry, have to stop there for now, but will come back to it soon.