java 心跳機制

Coding-lover發表於2015-09-28

什麼是心跳包?

心跳包就是在客戶端和伺服器間定時通知對方自己狀態的一個自己定義的命令字,按照一定的時間間隔傳送,類似於心跳,所以叫做心跳包。

  
用來判斷對方(裝置,程式或其它網元)是否正常執行,採用定時傳送簡單的通訊包,如果在指定時間段內未收到對方響應,則判斷對方已經離線。用於檢測TCP的異常斷開。基本原因是伺服器端不能有效的判斷客戶端是否線上,也就是說,伺服器無法區分客戶端是長時間在空閒,還是已經掉線的情況。所謂的心跳包就是客戶端定時傳送簡單的資訊給伺服器端告訴它我還在而已。程式碼就是每隔幾分鐘傳送一個固定資訊給服務端,服務端收到後回覆一個固定資訊如果服務端幾分鐘內沒有收到客戶端資訊則視客戶端斷開。

比如有些通訊軟體長時間不使用,要想知道它的狀態是線上還是離線就需要心跳包,定時發包收包。發包方:可以是客戶也可以是服務端,看哪邊實現方便合理,一般是客戶端。伺服器也可以定時發心跳下去。一般來說,出於效率的考慮,是由客戶端主動向伺服器端發包,而不是伺服器向客戶端發。客戶端每隔一段時間發一個包,使用TCP的,用send發,使用UDP的,用sendto發,伺服器收到後,就知道當前客戶端還處於“活著”的狀態,否則,如果隔一定時間未收到這樣的包,則伺服器認為客戶端已經斷開,進行相應的客戶端斷開邏輯處理。

伺服器實現心跳機制的兩種策略

大部分CS的應用需要心跳機制。心跳機制一般在Server和Client都要實現,兩者實現原理基本一樣。Client不關心效能,怎麼做都行。

如果應用是基於TCP的,可以簡單地通過SO_KEEPALIVE實現心跳。TCP在設定的KeepAlive定時器到達時向對端發一個檢測TCP segment,如果沒收到ACK或RST,嘗試幾次後,就認為對端已經不存在,最後通知應用程式。這裡有個缺點是,Server主動發出檢測包,對效能有點影響。

應用自己實現

Client啟動一個定時器,不斷髮心跳;

Server收到心跳後,給個回應;

Server啟動一個定時器,判斷Client是否存在,判斷方法這裡列兩種:時間差和簡單標誌。

1. 時間差策略

收到一個心跳後,記錄當前時間(記為recvedTime)。

判斷定時器時間到達,計算多久沒收到心跳的時間(T)=當前時間 - recvedTime(上面記錄的時間)。如果T大於某個設定值,就可以認為Client超時了。

2. 簡單標誌

收到一個心跳後,設定連線標誌為true;

判斷定時器時間到達,檢視所有的標誌,false的,認為對端超時了;true的將其設成false。

上面這種方法比上面簡單一些,但檢測某個Client是否離線的誤差有點大。

您還有心跳嗎?超時機制分析

問題描述

在C/S模式中,有時我們會長時間保持一個連線,以避免頻繁地建立連線,但同時,一般會有一個超時時間,在這個時間內沒發起任何請求的連線會被斷開,以減少負載,節約資源。並且該機制一般都是在服務端實現,因為client強制關閉或意外斷開連線,server端在此刻是感知不到的,如果放到client端實現,在上述情況下,該超時機制就失效了。本來這問題很普通,不太值得一提,但最近在專案中看到了該機制的一種糟糕的實現,故在此深入分析一下。

問題分析及解決方案

服務端一般會保持很多個連線,所以,一般是建立一個定時器,定時檢查所有連線中哪些連線超時了。此外我們要做的是,當收到客戶端發來的資料時,怎麼去重新整理該連線的超時資訊?

最近看到一種實現方式是這樣做的:

public class Connection {
    private long lastTime;
    public void refresh() {
        lastTime = System.currentTimeMillis();
    }

    public long getLastTime() {
        return lastTime;
    }
    //......
}

在每次收到客戶端發來的資料時,呼叫refresh方法。

然後在定時器裡,用當前時間跟每個連線的getLastTime()作比較,來判定超時:

public class TimeoutTask  extends TimerTask{
    public void run() {
        long now = System.currentTimeMillis();
        for(Connection c: connections){
            if(now - c.getLastTime()> TIMEOUT_THRESHOLD)
                ;//timeout, do something
        }
    }
}

看到這,可能不少讀者已經看出問題來了,那就是記憶體可見性問題,呼叫refresh方法的執行緒跟執行定時器的執行緒肯定不是一個執行緒,那run方法中讀到的lastTime就可能是舊值,即可能將活躍的連線判定超時,然後被幹掉。

有讀者此時可能想到了這樣一個方法,將lastTime加個volatile修飾,是的,這樣確實解決了問題,不過,作為服務端,很多時候對效能是有要求的,下面來看下在我電腦上測出的一組資料,測試程式碼如下,供參考

public class PerformanceTest {
    private static long i;
    private volatile static long vt;
    private static final int TEST_SIZE = 10000000;

    public static void main(String[] args) {
        long time = System.nanoTime();
        for (int n = 0; n < TEST_SIZE; n++)
            vt = System.currentTimeMillis();
        System.out.println(-time + (time = System.nanoTime()));
        for (int n = 0; n < TEST_SIZE; n++)
            i = System.currentTimeMillis();
        System.out.println(-time + (time = System.nanoTime()));
        for (int n = 0; n < TEST_SIZE; n++)
            synchronized (PerformanceTest.class) {
            }
        System.out.println(-time + (time = System.nanoTime()));
        for (int n = 0; n < TEST_SIZE; n++)
            vt++;
        System.out.println(-time + (time = System.nanoTime()));
        for (int n = 0; n < TEST_SIZE; n++)
            vt = i;
        System.out.println(-time + (time = System.nanoTime()));
        for (int n = 0; n < TEST_SIZE; n++)
            i = vt;
        System.out.println(-time + (time = System.nanoTime()));
        for (int n = 0; n < TEST_SIZE; n++)
            i++;
        System.out.println(-time + (time = System.nanoTime()));
        for (int n = 0; n < TEST_SIZE; n++)
            i = n;
        System.out.println(-time + (time = System.nanoTime()));
    }
}

測試一千萬次,結果是(耗時單位:納秒,包含迴圈本身的時間):
238932949 volatile寫+取系統時間
144317590 普通寫+取系統時間
135596135 空的同步塊(synchronized)
80042382 volatile變數自增
15875140 volatile寫
6548994 volatile讀
2722555 普通自增
2949571 普通讀寫

從上面的資料看來,volatile寫+取系統時間的耗時是很高的,取系統時間的耗時也比較高,跟一次無競爭的同步差不多了,接下來分析下如何優化該超時時機。

首先:同步問題是肯定得考慮的,因為有跨執行緒的資料操作;另外,取系統時間的操作比較耗時,能否不在每次重新整理時都取時間?因為重新整理呼叫在高負載的情況下很頻繁。如果不在重新整理時取時間,那又該怎麼去判定超時?

我想到的辦法是,在refresh方法裡,僅設定一個volatile的boolean變數reset(這應該是成本最小的了吧,因為要處理同步問題,要麼同步塊,要麼volatile,而volatile讀在此處是沒什麼意義的),對時間的掌控交給定時器來做,併為每個連線維護一個計數器,每次加一,如果reset被設定為true了,則計數器歸零,並將reset設為false(因為計數器只由定時器維護,所以不需要做同步處理,從上面的測試資料來看,普通變數的操作,時間成本是很低的),如果計數器超過某個值,則判定超時。 下面給出具體的程式碼:

public class Connection {
    int count = 0;
    volatile boolean reset = false;
    public void refresh() {
        if (reset == false)
            reset = true;
    }
}

public class TimeoutTask extends TimerTask {
    public void run() {
        for (Connection c : connections) {
            if (c.reset) {
                c.reset = false;
                c.count = 0;
            } else if (++c.count >= TIMEOUT_COUNT)
                ;// timeout, do something
        }
    }
}

程式碼中的TIMEOUT_COUNT 等於超時時間除以定時器的週期,週期大小既影響定時器的執行頻率,也會影響實際超時時間的波動範圍(這個波動,第一個方案也存在,也不太可能避免,並且也不需要多麼精確)。

程式碼很簡潔,下面來分析一下。

reset加上了volatile,所以保證了多執行緒操作的可見性,雖然有兩個執行緒都對變數有寫操作,但無論這兩個執行緒怎麼穿插執行,都不會影響其邏輯含義。

再說下refresh方法,為什麼我在賦值語句上多加了個條件?這不是多了一次volatile讀操作嗎?我是這麼考慮的,高負載下,refresh會被頻繁呼叫,意味著reset長時間為true,那麼加上條件後,就不會執行寫操作了,只有一次讀操作,從上面的測試資料來看,volatile變數的讀操作的效能是顯著優於寫操作的。只不過在reset為false的時候,多了一次讀操作,但此情況在定時器的一個週期內最多隻會發一次,而且對高負載情況下的優化顯然更有意義,所以我認為加上條件還是值得的。

————————————-
補充一下:一般情況下,也可用特定的心跳包來重新整理,而不是每次收到訊息都重新整理,這樣一來,重新整理頻率就很低了,也就沒必要太在乎效能開銷。

轉載自:
java心跳是怎麼回事兒啊?
伺服器實現心跳機制的兩種策略
您還有心跳嗎?超時機制分析

相關文章