18

I'm trying to create an object which can execute some tasks sequentially in its own thread like it is a queue.

The following sample is just for demonstrating my setup and may be completely wrong.

class CoroutinesTest {
    fun a() {
        GlobalScope.launch {
            println("a started")
            delay(1000)
            println("a completed")
        }
    }

    fun b() {
        GlobalScope.launch {
            println("b started")
            delay(2000)
            println("b completed")
        }
    }

    fun complex() {
        a()
        b()
    }
}

fun main() {
    runBlocking {
        val coroutinesTest = CoroutinesTest()

        coroutinesTest.complex()

        delay(10000)
    }
}

For now this code prints the following

a started
b started
a completed
b completed

which means a and b executed in parallel. Methods a, b and complex can be called from different threads. Of course, the complex method should also support this concept. For now, I need a mechanism that allows me to execute only one task at a moment, so I could get the following output:

a started
a completed
b started
b completed

I did some research and think that actor with a Channel can do what needed, but actor for now is marked as obsolete (issue #87). I don't like the idea of using API that is subject to change, so I would like to do the thing in a common way.

Udara Abeythilake
  • 1,215
  • 1
  • 20
  • 31
  • There will be no common way until Kotlin introduces the replacement for `actor`. I'd just use `actor` and adapt to the new API when it comes out. It probably won't be that different. Or you can avoid `actor` and work with a `Channel` directly. – Marko Topolnik Jun 06 '19 at 19:10
  • I agree -- I use actors and it works perfectly for the exact use case that you describe. Plus, you get to encapsulate a state for it (declaring val and vars inside the actor) as a bonus. – Patrick Steiger Oct 23 '19 at 00:40

3 Answers3

23

TL;DR There are a few options for controlling sequential coroutines.

  1. Use a Channel to make them run one at a time in the order called
  2. Use a Mutex to make them run one at a time but without a guarantee of order
  3. Use a Flow (as described in the answer below by BigSt) to make them run one at a time in the order called, however make sure that the flow buffer is large enough or jobs can be lost if the number of jobs "in flight" is larger than the buffer size.
  4. If the desired sequence is always the same, put the actual work into suspend functions and call the sequence from within the same coroutine scope to make them run one at a time in the order prescribed by the code

Channel

One way to control execution order is to use a Channel - where lazily executed coroutine jobs are passed to the channel to be run in sequence. Unlike the Mutex, the Channel guarantees that the jobs are run in the order they are launched.

class CoroutinesTest {

    private val channel = Channel<Job>(capacity = Channel.UNLIMITED).apply {
        GlobalScope.launch {
            consumeEach { it.join() }
        }
    }

    fun a() {
        channel.trySend(
            GlobalScope.launch(start = CoroutineStart.LAZY) {
                println("a started")
                delay(1000)
                println("a completed")
            }
        )
    }

    fun b() {
        channel.trySend(
            GlobalScope.launch(start = CoroutineStart.LAZY) {
                println("b started")
                delay(2000)
                println("b completed")
            }
        )
    }

    fun complex() {
        // add two separate jobs to the channel,
        // this will run a, then b
        a()
        b()
    }
}

Calling complex always produces:

a started
a completed
b started
b completed

Mutex

You can keep jobs from running at the same time with a Mutex and withLock call. The call order is not guaranteed if you make a bunch of calls in short succession. For example:

class CoroutinesTest {
    private val lock = Mutex()
    
    fun a() {
        GlobalScope.launch {
            lock.withLock {
                println("a started")
                delay(1000)
                println("a completed")
            }
        }
    }

    fun b() {
        GlobalScope.launch {
            lock.withLock {
                println("b started")
                delay(2000)
                println("b completed")
            }
        }
    }

    fun complex() {
        a()
        b()
    }
}

Calling complex can produce:

a started
a completed
b started
b completed

or:

b started
b completed
a started
a completed

Suspend Functions

If you must always run a then b you can make both of them suspend functions and call them from within a single scope (only allowing the complex call, not individual a and b calls). In this case, the complex call does guarantee that a runs and completes before starting b.

class CoroutinesTest {
    
    suspend fun aImpl() {
        println("a started")
        delay(1000)
        println("a completed")
    }

    suspend fun bImpl() {
        println("b started")
        delay(2000)
        println("b completed")
    }

    fun complex() {
        GlobalScope.launch {
            aImpl()
            bImpl()
        }
    }
}

Calling complex always produces:

a started
a completed
b started
b completed
Tyler V
  • 9,694
  • 3
  • 26
  • 52
  • The first example with `Mutex` doesn't guarantee the order. `b started` can be printed first. – Sergio May 21 '22 at 18:33
  • 1
    Yes, that is true, it only guarantees that only one runs at a time. The channel does guarantee order though. – Tyler V May 21 '22 at 18:38
  • Can I kindly ask anyone to explain further why the Mutex doesn't guarantee the order of execution? The documentation says, "This function is fair; suspended callers are resumed in first-in-first-out order", but it seems not the case. – Ganso Doido Mar 23 '23 at 18:35
  • 1
    @GansoDoido It takes time for `GlobalScope.launch` to actually launch the coroutine, and the order there is what is not deterministic. If you call `GlobalScope.launch { a }` then immediately call `GlobalScope.launch { b }` the code in "b" may actually run first, and acquire the mutex lock first. – Tyler V Mar 23 '23 at 19:00
3

Old question but here's a simpler approach anyway. Change a() to return the Coroutine job:

fun a() = GlobalScope.launch {
    println("a started")
    delay(1000)
    println("a completed")
}

Then you can invoke a() / b() like this:

a().invokeOnCompletion { b() }

This way b() won't be triggered before a() terminates.

Alternatively you can use join:

fun complex() {
    GlobalScope.launch {
        a().join()
        b()
    }
}
Emanuel Moecklin
  • 28,488
  • 11
  • 69
  • 85
1

Flows are sequential, using MutableSharedFlow it can be achieved like the following:

class CoroutinesTest {

    // make sure replay(in case some jobs were emitted before sharedFlow is being collected and could be lost)
    // and extraBufferCapacity are large enough to handle all the jobs. 
    // In case some jobs are lost try to increase either of the values.
    private val sharedFlow = MutableSharedFlow<Job>(replay = 2, extraBufferCapacity = 2)

    init {
        sharedFlow.onEach { job ->
            job.join()
        }.launchIn(GlobalScope)
    }

    
    fun a() {
        // emit job to the Flow to execute sequentially
        sharedFlow.tryEmit(
            // using CoroutineStart.LAZY here to start a coroutine when join() is called
            GlobalScope.launch(start = CoroutineStart.LAZY) {
                println("a started")
                delay(1000)
                println("a completed")
            }
        )
    }

    fun b() {
        // emit job to the Flow to execute sequentially
        sharedFlow.tryEmit(
            // using CoroutineStart.LAZY here to start a coroutine when join() is called
            GlobalScope.launch(start = CoroutineStart.LAZY) {
                println("b started")
                delay(2000)
                println("b completed")
            }
        )
    }


    fun complex() {
        a()
        b()
    }
}

Note: GlobalScope is not recommended to use, it violates the principle of structured concurrency.

Sergio
  • 27,326
  • 8
  • 128
  • 149
  • This doesn't let you run `a` and `b` individually though. I added `fun runA() { sharedFlow.tryEmit(a()) }` and similar for `runB` and calling those in sequence does not always produce consistent behavior (e.g. `runA() runB() runA() runB() runB() runA()` sometimes misses the first run, sometimes only one A-B sequence runs, sometimes the order changes) – Tyler V May 27 '22 at 12:45
  • As for the first statement it makes sense then to launch a coroutine when calling `tryEmit(...)` function. – Sergio May 27 '22 at 12:51
  • Adding a "name" argument to pass in (e.g. `run("1") run("2") run("3") run("4") run("5") run("6")`) using this I often see it only run "5" and "6" - probably related to the flow replay and buffer size. I have tried the flow approach in the past and it seemed to "lose" jobs. – Tyler V May 27 '22 at 12:51
  • Right, I guess it loses some items if they are emitted before `sharedFlow` starts to be collected. Increasing `replay` argument should solve the problem. – Sergio May 27 '22 at 12:54
  • Yeah, you have to make replay large enough, but if something external is triggering `a` you could still lose jobs if too many are launched in short succession unless there is an unlimited replay option. – Tyler V May 27 '22 at 12:56
  • `extraBufferCapacity` can also be increased for such cases. – Sergio May 27 '22 at 12:59
  • 2
    It's weird that for such simple case (sequential execution of coroutines) there is no simple solution. The solution without using different additional helper classes like `Flow`s, `Channel`s etc. – Sergio May 27 '22 at 13:02
  • I think the channel is the cleanest solution I've found if you need the order. I tried to get the flow working for awhile but it seemed like a lot of messing with parameters (replay and buffer, adding a delay to make sure it starts collecting before you start emitting) was needed and even then it wasn't watertight. – Tyler V May 27 '22 at 13:04
  • As for using `Flow` I think it works correctly, but provided that you set needed values for `replay` and `extraBufferCapacity` parameters. – Sergio May 27 '22 at 13:13
  • Yeah, you just have to know *a priori* how many sequential jobs you want to have "in flight" before you start dropping jobs. Is there any downside (memory) to setting the buffer and replay to absurdly large numbers? – Tyler V May 27 '22 at 13:14
  • Multiplied by 2 just in case :). Cant't tell for sure, but of course it takes some memory, I would choose the value for it (according to the needs * 2). – Sergio May 27 '22 at 13:16
  • Maybe worth adding something to the answer about how to pick those numbers and what the values imply in terms of potential lost jobs? – Tyler V May 27 '22 at 13:20
  • Hm, I've already added it in the comments to the code. – Sergio May 27 '22 at 13:22
  • Oh, I see that now. Might be easier to spot as text outside of the code block, but that's fine either way. – Tyler V May 27 '22 at 13:24