0%

15.3 延时确认

累积确认可以允许 TCP 延迟一段时间发送 ACK ,以便将 ACK 和相同方向上需要传的数据结合发送。不过TCP 不能任意时长地延迟 ACK,否则对方会误认为数据丢失而出现不必要的重传。

15.4 Nagle 算法

从前面可知, ssh 连接中,通常单次按键就会引发数据流的传输。这些包很小,但是会造成相当高的网络传输代价,也就是说与其他包相比,有效的应用数据占比较低(IP头部有 20 字节,TCP头部 20 字节,数据部分仅仅 48 字节)。

Nagle 算法要求:当一个 TCP 在传数据时,小的报文段不能被发送,直到所有的在传数据都收到了 ACK,并且,TCP 需要收集这些小数据,将其整合到一个报文段中发送。

15.5 流量控制与窗口管理

前面提到可以采用可变滑动窗口来实现流量控制。

14.1 引言

TCP 拥有2套独立的机制来完成重传:

  • 基于时间: 发送数据后设置计时器,若超时还未收到数据确认,则触发超时重传。

  • 基于确认信息的构成: 也叫做快速重传,通常发生在没有延时的情况下(根据以前的知识就是连续收到 3 个相同的 ACK),重传可能丢失的分组

当失序数据到达时,重复 ACK 应当立即返回,不能延时发送,原因在于 使发送端尽早得知有失序报文,并告诉其空缺在哪

与超时重传相比,快速重传能更有效地修复丢包情况

14.3 设置超时重传

TCP 并非对其接收到的每个报文段都返回 ACK,例如:当传输大批量数据时,TCP 通常采取每2个报文段返回一个 ACK 方法;另外,当数据出现丢失、失序或者重传成功时TCP 的累积确认机制表明报文段与ACK 之间并非严格一一对应的。

在TCP 握手阶段,SYN 、ACK 等数据包并未包含实际数据,由于 TCP 对不包含数据的报文段不提供可靠传输,意味着若出现丢包不会重传,因此无须设定重传计时器

由于接收端在收到失序的数据后会立即返回 ACK,以此来帮助触发快速重传,因此网络中任何一个失序的数据包都会生成重复的ACK。如果我们一旦收到重复 ACK 就立即启动快速重传,那就会导致大量不必要的重传发生,为了解决这一问题,快速重传仅在达到重复阈值之后才会被触发

14.10 重新组包

当TCP 超时重传时,并不需要完全重传相同的报文段。TCP 允许执行重新组包发送一个更大的报文来提高性能。

允许这样做的原因在于,TCP 是通过字节号来识别发送和接收的数据,而非报文段(或包)号。

可以基于重新组包的方式来检测伪超时。

14.11 与 TCP 重传相关的攻击

有一类 DoS 攻击称为 低速率DoS 攻击。这类攻击向网关或主机发送大量数据,使得受害系统持续处于重传超时的状态,由于攻击者可以预知受害TCP何时启动重传,并在每次重传时发送大量数据,因此,受害 TCP 总能感知到拥塞的存在,根据Karn算法不断减小发送速率并退避发送,导致无法正常使用网络宽带

解决方案:随机选择 RTO,使得攻击者无法预知准确的重传时间

13.1 引言

TCP 必须检测并修补所有在 IP 层产生的数据传输问题: 比如丢包、重复 以及 错误。

13.5.2 TIME_WAIT 状态

