pre-ACP: identifying privileged threads

Split off from Ability to determine if current thread is the main thread of process (cc @LunarLambda). (Likely not going to actually submit, but providing a draft for someone more motivated to do so.)

Problem statement

Provide a standardized way of identifying threads with special semantics assigned by the runtime and/or OS. The only portable special thread is the one which runs the binary entrypoint (main). OS-specific thread queries are available under std::os.

Motivating use cases

Sometimes OS APIs are restricted to being called from specific threads. Normally thread locality in Rust should be handled by handle types which are not Send, and thus cannot be transferred across thread boundaries. Unfortunately, this isn't always practical, such as with zero-argument functions without a clear preexisting context to tie a non-Send token to.

The primary example of this is windowing. On some reasonably relevant targets, it is impossible to create a windowing event loop except on the expected thread. In order to expose a more consistent API across platforms, winit chooses to restrict creating an event loop to the main thread on all platforms, with target-specific extensions to relax that check to the one actually required by the target.

Usually the preferable way to run some code on the event loop thread from any thread is by running a callback on the event loop (e.g. event_loop.spawn(callback)). This obviously isn't an option when creating the event loop. Similarly, helpers expecting to run on the event loop thread would generally prefer to consistently panic if run on the wrong thread (e.g. assert!(is_main_thread())) instead of returning OS-specific error conditions from OS-dependent subsets of functionality.

Solution sketch

Presented roughly in rustdoc format.

Function std::thread::main

pub fn main() -> Option<Thread>

Gets a handle to the process main thread.

The main thread is the thread which is used to execute the program's main entrypoint and shuts down the program execution when it terminates independent of any other threads.

This function only returns Some when the program main is defined in Rust. Additionally, it may return None even when main is defined in Rust, such as when compiled into a system library.

Because of this, it is generally preferred to use other mechanisms for thread cooperation when possible, such as capturing the parent Thread handle in the closure spawned on new threads.

OS-specific versions of this functionality that work even when the program entry is externally managed are provided in the std::os modules where possible.

Examples

let main_thread = thread::main().unwrap();
let this_thread = thread::current();

if this_thread.id() == main_thread.id() {
    println!("running on the main thread {:?}", main_thread);
} else {
    println!("running off the main thread {:?}", main_thread);
    println!("running on spawned thread {:?}", this_thread);
}

Implementation strategy: during rt init, when the main thread name is set, store the main thread handle into a global. thread::main then returns the value from that global. If the rt was never init, that value is None.

In fact, this is already done to set/get the thread local record of the current thread handle. All that needs to happen extra is a full global for the main thread.

Function std::os::linux::thread::is_main_thread

(Available on cfg(linux) only.)

pub fn is_main_thread() -> bool

Returns true if the current thread is the main thread.

A thread is considered the main thread if the thread id is the same as the process id. Because thread ids can get reused after a thread exits, this function may potentially return true for a thread started after the main thread exited. [This is only possible if the main thread is external to Rust.]

Implementation strategy: getpid() == gettid().

The bracketed sentence relies on a forced unwind out of Rust main being disallowed for correctness. That is technically a property we haven't committed to yet. (Currently any forced unwinding — deallocating stack frames without running unwind handlers / drop glue — is always UB. Lack of unwind handlers in the unwound stack frames is currently considered a necessary but not sufficient condition for forced unwinding to not be UB.)

Function std::os::windows::thread::is_gui_thread

(Available on cfg(windows) only.)

pub fn is_gui_thread() -> bool

Determines whether the calling thread is already a GUI thread.

A non-GUI thread can be converted to a GUI thread, so returning false does not mean this function won't return true for this thread later.

Implementation strategy: Win32::UI::WindowsAndMessaging::IsGUIThread(FALSE).

There may be other targets which can offer relevant OS-specific functions here, but the ACP author is not familiar enough with such targets to determine such. E.g. I think macOS/iOS exposes an isMainThread property via ObjC message.

Alternatives

Do nothing, and leave this functionality to be provided by external crates like is-main-thread (extracted from winit over 3 years ago). The functionality provided by std::thread::main cannot be fully replicated externally, as it requires running code before main. E.g. on Windows, is-main-thread uses a global dynamic initializer to record the thread id of the thread which runs it to test against. This works like the proposed implementation of std::thread::main in a static executable, but in a dynamic library the test is not against the program main's thread but the DllMain thread instead. (For winit on Windows, the test is essentially just a lint, so this is acceptable.)

Only provide std::thread::main, and leave it to ecosystem crates to provide the OS-specific tests. This is a reasonable alternative.

Stop testing for the main thread in the ecosystem, and test the actual OS-specific requirements that "main thread" is a proxy for, either by ecosystem crates or std::os support. While this is "more correct," it limits the predictable portability of cross-platform libraries trying to encapsulate different targets with different requirements. "On the main thread" is commonly used as a proxy requirement because it's both simple to understand and sufficient on effectively every platform. (Though in some configurations, the "main thread" might not be able to be the literal main entrypoint. In a perfect world, there'd be a separate target for each which can shim the actual entry to Rust main.)

