根本原因是timer回调中存在阻塞调用,导致协程无法yield,调度器tick被拖垮;必须禁用sleep、file_get_contents等同步操作,改用co::sleep、httpclient等协程安全方式,并将重逻辑投递至taskworker。

Hyperf 的 Swoole\Timer 回调越跑越慢,根本不是 Timer 本身不准,而是闭包里写了阻塞调用,导致协程无法 yield,整个定时器 tick 被拖垮——它本质是协程调度器的一次 tick,不是独立线程。
为什么 Swoole\Timer::tick 会偏移甚至卡住
Hyperf 中的 Swoole\Timer::tick 运行在 Worker 进程的协程上下文中,每次触发都依赖当前协程让出控制权(yield),才能进入下一轮。一旦你在回调闭包里写了 sleep()、file_get_contents()、未加超时的 curl_exec() 或同步 DB 查询,协程就卡死,调度器无法推进,后续所有 tick 都被顺延。
常见表现包括:
- 本该每 5 秒执行一次的定时器,实际间隔变成 6s、8s、12s,且偏差持续扩大
- 多个
tick注册后,后注册的延迟更严重 -
swoole_server->stats()显示coroutine_num长期为 1,request_count几乎不涨
必须禁用的阻塞写法(含等效变体)
以下写法在 Timer 闭包中一律禁止,它们都会退化为同步阻塞:
-
sleep(3)、usleep(500000)→ 改用co::sleep(3) -
file_get_contents('http://api.com')→ 改用$client->get()(Hyperf\HttpClient\Client) -
Db::table('log')->insert(...)→ 确保已在协程内,且 DB 驱动为协程版(pdo_mysql+mysqlnd) -
Redis::get('key')→ 确认 Redis 客户端已启用async_redis(config/autoload/redis.php中'async' => true)
正确注册与隔离方式
Timer 本身不提供并发保护,多个耗时任务挤在同一个 Worker 协程里会互相拖累。推荐做法是:
- 轻量逻辑(如计数、状态检查)可留在
tick闭包,但必须全协程安全 - 中重度逻辑(如批量推送、缓存刷新)应投递到
TaskWorker:$this->container->get(TaskExecutor::class)->execute(new YourTask($data)) - 避免在
onWorkerStart外多次重复调用Timer::tick,防止句柄泄漏;统一在Process或Command中启动 - 务必设置超时兜底:
Timer::tick(5000, function () { /* ... */ }, ['timeout' => 4000]),防止单次执行过长拖垮全局
验证是否真被阻塞:用 strace 看系统调用
当怀疑 Timer 偏移时,不要只看日志。直接 attach 到 Worker 进程抓系统调用:
strace -p $(pgrep -f 'worker') -e trace=epoll_wait,read,write,open -T 2>&1 | grep -E "(read|epoll)"
若看到大量 read 调用耗时 >100ms,或 epoll_wait 长时间无返回,基本锁定是闭包内某处阻塞 I/O 没释放协程。
真正难排查的,是那些看起来“只是读个配置文件”或“查个本地 Redis”的操作——它们在协程里一旦没走异步路径,就是定时炸弹。











