-
Notifications
You must be signed in to change notification settings - Fork 0
Description
section: (11장 아이템 78, 79)
🍵 서론
동시성 문제는 양날의 칼이다. 애초에 자바라는 언어의 저변에 깔려있는 개념이기도 하고 넓게 활용되고 있는 멀티코어 프로세서의 성능을 십분 발휘하기 위해선 동시성 프로그래밍은 꼭 넘어야 할 산이다.
다만, 상당히 어렵다. 디버깅이.
문제가 발생한 부분은 확률적으로 문제를 관찰할 수 있고 문제를 재현하기도 어려운 상황이 생긴다. 말만 들어도 어지럽다. 애초에 이런 일이 발생하지 않도록 제대로 알고 써야하지 않겠나? 하지만 어려워 보여도 걱정하지 말자. 나 뿐만 아니라 내 뒤로 믿을 만한 조원이 동시성을 도와줄 것이다.

우선 나부터 동기화에 대해서 알아보며 시작한다.
🌒 본론
synchronized
Java에서 synchronized 키워드는 멀티스레딩 환경에서 스레드 간의 동기화를 제공하는 데 사용된다. synchronized 키워드를 사용하면 특정 메서드나 코드 블록에 대한 동기화를 설정하여 여러 스레드 간의 안전한 데이터 접근을 보장할 수 있습니다. 활용법은 메서드에 한정자로 붙여 사용하거나 특정 코드 블록을 동기화하는, 크게 두가지 방법이 있다.
public synchronized void synchronizedMethod() {
// 동기화된 메서드 내용
}public void someAction() {
// 비동기화 코드
synchronized (lockObject) {
// 동기화된 블록 내용
}
// 비동기화 코드
}메서드나 블록을 단위로 삼고 각 단위가 실행하는 동안 다른 스레드는 접근하지 못하고 대기하게 한다.
책에서 서술하는 동기화의 관점은 두 가지이다.
- 한 스레드가 변경 작업을 수행하는 중이라 다른 스레드는 그 순간 상태를 바라보지 못하게 막는다.
- 한 스레드가 만들어낸 변경의 최종본을 공유하게 해준다. 이로써 스레드 사이의 안정적인 통신을 지킨다.
책에 너무 좋은 예시가 나온다.
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;
}
}boolean 값을 변경하는 작업은 원자적이라 변경 작업을 바라볼 수 있는 스레드는 없을 것이라 동기화를 사용하지 않아도 되겠다고 생각한 헛똑똑이 최세은 양은 위 코드를 실행시키고 멈추지 않는 자바 애플리케이션을 보며 소주가 땡길 것이다.
JVM의 최적화, "hoisting"
Hoisting은 변수의 값을 반복문 외부로 끌어올려서 반복문 내에서 중복 계산을 피하고, 성능을 향상시키는 것을 목적으로 한다. 그러나 이 최적화가 변수에 대해 적용되는 경우, 메모리 가시성 문제가 발생할 수 있다. 이는 변수가 여러 스레드 간에 공유되고 있을 때, 한 스레드가 변경한 변수의 값을 다른 스레드가 인지하지 못하는 문제를 의미한다.
// 원래 코드
while (!stopRequested)
i++;
// 최적화한 코드
if (!stopRequested)
while (true)
i++;예를 들어, 주어진 코드에서 stopRequested 변수를 hoisting 최적화가 적용된다면, while (!stopRequested) 부분이 반복문 외부로 빠져나가서 stopRequested 변수를 계속해서 읽게 된다. 이로 인해 backgroundThread 는 stopRequested 변수의 변경을 인지하지 못하고 계속해서 무한 루프를 수행할 수 있다.
원인은 물론 “동기화의 미사용”이다. 동기화하지 않았기 때문에 메인 스레드가 수정한 stopRequested값을 backgroundThread가 언제쯤 보게 될지 보장할 수 없다. 위 상황은 stopRequested 변수를 동기화해 접근하여 바로 해결 가능하다.
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();
}
}hoisting 최적화 때문에 read와 write 계산이 모두 동기화되어야 한다는 점에 주목하자. 또한 주의해야 할 것이 위 예시코드는 연산이 애초에 원자적으로 이루어지기 때문에 동기화의 관점 2에만 주목한 코드이다.
반복문 내에서 매번 동기화된 메서드를 호출하는게 마음에 걸리면 volatile 한정자를 사용하자. volatile 한정자는 배타적 수행과 관계없이 한상 가장 최근에 기록된 값을 읽는 것을 보장하는 한정자이다. 이론적으로 CPU 캐시가 아닌 컴퓨터의 메인 메모리로부터 값을 읽어온다. 그렇기 때문에 읽기/쓰기 모두가 메인 메모리에서 수행되기 때문에 가능한 구현이다. volatile을 쓰면 메인 메모리 한곳을 바라보므로 안정적이다. 물론 값을 읽고 쓰는 비용은 더 높다.
게임 프로그램은 FPS(초당 프레임)가 높고 각각의 쓰레드가 끊임없이 돌아가므로 메인 메모리와 로컬 메모리의 복제의 동시성을 잃고 불일치가 일어날 수 있다. 특히 무한루프의 on/off와 관련된 변수는 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을 얻은 최세은 양은 두번째 억까를 당한다.
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}동시성 문제가 일어날 의심조차 없어 보이는 이 식은 믿었던 증가 연산자에 의해 배신을 당한다. 원자적으로 보이는 이 연산은 사실 nextSerialNumber를 두 번 호출한다. 따라서 첫번째 스레드가 증가 연산자로 값을 변경하고 있는 와중에 두번째 스레드가 사이를 억지로 비집고 들어가 채가면 동시성이 깨지게 된다.
이처럼 잘못된 결과를 계산해놓고 에러를 던지지 않는 오류를 안전 실패(safety failure) 라고 한다. 이 문제는 메서드에 synchronized를 붙이고 volatile 키워드를 공유 필드에서 제거하면 해결된다. 또는 java.util.concurrent.atomic 패키지(item.59)에는 락 없이도 thread-safe한 클래스를 제공한다. volatile은 동기화의 효과 중 통신 쪽만 지원하지만 이 패키지는 원자성(배타적 실행)까지 지원한다. 게다가 성능도 동기화 버전보다 우수하다.
private static final AtomicLong nextSerialNum = new AtomicLong();
public static long generateSerialNumber() {
return nextSerialNum.getAndIncrement();
}- 애초에 가변 데이터는 공유하지 말고 단일 스레드에서만 사용하자.
- 위 사실을 문서화하여 정책으로 유지하자.
- 프레임워크와 라이브러리를 깊이 이해하자.
여기까지 읽어놓고 머리가 띵해진 김규현 군은 정신이 혼미해지는 것을 느낀다. ‘아니 김정욱이 synchronized 쓰지 말랬다니까?’

