Clean Code: 동시성

jiffydev·2021년 6월 17일
0

Clean Code

목록 보기
12/13

다중 스레드 코드는 구현하기 어렵다. 따라서 다음과 같은 원칙을 준수해야 한다.

  • SRP를 준수하여 스레드를 아는 코드와 모르는 코드를 분리
  • 동시성 오류를 일으키는 잠정적 원인을 철저히 이해
  • 사용하는 라이브러리와 기본 알고리즘 파악
  • 보호할 코드 영역을 찾아내는 방법과 특정 코드 영역을 잠그는 방법 이해
  • 많은 플랫폼에서 많은 설정으로 반복해서 계속 테스트

1. 동시성이 필요한 이유

동시성은 결합(coupling)을 없애는 전략.
'무엇'과 '언제'를 분리하면 애플리케이션 구조와 효율이 개선되기 때문에 동시성 사용.
응답 시간과 작업 처리량 개선에도 동시성이 사용될 수 있음.

하지만 동시성은 구현하기 어렵다. 다음과 같은 생각을 가지고 구현해야 한다.

  • 동시성은 다소 부하를 유발한다

  • 동시성은 복잡하다

  • 일반적으로 동시성 버그는 재현하기 어렵기에 진짜 결함으로 간주되지 않고 무시하는 경우가 많다

  • 동시성을 구현하려면 근본적인 설계 전략을 재고해야 한다

2. 난관

다음과 같은 간단한 클래스에서도 동시성 구현의 어려움을 볼 수 있다.

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

인스턴스 X를 생성하고 lastIdUsed를 42로 설정한 다음, 두 스레드가 해당 인스턴스를 공유한다. 두 스레드가 getNextId()를 호출하면 결과는 세 가지로 예상할 수 있다.

  • 한 스레드는 43, 다른 스레드는 44를 받아 lastIdUsed는 44가 된다.

  • 한 스레드는 44, 다른 스레드는 43을 받아 lastIdUsed는 44가 된다.

  • 한 스레드는 43, 다른 스레드는 43을 받아 lastIdUsed는 43이 된다.

세 번째처럼 말도 안되는 결과가 발생할 수 있다는 점이 동시성 구현의 어려운 점이다. 물론 대부분은 올바른 결과가 나오지만, 잘못된 일부 경로가 문제를 일으킨다.

3. 동시성 방어 원칙

  • 단일 책임 원칙(SRP)
    동시성 관련 코드는 다른 코드와 분리해야 한다.
    동시성 코드는 독자적인 개발, 변경, 조율 주기가 있다.
    동시성 코드에는 독자적인 난관이 있다. 이는 다른 코드에서의 난관과는 전혀 다르며 훨씬 어렵다.
    • 따름 정리 1: 자료 범위 제한
      자료를 캡슐화하고 공유자료는 최대한 줄여라
      공유 객체를 사용한다면 임계영역(critical section)을 (자바의 경우) synchronized 키워드로 보호.
      하지만 이런 임계영역의 수를 줄이는 기술이 중요
    • 따름 정리 2: 자료 사본 사용
      공유 객체를 사용해야 한다면 객체를 복사해 읽기 전용으로 사용하거나
      각 스레드가 객체를 복사해 사용한 후 한 스레드가 해당 사본에서 결과를 가져오는 방법을 사용
    • 따름 정리 3: 스레드는 독립적으로 구현
      독자적인 스레드, 가능하면 다른 프로세서에서, 자료를 독립적인 단위로 분할하라
      각 스레드는 요청 하나를 처리
      정보는 비공유 출처에서 가져오며 로컬 변수에 저장

4. 라이브러리를 이해

  • 스레드 환경에 안전한 컬렉션
    언어가 제공하는 클래스를 검토.

5. 실행 모델 이해

동시성 관련 용어

  • 한정된 자원: 다중 스레드 환경에서 사용하는 자원으로 크기나 숫자가 제한적.

  • 상호 배제: 한 번에 한 스레드만 공유 자료나 자원을 사용할 수 있는 경우.

  • 기아: 한 스레드나 여러 스레드가 오랫동안 혹은 영원히 자원을 기다림. 예를 들어 항상 짧은 스레드에게 우선순위를 주면 짧은 스레드가 지속될 경우 긴 스레드가 기아상태에 빠짐.

  • 데드락: 여러 스레드가 서로가 끝나기를 기다림. 모든 스레드가 각기 필요한 자원을 다른 스레드가 점유하는 바람에 어느 쪽도 더 이상 진행하지 못함.

  • 라이브락: 락을 거는 단계에서 각 스레드가 서로 방해. 스레드는 계속해서 진행하려 하지만, 공명으로 인해 오랫동안 혹은 영원히 진행하지 못함

실행 모델

  • 생산자-소비자
    생산자 스레드는 정보를 생성해 버퍼나 대기열(queue)에 넣고, 소비자 스레드는 대기열에서 정보를 가져와 사용한다.
    대기열은 한정된 자원으로, 생산자 스레드는 빈 공간이 있어야 정보를 채우고 소비자 스레드는 대기열에 정보가 있을 때까지 기다린다.
    생산자 스레드는 대기열에 정보를 채운 뒤 소비자 스레드에게 정보가 있다는 시그널을 보내고,
    소비자 스레드는 대기열에서 정보를 읽어들인 후 생산자 스레드에게 대기열에 빈 공간이 있다는 시그널을 보낸다.
    이 때 두 스레드가 서로 진행이 가능함에도 동시에 서로에게서 시그널을 기다릴 가능성이 존재한다.

  • 읽기-쓰기
    읽기 스레드를 위한 정보원으로 공유 자원을 사용하지만, 이따금 쓰기 스레드가 이 자원을 갱신해야 할 때가 있다.
    이 때 처리율을 강조하면 기아 현상이 생기거나 오래된 정보가 쌓인다.
    반면 갱신을 하다 보면 쓰기 스레드가 버퍼를 오랫동안 점유하는 바람에 처리율이 떨어진다.
    양쪽 균형을 잡으면서 동시 갱신 문제를 피하는 해법이 필요하다.

  • 식사하는 철학자들

    자세한 설명은 https://en.wikipedia.org/wiki/Dining_philosophers_problem 에서 확인 가능하다.
    여러 프로세스가 자원을 얻기 위해 경쟁해야 하는 상황에서, 주의해서 설계하지 않으면 데드락, 라이브락, 처리율 저하, 효율성 저하, 기아를 겪게 된다.

