本文是Netty文集中“Netty 那些事兒”系列的文章。主要結合在開發實戰中,我們遇到的一些“奇奇怪怪”的問題,以及如何正確且更好的使用Netty框架,并會對Netty中涉及的重要設計理念進行介紹。
什么是心跳機制?
心跳說的是在客戶端和服務端在互相建立ESTABLISH狀態的時候,如何通過發送一個最簡單的包來保持連接的存活,還有監控另一邊服務的可用性等。
心跳包的作用
保活
Q:為什么說心跳機制能保持連接的存活,它是集群中或長連接中最為有效避免網絡中斷的一個重要的保障措施?
A:之所以說是“避免網絡中斷的一個重要保障措施”,原因是:我們得知公網IP是一個寶貴的資源,一旦某一連接長時間的占用并且不發數據,這怎能對得起網絡給此連接分配公網IP,這簡直是對網絡資源最大的浪費,所以基本上所有的NAT路由器都會定時的清除那些長時間沒有數據傳輸的映射表項。一是回收IP資源,二是釋放NAT路由器本身內存的資源,這樣問題就來了,連接被從中間斷開了,雙發還都不曉得對方已經連通不了了,還會繼續發數據,這樣會有兩個結果:a) 發方會收到NAT路由器的RST包,導致發方知道連接已中斷;b) 發方沒有收到任何NAT的回執,NAT只是簡單的drop相應的數據包
通常我們測試得出的是第二種情況會多些,就是客戶端是不知道自己應經連接斷開了,所以這時候心跳就可以和NAT建立關聯了,只要我們在NAT認為合理連接的時間內發送心跳數據包,這樣NAT會繼續keep連接的IP映射表項不被移除,達到了連接不會被中斷的目的。檢測另一端服務是否可用
TCP的斷開可能有時候是不能瞬時探知的,甚至是不能探知的,也可能有很長時間的延遲,如果前端沒有正常的斷開TCP連接,四次握手沒有發起,服務端無從得知客戶端的掉線,這個時候我們就需要心跳包來檢測另一端服務是否還存活可用。
應用層的心跳機制?? VS ??TCP的keepalive機制
- 傳輸層心跳是保證連接可用,但應用層心跳卻可以保證服務可用.
TCP的keepalive機制能保證連接沒有問題,但當進程出現死鎖或者阻塞的情況下,雖然連接沒有問題,但是服務已經不能正常使用了。 - 從TCP的keepalive機制的本質上來說,是用來檢測長時間不活躍的連接的,不適合用來及時檢測連接的狀態;而應用層的心跳機制具有更大的靈活性,可以自己控制檢測的間隔和檢測方式,并且可以通過心跳包來附帶一些信息等。
TCP有個KeepAlive開關,打開后可以用來檢測死連接。通常默認是2小時,可以自己設置。但是注意,這是TCP的全局設置。
用 Netty 的 IdleStateHandler 實現固定周期的心跳機制
因為IdleStateHandler的超時時間是不可改變的,所以通過IdleStateHandler只能實現固定周期的心跳機制。
以此為基礎的心跳機制:
方案一:
client:
① arg0.pipeline().addLast("ping", new IdleStateHandler(25, 0, 10,TimeUnit.SECONDS));
處理ReadIdleEvent和AllIdleEvent。
當AllIdleEvent觸發時說明此時間段內既沒有讀也沒有寫操作,那么就發送一個心跳包。
因為ReadIdleEvent的超時時間比AllIdleEvent長,所以如果在指定時間范圍內收到了心跳包的回復是不會觸發這個事件的。所以如果ReadIdleEvent事件被觸發了,則認為和服務端的連接已經斷掉了,那么就close這個channel。??注意,這邊有個有個優化,每次發送心跳包的時候就計數下,如果有收到pong包則重新計數,依次來實現發送N此心跳包后依舊么有回復的情況下,再關閉這個channel。
② 通過channelInactive方法來處理客戶端的重連機制的。該方法觸發使,會調用一個延遲器來執行和服務端的重連。
server:
① arg0.pipeline().addLast("ping", new IdleStateHandler(25 * N, 0, 0,TimeUnit.SECONDS));
N為客戶端重試發送心跳包的次數,這么設計主要是為了讓客戶端和服務端能幾乎同時的去關閉這個channel。
當ReadIdleEvent被觸發時,則認為和客戶端的這次連接已經斷掉了,則close這個channel。
方案二:
client:
① arg0.pipeline().addLast("ping", new IdleStateHandler(10, 0, 0,TimeUnit.SECONDS));
當ReadIdleEvent事件被觸發使,則發送一個心跳包,并對發送的心跳包進行計數。如果連接正常,則會收到服務端的pong包,這時會清空計數器。當然在收到其他的數據包時也是會清空這個計數器的。
當發送心跳包的計數值達到一定數量的時候,則認為和服務端的連接已經斷掉了,這個時候則會close掉這個channel。
② 通過channelInactive方法來處理客戶端的重連機制的。該方法觸發使,會調用一個延遲器來執行和服務端的重連。
server:
① arg0.pipeline().addLast("ping", new IdleStateHandler(10 * N, 0, 0,TimeUnit.SECONDS));
N為客戶端重試發送心跳包的次數,這么設計主要是為了讓客戶端和服務端能幾乎同時的去關閉這個channel。
當ReadIdleEvent被觸發時,則認為和客戶端的這次連接已經斷掉了,則close這個channel。
這里個人建議使用第二個方案來實現心跳機制。因為通過查看源碼發現,無論channel的寫操作是否成功,只有是執行了channel的write操作就會注冊IdleStateHandler中的writeListener到write操作的promise中。這樣只要操作完成,無論是失敗還是成功都會觸發注冊到其上的listener的回調。這樣的話就可能出現使得即使write操作失敗了也不會觸發和寫有關的超時事件了的情況了,即AllIdleEvent就不會被觸發了,這將導致即便這個時候寫操作時因為一些邏輯關系而操作失敗了,我們的心跳機制在幾次ReadIdleEvent事件被觸發后,會錯誤的認為連接已經“斷”了,而去關閉這個channel了(實際上,有可能是write操作是失敗的,但因為AllIdleEvent沒有被觸發,那么就不會發送心跳包,最終導致ReadIdleEvent事件的觸發)。
當然,到底使用AllIdleEvent還是ReadIdleEvent活著WriteIdleEvent還是要根據實際的業務情況來決定的
代碼示例
我們通過一個簡單的聊天系統來展示如何在Netty中使用心跳機制,并采用“方案二”的方式來實現。由客戶端向服務器端發起心跳(ping包),服務器端在收到客戶端的心跳包后會返回一個響應(pong包)。若服務端在一定時間內沒有收到客戶端任何的數據包時(包括心跳包以及邏輯數據包),則認為該客戶端已經無法正常通信了,那么就關掉相應socket以釋放資源;而客戶端同理。
首先我們發送的是自定義的消息包
自定義消息格式: | MAGIC | LENGTH | BODY |
MAGIC(byte) :消息類型。{@link ChatProtocol#MAGIC_MESSAGE}表示消息類型;{@link ChatProtocol#MAGIC_HEARTBEAT_PING}表示PING心跳包;{@link ChatProtocol#MAGIC_HEARTBEAT_PONG}表示PONG心跳包
LENGTH(int32) :消息長度
BODY(byte[]) :消息體
客戶端主要代碼:
MyChatClient:
public class MyChatClient {
private ExecutorService executor;
// private Future result;
private static BufferedReader bufferedReader;
private PrintTask printTask;
public static void main(String[] args) throws Exception {
MyChatClient chatClient = new MyChatClient();
EventLoopGroup group = new NioEventLoopGroup();
bufferedReader = new BufferedReader(new InputStreamReader(System.in));
try {
chatClient.connect(group);
} finally {
// group.shutdownGracefully();
}
Runtime.getRuntime().addShutdownHook(new Thread(() -> group.shutdownGracefully()));
}
public void connect(EventLoopGroup group) {
try{
executor = Executors.newSingleThreadExecutor();
Bootstrap client = new Bootstrap();
client.group(group).channel(NioSocketChannel.class).handler(new MyChatClientInitializer(this));
// ======= 說明 ========
/**
* 這種寫法在重連接的時候回拋 "io.netty.util.concurrent.BlockingOperationException: DefaultChannelPromise@d21e95e(incomplete)"異常
* 解決方法:不能在ChannelHandler中調用 ChannelFuture.sync() 。通過注冊Listener來實現功能
*/
// ChannelFuture future = client.connect(new InetSocketAddress("127.0.0.1", 5566)).sync();
// ======= 說明 ========
//192.168.1.102
client.remoteAddress(new InetSocketAddress("127.0.0.1", 5566));
client.connect().addListener((ChannelFuture future) -> {
if(future.isSuccess()) {
// ======= 說明 ========
// 這個 死循環 導致了走到了channelRegistered, 后面的channelActive流程就被它堵塞了,以至于沒往下走。。。
/*while (!readerExit) {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
future.channel().writeAndFlush(bufferedReader.readLine());
}*/
// ======= 說明 ========
if(printTask == null) {
printTask = new PrintTask(future, bufferedReader);
} else {
printTask.setFuture(future);
}
executor.submit(printTask);
}
});
} catch (Exception e) {
e.printStackTrace();
}
}
}
MyChatClient主要完成了對服務端的連接,并將connect方法獨立出來,以便重連時使用。
上面“說明”注解中提及的兩點是Netty線程模式中非常重要的兩個知識點,在之前的理論篇以及源碼篇都有進行說明,在文章的后面,會再次結合實戰情況再次對這兩個重要的知識點進行說明。
MyChatClientInitializer:
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline()
.addLast("idleStateHandler", new IdleStateHandler(MyChatContants.CLIENT_READ_TIME, 0, 0, TimeUnit.SECONDS))
.addLast("myChatClientIdleHandler", new MyChatClientIdleHandler(chatClient))
.addLast("myChatDecoder", new MyChatDecoder())
.addLast("myChatEncoder", new MyChatEncoder())
.addLast("stringDecoder", new StringDecoder(charset))
.addLast("stringEncoder", new StringEncoder(charset))
.addLast("myChatClientHandler", new MyChatClientHandler());
}
初始化客戶端的ChannelHandler,其中就包括了用于實現心跳機制的IdleStateHandler以及MyChatClientIdleHandler。
MyChatClientIdleHandler:
public class MyChatClientIdleHandler extends ChannelInboundHandlerAdapter {
private final MyChatClient chatClient;
public MyChatClientIdleHandler(MyChatClient chatClient) {
this.chatClient = chatClient;
}
private int retryCount;
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if(evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent)evt;
if(event.state() == IdleState.READER_IDLE) {
if(++retryCount > RETRY_LIMIT) {
System.out.println("server " + ctx.channel().remoteAddress() + " is inactive to close");
closeAndReconnection(ctx.channel());
} else {
System.out.println("send ping package to " + ctx.channel().remoteAddress());
ctx.writeAndFlush(MyHeartbeat.getHeartbeatPingBuf());
}
}
} else {
super.userEventTriggered(ctx, evt);
}
}
private void closeAndReconnection(Channel channel) {
channel.close();
channel.eventLoop().schedule(() -> {
System.out.println("========== 嘗試重連接 ==========");
chatClient.connect(channel.eventLoop());
}, 10L, TimeUnit.SECONDS);
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
retryCount=0;
super.channelRead(ctx, msg);
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println(ctx.channel() + " 已連上. 可以開始聊天...");
super.channelActive(ctx);
}
@Override
public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
System.out.println("channelRegistered : " + ctx.channel());
super.channelRegistered(ctx);
}
}
MyChatClientIdleHandler類實現對讀空閑時間超時的處理,當發現IdleState.READER_IDLE事件連續發生RETRY_LIMIT次后,則任務與服務端的連接已經失效了,此時就會關閉當前的socket,定義一個延時任務進行與服務器的重新連接。若還未超過RETRY_LIMIT次,則會發送一個心跳包(ping包)。
服務端主要代碼:
MyChatServerInitializer:
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline()
.addLast("idleStateHandler", new IdleStateHandler(MyChatContants.SERVER_READ_TIME, 0, 0, TimeUnit.SECONDS))
.addLast("myChatServerIdleHandler", new MyChatServerIdleHandler())
.addLast("myChatDecoder", new MyChatDecoder())
.addLast("myChatEncoder", new MyChatEncoder())
.addLast("stringDecoder", new StringDecoder(charset))
.addLast("stringEncoder", new StringEncoder(charset))
.addLast("myChatServerHandler", new MyChatServerHandler());
}
初始化服務器端的ChannelHandler,其中就包括了用于實現心跳機制的IdleStateHandler以及MyChatServerIdleHandler。
MyChatServerIdleHandler:
public class MyChatServerIdleHandler extends ChannelInboundHandlerAdapter {
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if(evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent)evt;
if (event.state() == READER_IDLE) {
System.out.println("client " + ctx.channel().remoteAddress() + " is inactive to close");
ctx.channel().close();
}
} else {
super.userEventTriggered(ctx, evt);
}
}
}
MyChatServerIdleHandler類實現對讀空閑時間超時的處理,若發送了讀空閑時間超時則認為和客戶端的連接已經失效,就會調用channel.close()來實現socket的關閉以及資源的釋放。
MyChatDecoder:
public class MyChatDecoder extends ReplayingDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
byte magic = in.readByte();
switch (magic) {
case ChatProtocol.MAGIC_MESSAGE:
int length = in.readInt();
ByteBuf body = in.readBytes(length);
out.add(body);
break;
case ChatProtocol.MAGIC_HEARTBEAT_PING:
System.out.println("收到 " + ctx.channel().remoteAddress() + " 的 ping 包,返回一個 pong 包。");
ctx.writeAndFlush(MyHeartbeat.getHeartbeatPongBuf());
break;
case ChatProtocol.MAGIC_HEARTBEAT_PONG:
System.out.println("收到 " + ctx.channel().remoteAddress() + " 的 pong 包。");
break;
}
}
}
消息解碼器處理器,這里實現了,當收到客戶端的心跳包時(ping包),則會返回一個響應(pong包)。
完整的代碼歡迎參閱github
關于 Netty4 的 BlockingOperationException 異常
好了,現在我們來說說上面兩個注釋
// ======= 說明 ========
/**
* 這種寫法在重連接的時候回拋 "io.netty.util.concurrent.BlockingOperationException: DefaultChannelPromise@d21e95e(incomplete)"異常
* 解決方法:不能在ChannelHandler中調用 ChannelFuture.sync() 。通過注冊Listener來實現功能
*/
// ChannelFuture future = client.connect(new InetSocketAddress("127.0.0.1", 5566)).sync();
// ======= 說明 ========
首先,來說說明下產生BlockingOperationException的場景:當發現連接失效時,通過如下代碼進行重連時拋出該異常:
channel.eventLoop().schedule(() -> {
System.out.println("========== 嘗試重連接 ==========");
chatClient.connect(channel.eventLoop());
}, 10L, TimeUnit.SECONDS);
異常是由 ChannelFuture.sync()方法拋出的。那么,我們來看看sync方法的實現:
而異常就是由其中的「checkDeadLock()」方法拋出的:
protected void checkDeadLock() {
EventExecutor e = executor();
if (e != null && e.inEventLoop()) {
throw new BlockingOperationException(toString());
}
}
checkDeadLock()方法是await()方法中在調用Object的wait()前必須調用的方法,以檢查當前的上下文是否會使Object wait()的調用造成死鎖。
當e!=null && e.inEventLoop()為true,則說明執行當前方法的線程就是EventLoop所關聯的線程(即,I/O線程)。
checkDeadLock()方法之后就會調用Object的wait()方法。wait()操作會將當前線程掛起,并釋放對象鎖,直到另一個線程調動該對象的notifyf()或notifyAll()方法,會喚醒一個被掛起的線程。所以如果掛起的線程和需要調用notify的線程是同一個線程的話,就會發生死鎖。(因為線程都已經被掛起了,還怎么去進行notify/notifyAll操作了?)
再者在在Netty4中,一個Channel對于的所以操作都會在它被創建時分配給它的EventLoop中完成,而一個EventLoop的整個生命周期只會和一個線程綁定,不會修改它。
而ChannelFuture則表示Channel異步操作的一個結果。你可以通過ChannelFuture來獲取Channel異步操作的結果。獲取結果的方式有兩種:a) 調用await(*)、sync(*)、get(*) 等方法阻塞當前線程直到獲取到異步操作的結果;b) 通過注冊回調函數,當操作完成的時候該回調函數會得到調用。
Q:所以,BlockingOperationException到底是在什么情況下會被拋出了?
A:首先,我們已經直到了當執行wait()線程和將會執行notify()/notifyAll()的線程會是同一個線程時,就會造成死鎖。
然后,我們知道當ChannelFuture調用await(*)、sync(*)、get(*) 等方法時就會觸發當前線程的wait()操作,并將當前線程掛起,等待Channel相關的操作完成。
所以,如果執行Channel相關操作的線程( 即,EventLoop所關聯線程,它們會調用notify()/notifyAll() )和執行ChannelFuture的await(*)、sync(*)、get(*) 等方法的線程( 即,調用wait()的線程 )是同一個線程時,就會發送死鎖了!!!
結合示例,因為通過「channel.eventLoop().schedule(Runnable command, long delay, TimeUnit unit)」提交的定時任務最終都將會在EventLoop所關聯的線程上得以執行,那么如果你在定時任務中又調用了await()這樣操作,就會發生上面說所的,掛起的線程和將會notify()/notifyAll()的線程會是同一個線程時,這就會造成死鎖。所以Netty在每次執行Object的await()操作去都會進行判斷,判斷當前的環境下調用object.await()是否會發送死鎖,如果檢測任務可能發生死鎖,則拋出BlockingOperationException異常,而不會真正的去執行object.await()操作而導致真的死鎖發生。
因此,總的來說,我們不應該在Channel所注冊到的EventLoop相關聯的線程為同一個線程上調用與該Channel關聯的ChannelFuture的sync* 或 await* 方法。好像很拗口。。簡單圖示如下:
Channel_A注冊到了EventLoop_A上:Channel_A —— 注冊 ——> EventLoop_A
ChannelFuture_A表示Channel_A的一個異步操作:ChannelFuture_A —— 關聯 ——> Channel_A
那么不能再EventLoop_A 上執行 ChannelFuture_A的await(*)、sync(*)、get(*) 等方法。
同時建議,不在ChannelFuture中調用await(*)、sync(*)、get(*) 等方法來獲取操作的結果;而是使用注冊Listener的方法,通過回調函數來獲取操作結果。
不要阻塞EventLoop線程
第二個注釋說明:
client.connect().addListener((ChannelFuture future) -> {
if(future.isSuccess()) {
// ======= 說明 ========
// 這個 死循環 導致了走到了channelRegistered, 后面的channelActive流程就被它堵塞了,以至于沒往下走。。。
/*while (!readerExit) {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
future.channel().writeAndFlush(bufferedReader.readLine());
}*/
// ======= 說明 ========
// 業務邏輯代碼
......
}
}
發生這個問題的原因在于:
① 一個EventLoop在它的整個生命周期當中都只會與唯一一個Thread進行綁定。
② 所有由EventLoop所處理的各種I/O事件都將在它所關聯的那個Thread上進行處理。
③ 一個Channel在它的整個生命周期中只會注冊在一個EventLoop上。
④ ChannelFuture代表了一個Channel的異步操作,并且可以通過注冊ChannelFutureListener使得再Channel的異步操作結束后以回調的方式來獲取這個執行結果。
⑤ 值得注意的是:ChannelFutureListener的operationComplete方法是由I/O線程執行的。
因此,如果在client.connect()這個異步操作上注冊了一個ChannelFutureListener,而該ChannelFutureListener的operationComplete方法中卻執行了一個死循環,這會導致整個I/O線程就卡在了這個死循環上,而無法繼續執行Channel該有的其他流程,以及導致注冊到該EventLoop上所有的Channel操作都無法得以執行了。
因此,我們要注意,千萬不要阻塞這個I/O線程(即,EventLoop所關聯的線程),也不要在該線程上執行任何耗時的操作。我們應該將耗時的操作放到另外的一個線程池中去執行。
后記
本文主要對心跳機制進行了簡單介紹,并主要針對Netty下如何實現固定周期的心跳機制進行了深入的討論,同時結合示例對真實開發中很容易遇到的兩個Netty線程模式的問題進行了討論和說明。
參考
圣思園《精通并發與Netty》
https://github.com/jianfengye/doc_web/blob/master/linux/heartbeat.md
https://blog.coderzh.com/2015/03/05/WhyHeartBeatNeeded/
http://www.voidcn.com/article/p-wcfsgijn-bbx.html