【音视频流媒体进阶:从网络到 WebRTC】第01篇-Socket 编程基础:TCP 与 UDP 的选择

Socket 编程基础:TCP 与 UDP 的选择

前言

想象一个直播场景:主播的摄像头每秒采集 30 帧画面,麦克风不断产出音频采样,这些数据经过编码压缩后,需要在毫秒级的时间窗口内穿越网络,到达成千上万个观众的播放器。或者一个视频通话场景:两端的音视频数据必须双向实时流动,任何超过 200ms 的延迟都会让对话变得别扭。

在这些场景背后,无论你用 FFmpeg 推流、用 WebRTC 建立 P2P 连接,还是用 RTMP 向 CDN 分发内容,最终都绕不开一个基础设施------Socket。它是应用程序与网络协议栈之间的接口,是所有网络通信的起点。

本文将从 Socket 编程模型出发,分别深入 TCP 和 UDP 的编程实践,并结合流媒体的实际需求,帮助你理解在不同场景下如何做出正确的协议选择。


1. Socket 编程模型概述

BSD Socket API

Socket 最早由 BSD Unix 在 1983 年引入,至今仍是几乎所有操作系统网络编程的标准接口。它的核心思想是将网络通信抽象为类似文件 I/O 的操作------创建一个"端点"(socket),然后通过这个端点读写数据。

一个 Socket 由五元组唯一标识:协议、本地地址、本地端口、远端地址、远端端口

两种编程模型

根据传输层协议的不同,Socket 编程分为两种基本模型:

面向连接(TCP) :通信前需要建立连接,数据按序到达,丢包自动重传。编程流程较长,涉及 listenacceptconnect 等步骤。

无连接(UDP) :不需要建立连接,直接发送数据报。每个数据报独立路由,可能乱序、丢失。编程流程简洁,核心就是 sendtorecvfrom

两种模型没有绝对的优劣,选择取决于应用场景------这正是流媒体开发中需要反复权衡的问题。


2. TCP Socket 编程详解

三次握手与四次挥手

TCP 在传输数据前必须建立连接,这个过程称为三次握手:

  1. SYN :客户端发送 SYN 报文,携带初始序列号 seq=x
  2. SYN+ACK :服务端回复 SYN+ACK,携带自己的序列号 seq=y 和确认号 ack=x+1
  3. ACK :客户端发送 ACK,确认号 ack=y+1,连接建立

断开连接则需要四次挥手,因为 TCP 是全双工的,每个方向的关闭需要独立确认。

核心 API 流程

TCP 服务端的典型调用顺序:

复制代码
socket() → bind() → listen() → accept() → recv()/send() → close()

TCP 客户端的典型调用顺序:

复制代码
socket() → connect() → send()/recv() → close()

实战代码:TcpServer 类

下面封装一个支持多客户端连接的 TCP 服务端,使用 std::thread 处理并发:

cpp 复制代码
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

#include <cstring>
#include <functional>
#include <iostream>
#include <string>
#include <thread>
#include <vector>
#include <atomic>

class TcpServer {
public:
    using ClientHandler = std::function<void(int client_fd, const std::string& client_addr)>;

    explicit TcpServer(uint16_t port, ClientHandler handler)
        : port_(port), handler_(std::move(handler)) {}

    ~TcpServer() { Stop(); }

