Kotlin协程学习 -- 基础,取消和超时

479 阅读14分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第9天,点击查看活动详情

第一个协程程序

下面是一段包含了协程的代码:

    //一个简单的协程程序
    fun simpleCoroutine(){
        GlobalScope.launch {
            //非阻塞地等待1秒钟
            delay(1000L)
            LogUtils.e("World")
        }
        LogUtils.e("Hello")
        //主线程阻塞2秒钟来保证jvm存活
        Thread.sleep(2000L)
    }
    
    //打印日志如下:
    13:37:57    TAG_ZYF:	Hello
    13:37:58    TAG_ZYF:	World

从上面的打印信息可以看出:首先执行了打印Hello的操作,等待一秒之后执行了打印World的操作。

本质上来说,协程是轻量级的线程,它们在某些CoroutineScope上下文中与launch协程构建器一起启动。这里我们在GlobalScope中启动了一个新的协程,这意味着新协程的声明周期只受整个应用程序声明周期的影响。

在上面的代码中,如果我们将Thread.sleep(2000L)这一行代码删掉,那么就不会打印出World这个字符串,因为执行完打印Hello之后程序就已经结束了,此时协程中的代码也不会继续执行了。

另外,对于上面的代码,我们可以将GlobalScope.launch替换为thread{...},并将delay()替换为Thread.sleep()达到同样的目的,如下所示:

    fun simpleCoroutine2(){
        thread {
            //delay(1000L)       //这一行代码会报错,因为delay()只能应用在一个协程内部或者一个suspend()方法中,而thread{}内部其实是创建了一个线程,并不是协程
            //要达到延迟一秒的效果,应该使用Thread.sleep()
            Thread.sleep(1000L)
            LogUtils.e("World")
        }
        LogUtils.e("Hello")
        Thread.sleep(2000L)
    }
    
    //打印日志如下:
    14:19:31    TAG_ZYF:	Hello
    14:19:32    TAG_ZYF:	World

阻塞与非阻塞

在上面第一段代码中,我们混用了线程和协程,使用了非阻塞的delay()和阻塞的Thread.sleep()。第二段代码则没有使用协程。有时候这样使用容易让我搞混了哪个是阻塞的,哪个是非阻塞,下面的代码通过使用runBlocking协程构造器来阻塞:

    fun simpleCoroutine3(){
        GlobalScope.launch {
            delay(1000L)
            LogUtils.e("World")
        }
        LogUtils.e("Hello") //主线程中的代码会立即执行
        runBlocking {       //这个表达式阻塞了主线程
            delay(2000L)    //延迟2秒
        }
    }
    
    //打印日志如下
    14:28:22    TAG_ZYF:	Hello
    14:28:23    TAG_ZYF:	World

通过打印的信息可以看出,结果是一样的,但是这里的代码只使用了非阻塞的delay.调用了runBlocking{...}的主线程会一直阻塞直到runBlocking内部的协程执行完毕。

上面的例子可以使用下面的方式重写,将合乎惯用的方式:

    fun simpleCoroutine4() = runBlocking<Unit> { //开始执行主协程
        GlobalScope.launch {             //启动同一个新的协程并接续
            delay(1000L)
            LogUtils.e("World")
        }
        LogUtils.e("Hello")          //主协程立即执行
        delay(2000L)        //延迟2秒保证Jvm存活
    }
    
    //打印日志如下
    14:45:08    TAG_ZYF:	Hello
    14:45:09    TAG_ZYF:	World

等待作业

在上面的代码中,我们都是通过阻塞主线程一段时间来保证jvm存活,从未为协程的执行留下时间,但是很多时候我们并不知道协程具体需要执行多长时间,因此上面的方法并不是一个妥当的办法。我们可以显示(以非阻塞的方式) 等待所启动的后台job执行结束,如下所示:

    fun simpleCoroutine5() = runBlocking {
        val job = GlobalScope.launch {
            delay(1000L)
            LogUtils.e("World")
        }
        LogUtils.e("Hello")
        job.join()
    }
    
    //打印日志如下:
    16:03:36    TAG_ZYF:	Hello
    16:03:37    TAG_ZYF:	World

结构化的并发

上面的代码虽然已经足够简单,但是每当我们创建一个新的协程,都需要调用join()方法还是容易出错。解决办法是我们可以使用结构化的并发。也就是说:我们可以在执行操作所在的指定作用域内启动协程,而不是像通常使用线程(线程总是全局的)那样在GlobalScope中启动。

在下面的代码中,我们将会使用runBlocking{...}协程构建器将simpleCoroutine6()函数转换为协程。包括runBlocking{...}在内的每个协程构建器都将CoroutineScope的实例添加到其代码块所在的作用域中,我们在这个作用域中启动协程而无需join。因为外部协程(示例中的runBlocking{...})直到在其作用域中启动的所有协程都执行完毕才会结束,因此,上面的代码也可以简化为:

    fun simpleCoroutine6() = runBlocking {
        launch {
            delay(1000L)
            LogUtils.e("World")
        }
        LogUtils.e("Hello")
    }
    
    //打印日志如下
    16:15:44    TAG_ZYF:	Hello
    16:15:45    TAG_ZYF:	World

另外,我们也可以通过打印当前线程的信息来观察到simpleCoroutine5()simpleCoroutine6()的区别,如下所示:


//simpleCoroutine5()打印信息
16:17:50    TAG_ZYF:	Hello -> Thread[main,5,main]
16:17:51    TAG_ZYF:	World -> Thread[DefaultDispatcher-worker-1,5,main]

//simpleCoroutine6()打印信息
16:17:51    TAG_ZYF:	Hello -> Thread[main,5,main]
16:17:52    TAG_ZYF:	World -> Thread[main,5,main]

可以看到,通过GlobalScope.launch{}创建的协程其实是在另一个线程中执行的,而在runBlocking{...}中创建的协程是在当前线程中执行的。

作用域构建器

除了由不同的构建器提供协程的作用域之外,还可以使用coroutineScope构建器声明自己的作用域。它会创建一个协程作用域并且在所有已启动子协程执行完毕之前不会结束。

runBlockingcoroutineScope可能看起来很类似,它们都会等待其协程体以及子协程执行完毕。主要区别在于,runBlocking会阻塞当前线程来等待,而coroutineScope只是挂起,会释放底层线程用于其它用途。由于存在这点差异,runBlocking是普通函数,而cotroutineScope是挂起函数。

    fun simpleCoroutine7() = runBlocking {
        launch {
            delay(200L)
            LogUtils.e("Task from runBlocking")
        }
        coroutineScope {
            launch {
                delay(500L)
                LogUtils.e("Task from nested launch")
            }
            delay(100L)
            LogUtils.e("Task from coroutineScope")
        }
        LogUtils.e("Coroutine Scope is over")
    }
    
    //打印信息如下
    10:42:37:527    TAG_ZYF:	programing start
    10:42:37:696    TAG_ZYF:	Task from coroutineScope
    10:42:37:801    TAG_ZYF:	Task from runBlocking
    10:42:38:100    TAG_ZYF:	Task from nested launch
    10:42:38:100    TAG_ZYF:	Coroutine Scope is over
    10:42:38:100    TAG_ZYF:	programing end

暂时不太理解这里为什么首先输出了Task from coroutineScope而不是Coroutine Scope is over

上面的代码容易由于同时使用了runBlockingcoroutineScope,容易让我们造成误解,其实最重要的一句话是 -- runBlockingcoroutineScope都会等待其协程体以及子协程执行完毕。不管是runBlocking还是coroutineScope,协程体内部都是线性执行的。在上面的代码中,runBlocking创建了一个协程作用域,然后通过launch创建了一个新的协程,在这个协程中等待200毫秒输出一段打印。紧接着通过croutineScope创建了一个新的协程作用域,需要注意的是,coroutineScope也会等待其中的协程体和子协程执行完毕。coroutineScope创建的是一个协程作用域,并不是一个新的协程,按照线性执行的逻辑,程序执行到此处之后会首先执行coroutineScope协程块中的代码,直到这里的代码执行完成之后才会继续执行后面的代码。

经过网上学习,在这篇博文中找到了二者的差别。下面是根据这篇博文中的示例来做了一个简单的测试:

首先是针对runBlocking进行测试的代码:

    fun simpleCoroutine11(){
        runBlocking {
            LogUtils.e("simpleCoroutine11 runBlocking start -> ${getCurrentThreadId()}")
            delay(1000L)
            LogUtils.e("simpleCoroutine11 runBlocking end ->${getCurrentThreadId()}")
        }
        LogUtils.e("simpleCoroutine11 end --> ${getCurrentThreadId()}")
    }
    
    //打印的日志如下:
    13:48:23:792    TAG_ZYF:	simpleCoroutine11 runBlocking start -> Thread[main,5,main]
    13:48:24:810    TAG_ZYF:	simpleCoroutine11 runBlocking end ->Thread[main,5,main]
    13:48:24:812    TAG_ZYF:	simpleCoroutine11 end --> Thread[main,5,main]

可以看到我们在一个普通函数中启动了一个runBlocking协程,打印顺序是先执行了协程中的代码块,包括延迟,然后再执行了协程外的代码块,这些都是在同一个线程中执行的,这说明runBlocking确实会阻塞当前线程的执行,也就是说,当前线程是顺序执行的。

然后是对coroutineScope进行测试的代码:

    fun simpleCoroutine12(){
        GlobalScope.launch(start = CoroutineStart.UNDISPATCHED) {
            coroutineScope {
                LogUtils.e("simpleCoroutine12 runBlocking coroutineScope start -> ${getCurrentThreadId()}")
                delay(500)
                LogUtils.e("simpleCoroutine12 runBlocking coroutineScope end -> ${getCurrentThreadId()}")
            }
        }
        LogUtils.e("simpleCoroutine12 end --> ${getCurrentThreadId()}")
        Thread.sleep(1000L)
    }
    
    //打印信息如下:
    13:57:57:719    TAG_ZYF:	simpleCoroutine12 runBlocking coroutineScope start -> Thread[main,5,main]
    13:57:57:727    TAG_ZYF:	simpleCoroutine12 end --> Thread[main,5,main]
    13:57:58:250    TAG_ZYF:	simpleCoroutine12 runBlocking coroutineScope end -> Thread[DefaultDispatcher-worker-1,5,main]

在上面的代码中,我们通过GlobalScope.launch启动了一个顶层协程并且立即执行,协程中会有延迟500ms的操作,最后我们通过Thread.sleep()延迟一段时间保证jvm存活。根据打印的情况我们可以看到,coroutineScope中的协程不会阻塞当前线程,但是协程体中前后执行代码的线程已然发生了切换。

提取函数重构

我们可以将launch{...}内部的代码块提取到独立的函数中,当我们对这段代码执行提取函数重构时,我们将会的到一个带有suspend修饰符的新函数。在协程内部可以像普通函数一样使用挂起函数,不过它有一个额外特性,同样可以使用其它挂起函数(例如下面例子中的delay)来挂起协程的执行。

    //提取函数重构
    fun simpleCoroutine8() = runBlocking {
        launch { doWork() }
        LogUtils.e("Hello")
    }

    //一个挂起函数
    private suspend fun doWork(){
        delay(1000L)
        LogUtils.e("World")
    }
    
    //打印信息如下
    10:55:58:729    TAG_ZYF:	programing start
    10:55:58:780    TAG_ZYF:	Hello
    10:55:59:789    TAG_ZYF:	World
    10:55:59:790    TAG_ZYF:	programing end

协程很轻量

下面的代码创建了10万个协程,并且每个协程都会在5秒之后输出一个点,如下所示:

    //协程很轻量
    fun simpleCoroutine9() = runBlocking {
        repeat(10 * 10000){
            launch {
                delay(5 * 1000)
                LogUtils.e(".")
            }
        }
    }
    
    //输出信息如下:
    ......
    ......
    ......
    11:01:25:717    TAG_ZYF:	.
    11:01:25:717    TAG_ZYF:	.
    11:01:25:717    TAG_ZYF:	.
    11:01:25:717    TAG_ZYF:	.
    11:01:25:717    TAG_ZYF:	.
    11:01:25:718    TAG_ZYF:	programing end

尝试使用线程实现上面的功能:

        repeat(10 * 10000){
            thread {
                Thread.sleep(5 * 1000)
                LogUtils.e(".")
            }
        }
    
    //使用上面的方式虽然没有崩溃,但是程序从开始到结束比用协程实现慢了10倍不止。

全局协程很像守护线程

