Linux內核中斷和io

首先考慮一個很簡單的問題,

假設一段代碼a和b,a是cpu密集型運算,b是io密集運算。
a的運行時間是O(a)
b的運行時間是O(b)
如果用同步代碼寫的話,a+b的運行時間是 O(a+b),
用協程寫,launch{a+b}的時間C,C < O(a+b) 嗎?

這個問題的答案是,C確實會小於O(a+b)。

很多開發者,包括很多技術大V對協程的解釋比較淺,認爲沒這種好事,C肯定還是等於O(a+b)的。

這個結論跟常識其實有點相反。一開始學習相關的技術點會覺得違反常理,但隨着學習的深入,會發現這東西本來就應該是這樣。

理解這個問題需要明白兩個東西,

· Linux內核中斷
· IO總線機制

下面的內容會盡量避免用專業技術知識,用最簡單的邏輯解釋爲啥。

Linux內核中斷

學習過計算機原理,或者學過單片機的應該聽說過中斷。

在Linux中也有中斷,只是複雜了很多。通常來說中斷可以簡單分爲硬件中斷和軟件中斷。硬件中斷是CPU外圍硬件發起的,CPU通過引腳收到中斷後,需要調用中斷處理程序去處理中斷。

軟件中斷則是由軟件發起內核調用調起的中斷。CPU同樣會在收到中斷後調用預先放在內存中的中斷處理程序去處理這個中斷請求。

舉個例子說明中斷是幹嘛用的。我們平時對磁盤進行讀寫,其實都是通過中斷來完成。比如在Linux系統裏想讀一個文件,或者說在android裏,這個流程可以簡化成這樣。

app:hi 系統,我想讀一個磁盤文件
操作系統:收到。hi CPU,你先停一下手上的活,這裏有個讀文件的事情給你
CPU:收到。(放下手上的活)hi IO總線,跟磁盤說一下,去讀那個扇區上的文件
磁盤:loading…
磁盤:找到文件了,把文件內容寫入到緩衝區…
磁盤:CPU你停一下,文件讀完了
CPU:收到。hi 操作系統,跟app說一下可以讀數據了

app:收到。正在從緩衝區讀數據…

上面的流程跟大部分開發的常識是不一樣的。一般我們說讀文件,就是一行代碼,

File f = new File(...)

但實際上在這行代碼背後,是CPU-操作系統-總線互相配合完成了一系列的操作。

現在回到上面的例子,你會發現一個很有趣的地方,
在cpu請求IO去讀文件的時候,cpu其實是沒幹活的
這也是爲什麼我們說IO是一個阻塞操作。因爲這時候,當前進程或者說線程,佔用了CPU並且啥事沒做乾等着。

問題來了,既然CPU的控制權還在手上,並且處於空轉狀態,是不是意味着可以把控制權交出去給其他任務進行計算?

協程的核心思想就是基於這麼個理論。所以協程的掛起時機都是在suspend函數中。golang做的更極致,在編譯過程中對於會發生掛起的地方,都直接幫開發者在編譯過程封裝成goroutine。

IO總線機制

總線包含了很多複雜的知識,爲了說明中斷和IO的關係,這篇文章裏的描述很多地方都做了簡化。

在計算機中,CPU和硬盤在一塊主板上,硬盤通過數據線連接到主板。這條數據線也叫SATA總線。

串行ATA(英語:Serial ATA,全稱:Serial Advanced Technology Attachment)是一種電腦總線,負責主板和大容量存儲設備(如硬盤及光盤驅動器)之間的數據傳輸,主要用於個人電腦

在很久以前,計算機讀取存儲設備裏的數據是非常慢的。很多人可能沒見過3.5寸盤,上古時期還沒有硬盤的時候,數據都存儲在一塊3.5寸盤裏,再往前一點還有5寸盤。

在操作系統中讀一塊磁盤的數據,需要通過磁盤驅動器或者光驅去讀。CPU首先要調用驅動器的驅動程序,通過SATA總線,讓驅動器掃描磁盤,定位到扇區,再定位到文件位置,最後才能讀數據。這個過程還涉及到Linux內核態和用戶態的切換,這裏省略不講。

現在的固態硬盤讀個文件可能非常快,而在幾十年前讀磁盤實際是個非常開銷性能的阻塞操作。磁盤技術經過幾十年的發展,現在磁盤的讀寫速度已經非常快了,但這個交互邏輯延續了下來。其實不止磁盤IO,網絡IO,也是一樣的邏輯。

不管是在什麼系統裏,安卓也好linux也好,每個IO流程都一樣。首先需要操作系統發起中斷,CPU調用中斷處理程序,通過總線通知驅動器去讀磁盤。當CPU完成這個事情之後它就處於一個等待狀態。

對於CPU和IO總線的交互方式,開發者就提出了 select 模型。select 的原理是由操作系統去輪訓文件操作符(Linux系統裏的概念),直到這個文件操作符有數據了,再把數據返回給應用層。

select操作在現在很多框架裏也還有身影,比如Redis的源碼中,就提供了select的默認實現。Redis除了select之外,還提供了另外一個叫 epoll 的操作用來替代select。

epoll是什麼?

思考select的過程就會發現,在緩衝區的寫事件完成之前,CPU在不停地輪詢。輪詢可以每10ms,每50ms,或者每100ms判斷緩衝區是否寫完成。不管時間間隔多少,這個過程其實都是對CPU算力的一個浪費。所以就有人提出一個新的思路,CPU通過總線通知驅動器讀之後,就不管它了。等驅動器寫完緩衝區,在由總線發起中斷,CPU得到中斷後,再去緩衝區讀數據。

這裏就是協程可以發揮作用的地方。假設在一個線程裏發起讀磁盤請求,那麼在CPU發起請求到等待緩衝區的這個過程中,它的算力並沒有被利用起來。一個很自然的想法是,這個時候能不能先把CPU的控制權交出來,給其他計算邏輯,等到收到中斷了再回來去讀緩衝區數據?

最開始實現這個技術的是微軟。他們提供了一種叫Fiber的技術,在CPU空閒的時候可以把控制權交出去給其他Fiber運行。

Fiber也叫纖程,大部分開發可能都沒聽過這個概念。它現在仍然在微軟的官網上,有興趣可以看下。

微軟的Fiber-纖程

上面的鏈接裏有纖程的demo,摘取部分如下,

int __cdecl _tmain(int argc, TCHAR *argv[])
{
   LPFIBERDATASTRUCT fs;

   if (argc != 3)
   {
      printf("Usage: %s <SourceFile> <DestinationFile>\n", argv[0]);
      return RTN_USAGE;
   }

   //
   // Allocate storage for our fiber data structures
   //
   fs = (LPFIBERDATASTRUCT) HeapAlloc(
                              GetProcessHeap(), 0,
                              sizeof(FIBERDATASTRUCT) * FIBER_COUNT);

   if (fs == NULL)
   {
      printf("HeapAlloc error (%d)\n", GetLastError());
      return RTN_ERROR;
   }

   //
   // Allocate storage for the read/write buffer
   //
   g_lpBuffer = (LPBYTE)HeapAlloc(GetProcessHeap(), 0, BUFFER_SIZE);
   if (g_lpBuffer == NULL)
   {
      printf("HeapAlloc error (%d)\n", GetLastError());
      return RTN_ERROR;
   }

   //
   // Open the source file
   //
   fs[READ_FIBER].hFile = CreateFile(
                                    argv[1],
                                    GENERIC_READ,
                                    FILE_SHARE_READ,
                                    NULL,
                                    OPEN_EXISTING,
                                    FILE_FLAG_SEQUENTIAL_SCAN,
                                    NULL
                                    );

   if (fs[READ_FIBER].hFile == INVALID_HANDLE_VALUE)
   {
      printf("CreateFile error (%d)\n", GetLastError());
      return RTN_ERROR;
   }

   //
   // Open the destination file
   //
   fs[WRITE_FIBER].hFile = CreateFile(
                                     argv[2],
                                     GENERIC_WRITE,
                                     0,
                                     NULL,
                                     CREATE_NEW,
                                     FILE_FLAG_SEQUENTIAL_SCAN,
                                     NULL
                                     );

   if (fs[WRITE_FIBER].hFile == INVALID_HANDLE_VALUE)
   {
      printf("CreateFile error (%d)\n", GetLastError());
      return RTN_ERROR;
   }

   //
   // Convert thread to a fiber, to allow scheduling other fibers
   //
   g_lpFiber[PRIMARY_FIBER]=ConvertThreadToFiber(&fs[PRIMARY_FIBER]);

   if (g_lpFiber[PRIMARY_FIBER] == NULL)
   {
      printf("ConvertThreadToFiber error (%d)\n", GetLastError());
      return RTN_ERROR;
   }

   //
   // Initialize the primary fiber data structure.  We don't use
   // the primary fiber data structure for anything in this sample.
   //
   fs[PRIMARY_FIBER].dwParameter = 0;
   fs[PRIMARY_FIBER].dwFiberResultCode = 0;
   fs[PRIMARY_FIBER].hFile = INVALID_HANDLE_VALUE;

   //
   // Create the Read fiber
   //
   g_lpFiber[READ_FIBER]=CreateFiber(0,ReadFiberFunc,&fs[READ_FIBER]);

   if (g_lpFiber[READ_FIBER] == NULL)
   {
      printf("CreateFiber error (%d)\n", GetLastError());
      return RTN_ERROR;
   }

   fs[READ_FIBER].dwParameter = 0x12345678;

   //
   // Create the Write fiber
   //
   g_lpFiber[WRITE_FIBER]=CreateFiber(0,WriteFiberFunc,&fs[WRITE_FIBER]);

   if (g_lpFiber[WRITE_FIBER] == NULL)
   {
      printf("CreateFiber error (%d)\n", GetLastError());
      return RTN_ERROR;
   }

   fs[WRITE_FIBER].dwParameter = 0x54545454;

   //
   // Switch to the read fiber
   //
   SwitchToFiber(g_lpFiber[READ_FIBER]);

代碼省略了部分,最後一行的SwitchToFiber就是切協程。如果瞭解其他語言比如python的話,就知道在python的協程中切協程的api也叫switch。

不過微軟的纖程實在是太難用了,就像張三丰在武當山上當衆耍了一遍太極,張無忌和一萬個人都看到了,結果只有張無忌學會了一樣。張無忌能學會是因爲他本身有九陽神功和乾坤大挪移做內功,有這個基礎再加上天賦,去學其他東西就易如反掌。使用Fiber則需要非常深厚的編程實力,除了一些頂級程序員外,其他人用Fiber差不多是搬石頭砸自己腳。雖然Fiber退出的時間非常早,但一直都默默無聞。

好在後來golang把協程發揚光大,不過那是後話了。

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