[01] C#網路程式設計的最佳實踐

egmkang發表於2020-09-09

網路框架的選擇

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內跑的一樣快.

所以, 為了避免這個問題, 需要:

  1. 把IO和計算分開

    這就是傳統遊戲伺服器把Gateway和GameServer分開的好處. IO密集在Gateway, GC Stop對GameServer影響不大, 對玩家收發訊息影響也不大.

  2. 把IO放到C++/Rust裡面去

    這不是奇思妙想, 是大家都這麼做. 例如ASP.NET Core就用libuv當做傳輸層.

    所以對於遊戲伺服器來講, 可以在C++/Rust內實現傳輸層, 然後通過P/Invoke來和Native層通訊, 降低IO不斷分配記憶體對計算部分的影響.

  3. 將程式改造成Alloc Free

    如果我不分配物件, 就不會有GC, 也就不會對計算有影響. 這也是筆者才彩虹聯萌伺服器內做的事情.

    Alloc Free是我自己造的詞彙, 類似於Lock Free. 但是不是說不分配任何記憶體, 只是把高頻分配降低了, 低頻分配還是允許的, 否則程式碼會非常難寫.

參考:

  1. C# Socket
  2. TcpStream
  3. ASP.NET Core Bedrock
  4. Golang Gnet
  5. Netty
  6. DotNetty
  7. DotNetty Send
  8. C# Benchmark

相關文章