현대 컴퓨터에서는 CPU가 메모리의 데이터를 사용할 때, 메모리에 곧바로 접근하지 않고 캐시메모리를 활용한다.
또한 운영체제 자체에서 코드를 재정렬해서 효율적으로 구동하려고 하는 경우가 있는데, 이러한 상황들과 멀티쓰레딩 환경이 만나서 발생할 수 있는 여러 문제들이 있다.
어떤 문제가 있고, 어떻게 방지할 수 있는지 알아보자.
코드 재정렬(Reordering)
아래의 공유 클래스를 통하여 thread1 은 increment()를 무한반복하고, thread2는 checkForDataRace()를 무한반복하는 경우에 생긴다.
public static class SharedClass {
private int x = 0;
private int y = 0;
public void increment() {
x++;
y++;
}
public void checkForReordering() {
if (y > x) {
System.out.println("y > x");
}
}
}
논리적인 순서로 생각하면 x++가 y++보다 먼저 실행 될 것이기 때문에 (y > x)가 성립하는 경우는 없을 것이라고 생각된다.
하지만 실제로 코드를 실행시키면 무수히 많은 "y > x"를 출력한다.
그 이유는 운영체제에서 아래 두개의 메소드를 똑같은 논리적 코드로 생각한다.
public void increment() {
x++;
y++;
}
public void increment() {
y++;
x++;
}
즉 자체적으로 성능최적화를 위하여 비순차적으로 실행시켜 버린다. 그러므로 멀티 쓰레드 방식을 사용하는 경우에는 이와 같은 상황을 잘 고려해서 설계/구현 해야한다.
그렇다면 x++; 와 y++;의 실행순서를 보장할 수 있는 방법은?!
volatile 순서보장 기능
바로 volatile 키워드를 사용하는 것이다. java5부터 volatile 키워드는 다양한 기능을 제공하는데 그 중에서 Happens-Before Ordering 특징을 이용할 수 있다.
위의 예제와 같이 쓰레드1 에서는 쓰기기능을 하고(increment()) , 다른 스레드2 에서는 읽기기능을 할 때(checkForDataRace()) , y의 변수에 volatile키워드를 추가해주면 쓰레드1에서 y변수를 쓰기 전에 쓰레드1 에 표시되었던 모든 값들은 y변수를 읽은 후에 쓰레드2 에도 모두 표시된다.
즉 위 예제에서는 y++라는 쓰기 시점 이전에 x++가 있었기 때문에 메모리에 잘 반영이 되고, thread2에서 y > x를 수행할 때 y값이 읽기가 되고 그 이후 x값은 메모리에 반영된 x++된 값을 가져올 수 있다는 것이다. 즉 자연스럽게 volatile 키워드 y++ 앞에있던 값들이 메모리상에 잘 반영되어 순서가 보장된다고 할 수 있다.
public static class SharedClass {
private int x = 0;
private volatile int y = 0;
public void increment() {
x++;
y++;
}
public void checkForDataRace() {
if (y > x) {
System.out.println("y > x");
}
}
}
volatile 가시성 보장
volatile 키워드의 목적은 위의 DataRace 상황에서 해결책으로 보였듯 데이터들을 메모리상에 갱신하여 여러 스레드들이 갱신된 데이터를 볼 수 있도록 가시화 하는 것이다.
프로그램을 실행 시 CPU와 메모리 사이에 캐시를 두어서 빠르게 데이터를 가져올 수 있다. 그러나 멀티 쓰레드 환경에서는 각각의 코어가 각각의 스레드 작업을 수행하고, 각각의 코어는 각각의 캐시메모리를 가지고 있기 때문에 곧 바로 메모리에 최신값을 갱신하지 않고 캐시메모리에 갱신하는 특징이 있다. 혹은 캐시 메모리에 갱신하지 않더라도 쓰기버퍼에 저장해놓고 나중에 한번에 메모리에 갱신하는 경우도 있다. 즉 여기 중요한 점은 한 쓰레드가 공유변수 값을 갱신할 때 곧바로 메모리상에 저장될지 아닐지 모른다는 것이다. (보장이 안됨)
이런 현상 때문에 여러 스레드들이 공유변수에 접근할 때 공유변수의 데이터가 실시간으로 최신화 되지 못하였고 이러한 현상을 해결하는 데에 도움을 주는 키워드가 바로 volatile이다.
공유변수를 volatile 키워드로 선언하면 해당 공유변수는 데이터 값이 갱신될 때 곧바로 메모리에 올리고 읽어올 때도 메모리상에서 바로 읽어온다는 점이 특징이다.
대표적인 예제로 아래와 같은 코드가 있다.
private static int number;
private static boolean ready;
private static class Reader extends Thread {
@Override
public void run() {
while (!ready) {
System.out.println("무한");
}
System.out.println(number);
}
}
public static void main(String[] args) {
new Reader().start();
number = 42;
ready = true;
}
Reader 스레드는 run메서드가 시작되면 ready라는 공유변수의 값이 false인 경우에 무한루프를 돌게 되지만, 해당 main 코드에서 곧바로 ready값으로 true값으로 변경했기 때문에 곧바로 스레드가 종료되는 걸 기대할 수 있다.
그러나 위의 코드에서 생성된 Reader쓰레드는 종료되지 않고 계속해서 실행중인 상태를 확인할 수 있다.
그 이유가 바로 main 메서드에서 ready 값을 true로 변경했을 때 해당 true값이 메모리상에 반영된 것이 아니라 main메서드를 실행시키는 CPU core의 캐시메모리에 저장되었기 때문에, 실제 Reader 스레드는 ready = true 라는 값에 접근할 수 없는 것이다.
기존에 CPU가 메모리에 접근하는 시간이 오래걸리기 때문에 캐시메모리를 사용하게 되었는데, 이러한 장점을 포기하고 volatile키워드를 사용하는 것이기 때문에 멀티쓰레드 환경에서 최소한으로 필요한 경우에 적절히 사용해야할 필요가 있어보인다.
또한 멀티 쓰레드 환경에서 양쪽이 모드 쓰기가 가능한 경우라면 volatile이 아니라 경쟁상태라는 문제가 발생할 수 있으므로 동기화 방법을 고려해야 한다.
<참고자료>
https://www.baeldung.com/java-volatile
자바 병렬 프로그래밍 - https://product.kyobobook.co.kr/detail/S000000935083
'동시성 프로그래밍' 카테고리의 다른 글
모니터와 자바 동기화(synchronized) (0) | 2024.04.06 |
---|---|
[Java] 가상 스레드 (0) | 2024.01.11 |
[동시성 프로그래밍] 동기 vs 비동기 vs Blocking vs Non-blocking (0) | 2024.01.08 |
[자바 멀티쓰레드] 동기화 기법 (0) | 2023.09.07 |
[자바 멀티쓰레드] 경쟁상태와 임계영역 (0) | 2023.09.07 |