Hello, and welcome back to CS631 "Advanced Programming in the UNIX Environment". In this video, we continue our discussion of "Advanced I/O". After we covered non-blocking I/O in our last video, we'll now talk about resource- and record locking. That is, we are looking for ways to ensure that multiple processes can safely access a shared resource without running into race conditions. For our purposes here, we'll focus once more on file specific semantics, and even more specifically on managing file descriptors. --- Let's start by identifying some of the ways in which we can guarantee exclusive access to a resource. In so doing, let's clarify and keep in mind that we are talking about protection from simultaneous access by processes with the same effective UID, thereby making access controls and permissions not an option, although we _will_ look at some related functionality in next week's materials. But anyway, suppose you have a file and you want to ensure that your current process has exclusive access, what can you do? - One things we can do is create a new file exclusively, and then immediately unlink it. As we know, as long as we have an open file handle, the file blocks are not released, and when we can continue to operate on the handle like any other file descriptor. Since we unlinked it right away, no other process can open it, however, so we know we have exclusive access. - Another way of ensuring exclusive access might be use of a lockfile. We test for the existence of a file, and if we find it to be present, we know some other process is accessing the resource we're interested in, so we bail. - And of course instead of using a lockfile, we can use a semaphore, as we've shown in Week 8 to protect a critical region in your code. But each of these approaches has some drawbacks: an unlinked file does not exist in the file system, for example. While on some Unix systems you can find ways to link the file descriptor into the filesystem, there is no good standardized way of doing that. Using a semaphore or a lockfile involves managing an additional resource beyond the file we're trying to access and thus adds complexity. So instead of these, we can use a system call specifically designed to handle locks on file descriptors: --- The flock(2) system call. - flock(2) applies or removes an advisory lock on the file associated with the file descriptor 'fd'. These advisory locks allow cooperating processes to perform consistent operations on files, but it's worth noting that they do not prevent a process from accessing the file without using these locks. That is, the locking guarantees, similar to semaphores, rely on the processes in question to cooperate, to agree to perform these checks. - You can apply a lock in a blocking or non-blocking manner -- applying the same semantics as we discussed in our previous video -- and we differentiate two types of locks: - shared locks, or - exclusive locks. Multiple processes may hold a shared lock, but in order to hold an exclusive lock, no other lock may exist, not even a shared lock. Think of a shared lock as a read lock -- multiple processes can simultaneously read from a given file without any problems -- and an exclusive lock as a write lock, where even a read operation might be corrupted by another process writing data at the same time. If you have a shared lock, and you are trying to upgrade to an exclusive lock, your shared lock is momentarily released, and another process might gain a hold of the lock before you, though. We'll see an example of this in a minute. To unlock the file, you specify - LOCK_UN. - The locks controlled via flock(2) apply to the entire file; we'll see in a few minutes another method of applying a lock only to a specific region of a file. And finally, since these locks are applied to a file descriptor, - we also have a way to lock a file stream via the flockfile(3) function. But enough talk, let's show this mechanism in practice: --- Here's our code, flock.c. We have a progress function that just prints a few dots while waiting. In 'main', we open a file and then request a shared lock on the resulting file descriptor. After we have established our shared lock, we wait for ten seconds to let another process try the same, then try to upgrade our shared lock to an exclusive lock. When using non-blocking mode, we print a message on failure, then try again, but eventually giving up to avoid a deadlock scenario. Ok, let's run this program. We start out in non-blocking mode, and our program immediately gains a shared lock on the file descriptor. We run a second shell and run the same program again, this time in blocking mode. Here, too, our process can gain the shared lock, showing that two processes can hold a shared lock simultaneously. Now up here, our first process is now trying to upgrade to an exclusive lock, but will fail since our second process still has a shared lock. (We see the process trying and failing repeatedly in non-blocking mode.) Now the second process tries to upgrade its shared lock to an exclusive lock, but in the process has to give up its shared lock, so our first process can then immediately grab the exclusive lock. As a result, the process in the bottom half is now blocking on upgrading its lock. As soon as the process on the top finishes, the exclusive lock it held is released and the bottom process gains its exclusive lock. Now starting the same program again, we notice that it will block on gaining even the shared lock, since the bottom process still has an exclusive lock, but as soon as the bottom process terminates, the top process gains the shared lock. In the bottom half, running the program again gains the shared lock as before, thus blocking the top process again from gaining an exclusive lock. If we Ctrl-C the program, all locks are released upon termination and the top program can immediately gain the exclusive lock. Likewise the other way around. Ok, so this showed how we gain a lock on the entire file identified by the file descriptor. But that can be somewhat suboptimal: consider a large file where one process wants to write to one section, but doesn't care if another process writes to a different region. --- For that, we introduce so-called "record locking", which is done using fcntl(2) to apply or remove a similar advisory lock. These record lock can be applies to a region of a file by specifying a - struct flock, which uses the offset and whence semantics we're familiar with from our discussion of the lseek(2) syscall. Like before, - the lock type is either a read-lock, a write-lock, or we can unlock, of course. - Like before, how shared or exclusive locks are combined are shown here. - And because you can't have too much of a good thing, we also --- get the lockf(3) library function, not to be confused with the flock(2) system call we discussed a minute ago. lockf(3) locks a region of the file referenced by the file descriptor starting at the current offset and of the specified size. The options are unsurprising and self-explanatory, although we have explicit options for non-blocking tests. --- Now with the notion of locks on file descriptors, it's worth thinking about how these behave when we fork another process: - For starters, it seems reasonable and intuitive that if we call fork(2), the lock is not inherited by the child process: if it did inherit the lock, then we'd have two processes, both possibly with an exclusive lock -- a contradiction of the very notion of an exclusive lock. - When we call exec(2), however, the lock remains in place. That, too, seems logical, but we do - have an option to prevent this. If a file descriptor had the close-on-exec flag set, then it is of course closed, and thus no lock is retained when we call exec(2). - Likewise, all locks on a file descriptor are released when the process terminates. That, again, makes sense and is desirable. We don't want a file to keep a lock if the process that applied it has terminated. But now there's one thing where we run into a situation that's neither obvious, nor actually desirable: - A lock is associated with a file for a given process, not with a _file descriptor_ for the specific file, and it is released if any file descriptor for the file is closed. --- Remember this illustration? It reminds us that one and the same file can be opened by multiple processes or within the same process at the same time. We've previously seen examples of multiple access of the same file leading to problems when we talked about async-signal-safe functions and illustrated the problem with a call to the password file functions, which, when called, open the password database, perform the lookup, then close the password database. - If you gain a lock on the password database, and then make a call to e.g., getpwnam(3), then upon return from that call, your lock is gone! In other words, your process needs to know whether any of the library functions you call might manipulate any of the files referenced by the file descriptor on which you have a lock. This is problematic, and called out as such - in the manual pages in no uncertain terms. --- Now remember that we mentioned that all of the locking mechanisms we discussed here are "advisory". That is, they require the processes to cooperate -- they do not prevent access to a locked resource by another process that doesn't bother to check for any existing locks. So why don't we implement "mandatory" record locking? Well, to some degree, some Unix versions implement "mandatory" locking by overloading group permissions: if you change a file to be setgid, but without group execute permissions -- and if your OS and filesystem supports mandatory file locking -- then this signals to the kernel to deny reads or writes by _any_ process on a file that another process has a lock on. However, the implementation here is subject to a few subtle race conditions, which is why even though this feature may be available, it is generally advised against relying on. What's more, it's often possible to circumvent mandatory locking not by manipulating the file itself, but rather the directory entries relating to the file: - If you have a lock on /tmp/file, you could still create a brand new file, make whatever changes you want, then unlink /tmp/file and move the new file into place. As you recall, removing a file is an operation on the directory, not the file itself, so a lock on the file cannot prevent it from being removed from the directory or be renamed. In this way, you have not strictly speaking violated the file lock -- the file descriptor you got the lock on was never manipulated -- but the end result is the same. In other words, mandatory locks basically don't work or would be extraordinarily difficult to implement. - This link has some details, but in short: don't rely on mandatory locks. We'll take a closer look at how we can restrict processes from interfering with one another in the next week, though. --- So, to summarize: Most locking mechanisms discussed here are advisory: they require the cooperation of the processes. - Since we're talking about coordinating cooperating processes, it shouldn't come as a surprise that it's easy to make mistakes here; mistakes, that can lead to a deadlock. Make sure to account for this possibility when you're writing code that handles locks. To better understand file- and record locks, try these exercises: - We saw examples of locking file descriptors referencing files -- what happens if you try to lock STDOUT? What about file streams? - Write some code to illustrate the problem we just discussed about locks being released by a file being closed. - Try to rewrite the code from flock.c using fcntl(2) rather than flock(2). - Thinking about seeking within a file, we discussed the concept of a sparse file -- we were able to seek past the end of a file. Can we _lock_ beyond the current end of the file? Write some code to give it a try. - What happens when you try to apply a record lock on a section of a file that you already have a full file lock on? How about attempts to lock overlapping regions? As you can tell, there's a lot of possibly confusing situations arising, and verifying your understanding by writing some code is always the best approach. In our next video, we'll wrap up our discussion of "advanced I/O" topics with a look at asynchronous- and then memory mapped I/O. Until then - thanks for watching! Cheers!