Golang mem model 与内存屏障

前几天有小伙伴读 go101 Memory Order Guarantee 问了我这个问题: 红框中的两句话是不是矛盾的?

我们可以看看官方关于 Go Memory Model 中对应的描述:

  • A send on a channel happens before the corresponding receive from that channel completes.
  • The closing of a channel happens before a receive that returns a zero value because the channel is closed.
  • A receive from an unbuffered channel happens before the send on that channel completes.
  • The kth receive on a channel with capacity C happens before the k+Cth send from that channel completes.

除去关闭 channel 的情况后,我们可以结合官方的解释将上面 go101 描述的三种情况分为下面两个时序图:

  1. unbuffered:
  2. buffered

为什么要这么做?

Many compilers (at compile time) and CPU processors (at run time) often make some optimizations by adjusting the instruction orders, so that the instruction execution orders may differ from the orders presented in code. Instruction ordering is also often called memory ordering.

我们先来看看 go101 中提到的这段 ”unprofessional“ 代码

package main

import "log"
import "runtime"

var a string
var done bool

func setup() {
    a = "hello, world"
    done = true
    if done {
        log.Println(len(a)) // always 12 once printed
    }
}

func main() {
    go setup()

    for !done {
        runtime.Gosched()
    }
    log.Println(a) // expected to print: hello, world
}

上面这段代码中有两种可能引起乱序的原因

  1. setup() 可能被编译器编译或者CPU执行为

    func setup() {
        done = true // done assgined at first
        a = "hello, world"
        if done {
            log.Println(len(a)) // always 12
        }
    }
  2. 即使指令没有被重新排序,CPU 有 Cache 一致性协议 MESI 的情况下,执行仍然可能乱序。(详见这里

内存屏障

硬件大佬给出了能同时解决上面两个乱序问题的方式:内存屏障(fence/barrier)

x86下,内存屏障在内核中的定义:这里可以看到32位和64位使用的指令是不同的

/*
 * Force strict CPU ordering.
 * And yes, this is required on UP too when we're talking
 * to devices.
 */
#ifdef CONFIG_X86_32
/*
 * Some non-Intel clones support out of order store. wmb() ceases to be a
 * nop for these.
 */
#define mb() alternative("lock; addl $0,0(%%esp)", "mfence", X86_FEATURE_XMM2)
#define rmb() alternative("lock; addl $0,0(%%esp)", "lfence", X86_FEATURE_XMM2)
#define wmb() alternative("lock; addl $0,0(%%esp)", "sfence", X86_FEATURE_XMM)
#else
#define mb()     asm volatile("mfence":::"memory")
#define rmb()    asm volatile("lfence":::"memory")
#define wmb()    asm volatile("sfence" ::: "memory")
#endif

内存屏障如何解决上述问题

1. 指令重排

简单来说,屏障的作用正如其名:防止屏障前后的指令重排。下表是几种屏障的具体作用,具体细节可以参考 Linux Kernel Development 3rd Edition - Chapter 10 - Ordering and Barriers

Memory and Complier Barrier Methods

BarrierDescription
rmb()Prevents loads from being reordered across the barrier
read_barrier_depends()Prevents data-depends() loads from being reordered across the barrier
wmb()Prevents stores from being reordered across the barrier
mb()Prevents stores and loads being reordered across the barrier

2. MESI乱序

这篇文章中提到了屏障保证MESI不乱序的具体生效方式,这里为了容易理解画了张写屏障简单的时序图:

全文最重要的结论点来了:内存屏障解决了程序并发时的两个问题:1.阻止指令重排 2.MESI顺序保证

和 golang 有什么关系

其实我们可以看到,golang channel 中用到的 lock() 方法,其实调用的是 plan9 中的 lock 指令,具体翻译为 x86 指令也是一个 lock。lock 其实隐含了 屏障功能,lock 指令执行之前,会将未完成读写操作完成。

// runtime/chan.go

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
    if c == nil {
        if !block {
            return false
        }
        gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
        throw("unreachable")
    }

...........

    lock(&c.lock) // <---------------------------- here

    if c.closed != 0 {
        unlock(&c.lock)
        panic(plainError("send on closed channel"))
    }

    if sg := c.recvq.dequeue(); sg != nil {
        // Found a waiting receiver. We pass the value we want to send
        // directly to the receiver, bypassing the channel buffer (if any).
        send(c, sg, ep, func() { unlock(&c.lock) }, 3)
        return true
    }

.................    

}
//runtime internal atomic

