文章目錄
協程能夠實現一種協作式多線程。每個協程都等價於一個線程。一對yield-resume可以將執行權在不同線程之間切換。不過,與普通的多線程的不同,協程是非搶佔的。當一個協程正在運作時,是無法從外部停止它的。只有當協程顯式地要求時它纔會掛起執行。對於有些應用而言,這並沒有問題,而對於另外一些應用則不行。當不存在搶佔時,編程簡單得多。由於在程序中所有的線程間同步都是顯式的,所以我們無須爲線程同步問題抓狂,只需要確保一個協程只在它的臨界區之外調用yield即可。
不過,對於非搶佔式多線程來說,只要有一個線程調用了阻塞操作,整個程序在該操作完成前都會阻塞。對於很多應用來說,這種行爲是無法接受的,而這也正是導致許多程序員不把協程看作傳統多線程的一種實現的原因。
讓我們假設一個典型的多線程的場景:我們希望通過HTTP下載多個遠程文件。爲了下載多個遠程文件,我們必須先知道如何下載一個遠程文件。要下載一個文件,必須先打開一個到對應站點的連接,然後發送下載文件的請求,接收文件,最後關閉連接。在Lua語言中,可以按以下步驟來完成這項任務。首先,加載LuaSocket庫:
local socket = require "socket"
然後,定義主機和要下載的文件。在本例中,我們從Lua語言官網下載Lua5.3手冊:
host = "www.lua.org"
file = "/manual/5.3/manual.html"
接下來,打開一個TCP連接,連接到站點的80端口:
c = assert(socket.connect(host,80))
這步操作返回一個連接對象,可以用它來發送下載文件的請求:
local request = string.format("GET %s HTTP/1.0\r\nhost: %s\r\n\r\n",file,host)
c:send(request)
接下來,以1KB爲一塊讀取文件,並將每塊寫入到標準輸出中:
repeat
local s ,status,partial = c:receive(2^10)
io.write(s or partial)
until status == "closed"
函數receive要麼返回它讀取到的字符串,要麼在發生錯誤時返回nil外加錯誤碼及出錯前讀取到的內容。當主機關閉連接時,把輸入流中剩餘的內容打印出來,然後退出接收循環。
下載完文件後,關閉連接:
c:close()
既然我們知道了如何下載一個文件,那麼再回到下載多個文件的問題上。最簡單的做法是逐個地下載文件。不過,這種串行的做法太慢了,它只能在下載完一個文件後再下載一個文件。當讀取一個遠程文件時,程序把大部分的時間耗費在了等待數據到達上。更確切地說,程序將時間耗費在了對receive的阻塞調用上。因此,如果一個程序能夠同時並行下載所有文件的話,就會快很多。當一個連接沒有可用數據時,程序便可以從其他連接讀取數據。很明顯,協程爲構造這種併發下載的代碼結構提供了一種簡單的方式。我們可以爲每個下載任務創建一個新線程,當一個線程無可用數據時,它就可以將控制權傳遞給一個簡單的調度器,這個調度器再去調用其他的線程。
在用協程重寫程序前,我們先把之前下載的代碼重寫成一個函數。
示例 下載Web頁面的數據
function download (host,file)
local c = assert(socket.connect(host,80))
local count = 0
local request = string.format("GET %s HTTP/1.0\r\nhost:%s\r\n\r\n",file,host)
c:send(request)
while true do
local s ,status = receive(c)
count = count + #s
if status == "closed" then break end
end
c:close()
print(file,count)
end
由於我們對遠程文件的內容並不感興趣,所以不需要將文件內容寫入到標準輸出中,只要計算並輸出文件大小即可。
在新版本中,我們使用一個輔助函數receiver從連接接收數據。在串行的下載方式中,receive的代碼如下:
function receive(connection)
local s,status,partial = connection:receive(2^10)
return s or partial,status
end
在並行的實現中,這個函數在接收數據時不能阻塞。因此,在沒有足夠的可用數據時,該函數會掛起,如下:
function receive(connection)
connection:settimeout(0) --不阻塞
local s ,status,partial = connection:receive(2^10)
if status == "timeout" then
coroutine.yield(connection)
end
return s or partial,status
end
調用settimeout(0)是的後續所有對連接進行的操作不會則塞。如果返回狀態爲"timeout",就表示該操作在返回時還未完成。此時,線程就會掛起。傳遞給yield的非假參數通知調度器線程仍在執行任務中。請注意,即使在超時的情況下,連接也會返回超時前已讀取到的內容,也就是變量partial中的內容。
示例 調度器
tasks = {} -- 所有活躍任務的列表
function get (host,file)
local co = coroutine.wrap(function()
download(host,file)
end)
table.insert(tasks,co)
end
function dispatch ()
local i = 1
while true do
if tasks[i] == nil then
if tasks[1] == nil then
break
end
i = 1
end
local res = tasks[i]()
if not res then
table.remove(tasks,i)
else
i = i + 1
end
end
end
在tasks爲調度器保存着所有正在運行中的線程的列表。函數get保證每個下載任務運行在一個獨立的線程中。調度器本身主要就是一個循環,它遍歷所有的線程,逐個喚醒它們。調度器還必須在線程完成任務後,將該線程從列表中刪除。在所有線程都完成運行後,調度器停止循環。
最後,主程序創建所有需要的線程並調起調度器。例如,如果要從Lua官網下載幾個發行包,主程序可能如下:
get("www.lua.org","/ftp/lua-5.3.2.tar.gz")
get("www.lua.org","/ftp/lua-5.3.1.tar.gz")
get("www.lua.org","/ftp/lua-5.3.0.tar.gz")
get("www.lua.org","/ftp/lua-5.2.4.tar.gz")
get("www.lua.org","/ftp/lua-5.2.3.tar.gz")
dispatch()
在筆者的機器上,串行實現花了15秒下載到這些文件,而協程實現比串行實現快了三倍多。
儘管速度提高了,但最後一種實現還有很大的優化空間。當至少由一個線程有數據可讀取時不會有問題;然而,如果所有的線程都沒有數據可讀,調度程序就會陷入忙等待,不斷地從一個線程切換到另一個線程來檢查是否有數據可讀。這樣,會導致協程版的實現比串行版實現耗費多達3倍的CPU時間。
爲了避免這樣的情況,可以使用LuaSocket中的函數select,該函數允許程序阻塞直到一組套接字的狀態發生改變。要實現這種改動,只需要修改調度器即可。
示例 使用select的調度器
function dispatch()
local i = 1
local timedout = {}
while true do
if tasks[i] == nil then
if tasks[1] == nil then
break
end
i = 1
timedout ={}
end
local res = tasks[i]()
if not res then
table.remove(tasks,i)
else
i = i + 1
timeout[#timedout + 1] = res
if #timedout == #tasks then
socket.select(timedout)
end
end
end
end
在循環中,新的調度器將所有超時的連接收集到表timedout中。請記住,函數receive將這種超時的連接傳遞給yield,然後由resume返回。如果所有的連接均超時,那麼調度器調用select等待這些連接的狀態就會發生改變。這個最終的實現與上一個使用協程的實現一樣快。另外,由於它不會有忙等待,所以與串行實現耗費的CPU資源一樣多。