Clean Code 13. 동시성(Concurrency)


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

  • James O. Coplien

동시성이 필요한 이유?

동시성은 무엇(what)언제(when)의 결합(coupling)을 분리하는 전략이다. 이러면 프로그램 구조와 효율이 극적으로 나아진다. 하지만 구조적 개선만을 위한건 아니다. Batch Job같은 경우에는 응답 시간과 작업 처리량(throughput) 개선이라는 요구사항이 중요하다.

미신과 오해

  • 동시성은 항상 성능을 높여준다.
    => 대기 시간이 긴 경우나, 여러 프로세서가 동시에 독립적으로 계산이 가능한 경우에만 성능이 높아진다.
  • 동시성을 구현해도 설계는 변하지 않는다.
    => 무엇과 언제를 분리하면 크게 달라진다.
  • Web 또는 EJB Container를 사용하면 동시성을 이해할 필요가 없다.
    => 어떻게 동작하는지 알아야 동시 수정, 데드락 같은 문제를 해결할 수 있다.

아래 내용은 사실이다.

  • 성능, 코드 양쪽 다 오버헤드가 있다.
  • 복잡하다.
  • 버그 재현이 어렵다. 그래서 one-off(일회성 문제)로 무시하기 쉽다.
  • 동시성을 적용하기 위해서는 근본적인 디자인 개편이 필요하다.

난관

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

두 스레드가 같은 변수를 동시에 참조하면 2개의 thread가 같은 값을 반환받는 일이 발생할 수 있다.

동시성 방어 원칙

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

동시성 코드는 다른 코드와 분리되어야 한다.

  • 동시성 코드는 개발, 변경, Tunning 주기가 있다.
  • 다른 코드와는 다르게 훨씬 어렵다.
  • 잘못된 동시성 코드는 별의별 방식으로 실패한다.

권장사항 : 다른 코드와 분리하라.

Corollary : 자료 범위를 제한하라.

코드 내 임계영역(critical section)을 synchronized 키워드로 보호하라. 그곳을 수정하는 곳이 많아질수록 문제가 발생하기 쉽다.

  • 보호할 임계영억을 빼먹는다.
  • 모든 곳에서 보호했는지(DRY 위반) 확인해야 한다.
  • 원래 어려운 문제를 이제는 찾기 어렵게까지 한다.

권장사항 : 자료를 encapsulation하라. 공유 자료를 최대한 줄여라.

Corollary : 자료 사본을 사용하라.

공유 자료를 줄이는 가장 좋은 방법은 공유하지 않는 것이다. Read-only일 경우에는 복사하여 사용하라.

Corollary : Thread를 가능한 독립적으로 구현하라.

스레드가 공유 자원을 사용하지 않고, 자신의 local 변수만 사용한다면 동기화 문제가 없어진다.

권장사항 : 독립된 thread로, 가능하면 다른 processor에서 실행이 가능하도록 자료를 분리하라.

라이브러리를 이해하라.

모든 라이브러리가 thead-safety하지 않다.

Concurrency Design

NameDescription
ReentrantLock한 method에서 lock하고 다른 method에서 release가능한 lock
Semaphore개수(count)가 있는 lock
CountDownLatch지정된 수만큼 이벤트가 발생한 뒤 대기 중인 thread를 release하는 lock. 모든 thread가 공평하게 시작할 기회를 준다.

권장사항 : 언어에서 제공하는 class를 검토하라.

실행 모델을 이해하라.

기본 용어를 먼저 익히고 알아보자.

NameDescription
Bound Resources한정된 자원. DB connections, read/write buffers
Mutual Exclusion상호 배제. 한 번에 한 thread만 공유 자원의 사용이 가능
Starvation기아. thread가 무한히 기다리는 현상. priority가 있을 경우 발생 가능
Deadlock여러 thread가 상대가 필요한 자원을 가진체 서로 끝나기마을 기다리는 현상
Livelock공명(resonance)으로 인해 lock을 걸려는 시점에 다른 thread를 인식해서 계속해서 양보하는 현상

실행 모델들을 알아보자.

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

한 개 이상의 producer가 작업을 생성하여 buffer나 queue에 넣는다. 한 개 이상의 comsumer가 그걸 가져다가 작업한다. 둘 사이의 queue는 bound resource이다. producer는 queue에 공간이 있을 때까지, consumer는 queue에 작업이 하나라도 있을 때까지 기다혀야 한다. producer는 “queue가 비어있지 않다“는 signal을 consumer에게 보내고, comsumer는 “queue가 가득차 있지 않다“는 signal을 producer에게 보낸다.

읽기-쓰지 (Readers-Writers)

Reader가 읽는게 주요 작업이지만 가끔 writer가 쓰는 작업이 필요하다. reader의 throughput을 강조하면 정보가 오랫동안 update가 안될 수 있고, writer의 thoughput을 강조하면 reader의 starvation이 발생하기 쉽다. 양쪽 균형을 어떻게 잡느냐가 중요하다.

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

둥근 식탁에 각자 왼쪽에 포크가 놓여져 있다. 배가 고프면 양쪽 포크 2개를 잡고 스파게티를 먹는다. 양쪽 포크 2개 중 하나라도 없으면 기다려야 한다. 철학자를 thread로, 포크를 자원으로 바꿔 생각해보자. deadlock, livelock, throughput, 효율성 등 문제가 발생하기 쉽다.

대부분의 경우 위 3가지 중 하나에 속한다.

권장사항 : 3가지 알괴즘과 각 해법을 이해하라.

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

공유 class하나에 synchronized method가 여럿이라면 구현이 올바른지 확인해야 한다.

권장사항 : 공유 객체 하나에는 method 하나만 사용하라.

