Search code examples
javaandroidmultithreadingkotlinbusy-waiting

How to avoid busy spinning of a pause-able producer?


Background

I've found some GIF animation library, which has a background thread that constantly decodes the current frame into a bitmap, being a producer to other thread :

@Volatile
private var mIsPlaying: Boolean = false

...
while (mIsRunning) {
    if (mIsPlaying) {
        val delay = mGifDecoder.decodeNextFrame()
        Thread.sleep(delay.toLong())
        i = (i + 1) % frameCount
        listener.onGotFrame(bitmap, i, frameCount)
    }
}

The sample POC I've made for this is available here.

The problem

This is inefficient, because when the thread gets to a point that mIsPlaying is false, it just waits there and constantly checks it. In fact, it causes this thread to do more CPU usage somehow (I checked via the profiler).

In fact, it goes from 3-5% of CPU, to 12-14% CPU.

What I've tried

I had a good knowledge about threads in the past, and I know that simply putting a wait and notify is dangerous as it could still cause the thread to wait on some rare cases. For example when it identified that it should wait, and then before it starts to wait, the outside thread marks it that it shouldn't wait.

This behavior is called "busy spinning" or "Busy Waiting" , and there are actually some solutions about it, in the case of multiple threads that need to work together, here .

But here I think it's a bit different. The wait isn't for some thread to finish its work. It's for temporary waiting.

Another issue here is that the consumer thread is the UI thread, as it is the one that needs to get the bitmap and view it, so it can't just wait work like a consumer-producer solution (UI must never wait, as it can cause "jank") .

The question

What's the proper way to avoid spinning here?


Solution

  • So I decided to use wait-notify mechanism, as I couldn't find any nice class to handle this case. This requires delicate thinking, as using threads in the wrong way can cause (on very rare cases) infinite waiting and other weird things.

    I've decided to use synchronized even on the UI thread, but I use it while promising it won't be long there, ever. That's because the UI thread should not wait for other threads, in general. I could use a thread-pool (of size 1) for this, to avoid the UI thread from waiting on the synchronized part, but I think it's good enough.

    Here's my modified code for the gifPlayer:

    class GifPlayer(private val listener: GifListener) : Runnable {
        private var playThread: Thread? = null
        private val gifDecoder: GifDecoder = GifDecoder()
        private var sourceType: SourceType? = null
        private var filePath: String? = null
        private var sourceBuffer: ByteArray? = null
        private var isPlaying = AtomicBoolean(false)
    
        interface GifListener {
            fun onGotFrame(bitmap: Bitmap, frame: Int, frameCount: Int)
    
            fun onError()
        }
    
        @UiThread
        fun setFilePath(filePath: String) {
            sourceType = SourceType.SOURCE_PATH
            this.filePath = filePath
        }
    
        @UiThread
        fun setBuffer(buffer: ByteArray) {
            sourceType = SourceType.SOURCE_BUFFER
            sourceBuffer = buffer
        }
    
        @UiThread
        fun start() {
            if (sourceType != null) {
                playThread = Thread(this)
                synchronized(this) {
                    isPlaying.set(true)
                }
                playThread!!.start()
            }
        }
    
        @UiThread
        fun stop() {
            playThread?.interrupt()
        }
    
        @UiThread
        fun pause() {
            synchronized(this) {
                isPlaying.set(false)
                (this as java.lang.Object).notify()
            }
        }
    
        @UiThread
        fun resume() {
            synchronized(this) {
                isPlaying.set(true)
                (this as java.lang.Object).notify()
            }
        }
    
        @UiThread
        fun toggle() {
            synchronized(this) {
                isPlaying.set(!isPlaying.get())
                (this as java.lang.Object).notify()
            }
        }
    
        override fun run() {
            try {
                val isLoadOk: Boolean = if (sourceType == SourceType.SOURCE_PATH) {
                    gifDecoder.load(filePath)
                } else {
                    gifDecoder.load(sourceBuffer)
                }
                val bitmap = gifDecoder.bitmap
                if (!isLoadOk || bitmap == null) {
                    listener.onError()
                    gifDecoder.recycle()
                    return
                }
                var i = -1
                val frameCount = gifDecoder.frameCount
                gifDecoder.setCurIndex(i)
                while (true) {
                    if (isPlaying.get()) {
                        val delay = gifDecoder.decodeNextFrame()
                        Thread.sleep(delay.toLong())
                        i = (i + 1) % frameCount
                        listener.onGotFrame(bitmap, i, frameCount)
                    } else {
                        synchronized(this@GifPlayer) {
                            if (!isPlaying.get())
                                (this@GifPlayer as java.lang.Object).wait()
                        }
                    }
                }
            } catch (interrupted: InterruptedException) {
            } catch (e: Exception) {
                e.printStackTrace()
                listener.onError()
            } finally {
            }
        }
    
    
        internal enum class SourceType {
            SOURCE_PATH, SOURCE_BUFFER
        }
    
    }
    

    After some work, I've got a nice way to do it using HandlerThread. I think it's nicer and probably has better stability. Here's the code:

    open class GifPlayer(private val listener: GifListener) {
            private val uiHandler = Handler(Looper.getMainLooper())
            private var playerHandlerThread: HandlerThread? = null
            private var playerHandler: Handler? = null
            private val gifDecoder: GifDecoder = GifDecoder()
            private var currentFrame: Int = -1
            var state: State = State.IDLE
                private set
            private val playRunnable: Runnable
    
            enum class State {
                IDLE, PAUSED, PLAYING, RECYCLED, ERROR
            }
    
            interface GifListener {
                fun onGotFrame(bitmap: Bitmap, frame: Int, frameCount: Int)
    
                fun onError()
            }
    
            init {
                playRunnable = object : Runnable {
                    override fun run() {
                        val frameCount = gifDecoder.frameCount
                        gifDecoder.setCurIndex(currentFrame)
                        currentFrame = (currentFrame + 1) % frameCount
                        val bitmap = gifDecoder.bitmap
                        val delay = gifDecoder.decodeNextFrame().toLong()
                        uiHandler.post {
                            listener.onGotFrame(bitmap, currentFrame, frameCount)
                            if (state == State.PLAYING)
                                playerHandler!!.postDelayed(this, delay)
                        }
                    }
                }
            }
    
            @Suppress("unused")
            protected fun finalize() {
                stop()
            }
    
            @UiThread
            fun start(filePath: String): Boolean {
                if (state != State.IDLE)
                    return false
                currentFrame = -1
                state = State.PLAYING
                playerHandlerThread = HandlerThread("GifPlayer")
                playerHandlerThread!!.start()
                playerHandler = Handler(playerHandlerThread!!.looper)
                playerHandler!!.post {
                    gifDecoder.load(filePath)
                    val bitmap = gifDecoder.bitmap
                    if (bitmap != null) {
                        playRunnable.run()
                    } else {
                        gifDecoder.recycle()
                        uiHandler.post {
                            state = State.ERROR
                            listener.onError()
                        }
                        return@post
                    }
                }
                return true
            }
    
            @UiThread
            fun stop(): Boolean {
                if (state == State.IDLE)
                    return false
                state = State.IDLE
                playerHandler!!.removeCallbacks(playRunnable)
                playerHandlerThread!!.quit()
                playerHandlerThread = null
                playerHandler = null
                return true
            }
    
            @UiThread
            fun pause(): Boolean {
                if (state != State.PLAYING)
                    return false
                state = State.PAUSED
                playerHandler?.removeCallbacks(playRunnable)
                return true
            }
    
            @UiThread
            fun resume(): Boolean {
                if (state != State.PAUSED)
                    return false
                state = State.PLAYING
                playerHandler?.removeCallbacks(playRunnable)
                playRunnable.run()
                return true
            }
    
            @UiThread
            fun toggle(): Boolean {
                when (state) {
                    State.PLAYING -> pause()
                    State.PAUSED -> resume()
                    else -> return false
                }
                return true
            }
    
        }