-
[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가지이다.
- WaitGroup, Mutex Lock/Unlock을 이용한 동시성 제어
- Channel을 이용한 동시성 제어
먼저 가장 쉬운
WaitGroup
과Mutex
를 이용한 방법을 살펴보자.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
가 실제로 여러 고루틴이 동시에 접근할 때 순차적으로 접근할 수 있도록 잠금을 해주는 장치라고 보면 된다. 이 때,Lock
과Unlock
이 쌍을 이루어야 한다는 점에 주의하자. 순서가 잘못되거나, 쌍이 맞지 않으면 여러 고루틴이 영원히 종료되지 않는 데드락 상황이 벌어지기 때문이다.두 번째 방식은
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'레거시 > 트러블슈팅' 카테고리의 다른 글