如何讓ThreadPoolExecutor更早地建立非核心執行緒

卷卷子發表於2020-04-28

最近在專案中遇到一個需要用執行緒池來處理任務的需求,於是我用ThreadPoolExecutor來實現,但是在實現過程中我發現提交大量任務時它的處理邏輯是這樣的(提交任務還有一個submit方法內部也呼叫了execute方法):

public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
        /*
         * Proceed in 3 steps:
         *
         * 1. If fewer than corePoolSize threads are running, try to
         * start a new thread with the given command as its first
         * task.  The call to addWorker atomically checks runState and
         * workerCount, and so prevents false alarms that would add
         * threads when it shouldn't, by returning false.
         *
         * 2. If a task can be successfully queued, then we still need
         * to double-check whether we should have added a thread
         * (because existing ones died since last checking) or that
         * the pool shut down since entry into this method. So we
         * recheck state and if necessary roll back the enqueuing if
         * stopped, or start a new thread if there are none.
         *
         * 3. If we cannot queue task, then we try to add a new
         * thread.  If it fails, we know we are shut down or saturated
         * and so reject the task.
         */
        int c = ctl.get();
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        else if (!addWorker(command, false))
            reject(command);
    }

註釋中已經寫的非常明白:

  1. 如果執行緒數量小於corePoolSize,直接建立新執行緒處理任務
  2. 如果執行緒數量等於corePoolSize,嘗試將任務放到等待佇列裡
  3. 如果等待佇列已滿,嘗試建立非核心執行緒處理任務(如果maximumPoolSIze > corePoolSize

但是在我的專案中一個執行緒啟動需要10s左右的時間(需要啟動一個瀏覽器物件),因此我希望實現一個更精細的邏輯提升資源的利用率:

  1. 執行緒池保持corePoolSize個執行緒確保有新任務到來時可以立即得到執行
  2. 當沒有空閒執行緒時,先把任務放到等待佇列中(因為開啟一個執行緒需要10s,所以如果在等待佇列比較小的時候,等待其他任務完成比等待新執行緒建立更快)
  3. 當等待佇列的大小大於設定的閾值threshold時,說明堆積的任務已經太多了,這個時候開始建立非核心執行緒直到執行緒數量已經等於maximumPoolSize
  4. 當執行緒數量已經等於maximumPoolSize,再將新來的任務放回到任務佇列中等待(直到佇列滿後開始拒絕任務)
  5. 長時間空閒後退出非核心執行緒回收瀏覽器佔用的記憶體資源

當我研究了常見的CachedThreadPoolFixedThreadPool以及嘗試自己配置ThreadPoolExecutor的建構函式後,發現無論如何都不能實現上面提到的邏輯,因為預設的實現只有在workQueue達到容量上限後才會開始建立非核心執行緒,因此需要通過繼承的方法實現一個新的類來完成需求。

怎麼實現在workQueue到達容量上限前就建立非核心執行緒?還要回顧下execute函式的程式碼

					//嘗試將任務插入等待佇列,如果返回false
					//說明佇列已經到達容量上限,進入else if邏輯
if (isRunning(c) && workQueue.offer(command)) {
    int recheck = ctl.get();
    if (! isRunning(recheck) && remove(command))
        reject(command);
    else if (workerCountOf(recheck) == 0)
        addWorker(null, false);
}
//嘗試建立非核心執行緒
else if (!addWorker(command, false))
    reject(command);

那麼只要改變workQueue.offer()的邏輯,線上程數量還小於maximumPoolSize的時候就返回false拒絕插入,讓執行緒池呼叫addWoker,等不能再建立更多執行緒時再允許新增到佇列即可。

可以通過子類重寫offer方法來實現新增邏輯的改變

@Override
public boolean offer(E e) {
    if (threadPoolExecutor == null) {
        throw new NullPointerException();
    }
    //當呼叫該方法時,已經確定了workerCountOf(c) > corePoolSize
    //當數量小於threshold,在佇列裡等待
    if (size() < threshold) {
        return super.offer(e);
	//當數量大於等於threshold,說明堆積的任務太多,返回false
	//讓執行緒池來建立新執行緒處理
    } else {
        //此處可能會因為多執行緒導致錯誤的拒絕
        if (threadPoolExecutor.getPoolSize() < threadPoolExecutor.getMaximumPoolSize()) {
            return false;
        //執行緒池中的執行緒數量已經到達上限,只能新增到任務佇列中
        } else {
            return super.offer(e);
        }
    }
}

這樣就實現了基本實現了我需要的功能,但是在寫程式碼的過程中我找到了一個可能出錯的地方:ThreadPoolExecutor執行緒安全的,那麼重寫的offer方法也可能遇到多執行緒呼叫的情況

//設想當poolSize = maximumPoolSize-1時,兩個任務到達此處同時返回false
if (threadPoolExecutor.getPoolSize() < threadPoolExecutor.getMaximumPoolSize()) {
	return false;
}

由於新增到佇列返回falseexecute方法進入到else if (!addWorker(command, false))

if (isRunning(c) && workQueue.offer(command)) {
	int recheck = ctl.get();
	if (! isRunning(recheck) && remove(command))
		reject(command);
	else if (workerCountOf(recheck) == 0)
		addWorker(null, false);
}
//新增到佇列失敗後進入addWorker方法中
else if (!addWorker(command, false))
	reject(command);
}

再來看一下addWorker方法的程式碼,這裡只擷取需要的一部分

for (;;) {
    int wc = workerCountOf(c);
    if (wc >= CAPACITY ||
    	//兩個執行緒都認為還可以建立再建立一個新執行緒
        wc >= (core ? corePoolSize : maximumPoolSize))
        return false;
        //兩個執行緒同時呼叫cas方法只有一個能夠成功
        //成功的執行緒break retry;進入後面的建立執行緒的邏輯
        //失敗的執行緒重新回到上面的檢查並返回false
    if (compareAndIncrementWorkerCount(c))
        break retry;
    c = ctl.get();  // Re-read ctl
    if (runStateOf(c) != rs)
        continue retry;
    // else CAS failed due to workerCount change; retry inner loop
}

最終,在競爭中失敗的執行緒由於addWorker方法返回了false最終呼叫了reject(command)。在前面寫的要實現的邏輯裡提到了,只有在等待佇列容量達到上限無法再插入時才拒絕任務,但是由於多執行緒的原因,這裡只是超過了threshold但沒有超過capacity的時候就拒絕任務了,所以要對拒絕策略的觸發做出修改:第一次觸發Reject時,嘗試重新新增到任務佇列中(不進行poolSize的檢測),如果仍然不能新增,再拒絕任務
這裡通過對execute方法進行重寫來實現重試

@Override
public void execute(Runnable command) {
    try {
        super.execute(command);
    } catch (RejectedExecutionException e) {
    	/*
    	這裡參考原始碼中將任務新增到任務佇列的實現
    	但是其中通過(workerCountOf(recheck) == 0)
    	檢查當任務新增到佇列後是否還有執行緒存活的部分
    	由於是private許可權的,無法實現類似的邏輯,因此需要做一定的特殊處理
		if (isRunning(c) && workQueue.offer(command)) {
		     int recheck = ctl.get();
		     if (! isRunning(recheck) && remove(command))
		         reject(command);
		     else if (workerCountOf(recheck) == 0)
		         addWorker(null, false);
		 }
		*/
        if (!this.isShutdown() && ((MyLinkedBlockingQueue)this.getQueue()).offerWithoutCheck(command)) {
            if (this.isShutdown() && remove(command))
                //二次檢查
                realRejectedExecutionHandler.rejectedExecution(command, this);
            } else {
                //插入失敗,佇列已經滿了
                realRejectedExecutionHandler.rejectedExecution(command, this);
            }
        }
    }
}

這裡有兩個小問題:

  1. 初始化執行緒池傳入的RejectedExecutionHandler不一定會丟擲異常(事實上,ThreadPoolExecutor自己實現的4中拒絕策略中只有AbortPolicy能夠丟擲異常並被捕捉到),因此需要在初始化父類時傳入AbortPolicy拒絕策略並將建構函式中傳入的自定義拒絕策略儲存下來,在重試失敗後才呼叫自己的rejectedExecution
  2. corePoolSize = 0 的極端情況下,可能出現一個任務剛被插入佇列的同時,所有的執行緒都結束任務然後被銷燬了,此使這個被加入的任務就無法被執行,在ThreadPoolExecutor中是通過
    else if (workerCountOf(recheck) == 0)
    	addWorker(null, false);
    
    在新增後再檢查工作執行緒是否為0來確保任務可以被執行,但是其中使用的方法是私有的,無法在子類中實現類似的邏輯,因此在初始化時只能強制corePoolSize至少為1來解決這個問題。

全部程式碼如下

public class MyThreadPool extends ThreadPoolExecutor {

    private RejectedExecutionHandler realRejectedExecutionHandler;

    public MyThreadPool(int corePoolSize,
                        int maximumPoolSize,
                        long keepAliveTime,
                        TimeUnit unit,
                        int queueCapacity) {
        this(corePoolSize,
                maximumPoolSize,
                keepAliveTime,
                unit,
                queueCapacity,
                new AbortPolicy());
    }

    public MyThreadPool(int corePoolSize,
                        int maximumPoolSize,
                        long keepAliveTime,
                        TimeUnit unit,
                        int queueCapacity,
                        RejectedExecutionHandler handler) {
        super(corePoolSize == 0 ? 1 : corePoolSize,
                maximumPoolSize,
                keepAliveTime,
                unit,
                new MyLinkedBlockingQueue<>(queueCapacity),
                new AbortPolicy());
        ((MyLinkedBlockingQueue)this.getQueue()).setThreadPoolExecutor(this);
        realRejectedExecutionHandler = handler;
    }

