当音视频遇见Go

01. 背景

由于音视频的编解码都是非常消耗计算能力的,传统的音视频应用软件以及整个生态的基础库都是基于C或C++来编写的,特别对于核心的计算模块,一般还会采用汇编语言来编写,而且会大量使用SIMD指令集进行加速(比如SSE,AVX,ARM平台的NEON等)。传统流媒体服务一般会有两类的工程师来编写,一类是搞编解码的媒体工程师,另一类则是搞传输的网络工程师。搞媒体的工程师提供基础音视频处理库供网络工程师去调用,网络应用工程师则需要了解一些常规的视频知识(比如GOP,I/P/B帧),大家就可以相安无事,各自愉快的玩耍。

近些年,新出现的go语言越来越受到广大服务端开发者的欢迎,特别是做底层网络服务的工程师。Go语言有着非常简洁的语法、广泛的社区和生态支持,最重要的是协程机制(Goroutine)使得开发网络程序得到极大的简化,还能保持优良的性能,极大的解放了生产力。但随之带来了新的问题:网络工程师喜新厌旧热烈拥抱Go语言,但媒体应用工程师确无法利用Go语言带来的技术红利。

02. 高并发网络应用

一提到网络应用程序,自然离不开socket。操作系统提供给应用程序的网络IO的系统调用就是Socket API。提到IO就绕不开两组概念:同步与异步,阻塞与非阻塞。由于同步和异步与咱们的主题相关性不强,这里先暂且不提。下面重点来讨论阻塞与非阻塞。

阻塞与非阻塞

调用一个函数,如果能“立即”返回,不用等待其他资源(如IO、网络、锁)可用,那么这样的函数就是非阻塞的。反之,如果需要等待资源变得“可用”再返回,这样的函数调用就是阻塞的。那么区分阻塞和非阻塞有什么用呢?或者最大的影响是什么呢?答案是效率。通常来说,计算机应用程序一般分成CPU密集型(或称为计算密集型)和IO密集型,像访问磁盘、读取网络就是典型的IO密集型; 而做数学计算、视频编解码、深度学习网络推理等就是典型的CPU密集型的应用。这又和阻塞、非阻塞有什么关系呢?这里又不得不提到操作系统的任务调度机制。对于阻塞的调用,由于要等待资源,系统会把当前的线程挂起,把CPU计算资源交出去,给那些不用等待资源就能执行的任务(线程)。对于那些要管理几万、几十万、甚至上百万个网络连接的应用程序来说,这里存在两个方面的问题会影响效率: 一方面每个线程都有自己的栈空间(通常是几MB左右大小),如果每个连接都通过一个线程来管理,那么就需要对应数量的线程,光内存开销就已经吃不消了,比如2MB的栈空间,500个线程啥都不干就要消耗1个GB的内存。当然可以调整默认栈大小来支撑更多的线程,但这种方式能起到的作用比较有限,况且栈太小还有有栈溢出的问题)。另一方面,切换线程有比较大的开销。CPU切换线程需要做一系列的上下文的保存和恢复工作。网络数据包都是不固定、有间隙的到达,如果每一次到达都要触发一次线程切换,那么开销就太大了。回到正题,阻塞模式的方式,使得每个网络连接需要单独起一个线程来管理,在管理多连接时有较大的内存开销和上下文切换开销。而对于CPU密集型的应用,则最喜欢的就是无任何阻塞、无任何上下文切换,埋头拼命干活,这样才能最大限度的利用CPU。一句话总结:阻塞的IO模型无法应对高并发网络应用的需求。

IO多路复用

为了应对上节提到的阻塞带来的两方面的问题,聪明的计算机前辈们想到了解决办法:多路IO复用。提到这个概念,大家自然就想到select,epoll,iocp,kqueue等大家耳熟能详的IO复用模式。虽然这些模式底层实现机制差异比较大、效率也差别很大,但是基本的思路是一致的:一个线程管理更多的socket连接。比如同样管理1000条tcp连接,可以开10个线程,每个线程管理100条连接。对应的线程栈内存开销和上下文切换的开销都能降低很多。IO多路复用的模型有很多,最古老和常用的就是select了,select解决了IO多路复用问题,但是他有个两个较大的问题:1. 支持的文件描述符的的个数有限(1024个),对于高并发服务来说不太够用;2. 检查可读、可写事件的机制是轮询的方式,效率不太高。

为了解决select的效率问题,Linux从2.4的内核开始支持epoll机制,一方面打破了支持的文件描述符的限制,另外一方面能直接返回有事件的文件描述符列表,不用进行轮询检查,极大的提升了效率。kqueue、evport则是其他平台类似的实现。有了高效的多路IO复用机制后,服务器单机处理的网络连接数有了非常大的提升,像当前流行的web应用服务nginx,能轻松处理几十万的连接压力。

IO多路复用技术解决了运行效率问题,但还有一个开发效率的问题需要解决。虽然有很多开源框架(libevent、asio等)对这些IO多路复用技术进行了封装,降低开发人员的使用成本,但是写异步的程序还要翻过一座大山:事件驱动与状态机。

事件驱动(Event Driven)与状态机(State Machine)

对于使用多路IO复用的线程而言,虽然能管理很多个连接,但是线程执行的上下文只有一个。顺序执行的代码如何处理众多的连接上下文呢?答案是:状态机。通过多路IO复用机制能以事件驱动的方式获得连接的可读、可写事件,然后切换到该上下文(状态)后执行对应的读写操作和数据处理流程。由于所有的IO操作都是非阻塞的,当资源不可用时IO操作还是会正常的返回,只不过拿不到可用的数据. 由于是非阻塞的接口,返回的数据通常也不是一个完整逻辑意义上包,还需要有暂时的缓存和拼接逻辑,这给开发人员带来了极大复杂度。另外维护这些状态和对应的生命周期也极容易出错。这都对开发人员带来了极大的挑战。不是资深的网络程序开发人员,一般都胜任不了这种大规模、高并发的网络应用程序开发。就算找到了这样的开发人员,从开发到线上稳定运行也需要较长的周期,极大的降低了开发的效率。

03. Go语言的解决方式

Go语言是如何处理上面提到的运行效率和开发效率的问题呢?这里就需要提到Go语言的一个特有机制:Goroutine,Goroutine是Go语言的并发执行机制。但对于搞并发网络应用程序来说,它有两个特别有用的机制:

机制1:动态可变的栈

Goroutine的栈空间默认只有几KB级别(早期的是4KB,8KB),相对Linux的线程的栈大小来说小了很多。而且这个栈可以根据需要来改变大小。这个特性使得Goroutine比较轻量,一个应用拥有的Goroutine数量可以比线程要多上百倍。

机制2:轻量级的协程切换

go语言有自己的调度器,它是在系统的线程机制之上做了一层自己的调度器。goroutine的协程切换,通常只需要系统线程切换开销的几分之一。go语言对网络IO部分都会调用系统底层的多路IO复用机制,继承了IO多路复用的优点,同时轻量级的goroutine调度机制没有增加太多额外开销。

Go语言的上面两个机制直接带来一个结果: Go里可以用同步的方式写异步执行的程序。这个对于网络开发人员来说有极大的吸引力,毕竟写同步的程序难度跟事件驱动的状态机程序比容易太多了,而且又没有太大的降低运行效率。由于这个原因,越来越多的网络应用程序都开始拥抱Go,再加上Go本身简洁的语法、高效的并发机制、丰富的生态支持,使得Go语言越来越成为第一选择。

04. Go与音视频的结合

虽然用go语言来开发高并发网络应用程序可以很高效,但是用来开发音视频的应用程序,还有一些问题需要解决。虽然go语言本身的计算效率与C/C++的相差没有数量级上的差异,但由于音视频的主要计算都是编解码操作,通常用的编解码库都会使用SIMD指令进行加速计算,这就使得直接用go语言开发编解码算法还是不那么吸引人。对于计算密集型的部分,还是需要通过调用C/C++的库来实现。

在音视频领域,FFMpeg是一个当前最成熟、使用得最广的一个开源项目了。它几乎支持所有的网络协议、容器格式、编解码类型。ffmpeg本身也提供了一套简单、清晰的音视频处理框架,从输入经过一个处理管道或有向无环图,实现各种复杂的音视频处理任务。早期的版本,ffmpeg也提供了一个在服务端应用的ff-server工具,后来也因为一些原因从ffmpeg项目里移除。最大的原因就是ffmpeg的网络协议处理部分都是基于同步阻塞的接口来实现的(就算是非阻塞也是在那循环重试),网络与编解码处理在同一个pipeline处理流程里,使得ffmpeg无法应用于高网络并发的场合,特别是很多流媒体应用。

最理想的方式就是网络部分使用go语言来编写,而编解码部分还是调用原始的C/C++库,把网络协议处理与编解码处理解耦。这样做的好处显而易见,网络和协议部分的处理充分利用go语言的优势,提升开发效率。编解码计算还是用底层的C或SIMD指令优化过的库,充分发挥各自语言的优势,达到共赢。Go语言支持与C语言相互调用,具体的方式也比较简单。值得注意的是,音视频数量比较大,为了减少数据传递时的拷贝,尽量用unsafe.Pointer传递大批量数据,降低拷贝开销。尽量要保持未压缩的数据停留在C层,不要与go层频繁交换。加工完的结果也是在压缩后传递回go语言这边。比如对一个直播流进行实时截图,并调用AI模块进行安全审核,就可以把视频帧通过cgo的方式传递给C语言写的解码抽帧模块,抽取到的jpg压缩图像数据再返回给go层,go层拿到数据后再调用AI审核的API。

05. Go如何调用C代码及性能对比

我们写一个Go语言调用C代码示例test.c, 例子程序把一段内存里的每个字节都加1,之所以用传递一个比较大buffer的方式,是因为想模拟真实的音视频应用,用go语言处理网络协议后得到的音视频帧数据通过buffer的形式传递给C语言实现的代码来加工处理(比如解码)。

#include <stdint.h>
​
void add(uint8_t* buf, int len)
{
    for (int i = 0; i < len; i++) {
        buf[i]++;
    }
}

用gcc来编译一个可以调用的库

gcc -c test.c 
ar r libadd.a test.o

这样就能得到一个标准的库 libadd.a, 在Go语言里,可以用C.add来引用这个函数。

我们用go语言也实现了同样功能的go_add函数,并循环执行一定的次数,用来评测性能。代码如下

package main
​
import "unsafe"
import "fmt"
import "time"
​
// #cgo LDFLAGS: -L. -ladd
// #include "test.h"
import "C"
​
func go_add(x []byte) {
    for i := 0; i < len(x); i = i + 1 {
        x[i] = x[i] + 1
    }
}
​
func main() {
    len := 100000
    var buf []byte = make([]byte, len)
    x := (*C.uchar)(unsafe.Pointer(&buf[0]))
    cnt := 100000
    t1 := time.Now().UnixNano()
    for i := 0; i < cnt; i = i + 1 {
        C.add(x, C.int(len))
    }
    t2 := time.Now().UnixNano()
    for i := 0; i < cnt; i = i + 1 {
        go_add(buf)
    }
    t3 := time.Now().UnixNano()
    duration_c  := float32(t2 - t1) / 1000000000.0
    duration_go := float32(t3 - t2) / 1000000000.0
    fmt.Printf("C  : %0.3f seconds\nGo : %0.3f seconds\n", duration_c, duration_go)
}

上面的代码中使用了unsafe.Pointer,这是一种避免数据拷贝开销的数据传递方式(如果考虑移植性和通用性,不推荐此种方式;但在特别关注性能,并清楚的知道自己在做什么的情况下,可以使用unsafe包提供的工具来提升性能)。

go build -gcflags "-N -l" main.go

运行的结果(都吃满一个核的CPU,不使用多线程和多核处理能力)

C  : 13.425 seconds
Go : 27.946 seconds

可以看到Go的运行速度还是相对来说慢了一些。再来看看开启编译优化的情形

# 编译C库
gcc -O2 -c test.c 
ar r libadd.a test.o
​
#编译go程序(go编译器是默认开启优化的)
go build main.go

运行的结果

C  : 0.215 seconds
Go : 3.107 seconds

编译优化后,C语言的性能优势就更加突出。这也是为什么我们还是要用C/C++来做计算密集型的处理。

06. 总结

本文分析了传统高并发网络程序的挑战与解决方法,通过一个简单的实例讲述了如何在go语言里调用C语言编写的库,并对两者的性能做了一个对比。同时也讲述了使用go语言来开发高并发的音视频网络应用程序的优势与注意事项。当前go社区已经积累了非常丰富的媒体协议处理库,比如RTMP/HLS/FLV的服务的https://github.com/gwuhaolin/livego, 支持RTSP协议的https://github.com/djwackey/dorsvr,支持WebRTC的https://github.com/pion/webrtc。对于暂时没有实现的,就需要自己开发并贡献到社区了。相信未来越来越多的音视频服务端应用会拥抱Go语言生态。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