你在使用办公软件处理文档时,点击保存后,软件界面突然定格,只能眼巴巴地看着进度条缓慢前进,期间什么操作都做不了,是不是很恼人?这便是传统同步编程的阻塞问题在作祟。同步编程就像单行道,任务得一个接一个按顺序执行,一旦遇到文件读写、网络请求这类耗时操作,整个程序就会被卡住,CPU 资源也只能闲置浪费。
而异步编程,堪称程序世界的 “多车道高速公路”。当程序碰到耗时任务,无需原地等待,可立即切换到其他车道,继续执行别的任务。如此一来,CPU 得以充分利用,程序响应速度大幅提升,用户体验自然也更上一层楼。
C++ 作为一门以高性能著称的编程语言,在异步编程领域底蕴深厚,从基础的多线程技术,到 C++11 引入的 std::async、std::future 等高级特性,再到 C++20 推出的协程,为开发者解锁高效编程提供了丰富且强大的工具。接下来,就让我们一同深入 C++ 异步编程的奇妙世界,探索这些工具的用法 。
一、异步编程的简介
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++ 常用于处理大规模数据和复杂算法。异步编程可以充分利用多核处理器的优势,将不同的计算任务分配到不同的核心上并行执行,大大缩短了计算时间,提高了计算效率。
二、探索 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() 获取异步任务的结果并输出。
三、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)机制,在构造时自动锁定互斥锁,在析构时自动解锁,确保了在访问共享数据时的线程安全性。
通过多次测试,对比单线程和多线程处理数据的时间,发现多线程处理在处理大量数据时具有明显的性能优势。因为多线程可以充分利用多核处理器的优势,将任务分配到不同的核心上并行执行,从而大大缩短了处理时间 。但需要注意的是,线程的创建和管理也会消耗一定的资源,当数据量较小时,多线程处理可能会因为线程调度等开销而导致性能反而不如单线程。此外,合理地划分任务分块大小也会对性能产生影响,需要根据实际情况进行优化。