경쟁 조건의 정의
**경쟁 조건(Race condition)**은 여러 스레드가 동시에 같은 공유 자원에 접근하는 상황입니다.
실행 타이밍에 따라 결과가 달라지는 비결정성이 핵심 특징입니다.
경쟁 조건이 무서운 이유
경쟁 조건은 발견이 어렵습니다.
재현이 특히 어렵습니다.
- 재현이 어려운 이유입니다.
- 실행 순서가 매번 달라질 수 있습니다.
- 특정 타이밍에서만 터지는 경우가 많습니다.
- 디버깅이나 로그 추가만으로도 타이밍이 바뀝니다.
한 줄의 코드도 원자적이지 않음
고급 언어의 한 줄 코드는 기계어 수준에서 여러 단계로 분해됩니다.
- 보통 이런 단계로 쪼개집니다.
- 메모리에서 값 읽기
- 연산 수행
- 결과를 메모리에 저장
이 단계들이 다른 스레드의 실행과 섞여(interleave) 실행될 수 있습니다.
그래서 접근 순서가 보장되지 않으면 경쟁 조건이 발생합니다.
경쟁 조건의 전형적인 예시
count++는 한 줄이지만 복합 동작입니다.
- 내부 동작입니다.
count읽기+1연산- 결과를
count에 쓰기
두 스레드가 동시에 수행하면 증가가 누락될 수 있습니다.
- 예시 흐름입니다.
- 스레드 A가
count=0을 읽습니다. - 스레드 B도
count=0을 읽습니다. - A가 1을 저장합니다.
- B도 1을 저장합니다.
- 결과는 2가 아니라 1이 됩니다.
- 스레드 A가
C C++에서 경쟁 조건이 더 쉽게 생기는 이유
C/C++에서는 동기화가 전적으로 개발자 책임인 경우가 많습니다.
언어 차원에서 안전장치를 자동으로 제공하지 않는 영역이 많습니다.
- 더 까다로운 이유입니다.
- 메모리 모델을 잘못 이해하면 “작동하는 것처럼 보이는” 코드가 나옵니다.
- 최적화와 재정렬이 끼어들면 특정 환경에서만 깨질 수 있습니다.
- 데이터 레이스는 기계어 수준에서 드러나기 때문에 원인 추적이 어렵습니다.
스레드가 필요한 이유와 부작용
스레드는 실행의 단위입니다.
병렬 처리와 동시성 처리를 가능하게 합니다.
하지만 동기화 문제와 오버헤드를 동반합니다.
- 대표 오버헤드입니다.
- 스레드 생성 비용
- 스택 등 메모리 영역 할당
- 컨텍스트 스위칭 비용
- 낭비되는 시간과 비용이 발생합니다.
가상 스레드의 핵심 목표
가상 스레드는 컨텍스트 스위칭 비용을 줄이려는 접근입니다.
“많은 스레드를 만들고 싶다”에서 출발하는 개념입니다.
- 메모에 적힌 관찰을 기준으로 정리합니다.
- 하나의 CPU 코어에서 여러 실행 흐름을 번갈아 처리합니다.
- 동시에 실행되는 것처럼 보이지만 실제로는 빠르게 스위칭합니다.
주의할 점도 있습니다.
- 흔한 오해 포인트입니다.
- 가상 스레드가 동기화 문제를 없애는 것은 아닙니다.
- 공유 자원을 건드리면 여전히 경쟁 조건이 발생합니다.
스레드 동기화의 목적
멀티스레드 환경에서 가장 중요한 개념은 동기화입니다.
동기화의 목적은 결국 하나입니다.
공유 메모리를 안전하게 접근하게 만드는 것입니다.
OS 수준 동기화 기제의 큰 분류
대부분의 동기화는 Lock 개념과 직접 연결됩니다.
OS는 다양한 동기화 기제를 제공합니다.
-
Event입니다.
- 스레드 간 신호 전달 용도입니다.
- “일이 끝났음” 같은 상태 변화를 알리는 데 사용합니다.
-
Mutex입니다.
- 상호 배제용 객체입니다.
- 한 시점에 하나의 스레드만 접근하게 합니다.
-
Semaphore입니다.
- 여러 개의 접근을 허용하는 구조입니다.
- 개수가 제한된 자원을 관리할 때 씁니다.
- 커널 객체 기반이라 상대적으로 무겁습니다. (사용 비용이 큰 편이라는 의미입니다)
동기화 기제는 결국 공유 자원 접근 제어
C/C++에서 쓰는 여러 동기화 기제도 같은 목적입니다.
공유 메모리에 대한 접근을 안전하게 만드는 장치입니다.
즉, 대부분은 “락을 어떻게 잡고 풀 것인가”의 변형입니다.
스핀락의 동작 방식
Spin Lock은 별도의 커널 객체가 아닐 수 있습니다.
락이 풀릴 때까지 CPU를 점유한 채 반복문으로 계속 확인합니다.
-
장점입니다.
- 문맥 전환을 줄일 수 있습니다.
- 아주 짧은 임계구역에서는 유리할 수 있습니다.
-
단점입니다.
- 락이 오래 잡히면 CPU 자원을 계속 소모합니다.
- 코어가 바쁜 환경에서는 전체 처리량을 망칠 수 있습니다.
정리
경쟁 조건은 “동시에 접근” 그 자체보다 “순서가 보장되지 않음”이 본질입니다.
한 줄 코드도 기계어 수준에서는 여러 단계로 쪼개집니다.
그 사이에 실행이 섞이면 경쟁 조건이 발생합니다.
동기화 기제는 공유 자원 접근을 통제하기 위한 장치입니다.
가상 스레드는 비용을 줄이는 접근이지만 동기화 문제를 자동으로 해결하지는 않습니다.
- 실행 순서를 바꾸는 주요 요인들입니다.
- CPU 캐시로 인해 최신 값이 아닌 값을 읽을 수 있습니다.
- 컴파일러 최적화로 명령어가 재배치될 수 있습니다.
- JIT 최적화로 재정렬이 더 공격적으로 적용될 수 있습니다.
이 문제를 통제하는 규칙이 happens-before입니다.
happens-before의 의미
happens-before 관계가 성립하면 앞의 쓰기가 뒤의 읽기에 보입니다.
happens-before 관계가 없으면 최신 값 가시성을 기대할 근거가 없습니다.
- 자주 쓰는 happens-before 기준입니다.
- synchronized에서 락 해제는 이후 같은 락 획득보다 먼저입니다.
- volatile write는 이후 volatile read보다 먼저입니다.
- Thread.start() 이전 작업은 새 스레드 시작 이후보다 먼저입니다.
- **Thread.join()**으로 기다린 스레드의 종료 전 작업은 join 이후보다 먼저입니다.
- 클래스 초기화(정적 초기화) 완료는 해당 클래스를 사용하는 스레드에 안전하게 공개됩니다.
일반 변수의 한계
일반 변수는 “동기화 시점”이 정의되지 않습니다.
즉, 메인 메모리 반영 시점과 다른 스레드 관측 시점이 보장되지 않습니다.
- 위험해지는 조건입니다.
- 여러 스레드가 같은 변수를 읽고 씁니다.
- happens-before 관계가 없습니다.
- 명시적 동기화가 없습니다.
스레드 시작과 종료에서의 동기화 포인트
**Thread.start()**는 대표적인 공개 안전 지점입니다.
- start 직전 상태와 start 직후 관측 사이에 규칙이 생깁니다.
- 부모 스레드가 start 이전에 수행한 작업은 새 스레드에 보이도록 정의됩니다.
- 새 스레드는 동기화된 기준으로 값을 로딩하게 됩니다.
다만 “이미 실행 중인 다른 스레드들까지 함께 동기화”되는 보장은 없습니다.
기존 스레드 간 가시성은 별도의 happens-before가 필요합니다.
**Thread.join()**은 종료 기반 동기화 지점입니다.
- join 이후에는 대상 스레드의 종료 전 작업이 현재 스레드에 보이도록 정의됩니다.
volatile의 역할과 장단점
volatile은 메모리 가시성을 제공하는 키워드입니다.
volatile 변수는 캐시 값이 아니라 메인 메모리 기준으로 읽고 쓰도록 강제합니다.
-
volatile의 효과입니다.
- 읽기 시점마다 최신 값이 보이도록 규칙이 걸립니다.
- 컴파일러와 CPU의 재정렬이 제한됩니다.
- happens-before 관계를 만들 수 있습니다.
-
비용과 주의점입니다.
- 메인 메모리 접근이 늘어 성능 비용이 생길 수 있습니다.
- 가시성만 보장하고 원자성은 보장하지 않습니다.
volatile만으로 안전하지 않은 대표 케이스
복합 연산은 원자 연산이 아닙니다.
따라서 volatile만으로 레이스가 막히지 않습니다.
- 대표적인 위험 연산입니다.
count++count += 1x = x + y같은 읽기-계산-쓰기 형태
이 경우 필요한 선택지는 아래입니다.
- synchronized
- Lock
- Atomic 클래스
volatile이 적합한 전형적인 사용처
“한 스레드만 쓰고 여러 스레드가 읽는 구조”에 적합합니다.
즉, 최신 값의 가시성만 확보하면 되는 신호 변수에 적합합니다.
- 대표 사례입니다.
- 종료 플래그
- 상태 플래그
- 구성 리로드 트리거
예시입니다.
- 종료 플래그 예시입니다.
volatile boolean running = true;- 작업 스레드는
while (running)으로 루프를 실행합니다. - 종료 스레드는
running = false로 종료 신호를 보냅니다.
synchronized, Lock, Atomic 선택 기준
동기화 요구사항을 분해하면 선택이 쉬워집니다.
-
synchronized가 적합한 상황입니다.
- 임계구역 보호가 필요합니다.
- 여러 필드의 일관성을 한 덩어리로 보장해야 합니다.
- 구현 단순성이 중요합니다.
-
Lock이 적합한 상황입니다.
- 타임아웃, 공정성, 조건 변수 등 고급 제어가 필요합니다.
- 락 획득과 해제를 더 세밀하게 다루어야 합니다.
-
Atomic이 적합한 상황입니다.
- 단일 변수의 원자 연산이 핵심입니다.
- 카운터, 플래그의 CAS 기반 갱신이 필요합니다.
클래스 로딩과 정적 초기화의 공개 안전성
정적 초기화는 클래스 초기화 과정에서 한 번만 실행됩니다.
그리고 초기화 완료 결과는 다른 스레드에 안전하게 공개되도록 규칙이 잡혀 있습니다.
- 활용 패턴입니다.
- 초기화 지연 홀더 패턴
- 정적 내부 클래스 기반 싱글턴
정적 초기화 이후의 상태 변경은 별개 문제입니다.
정적 필드 변경은 별도의 동기화가 필요할 수 있습니다.
디버깅이 재현성을 깨는 이유
브레이크포인트는 실행을 멈추고 타이밍을 크게 바꿉니다.
그 결과 버그가 사라지는 착시가 생깁니다.
- 흔한 변화 요인입니다.
- 스케줄링 순서 변화
- JIT 최적화 조건 변화
- 특정 시점의 메모리 장벽 효과 동반 가능
“브레이크포인트가 동기화를 보장한다”로 일반화하면 위험합니다. (대개는 타이밍 교란 영향이 더 큽니다)
System.out.println이 동기화처럼 보이는 현상
System.out은 PrintStream입니다.
PrintStream의 많은 메서드는 내부적으로 동기화를 사용합니다.
그래서 println을 넣으면 문제가 줄어드는 것처럼 보일 수 있습니다.
이 방식은 해결책이 아닙니다.
출력 코드는 타이밍을 바꾸는 부작용이 커서 재발 가능성이 높습니다.
결론
멀티스레드에서 확인할 질문은 하나입니다.
이 읽기와 쓰기 사이에 happens-before 관계가 존재하는가입니다.
-
빠른 선택 가이드입니다.
- 가시성만 필요하면 volatile입니다.
- 복합 연산과 일관성이 필요하면 synchronized 또는 Lock입니다.
- 원자 카운터가 필요하면 Atomic입니다.
- 스레드 경계는 start, join으로 규칙을 확보합니다.
-
피해야 할 착시입니다.
- 디버거와 println은 버그를 숨길 수 있습니다.
- 재현이 안 되면 동기화가 된 것이 아니라 환경이 바뀐 것입니다.
레이스 컨디션이 만들어지는 전형적 상황
- 파일 처리 스레드가 큐에 이벤트를 추가합니다.
- UI 스레드가 큐에서 이벤트를 삭제합니다.
- 둘이 같은 시점에 같은 링크를 건드리면 아래가 발생합니다.
- 조회 중에 노드가 끼어듭니다.
- 삭제 중에 노드가 다시 연결됩니다.
- 결과적으로 리스트가 찢어지거나 루프가 생길 수 있습니다.
임계 영역은 어디로 잡아야 하나
-
임계 영역은 “공유 불변식”이 깨지는 구간입니다.
-
연결 리스트의 불변식은 아래처럼 정리할 수 있습니다.
-
불변식 예시
prev.next == thisnext.prev == thishead.next와tail.prev가 항상 일관됩니다.counter가 실제 노드 개수와 일치합니다.
-
즉 아래가 동시에 바뀌는 구간이 임계 영역입니다.
- 링크 갱신
- head tail 갱신
- 길이 카운터 갱신
예제 코드로 보는 레이스 포인트
노드 추가 appendNode
public boolean appendNode(String name) {
UserData newUser = new UserData(name);
newUser.prev = tail.prev;
newUser.next = tail;
tail.prev.next = newUser; // A Start
tail.prev = newUser;
++counter; // A End
return true;
}
- 여기서 위험한 지점은 아래입니다.
tail.prev.next = newUsertail.prev = newUser++counter
- 이 구간은 “작은 것처럼 보여도” 하나의 트랜잭션입니다.
- 중간에 끼어들면 tail 체인이 깨질 수 있습니다.
counter도 같이 깨집니다
counter++는 원자 연산이 아닙니다.- 그래서 동기화 없이 쓰면 값이 틀어질 수 있습니다.
counter는 아래 중 하나가 필요합니다.- 임계 영역 안에서만 갱신
AtomicInteger사용
노드 삭제 removeAtHead
public UserData removeAtHead() {
if (isEmpty()) return null;
UserData node = head.next; // 임계영역 Start
node.next.prev = head;
head.next = node.next;
--counter; // 임계영역 End
return node;
}
- 여기서도 링크 2개와 카운터 1개가 한 묶음입니다.
- 이 도중에 append가 끼면 아래가 나옵니다.
- 삭제한 노드가 다시 참조됩니다.
- head.next가 잘못된 노드를 가리킵니다.
- 리스트가 끊기거나 순환할 수 있습니다.
join이 있을 때는 왜 멀쩡해 보이나
producer.join()을 하면 생산이 끝난 뒤 소비가 시작됩니다.- 그래서 동시에 같은 링크를 건드릴 일이 줄어듭니다.
- 즉 레이스가 사라진 게 아니라 환경이 안전해진 것입니다.
join 없이 동시에 돌리면 왜 터지나
- 생산과 소비가 같은 자료구조를 동시에 만집니다.
- 임계 영역이 없으면 연결 구조가 쉽게 손상됩니다.
- 그래서
counter가 깨지고 구조 자체도 깨질 수 있습니다.
해결 1 synchronized로 감싸기
- 가장 쉬운 개선은 메서드를 통째로 동기화하는 것입니다.
public synchronized boolean appendNode(String name) { ... }
public synchronized UserData removeAtHead() { ... }
- 장점
- 구현이 단순합니다.
- 구조 손상이 거의 사라집니다.
- 단점
- 필요 없는 코드까지 임계 영역이 됩니다.
- 경쟁이 늘면 병목이 커집니다.
해결 2 ReentrantLock으로 임계 영역만 줄이기
- 핵심은 “링크 갱신 구간만” 락으로 묶는 것입니다.
import java.util.concurrent.locks.ReentrantLock;
class LinkedQueue {
private final ReentrantLock lock = new ReentrantLock();
private int counter = 0;
public boolean appendNode(String name) {
lock.lock();
try {
// 링크 갱신 + counter 갱신만 보호
// tail.prev.next = newUser;
// tail.prev = newUser;
// counter++;
return true;
} finally {
lock.unlock();
}
}
public UserData removeAtHead() {
lock.lock();
try {
// 링크 갱신 + counter 갱신만 보호
// head.next = node.next;
// node.next.prev = head;
// counter--;
return null;
} finally {
lock.unlock();
}
}
}
- 장점
- 임계 영역을 줄이기 쉽습니다.
tryLock()같은 선택지도 생깁니다.
- 단점
- 락 해제를 빼먹으면 장애로 이어집니다.
- 설계 난도가 올라갑니다.
CAS Compare And Swap이란 무엇인가
- CAS는 Compare And Swap 또는 Compare And Set입니다.
- 락 없이 원자적으로 바꾸기 위한 핵심 원리입니다.
- CPU가 제공하는 원자 명령을 이용합니다.
- 대표적으로
cmpxchg계열이 유명합니다. (CPU/아키텍처마다 표현은 다릅니다)
- 대표적으로
- CAS는 아래를 “한 번에” 처리합니다.
- 현재 값이 기대값인지 비교
- 기대값이면 새 값으로 교체
- 성공 실패를 반환
왜 CAS가 Lock free를 가능하게 하나
- 임계 영역을 “잠그는 방식”이 아닙니다.
- 대신 “충돌하면 다시 시도하는 방식”입니다.
- 그래서 커널 블로킹을 줄일 수 있습니다.
Spin lock은 무엇인가
- 스핀 락은 락을 얻을 때까지 반복문으로 재시도합니다.
- 반복문은 CPU 시간을 계속 씁니다.
- 그래서 스핀 락은 아래에서만 이득입니다.
- 이득 조건
- 임계 영역이 매우 짧습니다.
- 경쟁 스레드 수가 적습니다.
- 락이 곧 풀릴 가능성이 큽니다.
CAS 기반 SpinLock 예제
- 보통은 직접 구현하지 않습니다.
- 학습 목적이나 특수한 경우에만 구현합니다.
AtomicBoolean + LockSupport.parkNanos 예제
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.LockSupport;
class SpinLock {
private final AtomicBoolean locked = new AtomicBoolean(false);
public void lock() {
while (!locked.compareAndSet(false, true)) {
// 너무 공격적으로 돌면 CPU를 태움
LockSupport.parkNanos(1_000); // 1us 정도 쉬며 재시도
}
}
public void unlock() {
locked.set(false);
}
}
compareAndSet(false, true)가 CAS입니다.parkNanos는 불필요한 CPU 소모를 줄이는 데 도움이 될 수 있습니다.- 단, 값은 상황별로 튜닝이 필요합니다. (정답은 없습니다)
Lock free 큐가 좋은 예가 되는 이유
- 큐는 선형 자료구조입니다.
- 단일 연결 리스트 기반 큐는 핵심 참조가 단순합니다.
- head 참조
- tail 참조
- node.next 참조
- 이 참조 변경에 CAS를 적용하면 lock free 접근이 가능해집니다.
AtomicReference를 쓰는 이유
- 참조자 변경이 “원자적으로” 이뤄져야 합니다.
- 그래서
AtomicReference<Node>로 head나 tail을 감쌉니다. - 핵심은 “참조자 한 칸 교체”를 CAS로 만드는 것입니다.
lock free가 항상 이기나
- lock free는 경합이 적고 임계 영역이 작을 때 유리할 수 있습니다.
- 경합이 심해지면 재시도가 늘어서 비용이 커질 수 있습니다.
- 그래서 어느 순간에는
ReentrantLock과 큰 차이가 없어질 수 있습니다. (상황 의존입니다)
결론
- 멀티스레드에서 큐가 자주 등장하고 자주 깨집니다.
- 연결 리스트는 포인터 갱신이 여러 단계라 레이스에 취약합니다.
- 해결은 단계적으로 접근하는 게 좋습니다.
- 1단계
synchronized로 correctness를 먼저 확보합니다.
- 2단계
ReentrantLock으로 임계 영역을 줄입니다.
- 3단계
- CAS 기반으로 lock free 구조를 검토합니다.
- 임계 영역 판단이 결국 성능과 안정성을 갈라놓습니다.