FreeSWITCH 的 session:一次呼叫的生老病死,都装在一个容器里
Posted on 六 19 9月 2026 in Tech
| Abstract | FreeSWITCH 的 session:一次呼叫的生老病死,都装在一个容器里 |
|---|---|
| Authors | Walter Fan |
| Category | Tech |
| Version | v1.0 |
| Updated | 2026-09-19 |
| License | CC-BY-NC-ND 4.0 |
大纲
展开看看
- 一句话:session 是一路呼叫的容器,也是它的边界。它把这路通话的信令、媒体、状态、内存、线程全部收拢到一个结构体里,生死与共。
- 它到底装了什么:一个
switch_core_session_t里挂着 channel(呼叫状态)、media_handle(媒体)、一个专属内存池、一个专属线程、一堆锁和队列。这不是散装的字段,是一个有明确生命周期的整体。 - 生命周期八个状态:
CS_NEW → CS_INIT → CS_ROUTING → CS_EXECUTE → CS_EXCHANGE_MEDIA → CS_HANGUP → CS_REPORTING → CS_DESTROY,一台状态机把呼叫的生老病死串成一条线。 - 私有 vs 共享:session 线程独占地跑自己的状态机、读写自己的媒体缓冲;别的线程想动它,只能过
session_locate拿读锁,或者往它的消息队列里投递——不是直接改内存,是"敲门递条子"。 - 为什么绑死内存池:session 里几乎所有东西都从它自己的 pool 里分配。销毁时一句
switch_core_destroy_memory_pool全部回收。这个设计干净利落,但也把 session 钉死在了本进程的地址空间里。 - 为什么不能跨进程迁移:内存池是本进程的裸指针,线程是本进程的调度实体,socket、锁、协商好的媒体上下文全是本机资源。序列化这些东西的成本,远超过重建一路呼叫。
- 会话漂移怎么做:与其在服务端把 session 从 A 搬到 B,不如让客户端 endpoint 重新 INVITE 到 B。障碍不在服务端搬不搬得动,而在媒体不能断和状态谁来接续。
- 收尾:session 是 FreeSWITCH 的"进程内的进程"——理解了这个容器的边界,就理解了它能做什么、做不了什么。
上一篇《FreeSWITCH 架构设计之快与脆》里我说,FreeSWITCH 把整套电话系统塞进一个进程里,状态归 session 管,“一路呼叫一个 session,一个 session 一个线程一台状态机”。那篇是站在高处看架构。这一篇,我想钻进去,把这个反复出现的 session 拆开来看:它到底是个什么东西?一次呼叫从进来到消失,在它内部经历了什么?
修那个 use-after-free 的时候我盯着 struct switch_core_session 看了很久,越看越觉得这个结构体不简单。它不是随便一个装数据的 bag。它是一路通话的容器——把信令、媒体、状态、内存、线程全部收进来;它也是一路通话的边界——通话与通话之间靠它隔离,而它一旦跨过某条线(比如跨进程),整套设计就崩了。
这一篇就讲三件事:一次呼叫在 session 里完整的生命周期是怎样的;session 里哪些数据私有、哪些共享;以及为什么这个容器几乎不可能整体搬到另一台机器上——真想做“会话漂移”,正确的姿势是什么。
术语先对齐:session(会话)是 FreeSWITCH 里一路呼叫(call leg)的运行时对象;channel(通道)是挂在 session 上、专门管呼叫状态和通道变量的那部分;endpoint(端点)是发起或接收这路呼叫的一方——可以是
mod_sofia代表的 SIP 对端,也可以是一个 WebRTC 浏览器。下文这几个词会反复用到。
先看它装了什么:session 是个“进程内的进程”
要理解 session,最快的办法是直接看它的结构体。我把 switch_core_pvt.h 里 struct switch_core_session 开头那几行摘出来,删掉次要字段,留下最能说明问题的:
struct switch_core_session {
switch_memory_pool_t *pool; /* 专属内存池 */
switch_thread_t *thread; /* 专属线程 */
switch_thread_id_t thread_id;
switch_endpoint_interface_t *endpoint_interface; /* 是谁创建的我:mod_sofia? */
switch_channel_t *channel; /* 呼叫状态、通道变量 */
switch_mutex_t *mutex; /* 一堆锁 */
switch_thread_rwlock_t *rwlock; /* 跨线程访问的读写锁 */
switch_queue_t *event_queue; /* 事件队列 */
switch_queue_t *message_queue; /* 消息队列——别人给我递条子的地方 */
switch_queue_t *private_event_queue;
switch_media_handle_t *media_handle; /* 媒体:codec、RTP、缓冲 */
switch_buffer_t *raw_read_buffer; /* 读到的原始音频 */
switch_buffer_t *raw_write_buffer; /* 要写出的原始音频 */
/* ... 还有一大堆 codec、resampler、media bug ... */
};
看出门道了吗?这个结构体里有它自己的内存池、它自己的线程、它自己的锁、它自己的消息队列、它自己的媒体缓冲。
这几样东西凑在一起是什么概念?操作系统里的一个进程,无非就是“独立的地址空间 + 独立的执行流 + 和外界通信的信道”。而一个 session:内存池就是它的“地址空间”(这路呼叫要用的内存都从这里出),专属线程就是它的“执行流”,消息队列和事件队列就是它“和外界通信的信道”。
所以我更愿意把 session 理解成 FreeSWITCH 进程内部的一个“轻量进程”。它不是真的进程——没有内核给的隔离,一个野指针照样能踩到隔壁——但从资源组织的角度看,它确实是一个自成一体、有明确边界、有独立生命周期的单元。这个类比,是理解后面所有问题的钥匙。
生命周期:一次呼叫的生老病死
session 不是一个静态的容器,它是活的——从被创建的那一刻起,它就在一台状态机上流转,直到被销毁。这台状态机的状态,switch_types.h 里定义得清清楚楚:
CS_NEW → CS_INIT → CS_ROUTING → CS_EXECUTE
→ CS_EXCHANGE_MEDIA → CS_HANGUP → CS_REPORTING → CS_DESTROY
跟着一通电话走一遍,你就懂了。假设一个 SIP INVITE 打进来:
1. 出生(CS_NEW / CS_INIT)。 mod_sofia 收到 INVITE,调 switch_core_session_request_uuid() 请求一个新 session。这一步做的第一件大事,就是开一个新内存池:
switch_core_new_memory_pool(&usepool);
session->pool = usepool;
switch_channel_alloc(&session->channel, direction, session->pool); /* channel 从 pool 分配 */
switch_mutex_init(&session->mutex, ..., session->pool); /* 锁从 pool 分配 */
switch_queue_create(&session->message_queue, ..., session->pool); /* 队列从 pool 分配 */
switch_thread_rwlock_create(&session->rwlock, session->pool); /* rwlock 从 pool 分配 */
注意每一行末尾——channel、锁、队列、rwlock,全部从 session->pool 里分配。这不是巧合,是刻意的设计,后面讲销毁的时候你会看到它的威力。session 建好后被登记进全局的 session_table(那张用 UUID 查 session 的哈希表),然后 switch_core_session_thread_launch() 给它拉起专属线程,状态机开始转。
2. 找路(CS_ROUTING)。 呼叫要去哪儿?这个状态里,拨号计划(dialplan)登场,根据主被叫号码、通道变量,决定这通电话该干嘛——转接、进 IVR、进会议、还是直接桥接到另一路。
3. 干活(CS_EXECUTE)。 拨号计划里排好的一个个 application(answer、bridge、playback、conference……)在这里依次执行。大部分业务逻辑都发生在这个状态。
4. 通媒体(CS_EXCHANGE_MEDIA)。 到了真正收发 RTP 包的阶段。session 线程在这里跑一个循环:从 socket 读 RTP → 解码 → 丢进 raw_read_buffer → 应用各种 media bug(录音、检测)→ 编码 → 从 socket 写出去。每 20 毫秒一帧,雷打不动。
5. 挂断(CS_HANGUP)。 一方挂了,或者逻辑走到头了。状态机进入 CS_HANGUP,通知 endpoint 模块发 BYE、拆媒体、做收尾。
6. 记账(CS_REPORTING)。 挂断后,生成这通电话的 CDR(话单:谁打给谁、通了多久、什么原因挂的)。
7. 销毁(CS_DESTROY)。 最后一步,也是最能说明 session 设计哲学的一步,下一节专门讲。
每一个状态转换,状态机都先调创建这个 session 的 endpoint 模块(比如 mod_sofia)注册的 state handler——这就是上一篇说的“模块是状态机上的插件”。模块处理完,再走通用逻辑,把 channel 推向下一个状态。整个流程,都在 session 自己那个专属线程里跑,不借别人的线程。
私有还是共享:线程独占,别人只能敲门
现在回答那个更细的问题:session 里的数据,哪些是线程私有的,哪些是共享的?
默认全是“私有”的——由 session 自己那个线程独占地访问。 状态机的流转、媒体缓冲的读写、codec 的编解码,都发生在 session 线程内部。正常情况下,没有别的线程会去碰这些东西。这就是为什么“一路呼叫一个线程”能跑得又快又不容易出并发问题——大部分时候根本不需要加锁,因为只有一个线程在动它。
问题是,总有别的线程需要跟这路呼叫打交道。比如:
- 你在
fs_cli里敲命令要挂断某路呼叫——发命令的是 CLI 线程。 - 另一路呼叫要跟它桥接、要给它发个 DTMF、要往它的媒体流里插一段放音——发起方是另一个 session 的线程。
- 一个事件订阅者想读它的通道变量。
这些“外来线程”怎么安全地访问一个不属于自己的 session?FreeSWITCH 给了两条路,而且都不是“直接改内存”:
第一条:session_locate 拿读锁。 上一篇讲过,拿 UUID 去 session_table 查,查到给你一个加了读锁的指针,用完必须解锁,而且别锁超过几毫秒。这条路适合“我要读一下它的状态”或者“我要短暂地操作它”。锁的存在本身就说明:session 是共享的资源,但共享是有代价、要小心的。
第二条:往消息队列里投递。 这是我觉得更能体现设计思想的一条。如果你想让某个 session“做件事”,很多时候不是你直接去改它的内存,而是构造一个消息,switch_core_session_queue_message() 塞进它的 message_queue:
switch_status_t switch_core_session_queue_message(
switch_core_session_t *session, switch_core_session_message_t *message);
然后它自己的线程在状态机循环里 pop 出这个消息,switch_core_session_receive_message() 处理掉。
这是什么模式?这是 actor 模式的雏形——不共享内存,靠消息通信。 你不能随便闯进别人家里改家具,你只能把要求写成条子,塞进人家门口的信箱,等主人自己出来处理。跨 session 的很多交互(比如桥接时把一方的媒体请求转给另一方),底层走的就是这套“投递消息、对方线程自己消化”的机制。
所以“私有 vs 共享”的准确答案是:
| 数据/资源 | 谁能访问 | 怎么访问 |
|---|---|---|
| 状态机流转、媒体缓冲读写、codec | session 自己的线程 | 独占,基本无锁 |
| 整个 session 结构体(读) | 任意线程 | session_locate 拿读锁,快进快出 |
| 让 session“做件事” | 任意线程 | 往 message_queue 投递,它自己消化 |
| 全局资源(session_table、事件系统) | 任意线程 | 各自的全局锁保护 |
这套“线程独占为主、外来访问靠锁或消息”的设计,是 session 能既高效又相对安全的关键。 它把并发的复杂度收敛到了几个明确的入口上,而不是让所有线程在共享内存里自由厮杀。
为什么和内存池绑死:一句话回收整个宇宙
回到 CS_DESTROY,看销毁那一刻发生了什么。switch_core_session_perform_destroy() 的结尾,是这样几行:
/* 清空还没处理完的事件队列 */
while (switch_queue_trypop(session->event_queue, &pop) == SWITCH_STATUS_SUCCESS) {
switch_event_destroy(&event);
}
pool = session->pool;
*session = NULL;
switch_core_destroy_memory_pool(&pool); /* 关键就这一句 */
看最后一句。销毁一个 session,核心动作就是销毁它的内存池。
前面出生的时候,channel、锁、队列、rwlock、大部分缓冲……全是从 session->pool 分配的。所以销毁的时候,不需要一个个 free——把整个池子端掉,里面所有东西一次性全部回收。这就是 FreeSWITCH(底层是 APR 内存池)最优雅的地方:用内存池的生命周期,精确地框定 session 的生命周期。session 生,则池生;session 亡,则池亡。分配时不用操心谁来释放,销毁时一网打尽。
对一个每秒可能创建销毁成百上千路呼叫的系统来说,这个设计有两个实在的好处:一是不容易内存泄漏——只要你把内存挂在 session 的池上,session 一销毁就自动回收,忘了 free 也不怕;二是快——批量回收一个池,比逐个 free 成千上万个小对象高效得多。
但是——凡事有一利就有一弊,这句话在 FreeSWITCH 里我要说第二遍了——正是这个“和内存池绑死”的优雅设计,把 session 死死钉在了本进程的地址空间里。 这就引出了那个最有意思的问题。
为什么 session 不能随便跨进程迁移
现在来正面回答那个问题:能不能把一路正在进行的呼叫,连同它的 session,从服务器 A 整体搬到服务器 B?
结论是:几乎不可能,而且障碍不是“没人写这个功能”,是这个设计从根上就不支持。 逐条看 session 里那些东西,它们无一例外都是本进程、本机的资源:
内存池是本进程的裸指针。 session 里到处是指针——session->channel、session->media_handle、session->pool 自己。这些指针指向的是本进程虚拟地址空间里的地址。搬到另一台机器上,这些地址一文不值。你要迁移,就得把整个对象图(session 引用的 channel、channel 引用的通道变量、media_handle 引用的 codec 状态……)全部序列化成一份和地址无关的数据,在对端反序列化重建。这个对象图有多大、多深、多少循环引用,想想就头疼。
线程是本进程的调度实体。 session 那个专属线程,是本机操作系统调度的。你没法把一个正在运行的线程“搬”到另一台机器上——线程的栈、寄存器状态、它正卡在哪一行代码,这些是 CPU 和 OS 的私产。对端只能重新起一个线程,从某个状态重新开始跑。
媒体上下文是本机的实时资源。 这是最要命的一条。这路呼叫协商好的 codec、正在用的 RTP socket、丢包重传的状态、jitter buffer 里缓着的包、加密的密钥……全是和这台机器、这个网络位置绑定的。RTP 是有状态的实时流,序列号、时间戳、SSRC 都在连续推进。你就算把这些状态字节不差地搬过去,对端那台机器的 IP 变了,对方(remote endpoint)还在往老地址发包呢。
把这几条摆一起,答案就很清楚了:序列化一个 session 并在异地重建它的成本,远远超过“干脆重新建一路呼叫”。 这不是 FreeSWITCH 偷懒,是它当年选择“信令媒体同处一个地址空间、用内存池管生命周期”这套架构时,顺带付出的代价。上一篇说“信令媒体贴得近换来对话零成本”,这一篇就是那笔账的另一面——贴得越近、绑得越死,就越搬不动。
那“会话漂移”到底该怎么做
既然服务端搬不动 session,是不是就没法做会话保持、故障切换了?不是。换个思路:不搬服务端的 session,让客户端 endpoint 重新连到新服务器。
这正是我一开始的判断,从源码角度看,它是对的。想象一下:与其绞尽脑汁把 A 上那个绑死了内存池和线程的 session 整体搬到 B,不如让持有这路呼叫的那个客户端(浏览器、软电话、话机)重新发起一次 INVITE,连到 B。B 上按标准流程新建一个 session——走一遍 CS_NEW 到 CS_EXCHANGE_MEDIA,干净利落,完全在这套架构的舒适区里。
但这条路也不是白捡的,它把难题从“服务端搬不搬得动”换成了另外两个更本质的问题:
难题一:媒体不能断,或者尽量少断。 重新 INVITE 意味着要重新协商媒体、重新建 RTP 流。这中间必然有一小段空档,对方可能听到卡顿甚至断音。要把这段空档压到用户无感,得靠客户端和信令层配合——比如提前和 B 建好媒体通道再切、用 ICE 重启保住连接、或者上层协议(像某些 WebRTC 方案里的连接迁移)来兜底。真正的技术含量在这里,不在“搬 session”。
难题二:状态谁来接续。 这路呼叫在 A 上已经进行到某个业务状态了——放到 IVR 第三层菜单了、已经录了 5 分钟音了、正在一个有 8 个人的会议里。B 新建的 session 是张白纸,它不知道这些。要接续,就得把业务状态(不是内存指针,是“这通电话逻辑上进行到哪了”)存在 session 之外的地方——数据库、共享缓存、或者干脆由客户端携带。这本质上是在做有状态服务的状态外置,和 Web 后端把用户会话从内存挪到 Redis 是同一个道理。
所以“会话漂移”的正确打开方式,一句话:别跟服务端那个绑死的 session 较劲,把可迁移的状态外置出来,让客户端 endpoint 重连新节点、由外置状态接续。 服务端的 session 该销毁就销毁,该新建就新建——它本来就是设计成“廉价地生、干净地灭”的东西。
这里其实藏着一个更普适的道理:当一个对象和它的运行时环境(内存、线程、连接)绑得太死,你就别指望能整体迁移它;能迁移的,永远是那些被刻意剥离出来、和环境无关的状态。 这条规律,从 FreeSWITCH 的 session,到无状态微服务,到 Kubernetes 里的 Pod 为什么是“杀掉重建”而不是“热迁移”,背后是同一句话。
总结:session 是容器,是边界,也是它搬不动的原因
把这一篇收一下,三句话:
-
session 是一路呼叫的容器。 它把信令(channel)、媒体(media_handle)、内存(pool)、执行流(thread)、通信信道(queue)全部收进一个结构体,是 FreeSWITCH 进程内的一个“轻量进程”。它有完整的生老病死:
CS_NEW → … → CS_DESTROY,一台状态机串起呼叫的一生。 -
session 是并发的边界。 数据默认由自己的线程独占访问;外来线程要么
session_locate拿读锁快进快出,要么往消息队列投条子让它自己消化。这套“独占为主、访问收敛到明确入口”的设计,是它既高效又相对安全的根。 -
session 搬不动,是因为它和本机资源绑死了。 内存池是本进程裸指针、线程是本机调度实体、媒体是本机实时流。销毁时一句
destroy_memory_pool全回收的那份优雅,反过来就是它无法跨进程迁移的枷锁。真要做会话漂移,让客户端 endpoint 重连新服务器、把业务状态外置,比在服务端搬 session 靠谱得多。
上一篇看的是“整个进程为什么这么组织”,这一篇看的是“进程里每一路呼叫这个原子单元长什么样、活多久、边界在哪”。两篇连起来,FreeSWITCH 的骨架就露出来了:一个进程,里面跑着一群绑死了内存和线程的 session,彼此靠锁和消息小心地打交道。
下一篇,想接着往媒体那一层钻:CS_EXCHANGE_MEDIA 状态里,一帧音频从 RTP 包进来、到被另一路呼叫听见,中间到底经过了哪些手?media bug、codec、jitter buffer 是怎么串在这条流水线上的?
全文思维导图
@startmindmap
<style>
mindmapDiagram {
node {
BackgroundColor #F8F9FA
RoundCorner 10
Padding 10
FontSize 13
}
:depth(0) {
BackgroundColor #1E3A5F
FontColor white
FontSize 18
FontStyle bold
}
:depth(1) {
FontSize 15
FontStyle bold
}
:depth(2) {
FontSize 13
}
}
</style>
* FreeSWITCH session:\n一次呼叫的一生
** session 装了什么
*** 内存池 = 地址空间
*** 专属线程 = 执行流
*** 队列 = 通信信道
*** 进程内的"轻量进程"
** 生命周期
*** CS_NEW / CS_INIT 出生
*** CS_ROUTING 找路
*** CS_EXECUTE 干活
*** CS_EXCHANGE_MEDIA 通媒体
*** CS_HANGUP / REPORTING / DESTROY
** 私有 vs 共享
*** 默认线程独占,基本无锁
*** session_locate 拿读锁
*** message_queue 投条子(actor 雏形)
** 和内存池绑死
*** 万物从 session->pool 分配
*** destroy_memory_pool 一句回收
*** 优雅的代价:钉死在本进程
** 为什么搬不动
*** 内存池是本进程裸指针
*** 线程是本机调度实体
*** 媒体是本机实时流
** 会话漂移怎么做
*** 让 endpoint 重连新服务器
*** 难点在媒体不断
*** 难点在状态外置接续
@endmindmap

本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。