3、Socket API概览:BSD Socket接口、常用函数及其参数和返回值

BSD Socket API 是网络编程的基石,自 1983 年在 4.2BSD 中首次引入以来,它已成为事实上的跨平台网络编程标准。无论是 Linux、Windows(Winsock)还是 macOS,其核心接口都遵循同一套设计哲学:将网络端点抽象为文件描述符,通过一组统一的函数完成通信控制。理解这套 API 的调用关系、参数语义和返回值约定,是构建稳定、高效网络应用的前提。本章将系统梳理从创建套接字到关闭连接的全生命周期,并深入剖析每个关键函数的底层行为与工程陷阱。

BSD Socket 核心调用链路与状态流转 socket() 创建套接字 bind() 绑定地址与端口 listen() 进入监听状态 accept() 接受客户端连接 recv() / send() 数据收发 close() 关闭连接 socket() 创建套接字 connect() 发起连接请求 send() / recv() 数据收发 close() 关闭连接 TCP 三次握手 连接建立后双向数据流 TCP 四次挥手 核心状态 LISTEN → ESTABLISHED SYN_SENT → ESTABLISHED FIN_WAIT → TIME_WAIT 返回值关键约定 成功:≥0(fd 或字节数) 失败:-1,errno 指示原因 阻塞 vs 非阻塞 阻塞:调用挂起直到完成 非阻塞:立即返回 EAGAIN

上图展示了 TCP 协议下客户端与服务器端完整的 Socket 调用时序。服务器端遵循 socket → bind → listen → accept → recv/send → close 的固定路径,而客户端则走 socket → connect → send/recv → close 的简化路径。两条路径在 accept 与 connect 处交汇,完成三次握手后进入数据交换阶段。理解这张图的关键在于:服务器端的 accept 返回的是一个全新的套接字描述符,它专门用于与特定客户端通信,而原始的监听套接字继续等待其他连接请求。

3.1 核心函数逐一拆解

3.4.1 socket():一切通信的起点

socket() 函数用于创建一个网络端点,其原型为:

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

三个参数分别控制协议族、传输类型和具体协议。工程中最常用的组合是 AF_INET + SOCK_STREAM + 0(TCP)和 AF_INET + SOCK_DGRAM + 0(UDP)。protocol 参数通常传 0,表示由内核根据前两个参数自动选择默认协议。返回值是文件描述符(非负整数),失败时返回 -1 并设置 errno。常见的 errno 包括 EAFNOSUPPORT(协议族不支持)和 EMFILE(进程文件描述符耗尽)。

经验提醒:在 Linux 上,SOCK_CLOEXEC 标志可以与 type 按位或,例如 socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0)。这能防止在 exec 后文件描述符泄漏到子进程,是多线程服务器中避免竞态条件的标准做法。

3.4.2 bind():给套接字一个身份

bind() 将本地地址(IP + 端口)与套接字绑定。对于服务器,这是必须的步骤;对于客户端,通常可以省略,由内核自动分配临时端口。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

第二个参数是通用地址结构指针,实际使用时需要根据协议族转换为 struct sockaddr_in(IPv4)或 struct sockaddr_in6(IPv6)。addrlen 必须传入实际结构体的大小。一个典型的 IPv4 绑定代码如下:

struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 监听所有网卡
serv_addr.sin_port = htons(8080);
if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
    perror("bind failed");
    exit(EXIT_FAILURE);
}

这里有两个关键点:字节序转换(htonl/htons)和地址通配(INADDR_ANY)。端口号 0 表示让内核随机分配,常用于客户端或测试场景。若端口已被占用,bind 会返回 EADDRINUSE 错误。

3.4.3 listen():进入被动接收状态

int listen(int sockfd, int backlog);

listen() 将主动套接字转换为被动监听套接字。backlog 参数指定内核维护的连接队列最大长度,包括已完成三次握手(ESTABLISHED)和尚未完成握手的半连接(SYN_RCVD)。在 Linux 2.2 之后,backlog 仅表示已完成连接队列的长度,半连接队列由 tcp_max_syn_backlog 内核参数控制。典型值设为 128 或 256,过高并不会提升性能,反而可能加剧 SYN Flood 攻击的影响。

3.4.4 accept():提取已完成连接

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

accept() 从已完成连接队列头部取出一个连接,返回一个新的套接字描述符。后两个参数用于获取客户端地址信息,如果不关心可以传 NULL。这是一个阻塞函数:当队列为空时,调用线程会挂起,直到有新连接到达。返回值是新的 fd,失败返回 -1。常见的错误包括 EINTR(被信号中断)和 EMFILE(进程 fd 耗尽)。

风险提示:在高并发场景下,accept 返回 EMFILE 时,如果直接关闭监听套接字,会导致所有新连接被内核丢弃。更稳妥的做法是维护一个“备用 fd”,在资源紧张时先关闭它,再 accept 新连接,然后立即关闭新连接,最后重新打开备用 fd。这种“救急”模式能有效避免连接风暴。

3.4.5 connect():主动发起连接

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

客户端使用 connect() 向服务器发起 TCP 握手。对于 TCP,该函数会触发三次握手过程;对于 UDP,它仅设置默认的对端地址,不产生网络交互。阻塞模式下,connect 会等待握手完成或超时返回。非阻塞模式下,立即返回 EINPROGRESS,后续通过 select/poll 检测可写事件来判断连接是否成功。

3.4.6 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);

这两个函数是 TCP 数据传输的主力。send() 返回实际发送的字节数,可能小于 len,需要循环发送直到全部发完。recv() 返回接收到的字节数,返回 0 表示对端已关闭连接。关键标志位包括:

  • MSG_DONTWAIT:本次调用非阻塞,即使套接字是阻塞模式
  • MSG_PEEK:查看数据但不移除(常用于探测)
  • MSG_NOSIGNAL:禁止发送 SIGPIPE 信号(对端关闭后继续 send 会触发)

