Asynchronous and Synchronous Operations: An iOS developer’s guide

Himali Marasinghe
Aeturnum
Published in
7 min readJun 26, 2024

Synchronous operations: Like standing in a line at the ice cream shop, where everybody gets served in order, but it can take a while if there’s a crowd. Asynchronous operations: Like having several ice cream windows; you could order and pay at one while others are being served, and this way, you could keep the line moving and your calm cravings satisfied!

As an iOS developer, it is essential to keep your app running correctly and with a responsive user interface. As of Swift 5.5, the async and await feature makes it super simple to work asynchronously. There is also a complete guide with best practices and pro tips regarding working with operations and the core differences between synchronous and asynchronous processes. Whether it is optimization for performance enhancement or optimization to smoothen the development workflow, this article will help you with the knowledge of handling async tasks effectively in Swift.

Synchronous Vs Asynchronous

Understanding Synchronous vs. Asynchronous Operations

When it comes to iOS development, it is critical to know what kind of operation is most appropriate for each task. What separates synchronous from asynchronous operation is very important if one wants their app to perform better and be more responsive.

Synchronous Operations

In general, synchronous operations run sequentially, blocking the main thread until they are finished. Because the sync procedure is still in progress, your application’s UI becomes unresponsive. To ensure a seamless user experience, synchronous operation can be used for short-lived operations. However, using synchronous operations for longer tasks, such as network queries or data processing, will result in a slow-moving, non-responsive app.

func performSynchronousTask() {
for i in 1...5 {
print("Synchronous task: \(i)")
// Simulate a time-consuming task
Thread.sleep(forTimeInterval: 1)
}
}
Synchronous task: 1
Synchronous task: 2
Synchronous task: 3
Synchronous task: 4
Synchronous task: 5

Keeping Your App Responsive: Asynchronous Operations in Swift

On the other hand, Asynchronous operations run concurrently and do not block the main thread. This way, your app remains responsive, running other tasks in the background. Some of the activities that can be performed using an asynchronous operation are: Downloading files or images, Upload content, and Performing lengthy running operations.

Swift features and tools help you to handle async operations well. Now let’s have a look at some examples for GCD, Combine, and the newly introduced async/await syntax of Swift 5.5.

Grand Central Dispatch (GCD)

GCD provides a powerful mechanism for handling concurrent tasks through queues (serial and concurrent) and dispatch groups. You can use it to schedule multiple asynchronous operations on different queues while ensuring the efficient use of system resources.

Example using GCD:

func downloadImageWithGCD(imageUrl: String, completion: @escaping (UIImage?, Error?) -> Void) {
DispatchQueue.global().async {
guard let url = URL(string: imageUrl) else {
completion(nil, NSError(domain: "Invalid URL", code: 1, userInfo: nil))
return
}

do {
let data = try Data(contentsOf: url)
let image = UIImage(data: data)
completion(image, nil)
} catch {
completion(nil, error)
}
}
}
// Example image URL
let imageUrl = "https://example.com/image.jpg"

// Call the function
downloadImageWithGCD(imageUrl: imageUrl) { image, error in
DispatchQueue.main.async {
if let error = error {
// Handle the error
print("Failed to download image: \(error.localizedDescription)")
} else if let image = image {
// Handle the downloaded image
print("Image downloaded successfully")
// For example, you can display the image in an UIImageView
imageView.image = image
} else {
// Handle the case where image is nil without an error
print("Image is nil, but no error occurred")
}
}
}

Combine Framework

Combine was introduced in Swift 5 as a declarative framework for handling asynchronous events using publishers and subscribers, where publishers encapsulate a data stream, and subscribers show interest in receiving that stream. This framework makes it easier to manage asynchronous events and error conditions.

Example using Combine:

import Combine
import UIKit

func downloadImageWithCombine(imageUrl: String) -> AnyPublisher<UIImage, Error> {
return URLSession.shared.dataTaskPublisher(for: URL(string: imageUrl)!)
.tryMap { data, _ in
guard let image = UIImage(data: data) else {
throw NSError(domain: "Image Conversion Error", code: 2, userInfo: nil)
}
return image
}
.eraseToAnyPublisher()
}

var cancellable: AnyCancellable?

func fetchImage() {
let imageUrl = "https://example.com/image.jpg"
cancellable = downloadImageWithCombine(imageUrl: imageUrl)
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("Image download completed.")
case .failure(let error):
print("Error downloading image: \(error)")
}
}, receiveValue: { image in
// Use the image (e.g., set it to an UIImageView)
DispatchQueue.main.async {
imageView.image = image
}
})
}

Async/Await (Swift 5.5 and above)

This type of syntactic sugar significantly improves the readability of programming with asynchronous operations and reduces error probability in code. An async labeled function declares that the operation is carried out asynchronously, whereas the await keyword pauses execution until some asynchronous operation is completed.

Example using Async/ Await:

func downloadImageWithAsyncAwait(imageUrl: String) async throws -> UIImage {
guard let url = URL(string: imageUrl) else {
throw URLError(.badURL)
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw NSError(domain: "Image Conversion Error", code: 2, userInfo: nil)
}
return image
}

func fetchImage() {
Task {
do {
let imageUrl = "https://example.com/image.png"
let image = try await downloadImageWithAsyncAwait(imageUrl: imageUrl)
// Update UI with the downloaded image
DispatchQueue.main.async {
imageView.image = image
}
} catch {
print("Failed to download image: \(error)")
}
}
}

Managing Concurrent Tasks with Task and TaskGroup

Swift’s async/await syntax provides a powerful way to write asynchronous code. But what if you need to manage multiple concurrent asynchronous tasks? That’s where Task and TaskGroup come in.

Concurrent Image Downloads:

import UIKit

func downloadImagesWithTaskGroup(imageUrls: [String]) async throws -> [UIImage] {
return try await withThrowingTaskGroup(of: UIImage.self) { group in
var images: [UIImage] = []

for imageUrl in imageUrls {
group.addTask {
guard let url = URL(string: imageUrl) else {
throw URLError(.badURL)
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw NSError(domain: "Image Conversion Error", code: 2, userInfo: nil)
}
return image
}
}

// Wait for all tasks to finish
for try await image in group {
images.append(image)
}

return images
}
}

func fetchImages() {
let imageUrls = [
"https://example.com/image1.png",
"https://example.com/image2.png",
"https://example.com/image3.png"
]

let task = Task {
do {
let images = try await downloadImagesWithTaskGroup(imageUrls: imageUrls)
// Update UI with the downloaded images
DispatchQueue.main.async {
imageView1.image = images[0]
imageView2.image = images[1]
imageView3.image = images[2]
}
} catch {
print("Failed to download images: \(error)")
}
}

// Cancel the task group if needed
task.cancel()
}

Benefits of TaskGroup:

  • Error Handling: You may centrally manage all errors that have occurred in any of the group’s tasks.
  • Cancellation: All group tasks can be simply cancelled if necessary.
  • Coordination: TaskGroup enables you to wait for all tasks to complete or to retrieve results as they become available.

Use Cases for TaskGroup:

  • Downloading multiple files concurrently.
  • Making concurrent multiple network requests.
  • Performing independent background processing tasks.

Actors in Swift: A Safe Haven for Shared State

Swift actors help manage and synchronize access to shared data in Swift, ensuring only one piece of code interacts with the data at a time. This prevents race conditions and makes writing safe, concurrent code easier.

The use of Swift actors in combination with async/await provides additional benefits beyond what async/await alone can offer.

Example of Using Actors with Async/Await:

actor ImageDownloader {
private var cachedImages: [URL: UIImage] = [:]

func downloadImage(from url: URL) async throws -> UIImage? {
if let cachedImage = cachedImages[url] {
return cachedImage
}

let (data, response) = try await URLSession.shared.data(from: url)

guard (response as? HTTPURLResponse)?.statusCode == 200 else {
return nil
}

let image = UIImage(data: data)
cachedImages[url] = image
return image
}
}

// Create an instance of ImageDownloader actor
let imageDownloader = ImageDownloader()

// Start downloading the image
Task {
do {
if let image = try await imageDownloader.downloadImage(from: someURL) {
// Update the UI on the main thread
await MainActor.run {
imageView.image = image
}
} else {
print("Failed to download image")
}
} catch {
print("Error downloading image: \(error)")
}
}

While async/await makes asynchronous programming easier to read and write, Swift actors add extra safety and simplicity by managing concurrency and protecting shared data. The key benefits are,

  • Data Safety: Prevents race conditions.
  • Simplified Concurrency: Easier async code management.
  • Clean Code: Less error-prone and more maintainable.

Each of this tool has its own way to manage the asynchronous tasks and you can choose your best that fulfill your requirements. Swift can be efficient at async by design, whether you like async/await for its simplicity, Combine for its declarative nature, GCD for its flexibility or Task and Task Group for structured concurrency, or Swift Actors for managing concurrency and protecting shared data.

Conclusion

Asynchronous and synchronous operations are the most basic building blocks for all iOS developers. With the introduction of powerful concepts, such as Async/Await and TaskGroup, Swift takes another massive step toward advanced, efficient ways of handling parallel activities. Stay one step ahead with other growing capabilities and continue building innovative and performant iOS apps!

--

--