TEXT runtime∕internal∕atomic·Cas64(SB), NOSPLIT, $0-25
    MOVQ    ptr+0(FP), BX
    MOVQ    old+8(FP), AX
    MOVQ    new+16(FP), CX
    LOCK
    CMPXCHGQ    CX, 0(BX)
    SETEQ    ret+24(FP)
    RET

TEXT runtime∕internal∕atomic·Casuintptr(SB), NOSPLIT, $0-25
    JMP    runtime∕internal∕atomic·Cas64(SB)

回头来看 golang 的设计

The design philosophy of Go is to use as fewer features as possible to support as more use cases as possible, at the same time to ensure a good enough overall code execution efficiency. So Go built-in and standard packages don't provide direct ways to use the CPU fence instructions. In fact, CPU fence instructions are used in implementing all kinds of synchronization techniques supported in Go. So, we should use these synchronization techniques to ensure expected code execution orders.

golang 并没有像 C++ 一样给出 LoadLoad、StoreLoad 或者是 直接调用汇编指令 之类的非常底层同步元语,而是封装了 channel 、atomic 等更高抽象程度的同步方法,来减轻程序员的负担。但是从原理上理解并发编程模型,能让我们写出健壮的代码。写到这里想到 golang 官方在内存模型这一章里写的一句话:

If you must read the rest of this document to understand the behavior of your program, you are being too clever.

Don't be clever.

推荐阅读

Memory Order Guarantees in Go

浅谈Memory Reordering

linux内核中的内存屏障_qb_2008的专栏-CSDN博客

Linux内核中的内存屏障(1)

谢宝友:深入理解 Linux RCU 从硬件说起之内存屏障

10 张图打开 CPU 缓存一致性的大门

Go 和 CPU 高速缓存:原理和应用

如何使用金字塔结构来写一篇技术文档

技术人员都知道一篇好的技术文档的价值(这里换个表述),但是如何去写好一篇技术文档却一筹莫展。然而,在咨询业的传奇公司麦肯锡中,有一种叫做 ‘金字塔原理' 的写作方式可以快速的组织一篇结构化的文章。那么我们是否可以用 '金字塔原理' 来写一篇技术文档呢?

我们写技术文档的目的

对于软件来说,最重要的两点莫过于速度和质量了。写技术文档到底能不能提高软件研发速度不好衡量,但绝对可以提升软件研发过程的质量。软件的质量反映的是人类程序员的思考深度,而所有人类程序员都有如下两点先天限制: 缓存不足、上下文不同。

人脑的灵活性让我们有很高的创造力,但是在处理大型问题的时候却很难记住所有细节逻辑。所以将发散的思路梳理好并且记录下来这个过程,不仅可以帮助我们做好系统性的设计,还可以在设计阶段排除很多不必要的风险。简单来说就是: “高配 CPU 带小内存,你需要额外的空间来干活” 。

多人协作中,信息的传达效率以及信噪比是非常重要的。举个例子:我们知道在软件研发领域,英语是事实上的标准编程语言,但是在不同的业务场景中 "prod" 这几个字母的意思可能是 ”product“ 、”produce“ 、“producer” 这几个计算机领域常见词之一。文档的另一个核心作用就是: 显式固化只有开发人员才知道的隐性知识无论是缩写还是简写只要团队约定好字符串与实体的映射,那么整体沟通成本将明显下降。关于这一点,埃里克在 领域驱动设计) 这本书中有着精彩的论述,这里就不赘述了。

总的来说,写技术文档可以帮助我们作出更好的设计、显式固化隐性知识,从而提升软件研发的质量。

金字塔原理

我们用一些篇幅说明了文档和软件质量之间的联系,下面我们就来介绍一下金字塔原理。金字塔原理是一种突出重点、逻辑清晰、层次分明的思维方式,也是一种有效的分析问题、得出解决方案的有效工具。

01

作者在书中提供了一种金字塔式的文章组织方式,用来阐述有逻辑关联的事务,而软件正是不同事务变化时逻辑固化在硬盘上的表现,所以我认为用金字塔原理这种方式来写技术文档是再好不过的选择。

