Pre Modern Concurrency Recap Swift part 1
Before the arrival of modern Swift Concurrency, iOS developers relied heavily on a tool called Grand Central Dispatch (GCD) to manage concurrent tasks. Let’s delve into how iOS handled concurrency before Swift Concurrency was introduced. The topics covered in Part 1 are:
- Concurrency:
- Context Switching (Single Core):
- Parallelism (Multi-Core):
- Tasks:
- Threads:
- Queue:
- Grand Central Dispatch (GCD):
- Types of Dispatch Queues :
- DispatchQoS.QoSClass (Quality of Service):
- Execution with sync and async:
- Dispatch Groups:
- Dispatch Semaphore:
- Custom Serial vs. Concurrent Queues:
- DispatchSource:
- DispatchWorkItem:
Concurrency
- Definition: Concurrency is the ability to run multiple tasks or threads seemingly at the same time. In reality, on a single-core processor, concurrency is achieved by quickly switching between tasks (context switching). On a multi-core processor, true parallelism is possible.
- Importance: Concurrency allows apps to remain responsive by running time-consuming tasks (like network requests or complex calculations) without blocking the user interface.
- Real-life analogy: Think of a restaurant where the chef is cooking multiple dishes simultaneously. Each dish represents a task, and the chef represents the CPU. Even if the chef is handling one dish at a time (single-core), they switch between them rapidly, giving the illusion of cooking them simultaneously.
Example:
DispatchQueue.global().async {
// Background task
print("Task running in the background")
}
print("Main thread continues without waiting")
- In this example, the background task runs concurrently, allowing the
print
statement on the main thread to execute without waiting.
Context Switching (Single Core)
- Definition: Context switching refers to the CPU’s ability to switch between different threads, allowing multiple tasks to be executed on a single-core processor. This switch happens so quickly that it gives the appearance of parallelism.
- Use-case: In single-core processors, iOS uses context switching to maintain smooth UI interactions while handling background tasks.
Example:
let queue = DispatchQueue(label: "com.example.serial")
queue.async {
print("Task 1 running")
sleep(2) // Simulate work
print("Task 1 completed")
}
queue.async {
print("Task 2 running")
sleep(1)
print("Task 2 completed")
}
- Even though this is a serial queue, it uses context switching to schedule and manage these tasks. Task 1 and Task 2 are executed sequentially on the same thread.
Parallelism (Multi-Core)
- Definition: Parallelism occurs when multiple threads are executed simultaneously across different CPU cores. It is true simultaneous execution, unlike context switching.
- Importance: Parallelism can significantly improve the performance of apps by dividing tasks into smaller subtasks and executing them on different cores.
Example:
DispatchQueue.concurrentPerform(iterations: 4) { index in
print("Task \(index) is being processed")
}
- Here, the
concurrentPerform
function splits the work into four tasks, which can be executed on different cores simultaneously, making the process faster.
Tasks
- Definition: A task represents a unit of work that the CPU executes. It could be something like downloading data from the internet or performing a calculation.
- Real-life analogy: Think of a task as a dish that a chef needs to cook. Each dish (task) requires a set of instructions and ingredients, similar to how a program executes tasks using instructions.
Example:
let task = {
print("Task is running")
}
task() // This executes the task immediately
- The closure
task
here is a block of code that is executed when called, representing a simple task.
Threads
- Definition: A thread is a sequence of instructions within a program that can be managed independently by the system. Threads run tasks and can be created and managed by the OS.
- Importance: Threads allow for multitasking within applications, making it possible to perform multiple operations concurrently.
- Real-life analogy: Imagine a chef (thread) in a kitchen (CPU core). The chef can cook multiple dishes (tasks) one after another, or if more chefs are hired, they can cook dishes simultaneously (parallelism).
Example:
Thread {
print("This is running on a new thread")
}.start()
- This code starts a new thread, which then executes the given closure.
Queue
- Definition: A queue is a data structure that manages a collection of tasks. In GCD, queues help organize and manage the execution of these tasks.
Types:
- Serial Queues: Execute one task at a time, in the order they are added.
- Concurrent Queues: Allow multiple tasks to run simultaneously but finish in any order.
- Importance: Queues make it easy to manage background tasks without needing to handle the complexities of creating and destroying threads manually.
Example:
let serialQueue = DispatchQueue(label: "com.example.serial")
serialQueue.async {
print("Task 1")
}
serialQueue.async {
print("Task 2")
}
- In a serial queue,
Task 1
will always run beforeTask 2
, even though they are both added asynchronously.
Grand Central Dispatch (GCD)
GCD is a low-level API used for managing concurrent operations in iOS and macOS applications. It allows developers to run tasks asynchronously and parallelize code execution, improving app performance by efficiently using system resources. GCD manages a shared thread pool, allowing developers to focus on the task’s logic rather than handling threads directly. It uses a Dispatch Queue to manage how and when tasks (blocks of code or DispatchWorkItem
instances) are executed.
DispatchQueue
GCD provides a FIFO (First In First Out) Queue.
In other words, the process is started in the order in which it was added to the Queue.
Types of Dispatch Queues
1. Serial Dispatch Queue (Private Dispatch Queue)
- A serial queue executes one task at a time in a specific order.
- Tasks are executed one after another but not necessarily on the same thread.
- Use serial queues when accessing resources that should not be accessed simultaneously.
let serialQueue = DispatchQueue(label: "com.example.serialQueue")
serialQueue.async {
print("Task 1")
}
serialQueue.async {
print("Task 2")
}
//output
Task 1
Task 2
2. Concurrent Dispatch Queue (Global Dispatch Queue)
- In a concurrent queue, multiple tasks can run simultaneously.
- Tasks start in the order they are added, but the completion time may vary.
- Suitable for tasks that can run in parallel without dependency on each other.
let concurrentQueue = DispatchQueue(label: "com.example.concurrentQueue", attributes: .concurrent)
concurrentQueue.async {
print("Task A")
}
concurrentQueue.async {
print("Task B")
}
//output
Task A
Task B
Specific Queue Types in GCD
Main Queue:
- A serial queue that runs tasks on the application’s main thread.
- Use it for UI updates or tasks that require interaction with the UI.
DispatchQueue.main.async {
print("Update UI on main thread")
}
Global Queue:
- A system-wide concurrent queue with different priority levels.
- Priorities are set using
DispatchQoS.QoSClass
(Quality of Service).
let globalQueue = DispatchQueue.global(qos: .userInitiated)
globalQueue.async {
print("Background work on a global queue")
}
Custom Queue:
- Developers can create their own serial or concurrent queues.
- Custom queues are often used for background tasks that need sequential execution.
let customQueue = DispatchQueue(label: "com.example.customQueue")
customQueue.async {
print("Custom serial queue task")
}
DispatchQoS.QoSClass (Quality of Service)
QoS defines the priority of a task and affects how the system schedules it. Here are the classes:
- userInteractive: For tasks that require immediate execution on the main thread, such as UI updates.
- userInitiated: For tasks initiated by user actions where the user expects an immediate response.
- default: The standard priority if no QoS is specified.
- utility: For tasks that need progress indicators, such as downloads. It has a lower priority.
- background: For tasks that do not require user interaction, like pre-fetching data.
- unspecified: The system decides the priority based on available resources.
let userInteractiveQueue = DispatchQueue.global(qos: .userInteractive)
userInteractiveQueue.async {
print("Performing a quick UI update")
}
let backgroundQueue = DispatchQueue.global(qos: .background)
backgroundQueue.async {
print("Performing background work")
}
Execution with sync
and async
async
:
- The
async
method executes a block asynchronously and does not block the caller. - Control returns immediately, and the task is performed in the background.
- Example: Using
async
to update UI elements after background processing.
let queue = DispatchQueue(label: "com.example.asyncQueue")
queue.async {
print("This runs in the background")
}
print("This runs immediately")
// output
This runs immediately
This runs in the background
sync
:
- The
sync
method waits until the block finishes execution before returning control. - It blocks the current thread until the task is complete.
- Example: Using
sync
when a result is needed immediately before proceeding.
let syncQueue = DispatchQueue(label: "com.example.syncQueue")
syncQueue.sync {
print("This runs in sync, blocking until done")
}
print("This runs after the sync task completes")
// output
This runs in sync, blocking until done
This runs after the sync task completes
Example of sync
vs. async
Affecting Values
Async Example (value remains unchanged immediately):
var value = 42
serialQueue.async {
value = 0
}
print(value) // Output: 42, because async does not wait for the update.
Sync Example (value is updated before continuing):
var value = 42
serialQueue.sync {
value = 0
}
print(value) // Output: 0, because sync waits for the update.
Use of Async to have series of synchronous function calls
Example:
func step1() -> String {
return "One"
}
func step2(_ input: String) -> String {
return "\(input), Two"
}
func step3(_ input: String) -> String {
return "\(input), Three"
}
let queue = DispatchQueue.global(qos: .userInitiated)
queue.async {
let result = step3(step2(step1()))
print(result) // Output: One, Two, Three
}
- The use of
queue.async
means that this block of code will run on a background thread without blocking the main thread. The output of"One, Two, Three"
will appear once the background task completes. - This approach is commonly used in scenarios where you need to perform time-consuming operations (like network requests or heavy calculations) off the main thread to keep the UI responsive.
Using DispatchQueue.asyncAfter
asyncAfter
allows you to delay the execution of a block. It is commonly used for scheduling tasks to run after a specified time delay.
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
print("Executed after 1 second")
}
Dispatch Groups
Dispatch Groups are used to manage multiple asynchronous tasks as a single unit. They allow you to:
- Group tasks together and wait for all of them to complete.
- Notify when all tasks in the group finish.
let group = DispatchGroup()
let queue = DispatchQueue.global()
group.enter()
queue.async {
sleep(1)
print("Task 1 completed")
group.leave()
}
group.enter()
queue.async {
sleep(2)
print("Task 2 completed")
group.leave()
}
group.notify(queue: .main) {
print("All tasks are completed")
}
//output
Task 1 completed
Task 2 completed
All tasks are completed
Dispatch Semaphore
A Dispatch Semaphore is used to control access to a resource by multiple threads, limiting the number of concurrent tasks.
- Initialize a semaphore with a counter value representing the maximum number of concurrent tasks allowed.
- Call
wait()
to decrement the counter and block the thread if the value is zero. - Call
signal()
to increment the counter, allowing another task to proceed.
Example:
let semaphore = DispatchSemaphore(value: 1)
let queue = DispatchQueue.global()
for i in 1...3 {
queue.async {
semaphore.wait() // Decrease semaphore count and block if count is zero
print("Task \(i) started")
sleep(2)
print("Task \(i) finished")
semaphore.signal() // Increase semaphore count
}
}
//output
Task 1 started
Task 1 finished
Task 2 started
Task 2 finished
Task 3 started
Task 3 finished
Semaphores are useful when you want to ensure a certain number of tasks run concurrently or when you need to use results from an asynchronous process as a return value.
Custom Serial vs. Concurrent Queues
- Custom Serial Queue ensures that tasks are executed one at a time, useful for sequential background operations.
Example:
let serialQueue = DispatchQueue(label: "com.example.serial")
serialQueue.async {
print("First task")
}
serialQueue.async {
print("Second task")
}
// output
First task
Second task
- Custom Concurrent Queue allows multiple tasks to execute simultaneously, useful for independent background operations like image processing.
Example of creating a custom concurrent queue:
let concurrentQueue = DispatchQueue(label: "com.example.concurrent", attributes: .concurrent)
concurrentQueue.async {
print("Concurrent Task 1")
}
concurrentQueue.async {
print("Concurrent Task 2")
}
//output
Concurrent Task 1
Concurrent Task 2
Effective Use of Concurrent Queues
Concurrent queues are ideal for parallel tasks like downloading images or processing large data sets.
Example:
let downloadQueue = DispatchQueue(label: "com.example.download", attributes: .concurrent)
for i in 1...5 {
downloadQueue.async {
print("Downloading image \(i)")
sleep(2) // Simulate network delay
print("Downloaded image \(i)")
}
}
Output (order may vary):
Downloading image 1
Downloading image 2
Downloading image 3
Downloading image 4
Downloading image 5
Downloaded image 1
Downloaded image 2
DispatchSource
DispatchSource is a GCD feature used to monitor various low-level system events. It allows your app to respond to changes like file system changes, timers, signals, or process events. Here’s a breakdown of the types and uses of DispatchSource
:
Types of DispatchSource
- Timer Sources: Generates periodic notifications. Useful for creating timers.
- Signal Sources: Notifies when a UNIX signal (like
SIGSTOP
) is delivered. - Descriptor Sources: Monitors file or socket activities (e.g., data becomes available to read/write).
- Process Sources: Notifies about process-related events (e.g., when a process terminates).
- Mach Port Sources: Monitors events related to Mach ports (a low-level inter-process communication mechanism).
- Custom Sources: Allows you to define your own conditions for event notifications.
Example of a Timer with DispatchSource
Here’s an example of creating a simple timer using DispatchSource
:
// Create a timer using DispatchSource
let timer = DispatchSource.makeTimerSource()
// Set the timer to fire every second, starting immediately
timer.schedule(deadline: .now(), repeating: 1.0)
// Set an event handler for the timer
timer.setEventHandler {
print("Timer fired!")
}
// Start the timer
timer.resume()
In this example:
- We create a
DispatchSource
timer that fires every second. - The
setEventHandler
closure defines what happens each time the timer fires. - Calling
resume()
starts the timer.
Example of Signal Handling with DispatchSource
Here’s an example of setting up a DispatchSource
to handle UNIX signals (like SIGINT
):
var signalSource: DispatchSourceSignal?
func setupSignalHandler() {
// Create a source that listens for the SIGINT signal
signalSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
signalSource?.setEventHandler {
print("SIGINT received!")
// Perform cleanup or other actions here
}
// Start listening for the signal
signalSource?.resume()
}
// Call this function during app setup
setupSignalHandler()
This code listens for the SIGINT
signal and prints a message when the signal is received. It’s a way to handle events like user interruptions (Ctrl+C in terminal).
Example of File Monitoring with DispatchSource
You can also monitor file changes using DispatchSource
:
let fileDescriptor = open("/path/to/file", O_EVTONLY)
let fileSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, eventMask: .write, queue: .main)
fileSource.setEventHandler {
print("File changed!")
}
fileSource.resume()
his example monitors changes to a specified file and prints a message whenever the file is modified.
DispatchWorkItem
DispatchWorkItem is a wrapper for a block of code (closure) that you want to execute. It provides additional control over tasks, like canceling them before they are executed or performing specific actions upon completion.
Key Features of DispatchWorkItem
- Encapsulating Work: Encapsulates a unit of work that can be executed on a
DispatchQueue
. - Cancellation: You can cancel a
DispatchWorkItem
before it starts executing. - Notifying Completion: Allows you to set a completion handler that runs after the work item finishes.
Example of DispatchWorkItem
Here’s a simple example showing how to create and use a DispatchWorkItem
:
// Create a DispatchWorkItem
let workItem = DispatchWorkItem {
print("This work item is executing")
}
// Execute the work item asynchronously
DispatchQueue.global().async(execute: workItem)
In this example:
- A
DispatchWorkItem
is created and encapsulates the task of printing a message. - It is executed on a global concurrent queue using
DispatchQueue.global()
.
Example of Canceling a DispatchWorkItem
You can cancel a work item before it starts executing:
// Create a DispatchWorkItem with a long-running task
let longRunningWorkItem = DispatchWorkItem {
for i in 1...10 {
if longRunningWorkItem.isCancelled {
print("Work item was cancelled!")
return
}
print("Processing \(i)")
sleep(1) // Simulate time-consuming work
}
print("Work item completed")
}
// Execute the work item
DispatchQueue.global().async(execute: longRunningWorkItem)
// Cancel the work item after 3 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
longRunningWorkItem.cancel()
}
In this example:
- A
DispatchWorkItem
is created to perform a long-running task (a loop that prints values). - The
isCancelled
property is checked inside the loop to stop the task if it is canceled. - The work item is canceled after 3 seconds, stopping it before it completes all iterations.
Example of Using DispatchWorkItem with Completion Handler
You can add a completion handler to a DispatchWorkItem
:
let workItem = DispatchWorkItem {
print("Performing some task")
}
// Set a completion handler to run after the task finishes
workItem.notify(queue: .main) {
print("Task completed")
}
// Execute the work item
DispatchQueue.global().async(execute: workItem)
In this example:
- A
DispatchWorkItem
is created and given a task. - The
notify
method is used to add a completion handler, which runs on the main queue after the work item finishes.