본문 바로가기

책읽기/클린코드

동시성

객체는 처리의 추상화다.
스레드는 일정의 추상화다.

동시성이 필요한 이유 ?

  • 동시성은 무엇과 언제를 분리하여 결합을 없앤다.
  • 응답 시간, 작업 처리량 개선을 위한 동시성이 필요할 수 있다.

동시성의 미신, 오해

  • 동시성은 항상 성능르 높여준다. -> 대기 시간이 아주 길어 여러 스레드가 프로세서를 공유하거나, 여러 스레드가 동시에 처리할 독립적 계산이 충분히 많은 상황에만 높아진다.
  • 동시성을 구현해도 설계는 바뀌지 않는다. -> 무엇과 언제를 분리하므로 판이하게 다르다.
  • 웹, EJB 컨테이너를 사용하면 동시성을 이해하지 않아도 된다. -> 컨테이너가 어떻게 동작하는지 알아야 문제에 대처가 가능하다.

동시성에 대한 타당한 생각

  • 동시성은 다소 부하를 유발한다.
  • 동시성은 복잡하다.
  • 일반적으로 동시성 버그는 재현하기 어렵다.
  • 동시성을 구현하려면 흔히 근본적인 설계 전략을 재고해야 한다.

동시성이 구현하기 어려운 이유 

public class X {
    private int lastIdUsed;
       
    public int getNextId() {
        return ++lastIdUsed;
    }
}

lastIdUsed 를 10 으로 초기화하고,

두 스레드가 getNextId() 를 접근한다면 아래의 세가지 결과를 예상할 수 있다.

 

  • 한 스레드는 11, 나머지는 12, lastIdUsed = 12
  • 한 스레드는 12, 나머지는 11, lastIdUsed = 12
  • 한 스레드는 11, 나머지도 11, lastIdUsed = 11  --> 문제가 되는 경우

JVM 의 jit 컴파일러는 코드를 바이트코드로 실행한다.

위의 ++lastIdUsed 는 한줄의 코드지만, 바이트코드로 변환하면 여러줄이다.

 

따라서 한줄을 실행하다 다른 스레드로 처리가 옮겨가면 저런 경우가 발생할 수 있다.

 

바이트코드만 고려했을 때, 위의 코드는 12870개의 잠재적인 경로로 결과를 낸다.

이 수많은 경로중 일부의 경로만 문제를 발생시키기에, 동시성 코드는 구현하기가 어렵다고 할 수 있다.

 

동시성 방어 원칙

1) 단일 책임의 원칙

-> 동시성 코드는 다른 일반 코드와 분리한다.

 

2) 따름 정리 : 자료 범위를 제한하라

공유객체를 사용하는 영역을 synchronized 키워드로 임계영역으로 지정하고 보호한다.

-> 자료를 캡슐화하라. 공유 자료를 최대한 줄여라.

 

3) 따름 정리 : 자료 사본을 사용하라

공유 객체가 문제를 일으킨다면, 복사한 객체를 사용하는 것도 방법이다.

사본 생성과 GC 에 드는 비용보다 데드락같은 동기화 문제가 더 클 수 있다.

 

4) 따름 정리 : 스레드는 가능한 독립적으로 구현하라. 

각 스레드는 클라이언트 요청 하나를 처리한다.

모든 정보를 비공유 출처에서 가져오고, 각 스레드에서 로컬 변수만 사용한다면 독립적인 스레드가 될 수 있다.

-> 독자적인 스레드로, 가능하다면 다른 프로세서에서 돌려도 괜찮도록 자료를 독립적인 단위로 분할하라.

 

라이브러리를 이해하라

  • 서로 무관한 작업을 수행할 때는 executor 프레임워크를 사용한다.
  • java.util.concurrent 의 클래스에는 다중 스레드 환경에서 사용하면 안전한 클래스들이 있다.
  • java.util.concurrent.atomic
  • java.util.concurrent.locks

실행 모델을 이해하라

  • 한정된 자원 : 크기나 숫자가 제한된 자원.
  • 상호 배제 : 한 번에 한 스레드만 공유 자료/자원을 사용할 수 있는 경우
  • 기아 : 하나/여러 스레드가 자원을 오래/영원히 기다리는 경우
  • 데드락 : 여러 스레드가 서로 끝내기를 기다리며 스레드가 막힌 경우
  • 라이브락 : 락을 거는 단계에서 서로를 방해하여 스레드가 진행을 못하는 경우

1) 생산자-소비자 모델

생산자 : 하나의 스레드가 정보를 생성하여 buffer 나 queue에 넣는다.

소비자 : 하나의 스레드가 그 정보를 꺼내 사용한다.

 

여기서 buffer, queue 는 한정된 자원이다.

여기가 꽉 차있다면 생산자 스레드는 빈 곳이 나올때 까지 기다린다.

 

생산자는 소비자에게 대기열에 자원이 있다는 신호,

소비자는 생산자에게 대기열에 자리가 있다는 신호를 보낸다.

 

서로 진행이 가능하지만, 서로에게서 신호를 무한정 기다릴 가능성이 있다.

 

 

2) 읽기-쓰기 모델

읽기 스레드가 공유 자원을 사용해 정보를 제공한다.

쓰기 모델은 읽기 모델을 가끔 참조해 정보를 갱신한다.

 

이 경우에는 처리율이 문제의 핵심이다.

 

처리율을 강조하면 기아현상이 생길 수 있다.

읽기 , 쓰기 스레드가 하나가 동작할 때 나머지가 동작하지 못하게 하려면 복잡한 균형잡기가 필요하다.

대게의 경우는 쓰기 스레드가 갱신을 위해 버퍼를 점유할 때, 여러 읽기 쓰레드가 버퍼를 기다리느라 처리량이 떨어진다.

 

