클린 코드 Chapter 13. 동시성

Jeongmin Yeo (Ethan)·2021년 3월 13일
3

Clean Code

목록 보기
13/14
post-thumbnail

클린 코드 Chapter 13. 동시성에 대해 정리합니다.

학습할 내용은 다음과 같습니다.

  • Intro - 동시성이 필요한 이유
  • Principle 1. 동시성 방어 원칙
  • Principle 2. 라이브러리를 이해하라
  • Principle 3. 실행 모델을 이해하라
  • Principle 4. 동기화하는 메소드 사이에 존재하는 의존성을 이해하라
  • Principle 5. 동기화하는 부분을 작게 만들어라
  • Principle 6. 올바른 종료 코드는 구현하기 어렵다
  • Principle 7. 스레드 코드 테스트하기
  • 동시성에 대해 더 알아보자

Reference


Intro

동시성과 깔끔한 코드는 양립하기 어렵다.

스레드를 하나만 실행하는 코드는 짜기가 쉽다. 겉으로 보기에는 멀쩡하나 깊숙한 곳에 문제가 있는 다중 스레드 코드도 짜기 쉽다.

하지만 다중 스레드로 깔끔한 코드를 짜기는 어렵다.

여기에서는 여러 스레드를 동시에 돌리는 이유를 논하고 동시성을 가지고 깨끗한 코드를 작성하는 몇가지 방법을 말한다.

마지막으로 동시성을 테스트하는 방법과 문제점을 논한다.

동시성이 필요한 이유

시스템은 응답 시간과 처리량 개선을 목적으로 동시성을 요구한다.

예를 들어 매일 수많은 웹 사이트에서 정보를 가져와서 요약하는 어플리케이션이 있다고 생각해보자.

단일 스레드로 이 프로그램을 끝내려면 한 사이트를 끝내고 다음 사이트로 넘어가는 작업을한다. 웹 소켓에서 입출력을 기다리는 시간을 계산해보면 사이트가 추가될수록 계속해서 길어진다.

이런 경우는 다중 스레드로 성능을 높일 수 있다.

이렇게 동시성은 성능을 높여줄 수 있다.

하지만 동시성은 어렵고 주의가 필요하다. 다음은 동시성과 관련된 일반적인 오해를 알아보자.

  • 동시성은 항상 성능을 높여준다.

    • 동시성은 때로 성능을 높여준다. 대기 시간이 아주 길어서 여러 스레드가 프로세서를 공유할 수 있거나 여러 프로세서가 동시에 처리할 독립적인 계산이 있는 경우 성능이 높아진다.
  • 동시성을 구현해도 설계는 변하지 않는다.

    • 단일 스레드 시스템과 다중 스레드 시스템은 설계가 판이하게 다르다.
  • 웹 또는 EJB 컨테이너를 사용하면 동시성을 이해할 필요는 없다.

    • 실제로는 컨테이너가 어떻게 동작하는지 어떻게 동시 수정과 데드락과 같은 문제를 해결할 수 있는지 알아야만 한다.

그리고 동시성의 단점은 다음과 같다.

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

    • 성능 측면에서 부하가 걸리며 코드도 더 짜야한다.
  • 동시성은 복잡하다.

    • 간단한 문제라도 동시성은 복잡하다.
  • 일반적으로 동시성 버그는 재현하기 어렵다.

  • 그래서 진짜 결함으로 간주되지 않고 일회성 문제로 여겨 무시하기 쉽다.


Principle 1. 동시성 방어 원칙

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

단일 책임 원칙(Single Responsibility Principle)

SRP는 주어진 메소드/클래스/컴포넌트가 변경될 이유가 하나여야 한단 말이다.

동시성은 복잡성 하나만으로도 따로 분리항 이유가 충분하다.

그런데 불행히도 동시성과 관련이 없는 코드에 동시성을 곧바로 구현하는 사례가 너무나도 흔하다.

동시성을 구현할 땐 동시성 코드를 다른 코드와 분리하자.

자료 범위를 제한하라

동시성 문제중 하나는 공유된 객체 하나를 다른 두 스레드가 수정할 때 생긴다.

이런 문제를 해결하는 방안으로는 공유 객체를 사용하는 코드 내 임계영역을 자바 기준으로는 synchronized 키워드로 보호할 수 있다.

