자바/클린 코드

클린코드 13장 - 동시성

끄적끄적 2022. 6. 17. 19:16

동시성과 깔끔한 코드는 양립하기 어렵다. 이런 어려움을 대처하고 깨끗한 코드를 작성하는 방법을 알아보자.

동시성이 필요한 이유?
쓰레드가 하나인 프로그램은 무엇언제가 서로 밀접하다. 동시성은 무엇언제를 분리하는 전략이다.
웹애플리케이션에서 서블릿 모델을 보면, 웹 요청이 올때마다 각 서블릿 스레드가 각각 자신만의 세상에서 돌아감에 따라 많은 구조적 이점을 가진다. 또한, 많은 배치프로그램에서 하나의 쓰레드가 수행할 때보다 많은 일을 동시성을 이용해서 해결하고 있다.

하지만, 동시성은 각별히 주의하지 않으면 난감한 상황에 처하게 되며, 동시성과 관련된 일반적인 미신과 오해가 있다.
1) 동시성은 항상 성능을 높여준다 : 동시성은 때로 성능을 높여준다. 대기시간이 길거나, 독립적인 계산이 충분히 많은 경우에만 성능에서 효과를 보여줄 수 있다.
2) 동시성을 구현해도 설계는 변하지 않는다. : 단일 쓰레드와 다중 쓰레드는 시스템 구조와 설계가 판이하게 다르다.
3) 웹 또는 EJB컨테이너를 사용하면 동시성을 이해할 필요가 없다. : 컨테이너의 동작방식과 동시제어 및 데드락에 대해 알아야 한다.

동시성 방어 원칙
단일 책임 원칙(Single Responsibility Principle) : 동시성 코드는 다른 코드와 분리해야 한다.
공유변수영역(여러 쓰레드가 동시에 사용하는 영역)을 제한하라 : 공유되는 영역을 최대한 줄이고, 캡슐화하라.
자료 사본을 사용하라 : 공유 객체를 복사해 읽기 전용으로 사용하는 방법을 검토하라. 사본 생성과 관련된 성능저하가 동시성으로 인한 내부잠금으로 인한 성능저하보다 나을 수가 있다.
쓰레드는 가능한 독립적으로 구현하라 : 쓰레드마다 스택영역(지역 변수)에만 저장하고 사용하면 동기화 문제가 없다. 

라이브러리를 이해하라
Java5부터 동시성을 지원하는 라이브러리가 많이 추가되었다. ConcurrentHashMap 등의 클래스를 살펴보자.

동시성 설계를 지원하는 클래스들을 검토하라. 자바의 경우 java.util.concurrent, java.util.concurrent.atomic, java.util.concurrent.locks를 살펴보라.

ReentrantLock 한 메서드에서 잠그고 다른 메서드에서 해제될 수 있는 lock이다.
Semaphore 전통적인 세마포어(갯수를 셀 수 있는 lock)의 구현체이다.
CountDownLatch 기다리는 모든 스레드들을 해제하기 전 특정 횟수의 이벤트가 발생하는 것을 기다리게 할 수 있는 lock이다. 모든 스레드가 거의 동시에 시작될 수 있게 도와줄 수 있다.

 

실행 모델을 이해하라
실행 모델에 대해 이야기하기 위해 필요한 기본적인 용어를 먼저 알아 보자

Bound Resources Concurrent 환경에서 사용되는 고정된 크기의 자원이다. 예시로 데이터베이스 연결, 고정된 크기의 읽기/쓰기 버퍼가 있다.
Mutual Exclusion 한 시점에 공유 자원에 접근할 수 있는 스레드는 단 하나이다.
Starvation 한 스레드 혹은 스레드의 그룹이 긴 시간 혹은 영원히 작업을 수행할 수 없게 된다. 작업의 우선권을 가지는 수행 시간이 짧은 스레드가 끝없이 실행된다면 수행 시간이 긴 스레드는 굶게 된다.
Deadlock 두 개 이상의 스레드들이 서로의 작업이 끝나기를 기다린다. 각 스레드는 서로가 필요로 하는 자원을 점유하고 있으며 필요한 자원을 얻지 못하는 이상 그 누구도 작업을 끝내지 못하게 된다.
Livelock 스레드들이 서로 작업을 수행하려는 중 다른 스레드가 작업중인 것을 인지하고 서로 양보한다. 이러한 공명 때문에 스레드들은 작업을 계속 수행하려 하지만 장시간 혹은 영원히 작업을 수행하지 못하게 된다.
(두 사람이 좁은 길목에서 만나 서로 비켜가기 위해 한 쪽으로 걷는다. 하지만 우연히도 두 사람은 계속 같은 방향으로 피하게 된다. 따라서 두 사람 모두 앞으로 진행하지 못하게 된다.)
하나의 프로세스(무작위로 선택되거나 우선 순위에 따라 선택됨)만 조치를 취하도록 함으로써 해결가능

