Skip to content

信号处理

概述

信号(Signal)是 Unix/Linux 操作系统提供的一种进程间通信机制,用于通知进程发生了特定事件。在 PHP CLI 守护进程或长运行脚本中,信号处理至关重要 -- 它允许程序优雅地响应终止信号、定时器信号等外部事件,确保资源的正确释放和状态的一致性。

PHP 版本要求

PHP 8.1+ 中 pcntl_async_signals(true) 使得信号处理更加可靠。PHP 8.1 引入了 Fiber,需要注意信号与 Fiber 的交互。本文基于 PHP 8.1+ 编写。

基础概念

什么是信号

信号是操作系统发送给进程的异步通知。每个信号都有一个编号和名称,常见信号包括:

信号编号说明默认行为
SIGINT2中断信号(Ctrl+C)终止进程
SIGTERM15终止请求终止进程
SIGHUP1终端断开终止进程
SIGUSR110用户自定义信号 1终止进程
SIGUSR212用户自定义信号 2终止进程
SIGALRM14闹钟超时终止进程
SIGCHLD17子进程状态变化忽略

PHP 信号处理模型

PHP 的信号处理有两种模式:

  1. 同步模式(PHP 7.1 之前):信号仅在执行引擎的特定检查点被处理
  2. 异步模式(PHP 7.1+):通过 pcntl_async_signals(true) 启用,信号会中断当前的 PHP 代码执行

安装与配置

bash
# pcntl 扩展通常随 PHP 一起编译
php -m | grep pcntl

# 如果未安装(编译安装时添加)
./configure --enable-pcntl

# POSIX 扩展(信号相关常量)
php -m | grep posix

详细说明

pcntl_signal 信号注册

php
<?php
declare(strict_types=1);

/**
 * 基本信号处理
 */
class SignalHandler
{
    private bool $shouldStop = false;

    public function register(): void
    {
        // 启用异步信号处理
        pcntl_async_signals(true);

        // 注册 SIGINT 处理(Ctrl+C)
        pcntl_signal(SIGINT, function (int $signo): void {
            echo PHP_EOL . "收到 SIGINT ({$signo}),准备优雅退出..." . PHP_EOL;
            $this->shouldStop = true;
        });

        // 注册 SIGTERM 处理
        pcntl_signal(SIGTERM, function (int $signo): void {
            echo "收到 SIGTERM ({$signo}),准备优雅退出..." . PHP_EOL;
            $this->shouldStop = true;
        });

        // 注册 SIGHUP 处理(重新加载配置)
        pcntl_signal(SIGHUP, function (int $signo): void {
            echo "收到 SIGHUP ({$signo}),重新加载配置..." . PHP_EOL;
            $this->reloadConfig();
        });

        // 注册 SIGUSR1 处理(自定义功能,如状态输出)
        pcntl_signal(SIGUSR1, [$this, 'handleSigusr1']);
    }

    public function run(): void
    {
        $this->register();

        echo "守护进程启动,PID: " . getmypid() . PHP_EOL;
        echo "发送 SIGINT/SIGTERM 停止 | SIGHUP 重载配置 | SIGUSR1 状态输出" . PHP_EOL;

        while (!$this->shouldStop) {
            // 执行业务逻辑
            $this->doWork();

            // pcntl_signal_dispatch() 在异步模式下通常不需要
            // 但在某些场景下仍有用
            pcntl_signal_dispatch();

            usleep(100000); // 100ms
        }

        $this->cleanup();
        echo "进程优雅退出" . PHP_EOL;
    }

    private function doWork(): void
    {
        // 模拟工作
        static $counter = 0;
        $counter++;
        if ($counter % 50 === 0) {
            echo ".";
        }
    }

    private function reloadConfig(): void
    {
        echo "配置已重新加载" . PHP_EOL;
    }

    private function cleanup(): void
    {
        echo "清理资源..." . PHP_EOL;
    }

    public function handleSigusr1(int $signo): void
    {
        echo PHP_EOL . "SIGUSR1: 状态报告" . PHP_EOL;
        echo "  内存使用: " . round(memory_get_usage() / 1024 / 1024, 2) . "MB" . PHP_EOL;
        echo "  运行时间: " . (time() - ($_SERVER['REQUEST_TIME'] ?? time())) . "秒" . PHP_EOL;
    }
}

if (PHP_SAPI === 'cli') {
    $handler = new SignalHandler();
    $handler->run();
}

pcntl_sigprocmask 信号屏蔽

