协程用久了,你会发现一个很有意思的现象:很多人写同步代码时异常处理头头是道,一进协程就开始放飞自我,要么异常被静默吞掉,要么直接击穿整个事件循环,日志里留下一堆看不懂的堆栈。这篇文章不聊概念,直接聊协程里“异常到底是怎么从源头走到处理者”这一整条链路,以及在不同语言、不同异步框架下怎么把错误处理做得干净利落。
1. 协程异常与同步异常的底层差异
1.1 为什么协程里的异常“不听话”
先看最基础的问题:在普通函数里写try/catch,异常是沿着调用栈一路往上抛的,逻辑非常直观。但协程不一样,协程的挂起和恢复本质上已经把“调用栈”这个模型打破了。一个协程可能在 A 线程启动,在 B 线程恢复,异常抛出的位置和捕获的位置往往隔着好几个调度器层级,中间还可能经历了任务队列的转手、IO 回调的触发、超时定时器的介入。
举个例子,你在 Python 里用asyncio.create_task创建一个后台任务,这个任务里抛了异常,除非你在任务对象上显式调用await task或者给任务加add_done_callback,否则这个异常会一直“挂”在任务对象内部,等你最后去取结果时才重新抛出来。最要命的是,如果你的任务没有保留引用,GC 回收任务对象时甚至可能打出Task exception was never retrieved的警告,异常就无声无息地没了。这不是 Python 特有的问题,Kotlin、JavaScript 里都有类似的机制,只是表现形式不同。
把这个机制想明白以后,你就会意识到:协程异常处理的关键不在于“怎么捕获”,而在于“异常从哪来、会传到哪去、谁负责接收”。只有搞清楚整条链路,你才知道该在哪里下手。
1.2 从同步思维切换到异步思维
大多数人在协程里踩坑,本质是还带着同步代码的处理习惯。同步代码里异常沿着栈一路向上,最终会被某个try/catch接住;协程里却没有这个“一路向上”的保证。相对应的,协程里的异常更像一个“包裹”——你把它交给某个执行体(任务、协程作用域、异步流),由执行体来决定是投递给上级、暂时存储在内部,还是直接丢弃。
我在实际项目里总结过一个简单的心法:同步代码你问“谁调用我”,协程代码你问“谁管理我的生命周期”。前者是调用关系,后者是所有权关系。一旦你明确了某个协程的执行体归属,异常处理的落点就自然清晰了。比如asyncio.Task的所有者是你自己,那你就必须负责await它;asyncio.gather里的协程归 gather 管理,那 gather 负责聚合所有异常;async for消费的异步生成器归消费者管理,那消费者就要处理生成器内部抛出的一切。
2. 异步流中异常传递的完整链路
2.1 谁在收集异常:调度器与任务管理的分工
讨论异常传递,先要理解协程背后那个“搬运工”是谁。在主流框架里,通常是事件循环(Event Loop)或者协程调度器负责协程的创建、唤醒和销毁。当一个协程内部抛出未捕获异常时,事件的走向是这样的:
- 协程内部向上抛出,但因为没有同步栈可走,异常被交回给调度器。
- 调度器根据协程的状态决定异常去向。如果是
Task类型,异常往往被塞进Task内部字段,等待外部获取;如果是gather这类聚合操作,异常被收集到返回值容器中;如果是 fire-and-forget 型协程,异常只能打日志。 - 如果没有任何接收方,框架就会执行兜底逻辑:Python 打
Task exception was never retrieved,Kotlin 走CoroutineExceptionHandler,Java 的虚拟线程则进入未捕获异常处理逻辑。
从这个流程能看到一个关键点:异常不是“消失”的,而是你要主动界定谁拥有它的接收权。代码里最常见的问题就是——协程被无脑创建,任务引用没有保存,也没有接收异常,最后只能指望日志兜底,一旦日志框架配置不对,错误信息就真的石沉大海了。
2.2 异步流中异常的三种传播方式
异步流(Async Stream)是协程生态里一个很重要的应用形态,典型代表包括 Python 的异步生成器、Kotlin 的 Flow、JavaScript 的 ReadableStream。异步流里的异常传播主要有三种方式,理解它们对排查问题帮助极大。
第一种是生产者内部抛出。比如异步生成器从数据库读数据读到一半,数据库连接断了,这个异常会从生成器内部抛出来。此时消费者如果用async for,异常会直接抛给消费者的循环,消费者不捕获则继续向上。
第二种是消费者侧处理异常。在 Kotlin Flow 里,你可以用catch操作符直接在流链中捕获异常。由于 Flow 的网络结构,异常不一定来自生产者,也可能来自中间操作符(map、filter 等)。Kotlin 的catch会捕获其上游的所有异常,这就给开发者留了一个非常灵活的组装空间。
第三种是传输过程中的复合异常。多个协程并发执行时,异常会被聚合。Python 的asyncio.gather默认是第一个异常直接抛出,用return_exceptions=True时则是把异常对象作为元素返回;Kotlin 的supervisorScope与coroutineScope在异常处理语义上也有本质区别,coroutineScope里任何一个子协程异常都会导致整个作用域取消,supervisorScope则会隔离子协程的失败。
2.3 一个完整的异常传递链路实例
用一个实际场景把上面的链路串起来。假设你写了一个实时数据处理管道,流程是这样的:
- 用
async for从一个异步队列中批量读取消息; - 每条消息经过 decode、业务校验、落库三个步骤;
- 落库失败时,希望记录日志并继续处理下一条,而不是让整个消费者退出。
在这个场景下,异常传递的链路是“消息队列消费端 → 业务处理函数 → 数据库写入协程”。每一步都可能有异常:
- 队列读取超时或中断,属于生产端异常;
- 业务处理里的脏数据属于自己的逻辑异常;
- 数据库写入超时、连接池耗尽,属于依赖服务的异常。
如果你只是给整个async for包一个大try/catch,一旦中间某条消息处理失败,整个消费循环都会结束,消息队列里等待的消息全部阻塞。在项目里遇到这类问题时,我常用的策略是:链路分三层拦截,第一层在单条消息处理函数内部捕获业务异常,第二层在异步生成器外层捕获流本身的异常,第三层在最外层的任务层面捕获崩溃级异常,保证消费循环永不退出,同时让异常能逐层向上留痕。
经验之谈:协程异常处理好坏的分水岭,往往不是谁捕获得更全面,而是谁能否在“继续运行”和“快速失败”之间做出准确取舍。适合快速失败的场景你硬要兜底,反而会掩盖系统性的故障。
3. 核心实操:不同语言下的优雅捕获方案
3.1 通用原则:先规划异常边界,再写代码
在展开具体的语言方案之前,有一个通用原则必须强调:异常边界必须在编码之前规划好。边界指什么?指你划分的正常流程和异常流程的分界点。一个协程函数的内部应该分层、分块定义哪些异常由自己消化,哪些异常必须抛给上层。我见过很多项目,协程函数里全是try/catch包着所有的代码,结果异常全部被吞,排查问题时无从下手。
合理的做法是给每个协程函数定义清晰的异常契约,包括四类:可恢复异常(继续执行)、可重试异常(延迟重试)、可容忍异常(记录后跳过)、致命异常(必须终止)。这四类异常在代码里的处理方式完全不同。可恢复异常通常在函数内部处理,可重试异常往往依赖重试器,可容忍异常记录日志并返回默认值,致命异常则要原样向上抛并且让调用方感知。
3.2 Python 协程的异常处理实践
Python 的 asyncio 是生态最成熟也是坑最多的协程框架之一。我在项目里总结的 Python 协程异常处理方案大致有这么几条路径:
路径一:使用asyncio.gather聚合并发异常。
这是最常用的并发方案。直接写await asyncio.gather(task1, task2, task3)时,只要其中任意一个协程抛出异常,gather 就会立刻向上抛,其他协程不会取消,但返回值也拿不到了。想让多个协程都跑完再统一处理异常,就设return_exceptions=True。我在实际项目中通常这样封装:
import asyncio async def run_parallel(tasks): results = await asyncio.gather(*tasks, return_exceptions=True) errors = [r for r in results if isinstance(r, Exception)] successes = [r for r in results if not isinstance(r, Exception)] if errors: logger.error(f"parallel task finished with {len(errors)} errors") return successes, errors这样所有协程都能完整跑完,成功结果和异常结果分开返回,逻辑非常清晰。
路径二:用asyncio.wait精细控制任务状态。
如果你需要区分任务的不同终态(FINISHED、PENDING、CANCELLED),asyncio.wait比 gather 更灵活。但要注意,asyncio.wait不会自动抛出异常,你必须拿到任务对象后逐个调用task.exception()或者重新await task来触发异常抛出。
路径三:给任务挂回调,让异常“有去有回”。
当协程任务的引用很容易丢失时,我会在创建任务的同时挂一个done_callback:
def handle_result(task): try: result = task.result() # 这里会重新引发任务内部的异常 # 正常处理返回值 except asyncio.CancelledError: pass except Exception as exc: logger.exception("task failed: %s", exc) async def create_tracked_task(coro): task = asyncio.create_task(coro) task.add_done_callback(handle_result) return task这个方案适合“任务框起来不管结果”的场景,至少保证异常会被记录。不过我还是要提醒一句:如果任务里有很强的失败重试诉求,不要靠回调来做,去把重试逻辑放在协程内部。
路径四:异步生成器中的异常处理。
异步生成器的异常处理是很多人忽略的细节。async for循环内部消费生成器时,如果生成器抛出异常,异常会顺着循环传播。但有一个很容易踩的坑是——如果你没有用aclose()正确关闭异步生成器,生成器内部因为异常退出后残留的资源可能不会被立即清理。项目里我会习惯性用try/finally或者async with包住生成器生命周期。
async def safe_consume(agen): try: async for item in agen: process(item) except Exception as exc: logger.error("consumer stopped by exception", exc_info=exc) finally: await agen.aclose()3.3 Kotlin 协程的异常处理实践
Kotlin 协程的异常语义与 Python 有着本质区别,结构性并发是它最鲜明的特色。用 Kotlin 时你该关心的问题是“作用域”,而不是“任务”。
coroutineScope与supervisorScope的差别在处理异常时非常关键。coroutineScope里任何一个子协程抛出未捕获异常,整个作用域会被取消,其他子协程也会被取消。这在某些场景下是你想要的——比如多个子任务共同构建一个响应,任何一个失败都意味着整个响应不完整;但也是坑——如果你在互相独立的任务上误用了coroutineScope,一个失败会连坐其他任务。supervisorScope则专门解决这个问题,它隔离子协程的失败,一个子协程异常不会影响其他兄弟协程。
Kotlin 的协程异常处理器CoroutineExceptionHandler只在launch方式启动的协程中生效,对async启动的协程无效,后者要拿await来获取异常。这是我见过 Kotlin 新手上手时最容易踩的坑:
launch协程:异常默认传递给父作用域,可以用CoroutineExceptionHandler拦截。async协程:异常作为Result的一部分,等你await时抛出。
需要特别注意的一点:Kotlin 协程在处理CancellationException上有特殊逻辑。取消异常是“正常退出”,它不是业务错误,不需要你捕获,更不应该在catch (e: Exception)里把它吞掉,否则会导致协程协作式取消机制被破坏。我见过有人在 catch 里顺手 catch 了所有异常,结果协程的取消操作永远无法完成,整个作用域卡死。项目里我习惯把取消异常的 catch 单独拎出来。
fun CoroutineScope.safeLaunch(block: suspend CoroutineScope.() -> Unit) { launch { try { block() } catch (e: CancellationException) { throw e // 重新抛出,保证取消语义 } catch (e: Exception) { logger.error("coroutine failed", e) } } }Kotlin Flow 的异常处理则是另一套体系。Flow 链路上可以用catch操作符捕获上游异常,但catch不会捕获下游(collect 端)的异常。如果你想把 collect 端的异常也统一处理,要在 collect 外层包try/catch,或者用onCompletion来观察流的结束状态。
3.4 JavaScript 异步流异常处理实践
JavaScript 的 async/await 异常处理表面上跟同步代码很像,其实暗藏玄机。在处理异步迭代时,for await...of循环里抛出的异常需要在循环外层捕获,但这里有个很微妙的问题——异步迭代器内部如果有多个阶段,异常出现在不同阶段时行为完全不同。
JavaScript 的 Promise 异常链有一个特性是“自动传递”:async函数内部无论哪个await抛出异常,都会统一变成外层 Promise 的 rejection。这带来的好处是异常处理很简单,坏处是你容易忽略“Promise 还没有被消费时异常就发生了”的问题。一个典型的坑是:void someAsyncFunc()这种 fire-and-forget 调用,如果函数内部抛出了异常,会产生一个未被处理的 Promise rejection,在 Node.js 里触发unhandledRejection,导致进程崩溃(取决于 Node 版本和启动参数)。
项目里我用过的方案是在入口处统一挂unhandledRejection和uncaughtException的监听器做兜底,同时在业务代码里坚持“异步函数必须有明确调用方”的原则,禁止从事件回调里裸调 async 函数。
process.on('unhandledRejection', (reason, promise) => { logger.error('Unhandled Rejection at:', promise, 'reason:', reason); });Node.js 的异步迭代(readable stream 消费等场景)同样要小心。用for await...of消费流时,流的error事件和循环体内抛出的异常是完全不同的两条路径。error事件触发的异常不会自动抛给循环,而是先触发流的 error 事件,如果你没有监听 error 事件,Node 默认行为在某些版本里会让进程崩溃。因此消费流时我会同时保留stream.on('error')监听,并让循环体内部的业务异常自行处理。
3.5 PHP 协程实现中的错误处理思路
很多人觉得 PHP 跟协程不沾边,其实 Swoole、Amphp 这些扩展和框架早就把协程能力带进了 PHP 生态。PHP 协程的错误处理有一个独有的复杂度:PHP 传统上依赖try/catch/finally处理异常,但协程调度会让异常在不同协程间跨栈传递,这就涉及一个基本问题——PHP 的异常对象本身是同步的,协程调度器必须自行维护异常投递的路径。
在 Swoole 项目中处理协程异常,我总结的经验是:别指望框架替你做全局兜底,必须对每个协程入口做边界拦截。常见的做法是在创建协程的函数外封装一层:
function createSafeCoroutine(callable $fn): void { go(function () use ($fn) { try { $fn(); } catch (\Throwable $e) { logger()->error('coroutine failed', [ 'exception' => $e, 'trace' => $e->getTraceAsString(), ]); } }); }同时要把set_exception_handler和register_shutdown_function配好,作为最后的兜底防线。PHP 的协程还容易有一个问题:协程内的数据库连接、Redis 连接等资源如果不主动释放,协程结束时不一定会立刻回收。异常发生时,连接资源更有可能停留在不可用状态,所以异常处理逻辑里要顺手做资源清理。
4. 场景化错误处理:异步流中的配套策略
4.1 超时控制是异常处理的第一道防线
异步流里最容易被低估的异常来源不是业务异常,而是等待——等待网络响应、等待队列消息、等待锁释放。协程的特点是挂起占用的资源极少,所以一个协程可能等一个永远不会返回的 IO 等上几个小时。超时控制本质上就是一个“人为制造的异常”:超过阈值直接抛出TimeoutError,然后走异常处理链路。
Python 里处理超时最干净的工具是asyncio.wait_for。它能给任意协程加超时,超时之后协程会被取消,并抛出TimeoutError。Kotlin 里有withTimeout和withTimeoutOrNull,区别在于前者超时抛出TimeoutCancellationException,后者超时返回null。在 JavaScript 里通常用Promise.race或者AbortController来模拟超时。
在设计超时阈值时,有一条经验值得参考:超时时间最好是目标服务 P99 响应时间的 2-3 倍,而不是 P50 响应时间的倍数。因为 P50 对应的超时太紧,稍微波动就会误杀正常请求。我在做数据库查询超时控制时,还会把超时做成可配置项,不同环境(开发、测试、生产)用不同的阈值,避免生产环境超时压力之下,开发环境因日志噪音被淹没。
4.2 重试策略:什么时候可以重试,什么情况下必须放弃
异常处理中有不少“可重试异常”,比如网络超时的瞬时故障、数据库死锁冲突、服务端返回 503。对这些场景,正确的做法不是立刻把异常抛给上层,而是先进入重试循环。
重试有一套基本逻辑:指数退避 + 抖动(jitter)。指数退避保证每次重试的间隔是前一次的两倍,抖动避免多个协程同时重试造成踩踏效应。Python 里可以自己封装一个重试器,用asyncio.sleep做退避:
async def with_retry(coro_factory, retries=3, base_delay=0.5): delay = base_delay for attempt in range(retries): try: return await coro_factory() except TransientError as exc: if attempt == retries - 1: raise await asyncio.sleep(delay + random.uniform(0, delay)) delay *= 2这里要特别小心一个决策点:哪些异常值得重试?我的判断标准很简单——只有那些“服务端可能已经收到请求但响应没到”的异常,以及在短暂恢复后成功的概率很高的异常,才适合自动重试。像业务校验失败、权限不足这类异常,重试多少次都不会成功,直接抛出去反而更好。
4.3 熔断与降级:异常数量超过阈值时主动“认输”
异步流还有一个常见困境:某个下游服务已经持续异常,但同时还有大量协程在疯狂地尝试调用它,雪上加霜。这时候异常处理无法靠单个协程解决,需要的是一个全局的熔断器。
熔断器的核心逻辑是:维护一个连续失败的计数器,达到阈值后熔断器打开,后续请求快速失败(降级返回值),不再真实地下游调用。每隔一段时间允许一个探测请求通过,下游恢复则关闭熔断器,反之继续熔断。我用过的简单实现思路如下:
class CircuitBreaker: def __init__(self, failure_threshold=5, recovery_timeout=30): self.failure_threshold = failure_threshold self.recovery_timeout = recovery_timeout self.failure_count = 0 self.last_failure_time = None async def call(self, coro): if self._is_open(): raise CircuitOpenError() try: result = await coro() self._on_success() return result except Exception as exc: self._on_failure() raise在协程生态里,熔断器的异常处理视角是“主动放弃”:与其让大量协程都在超时和失败中挣扎,不如快速返回一个默认值或者错误码,把异常控制在一个局部范围内。这个策略在消息队列消费者场景中特别有用——消费者可以继续批量消费,但有问题的消息直接进入死信队列,而不是每次都消耗重试和等待资源。
4.4 结构化日志与上下文追踪:让异常不再是孤岛
处理协程异常时有一个比捕获本身更麻烦的问题:可观测性。协程崩溃了,你能在日志里看到异常栈,但强烈建议让日志里包含上下文信息——是哪个业务的请求导致了这个协程,处理的是哪条消息,位于哪条异步流的哪个步骤。没有这些上下文,异常栈只是一堆无意义的函数名。
实践中我很少直接用全局 logger,而是倾向于给每个协程任务创建一个带有上下文的日志器。在 Python 里可以用contextvars来保存请求 ID、业务类型等上下文信息,在执行协程时自动打印:
import contextvars request_context = contextvars.ContextVar("request_context", default={}) def log_error(msg, exc): ctx = request_context.get() logger.error(f"[{ctx.get('request_id', '-')}] {msg}", exc_info=exc)有了上下文,配合链路追踪工具(如 OpenTelemetry),你就能把异常事件串联成一条完整的调用链路。对异步流这种跨越多个协程、多个阶段的执行模型来说,链路追踪不是可选优化,而是保证可维护性的必要设施。
5. 常见问题与排查技巧实录
5.1 异常被“静默吞掉”了,去哪里找?
这是协程开发中最常见的故障现象:功能看起来正常,日志里干干净净,但你知道某个流程肯定出了问题。
排查思路是这样的:
- 先确认是否创建了大量无引用的 Task。Python 里每条
asyncio.create_task产生的任务对象都要被引用,否则任务可能被 GC 回收,异常也随之消失。 - 检查代码里是否存在“空的 except”。一个只记录
logger.error("error")却不打印异常对象的 catch 块,等价于没有处理。排查时直接搜索except:和except Exception as e:这种空壳。 - 确认全局异常钩子是否配置。Python 的
loop.set_exception_handler、Node 的unhandledRejection都能提供异常最后一道防线。
5.2 协程泄漏:看似并发数正常,实际任务越积越多
协程泄漏的典型症状是:内存占用缓慢上升,上下文切换频率越来越高,延迟越来越大,但并发监控看起来在正常范围。排查时我的第一步永远是看“是否有协程永远不会结束”。
在 Python 里可以用asyncio.all_tasks()打印所有尚未完成的任务,观察堆栈:
tasks = [t for t in asyncio.all_tasks() if not t.done()] for t in tasks: t.print_stack()绝大多数协程泄漏的根因是异常处理不当导致挂起点没有正确退出——比如在finally之外用了await queue.get(),异常打断了队列消费流程但 queue 里还有任务等着;再比如一个协程内部抛异常后没有正确关闭数据库连接,连接池被占满,所有后续协程都在等待获取连接。
5.3 超时与取消的混淆:为什么任务取消了还报错
很多人分不清TimeoutError(超时)和CancelledError(取消)的区别。超时是“等待超过了预期时间”,取消是“主动放弃了这次执行”。在 Python 里,asyncio.wait_for超时后会先取消内部协程,再抛出TimeoutError,所以你在 catch 到TimeoutError时,内部协程其实已经被取消了。这就会导致一个连锁问题:如果内部协程里有try/finally且 finally 里又用了其他 await 操作,取消过程中会再次抛CancelledError,掩盖原来的超时异常。
这里分享一个我自己的习惯:在业务协程里统一处理取消信号时,用shield保护需要执行完毕的清理逻辑:
async def clean_up(): # 关闭连接、写日志等兜底操作 pass async def business_op(): try: return await do_work() finally: await asyncio.shield(clean_up())shield的作用是防止清理逻辑被取消信号打断,但不影响外部超时取消核心业务逻辑。使用场景有限,但每次用到都是关键时刻。
5.4 日志堆栈看不清:单行日志找不到问题根因
协程调度切换会导致异常堆栈只打印最后一个挂起点,之前的调用链信息都丢失了。排查这种问题时,我会把异常的完整链条打印出来。Python 3.11 以后引入的ExceptionGroup能在一个异常对象里同时携带多个子异常,这正好契合协程并发场景——多个子任务各自失败时,用except*分别处理不同类型的子异常。Kotlin 里同样引入了异常的“suppressed”机制。
实际排查中还有一个朴素但有效的技巧:给每个协程的入口打点,把协程的入参和启动位置记录下来。日志系统里看到异常时,可以直接反查协程的上下文,快速定位问题来源,不需要逐层翻堆栈。
def trace_coro(coro): async def wrapper(*args, **kwargs): logger.debug(f"start coroutine from {inspect.currentframe().f_back.f_code.co_name}") return await coro(*args, **kwargs) return wrapper排查技巧速查表如下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 异常无日志 | 任务无引用、空catch、循环吞异常 | 全局hook + 查空except + all_tasks排查 |
| 协程越积越多 | 挂起点未正确退出、资源连接池耗尽 | 打印task堆栈、查看连接池状态 |
| 超时有但业务无感 | 超时异常被业务catch吞掉 | 排查catch链、区分超时与取消 |
| 日志无上下文 | 缺少链路ID传递 | 引入contextvars或类似机制 |
| 取消后状态不一致 | CancellationError被吞、清理乱序 | shield保护清理逻辑、重新抛出取消异常 |
| 聚合任务部分失败 | gather语义理解不清 | 明确使用return_exceptions,拆分异常处理 |
| 流消费中断 | 生成器异常未捕获 | 外层try/catch + aclose兜底 |
| 熔断不生效 | 全局计数器状态管理缺失 | 熔断器组件化、状态原子更新 |
6. 避坑清单与设计原则
写到这里,最后补充几条我在多个项目中反复验证过的设计原则,每一条都对应着至少一次真实的踩坑记录。
第一条:协程函数的异常契约必须写在函数文档里。一个协程函数可能被多个调用方消费,有些调用方需要异常继续向上,有些需要异常被转换。如果你不把“这个函数会抛哪些异常、哪些异常被内部消化”讲清楚,调用方就只能猜,猜的代价就是异常处理逻辑散落在各个角落,无法统一治理。
第二条:永远保持取消异常的原始语义。无论使用哪种语言,CancellationException都不是普通异常,它是协程协作式取消机制的一部分。任何形式的把CancellationException吞掉的行为都会导致协程无法正确响应取消信号,进而引发任务不结束、资源不释放、调度器状态错乱等问题。处理协程异常时,最优先要做的事情就是识别并重新抛出取消异常。
第三条:异步流中的错误处理要放在数据流上下游的边界上。不要试图在每个操作符、每个中间环节都处理一遍异常,那样只会让代码充满重复的 try/catch,而且容易遗漏真正需要处理的边界。把异常处理集中放在“数据入口”和“数据出口”,中间环节保持透明的异常传递,配合链路追踪记录,才是最干净的结构。
第四条:不要试图在协程里模拟线程的异常处理模式。有些语言在并发模型里提供了线程组、回调注册等全局机制,协程未必有。协程的异常处理更讲究“所有权明确、传递透明、边界清晰”。每次看到一个协程对象,先问自己三个问题:谁创建了它?谁负责它的最终状态?异常发生时谁应该看到?三个问题答完,处理方案基本就出来了。
第五条:深思熟虑后再决定是否使用全局兜底机制。set_exception_handler、unhandledRejection监听器、CoroutineExceptionHandler这些兜底手段适合做最后防线,但绝不应该成为唯一的异常处理方案。项目里如果全局兜底打出的日志占比超过所有异常日志的10%,说明业务代码里的异常处理做得太粗糙,需要回头审视协程的创建和调用结构。
以上是我在协程异常处理上的全部实战经验总结。这些结论没有一条来自教科书,全部来自线上问题的血泪教训。协程让我们获得了极高的并发表达能力,但代价是需要把异常传递的每一个环节都掌握得明明白白。希望这篇文章对你正在处理的协程项目有所启发,也欢迎分享你在自己的项目里遇到的协程异常处理难题。