3、Socket API概览:BSD Socket接口、常用函数及其参数和返回值
BSD Socket API 是网络编程的基石,自 1983 年在 4.2BSD 中首次引入以来,它已成为事实上的跨平台网络编程标准。无论是 Linux、Windows(Winsock)还是 macOS,其核心接口都遵循同一套设计哲学:将网络端点抽象为文件描述符,通过一组统一的函数完成通信控制。理解这套 API 的调用关系、参数语义和返回值约定,是构建稳定、高效网络应用的前提。本章将系统梳理从创建套接字到关闭连接的全生命周期,并深入剖析每个关键函数的底层行为与工程陷阱。
上图展示了 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(进程文件描述符耗尽)。
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 常见误区与工程建议
在实际开发中,以下问题出现频率最高,需要特别警惕:
- 忽略返回值:
send返回的字节数可能小于请求长度,必须循环发送。许多初学者直接判断返回值等于len,导致数据丢失。 - 字节序混乱:端口和 IP 地址必须使用
htons/htonl转换。在 x86 等小端机器上,直接赋值会导致端口错误。 - 误用
close与shutdown:close只是减少引用计数,多线程共享 fd 时不会立即断开。shutdown(SHUT_RDWR)才能真正终止双向数据流。 - 忽视
SIGPIPE:当对端已关闭连接,继续send会触发SIGPIPE信号,默认终止进程。应在send时加上MSG_NOSIGNAL标志,或忽略该信号。 - 端口复用未设置:服务器重启时,若上次连接处于
TIME_WAIT状态,bind会失败。务必在bind前设置SO_REUSEADDR。
setsockopt 设置 TCP_NODELAY。虽然禁用 Nagle 算法能降低延迟,但会显著增加小包数量,在广域网场景下可能降低吞吐量。建议仅在交互式应用(如 SSH、游戏)中启用,并配合 TCP_CORK 进行批量发送优化。
最后,关于 errno 的处理,建议使用 perror 或 strerror 输出可读信息,并区分可重试错误(EINTR、EAGAIN)和致命错误(EBADF、EPIPE)。在编写库代码时,应避免直接打印日志,而是将错误码返回给调用方,由上层决定处理策略。