本文用来记录我对于并发编程的理解
并发与并行
了解并发编程之前需要知道并发和并行的概念
现代的cpu都是多核心,可以同时运行多个任务,那么在程序编写时就需要考虑到如何利用起多核心的cpu提高程序运行效率
在多核心环境中,我们可以同时启动多个任务,同时执行,但是其中有一些细节
假设cpu为4个核心,那么当在程序中同时启动了4个任务时,是否这4个任务分别占据一个核心在同时运行呢?
答案是不一定,这其中依赖与操作系统的调度。
操作系统给一个线程任务分配了资源和核心,线程开始运行,此时此线程任务因为某些原因在等待中(例如读取文件、网络IO),那么操作系统就会将此线程任务放入等待队列,然后将此线程任务占有的核心分配给可以运行的线程任务,当等待的线程任务有了结果,操作系统又会在合适的时候分配核心使其继续运行
由上面的规则可知:N个任务不一定会同时占有N个核心,同时运行
并发并行的概念就由此而来
并发:多个任务同时存在,但是并没有同时执行,依赖于操作系统的调度
并行:多个任务同时存在,且在不同的核心同时运行
其实可以看出,并发和并行实际上都表示:多个任务同时存在,不同的是如何执行
在编程领域,将重点放在了
并发
上
原因在于:程序支持并发,就能支持并行
并行需要硬件的支持,而即便是在单个核心上,如果程序能够很好的管理多任务,那么就能实现并发
自然的,能够很好的管理多任务的程序,在多核心上就能适应并行
多线程模型
那么针对:如何在程序中管理好多任务,出现了多种不同的方式。或者叫做实现并发编程的不同模型
多线程模型是其中一种,这种方式将编程中的一个任务映射到一个操作系统线程,也就是 1:1 的模型
java 的并发编程就是此种方式,当在 java 中创建多个线程任务时,实际上就会在操作系统上创建多个线程
public class ThreadFunctionInterface {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
System.out.println("this is a thread function name is " + Thread.currentThread().getName());
});
Thread thread2 = new Thread(() -> {
System.out.println("this is a thread function name is " + Thread.currentThread().getName());
});
thread.start();
thread2.start();
thread.join();
thread2.join();
}
}
此时 thread 和 thread2 就是两个操作系统线程,由操作系统调度,既支持并发也支持并行
通过操作系统的调度也能做到支持超过cpu核心数的任务同时存在
好像挺完美的?
异步模型
多线程模型解决了很大的问题,但是互联网蓬勃发展之后,一台服务器需要同时处理的任务不断增长
出现了很经典的 C10K 问题,甚至现代的服务器不止 10k,可能 100k 1000k …
在多线程模型下,当一台服务器需要同时处理 10k 的请求时,就无法很好的完成任务了
即便服务器存在很多核心,也无法有效的处理如此多的并发连接
核心原因在于
- 每个线程需要单独的栈空间,大量线程需要的空间是巨大的
- 操作系统虽然可以自行调度线程,但是数量过多会导致大部分时间被花在了线程调度中
- 大量的等待线程在闲置时依然会占用系统资源
对于处理网络请求这种IO密集型的操作,当其线程任务在等待IO响应时,不能做任何事却还占用线程资源,阻塞整个线程,那么能否在其等待时不阻塞所在线程,使当前线程执行其他任务呢?
这就是异步模型的核心:在单个线程中不阻塞的执行多个任务
类似于多线程,异步模型同样可以启动多个不同的任务,但是这多个任务可以存在于单个操作系统线程中,由异步运行时负责在单个线程中不阻塞的切换任务
异步模型可以在单个操作系统线程中存在多个任务,同时也能在多个多个线程中存在多个任务
之间的关系可以理解为 M:N
异步模型的优势不仅限于此,由于多任务都在单个线程内部,就不存在多线程模型中的大量任务占用太多栈空间,且操作系统只需要处理少量的线程调度工作
针对这种思想,不同的语言对于异步有不同的实现方式
rust
rust 在语言层面提供 async/await 关键字,但不提供具体异步运行时
社区提供了常用的异步运行时 tokio
rust 会将异步函数编译成实现了 Future
特征的状态机,异步运行时(如 tokio)在其任务队列对状态机进行轮询得到状态,配合事件循环做到异步支持
use tokio::time::{sleep, Duration};
async fn first_async_fn() -> i32 {
sleep(Duration::from_secs(1)).await; // 模拟异步等待
println!("first async complete");
42
}
async fn second_async_fn() -> i32 {
sleep(Duration::from_secs(1)).await;
println!("second async complete");
84
}
// 异步函数都会被编译成状态机
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let res1: i32 = first_async_fn().await;
println!("res1: {}", res1);
let res2 = second_async_fn().await;
println!("res2: {}", res2);
// let res = select! {
// res1 = first_async_fn() => res1,
// res2 = second_async_fn() => res2
// };
// println!("res is {}", res);
// join!(
// second_async_fn(),
// first_async_fn()
// );
Ok(())
}
golang
go 语言使用 goroutine 模型,一般叫做协程,使用 go 关键字,同时在语言层面提供运行时支持,其内部维护了一个和操作系统 cpu 核心数相同的线程池,单个线程中可以存在大量 goroutine 任务,由 go 的运行时自动调度到 OS线程。所以在单线程还是多线程中由 go 运行时决定
// 这两个goroutine可能在不同的OS线程上并行执行
go func() {
// 可能在线程1上运行
fmt.Println("goroutine 1")
}()
go func() {
// 可能在线程2上运行
fmt.Println("goroutine 2")
}()
js
js 的异步相关经历了几个发展阶段
我最初接触到 js 中的异步来自于编写 ajax 请求时的回调,大概如下
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/data', true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
var response = JSON.parse(xhr.responseText);
// 处理数据...
}
};
xhr.send();
// 程序继续执行,不等待结果
后面使用 Promise 的方式
// ES6 Promise
fetch('/api/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(err => console.error(err));
再到 async/await 关键字的方式,与 rust 类似,此方式也会被编译成状态机
async function fetchData() {
const response = await fetch('/api/users');
const data = await response.json();
return data;
}
虽然 js 一直保持着单线程执行模型和事件循环的方式实现异步,但是其具体方式经过了不断演化
java
java 被放在最后讨论,足以见得Ta在我心中的重量 QAQ
以 异步模型 作为标准的话,实际上在 java21 之前,都没有真正意义上实现 异步模型
ExecutorService
:线程池只是预先创建好了多个线程,避免了创建线程需要的开销,但依然是 1:1 的模式
// 创建固定大小的10个线程的线程池
ExecutorService executor = Executors.newFixedThreadPool(10);
// 在系统层面会创建10个OS线程
// 无论提交多少任务,最多只有10个OS线程并行执行
for (int i = 0; i < 1000; i++) {
executor.submit(() -> {
// 这个任务会在某一个OS线程上执行
performWork();
});
}
CompletableFuture
:虽然使用起来不会阻塞,看上去类似于 js 中的异步,但是一个任务依然需要一个os线程执行
// 看似是异步链式调用
CompletableFuture.supplyAsync(() -> fetchData()) // 在线程A(OS线程)上执行
.thenApply(data -> processData(data)) // 可能在线程A或B(OS线程)上执行
.thenAccept(result -> saveResult(result)); // 可能在线程B或C(OS线程)上执行
// 如果创建1000个这样的链,仍只能使用有限的OS线程执行
// 通常是CPU核心数量的线程
NIO和事件循环
:这个比较特殊
// 典型NIO服务器
Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
// 这个循环运行在单个OS线程上
while (true) {
selector.select();
for (SelectionKey key : selector.selectedKeys()) {
if (key.isAcceptable()) {
// 接受连接
} else if (key.isReadable()) {
// 读取数据 - 这里的处理代码在同一个OS线程上执行
// 如果处理很耗时,会阻塞所有其他I/O事件处理
}
}
}
代码逻辑为
- 服务器启动,创建Selector和ServerSocketChannel
- 将ServerSocketChannel注册到Selector,关注ACCEPT事件
- 进入事件循环,调用select()方法等待事件发生
- 当有客户端尝试连接时,ACCEPT事件触发
- 接受连接,创建SocketChannel并注册到Selector(关注READ事件)
- 当客户端发送数据时,READ事件触发
- 在同一个线程中处理读取的数据
- 继续循环处理更多事件
他的重点在于,可以在单个线程中接收到多个任务(socket连接)
但是当处理任务时,还是当前线程中处理,如果处理时阻塞,整个线程将会阻塞
所以也可以说:NIO 只实现了一半的异步模型
O_O
虚拟线程
:来自于 java21 的虚拟线程(感受他的威力吧!)
这个很好理解,无需过多解释了
// 创建100万个虚拟线程
for (int i = 0; i < 1_000_000; i++) {
Thread.startVirtualThread(() -> {
processTask();
});
}
// 可能只会使用几个(如16个)底层OS线程
局限性
前面说了异步的高性能以及各种优点,但是异步并不是万能的,他存在自己的局限性
异步核心在于:可以单线程不阻塞的处理多个任务
前提是其中的任务在某一时刻会处于等待状态,例如网络请求、文件读取等属于IO相关的操作
但是如果任务不会处于等待状态,一直会占用线程,例如进行加密解密、图形渲染等一直需要占用cpu进行计算的任务,此时异步是帮不上多大忙的,该等待一样等待
这就是被常常提到的 IO密集
和 CPU密集
型操作,我们需要考虑到任务的情况再决定使用何种方式进行并发编程
评论区