0

First off, I'd like to clarify that I'm not talking about concurrency here. I fully understand that having multiple threads modify the UI at the same time is bad, can give race conditions, deadlocks, bugs etc, but that's separate to my question.

I'd like to know why MacOS/iOS forces the main thread (ID 0, first thread, whatever) to be the thread on which the GUI must be used/updated/created on. see here, related:

on OSX/iOS the GUI must always be updated from the main thread, end of story.

I understand that you only ever want a single thread doing the acutal updating of the GUI, but why does that thread have to be ID 0?

(this is background info, TLDR below)
In my case, I'm making a rust app that uses a couple of threads to do things:

  • engine - does processing and calculations
  • ui - self explanatory
  • program/main - monitors other threads and generally synchronizes things

I'm currently doing something semi-unsafe and creating the UI on it's own thread, which works since I'm on windows, but the API is explicitly marked as BAD to use, and it's not cross compatible for MacOS/iOS for obvious reasons (and I want it to be as compatible as possible).

With the UI/engine threads (there may be more in the future), they are semi-unstable and could crash/exit early, outside of my control (external code). This has happened before, and so I want to have a graceful shutdown if anything goes wrong, hence the 'main' thread monitoring (among other things it does).

I am aware that I could just make Thread 0 the UI thread and move the program to another thread, but the app will immediately quit when the main thread quits, which means if the UI crashes the whole things just aborts (and I don't want this). Essentially, I need my main function on the main thread, since I know it won't suddenly exit and abort the whole app abruptly.

TL;DR

Overall, I'd like to know three things

  1. Why does MacOS/iOS enforce the GUI being on THread 0 (ignoring thread-safety outlined above)
  2. Are there any ways to bypass this (use a different thread for GUI), or will I simply need to sacrifice those platforms (and possible others I'm unaware of)?
  3. Would it be possible to do something like have the UI run as a separate process, and have it share some memory/communicate with the main process, using safe, simple rust?

p.s. I'm aware of this question, it's relevant but doesn't really answer my questions.

Anon
  • 13
  • 1
  • Why is this really a problem? Make a new thread. Live there. This convention isn't uncommon, to be honest. – tadman Dec 22 '22 at 20:18
  • 1
    "if the UI crashes the whole things just aborts" seems to suggest the actual problem is "I need to know how to rescue exceptions my UI". – tadman Dec 22 '22 at 20:19
  • 2
    *"which means if the UI crashes the whole things just aborts"* - If *any* thread crashes the whole app goes down. – HangarRash Dec 22 '22 at 20:21
  • 1
    @tadman There isn't a good way to handle exceptions in Cocoa. The ObjC runtime is not exception-safe. Separating the UI from the core is absolutely legitimate, and a a major reason xpc was developed. – Rob Napier Dec 22 '22 at 20:21
  • 3
    If you really need to have a "back end" that keeps running despite the possibility of a UI crash, then you might consider making the back end and the UI separate processes. – Solomon Slow Dec 22 '22 at 20:23
  • I am a bit confused about part of your question: you suggest that one thread might crash, you just don't want that to be thread 0. How are you implementing that without putting the entire process into an undefined state? I know there are unhandled exception handlers, but those don't save you. The whole process is still in an undefined state and needs to terminate "soon." https://stackoverflow.com/questions/58045829/why-does-a-single-thread-exception-crash-entire-program-how-to-prevent-this This is why modern web browsers use separate processes for each tab. – Rob Napier Dec 22 '22 at 20:35
  • I may have been unclear here. I know my 'watcher' thread will never panic/exit outside of a graceful shutdown. Since thread 0 is the 'main' thread, it exiting aborts the app immediately, without any cleanup. My watcher thread handles any cleanup, so I'd like that to be running last. I'm aware of undefined state, but that shouldn't be too serious (the UI literally just displays stuff, nothing else). – Anon Dec 23 '22 at 09:10
  • Also I would like to point out that @HangarRash is incorrect, a thread crash does not abort the process. Proof: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=27224843c201eb896bee588d31f093a5 – Anon Dec 23 '22 at 09:11
  • 1
    @tadman See my other comments, I need to have a way to perform cleanup, but if the main thread exits, I can't since the whole process just stops. – Anon Dec 23 '22 at 09:12
  • Follow-up since I can't edit: @RobNapier I'm aware of undefined state, but I don't expect there to be any. All my UI does is display data from other threads, and possibly send messages, but the app could easily keep running without it. The UI is *just for display*, does no processing, doesn't change state, or anything else like that. Same for the engine thread, I can just restart it since there isn't any persistent mutable state. – Anon Dec 23 '22 at 09:21
  • 1
    @Anon At least in an iOS app, if any thread crashes, the whole app crashes. – HangarRash Dec 23 '22 at 14:58
  • If one part of a process enters undefined state, that contaminates the entire process. The fact that one thread isn't *meant* to modify anything in other threads doesn't mean that it won't once you've entered an undefined state. There is no memory protection between threads, only between processes. The process does not recognize things like "immutable state." If it's writable memory, any thread can trash it. A thread that has smashed its stack can execute any code in the program, not just code that's "assigned" to that thread. What you're describing definitely wants independent processes. – Rob Napier Dec 23 '22 at 23:27
  • 1
    @RobNapier thank you for clarifying. Just read the docs (https://doc.rust-lang.org/book/ch09-03-to-panic-or-not-to-panic.html#to-panic-or-not-to-panic) and it seems my understanding was wrong - panics should always be unrecoverable. Going by you answer, it seems that a multi-process model would be best. Considering I've already got inter-thread message pipes, it shouldn't be too hard to do so. – Anon Dec 24 '22 at 08:29
  • @Anon As a matter of Rust-specific software engineering: Rust panics are intended for errors that are unrecoverable _for the current task_ (that is: the program is giving up on completing that task) but not necessarily the entire process. The important thing is that the bad state must be discarded cleanly upon panic-unwind. If memory safety can't be maintained, then you have to make sure to abort, not merely panic, to keep your program sound, but if memory safety _is_ maintained, then recovering from panic in the sense of "the program keeps doing _something_" is something Rust is designed for. – Kevin Reid Dec 24 '22 at 16:03
  • @KevinReid How would I ensure that memory safety would be maintained? In my current implementation, the only 'shared' data is a few structs with some primitives, and messages (pipes) are used for the majority. Is this safe enough or would I need to get rid of all sharing completely? – Anon Dec 26 '22 at 07:47
  • @Anon In regular, safe Rust (that is, Rust code containing no `unsafe`), you don't need to do anything to ensure memory safety; that's the entire point of the safe/unsafe system. – Kevin Reid Dec 26 '22 at 15:53

1 Answers1

4

Why does MacOS/iOS enforce the GUI being on Thread 0.

Because it's been that way for over 30 years now (since NeXTSTEP), and changing it would break just about every program out there, since almost every Cocoa app assumes this, and relies on it regularly, not just for the main thread, but also the main runloop, the main dispatch group, and now the main actor. External UI events (which come from other processes like the window manager) are delivered on thread 0. NSDistributedNotifications are delivered on thread 0. Signal handling, the list goes on. Yes, it is certainly possible for Darwin (which underlies Cocoa) to be rewritten to allow this. That's not going to happen. I'm not sure what other answer you want.

Would it be possible to do something like have the UI run as a separate process, and have it share some memory/communicate with the main process, using safe, simple rust?

Absolutely. See XPC, which is explicitly for this purpose (communicating, not sharing memory; don't share memory, that's a mess). See sys-xpc for the Rust interface.

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • 2
    Of course XPC is only for macOS, not iOS. – HangarRash Dec 22 '22 at 20:23
  • Agreed. This is not solvable on iOS (but the question doesn't really make sense in iOS; many required things are impossible there). The whole idea of having isolated pieces that could crash and the whole system stay stable is not implementable in a practical iOS code. – Rob Napier Dec 22 '22 at 20:25
  • "Luckily" the problem also doesn't exist in iOS, since you can't create "external code" in iOS as described. Everything your app loads must be shipped with it, so you can (and must) test it all before shipping. "Let it crash like Erlang" is not a thing on iOS, but neither are plugins. – Rob Napier Dec 22 '22 at 20:42
  • Thank you for your answer (and comments). Are there any corss-platform alternatives to XPC? – Anon Dec 23 '22 at 12:26
  • @Anon practically any cross-platform IPC mechanism (pipes, local sockets, shared memory) and abstractions on top of those. – nneonneo Dec 23 '22 at 13:24
  • Agreed. Any portable IPC library will likely work with Darwin. Boost is popular. But for Rust, I'd probably look at something like https://github.com/servo/ipc-channel – Rob Napier Dec 23 '22 at 23:20
  • Or use a loopback HTTP server. I've built these many times as a cross-language IPC mechanism because it's so convenient. I've often built them using a named pipe as a transport rather than a TCP port because they're a little easier to secure that way, but there are many approaches (and you can change your mind about transport after you nail down your API). HTTP is so ubiquitous that there's almost always a ready-made solution to whatever problem you're facing. – Rob Napier Dec 23 '22 at 23:34