    bool Start() {
        listen_fd_ = socket(AF_INET, SOCK_STREAM, 0);
        if (listen_fd_ < 0) {
            perror("socket");
            return false;
        }

        int opt = 1;
        setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

        sockaddr_in addr{};
        addr.sin_family = AF_INET;
        addr.sin_addr.s_addr = INADDR_ANY;
        addr.sin_port = htons(port_);

        if (bind(listen_fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
            perror("bind");
            return false;
        }

        if (listen(listen_fd_, 128) < 0) {
            perror("listen");
            return false;
        }

        running_ = true;
        accept_thread_ = std::thread(&TcpServer::AcceptLoop, this);
        std::cout << "TcpServer listening on port " << port_ << std::endl;
        return true;
    }

    void Stop() {
        running_ = false;
        if (listen_fd_ >= 0) {
            shutdown(listen_fd_, SHUT_RDWR);
            close(listen_fd_);
            listen_fd_ = -1;
        }
        if (accept_thread_.joinable()) accept_thread_.join();
        for (auto& t : client_threads_) {
            if (t.joinable()) t.join();
        }
    }

private:
    void AcceptLoop() {
        while (running_) {
            sockaddr_in client_addr{};
            socklen_t addr_len = sizeof(client_addr);
            int client_fd = accept(listen_fd_,
                                   reinterpret_cast<sockaddr*>(&client_addr), &addr_len);
            if (client_fd < 0) {
                if (running_) perror("accept");
                break;
            }

            std::string addr_str = std::string(inet_ntoa(client_addr.sin_addr))
                                   + ":" + std::to_string(ntohs(client_addr.sin_port));
            std::cout << "New connection from " << addr_str << std::endl;

            client_threads_.emplace_back([this, client_fd, addr_str]() {
                handler_(client_fd, addr_str);
                close(client_fd);
            });
        }
    }

    uint16_t port_;
    ClientHandler handler_;
    int listen_fd_ = -1;
    std::atomic<bool> running_{false};
    std::thread accept_thread_;
    std::vector<std::thread> client_threads_;
};

使用方式非常直观:

cpp 复制代码
int main() {
    TcpServer server(8080, [](int fd, const std::string& addr) {
        char buf[4096];
        while (true) {
            ssize_t n = recv(fd, buf, sizeof(buf), 0);
            if (n <= 0) break;
            // 回显收到的数据
            send(fd, buf, n, 0);
        }
        std::cout << "Client " << addr << " disconnected" << std::endl;
    });

    server.Start();

    std::cout << "Press Enter to stop..." << std::endl;
    std::cin.get();
    server.Stop();
    return 0;
}

TCP 在流媒体场景中的表现

TCP 提供了有序、可靠的字节流传输,这对流媒体意味着:

优势:数据完整无丢失,天然适合需要完整性保障的协议(如 RTMP、HLS/HTTP)。

劣势 :当网络出现丢包时,TCP 的重传机制会导致队头阻塞(Head-of-Line Blocking)------后续数据必须等待丢失的包重传完成才能交付给应用层。对于实时音视频来说,一个已经过时的帧被重传回来,不如直接丢弃。此外,TCP 的拥塞控制算法在检测到丢包时会主动降速,这可能导致直播画面突然模糊(编码器被迫降低码率)。


3. UDP Socket 编程详解

无连接特性

UDP 是一种"发后即忘"的协议。它不维护连接状态,不保证送达,不保证顺序,也不做拥塞控制。每个 UDP 数据报都是独立的,头部开销只有 8 字节(TCP 是 20 字节起)。

这些"缺点"在流媒体场景中反而是优点------应用层可以根据自己的需求定制可靠性策略,而不被传输层的固有机制所束缚。

核心 API 流程

UDP 的编程模型要简单得多:

复制代码
socket() → bind() → recvfrom()/sendto() → close()

注意 UDP 没有 listenacceptconnect(虽然 UDP 也可以调用 connect,但语义不同,只是绑定默认目标地址)。

实战代码:UdpSocket 类

cpp 复制代码
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

#include <cstring>
#include <iostream>
#include <string>

class UdpSocket {
public:
    UdpSocket() = default;
    ~UdpSocket() { Close(); }

    UdpSocket(const UdpSocket&) = delete;
    UdpSocket& operator=(const UdpSocket&) = delete;

