Cracking Kotlin coroutines (2) - Several common implementations of coroutines

foreword

insert image description here

The so-called know yourself and know the enemy, and you will never be imperiled in a hundred battles. In order to understand what is going on with Kotlin coroutines, let's also look at how coroutines in other languages ​​are implemented.

In the previous article, we roughly discussed what coroutines are all about, and also gave some examples, but the overall coverage of details is relatively small. In this article, we follow the idea of ​​the classic paper "Revisiting Coroutines" on coroutines, and discuss in detail how coroutines exist. Of course, due to the many languages ​​involved and the limited personal level, if there is any inappropriateness, please correct me.

  1. Classification of coroutines
    Although the mainstream implementation of coroutines is quite different in details, there are still rules to follow in general.

1.1 Classified by call stack
Since coroutines need to support suspension and recovery, it is extremely critical to save the state of the suspension point. Similarly, a thread will be interrupted due to the switching of CPU scheduling rights, and its interrupt status will be saved in the call stack. Therefore, the implementation of the coroutine also has the following two types according to whether the corresponding call stack is opened:

Stackful Coroutine: Each coroutine has its own call stack, which is somewhat similar to the call stack of a thread. In this case, the implementation of the coroutine is actually very close to the thread, and the main difference is reflected in the scheduling.
Stackless Coroutine: The coroutine does not have its own call stack, and the state of the suspension point is realized by syntax such as state machine or closure.
The advantage of stacked coroutines is that they can be suspended at any position of any function call level and transfer the scheduling right, such as Lua coroutines. In this regard, most stackless coroutines are powerless, such as Python's Generator; usually In other words, a stacked coroutine will always open up a piece of stack memory for the coroutine, so the memory overhead is relatively considerable, while a stackless coroutine has more advantages in terms of memory.

Of course there are counterexamples.

The go routine of the Go language can be considered as an implementation of a stack coroutine, but the Go runtime has done a lot of optimization here. Its stack memory can be expanded and reduced according to needs. The minimum memory page length is generally 4KB. In contrast, the stack space of a thread is usually at the MB level, so its performance in terms of memory is relatively lightweight.

Kotlin's coroutine is an implementation of a stackless coroutine. Its control flow is realized by the state flow of the state machine compiled and generated by the coroutine body itself. Variable storage is also realized through closure syntax. However, Kotlin's coroutine A program can be suspended at any call level. In other words, we start a Kotlin coroutine, and we can nest suspend functions in it arbitrarily, and this is precisely one of the most important features of a stacked coroutine:

suspend fun level_0() {
println(“I’m in level 0!”)
level_1() // … ①
}

suspend fun level_1() {
println(“I’m in level 1!”)
suspendNow() // … ②
}

suspend fun suspendNow()
= suspendCoroutine {

}

In the example, ① does not really suspend directly, but the call at ② actually suspends. Kotlin can suspend any function call level by nesting the suspend function.

Of course, if you want to suspend at any position, you need the call stack. Compared with the developer who explicitly suspends the coroutine by calling the API, the suspension at any position is mainly used to intervene in the execution of the coroutine at runtime. This suspend method is invisible to the developer, so it is an implicit suspend operation. The go routine of the Go language can be suspended and resumed by reading and writing to the channel. In addition to this explicit switching of scheduling rights, the Go runtime will also implicitly suspend the go routines that occupy the scheduling rights for a long time. And transfer the scheduling right to other go routines, which is actually the preemptive scheduling of threads we are familiar with.

1.2 Classification by scheduling method
In the scheduling process, coroutines are divided into symmetric coroutines and asymmetric coroutines according to the goal of coroutine transfer of scheduling rights:

Symmetric Coroutine: Any coroutine is independent and equal to each other, and the scheduling right can be transferred between any coroutines.
Asymmetric Coroutine: The target of a coroutine's transfer of scheduling rights can only be its caller, that is, there is a calling and called relationship between coroutines.
Symmetric coroutines are actually very close to threads. For example, the go routine in Go language can realize the free transfer of control rights by reading and writing different channels. The call relationship of asymmetric coroutines is actually more in line with our way of thinking. The implementation of coroutines in common languages ​​​​is mostly asymmetrical. For example, in Lua's coroutines, the current coroutine call yield will always transfer the scheduling right to resume its coroutine; also, we mentioned above async/await, when we await, we transfer the scheduling right to the asynchronous call. When the asynchronous call returns a result or throws an exception, the scheduling right is always transferred back to the await position.

From an implementation point of view, the implementation of asymmetric coroutines is more natural and relatively easy; however, we only need to slightly modify the asymmetric coroutines to realize the ability of symmetric coroutines. On the basis of asymmetric coroutines, we only need to add a neutral third party as the distribution center of the coroutine scheduling right. All coroutines will transfer control to the distribution center when they are suspended, and the distribution center will decide according to the parameters. Which coroutine to transfer the scheduling right to, such as Lua's third-party library coro (https://luapower.com/coro), and the Kotlin coroutine framework based on Channel (https://kotlinlang.org/docs/reference/coroutines /channels.html) etc.

  1. Examples of the implementation of coroutines
    We have introduced a lot of theoretical knowledge related to coroutines. Simply put, what coroutines need to pay attention to is that the program handles suspend and resume by itself. The difference in specific implementation details distinguishes the classification according to the presence or absence of the stack and the symmetry of the scheduling right transfer. In any case, the focus of coroutines is that the program handles suspending and resuming by itself. Below we give some implementations, please pay attention to how they do this.

2.1 Python's Generator
Python's Generator is also a coroutine, which is a typical implementation of a stackless coroutine. We can call yield in any Python function to suspend the current function call. The parameters of yield are used as the next (num_generator ) call return value:

import time

def numbers():
i = 0
while True:
yield(i) # … ①
i += 1
time.sleep(1)

num_generator = numbers()

print(f"[0] {next(num_generator)}“) # … ②
print(f”[1] {next(num_generator)}") # … ③

for i in num_generator: # … ④
print(f"[Loop] {i}")

So when running this program, it will first yield at ①, pass out 0, and output at ②:

[0] 0

Then call next from ③, transfer the scheduling right from the main process to the numbers function, continue execution from the last suspended position ①, modify the value of i to 1, and after 1s, suspend again through yield(1), ③ output:

[1] 1

Followed by the same logic in the for loop has been output [Loop] n until the program is terminated.

We see that the reason why Python’s Generator is called a coroutine is because it has the ability to suspend the execution of the current Generator function through yield, and to resume the execution of the Generator corresponding to the parameter through next to realize the coroutine scheduling right of suspension and recovery. transfer of control.

Of course, if you call yield nested in the numbers function, you cannot interrupt the calls to numbers:

def numbers():
i = 0
while True:
yield_here(i) # … ①
i += 1
time.sleep(1)

def yield_here(i):
yield(i)

At this time, if we call the numbers function again, it will fall into an infinite loop and cannot return, because the return value of yield_here is the Generator.

It shows that Python's Generator is an implementation of asymmetric stackless coroutines. Starting from Python 3.5, async/await is also supported. The principle is similar to the implementation of JavaScript. The difference from Generator is that we can use this set of keywords to realize the suspension of function nested calls.

2.2 The implementation of coroutines in the Lua standard library
Lua's implementation of coroutines can be considered a textbook case. It provides several APIs to allow developers to flexibly control the execution of coroutines:

coroutine.create: Create a coroutine, the parameter is a function, as the execution body of the coroutine, and return the coroutine instance.
coroutine.yield: Suspend the coroutine, the first parameter is the suspended coroutine instance, and the following parameters are used as the return value of the external call resume to continue the current coroutine, and its return value is the external download Parameters passed to a resume call.
coroutine.resume: continue the coroutine, the first parameter is the coroutine instance to be resumed, the following parameters are used as the return value of the yield inside the coroutine, and the return value is the parameter passed out when the next yield inside the coroutine; If it is the first time to resume the coroutine instance, the parameters will be passed in as parameters of the coroutine function.
Lua's coroutine also has several states, suspended (suspended), running (running), and ended (dead). Among them, the coroutine after calling yield is in the suspended state, the coroutine that has obtained the execution right and is running is in the running state, and after the function corresponding to the coroutine finishes running, it is in the end state.

function producer()
for i = 0, 3 do
print("send "…i)
coroutine.yield(i) – ④
end
print(“End Producer”)
end

function consumer(value)
repeat
print("receive "…value)
value = coroutine.yield() – ⑤
until(not value)
print(“End Consumer”)
end

producerCoroutine = coroutine.create(producer) – ①
consumerCoroutine = coroutine.create(consumer) – ②

repeat
status, product = coroutine.resume(producerCoroutine) – ③
coroutine.resume(consumerCoroutine, product) – ⑥
until(not status)
print(“End Main”)

This code creates coroutines at ① and ②, and starts to execute at ③. The producer yields (0) at ④, which means that the return value product of ③ is 0. We pass 0 as a parameter to the consumer. The first time The resume parameter 0 will be passed in as the parameter value of the consumer, so it will print out:

send 0
receive 0

Next, the consumer is suspended through the yield at ⑤, and its parameters will be used as the return value at ⑥, but we do not pass any parameters. At this time, the control returns to the main process, and the value of status will return false after the corresponding coroutine ends. At this time, the producer has not yet ended, so it is true, so the loop continues to execute, and the subsequent process is similar, and the output result is as follows:

send 1
receive 1
send 2
receive 2
send 3
receive 3
End Producer
End Consumer
End Main

Through this example, I hope everyone can have a more specific understanding of coroutines. We see that for coroutines, it includes:

The execution body of the coroutine mainly refers to the control instance of the corresponding function when the coroutine is started
. We can control the call flow of the coroutine through the instance returned when the coroutine is created
. The state of the process will change accordingly
, indicating that the coroutines in the Lua standard library are asymmetric stacked coroutines, but the third party provides the implementation of symmetric coroutines based on the standard library. If you are interested, you can refer to: coro(https:// luapower.com/coro). Interestingly, this is also a good example of how symmetric coroutines can be implemented based on asymmetric coroutines.

2.3 The go routine in Go language
The scheduling of go routine is not as obvious as Lua, and there are no functions like yield and resume.

channel := make(chan int) // … ①
var readChannel <-chan int = channel
var writeChannel chan<- int = channel

// reader
go func() { // … ②
fmt.Println(“wait for read”)
for i := range readChannel { // … ③
fmt.Println(“read”, i)
}
fmt.Println(“read end”)
}() // … ④

// writer
go func() {
for i := 0; i < 3; i++{
fmt.Println(“write”, i)
writeChannel <- i // … ⑤
time.Sleep(time.Second)
}
close(writeChannel)
}()

Let's first briefly introduce how the go routine is started. Add the keyword go before any function call to start a go routine, and call this function in the go routine. For example, at ②, an anonymous function is actually created, and the function is called immediately at ④. We call these two go routines "reader" and "writer" in turn.

① A bidirectional channel is created, which is readable and writable, and then the created readChannel is declared as a read-only type, and the writeChannel is declared as a write-only type. The two are actually the same channel, and since this channel has no buffer, so Write operations are suspended until read operations are performed, and vice versa.

In the reader, the for loop at ③ will read the readChannel. If there is no corresponding write operation at this time, it will hang until there is data written; in the writer, the ⑤ means writing i to the writeChannel , similarly, if there is no corresponding read operation at the time of writing, it will be suspended until data is read. The output of the entire program is as follows:

wait for read
write 0
read 0
write 1
read 1
write 2
read 2
read end

If we have multiple go routines to read and write channels, or multiple channels for multiple go routines to read and write, then the read and write operations at this time are actually equal transfer of scheduling rights between go routines, so it can be considered go routine is a symmetric coroutine implementation.

This example seems to read and write operations on the channel a bit similar to blocking IO operations in two threads, but the go routine is much lighter than the kernel thread of the operating system, and the switching cost is also very low, so the read The cost of hanging during the writing process is also far more than the call switching cost of thread blocking that we are familiar with. In fact, when the two go routines switch, there is a high probability that there will be no thread switching. In order to make the example more illustrative, we add the current thread id to the output, and at the same time write the data after each writeChannel The Sleep operation is removed:

go func() {
fmt.Println(windows.GetCurrentThreadId(), “wait for read”)
for i := range readChannel {
fmt.Println(windows.GetCurrentThreadId(), “read”, i)
}
fmt.Println(windows.GetCurrentThreadId(), “read end”)
}()
go func() {
for i := 0; i < 3; i++{
fmt.Println(windows.GetCurrentThreadId(), “write”, i)
writeChannel <- i
}
close(writeChannel)
}()

The modified running result can see the thread id where the program is outputting:

181808 write 0
183984 wait for read
181808 read 0
181808 write 1
181808 write 2
181808 read 1
181808 read 2
181808 read end

The two go routines occupy two threads except when they start running, and subsequently transfer the scheduling right in one thread (the actual running results of different scenarios may have slight differences, depending on the scheduler of the Go runtime).

Obtaining the thread id This example is debugged on windows, and the thread id is obtained through the GetCurrentThreadId function provided under the windows package of the sys(https://github.com/golang/sys) library. Linux system can get it through syscall.Gettid.

Explain that although we have been using go routine as an example, and call it an implementation of symmetric stack coroutines, considering that the Go runtime itself has done enough capabilities beyond other languages, such as stack optimization, scheduling optimization, etc. , especially the scheduler also supports preemptive scheduling in specific scenarios, which in a sense has gone beyond the scope of the discussion of the concept of coroutines, so many people think that go routines cannot simply be regarded as coroutines.

  1. Summary
    This article discusses the classification of coroutines in detail. No matter how it is classified, the essence of the coroutine is that the program handles the suspension and recovery by itself. Coroutines describe how multiple programs complete execution by transferring the scheduling right to each other. Based on this pair of basic control transfer operations, various asynchronous models are derived, such as concurrency models such as async/await, Channel, etc.

In contrast, some friends complain that Kotlin's coroutines are not as easy to use as async/await in other languages, nor are they as easy to use as go routines. The reason is also very simple. Kotlin's coroutines are supported by a basic suspend keyword The most basic suspend recovery logic, and then encapsulated in the upper layer, derived almost all the models mentioned above, so that we can have the opportunity to use async/await, Channel, and the latest Flow API in Kotlin. There will be more (maybe including the Actor mentioned in the issue to be reworked), it wants to do too many things, and it is indeed doing it step by step.

End of article

Kotlin coroutine learning materials can be obtained for free by scanning the QR code below!

"The most detailed Android version of kotlin coroutine entry advanced combat in history"

Chapter 1 Introduction to the Basics of Kotlin Coroutines

            ● 协程是什么

            ● 什么是Job 、Deferred 、协程作用域

            ● Kotlin协程的基础用法

img

Chapter 2 Preliminary Explanation of Key Knowledge Points of Kotlin Coroutine

            ● 协程调度器

            ● 协程上下文

            ● 协程启动模式

            ● 协程作用域

            ● 挂起函数

img

Chapter 3 Exception Handling of Kotlin Coroutines

            ● 协程异常的产生流程

            ● 协程的异常处理

img

Chapter 4 Basic application of kotlin coroutines in Android

            ● Android使用kotlin协程

            ● 在Activity与Framgent中使用协程

            ● ViewModel中使用协程

            ● 其他环境下使用协程

img

Chapter 5 Network request encapsulation of kotlin coroutine

            ● 协程的常用环境

            ● 协程在网络请求下的封装及使用

            ● 高阶函数方式

            ● 多状态函数返回值方式

Guess you like

Origin blog.csdn.net/Android_XG/article/details/131064897