대다수 다중 스레드 문제는 위 세 범주 중 하나에 속한다. 각 알고리즘을 공부하고 해법을 직접 구현해 보는 것을 추천한다.

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

동기화하는 메서드 사이에 의존성이 존재하면 찾아내기 어려운 버그가 생긴다.
공유 클래스 하나에 동기화된 메서드가 여럿이라면 우선은 구현이 올바른지 다시 한 번 확인해야 한다.

공유 객체 하나에 여러 메서드가 필요한 상황이라면 다음과 같은 방법을 고려할 수 있다.

  • 클라이언트에서 첫 번재 메서드를 호출하기 전에 서버를 잠그고, 마지막 메서드를 호출할 때까지 잠금 유지

  • 서버를 잠그고 모든 메서드를 호출한 후 잠금을 해제하는 메서드를 서버에 구현하고, 클라이언트가 이를 호출

  • 잠금을 수행하는 중간 단계 생성. 원래 서버는 변경하지 않는다.

7. 동기화하는 부분을 작게

자바에서는 synchronized를 사용해 락을 설정할 수 있다.
락으로 감싼 모든 코드는 한 번에 한 스레드만 실행 가능하지만 스레드를 지연시키고 부하를 가중시킨다.
따라서 코드를 짤 때는 synchronized를 남발해서는 안되고 반드시 보호해야 하는 임계영역 수는 줄여야 한다.

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

깔끔하게 종료하는 코드를 개발 초기부터 고민하고, 동작하도록 초기부터 구현하라.
어렵고 오래 걸리는 작업이므로 이미 나온 알고리즘을 검토하라.

9. 스레드 코드 테스트

문제를 노출하는 테스트 케이스를 작성하고, 프로그램 설정/시스템 설정/부하를 바꿔가며 자주 돌려라.
테스트가 실패하면 원인을 추적하라. 다시 돌렸더니 통과한다는 이유로 그냥 넘어가서는 안 된다.

테스트 작성시에는 다음과 같은 지침을 따를 것을 추천한다.

  • 말이 안 되는 실패는 잠정적인 스레드 문제로 취급
    다중 스레드 코드는 때때로 '말이 안 되는' 오류를 일으킨다. 실패를 재현하기도 어렵다.
    이를 '일회성 문제'로 생각하고 무시하면 잘못된 코드 위에 코드가 계속 쌓인다.

  • 다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자
    스레드 환경 밖에서 코드가 제대로 도는지 반드시 확인한다.
    스레드 환경 밖에서 생기는 버그와 스레드 환경에서 생기는 버그를 동시에 디버깅하지 마라.

  • 다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있게 스레드 코드를 구현
    한 스레드로 실행하거나, 여러 스레드로 실행하거나, 실행 중 스레드 수를 바꿔본다.
    스레드 코드를 실제 환경이나 테스트 환경에서 돌려본다.
    테스트 코드를 빨리, 천천히, 다양한 속도로 돌려본다.
    반복 테스트가 가능하도록 테스트 케이스를 작성한다.

  • 다중 스레드를 쓰는 코드 부분을 상황에 맞게 조율할 수 있게 작성
    처음부터 다양한 설정으로 성능 측정 방법을 강구한다.
    따라서 스레드 개수를 조율하기 쉽게 코드를 구현하고, 프로그램이 돌아가는 중에 스레드 개수를 변경하는 방법도 고려한다.
    처리율과 효율에 따라 스스로 스레드 개수를 조율하는 코드도 고민한다.

  • 프로세서 수보다 많은 스레드를 돌려보라
    시스템이 스레드를 스와핑할 때도 문제가 발생한다.
    스와핑을 일으키려면 프로세서 수보다 많은 스레드를 돌린다.
    스와핑이 잦을수록 임계영역을 빼먹은 코드나 데드락을 일으키는 코드를 찾기 쉬워진다.

  • 다른 플랫폼에서 돌려보라
    다중 스레드 코드는 플랫폼에 따라 다르게 돌아간다.
    따라서 코드가 돌아갈 가능성이 있는 플랫폼 전부에서 테스트를 수행해야 한다.

  • 코드에 보조 코드를 넣어 돌려라. 강제로 실패를 일으키게 해보라
    스레드 버그가 재현이 어려운 이유는 코드가 실행되는 수천 가지 경로 중 극소수만 실패하기 때문이다.
    보조 코드를 추가해 코드가 실행되는 순서를 바꿔주면 오류를 좀 더 자주 일으킬 수 있다.
    보조 코드를 추가하는 방법은 두 가지다.

    • 직접 구현
      코드에 직접 wait(), sleep(), yield(), priority()함수를 추가한다.
    • 자동화
      도구를 사용하면 함수를 무작위로 수행할 수 있고, 테스트 환경과 배포 환경을 나눠서 사용할 수 있다.

좋은 테스트 케이스와 흔들기 기법은 오류가 드러날 확률을 크게 높여준다.

profile
잘 & 열심히 살고싶은 개발자

0개의 댓글