TIME_WAIT 状态也称为 2MSL 等待状态,该状态下,TCP 将会等待 2 倍于最大段生存期(Maximum Segment Lifetime, MSL的时间,这段时间连接不可用,等待链路上的报文段过期,防止复用这个连接时,将老的连接中的报文当成新连接发送的数据。

13.5.3 静默时间

在本地与外地的 IP 地址、端口号 都相同的情况下,2MSL 状态能防止新的连接将前一个连接的延迟报文解释成自身数据的状况。然而,这种方法只有在与处于2MSL等待状态的连接相关的主机未关闭的情况才有意义。为什么呢?

假如一台与处于 TIME_WAIT 状态下的链接相关联的主机崩溃,然后再 MSL 内重新启动,并且使用与主机崩溃之前处于 TIME_WAIT 状态的连接相同的 IP 地址与端口号,那要怎么处理呢?解决办法就是:在崩溃或者重启后,TCP 应该在创建新的连接之前等待一个 MSL 的时间,这段时间称为 静默时间

13.8 与 TCP 连接管理相关的攻击

SYN 洪泛攻击时一种 TCP 拒绝服务攻击。在这种攻击中一个或者多个恶意的客户端产生一系列 TCP 连接尝试,并将他们发送给一台服务器,他们通常采用“伪造”的源 IP 地址。服务器回味每条链接分配一定数量的连接资源,由于连接尚未完全建立,服务器为了维护大量的半打开连接会在耗尽自身内存后拒绝为后续的合法连接请求服务。

解决办法:只有当 SYN+ACK 报文段本身被确认后,才会分配真正的内存

还有一种攻击与路径最大传输单元发现过程相关。攻击者伪造一个非常小的 MTU 值,迫使受害的 TCP 尝试采用非常小的数据报来填充数据,从而大大降低性能。

12.1 引言

12.1.1 ARQ 和 重传

重传的时候,接收方可能收到被传送分苏的重复副本,这就要求使用序列号来解决:在被远端发送时,每个唯一的分组都有一个新的序列号。

如果想要吞吐量更高,我们就要求在网络中可以同时存在多个分组,这就变得更加复杂:发送方不仅要决定什么时候注入一个分组到网络中,还要考虑注入多少个,并且在等待 ACK 的时候,怎样维持计时器,同时还需要保存每个还没收到 ACK 分组的副本防止重传需要。

12.1.2 分组窗口和滑动窗口

为了解决上述问题,我们假设每个分组有一个序列号,并且定义一个分组窗口作为已经被发送但是还没完成确认的分组集合,我们把窗口中分组的数量称为窗口大小,如下图所示:

发送窗口示意图

上图显示当前窗口有3个分组,整个窗口大小是3。其中,3号分组已经被发送和确认,所以发送方保存的副本可以释放了;分组 7 在发送方已经准备好,但是还没发送,因为还没“进入窗口”。现在假如发送方下一步收到分组 4 的 ACK,此时窗口向右边 “滑动”一个分组,意味着 4 可以释放了,而 7 可以发送了。这种就叫做 “滑动窗口”协议。

一般来说,这个滑动窗口在发送方和接收方都会有,在发送方,它记录着哪些分组可以释放,哪些正在等待 ACK,哪些分组还不能被发送;在接收方,它记录着哪些分组已经被接收和确认,哪些分组时下一步期望的,哪些分组即使接收了也会被丢弃。

12.1.3 变量窗口:流量控制和拥塞控制

为了处理接收方相对发送方太慢的问题,我们在接收方跟不上时会强迫发送方慢下来,这称为流量控制。主要有以下2种方式:

  • 基于速率流量控制:给发送方指定某个速率,去报数据不能超过这个速率发送。多用于流应用程序,可被用于广播和组播发现

  • 基于窗口流量控制:这是使用滑动窗口最流行的方法,思想是:窗口大小不固定,允许随时间变化而变化,必须有一种方法让接收方可以通知到发送方使用多大窗口(即窗口通告)

拥塞控制用于发送方减低速度以不至于压垮发送方与接收方之间的网络。

12.2 TCP的引入

TCP 提供一种字节流抽象概念给应用程序使用,这就意味着没有消息边界。举例解释一下:如果应用程序一一端写入 10 字节,随后又写入 20 字节,再随后写入 50 字节,那么在另一端时不知道每次写入多少字节的;另一端可能以每次读取20字节分4次读取或者一次性读取80字节的方式读取。

TCP根本不会解读字节流中的字节内容,它不知道传输的是二进制数据、ASCII 还是其他东西。

12.2.2 TCP中的可靠性

TCP 必须把应用程序发送的字节流转换为一组 IP可以携带的分组,这被称为组包。这些分组包含序列号,该序列号在TCP 中代表了每个分组的第一个字节在整个数据流中的字节偏移,而不是分组号。TCP传给IP的块称为 报文段

而UDP 就不同了,应用程序每次写入通常就产生一个 UDP 数据,其大小就是写入的那么大(加上头部)。

TCP 发送一组报文段时会设置一个重传计时器,等待对方的确认。不过它不会为每个报文段设置,而是发送一个窗口的数据,它只设置一个计时器,当 ACK 到达时再更新超时。如果有一个确认没有及时接收到,这个报文段就会被重传。

12.3 TCP 头部封装

UDP 的头部一般是 8 字节,TCP 头部通常是 20 个字节(带选项的话可达 60 字节)。每个TCP 的头部都包含了 源和目的IP地址,以及源和目的 端口号。

在 TCP 术语中,一个 IP 地址和一个端口的组合被称为 套接字(Socket) 或者 端点(endpoint)

TCP 报文段的数据部分是可选的,基于后面的知识我们可以看到:当一个连接被建立和终止时(我理解的是 握手和挥手),交换的报文段只包含 TCP 头部而没有数据。

12.4 总结

处理传输过程的差错主要有2种方法:

  • 差错校正码:添加一些冗余比特,使得即使某些比特被毁,真实的信息也能被恢复过来

  • 重传,直至信息被正确接收

10.2 UDP 头部

在UDP 中,源端口号是可选的,如果数据报的发送者不要求对方回复的话,它可以被置为 0。

TCP 的端口号只能被 TCP 使用,UDP 端口号只能被 UDP 使用,以此类推。这样导致的一个结果就是:2个完全不同的服务器可以使用相同的端口号和 IP 地址,只要它们属于不同的传输协议。

UDP 头部中的 Length 字段表示的是长度,这个长度包括 UDP 头部 和 UDP 数据 的总长度;需要注意的是,发送一个带 0 字节数据的 UDP 数据报是允许的,尽管很少见。

10.3 UDP 校验和

UDP 在 IPv4 头部中的校验和只覆盖整个头部(并不覆盖IP分组中的任何数据),头部中的校验和是可选的。不过,在 IPv6 中,UDP 的校验和的计算与使用时强制的,因为在 IP 层没有头部校验和。

10.7 IP 分片

链路层通常对可传输的每个帧的最大长度有一个上限, IP 比较外出接口的 MTU 和数据大小,如果数据报太大则进行分片。当一个 IP 数据报被分片了,直到它到大最终目的地才会被重组,这是为什么呢?为什么不在中途重组呢?有 2 个原因,第 2 个原因比第 1 个原因更重要:

  1. 在网络中不进行重组要比重组更能减轻路由转发软件(或硬件)的负担

  2. 同一数据报的不同分片可能经由不同的路径到达相同的目的地。也就是说路径上的路由通常没有能力来重组原始数据报(因为手头只有所有分片的一个子集)

总结

UDP 不提供差错纠正、队列管理、重复消除、流量控制和拥塞控制,它提供了差错检测,包含校验和。当要避免连接的开销、使用多端点(组播/广播)传送时,或者不需要 TCP 相对“笨重”的可靠语义时,最常用的就是 UDP,主要用途就是支持 DNS ,多媒体上也得到广泛使用。

为了学习官方协程,我们先实现一些轻量级的复合协程,只是用于学习,不适合生产环境。

5.1 开胃菜:实现一个 delay 函数

使用线程的时候,如果希望代码延迟一段时间再执行,通常会调用 Thread.sleep 函数,这会令当前线程阻塞。在协程中也可以这样,不过协程可以挂起还去阻塞线程,就很浪费资源,我们的目的是后面的代码延迟一段时间执行,因此可以确定以下2点:

  • 不要阻塞线程

  • 是个挂起函数,指定时间之后能够恢复执行即可

从上面 2 点我们可以给出 delay 函数的声明:

1
2
3
4
suspend fun delay(time: Long, unit: TimeUnit = TimeUnit.MILLISECONDS) {
if (time <= 0) return
。。。
}

接下来要考虑挂起,自然就想到了 suspendCoroutin

1
2
3
4
5
6
7
suspend fun delay(time: Long, unit: TimeUnit = TimeUnit.MILLISECONDS) {
。。。
suspendCoroutine<Unit> {continuation: Continuation<Unit> ->

}
。。。
}

只需要做到在指定的 time 之后执行 continuation.resume() 就行,因此,我们只需要提供这样一个定时回调机制就可以!,定时任务很容易想到 ScheduledExecutorService,因此代码可以这样写:

1
2
3
4
5
6
7
private val executor = Executors.newScheduledThreadPool(1, object : ThreadFactory{
override fun newThread(r: Runnable): Thread {
return Thread(r, "Scheduler").apply {
isDaemon = true
}
}
})

这里为什么要设置 isDaemon ,放到后面说。接着我们可以实现功能了:

1
2
3
4
5
6
7
8
9
suspend fun delay(time: Long, unit: TimeUnit = TimeUnit.MILLISECONDS) {
suspendCoroutine<Unit> {continuation: Continuation<Unit> ->
executor.schedule(object: Runnable{
override fun run() {
continuation.resume(Unit)
}
}, time, unit)
}
}

5.1.1 为什么这样用 ScheduledExecutorService(自己加的标题)

了解 ScheduledExecutorService 工作机制的朋友还会有疑惑:Scheduled-ExecutorService 在等待延时的阶段还是会阻塞,这不也浪费资源吗?这里说明下2个原因:

  • 如果当前线程有特殊地位,例如 UI线程 等,那么它们是不能被阻塞的,因此我们将阻塞动作放到后台线程上是有意义的

  • 后台一个线程可以承载非常多的延时任务,例如:有 10 个协程调用 delay ,那么只需要阻塞一个 后台线程即可实现这 10 个协程的延时执行 !参考下图:

多个delay协程公用一个后台线程

5.2 协程的描述

Java 平台上 Thread 的定义很直观,让人很容易识别 Java 线程;而协程一开始只是在标准库中放了协程基础设施,导致难以上手和分辨。这里我们尝试给一个类来描述协程,按照官方的做法把它命名为 Job ,如下代码清单:

1
2
3
4
5
6
7
8
9
10
11
interface Job : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<Job>

override val key: CoroutineContext.Key<*> get() = Job
val isActive: Boolean
fun invokeOnCancel(onCancel: OnCancel): Disposable
fun invokeOnCompletion(onComplete: OnComplete): Disposable
fun cancel()
fun remove(disposable: Disposable)
suspend fun join()
}

