Skip to content

[Bug]: macosx backend crashes when used on a worker thread #31968

Open

Description

@iccir

Bug summary

While Interactive figures and asynchronous programming specifically states that "In general Matplotlib is not thread safe", the macosx backend crashes with SIGSEGV or SIGTRAP when various methods are called on a worker thread:

Method Result
FigureCanvas.flush_events CRASH
FigureManager.set_window_title Obj-C Exception
FigureManager.show CRASH
FigureManager.resize CRASH
FigureManager.full_screen_toggle CRASH

Some methods, like FigureCanvas.set_cursor, do work without crashing; however, NSCursor isn't documented to be thread-safe. It's also not on the historic list of thread-unsafe classes, so our usage falls into a gray area.

I think we should default to setting a Python error when any macosx backend method is called on a background thread, with specific exceptions for Timer or FigureCanvas.draw_idle(). Possibly others?

Code for reproduction

import threading
import matplotlib
import matplotlib.pyplot as plt

matplotlib.use("macosx")
 
fig = plt.figure()
canvas = fig.canvas
manager = fig.canvas.manager

def worker():
    canvas.flush_events()       # EXC_BAD_ACCESS (SIGSEGV)
#    manager.show()
#    manager.resize(100, 200)
 
t = threading.Thread(target=worker)
t.start()
t.join()

Actual outcome

SIGSEGV, SIGTRAP, Obj-C Exception

Expected outcome

A Python error is thrown.

Additional information

No response

Operating system

No response

Matplotlib Version

3.11

Matplotlib Backend

macosx

Python version

3.14.5

Jupyter version

No response

Installation

None

