skynet--lua層消息處理機制

lua層消息處理機制

  1. 協程的概念
    在討論lua層的消息處理機制之前,首先要了解一個概念,協程。協程可以視爲程序的執行單位,和線程不同,線程是搶佔式的,多條線程是並行時運行的,而協程則不是,協程是協同式的,比如有三個協程按順序先後創建coA、coB、coC,那麼在沒有任意一條協程主動掛起(yield)的情況下,執行順序則是coA執行完,在執行coB,然後再執行coC。也就是說,除非有協程主動要求掛起,否則必須等當前協程執行完,再去執行下面一個創建的協程。比如說,coA執行完,接着就是執行coB,此時coB掛起,那麼直接執行coC,coC執行完以後,如果coB被喚醒了,則接着上次開始阻塞的部分繼續執行餘下的邏輯。維基百科對協程的定義如下:

引證來源:https://en.wikipedia.org/wiki/Thread_(computing) 【Processes, kernel threads, user threads, and fibers】這一節
Fibers are an even lighter unit of scheduling which are cooperatively scheduled: a running fiber must explicitly "yield" to allow another fiber to run, which makes their implementation much easier than kernel or user threads. A fiber can be scheduled to run in any thread in the same process. This permits applications to gain performance improvements by managing scheduling themselves, instead of relying on the kernel scheduler (which may not be tuned for the application). Parallel programming environments such as OpenMP typically implement their tasks through fibers. Closely related to fibers are coroutines, with the distinction being that coroutines are a language-level construct, while fibers are a system-level construct.

如上所示,協程和纖程十分相似(纖程是線程下的執行單位),區別在於,纖程是操作系統實現的,而協程是語言本身提供。

  1. 協程的使用
    這裏引用一篇文檔加以說明:

引證來源:http://cloudwu.github.io/lua53doc/manual.html#2.6
Lua 支持協程,也叫 協同式多線程。 一個協程在 Lua 中代表了一段獨立的執行線程。 然而,與多線程系統中的線程的區別在於, 協程僅在顯式調用一個讓出(yield)函數時才掛起當前的執行。

調用函數 coroutine.create 可創建一個協程。 其唯一的參數是該協程的主函數。 create 函數只負責新建一個協程並返回其句柄 (一個 thread 類型的對象); 而不會啓動該協程。

調用 coroutine.resume 函數執行一個協程。 第一次調用 coroutine.resume 時,第一個參數應傳入 coroutine.create 返回的線程對象,然後協程從其主函數的第一行開始執行。 傳遞給 coroutine.resume 的其他參數將作爲協程主函數的參數傳入。 協程啓動之後,將一直運行到它終止或 讓出。

協程的運行可能被兩種方式終止: 正常途徑是主函數返回 (顯式返回或運行完最後一條指令); 非正常途徑是發生了一個未被捕獲的錯誤。 對於正常結束, coroutine.resume 將返回 true, 並接上協程主函數的返回值。 當錯誤發生時, coroutine.resume 將返回 false 與錯誤消息。

通過調用 coroutine.yield 使協程暫停執行,讓出執行權。 協程讓出時,對應的最近 coroutine.resume 函數會立刻返回,即使該讓出操作發生在內嵌函數調用中 (即不在主函數,但在主函數直接或間接調用的函數內部)。 在協程讓出的情況下, coroutine.resume 也會返回 true, 並加上傳給 coroutine.yield 的參數。 當下次重啓同一個協程時, 協程會接着從讓出點繼續執行。 此時,此前讓出點處對 coroutine.yield 的調用 會返回,返回值爲傳給 coroutine.resume 的第一個參數之外的其他參數。

