现在面试都不满足于问进程线程,开始问起协程了?

开发 前端
如果协程调用了一个阻塞 IO 操作,由于操作系统并不知道协程的存在(因为协程运行在用户态),它只知道线程,因此在协程调用阻塞 IO 操作的时候,操作系统会让协程之上对应的线程陷入阻塞状态,也就是说当前的协程和其它绑定在该线程之上的协程都会陷入阻塞而得不到调度。

用 Go 语言的小伙伴对协程应该都非常熟悉了,而 Java 直到 2022 年 9 月 20 日,JDK19 才终于提供了协程(官方说法是 Virtual Thread 虚拟线程,不过看介绍就是协程 Coroutine)的测试版本功能。

在 Java 中,我们一直依赖线程作为并发服务器应用程序的构建基础。每个方法中的每个语句都在线程内执行,并且每个线程都提供一个堆栈来存储局部变量和协调方法调用,以及出错时的上下文,开发人员可以使用线程的堆栈来跟踪程序的具体执行过程。

以下参考 OpenJDK 官方文档:https://openjdk.org/jeps/425

Thread-Per-Request

Thread-Per-Request,翻译过来就是一个请求一个线程。服务器应用程序通常处理相互独立的并发用户请求,因此应用程序通过在某个请求的持续时间内将一个线程专门用于处理这个请求是非常有意义且必要的。这种 thread-per-request 风格易于理解、易于编程、易于调试和分析,因为它使用平台的并发单元来表示应用程序的线程数量,比如你有 100 个并发请求,那就对应 100 个线程。

但是,服务器应用程序的可伸缩性受 Little 定律支配,它与延迟、并发性和吞吐量相关,这里我简单介绍下 Little 定律,不是什么重点知识,大伙儿随便看下就行:

Little 定律是由 John Little 在 1961 年提出的,在一个具有稳定流量和容量的队列中,平均用户数等于平均流量和平均服务时间的乘积。

具体来说,假设我们有一个队列,它有一定的容量,同时有一定的流量在进出队列。如果我们令队列中平均用户数为 L,平均流量为 λ,平均服务时间为 W,则 Little 定律可以表示为:

这个定律适用于任何类型的任务,包括服务请求、进程、线程、作业、数据包等等。它可以用来预测系统的吞吐量、延迟和并发性,并且在系统设计和性能优化中非常有用。

  • 所谓平均服务时间 W -> 其实就是请求处理的时间
  • 平均用户数 L -> 就是同时处理的请求数量
  • 平均流量 λ -> 就是吞吐量

如果我们想要在平均服务时间 W(请求处理时间)不变的情况下,增大平均流量 λ(吞吐量),那么平均用户数(L)势必要同比例增长,换句话说,对于给定的请求处理持续时间(即延迟),应用程序同时处理的请求数(即并发性)必须与吞吐量成比例增长。

例如,假设一个平均延迟为 50ms(W = 0.05) 的应用程序通过并发处理 10 个请求(L = 10)来实现每秒 200 个请求的吞吐量(λ = 200)。为了使该应用程序扩展到每秒 2000 个请求的吞吐量(λ = 2000),它需要并发处理 100 个请求(L = 100)。

如果每个请求都需要一个单独的线程进行处理,那么随着吞吐量的增加,线程数量将会急剧增加。

不幸的是,可用线程的数量是有限的,因为 JDK 线程的本质其实是操作系统线程,详细可看下这篇文章 Java 线程和操作系统的线程有啥区别?,而操作系统线程成本很高,所以我们不可能拥有太多线程,这使得 Thread-Per-Request 风格难以实现。如果每个请求在其持续时间内消耗一个 Java 线程,并因此消耗一个操作系统线程,那么在其他资源(例如 CPU 或网络连接)耗尽之前,线程的数量必定会成为性能限制的重要因素,所以 JDK 当前的线程实现使得应用程序的吞吐量被限制在远低于硬件可以支持的水平,有同学可能会说不是有线程池吗?即使线程被池化也会发生这种情况,因为池化虽然有助于避免启动新线程的高成本,但并不会增加线程总数。

使用异步

为了充分利用硬件,开发者们放弃了 Thread-Per-Request 的风格,转而采用线程共享(Thread-Sharing)。不是在一个线程上从头到尾处理一整个请求,而是在等待 I/O 操作完成时将该线程返回到线程池中,以便该线程可以为其他请求提供服务, I/O 操作完成后再利用回调函数进行通知。

通俗来说,在异步风格中,请求的每个阶段可能在不同的线程上执行,并且每个线程以交错的方式运行属于不同请求的阶段。这种细粒度的线程共享允许大量并发操作而不会消耗大量线程,消除了操作系统线程稀缺对吞吐量的限制。

