從根上理解高性能、高併發(五):深刻操做系統,理解高併發中的協程

本文原題「程序員應如何理解高併發中的協程」,轉載請聯繫做者。php

一、系列文章引言

1.1 文章目的

做爲即時通信技術的開發者來講,高性能、高併發相關的技術概念早就瞭然與胸,什麼線程池、零拷貝、多路複用、事件驅動、epoll等等名詞信手拈來,又或許你對具備這些技術特徵的技術框架好比:Java的NettyPhpworkman、Go的gnet等熟練掌握。但真正到了面視或者技術實踐過程當中遇到沒法釋懷的疑惑時,方知自已所掌握的不過是皮毛。html

返璞歸真、迴歸本質,這些技術特徵背後的底層原理究竟是什麼?如何能通俗易懂、絕不費力真正透徹理解這些技術背後的原理,正是《從根上理解高性能、高併發》系列文章所要分享的。java

1.2 文章源起

我整理了至關多有關IM、消息推送等即時通信技術相關的資源和文章,從最開始的開源IM框架MobileIMSDK,到網絡編程經典鉅著《TCP/IP詳解》的在線版本,再到IM開發綱領性文章《新手入門一篇就夠:從零開發移動端IM》,以及網絡編程由淺到深的《網絡編程懶人入門》、《腦殘式網絡編程入門》、《高性能網絡編程》、《鮮爲人知的網絡編程》系列文章。node

越往知識的深處走,越以爲對即時通信技術瞭解的太少。因而後來,爲了讓開發者門更好地從基礎電信技術的角度理解網絡(尤爲移動網絡)特性,我跨專業收集整理了《IM開發者的零基礎通訊技術入門》系列高階文章。這系列文章已然是普通即時通信開發者的網絡通訊技術知識邊界,加上以前這些網絡編程資料,解決網絡通訊方面的知識盲點基本夠用了。python

對於即時通信IM這種系統的開發來講,網絡通訊知識確實很是重要,但迴歸到技術本質,實現網絡通訊自己的這些技術特徵:包括上面提到的線程池、零拷貝、多路複用、事件驅動等等,它們的本質是什麼?底層原理又是怎樣?這就是整理本系列文章的目的,但願對你有用。git

1.3 文章目錄

從根上理解高性能、高併發(一):深刻計算機底層,理解線程與線程池程序員

從根上理解高性能、高併發(二):深刻操做系統,理解I/O與零拷貝技術github

從根上理解高性能、高併發(三):深刻操做系統,完全理解I/O多路複用算法

從根上理解高性能、高併發(四):深刻操做系統,完全理解同步與異步apache

從根上理解高性能、高併發(五):深刻操做系統,理解高併發中的協程》(* 本文

《從根上理解高性能、高併發(六):高併發高性能服務器究竟是如何實現的 (稍後發佈..)》

1.4 本篇概述

接上篇《深刻操做系統,完全理解同步與異步》,本篇是高性能、高併發系列的第5篇文章。

協程是高性能高併發編程中不可或缺的技術,包括即時通信(IM系統)在內的互聯網產品應用產品中應用普遍,好比號稱支撐微信海量用戶的後臺框架就是基於協程打造的(詳見《開源libco庫:單機千萬鏈接、支撐微信8億用戶的後臺框架基石》)。並且愈來愈多的現代編程語言都將協程視爲最重要的語言技術特徵,已知的包括:GoPythonKotlin等。

所以瞭解和掌握協程技術對於不少程序員(尤爲海量網絡通訊應用的後端程序員)來講是至關有必要的,本文正是爲你解惑協程技術原理而寫

二、本文做者

應做者要求,不提供真名,也不提供我的照片。

本文做者主要技術方向爲互聯網後端、高併發高性能服務器、檢索引擎技術,網名是「碼農的荒島求生」,公衆號「碼農的荒島求生」。感謝做者的無私分享。

三、正文引言

做爲程序員,想必你多多少少聽過協程這個詞,這項技術近年來愈來愈多的出如今程序員的視野當中,尤爲高性能高併發領域。當你的同窗、同事提到協程時若是你的大腦一片空白,對其毫無概念。。。

那麼這篇文章正是爲你量身打造的。

話很少說,今天的主題就是做爲程序員,你應該如何完全理解協程。

四、普通的函數

咱們先來看一個普通的函數,這個函數很是簡單:

def func():
   print("a")
   print("b")
   print("c")

這是一個簡單的普通函數,當咱們調用這個函數時會發生什麼?

  • 1)調用func;
  • 2)func開始執行,直到return;
  • 3)func執行完成,返回函數A。

是否是很簡單,函數func執行直到返回,並打印出:

a
b
c

So easy,有沒有,有沒有!

很好!

注意這段代碼是用python寫的,但本篇關於協程的討論適用於任何一門語言,由於協程並非某種語言特有的。而咱們只不過剛好使用了python來用做示例,因其足夠簡單。

那麼協程是什麼呢?

五、從普通函數到協程

接下來,咱們就要從普通函數過渡到協程了。和普通函數只有一個返回點不一樣,協程能夠有多個返回點。

這是什麼意思呢?

void func() {
  print("a")
  暫停並返回
  print("b")
  暫停並返回
  print("c")
}

普通函數下,只有當執行完print("c")這句話後函數纔會返回,可是在協程下當執行完print("a")後func就會因「暫停並返回」這段代碼返回到調用函數。

有的同窗可能會一臉懵逼,這有什麼神奇的嗎?

我寫一個return也能返回,就像這樣:

void func() {
  print("a")
  return
  print("b")
  暫停並返回
  print("c")
}

直接寫一個return語句確實也能返回,但這樣寫的話return後面的代碼都不會被執行到了。

協程之因此神奇就神奇在當咱們從協程返回後還能繼續調用該協程,而且是從該協程的上一個返回點後繼續執行。

就比如孫悟空說一聲「定」,函數就被暫停了:

void func() {
  print("a")
  定
  print("b")
  定
  print("c")
}

這時咱們就能夠返回到調用函數,當調用函數何時想起該協程後能夠再次調用該協程,該協程會從上一個返回點繼續執行。

Amazing,有沒有,集中注意力,千萬不要翻車。

只不過孫大聖使用的口訣「定」字,在編程語言中通常叫作yield(其它語言中可能會有不一樣的實現,但本質都是同樣的)。

須要注意的是:當普通函數返回後,進程的地址空間中不會再保存該函數運行時的任何信息,而協程返回後,函數的運行時信息是須要保存下來的。

接下來,咱們就用實際的代碼看一看協程。

六、「Talk is cheap,show me the code」

下面咱們使用一個真實的例子來說解,語言採用python,不熟悉的同窗不用擔憂,這裏不會有理解上的門檻。

在python語言中,這個「定」字一樣使用關鍵詞yield。

這樣咱們的func函數就變成了:

void func() {
  print("a")
  yield
  print("b")
  yield
  print("c")
}

注意:這時咱們的func就再也不是簡簡單單的函數了,而是升級成爲了協程,那麼咱們該怎麼使用呢?

很簡單:

def A():
  co =func() # 獲得該協程
  next(co)    # 調用協程
  print("in function A") # do something
  next(co)    # 再次調用該協程

咱們看到雖然func函數沒有return語句,也就是說雖然沒有返回任何值,可是咱們依然能夠寫co = func()這樣的代碼,意思是說co就是咱們拿到的協程了。

接下來咱們調用該協程,使用next(co),運行函數A看看執行到第3行的結果是什麼:

a

顯然,和咱們的預期同樣,協程func在print("a")後因執行yield而暫停並返回函數A。

接下來是第4行,這個毫無疑問,A函數在作一些本身的事情,所以會打印:

a
in function A

接下來是重點的一行,當執行第5行再次調用協程時該打印什麼呢?

若是func是普通函數,那麼會執行func的第一行代碼,也就是打印a。

但func不是普通函數,而是協程,咱們以前說過,協程會在上一個返回點繼續運行,所以這裏應該執行的是func函數第一個yield以後的代碼,也就是 print("b")

a
in function A
b

看到了吧,協程是一個很神奇的函數,它會本身記住以前的執行狀態,當再次調用時會從上一次的返回點繼續執行。

七、圖形化解釋

爲了讓你更加完全的理解協程,咱們使用圖形化的方式再看一遍。

首先是普通的函數調用:

在該圖中:方框內表示該函數的指令序列,若是該函數不調用任何其它函數,那麼應該從上到下依次執行,但函數中能夠調用其它函數,所以其執行並非簡單的從上到下,箭頭線表示執行流的方向。

從上圖中咱們能夠看到:咱們首先來到funcA函數,執行一段時間後發現調用了另外一個函數funcB,這時控制轉移到該函數,執行完成後回到main函數的調用點繼續執行。這是普通的函數調用。

接下來是協程:

在這裏:咱們依然首先在funcA函數中執行,運行一段時間後調用協程,協程開始執行,直到第一個掛起點,此後就像普通函數同樣返回funcA函數,funcA函數執行一些代碼後再次調用該協程。

注意:協程這時就和普通函數不同了,協程並非從第一條指令開始執行而是從上一次的掛起點開始執行,執行一段時間後遇到第二個掛起點,這時協程再次像普通函數同樣返回funcA函數,funcA函數執行一段時間後整個程序結束。

八、函數只是協程的一種特例

怎麼樣,神奇不神奇。和普通函數不一樣的是,協程能知道本身上一次執行到了哪裏。

如今你應該明白了吧,協程會在函數被暫停運行時保存函數的運行狀態,並能夠從保存的狀態中恢復並繼續運行。

很熟悉的味道有沒有,這不就是操做系統對線程的調度嘛(見《深刻計算機底層,理解線程與線程池》),線程也能夠被暫停,操做系統保存線程運行狀態而後去調度其它線程,此後該線程再次被分配CPU時還能夠繼續運行,就像沒有被暫停過同樣。

只不過線程的調度是操做系統實現的,這些對程序員都不可見,而協程是在用戶態實現的,對程序員可見。

這就是爲何有的人說能夠把協程理解爲用戶態線程的緣由。

此處應該有掌聲。

也就是說如今程序員能夠扮演操做系統的角色了,你能夠本身控制協程在何時運行,何時暫停,也就是說協程的調度權在你本身手上。

在協程這件事兒上,調度你說了算。

當你在協程中寫下 yield 的時候就是想要暫停該協程,當使用 next() 時就是要再次運行該協程。

如今你應該理解爲何說函數只是協程的一種特例了吧,函數其實只是沒有掛起點的協程而已。

九、協程的歷史

有的同窗可能認爲協程是一種比較新的技術,然而其實協程這種概念早在1958年就已經提出來了,要知道這時線程的概念都尚未提出來。

到了1972年,終於有編程語言實現了這個概念,這兩門編程語言就是Simula 67 以及Scheme。

但協程這個概念始終沒有流行起來,甚至在1993年還有人考古同樣專門寫論文挖出協程這種古老的技術。

由於這一時期尚未線程,若是你想在操做系統寫出併發程序那麼你將不得不使用相似協程這樣的技術,後來線程開始出現,操做系統終於開始原生支持程序的併發執行,就這樣,協程逐漸淡出了程序員的視線。

直到近些年,隨着互聯網的發展,尤爲是移動互聯網時代的到來,服務端對高併發的要求愈來愈高,協程再一次重回技術主流,各大編程語言都已經支持或計劃開始支持協程。

那麼協程究竟是如何實現的呢?

十、協程究竟是如何實現的?

讓咱們從問題的本質出發來思考這個問題:協程的本質是什麼呢?

其實就是能夠被暫停以及能夠被恢復運行的函數。那麼能夠被暫停以及能夠被恢復意味着什麼呢?

看過籃球比賽的同窗想必都知道(沒看過的也能知道),籃球比賽也是能夠被隨時暫停的,暫停時你們須要記住球在哪一方,各自的站位是什麼,等到比賽繼續的時候你們回到各自的位置,裁判哨子一響比賽繼續,就像比賽沒有被暫停過同樣。

看到問題的關鍵了嗎:比賽之因此能夠被暫停也能夠繼續是由於比賽狀態被記錄下來了(站位、球在哪一方),這裏的狀態就是計算機科學中常說的上下文(context)。

回到協程。

協程之因此能夠被暫停也能夠繼續,那麼必定要記錄下被暫停時的狀態,也就是上下文,當繼續運行的時候要恢復其上下文(狀態)另外:函數運行時全部的狀態信息都位於函數運行時棧中。

函數運行時棧就是咱們須要保存的狀態,也就是所謂的上下文。

如圖所示:

從上圖中咱們能夠看出:該進程中只有一個線程,棧區中有四個棧幀,main函數調用A函數,A函數調用B函數,B函數調用C函數,當C函數在運行時整個進程的狀態就如圖所示。

如今:咱們已經知道了函數的運行時狀態就保存在棧區的棧幀中,接下來重點來了哦。

既然函數的運行時狀態保存在棧區的棧幀中,那麼若是咱們想暫停協程的運行就必須保存整個棧幀的數據,那麼咱們該將整個棧幀中的數據保存在哪裏呢?

想想這個問題:整個進程的內存區中哪一塊是專門用來長時間(進程生命週期)存儲數據的?是否是大腦又一片空白了?

先別空白!

很顯然:這就是堆區啊(heap),咱們能夠將棧幀保存在堆區中,那麼咱們該怎麼在堆區中保存數據呢?但願你尚未暈,在堆區中開闢空間就是咱們經常使用的C語言中的malloc或者C++中的new。

咱們須要作的就是:在堆區中申請一段空間,讓後把協程的整個棧區保存下,當須要恢復協程的運行時再從堆區中copy出來恢復函數運行時狀態。