php
<?php
declare(strict_types=1);

/**
 * 信号屏蔽与批量处理
 * 在关键操作期间屏蔽信号,操作完成后批量处理
 */
class SignalMaskManager
{
    /**
     * 在关键操作期间屏蔽信号
     */
    public function criticalSection(): void
    {
        // 屏蔽 SIGINT 和 SIGTERM
        pcntl_sigprocmask(SIG_BLOCK, [SIGINT, SIGTERM]);

        echo "进入临界区..." . PHP_EOL;

        try {
            // 执行不能被中断的操作
            $this->atomicOperation();
        } finally {
            // 解除屏蔽
            pcntl_sigprocmask(SIG_UNBLOCK, [SIGINT, SIGTERM]);
        }

        echo "退出临界区" . PHP_EOL;
    }

    /**
     * 收集被屏蔽期间的信号并处理
     */
    public function processPendingSignals(): void
    {
        // 获取待处理的信号
        $pending = [];
        pcntl_sigpending($pending);

        if (!empty($pending)) {
            echo "待处理信号: " . implode(', ', $pending) . PHP_EOL;
        }
    }

    /**
     * 设置信号屏蔽集并等待
     */
    public function waitForSignals(): void
    {
        // 仅等待 SIGUSR1
        pcntl_sigprocmask(SIG_BLOCK, [SIGUSR1]);

        echo "等待 SIGUSR1 信号... (PID: " . getmypid() . ")" . PHP_EOL;
        echo "发送: kill -USR1 " . getmypid() . PHP_EOL;

        // 阻塞等待信号
        pcntl_sigwaitinfo([SIGUSR1], $info);

        echo "收到信号: {$info['signo']}" . PHP_EOL;

        // 解除屏蔽
        pcntl_sigprocmask(SIG_UNBLOCK, [SIGUSR1]);
    }

    private function atomicOperation(): void
    {
        echo "执行原子操作..." . PHP_EOL;
        sleep(3);
        echo "原子操作完成" . PHP_EOL;
    }
}

// CLI 使用
if (PHP_SAPI === 'cli' && isset($argv[1]) && $argv[1] === 'wait') {
    pcntl_async_signals(true);
    $manager = new SignalMaskManager();
    $manager->waitForSignals();
}

pcntl_async_signals 异步模式

php
<?php
declare(strict_types=1);

/**
 * 异步信号 vs 同步信号对比
 */

/**
 * 同步模式(PHP 7.1 之前的行为)
 * 信号仅在引擎检查点处理
 */
function syncModeExample(): void
{
    pcntl_async_signals(false);

    pcntl_signal(SIGUSR1, function (int $signo): void {
        echo "同步模式: 收到 SIGUSR1" . PHP_EOL;
    });

    echo "同步模式: 等待 5 秒..." . PHP_EOL;
    sleep(5); // 信号可能在 sleep 期间被处理
    echo "同步模式: 结束" . PHP_EOL;
}

/**
 * 异步模式(PHP 7.1+)
 * 信号立即中断当前执行
 */
function asyncModeExample(): void
{
    pcntl_async_signals(true);

    pcntl_signal(SIGALRM, function (int $signo): void {
        echo "异步模式: 闹钟触发!" . PHP_EOL;
    });

    echo "异步模式: 设置 2 秒闹钟..." . PHP_EOL;
    pcntl_alarm(2);

    // 执行耗时操作(会被信号中断)
    echo "异步模式: 开始计数..." . PHP_EOL;
    for ($i = 0; $i < 10; $i++) {
        echo "  {$i}" . PHP_EOL;
        sleep(1);
    }

    echo "异步模式: 结束" . PHP_EOL;
}

定时器信号 SIGALRM

php
<?php
declare(strict_types=1);

/**
 * 基于 SIGALRM 的定时器
 */
class SignalTimer
{
    private int $tickCount = 0;
    private int $interval;

    public function __construct(int $intervalSeconds = 1)
    {
        $this->interval = $intervalSeconds;
    }

    /**
     * 启动定时器
     */
    public function start(): void
    {
        pcntl_async_signals(true);

        pcntl_signal(SIGALRM, function (): void {
            $this->tickCount++;
            echo "[定时器] tick #{$this->tickCount} | "
                . date('Y-m-d H:i:s') . " | "
                . "内存: " . round(memory_get_usage() / 1024, 2) . "KB" . PHP_EOL;
        });

        pcntl_alarm($this->interval);
    }

