Okay, so, here's the resolution to the problem I got to in my last meandering ramble. To summarize where we left off at:
on the one hand, preemptive, implicit, unpredictable multitasking is awful for reasoning about code behavior any time there can be relevant side-effects, but
on the other hand, preemptive, implicit, unpredictable multitasking is fine and a significant benefit to writing code if no side-effects that could slip in matter.
Here's where I'm at now with this:
What we really need is a system which requires cooperative multitasking unless you have written code which is "pure" code. If something in the system running your code can prove or has been told that your code need not care about side-effects, then it is fine for the system to do preemptive multitasking anywhere in your logic. But, code rootwards in the call tree from your code must be able to control if preemptive multitasking happens in your code.
Because preemptive multitasking is an optimization, the dangerous kind which is liable to change behavior or introduce bugs unless properly constrained, and like most optimizations it cannot be done without some sort of dialog between the thing implementing the optimization and the people or code which is aware of the bigger picture of the specific use-case, situation, and relevant caveats.
The simplest version of this seems trivial to do.
What's left is to figure out how to best make it granular, because stop-the-world blocking of all preemptive multitasking is rarely necessary, and would probably cause problems if code regularly relied on it to prevent race conditions.
Okay, so, here's roughly the thought process, although by now I've re-ordered and re-written several chunks of this, because the original thought process was a meandering thinking-out-loud. And this still is that, but I've tried to make it at least a little coherent.
Clearly "not safe to preempt" has to be transitive, from the caller down. Even if some pure code by itself is fit to get preempted, and even if we had a system where that code actively indicated that it is compatible with preemptive multitasking or did some sort of yield-if-needed operation, if that pure code sits in a code path which cares about any side-effects that might happen when it is preempted or something else is scheduled concurrently, then it needs to be able to call into that pure code without that code being preemptible.
Caveat: for some code, timing and latency is a relevant side-effect. This is why mainstream preemptive multitasking operating systems are so terrible for trying to build real-time computing systems - you can't guarantee any upper time bounds if any number of other things can be preemptively scheduled in between all of your operations. So whatever method we use cannot even assume that code paths of entirely pure code without side-effects in the semantics of its language is safe to preempt.
Finally, part of why "coloring" functions with an `async` keyword is good is that when `async` is transitive rootwards on the call tree, we always know when something we call is forcing concurrency problems onto us. Of course, if you can perfectly contain the concurrency within your implementation then there might be a way to split your call stack into a call tree of coroutines which are concurrent with each other but isolated from all other concurrency, wait for all those to finish or get cancelled, and that's fine to not reveal to the caller. But if your use of concurrency can breach containment in any way, you shouldn't even have the ability to express your code in a way that hides that from your caller. So that is the wisdom of function "colors".
But if we have an explicit yield-if-needed operation, should it be colored async? If you're already convinced of how great `async` color which contaminates all callers recursively is, and if you've accepted the wisdom of structured concurrency that concurrency should not be allowed to breach containment or slip in under you without you knowing about it and consenting, you might be tempted to say that the answer is obviously yes. Every instance of yield-if-needed is an opportunity for side-effects, after all.
So here's the key realization: yield-if-needed isn't saying "I use concurrency here (you are using concurrency by calling me)", it's saying "I yield to concurrency (you can use concurrency while calling me)".
So they are actually really different things, that we need to handle differently. Code which uses concurrency in a way that breaches containment is non-optionally forcing you to use concurrency, and it needs to be seen and managed as a place where something else can be scheduled underneath you. But code which merely yields to concurrency without using it is just optionally concurrent.
And so we could have a system where we can tell the scheduler "hey, I'm going to execute some optionally concurrent code now, but concurrency in this case is not safe for my logic, so don't schedule anything else in the meantime (and I accept that the only choice this might leave you with is to give me a cancellation signal or just kill me if it takes too long in total)".
Also, if yield-if-needed is an async-colored operation, then libraries doing pure logic can't just call it without tainting themselves as impure, as async-colored, and compilers or other tooling might need to do smarter and more extensive code transforms to automatically put it in for you if you don't care and just want the computer to do it for you. Because async-coloring in many languages changes the implementation of a function to return something that at a minimum holds the state which must be saved between pausing the now-interruptible function and resuming it.
So if we decide that yield-if-needed is not an async-colored operation, then
only code that introduces concurrency is async-colored, rather than any code which is not harmed by yielding to concurrency, and
yield-if-needed becomes something that actually could be slapped literally between any two operations in pure non-async code.
So finally we get to the breakthrough: we upgrade the yield-if-needed operation to yield-if-needed-and-safe.
The scheduler brings the "needed". The caller brings the "safe".
Of course, the scheduler has final say, so if you try to disallow yielding for too long you get some sort of cancellation signal, and if you don't obey your process gets killed. In fact I think there doesn't even need to be an explicit cancellation signal in the simplest version of this: when code sees that the "do I need to yield?" value is no longer zero, it could consult another "is it safe to yield?" value. If that says no, it quickly bails out and goes back to the caller with a "scheduler said to yield before I could finish" error. Or maybe there is also a good argument in some cases for the code to yield in that situation, and then only upon being resumed go back to the caller with the "scheduler said to yield before I could finish" error. Either way, getting that error from something you called functions as the cancellation signal.
I really like this. I think this elegantly solves the problem:
if you have logic which really doesn't care about being preempted, you can just write code without thinking about it, and it's trivial for your tooling or the system to automatically inject preemptive multitasking,
if you are calling into pure logic you don't need to worry about whether or not it has an yield-if-needed operations, because they're yield-if-needed-and-safe operations, and your code gets to say whether or not it is safe,
yield-if-needed-and-safe no longer needs to be async-colored, because it can only introduce concurrency which the system has proven would be irrelevant if you followed its rules, or which you have told the system is irrelevant - it can no longer force concurrency onto your caller,
we can implement this in a backwards-compatible way which allows incremental migration: add the yield-if-needed-and-safe operation, and let code opt-into this mode where it can say "don't interrupt me until I say it is okay" but it will get killed if it doesn't yield for too long,
in fact we maybe no longer need the yield-if-needed-and-safe operation, because the system can do the same check that yield-if-needed-and-safe would do, and if it knows you don't want to be preempted, it can just not schedule the next preemption until the latest possible moment,
although the yield-if-needed-and-safe might still turn out to be useful, either as an optimization, or because there is a need for it which I'm not yet seeing.
And the cool thing here is that we could generalize this! We could have systems that let you take any arbitrary sequence of operations, and the entire sequence will either succeed without interruption or race conditions, or fail somewhere along the way because there wasn't enough time in the time slot you had from the scheduler. You still have to do the hard work of figuring out how to deal with any inconsistent state from partial completions, but you no longer have to worry about all the problems that can only happen if something else gets to operate on the same state at the same time.
We could even probably figure out how to do this across processes. So even shell scripts could say "hey please don't schedule something between these commands that I'm about to run". And if the shell script or its child processes take too long, the whole process tree starting from that shell script gets whacked. And it seems like it would be fairly trivial to pass information between the operating system's scheduler and any event loop scheduler or lightweight thread scheduler in user-space, so most software developers would only need to make the request once in whatever language or framework they're using, and it could apply across all the relevant nested schedulers that are in play.
The other way this generalizes is that it would be nice and probably necessary to have the capability for branches of the call tree to isolate their concurrency from all other concurrency. To let code say "I am using concurrency as part of my implementation, and I am fine with yielding to other coroutines which I spawned as part of my logic, but I need to be safe against races with other logic".
(Incidentally, the most mature and thought-out structured concurrency implementation I know of (the Python library Trio) lets you shield a branch of the call tree from cancellation by code rootwards on the call tree. And what I'm describing here is shielding a branch of the call tree from concurrency with code which is rootwards on the call tree. I imagine Trio has cancellation shielding and not concurrency shielding because in most async code, you want other stuff scheduled while you wait on I/O, and you can get away with putting off handling cancellation until you're done with something that needs to finish to keep things consistent. Meanwhile, I'm currently more focused in robustness against race conditions and how to get the benefits of cooperative multitasking while still being robust against code which fails to yield for too long, all while making guarantees about completing within some upper bound of time - so desirability of concurrency is no longer taken for granted, and cancellation is more starkly revealed to be something that needs to be a non-optional final warning. Notably though, to the extent that cancellation shielding can be permitted, it seems a lot like a special case of concurrency shielding.)
So this is all cool, but so far this solution is fairly all-or-nothing. The simplest implementation I described is absolute - there is a "need to yield" boolean (which we might consider turning into a "yield deadline" monotonic clock value) and a "safe to yield" boolean. Of course, we can implement more granular concurrency scopes/shielding on top of that mechanism. The trouble is actually coming up with how to make it more granular than "yes I'm fine with stuff preempting me" or "no I'm not fine with stuff preempting me". What we really want is a way to specify which stuff we are fine being preempted by.
And maybe the answer is almost just locks - mutexes, or read-write locks, the usual. In a sense, locks are how you say "please do not schedule anything else which uses this specific thing at the same time". Of course this was very hard for me to see because a lot of systems do not have locks for most things or actions which you might want to avoid concurrency for, and what locks they do have are often advisory. But if
everything which touched or did a thing in a way where races might matter was required to get a lock for precisely that thing, and
all locks were automatically released if the holder did not release them on their own soon enough,
then holding locks basically functions as precise specification to the system and its scheduler of exactly what you care about not having scheduled during your code.
But... I can see other avenues besides locks which I'd like to really deeply think through here.