RunLoop vs DispatchQueue as Scheduler
I saw the response posted by Roy and thought I could use them interchangeably, but I actually noticed a big difference in my app.
I was loading an image asyncronously in a custom table view cell.
Using RunLoop.main
would block images from loading as long as the table view was scrolling.
subscriber = NetworkController.fetchImage(url: searchResult.artworkURL)
.receive(on: RunLoop.main)
.replaceError(with: #imageLiteral(resourceName: "PlaceholderArtwork"))
.assign(to: \.image, on: artworkImageView)
But switching to DispatchQueue.main
allowed the images to load while it was scrolling.
subscriber = NetworkController.fetchImage(url: searchResult.artworkURL)
.receive(on: DispatchQueue.main)
.replaceError(with: #imageLiteral(resourceName: "PlaceholderArtwork"))
.assign(to: \.image, on: artworkImageView)
There actually is a big difference between using RunLoop.main
as a Scheduler
and using DispatchQueue.main
as a Scheduler
:
RunLoop.main
runs callbacks only when the main run loop is running in the.default
mode, which is not the mode used when tracking touch and mouse events. If you useRunLoop.main
as aScheduler
, your events will not be delivered while the user is in the middle of a touch or drag.DispatchQueue.main
runs callbacks in all of the.common
modes, which include the modes used when tracking touch and mouse events. If you useDispatchQueue.main
, your events will be delivered while the use user in the middle of a touch or drag.
Details
We can see the implementation of RunLoop
's conformance to Scheduler
in Schedulers+RunLoop.swift
. In particular, here's how it implements schedule(options:_:)
:
public func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void) { self.perform(action) }
This uses the RunLoop
perform(_:)
method, which is the Objective-C method -[NSRunLoop performBlock:]
. The performBlock:
method schedules the block to run in the default run loop mode only. (This is not documented.)
UIKit and AppKit run the run loop in the default mode when idle. But, in particular, when tracking a user interaction (like a touch or a mouse button press), they run the run loop in a different, non-default mode. So a Combine pipeline that uses receive(on: RunLoop.main)
will not deliver signals while the user is touching or dragging.
We can see the implementation of DispatchQueue
's conformance to Scheduler
in Schedulers+DispatchQueue.swift. Here's how it implements schedule(options:_:)
:
public func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void) { let qos = options?.qos ?? .unspecified let flags = options?.flags ?? [] if let group = options?.group { // Distinguish on the group because it appears to not be a call-through like the others. This may need to be adjusted. self.async(group: group, qos: qos, flags: flags, execute: action) } else { self.async(qos: qos, flags: flags, execute: action) } }
So the block gets added to the queue using a standard GCD method, async(group:qos:flags:execute:). Under what circumstances are blocks on the main queue executed? In a normal UIKit or AppKit app, the main run loop is responsible for draining the main queue. We can find the run loop implementation in CFRunLoop.c
. The important function is __CFRunLoopRun
, which is much too big to quote in its entirety. Here are the lines of interest:
#if __HAS_DISPATCH__ __CFPort dispatchPort = CFPORT_NULL; Boolean libdispatchQSafe = pthread_main_np() && ( (HANDLE_DISPATCH_ON_BASE_INVOCATION_ONLY && NULL == previousMode) || (!HANDLE_DISPATCH_ON_BASE_INVOCATION_ONLY && 0 == _CFGetTSD(__CFTSDKeyIsInGCDMainQ)) ); if ( libdispatchQSafe && (CFRunLoopGetMain() == rl) && CFSetContainsValue(rl->_commonModes, rlm->_name) ) dispatchPort = _dispatch_get_main_queue_port_4CF(); #endif
(I have wrapped the original source lines for readability.) Here's what that code does: if it's safe to drain the main queue, and it's the main run loop, and it's a .common
mode, then CFRunLoopRun
will check for the main queue being ready to drain. Otherwise, it will not check and so it will not drain the main queue.
The .common
modes include the tracking modes. So a Combine pipeline that uses receive(on: DispatchQueue.main)
will deliver signals while the user is touching or dragging.