    /**
     * 使用多闹钟实现不同间隔
     */
    public function startMultiAlarm(): void
    {
        pcntl_async_signals(true);

        // 每秒触发
        pcntl_signal(SIGALRM, function (): void {
            echo "[" . date('H:i:s') . "] 1秒定时器" . PHP_EOL;
        });
        pcntl_alarm(1);

        // SIGUSR1 作为 5 秒定时器(需要外部进程发送或使用 pcntl_alarm 重置)
        pcntl_signal(SIGUSR1, function (): void {
            echo "[" . date('H:i:s') . "] 5秒定时器(模拟)" . PHP_EOL;
        });
    }

    /**
     * 心跳监控模式
     */
    public function heartbeat(): void
    {
        $lastTick = time();

        pcntl_async_signals(true);
        pcntl_signal(SIGALRM, function () use (&$lastTick): void {
            $now = time();
            $gap = $now - $lastTick;

            if ($gap > 2) {
                echo "[警告] 心跳延迟 {$gap} 秒!" . PHP_EOL;
            } else {
                echo "[心跳] 正常 (间隔 {$gap}s)" . PHP_EOL;
            }

            $lastTick = $now;
            pcntl_alarm(1); // 重新设置闹钟
        });

        pcntl_alarm(1);
    }
}

实战示例

优雅退出的守护进程

php
<?php
declare(strict_types=1);

/**
 * 生产级守护进程 - 完整的信号处理
 */
class DaemonProcess
{
    private bool $running = true;
    private int $startTime;
    private int $processedJobs = 0;
    private \PDO $db;

    public function __construct(private string $name = 'worker')
    {
        $this->startTime = time();
    }

    public function start(): void
    {
        $this->setupSignalHandlers();
        $this->daemonize();
        $this->runMainLoop();
    }

    private function setupSignalHandlers(): void
    {
        pcntl_async_signals(true);

        // SIGTERM/SIGINT: 优雅退出
        $stopHandler = function (int $signo): void {
            $signalName = match ($signo) {
                SIGTERM => 'SIGTERM',
                SIGINT => 'SIGINT',
                default => "信号{$signo}",
            };
            echo "[{$this->name}] 收到 {$signalName},开始优雅关闭..." . PHP_EOL;
            $this->running = false;
        };

        pcntl_signal(SIGTERM, $stopHandler);
        pcntl_signal(SIGINT, $stopHandler);

        // SIGHUP: 重新加载配置
        pcntl_signal(SIGHUP, function (): void {
            echo "[{$this->name}] 收到 SIGHUP,重新加载配置" . PHP_EOL;
            // 重新加载配置逻辑
        });

        // SIGUSR1: 输出状态信息
        pcntl_signal(SIGUSR1, function (): void {
            $uptime = time() - $this->startTime;
            echo "[{$this->name}] 状态报告:" . PHP_EOL;
            echo "  PID: " . getmypid() . PHP_EOL;
            echo "  运行时间: {$uptime}秒" . PHP_EOL;
            echo "  处理任务: {$this->processedJobs}" . PHP_EOL;
            echo "  内存: " . round(memory_get_usage() / 1024 / 1024, 2) . "MB" . PHP_EOL;
            echo "  峰值内存: " . round(memory_get_peak_usage() / 1024 / 1024, 2) . "MB" . PHP_EOL;
        });

        // SIGUSR2: 输出堆栈
        pcntl_signal(SIGUSR2, function (): void {
            echo "[{$this->name}] 调用堆栈:" . PHP_EOL;
            $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
            foreach (array_slice($trace, 1, 10) as $frame) {
                echo "  {$frame['file']}:{$frame['line']} {$frame['function']}()" . PHP_EOL;
            }
        });
    }

    private function daemonize(): void
    {
        $pid = pcntl_fork();
        if ($pid < 0) {
            throw new RuntimeException('Fork 失败');
        }
        if ($pid > 0) {
            exit(0); // 父进程退出
        }

        // 子进程成为新会话的领导者
        if (posix_setsid() < 0) {
            throw new RuntimeException('setsid 失败');
        }

        // 重设文件创建掩码
        umask(0);

        // 关闭标准 I/O
        fclose(STDIN);
        fclose(STDOUT);
        fclose(STDERR);

        // 重新打开标准 I/O 到 /dev/null
        $stdin = fopen('/dev/null', 'r');
        $stdout = fopen('/dev/null', 'w');
        $stderr = fopen('/var/log/' . $this->name . '.log', 'a');
    }

