[Twisted] Part 19: I Thought I Wanted It But I Changed My Mind

本文由Dave的Part 19: I Thought I Wanted It But I Changed My Mind翻譯而成,你可以由Part 1開始閱讀這個系列的文章,也可以在這裡找到整個系列的目錄。

Introduction

Twisted是一個進行中的專案,Twisted的開發人員會定期的加入新的功能並擴展舊的功能。隨著Twisted 10.1.0的發布,開發人員為Deferred類別加入了一個新的能力-取消(cancellation),我們今天會來探討這個能力。

非同步程式設計將請求從回應中脫離,因而提升了一種新的可能性:在請求結果與取回結果之間,你可能決定你不再需要它了。想一想Part 14的詩歌代理伺服器。以下是代理伺服器的運作方式,至少第一次詩歌請求是這樣子:

  1. 來了一個詩歌的請求。
  2. 代理伺服器聯絡真的伺服器來取得詩歌。
  3. 一但詩歌下載完成,將它傳送給原來的用戶端。

這樣看起來都很好,但是如果用戶端在收到詩歌前中止了怎麼辦?也許它們一開始請求Paradise Lost的完整文本,後來決定它們真的想要的是Kojo的俳句。現在我們的代理伺服器正卡在下載前者,而慢速伺服器還需要一點時間。我們最好關閉這個連接,並且讓慢速伺服器回去睡覺。

回憶一下圖15,那張顯示同步程式控制的概念流程的圖。在那張圖中我們看到函式呼叫是由上而下,而例外是由下而上返回。如果我們想要取消同步函式的呼叫(而這只是假設),流程控制會與函式呼叫的方向相同,像圖38從高層程式碼到底層程式碼:
38.synchronous%2Bprogram%2Bflow%252C%2Bwith%2Bhypothetical%2Bcancellation.png-Part 19: I Thought I Wanted It But I Changed My Mind

圖38.有假設性取消的同步程式流程


當然,在同步程式中這是不可能的,因為直到底層程式碼操作完成前,高層程式碼根本不會恢復執行,在這個時間根本沒有東西可以取消。但是在非同步程式中,高層程式碼在底層程式碼完成前得到控制權,這至少提升了在底層請求完成之前取消它的可能性。

在Twisted程式中,底層請求是由Deferred物件來具體化的,你可以想像它是未完成的非同步操作的「handle」。在deferred實例中訊息的一般流程是向下的,從底層程式碼到高層程式碼,這與同步程式中的回傳訊息流程一致。從Twisted 10.1.0開始,高層程式碼可以向反方向傳送訊息-這樣可以告訴底層程式碼它不在需要底層程式碼的結果了。看看圖39:
39.Information%2Bflow%2Bin%2Ba%2Bdeferred%252C%2Bincluding%2Bcancellation.png-Part 19: I Thought I Wanted It But I Changed My Mind

圖39.在deferred實例中的訊息流程,包含取消


Canceling Deferreds

讓我們看看幾個簡單的程式,來了解取消deferred實際上如何運作。注意,要執行這個範例與本章節中其他的程式碼,你需要Twisted 10.1.0或以上的版本。研究一下deferred-cancel/defer-cancel-1.py
from twisted.internet import defer

def callback(res):
    print 'callback got:', res

d = defer.Deferred()
d.addCallback(callback)
d.cancel()
print 'done'

隨著新的取消功能,Deferred類別得到了一個新的方法叫做cancel。範例建立了新的deferred實力、加入一個callback、然後沒有觸發就取消了deferred實例。這裡是輸出:
done
Unhandled error in Deferred:
Traceback (most recent call last):
Failure: twisted.internet.defer.CancelledError:

好,所以取消deferred實例似乎會導致errback鏈執行,而常規的callback根本就不會被呼叫。同樣注意到錯誤是twisted.internet.defer.CancelledError,一個表示deferred實例被取消(但是繼續看下去!)的自訂例外。讓我們試著在deferred-cancel/defer-cancel-2.py加入一個errback:
from twisted.internet import defer

def callback(res):
    print 'callback got:', res

def errback(err):
    print 'errback got:', err

d = defer.Deferred()
d.addCallbacks(callback, errback)
d.cancel()
print 'done'

現在我們得到這個輸出:
errback got: [Failure instance: Traceback (failure with no frames): : 
]
done

所以我們能「捕捉」cancel產生的errback,就像像其他任何deferred失敗一樣

