Configuring the Main Thread Checker

Did you know that you can customize the methods that Apple's Main Thread Checker warns you about? Or that by default, it won't warn you about the same method multiple times in one session?

Even though this tool was introduced some time ago, there's still not much more information about it than “here's what it is, and here's how to enable it” — which is a shame, because it leaves out a lot of interesting details, like what exactly the checker catches, how it's implemented, and maybe most importantly, the whole suite of (undocumented!) options it allows for configuration.

We'll take a look at those configuration options in this post, with a follow-up in the future to dive into the details of the checker's implementation. With only a bit of tweaking, this already-useful tool can be made even better for your project in particular.

Note: While many of these options are quite useful, remember that they are still undocumented and can change at any time! All configuration options are up-to-date as of Xcode 11.4.


Table of Contents


Setting Configuration Options

Nearly all configuration for the Main Thread Checker is done through the use of environment variables, This means you can easily set configuration options in Xcode by simply editing your scheme:

Product > Scheme > Edit Scheme

and adding environment variables under:

Run > Arguments > Environment Variables

The Scheme Editor's Arguments Tab

Each configuration option discussed here will include the option's name and some information about its expected value. For almost all cases, this will simply be an integer (including boolean options, which should be set to 0 or 1), and any exceptions will be called out where appropriate.


Configuration Options

Below, we will cover all available configuration options, starting with the most significant and complex, and then diving into the myriad of different feature flags that the checker provides.


Increasing the Maximum Hit Count

The Main Thread Checker has an interesting default behavior that I don't think I've seen discussed before — it will only flag a single violation for any called method.

For example, once you get one violation for calling -[UIViewController view] from a background thread, you will not see any more violations from calls to -[UIViewController view] for the rest of the process’ lifetime — even calls made at vastly different times and on entirely different view controllers.

If this sounds like a downside to you, don't worry — it's easy to change with a pretty simple configuration:

MTC_MAX_HIT_COUNT=99999

MTC_MAX_HIT_COUNT lets you choose how many times a given method can be called from a background thread before the checker stops complaining about it. It defaults to 1, hence the behavior described above. While this default might be fine in some cases, having the extra data can definitely help in others, as described in the Configuration Recommendations section of this post.


Suppressing Types of Failures

One of the most powerful options that the Main Thread Checker provides is MTC_SUPPRESSION_FILE. This allows you to provide a path to a file that contains a list of classes, methods, and/or selectors should be excluded from the checker:

MTC_SUPPRESSION_FILE=/Path/To/suppressions.txt

Not all paths will work — there's no extra logic to allow for tilde expansion, for example — but if you're working within Xcode, you can take advantage of Xcode's variable expansion to provide a much cleaner path:

MTC_SUPPRESSION_FILE=$(PROJECT_DIR)/suppressions.txt

As for the format of the given file, it should contain one suppression per line, starting with class: or method: or selector: to indicate the intended type. For example, the following file provides three different suppressions, one of each type:

class:UIActivityViewController
method:-[UIViewController view]
selector:setText:

You'll see some warnings in the console if the given suppressions file was not found, or if any of the line prefixes are incorrect; but you won't see any indication of failure for other mistakes, such as extra spaces or ill-formatted methods.

For help debugging a suppressions list, consider using the MTC_VERBOSE option, which includes a printout of the number of classes & selectors that the checker will monitor — a valid line in the suppressions file that corresponds to an actual class or method will affect those numbers.

Note that only instance methods can be provided using the method format; the prefix that the checker looks for in the method case is, to be more accurate than above, method:-


Enable or Disable Functionality

There are quite a few boolean options in the Main Thread Checker that can be enabled or disabled to fine-tune its functionality.

When setting these options, use a value of 1 to indicate that a feature should be enabled, or any other value (though preferably 0!) to disable.

MTC_VERBOSE

(Default: Disabled)

As the name implies, this option enables additional output from the checker. Specifically, it enables the following output during launch:

MTC_MEASURE_PERFORMANCE