與 coroutine.create 類似, coroutine.wrap 函數也會創建一個協程。 不同之處在於,它不返回協程本身,而是返回一個函數。 調用這個函數將啓動該協程。 傳遞給該函數的任何參數均當作 coroutine.resume 的額外參數。 coroutine.wrap 返回 coroutine.resume 的所有返回值,除了第一個返回值(布爾型的錯誤碼)。 和 coroutine.resume 不同, coroutine.wrap 不會捕獲錯誤; 而是將任何錯誤都傳播給調用者。

下面的代碼展示了一個協程工作的範例:

function foo (a)
   print("foo", a)
   return coroutine.yield(2*a)
 end
 
 co = coroutine.create(function (a,b)
       print("co-body", a, b)
       local r = foo(a+1)
       print("co-body", r)
       local r, s = coroutine.yield(a+b, a-b)
       print("co-body", r, s)
       return b, "end"
 end)
 
 print("main", coroutine.resume(co, 1, 10))
 print("main", coroutine.resume(co, "r"))
 print("main", coroutine.resume(co, "x", "y"))
 print("main", coroutine.resume(co, "x", "y"))

當你運行它,將產生下列輸出:

co-body 1       10
 foo     2
 main    true    4
 co-body r
 main    true    11      -9
 co-body x       y
 main    true    10      end
 main    false   cannot resume dead coroutine

你也可以通過 C API 來創建及操作協程: 參見函數 lua_newthread, lua_resume, 以及 lua_yield。

這裏對lua協程的代碼使用,做了充分的說明,對我們理解lua層消息派發十分有幫助

  1. skynet消息處理機制
    在前文,我們已經說明了,一個lua服務在接收消息時,最終會傳給lua層的消息回調函數skynet.dispatch_message
-- skynet.lua
function skynet.dispatch_message(...)
	local succ, err = pcall(raw_dispatch_message,...)
	while true do
		local key,co = next(fork_queue)
		if co == nil then
			break
		end
		fork_queue[key] = nil
		local fork_succ, fork_err = pcall(suspend,co,coroutine.resume(co))
		if not fork_succ then
			if succ then
				succ = false
				err = tostring(fork_err)
			else
				err = tostring(err) .. "\n" .. tostring(fork_err)
			end
		end
	end
	assert(succ, tostring(err))
end

消息處理函數,只做兩件事情,一件是消費當前消息,另一件則是按順序執行之前通過調用skynet.fork創建的協程,這裏我麼只關注處理當前消息的情況raw_dispatch_message

-- skynet.lua
local function raw_dispatch_message(prototype, msg, sz, session, source, ...)
	-- skynet.PTYPE_RESPONSE = 1, read skynet.h
	if prototype == 1 then
        ... -- 暫不討論,直接忽略
	else
		local p = proto[prototype]    -- 找到與消息類型對應的解析協議
		if p == nil then
			if session ~= 0 then
				c.send(source, skynet.PTYPE_ERROR, session, "")
			else
				unknown_request(session, source, msg, sz, prototype)
			end
			return
		end
		local f = p.dispatch  -- 獲取消息處理函數,可以視爲該類協議的消息回調函數
		if f then
			local ref = watching_service[source]
			if ref then
				watching_service[source] = ref + 1
			else
				watching_service[source] = 1
			end
			local co = co_create(f)   -- 如果協程池內有空閒的協程,則直接返回,否則創建一個新的協程,該協程用於執行該類協議的消息處理函數dispatch
			session_coroutine_id[co] = session
			session_coroutine_address[co] = source
			suspend(co, coroutine.resume(co, session,source, p.unpack(msg,sz, ...)))  -- 啓動並執行協程,將結果返回給suspend
		else
			unknown_request(session, source, msg, sz, proto[prototype].name)
		end
	end
end

消息處理的分爲兩種情況,一種是其他服務send過來的消息,還有一種就是自己發起同步rpc調用(調用call)後,獲得的返回結果(返回消息的類型是PTYPE_RESPONSE)。關於call的情況,後面會詳細討論,現在只討論如何處理其他服務send過來的消息。
整個執行的流程如下所示:

  • 根據消息的類型,找到對應的先前註冊好的消息解析協議
  • 獲取一個協程(如果協程池中有空閒的協程,則直接獲取,否則重新創建一個),並讓該協程執行消息處理協議的回調函數dispatch
  • 啓動並執行協程,將協程執行的結果返回給suspend函數,返回結果,就是一個coroutine掛起的原因,這個suspend函數,就是針對coroutine掛起的不同原因,做專門的處理

這裏對協程的複用,做一些小小的說明,創建協程的函數,非常有意思,爲了進一步提高性能,skynet對協程做了緩存,也就是說,一個協程在使用完以後,並不是讓他結束掉,而是把上一次使用的dispatch函數清掉,並且掛起協程,放入一個協程池中,供下一次調用。下次使用時,他將執行新的dispatch函數,只有當協程池中沒有協程時,纔會去創建新協程,如此循環往復

-- skynet.lua
local function co_create(f)
	local co = table.remove(coroutine_pool)
	if co == nil then  -- 協程池中,再也找不到可以用的協程時,將重新創建一個
		co = coroutine.create(function(...)
			f(...)  -- 執行回調函數,創建協程時,並不會立即執行,只有調用coroutine.resume時,纔會執行內部邏輯,這行代碼,只有在首次創建時會被調用
			
			-- 回調函數執行完,協程本次調用的使命就完成了,但是爲了實現複用,這裏不能讓協程退出,而是將
			-- upvalue回調函數f賦值爲空,再放入協程緩存池中,並且掛起,以便下次使用
			while true do
				f = nil
				coroutine_pool[#coroutine_pool+1] = co
				
				f = coroutine_yield "EXIT"  -- 1
				f(coroutine_yield())  -- 2
			end
		end)
	else
		coroutine.resume(co, f)  -- 喚醒第(1)處代碼,並將新的回調函數,賦值給(1)處的upvalue f函數,此時在第(2)個yield處掛起
	end
	return co
end

local function raw_dispatch_message(prototype, msg, sz, session, source, ...)
    ...
    -- 如果是創建後第一次使用這個coroutine,這裏的coroutine.resume函數,將會喚醒該coroutine,並將第二個至最後一個參數,傳給運行的函數
    -- 如果是一個複用中的協程,那麼這裏的coroutine.resume會將第二個至最後一個參數,通過第(2)處的coroutine_yield返回給消息回調函數
    suspend(co, coroutine.resume(co, session,source, p.unpack(msg,sz, ...)))
    ...
end

上面的邏輯在完成回調函數調用後,會對協程進行回收,它會將回調函數清掉,並且將當前協程寫入協程緩存列表中,然後掛起協程,掛起類型爲“EXIT”,如上面的代碼所示,對掛起類型進行處理的函數是suspend函數,當一個協程結束時,會進行如下操作

-- skynet.lua
function suspend(co, result, command, param, size)
...
	elseif command == "EXIT" then
		-- coroutine exit
		local address = session_coroutine_address[co]
		release_watching(address)
		session_coroutine_id[co] = nil
		session_coroutine_address[co] = nil
		session_response[co] = nil
...
end

其實這裏是將與本協程關聯的數據清空,包括向本服務發送消息的服務的地址,session,以及本服務對請求服務返回消息的確認信息。在lua層處理一條消息,本質上是在一個協程裏進行的,因此要以協程句柄作爲key,保存這些變量。協程每次暫停,都需要使用或處理這些數據,並告知當前協程的狀態,以及要根據不同的狀態做出相應的處理邏輯,比如當一個協程使用完畢時,就會掛起,並返回“EXIT”類型,意味着協程已經和之前的消息無關係了,需要清空與本協程關聯的所有消息相關的信息,以便下一條消息使用。
協程發起一次同步RPC調用(掛起狀態類型爲“CALL”),或者投入睡眠時(掛起狀態類型爲“SLEEP”),也會使自己掛起,此時要爲當前的協程分配一個唯一的session變量,並且以session爲key,協程地址爲value存入一個table表中,目的是,當對方返回結果,或者定時器到達時間timer線程向本服務發送一個喚醒原來協程的消息時,能夠通過session找到對應的協程,並將其喚醒,從之前掛起的地方繼續執行下去。
當一個服務向本服務發起一次call調用時,本服務需要返回一個結果變量給請求者,此時也需要將本協程掛起,向請求者返回結果時,需要調用如下接口

-- skynet.lua
function skynet.ret(msg, sz)
	msg = msg or ""
	return coroutine_yield("RETURN", msg, sz) -- 1
end

function suspend(co, result, command, param, size)
...
	elseif command == "RETURN" then
		local co_session = session_coroutine_id[co]
		local co_address = session_coroutine_address[co]
		if param == nil or session_response[co] then
			error(debug.traceback(co))
		end
		session_response[co] = true
		local ret
		if not dead_service[co_address] then
			ret = c.send(co_address, skynet.PTYPE_RESPONSE, co_session, param, size) ~= nil
			if not ret then
				-- If the package is too large, returns nil. so we should report error back
				c.send(co_address, skynet.PTYPE_ERROR, co_session, "")
			end
		elseif size ~= nil then
			c.trash(param, size)
			ret = false
		end
		return suspend(co, coroutine.resume(co, ret)) -- 重新喚醒(1)處,此時skynet.ret返回
...
end

在調用skynet.ret以後,調用該接口的協程就會掛起,此時掛起的狀態類型是“RETURN”,這裏掛起的目的是,等待返回消息的邏輯處理完,再接着執行協程掛起處後面的邏輯。suspend裏所做的處理,也就是,將消息插入目的服務的次級消息隊列中,然後再喚醒已經掛起的協程。

  1. 對其他服務的call訪問
    一個服務,向另一個服務發起同步rpc調用,首先要掛起當前協程,然後是將目的服務發送一個消息,並且在本地記錄一個唯一的session值,並以其爲key,以掛起的協程地址爲value存入一個table中,當目標服務返回結果時,根據這個session找回對應的協程,並且調用resume函數喚醒他。
-- skynet.lua
local function yield_call(service, session)
	watching_session[session] = service
	local succ, msg, sz = coroutine_yield("CALL", session)
	watching_session[session] = nil
	if not succ then
		error "call failed"
	end
	return msg,sz
end

function skynet.call(addr, typename, ...)
	local p = proto[typename]
	local session = c.send(addr, p.id , nil , p.pack(...))
	if session == nil then
		error("call to invalid address " .. skynet.address(addr))
	end
	return p.unpack(yield_call(addr, session))
end

function suspend(co, result, command, param, size)
...
	if command == "CALL" then
		session_id_coroutine[param] = co
...
end

local function raw_dispatch_message(prototype, msg, sz, session, source, ...)
	-- skynet.PTYPE_RESPONSE = 1, read skynet.h
	if prototype == 1 then
		local co = session_id_coroutine[session]
		if co == "BREAK" then
			session_id_coroutine[session] = nil
		elseif co == nil then
			unknown_response(session, source, msg, sz)
		else
			session_id_coroutine[session] = nil
			suspend(co, coroutine.resume(co, true, msg, sz))
		end
	else
	...
	end

上面一段邏輯的流程如下所示:

  • 發起一個同步rpc調用,向目標服務的次級消息隊列插入一個消息
  • 掛起當前協程,yield_call裏的coroutine_yield("CALL", session)使得當前協程掛起,並在此時suspend執行記錄session爲key,協程地址爲value,將其寫入一個table session_id_coroutine中,此時協程等待對方返回消息
  • 當目標服務返回結果時,先根據session找到先前掛起的協程地址,然後通過resume函數喚醒他,此時call返回結果,一次同步rpc調用就結束了。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章