4

更精准的时延:使用软件时间戳和硬件时间戳

 7 months ago
source link: https://colobu.com/2023/09/24/precise-rtt-for-ping/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

更精准的时延:使用软件时间戳和硬件时间戳

在我上一篇文章mping: 使用新的icmp库实现探测和压测工具文章中,介绍了使用新的第三方库icmpx使用ping的功能,实现了mping这样一个高性能的探测和压测工具,并且还计算了往返时延指标(RTT, Round Trip Time)。

有时候,我们在做应用开发的时候,比如微服务调用的时候,也常常会计算程序的延时(latency)。

一般情况下,我们通过在应用层读取时间戳,计算两个时间戳的延时(t1−t0t1−t0),就可以得到时延,就足够了。通过观察这个数据,我们可以看到网络的时延情况(latency)和抖动(jitter)。但是有时候,我们想知道物理网络传输网络的时延是多少,比如北京A机房到B机房的时延,如果通过应用层的时间戳来计算,误差就太大了。为什么呢?

我们知道,当你的服务器和另外一个服务器通讯的时候,包(packet)其实经过了很漫长的链路,从你的应用程序写入本机的buffer,到本机协议栈的处理,网卡处理、网线、机房的各种网络设备、骨干网、再到对端机房、网卡、协议栈、应用程序,经过了很多很多的环节,如果还经过了云网络的话,会更复杂。其中应用层到网卡处理这一段时间,可能会因为CPU的处理能力、服务器负载、网络处理的能力,导致有比较大的耗时,如果在应用层计算网络两点之间的网络时延的话,不能正确得到两点之间的时延或者RTT。

一般来说,光信号在光纤中的传输速度大约为20万公里/秒,所以理论上每100公里的物理网络时延大约为0.5毫秒。但光信号在光纤上的传播时延会受到光纤材质、组件损耗、连接损耗等因素的影响,会比理论值稍大一些。另外在运营商实际网络中,还需要考虑路由器处理带来的转发时延的影响。

北京到广州的全程大约为2200公里。按照理论计算时延11毫秒, RTT的话需要来回的时延,所以是22毫秒,但是实际是,我使用我在北京的一个腾讯云的服务器ping广州的一台机器,时延大约38.9毫秒:

ubuntu@lab:~$ ping 221.4.66.66
PING 221.4.66.66 (221.4.66.66) 56(84) bytes of data.
64 bytes from 221.4.66.66: icmp_seq=1 ttl=251 time=38.9 ms
64 bytes from 221.4.66.66: icmp_seq=2 ttl=251 time=38.8 ms
64 bytes from 221.4.66.66: icmp_seq=3 ttl=251 time=38.9 ms
64 bytes from 221.4.66.66: icmp_seq=4 ttl=251 time=38.9 ms
64 bytes from 221.4.66.66: icmp_seq=5 ttl=251 time=38.9 ms

这个指标对于物理网络建设以及准备使用云设施的服务器来说,非常的重要,毕竟越短的时延会给我们带来更好的性能。同时如果更好的更准确的计算这个时延也很重要了。

软件时间戳和硬件时间戳

我们可以通过软件时间戳或者硬件时间戳,更精确的计算包的进入发送和接收的时间戳,去掉应用层或者协议栈层带来的误差。

如果硬件和驱动程序支持,网卡会在发送和接收数据包时,使用硬件计数器向数据包的时间戳字段写入一个高精度时间戳。

如果硬件不支持,Linux也实现实现一个软件的时间戳,协议栈处理收到和发出的包时写入一个高精度时间戳。

  • 软件时间戳(Software Timestamp)
    通过软件方式获取时间和写入数据包的时间戳。相比硬件时间戳,软件时间戳有以下特点:

    • 获取时间和写入时间戳的过程在软件层完成,不需要硬件支持。
    • 时间精度较低,通常只能达到毫秒级。硬件时间戳可以达到微秒或纳秒级精度。
    • 时间同步不够精确。受到软件运行开销、系统调度等因素影响。
    • 对系统资源占用较大,会增加中断开销。
    • 只能标记退出和进入协议栈的时间,不能精确标记发送和接收时刻。
    • 不同设备之间时间同步困难,容易产生时间偏差。
  • 硬件时间戳(Hardware Timestamp)
    通过硬件芯片中的计数器来获取时间和写入数据包时间戳。相比软件时间戳,硬件时间戳具有以下优点:

    • 时间精度高,可以达到纳秒或皮秒级,满足对实时性要求较高的场景。
    • 时间捕获精确,可以准确标记数据包的发送时刻和接收时刻。
    • 对系统资源占用少,减少了中断开销。
    • 不同设备之间时间同步容易,通过协议如PTP实现同步精度高。
    • 不受软件运行开销等影响,时间戳更准确。

可以通过 ethtool -T <网络接口名>来查看机器对软硬件时间戳的支持情况。比如下面这台机器软硬件时间戳都不支持

下面这台机器只支持软件时间戳:

下面这台机器支持软硬件时间戳:

使用软硬件时间戳

Linux内核对软硬件时间戳的支持是渐进的。

软件时间戳(Software Timestamping)自2.6内核开始支持,通过调用clock_gettime()等时间系统调用可以获取software timestamp,timestamp精度可以达到纳秒级。但软件时间戳易受到系统调度、中断等影响,精度较差。

硬件时间戳(Hardware Timestamping)自3.5内核开始引入PTP硬件时间戳支持,主要应用于高精度时间同步,能够直接读取网络卡、FPGA等硬件计数器的值作为时间戳,精度可以达到纳秒甚至皮秒级。但需要硬件支持,且对驱动和读数有一定要求。

接下来我对mping工具进行改造,让它:

  • 如果client支持硬件时间戳,那么则使用硬件时间戳
  • 如果client不支持硬件时间戳,退而求其次,使用软件时间戳
  • 如果client软硬件时间戳都不支持,那么则使用应用程序的时间戳

接下来我边讲解代码的同时,边讲解如何使用软硬件时间戳的。

因为需要对socket进行底层的设置和读写,所以使用icmpx这个库已经不合适了,我把原来的mping项目转换回conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")的形式,这样我们就可以得到socket的文件描述符进行开启软硬件时间戳的设置,并且可以读取这些时间戳了。

