異步、並發、協程原理


Linux 操作系統在設計上將虛擬空間划分為用戶空間和內核空間,兩者做了隔離是相互獨立的,用戶空間給應用程序使用,內核空間給內核使用。

一、異步

應用程序和內核

內核具有最高權限,可以訪問受保護的內存空間,可以訪問底層的硬件設備。而這些是應用程序所不具備的,但應用程序可以通過調用內核提供的接口來間接訪問或操作。所謂的常見的 IO 模型就是基於應用程序和內核之間的交互所提出來的。以一次網絡 IO 請求過程中的 read 操作為例,請求數據會先拷貝到系統內核的緩沖區(內核空間),再從操作系統的內核緩沖區拷貝到應用程序的地址空間(用戶空間)。而從內核空間將數據拷貝到用戶空間過程中,就會經歷兩個階段:

  • 等待數據准備
  • 拷貝數據

也正因為有了這兩個階段,才提出了各種網絡 I/O 模型。

Unix/Linux的體系架構

同步和異步

同步(Synchronised)和異步(Asynchronized)的概念描述的是應用程序與內核的交互方式,同步是指應用程序發起 I/O 請求后需要等待或者輪詢內核 I/O 操作完成后才能繼續執行;而異步是指應用程序發起 I/O 請求后仍繼續執行,當內核 I/O 操作完成后會通知應用程序,或者調用應用程序注冊的回調函數。

阻塞和非阻塞

阻塞和非阻塞的概念描述的是應用程序調用內核 IO 操作的方式,阻塞是指 I/O 操作需要徹底完成后才返回到用戶空間;而非阻塞是指 I/O 操作被調用后立即返回給用戶一個狀態值,無需等到 I/O 操作徹底完成。

常見的網絡I/O模型大概有四種:

  1. 同步阻塞IO(Blocking IO)
  2. 同步非阻塞IO(Non-blocking IO)
  3. IO多路復用(IO Multiplexing)
  4. 異步IO(Asynchronous IO)

IO多路復用

多路 I/O 復用模型是利用 select、poll、epoll 可以同時監察多個流的 I/O 事件的能力,在空閑的時候,會把當前線程阻塞掉,當有一個或多個流有 I/O 事件時,就從阻塞態中喚醒,於是程序就會輪詢一遍所有的流(epoll 是只輪詢那些真正發出了事件的流),並且只依次順序的處理就緒的流,這種做法就避免了大量的無用操作。這里“多路”指的是多個網絡連接,“復用”指的是復用同一個線程。采用多路 I/O 復用技術可以讓單個線程高效的處理多個連接請求(盡量減少網絡 IO 的時間消耗)。 IO 多路復用是異步阻塞的。

二、並發

並發,在操作系統中,是指 一個時間段 中有幾個程序都處於已啟動運行到運行完畢之間,且這幾個程序都是在同一個處理機上運行,但任一個時刻點上只有一個程序在處理機上運行。

並發和並行的區別:

  • 並發(concurrency):邏輯上具備同時處理多個任務的能力。
  • 並行(parallesim):物理上在同一時刻執行多個並發任務,依賴多核處理器等物理設備。

多線程或多進程是並行的基本條件,但單線程也可用協程做到並發。通常情況下,用多進程來實現分布式和負載平衡,減輕單進程垃圾回收壓力;用多線程搶奪更多的處理器資源;用協程來提高處理器時間片利用率。現代系統中,多核 CPU 可以同時運行多個不同的進程或者線程。所以並發程序可以是並行的,也可以不是。

三、協程

在了解協程前先了解一些概念:

1、線程模型

在現代計算機結構中,先后提出過兩種線程模型:用戶級線程(user-level threads)和內核級線程(kernel-level threads)。所謂用戶級線程是指,應用程序在操作系統提供的單個控制流的基礎上,通過在某些控制點(比如系統調用)上分離出一些虛擬的控制流,從而模擬多個控制流的行為。由於應用程序對指令流的控制能力相對較弱,所以,用戶級線程之間的切換往往受線程本身行為以及線程控制點選擇的影響,線程是否能公平地獲得處理器時間取決於這些線程的代碼特征。而且,支持用戶級線程的應用程序代碼很難做到跨平台移植,以及對於多線程模型的透明。用戶級線程模型的優勢是線程切換效率高,因為它不涉及系統內核模式和用戶模式之間的切換;另一個好處是應用程序可以采用適合自己特點的線程選擇算法,可以根據應用程序的邏輯來定義線程的優先級,當線程數量很大時,這一優勢尤為明顯。但是,這同樣會增加應用程序代碼的復雜性。有一些軟件包(如 POSIXThreads 或 Pthreads 庫)可以減輕程序員的負擔。

