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

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

在我上一篇文章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毫秒:

1234567ubuntu@lab:~$ ping 221.4.66.66PING 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 ms64 bytes from 221.4.66.66: icmp_seq=2 ttl=251 time=38.8 ms64 bytes from 221.4.66.66: icmp_seq=3 ttl=251 time=38.9 ms64 bytes from 221.4.66.66: icmp_seq=4 ttl=251 time=38.9 ms64 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的文件描述符进行开启软硬件时间戳的设置,并且可以读取这些时间戳了。

创建连接

12345678910111213141516171819202122232425262728293031323334353637func 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的文件描述符的:

1234var connFd interr = conn.Control(func(fd uintptr) { connFd = int(fd) })

两种方法都可以。

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

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

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

发送时读取发送的时间戳

1234567891011121314151617181920212223242526272829303132 ......_, 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获取。

接收时读取软硬件时间戳

1234567891011121314151617 ...... _ = conn.SetReadDeadline(time.Now().Add(10 * time.Millisecond))n, oobn, _, ra, err := conn.ReadMsgIP(pktBuf, oob)if err != nil { return err}var rxts int64if 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方法解析:

1234567891011121314151617181920212223242526272829303132func 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上获取到。

评论留言