102
type Stat struct {
    counters     map[string]*int64
    countersLock sync.RWMutex
    averages     map[string]*int64
    averagesLock sync.RWMutex
}

It is called below

func (s *Stat) Count(name string) {
    s.countersLock.RLock()
    counter := s.counters[name]
    s.countersLock.RUnlock()
    if counter != nil {
        atomic.AddInt64(counter, int64(1))
        return
    }
}

My understanding is that we first lock the receiver s (which is a type Stat) and then we add to it if the counter does exist.

Questions:

Q1: why do we need to lock it? What does RWMutex even mean?

Q2: s.countersLock.RLock() - does this lock up the entire receiver s or only the counters field in type Stat?

Q3: s.countersLock.RLock() - does this lock up the averages field?

Q4: Why should we use RWMutex? I thought channel was the preferred way to handle concurrency in Golang?

Q5: What is this atomic.AddInt64. Why do we need atomic in this case?

Q6: Why would we unlock right before we add to it?

Rambatino
  • 4,716
  • 1
  • 33
  • 56
samol
  • 18,950
  • 32
  • 88
  • 127

3 Answers3

186

When more than one thread* needs to mutate the same value, a locking mechanism is needed to synchronizes access. Without it two or more threads* could be writing to the same value at the same time, resulting in corrupt memory that typically results in a crash.

The atomic package provides a fast and easy way to synchronize access to primitive values. For a counter it is the fastest synchronization method. It has methods with well defined use cases, such as incrementing, decrementing, swapping, etc.

The sync package provides a way to synchronize access to more complicated values, such as maps, slices, arrays, or groups of values. You use this for use cases that are not defined in atomic.

In either case locking is only required when writing. Multiple threads* can safely read the same value without a locking mechanism.

Lets take a look at the code you provided.

type Stat struct {
    counters     map[string]*int64
    countersLock sync.RWMutex
    averages     map[string]*int64
    averagesLock sync.RWMutex
}

func (s *Stat) Count(name string) {
    s.countersLock.RLock()
    counter := s.counters[name]
    s.countersLock.RUnlock()
    if counter != nil {
        atomic.AddInt64(counter, int64(1))
        return
    }
}

What's missing here is how the map's themselves are initialized. And so far the maps are not being mutated. If the counter names are predetermined and cannot be added to later, you don't need the RWMutex. That code might look something like this:

type Stat struct {
    counters map[string]*int64
}

func InitStat(names... string) Stat {
    counters := make(map[string]*int64)
    for _, name := range names {
        counter := int64(0)
        counters[name] = &counter
    }
    return Stat{counters}
}

func (s *Stat) Count(name string) int64 {
    counter := s.counters[name]
    if counter == nil {
        return -1 // (int64, error) instead?
    }
    return atomic.AddInt64(counter, 1)
}

