2、Socket API 详解:从创建到关闭的完整旅程
Socket API,说白了就是操作系统给应用层开的一扇门。你通过这扇门,把数据扔给内核,内核再帮你发到对端。我刚开始学网络编程时,总觉得这些函数就是背背参数、调调顺序就完事了。后来线上出了几次事故,才明白每个 API 背后的细节,才是真正决定程序健壮性的关键。
这一章,咱们把 socket()、bind()、listen()、accept()、connect()、send()、recv()、close() 这八个函数逐个拆开。每个函数我都会结合自己的踩坑经历来讲,希望能帮你少走弯路。
核心要点:Socket API 是 TCP 协议栈的用户态接口。理解每个函数的阻塞行为、错误码和资源管理,是写出高并发、高可靠网络程序的基础。
2.1 socket():创建通信端点
socket() 是一切通信的起点。它告诉内核:「嘿,给我创建一个网络通信的端点,我要用它来收发数据。」
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
三个参数,我一个个说:
- domain:地址族。IPv4 用
AF_INET,IPv6 用AF_INET6。我见过有人写成PF_INET,虽然大多数系统上两者等价,但规范上AF_才是地址族,PF_是协议族。建议统一用AF_INET。 - type:套接字类型。TCP 用
SOCK_STREAM,UDP 用SOCK_DGRAM。注意SOCK_RAW需要 root 权限,我当年做抓包工具时用过,普通用户调不了。 - protocol:传 0 就行,内核会根据前两个参数自动选择。TCP 对应
IPPROTO_TCP,UDP 对应IPPROTO_UDP。
我的习惯:创建 socket 后立刻检查返回值。返回 -1 就打印 errno,别等到后面 bind 或 connect 失败了才排查。我曾经在一个容器化项目里,因为进程没有 CAP_NET_RAW 权限,socket(AF_INET, SOCK_RAW, 0) 直接返回 EPERM,排查了半天。
2.2 bind():绑定地址和端口
服务端必须调用 bind(),把 socket 和一个具体的 IP + 端口绑在一起。客户端通常不需要,内核会自动分配临时端口。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
这里有个坑:struct sockaddr 是个通用结构体,实际使用时需要强转。IPv4 用 struct sockaddr_in:
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080); // 端口号,网络字节序
addr.sin_addr.s_addr = INADDR_ANY; // 绑定所有网卡
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
几个要点:
- 端口号必须用
htons()转换。我见过新手直接写addr.sin_port = 8080,结果 bind 到别的端口上去了。字节序问题,后面章节会细讲。 INADDR_ANY表示监听所有网络接口。如果你只想监听某个特定 IP,比如内网地址,就显式指定。- 端口 0 表示让内核随机分配。客户端常用这种方式,服务端一般固定端口。
我曾经踩过的坑:程序崩溃后立即重启,bind 返回 EADDRINUSE。这是因为 TIME_WAIT 状态还没结束。解决方案是设置 SO_REUSEADDR 选项:
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
这个选项要在 bind 之前设置,否则没用。
2.3 listen():进入监听状态
listen() 把主动 socket 变成被动 socket,告诉内核:「我开始监听了,有连接请求就排队等着。」
int listen(int sockfd, int backlog);
backlog 参数:已完成三次握手但还没被 accept() 取走的连接队列的最大长度。这个值不是越大越好。我见过有人设成 1024,结果客户端大量超时。为什么?
- backlog 太大,队列里积压的连接太多,内核需要维护大量半连接和全连接状态,内存开销大。
- 如果应用层处理不过来,队列满了之后,新的 SYN 会被内核直接丢弃,客户端会重试,反而加重负载。
我的建议:对于高并发服务,backlog 设成 128 到 512 之间比较合理。配合非阻塞 I/O 和事件驱动(比如 epoll),效果更好。Linux 内核 2.2 之后,backlog 指的是已完成三次握手的全连接队列长度,不是半连接队列。
2.4 accept():接受连接
accept() 从已完成连接队列里取出一个连接,返回一个新的 socket 文件描述符。这个新 socket 才是真正用来和客户端通信的。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
注意:
- 返回的是新 socket,原来的监听 socket 还在继续监听。别搞混了。
- 默认是阻塞的。如果没有新连接,
accept()会一直等。生产环境里,我建议配合O_NONBLOCK使用,或者用 epoll 监听监听 socket 的可读事件。 - addr 参数可以传 NULL,如果你不需要知道客户端地址的话。但我建议还是传,方便记日志。
性能关键:每次 accept() 返回一个新 fd,这个 fd 默认是阻塞的。如果你用多线程或线程池处理连接,记得把新 fd 也设置成非阻塞,否则一个慢客户端会拖垮整个线程。
2.5 connect():发起连接
客户端用 connect() 向服务端发起三次握手。这个函数会一直阻塞到连接建立成功或失败。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
常见错误:
| 错误码 | 含义 | 常见原因 |
|---|---|---|
ECONNREFUSED |
连接被拒绝 | 服务端没在监听,或者端口不对 |
ETIMEDOUT |
连接超时 | 网络不通,或者服务端防火墙丢包 |
EHOSTUNREACH |
主机不可达 | 路由问题,或者目标 IP 不存在 |
EINTR |
被信号中断 | 连接过程中收到了信号,需要重试 |
我曾经踩过的坑:非阻塞 connect 的写法。很多人以为 connect() 返回 -1 且 errno 为 EINPROGRESS 就是还在连接中,然后直接用 select() 或 poll() 等可写事件。但要注意:可写事件不代表连接成功,还需要用 getsockopt() 检查 SO_ERROR。否则你以为连上了,一发数据就报错。
2.6 send() 和 recv():收发数据
这两个函数是数据传输的核心。TCP 是流式协议,没有消息边界,所以 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);
send() 的返回值:
- 返回正数:实际发送的字节数。可能小于 len,需要循环发送。
- 返回 -1:出错。常见错误有
EPIPE(对端关闭连接)、EAGAIN(非阻塞模式下发送缓冲区满)。
recv() 的返回值:
- 返回正数:实际接收的字节数。同样可能小于 len。
- 返回 0:对端关闭了连接。这是优雅关闭的信号。
- 返回 -1:出错。常见
EAGAIN(非阻塞模式下无数据可读)。
核心原则:永远不要假设 send() 一次就能发完所有数据,也永远不要假设 recv() 一次就能收完一个完整的消息。粘包拆包问题,就是从这里来的。后面章节会专门讲。
flags 参数一般传 0。有几个有用的:
MSG_NOSIGNAL:防止对端关闭时触发 SIGPIPE 信号导致进程退出。我写服务端时一定会加这个。MSG_PEEK:偷看数据,不取走。调试时有用。MSG_WAITALL:阻塞直到收到指定长度的数据。但要注意,这个标志不能保证一定收到 len 字节,信号中断时可能提前返回。
2.7 close():关闭连接
close() 把 socket 的引用计数减 1。当引用计数降到 0 时,内核会发起四次挥手,关闭连接。
int close(int fd);
这里有个容易被忽略的点:
- close() 不是立即关闭。如果 socket 被多个进程共享(比如 fork 之后),每个进程都要 close 一次,内核才会真正关闭。
- shutdown() 比 close() 更精细。
shutdown(sockfd, SHUT_WR)可以只关闭写方向,读方向仍然可以接收数据。这在实现 HTTP 的「半关闭」场景下很有用。 - close() 之后,socket 描述符就失效了。如果误操作继续读写,会返回
EBADF。
我的习惯:close 之后把 fd 置为 -1,防止后续误用。另外,在 fork 的子进程里,记得关闭不需要的监听 socket fd,否则父进程 close 时引用计数不会归零,连接不会真正关闭。
2.8 完整示例:一个简单的 TCP 回显服务器
把上面八个函数串起来,写一个最简单的回显服务器。客户端发什么,服务端原样返回。
// 服务端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main() {
int listen_fd, conn_fd;
struct sockaddr_in addr;
char buf[1024];
int opt = 1;
// 1. socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
exit(1);
}
// 2. setsockopt 避免地址重用问题
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 3. bind
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = INADDR_ANY;
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
close(listen_fd);
exit(1);
}
// 4. listen
if (listen(listen_fd, 128) < 0) {
perror("listen");
close(listen_fd);
exit(1);
}
printf("Server listening on port 8888...\n");
// 5. accept 循环
while (1) {
conn_fd = accept(listen_fd, NULL, NULL);
if (conn_fd < 0) {
perror("accept");
continue;
}
// 6. recv / send 回显
ssize_t n;
while ((n = recv(conn_fd, buf, sizeof(buf), 0)) > 0) {
send(conn_fd, buf, n, MSG_NOSIGNAL);
}
// 7. close
close(conn_fd);
printf("Connection closed.\n");
}
close(listen_fd);
return 0;
}
注意:这个例子是单线程阻塞模型,一次只能处理一个客户端。生产环境里,你会用 epoll + 非阻塞 I/O 或者多线程来处理并发。但核心的 API 调用顺序,就是这个骨架。
2.9 避坑总结
最后,把我这些年遇到过的、和这八个 API 相关的坑列一下:
| API | 常见坑 | 解决方案 |
|---|---|---|
| socket() | 忘记检查返回值 | 创建后立即判断,打印 errno |
| bind() | 端口被占用,未设置 SO_REUSEADDR | bind 前设置 SO_REUSEADDR |
| listen() | backlog 设得太大或太小 | 128~512 之间,配合非阻塞使用 |
| accept() | 阻塞导致线程挂起 | 使用非阻塞模式 + epoll |
| connect() | 非阻塞模式下误判连接成功 | 检查 SO_ERROR |
| send() | 未处理部分发送 | 循环发送直到全部写完 |
| recv() | 未处理粘包 | 应用层协议定义消息边界 |
| close() | 多进程下引用计数未归零 | 每个进程都 close,或使用 shutdown |
嗯,这八个 API 看起来简单,但每个都有不少细节。我当年刚入行时,觉得背下来参数就完事了。后来线上出了几次事故,才明白真正重要的是理解它们的行为——阻塞还是非阻塞?返回值怎么处理?错误码代表什么?这些才是写出健壮网络程序的关键。