超时机制:alarm()信号超时、select()超时、setsockopt()超时

网络编程里,超时处理是个绕不开的话题。你想想看,一个socket连不上,总不能一直等下去吧?我在早期做项目时,就吃过这个亏——一个客户端连不上服务器,程序直接卡死了,用户那边还以为崩溃了。后来我学乖了,每个网络操作都得配上超时。

C语言里实现超时,主要有三条路:alarm()信号、select()轮询、setsockopt()选项。这三条路各有各的脾气,咱们一条一条捋清楚。

1. alarm() 信号超时

alarm()是Unix系统里最古老的超时手段。它本质上是让内核在指定秒数后,给当前进程发一个SIGALRM信号。如果你没处理这个信号,进程就挂了。

我个人的习惯是,用它来给read()write()这类阻塞操作加个“闹钟”。代码长这样:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <sys/socket.h>

void timeout_handler(int sig) {
    // 啥也不干,就是为了打断阻塞调用
    // 我一般在这里写个空函数
}

int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    // ... 连接服务器 ...

    signal(SIGALRM, timeout_handler);
    alarm(5);  // 5秒后触发信号

    char buf[1024];
    int n = read(sockfd, buf, sizeof(buf));
    if (n < 0 && errno == EINTR) {
        printf("读取超时了\n");
    }

    alarm(0);  // 取消闹钟
    return 0;
}
注意:alarm()是秒级精度,而且一个进程只有一个闹钟。如果你同时给多个操作设闹钟,后一个会覆盖前一个。我曾经在项目里踩过这个坑——两个socket同时读,结果闹钟被覆盖了,一个超时没生效。

另外,alarm()在多线程环境下不太安全。信号是发给进程的,不是线程。你想想看,如果两个线程都在等闹钟,到底谁收到信号?嗯,这问题挺麻烦的。所以我建议,多线程项目就别用alarm()了。

2. select() 超时

select()是我用得最多的超时方案。它不光能设超时,还能同时监控多个socket。说白了,它就是让你告诉内核:“我等着这些socket,最多等xx时间,超时了你就回来告诉我。”

核心用法是这样的:

#include <sys/select.h>
#include <sys/time.h>

struct timeval tv;
tv.tv_sec = 3;      // 3秒
tv.tv_usec = 0;     // 0微秒

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);

int ret = select(sockfd + 1, &readfds, NULL, NULL, &tv);
if (ret == -1) {
    perror("select出错");
} else if (ret == 0) {
    printf("超时了,3秒内没数据\n");
} else {
    if (FD_ISSET(sockfd, &readfds)) {
        // 可以读了
        read(sockfd, buf, sizeof(buf));
    }
}

关键点:select()返回后,timeval结构体里的值会被修改。Linux下会减去已经等待的时间,但有些系统不会。所以我每次调用前都会重新设置超时值。

为什么我喜欢用select()?因为它精度高——微秒级。而且它不依赖信号,线程安全。我在做一个高并发网关时,就是用select()来管理上千个连接的读写超时。

小技巧:如果你只想检查一个socket,可以把nfds设成sockfd + 1readfds只放这一个。这样性能更好。别傻乎乎地设成FD_SETSIZE,那是浪费。

3. setsockopt() 超时

这是最“优雅”的方式——直接通过socket选项设置超时。内核会在收发操作时自动检查时间,超时了就返回错误。

两个关键选项:

  • SO_RCVTIMEO:接收超时
  • SO_SNDTIMEO:发送超时

用法示例:

#include <sys/socket.h>
#include <sys/time.h>

struct timeval timeout;
timeout.tv_sec = 5;   // 5秒
timeout.tv_usec = 0;

// 设置接收超时
if (setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, 
               &timeout, sizeof(timeout)) < 0) {
    perror("setsockopt失败");
}

// 之后调用read,如果5秒内没数据,返回-1,errno为EAGAIN或EWOULDBLOCK
char buf[1024];
int n = read(sockfd, buf, sizeof(buf));
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
    printf("接收超时\n");
}
注意:setsockopt()设置的超时是“每次操作”的超时,不是“整个连接”的超时。比如你循环读数据,每次read()都会重新计时。这和select()的“一次性超时”不一样。我刚开始用的时候没注意,结果发现每次读都等5秒,整个流程慢得离谱。

另外,setsockopt()的超时在有些系统上只对阻塞模式有效。非阻塞模式下,它可能不生效。嗯,这个坑我也踩过——在Linux上没问题,换到某BSD系统上就不行了。所以跨平台项目要小心。

三种方式对比

特性 alarm() select() setsockopt()
精度 秒级 微秒级 微秒级
线程安全
多socket监控
代码侵入性
可移植性 POSIX 几乎全平台 大部分平台
适用场景 简单阻塞操作 多路复用、高精度 单个socket的收发

核心逻辑图

下面这张图展示了三种超时机制在socket编程中的位置和关系:

Socket超时机制核心逻辑 Socket 读写操作 alarm() 信号超时 秒级精度,单进程 select() 轮询超时 微秒级,多socket setsockopt() 选项超时 微秒级,单socket 超时处理:返回错误 / 重试 / 关闭连接 三种方式可组合使用,但注意alarm()与select()同时用可能冲突 🔴 信号驱动 🟠 事件驱动 🟢 选项配置 🟣 统一处理

我的建议

如果你问我选哪个,我的回答是:看场景。

  • 简单脚本或小工具:用alarm(),代码少,够用就行。
  • 高并发服务器:用select()epoll(后面会讲),精度高,可控性强。
  • 单个socket的收发:用setsockopt(),最省事,不用改代码逻辑。

我个人在实际项目中,80%的情况用select(),15%用setsockopt(),5%用alarm()。为什么alarm()用得少?因为信号处理太容易出幺蛾子了。我曾经在一个项目里,alarm()sleep()混用,结果闹钟信号把sleep()打断了,整个时序全乱套。从那以后,我对信号超时就格外谨慎。

避坑指南:如果你非要用alarm(),记得在信号处理函数里只设一个标志位,别做复杂操作。信号处理函数里调用printf()?千万别,那是未定义行为。我见过有人这么干,程序偶尔崩溃,查了两天才找到原因。

好了,超时机制就聊到这儿。三种方式各有千秋,选对工具,你的网络程序才能既健壮又高效。

公众号:蓝海资料掘金营,微信deep3321