松本行弘:我為什麼要開發新語言Streem(上)

樂馨發表於2015-01-13

原文刊載於《日經Linux》2015/01號中(©日經BP社 2015)。原文題為《跟Matz邊做邊學程式語言:21世紀的併發程式語言》。文/松本行弘,譯/劉斌。 enter image description here

隨著多核CPU的普及,shell指令碼的(一部分)價值也在逐漸被我們重新認識。shell指令碼的基本計算模型是基於管道來連線多個程式。如果作業系統支援多核的話,則各程式會被分配到不同的CPU上去執行,這樣就可以充分發揮多核CPU的優勢。同時這也證明了一點,那就是隻要選擇合適的計算模型,就能非常容易地實現併發執行。

在實際的業務系統中,我也聽說有人採用shell指令碼來進行處理。雖說是用shell指令碼進行資訊的篩選和加工,但是和傳統的軟體開發模式相比,它有著成本低、靈活性高等優點。

shell指令碼已經有些力不從心

但也並不能說shell指令碼有多麼理想,實際上它也有它的侷限性。

比如,建立OS程式的成本非常高,如果需要使用shell指令碼建立大量輕量程式的話,那麼在效能上將會非常不利。

還有另外一種成本,由於連線程式的管道只能傳送位元組陣列的資料,所以傳送方需要先將資料轉換為位元組陣列,接收方則需要將位元組陣列還原。比如很多時候我們都會使用以逗號分隔的CSV(Comma Separated Values)格式或表示JavaScript物件的JSON(JavaScript Object Notation)格式,將資料從這些格式轉換為位元組陣列,或者對位元組陣列進行解析並還原,這樣做的成本是非常高的。

在進行大資料處理、高效能運算等時,我們多會選擇使用多核CPU。因此,資料轉換或建立程式所花費的成本是不可忽視的。這可以說是shell指令碼的一個缺陷。

更進一步來說,構成管道的程式(process)所執行的命令(command),可能並不是由同一個開發者所開發的,這些命令的引數設定方法等往往並不統一,因此要想熟練使用這些命令,難度會有所增加。

21世紀的shell指令碼

這樣說來,如果能將shell指令碼的優點,和通用程式語言的優點結合起來的話,應該就可以創造出一門非常強大的語言。

首先我們來看看這門強大的語言都需要滿足哪些必要條件。

第1個條件是可以進行輕量的併發。由於不管是OS級別的程式還是執行緒,建立成本都很高,因此我們應該儘量避免去使用它們。比較現實的方式是在一個OS的程式中,預先生成與CPU的核數(+α)相同個數的執行緒,讓它們輪番去執行各種操作請求。採用這種實現方式的典型語言包括Erlang和Go。在本文中,我們將相當於Erlang中的“process”、Go中的“goroutine”的概念稱為“任務”(task)。

第2個條件就是解決併發執行時的競爭條件。具體來說就是“狀態”的排除。也就是說,如果變數或者屬性的值發生變化,就會產生一個新的狀態,這也帶來了因執行時機(timing)不同而產生問題的危險。所以需要將所有資料都設為不可變(immutable),這樣就可以避免因執行時機而出現的缺陷。

第3個條件是計算模型。執行緒模型雖然應用領域非常廣泛,但自由程度也很高,因此程式可能會變得難以掌控。於是我們可以參考shell的執行模型,引入一個抽象度非常高的併發計算模型。抽象度高了,反過來表現的自由度就會降低,所以在編寫程式碼的時候就要下一番功夫。而另一方面,我們的程式也會變得非常容易除錯。

新語言Streem

於是我就開始設計一門滿足上述條件的新語言。由於是以流(Stream)為計算模型的語言,因此我們就將它命名為“Streem”。

首先我們來看看它的語法。由於它是基於shell的,因此也沒有什麼特別的語法。它的基本語法如下所示。

表示式1 | 表示式2 | ...  

若要採用這種語法來實現一個從標準輸入讀取資料,然後輸出到標準輸出,功能類似於cat命令的程式,只需編寫如下程式碼即可。

STDIN | STOUT

