[CleanCode] -13. 동시성

Young Min Sim ·2021년 5월 4일
0

CleanCode

목록 보기
13/16

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

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

동시성은 바로 이 무엇언제를 분리하여 결합을 없애는 전략이다.
또한 응답 시간과 작업 처리량 개선을 위해서이기도 하다.

이렇듯 동시성은 다양한 장점을 가지고 있지만, 이해 관한 오해도 존재한다.

  • 동시성은 항상 성능을 높여준다. (x)
    -> 동시성은 '경우에 따라서' 성능을 높여준다.
    • 대기 시간이 아주 길어 여러 스레드가 프로세서를 공유할 수 있거나
    • 여러 프로세서가 동시에 처리할 독립적인 계산이 충분히 많은 경우에만 성능이 높아진다.
      (둘 다 일상적으로 발생하는 상황은 아니다)
  • 동시성을 구현해도 설계는 변하지 않는다.(x)
    -> 무엇언제를 분리하면 시스템 구조가 크게 달라진다.
    단일 스레드, 다중 스레드 시스템의 설계는 판이하게 다르다.

이번엔 반대로 동시성과 관련된 타당한 생각들이다.

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

    Lock 으로 인한 오버헤드, context switching 정도를 말하는게 아닐까 생각되네요.

  • 동시성은 복잡하다.
  • 동시성 버그는 재현하기 어렵다.

    일어날 때도 있고 일어나지 않을 때도 있기 때문에..

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

2. 난관

public class X {
   private int lastIdUsed;

   public int getNextId() {
      return ++lastIdUsed;
   }
}

두 스레드가 위 클래스의 같은 변수를 동시에 참조하는 경우

lastIdUsed 가 42이고 두 개의 스레드가 한 번씩 증가를 시켜도 44가 아닌,
43이 되는 경우가 발생할 수도 있다. 두 스레드가 자바 코드 한 줄을 거쳐가는 경로는 수없이 많기 때문이다.
(책에선 잠재적으로 12,870개라고 언급)

대다수의 경우 올바른 결과를 내놓지만, 문제는 잘못된 결과를 내놓는 '일부 경로'가 존재한다는 것이다.

아래부터는 이를 해결하는 방법들에 대해 다룬다.


3. 동시성 방어 원칙

동시성 코드가 일으키는 문제로부터 시스템을 방어하는 원칙과 기술들

1. 단일 책임 원칙

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

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

자료를 캡슐화하고 공유 자료를 최대한 줄여라

여기저기 산발적으로 퍼져있을 수록, 많을 수록 빼먹기 쉽기 때문

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

객체를 복사해 읽기 전용으로 사용할 수 있는 경우라면 그렇게 하는 것이 좋다.

복사 비용 < Lock 으로 인한 비용

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

(가능하면) 다른 스레드와 자료를 공유하지 않는 것이 좋다.


4. 실행 모델을 이해하라

멀티스레드에 대해 다루는 전통적인 실행 모델들을 다룬다.

1. 생산자-소비자

생산자는 empty.acquire() // # of permit = BUF_SIZE
즉, 버퍼가 가득 차면 기다리고

소비자는 full.acquire() // # of permit = 0
버퍼가 비면 기다린다.

서로에게 시그널을 보내어 락을 해제하므로
잘 못 설계하면 동시에 서로에게서 시그널을 기다릴 가능성이 존재한다.

2. 읽기-쓰기

쓰기 스레드가 버퍼를 갱신하는 동안 읽기 스레드가 버퍼를 읽지 않으려면,
읽기 스레드가 버퍼를 읽는 동안 쓰기 스레드가 버퍼를 갱신하지 않으려면,
복잡한 균형잡기가 필요하다.

읽기 스레드에게만 우선권을 부여하면 쓰기 스레드는 기아 상태에 빠질 것이다.
반면 쓰기 스레드에게만 우선권을 부여하면 처리율이 떨어질 것이다.

즉, 동시 갱신 문제를 회피하면서도 양쪽 균형을 잡을 수 있는 해법이 필요하다.

3. 식사하는 철학자들

