资讯动态

C语言定时任务实现与选型:从sleep到timer_create详解

发布时间:2026/10/5 7:25:35 来源:尧图企业网站定制
在C语言项目里安排一个定时任务说难不难说简单也容易踩坑。Java那边有Spring的Scheduled、xxl-job这类现成框架C这边通常什么都得自己搭。以前在嵌入式设备上调一个定期上报功能为了搞清楚setitimer和timer_create的区别啃了不少文档也踩过在信号处理函数里调printf导致程序卡死的坑。这篇就把C语言定时任务的主流方案从朴素到进阶完整梳理一遍每个方案都附带能跑的代码顺便讲讲底层原理和使用注意。不管是刚学C语言的新手还是要在生产项目里做定时调度的人都可以对照着选型。1. 先理清需求C语言定时任务到底该怎么做1.1 定时任务的常见场景在动手写代码之前最好先想想自己到底要做什么。C语言写成定时任务在我经历过的项目里大致分三类。第一类是嵌入式/单片机上的周期性采集与上报。比如一个温度采集器每5秒读一次传感器然后通过串口或者网络上报。这类场景通常资源紧张任务本身简单往往一个主循环加一个延时就能搞定。第二类是服务器后台程序里的心跳检测、缓存清理、统计汇总。这类程序往往已经有一个事件循环比如epoll这时候希望在不额外开线程的情况下定期执行某个回调。于是select/poll/epoll的timeout参数就成了天然定时器。第三类是独立运行的小工具比如日志切割、数据备份、程序自我更新。这些程序平时可能什么也不干就等着定时触发才执行一次偶尔还会要求在某个时间点执行。这类需求用alarm、setitimer或者条件变量都合适。需求明确了方案选型的思路也就清楚了。核心问题就是三个精度要求多少、程序本身是单线程还是多线程、运行平台支不支持POSIX接口。1.2 选型之前必须想明白的三件事第一件定时精度。说到精度得先理解在普通Linux系统上用户态定时不是真正的“硬实时”。哪怕timer_create号称纳秒精度实际触发也会受到内核调度、系统负载的影响。实测下来毫秒级需求用setitimer或select完全够微秒级才需要认真考察timer_create而且还要考虑是否允许牺牲CPU换精度。第二件程序结构。如果程序本身是多线程的事件驱动模型就不要在主线程里sleep那样会阻塞整个事件循环。反过来一个简单的命令行工具非要为了定时专门引入线程池也是过度设计。第三件平台环境。Windows下没有setitimer和timer_create通常用CreateTimerQueueTimer或者timeSetEvent。Linux/Unix下POSIX接口齐全但也要注意宏定义比如用CLOCK_MONOTONIC通常要定义_POSIX_C_SOURCE 199309L以上。这些想清楚以后再来逐一看看各种实现。2. 入门方案sleep循环和时间检测2.1 sleep/usleep轮询的代码实现先来个最容易理解的版本。程序陷入一个while循环执行完任务之后调用sleep挂起当前线程到时间再醒来执行下一次任务。#include stdio.h #include unistd.h #include time.h void do_task(void) { printf([%ld] task running\n, time(NULL)); } int main(void) { while (1) { do_task(); sleep(5); } return 0; }这段代码放到绝大多数Linux环境里都能直接编译运行gcc编译命令是gcc -o sleep_demo sleep_demo.c。逻辑上没有任何问题但它有几个天生的缺陷。首先sleep的单位是秒需要更短的周期时可以用usleep微秒已逐渐废弃、nanosleep纳秒或者直接选用后面的方案。其次sleep会在收到信号时提前返回返回值是剩余的秒数。比如你sleep(5)结果第2秒来了个SIGCHLD信号sleep就会立即返回3程序立刻进入下一轮do_task任务周期被无端打乱。再次任务本身的执行时间被直接叠加到周期里比如任务要跑1秒sleep(5)实际周期是6秒而且是累加的长时间运行后会和预期越走越远。2.2 用time()检测系统时间的轮询方式另一种朴素做法是不sleep而是让主要业务逻辑照常跑每次循环检查一下当前时间到了指定间隔才执行任务。#include stdio.h #include time.h #include unistd.h int main(void) { time_t last time(NULL); int interval 3; while (1) { time_t now time(NULL); if (now - last interval) { printf([%ld] task running\n, now); last now; } /* 这里可以穿插做其他事情 */ usleep(100000); } return 0; }这种写法最大的优点是任务不会阻塞业务主流程适合程序本身就有大量循环操作的情况。但它的精度只能到秒级因为time()返回的time_t精确到秒而且循环周期不能太长否则最后一次检查和任务触发之间的延迟比较明显。如果业务循环本身已经很耗时usleep(100000)可以去掉不过CPU占用会上升。我看到有些教程会推荐clock()函数但clock()统计的是CPU时间而非墙上时钟时间用在这里容易误判不建议。2.3 入门方案最容易踩的三个坑第一个坑是sleep被信号打断。在信号驱动的程序里sleep经常睡着睡着就被唤醒导致周期错乱。如果你只是写个简单的demo那无所谓但如果在生产环境最好用nanosleep替代sleep。nanosleep虽然在有信号时也会返回剩余时间但它会把剩余时间写回第二个参数你可以根据返回值判断是否被打断并继续睡完剩余时间。第二个坑是漂移。前面说了任务执行时间会叠加到周期里时间一长偏差越来越大。解决办法是“用绝对时间计算下一次执行点”比如记录第一次执行的绝对时间每次醒来通过now - start判断执行了几轮而不是单纯task(); sleep(interval)这样朴素累加。第三个坑是CPU占用。上面time()检测方案如果去掉usleep空转会让单核CPU跑满。在低功耗设备上有时候这甚至是致命问题处理办法是结合事件等待比如后面要讲的select超时。3. 信号驱动定时alarm与setitimer3.1 alarm()最简单的秒级信号定时alarm是UNIX历史上很老的接口作用是在指定秒数之后向进程发送SIGALRM信号。配合signal或sigaction注册信号处理函数就能实现定时任务。#include stdio.h #include signal.h #include unistd.h #include string.h static void alarm_handler(int sig) { write(STDOUT_FILENO, tick\n, 5); (void)sig; } int main(void) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler alarm_handler; sigaction(SIGALRM, sa, NULL); alarm(2); /* 2秒后触发一次 */ while (1) { pause(); /* 挂起等待信号 */ alarm(2); /* 每次触发后重新设定 */ } return 0; }注意alarm()只能触发一次。要实现周期任务必须在处理完信号后再次调用alarm()或者改用setitimer。另外alarm和sleep不要混用它们会共享同一个定时通道后面的调用会覆盖前面的。3.2 setitimer()微秒级周期定时setitimer是alarm的升级版可以设置首次触发时间和间隔时间一次配置之后自动周期触发不用手动重新设置。#include stdio.h #include signal.h #include string.h #include sys/time.h #include unistd.h static void timer_handler(int sig) { write(STDOUT_FILENO, tick\n, 5); } int main(void) { struct itimerval tv; struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler timer_handler; sigaction(SIGALRM, sa, NULL); memset(tv, 0, sizeof(tv)); tv.it_value.tv_sec 1; /* 1秒后首次触发 */ tv.it_value.tv_usec 0; tv.it_interval.tv_sec 1; /* 此后每隔1秒触发一次 */ tv.it_interval.tv_usec 0; if (setitimer(ITIMER_REAL, tv, NULL) -1) { perror(setitimer); return 1; } while (1) { pause(); } return 0; }这里关键是itimerval结构里两个成员的含义it_value表示从现在到第一次触发的时间如果只想单次执行把it_interval全部置0就行it_interval是周期时间可以精确到微秒tv_usec。系统计时器有三种类型ITIMER_REAL按真实时间计时到期发SIGALRMITIMER_VIRTUAL只统计进程用户态CPU时间到期发SIGVTALRMITIMER_PROF统计用户态内核态CPU时间到期发SIGPROF。一般我们用ITIMER_REAL。setitimer的精度受内核时钟频率影响但通常微秒量级的周期任务已经够用。不过有一个细节进程里同一时刻只能有一个ITIMER_REAL计时器。如果程序里其他地方也用alarm或setitimer会互相覆盖这时候就得考虑更高的方案。3.3 信号处理函数的安全守则用信号做定时最麻烦也最容易翻车的是在信号处理函数里写复杂代码。信号处理函数是在进程上下文中被异步执行的有可能在你正在调用printf、malloc时突然插入而这些函数大多不是异步信号安全的一旦重入就会导致死锁或数据损坏。安全做法有几种。最简单的是在handler里只调用write、read、sig_atomic_t赋值这类异步安全操作。比如上面的handler就只写了一个字符串这类操作是可重入的。另一种做法是handler里只设置一个volatile sig_atomic_t标志主循环检测到标志后再去执行真正的任务。static volatile sig_atomic_t tick_flag 0; static void timer_handler(int sig) { tick_flag 1; } int main(void) { /* 注册handler、配置setitimer ... */ while (1) { if (tick_flag) { tick_flag 0; do_task(); /* 在主流程里执行 */ } /* 做其他事 */ } }注意这里的volatile只是防止编译器优化掉对标志的读取并不会保证并发安全但在信号这种单线程异步场景够用了。还有别在handler里调用longjmp之类的跳转容易把程序搞乱。4. 高精度专项POSIX定时器timer_create4.1 三种通知方式怎么选setitimer已经能满足大多数场景为什么还要有timer_create因为setitimer只能有一个REAL计时器而且触发后只能走信号处理粒度比较粗。timer_create可以创建多个定时器且精度更高能指定使用哪种时钟源比如CLOCK_REALTIME或CLOCK_MONOTONIC还能设定触发通知的方式。timer_create创建定时器后通过sigevent结构体指定通知方式。常见的有三种SIGEV_NONE定时器到期什么都不通知只能自己用timer_gettime或timer_getoverrun查实际用得少。SIGEV_SIGNAL到期时向进程发送指定信号和setitimer类似。SIGEV_THREAD到期时内核创建一个新线程在线程里调用你指定的回调函数。这种方式最直观不需要写信号处理函数也是我实际项目中最常用的。选择依据很简单如果只是给现有事件循环增加一个定时能力信号方式配合标志位即可如果想要回调独立执行、不干扰主逻辑用SIGEV_THREAD。4.2 用timer_create实现周期任务的完整代码直接给一个可以运行的示例使用SIGEV_THREAD方式每秒触发一次回调跑10秒后退出。#define _POSIX_C_SOURCE 199309L #include stdio.h #include signal.h #include time.h #include string.h #include unistd.h #include stdlib.h static void timer_cb(union sigval sv) { int *id (int *)sv.sival_ptr; write(STDOUT_FILENO, tick\n, 5); (void)id; } int main(void) { timer_t tid; struct sigevent sev; struct itimerspec its; int id 100; memset(sev, 0, sizeof(sev)); sev.sigev_notify SIGEV_THREAD; sev.sigev_value.sival_ptr id; sev.sigev_notify_function timer_cb; if (timer_create(CLOCK_MONOTONIC, sev, tid) -1) { perror(timer_create); return 1; } its.it_value.tv_sec 1; its.it_value.tv_nsec 0; its.it_interval.tv_sec 1; its.it_interval.tv_nsec 0; if (timer_settime(tid, 0, its, NULL) -1) { perror(timer_settime); return 1; } sleep(10); timer_delete(tid); return 0; }编译时注意timer_create和CLOCK_MONOTONIC属于POSIX.1b扩展gcc编译需要在文件开头加#define _POSIX_C_SOURCE 199309L或者在编译命令里加-D_POSIX_C_SOURCE199309L。这是很多新手第一次编译报错的原因。4.3 使用注意与资源释放第一timer_delete必须调用。虽然进程退出后内核会回收资源但在长时间运行的程序里反复创建不删除最终会撑爆系统定时器资源出现EAGAIN错误。第二CLOCK_MONOTONIC和CLOCK_REALTIME的选择。REALTIME会随着系统时间修改而跳动如果运维用ntp同步了时间定时周期可能被拉长或缩短。MONOTONIC是单调递增的不受系统时间跳变影响做周期任务更稳。前提是你的系统支持MONOTONIC。第三SIGEV_THREAD的回调是在新线程里执行的如果用到了共享数据要保证线程安全。打个比方回调里更新一个全局链表另一个线程也在遍历链表不加锁的话必然出问题。5. 事件驱动里的定时方案select和epoll的timeout5.1 select超时定时任务和事件循环共存很多C程序员没意识到select不只是用来监听fd的它的timeout参数本身就是个定时器。select的timeout是一个timeval如果监听的所有fd都不就绪那么最多等这么久就返回0于是就可以利用这个特性实现周期任务。#include stdio.h #include sys/select.h #include time.h #include unistd.h void do_heartbeat(void) { printf([%ld] heartbeat\n, time(NULL)); } int main(void) { struct timeval tv; while (1) { tv.tv_sec 2; tv.tv_usec 0; int ret select(0, NULL, NULL, NULL, tv); if (ret 0) { do_heartbeat(); } else if (ret 0) { perror(select); break; } } return 0; }需要注意select会修改timeout参数把剩余等待时间写回去。所以每次循环都必须重新设置tv否则第二次调用时tv可能是0变成忙轮询。这是新手常犯的错误之一。这种方案的优点是能自然嵌入到已有的select事件循环里。比如你需要同时监听socket、标准输入又希望每隔几秒执行一个任务只需要每次循环都设置timeout然后判断select返回值如果是0说明超时了该做定时任务如果是大于0说明有fd就绪先处理IO事件。5.2 epoll_wait超时高并发服务器里的定时利器在Linux高并发服务器里select有1024个fd上限所以通常用epoll。epoll_wait的timeout参数单位是毫秒和其他方案相比它最大的好处是让定时任务与网络事件在一个线程里协同工作不需要额外线程也不用处理复杂的锁。#define _GNU_SOURCE #include stdio.h #include sys/epoll.h #include time.h #include unistd.h int main(void) { int epfd epoll_create1(0); struct epoll_event events[8]; int timeout_ms 1000; long long count 0; if (epfd -1) { perror(epoll_create1); return 1; } while (1) { int n epoll_wait(epfd, events, 8, timeout_ms); if (n -1) { perror(epoll_wait); break; } if (n 0) { count; printf([%lld] periodic task\n, count); } else { /* 处理网络事件 */ } } close(epfd); return 0; }上面代码没注册任何fd其实就是个空转的定时器演示了epoll_wait作为定时器的用法。真实项目里epfd里会注册一堆socket fd每次epoll_wait返回时先处理就绪事件再根据n 0判断是否到定时点。5.3 什么时候值得用这套简单说如果你的程序已经有事件循环或者主要业务是IO密集型那么用select/poll/epoll的timeout实现定时任务是所有方案里最优雅的。它避免了信号处理函数的异步陷阱因为超时返回是在正常代码路径里处理的不存在回调重入问题它也不会像sleep那样阻塞整个进程因为在等待期间内核还能处理其他fd的就绪事件。但代价是代码结构上必须有一个事件循环程序逻辑会被改造成回调驱动。如果只是写一个非常简单的一次性定时工具用select这套会显得绕。6. 多线程定时条件变量和定时线程池6.1 用pthread_cond_timedwait实现周期任务在需要定时但又要求线程能随时被唤醒的场景下条件变量是很顺手的一个工具。pthread_cond_timedwait的作用是等待某个条件成立同时可以设置一个绝对时间上限如果到了时间还没被唤醒函数返回ETIMEDOUT。#define _POSIX_C_SOURCE 199506L #include stdio.h #include pthread.h #include time.h #include string.h #include errno.h static pthread_mutex_t mtx PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t cond PTHREAD_COND_INITIALIZER; void *timer_worker(void *arg) { while (1) { struct timespec ts; clock_gettime(CLOCK_REALTIME, ts); ts.tv_sec 2; pthread_mutex_lock(mtx); int rc pthread_cond_timedwait(cond, mtx, ts); pthread_mutex_unlock(mtx); if (rc ETIMEDOUT) { printf([%ld] timer fired\n, time(NULL)); } else if (rc 0) { printf(wake by signal, stop.\n); break; } } return NULL; } int main(void) { pthread_t tid; pthread_create(tid, NULL, timer_worker, NULL); sleep(6); pthread_mutex_lock(mtx); pthread_cond_signal(cond); pthread_mutex_unlock(mtx); pthread_join(tid, NULL); return 0; }这个方案的核心优点就是“可唤醒”。sleep期间如果程序需要提前退出你得用信号打断条件变量则可以随时从其他线程发信号唤醒等待线程优雅地结束。另外它以绝对时间传入天然适合做“下一次执行时间点”的计算周期性任务的误差比单纯sleep累加要小。6.2 一个最简单的定时线程池模型如果定时任务有多个每个都开一个线程显然不划算。实际中常做一个专门的定时线程一个线程不停计算最近任务的触发时间然后调pthread_cond_timedwait等待任务队列里放的是一个个带时间戳的任务节点到点后取出执行。这种模型足够支撑一个轻量级调度器。我之前在日志上报服务里就用过这种结构简化版一个全局任务链表每个节点保存任务函数指针、参数、下一次执行时间、间隔调度线程每次遍历链表找到最近的时间点用pthread_cond_timedwait等待到点后把到期的任务全部拎出来依次执行再更新它们的下一次执行时间。整体代码两百行左右稳定跑了很长时间。6.3 多线程方案的风险点风险一pthread_cond_timedwait的第二个参数是绝对时间点计算时必须用CLOCK_REALTIME默认条件变量基于它如果你想用CLOCK_MONOTONIC需要设置条件变量的属性否则会有系统时间跳变影响的问题。风险二所有共享状态任务链表、标志位都要加锁等待前必须持有互斥量否则条件变量行为未定义。风险三销毁线程时要先向条件变量发送信号让线程从等待中醒来再pthread_join否则线程一直阻塞在timedwait里join会卡死。这句话很多资料不会讲但实际调试时经常因为忘了唤醒导致程序退出卡住。7. 方案选型对照与避坑经验7.1 一张表看懂各方案方案精度阻塞性实现复杂度典型场景sleep/usleep循环秒/毫秒级阻塞当前线程最低一次性任务、无并发小工具time()时间检测秒级不阻塞低主流程已有大循环的任务alarm()秒级信号异步低一次性延迟任务setitimer()微秒级信号异步中常见周期任务timer_create()纳秒级信号或线程回调中高高精度多定时器任务select/poll/epoll超时微秒/毫秒级事件循环中已有事件循环的服务端pthread_cond_timedwait毫秒/微秒级线程内阻塞中高可唤醒线程、多任务调度精度这列有个前提都是指理论分辨率真正能到多少取决于内核调度和系统负载。7.2 时间漂移的修正技巧不管哪种方案做周期任务久了都会面临漂移。sleep循环最容易漂移因为任务执行时间被累加进去。要修正核心思路是记住“绝对起点”每次用start n * interval计算下一次触发时间点而不是用last_run interval。举个例子任务第一次在t0执行周期5秒那么期望的执行时间点应该是5、10、15……每轮循环里根据当前时间算出距离下一个期望点还差多久再决定睡多久。任务万一超过了一个周期也没关系计算出差了多个周期就补跑几次或者丢弃过期轮次视业务要求而定。7.3 我踩过的几个坑写定时任务这几年我踩过几个让大家少走弯路的坑。一是setitimer在某个模块里用得很开心后来另一个模块也调了setitimer结果前一个定时器无声无息失灵了。后来排查发现ITIMER_REAL全局只能有一个两个模块共用必然打架。项目里如果有多处定时需求趁早统一规划要么都走timer_create要么都走事件循环。二是CLOCK_REALTIME和NTP时间同步问题。之前有个设备用带REALTIME时钟的alarm定时结果运维同步了系统时间设备所有定时任务都跳了一次。后来全部改为CLOCK_MONOTONIC世界安静了。三是信号处理函数里的锁问题。最早我在SIGALRM的handler里直接对全局链表加锁再增删节点结果主循环也在处理链表偶尔出现死锁。后来改成handler只置标志位主循环看到标志再操作链表问题消失。记住一句话信号处理函数里除了write、读volatile标志、调sig_atomic_t操作其他都要考虑清楚了再做。8. 常见问题与排查技巧8.1 任务不执行或执行一次就停了排查方向先判断定时器配置是否正确。setitimer和timer_create都需要正确设置it_interval如果it_interval为0但it_value不为0那就只会触发一次。如果使用alarm它本身就只能触发一次没有周期能力想要周期任务必须在处理完信号后重新alarm。还有信号被屏蔽的情况。程序里如果用了pthread_sigmask或者sigprocmask把SIGALRM屏蔽掉了信号永远递达不了任务自然不执行。排查时可以先用kill -l确认信号编号再用strace看调用。8.2 定时周期不准先看是不是任务执行时间太长把周期撑大了再看系统负载是否过高导致调度延迟如果用了CLOCK_REALTIME还要检查系统时间是否被调整过。一个特别容易被忽略的点usleep和sleep在收到信号后会提前返回循环里如果没有处理返回值下一次循环就会立刻又进入任务造成“短期内连续执行多次”的错觉。解决办法是用nanosleep或者干脆改用条件变量等待。8.3 快速定位问题的三个手段第一招加时间戳日志。在任务开头打印time(NULL)或clock_gettime得到的纳秒时间肉眼就能看出周期偏移。第二招strace。strace -f -e tracealarm,timer_settime,setitimer,nanosleep,clock_nanosleep ./your_program可以直接看到系统调用层面的定时器设置情况快速确认生效的是哪个定时器。第三招检查编译宏。如果代码报implicit declaration of function timer_create十有八九是没定义_POSIX_C_SOURCE报CLOCK_MONOTONIC undeclared则需要确认同类宏定义或者头文件包含是否完整。写到最后我还是想强调一点C语言定时任务没有银弹。就算把timer_create和epoll玩得再熟新项目里也还是要先问自己三个问题——精度够不够、会不会阻塞主流程、线程模型是否已经确定。我个人最常用的是epoll_wait配合事件循环项目里能少一个线程就少一个线程而独立小工具往往setitimer就够了。如果照着这篇把每个例子跑一遍你会发现定时任务的核心从来不是某个API而是对时间模型的理解。希望这些踩坑经验能帮你少走几步弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