3. 深入理解 Socket API:从创建到关闭的完整旅程

Socket 编程,说白了就是让两台机器通过网络「对话」。而这一整套对话流程,都围绕着几个核心 API 展开。今天我就带你逐个拆解,把每个函数背后的门道讲清楚。

核心流程速览:服务端走的是 socket() → bind() → listen() → accept() → recv()/send() → close();客户端则是 socket() → connect() → send()/recv() → close()。记住这个骨架,剩下的都是血肉。

Socket 编程核心流程对比图 服务端 socket() 创建套接字 bind() 绑定地址和端口 listen() 进入监听状态 accept() 接受客户端连接 recv()/send() 收发数据 close() 关闭连接 客户端 socket() 创建套接字 connect() 发起连接请求 send()/recv() 收发数据 close() 关闭连接 三次握手 💡 核心差异 服务端被动等待连接,客户端主动发起连接

3.1 socket() —— 一切从这里开始

socket() 函数,说白了就是向内核申请一个「网络通信的句柄」。我习惯把它比作申请一根电话线——你告诉运营商你要哪种线路,然后拿到一个线路编号。

#include <sys/socket.h>
int socket(int domain, int type, int protocol);

三个参数,一个返回值。咱们逐个说:

  • domain:协议族。最常用的是 AF_INET(IPv4)和 AF_INET6(IPv6)。我个人建议新项目直接上 AF_INET6,兼容性好。
  • type:套接字类型。SOCK_STREAM 是流式套接字(TCP),SOCK_DGRAM 是数据报套接字(UDP)。
  • protocol:具体协议。传 0 表示让内核根据前两个参数自动选择。

我的经验:protocol 参数传 0 就够了。我见过有人非要传 IPPROTO_TCP,结果写错了宏定义,编译报错查了半天。何必呢?

返回值是一个非负整数,也就是套接字描述符。如果返回 -1,说明创建失败,具体原因看 errno。

3.2 bind() —— 给套接字一个身份

bind() 的作用,就是把 socket() 拿到的「空套接字」绑定到一个具体的 IP 地址和端口上。你想想看,没有 bind(),服务端怎么告诉客户端「我在这儿等你」?

#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

这里有个坑,我当年刚学的时候踩过——struct sockaddr 是个通用结构体,实际使用时需要根据协议族转换成具体的结构体。比如 IPv4 用 struct sockaddr_in:

struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 绑定所有网卡
serv_addr.sin_port = htons(8080);               // 端口号

注意字节序:网络字节序是大端模式。x86 机器是小端,所以必须用 htonl() 和 htons() 转换。我曾经见过一个同事忘了转换端口号,结果 bind 到 0x1F90(8080 的小端表示)上,客户端死活连不上。

bind() 返回 0 表示成功,-1 表示失败。常见的失败原因有:

  • EADDRINUSE:端口已被占用。这是最常遇到的错误。
  • EACCES:权限不足。绑定 1024 以下的端口需要 root 权限。

3.3 listen() —— 准备好接客

bind() 之后,套接字还处于「未监听」状态。listen() 的作用,就是告诉内核:「我准备好了,可以接受连接了。」

#include <sys/socket.h>
int listen(int sockfd, int backlog);

backlog 这个参数,很多人理解错了。它不是「最大连接数」,而是「已完成连接队列 + 未完成连接队列」的总长度上限。说白了,就是内核为这个套接字预留的「待处理连接」缓冲区大小。

backlog 到底设多大? 我个人的建议是:高并发场景设 128 或 256,普通场景 32 就够了。设太大反而浪费内核内存。Linux 2.2 之后,backlog 实际是已完成连接队列的长度,未完成队列由 /proc/sys/net/ipv4/tcp_max_syn_backlog 控制。

listen() 成功返回 0,失败返回 -1。注意,listen() 不会阻塞——它只是设置一个状态,真正的连接等待在 accept() 里。

3.4 accept() —— 接起电话

accept() 是服务端最核心的阻塞函数。它从已完成连接队列中取出一个连接,返回一个新的套接字描述符。这个新套接字,才是真正用来和客户端通信的。

#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

addr 和 addrlen 用于获取客户端的地址信息。如果你不关心客户端是谁,可以传 NULL。

一个常见的误解:很多人以为 accept() 返回的套接字和 listen 的套接字是同一个。不是的!listen 的套接字继续监听,accept 返回的新套接字负责通信。我见过有人把 listen 的套接字直接拿去 recv(),结果一直收不到数据,急得团团转。

accept() 默认是阻塞的。如果没有新连接,它会一直等。如果你想做非阻塞,可以在 socket() 时加上 SOCK_NONBLOCK 标志,或者用 fcntl() 设置。

3.5 connect() —— 主动出击

客户端用 connect() 向服务端发起连接请求。它触发 TCP 的三次握手。

#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

connect() 也是阻塞的。它会一直等到三次握手完成,或者超时失败。超时时间由内核参数 tcp_syn_retries 控制,默认大约 20 秒左右。

注意:connect() 失败后,这个套接字就不能再用了。你必须 close() 它,然后重新 socket()。这是 POSIX 规范规定的,别问我为什么,我也觉得不合理。

常见的 connect() 错误:

  • ECONNREFUSED:服务端没有在监听。端口没开或者防火墙拦截了。
  • ETIMEDOUT:连接超时。可能是网络不通,或者服务端挂了。
  • EHOSTUNREACH:主机不可达。路由问题。

3.6 send() / recv() —— 真正的数据传输

连接建立之后,就可以用 send() 和 recv() 收发数据了。这两个函数是 TCP 专用的,UDP 用 sendto() 和 recvfrom()。

#include <sys/socket.h>
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 信号,而是返回 -1
MSG_PEEK 「偷看」数据,数据不会被移出接收缓冲区
MSG_WAITALL 等待接收完整 len 字节才返回(recv 专用)

返回值要仔细检查:send() 返回实际发送的字节数,可能小于 len。recv() 返回 0 表示对方优雅关闭连接,返回 -1 表示出错。我写代码时,每次调用 send() 和 recv() 都会检查返回值,从不偷懒。

recv() 有个特性:它不保证一次能收到你期望的字节数。TCP 是流式协议,没有消息边界。所以你需要自己处理「粘包」问题——要么固定长度,要么加长度头,要么用特殊分隔符。

3.7 close() —— 好聚好散

通信结束,记得 close()。这个函数关闭套接字,释放内核资源。

#include <unistd.h>
int close(int fd);

close() 的行为取决于套接字的引用计数。如果套接字被多个进程共享(比如 fork 之后),close() 只是减少引用计数,只有计数归零时才真正关闭。

半关闭状态:close() 会立即发送 FIN 包,但如果有数据还在发送缓冲区,内核会尝试发送完再关闭。如果你想只关闭写方向而保留读方向,可以用 shutdown(sockfd, SHUT_WR)。这个细节在高并发服务中很重要。

我曾经遇到过一个线上问题:服务端 close() 之后,客户端还在 recv(),结果 recv() 返回 0,客户端以为数据发完了,直接退出。后来加了 shutdown() 和超时机制才解决。

3.8 避坑指南:我踩过的那些坑

  • bind() 前忘记设置 sin_family:结构体没初始化,sin_family 是随机值,bind 失败。我至少帮三个同事排查过这个问题。
  • accept() 返回的套接字忘记 close():文件描述符泄漏,服务跑几天就报 "Too many open files"。用 lsof 一查,全是 CLOSE_WAIT 状态的连接。
  • send() 返回值没检查:发送大文件时,send() 可能只发送了一部分。不检查返回值直接继续,数据就丢了。
  • recv() 假设一次收完:TCP 是流,不是消息。你 recv(1024) 可能只收到 100 字节。必须循环接收直到收够为止。

我的习惯:每次写完 socket 代码,我都会用 strace 跟踪系统调用,看看每个函数的返回值是否如预期。这招帮我发现了不少隐藏 bug。

好了,这一章的内容就到这里。这些 API 是 socket 编程的基石,每个函数背后都有不少细节。多写、多调试、多踩坑,慢慢就熟练了。