About a year ago, my team started a new project. This time we want to use all of our knowledge from previous projects. One decision was that we wanted to make the entire Model API asynchronous. This will allow us to change the entire implementation of the Model without affecting the rest of the APP. If our APP can handle asynchronous calls, then we don’t need to worry about communicating with the back end and caching data to the database. It also allows us to achieve concurrency.
As developers, we have to understand what parallelism and concurrency mean. Otherwise, we might make some very serious mistakes. Now, let’s learn how to program concurrently!
Synchronous vs Asynchronous
So what’s the difference between synchronous and asynchronous? Suppose we have a bunch of items, and when we synchronize the items, we start with the first item, then the second, and so on. It is executed In the same order as a FIFO(First In, First Out) queue, First In, First Out.
Converted to code: method1() each statement is executed sequentially.
Statement1 () -> statement2() -> statement3() -> statement4() funcmethod1() {
statement1()
statement2()
statement3()
statement4()
}
Copy the code
So, synchronization means that only one item can be completed at a time.
In contrast, asynchronous processing can process multiple items at the same time. For example, it will process Item1, then pause Item1, process Item2, then continue and complete Item1.
Using a simple callback as an example, we can see that Statement2 is executed before the callback.
func method2() {statement1 {callback1()} statement2requestData() {
URLSession.shared.dataTask(with: URL(string: "https://www.example.com/")! { (data, response, error)in
DispatchQueue.main.async {
print("callback")
}
}.resume()
print("statement2"} requestData() // Print the order statement2 callbackCopy the code
Concurrency vs. Parallelism
Concurrency and parallelism are often used interchangeably (even Wikipedia has errors). This leads to some problems that can be avoided if we can clearly understand the meaning of both. Let’s give an example:
Consider this: We have A stack of boxes that need to be transported from point A to point B. We can use workers to finish the work. In A synchronous environment, we can only do this with one worker, who picks up A box from point A and drops it from point B.
If we were able to hire multiple workers at the same time, they would be working at the same time, picking up A box from point A, dropping it from point B. Obviously, this will greatly improve our efficiency. As long as at least two workers are transporting boxes at the same time, they are doing parallel processing.
Parallelism is working at the same time.
If we only have one worker and we want him to do a few more things, then what happens? Then we should consider having multiple boxes in the processing state. That’s what concurrency means, it’s like dividing the distance from point A to point B into several steps, so A worker can pick up A box from point A, walk halfway down the box, and then go back to point A to pick up another box.
Using multiple workers we can make them all carry boxes at different distances. So we can process these boxes asynchronously. If we have multiple workers then we can work on these boxes in parallel.
The distinction between parallelism and concurrency is now clear. Parallel means working at the same time; Parallelism refers to the choice of working at the same time, which may or may not be parallel. Most of our computers and mobile devices can do parallel processing (depending on how many cores it has), but software certainly works concurrently.
Concurrent mechanism
Different operating systems provide different tools for you to use concurrency. In iOS, our default tools: processes and threads, due to the history of OC, also have Dispatch Queues.
Process
Processes are instances of your app. It contains everything you need to implement your App, including your stack, heap, and all your resources.
Although iOS is a multitasking OS, it does not support multiple processes for an App, so you only have one process. On MAC OS, however, you can use the Process class to create new child processes. They are independent of the parent process, but contain all the information that the parent process has when it creates the child process. If you are using macOS, here is the code to create and execute a process:
let task = Process()
task.launchPath = "/bin/sh" //executable you want to run
task.arguments = arguments //here is the information you want to pass
task.terminationHandler = {
// do here something in case the process terminates
}
task.launch()
Copy the code
Threads
Threads are similar to lightweight processes. In contrast to processes, threads share their memory in their parent process. This can cause problems, such as two threads changing a variable at the same time. When we read the value of the change again, we get an unpredictable value. In iOS (and other POSIX-compliant systems), threads are a restricted resource, with a maximum of 64 threads in a process at a time. You can create and execute threads like this:
class CustomThread: Thread {
override func main() {
do_something
}
}
let customThread = CustomThread()
customThread.start()
Copy the code
Dispatch Queues
Since we only have one process and can only use 64 threads at most, we must use other methods to make the code concurrent. Apple’s solution is the Dispatch queue. You can add tasks to the Dispatch queue and expect them to be executed at some point. There are different types of Dispatch queues:
- SerialQueue: a SerialQueue that executes its tasks sequentially.
- ConcurrentQueue: a ConcurrentQueue that executes tasks in the queue concurrently.
That’s not really concurrency, right? Especially serial queues, we don’t see any improvement in efficiency. Concurrent queues don’t make anything easier. We do have threads, so what’s the point?
Let’s think about what happens if we have multiple queues. We can run multiple queues on threads and then add tasks to one of them when we need them. Let’s imagine that we can even optimize our system resources by distributing tasks that need to be added based on priority and current workload.
Apple calls this implementation Grand Central Dispatch, or GCD for short. How does it work on iOS?
DispatchQueue.main.async {
// execute async on main thread
}
Copy the code
The biggest advantage of GCD is that it changes the mindset of concurrent programming. You don’t need to think about threads when you use it, you just add tasks you need to execute to different queues, which makes concurrent programming much easier.
Operation Queues
Operation Queue is a higher abstraction of Cocoa from GCD. You can create operations instead of blocks. It adds operations to the queue and executes them in the correct order. There are the following types of queues:
- Main queue: The main queue, executed on the main thread.
- Custom Queue: a custom queue that is not executed on the main thread.
let operationQueue: OperationQueue = OperationQueue()
operationQueue.addOperations([operation1], waitUntilFinished: false)
Copy the code
You can create operation using a block or subclass. If you subclass, don’t forget to call Finish. If you do, Operation will always execute.
class CustomOperation: Operation {
override func main() {
guard isCancelled == false else {
finish(true)
return
}
// Do something
finish(true)}}Copy the code
The advantage of operation is that you can use dependencies. If A depends on the result of B, then A will not be executed until the result of B is obtained.
//execute operation1 before operation2
operation2.addDependency(operation1)
Copy the code
Run Loops
Run Loop is similar to a queue. The system queue runs all the work and then restarts at the beginning, for example, by redrawing the screen, via Run Loop. One thing to note here is that they are not really concurrent methods, they run on a single thread. It allows your code to execute asynchronously while removing the burden of concurrency considerations. Not every thread has a Run Loop. The Run Loop for the main thread is enabled by default, and the Run Loop for the child thread needs to be created manually.
When you use Run Loop, you need to consider the different modes. For example, when you slide your device, the Run Loop in the main thread changes and delays all incoming events. When you stop sliding, the Run Loop switches to its default mode and processes events. The input source is necessary for the Run Loop; otherwise, each execution will end immediately. So don’t forget this point.
Lightweight Routines
There is a new idea for a truly lightweight thread, but it is not yet implemented by Swift. Details can be found here.
Options to Control Concurrency
We looked at all the different elements provided by the operating system that can create concurrency. But as mentioned above, this can also cause a lot of problems. One of the easiest and most difficult problems to identify is when multiple concurrent tasks access the same resource at the same time. If there is no mechanism to handle these accesses, one task may write one value. When the first task reads the value, it expects the value it wrote, not the value written by other tasks. Therefore, the default method is to lock access to the resource to prevent other threads from accessing it while the resource is locked.
Priority Inversion
Before we look at the differences between locking mechanisms, we need to look at thread priorities. As you might expect, threads can be set to high and low priority, meaning that the higher priority will be executed before the lower priority. When a lower priority thread locks a resource, if a higher priority thread accesses the resource, the higher priority thread must wait to unlock, so that the lower priority thread’s priority increases. This is called priority inversion, but it causes the high-priority thread to wait because it will never be executed. So we need to be careful not to cause this.
Imagine that you now have two high-priority threads 1, 2, and a low-priority thread 3. If thread 3 blocks thread 1 from accessing the resource, thread 1 must wait. Because thread 2 has a higher priority, its task will be finished first. Thread 3 will not execute without terminating, so thread 1 will block indefinitely.
Priority inheritance
The solution to priority inversion is priority inheritance. In this case, if thread 1 is blocked by thread 3, it will give its priority to thread 3. So both thread 3 and thread 2 have high priority and can execute together (OS dependent). When thread 3 unlocks the resource, it gives priority back to thread 1, which will continue with the original operation.
Atomicity (Atomic)
Atomicity involves the same idea as transactions in a database context. You want to write one value at a time as an operation. 32-bit compiled applications can behave strangely when using INT64_t without atomicity. Let’s take a closer look at what happened:
int64_t x = 0
Thread1:
x = 0xFFFF
Thread2:
x = 0xEEDD
Copy the code
The non-atomic operation would have caused x to be written in thread 1, but because we were working on a 32-bit system, we had to split the written X into 0xFF.
When thread 2 decides to write x at the same time, the following occurs:
Thread1: part1
Thread2: part1
Thread2: part2
Thread1: part2
Copy the code
We end up with:
x == 0xEEFF
Copy the code
Neither 0xFFFF nor 0xEEDD.
Using atomicity, we create a single transaction that produces the following behavior:
Thread1: part1
Thread1: part2
Thread2: part1
Thread2: part2
Copy the code
As a result, x contains the values set by thread 2. Swift itself does not implement atomic. You can add a suggestion here, but for now, you have to implement it yourself.
The lock
A lock is a simple way to prevent multiple threads from accessing the same resource. The thread first checks to see if it can enter the protected part, and if it can, it locks the protected resource, and then performs that thread operation. When the thread completes its operation, it unlocks the resource. If the incoming thread touches the locked part, it waits to unlock it. This is somewhat similar to sleep and wake up to check if resources are locked.
On iOS, you can implement this mechanism through NSLock. Note that the thread you unlock and the thread you lock must be the same thread.
let lock = NSLock()
lock.lock()
//do something
lock.unlock()
Copy the code
There are other types of locks, such as recursive locks. It can lock the same resource multiple times and must release it at lock time. During this process, other threads are excluded.
Another is read-write locks, which can be useful for large apps that require a large number of threads to read but not write. As long as no thread writes, all threads are accessible. It locks the resources of all threads as long as any thread wants to write. All threads cannot read until unlocked.
At the process level, there is also a distributed lock. The difference is that if the process is blocked, it only reports it to the process, and the process can decide how to handle the situation.
Spinlock
A lock consists of multiple operations that put a thread to sleep until it is started again. This causes the CPU to change context (push registration, etc., to store thread state). These changes take a lot of computing time, and if you have really small operations to protect, you can use spin locks. The basic idea is to poll the lock as long as the thread is waiting. This requires more resources than hibernating a thread. At the same time, it bypasses text changes, so it’s faster on small operations.
That sounds good in theory, but iOS is always surprising. IOS has a concept called Quality of Service (QoS). Using it, it is possible that low-priority threads will not execute at all. Setting a spinlock on such a thread would cause the higher-priority thread to overwrite the lower-priority thread when a higher-priority thread tried to access it, thus failing to unlock the required resource and blocking itself. So, spin locks are illegal on iOS.
The Mutex (Mutex)
Mutexes are similar to locks, except that they can access processes rather than just threads. Sadly, you have to implement it yourself, Swift doesn’t support mutexes. You can use C’s pthread_mutex.
var m = pthread_mutex_t()
pthread_mutex_lock(&m)
// do something
pthread_mutex_unlock(&m)
Copy the code
Semaphores (Semaphore)
A semaphore is a data structure that supports mutual exclusion in thread synchronization. It consists of counters and is a first-in, first-out queue with wait() and signal() functions.
Whenever a thread wants to enter a protected part, it calls wait() for the semaphore. The semaphore will decrement its count, and as long as the count is not zero, the thread can continue. Instead, it stores threads in its queue. When the protected part leaves a thread, it calls signal() to notify the semaphore. The semaphore first checks to see if there are waiting threads in the queue, and if there are, it wakes up the thread and lets it continue. If not, it will increase its count again.
In iOS, we can implement this behavior using DispatchSemaphors. It prefers to use DispatchSemaphors over the default semaphores, as they only drop down to the kernel level when they are really needed. Otherwise, it would run much faster.
let s = DispatchSemaphore(value: 1)
_ = s.wait(timeout: DispatchTime.distantFuture)
// do something
s.signal()
Copy the code
It has been argued that binary semaphores (semaphores with a count of one) are the same as mutex. But mutex is a locking mechanism, and a semaphore is a signaling mechanism. This explanation doesn’t help, so what’s the difference?
Locking is about protecting and managing access to a resource, so it prevents multiple threads from accessing the same resource at the same time. The signal system is more like, “Hey, I’m done. Move on!” . For example: If you are listening to music on your cell phone and a call comes in. When you finish the call, it will send a notification to your player to continue. This is a case where the semaphore is considered on a mutex.
My guess is that playing music and listening to music are mutually exclusive, because you can't answer the phone and listen to music at the same time. After the call is complete, the phone sends a signal to the Player to continue playing, which is a semaphore operation.
If you have a low-priority thread 1 in the protected area, you also have a high-priority thread 2 called wait() by the semaphore to make it wait. At this point thread 2 is dormant waiting for a semaphore to wake it up. At this point, we have thread 3, which has a higher priority than thread 1. Thread 3 combines Qos to prevent thread 1 from notifying the semaphore, thus overwriting other threads. So semaphores in iOS don’t have priority inheritance.
Synchronized
In OC, there is a keyword @synchronized. This is an easy way to create a mutex. Since Swift doesn’t support it, we have to use a lower-level method: objc_sync_Enter.
let lock = self
objc_sync_enter(lock)
closure()
objc_sync_exit(lock)
Copy the code
Because I’ve seen this question on the Internet a lot, so let’s answer it. As far as I know, this is not a private method, so using it will not be rejected by the App Store.
Concurrency Queues Dispatching
Since there is no Metux in Swift and synchornized has been removed, using DispatchQueues has been the golden rule for Swift developers. It has the same behavior as Metux when used for synchronization. Because all operations are queued in the same queue. This prevents simultaneous execution.
Its disadvantages are that it is time consuming and it must be constantly allocated and changed context. This doesn’t matter if your App doesn’t need any high computing power. However, if you encounter problems such as frame loss, you may need to consider alternative solutions (such as Mutex).
Dispatch Barriers
If you use GCD, you have a number of ways to synchronize code. One of them is Dispatch Barriers. It allows us to create blocks of protected parts that need to be executed together. We could also execute this code asynchronously, which sounds strange, but imagine that you have a time-consuming operation that can be broken up into several smaller tasks. These small tasks can be performed asynchronously, and when all the small tasks are completed, Dispatch Barriers synchronize these small tasks.
Translator's note: For example, divide a large image into several small images and download them asynchronously. After downloading all the small images asynchronously, synchronize them to a large image.
Trampoline
It is not a mechanism provided by the operating system. It is a pattern used to ensure that methods are called in the correct thread. The idea is simple: it starts by checking if the method is on the right thread, and if not, it calls itself on the right thread and returns. Sometimes, you need to use the above locking mechanism to implement waiting programs. This happens only if the calling method has a return value. Otherwise, you can simply return.
func executeOnMain() {
if! Thread.isMainThread { DispatchQueue.main.async(execute: {() -> Voidin
executeOnMain()
return/ /})}do something
}
Copy the code
Don’t use this pattern too often. While it can make sure you’re on the right thread, at the same time, it can confuse your colleagues. They may not understand that you’re changing threads around. At some point, it will make your code like shit and waste your time cleaning up your code.
conclusion
Wow, that’s a lot of work. There are so many techniques for concurrent programming that this article just scratches the surface. People get sick of me when I talk about concurrency, but concurrency is really important, and my colleagues are starting to recognize me. Today, I had to fix an array asynchrony problem that we know Swift does not support atomic operations. Guess what? This caused a crash. If we knew more about concurrency, we might not have this problem. But to be honest, I didn’t know that either.
The best advice I can give you is this: know yourself, know your enemy. With all this in mind, I hope you can start learning about concurrency and find a way to solve your problems. Once you go deeper, you’ll get more and more clarity. Good Luck!
- The original link