    @Override
    public void execute(Runnable command) {
        try {
            super.execute(command);
        } catch (RejectedExecutionException e) {
            if (!this.isShutdown() && ((MyLinkedBlockingQueue)this.getQueue()).offerWithoutCheck(command)) {
                if (this.isShutdown() && remove(command))
                    //二次檢查
                    realRejectedExecutionHandler.rejectedExecution(command, this);
            } else {
                //插入失敗,佇列已經滿了
                realRejectedExecutionHandler.rejectedExecution(command, this);
            }
        }
    }
}


public class MyLinkedBlockingQueue<E> extends LinkedBlockingQueue<E> {

    private int threshold = 20;

    private ThreadPoolExecutor threadPoolExecutor = null;

    public MyLinkedBlockingQueue(int queueCapacity) {
        super(queueCapacity);
    }

    public void setThreadPoolExecutor(ThreadPoolExecutor threadPoolExecutor) {
        this.threadPoolExecutor = threadPoolExecutor;
    }

    @Override
	public boolean offer(E e) {
	    if (threadPoolExecutor == null) {
	        throw new NullPointerException();
	    }
	    //當呼叫該方法時,已經確定了workerCountOf(c) > corePoolSize
	    //當數量小於threshold,在佇列裡等待
	    if (size() < threshold) {
	        return super.offer(e);
		//當數量大於等於threshold,說明堆積的任務太多,返回false
		//讓執行緒池來建立新執行緒處理
	    } else {
	        //此處可能會因為多執行緒導致錯誤的拒絕
	        if (threadPoolExecutor.getPoolSize() < threadPoolExecutor.getMaximumPoolSize()) {
	            return false;
	        //執行緒池中的執行緒數量已經到達上限,只能新增到任務佇列中
	        } else {
	            return super.offer(e);
	        }
	    }
	}

    public boolean offerWithoutCheck(E e) {
        return super.offer(e);
    }
}

最後進行簡單的測試

corePoolSize:2
maximumPoolSize:5
queueCapacity:10
threshold:7
任務2
執行緒數量:2
等待佇列大小:0
等待佇列大小小於閾值,繼續等待。
任務3
執行緒數量:2
等待佇列大小:1
等待佇列大小小於閾值,繼續等待。
任務4
執行緒數量:2
等待佇列大小:2
等待佇列大小小於閾值,繼續等待。
任務5
執行緒數量:2
等待佇列大小:3
等待佇列大小小於閾值,繼續等待。
任務6
執行緒數量:2
等待佇列大小:4
等待佇列大小小於閾值,繼續等待。
任務7
執行緒數量:2
等待佇列大小:5
等待佇列大小小於閾值,繼續等待。
任務8
執行緒數量:2
等待佇列大小:6
等待佇列大小小於閾值,繼續等待。
任務9
執行緒數量:2
等待佇列大小:7
等待佇列大小大於等於閾值,執行緒數量小於MaximumPoolSize,建立新執行緒處理。
任務10
執行緒數量:3
等待佇列大小:7
等待佇列大小大於等於閾值,執行緒數量小於MaximumPoolSize,建立新執行緒處理。
任務11
執行緒數量:4
等待佇列大小:7
等待佇列大小大於等於閾值,執行緒數量小於MaximumPoolSize,建立新執行緒處理。
任務12
執行緒數量:5
等待佇列大小:7
等待佇列大小大於等於閾值,但執行緒數量大於等於MaximumPoolSize,只能新增到佇列中。
任務13
執行緒數量:5
等待佇列大小:8
等待佇列大小大於等於閾值,但執行緒數量大於等於MaximumPoolSize,只能新增到佇列中。
任務14
執行緒數量:5
等待佇列大小:9
等待佇列大小大於等於閾值,但執行緒數量大於等於MaximumPoolSize,只能新增到佇列中。
任務15
執行緒數量:5
等待佇列大小:10
等待佇列大小大於等於閾值,但執行緒數量大於等於MaximumPoolSize,只能新增到佇列中。
佇列已滿
任務16
執行緒數量:5
等待佇列大小:10
等待佇列大小大於等於閾值,但執行緒數量大於等於MaximumPoolSize,只能新增到佇列中。
佇列已滿

再重新複習一遍要實現的功能:

  1. 執行緒池保持corePoolSize個執行緒確保有新任務到來時可以立即得到執行
  2. 當沒有空閒執行緒時,先把任務放到等待佇列中(因為開啟一個執行緒需要10s,所以如果在等待佇列比較小的時候,等待其他任務完成比等待新執行緒建立更快)
  3. 當等待佇列的大小大於設定的閾值threshold時,說明堆積的任務已經太多了,這個時候開始建立非核心執行緒直到執行緒數量已經等於maximumPoolSize
  4. 當執行緒數量已經等於maximumPoolSize,再將新來的任務放回到任務佇列中等待(直到佇列滿後開始拒絕任務)
  5. 長時間空閒後退出非核心執行緒回收瀏覽器佔用的記憶體資源

可以看出,執行緒池執行的邏輯和要實現的目標是相同的。

相關文章