여러 method가 필요하다면 아래 3가지 방법을 고려하라.

  • Client-based Locking : client에서 method 호출 전 server를 lock한다. Bad 모든 client code에서 lock하는 code를 넣어야 한다.
  • Server-based Locking : server가 lock/release method를 구현하고, 이를 clinet가 호출한다. Good Critical section 접근 코드를 최소화 할 수 있다.
  • Adapted Server(중계 서버) : 원래 server는 변경하지 않고, 별도 server가 lock역할을 수행한다. Good Server-based Locking 사용이 불가능할 경우

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

Critical section수를 줄인다고 거대하게 구현하는 사람도 있다. 그러면 thread 대기시간이 길어져서 성능이 떨어진다.

권장사항 : 동기화 부분을 최대한 작게 만들어라.

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

Deadlock이 발생하기 쉽다. 즉, 절대 오지않을 signal을 기다린다.
예를 들어 부모 thread가 자식 thread를 여럿 만든 뒤 기다리는 경우, 자식 중 하나라도 deadlock이 걸리면 시스템은 종료하지 못한다. 다른 예로 사용자가 종료 명령을 내려서 부모가 자식들에게 종료하라는 signal을 보냈는데, 자식 중 2개가 producer/consumer 관계라면? producer는 재빨리 종료했는데, consumer가 producer의 signal을 계속 기달 수 있다.

권장사항 : 종료 코드를 초기부터 괸하고 구현하라. 생각보다 오래 걸린다. 너무 어려우면 이미 나온 알고리즘을 검토하라.

Thread code test

권장사항 : 문제를 발생하는 tese case를 작성하라. 프로그램/시스템 설정과 부하를 바꿔가면서 자주 돌려라. 실패 후 다시 돌려서 성공했다고 해서 그냥 넘어가면 절대로 안 된다.

  • 말이 안되는 실패는 잠정적 thread 문제로 취급하라.
  • Multi-thread를 고려하지 않은 순차 코드부터 제대로 돌게 만들자.
  • Multi-thread를 다양한 환경에 쉽게 끼워 넣을 수 있도록 구현하라.
  • Multi-thread를 상황에 맞춰 조정가능하게 작성하라.
  • Processor수보다 많은 thread를 돌려보라.
  • 다른 platform에서 돌려보라.
  • 보조 코드(instrument)를 넣어 강제로 실패를 일으키게 해보라.

말이 안되는 실패는 잠정적 thread 문제로 취급하라

일회성 문제란 존재하지 않는다고 생각해라.

권장사항 : 시스템 실패를 ‘일회성’이라 치부하지 마라.

Multi-thread를 고려하지 않은 순차 코드부터 제대로 돌게 만들자

권장사항 : Thread 환경 밖에서 생기는 버그와 안에서 생기는 버그를 동시에 디버깅하지 마라. 먼저 환경 밖에서 코드를 올바로 돌려라.

Multi-thread를 다양한 환경에 쉽게 끼워 넣을 수 있도록 구현하라

  • 한 thread, multi-thread로 실행하거나, 실행 중 thread 수를 바꿔본다.
  • 실제 환경/테스트 환경에서 돌려본다.
  • test 코드를 빨리, 천천히, 다양한 속도로 돌려본다.
  • 반복 test가 가능하도록 test case를 작성한다.

권장사항 : 다양한 설정에서 실행할 목적으로 다른 환경에 쉽게 끼워 넣을 수 있게 코드를 구현하라.

Multi-thread를 상황에 맞춰 조정가능하게 작성하라

적절한 thread수를 알아내려면 상당한 시행착오가 필요하다. thread 개수를 조율하기 쉽게, 돌아가는 중 개수 변경이 가능하게 구혀하라. throughput에 따라 thead 개수를 조율하는 코드도 고민하자.

Processor수보다 많은 thread를 돌려보라

Swapping이 잦을수록 critical section을 매먹은 코드나 deadlock을 일으키는 코드가 찾기 쉽다.

다른 platform에서 돌려보라

Thread code는 platform마다 다르게 돌아간다. 실행된 가능성이 있는 모든 platform에서 test를 수행해야 한다.

추천사항 : 처음부터 자주 모든 목표 platform에서 test를 돌려라.

보조 코드(instrument)를 넣어 강제로 실패를 일으키게 해보라

thead 버그 재현이 힘든 이유는 많은 실행경로 중 극히 일부에서만 발생하기 때문이다. wait, sleep, yield, priority 등의 코드를 추가해서 다양한 순서로 실행해보자.

보조 코드를 추가하는 방법은 직접 구현하기, 자동화 2 가지가 있다.

직접 구현하기
public synchronized String nextUrlOrNull() {
    if(hasNext()) {
        String url = urlGenerator.next();
        Thread.yield(); // inserted for testing.
        updateHasNext();
        return url;
    }
    return null;
}

이런 test에는 몇 가지 문제가 있다.

  • 삽입할 위치를 직접 찾아야 한다.
  • 어떤 함수를 어디서 호출해야 적당한지 어떻게 알까?
  • 배포시 그냥두면 성능이 떨어진다.
  • 무작위(shotgun approach)이다. 문제가 안일어날 가능성이 더 높다.
자동화
public class ThreadJigglePoint {
    public static void jiggle() { }
}

public synchronized String nextUrlOrNull() {
    if(hasNext()) {
        ThreadJigglePoint.jiggle();
        String url = urlGenerator.next();
        ThreadJigglePoint.jiggle();
        updateHasNext();
        ThreadJigglePoint.jiggle();
        return url;
    }
    return null;
}

AOP 도구를 사용한다. jiggle(흔들기)은 무작위로 sleep, yield를 호출하거나 아무것도 안한다. 배포시는 jiggle을 비워두면 된다.

권장사항 : 흔들기 기법을 사용해 오류를 찾아라.

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)