就是如此簡單。STDIN和STDOUT是用常量表示標準輸入輸出的物件。Streem程式中的STDIN會從標準輸入中一行一行地讀取(字串)資料,並將這一行行的資料傳遞給其他表示式,就像“流”一樣。像這樣用來表示資料的流動的物件就稱為“流”(物件)。STDOUT則正相反,它是一個接收字串引數並輸出到外部(標準輸出)的流。如果想讀取指定檔名的檔案內容的話,可以使用如下程式碼。

read(path)

如果是寫入到指定檔案,則可以使用如下程式碼。

write(path)

這兩個方法都會返回用來讀取或者寫入的流物件。

表示式

Streem的表示式包括常量、變數引用、函式呼叫、陣列表示式、Map表示式、函式表示式、運算子表示式以及if表示式。它們的語法如表1所示。如果你有其他語言的程式設計經驗的話,這些內容應該都很容易理解。

表1 Streem的表示式

型別 語法 示例
字串常量(字面值) "字串" "foobar"
數值常量 數值表現形式 123
符號(Symbol)常量 :識別符號 :Foo
變數引用 識別符號 FooBar
函式呼叫 識別符號(引數...) square(2)
方法呼叫 表示式.識別符號(引數...) ary.push(2)
陣列表示式 [表示式, ...] [1,2,3]
Map表示式 [表示式:表示式, ...] [1:"2", 3:"4"], [:](空Map)
函式表示式 {|變數..| 語句 } {|x| x + 1}
運算子表示式 表示式 運算子 表示式 1 + 1
if表示式 if 表示式 {語句 ...} else { 語句 ...} if true {1} else {2}

賦值

Streem中的賦值有兩種方式。第一種是和其他語言類似的使用“=”的方式。

 ONE = 1

還有一種就是使用“->”的反方向賦值的方法。如果把上面採用等號的賦值語句改為使用“->”的話,程式碼將如下所示。

1 -> ONE  

這種賦值方式非常適合在將管道執行結果存放到變數時使用,這樣程式碼的順序就能和程式的執行流程保持一致,非常方便。

不管採取上面哪種賦值方式,為了避免變數狀態的改變,都需要遵循如下規則。

  • 規則1:不能給同一個變數進行多次賦值。對一個變數的賦值在其作用域之內只能進行一次。

  • 規則2:僅在互動執行環境的頂層作用域(top level)下,才允許對一個變數進行重複賦值。不過,你可以將這看作是對變數名相同的不同變數進行的賦值。

語句的組合

Streem支援將多條表示式語句並列編寫。語句之間用分號(;)或者換行來分割。也可以認為這些語句會按照編碼的順序來執行。如果這些語句之間沒有依賴關係的話,在實際執行的時候也可能會併發執行。

Streem程式示例

接下來讓我們來看一些Streem程式的具體例子。

前面我們看到了一個實現類似於cat命令的例子,下面我們來看一個稍微有點不同的例子。這裡我們將使用Streem來實現經常被拿來舉例的FizzBuzz遊戲(圖1)。這個遊戲要求玩家從1開始輸入數字,當這個數字能被3整除的時候,會輸出“Fizz”;當能被5整除的時候,會輸出“Buzz”;當能同時被3和5整除的時候,則會輸出“FizzBuzz”。

圖1 Streem版FizzBuzz

seq(100) | { |x|  
  if x % 15 == 0 {  
    "FizzBuzz"  
  }  
  else if x % 3 == 0 {  
    "Fizz"  
  }  
  else if x % 5 == 0 {  
    "Buzz"  
  }  
  else {  
    x  
  }  
} | STOUT

seq函式用來生成一個從1到指定引數的整數數列。如果將該數列連線到管道上的話,則該數列會將各元素的值按順序傳遞給管道。STDOUT則將接收到的數列的值進行輸出。

從上面的例子我們可以看出,Streem的管道表述方式直接體現了要幹什麼,是不是更為直截了當呢?

1對1、1對n、n對m

通過圖1的例子,我們已經知道了使用Streem可以非常簡單地完成諸如對數值序列進行處理並輸出的程式。然而現實中的程式並不完全都是對這種1對1關係的資料進行處理。比如類似於grep(單詞搜尋)這樣“查詢所有滿足指定條件”的型別,以及類似於wc(統計單詞數量)這樣對資料進行聚合計算的型別。

