본문 바로가기
Dev/Go

고루틴 Race Condition 해결 과정

by Sovereign 2024. 8. 13.

장애 상황

Escalation 기능의 백엔드 로직 성능을 개선하기 위해 jiraEscalation 함수와 slackEscalation 함수를 각각 비동기 방식으로 구현하려고 했다. 하지만 두 함수가 동시에 실행되어 Incident 객체에 대해 읽기 및 쓰기 작업을 수행하는 경우, 동일한 객체를 동시에 수정하려고 시도하면서 Race Condition 오류가 발생했다.

 

 

위와 같은 jiraEscalation 함수와 slackEscalation 함수를 비동기로 실행시키기 위해 최초에는 두 메서드를 고루틴으로 실행하고 고루틴 내부에서 Incident의 값을 업데이트하도록 로직을 수정했다.

 

 

그 결과, 두 개의 고루틴이 동시에 실행되면서 incident.IssueId를 수정하고 있다.

jiraEscalation과 slackEscalation 함수가 각각 다른 결과를 반환하더라도, 이 두 값이 동시에 incident.IssueId에 쓰이는 현상이 발생했다.

 

문제 원인은 고루틴의 비결정적 실행 특성 때문이었다.

고루틴의 실행 순서와 완료 시점이 보장되지 않기 때문에, 하나의 고루틴에서 IssueId를 설정한 이후 다른 고루틴이 덮어쓰는 상황이 발생했다.

 

 

 

문제 해결을 위해 학습한 개념

각 고루틴이 동일한 필드에 접근하지 않도록 수정하면 해결될 문제지만, 다음번에 어떤 환경에서 Race Condition 현상을 마주할지 모르기 때문에 이번 기회에 본질적인 내용을 학습하려 한다.

 

Go scheduler의 구조와 이를 기반으로 고루틴의 원리에 대해 살펴보자.

 

 

 

Go Scheduler

1. 고루틴 vs 쓰레드

1-1. 메모리 소비 측면

고루틴 쓰레드
약 2KB의 스택 공간을 사용 약 1MB의 스택 영역 사용
필요에 따라 이 스택 공간은 힙에서 동적으로 확장되며, 메모리 효율을 높이기 위해서 작은 초기 스택 크기를 사용 쓰레드 간 메모리 보호를 위해 보호 공간 (guard page)이 필요

 

 

1-2. 설치와 철거 비용 측면

쓰레드는 OS에서 리소스를 요청하여 생성되며, 종료 시에도 해당 리소스를 반환해야 한다. 이 과정에서 상당한 비용이 발생하며, 쓰레드 풀과 같은 기법을 통해 비용을 최적화한다.

 

고루틴은 Go 런타임에서 관리되며, 생성 및 철거 비용이 매우 적다. 이론적으로 수백만 개의 고루틴을 생성하고 관리할 수 있다.

 

 

1-3. 컨텍스트 스위치 비용 측면

쓰레드가 블로킹되면 다른 쓰레드가 실행을 이어받아야 하며, 이 과정에서 컨텍스트 스위치가 발생한다. 이때 실행 중이던 쓰레드의 모든 레지스터 상태를 저장하고, 새로운 쓰레드로 복구하는 작업이 필요하다.

 

쓰레드 수가 많아질수록 이 과정에서 발생하는 비용이 커지며, 이는 성능 저하로 이어질 수 있다.

쓰레드
16개의 범용 레지스터
PC (Program Counter)
SP (Stack Pointer)
Segment 레지스터
...

 

 

고루틴은 쓰레드와 비교할 때, 훨씬 적은 수의 레지스터(PC, SP, DX)만을 사용하기 때문에 컨텍스트 스위치 비용이 상대적으로 작다.

 

많은 수의 고루틴이 생성되더라도 컨텍스트 스위치 비용이 상대적으로 적기 때문에, 고루틴의 생성 비용은 쓰레드와 비교할 수 없을 정도로 작다.

고루틴
PC (Program Counter)
SP (Stack Pointer)
DX
...

 

 

 

2. 쓰레드 모델

 

위 이미지에서는 세 가지 쓰레드 모델, 즉 N:1, 1:1, M모델을 비교하고 있다. 각각의 모델은 사용자 수준의 스레드와 커널 수준의 스레드 간의 관계를 나타내며, 이러한 모델에 따라 성능과 리소스 활용 방식이 달라진다.

 

Mx1 (M:1)  
설명 여러 사용자 스레드가 하나의 OS 스레드에 매핑되어 동작하는 방식이다.

이 모델에서는 스케줄링이 사용자 수준에서 이루어지며, 커널의 개입이 최소화한다.
장점 컨텍스트 스위칭이 빠르고, 오버헤드가 적다.

하나의 OS 스레드만 사용되기 때문에 리소스 소모가 적다.
단점 멀티코어를 활용할 수 없다.

즉, 여러 코어에서 동시에 실행할 수 없기 때문에, 성능 확장이 제한된다.

 

 

1x1 (1:1)  
설명 각각의 사용자 스레드가 하나의 OS 스레드에 매핑되는 방식이다.

이 모델에서는 스레드마다 독립적인 OS 스레드가 존재하며, 커널이 직접 스케줄링을 담당한다.
장점 멀티코어 활용이 가능하여 병렬 처리가 가능하다.
단점 커널에 의한 컨텍스트 스위칭이 발생하며, 이는 비용이 크고 성능 저하로 이어질 수 있다.

스레드 수가 많아질수록 오버헤드가 증가한다.

 

 

MxN (M:N)  
설명 여러 사용자 스레드가 여러 OS 스레드에 매핑되는 방식이다.

Go의 고루틴 모델이 이 방식을 사용하며, Go Scheduler가 사용자 스레드(고루틴)를 여러 OS 스레드에 효율적으로 분배한다.
장점 컨텍스트 스위칭이 빠르고, 멀티코어 활용이 가능하며, 유연성과 성능의 균형을 이룬다.

수백만 개의 고루틴을 동시에 관리할 수 있을 정도로 확장성이 뛰어나다.
단점 이러한 모델은 스케줄링 알고리즘의 복잡성으로 인해 구현이 복잡하다.

 

 

 

3. G, M, P 구조체

Go Scheduler는 G(Goroutine), M(Machine, OS Thread), P(Processor, 논리 프로세서)라는 구조체를 이용하여 M:N 스레딩 모델을 구현한다.

 

 

G: Goroutine - Go의 동시성 모델에서 가장 기본이 되는 실행 단위다. 고루틴은 가볍고, 시스템 자원을 효율적으로 사용한다.

 

M: Machine (OS Thread) - 고루틴을 실제로 실행하는 OS 스레드다. M은 고루틴이 실행될 수 있는 환경을 제공한다.

 

P: Processor (논리 프로세서) - 고루틴과 OS 스레드 사이를 연결하는 역할을 하며, 고루틴을 실행하기 위한 논리적 컨텍스트를 관리한다. 사실상 P는 고루틴의 컨텍스트를 관리한다고 볼 수 있다.

 

 

P의 개수GOMAXPROCS 환경 변수를 통해 설정할 수 있으며, 기본값은 CPU 코어의 개수에 맞춰 설정된다. 이로 인해 Go는 다수의 고루틴을 멀티코어 환경에서 병렬로 실행할 수 있다.

 

 

 

 

고루틴의 스케줄링 과정
고루틴 생성 새로 생성된 고루틴은 먼저 LRQ(Local Run Queue)에 추가된다.

LRQ가 가득 찬 경우, 고루틴은 GRQ(Global Run Queue)로 이동된다.
LRQ에서 실행 각 P(Processor)는 자신에게 할당된 LRQ에서 고루틴을 선택하여 실행한다.
GRQ에서 할당 만약 P의 LRQ에 더 이상 실행할 고루틴이 없다면, GRQ에서 고루틴을 가져와 실행한다.
시스템 콜 처리 고루틴이 시스템 콜이나 블로킹 작업을 수행할 경우,

M(Machine, OS Thread)는 해당 고루틴을 처리한 후 다시 GRQ로 반환하거나,
Net Poller에서 대기 상태로 전환한다.

 

 

고루틴의 처리 흐름
LRQ로 진입 고루틴이 생성되면, 가장 먼저 해당 P의 LRQ로 들어간다.
LRQ에서 실행 P는 LRQ의 LIFO 구조에 따라 고루틴을 실행합니다. 최근에 들어온 고루틴이 먼저 실행된다.
GRQ로 이동 LRQ가 가득 차거나 P가 실행할 고루틴이 더 이상 없을 경우, 고루틴은 GRQ로 이동되거나, P는 GRQ에서 새로운 고루틴을 가져온다.
시스템 콜 후 처리 고루틴이 시스템 콜을 마치면, 해당 고루틴은 다시 실행 대기 상태로 돌아가며, GRQ 또는 LRQ에 재배치된다.
Net Poller 사용 네트워크 I/O와 같은 블로킹 작업을 수행하는 고루틴은 Net Poller에 의해 관리되며, 작업이 완료되면 다시 스케줄링 큐로 복귀한다.

 

 

3-1. LRQ와 GRQ의 목적

GRQ만을 사용하게 되면 모든 P(Processor)가 동일한 GRQ에 접근하게 되어, 이를 보호하기 위해 mutex(상호 배제)를 사용해야 한다. 이는 고루틴을 생성, 파괴, 스케줄링할 때마다 락이 걸리게 되어 성능 저하를 일으킬 수 있다. 모든 P가 하나의 글로벌 큐를 통해 고루틴을 관리하게 되면, 큐의 경합이 발생하여 병목현상이 일어날 수 있다.

 