下面的代码使用GlobalScope启动了一个长期运行的协程,该协程每秒输出两次sleeping信息,如下所示:

    fun simpleCoroutine10() = runBlocking{
        GlobalScope.launch {
            repeat(1000){
                delay(500)
                LogUtils.e("sleeping .. $it")
            }
        }
        delay(1300)
    }
    
    //输出信息如下:
    11:27:52:719    TAG_ZYF:	programing start
    11:27:53:289    TAG_ZYF:	sleeping .. 0
    11:27:53:789    TAG_ZYF:	sleeping .. 1
    11:27:54:092    TAG_ZYF:	programing end

从输出信息可以看出:主线程中的代码执行完成后,创建的协程即使没有执行完成也会结束。也就是说,在GlobalScope中启动的协程并不会使进程保活。类似于守护线程。

取消协程的执行

在一个长时间运行的应用程序中,我们也许需要对后台协程进行细粒度的控制。比如,用户关闭了一个启动了协程的界面,这样这个协程的结果现在已经不再需要了,这时,它应该是可以被取消的。我们上面使用的launch函数会返回一个Job对象,这个对象使支持被取消的,如下所示:

    fun cancelJob1() = runBlocking {
        val job = launch {
            repeat(1000){
                LogUtils.e("i am working:$it")
                delay(500)
            }
        }
        //等待一段时间
        delay(1300)
        LogUtils.e("main wait end")
        //取消协程
        job.cancel()
        //等待协程结束
        job.join()
        delay(10 * 1000)
        LogUtils.e("main will quit")
    }
    
    //打印信息如下:
    16:45:35:603    TAG_ZYF:	programing start
    16:45:35:672    TAG_ZYF:	i am working:0
    16:45:36:173    TAG_ZYF:	i am working:1
    16:45:36:688    TAG_ZYF:	i am working:2
    16:45:36:977    TAG_ZYF:	main wait end
    16:45:46:985    TAG_ZYF:	main will quit
    16:45:46:985    TAG_ZYF:	programing end

可以看到,在经过三次打印之后,我们成功取消了线程中的任务,没有再看到打印的信息了,我们在取消任务之后仍然等待了10秒的时间,此时也没有看到任何打印。

我们可以通过使用job.cancelAndJoin()函数一步达到上面的效果,如下所示:

    fun cancelJob1() = runBlocking {
        val job = launch {
            repeat(1000){
                LogUtils.e("i am working:$it")
                delay(500)
            }
        }
        //等待一段时间
        delay(1300)
        LogUtils.e("main wait end")
        job.cancelAndJoin()
        delay(10 * 1000)
        LogUtils.e("main will quit")
    }   

取消是协作的

协程的取消是协作的,一段协程代码也必须是协作的才能被取消。所有kotlin.coroutines中的挂起函数都是可被取消的。它们检查协程的取消,并在取消时抛出CancellationException。然而,如果协程正在执行计算任务,并且没有检查取消的话,那么他就是不能被取消的,如下所示:

    fun cancelCoroutine2() = runBlocking {
        val startTime = System.currentTimeMillis()
        val job = launch(Dispatchers.Default) {
            var nextPrintTime = startTime
            var i = 0
            while(i < 5){//一个执行计算的循环,只是为了占用CPU
                if(System.currentTimeMillis() >= nextPrintTime){
                    LogUtils.e("job: sleeping:${i++}")
                    nextPrintTime += 500L
                }
            }
        }

        delay(1300)
        LogUtils.e("main wait end")
        job.cancelAndJoin()
        LogUtils.e("main quit")
    }
    
    //打印的信息如下:
    17:10:36:816    TAG_ZYF:	programing start
    17:10:36:878    TAG_ZYF:	job: sleeping:0
    17:10:37:364    TAG_ZYF:	job: sleeping:1
    17:10:37:864    TAG_ZYF:	job: sleeping:2
    17:10:38:196    TAG_ZYF:	main wait end
    17:10:38:364    TAG_ZYF:	job: sleeping:3
    17:10:38:864    TAG_ZYF:	job: sleeping:4
    17:10:38:865    TAG_ZYF:	main quit
    17:10:38:865    TAG_ZYF:	programing end

从上面打印的日志可以看出。我们在取消了协程之后仍然执行到整个循环执行结束才退出,说明上面的协程并没有被成功取消。

使计算代码可被取消

我们有两种方式来使执行计算的代码可被取消,一种是定期调用挂起函数来检查取消,对于这种方式使用yield是比较好的选择。(暂时不清楚如何使用,后续学习)。

另一种方式是显式地检查取消状态,下面是修改上面的代码来达到取消的目的:

    fun cancelCoroutine3() = runBlocking {
        val startTime = System.currentTimeMillis()
        val job = launch(Dispatchers.Default) {
            var nextPrintTime = startTime
            var i = 0
            while (isActive){
                if(System.currentTimeMillis() >= nextPrintTime){
                    LogUtils.e("job running:${i++}")
                    nextPrintTime += 500L
                }
            }

        }
        //让协程中的代码运行一段时间
        delay(1300L)
        LogUtils.e("main wait end")
        job.cancelAndJoin()
        LogUtils.e("main quit")
    }
    
    //打印的日志如下:
    14:23:24:246    TAG_ZYF:	job running:0
    14:23:24:732    TAG_ZYF:	job running:1
    14:23:25:232    TAG_ZYF:	job running:2
    14:23:25:560    TAG_ZYF:	main wait end
    14:23:25:563    TAG_ZYF:	main quit

从打印的信息可以看到,我们成功地取消了协程中代码的执行。isActive是一个可以被使用在CoroutineScope中的扩展属性。

finally中释放资源

通过上面的学习我们也知道了可被取消的挂起函数在被取消时将会抛出CancellationException异常,我们通常使用以下方式处理这个异常:try{}finally{}表达式以及Kotlin的use函数一般在协程被取消时执行它们的终结动作,如下所示:

    fun cancelCoroutine4() = runBlocking {
        val job = launch {
            try {
                repeat(1000) {
                    LogUtils.e("repeat running:$it")
                    delay(500L)
                }
            }finally {
                LogUtils.e("job finally")
            }
        }

        //让协程中的代码块执行一段时间
        delay(1300L)
        LogUtils.e("wait end")
        job.cancelAndJoin()
        LogUtils.e("cancelCoroutine4 end")
    }
    
    //打印日志如下:
    15:03:33:090    TAG_ZYF:	repeat running:0
    15:03:33:596    TAG_ZYF:	repeat running:1
    15:03:34:098    TAG_ZYF:	repeat running:2
    15:03:34:393    TAG_ZYF:	wait end
    15:03:34:395    TAG_ZYF:	job finally
    15:03:34:396    TAG_ZYF:	cancelCoroutine4 end

通过打印的日志可以看到,我们在取消协程中的任务的时候执行了finally中的代码块,也就是说,如果我们在协程被取消的时候有需要释放的资源,那么也是可以放在这里执行的。

运行不能取消的代码块

在上面的例子中,我们将一些需要释放资源的操作放在了finally代码块中,上面我们仅仅是打印一行数据,这并不会有什么问题,但是,如果我们尝试在此处调用挂起函数则会抛出CancellationException异常,因为这里持续运行的代码是可以被取消的。通常,这并不是一个问题,所有良好的关闭操作(关闭一个文件,取消一个作业,或是关闭任何一种通信通道)通常都是非阻塞的,这并不会导致调用任何的挂起函数。然而,在真实的案例中,我们是有可能需要在finally中调用挂起函数的,面对这样的需求,我们可以将相应的代码包装在winthContext(NonCancellable){...}中,并使用withContext()函数以及NonCanCellable上下文,如下所示:

    fun cancelCoroutine5() = runBlocking {
        val job = launch {
            try {
                repeat(1000){
                    LogUtils.e("repeat running:$it")
                    delay(500L)
                }
            }finally {
                withContext(NonCancellable){
                    LogUtils.e("finally start")
                    delay(1000L)
                    LogUtils.e("finally end")
                }
            }
        }
        delay(1300L)
        LogUtils.e("wait end")
        job.cancelAndJoin()
        LogUtils.e("cancelCoroutine5 end")
    }
    
    //打印日志如下:
    15:44:40:234    TAG_ZYF:	repeat running:0
    15:44:40:737    TAG_ZYF:	repeat running:1
    15:44:41:248    TAG_ZYF:	repeat running:2
    15:44:41:543    TAG_ZYF:	wait end
    15:44:41:547    TAG_ZYF:	finally start
    15:44:42:557    TAG_ZYF:	finally end
    15:44:42:558    TAG_ZYF:	cancelCoroutine5 end

从上面的代码可以看出,我们成功在finally中运行了挂起函数withContext()

超时

在实践中,绝大多数取消一个协程的原因是它有可能超时。当我们手动追踪一个相关Job的引用并启动了一个单独的协程在延迟后取消追踪,我们可以使用withTimeout函数来做这件事,如下所示:

    /**
     * 演示[withTimeout]函数的使用
     */
    fun cancelCoroutine6() = runBlocking {
        //超时时间设置为1300毫秒
        withTimeout(1300L){
            //重复一个工作1000次
            repeat(1000){
                LogUtils.e("repeat running:$it")
                //等待500毫秒
                delay(500L)
            }
        }
    }
    
    //打印的日志如下:
    13:37:44:825    TAG_ZYF:	repeat running:0
    13:37:45:335    TAG_ZYF:	repeat running:1
    13:37:45:840    TAG_ZYF:	repeat running:2
    Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms

从上面打印的日志可以看出:在withTimeout中的代码块运行超过设置的时间的时候,就会抛出异常。这个异常是CancellationException的子类,我们在之前的代码中没有看到在协程取消之后的堆栈打印信息,是因为在被取消的协程中,CancellationException被认为是协程执行结束的正常原因。

由于取消只是一个例外,所有的资源都使用常用的方法来关闭。如果我们需要做一些各类使用超时的特别的额外操作,可以使用withTimeoutOrNull函数。并且把这些会超时的代码包装在try{...}catch(e: TimeoutCancellaionException){...}代码块中,而withTimeoutOrNull通过返回null来代替在超时的时候抛出异常,如下所示:

    /**
     * 此方法演示[withTimeoutOrNull]函数的使用
     */
    fun cancelCoroutine7() = runBlocking {
        val result = withTimeoutOrNull(1300L){
            repeat(10){
                LogUtils.e("repeat block running:$it")
                delay(100L)
            }
            "end"
        }
        LogUtils.e("runBlocking end:$result")
    }
    
    //打印的日志如下:
    13:53:21:084    TAG_ZYF:	repeat block running:0
    13:53:21:197    TAG_ZYF:	repeat block running:1
    13:53:21:306    TAG_ZYF:	repeat block running:2
    13:53:21:417    TAG_ZYF:	repeat block running:3
    13:53:21:526    TAG_ZYF:	repeat block running:4
    13:53:21:634    TAG_ZYF:	repeat block running:5
    13:53:21:744    TAG_ZYF:	repeat block running:6
    13:53:21:853    TAG_ZYF:	repeat block running:7
    13:53:21:963    TAG_ZYF:	repeat block running:8
    13:53:22:071    TAG_ZYF:	repeat block running:9
    13:53:22:179    TAG_ZYF:	runBlocking end:end

上面的代码执行过程中并没有超时,最终我们会获得正确的执行结果,而如果我们延迟循环中时间,将delay(100L)调整为delay(500L)的时候我们将会得到如下的输出:

13:56:59:898    TAG_ZYF:	repeat block running:0
13:57:00:413    TAG_ZYF:	repeat block running:1
13:57:00:929    TAG_ZYF:	repeat block running:2
13:57:01:197    TAG_ZYF:	runBlocking end:null

此时没有抛出异常,而是在超时的时候返回了null

根据上面所述,我们可以通过捕获withTimeout函数发生的异常来执行我们需要的操作,如下所示:

    /**
     * 这个方法用于演示通过捕获异常来防止[withTimeout]中的代码异常退出
     */
    fun cancelCoroutine8() = runBlocking {
        val result = try {
            withTimeout(1300L) {
                repeat(10) {
                    LogUtils.e("repeat running:$it")
                    delay(500L)
                }
                "end"
            }
        } catch (e: TimeoutCancellationException) {
            LogUtils.e("catch time out cancellation exception")
            "error end"
        }
        LogUtils.e("runBlocking end:$result")
    }
    
    //打印的日志如下:
    14:04:12:624    TAG_ZYF:	repeat running:0
    14:04:13:138    TAG_ZYF:	repeat running:1
    14:04:13:655    TAG_ZYF:	repeat running:2
    14:04:13:923    TAG_ZYF:	catch time out cancellation exception
    14:04:13:923    TAG_ZYF:	runBlocking end:error end