在PHP中用協同程式實現合作多工

發表於2013-06-30

PHP 5.5 一個比較好的新功能是實現對生成器和協同程式的支援。對於生成器,PHP的文件和各種其他的部落格文章(就像這一篇這一篇)已經有了非常詳細的講解。協同程式相對受到的關注就少了,所以協同程式雖然有很強大的功能但也很難被知曉,解釋起來也比較困難。

這篇文章指導你通過使用協同程式來實施任務排程,通過例項實現對技術的理解。我將在前三節做一個簡單的背景介紹。如果你已經有了比較好的基礎,可以直接跳到“協同多工處理”一節。

生成器

生成器最基本的思想也是一個函式,這個函式的返回值是依次輸出,而不是隻返回一個單獨的值。或者,換句話說,生成器使你更方便的實現了迭代器介面。下面通過實現一個xrange函式來簡單說明:

上面這個xrange()函式提供了和PHP的內建函式range()一樣的功能。但是不同的是range()函式返回的是一個包含屬組值從1到100萬的陣列(注:請檢視手冊)。而xrange()函式返回的是依次輸出這些值的一個迭代器,而且並不會真正以陣列形式計算。

這種方法的優點是顯而易見的。它可以讓你在處理大資料集合的時候不用一次性的載入到記憶體中。甚至你可以處理無限大的資料流。

當然,也可以不同通過生成器來實現這個功能,而是可以通過繼承Iterator介面實現。通過使用生成器實現起來會更方便,而不用再去實現iterator介面中的5個方法了。

生成器為可中斷的函式

要從生成器認識協同程式,理解它們內部是如何工作的非常重要:生成器是可中斷的函式,在它裡面,yield構成了中斷點。

緊接著上面的例子,如果你呼叫xrange(1,1000000)的話,xrange()函式裡程式碼沒有真正地執行。相反,PHP只是返回了一個實現了迭代器介面的 生成器類例項:

你對某個物件呼叫迭代器方法一次,其中的程式碼執行一次。例如,如果你呼叫$range->rewind(),那麼xrange()裡的程式碼執行到控制流 第一次出現yield的地方。在這種情況下,這就意味著當$i=$start時yield $i才執行。傳遞給yield語句的值是使用$range->current()獲取的。

為了繼續執行生成器中的程式碼,你必須呼叫$range->next()方法。這將再次啟動生成器,直到yield語句出現。因此,連續呼叫next()和current()方法 你將能從生成器裡獲得所有的值,直到某個點沒有再出現yield語句。對xrange()來說,這種情形出現在$i超過$end時。在這中情況下, 控制流將到達函式的終點,因此將不執行任何程式碼。一旦這種情況發生,vaild()方法將返回假,這時迭代結束。

協程

協程給上面功能新增的主要東西是回送資料給生成器的能力。這將把生成器到呼叫者的單向通訊轉變為兩者之間的雙向通訊。

通過呼叫生成器的send()方法而不是其next()方法傳遞資料給協程。下面的logger()協程是這種通訊如何執行的例子:

正如你能看到,這兒yield沒有作為一個語句來使用,而是用作一個表示式。即它有一個返回值。yield的返回值是傳遞給send()方法的值。 在這個例子裡,yield將首先返回”Foo”,然後返回”Bar”。

上面的例子裡yield僅作為接收者。混合兩種用法是可能的,即既可接收也可傳送。接收和傳送通訊如何進行的例子如下:

馬上理解輸出的精確順序有點困難,因此確定你知道為什按照這種方式輸出。我願意特別指出的有兩點:第一點,yield表示式兩邊使用 圓括號不是偶然。由於技術原因(雖然我已經考慮為賦值增加一個異常,就像Python那樣),圓括號是必須的。第二點,你可能已經注意到 呼叫current()之前沒有呼叫rewind()。如果是這麼做的,那麼已經隱含地執行了rewind操作。

多工協作

如果閱讀了上面的logger()例子,那麼你認為“為了雙向通訊我為什麼要使用協程呢? 為什麼我不能只用常見的類呢?”,你這麼問完全正確。上面的例子演示了基本用法,然而上下文中沒有真正的展示出使用協程的優點。這就是列舉許多協程例子的理由。正如上面介紹裡提到的,協程是非常強大的概念,不過這樣的應用很稀少而且常常十分複雜。給出一些簡單而真實的例子很難。

