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

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

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

网络时延

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

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

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

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

1
2
3
4
5
6
7
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的文件描述符进行开启软硬件时间戳的设置,并且可以读取这些时间戳了。

创建连接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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的文件描述符的:
1
2
3
4
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以来就开始支持了。

发送时读取发送的时间戳

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
......
_, 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获取。

接收时读取软硬件时间戳

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
......
_ = 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方法解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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上获取到。