欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Netty中HashWheelTimer的使用

程序员文章站 2022-04-22 17:57:47
...

最近在写项目的时候, 需要用到延迟任务. 需求如下: 用户通过微信绑定一个设备的开关机时间, 可以选择一周内哪几天需要开启这个定时任务, 就像我们得手机闹钟一样. 因此用到了netty的HashedWheelTimer时间轮计时器来处理这个问题.
什么是时间轮? 简单来说, 就像我们的时钟一样,上面有很多格子, 本质上一个wheel是一个哈希表,每个延时任务通过散列函数放入对应的位置. 每个格子中的延时任务是一个双向链表, 当"指针"指到哪个格子中的时候, 格子中的第一个任务便开始执行, 这样的设计方便取消和添加任务.
一个Timer的构造函数有几个重要的参数:

1.tickDuration: tick一次需要的时间, 默认100ms
2.tickPerWheel: 每个wheel需要tick多少次, 即每个wheel有多少个格子,默认5123.timeUntil: tickDuration的时间单位
4.ThreadFactory: 负责创建worker线程

除了构造函数, 还有一个比较重要的概念.
轮(round): 一轮的时长 tickDuration*tickPerWheel,也就是转一圈的时长. 其中Worker线程是HashedWheelTimer的核心,主要负责每过tickDuration时间就累加一次tick. 同时, 也负责执行到期的timeout任务并添加timeout任务到指定的wheel中. 当添加timeout任务的时候, 会根据设置的时间, 来计算需要等待的时间长度, 根据时间长度,进而计算出要经过多少次tick,然后根据tick的次数来计算进过多少轮,最终得出任务在wheel中的位置.

例如: 如果任务设置为在100s后执行, 按照默认的配置tickDuration=100ms, tickPerWheel=512.
任务需要经过的tick = (100 * 1000) / 100 = 1000次
任务需要经过的轮数 = 1000 / 512 = 1.....488
这表明这个定时任务会放到第一轮的第488的索引的位置.

大致了解了时间轮的原理, 看看我们的需求应该如何实现呢? 首先,用户通过微信绑定了设备的开关机时间,以及一周内哪几天需要执行开关机任务,按照面向对象的思想,我们需要建立两个类, 一个是用户开关机的数据UserTimer, 还有一个是用户的开关机任务UserTask.

/**
 * 用户定时任务数据
 */
@Data
public class UserTimer {
    /**
     * 设备id
     */
    private int eId;
    /**
     * 每周哪几天需要定时(数据:1100111 周日~周六) 
     * 1代表需要开启定时  0代表不需要开启定时
     */
    private String week;
    /**
     * 开机时间 例如:08:00
     */
    private String openTime;
    /**
     * 关机时间 例如:23:23
     */
    private String closeTime;
}
/**
 * 用户定时任务
 */
@Data
public class UserTask {

    /**
     * 开机任务
     */
    private TimerTask openTask;
    /**
     * 关机任务
     */
    private TimerTask closeTask;

    public TimerTask getOpenTask() {
        return openTask;
    }

    public TimerTask getCloseTask() {
        return closeTask;
    }

    public UserTask(TimerTask openTask, TimerTask closeTask) {
        this.openTask = openTask;
        this.closeTask = closeTask;
    }
}

用户的开关机定时任务和数据已经封装好, 开始写我们的接口Controller.
url=server/client/device/usertimer?Mac=9999&closetime=20:30&opentime=20:22&week=0111001
首先我们需要思考: 如果用户操作失误或者的确需要修改设备的开关机时间的话,上一次的开关机任务如何取消?因为此时任务已经添加到队列中了.
我的做法是: 将用户的开关机任务和开关机数据放到一个map中, key为设备的id, value是开关机数据或者开关机任务:

private static Map<Integer, UserTask> taskMap = new ConcurrentHashMap<>();
private static Map<Integer, UserTimer> timerMap = new ConcurrentHashMap<>();

这样每当用户修改的时候这个map可以随时更新 (这种做法其实并不好, 因为如果程序重启, 数据就会丢失, 最好用redis存储) , 但是这样还不够, 因为我们还是不能取消上次的任务, 通过分析源码, 我们可以发现, 每次启动一个延时任务的时候,都会返回一个绑定这个任务的句柄timeout, 通过这个句柄我们可以取消定时任务, 因此再创建一个map保存用户任务的句柄:

private static Map<Integer, UserTimeout> userTimeoutMap = new ConcurrentHashMap<>();

用户延迟任务的句柄UserTimeout:

/**
 * 用户延时任务句柄,用来取消延时任务
 */
