C++异步编程8大核心错误解析:从std::async到协程的实战避坑指南
1. 项目概述:为什么C++异步编程的“坑”如此致命?
干了二十多年C++,从MFC时代的多线程消息泵,到C++11的std::async,再到如今协程满天飞,我最大的感触就是:异步编程是C++性能的放大器,也是程序稳定性的粉碎机。一个看似简单的异步调用,背后可能潜伏着数据竞争、生命周期管理、资源泄漏等一系列“定时炸弹”。新手往往被std::async一句代码启动线程的便利性所迷惑,老手也可能在复杂的回调地狱或协程调度中翻车。这篇文章,我想抛开那些教科书式的API讲解,直接聚焦在我和团队这二十年来,用无数个不眠之夜和线上事故换来的8个最致命、最高频的错误。这些错误,轻则导致内存泄漏、数据错乱,重则直接引发程序崩溃、服务雪崩,而且它们极其隐蔽,在开发环境和轻度测试下很难复现。
如果你正在使用std::async,std::future,std::promise,或者基于事件循环的库(如Boost.Asio、libuv),亦或是探索C++20的协程,那么这些“坑”你大概率都会遇到。理解它们,不是为了背诵规则,而是为了建立一套正确的异步心智模型——知道数据在哪里流动,生命周期由谁管理,异常该如何穿越线程边界。这比单纯学会几个API要重要得多。
2. 核心错误一:误用std::async的启动策略,导致“异步”变“同步”
这是新手甚至部分有经验的开发者最容易踩中的第一个坑,它直接摧毁了异步编程提升性能的初衷。
2.1std::async的两种策略:std::launch::asyncvsstd::launch::deferred
std::async的默认启动策略并非一定是开新线程。它的函数签名是:
template< class Function, class... Args > std::future<std::invoke_result_t<std::decay_t<Function>, std::decay_t<Args>...>> async( Function&& f, Args&&... args ); // 使用默认策略 template< class Function, class... Args > std::future<std::invoke_result_t<std::decay_t<Function>, std::decay_t<Args>...>> async( std::launch policy, Function&& f, Args&&... args );关键在于,如果你不显式指定策略,它使用的是std::launch::async | std::launch::deferred。这意味着标准库实现可以自由选择是立即异步执行(async)还是延迟执行(deferred)。
延迟执行(deferred)的陷阱:当策略包含deferred时,任务不会立即启动。它只会在你调用future.get()或future.wait()时,在调用get/wait的线程上同步执行。这完全违背了异步的初衷。
// 一个危险的例子 auto future = std::async([]{ std::this_thread::sleep_for(std::chrono::seconds(2)); std::cout << "Task done\n"; return 42; }); // ... 这里本希望进行一些其他计算,与上述任务并发 std::cout << "Doing other work...\n"; // 当调用get时,如果任务被延迟,将在这里阻塞2秒! int result = future.get(); // 可能在这里才同步执行任务,阻塞当前线程在上面的代码中,“Doing other work...”的输出和2秒的睡眠理论上应该并发。但如果实现选择了deferred,那么整个流程就变成了:打印“Doing other work...”,然后阻塞2秒执行任务,最后获取结果。性能提升为零。
2.2 实战经验与明确建议
始终显式指定启动策略:除非你明确需要延迟执行的语义(这种场景极少),否则永远使用
std::launch::async。// 正确的做法 auto future = std::async(std::launch::async, []{ // 你的任务 });理解“异步”的成本:
std::launch::async保证任务会在一个新线程(或线程池)中执行。创建线程是有开销的。对于极其轻量级的任务(比如简单的加法),异步执行的开销可能远大于任务本身,得不偿失。这时你需要考虑更轻量的并发机制,或者将小任务批量提交。注意析构时的同步:即使你使用了
std::launch::async,还有一个隐藏行为:std::future的析构函数会阻塞,直到关联的异步任务完成。这意味着如果你不保存future的返回值,临时对象会在表达式结束时析构,导致隐式等待。void fireAndForget() { // 错误!临时future立即析构,会阻塞等待任务完成,不是真正的“发射后不管” std::async(std::launch::async, []{ /* 长时间任务 */ }); // 函数不会立即返回 }要实现真正的“发射后不管”,你需要让
future的生命周期延长,例如将其存储到某个全局容器或类成员中(并注意生命周期管理),或者使用其他专门用于此模式的库(如线程池)。
3. 核心错误二:忽视std::future析构的阻塞行为
这个错误是上一个错误的延伸,但因其隐蔽性和重要性,必须单独列为一项。
3.1 阻塞析构的机制与影响
std::future对象代表了一个异步计算结果的句柄。标准规定:如果这个future是通过std::async创建的(且启动策略是async),并且它是最后一个引用该共享状态的future,那么它的析构函数将阻塞,直到关联的异步任务执行完毕。
这背后的逻辑是确保异步任务的副作用(如修改捕获的引用、输出等)在future对象生命周期结束前完成,避免资源泄漏和未定义行为。但这带来了一个非常反直觉的后果。
{ auto fut = std::async(std::launch::async, []{ std::this_thread::sleep_for(std::chrono::seconds(5)); std::cout << "Long task finished.\n"; }); // fut 的作用域在此结束 } // 这里会阻塞大约5秒,等待异步任务完成! std::cout << "Scope exited.\n";很多开发者期望在作用域结束时,函数立即返回,后台任务继续运行。但实际是,程序会卡在右花括号处5秒。这在很多场景下是不可接受的,比如在UI事件循环或网络服务器的主循环中,这种阻塞会导致界面卡死或无法处理新请求。
3.2 如何规避:从std::future到std::shared_future与线程池
使用
std::shared_future:std::shared_future是可以拷贝的,它的析构不会阻塞。只有当最后一个shared_future副本被销毁时,如果它是由std::async创建的,才会发生阻塞等待。你可以通过future.share()来获取一个shared_future。{ auto fut = std::async(std::launch::async, []{ /* 长任务 */ }).share(); // 转换为 shared_future // 现在 fut 是 shared_future,可以安全拷贝 auto fut_copy = fut; } // 这里 fut 和 fut_copy 都析构,但因为是 shared_future,且不是最后一个?注意:需要让 shared_future 脱离局部作用域。 // 更常见的做法是将 shared_future 存储到生命周期更长的上下文中。但请注意,这并没有完全解决问题,只是将阻塞点转移了。最终还是要管理好最后一个
shared_future的生命周期。采用线程池模式:这是生产环境中最推荐的做法。不要为每个小任务都通过
std::async启动线程,而是使用一个全局或局部线程池。任务被提交到线程池的任务队列,future(或类似的句柄)的析构行为由线程池实现定义,通常不会阻塞提交线程。像Intel TBB、微软的PPL,或开源的BS::thread_pool等库都提供了优秀的实现。// 伪代码示例,使用一个简单的线程池 ThreadPool pool(4); // 4个工作者线程 auto future = pool.submit([]{ // 你的任务 }); // future 的析构不会阻塞,因为任务生命周期由线程池管理线程池还能避免频繁创建销毁线程的开销,是性能敏感应用的必备。
明确的任务生命周期管理:如果必须使用
std::async,那么请显式地管理future对象的生命周期。例如,将future存储为类的成员变量,在类的析构函数中调用get()或wait()来显式等待,这样就把阻塞行为放在了可控的、预期的地方。
4. 核心错误三:在异步任务中捕获局部变量的引用或指针
这是导致悬空引用和内存访问错误的头号杀手,在多线程环境下,这类错误引发的崩溃往往难以定位。
4.1 问题根源:生命周期不同步
在C++中,lambda表达式通过捕获列表来访问外部变量。当你在一个函数中创建lambda,并用std::async异步执行时,lambda(连同其捕获的变量)可能会被拷贝或移动到新线程的上下文中。关键在于时间差:发起异步调用的函数(父作用域)可能很快返回,导致其栈帧被销毁,而异步任务才刚刚开始甚至还未开始执行。
std::future<int> dangerousAsync() { int localValue = 42; // 局部变量,在栈上 // 错误!捕获了局部变量的引用 auto fut = std::async(std::launch::async, [&localValue]() { std::this_thread::sleep_for(std::chrono::milliseconds(100)); return localValue * 2; // 访问可能已销毁的栈内存!未定义行为! }); return fut; // 函数返回,localValue 被销毁 } // 调用方 auto fut = dangerousAsync(); int result = fut.get(); // 可能导致崩溃或读取到垃圾数据即使捕获的是指针,问题也一样存在。如果指针指向的是堆内存,但该内存在异步任务访问前被释放了,同样会导致悬空指针。
4.2 安全捕获的黄金法则
默认使用值捕获(
[=]或显式列出变量):对于基本类型和可移动/拷贝的类型,值捕获是最安全的。它会将变量在lambda创建时点的值拷贝一份。int localValue = 42; auto fut = std::async(std::launch::async, [localValue]() { // 值捕获 // 安全地使用 localValue 的副本 return localValue * 2; });对于不可拷贝或移动开销大的对象,使用
std::shared_ptr或std::unique_ptr进行间接管理:如果你想在异步任务中操作一个大的资源或对象,不要直接捕获其引用。而是将它包装在智能指针中,捕获智能指针。auto bigData = std::make_shared<BigObject>(/* ... */); auto fut = std::async(std::launch::async, [bigData]() { // 捕获 shared_ptr,增加引用计数 bigData->process(); }); // 即使当前作用域结束,只要异步任务还在运行,bigData 就不会被释放绝对避免捕获
this指针:在类成员函数中启动异步任务,并需要访问类成员时,这是一个极其常见的错误模式。class MyClass { int member = 100; void unsafeMethod() { // 致命错误!如果 MyClass 对象在任务执行前被销毁... auto fut = std::async(std::launch::async, [this]() { return member; // 访问已销毁对象的成员 }); } };正确做法:捕获一个由
shared_from_this()返回的std::shared_ptr<MyClass>(前提是你的类继承自std::enable_shared_from_this),或者确保类对象的生命周期明确长于所有异步任务(例如,在类析构函数中等待所有任务完成)。使用
std::promise和std::future传递结果,而非通过捕获的引用修改外部变量:这是更清晰的范式。异步任务将计算结果设置到promise中,调用方通过对应的future来获取。std::future<int> safeAsync() { auto promise = std::make_shared<std::promise<int>>(); auto future = promise->get_future(); std::thread([promise]() { // 捕获 promise 的 shared_ptr int result = doHeavyWork(); promise->set_value(result); // 传递结果 }).detach(); // 注意线程管理 return future; }
5. 核心错误四:未正确处理异步任务中的异常
同步代码中,异常会沿着调用栈向上传播,直到被捕获。但在异步世界中,异常被隔离在了另一个线程的上下文中。如果不对其进行特殊处理,异常会悄无声息地吞没,导致程序行为异常却无任何日志,这是最可怕的“静默失败”。
5.1std::future的异常传递机制
std::future提供了一种将异常从工作线程传递到主线程(或任何等待结果的线程)的机制。当你在异步任务中抛出异常,并且该异常未被任务内部捕获,那么这个异常会被std::async或std::packaged_task捕获,并存储到关联的共享状态中。
关键点在于:这个异常不会立即导致程序崩溃。只有当你在调用future.get()时,存储的异常才会在调用get()的线程上被重新抛出。
auto fut = std::async(std::launch::async, []() { throw std::runtime_error("Something bad happened in async task!"); return 1; }); // ... 此时异步任务已因异常结束,但程序不会崩溃 try { int result = fut.get(); // 在这里,异常被重新抛出! } catch (const std::runtime_error& e) { std::cerr << "Caught async exception: " << e.what() << std::endl; }5.2 异常处理的常见陷阱与最佳实践
永远不要忽略
future.get()的返回值或异常:如果你不关心结果,也应该调用future.get()或future.wait(),并在可能的情况下用try-catch包裹,至少将异常记录下来。更好的做法是,在异步任务内部就用try-catch块包裹核心逻辑,将捕获的异常转换为错误码或日志信息。使用
std::future::wait_for或std::future::wait_until时要检查就绪状态:这些函数返回一个std::future_status。如果状态是std::future_status::ready,你才能安全调用get()。如果任务因异常结束,get()会抛出异常;如果任务正常结束,get()返回值。如果你在未就绪时调用get(),它会阻塞等待。auto status = fut.wait_for(std::chrono::milliseconds(100)); if (status == std::future_status::ready) { try { auto result = fut.get(); // 处理结果 } catch (...) { // 处理异常 } } else { // 超时处理 }为异步任务设计明确的错误处理接口:对于复杂的异步操作,考虑不使用异常,而是定义一套包含错误码和结果值的返回类型(如
std::expected或自定义的Result<T, E>结构)。这可以让错误处理逻辑更清晰,也更容易与不支持异常的代码或环境交互。注意异常安全与资源泄漏:即使在异步任务中,也要遵循RAII原则。如果任务在持有锁、打开文件或分配内存时抛出异常,要确保这些资源能被正确释放。使用智能指针、锁守卫(
std::lock_guard)等是基本要求。
6. 核心错误五:在多线程环境下共享可变状态未加锁
这是一个经典的并发问题,但在异步编程的语境下,由于其“异步”和“看似独立”的特性,更容易被开发者忽视。std::async虽然简化了任务的启动,但并没有提供任何数据同步的魔法。
6.1 数据竞争(Data Race)的典型场景
当多个异步任务(即使是通过多个std::async调用创建)访问同一块内存区域,并且至少有一个是写操作,且没有正确的同步时,就会发生数据竞争。结果是未定义行为,可能表现为程序崩溃、计算结果错误、或出现极其诡异且难以复现的bug。
std::vector<int> sharedData(1000, 0); // 共享数据 std::vector<std::future<void>> futures; for (int i = 0; i < 10; ++i) { futures.push_back(std::async(std::launch::async, [&sharedData, i]() { // 捕获引用! // 多个线程并发修改 sharedData,没有锁! for (auto& num : sharedData) { num += i; // 数据竞争! } })); } // 等待所有任务完成 for (auto& fut : futures) { fut.wait(); } // 此时 sharedData 的内容是不可预测的6.2 同步原语的选择与性能考量
std::mutex(互斥锁):最基础的同步工具。在访问共享数据前加锁,访问后解锁。std::mutex dataMutex; std::vector<int> sharedData; auto fut = std::async(std::launch::async, [&sharedData, &dataMutex]() { std::lock_guard<std::mutex> lock(dataMutex); // RAII锁守卫,构造时加锁,析构时解锁 // 安全地修改 sharedData sharedData.push_back(42); });注意:锁的粒度要尽可能细。锁住整个大循环和锁住单次数据访问,性能差异巨大。同时要警惕死锁(两个线程互相等待对方持有的锁)。
std::atomic(原子操作):对于简单的标量类型(如int,bool,指针),使用原子变量通常是更高性能的选择。原子操作保证了对该变量的读-改-写操作是不可分割的。std::atomic<int> counter{0}; auto fut = std::async(std::launch::async, [&counter]() { for (int i = 0; i < 1000; ++i) { counter.fetch_add(1, std::memory_order_relaxed); // 原子递增 } });但
std::atomic不能用于保护复杂的数据结构或需要多个变量保持一致性(不变量)的场景。无锁数据结构与线程局部存储:
- 无锁队列:如
boost::lockfree::spsc_queue或moodycamel::ConcurrentQueue,适用于生产者-消费者模式,性能极高。 thread_local:如果数据不需要在线程间共享,或者每个线程需要自己的副本,使用thread_local关键字是完美的选择。这彻底避免了同步开销。
- 无锁队列:如
重新设计,避免共享:这是最高明的策略。审视你的设计,是否可以通过任务分解、数据划分(将大数据集分成互不相交的子集分给不同任务处理)、或结果归并的方式,让每个异步任务只操作自己独立的数据副本,最后再合并结果。这从根本上消除了数据竞争的可能。C++17的并行算法(
std::for_each+std::execution::par)就是这种思想的体现。
7. 核心错误六:滥用std::future导致回调地狱与逻辑割裂
std::future提供了“拉”(pull)模型的结果获取方式:你启动一个任务,然后在未来的某个时间点去“拉取”结果。对于简单的单一异步任务,这很直观。但对于多个有依赖关系的异步任务,代码会迅速变得复杂和难以维护。
7.1 回调地狱(Callback Hell)的C++版本
假设任务B依赖于任务A的结果,任务C又依赖于任务B的结果。使用朴素的std::future,你会写出这样的代码:
auto futA = std::async(std::launch::async, doTaskA); auto resultA = futA.get(); // 阻塞等待A完成 auto futB = std::async(std::launch::async, [resultA] { return doTaskB(resultA); }); auto resultB = futB.get(); // 阻塞等待B完成 auto futC = std::async(std::launch::async, [resultB] { return doTaskC(resultB); }); auto resultC = futC.get();这段代码有两个大问题:1)同步阻塞:虽然在异步执行任务,但主线程在get()处是阻塞的,无法做其他事情。2)无法组合:如果我想在A完成后,同时启动B和另一个不依赖A的任务D,逻辑就变得很别扭。
更糟糕的是,如果你想在任务完成后执行某个回调(非阻塞),就需要手动管理线程或使用额外的设施,代码会迅速演变成“回调地狱”。
7.2 解决方案:迈向更高级的抽象
使用
std::future的then续延(C++未标准化,但可用第三方库或自己实现):概念上,你希望表达“当这个future就绪后,接着做那件事”。这被称为continuation。虽然C++标准库没有直接提供future::then,但你可以通过包装来实现,或者使用std::experimental::future(如果编译器支持)以及第三方库如Folly或Boost.Thread。// 伪代码,展示 then 的链式调用思想 async(doTaskA) .then([](ResultA a) { return doTaskB(a); }) .then([](ResultB b) { return doTaskC(b); }) .then([](ResultC c) { processFinalResult(c); });这种方式让异步流程的描述变得线性且清晰。
采用基于回调的异步库(如Boost.Asio):在网络编程中,Boost.Asio是事实标准。它使用
async_*函数配合完成处理程序(handler)或回调函数。通过asio::post或asio::dispatch,你也可以将任意函数投递到Asio的IO上下文中执行,实现通用的异步任务调度。Asio通过io_context来驱动所有异步操作,避免了手动管理线程的麻烦。asio::io_context io; // 投递一个链式任务 asio::post(io, []{ auto resultA = doTaskA(); asio::post(io, [resultA]{ auto resultB = doTaskB(resultA); asio::post(io, [resultB]{ doTaskC(resultB); }); }); }); // 需要运行 io.run() 来驱动虽然回调嵌套依然存在,但所有任务都在同一个
io_context调度器上,资源管理更统一。拥抱C++20协程(Coroutines):这是解决异步编程复杂性的终极语言级方案。协程允许你以近乎同步的写法来编写异步代码,编译器会帮你将其转换为状态机。配合
co_await关键字,你可以“挂起”当前协程,等待一个异步操作完成,而不会阻塞线程。Task<int> doAsyncWork() { auto resultA = co_await asyncDoTaskA(); // 挂起,不阻塞线程 auto resultB = co_await asyncDoTaskB(resultA); auto resultC = co_await asyncDoTaskC(resultB); co_return resultC; }代码逻辑是顺序的,但执行是异步的。这极大地提高了代码的可读性和可维护性。不过,C++20的协程是“无栈协程”,需要一定的学习成本,并且需要与支持协程的异步库(如cppcoro, Boost.Asio with C++20)一起使用。
8. 核心错误七:忽视线程局部存储(thread_local)的初始化与销毁顺序
thread_local变量是每个线程独有的全局变量。它在异步编程中非常有用,可以用来存储线程特定的上下文,比如数据库连接、随机数生成器、或性能计数器。然而,它的初始化和销毁有着微妙的规则,处理不当会导致内存泄漏或访问已销毁对象。
8.1thread_local的构造与析构时机
一个thread_local变量在线程首次使用它时进行初始化(对于非POD类型,调用其构造函数)。当线程退出时,所有该线程拥有的thread_local变量会以与初始化相反的顺序被销毁。
问题出现在:如果thread_local变量持有资源(如堆内存、文件句柄、网络连接),而该资源的生命周期依赖于另一个全局或静态对象,或者另一个thread_local对象,那么在线程退出时,销毁顺序可能导致未定义行为。
// 一个简单的日志器,每个线程有自己的日志文件 class ThreadLogger { public: ThreadLogger() { // 打开一个以线程ID命名的文件 file_.open(generateFileName()); } ~ThreadLogger() { file_.close(); // 析构时关闭文件 } void log(const std::string& msg) { /* 写入文件 */ } private: std::ofstream file_; }; thread_local ThreadLogger t_logger; // 每个线程一个实例 void asyncTask() { t_logger.log("Task started"); // 首次使用,触发构造 // ... 工作 t_logger.log("Task finished"); } // 线程结束,t_logger 被析构,文件关闭这个例子看起来没问题。但如果ThreadLogger的构造函数或log函数使用了另一个thread_local对象(比如一个全局缓存),而那个对象可能先于t_logger被销毁,那么在t_logger析构时去使用那个缓存就会出错。
8.2 安全使用thread_local的准则
保持
thread_local对象的简单性:理想情况下,thread_local对象应该是POD类型(如int,指针)或仅包含POD成员。避免在thread_local对象中管理复杂的、有依赖关系的资源。如果必须管理资源,使用原始指针和惰性初始化:与其直接声明一个复杂的
thread_local X,不如声明一个thread_local X*,初始化为nullptr。然后通过一个获取函数来惰性初始化它。在线程结束时,你需要手动清理(或使用一个小的RAII包装器)。thread_local std::unique_ptr<ThreadLogger> t_logger_ptr; ThreadLogger& getThreadLogger() { if (!t_logger_ptr) { t_logger_ptr = std::make_unique<ThreadLogger>(); } return *t_logger_ptr; } // 在线程入口函数或明确知道线程结束的地方 void cleanupThread() { t_logger_ptr.reset(); }这种方式让你对资源的生命周期有完全的控制权,但增加了管理的负担。
绝对避免在
thread_local对象的析构函数中访问其他thread_local对象:因为你无法控制它们的销毁顺序。如果必须交互,确保这种交互不依赖于对象的有效性(例如,访问全局的、不会销毁的原始资源)。注意动态库中的
thread_local:在不同平台和动态库加载/卸载模型中,thread_local的行为可能更加复杂。在编写跨平台的库代码时,需要特别小心。
9. 核心错误八:错误估计任务粒度与系统负载,导致性能退化或资源耗尽
异步编程不是银弹。盲目地将所有任务异步化,尤其是大量细粒度的任务,可能会导致严重的性能问题。
9.1 任务粒度过细的代价
每次std::async(使用std::launch::async策略)都可能创建一个新的系统线程(尽管标准库实现可能使用线程池,但C++标准并不保证)。创建线程、调度线程、在线程间切换上下文(Context Switch)都是有开销的。
- 开销超过收益:如果一个任务只执行几条简单的指令,那么创建和管理线程的开销可能远大于任务本身的计算开销。
- 缓存失效:频繁的线程切换会导致CPU缓存(L1, L2, L3)效率降低,因为新线程需要加载自己的数据到缓存中。
- 资源竞争:如果任务数量远大于CPU核心数,大量的线程会争抢CPU时间片和内存带宽,导致整体吞吐量下降。
// 反例:对大量小元素进行异步处理 std::vector<int> data(1000000); std::vector<std::future<void>> futures; for (size_t i = 0; i < data.size(); ++i) { // 为每个元素启动一个异步任务?灾难! futures.push_back(std::async(std::launch::async, [&data, i] { data[i] = process(data[i]); // process是一个非常轻量的操作 })); } // 等待所有任务完成...9.2 合理规划异步任务的策略
任务分块(Chunking):将大量细粒度任务聚合成适当大小的块,每个块作为一个异步任务提交。块的大小需要根据任务性质和硬件环境(CPU核心数)进行调优。C++17的并行算法库自动做了这件事。
const size_t chunkSize = data.size() / (4 * std::thread::hardware_concurrency()); for (size_t start = 0; start < data.size(); start += chunkSize) { futures.push_back(std::async(std::launch::async, [&data, start, chunkSize] { size_t end = std::min(start + chunkSize, data.size()); for (size_t i = start; i < end; ++i) { data[i] = process(data[i]); } })); }使用工作线程池:如前所述,线程池可以复用固定数量的线程,避免频繁创建销毁。你可以向线程池提交任意数量的任务,池内的调度器会负责将这些任务分配给空闲的工作线程。这是处理大量短期异步任务的最佳实践。
测量与性能剖析:永远不要凭感觉优化。使用性能剖析工具(如perf, VTune, 各种Profiler)来识别热点和瓶颈。也许你花费大量精力异步化的部分,只占程序总运行时间的1%。阿姆达尔定律告诉我们,优化非热点部分对整体性能提升微乎其微。
考虑I/O密集型与CPU密集型任务:
- CPU密集型:任务主要消耗CPU计算资源。对于这类任务,异步并行的核心目标是充分利用多核CPU。任务数量最好与逻辑核心数相匹配或略多。
- I/O密集型:任务大部分时间在等待磁盘、网络等I/O操作。对于这类任务,你可以使用比CPU核心数多得多的线程(或异步IO),因为当一些线程在等待I/O时,CPU可以切换到其他线程执行。这就是为什么网络服务器(如Nginx)采用异步事件驱动模型,可以轻松处理成千上万的并发连接。
理解你的任务类型,选择合适的并发模型(多线程、线程池、异步I/O、协程),并合理设置并发度,是写出高性能异步程序的关键。
