浅析协程

并发编程的核心目标之一就是”",并发编程发展史中若干特性与改进无不围绕这个目标。

quick.gif

笔者通过《Learning Concurrency in Kotlin》书中的"Processes, threads, and coroutines"章节,浅析一下"协程"

进程(Processes)

  • 我们先看看书中对进程(Processes)的介绍:

A process is an instance of an application that is being executed. Each time an application is started, a process is started for it.

进程是正在执行的应用程序的实例。每次启动应用程序时,都会为其启动一个进程。

A process has a state, things such as handles to open resources, a process ID, data, network connections, and so on, are part of the state of a process and can be accessed by the threads inside that process.

进程是有状态的,打开资源的句柄、进程 ID、数据、网络连接等都是进程状态的一部分,这些信息都可以被该进程内的线程访问。

An application can be composed of many processes, a common practice for example for internet browsers.

……But implementing a multi-process application brings challenges that are out of the scope of this book. For this book, we will cover the implementation of applications that run in more than one thread, but still in a single process…….

  • 归纳一下书中内容:
    • 1个应用可以包含N个进程。
    • Chrome的多进程架构图展示了:1个Chrome浏览器包含了浏览器进程、渲染进程等N个进程。

img

线程(Threads)

  • 我们再看看书中对线程(Threads)的介绍:

A thread of execution encompasses a set of instructions for a processor to execute.

So a process will contain at least one thread, which is created to execute the point of entry of the application; usually this entry point is the main() function of the application. This thread is called the main thread, and the life cycle of the process will be tied to it; if this thread ends, the process will end as well, regardless of any other threads in the process.

线程包含一组供处理器执行的指令。1个进程至少会包含1个线程,这个线程就是主线程。应用程序在入口点创建该线程,并绑定了该线程与进程的生命周期。

其中:

1
2
(1)应用程序的入口点通常是main()函数。
(2)主线程结束,则进程也结束,即使这个进程中还有其它未执行完的线程。

Each thread can access and modify the resources contained in the process it’s attached to, but it also has its own local storage, called thread-local storage.

每个线程都可以访问和修改它所附加的进程中包含的资源,但它也有自己的本地存储,称为线程本地存储(thread-local)。

Only one of the instructions in a thread can be executed at a given time. So if a thread is blocked, the execution of any other instruction in that same thread will not be possible until the blocking ends. Nevertheless, many threads can be created for the same process, and they can communicate with each other. So it is expected that an application will never block a thread that can affect negatively the experience of the user; instead, the blocking operations should be assigned to threads that are dedicated to them.

In Graphic User Interface (GUI) applications, there is a thread called a UI thread; its function is to update the User Interface and listen to user interactions with the application. Blocking this thread, obstructs the application from updating its UI and from receiving interactions from the user. Because of this, GUI applications are expected to never block the UI thread*,* in order to keep the application responsive at all times.

Android 3.0 and above, for example, will crash an application if a networking operation is made in the UI thread, in order to discourage developers from doing it, given that networking operations are thread-blocking.

在给定的时间内,只能执行1个线程中的1条指令。如果一个线程被阻塞,则在阻塞结束前,该线程内任何其它的指令都不可能被执行。

尽管如此,我们可以在1个进程内创建N个线程,线程间可以互相通信。

因此,为了不影响用户体验,我们应该将阻塞操作分配给专门的线程,而不是阻塞主线程。

例如:

1
2
在GUI应用程序中,存在1个UI线程,这个线程的功能是更新用户界面并监听用户与应用程序的交互,阻塞了UI线程就会阻止用户界面的更新和交互。
因此,我们永远不会阻塞UI线程。

再例如:

1
在Android 3.0+的版本中,如果在UI 线程中进行网络操作,Android会触发应用程序崩溃,因为Android认为网络操作是一种阻塞操作。

Throughout the book, we will refer to the main thread of a GUI application both as a UI thread and as a main thread (because in Android, by default, the main thread is also the UI thread), while for command-line applications we will refer to it only as a main thread. Any thread different from those two will be called a background thread, unless a distinction between background threads is required, in which case each background thread will receive a unique identifier for clarity.

在本书中,

我们将GUI应用程序的主线程称为UI线程,也称为主线程——比如:在 Android 中,默认情况下,主线程就是UI线程。

我们将命令行应用程序的主线程,仅称为主线程

任何不同于前述两个线程的其它线程都称为后台线程

Given the way that the Kotlin has implemented concurrency, you will find that it’s not necessary for you to manually start or stop a thread. The interactions that you will have with threads will commonly be limited to tell Kotlin to create or use a specific thread or pool of threads to run a coroutine – usually with one or two lines of code. The rest of the handling of threads will be done by the framework.

