I am Boris Dobretsov, and this is the fourth part of a series Understanding Parallel Programming: A Guide for Beginners.
If you haven’t read the first three parts, have a look Understanding Parallel Programming: A Guide for Beginners, Understanding Parallel Programming: A Guide for Beginners, Part II, Understanding Threads to Better Manage Threading in iOS.
Today we are going to continue exploring Threads and will discuss Thread management, Thread execution flags and Thread priority management tool. Let’s get started!
Sometimes it is not enough just to execute code in a separate thread, you need to manage it, start and cancel its execution.
The simplest thing you can do with a thread is to start
it.
let thread1 = ThreadprintDemon()
thread1.start()
The thread will start and begin executing the main method. Sometimes beginners make a mistake: they create a thread and call its main method instead of start. In this case, the method will be executed in the thread in which it was called, without creating a separate one. Be careful. The second possible action with a thread is cancellation. To do this, call the cancel
method.
let thread1 = ThreadprintDemon()
thread1.start()
thread1.cancel()
This method often causes mistakes among beginners. The fact is that it does not stop the flow, but only gives a stop command, which the developer must process himself during the flow execution.
Let's create a thread that will perform an infinite task and experiment with it.
class InfintyLoop: Thread {
override func main() {
while true {
print("😈")
}
}
}
Let's create an instance of the thread, start it and stop it immediately.
let thread1 = InfintyLoop()
thread1.start()
thread1.cancel()
This is how beginners usually check the performance of the cancel
method, and here's where the first pitfall awaits them. As a result of executing this code, there will be no messages in the console: the thread will stop, and we will think that the cancel
method really stops the thread. But in fact, when calling cancel
immediately after start, the thread will not even start executing. Let's change the code, add a 2-second pause between starting and stopping. During this time, the thread will have the time to start.
let thread1 = InfintyLoop()
thread1.start()
sleep(2)
thread1.cancel()
As you can see after executing this code, the demons have filled the console and show no intention of stopping their expansion. The stop method didn't work. Let's figure out why.
The Thread execution flags will help us with this:
• isExecuting - Indicates if the thread is running.
• isCancelled - Indicates if the thread has been stopped.
• isFinished - Indicates if the thread has been completed.
It might seem like three flags are too much, as they all represent the state of the thread. However, that’s not true. The first flag, isExecuting
, is the only one that reliably reflects the current state of the thread. The second, isCancelled
, can be modified by the developer, so the thread's actual state may not align with this flag. The third flag, isFinished
, indicates whether the thread has completed its execution.
For example, a thread that has not started executing has a state of isExecuting - false
, but isFinished
is also false
, since it has not yet completed either.
How do we use these flags to stop a thread?
We need to track the isCancelled
flag in the thread code - if it becomes true
, we simply stop the task. In this example, we can replace the while loop condition by writing !isCancelled
instead of true
. This way, the loop will only run until it is stopped.
class InfintyLoop: Thread {
override func main() {
while !isCancelled {
print("😈")
}
}
}
Now the start stop example will work as planned. This gives some flexibility: if your thread is performing an important task, you can allow it to finish gracefully rather than stopping it immediately upon receiving the command. The remaining flags are informational in nature, and there is no point in using them inside the thread. You can use them outside to see its state.
Threads help separate task execution, offloading the main thread and reducing UI lags. However, simply adding more threads doesn’t always resolve performance issues. Even with hundreds of threads, you're still limited by the device's resources. This means that while you can delegate logic to secondary threads, they may still compete for resources, impacting the performance of the main thread.
This is exactly what the thread priority management tool is for. With its help, you can prioritise certain threads based on their resource demands. For instance, a resource-intensive task like applying effects to an image can be moved to a separate thread with a lower priority, allowing the main thread to handle tasks that require immediate processor resources without freezing. Meanwhile, the image processing will be carried out when resources are available.
Let’s create a new example: we’ll define two threads that each loop and print messages to the console. We'll increase the number of iterations to 100 to make the output more noticeable.
class ThreadprintDemon: Thread {
override func main() {
for _ in (0..<100) {
print("😈")
}
}
}
class ThreadprintAngel: Thread {
override func main() {
for _ in (0..<100) {
print("😇")
}
}
}
Let's run both threads at the same time. We'll see that demons and angels interchange. There may be more demons at the very beginning - this is because their thread was launched earlier.
let thread1 = ThreadprintDemon()
let thread2 = ThreadprintAngel()
thread1.start()
thread2.start()
😈
😈
😈
😇
😈
😇
😈
😇
😈
😇
😈
😇
😈
Let's change the priorities for the threads. There are 5 priorities:
• UserInteractive - the highest priority. Used when the result is needed as soon as possible, preferably right now.
• UserInitiated - lower priority. Used when the result is important, but a small delay is acceptable.
• Utility - almost the minimum priority - for situations when the result can wait.
• Background - the lowest priority, used for background tasks.
• Default - the default priority, selected between userInitiated and Utility. Let's assign a low priority (Utility) to the thread with daemons, and the highest (userInteractive) to the thread with angels.
let thread1 = ThreadprintDemon()
let thread2 = ThreadprintAngel()
thread1.qualityOfService = .utility
thread2.qualityOfService = .userInteractive
thread1.start()
thread2.start()
The console output has changed dramatically. Now almost all angels are at the beginning, and demons are at the end. The thing is that all resources were given to the thread with angels, and the thread with demons was waiting for them to be available. This demonstrates priorities very well, but we have considered a situation where threads are very greedy, and a regular loop seeks to get all available resources.
Let's add a pause to the loop: in this case, the thread will need few processor resources to execute.
class ThreadprintDemon: Thread {
override func main() {
for _ in (0..<100) {
print("😈")
Thread.sleep(forTimeInterval: 1)
}
}
}
class ThreadprintAngel: Thread {
override func main() {
for _ in (0..<100) {
print("😇")
Thread.sleep(forTimeInterval: 1)
}
}
}
let thread1 = ThreadprintDemon()
let thread2 = ThreadprintAngel()
thread1.qualityOfService = .utility
thread2.qualityOfService = .userInteractive
thread1.start()
thread2.start()
Even though the thread priorities are still different, the console output has returned to the view where angels and demons interchange. This is because there are enough resources to run both threads at the same time.
By default, RunLoop is only present in the main thread, and it is not automatically available in any other threads you create. This means that asynchronous code, timers, and certain notifications (like Realm) will not work properly in secondary threads. Without a RunLoop, a thread will terminate as soon as its code finishes executing. However, if you need to run timers or prevent a thread from closing too soon, you can create and start a RunLoop in that thread. The RunLoop is initialised as soon as you try to access it, ensuring the thread remains alive and can handle events like timers or asynchronous tasks.
The easiest way to access the Event Loop is to try to print the current loop to the screen.
print(RunLoop.current)
After that, RunLoop will be created, even if it was not there. But it is in an inactive state. To start it, there is a Run
method.
RunLoop.current.run()
The code above creates a RunLoop if it doesn't already exist and starts it. Once the event loop is running, it keeps the thread alive infinitely. However, after the loop is started, actions in the thread will only be executed if they are added to the RunLoop sources. This means that any asynchronous code or tasks need to be added to the Event Loop before it starts or from another thread. For example, if we try to set up a timer in a thread without an event loop, it won’t work because the thread won’t have an active loop to manage the timer. Let's look at a simple example that demonstrates this issue.
class TimeThread: Thread {
override func main() {
// setup timer
Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { timer in
print("Tick")
}
}
}
Add a loop and the timer will start working.
class TimeThread: Thread {
override func main() {
// setup timer
Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { timer in
print("Tick")
}
// run loop
RunLoop.current.run()
}
}
Important: in the example, the timer is added before the event loop is started. After that, RunLoop will block the thread with its loop, and the code execution will not continue. This is how the timer will not work:
class TimeThread: Thread {
override func main() {
// run loop
RunLoop.current.run()
// setup timer
Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { timer in
print("Tick")
}
}
}
A thread started with a RunLoop will run indefinitely, as the loop will keep the thread alive. If you need to stop the thread after a certain period, you can set a time limit for the RunLoop to run. For example, for 20 seconds:
RunLoop.current.run(until: Date() + 20)
The event loop will start, but after 20 seconds the thread will be stopped. You can set any date as a limit, and even run the event loop for a week. Stopping the event loop is a bit more complicated with the cancel
method. Typically, you create an infinite while
loop that runs the RunLoop for one second if the thread is not stopped.
while !isCancelled {
RunLoop.current.run(until: Date() + 1)
}
The while
loop will continue to execute until the thread receives a cancel
command and its flag becomes true
. Inside this loop, another loop runs for exactly one second. Afterward, it stops, and the loop checks whether there was a command to stop the thread. If not, the loop will start again.
In today's lesson, we explored Thread management, Thread execution flags and Thread priority management tool. We covered how to create and manage threads, including how to start and cancel them. We also discussed the common pitfalls, such as misusing the cancel method, and how to handle thread flags like isExecuting
, isCancelled
, and isFinished
to manage thread states properly. We looked at how to control thread priorities to optimise resource usage and prevent UI sluggishness, along with the importance of RunLoop in managing asynchronous tasks and timers within threads.
Understanding these concepts is essential for efficient multithreading in iOS applications.
Next time we will discuss Grand Central Dispatch library (GCD) and simplifying thread management. Stay tuned!