本文来自公众号 猩猩程序员 欢迎关注
经常有一些刚接触编程的学员,搞不明白为什么需要异步,今天通过一篇简单文章解释一下
1.并发的基础——进程与线程
1. 进程 (Process)
- 是什么:进程是操作系统进行资源分配和调度的基本单位。简单来说,你电脑上运行的每一个程序(比如微信、Chrome浏览器)都是一个独立的进程。
- 核心特征:
- 资源独立:每个进程都有自己独立的内存空间、数据和资源。一个进程崩溃了,通常不会影响其他进程。
- 隔离性强:这是优点也是缺点。优点是安全稳定;缺点是进程间通信(IPC)比较复杂且开销大。
- 比喻:一座座独立的工厂。每座工厂有自己的设备、原材料和工人,工厂之间互不干扰。如果想让两个工厂协作,需要通过复杂的物流系统(IPC)来运输物资。
2. 线程 (Thread)
- 是什么:线程是进程内的一个执行单元,是CPU调度的基本单位。一个进程可以包含多个线程。
- 核心特征:
- 共享资源:同一进程内的所有线程共享该进程的内存空间和资源(如文件句柄)。
- 轻量级:创建和切换线程的开销远小于进程。
- 通信方便:因为共享内存,线程间通信非常简单,但也带来了数据竞争(Race Condition)和死锁(Deadlock)等问题,需要加锁来保护共享数据。
- 比喻:工厂里的工人们。所有工人共享工厂的设备和原材料。多一个工人(线程),就能多一条生产线,提高生产效率。但工人们需要协调(加锁),否则会抢夺同一个设备导致混乱。
2.传统模型的瓶颈 - “等待”的代价
我们有了进程和线程,似乎可以通过“多开工人”的方式无限提升效率。但一个核心问题出现了:等待,或者说 阻塞I/O (Blocking I/O)。
-
什么是阻塞I/O? I/O(Input/Output)指的是输入/输出操作,通常指那些速度远慢于CPU的操作,比如:
- 读取硬盘文件
- 发起网络请求(访问网站、查询数据库)
- 等待用户输入
当一个线程执行到这些操作时,它必须停下来,“阻塞” 在原地,直到操作完成。此时,CPU是被这个线程霸占着,但它什么也没干,只是在空等。这造成了巨大的资源浪费。
-
生动的例子:咖啡店的故事
-
单线程同步模型(一个笨拙的咖啡师)
- 一个咖啡师(线程)接待一位顾客。他接到订单后,就去操作咖啡机。在咖啡机制作咖啡的30秒内,他必须站在旁边盯着,不能接待下一位顾客。直到这杯咖啡做好,递给顾客,他才能服务下一个人。
- 问题:咖啡师(CPU)在等待咖啡机(I/O)工作时,完全被浪费了。
-
多线程同步模型(雇佣更多咖啡师)
- 为了解决排队问题,老板雇了5个咖啡师(线程)。这样可以同时服务5位顾客。
- 问题:
- 成本高:雇佣更多咖啡师需要更多工资和管理成本(创建和切换线程有开销)。
- 资源瓶颈:如果店里只有一台咖啡机(I/O资源),那么同一时间还是只有一杯咖啡在做。另外4个咖啡师接到单后,都得排队等着使用咖啡机,他们同样在**“阻塞”等待**。
无论是单线程还是多线程,只要是同步阻塞模型,“等待” 这个问题就无法根本解决。线程(工人)的大部分时间可能都浪费在等待慢速I/O(设备)上。
-
3.优雅的解决方案 - 异步编程
异步就是为了解决“等待”的浪费而生的。它的核心思想是:发起一个耗时操作后,不原地等待结果,而是立即返回去做别的事情。当操作完成后,系统会通知我,我再回来处理结果。
-
异步模型下的咖啡店(一个聪明的咖啡师)
- 一个聪明的咖啡师(单线程)接待顾客A,收钱下单,然后把指令发给咖啡机(发起I/O操作),并给顾客A一个取餐器(Future/Promise)。
- 他不等待咖啡机做完,而是立即去接待顾客B,同样下单、启动机器、给取餐器。
- 他不断地接待新顾客,直到空闲下来。
- 突然,顾客A的咖啡做好了,咖啡机发出“滴滴”声(事件通知/Callback)。咖啡师听到后,停下手中的事,把咖啡递给A。
- 然后他继续做之前的事(比如接待新顾客或响应其他咖啡机的通知)。
-
异步编程的核心组件:事件循环 (Event Loop) 这个聪明的咖啡师,他的大脑里就在运行一个“事件循环”。
- 任务队列:他有一个任务列表(比如“接待新顾客”、“响应机器A”、“响应机器B”)。
- 循环检查:他不断地从队列里取任务来执行。
- 注册回调:当他发起一个I/O操作(启动咖啡机),他会告诉系统:“这个任务做完后,请把‘处理结果’这个新任务放进我的任务队列里”。
- 非阻塞:他永远不会停下来等待,总是在处理任务队列里的事情。
-
代码示例对比 (Rust)
use std::time::{Duration, Instant}; // --- 同步阻塞版本 --- // 模拟阻塞的I/O操作 fn sync_download(file_name: &str, duration_ms: u64) { println!("同步:开始下载文件 {}...", file_name); std::thread::sleep(Duration::from_millis(duration_ms)); println!("同步:文件 {} 下载完成", file_name); } fn sync_task() { let now = Instant::now(); sync_download("A", 1000); sync_download("B", 2000); println!("同步任务总耗时: {:.2?}s", now.elapsed().as_secs_f32()); } // --- 异步非阻塞版本 --- // 模拟非阻塞的I/O操作 async fn async_download(file_name: &str, duration_ms: u64) { println!("异步:开始下载文件 {}...", file_name); // 使用 tokio::time::sleep 来模拟非阻塞等待 // 它会让出CPU控制权,而不是霸占线程 tokio::time::sleep(Duration::from_millis(duration_ms)).await; println!("异步:文件 {} 下载完成", file_name); } async fn async_task() { let now = Instant::now(); // tokio::join! 会并发地运行多个Future // 它会等待所有Future完成后再继续 tokio::join!( async_download("A", 1000), async_download("B", 2000) ); println!("异步任务总耗时: {:.2?}s", now.elapsed().as_secs_f32()); } // 使用 tokio::main 宏来启动异步运行时 #[tokio::main] async fn main() { println!("--- 执行同步任务 ---"); sync_task(); println!("\n--- 执行异步任务 ---"); async_task().await; }
4.异步的终极目的
异步的终极目的,不是让代码运行得更快,而是为了最大限度地压榨CPU的性能,提升系统在单位时间内的吞吐量 (Throughput)。
它通过以下方式实现这一目标:
- 剥离等待:将CPU从无效的I/O等待中解放出来,让它去做其他有意义的计算任务。
- 提高资源利用率:用极少的线程(甚至单线程)来处理海量的并发连接。对于每一个连接,只在真正需要CPU计算时才为其服务,其余时间(网络延迟、数据传输)CPU都在服务其他连接。
- 用并发实现高吞吐:在I/O密集型场景下(如Web服务器、数据库代理),异步可以用一个线程实现远超传统多线程模型的并发处理能力。
一句话总结:异步,就是一种用“协作式调度”的思想,让单线程或少数几个线程,通过不停地在不同任务之间切换,来模拟出大规模并发效果的编程模型,其核心是避免CPU在I/O上浪费时间。
总结对比
| 特性 | 多线程模型 | 异步模型 |
|---|---|---|
| 核心思想 | 多雇工人,一人一件事 | 一人多事,任务间切换 |
| 调度方式 | 抢占式(操作系统决定) | 协作式(代码自己决定何时让出CPU) |
| 开销 | 线程创建/切换开销大 | 开销极小(函数调用) |
| 适用场景 | CPU密集型(多核并行计算) | I/O密集型(高并发网络服务) |
| 编程复杂度 | 复杂(锁、死锁、数据竞争) | 较复杂(回调地狱、心智模型转换) |
本文来自公众号 猩猩程序员 欢迎关注