0

My goal is to create a simple websocket server. I use chan to distribute the message e.g by invoking <-messageChan. the messageChan have many writers and readers.

However, this StackOverflow question scares me of causing an unintentionally deadlock.

What I've did:

  1. Create a test that essentially do:
    1. populate a chan int with 0 to 1000.
    2. create 100 goroutine to invoke <-chan and add it to a map[int]bool.
    3. invoke t.Fatal if len(map[int]bool) is not 1000. In other words, race condition.

However, the test did not fail. I am afraid, I did something wrong, and chan can have deadlock.

The code

main_test.go

package main

import (
    "log"
    "sync"
    "testing"
)

type MapMux struct {
    sync.RWMutex
    m map[int]bool
    sync.WaitGroup
}

func (mux *MapMux) AddInt(i int) {
    mux.RLock()
    if _, isExist := mux.m[i]; isExist {
        log.Fatal("race condition")
    }
    mux.RUnlock()
    mux.Lock()
    mux.m[i] = true
    mux.Unlock()
    mux.Done()
}

func TestChanRaceCondition(t *testing.T) {
    l := 1000
    c := make(chan int, l)
    defer close(c)
    for i := 0; i < l; i++ {
        c <- i
    }

    mux := MapMux{sync.RWMutex{}, map[int]bool{}, sync.WaitGroup{}}

    mux.Add(l)

    for i := 0; i < 100; i++ {
        go func(key int) {
            for {
                payload := <-c
                log.Printf("go%d: %d", key, payload)
                mux.AddInt(payload)
            }
        }(i)
    }

    mux.Wait()

    if len(mux.m) != l {
        t.Fatal("expected len:", l, ", actual len:", len(mux.m))
    }
}

Edit:

  1. The code finds duplicates in the map[int]bool field. Edit: This is because defer close(c). I should have check is the channel was open for every operation.

Anyway, this is what I learn. Hope this help new Golangers.

Lesson learnt:

  1. it is okay to have many writers and many readers for one channel.
  2. always check val, isOpen := <-chan. If isOpen == false, then stop using the channel.
  3. sync.RWMutex.RLock() does not guarantee other goroutine from making changes to the map. So, becareful. This is how it should be done.
     func (mux *MapMux) AddInt(i int) {
         mux.RLock()
         if _, isExist := mux.m[i]; isExist {
             log.Fatal("race condition")
         }
         mux.RUnlock()
         mux.Lock()
         if _, isExist := mux.m[i]; isExist {
             log.Fatal("race condition")
         }
         mux.m[i] = true
         mux.Unlock()
         mux.Done()
     }
    
Jason Rich Darmawan
  • 1,607
  • 3
  • 14
  • 31
  • By *race condition* you mean having several goroutines to handle the same value? It could have happened in early versions of Go, but now receiving from a channel is [protected by a lock internally](https://github.com/golang/go/blob/9faf6b79297810f6c9418201c6a9fe7fe5a3695c/src/runtime/chan.go#L511). Only one goroutine receives a message at a time. – Pak Uula Sep 30 '22 at 03:34
  • Many writers and many readers typically requires a broker between the readers and writers. See the [gorilla chat example](https://github.com/gorilla/websocket/tree/master/examples/cha) where the hub acts as the broker. The hub maintains a collection of channels to connected clients, receives messages on its own channel and distributes messages to the client channels. – Charlie Tumahai Sep 30 '22 at 03:44
  • @PakUula Yes, I mean several goroutines get the same value. I use Go 1.19. The `func (mux *MapMux) AddInt(i int)` find duplicated value. I will edit the post. – Jason Rich Darmawan Sep 30 '22 at 03:48
  • @CeriseLimón I will check that out, great concept with the `type Hub struct { contain client connection }`. However, I don't see any lock mechanism. Is it because the `broadcast` chan, etc only have one reader and many writers? – Jason Rich Darmawan Sep 30 '22 at 03:51
  • The question you quoted is 9 years old. Since 2014 reading from/writing to a channel is protected with a fast mutex. Only one goroutine has access to the internal buffer at a time. So there is no risk of race condition even for multi-write/multi-read scenario – Pak Uula Sep 30 '22 at 03:52
  • @kidfrom The Gorilla example does not use locks because (1) channel operations are safe to execute concurrently (2) there is no mutable state shared between the goroutines. – Charlie Tumahai Sep 30 '22 at 04:01
  • @PakUula but the example that I posted above print this `2022/09/30 11:00:46 go4: 0 2022/09/30 11:00:46 race condition: 0 2022/09/30 11:00:46 go73: 0` and I use Go 1.19. `go4` and `go73` get value `0` from invoking `<-c` – Jason Rich Darmawan Sep 30 '22 at 04:02
  • @PakUula and @Cerise Limón sorry for wasting both of your time. I use `defer close(c)` and I didn't check whether the channel is closed or not everytime I call `AddInt`. The test never failed now. – Jason Rich Darmawan Sep 30 '22 at 04:06
  • Yes, it reads zero from non-blocking channel. I wrapped it into `select { case payload := <-c: ... default:... }` and stopped goroutiones when the buffer is emptied: https://go.dev/play/p/4adrgpi095t - no *race conditions* :) – Pak Uula Sep 30 '22 at 04:21
  • @PakUula thank you for the playground example, anyway is `default in select` an idiomatic way in Go?. This is a question because if I add `time.Sleep(1 * time.Milisecond)` in the `writer goroutine`, the `reader goroutine` will kill themselves because it wait for too long. I will use `case <-time.After(1 * time.Second):` [repository](https://github.com/kidfrom/learn-golang/blob/master/chan-multiple-receivers/main_test.go) – Jason Rich Darmawan Sep 30 '22 at 05:05
  • I used `return` in `default` since it is reasonable in the case of pre-filled chain. In general case it is better to use `case <- time.After` or other reasonable guardians. – Pak Uula Sep 30 '22 at 06:10

1 Answers1

0

A data race happens if you share memory between goroutines. If you do not share memory between goroutines, the kind of data races you are afraid of will not happen. There can be other race conditions, such as the one you have in your AddInt method. The correct version should be:

func (mux *MapMux) AddInt(i int) {
    mux.RLock()
    if _, isExist := mux.m[i]; isExist {
        log.Fatal("race condition")
    }
    mux.RUnlock()
    mux.Lock()
    if _, isExist := mux.m[i]; isExist {
        log.Fatal("race condition")
    }
    mux.m[i] = true
    mux.Unlock()
    mux.Done()
}

This is because there is no guarantee that another goroutine changes the map between RUnlock and Lock.

In your example, you are sharing a map between goroutines. That means you have to protect all access to it using a mutex. If you do that, there will be no data races around the use of that map.

However, in general, if you can avoid sharing memory, your program will not have such data races.

Burak Serdar
  • 46,455
  • 3
  • 40
  • 59
  • I get the idea of `mux.Lock()` does not guarantee that another goroutine changes the map between `mux.RUnlock()` and `mux.Lock()`. However, I find it odd to check `_, isExist := mux.m[i]; isExist` because I am surprised that the second `_, isExist := mux[i]; isExist` does not throw error. Do you know why? – Jason Rich Darmawan Sep 30 '22 at 04:29
  • Ahh.. I get it, `AddInt` block other goroutine because of `mux.Lock()`. That's why `if _, isExist := mux.m[i]; isExist` does not throw error. So, it is better to just use `mux.Lock()` and do the **isExist check** after `mux.Lock()`. Because in this case, the `AddInt` guarantee no one will read the map. Except I create another function that does the read, that's where the problem come. – Jason Rich Darmawan Sep 30 '22 at 04:33