【c#】分享一個簡易的基於時間輪排程的延遲任務實現

2022-12-30 21:00:56

        在很多.net開發體系中開發者在面對排程作業需求的時候一般會選擇三方開源成熟的作業排程框架來滿足業務需求,比如Hangfire、Quartz.NET這樣的框架。但是有些時候可能我們只是需要一個簡易的延遲任務,這個時候引入這些框架就費力不討好了。

        最簡單的粗暴的辦法當然是:

Task.Run(async () =>
{
    //延遲xx毫秒
    await Task.Delay(time);
    //業務執行
});

       當時作為一個開發者,有時候還是希望使用更優雅的、可複用的一體化方案,比如可以實現一個簡易的時間輪來完成基於記憶體的非核心重要業務的延遲排程。什麼是時間輪呢,其實就是一個環形陣列,每一個陣列有一個插槽代表對應時刻的任務,陣列的值是一個任務佇列,假設我們有一個基於60秒的延遲時間輪,也就是說我們的任務會在不超過60秒(超過的情況增加分鐘插槽,下面會講)的情況下執行,那麼如何實現?下面我們將定義一段程式碼來實現這個簡單的需求

  話不多說,擼程式碼,首先我們需要定義一個時間輪的Model類用於承載我們的延遲任務和任務處理器。簡單定義如下:

public class WheelTask<T>
{
    public T Data { get; set; }
    public Func<T, Task> Handle { get; set; }
}

  定義很簡單,就是一個入參T代表要執行的任務所需要的入參,然後就是任務的具體處理器Handle。接著我們來定義時間輪本輪的核心程式碼:

  可以看到時間輪其實核心就兩個東西,一個是毫秒計時器,一個是陣列插槽,這裡陣列插槽我們使用了字典來實現,key值分別對應0到59秒。每一個插槽的value對應一個任務佇列。當新增一個新任務的時候,輸入需要延遲的秒數,就會將任務插入到延遲多少秒對應的插槽內,當計時器啟動的時候,每一跳剛好1秒,那麼就會對插槽計數+1,然後去尋找當前插槽是否有任務,有的話就會呼叫ExecuteTask執行該插槽下的所有任務。

public class TimeWheel<T>
{
    int secondSlot = 0;
    DateTime wheelTime { get { return new DateTime(1, 1, 1, 0, 0, secondSlot); } }
    Dictionary<int, ConcurrentQueue<WheelTask<T>>> secondTaskQueue;
    public void Start()
    {
        new Timer(Callback, null, 0, 1000);
        secondTaskQueue = new Dictionary<int, ConcurrentQueue<WheelTask<T>>>();
        Enumerable.Range(0, 60).ToList().ForEach(x =>
        {
            secondTaskQueue.Add(x, new ConcurrentQueue<WheelTask<T>>());
        });
    }
    public async Task AddTaskAsync(int second, T data, Func<T, Task> handler)
    {
        var handTime = wheelTime.AddSeconds(second);
        if (handTime.Second != wheelTime.Second)
			secondTaskQueue[handTime.Second].Enqueue(new WheelTask<T>(data, handler));
		else
			await handler(data);
    }
    async void Callback(object o)
    {
        if (secondSlot != 59)
            secondSlot++;
        else
        {
            secondSlot = 0;
        }
        if (secondTaskQueue[secondSlot].Any())
            await ExecuteTask();
    }
    async Task ExecuteTask()
    {
        if (secondTaskQueue[secondSlot].Any())
            while (secondTaskQueue[secondSlot].Any())
                if (secondTaskQueue[secondSlot].TryDequeue(out WheelTask<T> task))
                    await task.Handle(task.Data);
    }
}

  接下來就是如果我需要大於60秒的情況如何處理呢。其實就是增加分鐘插槽陣列,舉個例子我有一個任務需要2分40秒後執行,那麼當我插入到時間輪的時候我先插入到分鐘插槽,當計時器每過去60秒,分鐘插槽值+1,當分鐘插槽對應有任務的時候就將這些任務從分鐘插槽裡彈出再入隊到秒插槽中,這樣一個任務會先進入插槽值=2(假設從0開始計算)的分鐘插槽,計時器執行120秒後分鍾值從0累加到2,2插槽的任務彈出到插槽值=40的秒插槽裡,當計時器再執行40秒,剛好就可以執行這個延遲2分40秒的任務。話不多說,上程式碼:

  首先我們將任務WheelTask增加一個Second屬性,用於當任務從分鐘插槽彈出來時需要知道自己入隊哪個秒插槽

