Please purchase the course to watch this video.

Full Course
File locking is a crucial technique for ensuring data integrity in multi-process applications, particularly when multiple instances of the same program may attempt to read from or write to the same file concurrently. By implementing a file lock using the syscall
package, applications can prevent race conditions and duplicate entries when accessing shared resources. This approach functions similarly to mutexes in concurrent programming, blocking subsequent processes until the lock is released. Effective use of file locks not only enhances reliability but also requires careful handling to avoid deadlocks. Overall, mastering file locking techniques in Go is essential for developing robust applications that maintain data consistency.
No links available for this lesson.
In the previous lesson, we explored lockfiles and PID files to prevent concurrent execution of applications. In this lesson, we'll look at another approach: directly locking files that our application is reading from or writing to, similar to how mutexes work in concurrent programming.
The Problem: Race Conditions with File Access
Let's start with a simple example application that demonstrates why file locking is important:
package main
import (
"bufio"
"fmt"
"log"
"os"
"strconv"
"strings"
)
func main() {
// Parse the number of iterations from command line
if len(os.Args) < 2 {
log.Fatal("Please provide a number as argument")
}
count, err := strconv.Atoi(os.Args[1])
if err != nil {
log.Fatal("Invalid number:", err)
}
// Open the file for appending (create if doesn't exist)
file, err := os.OpenFile("numbers.txt", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
log.Fatal("Could not open file:", err)
}
defer file.Close()
// Read the last line to get the last number
scanner := bufio.NewScanner(file)
lastNum := 0
for scanner.Scan() {
text := strings.TrimSpace(scanner.Text())
if text != "" {
lastNum, err = strconv.Atoi(text)
if err != nil {
log.Fatal("Invalid number in file:", err)
}
}
}
// Append new numbers
for i := 1; i <= count; i++ {
nextNum := lastNum + i
fmt.Fprintf(file, "%d\n", nextNum)
fmt.Println("Wrote:", nextNum)
}
}
This program:
- Takes a number argument from the command line
- Opens a file named "numbers.txt" for appending
- Reads the file to find the last number
- Appends the specified number of sequential numbers to the file
When we run this program with an argument, it works as expected:
$ go run main.go 10
Wrote: 1
Wrote: 2
...
Wrote: 10
$ go run main.go 5
Wrote: 11
Wrote: 12
...
Wrote: 15
However, if we run multiple instances simultaneously, we encounter a race condition:
# In terminal 1
$ go run main.go 10
# In terminal 2 (at the same time)
$ go run main.go 10
Both processes read the file, determine the same "last number," and then both write sequential numbers starting from that point, leading to duplicate entries.
Implementing File Locking
To solve this problem, we can use system-level file locking through the syscall
package. The flock
system call allows us to obtain an exclusive lock on a file:
package main
import (
"bufio"
"fmt"
"log"
"os"
"strconv"
"strings"
"syscall"
)
func main() {
// Parse the number of iterations from command line
if len(os.Args) < 2 {
log.Fatal("Please provide a number as argument")
}
count, err := strconv.Atoi(os.Args[1])
if err != nil {
log.Fatal("Invalid number:", err)
}
// Open the file for appending (create if doesn't exist)
file, err := os.OpenFile("numbers.txt", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
log.Fatal("Could not open file:", err)
}
defer file.Close()
// Lock the file (will block until lock is acquired)
err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX)
if err != nil {
log.Fatal("Could not lock file:", err)
}
// Read the last line to get the last number
scanner := bufio.NewScanner(file)
lastNum := 0
for scanner.Scan() {
text := strings.TrimSpace(scanner.Text())
if text != "" {
lastNum, err = strconv.Atoi(text)
if err != nil {
log.Fatal("Invalid number in file:", err)
}
}
}
// Append new numbers
for i := 1; i <= count; i++ {
nextNum := lastNum + i
fmt.Fprintf(file, "%d\n", nextNum)
fmt.Println("Wrote:", nextNum)
}
// Unlock the file when finished
err = syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
if err != nil {
log.Fatal("Could not unlock file:", err)
}
}
Key additions:
- Import the
syscall
package - After opening the file, call
syscall.Flock(int(file.Fd()), syscall.LOCK_EX)
to obtain an exclusive lock - Before exiting, call
syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
to release the lock
Understanding the flock
System Call
The syscall.Flock
function corresponds to the Unix flock(2)
system call and takes two parameters:
- A file descriptor (
int(file.Fd())
converts Go's file descriptor to an integer) - An operation code specifying the type of lock:
The main lock operations are:
syscall.LOCK_SH
: Shared lock - multiple processes can hold a shared locksyscall.LOCK_EX
: Exclusive lock - only one process can hold this locksyscall.LOCK_UN
: Unlock - releases an existing lock
By default, flock
will block until the lock can be acquired. If you want non-blocking behavior, you can OR the operation with syscall.LOCK_NB
.
Important Notes on File Locking
-
Platform Compatibility: The
flock
system call is available on Unix-like systems (Linux, macOS) but not on Windows. For Windows, different APIs are required. -
Blocking Behavior: By default,
flock
blocks until the lock is available. This means a process will wait if another process has the lock. -
Lock Inheritance: File locks are maintained per process, not per file descriptor. This means:
- If a process opens the same file twice, the second open doesn't block
- Child processes do not inherit locks from parent processes
- Locks are automatically released when a process exits
-
Deadlock Prevention: Be careful with nested locks to avoid deadlocks. Just as with mutexes in concurrent programming, always acquire locks in the same order to prevent deadlocks.
Comparison with Other Locking Approaches
Let's compare the three locking approaches we've covered:
Approach | Mechanism | Behavior | Use Case |
---|---|---|---|
Lockfile | Create empty file | Fails if file exists | Single instance applications |
PID File | Create file with PID | Can recover from crashes | Single instance with recovery |
File Lock | flock system call |
Blocks until lock available | Coordinating file access |
Abstracting File Locking
As an exercise, consider creating a FileLock
type that abstracts the file locking functionality:
type FileLock struct {
file *os.File
}
func NewFileLock(filename string) (*FileLock, error) {
file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
return nil, err
}
return &FileLock{file: file}, nil
}
func (l *FileLock) Lock() error {
return syscall.Flock(int(l.file.Fd()), syscall.LOCK_EX)
}
func (l *FileLock) Unlock() error {
return syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
}
func (l *FileLock) Close() error {
return l.file.Close()
}
Summary
In this lesson, we've learned:
- How to identify race conditions when multiple processes access the same file
- How to use
syscall.Flock
to implement file locking - The difference between exclusive and shared locks
- The benefits and limitations of file locking
- How file locking compares to lockfiles and PID files
File locking is a powerful technique for ensuring data integrity in multi-process applications, similar to mutexes in concurrent programming. By properly implementing file locks, we can coordinate access to shared resources and prevent race conditions.
In the next lesson, we'll shift our focus to networking in Go, exploring the net
package and eventually combining our file handling knowledge with networking to build more complex applications.