1-9、Lua協同程序

1-9、Lua協同程序



協同程序(coroutine)與多線程情況下的線程比較類似:有自己的堆棧,自己的局部變量,有自己的指令指針(IP,instruction pointer),與其它協同程序共享全局變量等很多信息。線程和協同程序的主要不同在於:在多處理器情況下,從概念上來講多線程程序同時運行多個線程;而協同程序是通過協作來完成,在任一指定時刻只有一個協同程序在運行,並且這個正在運行的協同程序只在必要時纔會被掛起。

協同是非常強大的功能,但是用起來也很複雜。如果你是第一次閱讀本章,某些例子可能會不大理解,不必擔心,可先繼續閱讀後面的章節,再回頭琢磨本章內容。

1、協同的基礎

Lua的所有協同函數存放於coroutine table中。create函數用於創建新的協同程序,其只有一個參數:一個函數,即協同程序將要運行的代碼。若一切順利,返回值爲thread類型,表示創建成功。通常情況下,create的參數是匿名函數:

co = coroutine.create(function ()
	print("hi")
end)

print(co)		--> thread: 0x8071d98

協同有三個狀態:掛起態(suspended)、運行態(running)、停止態(dead)。當我們創建協同程序成功時,其爲掛起態,即此時協同程序並未運行。我們可用status函數檢查協同的狀態:

print(coroutine.status(co))		--> suspended

函數coroutine.resume使協同程序由掛起狀態變爲運行態:

coroutine.resume(co)				--> hi

本例中,協同程序打印出"hi"後,任務完成,便進入終止態:

print(coroutine.status(co))		--> dead

當目前爲止,協同看起來只是一種複雜的調用函數的方式,真正的強大之處體現在yield函數,它可以將正在運行的代碼掛起,看一個例子:

co = coroutine.create(function ()
	for i=1,10 do
		print("co", i)
		coroutine.yield()
	end
end)

執行這個協同程序,程序將在第一個yield處被掛起:

coroutine.resume(co)				--> co   1
print(coroutine.status(co))		--> suspended

從協同的觀點看:使用函數yield可以使程序掛起,當我們激活被掛起的程序時,將從函數yield的位置繼續執行程序,直到再次遇到yield或程序結束。

coroutine.resume(co)		--> co   2
coroutine.resume(co)		--> co   3
...
coroutine.resume(co)		--> co   10
coroutine.resume(co)		-- prints nothing

上面最後一次調用時,協同體已結束,因此協同程序處於終止態。如果我們仍然希望激活它,resume將返回false和錯誤信息。

print(coroutine.resume(co))
		--> false   cannot resume dead coroutine

注意:resume運行在保護模式下,因此,如果協同程序內部存在錯誤,Lua並不會拋出錯誤,而是將錯誤返回給resume函數。

Lua中協同的強大能力,還在於通過resume-yield來交換數據。
第一個例子中只有resume,沒有yield,resume把參數傳遞給協同的主程序。

co = coroutine.create(function (a,b,c)
	print("co", a,b,c)
end)
coroutine.resume(co, 1, 2, 3)		--> co  1  2  3

第二個例子,數據由yield傳給resume。true表明調用成功,true之後的部分,即是yield的參數。

co = coroutine.create(function (a,b)
	coroutine.yield(a + b, a - b)
end)
print(coroutine.resume(co, 20, 10))	--> true  30  10

相應地,resume的參數,會被傳遞給yield。

co = coroutine.create (function ()
	print("co", coroutine.yield())
end)
coroutine.resume(co)
coroutine.resume(co, 4, 5)		--> co  4  5

最後一個例子,協同代碼結束時的返回值,也會傳給resume:

co = coroutine.create(function ()
	return 6, 7
end)
print(coroutine.resume(co))		--> true  6  7

我們很少在一個協同程序中同時使用多個特性,但每一種都有用處。

現在已大體瞭解了協同的基礎內容,在我們繼續學習之前,先澄清兩個概念:Lua的協同稱爲不對稱協同(asymmetric coroutines),指“掛起一個正在執行的協同函數”與“使一個被掛起的協同再次執行的函數”是不同的,有些語言提供對稱協同(symmetric coroutines),即使用同一個函數負責“執行與掛起間的狀態切換”。

有人稱不對稱的協同爲半協同,另一些人使用同樣的術語表示真正的協同,嚴格意義上的協同不論在什麼地方只要它不是在其他的輔助代碼內部的時候都可以並且只能使執行掛起,不論什麼時候在其控制棧內都不會有不可決定的調用。(However, other people use the same term semi-coroutine to denote a restricted implementation of coroutines, where a coroutine can only suspend its execution when it is not inside any auxiliary function, that is, when it has no pending calls in its control stack.)。只有半協同程序內部可以使用yield,python中的產生器(generator)就是這種類型的半協同。

