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
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
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.-[UIViewController view]
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:
- List of found classes to swizzle
- Overall stats about the number of swizzled classes & methods
- Warnings if the checker will be skipped (particularly if UIKit or AppKit is not loaded)
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:
- 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).
- 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.
- Assuming we’re in a view controller, calling
self.view.layoutSubviews
rather than capturingview
outside of the block- Only consecutive failures to the same method are caught; in this case, we’d have one failure for
, then another for-[UIViewController view]
; when we start the next iteration, we will not have two-[UIView layoutSubviews]
violations back-to-back, and so the duplication will not be caught.-[UIViewController view]
- Only consecutive failures to the same method are caught; in this case, we’d have one failure for
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
and another for
-[UIViewController view]
-[UIView layoutSubviews]
With this option disabled, however, there will also be a violation for a call made by
, to
-[UIViewController view]
.-[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:
allowsWeakReference
class
conformsToProtocol:
debugDescription
description
doesNotRecognizeSelector:
forwardingTargetForSelector:
hash
isEqual:
isFault
isKindOfClass:
isMemberOfClass:
isProxy
methodForSelector:
methodSignatureForSelector:
respondsToSelector:
retainWeakReference
self
superclass
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:
- Check that
+[NSGraphicsContext currentContext]
returns nil - Check all calls in the stacktrace to see if any are to
NSDocument
with a selector starting withwriteToURL
orwriteSafelyToURL
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!