极客时间已完结课程限时免费阅读

19 | 如何用协程来优化多线程业务?

19 | 如何用协程来优化多线程业务?-极客时间

19 | 如何用协程来优化多线程业务?

讲述:李良

时长11:59大小10.97M

你好,我是刘超。
近一两年,国内很多互联网公司开始使用或转型 Go 语言,其中一个很重要的原因就是 Go 语言优越的性能表现,而这个优势与 Go 实现的轻量级线程 Goroutines(协程 Coroutine)不无关系。那么 Go 协程的实现与 Java 线程的实现有什么区别呢?

线程实现模型

了解协程和线程的区别之前,我们不妨先来了解下底层实现线程几种方式,为后面的学习打个基础。
实现线程主要有三种方式:轻量级进程和内核线程一对一相互映射实现的 1:1 线程模型、用户线程和内核线程实现的 N:1 线程模型以及用户线程和轻量级进程混合实现的 N:M 线程模型。

1:1 线程模型

以上我提到的内核线程(Kernel-Level Thread, KLT)是由操作系统内核支持的线程,内核通过调度器对线程进行调度,并负责完成线程的切换。
我们知道在 Linux 操作系统编程中,往往都是通过 fork() 函数创建一个子进程来代表一个内核中的线程。一个进程调用 fork() 函数后,系统会先给新的进程分配资源,例如,存储数据和代码的空间。然后把原来进程的所有值都复制到新的进程中,只有少数值与原来进程的值(比如 PID)不同,这相当于复制了一个主进程。
采用 fork() 创建子进程的方式来实现并行运行,会产生大量冗余数据,即占用大量内存空间,又消耗大量 CPU 时间用来初始化内存空间以及复制数据。
如果是一份一样的数据,为什么不共享主进程的这一份数据呢?这时候轻量级进程(Light Weight Process,即 LWP)出现了。
相对于 fork() 系统调用创建的线程来说,LWP 使用 clone() 系统调用创建线程,该函数是将部分父进程的资源的数据结构进行复制,复制内容可选,且没有被复制的资源可以通过指针共享给子进程。因此,轻量级进程的运行单元更小,运行速度更快。LWP 是跟内核线程一对一映射的,每个 LWP 都是由一个内核线程支持。

N:1 线程模型

1:1 线程模型由于跟内核是一对一映射,所以在线程创建、切换上都存在用户态和内核态的切换,性能开销比较大。除此之外,它还存在局限性,主要就是指系统的资源有限,不能支持创建大量的 LWP。
N:1 线程模型就可以很好地解决 1:1 线程模型的这两个问题。
该线程模型是在用户空间完成了线程的创建、同步、销毁和调度,已经不需要内核的帮助了,也就是说在线程创建、同步、销毁的过程中不会产生用户态和内核态的空间切换,因此线程的操作非常快速且低消耗。

N:M 线程模型

N:1 线程模型的缺点在于操作系统不能感知用户态的线程,因此容易造成某一个线程进行系统调用内核线程时被阻塞,从而导致整个进程被阻塞。
N:M 线程模型是基于上述两种线程模型实现的一种混合线程管理模型,即支持用户态线程通过 LWP 与内核线程连接,用户态的线程数量和内核态的 LWP 数量是 N:M 的映射关系。
了解完这三个线程模型,你就可以清楚地了解到 Go 协程的实现与 Java 线程的实现有什么区别了。
JDK 1.8 Thread.java 中 Thread#start 方法的实现,实际上是通过 Native 调用 start0 方法实现的;在 Linux 下, JVM Thread 的实现是基于 pthread_create 实现的,而 pthread_create 实际上是调用了 clone() 完成系统调用创建线程的。
所以,目前 Java 在 Linux 操作系统下采用的是用户线程加轻量级线程,一个用户线程映射到一个内核线程,即 1:1 线程模型。由于线程是通过内核调度,从一个线程切换到另一个线程就涉及到了上下文切换。
而 Go 语言是使用了 N:M 线程模型实现了自己的调度器,它在 N 个内核线程上多路复用(或调度)M 个协程,协程的上下文切换是在用户态由协程调度器完成的,因此不需要陷入内核,相比之下,这个代价就很小了。

协程的实现原理

协程不只在 Go 语言中实现了,其实目前大部分语言都实现了自己的一套协程,包括 C#、erlang、python、lua、javascript、ruby 等。
相对于协程,你可能对进程和线程更为熟悉。进程一般代表一个应用服务,在一个应用服务中可以创建多个线程,而协程与进程、线程的概念不一样,我们可以将协程看作是一个类函数或者一块函数中的代码,我们可以在一个主线程里面轻松创建多个协程。
程序调用协程与调用函数不一样的是,协程可以通过暂停或者阻塞的方式将协程的执行挂起,而其它协程可以继续执行。这里的挂起只是在程序中(用户态)的挂起,同时将代码执行权转让给其它协程使用,待获取执行权的协程执行完成之后,将从挂起点唤醒挂起的协程。 协程的挂起和唤醒是通过一个调度器来完成的。
结合下图,你可以更清楚地了解到基于 N:M 线程模型实现的协程是如何工作的。
假设程序中默认创建两个线程为协程使用,在主线程中创建协程 ABCD…,分别存储在就绪队列中,调度器首先会分配一个工作线程 A 执行协程 A,另外一个工作线程 B 执行协程 B,其它创建的协程将会放在队列中进行排队等待。
当协程 A 调用暂停方法或被阻塞时,协程 A 会进入到挂起队列,调度器会调用等待队列中的其它协程抢占线程 A 执行。当协程 A 被唤醒时,它需要重新进入到就绪队列中,通过调度器抢占线程,如果抢占成功,就继续执行协程 A,失败则继续等待抢占线程。
相比线程,协程少了由于同步资源竞争带来的 CPU 上下文切换,I/O 密集型的应用比较适合使用,特别是在网络请求中,有较多的时间在等待后端响应,协程可以保证线程不会阻塞在等待网络响应中,充分利用了多核多线程的能力。而对于 CPU 密集型的应用,由于在多数情况下 CPU 都比较繁忙,协程的优势就不是特别明显了。

Kilim 协程框架

虽然这么多的语言都实现了协程,但目前 Java 原生语言暂时还不支持协程。不过你也不用泄气,我们可以通过协程框架在 Java 中使用协程。
目前 Kilim 协程框架在 Java 中应用得比较多,通过这个框架,开发人员就可以低成本地在 Java 中使用协程了。
在 Java 中引入 Kilim ,和我们平时引入第三方组件不太一样,除了引入 jar 包之外,还需要通过 Kilim 提供的织入(Weaver)工具对 Java 代码编译生成的字节码进行增强处理,比如,识别哪些方式是可暂停的,对相关的方法添加上下文处理。通常有以下四种方式可以实现这种织入操作:
在编译时使用 maven 插件;
在运行时调用 kilim.tools.Weaver 工具;
在运行时使用 kilim.tools.Kilim invoking 调用 Kilim 的类文件;
在 main 函数添加 if (kilim.tools.Kilim.trampoline(false,args)) return。
Kilim 框架包含了四个核心组件,分别为:任务载体(Task)、任务上下文(Fiber)、任务调度器(Scheduler)以及通信载体(Mailbox)。
Task 对象主要用来执行业务逻辑,我们可以把这个比作多线程的 Thread,与 Thread 类似,Task 中也有一个 run 方法,不过在 Task 中方法名为 execute,我们可以将协程里面要做的业务逻辑操作写在 execute 方法中。
与 Thread 实现的线程一样,Task 实现的协程也有状态,包括:Ready、Running、Pausing、Paused 以及 Done 总共五种。Task 对象被创建后,处于 Ready 状态,在调用 execute() 方法后,协程处于 Running 状态,在运行期间,协程可以被暂停,暂停中的状态为 Pausing,暂停后的状态为 Paused,暂停后的协程可以被再次唤醒。协程正常结束后的状态为 Done。
Fiber 对象与 Java 的线程栈类似,主要用来维护 Task 的执行堆栈,Fiber 是实现 N:M 线程映射的关键。
Scheduler 是 Kilim 实现协程的核心调度器,Scheduler 负责分派 Task 给指定的工作者线程 WorkerThread 执行,工作者线程 WorkerThread 默认初始化个数为机器的 CPU 个数。
Mailbox 对象类似一个邮箱,协程之间可以依靠邮箱来进行通信和数据共享。协程与线程最大的不同就是,线程是通过共享内存来实现数据共享,而协程是使用了通信的方式来实现了数据共享,主要就是为了避免内存共享数据而带来的线程安全问题。

协程与线程的性能比较

