4. 阻塞与非阻塞:从“等”到“不等”的进化
聊到Socket编程,绕不开的一个话题就是阻塞。说白了,阻塞就是你的程序在调用某个函数时,如果条件不满足,它就停在那干等,啥也不干。我刚开始学网络编程那会儿,觉得这很正常啊——等数据来了再处理,天经地义。但后来在项目中吃过亏,才发现事情没那么简单。
4.1 阻塞模式:最直观,也最危险
默认情况下,我们创建的Socket都是阻塞模式。拿recv()来说,如果内核缓冲区里没数据,这个调用就会一直挂起,直到有数据到达或者连接断开。
// 阻塞模式示例(伪代码)
int sock = socket(AF_INET, SOCK_STREAM, 0);
connect(sock, ...);
char buf[1024];
int n = recv(sock, buf, sizeof(buf), 0); // 这里会阻塞,直到有数据
printf("收到 %d 字节\n", n);
这段代码看起来简单,但有个致命问题:如果对方一直不发数据,你的程序就永远卡在recv()那行。单线程下,整个进程都停了。多线程呢?每个连接一个线程,连接一多,线程开销直接爆炸。
4.2 非阻塞模式:把控制权拿回来
非阻塞模式就聪明多了。调用recv()时,如果没数据,它立刻返回一个错误码EAGAIN或EWOULDBLOCK,告诉你“现在没有,你等会儿再来问”。这样你的程序就可以去做别的事,不用死等。
设置非阻塞很简单,就一行代码:
// 设置非阻塞模式
int flags = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);
// 之后调用 recv 就不会阻塞了
int n = recv(sock, buf, sizeof(buf), 0);
if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// 没数据,先干别的
printf("暂时没数据,稍后再试\n");
} else if (n > 0) {
printf("收到 %d 字节\n", n);
}
嗯,这里要注意:非阻塞模式需要配合循环来轮询。你想想看,如果一直轮询,CPU占用率会飙升。所以实际项目中,很少单独用非阻塞,通常会搭配后面要讲的I/O多路复用。
4.3 超时控制:给等待加个期限
阻塞模式太死板,非阻塞模式又太“忙”。有没有折中方案?有,就是超时控制。你可以通过setsockopt()设置SO_RCVTIMEO和SO_SNDTIMEO,给收发操作加个时间上限。
struct timeval tv;
tv.tv_sec = 5; // 5秒
tv.tv_usec = 0;
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
// 现在 recv 最多等5秒
int n = recv(sock, buf, sizeof(buf), 0);
if (n == -1 && errno == EAGAIN) {
printf("接收超时\n");
}
我个人习惯用超时控制来处理一些“半阻塞”场景。比如心跳检测,如果5秒内没收到心跳包,就认为连接断了。这样既不用一直轮询,也不会无限期阻塞。
4.4 select/poll/epoll:I/O多路复用的三兄弟
前面说的都是单个Socket的处理。但实际项目中,一个服务器要管成百上千个连接。总不能每个连接开一个线程吧?这时候就需要I/O多路复用技术了。
简单说,就是用一个线程同时监控多个Socket,哪个有数据就处理哪个。select、poll、epoll就是干这个的。
4.4.1 select:老当益壮
select是最早的方案,几乎所有操作系统都支持。它的原理是把要监控的Socket集合传给内核,内核遍历一遍,返回有事件的Socket。
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock1, &readfds);
FD_SET(sock2, &readfds);
struct timeval tv = {5, 0}; // 5秒超时
int ret = select(max_fd + 1, &readfds, NULL, NULL, &tv);
if (ret > 0) {
if (FD_ISSET(sock1, &readfds)) {
// sock1 可读
}
if (FD_ISSET(sock2, &readfds)) {
// sock2 可读
}
}
但select有个硬伤:它用位图来管理Socket集合,默认上限是1024个。而且每次调用都要把整个集合从用户态拷贝到内核态,效率不高。
4.4.2 poll:改进版
poll解决了select的1024限制,改用链表来管理文件描述符。但性能问题还在——它仍然需要遍历所有监控的Socket。
struct pollfd fds[2];
fds[0].fd = sock1;
fds[0].events = POLLIN;
fds[1].fd = sock2;
fds[1].events = POLLIN;
int ret = poll(fds, 2, 5000); // 5秒超时
if (ret > 0) {
if (fds[0].revents & POLLIN) {
// sock1 可读
}
}
poll的API比select清晰一些,但本质还是“遍历+拷贝”的模式。连接数一多,性能就直线下降。
4.4.3 epoll:Linux下的王者
epoll是Linux特有的方案,也是目前最高效的。它解决了两个核心问题:
- 避免遍历: 只返回有事件的Socket,不用遍历全部
- 避免拷贝: 用内存映射技术,减少用户态和内核态的数据拷贝
// 创建 epoll 实例
int epfd = epoll_create1(0);
// 添加监控
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock1;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock1, &ev);
// 等待事件
struct epoll_event events[10];
int n = epoll_wait(epfd, events, 10, 5000);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
// 处理 events[i].data.fd 上的数据
}
}
epoll有两种触发模式:
| 模式 | 特点 | 适用场景 |
|---|---|---|
| 水平触发(LT) | 只要缓冲区有数据,就不断通知 | 简单、不容易漏事件 |
| 边缘触发(ET) | 只在状态变化时通知一次 | 高性能、需要一次性读完 |
我个人建议新手先用水平触发。边缘触发虽然性能更好,但处理起来容易出bug——如果你没把数据读完,内核就不会再通知你,数据就丢了。
- select:跨平台,但上限1024,效率低
- poll:无上限,但效率依然不高
- epoll:Linux专属,性能最强,支持百万级连接
4.5 知识体系总览
下面这张图把本章的核心知识点串起来了,你可以对照着理解:
从这张图可以看得很清楚:阻塞模式最简单,但只适合低并发场景;非阻塞模式给了你控制权,但需要配合轮询;I/O多路复用才是高并发服务器的基石,而epoll是其中的佼佼者。