Concurrency in iOS. GCD. Readers–Writers Problem

Sasha Terentev
11 min readFeb 6, 2024

--

Mobile devices are continuously evolving, gaining more computational power with each passing day. Despite these advancements, challenges related to concurrency and asynchronicity persist in our codebase. In this article, I aim to illuminate essential aspects concerning asynchronicity and concurrency in mobile client development.

In subsequent articles, I plan to delve into the following topics:

  • Async data source.
  • Compromises.
  • Asynchronous layout.
  • Asynchronous layout calculation.
  • Async rendering.

Async Data Source.

Async and Declarative Layouts. Async Rendering.

As a starting point, I’ve opted to address the Readers-Writers problem, as it is, from my point of view, the most frequent challenge encountered in concurrent code. In this article, we will not only discuss this problem but also propose a solution for it based on Grand central dispatch (GCD).

Why do we need this?

Single-core performance limitations

The performance of a single core is bound by physical constraints like the speed of light and transistor size and some other. Consequently, it’s more cost-effective to augment CPU capabilities by incorporating additional cores, rather than attempting to extract more power from a single core.

Heavy operations

As developers, we frequently encounter “heavy” operations, which are time-consuming and often involve blocking:

  • Writing to the persistent storage.
  • Awaiting of data from Network.
  • Processing media signals (such as photo, video, or audio).
  • Handling large arrays of data.
  • Executing machine learning algorithms.
  • And more.

Many of these tasks and algorithms can be effectively parallelized or processed in a non-blocking manner.

Consequently, when a solution can be divided into multiple tasks or parallelized, executing these tasks in parallel results in significantly faster completion times compared to sequential execution one after another.

UI Rendering performance on iOS

You may have observed that certain animations run smoothly even on older iOS devices.

Apple’s engineers achieved this by optimizing the rendering process. They accomplished this by parallelizing and segregating each frame preparation task between our app’s process and separate Render server. This innovative approach allows some animations, like the Activity indicator, to continue running even if the app process is paused:

Additionally, it’s worth noting that the UIKit layout cycle is asynchronous. This characteristic is a contributing factor to why certain issues can be mitigated by doing this:

I have described the UIKit layout cycle and the rendering pipeline here.

Swift structured concurrency and Actors in Swift

Throughout this article, we delve into issues that may happen to Swift structured concurrency and Swift Actors as well.

The inconsistency problems, which will be described in the `Asynchronous data source` article hold relevance for Swift Actors due to considerations around Reentrancy.

In the realm of Swift structured concurrency, it’s essential to acknowledge the insights of engineers, such as Mike Ash from the Swift runtime team. As suggested (time: since 19.40), it may be prudent to move resource-intensive operations and eliminate any lock usages from the context of structured concurrency execution. This is imperative due to the relatively strict limitations on the number of threads utilized by structured concurrency. Solutions like Grand Central Dispatch or other alternatives may become necessary to optimize and ensure the efficient functioning of Swift structured concurrency.

Concurrent computing problems.

To begin our exploration, let’s start by revisiting some fundamental definitions.

As we progress through this article, we will introduce additional terms as needed, but rest assured, the terminology will remain concise and limited to the essentials.

Multithreading

Multithreading refers to an application’s capacity to run on multiple threads without a predetermined sequence or order of execution.

A Thread can be defined as an execution entity that encompasses a series of instructions and a context. Threads are fundamental components of concurrent computing, allowing different parts of a program to execute concurrently and potentially in parallel, improving overall performance and responsiveness.

Async actions

Async actions pertain to operations carried out in a non-blocking manner, enabling a thread, including the program’s main thread, to proceed with its execution.

Asynchronicity can be achieved even when working within a single thread.

We use async approaches for several purposes, including:

  • Delaying Execution: To postpone the execution of operations until a specific condition is met or a particular time has elapsed.
  • Minimizing Waiting (Blocking) Time: To prevent operations from halting the progress of the program while waiting for external events, such as I/O operations or network requests.
  • Scheduling and Batch Processing: To efficiently manage and schedule delayed operations, optimizing resource utilization and overall system responsiveness.

Classical Problems

Issues associated with async or concurrent code can be categorized as follows.

Algorithmic

Algorithmic problems encompass a wide range of operations performed on extensive datasets. Examples of such operations include:

  • matrix multiplication;
  • big data processing.

Synchronization