    private function runMainLoop(): void
    {
        while ($this->running) {
            $this->processJob();
            usleep(100000); // 100ms
        }

        $this->gracefulShutdown();
    }

    private function processJob(): void
    {
        // 模拟处理任务
        $this->processedJobs++;
    }

    private function gracefulShutdown(): void
    {
        echo "[{$this->name}] 清理资源..." . PHP_EOL;
        // 关闭数据库连接
        // 释放锁
        // 写入状态文件
        echo "[{$this->name}] 优雅退出完成" . PHP_EOL;
    }
}

// 使用示例
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'start') {
    $daemon = new DaemonProcess('queue-worker');
    $daemon->start();
}

信号与中断处理

php
<?php
declare(strict_types=1);

/**
 * 信号中断长操作
 * 展示信号如何在长时间运行的操作中被处理
 */
class InterruptibleTask
{
    private bool $interrupted = false;

    /**
     * 可中断的长循环
     */
    public function longRunningTask(int $totalSteps): int
    {
        pcntl_async_signals(true);

        pcntl_signal(SIGINT, function (): void {
            $this->interrupted = true;
        });

        $completedSteps = 0;

        for ($i = 0; $i < $totalSteps; $i++) {
            if ($this->interrupted) {
                echo "任务被中断(完成 {$completedSteps}/{$totalSteps})" . PHP_EOL;
                break;
            }

            // 模拟计算
            $result = 0;
            for ($j = 0; $j < 10000; $j++) {
                $result += $j;
            }

            $completedSteps++;

            if ($completedSteps % 1000 === 0) {
                echo "进度: {$completedSteps}/{$totalSteps} (" .
                    round($completedSteps / $totalSteps * 100, 1) . "%)" . PHP_EOL;
            }
        }

        return $completedSteps;
    }
}

// 使用示例
if (PHP_SAPI === 'cli') {
    $task = new InterruptibleTask();
    echo "开始长任务(Ctrl+C 中断)..." . PHP_EOL;
    $completed = $task->longRunningTask(100000);
    echo "已完成: {$completed} 步" . PHP_EOL;
}

注意事项

信号安全的操作

并非所有操作在信号处理器中都是安全的。信号处理器中应避免:

  1. I/O 操作:不安全的(可能引起死锁)
  2. 内存分配:不安全的(可能触发内存管理器死锁)
  3. 调用非可重入函数:不安全的
  4. 抛出异常:PHP 7.1+ 中信号处理器抛出异常可能导致不可预期行为
php
<?php
declare(strict_types=1);

// 信号处理器中只应做最简单的操作
pcntl_signal(SIGTERM, function (int $signo): void {
    // 好的做法:仅设置标志
    global $shouldStop;
    $shouldStop = true;
});

// 不好:在信号处理器中执行复杂操作
// pcntl_signal(SIGTERM, function (int $signo): void {
//     $this->closeDatabase();  // 不安全
//     $this->writeLog();       // 不安全
//     echo "bye";              // 相对安全但不推荐
// });

PHP-FPM 中的信号限制

Web 模式下的限制

在 PHP-FPM / Apache 模块中,信号处理受到严重限制。pcntl_* 函数仅在 CLI / CGI 模式下可用。Web 请求中不应使用信号处理。

最佳实践

1. 守护进程模板

php
<?php
declare(strict_types=1);

// 1. 启用异步信号
pcntl_async_signals(true);

// 2. 注册信号处理器
$shutdown = false;
$reload = false;
pcntl_signal(SIGTERM, fn() => $shutdown = true);
pcntl_signal(SIGINT, fn() => $shutdown = true);
pcntl_signal(SIGHUP, fn() => $reload = true);

// 3. 主循环
while (!$shutdown) {
    if ($reload) {
        // 重新加载配置
        $reload = false;
    }
    // 执行任务
    pcntl_signal_dispatch();
    usleep(100000);
}

// 4. 优雅退出
// cleanup...

2. 使用状态文件

守护进程应将其 PID 写入文件,方便管理和发送信号:

php
<?php
declare(strict_types=1);

// 写入 PID 文件
file_put_contents('/var/run/mydaemon.pid', (string) getmypid());

// 管理脚本
// kill -TERM $(cat /var/run/mydaemon.pid)
// kill -HUP $(cat /var/run/mydaemon.pid)
// kill -USR1 $(cat /var/run/mydaemon.pid)

下一节

继续学习:共享内存与信号量

参考链接