생산자-소비자 모델
생산자들은 정보를 생성해 buffer나 queue에 넣느다. 하나 이상의 소비자 쓰레드는 queue에서 정보를 가져와 사용한다. 
이들이 사용하는 queue는 보통 한정된 자원으로, 생산자는 무한정 queue에 넣을 수가 없고, 빈 공간이 생길때까지 기다린다. 소비자는 queue에 데이터가 들어올 때까지 기다린다. 이에 따라 서로 시그널을 주고 받고, 기다리는 매커니즘이 존재하며 잘못 구현하면 무한정 기다릴 수가 있다.

읽기-쓰기 모델
데이터에 대해 읽기를 수행하는 쓰레드들과 공유 자원에 쓰기를 수행하는 쓰레드가 존재할 때 동시에 수행하면 안되기 때문에 syncronized와 같은 제어가 필요한데, 이들의 쓰레드간 우선순위에 균형을 잡는 것이 필요하다. 
읽기 쓰레드의 처리율을 강조할 경우, 쓰기가 지연될 수가 있고, 쓰기 쓰레드가 강조되면, 오히려 읽기 쓰레드가 지연될 수도 있는 것이다. 기아(Starvation) 상태에 빠지지 않도록 체크가 필요하다.

식사하는 철학자들 모델
둥근 식탁에 철학자들이 앉아있다. 식탁 가운데는 커다란 스파게티가 한 접시 있는데, 각 철학자 왼쪽에 포크가 놓여있다.
식사를 하기 위해서는 양 손에 포크를 집어야만 식사를 하고, 본인 옆의 철학자가 식사중일때는 기다려야 한다. 배가 고플 때까지 생각에 잠기는 모델이다.
여기서 철학자는 쓰레드이고, 포크는 자원으로 생각할 수 있다. 주의해서 설계하지 않으면 데드락, 라이브락, 처리효율 저하 등 문제가 발생할 수 있다.

대부분의 동시성 프로그래밍의 쓰레드 문제는 위 세 범주의 실행모델 중 하나에 속한다. 이에 대한 해법을 이해할 필요가 있다.

동기화하는 메서드 사이에 존재하는 의존성을 이해하라.
동기화된 메서드 간의 의존성은 concurrent 코드에서 사소한 버그를 일으킬 수 있다. 자바는 synchronized 를 이용해서 동시성을 제어한다. 하지만 한 클래스에 두 개 이상의 synchronized 메서드가 존재하면 문제를 일으킬 수도 있다.

추천: 공유된 객체의 두 메서드 이상을 사용하는 것을 피하라.

만약 위 추천을 따를 수 없는 상황이라면 아래의 세 방법을 고려해 볼 수 있다. 우선 참고할 소스를 보자.

public class IntegerIterator implements Iterator<Integer> {
    private Integer nextValue = 0;
    
    public synchronized boolean hasNext() {
        return nextValue < 100000;
    }
    
    public synchronized Integer next() {
        if (nextValue == 100000)
            throw new IteratorPastEndException();
        return nextValue++;
    }
    
    public synchronized Integer getNextValue() {
        return nextValue;
    }
}

// Shared Resource
IntegerIterator iterator = new IntegerIterator();

// Threaded-Code
while(iterator.hasNext()) {
    // nextValue가 99999인 상황에서 두 스레드에서 순차적으로 while(iterator.hasNext())를 호출하게 되면
    // 두 스레드 모두 while문 안으로 진입하게 된다. 이는 예상되지 않은 결과이다.
    int nextValue = iterator.next();
    // do something with nextValue
}


클라이언트에서 잠금 : 해당 공유 객체를 사용하는 코드에서 공유 객체를 잠그는 것이다.
=> Bad: 서버를 사용하는 모든 클라이언트 코드에서 lock이 필요하게 되며 이는 유지보수 및 디버깅에 필요한 비용을 상승시킨다.

/* Code 2-2: Client-Based Locking */

// Shared Resource
IntegerIterator iterator = new IntegerIterator();

// Threaded-Code
while (true) {
    int nextValue;
    synchronized (iterator) {
        if (!iterator.hasNext())
            break;
        nextValue = iterator.next();
    }
    doSometingWith(nextValue);
}

서버 기반 잠금(Server-Based Locking): 공유 객체에 새로운 메서드를 작성하고 잠금이 필요한 동작 전체를 수행하게 하고 클라이언트(사용로직)는 이 메서드를 호출한다.

