The Beauty of Semaphores in Swift

In this story, we will do the following:
  • Understand what Semaphores are
  • Understand how Semaphores work
  • Implement and explain 2 examples

Let’s Start

Semaphores gives us the ability to control access to a shared resource by multiple threads. For an easy start, let’s consider the following real-life scenario:
A father sits with his three kids at home, then he pulls out an iPad…
Kid 2: I want to play with the iPad!!! 
Kid 1: NO!, I want to play first…
Kid 3: Ipad! Ipad! Ipad! *sound of claps*Father: Ok, Kid 2, since you asked first and no one is currently using the iPad, take it, but let me know once you are done. Rest of kids, please wait patiently.
Kid 2: (5 min later) I’m done father.
Father: Kid 1, the iPad is available, let me know once you are done.
Kid 1: (5 min later) I’m done father.
Father: Kid 3, the iPad is available, let me know once you are done.
Kid 3: (5 min later) I’m done father.
In the scenario above, the father is the semaphore, the iPad is the shared resource, and the kids are the threads. Note how the father make sure that only one kid use the iPad at a time. If we compare this to programming, only one thread has access to a shared resource at a time. In addition, note the order of use, the first who asked is the first who get (FIFO).
Tip: a shared resource can represent a variable, or a job such as downloading an image from url, reading from a database, etc.
What if the father just gave the iPad to the kids? A flight would build up to the point of a probably broken iPad 😖. If we compare this to programming, multiple threads try to access the same resource at the same time and nothing is preventing it. Such behavior could lead to race conditions, crashes, and obviously, our code won’t be thread safe.
Thread safe: code that can be safely called from multiple threads without causing any issues.

A Bit of Theory

A semaphore consist of a threads queue and a counter value (type Int).
Threads queue is used by the semaphore to keep track on waiting threads in FIFO order (The first thread entered to the queue will be the first to get access to the shared resource once it is available).
Counter value is used by the semaphore to decide if a thread should get access to a shared resource or not. The counter value changes when we call signal() or wait() functions.
So, when should we call wait() and signal() functions?
  • Call wait() each time before using the shared resource. We are basically asking the semaphore if the shared resource is available or not. If not, we will wait.
  • Call signal() each time after using the shared resource. We are basically signaling the semaphore that we are done interacting with the shared resource.
Calling wait() will do the following:
  • Decrement semaphore counter by 1.
  • If the resulting value is less than zero, thread is freezed.
  • If the resulting value is equal or bigger than zero, code will get executed without waiting.
Calling signal() will do the following:
  • Increment semaphore counter by 1.
  • If the previous value was less than zero, this function wakes the oldest thread currently waiting in the thread queue.
  • If the previous value is equal or bigger than zero, it means thread queue is empty, aka, no one is waiting.
Flow chart
Hmmm… I know, I know, this is getting confusing, no worries, let’s jump into the code and things will get clearer.

Example 1

In order to be consistent, this example will be based on the kids-iPad story.
First, let’s create a semaphore instance:
let semaphore = DispatchSemaphore(value: 1)
DispatchSemaphore init function has one parameter called “value”. This is the counter value which represents the amount of threads we want to allow access to a shared resource at a given moment. In this case, we want to allow only one thread (kid) to access the shared resource (iPad), so let’s set it to 1.
Next, let’s create 3 global queues, each one represent a kid. Each kid will do the following: wait() → play iPad → signal()
DispatchQueue.global().async {
   print("Kid 1 - wait")
   semaphore.wait()
   print("Kid 1 - wait finished")
   sleep(1) // Kid 1 playing with iPad
   semaphore.signal()
   print("Kid 1 - done with iPad")
}DispatchQueue.global().async {
   print("Kid 2 - wait")
   semaphore.wait()
   print("Kid 2 - wait finished")
   sleep(1) // Kid 1 playing with iPad
   semaphore.signal()
   print("Kid 2 - done with iPad")
}DispatchQueue.global().async {
   print("Kid 3 - wait")
   semaphore.wait()
   print("Kid 3 - wait finished")
   sleep(1) // Kid 1 playing with iPad
   semaphore.signal()
   print("Kid 3 - done with iPad")
}
Console:
As we can see, all three kids starts with wait(). Since kid 1 was the first, at that point the shared resource (iPad) was available. Once kid 1 finished playing, kid 2 got awaken and began playing, and so on.
Let’s track the semaphore counter for a better understanding:
  • 1 (our initial value)
  • 0 (kid 1 wait, since value >= 0, kid 1 can play the iPad)
  • -1 (kid 2 wait, since value < 0, it enters threads queue)
  • -2 (kid 3 wait, since value < 0, it enters thread queue)
  • -1 (kid 1 signal, last value < 0, wake up kid 2 and pop it from queue)
  • 0 (kid 2 signal, last value < 0, wake up kid 3 and pop it from queue)
  • 1 (kid 3 signal, last value >= 0, no threads are waiting to be awaken)

Example 2

Now that we understand how semaphores work, let’s go over a scenario that is more realistic for an app, and it is, downloading 15 songs from a url.
First we create a concurrent queue that will be used for executing our song downloading blocks of code.
Second, we create a semaphore and we set it with initial counter value of 3, can you guess why? 🤭 well, we decided to download 3 songs at a time in order not to take too much CPU time at once.
Third, we iterate 15 times using a for loop. On each iteration we do the following: wait() → download song → signal()
let queue = DispatchQueue(label: "com.gcd.myQueue", attributes: .concurrent)
let semaphore = DispatchSemaphore(value: 3)for i in 0 ..> 15 {
   queue.async {
      let songNumber = i + 1
      semaphore.wait()
      print("Downloading song", songNumber)
      sleep(2) // Download take ~2 sec each
      print("Downloaded song", songNumber)
      semaphore.signal()
   }
}
Console:
Let’s track the semaphore counter for a better understanding:
  • 3 (our initial value)
  • 2 (song 1 wait, since value >= 0, start song download)
  • 1 (song 2 wait, since value >= 0, start song download)
  • 0 (song 3 wait, since value >= 0, start song download)
  • -1 (song 4 wait, since value < 0, add to queue)
  • -2 (song 5 wait, since value < 0, add to queue)
  • Repeats for all songs, it will take us to counter value of -12
  • -12 (song 15 wait, sing value < 0, add to queue)
  • -11 (song 1 signal, since last value < 0, wake first song in queue)
  • -10 (song 2 signal, since last value < 0, wake first song in queue)
  • You can continue this yourself in order to be sure you got the idea…

Tips

  • 🚧 NEVER run semaphore wait() function on main thread as it will freeze your app.
  • Wait() function allows us to specify a timeout. Once timeout reached, wait will finish regardless semaphore count value.

Conclusion

If you got here, it means you survived my tutorial, well done! I know it was a bit long, it is important not to only understand what semaphores are, but also how they work, and how to work with them. Using semaphores we can be sure in 100% that a shared resource will be used by ONLY 1 (or more) threads at a given moment — LOVELY.

signal() 😉


Comments

Popular Posts