Context and prior understanding:
In Node.JS, I/O code is run in a thread pool / event loop parallel to the main JavaScript thread. async code is never truly blocking per-se, unless a blocking call (e.g. fs.*sync functions) are called, which usually shouldn't happen. When the blocking code yields a result, the main loop obtains it from a concurrent queue and runs the continuation.
In Rust, async code is usually run in a multi-threaded runtime like tokio. tokio distinguishes two thread pools: one for scheduling the continuations of async tasks — which also shouldn't be used for blocking code as it relies on cooperative scheduling, and one "worker pool" for the blocking (I/O without pollable syscalls, or CPU-bound) tasks. This is pretty much the same thing as Node, but with multiple threads for polling the tasks/futures. Also, Rust has actual multithreading, so CPU-bound code can be run in the same process, as long as it runs on the worker pool.
But on Android with Kotlin, some suspend functions provided by libraries, when awaited from the main thread (using lifecycleScope.launch {}), will cause the runtime to crash. One example of that are suspend functions defined using Room (the SQLite3 wrapper). I would expect the blocking code to be run on another thread, and the result to be yielded back to a main thread's async runtime, but that's apparently not what's happening. To avoid the exception, I have to use withContext(Dispatchers.IO) {}, but this function is supposed to be used to schedule actually blocking code on a worker thread, right? From my bad understanding, it seems like Android (or Kotlin) conflates some concepts I'm used to. Or maybe, Room's suspend functions are actually implemented as blocking functions whose coroutine resolves immediately, and the suspend qualifier is just there for looks?
Another weird detail is that running these suspend functions from the main thread doesn't always cause an exception, as if there was some non-determinism in the thread pool used to schedule the coroutines.
IO isn't allowed on the main thread at all. That's why an IO coroutine isn't allowed on the main thread. This is for several reasons:
1)The central message loop. If control isn't returned to the central message loop, the entire UI will be unresponsive. As such, we need to ensure that the main thread is not waiting for significant amounts of time. Coroutines do not solve this problem, as coroutines just allow a thread to be reused, it would not allow the thread to go back to the message loop. And changing the message loop so it could do that would cause major problems, as part of the message loop's purpose is sycnhronizing events.
This isn't a problem in JS because the entire JS engine runs on its own thread, separate from the browser's UI. So even if there is a message loop, it won't hold up the UI.
2)Lifecycle functions. This is similar to 1, but a special case of it. Lifecycle functions like onCreate, onPause, etc are timed. If they go to long, the app is unresponsive. As such the OS will kill them under the assumption that the app is broken. This is what causes ANRs. This watchdog timer behavior is very common in hardware and embedded worlds.
3)The OS wasn't written for kotlin or other languages with coroutines or await mechanics. It was written for Java. In that world, coroutines don't exist so there was no reason to try to allow them. But moving out of that world would take major changes to the framework for minor gains (that would still probably be bad practice) so I don't expect them to do it.
As for your claims that you can sometimes call suspend functions on the main thread- you can't, and the compiler will not compile the code if you try. You're missing something there that calls it from an allowed context.