如何测定 TCP 传输的延迟?

我如果想要知道一个字节从一个终端经由TCP传输到另一个终端所耗费的时间,即 TCP 的传输延迟(Latency),应该怎么办?

经过查阅相关信息,发现了两种方案:

  1. TCPing(绝大部分答案,但真的如此吗?)
  2. 通过手动构造 TCP SYN 包来测量延迟(github.com/grahamking/latency

然后还有我的灵光一现…

关于 TCPing

尝试搜索了一下 TCPing 的原理,却发现没有一个人从传输层的角度对此进行说明,我甚至得不到我想要的答案。

于是在 GitHub 上搜了一下关键字 tcping,star 最多的项目是 github.com/cloverstd/tcping,很巧是基于 Go 语言实现的,立刻 clone 下来查看源码,却发现乏善可陈…

cloverstd/tcping

入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 省略了大部分代码
var rootCmd = cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
// 拿到 host,即命令行参数,可以为域名,如 google.com
host := args[0]
// 域名不会被 Format,这个方法的实现有 BUG
parseHost := ping.FormatIP(host)

// 构造了 target
target := ping.Target{
Host: parseHost,
}
// 判断协议
switch protocol {
case ping.TCP:
pinger = ping.NewTCPing()
}
pinger.SetTarget(&target)
pingerDone := pinger.Start() // 从这里进去 ping
},
}
1
2
3
4
5
6
7
8
9
// Start,省略了大部分代码
func (tcping TCPing) Start() <-chan struct{} {
go func() {
// 从这里调用 ping() 方法
duration, remoteAddr, err := tcping.ping()
}()
return tcping.done
}

TCPing 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 这个方法便是所谓 tcping
func (tcping TCPing) ping() (time.Duration, net.Addr, error) {
var remoteAddr net.Addr
// timeIt 是一层封装,功能是获取函数执行时间
duration, errIfce := timeIt(func() interface{} {
// TCP 拨号(这里神了,直接传域名进去,DNS LookUp 不需要时间嘛?
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", tcping.target.Host, tcping.target.Port), tcping.target.Timeout)
if err != nil {
return err
}
remoteAddr = conn.RemoteAddr()
// 如果成功建立连接就关闭连接
conn.Close()
return nil
})
// 得到测得的时间,返回
return time.Duration(duration), remoteAddr, nil
}

可以看到,原理:计时开始 –> 建立连接并马上关闭连接 –> 计时结束

zhengxiaowai/tcping

前面一个比较离谱,再看一个吧:zhengxiaowai/tcping,Python 实现的 TCPing,star 数第二名

入口

1
2
if __name__ == '__main__':
cli()

main 函数进来直接到 cli

1
2
3
4
5
6
7
@click.command()
@click.argument('host')
def cli(host, port, count, timeout, report):
ping = Ping(host, port, timeout) # 直接拿命令行参数实例化类
try:
ping.ping(count) # 进入 ping

TCPing 实现

可以看到就是建立连接到关闭连接,调用了 connect

1
2
3
4
5
6
# TCPing,省略了绝大部分代码
def ping(self, count=10):
cost_time = self.timer.cost(
(s.connect, s.shutdown),
((self._host, self._port), None))
self.statistics(n)

connect

再看看这个 connect,唔姆,如出一辙,直接传域名

1
2
def connect(self, host, port=80):
self._s.connect((host, int(port)))

i3h/tcping

