协程 kotlin
重要要点
- JVM不为协程提供本机支持
- Kotlin通过转换为状态机在编译器中实现协程
- Kotlin使用单个关键字进行实施,其余工作在库中完成
- Kotlin使用连续传递样式(CPS)来实现协程
- 协程使用Dispatchers,因此在JavaFX,Android,Swing等中使用的方式略有不同
协程是一个引人入胜的主题,尽管这并不是一个新话题。 正如其他地方所记录的那样,协同程序多年来已经被多次重新发现,通常是在需要某种形式的轻量级线程和/或“回调地狱”的解决方案时。
最近,在JVM上,协程已成为响应式编程的替代方法。 诸如RxJava或Project Reactor之类的框架为客户端提供了一种增量处理传入信息的方式,并且对节流和并行性提供了广泛的支持。 但是,您必须围绕响应流上的功能操作来重组代码,并且在许多情况下,成本大于收益 。
这就是为什么(例如)Android社区内有对更简单替代方案的需求的原因。 Kotlin语言将协程作为一项实验功能来满足此需求,并且经过一些改进后,它们已成为该语言1.3版的正式功能。 Kotlin协程的采用范围已从UI开发扩展到服务器端框架(例如Spring 5中添加的支持 ),甚至是诸如Arrow( 通过Arrow Fx )之类的功能框架。
了解协程的挑战
不幸的是,了解协程并非易事。 尽管Kotlin专家进行了许多协程对话,但其中许多启发性和启发性是关于协程是什么(或应如何使用)的单一见解。 您可能会说协程是并行编程的单子 。
问题的一部分是基础实现。 在Kotlin协程中,编译器仅实现suspend关键字,其他所有内容都由协程库处理。 结果,Kotlin协程非常强大和灵活,但也有些无定形。 对于新手而言,这是学习障碍,他们可以通过扎实的准则和严格的原则来学习得最好。 本文从下至上介绍了协程的观点,希望以此为基础。
我们的示例应用程序(服务器端)
我们的应用程序将基于安全有效地对RESTful服务进行多次调用的规范问题之上。 我们将播放《 Where's Waldo》的文字版本-用户遵循一系列名称,直到他们到达“ Waldo”。
这是使用Http4k编写的完整的RESTful服务。 Http4k是Marius Eriksen撰写的著名论文中描述的功能服务器体系结构的Kotlin版本。 有许多其他语言实现,包括Scala( Http4s )和Java 8或更高版本( Http4j )。
有一个端点,它通过Map实现名称链。 给定名称,我们将返回匹配值和200状态代码,或者返回404和错误消息。
fun main() {
val names = mapOf(
"Jane" to "Dave",
"Dave" to "Mary",
"Mary" to "Pete",
"Pete" to "Lucy",
"Lucy" to "Waldo"
)
val lookupName = { request: Request ->
val name = request.path("name")
val headers = listOf("Content-Type" to "text/plain")
val result = names[name]
if (result != null) {
Response(OK)
.headers(headers)
.body(result)
} else {
Response(NOT_FOUND)
.headers(headers)
.body("No match for $name")
}
}
routes(
"/wheresWaldo" bind routes(
"/{name:.*}" bind Method.GET to lookupName
)
).asServer(Netty(8080))
.start()
}
本质上,我们希望我们的客户提出以下请求链:
我们的示例应用程序(客户端)
我们的客户端应用程序将基于JavaFX库来创建桌面用户界面。 但是为了简化我们的任务并避免不必要的细节,我们将使用TornadoFX ,它将Kotlin DSL放在JavaFX之上。
这是客户端视图的完整定义:
class HelloWorldView: View("Coroutines Client UI") {
private val finder: HttpWaldoFinder by inject()
private val inputText = SimpleStringProperty("Jane")
private val resultText = SimpleStringProperty("")
override val root = form {
fieldset("Lets Find Waldo") {
field("First Name:") {
textfield().bind(inputText)
button("Search") {
action {
println("Running event handler".addThreadId())
searchForWaldo()
}
}
}
field("Result:") {
label(resultText)
}
}
}
private fun searchForWaldo() {
GlobalScope.launch(Dispatchers.Main) {
println("Doing Coroutines".addThreadId())
val input = inputText.value
val output = finder.wheresWaldo(input)
resultText.value = output
}
}
}
我们还将使用以下辅助函数作为String类型的扩展:
fun String.addThreadId() = "$this on thread ${Thread.currentThread().id}"
这是用户界面在运行时的外观:
当用户单击按钮时,我们将启动一个新的协程,并通过“ HttpWaldoFinder”类型的服务对象访问RESTful端点。
Kotlin协程存在于“ CoroutineScope”中,而“ CoroutineScope”又与表示基础并发模型的某些Dispatcher关联。 并发模型通常是线程池,但是有所不同。
哪些分派器可用取决于运行Kotlin代码的环境。 Main Dispatcher表示UI库的事件处理线程,因此(在JVM上)仅在Android,JavaFX和Swing中可用。 最初,Kotlin Native根本不支持Coroutines多线程, 但是这种情况正在改变 。 在服务器端,您可以自己引入协程,但是默认情况下它们会越来越多, 例如在Spring 5中 。
在开始调用暂停方法之前,我们必须有一个协程,一个“ CoroutineScope”和一个“调度程序”。 如果这是最初的调用(如上面的代码所示),我们可以通过“协程生成器”功能(如“启动”和“异步”)启动该过程。
调用协程构建器函数或诸如“ withContext”之类的作用域函数始终会创建一个新的'CoroutineScope'。 在此范围内,任务由“作业”实例的层次结构表示。
这些具有一些有趣的属性,即:
- 作业等待自己区域中的所有协程完成后再完成自己。
- 取消工作导致其所有子级被取消。
- 孩子的失败或取消会传播到父母。
此设计旨在避免并发编程中的常见问题,例如在不终止其子任务的情况下终止父任务。
访问REST端点的服务
这是我们的HttpWaldoFinder服务的完整代码:
class HttpWaldoFinder : Controller(), WaldoFinder {
override suspend fun wheresWaldo(starterName: String): String {
val firstName = fetchNewName(starterName)
println("Found $firstName name".addThreadId())
val secondName = fetchNewName(firstName)
println("Found $secondName name".addThreadId())
val thirdName = fetchNewName(secondName)
println("Found $thirdName name".addThreadId())
val fourthName = fetchNewName(thirdName)
println("Found $fourthName name".addThreadId())
return fetchNewName(fourthName)
}
private suspend fun fetchNewName(inputName: String): String {
val url = URI("http://localhost:8080/wheresWaldo/$inputName")
val client = HttpClient.newBuilder().build()
val handler = HttpResponse.BodyHandlers.ofString()
val request = HttpRequest.newBuilder().uri(url).build()
return withContext<String>(Dispatchers.IO) {
println("Sending HTTP Request for $inputName".addThreadId())
client
.send(request, handler)
.body()
}
}
}
“ fetchNewName”函数采用一个已知名称,并向端点查询关联的名称。 这是使用“ HttpClient”类型完成的,该类型是Java 11以后的标准配置。 实际的HTTP GET在使用IO Dispatcher的新子协程中运行。 这表示为长期运行的活动(如网络调用)优化的线程池。
“ wheresWaldo”功能遵循名称链五次,以便(希望)找到Waldo。 因为我们将分解生成的字节码,所以使实现尽可能简单。 我们感兴趣的是,每次调用“ fetchNewName”都会导致当前协程在子协程运行时被挂起。 在这种特殊情况下,父级在主Dispatcher上运行,而子级在IO Dispatcher上运行。 因此,当孩子执行HTTP请求时,将释放UI事件处理线程以处理其他用户与视图的交互。 如下所示。
IntelliJ会在我们进行挂起呼叫时向我们显示,从而在协程之间转移控制权。 请注意,如果我们不切换Dispatcher,则进行调用不一定会导致创建新的协程。 当一个挂起函数调用另一个挂起函数时,可以在同一协程中继续执行,实际上,如果我们停留在同一线程上,这就是我们想要的行为。
当我们执行客户端时,这是写入控制台的输出:
我们可以看到,在这种特殊情况下,Main Dispatcher / UI事件处理程序在线程17上运行,而IO Dispatcher在包含线程24和26的池上运行。
开始调查
使用IntelliJ随附的字节码反汇编工具,我们可以窥视实际情况。 请注意,我们还可以使用JDK随附的标准“ javap”工具。
我们可以看到'HttpWaldoFinder'的方法的签名已更改,因此它们接受延续对象作为附加参数,并返回一些常规对象。
public final class HttpWaldoFinder extends Controller implements WaldoFinder {
public Object wheresWaldo(String a, Continuation b)
final synthetic Object fetchNewName(String a, Continuation b)
}
现在,让我们深入研究添加到这些方法中的代码,并解释“继续”是什么以及现在返回什么。
延续通过风格(CPS)
正如针对协同程序的Kotlin标准化流程(也称为KEEP) 提案中所记录的那样,协同程序的实现基于Continuation Passing Style。 连续对象用于存储函数在挂起期间所需的状态。
本质上,您的暂停函数的每个局部变量都将成为延续的字段。 还需要为任何参数和当前对象创建字段(如果函数是方法)。 因此,具有四个参数和五个局部变量的挂起方法将具有至少十个字段的延续。
对于“ HttpWaldoFinder”中的“ wheresWaldo”方法,只有一个参数和四个局部变量,因此我们希望延续实现类型具有六个字段。 如果我们将Kotlin编译器发出的字节码反汇编为Java源代码,我们可以看到确实如此:
$continuation = new ContinuationImpl($completion) {
Object result;
int label;
Object L$0;
Object L$1;
Object L$2;
Object L$3;
Object L$4;
Object L$5;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return HttpWaldoFinder.this.wheresWaldo((String)null, this);
}
};
由于所有字段均为对象类型,因此如何立即使用它们并不是很明显。 但是随着我们进一步冒险,我们将看到:
- “ L $ 0”保存对“ HttpWaldoFinder”实例的引用。 这始终存在。
- 'L $ 1'保存'starterName'参数的值。 这始终存在。
- “ L $ 2”至“ L $ 5”保存局部变量的值。 这些将在代码执行时逐步填充。 “ L $ 2”将保留“ firstName”的值,依此类推。
我们还有其他字段用于最终结果和一个有趣的整数,称为“标签”。
暂停还是不暂停-这就是问题所在
在检查生成的代码时,我们需要记住,它必须处理两个用例。 每当挂起函数调用另一个挂起函数时,它要么挂起当前协程(以便另一个可以在同一线程上运行),要么继续执行当前协程。
考虑一个挂起函数,该函数从数据存储中读取一个值。 它很可能会在发生I / O时挂起,但也可能会缓存结果。 后续调用可以同步返回缓存的值,而不会暂停。 Kotlin编译器生成的代码必须允许两个路径。
Kotlin编译器会调整每个挂起函数的返回类型,以便它可以返回实际结果或特殊值COROUTINE_SUSPENDED。 在后一种情况下,当前的协程被暂停。 这就是为什么将挂起函数的返回类型从结果类型更改为“对象”的原因。
在我们的示例应用程序中,“ wheresWaldo”将重复调用“ fetchNewName”。 从理论上讲,这些调用中的每一个都可以挂起或不挂起当前的协程。 自从我们写了“ fetchNewName”以来,我们知道暂停总是会发生。 但是,为了理解所生成的代码,我们必须记住,它需要能够处理所有可能性。
大开关声明和标签
如果进一步查看反汇编的代码,我们会发现埋在多个嵌套标签中的switch语句。 这是状态机的实现,用于控制wheresWaldo()方法中的不同悬浮点。 这是高层结构:
// listing one: the generated switch statement and labels
String firstName;
String secondName;
String thirdName;
String fourthName;
Object var11;
Object var10000;
label48: {
label47: {
label46: {
Object $result = $continuation.result;
var11 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch($continuation.label) {
case 0:
// code omitted
case 1:
// code omitted
case 2:
// code omitted
case 3:
// code omitted
case 4:
// code omitted
case 5:
// code omitted
default:
throw new IllegalStateException(
"call to 'resume' before 'invoke' with coroutine");
} // end of switch
// code omitted
} // end of label 46
// code omitted
} // end of label 47
// code omitted
} // end of label 48
// code omitted
现在,我们可以在续篇中看到“标签”字段的用途。 当我们完成“ wheresWaldo”的不同阶段时,我们将更改“ label”中的值。 嵌套的标签块包含原始Kotlin代码中悬浮点之间的代码块。 此“标签”值允许重新输入该代码,跳至上次暂停的位置(适当的case语句),以从延续中提取一些数据,然后中断至正确的带标签的块。
但是,如果我们的所有挂起点均未真正挂起,则可以同步执行整个块。 在生成的代码中,我们经常发现以下片段:
// listing two - deciding if the current coroutine should suspend
if (var10000 == var11) {
return var11;
}
正如我们在上面看到的,“ var11”已设置为CONTINUATION_SUSPENDED值,而“ var10000”保存了从调用到另一个挂起函数的返回值。 因此,当发生挂起时,代码将返回(稍后再输入),如果未发生挂起,则代码将中断到适当的带标签的块,从而继续执行该函数的下一部分。
再一次,请记住,生成的代码不能假定所有调用都将挂起,或者所有调用将继续使用当前协程。 它必须能够应付任何可能的组合。
追踪执行
当我们开始执行时,连续中的'label'值将设置为零。 这是switch语句的相应分支:
// listing three - the first branch of the switch
case 0:
ResultKt.throwOnFailure($result);
$continuation.L$0 = this;
$continuation.L$1 = starterName;
$continuation.label = 1;
var10000 = this.fetchNewName(starterName, $continuation);
if (var10000 == var11) {
return var11;
}
break;
我们将实例和参数存储到延续对象中,然后将延续对象传递给“ fetchNewName”。 如前所述,编译器生成的“ fetchNewName”版本将返回实际结果或COROUTINE_SUSPENDED值。
如果协程被挂起,那么我们从函数中返回,并且当我们继续时跳转到'case 1'分支。 如果我们继续使用当前的协程,那么我们将跳出标记框之一的开关,转到以下代码:
// listing four - calling ‘fetchNewName’ for the second time
firstName = (String)var10000;
secondName = UtilsKt.addThreadId("Found " + firstName + " name");
boolean var13 = false;
System.out.println(secondName);
$continuation.L$0 = this;
$continuation.L$1 = starterName;
$continuation.L$2 = firstName;
$continuation.label = 2;
var10000 = this.fetchNewName(firstName, $continuation);
if (var10000 == var11) {
return var11;
}
因为我们知道'var10000'包含了我们想要的返回值,所以我们可以将其转换为正确的类型并将其存储在本地变量'firstName'中。 然后,生成的代码使用变量'secondName'存储连接线程ID的结果,然后将其打印出来。
我们将更新延续中的字段,并添加从服务器中检索到的值。 请注意,“ label”的值现在为2。然后,我们第三次调用“ fetchNewName”。
第三次调用“ fetchNewName”-不暂停
再次,我们必须基于'fetchNewName'返回的值进行选择,如果返回的值为COROUTINE_SUSPENDED,则从当前函数返回。 下次调用时,我们将遵循开关的“案例2”分支。
如果我们继续当前的协程,则执行下面的代码块。 如您所见,它与上面的相同,除了现在我们有更多的数据要存储在延续中。
// listing four - calling ‘fetchNewName’ for the third time
secondName = (String)var10000;
thirdName = UtilsKt.addThreadId("Found " + secondName + " name");
boolean var14 = false;
System.out.println(thirdName);
$continuation.L$0 = this;
$continuation.L$1 = starterName;
$continuation.L$2 = firstName;
$continuation.L$3 = secondName;
$continuation.label = 3;
var10000 = this.fetchNewName(secondName, (Continuation)$continuation);
if (var10000 == var11) {
return var11;
}
对于所有剩余的调用(假设COROUTINE_SUSPENDED永不返回)重复此模式,直到到达结尾为止。
第三次调用“ fetchNewName”-暂停
或者,如果协程已被暂停,那么这是我们将要运行的案例块:
// listing five - the third branch of the switch
case 2:
firstName = (String)$continuation.L$2;
starterName = (String)$continuation.L$1;
this = (HttpWaldoFinder)$continuation.L$0;
ResultKt.throwOnFailure($result);
var10000 = $result;
break label46;
我们从延续中提取值到函数的局部变量中。 然后使用标记的中断将执行跳转到上面的清单4。 因此,最终我们将在同一个地方结束。
总结执行
现在,我们可以重新访问代码结构清单,并对每个部分中发生的事情进行高级描述:
// listing six - the generated switch statement and labels in depth
String firstName;
String secondName;
String thirdName;
String fourthName;
Object var11;
Object var10000;
label48: {
label47: {
label46: {
Object $result = $continuation.result;
var11 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch($continuation.label) {
case 0:
// set label to 1 and make the first call to ‘fetchNewName’
// if suspending return, otherwise break from the switch
case 1:
// extract the parameter from the continuation
// break from the switch
case 2:
// extract the parameter and first result from the continuation
// break to outside ‘label46’
case 3:
// extract the parameter, first and second results from the
// continuation
// break to outside ‘label47’
case 4:
// extract the parameter, first, second and third results from
// the continuation
// break to outside ‘label48’
case 5:
// extract the parameter, first, second, third and fourth
// results from the continuation
// return the final result
default:
throw new IllegalStateException(
"call to 'resume' before 'invoke' with coroutine");
} // end of switch
// store the parameter and first result in the continuation
// set the label to 2 and make the second call to ‘fetchNewName’
// if suspending return, otherwise proceed
} // end of label 46
// store the parameter, first and second results in the
// continuation
// set the label to 3 and make the third call to ‘fetchNewName’
// if suspending return, otherwise proceed
} // end of label 47
// store the parameter, first, second and third results in the
// continuation
// set the label to 4 and make the fourth call to ‘fetchNewName’
// if suspending return, otherwise proceed
} // end of label 48
// store the parameter, first, second, third and fourth results in the continuation
// set the label to 5 and make the fifth call to ‘fetchNewName’
// return either the final result or COROUTINE_SUSPENDED
结论
这不是一个容易理解的代码库。 我们正在研究从Kotlin编译器中的代码生成器生成的字节码反汇编的Java代码。 此代码生成器的输出旨在提高效率和简化程度,而非清晰度。
但是,我们可以得出一些有用的结论:
- 没有魔术 。 当开发人员第一次了解协程时,很容易假设存在一些特殊的“魔术”将所有东西捆绑在一起。 如我们所见,生成的代码仅使用过程编程的基本构建块,例如条件和标记的中断。
- 实现是基于延续的 。 如原始KEEP提案中所述,通过在对象内缓存功能的状态来暂停和恢复功能。 因此,对于每个挂起函数,编译器将创建一个具有N个字段的延续类型,其中N是参数数量加上字段数量加上3。 这最后三个保存当前对象,最终结果和索引。
- 执行始终遵循标准模式 。 如果要从暂停中恢复,则可以使用延续的'label'字段跳转到switch语句的相应分支。 在此分支中,我们检索到到延续对象为止的数据,然后使用标记的中断跳转到如果没有发生暂停将直接执行的代码。
关于作者
Garth Gilmour是Instil的学习主管。 他在1999年放弃了全职开发工作,先是向C编码员讲C ++,然后是Java到C ++编码员,然后是C#到Java编码器,现在教了所有人所有的知识,但更喜欢在Kotlin工作。 如果他算出交货的话,那一定会超过1000年前。 他是20多门课程的作者,经常在聚会上演讲,在国家和国际会议上发表演讲,并共同组织了贝尔法斯特BASH系列开发人员活动和最近成立的Kotlin贝尔法斯特用户组。 不在白板上时,他执教Krav Maga并举重。
Eamonn Boyle从事开发人员,建筑师和团队领导超过15年。 在过去的大约四年中,他一直是一名全职培训师和教练,为众多代表撰写并提供有关各种主题的课程。 这些包括从核心语言技能,框架到工具和过程的范式和技术。 他还在包括Goto和KotlinConf在内的许多会议,活动和聚会中演讲并举办了研讨会。
协程 kotlin