非同步程式設計真的讓程式更快了嗎?

萤火架构發表於2024-03-11

同步和非同步呼叫的本質區別是什麼?

引言

現在非同步程式設計真的是越來越普遍了,從前端的Promise到後端的Channel、Future、Task,非同步程式設計正變得越來越流行。很多同學也玩得很溜了,滿世界的非同步呼叫,讓程式的效率和使用者體驗都大大提升。不過,當談到為什麼要使用非同步程式設計,以及它背後的工作原理時,大部分同學就啞火了。對於一個有追求的程式設計師來說,我們不僅要會用,更要理解其中的原理,所謂“知其所以然”。

而且非同步程式設計並不是銀彈,本質上它不會讓程式執行的更快,使用它也伴隨著複雜的錯誤處理和除錯難題,比如著名的“回撥地獄”。因此,瞭解它的工作原理,以及正確地使用它,對於編寫高質量的程式碼來說特別重要。

本文,我們就來一起探討下同步和非同步呼叫的本質區別,深入解析非同步程式設計的工作原理,以及介紹如何在實際開發中靈活運用這兩種呼叫方式。

概念

要討論問題,首先得明確概念,也就是我們到底在說什麼。

同步呼叫,簡單來說,就是執行多個任務的時候,其中一個任務必須完成後,才能開始下一個任務。在這種模式下,任務按照順序依次執行,每個任務的執行必須等待前一個任務完成,所以大家也稱之為阻塞呼叫。

在程式設計中,同步呼叫的一個典型應用場景是資料庫事務。比如,在事務中更新一系列的記錄時,系統會按照順序執行這些操作,直到全部完成,期間不會去處理其他任務。這確保了資料的一致性和完整性,但也意味著在事務處理期間,其他依賴於這些資料的操作必須等待。

非同步呼叫,顧名思義,是一種任務可以在後臺執行,而不阻塞當前執行緒繼續執行其他任務的呼叫方式,這可以使多個任務得以並行處理。

在程式設計中,非同步呼叫的一個典型應用場景是網路請求。比如,前端向伺服器請求資料時,我們可以不需要讓整個應用停下來等待伺服器的響應。透過非同步呼叫,前端可以在等待伺服器響應的同時,繼續執行其他任務,比如響應使用者的輸入,這會提高使用者體驗。

簡單來說,同步呼叫就像是在排隊取餐,不能走開,而非同步呼叫則像是掃碼點餐,可以去做其他事情,等飯好了給你送過來。

非同步的優勢所在

更快

這裡先丟擲一個問題:非同步會不會讓程式執行的更快?

我們以經典的網路請求場景為例,當客戶端使用非同步的方式發起一次請求後,程式霸佔的當前執行緒就被底層系統分配去幹別的事情去了,然後請求會在網路上傳遞極短的一些時間,到達服務端後再進行一段時間的處理,最後再透過網路將處理結果返回給客戶端底層系統,底層系統再喚起之前的任務繼續處理。

在這個過程中,網路來回傳輸的時間、服務端處理的時間都沒有受到非同步呼叫的任何影響,反而可能會因為非同步呼叫產生任務切換而增加網路請求的響應時間。所以單次的非同步呼叫並沒有讓程式執行的更快。

但是但是,非同步呼叫還是可能會讓程式整體執行的更快。還是以網路請求場景為例,假設我們需要在頁面上發起3個網路請求,每個網路請求的響應時間都是基本相同的,同步的情況下我們只能一個一個的幹,總的響應時間就是單次網路請求響應時間的3倍,如果換成非同步呼叫,理想情況下,這三個網路請求可以在服務端並行處理,而網路傳輸的時間是極短的,那麼總的響應時間可能就是一個比單次網路請求響應時間略高一點的數字。所以非同步呼叫相比同步呼叫,很有可能會讓程式整體執行的更快。

談到更快時,我們這裡一直比較的就是時間,如果網路傳輸的時間、服務端處理的時間都很短,短到就像本地的一次函式呼叫,那麼非同步也不會讓程式更快。所以根本的問題是網路傳輸的時間太慢、服務端處理的時間太慢,它們相比CPU的處理速度要慢上很多個數量級,所以這才讓非同步有了可乘之機,而非同步就是在這些網路IO、磁碟IO等慢速裝置的通訊上發揮主要作用。

更多

我們以一個服務端網路處理程式為例,當請求到達服務端時,程式會給這個請求分配一個執行緒,用來執行相關的服務端處理程式,假設這個處理中還要呼叫別的API,同步呼叫和非同步呼叫就會出現不同的行為了。

同步呼叫時,執行緒會一直等在這裡,等待的時候誰也不能搶走這個執行緒,直到這次內部呼叫返回結果,然後繼續處理,直到全部完成,最後返回給呼叫方。

