Am I the only person who likes channels better tha...
# coroutines
g
Am I the only person who likes channels better than flows? after reading and re-reading hot vs cold medium article and using the hot-flow and trying to use cold flows, i find myself preferring the old channel APIs, even if its got me using
@ObsoleteAPI
Is there an article I can read that will sell me on
when you used channels you hated this, but now, if you use flows, you can change this bad thing into this much better thing!
? One reason is that I was using channels as a kind of value objects. Some component would parse a header message and pass the channel off to another function to read out the reamaining messages according to some protocol implied by the first message. This was with GRPC streams. Was it a bad idea? whats a better pattern?
1
s
Ask me again next week, I'm planning on writing just such an article 😄
👍 1
k
I like flows for 99% of the work I do, but they’re not perfect for every problem
f
channels are a lower level API and serve a different purpose. A main difference is that channels are fan-out, if you have multiple collectors every emission goes to just 1 collector, so this works very well when you have some producer and multiple consumers, when you want just 1 consumer to handle the emitted items. This is not something flows are good at, but most scenarios are best served by a flow.
☝️ 1
k
I don’t know the intricacies of GRPC headers, but assuming that a header is always the first element in a GRPC stream maybe something like this would work?
Copy code
sealed interface GRPCMessage {
  enum class Header : GRPCMessage { TypeA, TypeB }
  data class Content(…) : GRPCMessage
}

flow<GRPCMessage> { … }
  .withHeaderType()
  .collect { (header, content) ->
    when (header) {
      TypeA -> processTypeAContent(content)
      TypeB -> processTypeBContent(content)
    }
  }

/**
 * Strips the first element from this stream and passes it to every subsequent content message in the stream.
 *This function assumes that the receiver flow's first element will be a header.
 */
fun Flow<GRPCMessage>.withHeaderType(): Flow<Pair<Header, Content>> {
  return flow {
    var header: Header? = null
    collect { message ->
      if (header == null) {
        header = message as Header
      } else {
        emit(Pair(header!!, message as Content))
      }
    }
  }
}
The above likely lacks nuance, but if I needed to use the first element of a flow to instruct how the rest of the elements in a flow would behave I think I would do that.
g
@Francesc and one really important difference between flows and channels is fan-in with
select
which is still very difficult with flows. If there's a combinator that does what you want that combines multiple flows your set. If there isn't, you have to write some really strange looking code.
and yeah @kevin.cianfarini this is pretty simple and it does address the root of my problem, but just, i treated a
Channel
as a kind of
reaminingMessages: Channel<Message>
, and then I had functions that took those messages, sometimes as actors sometimes as producers sometimes as one-shots. I have a background in parsers so this was ind've a logical step for me. But
Flow
s are, a little trickier. As I read once from jetbrains,
Channel
seems very unopinionated, whereas
Flow
seems very opinionated. Im not saying they're bad opinions, they're just not the ones I had organically
k
Flows and channels are not built for the same purpose. Channels are sometimes used to implement a flow, but: • Flows are an asynchronous sequence of elements. Special cases like stateflow still hold true here, but offer additional functionality. • Channels are asynchronous queues of elements that an outside entity must enqueue into. A channel can be used to implement a flow (see:
channelFlow
) but flows are more flexible than channels in many cases. I can write a flow that produces elements without any underlying callback or queue.
Copy code
flow {
  var i = 0
  while (true) {
    emit(i++)
    delay(10)
  }
}
To do the same with a channel you’d need to launch some sort of async task that runs concurrently to the function that returns you a channel:
Copy code
fun CoroutineScope.produceNumbers(): Channel<Int> {
  val channel = Channel(...)
  launch {
    var i = 0
    while (true) {
      channel.send(i++)
      delay(10)
    }
  }
  return channel
}
This is one of the numerous benefits of having a cold API as well as a hot API.
Mostly, I use channels as a way to communicate across coroutine boundaries.
f
A channel is hot. There is a coroutine producing data and one consuming it. You have to manage them manually. A cold flow instead is a more abstract data producer whose lifecycle is limited by collector duration. For this reason a cold flow is inherently structured and encapsulated, while a channel is not (producer is not bound to the consumer, and it is an external actor). Generally we always use cold flows, which give us that structured concurrency guarantee. If we use a hot flow is generally a SharedFlow or StateFlow, which is something beyond channel, and we use those only on boundary APIs. We rarely use channel, as most often the corresponding things can be expressed as a cold Flow. Channels can be used to implement some behaviors (like buffering of flow values), but that is generally done in some generic extension function and not use directly in the app domain.
👍 2
g
@kevin.cianfarini i think a
produce
with a
Rendezvous
channel possibly with
Unconfined
dispatch is going to get you very close to your flow, but it will be hot (producer runs first, element is cached) rather than cold (producer called on demand)
IE:
Copy code
fun CoroutineScope.produceNumbers(): Chan<Int> {
  return produce<Int>(Channel.Rendezvous) {
    var i = 0
    while(true){
      channel.send(i++)
      delay(10) 
      // you will not get an int every 10ms
    }
  }
}
@franztesca I think this is a very good point:
cold flow instead is a more abstract data producer whose lifecycle is limited by collector duration. For this reason a cold flow is inherently structured and encapsulated, while a channel is not (producer is not bound to the consumer, and it is an external actor).
With respect to life cycle management this necessarily makes all
Channel
instances
Closeable
and thus you have to clean them up. I'd like to say this isn't that difficult, but in practice it has been. Right now, have some rather tricky cleanup bugs that I'm chasing down, related to a
Channel is closed
making some tests into failures --of course, production doesn't care because this only happens at shutdown time.
We rarely use channel, as most often the corresponding things can be expressed as a cold Flow.
And this is exactly my point, I've spent some time with flows and Im maybe a C+ person at properly using the flow combinators, but I find myself butting up against flow limitations frequently. I suspect you and your team would deftly navigate these limitations by understanding something about flows or thinking about flows in a way I do not, but I struggle, and frequently think "well if I just had a channel here and a channel there then this feature becomes a simple
select{}
call."
Again Id like to emphasize I believe I have a strong understanding of what Flows are and what Channels are, and I think for me one key distinction is that a
Flow
is like a
Collection
where the only exposed method of consumption is
foreach
(ie
consume
), where a channel is more like an
Iterator
which lets you consume elements from it piece meal, and that's just how I've traditionally written code. That said, if you think about how often one uses things like indexing operators into lists or
headSet
on a
NavigableSet
(did you even know that was an interface), its just vastly more common to use
foreach
, especially when you have combinators as powerful as
filter
and
flatMap
. I'm clearly on the wrong team here, its just, I guess the only way to get on-side is to force myself to use flows 🙃
k
i think a
produce
with a
Rendezvous
channel possibly with
Unconfined
dispatch is going to get you very close to your flow, but it will be hot (producer runs first, element is cached) rather than cold (producer called on demand)
This still requires a coroutine queueing elements up into the channel and a separate coroutine consuming those elements. That’s the main distinction I was trying to illustrate.
s
I finally got around to finishing my article comparing flows and channels. Took a bit longer than I had planned 😄 https://betterprogramming.pub/stop-calling-kotlin-flows-hot-and-cold-48e87708d863?sk=9967d6bf37bc3501e440e18fe2a6be5a
💯 3
👏 3
❤️ 1
K 1
g
+1 for a good subheading XD
😎 1
s
All feedback is appreciated, I feel like I started the post off with a strong idea in mind and then kind of tailed off a bit…
My basic point was that flows are for encapsulation, but I’m not sure if I ever actually said it 😂
I have a feeling I could do more to tighten up some of the key points (maybe with examples) and remove some cruft.
k
I like this article.
s
Thank you!
p
So you propose statefull/stateless vs hot/cold terminology? Sounds like right
s
Actually I'm okay with the hot channels/cold flows distinction. I just think we can remove the extra idea of "hot flows", as I find it confusing and not particularly useful.
🆗 1
x
Great article! 👏 I was lacking context to properly distinguish channels and flows I’m finding it difficult to understand the paragraph in the picture, that belongs to the
SharedFlow
section. Besides from that, do you have any real world example where channels are a good fit? I’m having it tough to picture by myself
s
Thank you for the feedback! I think I can clarify the shared flow section with an example. I'll also try to come up with an example of some concurrent code where a channel is needed 👍
❤️ 1
gratitude thank you 1
I made some small changes to the confusing sections, and added a couple of examples. Hopefully it's a little better! I also changed the title to focus more on the different between flows and channels, since many people have said that's something the article conveys well.
🆒 2
x
Great to here @Sam!