[Go] Map 사용시 Read도 RLock을 해야하는 이유

[Go] Map 사용시 Read도 RLock을 해야하는 이유
unsplash

[요약]

  • Map에 여러 프로세스가 접근할 때 다른 key에 대해 write을 하더라도 리밸런스로 서로 영향을 미칠 수 있다.
  • Read시 다른 Key에 write을 하는 프로세스에 영향을 받을 수 있으니 key별로 lock을 걸지 말고 전체 Map에 대해 RLock, WLock을 걸어야 한다.

멀티 프로세스 혹은 여러 고루틴을 이용해 병렬 프로그래밍을 할 때 가장 골치가 아픈 부분은 동시에 접근할 수 있는 객체들을 다루는 것이다.

최대한 그러한 객체들을 안 만드는 것이 베스트지만, 필요할 때 자주 사용하는 방법은 lock을 거는 것이다. Go는 race condition을 다룰 수 있는 Mutex, 채널, atomic 패키지 등 다양한 방법을 제공한다. 고루틴의 경우 채널 버퍼가 가득 차면 블락되는 것을 이용할 수 있다. 여러 프로세스가 접근하는 경우 일반적으로 Mutex를 사용한다.

하지만 Lock을 걸게 되면 필연적으로 기다리는 프로세스가 생기게 되고, 동시에 실행되는 프로세스가 많아질 수록 성능 손해가 심해진다. 그래서 개발자는 정말 필요할 때만 Lock을 걸어야 한다.

자, 이제 주제로 돌아와보자. 바로 Map에 여러 프로세스가 접근하는 상황이다. 프로세스가 write을 하는 것은 당연히 lock이 필요하다. 같은 key에 여러 프로세스가 동시에 write을 하면 값이 유실되기 때문이다.

그런데, read를 할 때도 꼭 lock이 필요할까? 만약 read를 할 때 write이 들어와 값의 일관성을 해치게 된다면 lock이 필요할 것이다.

하지만 그것이 목적이 아니라면? 값이 중요하지 않고 단순히 존재 확인 용도의 read라면 일관성이 중요하지 않으므로 write이 들어와도 상관이 없다. 이런 특수한 상황에서는 read는 lock이 필요 없을까?

결론부터 말하자면 read도 lock이 필요하다.

문제 상황

아무 key-value도 가지지 않는 Map을 가지고 있다고 해보자. 이 Map에 수많은 고루틴이 랜덤으로 key를 넣어 Read를 시도한다. 이때 만약 value가 있으면 그대로 가져오고, value가 없다면 랜덤으로 만든 value를 key-value 쌍으로 만들어 Map에 넣는다.

즉 각 고루틴은 (1) read를 시도 (2) 없으면 write 을 반복한다.

이때 한 고루틴이 Map[A]을 읽고 있을 때 다른 고루틴이 Map[B]에 값을 write을 할 때, Map[A]의 값에 영향이 갈까?

Map의 구조

출처: https://dave.cheney.net/2018/05/29/how-the-go-runtime-implements-maps-efficiently-without-generics / 프로젝트와 Github의 stars 수를 매핑하는 Map

Map은 채널이나 슬라이스보다 훨씬 복잡하다.

단순히 backing array를 보는 것이 아니라 내부 상태와 map iterator 등을 가지고 있다.

Map은 버킷 내부에 원소가 많아지게 되면 성능이 줄어들기 때문에 load factor를 일정 수준 이하로 유지하는 것이 중요하다. 그래서 Go의 빌트인 자료 구조들 중 오직 Map만 data를 내부적으로 움직이는데, O(1) 동작을 보장하기 위해 insert나 delete시 리밸런스를 한다. 이러한 이유 때문에 Map의 값은 addressable하지 않다.

💡
Addressable이란?
변수 x가 타입 T일때, &x가 x를 가리키는 포인터 타입 *T를 생성하는 것을 말한다.
Reference
go 공식 문서

예를 한번 들어보자. Map[1]=2로 할당하고, a=&Map[1]로 할당해보자. 그러면 우리는 *a가 2를 가질 것이라고 생각한다. 그러나 리밸런스가 되면 *a가 2를 가리키지 않을 수도 있다는 것이다. Map[1]은 여전히 2를 가리키고 있겠지만.

그렇다면 어떻게 써야할까?

1. RWMutex를 사용해 Map에서 Read를 할 때 RLock을 걸자

2. go 1.9 버전(이제는 너무도 오래됐지만..) 이후 부터 sync.Map이 나왔다. 속편하게 이것을 쓰면 된다

Reference

Are Go maps sensitive to data races ? | Dave Cheney
How to use RWMutex?
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