3. Socket 生命周期:创建、绑定、监听、连接、收发数据、关闭
Socket 的生命周期,说白了就是一个网络连接从生到死的完整过程。我见过不少同事,写业务代码一把好手,但一遇到 Socket 层面的问题就抓瞎。为什么?因为不理解这六个阶段到底在干什么。
今天咱们就把这六个阶段掰开揉碎,一个一个讲清楚。
3.1 创建(socket)
一切从 socket() 系统调用开始。这个调用告诉内核:「嘿,给我准备一个网络通信的端点。」
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
三个参数决定了这个 Socket 的「基因」:
- domain:地址族,
AF_INET是 IPv4,AF_INET6是 IPv6 - type:
SOCK_STREAM是 TCP,SOCK_DGRAM是 UDP - protocol:通常传 0,让内核自动选择
创建成功后,内核会返回一个文件描述符(fd)。在 Linux 里,一切皆文件,Socket 也不例外。这个 fd 就是后续所有操作的凭证。
3.2 绑定(bind)
创建完 Socket,它还是个「黑户」——没有 IP,没有端口。绑定就是给它上户口。
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(8080);
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
这里有几个坑,我一个个说:
- 端口冲突:如果端口已经被占用,
bind()会返回EADDRINUSE。我曾经遇到过测试环境端口没释放,导致新服务起不来的情况。 - 权限问题:绑定 1024 以下的端口需要 root 权限。生产环境我建议用 1024 以上的端口,避免不必要的安全风险。
- 地址重用:如果服务崩溃重启,内核可能还在 TIME_WAIT 状态。这时候需要设置
SO_REUSEADDR:
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
3.3 监听(listen)
绑定之后,Socket 还不会主动接收连接。你需要调用 listen() 告诉内核:「我准备好了,有连接来找我吧。」
listen(sockfd, SOMAXCONN);
第二个参数是 backlog,也就是连接队列的长度。这个值很关键:
| backlog 值 | 含义 | 生产建议 |
|---|---|---|
| 过小(如 5) | 高并发时连接被拒绝 | 不推荐 |
| 适中(128-1024) | 平衡资源与并发 | 推荐 |
| 过大(如 65535) | 浪费内存,可能被攻击 | 不推荐 |
我个人习惯用 SOMAXCONN,让内核决定最大值。但要注意,不同系统的 SOMAXCONN 值不一样,Linux 上通常是 128 或 4096。
3.4 连接(connect / accept)
这是 Socket 生命周期里最核心的阶段。客户端和服务端各走各的路:
- 客户端:调用
connect()发起连接 - 服务端:调用
accept()接受连接
// 客户端
connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
// 服务端
int client_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &addr_len);
这里有个经典问题:三次握手到底发生在哪一步?
答案是:connect() 成功返回时,三次握手已经完成。而服务端的 accept() 只是从已完成连接队列里取一个连接出来。
关键理解:服务端可以不用 accept(),连接照样能建立。但如果不 accept(),连接会一直堆积在队列里,直到队列满了新连接被拒绝。
我曾经遇到过一个问题:服务端 accept() 太慢,导致连接队列溢出,客户端大量报错。后来加了异步 accept() 才解决。
3.5 收发数据(send / recv)
连接建立后,就是数据的你来我往。TCP 是流式协议,没有消息边界。这意味着:
send()不一定一次发完所有数据recv()不一定一次收完所有数据
// 发送
ssize_t n = send(sockfd, buf, len, 0);
if (n < 0) {
// 处理错误
} else if (n < len) {
// 没发完,需要继续发
}
// 接收
ssize_t n = recv(sockfd, buf, sizeof(buf), 0);
if (n == 0) {
// 对端关闭连接
} else if (n < 0) {
// 处理错误
}
这里有几个常见陷阱:
- 阻塞与非阻塞:默认是阻塞模式。如果
recv()时没数据,线程会挂起。高并发场景下,我建议用非阻塞 + epoll。 - 粘包问题:TCP 是流,不是消息。应用层需要自己定义消息边界,比如固定长度、特殊分隔符、或者长度前缀。
- 缓冲区溢出:
send()返回成功不代表数据已经到达对端,只代表数据进了内核缓冲区。如果缓冲区满了,send()会阻塞或返回EAGAIN。
recv() 上,其他连接怎么办?
3.6 关闭(close / shutdown)
天下没有不散的筵席。关闭连接有两种方式:
| 函数 | 行为 | 适用场景 |
|---|---|---|
close() |
关闭整个 Socket,引用计数减 1 | 彻底不再使用 |
shutdown() |
关闭读或写方向,或两者 | 半关闭、优雅关闭 |
// 关闭写方向,告诉对端「我不再发了」
shutdown(sockfd, SHUT_WR);
// 彻底关闭
close(sockfd);
这里有个很多人忽略的点:close() 不是立即释放资源。内核会等引用计数为 0 时才真正释放。如果 fork 了子进程,父子进程都持有 fd,需要都 close 才行。
close() 后,客户端还在读数据,结果读到的是 RST 包。后来改用 shutdown(SHUT_WR) 先告诉对端「我不发了」,等对端也关闭后,再 close()。这才是优雅关闭。
3.7 生命周期全景图
下面这张图把整个流程串起来了。建议你保存下来,遇到 Socket 问题时拿出来对照一下。
3.8 总结
Socket 的六个阶段,每个阶段都有它的坑。我见过太多线上事故,归根结底就是某个阶段没处理好:
- 创建时没检查返回值
- 绑定时端口冲突
- 监听时 backlog 太小
- 连接时 accept 太慢
- 收发时没处理粘包
- 关闭时没优雅挥手
记住一句话:Socket 的生命周期,就是网络编程的命脉。把这六个阶段吃透了,生产环境里 90% 的网络问题你都能快速定位。