在這篇文章裡,我決定去做的是使用協程實現多工協作。我們盡力解決的問題是你想併發地執行多工(或者“程式”)。不過處理器在一個時刻只能執行一個任務(這篇文章的目標是不考慮多核的)。因此處理器需要在不同的任務之間進行切換,而且總是讓每個任務執行 “一小會兒”。

 

多工協作這個術語中的“協作”說明了如何進行這種切換的:它要求當前正在執行的任務自動把控制傳回給排程器,這樣它就可以執行其他任務了。這與“搶佔”多工相反,搶佔多工是這樣的:排程器可以中斷執行了一段時間的任務,不管它喜歡還是不喜歡。協作多工在Windows的早期版本(windows95)和Mac OS中有使用,不過它們後來都切換到使用搶先多工了。理由相當明確:如果你依靠程式自動傳回 控制的話,那麼壞行為的軟體將很容易為自身佔用整個CPU,不與其他任務共享。

這個時候你應當明白協程和任務排程之間的聯絡:yield指令提供了任務中斷自身的一種方法,然後把控制傳遞給排程器。因此協程可以執行多個其他任務。更進一步來說,yield可以用來在任務和排程器之間進行通訊。

我們的目的是 對 “任務”用更輕量級的包裝的協程函式:

一個任務是用 任務ID標記一個協程。使用setSendValue()方法,你可以指定哪些值將被髮送到下次的恢復(在之後你會了解到我們需要這個)。 run()函式確實沒有做什麼,除了呼叫send()方法的協同程式。要理解為什麼新增beforeFirstYieldflag,需要考慮下面的程式碼片段:

通過新增 beforeFirstYieldcondition 我們可以確定 first yield 的值 被返回。

排程器現在不得不比多工迴圈要做稍微多點了,然後才執行多工:

newTask()方法(使用下一個空閒的任務id)建立一個新任務,然後把這個任務放入任務對映陣列裡。接著它通過把任務放入任務佇列裡來實現對任務的排程。接著run()方法掃描任務佇列,執行任務。如果一個任務結束了,那麼它將從佇列裡刪除,否則它將在佇列的末尾再次被排程。
讓我們看看下面具有兩個簡單(並且沒有什麼意義)任務的排程器:

兩個任務都僅僅回顯一條資訊,然後使用yield把控制回傳給排程器。輸出結果如下:

輸出確實如我們所期望的:對前五個迭代來說,兩個任務是交替執行的,接著第二個任務結束後,只有第一個任務繼續執行。

與排程器之間通訊

既然排程器已經執行了,那麼我們就轉向日程表的下一項:任務和排程器之間的通訊。我們將使用程式用來和作業系統會話的同樣的方式來通訊:系統呼叫。我們需要系統呼叫的理由是作業系統與程式相比它處在不同的許可權級別上。因此為了執行特權級別的操作(如殺死另一個程式),就不得不以某種方式把控制傳回給核心,這樣核心就可以執行所說的操作了。再說一遍,這種行為在內部是通過使用中斷指令來實現的。過去使用的是通用的int指令,如今使用的是更特殊並且更快速的syscall/sysenter指令。

我們的任務排程系統將反映這種設計:不是簡單地把排程器傳遞給任務(這樣久允許它做它想做的任何事),我們將通過給yield表示式傳遞資訊來與系統呼叫通訊。這兒yield即是中斷,也是傳遞資訊給排程器(和從排程器傳遞出資訊)的方法。

為了說明系統呼叫,我將對可呼叫的系統呼叫做一個小小的封裝:

它將像其他任何可呼叫那樣(使用_invoke)執行,不過它要求排程器把正在呼叫的任務和自身傳遞給這個函式。為了解決這個問題 我們不得不微微的修改排程器的run方法:

第一個系統呼叫除了返回任務ID外什麼都沒有做:

這個函式確實設定任務id為下一次傳送的值,並再次排程了這個任務。由於使用了系統呼叫,所以排程器不能自動呼叫任務,我們需要手工排程任務(稍後你將明白為什麼這麼做)。要使用這個新的系統呼叫的話,我們要重新編寫以前的例子:

 

