侧边栏壁纸
  • 累计撰写 10 篇文章
  • 累计创建 5 个标签
  • 累计收到 2 条评论
标签搜索

目 录CONTENT

文章目录

并发编程: 多线程模型与异步模型

本文用来记录我对于并发编程的理解

并发与并行

了解并发编程之前需要知道并发和并行的概念

现代的cpu都是多核心,可以同时运行多个任务,那么在程序编写时就需要考虑到如何利用起多核心的cpu提高程序运行效率
在多核心环境中,我们可以同时启动多个任务,同时执行,但是其中有一些细节

假设cpu为4个核心,那么当在程序中同时启动了4个任务时,是否这4个任务分别占据一个核心在同时运行呢?

答案是不一定,这其中依赖与操作系统的调度。

操作系统给一个线程任务分配了资源和核心,线程开始运行,此时此线程任务因为某些原因在等待中(例如读取文件、网络IO),那么操作系统就会将此线程任务放入等待队列,然后将此线程任务占有的核心分配给可以运行的线程任务,当等待的线程任务有了结果,操作系统又会在合适的时候分配核心使其继续运行

由上面的规则可知:N个任务不一定会同时占有N个核心,同时运行

并发并行的概念就由此而来

并发:多个任务同时存在,但是并没有同时执行,依赖于操作系统的调度
0316-2-1742101593048
并行:多个任务同时存在,且在不同的核心同时运行
0316-1

其实可以看出,并发和并行实际上都表示:多个任务同时存在,不同的是如何执行

在编程领域,将重点放在了 并发
原因在于:程序支持并发,就能支持并行

并行需要硬件的支持,而即便是在单个核心上,如果程序能够很好的管理多任务,那么就能实现并发
自然的,能够很好的管理多任务的程序,在多核心上就能适应并行

多线程模型

那么针对:如何在程序中管理好多任务,出现了多种不同的方式。或者叫做实现并发编程的不同模型

多线程模型是其中一种,这种方式将编程中的一个任务映射到一个操作系统线程,也就是 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 的请求时,就无法很好的完成任务了
即便服务器存在很多核心,也无法有效的处理如此多的并发连接

核心原因在于

  1. 每个线程需要单独的栈空间,大量线程需要的空间是巨大的
  2. 操作系统虽然可以自行调度线程,但是数量过多会导致大部分时间被花在了线程调度中
  3. 大量的等待线程在闲置时依然会占用系统资源

对于处理网络请求这种IO密集型的操作,当其线程任务在等待IO响应时,不能做任何事却还占用线程资源,阻塞整个线程,那么能否在其等待时不阻塞所在线程,使当前线程执行其他任务呢?

这就是异步模型的核心:在单个线程中不阻塞的执行多个任务

类似于多线程,异步模型同样可以启动多个不同的任务,但是这多个任务可以存在于单个操作系统线程中,由异步运行时负责在单个线程中不阻塞的切换任务
async

异步模型可以在单个操作系统线程中存在多个任务,同时也能在多个多个线程中存在多个任务
之间的关系可以理解为 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事件处理  
        }  
    }  
}  

代码逻辑为

  1. 服务器启动,创建Selector和ServerSocketChannel
  2. 将ServerSocketChannel注册到Selector,关注ACCEPT事件
  3. 进入事件循环,调用select()方法等待事件发生
  4. 当有客户端尝试连接时,ACCEPT事件触发
  5. 接受连接,创建SocketChannel并注册到Selector(关注READ事件)
  6. 当客户端发送数据时,READ事件触发
  7. 在同一个线程中处理读取的数据
  8. 继续循环处理更多事件

他的重点在于,可以在单个线程中接收到多个任务(socket连接)
但是当处理任务时,还是当前线程中处理,如果处理时阻塞,整个线程将会阻塞

所以也可以说:NIO 只实现了一半的异步模型 O_O

虚拟线程:来自于 java21 的虚拟线程(感受他的威力吧!)

这个很好理解,无需过多解释了

// 创建100万个虚拟线程  
for (int i = 0; i < 1_000_000; i++) {  
    Thread.startVirtualThread(() -> {  
        processTask();  
    });  
}  
// 可能只会使用几个(如16个)底层OS线程  

局限性

前面说了异步的高性能以及各种优点,但是异步并不是万能的,他存在自己的局限性

异步核心在于:可以单线程不阻塞的处理多个任务
前提是其中的任务在某一时刻会处于等待状态,例如网络请求、文件读取等属于IO相关的操作
但是如果任务不会处于等待状态,一直会占用线程,例如进行加密解密、图形渲染等一直需要占用cpu进行计算的任务,此时异步是帮不上多大忙的,该等待一样等待

这就是被常常提到的 IO密集CPU密集 型操作,我们需要考虑到任务的情况再决定使用何种方式进行并发编程

0

评论区