[Effective Java] 아이템 84: 프로그램의 동작을 스레드 스케줄러에 기대지 마라

Loopy·2023년 7월 3일
2

이펙티브 자바

목록 보기
75/76
post-thumbnail

스케줄링 정책은 운영체제마다 다를 수 있으므로, 프로그램의 성능이 스케줄러에 따라 달라지지 않도록 해야 한다.

아래 세 가지 방안을 지킨다면, 스케줄러에 독립적인 이식성이 높은 프로그램을 만들 수 있다.

☁️ 실행 가능한 스레드 작게 유지하기

실행 가능한 스레드의 평균적인 수를, 프로세서 수보다 지나치게 많아지지 않도록 하면 스케줄링 정책과 상관 없이 동작이 크게 달라지지 않는다. 따로 스케줄링이 필요 없어지기 때문이다.

그렇다면 어떻게 실행 가능한 스레드 수를 적게 유지할 수 있을까?

각 스레드가 작업을 완료한 이후에, 다음 작업이 생길 때 까지 대기하도록 하면 된다. 예를 들어, 스레드 풀 크기를 설정하고 각각의 작업은 짧게 유지할 수 있다.

(추가) 적정 스레드 개수 계산

  1. CPU Bound 프로세스

CPU를 써야 하는 프로세스라면, 일반적으로 하나의 코어에 하나의 스레드만 수행할 수 있으므로 스레드 개수만 늘린다고 달라지는 것이 없다. 따라서, CPU 코어 개수와 동일하게 스레드 풀의 크기를 정하는 것이 좋다.

  1. I/O Bound 프로세스

외부 시스템으로부터 데이터를 받는 등 기다림이 필요한 프로세스라면, CPU는 자유롭기 때문에 그 사이에 다른 스레드를 처리할 수 있다.

하지만, 대기시간이 길다면 아래 그림 처럼 스레드 개수를 무작정 늘리는게 큰 메리트가 없다. 따라서, 코어 개수 뿐만 아니라 대기 시간까지 고려해 스레드 풀의 크기를 정하는 것이 좋다.

실제로는 HTTP 커넥션 풀, JDBC 커넥션 풀 등 까지 고려해야 하기 때문에 목표 CPU 사용률을 도입할 수 있다.

결론적으로 지속적으로 성능 테스트를 해보고, 적절한 스레드 개수를 찾아야 한다. 최대 초당 처리양은 리틀의 법칙 (Little's Law) 공식을 통해 구할 수 있다.

L = λ * W
L : 동시에 처리되는 요청의 개수
λ : 시스템이 처리 가능한 평균 처리량
W : 평균 요청 처리 시간

시스템 처리량을 나타내는 TPS를 구하는 공식에서도, 해당 법칙이 적용이 된다. Active usersL , TPSλ, Response timeW 라고 생각해보면 편하다.

예를 들어 평균 응답시간이 55ms 이고 스레드 풀의 크기를 22 로 설정해두었을 때, 시스템이 처리 가능한 평균 처리량을 구하면 22/0.055 가 되므로 400 건이 나온다.

우리는 평균 처리량을 높여 응답 속도인 평균 요청 처리 시간을 줄이는 것이 중요하기 때문에, 해당 값을 높이도록 적절히 조절하면 된다.

https://code-lab1.tistory.com/269
https://incheol-jung.gitbook.io/docs/study/with-scouter/chap-08.
https://performance.tistory.com/3

☁️ 바쁜 대기 상태 회피하기

동기화를 위해, 혹은 선행 작업이 끝나기를 기다리기 위해서 wait을 하는 경우가 존재하는데 두 가지 방식이 존재한다.

1. busy waiting 방식

바쁜 대기 상태(busy waiting)이란, 자원을 얻기 위해 기다리는 것이 아니라 권한을 얻을 때까지 확인하는 것을 의미한다.

while (cond()) {  // 계속해서 조건을 충족하는지 검사
}
doTask();
public void await() {
	while (true) {    // 계속해서 조건을 충족하는지 검사
    	synchornized(this) {
        	if (count == 0) return;
        }
    }
}

하지만, 이러한 동기화 방식은 스케줄러에 영향을 많이 받으며 지속적으로 확인하기 때문에 CPU 자원의 계속적인 활용으로 프로세서에 부담을 주어 좋지 않다.

2. sleeping 방식

쓰레드가 공유자원을 모두 사용하면 그때 기다리고 있던 다른 쓰레드를 깨우는 방식으로, 기다리는 쓰레드는 지속해서 확인할 필요가 없어지기 때문에 자원 낭비가 덜하다는 장점이 있다. 하지만, 컨텍스트 스위칭 비용으로 인한 오버헤드가 존재한다.

대기 스레드를 block 시키고 waiting queue 로 보내놨다가, 커널 이벤트가 발생하면 다시 스레드를 깨워 ready 상태로 변환하고 CPU 권한을 부여한다.

  1. Monitor : 뮤텍스락(할당, 해제)의 관리를 자동으로 해준다.
  2. Mutex, Semaphore : 직접 락을 얻고 해제하는 코드를 작성해야 한다.

세마포어 예시이다.

S--  // 자원 획득 
if (S.value < 0) {  // 얻을 자원이 없는 경우
	add this process to S.L  // 대기 큐에 넣기
    block;
}
S++  // 자원 반납
if (S.value <= 0) {  // 대기 중인 프로세스가 있었다면
	remove a process in S.L  // 큐에서 제거
	wake_up;    // 깨우기
}

둘 중에 어떤 방식이 효율적일까?

  1. 기다리고 있는 작업이 자주 호출되고 수행 시간이 짧은 경우

잦은 컨텍스트 스위칭 비용이 비쌀 수 있기 때문에 busy-waiting 방식 사용 ex) spinlock

  1. 기다리는 시간이 예측 불가능한 상황인 경우

미리 wait queue 에 스레드들을 넣고 잠들어 있게 해야 효율적이기 때문에 sleeping 방식 사용

☁️ 스레드 우선순위 부여하지 않기

스레드 우선순위를 정해 당장 성능을 높일 수는 있지만, 문제가 발생했을 때 고치는 용도로 사용하면 안된다.

Thread.yield 를 사용해서 우선 순위를 정하는 것은, 매번 같은 결과를 보장하지 않으며 플랫폼(OS)에 따라 달라질 가능성이 높기 때문이다.

Thread.yield

현재 돌고 있는 쓰레드가 다른 Thread를 위해서 양보하겠다는 힌트를 주는 메서드이다.

무조건적으로 즉시 양보가 되는 것이 아니고, 스케줄러가 힌트를 알아차린 경우에만 run에서 runnable 상태로 변경하게 된다. 또한, 대기 상태에 들어갔다 하더라도 이 역시 스케줄러에 따라 다르게 언제든지 다시 선택될 수 있기 때문에 결론적으로 실행 결과가 보장이 되지 않는다.

public class ThreadYield {
    public static void main(String[] args) {
        Runnable r = () -> {
            int counter = 0;
            while (counter < 2) {
                System.out.println(Thread.currentThread()
                    .getName());
                counter++;
                Thread.yield();
            }
        };
        new Thread(r).start();
        new Thread(r).start();
    }
}

기대했던 결과는 0-1-0-1 혹은 1-0-1-0 처럼 순서대로 양보가 되는 방식이겠지만, 결과는 아래처럼 매 실행마다 달라진다.

Thread-0
Thread-1
Thread-1
Thread-0
Thread-0
Thread-0
Thread-1
Thread-1

https://www.baeldung.com/java-thread-yield

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글