🎬 第九章 EasyDarwin搭建直播平台
📄 一句话概括

EasyDarwin 不是「一个程序干所有事」,而是「一个主程序加一张服务表」。 它最在意的不是快,是「坏一块只坏一块」。

📝 本节和前面几节的关系

7.4 讲的是它的来历与商业版差别,这一节拆它的骨架。 9.1.2 讲模块怎么分类,9.2 讲怎么把它装起来。

一、它从哪来

EasyDarwin 的骨架来自苹果开源的 Darwin Streaming Server。 那套东西当年是用来替代 QuickTime 流媒体服务器的, 后来开源,成了不少人搭 RTSP 服务的起点。

国内这一支把它重写成了 Go 版本, 定位也从「纯 RTSP 服务器」扩成「多协议接入与分发平台」。 所以你在不同年份的教程里会看到两套完全不同的编译方式——一套是 C++ 的 ./configure && make,一套是 Go 的 go build。 这不是谁写错了,是两个不同的代码库。

这一点在动手前必须搞清楚: 先看你要装的那个仓库 README 里写的语言, 再决定按哪套流程走。 两套流程的配置文件、端口、启动方式全不一样。

二、分层与服务表

主程序只干三件事:读配置、建表、按表拉起服务。 具体协议怎么处理,全在服务里。

① 主程序:读配置、建服务表、依次拉起。 它自己不懂任何协议。 失败就是直接退出——配置都读不对,后面没意义。 ② 服务层:RTSP、RTMP、HTTP 各是一个服务。 每个服务自己占端口、自己起协程。 某一个端口被占,只有那一个协议不可用,别的照跑。 ③ 处理层:解复用、转发、录制。 它是「流进得来出不去」的那一层, 也是唯一会成倍放大数据的地方。 ④ 存储层:录像文件与配置持久化。 坏了只影响录像,不影响直播。

这种「服务表」结构的价值在隔离: 一个协议起不来,不至于让整个进程退出。 所以框架设计的第一原则不是「快」,是「坏一块只坏一块」。

三、启动与退出的次序

启动按注册顺序,退出按相反顺序。 最后起来的那个最先关——因为它最可能依赖别人。

这个规则不是讲究,是必须: 如果先关掉「处理层」再关「接入层」, 接入层还在收包,就会往一个已经不存在的地方送,直接报错。 逆序关就没有这个问题。

判断有没有退干净,有个硬指标:线程或协程数回到启动前的值。 只看「进程还在不在」是不够的—— 进程退了但端口没释放、临时文件没删,下次启动就起不来。 这类问题在重启脚本里特别常见,表现是「第一次能起,第二次起不来」。

还有一个更隐蔽的: 退出时如果某个协程卡在阻塞读上, 整个进程会一直等着,看起来像「关不掉」。 所以生产上的关闭流程都带一个超时上限,超时就强杀。

四、和 Live555 是两种极端

把这两个放一起看,架构选择的差别特别清楚:

① Live555:单线程、全靠回调、没有一个锁。 优点是行为可预测,断点一打就知道走到哪; 缺点是一个慢操作拖住所有会话。 ② EasyDarwin:多协程、写法像同步、调度交给运行时。 优点是代码读起来像顺序执行,好写; 缺点是协程泄漏了不容易发现。

所以两边排障的手感完全不同: Live555 卡住了,去看是哪个回调里做了耗时的事; EasyDarwin 卡住了,去看协程数是不是一直涨。 知道这一点,抓问题能省很多时间。

五、动手 Demo:主体框架——一个进程、一张服务表、一个事件循环

真起四个服务,各自占一个端口、起一个线程, 再逆序关掉,最后数线程看有没有漏。

import socket, threading, time

class Svc(object):
    def __init__(self, name):
        self.name, self.sock, self.th, self.run = name, None, None, False

    def start(self):
        self.sock = socket.socket()
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.sock.bind(("127.0.0.1", 0))
        self.sock.listen(4)
        self.sock.settimeout(0.2)
        self.run = True
        self.th = threading.Thread(target=self.loop)
        self.th.start()

    def loop(self):
        while self.run:
            try:
                self.sock.accept()[0].close()
            except socket.timeout:
                continue
            except OSError:
                break

    def stop(self):
        self.run = False
        self.th.join()
        self.sock.close()

base = threading.active_count()
svcs = [Svc(n) for n in ("rtsp", "rtmp", "http", "record")]

t0 = time.perf_counter()
for s in svcs:
    s.start()
print("启动四个服务 %.2f 毫秒" % ((time.perf_counter() - t0) * 1e3))
print("线程数 %d -> %d" % (base, threading.active_count()))

t0 = time.perf_counter()
for s in reversed(svcs):
    s.stop()
print("关闭四个服务 %.2f 毫秒" % ((time.perf_counter() - t0) * 1e3))
print("关完之后线程数 %d,退干净了吗 %s"
      % (threading.active_count(),
         threading.active_count() == base))

实跑输出:

--------------------------------------------------------------------
主体框架——一个进程、一张服务表、一个事件循环
--------------------------------------------------------------------
**① EasyDarwin 的骨架长什么样**

  **它不是一个「程序干所有事」,而是「一个主程序 + 一组服务」。**
  **每个服务在启动时把自己注册进一张表,主程序按表依次拉起。**
  **退出时按相反的方向关,谁最后起来谁最先关。**

  层         干什么                            失败会怎样
  -----------------------------------------------------------------------
  主程序     读配置、建服务表、拉起服务        直接退出,什么都不启动
  服务层     RTSP、RTMP、HTTP 各是一个服务     该协议不可用,别的照跑
  处理层     解复用、转发、录制                流进得来出不去
  存储层     录像文件、配置持久化              只影响录像,不影响直播

  **这种结构的价值在「局部失败不拖垮全局」:**
  **RTMP 端口被占了,RTSP 照样能用,业务不至于全断。**
  **所以框架设计的第一原则不是「快」,是「坏一块只坏一块」。**

**② 真跑一遍:注册四个服务,按序启动,逆序关闭**

  **四个服务各自真占一个端口、真起一个线程。**
  **端口号是系统临时分配的,这里只看启动与关闭的时序。**
  **关的时候逐个 join,最后数一遍线程,看有没有漏。**

  序     服务       端口      启动毫秒     关闭毫秒
  -----------------------------------------------------
  1      rtsp       50179     0.59         188.70
  2      rtmp       50180     0.45         205.54
  3      http       50181     0.36         206.55
  4      record     50182     0.70         213.50

  项                 数值
  ----------------------------------
  启动四个服务       2.10 毫秒
  关闭四个服务       814.33 毫秒
  启动前的线程数     1
  跑起来后           5
  关完之后           1
  线程全退了吗       是

  **关闭比启动慢 387 倍,原因是 join 要等线程自己看到标志位。**
  **线程里那个 `accept` 有个 0.2 秒超时,**
  **所以最坏情况要等一个超时周期才退出。**
  **生产上这个「等」必须设上限,不然退出会卡住。**

  **线程数回到启动前的值,才叫真的退干净了。**
  **只看「进程还在不在」是不够的:**
  **进程退了但端口没释放、临时文件没删,下次启动就起不来。**

**③ 框架里那个「事件循环」到底在转什么**

  **上面每个服务的线程里都是一个 `accept` 循环,**
  **这就是最小的事件循环:等事件、处理、回去再等。**

  **EasyDarwin 用的是 Go 的 goroutine,形式不一样,道理一样:**
  **一个连接一个协程,读写都是阻塞写法,**
  **调度交给运行时,代码里看不到回调。**

  **这一点和 Live555 正好是两个极端:**
  **Live555 单线程、全靠回调、没有一个锁;**
  **EasyDarwin 多协程、写法像同步、调度交给运行时。**
  **前者的坑是「一个慢操作拖死全部」,后者的坑是「协程泄漏」。**

启动只要一两毫秒,关闭要几百毫秒,差别全在 join 上。 因为 accept 设了 0.2 秒超时,线程最坏要等一个周期才看到标志位。 线程数回到启动前的值,才叫真的退干净了。 这个检查很便宜,但能抓住「第二次起不来」那类问题。

30 秒实验

  1. 把 settimeout(0.2) 改成 0.02,看关闭快多少——快十倍,代价是空转轮询更频繁。
  2. 把关闭循环里的 reversed 去掉,想想会出什么事——先关接入层再关处理层,正在处理的请求会撞空。
  3. 故意让某个 stop 不 join,看线程数——比启动前多一个,这就是「泄漏」。

常见误区

  1. 「EasyDarwin 只有一个版本」——C++ 老版与 Go 新版是两套。
  2. 「关服务顺序无所谓」——必须逆序,否则会往不存在的地方送数据。
  3. 「进程退了就算退干净」——端口与线程可能还没释放。
自测三问(点开对答案) 1. 为什么退出要按注册的相反顺序? —— **后起的服务可能依赖先起的**,先关依赖方会撞空。 2. 判断退干净的硬指标是什么? —— **线程或协程数回到启动前的值**,光看进程不够。 3. Live555 与 EasyDarwin 的架构差别在哪? —— **单线程回调 对 多协程同步写法**,坑一个是拖死、一个是泄漏。

相关笔记