好,讓我們嘗試觸發deferred實例然後取消它,就像deferred-cancel/defer-cancel-3.py
from twisted.internet import defer

def callback(res):
    print 'callback got:', res

def errback(err):
    print 'errback got:', err

d = defer.Deferred()
d.addCallbacks(callback, errback)
d.callback('result')
d.cancel()
print 'done'

這裡我們用callback方法正常的觸發deferred實例,然後取消它。這裡是輸出:
callback got: result
done

我們的callback被調用(正如我們所預期的),然後程式正常的完成,好像cancel從沒被呼叫過一樣。所以如果deferred已經被觸發,取消它似乎沒有效果(但是繼續看下去!)。

如果我們在取消deferred實例後觸發它會怎樣?就像deferred-cancel/defer-cancel-4.py
from twisted.internet import defer

def callback(res):
    print 'callback got:', res

def errback(err):
    print 'errback got:', err

d = defer.Deferred()
d.addCallbacks(callback, errback)
d.cancel()
d.callback('result')
print 'done'

在這個案例中我們得到這樣的輸出:
errback got: [Failure instance: Traceback (failure with no frames): : 
]
done

很有趣,這和第二個範例的輸出相同,我們完全沒有觸發deferred實例。所以deferred實例已經被取消,觸發deferred實例通常沒有效果。但是為什麼d.callback(‘result’)沒有拋出錯誤,由於你不應該可以觸發deferred實例超過一次,而且errback鏈已經清楚的執行了。觸發deferred實例表示「這是你的結果」,而取消deferred實例表示「我再想要它了」。請記住,取消是一個新功能,所以多數現有的Twisted程式碼還沒被改寫為可以處理取消操作。但Twsited的開發人員已經使它可以讓我們取消任何我們想要的deferred,即使我們得到deferred實例的程式碼是Twisted 10.1.0之前寫的。

為了實現這點,cancel方法實際上做兩件事情:

  1. 如果結果還沒顯示(即deferrd實例還沒被觸發),告訴Deferred物件它本身你不想要結果了,因而忽略任何後續callback或errback的調用。
  2. 而且可以選擇告訴產生結果的底層程式碼,去採取取消操作所需的任何步驟。

由於較舊的Twisted程式碼將會繼續進行並仍然要觸發被取消的deferred,如果我們取消我們從舊有函式庫得到的deferred實例,上面說的第一點確保我們的程式不會爆炸。

這表示我們可以隨心所欲的取消deferred實例,並且我們確定不會得到結果,如果結果沒有到來(即使它稍後到來)的話。但是取消deferred實例可能不會真的取消非同步操作。中止非同步操作需要一個context-specific的動作。你可能需要關閉網路連接、反轉資料庫交易、中止子程序等等。由於deferred實例只是一個組織一般用途callback的腳色,它怎麼知道當你取消它時要採取什麼具體行動?或者,換種說法,它如何將取消請求轉送到起初建立與回傳deferred實例底層程式碼?現在跟我一起說:
I know, with a callback!
(我知道,用callback!)


Canceling Deferreds, Really

好,看一下deferred-cancel/defer-cancel-5.py
from twisted.internet import defer

def canceller(d):
    print "I need to cancel this deferred:", d

def callback(res):
    print 'callback got:', res

def errback(err):
    print 'errback got:', err

d = defer.Deferred(canceller) # created by lower-level code
d.addCallbacks(callback, errback) # added by higher-level code
d.cancel()
print 'done'

這段程式碼基本上與第二個範例一樣,除了當我們建立Deferred實例時傳給它第三個callback(canceller),而不是之後才加入。這個callback負責完成中止非同步操作所需的context-specific動作(當然,只有當deferred實例真的被取消時)。canceller callback是回傳deferred實例的底層程式碼必要部分,而不是接收deferred實例並加入自己的callbacks與errbacks的高層程式碼。

執行這個範例會產生這樣的輸出:
I need to cancel this deferred: 
errback got: [Failure instance: Traceback (failure with no frames): : 
]
done

如你所見,我們不再需要結果的deferred實例被傳給canceller callback。這就是為了中止非同步操作我們採取任何所需動作的地方。注意canceller是在errback鏈觸發前被調用的。事實上,我們可以選擇用我們要的任意正常結果或錯誤結果,在這個地方(譯註:canceller callback)自行觸發deferred實例(因此搶先於CancelledError失敗)。這兩種可能的狀況都在deferred-cancel/defer-cancel-6.pydeferred-cancel/defer-cancel-7.py中說明。

