Time Namespaces
A time namespace (created by the
clone(2)/unshare(2)flagCLONE_NEWTIME) gives a set of processes their own per-namespace offsets for two of the kernel’s clocks:CLOCK_MONOTONIC(the never-going-backwards “time since some unspecified point in the past”) andCLOCK_BOOTTIME(monotonic plus time spent suspended). It deliberately does not virtualizeCLOCK_REALTIME— wall-clock time stays global. Each namespace carries amonotonicand aboottimeoffset; a process inside the namespace readsactual_clock + offsetinstead of the host’s raw value. It was added in Linux 5.6 (released 2020-03-29), introduced “mainly in the context of checkpoint/restore functionality, where monotonic and boottime clocks … have to be guaranteed that they never go backwards when a container is migrated to another machine” (kernelnewbies Linux 5.6). That is the whole reason it exists: a process that has been running and readingCLOCK_MONOTONICfor hours must not see that clock jump backward to the fresh-boot value of a different host when restore (CRIU) moves it there.
This note is pinned to Linux 6.12 LTS (released 2024-11-17); the data structures and offset logic are unchanged through 6.18 LTS (2025-11-30). The implementation is include/linux/time_namespace.h and kernel/time/namespace.c. Time namespaces have two genuinely unusual properties that set them apart from every other namespace type (see Linux Namespaces Overview): the creating process is not itself placed in the new namespace (only its later children are), and the virtualization reaches all the way into user space through the vDSO so that clock_gettime() stays a fast library call rather than a syscall. Both are explained below.
Mental Model
Every other namespace virtualizes a set of objects — a process tree, a mount table, a network stack. The time namespace virtualizes something far smaller: two numbers. The kernel keeps a global CLOCK_MONOTONIC (call it M) and a global CLOCK_BOOTTIME (call it B), both counting up from boot. A time namespace adds a pair of constant offsets — off_mono and off_boot — and a process inside it sees M + off_mono and B + off_boot. The init (host) time namespace has both offsets fixed at zero, so it reads the raw clocks. A new namespace can set nonzero offsets before any process joins it, and after that the offsets are frozen for the namespace’s life.
Why only those two clocks? CLOCK_MONOTONIC and CLOCK_BOOTTIME measure elapsed durations from an arbitrary origin — applications use them for timeouts, timers, and “how long since X.” Their absolute value is meaningless across reboots and across machines, but their monotonicity (never going backward) is load-bearing: code computes now - then and assumes it is non-negative. When you migrate a process to a new host, that host’s M started counting at its boot, which could be smaller than the source’s M. Without correction the migrated process would see CLOCK_MONOTONIC lurch backward — a violation that can hang timers, produce negative durations, and crash assumptions. The time namespace’s offset is computed at restore time to exactly cancel the difference, so the clock appears continuous. CLOCK_REALTIME, by contrast, is wall-clock time that is already the same on every machine (NTP-synced) and is expected to be settable and shared — virtualizing it “was avoided for reasons of complexity and overhead within the kernel” (time_namespaces(7)).
flowchart LR subgraph kernel["Kernel global clocks"] M["CLOCK_MONOTONIC = M<br/>(since this host's boot)"] B["CLOCK_BOOTTIME = B"] R["CLOCK_REALTIME<br/>(wall clock, NTP)"] end subgraph ns["Time namespace (offsets frozen)"] OM["off_mono"] OB["off_boot"] end M -->|"+ off_mono"| RDM["process reads<br/>M + off_mono"] B -->|"+ off_boot"| RDB["process reads<br/>B + off_boot"] R -->|"NOT virtualized<br/>(no offset)"| RDR["process reads R<br/>(global wall clock)"] OM -.-> RDM OB -.-> RDB
How a time namespace transforms clock reads. What it shows: monotonic and boottime get a per-namespace additive offset, so a process reads M + off_mono / B + off_boot; realtime passes straight through with no offset. The insight to take: the namespace does not run its own clock — it reuses the host’s single hardware-driven clock and merely shifts the reported value by a constant, which is why it is cheap and why it can keep monotonicity intact across a migration by choosing the offset to cancel the host-to-host gap.
Mechanical Walk-through
The kernel object
In Linux 6.12 the namespace is struct time_namespace in include/linux/time_namespace.h (v6.12):
struct timens_offsets {
struct timespec64 monotonic; /* offset added to CLOCK_MONOTONIC */
struct timespec64 boottime; /* offset added to CLOCK_BOOTTIME */
};
struct time_namespace {
struct user_namespace *user_ns;
struct ucounts *ucounts;
struct ns_common ns; /* generic ns header: inode #, ops, refcount */
struct timens_offsets offsets; /* THE two numbers */
struct page *vvar_page; /* per-ns vDSO data page (user-space reads) */
bool frozen_offsets; /* true once a task has joined */
} __randomize_layout;Note what is not here: there is no clock, no timer, no source of time. The namespace is literally the two timespec64 offsets, a pointer to a single page of memory used by the vDSO (vvar_page), and a frozen_offsets flag. That minimalism is the design.
The unusual “creator is not moved” rule
Here is the property that surprises everyone. When a process calls unshare(CLONE_NEWTIME), it is not placed into the new namespace. Per the man page: “This call creates a new time namespace but does not place the calling process in the new namespace. Instead, the calling process’s subsequently created children are placed in the new namespace” (time_namespaces(7)). The kernel implements this with two time-namespace pointers per process. Inside struct nsproxy there is time_ns (the namespace the task actually reads its clocks from) and time_ns_for_children (the namespace its future children will be born into). unshare(CLONE_NEWTIME) sets only time_ns_for_children; the caller’s own time_ns is untouched.
The reason is purely practical and is stated in the source’s own comment: it “allows clock offsets … for the new namespace to be set before the first process is placed in the namespace.” Offsets become immutable once any process is a member (see freezing, below), so there must be a window in which the namespace exists, is selectable for children, yet has no members — so its offsets can still be written. The split time_ns / time_ns_for_children is exactly that window. The intended dance is: unshare(CLONE_NEWTIME) → write the offsets to /proc/self/timens_offsets → fork(); the child inherits time_ns_for_children as its time_ns and is the first real member.
The fork path makes this concrete. timens_on_fork() in kernel/time/namespace.c (v6.12) runs when a child is created:
void timens_on_fork(struct nsproxy *nsproxy, struct task_struct *tsk)
{
struct ns_common *nsc = &nsproxy->time_ns_for_children->ns;
struct time_namespace *ns = to_time_ns(nsc);
...
if (nsproxy->time_ns == nsproxy->time_ns_for_children)
return; /* nothing to do: already aligned */
get_time_ns(ns);
put_time_ns(nsproxy->time_ns);
nsproxy->time_ns = ns; /* child's ACTUAL ns = parent's for_children */
timens_commit(tsk, ns); /* set up its vDSO page + freeze */
}So the child’s active time_ns is set to the parent’s time_ns_for_children, and timens_commit() finalizes it. The creator never moved; only descendants live in the new namespace. (A process can still join its own children’s namespace later via setns(2) — timens_install() requires the caller be single-threaded and hold CAP_SYS_ADMIN.)
Freezing the offsets
Offsets are writable only while the namespace has no members. The moment the first task enters, they freeze. This is enforced by frozen_offsets and the timens_set_vvar_page() function. On first commit:
static void timens_set_vvar_page(struct task_struct *task, struct time_namespace *ns)
{
...
if (likely(ns->frozen_offsets)) /* fast path: every task after the first */
return;
mutex_lock(&offset_lock);
...
ns->frozen_offsets = true; /* one-way latch */
vdata = arch_get_vdso_data(page_address(ns->vvar_page));
for (i = 0; i < CS_BASES; i++)
timens_setup_vdso_data(&vdata[i], ns); /* bake offsets into vDSO page */
...
}After frozen_offsets is set, any write(2) to /proc/[pid]/timens_offsets fails. The man page states this directly: “after the first process has been created in or has entered the namespace, write(2)s on this file fail with the error EACCES.” The kernel’s proc_timens_set_offset() confirms it — it checks time_ns->frozen_offsets under a mutex and returns -EACCES. This is what makes a time namespace’s offsets a constant: once anyone is using it, the offset can never shift under their feet, preserving monotonicity for the namespace’s whole life.
The vDSO interaction — keeping clock_gettime in user space
A naive implementation would make clock_gettime(CLOCK_MONOTONIC) a syscall inside a time namespace, so the kernel could add the offset. That would be a serious regression: on a normal system clock_gettime is serviced entirely in user space by the vDSO (virtual dynamic shared object — a small kernel-provided shared library mapped into every process that reads a kernel-updated data page directly, avoiding a syscall). The time-namespace design preserves this. The trick is the per-namespace vvar_page: when a process joins a time namespace, the kernel installs a different vDSO data page whose clock_mode is set to VDSO_CLOCKMODE_TIMENS and whose offset[] array holds the namespace’s monotonic/boottime offsets (timens_setup_vdso_data() populates offset[CLOCK_MONOTONIC], offset[CLOCK_BOOTTIME], and the coarse/raw/alarm variants). The man page summarizes the effect: time-namespace offsets “are visible to the vDSO … so that clock_gettime() calls … can be handled entirely in user space.”
The clever bit, documented in the source, is the page ordering. For a normal task the pages are laid out VVAR, PVCLOCK, HVCLOCK, TIMENS; for a timens task they are reordered to TIMENS, PVCLOCK, HVCLOCK, VVAR. The vDSO’s fast path checks clock_mode; when it sees VDSO_CLOCKMODE_TIMENS it knows to add the offsets from the timens page before returning. Crucially, this branch sits in the unlikely path of the sequence-lock magic, so a normal (non-timens) task pays essentially nothing — the offset machinery is invisible to processes in the init namespace. The net result: a process in a time namespace still gets CLOCK_MONOTONIC from a user-space library read, with the per-namespace offset folded in, at full speed.
Configuration / Code / Walk-through Example
The intended creation sequence with unshare(1), which natively understands --monotonic / --boottime:
# Create a time ns, set CLOCK_MONOTONIC/BOOTTIME +1 day for children, run bash
# AS A CHILD (--fork) so it actually enters the namespace, then read uptime
# (which derives from CLOCK_BOOTTIME and so reflects the offset).
$ sudo unshare --time --fork \
--monotonic $((24*3600)) --boottime $((24*3600)) -- \
cat /proc/self/uptimeThe --fork is mandatory: unshare --time only places children in the new namespace (the “creator not moved” rule), so without it the program would run in the host time namespace and see none of the offsets. --monotonic/--boottime write the offsets before the child is forked in.
Doing it by hand exposes the offset file directly:
$ sudo unshare --time --fork bash # --fork is REQUIRED (see below)
# we are now the child, the first member of the new time ns
# cat /proc/self/timens_offsets
monotonic 0 0
boottime 0 0The file format is exactly what proc_timens_show_offsets() prints: a clock label, an offset-seconds field, and an offset-nanoseconds field, one line each for monotonic and boottime. To set an offset you must write before any task joins — which is why you write it through /proc/self/timens_offsets of the process that did the unshare but before it forks a child into the namespace. Note that execve does not change a process’s namespaces (it never touches nsproxy), so only a forked child — never an exec — becomes the first member:
# in the unshared (parent) shell, BEFORE a child joins the ns:
$ unshare --time bash -c '
echo "monotonic 86400 0" > /proc/$$/timens_offsets # +1 day to monotonic
echo "boottime 86400 0" > /proc/$$/timens_offsets
sleep 1000 & # FORK → child is first member
cat /proc/$!/timens_offsets # read the child s frozen offsets
wait'Commentary: the writes set the monotonic and boottime offsets to +86400 seconds (one day) on the namespace recorded in this shell’s time_ns_for_children — the shell itself is still in the old namespace, so the writes are still permitted. The <clock-id> field is monotonic or boottime (numeric 1 and 7 are also accepted for compatibility); the two numeric fields are seconds and nanoseconds. The backgrounded sleep 1000 & forks a child, which is the first member of the new namespace; that membership is what freezes the offsets — any further write returns EACCES. (An exec sleep would not work: replacing the image leaves nsproxy — and thus the time namespace — unchanged, so the offsets would never apply and never freeze.) The kernel range-checks every write in proc_timens_set_offset(): nanoseconds must be ≤ 999,999,999, and the resulting time cannot be negative or exceed KTIME_SEC_MAX / 2 (the / 2 keeping KTIME_MAX unreachable — about 146 years of headroom), and writing requires CAP_SYS_TIME in the namespace’s owning user namespace.
Inspecting namespace identity works like any namespace (namespaces(7)) — note there are two symlinks:
$ readlink /proc/self/ns/time
time:[4026531834]
$ readlink /proc/self/ns/time_for_children
time:[4026531834] # differs from ns/time right after unshare, before forkThe separate time_for_children symlink is the user-visible face of the time_ns_for_children pointer — the only namespace type with this split, and the direct evidence of the “creator not moved” rule.
Failure Modes and Common Misunderstandings
Forgetting --fork. unshare --time puts only children in the new namespace. If you do not --fork, the shell you get back is still in the old time namespace and its clocks are unchanged — a baffling “nothing happened” result. Note that exec is not an alternative entry path: execve keeps nsproxy untouched, so it never moves a process into the namespace — only a fork does (the entry happens in timens_on_fork, not in execve). unshare(1)’s --fork exists precisely for this; without it the new time namespace has the creator pointing at it via time_ns_for_children but the creator itself never joins.
Trying to change offsets after a task joined. write(2) returns EACCES. This is by design — offsets are a one-way latch (frozen_offsets). If you need different offsets, you need a new namespace. Symptom: a script that unshares, forks, then tries to tune the offset, gets Permission denied on the second write.
Expecting CLOCK_REALTIME to shift. Setting offsets does nothing to wall-clock time — date (which reads CLOCK_REALTIME) is identical inside and outside. Only monotonic/boottime-derived measurements (clock_gettime(CLOCK_MONOTONIC), /proc/uptime, timer durations) move. People who expect to “fake the date” in a container with a time namespace are reaching for the wrong tool; that is not what it does.
Needing CAP_SYS_TIME and a single-threaded caller to join. setns(2) into a time namespace via timens_install() requires the process be single-threaded (-EUSERS otherwise) and hold CAP_SYS_ADMIN; writing offsets requires CAP_SYS_TIME. A multi-threaded runtime that tries to setns its main thread into a time namespace will fail — the offsets and vDSO page must be committed coherently for the whole process, which the kernel only allows when there is a single thread to update.
Uncertain
Verify: that the
time_ns/time_ns_for_childrensplit and the vDSO page-reordering behavior are byte-for-byte identical in 6.18 LTS as read here from v6.12. Reason: the v6.12 source was confirmed directly; the 6.18 tree was not diffed for this note. To resolve: diffkernel/time/namespace.candinclude/linux/time_namespace.hbetween thev6.12andv6.18tags. uncertain
Alternatives and When to Choose Them
- Just running the process unmigrated. If a workload never moves between hosts and never checkpoints, it has no use for a time namespace at all — monotonic clocks are already monotonic on a single running kernel. The feature earns its keep only under restore and live migration.
- Faking realtime with
libfaketime/LD_PRELOAD. To shift the wall clock a process sees (for testing date-dependent logic),libfaketimeinterposes ontime()/clock_gettime()in user space. This is the right tool when you want to manipulateCLOCK_REALTIME— which the time namespace explicitly refuses to virtualize. The two are complementary, not competing: time namespaces handle monotonic/boottime continuity;libfaketimehandles realtime fakery. - Per-container NTP / clock skew. There is no kernel mechanism to give containers independent realtime clocks; they share the host’s
CLOCK_REALTIME. If isolated wall-clock time is truly needed, a full VM (with its own emulated RTC) is the only option — another instance of the container-vs-VM trade-off.
Production Notes
The time namespace was built for CRIU — Checkpoint/Restore In Userspace, the tool that snapshots a running process tree to disk and restores it later, possibly on another machine. CRIU’s authors (Andrei Vagin and Dmitry Safonov, the same names in the kernel/time/namespace.c copyright header) drove the feature in. The restore problem it solves: a checkpointed process recorded CLOCK_MONOTONIC values relative to the source host’s boot; on restore, the target host’s monotonic clock is a different, unrelated value. CRIU computes the offset source_monotonic_at_checkpoint - target_monotonic_at_restore, creates a time namespace with that offset, and restores the process tree into it — so every clock_gettime(CLOCK_MONOTONIC) the restored process makes yields a value continuous with what it saw before the checkpoint. The same is done for CLOCK_BOOTTIME. Without the namespace, CRIU could not honestly restore these clocks at all; this is why kernelnewbies records the feature as existing “mainly in the context of checkpoint/restore” (kernelnewbies Linux 5.6).
This is the same migration motivation behind Cgroup Namespaces — both exist so a container carries no host-specific absolute reference (a host cgroup path; a host boot epoch) that would break when it lands somewhere new. Adoption in general-purpose container runtimes (Docker/runc) has lagged the kernel feature: as of the 6.12/6.18 era, time namespaces are used primarily by CRIU-based migration (LXC/LXD, Podman checkpoint, OpenVZ) rather than enabled by default for ordinary docker run containers, because most containers never migrate and the namespace adds nothing for a stationary workload. The vDSO integration means that even when it is used, there is no measurable clock_gettime performance penalty — a deliberate design goal, since timing-sensitive code calls these clocks millions of times per second and a syscall-per-read would be unacceptable.
See Also
- Linux Namespaces Overview — the eight namespace types; time ns is the newest (5.6) and most unusual
- Cgroup Namespaces — sibling namespace sharing the checkpoint/restore migration motivation
- Containers vs Virtual Machines — why realtime isolation needs a VM, not a time namespace
- clone unshare and setns — the syscalls that create/join it; note
--forkrequirement - Container Image Layers and Copy-on-Write — the checkpoint/restore (CRIU) context this serves
- User Namespaces — owns the user namespace whose
CAP_SYS_TIMEgates offset writes - Linux Containers and Isolation MOC — parent map (section B, Namespaces)