2.3-结构化并发:协程的结构化取消

线程的取消方式:强制取消 stop() 与交互式取消 interrupt()

强制取消:stop()

线程的取消可以调用 Thread 的 stop(),它可以将线程强制取消:

val thread = thread {
	println("Thread: I'm running!")
	Thread.sleep(200)
	println("Thread: I'm done!")
}
Thread.sleep(100)
thread.stop()

输出结果:
Thread: I'm running!

可以看到只打印了一句代码,后面的没再执行。

这种取消线程的方式已经被 Java 很早就废弃了,原因也很简单:取消方式太野蛮了!无论线程执行到哪行代码,在调用 stop 时都会强制将线程杀死。

可能线程的代码执行到一半就结束执行了,stop() 取消线程的方式会带来非常不可预期的结果,比如写文件、数据库等操作,直接结束会导致文件损坏,甚至把内存的对象都毁掉,程序的状态信息都错乱了,根本没法修复,只能重启程序才能修复。

交互式取消:interrupt()

为了线程结束的更安全更可靠,Java 提供了 interrupt() 交互式取消线程的方式。当然它安全可靠的地方在于它的机制。

val thread = object : Thread() {
	override fun run() {
		println("Thread: I'm running!")
		var count = 0
		while (true) {
			// 调用了 thread.interrupt() 后有两个判断方式:
			// isInterrupted():第一次调用时返回 true,第二次调用返回 false
			// isInterrupted:都为 true
			// 自行决定用哪种方式,一般比较常用 isInterrupted
			
			// 在合适的位置判断线程是否已中断
			if (isInterrupted) {
				println("Thread: I'm interrupted!")
				// 处理收尾清理工作
				return // 结束线程
			}
			count++
			if (count % 100_000_000 == 0) println(count)
			if (count % 1_000_000_000 == 0) break
		}
		println("Thread: I'm done!")
	}
}.apply { start() }
Thread.sleep(500)
thread.interrupt()

输出结果:
Thread: I'm running!
100000000
200000000
300000000
Thread: I'm interrupted!

调用 thread.interrupt() 是不会导致线程结束的,还需要在线程内部通过 isInterrupted 判断线程是否已经中断,然后在结束线程前做好收尾清理的工作。一般这个检查点会设置在线程内部执行耗时任务前

thread.interrupt() 可以认为它只是打了一个标记,告诉你线程可以结束了,但啥时候结束、怎么结束、是否要结束你可以在线程内部自己判断处理。

在线程处理中,有关休眠等待的操作比如 Thread.sleep(),即使在休眠状态也会立即抛出 InterruptedException 异常打断休眠:

 val thread = object: Thread() {
     override fun run() {
         println("Thread: I'm running!")
         try {
             // 休眠过程中调用 thread.interrupt()
             // sleep 会立即抛出 InterruptedException 打断休眠,告知要处理线程清理工作
             sleep(200)
         } catch (e: InterruptedException) {
             println("isInterrupted: $isInterrupted") // InterruptedException 会重置标记打印为 false
             println("Clearing...")
             return
         }
         println("Thread: I'm done!")
     }
 }
 thread.start()
 Thread.sleep(100)
 thread.interrupt()

输出结果:
Thread: I'm running!
isInterrupted: false
Clearing...

协程的交互式取消

理解了线程的交互式取消,协程的交互式取消其实也是一样的逻辑。

用下面的例子和线程的交互式取消做类比:

fun main() = runBlocking {
	val job = launch(Dispatchers.Default) {
		var count = 0
		while (true) {
			// !isActive 等同于 isInterrupted 标记位判断
			if (!isActive) {
				// 处理收尾清理工作
				// 协程会处理的特殊异常,会接住这个异常并把自己取消
				throw CancellationException()
			}
			count++
			if (count % 100_000_000 == 0) {
				println(count)
			}
			if (count % 1_000_000_000 == 0) {
				break
			}
		}
	}
	delay(1000)
	job.cancel() // 等同于 Thread.interrupt()
}

输出结果:
100000000
200000000
300000000

可以看到协程的交互式取消在写法上和线程是差不多的,只是协程的取消并不是用 return,而是需要抛出一个特殊的异常:CancellationException,这个特殊异常在协程抛出时会被接住并正常取消协程,而不会引起程序异常

协程其实还为我们提供了 [检查一下 isActive 是 false 就取消协程] 的逻辑,又不需要做清理工作,协程提供了一个专用的函数:ensureActive()

launch {
	// 实际上 scope.ensureActive() 就是用的下面两个方式的实现
	ensureActive()
	coroutineContext.ensureActive()
	coroutineContext.job.ensureActive()
}

Job.kt
public fun Job.ensureActive(): Unit {
	if (!isActive) throw getCancellationException()
}

在一些项目开发中我们可能会遇到直接调用 job.cancel() 协程就被取消的场景:

fun main() = runBlocking {
	val job = launch(Dispatchers.Default) {
		var count = 0
		while (true) {
			println("count: ${count++}")
			delay(500) // 加了 delay 函数
		}
	}
	delay(1000)
	job.cancel()
}

输出结果:
count: 0
count: 1
count: 2

上面的代码就是每 500ms 打印一次,延时 1s 后调用 job.cancel(),发现打印暂停协程被取消了。

为什么上面的代码不需要做交互式取消?

我们在讲线程的时候有提到,Thread.sleep() 即使在休眠状态,调用 Thread.interrupt() 也会立即打断休眠并抛出 InterruptedException,我们会在 Thread.sleep() 加上 try-catch 捕获这个异常然后处理结束线程的工作。

同理的协程调用 job.cancel(),也会引起 delay() 抛出 CancellationException 异常取消协程,只是相比线程我们不用自己处理取消协程的操作,只需要抛个异常就帮我们做好了

协程里几乎所有的挂起函数(包括等待型函数例如 delay)都会抛 CancellationException,除了 suspendCoroutine 不支持取消

我们可以验证一下:

fun main() = runBlocking {
	val job = launch(Dispatchers.Default) {
		var count = 0
		while (true) {
			println("count: ${count++}")
			try {
				delay(500) 
			} catch (e: CancellationException) {
				println("Cancelled")
			}
		}
	}
	delay(1000)
	job.cancel()
}

输出结果:
// 协程没有被结束不断打印
// 并且因为协程 isActive = false,delay 没有生效会立即抛出异常打印 Cancelled
fun main() = runBlocking {
	val job = launch(Dispatchers.Default) {
		var count = 0
		while (true) {
			println("count: ${count++}")
			try {
				delay(500) 
			} catch (e: CancellationException) {
				println("Cancelled")
				// Clear
				throw e
			}
			// 如果不需要捕获异常做额外处理,也可以用 try-finally
			//try {
			//	delay(500)
			//} finally {
			//	// Clear
			//}
		}
	}
	delay(1000)
	job.cancel()
}

在协程里不光是不用像线程里一样写 try-catch,而且写 try-catch 反而还可能导致问题,所以要小心谨慎,特别是如果 try-catch 的是 CancellationException,要记得重新把它抛出来

协程的结构化取消

为了更好的理解协程的结构化取消,在这里提出一个疑问:子协程能拒绝父协程的取消吗?

所谓的协程结构化取消,就是取消父协程时它会自动把所有子协程也取消掉

每个父 Job 都包含了它的每个子 Job 的 cancel() 调用,这样就会把协程往下的协程树全部取消,即父协程的 cancel() 会触发所有子协程的 cancel()

fun main() = runBlocking {
	val scope = CoroutineScope(EmptyCoroutineContext)
	val parentJob = scope.launch {
		launch {
			println("Child job started")
			delay(3000)
			println("Child job finished")
		}
	}
	delay(1000)
	parentJob.cancel()
	// 这里加个延时是因为 scope.launch 不是父协程 runBlocking 的子协程
	// 不加延时的话就会导致 runBlocking 这个协程执行完了,因为没有子协程它就结束了
	// runBlocking 结束了也让 main 函数也结束了,scope.launch 就没法正常执行了
	// 这里仅测试代码
	delay(10000) 
}

输出结果:
Child job started // 只打印了一行,子协程被取消了

调用 job.cancel() 的整体过程:

  • Job 在 cancel() 里面修改自己的 isActive 变成 false,作为一个父协程还会调用所有子协程 Job 的 cancel(),也会修改子协程 isActive 变成 false;由于 isActive 状态被改变,在检查点协程各自会抛出 CancellationException 异常

  • 协程什么时候抛 CancellationException 是不确定的,因为协程代码要执行到检查点检查 isActive 时才会抛出异常。并且每个协程在抛 CancellationException 之后,还会产生跟对自己调用 cancel() 一样的效果去修改 isActive 变成 false,以及调用所有子 Job 的 cancel()

cancel() 做过的事情为什么 CancellationException 还要做一次?主要是为了让你可以主动通过抛异常来自我取消,让我们在写代码时只需要抛出 CancellationException 就能取消协程。

协程取消有两种方式

  • 外界调用 job.cancel()

  • 协程内部抛 CancellationException

