File Locks, Pidfiles, and Single-Instance Tools
File Locks, Pidfiles, and Single-Instance Tools
Overview
Ops tools often need mutual exclusion: only one compactor, one migrator, one agent per host. Classic patterns are pidfiles and advisory file locks (flock). Both are imperfect; know the failure modes.
Advisory flock (Unix)
//go:build unix
package single
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
type Lock struct {
f *os.File
}
func Acquire(path string) (*Lock, error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, err
}
// LOCK_EX exclusive; LOCK_NB non-blocking
if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
f.Close()
return nil, fmt.Errorf("already running: %w", err)
}
return &Lock{f: f}, nil
}
func (l *Lock) Close() error {
if l == nil || l.f == nil {
return nil
}
_ = unix.Flock(int(l.f.Fd()), unix.LOCK_UN)
err := l.f.Close()
l.f = nil
return err
}lock, err := single.Acquire("/var/lock/myapp.lock")
if err != nil {
log.Fatal(err)
}
defer lock.Close()
// only one instance holds the lock| Property | Note |
|---|---|
| Advisory | Cooperation required; another process can ignore |
| Released on close/exit | Kernel drops locks when FD closes |
| NFS | Historically unreliable; prefer local disk |
Pidfiles (classic, racy)
func WritePid(path string) error {
pid := os.Getpid()
return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o644)
}
func ReadPid(path string) (int, error) {
b, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return strconv.Atoi(strings.TrimSpace(string(b)))
}Problems:
- Stale pidfile after crash
- PID reuse (new process gets same PID)
- TOCTOU between check and write
Slightly better pidfile
// open O_CREATE|O_EXCL for create-only; if exists, read and probe process
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err == nil {
fmt.Fprintf(f, "%d\n", os.Getpid())
return f, nil // keep open; delete on exit
}
// else: read pid, kill(pid, 0) to test existence — still imperfectPrefer flock for single-instance when available; use pidfiles for human operators (“what PID is running?”) in addition, not alone.
Combining lock + pid
lock, err := Acquire(lockPath)
// write pid into lock file while holding lock
fmt.Fprintf(lock.f, "%d\n", os.Getpid())
_ = lock.f.Sync()Readers open the file read-only and parse the PID for status tools.
Cross-process mutex via lock directory (portable sketch)
// mkdir is atomic on POSIX for create
err := os.Mkdir(lockDir, 0o700)
if err != nil {
if os.IsExist(err) {
return fmt.Errorf("locked")
}
return err
}
// hold: keep directory; release: Remove
defer os.Remove(lockDir)Works without flock but needs crash cleanup of empty lock dirs (stale locks).
Signal-safe release
Always defer lock.Close(). On SIGTERM, unlock as part of shutdown so a supervisor restart can acquire immediately.
ctx, stop := signal.NotifyContext(...)
defer stop()
defer lock.Close()
<-ctx.Done()Minimal CLI: once
once -lock /tmp/job.lock -- /usr/bin/rsync ...
lock, err := Acquire(*lockPath)
if err != nil {
os.Exit(75) // EX_TEMPFAIL — try later
}
defer lock.Close()
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
err = cmd.Run()Cron-friendly: non-zero if already running.
Rules of thumb
| Do | Don’t |
|---|---|
| flock on local disk | Trust pidfiles alone for exclusion |
| Document lock path | Scatter locks in world-writable dirs without care |
| Exit distinct code when busy | Hang forever waiting (unless intentional) |
| Defer unlock | Leave exclusive lock across network calls for minutes without need |
Try next
- Run two instances of a flock demo; confirm the second fails fast.
- Kill -9 the holder; confirm the second instance can acquire after.
- Implement
onceand schedule it in cron every minute safely.