Locks are used for mutual exclusion. If you want a piece of code to be atomic, put a lock around it. Theoretically, you can use a binary semaphore for this, but this is a special case.
Semaphores and variable conditions are built on top of the mutual exclusion provided by locks and are used to provide synchronous access to shared resources. They can be used for similar purposes.
Typically, a condition variable is used to avoid waiting for a wait (repeating a loop when checking the status), waiting for a resource to become available. For example, if you have a thread (or several threads) that cannot continue until the queue is empty, the wait approach will simply do something like:
//pseudocode while(!queue.empty()) { sleep(1); }
The problem is that you are losing processor time when this thread re-checks the status. Why is there instead a synchronization variable that can be signaled to tell the stream that the resource is available?
//pseudocode syncVar.lock.acquire(); while(!queue.empty()) { syncVar.wait(); } //do stuff with queue syncVar.lock.release();
Presumably, you will have a thread elsewhere that pulls things from the queue. When the queue is empty, it can call syncVar.signal() to wake up a random stream that falls asleep in syncVar.wait() (or the signalAll() or broadcast() method is usually used to wake up all waiting threads).
I usually use synchronization variables such as this when I have one or more threads waiting in one particular state (for example, so that the queue is empty).
Semaphores can be used in a similar way, but I think they are better used when you have a shared resource that can be accessed and inaccessible based on some integer number of things available. Semaphores are good for producer / consumer situations where producers allocate resources and consumers consume them.
Think about whether you have a soda vending machine. There is only one soda machine, and this is a common resource. You have one stream, which is the seller (manufacturer), which is responsible for storing spare parts of the machine and N flows, which are buyers (consumers) who want to get soda from the machine. The amount of soda in the machine is the integer value that will drive our semaphore.
Each consumer (consumer) thread that enters the soda machine calls the down() semaphore method to receive the soda. This will bring the soda from the machine and reduce the number of soda available by 1. If there is soda available, the code will simply continue to work after the down() instruction without any problems. If no soda is available, the thread will sleep here, waiting for a notification when the soda will be available again (when there will be more carbonated drinks in the machine).
The supplier (manufacturer) thread will essentially wait until the soda machine is empty. The supplier is notified when the last soda is taken from the machine (and one or more consumers are potentially waiting for the soda to be released). The supplier will replenish the soda machine using the up() semaphore method, the available number of soda will increase each time, and thus, pending consumer flows will be notified that more soda is available.
The wait() and signal() methods of the synchronization variable are usually hidden in the semaphore down() and up() operations.
Of course, there is overlap between these two options. There are many scenarios in which a semaphore or condition variable (or a set of condition variables) can serve your purpose. Both semaphores and condition variables are associated with a lock object, which they use to maintain mutual exclusion, but then they provide additional functionality on top of the lock to synchronize thread execution. It is basically up to you to figure out which one makes the most sense for your situation.
This is not necessarily the most technical description, but how it makes sense in my head.