libdispatch efficiency tips
The libdispatch is one of the most misused API due to the way it was presented to us when it was introduced and for many years after that, and due to the confusing documentation and API. This page is a compilation of important things to know if you're going to use this library. Many references are available at the end of this document pointing to comments from Apple's very own libdispatch maintainer (Pierre Habouzit).
My take-aways are:
You should create very few, long-lived, well-defined queues. These queues should be seen as execution contexts in your program (gui, background work, ...) that benefit from executing in parallel. An important thing to note is that if these queues are all active at once, you will get as many threads running. In most apps, you probably do not need to create more than 3 or 4 queues.
Go serial first, and as you find performance bottle necks, measure why, and if concurrency helps, apply with care, always validating under system pressure. Reuse queues by default and add more if there's some measurable benefit to it. Do not attempt to go wide by default.
Queues that target other (non-global) queues are fine and you can have many of those (the main point is having different labels). You can create such queues with
DispatchQueue.global(). Global queues easily lead to thread explosion: threads blocking on sleeps/waits/locks are considered inactive by the libdispatch which in turn will spawn new threads when other parts of your program dispatch. Note that it is impossible to guarantee that your threads are never going to block, as merely using the system libraries will cause it to happen. Global queues also do not play nice with qos/priorities. The libdispatch maintainer at Apple declared it "the worst thing that the dispatch API provides". Run your code on one of your custom queue instead (one of your well-defined execution context).
Concurrent queues are not as optimized as serial queues. Use them if you measure a performance improvement, otherwise it's likely premature optimization.
queue.async()is wasteful if the dispatched block is small (< 1ms), as it will most likely require a new thread due to libdispatch's overcommit behavior. Prefer locking to protect shared state (rather than switching the execution context).
Some classes/libraries are better designed as synchronous APIs, reusing the execution context from their callers/clients (instead of creating their own private queues which can lead to terrible performance). That means using traditional locking for thread-safety.
os_unfair_lockis usually the fastest lock on the system (nicer with priorities, less context switches). It is unsafe to use directly in Swift but it turns out
pthread_mutexhas been updated to use unfair locking by default and
NSLockthe best choice for locking in Swift.
Do not block the current thread waiting on a semaphore or dispatch group after dispatching work. This is inefficient as the kernel can't know what thread will ultimately unblock the thread. Rather, continue work asynchronously in a completion handler that is executed once the asynchronous work ends or just do the work synchronously. If you are doing XPC, synchronous message sending is fine and can be achieved with
Do not use
DispatchQueue.mainin non-GUI programs and frameworks. Per the
<dispatch/queue.h>header: "Because the main queue doesn't behave entirely like a regular serial queue, it may have unwanted side-effects when used in processes that are not UI apps (daemons). For such processes, the main queue should be avoided".
If running concurrently, your work items need not to contend, else your performance sinks dramatically. Contention takes many forms. Locks are obvious, but it really means use of shared resources that can be a bottle neck: IPC/daemons, malloc (lock), shared memory, I/O, ...
You don't need to be async all the way to avoid thread explosion. Using a small number of dispatch queues and not using
DispatchQueue.global()is a better fix.
The complexity (and bugs) of heavy async/callback designs also cannot be ignored. Synchronous code remains much easier to read, write and maintain.
Utilizing more than 3-4 cores isn't something that is easy, most people who try actually do not scale and waste energy for a modicum performance win. It doesn't help that CPUs have thermal issues if you ramp up, e.g. Intel will turn off turbo-boost if you use enough cores.
Measure the real-world performance of your product to make sure you are actually making it faster and not slower. Be very careful with micro benchmarks (they hide cache effects and keep thread pools hot), you should always have a macro benchmark to validate what you're doing.
libdispatch is efficient but not magic. Resources are not infinite. You cannot ignore the reality of the underlying operating system and hardware you're running on. Not all code is prone to parallelization.
This long discussion on the swift-evolution mailing-list started it all (look for Pierre Habouzit).
Use very few queues
Go serial first
Don't use global queues
Avoid concurrent queues in almost all circumstances
Don't use async to protect shared state
Don't use async for small tasks
Some classes/libraries should just be synchronous
Contention is a performance killer for concurrency
To avoid deadlocks, use locks to protect shared state
Don't use semaphores to wait for asynchronous work
Synchronous IPC is not bad
The NSOperation API has some serious performance pitfalls
Locks are not a bad thing
Resources are not infinite
Background QOS work is paused when low-power mode is enabled
Utilizing more than 3-4 cores isn't something that is easy
A lot of iOS 12 perf wins were from daemons going single-threaded
This page is the real deal
Hi @tclementdev, thanks a lot for this list!
I was reading the documentation of Dispatch Queue and it states:
Now, I could maybe see why it advocates for using global instead of private concurrent queues (the less queues the better), but I wanted to ask you what do you think about the "for serial tasks, set the target of your serial queue to one of the global concurrent queues"?
Does it really make any difference if I'm using 3-4 "bottom queues" with no target or with global target?
Or (I'm speculating) maybe is that even worse as we could loose some of the optimizations of the serial queues by using the global target?