mikro098
mikro098

Reputation: 2343

RunLoop vs DispatchQueue as Scheduler

When using new Combine framework you can specify the scheduler on which to receive elements from the publisher.

Is there a big difference between RunLoop.main and DispatchQueue.main in this case when assigning publisher to UI element? The first one returns the run loop of the main thread and the second queue associated with the main thread.

Upvotes: 56

Views: 13362

Answers (6)

okihand
okihand

Reputation: 75

After seeing the replies from Rob and landonepps I had to test it myself. I also always thought it was interchangeable.

Here is the visible result:

enter image description here

Here is the source code:

import SwiftUI
import Combine

final class Service {
    var subject = PassthroughSubject<Int, Never>()
    private let items = Array(1...100)
    
    init() {
        for i in items.indices {
            DispatchQueue.main.asyncAfter(deadline: .now() + Double(i)) {
                self.subject.send(self.items[i])
            }
        }
    }
}

final class ViewModel: ObservableObject {
    
    @Published var runLoopNumbers: [Int] = []
    @Published var dispatchQueueNumbers: [Int] = []
    
    let service = Service()
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        service.subject
            .receive(on: RunLoop.main)
            .sink { [weak self] in
                self?.runLoopNumbers.append($0)
            }
            .store(in: &cancellables)
        
        service.subject
            .receive(on: DispatchQueue.main)
            .sink { [weak self] in
                self?.dispatchQueueNumbers.append($0)
            }
            .store(in: &cancellables)
    }
}

struct ContentView: View {
    
    @StateObject var viewModel = ViewModel()

    var body: some View {
        ScrollView {
            HStack {
                VStack(alignment: .center) {
                    Text("RunLoop.main")
                        .font(.headline).bold()
                    ForEach(viewModel.runLoopNumbers, id: \.self) { item in
                        Text("\(item)")
                            .bold()
                    }
                    Spacer()
                }
                
                Divider()
                
                VStack(alignment: .center) {
                    Text("DispatchQueue.main")
                        .font(.headline).bold()
                    ForEach(viewModel.dispatchQueueNumbers, id: \.self) { item in
                        Text("\(item)")
                            .bold()
                    }
                    Spacer()
                }
            }
        }
    }
}

Upvotes: 3

rob mayoff
rob mayoff

Reputation: 385600

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 use RunLoop.main as a Scheduler, 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, including the modes used when tracking touch and mouse events. If you use DispatchQueue.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.

Upvotes: 87

Leo Xu
Leo Xu

Reputation: 1

Runloop.main may lose his signal in some cases,such as scrolling. Most of the time, it's OK to use DispatchQueue.main~

Upvotes: -1

pqnet
pqnet

Reputation: 6588

An important caveat of RunLoop is that it is "not really thread safe" (see https://developer.apple.com/documentation/foundation/runloop), so it can be used to delay execution of blocks but not to dispatch them from another thread. If you are doing multithread work (such as loading an image asynchronously) you should be using a DispatchQueue to go back to your main UI thread

Upvotes: 4

landonepps
landonepps

Reputation: 645

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)

Upvotes: 33

Roy Hsu
Roy Hsu

Reputation: 152

I've posted the similar question on the Swift Forum. I encourage you to see the discussion https://forums.swift.org/t/runloop-main-or-dispatchqueue-main-when-using-combine-scheduler/26635.

I just copy and paste the answer from Philippe_Hausler

RunLoop.main as a Scheduler ends up calling RunLoop.main.perform whereas DispatchQueue.main calls DispatchQueue.main.async to do work, for practical purposes they are nearly isomorphic. The only real differential is that the RunLoop call ends up being executed in a different spot in the RunLoop callouts whereas the DispatchQueue variant will perhaps execute immediately if optimizations in libdispatch kick in. In reality you should never really see a difference tween the two.

RunLoop should be when you have a dedicated thread with a RunLoop running, DispatchQueue can be any queue scenario (and for the record please avoid running RunLoops in DispatchQueues, it causes some really gnarly resource usage...). Also it is worth noting that the DispatchQueue used as a scheduler must always be serial to adhere to the contracts of Combine's operators.

Upvotes: 12

Related Questions