與對稱的協同和不對稱協同的區別不同的是,協同與產生器的區別更大。產生器相對比較簡單,他不能完成真正的協同所能完成的一些任務。我們熟練使用不對稱的協同之後,可以利用不對稱的協同實現比較優越的對稱協同。

2、管道和過濾器

協同最具代表性的例子是用來解決生產者-消費者問題。假定有一個函數不斷地生產數據(比如從文件中讀取),另一個函數不斷的處理這些數據(比如寫到另一文件中),函數如下:

function producer ()
	while true do
		local x = io.read()		-- produce new value
		send(x)					-- send to consumer
	end
end

function consumer ()
	while true do
		local x = receive()		-- receive from producer
		io.write(x, "\n")			-- consume new value
	end
end

(例子中生產者和消費者都在不停的循環,修改一下使得沒有數據的時候他們停下來並不困難),問題在於如何使得receive和send協同工作。只是一個典型的誰擁有主循環的情況,生產者和消費者都處在活動狀態,都有自己的主循環,都認爲另一方是可調用的服務。對於這種特殊的情況,可以改變一個函數的結構解除循環,使其作爲被動的接受。然而這種改變在某些特定的實際情況下可能並不簡單。

協同爲解決這種問題提供了理想的方法,因爲調用者與被調用者之間的resume-yield關係會不斷顛倒。當一個協同調用yield時並不會進入一個新的函數,取而代之的是返回一個未決的resume的調用。相似的,調用resume時也不會開始一個新的函數而是返回yield的調用。這種性質正是我們所需要的,與使得send-receive協同工作的方式是一致的。receive喚醒生產者生產新值,send把產生的值送給消費者消費。

function receive ()
	local status, value = coroutine.resume(producer)
	return value
end

function send (x)
	coroutine.yield(x)
end

producer = coroutine.create( function ()
	while true do
		local x = io.read()		-- produce new value
		send(x)
	end
end)

這種設計下,開始時調用消費者,當消費者需要值時他喚起生產者生產值,生產者生產值後停止直到消費者再次請求。我們稱這種設計爲消費者驅動的設計。

我們可以使用過濾器擴展這個設計,過濾器指在生產者與消費者之間,可以對數據進行某些轉換處理。過濾器在同一時間既是生產者又是消費者,他請求生產者生產值並且轉換格式後傳給消費者,我們修改上面的代碼加入過濾器(給每一行前面加上行號)。完整的代碼如下:

function receive (prod)
	local status, value = coroutine.resume(prod)
	return value
end

function send (x)
	coroutine.yield(x)
end

function producer ()
	return coroutine.create(function ()
		while true do
			local x = io.read()		-- produce new value
			send(x)
		end
	end)
end

function filter (prod)
	return coroutine.create(function ()
		local line = 1
		while true do
			local x = receive(prod)	-- get new value
			x = string.format("%5d %s", line, x)
			send(x)		-- send it to consumer
			line = line + 1
		end
	end)
end

function consumer (prod)
	while true do
		local x = receive(prod)	-- get new value
		io.write(x, "\n")			-- consume new value
	end
end

可以調用:

p = producer()
f = filter(p)
consumer(f)

或者:

consumer(filter(producer()))

看完上面這個例子你可能很自然的想到UNIX的管道,協同是一種非搶佔式的多線程。管道的方式下,每一個任務在獨立的進程中運行,而協同方式下,每個任務運行在獨立的協同代碼中。管道在讀(consumer)與寫(producer)之間提供了一個緩衝,因此兩者相關的的速度沒有什麼限制,在上下文管道中這是非常重要的,因爲在進程間的切換代價是很高的。協同模式下,任務間的切換代價較小,與函數調用相當,因此讀寫可以很好的協同處理。

3、用作迭代器的協同

我們可以將循環的迭代器看作生產者-消費者模式的特殊的例子。迭代函數產生值給循環體消費。所以可以使用協同來實現迭代器。協同的一個關鍵特徵是它可以不斷顛倒調用者與被調用者之間的關係,這樣我們毫無顧慮的使用它實現一個迭代器,而不用保存迭代函數返回的狀態。

我們來完成一個打印一個數組元素的所有的排列來闡明這種應用。直接寫這樣一個迭代函數來完成這個任務並不容易,但是寫一個生成所有排列的遞歸函數並不難。思路是這樣的:將數組中的每一個元素放到最後,依次遞歸生成所有剩餘元素的排列。代碼如下:

function permgen (a, n)
	if n == 0 then
		printResult(a)
	else
		for i=1,n do

			-- put i-th element as the last one
			a[n], a[i] = a[i], a[n]

			-- generate all permutations of the other elements
			permgen(a, n - 1)

			-- restore i-th element
			a[n], a[i] = a[i], a[n]

		end
	end
end

function printResult (a)
	for i,v in ipairs(a) do
		io.write(v, " ")
	end
	io.write("\n")
end

permgen ({1,2,3,4}, 4)

有了上面的生成器後,下面我們將這個例子修改一下使其轉換成一個迭代函數:

  1. 第一步printResult 改爲 yield
    function permgen (a, n)
    if n == 0 then
    coroutine.yield(a)
    else
  2. 第二步,我們定義一個迭代工廠,修改生成器在生成器內創建迭代函數,並使生成器運行在一個協同程序內。迭代函數負責請求協同產生下一個可能的排列。
    function perm (a)
    local n = table.getn(a)
    local co = coroutine.create(function () permgen(a, n) end)
    return function () – iterator
    local code, res = coroutine.resume(co)
    return res
    end
    end
    這樣我們就可以使用for循環來打印出一個數組的所有排列情況了:
    for p in perm{“a”, “b”, “c”} do
    printResult§
    end
    –> b c a
    –> c b a
    –> c a b
    –> a c b
    –> b a c
    –> a b c
    perm函數使用了Lua中常用的模式:將一個對協同的resume的調用封裝在一個函數內部,這種方式在Lua非常常見,所以Lua專門爲此專門提供了一個函數coroutine.wrap。與create相同的是,wrap創建一個協同程序;不同的是wrap不返回協同本身,而是返回一個函數,當這個函數被調用時將resume協同。wrap中resume協同的時候不會返回錯誤代碼作爲第一個返回結果,一旦有錯誤發生,將拋出錯誤。我們可以使用wrap重寫perm:
function perm (a)
	local n = table.getn(a)
	return coroutine.wrap(function () permgen(a, n) end)
end

一般情況下,coroutine.wrap比coroutine.create使用起來簡單直觀,前者更確切的提供了我們所需要的:一個可以resume協同的函數,然而缺少靈活性,沒有辦法知道wrap所創建的協同的狀態,也沒有辦法檢查錯誤的發生。

4、非搶佔式多線程

如前面所見,Lua中的協同是一協作的多線程,每一個協同等同於一個線程,yield-resume可以實現在線程中切換。然而與真正的多線程不同的是,協同是非搶佔式的。當一個協同正在運行時,不能在外部終止他。只能通過顯示的調用yield掛起他的執行。對於某些應用來說這個不存在問題,但有些應用對此是不能忍受的。不存在搶佔式調用的程序是容易編寫的。不需要考慮同步帶來的bugs,因爲程序中的所有線程間的同步都是顯示的。你僅僅需要在協同代碼超出臨界區時調用yield即可。

對非搶佔式多線程來說,不管什麼時候只要有一個線程調用一個阻塞操作(blocking operation),整個程序在阻塞操作完成之前都將停止。對大部分應用程序而言,只是無法忍受的,這使得很多程序員離協同而去。下面我們將看到這個問題可以被有趣的解決。

看一個多線程的例子:我們想通過http協議從遠程主機上下在一些文件。我們使用Diego Nehab開發的LuaSocket庫來完成。我們先看下在一個文件的實現,大概步驟是打開一個到遠程主機的連接,發送下載文件的請求,開始下載文件,下載完畢後關閉連接。

第一,加載LuaSocket庫
require “luasocket”
第二,定義遠程主機和需要下載的文件名
host = “www.w3.org”
file = “/TR/REC-html32.html”
第三,打開一個TCP連接到遠程主機的80端口(http服務的標準端口)
c = assert(socket.connect(host, 80))
上面這句返回一個連接對象,我們可以使用這個連接對象請求發送文件
c:send(“GET " … file … " HTTP/1.0\r\n\r\n”)
receive函數返回他送接收到的數據加上一個表示操作狀態的字符串。當主機斷開連接時,我們退出循環。
第四,關閉連接
c:close()

