[Clean Code] XIII. 동시성

Clean Code 3판을 읽고 정리한 글입니다

XIII. 동시성(Concurrency)

“Objects are abstractions of processing. Threads are abstractions of schedule.”
객체는 처리의 추상화. 스레드는 일정의 추상화.

James O. Coplien밥아저씨 지인

동시성과 클린 코드의 양립은 아주 어렵다

  • 단일 스레드에서 동작하는 코드는 작성하기 쉽다
  • 잘 동작하는 “것 처럼” 보이는 멀티 스레드 코드를 작성하기도 쉽다
  • 부하를 받기 전까지 멀쩡하게 돌아간다

동시성(Concurrency)이 필요한 이유?

동시성 = what과 when을 분리하는 전략

  • 동시성은 coupling을 없애는 전략 : what과 when
  • 스레드가 하나인 프로그램 : what과 when이 강하게 결합되어 있고 밀접
    • 단일 스레드 디버깅: breakpoint 정한 후 어느 정지점에 걸렸는가 시스템 파악

what when의 분리 → 어플리케이션의 구조와 효율의 극적 개선

  • 구조관점 : 거대한 loop가 아닌 작은 여러개의 협력 프로그램으로 보임 → 시스템 이해가 쉽고 문제 분리도 쉬움
    • 예시 ) 서블릿
      • 서블릿은 서블릿 컨테이너 안에서 돌아감 → 컨테이너가 동시성을 ‘부분적으로’ 관리
      • 웹 요청시 마다 웹 서버는 비동기식으로 서블릿 실행
      • (이론적으로) 서블릿 개발자는 들어오는 모든 웹 요청 관리가 필요없음
      • (원칙적으로) 각 서블릿 스레드는 다른 서블릿 스레드와 무관하게 자신만의 세상에서 돌아간다

동시성이 이렇게 간단하면 이번 챕터가 필요 없었을 것

  • 서블릿이 제공하는 의존성 해소는 완벽하지 않음
  • 웹 컨테이너의 deCoupling → 완벽과 거리가 아주 멀다
  • 서블릿 프로그래머는 동시성을 정확히 구현하도록 각별한 주의와 노력을 기울여야 한다.
  • 그럼에도 서블릿 모델이 제공하는 구조적 이점은 아주 크다.

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

  • 어떤 시스템은 응답 시간과 작업 처리량 (throughput) 개선이라는 요구사항으로 인해 직접적인 동시성 구현이 불가피하다
  • 예시1) 매일 수많은 웹 사이트에서 정보를 가져와 요약하는 정보 수집기인데 단일 스레드 프로그램???
    • 한 번에 한 웹 사이트를 방문해 정보를 가져오고, 이 과정에서 한 사이트를 끝내야 다음 사이트로 넘어간다
    • 웹 사이트를 계속 추가하면 정보 수집하는 시간도 늘어난다
    • 매일 실행하므로 24시간 안에 끝나야 하지만 24시간을 넘기게 된다
    • 단일 스레드 수집기는 웹 소켓에서 입출력을 기다리는 시간이 아주 많다
    • 따라서 다중 스레드 알고리즘을 사용하면 수집기 성능을 높일 수 있다.
  • 예시 2) 한 번에 한 사용자를 처리하는 시스템: 한 사용자를 처리하는 시간은 1초
    • 사용자가 소수라면 시스템이 아주 빨리 반응하지만, 사용자 수가 늘어날수록 시스템이 응답하는 속도도 늦어진다
    • 150명 뒤에 줄 서려는 사용자는 없다
    • 대신 많은 사용자를 동시에 처리하면 시스템 응답 시간을 높일 수 있다
  • 예시 3) 정보를 대량으로 분석하는 시스템
    • 모든 정보를 처리한 후에야 최종적인 답을 낼 수 있다
    • 정보를 나눠 여러 컴퓨터에서 돌리면 어떨까? 대량의 정보를 병렬로 처리한다면?

미신과 오해

동시성은 어려우며 각별히 주의하지 않으면 난감한 상황에 빠진다

일반적인 오해와 미신

