Skip to content

macOS Rework: Timers - #32369

Draft
iccir wants to merge 21 commits into
matplotlib:mainfrom
iccir:macos-pr-timer
Draft

iccir wants to merge 21 commits into
matplotlib:mainfrom
iccir:macos-pr-timer

Conversation

@iccir

@iccir iccir commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

PR summary

Important

This PR depends on #32161 and will appear larger than it really is until #32161 is merged. To view the actual changes in this PR, compare macos-staging → macos-pr-timer.

This PR adds an MPLTimer class.

Previously, TimerType wrapped both an NSTimer object and a shouldInvalidate boolean. It contained logic to handle scheduling and invalidation. This is contrary to our new design where "_macos.m" is a simple glue layer with logic living in the wrapped MPL* class.

MPLTimer attempts to mimic the behavior of a QTimer as much as possible in order to ease our timer consistency efforts.

Threading Changes

The previous implementation of _macosx.Timer would schedule the NSTimer on the main thread. From research (#27527, #25553, #5675), this behavior was needed due to draw_idle using timers internally. As draw_idle now uses dispatch_async, we can re-examine this behavior.

I am use to "callbacks are called in the execution context from which the timer was created/scheduled/started". I am not use to the concept of timers being used for inter-thread messaging.

Consider the following code:

import threading
import matplotlib.pyplot as plt

fig, ax = plt.subplots()

timer = fig.canvas.new_timer(interval=500)
timer.add_callback(lambda: print(threading.current_thread().name))

def worker():
    timer.start()

t = threading.Thread(target=worker)
t.start()

plt.show()

Whether or not this is allowed depends on the backend:

backend Results
gtk4agg Prints "MainThread"
tkagg Prints "MainThread"
wxagg Error: "timer can only be started from the main thread"
qtagg Error: "Timers cannot be started from another thread"

Per discussion in #31968, I am inclined to make any usage of timers on a worker thread raise an error. I believe that this should be extended to other backends as well (see #32352).

A future pull request will raise an error when crossing into Objective-C from Python (except for a select list of methods). That is out of scope for this pull request.

Thread-safe Variant

In the event that we someday want a thread-safe variant of MPLTimer, here's the relevant code:

Thread-safe MPLTimer.h
#import <AppKit/AppKit.h>
#import <Python.h>

NS_ASSUME_NONNULL_BEGIN

@interface MPLTimer : NSObject

- (void) start;
- (void) stop;

- (void) updateIntervalInMsecs:(int)intervalInMsecs;
- (void) updateSingleShot:(BOOL)singleShot;

@property (atomic, assign, nullable) PyObject *pyObject;

@end

NS_ASSUME_NONNULL_END
Thread-safe MPLTimer.m
/*
    MPLTimer Thread-safety Notes

    Historically, the macOS backend utilitized a single-shot timer as a mechanism
    to forward draw_idle() requests to the main thread (#25553/#27527). This had
    the side-effect of allowing external clients to use the timer API to do the same.

    This class has been written to be thread safe in order to err on the side
    of caution. This may change in the future as the discussion of worker-thread
    usage is ongoing (#31968).

    To implement thread-safety, we do the following:

    1) Properties and ivars (except _storage) are only modified on the main thread.

    2) We store our PyObject inside a special MPLTimerStorage class that also
       acts as a mutex via the @synchronized directive.

       This storage object is strongly-retained by a block copy and automatically
       released at the end of the block dispatch. Hence, it's impossible for the
       MPLTimerStorage instance to be dealloc'd during the timer callback.

    3) When calling the "on_timer" callback:
       - Acquire the GIL.
       - Acquire the _storage mutex.
       - Extract the pyObject and increment the reference count.
       - Release the _storage mutex.
       - Call "_on_timer" on the pyObject.
       - Decrement the pyObject reference count.
       - Release the GIL.
*/

#import "MPLTimer.h"
#import "MPLUtils.h"


@interface MPLTimerStorage : NSObject
@property (nonatomic, assign, nullable) PyObject *pyObject;
@end


@implementation MPLTimerStorage
@end


@interface MPLTimer ()
@property (nonatomic, getter=isSingleShot) BOOL singleShot;
@end


@implementation MPLTimer {
    dispatch_source_t _source;
    uint64_t _intervalInNsecs;
    MPLTimerStorage *_storage;
}


#pragma mark - Lifecycle

- (instancetype) init
{
    if ((self = [super init])) {
        MPLLog("[Lifecycle] MPLTimer<%p> init", self);
        _storage = [[MPLTimerStorage alloc] init];
    }

    return self;
}


- (void) dealloc
{
    @synchronized (_storage) {
        [_storage setPyObject:NULL];
    }

    // We always call -stop prior to dealloc, which will clear and cancel
    // our _source. As a failsafe, the source's callback will cancel itself
    // if it sees a NULL pyObject.

    MPLLog("[Lifecycle] MPLTimer<%p> dealloc", self);
}


#pragma mark - Private Methods

- (void) _clearSource
{
    _source = nil;
}

- (void) _cancelAndClearSource
{
    dispatch_source_t source = _source;
    _source = nil;
    if (source) dispatch_source_cancel(source);
}


- (void) _restartTimer
{
    dispatch_source_t source = dispatch_source_create(
        DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue()
    );

    dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, _intervalInNsecs);
    dispatch_source_set_timer(source, start, _intervalInNsecs, 0);

    __weak MPLTimer *weakSelf = self;
    __weak dispatch_source_t weakSource = source;

    // 'storage' will be strongly retained by the block and also act as our mutex.
    MPLTimerStorage *storage = _storage;
    dispatch_source_set_event_handler(source, ^{
        PyGILState_STATE gstate = PyGILState_Ensure();

        PyObject *pyObject = NULL;

        @synchronized (storage) {
            pyObject = [storage pyObject];
            Py_XINCREF(pyObject);
        }

        if (pyObject) {
            MPLCallMethod(pyObject, "_on_timer", "");
            Py_DECREF(pyObject);
        }

        __strong MPLTimer *strongSelf = weakSelf;
        if ([strongSelf isSingleShot]) {
            [strongSelf _cancelAndClearSource];

        // If this callback has fired after -[MPLTimer dealloc],
        // be absolutely certain that the source has been cancelled.
        // This should be not needed, but it's better to err on the
        // side of caution
        } else if (!pyObject || !strongSelf) {
            dispatch_source_cancel(weakSource);
        }

        PyGILState_Release(gstate);
    });

    dispatch_source_set_cancel_handler(source, ^{
        [weakSelf _clearSource];
    });

    [self _cancelAndClearSource];
    _source = source;
    dispatch_activate(source);
}


