2、Socket API 详解:从创建到关闭的完整旅程

Socket API,说白了就是操作系统给应用层开的一扇门。你通过这扇门,把数据扔给内核,内核再帮你发到对端。我刚开始学网络编程时,总觉得这些函数就是背背参数、调调顺序就完事了。后来线上出了几次事故,才明白每个 API 背后的细节,才是真正决定程序健壮性的关键。

这一章,咱们把 socket()、bind()、listen()、accept()、connect()、send()、recv()、close() 这八个函数逐个拆开。每个函数我都会结合自己的踩坑经历来讲,希望能帮你少走弯路。

核心要点:Socket API 是 TCP 协议栈的用户态接口。理解每个函数的阻塞行为、错误码和资源管理,是写出高并发、高可靠网络程序的基础。

Socket API 调用流程 Socket API 核心调用流程 socket() 创建 bind() 绑定地址 listen() 监听 accept() 接受连接 recv() / send() close() 关闭 socket() 创建 connect() 连接 send() / recv() close() 关闭 说明 服务端:socket → bind → listen → accept → recv/send → close 客户端:socket → connect → send/recv → close

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 看起来简单,但每个都有不少细节。我当年刚入行时,觉得背下来参数就完事了。后来线上出了几次事故,才明白真正重要的是理解它们的行为——阻塞还是非阻塞?返回值怎么处理?错误码代表什么?这些才是写出健壮网络程序的关键。