I believe that synchronization represents a pivotal category of challenges that emerge when developing concurrent code. These issues all revolve around the shared access to resources or memory by various threads or processes.

For instance, the “Dining philosophers” problem serves as a compelling illustration of concurrent access and mutual blocking. It highlights the intricacies of managing resources when multiple entities are vying for access.

Similarly, the “Producer-Consumer” problem provides a model for communication between concurrent entities using a limited shared clipboard or buffer.

I recommend delving into these problems in more detail to gain a comprehensive understanding. However, for the remainder of this article, our primary focus will be on the “Readers–writers” problem, which will be our central synchronization challenge.

Readers–writers problem

Let’s delve into a specific scenario.

Imagine that we’re in the process of developing a UI application, featuring a social component — a feed of posts contributed by our users. Our objective is to maintain a record of the items already displayed to users, ensuring that they are not presented again.

We can visualize this as follows:

In this context, we are dealing with a set of essential characteristics:

  • Shared Resource: We are operating with a resource that is shared among multiple threads concurrently.
  • Multithreaded Access: The resource is accessed by multiple threads simultaneously, potentially leading to concurrency issues.
  • Reading and Writing Operations: Both reading and writing operations are possible on this shared resource.

It’s worth noting that the priority of access to the resource is contingent on specific business logic conditions. We won’t delve into scenarios where reading or writing operations must be synchronous.

Urgent remark. This problem can be generalized and adapted to various types of resources, making it versatile and scalable.

To proceed further, let’s clarify some terms.

Critical section

Critical section refers to a segment of code that interacts with a shared resource, such as a data structure or device, and should not be accessed simultaneously by multiple threads of execution.

When two or more threads find themselves within their critical sections concurrently, it results in what is known as a “Race condition”. In the event of a Race condition, the final state of the shared resource after all critical sections have completed is undefined.

In the context of our example, the critical section pertains to the access, whether it’s reading or writing, of shared resource memory, where parallel alterations can potentially occur.

The simplest solution

One of the most straightforward approaches to addressing this problem, aside from ignoring it entirely, is to ensure exclusive execution of any operation that accesses the shared resource. This exclusivity can be achieved through several means:

  • Mutex (Mutual Exclusion) Lock: Implementing a mutex lock ensures that only one thread can access the critical section at a time, preventing concurrent access and potential race conditions.
  • Objective-C’s @synchronized: In Objective-C, you can use the @synchronized directive providing the same synchronization token for each critical section. It ensures that only one thread can execute the synchronized block at a time.
  • Swift Actors: Swift’s actor model grants exclusive access to any operation within an actor, as actors are single-threaded by design. This naturally prevents concurrent access to shared resource’s memory.

It’s essential to reiterate that all the mentioned approaches ensure locks for memory access, but they do not guarantee logical consistency of the shared data. This aspect will be thoroughly examined in the upcoming “Async Data Source” article.

More effective solution

In reality, exclusive access is not required for reading operations, as multiple threads can technically read the resource concurrently without causing issues.

The critical restriction we need is exclusive locking for write (change) operations. In other words, when someone is in the process of changing the resource, no one else should be able to read or modify it simultaneously.

There are various tools available to parallelize execution:

  • pthread: The POSIX Threads library, which provides a standard interface for creating and managing threads.
  • NSThread: A class in Objective-C (Thread in Swift) that represents a thread of execution and can be used for concurrent programming.

For synchronization purposes, you can employ mechanisms such as:

  • Semaphore: A synchronization primitive that controls access to a resource, allowing a specified number of threads to access it concurrently.
  • Mutex: Mutual exclusion locks guarantee that only one thread can access a critical section at any given time. In a simplified analogy, they can be seen as single-location semaphores.
  • Monitor: A synchronization construct that encapsulates a resource and provides methods for thread-safe access.

For a combination of parallel execution and synchronization, you can use:

  • NSOperationQueue: A high-level abstraction in iOS for managing the execution of tasks as operations in a queue.

But I’d prefer to explore a tool that is, I suppose, more commonly used in iOS Development.

Grand Central Dispatch

The fundamental concept of GCD is the execution Queue, which can be categorized as either serial or concurrent.

In a serial queue, each operation is executed sequentially, waiting for the previous one to complete before starting the next:

In a concurrent queue (also known as a parallel queue), multiple operations can be executed simultaneously, without the need to wait for the previous one to finish:

