8.8 KiB
Solving The CPU Hog Problem
Sometimes a handler has a lot of CPU intensive work to do, and getting through it will take a while.
When a handler hogs the CPU, nothing else can happen. Browsers
only give us one thread of execution and that CPU-hogging handler
owns it, and it isn't giving it up. The UI will be frozen and
there will be no processing of any other handlers (eg: on-success
of POSTs), etc, etc. Nothing.
And a frozen UI is a problem. GUI repaints are not happening. And user interactions are not being processed.
How are we to show progress updates like "Hey, X% completed"? Or how can we handle the user clicking on that "Cancel" button trying to stop this long running process?
We need a means by which long running handlers can hand control back for "other" processing every so often, while still continuing on with their computation.
The re-frame Solution
First, all long running, CPU-hogging processes are put in event handlers. Not in subscriptions. Not in components. Not hard to do, but worth establishing as a rule, right up front.
Second, you must be able to break up that CPU work into chunks. You need a way to do part of the work, pause, then resume from where you left off. (More in min).
In a perfect world, each chunk would take something like 16ms (60 fps). If you go longer, say 50ms or 100ms, it is no train smash, but UI responsiveness will degrade and animations, like busy spinners, will get jerky. Shorter is better, but less than 16ms delivers no added smoothness.
Third, within our handler, after it completes one unit (chunk)
of work, it should not continue straight on with the next. Instead,
it should do a dispatch
to itself and, in the event vector,
include something like the following:
- a flag to say the work is not finished
- the working state so far; and
- what chunk to do next.
A Sketch
Here's an -fx
handler which counts up to some number in chunks:
(re-frame.core/reg-event-fx
:count-to
(fn
[{db :db} [_ first-time so-far finish-at]]
(if first-time
;; We are at the beginning, so:
;; - modify db, causing popup of Modal saying "Working ..."
;; - begin iterative dispatch. Give initial version of "so-far"
{:disptch 1[:count-to false {:counter 0} finish-at] ;; dispatch to self
:db (assoc db :we-are-working true)}
(if (> (:counter so-far) finish-at)
;; We are finished:
;; - take away the state which causes the modal to be up
;; - store the result of the calculation
{:db (-> db
(assoc :fruits-of-labour (:counter so-far)) ;; remember the result
(assoc :we-are-working false))} ;; no more modal
;; Still more work to do
;; - run the calculation
;; - redispatch, passing in new running state
(let [new-so-far (update so-far :counter inc)]
{:dispatch [:count-to false new-so-far finish-at]}))))
Why Does A Redispatch Work?
A dispatched
event is handled asynchronously. It is queued
and not actioned straight away.
And here's the key: After handling current events, re-frame yields control to the browser, allowing it to render any pending DOM changes, etc. After it is finished, the browser will hand control back to the re-frame router loop, which will then handle any other queued events which, in our case, would include the event we just dispatched to perform the next chunk of work.
When the next dispatch is handled, a next chunk of work will be done, and then another
dispatch
will happen. And so on. dispatch
after dispatch
. Chunk
after chunk. In 16ms increments if we are very careful (or some small amount
of time less than, say, 100ms). But with the browser getting a look-in after each iteration.
Variations
As we go, the handler could be updating some value in app-db
which indicates
progress, and this state would then be rendered into the UI.
At a certain point, when all the work is done, the handler will likely put the
fruits of its computational labour into app-db
and clear any flags which might, for example,
cause a modal dialog to be showing progress. And the process would then be done.
Cancel Button
It is a flexible pattern. For example, it can be tweaked to handle a "Cancel' button ...
If there was a “Cancel” button to be clicked, we might
(dispatch [:cancel-it])
and then have this event’s handler tweak the app-db
by adding :abandonment-required
flags. When a chunk-processing-handler
next begins, it could check for this :abandonment-required
flag, and,
if found, stop the CPU intensive process (and clear the abandonment flags).
When the abandonment-flags
are set, the UI could show "Abandoning process ..." and thus appear responsive
to the user's click on “Cancel”.
That's just one approach. You can adapt the pattern as necessary.
Further Notes
Going to this trouble is completely unnecessary if the long running task involves I/O (GET, POST, HTML5 database action?) because the browser will handle I/O in another thread and give UI activities plenty of look in.
You only need to go to this trouble if it is your code which is hogging the CPU.
Forcing A One Off Render
Imagine you have a process which takes, say, 5 seconds, and chunking is just too much effort.
You lazily decide to leave the UI unresponsive for that short period.
Except,
you aren't totally lazy. If there was a button which kicked off
this 5 second process, and the user clicks it, you’d like the UI to
show a response. Perhaps it could show a modal popup thing saying
“Doing X for you”.
At this point, you still have a small problem to solve. You want the UI to show your modal message before you then hog the CPU for 5 seconds.
Updating the UI means altering app-db
. Remember, the UI is a
function of the data in app-db
. Only changes to app-db
cause UI
changes.
So, to show that Modal, you’ll need to assoc
some value into app-db
and have that new value change what is rendered in your reagent components.
You might be tempted to do this:
(re-frame.core/reg-event-db
:process-x
(fn
[db event-v]
(assoc db :processing-X true) ;; hog the CPU
(do-long-process-x))) ;; update state, so reagent components render a modal
But that is just plain wrong.
That assoc
into db
is not returned (and it must be for a -db
handler).
And, even if that did somehow work,
then you continue hogging the thread with do-long-process-x
. There's no
chance for any UI updates because the handler never gives up control. This
handler owns the thread right through.
Ahhh, you think. I know what to do! I'll use that pattern I read
about in the Wiki, and re-dispatch
within an-fx
handler:
(re-frame.core/reg-event-fx
:process-x
(fn
[{db :db} event-v]
{:dispatch [:do-work-process-x] ;; do processing later, give CPU back to browser.
:db (assoc db :processing-X true)})) ;; ao the modal gets rendered
(re-frame.core/reg-event-db
:do-work-process-x
(fn [db _]
(do-long-process-x db))) ;; return a new db, presumably containing work done
So close. But it still won’t work. There's a little wrinkle.
That event handler for :process-x
will indeed give back control
to the browser. BUT, because of the way reagent works, that assoc
on db
won't trigger DOM updates until the next animation frame runs, which is 16ms away.
So, you will be yielding control to the browser, but for next 16ms there won't appear to be anything to do. And, by then, your CPU hogging code will have got control back, and will keep control for the next 5 seconds. That nice little Dialog telling you the button was clicked and action is being taken won't show.
In these kinds of cases, where you are only going to give the UI one chance to update (not a repeated chances every few milli seconds), then you had better be sure the DOM is fully synced.
To do this, you put meta data on the event being dispatched:
(re-frame.core/reg-event-fx
:process-x
(fn
[{db :db} event-v]
{:dispatch ^:flush-dom [:do-work-process-x] ;; <--- NOW WITH METADATA
:db (assoc db :processing-X true)})) ;; ao the modal gets rendered
Notice the ^:flush-dom
metadata on the event being dispatched. Use
that when you want the UI to be fully updated before the event dispatch
is handled.
You only need this technique when you:
- want the DOM to be fully updated
- because you are going to hog the CPU for a while and not give it back. One chunk of work.
If you handle via multiple chunks you don't have to do this, because you are repeatedly handing back control to the browser/UI. Its just when you are going to tie up the CPU for a one, longish chunk.