Post

[Book - Clean Code] 18. 동시성2

13장에서 소개한 동시성을 좀 더 자세히 설명하고 보완한다.

클라이언트/서버 예제

서버는 소켓을 열어놓고 클라이언트가 연결하기를 기다린다.
클라이언트는 소켓에 연결해 요청을 보낸다.

이벤트 폴리 루프를 구현하면 모를까, 단일 스레드 환경에서 속도를 끌어올릴 방법은 거의 없다.

다중 스레드를 사용하면 성능이 높아질까?
그럴지도 모르지만, 먼저 애플리케이션이 어디서 시간을 보내는지 알아야 한다.

  • I/O: 소켓 사용, DB 연결, 가상 메모리 스와핑 기다리기 등에 시간을 보낸다.
  • 프로세서: 수치 계산, 정규 표현식 처리, GC 등에 시간을 보낸다.

만약 프로그램이 주로 프로세서 연산에 시간을 보낸다면, 새로운 하드웨어를 추가해 성능을 높여 테스트를 통과하는 방식이 적합하다.
프로세서 연산에 시간을 보내는 프로그램은 스레드를 늘인다고 빨라지지 않는다.
CPU 사이클은 한계가 있기 때문이다.

반면 프로그램이 주로 I/O 연산에 시간을 보낸다면 동시성이 성능을 높여주기도 한다.
시스템 한쪽이 I/O를 기다리는 동안에 다른 쪽이 뭔가를 처리해 노는 CPU를 효과적으로 활용할 수 있다.

서버 코드가 지는 책임은 몇 개일까?

  • 소켓 연결 관리
  • 클라이언트 처리
  • 스레드 정책
  • 서버 종료 정책

불행하게도 이 모든 책임은 process 함수가 진다.
process 함수가 작기는 하지만 확실히 분할할 필요가 있다.

스레드를 관리하는 코드는 스레드만 관리해야 한다.
비동시성 문제까지 뒤섞지 않더라도 동시성 문제는 그 자체만으로도 추적하기 어렵다.

동시성은 그 자체가 복잡한 문제이므로 다중 스레드 프로그램에서는 SRP가 특히 중요하다.

가능한 실행 경로

1
2
3
4
5
6
7
public class IdGenerator {
    int lastIdUsed;

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

각 스레드가 incrementValue 메서드를 한 번씩 호출한다면?

lastIdUsed 초깃값을 93으로 가정할 때

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

루프나 분기가 없는 명령 N개를 스레드 T개가 차례로 실행한다면 가능한 경로 수는 (NT)!/N!^T와 같다.

return ++lastIdUsed라는 자바 코드 한줄은 바이트 코드 명령 8개(int)에 해당한다.
N=8이고 T=2이면, 수식에 대입하여 계산하면 가능한 경로 수는 12,870개다.
lastIdUsed가 long 정수라면 가능한 경로 수는 2,704,156개로 늘어난다.

만약 메서드를 아래와 같이 변경하면 어떨까?

1
2
3
public synchronized void incrementValue() {
    ++lastIdUsed;
}

가능한 경로 수는 (스레드가 2개일 때) 2개로 줄어든다!
스레드가 N개라면 가능한 경로 수는 N!이다.

(전처리 연산과 후처리 연산 모두) ++ 연산은 원자적 연산이 아니다.
즉 다음을 알아야 한다.

  • 공유 객체/값이 있는 곳
  • 동시 읽기/수정 문제를 일으킬 소지가 있는 코드
  • 동시성 문제를 방지하는 방법

라이브러리를 이해하라

스레드 풀링으로 정교한 실행을 지원하는 java.util.concurrent 패키지에 속한 Executor 클래스가 있다.
Executor 프레임워크는 스레드 풀을 관리하고, 풀 크기를 자동으로 조정하며, 필요하다면 스레드를 재사용한다.
게다가 다중 스레드 프로그래밍에서 많이 사용하는 Future도 지원한다.
Future는 독립적인 연산 여럿을 실행한 후 모두가 끝나기를 기다릴 때 유용하다.

많은 스레드가 경쟁하는 상황이라도 락을 거는 쪽보다 문제를 감지하는 쪽이 거의 항상 더 효율적이다.

본질적으로 다중 스레드 환경에서 안전하지 않은 클래스가 있다.

  • SimpleDateFormat
  • DB 연결
  • java.util 컨테이너 클래스
  • 서블릿

메서드 사이에 존재하는 의존성을 조심하라

스레드 두 개가 인스턴스 하나를 공유하는 메서드를 조심하라.

다중 스레드 환경에서 발생하는 문제를 추적하기는 어렵다.
해결 방안은 세 가지다.

  • 실패를 요인
  • 클라이언트를 바꿔 문제를 해결. 즉, 클라이언트-기반 잠금 메커니즘을 구현
  • 서버를 바꿔 문제를 해결. 서버에 맞춰 클라이언트도 변경. 즉, 서버-기반 잠금 메커니즘을 구현

일반적으로 서버-기반 잠금이 더 바람직하다.
이유는 아래와 같다.

  • 코드 중복 감소
  • 성능 향상
  • 오류 발생 가능성 감소
  • 하나의 스레드 정책
  • 공유 변수 범위 감소

데드락

데드락을 근본적으로 해결하려면 원인을 이해해야 한다.
다음 네 가지 조건을 모두 만족하면 데드락이 발생한다.

  • 상호 배제
  • 잠금 & 대기
  • 선점 불가
  • 순환 대기

상호 배제

여러 스레드가 한 자원을 공유 하나 그 자원은 여러 스레드가 동시에 사용하지 못하며, 개수가 제한적이라면, 상호 배제 조건을 만족한다.

좋은 예는 DB 연결, 쓰기용 파일 열기, 레코드 락, 세마포어 등과 같은 자원이다.

데드락을 피하는 구체적인 방법은 아래와 같다.

  • AtomicInteger 같이 동시에 사용해도 괜찮은 자원을 사용
  • 스레드 수 이상으로 자원 수 추가
  • 자원을 점유하기 전에 필요한 자원이 모두 있는지 확인

불행하기도 대다수 자원은 그 수가 제한적인 데다 동시에 사용하기도 어렵다.

잠금 & 대기

일단 스레드가 자원을 점유하면 필요한 나머지 자원까지 모두 점유해 작업을 마칠 때까지 이미 점유한 자원을 내놓지 않는다.

대기하지 않으면 데드락이 발생하지 않는다.

각 자원을 점유하기 전에 확인해서 만약 어느 하나라도 점유하지 못한다면 지금까지 점유한 자원을 몽땅 내놓고 처음부터 다시 시작한다.
이 방법은 잠재적인 문제가 있다.

  • 기아: 한 스레드가 계속해서 필요한 자원을 점유 X
  • 라이브락: 여러 스레드가 한꺼번에 잠금 단계로 진입하는 바람에 계속해서 자원을 점유했다 내놨다를 반복

기아는 CPU 효율을 저하시키는 반면 라이브락은 쓸데 없이 CPU만 많이 사용한다.
두 경우 모두 자칫하면 작업 처리량을 크게 떨어뜨린다.

선점 불가

한 스레드가 다른 스레드로부터 자원을 빼앗지 못한다.
자원을 점유한 스레드가 스스로 내놓지 않는 이상 다른 스레드는 그 자원을 점유하지 못한다.

다른 스레드로부터 자원을 뺏어오는 방법으로 데드락을 피한다.

필요한 자원이 잠겼다면 자원을 소유한 스레드에게 풀어달라 요청한다.
소유 스레드가 다른 자원을 기다리던중이었다면 자신이 소유한 자원을 모두 풀어주고 처음부터 다시 시작한다.
이 모든 요청을 관리하기는 그리 간단하지 않다.

순환 대기

T1이 R1을 점유, T2가 R2를 점유, T1은 R2가 필요, T2도 R2가 필요

데드락을 방지하는 가장 흔한 전략으로, 모든 스레드가 일정 순서에 동의하고 그 순서로만 자원을 할당한다면 데드락은 불가능하다.
그러나 여느 전략과 마찬가지로 아래와 같은 문제가 있다.

  • 자원을 할당하는 수선와 자원을 사용하는 순서가 다를지도 모름. 즉, 자원을 꼭 필요한 이상으로 오랫동안 점유
  • 때로는 순서에 따라 자원 할당이 어려움. 첫 자원을 사용한 후에야 둘째 자원 ID를 얻는다면 순서대로 할당하기란 불가능

프로그램에서 스레드 관련 코드를 분리하면 조율과 실험이 가능하므로 통찰력이 높아져 최적의 전략을 찾기 쉬워진다.

This post is licensed under CC BY 4.0 by the author.

© Yn3. Some rights reserved.