網絡框架的選擇
C++語言里面有asio和libuv等網絡庫, 可以方便的進行各種高效編程. 但是C#里面, 情況不太一樣, C#自帶的網絡API有多種. 例如:
- Socket
- TcpStream(同步接口和BeginXXX異步接口)
- TcpStream Async/Await
- Pipeline IO
- ASP.NET Core Bedrock
眾多網絡庫, 但是每個編程模型都不太一樣, 和C++里面我常用的reactor模型有很大區別. 最重要的是, 編程難度和性能不是很好. 尤其是后面三種模型, 都是面對輕負載的互聯網應用設計, 每個玩家跑兩個協程(一讀一寫)會對進程造成額外的負擔.
Golang面世的時候, 大家都說協程好用, 簡單, 性能高. 可是面對大量 高頻交互的應用, 最終還是需要重新編寫網絡層(參見Gnet). 因為協程上下文切換需要消耗微秒左右的時間(通常是0.5us到1微秒左右), 另外有棧協程占用額外的內存(無棧協程不存在這個問題).
所以在C#里面需要選擇一個類似於Reactor模型的網絡庫. Java里面有Netty. 好在微軟把Netty移植到了.NET里面, 所以我們只需要照着Netty的文檔和DotNetty的Sample(包括源碼)就可以寫出高效的網絡框架.
另外DotNetty有libuv的插件, 可以將傳輸層放到libuv內, 減少托管語言的消耗.
DotNetty編程
由於我們是服務器編程, 需要處理多個Socket而不像客戶端只需要處理一兩個Socket, 所以在每個Socket上, 都需要做一些標記信息, 用來標記當前Socket的狀態(是否登錄, 用戶是哪個等等); 還需要一個管理維護的這些Socket的管理者類.
鏈接狀態
Socket的狀態可以使用IChannel.GetAttribute
來實現, 我們可以給IChannel上面增加一個SessionInfo
的屬性, 用來保存當前鏈接的其他可變屬性. 那么可以這么做:
public class SessionInfo { //SessionID不可變 private readonly long sessionID; public SessionInfo(long sessionID) { this.sessionID = sessionID; } //其他屬性 } static readonly AttributeKey<ConnectionSessionInfo> SESSION_INFO = AttributeKey<ConnectionSessionInfo>.ValueOf("SessionInfo"); //新鏈接 bootstrap.ChildHandler(new ActionChannelInitializer<IChannel>(channel => { var sessionInfo = new SessionInfo(++seed); channel.GetAttribute(SESSION_INFO).Set(sessionInfo); //其他參數 }));
由於游戲服務器通常是有狀態服務, 所以鏈接上還需要保存PlayerID, OpenID等信息, 方便解碼器在解碼的時候, 直接把消息派發給相應的處理器.
管理器和生命周期
托管語言有GC, 但是對於非托管資源還是需要手動管理. C#有IDisposable
模式, 可以簡化異常場景下資源釋放問題, 但是對於Socket這種生命周期比較長的資源就無能為力了.
所以, 我們必須要編寫自己的ChannelManager
類, 並且遵從:
- 新鏈接一定要立刻放到Manager里面
- 通過ID來獲取IChannel, 不做長時間持有
- 想要長時間持有, 則使用WeakReference
- MessageHandler的異常里面釋放Manager里面的IChannel
- 心跳超時也要釋放IChannel
對於IChannel對象的持有, 一定要是短時間的持有
, 比如在一次函數調用內獲取, 否則問題會變得很復雜.
防止主動關閉Socket和異常同時發生, IChannel.CloseAsync()
函數調用需要try catch.
參數調節
GameServer一般來講單個網絡線程就夠了, 但是作為網關是絕對不夠的, 所以網絡庫需要支持多線程Loop. 好在DotNetty這方面比較簡單, 只需要構造的時候改一下參數, 具體可以看看Sample, 托管和Libuv的傳輸層構造不一樣.
var bootstrap = new ServerBootstrap(); //1個boss線程, N個工作線程 bootstrap.Group(this.bossGroup, this.workerGroup); if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { //Linux下需要重用端口, 否則服務器立馬重啟會端口占用 bootstrap .Option(ChannelOption.SoReuseport, true) .ChildOption(ChannelOption.SoReuseaddr, true); } bootstrap .Channel<TcpServerChannel>() //Linux默認backlog只有128, 並發較高的時候新鏈接會連不上來 .Option(ChannelOption.SoBacklog, 1024) //跑滿一個網絡需要最少 帶寬*延遲 的滑動窗口 //移動網絡延遲比較高, 建議設置成64KB以上 //如果是內網通訊, 建議設置成128KB以上 .Option(ChannelOption.SoRcvbuf, 128 * 1024) .Option(ChannelOption.SoSndbuf, 128 * 1024) //將默認的內存分配器改成 內存池版本的分配器 //會占用較多的內存, 但是GC負擔比較小 //一個堆16M, 會占用多個堆 .Option(ChannelOption.Allocator, PooledByteBufferAllocator.Default) .ChildOption(ChannelOption.TcpNodelay, true) .ChildOption(ChannelOption.SoKeepalive, true) //開啟高低水位 .ChildOption(ChannelOption.WriteBufferLowWaterMark, 64 * 1024) .ChildOption(ChannelOption.WriteBufferHighWaterMark, 128 * 1024) .ChildHandler(new ActionChannelInitializer<IChannel>(channel => {
這里強調一下高低水位. 如果往一個Socket不停的發消息, 但是對端接收很慢, 那么正確的做法就是要把他T掉, 否則一直發下去, 服務器可能會內存不足. 這部分內存是無法GC的, 處理不當可能會被攻擊.
編解碼器和ByteBuffer的使用
DotNetty有封裝好的IByteBuffer類, 該類是一個Stream, 支持Mark/Reset/Read/Write. 和Netty不太一樣的是ByteBuffer類沒有大小端, 而是在接口上做了大小端處理.
對於一個解碼器, 大致的樣式是:
public static (int length, uint msgID, IByteBuffer bytes) DecodeOneMessage(IByteBuffer buffer) { if (buffer.ReadableBytes < MinPacketLength) { return (0, 0, null); } buffer.MarkReaderIndex(); //這只是示例代碼, 實際需要根據具體情況調整 var head = buffer.ReadUnsignedIntLE(); var msgID = buffer.ReadUnsignedIntLE(); var bodyLength = head & 0xFFFFFF; if (buffer.ReadableBytes < bodyLength) { buffer.ResetReaderIndex(); return (0, 0, null); } var bodyBytes = buffer.Allocator.Buffer(bodyLength); buffer.ReadBytes(bodyBytes, bodyLength); return (bodyLength + 4 + 4, msgID, bodyBytes); }
真實情況肯定要比這個復雜, 這里只是一個簡單的sample. 讀取消息因為需要考慮半包的存在, 所以需要ResetReaderIndex
, 在編碼的時候就不存在這個情況.
編碼的情況就要稍微簡單一些, 因為解碼可能包不完整, 但是編碼不會出現半個消息的情況, 所以在編碼初期就能知道整個消息的大小(也有部分序列化類型會不知道消息長度).
var allocator = PooledByteBufferAllocator.Default; var buffer = allocator.Buffer(Length); buffer.WriteIntLE(Header); buffer.WriteIntLE(MsgID); //xxx這邊寫body
用ByteBuffer編碼Protobuf
之所以這邊要單獨提出來, 是因為高性能的服務器編程, 需要榨干一些能榨干的東西(在力所能及的范圍內).
很多人做Protobuf IMessage
序列化的時候, 就是簡單的一句msg.ToByteArray()
. 如果服務器是輕負載服務器, 那么這么寫一點問題都沒有; 否則就會多產生一個byte[]數組對象. 這顯然不是我們想要的.
對於編碼器來講, 我們肯定是希望我給定一個預定的byte[], 你序列化的時候往這里面寫. 所以我們來研究一下Protobuf的消息序列化.
//反編譯的代碼 public static Byte[] ToByteArray(this IMessage message) { ProtoPreconditions.CheckNotNull(message, "message"); CodedOutputStream codedOutputStream = new CodedOutputStream(new Byte[message.CalculateSize()]); message.WriteTo(codedOutputStream); return (Byte[])codedOutputStream.CheckNoSpaceLeft(); }
通過代碼分析可以看出內部在使用CodedOutputStream
做編碼, 但是這個類的構造函數, 沒有支持Slice的重載. 通過dnSpy
反匯編發現有一個私有的重載:
private CodedOutputStream(byte[] buffer, int offset, int length) { this.output = null; this.buffer = buffer; this.position = offset; this.limit = offset + length; this.leaveOpen = true; }
這就是我們所需要的接口, 有了這個接口就可以在ByteBuffer上面先申請好內存, 然后在寫到ByteBuffer上, 減少了一次拷貝
和內存申請
操作, 主要是對GC的壓力會減輕不少.
這邊給出示意代碼:
var messageLength = msg.CalculateSize(); var buffer = allocator.Buffer(messageLength); ArraySegment<byte> data = buffer.GetIoBuffer(buffer.WriterIndex, messageLength); //這邊需要通過反射去調用CodedOutputStream對象的私有構造函數 //具體可以研究一下 using var stream = createCodedOutputStream(data.Array, data.Offset, messageLength); msg.WriteTo(stream); stream.Flush();
至此, 我們就實現了高效的編碼和解碼器.
網絡小包的處理
小包處理的一般思路不外乎合批, 合批壓縮. 后者實現的難度要稍微高一點. 主要是游戲的流量還沒有高到每一幀都會發送超過幾百字節(小於128Byte的包壓縮起來效果沒那么好).
所以, 只有登錄的時候, 服務器把玩家的幾十K到上百K數據發送給客戶端的時候, 壓縮的時候才有效果; 平時只需要合批就可以了.
合批還能解決另外一個問題, 就是網卡PPS的瓶頸. 雖然是千兆網, 但是PPS一般都是在60W~100Wpps這個范圍. 意味着一味的發小包, 一秒最多收發60W到100W個小包, 所以需要通過合批來突破PPS的瓶頸.
這是騰訊雲SA2機型PPS的數據:
DotNetty中合批的兩種實現方式. 先說第一種.
DotNetty發送消息有兩個API:
- WriteAsync
- WriteAndFlushAsync 其中第一個API只是把ByteBuffer塞到Channel要發送的隊列里面去, 第二個API塞到隊列里面去還會觸發真正的Send操作.
比如說我們要發送4個消息, 那么可以先:
//queue是一個List<IMessage> for(int i = 0; i < queue.Count; ++i) { if ((i + 1) % 4 == 0) { channel.WriteAndAsync(queue[i]); } else { channel.WriteAsync(queue[i]); } } channel.Flush();
然后我們研究DotNetty的源碼, 發現他底層實現也是調用發送一個List的API, 那么就可以達到我們想要的效果.
還有一種方式, 就是把想要發送的消息攢一攢, 通過Allocter New一個更大的Buffer, 然后把這些消息全部塞進去, 再一次性發出去. 彩虹聯萌服務器用的就是這種方式, 大概10ms主動發送一次.
DotNetty的缺點
與其說是DotNetty的缺點, 不如說是所有托管內存語言的缺點. 所有托語言申請和釋放資源的開銷是不固定的, 這是IO密集型應用面臨的巨大挑戰.
在C++/Rust帶有RAII的語言里面, 申請一塊Buffer和釋放一塊Buffer的消耗都是比較固定的. 比如New一塊內存大概是25ns, Delete一塊大概是30~50ns.
但是在托管內存語言里面, New一塊內存大概25ns, Delete就不一定了. 因為你不能手動Delete, 只能靠GC來Delete. 但是GC釋放資源的時候, 會有Stop. 不管是並行GC還是非並行GC, 只是Stop時間的長短.
只有消除GC之后, 程序才會跑得非常快, 和Benchmark Game內跑的一樣快.
所以, 為了避免這個問題, 需要:
-
把IO和計算分開
這就是傳統游戲服務器把Gateway和GameServer分開的好處. IO密集在Gateway, GC Stop對GameServer影響不大, 對玩家收發消息影響也不大.
-
把IO放到C++/Rust里面去
這不是奇思妙想, 是大家都這么做. 例如ASP.NET Core就用libuv當做傳輸層.
所以對於游戲服務器來講, 可以在C++/Rust內實現傳輸層, 然后通過P/Invoke來和Native層通訊, 降低IO不斷分配內存對計算部分的影響.
-
將程序改造成Alloc Free
如果我不分配對象, 就不會有GC, 也就不會對計算有影響. 這也是筆者才彩虹聯萌服務器內做的事情.
Alloc Free是我自己造的詞匯, 類似於Lock Free. 但是不是說不分配任何內存, 只是把高頻分配降低了, 低頻分配還是允許的, 否則代碼會非常難寫.
參考: