Skip to main content

Threads and locks

One goroutine draws and starts the others; the others change the state only inside 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.

A crossroads with traffic lights. Each car is simple; the danger is only where they meet. The rules (stop on red, one direction at a time) make every crossing safe without the drivers having to talk to each other.

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.

internal/app/openbook/pages.go (trimmed)
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 named name while 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 …Locked function without the mutex is a race: another goroutine may change the same fields at the same time.
  • Taking the mutex inside a …Locked function 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:

OrderLockHeld while
1the progress.Syncer's lockit sends reading progress to the server
2the state's mutexanything reads or changes the state, and while drawing
3a reading.TextBook's renderer lockan 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.

InteractiveThe deadlock game
1. Syncer.PushNowSyncer lock → state mutex
2. a tap calling PushNow inside Updatestate mutex → Syncer lock
1Syncer.PushNow2a tap calling PushNow inside …SSyncer lockMstate mutexRrenderer lock
step 1/4
Both goroutines start. No lock is taken.
Try this: Play “The forbidden call”: the scheduler finds the interleaving that freezes. Then play “Inkshelf’s order”, or build your own: as long as both goroutines take the locks in the same order (S before M before R), no interleaving can freeze them.

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:

internal/app/state/work.go (trimmed)
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.

InteractiveThe Wakeup doorbell
🔔the main threadnever waitschannel, holds 1Preloader goroutinepreloading pages…
1rings0covered by the one waiting1times the loop ran0 msthe ringer waited1work in progress
Wake(): slot empty, the wake-up goes in
loop takes it: preloading pages
Try this: Ring ten times fast while the preloader works: it runs once more, not ten times, and “the ringer waited” stays at 0 ms. Turn on “Ring every 250 ms” to see it keep up.

How Inkshelf does it​

  • application.Init starts three goroutines: app.Go(state.LoadingWork, …session.Load…), app.GoLoop(…openbook.Preloader…) and app.GoLoop(…covers.Loader…).
  • state.Go counts the task in state.Work, then hands it to GoLoop, which runs it in the sync.WaitGroup that Close waits on (app.Wait).
  • The preloader's loop is a select on app.Ctx.Done() and app.PreloadWake.Woken(), then preload, then PreloadWake.Done().
Going deeper
  • Fields outside the mutex. State has 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 (the WaitGroup, the fonts). Their comments say which.
  • Wake-ups as work. Each wake-up counts as background work from Wake until the goroutine calls Done. 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) and timerMu, which only guards its timer and is never held while taking another lock.

Where it lives in the code​

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.

Check yourself​

A function is named saveProgressLocked. What may it do?
A tap’s handler, inside Update, wants the progress sent right now. What should it do?
The main thread calls PreloadWake.Wake() 20 times while the preloader is busy. What happens?
Why may only the main thread start goroutines?