In the previous part, we discussed locks and how to use them. In this part, we will take a more complicated example where we will see how this is used in real life.
We will wrap the lock functionality in a struct to abstract all the details and ensure that the experience is consistent across our applications. This might not be the case in all applications, but we will guarantee that we have a lock and can acquire and release it.
Let’s create a new package and name it “lock” and create a file and call it “manager.go”.
We want to add a method to take a lock and include all the implementation details there.
func (m *Manager) TakeLock(waitToAcquire bool) error {
lockType := syscall.LOCK_EX
if !waitToAcquire {
lockType = lockType | syscall.LOCK_NB
}
file, err := os.OpenFile(m.fileName, os.O_WRONLY|os.O_CREATE, 0666)
if err != nil {
return err
}
m.file = file
return syscall.Flock(int(file.Fd()), lockType)
}
So far, so good! We want to make sure that the package user will cleanup the package after finish using the lock, e.g., release the lock.
func (m *Manager) Close() error {
if err := syscall.Flock(int(m.file.Fd()), syscall.LOCK_UN); err != nil {
return err
}
return m.file.Close()
}
In this way, we will release the lock and close the file when we’re done with whatever we’re doing. This is expected to be called with defer.
Now let’s use this in the following scenario.
We have two background tasks. The first task will update a directory every x minutes, and the second task will read the content and process it somehow. There can be multiple read tasks running at the same time.
A race condition can happen between the tasks if the first task is moving files or directories, adding or removing files, and the second background is trying to read a file that doesn’t exist anymore.
We will get an error message like “no such file or directory” or something similar.
To protect our tasks from this case, we will use the lock just as we would have used RWMutex. We will acquire write lock when we update and read lock when we read, obvious!
In this case, we will ensure that the read tasks will wait if there is an update in progress, and the update task will not move stuff around if there is a read process in progress.
Let’s write some code.
First, let’s try the code with the race to get the feeling of it
We have a directory with this structure:
├── a.b
├── c.d
└── foo
└── y.z
1 directory, 3 files
func updateTask(dirName string) error {
dirEntries, err := ioutil.ReadDir(dirName)
if err != nil {
return fmt.Errorf("failed to read dir: %w", err)
}
for _, dirEntry := range dirEntries {
fmt.Println("== " + dirEntry.Name())
srcSubDirName := filepath.Join(dirName, dirEntry.Name())
if dirEntry.IsDir() {
destSubDirName := srcSubDirName + "_v2"
fmt.Printf("== Moving %s to %s\n", srcSubDirName, destSubDirName)
if err := os.Rename(srcSubDirName, destSubDirName); err != nil {
return err
}
continue
}
if dirEntry.Name() == "c.d" {
if err := os.Remove(srcSubDirName); err != nil {
return nil
}
}
}
return nil
}
In the update task, we list files and directories of a specific path, and then we will rename it if it’s a directory and delete file “c.d.”
I’m trying to simulate an update script that contains some changes to the directory.
func readerTask(dirName string) error {
return read(dirName)
}
func read(dirName string) error {
dirEntries, err := ioutil.ReadDir(dirName)
if err != nil {
return fmt.Errorf("failed to read dir: %w", err)
}
for _, dirEntry := range dirEntries {
filePath := filepath.Join(dirName, dirEntry.Name())
fmt.Println("-- Reading " + filePath)
if dirEntry.IsDir() {
read(filePath)
continue
}
fileContent, err := ioutil.ReadFile(filePath)
if err != nil {
return err
}
fmt.Println(string(fileContent))
time.Sleep(1 * time.Second)
}
return nil
}
In the read task, we’re doing something similar to update, but we read the files’ content instead of updating them.
If we tried to run the following:
go func() {
if err := readerTask("/tmp/test"); err != nil {
fmt.Println(err.Error())
}
}()
go func() {
if err := updateTask("/tmp/test"); err != nil {
fmt.Println(err.Error())
}
}()
Full Code can be found at filelockingingo.go
We will get the following
== a.b
== c.d
-- Reading /tmp/test/a.b
== foo
== Moving /tmp/test/foo to /tmp/test/foo_v2
World
-- Reading /tmp/test/c.d
open /tmp/test/c.d: no such file or directory
As expected, we can’t read the file content because it’s been removed by update task.
Now let’s fix it by adding locks:
func updateTask(dirName string) error {
.......
locker := lock.NewExclusiveLock(dirName)
if err := locker.TakeLock(true /* waitToAcquire */); err != nil {
return err
}
defer locker.Close()
.......
}
func readerTask(dirName string) error {
.......
locker := lock.NewSharedLock(dirName)
if err := locker.TakeLock(true /* waitToAcquire */); err != nil {
return err
}
defer locker.Close()
.......
}
And try to run it again
== a.b
== c.d
== foo
== Moving /tmp/test/foo to /tmp/test/foo_v2
-- Reading /tmp/test/a.b
World
-- Reading /tmp/test/foo_v2
-- Reading /tmp/test/foo_v2/y.z
Hello
It works and without any errors
Conclusion
File Locking is an important mechanism to ensure consistency between reading and wiring.
We went through a case where there is a race between reading and writing and demonstrated how we could protect the shared resources using exclusive and shared locks.
Full Code can be found here