21

SharedFlow has just been introduced in coroutines 1.4.0-M1, and it is meant to replace all BroadcastChannel implementations (as stated in the design issue decription).

I have a use case where I use a BroadcastChannel to represent incoming web socket frames, so that multiple listeners can "subscribe" to the frames. The problem I have when I move to a SharedFlow is that I can't "end" the flow when I receive a close frame, or an upstream error (which I would like to do to inform all subscribers that the flow is over).

How can I make all subscriptions terminate when I want to effectively "close" the SharedFlow? Is there a way to tell the difference between normal closure and closure with exception? (like channels)

If MutableSharedFlow doesn't allow to convey the end of the flow to subscribers, what is the alternative if BroadcastChannel gets deprecated/removed?

Joffrey
  • 32,348
  • 6
  • 68
  • 100

3 Answers3

17

The SharedFlow documentation describes what you need:

Note that most terminal operators like Flow.toList would also not complete, when applied to a shared flow, but flow-truncating operators like Flow.take and Flow.takeWhile can be used on a shared flow to turn it into a completing one.

SharedFlow cannot be closed like BroadcastChannel and can never represent a failure. All errors and completion signals should be explicitly materialized if needed.

Basically you will need to introduce a special object that you can emit from the shared flow to indicate that the flow has ended, using takeWhile at the consumer end can make them emit until that special object is received.

Kiskae
  • 24,655
  • 2
  • 77
  • 74
  • 1
    This is indeed what the doc suggests, but I don't think this is enough to replace `BroadcastChannel`. We need a low-level primitive that would allow performant code behaving like broadcast channel. Materializing the close event through wrapper objects really makes me reluctant to make the move. – Joffrey Oct 18 '20 at 22:08
  • In theory you could use an `inline` class and a symbol object to create a wrapper with minimal overhead. It would essentially end up being an implementation of the "poison pill" pattern. Overhead limited to a referential equals check for each item. – Kiskae Oct 19 '20 at 20:20
  • I see, the symbol object could help. But as far as I remember inline classes do create wrapper objects when used as generics, which is the case in a `Flow`, so I'm a bit weary about the efficiency of the wrapper (in terms of gc pressure for instance in case of high number of messages) – Joffrey Oct 19 '20 at 21:26
  • 1
    You're right, it seems generics force a boxed type. In that case you'll probably end up like many of the internal kotlin classes and use `Any` to represent a union of `ValueType | TerminationSymbol`, where it can be either the next value or the terminal symbol object. With a wrapper around the entire system you'd be able to maintain some type safety. – Kiskae Oct 19 '20 at 22:01
  • I didn't think of that, but I guess that could work. If it leaks into the API it's a no go of course, but for internal stuff it's actually an ok approach. – Joffrey Oct 20 '20 at 00:12
0

I think a possible solution is creating a boolean flag isValid and publicly expose only flows with .takeWhile { isValid }. Then just call isValid = false and sFlow.emit() when you want to close all subscribers.

Possible implementation:

private var isValid = true // In real scenario use atomic boolean
private val _sharedFlow = MutableSharedFlow<Unit>()
val sharedFlow: Flow<Unit> get() = _sharedFlow.takeWhile { isValid }

suspend fun cancelSharedFlow() {
    isValid = false
    _sharedFlow.emit(Unit)
}

EDIT: In my case .emit() was always suspending so I had to use BufferOverflow.DROP_LATEST (which is not suitable for many usecases). Not sure if the problem is in this example or elsewhere in my app. If you see a problem, please comment :)

woodenleg
  • 84
  • 1
  • 9
  • 3
    Yes, of course we can always use out-of-band information, but this is in general cumbersome, and I was looking for a more "native" way of doing this using the flow's API itself. It's true though that we can limit the scope of this out-of-band information to a minimum by ensuring we use `takeWhile` on every publicly visible flows. Good point. – Joffrey Jan 06 '21 at 10:12
  • 1
    Actually there are problems with this approach despite the synchronization hassle (at that would be enough to rule it out). Here `isValid` can become false before all consumers have consumed the last messages. This is a no-go, that's why the termination symbol usually needs to be part of the flow itself, so that consumers can reach it at their own pace. – Joffrey Jan 06 '21 at 10:15
  • Yeah, you are right. I didn't think of that... But I'll leave the answer here because it could be helpful for some usecases. Thanks for the reply @Joffrey – woodenleg Jan 07 '21 at 08:02
  • yea, really uncomfortable place in coroutine-flow. Why somebody resolved that share-flow could not be cancelled? It's just stupid, because every body want to have this possibility. Kotlin team have to resolve this problem. I don't want to make special objects in my code to cancel channels subscription in the end, I just want cancel it by one line! – Georgiy Chebotarev Nov 23 '21 at 10:07
  • And yes, @woodenleg - your solution is not enough reliable. Anyway finger up by attempt and idea – Georgiy Chebotarev Nov 23 '21 at 10:07
0

just "played around" with a similar "challenge".

I wanted to "split" the result Flow of (possibly many) producers in a round-robin manner to multiple asynchronous consumers.

Achieving that:

  • the producer produces a flow "in the background"
  • one / many consumers consume chunks of the producers outcome in a round-robin fashion (also "in the background")
  • while all of this happens, you can continue to "do something" until all data is produced AND consumed

To achieve the above I used MutableSharedFlow and after the producer (and consumers) finished "their" flows finally had to be canceled/finished.

I doubt that my result is in any way optimal or even a "good" solution, but as I have spend a "significant" time on it (learning) I thought I might share it here as well:

package scratch

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import okio.*
import kotlin.random.Random

data class Packet(val nr: Int, val cmd: String, val line: String, val isFinal: Boolean = false)
class TerminatedException(message: String, cause: Throwable? = null) : Exception(message, cause)

/** possible multiple producers sending to one MutableSharedFlow</br>
 *  multiple consumers fed round-robin from the one SharedFlow of all producers */
fun main(@Suppress("UNUSED_PARAMETER") args: Array<String>) {
    println("===============================")
    println("====   multipleConsumers() ====")
    println("===============================")
    showcaseMultipleConsumers()

    println("\nmompl...\n")
    Thread.sleep(2000L)

    println("===============================")
    println("=====   singleConsumer() ======")
    println("===============================")
    showcaseSingleConsumer()

    println("main ended.")
}

private fun showcaseMultipleConsumers() {
    runBlocking {

        // launch producer with SharedFlow to emit to
        val producerFlow = MutableSharedFlow<Packet>()
        launch(Dispatchers.IO) {
            producer(name = "producer", cmd = "/Users/hoffi/gitRepos/scratch/scratch/some.sh", producerFlow)
        }

        // launch concurrent consumers with each its own SharedFlow
        val consumerCount = 3
        val mapOfFlows = mutableMapOf<Int, MutableSharedFlow<Packet>>()
        for (i in 1..consumerCount) {
            mapOfFlows[i - 1] = MutableSharedFlow()
            launch(Dispatchers.IO) {
                consume(i.toString(), mapOfFlows[i - 1]!!)
            }
        }
        println("finished launching $consumerCount consumers.")

        // round-robin emit from the producerFlow to the existing MutableSharedFlows
        var i = 0
        try {
            producerFlow.buffer().collect() { packet ->
                if (packet.isFinal) {
                    println("producer: final packet received"); throw TerminatedException("'producer' terminated")
                }
                mapOfFlows[i++ % consumerCount]!!.emit(packet)
            }
        } catch (e: TerminatedException) {
            println(e.message)
        }

        println("round-robbin load-balancing finished.")

        // finally send terminal packet to all consumer's MutableSharedFlows
        for (flow in mapOfFlows.values) {
            flow.emit(Packet(-1, "final", "final", true))
        }

        // might do something here _after_ the process has finished and its output is load-balanced to consumers
        // but consuming of process output has not finished yet.

        println("end of runBlocking ...")
    }
}

/** coroutine consuming from given Flow (which is a MutableSharedFlow!) */
suspend fun consume(name: String, flow: Flow<Packet>) {
    try {
        flow.buffer().collect { packet ->
            if (packet.isFinal)  { println("$name: final packet received") ; throw TerminatedException("'$name' terminated") }
            println("%5d in c%s: %s".format(packet.nr, name, packet.line))
            delay(Random.nextLong(50L, 550L))
        }
    } catch(e: TerminatedException) {
        println("consumer: ${e.message}")
    }
}

/** coroutine emitting to given producer's MutableSharedFlow */
suspend fun producer(name: String, cmd: String, producerFlow: MutableSharedFlow<Packet>) {
    val process = ProcessBuilder("\\s".toRegex().split(cmd))
        .redirectOutput(ProcessBuilder.Redirect.PIPE)
        .redirectError(ProcessBuilder.Redirect.PIPE)
        .redirectErrorStream(true)
        .start() // non-blocking asynchronous start process in the background

    val inputBuffer = process.inputStream.source().buffer()
    var i = 0
    while (true) {
        val line = inputBuffer.readUtf8Line() ?: break
        producerFlow.emit(Packet(++i, cmd, line))
    }

    producerFlow.emit(Packet(-1, "final", "final", true))
    println("producer function ended")
}

// =====================================================================================================================
// =====================================================================================================================
// =====================================================================================================================

private fun showcaseSingleConsumer() {
    runBlocking {

        val flow: Flow<Packet> = singleProducer(name = "producer", cmd = "/Users/hoffi/gitRepos/scratch/scratch/some.sh")
        launch(Dispatchers.IO) {
            singleConsumer(name = "consumer", flow)
        }

        // do something here, while the process is executed
        // and the consumer is consuming the process's output

        println("end of runBlocking ...")

    }
}

/** no suspend needed as a flow { ... } implicitly "runs in a coroutine" */
fun singleProducer(name: String, cmd: String) = flow {
    val process = ProcessBuilder("\\s".toRegex().split(cmd))
        .redirectOutput(ProcessBuilder.Redirect.PIPE)
        .redirectError(ProcessBuilder.Redirect.PIPE)
        .redirectErrorStream(true)
        .start() // non-blocking asynchronous start process in the background

    val inputBuffer = process.inputStream.source().buffer()
    var i = 0
    while (true) {
        val line = inputBuffer.readUtf8Line() ?: break
        emit(Packet(++i, cmd, line))
    }

    println("producer function ended")
}

suspend fun singleConsumer(name: String, flow: Flow<Packet>) {
    flow.buffer().collect { packet ->
        println("%5d in c%s: %s".format(packet.nr, name, packet.line))
        delay(Random.nextLong(30L, 150L))
    }
}

some.sh shellscript used as "data source":

#!/bin/bash
 # some.sh
echo "first line"
echo -e "second line\nand third line"
echo -n "fourth line without newline"
sleep 2
echo
echo -e "fifth line after sleep\nand sixth line"
echo -e "some stderr\nand stderr has two lines" >&2
for i in {1..25}; do
  echo "and loop line $i"
done
echo -n "last line"
Dirk Hoffmann
  • 1,444
  • 17
  • 35