File Locks, Pidfiles, and Single-Instance Tools

Updated

September 8, 2026

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:

  1. Stale pidfile after crash
  2. PID reuse (new process gets same PID)
  3. 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 imperfect

Prefer 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

  1. Run two instances of a flock demo; confirm the second fails fast.
  2. Kill -9 the holder; confirm the second instance can acquire after.
  3. Implement once and schedule it in cron every minute safely.