在我們啟動reactor之前,讓我們再做一個簡單的測試。我們會用canceller callback建立deferred實例,正常的觸發它之後再取消它。你可以在deferred-cancel/defer-cancel-8.py看到程式碼。藉由檢查這個script的輸出,你可以看到在deferred實例已經被觸發後取消它並不會調用canceller callback。這就是我們期望的,因為沒有東西可以取消。

到目前為止我們所看過的範例都沒有任何實際的非同步操作。讓我們寫一個調用非同步操作的簡單程式,然後我們來搞清楚如何讓這個操作可以取消。研究一下deferred-cancel/defer-cancel-9.py的程式碼:
from twisted.internet.defer import Deferred

def send_poem(d):
    print 'Sending poem'
    d.callback('Once upon a midnight dreary')

def get_poem():
    """Return a poem 5 seconds later."""
    from twisted.internet import reactor
    d = Deferred()
    reactor.callLater(5, send_poem, d)
    return d

def got_poem(poem):
    print 'I got a poem:', poem

def poem_error(err):
    print 'get_poem failed:', err

def main():
    from twisted.internet import reactor
    reactor.callLater(10, reactor.stop) # stop the reactor in 10 seconds

    d = get_poem()
    d.addCallbacks(got_poem, poem_error)

    reactor.run()

main()

這個範例包含了一個get_poem函式,該函式使用reactor的callLater方法在get_poem被呼叫的五秒鐘後非同步的回傳詩歌。main函式呼叫get_poem,加入一對callback/errback,然後啟動reactor。我們也安排(再次使用callLater)在十秒鐘後停止reactor。通常我們會透過附加一個callback到deferred實例來做到這點,但是你很快就會看到為什麼我們這樣做。

執行上面的範例會產生這樣的輸出(在適當的延遲之後):
Sending poem
I got a poem: Once upon a midnight dreary

十秒鐘後,我們的小程式就停止了。現在讓我們試試在詩歌傳送前取消deferred。我們只要加入這一小段程式碼就可以在兩秒後(在傳送詩歌的五秒延遲之前)取消deferred實例:
    reactor.callLater(2, d.cancel) # cancel after 2 seconds

完整的程式在deferred-cancel/defer-cancel-10.py,它會產生以下的輸出:
get_poem failed: [Failure instance: Traceback (failure with no frames): : 
]
Sending poem

這個範例清楚的說明取消deferred實例不一定會取消它根本的非同步請求。兩秒鐘後我們看到errback的輸出,印出我們預期的CancelledError。但五秒鐘後然然會看到send_poem的輸出(但是deferred實例上的callback沒有被觸發)。

此時我們與deferred-cancel/defer-cancel-4.py的情況相同。「取消」deferred實例導致最終的結果被忽略,但不會真正的中止操作。如我們上面所學的,為了建立真正可以取消的deferred實例,我們必須在deferred實例被建立的時候加入cancel callback。

這個新的callback需要做什麼?看一下callLater方法的文件。callLater的回傳值是另一個實現IDelayedCall的物件,它有一個cancel方法我們可以用來避免延遲的呼叫被執行。

這很簡單,更新後的程式碼在deferred-cancel/defer-cancel-11.py。相關的改變都在get_poem函式中:
def get_poem():
    """Return a poem 5 seconds later."""

    def canceler(d):
        # They don't want the poem anymore, so cancel the delayed call
        delayed_call.cancel()

        # At this point we have three choices:
        #   1. Do nothing, and the deferred will fire the errback
        #      chain with CancelledError.
        #   2. Fire the errback chain with a different error.
        #   3. Fire the callback chain with an alternative result.

    d = Deferred(canceler)

    from twisted.internet import reactor
    delayed_call = reactor.callLater(5, send_poem, d)

    return d

在這個新版本中,我們保存了callLater的回傳值,所以我們可以在我們的取消callback中使用它。我們的callback唯一要做的事情是調用delayed_call.cancel()。但是如我們之前討論的,我們也可以選擇自行觸發deferred實例。我們範例的最新版本產生這樣的輸出:
get_poem failed: [Failure instance: Traceback (failure with no frames): : 
]

如你所建,deferred實例被取消了,而且非同步操作真正的被中止了(即我們看不到send_poem的print輸出)。

Poetry Proxy 3.0