這段程式碼將給出與前一個例子相同的輸出。注意系統呼叫同其他任何呼叫一樣正常地執行,不過預先增加了yield。要建立新的任務,然後再殺死它們的話,需要兩個以上的系統呼叫:

killTask函式需要在排程器裡增加一個方法:

用來測試新功能的微指令碼:

這段程式碼將列印以下資訊:

經過三次迭代以後子任務將被殺死,因此這就是”Child is still alive”訊息結束的時候。可能應當指出的是這不是真正的父子關係。 因為甚至在父任務結束後子任務仍然可以執行。或者子任務可以殺死父任務。可以修改排程器使它具有更層級化的任務結構,不過 在這篇文章裡我沒有這麼做。

你可以實現許多程式管理呼叫。例如 wait(它一直等待到任務結束執行時),exec(它替代當前任務)和fork(它建立一個 當前任務的克隆)。fork非常酷,而且你可以使用PHP的協程真正地實現它,因為它們都支援克隆。

然而讓我們把這些留給有興趣的讀者吧,我們去看下一個議題。

非阻塞IO

很明顯,我們的任務管理系統的真正很酷的應用是web伺服器。它有一個任務是在套接字上偵聽是否有新連線,當有新連線要建立的時候  ,它建立一個新任務來處理新連線。
web伺服器最難的部分通常是像讀資料這樣的套接字操作是阻塞的。例如PHP將等待到客戶端完成傳送為止。對一個WEB伺服器來說,這 根本不行;這就意味著伺服器在一個時間點上只能處理一個連線。

解決方案是確保在真正對套接字讀寫之前該套接字已經“準備就緒”。為了查詢哪個套接字已經準備好讀或者寫了,可以使用 流選擇函式。

首先,讓我們新增兩個新的 syscall,它們將等待直到指定 socket 準備好:

這些 syscall 只是在排程器中代理其各自的方法:

waitingForRead 及 waitingForWrite 屬性是兩個承載等待的socket 及等待它們的任務的陣列。有趣的部分在於下面的方法,它將檢查 socket 是否可用,並重新安排各自任務:

stream_select 函式接受承載讀取、寫入以及待檢查的socket的陣列(我們無需考慮最後一類)。陣列將按引用傳遞,函式只會保留那些狀態改變了的陣列元素。我們可以遍歷這些陣列,並重新安排與之相關的任務。

為了正常地執行上面的輪詢動作,我們將在排程器裡增加一個特殊的任務:

需要在某個地方註冊這個任務,例如,你可以在run()方法的開始增加$this->newTask($this->ioPollTask())。然後就像其他 任務一樣每執行完整任務迴圈一次就執行輪詢操作一次(這麼做一定不是最好的方法)。ioPollTask將使用0秒的超時來呼叫ioPoll, 這意味著stream_select將立即返回(而不是等待)。

只有任務佇列為空時,我們才使用null超時,這意味著它一直等到某個套介面準備就緒。如果我們沒有這麼做,那麼輪詢任務將一而再, 再而三的迴圈執行,直到有新的連線建立。這將導致100%的CPU利用率。相反,讓作業系統做這種等待會更有效。

現在編寫伺服器相對容易了:

這段程式碼將接收到localhost:8000上的連線,然後僅僅返回傳送來的內容作為HTTP響應。要做“實際”的事情的話就愛哪個非常複雜(處理 HTTP請求可能已經超出了這篇文章的範圍)。上面的程式碼片段只是演示了一般性的概念。

你可以使用類似於ab -n 10000 -c 100 localhost:8000/這樣命令來測試伺服器。這條命令將向伺服器傳送10000個請求,並且其中100個請求將同時到達。使用這樣的數目,我得到了處於中間的10毫秒的響應時間。不過還有一個問題:有少數幾個請求真正處理的很慢(如5秒), 這就是為什麼總吞吐量只有2000請求/秒(如果是10毫秒的響應時間的話,總的吞吐量應該更像是10000請求/秒)。調高併發數(比如 -c 500),伺服器大多數執行良好,不過某些連線將丟擲“連線被對方重置”的錯誤。由於我對低階別的socket資料瞭解的非常少,所以 我不能指出問題出在哪兒。

協程堆疊