(Default: Disabled)

Records the amount of time spent in initializing the main thread checker, logging the duration to stderr after initialization has completed.

It does not enable any performance measurements past launch.

MTC_PRINT_SELECTOR_STATS

(Default: Disabled)

Uses atexit_b to register a block that prints swizzled implementation call counts on process exit. Prints one line for every swizzled method that was later called, with the call count followed by the method name:

Swizzled selector statistics:
26	-[UIScene delegate]
1	-[UIScene setTitle:]
15	-[UIScene nextResponder]
122	-[UIScene activationState]
14	-[UIScene session]
...

Note that you should exit the process gracefully to see the output; e.g., if using the iOS Simulator, close the process within the simulator rather than using the Stop button within Xcode.

MTC_CRASH_ON_REPORT

(Default: Disabled)

Turns things up a notch.

More specifically, causes the checker to call abort() upon encountering a violation, killing the process accordingly.

MTC_DONT_SWIZZLE

(Default: Disabled)

Disables the Main Thread Checker completely by skipping the rest of initialization immediately after parsing configuration options. Potentially useful if always including libMainThreadChecker.dylib is easier for your particular setup, but you still want to conditionally enable or disable the checker.

MTC_IGNORE_DUPS_BY_THREAD_PC

(Default: Enabled)

While enabled, consecutive failures to the same method, from the same calling address, on the same thread, will not be flagged.

Take the following snippet as an example; even when otherwise configured to allow multiple violations for the same called method, with MTC_IGNORE_DUPS_BY_THREAD_PC enabled, this snippet will only flag view.layoutSubviews once:

let view = self.view!
DispatchQueue.global(qos: .background).async {
    for _ in 0 ..< 2 {
        view.layoutSubviews() // Only flagged once
    }
}

This can be helpful for reducing noise, but there are several similar setups that will not be caught by this check:

  1. Calling view.layoutSubviews twice in a row rather than in a for loop
    • Not caught because the second call is coming from a different memory address than the first (assuming no optimizations made by the compiler).
  2. Using two different DispatchQueue.async calls, even if they were invoking the same block
    • Usually not caught because the blocks are not guaranteed to run on the same thread.
  3. Assuming we're in a view controller, calling self.view.layoutSubviews rather than capturing view outside of the block
    • Only consecutive failures to the same method are caught; in this case, we'd have one failure for -[UIViewController view], then another for -[UIView layoutSubviews]; when we start the next iteration, we will not have two -[UIViewController view] violations back-to-back, and so the duplication will not be caught.
MTC_CHECK_CATRANSACTION

(Default: Enabled)

When enabled, adds an extra check that calls have to pass in order to be treated as violations; calls will not be flagged if there is an ongoing CATransaction:

DispatchQueue.global(qos: .background).async {
    CATransaction.begin()
    self.view.layoutSubviews() // not flagged
    CATransaction.commit()
}

Disabling this option causes these violations to instead be flagged as normal.

MTC_SUPPRESS_SYSTEM_REPORTS

(Default: Enabled)

Prevents calls from binaries that live within the /System directory from causing violations.

This effectively prevents all Apple Frameworks from being flagged themselves.

MTC_IGNORE_INLINE_CALLS

(Default: Enabled)

Prevents calls from within UIKit, AppKit, WebKit, or CoreFoundation from being flagged by the checker. Since these frameworks all live within the /System directory, MTC_SUPPRESS_SYSTEM_REPORTS must also be disabled to have any effect. As a result, this option effectively acts as an extra layer in case you want to allow some Apple Framework violations, but do not want to completely open the floodgates.

As an example, take this very basic setup:

DispatchQueue.global(qos: .background).async {
    self.view.layoutSubviews()
} 

By default, the above will have two violations: one for -[UIViewController view] and another for -[UIView layoutSubviews]

With this option disabled, however, there will also be a violation for a call made by -[UIViewController view], to -[UIViewController loadViewIfRequired].

This can open up a firehose of violations from a single external call to UIKit made from a background thread, and as a result, it is entirely unclear why you would ever want this to be disabled.

