第39章:网络编程基础:Socket API与I/O多路复用
网络编程,说白了就是让两台机器能互相说话。我刚开始接触这块时,总觉得它很神秘——数据是怎么从一台电脑跑到另一台电脑的?后来亲手写了几个Socket程序,才明白其实没那么玄乎。今天我们就来聊聊网络编程的核心:Socket API、TCP/UDP的区别,以及如何用select/poll/epoll处理大量连接。
39.1 Socket API:网络通信的基石
Socket,中文叫“套接字”,是操作系统提供的一个抽象层。你可以把它想象成一根电话线——一端连着你的程序,另一端连着网络。通过这根“线”,你可以发送和接收数据。
常用的Socket API有五个核心函数:socket()、bind()、listen()、accept()、connect()。它们的使用顺序,决定了你是服务端还是客户端。
39.1.1 服务端流程
服务端的套路很固定:创建Socket → 绑定地址 → 监听 → 接受连接 → 收发数据。我习惯把这个流程称为“四步走”。
// 1. 创建Socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
exit(1);
}
// 2. 绑定地址
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;
if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
exit(1);
}
// 3. 监听
if (listen(sockfd, 10) < 0) {
perror("listen");
exit(1);
}
// 4. 接受连接
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int clientfd = accept(sockfd, (struct sockaddr*)&client_addr, &len);
if (clientfd < 0) {
perror("accept");
exit(1);
}
// 5. 收发数据
char buf[1024];
int n = recv(clientfd, buf, sizeof(buf), 0);
send(clientfd, "OK", 2, 0);
嗯,这里要注意:htons()和htonl()是用来转换字节序的。不同机器的字节序可能不同,网络传输统一用大端序。我当年第一次写服务端时,忘了做这个转换,结果端口号死活对不上,排查了半天才发现是字节序的问题。
39.1.2 客户端流程
客户端更简单:创建Socket → 连接服务器 → 收发数据。
// 1. 创建Socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 连接服务器
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
exit(1);
}
// 3. 收发数据
send(sockfd, "Hello", 5, 0);
char buf[1024];
int n = recv(sockfd, buf, sizeof(buf), 0);
你想想看,客户端不需要bind()和listen(),因为操作系统会自动分配一个临时端口给它。而服务端必须显式绑定一个固定端口,这样客户端才能找到它。
核心要点:
socket():创建通信端点,返回文件描述符bind():将Socket绑定到本地地址和端口listen():将Socket设为被动监听状态accept():从已完成连接队列中取出一个连接connect():主动发起连接请求
39.2 TCP与UDP编程
TCP和UDP是传输层的两个主要协议。它们的区别,我用一个比喻来解释:TCP像打电话——先建立连接,确保对方能听到,然后一句一句说,顺序不乱;UDP像发短信——直接扔出去,不管对方收没收到,也不管顺序。
39.2.1 TCP编程
TCP是面向连接的、可靠的、基于字节流的协议。它的特点:
- 三次握手:建立连接时,客户端和服务端交换三个报文
- 可靠传输:丢包会重传,乱序会重组
- 流量控制:防止发送方太快,接收方来不及处理
- 拥塞控制:网络拥堵时自动降低发送速率
我在项目中遇到过一个问题:用TCP传输大文件时,速度越来越慢。后来发现是拥塞控制算法在作祟——默认的Cubic算法在长肥网络中表现不佳,换成BBR算法后速度提升了好几倍。
个人经验:TCP的send()函数不一定一次发送完所有数据。它返回的是实际发送的字节数。我建议写一个循环,直到所有数据都发送完毕。同样,recv()也可能只收到部分数据,需要循环接收。
39.2.2 UDP编程
UDP是无连接的、不可靠的、基于数据报的协议。它的特点:
- 无连接:直接发送,不需要握手
- 不可靠:丢包不重传,乱序不重组
- 低延迟:没有拥塞控制,适合实时应用
- 支持广播和多播:可以一对多发送
// UDP服务端
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
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));
char buf[1024];
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int n = recvfrom(sockfd, buf, sizeof(buf), 0,
(struct sockaddr*)&client_addr, &len);
sendto(sockfd, "OK", 2, 0,
(struct sockaddr*)&client_addr, len);
UDP的recvfrom()和sendto()需要指定对端地址,因为UDP是无连接的,每次发送都要告诉内核“发给谁”。
避坑指南:我曾经在UDP程序中犯过一个低级错误——没有检查recvfrom()的返回值。结果对方发送的数据包太大,超过了缓冲区大小,数据被截断了。UDP数据报如果大于缓冲区,会被直接丢弃。所以一定要确保缓冲区足够大,或者分片发送。
39.3 select/poll/epoll模型简介
当你的服务端需要同时处理成百上千个连接时,用多线程或单线程轮询都不现实。这时候就需要I/O多路复用技术。Linux下主要有三种:select、poll、epoll。
39.3.1 select模型
select是最古老的I/O多路复用函数。它用一个位图来监控多个文件描述符。
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
FD_SET(clientfd, &readfds);
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
int ret = select(maxfd + 1, &readfds, NULL, NULL, &tv);
if (ret > 0) {
if (FD_ISSET(sockfd, &readfds)) {
// 有新连接
}
if (FD_ISSET(clientfd, &readfds)) {
// 有数据可读
}
}
select的缺点很明显:
- 文件描述符数量有限制(默认1024)
- 每次调用都要把整个fd_set从用户态拷贝到内核态
- 内核需要遍历所有fd,效率随fd数量线性下降
39.3.2 poll模型
poll用链表代替了位图,突破了1024的限制。但其他问题依然存在。
struct pollfd fds[2];
fds[0].fd = sockfd;
fds[0].events = POLLIN;
fds[1].fd = clientfd;
fds[1].events = POLLIN;
int ret = poll(fds, 2, 5000);
if (ret > 0) {
if (fds[0].revents & POLLIN) {
// 有新连接
}
if (fds[1].revents & POLLIN) {
// 有数据可读
}
}
poll比select好一点,但本质上还是遍历所有fd。当连接数达到几万时,性能会急剧下降。
39.3.3 epoll模型
epoll是Linux下最高效的I/O多路复用模型。它解决了select和poll的两个核心问题:
- 事件驱动:只返回就绪的fd,不需要遍历所有fd
- 内存映射:通过mmap减少用户态和内核态的数据拷贝
// 创建epoll实例
int epfd = epoll_create1(0);
// 添加事件
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
// 等待事件
struct epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == sockfd) {
// 有新连接
} else {
// 有数据可读
}
}
epoll有两种工作模式:
- LT(水平触发):默认模式。只要fd还有数据可读,每次epoll_wait都会返回它
- ET(边缘触发):只有状态发生变化时才通知。比如数据从无到有时,只通知一次
我的建议:新手先用LT模式,它更安全,不容易漏掉事件。ET模式虽然效率更高,但需要配合非阻塞I/O和循环读取,否则容易丢数据。我在一个高并发网关项目中用过ET模式,性能确实好,但调试起来也够呛。
39.4 三种模型的对比
| 特性 | select | poll | epoll |
|---|---|---|---|
| 最大连接数 | 1024(有限制) | 无限制 | 无限制 |
| 遍历方式 | 遍历所有fd | 遍历所有fd | 只遍历就绪fd |
| 数据拷贝 | 每次拷贝全部fd | 每次拷贝全部fd | mmap共享内存 |
| 触发方式 | LT | LT | LT/ET |
| 适用场景 | 连接数少(<100) | 连接数中等(<1000) | 高并发(>1000) |
说白了,如果你只是写个简单的聊天室,select就够了。但如果你要写一个支持百万连接的服务器,epoll是唯一的选择。
39.5 知识体系总览
下面这张图展示了本章的核心知识结构:
这张图把本章内容分成了三层:最上层是Socket API的核心函数,中间是TCP/UDP两种编程模式,最下层是三种I/O多路复用模型。从下往上看,你会发现网络编程的演进路径:从单连接到多连接,从阻塞到非阻塞,从低效到高效。
最后说一句:网络编程入门不难,但想写好却不容易。我建议你从最简单的TCP回射服务器开始,先跑通一个客户端一个服务端,然后逐步增加并发连接数,最后尝试用epoll优化。每一步踩过的坑,都是你成长的阶梯。