3

Why is calling start() for BlockOperation with more then 1 block on a main thread not calling its block on the main thread? My first test is always passed but second not every time - some times blocks executes not on the main thread

func test_callStartOnMainThread_executeOneBlockOnMainThread() {
    let blockOper = BlockOperation {
        XCTAssertTrue(Thread.isMainThread, "Expect first block was executed on Main Thread")
    }
    blockOper.start()
}
func test_callStartOnMainThread_executeTwoBlockOnMainThread() {
    let blockOper = BlockOperation {
        XCTAssertTrue(Thread.isMainThread, "Expect first block was executed on Main Thread")
    }
    blockOper.addExecutionBlock {
        XCTAssertTrue(Thread.isMainThread, "Expect second block was executed on Main Thread")
    }
    blockOper.start()
}

Even next code is failed

func test_callStartOnMainThread_executeTwoBlockOnMainThread() {
    let asyncExpectation = expectation(description: "Async block executed")
    asyncExpectation.expectedFulfillmentCount = 2
    let blockOper = BlockOperation {
        XCTAssertTrue(Thread.isMainThread, "Expect first block was executed on Main Thread")
        asyncExpectation.fulfill()
    }
    blockOper.addExecutionBlock {
        XCTAssertTrue(Thread.isMainThread, "Expect second block was executed on Main Thread")
        asyncExpectation.fulfill()
    }
    OperationQueue.main.addOperation(blockOper)
    wait(for: [asyncExpectation], timeout: 2.0)
}
  • 1
    Why do you think a `BlockOperation` will be always and only be executed on the main thread? Check the docs: _Blocks added to a block operation are dispatched with default priority to an appropriate work queue. The blocks themselves should not make any assumptions about the configuration of their execution environment._ – Andreas Oetjen Feb 19 '20 at 14:02
  • But what if i want my tasks execute only on main thread? (I know maybe it would be strange) If I call OperationQueue.main.addOperation(blockOper) - I don't get what expect. Blocks executes on Main Thread or not - it depends on something i don't know – Gulya Boiko Feb 19 '20 at 14:10
  • If you use `OperationQueue.main.addOperation(blockOper)` instead of `blockOper.start()`, this should indeed execute in the main thread. But that's not what's shown in your code, or do I get something wrong? – Andreas Oetjen Feb 19 '20 at 16:17
  • @AndreasOetjen - No, even then, the `addExecutionBlock` pattern can still manifest the behavior that the OP described. As Andreas quoted, when using `addExecutionBlock`, don’t make any assumptions about the execution environment. – Rob Feb 19 '20 at 16:47
  • @Rob Hmm, there seems to be is a little contradiction in the documentation, I guess: As I understand, _dont assume anything about the execution environment_ is just related to the `BlockOperation` itself, because it don't know which `OperationQueue` it will be enqueued into. When using `OperationQueue.main`, the operations should always execute in the main thread. At least that's my understanding of _executes one operation at a time on the app’s main thread_ (but then the BlockOperation doc states _Blocks added ... are dispatched with default priority to an appropriate work queue._) – Andreas Oetjen Feb 19 '20 at 17:05
  • Operation queues govern the behavior between operations on that queue, not the behavior of execution blocks within an operation. The `addExecutionBlock` is not the same as `addOperation`. – Rob Feb 19 '20 at 17:44

1 Answers1

2

As Andreas pointed out, the documentation warns us:

Blocks added to a block operation are dispatched with default priority to an appropriate work queue. The blocks themselves should not make any assumptions about the configuration of their execution environment.

The thread on which we start the operation, as well as the maxConcurrentOperationCount behavior of the queue, is managed at the operation level, not at the individual execution blocks within an operation. Adding a block to an existing operation is not the same as adding a new operation to the queue. The operation queue governs the relationship between operations, not between the blocks within an operation.

The problem can be laid bare by making these blocks do something that takes a little time. Consider a task that waits one second (you would generally never sleep, but we're doing this simply to simulate a slow task and to manifest the behavior in question). I've also added the necessary “points of interest” code so we can watch this in Instruments, which makes it easier to visualize what’s going on:

import os.log
let pointsOfInterest = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: .pointsOfInterest)

func someTask(_ message: String) {
    let id = OSSignpostID(log: pointsOfInterest)
    os_signpost(.begin, log: pointsOfInterest, name: "Block", signpostID: id, "Starting %{public}@", message)
    Thread.sleep(forTimeInterval: 1)
    os_signpost(.end, log: pointsOfInterest, name: "Block", signpostID: id, "Finishing %{public}@", message)
}

Then use addExecutionBlock:

let queue = OperationQueue()          // you get same behavior if you replace these two lines with `let queue = OperationQueue.main`
queue.maxConcurrentOperationCount = 1

let operation = BlockOperation {
    self.someTask("main block")
}
operation.addExecutionBlock {
    self.someTask("add block 1")
}
operation.addExecutionBlock {
    self.someTask("add block 2")
}
queue.addOperation(operation)

Now, I'm adding this to a serial operation queue (because you’d never add a blocking operation to the main queue ... we need to keep that queue free and responsive), but you see the same behavior if you manually start this on the OperationQueue.main. So, bottom line, while start will run the operation “immediately in the current thread”, any blocks you add with addExecutionBlock will just run, in parallel, on “an appropriate work queue”, not necessary the current thread.

If we watch this in Instruments, we can see that not only does addExecutionBlock not necessarily honor the thread on which the operation was started, but it doesn’t honor the serial nature of the queue, either, with the blocks running in parallel:

Parallel

Obviously, if you add these blocks as individual operations, then everything is fine:

for i in 1 ... 3 {
    let operation = BlockOperation {
        self.someTask("main block\(i)")
    }
    queue.addOperation(operation)
}

Yielding:

enter image description here

Rob
  • 415,655
  • 72
  • 787
  • 1,044