Dispatch groups are a bit more advanced, but I hope the previous episodes have shown that they can be incredibly useful to manage complex tasks. The DispatchSemaphore class is another more advanced member of Apple's concurrency library. While there are some similarities between dispatch semaphores and dispatch groups, dispatch semaphores are more powerful and more versatile.
In this episode, you learn what a semaphore is and what problem it solves. We explore the DispatchSemaphore class and find out how it compares to the DispatchGroup class.
What Is a Semaphore?
Programmers have been using semaphores for many decades and they're in no way unique to Grand Central Dispatch. It's a common construct that can be found in many operating systems, frameworks, and libraries. This isn't surprising since semaphores solve a common and important problem.
What is a semaphore and what problem does it solve? Let's start with the latter question. What problem does a semaphore solve? A semaphore is designed to control or restrict access to a resource and it does this in a thread safe manner. This makes semaphores an ideal fit for multithreaded applications.
A semaphore is in essence nothing more than a variable that can be incremented and decremented in a thread safe manner. A semaphore helps synchronize processes in a multithreaded environment.
Binary and Counting Semaphores
Most developers aren't familiar or interested in semaphores and how they can be used. But, as with dispatch groups, semaphores can significantly reduce the complexity of your code if used correctly.
There are two types of semaphores, binary semaphores and counting semaphores. The main difference is the possible values the semaphore can have. As the name implies, a binary semaphore can have a value of 0 or 1. A counting semaphore doesn't have this limit. The DispatchSemaphore class encapsulates a counting semaphore. How that works becomes clear in a moment.
Before we discuss the DispatchSemaphore class, I'd like to explain the concept of a semaphore with a simple example. Every supermarket has one or more cash registers. A small supermarket may have only one cash register. It's important that only one customer at a time uses the cash register. Can you imagine what would happen if the same cash register was used by two or more customers at the same time? The bills of the customers would most likely end up being incorrect, resulting in unhappy customers. To make sure only one customer uses the cash register at a time, a staff member of the supermarket controls access to the cash register. What does this have to do with semaphores?
The cash register is a shared resource. Several objects, the customers, want access to that shared resource. To make sure only one object at a time accesses the shared resource, a semaphore, the staff member of the supermarket, controls access to the cash register. A store with only one cash register illustrates how a binary semaphore works. The shared resource is available or it isn't. The semaphore controls access to the shared resource to avoid resource contention.
If the supermarket has multiple cash registers, then multiple customers can use a cash register concurrently or at the same time. There's a single queue of customers waiting to use one of the cash registers and there's a staff member of the supermarket controlling access to the cash registers. The moment a customer uses a cash register, the staff member takes note that there's one less cash register available. The moment a customer finishes using a cash register, the staff member notes that there's a cash register available. The staff member allows the next customer in the queue to use the cash register. This example illustrates how a counting semaphore works.
Dispatch Groups and Dispatch Semaphores
Let's explore the API of the DispatchSemaphore class by refactoring the playground we used to learn about the DispatchGroup class. This is what we start with.
import Foundation
print("START")
// Create Dispatch Group
let group = DispatchGroup()
// Enter Group
group.enter()
let dataTask0 = URLSession.shared.dataTask(with: URL(string: "https://cocoacasts.com")!) { (data, _, _) in
print("DATA TASK 0 COMPLETED")
// Leave Group
group.leave()
}
// Resume Data Task
dataTask0.resume()
// Enter Group
group.enter()
let dataTask1 = URLSession.shared.dataTask(with: URL(string: "https://apple.com")!) { (data, _, _) in
print("DATA TASK 1 COMPLETED")
// Leave Group
group.leave()
}
// Resume Data Task
dataTask1.resume()
print("END 1")
// Being Notified
group.notify(queue: .main) {
print("NOTIFIED")
}
print("END 2")
We no longer instantiate an instance of the DispatchGroup class. We create an instance of the DispatchSemaphore class and assign the value to a constant with name semaphore. The initializer of the DispatchSemaphore class takes one argument of type Int. The value we pass to the initializer is the initial value of the semaphore. Remember that a semaphore is nothing more than a variable that can be incremented and decremented. We pass 0 to the initializer.
import Foundation
print("START")
// Create Dispatch Semaphore
let semaphore = DispatchSemaphore(value: 0)
We add a print statement before initializing the first data task. The print statements we add will help understand how a dispatch semaphore works and how the initial value affects its behavior.
import Foundation
print("START")
// Create Dispatch Semaphore
let semaphore = DispatchSemaphore(value: 0)
print("SCHEDULE DATA TASK 0")
Remember from the previous episodes that a dispatch group should be notified when a task is added to the dispatch group, by invoking the enter() method, and when that task has completed executing, by invoking the leave() method. The DispatchSemaphore class defines an API that is similar. Start by removing the enter() and leave() invocations.
We invoke the wait() method on the DispatchSemaphore instance to decrement the semaphore's value. You never directly access or modify the value of a semaphore. When the value of a semaphore drops below 0, the execution of the current thread is blocked. We initialized the DispatchSemaphore instance with an initial value of 0. By invoking the wait() method, the value of the semaphore drops to -1, blocking the execution of the current thread.
import Foundation
print("START")
// Create Dispatch Semaphore
let semaphore = DispatchSemaphore(value: 0)
print("SCHEDULE DATA TASK 0")
let dataTask0 = URLSession.shared.dataTask(with: URL(string: "https://cocoacasts.com")!) { (data, _, _) in
print("DATA TASK 0 COMPLETED")
}
// Resume Data Task
dataTask0.resume()
// Wait
semaphore.wait()
To increment the semaphore, we invoke the signal() method on the DispatchSemaphore instance. We execute the signal() method in the completion handler of the data task.
import Foundation
print("START")
// Create Dispatch Semaphore
let semaphore = DispatchSemaphore(value: 0)
print("SCHEDULE DATA TASK 0")
let dataTask0 = URLSession.shared.dataTask(with: URL(string: "https://cocoacasts.com")!) { (data, _, _) in
print("DATA TASK 0 COMPLETED")
// Signal Semaphore
semaphore.signal()
}
// Resume Data Task
dataTask0.resume()
// Wait
semaphore.wait()
Let's repeat these steps for the second data task. Remove the enter() and leave() invocations and add a print statement before initializing the second data task. To decrement the semaphore's value, we invoke the wait() method immediately after resuming the second data task. To increment the semaphore's value when the data task has completed executing, we invoke the signal() method in the completion handler of the data task.
print("SCHEDULE DATA TASK 1")
let dataTask1 = URLSession.shared.dataTask(with: URL(string: "https://apple.com")!) { (data, _, _) in
print("DATA TASK 1 COMPLETED")
// Signal Semaphore
semaphore.signal()
}
// Resume Data Task
dataTask1.resume()
// Wait
semaphore.wait()
Remove any references to the DispatchGroup instance and replace the print statements at the bottom with a single print statement that prints END.
import Foundation
print("START")
// Create Dispatch Semaphore
let semaphore = DispatchSemaphore(value: 0)
print("SCHEDULE DATA TASK 0")
let dataTask0 = URLSession.shared.dataTask(with: URL(string: "https://cocoacasts.com")!) { (data, _, _) in
print("DATA TASK 0 COMPLETED")
// Signal Semaphore
semaphore.signal()
}
// Resume Data Task
dataTask0.resume()
// Wait
semaphore.wait()
print("SCHEDULE DATA TASK 1")
let dataTask1 = URLSession.shared.dataTask(with: URL(string: "https://apple.com")!) { (data, _, _) in
print("DATA TASK 1 COMPLETED")
// Signal Semaphore
semaphore.signal()
}
// Resume Data Task
dataTask1.resume()
// Wait
semaphore.wait()
print("END")
Execute the contents of the playground and inspect the output in the console. The output may surprise you. Let me explain what happens.
START
SCHEDULE DATA TASK 0
DATA TASK 0 COMPLETED
SCHEDULE DATA TASK 1
DATA TASK 1 COMPLETED
END
The DispatchSemaphore instance starts with an initial value of 0. When we invoke the wait() method for the first time, the semaphore's value is decremented to -1. This means that the execution of the current thread is blocked until the semaphore's value is incremented to a value equal to or higher than 0. That happens when the signal() method is invoked in the completion handler of the first data task.
The semaphore's value is decremented to -1 when the wait() method is invoked for the second time, blocking the execution of the current thread. The execution resumes when the signal() method is invoked in the completion handler of the second data task, incrementing the semaphore's value to 0.
The current use of the semaphore forces the contents of the playground to be executed synchronously. The data tasks are no longer executed concurrently or at the same time.
Mimicking a Dispatch Group
Can we use a semaphore to mimic the behavior of a dispatch group? That is possible, but we need to be careful. There are several options and each option results in a different result.
Option 1
Set the initial value of the semaphore to 1 and execute the contents of the playground. Inspect the output in the console.
import Foundation
print("START")
// Create Dispatch Semaphore
let semaphore = DispatchSemaphore(value: 1)
START
SCHEDULE DATA TASK 0
SCHEDULE DATA TASK 1
DATA TASK 0 COMPLETED
END
DATA TASK 1 COMPLETED
Setting the initial value of the semaphore to 1 doesn't seem to work as expected. Let's find out what went wrong. The initial value of the semaphore is 1. The first wait() invocation decrements it to 0, which means that the execution of the current thread isn't blocked. The second wait() invocation decrements the semaphore's value to -1, blocking the execution of the current thread.
When the completion handler of the first or second data task is executed, the semaphore's value is incremented to 0. The moment the semaphore's value is equal to or higher than 0 the execution of the current thread resumes and the last print statement is executed. This happens before both data tasks have completed executing. This approach isn't mimicking the behavior of a dispatch group. We need to find a different solution.
Option 2
Set the initial value of the semaphore to 0 and move the first wait() invocation immediately before the second wait() invocation. We execute both wait() invocations before the last print statement. Execute the contents of the playground and inspect the output in the console.
import Foundation
print("START")
// Create Dispatch Semaphore
let semaphore = DispatchSemaphore(value: 1)
print("SCHEDULE DATA TASK 0")
let dataTask0 = URLSession.shared.dataTask(with: URL(string: "https://cocoacasts.com")!) { (data, _, _) in
print("DATA TASK 0 COMPLETED")
// Signal Semaphore
semaphore.signal()
}
// Resume Data Task
dataTask0.resume()
print("SCHEDULE DATA TASK 1")
let dataTask1 = URLSession.shared.dataTask(with: URL(string: "https://apple.com")!) { (data, _, _) in
print("DATA TASK 1 COMPLETED")
// Signal Semaphore
semaphore.signal()
}
// Resume Data Task
dataTask1.resume()
// Wait
semaphore.wait()
// Wait
semaphore.wait()
print("END")
START
SCHEDULE DATA TASK 0
SCHEDULE DATA TASK 1
DATA TASK 0 COMPLETED
DATA TASK 1 COMPLETED
END
This looks better. The semaphore is decremented twice before executing the last print statement, setting the value of the semaphore to -2. Every time the semaphore is signaled in a completion handler, the semaphore's value is incremented. The value of the semaphore reaches 0 when both data tasks have completed executing.
Dispatch Group or Dispatch Semaphore
The task of a dispatch group is clear and well defined. A dispatch group enables developers to synchronize work by grouping a set of tasks. The DispatchSemaphore class is quite different. A semaphore has a much broader use and a wider range of applications. It can be used to replace a dispatch group as illustrated by the previous example.
But should you use a semaphore instead of a dispatch group? A dispatch group is a higher level API and it is recommended to use a higher level API whenever possible. Higher level APIs are usually designed for a specific task and they execute that task well. They are often easier to use, resulting in code that is straightforward to understand and less complex.
It is possible to use a dispatch semaphore instead of a dispatch group, but the dispatch semaphore is more complex to use if you want to accomplish the same result. The result in the example was impacted by the position of the first wait() invocation. Such a subtle detail is easy to miss. The API of the DispatchGroup class is more forgiving and more intuitive if your goal is synchronizing the execution of a group of tasks.
Pitfalls
There are several pitfalls you need to watch out for when using the DispatchSemaphore class.
Don't Wait On the Main Thread
The first one isn't new and I already mentioned this pitfall in the previous episodes. Don't invoke the wait() method on the main thread. Remember that the wait() method blocks the execution of the current thread. Blocking the execution of the main thread is something you need to avoid at any cost because it renders the application unresponsive to the user.
Don't Forget to Signal
A wait() call should always be paired with a signal() call. You first decrement the semaphore's value and, when the task has completed executing, you increment the semaphore's value.
It's equally important to invoke the wait() and signal() methods at the appropriate time. Semaphores are easy to understand, but they can result in complex code. If you invoke the wait() and signal() methods too early or too late, you may cause a deadlock.
Let's take the playground we started with. If we place the first wait() call before resuming the first data task, we cause a deadlock. Let's try it out.
import Foundation
print("START")
// Create Dispatch Semaphore
let semaphore = DispatchSemaphore(value: 0)
print("SCHEDULE DATA TASK 0")
let dataTask0 = URLSession.shared.dataTask(with: URL(string: "https://cocoacasts.com")!) { (data, _, _) in
print("DATA TASK 0 COMPLETED")
DispatchQueue.main.async {
// Signal Semaphore
semaphore.signal()
}
}
// Wait
semaphore.wait()
// Resume Data Task
dataTask0.resume()
print("SCHEDULE DATA TASK 1")
let dataTask1 = URLSession.shared.dataTask(with: URL(string: "https://apple.com")!) { (data, _, _) in
print("DATA TASK 1 COMPLETED")
DispatchQueue.main.async {
// Signal Semaphore
semaphore.signal()
}
}
// Resume Data Task
dataTask1.resume()
// Wait
semaphore.wait()
print("END")
The execution of the current thread is blocked before the data task is resumed, which means that the completion handler of the data task is never invoked. The signal() call in the completion handler is never executed and the semaphore's value is never incremented to resume the execution of the current thread. This is a typical example of a deadlock.
Multiple Threads
The wait() method blocks the execution of the current thread, which implies that the signal() method needs to be invoked on a different thread. Let me illustrate this with an example. Let's invoke the signal() method on the main thread using Grand Central Dispatch. The API should look familiar.
import Foundation
print("START")
// Create Dispatch Semaphore
let semaphore = DispatchSemaphore(value: 0)
print("SCHEDULE DATA TASK 0")
let dataTask0 = URLSession.shared.dataTask(with: URL(string: "https://cocoacasts.com")!) { (data, _, _) in
print("DATA TASK 0 COMPLETED")
DispatchQueue.main.async {
// Signal Semaphore
semaphore.signal()
}
}
// Resume Data Task
dataTask0.resume()
print("SCHEDULE DATA TASK 1")
let dataTask1 = URLSession.shared.dataTask(with: URL(string: "https://apple.com")!) { (data, _, _) in
print("DATA TASK 1 COMPLETED")
DispatchQueue.main.async {
// Signal Semaphore
semaphore.signal()
}
}
// Resume Data Task
dataTask1.resume()
// Wait
semaphore.wait()
// Wait
semaphore.wait()
print("END")
Execute the contents of the playground and inspect the output in the console. Are you surprised by the output?
START
SCHEDULE DATA TASK 0
DATA TASK 0 COMPLETED
The explanation is surprisingly simple. The wait() method blocks the execution of the current thread. In the example, the wait() method is invoked on the main thread. We dispatch the signal() call to the main thread to increment the value of the semaphore. But remember that the semaphore blocks the execution of the main thread until its value is incremented. We end up with a deadlock since the signal() calls are never executed.
This example illustrates that the wait() and signal() methods should always be invoked on different threads to avoid a deadlock. The example also illustrates that semaphores can make your code complex. If your goal is synchronizing the execution of a group of tasks, then a dispatch group is most likely the better option.
What's Next?
Dispatch semaphores are powerful constructs. If used correctly, they can help guard against issues that are common in multithreaded applications, such as race conditions. A dispatch semaphore isn't as focused as a dispatch group. You can use semaphores in a range of scenarios. In the next episode, I show you a concrete implementation of a dispatch semaphore.