如果你試圖用我們的排程系統建立更大的系統的話,你將很快遇到問題:我們習慣了把程式碼分解為更小的函式,然後呼叫它們。然而, 如果使用了協程的話,就不能這麼做了。例如,看下面程式碼:

這段程式碼試圖把重複迴圈“輸出n次“的程式碼嵌入到一個獨立的協程裡,然後從主任務裡呼叫它。然而它無法執行。正如在這篇文章的開始  所提到的,呼叫生成器(或者協程)將沒有真正地做任何事情,它僅僅返回一個物件。這也出現在上面的例子裡。echoTimes呼叫除了放回一個(無用的)協程物件外不做任何事情。

為了仍然允許這麼做,我們需要在這個裸協程上寫一個小小的封裝。我們將呼叫它:“協程堆疊”。因為它將管理巢狀的協程呼叫堆疊。 這將是通過生成協程來呼叫子協程成為可能:

使用yield,子協程也能再次返回值:

retval函式除了返回一個值的封裝外沒有做任何其他事情。這個封裝將表示它是一個返回值。

為了把協程轉變為協程堆疊(它支援子呼叫),我們將不得不編寫另外一個函式(很明顯,它是另一個協程):

這個函式在呼叫者和當前正在執行的子協程之間扮演著簡單代理的角色。在$gen->send(yield $gen->key()=>$value);這行完成了代理功能。另外它檢查返回值是否是生成器,萬一是生成器的話,它將開始執行這個生成器,並把前一個協程壓入堆疊裡。一旦它獲得了CoroutineReturnValue的話,它將再次請求堆疊彈出,然後繼續執行前一個協程。

為了使協程堆疊在任務裡可用,任務構造器裡的$this-coroutine =$coroutine;這行需要替代為$this->coroutine = StackedCoroutine($coroutine);。

現在我們可以稍微改進上面web伺服器例子:把wait+read(和wait+write和warit+accept)這樣的動作分組為函式。為了分組相關的 功能,我將使用下面類:

現在伺服器可以編寫的稍微簡潔點了:

錯誤處理

作為一個優秀的程式設計師,相信你已經察覺到上面的例子缺少錯誤處理。幾乎所有的 socket 都是易出錯的。我這樣做的原因一方面固然是因為錯誤處理的乏味(特別是 socket!),另一方面也在於它很容易使程式碼體積膨脹。

不過,我仍然了一講一下常見的協程錯誤處理:協程允許使用 throw() 方法在其內部丟擲一個錯誤。儘管此方法還未在 PHP 中實現,但我很快就會提交它,就在今天。

throw() 方法接受一個 Exception,並將其丟擲到協程的當前懸掛點,看看下面程式碼:

這非常棒,因為我們可以使用系統呼叫以及子協程呼叫異常丟擲。對與系統呼叫,Scheduler::run() 方法需要一些小調整:

Task 類也許要新增 throw 呼叫處理:

現在,我們已經可以在系統呼叫中使用異常丟擲了!例如,要呼叫 killTask,讓我們在傳遞 ID 不可用時丟擲一個異常:

試試看:

這些程式碼現在尚不能正常運作,因為 stackedCoroutine 函式無法正確處理異常。要修復需要做些調整:

結束語

在這篇文章裡,我使用多工協作構建了一個任務排程器,其中包括執行“系統呼叫”,做非阻塞操作和處理錯誤。所有這些裡真正很酷的事情是任務的結果程式碼看起來完全同步,甚至任務正在執行大量的非同步操作的時候也是這樣。如果你打算從套介面讀取資料的話,你將不需要傳遞某個回撥函式或者註冊一個事件偵聽器。相反,你只要書寫yield $socket->read()。這兒大部分都是你常常也要編寫的,只在它的前面增加yield。

當我第一次聽到所有這一切的時候,我發現這個概念完全令人折服,而且正是這個激勵我在PHP中實現了它。同時我發現協程真正令人心慌。在令人敬畏的程式碼和很大一堆程式碼之間只有單薄的一行,我認為協程正好處在這一行上。講講使用上面所述的方法書寫非同步程式碼是否真的有益對我來說很難。

無論如何,我認為這是一個有趣的話題,而且我希望你也能找到它的樂趣。歡迎評論:)

 

相關文章