1. 程式人生 > >dubbo與trivial超時機制的深入思考

dubbo與trivial超時機制的深入思考

說在前面

trivial是根據之前設計的RPC框架而來的(還在增進當中),其中較為不同的一個點為,在客戶端去掉了業務執行緒池,因為既然都要等待,不必要再加一層。

 

進入正題

有在網上看到這樣的資訊,“之前有簡單提到過, dubbo預設採用了netty做為網路元件,它屬於一種NIO的模式。消費端發起遠端請求後,執行緒不會阻塞等待服務端的返回,而是馬上得到一個ResponseFuture,消費端通過不斷的輪詢機制判斷結果是否有返回。因為是通過輪詢,輪詢有個需要特別注要的就是避免死迴圈,所以為了解決這個問題就引入了超時機制,只在一定時間範圍內做輪詢,如果超時時間就返回超時異常”。

我認為這種說法是錯誤。

1.以上說法只關注結果,但是如果只關注結果的話何不阻塞等待?還需要輪詢判斷,耗費cpu資源?超時機制絕不是為了讓輪詢在一定時間內結束!

問題1:超時機制有什麼作用?

2.上述說“消費端通過不斷的輪詢機制判斷結果是否有返回”,沒有指明是消費端的什麼執行緒,但是容易讓人誤以為是呼叫者執行緒(下稱caller)。而事實上是由一個deamon執行緒去掃描判斷所有的caller發起的呼叫是否超時。

問題2:為什麼不讓caller自己去輪詢?

 

問題1個人觀點:

在正常情況下,即caller發起呼叫,而後只需阻塞等待服務提供方的結果即可,因為在正常情況下是能收到的。

那要是因為某些原因而收不到呢?比如,服務提供方的處理執行緒意外結束了,那caller豈不是要一直等下去?

所以要有超時。

dubbo中超時後重試的請求是路由到其他機器上的。咋一看合情合理,再細想大有學問(有可能是我想多了)。

除了剛剛說的因為處理執行緒意外結束使得caller得不到結果這種情況之外,有些人會想到另一種情況——在網路中丟失?這種情況也是不適合再發送到同一個機器的,因為有tcp的重傳,這樣你重試的請求若要到同一個機器,便到了協議棧同一個緩衝區,那麼最先發送成功的依然是上一次的請求,再按正常情況,首次收到的依然是上一次請求的結果,相當於重試沒有作用。

事實上,這只是我的猜想,對於tcp序列傳輸,並行傳輸什麼的還沒有去了解,這裡只算是提出一個問題來思考,如果有錯誤還望指出!

 

問題2個人觀點:

假設是由caller自己輪詢(有10個),那麼每個cpu時間片結束後,都會從執行態轉到就緒態(同樣有上下文的切換)。適合短時間輪詢

假設是由超時掃描執行緒掃描,這10個caller直接一次進入java執行緒的等待狀態(linux的阻塞態?),結束後由他人喚醒。適合較長時間輪詢

前者每次狀態切換耗費資源少,但次數多。

後者每次狀態切換耗費資源多,但只有一次。

所以多短算短,多長算長呢?未經測試。

同樣我並不知道dubbo是怎麼考慮的,但我自己是這樣想的,所以再次強調這是個人觀點,可能有錯誤。

 

dubbo超時細節

超時掃描執行緒

static {
    Thread th = new Thread(new RemotingInvocationTimeoutScan(), "DubboResponseTimeoutScanTimer"); //掃描超時
    th.setDaemon(true);
    th.start();
}

 

DefaultFuture的get方法

@Override
public Object get() throws RemotingException {
    return get(timeout);
}

@Override
public Object get(int timeout) throws RemotingException {
    if (timeout <= 0) {
        timeout = Constants.DEFAULT_TIMEOUT;
    }
    if (!isDone()) {
        long start = System.currentTimeMillis();
        lock.lock();
        try {
            while (!isDone()) { // wait應該在迴圈當中
                // 在呼叫的時候需要等待
                done.await(timeout, TimeUnit.MILLISECONDS);
                if (isDone() || System.currentTimeMillis() - start > timeout) {
                    break;
                }
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
        
        if (!isDone()) {
            throw new TimeoutException(sent > 0, channel, getTimeoutMessage(false));
        }
    }
    return returnFromResponse();
}

 

掃描執行緒細節

private static class RemotingInvocationTimeoutScan implements Runnable {

        @Override
        public void run() {
            while (true) {
                try {
                    // 掃描DefaultFuture列表
                    for (DefaultFuture future : FUTURES.values()) {
                        if (future == null || future.isDone()) {
                            continue;
                        }
                        // 如果future未完成且超時
                        if (System.currentTimeMillis() - future.getStartTimestamp() > future.getTimeout()) {
                            Response timeoutResponse = new Response(future.getId());
                            // 設定超時狀態
                            timeoutResponse.setStatus(future.isSent() ? Response.SERVER_TIMEOUT : Response.CLIENT_TIMEOUT);
                            timeoutResponse.setErrorMessage(future.getTimeoutMessage(true));
                            DefaultFuture.received(future.getChannel(), timeoutResponse);
                        }
                    }
                    Thread.sleep(30);
                } catch (Throwable e) {
                    logger.error("Exception when scan the timeout invocation of remoting.", e);
                }
            }
        }
    }

可以看到該執行緒用於掃描所有caller註冊的呼叫資訊,檢查超時。值得注意的一個細節是,“Thread.sleep(30)”,也是在說明while(true)是不讓出cpu的嗎?

 

trivial超時細節

超時觀察者watcher

private class Watcher extends Thread{
        @Override
        public void run() {
            while(!RPCClient.shutdown){//每次迴圈檢查是否已經關閉,同樣會讓出cpu
                try {
                    CountDownNode head=waiterQueue.take();//阻塞獲取頭
                    if(System.currentTimeMillis()-head.createTime <RPCClient.timeout)
                        waiterQueue.add(head);//如果沒有超時再加回到隊尾
                    else{//如果超時了
                        long callerId=head.message.getCallerId();
                        long count=head.message.getCount();
                        if(countMap.get(callerId)==null
                                || countMap.get(callerId)!=count) continue;//實際上已經成功返回
                        if(head.retryNum>0){
                            head.retryNum--;
                            log.error("執行緒——"+callerId+" 第 "+count +" 次呼叫超時,即將進行第 "
                                    +(RPCClient.retryNum-head.retryNum)+" 次重試");
                            context.writeAndFlush(head.message);//重發資訊
                            continue;
                        }
                        resultMap.put(callerId,"呼叫超時");
                        log.error("執行緒—— "+callerId+" 第 "+count
                                +"次呼叫超時,已重試 "+RPCClient.retryNum+" 次,即將返回超時提示");
                        LockSupport.unpark(waiterMap.get(callerId));
                        waiterMap.remove(callerId);
                        countMap.remove(callerId);
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            log.info("超時觀察者退出");
        }
    }

大致上是差不多的,都是要一個執行緒去掃描,但有一點較為不同的是,

dubbo的超時掃描執行緒雖然每次迴圈sleep(30),但即使沒有caller發起呼叫也會一直掃描,耗費cpu資源;

而trivial則會阻塞地從阻塞佇列中獲取,如果沒有caller發起呼叫則阻塞,不耗費cpu資源。

在頻繁發起呼叫的時候兩者差不多的,因為後者也不會總是進入阻塞,但在偶發呼叫時,或許trivial較好。當然取決於真實情況。

 

最後,如果有興趣的話,可以瞭解一下這個平凡的RPC框架,https://github.com/AllenDuke/trivia