协程/虚拟线程

314 阅读18分钟
  1. 协程/虚拟线程简介

    1.   什么是协程/虚拟线程

          协程 (Coroutine) 是一种比线程更加轻量级的并发编程方式。它允许函数在执行过程中暂停,并且可以在稍后恢复执行。协程在许多编程语言中都有实现,比如Python、Kotlin、JavaScript等。

          虚拟 线程 (Virtual Thread 是JDK 19引入的一个新特性并在JDK21转正,它提供了比传统Java线程更加轻量的并发执行方式。

          下面统称协程

          协程(虚拟线程)的关键特性包括:

      • 轻量级:协程通常比线程更轻量,占用的资源更少,因为它们不需要操作系统级别的上下文切换。

      • 非抢占式调度:协程的切换是显式的,通过程序代码控制,而不是由操作系统抢占调度。

      • 适用于IO密集型任务:协程非常适合处理大量IO操作的任务,例如网络请求、文件读写等,因为它们在等待IO操作时可以挂起,而不会阻塞整个线程。

    2.   既生线程何生协程?(线程和协程的区别)

      1. 线程(Thread):

        1. 线程是操作系统级别的并发执行单元,每个线程都有自己的堆栈和程序计数器。
        2. 多线程通常使用操作系统的线程调度器来管理线程之间的切换,因此线程的切换会涉及一些开销。
        3. 线程通常适用于需要并行执行阻塞或 I/O 密集型任务的情况,如网络通信或文件读写操作。
      2. 协程(Coroutine):

        1. 协程是用户级别的轻量级线程,通常由编程语言或库来实现,而不依赖于操作系统的线程。
        2. 协程之间的切换是由程序控制的,因此通常比线程切换更高效,减少了开销。
        3. 协程通常适用于需要高度并发、非阻塞、事件驱动的应用程序,如Web服务器、实时游戏服务器等。

          因此,要根据应用程序的需求来选择线程还是协程:

      • 如果 需要执行大量的 I/O 操作,而不希望线程切换带来太多开销,那么协程可能是更好的选择。
      • 如果 需要执行大量的计算密集型操作,那么线程可能更适合,它可以利用多核处理器实现真正的并行性。
    1.   有什么是只能线程做,协程无法做的

      • 多核并行计算:如果要利用多核处理器执行计算密集型任务,线程通常更适合,因为线程可以在不同的核上并行执行任务,而协程通常在单个线程内执行。
      • 阻塞式的外部资源访问:如果你的应用程序需要频繁地与阻塞式的外部资源(如传统的同步文件 I/O、数据库查询)交互,线程可能更适合,因为线程可以在等待外部资源时切换到其他任务,从而提高并发性。
      • 并发数据结构和共享状态:线程更容易用于管理共享状态和并发数据结构,因为线程可以直接访问共享内存,而协程通常需要使用锁或其他同步机制来实现。

          尽管如此,可以通过合理的设计和结合不同的技术来克服这些限制。

          例如:将协程与线程结合使用,使用线程来处理多核计算任务,而使用协程来管理异步 I/O 操作

  1. 协程VS线程

    1.   定义

    线程

    • 线程是操作系统级别的并发执行单元,每个线程都有自己的堆栈和程序计数器。
    • 多线程通常使用操作系统的线程调度器来管理线程之间的切换,因此线程的切换会涉及一些开销。
    • 线程通常适用于需要并行执行阻塞或 I/O 密集型任务的情况,如网络通信或文件读写操作

    协程

    • 协程是用户级别的轻量级线程,通常由编程语言或库来实现,而不依赖于操作系统的线程。

    • 协程之间的切换是由程序控制的,因此通常比线程切换更高效,减少了开销。

    • 协程通常适用于需要高度并发、非阻塞、事件驱动的应用程序,如Web服务器、实时游戏服务器等。

    1.   适用场景

      • 如果 需要执行大量的 I/O 操作,而不希望线程切换带来太多开销,那么协程可能是更好的选择。
      • 如果 需要执行大量的计算密集型操作,那么线程可能更适合,它可以利用多核处理器实现真正的并行性。
    1.    并发模型

      线程

      • 线程是并发的,多个线程可以同时运行在多核CPU上。
      • 线程适用于CPU密集型和IO密集型任务。
      • 线程间需要同步机制(如锁、信号量)来保护共享资源,避免竞争条件。

      协程

      • 协程是协作式并发的,通常在一个线程内运行,适用于IO密集型任务。
      • 协程通过异步操作(如网络请求、文件读写)实现并发,避免阻塞。
      • 协程的同步机制通常是轻量级的,如async/awaitPromise等。
    1.   并发模型

    1.   资源消耗

      线程

      • 线程的创建和上下文切换开销较大,占用更多的内存和CPU资源。
      • 每个线程有独立的堆栈和寄存器状态,需要操作系统管理。

      协程

      • 协程的创建和上下文切换开销较小,占用更少的资源。
      • 协程通常在同一线程内运行,共享相同的堆栈和寄存器状态。
    1.   JAVA虚拟线程运行对比

      1.     固定线程池

        • 测试代码

          • @SuppressWarnings("ALL")
            public class RealThreadTest {
            
                /**
            * 任务总数
            */
            private static final int TASK_COUNT = 100_000_00;
            
                /**
            * 并发任务数
            */
            private static final int THREAD_COUNT = 1000;
            
                /**
            * 主函数入口。
            * 创建一个固定大小的线程池,并提交多个任务到线程池中执行。
            * 每个任务简单地打印当前线程信息,并休眠10毫秒。
            * 任务执行完毕后,线程池会自动关闭。
            * <p>
            * 参数: args - 传入的命令行参数数组(在此示例中未使用)
            */
            public static void main(String[] args) {
            
                    // 创建一个包含1000个线程的固定大小线程池
                    ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_COUNT);
                    // 提交TASK_COUNT个任务到线程池
                    for (int i = 0; i < TASK_COUNT; i++) {
                        int finalI = i;
                        // 每个任务打印线程信息并休眠10毫秒
                        threadPool.submit(() -> {
                            try {
                                System.out.println("Thread " + finalI + " " + Thread.currentThread().getName() + " is running");
                                Thread.sleep(10);
                            } catch (InterruptedException e) {
                                // 线程休眠被中断,打印线程索引并重新设置中断状态
                                System.out.println(finalI);
                                Thread.currentThread().interrupt();
                            }
                        });
                    }
            
                    // 关闭线程池
                    threadPool.close();
                    // 注释掉的代码:原本计划在此处让主线程休眠1小时,但当前未使用
                    // TimeUnit.HOURS.sleep(1);
                }
            }
            
        • 运行占用资源

          • 任务量在10000000(千万) 并发(线程)数量在 1000(千)

          • 任务量在1000000(百万) 并发(线程)数量在 10000(万)

          • 任务量在1000000(百万) 并发(线程)数量在 2000(千)

          • 任务量在1000000(百万) 并发(线程)数量在 1000(千)

          • 任务量在1000000(百万) 并发(线程)数量在 100(百)

      1.     协程+信号量

        • 测试代码

          • public class VirtualThreadTest {
            
                /**
            * 任务总数
            */
            private static final int TASK_COUNT = 100_000_00;
            
                /**
            * 并发任务数
            */
            private static final int THREAD_COUNT = 1000;
            
                /**
            * 主函数示例,演示了如何使用Semaphore控制并发访问。
            * 使用一个Semaphore来限制同时运行的任务数量,通过虚拟线程池执行任务。
            * 每个任务在执行前后都会对Semaphore进行acquire和release操作。
            */
            public static void main(String[] args) throws InterruptedException {
                    // 创建一个Semaphore,允许1000个任务同时运行
                    Semaphore semaphore = new Semaphore(THREAD_COUNT);
            
                    try (ExecutorService virtualThreadPool = Executors.newVirtualThreadPerTaskExecutor()) {
                        // 循环提交TASK_COUNT个任务到线程池
                        for (int i = 0; i < TASK_COUNT; i++) {
                            int finalI = i;
                            // 获取Semaphore许可,如果所有许可都被占用则线程会等待
                            semaphore.acquire();
                            // 提交一个任务到虚拟线程池
                            virtualThreadPool.submit(() -> {
                                try {
                                    // 打印当前线程信息
                                    System.out.println("Thread " + finalI + " " + Thread.currentThread().threadId() + " is running");
                                    // 模拟任务执行时间
                                    Thread.sleep(10);
                                } catch (InterruptedException e) {
                                    // 如果线程在睡眠中被中断,打印线程编号并重新设置中断状态
                                    System.out.println(finalI);
                                    Thread.currentThread().interrupt();
                                } finally {
                                    // 无论任务执行结果如何,最后都释放Semaphore许可
                                    semaphore.release();
                                }
                            });
                        }
                    }
                    // 注释掉的代码是为了等待所有任务执行完成,这里不再需要因为线程池的关闭会阻塞直到所有任务完成
                    // TimeUnit.HOURS.sleep(1);
                }
            }
            
        • 运行占用资源

          • 任务量在10000000(千万) 并发(线程)数量在 1000(千)

          • 任务量在10000000(千万) 并发(线程)数量在 10000(万)

          • 任务量在1000000(百万) 并发(线程)数量在 2000(千)

          • 任务量在1000000(百万) 并发(线程)数量在 1000(千)

          • 任务量在1000000(百万) 并发(线程)数量在 100(百)

  1. 拓展问答

  • 为什么阻塞式的外部资源访问使用 协程 就不合适,协程无法等待外部资源时切换到其他任务吗?

    •   对于阻塞式的外部资源访问,协程在某些情况下也是可以使用的,但需要注意一些问题和工作细节。协程通常用于异步编程,其目标是通过在等待外部资源时允许其他任务继续执行来提高并发性。这是协程的一个主要优势。
    •   然而,存在一些情况,协程可能不太适合阻塞式的外部资源访问的原因:
    • 阻塞调用的库:如果你使用的是一个不支持异步操作的阻塞式库,例如传统的同步文件 I/O 或阻塞式数据库驱动程序,那么在协程中等待这些资源可能会导致整个协程堵塞,而无法充分利用协程的优势。
    • 阻塞时间长:如果外部资源的阻塞时间非常长,协程可能会在等待期间占用过多的内存,因为每个协程都需要保持其状态。这可能会导致内存问题。
    • 协程库和异步生态系统的适配:将阻塞式操作与协程库协同工作可能需要一些特殊的处理和工作,如使用线程池来管理阻塞操作,以便不阻塞事件循环。
    •   解决这些问题的方法包括使用线程池或使用异步库中的异步版本来处理阻塞式操作,以确保协程能够继续执行其他任务。例如,可以使用Python的asyncio库中的loop.run_in_executor方法来在协程中执行阻塞操作,这会将操作委托给线程池执行,以便协程不会阻塞整个事件循环。
    •   总之,虽然协程通常用于异步编程,但在处理阻塞式的外部资源访问时,需要采取一些额外的措施,以确保协程能够有效地处理并发请求。这取决于具体的应用场景和所使用的编程语言和库。
  • 不支持 异步 操作的阻塞式 ,例如传统的同步文件 I/O 或阻塞式 数据库 驱动程序,那么在 协程 中等待这些资源可能会导致整个协程堵塞。 如果使用 线程 的话就不会导致整个 线程池 阻塞吗?

    •   如果在协程中等待不支持异步操作的阻塞式库资源,确实会导致整个协程被阻塞,但这种情况下使用线程也不会阻止整个线程池。关键的区别在于线程和协程在等待资源时的并发性管理方式:
    • 线程:如果你在一个线程中等待一个阻塞式资源,只会阻塞该线程,而不会影响其他线程。这是因为线程是操作系统级别的并发执行单元,每个线程都有自己的执行堆栈和程序计数器。其他线程仍然可以继续执行,因为线程之间的切换是由操作系统的线程调度器控制的。
    • 协程:协程通常在单个线程内运行,因此如果一个协程在等待阻塞式资源时被阻塞,它会阻塞整个事件循环,因为事件循环无法同时运行其他协程。这是因为协程的切换是由程序员控制的,而不是由操作系统控制的。
    •   要解决这个问题,可以采用以下策略:
    • 使用线程池:可以将阻塞式操作委托给线程池中的线程执行,以确保协程不会被阻塞。这样,协程可以继续执行其他任务,而不会阻塞整个事件循环。
    • 使用异步库的异步版本:一些阻塞式库有与之相关的异步版本,这些版本可以与协程一起使用,以确保不会阻塞事件循环。
    •   总之,虽然线程和协程都可以在等待阻塞式资源时继续执行其他任务,但在协程中需要额外的处理,如使用线程池或异步库的异步版本,以确保协程能够有效地处理并发请求。线程的切换是由操作系统控制的,因此不会影响整个线程池的执行。
  • 传统的同步文件 I/O 或阻塞式 数据库 驱动程序有什么呢?读写图片文件 读写数据库 网络i/o 这些是不是阻塞式?

    •   传统的同步文件 I/O、阻塞式数据库驱动程序和网络 I/O 操作在默认情况下通常是阻塞式的,这意味着当执行这些操作时,程序会被阻塞,直到操作完成。以下是一些示例:
    • 同步文件 I/O:当你使用传统的同步文件 I/O 操作读取或写入文件时,程序会等待操作完成,直到数据被读取或写入磁盘。这意味着在文件 I/O 操作期间,程序无法执行其他任务,会被阻塞。
    • 阻塞式数据库驱动程序:某些数据库驱动程序在执行查询或更新操作时是阻塞的。例如,如果你使用了传统的数据库连接,执行查询时会等待数据库服务器的响应,这也会导致程序被阻塞。
    • 网络 I/O:当你使用传统的同步网络 I/O 操作时,例如使用传统的套接字 API,程序会等待数据的发送或接收完成。如果网络操作涉及到等待对方响应的情况,也会导致程序阻塞。
    •   要解决这些问题,可以采用以下方法:
    • 异步文件 I/O:使用异步文件 I/O 操作或者将同步文件 I/O 操作委托给线程池中的线程来避免阻塞。
    • 异步数据库驱动程序:一些数据库驱动程序提供了异步版本,或者你可以使用异步框架来与数据库进行交互,以确保数据库操作不会阻塞整个应用程序。
    • 异步网络 I/O:使用异步网络库或框架,如Python的asyncio,来执行非阻塞的网络 I/O 操作,以确保程序在等待数据时可以继续执行其他任务。
    •   总之,传统的同步文件 I/O、阻塞式数据库驱动程序和网络 I/O 操作可能是阻塞式的,但可以通过使用异步操作、线程池等技术来避免阻塞,以提高应用程序的并发性和性能。
  • 异步 io 是不是就是nio?详解这些io的区别, java 中有什么异步文件 I/O API 吗?

    •   异步 I/O 和 NIO(Non-blocking I/O)虽然在某种程度上涉及相似的概念,但它们在不同的编程语境和实现方式下有一些区别。下面我将详细解释它们之间的区别,并讨论在Java中异步文件 I/O 的相关API。

    • 异步 I/O(Async I/O):

      1. 异步 I/O 是一种编程模型,通常用于处理 I/O 操作,如文件 I/O、网络 I/O 等,以一种非阻塞的方式。
      2. 在异步 I/O 模型中,程序发起一个 I/O 操作,但不会等待该操作完成。相反,它会继续执行其他任务,并在 I/O 操作完成后通过回调或事件来处理结果。
      3. 异步 I/O 通常涉及事件循环或回调机制,例如Python的asyncio、Node.js等都采用了异步 I/O 模型。
    • NIO(Non-blocking I/O):

      1. NIO 是Java编程语言中的一种非阻塞 I/O 模型,主要用于网络编程。它引入了通道(Channel)和缓冲区(Buffer)的概念,以支持非阻塞的 I/O 操作。
      2. 在 NIO 中,程序可以发起非阻塞的读写操作,如果没有数据可读取或无法立即写入,不会阻塞线程,而是返回一个状态,允许线程继续执行其他任务。
      3. NIO 在 Java 中是通过 java.nio 包实现的。
    •   关于Java中的异步文件 I/O,Java本身提供了一些异步 I/O 的支持,主要基于NIO和NIO.2(也称为NIO2或NIO2.0)来实现。以下是一些 Java 中的异步文件 I/O 相关的API:

    • AsynchronousFileChannel(NIO.2):这个类允许你异步地读写文件,而不会阻塞线程。你可以注册回调来在操作完成时接收通知。

    • CompletableFuture:Java 8 引入的 CompletableFuture 类可以与异步文件 I/O 结合使用,以更容易地处理异步操作的结果和依赖关系。

    • java.nio.channels.CompletionHandler:这是一个用于处理异步 I/O 操作结果的接口,可以用于 AsynchronousFileChannel 和其他异步 I/O 类。

    •   这些API使Java开发人员能够以异步方式执行文件 I/O 操作,从而提高程序的并发性和性能,特别是在需要处理大量文件或需要高度响应性的应用程序中。

  • NIO的原理

    •   Java NIO(New I/O)是一种非阻塞的 I/O 模型,它的核心思想是使用通道(Channel)和缓冲区(Buffer)来进行高效的数据传输。以下是 Java NIO 的工作原理的详细解释:

    • 通道和缓冲区:

      1. 通道是与数据源或数据目标之间的连接,可以是文件、套接字(Socket)、管道(Pipe)等。
      2. 缓冲区是一个数据存储区域,它在内存中分配一块固定大小的空间,用于存储要传输的数据。
    • 非阻塞模式:

      1. NIO 采用了非阻塞的工作模式。在传统的阻塞式 I/O 中,当程序请求从输入流中读取数据时,如果没有数据可读,线程会阻塞等待数据。而在 NIO 中,线程不会被阻塞,它可以继续执行其他任务。
      2. 当一个通道(如一个套接字通道)在非阻塞模式下等待数据时,如果没有数据可读取,通道不会阻塞线程,而是返回一个状态来告知线程没有数据可用。
    • 事件驱动:

      1. NIO 采用了事件驱动的编程模型。程序会注册对某些事件(如数据可读、数据可写等)的关注,然后通过事件处理器来处理这些事件。
      2. 当一个通道准备好读取数据时,会触发一个“数据可读”的事件,通知程序可以执行读操作。同样,当一个通道准备好写入数据时,会触发一个“数据可写”的事件,通知程序可以执行写操作。
    • 选择器(Selector):

      1. 选择器是 NIO 的核心组件之一,它用于管理多个通道的事件。程序可以注册多个通道到一个选择器,并通过选择器来监听这些通道上的事件。
      2. 当一个或多个通道上的事件就绪时,选择器会通知程序,程序可以通过遍历选择器的就绪通道集合来处理这些事件。
    • 缓冲区操作:

      1. 在 NIO 中,数据的读取和写入都是通过缓冲区来实现的。程序将数据从通道读取到缓冲区中,或者将数据从缓冲区写入到通道中。
      2. 缓冲区提供了一种高效的方式来管理数据,避免了频繁的数据拷贝操作。
    • 非阻塞连接:

      1. NIO 也支持非阻塞的套接字连接。程序可以通过套接字通道发起非阻塞连接请求,当连接建立或失败时,通道会通知程序。
      2. 这使得 NIO 适用于构建高性能的网络应用程序。
    •   总的来说,Java NIO 的原理是基于通道、缓冲区、选择器和事件驱动的编程模型,它允许程序以非阻塞的方式处理 I/O 操作,提高了并发性和性能。选择器用于管理多个通道的事件,程序通过注册关注的事件和事件处理器来处理这些事件。缓冲区用于高效地存储和传输数据。这种模型适用于文件 I/O、网络编程等各种 I/O 场景。