75、网络编程基础:socket、bind、listen、accept
网络编程,说白了就是让两台机器能互相说话。很多初学者一上来就被各种API搞晕了,觉得特别抽象。我当年刚接触时也一样,对着man page看了半天,脑子里还是一团浆糊。后来亲手写了个聊天程序,才真正明白这些函数到底在干什么。
今天咱们就把socket、bind、listen、accept这四个函数彻底讲透。你想想看,一个服务器要接受客户端的连接,本质上就四步:拿个电话机、绑上号码、开机等着、有人打进来就接。嗯,就是这么回事。
1. socket——拿个电话机
socket函数的作用,就是创建一个通信端点。你可以把它理解成「拿一个电话机」。电话机有不同型号,socket也有不同类型。
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
三个参数分别指定:
- domain:协议族。最常用的是
AF_INET(IPv4)和AF_INET6(IPv6)。 - type:套接字类型。
SOCK_STREAM是流式套接字(TCP),SOCK_DGRAM是数据报套接字(UDP)。 - protocol:具体协议。一般传0,让系统自动选择。
socket(AF_INET, SOCK_STREAM, 0)。第三个参数传0,系统会自动匹配TCP协议。省事,也不容易出错。
返回值是一个文件描述符。如果返回-1,说明创建失败。我在项目中遇到过有人忘了检查返回值,结果后面bind到一个无效描述符上,排查了半天才发现是socket创建时资源不够了。
2. bind——绑上号码
有了电话机,你得给它分配一个号码,别人才能打进来。bind函数就是把一个本地地址绑定到socket上。
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
这里有个坑:struct sockaddr是个通用结构体,实际使用时我们一般用struct sockaddr_in(IPv4)或struct sockaddr_in6(IPv6),然后强制类型转换。为什么这么设计?说白了就是历史遗留问题,早期C语言没有void指针,只能这么搞。
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(8888); // 端口号
bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
htonl和htons转换。我曾经见过有人直接写端口号,结果bind成功但客户端死活连不上——因为端口被解释成了另一个值。
bind失败常见原因:端口被占用、权限不够(绑定1024以下端口需要root)、地址已被使用但未设置SO_REUSEADDR选项。
3. listen——开机等着
bind之后,socket还处于「待机」状态。你需要调用listen,告诉内核:这个socket要作为服务器,准备接受连接请求。
#include <sys/socket.h>
int listen(int sockfd, int backlog);
backlog参数指定连接请求队列的最大长度。什么意思呢?当多个客户端同时发起连接时,内核会维护一个队列。backlog就是这个队列的上限。
我个人的建议:backlog不要设太大,也不要太小。一般设5到128之间。设太大浪费内存,设太小在高并发时容易丢连接。Linux内核实际会取这个值和/proc/sys/net/core/somaxconn的较小值。
关键点:listen只是让socket进入监听状态,它并不阻塞。调用后立即返回,真正的阻塞发生在accept那里。
4. accept——有人打进来就接
这是最核心的一步。accept从已完成连接队列中取出一个连接,并返回一个新的socket描述符。这个新描述符才是真正用来和客户端通信的。
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
第二个参数用来获取客户端的地址信息,如果不需要可以传NULL。第三个参数是地址结构体长度的指针,传入时指定缓冲区大小,返回时被设置为实际地址长度。
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int connfd = accept(listenfd, (struct sockaddr *)&cli_addr, &cli_len);
if (connfd < 0) {
perror("accept error");
exit(1);
}
这里有个容易忽略的点:accept返回的connfd和原来的listenfd是两回事。listenfd继续监听新的连接,connfd专门和这个客户端通信。我见过有人搞混了,拿着listenfd去收发数据,结果一直报错。
完整流程与SVG图解
下面这张图把整个流程串起来了。从socket创建,到bind绑定,到listen监听,再到accept接受连接,最后用新描述符通信。每一步都清清楚楚。
常见错误与避坑指南
写网络程序,坑是真的多。我把自己踩过的和帮别人排查过的常见问题列出来,你写代码时多留个心眼。
| 错误 | 现象 | 原因 | 解决方法 |
|---|---|---|---|
| bind: Address already in use | bind返回-1,errno为EADDRINUSE | 端口被占用,或上次程序退出后未释放 | 设置SO_REUSEADDR选项 |
| accept返回-1 | 客户端连接不上 | listenfd无效、队列满、被信号中断 | 检查listenfd,处理EINTR |
| 客户端连接成功但无法通信 | read/write返回-1 | 用了listenfd而不是connfd | 确认使用accept返回的描述符 |
| 端口号不对 | 客户端连不上服务器 | bind时未用htons转换端口 | 使用htons()转换端口号 |
我曾经踩过的坑:有一次写一个压力测试程序,客户端疯狂发起连接,结果accept返回了-1。查了半天,发现是backlog设得太小,连接队列满了。后来我把backlog调大,同时加了重试机制,问题就解决了。
一个完整的TCP服务器骨架
说了这么多,不如直接看代码。下面是一个最简的TCP服务器框架,你可以在它的基础上扩展。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8888
#define BACKLOG 5
int main() {
int listenfd, connfd;
struct sockaddr_in serv_addr, cli_addr;
socklen_t cli_len;
// 1. 创建socket
listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd < 0) {
perror("socket");
exit(1);
}
// 设置SO_REUSEADDR,避免端口被占用
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 2. 绑定地址
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(PORT);
if (bind(listenfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
perror("bind");
close(listenfd);
exit(1);
}
// 3. 监听
if (listen(listenfd, BACKLOG) < 0) {
perror("listen");
close(listenfd);
exit(1);
}
printf("Server listening on port %d...\n", PORT);
// 4. 接受连接
cli_len = sizeof(cli_addr);
connfd = accept(listenfd, (struct sockaddr *)&cli_addr, &cli_len);
if (connfd < 0) {
perror("accept");
close(listenfd);
exit(1);
}
printf("Client connected!\n");
// 5. 通信(这里只做演示,实际需要循环处理)
char buf[1024];
read(connfd, buf, sizeof(buf));
printf("Received: %s\n", buf);
// 6. 关闭
close(connfd);
close(listenfd);
return 0;
}
我的建议:实际项目中,accept之后通常会fork一个子进程来处理通信,或者用select/epoll实现多路复用。单进程串行处理只能用于教学演示,千万别用在生产环境。
好了,socket、bind、listen、accept这四个函数,咱们今天就讲到这里。你想想看,其实网络编程没那么神秘,就是按照流程一步步来。多写几遍,自然就熟了。
公众号:蓝海资料掘金营,微信deep3321