슬랙봇을 만드는 개인 프로젝트를 하는 와중, 매일 실행되어야 하는 스케쥴러가 제대로 동작하지 않고 자꾸 시간이 밀리거나, 아예 실행되지 않는 등의 문제를 발견했다.
이를 해결하기 위해 스케쥴링에 사용한 java.util.Timer 와 java.util.TimerTask를 뜯어보았다.
멀티쓰레드 환경에서 공유되는 자원이 있을 때, 현재 자원에 이미 접근한 쓰레드 외의 다른 쓰레드들의 접근을 막는 메서드
처음보는 키워드라 java - synchronized 란? 사용법? 를 봤다.
Timer 에는 synchronized() 메서드가 자주 나온다.
TimerTask에 'Object lock' 이라는 빈 인스턴스를 생성하고, TimerTask 내부에 접근할 때 마다 synchronized(lock){}를 사용해 자원을 lock하는 식이다.
Timer를 이용해 스케쥴링을 할 때, 한 task의 단위가 되는 클래스이자 java의 Runnable 인터페이스를 구현한 '추상클래스' 이다. 추상클래스 이므로, 여기에 포함된 추상메서드 'run()'을 직접 구현해서 TimerTask의 익명 인스턴스를 만들어 사용한다 (클래스 상속 받아서 만들어도 됨)
주요한 멤버와 메서드를 살펴보자
public boolean cancel() {
synchronized(lock) {
boolean result = (state == SCHEDULED);
state = CANCELLED;
return result;
}
}
public long scheduledExecutionTime() {
synchronized(lock) {
return (period < 0 ? nextExecutionTime + period
: nextExecutionTime - period);
}
}
Timer 라는 메인 클래스와 TimerThread, TimerQueue라는 헬퍼 클래스로 이루어져 있다.
우선순위큐로 동작하는 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);
}
java.util.Thread를 상속하며, Timer에 등록된 task를 실행하는 쓰레드이다.
task가 시간이 될때까지 기다렸다가 시간이 되면 실행시키고, 반복 task 를 리스케쥴링 하고, 취소된 task를 제거하거나 반복되지 않은 task는 queue에서 제거하는 등의 갖가지 일을 한다.
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.
public void run() {
try {
mainLoop();
} finally {
// Someone killed this Thread, behave as if Timer cancelled
synchronized(queue) {
newTasksMayBeScheduled = false;
queue.clear(); // Eliminate obsolete references
}
}
}
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) {
}
}
}
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();
}
이것도 개수는 많은데 어렵지 않아요~ 어렵지 않아요~~
모든 schedule 메서드는 다음과 같은 메서드를 최종적으로 사용하게 되어있다.
sched(TimerTask task, long time, long period)
'할 일(task)을 정해진 시간(time)에 실행할거다 + 이거 반복되는지 + 얼마의 기간동안(period) 반복되는지' 결정하는 메서드일 뿐이다. time은 최초로 실행되는 시간이다.
이 때, period가 0이면 반복이 없는거고 0이 아니면 반복이 있는거다. 그럼 음수면? 음수여도 반복이 있긴하다.
각각의 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)
sched(task, System.currentTimeMillis()+delay, period)
sched(task, time, 0)
라이브러리를 뜯어보면 항상 많은걸 배워간다... 귀찮지만 많이들 뜯어보도록 하자.