ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Golang] fatal error: concurrent map writes
    레거시/트러블슈팅 2022. 4. 4. 19:19
    반응형

    문제 상황

    golang 개발 중, map 객체에 어떤 값을 저장하여, 이를 이용하는 서비스를 만든다고 해보자. 처음에는 쉽게 다음과 같이 WriteMap 함수를 이용해서 코드를 간단하게 작성할 수 있다.

    package main
    
    import "fmt"
    
    func WriteMap(m map[int]int, num int) {
        for i := 0; i < num; i++ {
            m[i] = i
        }
    }
    
    func main() {
        fmt.Println("map writes start!")
        m := map[int]int{}
        WriteMap(m, 100)
        fmt.Println("map writes end!")
    }

     

    이 때, 고루틴을 이용하여 WriteMap 함수의 성능 향상을 계획했다고 가정해보자. 그럼 코드는 이렇게 바꿀 수 있을 것이다.

    package main
    
    import "fmt"
    
    func WriteMap(m map[int]int, num int) {
        for i := 0; i < num; i++ {
            go func() {
                m[i] = i
            }()
        }
    }
    
    func main() {
        fmt.Println("map writes start!")
        m := map[int]int{}
        WriteMap(m, 100)
        fmt.Println("map writes end!")
    }

     

    이 때 프로그램을 실행시키면, 다음 에러를 일으키는 것을 확인할 수 있다.

    $ go run main.go 
    map writes start!
    fatal error: concurrent map writes
    
    goroutine 12 [running]:
    runtime.throw(0x10ca420, 0x15)
            /Users/gurumee/.gvm/gos/go1.16/src/runtime/panic.go:1117 +0x72 fp=0xc00004d760 sp=0xc00004d730 pc=0x10327f2
    runtime.mapassign_fast64(0x10b1ce0, 0xc00007a180, 0x1e, 0x0)
            /Users/gurumee/.gvm/gos/go1.16/src/runtime/map_fast64.go:101 +0x33e fp=0xc00004d7a0 sp=0xc00004d760 pc=0x1010c3e
    main.WriteMap.func1(0xc0000160c8, 0xc00007a180)
            /Users/gurumee/Workspace/troubleshooting/go-concurrent-map-writes/main.go:8 +0x45 fp=0xc00004d7d0 sp=0xc00004d7a0 pc=0x10a3365
    runtime.goexit()
            /Users/gurumee/.gvm/gos/go1.16/src/runtime/asm_amd64.s:1371 +0x1 fp=0xc00004d7d8 sp=0xc00004d7d0 pc=0x1064361
    created by main.WriteMap
            /Users/gurumee/Workspace/troubleshooting/go-concurrent-map-writes/main.go:7 +0x65
    
    goroutine 1 [runnable]:
    fmt.Fprintln(0x10e83a0, 0xc00000e018, 0xc000076f58, 0x1, 0x1, 0x12, 0x0, 0x0)
            /Users/gurumee/.gvm/gos/go1.16/src/fmt/print.go:262 +0xed
    fmt.Println(...)
            /Users/gurumee/.gvm/gos/go1.16/src/fmt/print.go:274
    main.main()
            /Users/gurumee/Workspace/troubleshooting/go-concurrent-map-writes/main.go:17 +0xe5
    
    goroutine 9 [runnable]:
    main.WriteMap.func1(0xc0000160c8, 0xc00007a180)
            /Users/gurumee/Workspace/troubleshooting/go-concurrent-map-writes/main.go:7
    created by main.WriteMap
            /Users/gurumee/Workspace/troubleshooting/go-concurrent-map-writes/main.go:7 +0x65
    
    goroutine 11 [runnable]:
    main.WriteMap.func1(0xc0000160c8, 0xc00007a180)
            /Users/gurumee/Workspace/troubleshooting/go-concurrent-map-writes/main.go:7
    created by main.WriteMap
            /Users/gurumee/Workspace/troubleshooting/go-concurrent-map-writes/main.go:7 +0x65
    ...

    문제 원인

    다음 문서를 보면, 그 실마리를 찾을 수 있다.

    Why are map operations not defined to be atomic?

    After long discussion it was decided that the typical use of maps did not require safe access from multiple goroutines, and in those cases where it did, the map was probably part of some larger data structure or computation that was already synchronized. Therefore requiring that all map operations grab a mutex would slow down most programs and add safety to few. This was not an easy decision, however, since it means uncontrolled map access can crash the program.

     

    문서에 따르면, golang 개발진은 map 객체를 동시에 접근하는 상황을 일반적인 상황으로 보지 않았고, 따라서 표준 라이브러리에서 제공하는 map은 "atomic"한 연산을 지원하지 않는다. 쉽게 말해 동시성을 처리하는 연산을 지원하지 않는다는 뜻이다. 즉 위 WriteMap의 경우 num개 만큼 생성되는 고루틴들에서 map 객체를 동시에 접근하나, 이를 처리하지 않기 때문에 생기는 문제였다.

    문제 해결

    문제 해결 방법은 크게 2가지이다.

    1. WaitGroup, Mutex Lock/Unlock을 이용한 동시성 제어
    2. Channel을 이용한 동시성 제어

    먼저 가장 쉬운 WaitGroupMutex를 이용한 방법을 살펴보자. Mutex는 여러 스레드가 동시에 접근하는 객체에 한 스레드만 수행할 수 있도록 잠금을 해버린다. 한 스레드의 수행이 끝나야 다른 스레드의 수행이 실행될 수 있다. 다음과 같이 WriteMap을 수정하면 된다.

    package main
    
    import (
        "fmt"
        "sync"
    )
    
    func WriteMap(m map[int]int, num int) {
        mutex := &sync.Mutex{}
        wg := &sync.WaitGroup{}
    
        for i := 0; i < num; i++ {
            wg.Add(1)
            go func(i int) {
                defer wg.Done()
                mutex.Lock()
                m[i] = i
                mutex.Unlock()
            }(i)
        }
    
        wg.Wait()
    }
    
    // ...

    WaitGroup은 고루틴 개수만큼 카운터를 저장한다. 이 카운터가 0가 될 때까지 WriteMap 함수는 종료되지 않고 대기하게 된다. defer wg.Done() 구문 덕분에 고루틴이 종료되는 순간 풀에서 WaitGroup의 카운터를 줄이게 된다. 쉽게 말해서 동시에 접근하는 동안, 모든 고루틴이 실행을 마칠 때까지 기다리기 위해서 사용된다.

     

    Mutex가 실제로 여러 고루틴이 동시에 접근할 때 순차적으로 접근할 수 있도록 잠금을 해주는 장치라고 보면 된다. 이 때, LockUnlock이 쌍을 이루어야 한다는 점에 주의하자. 순서가 잘못되거나, 쌍이 맞지 않으면 여러 고루틴이 영원히 종료되지 않는 데드락 상황이 벌어지기 때문이다.

     

    두 번째 방식은 Channel을 이용한 방식이다. 실제로 golang에서 수 많은 고루틴을 제어할 때 권장하는 방법이며 주로 select랑 같이 사용된다. 다음과 같이 코드를 변경하면 채널을 이용해서 여러 고루틴들을 제어할 수 있다.

    package main
    
    // ...
    
    func WriteMapChan(m map[int]int, num int) {
        c := make(chan int)
    
        for i := 0; i < num; i++ {
            go func(i int) {
                c <- i
            }(i)
        }
    
        for i := 0; i < num; i++ {
            v := <-c
            m[v] = v
        }
    }
    
    // ...

     

    channel은 보통 make(chan [type]) 형태로 만들어진다. 이들은 타입만 맞는다면 여러 고루틴이 값을 전송할 수 있는 일종의 통로가 된다. 즉, 첫 번째 for문에서, 다음과 같은 형태로 채널에 값을 전달하는 코드이다.

    for i := 0; i < num; i++ {
        go func(i int) {
            c <- i        // 채널에 값을 전달
        }(i)
    }

     

    <-c는 채널에서 값을 빼온다는 구문이다. 즉 num개만큼 채널에 데이터를 전달했으니 그만큼 데이터를 빼와야 하는 것이다. 즉 두 번째 for문은 채널에서 값을 빼와 맵에 값을 넣는 방식이다.

    for i := 0; i < num; i++ {
        v := <-c        // 채널에서 값을 빼옴
        m[v] = v        // 빼온 값을 맵객체에 넣음.
    }

     

    이 두 가지 중 하나를 적용했다면, "fatal error: concurrent map writes" 문제는 제거됨을 확인할 수 있다.

    $ go run main.go
    map writes start!
    map writes end!

     

    둘 다 장단점이 있다. WaitGroup/Mutex 방법은 코드 구조가 변경되지 않기 때문에 굉장히 직관적이다. 하지만 성능이 떨어진다. Channel 방식은 성능은 빠르나, 코드 구조를 살짝 바꿔줘야 하는 번거로움이 있다. 개인적으로는 Channel 방식이 숙달만 되면 확실히 더 편한 감이 있다.

    728x90
Designed by Tistory.