內核級線程往往指操作系統提供的線程語義,由於操作系統對指令流有完全的控制能力,甚至可以通過硬件中斷來強迫一個進程或線程暫停執行,以便把處理器時間移交給其他的進程或線程,所以,內核級線程有可能應用各種算法來分配處理器時間。線程可以有優先級,高優先級的線程被優先執行,它們可以搶占正在執行的低優先級線程。在支持線程語義的操作系統中,處理器的時間通常是按線程而非進程來分配,因此,系統有必要維護一個全局的線程表,在線程表中記錄每個線程的寄存器、狀態以及其他一些信息。然后,系統在適當的時候掛起一個正在執行的線程,選擇一個新的線程在當前處理器上繼續執行。這里“適當的時候”可以有多種可能,比如:當一個線程執行某些系統調用時,例如像 sleep 這樣的放棄執行權的系統函數,或者像 wait 或 select 這樣的阻塞函數;硬中斷(interrupt)或異常(exception);線程終止時,等等。由於這些時間點的執行代碼可能分布在操作系統的不同位置,所以,在現代操作系統中,線程調度(thread scheduling)往往比較復雜,其代碼通常分布在內核模塊的各處。

內核級線程的好處是,應用程序無須考慮是否要在適當的時候把控制權交給其他的線程,不必擔心自己霸占處理器而導致其他線程得不到處理器時間。應用線程只要按照正常的指令流來實現自己的邏輯即可,內核會妥善地處理好線程之間共享處理器的資源分配問題。然而,這種對應用程序的便利也是有代價的,即,所有的線程切換都是在內核模式下完成的,因此,對於在用戶模式下運行的線程來說,一個線程被切換出去,以及下次輪到它的時候再被切換進來,要涉及兩次模式切換:從用戶模式切換到內核模式,再從內核模式切換回用戶模式。在 Intel 的處理器上,這種模式切換大致需要幾百個甚至上千個處理器指令周期。但是,隨着處理器的硬件速度不斷加快,模式切換的開銷相對於現代操作系統的線程調度周期(通常幾十毫秒)的比例正在減小,所以,這部分開銷是完全可以接受的。

除了線程切換的開銷是一個考慮因素以外,線程的創建和刪除也是一個重要的考慮指標。當線程的數量較多時,這部分開銷是相當可觀的。雖然線程的創建和刪除比起進程要輕量得多,但是,在一個進程內建立起一個線程的執行環境,例如,分配線程本身的數據結構和它的調用棧,完成這些數據結構的初始化工作,以及完成與系統環境相關的一些初始化工作,這些負擔是不可避免的。另外,當線程數量較多時,伴隨而來的線程切換開銷也必然隨之增加。所以,當應用程序或系統進程需要的線程數量可能比較多時,通常可采用線程池技術作為一種優化措施,以降低創建和刪除線程以及線程頻繁切換而帶來的開銷。

在支持內核級線程的系統環境中,進程可以容納多個線程,這導致了多線程程序設計(multithreaded programming)模型。由於多個線程在同一個進程環境中,它們共享了幾乎所有的資源,所以,線程之間的通信要方便和高效得多,這往往是進程間通信(IPC,Inter-Process Communication)所無法比擬的,但是,這種便利性也很容易使線程之間因同步不正確而導致數據被破壞,而且,這種錯誤存在不確定性,因而相對來說難以發現和調試。

2、什么是協同式和搶占式?

許多協同式多任務操作系統,也可以看成協程運行系統。說到協同式多任務系統,一個常見的誤區是認為協同式調度比搶占式調度“低級”,因為我們所熟悉的桌面操作系統,都是從協同式調度(如 Windows 3.2, Mac OS 9 等)過渡到搶占式多任務系統的。實際上,調度方式並無高下,完全取決於應用場景。搶占式系統允許操作系統剝奪進程執行權限,搶占控制流,因而天然適合服務器和圖形操作系統,因為調度器可以優先保證對用戶交互和網絡事件的快速響應。當年 Windows 95 剛剛推出的時候,搶占式多任務就被作為一大買點大加宣傳。協同式調度則等到進程時間片用完或系統調用時轉移執行權限,因此適合實時或分時等等對運行時間有保障的系統。