/* Code 2-3: Server-Based Locking */
public class IntegerIterator implements Iterator<Integer> {
    private Integer nextValue = 0;
    
    //... 중략(기존코드) ...
    
    public synchronized Integer getNextOrNull() {
        if (nextValue < 100000)
            return nextValue++;
        else
            return null;
    }
}

// Shared Resource
IntegerIterator iterator = new IntegerIterator();

// Threaded-Code
while (true) {
    Integer nextValue = iterator.getNextOrNull();
    if (next == null)
        break;
    // do something with nextValue
}

중계된 서버(Adapted Server): 잠금을 수행하는 중계자를 작성한다. 이는 기본적으로 서버 기반 잠금이지만 기존의 서버를 변경할 수 없는 상황에 사용할 수 있는 방법이다.(역주: 서드 파티 라이브러리를 사용한다고 생각하면 쉬울 것이다.)

/* Code 2-4: Adapted Server */

public class ThreadSafeIntegerIterator {
    private IntegerIterator iterator = new IntegerIterator();
    
    public synchronized Integer getNextOrNull() {
        if(iterator.hasNext())
            return iterator.next();
        return null;
    }
}

// Threaded-Code는 위 Code 2-3과 동일

 

동기화하는 부분을 작게 만들어라
자바에서 동기화하는 부분은 synchronized 키워드를 사용하며, 이 때 내부적으로 락을 설정한다. 락은 쓰레드를 지연시키고 부하를 가중시키므로, 공유영역은 최대한 줄여야 하는데, 이를 거대한 하나의 임계영역으로 만드는 건 더 나쁜 선택이다. 동기화하는 부분은 최대한 작게 만들어야 한다.

올바른 종료 코드는 구현하기 어렵다
동시성으로 구현된 코드는 종료 처리도 개발 초기부터 고민하고 구현해야 한다. 서로간에 시그널을 기다리는 등의 동작, 데드락 등의 동작으로 인해 종료 시그널을 받지 못하는 경우가 발생할 수 있다

쓰레드 코드 테스트하기
멀티 쓰레드 환경에서 테스트는 더욱 어렵다. 문제를 노출하는 테스트 케이스를 작성하고 설정과 부하를 바꿔가면 테스트할 필요가 있다. 몇가지 구체적인 지침은 다음과 같다.

  • 말이 안 되는 실패는 잠정적인 스레드 문제로 취급하라 : 일회성 문제를 무시할 경우, 잘못된 코드 위에 코드가 계속 쌓인다. 쓰레드 코드에 잠입한 버그는 수천, 아니 수백만 번에 한번씩 드러나기도 한다.

  • 다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자 : 쓰레드 환경 밖에서 생기는 버그와 쓰레드 환경에서 생기는 버그를 동시에 디버깅하지 마라. 쓰레드 환경 밖에서부터 우선 올바르게 돌아야 한다.

  • 다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있게 스레드 코드를 구현하라 : 한 쓰레드이든 여러 쓰레드이든, 빠르든 느리든 다양한 환경에서 쉽게 끼워 넣을 수 있게 구현되어야 한다.

  • 다중 스레드를 쓰는 코드 부분을 상황에 맞게 조율할 수 있게 작성하라 : 쓰레드의 개수는 성능 테스트 등을 통해 쉽게 조정할 수 있어야 한다.

  • 프로세서 수보다 많은 스레드를 돌려보라 : CPU 개수보다 쓰레드가 많을 경우 스와핑을 수행하며, 스와핑이 잦을 수록 임계영역 관련 문제가 잘 드러난다.

  • 다른 플랫폼에서 돌려보라 : 운영체제마다 쓰레드를 처리하는 정책이 다르니 수행할 플랫폼 전부에서 테스트를 수행해야 한다.

  • 코드에 보조 코드instrument를 넣어 돌려라. 강제로 실패를 일으키게 해보라
    이는 Object.wait(), Object.sleep(), Thread.yield(), Thread.setPriority()등의 메서드를 사용해 실행 경로를 변경함으로써 코드의 문제를 발견하는 방법이다.
public synchronized String nextUrlOrNull() {
    if(hasNext()) {
        String url = urlGenerator.next();
        Thread.yield();
        // inserted for testing.
        updateHasNext();
        return url;
    }
    return null;
}

하지만 위와 같이 테스트 하기 위해서는 실제 코드를 수정해서 동작을 변경하는 등 여러 문제가 있다. 
AOP 등을 이용해서 아래와 같이 테스트할 수도 있다. 배포용 코드에서는 jiggle()은 아무 수행도 없지만 테스트에선 sleep, yield 등 동작을 넣어볼 수 있다.

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

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