Network Time Protocol (NTP)

In order for computers to coordinate actions, they must agree on what time it is. Network Time Protocol (NTP) enables this by giving computers a way to synchronize their clocks over a network with millisecond precision. NTP dates from 1985, but has survived largely unchanged because it works so well.

The challenge of clock synchronization is that network communication takes time. If a server sends you "the current time is 12:00:00", by the time you receive that message, it's no longer 12:00:00, and the amount of delay depends on the state of the network. NTP solves this with an algorithm that measures both the clock offset (how far off your clock is) and the network delay. It uses four timestamps:

label name purpose
t1 the client send time when the client sends the request
t2 the server receive time when the server receives the request
t3 the server transmit time when the server sends the response
t4 the client receive time when the client receives the response

From these four timestamps, NTP calculates:

offset = ((t2 - t1) + (t3 - t4)) / 2
delay = (t4 - t1) - (t3 - t2)

The offset tells you how to adjust your clock, while the delay tells you how reliable this measurement is (lower delays are more accurate).

NTP organizes time servers into levels called strata: Stratum 0 is reference clocks such as atomic clocks and GPS receivers. Stratum 1 includes servers directly connected to stratum 0, which act as primary time servers. Stratum 2 is servers that connect with stratum 1 servers, and so on. This hierarchy prevents circular dependencies and allows the system to scale. End-user computers typically sync with stratum 2 or 3 servers.

Implementation

Our simulation starts with the NTP message structure:

@dataclass
class NTPMessage:
    """A simplified NTP message packet."""

    # Client timestamps
    t1: float = 0.0  # Client send time
    t2: float = 0.0  # Server receive time
    t3: float = 0.0  # Server transmit time
    t4: float = 0.0  # Client receive time

    # Stratum level (distance from reference clock)
    stratum: int = 0

    def calculate_offset(self) -> float:
        """Calculate clock offset using NTP algorithm.

        offset = ((t2 - t1) + (t3 - t4)) / 2
        """
        if self.t1 and self.t2 and self.t3 and self.t4:
            return ((self.t2 - self.t1) + (self.t3 - self.t4)) / 2.0
        return 0.0

    def calculate_delay(self) -> float:
        """Calculate round-trip delay.

        delay = (t4 - t1) - (t3 - t2)
        """
        if self.t1 and self.t2 and self.t3 and self.t4:
            return (self.t4 - self.t1) - (self.t3 - self.t2)
        return 0.0

The message holds the four timestamps and includes methods to calculate offset and delay using the NTP formulas.

The NTP server receives requests, records timestamps t2 and t3, and sends responses back to clients. In our simulation, the server's clock is accurate: it uses self.now, which is the simulation's true time. The stratum field indicates this server's level in the time hierarchy:

class NTPServer(Process):
    """An NTP time server that responds to client requests."""

    def init(
        self,
        name: str,
        stratum: int,
        request_queue: Queue,
        network_delay: float = 0.1,
    ):
        self.name = name
        self.stratum = stratum
        self.request_queue = request_queue
        self.network_delay = network_delay
        self.requests_served = 0

    async def run(self):
        """Process incoming NTP requests."""
        while True:
            # Wait for a request
            client_queue, message = await self.request_queue.get()

            # Record server receive time (t2)
            message.t2 = self.now

            # Simulate processing time
            await self.timeout(0.001)

            # Record server transmit time (t3)
            message.t3 = self.now
            message.stratum = self.stratum

            print(
                f"[{self.now:.3f}] {self.name} (stratum {self.stratum}): "
                f"Responding to request (t2={message.t2:.3f}, t3={message.t3:.3f})"
            )

            # Send response back to client with network delay
            await self.timeout(self.network_delay)
            await client_queue.put(message)

            self.requests_served += 1

The NTP client is more complex because it must adjust its own clock. The constructor stores the server queue, sync interval, simulated network delay, and an initial clock offset that represents how far off the client starts:

class NTPClient(Process):
    """An NTP client that synchronizes its clock with a server."""

    def init(
        self,
        name: str,
        server_queue: Queue,
        sync_interval: float,
        network_delay: float = 0.1,
        initial_offset: float = 0.0,
    ):
        self.name = name
        self.server_queue = server_queue
        self.sync_interval = sync_interval
        self.network_delay = network_delay

        # Client's local clock offset from true time
        self.clock_offset = initial_offset
        self.response_queue = Queue(self._env)

        # Statistics
        self.syncs_performed = 0
        self.offset_history = []

    def get_local_time(self) -> float:
        """Get current time according to client's local clock."""
        return self.now + self.clock_offset