非同步呼叫時,呼叫發起後,執行緒就被底層系統分配給別的任務了,比如用來接收新的網路請求,等這次內部呼叫的結果返回後,底層系統再為本次任務分配執行緒資源,然後繼續處理,直到全部完成,最後返回給呼叫方。

我們可以看到,在使用非同步呼叫的情況下,執行緒的利用率提高了,而這會節省大量的伺服器資源。比如,在Linux系統中,一個執行緒會佔用8M的記憶體資源,那麼同步呼叫時,8G的記憶體也就能同時接入大概1000個請求,改為非同步呼叫後,8G的記憶體能同時接入多少請求呢?這裡做一個不是很嚴謹的計算,假設1個請求的完整處理時間為100毫秒,請求接入到發起非同步呼叫的時間為1毫秒,那麼使用非同步呼叫後,8G記憶體就能在這100毫秒內接收100倍的請求,也就是10萬個請求。

這也是Go語言、Node.js等可以輕鬆駕馭高併發的核心法門。

更省

有一種說法是非同步呼叫後,CPU就去幹別的了,不用等著網路請求返回,所以節省了CPU資源。其實現代作業系統一般沒有這麼傻,它有一套比較科學的CPU排程演算法,CPU並不會傻傻的等著網路請求返回,除非我們使用特殊的方法霸佔著CPU不放。這種說法可能只在古老的作業系統或者一些特殊的嵌入式系統中存在。

非同步節省記憶體資源是實實在在的,同樣的網路請求數量下,需要的執行緒更少了,佔用的記憶體也就更少了。

更好的使用者體驗

我們可以以一個現代Web應用的例項來說明。當使用者在一個複雜的Web應用中進行操作時,比如提交一個表單,這個表單的資料需要透過網路傳送到伺服器。在這個過程中,我們不希望使用者介面凍結或變得無響應。透過使用非同步呼叫傳送資料,使用者介面可以繼續響應其他使用者操作,比如滾動頁面、點選其他按鈕等。伺服器的響應會在資料處理完成後返回,這時應用會相應地更新使用者介面,而使用者可能都沒有注意到這個後臺的資料交換過程。

非同步的實現原理

接下來,我們深入探討一下非同步是怎麼做到上邊這一切的,特別是事件迴圈、回撥函式,以及Promises和Async/Await這些概念。以Node.js為例,可以先看看這張圖,下邊會有詳細介紹。

非同步程式設計真的讓程式更快了嗎?

事件迴圈

在一家餐廳裡,有一個廚師(CPU)和一個服務員(事件迴圈)。當顧客(任務)下單(發起非同步呼叫)後,服務員記錄下訂單,然後繼續服務其他顧客。廚師在後廚準備好食物後,服務員再將食物遞給對應的顧客。這個過程中,服務員不斷的在顧客和廚師之間迴圈,確保每個顧客的需求都得到滿足,這就是事件迴圈的機制。

在不同的作業系統和語言框架中,事件迴圈的具體實現可能有所不同,但核心思想是一致的:使得單執行緒環境下,可以高效地處理多個非同步任務,而不會造成阻塞。

Node.js

Node.js是一個基於Chrome V8引擎的JavaScript執行環境,它使用事件驅動、非阻塞IO模型,非常適合處理大量的併發連線。Node.js的事件迴圈由libuv庫實現,這個庫專門為了提高Node.js的非同步IO效能而設計。

在Node.js中,事件迴圈負責執行使用者程式碼、收集和處理事件,以及執行佇列中的子任務。

.NET

在.NET框架中,非同步程式設計模型(Asynchronous Programming Model, APM)和基於任務的非同步模式(Task-based Asynchronous Pattern, TAP)都是.NET中處理非同步操作的方式。.NET中的事件迴圈不像Node.js那樣明顯,因為.NET應用通常執行在多執行緒環境下,透過執行緒池(Thread Pool)來處理非同步任務。

在.NET中,非同步操作通常透過Task來表示,搭配使用async和await關鍵字讓非同步程式碼的編寫和閱讀更加直觀。.NET執行時會負責排程這些Task到執行緒池中的執行緒上執行,從而實現非阻塞的非同步操作。

作業系統

語言框架的非同步處理都是基於作業系統的底層支援。

在作業系統層面,Linux和Windows提供了不同的機制來實現高效的IO事件處理。

  • Linux上的epoll是一種高效的IO事件通知機制,它允許應用程式監視多個檔案描述符,以瞭解是否有IO操作可執行。epoll相比於傳統的select或poll,在處理大量併發連線時可以顯著減少資源消耗和提高效能。
  • Windows上的IO完成埠(IOCP)是一個高效的執行緒池技術,用於處理大量的併發IO操作。IOCP能夠將IO操作的完成通知直接與執行緒池結合起來,當IO操作完成時,相應的處理執行緒會被喚醒來處理結果。

語言框架為了實現非同步操作,在不同的作業系統上會選擇相應的非同步IO處理方式。

