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

本文原題“讀取文件時,程序經歷了什麼?”,本次發佈時有少許改動。

1、系列文章引言

1.1 文章目的

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

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

1.2 文章源起

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

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

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

1.3 文章目錄

1.4 本篇概述

接上篇《深入計算機底層,理解線程與線程池》,本篇是高性能、高併發系列的第2篇文章,在這裏我們來到了I/O這一話題。你有沒有想過,當我們執行文件I/O、網絡I/O操作時計算機底層到底發生了些什麼?對於計算機來說I/O是極其重要的,本篇將帶給你這個問的答案。

2、本文作者

應作者要求,不提供真名,也不提供個人照片。

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

3、不能執行I/O的計算機是什麼?

相信對於程序員來說I/O操作是最爲熟悉不過的了,比如:

  • 1)當我們使用C語言中的printf、C++中的"<<",Python中的print,Java中的System.out.println等時;
  • 2)當我們使用各種語言讀寫文件時;
  • 3)當我們通過TCP/IP進行網絡通信時;
  • 4)當我們使用鼠標龍飛鳳舞時;
  • 5)當我們拿起鍵盤在評論區指點江山亦或是埋頭苦幹努力製造bug時;
  • 6)當我們能看到屏幕上的漂亮的圖形界面時等等。

以上這一切,都是I/O!

想一想:如果沒有I/O計算機該是一種多麼枯燥的設備,不能看電影、不能玩遊戲,也不能上網,這樣的計算機最多就是一個大號的計算器。

既然I/O這麼重要,那麼到底什麼纔是I/O呢?

4、什麼是I/O?

I/O就是簡單的數據Copy,僅此而已!

這一點很重要!

既然是copy數據,那麼又是從哪裏copy到哪裏呢?

如果數據是從外部設備copy到內存中,這就是Input。

如果數據是從內存copy到外部設備,這就是Output。

內存與外部設備之間不嫌麻煩的來回copy數據就是Input and Output,簡稱I/O(Input/Output),僅此而已。

5、I/O與CPU

現在我們知道了什麼是I/O,接下來就是重點部分了,大家注意,坐穩了。

我們知道現在的CPU其主頻都是數GHz起步,這是什麼意思呢?

簡單說就是:CPU執行機器指令的速度是納秒級別的,而通常的I/O比如磁盤操作,一次磁盤seek大概在毫秒級別,因此如果我們把CPU的速度比作戰鬥機的話,那麼I/O操作的速度就是肯德雞。

也就是說當我們的程序跑起來時(CPU執行機器指令),其速度是要遠遠快於I/O速度的。那麼接下來的問題就是二者速度相差這麼大,那麼我們該如何設計、該如何更加合理的高效利用系統資源呢?

既然有速度差異,而且進程在執行完I/O操作前不能繼續向前推進,那麼顯然只有一個辦法,那就是等待(wait)。

同樣是等待,有聰明的等待,也有傻傻的等待,簡稱傻等,那麼是選擇聰明的等待呢還是選擇傻等呢?

假設你是一個急性子(CPU),需要等待一個重要的文件,不巧的是這個文件只能快遞過來(I/O),那麼這時你是選擇什麼事情都不幹了,深情的注視着門口就像盼望着你的哈尼一樣專心等待這個快遞呢?還是暫時先不要管快遞了,玩個遊戲看個電影刷會兒短視頻等快遞來了再說呢?

很顯然,更好的方法就是先去幹其它事情,快遞來了再說。

因此:這裏的關鍵點就是快遞沒到前手頭上的事情可以先暫停,切換到其它任務,等快遞過來了再切換回來。

理解了這一點你就能明白執行I/O操作時底層都發生了什麼。

接下來讓我們以讀取磁盤文件爲例來講解這一過程。

6、執行I/O時底層都發生了什麼

在上一篇《深入計算機底層,理解線程與線程池》中,我們引入了進程和線程的概念。

在支持線程的操作系統中,實際上被調度的是線程而不是進程,爲了更加清晰的理解I/O過程,我們暫時假設操作系統只有進程這樣的概念,先不去考慮線程,這並不會影響我們的討論。

現在內存中有兩個進程,進程A和進程B,當前進程A正在運行。

如下圖所示:

進程A中有一段讀取文件的代碼,不管在什麼語言中通常我們定義一個用來裝數據的buff,然後調用read之類的函數。

就像這樣:

read(buff);

這就是一種典型的I/O操作,當CPU執行到這段代碼的時候會向磁盤發送讀取請求。

注意:與CPU執行指令的速度相比,I/O操作操作是非常慢的,因此操作系統是不可能把寶貴的CPU計算資源浪費在無謂的等待上的,這時重點來了,注意接下來是重點哦。

由於外部設備執行I/O操作是相當慢的,因此在I/O操作完成之前進程是無法繼續向前推進的,這就是所謂的阻塞,即通常所說的block。

操作系統檢測到進程向I/O設備發起請求後就暫停進程的運行,怎麼暫停運行呢?很簡單:只需要記錄下當前進程的運行狀態並把CPU的PC寄存器指向其它進程的指令就可以了。

進程有暫停就會有繼續執行,因此操作系統必須保存被暫停的進程以備後續繼續執行,顯然我們可以用隊列來保存被暫停執行的進程。

如下圖所示,進程A被暫停執行並被放到阻塞隊列中(注意:不同的操作系統會有不同的實現,可能每個I/O設備都有一個對應的阻塞隊列,但這種實現細節上的差異不影響我們的討論)。

這時操作系統已經向磁盤發送了I/O請求,因此磁盤driver開始將磁盤中的數據copy到進程A的buff中。雖然這時進程A已經被暫停執行了,但這並不妨礙磁盤向內存中copy數據。

注意:現代磁盤向內存copy數據時無需藉助CPU的幫助,這就是所謂的DMA(Direct Memory Access)。

這個過程如下圖所示:

讓磁盤先copy着數據,我們接着聊。

實際上:操作系統中除了有阻塞隊列之外也有就緒隊列,所謂就緒隊列是指隊列裏的進程準備就緒可以被CPU執行了。

你可能會問爲什麼不直接執行非要有個就緒隊列呢?答案很簡單:那就是僧多粥少,在即使只有1個核的機器上也可以創建出成千上萬個進程,CPU不可能同時執行這麼多的進程,因此必然存在這樣的進程,即使其一切準備就緒也不能被分配到計算資源,這樣的進程就被放到了就緒隊列。

現在進程B就位於就緒隊列,萬事俱備只欠CPU。

如下圖所示:

當進程A被暫停執行後CPU是不可以閒下來的,因爲就緒隊列中還有嗷嗷待哺的進程B,這時操作系統開始在就緒隊列中找下一個可以執行的進程,也就是這裏的進程B。

此時操作系統將進程B從就緒隊列中取出,找出進程B被暫停時執行到的機器指令的位置,然後將CPU的PC寄存器指向該位置,這樣進程B就開始運行啦。

如下圖所示:

注意:接下來的這段是重點中的重點!

注意觀察上圖:此時進程B在被CPU執行,磁盤在向進程A的內存空間中copy數據,看出來了嗎——大家都在忙,誰都沒有閒着,數據copy和指令執行在同時進行,在操作系統的調度下,CPU、磁盤都得到了充分的利用,這就是程序員的智慧所在。

現在你應該理解爲什麼操作系統這麼重要了吧。

此後磁盤終於將全部數據都copy到了進程A的內存中,這時磁盤通知操作系統任務完成啦,你可能會問怎麼通知呢?這就是中斷。

操作系統接收到磁盤中斷後發現數據copy完畢,進程A重新獲得繼續運行的資格,這時操作系統小心翼翼的把進程A從阻塞隊列放到了就緒隊列當中。

如下圖所示:

注意:從前面關於就緒狀態的討論中我們知道,操作系統是不會直接運行進程A的,進程A必須被放到就緒隊列中等待,這樣對大家都公平。

此後進程B繼續執行,進程A繼續等待,進程B執行了一會兒後操作系統認爲進程B執行的時間夠長了,因此把進程B放到就緒隊列,把進程A取出並繼續執行。

注意:操作系統把進程B放到的是就緒隊列,因此進程B被暫停運行僅僅是因爲時間片到了而不是因爲發起I/O請求被阻塞。

如下圖所示:

進程A繼續執行,此時buff中已經裝滿了想要的數據,進程A就這樣愉快的運行下去了,就好像從來沒有被暫停過一樣,進程對於自己被暫停一事一無所知,這就是操作系統的魔法。

現在你應該明白了I/O是一個怎樣的過程了吧。

這種進程執行I/O操作被阻塞暫停執行的方式被稱爲阻塞式I/O,blocking I/O,這也是最常見最容易理解的I/O方式,有阻塞式I/O就有非阻塞式I/O,在這裏我們暫時先不考慮這種方式。

在本節開頭我們說過暫時只考慮進程而不考慮線程,現在我們放寬這個條件,實際上也非常簡單,只需要把前圖中調度的進程改爲線程就可以了,這裏的討論對於線程一樣成立。

7、零拷貝(Zero-copy)

最後需要注意的一點就是:上面的講解中我們直接把磁盤數據copy到了進程空間中,但實際上一般情況下I/O數據是要首先copy到操作系統內部,然後操作系統再copy到進程空間中。

因此我們可以看到這裏其實還有一層經過操作系統的copy,對於性能要求很高的場景其實也是可以繞過操作系統直接進行數據copy的,這也是本文描述的場景,這種繞過操作系統直接進行數據copy的技術被稱爲Zero-copy,也就零拷貝,高併發、高性能場景下常用的一種技術,原理上很簡單吧。

PS:對於搞即時通訊開發的Java程序員來說,著名的高性能網絡框架Netty就使用了零拷貝技術,具體可以讀《NIO框架詳解:Netty的高性能之道》一文的第12節。如果對於Netty框架很好奇但不瞭解的話,可以因着這兩篇文章入門:《新手入門:目前爲止最透徹的的Netty高性能原理和框架架構解析》、《史上最通俗Netty入門長文:基本介紹、環境搭建、動手實戰》。

8、本文小結

本文講解的是程序員常用的I/O(包括所謂的網絡I/O),一般來說作爲程序員我們無需關心,但是理解I/O背後的底層原理對於設計比如IM這種高性能、高併發系統是極爲有益的,希望這篇能對大家加深對I/O的認識有所幫助。

接下來的一篇《從根上理解高性能、高併發(三):深入操作系統,徹底理解I/O多路複用》將要分享的是I/O技術的一大突破,正是因爲它,才徹底解決了高併發網絡通信中的C10K問題(見《高性能網絡編程(二):上一個10年,著名的C10K併發連接問題),敬請期待!

附錄:相關資料

高性能網絡編程(一):單臺服務器併發TCP連接數到底可以有多少

高性能網絡編程(二):上一個10年,著名的C10K併發連接問題

高性能網絡編程(三):下一個10年,是時候考慮C10M併發問題了

高性能網絡編程(四):從C10K到C10M高性能網絡應用的理論探索

高性能網絡編程(五):一文讀懂高性能網絡編程中的I/O模型

高性能網絡編程(六):一文讀懂高性能網絡編程中的線程模型

高性能網絡編程(七):到底什麼是高併發?一文即懂!

本文已同步發佈於“即時通訊技術圈”公衆號。

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章