6 Likes

Windows also has thread pools, midi and other media API threads which can matter a lot in callbacks, and other fun stuff, but annoyingly it looks like there's no nice query API :pensive:

1 Like

Fun fact: calling IsGuiThread can, indirectly, cause a single thread to become a gui thread even if the convert parameter is FALSE. The function itself is loaded from user32.dll and loading user32.dll, amongst other things, sets up a message input queue on the thread that runs its DLL main (which incidentally is likely to be the main thread if it's loaded on startup). This is usually not an issue as it's very likely you actually do want to do other gui stuff if you're calling that function.

4 Likes

I'm a bit ignorant of Windows, but wouldn't this only apply if you do something like dlopen (LoadLibrary?), not if you link normally, in which case it would be loaded before main by the dynamic linker? And you would presumably be linking normally (not loading at runtime) if you just normally use this function? At least std could make sure to link user32 this way.

At least that is how it would work on Linux and other ELF platforms.

The loader runs on the main thread before the program entry point is ever called. So it is essentially pretty similar to using LoadLibrary, just very early in the program's life.

Incidentally, std typically avoids calling functions that initialize GUI as a side-effect. For example.

2 Likes

Trying to make the main thread special even though an event loop could be run on a different thread is a problem because it turns an easy-to-obtain resource into a contended resource. Providing a sloppy abstraction perpetuates the problem.

1 Like

FWIW, on the targets where the restriction is not required, winit supports bypassing the "main thread" check. I would expect this to remain the case for high quality ecosystem crates.

And the main thread is still at least somewhat special, because it needs to stick around while the event loop runs or else the process will terminate early.

That is crazy overhead, and it is absurd to trigger such an overhead for just using an unrelated library function. Windows really has a very strange and inefficient design.

(Yet another reason I stay well away from it. But I will spare you the full rant on how Windows is flawed on every level from file system to the user interface, it does not belong in this forum.)

On Windows if the main thread calls ExitThread then the main thread will cleanly exit but other threads will continue running until they all finish (or one calls ExitProcess). Granted, this would be unusual.

This is not allowed when Rust stack frames are on the stack. It is permitted to cooperatively unwind through Rust stack frames (that is, unwind by the platform native method that runs unwind handlers, so long as moving between native and Rust frames is done over extern "C-unwind" and the same language catches an unwind as starts it), but thread termination is a form of forced unwinding where stack frames are unwound/deallocated without running unwind handlers. The C-unwind RFC which allowed cooperative unwinding declared the lack of any locals or temporaries with drop glue ("plain ol' frames" with no (or trivial) unwind handlers) a necessary but not sufficient precondition for forced unwinding through Rust code to be sound. There is currently no sufficient precondition.

Because the Rust main entry point permits unwinding and the runtime catches said unwind, I find it very unlikely that it could ever be permissable to terminate the Rust main thread abnormally. (A Rust #[start] entry point (unstable) is not allowed to unwind, and will probably get an unwind-to-abort shim along with extern "C" soon.) Thus the process main thread can only be terminated independently of the process if it is external to Rust and thus std::thread::main().is_none().

The Microsoft docs for ExitThread also explicitly say

In C++ code, the thread is exited before any destructors can be called or any other automatic cleanup can be performed. Therefore, in C++ code, you should return from your thread function. [As opposed to C code which has no destructors and ExitThread is preferred.]

and this applies equally to Rust code.

Granted that it's currently unspecified (pending a future RFC) but even it it does end up being considered UB then that still leaves the case of a C runtime that does not call ExitProcess after main (note: I'm using "C runtime" as a shorthand for "thing that calls main, not necessarily an ISO C compliant runtime").

Rust claims that exiting the Rust main terminates the process. If Rust is made available on a target where exiting #[start] (roughly speaking, the linker defined program entry point[1]) does not terminate the process, then the std-provided #[start] which calls user main will need to include an exit(0) on said target(s).

This is why the proposed std::thread::main can portably care about the "Rust main" thread; the Rust runtime confers special semantics to it even if the host OS doesn't.

From the std::thread module docs:

When the main thread of a Rust program terminates, the entire program shuts down, even if other threads are still running.


  1. MSVC makes things more complicated again! :sweat_smile: TL;DR the linker-level entrypoint on Windows is typically a function provided as a static library injected by MSVC build tooling which initializes the C runtime, runs global initializers, and then calls the appropriate user-level entry function. But this isn't completely transparent, because you can override this behavior. As opposed to Linux, where IIUC global initializers are run by the dynamic linker before it calls into the configured program entrypoint. ↩ī¸Ž

I don't object to this being forbidden for Rust programs, but on Unix it is considered fully supported and not strange for a C program to call pthread_exit from the initial thread, leaving other threads running.

1 Like

Note that this is easy to implement on macOS (pthread_main_np) which also has very hard rules around what main and non-main thread can and cannot do (It is always UB to do UI things off of the main thread, for example -- it is not a proxy for other behavior).

1 Like

Noting that pthread_main_np is available on all target_vendor = "apple", i.e. also iOS, tvOS and watchOS.