Kotlin(七)—— 异步程序设计

开发人员始终面临着需要解决的问题——如何防止应用程序被阻塞。 开发桌面应用,移动应用,甚至服务端应用程序时,希望避免让用户等待或阻碍应用程序扩展。

以下会介绍实现异步编程的不同方式 ,包括:

  • 线程
  • 回调
  • Futures,Promises 等等
  • 响应式扩展
  • 协程

1 线程

当我们需要执行一些耗时操作,比如发起一条网络请求时,考虑到网速等其他原因,服务器未必能够立刻响应我们的请求,如果不将这类操作放在子线程里运行,就会导致主线程被阻塞,从而影响用户对软件的正常使用。

1.1 线程的基本用法

Android 的多线程编程与 Java 中的多线程编程基本是使用相同的语法。定义一 个线程只需要新建一个类继承自 Thread,然后重写父类的 run() 方法,并在里面编写耗时逻辑即可:

class MyThread : Thread() {
    override fun run() {
        // 编写具体的逻辑
    }
}

使用的时候只需要创建 MyThread 的实例,然后调用它的 start() 方法即可,这样 run() 方法中的代码就会在子线程当中运行了:

MyThread().start()

使用继承的方式耦合性有点高,也可以用实现 Runnable 接口的方式来定义一个线程:

class MyThread : Runnable {
    override fun run() {
        // 编写具体的逻辑
    }
}

如果使用了这种写法,启动线程的方法也需要进行相应的改变,如下所示:

val myThread = MyThread()
Thread(myThread).start()

Thread 的构造函数接收一个 Runnable 参数,而 MyThread 正是一个实现了 Runnable 接口的对象,所以可以直接将它传入 Thread 的构造函数里。接着调用 Thread.start() 方法,run() 方法中的代码就会在子线程当中运行了。

如果不想专门再定义一个类去实现 Runnable 接口,也可以使用 Lambda 的方式,这种写法更为常见:

fun main() {
    Thread {
        // 编写具体的逻辑
    }.start()
}

在 Kotlin 中提供了一种更加简单的开启线程的方式,写法如下:

fun main() {
    thread {
        // 编写具体的逻辑
    }
}

thread 是一个 Kotlin 内置的顶层函数,只需要在 Lambda 表达式中编写具体的逻辑就可以了,不用调用 start() 方法,thread 函数在其内部已经全部都处理好了。

1.2 在主线程中更新 UI

Android 的 UI 是线程不安全的。也就是说,如果想要更新应用程序里的 UI 元素,必须在主线程中进行,否则就会出现异常。

新建一个项目,在布局文件中定义了两个控件:TextView 用于在屏幕的正中央显示一个 Hello world 字符串;Button 用于改变 TextView 中显示的内容,我们希望在点击 Button 后可以把 TextView 中显示的字符串改成 Nice to meet you。

接下来在 MainActivity 中编写以下代码:

class MainActivity : BaseActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        changeTextBtn.setOnClickListener {
            thread {
                textView.text = "Nice to meet you"
            }
        }
    }
}

可以看到,在按钮的点击事件里面开启了一个子线程,然后在子线程中调用 TextView 的 setText() 方法将显示的字符串改成 Nice to meet you。运行,发现程序崩溃了。

由此证实了 Android 确实是不允许在子线程中进行更新 UI 的。但是有些时候,我们必须在子线程里执行一些耗时任务,然后根据任务的执行结果来更新相应的 UI 控件,这该如何是好呢? 对于这种情况,Android 提供了一套异步消息处理机制,完美地解决了在子线程中进行 UI 操作的问题。

修改 MainActivity 中的代码,如下所示:

class MainActivity : BaseActivity() {

  val updateText = 1

  private val handler = object : Handler(Looper.getMainLooper()) {
    override fun handleMessage(msg: Message) {
      when (msg.what) {
        updateText -> textView.text = "Nice to meet you"
      }
    }
  }

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    changeTextBtn.setOnClickListener {
      thread {
        val msg = Message()
        msg.what = updateText
        handler.sendMessage(msg)
      }
    }
  }
}

这里先是定义了一个整型变量 updateText,用于表示更新 TextView 这个动作。然后新增一个 Handler 对象,并重写父类的 handleMessage() 方法,在这里对具体的 Message 进行处理。如果发现 Message 的 what 字段的值等于 updateText,就将 TextView 显示的内容改 成 Nice to meet you。

这次并没有在子线程里直接进行 UI 操作,而是创建了一个 Message(android.os.Message)对象,并将它的 what 字段的值指定为 updateText,然后调用 Handler.sendMessage() 方法将这条 Message 发送出去。Handler 就会收到这条 Message,并在 handleMessage() 方法中对它进行处理。注意此时 handleMessage() 方法中的代码就是在主线程当中运行的了,所以可以放心地在这里进行 UI 操作。接下来对 Message 携带的 what 字段的值进行判断,如果等 于 updateText,就将 TextView 显示的内容改成 Nice to meet you。

1.3 异步消息处理机制

Android 中的异步消息处理主要由 4 个部分组成:Message、Handler、MessageQueue 和 Looper。

1.3.1 Message

Message 是在线程之间传递的消息,它可以在内部携带少量的信息,用于在不同线程之间传递数据。除了 Message.what 字段,还可以使用 arg1 和 arg2 字段来携带一些整型数据,使用 obj 字段携带一个 Object 对象。

1.3.2 Handler

Handler 顾名思义也就是处理者的意思,它主要是用于发送和处理消息的。发送消息一般是使用 Handler 的 sendMessage() 方法、post() 方法等,而发出的消息经过一系列地辗转处理后,最终会传递到 Handler.handleMessage() 方法中。

1.3.3 MessageQueue

MessageQueue 是消息队列的意思,它主要用于存放所有通过 Handler 发送的消息。这部分消息会一直存在于消息队列中,等待被处理。每个线程中只会有一个 MessageQueue 对象。

1.3.4 Looper

Looper 是每个线程中的 MessageQueue 的管家,调用 Looper.loop() 方法后,就会进入 一个无限循环当中,然后每当发现 MessageQueue 中存在一条消息时,就会将它取出,并传递到 Handler.handleMessage() 方法中。每个线程中只会有一个 Looper 对象。

总结:首先需要在主线程当中创建一个 Handler 对象,并重写 handleMessage() 方法。然后当子线程中需要进行 UI 操作时,就创建一个 Message 对象,并通过 Handler 将这条消息发送出去。之后这条消息会被添加到 MessageQueue 的队列中等待被处理,而 Looper 则会一直尝试从 MessageQueue 中取出待处理消息,最后分发回 Handler.handleMessage() 方法中。由于 Handler 的构造函数中我们传入了 Looper.getMainLooper(),所以此时 handleMessage() 方法中的代码也会在主线程中运行,于是我们在这里就可以进行 UI 操作了。

整个异步消息处理机制的流程如图所示:

异步消息处理机制

一条 Message 经过以上流程的辗转调用后,也就从子线程进入了主线程,从不能更新 UI 变成了可以更新 UI,整个异步消息处理的核心思想就是如此。

到目前为止,线程可能是最常见的避免应用程序阻塞的方法,但有一系列缺点:

  • 线程切换十分浪费资源
  • 可被启动的线程数受底层操作系统的限制,无能无限启动。在服务器端应用程序中,这可能会导致严重的瓶颈
  • 在一些平台中,比如 JavaScript 并不支持线程
  • 线程不容易使用。线程的 Debug,避免竞争条件是在多线程编程中遇到的常见问题

2 回调

使用回调,将一个函数作为参数传递给另一个函数,并在处理完成后调用此函数:

fun postItem(item: Item) {
    preparePostAsync { token -> 
        submitPostAsync(token, item) { post -> 
            processPost(post)
        }
    }
}

fun preparePostAsync(callback: (Token) -> Unit) {
    // 发起请求并立即返回
    // 设置稍后调用的回调
}

原则上这感觉就像一个更优雅的解决方案,但又有几个问题:

  • 回调嵌套的难度。通常被用作回调的函数,经常最终需要会调自己,这导致出现一系列难以理解的回调嵌套。该模式通常被称为标题圣诞树(大括号代表树的分支)。
  • 错误处理很复杂。嵌套模型使错误处理变得更加复杂。

回调在诸如 JavaScript 之类的事件循环体系结构中非常常见,但是通常会使用其他方法,例如 promises 或响应式扩展。

3 Futures,Promises 等等

futures 或 promises 的原理是当发起调用的时候,将会在某些时候返回一个 Promise 类型的可被操作的对象:

fun postItem(item: Item) {
    preparePostAsync() 
        .thenCompose { token -> 
            submitPostAsync(token, item)
        }
        .thenAccept { post -> 
            processPost(post)
        }    
}

fun preparePostAsync(): Promise<Token> {
    // 发起请求并当稍后的请求完成时返回一个 promise
    return promise 
}

这种方法需要对编程方式进行一系列更改,尤其是:

  • 不同的编程模型。与回调类似,编程模型从自上而下的命令式方法转变为具有链式调用的组合模型。传统的编程结构例如循环,异常处理等等。通常在此模型中不再有效;
  • 不同的 API。通常这需要学习完整的新 API 诸如 thenCompose 或 thenAccept,这也可能因平台而异;
  • 具体的返回值类型。返回类型不是需要的实际数据,而是返回一个新类型 Promise;
  • 异常处理会很复杂。误处理变得更加复杂;

4 响应式扩展

Rx 的理念是“可观察流”,将数据视为流(无限量的数据),并且可以观察到这些流。 实际上,Rx 很简单, Observer Pattern (观察者模式) 带有一系列扩展,允许我们对数据进行操作。著名的表述是:“一切都是流,并且它是可被观察的”