Streem也支援這種應用場景,並提供了一些關鍵字來進行這類操作。

在一次執行需要返回多個值的時候,可以使用emit。如果給它傳遞多個(引數)值的話,那麼它也會返回多個值。也就是說,

emit 1, 2

就相當於下面這行程式碼。

emit 1; emit 2

此外,如果在陣列前面加上“*”的話,就表示要返回這個陣列的所有元素。比如,

a = [1, 2, 3]; emit *a

就相當於如下程式碼。

emit 1; emit 2; emit 3

圖2是一個使用emit的例子。這個程式會將從1到100之間的整數每個都列印兩次。

圖2 使用emit的例子

# 將從1到100的整數分別列印兩次  
seq(100) | {|x| emit x, x} | STDOUT

return用來終止函式的執行並返回值。return可以返回多個值,這時候它就相當於對多個值進行了emit操作。有一點前面我們沒有提到,那就是如果一個函式主體只有一個表示式的話,那麼即使不使用return,這個表示式的執行結果也會作為函式的返回值。

使用emit和return的話,就可以產生比輸入值個數更多的返回值。與之相反,如果我們想生成少於輸入值個數的返回值的話,則可以使用skip函式。skip用來終止當前函式的執行,但是並不產生任何返回值。圖3是一個使用skip的例子,該程式用來篩選出1到100之間的偶數。

圖3 使用skip的例子

#  skip奇數,選擇偶數   
seq(100) | {|x| if x % 2 == 1 {skip}; x} | STDOUT

不可變性(immutable)

前面我們已經說過,在Streem中,為了避免競爭條件的出現,所有的資料結構都是不可變的。陣列和Map(類似於Ruby中的Hash)型別的變數也是不可變的。向這些結構的資料新增新元素的時候,並不是直接修改已有的資料,而是在原資料的基礎上新增新元素來建立新的資料(圖4)。

圖4 修改immutable資料

a = [1, 2, 3, 4] # a是一個擁有4個元素的陣列  
b = a.push(5)    # b是在a之後新增了5的陣列  

在一般的物件導向程式語言中,物件的屬性(例項的變數)都是可以修改的,而在Streem中,這種操作是被禁止的,這需要注意一下。從這一點上來說,Streem非常像函數語言程式設計語言。

統計單詞出現次數

接著我們再看另一個Streem程式的例子。這裡我們選擇了在介紹MapReduce時經常使用的一個例子——統計單詞出現次數。下面我們用Streem來實現一下(圖5)。

圖5 使用Streem統計單詞出現次數

STDIN | { |line|  
    return *line.split  
} | reduce([:]) { |map, word|   # [:]是一個空Map  
    map.set(word, map.get(word,0) + 1)  
} | STDOUT

首先我們對圖5的程式中新出現的語法進行說明。在呼叫reduce函式的地方,我們看到了類似於Ruby中的Block的語句。這是Streem語言中的一個語法糖,如果函式的引數列表後面是一個函式表示式的話,那麼這個函式表示式就會被視為該函式的引數列表的最後一個元素。也就是說表示式

reduce(0) {|x, y| x + y }

是下面的表示式的另一種寫法。

reduce(0, {|x, y| x + y })

這也是Streem為了能在普通函式呼叫中將類似於Ruby中的Block變數作為引數而做出的努力,而不必使用&block的方式。

如果我們看一下圖5中程式的實際執行情況,就會看到具體流程是首先從STDIN中一行行地讀取資料,用split進行單詞分割,再通過reduce函式來統計各個單詞出現的次數,並將結果存放到Map中去。如果作為key的單詞不存在的話,map.get就會返回第二個引數作為預設值(這裡是0),這樣就可以通過map.get得到該單詞的出現次數。map.set用來更新單詞出現的次數,並建立一個新的Map。因為每次更新單詞出現次數時都會建立一個新的Map,所以看上去有點浪費系統資源,但實際上我們無需為此擔心,完全可以將這些問題交給垃圾收集器或者系統執行時環境的內部實現。實際上Clojure及Haskell等很多函數語言程式設計語言也都採用了相同的策略。

