Threads and locks
state.Update; and the three locks are always taken in the same order, so the app can never freeze waiting for itself.The last chapter followed one tap. But while you tap, other goroutinesGoroutine: Go’s lightweight thread. Inkshelf starts them only from the main thread, with state.Go. are at work: one downloads a book, one loads covers, one prepares the next pages, one sends your reading progress. Each is simple on its own. Together they could step on each other's data, or wait for each other forever. This chapter is the handful of rules that prevent both.
One main thread draws​
The event loop calls the app (Init, Draw, Key, Pointer, System, Close) from one
goroutine. We call it the main thread. It is the only one that draws, for a hard reason: the
CanvasCanvas: Where Inkshelf draws: a 1404Ă—1872 gray image in shared memory, upright. A refresh copies it, rotated, to the framebuffer. is one image in memory, and the panel is one device, driven by
ioctlsioctl: A system call that sends a device-specific command to a driver, here to ask the panel for a refresh (MXCFB_SEND_UPDATE).. Two goroutines drawing at once would mix their pixels and their
refreshes.
The other goroutines never call the app and never draw. When they need the event loop, they
ask through functions that only queue a request: pocketbook.Repaint (call Draw soon),
pocketbook.Exit and pocketbook.PreventSleepFor (keep the reader awake during a download).
Each request is a channel that holds one; a second identical request, sent before the loop
reads the first, is dropped, since it would do the same thing.
The others only change the state, inside Update​
A goroutine that has news (a page downloaded, a list loaded) changes the state inside
state.Updatestate.Update: The only way another goroutine changes the state: take the lock, change it, and ask for one repaint if needed., and nowhere else. Update holds the mutex while the
change runs, so the main thread never draws a half-changed state. Reading is the same: a
goroutine that needs a value takes the mutex for a moment, copies the value, and lets go
before doing anything slow.
app.Lock()
book, save := app.Reader.OpenBook, app.Reader.Unsaved
app.Reader.Unsaved = false
app.Unlock()
if save {
app.Store.Save(book) // the slow part, without the mutex
}
Only the main thread starts goroutines​
Goroutines are started by the state, from the main thread only:
state.Go(name, task)runs a one-off task: loading the session, a download, sending a book's read state. It counts as background work namednamewhile it runs.state.GoLoop(task)runs a goroutine that lives until the app closes, waiting to be woken: the page preloader (openbook.Preloader) and the cover loader (covers.Loader).
Why only the main thread? Because Close, on the main thread, cancels app.Ctx and then waits
for every goroutine started this way. If some other goroutine could start a new one at that
moment, the app could close with it still running. With one starter, there is no such moment.
The …Locked suffix​
A function whose name ends in Locked runs with the state's mutex already held, and never takes
it itself (…Locked…Locked: A function whose name ends in Locked runs with the state’s mutex already held, and never takes it itself.). The suffix is a promise written in the name, checked by
every reader of the code:
- Calling a
…Lockedfunction without the mutex is a race: another goroutine may change the same fields at the same time. - Taking the mutex inside a
…Lockedfunction is a deadlock: Go's mutexes are not reentrant, so it waits for itself.
Every screen's DrawLocked, and everything it calls, runs with the mutex held.
downloadqueue.EnqueueLocked, openbook.OpenLocked and state.ShowLocked are called from
inside an Update's change.
Three locks, one order​
The app has three locks that matter:
| Order | Lock | Held while |
|---|---|---|
| 1 | the progress.Syncer's lock | it sends reading progress to the server |
| 2 | the state's mutex | anything reads or changes the state, and while drawing |
| 3 | a reading.TextBook's renderer lock | an EPUB chapter is laid out or a page drawn |
They are always taken in this order, never the other way round. The Syncer, while pushing,
takes the state's mutex for short moments (to find the server, to mark a book synced). A
Draw of the reader holds the state's mutex and may draw a page of an EPUB, which takes the
renderer's lock. The renderer never calls back into the app.
The rule that follows is written in the project's rules for contributors (CLAUDE.md): never call the Syncer with the state's
mutex held. Why it matters is easiest to see by breaking it. A DeadlockDeadlock: Two goroutines each wait for a lock the other holds, forever. A fixed lock order prevents it. needs a
cycle: goroutine 1 holds lock A and waits for B, while goroutine 2 holds B and waits for A.
The game tries every interleaving of the two goroutines' steps, the way an unlucky scheduler
might, and plays the first that deadlocks. With a single global order, the goroutine that takes
the first shared lock always gets to finish, so no cycle can form. (In the "forbidden call",
it is even worse than the game shows: PushNow itself takes the state's mutex, so a
goroutine already holding it would wait for itself.)
The page preloader shows the order paying off: it draws the next pages of an EPUB holding only the renderer's lock, not the state's mutex, so a tap is never stuck behind a slow layout.
Wakeup: a doorbell that never blocks​
The preloader and the cover loader sleep until there is work. The main thread wakes them with
a WakeupWakeup: A doorbell for a background loop: ringing it many times wakes the loop once, and never blocks the ringer. (state.Wakeup): drawing the reader wakes the preloader, drawing a list
wakes the cover loader for the covers it lacked.
A Wakeup is a channel that holds one wake-up. Wake tries to put one in and, if one is
already waiting, does nothing:
func (wakeup *Wakeup) Wake() {
wakeup.work.Begin(wakeup.name)
select {
case wakeup.channel <- struct{}{}:
default:
wakeup.work.End(wakeup.name) // the wake-up waiting covers this one
}
}
Two properties follow. The main thread never blocks on Wake, however busy the loop is. And a
busy loop runs once more after its current work, not once per ring: the work it does (preload
the pages around the one shown) looks at the state as it is then, so one more run covers every
ring.
How Inkshelf does it​
application.Initstarts three goroutines:app.Go(state.LoadingWork, …session.Load…),app.GoLoop(…openbook.Preloader…)andapp.GoLoop(…covers.Loader…).state.Gocounts the task instate.Work, then hands it toGoLoop, which runs it in thesync.WaitGroupthatClosewaits on (app.Wait).- The preloader's loop is a
selectonapp.Ctx.Done()andapp.PreloadWake.Woken(), thenpreload, thenPreloadWake.Done().
Going deeper
- Fields outside the mutex.
Statehas groups of fields not guarded by the mutex: set before the goroutines start then read-only (Dir,Ctx,Syncer…), set while loading then read-only (Store,ServerDir), or main thread only (theWaitGroup, the fonts). Their comments say which. - Wake-ups as work. Each wake-up counts as background work from
Wakeuntil the goroutine callsDone. It is counted before it is sent, so the work never looks over while it waits. The emulator reads this (App.BackgroundWork) to know when a script's screenshot may be taken; the app never does. - The Syncer's own locks. It has two:
pushing(one push at a time: this is lock 1) andtimerMu, which only guards its timer and is never held while taking another lock.
Where it lives in the code​
internal/app/state/state.go · Update, Go, GoLoop, WaitThe one way to change the state, and the only way to start goroutines.internal/app/state/work.go · Wakeup, WorkThe doorbell of the background loops, and the work counter the emulator reads.internal/app/openbook/pages.go · PreloaderA loop that sleeps on a Wakeup, preloads pages and saves progress.internal/progress/syncer.go · Syncer.PushNowTakes its own lock, then the state’s mutex for short moments: lock 1, then lock 2.internal/reading/renderer.go · rendererLock 3: lays out and draws chapters, from the main thread or the preloader.internal/device/pocketbook/app.go · Repaint, Exit, PreventSleepForThe requests any goroutine may send the event loop.
The full story: App threading.
Next: the biggest of those background goroutines brings books onto the reader. First, where those books come from: Three servers, one interface.