关于对金字塔原理的理解,可以选择看完《金字塔原理》的前三章,或者是通过我摘抄的作者对金字塔原理的解释来做初步了解:

  • 对受众(包括读者、听众、观众或学员)来说,最容易理解的顺序是: 先了解主要的、抽象的思想,然后了解次要的、为主要思想提供支持的思想。因为主要思想总是从次要思想概括总结得出,文章中所有思想的理想组织结构也必定是一个金字塔结构一一由一个总的思想统领多组思想。在这种金字塔结构中,思想之间的联系方式可以是纵向的( vertically)ー即任何一个层次上的思想都是对其下面一个层次上思想的总结;也可以是横向的( horizontally)-即多个思想因共同组成同一个逻辑推理过程,而被并列排在一起。
  • 受众的大脑只能逐句理解作者(演讲者、培训讲师)表达的思想。他们会假定一同出现的思想在逻辑上存在某种关系。如果你不预先告诉他们这种逻辑关系,而只是一句一句地表达你的思想,读者就会自动从中寻找共同点, 将你所表达的思想归类组合,以便了解各个组合的意义。

金字塔原理应用到技术文档写作的流程

02

时时刻刻,必须要提醒你自己:读这篇文档的人类是个缓存很小单线程图灵机,爆栈你就破功了,老弟/妹!

前面讲了很多理论性的东西,那么我们如何将理论与实践结合起来呢?下面分享一下我个人写技术文档的结构:

  1. 核心目标:金字塔的尖顶

    这里是金字塔最耀眼的尖顶,只写最核心的目标,比如说:调研某种技术方案、依照某个 PRD 开发产品部分功能。具体行文思路可以参照 《金字塔原理》 第4章— 序言的写法

  2. 方案概要设计: 金字塔的腰部

    这里是整体方案的蓝图,概要的抽象程度一定要很高,在这里必须可以看到全部架构。文字表述不清楚的地方可以多画图来辅助表达:

    1. 建议使用流程图用来表达状态流转
    2. 建议使用时序图用来表达交互顺序
    3. 建议使用C4 用来画系统架构
  3. 详细技术设计:金字塔坚实的基础

    详细设计部分是对上面概要设计的填充。这里是发现问题的黄金时间,一定要做很细致,需要有数据结构和关键逻辑的伪代码。细节设计做好之后,代码也写完三分之一了,剩下的就是写代码、写测试了。

  4. 维护:金字塔墙面砌砖与补漆抛光工艺

    根据我们上面对写文档目的的论述,文档不是一次性设计消耗,我们需要在开发有调整的时候,即时回来调整文档。举个不恰当的例子:汽车建造过程中刹车和油门换了位置,说明书总是要更新的吧。还有需要注意的一点就是: 当前文档只对当前版本负责,新版本不要在旧的文档上直接修改。

    03

这篇文章用了哪些金字塔原理中的方法

  1. 序言 :这里使用了 《第四章: 序言的常见模式》 中,背景-冲突-疑问 的三段模式,来隐晦的告诉读者可以用金字塔原理来写技术文档
  2. 金字塔原理应用的内容部分是用 顺序推理的方式来演示如何写技术文档

04

  1. 这篇文章的整体结构是归纳的方式

05

结语

金字塔原理是一套非常高效的结构化思考、表达工具。除了用来写文档,作者在书中也列举了很多其他应用场景,比如说:如何将 金字塔原理应用在做 PPT 上,如何用金字塔原理来分析问题等等。推荐各位仔细品读。

也来谈谈软件复杂度

最近一直在做框架开发,很多软件设计理论也有了尝试的机会,渐渐的也有些自己的思考。我一直在想一个问题:之前我所学习到的软件设计方式,几乎都是最佳实践类型的指引:你该如何去做。比如:SOLID 原则 告诉了我们遵从这 5 个原则就可以设计出优秀的软件。

那么什么是差的软件呢?是主观上的 bad smell 吗?读完《A Philosophy of software design》之后,我觉的软件的好坏可以由两个维度的 复杂度(complexity) 来描述:

  • 机器维度:空间和时间复杂度,时间和空间复杂度代表了执行同样任务的资源消耗
  • 人类维度:软件复杂度,人去理解、维护软件需要消耗的资源(包括时间,沟通,试错等各种成本)

对于软件复杂度 John Ousterhout 给的定义是:Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system. 学习过程中发现其他几篇博客也对软件复杂度有很精彩的观点:

在衡量软件复杂度的方面,Ousterhout 教授的定义方式是基于时间:

C=\sum_{p}{c_pt_p}

The overall complexity of a system (C) is determined by the complexity of each part p (cp) weighted by the fraction of time developers spend working on that part (tp).

这篇中还介绍了两种基于代码的衡量方式:cyclomatic complexiteyNPath, 我认为这两种在工程中有实践的意义。

复杂度是软件的固有属性之一,只有理解了什么是复杂度和衡量标准才能更好的处理软件中的复杂度。具体如何处理软件复杂度,Ousterhout 教授在他的书中有很多精彩的案例,强烈推荐阅读。

golang 疑问1:defer 调用之后到底发生了什么

先来看一个个函数,并猜猜它们的返回值

func f1() (result int) {
    defer func() {
        result *= 7
    }()
    return 6
}

尝试打印这个函数的返回值看看

fmt.Println(f1())
//stdout:42 

Why ‘42’ ???


‘42’ 的前世今生

首先,我们先用最简单的方式:单步调试看一下返回值的变化情况。

3f0076f6334f2d77907291a3c4dfa4d9.png
figure-1

55d020405d2e5102f15222240404124a.png
figure-2

ebbfa320125006bab01e2c7cbf0fe2e4.png
figure-3

69b503f7f002a832d18ac4ca51d55fc6.png
figure-4

通过上面的简单截图,我们可以看到:

  1. 返回值result初始值为 0
  2. 程序先在 figure-2 的时候,通过语句 return 6 将返回值修改为 6
  3. 然后在 figure-3的时候开始执行 defer 闭包
  4. 在 defer 执行完之后,返回值被修改成了42

通过上面简单分析,我们可以看到是 defer 最终修改了返回值,但是要想知道修改的机制,就要看下汇编了。

汇编分析

首先通过命令 GOOS=linux GOARCH=amd64 go tool compile -l -S main.go 获得如下汇编代码(注释是我自己加的):

"".f1 STEXT size=124 args=0x8 locals=0x20
    0x0000 00000 (main.go:7)    TEXT    "".f1(SB), ABIInternal, $32-8 // 生命函数即将分配的 栈帧32字节 参数8字节
    0x0000 00000 (main.go:7)    MOVQ    (TLS), CX // 这三行是检测栈空间是否足够,不够的话会跳转到runtime.morestack_noctxt(SB) 申请更多空间
    0x0009 00009 (main.go:7)    CMPQ    SP, 16(CX)
    0x000d 00013 (main.go:7)    JLS    117
    0x000f 00015 (main.go:7)    SUBQ    $32, SP // 申请32字节栈空间
    0x0013 00019 (main.go:7)    MOVQ    BP, 24(SP) // 保存 caller BP ,供函数执行完成之后恢复 caller 使用
    0x0018 00024 (main.go:7)    LEAQ    24(SP), BP // 将f1() 的栈低地址传入BP寄存器
    0x001d 00029 (main.go:7)    MOVQ    $0, "".result+40(SP) //赋初始值0给 返回参数 result
    0x0026 00038 (main.go:8)    MOVL    $8, (SP) // 将 deferproc 第一个参数压栈,runtime.panic.go:92 deferproc(siz int32, fn *funcval)
    0x002d 00045 (main.go:8)    LEAQ    "".f1.func1·f(SB), AX //保存匿名函数 AX
    0x0034 00052 (main.go:8)    MOVQ    AX, 8(SP) // 压栈
    0x0039 00057 (main.go:8)    LEAQ    "".result+40(SP), AX // 保存返回值地址 AX
    0x003e 00062 (main.go:8)    MOVQ    AX, 16(SP) //压栈, 和下方的deferproc 没太多关系,deferproc 只接受2个参数
    0x0043 00067 (main.go:8)    CALL    runtime.deferproc(SB) //调用 deferproc
    0x0048 00072 (main.go:8)    TESTL    AX, AX
    0x004a 00074 (main.go:8)    JNE    101
    0x004c 00076 (main.go:11)    MOVQ    $6, "".result+40(SP) //将数字6传入 返回参数 result
    0x0055 00085 (main.go:11)    XCHGL    AX, AX // 据说是只是为了填充指令 nop
    0x0056 00086 (main.go:11)    CALL    runtime.deferreturn(SB) //执行匿名函数, 在 runtime.panic.go:351 中,getcallersp() 获取了当前函数栈,
    0x005b 00091 (main.go:11)    MOVQ    24(SP), BP //恢复BP寄存器
    0x0060 00096 (main.go:11)    ADDQ    $32, SP // 释放32字节栈空间
    0x0064 00100 (main.go:11)    RET
    0x0065 00101 (main.go:8)    XCHGL    AX, AX
    0x0066 00102 (main.go:8)    CALL    runtime.deferreturn(SB)
    0x006b 00107 (main.go:8)    MOVQ    24(SP), BP
    0x0070 00112 (main.go:8)    ADDQ    $32, SP
    0x0074 00116 (main.go:8)    RET
    0x0075 00117 (main.go:8)    NOP
    0x0075 00117 (main.go:7)    CALL    runtime.morestack_noctxt(SB)
    0x007a 00122 (main.go:7)    JMP    0

