java.util Timer/TimerTask 뜯어보기

GuruneLee·2021년 12월 13일
3

Let's Study 공부해요~

목록 보기
7/36

근데 왜 뜯어봐요

슬랙봇을 만드는 개인 프로젝트를 하는 와중, 매일 실행되어야 하는 스케쥴러가 제대로 동작하지 않고 자꾸 시간이 밀리거나, 아예 실행되지 않는 등의 문제를 발견했다.

이를 해결하기 위해 스케쥴링에 사용한 java.util.Timer 와 java.util.TimerTask를 뜯어보았다.

synchronized

멀티쓰레드 환경에서 공유되는 자원이 있을 때, 현재 자원에 이미 접근한 쓰레드 외의 다른 쓰레드들의 접근을 막는 메서드

처음보는 키워드라 java - synchronized 란? 사용법? 를 봤다.

Timer 에는 synchronized() 메서드가 자주 나온다.
TimerTask에 'Object lock' 이라는 빈 인스턴스를 생성하고, TimerTask 내부에 접근할 때 마다 synchronized(lock){}를 사용해 자원을 lock하는 식이다.

  • wait랑 notify는 synchronized 블럭안에서만 쓸 수 있다.

java.util.TimerTask 뜯어보기

Timer를 이용해 스케쥴링을 할 때, 한 task의 단위가 되는 클래스이자 java의 Runnable 인터페이스를 구현한 '추상클래스' 이다. 추상클래스 이므로, 여기에 포함된 추상메서드 'run()'을 직접 구현해서 TimerTask의 익명 인스턴스를 만들어 사용한다 (클래스 상속 받아서 만들어도 됨)

주요한 멤버와 메서드를 살펴보자

members

  1. int state
    : 인스턴스의 상태를 표현. 다음과 같은 상태들이 있다
    • VIRGIN : 아직 스케쥴링이 되지 않은 task (Timer 에 추가되지 못했다)
    • SCHEDULED : 스케쥴링이 완료됐다
    • EXECUTED : 이미 실행됐거나 현재 실행중이며, cancel되지 않았다
    • CANCELLED : 취소되었다 (TimerTask.cancel 이 실행되었다)
  2. long nextExcutionTime
    : 다음 실행될 시간 in millisecond. 만약 반복될 task라면 (period>0 이라면) 이후 계속 업데이트 될 것.
  3. long period = 0
    : 반복되는 주기. 0 이라면 반복되지 않음을 의미.
  4. final Object lock = new Object()
    : TimerTask 내부 권한을 얻기 위해 사용 하는 Object. synchronized() 와 함께 쓰인다.

methods

  1. public abstract void run()
    : 보시다시피 추상메서드. 사용자가 직접 구현해야함.
  2. pubilc boolean cancel()
    : TimerTask 에 접근하여 state 를 CANCELLED 로 만들어버린다. 만약 취소 전 state가 SCHEDULED 가 아니라면 False를, 아니면 True를 반환한다.
public boolean cancel() {
	synchronized(lock) {
        boolean result = (state == SCHEDULED);
        state = CANCELLED;
		return result;
    }
}
  1. public long scheduledExecutionTime()
    : 스케쥴링된 실행시간을 반환한다. 만약 현재 실행중이라면, 이 실행이 시작한 시간을 반환한다.
    • 이게 왜 이따구로 짜여있는지 알려면, Timer 를 봐야한다.
    • TimerTask 의 run 메서드와 같이 실행되게 되는데(Timer 에 의해), 현재 실행 시간이 ㅏ스케쥴링된 활동을 수행하기에 부족함이 없는지 판단하기 위해 쓰인다.... 라는데 뭔말이야
    • System.currentTimeMillis() - secheduledExcutionTime() >= MAX_TARDINESS 이라면 실행 할 수 없다
public long scheduledExecutionTime() {
	synchronized(lock) {
		return (period < 0 ? nextExecutionTime + period
                               : nextExecutionTime - period);
        }
    }

java.util.Timer 뜯어보기

Timer 라는 메인 클래스와 TimerThread, TimerQueue라는 헬퍼 클래스로 이루어져 있다.

class TaskQueue

우선순위큐로 동작하는 TimerTask 배열을 가지고 있다. TimerTask[] queue 라는 필드를 가지고 있다. init size는 128이고, 이를 우선순위큐 처럼 동작시키기 위한 메서드들이 있으며, queue 의 head 는 queue[1] 이다.
(size, add, getMin, get(i), removeMin, quickRemove(i), rescheduleMin(long), isEmpty, clear 등...)

이 중 rescheduleMin() 만 소개한다.

void rescheduleMin(long newTime) {
	queue[1].nextExecutionTime = newTime;
	fixDown(1);
}
  • head(=Excution 시간이 제일 이른거)에 있는 TimerTask의 nextExecutionTime을 newTime으로 바꾸고 fixDown(1) 한다
    - fixDown(int k) : 큐의 k번째 요소를 루트로 하는 subtree를 heapify 한다 (heap invariant 를 유지하게 한다)

class TimerThread

java.util.Thread를 상속하며, Timer에 등록된 task를 실행하는 쓰레드이다.
task가 시간이 될때까지 기다렸다가 시간이 되면 실행시키고, 반복 task 를 리스케쥴링 하고, 취소된 task를 제거하거나 반복되지 않은 task는 queue에서 제거하는 등의 갖가지 일을 한다.

members

  1. boolean newTasksMayBeScheduled = true
    : 수행할 task가 남아있는지 체크하는 flag
  2. private TaskQueue queue
    : Timer의 queue를 TimerThread가 관리한다. 이유가 다음과 같이 적혀있긴한데 이해하지 못했다.

    java.util.Timer line 491
    : We store this reference in preference to a reference to the Timer so the reference graph remains acyclic. Otherwise, the Timer would never be garbage-collected and this thread would never go away.

methods

  1. (생성자) TimerThread(TaskQueue queue) {this.queue = queue}
    : Timer 에서 queue에 task를 추가해서 넘겨주는 듯 하다
  2. public void run()
    : 아래의 'mainLoop()' 를 실행한다. 실행이 종료되면, newTaskMayBeScheduled를 False로 바꾸고 queue를 깨끗이 비운다
public void run() {
        try {
            mainLoop();
        } finally {
            // Someone killed this Thread, behave as if Timer cancelled
            synchronized(queue) {
                newTasksMayBeScheduled = false;
                queue.clear();  // Eliminate obsolete references
            }
        }
    }
  1. private void mainLoop()
    : queue에 등록된 TimerTask를 실행하는게 바로 이놈이다. 기본적으로 무한루프를 돌며 queue의 상태를 체크한다.
    • 설명이 좀 길다. 자세히 알고 싶으면 자세히 읽자!
  • queue가 비어있고 newTasksMayBeScheduled가 True면 가지고있던 queue에 대한 락을 해제하고, 다른 쓰레드가 깨워주길 기다린다.
    - Timer에 새로운 Task가 등록되거나, Timer가 cancel되면 깨어난다.
    - 깨어났는데 queue가 비어있다 (cancel된거임) => 루프를 탈출하며 mainLoop를 종료한다
    - queue가 비어있지 않다(task가 추가된거임) => queue의 head task에 대해 로직을 수행한다.
  • queue의 head task를 꺼내서 synchronized를 이용해 task의 권한을 현재 thread가 가져간다.
    - 이미 취소된 task면, 삭제하고 loop의 처음으로 돌아간다
  • currentTime 은 진짜 지금 시간이고, executionTime은 task의 nextExecutionTime이다.
    - 실행시간이 현재시간 전이라면 실행해야 하는(혹은, 실행했어야하는) task이므로 taskFired를 True로 만든다(대충 로켓발사 전 점화하는 느낌인듯).
    - taskFired가 True라면 task.period가 0인지 아닌지 판별한다. 0이라면 queue에서 head task를 지워버린다 (다시 리스케쥴링 될 일이 없다)
    a. 0이 아니라면 queue의 head task를 리스케쥴링한다.
    b. period가 음수라면 현재시간에서 period를 더하고, 양수라면 실행시간에서 period를 더한다
    • (Timer 클래스에서 schedule 과 scheduleAtFixedRate 의 차이다)
  • task.lock을 풀고나서 taskFired가 False라면 실행까지 남은 시간만큼 기다린다
  • queue에 대한 lock을 풀고나서 taskFiled가 True라면, task.run() 을 수행한다 ( run()은 사용자 정의 함수임을 잊지말자 )
private void mainLoop() {
    while (true) {
        try {
            TimerTask task;
            boolean taskFired;
            synchronized(queue) {
                // Wait for queue to become non-empty
                while (queue.isEmpty() && newTasksMayBeScheduled)
                    queue.wait();
                if (queue.isEmpty())
                    break; // Queue is empty and will forever remain; die

                // Queue nonempty; look at first evt and do the right thing
                long currentTime, executionTime;
                task = queue.getMin();
                synchronized(task.lock) {
                    if (task.state == TimerTask.CANCELLED) {
                        queue.removeMin();
                        continue;  // No action required, poll queue again
                    }
                    currentTime = System.currentTimeMillis();
                    executionTime = task.nextExecutionTime;
                    if (taskFired = (executionTime<=currentTime)) {
                        if (task.period == 0) { // Non-repeating, remove
                            queue.removeMin();
                            task.state = TimerTask.EXECUTED;
                        } else { // Repeating task, reschedule
                            queue.rescheduleMin(
                                    task.period<0 ? currentTime   - task.period
                                            : executionTime + task.period);
                        }
                    }
                }
                if (!taskFired) // Task hasn't yet fired; wait
                    queue.wait(executionTime - currentTime);
            }
            if (taskFired)  // Task fired; run it, holding no locks
                task.run();
        } catch(InterruptedException e) {
        }
    }
}

class Timer

TimerTask를 queue에 추가해서 TimerThread에 공유하고, 이 TimerThread를 관리하는 역할을 한다. 크게 보면 'Timer가 task를 생성 -> TimerThread가 task소비'라는 사이클을 만든다. 또한, 한 Timer는 Single-Thread로 실행된다 (앞의 작업이 끝날 때까지 뒷 작업을 시작하지 않는다)

실제로 사용할 메서드를 중심으로 알아보자

생성자

생성자가 다양한데 이 timer를 수행할 Thread를 Daemon으로 실행할지, 이름은 뭘로 할 지 지정하는 것 뿐이다. JVM은 User 쓰레드가 하나도 없으면 종료되기 때문에 Daemon으로 실행되는 쓰레드는 애플리케이션의 라이프사이클보다 길게 실행될 수 없다 (이게 어떻게 쓸모있을지는 잘 모르겠다). 이름도 Thread의 이름인데 log를 찍거나 할 때 유용해보인다.

//noArgs
public Timer() {
    this("Timer-" + serialNumber());
}
//이름
public Timer(String name) {
	thread.setName(name);
    thread.start();
}
//데몬여부
public Timer(boolean isDaemon) {
	this("Timer-" + serialNumber(), isDaemon);
}
//이름,데몬여부
public Timer(String name, boolean isDaemon) {
	thread.setName(name);
    thread.setDaemon(isDaemon);
    thread.start();
}

Task를 추가하는 Schedule 메서드들

이것도 개수는 많은데 어렵지 않아요~ 어렵지 않아요~~

모든 schedule 메서드는 다음과 같은 메서드를 최종적으로 사용하게 되어있다.
sched(TimerTask task, long time, long period)
'할 일(task)을 정해진 시간(time)에 실행할거다 + 이거 반복되는지 + 얼마의 기간동안(period) 반복되는지' 결정하는 메서드일 뿐이다. time은 최초로 실행되는 시간이다.
이 때, period가 0이면 반복이 없는거고 0이 아니면 반복이 있는거다. 그럼 음수면? 음수여도 반복이 있긴하다.

schedule VS scheduleAtFixedTime

각각의 sched 메서드를 보자

//schedule
sched(task, , -period);
//shceduleAtFixedTime
sched(task, , period);

schedule은 -period가 들어가고, scheduleAtFixedTime은 period가 그대로 들어간다. 이건 task를 반복되게끔 할 때, task.run()이 실행된 시간을 기준으로 리스케쥴링 하느냐, 실행되기로 예정된 시간을 기준으로 리스케쥴링 하느냐의 차이다.

이해가 잘 안된다!

이를 실제로 적용하는 TimerThread의 mainLoop 메서드의 코드를 보자.

queue.rescheduleMin(
	task.period<0 ? currentTime-task.period : executionTime+task.period
);

음수면 현재 시간(currentTime)에 period를 더해서 다음 실행 시간을 정하고, 양수면 실행 예정이었던 시간(executionTime)에 period를 더해서 다음 실행 시간을 정한다.
보통 스케쥴러를 사용하는 사람이라면 후자의 방식을 원하고 사용할 것인데, 전자의 방식이 언제 쓰이는지 당최 알 수가 없다...

그러니 웬만하면 scheduleAtFixedTime을 사용하고, 제가 아직 모르는 특정한 상황이 닥쳐서 schedule를 쓰게 되면 알려주세요...

마지막으로 메서드들 선언된 원형을 보고, 글을 마치도록 하자

// schedule
public void schedule(TimerTask task, long delay)
public void schedule(TimerTask task, Date time)
public void schedule(TimerTask task, long delay, long period)
public void schedule(TimerTask task, Date firstTime, long period)
// scheduleAtFixedRate
public void scheduleAtFixedRate(TimerTask task, long delay, long period)
public void scheduleAtFixedRate(TimerTask task, Date firstTime, long period)
  • delay가 있는 놈들은 (아마 앱이 실행되는 시간이라고 기대할) 스케쥴링 메서드가 실제로 실행되는 시간의 delay밀리초 후를 최초실행시간으로 한다는 의미다.
    sched(task, System.currentTimeMillis()+delay, period)
  • period를 넣어주지 않으면 default는 0이다. 반복이 없다는 말.
    sched(task, time, 0)

마치며

라이브러리를 뜯어보면 항상 많은걸 배워간다... 귀찮지만 많이들 뜯어보도록 하자.

profile
Today, I Shoveled AGAIN....

0개의 댓글