동시성은 항상 성능을 높여준다?
  • 동시성은 항상은 아니고 때로 성능을 높여준다
  • 대기 시간이 아주 길어 여러 스레드가 프로세서를 공유할 수 있거나, 여러 프로세서가 동시에 처리할 독립적인 계산이 충분히 많은 경우에만 성능이 높아진다
  • 일상적으로 발생하는 상황은 아니다
동시성을 구현해도 설계는 변하지 않는다?
  • 단일 스레드 시스템과 다중 스레드 시스템은 설계가 판이하게 다르다
  • 일반적으로 무엇과 언제를 분리하면 시스템 구조가 크게 달라진다
웹 또는 EJB 컨테이너를 사용하면 동시성을 이해할 필요가 없다?
  • 실제로는 컨테이너 동작 원리와 어떻게 동시 수정(concurrent update), 데드락 등과 같은 문제를 피할 수 있는지 알아야만 한다

동시성과 관련된 타당한 생각들

동시성은 다소 부하를 유발한다
  • 성능 측면에서 부하가 걸리며, 코드도 더 짜야한다 → 성능, 코드 작성 양쪽 모두 약간의 오버헤드를 일으킴
동시성은 복잡하다
  • 간단한 문제라도 동시성은 복잡해서 해결이 쉽지 않음
일반적으로 동시성 버그는 재현하기 어렵다]
  • 그래서 진짜 결함으로 간주되지 않고 일회성 문제(one-off)로 여겨 무시하기 쉽다
동시성을 구현하려면 흔히 근본적인 설계 전략을 재고해야 한다

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ClassWithThreadingProblem {
private int lastIdUsed;

public ClassWithThreadingProblem(int lastIdUsed) {
this.lastIdUsed = lastIdUsed;
}

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

public static void main(String args[]) {
final ClassWithThreadingProblem classWithThreadingProblem = new ClassWithThreadingProblem(42);

Runnable runnable = new Runnable() {
public void run() {
classWithThreadingProblem.getNextId();
}
};

Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
t2.start();
}
}

설명

  • 인스턴스 ClassWithThreadingProblem를 생성하고, lastIdUsed 필드를 42로 설정한 다음, 두 스레드가 해당 인스턴스를 공유한다
  • 이제 두 스레드가 getNextId();를 호출한다고 가정하자. 결과는 셋 중 하나다.
    • 한 스레드는 43을 받는다. 다른 스레드는 44를 받는다. lastIdUsed는 44가 된다.
    • 한 스레드는 44를 받는다. 다른 스레드는 43을 받는다. lastIdUsed는 44가 된다.
    • 한 스레드는 43을 받는다. 다른 스레드는 43을 받는다. lastIdUsed는 43이 된다. (??)
  • 2스레드가 실행하는 잠재적 경로(N=8,T=2)는 12,870개
  • 극히 드물지만 위와 같이 잘못된 결과가 나오게 된다

동시성 방어 원칙

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

  • 주어진 메서드/클래스/컴포넌트를 변경할 이유는 하나여야 한다는 원칙
  • 동시성은 복잡성 하나만으로도 따로 분리할 이유가 충분 → 동시성 관련 코드는 다른 코드와 분리해야 한다
  • 불행히 동시성과 관련이 없는 코드에 동시성을 곧바로 구현하는 사례가 너무도 흔함
  • 동시성 구현시 고려사항
  1. 동시성 코드는 독자적인 개발, 변경, 조율 주기가 있다.
  2. 동시성 코드에는 독자적인 난관이 있다. 다른 코드에서 겪는 난관과 다르며 훨씬 어렵다.
  3. 잘못 구현한 동시성 코드는 별의별 방식으로 실패한다. 주변에 있는 다른 코드가 발목을 잡지 않더라도 동시성 하나만으로도 충분히 어렵다.
    권장사항 : 동시성 코드는 다른 코드와 분리하라!

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

  • 위 예제에서 보듯이, 객체 하나를 공유한 후 동일 필드를 수정하던 두 스레드가 서로 간섭하므로 예상치 못한 결과를 내놓는다.
  • 이런 문제를 해결하는 방안으로 공유 객체를 사용하느 코드 내 임계 영역(critical section)을 synchronized 키워드로 보호하라고 권장한다
  • 이런 임계영역의 수를 줄이는 기술이 중요하다.
  • 공유 자료 수정 위치가 많아지면?
    1. 보호할 임계영역을 빼먹는다. 그래서 공유 자료를 수정하는 모든 코드를 망가뜨린다
    2. 모든 임계영역을 올바로 보호했는지(DRY 위반) 확인하느라 똑같은 노력과 수고를 반복한다
    3. 그렇지 않아도 찾아내기 어려운 버그가 더욱 찾기 어려워진다
      권장사항 : 자료를 EnCapsulation하고 공유 자료를 최대한 줄여라~!

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

  • 공유 자료를 줄이는 가장 좋은 방법? → 처음부터 공유하지 않는 방법
    • 어떤 경우엔 객체를 복사해 읽기 전용으로 사용하는 것이 가능
    • 어떤 경우에는 객체의 복사본을 각 스레드에 전달, 작업을 수행하고 결과를 단일 스레드에서 수집해 사용하는 것도 가능
  • 공유 객체를 피하는 방법이 있다면 코드가 문제를 일으킬 가능성도 아주 낮아진다
  • 객체의 복사에 드는 비용(시간과 부하)은?
    • 이 복사 비용이 정말 문제인지 실측할 필요가 있음
    • 사본 사용으로 동기화 피할 수 있으면? → 내부 잠금이 없음 → 수행 시간 절약 → 사본생성과 가비지 컬렉션의 비용 상쇄할 가능성이 크다

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