"".f1.func1 STEXT nosplit size=20 args=0x8 locals=0x0 // 这里的栈结构要看 runtime.panic.go:345 deferreturn(arg0 uintptr)
    0x0000 00000 (main.go:8)    TEXT    "".f1.func1(SB), NOSPLIT|ABIInternal, $0-8
    0x0000 00000 (main.go:9)    MOVQ    "".&result+8(SP), AX // result 指针放入AX
    0x0005 00005 (main.go:9)    MOVQ    (AX), CX // AX指向的值放入 CX
    0x0008 00008 (main.go:9)    LEAQ    (CX)(CX*2), DX //DX = 2*CX + CX  -> 2*result + result = 3*result
    0x000c 00012 (main.go:9)    LEAQ    (CX)(DX*2), CX //CX = 2*DX + CX  -> 2*(3*result) + result = 7*result
    0x0010 00016 (main.go:9)    MOVQ    CX, (AX) // 将AX指向的值更新为 7*result
    0x0013 00019 (main.go:10)    RET

--未完待续--

参考连接

https://github.com/go-internals-cn/go-internals/blob/master/chapter1_assembly_primer/README.md
http://xargin.com/plan9-assembly/
https://github.com/golang/go/files/447163/GoFunctionsInAssembly.pdf
https://github.com/chai2010/advanced-go-programming-book/tree/master/ch3-asm
http://mcll.top/2018/04/15/golang%E4%B8%AD%E7%9A%84defer%E5%AE%9E%E7%8E%B0/

如果计算机体系不太熟,建议先看看这个

https://www.bilibili.com/video/av31289365/?p=6
http://www.cs.cmu.edu/afs/cs/academic/class/15213-f15/www/schedule.html

Dash & 超低延时直播的研究

延时的来源

链式传播叠加的延时

  1. 编码和封装:引入延迟和参数配置、质量要求密切相关。某些流媒体协议可能会引入额外的延迟,因为它们只有在完全接收到后才输出一大块(chunk)媒体内容。
  2. 第一英里上传(first mile upload):将打包内容上传到CDN通常会受到商业条款的限制。例如,与来自新闻工作室的租用线路设置相比,如果通过无线连接完成上传将会产生更大的延迟。
  3. CDN传播:为了大规模传送内容,大多数媒体管道都利用内容传送网络(content delivery network)。因此,内容需要在不同缓存之间传播,从而引入额外延迟。
  4. 最后一英里交付(last mile delivery):用户网络连接可能会对延迟产生重大影响。用户可以在家庭网络连接到wifi热点,或者使用移动连接来访问网络内容。此外,由于可能会选取不同远近的CDN端点,用户地理位置也会造成额外延迟。
  5. 播放器缓冲区:视频播放器必须缓冲媒体以确保流畅播放。缓冲区大小通常在媒体规范中定义,但具有一定灵活性。播放缓冲是延迟的主要因素,优化缓冲区配置是常态。

7d8dfc0b722b8df93878853e19232c43.png

fig.1 (延迟来源)