一个协程取消过程具体有以下三件事

  • Job 的 isActive 从 true 变成 false

  • 父协程调用所有子协程的 cancel(),子协程的 isActive 也会从 true 变成 false

  • 在自己的代码块检查 isActive 状态,并在发现它变成 false 之后抛 CancellationException

子协程可以拒绝取消吗?实际上是在问:能不能让协程取消时这三个效果不出现?

可以发现 子协程是无法拒绝父协程的取消的,顶多就在检查 isActive 为 false 后抛异常前做下清理工作,但最终还是得取消

或许你会说:我可以用 try-catch 捕获 CancellationException,让协程不取消强制继续执行。在协程用 try-catch 捕获 CancellationException 的风险是很大的,协程状态都改变了,导致程序后面还有挂起函数都得用 try-catch,不推荐这种做法

fun main() = runBlocking {
	val scope = CoroutineScope(EmptyCoroutineContext)
	val parentJob = scope.launch {
		launch {
			println("Child job started")
			// Thread.sleep(3000)
			delay(3000)
			println("Child job finished")
		}
	}
	delay(1000)
	parentJob.cancel()
	measureTime { parentJob.join() }.also { println("Duration: $it") }
	delay(10000) 
}

输出结果:
// 配合取消
Child job started
Duration: 311.791us

// 不配合取消
Child job started
Child job finished
Duration: 1.990989375s // 子协程拖住父协程执行完大概 2s

不配合取消 NonCancellable

在上面我们都是讲解的结构化相关的内容,协程其实还有一个既不交互式也不结构化的特例:NonCancellable。

为了更好的了解 NonCancellable,我们先用一个例子来说明怎么使用,然后再讲解它的原理。

fun main() = runBlocking {
	val scope = CoroutineScope(EmptyCoroutineContext)
	val parentJob = scope.launch {
		// 子协程加上 NonCancellable
		// 父协程被取消了,子协程不会被取消
		launch(NonCancellable) {
			println("Child started")
			delay(3000)
			println("Child stopped")
		}	
		println("Parent started")
		delay(3000)
		println("Parent stopped")
	}
	delay(1500)
	parentJob.cancel()
	delay(10000)
}

输出结果:
Parent started
Child started
Child stopped // 父协程取消了子协程还是有打印执行完成

那么如果我们直接取消子协程,会能取消吗?看下面的效果:

fun main() = runBlocking {
	val scope = CoroutineScope(EmptyCoroutineContext)
	var childJob: Job? = null
	scope.launch {
		childJob = launch(NonCancellable) {
			println("Child started")
			delay(3000)
			println("Child stopped")
		}	
		println("Parent started")
		delay(3000)
		println("Parent stopped")
	}
	delay(1500)
	childJob?.cancel()
	delay(10000)
}

输出结果:
Parent started
Child started
Parent stopped // 没有打印子协程,被取消了

可以看到直接用子协程的 Job 调用 cancel() 是可以被取消的。

在日常的开发中,我们都不要轻易的解绑协程之间的关系(一般代指的父子协程的关系),因为解除了绑定就会导致父协程在取消时没法带上子协程一起取消。

比如给子协程单独提供一个 Job 作为父协程,此时就只能是子协程自身或单独提供的父 Job 调用 cancel() 才能取消它:

fun main() = runBlocking {
	val scope = CoroutineScope(EmptyCoroutineContext)
	var childJob: Job? = null
	val parentJob = scope.launch {
		launch {
			println("Child started")
			delay(3000)
			println("Child stopped")
		}	
		// 该协程此时是 customJob 的子协程
		// 即使 parentJob 已经取消,该协程还是继续运行
		val customJob = Job()
		childJob = launch(customJob) {
			println("Child started2")
			delay(3000)
			println("Child stopped2")		
		}
		println("Parent started")
		delay(3000)
		println("Parent stopped")
	}
	delay(1500)
	parentJob.cancel()
	// childJob?.cancel()
	delay(10000)
}

输出结果:
// parentJob.cancel()
Child started
Parent started
Child started2
Child stopped2 // 没有被取消

// childJob?.cancel()
Child started
Parent started
Child started2
Child stopped
Parent stopped
// Child stopped2 没有打印

那么 NonCancellable 是怎么阻断取消链条的呢?实际上 NonCancellable 也是一个 Job:

NonCancellable.kt

// NonCancellable 也是一个 Job,只是它是一个单例的 Job
public object NonCancellable : AbstractCoroutineContextElement(Job), Job {
}

所以 NonCancellable 就是一个 [不让内部协程被外部取消] Job,通过单独提供的 Job 切断子协程和父协程的关系

为了让它的作用更纯粹不把 NonCancellable 当成一般的 Job 使用,只为了用于阻断取消链条,NonCancellable 的 cancel() 也是空实现,不能取消子协程,子协程也拿不到它:

NonCancellable.kt

@Deprecated(level = DeprecationLevel.WARNING, message = message)
override fun cancel(cause: CancellationException?) {}

fun main() = runBlocking {
	val scope = CoroutineScope(EmptyCoroutineContext)
	scope.launch {
		val childJob = launch(NonCancellable) {
			println("Child started")
			delay(3000)
			println("Child stopped")
		}	
		println("childJob parent: ${childJob.parent}")
	}
	delay(10000)
}

输出结果:
childJob parent: null

NonCancellable 的使用场景就是让内部协程不被取消,通常是结合 withContext 使用,不是在 launch 或 async 使用的

在这里插入图片描述

当然你如果清楚自己的使用场景就是要使用 launch 或 async 设置 NonCancellable,也不会有副作用,只是不能被结构化取消而已。

不希望被取消的一般是三类事情

  • 取消前的收尾清理工作:协程被调用 cancel() 后真正退出执行之前的工作,在处理清理工作时不希望其他挂起函数中断收尾清理工作
fun main() = runBlocking {
	val scope = CoroutineScope(EmptyCoroutineScope)
	scope.launch {
		if (!isActive) {
			// 协程已经是非活跃状态,交互式取消协程前做清理工作
			// 如果没有 NonCancellable,做清理工作时遇到挂起函数就会直接打断取消
			// 这不是我们预期想要的效果
			// 如果清理工作没有挂起函数,可以不用 withContext(NonCancellable)
			withContext(NonCancellable) {
				// 挂起函数
				// delay(1000)
			}
			throw CancellationException()
		}
	}
	delay(10000)
}
  • 不好收尾的业务工作:比如写文件、数据库等不想被中途停止,写入的东西还要撤销就是很麻烦的事情
suspend fun writeInfo() = withContext(Dispatchers.IO + NonCancellable) {
	// 假设 readFromDatabase 是一个挂起函数
	// 没有 NonCancellable 时所在的协程取消,它就会抛出异常,导致无法继续执行 writeDataToFile()
	writeToFile()
	readFromDatabase()
	writeDataToFile()
}
  • 跟当前业务无关的其他工作:比如写日志,协程的任务被取消了,日志还是会需要记录的,这种场景就是用 launch(NonCancellable) 来处理

总结

一、线程与协程的交互式取消

1、线程的交互式取消:

  • 调用 thread.interrupt() 标记线程结束

  • 在线程内部配合使用 isInterrupted 判断线程是否已经中断

  • 在结束线程前做好收尾清理的工作,最后 return 结束线程

一般 isInterrupted 会设置在线程内部执行耗时任务前

2、在线程处理中,有关休眠等待的操作比如 Thread.sleep(),即使在休眠状态也会立即抛出 InterruptedException 异常打断休眠

3、协程的交互式取消

  • 调用 job.cancel() 标记要取消协程

  • 在协程内部配合使用 isActive 判断是否为 false 协程已经处于非活跃状态

  • 在取消协程前做好收尾清理工作,最后抛出 CancellationException 取消协程

CancellationException,这个特殊异常在协程抛出时会被接住并正常取消协程,而不会引起程序异常

4、协程调用 job.cancel() 也会引起 delay() 抛出 CancellationException 异常取消协程,只是相比线程我们不用自己处理取消协程的操作,只需要抛个异常就帮我们做好了

5、协程里几乎所有的挂起函数(包括等待型函数例如 delay)都会抛 CancellationException,除了 suspendCoroutine 不支持取消

二、协程的结构化取消

所谓的协程结构化取消,就是取消父协程时它会自动把所有子协程也取消掉

协程取消有两种方式:

  • 外界调用 job.cancel()

  • 协程内部抛 CancellationException

一个协程取消过程具体有以下三件事:

  • Job 的 isActive 从 true 变成 false

  • 父协程调用所有子协程的 cancel(),子协程的 isActive 也会从 true 变成 false

  • 在自己的代码块检查 isActive 状态,并在发现它变成 false 之后抛 CancellationException

有绑定关系的父子协程,子协程不能拒绝父协程的取消

三、NonCancellable

NonCancellable 就是一个 [不让内部协程被外部取消] Job,通过单独提供的 Job 切断子协程和父协程的关系

NonCancellable 的使用场景就是让内部协程不被取消,通常是结合 withContext 使用,不是在 launch 或 async 使用的

使用 NonCancellable 的三种场景:

  • 取消前的收尾清理工作

  • 不好收尾的业务工作

  • 跟当前业务无关的其他工作

  • 15
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值