最後,程式將生成的Map和STDIN通過管道連線起來,將Map中的鍵值對列印出來,顯示各個單詞及其出現次數。這個例子中我們並沒有做其他的額外處理,需要的話你可以增加一個管道以在輸出結果之前對單詞進行排序等工作。

Socket程式設計

Unix的Socket也是基於流而設計的,Streem當然也支援Socket操作。圖6的程式是一個最簡單的使用了Socket的網路Echo伺服器(將接收到的資料原封不動地返回給客戶端)。

圖6 Echo伺服器程式

# 在8007埠提供服務  
tcp_server(8007) | { |s|  
  s | s    # 直接將輸入資料作為輸出返回給客戶端  
}

程式碼是不是非常簡單?如果程式的應用場景非常匹配流模型,那麼採用Streem語言的話,編碼工作將會非常簡單。

我們還是來解析一下這段程式碼吧。tcp_server會在引數指定的埠上開啟一個伺服器端Socket進行監聽,等待客戶端的連線。在Streem中,伺服器端Socket是客戶端Socket的流物件。

客戶端Socket是客戶端的輸入和輸出的流,所以如下程式碼

s | s

的實際功能就是“原封不動地將客戶端輸入直接返回給客戶端”。如果需要對輸入內容進行加工處理的話,只需要在管道之間加入一個進行資料處理的流就可以了。

管道業務

目前為止我們看到的管道的組成都是如下方式。

表示式1 | 表示式2 ... | 表示式n

表示式1是一個產生值的流(產生器,generator),表示式2及後續表示式都是對值進行變換、處理的流(過濾器,filter),管道最後的表示式n則可以認為是輸出目的地(消費者,consumer)。

產生器有很多種,比如像STDIN這樣的從外部獲取輸入的流,以及像seq()這樣的通過計算產生值序列的函式。如果將產生器替換為一個函式表示式的話,那麼這個函式表示式就成為了一個通過return或emit來產生值的產生器。

過濾器在大多數情況下都是一個函式,通過引數接收前面的流傳過來的值,再通過emit或return將值傳遞給下一個流。

最後的消費者只會接收值,是一個不會emit值的流。

Streem程式的基本結構就是像這樣將流通過管道串聯起來,從產生器開始對資料進行流式處理。也許我們也可以稱之為“管道業務”。雖說這種計算模型並不是萬能的,但是它具有抽象程度高、容易理解、支援併發程式設計等優點。有時候我們並不需要做到100%的功能,而是專注於那重要的80%就可以了。

但是,並不是說所有程式中的資料流都只有一種(即一條管線),因此完全放棄這樣的程式的做法也有點過頭。我們需要更加複雜的管線配置。具體來說,我們還需要將多個流合併(merge)為一個流,以及從一個流派生出多條通知(廣播)這兩種型別的結構。

更進一步來說,在將流進行連線的時候,如果有一個能指定緩衝區大小的方法的話,是不是更好呢?

合併管道

到這裡為止我們看到的例子中資料流都只有一條管線,這在簡單的應用場景下倒沒什麼問題,但是這種方式並不能解決現實中的所有問題。

有時候我們可能需要將多個管道合併為一個,或者對一條管道進行分割操作。管道的合併可以使用“&”操作符。

管道1 & 管道2

通過使用“&”操作符,就能將管道1和管道2的值合併成一個陣列,並建立一個新的管道。合併後的新管道在任意一個原管道(這裡為管道1和管道2)終止的時候都會同時終止。比如本文前面的cat的例子,我們如果想像cat -n一樣同時輸出行號的話,可以使用圖9中的程式碼。

圖7 cat -n的實現程式碼

seq() & STDIN | STDOUT

由於“&”運算子的優先順序高於“|”,所以下面的程式碼

a & b | c

會被解釋為

(a & b) | c

當省略seq()的引數的時候,該函式會從1開始進行無限迴圈。由於STDIN是從標準輸入一行一行地讀取資料並寫入到管道中的,因此管道合併的結果如下所示。

