iosswiftmultithreadingconcurrencydispatchgroup

Hows does DispatchGroup().leave() function correctly across different threads?


I was reading this warning about releasing a lock across different threads:

The NSLock class uses POSIX threads to implement its locking behavior. When sending an unlock message to an NSLock object, you must be sure that message is sent from the same thread that sent the initial lock message. Unlocking a lock from a different thread can result in undefined behavior.

Do such issues not affect other concurrency primitives like NSRecursiveLock and DispatchGroup?

For example, here the wait happens on the main thread and leave on the background thread. And there is no issue of entering a deadlock due to missed leave notifications.

func parallelDownload(urls: [URL]) -> [Data] {
    let session = URLSession.shared
    var fetchedImages = [Data]()

    let group = DispatchGroup()

    for url in urls {
        let request = URLRequest(url: URL)
        let task = session.dataTask(with: request) { maybeData, maybeResponse, maybeError in
            defer {
                print("Leave in completion handler \(Thread.current): Is main thread? \(Thread.current.isMainThread)")
                group.leave()
            } // leave after block completes
            guard maybeError == nil, let response = maybeResponse as? HTTPURLResponse, let data = maybeData else { return }
            if !(200...299 ~= response.statusCode) { return }
            fetchedImages.append(data)
        }
        group.enter()
        task.resume()
    }
    // Waiting for last task to finish
    print("Before wait \(Thread.current):  Is main thread? \(Thread.current.isMainThread)")
    group.wait()
    print("After wait \(Thread.current):  Is main thread? \(Thread.current.isMainThread)")
    return fetchedImages
}

let urls = Array(repeating: "https://picsum.photos/200/300", count: 3).compactMap { URL(string: $0) }
let startTime = Date()
let images = parallelDownload(urls: urls)
let endTime = Date()

print("Download time \(endTime.timeIntervalSinceNow - startTime.timeIntervalSinceNow)")

Output:

Before wait <_NSMainThread: 0x600002494180>{number = 1, name = main}:  Is main thread? true
Leave in completion handler <NSThread: 0x60000249a540>{number = 5, name = (null)}: Is main thread? false
Leave in completion handler <NSThread: 0x600002491000>{number = 4, name = (null)}: Is main thread? false
Leave in completion handler <NSThread: 0x600002496880>{number = 8, name = (null)}: Is main thread? false
After wait <_NSMainThread: 0x600002494180>{number = 1, name = main}:  Is main thread? true
Download time 0.6723159551620483


Solution

  • Re NSRecursiveLock, the docs say, “A lock that may be acquired multiple times by the same thread without causing a deadlock” (emphasis added).

    You did not ask about it, but even with unfair locks, “you must call unlock() from the same thread you use to call lock().”

    However, DispatchGroup has no such caveat. It is safe to use from different threads. If you are really interested in the details, see the source code.

    FWIW, DispatchSemphore (another anti-pattern in most situations) can also be used “across multiple execution contexts”.


    By the way, using wait of either DispatchSemaphore or DispatchGroup is an anti-pattern as it blocks a thread. This is especially problematic when invoked from the main thread. With DispatchGroup, we can avoid this problem by using notify with asynchronous pattern (e.g., a completion handler closure parameter) instead of wait.

    Or, better, nowadays we would use Swift concurrency’s async-await.