observer [əbˈzɜːrvər] 观察者,目击者;观察家,评论员;(会议等的)观察员 pattern [ˈpætərn] 模式;图案;样品

在方法上它与 Futures 非常相似,但是开发者可以将 Future 视为一个离散元素,而 Rx 返回一个流。 Rx 的一个好处是,它被移植到这么多平台,通常我们可以找到一致的 API 体验,无论我们使用 C#、Java、JavaScript,还是 Rx 可用的任何其他语言。

此外,Rx 确实引入了一种更好的错误处理方法。

5 协程(Coroutine)

coroutine[kəruːˈtiːn] 协同程序

首先思考,多线程一定优于单线程吗?

为了能在一个程序内或者说进程内同时执行多个任务,引入了多线程的概念。假设这个商城系统在某个时间段内有多个人同时下单,如果只用一个线程去处理,那么一次只能处理一位用户的请求,后面的人必须等待,如果某个人处理的时间非常长,那么后面等待的时间就会很长,这样效率非常低下。使用多线程,就可以同时处理多个用户的请求,从而提高了效率。

传统的 Java Web 框架所采用的服务器通常是 Tomcat ,而 Tomcat 所采用的就是多线程的方式。当有请求接入服务器的时候,Tomcat 会为每一个请求连接分配一个线程。当请求不是很多的时候,系统是不会出现什么问题的,一旦请求数多于 Tomcat 所能分配的最大线程数时,如果这时有多个请求被阻塞住了,就会出现一些问题。

多线程在执行的时候,只是看上去是同时执行的,因为线程的执行是通过 CPU 来进行调度的, CPU 通过在每个线程之间快速切换,使得其看上去是同时执行的一样。其实 CPU 在某一个时间片内只能执行一个线程,当这个线程执行一会儿之后,它就会去执行其他线程。当 CPU 从一个线程切换到另一个线程的时候会执行许多操作,主要有如 下两个操作:

  • 保存当前线程的执行上下文;
  • 载入另外一个线程的执行上下文;

注意,这种切换所产生的开销是不能忽视的,当线程池中的线程有许多被阻塞住了,CPU 就会将该线程挂起,去执行别的线程,那么就产生了线程切换。当切换很频繁的时候,就会消耗大量的资源在切换线程操作上,这就会导致一个后果——采用多线程的实现方式并不优于单线程。

为此,Kotlin 引入了协程(Coroutine)来支持更好的异步操作。利用协程我们可以避免在异步编程中使用大量的回调,同时相比传统多线程技术,更容易提升系统的高并发处理能力。

进程、线程、协程

5.1 协程简介

协程并不是一个非常新的概念,它早在1963年就已经被提出来了。

协程是一个无优先级的函数(子程序)调度组件(函数/子程序的调度是通过栈来实现的),允许函数(子程序)在特定的地方挂起恢复。线程包含于进程,协程包含于线程,协程不能脱离线程运行。只要内存足够,一个线程中可以有任意多个协程,但某一时刻只能有一个协程在运行,多个协程分享该线程分配到的计算机资源。 比如以下代码:

fun foo() {
    a()
    b()
    c()
}

fun bar() {
    x()
    y()
    z()
}

在没有开启线程的情况下,先后调用 foo() 和 bar() 这两个方法,那么理论上结果一定是 a()->b()->c() 执行完了之后,才是 x()->y()->z() 执行。而如果使用了协程,在协程 A 中去调用 foo() 方法,在协程 B 中调用 bar() 方法, 虽然它们仍然会运行在同一个线程当中,但是在执行 foo() 方法时随时都有可能挂起转而执行 bar() 方法,执行 bar() 方法时也随时都有可能被挂起转而去执行 bar() 方法,执行 bar() 时也随时有可能被挂起转而继续执行 foo() 方法,最终的输出结果就变得不确定了。

协程的真正魅力在于:最大程度简化异步并发任务,用同步代码写出异步效果。

线程是由操作系统来进行调度的,系统在切换线程的时候,会产生一定的消耗。而协程不一样,协程是包含于线程的,也就是说协程是工作在线程之上的,协程的切换可以由程序自己来控制,不需要操作系统去进行调度。这样的话就大大降低了开销。

5.2 协程的基本用法

Kotlin 并没有将协程纳入标准的 API 当中,而是以依赖库的形式提供的。所以如果想要使用协程,需要在 app/build.gradle 文件中添加以下依赖库:

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1" // Android项目中

使用 GlobalScope.launch 函数开启一个协程:

fun main() {
  GlobalScope.launch { // 启动一个协程
    println("codes run in coroutine scope")
  }
}

scope [skoʊp] 范围;余地;视野;眼界;导弹射程 coroutine [ˈkəʊruːˌtiːn] 协同程序

GlobalScope.launch 函数可以创建一个协程的作用域,这样传递给 launch 函数的代码块就是在协程中运行的了。

运行 main 函数,没有任何日志输出。这是因为,GlobalScope.launch 函数每次创建的都是一个顶层协程,这种协程当应用程序运行结束时会跟着一起结束。 所以刚才的日志无法打印出来,这是因为代码块中的代码还没来得及运行,应用程序就结束了。如果要解决这个问题,需要将程序延迟一段时间结束:

fun main() {
    GlobalScope.launch {
        println("codes run in coroutine scope")
    }
    Thread.sleep(1000) // 为了使JVM保活,阻塞主线程1s
}

这里使用 Thread.sleep 方法让主线程阻塞 1s,重新运行程序,日志可以正常打印出来。这种写法是存在问题的,如果代码块中的代码在 1s 之内不能运行结束,那么就会被强制中断:

fun main() {
    GlobalScope.launch {
        println("codes run in coroutine scope")
        delay(1500)
        println("codes run in coroutine scope finished")
    }

    Thread.sleep(1000)
}

在代码块中加入一个 delay() 函数,并在之后又打印了一行日志。delay() 函数可以让当前协程延迟指定时间后再运行,但它和 Thread.sleep 方法不同,delay 函数是一个非阻塞式的挂起函数,它只会挂起当前协程,并不会影响其他协程的运行。而 Thread.sleep 方法会阻塞当前线程,这样运行在该线程下的所有协程都会被阻塞。注意,delay() 函数只能在协程的作用域或者其他挂起函数中调用。(简单来说:delay 只能在协程内部使用,它用于挂起协程,不会阻塞线程;sleep 用来阻塞线程)

重新运行程序,代码中的最后一条日志并没有打印出来,因为它还没来得及运行,应用程序就已经结束了。

那么有没有什么办法能让应用程序在协程总所有代码都运行完之后再结束呢?借助 runBlocking 函数就可以实现这个功能:

fun main() {
    runBlocking {
        println("codes run in coroutine scope")
        delay(1500)
        println("codes run in coroutine scope finished")
    }
}

runBlocking 函数同样会创建一个协程的作用域,但是它可以保证在协程作用域内的所有代码和子协程没有全部执行完之前一直阻塞当前线程。 需要注意的是,runBlocking 函数通常只应该在测试环境下使用,在正式环境中容易产生一些性能上的问题。

通过上面的例子我们可以看出,协程与线程非常类似。那么为什么说协程是轻量级的线程呢?它又是如何来帮助我们解决前面所提出的问题的呢?线程是由操作系统来进行调度的,当操作系统切换线程的时候,会产生一定的消耗。而协程不一样,协程是包含于线程的,也就是说协程是工作在线程之上的,协程的切换可以由程序自己来控制,不需要操作系统去进行调度,这样的话就大大降低了开销。

接下来就通过一个简单的例子来看一下,采用协程为何能够降低开销:

fun main() {
  runBlocking {
    repeat(100_000) {
      launch {
        println(".")
      }
    }
  }
}

这里的 launch 函数和 GlobalScope.launch 函数不同。首先它必须在协程的作用域中才能调用,其次它会在当前协程的作用域下创建子协程。子协程的特点是如果外层作用域的协程结束了,该作用域下的所有子协程会一同结束。相比而言,GlobalScope.launch 函数创建的永远是顶层协程,这一点和线程比较像,因为线程有没有层级者一说,永远都是顶层的。

在上面的代码中,启动了 10 万个协程去执行了一个输出的操作,当执行这段代码之后,就会被陆续地打印出来。但是,如果启动 10 万个线程去做的话,就可能会出现 out of memory 的错误了。

协程允许我们在单线程模式下模拟多线程编程效果,代码执行时的挂起和恢复完全由编程语言来控制,和操作系统无关。这种特性使得高并发程序的运行效率得到了极大的提升:

// 协程
fun main() {
  val start = System.currentTimeMillis();
  runBlocking {
    repeat(100000) {
      launch {
        println(".")
      }
    }
  }
  val end = System.currentTimeMillis()
  println(end - start) // 513
}

// 线程
fun main() {
  val start = System.currentTimeMillis();
  runBlocking {
    repeat(100000) {
      thread(start = true) {
        println(".")
      }
    }
  }
  val end = System.currentTimeMillis()
  println(end - start) // 6485
}

另外,如果 launch 函数中的逻辑比较复杂,就需要将部分代码提取到一个单独的函数中,这时就产生了一个问题:在 launch 函数中编写的代码是拥有协程作用域的,但是提取到一个单独的函数中就没有协程作用域了,那么怎样才能调用像 delay 这样的挂起函数呢?为此,Kotlin 提供了一个 suspend 关键字,使用它可以将任意函数声明成挂起函数,而挂起函数之间都是可以相互调用的, 如下所示:

suspend fun printDot() {
    println(".")
    delay(1000)
}

