Search code examples
swiftgrand-central-dispatch

Dispatch work to multiple queue's and wait synchronically


I want the test below to pass. In my real code, AsyncClass is dispatching work to multiple queue's using the DispatchGroup class. In Swift we work with @escaping completion handlers for async work. I am looking for a way to make the calling thread wait for the dispatchGroup to finish working. The closure could go away that way.

So long story short: I have a calling thread (main) that is calling a function that is dispatching work to multiple queue's. I want the calling thread to be blocked while that work is going on and be unblocked when the work is done. This results in that the @escaping completionHandler can go away and I can call the function normally without a closure (and when I go to the next line after the method call, the work is completely done ofcourse)

I use this code only for tests, I know I should never block the main thread in production.

Usecase: I have tests which call an expensive method. That method is doing work like ‘AsyncClass’ in this example. Inside AsyncClass I am dispatching work to some threads to speed things up. Now while testing, I can create expectations all the time and call this method with a closure, but that’s to verbose for me. I want to turn an async method to a sync method, for the ease of use.

import XCTest

class DispatchGroupTestTests: XCTestCase {

    func testIets() {
        let clazz = AsyncClass()

        var isCalled = false

        clazz.doSomething {
            isCalled = true
        }

        XCTAssert(isCalled)
    }

}

class AsyncClass {
    func doSomething(completionHandler: @escaping () -> ()) {
        let dispatchGroup = DispatchGroup()

        for _ in 0...5 {
            dispatchGroup.enter()

            DispatchQueue.global().async {
                let _ = (0...10000).map { $0 * 1000 }

                dispatchGroup.leave()
            }
        }

        dispatchGroup.wait() // Doesn't work

        dispatchGroup.notify(queue: .main) {
            completionHandler()
        }
    }
}

Solution

  • I managed to do it spinlock-way (just sleep the thread in a while loop):

    import XCTest
    
    class DispatchGroupTestTests: XCTestCase {
    
        func testIets() {
            let clazz = AsyncClass()
    
            var isCalled = false
    
            clazz.doSomething {
                isCalled = true
            }
    
            XCTAssert(isCalled)
        }
    
    }
    
    class AsyncClass {
        func doSomething(completionHandler: () -> ()) {
            var isDone = false
            let dispatchGroup = DispatchGroup()
    
            for _ in 0...5 {
                dispatchGroup.enter()
    
                DispatchQueue.global().async {
                    let _ = (0...1000000).map { $0 * 10000 }
    
                    dispatchGroup.leave()
                }
            }
    
            dispatchGroup.notify(queue: .global()) {
                isDone = true
            }
    
            while !isDone {
                print("sleepy")
                Thread.sleep(forTimeInterval: 0.1)
            }
    
            completionHandler()
        }
    }
    

    Now, the closure isn't needed anymore, and the expectations can be removed (although I don't have them in the provided examples, I can omit them in my 'real' testing code):

    import XCTest
    
    class DispatchGroupTestTests: XCTestCase {
    
        var called = false
    
        func testIets() {
            let clazz = AsyncClass()
    
            clazz.doSomething(called: &called)
    
            XCTAssert(called)
        }
    
    }
    
    class AsyncClass {
        func doSomething(called: inout Bool) {
            var isDone = false
            let dispatchGroup = DispatchGroup()
    
            for _ in 0...5 {
                dispatchGroup.enter()
    
                DispatchQueue.global().async {
                    let _ = (0...1000000).map { $0 * 10000 }
    
                    dispatchGroup.leave()
                }
            }
    
            dispatchGroup.notify(queue: .global()) {
                isDone = true
            }
    
            while !isDone {
                print("sleepy")
                Thread.sleep(forTimeInterval: 0.1)
            }
    
            called = true
        }
    }
    

    So I did a performance test with the measure block and my expectations (not XCTestCase expectations) become fulfilled: it is quicker to dispatch work to other threads and block the calling thread in a spinlock, comparing to dispatch all the work to the calling thread (taking into account we don't want escaping blocks and we want everything sync, just for easy calling functions inside test methods). I literally just filled in random computations and this was the result:

    import XCTest
    
    let work: [() -> ()] = Array.init(repeating: { let _ = (0...1000000).map {  $0 * 213123 / 12323 }}, count: 10)
    
    class DispatchGroupTestTests: XCTestCase {
    
        func testSync() {
            measure {
                for workToDo in work {
                    workToDo()
                }
            }
        }
    
        func testIets() {
            let clazz = AsyncClass()
    
            measure {
                clazz.doSomething()
            }
        }
    
    }
    
    class AsyncClass {
        func doSomething() {
            var isDone = false
            let dispatchGroup = DispatchGroup()
    
            for workToDo in work {
                dispatchGroup.enter()
    
                DispatchQueue.global().async {
                    workToDo()
    
                    dispatchGroup.leave()
                }
            }
    
            dispatchGroup.notify(queue: .global()) {
                isDone = true
            }
    
            while !isDone {
                Thread.sleep(forTimeInterval: 0.1)
            }
        }
    }
    

    enter image description here