동시성은 결합을 없애는 전략이다. 즉, 무엇과 언제를 분리하는 전략이다. 스레드가 하나인 프로그램은 무엇과 언제가 서로 밀접하다. 그래서 호출 스택을 살펴보면 프로그램 상태가 곧바로 드러난다. 흔히 단일 스레드 프로그램을 디버깅하는 프로그래머는 정지점을 정한 후 어느 지점에 걸렸는지 살펴보면서 시스템 상태를 파악한다.
무엇과 언제를 분리하면 애플리케이션 구조와 효율이 극적으로 나아진다. 구조적인 관점에서 프로그램은 거대한 루프 하나가 아니라 작은 협력 프로그램 여럿으로 보인다. 따라서 시스템을 이해하기가 쉽고 문제를 분리하기도 쉽다.
동시성은 어렵다. 각별히 주의하지 않으면 난감한 상황에 처한다. 다음은 동시성과 관련한 일반적인 미신과 오해다.
동시성은 항상 성능을 높여준다.
동시성은 때로 성능을 높여준다. 대기 시간이 아주 길어 여러 스레드가 프로세서를 공유할 수 있거나, 여러 프로세서가 동시에 처리할 독립적인 계산이 충분히 많은 공유에만 성능이 높아진다. 어느 쪽도 일상적으로 발생하는 상황은 아니다.
동시성을 구현해도 설계는 변하지 않는다.
단일 스레드 시스템과 다중 스레드 시스템은 설계가 판이하게 다르다. 일반적으로 무엇과 언제를 분리하면 시스템 구조가 크게 달라진다.
반대로 다음은 동시성과 관련된 타당한 생각 몇가지다.
지금부터 동시성 코드가 일으키는 문제로부터 시스템을 방어하는 원칙과 기술을 소개한다.
SRP는 주어진 메서드/클래스/컴포넌트를 변경할 이유가 하나여야 한다는 원칙이다. 동시성은 복잡성 하나만으로도 따로 분리할 이유가 충분하다. 즉, 동시성 관련 코드는 다른 코드와 분리해야 한다는 뜻이다.
공유 객체를 사용하는 코드 내 임계영역을 synchronized 키워드로 보호하라.
자료를 캡슐화하라. 공유 자료를 최대한 줄여라.
공유 자료를 줄이려면 처음부터 공유하지 않는 방법이 제일 좋다. 어떤 경우에는 객체를 복사해 읽기 전용으로 사용하는 방법이 가능하다. 어떤 경우에는 각 스레드가 객체를 복사해 사용한 후 한 스레드가 해당 사본에서 결과를 가져오는 방법도 가능하다.
사본으로 동기화를 피할 수 있다면 내부 잠금을 없애 절약한 수행 시간이 사본 생성과 가비지 컬렉션에 드는 부하를 상쇄할 가능성이 크다.
각 스레드는 클라이언트 요청 하나를 처리한다. 모든 정보는 비공유 출처에서 가져오며 로컬 변수에 저장한다. 그러면 각 스레드는 세상에 자신만 있는 듯이 돌아갈 수 있다.
자바 5는 동시성 측면에서 이전 버전보다 많이 나아졌다. ConcurrentHashMap은 거의 모든 상황에서 HashMap보다 빠르다.
한정된 자원(Bound Resource): 다중 스레드 환경에서 사용하는 자원으로, 크기나 숫자가 제한적이다. 데이터베이스 연결, 길이가 일정한 읽기/쓰기 버퍼 등이 예다.
상호 배제(Mutual Exclusion): 한 번에 한 스레드만 공유 자료나 공유 자원을 사용할 수 있는 경우를 가리킨다.
기아(Starvation): 한 스레드나 여러 스레드가 굉장히 오랫동안 혹은 영원히 자원을 기다린다. 예를 들어, 항상 짧은 스레드에게 우선순위를 준다면, 짧은 스레드가 지속적으로 이어질 경우, 긴 스레드가 기아 상태에 빠진다.
데드락(Deadlock): 여러 스레드가 서로가 끝나기를 기다린다. 모든 스레드가 각기 필요한 자원을 다른 스레드가 점유하는 바람에 어느 쪽도 더 이상 진행하지 못한다.
라이브락(Livelock): 락을 거는 단게에서 각 스레드가 서로 방해한다. 스레드는 계속해서 진행하려 하지만, 공명(resonance)으로 인해, 굉장히 오랫동안 혹은 영원히 진행하지 못한다.
자바에서 synchronized 키워드를 사용하면 락을 설정한다. 같은 락으로 감싼 모든 코드 영역은 한 번에 한 스레드만 실행이 가능하다. 락은 스레드를 지연시키고 부하를 가중시킨다. 그러므로 여기저기서 synchronized 문을 남발하는 코드는 바람직하지 않다. 반면, 임계영역은 반드시 보호해야 한다. 따라서 코드를 짤 때는 임계영역 수를 최대한 줄여야 한다.
스레드 환경 밖에서 코드가 제대로 도는지 반드시 확인한다. 일반적인 방법으로, 스레드가 호출하는 POJO (Playin Old Java Object)를 만든다. POJO는 스레드를 모른다.
시스템이 스레드를 스와핑할 때도 문제가 발생한다.
스레드 버그가 산발적이고 재현이 어려운 이유는 코드가 실행되는 수천 가지 경로 중에 아주 소수만 실패하기 때문이다.
보조 코드를 추가해 코드가 실행되는 순서를 바꿔주면 오류를 좀 더 자주 일으킬 수 있다.
코드에다 직접 wait(), sleep(), yield(), priority() 함수를 추가한다.
yield()를 삽입하면 코드가 실행되는 경로가 바뀐다. 코드가 실패한다면 yield()를 추가했기 때문이 아니다. 원래 잘못된 코드인데 증거가 드러났을 뿐이다.
보조 코드를 자동으로 추가하려면 AOF, CGLIB, ASM 등과 같은 도구를 사용한다.