鉴于Kotlin实现并发的方式,我们没有必要手动启动或停止线程。

我们与线程的交互通常仅限于告诉Kotlin创建/使用特定线程,或者告诉Kotlin使用线程池中的某个空闲的协程,线程的其余处理将由底层框架完成。

  • 归纳一下书中内容:
    • 1个进程包含N个线程,这些线程包含主线程、后台线程等。
    • Chrome的多进程架构图展示了:浏览器进程中包含了主线程、IO线程,渲染进程包含了主线程、渲染线程。

img

协程(Coroutines)

  • 我们还是看看书中对协程(Coroutines)的介绍:

Kotlin’s documentation often refers to coroutines as lightweight threads. This is mostly because, like threads, coroutines define the execution of a set of instructions for a processor to execute. Also, coroutines have a similar life cycle to that of threads.

在Kotlin中,通常将协程称为轻量级线程。这主要是因为,与线程一样,协程定义了处理器执行的一组指令的执行。此外,协程具有与线程相似的生命周期。

A coroutine is executed inside a thread. One thread can have many coroutines inside it, but as already mentioned, only one instruction can be executed in a thread at a given time. This means that if you have ten coroutines in the same thread, only one of them will be running at a given point in time.

协程在线程内执行。1个线程内部可以有N个协程。

但正如前文提到的:在给定时间内,1个线程中只能执行1条指令,因此1个线程中有10个协程,那么在给定的时间点只会运行其中1个协程。

The biggest difference between threads and coroutines, though, is that coroutines are fast and cheap to create. Spawning thousands of coroutines can be easily done, it is faster and requires fewer resources than spawning thousands of threads.

不过,线程和协程之间的最大区别在于:创建协程速度快、成本低,它比产生数千个线程更快、需要更少的资源。

Take this code as an example. Don’t worry about the parts of the code you don’t understand yet:

This function creates as many coroutines as specified in the parameter amount, delays each one for a second, and waits for all of them to end before returning. This function can be called, for example, with 10,000 as the amount of coroutines:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
suspend fun createCoroutines(amount: Int) {
    val jobs = ArrayList<Job>()
    for (i in 1..amount) {
        jobs += launch {
            delay(1000)           
        }
    }
    jobs.forEach {
        it.join()
    }
}

fun main(args: Array<String>) = runBlocking {
    val time = measureTimeMillis {
        createCoroutines(10_000)
    }

    println("Took $time ms")
}

以上述代码为例:createCoroutines()支持创建指定数量的协程,执行每个协程就是休眠1秒,此函数等待所有协程结束后就会返回。

In a test environment, running it with an amount of 10,000 took around 1,160 ms, while running it with 100,000 took 1,649 ms. The increase in execution time is so small because Kotlin will use a pool of threads with a fixed size, and distribute the coroutines among those threads – so adding thousands of coroutines will have little impact. And while a coroutine is suspended – in this case because of the call to delay() – the thread it was running in will be used to execute another coroutine, one that is ready to be started or resumed.

在测试环境中,创建1万个协程需要1160毫秒,创建10万个协程需要1649毫秒。协程增加了9万个,执行时间却没有增加太多,是因为:

Kotlin使用了固定大小的线程池,并将协程分布在线程池中的这些线程上,所以增加协程数量,并不会增加更多的资源消耗和时间消耗。

所谓的将"协程分布在线程池中的这些线程上”,就是将1个协程放到1个线程中去执行,当这个协程被挂起时(因为调用了delay()函数),它所属的这个线程就用来执行其它准备好的协程。

How many threads are active can be determined by calling the activeCount() method of the Thread class.

activeCount()函数可以确定当前有多少个线程处于活动状态。

For example, let’s update the main() function to do so:

1
2
3
4
5
6
7
8
fun main(args: Array<String>) = runBlocking {
    println("${Thread.activeCount()} threads active at the start")
    val time = measureTimeMillis {
        createCoroutines(10_000)
    }
    println("${Thread.activeCount()} threads active at the end")
    println("Took $time ms")
}

比如:我们修改main()函数的实现,分别打印了main()函数创建协程前的线程个数、创建协程后的线程个数。

In the same test environment as before, it was found that in order to create 10,000 coroutines, only four threads needed to be created:

img

But once the value of the amount being sent to createCoroutines() is lowered to one, for example, only two threads are created:

img

在测试环境上,创建1万个协程需要4个线程(6个线程-2个线程),创建1个协程仅需要创建2个线程(4个线程-2个线程)。

It’s important to understand that even though a coroutine is executed inside a thread, it’s not bound to it. As a matter of fact, it’s possible to execute part of a coroutine in a thread, suspend the execution, and later continue in a different thread. In our previous example this is happening already, because Kotlin will move coroutines to threads that are available to execute them. For example, by passing 3 as the amount to createCoroutines(), and updating the content of the launch() block so that it prints the current thread, we can see this in action:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
suspend fun createCoroutines(amount: Int) {
    val jobs = ArrayList<Job>()
    for (i in 1..amount) {
        jobs += launch {
            println("Started $i in ${Thread.currentThread().name}")
            delay(1000)
            println("Finished $i in ${Thread.currentThread().name}")
        }
    }
    jobs.forEach {
         it.join()
    }
}

另一个很重要的点是:即使协程在线程内执行,它也不会受线程约束。

事实上,可以在1个线程中执行1个协程的一部分,当这个协程被挂起再恢复执行时,可以在另1个线程中继续执行这个协程的剩余部分。

You will find that in many cases they are being resumed in a different thread:

img

我们可以看到示例代码创建了3个协程,协程1在线程1中执行,延迟1000毫秒后,协程1在线程2中继续执行完毕。

  • 归纳一下书中内容:
    • 1个线程包含N个协程。
    • 同1个协程可以在线程1中执行一部分,在线程2中执行剩余部分。

全局观

  • 最后我们看看书中对进程、线程、协程的总结:

So far, we have learned that an application is composed of one or more processes and that each process has one or more threads. We have also learned that blocking a thread means halting the execution of the code in that thread, and for that reason, a thread that interacts with a user is expected to never be blocked. We also know that a coroutine is basically a lightweight thread that resides in a thread but is not tied to one. The following diagram encapsulates the content of this section so far. Notice how each coroutine is started in one thread but at some point is resumed in a different one:

至此,

我们可以知道:1个应用程序是由N个进程组成,1个进程包含N线程。

我们还知道:阻塞线程意味着停止该线程中代码的执行,因此永远不应该阻塞与用户交互的线程。

我们还知道:协程是1个轻量级线程,它驻留在1个线程中但又不绑定到具体的这个线程中。

1
1个协程可以在1个线程中启动,但在某个时刻又可以在另一个线程中恢复执行。

下图概括了本节到目前为止的内容。

img

协程的语言实现(Java版)

协程在Java中的实现非常落后,直到Java8也没有在正式版本中支持协程。

OpenJDK团队后来引入了quasar库的大神,启动了Loom项目,Loom项目的最新进展后续另开文章介绍,本文就基于quasar体验一下Java版的协程性能。

在此,我们模仿前文翻译的《Learning Concurrency in Kotlin》中的示例代码:

  • 创建10万个线程,每个线程执行20万次加法,等待10万个线程都执行完,打印总耗时。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Main1 {
    public static final int MAX_COUNT = 100000;

    public static void main(String[] args) {
        long iStartTime = System.currentTimeMillis();

        Thread[] arrThread = new Thread[MAX_COUNT];
        for (int i = 0; i < arrThread.length; i++) {
            arrThread[i] = new Thread(Main1::calc);
        }

        for (Thread thread : arrThread) {
            thread.start();
        }

        for (Thread thread : arrThread) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("Total: " + (System.currentTimeMillis() - iStartTime));
    }

    public static void calc() {
        int iRes = 0;
        for (int i = 0; i < 10000; i++) {
            for (int j = 0; j < 20; j++) {
                iRes += 1;
            }
        }
        System.out.println(iRes);
    }
}

执行结果:消耗了8945毫秒。

image-20221201183245050

  • 我们再创建10万个协程,每个协程也是执行20万次加法,等待10万个协程都执行完,打印总耗时。
    • 这里我们用到了quasar来创建协程。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Main2 {
    public static void main(String[] args) {
        long iStartTime = System.currentTimeMillis();

        Fiber<Void>[] arrFiber = new Fiber[Main1.MAX_COUNT];
        for (int i = 0; i < arrFiber.length; i++) {
            arrFiber[i] = new Fiber<>(Main1::calc);
        }

        for (Fiber<Void> fiber : arrFiber) {
            fiber.start();
        }

        for (Fiber<Void> thread : arrFiber) {
            try {
                thread.join();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        System.out.println("Total: " + (System.currentTimeMillis() - iStartTime));
    }
}

执行结果:消耗了1812毫秒。

image-20221201183730909

  • 测试结论:反复执行上述测试代码,Java协程的性能远高于比Java线程的性能。

参考

《Learning Concurrency in Kotlin》:https://www.amazon.com/Learning-Concurrency-Kotlin-efficient-applications/dp/1788627164

Wiki:https://en.wikipedia.org/wiki/Coroutine

image-20221121164732405