    bool Bind(uint16_t port) {
        fd_ = socket(AF_INET, SOCK_DGRAM, 0);
        if (fd_ < 0) {
            perror("socket");
            return false;
        }

        int opt = 1;
        setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

        sockaddr_in addr{};
        addr.sin_family = AF_INET;
        addr.sin_addr.s_addr = INADDR_ANY;
        addr.sin_port = htons(port);

        if (bind(fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
            perror("bind");
            return false;
        }
        return true;
    }

    ssize_t SendTo(const void* data, size_t len,
                   const std::string& ip, uint16_t port) {
        sockaddr_in dest{};
        dest.sin_family = AF_INET;
        dest.sin_port = htons(port);
        inet_pton(AF_INET, ip.c_str(), &dest.sin_addr);
        return sendto(fd_, data, len, 0,
                      reinterpret_cast<sockaddr*>(&dest), sizeof(dest));
    }

    ssize_t RecvFrom(void* buf, size_t len,
                     std::string& src_ip, uint16_t& src_port) {
        sockaddr_in src{};
        socklen_t addr_len = sizeof(src);
        ssize_t n = recvfrom(fd_, buf, len, 0,
                             reinterpret_cast<sockaddr*>(&src), &addr_len);
        if (n > 0) {
            char ip_buf[INET_ADDRSTRLEN];
            inet_ntop(AF_INET, &src.sin_addr, ip_buf, sizeof(ip_buf));
            src_ip = ip_buf;
            src_port = ntohs(src.sin_port);
        }
        return n;
    }

    void Close() {
        if (fd_ >= 0) {
            close(fd_);
            fd_ = -1;
        }
    }

    int fd() const { return fd_; }

private:
    int fd_ = -1;
};

一个简单的 UDP 回显服务端:

cpp 复制代码
int main() {
    UdpSocket sock;
    if (!sock.Bind(9000)) return 1;

    std::cout << "UDP server listening on port 9000" << std::endl;

    char buf[65536];
    std::string src_ip;
    uint16_t src_port;

    while (true) {
        ssize_t n = sock.RecvFrom(buf, sizeof(buf), src_ip, src_port);
        if (n <= 0) break;

        std::cout << "Received " << n << " bytes from "
                  << src_ip << ":" << src_port << std::endl;

        sock.SendTo(buf, n, src_ip, src_port);
    }
    return 0;
}

UDP 在流媒体场景中的表现

优势:没有队头阻塞,丢包不会拖累后续数据的交付。延迟可控,适合实时通信场景。应用层可以自己决定哪些包值得重传(如关键帧),哪些直接丢弃(如过期的 P 帧)。

劣势:所有的可靠性保障都需要应用层自己实现------序列号、乱序重组、丢包检测、重传策略、拥塞控制,一个都不能少。此外,UDP 包大小受 MTU 限制(通常 1500 字节),大帧需要应用层分片和重组。


4. 流媒体场景下 TCP vs UDP 的选择

多维度对比

维度 TCP UDP
延迟 握手增加 1-RTT 开销;队头阻塞可能导致突发延迟 无握手开销;无队头阻塞
可靠性 协议栈保证有序可靠 应用层自行保障
拥塞控制 内置(慢启动、AIMD),不可控 无内置机制,灵活但需自行实现
NAT 穿越 困难(需要中继服务器) 相对容易(STUN/TURN/ICE)
头部开销 20+ 字节 8 字节
适用场景 点播、文件传输、信令 实时通话、低延迟直播

实际协议的选择

流媒体领域中,各协议的传输层选择都有其工程考量:

RTMP(TCP):Adobe 设计的直播推流协议。选择 TCP 是因为推流端到 CDN 之间的链路通常较好,可靠传输比低延迟更重要。代价是在弱网下延迟会累积。

RTP/RTCP(UDP):IETF 为实时媒体设计的传输协议。RTP 承载媒体数据,RTCP 负责质量反馈。选择 UDP 是为了获得最低延迟,丢包由应用层处理(NACK 重传或 FEC 前向纠错)。

WebRTC(UDP + DTLS + SRTP):Google 主导的实时通信框架。底层走 UDP,用 DTLS 做密钥协商,用 SRTP 加密媒体数据。通过 ICE 框架实现 NAT 穿越,是目前端到端实时通信的主流方案。

SRT(UDP):Haivision 开源的安全可靠传输协议。在 UDP 之上实现 ARQ 重传和自适应拥塞控制,专为不稳定网络上的低延迟直播设计。

QUIC/HTTP3(UDP):新一代传输协议,在 UDP 之上实现了多路复用和独立流控制,解决了 TCP 的队头阻塞问题。LL-HLS 等低延迟协议正在向 HTTP/3 迁移。

决策参考表

场景 推荐协议 传输层 理由
CDN 推流 RTMP / SRT TCP / UDP 链路可控,RTMP 生态成熟;弱网场景优先 SRT
CDN 拉流(点播) HLS / DASH TCP (HTTP) 兼容性好,CDN 友好
低延迟直播 WebRTC / SRT UDP 延迟敏感,允许少量丢包
视频通话 WebRTC UDP 双向实时,P2P 优先
信令传输 WebSocket / HTTP TCP 可靠性优先,数据量小
监控/安防 RTSP + RTP UDP (媒体) + TCP (信令) 经典方案,设备兼容性好

5. 踩坑与调优

常见 Socket 编程陷阱

SIGPIPE 信号

向一个已关闭的 TCP 连接写数据,内核会发送 SIGPIPE 信号,默认行为是终止进程。流媒体服务端一个客户端断开就导致整个进程崩溃,这是经典的线上事故。

解决方案:

cpp 复制代码
// 方案一:全局忽略 SIGPIPE
signal(SIGPIPE, SIG_IGN);

// 方案二:send 时指定 MSG_NOSIGNAL 标志(Linux)
send(fd, buf, len, MSG_NOSIGNAL);

// 方案三:使用 SO_NOSIGPIPE 选项(macOS)
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &opt, sizeof(opt));

TIME_WAIT 状态

TCP 主动关闭方会进入 TIME_WAIT 状态,默认持续 2MSL(通常 60 秒)。如果流媒体服务端频繁重启,会发现端口被占用无法绑定。

cpp 复制代码
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

SO_REUSEADDR 允许绑定处于 TIME_WAIT 状态的地址,是服务端 Socket 的标准配置。

send/recv 的短读短写

TCP 是字节流协议,一次 send(1000) 可能只发出 500 字节,一次 recv 可能收到半个消息或者一个半消息。必须在应用层处理消息边界:

cpp 复制代码
bool SendAll(int fd, const void* data, size_t len) {
    const char* p = static_cast<const char*>(data);
    size_t remaining = len;
    while (remaining > 0) {
        ssize_t n = send(fd, p, remaining, MSG_NOSIGNAL);
        if (n <= 0) return false;
        p += n;
        remaining -= n;
    }
    return true;
}

UDP 数据报截断

UDP 的 recvfrom 如果提供的缓冲区小于实际数据报大小,多余的部分会被静默丢弃。接收缓冲区大小要根据实际最大报文大小来设置,RTP 场景下通常 2048 字节足够(MTU 限制下单个 RTP 包一般不超过 1400 字节)。

流媒体场景的特殊注意事项

Socket 缓冲区大小调优

流媒体的数据量远大于普通 Web 应用。系统默认的 Socket 收发缓冲区(通常 128KB ~ 256KB)在高码率场景下可能成为瓶颈:

cpp 复制代码
int buf_size = 2 * 1024 * 1024; // 2MB
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &buf_size, sizeof(buf_size));
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &buf_size, sizeof(buf_size));

注意 Linux 内核会将设置值翻倍(内核需要额外空间存储元数据),可以通过 getsockopt 验证实际值。如果需要设置超过系统上限的缓冲区大小,需要调整 /proc/sys/net/core/rmem_max/proc/sys/net/core/wmem_max

TCP_NODELAY

TCP 默认启用 Nagle 算法,会将小包聚合后再发送,这会给流媒体信令带来额外延迟。对于交互性要求高的连接,应当禁用:

cpp 复制代码
#include <netinet/tcp.h>
int opt = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));

非阻塞模式

生产环境的流媒体服务不会使用阻塞 Socket,因为一个卡住的客户端会拖累整个服务。通常会将 Socket 设置为非阻塞模式,配合 I/O 多路复用(epoll/kqueue)使用:

cpp 复制代码
#include <fcntl.h>

int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

非阻塞模式下 send/recv 在无法立即完成时会返回 -1 并设置 errno = EAGAIN,这不是错误,而是需要等待 I/O 就绪后重试。这部分内容将在下一篇文章中展开。


总结

本文从 Socket 编程模型出发,分别介绍了 TCP 和 UDP 两种传输协议的编程实践。核心要点回顾:

  • Socket 是网络编程的基础抽象,理解它的 API 和行为是构建流媒体系统的前提
  • TCP 提供有序可靠的字节流,适合对完整性要求高、对延迟容忍度较大的场景(点播、推流、信令)
  • UDP 提供轻量的数据报服务,适合对延迟敏感、允许应用层自定义可靠性的场景(实时通话、低延迟直播)
  • 流媒体协议的传输层选择是工程权衡的结果,没有银弹
  • Socket 编程中的细节(SIGPIPE、短读短写、缓冲区调优、Nagle 算法)往往是线上问题的根源

掌握了 Socket 基础之后,你会发现单线程逐个处理连接的方式无法应对高并发的流媒体服务。下一篇文章我们将进入 I/O 多路复用 的世界,探讨 selectpollepoll 的原理与实践,看看如何用一个线程高效管理成千上万个连接。

相关推荐
刘佬GEO2 小时前
GEO 黑帽和正常优化的边界拆解:哪些是优化,哪些是风险操作?
网络·人工智能·搜索引擎·ai·语言模型
踏着七彩祥云的小丑2 小时前
Linux命令——开机自启配置
linux·运维·网络
ZStack开发者社区2 小时前
ZSTACK · 答客问 | 高频问题合集
前端·网络·php
是小崔啊2 小时前
网络安全小白了解
网络·安全·web安全
江畔何人初3 小时前
OSI七层参考模型
网络
振浩微433射频芯片11 小时前
433MHz在智能家居中的应用大全(二):智能安防篇——安全不容“信号死角”
网络·单片机·嵌入式硬件·物联网·智能家居
fengfuyao98512 小时前
基于STM32的4轴步进电机加减速控制工程源码(梯形加减速算法)
网络·stm32·算法
瀚高PG实验室14 小时前
审计策略修改
网络·数据库·瀚高数据库
forAllforMe14 小时前
etherCAT的协议VoE,FoE,EoE,CoE的概念和区别
网络