接下来,我们通过一个简单的生产者和消费者的案例,来对比下协程和线程的性能。可通过 Github 下载本地运行代码。
Java 多线程实现源码:
public class MyThread {
private static Integer count = 0;//
private static final Integer FULL = 10; //最大生产数量
private static String LOCK = "lock"; //资源锁
public static void main(String[] args) {
MyThread test1 = new MyThread();
long start = System.currentTimeMillis();
List<Thread> list = new ArrayList<Thread>();
for (int i = 0; i < 1000; i++) {//创建五个生产者线程
Thread thread = new Thread(test1.new Producer());
thread.start();
list.add(thread);
}
for (int i = 0; i < 1000; i++) {//创建五个消费者线程
Thread thread = new Thread(test1.new Consumer());
thread.start();
list.add(thread);
}
try {
for (Thread thread : list) {
thread.join();//等待所有线程执行完
}
} catch (InterruptedException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("子线程执行时长:" + (end - start));
}
//生产者
class Producer implements Runnable {
public void run() {
for (int i = 0; i < 10; i++) {
synchronized (LOCK) {
while (count == FULL) {//当数量满了时
try {
LOCK.wait();
} catch (Exception e) {
e.printStackTrace();
}
}
count++;
System.out.println(Thread.currentThread().getName() + "生产者生产,目前总共有" + count);
LOCK.notifyAll();
}
}
}
}
//消费者
class Consumer implements Runnable {
public void run() {
for (int i = 0; i < 10; i++) {
synchronized (LOCK) {
while (count == 0) {//当数量为零时
try {
LOCK.wait();
} catch (Exception e) {
}
}
count--;
System.out.println(Thread.currentThread().getName() + "消费者消费,目前总共有" + count);
LOCK.notifyAll();
}
}
}
}
}
Kilim 协程框架实现源码:
public class Coroutine {
static Map<Integer, Mailbox<Integer>> mailMap = new HashMap<Integer, Mailbox<Integer>>();//为每个协程创建一个信箱,由于协程中不能多个消费者共用一个信箱,需要为每个消费者提供一个信箱,这也是协程通过通信来保证共享变量的线程安全的一种方式
public static void main(String[] args) {
if (kilim.tools.Kilim.trampoline(false,args)) return;
Properties propes = new Properties();
propes.setProperty("kilim.Scheduler.numThreads", "1");//设置一个线程
System.setProperties(propes);
long startTime = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {//创建一千生产者
Mailbox<Integer> mb = new Mailbox<Integer>(1, 10);
new Producer(i, mb).start();
mailMap.put(i, mb);
}
for (int i = 0; i < 1000; i++) {//创建一千个消费者
new Consumer(mailMap.get(i)).start();
}
Task.idledown();//开始运行
long endTime = System.currentTimeMillis();
System.out.println( Thread.currentThread().getName() + "总计花费时长:" + (endTime- startTime));
}
}
//生产者
public class Producer extends Task<Object> {
Integer count = null;
Mailbox<Integer> mb = null;
public Producer(Integer count, Mailbox<Integer> mb) {
this.count = count;
this.mb = mb;
}
public void execute() throws Pausable {
count = count*10;
for (int i = 0; i < 10; i++) {
mb.put(count);//当空间不足时,阻塞协程线程
System.out.println(Thread.currentThread().getName() + "生产者生产,目前总共有" + mb.size() + "生产了:" + count);
count++;
}
}
}
//消费者
public class Consumer extends Task<Object> {
Mailbox<Integer> mb = null;
public Consumer(Mailbox<Integer> mb) {
this.mb = mb;
}
/**
* 执行
*/
public void execute() throws Pausable {
Integer c = null;
for (int i = 0; i < 10000; i++) {
c = mb.get();//获取消息,阻塞协程线程
if (c == null) {
System.out.println("计数");
}else {
System.out.println(Thread.currentThread().getName() + "消费者消费,目前总共有" + mb.size() + "消费了:" + c);
c = null;
}
}
}
}
在这个案例中,我创建了 1000 个生产者和 1000 个消费者,每个生产者生产 10 个产品,1000 个消费者同时消费产品。我们可以看到两个例子运行的结果如下:
多线程执行时长:2761
协程执行时长:1050
通过上述性能对比,我们可以发现:在有严重阻塞的场景下,协程的性能更胜一筹。其实,I/O 阻塞型场景也就是协程在 Java 中的主要应用。

总结

协程和线程密切相关,协程可以认为是运行在线程上的代码块,协程提供的挂起操作会使协程暂停执行,而不会导致线程阻塞。
协程又是一种轻量级资源,即使创建了上千个协程,对于系统来说也不是很大的负担,但如果在程序中创建上千个线程,那系统可真就压力山大了。可以说,协程的设计方式极大地提高了线程的使用率。
通过今天的学习,当其他人侃侃而谈 Go 语言在网络编程中的优势时,相信你不会一头雾水。学习 Java 的我们也不要觉得,协程离我们很遥远了。协程是一种设计思想,不仅仅局限于某一门语言,况且 Java 已经可以借助协程框架实现协程了。
但话说回来,协程还是在 Go 语言中的应用较为成熟,在 Java 中的协程目前还不是很稳定,重点是缺乏大型项目的验证,可以说 Java 的协程设计还有很长的路要走。

思考题

在 Java 中,除了 Kilim 框架,你知道还有其它协程框架也可以帮助 Java 实现协程吗?你使用过吗?
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 10

提建议

上一篇
18 | 如何设置线程池大小?
下一篇
20 | 答疑课堂:模块三热点问题解答
unpreview
 写留言

精选留言(36)

  • 帽子丨影
    2019-08-16
    文中提到协程有个队列,最终还是需要主线程来调度执行。那是不是有点类似线程池。主线程是Worker线程,每一个协程就是一个Task。只不过主线程执行到的某个Task阻塞时会去执行下一个Task。

    作者回复: 对的,这样理解很清晰

    14
  • 小橙橙
    2019-07-05
    老师,以后的JAVA版本是不是也会自带协程功能?

    作者回复: 是的,Java未来的三个主要项目之一Loom项目 引入了被称为 fibers 的新型轻量级用户线程,甲骨文公司 Loom 项目技术负责人 Ron Pressler 在 QCon 伦敦 2019 大会上指出:“利用 fibers,如果我们确保其轻量化程度高于内核提供的线程,那么问题就得到了解决。大家将能够尽可能多地使用这些用户模式下的轻量级线程,且基本不会出现任何阻塞问题”。 具体的可以阅读以下openjdk官网链接: https://cr.openjdk.java.net/~rpressler/loom/Loom-Proposal.html

    共 2 条评论
    14
  • ~Wade
    2019-07-24
    有一个java库叫Quasar Fiber ,通过javaagent技术可以实现轻量级线程  官网:http://www.paralleluniverse.co/quasar/
    11
  • 欧星星
    2019-07-02
    老师有几个疑问 1. 协程必须手动调用等待或阻塞才能被安排到等待队列去吗?还是说协程也可以跟线程一样会被随机丢到等待队列去每个协程也有个运行时间片?如果可以随机一般是如何实现的? 2. 协程之间的争抢基于什么实现的?我想的话可以使用CAS来实现没有抢到的再次被丢到等待队列不知道对不对。 3. 我看例子上邮箱里面没有数据时消费者协程没有类似线程的等待机制,这个要如何写呢?
    展开
    4
  • 2019-09-10
    课前思考及问题 1:协程是啥? 2:协程更轻量怎么体现,它轻量在哪里? 3:为什么多协程没有高并发的问题? 4:协程真这么优秀,为啥没有全面支持或使用?换言之实现协程需要什么代价? 昨晚休息的太晚了,今天脑袋晕晕的,状态不佳感觉学完还是回答不好,课前的思考题。先跳过,回头再看一下。 课后思考及问题 1:协程与线程最大的不同就是,线程是通过共享内存来实现数据共享,而协程是使用了通信的方式来实现了数据共享,主要就是为了避免内存共享数据而带来的线程安全问题。 这里的通信方式具体指什么?它比共享内存通信的方式还要快嘛?每个协程间都需要建立这种通信方式不耗资源不影响性能嘛? 2:而 Go 语言是使用了 N:M 线程模型实现了自己的调度器,它在 N 个内核线程上多路复用(或调度)M 个协程,协程的上下文切换是在用户态由协程调度器完成的,因此不需要陷入内核,相比之下,这个代价就很小了。 协程的执行不需要CPU?若需要,就会存在切入切出,协程的上下文切换怎么能在用户态全部完成,这个没有理解?
    展开

    作者回复: 1、这里说的通信方式是指协程间的通信方式,是通过数据共享来实现的,不存在线程安全问题; 2、需要CPU,协程可以更充分的利用好CPU,不会来回切换资源。

    共 2 条评论
    3
  • QQ怪
    2019-07-02
    老师讲协程讲的最深最易懂的一个,赞赞赞
    3
  • 听雨
    2019-07-02
    老师,读了今天的内容,我理解的意思是: 1.因为每个轻量级线程都有一个内核线程支持,而java中,每个用户线程对应一个轻量级线程,可以看作用户线程和支持轻量级线程的内核线程是一对一的,所以就说java线程模型是用户线程和内核线程一对一。 2.那这里轻量级线程属于内核线程吗,我看文中说的是由内核线程clone而来的,那它算内核线程吗? 请老师解答一下!
    展开

    作者回复: 1、对的 2、属于用户线程,与内核线程一对一映射