Activity

  1. timhoffm commented on Jun 30, 2026

    @timhoffm
    Member

    I'm not convinced we should scatter thread checks all over the backends. This is just not supported. Code is generally not thread-safe and when working with threads, users have to explicitly check whether this is supported. Therefore, I don't think we have the obligation to catch unsupported threaded calls.

    Interactive figures and asynchronous programming also states:

    Most GUI frameworks require that all updates to the screen, and hence their main event loop, run on the main thread.

    We could be clearer here: "All supported GUI frameworks require ..." and add "When using any of the built-in GUI backends, all matplotlib code must run in the main thread".

  2. iccir commented on Jun 30, 2026

    @iccir
    ContributorAuthor

    Therefore, I don't think we have the obligation to catch unsupported threaded calls.

    That's completely fair, and there are plenty of ways that someone can cause a crash from a Python script if they try hard enough :)

    That said, for the macosx backend, we already need to set up an autorelease pool and exception handler prior to entering Obj-C land. We could add a main thread check to the BEGIN_OBJC_ENTRY macro if desired and get it for "free".

    My main issue is that, internally, Apple does a main thread check and then uses _os_crash() with a "Must only be used from the main thread" message. That's great if you are running inside of Xcode and the _os_crash handler fires and logs - at least you get an idea where you messed up. It's not so great if you are running from terminal and get a cryptic zsh: trace trap python3 ~/Desktop/crash.py.

    We could be clearer here: "All supported GUI frameworks require ..." and add "When using any of the built-in GUI backends, all matplotlib code must run in the main thread".

    From the description of #25553, I thought that using a worker thread to update figures and then calling draw_idle() is specifically supported. Is this not the case?

  3. greglucas commented on Sep 11, 2026

    @greglucas
    Contributor

    I think that there are quite a few people that use thread pools to stream data and aggregate things from multiple sensors into the data objects we are plotting. I think what we want is for the GUI toolkit to do the drawing from a single main thread I believe? So is what we really need here is a dispatch_async() on the cases you identified as crashing? canvas.flush_events() would dispatch the flush to the main thread even if called from a worker thread. I'm not sure we care where the draw_idle() was called from, just that the GUI updates and work should be from the main thread.

  4. iccir commented on Sep 12, 2026

    @iccir
    ContributorAuthor

    I think that there are quite a few people that use thread pools to stream data and aggregate things

    I'm going to reply in detail tomorrow (Saturday). For now: I 100% agree that this is something we should be aware of and support. I fully agree that draw_idle() should be callable on a worker thread and should immediately dispatch_async() to the main queue.

    I think that in most other cases, calling dispatch_async() will change our API contract. For example, flush_events() is defined to "…run the GUI event loop until all UI events currently waiting have been processed". You can't do that from a worker thread without a dispatch_sync(), which would open up the possibility to deadlock.

  5. iccir commented on Sep 13, 2026

    @iccir
    ContributorAuthor

    The list in my original issue represents a subset of potential problems. The actual results may vary depending on macOS version, architecture, and race conditions.

    Apple provides a tool called Main Thread Checker which "verifies that system APIs that must run on the main thread actually do run on that thread". It is enabled by default when you create an application in Xcode.

    Due to macOS Security Restrictions, the best way for us to invoke it is via ctypes.CDLL:

    Main Thread Checker Example

    import threading
    import matplotlib.pyplot as plt
    import os
    import ctypes
    import sys
    import time
    
    # Uncomment to turn on verbose mode
    # This will dump a list of non-main-thread classes
    # os.environ["MTC_VERBOSE"] = "1"
    
    # Load AppKit first, then libMainThreadChecker
    ctypes.CDLL("/System/Library/Frameworks/AppKit.framework/AppKit")
    ctypes.CDLL(
        "/Applications/Xcode.app/Contents/Developer/usr/lib/libMainThreadChecker.dylib"
    )
    
    fig, ax = plt.subplots()
    
    def worker():
        print(fig.canvas.manager.get_window_title())
    
    thread = threading.Thread(target=worker)
    thread.start()
    
    plt.show()

    Running this example results in an error:

    Main Thread Checker: UI API called on a
    background thread: -[NSWindow title]
    

    I spent the morning running similar examples on the old macosx backend. I tried to exercised every entry point into "_macosx.m". The results are as follows:

    Main Thread Checker Results

    Icon Meaning
    ✅ No issue
    ⚠️ Main Thread Checker error
    🛑 Crashed

    FigureCanvas

    Method Result
    update ⚠️
    flush_events 🛑
    set_cursor ✅
    set_rubberband ⚠️
    remove_rubberband ⚠️
    (if rubberband set)
    _start_event_loop 🛑
    stop_event_loop ✅
    (postEvent:atStart: on allowlist)

    FigureManager

    Method Result
    _show 🛑
    _raise 🛑
    _set_window_mode ⚠️
    set_window_title 🛑
    get_window_title ⚠️
    resize 🛑
    full_screen_toggle 🛑

    NavigationToolbar2

    Method Result
    set_message ⚠️

    Timer

    Method Result
    _timer_start ✅
    _timer_stop ✅

    _macosx

    Method Result
    event_loop_is_running ✅
    wake_on_fd_write ✅
    stop ⚠️
    show 🛑
    choose_save_file (fails with message to stderr)

    Each "⚠️" is a potential crash. Even if the call works today, Apple makes no guarantee that it will continue to work in the future. I've been burned several times in my career where a minor or bugfix release breaks existing code (although they were generally my fault!)


    I really don't want to add dispatch_async() logic for each ⚠️/🛑.

    As mentioned yesterday, I don't know of a safe way to do this for methods like flush_events(), as it is suppose to block until no more events.

    There are also cases like the following:

    …
    def worker():
        fig.canvas.manager.set_window_title("Foo")
        print(fig.canvas.manager.get_window_title())
    …

    If we use dispatch_async() for set_window_title, we will almost certainly get a stale window title. We would need to:

    • Use dispatch_sync() in get_window_title and introduce a potential deadlock.
    • Store a copy of the title in the manager and access it in a @synchronized block. This works, but it's a lot of added complexity for what I would consider little utility to our users.

    resize() or full_screen_toggle() could safety dispatch to the main thread, but again: what actual utility does it provide?

    Where I think it makes sense

    I definitely think that we need to support draw_idle() on a worker thread. I suspect that this is the main use.

    I'm unsure about Timers. While they currently work in the old backend, I believe that your main intention in #27527 was focused on draw_idle() due to the internal implementation.

    I'm not aware of many UI frameworks where a timer created/started on a worker thread will always fire its callback on the main thread. If the user wants this behavior, wouldn't they create/start it there?

    Whether or not this even works on a background thread depends on the backend:

    Backend Result
    macosx, gtk, tk Works
    qt, wx Fails with error

    If the user is using a single_shot Timer to mimic dispatch_async - that's an interesting use case. It fails on qt and wx, but there may be some utility there. I could see an argument for "please queue this lambda and execute it on the main thread's event loop". Does such a mechanism already exist in Python?

    Overall, I still think that we should default to raising a Python error if we are about to cross into Objective-C with the exception of very-strategically-chosen methods (draw_idle()).

    The code for this is simple. While it would also raise an error for Timers, this matches the existing behavior of qt and wx.

    Please let me know if there are any real-world use cases other than draw_idle()!

  6. greglucas commented on Sep 14, 2026

    @greglucas
    Contributor

    I agree that the main desire is around draw_idle() for myself at least, so I would be fine with putting the guards in for the other methods, and a Python error is much better than a segfault.

    I'm not aware of many UI frameworks where a timer created/started on a worker thread will always fire its callback on the main thread. If the user wants this behavior, wouldn't they create/start it there?

    I think I agree with this and the code in the callback should be responsible for delegating out the work to the proper thread if needed. I am guessing that the reason some of this is implemented to send to the main thread is for the simplicity of avoiding needing to think about what thread the timer was created on, maybe not correct but simple. I'd be fine with doing the more correct thing, but I think we need to make sure our methods are delegating appropriately then like calling draw_idle() from a worker thread would make sure that the draw() work gets done on the main thread even if called from somewhere else.

  7. iccir commented on Sep 14, 2026

    @iccir
    ContributorAuthor

    To be clear, draw_idle() on a worker thread should absolutely invoke draw() on the main thread!

    I pondered this a lot yesterday. Event loops, timers, and draw_idle() all have inconsistent behavior amongst the various interactive backends. You are addressing some of the timer concerns in #29062. I've identified a few others and will probably have an "Even more consistent timers" PR at some point in the future :) For event loops, I filed #31858 back in June.

    I think that, ultimately, we should have consistent behavior across the backends. We should understand the specific use cases and try to address them in a way that is still maintainable.

    For the new macOS backend, I think it makes the most sense to be strict/correct/simple from the very beginning while also addressing the most common cases (draw_idle(), maybe others). If this breaks an existing script in 3.12, the user can switch to the macosx backend and (hopefully) file an issue.

    The one situation I'm reviewing right now is FigureCanvas.new_timer(). All existing backends allow the timer to be created on a worker thread; however, from that point onward the behavior is all over the place. For macos, The cleanest approach would be to throw an error in new_timer() and not allow the Timer to be created; however, maybe we should be consistent with the other backends and not throw until start()? That will complicate the native code, unfortunately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions