Is the following a safe use of dispatch_set_target_queue()?

644 Views Asked by At

What I want to do is create an indirect queue targeting the main queue.

dispatch_queue_t myQueue = dispatch_queue_create("com.mydomain.my-main-queue", NULL);
dispatch_set_target_queue(myQueue, dispatch_get_main_queue());

My ultimate goal is to use the queue as the underlyingQueue property of an NSOperationQueue, because Apple's documentation clearly states not to use dispatch_get_main_queue(). Though using an indirect queue it technically is following the documentation.

The reason for all this is because NSOperationQueue.mainQueue is not a safe for asynchronous operations, because it is globally accessible and it's maxConcurrentOperationCount is set to 1. So can easily shoot yourself in the foot with this operation queue.

Update 1

It seems there is a lot of confusion about the basis of what this question assumes an "asynchronous NSOperation" is. To be clear this is based on the concepts in this WWDC session The particular concept is using "operation readiness" and dependency management to manage the tasks in your app, which means asynchronous NSOperations are added to NSOperationQueues to take advantage of this. If you take these concepts to the spirit of this question hopefully the reasoning will make more sense, and you can focus on comparing and contrasting the solution with other ones.

Update 2 - Example of issue:

// VendorManager represents any class that you are not in direct control over.

@interface VendorManager : NSObject
@end

@implementation VendorManager

+ (void)doAnsyncVendorRoutine:(void (^)(void))completion {
    // Need to do some expensive work, make sure we are off the main thread
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND 0), ^(void) {
        // Some off main thread background work
        sleep(10);
        // We are done, go back to main thread
        [NSOperationQueue.mainQueue addOperationWithBlock:completion];
    });
}

@end


// MYAsyncBoilerPlateOperation represents all the boilerplate needed
// to implement a useful asnychronous NSOperation implementation.

@interface MYAlertOperation : MYAsyncBoilerPlateOperation
@end

@implementation MYAlertOperation

- (void)main {

    UIAlertController *alertController = [UIAlertController alertControllerWithTitle:"Vendor"
                                                                             message:"Should vendor do work?"
                                                                      preferredStyle:UIAlertControllerStyleAlert];
    __weak __typeof(self) weakSelf = self;
    [alertController addAction:[UIAlertAction actionWithTitle:@"Yes"
                                                        style:UIAlertActionStyleDefault
                                                      handler:^(UIAlertAction *action) {
                                                          [VendorManager doAnsyncVendorRoutine:^{
                                                              // implemented in MYAsyncBoilerPlateOperation
                                                              [weakSelf completeThisOperation];
                                                          }];
                                                      }]];
    [alertController addAction:[UIAlertAction actionWithTitle:@"No"
                                                        style:UIAlertActionStyleDefault
                                                      handler:^(UIAlertAction *action) {
                                                          [weakSelf cancel];
                                                      }]];

    [MYAlertManager sharedInstance] presentAlert:alertController animated:YES];
}

@end

// MYAlertOperation will never complete.
// Because of an indirect dependency on operations being run on mainQueue.
// This example is an issue because mainQueue maxConcurrentOperationCount is 1.
// This example would not be an issue if maxConcurrentOperationCount was > 1.

[NSOperationQueue.mainQueue addOperation:[[MYAlertOperation alloc] init]];

Update 3 - Example 2:

I am not showing the implementation of MyAsyncBlockOperation but you can use this as what it's based on in Swift.

// operation.asynchronous is YES, and things are implemented correctly for state changes.
MyAsyncBlockOperation *operation = [MyAsyncBlockOperation new];
__weak MyAsyncBlockOperation *weakOperation = operation;
// executionBlock is simply invoked in main
// - (void)main { self.executionBlock() };
operation.executionBlock = ^{
    UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Vendor"
                                                                             message:@"Should vendor do work?"
                                                                      preferredStyle:UIAlertControllerStyleAlert];

    [alertController addAction:[UIAlertAction actionWithTitle:@"Yes"
                                                        style:UIAlertActionStyleDefault
                                                      handler:^(UIAlertAction *action) {
                                                          [[NSOperationQueue mainQueue] addOperationWithBlock:^{
                                                              NSLog(@"Never called");
                                                              [weakOperation completeWithSuccess];
                                                          }];
                                                      }]];

    [alertController addAction:[UIAlertAction actionWithTitle:@"No"
                                                        style:UIAlertActionStyleDefault
                                                      handler:^(UIAlertAction *action) {
                                                          [weakOperation cancel];
                                                      }]];

    [[UIApplication sharedApplication].keyWindow.rootViewController presentViewController:alertController animated:YES completion:nil];
}];

operation.completionBlock = ^{
    NSLog(@"If YES, Never called. If NO, called.");
};

[[NSOperationQueue mainQueue] addOperation:operation];

So I thought, why not have another NSOperationQueue? One with it's underlyingQueue set to the previously mentioned indirect GCD queue (still following the documentation). So we can have a concurrent NSOperationQueue, legally targeting the serial main GCD queue, and ultimately ensuring the operations run on the main thread.

Let me know if you want clarification, here is an example of the full code:

NSOperationQueue *asyncSafeMainQueue = [[NSOperationQueue alloc] init];
asyncSafeMainQueue.qualityOfService = NSQualityOfServiceDefault; // not needed, just for clarity
dispatch_queue_t underlyingQueue = dispatch_queue_create("com.mydomain.main-thread", NULL);
dispatch_set_target_queue(underlyingQueue, dispatch_get_main_queue());
asyncSafeMainQueue.underlyingQueue = underlyingQueue;

Now... there is a safe operation queue for asynchronous operations that need to run on the main thread, and without any unnecessary context switching.

Is it safe?

2

There are 2 best solutions below

11
On

I don't understand why you think mainQueue is not safe for asynchronous operations. The reasons you gave would make it unsafe for synchronous operations (because you could deadlock).

Anyway, I think it's a bad idea to try the workaround you're suggesting. Apple didn't explain (on the pages you linked) why you shouldn't set underlyingQueue to the main queue. I recommend you play it safe and follow the spirit of the prohibition rather than the letter.

Update

Looking now at your updated question, with example code, I see nothing that can block the main thread/queue, so there is no possibility of deadlocking. It doesn’t matter that mainQueue has a macConcurrentOperationCount of 1. I don’t see anything in your example that requires or benefits from the creation of a separate NSOperationQueue.

Also, if the underlyingQueue is a serial queue (or has a serial queue anywhere in its target chain), then it doesn’t matter what you set maxConcurrentOperationCount to. The operations will still run serially. Try it yourself:

@implementation AppDelegate {
    dispatch_queue_t concurrentQueue;
    dispatch_queue_t serialQueue;
    NSOperationQueue *operationQueue;
}

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
    concurrentQueue = dispatch_queue_create("q", DISPATCH_QUEUE_CONCURRENT);
    serialQueue = dispatch_queue_create("q2", nil);
    operationQueue = [[NSOperationQueue alloc] init];

    // concurrent queue targeting serial queue
    //dispatch_set_target_queue(concurrentQueue, serialQueue);
    //operationQueue.underlyingQueue = concurrentQueue;

    // serial queue targeting concurrent queue
    dispatch_set_target_queue(serialQueue, concurrentQueue);
    operationQueue.underlyingQueue = serialQueue;

    operationQueue.maxConcurrentOperationCount = 100;

    for (int i = 0; i < 100; ++i) {
        NSOperation *operation = [NSBlockOperation blockOperationWithBlock:^{
            NSLog(@"operation %d starting", i);
            sleep(3);
            NSLog(@"operation %d ending", i);
        }];
        [operationQueue addOperation:operation];
    }
}

@end
2
On

Hmm.. This crashes very badly in Swift-4 if using setTarget instead of the designated constructor..

If you use Objective-C bridging, then you can do:

@interface MakeQueue : NSObject
+ (NSOperationQueue *)makeQueue:(bool)useSerial;
@end

@implementation MakeQueue
+ (NSOperationQueue *)makeQueue:(bool)useSerial {
    dispatch_queue_t serial = dispatch_queue_create("serial", nil);
    dispatch_queue_t concurrent = dispatch_queue_create("concurrent", DISPATCH_QUEUE_CONCURRENT);

    dispatch_queue_t queue = useSerial ? serial : concurrent;
    dispatch_set_target_queue(queue, dispatch_get_main_queue());

    NSOperationQueue *opq = [[NSOperationQueue alloc] init];
    opq.underlyingQueue = queue;
    opq.maxConcurrentOperationCount = 8;
    return opq;
}
@end

and if using Swift, you have:

func makeQueue(_ useSerial: Bool) -> OperationQueue? {

    let testCrash: Bool = false
    var queue: DispatchQueue!

    if testCrash {
        let serial = DispatchQueue(label: "serial")
        let concurrent = DispatchQueue(label: "concurrent", attributes: .concurrent)
        queue = useSerial ? serial : concurrent
        queue.setTarget(queue: DispatchQueue.main)
    }
    else {
        let serial = DispatchQueue(label: "serial", qos: .default, attributes: .init(rawValue: 0), autoreleaseFrequency: .inherit, target: DispatchQueue.main)
        let concurrent = DispatchQueue(label: "concurrent", qos: .default, attributes: .concurrent, autoreleaseFrequency: .inherit, target: DispatchQueue.main)
        queue = useSerial ? serial : concurrent
    }

    let opq = OperationQueue()
    opq.underlyingQueue = queue
    opq.maxConcurrentOperationCount = 8;
    return opq
}

So now we test it:

class ViewController : UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        //Test Objective-C
        let operationQueue = MakeQueue.makeQueue(false)!
        operationQueue.addOperation {
            self.download(index: 1, time: 3)
        }

        operationQueue.addOperation {
            self.download(index: 2, time: 1)
        }

        operationQueue.addOperation {
            self.download(index: 3, time: 2)
        }


        //Test Swift
        let sOperationQueue = makeQueue(false)!
        sOperationQueue.addOperation {
            self.download(index: 1, time: 3)
        }

        sOperationQueue.addOperation {
            self.download(index: 2, time: 1)
        }

        sOperationQueue.addOperation {
            self.download(index: 3, time: 2)
        }
    }

    func download(index : Int, time: Int){
        sleep(UInt32(time))
        print("Index: \(index)")
    }
}

In any case, it doesn't seem to matter what the maxConcurrentOperations are.. if the underlying queue is serial, then setting this value seems to do NOTHING.. However, if the underlying queue is concurrent, it places a limit on how many operations can be ran at once.

So all in all, once the underlying queue is MainQueue or any serial-queue, all the operations get submitted to it (serially) and they block (it waits because it is serial queue).

I'm not sure what the point of the underlying queue is if we're already using a designated queue anyway.. but in any case, setting it to main causes everything to run on the main queue and serially regardless of max concurrent count.

This: https://gist.github.com/jspahrsummers/dbd861d425d783bd2e5a is the only use-case that I could find.. AND that you can independently resume/suspend tasks on your custom queue even if its underlying queue is main or some other queue. AND suspending/resuming the one queue that all other queues target, will in turn suspend/resume all other queues.