与 Thread 相比, Job 同样有 join ,调用时会挂起( 而线程的 join 则会阻塞线程 ),直到协程完成;cancel() 类比与 Thread 的 interrupt() ,用于取消协程; isAlive() 类比 Thread 的 isAlive() ,用于查询协程是仍在执行。

invokeOnCancel 用于协程取消时的回调;invokeOnCompletion 可以注册协程完成的回调。remove 用于移除回调。 key 将协程 Job 存入上下文,这样就很容易拿到 Job 实例。

5.2.2 协程的状态

我们对协程进行封装,目的就是让它状态更容易管理。对于协程来讲,启动之后主要就是 未完成、已取消、已完成这几种状态,接下来定义一下状态:

1
2
3
4
5
6
7
8
sealed class CoroutineState {
//未完成:协程启动后立即进入该状态,直到完成获取取消
class Imcomplete : CoroutineState()
//已取消:协程被取消后立即进入该状态
class Cancelling : CoroutineState()
//已完成:协程执行完后(不管成功失败)进入该状态
class Complete<T>(val value: T? = null, val exception: Throwable? = null) : CoroutineState()
}

——————-中间略过一大截,看不懂—————————–

5.4 协程的执行调度

协程在哪里挂起、什么时候恢复都是开发者自己决定的,意味着不像线程那样把调度工作交给操作系统,而是在用户态解决,所以协程也经常被称为用户态线程