这样就可以在 printDot() 函数中调用 delay() 函数了。

但是,suspend 关键字只能将一个函数声明成挂起函数,是无法给它提供协程作用域的。 比如尝试在 printDot() 函数中调用 launch 函数,一定是无法调用成功的,因为 launch 函数要求必须在协程作用域中才能调用。这个问题可以借助 coroutineScope 函数来解决。coroutineScope 函数也是一个挂起函数,因此可以在任何其他挂起函数中调用。它的特点是会继承外部的协程作用域并创建一个子协程, 借助这个特性,就可以给任意挂起函数提供协程作用域了:

suspend fun printDot() = coroutineScope {
    launch {
        println(".")
        delay(1000)
    }
}

这样就可以在 printDot 这个挂起函数中调用 launch 函数了。

另外,coroutineScope 函数和 runBlocking 函数还有点类似,它可以保证其作用域内的所有代码和子协程在全部执行完之前,外部的协程会一直被挂起:

fun main() {
    runBlocking {
        coroutineScope {
            launch {
                for (i in 1..3) {
                    println(i)
                    delay(1000)
                }
            }
        }
        println("coroutineScope finished")
    }
    println("runBlocking finished")
}

// 1
// 2
// 3
// coroutineScope finished
// runBlocking finished

这里先使用 runBlocking 函数创建了一个协程作用域,然后调用 coroutineScope 函数创建了一个子协程。在 coroutineScope 的作用域中,又调用 launch 函数创建了一个子协程,并通过 for 循环依次打印数字 1 到 3,每次打印间隔 1s。最后在 runBlocking 和 coroutineScope 函数的结尾,分别又打印了一行日志。

由此可见,coroutineScope 函数确实是将外部协程挂起了,只有当它作用域内的所有代码和子协程都执行完毕之后,coroutineScope 函数之后的代码才能得以运行。

虽然看上去 coroutineScope 函数和 runBlocking 函数的作用是有点类似的,但是 coroutineScope 函数只会阻塞当前协程,既不影响其他协程,也不影响任何线程,因此是不会造成任何性能上的问题的。而 runBlocking 函数由于会挂起外部线程,如果又恰好在主线程中调用的话,那么就有可能会导致界面卡死的情况,所以不太推荐在实际项目中使用。

那么,如何创建多个协程呢?

fun main() {
    runBlocking {
        launch {
            println("launch1")
            delay(1000)
            println("launch1 finished")
        }

        launch {
            println("launch2")
            delay(1000)
            println("launch2 finished")
        }
    }
}

上面的代码中调用了两次 launch 函数,也就是创建了两个子协程。运行程序,可以看到,两个子协程中的日志是交替打印的,说明它们确实是像多线程那样并发运行的。然而这两个子协程实际运行在同一个线程中,只是由编程语言来决定如何在多个协程之间进行调度,让谁运行,让谁挂起。

5.3 更多的作用域构建器

GlobalScope.launch、runBlocking、launch、coroutineScope 这几种作用域构建器,都可以用于创建一个新的协程作用域。不过,GlobalScope.launch 和 runBlocking 函数是可以在任意地方调用的, coroutineScope 函数可以在协程作用域或挂起函数中调用,而 launch 函数只能在协程作用域中调用。

runBlocking 由于会阻塞线程,因此只建议在测试环境下使用。而 GlobalScope.launch 由于每次创建的都是顶层协程,一般也不太建议使用,除非是非常明确要创建顶层协程。

为什么不建议使用顶层协程呢?主要是因为它管理起来成本太高了。举个例子,比如在某个 Activity 中使用协程发送一条网络请求,由于网络请求是耗时的,用户在服务器还没有来得及响应的情况下就关闭了当前 Activity,此时按道理说应该取消这条网络请求,或者至少不应该进行回调,因为 Activity 已经不存在了,回调了也没有意义。

那么协程要怎样取消呢?不管是 GlobalScope.launch 函数还是 launch 函数,它们都会返回一个 Job 对象,只需要调用 Job 对象的 cancel() 方法就可以取消写成了, 如下所示:

fun main() {
    val job = GlobalScope.launch {
        // 处理的逻辑
    }
    job.cancel()
}

如果每次创建的都是顶层协程,那么当 Activity 关闭时,就需要逐个调用所有已创建协程的 cancel() 方法,这样代码根本无法维护。

因此,GlobalScope.launch 这种协程作用构建器,在实际项目中也是不太常用的。以下是实际项目中比较常用的写法:

fun main() {
    val job = Job()
    val scope = CoroutineScope(job)
    scope.launch {
        // 处理具体的逻辑
    }
    job.cancel()
}

首先创建一个 Job 对象,然后把它传入 CoroutineScope() 函数中,注意,这里的 CoroutineScope() 是个函数,虽然它的命名更像是一个类。CoroutineScope() 函数会返回一个 CoroutineScope 对象,这种语法结构的设计更像是我们创建了一个 CoroutineScope 的实例。有了 CoroutineScope 对象之后,就可以随时调用它的 launch 函数来创建一个协程了。

现在所有调用 CoroutineScope 的 launch 函数所创建的协程,都会被关联在 Job 对象的作用域下面。这样只需要调用一次 cancel() 方法,就可以将同一作用域内的所有协程全部取消,从而大大降低了协程管理的成本。

相比之下,CoroutineScope() 函数更适用于实际的项目当中,如果只是 main() 函数中编写一些学习测试用的代码,还是使用 runBlocking 的函数最为方便。

另外,如果我们要在协程中执行一个查询数据库的操作,但我们并不知道该查询操作要执行多久,所以没有办法去设定一个合理的时间来让程序一直保活。为了能够让程序在协程执行完毕之前一直保活,我们可以使用 job.join:

fun main() = runBlocking {
    val job = launch { search() }
    println("Hello, ")
    job.join()
}

suspend fun search() {
    delay(1000)
    println("World!")
}

// Hello, 
// World!

这样程序就会一直等待,直到我们启动的协程结束。注意,这里的等待是非阻塞式的等待,不会将当前线程挂起。

5.4 用同步方式写异步代码

launch 函数可以创建一个新的协程,但是 launch 函数只能用于执行一段逻辑,不能获取执行的结果,因为它的返回值永远是一个 Job 对象。 那么有没有什么办法可以创建一个协程,并返回它的执行结果呢?使用async函数就可以实现。

async 函数必须在协程作用域中才能调用,它会创建一个新的子协程并和其它子协程一样并行工作,与 launch 不同的是,async 会返回一个 Deferred 对象,Deferred 值是一个非阻塞可取消的 future,它是一个带有结果的 job(launch 也会返回一个 job 对象,但是没有返回值)。如果我们想要获取 async 函数代码块的执行结果,只需要调用 Deferred 对象的 await() 方法即可:

fun main() = runBlocking {
    val result = async { 5 + 5 }.await()
    println(result) // 10
}

不过,async 函数的奥妙还不止于此。事实上,在调用了 async 函数之后,代码块中的代码就会立刻开始执行。当调用 await() 方法时,如果代码中的代码还没有执行完,那么 await() 方法会将当前协程阻塞住,直到可以获得 async 函数的执行结果。

fun main() = runBlocking {
    val time = measureTimeMillis {
        val result1 = async { sumNumberOne() }.await()
        val result2 = async { sumNumberTwo() }.await()
        println("result is ${result1 + result2}") // result is 20
    }
    println("cost $time ms") // cost 2019ms
}

suspend fun sumNumberOne(): Int {
    delay(1000)
    return 5 + 5
}

suspend fun sumNumberTwo(): Int {
    delay(1000)
    return 4 + 6
}

这里连续使用了两个 async 函数来执行任务,并在代码块中调用 delay() 方法进行 1s 的延迟。 await() 方法在 async 函数代码块中的代码执行完之前会一直将当前协程阻塞住。

从运行结果中可以看出,整段代码的运行耗时是 2019ms,说明这里的两个 async 函数确实是一种串行的关系,前一个执行完了后一个才能执行。

但是这种写法明显是非常低效的,因为两个 async 函数完全可以同时执行从而提高运行效率:

fun main() = runBlocking {
    val time = measureTimeMillis {
        val result1 = async { sumNumberOne() }
        val result2 = async { sumNumberTwo() }
        println("result is ${result1.await() + result2.await()}") // result is 20
    }
    println("cost $time ms") // cost 1016ms
}

不在每次调用 async 函数之后就立刻使用 await() 方法获取结果了,而是仅在需要用到 async 函数的执行结果时才调用 await() 方法进行获取,这样两个 async 函数就变成一种并行关系了。

下面来学习一个比较特殊的作用域构建器:withContext() 函数。withContext() 函数是一个挂起函数,大体可以将它理解成 async 函数的一种简化版写法:

fun main() {
  runBlocking {
    val result = withContext(Dispatchers.Default) {
      5 + 5
    }
    println(result) // 10
  }
}

dispatcher [dɪˈspætʃər] 调度员;[计] 调度程序;[计] 分配器

调用 withContext 函数之后,会立即执行代码块中的代码,同时将外部协程挂起。当代码块中的代码全部执行完之后,会将最后一行的执行结果作为 withContext 函数的返回值返回,因此基本上相当于 val result = async { 5 + 5 }.await() 的写法。唯一不同的是,withContext 函数强制要求我们制定一个线程参数。

协程是一种轻量级的线程,因此很多传统编程情况下需要开启多线程执行的并发任务,现在只需要在一个线程下开启多个协程来执行就可以了。但是这并不意味着我们就永远不需要开启线程了,比如说 Android 中要求网络请求必须在子线程中进行,即使你开启了协程去执行网络请求,假如它是主线程当中的协程,那么程序仍然会出错。这个时候我们就应该通过线程参数给协程指定一个具体的运行线程。

线程参数主要有以下 3 种值可选:Dispatchers.Default、Dispathcers.IO 和 Dispatcher.Main。Dispatchers.Default 表示会使用一种默认低并发的线程策略,当要执行的代码属于计算密集型任务时,开启过高的并发反而可能会影响任务的运行效率,此时就可以使用 Dispatchers.Default。Dispathcers.IO 表示会使用一种较高并发的线程策略,当要执行的代码大多数时间是在阻塞和等待中,比如说执行网络请求时,为了能够支持更高的并发数量,此时就可以使用 Dispatchers.IO。Dispathcers.Main 则表示不会开启子线程,而是在 Android 主线程中执行代码,但是这个值只能在 Android 项目中使用,纯 Kotlin 程序使用这种类型的线程参数会出现错误。

事实上,在我们所学的协程作用构建器中,除了 coroutineScope 函数外,其他所有的函数都可以指定这样一个线程参数,只不过 withContext 函数时强制要求指定的,而其他函数则是可选的。

5.5 使用协程简化回调的写法

回调机制实现了获取异步网络请求数据响应的功能。回调机制基本上时通过匿名类来实现的,但是匿名类的写法通常比较繁琐:

HttpUtils.sendHttpRequest(address, object : HttpCallbackListener) {
  override fun onFinish(response: String) {
    // 得到服务器返回的具体内容
  }

  override fun onError(e: Exception) {
    // 在这里对异常情况处理
  }
}

在多少个地方发起网络请求,就需要编写多少次这样的匿名类实现。有没有更简单的实现方式呢? Kotlin 的协程使得写法更加简单,只需要结束 suspendCoroutine 函数就能将传统回调机制的写法大幅简化。

suspend [səˈspend] 暂停,中止;使暂停使用(或生效);使暂时停职(或停学等);

suspendCoroutine 函数必须在协程作用域或挂起函数中才能调用,它接收一个 Lambda 表达式参数,主要作用是将当前协程立即挂起,然后在一个普通的线程中执行 Lambda 表达式中的代码。 Lambda 表达式的参数列表上会传入一个 Continuation 参数,调用它的 resume 方法或 resumeWithException 可以让协程恢复执行。

continuation [kənˌtɪnjuˈeɪʃn] 连续,持续;延续物,附加物;(停顿后的)继续,再开始

了解了 suspendCoroutine 函数的作用之后,接下来会借助这个函数对传统的回调写法进行优化。首先定义一个 request 函数:

suspend fun request(address:String):String{
    return suspendCoroutine { continuation ->
        HttpUtils.sendHttpRequest(address, object : HttpCallbackListener) { 
            override fun onFinish(response: String) {
                // 得到服务器返回的具体内容  
            }
            override fun onError(e: Exception) { 
                // 在这里对异常情况处理  
            }
        }
    }
}

可以看出,request 函数时一个挂起函数,并且接收一个 address 参数。在 request 函数的内部,调用了 suspendCoroutine 函数,这样当前协程就会被立刻挂起,而 Lambda 表达式中的代码则会在普通线程中执行。接着在 Lambda 表达式中调用 HttpUtils.sendHttpRequest 发起网络请求,并通过传统回调的方式监听请求结果。如果请求成功就调用 Continuation 的 resume 方法恢复被挂起的协程,并传入服务器响应的数据,该值会成为 suspendCoroutine 函数的返回值。如果请求失败,就调用 Continuation 的 resumeWithException 恢复被挂起的协程,并传入具体异常原因。

这里仍然使用传统回调的写法,代码如何变得更加简化了?这是因为,不管之后我们要发起多少次网络请求,都不需要再回复进行回调实现了。比如说获取百度首页的响应数据:

suspend fun getBaiduResponse() {
    try {
        val response = request("https://www.baidu.com/")
        // 对服务器响应的数据进行处理
    } catch (e: Exception) {
        // 对异常情况处理
    }
}

代码清爽了很多。由于 getBaidResponse 是一个挂起函数,因此当它调用了 request 函数时,当前的协程就会被立刻挂起,然后一直等待网络请求成功或失败,当前协程才能恢复运行。这样即使不使用回调的写法,也能获得异步网络请求的响应数据,而如果请求失败,则会直接进入 catch 语句当中。

但是,getBaiduResponse 函数被声明成了挂起函数,这样它也只能在协程作用域或其他挂起函数中调用了,使用起来是不是非常有局限性?确实如此,因为 suspendCoroutine 函数本身就是要结合协程一起使用的。不过通过合理的项目架构设计,可以轻松地将各种协程的代码应用到一个普通的项目当中。

事实上,suspendCoroutine 函数几乎可以用于简化任何回调的写法,比如使用 Retrofit 发起网络请求需要这样写:

val appService = ServiceCreator.create<AppService>()
appService.getAppData().enqueue(object : Callback<List<App>> {
  
  override fun onResponse(call: Call<List<App>>, response: Response<List<App>>) {
    // 得到服务器返回的数据
  }
  
  override fun onFailure(call: Call<List<App>>, t: Throwable) {
    // 在这里对异常情况进行处理
  }
  
})

这里使用回调的写法是比较繁琐的,使用 suspendCoroutine 函数,就能对上述写法进行大幅度简化。

由于不同的 Service 接口返回的数据类型也不相同,所以这次不能像刚才那样针对具体的类型进行编程了,而是要使用泛型的方式。定义一个 await 函数,代码如下:

suspend fun <T> Call<T>.await(): T {
  return suspendCoroutine { continuation ->
      enqueue(object: Callback<T> {
        override fun onResponse(call: Call<T>, response: Response<T>) {
          val body = response.body()
          if (body != null) continuation.resume(body)
          else continuation.resumeWithException(
              RuntimeException("response body is null"))
        }
        
        override fun onFailure(call: Call<T>, t: Throwable) {
          continuation.resumeWithException(t)
        }
      })
  }
}

这段代码比刚才的 request 函数又复杂了一点。首先 await 函数仍然是一个挂起函数,然后我们给它声明了一个泛型 T,并将 await 函数定义成了 Call 的扩展函数,这样所有返回值是 Call 类型的 Retrofit 网络请求接口就可以直接调用 await 函数了。

接着,await 函数中使用了 suspendCoroutine 函数来挂起当前协程,并且由于扩展函数的原因,现在拥有了 Call 对象的上下文,那么这里就可以直接调用 enqueue 方法让 Retrofit 发起网络请求。接下来,使用同样的方式对 Retrofit 响应的数据或者网络请求失败的情况进行处理就可以了。另外还有一点需要注意,在 onResponse 回调当中,调用 body 方法解析出来的对象是可能为空的。如果为空的话,这里的做法是手动抛出一个异常,可以根据自己的逻辑进行更加合适的处理。

有了 await 函数之后,调用所有 Retrofit 的 Service 接口都会变得极其简单,比如刚才的功能就可以使用如下写法进行实现:

suspend fun getAppData() {
  try {
    val appList = ServiceCreator.create<AppService>().getAppData().await()
    // 对服务器响应的数据进行处理
  } catch (e: Exception) {
    // 对异常情况进行处理
  }
}

没有冗长的匿名类实现,只需要简单调用一下 await 函数就可以让 Retrofit 发起网络请求,并直接获得服务器响应的数据。对于 try catch 的处理,其实也可以选择在此处不处理,在不处理的情况下,如果发生了异常就会一层层向上抛出,知道被某一层的函数处理了为止。因此,我们也可以在某个统一的入口函数中进行一次 try catch,从而让代码变得更加精简。

6 总结

协程属于 Kotlin 中非常有特色的技术,这是一种计算可被挂起的理念:即一种函数可以在某个时刻暂停执行并稍后恢复。 对于开发人员来说,协程的一个好处是:编写非阻塞代码与编写阻塞代码基本相同,编程模型本身并没有真正改变。以下面的代码为例:

fun postItem(item: Item) {
    launch {
        val token = preparePost()
        val post = submitPost(token, item)
        processPost(post)
    }
}

suspend fun preparePost(): Token {
    // 发起请求并挂起该协程
    return suspendCoroutine { /* ... */ } 
}

launch[lɔːntʃ] 发动,发起; suspend [səˈspend] 暂停,中止;使暂停使用(或生效)

此代码将启动长时间运行的操作,而不会阻塞主线程。preparePost 就是所谓的 可挂起的函数它含有 suspend 前缀,该函数将被执行、暂停执行以及在某个时间点恢复。

  • 与普通函数相比,唯一的不同是它被添加了 suspend 修饰符
  • 编写这段代码代码就好像在编写同步代码,自上而下,不需要任何特殊语法,除了使用一个名为 launch 的函数,它实质上启动了该协程
  • 编程模型和 API 保持不变。可以继续使用循环,异常处理等,而且不需要学习一整套新的 API。
  • 与平台无关。无论我们是面向 JVM,JavaScript 还是其他任何平台,我们编写的代码都是相同的,编译器负责将其适应每个平台

协程并不是一个新的概念,它已经存在了几十年,在 Go 等其他一些编程语言中很受欢迎。但重要的是要注意是它们在 Kotlin 中实现的方式,大部分功能都委托给了库。事实上,除了 suspend 关键字,没有任何其他关键字被添加到 Kotlin 中,这也是与其他语言的不同之处,例如 C# 将 async 以及 await 作为语法的一部分,而在 Kotlin 中,它们都只是库函数。

参考

https://www.kotlincn.net/docs/tutorials/coroutines/async-programming.html
https://www.liaoxuefeng.com/wiki/1016959663602400/1017968846697824

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值