再看一个比较靠谱的,i3h/tcping,同样是 Go 实现,入口部分就不再贴了。这位老哥其实没有实现 TCPing,实现的是 HTTPing

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
38
39
40
41
42
43
44
45
46
// 其中一部分代码,可以看到大概分成了DNS LookUp,TCP 握手和 TLS 握手
trace := &httptrace.ClientTrace{
DNSStart: func(info httptrace.DNSStartInfo) {
t0 = time.Now().UnixNano()
},
DNSDone: func(info httptrace.DNSDoneInfo) {
t1 = time.Now().UnixNano()
if info.Err != nil {
err = info.Err
log.Fatal(info.Err)
}
},
ConnectStart: func(net, addr string) {
},
ConnectDone: func(net, addr string, err error) {
if err != nil {
log.Fatalf("unable to connect to host %v: %v", addr, err)
}
t2 = time.Now().UnixNano()
},
GotConn: func(info httptrace.GotConnInfo) {
t3 = time.Now().UnixNano()
},
GotFirstResponseByte: func() {
t4 = time.Now().UnixNano()
},
TLSHandshakeStart: func() {
t5 = time.Now().UnixNano()
},
TLSHandshakeDone: func(_ tls.ConnectionState, _ error) {
t6 = time.Now().UnixNano()
},
}

// 后面分别计算了不同阶段的耗时
if t0 == 0 {
t0 = t2
t1 = t2
}

stats.DNS = t1 - t0
stats.TCP = t2 - t1
stats.Process = t4 - t3
stats.Transfer = t7 - t4
stats.TLS = t6 - t5
stats.Total = t7 - t0

以上便是 TCPing 的实现情况,然而 TCP 的握手再关闭应该是多个 RTT 吧?

按照标准的三次握手四次挥手来算,连接然后立刻关闭应该是 3 个 RTT,但是也不能简单的除 3,因为有时候挥手的 FIN & ACK 是合并发送的,或者有更加复杂的情况…

TCPing 的过程包含多个 RTT,并不是我想知道的“从一端到另一端的延迟”。

那么除了 TCPing,是否有更科学的方法?

手动构造 TCP SYN 包

GitHub 搜索 TCP Latency,我们可以发现这个尘封的 Go Project(上一次 Commit 是 2016 年)

grahamking/latency

latency sends a TCP SYN packet (the opening of the three-way handshake) to a remote host on port 80. That host will respond with either a RST (if the port is closed), or a SYN/ACK (if the port is open). Either way, we time how long it takes between sending the SYN and receiving the response. That’s your network latency.

根据 README,我们手动构造一个 SYN 包发送出去,然后监听收到的SYN & ACK,即TCP三次握手中的前两次,这样便是实实在在的一个 RTT,跟描述一样,这个项目真的手撸了一个 TCP 打包…

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
const (
FIN = 1 // 00 0001
SYN = 2 // 00 0010
RST = 4 // 00 0100
PSH = 8 // 00 1000
ACK = 16 // 01 0000
URG = 32 // 10 0000
)

type TCPHeader struct {
Source uint16
Destination uint16
SeqNum uint32
AckNum uint32
DataOffset uint8 // 4 bits
Reserved uint8 // 3 bits
ECN uint8 // 3 bits
Ctrl uint8 // 6 bits
Window uint16
Checksum uint16 // Kernel will set this if it's 0
Urgent uint16
Options []TCPOption
}

// 手动打包头
func NewTCPHeader(data []byte) *TCPHeader {
var tcp TCPHeader
r := bytes.NewReader(data)
binary.Read(r, binary.BigEndian, &tcp.Source)
binary.Read(r, binary.BigEndian, &tcp.Destination)
binary.Read(r, binary.BigEndian, &tcp.SeqNum)
binary.Read(r, binary.BigEndian, &tcp.AckNum)

var mix uint16
binary.Read(r, binary.BigEndian, &mix)
tcp.DataOffset = byte(mix >> 12) // top 4 bits
tcp.Reserved = byte(mix >> 9 & 7) // 3 bits
tcp.ECN = byte(mix >> 6 & 7) // 3 bits
tcp.Ctrl = byte(mix & 0x3f) // bottom 6 bits

binary.Read(r, binary.BigEndian, &tcp.Window)
binary.Read(r, binary.BigEndian, &tcp.Checksum)
binary.Read(r, binary.BigEndian, &tcp.Urgent)

return &tcp
}

// 手动 TCP Checksum
func Csum(data []byte, srcip, dstip [4]byte) uint16 {

pseudoHeader := []byte{
srcip[0], srcip[1], srcip[2], srcip[3],
dstip[0], dstip[1], dstip[2], dstip[3],
0, // zero
6, // protocol number (6 == TCP)
0, byte(len(data)), // TCP length (16 bits), not inc pseudo header
}

sumThis := make([]byte, 0, len(pseudoHeader)+len(data))
sumThis = append(sumThis, pseudoHeader...)
sumThis = append(sumThis, data...)
//fmt.Printf("% x\n", sumThis)

lenSumThis := len(sumThis)
var nextWord uint16
var sum uint32
for i := 0; i+1 < lenSumThis; i += 2 {
nextWord = uint16(sumThis[i])<<8 | uint16(sumThis[i+1])
sum += uint32(nextWord)
}
if lenSumThis%2 != 0 {
//fmt.Println("Odd byte")
sum += uint32(sumThis[len(sumThis)-1])
}

// Add back any carry, and any carry from adding the carry
sum = (sum >> 16) + (sum & 0xffff)
sum = sum + (sum >> 16)

// Bitwise complement
return uint16(^sum)
}

// 手动解析 TCP 包头
func (tcp *TCPHeader) Marshal() []byte {

buf := new(bytes.Buffer)
binary.Write(buf, binary.BigEndian, tcp.Source)
binary.Write(buf, binary.BigEndian, tcp.Destination)
binary.Write(buf, binary.BigEndian, tcp.SeqNum)
binary.Write(buf, binary.BigEndian, tcp.AckNum)

var mix uint16
mix = uint16(tcp.DataOffset)<<12 | // top 4 bits
uint16(tcp.Reserved)<<9 | // 3 bits
uint16(tcp.ECN)<<6 | // 3 bits
uint16(tcp.Ctrl) // bottom 6 bits
binary.Write(buf, binary.BigEndian, mix)

binary.Write(buf, binary.BigEndian, tcp.Window)
binary.Write(buf, binary.BigEndian, tcp.Checksum)
binary.Write(buf, binary.BigEndian, tcp.Urgent)

for _, option := range tcp.Options {
binary.Write(buf, binary.BigEndian, option.Kind)
if option.Length > 1 {
binary.Write(buf, binary.BigEndian, option.Length)
binary.Write(buf, binary.BigEndian, option.Data)
}
}

out := buf.Bytes()

// Pad to min tcp header size, which is 20 bytes (5 32-bit words)
pad := 20 - len(out)
for i := 0; i < pad; i++ {
out = append(out, 0)
}

return out
}

这就是我心目中的 TCP 延迟测试的答案了,标准的一个 RTT 的时间


以上项目都有一个特点:通用

他们都是在只能控制一端的情况下对 TCP 的延迟进行测量

随后我就想到一种简单的算法,在控制两端的情况下测量一个 RTT 的传输延迟

一个奇思妙想

有两个前提:

  1. TCP 两端由我来编程控制
  2. 网络在一个较小时间段(可以认为是一个 RTT 内)内没有较大的波动

TCP 包结构设计为每个包的首 8 个字节为客户端打包时的时间戳,由此便可以利用该时间戳计算从客户端打包到服务端接收所耗费的时间。

一个简单的思路是直接用服务端的当前时间减去包首部的时间,即可近似认为是传输过程中所消耗的延迟。但是这个思路忽视了一个常见的问题:客户端和服务端的时间往往是不同步的。

不同语言获取 timestamp 的具体实现可能不同,但是可以假定是调用了系统的 date 命令,那么可以认为在同一操作系统上的两个程序,他们的时间是同步的。而问题就出在这个“同一操作系统”:客户端与服务端通常情况下都不会在同一台机器上,更别提操作系统了。

那么既然大部分操作系统都是 ntp 同步过的时间,几毫秒的误差确实也在接受范围内,这个问题就不再深究了?如果两端操作系统的时间差距在几秒,几分钟呢?这正是本设计较真的地方——如何计算出准确的 TCP 延迟。

与通用场景不同,我注意到在本项目中客户端和服务端都是由我控制的,也就是说我可以同时在两边构造 payload 供计算延迟使用——那么,我就想到了一个简单且有效的算法:通过校时使得客户端的时间与服务端保持一致,校时需要一个 RTT,证明如下:

假设服务端有四个时间节点,分别为 $t_0,t_1,t_2,t_3$,其中 $t_0<t_1<t_2<t_3$,客户端与服务端存在可为任意实数的时差 $\delta$,对任意时间节点,满足:
$$
t_{客户端}=t+\delta
$$
假设 $t_0$ 时刻服务端发送包含有 $t_0$ 的数据包到客户端,$t_1$ 时刻客户端接收到包,实际延迟为 $t_1-t_0$,客户端记录下:
$$
d=t_{客1}-t_0=t_1+\delta-t_0
$$
假设 $t_2$ 时刻客户端发送包含有 $t_{客2}-d$ 的数据包到客户端,$t_3$ 时刻服务端接收到包,实际延迟为 $t_3-t_2$,服务端记录下:
$$
total=t_3-(t_{客2}-d)=t_3-((t_2+\delta)-(t_1+\delta-t_0))
$$

$$
total=t_3-(t_2-t_1+t_0)
$$

$$
total=(t_3-t_2)+(t_1-t_0)
$$

即服务端最终所得值 $total$ 为一次发包和一次收包延迟的和,与两服务器间的时差无关,与客户端收包到发包之间间隔的时间无关。

使用该算法,将客户端和服务端的时差调整至 1 小时,经过测试,计算得出的延迟仍为正常值。

下面是该算法的一个 PoC,用 Go 实现,代码来自 github.com/bipy/streamera

服务端发送校时包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 服务端每隔一段时间(这里 128ms,可以任意)便向客户端发送自己的时间戳
func sendTimeStamp(conn net.Conn) {
writer := bufio.NewWriter(conn)
for {
time.Sleep(time.Millisecond << 7)

timePkt := make([]byte, common.TimeStampPacketSize)
binary.LittleEndian.PutUint64(timePkt, uint64(time.Now().UnixMicro()))

_, err := writer.Write(timePkt)
if err != nil {
return
}

err = writer.Flush()
if err != nil {
return
}
}
}

客户端收到包并校时

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
38
39
40
// 客户端每收到时间戳便更新自己的记录 TimeDiff
func handleReceive(client *Client) {
reader := bufio.NewReader(client.TCPConn)
timeStamp := make([]byte, common.TimeStampPacketSize)

for {
_, err := io.ReadFull(reader, timeStamp)
if err != nil {
fmt.Println(common.Red(client.TCPConn.RemoteAddr().String() + " Down! " + err.Error()))
break
}
curTime := time.Now().UnixMicro()
recvTime := int64(binary.LittleEndian.Uint64(timeStamp))

client.TimeDiff = curTime - recvTime
}
}

// 客户端发包时使用自己的时间戳减去 TimeDiff
func pack(frame []byte, td int64) []byte {
// ------ Packet ------
// timestamp (8 bytes)
// content-length (8 bytes)
// content (content-length bytes)
// ------ End ------

timePkt := make([]byte, common.TimeStampPacketSize)
binary.LittleEndian.PutUint64(timePkt, uint64(time.Now().UnixMicro() - td))

contentLengthPkt := make([]byte, common.ContentLengthPacketSize)
binary.LittleEndian.PutUint64(contentLengthPkt, uint64(len(frame)))

var pkt []byte
pkt = append(pkt, timePkt...)
pkt = append(pkt, contentLengthPkt...)
pkt = append(pkt, frame...)

return pkt
}

服务端收到包并更新延迟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 服务端收到校时后的包,取差值的 1/2 作为当前延迟
func handleConn(server *Server, conn net.Conn) {
// 业务处理,省略
for {
// 业务处理,省略

server.Counter.Mutex.Lock() // 获取 SpeedCounter 锁

// 更新 SpeedCounter 中的 LatencyRealTime,即实时延迟
server.Counter.LatencyRealTime = (curTime - recvTime) >> 1

server.Counter.Mutex.Unlock() // 释放 SpeedCounter 锁
}
}