MTC_IGNORE_RETAIN_RELEASE

(Default: Enabled)

Disables violations to calls to retain, release, autorelease, and retainCount.

MTC_IGNORE_DEALLOC

(Default: Enabled)

Disables violations to calls to dealloc and .cxx_destruct.

MTC_IGNORE_THREADSAFE_METHODS

(Default: Enabled)

Disables violations for the following list of selectors:

MTC_LOG_REPORTS_TO_STDERR

(Default: Enabled)

Prints violations to stderr. Output includes the method being called, the thead or queue name, and a backtrace.

MTC_LOG_REPORTS_TO_OS_LOG

(Default: Enabled)

Logs violations to os_log with a subsystem name of "com.apple.MainThreadChecker", and a category name of "reports".

MTC_RESET_INSERT_LIBRARIES

(Default: Enabled)

Removes libMainThreadChecker.dylib from the DYLD_INSERT_LIBRARIES environment variable, unsetting the value completely if it was the last component.

Xcode uses this environment variable to actually include the checker in your app on launch, so this is essentially cleaning up its tracks.

MTC_CALL_BREAKPOINT_SYMBOL

(Default: Enabled)

When enabled, the checker will call __main_thread_checker_on_report when encountering a failure, which is the primary way in which the checker communicates with Xcode.

If disabled, Runtime Issue breakpoints cannot be triggered, and Xcode will be unable to highlight failures in the Editor pane, but failures can still be logged to stderr and/or os_log.

MTC_APPKIT_SUPPRESSIONS

(Default: Enabled)

If enabled, and the checker is running on an application with AppKit loaded, two additional checks will be made before flagging a call:

  1. Check that +[NSGraphicsContext currentContext] returns nil
  2. Check all calls in the stacktrace to see if any are to NSDocument with a selector starting with writeToURL or writeSafelyToURL

Configuration Recommendations

And we're through! That was a lot to cover, including several configuration options that will only apply in the most exceptional circumstances. Hopefully they will provide some use to certain projects, but they won't provide much value to most.

But with that said, there are two options I'd like to call out explicitly as being worth some attention for many projects:

MTC_MAX_HIT_COUNT=99999

Besides the default causing potentially surprising behavior, increasing MTC_MAX_HIT_COUNT has some other notable benefits. It's particularly useful when cleaning up issues, as it prevents you from having to re-run the app to find additional occurrences of the same failure, and also allows for you to deal with failures in any order — especially useful for large projects where a failure near app launch might hide others across the entirety of the codebase. Overall, there don't seem to be any notable downsides to increasing this number, and it could let you catch issues more quickly — so it should be a reasonable change for any project.

MTC_CALL_BREAKPOINT_SYMBOL=0

This one should not be added lightly, and should likely only be a temporary measure — as mentioned above, disabling MTC_CALL_BREAKPOINT_SYMBOL prevents Xcode from showing failures in the Editor pane, and Runtime Issue breakpoints will no longer be triggered.

However, for large projects just getting started with the Main Thread Checker, this might be your only option for not bringing your app to a crawl. Even if you don't have a Runtime Issue breakpoint added locally, Xcode essentially uses them internally to spot the issues and highlight the offending lines of code.

This means that for every violation you encounter, you may end up hitting the same delay you see when waiting for lldb to prepare itself after hitting a breakpoint (based on some simple benchmarks, hitting an issue in the Main Thread Checker seems to actually add more time than an automatically-continuing breakpoint). If that takes a long time in your project, you might be effectively unable to launch the app with the checker enabled; consider disabling this option and relying on the Console output to identify problematic callsites first, and then re-enabling once most issues have been cleaned up.


Up Next

This post is the first half of a deep look into the Main Thread Checker; the second post will dive into the actual implementation itself, but I hope having a standalone reference for these configuration options provides its own value in the meantime.

Please reach out if you end up trying any of these options within your own project — I'd love to hear how it works out!


Say Hello!