27 Conditional Variables Sync. Cond Part 1

27 Conditional Variables sync #

In the previous article, we mainly discussed mutex locks. Today, I will talk to you about conditional variables.

Introduction: Condition Variables and Mutex Locks #

We often discuss condition variables together with mutex locks. In fact, condition variables are based on mutex locks and require the support of mutex locks to function.

Condition variables are not used to protect critical sections and shared resources, but rather to coordinate the threads that want to access shared resources. When the state of the shared resource changes, it can be used to notify the blocked threads waiting for the mutex lock.

For example, let’s say that two people are working together on a secret mission, which needs to be carried out without direct contact or meeting. I need to place intelligence in a mailbox, and you need to retrieve the intelligence from this mailbox. The mailbox functions as a shared resource, and we are the threads performing write and read operations, respectively.

If I find that there is still intelligence undelivered in the mailbox while placing the intelligence, I will not proceed and instead return. On the other hand, if you find that there is no intelligence in the mailbox while retrieving it, you will also have to return. This corresponds to the situation where the write or read threads are blocked.

Although we both have keys to the mailbox, only one person can insert the key and open the mailbox at a time, which is the role of the lock. Moreover, since we cannot meet directly, the mailbox itself can be considered a critical section.

Even if we haven’t coordinated well, we still need to find a way to complete the mission. So, if there is intelligence in the mailbox but you haven’t retrieved it for a long time, I need to periodically check with new intelligence. If I find that the mailbox is empty, I need to promptly place the new intelligence inside.

On the other hand, if there is still no intelligence in the mailbox, you also need to open it and check periodically. Once there is intelligence, you should retrieve it immediately. This approach is acceptable, but it is too risky and easily detected by enemies.

Later, we came up with another plan and hired inconspicuous children separately. If a child wearing a red hat passes by your house at 7 o’clock in the morning, it means that there is new intelligence in the mailbox. On the other hand, if a child wearing a blue hat passes by my house at 9 o’clock in the morning, it means that you have retrieved the intelligence from the mailbox.

In this way, the stealthiness of our mission execution is greatly enhanced, and the efficiency is significantly improved. These two children wearing different colored hats are equivalent to condition variables, and they notify when the state of the shared resource changes.

Of course, we are writing programs in the Go language, not executing secret missions. Therefore, the greatest advantage of condition variables here is the improvement in efficiency. When the state of the shared resource does not meet the condition, threads that want to operate on it no longer need to repeatedly check, they just need to wait for notification.

Speaking of this, do you know how to use condition variables? So, our question for today is: How should condition variables be used in conjunction with mutex locks?

The typical answer to this question is: The initialization of a condition variable cannot be done without a mutex lock, and some of its methods are also based on mutex locks.

Condition variables provide three methods: wait for notification (wait), single notification (signal), and broadcast notification (broadcast).

When we wait for notification using the condition variable, we need to do so under the protection of the corresponding mutex lock. When performing a single notification or broadcast notification, it is the opposite: we need to unlock the corresponding mutex lock before performing these two operations.

Problem Analysis #

This problem seems simple, but it actually can lead to many other questions. For example, what is the right timing to use each method? Or, what is the internal process of each method execution?

Now, let’s implement the example code while discussing the usage of condition variables.

First, let’s create the following variables:

var mailbox uint8
var lock sync.RWMutex
sendCond := sync.NewCond(&lock)
recvCond := sync.NewCond(lock.RLocker())

The variable mailbox represents the mailbox and is of type uint8. If its value is 0, it means there is no message in the mailbox. If its value is 1, it means there is a message in the mailbox. lock is a variable of type sync.RWMutex, which is a read-write lock and can be considered as the lock on the mailbox.

In addition, based on this lock, I also created two variables representing condition variables. The names of these variables are sendCond and recvCond. They are both of type *sync.Cond and are initialized using the sync.NewCond function.

Unlike the sync.Mutex and sync.RWMutex types, the sync.Cond type is not ready to use out of the box. We can only create a pointer value of it using the sync.NewCond function. This function requires a parameter of type sync.Locker.

Do you remember? I mentioned earlier that condition variables are based on a mutex lock, and they can only work with the support of a mutex lock. Therefore, this parameter is indispensable and will be involved in the implementation of the condition variable methods.

sync.Locker is actually an interface that only includes two method definitions: Lock() and Unlock(). Both the sync.Mutex and sync.RWMutex types have Lock and Unlock methods, but they are both pointer methods. Therefore, the pointer types of these two types are the implementation types of the sync.Locker interface.

When initializing the sendCond variable, I passed the pointer value based on the lock variable to the sync.NewCond function.

The reason is that the Lock and Unlock methods of the lock variable are used to lock and unlock the write lock inside it, which corresponds to the meaning of the sendCond variable. sendCond is a condition variable specifically prepared for putting messages in the mailbox, which can be considered as a write operation on the shared resource.

Similarly, the recvCond variable represents a condition variable specifically prepared for retrieving messages. Although retrieving messages also involves changes to the state of the mailbox, luckily only you can do this, and we also need to understand the combination of condition variables and read locks. So, here, we temporarily consider retrieving messages as a read operation on the shared resource.

Therefore, to initialize the recvCond condition variable, what we need is the read lock in the lock variable, and it also needs to be of type sync.Locker.

However, the methods used to lock and unlock the read lock in the lock variable are RLock and RUnlock, which do not match the methods defined in the sync.Locker interface.

Fortunately, the RLocker method of the sync.RWMutex type can fulfill this requirement. We just need to pass the result value of the expression lock.RLocker() when calling the sync.NewCond function to make the function return the desired condition variable.

Why is it said that the value obtained through lock.RLocker() is the read lock in the lock variable? In fact, the Lock and Unlock methods that this value possesses will internally call the RLock and RUnlock methods in the lock variable, respectively. In other words, the first two methods are just proxies for the last two methods.

Now, we have four variables. One represents the mailbox (mailbox), one represents the lock on the mailbox (lock). There are also two variables representing the blue-hatted child (sendCond) and the red-hatted child (recvCond).

Mutex and Condition Variables

(Me, a goroutine (carrying the go function), want to timely put messages in the mailbox and notify you. How should I do it?)

lock.Lock()
for mailbox == 1 {
    sendCond.Wait()
}
mailbox = 1
lock.Unlock()
recvCond.Signal()

I definitely need to call the Lock method of the lock variable first. Note that in this case, the Lock method means holding the lock on the mailbox and having the right to open the mailbox, rather than locking the lock itself.

Then, I need to check if the value of the mailbox variable is equal to 1, that is, to see if there is any intelligence in the mailbox. If there is intelligence, then I will go home and wait for the boy in the blue hat.

This is the meaning represented by the for loop and the sendCond.Wait() method call within it. You may ask why it is a for loop instead of an if statement. I will explain this later.

Let’s continue. If there is no intelligence in the mailbox, then I put the new intelligence in it, close the mailbox, lock the lock, and then leave. This is expressed in code as mailbox = 1 and lock.Unlock().

After leaving, there is one more thing I need to do, which is to make sure the boy in the red hat passes by your house on time. In other words, I will notify you in a timely manner that “there is new intelligence in the mailbox”. We can achieve this by calling the Signal method of the recvCond variable.

On the other hand, you are another goroutine that wants to timely obtain intelligence from the mailbox and then notify me.

lock.RLock()
for mailbox == 0 {
    recvCond.Wait()
}
mailbox = 0
lock.RUnlock()
sendCond.Signal()

What you are doing is basically the same as what I am doing in terms of process, but the objects of each step are different. You need to call the RLock method of the lock variable because you are performing a read operation and will use the recvCond variable as an auxiliary. The recvCond corresponds to the read lock of the lock variable.

After opening the mailbox, you need to check if there is no intelligence in the mailbox, which is to check if the value of the mailbox variable is equal to 0. If it is indeed 0, then you need to go home and wait for the boy in the red hat. This is still done with a for loop.

If there is intelligence in the mailbox, then you should take away the intelligence, close the mailbox, lock the lock, and then leave. The corresponding code is mailbox = 0 and lock.RUnlock(). Afterwards, you need to make sure the boy in the blue hat passes by my house on time. This way, I will know that the intelligence in the mailbox has been obtained by you.

The above is the implementation of the code for our secret mission. Pay special attention to the usage of condition variables.

Again, as long as the condition is not satisfied, I will wait for your notification by calling the Wait method of the sendCond variable, and I will only recheck the mailbox after receiving the notification.

Also, when I need to notify you, I will call the Signal method of the recvCond variable. The way you use these two condition variables is exactly the opposite. You may have also noticed that using condition variables can achieve one-way notification, while two-way notification requires two condition variables. This is also the basic usage rule of condition variables.

You can open the demo61.go file to see the complete implementation code of the example above.

Summary #

In our two articles, we will focus on the content of condition variables. Condition variables are a synchronization tool based on mutex locks, and they must be supported by mutex locks to be effective. Condition variables can coordinate threads that want to access shared resources. When the state of a shared resource changes, it can be used to notify blocked threads by mutex locks. I gave an example of two people accessing a mailbox in the article and implemented this process with code.

Thought Question #

Can a value of type *sync.Cond be passed? What about a value of type sync.Cond?

Thanks for listening, see you next time.

Click here to view detailed code for the Go Language column article.