스케줄링 정책은 운영체제마다 다를 수 있으므로, 프로그램의 성능이 스케줄러에 따라 달라지지 않도록 해야 한다.
아래 세 가지 방안을 지킨다면, 스케줄러에 독립적인 이식성이 높은 프로그램을 만들 수 있다.
실행 가능한 스레드의 평균적인 수를, 프로세서 수보다 지나치게 많아지지 않도록 하면 스케줄링 정책과 상관 없이 동작이 크게 달라지지 않는다. 따로 스케줄링이 필요 없어지기 때문이다.
그렇다면 어떻게 실행 가능한 스레드 수를 적게 유지할 수 있을까?
각 스레드가 작업을 완료한 이후에, 다음 작업이 생길 때 까지 대기하도록 하면 된다. 예를 들어, 스레드 풀 크기를 설정하고 각각의 작업은 짧게 유지할 수 있다.
CPU Bound
프로세스CPU를 써야 하는 프로세스라면, 일반적으로 하나의 코어에 하나의 스레드만 수행할 수 있으므로 스레드 개수만 늘린다고 달라지는 것이 없다. 따라서, CPU 코어 개수와 동일하게 스레드 풀의 크기를 정하는 것이 좋다.
I/O Bound
프로세스외부 시스템으로부터 데이터를 받는 등 기다림이 필요한 프로세스라면, CPU는 자유롭기 때문에 그 사이에 다른 스레드를 처리할 수 있다.
하지만, 대기시간이 길다면 아래 그림 처럼 스레드 개수를 무작정 늘리는게 큰 메리트가 없다. 따라서, 코어 개수 뿐만 아니라 대기 시간까지 고려해 스레드 풀의 크기를 정하는 것이 좋다.
실제로는 HTTP 커넥션 풀, JDBC 커넥션 풀 등 까지 고려해야 하기 때문에 목표 CPU 사용률을 도입할 수 있다.
결론적으로 지속적으로 성능 테스트를 해보고, 적절한 스레드 개수를 찾아야 한다. 최대 초당 처리양은 리틀의 법칙 (Little's Law)
공식을 통해 구할 수 있다.
L = λ * W
L : 동시에 처리되는 요청의 개수
λ : 시스템이 처리 가능한 평균 처리량
W : 평균 요청 처리 시간
시스템 처리량을 나타내는 TPS를 구하는 공식에서도, 해당 법칙이 적용이 된다. Active users
가 L
, TPS
가 λ
, Response time
을 W
라고 생각해보면 편하다.
예를 들어 평균 응답시간이 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
을 하는 경우가 존재하는데 두 가지 방식이 존재한다.
바쁜 대기 상태(busy waiting)이란, 자원을 얻기 위해 기다리는 것이 아니라 권한을 얻을 때까지 확인하는 것을 의미한다.
while (cond()) { // 계속해서 조건을 충족하는지 검사 } doTask();
public void await() {
while (true) { // 계속해서 조건을 충족하는지 검사
synchornized(this) {
if (count == 0) return;
}
}
}
하지만, 이러한 동기화 방식은 스케줄러에 영향을 많이 받으며 지속적으로 확인하기 때문에 CPU 자원의 계속적인 활용으로 프로세서에 부담을 주어 좋지 않다.
쓰레드가 공유자원을 모두 사용하면 그때 기다리고 있던 다른 쓰레드를 깨우는 방식으로, 기다리는 쓰레드는 지속해서 확인할 필요가 없어지기 때문에 자원 낭비가 덜하다는 장점이 있다. 하지만, 컨텍스트 스위칭 비용으로 인한 오버헤드가 존재한다.
대기 스레드를 block
시키고 waiting queue
로 보내놨다가, 커널 이벤트가 발생하면 다시 스레드를 깨워 ready
상태로 변환하고 CPU
권한을 부여한다.
Monitor
: 뮤텍스락(할당, 해제)의 관리를 자동으로 해준다.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; // 깨우기
}
잦은 컨텍스트 스위칭 비용이 비쌀 수 있기 때문에 busy-waiting 방식 사용 ex) spinlock
미리 wait queue
에 스레드들을 넣고 잠들어 있게 해야 효율적이기 때문에 sleeping
방식 사용
스레드 우선순위를 정해 당장 성능을 높일 수는 있지만, 문제가 발생했을 때 고치는 용도로 사용하면 안된다.
Thread.yield
를 사용해서 우선 순위를 정하는 것은, 매번 같은 결과를 보장하지 않으며 플랫폼(OS)에 따라 달라질 가능성이 높기 때문이다.
현재 돌고 있는 쓰레드가 다른 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