[클린코드] 부록. 동시성 II

June·2021년 12월 24일
0

[클린코드]

목록 보기
15/15

클라이언트/서버 예제

시스템 작업 처리량을 테스트했는데 실패한다면 어떻게 해야할까? 이벤트 폴링 루프를 구현한다면 모를까, 단일스레드 환경에서 속도를 끌어올릴 방법은 거의 없다.

먼저 애플리케이션이 어디서 시간을 보내는지 알아야 한다. 가능성은 두 가지다.

  • I/O: 소켓 사용, 데이터베이스 연결, 가상 메모리 스와핑 기다리기 등에 시간을 보낸다.
  • 프로세서: 수치 계산, 정규 표현식 처리, 가비지 컬렉션 등에 시간을 보낸다.

대개 시스템은 둘 다 하느라 시간을 보내지만, 특정 연산을 살펴보면 대개 하나가 지배적이다. 만약 프로그램이 주로 프로세서 연산에 시간을 보낸다면, 새로운 하드웨어를 추가해 성능을 높여 테스트를 통과하는 방식이 적합하다. 프로세서 연산에 시간을 보내는 프로그램은 스레드를 늘린다고 빨라지지 않는다. CPU 사이클은 한계가 있기 때문이다.

반면 프로그램이 주로 I/O 연산에 시간을 보낸다면 동시성이 성능을 높여주기도 한다. 시스템 한쪽이 I/O를 기다리는 동안에 다른 쪽이 뭔가를 처리해 노는 CPU를 효과적으로 활용할 수 있다.

가능한 실행 경로

public class IdGenerator {
    int lastIdUsed;
    
    public int incrementValue() {
        return ++lastIdUsed;
    }
}

만약 IdGenerator 인스턴스는 그대로지만 스레드가 두 개라면?

  • 스레드 1이 94를 얻고, 스레드2가 95를 얻고, lastIdUsed가 95가 된다.
  • 스레드 1이 95를 얻고, 스레드2가 94를 얻고, lastIdUsed가 95가 된다.
  • 스레드 1이 94를 얻고, 스레드2가 94를 얻고, lastIdUsed가 94가 된다.

경로 수

return ++lastIdUsed 는 바이트 코드 명령 8개에 해당한다. 여기서 스레드 2개가 있다면 많은 조합이 나온다. N개의 단계와 T개의 스레드가 있으면 가능한 경우의 수는 (NT)! / N^T 과 같다.

만약 메서드를

public synchronized int incrementValue() {
    return ++lastIdUsed;
}

이렇게 하면 가능한 경로의 수는 (스레드가 2개일 때) 2개로 줄어든다. 스레드가 N개라면 가능한 경로의 수는 N! 이다.

심층 분석

바이트 코드를 상세히 보기전에 다음 정의를 명심하자.

  • 프레임: 모든 메서드 호출에는 프레임이 필요하다. 프레임은 반환 주소, 메서드로 넘어온 매개변수, 메서드가 정의하는 지역 변수를 포함한다. 프레임은 호출 스택을 정의할 때 사용하는 표준 기법이다. 현대 언어는 호출 스택으로 기본 함수/메서드 호출과 재귀적 호출을 지원한다.

  • 지역 변수: 메서드 범위 내에 정의되는 모든 변수를 가리킨다. 정적 메서드를 제외한 모든 메서드는 this라는 지역 변수를 갖는다. this는 현재 객체, 즉 현재 스레드에서 가장 최근에 메시지를 받아 메서드를 호출한 객체를 가리킨다.

  • 피연산자 스택: JVM이 지원하는 명령 대다수는 매개변수를 받는다. 피연산자 스택은 이런 매개변수를 저장하는 장소다. 피연산자 스택은 표준 LIFO 자료구조다.

... 중략

어떤 연산이 안전하고 안전하지 못한지 파악할 만큼 메모리 모델을 이해하고 있어야 한다. ++ 연산은 원자적이라고 오해하는 사람이 많은데, ++ 연산은 분명히 원자적 연산이 아니다. 즉 다음을 알아야 한다.

  • 공유 객체/값이 있는 곳
  • 동시 읽기/수정 문제를 일으킬 소지가 있는 코드
  • 동시성 문제를 방지하는 방법

스레드를 차단하지 않는 방법

최신 프로세서는 차단하지 않고도 안정적으로 값을 갱신한다.

