File & file descriptor
File
- In Linux, everything is a file - file descriptors represent many kernel objects.
- Besides the regular file, these resources are considered as file: block device, terminal, pipe, socket.
- A file means it typically can be accessed by using the file interface:
read(),write(),close()andopen(). There are a few exceptions where it doesn’t support full methods in the interface.
File descriptor
- A file descriptor is a non-negative integer indexing an entry in a process’s file-descriptor table.
- A file descriptor refers to a open file description. Multiple file descriptors can refer to the same open file description.
- A file descriptor is unique within a process.
Open File Description
- Open file description is a data structure that stores state such as the file offset, status flags like
O_NONBLOCK, access mode, reference to the underlying inode, socket, pipe or other object. open()will create a new open file description
dup()andfork()will share the same open file description with the origin. That also means all related fds will share the same offset, file status and other information.
The central idea of the file interface open (create) a resource -> get a file descriptor -> read/write or operations supported by that resource
Linux provide the file interface through the Virtual File System (VFS), conceptually:
Some file types:
| File type | Description |
|---|---|
| regular file | use open() to open the file specify by path. If the file doesn’t exist, it may optionally create a new file. |
| pipe, fifo | Provide uni-directional inter-processes communication. Pipe is anonymous and commonly used between related processes (parent/child). FIFO is a named file in the filesystem that allows completely unrelated processes to communicate. |
| socket | Provide bi-directional inter-process communication locally on the same host or across a network |
| tty | Stand for teletypewriter - a historical term for an electromechanical teleprinter. tty has evolved into a software abstraction representing any text-based input/output communication channel. Providing a universal interface to read user input and write text output, completely independent from underlying hardware. |
| eventfd | providing inter-process event notification. It manages a single 64-bit unsigned integer. It can be used as an event wait/notify mechanism by userspace applications and by kernel applications to notify userspace applications about events. |
| inotify | The inotify API provides a mechanism for monitoring filesystem events. Inotify can be used to monitor individual files or to monitor directories. |
Read more about file: https://www.tecmint.com/everything-is-file-and-types-of-files-linux/
System call in Linux
- System calls (aka
syscall) are a set of functions that a user space application can request privileged service from the Linux kernel. - User space applications run with restricted permissions to ensure security; they can’t directly touch the hardware or manipulate the memory layout without initiating a system call.
- Every system call will force a transition from user mode (low privilege) to kernel mode (high privilege).
- The current thread is still running, but it will now execute kernel code.
- Some common system calls:
fork(),execve(),open(),read(), etc.
How to tell if a function is a syscall or a library function?
- Section 2 of man pages is specifically for system calls, for instance
open(2),read(2),fork(2). These functions usually get mapped directly to syscalls. - Section 3 is library functions, running in userspace, for instance,
printf(3),malloc(3),scan(3). These functions call make syscall internally. - We can use
straceto trace what system calls a program makes.
Read more about syscall: https://internals-for-interns.com/posts/linux-kernel-syscalls/ syscall tables man syscall
Blocking vs Non-Blocking syscall
Blocking syscall When a thread (task) makes a syscall, and the operation can’t make progress (wait indefinitely) because of a blocker (no data, lack of resources, resource locked, condition not met, etc), the kernel will:
- Put the task on the wait queue associated with the blocker (resource/condition).
- Change the task’s state to
TASK_INTERRUPTIBLEorTASK_UNINTERRUPTIBLE, so the scheduler will ignore it - Call the scheduler to find another runnable thread to run on the CPU
- When the blocker is broken, whatever did that (interrupt handler, another process or thread, timer, etc) will call
wake_up()on the wait queue. The task is transitioned toTASK_RUNNINGand is ready to be scheduled again. The thread resumes inside the syscall, where it rechecks the condition. Then it may return to userspace with the result, sleep again, or return because of a timeout, signal, or error. Non-Blocking syscall When the syscall can’t make progress, it will immediately return an error/indicator. The thread doesn’t get sleep and keeps running. It’s up to the userspace application to retry, poll or do something else.
Some syscalls support both modes, some support one or the other
These syscalls support both modes based on the flag or parameter:
read()/write()/recv()/send()/accept()-O_NONBLOCKon the fd = non blocking, orMSG_DONTWAITon per socket call (send, recv).connect()— withO_NONBLOCKreturnsEINPROGRESSimmediately instead of waiting for TCP handshake.waitpid()-WNOHANG= non blockepoll_wait()/poll()/select()-timeoutis0means non block,timeoutis-1/NULLmeans block indefinitely,timeoutis500blocks for a limited time.flock()—LOCK_NB= non-block, don’t wait on the lockfcntl(F_SETLKW)(wait) vsfcntl(F_SETLK)(don’t wait)getrandom()—GRND_NONBLOCKreturnsEAGAINinstead of blocking on the entropy pool initialisation
These syscalls are blocking only:
fsync(),fdatasync(),msync(MS_SYNC)- wait until the data hit the devicenanosleep()/clock_nanosleep()read()/write()on a regular file (O_NONBLOCKis ignored) may complete immediately or may block.
Some syscalls are never blocked. It doesn’t wait or depend on any resource or condition. Therefore, the distinction between blocking/non-blocking doesn’t make sense here:
getpid(),getuid(),gettimeofday(),clock_gettime()- pure state kernel/process getterssched_yield()getpid()
blocking is not limited to I/O When talking about blocking syscalls, I/O syscalls make up the majority, but not all blocking syscalls are I/O syscalls. These are other blocking system calls that are not related to I/O:
- futex (fast userspace mutex) - a thread calling futex wait will sleep indefinitely until another thread wakes it up, timeout, interruption, and spurious wakeups
- semop (System V semaphores) - if a thread tries to decrement a semaphore whose value is zero, it will sleep indefinitely until another thread increments the semaphore
- wait/waitpid - block the calling process until the child process changes its state
- flock - the calling thread will sleep indefinitely if the lock cannot be acquired immediately
Blocking I/O
As explained above, if a thread makes an I/O syscall such as read() or write() on an fd and can’t make progress, the OS can put the calling thread to sleep. When the fd is ready to make progress, the thread will be woken and placed in the queue to run. The thread will then resume the I/O syscall and continue to execute.
Consider some examples:
Regular file (without O_DIRECT, O_SYNC, or O_DSYNC)
In a regular file on a disk-backed filesystem, there is no producer/consumer relationship, and there is no fixed buffer to be full or empty, like a pipe or socket.
- To read, the kernel will check the page cache. If it’s a cache hit, it usually returns immediately without blocking. If it’s a cache miss or the data is not up to date, the kernel usually must issue a disk read and put the calling thread to sleep. After the data is read into the page cache, the calling thread will be rescheduled and resume.
- To write, the kernel copies the data into the page cache and marks it as dirty, then returns almost immediately. The data in the page cache will be written back to disk asynchronously by kernel threads. It can still be blocked by memory pressure, where too many dirty pages are in the page cache; it must wait until the kernel’s dirty-page throttling has finished flushing.
pipe Get blocked:
- The reader is blocked when the receive buffer is empty
- writer blocked when the send buffer is full, no room to accept more bytes.
Wake up:
- Writer wakes up: When a reader drains some bytes from the buffer. When the reader closes the pipe (closes all the descriptors referring to the read end), it wakes the writers who are waiting; writers get
SIGPIPE/EPIPE, meaning no one else is reading the data. - When a writer writes some bytes to an empty buffer. When the writer closes the pipe (closes all descriptors referring to the write end), it will wake those readers waiting on; the reader will read all remaining data and get
EOF, meaning no more data will ever come - because dup() and fork() can create additional references, closing one descriptor is not sufficient.
TCP socket Get blocked:
- The reader is not ready when the buffer is empty; no data has arrived from the peer yet.
- Writer is not ready in two cases: the local send buffer is full, or indirectly blocked by TCP congestion control, which slows the transmission and ack, causing the buffer to fill. Wake up:
- Blocked writer wakes up when: ACK signal from peer confirming that the data has been received, send buffer space free up. Reader sends
RST; writer wakes up and getsECONNRESETorEPIPE. - blocked reader wakes up when: new data segments queue up to the receive buffer. writer sends
FIN(orderly close), reader wakes up, reads all remaining data, and finally getsEOF. - connection lost (timeout, network unreachable): eventually returns
ETIMEDOUT/EHOSTUNREACH
Non-blocking I/O
A regular file doesn’t support non-blocking. If you set
O_NONBLOCKon a regular file, it will succeed, but it will not make subsequentread()andwrite()calls non-blocking; those calls may still block.If the file type supports non-blocking, we can call
open()withO_NONBLOCKor later time withfcntl(fd, F_SETFL, flags | O_NONBLOCK)Some file types (objects) that support
O_NONBLOCK: pipe, fifo, socket, eventfd, inotify, etc.After opening the file and getting the fd,
read()orwrite()will return-1and seterrnotoEAGAINorEWOULDBLOCKimmediately if it can’t make progress. The current thread will continue to execute.Non-blocking IO is commonly paired with a readiness API such as
select,pollandepollBesides notifiers, Non-blocking I/O can be paired with busy waiting in some niche situations.
Those objects support non-blocking, such as those that usually have two sides, a producer and a consumer, and there is a small buffer to read or write to. The reader is blocked when the buffer is empty, and the writer is blocked when the buffer is full. Since there are two sides, there are messages when each of them closes its end.
io_uring
O_NONBLOCK doesn’t make the regular file reads and writes non-blocking. To achieve non-blocking-like experience on a regular file, asynchronous interfaces such as io_uring or AIO allow applications to submit I/O operations and collect their result separately.
pollable/nonpollable file descriptor
- A file/fd is pollable if it has
.poll()implementation on thefile_operationsstruct:
Source: file_operations.poll()
File types such as pipe or socket implement file_operations.poll() to:
- Firstly, return a bitmask describing their current readiness. Readiness is not just data ready to read or space to write; readiness means the operation can return without waiting for relevant I/O condition. Its result may be data, EOF, or an error. Writable readiness doesn’t guarantee that a super large write will complete without blocking.
- Secondly, the
file_operations.poll()usually callpoll_wait()on its wait queue.poll_wait()invokes the callback supplied in thepoll_tableif one exists.select()andpoll()have similar callback, whileepoll_ctl(ADD)has different. file_operations.poll()is used byselect,pollandepoll.- However,
select,pollandepollwill not call.poll()directly; they call viavfs_poll(). Thevfs_poll()then calls.poll()on the fd; if the fd doesn’t have.poll()implementation, it will return a default readiness result. - Ordinary storage-backed, such as regular files, generally do not implement the
file_operations.poll()method.pollgenerally treats them as always ready, whileepollnormally can’t monitor them.
Source: poll_wait() and vfs_poll()
Pollable vs Non-Blocking I/O
Pollable and non-blocking I/O are two different things, but they are usually used together:
O_NONBLOCKprevents the operation from being blocked (sleep) when you make an I/O syscall on a fd that cannot make progress, the operation will returnEAGAINimmediately instead.file_operations.poll()answers the question of whether the file descriptor can make progress yet, and is used by select, poll and epoll.
Linux Readiness API (notifier): select, poll and epoll
- This is usually combined with non-blocking IO on a pollable file descriptor
- These are three mechanisms so that the kernel can notify the user space applications what file descriptors are ready:
select
| |
- Each
fd_setcontaining a set of fds that an application wants to track, the fd’s number value must be belowFD_SETSIZE, normally 1024. readfds,writefdsandexceptfdsare used for both input and output purposes. On returning,fd_setsare modified; those fds that are not ready will be unset, keeping only those fds that are ready.- The side effect is that we must build those
fd_setsbefore everyselectcall
Source: do_select
poll
| |
- Quite similar to
selectexcept that the fd list is now an array with no hard limit on the size. But it is still limited by available memory andRLIMIT_NOFILE. - Both
selectandpollare POSIX widely portable. poll’s behaviour is quite similar toselect:
Source: do_poll()
epoll
| |
epolldiffers fromselectandpollin that it has functions to register the fds that we want to monitor, so we don’t have to pass the whole list of fds every time we call the function.epollmaintains a ready list (ep->rdllist) containing those target fd are ready. The callepoll_wait()will process items from this list. If the list is empty, the current thread will sleep until a new event or a timeout occurs.- When a target object like a pipe or a socket is ready, it will add an item to the epoll ready list and wake a thread sleeping in
epoll_wait() epollis supported by Linux only
Source: do_epoll_wait() do_epoll_ctl() ep_poll_callback()
Level Trigger and Edge Trigger
- Level trigger is the default mode of epoll
For the reader’s side
- You create an epoll instance and register a single fd to it.
- The writer writes 2KB to the fd
- The reader calls
epoll_waitand receives the registered fd in the ready list - The reader read 1KB from the fd
- The reader calls
epoll_waitagain, and how it behaves depends on whether the fd is registered in Level Trigger or Edge Trigger mode Level trigger: notify when there is data to read
- The
epoll_waitcontinues to return the registered fd in the ready list because there is still 1KB of data left in the fd’s buffer. - Similar to the writer, because the buffer has free space to write. Edge trigger: notify when it receives new activity, but the event may coalesce
- The
epoll_waitwill not return the registered fd this time; the function call will hang until the timeout because the Edge trigger doesn’t repeatedly report an fd just because it has some unread data. Later activity may generate another event when the unread data is already present. That also means that once you read a file in this mode, you must read untilEAGAIN. - Note that multiple chunks arriving continuously between two
epoll_waitcalls can be coalesced into one event
For the writer side
A call to write() can place some data into the kernel’s output buffering without waiting. It doesn’t mean the reader has received the data.
Application output queue -> write() -> kernel write buffer -> network/pipe -> reader
For a pipe, space becomes available when the reader reads the data. For a TCP socket, space is reclaimed after the transmitted data is acknowledged.
Scenario 1: (note: epoll_wait in both scenarios is called with timeout)
- You create an epoll instance and register a single fd to it.
- The kernel send buffer is 2KB, and it’s currently empty
- The writer calls
epoll_waitand receives the registered fd in the ready list - The writer writes 1KB of data to the buffer
- The writer calls
epoll_waitagain Level Trigger: notify when there is space to write
- The
epoll_waitcontinues to return the registered fd Edge Trigger: notify when the write buffer transitions from “not writable” to “writable” - The
epoll_waitreturns an empty list. Because the write buffer hasn’t changed from “not writable” to “writable”.
- The reader read 1KB from the buffer
- The writer calls
epoll_waitagain, level trigger reports the fd ready, the edge trigger continues to report an empty fd list.
Scenario number 2 continues after step 3:
4. The buffer is only 2KB. The writer writes continuously until it gets the EAGAIN error, but it hasn’t finished yet.
5. The reader read enough data from the buffer to free enough space to make the fd writable
6. The writer calls epoll_wait again to continue to write
Level Trigger will return the fd in the ready list, because there is space in the buffer for the writer to write.
Edge Trigger will also return the fd in the ready list, because the buffer transitions from “full” to “not full”.
In Edge Level mode, epoll doesn’t repeatedly report an fd merely because it remains writable. If we write only half the fd’s buffer, is that a problem?
Not really, in Linux, the writer should hold the fd and be able to write whenever they want.
epoll for write in Edge-trigger mode serves a different purpose: to retry after a failed or unfinished write.
select, poll and epoll on regular file, fd without pollable and invalid/closed fd
| Underlying object | select() | poll() | epoll_ctl(ADD) |
|---|---|---|---|
| Regular file | Immediately reports readable/writable | Immediately returns requested read/write readiness | Fails with EPERM |
Another FD without .poll() | Same default readable/writable result | Same default readable/writable result | Fails with EPERM |
| Invalid/closed FD | Fails with EBADF | Reports POLLNVAL | Fails with EBADF |
Why can blocking I/O become expensive?
The context is a server environment that must handle a large volume of client requests. And the workload is I/O-bound, meaning that most of the time, request processing sits idle while waiting for I/O. While using blocking I/O mode, if the server is designed as a thread per connection. The more concurrent requests a server receives, the more OS threads it creates. One way to mitigate the thread-per-connection design is to use a fixed-size thread pool; this can cap the total number of threads the server creates and maintains. But the downside is that when the thread pool is saturated, the request must wait to be served.
Resource consumption and wasted Each OS thread consumes:
- Thread ID (TID) - unique per thread, drawn from the same PID namespace
- task_struct - the kernel’s bookkeeping data structure to manage threads. It typically consumes several KB of kernel memory. It consists of scheduling info, signal mask, etc.
- User space stack - reserve virtual address space (typically from 1-8MB). Physical pages are allocated lazily as the stack grows, so the actual RAM is smaller than the reservation.
- Kernel mode stack - a separate, small stack (typically from 8-16KB), used when the thread is executing in kernel mode, such as an interrupt or a syscall.
- Register state - saved in task_struct when not running
- Signal mask & pending signals
- Scheduling entity (sched_entity for CFS, EEVDF, etc) - track vruntime, priority, CPU affinity, etc.
- Thread local storage (TLS) -
__threadis a compiler extension in C and C++ to declare thread-local storage.
But most threads in our situation stay idle while still consuming resources; that’s wasteful.
Context switch overhead Switching from one thread to another (same process):
- Trap into the kernel mode - save the current userspace thread’s registers onto its kernel stack/
task_struct(optional if not in kernel mode yet) - Scheduler run - pick the next runnable thread, update vruntime accounting, run queue manipulation
- Switch kernel stack -
switch_to()swaps the kernel stack pointer and saved registers to point at the new thread’s context. - Resume to user mode - restores the new thread’s registers and resumes execution
Note: if both threads share the same mm_struct (same process), the kernel skips switching page tables and TLB flush.
Context switch side effects The side effects can happen when switching between two threads in the same process:
- Cache thrashing - When a thread is scheduled to run on a core, it may evict other threads’ data on the cache line (L1, L2, L3), making the CPU cost cycles just to read data from RAM.
- Branch predictor pollution
- Run queue lock contention - on multi-core systems (per CPU run queue), the scheduler’s run queue data structure (rbtree for CFS) needs locking and updating. At very high switch rates and a high number of cores, this becomes real overhead.
- NUMA effects (multi-socket systems) - when the thread gets scheduled to a new core on a different NUMA node than the previous one, subsequent memory access becomes cross-socket, significantly higher than local-node socket.
How does non-blocking I/O and epoll carry the server world?
Using this setup, the application can offload the file descriptor monitoring to the kernel, using just one thread to poll for updates from the kernel. On thread can wait on behalf of many connections. When one connection cannot make progress, it can serve another ready connection.
This idea is widely adopted nowadays: Node.js event loop, Python async, Go netpoll (multi-threaded), …
Below is a sample C program that builds a minimal TCP echo server. This is a simplified code to demonstrate the event-loop architecture. Production code should handle errors, partial writes, EAGAIN, queued output and multiple pending accept() calls.
| |
Appendix: stack trace for select, poll and epoll
select
| |
poll
epoll_ctl(ADD)
| |
epoll_wait
| |