回撥函式

回撥函式就像是你對服務員說:“當我的漢堡準備好了,請通知我。”服務員(事件迴圈)記下了這個請求,當廚師(CPU)做好漢堡後,服務員會回來通知你。這個過程就是回撥機制。

然而,如果你的要求變得複雜,比如:“我的漢堡準備好後,請通知我,然後我會要求加薯條,薯條準備好後,請再通知我,我可能還會有其他要求……”這樣的多層次回撥會導致所謂的“回撥地獄”,使得程式碼難以閱讀和維護。

function prepareBurger(callback) {
  console.log("開始準備漢堡...");
  setTimeout(() => {
    console.log("漢堡準備好了!");
    callback("漢堡");
  }, 2000); // 假設準備漢堡需要2秒鐘
}

function prepareFries(callback) {
  console.log("開始準備薯條...");
  setTimeout(() => {
    console.log("薯條準備好了!");
    callback("薯條");
  }, 1500); // 假設準備薯條需要1.5秒鐘
}

// 請求漢堡,然後請求薯條
prepareBurger(function(burger) {
  console.log("你的" + burger + "已經準備好了。");

  // 漢堡準備好後,請求薯條
  prepareFries(function(fries) {
    console.log("你的" + fries + "也準備好了。");

    // 如果這裡還有更多的非同步請求,程式碼會繼續巢狀下去...
  });
});

Promises和Async/Await

為了解決“回撥地獄”的問題,現代程式語言引入了Promises和Async/Await,以Javascript為例:

Promises 就像是你給服務員下了一個訂單,並得到了一個“承諾”。服務員說:“我保證會告訴你何時你的漢堡準備好。”這樣,你就不需要在櫃檯前等待,而是可以去做其他事情,服務員會在承諾的時間裡來通知你。

function prepareBurger() {
  // 返回一個Promise物件
  return new Promise((resolve, reject) => {
    console.log("開始準備漢堡...");
    setTimeout(() => {
      // 模擬漢堡準備過程
      console.log("漢堡準備好了!");
      resolve("漢堡"); // 成功完成時呼叫resolve
    }, 2000); // 假設準備漢堡需要2秒鐘
  });
}

// 呼叫prepareBurger,並處理結果
prepareBurger().then(burger => {
  console.log("你的" + burger + "已經準備好了。");
}).catch(error => {
  console.log("出錯了:" + error);
});

Promise的寫法看起來還是有點怪異,Async/Await 則是在Promises的基礎上,讓非同步程式碼看起來更像同步程式碼。使用async/await時,你可以用同步的方式寫非同步程式碼,這讓程式碼更加直觀易懂。比如,你對服務員說:“我會在這裡等,你準備好漢堡後直接給我。”儘管實際上漢堡的準備是非同步的,但對你來說,就像是同步等待結果一樣。

async function getOrder() {
  try {
    // 等待prepareBurger完成,並獲取結果
    const burger = await prepareBurger();
    console.log("你的" + burger + "已經準備好了。");
  } catch (error) {
    // 處理可能發生的錯誤
    console.log("出錯了:" + error);
  }
}

// 呼叫getOrder
getOrder();

async/await 其實還利用了協程的一些處理方式,協程不是作業系統提供的,而是由程式語言框架在使用者程式中實現的,在非同步程式設計中,它就是用來在IO操作發起後,將執行緒分給其它的任務,在IO操作完成後再給任務分配執行緒。具體到JavaScript中,是透過Generator生成器實現的,它可以控制函式的暫停和恢復,async/await只是做了一個包裝,實際執行時,執行引擎會轉換處理。

在 .NET 平臺中,同樣支援使用 async/await 的方式編寫非同步程式碼,只不過 Promise 變成了 Task。

總結

最後,讓我們總結一下同步呼叫和非同步呼叫的區別,以及它們對軟體開發的影響。

首先,同步呼叫就像是在餐廳裡排隊取餐,你得等服務員把飯端上來後才能幹別的事情;而非同步呼叫則像是掃碼點餐,餐點製作的時候,你可以去做任何其他事情。簡而言之,同步呼叫會阻塞當前操作直到任務完成,而非同步呼叫不會,它允許程式在等待過程中繼續執行其他任務。

對軟體開發來說,這兩種呼叫方式的本質區別影響深遠。同步呼叫因為簡單直接,適合那些必須順序執行、步步為營的任務,特別是計算密集型的任務,非同步了也沒有可以節省的地方;但是,在處理IO操作等耗時任務時,同步呼叫可能會導致程式"卡住",既霸佔大量的資源,又影響使用者體驗,此時選擇非同步呼叫則能更有效的利用計算資源,且顯著提高程式的響應性和效能,尤其是在需要大量IO操作的場景下,比如網路伺服器、大型資料庫操作等。

以上就是本文的主要內容,如有問題歡迎留言討論!

相關文章