    3
  • 晓晨同学
    2019-11-13
    请问老师,上面说N:1这种线程模型是当某个用户线程使用内核线程阻塞的时候会使整个进程下的所有用户线程阻塞,那我想为什么不能像N:M线程模型一样当某个用户线程阻塞时就挂起来从而去调度其他的用户线程呢

    作者回复: 可以,只是实现成本的问题

    2
  • Ericens
    2019-10-11
    老师,请教个关于协程与线程的疑问。比如,a协程调用socket. read(),此时数据没有准备好,则继续调度b协程。 把协程换成线程,上面这个过程哪个更加轻量?协程还是线程? 我理解这个过程涉及的过程如下,都一样。 1.都有系统调用read(),从用户态切换到了内核态, 2.都有上下文切换。(不同协程的寄存器,和不同线程的寄存器) 3. 都要执行任务调度。协程调度或者线程调度。 那协程到底轻量在哪?
    展开

    作者回复: 协程更轻量级,协程是线程的子集,所以不会存在线程间的上下文切换,而仅仅是通过调度器来完成调度的。

    共 2 条评论
    2
  • 葛阳
    2020-04-27
    "相对于 fork() 系统调用创建的线程来说,LWP 使用 clone() 系统调用创建线程,该函数是将部分父进程的资源的数据结构进行复制,复制内容可选,且没有被复制的资源可以通过指针共享给子进程。因此,轻量级进程的运行单元更小,运行速度更快。LWP 是跟内核线程一对一映射的,每个 LWP 都是由一个内核线程支持。" -- 这句话是不是有毛病,一会儿"进程"一会儿"线程"

    作者回复: 这里的进程应该理解为线程,因为在unix编程中,通常以进程来代表线程的

    1
  • 慎独
    2019-12-04
    老师,协程在高并发系统中使用过吗?

    作者回复: 协程可以胜任高并发场景,不过目前没有在线上的Java项目使用过协程

    1
  • neohope
    2019-11-22
    1. Java框架的话,Kilim有一段时间没有更新了,最近Quasar好像流行一些。 2. JVM里,可以混用Kotlin;或者是混用Scala+akka。 3. 未来版本的JDK也会逐步支持协程,但现在的实验版本好像只是在Linux下支持的比较好。 4 .不过说实话,现在的netty框架,在一定意义上工作原理和协程也有一些相似之处。

    作者回复: netty在处理同步业务场景下,特别是业务处理比较耗时的场景下,并发处理能力有限。

    1
  • -W.LI-
    2019-07-02
    老师好!1:1,N:1,N:M的线程模型。总感觉上学的时候有讲可是又想不起来。谢谢老师的讲解。不过还是有些不明白的地方。 Java是采用1:1的映射方式。一个Java线程就对应一个内核线程。用户态和内核态的切换和这个映射模型有关系么(用户态和内核态,和用户线程内核线程是否有关系)? 从用户态切换为内核态的时候具体做了哪些操作?之前讲IO模型时老师讲了,用户空间和内核空间,多次数据拷贝。和用户线程内核线程有什么联系么?后面会讲么?
    展开
    1
  • nightmare
    2019-07-02
    终于弄懂协程和线程的区别了,协程基于N:M的线程模型实现,M个协程被调度器调度,实际上也是被内核线程执行,不过由于需要的内核线程少,一个内核线程可以执行多个协程,占用资源少,而且上下文切换也更加少,而基于线程的1:1模型只有有阻塞就会有上下文切换
    1
  • | ~浑蛋~
    2022-12-11 来自广东
    协程的上下文怎么维护呢
  • 杯莫停
    2022-07-28 来自陕西
    协程其实还是要利用线程来实现多路复用的,因为操作系统只认线程。其实跟netty的多线程模型有些类似,只是协程干了阻塞IO的事,少了上下文切换。
  • 190coder
    2021-09-11
    java 里的协程就是玩具,在线程资源足够多的时候,性能和效率根本比不过线程
  • 书策稠浊
    2021-07-05
    每个协程都有自己的mailbox,为啥还能共享变量?如果不能共享变量,这两个测试程序根本体现不出啥。
  • Eco
    2021-03-08
    好像有个叫akka的框架不知道是不是协程的思想。还有老师在上面讲协程调度哪里,感觉有点像线程池呢。就指定数量的线程去执行远远大于线程数的Task,只不过这里的Task有状态还能阻塞。
  • 惘 闻
    2020-12-29
    而 Go 语言是使用了 N:M 线程模型实现了自己的调度器,它在 N 个内核线程上多路复用(或调度)M 个协程. 老师这里的多路复用是什么意思啊?复用的是协程还是内核线程.
    共 1 条评论