Greetings, traveler!
We continue the cycle of our conversations about Swift Concurrency. In the previous article, we discussed the actors. In this article, we will continue to discuss them and consider one of the problems that may arise when using them.
That’s where we last left off:
The actor’s methods are placed in a queue or, more precisely, a stack. Why wouldn’t I use the word ‘queue’? Because it can cause an association with GCD’s serial queues, which is wrong. The actor’s queue is dynamic. If the method does not change the actor’s state, its execution may be postponed. In this case, another method will take its place in the execution queue.
When using await inside a method’s body, this method waits for another asynchronous method inside itself. The actor processes other requests at this time. As a result, we get a problem that causes unexpected behavior. This problem is called actor reentrancy.
Example
To better understand the problem, let’s try to model it using code. Previously, we used an example with an image downloader. Let’s expand its functionality a bit. We want to download images of two shapes: a square and a rectangle. To do this, we will create an enumeration for the shapes.
enum Shape {
case rectangular
case square
}
Next, we will create a data source to store the URLs of the images. This data source will keep a dictionary with data. For convenience, we will make it a singleton.
final class ImageDataSource {
static let shared = ImageDataSource()
var data: [Shape: URL] = [
.rectangular: URL(string: "https://picsum.photos/200/300")!,
.square: URL(string: "https://picsum.photos/200")!
]
}
By the way, if you want to learn more about the Singleton design pattern, we have an article on this topic. This article forms part of a series on Design Patterns.
Now, let’s create a class that will download images directly. This class will also be able to cache the resulting image. Therefore, if we have previously downloaded an image with the desired shape, we can retrieve it from the cache instead of downloading it again.
actor ImageManager {
private let dataSource: ImageDataSource = .shared
private var cache: [Shape: UIImage] = [:]
func loadImage(shape: Shape) async throws -> UIImage {
// 1. Checking if the image is already in the cache.
if let image = cache[shape] {
print("Image from the cache")
return image
}
// 2. Creating a URLRequest and use the URLSession to upload an image from a specified URL.
let url = dataSource.data[shape]!
let request = URLRequest(url: url)
// Note, that "await" is also used here.
let (data, _) = try await URLSession.shared.data(for: request)
let image = UIImage(data: data) ?? .init()
// 3. Saving the image to the cache.
cache[shape] = image
print("Image from the Internet")
return image
}
}
Now, we can use our loader.
final class Client {
private let imageManager = ImageManager()
func downloadImages() {
Task {
do {
let image1 = try await imageManager.loadImage(shape: .rectangular)
let image2 = try await imageManager.loadImage(shape: .rectangular)
}
}
}
}
Let’s take a closer look at the details of the process. We have combined two upload operations into a single task. In this task, we uploaded the images sequentially, meaning that the download of the first image happened first, and only after that did the download of the second image begin.
With such an approach, the console will say:
Image from the internet
Image from the cache
In a previous article, we discussed several methods that can be processed asynchronously within a single task. This can be achieved with async let usage.
So, let’s use it to run our methods asynchronously.
final class Client {
let imageManager = ImageManager()
func downloadImages() {
Task {
do {
async let image1 = imageManager.loadImage(shape: .rectangular)
async let image2 = imageManager.loadImage(shape: .rectangular)
try await image1
try await image2
} catch {
print(error)
}
}
}
}
And now, let’s look at the console.
Image from the Internet
Image from the Internet
The result was different from what we would like to see.
As discussed earlier, this happened because each method already has an asynchronous task. In our case, this is the URLSession’s data(for: ) method. Since we performed all tasks asynchronously, the actor did not wait for the download of the first image to be completed and immediately started downloading the second one.
We can perform the following maneuver to achieve the expected result of the procedure.
Create an enumeration called ImageDownloadState. It must contain two cases. Let’s take a closer look at them:
- The first case is called .inProgress. It must have an associated value with the Task<UIImage, Error> type.
- The second case is called .completed. It must have an associated value with the UIImage type.
enum ImageDownloadState {
case inProgress(Task<UIImage, Error>)
case completed(UIImage)
}
Now, let’s use it with our cache dictionary.
actor ImageManager {
private var cache: [Shape: ImageDownloadState] = [:]
...
When trying to upload an image, we will check our dictionary, as before. But now, it will have a slightly different algorithm.
There can be two different cases.
The first case is when we have a value in our dictionary. Since then, we have two scenarios:
- The state is .inProgress, so we must wait for a response and return it afterward.
- The state is .completed, so we can return the desired image.
The second case is when we don’t have any state in our dictionary for the desired image shape. Since then, we must do the following:
- Create a new task for uploading an image.
- Update our dictionary with a new key-value pair. The value will be a .inProgress state with this task as its associated value.
- After downloading an image, we must change the state of the value in the dictionary with the corresponding key to .completed.
Now, let’s write a code.
actor ImageManager {
private let dataSource: ImageDataSource = .shared
private var cache: [Shape: ImageDownloadState] = [:]
func loadImage(shape: Shape) async throws -> UIImage {
// 1. We check if we have the data in the cache.
if let state = cache[shape] {
switch state {
// 1a. If the state is '.inProgress', we should await its result.
case let .inProgress(task):
print("Wait for it...")
return try await task.value
// 1b. If the state is '.completed', we can return the value.
case let .completed(data):
print("Completed")
return data
}
}
// 2. Creating an asynchronous request. The task will begin its execution immediately.
let task = Task<UIImage, Error> {
print("Downloading...")
let url = dataSource.data[shape]!
let request = URLRequest(url: url)
let (data, _) = try await URLSession.shared.data(for: request)
let image = UIImage(data: data) ?? .init()
return image
}
print("In Progress")
cache[shape] = .inProgress(task)
let image = try await task.value
print("Completed")
cache[shape] = .completed(image)
return image
}
}
Execute it and take a look in the console.
In Progress
Wait for it...
Downloading...
Completed
Conclusion
With this approach, several of the same requests will not be created. Only one request will be made, and its result will be expected by other asynchronous methods.
Alright then. Let’s continue exploring the Swift Concurrency series. The following article will be devoted to global factors.