Pre Modern Concurrency Recap Swift part 1

abdul ahad
11 min read1 day ago

--

Photo by Ben Wicks on Unsplash

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:

  1. Concurrency:
  2. Context Switching (Single Core):
  3. Parallelism (Multi-Core):
  4. Tasks:
  5. Threads:
  6. Queue:
  7. Grand Central Dispatch (GCD):
  8. Types of Dispatch Queues :
  9. DispatchQoS.QoSClass (Quality of Service):
  10. Execution with sync and async:
  11. Dispatch Groups:
  12. Dispatch Semaphore:
  13. Custom Serial vs. Concurrent Queues:
  14. DispatchSource:
  15. 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 before Task 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.

  1. Initialize a semaphore with a counter value representing the maximum number of concurrent tasks allowed.
  2. Call wait() to decrement the counter and block the thread if the value is zero.
  3. 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

  1. Timer Sources: Generates periodic notifications. Useful for creating timers.
  2. Signal Sources: Notifies when a UNIX signal (like SIGSTOP) is delivered.
  3. Descriptor Sources: Monitors file or socket activities (e.g., data becomes available to read/write).
  4. Process Sources: Notifies about process-related events (e.g., when a process terminates).
  5. Mach Port Sources: Monitors events related to Mach ports (a low-level inter-process communication mechanism).
  6. 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

  1. Encapsulating Work: Encapsulates a unit of work that can be executed on a DispatchQueue.
  2. Cancellation: You can cancel a DispatchWorkItem before it starts executing.
  3. 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.

--

--

abdul ahad

A software developer dreaming to reach the top and also passionate about sports and language learning