자신만의 세상속의 스레드를 구현한다면?

  • 다른 스레드와 자료 공유 안함
  • 각 스레드는 클라이언트 요청 하나 처리
  • 모든 정보는 비공유 출처에서 가져오며 로컬 변수에 저장
    → 다른 스레드와 동기화가 필요없음 → 각 스레드는 자신만 존재하듯 돌아갈 수 있음 → 동기화 문제 없어짐

ex)HttpServlet

  • HttpServlet의 파생 클래스는 모든 정보를 doGet, doPost 매개변수로 받아 처리
  • 각 서블릿은 마치 자신이 독자적인 시스템에서 동작하는 것처럼 요청 처리
  • 지역 변수를 사용하는 한 동기화 문제는 발생하지 않는다
  • 물론 대부분의 Servlet 어플리케이션은 결국 DB 연결과 같은 자원을 공유하는 상황에 처한다
    권장사항 : 독자적인 스레드로 (가능하면 다른 프로세서에서 돌려도 괜찮도록) 자료를 독립적인 단위로 분할!

라이브러리를 이해하라

자바 5부터 동시성 측면에서 이전 버전보다 많이 개선 되었음. 5이상 버전부터 스레드 구현시 고려사항

  • 자바5부터 제공하는 thread-safe 컬랙션을 사용한다
  • 서로 연관없이 무관한 작업을 수행 시 executor 프레임워크를 사용
  • 가능하면 스레드가 차단(block)되지 않는 논 블러킹 방법을 사용
  • 일부 클래스 라이브러리들은 thread-safe하지 않음에 주의

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

java.util.concurrent

  • 자바 초창기 : Doug Lea, Concurrent Programming in Java
  • 해당 책에서 스레드에서 안전한 컬렉션 몇 개를 구현 했는데 나중에 자바 패키지에 추가됨
  • 해당 패키지의 클래스는 다중 스레드에서 안정되며, 성능도 좋음
  • ConcurrentHashMap은 실제로 거의 모든 상황에서 HashMap보다 빠르다
    • 동시 읽기/쓰기 지원
    • (보통 다중 스레드에서 문제가 생기는) 자주 사용하는 복합 연산을 안전하게 만든 메서드로 제공

좀 더 복잡한 동시성 설계를 지원하는 클래스 몇 가지

Name Description
ReentrantLock 한 메서드에서 잠그고 다른 메서드에서 풀 수 있는 lock이다.
Semaphore 전통적인 세마포어(갯수를 셀 수 있는 lock)의 구현체.
CountDownLatch 지정한 특정 횟수의 이벤트가 발생하고 나서야 기다리는 모든 스레드들을 해제 할 수 있는 lock이다. 모든 스레드가 거의 동시에 시작될 수 있는 기회를 준다
권장사항 :
  • 언어가 제공하는 클래스를 검토하라. 자바의 경우 다음을 익혀라
    • java.util.concurrent
    • java.util.concurrent.atomic
    • java.util.concurrent.locks

