본문으로 바로가기

[Clean Code] 13장 동시성

category 카테고리 없음 2021. 3. 26. 18:30

'동시성과 깔끔한 코드는 양립하기 어렵다.'

 

1. 동시성이 필요한 이유?

동시성은 결합을 없애는 전략이다. 즉, 무엇과 언제를 분리하는 전략이다.

 

스레드가 하나인 프로그램은 무엇과 언제가 서로 밀접하다.

그래서 호출 스택을 살펴보면 프로그램 상태가 곧바로 드러난다.

흔히 단일 스레드 프로그램은 정지점(breakpoint)을 정한 후 어느 정지점인지 살펴보면서 시스템 상태를 파악한다.

 

무엇과 언제를 분리하면 애플리케이션 구조와 효율이 극적으로 나아진다.

구조적인 관점에서 프로그램은 거대한 루프 하나가 아니라 작은 협력 프로그램 여럿으로 보인다.

따라서 시스템을 이해하기가 쉽고 문제를 분리하기도 쉽다.

 

구조적 개선만을 위해 동시성을 채택하는 것은 아니다.

어떤 시스템은 응답 시간과 작업 처리량(throughput) 개선을 위해 사용하기도 한다.

 

1) 미신과 오해

  • 동시성은 항상 성능을 높여준다: 동시성은 때로 성능을 높여준다. 대기 시간이 길어 여러 스레드가 프로세서를 공유할 수 있거나, 여러 프로세서가 동시에 처리할 독립적인 계산이 충분한 경우에만 성능이 높아진다.
  • 동시성을 구현해도 설계는 변하지 않는다: 단일 스레드와 다중 스레드의 시스템은 설계가 판이하게 다르다.
  • 웹 또는 EJB 컨테이너를 사용하면 동시성을 이해할 필요가 없다: 실제로 컨테이너가 어떻게 동작하는지, 어떻게 동시 수정, 데드락 등과 같은 문제를 피할 수 있는지를 알아야만 한다.

 

2) 타당한 생각

  • 동시성은 다소 부하를 유발한다: 성능 측면에서 부하가 걸리며, 코드도 더 짜야한다.
  • 동시성은 복잡하다.
  • 일반적으로 동시성 버그는 재현하기 어렵다: 결함으로 간주되지 않고 일회성 문제로 여겨 무시되기 쉽다.
  • 동시성을 구현하려면 흔히 근본적인 설계 전략을 재고해야 한다.

 

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는 44가 된다.

// 객체 하나를 공유한 후 동일 필드를 수정하던 두 스레드가 서로 간섭하므로 예상치 못한 결과를 내놓는다.

 

3. 동시성 방어 원칙

1) 단일 책임 원칙(SRP)

주어진 메서드, 클래스, 컴포넌트를 변경할 이유가 하나여야 한다는 원칙이다.

동시성은 복잡성 하나만으로도 따로 분리할 이유가 충분하다. 즉, 동시성 관련 코드는 다른 코드와 분리해야 한다.

  • 동시성 코드는 독자적인 개발, 변경, 조율 주기가 있다.
  • 동시성 코드에는 독자적인 난관이 있다. 다른 코드에서 겪는 난관과 다르며 훨씬 어렵다.

 

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

공유 객체를 사용하는 코드 내 임계 영역을 synchronized 키워드로 캡슐화하여 보호해야 한다.

이런 임계 영역의 수를 줄이는 기술도 중요하다. 공유 자료를 수정하는 위치가 많을수록 위험성도 커진다.

  • 자료 사본을 사용하라: 공유 자료를 줄이려면 처음부터 사용하지 않는 방법이 제일 좋다. 객체를 복사해 읽기 전용으로 사용하거나 사용 후 결과를 가져오지 않는 방법도 가능하다.
  • 스레드는 가능한 독립적으로 구현하라: 다른 스레드와 자료를 공유하지 않는다. 각 스레드는 클라이언트 요청 하나를 처리한다.

 

4. 실행 모델을 이해하라

