2. Socket API详解:socket()、bind()、listen()、accept()、connect()、send()、recv()、close()

Socket编程,说白了就是让两台机器通过网络「对话」。而API就是这场对话的「语法规则」。我刚开始学的时候,觉得这些函数就是背背参数、调调接口。后来踩了无数坑才明白——每一个API背后,都藏着操作系统对网络连接的生命周期管理。

今天咱们就把这8个核心API掰开揉碎,一个一个讲清楚。你想想看,一个TCP连接从创建到关闭,本质上就是这8个函数的排列组合。

核心认知:Socket API不是孤立的函数调用,而是一套状态机。每个函数调用都会改变socket的内部状态,搞错顺序就会报错。

Socket API 核心调用流程 socket() 创建 bind() 绑定 listen() 监听 accept() 接受 socket() 创建 connect() 连接 send() / recv() 数据交换 close() 关闭 服务端(左) vs 客户端(右)

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

socket()是网络编程的「出生证明」。它告诉操作系统:我要创建一个网络通信的端点。返回值是一个文件描述符,后续所有操作都靠它。

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

// 典型用法
int sockfd = socket(AF_INET, SOCK_STREAM, 0);  // TCP
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);   // UDP

三个参数分别指定协议族、套接字类型和具体协议。我习惯把第三个参数传0,让系统根据前两个参数自动选择。比如AF_INET + SOCK_STREAM自动就是TCP。

个人经验:我曾经在项目中看到有人传IPPROTO_TCP作为第三个参数,结果代码在某个老系统上编译不过。后来我统一用0,再也没出过问题。说白了,除非你要用原始套接字,否则传0最省心。

2.2 bind() —— 给socket一个身份

bind()把socket和本地地址绑定在一起。就像你买了个手机(socket),得插上SIM卡(地址端口)才能用。

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

// IPv4地址结构
struct sockaddr_in {
    sa_family_t    sin_family;  // AF_INET
    in_port_t      sin_port;    // 端口号(网络字节序)
    struct in_addr sin_addr;    // IP地址
};

这里有个坑——端口号和IP地址必须用网络字节序(大端)。我见过新手直接传htons(8080)写成8080,结果bind总是失败。嗯,字节序转换是基本功,但确实容易忘。

避坑指南:我曾经在调试一个HTTP服务器时,发现bind返回EADDRINUSE。查了半天,原来是上一个进程没正常关闭,端口还在TIME_WAIT状态。解决方案是设置SO_REUSEADDR选项。

2.3 listen() —— 准备好接客

listen()把主动socket变成被动socket。说白了就是告诉内核:我要监听连接请求了,你先帮我排个队。

int listen(int sockfd, int backlog);

// 示例
listen(sockfd, 128);  // 最大等待队列长度128

backlog参数指定连接请求队列的最大长度。我建议不要设太大,128到256就够用。设得太大反而会掩盖系统负载问题。

关键理解:listen()之后,socket进入LISTEN状态。这时候内核会维护两个队列:未完成连接队列(SYN_RCVD)和已完成连接队列(ESTABLISHED)。accept()从已完成队列取连接。

2.4 accept() —— 建立连接

accept()从已完成连接队列中取出一个连接,返回一个新的socket描述符。原来的监听socket还在继续监听。

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

// 典型用法
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len);

这里有个细节:accept返回的是新socket,专门用于和这个客户端通信。监听socket只负责「接电话」,不负责「聊天」。

个人习惯:我写多线程服务器时,会在accept之后立即创建新线程处理client_fd,主线程继续accept。这样能充分利用多核CPU。但要注意线程安全问题——client_fd不要被多个线程同时操作。

2.5 connect() —— 主动出击

客户端用connect()发起连接请求。它会触发三次握手,直到连接建立或超时失败。

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

// 示例
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "192.168.1.100", &server_addr.sin_addr);
connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));