실행 모델을 이해하라

기본용어

Name Description
Bound Resources(한정된 자원) Concurrent 환경에서 사용되는 고정된 크기의 자원이다. 예시로 데이터베이스 연결, 고정된 크기의 읽기/쓰기 버퍼가 있다.
Mutual Exclusion(상호 배제) 한 번에 한 스레드만 공유 자료/공유 자원을 사용할 수 있는 경우
Starvation(기아) 한 스레드 or 스레드 그룹이 긴 시간 or 영원히 자원 대기. 예를 들어, 항상 짧은 스레드에게 우선 순위를 준다면, 수행 시간이 긴 스레드는 굶게 된다(기아 상태).
Deadlock(데드락) 여러 스레드가 서로가 끝나기를 기다린다. 각 스레드는 서로가 필요로 하는 자원을 점유하고 있기 떄문에 어느 쪽도 더 이상 진행하지 못함
Livelock(라이브락) 락을 거는 단계에서 각 스레드가 서로를 방해. 스레드들이 서로 작업을 수행하려는 중 다른 스레드가 작업중인 것을 인지하고 서로 양보한다. 이러한 공명(resonance) 때문에 스레드들은 작업을 계속 수행하려 하지만 장시간 혹은 영원히 작업을 수행하지 못하게 된다.

기본 개념을 이해했으면 다중 스레드 프로그래밍에서 사용하는 실행 모델을 살펴본다

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

  • 한 개 이상의 생산자 스레드가 정보를 생성한 후 버퍼나 큐에 넣는다
  • 한 개 이상의 소비자 스레드가 버퍼 혹은 큐에서 정보를 가져와 사용
  • 생산자와 소비자 사이에 있는 큐는 bound resource(한정된 자원)이다
  • 따라서 생산자는 큐에 빈 공간이 생길 때까지, 소비자는 큐에 정보가 하나라도 생길 때까지 기다린다
  • 대기열을 올바로 사용하기 위해 생산자/소비자는 서로에게 시그널을 보낸다
  • 생산자는 큐에 작업물을 넣고 소비자에게 “큐가 비어있지 않다”는 신호를 보내고
    소비자는 큐에서 작업물을 꺼낸 후 “큐가 가득차 있지 않다”는 신호를 보낸다 그 전까지 둘은 신호를 기다린다.
  • 따라서 잘못하면 생산자/소비자 둘 다 진행 가능함에도 서로 시그널을 기다릴 가능성이 존재

읽기-쓰기(Readers-Writers)

예) 읽기 스레드를 위한 주된 정보원으로 사용되는 공유자원인데, 쓰기 스레드에 의해 이따금씩 갱신되는 경우
예시의 경우 처리율(throughput)이 핵심 포인트

  • 쓰기가 갱신 중에 읽기 스레드가 멈추고, 읽기 스레드가 읽는 중엔 쓰기 스레드가 갱신을 멈춰야 한다
  • 처리율 강조? 독자가 상대적 우선권 → starvation(기아) 현상 or 오래되고 정체된 정보로 가득
  • 갱신 허용? : 쓰기가 상대적 우선권 → 처리율이 감소

간단한 해법 : 읽기 스레드가 없을 때까지 갱신을 원하는 쓰기 스레드가 버퍼를 기다리는 방법

  • 읽기 스레드 계속? 쓰기가 기아 상태에 빠짐
  • 쓰기 스레드 계속? 처리율이 떨어짐

양쪽 균형을 잡으면서 동시 갱신 문제를 피하는 해법이 필요

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

예시

  • 원탁을 둘러싼 여러 명의 철학자들
  • 각 철학자의 왼쪽에 포크가 놓여 있으며 테이블의 중앙에 큰 스파게티 한 그릇이 놓여 있다
  • 그들은 배가 고파지기 전까지 각자 생각을 하며 시간을 보낸다
  • 배가 고파지면 그들은 자신의 양쪽에 놓여 있는 포크 2개를 잡고 스파게티를 먹는다
  • 철학자는 포크 2개가 있어야만 스파게티를 먹을 수 있다.
  • 그렇지 않다면 옆 사람이 포크를 다 사용하기 전까지 기다려야 한다.
  • 스파게티를 먹은 철학자는 다시 배가 고파질 때까지 포크를 놓고 있게 된다.
  • 위 상황에서 철학자를 스레드로, 포크를 공유 자원으로 바꾸게 되면?
    이는 자원을 놓고 경쟁하는 프로세스와 비슷한 상황
  • 잘 설계되지 않은 시스템은 deadlock, livelock, 처리량 문제, 효율성 저하 문제에 맞닥뜨리기 쉽다

일상에서 접하는 대부분의 concurrent관련 문제들은 이 세 범주 중 하나에 속한다

  • 이 알고리즘들을 공부하고 스스로 해법을 직접 구현해보라
  • 그러면 나중에 실전 문제에서 해결이 쉬어진다
    권장 사항 : 위에서 설명한 기본 알고리즘과 각 해법을 이해하라

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

  • 동기화된 메서드 사이에 의존성이 존재하면 동시성 코드에서 찾아내기 어려운 버그가 생김
  • 자바는 synchronized라는 개별 메서드 보호 개념 지원
  • 하지만 한 공유 클래스에 두 개 이상의 synchronized 메서드가 존재하면 문제를 일으킬 수도 있다.

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

권장사항 밖의 상황 : 공유 객체 하나에 여러 메서드가 필요한 경우?

Client-Based Locking(클라이언트 기반 잠금)

  • 클라이언트에서 첫 번째 메서드를 호출하기 전에 서버를 잠근다
  • (공유 객체를 사용하는 코드에서 공유 객체 Lock)
  • 마지막 메서드를 호출할 떄까지 잠금 유지
  • Bad
    • 서버를 사용하는 모든 클라이언트 코드에서 lock이 필요
    • 이는 유지보수 및 디버깅에 필요한 비용을 상승시킨다.

Server-Based Locking

  • 서버 에다 “서버(자신)을 잠그고 모든 동작을 수행한 후 잠금을 푸는” 메서드를 구현
  • 클라이언트는 이 메서드를 호출
  • (공유 객체에 새로운 메서드를 작성하고 잠금이 필요한 동작 전체를 수행하게 하는 것)
    => Good: Critical section에 접근하는 코드를 최소화

Adapted Server

  • 잠금을 수행하는 중계자(중간 단계)를 생성
  • ‘서버에서 잠금’ 방식과 유사하나 원래 서버는 변경하지 않음
  • 이는 기본적으로 서버 기반 잠금이지만 기존의 서버를 변경할 수 없는 상황에 사용할 수 있는 방법
  • (서드 파티 라이브러리를 사용한다고 생각하면 쉬울 것이다)
    => Good: 서버 기반 잠금 방식을 사용할 수 없는 경우에 사용

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

  • 자바의 Synchronized 키워드 사용 → 락 설정
  • 같은 락으로 감싼 모든 코드 영역은 한 번에 한 스레드만 실행 가능
  • 락은 스레드 지연(delay)과 부하 가중 유발(비싼 수행) → Synchronized 남발하면 안된다
  • 반면 critical section은 꼭 보호되어야 한다.
    • 임계 영역 : 동시 사용을 막아야만 프로그램이 올바로 동작하는 보호받는 코드 영역
  • 코드를 짤 때 임계 영역수를 최대한 줄여야 한다
    • 순진한 프로그래머 : 임계 영역 수를 줄이기 위해 거대한 임계 영역 하나로 구현
    • 필요이상의 임계영역 크기 → 스레드 간에 경쟁이 늘어나고 프로그램 성능 감소
      권장 사항 : 동기화된 영역은 최대한 작게 만들어라.

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

  • “영구적으로 돌아가는 시스템 구현”과 “잠시 돌다 깔끔하게 종료하는 시스템 구현”은 방법이 다르다
  • 깔끔하게 종료하는 코드 : 올바로 구현이 어려움
    • 가장 흔한 문제 : 데드락(스레드가 절대 오지 않을 시그널 기다림)
    • 예1) 부모 스레드가 자식 스레드를 여러개 만든 후 다 끝나기를 기다렸다가 자원을 해제하고 종료하는 시스템
      • 자식 스레드 하나가 데드락에 걸리면?
      • 부모 스레드는 영원히 가디라고, 시스템은 영원히 종료하지 못한다
    • 예2) 유사 시스템의 종료 지시를 받고 부모가 모든 자식 스레드에게 종료 시그널 전달
      • 자식 스레드 중 2개가 생산자/소비자 관계인데 생산자가 재빨리 종료되고 소비자가 waiting이라면?
      • 생산자 메시지를 waiting하는 소비자는 blocked 상태 → 종료시그널 못받음
      • 소비자는 영원히 생산자 스레드 기다림 → 부모는 자식 스레드를 영원히 기다림
    • 종종 발생하는 상황임
  • 따라서 깔끔하게 종료하는 멀티 스레드 코드 작성하려면 시간을 투자해 올바로 구현해야 한다
    권장 사항
  • 종료 코드를 개발 초기부터 고민하고 동작하게 초기부터 구현하라
  • 생각보다 오래 걸린다
  • 생각보다 어려우므로 이미 나온 알고리즘을 검토하라

