在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
说下核心实现逻辑:
- 在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();
- 在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;
};
- 队列是如何运转,内部一共有2个触发execute执行任务的地方:
第一次,在pendingTask放入map后,会判断map里任务长度是否为1的话就开始execute任务,所谓execute就是执行一下task,把结果给到resolve或者reject。
后面execute的触发都在PendingTask里的resolve或者reject里,就是上一个task处理完成后,从队列里取出一个任务来执行execute,说白了就是一个递归,只需要先返回结果,再开启下次任务。如果循环往复就能把所有的异步任务处理完所有异步任务。
- 上面其实已经说完了核心流程,代码实现也是非常简洁,队列也增加了删除和停止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(() => {});
}
(匆忙写成,有空再回来补个图)