另外,搶占式系統依賴於 CPU 的硬件支持。 因為調度器需要“剝奪”進程的執行權,就意味着調度器需要運行在比普通進程高的權限上,否則任何“流氓(rogue)”進程都可以去剝奪其他進程了。只有 CPU 支持了執行權限后,搶占式調度才成為可能。x86 系統從 80386 處理器開始引入 Ring 機制支持執行權限,這也是為何 Windows 95 和 Linux 其實只能運行在 80386 之后的 x86 處理器上的原因。而協同式多任務適用於那些沒有處理器權限支持的場景,這些場景包含資源受限的嵌入式系統和實時系統。在這些系統中,程序均以協程的方式運行。調度器負責控制流的讓出和恢復。通過協程的模型,無需硬件支持,我們就可以在一個“簡陋”的處理器上實現一個多任務的系統。我們見到的許多智能設備,如運動手環,基於硬件限制,都是采用協同調度的架構。

協程基本概念

“協程”(Coroutine)概念最早由 Melvin Conway 於 1958 年提出。協程可以理解為純用戶態的線程,其通過協作而不是搶占來進行切換。相對於進程或者線程,協程所有的操作都可以在用戶態完成,創建和切換的消耗更低。總的來說,協程為協同任務提供了一種運行時抽象,這種抽象非常適合於協同多任務調度和數據流處理。在現代操作系統和編程語言中,因為用戶態線程切換代價比內核態線程小,協程成為了一種輕量級的多任務模型。

從編程角度上看,協程的思想本質上就是控制流的主動讓出(yield)和恢復(resume)機制,迭代器常被用來實現協程,所以大部分的語言實現的協程中都有 yield 關鍵字,比如 Python、PHP、Lua。但也有特殊比如 Go 就使用的是通道來通信。

有趣的是協程的歷史其實要早於線程。

WIKI 的解釋:

Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing multiple entry points for suspending and resuming execution at certain locations. Coroutines are well-suited for implementing more familiar program components such as cooperative tasks, exceptions, event loop, iterators, infinite lists and pipes.

進程、線程、協程的特點及區別

進程(process)

  • 進程是資源分配的最小單位
  • 進程間不共享內存,每個進程擁有自己獨立的內存
  • 進程間可以通過信號、信號量、共享內存、管道、隊列等來通信
  • 新開進程開銷大,並且 CPU 切換進程成本也大
  • 進程由操作系統調度
  • 多進程方式比多線程更加穩定

線程(thread)

  • 線程是程序執行流的最小單位
  • 線程是來自於進程的,一個進程下面可以開多個線程
  • 每個線程都有自己一個棧,不共享棧,但多個線程能共享同一個屬於進程的堆
  • 線程因為是在同一個進程內的,可以共享內存
  • 線程也是由操作系統調度,線程是 CPU 調度的最小單位
  • 新開線程開銷小於進程,CPU 在切換線程成本也小於進程
  • 某個線程發生致命錯誤會導致整個進程崩潰
  • 線程間讀寫變量存在鎖的問題處理起來相對麻煩

協程(coroutine)

  • 對於操作系統來說只有進程和線程,協程的控制由應用程序顯式調度,非搶占式的
  • 協程的執行最終靠的還是線程,應用程序來調度協程選擇合適的線程來獲取執行權
  • 切換非常快,成本低。一般占用棧大小遠小於線程(協程 KB 級別,線程 MB 級別),所以可以開更多的協程
  • 協程比線程更輕量級

不同模型下用戶空間與內核空間的關系:

注:協程可以理解為上圖中的用戶級線程模型。

支持協程的語言

  • Simula
  • Modula-2
  • C#
  • Lua
  • Go
  • JavaScript(ECMA-262 6th Edition)
  • Python
  • Ruby
  • Erlang
  • PHP(PHP5.5+)
C協程

C 標准庫里的函數 setjmp 和 longjmp 可以用來實現一種協程。

Go協程