再仔細想想,爲何咱們要這麼麻煩的來回copy數據呢?

實際上:咱們須要作的是直接把協程的運行須要的棧幀空間直接開闢在堆區中,這樣都不用來回copy數據了,以下圖所示。

從上圖中咱們能夠看到:該程序中開啓了兩個協程,這兩個協程的棧區都是在堆上分配的,這樣咱們就能夠隨時中斷或者恢復協程的執行了。

有的同窗可能會問,那麼進程地址空間最上層的棧區如今的做用是什麼呢?

答案是:這一區域依然是用來保存函數棧幀的,只不過這些函數並非運行在協程而是普通線程中的。

如今你應該看到了吧,在上圖中實際上共有3個執行流:

  • 1)一個普通線程;
  • 2)兩個協程。

雖然有3個執行流但咱們建立了幾個線程呢?

答案是:一個線程。

如今你應該明白爲何要使用協程了吧:使用協程理論上咱們能夠開啓無數併發執行流,只要堆區空間足夠,同時尚未建立線程的開銷,全部協程的調度、切換都發生在用戶態,這就是爲何協程也被稱做用戶態線程的緣由所在。

掌聲在哪裏?

所以:即便你建立了N多協程,但在操做系統看來依然只有一個線程,也就是說協程對操做系統來講是不可見的。

這也許是爲何協程這個概念比線程提出的要早的緣由,多是寫普通應用的程序員比寫操做系統的程序員最早遇到須要多個並行流的需求,那時可能都尚未操做系統的概念,或者操做系統沒有並行這種需求,因此非操做系統程序員只能本身動手實現執行流,也就是協程。

如今你應該對協程有一個清晰的認知了吧。

十一、協程技術概念小結

正文內容用了較多調侃語氣,目的是但願能輕鬆詼諧地助你理解協程技術概念。那麼,咱們從嚴肅專業知識來小結一下,到底什麼是協程呢?

11.1 協程是比線程更小的執行單元

協程是比線程更小的一種執行單元,你能夠認爲是輕量級的線程。

之因此說輕:其中一方面的緣由是協程所持有的棧比線程要小不少,java當中會爲每一個線程分配1M左右的棧空間,而協程可能只有幾十或者幾百K,棧主要用來保存函數參數、局部變量和返回地址等信息。

咱們知道:而線程的調度是在操做系統中進行的,而協程調度則是在用戶空間進行的,是開發人員經過調用系統底層的執行上下文相關api來完成的。有些語言,好比nodejs、go在語言層面支持了協程,而有些語言,好比C,須要使用第三方庫才能夠擁有協程的能力(好比微信開源的Libco庫就是這樣的,見:《開源libco庫:單機千萬鏈接、支撐微信8億用戶的後臺框架基石》)。

因爲線程是操做系統的最小執行單元,所以也能夠得出,協程是基於線程實現的,協程的建立、切換、銷燬都是在某個線程中來進行的。

使用協程是由於線程的切換成本比較高,而協程在這方面頗有優點。

11.2 協程的切換到底爲何很廉價?

關於這個問題,咱們回顧一下線程切換的過程:

  • 1)線程在進行切換的時候,須要將CPU中的寄存器的信息存儲起來,而後讀入另一個線程的數據,這個會花費一些時間;
  • 2)CPU的高速緩存中的數據,也可能失效,須要從新加載;
  • 3)線程的切換會涉及到用戶模式到內核模式的切換,聽說每次模式切換都須要執行上千條指令,很耗時。

實際上協程的切換之因此快的緣由我認爲主要是:

  • 1)在切換的時候,寄存器須要保存和加載的數據量比較小;
  • 2)高速緩存能夠有效利用;
  • 3)沒有用戶模式到內核模式的切換操做;
  • 4)更有效率的調度,由於協程是非搶佔式的,前一個協程執行完畢或者堵塞,纔會讓出CPU,而線程則通常使用了時間片的算法,會進行不少沒有必要的切換(爲了儘可能讓用戶感知不到某個線程卡)。

十二、寫在最後

寫到這裏,相信你已經理解協程究竟是怎麼一回事了,關於協程更系統的知識能夠自行查閱相關資料,我就再也不囉嗦了。

下一篇《從根上理解高性能、高併發(六):高併發高性能服務器究竟是如何實現的》,敬請期待!

附錄:更多高性能、高併發文章精選

本文已同步發佈於「即時通信技術圈」公衆號。

▲ 本文在公衆號上的連接是:點此進入。同步發佈連接是:http://www.52im.net/thread-3306-1-1.html

相關文章
相關標籤/搜索