這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
GO語言的進階之路-goroutine(並發)
作者:尹正傑
著作權聲明:原創作品,謝絕轉載!否則將追究法律責任。
有人把Go比作21世紀的C 語言,第一是因為 Go語言設計簡單,第二,21世紀最重要的就是並行程式設計,而GO 從語言層面就支援了並行。Go語言中最重要的一個特性,那就是 go 關鍵字。優雅的並發編程範式,完善的並發支援,出色的並發效能是Go語言區別於其他語言的一大特色。使用Go語言程式開發伺服器程式時,就需要對它的並發機制有深入的瞭解。
一.並發基礎
回到在Windows和Linux出現之前的古老年代,程式員在開發程式時並沒有並發的概念,因為命令式程式設計語言是以串列為基礎的,程式會順序執行每一條指令,整個程式只有一個執行內容,即一個調用棧,一個堆。並發則意味著程式在運行時有多個執行內容,對應著多個調用棧。我們知道每一個進程在運行時,都有自己的調用棧和堆,有一個完整的上下文,而作業系統在調度進程的時候,會儲存被調度進程的上下文環境,等該進程獲得時間片後,再恢複該進程的上下文到系統中。
1>.從整個作業系統層面來說,多個進程是可以並發的,那麼並發的價值何在?下面我們先看以下幾種情境。
2>.一方面我們需要靈敏響應的圖形化使用者介面,一方面程式還需要執行大量的運算或者IO密集操作,而我們需要讓介面響應與運算同時執行。
3>.當我們的Web伺服器面對大量使用者請求時,需要有更多的“Web伺服器工作單元”來分別響應使用者。
4>.我們的事務處於分布式環境上,相同的工作單元在不同的電腦上處理著被分區的資料。
5>.電腦的CPU從單核心(core)向多核心發展,而我們的程式都是串列的,電腦硬體的能力沒有得到發揮。
6>.我們的程式因為IO操作被阻塞,整個程式處於停滯狀態,其他IO無關的任務無法執行。
從以上幾個例子可以看到,串列程式在很多情境下無法滿足我們的要求。下面我們歸納了並發程式的幾條優點,讓大家認識到並發勢在必行:
a>.並發能更客觀地表現問題模型;
b>.並發可以充分利用CPU核心的優勢,提高程式的執行效率;
c>.並發能充分利用CPU與其他硬體裝置固有的非同步性。
現在我們已經意識到並發的好處了,那麼到底有哪些方式可以實現並發執行呢?就目前而言,並發包含以下幾種主流的實現模型。
1>.多進程。多進程是在作業系統層面進行並發的基本模式。同時也是開銷最大的模式。在Linux平台上,很多工具鏈正是採用這種模式在工作。比如某個Web伺服器,它會有專門的進程負責網路連接埠的監聽和連結管理,還會有專門的進程負責事務和運算。這種方法的好處在於簡單、進程間互不影響,壞處在於系統開銷大,因為所有的進程都是由核心管理的。
2>.多線程。多線程在大部分作業系統上都屬於系統層面的併發模式,也是我們使用最多的最有效一種模式。目前,我們所見的幾乎所有工具鏈都會使用這種模式。它比多進程 的開銷小很多,但是其開銷依舊比較大,且在高併發模式下,效率會有影響。
3>.基於回調的非阻塞/非同步IO。這種架構的誕生實際上來源於多線程模式的危機。在很多高並發伺服器開發實踐中,使用多線程模式會很快耗盡伺服器的記憶體和CPU資源。而這種模式通過事件驅動的方式使用非同步IO,使伺服器持續運轉,且儘可能地少用線程,降低開銷,它目前在Node.js中得到了很好的實踐。但是使用這種模式,編程比多線程要複雜,因為它把流程做了分割,對於問題本身的反應不夠自然。
4>.協程。協程(Coroutine)本質上是一種使用者態線程,不需要作業系統來進行搶佔式調度,且在真正的實現中寄存於線程中,因此,系統開銷極小,可以有效提高線程的任務並發性,而避免多線程的缺點。使用協程的優點是編程簡單,結構清晰;缺點是需要語言的支援,如果不支援,則需要使用者在程式中自行實現調度器。目前,原生支援協程的語言還很少。
接下來我們先詮釋一下傳統並行存取模型的缺陷,之後再講解goroutine並行存取模型是如何逐一解決這些缺陷的。
人的思維模式可以認為是串列的,而且串列的事務具有確定性。線程類併發模式在原先的確定性中引入了不確定性,這種不確定性給程式的行為帶來了意外和危害,也讓程式變得不可控。線程之間通訊只能採用共用記憶體的方式。為了保證共用記憶體的有效性,我們採取了很多措施,比如加鎖等,來避免死結或資源競爭。實踐證明,我們很難面面俱到,往往會在工程中遇到各種奇怪的故障和問題。
我們可以將之前的線程加共用記憶體的方式歸納為“共用記憶體系統”,雖然共用記憶體系統是一種有效併發模式,但它也暴露了眾多使用上的問題。電腦科學家們在近40年的研究中又產生了一種新的系統模型,稱為“訊息傳遞系統”。
對線程間共用狀態的各種操作都被封裝線上程之間傳遞的訊息中,這通常要求:發送訊息時對狀態進行複製,並且在訊息傳遞的邊界上交出這個狀態的所有權。從邏輯上來看,這個操作與共用記憶體系統中執行的原子更新操作相同,但從物理上來看則非常不同。由於需要執行複製操作,所以大多數訊息傳遞的實現在效能上並不優越,但線程中的狀態管理工作通常會變得更為簡單。
最早被廣泛應用的訊息傳遞系統是由C. A. R. Hoare在他的Communicating Sequential Processes中提出的。在CSP系統中,所有的並行作業都是通過獨立線程以非同步啟動並執行方式來實現的。這些線程必須通過在彼此之間發送訊息,從而向另一個線程請求資訊或者將資訊提供給另一個線程。使用類似CSP的系統將提高編程的抽象層級。
隨著時間的推移,一些語言開始完善訊息傳遞系統,並以此為核心支援並發,比如Erlang。
二.協程
執行體是個抽象的概念,在作業系統層面有多個概念與之對應,比如作業系統自己掌管的進程(process)、進程內的線程(thread)以及進程內的協程(coroutine,也叫輕量級線程)。與傳統的系統級線程和進程相比,協程的最大優勢在於其“輕量級”,可以輕鬆建立上百萬個而不會導致系統資源衰竭,而線程和進程通常最多也不能超過1萬個。這也是協程也叫輕量級線程的原因。
多數語言在文法層面並不直接支援協程,而是通過庫的方式支援,但用庫的方式支援的功能也並不完整,比如僅僅提供輕量級線程的建立、銷毀與切換等能力。如果在這樣的輕量級線程中調用一個同步 IO 操作,比如網路通訊、本地檔案讀寫,都會阻塞其他的並發執行輕量級線程,從而無法真正達到輕量級線程本身期望達到的目標。
Go 語言在語言層級支援輕量級線程,叫goroutine。Go 語言標準庫提供的所有系統叫用作業(當然也包括所有同步 IO 操作),都會出讓 CPU 給其他goroutine。這讓事情變得非常簡單,讓輕量級線程的切換管理不依賴於系統的線程和進程,也不依賴於CPU的核心數量。
三.goroutine
goroutine不同於thread,threads是作業系統中的對於一個獨立運行執行個體的描述,不同作業系統,對於thread的實現也不盡相同;但是,作業系統並不知道goroutine的存在,goroutine的調度是有Golang運行時進行管理的。啟動thread雖然比process所需的資源要少,但是多個thread之間的環境切換仍然是需要大量的工作的(寄存器/Program Count/Stack Pointer/...),Golang有自己的調度器,許多goroutine的資料都是共用的,因此goroutine之間的切換會快很多,啟動goroutine所耗費的資源也很少,一個Golang程式同時存在幾百個goroutine是很正常的。