public class UserTimeout {
    /**
     * 设备id
     */
    private int eId;
    /**
     * 开机任务句柄
     */
    private Timeout startTimeout;
    /**
     * 关机任务句柄
     */
    private Timeout closeTimeout;

    public int geteId() {
        return eId;
    }

    public Timeout getStartTimeout() {
        return startTimeout;
    }

    public Timeout getCloseTimeout() {
        return closeTimeout;
    }

    public UserTimeout(int eId, Timeout startTimeout, Timeout closeTimeout) {
        this.eId = eId;
        this.startTimeout = startTimeout;
        this.closeTimeout = closeTimeout;
    }
}

这些变量设置好之后, 我们开始实现主要功能:

public ResMsg deviceOnOffTimerController(@RequestParam("Mac") int Mac, 	@RequestParam("closetime")String closetime,@RequestParam("opentime")
String opentime, @RequestParam("week")String week) throws ParseException {

    opentime += ":00";
    closetime += ":00";
    UserTimer userTimer = new UserTimer(Mac, week, opentime, closetime);
    System.out.println("用户设置的定时任务:" + userTimer);
    timerMap.put(Mac, userTimer); //保存用户定时数据
    Water water = JedisUtil6379.getaDevice(Mac + "");
    Map<String, String> mapWater = WaterToMap.toMapWater(water);
    TimerTask openTask = openTask(Mac, mapWater); //开机任务
    TimerTask closeTask = closeTask(Mac, mapWater); //关机任务
    UserTask userTask = new UserTask(openTask, closeTask); //用户定时任务
    if (null != taskMap.get(Mac)) { //用户以前设置过定时任务,更新
        cancel(Mac); //取消上次的定时任务
        taskMap.put(Mac, userTask); //保存用户定时任务
        startUserTask(timerMap.get(Mac), userTask);//启动定时任务
    } else { //用户第一次设置定时
        taskMap.put(Mac, userTask);
        startUserTask(timerMap.get(Mac), userTask);
    }
    return new ResMsg("success");


/**
 * 取消用户定时任务
 * 用户绑定定时任务可能需要需改,此时需要取消上次的定时任务
 * 将最新的定时任务放入时间轮
 */
private void cancel(int eId) {
    UserTimeout userTimeout = userTimeoutMap.get(eId);
    //根据设备号,得到开关机任务的句柄
    Timeout startTimeout = userTimeout.getStartTimeout();
    Timeout closeTimeout = userTimeout.getCloseTimeout();
    if (startTimeout != null) { //如果今天开关机任务都要执行,全部取消
        startTimeout.cancel();
        closeTimeout.cancel();
    } else { //如果今天只执行关机任务,只取消关机任务
        closeTimeout.cancel();
    }

}

/**
 * 启动用户定时任务
 * @param userTimer 用户设置的定时数据
 * @param userTask 用户开关机定时任务
 */
private  void startUserTask(UserTimer userTimer, UserTask userTask) throws ParseException {
    String week = userTimer.getWeek();
    String startTime = userTimer.getOpenTime();
    String endTime = userTimer.getCloseTime();
    int[] weekArray = weekArray(week);

    for (int i : weekArray) {
        int weekOfDate = DateUtils.getWeekOfDate(new Date()); //获得当前是星期几
        long c = System.currentTimeMillis();
        String s = DateUtils.getShotDate(new Date()) + " " + startTime;
        long s1 = DateUtils.dateToStamp(s);
        String e = DateUtils.getShotDate(new Date()) + " " + endTime;
        long e1 = DateUtils.dateToStamp(e);
        if (weekOfDate == i) {
            if (s1 - c > 0 && e1 - c > 0) { //绑定时间在开关机之前,那么今天就要执行开关机
                Timeout startTimeout = timer.newTimeout(userTask.getOpenTask(), s1 - c, TimeUnit.MILLISECONDS);
                Timeout closeTimeout = timer.newTimeout(userTask.getCloseTask(), e1 - c, TimeUnit.MILLISECONDS);
                UserTimeout userTimeout = new UserTimeout(userTimer.getEId(), startTimeout, closeTimeout);
                userTimeoutMap.put(userTimer.getEId(), userTimeout); //保存用户定时任务的句柄
            } else if (s1 - c < 0 && e1 - c > 0) { //绑定时间在开关机中间,那没今天只执行关机
                Timeout closeTimeout = timer.newTimeout(userTask.getCloseTask(), e1 - c, TimeUnit.MILLISECONDS);
                UserTimeout userTimeout = new UserTimeout(userTimer.getEId(), null, closeTimeout);
                userTimeoutMap.put(userTimer.getEId(), userTimeout);
            }
            break;
        }

    }
}

这样我们就完成了,用户绑定设备的开关机操作.
HashWheelTimeer源码