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(() => {});
    }

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

相关推荐
wordbaby3 分钟前
TanStack Router 基于文件的路由
前端
wordbaby8 分钟前
TanStack Router 路由概念
前端
wordbaby10 分钟前
TanStack Router 路由匹配
前端
cc蒲公英11 分钟前
vue nextTick和setTimeout区别
前端·javascript·vue.js
程序员刘禹锡16 分钟前
Html中常用的块标签!!!12.16日
前端·html
我血条子呢26 分钟前
【CSS】类似渐变色弯曲border
前端·css
DanyHope27 分钟前
LeetCode 两数之和:从 O (n²) 到 O (n),空间换时间的经典实践
前端·javascript·算法·leetcode·职场和发展
hgz071028 分钟前
企业级多项目部署与Tomcat运维实战
前端·firefox
用户18878710698428 分钟前
基于vant3的搜索选择组件
前端
zhoumeina9928 分钟前
懒加载图片
前端·javascript·vue.js