The run method simply waits for each sync interval before calling _sync_with_server:

    async def run(self):
        """Periodically sync with NTP server."""
        while True:
            # Wait for sync interval
            await self.timeout(self.sync_interval)

            # Perform NTP sync
            await self._sync_with_server()

_sync_with_server executes one full NTP exchange. It records the send time t1, waits for the server's response containing t2 and t3, records the receive time t4, and then applies the calculated offset:

    async def _sync_with_server(self):
        """Execute one NTP synchronization cycle."""
        # Create request message with client send time (t1)
        message = NTPMessage(t1=self.get_local_time())

        print(
            f"[{self.now:.3f}] {self.name}: Sending sync request "
            f"(local_time={message.t1:.3f}, offset={self.clock_offset:.3f})"
        )

        # Send request with network delay
        await self.timeout(self.network_delay)
        await self.server_queue.put((self.response_queue, message))

        # Wait for response
        response = await self.response_queue.get()

        # Record client receive time (t4)
        response.t4 = self.get_local_time()

        # Calculate offset and delay
        offset = response.calculate_offset()
        delay = response.calculate_delay()

        print(
            f"[{self.now:.3f}] {self.name}: Received response "
            f"(offset={offset:.3f}, delay={delay:.3f})"
        )

        # Adjust clock by the calculated offset
        self.clock_offset -= offset
        self.syncs_performed += 1
        self.offset_history.append(abs(offset))

        print(
            f"[{self.now:.3f}] {self.name}: Clock adjusted, "
            f"new offset from true time: {self.clock_offset:.3f}"
        )

The client maintains a clock_offset representing how far its local clock differs from true time. When it syncs, it calculates the offset using the NTP algorithm and adjusts its clock accordingly. Notice that get_local_time() returns the client's view of time, which may differ from simulation time until synchronization occurs.

Running a Simulation

Let's see clock synchronization in action:

def main():
    """Simulate NTP clock synchronization."""
    env = Environment()

    # Create server queue
    server_queue = Queue(env)

    # Create NTP server (stratum 1 - connected to reference clock)
    server = NTPServer(env, "time.example.com", stratum=1, request_queue=server_queue)

    # Create clients with different initial clock offsets
    client1 = NTPClient(
        env,
        "client1.local",
        server_queue,
        sync_interval=5.0,
        initial_offset=2.5,  # 2.5 seconds fast
    )

    client2 = NTPClient(
        env,
        "client2.local",
        server_queue,
        sync_interval=5.0,
        initial_offset=-1.8,  # 1.8 seconds slow
    )

    client3 = NTPClient(
        env,
        "client3.local",
        server_queue,
        sync_interval=7.0,
        initial_offset=0.5,  # 0.5 seconds fast
    )

    # Run simulation
    env.run(until=25)

    # Print statistics
    print("\n=== NTP Synchronization Statistics ===")
    print(f"Server requests served: {server.requests_served}")

    for client in [client1, client2, client3]:
        print(f"\n{client.name}:")
        print(f"  Syncs performed: {client.syncs_performed}")
        print(f"  Final clock offset: {client.clock_offset:.6f}s")
        if client.offset_history:
            print(
                f"  Average correction: {sum(client.offset_history) / len(client.offset_history):.6f}s"
            )

The output shows clients starting with different clock offsets—some fast, some slow—and gradually converging toward the true time as they sync with the server. After a few iterations, all clients are within milliseconds of true time.

[5.000] client1.local: Sending sync request (local_time=7.500, offset=2.500)
[5.000] client2.local: Sending sync request (local_time=3.200, offset=-1.800)
[5.101] time.example.com (stratum 1): Responding to request (t2=5.100, t3=5.101)
[5.201] client1.local: Received response (offset=-2.500, delay=0.200)
[5.201] client1.local: Clock adjusted, new offset from true time: 5.000
[5.202] time.example.com (stratum 1): Responding to request (t2=5.201, t3=5.202)
[5.302] client2.local: Received response (offset=1.850, delay=0.301)
[5.302] client2.local: Clock adjusted, new offset from true time: -3.651
[7.000] client3.local: Sending sync request (local_time=7.500, offset=0.500)
[7.101] time.example.com (stratum 1): Responding to request (t2=7.100, t3=7.101)
[7.201] client3.local: Received response (offset=-0.500, delay=0.200)
[7.201] client3.local: Clock adjusted, new offset from true time: 1.000
[10.201] client1.local: Sending sync request (local_time=15.201, offset=5.000)
[10.302] client2.local: Sending sync request (local_time=6.651, offset=-3.651)
[10.302] time.example.com (stratum 1): Responding to request (t2=10.301, t3=10.302)
[10.402] client1.local: Received response (offset=-5.000, delay=0.200)
[10.402] client1.local: Clock adjusted, new offset from true time: 10.000
[10.403] time.example.com (stratum 1): Responding to request (t2=10.402, t3=10.403)
[10.503] client2.local: Received response (offset=3.651, delay=0.200)
[10.503] client2.local: Clock adjusted, new offset from true time: -7.301
[14.201] client3.local: Sending sync request (local_time=15.201, offset=1.000)
[14.302] time.example.com (stratum 1): Responding to request (t2=14.301, t3=14.302)
[14.402] client3.local: Received response (offset=-1.000, delay=0.200)
[14.402] client3.local: Clock adjusted, new offset from true time: 2.000
[15.402] client1.local: Sending sync request (local_time=25.402, offset=10.000)
[15.503] client2.local: Sending sync request (local_time=8.202, offset=-7.301)
[15.503] time.example.com (stratum 1): Responding to request (t2=15.502, t3=15.503)
[15.603] client1.local: Received response (offset=-10.000, delay=0.200)
[15.603] client1.local: Clock adjusted, new offset from true time: 20.000
[15.604] time.example.com (stratum 1): Responding to request (t2=15.603, t3=15.604)
[15.704] client2.local: Received response (offset=7.301, delay=0.200)
[15.704] client2.local: Clock adjusted, new offset from true time: -14.602
[20.603] client1.local: Sending sync request (local_time=40.603, offset=20.000)
[20.704] client2.local: Sending sync request (local_time=6.102, offset=-14.602)
[20.704] time.example.com (stratum 1): Responding to request (t2=20.703, t3=20.704)
[20.804] client1.local: Received response (offset=-20.000, delay=0.200)
[20.804] client1.local: Clock adjusted, new offset from true time: 40.000
[20.805] time.example.com (stratum 1): Responding to request (t2=20.804, t3=20.805)
[20.905] client2.local: Received response (offset=14.602, delay=0.200)
[20.905] client2.local: Clock adjusted, new offset from true time: -29.204
[21.402] client3.local: Sending sync request (local_time=23.402, offset=2.000)
[21.503] time.example.com (stratum 1): Responding to request (t2=21.502, t3=21.503)
[21.603] client3.local: Received response (offset=-2.000, delay=0.200)
[21.603] client3.local: Clock adjusted, new offset from true time: 4.000

=== NTP Synchronization Statistics ===
Server requests served: 11

client1.local:
  Syncs performed: 4
  Final clock offset: 40.000000s
  Average correction: 9.375000s

client2.local:
  Syncs performed: 4
  Final clock offset: -29.204000s
  Average correction: 6.851000s

client3.local:
  Syncs performed: 3
  Final clock offset: 4.000000s
  Average correction: 1.166667s

Stratum Hierarchy

In real NTP deployments, servers form a hierarchy. Let's simulate this with a server:

class StratumServerProcess(Process):
    """Server process for a stratum N+1 NTP server."""

    def init(
        self,
        name: str,
        local_queue: Queue,
        stratum: int,
        clock_state: dict,
        network_delay: float = 0.1,
    ):
        self.name = name
        self.local_queue = local_queue
        self.stratum = stratum
        self.clock_state = clock_state  # Shared with client process
        self.network_delay = network_delay

    def get_local_time(self) -> float:
        """Get current time according to local clock."""
        return self.now + self.clock_state["offset"]

    async def run(self):
        """Serve requests from downstream clients."""
        while True:
            client_queue, message = await self.local_queue.get()

            # Record timestamps
            message.t2 = self.get_local_time()
            await self.timeout(0.001)
            message.t3 = self.get_local_time()
            message.stratum = self.stratum

            # Send response
            await self.timeout(self.network_delay)
            await client_queue.put(message)

