-
-
Notifications
You must be signed in to change notification settings - Fork 716
Solve the CPU hog problem
This document is out of date.
A more modern version can be found within the repo
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.
Luckily, re-frame has a solution.
First, all long running, CPU-hogging processes are put in handlers. Not in subscriptions. Not in components. This is not hard to do, but worth highlighting.
Second, you organise for your handler 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 throbbers, will get jerky. Shorter is better, but less than 16ms delivers no added smoothness.
Third, after a handler completes a 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.
As a sketch, here's a handler which counts up to some number:
(defn count-to ;; assume this is the handler for :count-to
[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"
(do
(dispatch [:count-to false {:counter 0} finish-at]) ;; dispatch to self
(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
(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-in so-far [:counter] inc)]
(dispatch [:count-to false new-so-far finish-at])
db))))
A dispatched
event is handled asynchronously. It is put on the conveyor belt, and not actioned straight away.
And here's the key: After handling each event, 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 100ms). But with the browser getting a look-in after each iteration.
As we go, the handler might be updating some value in app-db
which indicates
progress, and this state might well 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.
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 these abandonment-required flags, 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.
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.
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:
(defn my-handler
[db event-v]
(assoc db :processing-X true) ;; update state, so reagent components render a modal
(do-long-process-x))) ;; now hog the CPU
But that is hopeless, in so many ways, right, apart from being just plain wrong? That assoc
into db
is not returned. 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 the handler:
(defn my-handler
[db event-v]
(dispatch [:do-process-x]) ;; do processing later, give CPU back to browser.
(assoc db :processing-X true))) ;; update state, so the components render a modal
(register
:do-process-x
(fn [db _]
(do-long-process-x db))) ;; must return a new db
So close. But it still won’t work. There's a little wrinkle.
That (dispatch [:do-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:
(defn my-handler
[db event-v]
(dispatch ^:flush-dom [:do-process-x]) ;; <--- NOW WITH METADATA
(assoc db :processing-X true))) ;; return new version of db !!
Notice the ^:flush-dom
metadata on the event being dispatched. Use that when you want the UI to be fully updated before the 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.
Deprecated Tutorials:
Reagent: