mediasoup sdp与异步队列

WebRTC sdp杂谈 - 掘金中的sdp相关的API与状态机有说到:WebRTC的sdp操作必须是要遵循其状态机。并且在使用unified-plan标准的单pc,sdp操作难度会增大,如果使用不当轻则使订阅发布的流程变慢,重则报错,无法恢复,阻塞业务流程,因此非常6地使用异步队列来操作sdp是提升WebRTC技术的必经之路,

一、为什么需要异步队列

1.1 为什么讨论单pc?

pc即RTCPeerConnection,单pc相对于多pc而言,多pc是使用一个pc去发布或者订阅1路流,而单pc是使用一个pc去发布或者订阅多路流。每一个pc建立连接都是相当耗时与耗费服务器端口资源,因此现在大多WebRTC应用都使用的单pc。

1.2 为什么unified-plan比较难管理?

单pc也可以使用plan-b或者unified-plan,unified-plan的难点如下:

  • m-section的数量不是固定的,每次订阅或者发布都会新增,要求offer与answer的数量对等;

  • offer与answer m-section中的mid还是对齐的;

  • 取消订阅取消发布后还需要关闭掉对应的m-section,关闭并非删除,sdp只能有增无减,下次再要使用(createOffer)sdp会从已经关闭了的m-section中从上开始复用这些m-section

在plan-b中这些问题都不是问题,虽然plan-b已经淘汰了,但为了说明这2种sdp格式不同操作难度,我们这里也分析一下,假使我们的业务流程是要连续订阅2个流,对于plan-b我们可以这么操作:

ini 复制代码
const pc = new RTCPeerConnection({ sdpSemantics: 'plan-b' });
const offer = await pc.createOffer({ // 他没有addTransicever api,想要生成offer带a/v mline的话,需要这么设置
    offerToReceiveAudio: true,
    offerToReceiveVideo: true
});

/**offer sdp长这样
* v=0
* m=audio xxx
* a=mid:audio
* m=video xxx
* a=mid:video
*/

// 订阅流1
signaling.send('consume', offer);
// 订阅流2
signaling.send('consume', offer);

// 收到answer1
await pc.setLocalDescription(offer);
await pc.setRemoteDescription(answer1);
/**answer1 sdp长这样
* v=0
* m=audio xxx
* a=mid:audio
* a=ssrc stream1 audio
* m=video xxx
* a=mid:video
* a=ssrc stream1 video
*/
// 收到answer2
await pc.setLocalDescription(offer);
await pc.setRemoteDescription(answer2);
/**answer2 sdp长这样
* v=0
* m=audio xxx
* a=mid:audio
* a=ssrc stream1 audio
* a=ssrc stream2 audio
* m=video xxx
* a=mid:video
* a=ssrc stream1 video
* a=ssrc stream2 video
*/

由于plan-b里面只有一个m=audio和一个m=video,offer和answer m-line的数量始终相等,mid也一直对应,我们可以肆无忌惮地调用createOffer、setLocalDescription、setRemoteDescription。

而在unified-plan下,这种好日子就到头了(下面只以audio说明,省点字数):

ini 复制代码
const pc = new RTCPeerConnection({ sdpSemantics: 'unified-plan' });

async function consume(streamId) {
    pc.addTransceiver('audio', { direction: 'recvonly' });
    const offer = await pc.createOffer();
    const answer = await signaling.send('consume', offer);
    await pc.setLocalDescription(offer);
    await pc.setRemoteDescription(answer);
}

// 重点看下这些
await consume(stream1);
await consume(stream2);

我们订阅的api consume必须是等上一个执行完成,再来执行下一个,否则就会在setRemoteDescription时报错,假设consume同步调用了2次,结果是:pc调用了2次addTransceiver,这个是同步的,生成的offer就是有2个m=audio,但是第一次订阅返回的answer里面只有1个m=audio,这时设置answer就会报m-section数量不相等的错误。

在实际应用中,是有同步或者是在事件回调里调用consume,那要使用unified-plan的单pc,为了确保pc的sdp状态被正确地设置,所以需要一个异步队列来维护sdp的调用顺序,下面会说明下在mediasoup-client里是如何做的。

二、awaitqueue实现原理

awaitqueue是mediasoup项目里自己实现一个异步队列,项目地址在:github.com/versatica/a...

javascript 复制代码
// Usage
const awaitQueue = new AwaitQueue();

function getDelayRandomNum() {
    return new Promise(resolve => setTimeout(() => resolve(Math.reandom()), 1000))
}

awaitQueue.push(getDelayRandomNum).then(console.log);
awaitQueue.push(getDelayRandomNum).then(console.log);
awaitQueue.push(getDelayRandomNum).then(console.log);

使用上就是给队列里push异步任务,返回值是异步任务调用后的返回值,上面的例子里是同步调用了3次push方法,最后打印的时间每次都是间隔1s:

makefile 复制代码
20:49:44 0.25259773345167624
20:49:45 0.23908595712464975
20:49:46 0.12529222553276376

说下核心实现逻辑:

  1. 在class内部使用了Map来存储task(就是传进来的异步任务),对于我说的一个冷知识,map.values()取数据的时候竟然是有序的,看看mdn是这么说的:代按插入顺序进行,即键值对按 set() 方法首次插入到集合中的顺序
typescript 复制代码
// Queue of pending tasks (map of PendingTasks indexed by id).
private readonly pendingTasks: Map<number, PendingTask<any>> = new Map();
  1. 在push的时候,按内部的数据结构放到上面的map中
typescript 复制代码
type PendingTask<T> =
{
        id: number; // 任务的id,从0自增
        task: AwaitQueueTask<T>; // 传进来的异步任务
        name?: string; // 任务名称,没啥 
        enqueuedAt: number; // 进队的时间,没啥用
        executedAt?: number; // task开始执行的时间,也用于判断防止重复执行
        completed: boolean; // 任务是否完成,成功和失败都算完成
        // push本身会返回promise,这里的resolve和reject就是缓存的promise的参数
        // 等task执行的结果也会给到resolve或者reject,这样就在能push().then里拿到数据了
        resolve: (result: T | PromiseLike<T>) => void; 
        reject: (error: Error) => void;
};
  1. 队列是如何运转,内部一共有2个触发execute执行任务的地方:

第一次,在pendingTask放入map后,会判断map里任务长度是否为1的话就开始execute任务,所谓execute就是执行一下task,把结果给到resolve或者reject。

后面execute的触发都在PendingTask里的resolve或者reject里,就是上一个task处理完成后,从队列里取出一个任务来执行execute,说白了就是一个递归,只需要先返回结果,再开启下次任务。如果循环往复就能把所有的异步任务处理完所有异步任务。

  1. 上面其实已经说完了核心流程,代码实现也是非常简洁,队列也增加了删除和停止api

删除就是根据索引找到pendingTask,然后直接执行下task.reject,这个api感觉实现起来有点鸡肋,索引还需要根据dump返回的内容查找一下;

停止和删除类似,把所有的任务都给reject了,然后stopping标志位置true,比如此时有个正在执行的任务执行完了,一看是stopping为true了,就不需要处理了(因为刚刚reject了)

三、mediasoup的异步队列与优化

知道了sdp为什么要放到异步队列里处理了,也知道了awaitqueue的工作原理,那么正式看一下mediasoup是如何使用的,一个Transport是分为send或者recv,发布数量少,就是正常的使用awaitqueue,订阅的数量比较多,所以会合并相同任务的。这里我们只讲异步队列到调用handler上的方法为止,以后单独讲不同版本的handler有什么不同的处理策略。

先看下produce,看下基础用法,就是针对需要修改sdp的操作通通都放到awaitqueue里,这里说的操作包括:

  • produce - 这个是入口,其他的操作都是在producer上,producer通过事件再发送到transport中处理

  • @close

  • @pause

  • @resume

  • @replacetrack

  • @setmaxspatiallayer

  • @setrtpencodingparameters

最后学习下mediasoup是如何进行任务合并优化的,订阅和发布的区别是订阅是可能订阅多次的,比如9宫格、16宫格,比如一次订阅(setLocal/setRemote)需要20ms,订阅15路,需要300ms,能批处理一次完成就能优化280ms。优化的手段就是在awaitqueue之外,还维护了4个task任务队列,分别是:

  • _pendingConsumerTasks:合并创建订阅任务,类型为Array

  • _pendingPauseConsumers:合并暂停订阅任务,类型为Map

  • _pendingResumeConsumers:合并恢复订阅任务,类型为Map

  • _pendingCloseConsumers:合并取消订阅任务,类型为Map(为数组也可以)

这4个队列也分别对应了4个API,以consume为例,consume调用后,并不会马上去awaitqueue.push调用handler上的接口,而是先把这个异步任务推到_pendingConsumerTasks队列里,然后判断是否有_pendingConsumerTasks的任务在执行,如果没有的话,会马上启动执行consume任务,在执行consume任务期间,又来了14个订阅任务,也会先保存到_pendingConsumerTasks,然后等上一个consume任务一完成,马上递归调用取出全部的_pendingConsumerTasks任务,来完全批量调用。

kotlin 复制代码
class ConsumerCreationTask {
    consumerOptions: ConsumerOptions;
    promise: Promise<Consumer>;
    resolve?: (consumer: Consumer) => void;
    reject?: (error: Error) => void;

    constructor(consumerOptions: ConsumerOptions) {
        this.consumerOptions = consumerOptions;
        this.promise = new Promise((resolve, reject) => {
            this.resolve = resolve;
            this.reject = reject;
        });
    }
}
    
    /**
     * Create a Consumer to consume a remote Producer.
     */
    async consume<ConsumerAppData extends AppData = AppData>({
        id,
        producerId,
        kind,
        rtpParameters,
        streamId,
        appData = {} as ConsumerAppData,
    }: ConsumerOptions<ConsumerAppData>): Promise<Consumer<ConsumerAppData>> {
        const consumerCreationTask = new ConsumerCreationTask({
            id,
            producerId,
            kind,
            rtpParameters,
            streamId,
            appData,
        });

        // Store the Consumer creation task.
        this._pendingConsumerTasks.push(consumerCreationTask);

        // There is no Consumer creation in progress, create it now.
        queueMicrotask(() => {
            if (this._closed) {
                return;
            }

            if (this._consumerCreationInProgress === false) {
                this.createPendingConsumers<ConsumerAppData>();
            }
        });

        return consumerCreationTask.promise as Promise<Consumer<ConsumerAppData>>;
    }
    
    // This method is guaranteed to never throw.
    private async createPendingConsumers<
        ConsumerAppData extends AppData,
    >(): Promise<void> {
        this._consumerCreationInProgress = true;

        this._awaitQueue
            .push(async () => {
                if (this._pendingConsumerTasks.length === 0) {
                    logger.debug(
                        'createPendingConsumers() | there is no Consumer to be created'
                    );

                    return;
                }

                const pendingConsumerTasks = [...this._pendingConsumerTasks];

                // Clear pending Consumer tasks.
                this._pendingConsumerTasks = [];

                // Fill options list.
                const optionsList: HandlerReceiveOptions[] = [];

                for (const task of pendingConsumerTasks) {
                    const { id, kind, rtpParameters, streamId } = task.consumerOptions;

                    optionsList.push({
                        trackId: id!,
                        kind: kind as MediaKind,
                        rtpParameters,
                        streamId,
                    });
                }

                try {
                    const results = await this._handler.receive(optionsList);

                    for (let idx = 0; idx < results.length; ++idx) {
                        const task = pendingConsumerTasks[idx];
                        const result = results[idx];
                        const { id, producerId, kind, rtpParameters, appData } =
                            task.consumerOptions;
                        const { localId, rtpReceiver, track } = result;
                        const consumer = new Consumer<ConsumerAppData>({
                           ...
                        });
                        task.resolve!(consumer);
                    }
                } catch (error) {
                    for (const task of pendingConsumerTasks) {
                        task.reject!(error as Error);
                    }
                }
            }, 'transport.createPendingConsumers()')
            .then(() => {
                this._consumerCreationInProgress = false;

                // There are pending Consumer tasks, enqueue their creation.
                if (this._pendingConsumerTasks.length > 0) {
                    this.createPendingConsumers<ConsumerAppData>();
                }
            })
            // NOTE: We only get here when the await queue is closed.
            .catch(() => {});
    }

(匆忙写成,有空再回来补个图)

相关推荐
四喜花露水7 分钟前
Vue 自定义icon组件封装SVG图标
前端·javascript·vue.js
前端Hardy17 分钟前
HTML&CSS: 实现可爱的冰墩墩
前端·javascript·css·html·css3
web Rookie1 小时前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript
Au_ust1 小时前
css:基础
前端·css
帅帅哥的兜兜1 小时前
css基础:底部固定,导航栏浮动在顶部
前端·css·css3
yi碗汤园1 小时前
【一文了解】C#基础-集合
开发语言·前端·unity·c#
就是个名称1 小时前
购物车-多元素组合动画css
前端·css
编程一生1 小时前
回调数据丢了?
运维·服务器·前端
丶21362 小时前
【鉴权】深入了解 Cookie:Web 开发中的客户端存储小数据
前端·安全·web
Missmiaomiao3 小时前
npm install慢
前端·npm·node.js