Upon hearing the word “Queue,” one might naturally expect first-in-first-out (FIFO) semantics from the mechanism. This expectation holds true in the case of serial queues for both the beginning and ending of operations. In the case of concurrent queues, FIFO semantics are typically maintained for operation initiation, ensuring that operations start in the order they were added to the queue.

Note: FIFO (first-in-first-out) semantics do not apply to the order of operation completion in concurrent queues.

Operations can be added to a queue in two ways:

async: The calling code is not waiting for the operation to complete and continues executing other tasks in parallel.

sync: The calling code is blocked, waiting for the operation to complete before it can proceed further.

In the case of synchronously adding operations, deadlocks can occur if two queues are waiting for each other to complete their respective tasks:

Or:

Priorities (QOS)

Each queue and operation is assigned a priority known as Quality of service (QOS), and there are mechanisms in place to handle priority inversion. However, this topic falls outside the scope of my current article. If you’re interested, I encourage you to explore it further on your own.

GCD exercise

As an exercise, we can leverage Grand Central Dispatch (GCD) to offload resource-intensive operations, such as deallocating memory-consuming objects, from the UI thread.

To comprehend how we can control the deallocation thread, it’s essential to revisit memory management fundamentals on iOS.

Memory management in both Objective-C and Swift relies on Automatic Reference Counting (ARC).

Each object maintains its own reference count, which is altered through retain (increment) and release (decrement) operations automatically added by the compiler. When the reference count of an object reaches 0, it is deallocated. However, there is an exception to this rule: Autorelease pools (Autoreleasepool).

When an object placed in an Autorelease pool has a reference count of zero, it is not immediately destroyed. Instead, it is only deallocated once the pool is drained, provided that the reference count has not been increased for this moment.

So, to move the deallocation of an object to another thread, all we need to do is transfer the last strong reference to the object to that thread:

// Main Thread
var object = HeavyDeinitObject()

DispatchQueue.global(qos: .background).async {
print(object)
}

The callstack:

Preventing App Suspension During Background Operations

It’s crucial to emphasize an important aspect when moving work to a background thread. The system is unaware of the tasks we’ve shifted away from the main (UI) thread. Consequently, if the app becomes inactive, the system may promptly suspend it.

To ensure that your background task is not suspended prematurely, and you want to guarantee its completion, it’s essential to notify the system using the following API:

func saveSomeInfoToDisk() {
// to avoid the app suspension and termination during writing
let taskID = UIApplication.shared.beginBackgroundTask()
DispatchQueue.global(qos: .utility).async {
// write the info to the disk

UIApplication.shared.endBackgroundTask(taskID)
}
}

Returning to the problem

Let’s revisit the problem we are currently addressing: the shared resource for which we require the following guarantees:

  • Unlimited parallel reading access.
  • Exclusive locking for writing operations.

To accomplish this, GCD provides us with a convenient solution through the use of a Barrier lock for writing operations:

Finally, here is the solution code for our example:

class ViewedPosts {
typealias PostID = String
private let queue = DispatchQueue(label: "com.ViewedPosts", qos: .userInitiated, attributes: .concurrent)
private var items = Set<PostID>()

func add(item: PostID) {
queue.async(flags: .barrier) { // Barrier!
self.items.update(with: item)
}
}

func getItems(_ block: @escaping (Set<PostID>) -> Void) {
queue.async {
block(self.items)
}
}
}

You may notice that we are using a Barrier lock for updating the set of viewed items.

Indeed, another critical aspect worth emphasizing is the immutability of items in the public interface of the ViewedPosts class. As previously mentioned, the reasons behind this choice and the potential problems, including inconsistencies, that may arise if this rule is violated, will be discussed in the upcoming article.

Conclusion

In conclusion, we have provided a brief overview of concepts related to concurrency and presented a solution for the Readers-Writers problem based on the GCD mechanism.

To summarize the key takeaways:

  • Heavy operations can be efficiently moved to a different queue, improving the responsiveness of the main (UI) thread.
  • Even object deallocation can be offloaded to a separate thread, enhancing the overall performance of the application.
  • The use of a barrier lock is a powerful technique for managing shared resources, allowing for exclusive access during write operations.
  • The approach is not limited to a specific task and can be applied to various scenarios, including working with CoreData.

In upcoming articles, we will delve into more concepts, including asynchronous declarative UI and its data sources, to further enhance our understanding of iOS development.

--

--