26 Sync. Mutex and Sync. Rwmutex

26 sync #

In the previous 20+ articles, I have provided a detailed analysis of some aspects of the Go language itself, including basic concepts, important syntax, advanced data types, special statements, testing strategies, and more.

These are the most core technologies provided by the Go language. I believe that this is enough for you to have a fairly deep understanding of the Go language.

Starting from this article, we will explore some core packages in the built-in standard library of the Go language together. This will involve the standard usage of these packages, prohibited usage, underlying principles, and peripheral knowledge.


Since Go language is a language that boasts a unique concurrent programming model, let’s start by learning about the code packages most closely related to concurrent programming.

Introduction: Race Conditions, Critical Sections, and Synchronization Tools #

Let’s start with the sync package. “sync” in Chinese means “同步” (synchronization). Let’s begin with the topic of synchronization.

Compared to the Go language’s emphasis on “sharing data by communication,” the more mainstream approach in concurrent programming is to share data through shared memory. After all, most modern programming languages use the latter approach as their solution for concurrent programming (this approach has a long history and can be traced back to the era of multi-process programming in the last century).

Once data is shared by multiple threads, contention and conflicts are likely to occur. This situation is also known as a race condition. It often breaks the consistency of shared data.

Consistency of shared data means a certain agreement, namely that multiple threads’ operations on shared data always achieve their respective expected effects.

If this consistency is not guaranteed, it may affect the correct execution of code and flow in some threads, and even cause unpredictable errors. Such errors are generally difficult to detect and locate, and the cost of troubleshooting is also very high, so they should be avoided as much as possible.

For example, if multiple threads simultaneously write data blocks to the same buffer without a mechanism to coordinate their write operations, the written data blocks are likely to be mixed up. For instance, while thread A has not finished writing one data block, thread B starts writing another data block.

Clearly, the data in these two blocks will be mixed together and it will be difficult to distinguish them. Therefore, in this case, some measures need to be taken to coordinate their modifications to the buffer. This usually involves synchronization.

In summary, synchronization has two purposes: to prevent multiple threads from operating on the same data block at the same time, and to coordinate multiple threads to avoid executing the same code block at the same time.

Since both data blocks and code blocks imply one or more resources (such as storage resources, computational resources, I/O resources, network resources, etc.), we can consider them as shared resources or representatives of shared resources. The synchronization we talk about is actually about controlling multiple threads’ access to shared resources.

When a thread wants to access a certain shared resource, it needs to request access permission to that resource, and only after successfully obtaining the permission can the access truly begin.

And when the thread finishes accessing the shared resource, it must return the access permission for that resource, and if it wants to access it again, it needs to request permission again.

You can imagine the access permission mentioned here as a token. Once a thread obtains the token, it can enter the designated area and access the resource. Once the thread wants to leave this area, it needs to return the token, and it must not take the token away.

If there is only one access token for a shared resource, then at the same time, at most one thread can enter that area and access that resource.

At this point, we can say that the access to this shared resource by multiple concurrently running threads is completely serial. Any code snippet that needs to achieve serialized access to shared resources can be regarded as a critical section, which is the area that must be entered to access the resource as I just mentioned.

For example, in the example I mentioned earlier, the code that implements the data block writing operation together forms a critical section. If there are multiple such code snippets for the same shared resource, they can be called related critical sections.

They can be a structure containing shared data and its methods, or multiple functions operating on the same shared data. Critical sections always need to be protected, otherwise race conditions will occur. One of the important means of protection is to use synchronization tools that implement certain synchronization mechanisms, also known as synchronization tools.

(Race Conditions, Critical Sections, and Synchronization Tools)

In Go, there are not many synchronization tools available for us to choose from. Among them, the most important and commonly used synchronization tool is the Mutex (mutual exclusion). The Mutex in the sync package is the corresponding type. A value of this type can be called a mutex or a lock.

A mutex can be used to protect a critical section or a group of related critical sections. With it, we can ensure that only one goroutine is inside the critical section at any given time.

To fulfill this guarantee, every time a goroutine wants to enter the critical section, it needs to lock it, and each goroutine must unlock it promptly when leaving the critical section.

Locking can be achieved by calling the Lock method of the mutex, and unlocking can be done by calling the Unlock method. The following is a simplified snippet of the key code in the demo58.go file:

mu.Lock()
_, err := writer.Write([]byte(data))
if err != nil {
 log.Printf("error: %s [%d]", err, id)
}
mu.Unlock()

You might have noticed that the mutex here is equivalent to the access token we mentioned earlier. So, how can we use this access token well? Please see the following question.

Today’s question is: What are the considerations when using a mutex?

Here is a typical answer.