스레드 코드 테스트하기

스레드 1개에서 무조건 옳은 경우

  • 코드가 올바르다고 증명은 현실적으로 불가능
  • 테스트는 정확성을 보장하지 않으며 “코드가 제대로 작성되었는가”를 증명할 수 없다
  • 다만 잘 작성된 충분한 테스트는 위험을 최소화할 수 있다
  • 이는 같은 코드와 같은 자원을 사용하는 멀티 스레드 상황에서는 훨씬 더 복잡해 진다

권장 사항

  • 문제를 노출할 만한 테스트 케이스를 작성하라
  • 프로그램 설정, 시스템 설정, 부하를 바꿔가며 자주 수행하라
  • 테스트가 한 번이라도 실패하면 원인을 분석하라
  • 다시 돌렸더니 통과하더라는 이유로 넘어가면 절대 안된다

멀티 스레드 테스트 지침

  • 말이 안 되는 실패는 잠정적인 스레드 문제로 취급하라
  • 다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자
  • 다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있게 스레드 코드를 구현하라
  • 다중 스레드를 쓰는 코드 부분을 상황에 맞게 조율할 수 있게 작성하라
  • 프로세서 수보다 많은 스레드를 돌려보라
  • 다른 플랫폼에서 돌려보라
  • 코드에 보조 코드instrument를 넣어 돌려라. 강제로 실패를 일으키게 해보라

말이 안 되는 실패는 잠정적인 스레드 문제로 취급하라

  • 멀티 스레드 코드는 일반적으로 발생할 리 없어 보이는 문제를 발생
  • (저자를 포함한)대부분의 개발자는 이러한 문제를 직관적으로 이해 불가
  • 스레드 코드의 코드는 파악하지 않으면서 때로는 수백만 번에 한번씩 매우 드물게 발생하기도 함
  • 그래서 많은 개발자들은 이러한 문제들을 우주선(宇宙線), 하드웨어 버그, 혹은 이러한 류의 one-off로 치부
  • 제일 안정된 방향은 one-off(일회성) 문제는 없다고 판단하는 것
  • 이러한 one-off들이 무시될 수록 더 많은 코드들이 이미 문제가 있는 시스템에 추가되게 될 뿐

권장 사항 : 시스템 오작동을 일회성으로 치부하지 마라

다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자

  • 당연한 소리, 스레드 환경 밖에서 코드가 제대로 동작하는지 반드시 확인
  • 일반적으로 스레드가 호출하는 POJO작성→POJO는 스레드를 모름→스레드 환경 밖에서 테스트 가능
  • 따라서 POJO에 넣는 코드가 많을 수록 더 좋다
    권장 사항 : 먼저 스레드 환경 밖에서 코드를 올바로 돌려라
    →스레드 환경 밖의 버그와 스레드 환경하에서 생기는 버그를 동시에 디버깅하려 하지 말라

다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있게 스레드 코드를 구현하라

  • 한 스레드로 실행, 여러 스레드로 실행, 실행 중 스레드 수 바꿔보기
  • 스레드 코드를 실제 환경이나 테스트 환경에서 돌려보기
  • 빨리, 천천히 등 다양한 속도로 테스트를 돌려보기
  • 반복 테스트가 가능하도록 테스트 케이스 작성하기
    권장 사항 : 다양한 설정에서 실행할 목적으로 다른 환경에 쉽게 끼워 넣을 수 있게 코드를 구현하라

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

  • 적절한 스레드 개수 파악은 상당한 시행착오를 필요
  • 처음부터 다양한 설정으로 프로그램 성능 측정 방법 강구
  • 실행할 스레드 갯수를 쉽게 변경할 수 있게 작성
  • 시스템이 동작하는 도중에 스레드 개수를 변경할 수 있게 하는 것을 고려한다
  • 처리량과 시스템 활용도를 기준으로 스스로를 조정할 수 있게 하는 것을 고민한다

프로세서 수보다 많은 스레드를 돌려보라

  • 시스템이 스레드 스와핑 할 때돟 문제는 발생
  • 작업 전환을 빈번히 발생하게 하기 위해 프로세서 수보다 많은 스레드를 실행해 보라
  • 작업 전환이 잦을수록 빠뜨린 critical section이나 dead lock을 찾을 확률이 높아지게 된다

다른 플랫폼에서 돌려보라

  • 멀티 스레드 코드는 실행 환경에 따라 다르게 동작
  • 따라서 모든 잠재적 배포 환경에 대해 테스트를 수행해야 한다.
    권장 사항 : 처음부터, 그리고 자주, 모든 타겟 플랫폼에서 코드를 돌려라

코드에 보조 코드instrument를 넣어 돌려라. 강제로 실패를 일으키게 해보라

스레드 버그는 어려움

  • 눈으로 보이지 않고 간단한 테스트로 버그 발견 안됨
  • 코드가 실행되는 수천 가지 경로 중에 아주 소수만 실패
  • 오류를 자주 일으킬 수 있는 방법 : 보조 코드 추가 ▶ 직접 구현하기, 자동화 이렇게 2가지 방법

직접 구현하기

Object.wait(), Object.sleep(), Object.yield(), Object.priority()등의 메서드를 사용해 실행 경로를 변경함으로써 코드의 문제를 발견하는 방법

sleep

  • 지정한 시간동안 작업을 일시정지 상태로 들어가는 메서드
  • static 메서드로 제공되며(인스턴스 메서드는 deprecated 되었다),
  • 이 메서드를 호출한 쓰레드가 지정한 시간만큼 일시정시 상태가 된다

sleep vs wait

  • sleep 은 Thread클래스, wait는 object 클래스
  • sleep은 객체가 중단되거나 실행 완료까지 객체 락 유지, wait은 락해제, 다른객체 notify시 다시 runnable

yield

  • RUNNABLE 상태로 들어가면서 다른 쓰레드에게 작업을 양보하는 메서드이다
  • 이 또한 static 메서드로 제공되며, 이 메서드를 호출한 쓰레드가 RUNNABLE 상태로 들어가게 된다
    테스트를 위해 yield 추가
    1
    2
    3
    4
    5
    6
    7
    8
    9
    public synchronized String nextUrlOrNull() {
    if(hasNext()) {
    String url = urlGenerator.next();
    Thread.yield(); // inserted for testing
    updateHasNext();
    return url;
    }
    return null;
    }
    설명
  • yield() 메서드를 호출함으로써 코드의 실행 경로를 변경 가능
  • 위 코드에서 문제가 발생한다면 이는 yield()를 추가해 생긴 문제가 아니라 이미 존재하던 문제를 명백히 만든것 뿐이다.
    문제점
  • 보조 코드를 삽입할 적정 위치를 직접 찾아야 한다.
  • 어떤 메서드를 어디서 호출해야 좋은지 어떻게 알 것인가
  • 제품에 이와 같은 보조 코드를 포함해 배포하는 것은 불필요하게 퍼포먼스를 저하
  • Shotgun approach 이기 때문에(무작위) 반드시 문제가 발생한다는 보장을 얻을 수 없다. 사실상 문제가 드러나지 않을 확율이 더 높다
    (Shotgun approach: 산탄총처럼 되는대로 시도하는 방법)
    → 배포 환경이 아니라 테스트 환경에서 설정을 바꿔가며 보조코드를 실행할 방법이 필요

시스템을 최대한 POJO와 스레드 제어 클래스로 분할해야한다

  • 보조코드를 추가할 위치를 찾기가 쉬어진다
  • 여러 상황에서 sleep, yield등으로 POJO를 호출하게 다양한 jig 구현 가능

자동화

보조 코드 자동 추가 : AOF(Aspect-Oriented Framework), CGLIB, ASM과 같은 도구

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 메서드가 하나인 클래스

public class ThreadJigglePoint {
public static void jiggle() { }
}

//다양한 위치에 ThreadJiglePoint.jiggle() 추가 : 무작위 sleep, yield 호출 or nop
public synchronized String nextUrlOrNull() {
if(hasNext()) {
ThreadJiglePoint.jiggle();
String url = urlGenerator.next();
ThreadJiglePoint.jiggle();
updateHasNext();
ThreadJiglePoint.jiggle();
return url;
}
return null;
}

ThreadJiglePoint를 2가지로 구현

  1. jiggle()을 비워두고 배포 환경에서 사용
  2. 무작위로 nop, sleep, yield등을 테스트 환경에서 수행
  • 2번 구현으로 테스트를 수천 번 반복하면 스레드 오류가 나타날 지도
  • 단순하나 복잡한 도구 사용이 어렵다면 합리적 대안으로 나쁘지 않음
  • IBM의 ConTest : 유사 작동하나 좀 더 복잡
  • 코드 흔들기(jiggle)→스레드 다른 순서 실행→ 스레드 오류 드러날 확율 높여줌
    권장 사항 : 흔들기 기법을 사용해 오류를 찾아라

결론

  • 다중 스레드 코드는 제대로 작성하기 어렵다

  • 다중 스레드 코드는 엄격한 기준으로 clean하게 작성하라

  • 최우선적으로 SRP를 준수하라 : POJO를 사용 스레드 관련 코드와 非스레드 관련 코드를 분리하라

  • 스레드 관련 코드를 테스트할 때에는 전적으로 스레드 관련 문제만 테스트하라

  • 즉, 스레드 코드는 최대한 집약되고 작아야 한다

동시성 오류를 일으키는 잠정적 원인을 철저이 이해한다

  • 여러 스레드가 공유 자료 조작 or 자원 풀 공유시 동시성 오류 발생

  • 깔끔하게 종료되게 하는 문제나 반복문 탈출과 같은 경계 조건의 경우는 특히 주의

  • 사용하는 라이브러리를 이해하고 기본적인 알고리즘을 이해하라

  • 라이브러리가 제공하는 기능이 어떻게 문제를 해결하는지 이해하라

보호할 코드 영역을 찾는 법, 특정 코드 영역을 잠그는 방법을 이해한다

  • 잠글 필요가 없는 코드는 잠그지 마라
  • 잠긴 영역에서 다른 잠긴 영역 호출 X
    그러려면? → 공유하는 정보와 공유하지 않는 정보를 제대로 이해 필요
  • 공유 객체의 갯수와 공유 범위를 최소한으로 줄여라
  • 클라이언트에게 공유 객체의 상태(잠금 등)를 관리하는 책임을 떠넘기지 않는다
    필요시 객체 디자인 변경으로 클라이언트에 편의를 제공

스레드 관련 코드는 여러 설정, 환경에서 반복적이고 지속적으로 수행해야 한다

  • 버그를 일회성으로 치부하지 않고 발견해서 고칠 수 있도록

테스트 용이성

  • TDD 3대규칙을 따르면 자연적으로 얻어짐
  • 좀더 넓은 설정 범위에서 코드를 수행하기 위해 필요한 기능을 제공하는 플러그인 수준을 의미

보조코드 추가

  • 코드를 시간을 들여 instrument하게 되면 오류가 드러날 확률은 크게 높아진다
  • 직접 코드를 작성할 수도 있고 자동화 툴을 사용할 수도 있다
  • 초반부터 보조 코드 고려
  • 스레드 코드는 출시하기 전까지 최대한 오래 테스트 해야할 것
  • Clean한 접근 방식을 사용한다면, 코드가 올바로 돌아갈 가능성이 극적으로 높아진다

Related POST

공유하기