1) 기본 용어

  • 한정된 자원(Bound Resource): 다중 스레드 환경에서 사용하는 자원으로 크기나 숫자가 제한적이다. 데이터베이스 연결, 길이가 일정한 읽기/쓰기 버퍼 등이 예이다.
  • 상호 배제(Mutual Exclusion): 한 번에 한 스레드만 공유 자료나 자원을 사용할 수 있다.
  • 기아(Starvation): 한 스레드나 여러 스레드가 굉장히 오랫동안 혹은 영원히 자원을 기다린다.
  • 데드락(Deadlock): 여러 스레드가 서로가 끝나기를 기다린다. 모든 스레드가 각기 필요한 자원을 다른 스레드가 점유하는 바람에 어느 쪽도 더 이상 진행하기 못한다.
  • 라이브락(Livelock): 락을 거는 단계에서 각 스레드가 서로를 방해한다. 스레드는 계속해서 진행하려 하지만, 공명(resonance)으로 인해 오랫동안 진행하지 못한다.

 

2) 생산자-소비자(Producer-Consumer)

하나 이상 생산자 스레드가 정보를 생성해 버퍼나 대기열에 넣는다.

하나 이상 소비자 스레드가 대기열에서 정보를 가져와 사용한다.

 

두 스레드가 사용하는 대기열은 한정된 자원이다.

생산자 스레드는 대기열에 빈 공간이 있어야 정보를 채운다. 즉, 빈 공간이 생길 때까지 기다린다.

소비자 스레드는 대기열에 정보가 있어야 가져온다. 즉, 정보가 채워질 때까지 기다린다.

 

따라서 잘못하면 두 스레드 모두 진행 가능함에도 불구하고 동시에 서로에게 시그널을 기다릴 가능성이 존재한다.

 

3) 읽기-쓰기(Readers-Writers)

읽기 스레드는 주된 정보원으로 공유 자원을 사용하지만

쓰기 스레드가 공유 자원을 가끔 갱신한다고 하자.

 

이런 경우 처리율이 문제의 핵심인데 처리율을 강조하면 기아 현상이 생기거나 오래된 정보가 쌓일 수 있다.

대개는 쓰기 스레드가 버퍼를 오랫동안 점유하는 바람에 여러 읽기 스레드가 버퍼를 기다리느라 처리율이 떨어진다.

 

따라서 읽기 스레드의 요구와 쓰기 스레드의 요구를 적절히 만족시켜 처리율도 적당히 높이고 기아도 방지해야 한다.

 

4) 식사하는 철학자들

여기서 철학자를 스레드로 포크를 자원으로 바꿔 생각해보자

주의해서 설계하지 않으면 데드락, 라이브락, 처리율 저하, 효율성 저하 등을 겪을 수 있다.

 

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

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

공유 객체 하나에는 메서드 하나만 사용해야 한다.

 

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

자바에서 synchronized 키워드를 사용하면, 같은 락으로 감싼 모든 코드 영역은 한 번에 한 스레드만 실행이 가능하다.

락은 스레드를 지연시키고 부하를 가중시키므로 계 영역 수를 최대한 줄여야 한다.

그러나 수를 줄인다고 필요 이상으로 임계 영역 크기를 키우면 스레드 간에 경쟁이 늘어나고 프로그램 성능이 떨어진다.

 

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

깔끔하게 종료되는 코드는 올바로 구현하기 어렵다.

가장 흔히 발생하는 문제가 데드락이다. 즉, 스레드가 절대 오지 않을 시그널을 기다린다.

예를 들어, 부모 스레드가 자식 스레드를 여러 개 만든 후 종료시키려는 데 만약 자식 스레드 중 하나가 데드락에 걸렸다면 부모 스레드는 영원히 기다려야 한다.

 

8. 스레드 코드 테스트하기

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

프로그램 설정과 시스템 설정과 부하를 바꿔가며 자주 돌려라.

테스트가 실패하면 원인을 추적하라. 그냥 넘어가면 절대로 안 된다.