在我上一篇文章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上获取到。