Who uses the CPU when you type `tail -f`? – SABTI Riad

rbanffy1 pts0 comments

Who uses the CPU when you type `tail -f`? | SABTI Riad> in another, and finding a red anon_inode:inotify entry in /proc. It unpacks that discovery and answers the question: who actually does the work when you run tail -f?">> in another, and finding a red anon_inode:inotify entry in /proc. It unpacks that discovery and answers the question: who actually does the work when you run tail -f?">> in another, and finding a red anon_inode:inotify entry in /proc. It unpacks that discovery and answers the question: who actually does the work when you run tail -f?"> before<br>the browser paints. Runs synchronously, no defer/async.<br>--><br>Skip to content<br>Go back<br>Who uses the CPU when you type `tail -f`?<br>31 May, 2026

Run tail -f file in one terminal and echo "something" >> file in another. The appended line shows up in the first terminal instantly — yet tail, which looks like it’s watching the file, sits at 0% CPU. So who is actually doing the work? It starts with a file descriptor that isn’t a file.

Table of contents

Open Table of contents

The file descriptor that isn’t a file

Step 1 — create the station

Step 2 — register the file

Step 3 — wait with poll

Step 4 — echo writes

Step 5 — tail wakes up

So why does a non-file have a file descriptor?

Don’t trust me — check it yourself

The file descriptor that isn’t a file

Run this:

ls -l /proc/$(pgrep tail)/fd/<br>You get a red anon_inode:inotify entry. Try to cat it:

cat /proc/$(pgrep tail)/fd/4

…and you get a “No such device or address” error.

Isn’t everything in Linux a file? There are some exceptions, and this is one of them. inotify is a notification instance, created here by tail -f (it could be created by something else). It’s the mechanism that tells tail “bytes were appended to the file — go print them.”

Step 1 — create the station

inotify is a notification station, empty when created. You’re telling the kernel “set up a station for me, I’ll use it later.” It’s built by the syscall inotify_init1(IN_NONBLOCK).

The IN_NONBLOCK argument is what separates this call from inotify_init(). It sets O_NONBLOCK on the inotify fd and governs one thing: what read(fd4) does when the event queue is empty. With it, read returns immediately with EAGAIN. Without it, read blocks until an event arrives.

Note: read(fd4) returns event records describing what happened — not the file’s appended bytes. The actual bytes are read separately, from the file’s own descriptor (fd 3). More on that at the end.

The call returns the fd of the station (fd 4) and sets up an empty watch list and an empty event queue.

Step 2 — register the file

Now tail adds the file to the station:

inotify_add_watch(fd4, file, IN_MODIFY | IN_DELETE_SELF | ...);<br>The third argument is a bitwise OR of the events tail cares about — think of them as light switches (1 | 0 | 1 | 1 ...). (Real tail asked only for IN_MODIFY here, you’ll see that in the trace.)

The kernel creates a small record — the watch — and attaches it to the file’s inode:

WATCH (a kernel struct, hanging off the inode):<br>├─ which inotify instance it belongs to (fd 4's station)<br>├─ the event mask (IN_MODIFY | ...)<br>└─ its id number (wd = 1)<br>Think of it as a sticky note on the file’s inode: “if one of these events (MODIFY, DELETE_SELF, …) happens to me, send a message to station fd 4, tagged wd 1.”

The call returns the wd (watch descriptor), which tail stores in its own wd[] array.

Two side lessons before the next step:

poll is a syscall whose job is: “here is a list of fds, put me to sleep, and wake me when any of them has something to read — and tell me which.”

fds 0, 1, 2 are reserved for STDIN, STDOUT, STDERR.

Step 3 — wait with poll

tail fills in a pollfd form for the station (fd 4) — “wake me when this is readable”:

struct pollfd {<br>int fd; // which fd is this form about?<br>short events; // what am I waiting for on it? ← tail fills this in<br>short revents; // what actually happened? ← poll fills this in<br>};

pollfds[0] = { fd: 4 /* station */, events: POLLIN }; // wake me if fd 4 has an event<br>POLLIN means “there’s something to read.” (POLLOUT is for writable, we only care about POLLIN here.)

Then it calls poll(pollfds, 1, -1). The -1 means “sleep until something happens,” so this call blocks tail.

But poll does not watch fd 4 — if it did, our “0% CPU” claim would be false. So how does it work? On the way in, poll does one thing and then sleeps: for each fd in its list (here, just the inotify fd), it leaves a note on that fd’s wait queue — “these processes are waiting on me, wake them when I get activity.” Then it marks tail BLOCKED and sleeps. That registration is all poll does. It is not watching.

Step 4 — echo writes

echo "something" >> file runs in the second terminal. Now echo is the one using the CPU, not tail. echo issues a write to the kernel, and inside that same write the kernel does several things: it appends the bytes, looks at the file’s inode, sees that wd 1 is watching this kind of event, queues an IN_MODIFY event into fd...

file tail step station inotify poll

Related Articles