connect默认是阻塞的。如果服务器没启动,它会等几十秒才超时。我建议用非阻塞模式+select/poll/epoll来管理超时,用户体验会好很多。

避坑指南:我曾经写一个监控程序,connect超时设了75秒。结果服务器宕机时,监控程序卡了整整75秒才报错。后来改成非阻塞+3秒超时,问题解决。记住:默认超时是系统参数,通常很保守。

2.6 send() 和 recv() —— 真正的数据传输

连接建立后,send()和recv()负责收发数据。它们比read()/write()多了个flags参数,可以控制一些特殊行为。

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

// 示例
char *msg = "Hello, Server!";
send(client_fd, msg, strlen(msg), 0);

char buffer[1024];
int n = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
buffer[n] = '\0';

flags参数我99%的情况传0。偶尔用MSG_PEEK偷看数据而不移除,或者MSG_NOSIGNAL防止SIGPIPE信号。

核心要点:send()不一定一次发完所有数据,recv()也不一定一次收完。你需要循环调用,直到数据全部发送或接收完毕。这是TCP流式传输的特性,也是新手最容易忽略的地方。

2.7 close() —— 优雅地告别

close()关闭socket,释放资源。但这里有个「优雅关闭」的概念——你可能需要shutdown()来精细控制关闭方向。

int close(int fd);

// 更精细的控制
int shutdown(int sockfd, int how);
// how: SHUT_RD, SHUT_WR, SHUT_RDWR

close会把引用计数减1,只有减到0时才真正关闭。而shutdown可以立即关闭某个方向的数据流。我通常在关闭写方向后,继续读数据直到recv返回0,确保对方收到了所有数据。

个人经验:我曾经在写一个文件传输工具时,直接close导致对方收不到最后一段数据。后来改成先shutdown(SHUT_WR)通知对方「我发完了」,再循环recv直到对方也关闭,问题解决。这叫「四次挥手」的完整流程。

2.8 完整示例:一个简单的回声服务器

把上面8个API串起来,就是一个完整的TCP回声服务器。客户端发什么,服务器原样返回。

// 服务端代码片段
int main() {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8888);
    addr.sin_addr.s_addr = INADDR_ANY;
    
    bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(listen_fd, 128);
    
    while (1) {
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        int client_fd = accept(listen_fd, (struct sockaddr*)&client, &len);
        
        char buf[1024];
        int n = recv(client_fd, buf, sizeof(buf), 0);
        send(client_fd, buf, n, 0);
        
        close(client_fd);
    }
    close(listen_fd);
    return 0;
}

注意:上面的示例为了简洁省略了错误处理。实际项目中,每个API调用都要检查返回值。我曾经因为没检查bind返回值,调试了整整一个下午才发现端口被占用了。

2.9 总结:API调用顺序就是连接的生命周期

API 角色 核心作用 常见错误
socket() 双方 创建通信端点 协议族/类型不匹配
bind() 服务端 绑定本地地址端口 端口被占用、字节序错误
listen() 服务端 进入监听状态 backlog设置不合理
accept() 服务端 接受连接请求 阻塞导致线程挂起
connect() 客户端 发起连接 超时处理不当
send() 双方 发送数据 未处理部分发送
recv() 双方 接收数据 未处理粘包/半包
close() 双方 关闭连接 未优雅关闭导致数据丢失

这8个API,说多不多,说少不少。但每一个都对应着TCP协议栈的一个状态转换。你想想看,从socket创建时的CLOSED状态,到listen后的LISTEN,再到accept后的ESTABLISHED,最后close回到CLOSED——这不就是一次完整的网络连接生命周期吗?

我个人建议,初学者先把这个流程跑通,再考虑非阻塞、多路复用、异步IO这些进阶话题。基础不牢,地动山摇。我见过太多人一上来就搞epoll,结果连accept返回-1都不知道怎么处理。

一句话总结:Socket API是网络编程的「九阳神功」——招式简单,但内力深厚。练好这8招,天下网络任你走。

可可精品资料库