Go의 동시성 모델을 개념으로는 알고 있었다. Goroutine은 경량 스레드이고, channel로 통신하고, sync 패키지로 동기화한다. 하지만 패턴별 차이를 코드와 수치로 직접 비교해본 적은 없었다.

직접 구현하고 벤치마크를 돌려보기로 했다. mutex, channel, lock-free 세 가지 접근을 하나의 프로젝트에서 다뤘다.

Mutex

첫 번째로 구현한 것은 sync.RWMutex 기반의 동시성 안전한 맵이었다. 쓰기에는 Lock(), 읽기에는 RLock()을 사용해서 여러 goroutine이 동시에 접근할 수 있게 했다.

구현 후 Go 표준 라이브러리의 sync.Map과 벤치마크를 비교했다. 세 가지 시나리오를 만들었다. 같은 키에 대한 쓰기 경합, goroutine마다 다른 키에 쓰기, 읽기 90% + 쓰기 10%.

같은 키 경합에서는 둘의 성능이 비슷했다. 하지만 다른 키에 분산 쓰기를 하면 sync.Map이 2-3배 빨랐고, 읽기 비중이 높을 때도 33% 빨랐다. sync.Map 공식 문서에서 명시한 최적화 조건과 정확히 일치하는 결과였다. 반대로 같은 키에 집중적으로 쓰는 상황에서는 sync.Map이 메모리를 더 사용할 뿐 이점이 없었다.

Channel

채널 패턴에서는 데이터 흐름 제어를 구현했다. FanOut은 하나의 입력 채널에서 여러 출력 채널로 데이터를 분배한다. select문으로 먼저 받을 수 있는 출력 채널에 전달하는 방식이다.

TurnOut은 여러 입력에서 여러 출력으로 라우팅하면서 quit 채널로 종료 신호를 처리한다. select문에 quit 채널을 포함시키면 데이터 처리와 종료 신호를 하나의 루프에서 자연스럽게 다룰 수 있었다. 채널을 닫고 남은 데이터를 소진하는 정리 과정도 구현했다.

제너릭 타입([T any])을 활용해서 타입에 무관하게 재사용할 수 있게 만들었다.

Lock-free

가장 흥미로웠던 부분이다. 두 가지 lock-free 패턴을 구현했다.

SpinningCAS는 atomic.CompareAndSwapInt32로 락을 구현한다. 다른 goroutine이 락을 점유하고 있으면 대기 큐에 들어가지 않고, CAS 연산을 반복하며 스핀한다. 여기서 runtime.Gosched()가 중요했다. 스핀 루프에서 CPU를 양보하지 않으면 다른 goroutine이 실행되지 못해 교착 상태에 가까운 상황이 발생했다. 한 줄을 추가하는 것만으로 동작이 달라지는 경험이었다.

SpinningCAS와 표준 sync.Mutex를 벤치마크로 비교했다. 단일 공유 변수를 증가시키는 높은 경합 시나리오에서 SpinningCAS가 약 7배 빨랐다. Mutex는 goroutine을 대기 큐에 넣고 깨우는 오버헤드가 있지만, CAS는 바로 재시도한다. 짧은 임계 영역에서는 스핀이 유리하다는 것을 수치로 확인했다.

TicketStorage는 순서 보장이 필요한 경우를 위한 패턴이다. atomic.AddUint64로 티켓 번호를 발급하고, 자신의 번호가 올 때까지 CAS로 스핀한다. 공정성(FIFO)을 보장하지만, 경합이 높으면 대기 시간이 길어지는 트레이드오프가 있다.

회고

동시성 패턴을 개념으로 아는 것과 직접 벤치마크를 돌려보며 체감하는 것은 다른 경험이었다.

가장 크게 배운 것은 벤치마크 방법론이었다. 처음에는 고정 횟수로 goroutine을 생성하는 방식으로 벤치마크를 작성했는데, 결과가 실행마다 달라졌다. Go의 b.RunParallel을 사용해 프레임워크가 반복 횟수를 자동 조절하도록 수정하자, 결과가 안정되고 패턴 간 차이가 명확해졌다. 벤치마크 코드의 정확성이 결과를 결정한다는 것을 체감했다.

sync.Map은 “항상 빠른 map"이 아니라, 공식 문서에서 명시한 조건에서만 이점이 있었다. SpinningCAS는 짧은 임계 영역에서 Mutex를 압도했지만, 긴 임계 영역이나 낮은 경합에서는 다를 수 있다. 도구마다 최적의 조건이 다르고, 그 조건을 확인하는 것이 벤치마크의 역할이었다.

runtime.Gosched() 한 줄이 동작을 바꿔놓은 경험도 기억에 남는다. 동시성 코드에서는 이론적으로 맞는 구현이 실행 환경에서는 다르게 동작할 수 있다.

동시성 패턴을 개념으로 아는 것과 직접 구현하고 수치를 마주하는 것. 그 차이를 확인한 프로젝트였다.

참고