3. TCP Socket API 详解:从创建到关闭的完整旅程
说实话,很多初学者学 Socket 编程,上来就背函数名。背完发现,写出来的代码还是跑不通。我当年也是这样,照着书抄了个 echo 服务器,结果 bind 就报错,折腾了一下午。
其实 Socket API 就那么几个函数,但每个都有它的脾气。今天咱们就把它们挨个捋一遍。你想想看,一个 TCP 连接从无到有,再到数据收发,最后优雅关闭,这中间到底经历了什么?
核心要点:TCP Socket API 是操作系统网络协议栈暴露给用户空间的接口。每个函数都对应 TCP 状态机的一次状态迁移。理解这些函数,就等于掌握了 TCP 连接的完整生命周期。
3.1 socket():一切从这里开始
socket() 是起点。它告诉操作系统:「嘿,给我创建一个网络通信的端点。」
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
三个参数,我一个个说:
- domain:地址族。TCP 通信用
AF_INET(IPv4)或AF_INET6(IPv6)。别用PF_INET,虽然数值一样,但语义上 AF 才是地址族。 - type:套接字类型。TCP 用
SOCK_STREAM,表示流式套接字。UDP 用SOCK_DGRAM。 - protocol:协议。传 0 就行,系统会根据前两个参数自动选择。TCP 对应
IPPROTO_TCP,但写 0 更常见。
我的习惯:创建 socket 后立刻检查返回值。返回 -1 就打印 errno。很多新手忽略这一步,结果后面 bind 失败半天找不到原因。我曾经在一个项目里,就是因为进程没有 root 权限,socket 创建成功但 bind 低端口失败,排查了半小时。
3.2 bind():给套接字一个身份
bind() 把 socket 和本地地址绑定在一起。说白了,就是告诉内核:「这个 socket 归这个 IP 和端口管。」
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
这里有个坑:struct sockaddr 是个通用结构体,实际传的是 struct sockaddr_in(IPv4)或 struct sockaddr_in6(IPv6)。需要强制类型转换。
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡
serv_addr.sin_port = htons(8080); // 端口号
if (bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) {
perror("bind failed");
exit(EXIT_FAILURE);
}
| 参数 | 说明 | 常见取值 |
|---|---|---|
| sin_family | 地址族 | AF_INET |
| sin_addr.s_addr | IP 地址 | INADDR_ANY(0.0.0.0)或具体 IP |
| sin_port | 端口号(网络字节序) | htons(8080) |
注意:bind() 之前一定要用 memset 清零结构体。我见过有人没清零,结果 sin_zero 字段里有垃圾数据,bind 莫名其妙失败。另外,端口号小于 1024 需要 root 权限,开发时建议用 8080、8888 这些高位端口。
3.3 listen():准备好迎接连接
bind 之后,socket 还只是个「有身份的普通人」。调用 listen() 后,它才变成「服务器」。这个函数把主动套接字变成被动套接字,进入 LISTEN 状态。
int listen(int sockfd, int backlog);
backlog 这个参数,很多人理解错了。它不是最大连接数,而是「已完成三次握手但还未被 accept 取走」的连接队列的最大长度。我习惯设成 128 或 256,具体看系统限制。
避坑指南:我曾经在一个高并发项目里,把 backlog 设成了 5,结果压测时大量连接被拒绝。后来查文档才发现,backlog 太小时,内核会直接丢弃 SYN 包。Linux 2.2 之后,这个值指的是已完成队列的长度,不是半连接队列。
3.4 accept():接住远方的握手
accept() 从已完成连接队列里取出一个连接。如果队列为空,默认会阻塞。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
它返回一个新的 socket 文件描述符。这个新 socket 才是用来和客户端通信的。原来的监听 socket 继续监听,不参与数据收发。
addr 参数可以传 NULL,如果你不关心客户端地址。但我建议别省,打印日志时很有用。
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int connfd = accept(listenfd, (struct sockaddr *)&cli_addr, &cli_len);
if (connfd == -1) {
perror("accept failed");
continue; // 别 exit,继续监听
}
printf("新连接来自 %s:%d\n",
inet_ntoa(cli_addr.sin_addr),
ntohs(cli_addr.sin_port));
3.5 connect():客户端主动出击
客户端不需要 bind(虽然可以),直接 connect 就行。内核会自动分配一个临时端口。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
connect() 会触发三次握手。默认是阻塞的,握手完成才返回。如果服务器没在监听,connect 会返回 -1,errno 是 ECONNREFUSED。
我的经验:connect 超时时间默认挺长的(几十秒)。生产环境建议用非阻塞 connect + select/poll 做超时控制。我曾经写过一个工具,connect 一个不存在的 IP,卡了 75 秒才返回,用户体验极差。
3.6 send() 和 recv():数据收发
连接建立后,数据收发就靠这两个函数了。
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
flags 一般传 0。特殊场景用 MSG_NOSIGNAL(防止对端关闭时收到 SIGPIPE 信号)或 MSG_PEEK(偷看数据不取出)。
返回值要仔细检查:
- 返回正数:实际发送/接收的字节数。可能小于 len,需要循环处理。
- 返回 0:对端关闭连接(recv 时)。
- 返回 -1:出错。errno 为 EINTR 时重试,EAGAIN/EWOULDBLOCK 时表示非阻塞模式下暂无数据。
// 可靠的 recv 循环
ssize_t n;
while ((n = recv(connfd, buf + total, len - total, 0)) > 0) {
total += n;
}
if (n == 0) {
printf("对端关闭连接\n");
} else if (n == -1) {
perror("recv error");
}
重要:TCP 是流协议,没有消息边界。send 一次 100 字节,对端 recv 可能一次收到 50 字节,也可能一次收到 200 字节(如果发送端发了两次)。应用层必须自己处理消息边界,比如加长度头或用特殊分隔符。
3.7 close():优雅地告别
通信结束,调用 close() 关闭 socket。它会将套接字标记为关闭,然后立即返回。内核会尝试发送缓冲区中剩余的数据,然后发送 FIN 包。
int close(int fd);
但这里有个问题:close() 会把引用计数减 1。如果 socket 被多个进程共享(比如 fork 之后),只有最后一个 close 才会真正触发四次挥手。
如果需要更精细的控制,用 shutdown():
int shutdown(int sockfd, int how);
// how: SHUT_RD, SHUT_WR, SHUT_RDWR
shutdown 可以单独关闭读或写方向,而且不受引用计数影响。我一般在关闭连接前先 shutdown(SHUT_WR),告诉对端「我不再发了」,然后继续 recv 直到收到 0,最后 close。
我的习惯:close 之前先 shutdown(SHUT_WR),然后循环 recv 直到返回 0。这样可以保证对端收到所有数据,实现优雅关闭。很多线上问题都是因为直接 close,导致对端还在发数据时连接就断了。
3.8 函数调用关系总结
| 函数 | 角色 | 阻塞点 | 常见错误 |
|---|---|---|---|
| socket() | 双方 | 无 | EACCES(权限不足)、EMFILE(进程 fd 耗尽) |
| bind() | 服务器 | 无 | EADDRINUSE(地址已占用)、EACCES(端口 < 1024) |
| listen() | 服务器 | 无 | EADDRINUSE(同 bind) |
| accept() | 服务器 | 是(默认) | ECONNABORTED(连接被对端中止) |
| connect() | 客户端 | 是(默认) | ECONNREFUSED(端口未监听)、ETIMEDOUT(超时) |
| send() | 双方 | 是(默认) | EPIPE(对端已关闭)、EAGAIN(非阻塞缓冲区满) |
| recv() | 双方 | 是(默认) | 返回 0(对端关闭)、EAGAIN(非阻塞无数据) |
| close() | 双方 | 无 | EBADF(无效 fd) |
嗯,到这里,TCP Socket 的八个核心 API 就讲完了。每个函数都不复杂,但组合起来就能构建出强大的网络应用。记住一点:写网络程序,错误处理比业务逻辑更重要。每次系统调用都要检查返回值,每个 errno 都要想清楚怎么处理。这是我从无数次线上故障里学到的教训。