public class WheelTask<T>
{
    ...
    public int Second { get; set; }
    ...
}

  接著我們再重新定義時間輪的邏輯增加分鐘插槽值以及插槽佇列的部分

public class TimeWheel<T>
{
    int minuteSlot, secondSlot = 0;
    DateTime wheelTime { get { return new DateTime(1, 1, 1, 0, minuteSlot, secondSlot); } }
    Dictionary<int, ConcurrentQueue<WheelTask<T>>>  minuteTaskQueue, secondTaskQueue;
    public void Start()
    {
        new Timer(Callback, null, 0, 1000);、
        minuteTaskQueue = new Dictionary<int, ConcurrentQueue<WheelTask<T>>>();
        secondTaskQueue = new Dictionary<int, ConcurrentQueue<WheelTask<T>>>();
        Enumerable.Range(0, 60).ToList().ForEach(x =>
        {
            minuteTaskQueue.Add(x, new ConcurrentQueue<WheelTask<T>>());
            secondTaskQueue.Add(x, new ConcurrentQueue<WheelTask<T>>());
        });
    }
    ...
}

  同樣的在新增任務的AddTaskAsync函數中我們需要增加分鐘,程式碼改為這樣,當大於1分鐘的任務會入隊到分鐘插槽中,小於1分鐘的會按原邏輯直接入隊到秒插槽中:

    public async Task AddTaskAsync(int minute, int second, T data, Func<T, Task> handler)
    {
        var handTime = wheelTime.AddMinutes(minute).AddSeconds(second);
            if (handTime.Minute != wheelTime.Minute)
                minuteTaskQueue[handTime.Minute].Enqueue(new WheelTask<T>(handTime.Second, data, handler));
            else
            {
                if (handTime.Second != wheelTime.Second)
                    secondTaskQueue[handTime.Second].Enqueue(new WheelTask<T>(data, handler));
                else
                    await handler(data);
            }
    }

  最後的部分就是計時器的callback以及任務執行的部分:

	async void Callback(object o)
    {
        bool minuteExecuteTask = false;
        if (secondSlot != 59)
            secondSlot++;
        else
        {
            secondSlot = 0;
            minuteExecuteTask = true;
            if (minuteSlot != 59)
                minuteSlot++;
            else
            {
                minuteSlot = 0;
            }
        }
        if (minuteExecuteTask || secondTaskQueue[secondSlot].Any())
            await ExecuteTask(minuteExecuteTask);
    }
    async Task ExecuteTask(bool minuteExecuteTask)
    {
        if (minuteExecuteTask)
            while (minuteTaskQueue[minuteSlot].Any())
                if (minuteTaskQueue[minuteSlot].TryDequeue(out WheelTask<T> task))
                    secondTaskQueue[task.Second].Enqueue(task);
        if (secondTaskQueue[secondSlot].Any())
            while (secondTaskQueue[secondSlot].Any())
                if (secondTaskQueue[secondSlot].TryDequeue(out WheelTask<T> task))
                    await task.Handle(task.Data);
    }

  基本上基於分鐘+秒的時間輪延遲任務核心功能就這些了,聰明的你一定知道如何擴充套件增加小時,天,月份甚至年份的時間輪了。雖然從程式碼邏輯上可以實現,但是大部分情況下我們使用時間輪僅僅是完成一些記憶體易失性的非核心的任務延遲排程,實現天,周,月年意義不是很大。所以基本上到小時就差不多了。再多就上作業系統來排程吧。