一、Socket:内核中的通信端点
1.1 Socket定义
socket() 定义:creates an endpoint for communication ,Socket 是操作系统内核提供的通信端点抽象。
调用 socket() 成功后返回一个整数文件描述符(fd),但这个 fd 只是用户态的一个句柄,其背后对应内核中一个完整的 Socket 对象(struct sock),包含:
- 协议族与套接字类型标识
- TCP 连接状态机(ESTABLISHED、LISTEN、TIME_WAIT 等)
- 本地/远端 IP 地址与端口号
- 接收缓冲区(
sk_receive_queue)、发送缓冲区(sk_sndbuf) - 连接等待队列、超时参数、拥塞控制参数、所有权信息等
Socket 是用户进程与内核网络协议栈之间的统一接口。
协议栈调用层次是:
应用层协议 → Socket API 系统调用 → 内核 TCP 层 → 内核 IP 层 → 数据链路层(以太网) → NIC 网卡硬件
对应到 FANUC 工业通信场景:
FANUC 私有应用协议 Request/Response → send()/recv() 函数调用 → TCP Socket 句柄 → TCP 协议传输 → IP 路由 → 以太网帧 → RJ45 网线 → 机器人控制器
1.2 socket() 三个参数
标准调用形式:
cpp
int fd = socket(AF_INET, SOCK_STREAM, 0);
三个参数分别对应 domain(协议族)、type(套接字类型)、protocol(具体协议):
1.2.1 domain:决定地址格式与协议族
AF_INET:IPv4 网络协议,地址结构为sockaddr_inAF_INET6:IPv6 网络协议,地址结构为sockaddr_in6AF_UNIX/AF_LOCAL:UNIX 域套接字,用于同一主机内进程间通信(IPC),不经过 IP 网络栈,性能远高于 TCP,地址结构为sockaddr_un,绑定文件系统路径或抽象命名空间
1.2.2 type:决定通信语义
SOCK_STREAM:流式套接字,对应 TCP 协议- 特性:可靠交付、按序到达、全双工、面向字节流
- 保证:数据不丢失、不重复、按顺序到达
- 不保证:应用层消息边界
SOCK_DGRAM:数据报套接字,对应 UDP 协议- 特性:无连接、不可靠、保留消息边界
- 适合:广播、多播、实时性优先的场景
SOCK_SEQPACKET:有序分组套接字- 特性:面向连接、可靠、按序交付、保留消息边界
- 常用于 UNIX 域套接字,TCP 也支持但使用较少
1.2.3 protocol:具体协议编号
通常填 0,由系统根据 domain 和 type 自动选择对应协议;特殊场景可指定具体协议,如 IPPROTO_TCP、IPPROTO_UDP。
IPPROTO = IP Protocol,IP 协议头里的protocol字段。
IPv4 头部有一个 1 字节的protocol字段,标记上层是什么协议:
- IPPROTO_TCP:值 = 6
- IPPROTO_UDP:值 = 17
抓包看 IP 头:Protocol=6 → TCP;Protocol=17 → UDP。
和 socket 第二个参数(SOCK_*)的关系
SOCK_STREAM:流式套接字,语义就是 TCP。此时第三个参数写0等价于IPPROTO_TCP
SOCK_DGRAM:数据报套接字,语义就是 UDP。第三个参数写0等价于IPPROTO_UDP
1.3 内核视角:数据流转模型
1.3.1 发送路径(用户态 → 网络)
- 用户态线程调用
send()/write(),触发系统调用进入内核 - 内核根据 fd 找到对应的
struct sock对象 - 将用户数据拷贝到内核 Socket 的发送缓冲区(
sk_sndbuf) - TCP 层进行封装:添加 TCP 头、进行拥塞控制、流量控制、Nagle 算法判断
- 下发到 IP 层:添加 IP 头、路由选择、分片处理
- 数据链路层封装成以太网帧,通过网卡驱动发送到 NIC
- NIC 将数据串行化发送到物理网络
Nagle 是 TCP 的发送端优化算法,减少小包数量,合并多个小数据包一起发,降低网络报文开销
NIC = Network Interface Card,网卡,主机和物理网络之间的硬件接口,负责把内存里的数据包变成电信号 / 光信号发到网线,同时把线上收到的信号转回数据包交给内核。
1.3.2 接收路径(网络 → 用户态)
- 网络数据包从 RJ45 进入网卡,NIC 进行帧校验
- 网卡产生硬件中断,通知内核有数据到达
- NAPI(New API)机制:关闭硬中断,切换到软中断上下文批量处理数据包
- 内核协议栈逐层解析:以太网 → IP → TCP,校验和验证、去重、排序
- 根据「源IP+源端口+目的IP+目的端口」四元组,找到对应 Socket 对象
- 将数据放入该 Socket 的接收队列(
sk_receive_queue) - 如果有线程阻塞在
recv()上,唤醒该线程,将数据从内核拷贝到用户缓冲区
主要内核数据结构:
struct sk_buff:内核网络数据包的载体,贯穿整个协议栈,包含数据指针、协议头、长度、接口等信息struct sock:Socket 内核对象,管理连接状态、缓冲区、队列、回调等全部资源- NAPI:网络包处理机制,通过软中断批量处理,避免频繁硬中断,大幅提升网络吞吐量
1.4 注意
- Socket 是接口抽象,TCP 是其中一种承载协议;Socket 也可以承载 UDP、UNIX 域协议等
- fd 是用户态的整数句柄,真正的 Socket 实体在内核里,一个 Socket 可以被多个 fd 引用
二、TCP 连接建立与字节流语义
2.1 服务端连接建立流程
服务端经典四步走:socket → bind → listen → accept,每一步的内核行为都需要理解透彻。
2.1.1 bind():绑定本地地址与端口
cpp
#include <sys/socket.h>
#include <netinet/in.h>
int main() {
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr{}; //定义 IPv4 地址变量addr
addr.sin_family = AF_INET;
addr.sin_port = htons(8000); // 把主机序的端口8000,转换成网络大端,存入sin_port
//错误写法:addr.sin_port = 8000; 直接赋值,大小端颠倒,监听的不是 8000 端口。
addr.sin_addr.s_addr = INADDR_ANY; // 监听所有本地接口
bind(fd, (struct sockaddr*)&addr, sizeof(addr)); //把 socket 文件描述符 fd 和一个本地地址 (IP + 端口) 绑定
listen(fd, 5);
return 0;
}
内核行为:
- 校验地址合法性、端口权限(<1024 端口需要 root)
- 检查端口是否被占用(受
SO_REUSEADDR选项影响) - 将 Socket 与指定的 IP+端口绑定,建立四元组的本地半边
关键细节:
INADDR_ANY(0.0.0.0)表示监听所有网卡接口,只要目的地址是本机任意 IP 都能命中该 Socket- 不主动调用 bind() 时,
connect()或listen()会自动绑定临时端口 SO_REUSEADDR选项允许 TIME_WAIT 状态的端口被重新绑定,服务端开发必设
cpp
struct sockaddr_in {
sa_family_t sin_family; /* 地址族 */
in_port_t sin_port; /* 端口号,网络字节序 */
struct in_addr sin_addr; /* IPv4地址 */
unsigned char sin_zero[8];// 填充,必须置0,对齐用,兼容sockaddr
};
struct in_addr {
uint32_t s_addr; // IPv4地址,网络字节序,uint32
};
htons = Host TO Network Short
- Host:本机字节序(PC 一般小端)
- Network:网络字节序(固定大端)
- Short:16 位,端口就是 16 位无符号整数,范围 0~65535
2.1.2 listen():转为被动监听套接字
cpp
listen(fd, 128);
内核行为:
- 将 Socket 从主动状态转为 LISTEN 状态
- 分配两个队列:
- 半连接队列(SYN 队列):存放已收到 SYN、未完成三次握手的连接
- 全连接队列(accept 队列):存放已完成三次握手、等待应用层 accept() 的连接
backlog参数:表示全连接队列的最大长度
关键细节:
- 半连接队列的最大长度由内核参数
/proc/sys/net/ipv4/tcp_max_syn_backlog控制 - 全连接队列实际长度 = min(backlog,
/proc/sys/net/core/somaxconn),Linux 5.4 默认 somaxconn 为 4096 - 队列满时,新的 SYN 会被丢弃或回复 RST,客户端表现为连接超时或被拒绝
2.1.3 accept():提取已建立的连接
accept 是阻塞系统调用,从监听 socket 的内核连接队列取出一个已完成 TCP 连接 ,返回新 socket 描述符专门用来和这个客户端通信 。
listen_fd 是前面 socket + bind + listen 创建的监听 fd,只负责等连接,不能收发业务数据
accept会创建一个全新的已连接 Socket,并返回新的 fd。
cpp
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr); //告诉内核最多可以往 client_addr 里写多少字节
int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &len);
内核行为:
- 从全连接队列头部取出一个已完成握手的连接
- 创建新的 Socket 对象和新的文件描述符
- 填充客户端地址信息到
client_addr - 原监听 Socket 不受任何影响,继续等待下一个连接
关键细节:
- 新返回的
conn_fd不继承监听 socket 的非阻塞、超时等标志(Linux 与 BSD 行为不同),需要显式设置 - 队列为空时,阻塞模式下 accept() 会阻塞,非阻塞模式下返回 EAGAIN
- 每调用一次 accept 只处理一个连接,高并发场景需要循环调用
2.2 客户端连接建立流程
客户端两步走:socket → connect
cpp
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr{};
// ... 填充服务器地址
connect(fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
内核行为:
- 未绑定的 Socket 自动分配一个临时端口(ephemeral port)
- 发送 SYN 包,发起 TCP 三次握手
- 阻塞模式下,直到握手完成或超时才返回
- 连接建立成功后,fd 就可以直接用于收发数据
2.3 TCP 连接的唯一标识:四元组
一个 TCP 连接由 <本地IP, 本地端口, 远端IP, 远端端口> 四元组唯一标识。
一个服务端端口可以同时承载多少连接 ?
答案:理论上无上限(受限于系统资源)。因为服务端所有连接的本地端口都相同,但每个连接的远端 IP/端口不同,四元组仍然唯一。
端口复用的真相:
- 服务端:成千上万的连接可以共享同一个监听端口(如 80、8080)
- 客户端:每个 outbound 连接通常占用不同的临时端口
- 只要四元组不重复,连接就可以唯一区分
2.4 TCP 字节流语义的四大坑
坑1:send() 不是「发一条消息」
连续两次 send 分别发送 "ABC" 和 "DEF":
cpp
send(fd, "ABC", 3, 0);
send(fd, "DEF", 3, 0);
对端可能的接收情况:
- 分两次收到 "ABC" 和 "DEF"
- 一次收到 "ABCDEF"
- 分多次收到,如 "A" + "BCDEF"、"AB" + "CDEF" 等
TCP 只负责按顺序交付字节流,完全不认识应用层的「消息」边界。
坑2:「粘包拆包」不是 TCP 的 bug
所谓「粘包」本质是应用层没有定义消息帧格式,TCP 本身就没有消息边界的概念,不存在「bug」一说。
工业协议常用的帧格式方案:
-
固定长度帧:每条消息固定 N 字节,简单但浪费带宽
-
长度前缀帧:前 2/4 字节表示后续载荷长度,工业控制最常用
┌──────────┬──────────────────┐
│ Length │ Payload │
│ 4 bytes │ N bytes │
└──────────┴──────────────────┘ -
特殊分隔符 :如
\r\n、特定 magic 字节,适合文本协议 -
标准工业帧:Magic + Type + Length + Payload + CRC,如 Modbus TCP、FANUC 私有协议
坑3:recv() 不保证读满指定长度
cpp
char buf[1024];
ssize_t n = recv(fd, buf, 1024, 0);
recv() 返回值是当前内核缓冲区中可读取的字节数,最多不超过请求长度,绝不会等满 1024 字节再返回。
正确的定长读取封装 :
recv_all:保证从 socket fd 完整读取指定len字节数据 。
原生recv不保证一次读完你想要的全部字节(TCP 流式,内核缓冲区数据可能分段到达),这个循环封装就是解决TCP 粘包 / 分段读取问题,读到满len字节才返回成功。
cpp
bool recv_all(int fd, void* buf, size_t len) {
char* p = static_cast<char*>(buf);
size_t total = 0; //已经成功收到的字节总数。初始 0。循环目标:total == len
while (total < len) {
ssize_t n = recv(fd, p + total, len - total, 0); //返回值ssize_t:有符号整数,正数 = 读到字节,0 = 连接关闭,-1 = 出错
if (n > 0) {
total += n;
} else if (n == 0) {
// 对端正常关闭连接
return false;
} else {
if (errno == EINTR) continue; // 被信号中断,重试
return false; // 真正错误
}
}
return true;
}
坑4:recv() == 0 的真正含义
对于 TCP 流式套接字,recv() 返回 0 表示对端执行了正常的有序关闭(FIN),即到达字节流的结束(EOF),不是错误。
返回值判断规范:
> 0:成功读取到字节== 0:对端关闭写端,流结束< 0:发生错误,需检查 errno
三、I/O 模型:阻塞、非阻塞与 epoll 多路复用
3.1 阻塞 I/O(Blocking I/O)
默认创建的 Socket 都是阻塞模式。
行为特点:
- 调用
recv()时如果接收队列为空,线程进入睡眠,直到数据到达、连接关闭、超时或被信号中断 - 调用
send()时如果发送缓冲区满,线程阻塞直到有空闲空间 - 阻塞期间 CPU 占用为 0,由内核调度唤醒
适用场景:
- 单连接、简单客户端程序
- 每个连接一个线程的简单服务端
- 优点:编程简单、逻辑直观
- 缺点:高并发下线程过多,调度开销大,内存占用高
3.2 非阻塞 I/O(Non-blocking I/O)
通过 fcntl() 设置 O_NONBLOCK 标志:
cpp
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
行为特点:
- 无数据时
recv()立即返回 -1,errno = EAGAIN或EWOULDBLOCK - 缓冲区满时
send()也返回 -1,errno = EAGAIN - 调用立即返回,不会阻塞线程
绝对禁忌 :不能用 while 循环持续轮询,会占满 100% CPU。
非阻塞 Socket 必须配合 I/O 多路复用使用。
3.3 I/O 多路复用:select / poll / epoll 对比
I/O 多路复用的核心是:用一个线程同时监听多个 fd,内核告诉你哪些 fd 就绪了,再去操作。
| 模型 | 最大连接数 | 性能 | 触发模式 | 移植性 |
|---|---|---|---|---|
| select | 一般 1024 | O(n),遍历检查 | 水平触发 | 跨平台 |
| poll | 无限制 | O(n),遍历检查 | 水平触发 | 跨平台 |
| epoll | 无限制 | O(1),事件回调 | 水平/边缘触发 | Linux 专属 |
3.4 epoll
epoll 是 Linux 高性能网络编程的基石,nginx、redis 等都基于 epoll 构建。
3.4.1 数据结构
- interest list(兴趣集合):内核维护的需要监听的 fd 红黑树
- ready list(就绪集合):已就绪的 fd 链表
3.4.2 两种触发模式
1. 水平触发(Level Triggered, LT)
- 行为:只要 fd 还有数据可读/可写,每次
epoll_wait都返回就绪 - 特点:编程简单,不容易漏事件
- 类似 select/poll 的行为
2. 边缘触发(Edge Triggered, ET)
- 行为:只在状态变化时通知一次
- 要求:必须配合非阻塞 Socket,循环读写直到返回
EAGAIN - 优点:减少 epoll_wait 调用次数,高并发下性能更高
- 坑点:如果一次没读完,剩下的数据不会再通知,会造成数据积压
ET 模式标准读取范式:
cpp
while (true) {
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n > 0) {
process_data(buf, n);
} else if (n == 0) {
close(fd);
break;
} else {
if (errno == EAGAIN) break; // 读完了
if (errno == EINTR) continue;
close(fd);
break;
}
}
3.5 常见误区
- ❌ 错误:epoll 一定比 select 快
- ✅ 正确:连接数少时差别不大,连接数多且活跃连接稀疏时 epoll 优势才明显
- ❌ 错误:ET 模式一定比 LT 好
- ✅ 正确:ET 编程复杂容易出 bug,大部分场景 LT 足够稳定可靠
四、多线程 Socket 工程实践:FANUC 案例
这是实际工业开发中最容易踩坑的部分,也是笔者在 FANUC 机器人通信项目中踩过的。
4.1 问题背景
FANUC 机器人控制器通过 TCP 提供指令接口,采用标准的 Request-Response 模式:
- 客户端发送一条指令请求(如读取位置、设置寄存器)
- 控制器执行后返回对应响应
- 每条请求对应一条响应,顺序一一对应
项目初期架构:两个业务线程共享同一个 TCP Socket
- 线程 A:位置设置与控制指令
- 线程 B:周期缓存数据更新
4.2 错误与问题
错误架构示意图
text
Thread A (控制指令) Thread B (缓存更新)
│ send Req A │ send Req B
▼ ▼
同一个 Socket fd
同一个 TCP 字节流
同一个接收队列
问题根源
- 发送端交错:两个线程同时 send,虽然 TCP 保证字节有序,但两个请求的字节可能被拼接或拆分
- 接收端竞争:两个线程同时调用 recv,会竞争接收队列中的数据
- 响应错位:Thread A 可能收到 Response B,Thread B 可能收到 Response A
- 协议破坏:应用层的 Request-Response 对应关系完全混乱
很多人以为「只锁 send」就能解决,实际上只能保证发送操作不交错,接收端的竞争依然存在,响应还是会被对方线程抢走。
4.3 方案一:事务级串行化
最直接可靠的方案:锁整个 Request-Response 事务,而不是只锁 send。
cpp
class FanucClient {
int fd_;
std::mutex tx_mutex_;
bool send_frame(const Frame& req) {
// ... 帧封装
return send_all(fd_, req.data(), req.size());
}
bool recv_frame(Frame& resp) {
// ... 先读长度,再读载荷,校验
return true;
}
public:
Response execute(const Request& req) {
std::lock_guard<std::mutex> lock(tx_mutex_);
send_frame(req.serialize());
Frame resp_frame;
if (!recv_frame(resp_frame)) {
throw std::runtime_error("通信失败");
}
return Response::parse(resp_frame);
}
};
执行时序 :
请求A → 响应A → 请求B → 响应B
- ✅ 优点:实现简单,严格保证请求-响应一一对应
- ❌ 缺点:串行执行,并发度低,缓存更新会阻塞控制指令
- 适用场景:FANUC 控制器只允许单 Session、不支持多连接的情况
4.4 方案二:多连接隔离
如果控制器支持多会话,为每个业务线程分配独立的 Socket 和 TCP 连接。
text
Thread A (控制) Thread B (缓存)
│ │
Socket A Socket B
│ │
TCP 连接 1 TCP 连接 2
│ │
独立接收队列 独立接收队列
客户端端口说明:
- 两个客户端 Socket 不需要手动 bind,
connect()时内核自动分配不同的临时端口 - 两个连接的服务端端口相同(都是控制器的 6008)
- 四元组不同,连接可以正常区分
- ✅ 优点:业务完全隔离,互不阻塞,并发度高
- ❌ 缺点:占用更多连接资源,需要控制器支持多会话
- 适用场景:控制器允许多连接、业务之间独立性要求高
4.5 方案三:单 IO 线程 + 请求 ID 分发
高并发工业场景的标准架构:
架构设计:
- 单个 IO 线程专门负责 Socket 的读写
- 业务线程提交请求时带上唯一 Request ID
- IO 线程收到响应后,根据 ID 分发到对应业务线程的等待队列
- 业务线程阻塞等待自己的响应
- ✅ 优点:单连接、高吞吐、线程安全、并发度高
- ❌ 缺点:实现复杂,需要应用协议支持 Request ID 字段
- 适用场景:高性能网关、多设备集中管控系统
五、概念辨析
5.1 close() 与 shutdown() 的区别
5.1.1 close():文件描述符层面
- 作用:减少文件描述符的引用计数
- 当引用计数归零时,释放 Socket 内核对象
- 只是释放用户态句柄,不一定立即断开 TCP 连接
- 如果还有其他 fd 指向同一个 Socket,连接不受影响
5.1.2 shutdown():TCP 协议层面
- 作用:直接操作 TCP 连接,可单独关闭读、写或双向
SHUT_RD:关闭读端,后续接收的数据会被丢弃SHUT_WR:关闭写端,发送 FIN 包,对端会读到 EOFSHUT_RDWR:双向关闭
- 不影响文件描述符的引用计数,fd 仍然有效
典型应用:半关闭
一端发送完数据后调用 shutdown(SHUT_WR),告诉对端数据发完了,但仍然可以接收对端的数据。
5.2 监听 Socket 与连接 Socket 的区别
| 维度 | 监听 Socket(listen_fd) | 连接 Socket(conn_fd) |
|---|---|---|
| 状态 | LISTEN | ESTABLISHED |
| 数量 | 服务端通常只有 1 个 | 每个客户端连接 1 个 |
| 作用 | 只负责接受新连接 | 负责具体客户端的数据收发 |
| 生命周期 | 服务启动创建,退出销毁 | 连接建立创建,连接关闭销毁 |
| 四元组 | 只有本地半边完整 | 四元组完整 |
5.3 send() 返回值与内核发送缓冲区
send()成功返回只代表数据被拷贝到内核发送缓冲区,不代表已经发送到网络- 内核会根据拥塞控制、Nagle 算法、TCP_CORK 等决定何时发送
- 非阻塞模式下,发送缓冲区满时返回 EAGAIN
总结
- 内核层:Socket 是内核对象,fd 只是句柄,数据走内核网络栈和接收队列,NAPI 软中断处理数据包
- 协议层:TCP 是字节流,没有消息边界,粘包是应用层问题,必须自己做帧定界
- 工程层:多线程共享 Socket 会有响应错位问题,需要事务串行化、多连接隔离或 ID 分发