개요
멀티 스레드를 사용하는 환경에서 각 스레드가 공유 자원에 동시에 접근하는 상황이라면 경쟁상태(Race condition)가 발생할 수 있습니다. 경쟁상태가 발생하게 되는 원인은 가시성(Visibility)과 원자성(Mutual Exclusion)을 보장하지 못했기 때문인데요, Java에서는 synchronized 키워드와 Atomic Type, Concurrent Collection 등을 통해 이와 같은 동시성 문제를 해결할 수 있습니다.
본 포스팅에서는 멀티 스레드 환경에서 발생할 수 있는 문제에 대해 설명하고 이를 해결할 수 있는 sychronized, volatile 키워드와 Atomic Type에 대한 내용을 다루겠습니다.
가시성 문제
가시성 문제란, 여러 개의 스레드가 사용됨에 따라, CPU Cache Memory와 RAM의 데이터가 서로 일치하지 않아 생기는 문제를 의미합니다.
CPU가 어떤 작업을 처리하기 위해 데이터가 필요할 때, CPU는 RAM의 일부분을 고속의 저장 장치인 CPU Cache Memory로 읽어들입니다. 이 읽어들인 데이터로 명령을 수행하고, RAM에 저장하기 위해서는 데이터를 CPU Cache Memory에 쓴 다음 RAM에 쓰기 작업을 수행합니다.
그러나 CPU가 캐시에 쓰기 작업을 수행했다고 해서 바로 RAM으로 쓰기 작업을 수행하지는 않습니다. 반대로 읽기 작업도 해당 데이터가 RAM에서 변경이 되었다고 해도, 언제 CPU Cache Memory가 아닌 RAM에서 데이터를 읽어 들여서 CPU Cache Memory를 업데이트할 지 보장하지도 않습니다.
따라서, 동시성 프로그래밍에서는 CPU와 RAM의 중간에 위치하는 CPU Cache Memory와 병렬성이라는 특징때문에 다수의 스레드가 공유 자원에 접근할 때 가시성 문제가 발생할 수 있습니다.
이를 해결하기 위해서는 가시성이 보장되어야 하는 변수를 CPU Cache Memory가 아니라 RAM에서 바로 읽도록 보장해야 하는데요.
이때 변수에 valatile 키워드를 붙임으로써 가시성을 보장할 수 있습니다.
//Java
private static volatile boolean isStop;
//Kotlin
@Volatile
private var boolean isStop;
volatile을 사용하면 RAM에 접근해서 실제 값을 읽어오도록 함으로써 캐시사용으로 인한 데이터 불일치를 막고 가시성을 보장할 수 있습니다.
그러나 가시성만 보장된다고 동시성이 보장되는 것은 아닙니다.
간단한 예를 살펴 보겠습니다.
같은 계좌를 이용하는 A와 B라는 이용자가 있다고 가정하겠습니다. 동일한 시간에 A는 카드를 이용해 상품을 결제했고, B는 은행 어플을 통해 계좌이체를 했습니다. 대략의 플로우를 상상해보면 아래와 같습니다.
A와 B는 같은 시간에 잔액을 조회했습니다.
A는 40,000원을 결제하여 계좌에 남은 잔액인 60,000원을 반영하였습니다.
같은 시각, B는 20,000원을 계좌 이체하였습니다.
하지만, B의 처리 속도가 약간 늦어져 A가 계산된 금액 반영을 한 뒤에야 B의 계산된 금액을 반영했습니다.
결과적으로, 한 계좌에서 각각 40,000원과 20,000원이 결제되었으니 잔액은 40,000원이 남아야하지만, 현재 80,000원이 남은 상태가 됩니다.
이는 계좌라는 같은 자원에 여러 사람이 동시에 접근하기 때문에 발생하는 문제입니다.
위 사례를 보며 알 수 있듯이 가시성이 보장된다고 동시성이 보장되는 것은 아닙니다.
volatile 키워드는 어디까지나 volatile 변수를 메인 메모리로부터 읽을 수 있게 해 주는 것이 전부이고, 다른 스레드에 의해 이 값이 언제든 바뀔 수 있습니다.
즉, 가시성이란 공유 데이터를 읽는 경우의 동시성만 보장하는 것이라 생각하면 됩니다.
원자성 문제
여러 스레드가 공유 자원에 동시에 쓰기 연산을 할 경우 잘못된 결과를 반환하는 것을 의미합니다.
원자성은 가시성과 멀티 스레드 환경에서 스레드간 공유 메모리 이슈를 발생한다는 점에서 공통점이 있습니다. 하지만 시스템 관점에서 보면 두 개념은 다릅니다.
가시성
- CPU - Cache - Memory 관계 상의 개념
원자성
- 한 줄의 프로그램 문장이 컴파일러에 의해 기계어로 변경되면서, 이를 기계가 순차적으로 처리하기 위한 여러 개의 Machine Instruction이 만들어져 실행되기 때문에 일어나는 현상
- 예를 들어 프로그램 언어적으로 i++ 문장은 다음과 같은 기계가 수행하는 명령어로 쪼개집니다.
- i를 메모리로부터 읽는다.
- 읽은 값에 1을 더한다.
- 연산한 값을 메모리에 저장한다.
- 멀티 스레드 환경에서는 한 스레드가 각 기계 명령어를 수행하는 동안에 다른 스레드가 개입하여 공유 변수에 접근하여 같은 기계 명령어를 수행할 수 있으므로 값이 꼬이게 됩니다. (race condition)
가시성은 메모리 가시성이라고 하면 좀더 쉽게 와닿을 것이고, 원자성 역시 연산의 원자성이라고 하면 좀 더 이해가 쉽습니다.
- volatile: 행위의 타겟(변수)에 대한 동기화
- synchronized: 행위(메서드 및 블럭)에 대한 동기화
원자성 문제를 해결하기 위해서는 synchronized 또는 atomic을 사용해야 합니다.
참고로 원자성 문제를 synchronized 또는 atomic을 통해 해결한다면 가시성의 문제도 해결됩니다.
synchronized 블럭을 들어가기 전에 CPU Cache Memory와 Main Memory를 동기화 해주며, atomic의 경우에는 CAS 알고리즘에 의해 원자성 문제와 CPU Cache Memory에 잘못된 값을 참조하는 문제를 동시에 해결해주기 때문입니다.
Java의 동시성 이슈 해결 방법
synchronized
synchronized는 멀티 스레드 환경에서 동시성 제어를 위해 공유 객체를 동기화하는 키워드입니다. synchronized 블록 안에서 관리되는 자원들은 원자성을 보장할 수 있습니다.
하지만, 모든 동시성을 제어하기 위해 synchronized를 모든 메서드에 다는 행위는 성능에 심각한 영향을 미칠 수 있습니다.
한 스레드에서 synchronized 메서드를 호출하고 있다면 다른 스레드들에서는 해당 인스턴스의 모든 synchronized 메서드를 호출하기 위해 모두 대기(Block)하는 상황이 오게 됩니다.
스레드가 synchronized 메서드를 실행하는 경우 자동으로 lock을 획득하고, 메서드가 반환될 때 lock을 해제하는 과정을 거칩니다.
lock은 인스턴스 단위로 적용되며 한 스레드가 lock을 획득한 상태에서 다른 스레드가 해당 lock을 획득하는 것은 불가능합니다. (synchronized는 static 메서드 등에도 적용할 수 있는데, 이 경우에는 인스턴스가 아닌 Class object에 적용된다.)
아래 그림에서 Thread 1은 a(), Thread 2는 b(), Thread 3은 c() 메서드를 호출해야한다고 가정하겠습니다.
하지만 Thread 1이 lock을 획득한 순간 Thread 2, Thread 3, ...는 모두 lock이 해제될 때까지 기다리게 됩니다.
atomic
atomic 또한 멀티 스레드 환경에서 원자성을 보장하기 위해 나온 개념입니다.
대기(blocking)가 아닌, CAS(Compared And Swap)라는 알고리즘으로 작동하여 원자성을 보장합니다.
CAS 알고리즘이란 volatile에서 설명했던 CPU Cache Memory와 RAM을 비교하여 일치한다면 CPU Cache Memory와 RAM에 적용하고, 일치하지 않는다면 재시도함으로써 어떠한 스레드에서 공유 자원에 읽기/쓰기 작업을 하더라도 원자성을 보장한다.
대표적인 예로, 자바의 Concurrent 패키지의 타입들은 CAS 알고리즘을 이용해 원자성을 보장한다.
❓ synchronized vs Atomic 성능 차이
위의 테스트 코드를 기준으로 스레드의 개수를 늘리며 테스트해보았습니다. 테스트할 때마다 속도가 조금씩 달라지지만 Atomic이 더 빠르고 스레드가 많으면 많을수록 편차는 더 커졌습니다.
스레드 개수 | synchronized | Atomic |
10개 | 16 | 15 |
10,000개 | 3886 | 2735 |
참고자료
https://steady-coding.tistory.com/554
[Java] Java의 동시성 이슈
java-study에서 스터디를 진행하고 있습니다. 동시성 프로그래밍에서 발생할 수 있는 문제점 컴퓨터의 CPU와 RAM의 관계도를 그려보면 다음과 같은 그림이 될 것이다. CPU가 어떤 작업을 처리하기 위
steady-coding.tistory.com
[Java] 멀티 스레드 환경에서 발생할 수 있는 동시성 이슈와 해결 방법
개요 멀티 스레드를 사용하는 환경에서 각 스레드가 공유 자원에 동시에 접근하는 상황이라면 경쟁상태(Race condition)가 발생할 수 있습니다. 경쟁상태가 발생하게 되는 원인은 가시성(Visibility)과
rachel0115.tistory.com
https://yeonyeon.tistory.com/291
[Java] 동시성 이슈 해결하기 (1)
같은 계좌를 이용하는 A와 B라는 이용자가 있다고 가정한다. 동일한 시간에 A는 카드를 이용해 상품을 결제했고, B는 은행 어플을 통해 계좌이체를 했다. 대략의 플로우를 상상해보면 아래와 같
yeonyeon.tistory.com
'Java' 카테고리의 다른 글
[Java] 동시성 문제 해결을 위한 synchronized 키워드 (0) | 2025.02.11 |
---|---|
[Java] 가시성 문제 해결을 위한 volatile 키워드 (0) | 2025.02.11 |
[Java] 제네릭(Generic) 이란? (0) | 2025.01.31 |
[Java] 스레드 생명주기와 스케줄링 (0) | 2025.01.28 |
[Java] Throwable vs Error vs Exception 그리고 예외 처리 전략 (0) | 2025.01.14 |