#pragma mark - Public Methods

- (void) start
{
    if ([NSThread isMainThread]) {
        [self _restartTimer];

    } else {
        dispatch_async(dispatch_get_main_queue(), ^{
            [self start];
        });
    }
}


- (void) stop
{
    if ([NSThread isMainThread]) {
        [self _cancelAndClearSource];

    } else {
        dispatch_async(dispatch_get_main_queue(), ^{
            [self stop];
        });
    }
}


- (void) updateIntervalInMsecs:(int)intervalInMsecs
{
    if ([NSThread isMainThread]) {
        _intervalInNsecs = intervalInMsecs * NSEC_PER_MSEC;
        if (_source) [self _restartTimer];

    } else {
        dispatch_async(dispatch_get_main_queue(), ^{
            [self updateIntervalInMsecs:intervalInMsecs];
        });
    }
}


- (void) updateSingleShot:(BOOL)singleShot
{
    if ([NSThread isMainThread]) {
        [self setSingleShot:singleShot];

    } else {
        dispatch_async(dispatch_get_main_queue(), ^{
            [self updateSingleShot:singleShot];
        });
    }
}


#pragma mark - Accessors

- (void) setPyObject:(PyObject *)pyObject
{
    @synchronized (_storage) {
        [_storage setPyObject:pyObject];
    }
}


- (PyObject *) pyObject
{
    @synchronized (_storage) {
        return [_storage pyObject];
    }
}


@end

AI Disclosure

  • I use AI for web search due to search engines becoming less reliable.
  • I used AI to help check the threading logic for the attached thread-safe MPLTimer variant. Ultimately, I decided to not use it.
  • All other code is my own.

PR quality check

  • Use an expressive title, e.g. "Fix title font property precedence"
  • [N/A] New and changed code is tested (Tested manually)
  • [N/A] Plotting related features are demonstrated in an example
  • [N/A] New features and API changes have release notes
  • [N/A] Documentation complies with general and docstring guidelines

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant