TimingWheel
在Kafka中应用了大量的延迟操作,但在Kafka中并没用使用JDK自带的Timer或是DelayQueue用于延迟操作,而是使用自己开发的DelayedOperationPurgatory组件用于管理延迟操作,Kafka这类分布式框架有大量延迟操作并且对性能要求及其高,而java.util.Timer与java.util.concurrent.DelayQueue的插入和删除时间复杂度都为对数阶O(log n)并不能满足Kafka性能要求,所以Kafka实现了基于时间轮的定时任务组件,该时间轮定时任务实现的插入与删除(开始定时器与暂停定时器)的时间复杂度都为常数阶O(1) ;
时间轮数据结构
时间轮名词解释:
时间格: 环形结构中用于存放延迟任务的区块;
指针(CurrentTime): 指向当前操作的时间格,代表当前时间
格数(ticksPerWheel): 为时间轮中时间格的个数
间隔(tickDuration): 每个时间格之间的间隔
总间隔(interval): 当前时间轮总间隔,也就是等于ticksPerWheel*tickDuration
TimingWheel并非简单的环形时间轮,而是多层级时间轮,每个时间轮由多个时间格组成,每个时间格为一个时间间隔,底层的时间格跨度较小,然后随着延迟任务延迟时间的长短逐层变大;如上图,底下的时间轮每个时间格为1ms,整个时间轮为10ms,而上面一层的时间轮中时间格为10ms,整个时间轮为100ms;
时间轮添加上级时间轮的规则为: 当前currentTime为上级时间轮的startMs,当前interval为上级时间轮的tickDuration,每层ticksPerWheel相同;简单点说就是上层时间轮跨度为当前的M倍,时间格为当前的N倍;
kafka中时间轮代码实现
SystemTimer: 时间轮的承载,或者说一个触发器,调用其内部的advanceClock(long timeout) 方法能够触发时间轮转动,并执行任务。
TimingWheel: 时间轮的实现类,内部使用数组buckets承载时间轮,在存放数组时根据取余方式获取存放位置。tickMs为上述的tickDuration,wheelSize为时间轮格数,interval为当前时间轮总间隔。
TimeTaskList:buckets数组对象用于存放延迟任务,一个TimerTaskList就代表一个时间格,一个时间格中能保存的任务到期时间只可在[t~t+10ms]区间(t为时间格到期时间,10ms时间格间格),每个时间格有个过期时间,时间格过期后时间格中的任务将向前移动存入前面时间格,即bucket中;
TimerTaskEntry:TimeTaskList的节点表征
TimerTask:执行的任务
添加任务
SystemTimer持有第一层时间轮,调用add(TimeTask timetask)添加任务
public void add(TimerTask timerTask) {
readLock.lock();
try {
addTimerTaskEntry(new TimerTaskEntry(timerTask, timerTask.delayMs + Time.SYSTEM.hiResClockMs()));
} finally {
readLock.unlock();
}
}
private void addTimerTaskEntry(TimerTaskEntry timerTaskEntry) {
if (!timingWheel.add(timerTaskEntry)) {
// 添加失败有两种情况: 1. 任务超时,该执行了,2任务被取消
if (!timerTaskEntry.cancelled()) {
// 任务超时,交给任务执行线程池处理
taskExecutor.submit(timerTaskEntry.timerTask);
}
}
}
在调用TimingWheel添加任务时,有四种情况:
- 任务被取消 -> 不添加 -> 交给线程池执行
- 任务超期 -> 不添加 -> 交给线程池执行
- 任务符合当前时间轮时间范围 -> 添加到对应的bucket -> bucket首次添加或者变更需要添加到延迟队列
- 任务超出当前时间轮时间范围 -> 新建下层时间轮 -> 使用下层时间轮添加
值得注意的一点,kafka的时间轮使用了DelayQueue,DelayQueue本省就可以作用一个延迟任务、定时任务的实现,为啥kafka会使用呢?这里先卖个关子。
public boolean add(TimerTaskEntry timerTaskEntry) {
long expiration = timerTaskEntry.expirationMs;
if (timerTaskEntry.cancelled()) {
// Cancelled
return false;
} else if (expiration < currentTimeMs + tickMs) {
// Already expired
return false;
} else if (expiration < currentTimeMs + interval) {
// Put in its own bucket
long virtualId = expiration / tickMs;
int bucketId = (int) (virtualId % (long) wheelSize);
TimerTaskList bucket = buckets[bucketId];
bucket.add(timerTaskEntry);
// Set the bucket expiration time
if (bucket.setExpiration(virtualId * tickMs)) {
// The bucket needs to be enqueued because it was an expired bucket
// We only need to enqueue the bucket when its expiration time has changed, i.e. the wheel has advanced
// and the previous buckets gets reused; further calls to set the expiration within the same wheel cycle
// will pass in the same value and hence return false, thus the bucket with the same expiration will not
// be enqueued multiple times.
queue.offer(bucket);
}
return true;
} else {
// Out of the interval. Put it into the parent timer
if (overflowWheel == null) addOverflowWheel();
return overflowWheel.add(timerTaskEntry);
}
}
推进(转动)时间轮
调用SystemTimer的advanceClock(long timeoutMs)推动时间轮进行转动,但是注意这里可以指定延迟队列获取的超时时间,因此调用该方法也不一定能够推动时间轮,可以通过返回值判断是否推进了时间(执行任务),总体的逻辑在代码片段中注释了。
public boolean advanceClock(long timeoutMs) throws InterruptedException {
// 获取最近一个超时的bucket
TimerTaskList bucket = delayQueue.poll(timeoutMs, TimeUnit.MILLISECONDS);
if (bucket != null) {
writeLock.lock(); // advanceClock方法是线程安全的
try {
while (bucket != null) {
// 推进时间轮
timingWheel.advanceClock(bucket.getExpiration());
// 2种情况:1.投递到上层时间轮 2. 执行任务
bucket.flush(this::addTimerTaskEntry);
// 执行下一个超时时间格
bucket = delayQueue.poll();
}
} finally {
writeLock.unlock();
}
return true;
} else {
return false;
}
}
// 添加或取消或执行任务
private void addTimerTaskEntry(TimerTaskEntry timerTaskEntry) {
if (!timingWheel.add(timerTaskEntry)) {
// Already expired or cancelled
if (!timerTaskEntry.cancelled()) {
taskExecutor.submit(timerTaskEntry.timerTask);
}
}
}
public void advanceClock(long timeMs) {
if (timeMs >= currentTimeMs + tickMs) { // 需要推进时间轮
// 更新时间轮起始时钟到当前执行到的时间
currentTimeMs = timeMs - (timeMs % tickMs);
// Try to advance the clock of the overflow wheel if present
// 更新下层时间轮起始时钟到当前执行到的时间
if (overflowWheel != null) overflowWheel.advanceClock(currentTimeMs);
}
}
Demo
一个简单的demo如下,新建了一个时间格间隔为50ms,大小为20的时间轮。使用一个定时线程不断触发时间轮推进。启动了5个线程不断地塞入超时时间为0 - 10000ms的额任务。
public class TimingWheelTest {
public static void main(String[] args) throws InterruptedException {
try (SystemTimer timer = new SystemTimer("test", 50, 20, Time.SYSTEM.hiResClockMs())) {
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
scheduledExecutorService.scheduleAtFixedRate(() -> {
try {
timer.advanceClock(300);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}, 0, 20, TimeUnit.MILLISECONDS);
Thread thread = null;
for (int i = 0; i < 5; i++) {
thread = new Thread(() -> {
Random random = new Random();
while (true) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
int timeout = random.nextInt(10000);
long current = Time.SYSTEM.hiResClockMs();
timer.add(new TimerTask(timeout) {
@Override
public void run() {
System.out.println(String.format("[%s] task executed, expect time: %d, actual time: %d, timeout: %d 误差: %d",
Thread.currentThread().getName(),
current + timeout, Time.SYSTEM.hiResClockMs(),
timeout,
Math.abs(current + timeout - Time.SYSTEM.hiResClockMs())
)
);
}
});
}
});
thread.start();
}
thread.join();
}
}
}
DelayQueue
Kafka的实现比较有意思的一点是时间轮中使用了DelayQueue,回顾一下前沿,kafka本身就是为了避免DelayQ的O(n)插入与移除,才实现了实现轮,为什么这里会采用延迟队列呢? 试想一下,如果不适用DelayQueue,我们需要遍历所有bucket判断是否超期,在遍历的过程中会有大量的空判断,带来不必要的性能损耗。使用DelayQueue后,很好地解决了上面的现象,对于队列头部的获取时间复杂度也为O(1)。 此外,此处DelayQueue的大小也不与任务数量相关,而是与bucket相关,假设时间轮格数大小为20,存在10层,那么DelayQueue理论上最大值也就为10 * 20 = 200,在这个数量级下操作近似于O(1),在合理的配置下,10层早就足以涵盖业务需求的延迟时间。 Kafka中的定时器真可谓“知人善用”,用TimingWheel做最擅长的任务添加和删除操作,而用DelayQueue做最擅长的时间推进工作,两者相辅相成。