怎樣實現一個非阻塞的超時重試任務佇列

李可樂Cola發表於2018-04-13

起因

最近接手一個專案,要把其中的阻塞任務佇列,重構成非阻塞。在客戶端很少有機會直接處理任務佇列。專案完成需要總結經驗

阻塞的發生

我這裡先說明我遇到的阻塞問題,我這裡的阻塞不是多執行緒訪問的阻塞,概念上是任務執行的阻塞。具體是:

  • 任務開始客戶端準備資料,通過socket向伺服器傳送資料。
  • 阻塞等待伺服器socket的ack迴應。
  • 得到伺服器的socket迴應完成任務,取出佇列的後續任務繼續執行。

這樣的阻塞佇列優點就是:

  • 程式碼看起來非常簡潔且聚集,開始程式碼對應結束程式碼。
  • 邏輯上可以保證任務的完成,因為如果沒有完成,就會阻塞直到任務的完成。

但是致命的缺點也是阻塞等待,因為直接的socket通訊使用是不保證送達,如果伺服器一直沒有迴應,客戶端的任務佇列就一直阻塞在隊頭。除非通過其他方式強制終止任務佇列。

非阻塞的佇列

確定了問題的發生的原因,就可以一步步的解決問題。 首先阻塞就是因為在等待迴應,只有迴應後才能完成任務。任務以本地客戶端開啟,以伺服器迴應結束,期間阻塞。構成一個任務的概念。

拆任務

其實客戶端不必執著等待迴應,只要把任務拆分成

  1. 傳送任務
  2. 迴應任務

而期間不再阻塞,只要迴應任務能夠找到對應的傳送任務,客戶端就可以確定該任務的完成。

HandlerThread實現任務處理佇列

這裡socket的通訊肯定是發生在子執行緒的,而子執行緒想要維護任務處理佇列,最好的方式就是直接使用HandlerThread,它封裝在子執行緒中Handler的配置,而Handler本身就是的任務處理佇列。

package com.example.licola.myandroiddemo.java;

import android.os.Handler;
import android.os.HandlerThread;
import java.util.HashSet;

/**
 * Created by LiCola on 2018/4/10.
 * 簡化版非阻塞任務佇列
 */
public class Dispatcher {

  private static final String THREAD_NAME="dispatcher-worker";

  private Handler mHandler;
  private HandlerThread handlerThread;

  private HashSet<String> tasks = new HashSet<>();//任務集合

  public void run(){
    handlerThread = new HandlerThread(THREAD_NAME);
    handlerThread.start();
    mHandler = new Handler(handlerThread.getLooper());
  }

  public void postSendTask(String id,String data){
    mHandler.post(new Runnable() {
      @Override
      public void run() {
         //傳送任務的操作 如準備資料等

        tasks.add(id);
      }
    });
  }

  public void postAckTask(final String id){
    mHandler.post(new Runnable() {
      @Override
      public void run() {
        //迴應任務的操作 如解析迴應等

        tasks.remove(id);
      }
    });
  }
}

複製程式碼

上面的程式碼已經非常簡化,不涉及具體的任務處理,只有關鍵程式碼。實現了前文的拆任務的理念。

但是拆任務也帶來了一個很嚴重的問題,任務怎樣保證完成。因為不阻塞,傳送任務只管傳送,傳送完成迎來的可能是下一個傳送任務,而對應的迴應任務卻一直沒有到來。概念上這個任務始終沒有完成。程式碼上就是tasks堆積越來越多等待迴應的任務。

超時機制

為了應對可能堆積的tasks任務集合,就需要引入超時機制,就是給一個任務設定最長等待時間,如果超過這個時間還沒有完成就重試。有了前面的程式碼基礎加入超時檢測處理是很容易的。

  • 首先想到的就是在執行過程中加入定期迴圈執行的檢測程式碼。
  • 給傳送任務加入時間變數,用於檢測超時。
  • 任務集合儲存任務id和對應的傳送資料,用於重試。

超時重試機制的任務佇列

package com.example.licola.myandroiddemo.java;

import android.os.Handler;
import android.os.HandlerThread;
import android.util.Pair;
import com.example.licola.myandroiddemo.utils.Logger;
import java.util.HashMap;
import java.util.Map.Entry;