func openConn() (*net.IPConn, error) {
conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return nil, err
ipconn := conn.(*net.IPConn)
f, err := ipconn.File()
if err != nil {
return nil, err
defer f.Close()
fd := int(f.Fd())
flags := unix.SOF_TIMESTAMPING_SYS_HARDWARE | unix.SOF_TIMESTAMPING_RAW_HARDWARE | unix.SOF_TIMESTAMPING_SOFTWARE | unix.SOF_TIMESTAMPING_RX_HARDWARE | unix.SOF_TIMESTAMPING_RX_SOFTWARE |
unix.SOF_TIMESTAMPING_TX_HARDWARE | unix.SOF_TIMESTAMPING_TX_SOFTWARE |
unix.SOF_TIMESTAMPING_OPT_CMSG | unix.SOF_TIMESTAMPING_OPT_TSONLY
if err := syscall.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_TIMESTAMPING, flags); err != nil {
supportTxTimestamping = false
supportRxTimestamping = false
if err := syscall.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_TIMESTAMPNS, 1); err == nil {
supportRxTimestamping = true
return ipconn, nil
timeout := syscall.Timeval{Sec: 1, Usec: 0}
if err := syscall.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &timeout); err != nil {
return nil, err
if err := syscall.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_SNDTIMEO, &timeout); err != nil {
return nil, err
return ipconn, nil
  • 首先我们要先创建一个icmp conn对象,通过net.ListenPacket("ip4:icmp", "0.0.0.0")即可获得。
  • 然后得到它的文件描述符(通过File.Fd方法),也有通过Control方法得到socket的文件描述符的:
var connFd int
err = conn.Control(func(fd uintptr) {
connFd = int(fd)

两种方法都可以。

  • 接下来我们通过SetsockoptInt设置读取软硬件时间戳。 软硬件的标志都设置上,发送和接收的时间戳都设置上。你可以想想,发送的软硬件时间戳我们咋获取?应用程序把外放数据写入到缓冲区就返回了,那个时候它是得不到软硬件时间戳的。通过设置SOF_TIMESTAMPING_OPT_CMSG,可以在在网卡发送外发数据时,把软件或者硬件的时间戳写如到MSG_ERRQUEUE,你可以后续读取到这个时间戳。

这里不会主动帮你开启硬件时间戳。如果你的硬件支持,但是没有开启的话,你可以手动开始硬件时间戳。

这里如果当前的操作系统不支持SO_TIMESTAMPING的话,那么尝试设置SO_TIMESTAMPNS, SO_TIMESTAMPNS自2.6以来就开始支持了。

发送时读取发送的时间戳

......
_, err = conn.WriteTo(data, target)
if err != nil {
return err
rs := &Result{
txts: ts,
target: target.IP.String(),
seq: seq,
if supportTxTimestamping {
if txts, err := getTxTs(fd); err != nil {
if strings.HasPrefix(err.Error(), "resource temporarily unavailable") {
continue
fmt.Printf("failed to get TX timestamp: %s", err)
rs.txts = txts
......
func getTxTs(socketFd int) (int64, error) {
pktBuf := make([]byte, 1024)
oob := make([]byte, 1024)
_, oobn, _, _, err := syscall.Recvmsg(socketFd, pktBuf, oob, syscall.MSG_ERRQUEUE)
if err != nil {
return 0, err
return getTsFromOOB(oob, oobn)

每写完一个数据包,则尝试从这个socket中读取发送时的软硬件时间戳,通过Recvmsg系统调用从MSG_ERRQUEUE获取。

接收时读取软硬件时间戳

......
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Millisecond))
n, oobn, _, ra, err := conn.ReadMsgIP(pktBuf, oob)
if err != nil {
return err
var rxts int64
if supportRxTimestamping {
if rxts, err = getTsFromOOB(oob, oobn); err != nil {
return fmt.Errorf("failed to get RX timestamp: %s", err)
} else {
rxts = time.Now().UnixNano()
......

conn.ReadMsgIP会返回Out-Of-Band的数据,接收时的软件或者硬件时间戳就写入到这里面,我们通过getTsFromOOB方法解析:

func getTsFromOOB(oob []byte, oobn int) (int64, error) {
cms, err := syscall.ParseSocketControlMessage(oob[:oobn])
if err != nil {
return 0, err
for _, cm := range cms {
if cm.Header.Level == syscall.SOL_SOCKET && cm.Header.Type == syscall.SO_TIMESTAMPING {
var t unix.ScmTimestamping
if err := binary.Read(bytes.NewBuffer(cm.Data), binary.LittleEndian, &t); err != nil {
return 0, err
for i := 0; i < len(t.Ts); i++ {
if t.Ts[i].Nano() > 0 {
return t.Ts[i].Nano(), nil
return 0, ErrStampNotFund
if cm.Header.Level == syscall.SOL_SOCKET && cm.Header.Type == syscall.SCM_TIMESTAMPNS {
var t unix.Timespec
if err := binary.Read(bytes.NewBuffer(cm.Data), binary.LittleEndian, &t); err != nil {
return 0, err
return t.Nano(), nil
return 0, ErrStampNotFund

如果是时间戳的数据, Level是SOL_SOCKET, Type是SO_TIMESTAMPING或者老版本的SCM_TIMESTAMPNS。

我们需要一个unix.ScmTimestamping数据类型反序列这个数据,它包含长度是3的一个数据。一般软件时间戳放入到第一个元素中,硬件时间戳放入到第三个,但是至少会有一个元素包含时间戳,我们依次遍历,看看哪一个时间戳设置了就用哪一个。

这个mping的例子演示了使用软硬件时间戳精确计算时延的例子,使用软硬件时间戳还可以实现更精确的时间服务PTP。 mping的代码可以从github上获取到。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK