1. Volatile : 휘발성 있는 물질
CS에서 말하는 '휘발성 있는' 이란 메인 메모리를 말한다. 파일 정보를 담는 하드디스크와 다르게, 메인 메모리는 전원을 끄면 사라지는 휘발성 정보를 담고 있기 때문이다.
컴퓨터 동작 과정에서, 하드웨어 메모리는 프로세스가 사용하는 메모리가 적재된다. 프로세스의 메모리에는 코드, 데이터 정보 등이 들어있다. 폰 노이만 구조의 컴퓨터는 메모리에서 명령어를 가져와 명령어 코드의 데이터 및 메모리 주소를 참고하여 일련의 프로그램을 수행하게 된다. 메모리는 강조하건데 컴퓨터 동작 과정에서 없어선 안되는 중요한 것이다.
이렇게 중요한 메모리와 관련된 volatile은 어떤 키워드이며, 언제 사용하는 것일까?
2. 가시성(Visible)
visible 문제라는 것이 있다. visible은 '보이는'이라는 의미를 가지는데, 가시성 문제라 함은 이 보이는 것에 문제가 있는 것이다. 컴퓨터가 눈을 가진 것도 아닌데 뜬금없이 가시성?
1) 가시성 문제의 예시
아래 VisibleProblem은 메인 스레드와 thread 스레드가 동작한다. 두 스레드가 공유하는 "static boolean go"라는 변수가 존재한다.
public class VisibleProblem {
static boolean go = false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (!go); // wait until go == true
System.out.println("thread terminated");
});
thread.start();
Thread.sleep(1000);
go = true;
System.out.println("main thread terminated.");
}
}
- thread는 go 변수가 true가 될 때까지 while문을 반복하며 기다린다. go가 true가 되면 반복문을 빠져나오며, "thread terminated"를 출력하며 종료된다.
- main 스레드는 thread를 시작시키고 1초간 sleep한다. 그 뒤 공유하는 go 변수를 true로 만들고 "main thread terminated"를 출력하며 종료된다.
일반적으로 생각하기엔 별 문제 없어 보이는 코드이다. 어떤 스레드가 먼저 종료될지는 몰라도, 어쨌든 terminate를 보장하는 프로그램으로 보인다. 하지만 실행시켜보면, 결과는 이상하다.
일단 "main thread termiated" 결과가 출력된걸로 보아 메인 스레드는 종료된 것 같다. 근데 "thread terminated"는 어디에? 게다가 메인 메소드가 실행된지 14초가 지났음에도 프로그램이 계속 실행중이다.
2) 문제의 원인
컴퓨터는 프로그램의 실행 속도를 조금이라도 더 빨리 하기 위해 CPU 캐시를 사용한다. CPU의 속도에 비해 RAM에 접근하는 속도는 상당히 느리기 때문에, 프로그램 내에서 자주 사용하는 메모리 값은 접근 속도가 조금 더 빠른 CPU 캐시로 불러오게 된다.
locality 덕에 캐시를 이용하면 프로그램 성능이 훨씬 향상되는데, 그것은 캐시와 관련된 사항이므로 이 포스트에선 넘어가기로 하고. 앞선 가시성 문제의 예시를 생각해보면, 공유변수 "go"가 각 프로세스가 동작하는 CPU의 캐시 메모리에 따로 저장되어 있기 때문으로 생각할 수 있다. 그림을 그려보면 아래와 같다.
- thread는 최초에 false값인 go 변수를 참조한다. while문을 돌며 계속 go 값을 확인하고 있기 때문에 thread 스레드가 사용하는 캐시 메모리에 해당 변수값을 두고 사용했을 것이다.
- 메인 스레드는 같은 go 변수를 가져와 true를 write한다. 그러나 메인 스레드가 사용하는 CPU 캐시 메모리에 해당 값을 write했든, RAM에 직접 데이터를 write했든 thread 스레드엔 업데이트된 go 값이 전달되지 않을 것이다.
- thread 스레드가 참조하는 go 변수는 thread 스레드가 사용중인 캐시 메모리이기 때문이다.
이것이 가시성 문제이다. 스레드가 자신의 캐시 메모리만을 보기 때문에 다른 스레드의 캐시 메모리, 혹은 RAM에 있는 값이 "보이지 않는다"라고 해서 visible이라고 표현을 한 것이다.
3. volatile 키워드 : 메인 메모리를 참조하라!
이를 해결하기 위해 volatile 키워드를 사용한다. 위와 100% 똑같은 코드의 go 변수에 "volatile" 키워드를 추가한 뒤 실행시켜보면 우리가 원하는 결과가 잘 나온다.
main thread terminated.
thread terminated
volatile은 변수의 가시성을 보장한다. JVM으로 하여금 해당 키워드가 붙은 변수를 메인 메모리로부터 읽고, 메인 메모리에 쓰도록 한다. 이렇게 되면 캐시 불일치로 인해 생기는 가시성 문제를 해결할 수 있다.
스레드가 volatile 변수에 값을 쓰면 메인 메모리로 flush되고, 스레드가 volatile 변수 값을 참조할 땐 메인 메모리에 접근하는 것을 강제한다. 추가로, volatile 변수의 접근점을 넘어가는 코드의 최적화는 일어나지 않는다. 이는 설명하면 길어지므로 링크 참조!
4. 스레드간 협업도 가능
synchronized 키워드를 정리할 때, wait()과 notify()를 이용해 스레드의 동작 순서를 조절하는 예시를 살펴봤다. volatile을 이용해서 이와 비슷한 기능을 구현할 수 있다.
스레드가 공유하는 Data 객체를 작성하자.
public class Data {
private static volatile boolean sent = false;
private static String data;
public static void send(String data) {
while (sent) Thread.onSpinWait();
Data.data = data;
sent = true;
}
public static void receive() {
while (!sent) Thread.onSpinWait();
System.out.println("receiver 스레드가 받은 data : " + Data.data);
sent = false;
}
}
- send()는 sent가 false여야만, receive()는 sent가 true여야만 while문을 빠져나와 동작할 수 있다.
- send()는 data 필드에 인자로 들어온 문자열 값을 저장한 후 보냈다는 고지로 sent값을 true로 놓는다.
- receive()는 data필드를 받았다는 출력을 한 뒤 다시 sent값을 false로 놓는다.
위 Data를 사용하는 Example 클래스를 작성했다. sender 스레드는 send()를, receiver 스레드는 receive()를 호출한다.
public class CoOpExample {
public static void main(String[] args) {
Thread sender = new Thread(() -> {
Data.send("스레드가 보낸 데이터");
});
Thread receiver = new Thread(Data::receive);
receiver.start();
sender.start();
}
}
만약 sent가 volatile 변수가 아니라면 sender 스레드와 receiver 스레드가 각자의 캐시 속 sent 변수를 참조하기 때문에 가시성 문제가 발생할 수 있다. receiver가 참조하는 sender 변수가 캐시 상에선 계속 false이므로 루프를 빠져나오지 못할 수도 있다는 말이다.
4. 하자가 명백함
역시, 명시적으로 JVM에게 "이렇게 하세요"라고 명령하는 만큼 volatile 키워드는 성능 하자를 동반한다. 이는 컴퓨터가 캐시 메모리를 사용하는 이유와 동일하다.
1) 캐시보다 느리다!
캐시 접근 속도와 메모리 접근 속도는 큰 차이가 난다(대략 N배). 캐시를 사용하면 1초만에 접근 가능했던 변수가 메인 메모리 변수 접근 시마다 cache fault가 발생하는 것과 마찬가지이므로, 캐시를 사용하는 것에 비해 훨씬 느린 동작 시간을 갖는다. 따라서 정말 가시성의 문제를 해결해야 할 때만 최소로 사용해야한다. 실제로 사용되는 예시는 거의 본 적이 없는 것 같다..
2) Out Of Order X
또한 데이터의 가시성 때문에 volatile 변수는 해당 변수를 참조하는 부분 기준으로 코드의 재정리를 막는다. data dependency 문제가 없는 범위 내에서, JVM은 프로그램의 성능을 향상시키기 위해 OoO Execution을 사용한다. 더 빨리 실행될 수 있는 방향으로 코드의 순서를 바꾸는 과정이다.
앞서 스레드간 협업의 예시에서, 코드의 순서가 아래와 같이 바뀌면 어떻게 될까?
public void send(String data) {
while (sent);
// 아래 두 줄 순서 바뀜
sent = true;
this.data = data;
}
sender 스레드에 의해 data가 새로 써지기도 전에 receiver 스레드가 data를 읽을 수도 있다. 일반적인 경우에, sent와 data는 서로 다른 변수이고 data dependency가 존재하지 않아 위와 같이 코드의 재정리가 일어날 수도있다.
그렇게 되면 volatile의 의미가 없어진다! 따라서 JVM은 최적화를 위해 코드를 재정리할 때, volatile 변수에 접근하는 부분을 기준으로 코드의 순서를 바꾸지 않는다. sent값을 읽거나 쓰는 코드의 전에 있던 코드가 뒤로 간다거나, 반대의 경우도 불가하다는 소리이다.
이와 같은 특징을 "happens before guarantee"라고 한다.
동작 순서를 빠르게 하기 위한 코드 재정리에 제약이 생긴다.. 역시 성능 저하를 일으킬 것이다.
volatile은 가시성을 보장해주는 대신 synchronized와 마찬가지로 성능상 큰 하자를 불러일으킨다. 웬만해선 안쓰느니만 못하는 결과를 불러일으키므로, 정말로 사용해야 할 때만 신중하게 사용하는게 좋을 것이다.
REFERENCE
https://steady-coding.tistory.com/555
https://parkcheolu.tistory.com/16