对于 UDP,应使用 sendto()/recvfrom(),它们额外携带目标地址参数。

3.4.7 close():优雅或强制关闭

int close(int fd);

close() 将套接字引用计数减 1,当计数归零时触发 TCP 四次挥手。对于多线程程序,如果多个线程共享同一个 fd,需要确保所有线程都关闭后才真正断开。若想立即终止连接(发送 RST),可使用 setsockopt(SO_LINGER) 配置。此外,shutdown() 函数可以单独关闭读或写方向,比 close 更精细。

3.2 函数调用流程与状态转换

为了更直观地理解各函数在 TCP 状态机中的位置,下表总结了关键函数与 TCP 状态的对应关系:

函数 调用前状态 调用后状态 典型返回值 失败场景
socket() 无 CLOSED fd ≥ 0 EMFILE / EAFNOSUPPORT
bind() CLOSED CLOSED 0 EADDRINUSE / EACCES
listen() CLOSED LISTEN 0 EOPNOTSUPP(UDP)
accept() LISTEN ESTABLISHED(新 fd) 新 fd ≥ 0 EINTR / EMFILE
connect() CLOSED ESTABLISHED / SYN_SENT 0 ECONNREFUSED / ETIMEDOUT
send() ESTABLISHED ESTABLISHED 发送字节数 ≥ 0 EPIPE / ECONNRESET
recv() ESTABLISHED ESTABLISHED / CLOSE_WAIT 接收字节数 ≥ 0,0 表示对端关闭 ECONNRESET / EAGAIN
close() 任意 CLOSED / TIME_WAIT 0 EBADF

3.3 实战案例:构建一个最小可用的 TCP 回显服务器

下面通过一个完整的回显服务器案例,将上述函数串联起来。该服务器接收客户端发送的任意文本,原样返回。我们重点观察阻塞模式下的行为特征。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define PORT 8888
#define BUFFER_SIZE 1024

int main() {
    int listen_fd, conn_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    char buffer[BUFFER_SIZE];
    ssize_t n;

    // 1. 创建套接字
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    // 2. 设置 SO_REUSEADDR,避免 TIME_WAIT 导致端口无法立即重用
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 3. 绑定地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(PORT);
    if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }

    // 4. 监听
    if (listen(listen_fd, 128) == -1) {
        perror("listen");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }
    printf("Echo server listening on port %d...\n", PORT);

    // 5. 接受连接并回显
    while (1) {
        conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
        if (conn_fd == -1) {
            perror("accept");
            continue;
        }
        printf("Client connected: %s:%d\n", 
               inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

        // 回显循环
        while ((n = recv(conn_fd, buffer, BUFFER_SIZE, 0)) > 0) {
            send(conn_fd, buffer, n, 0);  // 原样返回
        }
        if (n == 0) {
            printf("Client closed connection.\n");
        } else {
            perror("recv");
        }
        close(conn_fd);
    }

    close(listen_fd);
    return 0;
}

这个案例虽然只有 60 行,但涵盖了所有核心 API。运行后,可以用 telnet 127.0.0.1 8888 或 nc 127.0.0.1 8888 测试。观察点:

  • 阻塞行为:accept 和 recv 都会阻塞当前线程,因此该服务器只能同时服务一个客户端。
  • 半关闭问题:当客户端发送完数据后调用 shutdown(SHUT_WR),服务器 recv 会返回 0,但服务器仍可 send 数据。本例中直接关闭连接,未处理半关闭场景。
  • 粘包问题:TCP 是字节流协议,recv 返回的数据可能不完整或包含多条消息。本例未做消息边界处理,仅适用于演示。
案例复盘:该服务器在生产环境中不可直接使用,原因有三:一是单线程阻塞模型无法支撑并发;二是未处理 EINTR 错误(信号中断导致 accept/recv 返回 -1);三是未设置接收超时,恶意客户端可长期占用连接。改进方向包括:使用 fork 或线程池、设置 SO_RCVTIMEO、增加应用层心跳机制。

3.4 常见误区与工程建议

在实际开发中,以下问题出现频率最高,需要特别警惕:

  1. 忽略返回值:send 返回的字节数可能小于请求长度,必须循环发送。许多初学者直接判断返回值等于 len,导致数据丢失。
  2. 字节序混乱:端口和 IP 地址必须使用 htons/htonl 转换。在 x86 等小端机器上,直接赋值会导致端口错误。
  3. 误用 close 与 shutdown:close 只是减少引用计数,多线程共享 fd 时不会立即断开。shutdown(SHUT_RDWR) 才能真正终止双向数据流。
  4. 忽视 SIGPIPE:当对端已关闭连接,继续 send 会触发 SIGPIPE 信号,默认终止进程。应在 send 时加上 MSG_NOSIGNAL 标志,或忽略该信号。
  5. 端口复用未设置:服务器重启时,若上次连接处于 TIME_WAIT 状态,bind 会失败。务必在 bind 前设置 SO_REUSEADDR。
性能陷阱:不要在每个连接上单独调用 setsockopt 设置 TCP_NODELAY。虽然禁用 Nagle 算法能降低延迟,但会显著增加小包数量,在广域网场景下可能降低吞吐量。建议仅在交互式应用(如 SSH、游戏)中启用,并配合 TCP_CORK 进行批量发送优化。

最后,关于 errno 的处理,建议使用 perror 或 strerror 输出可读信息,并区分可重试错误(EINTR、EAGAIN)和致命错误(EBADF、EPIPE)。在编写库代码时,应避免直接打印日志,而是将错误码返回给调用方,由上层决定处理策略。