/**
 * Created by LiCola on 2018/4/10.
 * 支援超時重試機制版非阻塞任務佇列
 */
public class Dispatcher {

  private static final String THREAD_NAME = "dispatcher-worker";

  //超時檢測時間
  private static final long CHECK_ACK_TIME_OUT = 10 * 1000;
  //任務限定等待時間,即任務超時時間
  private static final long ACK_TIME_OUT = 4 * 1000;

  private Handler mHandler;
  private HandlerThread handlerThread;

  private HashMap<String, Pair<Long, String>> tasks=new HashMap<>();//任務集合

  public void run() {
    handlerThread = new HandlerThread(THREAD_NAME);
    handlerThread.start();
    mHandler = new Handler(handlerThread.getLooper());

    //開啟迴圈檢測
    mHandler.postDelayed(checkTimeOutTask(), CHECK_ACK_TIME_OUT);
  }

  public void postSendTask(final String id, final String data) {
    mHandler.post(new Runnable() {
      @Override
      public void run() {
        //傳送任務的操作 如準備資料等

        Logger.d("開始傳送任務");
        tasks.put(id, new Pair<>(System.currentTimeMillis(), data));
      }
    });
  }

  public void postAckTask(final String id) {
    mHandler.post(new Runnable() {
      @Override
      public void run() {
        //迴應任務的操作 如解析迴應等

        Logger.d("開始迴應任務");
        tasks.remove(id);
      }
    });
  }

  public Runnable checkTimeOutTask() {
    return new Runnable() {
      @Override
      public void run() {
        int count = 0;
        long curTime = System.currentTimeMillis();

        if (!tasks.isEmpty()) {
          for (Entry<String, Pair<Long, String>> entry : tasks.entrySet()) {
            String id = entry.getKey();
            Pair<Long, String> pair = entry.getValue();
            Long time = pair.first;
            String data = pair.second;
            if (curTime - time >= ACK_TIME_OUT) {
              postSendTask(id, data);
              count++;
            }
          }
        }

        if (count > 0) {
          Logger.d(String.format("檢測到超時任務%d", count));
        }

        //迴圈檢測
        mHandler.postDelayed(checkTimeOutTask(), CHECK_ACK_TIME_OUT);

      }
    };
  }
}

複製程式碼

上面的程式碼已經實現超時重試機制。仔細想想這段程式碼的執行情況。還是問題和有優化空間的。

檢測時機

仔細想想定期檢測的時間和限定的超時時間,兩者的關係。

  //超時檢測時間
  private static final long CHECK_ACK_TIME_OUT = 10 * 1000;
  //任務限定等待時間,即任務超時時間
  private static final long ACK_TIME_OUT = 4 * 1000;
複製程式碼

為了檢測儘可能的高效,且不影響整個任務佇列處理效能。讓檢測時間間隔比較大,且大於任務超時時間。 實際的執行情況很可能如下圖所示:

執行時間圖

我們以時間點check為基準分析:

  1. check-1時間點之前:開始任務task-1、task-2。
  2. check-1時間點:檢測開始,發現任務集合中有2個等待任務,但是它們都沒有超時,沒有任何處理。
  3. check-2時間點之前:task-1正常完成,任務集合中刪除它。
  4. check-2時間定:檢測開始,發現任務集合中有1個等待任務,且已經超時。task-2任務重試,task-2的計時重置到當前時間點。

這是一種假設執行情況,但是還是暴露出了兩個問題:

  • 不夠高效:雖然檢測時間間隔足夠大,一個間隔內能夠完成整個傳送迴應的正常任務,但是檢測並沒有很高效,還是在check-1時間點中觀察到了兩個不應該被觀察到的任務。其中task-1:它剛開始且可以正常完成的。
  • 不精確的超時:在check-2之前任務task-2它已經超時了,但是在超時一段時間後才發現。

這兩個問題其實不嚴重,根據實際情況選擇。 如果任務的超時小概率發生,且不要求精確的超時檢測。超時重試機制的任務處理佇列-非精確控制時間,還是足夠滿足開發需求的。

精確的控制超時時間

怎樣做到精確的控制超時時間,且讓檢測更高效。在Android開發中有沒有遇到精確控制任務時間的情況,而其他工程師們怎樣實現高效處理的。雖然我們日常開發中沒有感知,但是這個情況其實非常非常的普遍存在。把這個問題換個角度:

怎樣精確的控制任務時間?

再想想你開發的各種系統處理:

  • 長按點選事件的監聽
  • ANR(Application Not Response)的檢測和發生

這兩個系統處理本質上就是精確控制任務時間的處理。

原始碼的智慧

確定了上面這兩個原始碼目標,我們來看看系統是怎樣實現的。

長按點選事件

一個點選的事件序列由ACTION_DOWN開始,後續的事件action不確定。

開始:

任務的開始就是在View.onTouchEvent(MotionEvent event)的action事件處理cast:MotionEvent.ACTION_DOWN中的方法checkForLongClick(0, x, y) 核心程式碼就一行:

private void checkForLongClick(int delayOffset, float x, float y) {
        if ((mViewFlags & LONG_CLICKABLE) == LONG_CLICKABLE || (mViewFlags & TOOLTIP) == TOOLTIP) {
		      
		      //傳送延遲任務
            postDelayed(mPendingCheckForLongPress,
                    ViewConfiguration.getLongPressTimeout() - delayOffset);
        }
    }
複製程式碼

結束:

點選任務處理已經開始,而典型點選任務結束就是ACTION_UP事件,同樣在程式碼中cast:MotionEvent.ACTION_UP中的方法removeLongPressCallback()

    private void removeLongPressCallback() {
        if (mPendingCheckForLongPress != null) {
            removeCallbacks(mPendingCheckForLongPress);
        }
    }
複製程式碼

超時:

因為在開始就已經確定固定時間點後執行超時處理,在這個時間點之前沒有其他action操作來及時remove掉超時處理。從而超時處理得到執行,具體就是執行長按事件。

private final class CheckForLongPress implements Runnable {
    
        @Override
        public void run() {
            if ((mOriginalPressedState == isPressed()) && (mParent != null)
                    && mOriginalWindowAttachCount == mWindowAttachCount) {
                if (performLongClick(mX, mY)) {
                    mHasPerformedLongPress = true;
                }
            }
        }
    }
複製程式碼

ANR的檢查與發生

總所周知ANR的發生有很多種,這裡就挑Service的建立超時來舉例說明

Service Timeout:比如前臺服務在20s內未執行完成。

這裡參考理解Android ANR的觸發原理的分析流程。作者很形象的總結整個ANR檢測的理念:

埋炸彈-拆炸彈

因為ANR的處理比較複雜,我們省略自動寫日誌和程式通訊等流程。

開始:埋炸彈

ActiveServices原始碼部分

private final void realStartServiceLocked(ServiceRecord r, ProcessRecord app, boolean execInFg) throws RemoteException {
    ...
    //傳送delay訊息(SERVICE_TIMEOUT_MSG)
    bumpServiceExecutingLocked(r, execInFg, "create");
    try {
        ...
        //最終執行服務的onCreate()方法
        app.thread.scheduleCreateService(r, r.serviceInfo,
                mAm.compatibilityInfoForPackageLocked(r.serviceInfo.applicationInfo),
                app.repProcState);
    } catch (DeadObjectException e) {
        mAm.appDiedLocked(app);
        throw e;
    } finally {
        ...
    }
}
複製程式碼
private final void bumpServiceExecutingLocked(ServiceRecord r, boolean fg, String why) {
    ... 
    scheduleServiceTimeoutLocked(r.app);
}

void scheduleServiceTimeoutLocked(ProcessRecord proc) {
    if (proc.executingServices.size() == 0 || proc.thread == null) {
        return;
    }
    long now = SystemClock.uptimeMillis();
    Message msg = mAm.mHandler.obtainMessage(
            ActivityManagerService.SERVICE_TIMEOUT_MSG);
    msg.obj = proc;
    
    //當超時後仍沒有remove該SERVICE_TIMEOUT_MSG訊息,則執行service Timeout流程
    mAm.mHandler.sendMessageAtTime(msg,
        proc.execServicesFg ? (now+SERVICE_TIMEOUT) : (now+ SERVICE_BACKGROUND_TIMEOUT));
}
複製程式碼

結束:拆炸彈