[行號, 行內容]

將這個流合併後得到的新陣列寫入到STDOUT(標準輸出),就實現了帶行號的cat。從實用角度來講,也許我們還需要對行號進行顯示位數的格式化等工作,不過這也只需要你在STDOUT之前加入一個用來格式化的管道操作就可以了注1

注1 由於Streem還在開發之中,因此還沒有格式化相關的規範。

通道緩衝(channel buffering)

如果管道中最後一個流不是消費者的話,則會返回一個被稱為“通道”(channel)的物件。比如下面的程式碼。

seq() & STDIN -> sequence

這裡的sequence就是一個用來表現合併了seq()產生的數列和從STDIN讀取的輸入內容的通道。我們可以將管道理解為使用通道將進行流處理的task串聯起來的結構。

當然各個流中對資料進行處理的速度都有所不同。如果前面的流中資料產生速度太快的話,就會將資料堆積到通道中,進而導致佔用大量記憶體。反過來說,如果通道中沒有任何快取資料的話,則會增加前面處理的等待時間,從而降低整體效率。

所以,Streem會將適當數量(當然這個數量既不多也不少最理想了)的通道放到緩衝區中。但是真正合適的緩衝區大小則是由程式來決定的,我們不能進行準確的預測。從效能的角度來講,有時需要根據實際情況來手動設定這個緩衝區的大小。這時候我們可以使用chan()這個非常方便的函式。

chan()函式用來顯式地建立通道物件。管道運算子“|”的右邊如果是通道物件的話,則該通道就會直接作為輸出目的地。另外你也可以為chan()指定一個整數型的引數,來設定緩衝區的大小。也就是說,如果我們想在圖9的程式中將緩衝區大小顯式地設定為3的話,程式碼就會變為圖10那樣。

圖8 指定了緩衝區大小的cat -n

seq() & STDIN | chan(3) | STDOUT

如果將緩衝區大小設定為0的話,那麼在一個通道物件被建立之後,直到其被消費掉之前,流會進行等待,這樣管道就會以前後互動的方式來執行。這在單核CPU環境下也許會非常實用。

廣播

在聊天類的應用程式中,一個人傳送的訊息要被廣播給所有參與聊天的成員。通道也可以應用在這種場景下。如果將通過chan()建立的通道連線到多個流的話,那麼作為輸入傳送給該通道的值就會被廣播給所有和其連線的流。

如果我們將圖6中的Echo伺服器修改為聊天伺服器,將接收到的訊息傳送給所有參與者,則程式碼如圖9所示。

圖9 Chat伺服器

broadcast = chan()  
# 開啟8008埠上的服務  
tcp_server(8008) | { |s|  
  broadcast | s    # 返回參與者的訊息  
  s | broadcast    # 將訊息傳送給所有參與者  
}

聰明的你也許已經發現了,廣播通道是具有狀態的。也就是說,連線到broadcast的流作為訊息接收方,是會被儲存到broadcast中的。另外,作為輸出目標的流如果關閉了的話,或者通過disconnect方法被顯式地斷開連線的話,則該流就不再是輸出目標了。immutable是基本的Streem,但是為了編寫容易理解的程式,有時候我們需要犧牲一點純粹性。當然,由於broadcast的狀態變化在Streem內部實現了互斥操作,因此即使在並行環境下執行也不會有問題。

總結

我們圍繞管道計算模型設計了的新語言Streem。如果是非常適合流處理的程式的話,寫起來將簡單得讓人吃驚。

實際上Streem語言剛開始設計沒多久,在達到實用的程度之前,還有許多需要考慮的東西。比如如何進行異常處理、如何支援使用者自定義流、類似於物件的概念該如何定義等問題。隨著軟體規模變得越來越大,程式語言不得不考慮的問題也會越來越多。

“這種語言不能用來編寫大型軟體專案”,這是程式語言設計者經常使用的“藉口”。但是,只要這種語言還不是一無是處,還沒有什麼證據能表明這種藉口會有什麼實際作用。

下次我們將會對Streem的設計進行更深入的講解,同時也會涉及一些具體的實現細節。

相關文章