KeyboardInterrupt behavior with multiple threads

import threading
import time

stop_signal = [0]
def main():
    try:
        while not stop_signal[0]:
            print(stop_signal[0])
            time.sleep(.5)
    except KeyboardInterrupt:
        print("A")
    finally:
        print("AA")

try:
    faden = threading.Thread(target=main, daemon=False,)
    faden.start()
    while faden.is_alive():
        faden.join(timeout=1)
except KeyboardInterrupt:
    print(faden.is_alive())
    print("B")
    faden.join()

output, when pressing Ctrl+C at some point:

0
0
0
0
False
B

this behaviour does not change when you have each print flush immediately, so why is it that none of the print statements in the thread faden are executed? how exactly is the SIGINT signal handled by a multithreaded python process?

In the normal case the signal is delivered to the main thread only.
That is the standard as defined for posix systems.

considering windows is posix compliant, i suppose this is unexpected behavior then?
i can’t explain why the entire process stops considering the non-daemon thread faden is still running, especially considering stop_signal isn’t even set by the main/original/first thread (that joins faden)

Windows is not posix, far from it.

See signal | Microsoft Learn and the win32 special handling of sigint.

The behavior is the same on all platforms. It’s due to handling of the exception that’s raised in the main thread while it’s executing Thread._wait_for_tstate_lock(), which is called by Thread.join(). If any exception is raised while or just after it waits to acquire the lock, then the exception handler assumes that if the lock is locked, then it must have been acquired by the current thread.

This is a bug. It’s a bad assumption. The thread’s _tstate_lock is always locked by the thread itself. The code is actually waiting for the lock to be released by the thread’s normal shutdown. Because of this bad assumption, _wait_for_tstate_lock() releases the lock (which the current thread didn’t actually acquire, but this is allowed in the case of a simple lock), and Thread._stop() gets called, which sets the thread’s _tstate_lock to None. The thread is actually still running, but as far as the threading module is concerned it’s finished and finalized.

Note also that the code executing in the new thread will never see KeyboardInterrupt due to pressing Ctrl+C in the terminal. Python’s C signal handler sets a flag for the interpreter to handle on the main thread, and only on the main thread, regardless of the platform.

On Windows, the chain of events for a console application is that pressing Ctrl+C in the terminal window or tab causes the host process for the console session (i.e. “conhost.exe” or “openconsole.exe”) to message the Windows session server (i.e. the USER server running in “csrss.exe”) with a request to send a CTRL_C_EVENT to all of the processes that are attached to the console session. For each target process, the Windows session server injects a control thread that starts at CtrlRoutine() in “kernelbase.dll”. This function in turn calls the list of functions that were registered via SetConsoleCtrlHandler(). For a console application, the C runtime library registers a console control handler at startup that emulates Unix-style SIGINT and SIGBREAK signals. The C runtime’s control handler in turn calls Python’s signal handler, which was previously registered via C signal(). Python’s C signal handler sets a flag to let the main thread know that a SIGINT signal has arrived. Finally, TRUE gets returned back to the system CtrlRoutine() function, which simply exits the control thread. When the interpreter is executing on the main thread, it sees that there’s a waiting SIGINT signal and calls the registered handler. The default SIGINT handler is signal.default_int_handler(), which raises KeyboardInterrupt.

1 Like