5.4.2 协程的调度位置

当协程执行到挂起点为止时,如果产生异步行为,协程就会在这个挂起点挂起,这里的一部情形包括以下形式:

  • 挂起点对应的挂起函数内部切换了线程,并在线程内部调用 Continuation 的恢复调用来恢复。

  • 挂起函数内部通过某种事件循环机制将 Continuation 的恢复调用转到新的线程调用栈上执行。如:Android平台通过 Handler 的 post 操作,实际上这个过程不一定发生线程切换

  • 挂起函数内部将 Continuation 保存,在后续某个时机再执行恢复调用,这个过程也不一定发生线程切换,但是函数调用栈会发生变化。

综上所述,不管何种形式,恢复和挂起不再同一个函数调用栈中执行就是挂起点挂起的充分条件!只有当挂起点真正挂起,我们才有机会实现调度,而实现调度需要使用协程拦截器

5.4.3 协程的调度器设计

官方协程框架的默认调度器就是基于线程池实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
object DefaultDispatcher : Dispatcher {
private val threadGroup = ThreadGroup("DefaultDispatcher")
private val threadIndex = AtomicInteger(0)
private val executor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() + 1
) { runnable ->
Thread(
threadGroup,
runnable,
"${threadGroup.name}-worker-${threadIndex.getAndIncrement()}"
).apply { isDaemon = true }
}
override fun dispatch(block: () -> Unit) {
executor.submit(block)
}
}

调用dispatch 方法的时候,实际上是将 block 扔给 executor 放到线程中执行。

其实也可以实现成基于拦截器的方式:

1
2
3
override fun resumeWith(result: Result<T>) {
dispatcher.dispatch { delegate.resumeWith(result) }
}

基于UI事件循环的调度器就是通过在disptch() 方法中用 Handler 的 post 实现:

1
2
3
4
5
6
object AndroidDispatcher : Dispatcher {
private val handler = Handler(Looper.getMainLooper())
override fun dispatch(block: () -> Unit) {
handler.post(block)
}
}

——————-后续的又看懵逼了—————————–

前面的介绍了解了协程可以挂起和恢复,但是对于协程如何使用仍然倍感疑惑,所以还需要机遇简单协程构建足够有好的上层 API ,即复合协程。