举个例子,假设有一个网络服务器程序,需要处理来自客户端的请求并进行数据库查询,然后将结果返回给客户端。如果使用传统的线程池来处理请求,每当有一个请求到来时,就需要从线程池中取出一个线程进行处理。但是在请求过程中,当线程需要等待数据库查询结果时,它就会被阻塞,无法进行其他的请求处理,浪费了一个线程资源。如果使用异步 IO 操作,当线程需要进行数据库查询时,它可以将这个线程释放给线程池中的其他请求,等到数据库查询完成后,再将线程恢复执行,将查询结果返回给客户端。这样,一个线程就可以处理多个请求,从而提高并发能力。

但是由于不是一个线程处理一整个请求,这就导致我们必须将请求处理逻辑分解为小阶段,通常编写为 lambda 表达式,然后使用 API 将它们组合成一个顺序管道(比如 CompletableFuture)。

如果实际用过 lambda 表达式的同学肯定会深有感触,这简直是对 Debug 的灾难性打击:

  • 堆栈跟踪不提供可用的上下文
  • 调试器无法单步执行请求处理逻辑
  • 分析器无法将操作的成本与其调用者相关联

并且,从另一个角度来说,这种编程风格与 Java 平台不一致,因为应用程序的并发单元(异步管道)不再是平台的并发单元(简单来说就是 100 个并发请求不是对应 100 个线程了,可能就对应 10 个线程)。

使用协程

除开上述两种编程风格的缺点考虑,使用进程/线程模型还有一个不容忽视的弊端,那就是上下文切换的开销。而协程的上下文切换代价较小,其优势在于可以将一个线程切换为多个协程,每个协程之间可以轻松地进行切换,从而提高应用程序的吞吐量。

举个例子,我们只需要启动 100 个线程,每个线程上运行 100 个协程,这样不仅减少了线程切换开销,而且还能够同时处理 100 * 100 = 10000 个请求。

所以什么是协程(Coroutine)?

  1. 协程是一种运行在线程之上的「用户态」模型,也称为纤程(Fiber),协程并没有增加线程数量,只是在线程的基础之上通过分时复用(并发)的方式运行多个协程。
  2. 协程的切换在用户态完成(完全由用户控制,这一点就显著区别于进程/线程模型),它是一种非抢占式的调度方式,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。协程拥有自己的寄存器上下文和栈,协程调度切换时,将寄存器上下文和栈保存到线程的堆区,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快,比线程从用户态到内核态的代价小很多,

图片

分析下协程相对于进程/线程的好处:

  1. 轻量性:协程只需要保存少量的上下文信息,占用的资源更少,可以创建更多的协程。相比之下,线程/进程需要占用较大的内核资源,创建线程的开销也更大。
  2. 高效性:协程切换不需要内核态/用户态切换,可以在用户态直接切换上下文,速度更快。
  3. 灵活性:协程的切换由程序员主动控制,可以灵活地在不同协程之间切换,实现并发执行。而线程/进程的切换由操作系统内核进行调度,限制了并发度和灵活性。
  4. 可维护性:由于协程是在代码层面进行控制,可以更容易地编写和维护。而线程之间的同步和共享资源需要复杂的锁机制和线程间通信。

使用协程的注意事项

协程运行在线程之上,所以必然受到线程的限制。

如果协程调用了一个阻塞 IO 操作,由于操作系统并不知道协程的存在(因为协程运行在用户态),它只知道线程,因此在协程调用阻塞 IO 操作的时候,操作系统会让协程之上对应的线程陷入阻塞状态,也就是说当前的协程和其它绑定在该线程之上的协程都会陷入阻塞而得不到调度。

因此在协程中要么就别调用导致线程阻塞的操作,要么就采用异步编程的方式。

责任编辑:武晓燕 来源: 飞天小牛肉
相关推荐

2022-04-08 07:32:24

JavaJUCThreadLoca

2023-02-24 08:36:47

ChatGPT微软

2020-11-29 17:03:08

进程线程协程

2020-04-07 11:10:30

Python数据线程

2020-08-04 10:56:09

进程线程协程

2023-10-12 09:46:00

并发模型线程

2021-04-25 09:36:20

Go协程线程

2015-06-10 11:40:23

2009-11-09 09:50:03

2023-05-04 23:47:02

人工智能ChatGPT机器人

2012-02-22 16:08:17

UbuntuAndroid

2023-03-31 13:32:01

禁用人工智能

2024-10-25 15:56:20

2021-10-08 17:31:57

Windows 11操作系统微软

2022-04-19 20:39:03

协程多进程

2024-05-16 12:44:30

模型训练

2023-11-29 08:02:16

线程进程

2021-09-16 09:59:13

PythonJavaScript代码

2021-10-12 09:24:23

RufusWindows 11介质

2010-09-01 13:00:47

网络安全
点赞
收藏

51CTO技术栈公众号