Go 語言是原生支持語言級並發的,這個並發的最小邏輯單元就是 goroutine。goroutine 就是 Go 語言提供的一種用戶態線程,當然這種用戶態線程是跑在內核級線程之上的。當我們創建了很多的 goroutine,並且它們都是跑在同一個內核線程之上的時候,就需要一個 調度器(scheduler)來維護這些 goroutine,確保所有的 goroutine 都使用 CPU,並且是盡可能公平的使用 CPU 資源。Go 的 scheduler 比較復雜,它實現了 M:N 的模式。M:N 模式指的是多個 goroutine 在多個內核線程上跑,Go 的 scheduler 可參考>>。goroutine 讓 Go 低成本地具有了高並發運算能力。另外 Go 協程是通過通道(channel)來通信的。

注意:goroutine 的實現並不完全是傳統意義上的協程。在協程阻塞的時候(CPU 計算或者文件 IO 等),多個 goroutine 會變成多線程的方式執行。

func main() {
for i := 0; i < 100; i++ {
go func() { // 啟動一個goroutine
fmt.Println(i)
}()
}
}
Python協程

Python 協程基於 Generator。Python 實現的 grep 例子:

def grep(pattern):
while True:
line = (yield)
if pattern in line:
print(line)

search = grep('coroutine')
next(search) # 啟動一個協程
search.send("send sha ne")
Lua協程

Lua 中的協同是一協作的多線程,每一個協同等同於一個線程,yield-resume 可以實現在線程中切換。然而與真正的多線程不同的是,協同是非搶占式的。當程序運行到 yield 的時候,使用協程將上下文環境記錄住,然后將程序操作權歸還到主函數,當主函數調用 resume 的時候,會重新喚起協程,讀取 yield 記錄的上下文。這樣形成了程序語言級別的多協程操作。

co = coroutine.create( -- 創建coroutine
function(i)
print(i);
end
)

coroutine.resume(co, 1) -- 喚醒coroutine
print(coroutine.status(co)) -- 查看coroutine的狀態

co = coroutine.wrap(
function(i)
print(i);
end
)

co(1)

co2 = coroutine.create(
function()
for i=1,10 do
print(i)
if i == 3 then
print(coroutine.status(co2))
print(coroutine.running()) -- 返回正在跑的coroutine
end
coroutine.yield() -- 掛起coroutine
end
end
)
PHP協程

PHP 5.5 一個比較好的新功能是加入了對迭代生成器和協程的支持。PHP 協程也是基於 Generator,Generator 可以視為一種“可中斷”的函數,而 yield 構成了一系列的“中斷點”。PHP 協程沒有 resume 關鍵字,而是“在使用的時候喚起”協程。

function xrange($start, $end, $step = 1) {
for ($i = $start; $i <= $end; $i += $step) {
yield $i;
}
}

foreach (xrange(1, 1000000) as $num) { // xrange返回的是一個Generator對象
echo $num, "\n";
}

Swoole 在 2.0 開始內置協程(Coroutine)的能力,提供了具備協程能力 IO 接口(統一在命名空間Swoole\Coroutine*)。基於 setjmp、longjmp 實現,在進行協程切換時會自動保存 Zend VM 的內存狀態(主要是 EG 全局內存和 vm stack)。

由於 Swoole 是在底層封裝了協程,所以對比傳統的 PHP 層協程框架,開發者不需要使用 yield 關鍵詞來標識一個協程 IO 操作,所以不再需要對 yield 的語義進行深入理解以及對每一級的調用都修改為 yield。

適合使用協程的場景

  1. 協程適合於 IO 密集型場景,這樣能提高並發性,比如請求接口、Mysql、Redis 等的操作;
  2. PHP 中利用協程還可以低成本處理處理大數據集合。參考>>
  3. 替代異步回調的代碼風格;(協程令開發者可以無感知的用同步的代碼編寫方式達到異步 IO 的效果和性能,避免了傳統異步回調所帶來的離散的代碼邏輯和陷入多層回調中導致代碼無法維護。但相比普通的異步回調程序,協程寫法會多增加額外的內存占用和一些 CPU 開銷。 )

四、協程與異步和並發的聯系

協程與異步:協程並不是說替換異步,協程一樣可以利用異步實現高並發。

協程與並發:協程要利用多核優勢就需要比如通過調度器來實現多協程在多線程上運行,這時也就具有了並行的特性。如果多協程運行在單線程或單進程上也就只能說具有並發特性。

 

轉載:樊浩柏科學院


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM