阿里P10 关涛(观滔)邀你探讨新一代数据平台的架构迭代方向,戳此了解。 了解详情
写点什么

深入浅出 Kotlin 协程

2020 年 2 月 03 日

深入浅出Kotlin协程

本文要点


  • JVM 并没有提供对协程的原生支持

  • Kotlin 在编译器中实现协程是通过将其转换为一个状态机实现的

  • Kotlin 为实现使用了一个关键字,其余都是通过库来完成的

  • Kotlin 使用连续传递风格(Continuation Passing Style,CPS)来实现协程

  • 协程会使用到 Dispatcher,所以在 JavaFX、Android、Swing 等场景下,用法略有差异。


尽管协程并不是一个新的话题,但它是一个很吸引人的话题。正如其他地方的文档所述,协程在这些年里被重新发现了很多次,特别是当需要某种形式的轻量级线程和/或寻找“回调地狱”的解决方案时。


最近,协程已经成为 JVM 上反应式编程的一个流行的替代方案。像RxJavaReactor项目这样的框架为客户端提供了一种渐进式处理传入信息的方式,并对节流和并行提供了广泛的支持。但是,我们必须围绕反应流的函数式操作来重构代码,在许多情况下,这么做的成本大于收益


举例来讲,这是 Android 社区需要更简单替代方案的原因。Kotlin 语言引入协程作为实验性的特性以满足该需求,在一些改进之后,它们成为该语言 1.3 版本的官方特性。Kotlin 协程的应用已经从 UI 开发扩展到服务器端框架(如Spring 5中添加的支持),甚至包括像 Arrow(通过Arrow Fx)这样的函数式框架。


理解协程的挑战

令人遗憾的是,理解协程并不是一件容易的任务。尽管有许多 Kotlin 专家所做的关于协程的演讲,其中很多都是启发性的并且包含非常多的信息,但是想要简单地知道协程是什么(或者它该怎样使用)并不容易。你可能会说协程就是并行编程的等价品。


导致该问题的部分原因在于底层实现。在 Kotlin 协程中,编译器只实现了一个 suspend 关键字,其他的事情都是由协程库处理的。因此,Kotlin 协程非常强大和灵活,但在结构上却不那么固定。对于初学者来说,这是一个学习的障碍,因为他们学习的最好方法是遵循坚实的指导方针和严格的原则。本文会从底层开始介绍协程,希望能够为读者提供这样的基础知识。


我们的示例应用(服务器端)

我们的应用程序将建立在安全有效地对 RESTful 服务进行多次调用的规范性(canonical)问题之上。我们将播放Where’s Waldo的一个文本版本——在这个版本中,用户遵循一系列的名称,直到他们找到“Waldo”。


下面是完整的 RESTful 服务,它是使用Http4k编写的。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,它在 JavaFX 之上提供了一个 Kotlin DSL。


如下是客户端视角的完整定义:


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       }   }}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}"
复制代码


在运行的时候,UI 如下所示:



当用户点击按钮的时候,我们会创建一个新的协程,并通过“HttpWaldoFinder”类型的服务对象访问 RESTful 端点。


Kotlin 协程存在于一个“CoroutineScope”中,而后者又会反过来和某种 Dispatcher 关联,Dispatcher 代表了底层的并发模型。并发模型通常是一个线程池,但是会有所差异。


具体哪些 Dispatcher 可用取决于 Kotlin 代码运行的环境。Main Dispatcher 表示 UI 库的事件处理线程,因此(在 JVM 上)只能在 Android、JavaFX 和 Swing 中使用。最初,Kotlin Native 根本不支持协程的多线程处理,但这种情况正在改变。在服务器端,我们可以自己引入协程,但是在越来越多的情况中,协程默认就是可用的,比如在Spring 5中。


在调用挂起(suspending)方法之前,我们必须将协程、“CoroutineScope”和“Dispatche”准备就绪。如果这是初始调用的话(如上面的代码所示),我们可以通过“协程构造者”函数,如“launch”和“async”,启动该过程。


不管是调用协程构建者函数,还是像“withContext”这样的作用域函数,都会创建一个新的“CoroutineScope”。在该作用域中,任务体现为“Job”实例的层级结构。


它们有一些很有意思的属性,即:


  • Job 会等待其块内所有协程完成后才能完成自己。

  • 取消一个 Job 会导致其所有的子 Job 都被取消。

  • 子 Job 的失败或取消会传播至父 Job。


这样设计的目的是避免并发编程中的常见问题,比如杀死父 Job 的时候没有终结其子 Job。


访问 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”都会导致当前协程在子协程运行时被挂起。在这个特殊场景下,父 Job 运行在 Main Dispatcher 上,而子 Job 运行在 IO Dispatcher 上。因此,当子 Job 执行 HTTP 请求时,UI 事件处理线程会被释放出来处理与视图的用户交互。如下图所示。



IntelliJ 会为我们展示何时发起挂起调用,因此会在协程间转换控制。需要注意,如果我们没有切换 Dispatcher 的话,那发起调用不一定会创建新的协程。当一个挂起函数调用另一个挂起函数时,可能会在相同的协程中继续,如果我们真的希望保持在同一个线程上,那么这的确就是我们想要的行为。



当我们执行客户端的时候,如下就是控制台的输出:



我们可以看到,在这个特殊的场景下,Main Dispatcher/UI 事件处理器运行在 17 号线程上,而 IO Dispatcher 运行在线程池上,包括 24 号和 26 号线程。


开始我们的调查

借助 IntelliJ 自带的字节码分解工具,我们可以看出到底发生了什么。另外,我们也可以使用 JDK 提供的标准“javap”工具。



我们可以看到“HttpWaldoFinder”的方法改变了其签名,所以它们可以接受一个 Continuation 对象作为其额外的参数,并且返回某种通用的对象。


public final class HttpWaldoFinder extends Controller implements WaldoFinder {  public Object wheresWaldo(String a, Continuation b)  final synthetic Object fetchNewName(String a, Continuation b)}
复制代码


现在,我们深入代码看一下这些方法都添加了些什么,并阐述“Continuation”是什么以及现在返回的是什么。


连续传递风格(Continuation Passing Style,CPS)

按照 Kotlin 标准化过程对协程提议的文档描述,协程的实现是基于连续传递风格(Continuation Passing Style,CPS)的。会有一个 continuation 对象来存储函数在挂起阶段所需的状态。


在本质上,挂起函数的每个局部变量都会成为 continuation 的一个字段。另外,还需要创建字段来存储所有的参数和当前对象(如果函数是方法的话)。所以,一个挂起方法如果有四个参数和五个局部变量的话,continuation 至少要有 10 个字段。


在“HttpWaldoFinder”的“wheresWaldo”方法中,有一个参数和四个本地变量,所以我们预期 continuation 实现类型会有六个字段。如果我们将 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);  }};
复制代码


鉴于所有的字段都是 Object 类型的,所以它们该如何使用并不明显。随着进一步探索,我们将会看到:


  • “L$0”持有对“HttpWaldoFinder”实例的引用。它始终都会存在。

  • “L$1”持有“starterName”参数的值。它始终都会存在。

  • “L5”持有本地变量的值。随着代码的执行,它们将会渐进式地填充进来。“L$2”会持有“firstName”的值,以此类推。


我们还有额外的字段存储最终结果,有一个名为“label”的有趣的整型字段。


挂起还是不挂起——这是一个问题

当我们检查生成的代码时,需要记住它要处理两个场景。每当一个挂起的函数调用另一个函数时,它可能会挂起当前的协程(这样另外一个函数可以在相同的线程上运行),也可能在当前协程会继续执行。


我们考虑一个从数据存储中读取值的挂起函数。当 I/O 发生的时候,它很可能会被挂起,但是它也可能会缓存结果。后续调用可以同步返回缓存的值,不需要任何的挂起。Kotlin 编译器所生成的代码必须要同时支持这两种路径。


Kotlin 会调整每个挂起函数的返回类型,这样的话,它要么返回真正的结果,要么返回特殊的值 COROUTINE_SUSPENDED。如果是后者的话,当前的协程会被挂起。这也是为什么挂起函数的返回类型从结果类型变成了“Object”。


在我们的样例应用中,“wheresWaldo”会重复调用“fetchNewName”。在理论上,每次这样的调用都可能挂起或不挂起当前的协程。按照我们编写“fetchNewName”的方式,可以知道,挂起始终都会发生。但是,为了理解生成的代码,我们必须记住它需要处理所有的可能性。


大的 Switch 语句和 Label

如果我们进一步查看分解后的代码,会发现一个隐藏在多个嵌套 label 中的 switch 语句。这是一个状态机的实现,用来在 wheresWaldo()方法中控制不同的挂起点。下面是整体的结构:


// 程序清单1:生成的switch语句和labelString 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:            // 省略代码        case 1:            // 省略代码        case 2:            // 省略代码        case 3:            // 省略代码        case 4:            // 省略代码        case 5:            // 省略代码        default:           throw new IllegalStateException(                "call to 'resume' before 'invoke' with coroutine");        } // 结束 switch        // 省略代码    } // 结束 label 46    // 省略代码  } // 结束 label 47  // 省略代码} // 结束 label 48// 省略代码
复制代码


我们现在可以看到 continuation 中“label”字段的目的了。当完成“wheresWaldo”的不同阶段时,我们将会变更“label”中的值。嵌套的 label 代码块中包含了原始 Kotlin 代码里面挂起点之间的代码块。这个“label”值允许重新进入该代码,跳到最后挂起的地方(适当的 case 语句),以便于从 continuation 中抽取数据,然后跳转到正确的 label 代码块。


但是,如果所有的挂起点都没有真正挂起的话,整个代码块可以同步执行。在生成的代码中,我们经常会看到这样的片段:


// 程序清单2:确定当前的协程是否应该挂起if (var10000 == var11) {  return var11;}
复制代码


从上面我们可以看到,“var11”被设置成了 CONTINUATION_SUSPENDED 的值,而“var10000”保存了对另一个挂起函数的调用的返回值。因此,当挂起发生时,代码将返回(稍后会重新进入),如果没有发生挂起,则代码将通过切换到适当的 label 块继续执行函数的下一部分。


再次强调,请记住,生成的代码不能假设所有调用都将挂起,或者所有调用都将继续使用当前的协程。它必须能够处理任何可能的组合。


跟踪执行

当我们开始执行时,continuation 中“label”的值将会被置为零。如下是对应的 switch 语句分支:


// 程序清单3: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;
复制代码


我们将实例和参数存储到了 continuation 对象中,然后将 continuation 对象传递给“fetchNewName”。如前文所述,编译器所生成的“fetchNewName”版本要么返回实际的结果,要么返回 COROUTINE_SUSPENDED 值。


如果协程挂起的话,那么我们会从函数中返回,并且当我们恢复的时候,会跳入“case 1”分支。如果我们继续使用当前的协程,那么会跳出 switch 到某一个 label 代码块,进入如下的代码:


// 程序清单4:第二次调用“fetchNewName”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 连接的结果,并且将其打印了出来。


我们更新了 continuation 中的字段,添加了服务器检索到的值。注意,“label”的值现在已经是 2 了。随后,我们第三次调用“fetchNewName”。


第三次调用“fetchNewName”——无挂起

我们需要再次基于“fetchNewName”返回的值做出选择,如果返回的值是 COROUTINE_SUSPENDED 的话,我们会从当前函数返回。当下一次调用时,我们要遵循 switch 的“case 2”分支。


如果我们继续使用当前协程的话,那么将会执行如下的代码块。我们可以看到它与上面的代码是相同的,只不过我们现在要有更多的数据存储到 continuation 中。


// 程序清单4:第三次调用“fetchNewName”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”——带有挂起

或者,如果协程已经被挂起的话,那么将会运行如下的代码块:


// 程序清单5: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;
复制代码


我们将 continuation 中的值抽取到函数的局部变量中。随后使用一个 label 形式的 break 使执行跳转至前述的程序清单 4 中。所以最终,我们会在相同的地方结束。


总结执行过程

现在,我们可以重新看一下程序清单的代码结构,并在整体上描述一下每个区域中都发生了什么:


// 程序清单6:深度解析生成的switch语句和labelString 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:            // 将label设置为1,如果返回挂起的话,第一次调用“fetchNewName”            // 否则,从switch中break退出        case 1:            // 从continuation中抽取参数            // 从switch中break退出        case 2:            // 从continuation中抽取参数和第一个结果            // 跳转到外边的“label46”        case 3:            // 从continuation中抽取参数,第一个和第二个结果            // 跳转到外边的“label47”        case 4:            // 从continuation中抽取参数,第一个、第二个和第三个结果            // 跳转到外边的“label48”        case 5:            // 从continuation中抽取参数,第一个、第二个、第三个和第四个结果            // 返回最后的结果        default:           throw new IllegalStateException(                "call to 'resume' before 'invoke' with coroutine");        } // 结束 switch        // 存储参数和第一个结果到continuation中        // 如果返回挂起的话,将label设置为2并对“fetchNewName”进行第二次调用        // 否则的话继续进行    } // 结束label 46        // 存储参数、第一个结果和第二个结果到continuation中        // 如果返回挂起的话,将label设置为3并对“fetchNewName”进行第三次调用        // 否则的话继续进行  } // 结束label 47        //  存储参数、第一个结果、第二个结果和第三个结果到continuation中        //  如果返回挂起的话,将label设置为4并对“fetchNewName”进行第四次调用        // 否则的话继续进行} // 结束label 48// 存储参数、第一个结果、第二个结果、第三个结果和第四个结果到continuation中// 将label设置为5并对“fetchNewName”进行第五次调用// 返回最终结果或COROUTINE_SUSPENDED
复制代码


结果

这个代码库理解起来并不简单。我们分析了字节码分解得到的 Java 代码,这些字节码是由 Kotlin 编译器中的代码生成器所产生的。这个代码生成器的输出在设计时考虑的是效率和最小化,而不是可理解性。


但是,我们可以得出一些有用的结论:


  1. 并没有什么魔法。当开发人员第一次开始学习协程时,很容易会认为有些特殊的“魔法”将所有的这些事情连接在了一起。我们可以看到,生成的代码只使用了一些过程式编程的基本构建块,比如条件语句和带有 label 的 break。

  2. 实现是基于 continuation 的。在最初的 KEEP 提议中,函数挂起和恢复的方法是将函数的状态缓存在一个对象中。因此,对于每个挂起函数,编译器将创建一个包含 N 个字段的 continuation 类型,其中 N 是参数的数量加上字段数再加上 3。最后的三个字段保存当前对象、最终结果和索引。

  3. 执行始终遵循一个标准的模式。如果从挂起中恢复,那么我们要使用 continuation 的“label”字段跳转到 switch 语句的适当分支。在这个分支中,我们从 continuation 对象检索到目前为止已经找到的数据,然后使用一个带有 label 的 break 跳转到没有发生挂起的代码,这些代码本来是要直接执行的。


作者简介


Garth Gilmour是 Instil 的学习主管(Head of Learning)。早在 1999 年,他就放弃了全职的开发工作,开始讲授 C++给 C 程序员,然后讲授 Java 给 C++程序员,然后是讲授 C#给 Java 程序员,现在他什么都教,但更喜欢 Kotlin 相关的工作。如果计算学员数量的话,很久之前就超过 1000 人了。他是 20 多门课程的作者,经常在技术会议上发言,在国内和国际会议上演讲,共同组织了贝尔法斯特 BASH 的开发人员系列活动,最近成立了贝尔法斯特 Kotlin 用户组。不在白板之前的时候,他还担任近身格斗和举重的教练。


Eamonn Boyle有超过 15 年的开发、架构和团队领导经验。在过去的 4 年里,他一直担任全职培训师和教练,为各种各样的客户撰写和讲授各种主题的课程。其中包括范式和技术,从核心语言技能、框架到工具和过程。他还在一系列会议、活动和技术聚会上发表演讲并举办 workshop,其中包括 Goto 和 KotlinConf。


原文链接:


A Bottom-Up View of Kotlin Coroutines


2020 年 2 月 03 日 12:533265

评论

发布
暂无评论
发现更多内容

OSPF的八大特点介绍

日语复习 Day03【~あまり(に)】

IT蜗壳-Tango

七日更 日语语法 程序员日语

使用DevSecOps保护CI / CD管道

啸天

DevSecOps 应用安全 开发安全

区块链交易所系统开发|区块链交易所软件APP开发

开發I852946OIIO

系统开发

JVM内存模型总结和上手实践,亲测学完没脱发

互联网架构师小马

非科班生的逆袭:四面斩获字节跳动offer,聊聊我的个人历程

互联网架构师小马

Java 面试 算法 操作系统 计算机组成原理

我做了回视频,告诉你需要用到哪些工具

和牛

工具

创业失败启示录|校园微生活之校园女神

青城

28天写作 创业失败启示录

Redis布隆过滤器原理与实践

云流

Java redis 面试

RocketMQ解析

石刻掌纹

Lambda 和 Stream API

学个球

Lambda Java 8 Stream<T>

android开发要学什么语言!掌握这些Android开发热门前沿知识,挥泪整理面经

欢喜学安卓

android 程序员 面试 移动开发

如何实现CentOS服务器的扩容??

冰河

Linux centos 扩容 服务器

C2C交易系统APP开发|C2C交易软件开发

开發I852946OIIO

系统开发

python自学 第四章 python语言基础之变量

WEB前端修行日志

Python 编码格式

一款dubbo服务可视化调试工具

程序员架构进阶

dubbo 工具 RPC 服务化 28天写作

RocketMQ源码分析之消息拉取流程

互联网架构师小马

python自学 第三章 python语言基础之保留字、标识符与内置函数

WEB前端修行日志

Python 编码格式

现成矿机挖矿软件系统APP开发案例

开發I852946OIIO

系统开发

基因编辑食品,能否端上我们的餐桌?

脑极体

android进阶之光!双非渣本Android四年磨一剑,进阶学习资料!

欢喜学安卓

android 程序员 面试 移动开发

阿里P8大神分享的并发编程笔记,颠覆了我以往“正确“的认知

云流

Java 程序员 面试 并发编程

产品经理训练营笔记-认识产品经理(上)

.nil?

Android面试总结(一)

我就感觉到快

「架构师训练营 4 期」 第三周 - 002

凯迪

一篇让你彻底理解网关是什么的文章

Java架构师迁哥

智能合约业务场景探索(一)

石君

智能合约 28天写作

写在开课前

5x

「架构师训练营 4 期」 第三周 - 001

凯迪

游戏夜读 | 游戏作品的生命力

game1night

Windows文件夹还能更改颜色?

程序员的时光

程序员 七日更 28天写作

深入浅出Kotlin协程-InfoQ