解鎖C++異步編程:告別阻塞,擁抱高效
你在使用辦公軟件處理文檔時,點擊保存后,軟件界面突然定格,只能眼巴巴地看著進度條緩慢前進,期間什么操作都做不了,是不是很惱人?這便是傳統同步編程的阻塞問題在作祟。同步編程就像單行道,任務得一個接一個按順序執行,一旦遇到文件讀寫、網絡請求這類耗時操作,整個程序就會被卡住,CPU 資源也只能閑置浪費。
而異步編程,堪稱程序世界的 “多車道高速公路”。當程序碰到耗時任務,無需原地等待,可立即切換到其他車道,繼續執行別的任務。如此一來,CPU 得以充分利用,程序響應速度大幅提升,用戶體驗自然也更上一層樓。
C++ 作為一門以高性能著稱的編程語言,在異步編程領域底蘊深厚,從基礎的多線程技術,到 C++11 引入的 std::async、std::future 等高級特性,再到 C++20 推出的協程,為開發者解鎖高效編程提供了豐富且強大的工具。接下來,就讓我們一同深入 C++ 異步編程的奇妙世界,探索這些工具的用法 。
Part1.異步編程的簡介
1.1什么是異步?
異步編程是一種編程范式,允許程序在等待某些操作時繼續執行其它任務,而不是阻塞或等待這些操作完成;異步(Asynchronous, async)是與同步(Synchronous, sync)相對的概念。
在我們學習的傳統單線程編程中,程序的運行是同步的(同步不意味著所有步驟同時運行,而是指步驟在一個控制流序列中按順序執行)。而異步的概念則是不保證同步的概念,也就是說,一個異步過程的執行將不再與原有的序列有順序關系。
簡單來理解就是:同步按你的代碼順序執行,異步不按照代碼順序執行,異步的執行效率更高。
常見的兩種異步:回調函數、異步Ajax
(1)回調函數
回調函數最常見的是setTimeout,實例如下:
<script type="text/javascript">
setTimeout(function() {
console.log("First")
}, 2000)
console.log("Second")
</script>
正常情況下(同步)應該先輸出First再輸出Second,但結果剛好相反。因為延遲了2秒,所以在這2秒內先輸出了Second,2秒后再輸出了First。
(2)異步Ajax
<button>發送一個 HTTP GET 請求并獲取返回結果</button>
<script>
$(document).ready(function() {
$("button").click(function() {
$.get("data.json", function(data, status) {
console.log("數據: " + data + "\n狀態: " + status);
});
console.log("1111")
});
});
</script>
1.2同步 VS 異步:編程世界的龜兔賽跑
在編程的奇妙世界里,同步與異步是兩種重要的任務執行方式,就如同龜兔賽跑中的烏龜和兔子,有著截然不同的行事風格。
先來說說同步編程,它就像一只穩扎穩打的烏龜 ,代碼按照順序,一個任務接著一個任務地執行。只有當前一個任務徹底完成,拿到了它的返回結果,程序才會繼續向下執行下一個任務。在這個過程中,如果某個任務因為等待資源(比如進行網絡請求、讀取大文件等)而花費了大量時間,整個程序就只能干等著,其他任務也都被阻塞,無法推進 ,就像排隊買票,必須等前面的人買完,下一個人才能上前買票。
而異步編程呢,則像是那只靈活的兔子。當程序遇到一個可能會耗時的任務時,它不會傻等這個任務完成,而是先把這個任務扔到一邊,自己繼續去執行后續的代碼。等那個異步任務完成了,再通過特定的機制(比如回調函數、事件監聽、Promise 等)來通知程序處理結果。這就好比你點了外賣,下單之后不需要一直盯著手機等待外賣送達,你可以繼續做自己的事情,等外賣到了,手機會收到通知(回調)。
1.3異步編程為何在 C++ 中如此重要
C++ 作為一門強大的編程語言,在系統級開發、游戲開發、嵌入式系統、高性能計算等眾多領域都有著廣泛的應用 。在這些場景中,異步編程發揮著舉足輕重的作用。
以系統級開發為例,操作系統需要同時處理多個任務,如文件讀寫、網絡通信、用戶輸入等。如果采用同步編程,當進行文件讀寫時,整個系統可能會被阻塞,無法及時響應其他任務,導致系統性能大幅下降。而異步編程可以讓操作系統在等待文件讀寫完成的過程中,繼續處理其他任務,大大提高了系統的響應速度和吞吐量。
在游戲開發中,為了實現流暢的畫面和實時交互,游戲需要在同一時間內處理圖形渲染、用戶操作、網絡同步等多個任務。異步編程能夠使這些任務并行執行,避免了因某個任務的阻塞而影響整個游戲的運行,從而提升了游戲的性能和用戶體驗。
在高性能計算領域,C++ 常用于處理大規模數據和復雜算法。異步編程可以充分利用多核處理器的優勢,將不同的計算任務分配到不同的核心上并行執行,大大縮短了計算時間,提高了計算效率。
Part2.探索 C++中的異步編程工具
了解了異步編程的重要性之后,接下來就來看看 C++ 中用于實現異步編程的強大工具。
2.1 std::async:異步編程的得力助手
std::async 是 C++ 標準庫提供的一個函數模板,用于異步執行任務。它的基本語法如下:
std::future<返回類型> future = std::async(啟動策略, 函數名, 參數1, 參數2, ...);
其中,啟動策略 是一個可選參數,用于指定任務的執行方式,有以下幾種取值:
- std::launch::async:表示任務將在一個新線程中異步執行。就像你在餐廳點餐,服務員接到你的訂單后,立即把訂單交給廚房的廚師,廚師在廚房(新線程)里開始為你烹飪美食。
- std::launch::deferred:任務會被延遲執行,直到調用 future.get() 或 future.wait() 時才會在調用線程中執行。這就好比服務員先把你的訂單放在一邊,等你催促(調用 get 或 wait)的時候,才開始讓廚師做菜。
- std::launch::async | std::launch::deferred:這是默認策略,由系統決定是立即異步執行還是延遲執行。
如果不指定啟動策略,默認使用 std::launch::async | std::launch::deferred。
std::async 的返回值是一個 std::future 對象,通過它可以獲取異步任務的執行結果 。例如:
#include <iostream>
#include <future>
#include <chrono>
// 模擬一個耗時任務
int heavyTask() {
std::this_thread::sleep_for(std::chrono::seconds(2)); // 模擬耗時2秒
return 42;
}
int main() {
// 啟動異步任務
std::future<int> futureResult = std::async(std::launch::async, heavyTask);
std::cout << "Doing other things while waiting for the task to complete..." << std::endl;
// 獲取異步任務的結果,如果任務還未完成,get() 會阻塞等待
int result = futureResult.get();
std::cout << "The result of the heavy task is: " << result << std::endl;
return 0;
}
在上面的代碼中,std::async 啟動了一個異步任務 heavyTask,主線程在等待任務完成的過程中可以繼續執行其他操作,當調用 futureResult.get() 時,如果任務尚未完成,主線程會被阻塞,直到任務完成并返回結果。
2.2 std::future:獲取異步操作結果的窗口
std::future 是一個模板類,用于獲取異步操作的結果。它就像是一個窗口,通過這個窗口可以窺視異步任務的執行狀態和獲取最終的結果。std::future 提供了以下幾個重要的成員函數:
①get():獲取異步操作的結果。如果異步任務還未完成,調用 get() 會阻塞當前線程,直到任務完成并返回結果。如果任務在執行過程中拋出了異常,get() 會重新拋出該異常。例如:
std::future<int> future = std::async([]() {
return 1 + 2;
});
int result = future.get(); // 阻塞等待任務完成并獲取結果
std::cout << "Result: " << result << std::endl;
②wait():阻塞當前線程,直到異步任務完成,但不返回結果。常用于在不關心結果,只需要等待任務完成的場景。比如:
std::future<void> future = std::async([]() {
// 模擬耗時操作
std::this_thread::sleep_for(std::chrono::seconds(3));
});
std::cout << "Waiting for the task to finish..." << std::endl;
future.wait();
std::cout << "Task finished." << std::endl;
wait_for():阻塞當前線程一段時間,等待異步任務完成。返回一個 std::future_status 枚舉值,表示等待的結果,可能的值有:
- std::future_status::ready:任務已完成。
- std::future_status::timeout:等待超時,任務還未完成。
- std::future_status::deferred:任務是延遲執行的,還未開始執行。
std::future<int> future = std::async([]() {
std::this_thread::sleep_for(std::chrono::seconds(2));
return 42;
});
std::this_thread::sleep_for(std::chrono::seconds(1)); // 主線程先干點別的
auto status = future.wait_for(std::chrono::seconds(1));
if (status == std::future_status::ready) {
int result = future.get();
std::cout << "Task completed, result: " << result << std::endl;
} else if (status == std::future_status::timeout) {
std::cout << "Task is still in progress." << std::endl;
} else if (status == std::future_status::deferred) {
std::cout << "Task is deferred." << std::endl;
}
wait_until():阻塞當前線程直到指定的時間點,等待異步任務完成。用法與 wait_for() 類似,只是等待的結束條件是時間點而不是時間段。
2.3 std::promise:線程間通信的橋梁
std::promise 也是一個模板類,它通常與 std::future 搭配使用,用于在不同線程之間傳遞數據 。std::promise 可以看作是一個承諾,它承諾在未來的某個時刻會提供一個值,而 std::future 則可以獲取這個承諾的值。
std::promise 的基本原理是:在一個線程中創建一個 std::promise 對象,然后將與該 std::promise 關聯的 std::future 對象傳遞給其他線程。在創建 std::promise 的線程中,通過調用 std::promise 的 set_value() 成員函數來設置一個值(或者調用 set_exception() 來設置一個異常),而在其他持有 std::future 的線程中,可以通過 std::future 的 get() 方法來獲取這個值(如果設置的是異常,get() 會拋出該異常)。
下面是一個簡單的示例,展示了如何使用 std::promise 和 std::future 在線程間傳遞數據:
#include <iostream>
#include <thread>
#include <future>
// 線程函數,設置 promise 的值
void setPromiseValue(std::promise<int>& promise) {
std::this_thread::sleep_for(std::chrono::seconds(2)); // 模擬耗時操作
promise.set_value(42); // 設置 promise 的值
}
int main() {
std::promise<int> promise;
std::future<int> future = promise.get_future(); // 獲取與 promise 關聯的 future
std::thread thread(setPromiseValue, std::ref(promise)); // 創建線程并傳遞 promise
std::cout << "Waiting for the result..." << std::endl;
int result = future.get(); // 阻塞等待,直到 promise 設置值
std::cout << "The result is: " << result << std::endl;
thread.join(); // 等待線程結束
return 0;
}
在這個例子中,主線程創建了一個 std::promise<int> 對象和與之關聯的 std::future<int> 對象。然后創建了一個新線程 thread,并將 std::promise 對象傳遞給新線程的函數 setPromiseValue。在新線程中,經過一段時間的模擬耗時操作后,調用 promise.set_value(42) 設置了 std::promise 的值。而主線程在調用 future.get() 時會被阻塞,直到新線程設置了 std::promise 的值,然后獲取到這個值并輸出。
2.4 std::packaged_task:封裝可調用對象的利器
std::packaged_task 是一個模板類,用于封裝一個可調用對象(如函數、lambda 表達式、函數對象等),以便異步執行該任務,并通過 std::future 獲取結果。它的作用是將任務的執行和結果的獲取分離開來,使得任務可以在不同的線程中異步執行 。
std::packaged_task 的基本原理是:將一個可調用對象封裝在 std::packaged_task 對象中,當調用 std::packaged_task 對象時,它會在后臺線程中執行封裝的可調用對象,并將執行結果存儲在一個共享狀態中。通過調用 std::packaged_task 的 get_future() 方法,可以獲取一個與該共享狀態關聯的 std::future 對象,從而在其他線程中獲取任務的執行結果。
例如,假設有一個計算兩個整數之和的函數,現在想要異步執行這個計算任務并獲取結果,可以使用 std::packaged_task 來實現:
#include <iostream>
#include <future>
#include <thread>
// 計算兩個整數之和的函數
int add(int a, int b) {
return a + b;
}
int main() {
// 創建一個 packaged_task 對象,封裝 add 函數
std::packaged_task<int(int, int)> task(add);
// 獲取與 task 關聯的 future 對象,用于獲取任務結果
std::future<int> futureResult = task.get_future();
// 在新線程中執行任務
std::thread thread(std::move(task), 3, 5);
// 主線程可以繼續執行其他操作
std::cout << "Doing other things while the task is running..." << std::endl;
// 獲取異步任務的結果
int result = futureResult.get();
std::cout << "The result of the addition is: " << result << std::endl;
thread.join(); // 等待線程結束
return 0;
}
在上述代碼中,首先創建了一個 std::packaged_task<int(int, int)> 對象 task,并將 add 函數封裝在其中。然后通過 task.get_future() 獲取了一個 std::future<int> 對象 futureResult,用于獲取任務的結果。接著,創建了一個新線程 thread,并將 task 移動到新線程中執行,同時傳遞了 add 函數所需的參數 3 和 5。在主線程中,可以繼續執行其他操作,最后通過 futureResult.get() 獲取異步任務的結果并輸出。
Part3.C++ 異步編程案例分析
3.1案例一:異步文件讀取
在實際應用中,文件讀取是一個常見的操作,尤其是在處理大文件時,如果采用同步讀取方式,可能會導致程序長時間阻塞,影響用戶體驗。下面通過一個示例來展示如何使用 std::async 和 std::future 實現異步文件讀取,并分析其性能優勢。
假設我們有一個大小為 1GB 的大文件 large_file.txt,需要讀取其內容。首先,使用同步方式讀取文件的代碼如下:
#include <iostream>
#include <fstream>
#include <chrono>
int main() {
auto start = std::chrono::high_resolution_clock::now();
std::ifstream file("large_file.txt");
std::string content;
file.seekg(0, std::ios::end);
content.resize(file.tellg());
file.seekg(0, std::ios::beg);
file.read(&content[0], content.size());
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Synchronous read time: " << duration << " ms" << std::endl;
return 0;
}
在上述代碼中,std::ifstream 用于打開文件,通過 seekg 和 tellg 函數獲取文件大小并分配相應的內存空間,然后使用 read 函數將文件內容讀取到字符串 content 中。整個過程是同步的,程序會阻塞直到文件讀取完成。
接下來,使用異步方式讀取文件:
#include <iostream>
#include <fstream>
#include <future>
#include <chrono>
std::string readFileAsync(const std::string& filename) {
std::ifstream file(filename);
std::string content;
file.seekg(0, std::ios::end);
content.resize(file.tellg());
file.seekg(0, std::ios::beg);
file.read(&content[0], content.size());
return content;
}
int main() {
auto start = std::chrono::high_resolution_clock::now();
// 啟動異步任務讀取文件
std::future<std::string> futureContent = std::async(std::launch::async, readFileAsync, "large_file.txt");
// 主線程可以在等待文件讀取的過程中執行其他操作
std::cout << "Doing other things while reading the file..." << std::endl;
// 獲取異步任務的結果
std::string content = futureContent.get();
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Asynchronous read time: " << duration << " ms" << std::endl;
return 0;
}
在這個異步讀取的示例中,定義了一個 readFileAsync 函數,用于讀取文件內容。通過 std::async 啟動一個異步任務來執行這個函數,并返回一個 std::future<std::string> 對象,用于獲取異步任務的結果。在主線程中,調用 futureContent.get() 之前,可以執行其他操作,當調用 get() 時,如果文件尚未讀取完成,主線程會被阻塞,直到讀取完成并返回結果。
通過多次測試,對比同步和異步讀取大文件的時間,發現異步讀取方式在讀取大文件時,雖然整體耗時可能不會有明顯的減少(因為文件讀取本身的 I/O 操作是耗時的主要因素),但它可以讓主線程在等待文件讀取的過程中繼續執行其他任務,提高了程序的響應性和整體效率 。例如,在一個圖形界面應用中,使用異步文件讀取可以避免界面在讀取大文件時出現卡頓現象,用戶可以繼續進行其他操作,如點擊按鈕、切換界面等。
3.2案例二:多線程數據處理
在多線程數據處理場景中,經常會遇到需要多個線程協同工作,共同處理一批數據的情況。同時,也會面臨共享數據競爭和線程同步等問題。下面通過一個示例來展示如何使用 std::thread 和 std::mutex 實現多線程數據處理,并解決共享數據競爭和線程同步問題。
假設我們有一個包含 10000 個整數的數組,需要對每個元素進行平方運算,然后將結果存儲到另一個數組中。首先,使用單線程處理的代碼如下:
#include <iostream>
#include <vector>
#include <chrono>
void squareArraySingleThread(std::vector<int>& input, std::vector<int>& output) {
for (size_t i = 0; i < input.size(); ++i) {
output[i] = input[i] * input[i];
}
}
int main() {
std::vector<int> input(10000);
std::vector<int> output(10000);
for (int i = 0; i < 10000; ++i) {
input[i] = i + 1;
}
auto start = std::chrono::high_resolution_clock::now();
squareArraySingleThread(input, output);
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Single - thread processing time: " << duration << " ms" << std::endl;
return 0;
}
上述代碼中,squareArraySingleThread 函數使用單線程遍歷輸入數組,對每個元素進行平方運算,并將結果存儲到輸出數組中。
接下來,使用多線程處理:
#include <iostream>
#include <vector>
#include <thread>
#include <mutex>
#include <chrono>
std::mutex mutex_;
void squareArrayMultiThread(std::vector<int>& input, std::vector<int>& output, int start, int end) {
for (int i = start; i < end; ++i) {
std::lock_guard<std::mutex> lock(mutex_);
output[i] = input[i] * input[i];
}
}
int main() {
std::vector<int> input(10000);
std::vector<int> output(10000);
for (int i = 0; i < 10000; ++i) {
input[i] = i + 1;
}
auto start = std::chrono::high_resolution_clock::now();
const int numThreads = 4;
std::vector<std::thread> threads;
int chunkSize = input.size() / numThreads;
for (int i = 0; i < numThreads; ++i) {
int startIndex = i * chunkSize;
int endIndex = (i == numThreads - 1)? input.size() : (i + 1) * chunkSize;
threads.emplace_back(squareArrayMultiThread, std::ref(input), std::ref(output), startIndex, endIndex);
}
for (auto& thread : threads) {
thread.join();
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Multi - thread processing time: " << duration << " ms" << std::endl;
return 0;
}
在多線程處理的代碼中,定義了 squareArrayMultiThread 函數,該函數負責處理數組的一部分元素。通過 std::thread 創建多個線程,每個線程處理數組的一個分塊。為了避免多個線程同時訪問和修改輸出數組時出現數據競爭問題,使用了 std::mutex 互斥鎖。std::lock_guard<std::mutex> lock(mutex_); 語句使用了 RAII(Resource Acquisition Is Initialization)機制,在構造時自動鎖定互斥鎖,在析構時自動解鎖,確保了在訪問共享數據時的線程安全性。
通過多次測試,對比單線程和多線程處理數據的時間,發現多線程處理在處理大量數據時具有明顯的性能優勢。因為多線程可以充分利用多核處理器的優勢,將任務分配到不同的核心上并行執行,從而大大縮短了處理時間 。但需要注意的是,線程的創建和管理也會消耗一定的資源,當數據量較小時,多線程處理可能會因為線程調度等開銷而導致性能反而不如單線程。此外,合理地劃分任務分塊大小也會對性能產生影響,需要根據實際情況進行優化。