Considerations when using a mutex are as follows:

  1. Do not lock a mutex repeatedly.
  2. Do not forget to unlock the mutex, and use the defer statement if necessary.
  3. Do not unlock a mutex that has not been locked or has already been unlocked.
  4. Do not pass a mutex directly between multiple functions.

Problem Analysis #

First of all, you should think of a mutex as the exclusive access token for a critical section or a group of related critical sections.

Although there are no mandatory rules to restrict you from using the same mutex to protect multiple unrelated critical sections, doing so will definitely make your program more complex and increase your cognitive burden.

You should know that locking a mutex that is already locked will immediately block the current goroutine. The execution flow of this goroutine will be stuck on the line of code that calls the Lock method of the mutex.

The subsequent code (i.e., the code inside the critical section) will only start executing once the Unlock method of the mutex is called and the locking operation successfully completes. This is the reason why a mutex can protect a critical section.

Once you use a mutex in multiple places at the same time, there will inevitably be more goroutines contending for this mutex. This will not only slow down your program but also greatly increase the possibility of deadlock.

Deadlock refers to a situation where the main goroutine of the program and the user-level goroutines we have launched are all blocked. These goroutines can be collectively referred to as user-level goroutines. It’s like the entire program has come to a standstill.

The Go runtime does not allow this situation to occur. As long as it detects that all user-level goroutines are in a waiting state, it will throw a panic with the following message:

fatal error: all goroutines are asleep - deadlock!

Note that these panics thrown by the Go runtime are fatal errors and cannot be recovered. Calling the recover function has no effect on them. In other words, once a deadlock occurs, the program will inevitably crash.

Therefore, we must try our best to avoid this situation. The simplest and most effective way is to let each mutex only protect a single critical section or a group of related critical sections.

Under this premise, we also need to be aware that for the same goroutine, we should neither lock the same mutex repeatedly nor forget to unlock it.

Locking the same mutex multiple times by a goroutine means that it locks itself. Apart from the fact that this practice itself is wrong, it is very difficult to guarantee the correctness of letting other goroutines unlock it in this case.

I have seen this kind of code in the team’s code repository before. The author’s intention was to let a goroutine lock itself and then let a scheduling goroutine unlock the mutex regularly. This way, the former goroutine can periodically perform some tasks, such as checking the server status every minute or cleaning up the log files every day.

There is nothing wrong with this idea itself, but the implementation tool was chosen incorrectly. For mutexes, which require fine-grained control as synchronization tools, such tasks are not suitable for them.

In this case, even choosing channels or the time.Ticker type and implementing the functionality by ourselves would be feasible. The complexity of the program and our cognitive burden would be much smaller, not to mention the fact that there are many well-established solutions to choose from. On the other hand, one important reason why we say “don’t forget to unlock the mutex” is to avoid duplicate locking.

During the execution of a goroutine, there may be operations such as “lock, unlock, lock again, unlock again”. If we forget to unlock in the middle, we will definitely cause duplicate locking.

Moreover, forgetting to unlock can also prevent other goroutines from entering the critical section protected by the mutex. This can result in the malfunction of some program functionalities at best, and at worst, it can cause deadlock and program crashes.

In many cases, the execution flow of a function is not linear, it may have branches or interruptions.

If a flow forks after locking a mutex or if there is a possibility of interruption, then we should use the defer statement to unlock it, and this defer statement should come immediately after the locking operation. This is the safest practice.

The problems caused by forgetting to unlock are sometimes subtle and may not be exposed immediately. That’s why we need to pay special attention to it. In comparison, unlocking an unlocked mutex immediately triggers a panic.

And just like panic caused by deadlock, they cannot be recovered from. Therefore, we should always ensure that for every locking operation, there is one and only one corresponding unlocking operation.

In other words, we should make them appear in pairs. This is also an important principle of using mutexes. In many cases, using the defer statement to unlock can make it easier to achieve this.

Mutex duplicate locking and unlocking

(Mutex duplicate locking and unlocking)

Finally, you may already know that mutexes in Go are ready to use. In other words, once we declare a variable of type sync.Mutex, we can use it directly.

However, note that this type is a struct type and belongs to the value types. Passing it to a function, returning it from a function, assigning it to other variables, or sending it to a channel will cause a copy of it.

And the original value, its copies, and multiple copies are completely independent, they are different mutexes.

If you pass a mutex as a parameter value to a function, all operations on the passed-in lock in this function will not have any impact on the original lock existing outside the function.

So, before you do this, you must consider whether this is the result you want. I think in most cases it shouldn’t be. Even if you really want to use another mutex in this function, don’t do it like this, mainly to avoid ambiguity.

These are what I want to tell you about mutex locking, unlocking, and passing. They also include some of my understandings. I hope it can be useful to you. I have written some related examples in the demo59.go file, you can read it and run it for reference.

Knowledge Expansion #

Question 1: What are the similarities and differences between read-write locks and mutex locks?

A read-write lock, also known as a reader-writer mutex, is a type of lock that is used to protect shared resources. In Go language, a read-write lock is represented by the sync.RWMutex type. Similar to the sync.Mutex type, the sync.RWMutex type is also ready to use.

As the name suggests, a read-write lock differentiates between “read operations” and “write operations” on the shared resource. It provides different degrees of protection for these two types of operations. In other words, compared to a mutex lock, a read-write lock allows for more nuanced access control.

A read-write lock actually consists of two locks: a read lock and a write lock. The Lock and Unlock methods of the sync.RWMutex type are used to lock and unlock the write lock, while the RLock and RUnlock methods are used to lock and unlock the read lock.

Additionally, the following rules apply to the same read-write lock:

  1. Attempting to lock the write lock when it is already locked will block the current goroutine.
  2. Attempting to lock the read lock when the write lock is already locked will also block the current goroutine.
  3. Attempting to lock the write lock when the read lock is already locked will also block the current goroutine.
  4. Attempting to lock the read lock when it is already locked will not block the current goroutine.

From another perspective, for a shared resource protected by a read-write lock, multiple write operations cannot be performed simultaneously, and neither can write and read operations occur simultaneously. However, multiple read operations can occur simultaneously.

Of course, we can only achieve this effect if we use read-write locks correctly. As always, we need to ensure that each lock only protects one critical section or a set of related critical sections, in order to minimize the possibility of misuse. By the way, operations that cannot be performed simultaneously are often referred to as mutually exclusive operations.

Now let’s consider another aspect. Unlocking the write lock will wake up “all the goroutines blocked because they are trying to lock the read lock,” and this usually results in all of them successfully acquiring the read lock.

However, unlocking the read lock will only wake up “the goroutine blocked because it is trying to lock the write lock,” only if no other read lock is locked. Eventually, only one of the awakened goroutines can successfully acquire the write lock, while the others continue to wait in their original positions. The decision of which goroutine is determined by its waiting time.

In addition, the mutual exclusion between write operations in a read-write lock is actually implemented using an embedded mutex lock. Therefore, it can also be said that a read-write lock in Go language is an extension of the mutex lock.

Finally, it is worth noting that unlocking an “unlocked write lock” in a read-write lock will immediately cause a panic, and the same applies to the read locks, and it is also unrecoverable.

In summary, the differences between read-write locks and mutex locks come from the fact that read-write locks differentiate between write operations and read operations on shared resources. This also makes the mutual exclusion rules implemented by read-write locks more complex.

However, precisely because of this, we can exercise more nuanced control over the operations on shared resources. Additionally, since read-write locks are an extension of mutex locks, they still follow some of the behaviors of mutex locks. For example, the behavior when unlocking an unlocked write lock or read lock, or the way mutual exclusion between write operations is implemented.

Summary #

Today we discussed a lot of knowledge related to multithreading, shared resources, and synchronization. This involved many important concepts in concurrent programming, such as race conditions, critical sections, mutexes, and deadlocks.

Although Go language emphasizes “sharing data by communicating,” it still provides some easy-to-use synchronization tools. Among them, mutex is the most commonly used one.

Mutexes are often used to ensure complete serialization of multiple goroutines accessing the same shared resource. This is achieved by protecting a critical section or a set of related critical sections for this shared resource. Therefore, we can think of it as the access token that goroutines must acquire when entering the relevant critical section.

In order to use mutexes correctly and effectively, we need to understand the mutual exclusion rules implemented by them and pay attention to some precautions.

For example, do not lock multiple times or forget to unlock, as this will cause unnecessary blocking of goroutines and even lead to program deadlock.

For example, do not pass mutexes, as this will create a copy of them, leading to ambiguity and possible failure of mutex operations.

Again, we should always make sure that each mutex only protects one critical section or a set of related critical sections.

As for the read-write lock, it is an extension of the mutex. We need to know the similarities and differences between it and the mutex, especially in terms of mutual exclusion rules and behavior patterns. A read-write lock contains both read locks and write locks, which shows that it treats read operations and write operations on shared resources differently. Based on this, we can implement more refined access control for shared resources.

Finally, it is worth mentioning that whether it is a mutex or a read-write lock, we should never try to unlock a lock that is not locked, as this will cause an irrecoverable panic.

Thought Questions #

  1. Do you know which interface is implemented by both the Mutex and RWMutex pointer types?
  2. How can we obtain a read lock from an RWMutex?

Click here to view the detailed code accompanying the Go language column article.