“객체는 처리의 추상화다. 스레드는 일정의 추상화다.” - 제임스 O. 코플리
- 동시성과 깔끔한 코드는 양립하기 어렵다.
- 스레드를 하나만 실행 하는 코드는 짜기가 쉽다.
- 겉으로 보기에는 멀쩡해 보이는 다중 스레드코드도 짜기 쉽다.
- 이런코드는 시스템이 부하를 받기 전까지 멀쩡 하게 돌아간다.
- 이 장에서는 concurrent 프로그래밍의 필요성, 어려움에 대해 논한다.
- 또한, 이런 어려움에 대처하고 깨끗한 코드를작성 하는 방법도 몇 가지 제안한다.
- 마지막으로, 동시성을 테스트하는 방법과 문제점을 논한다.
// Code 1-1
public class X {
private int lastIdUsed;
public int getNextId() {
return ++lastIdUsed;
}
}
getNextId()
; 를 호출하면 결과는 아래 셋중 하나이다.권장사항: 자료를 캡슐화하라. 공유 자료를 최대한 줄여라.
자신만의 세상에 존재하는 스레드를 구현한다. 즉, 다른 스레드와 자료를 공유 하지 않는다.
각 스레드는 클라이언트 요청 하나를 처리한다.
모든 정보는 비공유 출처에서 가져오며 로컬 변수에 저장한다.
그러면 각 스레드는 세상에 자신만 있는듯이 돌아갈 수 있다.
권장사항: 독자적인 스레드로, 가능하면 다른 프로세서에서, 돌려도 괜찮도록 자료를 독립적인 단위로 분할하라.
java.util.concurrent
패키지가 제공하는 클래스는 다중 스레드 환경에서 사용해도 안전하며, 성능도 좋다.
ConcurrentHashMap, ReentrantLock, Semaphore, CountDownLatch 등등
권장사항: 자바의 경우 java.util.concurrent, java.util.concurrent.atomic, java.util.concurrent.locks 살펴보기
권장사항: 공유 객체 하나에는 메서드 하나만 사용하라
클라이언트 기반 잠금(Client-Based Locking): 클라이언트가 첫 메서드를 부르기 이전부터 마지막 메서드를 부른 다음까지 서버를 잠근다. (역주: 공유 객체를 사용하는 코드에서 공유 객체를 잠그는 것이다.)
=> Bad: 서버를 사용하는 모든 클라이언트 코드에서 lock이 필요하게 되며 이는 유지보수 및 디버깅에 필요한 비용을 상승시킨다.
서버 기반 잠금(Server-Based Locking): 서버 내에서 서버(자신)을 잠그고 모든 동작을 수행한 후 잠금을 푸는 메서드를 제공한다. 클라이언트에게는 새로운 메서드를 제공한다. (역주: 공유 객체에 새로운 메서드를 작성하고 잠금이 필요한 동작 전체를 수행하게 하는 것이다.)
=> Good: Critical section에 접근하는 코드를 최소화해 위 4-2에 부합한다.
중계된 서버(Adapted Server): 잠금을 수행하는 중계자를 작성한다. 이는 기본적으로 서버 기반 잠금이지만 기존의 서버를 변경할 수 없는 상황에 사용할 수 있는 방법이다.(역주: 서드 파티 라이브러리를 사용한다고 생각하면 쉬울 것이다.)
=> Good: 서버 기반 잠금 방식을 사용할 수 없는 경우에 사용하자.
/* Code 2-1: 문제가 되는 상황 */
public class IntegerIterator implements Iterator<Integer> {
private Integer nextValue = 0;
public synchronized boolean hasNext() {
return nextValue < 100000;
}
public synchronized Integer next() {
if (nextValue == 100000)
throw new IteratorPastEndException();
return nextValue++;
}
public synchronized Integer getNextValue() {
return nextValue;
}
}
// Shared Resource
IntegerIterator iterator = new IntegerIterator();
// Threaded-Code
while(iterator.hasNext()) {
// nextValue가 99999인 상황에서 두 스레드에서 순차적으로 while(iterator.hasNext())를 호출하게 되면
// 두 스레드 모두 while문 안으로 진입하게 된다. 이는 예상되지 않은 결과이다.
int nextValue = iterator.next();
// do something with nextValue
}
/* Code 2-2: Client-Based Locking */
// Shared Resource
IntegerIterator iterator = new IntegerIterator();
// Threaded-Code
while (true) {
int nextValue;
synchronized (iterator) {
if (!iterator.hasNext())
break;
nextValue = iterator.next();
}
doSometingWith(nextValue);
}
/* Code 2-3: Server-Based Locking */
public class IntegerIteratorServerLocked {
private Integer nextValue = 0;
public synchronized Integer getNextOrNull() {
if (nextValue < 100000)
return nextValue++;
else
return null;
}
}
// Shared Resource
IntegerIterator iterator = new IntegerIterator();
// Threaded-Code
while (true) {
Integer nextValue = iterator.getNextOrNull();
if (next == null)
break;
// do something with nextValue
}
/* Code 2-4: Adapted Server */
public class ThreadSafeIntegerIterator {
private IntegerIterator iterator = new IntegerIterator();
public synchronized Integer getNextOrNull() {
if(iterator.hasNext())
return iterator.next();
return null;
}
}
// Threaded-Code는 위 Code 2-3과 동일
synchronized
를 남발하면 안된다.권장사항: 종료 코드를 개발 초기부터 고민하고 동작하게 초기부터 구현하라.
생각보다 오래 걸린다. 생각보다 어려우므로 이미 나온 알고리즘을 검토하라.
권장사항: 문제를 노출하는 테스트 케이스를 작성하라. 프로그램 설정과 시스템 설정과 부하를 바꿔가며 자주 돌려라.
테스트가 실패하면 원인을 추적하라. 다시 돌렸더니 통과하더라는 이유로 그냥 넘어가면 절대로 안 된다.
권장사항: 시스템 실패를 ‘일회성’이라 치부하지 마라.
권장사항: 스레드 환경 밖에서 생기는 버그와 스레드 환경에서 생기는 버그를 동시에 디버깅하지 마라.
먼저 스레드 환경 밖에서 코드를 올바로 돌려라.
권장사항: 다양한 설정에서 실행할 목적으로 다른 환경에 쉽게 끼워 넣을 수 있게 코드를 구현하라.
권장사항: 처음부터 그리고 자주 모든 목표 플랫폼에서 코드를 돌려라.
public synchronized String nextUrlOrNull() {
if(hasNext()) {
String url = urlGenerator.next();
Thread.yield();
// inserted for testing.
updateHasNext();
return url;
}
return null;
}
yield() 메서드를 호출함으로써 코드의 실행 경로를 변경할 수 있다. 만약 위 코드에서 문제가 발생한다면 이는 yield()를 추가해 생긴 문제가 아니라 이미 존재하던 문제를 명백히 만든것 뿐이다.
하지만 이 방법에는 몇 가지 문제가 있다.
우리는 실제 제품에 포함되지 않으며 여러 조합으로 실행해 에러를 찾기 쉽게 만들 방법이 필요하다.
이를 위해서는 시스템을 최대한 POJO 단위로 나눠 instrument code를 삽입할 부분을 찾기 쉽게 하고 여러 정책에 따라 sleep, yield등을 삽입할 수 있게 해야 한다.
/* Code 4-1 */
public class ThreadJigglePoint {
public static void jiggle() { }
}
public synchronized String nextUrlOrNull() {
if(hasNext()) {
ThreadJiglePoint.jiggle();
String url = urlGenerator.next();
ThreadJiglePoint.jiggle();
updateHasNext();
ThreadJiglePoint.jiggle();
return url;
}
return null;
}
위와 같이 구현한 후 간단한 Aspect 9를 이용해 '아무 것도 안하기', 'sleep', 'yield'등을 무작위로 선택하게 할 수 있다.
혹은 ThreadJigglePoint가 두 가지 구현을 가지게 할 수도 있다. 첫 번째 구현은 배포용 코드를 위한 '아무 것도 안하기'를 수행하며 두 번째 구현은 'sleep, yield, 아무 것도 안하기' 중의 하나를 무작위로 선택하는 것이다. 다소 간단하긴 하지만 좀 더 정교한 툴을 사용하는 대신 이 정도로 구현하는 것도 적절한 선택일 것이다.
혹은 이와 비슷한 작업을 수행해 주는 IBM에서 개발한 ConTest라는 툴도 있다. 이는 수행시마다 다른 순서로 스레드를 실행하게 만들어 줌으로써 문제를 발견할 확률을 극적으로 높여준다.
깔끔한 접근 방식을 취한다면 코드가 올바로 돌아갈 가능성이 극적으로 높아 진다.