현대 프로세서는 흔히 CAS(Compare and Swap)라 불리는 연산을 지원한다. CAS는 데이터베이스 분야에서 낙관적 잠금이라는 개념과 유사하다. 반면 동기화 버전은 비관적 잠금이라는 개념과 유사하다.

synchroinzed 키워드는 언제나 락을 건다. 둘째 스레드가 같은 값을 갱신하지 않더라도 무조건 락부터 건다. 자바 버전이 올라갈 때마다 내장 락의 성능이 좋아지기는 했지만 그래도 락을 거는 대가는 여전히 비싸다.

스레드를 차단하지 않는 버전은 여러 스레드가 같은 값을 수정해 문제를 일으키는 상황이 그리 잦지 않다는 가정에서 출발한다. 그래서 그런 상황이 발생했는지 효율적으로 감지해 갱신이 성공할 때까지 재차 시도한다. 많은 스레드가 경쟁하는 상황이더라도 락을 거는 쪽보다 문제를 감지하는 쪽이 더 효율적이다.

데드락

다음 네 가지 조건을 모두 만족하면 데드락이 발생한다.

  • 상호 배제 (Mutual Exclusion)
  • 잠금 & 대기 (Lock & Wait)
  • 선점 불가 (No preemption)
  • 순환 대기 (Circular Wait)

상호 배제

여러 스레드가 한 자원을 공유하나 그 자원은

  • 여러 스레드가 동시에 사용하지 못하며
  • 개수가 제한적이라면

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

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

잠금 & 대기 (Lock & Wait)

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

선점 불가 (No Preemption)

한 스레드가 다른 스레드로부터 자원을 빼앗지 못한다.

순환 대기 (Circular Wait)

죽음의 포옹이라고도 한다. T1, T2라는 스레드가 두 개 있으며 R1, R2 라는 자원이 두 개가 있다고 가정하자. T1이 R1을 점유하고, T2가 R2를 점유한다. 또한 T1은 R2가 필요하고, T2도 R2가 필요하다.

위의 네 가지 모두를 충족해야 데드락이 발생한다. 네 조건 중 하나라도 깨버리면 데드락이 발생하지 않는다.

상호 배제 조건 깨기

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

하지만 대다수 자원은 한정적이고 동시에 사용하기도 어렵다.

잠금 & 대기 조건 깨기

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

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

  • 기아(starvation): 한 스레드가 계속해서 필요한 자원을 점유하지 못한다. (점유하려는 자원이 한꺼번에 확보하기 어려운 조합일지도 모른다).

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

두 경우 모두가 자칫하면 작업 처리량을 크게 떨어뜨린다. 기아는 CPU 효율을 저하시키는 반면 라이브락은 쓸데 없이 CPU만 많이 사용한다.

선점 불가 조건 깨기

데드락을 피하는 또 다른 전략은 다른 스레드로부터 자원을 뺏어오는 방법이다. 일반적으로 간단한 요청 메커니즘으로 처리한다. 필요한 자원이 잠겼다면 자원을 소유한 스레드에게 풀어달라 한다. 소유 스레드가 다른 자원을 기다리던 중이었다면 자신이 소유한 자원을 모두 풀어주고 처음부터 다시 시작한다.

순환 대기 조건 깨기

데드락을 방지하는 가장 흔한 전략이다.

R1을 점유한 T1이 R2를 기다리고 R2를 점유한 T2가 T1을 기다리는 앞서 예제에서 T1과 T2가 자원을 똑같은 순서로 할당하게 만들면 순환 대기는 불가능하다.

좀 더 일반적으로 말해, 모든 스레드가 일정 순서에 동의하고 그 순서로만 자원을 할당한다면 데드락은 불가능하다. 그러나 이 전략도 문제를 일으킬 소지가 있다.

  • 자원을 할당하는 순서와 자원을 사용하는 순서가 다를지도 모른다. 그래서 맨 처음 할당한 자원을 아주 나중에야 쓸지도 모른다. 즉, 자원을 꼭 필요한 이상으로 오랫동안 점유한다.

  • 때로는 순서에 따라 자원을 할당하기 어렵다. 첫 자원을 사용한 후에야 둘때 자원 ID를 얻는다면 순서대로 할당하기란 불가능하다.

이렇게 데드락을 피하는 전략은 많다. 하지만 어떤 전략은 기아를 일으키고, 다른 전략은 CPU를 심하게 사용해 응답도를 낮춘다.

0개의 댓글