Lua 中避免低效解析 TCP 網絡數據包體的一種方式

TCP 是流式協議,發送方發送出的是字節流,接收方接收到的也是字節流數據。通常,在應用層都會通過 header + body 在字節流中標識出單個協議包。發送方將原始數據打包成 header + body 。header 是固定字節數包頭,標識 body 包含了多少字節數據。接收方先讀固定字節數 header ,然後根據 header 讀出具體的 body 數據。
在遊戲中,總會需要編寫一些和服務器通信的機器人客戶端。我們項目會習慣採用 Lua 來實現,就不可避免的解析 TCP 網絡數據。邏輯很簡單,通常採用字符串連接的方式幾行代碼就可以完成。完整代碼點擊這裏 ,下面列出主要的代碼片段。

function mt:init(header_bytes)
    self.cache = ""
    self.header_bytes = header_bytes
end

function mt:input(str)
    self.cache = self.cache .. str
end

function mt:output()
    local hb = self.header_bytes
    local total = #self.cache
    if total <= hb then
        return
    end

    local body_bytes = string.unpack(">I2", self.cache)
    if hb + body_bytes > total then
        return
    end

    local body = self.cache:sub(hb + 1, hb + body_bytes)
    self.cache = self.cache:sub(hb + body_bytes + 1)
    return body
end

input 函數用於緩存收到的數據,output 函數用於將接收到的字節流解析成單個協議數據包。inputoutput 涉及的字符串操作在調用比較頻繁時效率會很低。如果對工具的效率要求提高,便不再滿足需求。但是又想這個機器人儘量簡單,會先考慮用純 Lua 來解決這個問題。

上述方案的問題在於字符串連接效率比較低,在接收數據比較頻繁時,字符串操作佔用大量的 CPU 資源。於是新方案的思想就是儘量避免字符串連接,如下所示。

function mt:init(header_bytes)
    self.cache_list = {}
    self.total_size = 0
    self.header_bytes = header_bytes
    self.body_list = {}
end

function mt:input(str)
    local cache = self.cache_list
    local block = cache[#cache]

    if block and #block < self.header_bytes then
        cache[#cache] = block .. str
    else
        cache[#cache + 1] = str
    end

    self.total_size = self.total_size + #str
end

function mt:output()
    local body_list = self.body_list
    local cache_body = body_list[1]
    if cache_body then
        table.remove(body_list, 1)
        return cache_body
    end

    local total_str
    if #self.cache_list == 1 then
        total_str = self.cache_list[1]
    else
        total_str = table.concat(self.cache_list)
        self.cache_list = {total_str}
    end

    local hb = self.header_bytes
    local start_index = 1
    while true do
        if not total_str or #total_str < hb then
            break
        end

        if self.total_size <= hb then
            break
        end

        local header = total_str:sub(start_index, start_index + hb - 1)
        local body_bytes = string.unpack(">I2", header)
        if hb + body_bytes > self.total_size then
            break
        end

        self.total_size = self.total_size - hb - body_bytes

        local new_index = start_index + hb + body_bytes
        local body = total_str:sub(start_index + hb, new_index - 1)
        if cache_body then
            body_list[#body_list + 1] = body
        else
            cache_body = body
        end

        start_index = new_index
    end

    if start_index > 1 then
        self.cache_list = {total_str:sub(start_index)}
    end

    return cache_body
end

input 函數中不會進行字符串連接,而是把收到的數據保存到 self.cache_list 中。然後在 output 函數中一次盡最大可能解析協議數據,然後保存在 self.body_list 中,每次調用 output 時若 self.body_list 有數據,則直接返回這裏的數據即可。

測試方式見這裏。新的方式基本可以瞬間解析完 64M 數據。

最好是過一段時間調用一次 output 函數,這樣會更高效。手遊客戶端的幀率一般是 30 FPS 或 60 FPS 。所以完全可以 1/60 秒調用一次 output 函數,甚至 1/100 秒調用一次也可以。

具體使用時,需要先獲取完整的數據(位於 self.body_list )數組中,若沒有,則讀 socket ,然後添加到緩存中,再解析是否有收到了完整的數據,若沒有則 sleep 一小會兒,則嘗試。具體代碼如下。

function mt:read_packet()
    local packet
    while true do
        -- 嘗試獲取完整的數據
        packet = self.pack_obj:output(true)
        if packet then
            return packet
        end

        -- 讀 socket
        local buf, err = self.sock:read()
        if not buf or #buf == 0 then
            return nil, err
        end

        self.pack_obj:input(buf)
        -- 解析是否收到了完整的數據
        packet = self.pack_obj:output()
        if packet then
            break
        end
        Levent.sleep(0.01)
    end
end

一開始使用這段代碼時,沒有先嚐試獲取完整的數據,每次調用 read_packet 都會讀 socket ,當一次收到的數據量很大時,可能包含了多個完整的數據包,而此時還 read_packet ,若服務器沒有返回數據,則客戶端會一直等待 read_packet 返回,就會卡住。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章