LRQ의 필요성
이 문제를 해결하기 위해 Go 스케줄러는 각 P마다 LRQ를 도입하여, 고루틴을 로컬하게 관리할 수 있도록 했다. LRQ를 사용함으로써, P는 자신의 고유한 큐에서 고루틴을 관리하고 실행할 수 있으며, GRQ에 대한 접근이 최소화된다. 이는 전체 시스템의 성능을 개선하고, 경합을 줄이는 데 중요한 역할을 한다.

 

 

위 그림처럼 LRQ는 일반적인 큐와는 조금 다르게, FIFO(First In, First Out)와 LIFO(Last In, First Out)로 구성되어 있습니다.
FIFO는 일반적인 큐 구조로, 먼저 들어온 고루틴이 먼저 나가는 방식이다.
반면, LIFO는 가장 최근에 들어온 고루틴이 먼저 나가는 구조로, 크기가 1로 제한되어 있다.

 

고루틴이 LRQ에 들어올 때는 먼저 LIFO에 들어가며, 실행될 때는 LIFO에서 먼저 꺼내진다. 즉, LIFO에 있는 고루틴이 FIFO에 있는 고루틴보다 우선적으로 실행된다.

 

LIFO가 FIFO보다 우선되도록 설계된 이유는 Locality를 부여하기 위해서다.

예를 들어, 고루틴 A가 실행 중에 고루틴 B를 생성한 경우, B가 빠르게 실행되어 A의 종료를 도울 수 있다면, A의 전체 성능이 향상될 수 있다. 또한, A와 B가 동일한 P에서 실행되면 캐시 및 메모리 자원의 활용 측면에서도 효율적이다.

 

같은 P에서 A와 B가 실행되면, CPU 캐시의 재사용성이 높아지고, 스택 메모리 역시 효율적으로 사용될 수 있다. 이러한 이유로, Go 스케줄러는 고루틴 생성 시 LIFO에 우선적으로 배치하여 바로 실행되도록 하여, 성능 최적화를 도모한다.

 

 

 

어떻게 해결했나

각 고루틴이 동일한 필드에 접근하지 않도록 수정한 후, sync.Mutex를 사용하여 두 고루틴이 동시에 Incident 객체를 수정하지 않도록 보호했다. 이를 통해 한 번에 하나의 고루틴만이 Incident 객체에 접근할 수 있도록 보장했다.

 

각 고루틴이 동일한 필드에 접근하지 않도록 수정하면 굳이 Mutex를 사용하여 동기화를 적용하지 않아도 되지만, 미래에 코드 변경으로 인한 Race Condition이 발생할 수 있는 여지가 있다. 잠재적인 문제를 미리 예방하기 위해 안전망 역할로 Mutex를 사용해 동기화를 적용했다.

 

jiraEscalation 함수와 slackEscalation 함수를 고루틴으로 실행하고, 그 결과를 채널을 통해 수집하도록 구현했다. 또한, 채널로 수집된 결과를 처리할 때도 Mutex를 사용하여 Incident 객체의 수정 작업에서 사이드 이펙트가 발생하지 않도록 개선했다.

 

채널로 수집된 결과를 최종적으로 Incident 객체에 결합하여 Race Condition을 방지하고, 동시에 실행되는 비동기 함수의 결과를 결합하여 Race Condition 오류를 개선했다.

 


출처

https://www.codingexplorations.com/blog/understanding-the-go-concurrency-scheduler-a-deep-dive

 

Understanding the Go Concurrency Scheduler: A Deep Dive — Coding Explorations

Explore the inner workings of the Go concurrency scheduler in our latest blog post. Dive deep into how Go's M:N threading model optimizes CPU utilization, the role of goroutines, and the design principles behind this efficient concurrency management system

www.codingexplorations.com

The Go Memory Model - The Go Programming Language

 

The Go Memory Model - The Go Programming Language

The Go Memory Model Version of June 6, 2022 Introduction The Go memory model specifies the conditions under which reads of a variable in one goroutine can be guaranteed to observe values produced by writes to the same variable in a different goroutine. Adv

go.dev

Data races in Go(Golang) and how to fix them (sohamkamani.com)

 

Data races in Go(Golang) and how to fix them

What is a data race? What causes it? How to fix it?

www.sohamkamani.com

Go/Golang Scheduling — { DEV SWEETER ; } (tistory.com)

 

Go/Golang Scheduling

Go Memory에 이어 이번엔 Go Scheduler입니다 Go는 일반적인 프로그래밍 언어들과는 달리 goroutine이라는 형태를 사용합니다 우리는 goroutine으로 기존의 thread 방식보다 편리하게 concurrency를 구현합니다

syntaxsugar.tistory.com

Different Threading Models — Why I Feel Go Threading Is Better | by Uday Kiran Jonnala | The Startup | Medium

 

Different Threading Models — Why I Feel Go Threading Is Better, Though With Some Limitations

Kernel Scheduling Entity

medium.com