이 방법으로 모두 해결되는 건 아니다.

임계영역의 수를 줄이는게 중요하다. 여러 임게 영역을 가지면 빠트릴 수도 있기 때문에 조심해야한다.

자료 사본을 사용하라

공유 자료를 출이려면 처음부터 공유하지 않는 방법이 가장 좋다.

어떤 경우에는 객체를 복사해 읽기 전용으로 사용하는 방법이 가능하다.

물론 이 방법으로는 객체를 부하하는 시간과 가비지 컬렉션에 드는 부하가 있을 수 있다. 하지만 동기화를 피하는 내부 잠금을 없애는게 더 좋다.

스레드는 가능한 독립적으로 구현하라.

자신만의 세상에 존재하는 스레드를 구현해라.

즉 다른 스레드와 자료를 공유 하지 않도록 해라. 각 스레드는 클라이언트 요청 처리 하나를 담당하도록 해라.

이 경우 모든 정보는 로컬 변수를 이용하므로 다른 스레드와 동기화 할 필요가 없이 동작할 수 있다.


Principle 2. 라이브러리를 이해하라

자바 5는 동시성 측면에서 이전보다 많이 나아졌다.

자바 5로 스레드 코드를 구현한다면 다음을 고려해라.

  • 서로 무관한 작업을 수행할 땐 executor 프레임워크를 사용할 수 있다.

  • 스레드 환경에 안정한 컬렉션을 사용할 수 있다.

  • 가능하다면 스레드가 blocking 되지 않는 방법을 사용하라.

  • 일부 클래스 라이브러리는 스레드에 안전하지 못하다.

java.util.concurrent 패키지가 제공하는 클래스는 다중 스레드 환경에서 사용해도 안전하고 성능도 좋다.

그 중 한 예로 ConcurrentHashMap은 거의 모든 상황에서 HashMap보다 좋다.

Executor 프레임워크는 스레드 풀을 관리하고 풀 크기를 자동으로 조정한다. 필요하다면 스레드를 재사용한다.

게다가 다중 스레드 프로그래밍에서 많이 사용하는 Future도 지원한다.

또한 Executor 프레임워크는 Runnable 인터페이스를 구현한 클래스는 물론 Callable 인터페이스를 지원한다.

Callable 인터페이스는 Runnable 인터페이스와 유사하지만 결과 값을 반환한다. 결과 값은 다중 스레드 환경에서 흔히 요구되는 사항이다.

Future는 독립적인 연산 여럿을 실행한 후 모두 끝나기 기다릴 때 유용하다.

public String processRequest(String message) throws Exception {
  Callable<String> makeExternalCall = new Callable<String>(){
    public String call() throws Exception{
       String result = ""
       // 비즈니스 로직
       return result; 
    }
  }
  
  Future<String> result = executorService.submit(makeExternalCall);
  String partialResult = doSomething();
  return result.get() + partialResult; 
}

스레드를 차단하지 않는 non-blocking 방법

최신 프로세서는 차단하지 않고도 안정적으로 값을 갱신하는 방법이 있다.

자바 5에서는 이런 Atomic 클래스를 통해서 가능하다.

Atomic 클래스의 메소드들은 lock을 이용하는 이전 클래스보다 연산이 훨씬 더 빠르다.

이런 원리는 CAS(Compare And Swap)이라 불리는 연산을 사용한다. 데이터베이스에서 Optimistic locking이라는 개념과 유사하다.

CAS는 여러 스레드가 같은 값을 수정해 문제를 일으키는 상황은 그리 잦지 않다는 가정에서 출발한다.

그런 상황이 발생했을 때 효율적으로 감지해 갱신이 성공할 때까지 재차 시도함으로써 가능하다.

논리적으로는 메소드가 공유 변수를 갱신하려 할 때 현재 변수 값이 최종 내가 알고 있는 값이랑 같은지 확인한다.

값이 다르다면 다른 스레드에서 수정을 했다는 뜻이므로 실패를하고 그 값으로 업데이트를 한다. 그 후 다시 갱신을 시도한다. 이런 매커니즘이다.

다중 스레드 환경에서 안전하지 않은 클래스

본질적으로 다중 스레드 환경에서 안전하지 않은 클래스가 있다. 몇가지 예는 당므과 같다.

  • SimpleDataFormat

  • 데이터베이스 연결

  • java.util.컨테이너 클래스

  • 서블릿

참고로 몇몇 Collection 클래스는 스레드에 안전한 메소드를 제공해준다. 하지만 그런 메소드 여럿을 호출하는 작업은 안전하지 않다.

예를 들면 다음과 같다.

if(!hashTable.containsKey(someKey)){
  hashTable.put(someKey, new SomeValue()); 
}

hashTable의 각 메소드는 안전하다고 하자. 그렇지만 containsKey()와 put() 사이에 다른 메소드가 끼어들 수 있다. 그러므로 다음과 같이 잠금 메커니즘을 적용해야한다.

synchronized(map){
  if(!hashTable.containsKey(someKey)){
    hashTable.put(someKey, new SomeValue()); 
  }
}

이 경우 ADAPTER 패턴을 이용해 다른 API를 사용하도록 할 수 있다.

ADAPTER 패턴은 한 클래스의 인터페이스를 사용하고자 하는 다른 인터페이스로 변환해서 사용한다.


Principle 3. 실행 모델을 이해하라

다중 스레드 어플리케이션을 분류하는 방식은 여러가지다. 구체적으로 논하기 전에 기본 용어를 먼저 보자.

  • 한정된 자원 (Bound Resource)

    • 다중 스레드 환경에서 사용하는 자원으로 크기나 숫자가 제한적이다. 데이텀베이스 커넥션 숫자라던지, 길이가 일정한 버퍼가 있다.
  • 상호 배제(Mutual Exclusin)

    • 한 번에 한 스레드만 공유 자료나 공유 자원을 사용할 수 있는 경우를 가리킨다.
  • 기아 (Starvation)

    • 한 스레드나 여러 스레드가 굉장히 오랫동안 자원을 기다리는 행위를 말한다.
  • 데드락 (Deadlock)

    • 여러 스레드가 서로 끝나기를 기다리는 상황을 말한다. 모든 스레드가 각기 필요한 자원을 다른 스레드가 점유하는 바람에 진행되지 못하는 상황이 발생한다.
  • 라이브락 (livelock)

    • 락을 거는 단계에서 각 스레드가 서로를 방해한다. 스레드는 계속 진행하려 하지만 공명(Resonance) 때문에 진행하지 못한다.

이제 실행 모델을 살펴보자

아래와 같은 모델을 알아보고 해법을 이해하는게 중요하다.

Product - Consumer 모델

하나 이상 생상자 스레드가 정보를 생산해 버퍼나 큐에 넣고 소비자 스레드가 큐에서 정보를 가져와 사용하는 모델이다.

생산자 스레드와 소비자 스레드가 사용하는 큐는 한정된 자원이다.

생산자 스레드는 큐에 빈 공간이 있어야 정보를 채운다. 그렇지 않으면 대기한다.

소비자 스레드는 큐에 정보가 채워져야 가져온다. 즉 채워질 때까지 대기한다.

생산자 소비자는 큐를 채운 후 시그널을 보낸다. 큐에 정보가 있다고 소비자 스레드는 읽고나서 빈 공간이 있다는 시그널을 보낸다.

따라서 잘못하면 둘 다 진행함에도 동시에 서로가 시그널을 기다리는 상황이 발생한다.

Reader - Writer 모델

읽기 스레드를 위한 주된 정보로 공유 자원을 사용하고 쓰기 스레드가 이 공유 자원을 이따금 갱신한다.

이런 경우에는 처리율(throughput)이 문제의 핵심이다.

처리율을 높이기 위해서 읽기 스레드가 없을 때까지 쓰기 스레드가 대기하도록 할 수 있다. 이 경우 쓰기 스레드는 기아 상태에 빠진다.

반면 쓰기 스레드에게 우선권을 주면 처리율이 떨어진다.

즉 균형을 잡으면서 동시성 문제를 해결하기 위한 방법이 필요하다.

Dining Philosophers

둥근 식탁에 한 무리가 앉아있고 각 철학자 왼쪽에는 포크가 있다고 하자.

철학자는 배가 고프면 양손에 포크를 집어 들어서 스파게티를 먹는다. 양손에 포크를 쥐지 못하면 못먹는다.

그러므로 한 철학자가 스파게티를 먹는동안에 양 옆 철학자들은 먹지 못한다.

이 포크를 자원으로 생각해보면 동시성 어플리케이션이 겪는 문제를 알 수 있다.


Principle 4. 동기화하는 메소드 사이에 존재하는 의존성을 이해하라

동기화 하는 메소드 사이에 의존성이 존재한다면 찾아내기 어려운 버그가 발생할 수 있다.

공유 클래스 하나에 동기화 된 메소드가 여럿 존재한다면 구현이 올바른지 확인해보자. 공유 객체 하나에는 공유 메소드 하나만 사용하는게 좋다.

공유 객체 하나에 여러 메소드가 필요한 상황도 생기는데 그럴 경우 다음 세 가지 방법을 고려해보자.

  • 클라이언트 잠금

    • 동기화 작업을 요청하는 클라이언트에서 첫 번째 메소드를 호출하기 전에 잠그고 마지막 메소드를 호출 할 때 잠금을 푼다.
  • 서버 잠금

    • 동기화 작업을 수행하는 서버에서 잠그고 난 후 모든 메소드를 호출한 후 잠금을 해제한다.
  • 연결(Adapted) 서버

    • 서버와 유사하지만 잠금 단계를 수행하는 중간 단계를 생성한다. 이로 인해 서버는 코드를 변경하지 않아도 된다.

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

자바에서 synchronized 키워드를 사용하면 락을 설정한다. 같은 락으로 감싼 코드 영역은 한 번에 한 스레드만 실행이 가능하다.

락은 스레드를 지연시키고 비용이 비싸기 떄문에 여러 코드에서 synchronzied 문을 남발하는 코드는 좋지 않다.

임게영역은 반드시 보호해야 하지만 임계영역 수를 최대한 줄이자.

그렇다고 거대한 임계영역 하나로 구현하는 짓은 하지말자. 동기화 하는 부분을 최대한 작게 만들자.


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

영구적으로 돌아가는 시스템을 구현하는 방법과 잠시 돌다 깔끔하게 종료하는 시스템을 구현하는 방법은 다르다.

깔끔하게 종료하는 코드를 올바르게 구현하기는 어렵다.

가장 흔한 문제는 데드락이다.

예를 들어 부모 스레드가 자식 스레드를 여러 개 만든 후 모두가 끝나기를 기다렸다 자원을 해제하고 종료하는 시스템이 있다고 가정하자.

만약 자식 스레드 중 하나가 데드락에 걸리면 부모 스레드는 자식 스레드를 영원히 기다리고 종료하지 못한다.

이런 상황은 종종 발생한다. 그러므로 깔끔하게 종료하는 다중 스레드 코드를 짜야한다면 시간을 투자해 올바른 구현하기 바란다.

즉 종료 코드를 개발 초기부터 고민하고 동작하도록 초기부터 구현하라. 생각보다 오래 걸린다.


Principle 7. 스레드 코드 테스트하기

다중 스레드 테스트에서 올바르다고 증명하기는 현실적으로 불가능하다.

테스트가 정확성을 보장하지는 않는다. 그렇지만 충분한 테스트는 위험을 낮춰준다.

그러므로 문제를 노출하는 테스트 케이스를 작성하고 프로그램 설정과 시스템 설정과 부하를 바꿔가보면서 자주 돌려봐라.

테스트가 실패하면 원인을 추적하라. 다중 스레드에서 작업 흐름은 굉장히 다양하다. 경우의 수가 엄청 많다. 그러므로 다시 돌렸더니 통과했다고 넘어가지마라.

다중 스레드 환경에서 테스트를 짤 땐 스레드 환경 밖에서 코드가 제대로 동작하는지 확인하고 다중 스레드 환경에서 제대로 동작하는지 확인해야 한다.

즉 스레드 환경 밖에서 생기는 버그와 스레드 환경에서 생기는 버그를 동시에 디버깅하지마라.

그리고 다중 스레드를 다양한 환경에서 실행을 해봐야한다.

예로 프로세서 수보다 많은 스레드를 돌리면 swapping이 자주 발생한다. 이 경우 임계영역을 빼먹은 코드나 데드락을 일으키는 코드를 찾기가 쉽다.

또 다른 플랫폼에서 돌려보는것도 추천한다. OS마다 스레드를 다루는 정책이 달라서 작업 흐름이 다를 수 있다.

이 경우 다중 스레드에서 생기는 문제를 볼 가능성이 있다.

또 다른 방법으로는 코드에 보조 코드를 넣어서 강제로 실패를 일으키는 방법이 있다.

이 보조 코드란 스레드 흐름을 인위적으로 바꾸는 코드로 Object.wait(), Object.sleep(), Object.yield(), Object.priority()와 같은 메소드가 있다.

각 메소드는 스레드가 실행되는 순서에 영향을 미친다. 그래서 버그가 드러날 가능성도 높아진다.

코드에 보조 코드를 추가하는 방법은 두 가지가 있다.

  1. 직접 구현하기

  2. 자동화

직접 구현하기

코드에다 직접 wait(), sleep(), yield(), priority() 메소드를 추가하는 것이다.

특별히 까다로운 테스트 할 때 유용하다.

public synchronized String nextUrlOrNull(){
	if(hasNext(){
       String url = urlGenerator.next();
       Thread.yield(); // 테스트를 위해 추가됨
       updatedHasNext();
       return url; 
    }
    return null; 
}

yield() 메소드를 삽입하면 코드가 실행되는 경로가 바뀐다. 그래서 이전에 싫패하지 않았던 코드가 실패할 가능성을 열어둘 수 있다.

하지만 이렇게 직접 구현을하면 코드 환경에 영향을 받는다. 배포 환경에서는 이 테스트 코드를 지워줘야하기 때문이다.

이 부분은 자동화를 통해 가능하다.

자동화

보조 코드를 자동으로 추가하려면 AOP(Aspect-Oriented Framework)와 같은 도구를 통해서 가능하다.

예를 들면 다음과 같다.

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

여기서 다양한 위치에 ThreadJigglePoint.jiggle() 메소드를 호출한다.

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

ThreadJigglePoint.jiggle() 메소드는 무작위로 sleep이나 yield를 호출한다. 때로는 아무런 동작을 하지 않는다.

이 클래스를 배포환경에서는 메소드를 비워놓고 테스트 환경에서는 sleep이나 yield를 호출하도록 한다.

이렇게 코드를 흔들어서 매번 다른 순서로 실행하도록 한다. 그래서 오류가 드러날 확률을 높여준다.

동시성에 대해 더 알아보자

여기서 부터는 동시성에 대해 더 알아보고 싶은 사람만 보자.

가능한 실행 경로

다음 incrementValue 메소드를 살펴보자. 루프나 분기가 없는 한 줄짜리 메소드다.

public class IdGenerator{
  int lastIdUsed;
  
  public int incrementValue(){
  	return ++lastIdUsed;
  }
}

정수 오버플로우는 무시하고 스레드 하나가 IdGenerator 인스턴스 하나를 사용한다고 가정하면 실행 경로는 단 하나다. 가능한 결과도 단 하나다.

하지만 스레드 두개로 이 메소드를 실행한다면 가능한 경로의 개수는 몇개일까?

초기값이 93으로 가정해보겠다. 가능한 결과는 다음과 같을 수 있다.

  • 스레드 1이 94를 얻고 스레드 2가 95를 얻고 lastIdUsed는 95가 된다.

  • 스레드 1이 95를 얻고 스레드 2가 94를 얻고 lastIdUsed는 95가 된다.

  • 스레드 1이 94를 얻고 스레드 2가 94를 얻고 lastIdUsed는 94가 된다.

어떻게 이럴 수 있을까?

이 경우를 알려면 자바 컴파일러가 생성한 바이트 코드를 살펴봐야한다.

return ++lastIdUsed라는 자바 한 줄 코드는 바이트 코드 8개 명령에 해당한다.

즉 도박사가 카드를 뒤섞듯이 두 스레드가 명령 8개를 뒤섞어 실행할 가능성이 충분하다.

심층 분석

다중 스레드가 동일한 결과를 내기 위해서는 중단이 불가능한 연산이 필요하다 이를 Atomic Operation 이라고 한다.

ACID 트랜잭션의 성질 중 하나로 중단되지 않는 연산을 말한다. 그러므로 부분적인 갱신으로 인한 문제가 생기지 않도록 한다.

다음 예시를 보자.

public class AtomicOperation {
    private int count;

    public AtomicOperation(int count) {
        this.count = count;
    }

    // Atomic Operation 
    public void resetCount() {
       count = 0;
    }

    // Non Atomic Operation 
    public void addCount(){
        count++;
    }

    public static void main(String[] args) {
        AtomicOperation operation = new AtomicOperation(5);

        operation.resetCount();

        operation.addCount();
    }
}

addCount() 메소드가 Atomic Operation이 아닌 이유는 바이트 코드를 보면 알 수 있다.

그리고 깨알 상식으로 64 비트 자바에서 long과 double 타입의 operation도 atomic 하지는 않다. 효율성을 위해서 64 비트 자바에서도 32 비트 명령어 두개로 들어올 수 있기 때문이다.

바이트 코드 예시

해석은 밑에 있다.

// access flags 0x1
  public resetCount()V
   L0
    LINENUMBER 11 L0
    ALOAD 0
    ICONST_0
    PUTFIELD me/jeongmin/solution/concurrent/AtomicOperation.count : I
   L1
    LINENUMBER 12 L1
    RETURN
   L2
    LOCALVARIABLE this Lme/jeongmin/solution/concurrent/AtomicOperation; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1

  // access flags 0x1
  public addCount()V
   L0
    LINENUMBER 15 L0
    ALOAD 0
    DUP
    GETFIELD me/jeongmin/solution/concurrent/AtomicOperation.count : I
    ICONST_1
    IADD
    PUTFIELD me/jeongmin/solution/concurrent/AtomicOperation.count : I
   L1
    LINENUMBER 16 L1
    RETURN
   L2
    LOCALVARIABLE this Lme/jeongmin/solution/concurrent/AtomicOperation; L0 L2 0
    MAXSTACK = 3
    MAXLOCALS = 1

바이트 코드를 살펴보기전에 다음 세 개를 알고있어야한다.

  • 프레임(Frame)

    • 모든 메소드 호출에는 Frame이 필요하다. 프레임은 반환 주소, 메소드로 넘어온 매개 변수, 메소드가 정의한 지역 변수를 포함한다.

    • 프레임은 Call Stack을 정의할 때 사용하는 표준 기법이다.

  • 지역 변수 (local Varaiable)

    • 메소드 범위 내에 정의되는 모든 변수를 가리킨다. 정적 메소드를 제외한 모든 메소드는 기본적으로 this라는 지역변수를 갖는다.

    • this는 현재 객체, 현재 스레드에서 가장 최근에 메시지를 받아 메소드를 호출한 객체를 말한다.

  • 피 연산자 스택 (operand Stack)

    • JVM이 지원하는 명령 대다수는 매개변수를 받는다.

    • 피연산자 스택은 이런 매개변수를 저장하는 장소다.

    • 피연산자 스택은 말그대로 LIFO를 따른다.

바이트 코드 해석

resetCount()

  • ALLOAD 0

    • 0번째 변수를 피연산자 스택에 넣는다는 말이다. 0번째 변수는 현재 객체를 말한다 즉 this
    • setCount() 메소드를 호출하면 프레임이 생성되고 메시지를 받은 객체가 프레임에 저장된다.
    • this는 모든 인스턴스 메소드 프레임에 가장 먼저 저장된다.
  • ICONST_0

    • 피연산자 스택에 값 0을 넣는다
  • PUTIFIELD me/jeongmin/solution/concurrent/AtomicOperation.count : I

    • 첫번째 스택에 있는 값(0)을 스택 두번째 값에 넣는다.

이 명령어 3개는 다 Atomic 하다. 스택에 있는 값을 다른 스레드가 어떻게 할 수가 없기 떄문에.

addCount()

  • ALODA 0

  • DUP

    • 스택 첫번째 값(this)를 복사한다. 이렇게 되면 피연산자 스택에 this 값이 두개가 생긴다
  • GETFIELD me/jeongmin/solution/concurrent/AtomicOperation.count : I

    • 스택 첫번째 값(this)에서 count 필드 값을 가지고 온다
  • ICONST_1

    • 스택에 정수 상수 1을 넣는다 (여기까지 스택에 this와 count 값, 1 이렇게 담겨 있다)
  • IADD

    • 스택 첫번째 값(1)과 스택 두번째 값(count 값)을 더해서 그 결과를 스택에 넣는다
  • PUTFIELD me/jeongmin/solution/concurrent/AtomicOperation.count : I

    • 스택 첫번째 값 (count + 1)을 스택 두번째 값(this)에 넣는다.

이렇게 바이트 코드로 보면 되게 많은 경우의 수가 있다는 걸 알 수 있고 어디서 문제가 생기는지 알 수 있다.

GETFIELD로 값을 가지고 왔는데 다른 스레드가 PUTFILED까지 진행을해서 값을 넣었다고 생각해보자.


데드락 (Deadlock)

데드락을 해결하기 위해서는 원인을 이해해야 한다.

다음 네 가지 조건을 모두 만족하면 데드락이 발생한다.

  • 상호 배제 (Mutual exclusion)

  • 잠금 & 대기 (Lock & Wait)

  • 선점 불가 (No Preemption)

  • 순환 대기 (Circular Wait)

상호 배제 (Mutual exclusion)

여러 스레드가 한 자원을 공유하나 그 자원은 여러 스레드가 동시에 사용하지 못하며 개수가 제한적이다.

주로 데이터베이스 연결, 쓰기용 파일 열기, 세마포어 등과 같은 자원이다.

해결 방법은 다음과 같다.

동시에 사용해도 괜찮은 자원을 사용한다. 예로 Atomic 클래스를 사용한다.

스레드 수 이상으로 자원을 늘린다.

자원을 점유하기 전에 필요한 자원이 모두 있는지 확인한다.

잠금 & 대기 (Lock & Wait)

일단 스레드가 자원을 점유하면 나머지 자원을 모두 점유해서 작업을 마칠때까지 대기한다.

해결 방법은 다음과 같다.

각 자원을 점유하기 전에 확인한다. 만약 어느 하나라도 점유하지 못한다면 지금까지 점유한 자원을 모두 반납해야한다.

이 경우 나올 수 있는 문제는 다음과 같다.

  • 기아 (Starvation)

    • 모든 자원을 얻기 어려운 스레드는 자원을 얻지 못할 가능성이 있다.
  • 라이브락 (livelock)

    • 여러 스레드가 계속해서 자원을 점유했다 내놨다 하는 상황이 반복된다.

두 경우 모두 자칫하면 작업 처리량을 크게 떨어뜨릴 수 있다.

선점 불가 (No Preemption)

한 스레드가 다른 스레드로부터 자원을 빼앗지 못한다.

해결 방법은 다음과 같다.

필요한 자원을 가지고 있는 스레드에게 자원을 풀어달라는 요청을 한다. 이런 요청을 관리하기는 쉽지는 않다.

순환 대기 (Circular Wait)

두 스레드가 있고 각 스레드는 서로 필요한 자원을 얻을 때까지 대기하는 상황이다.

해결 방법은 다음과 같다.

모든 자원을 순서대로 스레드에게 할당하게 만들면 순환 대기는 불가능해진다. 이를 위해서는 모든 스레드가 일정 순서에 동의해야하고 그 순서로만 자원을 할당해야 한다.


Outro

다중 스레드 코드는 올바른 구현이 어렵다. 간단했던 코드가 여러 스레드와 공유 자료를 추가하면서 악몽으로 변할 수 있다.

다중 스레드 코드를 작성하려면 그러므로 각별히 깨끗한 코드를 짜야한다.

무엇보다 먼저 SRP(Single Responsibility Principle)을 따르자.

POJO를 사용해 스레드를 아는 코드와 스레드를 모르는 코드를 분리하자. 스레드 코드를 테스트 할 땐 전적으로 스레드만 테스트하자.

다음으로 동시성 오류를 일으키는 잠정적인 원인을 철저히 이해하자.

그리고 사용하는 라이브러리와 기본 알고리즘을 이해하자. 이런 라이브러리와 기본 알고리즘이 어떻게 문제를 해결하는지 알아보자.

다음으로 보호할 코드 영역을 찾아내는 방법과 특정 코드 영역을 잠그는 방법을 이해하자. 잠글 필요가 없는 코드는 잠그지 말자.

어떻게든 문제는 생긴다. 초반에 드러나지 않는 문제는 일회성으로 치부하기 쉬운데 그러지 말자.

profile
좋은 습관을 가지고 싶은 평범한 개발자입니다.

0개의 댓글