传输控制协议(英语:Transmission Control Protocol,缩写:TCP)是一种面向连接的、可靠的、基于字节流的传输层通信协议,其拥有着相对而言的可靠传输(相对UDP),由于Tcp的相关特性如在连接之前先创建两端的虚拟连接,以及发送数据的超时重传、滑动窗口、流量/拥塞控制等特性保证了其可靠的传输,因而TCP通常会保证数据准确交付。
但由于其在穿输数据之前需要进行虚拟连接的建立,这回消耗一定的传输时间,且在传输过程之中为保证数据正确交付而采用的超时重传、滑动窗口、流量/拥塞控制等机制也会消耗大量的传输时间,另外由于建立TCP需要进行虚拟连接建立,因此可能会被利用从而收到DDOS,DOS等攻击。
总的来说,Tcp算是一种消耗一定的传输性能而确保数据准确到达对端的一种协议,当需要网络质量很高时,需要采用Tcp协议。
用户数据报协议(英语:User Datagram Protocol,缩写:UDP;又称用户数据包协议)是一个简单的面向数据报的通信协议,其没有Tcp的发送前建立连接这一特性因此其是一个无状态协议,同时也没有Tcp中确保数据准确到达的一些保证机制,所以对于Tcp来说Udp的传输很快速,但是从而引发出了Udp协议的是不可靠的传输协议,当网络波动较大时,丢包率会很高,所以Udp保证数据快速到达但不能保证必定到达(管杀不管埋?),且虽然Udp无需建立连接,也就可能较Tcp被攻击者利用的漏洞就要少一些。但是其仍然无法避免被攻击。
那么有没有一种协议可以既拥有Tcp的可靠性,又拥有Udp的快速性呢?(既要也要你想)
诶,还真有,那就是本篇文章的主角Kcp协议,Kcp是个啥呢?其官网是这么介绍的:
KCP是一个快速可靠协议,能以比 TCP 浪费 10%-20% 的带宽的代价,换取平均延迟降低 30%-40%,且最大延迟降低三倍的传输效果。纯算法实现,并不负责底层协议(如UDP)的收发,需要使用者自己定义下层数据包的发送方式,以 callback的方式提供给 KCP。 连时钟都需要外部传递进来,内部不会有任何一次系统调用。
TCP是为流量设计的(每秒内可以传输多少KB的数据),讲究的是充分利用带宽。而 KCP是为流速设计的(单个数据包从一端发送到一端需要多少时间),以10%-20%带宽浪费的代价换取了比 TCP快30%-40%的传输速度。TCP信道是一条流速很慢,但每秒流量很大的大运河,而KCP是水流湍急的小激流。KCP有正常模式和快速模式两种,通过以下策略达到提高流速的结果。
理论上来说Kcp其实并不是一个传输层协议, 其是在传输层协议(一般都使用Udp作为基础,下文也以Udp为基础展开)的基础之上,通过算法方式实现ARQ模型以及可靠传输的应用层协议。
此处存疑,因为Kcp不算是一个传输层协议,但是网络上很多文章都将其算作为是一个具有可靠性的传输层ARQ协议,作者对此表示不解,如果有大神了解此问题,请后台留言,再次先做感谢。
那么Kcp通过何种方式来保证其传输可靠性呢?首先来看一下Kcp协议的协议头的组成格式。
在Kcp中每次的数据传输通常都是基于Udp的,也就是说在传输过程中,实际上Kcp的协议头是被包含在Udp的协议的数据包之中的数据字段中(自行前往了解Udp协议头长啥样)的,举个例子,下面的Byte数组打印结果是一个通过udp.ReadFromUdp接收到的Udp数据包,其中包含了Kcp的协议头以及数据,我们来看一下他长啥样:
从上图中可以看到,Kcp的协议头及其数据都是包含在Udp数据包的数据字段中的,因此可以说Kcp是基于Udp的,且其不是一个传输层协议而是一个应用层协议(此处仍然存疑)。
Kcp协议的开发者表示,KCP是一个快速可靠协议,那么可靠在哪里快速在哪里?众所周知,Tcp协议的可靠性是由连接之前先创建两端的虚拟连接,以及发送数据的超时重传、滑动窗口、流量/拥塞控制等特性保证了其可靠的传输,那么Kcp为何可靠且快速呢?官网中给了如下的解释,以下的保证可靠的方式均由纯算法完成。
RTO翻倍vs不翻倍:
TCP超时计算是RTOx2,这样连续丢三次包就变成RTOx8了,十分恐怖,而KCP启动快速模式后不x2,只是x1.5(实验证明1.5这个值相对比较好),提高了传输速度。
选择性重传 vs 全部重传:
TCP丢包时会全部重传从丢的那个包开始以后的数据,KCP是选择性重传,只重传真正丢失的数据包。
快速重传:
发送端发送了1,2,3,4,5几个包,然后收到远端的ACK: 1, 3, 4, 5,当收到ACK3时,KCP知道2被跳过1次,收到ACK4时,知道2被跳过了2次,此时可以认为2号丢失,不用等超时,直接重传2号包,大大改善了丢包时的传输速度。
延迟ACK vs 非延迟ACK:
TCP为了充分利用带宽,延迟发送ACK(NODELAY都没用),这样超时计算会算出较大 RTT时间,延长了丢包时的判断过程。KCP的ACK是否延迟发送可以调节。
UNA vs ACK+UNA:
ARQ模型响应有两种,UNA(此编号前所有包已收到,如TCP)和ACK(该编号包已收到),光用UNA将导致全部重传,光用ACK则丢失成本太高,以往协议都是二选其一,而 KCP协议中,除去单独的 ACK包外,所有包都有UNA信息。
非退让流控:
KCP正常模式同TCP一样使用公平退让法则,即发送窗口大小由:发送缓存大小、接收端剩余接收缓存大小、丢包退让及慢启动这四要素决定。但传送及时性要求很高的小数据时,可选择通过配置跳过后两步,仅用前两项来控制发送频率。以牺牲部分公平性及带宽利用率之代价,换取了开着BT都能流畅传输的效果。
Kcp协议各个语言几乎都对Kcp协议进行了实现,Go也不例外,Go的Kcp实现库如下:
https://github.com/xtaci/kcp-go
kcp-go is a Production-Grade Reliable-UDP library for golang.
This library intents to provide a smooth, resilient, ordered, error-checked and anonymous delivery of streams over UDP packets, it has been battle-tested with opensource project kcptun. Millions of devices(from low-end MIPS routers to high-end servers) have deployed kcp-go powered program in a variety of forms like online games, live broadcasting, file synchronization and network acceleration.
由于这个库对Kcp的原生特性进行了包装,给的案例也是基于封装之后的,其将Kcp的底层做了封装,那么接下来我把封装揭开,直接使用Kcp进行Demo的制作。
type Conv = uint32
// 案例是Kcp Server 但是只要把kcpList的类型从map换成
// *kcp.KCP 就可以当做一个客户端用了
type KcpServer struct {
kcpList map[Conv]*kcp.KCP
}
func NewKcpServer() *KcpServer {
return &KcpServer{
kcpList: make(map[Conv]*kcp.KCP, 128),
}
}
func (receiver *KcpServer) UdpListen() {
// 创建一个Udp监听 因为Udp客户端和服务器区分不大
// 所以客户端代码和这个一样
udp, err := net.ListenUDP("udp", &net.UDPAddr{
IP: net.ParseIP("0.0.0.0"),
Port: 8888,
})
if err != nil {
// 别问我为啥用Panic
// 这只是个demo
panic(err)
}
// kcp默认mtu为1400
data := make([]byte, 1400, 1400)
for {
// 接收udp数据包
n, addr, err := udp.ReadFromUDP(data)
if err != nil {
panic(err)
}
fmt.Println(data[:n])
// kcp包头长度24,且不存在只发个包头的情况
// 但是 当n==4 的时候代表新链接注册
// 要是作为客户端的话 可以根据自己的情况来修改
if n <= 24 && n != 4 {
continue
}
// 获取kcp协议头的前4位conv
conv := binary.LittleEndian.Uint32(data[:4])
// 看看有没有符合这个conv的kcp连接
// 要是作为客户端的话 可以根据自己的情况来修改
k, ok := receiver.kcpList[conv]
if ok {
// 有已经建立的kcp链接就去做一些设定好的操作
err := receiver.kcpDo(k, data, n, err)
if err != nil {
log.Println(err.Error())
}
continue
}
// 没有就新建一个
k, conv = receiver.CreateKcp(func(buf []byte) {
_, err := udp.WriteToUDP(buf, addr)
if err != nil {
panic(err)
}
})
receiver.kcpList[conv] = k
}
}
func (receiver *KcpServer) kcpDo(k *kcp.KCP, data []byte, n int, err error) error {
// 把当前数据包压入对应的Kcp连接的状态机中
// 这个函数可以将附带Kcp协议头的数据进行解析
// 之后将解析到的数据放入Recv Queue中
// 然后向发送方返回Ack
k.Input(data[:n], true, true)
// 检查看看Recv Queue中下一个消息的大小
newN := k.PeekSize()
if newN < 0 {
return errors.New(fmt.Sprintf("error code %d", newN))
}
recvData := make([]byte, newN, newN)
// 从Recv Queue中取出一份数据
leng := k.Recv(recvData)
if leng < 0 {
return errors.New(fmt.Sprintf("error code %d", newN))
}
// TODO: 利用获取到的数据做一些事情
return nil
}
// 这个是个测试发送而已
func WriteLoop(sess *kcp.KCP) {
for {
time.Sleep(time.Second)
msg := strconv.Itoa(rand.Int())
n := sess.Send([]byte(msg))
if n < 0 {
panic(n)
}
}
}
func (receiver *KcpServer) CreateKcp(callback func(buf []byte)) (*kcp.KCP, Conv) {
// 创建一个虚拟的kcp链接
// 随机出一个Conv
// 第二个参数传入一个回调函数
// 每次发送数据都会调用这个回调函数
// 可以理解为这个函数是自己编写的发送函数
conv := rand.Uint32()
k := kcp.NewKCP(conv, func(buf []byte, size int) {
// 发送数据要大于24 因为kcp头为24
if size > 24 {
callback(buf[:size])
}
})
// 发送一个空包 用于探测与对端的链接
// 这个写的感觉是C的错误处理方式,也不知道为啥要这样写
errCode := k.Send([]byte{0, 0, 0, 0})
if errCode < 0 {
panic(errCode)
}
// 定时做Update(k)
go Update(k)
// 这个是用来测试发送的
go WriteLoop(k)
return k, conv
}
func Update(k *kcp.KCP) {
// 创建一个定时器 并且调用Check()来判断下一次的调用Update()应该在啥时候
// 你可以在这段时间内调用Update()
// 而不是重复调用Update()。重要的是减少不必要的Update()调用。
// 使用它来调度Update()
// (例如,实现一个类似于epoll的机制,或者在处理大量kcp连接时优化Update())
tm := time.NewTicker(time.Duration(k.Check()))
defer tm.Stop()
for {
select {
case <-tm.C:
// 更新状态
// 以一定频率调用 Update()kcp状态
k.Update()
// 确定下一个调用时间
tm.Reset(time.Duration(k.Check()))
}
}
}
func main() {
NewKcpServer().UdpListen()
}
揭开封装后可以看见,实际上的Kcp操作无论是客户端还是服务器的接收操作和发送操作都是按照一下几个步骤进行:
接收操作:
- Udp Listen接收到数据。
- 使用Kcp对象调用Input函数将接收到的数据塞进Input函数,在此函数中会对传入的数据进行Kcp协议头的解析并且向发送方返回Ack(UNA)。然后把去掉Kcp协议头的数据放入Recv Queue中等待接收。
- 使用Kcp对象调用PeekSize()判断Recv Queue中下一个可接收包的长度。
- 使用Kcp对象调用Recv(),从Recv Queue中接收数据。
- 进行数据处理。
发送操作:
- 使用Kcp对象调用Send()函数,将要发送的数据进行Kcp协议头封装后,放入Send Queue。
- 定时调用Update()函数进行数据发送,发送时会调用在创建Kcp对象时传入的回调函数进行发送。
上面的步骤中有一步是定时调用Update()函数,这个函数是做啥的呢?Update()函数的作用是判断当前时间,如果符合一定规则,则调用kcp.flush()函数将处于Send Queue中的待发送数据发送出去,并根据需要判断是否需要重传、窗口滑动等操作。
// nodelay :是否启用 nodelay模式,0不启用;1启用。
// interval :协议内部工作的 interval,单位毫秒,比如 10ms或者 20ms
// resend :快速重传模式,默认0关闭,可以设置2(2次ACK跨越将会直接重传)
// nc :是否关闭流控,默认是0代表不关闭,1代表关闭。
// 普通模式:`NoDelay(kcp, 0, 40, 0, 0);
// 极速模式: NoDelay(kcp, 1, 10, 2, 1);
func (kcp *KCP) NoDelay(nodelay, interval, resend, nc int) int
// 该调用将会设置协议的最大发送窗口和最大接收窗口大小,默认为32.
// 这个可以理解为 TCP的 SND_BUF 和 RCV_BUF,只不过单位不一样 SND/RCV_BUF 单位是字节,这个单位是包。
func (kcp *KCP) WndSize(sndwnd, rcvwnd int) int
// 纯算法协议并不负责探测 MTU,默认 mtu是1400字节
// 可以使用SetMtu来设置该值。该值将会影响数据包归并及分片时候的最大传输单元
func (kcp *KCP) SetMtu(mtu int) int
不负责任的解析一下Send、Recv、Input三个重要的函数的源代码和工作过程的解析。
func (kcp *KCP) Send(buffer []byte) int {
var count int
// 不能只发送一个Kcp协议头
if len(buffer) == 0 {
return -1
}
// 如果当前kcp处于流模式,则和上一次发送的组合到一起(如果能组合的话)
// 这个地方就不细说了 正常来说使用NewKcp创建的kcp对象不会处于流模式
if kcp.stream != 0 {
n := len(kcp.snd_queue)
if n > 0 {
seg := &kcp.snd_queue[n-1]
if len(seg.data) < int(kcp.mss) {
capacity := int(kcp.mss) - len(seg.data)
extend := capacity
if len(buffer) < capacity {
extend = len(buffer)
}
// grow slice, the underlying cap is guaranteed to
// be larger than kcp.mss
oldlen := len(seg.data)
seg.data = seg.data[:oldlen+extend]
copy(seg.data[oldlen:], buffer)
buffer = buffer[extend:]
}
}
if len(buffer) == 0 {
return 0
}
}
// 如果要发送的数据的长度小于mss的长度则就发一个包就行了
// 否则进行分片
if len(buffer) <= int(kcp.mss) {
count = 1
} else {
count = (len(buffer) + int(kcp.mss) - 1) / int(kcp.mss)
}
// 分片大于255就出问题了
if count > 255 {
return -2
}
// 至少有1个包 不然发他干嘛
if count == 0 {
count = 1
}
for i := 0; i < count; i++ {
var size int
// 发的数据大于mss那就每片按照mss的大小发
// 否则就按照实际长度发
if len(buffer) > int(kcp.mss) {
size = int(kcp.mss)
} else {
size = len(buffer)
}
// 创建一个kcp的数据段
seg := kcp.newSegment(size)
// 复制过去
copy(seg.data, buffer[:size])
// 如果不是流模式那就把frg设置为当前是第几片
// 就是 假如分了10片,现在是第2片
// 当前的frg就是10-2-1=7
// frg就是告诉接收方后面还有几片
if kcp.stream == 0 {
seg.frg = uint8(count - i - 1)
} else {
seg.frg = 0
}
// 把组装好了的kcp的数据段塞进发送队列
kcp.snd_queue = append(kcp.snd_queue, seg)
buffer = buffer[size:]
}
return 0
}
Send()把要发送的buffer分片成Kcp的数据段,然后构建一个数据段结构体后把这个数据段塞进发送队列里。当要发送的数据长度超过一个MSS(最大分片大小)的时候(Mss默认为MTU-kcp Head,即1400-24),会对发送的数据进行分片。然后每片都放入到构建的Kcp数据段,然后塞进发送队列里等待发送。
func (kcp *KCP) Recv(buffer []byte) (n int) {
// 看看Recv Queue中下一个消息的大小
peeksize := kcp.PeekSize()
if peeksize < 0 {
return -1
}
if peeksize > len(buffer) {
return -2
}
var fast_recover bool
// 如果接收队列长度大于等于当前的接收窗口大小
// 则进入快速恢复模式
if len(kcp.rcv_queue) >= int(kcp.rcv_wnd) {
fast_recover = true
}
// 在接收队列里把发送时大于mss而进行分片的包组合起来
count := 0
for k := range kcp.rcv_queue {
seg := &kcp.rcv_queue[k]
copy(buffer, seg.data)
buffer = buffer[len(seg.data):]
n += len(seg.data)
count++
kcp.delSegment(seg)
if seg.frg == 0 {
break
}
}
if count > 0 {
kcp.rcv_queue = kcp.remove_front(kcp.rcv_queue, count)
}
// 从接收缓存中 拿出数据然后塞进接收队列里
count = 0
for k := range kcp.rcv_buf {
seg := &kcp.rcv_buf[k]
if seg.sn == kcp.rcv_nxt && len(kcp.rcv_queue)+count < int(kcp.rcv_wnd) {
kcp.rcv_nxt++
count++
} else {
break
}
}
if count > 0 {
kcp.rcv_queue = append(kcp.rcv_queue, kcp.rcv_buf[:count]...)
kcp.rcv_buf = kcp.remove_front(kcp.rcv_buf, count)
}
// 队列长度小于窗口且处于快速恢复阶段时
// 告知对方自身接受窗口大小
if len(kcp.rcv_queue) < int(kcp.rcv_wnd) && fast_recover {
kcp.probe |= IKCP_ASK_TELL
}
return
}
很简单。。没啥可说的。。就是从接收队列里拿出数据然后再从接收缓存里拿出来塞进接收队列里
func (kcp *KCP) Input(data []byte, regular, ackNoDelay bool) int {
// 获取上一个发送的una
snd_una := kcp.snd_una
// 获取到的数据长度不能小于24
if len(data) < IKCP_OVERHEAD {
return -1
}
var latest uint32
var flag int
var inSegs uint64
var windowSlides bool
for {
var ts, sn, length, una, conv uint32
var wnd uint16
var cmd, frg uint8
if len(data) < int(IKCP_OVERHEAD) {
break
}
// 22行到33行就是解析收到数据的kcp协议头
data = ikcp_decode32u(data, &conv)
if conv != kcp.conv {
return -1
}
data = ikcp_decode8u(data, &cmd)
data = ikcp_decode8u(data, &frg)
data = ikcp_decode16u(data, &wnd)
data = ikcp_decode32u(data, &ts)
data = ikcp_decode32u(data, &sn)
data = ikcp_decode32u(data, &una)
data = ikcp_decode32u(data, &length)
if len(data) < int(length) {
return -2
}
if cmd != IKCP_CMD_PUSH && cmd != IKCP_CMD_ACK &&
cmd != IKCP_CMD_WASK && cmd != IKCP_CMD_WINS {
return -3
}
// regular 表示它是来自远程的真实数据包
// 这意味着它不是由ReedSolomon编解码器生成的。
// 仅信任来自常规数据包的窗口更新。即最新更新
if regular {
kcp.rmt_wnd = uint32(wnd)
}
// 如果解析到的una大于0
// 那么后续就会执行窗口滑动
if kcp.parse_una(una) > 0 {
windowSlides = true
}
// 收缩
kcp.shrink_buf()
// 根据cmd的类型执行不同的操作
if cmd == IKCP_CMD_ACK {
kcp.parse_ack(sn)
kcp.parse_fastack(sn, ts)
flag |= 1
latest = ts
} else if cmd == IKCP_CMD_PUSH {
repeat := true
if _itimediff(sn, kcp.rcv_nxt+kcp.rcv_wnd) < 0 {
kcp.ack_push(sn, ts)
if _itimediff(sn, kcp.rcv_nxt) >= 0 {
var seg segment
seg.conv = conv
seg.cmd = cmd
seg.frg = frg
seg.wnd = wnd
seg.ts = ts
seg.sn = sn
seg.una = una
seg.data = data[:length]
repeat = kcp.parse_data(seg)
}
}
if regular && repeat {
atomic.AddUint64(&DefaultSnmp.RepeatSegs, 1)
}
} else if cmd == IKCP_CMD_WASK {
// ready to send back IKCP_CMD_WINS in Ikcp_flush
// tell remote my window size
kcp.probe |= IKCP_ASK_TELL
} else if cmd == IKCP_CMD_WINS {
// do nothing
} else {
return -3
}
inSegs++
data = data[length:]
}
atomic.AddUint64(&DefaultSnmp.InSegs, inSegs)
// update rtt with the latest ts
// ignore the FEC packet
if flag != 0 && regular {
current := currentMs()
if _itimediff(current, latest) >= 0 {
kcp.update_ack(_itimediff(current, latest))
}
}
// cwnd update when packet arrived
if kcp.nocwnd == 0 {
if _itimediff(kcp.snd_una, snd_una) > 0 {
if kcp.cwnd < kcp.rmt_wnd {
mss := kcp.mss
if kcp.cwnd < kcp.ssthresh {
kcp.cwnd++
kcp.incr += mss
} else {
if kcp.incr < mss {
kcp.incr = mss
}
kcp.incr += (mss*mss)/kcp.incr + (mss / 16)
if (kcp.cwnd+1)*mss <= kcp.incr {
if mss > 0 {
kcp.cwnd = (kcp.incr + mss - 1) / mss
} else {
kcp.cwnd = kcp.incr + mss - 1
}
}
}
if kcp.cwnd > kcp.rmt_wnd {
kcp.cwnd = kcp.rmt_wnd
kcp.incr = kcp.rmt_wnd * mss
}
}
}
}
if windowSlides { // if window has slided, flush
kcp.flush(false)
} else if ackNoDelay && len(kcp.acklist) > 0 { // ack immediately
kcp.flush(true)
}
return 0
}
Input的源代码很多,作者也仅仅是做了一点的注释,总的来说Input的工作过程大概可以解释为如下步骤:
在Input中有个很重要的函数就是func (kcp *KCP) flush(ackOnly bool) uint32
,他负责将处于发送缓存中的数据发出去,这个函数很长,就不在此放代码了。这个函数非常的重要,kcp的重要参数都是在调节这个函数的行为,这个函数只有一个参数ackOnly,意思就是只发送ack,如果ackOnly为true的话,该函数只遍历ack列表,然后发送,就完事了。 如果不是,也会发送真实数据。 在发送数据前先进行windSize探测,如果开启了拥塞控制nc=0,则每次发送前检测服务端的winsize,如果服务端的winsize变小了,自身的winsize也要更着变小,来避免拥塞。如果没有开启拥塞控制,就按设置的winsize进行数据发送1。
接着循环每个段数据,并判断每个段数据的是否该重发,还有什么时候重发:
如果这个段数据首次发送,则直接发送数据。
如果这个段数据的当前时间大于它自身重发的时间,也就是RTO,则重传消息。
如果这个段数据的ack丢失累计超过resent次数,则重传,也就是快速重传机制。这个resent参数由resend
参数决定。
如果这个段数据的ack有丢失且没有新的数据段,则触发ER,ER相关信息ER
最后放一下Kcp的工作流程作为结束。
我已开通自己的公众号【Echo的技术笔记】
日后的文章发布会主要在公众号上发布
希望各位关注一下
谢谢大家啦
https://cloud.tencent.com/developer/article/1165876 ↩︎