공유 중인 가변 데이터는 동기화해 사용하라
동기화의 기능
배타적 실행
synchronized 키워드는 해당 메서드나 블록을 한번에 한 스레드씩 수행하도록 보장한다. 많은 프로그래머가 동기화를 배타적 실행, 즉 한 스레드가 변경하는 중이라서 상태가 일관되지 않은 순간의 객체를 다른 스레드가 보지 못하게 막는 용도로만 생각한다. 즉, 객체를 하나의 일관된 상태에서 다른 일관된 상태로 변화시킨다.
스레드 사이 안정적인 통신
동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수 있다. 동기화는 일관성이 깨진 상태를 볼 수 없게 하는 것은 물론, 동기화된 메서드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다.
언어 명세상 long과 double 외의 변수를 읽고 쓰는 동작은 원자적이다. 여러 스레드가 같은 변수를 동기화 없이 수정하는 중이라도, 항상 어떤 스레드가 정상적으로 저장한 값을 읽어옴을 보장한다.
자바 언어 명세는 스레드가 필드를 읽을 때 항상 수정이 완전히 반영된 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 보이는가는 보장하지 않는다. 동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다. 이는 한 스레드가 만든 변화가 다른 스레드에게 언제 어떻게 보이는지를 규정한 자바의 메모리 모델 때문이다.
스레드를 멈추는 작업을 통한 동기화 예제
스레드는 자신의 boolean 필드를 폴링하면서 그 값이 true가 되면 멈춘다. 이 필드를 false로 초기화해놓고, 다른 스레드에서 이 스레드를 멈추고자 할 때 true로 변경하는 식이다. boolean 필드를 읽고 쓰는 작업은 원자적이라 어떤 프로그래머는 이런 필드에 접근할 때 동기화를 제거하기도 한다.
잘못된 코드
public class StopThread {
private static boolean stopRequested;
public static void main(String[] args)
throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested)
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
동기화하지 않으면 메인 스레드가 수정한 값을 백그라운드 스레드가 언제쯤 볼지 보증할 수 없다. OpenJDK 서버 VM이 끌어올리기(hoisting)라는 최적화 기법을 사용하게 되는데, 그 결과 프로그램은 응답 불가 상태가 된다.
// 원래 코드
while(!stopRequested)
i++;
// VM에 의해 최적화 코드
if(!stopRequested)
while(true)
i++;
동기화 사용
public class StopThread {
private static boolean stopRequested;
private static synchronized void requestStop() {
stopRequested = true;
}
private static synchronized boolean stopRequested() {
return stopRequested;
}
public static void main(String[] args)
throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested())
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
requestStop();
}
}
쓰기 메서드와 읽기 메서드 모두를 동기화했다. 쓰기와 읽기 모두가 동기화되지 않으면 동작을 보장하지 않는다.
사실 이 두 메서드는 단순해서 동기화 없이도 원자적으로 동작한다. 이 코드에서는 동기화의 두 기능 중 통신 목적으로만 사용된 것이다.
volatile 필드 사용
public class StopThread {
private static volatile boolean stopRequested;
public static void main(String[] args)
throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested)
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
반복문에서 매번 동기화 비용이 드는 코드보다 더 빠르다. volatile 한정자는 배타적 수행과는 상관없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다. volatile은 동기화의 두 기능 중 통신 쪽만 지원한다.
java.util.concurrent.atomic
volatile은 동기화의 두 기능 중 통신 쪽만 지원하지만 이 패키지는 원자성(배타적 실행)까지 지원한다. 더구나 성능도 동기화 버전보다 우수하다.
private static final AtomicLong nextSerialNum = new AtomicLong();
public static long generateSerialNumber() {
return nextSerialNum.getAndIncrement();
}
동기화 문제를 피하는 방법
동기화 문제들을 피하는 가장 좋은 방법은 애초에 불변 데이터만 공유하거나 아무것도 공유하지 말아야 한다. 가변 데이터는 단일 스레드에서만 쓰자.
한 스레드가 데이터를 다 수정한 후 다른 스레드에 공유할 때는 해당 객체에서 공유하는 부분만 동기화해도 된다. 그러면 그 객체를 다시 수정할 일이 생기기 전까지 다른 스레드들은 동기화 없이 자유롭게 값을 읽을 수 있다. 이런 객체를 사실상 불변이라 하고 다른 스레드에 이런 객체를 건네는 행위를 안전 발행이라 한다. 객체를 안전하게 발행하는 방법은 많다. 클래스 초기화 과정에서 객체를 정적 필드, volatile필드, final필드, 혹은 보통의 락을 통해 접근하는 필드에 저장해도 된다. 그리고 동시성 컬렉션에 저장하는 방법도 있다.
여러 스레드가 가변 데이터를 공유한다면 그 데이터를 읽고 쓰는 동작은 반드시 동기화해야 한다. 공유되는 가변 데이터를 동기화하는데 실패하면 응답 불가 상태에 빠지거나 안전 실패로 이어질 수 있다. 이는 디버깅 난이도가 가장 높은 문제에 속한다.
Comments