저자도 우리들의 신과 같은 이야기를 시작한다. 과도한 동기화는 개발자의 죄악이다.
동기화된 영역 안에서 재정의 가능한 메서드를 호출하거나, 클라이언트가 넘겨준 함수 객체를 호출하면 재앙이 벌어진다.
public class ObservableSet<E> extends ForwardingSet<E> {
public ObservableSet(Set<E> set) {
super(set);
}
private final List<SetObserver<E>> observers = new ArrayList<>();
public void addObserver(SetObserver<E> observer) {
synchronized(observers) {
observers.add(observer);
}
}
public boolean removeObserver(SetObserver<E> observer) {
synchronized(observers) {
return observers.remove(observer);
}
}
private void notifyElementAdded(E element) {
synchronized(observers) {
for (SetObserver<E> observer : observers)
observer.added(this, element);
}
}
@Override
public boolean add(E element) {
boolean added = super.add(element);
if (added)
notifyElementAdded(element);
return added;
}
@Override
public boolean addAll(Collection<? extends E> c) {
boolean result = false;
for (E element : c)
result |= add(element); // notifyElementAdded 호출
return result;
}
}
----------------------------------------------------------------------------
@FunctionalInterface
public interface SetObserver<E> {
// ObservableSet에 원소가 추가되면 호출된다.
void added(ObservableSet<E> set, E element);
}다음은 Set을 감싸고 있는 래퍼 클래스이고 옵저버 패턴을 적용하여 원소가 추가되면 알림을 받을 수 있다. 코드를 스을쩍 들여다 보고 있던 김정욱 군은 문제가 될 만한 곳을 바로 찾았다.
public static void main(String[] args) {
ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());
set.addObserver(new SetObserver<>() {
public void added(ObservableSet<Integer> s, Integer e) {
System.out.println(e);
if (e == 23)
s.removeObserver(this);
}
});
for (int i = 0; i < 100; i++)
set.add(i);
}우리들의 신: 이 코드는 i가 23일 때, 에러를 뿜을 것이다… 믿어라.

이유는 관찰자의 added 메서드 호출이 일어날 때, notifyElementAdded가 관찰자들의 리스트를 순회하는 중이기 때문이다.
added 메서드에서 removeObserver 메서드를 호출하고, 이 메서드가 다시 observers.remove 메서드를 호출하는데 리스트에서 원소를 제거하려고 보니까 이미 이 리스트는 다른 곳에서 순회중이다. 즉, 허용되지 않은 동작이 발생했고 에러를 뿜을 것이다.
notifyElementAdded 메서드에서 수행하는 순회는 동기화 블럭 안에 있어 안심했지만, 정작 본인 클래스가 콜백을 거쳐 되돌아와 수정하는 것까지 막을 수는 없었다.
한가지 더있다. 힘내자.
public static void main(String[] args) {
ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());
set.addObserver(new SetObserver<>() {
public void added(ObservableSet<Integer> s, Integer e) {
System.out.println(e);
if (e == 23) {
ExecutorService exec = Executors.newSingleThreadExecutor();
try {
// 메인이 lock 소유
// 특정 작업이 완료되기를 기다린다. (submit의 get 메서드)
exec.submit(() -> s.removeObserver(this)).get();
} catch (ExecutionException | InterruptedException ex) {
throw new AssertionError(ex);
} finally {
exec.shutdown();
}
}
}
})
for (int i = 0; i < 100; i++) {
set.add(i);
}
}방금 전 예시와 비슷하다. 차이점은 removeObserver를 직접 호출하지 않고 실행자 서비스(ExecutorService)를 사용해서 다른 스레드에게 부탁하는 것이다.
이 경우에 기가 막힌 일이 벌어진다. notifyElementAdded를 통해 메인 스레드가 이미 observers에 대해 락을 소유하고 있는데 백그라운드 스레드가 removeObserver를 호출해서 락이 풀리길 기다리고 있다. 메인 스레드에서 락이 풀리기 위해선 관찰자가 제거되어야 한다.
그렇다. 면접 빈출 “교착 상태”이다.
해결방법은 정말 간단하다. 동기화 내부에서 클래스 외부 환경에서 넘어온 외계인 메서드를 동기화 블록 바깥으로 옮겨준다.
private void notifyElementAdded(E element) {
List<SetObserver<E>> snapshot = null;
synchronized (observers) {
snapshot = new ArrayList<>(observers);
}
for (SetObserver<E> observer : snapshot) {
observer.added(this, element);
}
}더 좋은 방법은 동시성 컬렉션 라이브러리를 활용하는 것이다(CopyOnWriteArrayList).
