동시성(2)

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

책너두 5기 26일차

로버트 C. 마틴의 클린코드 p. 230~ p.237

내용정리

13.동시성

난관

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

예시

1
2
3
4
5
6
7
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번째와 같은 현상이 동시성으로 인해 발생하게 된다. 두 스레드가 실행하는 잠재적인 경로는 최대 12,870개, 변수가 int가 아니라 long이면 2,704,156개로 증가한다. 이 중 일부경로만 잘못된 결과를 내놓는다.

동시성 방어 원칙

단일 책임 원직(Single Responsibility Principle, SRP)

주어진 메서드/클래스/컴포넌트를 변경할 이유가 하나여야 한다는 원칙이다. 동시성 관련 코드는 다른 코드와 분리해야 한다는 뜻이다. 동시성을 구현할 때는 다음 몇가지를 고려한다.

  • 동시성 코드는 독자적인 개발, 변경, 조율 주기가 있다.
  • 동시성 코드에는 독자적인 난관이 있다. 다른 코드에서 겪는 난관가 다르며 훨씬 어렵다.
  • 잘못 구현한 동시성 코드는 별의별 방식으로 실패한다. 주변에 이쓴ㄴ 다른 코드가 발목을 잡지 않더라도 동시성 하나만으로도 충분히 어렵다.

권장사항: 동시성 코드는 다른 코드와 분리하라

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

공유 객체를 사용하는 코드 내 임계영역(critical section)을 synchronized 키워드로 보호해야한다.

권장사항: 자료를 캡슐화(encapsulation)하라. 공유 자료를 최대한 줄여라.

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

공유 자료를 줄이려면 처음부터 공유하지 않는 방법이 제일 좋다. 객체를 복하나는 시간과 부하를 실측하여 판단하자.

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

다른 스레드와 자료를 공유하지 않고, 클라이언트 요청 하나를 처리하며 모든 정보는 비공유 출처에서 가져오면서 로컬 변수에 저장하도록 스레드를 구현하라.

권장사항: 독자적인 스레드로, 가능하면 다른 프로세서, 돌려도 괜찮도록 자료를 독립적인 단위로 분할하라

라이브러리를 이해하라

스레드 환경에 안전한 컬렉션

권장사항: 언어가 제공하는 클래스를 검토하라

실행 모델을 이해하라

기본 용어

Bound Resource(한정된 자원) 다중 스레드 환경에서 사용하는 자원으로, 크기나 숫자가 제한적이다.
Mutual Exclusion(상호 배제) 한 번에 한 스레드만 공유 자료나 공유 자원을 사용할 수 있는 경우.
Starvation(기아) 한 스레드나 여러 스레드가 굉장히 오랫동안 혹은 영원히 자원을 기다린다.
Deadlock(데드락) 여러 스레드가 서로가 끝나기를 기다린다. 어느 쪽도 진행하지 못한다.
Livelock(라이브락) 락을 거는 단계에서 서로를 방해한다. 공명(resonance)으로 인해, 굉장히 오랫동안 혹은 영원히 진행하지 못한다.

생산자-소비자(Product-consumer)

생산자 스레드와 소비자 스레드가 사용하는 대기열은 한정된 자원이다. 둘은 서로 “대기열에 정보가 있다”는 시그널과 “대기열에 빈 공간이 있다”는 시그널을 보낸다. 따라서 잘못하면 생산자 스레드와 소비자 스레드가 둘 다 진행 가능함에도 불구하고 동시에 서로에게서 시그널을 기다릴 가능성이 존재한다.

읽기-쓰기(Readers-Writers)

읽기 스레드를 위한 주된 정보원으로 공유 자원을 사용하지만, 쓰기 스레드가 이 공유 자원을 이따끔 갱신한다고 하면, 이 때 처리율(throughput)이 문제의 핵심이 된다. 처리율을 강조하면 기아(starvation) 현상이 새익거나 오래된 정보가 쌓인다. 처리율도 적당히 높이고 기아도 방지하는 해법을 위해 양쪽 균형을 잡으면서 동시 갱신 문제를 피해야 한다.

식사하는 철학자들(Dining Philosophers)

둥근 식탁에서 자신의 왼쪽에 포크가 있다고 가정하자. 양손으로 포크를 사용해야 식사할 수 있다면? 누군가 양 손으로 식사하는 동안 옆 사람은 기다려야 한다. 식사가 끝난 스레드는 포크를 내려놓고 배가 고플때까지 다시 생각에 잠긴다. 포크가 자원, 사람(철학자)가 스레드이다. 보통 다중 스레드 문제는 위 3가지 중 하나이므로 각 알고리즘을 공부하고 해법을 직접 구현해보자.

권장사항: 위에서 설명한 기본 알고리즘과 각 해법을 이해하라.

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

권장사항공유 객체 하나에는 메서드 하나만 사용하라.

공유 객체 하나에 여러 메서드가 필요한 상황에는 다음 세 가지 방법을 고려한다.

  • 클라이언트에서 잠금 : 클라이언트에서 첫 번째 메서드를 호출하기 전에 서버를 잠근다. 마지막 메서드를 호출할 떄 까지 유지
  • 서버에서 잠금 : 서버에다 “서버를 잠그고 모든 메서드를 호출한 후 잠금을 해제하는” 메서드를 구현한다. 이 메서드는 클라이언트가 호출한다.
  • 연결(Adapted) 서버 : 잠금을 수행하는 중간 단계를 생성한다.

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

Synchronized 키워드를 위해 락을 설정하면 스레드를 지연시키고 부하르 가중시킨다. 따라서 남발해선 안된다. 하지만 임계영역(critical section)은 반드시 보호해야 한다.

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

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

가장 흔히 발생하는 문제가 데드락이다. 데드락은 영원히 기다리기 때문에 종료되지 않는다. 깔끔하게 종료하는 다중 스레드 코드를 짜야 한다면 시간을 투자해 올바로 구현해야 한다.

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

읽고 나서

정글에서 PintOS를 배울 때 첫 주가 multi thread와 관련된 주제였다. 여기서 synchronized에 대해서 공부했고 lock이나, semaphore 등 공부했었던 기억이 새록새록 떠올랐다. 배웠던게 나오니 반갑기도 하고, 정말 중요해서 어려웠구나 생각도 든다. 이참에 다시 공부해봐야겠다.