延迟的长短

  • 典型延迟(typical latency)18-45s:如下图所示,在这个区域,我们看到一般都是HLS和MPEG-DASH设置,这两种适用于非时间敏感的线性广播,并且不会与广播公司或社交媒体上的其他观众进行任何交互。
  • 较少延迟(reduced latency)5-18s:通过调整HLS和MPEG-DASH流来减少延迟,减少了segment大小并增加了infrastructure的大小。该方法通常用于直播新闻和体育赛事。
  • 低延迟(low latency)1-5s:低延迟通常被视为每个发布者的目标,因为它允许更多交互式用例。
  • 超低延迟(ultra low latency)200ms-1s:可以实现更好的交互性,感觉接近实时。虽然不适合语音通信或会议,但这种延迟通常对于常见用例而言足够低。
  • 实时通信(real time communication)0-200ms:实时通信对于双向会议和通信等用例至关重要。

142a9a1242f3aa2fe1e5102e3219ed15.png

fig.2 (延迟长短定义)

DASH 和低延时

MPEG-DASH an overview

DASH 是一个类似于 HLS 的分片传输协议(其中一些多轨道,无缝切换之类的特性我们这里暂不讨论),DASH 中的列表文件是 mpd (Media Presentation Description) 。根据 mpd 中的几个时间字段(fig.3),我们可以算出 服务器和播放器直接的端到端延迟,这点很重要(详细算法可以看dash.js中getCurrentLiveLatency方法源码)。

fb8fe91d3dff26d1f7675ead769ed73f.png

fig.3 (DASH 时间模型)

能准确的获取端到端延迟在直播中最重要的意义就是:我们有了控制延迟的基础条件。在上面描述延迟图中(fig.1),第二步到第四步的网络传输抖动是我们无法控制的,但是只要我们知道了延迟的具体时间,就可以通过控制播放器播放进度,来实现快进或者慢放来保持稳定延时(sample)。 在下图(fig.4)中,我们通过播放器设置将延迟控制在了5s整。

96dfb5085b6ced836e6c7e04103e1d55.png

fig.4 (示例)

如何稳定进入5s以内? CMAF!

分块编码

实现低延迟的第一个必需行为是分块编码(chunked encoding)。根据MPEG CMAF标准,CMAF中各个对象的命名如图1所示。chunk是最小的可引用单元,至少包含moof和mdat这两部分。一个或多个chunk以形成fragment,一个或多个fragment形成一个segment。标准CMAF的media segment使用单个moof和mdat编码,如图2所示,mdat包含单个IDR(Instantaneous Decoder Refresh,瞬时解码器刷新)帧,这是每个segment开始传输所必需的。
b3a74ef9f53c4050ea245c6c167c2117.png

359bd2896cd7f9c61b115d42da3de80f.png
一般来说,segment将保持一系列chunk,即多个moof / mdat元组的序列,如图2所示。只有第一个元组保持IDR帧。将segment分成这些较短片段的优点是编码器可以在编码后立即输出每个chunk以便传输,这样就会导致整体延迟直接减少相同的量。每个块中包含多少帧没有固定的规定,目前的编码器范围为1至15帧。

DASH DASH-CMFA

再次强调一下,只有满足以下所有条件,才能稳定实现ULL-CMAF的减少延迟功能:

  1. CMAF段中的内容是块编码的。
  2. 编码器调整其DASH manifest/ HLS playlist以适应分块编码的使用和数据的早期可用性。
  3. 编码器使用HTTP 1.1块编码传输将内容推送到origin处。
  4. CDN在分发链的每个步骤使用HTTP块编码传输。
  5. 客户端:

    1. 准确地对segment的请求进行计时,并在live edge的一个segment持续时间内请求该切片;
    2. 在接收到比特流时对其进行解码,并且不用等到segment传输结束。在浏览器中运行的HTML5播放器必须使用Fetch而不是XHR API,因为Fetch允许在数据仍在下载时读取响应主体;
    3. 有一个估计吞吐量的方案,因为标准的segment定时技术将会失效;
    4. 具有缓冲和自适应逻辑以应对非常低的缓冲;
    5. 由于吞吐量波动,如果它落后于直播流,要具有赶上直播流的功能。

参考内容

优化延迟的最佳解决方案(一)
优化延迟的最佳解决方案(二)
优化延迟的最佳解决方案(三)

The importance of low latency in video streaming
视频传输延迟分析及解决方案:CMAF、LHLS

BEST PRACTICES FOR ULTRA-LOW LATENCY STREAMING USING CHUNKED-ENCODED AND CHUNK-TRANSFERRED CMAF
超低延迟CMAF流媒体方案解析