如我們在一開始的簡介中討論的,詩歌代理伺服器是用來實現取消的很好的候選者,因為它允許我們取消詩歌下載,如果它發現沒有人想要詩歌了(即在我們傳送詩歌之前,用戶端關閉了連接)。在twisted-server-4/poetry-proxy.py的3.0版本的代理伺服器實現了deferred的取消。它的第一個改變在PoetryProxyProtocol
class PoetryProxyProtocol(Protocol):

    def connectionMade(self):
        self.deferred = self.factory.service.get_poem()
        self.deferred.addCallback(self.transport.write)
        self.deferred.addBoth(lambda r: self.transport.loseConnection())

    def connectionLost(self, reason):
        if self.deferred is not None:
            deferred, self.deferred = self.deferred, None
            deferred.cancel() # cancel the deferred if it hasn't fired

你可以把它與舊版本比對一下。兩個主要的變化是ProxyService

  1. 保存我們從get_poem得到的deferred實例,如此我們便可以在稍後需要時取消它。
  2. 當連接關閉時取消deferred實例。注意這在我們實際取得詩歌之後也會取消deferred實例,但如我們在之前的範例所發現的,取消已經觸發的deferred實例是沒有效果的。

現在我們需要確保取消deferred實例會真的中止詩歌下載。為此我們需要改變ProxyService ProxyService:
class ProxyService(object):

    poem = None # the cached poem

    def __init__(self, host, port):
        self.host = host
        self.port = port

    def get_poem(self):
        if self.poem is not None:
            print 'Using cached poem.'
            # return an already-fired deferred
            return succeed(self.poem)

        def canceler(d):
            print 'Canceling poem download.'
            factory.deferred = None
            connector.disconnect()

        print 'Fetching poem from server.'
        deferred = Deferred(canceler)
        deferred.addCallback(self.set_poem)
        factory = PoetryClientFactory(deferred)
        from twisted.internet import reactor
        connector = reactor.connectTCP(self.host, self.port, factory)
        return factory.deferred

    def set_poem(self, poem):
        self.poem = poem
        return poem

同樣的,你可能希望把它與舊版本比對一下。這個類別有一些改變:

  1. 我們保存了reactor.connetTCP的回傳值,它是一個IConnector物件。我們可以在該物件上使用disconnect方法去關閉連接。
  2. 我們用canceler callback建立了deferred實例。這個callback是一個用connector關閉連接的閉包(closure)。但首先它先將factory.deferred屬性設定為None。否則在deferred實例本身用CancelledError觸發之前,工廠可能用「連接關閉」的errback觸發deferred實例。由於deferred實例已經被取消,以CancelledError觸發deferred實例顯得更加明確。

你可能也注意到我們現在是在ProxyService中建立deferred實例,而不是PoetryClientFactory。由於canceler callback需要存取IConnector物件,ProxyService到頭來就成為建立deferred實例最方便的地方。

並且,如同我們之前其中一個範例(譯註:deferred-cancel/defer-cancel-11.py),我們的canceler callback是作為一個閉包來實現。閉包看起在實現cancel callback時來非常有用!

讓我們試試我們的新代理伺服器。首先啟動一個慢速伺服器。它必須很慢以便我們真的有時間去取消:
python blocking-server/slowpoetry.py --port 10001 poetry/fascination.txt

現在我們可以啟動我們的代理伺服器(記得你需要Twisted 10.1.0) :
python twisted-server-4/poetry-proxy.py --port 10000 10001

現在我們可以使用任何用戶端從代理伺服器開始下載詩歌,或只只使用curl:
curl localhost:10000

幾秒鐘之後,按下Ctrl+C停止用戶端或curl程序。在執行代理伺服器的終端機中你應該會看到這樣的輸出:
Fetching poem from server.
Canceling poem download.

而且你應該看到慢速伺服器已經停止為它傳送的每一小段詩歌印出輸出,因為我們的代理伺服器中斷了。你可以多次啟動與停止用戶端,來驗證每個下載每次都被取消。但是如果你讓這首詩歌下載完成,那麼代理伺服器會在快取儲存這首詩歌並且在此後會立即傳送它。

One More Wrinkle

我們之前多次提到取消已經觸發的deferred實例是沒有效過的。好吧,那不全然是真的。在Part 13我們學到附加到deferred實例的callbacks與errbacks可能會回傳另一個deferred實例。在這種情況下,原始(外部)的deferred實例會暫停它callback鏈的執行,並等待內部deferred實例觸發(請見圖28)。

因此,即使deferred實例已經觸發了建立非同步請求高層程式碼,但可能還沒有收到結果,因為callback鏈因為等待內部deferred實例完成而暫停了。那麼當高層的程式碼取消了外部deferred會發生什麼事情?在這種情況下,外部deferred實例不會取消它自己(畢竟它已經觸發了);相反的,外部deferred實例會取消內部deferred實例。

所以當你取消deferred實例時,你可能不會取消主要的非同步操作,而是取消作為第一個操作的結果所觸發的一些其他的非同步操作。呼!

我們可以在用一個範例來說明。研究一下deferred-cancel/defer-cancel-12.py的程式碼:
from twisted.internet import defer

def cancel_outer(d):
    print "outer cancel callback."

def cancel_inner(d):
    print "inner cancel callback."

def first_outer_callback(res):
    print 'first outer callback, returning inner deferred'
    return inner_d

def second_outer_callback(res):
    print 'second outer callback got:', res

def outer_errback(err):
    print 'outer errback got:', err

outer_d = defer.Deferred(cancel_outer)
inner_d = defer.Deferred(cancel_inner)

outer_d.addCallback(first_outer_callback)
outer_d.addCallbacks(second_outer_callback, outer_errback)

outer_d.callback('result')

# at this point the outer deferred has fired, but is paused
# on the inner deferred.

print 'canceling outer deferred.'
outer_d.cancel()

print 'done'

在這個範例中我們建立了外部與內部兩個deferreds,並且其中有一個外部callback回傳內部deferred實例。首先我們觸發外部deferred實例,然後我們取消它。這個範例產生這樣的輸出:
first outer callback, returning inner deferred
canceling outer deferred.
inner cancel callback.
outer errback got: [Failure instance: Traceback (failure with no frames): : 
]
done

如你所見,取消外部deferred實例不會導致外部cancel callback觸發。相反的,它取消內部deferred實例,所以內部cancel callback被觸發,然後外部errback接收到CancelledError(來自內部deferred實例)。

你可能希望盯著那些程式碼一段時間,並且嘗試變化一下看看它們如何影響結果。

Discussion

取消deferred實例可以做為一個非常有用的操作,它使我們的程式避免執行不需要的工作。但也如我們所見,它可能也有點棘手。

要謹記一件非常重要的事,取消deferred實例不一定會取消根本的非同步操作。事實上,在寫這篇文章時,大多數的deferreds不會真的被「取消」,因為大部分Twisted的程式碼是在Twisted 10.1.0之前寫的,並且還沒有更新。這包括很多Twisted自己的API!檢查文件及原始碼來找出取消deferred實例是否真的取消了請求,還是只是簡單的忽略它。

第二件重要的事是,簡單的從你的非同步APIs回傳deferred實例不一定會讓他們可以完整確實的取消。如果你要在你自己的程式中實現取消,你應該研讀Twisted原始碼來找到更多範例。取消是一項全新的功能,因此它的模式(patterns)與最佳的實踐方式還在發展中。

Looking Ahead

在這邊我們已經學習了關於Deferreds的所有事情與Twisted背後的核心概念。這表示沒有什麼需要介紹了,因為Twisted剩下的部分主要都是特定的應用,像是網路程式設計或者非同步資料庫存取等。所以接下來的幾個章節,我們要稍微繞一些路來看看另外兩個使用非同步I/O的系統,看看他們的一些構想如何與Twisted中的構想產生關聯。然後,在最後的章節,我們會統整並且對往後你的Twsited學習提出建議。

Suggested Exercises

  1. 你知道你可以用另外一兩種方法拼寫cancelled嗎?這是真的。這一切都取決於你的心情。
  2. 仔細閱讀Deferred類別的原始碼,特別注意cancellation的實現。
  3. 在Twisted 10.1.0原始碼中搜尋帶有cancel callbacks的deferreds的範例。研究它們的實現。
  4. 讓我們其中一個詩歌用戶端中get_poetry方法回傳的deferred實例可以取消。
  5. 寫一個reactor為基礎的範例,說明取消暫停於內部deferred實例的外部deferred實例。如果你使用callLater,你需要小心的選擇延遲時間,確保外部deferred實例在正確的時間被取消。
  6. 在Twisted中找到不支援真正取消的非同步API並且幫它實現取消。向Twisted專案送出這個修正。不要忘記單元測試!

留言