現在我們知道了如何下載一個文件,下面我們來看看如何下載多個文件。一種方法是我們在一個時刻只下載一個文件,這種順序下載的方式必須等前一個文件下載完成後一個文件才能開始下載。實際上是,當我們發送一個請求之後有很多時間是在等待數據的到達,也就是說大部分時間浪費在調用receive上。如果同時可以下載多個文件,效率將會有很大提高。當一個連接沒有數據到達時,可以從另一個連接讀取數據。很顯然,協同爲這種同時下載提供了很方便的支持,我們爲每一個下載任務創建一個線程,當一個線程沒有數據到達時,他將控制權交給一個分配器,由分配器喚起另外的線程讀取數據。

使用協同機制重寫上面的代碼,在一個函數內:

function download (host, file)
	local c = assert(socket.connect(host, 80))
	local count = 0		-- counts number of bytes read
	c:send("GET " .. file .. " HTTP/1.0\r\n\r\n")
	while true do
		local s, status = receive©
		count = count + string.len(s)
		if status == "closed" then break end
	end
	c:close()
	print(file, count)
end

由於我們不關心文件的內容,上面的代碼只是計算文件的大小而不是將文件內容輸出。(當有多個線程下載多個文件時,輸出會混雜在一起),在新的函數代碼中,我們使用receive從遠程連接接收數據,在順序接收數據的方式下代碼如下:

function receive (connection)
	return connection:receive(2^10)
end

在同步接受數據的方式下,函數接收數據時不能被阻塞,而是在沒有數據可取時yield,代碼如下:

function receive (connection)
	connection:timeout(0)	-- do not block
	local s, status = connection:receive(2^10)
	if status == "timeout" then
		coroutine.yield(connection)
	end
	return s, status
end

調用函數timeout(0)使得對連接的任何操作都不會阻塞。當操作返回的狀態爲timeout時意味着操作未完成就返回了。在這種情況下,線程yield。非false的數值作爲yield的參數告訴分配器線程仍在執行它的任務。(後面我們將看到分配器需要timeout連接的情況),注意:即使在timeout模式下,連接依然返回他接受到直到timeout爲止,因此receive會一直返回s給她的調用者。

下面的函數保證每一個下載運行在自己獨立的線程內:

threads = {}		-- list of all live threads
function get (host, file)
	-- create coroutine
	local co = coroutine.create(function ()
		download(host, file)
	end)
	-- insert it in the list
	table.insert(threads, co)
end

代碼中table中爲分配器保存了所有活動的線程。

分配器代碼是很簡單的,它是一個循環,逐個調用每一個線程。並且從線程列表中移除已經完成任務的線程。當沒有線程可以運行時退出循環。

function dispatcher ()
	while true do
		local n = table.getn(threads)
		if n == 0 then break end		-- no more threads to run
		for i=1,n do
			local status, res = coroutine.resume(threads[i])
			if not res then	-- thread finished its task?
				table.remove(threads, i)
				break
			end
		end
	end
end

最後,在主程序中創建需要的線程調用分配器,例如:從W3C站點上下載4個文件:

host = "www.w3c.org"

get(host, "/TR/html401/html40.txt")
get(host, "/TR/2002/REC-xhtml1-20020801/xhtml1.pdf")
get(host, "/TR/REC-html32.html")
get(host,
	"/TR/2000/REC-DOM-Level-2-Core-20001113/DOM2-Core.txt")

dispatcher()		-- main loop

使用協同方式下,我的機器花了6s下載完這幾個文件;順序方式下用了15s,大概2倍的時間。
儘管效率提高了,但距離理想的實現還相差甚遠,當至少有一個線程有數據可讀取的時候,這段代碼可以很好的運行。否則,分配器將進入忙等待狀態,從一個線程到另一個線程不停的循環判斷是否有數據可獲取。結果協同實現的代碼比順序讀取將花費30倍的CPU時間。

爲了避免這種情況出現,我們可以使用LuaSocket庫中的select函數。當程序在一組socket中不斷的循環等待狀態改變時,它可以使程序被阻塞。我們只需要修改分配器,使用select函數修改後的代碼如下:

function dispatcher ()
	while true do
		local n = table.getn(threads)
		if n == 0 then break end		-- no more threads to run
		local connections = {}
		for i=1,n do
			local status, res = coroutine.resume(threads[i])
			if not res then	-- thread finished its task?
				table.remove(threads, i)
				break
			else	-- timeout
				table.insert(connections, res)
			end
		end
		if table.getn(connections) == n then
			socket.select(connections)
		end
	end
end

在內層的循環分配器收集連接表中timeout地連接,注意:receive將連接傳遞給yield,因此resume返回他們。當所有的連接都timeout分配器調用select等待任一連接狀態的改變。最終的實現效率和上一個協同實現的方式相當,另外,他不會發生忙等待,比起順序實現的方式消耗CPU的時間僅僅多一點點。

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