Make a handle concurrency-safe¶
aferobilly.New returns a handle that can outlive the call that created it and be used
from other goroutines. Whether that is safe depends on the locker you give it.
The default: no locking¶
The default locker is a no-op. The adapter adds no synchronisation, so the handle is exactly as safe as the billy filesystem underneath — correct when the backing filesystem is single-threaded or the caller owns synchronisation.
Serialise through the producer's mutex¶
WithLocker acquires the locker around every Fs operation and every File
operation — Read, Write, Seek, Close, Truncate, ReadAt, WriteAt,
Readdir, Stat, all of them.
The point is which mutex you pass. Give it the mutex that also guards whatever
produces the filesystem, and the handle serialises against that producer's own
operations. A live afero.Fs over a mutex-guarded git worktree then can never touch the
worktree unsynchronised — even though the reference has escaped into code the producer
knows nothing about.
That is the difference between "the caller must synchronise" (which the caller will eventually forget) and a handle that is safe by construction.
Footgun 1: operations are atomic, sequences are not¶
Each operation takes and releases the lock. A sequence of operations does not hold it across the gaps:
// NOT atomic — another goroutine can act between these two calls
data, _ := afero.ReadFile(fs, "config.yaml")
_ = afero.WriteFile(fs, "config.yaml", transform(data), 0o644)
When a sequence must be atomic with respect to other users of the same lock, hold the lock around the whole sequence yourself:
mu.Lock()
data, _ := afero.ReadFile(fs, "config.yaml")
_ = afero.WriteFile(fs, "config.yaml", transform(data), 0o644)
mu.Unlock()
Holding the lock yourself is the right fix, and it walks you straight into the second footgun.
Footgun 2: the locker is not reentrant, so re-entering deadlocks¶
Never use the handle inside a critical section holding the same locker
Go's sync.Mutex is not reentrant. If you hold the lock and then call an
operation on a handle configured with that same lock, the operation blocks trying to
re-acquire it and the goroutine deadlocks — against itself.
Pick one pattern per handle¶
The two footguns pull in opposite directions — per-operation safety wants the handle used outside any critical section, sequence atomicity wants it used inside one — so a single handle cannot have both. Choose:
- Per-operation safety — a locked handle, used outside any critical section. Good
for handing an
afero.Fsto code that doesn't know about your lock. - Sequence atomicity — an unlocked handle used inside a critical section you manage yourself, or a callback API that takes the lock once and hands you a handle for the duration.
The second is why a producer wrapping this adapter often exposes a callback form
(WithFS(func(afero.Fs) error)) alongside a plain accessor: the callback holds the lock
for the whole closure, so a multi-step sequence is atomic. That callback belongs to the
producer, not to this module — the adapter has no transaction API of its own, because
only the producer knows what a unit of work is.
No escape hatch, by design¶
The returned Fs and its files expose no accessor for the wrapped billy object. That
is deliberate: if you could reach the underlying filesystem you could operate on it
without the lock, and the concurrency guarantee would only hold by convention. Removing
the accessor removes the mistake.
What the locker does not protect against¶
- Another process. The locker is an in-process
sync.Locker. Two processes over the same directory serialise against nothing. billy's per-fileLock/Unlockis not exposed throughafero.File, so this adapter gives you no cross-process locking at all. - The backend's own concurrency limits. Serialising the adapter's calls does not make an unsafe backend safe for anything that reaches it another way.
- Two handles with different lockers.
WithLockeris per handle. TwoNewcalls over the same billy filesystem with different lockers do not serialise against each other. - Partial writes. A
Writethat fails halfway leaves what it wrote. The lock keeps other holders out during the call; it does not roll anything back.
Related¶
- Work with a go-git worktree
WithLockerreference — the exact contract, including what anillocker does.- What this does not do — why there is no reentrant lock and no transaction API.
- billy vs afero semantics