부록 A: 동시성 2

공부하는 감자·2024년 2월 21일
0

클린코드

목록 보기
18/18

이 부록은 앞 장에서 소개했던 동시성을 좀 더 자세히 설명하고 보완한다.

클라이언트/서버 예제

  • 서버는 소켓을 열어 놓고 클라이언트가 연결하기를 기다린다.
    • 연결을 기다리다,
    • 들어오는 메시지를 처리한 후,
    • 다음 클라이언트 요청을 기다린다.
  • 클라이언트는 소켓에 연결해 요청을 보낸다.
    • 서버가 열어 놓은 소켓에 메시지를 전송하고,
    • 응답 메시지를 받는다.

클라이언트/서버 쌍의 성능

테스트 케이스

  • 시스템 작업 처리량을 검증하는 전형적인 예
    • 성능이 만족스러운지 확인하는 테스트 케이스
    • 예를 들어, 테스트가 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개가 차례로 실행할 경우 가능한 경로 수=(NT)!N!T루프나\ 분기가\ 없는\ 명령\ N개를\ 스레드\ T개가\ 차례로\ 실행할\ 경우\ 가능한\ 경로\ 수\\ =\frac{(NT)!}{N!^T}

만약 메서드에 synchronized 를 사용한다면,

스레드가 N개일 경우 가능한 경로 수=N!스레드가\ 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

  • 여러 스레드가 한 자원을 공유하나 그 자원은
    1. 여러 스레드가 동시에 사용하지 못하며

    2. 개수가 제한적이라면

      상호 배제 조건을 만족한다.

  • 데이터베이스 연결, 쓰기용 파일 열기, 레코드 락, 세마포어 등과 같은 자원

상호 배제 조건 깨기

  • 동시에 사용해도 괜찮은 자원을 사용한다.
    • 예를 들어, Atomicinteger 를 사용
  • 스레드 수 이상으로 자원 수를 늘인다.
  • 자원을 점유하기 전에 필요한 자원이 모두 있는지 확인한다.

하지만 대다수 자원은 그 수가 제한적인 데다 동시에 사용하기도 어렵다는 문제가 있다.

잠금&대기 Lock & Wait

  • 일단 스레드가 자원을 점유하면 필요한 나머지 자원까지 모두 점유해 작업을 마칠 때까지 이미 점유한 자원을 내놓지 않는다.

잠금&대기 조건 깨기

  • 대기하지 않으면 데드락이 발생하지 않는다.
  • 각 자원을 점유하기 전에 확인하고, 만약 어느 하나라도 점유하지 못한다면 지금까지 점유한 자원을 몽땅 내놓고 처음부터 다시 시작한다.

이 방법은 잠재적인 문제가 몇 가지 있다.

  • 기아(Starvation)
    • 한 스레드가 계속해서 필요한 자원을 점유하지 못한다.
  • 라이브락(Livelock)
    • 여러 스레드가 한꺼번에 잠금 단계로 진입하는 바람에 계속해서 자원을 점유했다 내놨다를 반복한다.
    • 단순한 CPU 스케줄링 알고리즘에서 특히 쉽게 발생한다.

선점 불가 No Prreemption

  • 한 스레드가 다른 스레드로부터 자원을 빼앗지 못한다.
  • 자원을 점유한 스레드가 스스로 내놓지 않은 이상 다른 스레드는 그 자원을 점유하지 못한다.

선점 불가 조건 깨기

  • 일반적으로 간단한 요청 메커니즘으로 처리한다.
  • 필요한 자원이 잠겼다면 자원을 소유한 스레드에게 풀어달라 요청한다.
  • 소유 스레드가 다른 자원을 기다리던 중이었다면 자신이 소유한 자원을 모두 풀어주고 처음부터 다시 시작한다.

스레드가 자원을 기다려도 괜찮다는 이점이 있어서, 처음부터 다시 시작하는 횟수가 줄어든다.

  • 하지만 이 모든 요청을 관리하기가 그리 간단하지 않다.

순환 대기 Circular Wait

  • 죽음의 포옹(deadly embrace)라고도 한다.
  • 스레드 1이 자원 1을 점유하고 자원 2를 필요로 하는데, 스레드 2는 자원 2를 점유하고 자원 1을 필요로 할 경우이다.

순환 대기 조건 깨기

  • 데드락을 방지하는 가장 흔한 전략
  • 대다수 시스템에서는 모든 스레드가 동의하는 간단한 규약이면 충분하다.
  • 모든 스레드가 일정 순서에 동의하고, 그 순서로만 자원을 할당한다면 데드락은 불가능하다.
    • 스레드1과 스레드2가 자원을 똑같은 순서로 할당하게 만든다.

이 전략 역시 문제를 일으킬 소지가 있다.

  • 자원을 할당하는 순서와 사용하는 순서가 달라서, 맨 처음 할당한 자원을 아주 나중에 쓸지도 모른다.
    • 자원을 꼭 필요한 이상으로 오랫동안 점유
  • 때로는 순서에 따라 자원을 할당하기가 어렵다.
    • 첫 자원을 사용한 후에야 둘째 자원 ID를 얻는다면 순서대로 할당하기란 불가능하다.

다중 스레드 코드 테스트

몬테 카를로 테스트

  • 조율이 가능하게 테스트를 만든다. 그런 다음, 임의로 값을 조율하면서 (예를 들어 테스트 서버에서) 반복해 돌린다.
    • 테스트가 실패하면 버그가 있다는 증거다.
    • 테스트는 일찌감치 작성하기 시작해 통합 서버에서 계속 돌린다.
    • 테스트가 실패한 조건은 신중하게 기록한다.

모든 플랫폼에서 테스트

  • 시스템을 배치할 플랫폼 전부에서 테스트를 반복해서, 계속 돌린다.
  • 테스트가 실패 없이 오래 돌아갈 수록 두 가지 중 하나일 확률이 높아진다.
    • 실제 코드가 올바르다.
    • 테스트가 부족해 문제를 드러내지 못한다.

부하가 변하는 장비에서 테스트

  • 부하기 변하는 장비에서 테스트를 돌린다.
  • 실제 환경과 비슷하게 부하를 걸어줄 수 있다면 그렇게 한다.

스레드 코드 테스트를 도와주는 도구

  • IBM의 ConTest
    • 스레드에 안전하지 않은 코드에 보조 코드를 더해 실패할 가능성을 높여주는 도구
  • 사용하는 방법은 다음과 같다.
    • 실제 코드와 테스트 코드를 작성한다. 다양한 부하 상황에서 여러 사용자를 시뮬레이션하는 테스트도 빼놓지 않는다.
    • ConTest로 실제 코드와 테스트 코드에 보조 코드를 추가한다.
    • 테스트를 실행한다.
profile
책을 읽거나 강의를 들으며 공부한 내용을 정리합니다. 가끔 개발하는데 있었던 이슈도 올립니다.

0개의 댓글