常用socket选项
概述
Socket选项通过setsockopt()/getsockopt()函数来设置和获取,用于控制套接字的行为- 参数
level:选项所在的协议层(如SOL_SOCKET、IPPROTO_TCP、IPPROTO_IP)optname:具体选项名optval:选项值的指针optlen:选项值长度
|
1 2 3 4 |
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen); int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen); |
SO_REUSEADDR
- 最常用的选项之一,允许绑定处于
TIME_WAIT状态的地址 - 服务器重启时经常需要它,否则会报 "
Address already in use"
|
1 2 |
int opt = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); |
SO_REUSEPORT
- 允许多个
socket绑定到同一个IP+端口,内核会做负载均衡(Linux 3.9+支持) - 常用于多进程/多线程服务器提升
accept性能
SO_KEEPALIVE
- 开启
TCP保活机制,定期发送探测包检测对端是否存活,避免连接假死 - 具体探测间隔可通过
TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT(IPPROTO_TCP层)调整
SO_LINGER
- 控制
close()时的行为l_onoff=0:默认行为,close立即返回,内核尽力发送剩余数据l_onoff=1, l_linger=0:close立即返回,丢弃未发送数据并发RST(常用于强制关闭)l_onoff=1, l_linger>0:close阻塞直到数据发送完或超时
|
1 2 3 4 |
struct linger { int l_onoff; // 是否开启 int l_linger; // 延迟时间(秒) }; |
SO_RCVBUF / SO_SNDBUF
- 设置接收/发送缓冲区大小,影响吞吐量,尤其在高延迟高带宽网络(长肥管道)中需要调大
SO_RCVTIMEO / SO_SNDTIMEO
- 设置阻塞式
recv/send的超时时间
|
1 2 |
struct timeval tv = {5, 0}; // 5秒 setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); |
SO_ERROR
- 获取并清除
socket上待处理的错误 - 常配合
select/epoll检测非阻塞connect是否成功
SO_BROADCAST
- 允许发送广播数据报(
UDP)
常用IPPROTO_TCP 层选项
TCP_NODELAY
- 禁用
Nagle算法,数据立即发送而不等待缓冲区填满或ACK - 对低延迟场景(如实时游戏、
RPC)很重要
|
1 2 |
int opt = 1; setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt)); |
TCP_KEEPIDLE / TCP_KEEPINTVL / TCP_KEEPCNT
- 配合
SO_KEEPALIVE使用,分别控制:空闲多久后开始探测、探测间隔、探测失败次数上限
TCP_CORK
- 与
TCP_NODELAY相反,将多次小写入合并成一个包发送(Linux特有),适合先写头部再写body的场景
TCP_FASTOPEN
- 支持
TFO,减少三次握手带来的一次RTT延迟
常用IPPROTO_IP 层选项
IP_TTL
- 设置
IP包的生存时间(跳数限制)
IP_MULTICAST_TTL / IP_ADD_MEMBERSHIP
- 用于多播场景,加入/离开多播组
一个典型服务器设置组合
|
1 2 3 4 |
int opt = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt)); setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt)); |
RTT (Round-Trip Time,往返时延)
定义
- 数据包从发送端出发,到达接收端,再收到接收端响应(如
ACK)所经历的总时间
|
1 2 3 |
发送端 --------数据包--------> 接收端 发送端 <--------ACK----------- 接收端 |<---------- RTT ---------->| |
RTT 的组成
- 传播时延:
- 信号在物理介质中传播的时间,由距离和介质决定(光纤中约
200,000 km/s) - 这部分有物理下限,无法通过技术手段消除(比如跨太平洋光缆传播时延就是几十毫秒起)
- 信号在物理介质中传播的时间,由距离和介质决定(光纤中约
- 传输时延:
- 把数据包"推"上链路所需的时间,取决于包大小和链路带宽
- 排队时延:
- 数据包在路由器/交换机队列里等待转发的时间,网络拥塞时这部分会显著增大
- 处理时延:
- 设备处理包头、路由查找等的时间,通常很小
|
1 |
RTT = 传播时延 × 2 + 传输时延 × 2 + 排队时延 + 处理时延 |
RTT 的测量与作用
TCP通过给每个包打时间戳、观察ACK返回时间来估算RTTRTT是计算RTO的基础,也是拥塞控制算法(如BBR)判断网络状况的关键信号RTT会动态波动(网络拥塞、路由变化),所以TCP/KCP都用移动平均而非单次采样值
RTO (Retransmission TimeOut,重传超时)
定义
- 发送端发出一个包后,如果在
RTO时间内没收到对应的确认(ACK),就认为这个包丢失,触发重传
为什么 RTO 不能是固定值
- 设太小:
- 网络稍有波动就误判丢包,触发不必要的重传,浪费带宽,甚至引发拥塞崩溃
- 设太大:
- 真丢包后要等很久才重传,浪费时间,影响实时性
- 所以
RTO必须根据实时RTT动态计算,这就是经典的Jacobson/Karels算法- 直觉理解:
srtt是RTT的"平滑估计值"(滤除瞬时抖动)rttvar反映RTT的波动程度,网络越不稳定这个值越大RTO= 平均RTT+ 4倍的抖动余量,网络越抖动,RTO留的安全边际越大
|
1 2 3 4 5 6 7 8 9 |
// 首次采样 srtt = rtt_sample; // 平滑RTT rttvar = rtt_sample / 2; // RTT方差 // 后续每次采样都更新(指数加权移动平均,EWMA) rttvar = (1 - beta) * rttvar + beta * abs(srtt - rtt_sample); // beta通常=0.25 srtt = (1 - alpha) * srtt + alpha * rtt_sample; // alpha通常=0.125 rto = srtt + max(clock_granularity, 4 * rttvar); |
RTO 超时后怎么办
TCP采用指数退避- 每次因超时重传后,
RTO翻倍(1s → 2s → 4s → ...),直到收到ACK或达到重传上限断开连接
- 每次因超时重传后,
KCP的关键区别在这里KCP认为很多场景(游戏)宁可承受一点冗余流量也要降低延迟,所以它的RTO增长策略更激进(可配置为线性增长而非指数翻倍)- 并且提供"快速重传"机制(不等
RTO,只要某个包被跳过确认达到阈值次数就立即重传),这是它比原生TCP更"快"的核心原因
TLS (Transport Layer Security,传输层安全协议)
作用
TLS跑在TCP之上(或者QUIC场景下集成在传输层里),为应用层数据提供加密、完整性校验、身份认证三大能力HTTPS本质就是HTTP + TLS
TLS 握手过程( TLS 1.3 为例)
TLS 1.3相比1.2最大的改进是握手轮次从2-RTT压缩到1-RTT1.2需要先协商加密套件再交换密钥,是两个来回1.3把这些合并到一次往返里
|
1 2 3 4 5 |
客户端 服务端 |-- ClientHello (支持的加密套件+随机数+密钥交换参数) -->| |<-- ServerHello + 证书 + 密钥交换参数 + Finished -----| |-- Finished ------------------------------------->| |============ 加密数据传输开始 =====================| |
完整连接建立的延迟叠加问题
- 这是理解
QUIC设计动机的关键
|
1 2 3 4 5 6 7 8 9 |
TCP + TLS 1.2 (传统HTTPS): TCP三次握手 (1 RTT) + TLS 1.2握手 (2 RTT) = 3 RTT 才能开始传数据 TCP + TLS 1.3: TCP三次握手 (1 RTT) + TLS 1.3握手 (1 RTT) = 2 RTT QUIC: QUIC 把传输层握手和 TLS 1.3 握手合并成一次交换 = 1 RTT 如果是曾经连接过的服务器(有缓存的会话信息),甚至能做到 0-RTT |
0-RTT 的原理
- 客户端如果之前和这个服务器建立过连接,会缓存一个叫
PSK (Pre-Shared Key)的密钥材料 - 下次连接时,客户端可以在第一个包里就直接带上加密的应用数据(不用等握手完成),服务端验证
PSK有效就能立即解密处理 - 代价:
0-RTT数据存在重放攻击风险(攻击者截获这个0-RTT包可以重放给服务器),所以只有"幂等"的请求(比如GET,不会因为重复执行造成副作用)才适合放在0-RTT里发送
为什么 QUIC 强制集成 TLS 1.3
- 这不是偶然设计,而是刻意为之:
QUIC从协议层面就不允许跑明文,传输层握手和加密握手合二为一,这样既减少了握手延迟,也从设计上消灭了"裸奔的QUIC连接"这种可能性- 对比
TCP,裸TCP是可以不加TLS明文跑的
队头阻塞
TCP 层面的队头阻塞
TCP是严格有序的字节流协议,接收端必须按序把数据交给应用层- 如果序号为
3的包丢了,即使序号4、5、6已经到达并且被接收端缓存,应用层也拿不到4、5、6的数据,必须死等3号包重传到达后才能一起交付:
- 如果序号为
|
1 2 3 4 5 6 |
发送方发出: [1][2][3][4][5][6] 接收方收到: [1][2] X [4][5][6] (3号丢失) 应用层视角: 只能读到 [1][2],[4][5][6]虽然已经在内核缓冲区里 但因为TCP保证顺序交付,必须等3号包重传到达后 才能把 [3][4][5][6] 一起交给应用层 |
- 这是传输层(
TCP自身)造成的阻塞
HTTP/1.1 层面的队头阻塞
HTTP/1.1单个TCP连接上,请求必须排队串行处理- 虽然有
pipelining技术允许连续发多个请求,但响应必须按请求顺序返回
- 虽然有
- 如果第一个请求处理慢,后面的请求即使已经处理完,也要排在它后面等着返回:
|
1 2 |
连接: [请求1(慢)] [请求2(快)] [请求3(快)] 响应: 必须等请求1处理完,才能依次返回响应1、2、3 |
- 浏览器的应对方案是
- 对同一个域名开多个
TCP连接(通常6个)绕开这个限制,但这带来了额外的连接建立开销和拥塞控制资源浪费
- 对同一个域名开多个
HTTP/2 层面:
- 解决了应用层阻塞,但引入了新的传输层阻塞
HTTP/2引入了多路复用(Multiplexing):- 单个
TCP连接上可以并行跑多个Stream,每个Stream独立编号,互不阻塞对方在应用层的处理顺序
- 单个
|
1 2 3 4 5 |
Stream 1: [帧1.1][帧1.2] Stream 2: [帧2.1][帧2.2] Stream 3: [帧3.1] ↓ (交错复用到同一个TCP连接里) TCP连接: [帧1.1][帧2.1][帧1.2][帧3.1][帧2.2]... |
- 这解决了
HTTP/1.1的应用层队头阻塞- 但所有
Stream的数据最终都是同一个TCP字节流的一部分 - 如果这个
TCP连接中间丢了一个包,属于该包位置的所有Stream数据都要等待重传,即使这个丢失的包只影响Stream 2
Stream 1和Stream 3的数据也会被这个TCP层面的顺序保证给拖住
- 但所有
|
1 2 3 4 5 |
TCP层收到: [帧1.1][帧2.1] X [帧3.1][帧2.2] ↑ 这个包丢了(假设是帧1.2) 即使 帧3.1、帧2.2 已经到达,TCP层也不会把它们交给上层的HTTP/2解复用逻辑, 必须等丢失的包重传到达——这时 Stream 2、3 也被 Stream 1 的丢包拖累了 |
- 这就是所谓"
HTTP/2把应用层队头阻塞问题转移到了传输层"
QUIC (HTTP/3) 如何真正解决
QUIC跑在UDP之上,每个Stream有自己独立的序号空间和独立的可靠性保证,不共享一个全局字节流顺序:- 因为底层是
UDP,一个QUIC包丢失只意味着它所携带的那些Stream帧需要重传,其他Stream的数据包该怎么到达就怎么到达,不会被没有关系的丢包连累 - 这是
QUIC相比HTTP/2-over-TCP的核心优势,也是队头阻塞问题在传输层第一次被真正消除,而不是"转移"
- 因为底层是
|
1 2 3 |
Stream 1: [帧1.1] X [帧1.2] (Stream1自己的1.2丢了,只影响Stream1) Stream 2: [帧2.1][帧2.2] (完全不受影响,正常交付) Stream 3: [帧3.1] (完全不受影响,正常交付) |
UDP
基本特性
UDP(User Datagram Protocol,用户数据报协议)是一种无连接的传输层协议,与TCP相对- 核心特点:
- 无连接:发送数据前不需要建立连接(没有三次握手)
- 不可靠:不保证数据到达、不保证顺序、不做重传
- 无拥塞控制:发送速率不受网络拥塞状况限制
- 面向报文:应用层交给
UDP多长的数据,UDP就照原样发送,不会拆分或合并 - 头部开销小:只有
8字节,比TCP的20字节小很多 - 支持一对一、一对多(多播)、广播
报文格式
- 源端口/目的端口:各
2字节 - 长度:
UDP首部+数据的总长度,最小值为8(只有首部无数据) - 校验和:可选(
IPv4中可以为0表示不校验,IPv6中必须计算),校验伪首部+UDP首部+数据
|
1 2 3 4 5 6 7 8 |
0 7 8 15 16 23 24 31 +--------+--------+--------+--------+ | 源端口 | 目的端口 | +--------+--------+--------+--------+ | 长度 | 校验和 | +--------+--------+--------+--------+ | 数据(可选) | +-----------------------------------+ |
UDP vs TCP 对比
| 特性 | UDP |
TCP |
| 连接方式 | 无连接 | 面向连接 |
| 可靠性 | 不可靠 | 可靠(确认、重传) |
| 顺序保证 | 不保证 | 保证 |
| 拥塞/流量控制 | 无 | 有 |
| 头部开销 | 8字节 |
20字节起 |
| 传输方式 | 面向报文 | 面向字节流 |
| 效率 | 高(少了握手和确认开销) | 相对低 |
| 适用场景 | 实时性要求高、可容忍丢包 | 要求可靠传输 |
适用场景
DNS查询- 一问一答,简单高效,重传交给应用层处理
- 视频/音频流、直播、
VoIP- 实时性比可靠性更重要,丢一帧比卡顿更能接受
- 在线游戏
- 低延迟优先,客户端会自己处理状态同步和丢包补偿
DHCP- 还没有
IP地址时无法建立TCP连接
- 还没有
SNMP- 网络管理协议
- 广播/多播场景
TCP天生不支持一对多
Socket 编程示例(Linux C)
- 服务端
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
int sockfd = socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in servaddr = {0}; servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = INADDR_ANY; servaddr.sin_port = htons(8888); bind(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)); char buf[1024]; struct sockaddr_in cliaddr; socklen_t len = sizeof(cliaddr); int n = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)&cliaddr, &len); sendto(sockfd, buf, n, 0, (struct sockaddr*)&cliaddr, len); |
- 客户端
|
1 2 3 4 5 6 7 8 9 |
int sockfd = socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in servaddr = {0}; servaddr.sin_family = AF_INET; servaddr.sin_port = htons(8888); inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr); sendto(sockfd, "hello", 5, 0, (struct sockaddr*)&servaddr, sizeof(servaddr)); |
丢包与乱序
UDP本身不处理,需要应用层自己实现(如加序号、超时重传机制)
报文大小限制
- 理论最大
65507字节(65535-8字节UDP头 -20字节IP头) - 实际受
MTU限制(以太网通常1500字节),超过会在IP层分片,分片会增加丢包概率(一片丢失整个报文就废了)- 所以实践中通常控制在
548字节以内(保证不分片)或应用层自己做分片重组
- 所以实践中通常控制在
- 为什么控制在
548字节以内?- 这个数字来源于一个历史标准:
IPv4规定所有主机都必须能够接收至少576字节的IP数据包,且不需要分片重组失败(RFC 791/RFC 1122)
|
1 2 3 4 |
576 字节(IPv4 保证的最小重组缓冲区) - 20 字节(IP 头部,最小情况,不带选项) - 8 字节(UDP 头部) = 548 字节(UDP 数据部分的安全上限) |
- 也就是说,任何一台联网的主机,无论其网络配置、
MTU大小是多少,都必须能正确接收576字节的IP包,这是IPv4协议栈的最低保证- 如果你发的
UDP数据报总大小(IP头+UDP头+数据)不超过576字节,那么无论中间经过多少台不同的路由器、无论这些路由器的接口MTU是多少,都不会出现"目标主机因为重组缓冲区太小而丢弃分片"的情况
- 如果你发的
- 为什么不是直接用以太网
MTU(1500)?- 以太网
MTU通常是1500字节,减去IP头和UDP头,也能算出一个"不分片"的安全值(1500-20-8=1472) - 问题在于:
- 中间链路可能有更小的
MTU(比如某些隧道、VPN、PPPoE会把MTU降到1492甚至更低) - 你不知道对端的完整网络路径,公网环境下无法保证全程
MTU都是1500 576字节这个数字是协议规范里的硬性保证,而1500只是"以太网通常如此"的经验值,不是标准强制的下限
- 以太网
常用 socket 选项
SO_RCVBUF/SO_SNDBUF:调大缓冲区避免因处理不及时导致丢包SO_BROADCAST:发送广播时需要IP_ADD_MEMBERSHIP:加入多播组
半连接的 connect()
UDP也可以调用connect(),但这不会建立真正连接,只是把对端地址"绑定"到socket上- 之后可以用
send/recv代替sendto/recvfrom,效率略高(内核不用每次都做地址检查)
UDP多播编程细节
基本概念
- 多播允许一份数据发送给一组感兴趣的接收者,而不需要像广播那样发给网段内所有主机,也不需要像单播那样对每个接收者发一份
多播地址范围
IPv4多播地址是D类地址:224.0.0.0 ~ 239.255.255.255
|
1 2 3 4 |
224.0.0.0 ~ 224.0.0.255 本地网络控制块(不会被路由器转发,TTL=1) 224.0.1.0 ~ 224.0.1.255 互联网控制块 232.0.0.0 ~ 232.255.255.255 SSM(源特定多播)专用 239.0.0.0 ~ 239.255.255.255 管理范围内(组织内部使用,类似私有地址) |
- 常见保留地址:
224.0.0.1:本网段所有主机224.0.0.2:本网段所有路由器224.0.0.5/224.0.0.6:OSPF
核心流程
- 多播编程和普通
UDP的区别主要在于:- 接收端需要显式"加入多播组",发送端一般不需要特殊处理(就是往多播地址发
UDP包)
- 接收端需要显式"加入多播组",发送端一般不需要特殊处理(就是往多播地址发
|
1 2 |
发送端: socket() -> sendto(多播地址) 接收端: socket() -> bind() -> setsockopt(加入组) -> recvfrom() |
接收端:加入多播组
- 创建
socket并绑定bind()通常绑定INADDR_ANY(也有些系统允许直接绑定多播地址,但跨平台兼容性较差,推荐绑INADDR_ANY)- 真正决定"接收哪个组的数据"的是下一步的
IP_ADD_MEMBERSHIP
|
1 2 3 4 5 6 7 8 9 10 11 |
int sockfd = socket(AF_INET, SOCK_DGRAM, 0); int reuse = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; // 绑定 INADDR_ANY,而不是多播地址本身 addr.sin_port = htons(5000); bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)); |
- 加入多播组(关键步骤)
- 这一步的本质是:
- 通知网卡驱动和内核把这个多播
MAC地址加入网卡的接收过滤表 - 同时(如果有路由器)触发
IGMP协议向上游路由器发送Membership Report,告诉路由器"这个网段有人对这个组感兴趣,请把相应多播流量转发过来"
|
1 2 3 4 5 |
struct ip_mreq mreq; mreq.imr_multiaddr.s_addr = inet_addr("224.1.1.1"); // 要加入的多播组 mreq.imr_interface.s_addr = INADDR_ANY; // 使用哪个本地网卡接口(多网卡时可指定) setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)); |
- 接收数据
|
1 2 3 4 5 6 |
char buf[1024]; struct sockaddr_in from; socklen_t fromlen = sizeof(from); int n = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)&from, &fromlen); |
- 离开多播组(可选,进程退出时内核会自动清理)
|
1 |
setsockopt(sockfd, IPPROTO_IP, IP_DROP_MEMBERSHIP, &mreq, sizeof(mreq)); |
发送端
- 发送端不需要加入组也能发送(但如果发送端自己也想接收自己发的包,需要设置
IP_MULTICAST_LOOP,且也要join)
|
1 2 3 4 5 6 7 8 9 |
int sockfd = socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = inet_addr("224.1.1.1"); addr.sin_port = htons(5000); sendto(sockfd, "hello multicast", 15, 0, (struct sockaddr*)&addr, sizeof(addr)); |
发送端专属选项
IP_MULTICAST_TTL:控制多播范围
|
1 2 |
unsigned char ttl = 1; // 默认值就是1,表示只在本网段 setsockopt(sockfd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl)); |
TTL的含义和普通IP包一样,但对多播有约定俗成的范围划分:
|
1 2 3 4 5 6 7 |
TTL 范围 0 仅本机 1 本地网段(默认值,不经过路由器) <32 本地站点/组织 <64 本地地区 <128 本大洲 <255 全球 |
IP_MULTICAST_IF:指定发送用的网卡- 多网卡主机上,指定多播数据从哪个网卡接口发出
|
1 2 3 |
struct in_addr local_if; local_if.s_addr = inet_addr("192.168.1.100"); // 指定网卡IP setsockopt(sockfd, IPPROTO_IP, IP_MULTICAST_IF, &local_if, sizeof(local_if)); |
IP_MULTICAST_LOOP:是否接收自己发的多播包
|
1 2 |
unsigned char loop = 0; // 0=禁止回环, 1=允许(默认通常是1) setsockopt(sockfd, IPPROTO_IP, IP_MULTICAST_LOOP, &loop, sizeof(loop)); |
源特定多播 SSM(Source-Specific Multicast)
- 传统多播(
ASM, Any-Source Multicast)中,接收端加入一个组就能收到任何人发到这个组的数据,容易被恶意源攻击或产生冲突 SSM让接收端可以指定"只接收来自特定源IP的多播流",地址范围通常在232.0.0.0/8
|
1 2 3 4 5 6 |
struct ip_mreq_source mreqs; mreqs.imr_multiaddr.s_addr = inet_addr("232.1.1.1"); // 组地址 mreqs.imr_sourceaddr.s_addr = inet_addr("10.0.0.5"); // 只接收来自这个源的 mreqs.imr_interface.s_addr = INADDR_ANY; setsockopt(sockfd, IPPROTO_IP, IP_ADD_SOURCE_MEMBERSHIP, &mreqs, sizeof(mreqs)); |
IGMP:底层怎么工作的
- 多播能跨网段传递,依赖
IGMP(Internet Group Management Protocol) 协议,运行在主机与直连路由器之间:- 主机执行
IP_ADD_MEMBERSHIP后,内核自动发送IGMP Membership Report,告诉路由器"我要加入组224.1.1.1" - 路由器记录下来,并通过多播路由协议(如
PIM)向上游请求这个组的流量 - 路由器周期性发送
IGMP Query,询问网段内还有没有人在监听这个组 - 主机如果还需要就回应
Report;如果IP_DROP_MEMBERSHIP或进程退出,路由器发现网段内没人回应后会停止转发对应组的流量
- 主机执行
多播的局限与实践问题
- 不可靠(和普通
UDP一样)- 多播同样没有确认、重传机制,丢包完全无感知
- 公网环境支持差
- 互联网上的路由器大多默认不开启多播路由(
PIM等),多播基本只能用在你能控制的网络里
- 互联网上的路由器大多默认不开启多播路由(
- 网卡/系统层的组数限制
- 每个网卡能加入的多播组数量有限制(内核参数、网卡硬件过滤表大小),大量小群组场景需要注意
IPv6多播IPv6没有广播概念,全部用多播替代,API类似但结构体和地址族不同:
|
1 2 3 4 5 |
struct ipv6_mreq mreq6; inet_pton(AF_INET6, "ff02::1", &mreq6.ipv6mr_multiaddr); mreq6.ipv6mr_interface = 0; // 0表示默认接口 setsockopt(sockfd, IPPROTO_IPV6, IPV6_JOIN_GROUP, &mreq6, sizeof(mreq6)); |
在 UDP 上实现可靠传输
概述
TCP已经是可靠传输了,为什么还要在UDP上重新造轮子?- 核心原因是
TCP的可靠性策略是操作系统内核实现的、不可定制的,而很多场景需要按需定制可靠性策略 - 比如牺牲一点带宽换取更低延迟
KCP、QUIC就是典型代表
- 核心原因是
为什么不用 TCP:核心痛点
- 队头阻塞(
Head-of-Line Blocking)TCP是字节流,必须严格按序交付- 如果第
3个包丢了,即使第4、5个包先到,应用层也拿不到,内核会一直攒着等第3个包重传 QUIC用多个独立的stream来解决这个问题,一个stream丢包不影响其他stream
- 重传策略保守
TCP默认使用超时重传(RTO)为主,RTO的计算比较保守(基于RTT均值和方差),丢包后往往要等一个较长的超时时间才重传,不适合实时性要求高的场景KCP采用更激进的快速重传+选择性重传策略,用带宽换延迟
- 拥塞控制不可控
TCP的拥塞控制(如Cubic、Reno)是内核实现的,应用层难以介入或替换- 而
QUIC跑在用户态,可以灵活切换拥塞控制算法(BBR等)
- 连接建立慢
TCP三次握手 +TLS握手叠加起来延迟很高QUIC把传输层和加密层握手合并,实现0-RTT / 1-RTT建连
核心设计要素
- 要在
UDP上做可靠传输,本质上要在应用层重新实现TCP干的几件事:- 序号机制:给每个包编号,接收端识别乱序、去重
- 确认机制(
ACK):接收方告诉发送方哪些包收到了 - 重传机制:没收到
ACK就重传 - 流量控制:防止接收方缓冲区被打爆
- 拥塞控制:防止网络被打爆
- (可选)多路复用:解决队头阻塞
KCP 简化版实现
包头设计
|
1 2 3 4 5 6 7 8 9 10 11 |
struct kcp_header { uint32_t conv; // 会话ID,区分不同连接 uint8_t cmd; // 命令类型:DATA/ACK/PING等 uint8_t frg; // 分片序号(用于大数据切片) uint16_t wnd; // 接收窗口大小 uint32_t ts; // 时间戳(发送时刻) uint32_t sn; // 序号 uint32_t una; // 此编号之前的包都已收到(类似TCP的ack number) uint32_t len; // 数据长度 // 后面跟数据 }; |
序号与确认:ARQ (Automatic Repeat reQuest)
- 最基本的思路:
- 发送端给每个数据包一个递增序号
sn - 接收端收到后记录哪些
sn到了,回一个ACK包告诉发送端 - 发送端维护一个"发送窗口",未被确认的包留在窗口里,超时或收到重复
ACK就重传
- 发送端给每个数据包一个递增序号
- 有两种确认方式:
- 累计确认(
Cumulative ACK,类似TCP)
只告诉发送方"这个序号之前的都收到了",简单但精度低,丢一个包发送方不知道具体是哪个 - 选择性确认(
SACK)
明确告诉发送方哪些序号收到了、哪些没收到,KCP和QUIC都用这个,效率更高
- 累计确认(
|
1 2 3 4 5 6 7 8 9 |
// 简化的ACK包结构 struct ack_packet { uint32_t base_sn; // 累计确认到这个序号 uint16_t sack_count; // 后面SACK块数量 struct { uint32_t start; uint32_t end; } sack_ranges[MAX_SACK]; // 具体收到的不连续区间 }; |
重传策略:这是 KCP 的核心优化点
- 超时重传(
RTO)- 维护每个包的发送时间戳,超过
RTO没收到ACK就重传 RTO的计算通常参考TCP的做法(Jacobson算法)KCP的关键改动:RTO增长不是翻倍(不像TCP的指数退避),而是线性增长,重传更激进,用带宽换延迟
这是它比TCP快的核心原因之一
- 维护每个包的发送时间戳,超过
|
1 2 3 4 |
// RTT估算(指数加权移动平均) rtt_var = (1 - beta) * rtt_var + beta * abs(srtt - rtt_sample); srtt = (1 - alpha) * srtt + alpha * rtt_sample; rto = srtt + max(interval, 4 * rtt_var); |
- 快速重传(Fast
Retransmit)- 不等超时,如果某个包被跳过确认的次数超过阈值(比如
skip=2),就认为它大概率丢了,立即重传
- 不等超时,如果某个包被跳过确认的次数超过阈值(比如
|
1 2 3 4 5 6 7 |
// 检测跳跃确认次数 if (seg->sn < ack_sn) { seg->fastack++; if (seg->fastack >= fast_resend_threshold) { resend(seg); // 立即重传,不等RTO } } |
早期重传 / 尾部丢包处理
QUIC还引入了Tail Loss Probe (TLP):- 如果发送队列尾部包迟迟没被确认,主动发探测包触发对端
ACK,避免死等超时
- 如果发送队列尾部包迟迟没被确认,主动发探测包触发对端
流量控制:滑动窗口
- 接收端通过
ACK包中的wnd字段告诉发送端自己还能接收多少数据,发送端据此调整发送速率,防止把对端缓冲区打爆:
|
1 2 3 4 |
// 发送端检查 if (unacked_bytes_in_flight < min(recv_wnd, cwnd)) { send_more_data(); } |
拥塞控制:这是最复杂也最能体现差异化的部分
KCP的做法(简化的类TCP拥塞控制)- 慢启动:
cwnd从小窗口开始,指数增长直到出现丢包 - 拥塞避免:丢包后
cwnd减半,之后线性增长 KCP提供"普通模式"和"极速模式"(ikcp_nodelay),极速模式关闭拥塞控制的保守策略,用更小的探测间隔、更激进的重传,牺牲带宽换延迟
适合游戏这种小数据量高频率的场景
- 慢启动:
QUIC的做法QUIC默认使用类似TCP CUBIC或BBR的拥塞控制算法,且因为跑在用户态,可以针对不同场景插拔式替换算法,比如:BBR:基于带宽和RTT建模而非丢包信号,更适合高丢包率但带宽充足的网络(如4G/5G)CUBIC:传统的基于丢包反馈调整
多路复用解决队头阻塞(QUIC 的杀手锏)
QUIC在一个连接内部支持多个独立的Stream
|
1 2 3 4 |
Connection ├── Stream 1 (HTTP请求1) ├── Stream 2 (HTTP请求2) └── Stream 3 (HTTP请求3) |
- 每个
Stream有自己独立的序号空间,一个Stream丢包重传,不会阻塞其他Stream的数据交付- 这是
HTTP/3选择QUIC而不是TCP的核心原因 HTTP/2多路复用跑在单个TCP连接上,一旦丢包全部Stream都会被卡住
- 这是
连接迁移(QUIC 独有)
TCP连接是用四元组(源IP、源端口、目的IP、目的端口)标识的- 换网络(如
WiFi切4G)四元组变了连接就断了
- 换网络(如
QUIC用一个独立的Connection ID标识连接,与IP/端口解耦- 网络切换时连接可以无缝迁移,不需要重新握手
|
1 2 3 4 5 6 7 |
发送端 接收端 |-- DATA(sn=1) ------------------->| |-- DATA(sn=2) ------------------->| (丢失) |-- DATA(sn=3) ------------------->| |<----------- ACK(una=1,sack=[3]) -| (选择性确认,告知3收到但2没收到) |-- 快速重传 DATA(sn=2) ---------->| |<----------- ACK(una=4) ----------| (累计确认到4,说明1-3全收到) |
总结对比
| 方面 | TCP | KCP | QUIC |
| 运行位置 | 内核 | 用户态 | 用户态 |
| 重传策略 | 保守(指数退避) | 激进(线性退避+快速重传) | 灵活可配置 |
| 拥塞控制 | 固定算法,不可插拔 | 简化的类TCP算法 | 可插拔(BBR/CUBIC等) |
| 队头阻塞 | 有 | 有(单流) | 无(多Stream) |
| 连接迁 | 不支持 | 不涉及(无连接概念) | 支持 |
| 握手延迟 | 高(TCP+TLS分离) | 无握手概念 | 低(0-RTT/1-RTT合并) |
| 目标场景 | 通用可靠传输 | 游戏、实时性优先 | Web、HTTP/3 |
KCP
- 见
kcp篇
QUIC
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ Windows进程通信相关03/10
- ♥ 51CTO:Linux C++网络编程五08/20
- ♥ Socket:创建TCP服务端10/17
- ♥ 创建socket环境:hello socket10/16
- ♥ Windows网络编程三07/17
- ♥ HTTP协议相关学习一03/22