Cool your heels, Emacs
TL;DR: Sometimes Emacs needs a timeout
A diamond is very pretty. But it is very hard to add to a diamond. A ball of mud is not so pretty. But you can always add more mud to a ball of mud.
– Gerald Sussman, paraphrasing Joel Moses
A common problem with Emacs’ giant ball of shared state: Any code can step on the feet of any other – including yours. There are conventions in place to keep this from being too much of an issue: Code included in Emacs is tested extensively for edge cases by the tireless maintainers, and third-party package authors are generally careful these days about stepping on too many toes.
In practice the casualty here is not correctness, it’s performance. Emacs can, as of today, only do one thing at a time. So every “background” task must, at some point, surface to the foreground and block Emacs for a bit
Many types of “background” processing are interruptible by signalling a quit
with keyboard-quit
, but then the task isn’t done! This doesn’t help the situation beyond giving you control of Emacs again.
.
The resultant hitching is experienced both with commands you initiate ("push operations", let’s say) and timers that run code without your involvement ("pull operations"). Every hook and timer installed by packages to dazzle you with real-time updates or feedback is another fraction of a second for which Emacs is blocked. Ideally these run when Emacs is idle and you don’t notice them. In practice there are many cases, especially with “push operations”, that involve significant chunks of synchronous auxiliary processing.
Under these limitations, a few obvious solutions present themselves.
As a package author, you can
Use conservative defaults
…or provide user options to selectively turn off or extend these timers. Packages for which responsiveness is a marquee feature generally do a good job of this.
Account for as many contexts as possible
…when writing packages.
Elisp code doesn’t always run in the context that the author intends. A mode-line update in one window can stagger image display in another. In-buffer completion can slow down keyboard macro execution. The more overarching the mode, the larger the surface area for these kinds of interactions. The list of ways in which a sprawling library like Org+plugins can slow down Emacs (and vice-versa) is long and convoluted. Few Emacs packages can afford the foresight to be, essentially, paranoid defensive drivers that guard against this unpredictable traffic.
These examples are not hypothetical
- mode-line update affecting image display: The first example was
pdf-tools
’spdf-misc-size-indication-minor-mode
freezing Emacs when updating LaTeX preview images in an unrelated buffer/window. - The second is a completion-at-point front-end like Company slowing down playback when using Macrursors, a multiple-cursors emulator that uses keyboard macros.
As a user, you can:
Report slowdowns
…so package maintainer(s) can fix them.
This is easier said than done. Binary-searching your way by toggling active modes is tedious. Profiler output can be mysterious without a good sense for how Emacs’ command loop works. A package like explain-pause-mode does some of this work for you, but the causes can be subtle interactions. Generally – but not always – if you are at the point where you can pinpoint causes of slowdowns, you can also avoid them without much trouble.
Use fewer packages
Turn down the bling. Use less mud.
The saner option. Emacs’ first-class extensibility, one of its biggest strengths, is also an inherent limitation. A significant fraction of complaints I read about Emacs being too slow are usually the result of installing packages with wild abandon. This results in busy, full-fat hooks (post-command-hook
, post-self-insert-hook
, window-state-change-hook
, …)
Among many others, like major-mode hooks. These hooks contain functions that run after every Emacs command, after typing in text, after window changes… i.e. all the time.
that bring things to a crawl before long.
All right! That was the preamble. Now that we’ve exhausted the good options, we’re free to ignore the less-is-more approach to code today. It’s time to be irresponsible and fix the giant mud ball by… adding more mud.
Timeouts and Delays
The problem common to both “push” and “pull” slowdowns is non-critical code running more often than it should. Our no-context, no-Chesterton’s-fence solution is to force such code into a timeout. There are two common ways of doing this, with different use patterns, and we’re going to implement both.
First up: Throttle
Throttled code (foo
) is placed in a timeout after it runs once. Attempts to run again for the duration of the timeout (the “Throttle time” above) are ignored. See this resource for an interactive explanation.
In the context of Emacs, throttling works well for “pull operations”, i.e. code that runs non-interactively and independent of user input Typically this means messing with timers, which is generally a bad idea… but we covered all the good solutions already, didn’t we? . Throttling user commands (or their consequents) leads to state changes being completely ignored, which is rarely what we want.
Of course, if packages with over-eager timers allow for customizable eagerness, then the simpler solution is to tweak the frequency directly. Packages that implement “pull operations” are generally conscientious about this. If something is running far more often than it should, searching for the variable(s) <package-name>-*-delay
or <package-name>-*-timer
is a good start to fixing it. Further, these actions typically run on idle timers, which is like a built-in debounce and unlikely to block Emacs in a perceptible way.
But there are many exceptions. What we’d like, in the absence of a provided dial for tuning this, is an elisp function or macro that can easily – and generically – throttle any other elisp function.
Second: Debounce
Debounced code (foo
) runs after a pre-specified delay from when it is called (the “Debounce time” above). If the command is called again before the delay ends, it resets to its full duration. See this resource for an interactive explanation.
In the context of Emacs, debouncing makes more sense with “push operations”. Debounced user commands are ignored when there are too many calls in quick succession, except for the last one. This is typically what we want in an interactive context, since the intermediate states from running each call would have been invalidated by the next call anyway.
Again, we want an elisp function or macro that can easily – and generically – debounce any other elisp function.
Aside: Memoize
i.e. just caching – store the results of expensive calculations and use a look-up table instead.
Emacs 29+ includes a handy with-memoization
macro to facilitate this for any chunk of code.
We do a bit of caching with the throttle implementation below. But in general this doesn’t work for our purposes because the functions we want to use this with are rarely pure – they work by side-effect using (and on) Emacs’ current state as a potential input. So there’s no way to provide a generic memoization routine without knowing what the specific code in question is doing.
throttle!
and debounce!
What we’d like: given a command or plain function foo
, modify it so that
(throttle! 'foo 2.0)
throttles it to run no more than once every 2 seconds, and
(debounce! 'foo 0.5)
debounces it to run with a delay of 0.5 seconds.
We can also envision the safer, “non-destructive” versions
Although our versions of throttle!
(etc) won’t be destructive because of Emacs’ robust advice system.
that return new functions, so we could do
(fset 'foo-throttled (timeout 'foo 2.0))
(fset 'foo-debounced (debounce 'foo 0.5))
This turns out to actually be trickier to do well (see below). For the in-place versions, we can rely on Emacs’ advice system to do most of the work for us.
Before we implement throttle!
and debounce!
, let’s look at the kinds of thing we’d like to do with them.
Example: throttle smart-mode-line
updates
Updating the mode-line display is one of the exceptions I mentioned in the throttle description. Typically the mode-line is updated along with redisplay, and Emacs is quite fast at handling all display elements that Emacs has C-level support for (typically the %
-prefixed format specifiers).
For efficiency, Emacs does not continuously recompute each window’s mode line and header line. It does so when circumstances appear to call for it—for instance, if you change the window configuration, switch buffers, narrow or widen the buffer, scroll, or modify the buffer.
– Mode Line Basics, elisp manual.
- The problem is that it appears to compute every element on the mode-line under any of these circumstances.
- You can evaluate arbitrary Lisp code during a mode-line update, and we sure do like to stuff the Emacs mode-line with fancy widgets in
:eval
blocks.
The combination of these two facts is a recipe for slowdown.
Case in point: the smart-mode-line
package uses the function sml/generate-minor-modes
to display active minor-modes in the mode-line with rich formatting. Generating this string
A rather plain one too, despite support for rich text.
takes up to 10% of Emacs’ time during routine text-editing.
As the Emacs profiler illustrates:
Samples % Function
---------------------------------------------
1076 65% - redisplay_internal (C function)
705 43% + jit-lock-function
404 22% - eval
220 13% + and-let*
--> 166 9% + sml/generate-minor-modes <-- PRE-THROTTLE
4 0% sml/generate-modified-status
52 3% - ...
52 3% Automatic GC
Recomputing the minor-modes string after every buffer insertion or window scroll is a waste. We’re actually fine so long as it’s updated soon after a minor-mode is enabled or disabled. So we throttle this function:
;; only allowed to run once every 4 seconds.
(throttle! 'sml/generate-minor-modes 4.0)
This frees up Emacs to do other things.
Samples % Function
----------------------------------------
1482 66% - redisplay_internal (C function)
733 33% - eval
681 30% + and-let*
17 0% sml/generate-modified-status
--> 9 0% + sml/generate-minor-modes <-- POST-THROTTLE
6 0% + format
700 31% + jit-lock-function
117 5% - ...
117 5% Automatic GC
Throttling lets us work around, in a coarse way, our lack of control over the mode-line update logic.
Throttle vs Debounce
In this example, using a debounce would have worked too… for the most part. There would be scenarios where it wouldn’t work well, though, such as turning on/off a minor mode and then immediately scrolling the window or typing. If we can tolerate slightly-out-of-date reports of Emacs’ state, throttling is generally the better option.
Example: debounce org-agenda-follow-mode
An example of an Org-mode feature that’s too eager for its own good: when following along entries with a preview in the Org Agenda, running down the list to the next item you’re interested in causes previews to be generated for each intermediate item (first video). Org does a fair bit of work – relative to the cursor movement speed - to set up the preview display, causing Emacs to hitch.
Let’s debounce this feature:
;; run after a resettable delay of 0.3 seconds.
(debounce! 'org-agenda-do-context-action 0.3)
Throwing in a debounce causes the intermediate cursor positions to be ignored (second video), making the process smoother and less visually noisy while keeping Emacs responsive.
Debouncing lets us work around, in a coarse way, user input that’s coming too fast to be smoothly acted on.
An inside-out implementation
To implement throttle!
and debounce!
, we can work our way outwards from the specification of what throttles and debounces should do.
But before we dig in…
This is primarily a fun elisp exercise.
If you are considering throttling elisp from outside of packages as an actual solution to addressing performance issues, you should have already tried the better options listed at the start of this write-up. We don’t play with mud balls without getting our hands dirty.
A throttled function will:
- Run when first called,
- and start keeping time.
- If called again within the timeout period,
- it refuses to run or returns immediately.
The core of this idea is captured in this thunk:
;; FUNC is the function to be throttled, called with arguments ARGS
;; TIMEOUT is the throttle duration
;; If the timer is running
(if (and throttle-timer (timerp throttle-timer))
;; do nothing, return nil
nil
;; else run and start the timer
(prog1 (apply func args)
(setq throttle-timer
(run-with-timer
timeout nil
(lambda ()
;; clear the timer
(cancel-timer throttle-timer)
(setq throttle-timer nil))))))
where throttle-timer
and timeout
need to be persistent between calls to this function.
The debounce version is built similarly. A debounced function will:
- Not run immediately, and start a timer when first called.
- If called again before the timer expires,
- the timer is reset and starts over.
- If the timer ends, the function finally runs.
The core of this process is captured in the following thunk:
;; FUNC is the function to be throttled, called with arguments ARGS
;; DELAY is the debounce period
;; If the timer is running
(if (timerp debounce-timer)
;; Do not run the function, reset the timer instead
(timer-set-idle-time debounce-timer delay)
;; start the timer over
(setq debounce-timer
(run-with-idle-timer
delay nil
(lambda ()
(setq debounce-timer nil)
;; Timer ran, finally run the function
(apply func args)))))
where debounce-timer
and delay
need to be persistent between calls to this function. We use an idle-timer when debouncing, and a regular timer for the throttle. Using an idle timer here is technically incorrect, but the better choice since func
is presumably an expensive call. The throttle timer only has to clear itself, which is cheap.
Next, we need to wrap these in a function that effectively acts as the throttled/debounced func
.
If you are trying this in Emacs, be sure to turn on lexical-binding
first.
(let ((throttle-timer)
(timeout 1.0))
(lambda (&rest args)
"Run with a throttle."
(if (and throttle-timer (timerp throttle-timer))
;; do nothing, return nil
nil
;; else run and start the timer
(prog1 (apply func args)
(setq throttle-timer
(run-with-timer
timeout nil
(lambda ()
;; clear the timer
(cancel-timer throttle-timer)
(setq throttle-timer nil))))))))
throttle-timer
and timeout
are now defined in the enclosing environment. Provided lexical-binding
is turned on, the inner lambda is a closure and these two objects will be persistent across calls to this closure.
func
is still undefined/free. We place the above code into a “throttle-maker” function that “closes over” func
:
(defun throttle (func &optional timeout)
"Return a throttled version of FUNC.
It runs at most once every TIMEOUT seconds (default 1.0)."
(let ((throttle-timer)
(timeout (or timeout 1.0)))
(lambda (&rest args)
(if (and throttle-timer (timerp throttle-timer))
nil
(prog1 (apply func args)
(setq throttle-timer
(run-with-timer
timeout nil
(lambda ()
(cancel-timer throttle-timer)
(setq throttle-timer nil)))))))))
Note that this is the “non-destructive” version, it returns a new function instead of modifying func
.
While we’re at it, we can return the previous result instead of nil
during the throttle duration. This is unimportant for top-level interactive commands, but might at least help avoid type errors if this is a pure function, or if we’re monkey-patching something deep inside an elisp library.
We effectively memoize the result
of calling func
:
(defun throttle (func &optional timeout)
"Return a throttled version of FUNC.
It runs at most once every TIMEOUT seconds (default 1.0)."
(let ((throttle-timer)
(timeout (or timeout 1.0))
(result))
(lambda (&rest args)
(if (and throttle-timer (timerp throttle-timer))
result
(prog1 (setq result (apply func args))
(setq throttle-timer
(run-with-timer
timeout nil
(lambda ()
(cancel-timer throttle-timer)
(setq throttle-timer nil)))))))))
The debounce version is similar, but there’s nowhere to return the result
to this time. func
may be called from anywhere in the code but runs (eventually, via the timer) at top-level, so it doesn’t make sense to store its return value.
We also couldn’t if we wanted to, since the return value doesn’t exist yet.
To satisfy the calling code, we can prespecify a constant return value when constructing the function.
There’s a new wrinkle as well: the throttled func
runs immediately or not at all. The debounced func
, however, runs at a later time when the environment can be different – or invalid. So we need to capture as much of the current context as we can and restore it when func
runs. Capturing the full context at call time is not possible (it could be anything)
As far as I know. This might be possible in other Lisps with continuations, etc.
, but we can set the active buffer at least.
Adding a default
“immediate-return” value and the calling context, we get
(defun debounce (func &optional delay default)
"Return a debounced version of FUNC.
It runs with a delay of DELAY seconds (default 0.5).
DEFAULT is the immediate return value of the function when
called."
(let ((debounce-timer)
(delay (or delay 0.5)))
(lambda (&rest args)
(if (timerp debounce-timer)
(timer-set-idle-time debounce-timer delay)
(prog1 default
(setq debounce-timer
(run-with-idle-timer
delay nil
(lambda (buf)
(setq debounce-timer nil)
(with-current-buffer buf ;set buffer when running
(apply func args)))
(current-buffer))))))))
We’re on the right track, but there are many issues with throttle
and debounce
above:
- The generated closures don’t have
interactive
forms, so they cannot be run as commands. We can’t add interactive forms unconditionally, since we might want to throttle/debounce non-interactive functions too. - The generated closures don’t have any documentation. Assigning them to variables with
fset
will leave them (mostly) non-introspectable. - Most importantly, we wanted a
throttle!
anddebounce!
to corral existing errant elisp code, not generate new, quieter variants.
It turns out most of these issues can be resolved in one go, with some…
Advising around
We have on hand our throttled and debounced versions of func
, albeit lacking some niceties. To replace func
with these variants, we will depend on Emacs’ advice system. We’re specifically looking for a recipe that routes around func
while letting us call it when necessary. The :around
advice combinator fits the bill:
‘:around’
Call
FUNCTION
instead of the old function, but provide the old function as an extra argument toFUNCTION
. This is the most flexible composition. For example, it lets you call the old function with different arguments, or many times, or within a let-binding, or you can sometimes delegate the work to the old function and sometimes override it completely. More specifically, the composition of the two functions behaves like:(lambda (&rest r) (apply FUNCTION OLDFUN r))
-Manual section on Advice Combinators (emphasis mine)
This time let’s work our way outside-in. The basic idea is:
- Define a function that accepts as arguments the original function (
oldfun
) and its arguments. - Add this as
:around
advice tooldfun
- Emacs runs this function instead of
oldfun
.
(defun newfun (oldfun &rest args)
;; do stuff
(if predicate
(apply oldfun args)
;; do something else
(apply foo args)))
(advice-add 'oldfun :around 'newfun)
Our newfun
is the generated closure (throttled or debounced) from the previous section. Here’s the same throttled closure as before, but following the above pattern instead:
(let ((throttle-timer)
(timeout 1.0)
(result))
(lambda (oldfun &rest args)
"Throttle this function."
(if (and throttle-timer (timerp throttle-timer))
result
(prog1 (setq result (apply oldfun args))
(setq throttle-timer
(run-with-timer
timeout nil
(lambda ()
(cancel-timer throttle-timer)
(setq throttle-timer nil))))))))
This closure, intended as :around
advice, accepts oldfun
as an argument along with args
.
This time there’s nothing to “close over” save the (customizable) timeout
:
(defun throttle--advice (&optional timeout)
(let ((throttle-timer nil)
(timeout (or timeout 1.0))
(result))
(lambda (oldfun &rest args)
"Throttle this function."
(if (and throttle-timer (timerp throttle-timer))
result
(prog1 (setq result (apply oldfun args))
(setq throttle-timer
(run-with-timer
timeout nil
(lambda ()
(cancel-timer throttle-timer)
(setq throttle-timer nil)))))))))
throttle--advice
is an “advice maker” that, when called with a timeout, returns an “advice function” that throttles the function it advises. Phew!
Of course, we need to actually add this advice:
(advice-add 'foo :around (throttle--advice 1.0))
This advice (closure) is nameless – we require a way to refer to it so that we can restore foo
to its un-throttled, over-eager, Emacs-blocking state. We should also add this advice last, in that any other advice placed on foo
should take precedence – the result of throttling the function would be unpredictable otherwise.
advice-add
provides the machinery to do both these things:
(advice-add 'foo :around (throttle--advice 1.0)
'((name . throttle)
(depth . -99)))
Wrapping up this action in a function (finally) gives us the throttle!
we are looking for:
(defun throttle! (func &optional timeout)
(if (= timeout 0)
(advice-remove 'func 'throttle)
(advice-add func :around (throttle--advice timeout)
'((name . throttle)
(depth . -99)))))
Instead of defining an unthrottle!
, we make it so passing a timeout
of 0
to throttle!
restores the original function.
debounce!
works the exact same way:
;; advice-maker
(defun debounce--advice (&optional delay default)
(let ((debounce-timer nil)
(delay (or delay 0.5)))
(lambda (oldfun &rest args)
"Debounce this function."
(if (timerp debounce-timer)
(timer-set-idle-time debounce-timer delay)
(prog1 default
(setq debounce-timer
(run-with-idle-timer
delay nil
(lambda (buf)
(setq debounce-timer nil)
(with-current-buffer buf
(setq result (apply oldfun args))))
(current-buffer))))))))
;; advice-adder or remover
(defun debounce! (func &optional delay)
(if (= delay 0)
(advice-remove func 'debounce)
(advice-add func :around (debounce--advice delay)
'((name . debounce)
(depth . -98)))))
debounce--advice
is an “advice maker” that, when called with a delay, returns an “advice function” that debounces the function it advises. debounce!
can then add or remove this advice function to its argument.
Fortuitously, the other two problems – mirroring the interactive spec and adding function documentation – have worked themselves out.
- Advice functions that don’t have an interactive spec will reuse that of the function they are advising.
- The documentation of the original function is still available, with a note about the advice at the end. This note is the documentation of the advice function itself, i.e. “Throttle this function”.
Top-level vs nested calls: what can we timeout?
A short digression on Emacs’ command loop.
Throttling or debouncing code that runs at top-level, such as user commands or timers, is simple. Since there’s nothing for them to “return to”, introducing delays or (effectively) refusing to run code has no consequences beyond a delay in the corresponding library’s understanding of Emacs’ current state.
Things get tricky when we mess with “internal” code. Often it’s not user commands themselves that need debouncing, but something heavy lurking well inside a hook that runs as a result of the command. Our throttle example above (sml/generate-minor-modes
) is a “hook function” triggered by mode-line updates. Our debounce example (org-agenda-do-context-action
) is a function called from deep inside the org-agenda
machinery when the cursor moves. Since
- throttled functions can return
nil
or repeat a previous result, and - debounced functions always return
nil
immediately,
we require nil
to be (at minimum) a valid return value wherever they are called, and (better) a value that stops further processing in the moment.
Picking a different return value
The same is true for any other non-nil
value we choose to return (see implementation). Reusing return values from past calls to debounced functions is not possible without setting up global variables… which is a project for another time.
Having throttled and debounced a bunch of internal functions now, I can report that this is generally true of elisp code that needs a timeout, so long as a little care is exercised in picking them.
throttle
and debounce
: Non-destructive finagling
So why is:
(fset 'foo-debounced (debounce 'foo 0.5))
difficult to pull off well?
We have the basic skeleton in place from before:
(defun debounce (func &optional delay default)
"Return a debounced version of FUNC.
It runs with a delay of DELAY seconds (default 0.5).
DEFAULT is the immediate return value of the function when
called."
(let ((debounce-timer)
(delay (or delay 0.5)))
(lambda (&rest args)
(if (timerp debounce-timer)
(timer-set-idle-time debounce-timer delay)
(prog1 default
(setq debounce-timer
(run-with-idle-timer
delay nil
(lambda (buf)
(setq debounce-timer nil)
(with-current-buffer buf ;set buffer when running
(apply func args)))
(current-buffer))))))))
The problems are:
- Mirroring the interactive form of
func
. - Documenting the unnamed closure that
debounce
returns.
Mirroring interactive
forms
We can lean on the advice system again for the former, but it’s ugly. We essentially write both versions of the function and pick one. Here’s throttle
:
Mirroring interactive forms: throttle
(defun throttle (func &optional throttle default)
"Return a throttled version of function FUNC.
THROTTLE defaults to 1 second."
(let ((throttle-timer nil)
(throttle (or throttle 1))
(iform (interactive-form func))
(result default))
(if iform
;; INTERACTIVE version
(lambda (&rest args)
(interactive (advice-eval-interactive-spec
(cadr iform)))
(if (and throttle-timer (timerp throttle-timer))
result
(setq result (apply func args))
(setq throttle-timer
(run-with-timer
throttle nil
(lambda ()
(cancel-timer throttle-timer)
(setq throttle-timer nil))))))
;; NON-INTERACTIVE version
(lambda (&rest args)
(if (and throttle-timer (timerp throttle-timer))
result
(setq result (apply func args))
(setq throttle-timer
(run-with-timer
throttle nil
(lambda ()
(cancel-timer throttle-timer)
(setq throttle-timer nil)))))))))
debounce
is similar.
Inserting function documentation
Named symbols can have function documentation inserted directly into their function-documentation
symbol property. Documenting unnamed closures programmatically is beyond me at this point – unless of course, we embrace the idea that we’re writing code that writes code…
It’s macrotime!
At this point it would actually be easier to get Emacs to write the code for us. This process is much more accessible (if not simpler) with a macro. We can fetch the relevant interactive form and documentation and shove them verbatim into the closure definition:
`(lambda (&rest args)
;; Add the documentation from FUNC with an extra note
,(concat
(documentation func)
(format "\n\nThis function is debounced -- it runs after a delay of %.3f seconds." delay))
;; Add the interactive form from FUNC
,(interactive-form func)
;; Rest of the debounce code from above
)
While we’re at it, we can even skip the fset
step and just define the new function func--debounced
directly:
`(defun ,(intern (concat (symbol-name func) "--debounced")) (&rest args)
;; Add the documentation from FUNC with an extra note
,(concat
(documentation func)
(format "\n\nThis function is debounced -- it runs after a delay of %.3f seconds." delay))
;; Add the interactive form from FUNC
,(interactive-form func)
;; Rest of the debounce code from above
)
Adding the lexical environment (for debounce-timer
etc) and the rest of the macro definition, we get
If you are trying this in Emacs, be sure to turn on lexical-binding
first.
(defmacro debounce (func &optional delay)
"Return a debounced version of function FUNC.
DELAY defaults to 0.5 seconds."
`(let ((debounce-timer nil)
(delay (or ,delay 0.5)))
(defun ,(intern (concat (symbol-name func) "--debounced")) (&rest args)
;; Add the documentation from FUNC with an extra note
,(concat
(documentation func)
(format "\n\nThis function is debounced -- it runs after a delay of %.3f seconds." delay))
;; Add the interactive form from FUNC
,(interactive-form func)
(if (timerp debounce-timer)
(timer-set-idle-time debounce-timer delay)
(let ((buf (current-buffer)))
(setq debounce-timer
(run-with-idle-timer
delay nil
(lambda ()
(cancel-timer debounce-timer)
(setq debounce-timer nil)
(with-current-buffer buf
(apply #',func args))))))))))
This works as we intended, only the function name should no longer be quoted. The following code
(debounce next-line 0.4)
expands to a definition for next-line--debounced
, replete with the correct documentation and interactive
behavior
Or we could stick to the anonymous closure version and run (fset 'next-line--debounced (debounce next-line))
.
For completeness, here is throttle
as a macro:
(defmacro throttle (func &optional timeout default)
"Return a throttled version of function FUNC.
TIMEOUT defaults to 1 second."
`(let ((throttle-timer nil)
(timeout (or ,timeout 1.0))
(result ,default))
(defun ,(intern (concat (symbol-name func) "--debounced")) (&rest args)
,(concat
(documentation func)
(format "\n\nThis function is throttled -- it runs at most once every %.3f seconds." timeout))
,(interactive-form func)
(if (and throttle-timer (timerp throttle-timer))
result
(setq result (apply #',func args))
(setq throttle-timer
(run-with-timer
timeout nil
(lambda ()
(cancel-timer throttle-timer)
(setq throttle-timer nil))))))))
Questions
A few concerns bubbled up on this excursion, and I don’t know how to address them:
-
Debouncing a function a few hundred times is easy to do just by holding down a key.
This creates and destroys many timers – I don’t know if the ensuing garbage is worth worrying about, and whether it can be avoided.This is now avoided in the code, see acknowledgments. Here is theatimer
struct that Emacs uses:struct atimer { /* The type of this timer. */ enum atimer_type type; /* Time when this timer is ripe. */ struct timespec expiration; /* Interval of this timer. */ struct timespec interval; /* Function to call when timer is ripe. Interrupt input is guaranteed to not be blocked when this function is called. */ atimer_callback fn; /* Additional user-specified data to pass to FN. */ void *client_data; /* Next in list of active or free atimers. */ struct atimer *next; };
This is… about 48 bytes of memory?
Debouncing a command 200 times – not an uncommon occurrence – then creates at least 10KB of garbage. How does this compare with the regular rate of garbage creation in Emacs? -
How long does it take, realistically speaking, to create and clear timers? Some testing suggests that this is not an insignificant number when done in bulk, especially coupled with garbage collection.
-
Using a macro to implement the “non-destructive” versions of
throttle!
anddebounce!
does not pair well with our functions – the call signatures are different. Perhaps there is a way to do this with functions that isn’t janky?
The timeout
library
This functionality is available as an elisp package: timeout. So far it’s exactly the code developed above, and just a couple of helper functions.
Writing debouncers and throttlers this way wasn’t a careful design process on my part. In fact, I have no idea how else I could write them in elisp. This suggests that I don’t understand the problem very well, or don’t understand elisp well, or (more likely) both. If you have corrections, suggestions or answers for me, please get in touch.
All right then – that’s it for me. Gotta debounce!
Glossary
- Idle timer
- An idle timer is some Lisp code scheduled to run when Emacs is idle for a specified period. What it means for Emacs to be idle is complicated: at minimum, this means no user input, no subprocess communication and no non-idle timers that need running. [2 uses]
- Mode-line
- This is the “status bar” at the bottom of Emacs windows. There are dozens of Emacs packages that prettify the mode-line, adding widgets and status reports. I’ve found many of these to be too aggressive in how often they update. [10 uses]
- Push operations
- Slow elisp code that runs as a direct result of user input. Example: an html preview of a markdown buffer that updates with each keystroke. Less exreme examples: sending data to an LSP server when some code is yanked into a buffer, or a header-line update because the cursor moved into a new function. So named because control of Emacs is “pushed” away as a result of the input action. [3 uses]
- Pull operations
- Slow elisp code that runs independent of user input, although it might be interrupted by it. So named because control of Emacs is “pulled” away by this code irrespective of user input. Examples: any idle timer not tied to user input, like RefTeX’s periodic scans of the LaTeX buffer. Of course, ultimately everything that happens in Emacs is user-driven and thus a “push operation”. This distinction is only one of proximity to the user. [3 uses]
- Redisplay
- This is the process of redrawing the screen, or more often parts of it, in Emacs. Considering Emacs’ terminal user interface and support for every platform under the sun, displaying is a complicated affair. [1 uses]
Acknowledgments
Joseph Turner points out that timerp
handles the case when its argument is nil
, so there is no need to check explicitly, as in (if (and debounce-timer (timerp debounce-timer)))
.
Ihor Radchenko points out that timer attributes (such as the remaining time) can be modified in place with the timer-set-*
functions. This completely fixes the problem of debounce timers generating a lot of garbage.