【AICon】AI 基础设施、LLM运维、大模型训练与推理,一场会议,全方位涵盖! >>> 了解详情
写点什么

深入浅出 Kotlin 协程

  • 2020-02-03
  • 本文字数:9848 字

    阅读完需:约 32 分钟

深入浅出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-02-03 12:535635

评论

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

Linux系统DolphinScheduler3.1.5安装部署教程。

百度搜索:蓝易云

云计算 Linux 运维 服务器 DolphinScheduler

Debian11系统编译安装MySQL5.7教程。

百度搜索:蓝易云

云计算 Linux 运维 Debian MySQL 5.7

华为云ROMA Connect 的智能集成 – 现代企业数字化转型的新利器

华为云PaaS服务小智

云计算 华为云 华为开发者大会

华为云CodeArts Check IDE插件体验之旅

华为云PaaS服务小智

云计算 软件开发 华为云 华为开发者大会2023 代码检查

什么是“软件定义汽车”

DevOps和数字孪生

软件定义汽车 汽车仿真

联通 Flink 实时计算平台化运维实践

Apache Flink

大数据 flink 实时计算

新一代iPaaS全域融合集成平台ROMA Connect HDC.Cloud 2023内容值得再读!

华为云PaaS服务小智

华为 华为云 华为开发者大会2023

红队攻防之JS攻防

权说安全

网络攻防

测试工程师如何做到初级测试管理(个人思考)?

团队管理 测试 测试管理 测试部门职责

区块链第一代系统——比特币概念及业务流程

TiAmo

比特币 区块链

虚拟ECU:助力汽车故障诊断

DevOps和数字孪生

软件定义汽车 虚拟ECU

当代数据库与数据管理技术的先驱者之一 Mohan 教授指导 IoTDB 时序数据库 Timecho 研发团队

Apache IoTDB

IoTDB Apache IoTDB

JMeter笔记14 | JMeter场景设计和设置

单元测试 Jmeter 性能测试 自动化测试 接口测试

河南理工大学高校专区入驻飞桨AI Studio,优质教育资源等你来学!

飞桨PaddlePaddle

人工智能 百度 paddle 飞桨 百度飞桨

在 Go 中如何编写测试代码

江湖十年

golang 测试 后端 单元测试 go语言

“数字孪生”:为什么要仿真嵌入式系统?

DevOps和数字孪生

数字孪生 嵌入式系统仿真

Flink 在新能源场站运维的应用

Apache Flink

大数据 flink 实时计算

每日站会如此简单,为什么总是开不好?

敏捷开发

项目管理 Scrum 敏捷开发 每日站会

关于 Elasticsearch 不同分片设置的压测报告

极限实验室

索引 压测 ES

Cnetos7编译安装Tomcat教程。

百度搜索:蓝易云

云计算 tomcat Linux centos 运维

Cnetos7编译安装Pure-Ftpd教程。

百度搜索:蓝易云

云计算 Linux centos 运维 Pure-FTPd

来自内部有很多需求,如何协调处理这些需求?

Bonaparte

产品 产品设计 产品思维 产品需求 内部需求

CodeArts Check系统规则集还不够?带你体验如何创建、启用自定义规则集

华为云PaaS服务小智

云计算 开发者 代码质量 华为云 代码检查

阿里云服务器安装宝塔面板教程。

百度搜索:蓝易云

云计算 Linux 运维 云服务器 ECS

享受云原生技术红利,大数据不应该被落下

智领云科技

云原生 Kubernetes 集群 云原生大数据平台 智领云

MobPush:Android客户端SDK厂商通道回执配置指南

MobTech袤博科技

程序员 前端 sdk 客户端开发 Andrdoid

虚拟ECU实践:汽车发动机控制器仿真

DevOps和数字孪生

软件定义汽车 虚拟ECU

少年侠客【InsCode Stable Diffusion美图活动一期】 | 社区征文

度假的小鱼

Stable Diffusion 年中技术盘点

大佬带你体验华为云代码检查服务CodeArts Check

华为云PaaS服务小智

云计算 开发者 软件开发 华为云

JMeter笔记15 | JMeter场景运行

单元测试 Jmeter 性能测试 自动化测试 接口测试

Python如何获取页面上某个元素指定区域的html源码?

Python 源码 HTML5, CSS3

深入浅出Kotlin协程_移动_Eamonn Boyle_InfoQ精选文章