如何实现单机大规模并发SIP语音呼叫?

锁定目标:单机5千

多大叫大,1千还是1万?好吧,暂定为5000或以上。带宽不够?千兆网。硬盘太慢?SSD。

本文不考虑IO的限制,只讨论结构和模式。

开源世界Voip领域最响亮的牌子应该是FreeSwitch,使用者众多,它能实现如此大规模的单机并发吗?我认为:不行。

为什么不行?因为它线程太多,一个通道一个线程,上5000个线程,玩不转:“CPU忙着切换线程上下文了,哪有时间干正事”(《GO语言并发之道》)。

 

为什么多线程不行?

说起来,我曾经也是多线程的拥趸,在2003年设计的蓝星际语音平台时,按1通道对应1线程的方式。那时候几十线正常,上百线就算规模较大的应用了。(注意:FreeSwitch 1.0在2008年才出现)

我们先来说说多线程的好处。

我为语音平台设计了一种叫Koodoo的脚本语言,每个通道运行各自独立的脚本,相当于在每个线程上跑应用,比如要跑一个IVR:

WaitRing();  // 堵塞等待一个来电

Play("welcome.wav");  // 播放欢迎词,放完了才执行下一个语句

k = "";

Getkeys(k, 1, 20);  // 最多等20秒接收一个按键

if( k=="1" )

    Play("menu1.wav");

else if( k=="2" )

    Play("menu2.wav");

else

    Sleep(5);  // 延时5秒钟

 

多线程的优势体现出来了,一个通道一个线程,允许随便堵塞,写流程变得很简单,因为不用关心状态机,程序员不用转换编程的模式,写多通道的应用就像写单通道程序一样。

除了开发应用带来的便捷,一个通道一个线程让系统的结构变得清晰,Bug更少,更稳定。

 

带来的这种优势并非没有代价:操作系统线程的开销太大了。以内存为例,Windows下默认一个线程的堆栈是1M,就算什么也不干,跑5千个空线程就得占用5G内存。

内存还不是主要问题,内存不足可以扩,主要问题在CPU:通道和线程同步增长,而逻辑CPU单元是有限的,一般4到8,很强的服务器32个,跑5千以上的线程,假设每个CPU上运行1千个线程,

则每个CPU需要在在上1千个线程之间来回切换,导致CPU的执行效率极低,很快所有的CPU负荷上升到100%,几乎瘫痪,别的什么也干不了。

 

因为Koodoo语言是我自己设计的,办法来了。

 

解决之道:协程

大家知道GO语言有所谓“协程”,这个概念应该沿袭自Erlang,是解决大并发IO的一把钥匙。

所谓协程,就是在语言级别将函数执行划分成时间片,需要在语言级别实现调度器来分配这些协程的时间片。

在一个操作系统线程运行一个调度器,可以执行数千个协程,因为协程是语言级别实现的,上下文开销极小,因此将极大提升CPU的效率。

 

我来改造下Koodoo语言,也实现一个协程版本,这而且要做到源代码兼容,开发者不需要做什么,仅仅改一下配置,就可以得到性能的极大提升。

 

Koodoo语言的每一条语句,对应C++的一个对象,每个对象有个指针nextObj,指向下一条语句(对象),显然我们的调度粒度是这一个个语句对象。我们很容易做一个调度器,在通道之间进行切换。

我们还要考虑堵塞问题,但很多IO类的操作是堵塞的,比如:等某个通道放完一个音再切换到下一个通道十几秒过去了,这显然不行。当然,Koodoo的IO类函数基本都有非堵塞版本,偷懒方法是要求开发者使用非堵塞版本,这违背了源代码兼容的原则。另外有些语句没办法替换,比如Sleep(延时秒数)。

 

只能在语句对象上动手术了。

 

对于堵塞型的语句对象,增加1个成员变量runCount,用来记录运行状态,初值为0,伪代码如下:

语句对象::Run()

{

  if( runCount==0 ){

      此处执行启动IO,比如开始放音

      runCount = 1;

  }

  else{

      if( IO已经完成 ){

         关闭IO

 

         runCount = 0;

      }   

  }

}

 

同时,改造一下得到下一个语句的函数:

Obj* 语句对象::GetNext()

{

  return(runCount ? this : nextObj);

}

如果IO没有完成就还是执行本语句对象,执行完就正常跳到下一条。

因为打开IO和判断IO是否完成执行时间很短,可以看成是非堵塞的,用这种方法来进行调度,完全可行。

 

调度器的灵活配置

每个调度器运行在一个操作系统线程上。可以指定调度器(线程)的个数。

上述调度器的调度方式实际上是将CPU时间平均分配,这是默认配置。还可以对某些通道专门绑定到特定分配器,有一些执行特殊任务的通道,比如来电队列的自动分配(ACD),调度器运行的协程更少,可以有更高的优先级。

 

 

对比实测:令人惊叹

测试环境:

MacBook Pro 15,2017版 MacOS Catalina 10.15.3

Paralles Desktop虚拟机下的windows10 64位家庭版,分配了2个处理器,6G内存。

运行结果:

传统模式(1通道1线程),配置2000通道,CPU:100%,系统非常卡。5000通道就不用试了。

协程模式(2个调度器),配置2000通道,CPU:6-10%之间,系统顺畅。

协程模式(2个调度器),配置5000通道,CPU:26-37%之间,系统顺畅。

 

 

上一篇:Spring Boot Serverless 实战系列“部署篇” | Mall 应用


下一篇:Pytest