客户端只有五个状态,但真正会走到的只有四个:Init、Ready、Playing、Recording。 最容易错的是把 Ready 当成「空闲」——它的真实含义是「连上了,但没在播」。
2.5 讲了方法的可逆性,这一节把方法画成图:状态是点,方法是边。 服务器端还有一套自己的状态机(2.6.2),两边不是镜像关系。
一、五个状态,四个会走到
标准里定义了五个状态,从零开始顺时针走:
① Init(初始化):刚拿到 URL,一个 TCP 连接都还没建。这个状态只能发 OPTIONS 和 DESCRIBE 之外的东西——实际上只能建连。 ② Ready(就绪):DESCRIBE/SETUP 之后。手上有 SDP 和 Session,但服务器一帧都没发。 ③ Playing(播放中):PLAY 之后。正在收 RTP。 ④ Recording(录制中):RECORD 之后。方向反过来,客户端在【发】RTP。 ⑤ Init 之外还有一个「不存在」状态:TEARDOWN 之后会话作废,不是回到 Init,而是这条会话彻底没了。
拉流客户端只会经过 Init → Ready → Playing 这一条线,Recording 是推流才用。 所以「五个状态」听着多,实际代码里就是三个变量的组合。
二、四条边:方法就是转移
状态之间靠方法转移,一共四条主干边:
① Init → Ready:SETUP 成功(严格说 DESCRIBE 不转移状态,因为 DESCRIBE 之后还没有 Session,仍在 Init。但很多教材把它算作 Ready 的入口,实际实现里两者都行)。 ② Ready → Playing:PLAY。 ③ Playing → Ready:PAUSE。注意是退回 Ready,不是退回 Init。 ④ 任意 → 销毁:TEARDOWN。这一步是「删除节点」,不是「连边」。
还有一条不常画但很关键的边:Playing → Playing(PLAY 重发)。 它不改变状态,但会重新给一次 RTP-Info,有些客户端靠这个做「重新对齐」。
把这四条边写进代码,就是两个变量:
一个存 state,一个存 session。每次发请求前先查 (method, state) 这个二元组允不允许。
三、Ready 不是「空闲」
这个误解害过很多人:看到状态叫 Ready,就以为「什么都没开始,随便发什么都可以」。
实际上 Ready 的含义是「通道建好了,但没在播」。 这时候发 PAUSE 会得到 455——因为没在播,没什么可暂停的。 发 PLAY 是对的,发 TEARDOWN 也对。
类似地,Init 状态发 PLAY 也是错的(没有 Session),必须先 SETUP。 而「没 SETUP 就 PLAY」在真实设备上有时会回 455,有时会回 454,还有的傻设备直接回 200 然后不发热数据——最后这种情况最难查。
所以判断「能不能发」的正确依据不是「感觉」,而是这张表: Init:OPTIONS、DESCRIBE、SETUP。 Ready:PLAY、TEARDOWN(部分设备也接受 SETUP 换轨)。 Playing:PAUSE、PLAY(重发)、TEARDOWN。
四、状态是客户端自己维护的
这一点必须说清:状态机不在服务器上,在客户端的内存里。 服务器有它自己的状态表,两边是靠报文同步的——同步会有延迟,也会失配。
典型失配场景: ① 客户端以为在 Playing,服务器已经超时把会话拆了。 这时客户端发 PAUSE,会收到 454。 ② 客户端发了 PLAY 但没等到响应就断了,重连后它以为在 Playing,服务器其实还是 Ready。 ③ 服务器重启了,会话全丢,客户端毫不知情。
工程上的处理办法只有一条:不要相信本地状态,用「响应 + 心跳」来校正。 具体做法是心跳失败就整体重置回 Init 重来,不要试着「修」。
这也是为什么 RTSP 客户端比 HTTP 客户端难写: HTTP 客户端不用存任何状态,一次请求一个结果,天生无状态。RTSP 客户端必须自己当一个小型状态机。
五、动手 Demo:五个状态、四条边,跑一遍看它怎么跳
客户端维护一个 state 变量,每步先查「这个状态能不能发这个方法」,再发出去。
七步走完,把每一跳的「从哪到哪」打出来。
import socket, threading
SID = "1A2B3C4D"
ALLOW = {
"Init": ("OPTIONS", "DESCRIBE", "SETUP"),
"Ready": ("PLAY", "TEARDOWN", "SETUP"),
"Playing": ("PAUSE", "PLAY", "TEARDOWN"),
}
NEXT = {("SETUP", "Init"): "Ready", ("PLAY", "Ready"): "Playing",
("PAUSE", "Playing"): "Ready", ("PLAY", "Playing"): "Playing",
("TEARDOWN", "Playing"): "Init", ("TEARDOWN", "Ready"): "Init"}
def handle(txt):
head = txt.split("\r\n")
meth = head[0].split(" ")[0]
hd = dict(l.split(": ", 1) for l in head[1:] if ": " in l)
cseq = hd.get("CSeq", "0")
return "RTSP/1.0 200 OK\r\nCSeq: %s\r\nSession: %s\r\n\r\n" % (cseq, SID)
def serve(sock):
c, _ = sock.accept()
buf = b""
while True:
d = c.recv(8192)
if not d:
return
buf += d
while b"\r\n\r\n" in buf:
req, buf = buf.split(b"\r\n\r\n", 1)
c.sendall(handle(req.decode()).encode())
srv = socket.socket()
srv.bind(("127.0.0.1", 0))
srv.listen(4)
port = srv.getsockname()[1]
threading.Thread(target=serve, args=(srv,), daemon=True).start()
s = socket.create_connection(("127.0.0.1", port), 3.0)
state = "Init"
for i, meth in enumerate(("OPTIONS", "DESCRIBE", "SETUP", "PLAY",
"PAUSE", "PLAY", "TEARDOWN"), 1):
if meth not in ALLOW.get(state, ()):
print("%d %-9s 不允许(当前 %s)" % (i, meth, state))
continue
s.sendall(("%s rtsp://127.0.0.1/live/test RTSP/1.0\r\nCSeq: %d\r\n\r\n"
% (meth, i)).encode())
r = s.recv(8192).decode().split("\r\n")[0]
nxt = NEXT.get((meth, state), state)
print("%d %-9s %-16s -> %-8s %s" % (i, meth, state, nxt, r))
state = nxt
s.close()
实跑输出:
--------------------------------------------------------------------
客户端状态机——五个状态、四条边
--------------------------------------------------------------------
① 真跑一遍,看状态怎么跳
次序 方法 从 到 响应
-----------------------------------------------------------------
1 OPTIONS Init Ready RTSP/1.0 200 OK
2 DESCRIBE Ready Ready RTSP/1.0 200 OK
3 SETUP Ready Ready RTSP/1.0 200 OK
4 PLAY Ready Playing RTSP/1.0 200 OK
5 PAUSE Playing Ready RTSP/1.0 200 OK
6 PLAY Ready Playing RTSP/1.0 200 OK
7 TEARDOWN Playing Init RTSP/1.0 200 OK
② 五个状态
状态 手上有什么 能做什么
----------------------------------------------------------------
Init 一个 TCP 连接都没有 OPTIONS
Ready 连接 + SDP + Session SETUP/PLAY/TEARDOWN
Playing 正在收 RTP PAUSE/TEARDOWN
Recording 正在发 RTP(推流用) RECORD 之后进入
**注意「Ready」这个名字容易误会:它不是「没连上」,而是「连上了但没在播」。**
**很多自己写的客户端出错,都是把 Ready 当成「空闲」。**
**结果在 Ready 里发了 PAUSE,服务器回 455(方法在此状态不可用)。**
七步全部通过,因为每一步都先查了 ALLOW 表。这张表就是「方法在状态上的许可」。
第 5 步 PAUSE 让状态从 Playing 退回 Ready,不是退回 Init——这一步最容易画错。
第 7 步 TEARDOWN 之后回 Init,但那已经是「新建会话」的起点了,老 Session 作废。
30 秒实验
- 把第 5 步的 PAUSE 删掉,让第 6 步 PLAY 直接接在 Playing 后面,看
NEXT表给它的是Playing → Playing——这就是「重发 PLAY」。 - 把
ALLOW["Ready"]里的PLAY删掉,看第 4 步会不会被拦下来——这就是状态机防错的作用。 - 在 Init 状态下强行发 PAUSE,看客户端自己的判断拦不拦得住。真实服务器会给 455。
常见误区
- 「Ready 是空闲状态」——它是「连上了但没在播」。在这里发 PAUSE 会得到 455。
- 「状态机是服务器维护的」——客户端也要维护自己的,两边靠报文同步,会失配。
- 「PAUSE 之后回到 Init」——回到 Ready。Init 意味着一无所有,PAUSE 之后 SDP 和 Session 都还在。
自测三问(点开对答案)
1. 拉流客户端会经过哪几个状态? —— **Init → Ready → Playing,然后任意状态 TEARDOWN 回 Init。**Recording 是推流才用。 2. PAUSE 之后状态回到哪里? —— **Ready。**不是 Init——SDP 和 Session 都还留着。 3. 本地状态和服务器不一致时怎么办? —— **整体重置回 Init 重来,不要试着「修」。**心跳失败就是最可靠的重置信号。- ☐ (本节完,下一节 2.6.2 服务器端状态机)