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,让内核自动选择
我的习惯:每次创建 Socket 后,我都会检查返回值。如果返回 -1,立刻打印 errno。很多线上问题其实在创建阶段就已经埋下了伏笔。

创建成功后,内核会返回一个文件描述符(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));
注意:绑定 0.0.0.0(INADDR_ANY)意味着监听所有网卡。如果只想监听某个特定 IP,一定要明确指定。我曾经见过一个服务绑错了 IP,导致内网访问不了,排查了半天。

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。
我的经验:生产环境里,我从来不用默认的阻塞模式。非阻塞 + 事件驱动(epoll / kqueue)才是王道。你想想看,一个线程阻塞在 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 问题时拿出来对照一下。

Socket 生命周期流程图 Socket 生命周期全景图 客户端 socket() 创建 connect() 连接 send() / recv() 收发 close() 关闭 服务端 socket() 创建 bind() 绑定 listen() 监听 accept() 接受 send() / recv() 收发 close() 关闭 三次握手 双向数据流 四次挥手 关键说明: • 客户端不需要 bind() 和 listen() • 服务端不需要 connect() • close() 不立即释放资源,需等引用计数为 0

3.8 总结

Socket 的六个阶段,每个阶段都有它的坑。我见过太多线上事故,归根结底就是某个阶段没处理好:

  • 创建时没检查返回值
  • 绑定时端口冲突
  • 监听时 backlog 太小
  • 连接时 accept 太慢
  • 收发时没处理粘包
  • 关闭时没优雅挥手

记住一句话:Socket 的生命周期,就是网络编程的命脉。把这六个阶段吃透了,生产环境里 90% 的网络问题你都能快速定位。