在Service的啟動前,已經埋下了炸彈,那就在啟動完成後拆掉炸彈。 ActiveServices原始碼部分

private void serviceDoneExecutingLocked(ServiceRecord r, boolean inDestroying, boolean finishing) {
    ...
    if (r.executeNesting <= 0) {
        if (r.app != null) {
            r.app.execServicesFg = false;
            r.app.executingServices.remove(r);
            if (r.app.executingServices.size() == 0) {
                //當前服務所在程式中沒有正在執行的service
                mAm.mHandler.removeMessages(ActivityManagerService.SERVICE_TIMEOUT_MSG, r.app);
        ...
    }
    ...
}
複製程式碼

超時:炸彈爆炸

如果Service沒有限定時間內完成啟動,拆掉炸彈,炸彈就會爆炸,就是超時任務執行。 就是ActiveService的serviceTimeout方法執行,寫下日誌發出ANR彈框。

總結

我們從精確控制任務超時時間這角度,分析了長按事件和ANR的發生原理。最終發現他們都是基於同樣的設計方式:埋炸彈-拆炸彈 在任務開始時設定定時任務,及時完成remove掉定時任務,否則任務超時就會執行超時處理,而定時任務精確的時間執行就保證了超時任務精確控制。這個方式完全不同於我前文實現的間隔檢測-非精確時間控制。

超時重試機制的任務佇列-精確控制時間

有對原始碼的理解和總結,稍微修改程式碼就可以得到如下

package com.example.licola.myandroiddemo.java;

import android.os.Handler;
import android.os.HandlerThread;
import com.example.licola.myandroiddemo.utils.Logger;
import java.util.HashMap;

/**
 * Created by LiCola on 2018/4/10.
 * 支援超時重試機制版非阻塞任務佇列
 */
public class DispatcherTime {

  private static final String THREAD_NAME = "dispatcher-worker";

  //任務限定等待時間,即任務超時時間
  private static final long ACK_TIME_OUT = 2 * 1000;

  private Handler mHandler;
  private HandlerThread handlerThread;

  private HashMap<String, Runnable> timeoutTask = new HashMap<>();//超時集合

  public void run() {
    handlerThread = new HandlerThread(THREAD_NAME);
    handlerThread.start();
    mHandler = new Handler(handlerThread.getLooper());
  }

  public void postSendTask(final String id, final String data) {
    mHandler.post(new Runnable() {
      @Override
      public void run() {
        //傳送任務的操作 如準備資料等

        Logger.d("開始傳送任務",data);
        Runnable checkTimeOutTask = checkTimeOutTask(id, data);
        timeoutTask.put(id, checkTimeOutTask);
        mHandler.postDelayed(checkTimeOutTask,ACK_TIME_OUT);
      }
    });
  }

  public void postAckTask(final String id) {
    mHandler.post(new Runnable() {
      @Override
      public void run() {
        //迴應任務的操作 如解析迴應等

        Logger.d("開始迴應任務",id);
        Runnable runnable = timeoutTask.remove(id);
        mHandler.removeCallbacks(runnable);
      }
    });
  }

  public Runnable checkTimeOutTask(final String id, final String data) {
    return new Runnable() {
      @Override
      public void run() {

        Logger.d("超時任務執行 ",id,data);
        postSendTask(id, data);
      }
    };
  }
}
複製程式碼

上面實現了每次任務傳送都會埋下一個延遲任務,如果沒有及時得到迴應就會重試。 這個實現的缺點如果要說的就是:

  • 每個傳送任務都會建立一個對應的延遲任務,如果傳送任務數量較大,且只有小概率任務超時,就會產生大量建立的任務而又短期存在且沒有機會執行的任務。

當然如果要優化就是使用Handler.handleMessage(Message msg)方法處理超時任務,而不是每次postDelayed都建立Runnable物件。這裡只留下思路就不用程式碼了。

總結

  • 其實原始碼的理解不是很難,只要找到切入點,從你關心的點出發,就能夠理解原始碼並應用它。我們從超時任務的處理為切入點就很容易理解長按事件的原理和ANR的發生機制。
  • 當我們瞭解到一個新的解決方案,不要急於去應用它,要分析新方案的利弊,和我們實際專案的匹配程度,才能很好的應用它和改造它。

參考

相關文章