
先把话说在前头C语言里没有官方提供的那种scheduleEvery(1000, callback)现成接口。你翻标准库翻来翻去也就time、sleep、clock这几个朴素的函数。所以“C语言实现定时任务”与其说是一个固定答案不如说是一道需要按场景选的开放题。嵌入式采集程序要定时发数据后台服务要定时清理日志网关设备要定时同步配置这些听着都是“定时任务”但底层思路可以差很远。这篇文章我按自己的实战经验把从最土到最进阶的几种方案都拆开讲清楚——循环sleep轮询、setitimer信号驱动、多线程条件变量以及任务量大了之后的最小堆和时间轮设计。每种方案的代码、原理、适用场景、隐藏坑我都会讲到。适合刚学 C 语言但对“定时器到底怎么工作”有疑惑的人也适合正在做嵌入式或 Linux 后台服务、需要自己设计定时调度模块的工程师参考。1. 先理清楚C语言定时任务到底要解决什么问题1.1 “定时任务”在C语言里为什么比脚本语言麻烦写 Python 或 JavaScript 的时候定时任务简直是一行命令的事语言内置了调度器框架也帮你在背后管理线程池、时间队列。C 语言的哲学是“只给你最底层的原语”进程、线程、信号、文件描述符都有但把“在某个时刻执行某个函数”这个应用层需求交给程序员自己拼装。这意味着你首先要回答三个问题时间怎么度量用time(NULL)拿的是墙上时钟wall clockclock_gettime(CLOCK_MONOTONIC)拿的是单调时钟两者在系统对时、NTP 跳变时行为完全不同。谁来触发是当前线程自己睡醒后继续干活还是操作系统通过信号打断你还是专门起一个线程在后台等时间。触发后在哪执行执行任务的是主线程、信号上下文、还是线程池里的工作线程这直接决定你能不能在任务回调里安全地操作共享数据。没有一个方案能同时把这三个问题回答得漂亮。所以下面的每种方案其实都是“在某个场景下对这三个问题的取舍”。1.2 三种典型场景决定你该选哪种方案我自己的经验是先别急着写代码先对号入座看自己属于哪一类。典型场景精度要求任务时长推荐方案命令行小工具、脚本级测试秒级即可很短的函数循环sleep轮询嵌入式传感器采集、数据上报毫秒级尽量短不能阻塞硬件定时器 /setitimer信号服务端后台任务、定时同步秒级到上百毫秒可能长可并发多线程 条件变量大量任务上千个时间点秒级 tick 即可不一最小堆 / 时间轮极端高精度微秒级微秒级极短实时系统专用接口如果你只是在写一个练习题想让程序每隔一秒钟打印一句话那你用最简单的sleep就够了。但如果你做的是工业采集设备100 毫秒的误差都会导致数据错位那就得认真考虑系统定时器和信号方案。再往上如果你维护的是一个网络网关要管理几千个设备的定期上报窗口线性扫描就扛不住了这时候数据结构才是关键。我见过不少刚接触嵌入式的新人一上来就抱着delay()函数写“定时”结果把整个 CPU 都占死了。其实只要你把“场景”先分清楚实现路径就清晰了一大半。1.3 一个反直觉的判断工程量不在“时间”而在“任务”这里有个容易忽视的点定时任务模块的复杂度往往不取决于时间精度而是取决于任务本身的形态。单一任务、单线程循环是最舒服的情况。但一旦出现“任务执行时间可能超过定时周期”“多个任务要并发跑”“任务必须优雅退出”“任务失败要重试”这些需求麻烦就来了。定时器只是负责“到点叫你”叫醒之后那堆事怎么调度、怎么排队、怎么退出才是真正的工程量。所以后面几个章节里我虽然都在讲定时触发但会不断回到“任务如何被正确执行”这个根本问题上。这也是为什么我在第 4 章会花很大篇幅讲条件变量框架——它是实际工程里最通用的形态。2. 最土但最不容易错的实现循环sleep轮询2.1 裸循环写法的代码长什么样先来一段最直觉的轮询代码#include stdio.h #include unistd.h #include time.h void periodic_task(void) { printf(task run at: %ld\n, time(NULL)); } int main(void) { while (1) { periodic_task(); sleep(1); // 睡 1 秒 } return 0; }这段代码看着没什么毛病运行起来也确实能“每秒执行一次任务”。初学者往往到这里就收工了但实际工程里这样写是要出事的。问题在于periodic_task()本身的执行时间没有算进周期里。如果这个函数运行了 200 毫秒那么实际周期就是 1.2 秒而不是 1 秒。更要命的是长时间运行后误差会累积任务执行的时间点会不断往后漂移。这跟跑表不走一个道理——你每次都对表但表针走慢了误差必然越积越多。2.2 sleep轮询的三个致命暗坑第一个坑是任务时长吞掉周期。解决办法是记录任务开始时间然后睡“剩下的时间”while (1) { time_t start time(NULL); periodic_task(); time_t spent time(NULL) - start; if (spent INTERVAL) { sleep(INTERVAL - spent); } }这样周期基本稳住了但依然有漂移的可能因为sleep的时长受系统调度影响它只保证“至少睡这么久”不保证“刚好睡这么久”。系统负载高的时候线程可能晚几百毫秒才被唤醒。第二个坑是信号打断sleep。在 Linux 下如果你的程序里注册了信号处理函数比如SIGUSR1、SIGALRM那么正在sleep的线程可能被提前唤醒。sleep和usleep被信号打断时不会自动补睡剩余时间函数直接返回你的循环就提前空转了一轮。处理方式是用nanosleep或者clock_nanosleep它们能告诉你有多少时间没睡够struct timespec req, rem; req.tv_sec 1; req.tv_nsec 0; while (nanosleep(req, rem) -1 errno EINTR) { req rem; // 被打断后接着睡 }第三个坑是系统时间跳变。如果系统通过 NTP 同步时间发生跳变用time(NULL)计算间隔的程序可能出现“瞬间觉得已经过了很久”的错觉。所以只要不是特别简单的场景我都建议用单调时钟。2.3 什么时候该坚持用sleep看到这里你可能会觉得sleep轮询一无是处。其实不是。它有一个非常大的优点线程安全模型极其简单。任务只在主线程跑不需要考虑多线程锁竞争不会出现数据竞争和死锁。在以下场景里我依然推荐这个方案任务本来就在单线程模型里跑在进程主循环里不希望引入信号或线程。定时周期是秒级以上对单次抖动的容忍度在几百毫秒。程序本身就是一次性脚本跑完就退没有长期漂移的顾虑。做一些自动化测试比如每 2 秒检查一次某文件是否生成根本不需要精确计时。如果你是做小型工具或练手项目用sleep完全没问题。但如果你准备把它用在长期运行的生产进程里那建议至少换成单调时钟 nanosleep重试的写法然后接受它“秒级可用、不保证毫秒级稳定”的现实。3. setitimer 信号毫秒级定时的系统方案3.1 一段能用的setitimer示例代码当精度要求到了百毫秒甚至十毫秒级别sleep轮询就力不从心了。Linux 下常用的替代方案是setitimer它由内核维护定时器倒计时结束后给进程发送SIGALRM信号你在信号处理函数里做该做的事。#include stdio.h #include signal.h #include string.h #include sys/time.h #include unistd.h void timer_handler(int signo) { // 信号处理里只做标记不做耗时操作 // 真实项目中可以在这里置一个 volatile 标志位 static int tick 0; write(STDOUT_FILENO, tick\n, 5); // write 是异步信号安全的 (void)signo; } int main(void) { struct sigaction sa; struct itimerval timer; memset(sa, 0, sizeof(sa)); sa.sa_handler timer_handler; sigemptyset(sa.sa_mask); sa.sa_flags 0; if (sigaction(SIGALRM, sa, NULL) -1) { perror(sigaction); return 1; } // 首次 100ms 后触发之后每 100ms 触发一次 timer.it_value.tv_sec 0; timer.it_value.tv_usec 100000; timer.it_interval.tv_sec 0; timer.it_interval.tv_usec 100000; if (setitimer(ITIMER_REAL, timer, NULL) -1) { perror(setitimer); return 1; } while (1) { pause(); // 等待信号 } return 0; }这段代码在 Linux 上可以跑100 毫秒触发一次tick。核心就是ITIMER_REAL用真实时间倒计时到点发SIGALRM然后it_interval负责周期重复。如果你只想要一次性定时把it_interval两个字段都设为 0 就行。3.2 信号处理不会告诉你的两个坑第一个坑是在信号处理函数里做了危险操作。信号处理函数运行在用户态栈上的一个独立上下文里它可以随时打断你主线程的任何代码。如果你在信号函数里调用了printf、malloc、strdup这类非异步信号安全函数一旦主线程恰好也在执行这些函数就可能造成死锁或内存损坏。所以信号处理函数里只做最轻的事置标志位、用write写日志、给管道写一个字节。实际任务放到主循环里去处理static volatile sig_atomic_t g_flag 0; void timer_handler(int signo) { g_flag 1; (void)signo; } int main(void) { // ... 注册信号 ... while (1) { if (g_flag) { g_flag 0; run_real_task(); // 真正的工作在这里 } pause(); } }volatile sig_atomic_t这种类型保证了对标志位的读写是原子的配合主循环轮询既拿到了定时能力又规避了信号上下文里干重活的风险。第二个坑是多线程环境下的信号投递。setitimer默认把信号发给进程而进程里谁去处理这个信号是不确定的。在多线程程序里如果你的某个线程刚进入临界区信号处理函数却随意打断它那就埋了雷。实践上要在非定时线程里屏蔽SIGALRM只留一个线程接收。用pthread_sigmask在创建线程前设置好这属于多线程信号编程的进阶内容新手如果真要用信号方案我建议先在单线程模型下跑通再扩展。3.3 嵌入式里常见的替代方案比较如果你做的是嵌入式开发setitimer并不一定是首选。原因很简单很多板子上跑的并不是完整 Linux可能只是一个 RTOS甚至裸机环境。这种情况下可供选择的“定时器”主要有硬件定时器外设精度极高微秒级但要用寄存器配置不同芯片差异很大。RTOS 软件定时器比如 FreeRTOS 的xTimerCreate由系统节拍驱动精度通常在毫秒级接口比自写信号方案安全得多。tickless 节拍 软件时钟适合低功耗场景CPU 可以睡很久到点由 RTC 或低功耗定时器唤醒。下面这张表是我个人的选型参考平台定时方案精度复杂度Linux 普通进程setitimer/timer_create毫秒级中Linux 强实时timerfdepoll毫秒级中高FreeRTOSxTimerCreate软件定时器系统节拍级低裸机硬件定时器中断微秒级高WindowsCreateTimerQueueTimer/ 多媒体定时器毫秒级中为什么我把timerfd也列进来了因为它解决了信号方案“回调里不敢干重活”的痛点。timerfd会把定时事件变成一个普通文件描述符配合epoll在事件循环里读取事件处理逻辑和普通 IO 完全一致没有信号那种打断性。这是我在 Linux 服务端项目里最常用的高级形态。3.4 timerfd epoll免信号的高级替代贴一段精简写法思路是创建定时器文件描述符然后用epoll等待它可读#include sys/timerfd.h #include sys/epoll.h #include unistd.h #include stdint.h int main(void) { int tfd timerfd_create(CLOCK_MONOTONIC, 0); struct itimerspec its; its.it_value.tv_sec 1; its.it_value.tv_nsec 0; its.it_interval.tv_sec 1; its.it_interval.tv_nsec 0; timerfd_settime(tfd, 0, its, NULL); int epfd epoll_create(1); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd tfd; epoll_ctl(epfd, EPOLL_CTL_ADD, tfd, ev); while (1) { epoll_wait(epfd, ev, 1, -1); if (ev.data.fd tfd) { uint64_t expirations 0; read(tfd, expirations, sizeof(expirations)); do_real_work(); } } }这里expirations接收的是定时器到期次数就算某次事件处理太久、导致中间漏了若干次 tick你也能知道漏了多少。这是一个非常实用的观测指标。相比信号方案timerfd不会打断任何线程所有逻辑都在事件循环里顺序执行异常好调试。我强烈推荐 Linux 服务端开发者优先考虑这个。4. 多线程 条件变量工程中最通用的定时器框架4.1 定时线程的核心代码骨架如果任务本身比较耗时或者你需要同时等待停止指令那么前面所有方案都不够优雅。sleep方案无法及时退出信号方案在多线程里容易互相打断timerfd又要求整个程序围绕epoll构建。这时候就该上多线程了一个专门的定时线程负责计时到点后通知工作线程执行任务。核心同步原语是pthread_cond_timedwait条件变量。先看一个最小闭环#include pthread.h #include stdio.h #include time.h typedef struct { pthread_mutex_t mutex; pthread_cond_t cond; int stop; // 停止标志 int interval_ms; // 定时周期毫秒 } timer_ctx; void* timer_thread(void* arg) { timer_ctx* ctx (timer_ctx*)arg; struct timespec ts; while (1) { clock_gettime(CLOCK_MONOTONIC, ts); ts.tv_sec ctx-interval_ms / 1000; ts.tv_nsec (ctx-interval_ms % 1000) * 1000000; if (ts.tv_nsec 1000000000) { ts.tv_sec; ts.tv_nsec - 1000000000; } pthread_mutex_lock(ctx-mutex); int ret pthread_cond_timedwait(ctx-cond, ctx-mutex, ts); int stop ctx-stop; pthread_mutex_unlock(ctx-mutex); if (stop) break; if (ret ETIMEDOUT) { do_work(); // 超时说明到点了 } // ret 0 说明被唤醒检查 stop 后继续循环 } return NULL; }这段代码的逻辑是定时线程先算好下一个绝对时间点然后pthread_cond_timedwait睡到那个时间点。如果没人打断它它自然醒来返回值是ETIMEDOUT代表“到点”执行任务。如果有人调用了停止函数通知条件变量它提前醒来发现stop是 1就退出循环。4.2 优雅退出与任务漂移控制停止函数这样写void timer_stop(timer_ctx* ctx) { pthread_mutex_lock(ctx-mutex); ctx-stop 1; pthread_cond_broadcast(ctx-cond); // 广播唤醒定时线程 pthread_mutex_unlock(ctx-mutex); pthread_join(ctx-thread, NULL); // 等待线程退出 }为什么用pthread_cond_broadcast而不是signal因为如果有多个线程在等同一个条件变量signal只唤醒一个你无法确定唤醒的是不是定时线程。broadcast虽然唤醒更多但逻辑上更安全。这里我踩过一个实实在在的坑最开始我的定时任务不是由定时线程自己执行而是往工作队列里丢任务结果程序退出时工作线程还在跑定时线程先退了队列里的任务直接被丢弃。后来我把“停止”拆成两步——先停止接收新任务再等待处理完任务最后才销毁队列和线程。工程里这叫两阶段退出two-phase shutdown在多线程定时框架里几乎是必须考虑的。关于任务漂移条件变量方案比sleep好很多因为pthread_cond_timedwait用的是绝对时间点就是“下一次应该执行的时间”不受上一轮任务耗时影响。例如周期是 100ms某一轮任务花了 60ms下一次唤醒时间依然是基于基准时间加 100ms而不是“这轮跑完再等 100ms”累计误差被控制在几个毫秒内。4.3 任务冲突、重复入队和日志处理当任务执行时间大于定时周期时问题立刻出现上一轮任务还没跑完下一轮 tick 又到了。怎么办主流做法有三种丢下一轮如果发现上一轮还没结束直接丢弃本次触发。补把本次触发标记成“pending”上一轮结束后马上补跑。并发每轮任务都新起线程跑互不等待但任务可能堆积。丢和补的取舍取决于业务。比如数据上报任务过期数据丢了没关系那就“丢”而清理任务晚一点跑总比不跑好那就“补”。我自己的经验是在定时线程里维护一个“上一次任务是否仍在执行”的标志位比直接起多个并发线程更可控。起线程虽然快但线程切换开销、共享数据锁竞争、任务堆积问题会一起爆发。如果确实需要并发可以考虑线程池而不是每次新建pthread_t。还要注意日志。定时任务的日志必须有时间戳、周期编号、执行耗时。比如每跑完一轮记录[tick1024] task done, cost45ms。这样一旦出现漂移或丢失你能从日志判断是系统负载过高、任务本身卡住还是定时框架的 bug。没有日志的定时任务出问题时你连排查的起点都没有。5. 任务量大时怎么办最小堆和时间轮5.1 从O(n)到O(log n)最小堆的取舍前几种方案都是单一周期任务一个线程管一个定时器就行。但很多系统不是这样的一个网络服务可能要管理上千个客户端的空闲超时每个客户端有自己的超时时间一个网关设备可能要维护几百个不同周期的数据上报任务。这种情况下一个线程对一堆循环扫描复杂度是 O(n)任务一多就完了。正确的做法是维护一个按到期时间排序的定时任务集合。最常用的数据结构是最小堆min-heap堆顶永远是最早到期的任务每次只需要检查堆顶复杂度 O(log n)。实现思路不上完整代码只说关键点typedef struct timer_node { time_t expire; // 绝对到期时间 void (*callback)(void*); void* arg; } timer_node; typedef struct min_heap { timer_node** nodes; int size; int capacity; } min_heap;插入一个任务时计算它的绝到期时间放进堆里并执行上浮操作定时循环每次从堆顶拿任务如果当前时间已经大于等于expire弹出并执行回调否则可以先睡到堆顶到期时间那么久。这样一个线程能管理成千上万个任务而且每次唤醒时间都是精确到最近的任务点。这个方案最需要注意的坑是任务可能被取消。比如客户端提前断开了那么它对应的超时任务就不该再执行。如果每次取消都要在堆里查找并删除复杂度又是 O(n)。常见的妥协办法是给每个任务一个canceled标志位弹出来的时候检查一下如果已取消就直接丢弃不真正从堆里删。这样堆里的空洞会增多但实践中如果取消不频繁完全够用。5.2 时间轮适合大量短周期任务的思路时间轮timing wheel是另一种经典结构思路很直观把时间分成固定大小的槽位比如 1 秒一个槽总共分 60 个槽围成一个环指针每秒移动一格落到哪个槽就执行哪个槽上挂的所有任务。关键代码结构#define WHEEL_SLOTS 60 typedef struct timer_node { struct timer_node* next; int round; // 还需要转多少圈才执行 void (*callback)(void*); void* arg; } timer_node; timer_node* wheel[WHEEL_SLOTS]; int current_slot 0;添加一个计划 5 秒后执行的任务就把节点挂到(current_slot 5) % 60这个槽上round为 0。如果任务计划 120 秒后执行就挂到(current_slot 60) % 60也就是当前槽但round计 2。指针每走一格所有槽里的节点的round减 1减到 0 才执行。这种方案的优势是插入和删除都是 O(1)非常适合海量短超时任务。但代价是精度受限于槽位大小——槽位是 1 秒那么 200ms 粒度的任务就没法精确执行。实际工程里时间轮常常和最小堆配合使用时间轮管近期密集任务最小堆管稀疏长任务。5.3 哪种方案最容易出现精度问题我在两个方案上都踩过坑挑两个共性问题说第一个是最小堆的“睡过头”问题。如果你只睡到堆顶到期时间中间万一发生时间跳变系统时钟被 NTP 修改或者当前线程被系统调度器延迟唤醒醒来后堆里可能已经积压了一堆到期任务。这种情况要一次性弹出所有到期任务而不是弹一个执行一个否则排在后面的任务会被饿死永远延后。所以正确的循环是while (heap_top_expired()) { pop_and_run(); }。第二个是时间轮的“旋转开销”问题。时间轮指针每 tick 都要扫描当前槽位如果槽位里挂了非常多的节点单次 tick 的处理时间可能超过一个 tick 间隔后面的轮转就会越来越慢。很多人忽视这一点直到线上出现“定时任务怎么越来越稀疏”才意识到。解决方法是限定每个槽位任务量或者把时间轮设计成 hierarchical分层时间轮把不同精度的任务分到不同层里。如果只是个人项目我建议先上最小堆。它思维负担小边界条件少任务量到几千也完全撑得住。时间轮更适合那种“要管理几十万连接超时”的高性能网络框架那里 O(log n) 的开销都嫌大必须 O(1)。6. 我的几个工程经验从可测到可控6.1 定时偏差是跑出来的不是想出来的很多工程师做定时器代码写完看一眼觉得“应该没问题”就上线了。但实际跑起来定时偏差会让你怀疑人生。我自己常用的测试方法定时任务里记录一个时间戳序列跑 10 分钟然后分析相邻时间戳之间的间隔分布。你会发现哪怕都是setitimer100ms也会出现 95ms、104ms、137ms 这样的抖动。这不是代码的问题而是内核调度延迟、中断处理、CPU 频率调整等在共同作用。所以对精度的预期一定要合理。Linux 普通进程的实时性没有那么玄百毫秒级定时实际抖动可能在几毫秒到十几毫秒如果你要求微秒级稳定那必须用实时内核调度策略SCHED_FIFO、锁定内存、规避缺页中断等一整套实时优化手段普通线程没有戏。6.2 日志与状态暴露定时任务也要能被观测定时任务很容易变成“黑盒”。它在后台默默执行你不知道它有没有跑、跑了多少次、每次花了多久、有没有漏执行。我的建议是至少暴露这几个指标上次执行时间戳累计执行次数最近一次执行耗时漏执行次数用到期计数器减去实际执行次数当前任务队列长度在服务端可以用一个全局结构体存这些值并提供print_status()接口在嵌入式里至少要在串口日志里周期打印。曾经有一个项目定时器性能指标全靠猜最后查出来是任务回调里一个printf导致整个 100ms 周期被拉长到 300ms。加了一行耗时统计日志后问题一分钟就定位了。6.3 跨平台和实战注意点C 语言定时任务的跨平台性很差setitimer在 Windows 上不存在pthread_cond_timedwait在不同操作系统上的时钟源也未必一致。如果你的目标平台是 Linux 和 Windows 双平台建议封装一层接口typedef struct timer_ops { int (*init)(void); int (*schedule)(int delay_ms, void (*cb)(void*)); int (*cancel)(int timer_id); void (*destroy)(void); } timer_ops;底层分别实现 Linux 的timerfd版和 Windows 的CreateTimerQueueTimer版上层业务代码完全不用关心平台差异。这个抽象成本很低但能省掉后面非常多的麻烦。最后几个小细节pthread_cond_timedwait在编译时要用gcc -pthread选项不加上跑起来会直接报错clock_gettime在较老的 glibc 上链接时需要-lrt新版本不需要volatile sig_atomic_t只能保证自身读写的原子性别指望用它当锁使。定时任务这块我自己最大的体会是方案没有贵贱只有合不合适。写个测试脚本用sleep很自然做嵌入式采集要尊重硬件定时器服务端后台一定要设计好线程模型和退出逻辑。你把“到点叫醒”这个机制想透了剩下的任务调度、数据结构、监控日志都是从工程需求里长出来的。多跑、多测、多记日志比背十个定时器 API 有用得多。