(Note: I removed averages because it wasn't being used in the original example.)

Now, lets say you didn't want your counters to be predetermined. In that case you would need a mutex to synchronize access.

Lets try it with just a Mutex. It's simple because only one thread* can hold Lock at a time. If a second thread* tries to Lock before the first releases theirs with Unlock, it waits (or blocks)** until then.

type Stat struct {
    counters map[string]*int64
    mutex    sync.Mutex
}

func InitStat() Stat {
    return Stat{counters: make(map[string]*int64)}
}

func (s *Stat) Count(name string) int64 {
    s.mutex.Lock()
    counter := s.counters[name]
    if counter == nil {
        value := int64(0)
        counter = &value
        s.counters[name] = counter
    }
    s.mutex.Unlock()
    return atomic.AddInt64(counter, 1)
}

The code above will work just fine. But there are two problems.

  1. If there is a panic between Lock() and Unlock() the mutex will be locked forever, even if you were to recover from the panic. This code probably won't panic, but in general it's better practice to assume it might.
  2. An exclusive lock is taken while fetching the counter. Only one thread* can read from the counter at one time.

Problem #1 is easy to solve. Use defer:

func (s *Stat) Count(name string) int64 {
    s.mutex.Lock()
    defer s.mutex.Unlock()
    counter := s.counters[name]
    if counter == nil {
        value := int64(0)
        counter = &value
        s.counters[name] = counter
    }
    return atomic.AddInt64(counter, 1)
}

This ensures that Unlock() is always called. And if for some reason you have more then one return, you only need to specify Unlock() once at the head of the function.

Problem #2 can be solved with RWMutex. How does it work exactly, and why is it useful?

RWMutex is an extension of Mutex and adds two methods: RLock and RUnlock. There are a few points that are important to note about RWMutex:

  • RLock is a shared read lock. When a lock is taken with it, other threads* can also take their own lock with RLock. This means multiple threads* can read at the same time. It's semi-exclusive.

  • If the mutex is read locked, a call to Lock is blocked**. If one or more readers hold a lock, you cannot write.

  • If the mutex is write locked (with Lock), RLock will block**.

A good way to think about it is RWMutex is a Mutex with a reader counter. RLock increments the counter while RUnlock decrements it. A call to Lock will block as long as that counter is > 0.

You may be thinking: If my application is read heavy, would that mean a writer could be blocked indefinitely? No. There is one more useful property of RWMutex:

  • If the reader counter is > 0 and Lock is called, future calls to RLock will also block until the existing readers have released their locks, the writer has obtained his lock and later releases it.

Think of it as the light above a register at the grocery store that says a cashier is open or not. The people in line get to stay there and they will be helped, but new people cannot get in line. As soon as the last remaining customer is helped the cashier goes on break, and that register either remains closed until they come back or they are replaced with a different cashier.

Lets modify the earlier example with an RWMutex:

type Stat struct {
    counters map[string]*int64
    mutex    sync.RWMutex
}

func InitStat() Stat {
    return Stat{counters: make(map[string]*int64)}
}

func (s *Stat) Count(name string) int64 {
    var counter *int64
    if counter = getCounter(name); counter == nil {
        counter = initCounter(name);
    }
    return atomic.AddInt64(counter, 1)
}

func (s *Stat) getCounter(name string) *int64 {
    s.mutex.RLock()
    defer s.mutex.RUnlock()
    return s.counters[name]
}

func (s *Stat) initCounter(name string) *int64 {
    s.mutex.Lock()
    defer s.mutex.Unlock()
    counter := s.counters[name]
    if counter == nil {
        value := int64(0)
        counter = &value
        s.counters[name] = counter    
    }
    return counter
}

With the code above I've separated the logic out into getCounter and initCounter functions to:

  • Keep the code simple to understand. It would be difficult to RLock() and Lock() in the same function.
  • Release the locks as early as possible while using defer.

The code above, unlike the Mutex example, allows you to increment different counters simultaneously.

Another thing I wanted to point out is with all the examples above, the map map[string]*int64 contains pointers to the counters, not the counters themselves. If you were to store the counters in the map map[string]int64 you would need to use Mutex without atomic. That code would look something like this:

type Stat struct {
    counters map[string]int64
    mutex    sync.Mutex
}

func InitStat() Stat {
    return Stat{counters: make(map[string]int64)}
}

func (s *Stat) Count(name string) int64 {
    s.mutex.Lock()
    defer s.mutex.Unlock()
    s.counters[name]++
    return s.counters[name]
}

You may want to do this to reduce garbage collection - but that would only matter if you had thousands of counters - and even then the counters themselves don't take up a whole lot of space (compared to something like a byte buffer).

* When I say thread I mean go-routine. A thread in other languages is a mechanism for running one or more sets of code simultaneously. A thread is expensive to create and tear-down. A go-routine is built on top of threads, but re-uses them. When a go-routine sleeps the underlying thread can be used by another go-routine. When a go-routine wakes up, it might be on a different thread. Go handles all this behind the scenes. -- But for all intents and purposes you would treat a go-routine like a thread when it comes to memory access. However, you don't have to be as conservative when using go-routines as you do threads.

** When a go-routine is blocked by Lock, RLock, a channel, or Sleep, the underlying thread might be re-used. No cpu is used by that go-routine - think of it as waiting in line. Like other languages an infinite loop like for {} would block while keeping the cpu and go-routine busy - think of that as running around in a circle - you'll get dizzy, throw up, and the people around you won't be very happy.

Luke
  • 13,678
  • 7
  • 45
  • 79
  • In your example code, Isn't your whole map locked when performing a write? In my understanding, incrementing counter["stars"] and counter["planets"] concurrently should not be a problem. Yet using a Mutex or an RWMutex in this case would not allow them to proceed in parallel. – Keeto Aug 26 '16 at 00:00
  • Simple example, yes. OP's example uses int pointers. Each counter would be in separate memory space. Simple example keeps them together in same space. Which is used depends on requirements. OP example uses RLock() to get counter and atomic to increment, so counters can be increments in parallel. But if u add entry to map u must use Lock() which blocks all reads momentarily. – Luke Aug 27 '16 at 17:46
  • 18
    I re-wrote my answer. It explains RWMutex in depth, how it's useful, and when you would need it. It's a bit long, but hopefully more useful. – Luke Aug 29 '16 at 18:59
  • 1/2 Your answer clarified something I've been trying to find the answer to - "If the reader counter is > 0 and Lock is called, future calls to RLock will also block until the existing readers have released their locks, the writer has obtained his lock and later releases it." The doc does not state this explicitly - https://pkg.go.dev/sync?utm_source=gopls#RWMutex - i.e. it only talks about a single RLock-er and also does not state that any waiting Write Lock goroutines will be given preference over any waiting Read Lock goroutines when the existing ReadLock holder has released its lock. – talonx Oct 08 '22 at 06:31
  • 2/2 (contd from above comment) I was wondering where you found this - I've looked at the docs, Kernighan's book, but could not find this. @Luke – talonx Oct 08 '22 at 06:32
  • 1
    @talonx The great thing about Go is if you have a question you can read the source code. Look at the comments for RLock() and Lock(). In Lock() you'll see "Announce to readers there is a pending writer." and in RLock() you'll see "A writer is pending, wait for it." – Luke Oct 09 '22 at 16:00
  • @Luke thanks for this. Reading the source code is definitely helpful. I was expecting the documentation to be less ambiguous though. – talonx Oct 16 '22 at 17:25
50

Questions:

Q1: why do we need to lock it? What does RWMutex even mean?

RW stands for Read/Write. CF doc: http://golang.org/pkg/sync/#RWMutex.

We need to lock it to prevent other routines/thread to change the value while we process it.

Q2: s.countersLock.RLock() - does this lock up the entire receiver s or only the counters field in type Stat?

As a mutex, the lock occurs only when you call the RLock() function. If any other goroutine already called the WLock(), then it blocks. You can call any number of RLock() within the same goroutine, it won't lock.

So it does not lock any other fields, not even s.counters. In your example, you lock the map lookup to find the correct counter.

Q3: s.countersLock.RLock() - does this lock up the averages field?

No, as said in Q2, a RLock locks only himself.

Q4: Why should we use RWMutex? I thought channel was the preferred way to handle concurrency in Golang?

Channel is very useful but sometimes it is not enough and sometimes it does not make sense.

Here, as you lock the map access, a mutex makes sense. With a chan, you'd have to have a buffered chan of 1, send before and receive after. Not very intuitive.

Q5: What is this atomic.AddInt64. Why do we need atomic in this case?

This function will increment the given variable in an atomic way. In your case, you have a race condition: counter is a pointer and the actual variable can be destroyed after the release of the lock and before the call to atomic.AddInt64. If you are not familiar with this kind of things, I'd advise you to stick with Mutexes and do all processing you need in between the lock/unlock.

Q6: Why would we unlock right before we add to it?

You should not.

I don't know what you are trying to do, but here is a (simple) example: https://play.golang.org/p/cVFPB-05dw

Tai Le
  • 102
  • 2
  • 10
creack
  • 116,210
  • 12
  • 97
  • 73
  • 31
    Many routines can call RLock() at the same time without problem, but only one routine can Lock() at the same time, and not while it is RLock'ed. From the docs for RWMutex: `The lock can be held by an arbitrary number of readers or a single writer.` – ANisus Oct 03 '13 at 06:37
  • The code in the answer (http://play.golang.org/p/pms05Cls0T) doesn't seem to properly lock. When I execute the code after building with the "-race" flag, I'm seeing a data race on line 23 inside the increaseCounter() function. It looks to me like the sync.Mutex inside struct doesn't work the way we think it should. My worry is that it has become an anti-pattern. The only way I've found to correct this data race is to explicitly wrap each use of the map with a global mutex. But that seems hackish. – havoc1 Jul 18 '17 at 17:28
  • 2
    The race detected by `-race` (which was not detected back in 2013) is regarding the final print. The mutex works as expected in the increaseCounter function. Here is the example with a small fix: https://play.golang.org/p/cVFPB-05dw – creack Jul 18 '17 at 20:06
  • @creack You're right. The problem I was seeing was due to the assumption that the time.wait allowed enough time for the goroutines to complete, which it doesn't. When I use a waitgroup instead of a timer, which is more likely the scenario you'd use for a live application, the final print statement works as the original code assumed it should. Thanks again for the clarification. – havoc1 Jul 18 '17 at 21:07
7

Let's compare it to the regular sync.Mutex, where only one consumer can hold the lock at a given time. And use a fun analogy: imagine a big delicious strawberry milkshake, that needs to be shared by a bunch of friends (consumers).

The friends want to share the milkshake and decide to use a single exclusive straw (the lock), so only one friend can drink from the straw at a given time. A friend calling m.Lock() signals that they want to drink. If no one is drinking they go ahead, but someone else was already using the straw, they have to wait (block) until the previous friend is done drinking and calls m.Unlock() on their side.

\\  |  |
 \\ |__|

m.Lock()
m.Unlock()

Let's move into the sync.RWMutex (Read Write Mutex), where any number of readers can hold the lock, or a single writer can hold the lock.

On the strawberry milkshake analogy, the friends decide to share the milkshake with many "reader" straws, and one single exclusive "writer" straw. A friend calling m.RLock() signals that they want to drink with one of the "reader" straws, and can star drinking along with other readers at the same time. However, the exclusive "writer" straw works like before. When someone calls m.Lock(), they signal that they want to drink alone. At that moment, everyone is blocked until all "reader" straws are done drinking (calling m.RUnlock()). Then, the exclusive writer starts drinking alone. Any other call to either m.RLock() or m.Lock() has to wait until the friend with the exclusive "writer" straw is done drinking (until they call m.Unlock()).

\\  |  |   //  //  //  //
 \\ |__|  //  //  //  //  ...

m.Lock()         m.RLock()
m.Unlock()       m.RUnlock()

The terminology "reader" and "writer" is used because that is the most common scenario. Concurrent memory reads are fine, but writes have to be sequential. If one process is trying to read a memory address, while another process is writing, that could cause memory corruption.

tothemario
  • 5,851
  • 3
  • 44
  • 39