這篇文章的標題是一個π表達式,結尾是一段JavaScript代碼,和這個表達式的含義完全一致,或者說,完成了這個表達式的估值。
π演算(π calculus)是一種表達併發過程(process)的數學語言,和λ在形式上有很多類似之處;λ已經是公認的計算模型,它和圖靈機或遞歸理論(Recursion Theory)描述的計算模型是等價的,但是這個計算模型無法描述併發計算。
π沒有λ那樣的地位;它只是描述併發計算的模型之一,在計算數學領域科學家們還沒有對併發計算模型達成一致,每一種併發計算模型都強調了併發計算的某些方面的特性但還沒有一個模型成爲λ那樣的經典模型可以解釋其他所有模型,或者證明其等價性;數學家們選擇使用不同的模型研究他們感興趣的併發計算特性。
π是併發計算模型中最精簡的一個,具有最小的概念元素集;它只包含兩個概念:過程(process)和通訊(channel)。過程是一個計算單元,計算是通過通訊來完成的。
“計算是通過通訊完成的”對於不熟悉數學理論的人來說不容易理解;這裏也沒有更好的解釋;推薦看一點關於λ的介紹,感受一下計算是如何通過變量名binding和替換(substitution)完成的。在數學上,λ可以被encode在π裏,但是不該對encode一詞產生基於其自然語言含義的聯想,如果想了解它的確切含義,請系統學習λ和π的相關理論。
題目是一個&pi表達式,也可以看作是用π演算寫下的一段程序,即把π當作一種編程語言,雖然這個表達式沒有什麼實際用處,也沒有人會打算用π作爲編程語言寫下實際的程序;但是在對照了題目的π表達式和文中的JavaScript代碼之後,我相信你會獲得一些新感受,關於對併發的理解,什麼是描述併發的最小概念集。
這篇文章是一個起點,如果我有時間,還會逐步解釋在JavaScript裏的callback,emitter,promise,async/await,等等,都不是在併發計算模型意義上的最小概念,它們更多的考慮了實際使用中最常用需求、最簡潔書寫、最容易學習等工程特性或需求,但同時也;而這篇文章,就是在探索對併發編程而言,最原始的東西是什麼。
解釋表達式
和λ一樣簡單的是,在π裏只有name,name表示一個channel。
標題裏的π表達式(π-term)沒有包含所有的π符號,只包含了其中的一部分;解釋如下:
- x(z)的意思是從channel x接收到z
- z<a>的意思是向channel z發送a
- x(z)和z<a>都稱爲π前綴(prefix),分別是input prefix和output prefix,是π裏最重要的兩個前綴;
-
'.'
(dot)可以被理解爲繼續(continuation),或者反過來理解,阻塞(blocking);它的意思是前綴必須先完成通訊,即接收或發送完成,'.'
之後的表達式纔可以開始執行,或者稱爲估值;這是π裏唯一表達順序(order)的地方;
你可以給一個表達式取一個名字,習慣上使用大寫字母P, Q, R...
例如:
如果 P = <SPAN STYLE="text-decoration:overline">z</SPAN>a.0,最左側的表達式就可以寫成x(z).P;
如果 P = x(z).<SPAN STYLE="text-decoration:overline">z</SPAN>a.0,則最左側的表達式就可以寫成P;
如果
- P = x(z).z<a>.0
- Q = x<w>.y<w>.0
- R = y(v).v(u).0
則整個標題的表達式可以寫成 P | Q | R
有時候我們採用π.P的寫法表示一個通用的π表達式,而不關心這個表達式裏的π具體是那種前綴(prefix);
當然也可以定義: U = P | Q | R,它仍然是π表達式。
每個π表達式都表達了一個過程。
'|'
(vertical pipe)在π裏的含義是併發組合,可以看作過程的運算符;U = P | Q | R就可以理解爲過程U是由三個過程併發組成的。
π裏的另一個組合過程的運算符是'+'
,summation,我們暫不介紹。
標題的表達式裏還有一個符號0
,0
表示一個無行爲的過程(inaction),對實際編程而言它等同於一個子過程結束。
Free name
這一段可以在看了後面的代碼之後再回來對照理解。
Free name的含義和λ或編程語言裏的定義一致;它是bound name的反義詞;bind的意思和λ也是一致的(λx);
在π裏有兩個符號會bind name,標題裏的表達式只出現了一個,即輸入前綴,例如:x(z)。這很容易理解,在JavaScript代碼裏我們常用listener函數接收消息:
emitter.on('data', data => {
// do something with data
})
這裏的data
變量的scope就是在這個匿名函數內的,即bound name。一個過程P的Free name是它和外部角度的唯一方式。
這裏是π process教材裏的描述:
The free names of a process circumscribe its capabilities for action: for a name x, in order for P to send x, to send via x, or to receive via x, it must be that x ∈ fn(P). Thus in order for two processes to interact via a name, that name must occur free in both of them, in one case expressing a capability to send, and in the other a capability to receive.
from π calculus by Davide Sangiorgi and David Walker
譯:
一個過程的free name決定了它的行爲能力,對於過程P中的name x,如果P能夠:
- 發送x
- 通過x發送其他name
- 通過x接收其他name
x必須是P的free name。所以如果兩個過程需要通過一個name交互,這個name必須在兩個過程中都是free name,其中一方用於發送,另一方用於接收。
Reduction
這個詞在編程界被用爛了。但是它的含義沒有什麼高大上的地方。一個數學公式的形式變換就是reduction,當然我們正常情況下是希望它越變越簡潔的(所以叫reduce),除了你的陰險的數學老師會在出題時有個相反的邪惡目的。
π只有一個reduction:
<big><big>x<y>.P | x(z).Q -> P | Q[y/z]</big></big>
含義是y從channel x發送出去之後,P纔可以繼續執行;同時x(z)前綴收到了y,Q得以繼續執行,此時Q裏的所有z都要替換成y。
在編程中:
x(z).Q意味着如果x尚未收到數據,Q不能開始執行;這個input prefix在程序語言裏很容易實現,就是常見的listener或者callback。
x<y>.P意味着只有y被讀走,P纔會開始執行;這類似lazy實現的stream,在數據沒有被讀走之前,不會再向buffer填充數據;在編程語言裏實現這個表達式,和實現readable stream時,收到drain
事件纔開始填充數據類似。
代碼
我們先假定存在一個constructor或者factory function,可以構造一個channel對象;當然channel也可能是個函數,我們先不回答這個對象如何構造,以及它內部是什麼。
我們要求channel對象有一對接口方法,按照π的邏輯應該叫做send和receive(實際上也可以按照rx邏輯稱之爲set/get或者setter/getter,熟悉rx的話可以對照思考異同);
注意在π裏我們沒有類型和值的概念,一切變量皆channel,寫成代碼就是一切變量皆channel對象,通過channel傳遞的變量也是channel,這是π系統的重要特性之一:pass channel via channel(因爲它會讓變量突破一個scope使用)。
我們首先發現這個表達式裏的free name都得先聲明(why?);x,y,w都需要是channel,a在這個例子中沒有實際用到,所以代碼裏可以不必是channel,可以是任何東西,但我們還是把它聲明成channel。
第一段代碼就是這個樣子。
class Channel {
// placeholder
}
const channel = () => new Channel()
const x = channel()
const y = channel()
const w = channel()
const a = channel()
'.'
(dot)所表達的繼續,我們可以用調用一個函數來實現;0
,可以用調用空函數(() => {}
)表示;
第一個表達式:x(z).z<a>.0,可以這樣寫:
x.receive(z => z.send(a, () => {}))
receive方法形式上是提供一個函數f作爲參數,channel x在接收到值z的時候調用這個函數f(z);
第二個表達式:x<w>.y<w>.0,可以這樣寫:
x.send(w, () => y.send(w, () => {}))
注意這裏要send成功之後才能繼續而不是調用send後就繼續,所以不能寫成:
x.send(w)
y.send(w)
最後一個表達式:y(v).v(u).0
y.receive(v => v.receive(u => (() => {})()))
到這裏我們寫完了使用者代碼;在使用的時候我們也給Channel類的接口下了定義。
但是在實現之前我們先考慮一個順序問題。
π裏的reduction是兩個併發過程之間發生的;在reduction的時候我們要調用兩個'.'
表示的函數,分別表示發送者繼續和接收者繼續,我們是否應該約定一個固定的順序?
答案是不應該;甚至應該故意加入隨機性,否則就不是併發了。
我們先試圖定義一個reduce函數;它的前提是send和receive兩個方法都被調用過了;這裏存在兩種順序可能性;如果receive先被調用了,f被保存下來直到send被調用;這和常見的listener沒有區別;但π也允許反過來的順序,send先被調用了,則c和f都被保存下來,等到receive調用的時候使用。無論那個順序,我們都希望reduce可以隨機執行sender和receiver提供的回調函數。
class Channel {
reduce () {
if (!this.sendF || !this.receiveF) return
let rnd = Match.random()
if (rnd >= 0.5) {
this.sendF()
this.receiveF(this.c)
} else {
this.receiveF(this.c)
this.sendF()
}
}
send (c, f) {
this.c = c
this.sendF = f
this.reduce()
}
receive (f) {
this.receiveF = f
this.reduce()
}
}
寫出reduce之後send和receive就是無腦代碼了;在標題的表達式裏每個channel都只用了一次,所以我們不用在這裏糾結如果重複發送和接受的情況如何解決;各種參數檢查和錯誤處理也先不管了,先跑起來試試。
最後所有的代碼都在這裏,加了一點打印信息,你可以運行起來感受一下,也思考一下:
- π系統capture了併發編程裏哪些最重要的特性?沒有capture下來哪些?這段代碼裏有什麼東西是可能在實際編程問題上可以派上用場?
- callback,emitter,promise,async/await能用Channel表達或者實現嗎?如果能,大概會是什麼樣?
- rx和這個Channel有什麼異同?
class Channel {
constructor (name) {
this.name = name
}
reduce () {
if (!this.sendF || !this.receiveF) return
console.log(`passing name ${this.c.name} via channel ${this.name}`)
let rnd = Math.random()
if (rnd >= 0.5) {
this.sendF()
this.receiveF(this.c)
} else {
this.receiveF(this.c)
this.sendF()
}
}
send (c, f) {
console.log(`${this.name} sending ${c.name}`)
this.c = c
this.sendF = f
this.reduce()
}
receive (f) {
console.log(`${this.name} receiving`)
this.receiveF = f
this.reduce()
}
}
const channel = name => new Channel(name)
const x = channel('x')
const y = channel('y')
const w = channel('w')
const a = channel('a')
x.receive(z => z.send(a, () => console.log('term 1 over')))
x.send(w, () => y.send(w, () => console.log('term 2 over')))
y.receive(v => v.receive(u => (() =>
console.log(`term 3 over, received ${u.name} finally`))()))