해법은,

쓰기 스레드가 읽기 스레드가 모두 끝날 때 까지 기다리는 방법. (쓰기가 기아상태에 걸릴 수 있다.)

쓰기 스레드가 끝날 때 까지 읽기가 기다리는 방법. (처리량에 문제가 생길 수 있다.)

 

 

3) 식사하는 철학자들 모델

여러 프로세스가 자원을 얻기 위해 경쟁을 하고,

자원을 얻지 못한 스레드는 기다린다.

 

데드락, 라이브락, 처리율 저하, 효율성 저하 등 많은 문제를 동반할 수 있다.

주의를 기울여서 설계해야 한다.

 

 

>> 위 세가지의 실행 모델의 알고리즘과 해법을 이해하고 있어야 한다.

 

 

동기화하는 메서드 사이에 존재하는 의존성을 이해하라.

동기화하는 메서드들 사이에 의존성이 존재하면 동시성 코드에 찾기 어려운 버그가 생긴다.

 

-> 공유 객체 하나에는 동기화된 메서드 하나만 사용한다.

 

만약 공유 객체 하나에서 동기화된 메서드 여러개를 사용해야 한다면 다음을 고려한다.

  • 클라이언트에서 잠금 
  • 서버에서 잠금
  • 연결 서버

 

동기화하는 부분을 작게 만들어라

synchronized 는 락을 설정한다.

락으로 감싸진 코드는 한 번에 한 스레드만 접근 가능하다.

락은 스레드를 지연시키고, 부하를 가중시킨다.

 

따라서 임계영역 수를 최대한 줄여야 한다.

-> 동기화하는 부분을 최대한 작게 만들어라

 

올바른 종료 코드는 구현하기 어렵다

데드락, 생산자-소비자 모델 등의 여러 문제점 때문에 스레드가 항상 올바르게 종료되는 코드는 구현하기 어렵다.

 

-> 종료 코드를 개발 초기부터 고민하고 동작하게 초기부터 구현하라. 생각보다 어려우므로 이미 나온 알고리즘을 검토하라.

 

 

스레드 코드 테스트하기

-> 문제를 노출하는 테스트 케이스를 작성.

프로그램 설정, 시스템 설정, 부하를 바꿔가며 자주 돌리기.

실패를 찾으면 원인을 추적

다시 돌렸더니 된다는 이유로 무시하면 안된다.

 

스레드 코드 테스트 지침

  • 말이 안되는 실패는 잠정적인 스레드 문제로 취급하라
  • -> 시스템 실패를 '일회성' 으로 치부하지 마라

 

  • 다중 스레드를 고려하지 않은 수차 코드부터 제대로 돌게 만들자
  • -> 먼저 스레드 환경 밖에서 코드를 올바로 돌린 후 스레드 환경에서 생기는 버그를 디버깅한다.

 

  • 다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있게 스레드 코드를 구현하라.
  • -> 한 스레드, 여러 스레드, 실행중인 스레드 수 바꾸기, 실제 환경, 테스트 환경, 천천히, 빠르게, 반복해서 여러 환경에서 테스트를 한다.

 

  • 다중 스레드를 쓰는 코드 부분을 상황에 맞게 조율할 수 있게 작성하라.

 

  • 프로세서 수보다 많은 스레드를 돌려보라. 스와핑이 잦을 수록 임계영역을 빼먹은 코드나 데드락 코드를 찾기 쉽다.

 

  • 다른 플랫폼에서 돌려보라. 플랫폼마다  스레드 정책이 달라서 다르게 동작할 수 있다.

 

  • 코드에 보조 코드를 넣어 돌려라. 강제로 실패를 일으키게 해보라.
  • wait(), sleep(), yield(), priority() 같은 보조 코드를 직접 추가하여 테스트를 한다.
  • AOF, GGLIB, ASM 같은 도구로 보조 코드를 자동화하여 테스트한다.
  • ThreadJigglePoint.jiggle() 은 jiggle(흔들기) 를 제공한다.
public class ThreadJigglePoint {
    public static void jiggle() { }
}

public synchronized String nextUrlOrNull() {
    if(hasNext()) {
        ThreadJiglePoint.jiggle();
        String url = urlGenerator.next();
        ThreadJiglePoint.jiggle();
        updateHasNext();
        ThreadJiglePoint.jiggle();
        return url;
    }
    return null;
}

 

 

 

결론

다중 스레드 코드는 올바로 구현하기 어렵다.

주의해서 써야 한다.

 

먼저 SRP를 준수한다.

POJO 를 이용하여 스레드를 아는 코드와 모르는 코드를 분리한다.

테스트에는 스레드만 전적으로 테스트한다.

 

동시성 오류를 일으키는 원인을 철저히 이해한다.

예를 들어 여러 스레드가 공유 자료를 이용하거나, 공유 풀을 공유할 때 동시성 오류가 발생한다.

루프 반복을 끝내거나 프로그램을 깔끔하게 종료하는 등 경계 조건의 경우 까다로우므로 주의한다.

 

라이브러리와 기본 알고리즘을 이해한다.

 

보호할 코드 영역을 찾아나고, 잠그는 방법을 이해한다.

잠글 필요가 없으면 잠그지 않느다.

잠긴 영역에서 다른 잠긴 영역을 호출하지 않는다.(의존성)

 

많은 플랫폼에서 많은 설정으로 반복해서 테스트한다.

'책읽기 > 클린코드' 카테고리의 다른 글

냄새와 휴리스틱 - 주석  (0) 2021.01.26
Junit 들여다보기, SerialDate 리펙터링  (0) 2021.01.24
창발성  (0) 2021.01.21
시스템  (0) 2021.01.18
클래스  (0) 2021.01.15