이 부록은 앞 장에서 소개했던 동시성을 좀 더 자세히 설명하고 보완한다.
클라이언트/서버 예제
- 서버는 소켓을 열어 놓고 클라이언트가 연결하기를 기다린다.
- 연결을 기다리다,
- 들어오는 메시지를 처리한 후,
- 다음 클라이언트 요청을 기다린다.
- 클라이언트는 소켓에 연결해 요청을 보낸다.
- 서버가 열어 놓은 소켓에 메시지를 전송하고,
- 응답 메시지를 받는다.
클라이언트/서버 쌍의 성능
테스트 케이스
- 시스템 작업 처리량을 검증하는 전형적인 예
- 성능이 만족스러운지 확인하는 테스트 케이스
- 예를 들어, 테스트가 10,000밀리초 내에 끝나는지는 검사한다.
@Test(timeout=10000)
public void shouldInUnder10Seconds() throws Exception() {
Thread[] threads = createThreads();
startAllThreads(threads);
waitForAllThreadsToFinish(threads);
}
테스트 실패 시
- 만약 테스트가 실패한다면?
- 이벤트 폴링 루프를 구현하면 모를까, 단일 스레드 환경에서 속도를 끌어올릴 방법은 거의 없다.
- 다중 스레드를 사용하면 성능이 높아질까?
- 그럴지도 모르지만, 먼저 애플리케이션이 어디서 시간을 보내는지 알아야 한다.
애플리케이션이 보내는 시간
가능성은 다음 두 가지다.
- I/O - 소켓 사용, 데이터베이스 연결, 가상 메모리 스와핑 기다리기 등에 시간을 보낸다.
- 프로세서 - 수치 계산, 정규식 표현식 처리, 가비지 컬렉션 등에 시간을 보낸다.
대개 시스템은 둘 다 하느라 시간을 보내지만, 특정 연산을 살펴보면 대개 하나가 지배적이다.
- 만약 프로그램이 주로 프로세서 연산에 시간을 보낸다면,
- 새로운 하드웨어를 추가해 성능을 높여 테스트를 통과하는 방식이 적합하다.
- 프로세서 연산에 시간을 보내는 프로그램은 스레드를 늘인다고 빨라지지 않는다. CPU 사이클은 한계가 있기 때문이다.
- 만약 프로그램이 주로 I/O 연산에 시간을 보낸다면,
- 동시성이 성능을 높여주기도 한다.
- 시스템 한쪽이 I/O를 기다리는 동안에 다른 쪽이 뭔가를 처리해 노는 CPU를 효과적으로 활용할 수 있다.
스레드 추가하기
- 자료 처리량을 높여 테스트를 통과할 방법이다.
- 서버의 함수가 주로 I/O 연산에 시간을 보낸다면, 스레드를 추가한다.
서버 살펴보기
위에서 고친 코드는 새로운 문제를 일으킨다.
- 새 서버가 만드는 스레드 수는 코드에서 한계를 명시하지 않으므로 이론상 JVM이 허용하는 수까지 가능하다.
- 너무 많은 사용자가 한꺼번에 몰린다면 시스템이 동작을 멈출 수 있다.
- 깨끗한 코드와 구조라는 관점에서, 서버 코드가 지는 책임이 여러 개다.
- 소켓 연결 관리
- 클라이언트 처리
- 스레드 정책
- 서버 종료 정책
- 코드는 추상화 수준도 다양하다.
- 서버 프로그램은 고칠 이유가 여럿이다. (SRP 위반)
따라서 다중 스레드 프로그램을 깨끗하게 유지하도록 해야 한다.
- 스레드를 관리하는 코드는 스레드만 관리해야 한다.
- 앞서 열거한 책임마다 클래스를 만들어서 스레드 관리 책임을 클래스로 분리한다.
그러면 다음과 같은 장점이 생긴다.
- 서버에 동시성 문제가 생긴다면 코드는 한 곳만 살펴보면 된다.
- 동시성 정책은 구현하기 쉽다.
- 스레드를 제어하는 동시성 정책을 바꾸기도 쉬워진다.
가능한 실행 경로
다음은 루프나 분기가 없는 한 줄짜리 자바 메서드다.
public class IdGenerator {
int lastIdUsed;
public int incrementValue() {
return ++lastIdUsed;
}
}
만약 여러 스레드가 메소드를 한 번씩 호출한다면 가능한 결과는 다음과 같다. lastIdUsed
초기값은 93으로 가정한다.
- 스레드 1이 94를 얻고, 스레드 2가 95를 얻고,
lastIdUsed
가 95가 된다.
- 스레드 1이 95를 얻고, 스레드 2가 94를 얻고,
lastIdUsed
가 95가 된다.
- 스레드 1이 94를 얻고, 스레드 2가 94를 얻고,
lastIdUsed
가 94가 된다.
이처럼 다양한 결과가 나오는 이유를 알려면 가능한 실행 경로 수와 JVM의 동작 방식을 알아야 한다.
경로 수
자바 컴파일러가 생성한 바이트 코드에서,
return ++lastIdUsed;
는 바이트 코드 명령 8개에 해당한다.
- 두 스레드가 명령 8개를 뒤섞어 실행할 가능성이 충분하다.
루프나 분기가 없는 명령 N개를 스레드 T개가 차례로 실행할 경우 가능한 경로 수=N!T(NT)!
만약 메서드에 synchronized
를 사용한다면,
스레드가 N개일 경우 가능한 경로 수=N!
심층 분석
- 원자적 연산(aotomic operaion): 중단이 불가능한 연산
- 예를 들어, 자바 메모리 모델에 의하면 32비트 메모리에 값을 할당하는 연산은 중단이 불가능하므로 int형인
lastId
에 0을 할당하는 연산은 원자적이다. → lastId = 0;
- JVM 명세에 따르면 64비트 값을 할당하는 연산은 32비트 값을 할당하는 두 개의 연산으로 나누어지므로 원자적 연산이 아니다. → 다른 프로세서는 원자적 연산으로 처리할지도 모르지만, JVM 명세에 따르면 아니다.
바이트 코드를 상세히 보기 전에 다음 정의 세 개를 명심해야 한다.
- 프레임 Frame
- 모든 메서드 호출에는 프레임이 필요하다.
- 반환주소, 메서드로 넘어온 매개변수, 메서드가 정의하는 지역 변수를 포함한다.
- 호출 스택(call stack)을 정의할 때 사용하는 표준 기법이다.
- 현대 언어는 호출 스택으로 기본 함수/메서드 호출과 재귀적 호출을 지원한다.
- 지역 변수 local variables
- 메서드 범위 내에 정의되는 모든 변수를 가리킨다.
- 정적 메서드를 제외한 모든 메서드는 기본적으로
this
라는 지역 변수를 갖는다.
this
: 현재 객체, 즉 현재 스레드에서 가장 최근에 메시지를 받아 메서드를 호출한 객체
- 피연산자 스택 operand stack
- JVM이 지원하는 명령 대다수는 매개변수를 받는데, 이런 매개변수를 저장하는 장소다.
- 표준 LIFO 구조다.
바이트 코드로 살펴 봤을 때 (자세한 내용은 서적 참고)
- 상수 값을 할당하는 코드는 여러 스레드가 실행해도 가능한 결과는 단 하나이다.
- 따라서 가능한 경로 수는 중요하지 않다.
- 서로 간섭하더라도 최종 결과는 마찬가지다.
- ex)
lastId = 0;
- 상수가 아닌, 값이 변하는 연산은 각 스레드가 서로의 작업을 덮어쓸 수 있기 때문에 문제를 일으킨다.
- ex) ++연산
- 메서드를
synchronized
로 선언하면 문제는 해결된다.
결론
- 스레드가 서로의 작업을 덮어쓰는 과정을 이해하기 위해 바이트 코드를 속속들이 이해할 필요는 없다.
- 하지만 어떤 연산이 안전하고 안전하지 못한지 파악할 만큼 메모리 모델을 이해하고 있어야 한다.
- 다음을 알아야 한다.
- 공유 객체/값이 있는 곳
- 동시 읽기/수정 문제를 일으킬 소지가 있는 코드
- 동시성 문제를 방지하는 방법
라이브러리를 이해하라
Executor 프레임워크
- 자바 5에서 처음 소개한 프레임워크로, 스레드 풀링으로 정교한 실행을 지원한다.
- Executor는
java.util.concurrent
패키지에 속하는 클래스다.
- 다음과 같은 상황에 Executor 클래스를 고려하기 바란다.
- 애플리케이션에서 스레드는 생성하나 스레드 풀을 사용하지 않을 경우
- 직접 생성한 스레드 풀을 사용할 경우
Executor 프레임워크
- 스레드 풀을 관리
- 풀 크기를 자동으로 조정
- 필요하다면 스레드를 재사용
- 다중 스레드 프로그래밍에서 많이 사용하는 Future 지원
- Runnable 인터페이스를 구현한 클래스와 Callable 인터페이스를 구현한 클래스 지원
스레드를 차단하지 않는(non blocking) 방법
- 최신 프로세서는 차단하지 않고도 안정적으로 값을 갱신한다.
- 흔히 CAS(Compare and Swap)라 불리는 연산을 지원
- 자바 5는 이를 이용한다.
AtomicBoolean
, AtomicInteger
AtomicReference
클래스 등
성능의 차이가 나는 이유는 다음과 같다.
- CAS는 데이터베이스 분야에서 낙관적 잠금(optimistic locking)이라는 개념과 유사하다.
- 여러 스레드가 같은 값을 수정해 문제를 일으키는 상황이 그리 잦지 않다는 가정에서 출발한다.
- 그런 상황이 발생했는지 효율적으로 감지해 갱신이 성공할 때까지 재차 시도한다.
- 동기화(synchronized) 버전은 비관적 잠금(pessimistic locking)이라는 개념과 유사하다.
- 언제나 락(lock)을 거므로 대가가 비싸다.
다중 스레드 환경에서 안전하지 않은 클래스
본질적으로 다중 스레드 환경에서 안전하지 않은 클래스의 몇 가지 예는 다음과 같다.
- SimpleDateFormat
- 데이터베이스 연결
- java.util 컨테이너 클래스
- 서블릿
추가로, 아래 작업도 스레드에 안전하지 않다.
- 몇몇 집합(collection) 클래스가 제공하는 스레드에 안전한 메서드 여럿을 호출하는 작업
- 해결 방안 1: HashTable을 잠그고, HashTable을 사용하는 클라이언트 모두가 클라이언트-기반 잠금 메커니즘을 구현
- 해결 방안 2: HashTable을 객체로 감싼 후 다른 API를 사용한다. Adapter 패턴을 사용해 서버-기반 잠금 ㅔ커니즘을 구현한다.
- 해결 방안 3: 스레드에 안전한 집합 클래스(java.util.concurrent 패키지가 제공하는 집합 클래스)를 사용한다.
메서드 사이에 존재하는 의존성을 조심하라
- 다중 스레드 환경에서 발생하는 문제 유형 중 하나
- 시스템을 출시하고도 오랜 시간이 지나서야 발생하는 버그로, 추적하기 어렵다.
해결 방안은 세 가지다.
- 실패를 용인한다.
- 클라이언트-기반 잠금 메커니즘을 구현한다.
- 서버-기반 잠금 메커니즘을 구현한다.
작업 처리량 높이기
단일 스레드와 다중 스레드
- 다중 스레드를 사용하여 처리율을 높일 수 있다.
- 단일 스레드 환경과 비교해 처리율은 세 배다.
데드락
데드락을 근본적으로 해결하려면 원인을 이해해야 한다. 다음 네 가지 조건을 모두 만족하면 데드락이 발생한다.
상호 배제 Mutual Exclusion
- 여러 스레드가 한 자원을 공유하나 그 자원은
-
여러 스레드가 동시에 사용하지 못하며
-
개수가 제한적이라면
상호 배제 조건을 만족한다.
- 데이터베이스 연결, 쓰기용 파일 열기, 레코드 락, 세마포어 등과 같은 자원
상호 배제 조건 깨기
- 동시에 사용해도 괜찮은 자원을 사용한다.
- 예를 들어,
Atomicinteger
를 사용
- 스레드 수 이상으로 자원 수를 늘인다.
- 자원을 점유하기 전에 필요한 자원이 모두 있는지 확인한다.
하지만 대다수 자원은 그 수가 제한적인 데다 동시에 사용하기도 어렵다는 문제가 있다.
잠금&대기 Lock & Wait
- 일단 스레드가 자원을 점유하면 필요한 나머지 자원까지 모두 점유해 작업을 마칠 때까지 이미 점유한 자원을 내놓지 않는다.
잠금&대기 조건 깨기
- 대기하지 않으면 데드락이 발생하지 않는다.
- 각 자원을 점유하기 전에 확인하고, 만약 어느 하나라도 점유하지 못한다면 지금까지 점유한 자원을 몽땅 내놓고 처음부터 다시 시작한다.
이 방법은 잠재적인 문제가 몇 가지 있다.
- 기아(Starvation)
- 한 스레드가 계속해서 필요한 자원을 점유하지 못한다.
- 라이브락(Livelock)
- 여러 스레드가 한꺼번에 잠금 단계로 진입하는 바람에 계속해서 자원을 점유했다 내놨다를 반복한다.
- 단순한 CPU 스케줄링 알고리즘에서 특히 쉽게 발생한다.
선점 불가 No Prreemption
- 한 스레드가 다른 스레드로부터 자원을 빼앗지 못한다.
- 자원을 점유한 스레드가 스스로 내놓지 않은 이상 다른 스레드는 그 자원을 점유하지 못한다.
선점 불가 조건 깨기
- 일반적으로 간단한 요청 메커니즘으로 처리한다.
- 필요한 자원이 잠겼다면 자원을 소유한 스레드에게 풀어달라 요청한다.
- 소유 스레드가 다른 자원을 기다리던 중이었다면 자신이 소유한 자원을 모두 풀어주고 처음부터 다시 시작한다.
스레드가 자원을 기다려도 괜찮다는 이점이 있어서, 처음부터 다시 시작하는 횟수가 줄어든다.
- 하지만 이 모든 요청을 관리하기가 그리 간단하지 않다.
순환 대기 Circular Wait
- 죽음의 포옹(deadly embrace)라고도 한다.
- 스레드 1이 자원 1을 점유하고 자원 2를 필요로 하는데, 스레드 2는 자원 2를 점유하고 자원 1을 필요로 할 경우이다.
순환 대기 조건 깨기
- 데드락을 방지하는 가장 흔한 전략
- 대다수 시스템에서는 모든 스레드가 동의하는 간단한 규약이면 충분하다.
- 모든 스레드가 일정 순서에 동의하고, 그 순서로만 자원을 할당한다면 데드락은 불가능하다.
- 스레드1과 스레드2가 자원을 똑같은 순서로 할당하게 만든다.
이 전략 역시 문제를 일으킬 소지가 있다.
- 자원을 할당하는 순서와 사용하는 순서가 달라서, 맨 처음 할당한 자원을 아주 나중에 쓸지도 모른다.
- 때로는 순서에 따라 자원을 할당하기가 어렵다.
- 첫 자원을 사용한 후에야 둘째 자원 ID를 얻는다면 순서대로 할당하기란 불가능하다.
다중 스레드 코드 테스트
몬테 카를로 테스트
- 조율이 가능하게 테스트를 만든다. 그런 다음, 임의로 값을 조율하면서 (예를 들어 테스트 서버에서) 반복해 돌린다.
- 테스트가 실패하면 버그가 있다는 증거다.
- 테스트는 일찌감치 작성하기 시작해 통합 서버에서 계속 돌린다.
- 테스트가 실패한 조건은 신중하게 기록한다.
모든 플랫폼에서 테스트
- 시스템을 배치할 플랫폼 전부에서 테스트를 반복해서, 계속 돌린다.
- 테스트가 실패 없이 오래 돌아갈 수록 두 가지 중 하나일 확률이 높아진다.
- 실제 코드가 올바르다.
- 테스트가 부족해 문제를 드러내지 못한다.
부하가 변하는 장비에서 테스트
- 부하기 변하는 장비에서 테스트를 돌린다.
- 실제 환경과 비슷하게 부하를 걸어줄 수 있다면 그렇게 한다.
스레드 코드 테스트를 도와주는 도구
- IBM의 ConTest
- 스레드에 안전하지 않은 코드에 보조 코드를 더해 실패할 가능성을 높여주는 도구
- 사용하는 방법은 다음과 같다.
- 실제 코드와 테스트 코드를 작성한다. 다양한 부하 상황에서 여러 사용자를 시뮬레이션하는 테스트도 빼놓지 않는다.
- ConTest로 실제 코드와 테스트 코드에 보조 코드를 추가한다.
- 테스트를 실행한다.