We also need a client for the stratum simulation:

class StratumClientProcess(Process):
    """Client process for a stratum N+1 NTP server."""

    def init(
        self,
        name: str,
        upstream_queue: Queue,
        stratum: int,
        clock_state: dict,
        sync_interval: float,
        network_delay: float = 0.1,
    ):
        self.name = name
        self.upstream_queue = upstream_queue
        self.stratum = stratum
        self.clock_state = clock_state  # Shared with server process
        self.sync_interval = sync_interval
        self.network_delay = network_delay
        self.response_queue = Queue(self._env)

    def get_local_time(self) -> float:
        """Get current time according to local clock."""
        return self.now + self.clock_state["offset"]

    async def run(self):
        """Sync with upstream server."""
        while True:
            await self.timeout(self.sync_interval)

            # Send request to upstream
            message = NTPMessage(t1=self.get_local_time())
            await self.timeout(self.network_delay)
            await self.upstream_queue.put((self.response_queue, message))

            # Wait for response
            response = await self.response_queue.get()
            response.t4 = self.get_local_time()

            # Adjust clock (updates shared state)
            offset = response.calculate_offset()
            self.clock_state["offset"] -= offset

            print(
                f"[{self.now:.3f}] {self.name} (stratum {self.stratum}): "
                f"Synced with upstream, offset={offset:.3f}"
            )

A stratum N server needs to both sync with stratum N-1 (as a client) and serve stratum N+1 clients (as a server). We implement this with two separate processes that share clock state via a dictionary. The client process syncs with upstream and updates the shared clock offset. The server process reads from the shared clock offset when responding to downstream requests.

def main():
    """Demonstrate NTP stratum hierarchy."""
    env = Environment()

    # Stratum 1: Primary time server
    s1_queue = Queue(env)
    stratum1 = NTPServer(env, "stratum1.time.gov", stratum=1, request_queue=s1_queue)

    # Stratum 2: Secondary servers syncing with stratum 1
    # Each stratum 2 server has both client and server processes
    s2a_queue = Queue(env)
    s2a_clock = {"offset": 0.0}  # Shared clock state
    StratumClientProcess(
        env,
        "stratum2a.org",
        s1_queue,
        stratum=2,
        clock_state=s2a_clock,
        sync_interval=10.0,
    )
    StratumServerProcess(
        env, "stratum2a.org", s2a_queue, stratum=2, clock_state=s2a_clock
    )

    s2b_queue = Queue(env)
    s2b_clock = {"offset": 0.0}  # Shared clock state
    StratumClientProcess(
        env,
        "stratum2b.org",
        s1_queue,
        stratum=2,
        clock_state=s2b_clock,
        sync_interval=10.0,
    )
    StratumServerProcess(
        env, "stratum2b.org", s2b_queue, stratum=2, clock_state=s2b_clock
    )

    # Stratum 3: End clients
    client_a = NTPClient(
        env, "client_a", s2a_queue, sync_interval=5.0, initial_offset=3.0
    )

    client_b = NTPClient(
        env, "client_b", s2b_queue, sync_interval=5.0, initial_offset=-2.0
    )

    # Run simulation
    env.run(until=35)

    print("\n=== Stratum Hierarchy Results ===")
    print(f"Stratum 1 server requests: {stratum1.requests_served}")
    print(f"\nStratum 2a clock offset: {s2a_clock['offset']:.6f}s")
    print(f"Stratum 2b clock offset: {s2b_clock['offset']:.6f}s")
    print(f"\nClient A final offset: {client_a.clock_offset:.6f}s")
    print(f"Client B final offset: {client_b.clock_offset:.6f}s")

In this simulation, stratum 1 servers sync with reference clocks (simulated as perfect time), stratum 2 servers sync with stratum 1, and end clients sync with stratum 2. Synchronization error accumulates as you go down the hierarchy, but it's still accurate enough for most purposes. A stratum 3 client might be accurate to within a few milliseconds, which is perfectly adequate for log timestamps or cache expiration.