别再担心Flow是热的还是冷的了, 还是多专注于老式的封装吧!
也许你曾经听Kotlin程序员说过: "Channel是热的, Flow是冷的".
这是对处理异步数据流的两种方式的有益区分. Flow和Channel就像函数和对象一样不同. 但这还不是全部, 因为流本身至少有两种截然不同的形式. 这就是有限的冷热类比开始崩溃的地方.
我们如何才能将模糊的比喻改进为更加具体翔实、更可操作呢?
- 🔥 我们称Channel为"热", 因为它们是有状态的对象. Channel是一种通信机制, 可让您从其他计算中接收值. 作为消费者, 你与Channel的交互并不一定能控制计算的开始和停止时间.
把它想象成地铁上移动的自动扶梯. 在你开始使用之前, 它就在运行, 而且很可能在你离开后继续运行.
- ❄️ Flow之所以被称为"冷", 是因为它们不保持状态. 当你在 Kotlin 代码中传递流时, Flow并不持有或产生任何数据. 这是因为
Flow
对象不是数据流的活动实例. 相反, 每次调用collect
时, 你都会创建一个新的, 短暂的流计算实例, 它只存在于该函数调用中.
如果说Channel就像地铁站里移动的自动扶梯 , 那么数据Flow则更像电梯. 只有当你开始与它交互时, 它才会开始运行, 一旦你离开, 它又会停止.
在处理最基本的Channel和Flow时, 这种区分是没有问题的. 但创建Flow
的方法不止一种, 如果你花了很多时间来处理它们, 你可能已经在我过于简化的解释中发现了漏洞.
"那热流呢?"
Flow: 热还是不热?
我们从流是冷的
这一简单说法开始. "热流"是一个较新的术语, 用于描述由某些活动计算支持的Flow, 这些计算比任何单个消费者的活得都要长. 例如, Kotlin内置的SharedFlow
文档的第一行就宣称它是"热流". 文档接着解释道:
"共享流之所以被称为热流, 是因为其活动实例的存在与收集器的存在无关".
我不喜欢这个术语. 将Channel称为热流而将Flow称为冷流是有用的, 但以热流的形式添加进一步的区分会造成混淆, 而且可以说没有必要. 我来解释一下原因.
我们需要停止谈论热流和冷流有两个原因. 首先, 这种区分并不精确. 究竟是什么让流从冷上升到热?
我们在SharedFlow
文档中看到流被描述为热流. 另一个被称为"热"流的例子是Channel的consumeAsFlow
功能. 它不是一个SharedFlow
, 但将其称为热流似乎仍然说得通, 因为Channel是一个活跃的, 有状态的数据源, 无论流是否被收集, 它都会持续产生值.
Kotlin
val flow = channel.consumeAsFlow() // 🔥 "Hot" flow
consumeAsFlow
函数并不是将热Channel转换为Flow的唯一方法. 我们只需几行手工制作的Flow代码, 就能消费相同的值Flow.
Kotlin
val flow = flow {
emitAll(channel)
}
这两段代码完全可以互换:它们提供的行为和功能即使不完全相同, 也是等同的. 然而,flow { ... }
生成器的文档非常清楚地说明它"创建一个冷Flow".
如果这两个Flow示例做的是同样的事情, 怎么可能一个是冷Flow, 一个是热Flow呢?我们会再讨论这个问题, 但简而言之, 区分热Flow和冷Flow并不是很有用, 因为没有明确的定义.
我不喜欢区分热Flow和冷Flow的第二个原因是, 它不具有可操作性.
假设有人递给你一个闪闪发光的新Flow, 并告诉你"小心, 它是热的!".
你很感激这个警告, 但过了一会儿, 你开始想: 你能用这个信息做什么?
你需要避免多次收集Flow吗? 由consumeAsFlow
创建的"热"Flow有这样的限制, 但包括类似的receiveAsFlow
在内的许多自称为"热"Flow的其他Flow都可以随意收集.
资源管理如怎么办呢? 在完成热Flow收集后, 是否需要清理底层热Channel或其他数据源? 也许有, 但要弄清细节并不容易. 如果使用consumeAsFlow
, 一旦停止收集热Flow, Channel就会被关闭. 但如果你使用了receiveAsFlow
来退出自动清理, 那么谁也不知道是谁取消了Channel.
甚至可能会有多个消费者同时收集Flow. 这看起来就像一个"第22条军规": 如果一个消费者取消了Channel, 可能会给其他消费者带来麻烦; 但如果没有消费者取消Channel, 它就会成为一个资源泄漏点, 并永远保持开放状态!
如何关闭与热Flow相关的资源也很棘手. Channel提供了自己的清理机制: 调用cancel
方法发出信号, 表示已完成接收值, 并相信生产者会做出适当反应, 决定哪些东西需要清理. 但对于Flow, 你唯一的交互就是开始和停止收集. 无论Flow声称自己有多"热", 它都没有任何关闭资源或关闭的功能.
这是否意味着热Flow只是在为难我们?难道热Flow不应该有专门的"关闭"或"取消"方法来解决所有这些问题吗?
我们还会再讨论这个问题, 但简短的答案是, 设计热Flow的目的是让消费者不必担心资源管理问题. 取而代之的是, 结构化并发会处理比单个Flow收集器寿命更长的资源. 如果处理得当, 这完全是自动的, 因此将一个Flow标记为"热"并不会也不应该改变你使用它的方式.
与众不同的一点
我认为, 从Flow文档中频繁提到的"热"和"冷"中可以清楚地看出, 某些区别是值得在此加以把握的. 问题是, 我们能不能明确两者之间的区别, 使其更加精确和具有可操作性呢?
首先, 让我们回到热Channel和冷Flow的最初区别. 暂且不论热Flow是否真实存在, 冷Flow和热Channel的最基本的区别是什么?
我们很容易认为, Flow和Channel只是异步流这一基本概念的两种变体. "热"和"冷"的区别让人觉得它们的主要区别在于Channel总是在运行, 而Flow只有在你收集它时才会运行.
这里我想提出一个要求: 不要再试图把Channel和Flow视为相关概念.
- ❄️ Flow是一种控制结构. 它包含可执行代码, 就像
suspend
函数一样. 当你从流中收集值时, 你调用的是流中的代码, 就像通过调用函数来执行函数代码一样. - 🔥 Channel是一种通信机制. 它可以处理消息或值, 并将它们从一个地方传递到另一个地方. 它不包含任何代码. 当你从一个Channel接收信息时, 你只是在收集其他代码留下的信息.
Kotlin 中Flow和Channel的区别就像函数和对象的基本区别一样. 你也可以把对象描述为热的, 而把函数描述为冷的. 对象是有状态的, 即使你不与它交互, 它也会继续存在. 与此同时, 函数只有在被调用时才有状态. 当你调用它时, 它会被实例化, 当你完成调用后, 它又会消失.
在 Kotlin 中, Flow和Channel之间的区别与函数和对象之间的区别一样重要.
当我们说Channel是热的, 而Flow是冷的时, 这就是我们捕捉到的基本思想. 我们并不只是指出数据流或其生命周期的属性, 而是在描述两个完全不同的编程概念之间的本质区别.
管理资源
由于数据Flow和Channel在本质上不同, 因此它们的功能和限制也大相径庭. 其中与本讨论最相关的是它们如何进行自我清理.
由于Flow的行为类似于函数, 因此每次调用都有明确定义的入口和出口. 这是结构化编程的一个原则, 它的好处之一是让我们可以在流中使用try
/finally
来自动管理资源. 一旦Flow终止, 无论是消费者离开, 生产者用完了值, 还是整个Flow出错, Flow都会像函数调用一样解压缩堆栈, 并调用退出路径中的任何finally
块. 你不可能忘记关闭Flow, 就像不可能忘记退出函数调用一样.
顺便说一下, 这并没有什么特殊的魔法. 流的行为类似于函数调用, 因为它就是 函数调用. 消耗一个流总是通过对其collect
函数进行一次调用. 当该函数因任何原因退出时, 数据Flow才会终止.
良好的资源管理是一种形式的封装. 当一个函数暴露了它的开放资源时(例如, 如果它返回了一个Closeable
结果), 调用者就需要知道它的存在, 以便进行清理. 但如果函数使用try
/finally
或相关机制(如consume
或use
)来管理自己的资源, 调用者就不必知道函数内发生了什么.
从外观上看, 包含自动资源清理功能的函数与不使用任何资源的函数无异. 编程语言保证在函数因任何原因退出时运行清理代码, 因此程序员无需担心.
从本质上讲, Flow非常适合这种资源管理和封装. Flow从不返回任何结果, 无论是否Closeable
. 相反, 当你收集流时, 它会通过执行你传递给collect
函数的代码来提供值. 流的实现可以在执行收集器代码前将其封装在一个try
/finally
块中, 这样, 如果你的代码因任何原因退出,流的资源将始终被关闭.
Kotlin
flow {
try {
emit("Hello, World!")
} finally {
println("Goodbye!")
}
}
当Flow调用emit
时, 收集器的代码就会被调用. 从通用的Closeable.use
到更专业的Reader.forEachLine
, 你会在各种资源管理函数中看到完全相同的"不要调用我们, 我们会调用你"模式.
在管理资源方面, Channel并不具备同样的优势. Flow只需调用一次collect
函数就能完全消耗掉, 而从Channel获取值则需要多次调用receive
函数. 这意味着Channel没有明确定义的起点和终点, 也就无法保证消费者离开时会发生什么.
Kotlin
val messages: ReceiveChannel<String> = GlobalScope.produce {
try {
send("Hello, World!")
send("How are things?")
} finally {
println("Producer is going away") // ❌ this won't work!
}
}
println(messages.receive())
error("That's all, folks.")
请注意, 运行这段代码时, finally
代码块并没有被执行. 如果你尝试通过添加对messages.cancel()
的缺失调用来修复它, 这将有助于你理解我们将在下一个示例中对代码进行的修改.
像consume
这样的函数就是为了缓解这个问题而设计的, 它能确保你在停止接收Channel值时不会忘记调用cancel
. 但在Channel中, 记住调用consume
或cancel
始终是消费者的责任, 而不是生产者的责任.
Channel的资源管理并没有完全封装好. 如果要保证其生产者能干净利落地关闭, Channel的消费者必须是行为良好的参与者. 当你调用一个返回Channel的函数, 然后忘记消耗或取消它时, 任何相关的资源都可能无限期地保持开放.
结构化并发
我之所以说"可能", 是因为这些泄露的资源还有另一种关闭方式. Channel通常用于关联程序之间的通信. 我们可以依靠结构化并发来防止在处理协程时发生资源泄露.
只需一个很小的改动, 我们就能让前面的示例正确使用结构化并发. 我们只需停止使用GlobalScope
, 而将代码分组到共享的coroutineScope
中即可.
Kotlin
coroutineScope {
val messages: ReceiveChannel<String> = produce {
try {
send("Hello, World!")
send("How are things?")
} finally {
println("Producer is going away") // ✅ now it works!
}
}
println(messages.receive())
error("That's all, folks.")
}
现在, 我们的所有代码都属于同一个结构化的协程作用域, 一个地方的错误会导致其他协程被取消和清理. 这意味着, 即使Channel从未被明确取消, 生产者协程中的finally
块仍会被执行.
这是一种有用的安全措施. 但如果感觉很乱, 那也是有原因的. Channel取消机制以及由此产生的取消异常是在引入结构化并发之前产生的, 并不总是能与结构化并发很好地配合.
现在有了结构化并发, 有两种不同的方法可以管理该Channel的资源. 取消Channel本身是一种选择, 而取消产生Channel值的协程则是另一种选择. 哪种选择是正确的? 结果是一样的吗? 还是在行为上有细微差别? 为了安全起见, 是否应该两者兼顾?
在文章"让你的协程崩溃的无声杀手"中, 我解释了取消的Channel会抛出一个CancellationException
, 它会导致一些令人讨厌和意想不到的问题. 由于取消的协程和取消的Channel重复使用同一种异常, 因此取消的Channel和取消的协程之间的界限可能会变得模糊. 在最糟糕的情况下, 取消Channel会导致整个程序在你不希望它被取消的情况下被静默取消.
值得明确的是, Channel本身并不拥有任何资源. 没有任何规定说它们在使用后需要清空或关闭. 不再被引用的Channel可以像其他对象一样被自由回收.
取消Channel的作用只是在生产者和消费者之间发送一个信号. 这让Channel另一端的代码知道, 它应该停止发送值并清理它正在使用的任何资源. 换句话说, Channel需要关闭或取消的唯一原因是, 它正被积极用于处理其他程序和资源. 一旦我们有了结构化并发功能来替我们管理这些协程和资源, 就完全没有必要取消Channel了.
让我们花一点时间来思考一下, 在一个永远不需要取消或关闭Channel的世界中, ReceiveChannel
API会是什么样子.
- Channel永远保持打开状态, 因此调用
receive
要么暂停, 要么返回一个值: 它永远不会抛出异常. - 因为Channel没有失败或关闭状态, 所以不需要使用
receiveCatching
函数. 如果Channel生产者遇到错误, 问题可以由其运行的协程作用域处理, 而不是传递给消费者.
但是, 当我们使用完这个理论上的Channel后该怎么办呢? 当生产者不打算生产更多的值时, 我们如何阻止消费者无限中止呢? 很简单. 我们取消消费者协程. 在许多情况下, 由于结构化并发, 这可以自动完成.
共享Flow
如果这听起来很耳熟, 那可能是因为这几乎正是SharedFlow
API 背后的设计. 你可以在SharedFlow
的文档中读到这样的内容:
"对共享Flow的
Flow.collect
调用永远不会正常完成".
这是因为共享Flow除了传递Flow的实际值之外, 上游Flow和下游收集器之间没有任何通信. 就像我们假设的不可闭合Channel一样, SharedFlow
永远不会向消费者传播错误或自身终止.
Kotlin
val sharedFlow = flowOf("Hello, World!")
.onCompletion { println("Upstream flow has no more values") }
.shareIn(this, SharingStarted.Lazily) // 💬 try removing this line!
val collector = sharedFlow
.onEach { println("Flow collector received '$it'") }
.onCompletion { println("Flow collection stopped, error was $it") }
.launchIn(this)
launch {
while (collector.isActive) {
println("Flow collector is waiting for more values...")
delay(50)
}
}
delay(250)
collector.cancel("Giving up waiting")
如果上游Flow终止, 共享Flow的消费者就会永远暂停, 等待一个永远不会到来的值. 要将此行为与正常Flow的行为进行比较, 请尝试编辑示例, 删除调用shareIn
的行.
即使上游Flow以最可怕的方式崩溃, 共享Flow仍会保持打开状态. 或者说, 如果不是因为结构化并发, 共享Flow会保持开放. 实际上, 上游Flow的失败会导致错误, 并传播到包含该Flow的其他作用域. 在那里, 应用程序可以决定是否取消消费者协程.
Kotlin
val sharedFlow = flow<Any> { throw Exception("This is a disaster!") }
.onCompletion { println("Upstream flow has no more values") }
.shareIn(this, SharingStarted.Lazily) // 💬 try removing this line!
val collector = sharedFlow
.onEach { println("Flow collector received '$it'") }
.onCompletion { println("Flow collection stopped, error was $it") }
.launchIn(this)
共享Flow就像封装更好的Channel. 它们可能有一个活跃的生产者协程, 其寿命会超过消费者, 但它们会向消费者隐藏所有错误, 资源和取消.
这样做的结果是, SharedFlow
可以和普通的Flow
一样被消费者处理. 你所需要做的就是调用collect
, 然后逐个处理值. 这是有道理的: 毕竟, 共享Flow和普通Flow实现了相同的接口. 这也是我们不需要费心去称呼Flow为"热"或"冷"的根本原因. 它们可能会附带资源, 但处理这些资源的责任完全在于生产者及其协程范围, 而绝不在于消费者.
封装和可替代性让Kotlin中的Flow变得如此强大和多变. 如果我使用一个SharedFlow
, 并对其应用一系列运算符和转换, 那么得到的Flow是更热还是更冷? 如果使用冷Flow并启动一些协程来监控和增强其值, 又会怎样呢? 答案很简单: 无所谓. 只要我坚持结构化并发, 正确管理和封装资源, 每个Flow都是可以互换的.
当函数返回一个Flow
时, 我不需要知道它是从哪里来的就可以使用它. 当函数接受一个Flow时, 我知道我可以随心所欲地生成它, 而且它将正常工作. 我不需要担心Flow是热 还是冷, 你也不需要担心.
用于Job的合适工具
也许, 说了这么多关于Channel和Flow的话题, 你还在纠结应该使用哪一种. 答案很简单: 两者都用! 与其将Channel和Flow视为做同一件事的两种不同方法, 不如将它们视为两种完全不同的工具, 用于两种不同的工作. Channel用于通信; Flow用于封装和代码重用.
- 当你想把值从一个协程传递到另一个协程时, 请使用Channel.
- 当你想对产生值的代码进行封装, 使消费者不必担心代码何时开始, 停止或失败时, 就可以使用Flow.
这两种工具也可能够而且应该一起使用. 那就是ChannelFlow
, 它内置的API, 将Channel生产者和Flow消费者结合在一起. 当你需要并发和封装的好处时, 可以随意创建一个Channel, 并将其封装在一个Flow中! 你可以混搭使用: 从Channel中读取一些值, 然后将其封装在Flow中, 将剩余的值和清理过程委托给其他代码.
将Channel封装在Flow中, 可以使应用程序更安全, 更可预测. 你可以决定Flow退出时会发生什么(如果有的话). 消费者不必担心要记得清理资源和处理错误, 你也不必担心你的协程会因为消费者在错误的时间调用cancel
而被终止.
不过, 封装也有其局限性. 无论这堵墙从外面看起来多么干净整洁, 墙后面总会有代码存在. Flow所提供的封装性主要来自于这样一个事实, 即它可以表示为单个函数调用, 以及由此带来的所有结构化编程保证. 但是, 单个函数调用需要单个控制流. 一旦我们需要在生产者和消费者之间传递值, 而这些值又不在同一个函数或同一个协程中, 我们就必须穿过流所提供的封装边界.
Kotlin
// You can't do this with a flow!
val job1 = launch {
channel.send("Hello from job 1!")
println("Job 1 received a message: ${channel.receive()}")
}
val job2 = launch {
println("Job 2 received a message: ${channel.receive()}")
channel.send("Hello from job 2!")
}
当多个程序同时消耗或生产时, Channel就是它们用来分配和协调工作的通信工具. 但是, 通过正确使用Flow和结构化并发, Channel及其所有的协程仍然可以被封装起来, 这样应用程序的其他部分就不必担心它们了.
总结一下
今天的文章很长, 而且比较抽象. 如果你今天已经看到了这里, 那么, 恭喜你! 我想你已经明白了Kotlin中Flow和Channel的不同之处, 他们是非常不一样的!
最后, Happy Coding! Stay GOLD! :-)