4.1 序列生成器

看得云里雾里,先略过

背景

MP4 格式的课程视频从打开播放器到第一帧画面显式,加载时间过长

时间埋点

博客作者使用的是 ijkPlayer ,我们项目中使用的是 ExoPlayer ,不过这个关系不太大。给播放器加载过程埋点的时候需要关闭播放器自己打印的日志,否则会打印很多无关的内容。在本地视频模式和在线视频模式下分别打印关键方法的耗时。

从作者分析的数据可以看到,在线模式下,首帧耗时为 4.967s ,在线模式下首帧耗时 0.257s ,然后发现主要耗时是 avformat_open_input 方法(Exoplayer中方法可能是另一个)

avformat_open_input 方法

这个函数的主要作用是打开一个输入流并且读取它的头部信息

分析视频结构

请求视频头部信息,在 MP4 格式下即为请求 moov 数据,可以使用 Media Parser 解析问题视频如下:

问题视频数据可视化

从图可以看出, moov 在视频文件尾部。本地播放时,可以通过指针快速定位到 moov ;在线播放时,需要先加载一部分头部数据,如 fytp、free、madat 等,计算出 moov 的偏移量,再通过 Http Range Bytes 请求 moov 数据。

moov 数据约有 2.2M ,在线播放视频时,avformat_open_input 方法需要获取全部的 moov 数据,建立索引表后再解析音视频数据。而本地播放无需这个网络请求。

结论及解决方案

视频头过大导致的首帧过慢

问题:视频头过大,导致下载时间比较长,然后首帧出来就比较迟了

解决方案:避免加载提及过大的视频头:1)将长视频拆分为多个短视频,减少 moov 的长度 2)使用轻量级格式,如分段FLV(爱奇艺、优酷)、DASH (YoTuBe、B站)等方案

结构特性导致首帧过慢

问题: moov 在视频文件尾部,多了一次 seek 请求操作,这个问题比上述的问题要轻

解决方案:在服务端使用 ffmpeg 命令将 moov 文件移到 ftyp 后面

加载过程

问题: 播放的时候,还要同时先去下载视频头,导致了耗时较长

解决方案:预加载,在适当时机提前加载视频头部数据,写入本地文件,播放器从本地读取数据,快速构建索引表,进入首帧解码

以上内容参考简书上的博客

背景

MP4 格式的课程视频从打开播放器到第一帧画面显式,加载时间过长

时间埋点

博客作者使用的是 ijkPlayer ,我们项目中使用的是 ExoPlayer ,不过这个关系不太大。给播放器加载过程埋点的时候需要关闭播放器自己打印的日志,否则会打印很多无关的内容。在本地视频模式和在线视频模式下分别打印关键方法的耗时。

从作者分析的数据可以看到,在线模式下,首帧耗时为 4.967s ,在线模式下首帧耗时 0.257s ,然后发现主要耗时是 avformat_open_input 方法(Exoplayer中方法可能是另一个)

avformat_open_input 方法

这个函数的主要作用是打开一个输入流并且读取它的头部信息

分析视频结构

请求视频头部信息,在 MP4 格式下即为请求 moov 数据,可以使用 Media Parser 解析问题视频如下:

问题视频数据可视化

从图可以看出, moov 在视频文件尾部。本地播放时,可以通过指针快速定位到 moov ;在线播放时,需要先加载一部分头部数据,如 fytp、free、madat 等,计算出 moov 的偏移量,再通过 Http Range Bytes 请求 moov 数据。

moov 数据约有 2.2M ,在线播放视频时,avformat_open_input 方法需要获取全部的 moov 数据,建立索引表后再解析音视频数据。而本地播放无需这个网络请求。

结论及解决方案

视频头过大导致的首帧过慢

问题:视频头过大,导致下载时间比较长,然后首帧出来就比较迟了

解决方案:避免加载提及过大的视频头:1)将长视频拆分为多个短视频,减少 moov 的长度 2)使用轻量级格式,如分段FLV(爱奇艺、优酷)、DASH (YoTuBe、B站)等方案

结构特性导致首帧过慢

问题: moov 在视频文件尾部,多了一次 seek 请求操作,这个问题比上述的问题要轻

解决方案:在服务端使用 ffmpeg 命令将 moov 文件移到 ftyp 后面

加载过程

问题: 播放的时候,还要同时先去下载视频头,导致了耗时较长

解决方案:预加载,在适当时机提前加载视频头部数据,写入本地文件,播放器从本地读取数据,快速构建索引表,进入首帧解码

以上内容参考简书上的博客