📜 ⬆️ ⬇️

Go Mutex Dancing

Translation of a developer’s tutorial article from SendGrid about when and why you can and should use “traditional” data synchronization methods in Go.

Reading Level: Intermediate - this article assumes that you are familiar with the basics of Go and the concurrency model, and, at a minimum, are familiar with the approaches to data synchronization using the methods of locks and channels.

Note to reader : I was inspired by a good friend for this post. When I helped him deal with some races in his code and tried to teach him the art of data synchronization as well as he could, I realized that these tips could be useful to others. So, be it an inherited code base, in which certain design decisions have already been made before you, or you just want to better understand the traditional synchronization primitives in Go — this article may be for you.

When I first started working with the Go programming language, I instantly fell in love with the slogan “Do not communicate by sharing memory. Share memory through communication. ” ( Don't communicate by sharing memory; share memory by communicating. ) For me, this meant writing all competitive (concurrent) code in the“ right ”way, using channels always-always. I thought that using the potential of the channels, I am guaranteed to avoid problems with competitiveness, locks, deadlocks, etc.
')
As I progressed into Go, learned to write Go code more idiomatically and learned best practices, I regularly came across large code bases where people regularly used the sync / mutex primitives, as well as sync / atomic , and several other low-level »Primitive synchronization of the" old school ". My first thoughts were - well, they obviously do it wrong, and, obviously, they didn’t watch a single speech by Rob Pike about the advantages of implementing concurrent code through channels in which he often talks about the design based on the work of Tony Hoara. Communicating Sequential Processes .

But the reality was harsh. The go community quoted this slogan here and there, but looking into many open source projects, I saw that mutexes are everywhere and there are a lot of them . I struggled with this mystery for a while, but in the end, I saw the light at the end of the tunnel, and it was time to roll up our sleeves and put the channels aside. Now, quickly rewind to 2015, in which I have been writing on Go for about 2.5 years, during which I had an epiphany or even two regarding more traditional synchronization primitives like locks with mutexes. Come on, ask me now, in 2015? Hey, @deckarep, do you still write competitive programs using only channels? I will answer - no, and this is why.

First, let's not forget the importance of pragmatism. When it comes to protecting the state of an object using locks or channels, let's ask ourselves: “What method should I use?”. And, as it turned out, there is a very good post that perfectly answers this question :
Use the method that is most expressive and / or simple in your case.

A common mistake for newcomers to Go is to reuse channels and gorutines simply because it is possible, and / or because it is fun. Do not be afraid to use sync.Mutex if it solves your problem best. Go is pragmatic in giving you those problem solving tools that suit you best, and does not impose on you only one approach.

Pay attention to the key words in this quotation: expressive, simple, reuse, do not be afraid, pragmatic . I can honestly admit some things voiced here: I was afraid when I first tried Go. I was completely new to the language, and I needed time to quickly draw conclusions. You will surely draw your own conclusions from the article mentioned above, and from this article, as we delve into the accepted practices of using mutexes and various nuances. The article above also describes well the considerations regarding the choice between mutexes and channels.

When to use Channels: transfer of ownership of data, distribution of calculations and transfer of asynchronous results.

When to use Mutexes: caches, states.

In the end, each application is different, and you may need a little experimentation and false starts. The instructions above help me personally, but let me explain them in a little more detail. If you need to protect access to a simple data structure, such as a slice, or map, or something of your own, and if the access interface to this data structure is simple and straightforward, start with a mutex. It also helps to hide the dirty details of the lock code in your API. The end users of your structure should not care about how it does internal synchronization.

If your synchronization on mutexes begins to become cumbersome and you start dancing dances with mutexes , this is the time to switch to a different approach. Once again, accept, as given, that mutexes are convenient for simple scenarios to protect minimally shared data. Use them for what they are needed for, but respect them and do not let them get out of control . Look back, look carefully at the logic of your program, and if you are fighting with mutexes, then this is a reason to rethink your design. Perhaps the transition to the channels much better fit into the logic of your application, or, even better, maybe you do not need to separate the state at all.

Multithreading is not difficult - blocking is difficult.

Understand, I do not claim that mutexes are better than channels. I'm just saying that you should be familiar with both methods of synchronization, and if you see that your solution on the channels looks overdeveloped, be aware that you have other options. The examples in this article serve the purpose of helping you write better, more supported, and reliable code. We, as engineers, need to be conscious of how we approach working with shared data and race conditions in multi-threaded applications. Go makes it incredibly easy to write high-performance competitive and / or parallel applications, but there are some pitfalls, and we should be able to carefully circumvent them by creating the right code. Let's look at them in more detail:

Number 1 : When defining a structure in which a mutex must protect one or more values, place the mutex above the fields to which it will protect. Here is an example of this idiom in Go source code. Keep in mind that this is just an agreement, and does not affect the logic of the code.
var sum struct { sync.Mutex // <--    i int // <--     } 

Number 2 : hold the lock no longer than it actually takes. Example - if possible, do not hold a mutex during an IO call. On the contrary, try to protect your data only the minimum necessary time. If you do something like this in the web handler, you simply lose the competitive advantage by serializing access to the handler:
 //    ,  `mu`   //    cache // NOTE:    ,     //   ,    func doSomething(){ mu.Lock() item := cache["myKey"] http.Get() // -  IO- mu.Unlock() } //  ,  -  func doSomething(){ mu.Lock() item := cache["myKey"] mu.Unlock() http.Get() //    ,    } 

Number 3 : Use defer to unlock a mutex where a function has multiple exit points. For you, this means less manual code and can help avoid deadlocks when someone changes the code after 3 months and adds a new exit point, losing sight of the lock.
 func doSomething() { mu.Lock() defer mu.Unlock() err := ... if err != nil { //log error return // <--    } err = ... if err != nil { //log error return // <--   } return // <-- , ,   } 

At the same time, try not to depend blindly on defer in all cases in a row. For example, the following code is a trap that you can get into if you think that defers are executed not when exiting a function, but when exiting a scope:
 func doSomething(){ for { mu.Lock() defer mu.Unlock() // -   // <-- defer    ,  - **  } // <-- ()   ,     } //       ! 


Finally, do not forget that defer can not be used at all in simple cases without multiple exit points. Deferred executions (defer) have a small overhead, although they can often be neglected. And consider this as a very premature and often excessive optimization.

Number 4 : exact (fine-grained) locking may give better performance at the cost of more complex code to manage it, while a coarse locking may be less productive, but making the code easier. But then again, be pragmatic in design evaluations. If you see that you are “dancing with mutexes”, then most likely this is the right moment to refactor and switch to synchronization through channels.

Number 5 : As mentioned above, it is good practice to encapsulate the synchronization method used. Users of your package should not care how you protect the data in your code.

In the example below, imagine that we represent the get () method, which will select the code from the cache only if it has at least one value. And since we have to block both the access to the content and the counting of values, this code will lead to deadlock :
 package main import ( "fmt" "sync" ) type DataStore struct { sync.Mutex // ←      cache map[string]string } func New() *DataStore { return &DataStore{ cache: make(map[string]string), } } func (ds *DataStore) set(key string, value string) { ds.Lock() defer ds.Unlock() ds.cache[key] = value } func (ds *DataStore) get(key string) string { ds.Lock() defer ds.Unlock() if ds.count() > 0 { // <-- count()  ! item := ds.cache[key] return item } return "" } func (ds *DataStore) count() int { ds.Lock() defer ds.Unlock() return len(ds.cache) } func main() { /*      ,    get()    count()      get()   */ store := New() store.set("Go", "Lang") result := store.get("Go") fmt.Println(result) } 

Since the mutexes in Go are non-recursive , the proposed solution might look like this:
 package main import ( "fmt" "sync" ) type DataStore struct { sync.Mutex // ←      cache map[string]string } func New() *DataStore { return &DataStore{ cache: make(map[string]string), } } func (ds *DataStore) set(key string, value string) { ds.cache[key] = value } func (ds *DataStore) get(key string) string { if ds.count() > 0 { item := ds.cache[key] return item } return "" } func (ds *DataStore) count() int { return len(ds.cache) } func (ds *DataStore) Set(key string, value string) { ds.Lock() defer ds.Unlock() ds.set(key, value) } func (ds *DataStore) Get(key string) string { ds.Lock() defer ds.Unlock() return ds.get(key) } func (ds *DataStore) Count() int { ds.Lock() defer ds.Unlock() return ds.count() } func main() { store := New() store.Set("Go", "Lang") result := store.Get("Go") fmt.Println(result) } 

Note in this code that for each non-exported method there is a similar one exported. These methods work as a public API, and take care of locks at this level. Then they call unexported methods that don't care about locks at all. This ensures that all calls to your methods from the outside will be blocked only once and are free from the problem of recursive locking.

Number 6 : In the examples above, we used a simple sync.Mutex , which can only block and unlock. sync.Mutex provides the same guarantees, regardless of whether it reads or writes data. But there is also sync.RWMutex , which provides more accurate blocking semantics for code that only accesses data. When to use RWMutex instead of the standard Mutex?

Answer: use RWMutex when you are absolutely sure that the code in your critical section does not change the protected data.

 //     RLock()  ,       func count() { rw.RLock() // <--   R  RLock (read-lock) defer rw.RUnlock() // <--   R  RUnlock() return len(sharedState) } //     Lock()  set(),    func set(key string, value string) { rw.Lock() // <-- ,    "" Lock (write-lock) defer rw.Unlock() // <--   Unlock(),  R sharedState[key] = value // <--  () } 

In the code above, we mean that the variable `sharedState` is an object, perhaps a map, in which we can read it is long. Since the count () function ensures that our object does not change, we can safely call it in parallel from any number of readers (gorutin). In some scenarios, this can reduce the number of gorutin in the blocking state and potentially give a performance boost in a scenario where many read-only data accesses occur. But remember, if you have code that changes data like in set (), you must use rw.Lock () instead of rw.RLock ().

Number 7 : Meet the hellishly cool and built-in race detector in Go. This detector has earned a reputation by finding race conditions even in the standard Go library in due time. That is why it is built into the Go toolkit and there are quite a few speeches and articles about it that tell better about it than I do.

I hope this article gives you a good idea of ​​how and when to use mutexes in Go. Please experiment with low-level synchronization primitives in Go, make mistakes, learn from them, appreciate and understand the toolkit. And above all, be pragmatic in your code, use the right tools for each case. Do not be afraid, as I was afraid at first. If I had always listened to all the negative things that say about blocking, I would not be in this business right now, creating the coolest distributed systems using such cool technologies as Go.
Note: I love feedback, so if you find this stuff useful, ping me, tweet, or give me a constructive review.
Thanks and good coding!

Source: https://habr.com/ru/post/271789/


All Articles