둥근 식탁에 철학자 한 무리가 둘러앉아있다.
각 철학자 왼쪽에는 포크가 놓였다. 스파게티를 먹으려면 양 손에 포크를 들어야 하기 때문에 누군가는 먹지 못하는 상황이 생긴다.
잘 설계하지 않으면 데드락, 처리율 저하를 겪게 된다.

일상에서 접하는 대다수의 멀티 스레드 문제는 위 세 범주 중 하나에 속한다.
각 알고리즘을 공부하고 해법을 이해하면 잘 대처할 수 있다.


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

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

공유 객체 하나에는 여러 메서드가 필요한 상황도 생기는데 그런 경우에는,

  • 클라이언트 잠금
  • 서버에서 잠금
    • 여러 클라이언트에서 잠금 처리해 줄 필요 없이 서버에서만 처리해주면 되기 때문에 가장 선호
  • 어댑터 패턴을 이용하여 API를 변경한 후 잠금 추가
    • 만약 서버 쪽의 코드를 변경할 수 없는 경우에 사용

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

Lock은 스레드를 지연시키고 부하를 가중시킨다. 그러므로 여기저기서 Lock 을 남발하는 코드는 바람직하지 않다.
임계영역은 최대한 줄여야 한다.


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

ex) 부모 스레드가 모든 스레드에게 종료하라는 시그널을 보냈는데
자식 스레드 중 생산자-소비자 관계가 있는 경우

생산자 스레드는 종료했는데 소비자 스레드가 생산자 스레드에서 오는 메시지를 기다리는 상태라면 ?
소비자 스레드는 block 상태이므로 부모 스레드로부터 종료하라는 시그널을 받지 못한다.
소비자 스레드는 생산자 스레드를 영원히 기다리고, 부모 스레드는 소비자 스레드를 영원히 기다린다.

위와 같은 다양한 문제들이 있기 때문에
깔끔하게 종료하는 코드는 올바르게 구현하기 어려우므로
시간을 많이 투자해 구현하고 이미 나온 알고리즘을 검토하라.


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

멀티 스레드 상황에서의 테스트 코드는 단일 스레드의 테스트코드와 다르다.

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

즉 고려할 것이 많다는 것.

고려할 몇 가지 구체적인 지침들

  1. 말이 안되는 실패는 잠정적인 스레드 문제로 취급하라
    • 스레드 코드에 잠입한 버그는 수천 수백만 번에 한 번씩 드러나기도 하기 때문에
      실패를 재현하기 아주 어렵다. 따라서 일회성으로 치부하면 안된다.
  2. 다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자
    • 스레드 환경 밖에서 생기는 버그와 스레드 환경에서 생기는 버그를 동시에 디버깅하면 안된다.
      먼저 스레드 환경 밖에서 코드가 올바르게 동작하는지 검사하자
  3. 다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있도록 스레드 코드를 구현하라
    • 스레드 개수를 조절할 수 있고, 다양한 속도에서 돌려볼 수 있도록 한다.
  4. 프로세서 수보다 많은 스레드를 돌려보라
    • 스와핑이 잦을 수록 (동시성으로 인한 문제가 발생할 가능성이 높아지기 때문에 그만큼)
      임계영역을 빼먹은 코드나 데드락을 일으키는 코드를 찾기 쉬워진다.
  5. 코드에 보조 코드를 넣어 돌려라. 강제로 실패를 일으키게 해보라

결론

  • SRP 준수
  • 테스트할 때는 스레드만 테스트
  • 잠정적인 원인을 철저히 이해

    생산자-소비자, 읽기-쓰기, 식사하는 철학자와 같은 케이스

  • 보호할 코드 영역을 찾아내는 방법과 잠그는 방법에 대한 이해
  • 공유하는 객체 수와 범위를 최대한 줄인다.
  • 많은 플랫폼에서 많은 설정으로 반복해서 계속 테스트해야 한다.
  • 오류가 드러날 가능성을 높여주는 여러 방법(스레드 수를 늘리거나 보조코드를 넣거나)를 사용한다.

0개의 댓글