- 동시성이 필요한 이유와 그 어려움, 그리고 동시성을 유지하면서 깨끗한 코드를 작성하고 테스트하는 방법과 문제점에 대해 알려준다
동시성이 필요한 이유?
- 동시성 = 결합을 없애는 전략
- 무엇과 언제를 분리하는 전략
- 스레드가 하나인 프로그램은 무엇과 언제가 서로 밀접하다
- 단일 스레드 프로그램은 정지점을 정한 후 시스템 상태를 파악
- 무엇과 언제를 분리하면 애플리케이션 구조와 효율이 극적으로 나아진다
- 웹 애플리케이션이 표준으로 사용하는 서블릿 모델
- 웹 혹은 EJB 컨테이너 아래에서 실행
- 이 컨테이너는 동시성을 부분적으로 관리
- 웹 요청이 들어오면 웹 서버는 비동기식으로 서블릿을 실행
- 원칙적으로 각 서블릿 스레드는 다른 서블릿 스레드와 무관
- 웹 컨테이너가 제공하는 결합분리 전략은 완벽하지 않음
미신과 오해
- 동시성은 항상 성능을 높여준다
- 동시성을 구현해도 설계는 변하지 않는다
- 웹 또는 EJB 컨테이너를 사용하면 동시성을 이해할 필요가 없다
타당한 생각
- 동시성은 부하를 유발한다
- 동시성은 복잡하다
- 일반적으로 동시성 버그는 재현하기 어렵다
- 동시성을 구현하려면 근본적인 설계 전략을 재고해야 한다
난관
public class ClassWithThreadingProblem {
private int lastIdUsed;
public ClassWithThreadingProblem(int lastIdUsed) {
this.lastIdUsed = lastIdUsed;
}
public int getNextId() {
return ++lastIdUsed;
}
}
public static void main(String args[]) {
final ClassWithThreadingProblem classWithThreadingProblem = new ClassWithThreadingProblem(42);
Runnable runnable = new Runnable() {
public void run() {
classWithThreadingProblem.getNextId();
}
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
t2.start();
}
- t1이 43을, t2가 44를 가져간다. lastIdUsed는 44이다(O)
- t1이 44을, t2가 43를 가져간다. lastIdUsed는 44이다(O)
- t1이 43을, t2가 43를 가져간다. lastIdUsed는 43이다(X)
- 위와 같이 일부 경로가 잘못된 결과를 내놓는다
동시성 방어 원칙
단일 책임 원칙
- 동시성과 관련된 코드는 다른 코드들과 분리한다
- 동시성 코드는 독자적인 개발, 변경, 조율 주기가 있다
- 동시성 코드에는 독자적인 문제가 있다
- 잘못 구현된 동시성 코드는 다양한 방식으로 실패한다
자룔 번위를 제한하라
- 공유 객체를 사용하는 코드 내 임계 영역을 synchronized 키워드로 보호한다
- 자료를 캡슐화하고 공유 자료를 최대한 줄인다
- 공유 자료를 수정하는 위치가 많을수록 위험도 커진다
- 보호할 임계 영역을 빼먹는다
- 모든 임계 영역을 제대로 보호했는지 확인하느라 노력과 수고가 든다
- 버그를 찾기 어렵다
자료 사본을 사용하라
- 처음부터 공유하지 않도록 한다
- 객체를 복사해 읽기 전용으로 사용한다
스레드는 가능한 독립적으로 구현하라
- 다른 스레드와 자료를 공유하지 않는다
- 다른 스레드와 동기화 할 필요성을 만들지 않는다
라이브러리를 이해하라
- 자바 5는 동시성 측면에서 발전했다
- 스레드 환경에 안전한 컬렉션을 사용
- 서로 무관한 작업을 수행한 때는 executor 프레임워크를 사용
- 스레드가 차단되지 않는 방법을 사용
- 일부 클래스 라이브러리는 스레드에 안전하지 못하다
스레드 환경에 안전한 컬렉션
실행 모델을 이해하라
- 기본 용어
- 한정된 자원 : 다중 스레드 환경에서 사용하는 자원으로 크기나 숫자가 제한적이다. 데이터베이스 연결, 길이가 일정한 읽기/쓰기 버퍼 등
- 상호 배제 : 한 번에 한 스레드만 공유 자료나 공유 자원을 사용할 수 있는 경우
- 기아 : 한 스레드나 여러 스레드가 굉장히 오랫동안 혹은 영원히 자원을 기다린다
- 데드락 : 여러 스레드가 서로 끝나기를 기다린다. 모든 스레드가 각기 필요한 자원을 다른 스레드가 점유하는 바람에 어느 쪽도 더 이상 진행하지 못한다
- 라이브락 : 락을 거는 단계에서 각 스레드가 서로를 방해한다. 스레드는 계속해서 진행하려 하지만 공명으로 인해 굉장히 오랫동안 혹은 영원히 진행하지 못한다
생산자-소비자
- 하나 이상 생산자 스레드가 정보를 생성해 버퍼나 대기열에 넣는다
- 하나 이상의 소비자 스레드가 대기열에서 정보를 가져와 사용한다
- 대기열은 한정된 자원이다
- 대기열에 빈 공간이 있어야 정보를 채운다
- 대기열에 정보가 있어야 가져올 수 있다
- 생산자 스레드와 소비자 스레드는 서로에게 시그널을 보낸다
- 잘못하면 둘 다 서로에게서 시그널을 기다릴 가능성이 존재한다
읽기-쓰기
- 읽기 스레드를 위한 주된 정보원으로 공유 자원을 사용하지만 쓰기 스레드가 이 공유 자원을 이따금 갱신한다
- 처리율의 문제가 핵심이다
- 처리율을 강조하면 기아 현상이 생기거나 오래된 정보가 쌓인다
- 갱신을 허용하면 처리율에 영향이 미친다
- 대개는 쓰기 스레드가 버퍼를 오랫동안 점유하는 바람에 여러 읽기 스레드가 버퍼를 기다리느라 처리율이 떨어진다
- 읽기 스레드가 없을 때까지 쓰기 스레드가 버퍼를 기다리는 방법을 쓰면 스레드가 기아 상태에 빠진다
식사하는 철학자들
- 철학자를 스레드로 포크를 자원으로 바꿔 생각
- 주의해서 설계하지 않으면 데드락, 라이브락, 처리율 저하, 효율성 저하 등을 겪을 수 있다
동기화하는 메서드 사이에 존재하는 의존성을 이해하라
- 동기화하는 메서드 사이에 의존성이 존재하면 동시성 코드에 찾아내기 어려운 버그가 생긴다
- 공유 객체 하나에는 메서드 하나만 사용해야 한다
동기화하는 부분을 작게 만들어라
- 자바에서 synchronized 키워드를 사용하면 같은 락으로 감싼 모든 코드 영역은 한 번에 한 스레드만 실행이 가능하다
- 락은 스레드를 지연시키고 부하를 가중시키므로 임계 영역 수를 최대한 줄여야 한다
- 그러나 수를 줄인다고 필요 이상으로 임계 영역 크기를 키우면 스레드 간에 경쟁이 늘어나고 프로그램 성능이 떨어진다
올바른 종료 코드는 구현하기 어렵다
- 깔끔하게 종료되는 코드는 올바로 구현하기 어렵다
- 가장 흔히 발생하는 문제가 데드락이다. 즉 스레드가 절대 오지 않을 시그널을 기다린다
- ex) 부모 스레드가 자식 스레드를 여러 개 만든 후 종료시키려는데 만약 자식 스레드 중 하나가 데드락에 걸렸다면 부모 스레드는 영원히 기다려야 한다
스레드 코드 테스트하기
- 문제를 노출하는 테스트 케이스를 작성하라
- 프로그램 설정과 시스템 설정과 부하를 바꿔가며 자주 돌려라
- 테스트가 실패하면 원인을 추적하라
개인적인 감상
- 동기화를 해야하는 순간들이 있겠지만 가능하다면 동기화를 피하고 자원을 공유하지 않는 것이 최선이라는 것을 알 수 있었다
- 만약 동기화를 해야한다면 값을 그대로 사용하는 것보다 값을 복사해서 사용하는 것이 낫다는 것을 알 수 있었다