대범하게

[클린코드] 44~48일차 - 동시성2 톺아보기 본문

알아두면쓸데있는신기한잡학사전/독서일지

[클린코드] 44~48일차 - 동시성2 톺아보기

대범하게 2023. 12. 22. 00:00
반응형

[클린코드] 44~45일차 - 동시성2 톺아보기

클린코드 44일차 (p. 404 ~ 407 (17장) / 408 ~ 412 (18)

클린코드 45일차 (p. 413 ~ 419 (18장) )

클린코드 46일차 (p. 420 ~ 428 (18장) )

클린코드 47일차 (p. 429 ~ 436 (18장) )

클린코드 48일차 (p. 437 ~ 446 (18장) )

18장 동시성2

225쪽에서 소개한 동시성을 좀 더 자세히 보완하기 위한 장이다.

클라이언트/서버 예제

예제를 살펴보면 서버와 클라이언트의 단순한 소켓 프로그래밍 코드를 보여주고 있다.

 

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

 

아래는 클라이언트/서버 테스트 코드이다. 해당 테스트는 10초 안에 처리가 되는지 확인하는 테스트 코드이다. 

@Test(timeout = 10000)
public void shouldRunInUnder10Seconds() throws Exception {
	Thread[] threads = createTthreads();
	startAllThreads(threads);
	waitForAllThreadsToFinish(threads);
}

 

정확히 얘기하면 시스템이 클라이언트 요청을 10초내에 처리해야 한다는 의미이다. 

 

이 테스트 코드를 잘 통과한다는 의미는 서버가 각 클라이언트 요청을 10초 내에 처리한다는걸 뜻한다.

 

이런 테스트 케이스는 시스템 작업 처리량을 검증하는 전형적인 예시이다.

 

그런데 만약 테스트가 실패한다면 ?

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

 

그렇다면 다중 스레드를 사용하면 성능이 높아지나? 그럴지도 모르지만 더 중요한건 어디서 시간을 보내는지 확인해야 한다. 

 

시간을 보낼만 한 곳은 두 가지이다.

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

대개 시스템은 둘 다 하느라 시간을 보내고 있을테지만, 특정 연산을 살펴보면 대개 하나가 지배적이다. 

 

1) I/O

 

프로그램이 주로 I/O 연산에 시간을 보낸다면 동시성이 성능을 높여주기도 한다.

 

시스템 한쪽이 I/O를 기다리는 동안 다른 쪽이 뭔가를 처리해 CPU를 효과적으로 활용할 수 있다. 

 

2) 프로세서

 

프로그램이 프로세서 연산에 시간을 보낸다면, 새로운 하드웨어 추가해 성능을 높여 테스트 통과하는 방식이 적합하다.

 

프로세서 연산에 시간을 보내는 프로그램은 스레드를 늘인다고 빨라지지 않는다. 

 

CPU 사이클은 한계가 있기 때문에.

 

즉, 

I/O 에서 시간을 보낸다 -> 동시성으로 성능 해결

프로세서에서 시간을 보낸다. -> 하드웨어를 추가해 성능 해결

 

어떻게 해야봐야하나?

스레드를 추가해보자.

서버의 process 함수가 주로 I/O 연산에 시간을 보낸다면, 다음처럼 스레드를 추가한다.

서버를 살펴보자.

위에서 고친 서버는 대략 1초 만에 성능 테스트를 완료하지만, 새로운 문제를 일으킨다.

 

1. 동작 문제 

새 서버가 만든 스레드 수는 몇 개일까?

코드에서 명시하지 않으므로 이론상 JVM이 허용하는 수까지 가능하다.

 

대다수 간단한 시스템은 그래도 괜찮다. 하지만 공용 네트워크에 연결된 수많은 사용자를 지원하는 시스템이라면 어떨까?

너무 많은 사용자가 한꺼번에 몰린다면 시스템이 동작을 멈출지도 모른다. (스레드 풀이 필요한 이유 ..!)

 

하지만 동작 문제는 잠시 미뤄두자.

왜냐하면 그 외에도 깨끗한 코드와 구조라는 관점에서도 문제가 있다. 서버 코드가 지는 책임이 몇 개일까?

 

2. 다양한 책임 

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

(클린코드에서 진짜 기가막히도록 강조했던 책임의 갯수 .. 단 하나 ...)

 

불행하게도 이 모든 책임은 process 함수가 진다.

즉, 단일 책임 원칙(SRP, Single Responsibility Principle)을 위반한다.

 

다중 스레드 프로그램을 깨끗하게 유지하려면 잘 통제된 곳으로 스레드 관리를 모아야 한다. 아니, 스레드를 관리하는 코드는 스레드만 관리해야 한다. 

void process(final Socket socket) {
    if (socket == null) {
        return;
    }
    
    Runnable clientHandler = new Runnable() {
        public void run() {
            try {
                String message = MessageUtils.getMessage(socket);
                MessageUtils.sendMessage(socket, "Processed: " + message);
                closeIgnoringException(socket);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    };

    Thread clientConnection = new Thread(clientHandler);
    clientConnection.start();
}

결론

동시성 그 자체가 복잡한 문제이기에 다중 스레드 프로그램에서는 단일 책임 원칙이 특히 중요하다. 

가능한 실행 경로

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

 

( 동시성 챕터에서 같은 예제를 다뤘었다..! 3. 동시성을 구현하기 어려운 이유 )

 

스레드 하나가 IdGenerator 인스턴스 하나를 사용하면 가능한 실행 경로는 단 하나고, 가능한 결과도 단 하나다.

  • 반환값은 lastIdUsed 값과 동일하다. 두 값 모두 메서드를 호출하기 전보다 1이 크다.

만약 IdGenarator 인스턴스는 그대로지만 스레드가 두 개라면? 각 스레드가 함수를 한 번씩 호출한다면 가능한 결과는 무엇일까? 실행 경로는? 초깃값을 93으로 가정할 때 가능한 결과는 다음과 같다.

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

놀랄지도 모르지만 마지막 결과도 가능하다. 가능한 실행 경로 수와 JVM의 동작 방식을 알아야 한다.

 

심층 분석

아래의 코드 중 lastID = 0은 원자적 연산 이고 ++lastId는 원자적 연산이 아닙니다.

 

- 원자적 연산 : 중단이 불가능한 연산을 말함

 

원자적 연산이 아니라면 연산간의 다른 스레드 간섭이 가능하기 때문에 위의 마지막 같은 문제가 생기는 것이다.

즉, 여러 스레드에서 코드를 실행할 때, 원자적 연산(lastID = 0)은 무조건 같은 결과가 나올 것이고, 원자적 연산이 아닌(++lastId) 것은 스레드 간섭으로 인해 다른 결과가 나올 수 있다.

 

라이브러리를 이해하라

Executor 프레임워크

스레드는 생성하나 스레드 풀을 사용하지 않는다면 or 직접 생성한 스레드 풀을 사용한다면 Executor 클래스를 고려하자.

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

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

Future, Runnable 구현 클래스, Callable 구현체 등을 지원한다.

 

자바의 Executor를 이용해 스레드 풀을 관리하는 방법에 대해 나오지만, 직접 사용하지 않는 이상 와닿진 않을 것 같다.

하지만, Blocking vs Non-Blocking / Sync vs Async 에 대해서는 잘 알고 있자.

 

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

여러 개의 스레드가 코드를 실행할 때, 간혈적으로 버그가 발생한다면 해결하는 방법으로 아래의 3가지 방법이 있습니다.

  1. 실패를 용인 : 클라이언트에서 예외를 받아 처리(조잡한 방법)
  2. 클라이언트 - 기반 잠금 : 버그가 발생하는 클라이언트 모든 부분에 적용해야 하므로, 시스템이 커질 수록 실수할 확률이 높아짐
  3. 서버-기반 잠금 : 클라이언트에서 일일이 잠금에 대한 처리를 해줄 필요가 없기 때문에 제일 바람직하며, 아래와 같은 장점이 있음

작업 처리량 높이기

이 장은 3.라이브러리를 이해하라의 Blocking vs Non-Blocking / Sync vs Async의 맨 아래의 동영상 스트리밍 서비스 부분으로 설명할 수 있다.

데드락

데드락은 아래의 4가지 조건을 만족해야 한다.

  1. 상호 배제(Mutual exclusion) : 동시에 사용해도 괜찮은 자원을 사용함으로써 해결한다. 대부분은 동시에 자원을 사용하기가 어렵다.
  2. 잠금 대기(Lock & Wait) : 각 자원을 점유하기 전에 확인하고 어느 하나라도 점유하지 못한다면 지금까지 점유한 자원을 반환하고 다시 시작 함으로써 해결한다. 기아, 라이브락이 발생할 수 있다.
  3. 선점 불가(No Preemption) : 다른 스레드로부터 자원을 뺏어오는 방법으로 해결한다. 모든 요청을 관리하기가 힘들다.
  4. 순환 대기(Circular Wait) : 자원에 접근하는 순서를 고정함으로써 해결한다. 다양한 상황이 있기에 자원 사용 순서가 다를 가능성이 높다.

위의 4가지 조건 중 하나라도 만족하지 않으면 데드락은 발생하지 않는다.

다중 스레드 코드 테스트

몬테 카를로 테스트로 아래와 같은 상황에서 돌아가는 테스트 케이스를 만들어 다중 스레드 테스트를 하더라도 간혈적으로 일어나는 버그를 발견하기는 쉽지 않다.

  • 시스템을 배치할 플랫폼 전부에서 반복적으로 테스트를 돌림. 실패 없이 오래 돌아간다면, 아래의 두 가지 중 하나일 확률이 높음.
  • 부하가 변하는 장비에서 테스트를 돌림.
  • 실제 환경과 비슷하게 부하를 걸어 주는 게 좋음.

스레드 코드 테스트를 도와주는 도구

저자는 IBM의 ConTest같은 스레드 코드 테스트를 도와주는 도구를 적극 활용하는게 좋다고 한다.

결론

이 장에서는 동시성 프로그래밍, 다중 스레드 코드를 깨끗하게 유지하는 방법을 익혔다.

동시 갱신과 동시 갱신은 방지하는 동기화/잠금 기법을 소개했다. 

스레드가 I/O 위주 시스템의 처리율을 높여주는 이유와 실제로 처리율을 높이는 방법도 보았다.

동시성을 당장에 다루는 것이 아니라면, 동시성에서 사용되는 다양한 개념들을 정확히 알고 가는 것이 좋지 않을까 생각한다. 

Comments