Fork() and File Descriptors: The Unix Gotcha Every Developer Should Know
Understanding shared file offsets, buffer duplication, and race conditions that can silently break your applications
Table of Contents
The Fork System Call and Process Creation
File Descriptors and the Kernel's File Management
How Fork Handles Open Files
Shared File Offsets: The Source of Many Surprises
File Descriptor Independence vs. File Description Sharing
Low-Level File Descriptor Mechanics
Standard File Descriptors and Redirection
Network Sockets and Fork
Race Conditions and Shared File Access
Buffered I/O Complications
File Locking Across Fork
Managing File Descriptors in Child Processes
Debugging File Descriptor Issues
Process Diagram Interactions
Performance Considerations
Alternatives to Fork for File Handling
Key Takeaways
When you call fork()
in a Unix-like system, you're creating a nearly identical copy of your process. The child process inherits almost everything from its parent: memory layout, environment variables, signal handlers, and crucially, all open file descriptors. But what exactly happens to those open files? How do parent and child processes interact with the same file resources, and what gotchas should you watch out for?
Understanding how fork handles open files is fundamental to writing robust system software. Whether you're building a web server that needs to handle multiple connections, implementing process pools, or just trying to debug why your log files have garbled output, grasping these mechanics will save you countless hours of head-scratching.
The Fork System Call and Process Creation
The fork()
system call creates a new process by duplicating the calling process. Unlike creating a process from scratch, fork produces an exact copy of the parent's address space, including all variables, heap data, and stack contents. The only immediate difference between parent and child is the return value of fork itself: the parent receives the child's process ID, while the child receives zero.
But process duplication goes beyond just memory. The kernel also duplicates the process control block, which contains metadata about the process including its file descriptor table. This table maps file descriptor numbers (like 0, 1, 2 for stdin, stdout, stderr) to actual file structures in the kernel.
Here's where things get interesting: while the file descriptor table is duplicated, the underlying file structures are shared. Think of it like two people holding separate bookmarks that point to the same book. Each person can move their bookmark independently, but they're still reading from the same physical book.
File Descriptors and the Kernel's File Management
To understand how fork affects open files, you need to grasp the kernel's three-level file management structure:
File Descriptor Table: Each process has its own table mapping small integers (file descriptors) to entries in the system-wide open file table. When you call open()
, you get back one of these integers.
Open File Table: This system-wide table contains entries for each open file, regardless of which process opened it. Each entry tracks the current file offset, access mode (read/write), and status flags.
Inode Table: The actual file metadata lives here - permissions, timestamps, disk block locations, and other filesystem-specific information.
When you read from a file using a file descriptor, the kernel follows this chain: your file descriptor points to an open file table entry, which contains the current offset and points to the inode representing the actual file.
How Fork Handles Open Files
When fork creates a child process, it duplicates the parent's file descriptor table. Each file descriptor in the child points to the same open file table entry as the corresponding descriptor in the parent. This is the key insight: file descriptors are duplicated, but open file descriptions are shared.
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd = open("test.txt", O_RDWR | O_CREAT, 0644);
write(fd, "Hello from parent\n", 18);
pid_t pid = fork();
if (pid == 0) {
// Child process
write(fd, "Hello from child\n", 17);
} else {
// Parent process
wait(NULL); // Wait for child to complete
write(fd, "Parent again\n", 13);
}
close(fd);
return 0;
}
In this example, both parent and child write to the same file using the same file descriptor. Because they share the open file description, they also share the file offset. The child's write will start where the parent left off, and the parent's final write will start where the child finished.
Shared File Offsets: The Source of Many Surprises
The shared file offset is probably the most misunderstood aspect of fork and open files. When both processes read or write to the same file descriptor, they're manipulating the same offset counter in the kernel's open file table.
Consider what happens when both parent and child try to read from the same file:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd = open("input.txt", O_RDONLY);
char buffer[100];
pid_t pid = fork();
if (pid == 0) {
// Child reads first
int bytes = read(fd, buffer, 50);
printf("Child read: %.*s\n", bytes, buffer);
} else {
sleep(1); // Let child read first
// Parent reads from where child left off
int bytes = read(fd, buffer, 50);
printf("Parent read: %.*s\n", bytes, buffer);
}
close(fd);
return 0;
}
The child reads the first 50 bytes, advancing the shared file offset. When the parent reads, it starts from byte 51, not from the beginning of the file. This behavior often catches programmers off guard, especially those coming from languages where file handles are typically not shared between threads or processes.
File Descriptor Independence vs. File Description Sharing
While file descriptors are independent between parent and child, the underlying file descriptions are shared. This means:
Closing a file descriptor in one process doesn't affect the other process's ability to use its copy of that descriptor
File operations (read, write, lseek) affect the shared file offset visible to both processes
File status flags set with fcntl affect both processes since they're stored in the shared open file description
Here's a practical example showing descriptor independence:
int fd = open("test.txt", O_RDWR);
pid_t pid = fork();
if (pid == 0) {
close(fd); // Child closes its descriptor
// fd is now invalid in child, but parent can still use it
exit(0);
} else {
wait(NULL);
write(fd, "Parent can still write\n", 23); // This works fine
close(fd);
}
The child's close operation only affects its own file descriptor table entry. The parent's descriptor remains valid because the kernel maintains a reference count on open file descriptions. Only when all file descriptors pointing to an open file description are closed does the kernel actually close the file.
Low-Level File Descriptor Mechanics
When fork duplicates the file descriptor table, it's essentially calling dup()
on every open file descriptor. The dup()
system call creates a new file descriptor entry that points to the same open file description as the original.
This equivalence means you can simulate fork's file handling behavior manually:
int original_fd = open("test.txt", O_RDWR);
int duplicated_fd = dup(original_fd);
// Now both descriptors share the same file offset
write(original_fd, "First write\n", 12);
write(duplicated_fd, "Second write\n", 13); // Starts where first ended
The kernel tracks this sharing through reference counting. Each open file description maintains a count of how many file descriptors point to it. When you close a descriptor, the reference count decreases. Only when it reaches zero does the kernel perform the actual file close operation.
Standard File Descriptors and Redirection
Fork's file descriptor inheritance is particularly important for standard streams (stdin, stdout, stderr). Shell redirection works by manipulating these descriptors before calling fork and exec:
int fd = open("output.log", O_WRONLY | O_CREAT | O_TRUNC, 0644);
pid_t pid = fork();
if (pid == 0) {
// Redirect child's stdout to the log file
dup2(fd, STDOUT_FILENO);
close(fd); // Close original descriptor
printf("This goes to the log file\n");
exit(0);
} else {
close(fd); // Parent doesn't need this descriptor
printf("This goes to the terminal\n");
wait(NULL);
}
The dup2()
call makes STDOUT_FILENO
(descriptor 1) point to the same open file description as fd
. After this, all printf output in the child goes to the log file, while the parent's output remains on the terminal.
Network Sockets and Fork
Network programming with fork introduces additional complexity because sockets are also file descriptors. When you fork a server process, both parent and child inherit the listening socket:
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// ... bind and listen setup ...
while (1) {
int client_fd = accept(listen_fd, NULL, NULL);
pid_t pid = fork();
if (pid == 0) {
// Child handles this client
close(listen_fd); // Child doesn't need to accept new connections
handle_client(client_fd);
close(client_fd);
exit(0);
} else {
// Parent continues accepting
close(client_fd); // Parent doesn't need this client connection
}
}
Notice how each process closes the descriptors it doesn't need. The child closes the listening socket because it only handles one client. The parent closes each client socket because child processes handle the actual communication.
This pattern prevents file descriptor leaks and ensures that connections are properly closed when child processes terminate.
Race Conditions and Shared File Access
Shared file offsets can lead to race conditions when multiple processes access the same file simultaneously. Consider two processes writing to a log file:
// Dangerous: Race condition possible
void log_message(int fd, const char* msg) {
lseek(fd, 0, SEEK_END); // Seek to end
write(fd, msg, strlen(msg)); // Write message
write(fd, "\n", 1);
}
Between the lseek()
and write()
calls, another process might write to the file, changing the offset. Your write might then overwrite part of the other process's data or create gaps in the file.
POSIX provides O_APPEND
flag to solve this specific problem:
int fd = open("logfile.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);
// All writes automatically go to end of file atomically
With O_APPEND
, the kernel automatically seeks to the end of the file before each write operation, making the seek-and-write atomic.
Buffered I/O Complications
Standard library functions like printf()
, fwrite()
, and fread()
use internal buffers to improve performance.
Fork complicates this because buffers are part of the process's memory space and get duplicated:
#include <stdio.h>
#include <unistd.h>
int main() {
printf("This message has no newline");
pid_t pid = fork();
if (pid == 0) {
printf(" - from child\n");
} else {
printf(" - from parent\n");
wait(NULL);
}
return 0;
}
This might output:
This message has no newline - from child
This message has no newline - from parent
The initial printf output was sitting in stdio's buffer when fork occurred. Both parent and child inherited this buffered data, so it gets printed twice when the buffers are eventually flushed.
To avoid this, explicitly flush buffers before forking:
printf("This message has no newline");
fflush(stdout); // Force buffer flush
pid_t pid = fork();
File Locking Across Fork
File locks created with fcntl()
have special semantics with fork.
Record locks are not inherited by child processes, but the file descriptors used to create them are:
int fd = open("data.txt", O_RDWR);
struct flock lock = {
.l_type = F_WRLCK,
.l_whence = SEEK_SET,
.l_start = 0,
.l_len = 0 // Lock entire file
};
fcntl(fd, F_SETLKW, &lock); // Parent acquires lock
pid_t pid = fork();
if (pid == 0) {
// Child does NOT inherit the lock
// Attempting to acquire the same lock will succeed
fcntl(fd, F_SETLKW, &lock); // This won't block
// ... child operations ...
exit(0);
} else {
// Parent still holds the lock
// ... parent operations ...
wait(NULL);
}
This behavior prevents children from accidentally inheriting locks that might cause deadlocks or prevent proper cleanup.
Managing File Descriptors in Child Processes
Good practice when using fork involves carefully managing which file descriptors each process needs:
int pipe_fds[2];
pipe(pipe_fds); // Create pipe for inter-process communication
pid_t pid = fork();
if (pid == 0) {
// Child: close write end, keep read end
close(pipe_fds[1]);
char buffer[256];
read(pipe_fds[0], buffer, sizeof(buffer));
printf("Child received: %s\n", buffer);
close(pipe_fds[0]);
exit(0);
} else {
// Parent: close read end, keep write end
close(pipe_fds[0]);
write(pipe_fds[1], "Hello child!", 12);
close(pipe_fds[1]);
wait(NULL);
}
Closing unused descriptors serves multiple purposes:
Prevents file descriptor leaks as your program scales
Ensures that pipes and sockets close properly when processes terminate
Reduces the process's resource usage
Makes debugging easier by eliminating irrelevant descriptors
Debugging File Descriptor Issues
Several tools help debug file descriptor problems in forked processes:
lsof: Lists open files for processes
lsof -p 1234 # Show all open files for process 1234
lsof /path/to/file # Show which processes have this file open
strace: Traces system calls including file operations
strace -e trace=file -f ./your_program # Trace file-related calls, follow forks
strace -e trace=read,write,open,close -f ./your_program
proc filesystem: Examine file descriptors directly
ls -la /proc/1234/fd/ # List file descriptors for process 1234
readlink /proc/1234/fd/3 # See what file descriptor 3 points to
These tools are invaluable when tracking down issues like:
File descriptor leaks in long-running processes
Unexpected file sharing between parent and child
Resource cleanup problems after process termination
Process Diagram Interactions
The interaction between processes, file descriptors, and open files during fork can be visualized as a sequence:
Initial State: Parent process has file descriptor table with entries pointing to open file descriptions
Fork Call: Kernel creates child process and duplicates parent's file descriptor table
Post-Fork: Both processes have independent file descriptor tables, but entries point to the same open file descriptions
File Operations: When either process performs file I/O, operations affect the shared open file description (including file offset)
Descriptor Management: Each process can independently close its file descriptors, but open file descriptions persist until all references are closed
This sequence illustrates why file offsets are shared (they're stored in the open file description) while descriptor management is independent (each process has its own file descriptor table).
Performance Considerations
Fork's file descriptor handling has several performance implications:
Memory Overhead: Duplicating large file descriptor tables takes time and memory. Processes with hundreds of open files will experience measurable overhead during fork.
Cache Effects: Shared file descriptions mean that file operations from different processes can interfere with each other's caching strategies in the kernel.
Lock Contention: Multiple processes accessing the same file through shared descriptions may contend for kernel-level locks protecting file metadata.
For high-performance applications, consider:
Closing unnecessary descriptors before forking
Using separate files for different processes when possible
Implementing application-level coordination to minimize conflicting file access patterns
Alternatives to Fork for File Handling
Understanding fork's file handling complexity explains why some applications choose alternatives:
Threading: Threads share file descriptors naturally, eliminating offset sharing surprises but requiring explicit synchronization.
exec after fork: Immediately calling exec in the child process starts fresh with only the descriptors you explicitly want to inherit.
Process pools: Pre-forking worker processes and using IPC avoids repeated fork overhead and file descriptor management complexity.
Event-driven architectures: Single-process designs using select/poll/epoll eliminate fork-related file handling issues entirely.
Key Takeaways
Fork's handling of open files follows a clear but often misunderstood model:
File descriptors are duplicated between parent and child, creating independent handles that point to shared file resources. This sharing includes file offsets, status flags, and reference counts, but not locks or buffers.
The practical implications are significant: processes can inadvertently interfere with each other's file I/O, buffered output can be duplicated, and careful descriptor management becomes essential for robust applications.
Success with fork and files requires understanding these shared semantics, using appropriate flags like O_APPEND
for concurrent access, managing descriptors explicitly in child processes, and leveraging debugging tools when things go wrong.
Whether you're building the next great web server or just trying to understand why your program's output looks strange, remembering that fork creates shared bookmarks into the same files will guide you toward correct solutions.
The Unix philosophy of "everything is a file" makes this behavior consistent across regular files, pipes, sockets, and devices. Master these concepts once, and you'll handle all types of file descriptors with confidence in your forked processes.
Does the content here clash with the content on https://tzimmermann.org/2017/07/28/data-structures-of-unix-file-io/? I'm trying to imagine a situation where one process opens a file for reading, and a second one opens the file for writing. What then stops the first read-only process from writing since the access mode is managed at the system-wide open file table?