[PHP]タイムアウト処理を実装!pcntl_alarm関数の使い方と実践例

PHP

はじめに

長時間実行される処理や、外部APIとの通信など、PHPスクリプトで「一定時間経過したら処理を中断したい」という場面に遭遇することがあります。

そんな時に役立つのが pcntl_alarm 関数です。この記事では、UNIXシグナルを使ったタイマー機能を実装するこの関数について、基礎から実践的な活用方法まで詳しく解説します。

pcntl_alarm関数とは

pcntl_alarm は、指定された秒数後にSIGALRMシグナルを送信するタイマーを設定する関数です。プロセス制御拡張(PCNTL)の一部で、主にCLI環境で使用されます。

基本構文

int pcntl_alarm(int $seconds)

パラメータ

  • $seconds (int): アラームを発生させるまでの秒数(0を指定するとアラームをキャンセル)

戻り値

  • int: 前回設定されていたアラームの残り秒数(設定されていない場合は0)

重要な前提条件

<?php
// 利用可能性のチェック
if (!function_exists('pcntl_alarm')) {
    die("pcntl拡張が利用できません。CLI環境で実行してください。\n");
}

// この関数はCLI(コマンドライン)環境でのみ動作します
if (php_sapi_name() !== 'cli') {
    die("この機能はCLI環境でのみ利用可能です。\n");
}
?>

基本的な使用例

シンプルなアラーム設定

<?php
// シグナルハンドラの定義
function alarmHandler($signo) {
    echo "\n⏰ アラームが発生しました!\n";
    exit(0);
}

// シグナルハンドラを登録
pcntl_signal(SIGALRM, 'alarmHandler');

echo "3秒後にアラームを設定します...\n";

// 3秒後のアラームを設定
pcntl_alarm(3);

// シグナル待機
while (true) {
    pcntl_signal_dispatch(); // シグナルをディスパッチ
    echo ".";
    sleep(1);
}
?>

アラームのキャンセル

<?php
function alarmHandler($signo) {
    echo "アラーム発生!\n";
}

pcntl_signal(SIGALRM, 'alarmHandler');

// 5秒後のアラームを設定
$remaining = pcntl_alarm(5);
echo "5秒後のアラームを設定しました\n";

sleep(2);

// アラームをキャンセル(0を設定)
$remaining = pcntl_alarm(0);
echo "アラームをキャンセルしました(残り{$remaining}秒)\n";

sleep(5);
echo "プログラム終了(アラームは発生しませんでした)\n";
?>

実践的な活用例

1. タイムアウト付き処理の実装

<?php
class TimeoutExecutor {
    private $timeout_occurred = false;
    private $original_handler = null;
    
    /**
     * タイムアウト付きで関数を実行
     * @param callable $callback 実行する関数
     * @param int $timeout タイムアウト秒数
     * @return mixed 実行結果、またはnull(タイムアウト時)
     */
    public function execute($callback, $timeout) {
        $this->timeout_occurred = false;
        
        // シグナルハンドラを設定
        $this->original_handler = pcntl_signal_get_handler(SIGALRM);
        pcntl_signal(SIGALRM, [$this, 'handleTimeout']);
        
        // アラーム設定
        pcntl_alarm($timeout);
        
        $result = null;
        
        try {
            // コールバック実行
            $result = call_user_func($callback);
            
            // 正常終了したらアラームをキャンセル
            pcntl_alarm(0);
            
        } catch (Exception $e) {
            pcntl_alarm(0);
            throw $e;
        } finally {
            // 元のハンドラを復元
            if ($this->original_handler !== null) {
                pcntl_signal(SIGALRM, $this->original_handler);
            }
        }
        
        return $this->timeout_occurred ? null : $result;
    }
    
    /**
     * タイムアウトハンドラ
     */
    public function handleTimeout($signo) {
        $this->timeout_occurred = true;
        throw new RuntimeException("処理がタイムアウトしました");
    }
    
    /**
     * タイムアウトが発生したかチェック
     */
    public function hasTimedOut() {
        return $this->timeout_occurred;
    }
}

// 使用例
$executor = new TimeoutExecutor();

// 正常に終了する処理
echo "=== ケース1: 正常終了 ===\n";
$result = $executor->execute(function() {
    echo "処理開始...\n";
    sleep(2);
    echo "処理完了\n";
    return "成功";
}, 5);

echo "結果: " . ($result ?? "タイムアウト") . "\n\n";

// タイムアウトする処理
echo "=== ケース2: タイムアウト ===\n";
try {
    $result = $executor->execute(function() {
        echo "重い処理開始...\n";
        for ($i = 0; $i < 10; $i++) {
            echo "処理中... {$i}\n";
            sleep(1);
            pcntl_signal_dispatch();
        }
        return "完了";
    }, 3);
    
    echo "結果: " . ($result ?? "タイムアウト") . "\n";
} catch (RuntimeException $e) {
    echo "エラー: " . $e->getMessage() . "\n";
}
?>

2. 外部コマンド実行のタイムアウト管理

<?php
class CommandExecutor {
    private $timeout = 30;
    private $timed_out = false;
    
    /**
     * コマンドをタイムアウト付きで実行
     * @param string $command 実行するコマンド
     * @param int $timeout タイムアウト秒数
     * @return array 実行結果
     */
    public function executeWithTimeout($command, $timeout = 30) {
        $this->timeout = $timeout;
        $this->timed_out = false;
        
        // シグナルハンドラ設定
        pcntl_signal(SIGALRM, [$this, 'handleAlarm']);
        pcntl_alarm($timeout);
        
        $start_time = microtime(true);
        $output = [];
        $return_code = 0;
        
        try {
            // コマンド実行
            exec($command, $output, $return_code);
            
            // 正常終了
            pcntl_alarm(0);
            
        } catch (Exception $e) {
            pcntl_alarm(0);
            throw $e;
        }
        
        $execution_time = microtime(true) - $start_time;
        
        return [
            'success' => !$this->timed_out && $return_code === 0,
            'timed_out' => $this->timed_out,
            'output' => $output,
            'return_code' => $return_code,
            'execution_time' => $execution_time,
            'timeout_setting' => $timeout
        ];
    }
    
    /**
     * アラームハンドラ
     */
    public function handleAlarm($signo) {
        $this->timed_out = true;
        throw new RuntimeException("コマンド実行がタイムアウトしました({$this->timeout}秒)");
    }
}

// 使用例
$executor = new CommandExecutor();

// 素早く終わるコマンド
echo "=== 高速コマンド ===\n";
$result = $executor->executeWithTimeout('echo "Hello, World!"', 5);
echo "成功: " . ($result['success'] ? 'Yes' : 'No') . "\n";
echo "実行時間: " . number_format($result['execution_time'], 4) . "秒\n";
echo "出力: " . implode("\n", $result['output']) . "\n\n";

// 長時間かかるコマンド(タイムアウトテスト)
echo "=== 長時間コマンド(タイムアウトテスト) ===\n";
try {
    $result = $executor->executeWithTimeout('sleep 10', 3);
    echo "タイムアウト: " . ($result['timed_out'] ? 'Yes' : 'No') . "\n";
} catch (RuntimeException $e) {
    echo "エラー: " . $e->getMessage() . "\n";
}
?>

3. データベースクエリのタイムアウト監視

<?php
class DatabaseQueryMonitor {
    private $connection;
    private $max_query_time = 30;
    
    public function __construct($connection, $max_query_time = 30) {
        $this->connection = $connection;
        $this->max_query_time = $max_query_time;
    }
    
    /**
     * タイムアウト監視付きクエリ実行
     */
    public function queryWithTimeout($sql, $params = [], $timeout = null) {
        $timeout = $timeout ?? $this->max_query_time;
        $query_timed_out = false;
        
        // アラームハンドラ設定
        pcntl_signal(SIGALRM, function($signo) use (&$query_timed_out, $sql) {
            $query_timed_out = true;
            error_log("クエリタイムアウト: " . substr($sql, 0, 100));
        });
        
        pcntl_alarm($timeout);
        
        $start_time = microtime(true);
        $result = null;
        $error = null;
        
        try {
            // クエリ実行(シミュレーション)
            $result = $this->executeQuery($sql, $params);
            
            pcntl_alarm(0);
            
        } catch (Exception $e) {
            pcntl_alarm(0);
            $error = $e->getMessage();
        }
        
        $execution_time = microtime(true) - $start_time;
        
        // ログ記録
        $this->logQuery([
            'sql' => $sql,
            'params' => $params,
            'execution_time' => $execution_time,
            'timed_out' => $query_timed_out,
            'success' => !$query_timed_out && $error === null,
            'error' => $error
        ]);
        
        if ($query_timed_out) {
            throw new RuntimeException("クエリがタイムアウトしました({$timeout}秒)");
        }
        
        if ($error !== null) {
            throw new RuntimeException("クエリエラー: {$error}");
        }
        
        return $result;
    }
    
    /**
     * クエリ実行(シミュレーション)
     */
    private function executeQuery($sql, $params) {
        // 実際のデータベース処理をシミュレート
        echo "クエリ実行中: " . substr($sql, 0, 50) . "...\n";
        
        // ランダムな処理時間
        $processing_time = rand(1, 5);
        
        for ($i = 0; $i < $processing_time; $i++) {
            sleep(1);
            pcntl_signal_dispatch();
            echo ".";
        }
        echo "\n";
        
        return ['rows' => rand(0, 100)];
    }
    
    /**
     * クエリログ記録
     */
    private function logQuery($log_data) {
        $status = $log_data['success'] ? '✓' : '✗';
        $time = number_format($log_data['execution_time'], 4);
        
        echo sprintf(
            "[%s] クエリ実行時間: %ss | タイムアウト: %s | SQL: %s\n",
            $status,
            $time,
            $log_data['timed_out'] ? 'Yes' : 'No',
            substr($log_data['sql'], 0, 50)
        );
    }
}

// 使用例
echo "=== データベースクエリ監視 ===\n\n";

$monitor = new DatabaseQueryMonitor(null, 3);

// 通常のクエリ
try {
    echo "ケース1: 通常クエリ\n";
    $result = $monitor->queryWithTimeout("SELECT * FROM users WHERE id = ?", [123]);
    echo "結果: 成功\n\n";
} catch (RuntimeException $e) {
    echo "エラー: " . $e->getMessage() . "\n\n";
}

// 長時間クエリ
try {
    echo "ケース2: 長時間クエリ(タイムアウト予想)\n";
    $result = $monitor->queryWithTimeout(
        "SELECT * FROM large_table JOIN another_large_table...",
        []
    );
    echo "結果: 成功\n\n";
} catch (RuntimeException $e) {
    echo "エラー: " . $e->getMessage() . "\n\n";
}
?>

4. バックグラウンドジョブの監視

<?php
class BackgroundJobMonitor {
    private $jobs = [];
    private $max_execution_time = 300; // 5分
    
    /**
     * ジョブを登録して実行
     */
    public function registerJob($job_id, $callback, $timeout = null) {
        $timeout = $timeout ?? $this->max_execution_time;
        
        $this->jobs[$job_id] = [
            'id' => $job_id,
            'callback' => $callback,
            'timeout' => $timeout,
            'start_time' => time(),
            'status' => 'running',
            'result' => null
        ];
        
        return $this->executeJob($job_id);
    }
    
    /**
     * ジョブ実行
     */
    private function executeJob($job_id) {
        $job = &$this->jobs[$job_id];
        
        // タイムアウトハンドラ設定
        pcntl_signal(SIGALRM, function($signo) use (&$job) {
            $job['status'] = 'timeout';
            $job['end_time'] = time();
            throw new RuntimeException("ジョブ {$job['id']} がタイムアウトしました");
        });
        
        pcntl_alarm($job['timeout']);
        
        try {
            echo "[{$job_id}] ジョブ開始(タイムアウト: {$job['timeout']}秒)\n";
            
            $result = call_user_func($job['callback']);
            
            pcntl_alarm(0);
            
            $job['status'] = 'completed';
            $job['result'] = $result;
            $job['end_time'] = time();
            
            $duration = $job['end_time'] - $job['start_time'];
            echo "[{$job_id}] ジョブ完了(実行時間: {$duration}秒)\n";
            
            return $result;
            
        } catch (RuntimeException $e) {
            pcntl_alarm(0);
            echo "[{$job_id}] " . $e->getMessage() . "\n";
            throw $e;
        }
    }
    
    /**
     * ジョブステータス取得
     */
    public function getJobStatus($job_id) {
        if (!isset($this->jobs[$job_id])) {
            return null;
        }
        
        $job = $this->jobs[$job_id];
        $duration = isset($job['end_time']) 
            ? $job['end_time'] - $job['start_time']
            : time() - $job['start_time'];
        
        return [
            'id' => $job['id'],
            'status' => $job['status'],
            'duration' => $duration,
            'timeout' => $job['timeout'],
            'result' => $job['result']
        ];
    }
    
    /**
     * 全ジョブのサマリー表示
     */
    public function displaySummary() {
        echo "\n=== ジョブ実行サマリー ===\n";
        
        foreach ($this->jobs as $job) {
            $duration = isset($job['end_time']) 
                ? $job['end_time'] - $job['start_time']
                : time() - $job['start_time'];
            
            $status_icon = $job['status'] === 'completed' ? '✓' : 
                          ($job['status'] === 'timeout' ? '⏱' : '⋯');
            
            echo sprintf(
                "[%s] ジョブID: %s | ステータス: %s | 実行時間: %ds / %ds\n",
                $status_icon,
                $job['id'],
                $job['status'],
                $duration,
                $job['timeout']
            );
        }
    }
}

// 使用例
$monitor = new BackgroundJobMonitor();

// ジョブ1: 正常終了
try {
    $monitor->registerJob('job1', function() {
        echo "  データ処理中...\n";
        sleep(3);
        return ['processed' => 100];
    }, 10);
} catch (RuntimeException $e) {
    echo "  エラー: " . $e->getMessage() . "\n";
}

// ジョブ2: タイムアウト
try {
    $monitor->registerJob('job2', function() {
        echo "  重い処理中...\n";
        for ($i = 0; $i < 10; $i++) {
            sleep(1);
            echo "  進捗: " . ($i + 1) * 10 . "%\n";
            pcntl_signal_dispatch();
        }
        return ['processed' => 1000];
    }, 5);
} catch (RuntimeException $e) {
    echo "  エラー: " . $e->getMessage() . "\n";
}

// ジョブ3: 高速処理
try {
    $monitor->registerJob('job3', function() {
        echo "  高速処理中...\n";
        sleep(1);
        return ['processed' => 50];
    }, 10);
} catch (RuntimeException $e) {
    echo "  エラー: " . $e->getMessage() . "\n";
}

$monitor->displaySummary();
?>

重要な注意点と制限事項

マルチスレッド環境での注意

<?php
/**
 * pcntl_alarmの制限事項
 */

// 1. プロセスごとに1つのアラームのみ
echo "=== 制限1: 1つのアラームのみ ===\n";

pcntl_signal(SIGALRM, function($signo) {
    echo "アラーム発生\n";
});

// 最初のアラーム設定
$remaining1 = pcntl_alarm(5);
echo "5秒後のアラームを設定\n";

sleep(1);

// 2つ目のアラームを設定すると、1つ目は上書きされる
$remaining2 = pcntl_alarm(3);
echo "3秒後のアラームを設定(前回の残り: {$remaining2}秒)\n";

// 2. sleep()との競合
echo "\n=== 制限2: sleep()との競合 ===\n";

pcntl_alarm(2);
echo "2秒のアラームを設定してから5秒スリープ\n";

sleep(5); // sleep中にアラームが発生

echo "スリープ終了\n";

// 3. Web環境では動作しない
echo "\n=== 制限3: CLI環境のみ ===\n";
echo "現在の実行環境: " . php_sapi_name() . "\n";

if (php_sapi_name() !== 'cli') {
    echo "⚠️  この関数はWeb環境では動作しません\n";
}
?>

ベストプラクティス

<?php
class AlarmBestPractices {
    /**
     * 安全なアラーム設定
     */
    public static function safeAlarmSetup() {
        // 1. 環境チェック
        if (php_sapi_name() !== 'cli') {
            throw new RuntimeException("CLI環境が必要です");
        }
        
        // 2. PCNTL拡張チェック
        if (!extension_loaded('pcntl')) {
            throw new RuntimeException("PCNTL拡張が必要です");
        }
        
        // 3. シグナルが有効か確認
        if (!function_exists('pcntl_signal')) {
            throw new RuntimeException("シグナル機能が利用できません");
        }
        
        return true;
    }
    
    /**
     * ネストしたアラームの管理
     */
    public static function nestedAlarmExample() {
        $alarm_stack = [];
        
        // 外側のアラーム
        pcntl_signal(SIGALRM, function($signo) {
            echo "外側のアラーム発生\n";
        });
        
        $alarm_stack[] = pcntl_alarm(10);
        echo "外側アラーム設定: 10秒\n";
        
        sleep(2);
        
        // 内側のアラーム(上書き)
        $remaining = pcntl_alarm(3);
        $alarm_stack[] = $remaining;
        echo "内側アラーム設定: 3秒(前回の残り: {$remaining}秒)\n";
        
        // 内側の処理完了後、外側のアラームを復元
        sleep(1);
        pcntl_alarm(0); // キャンセル
        
        // スタックから復元(簡易実装)
        $previous = array_pop($alarm_stack);
        if ($previous > 0) {
            pcntl_alarm($previous - 3); // 経過時間を考慮
            echo "外側のアラームを復元\n";
        }
    }
    
    /**
     * シグナルディスパッチの重要性
     */
    public static function signalDispatchExample() {
        $counter = 0;
        
        pcntl_signal(SIGALRM, function($signo) use (&$counter) {
            echo "\nアラーム発生!(カウント: {$counter})\n";
            exit(0);
        });
        
        pcntl_alarm(3);
        
        echo "pcntl_signal_dispatch()を呼ばないとシグナルが処理されません\n";
        
        while (true) {
            // シグナルディスパッチが必要
            pcntl_signal_dispatch();
            
            echo ".";
            $counter++;
            usleep(100000); // 0.1秒
        }
    }
}

// 実行例
try {
    AlarmBestPractices::safeAlarmSetup();
    echo "✓ 環境チェック完了\n\n";
    
    echo "=== ネストしたアラーム例 ===\n";
    AlarmBestPractices::nestedAlarmExample();
    
} catch (RuntimeException $e) {
    echo "エラー: " . $e->getMessage() . "\n";
}
?>

代替手段との比較

<?php
/**
 * pcntl_alarm の代替手段
 */

// 代替1: set_time_limit(Web環境でも使用可能)
echo "=== set_time_limit ===\n";
set_time_limit(5); // スクリプト全体の実行時間制限
echo "スクリプト実行時間を5秒に制限\n";

// 代替2: stream_set_timeout(ストリーム操作)
echo "\n=== stream_set_timeout ===\n";
$fp = fsockopen("example.com", 80, $errno, $errstr, 30);
if ($fp) {
    stream_set_timeout($fp, 5); // 5秒のタイムアウト
    echo "ストリームタイムアウトを5秒に設定\n";
    fclose($fp);
}

// 代替3: curl_setopt(HTTP通信)
echo "\n=== curl タイムアウト ===\n";
if (function_exists('curl_init')) {
    $ch = curl_init("https://example.com");
    curl_setopt($ch, CURLOPT_TIMEOUT, 5);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
    echo "cURLタイムアウトを設定\n";
    curl_close($ch);
}

// 比較表
echo "\n=== 各手法の比較 ===\n";
$comparison = [
    'pcntl_alarm' => [
        '環境' => 'CLI専用',
        '精度' => '秒単位',
        '用途' => '任意の処理のタイムアウト',
        '複雑さ' => '高(シグナル処理が必要)'
    ],
    'set_time_limit' => [
        '環境' => 'Web/CLI両方',
        '精度' => '秒単位',
        '用途' => 'スクリプト全体の制限',
        '複雑さ' => '低'
    ],
    'stream_set_timeout' => [
        '環境' => 'Web/CLI両方',
        '精度' => '秒+マイクロ秒',
        '用途' => 'ストリーム操作',
        '複雑さ' => '中'
    ],
    'curl timeout' => [
        '環境' => 'Web/CLI両方',
        '精度' => '秒単位',
        '用途' => 'HTTP通信',
        '複雑さ' => '低'
    ]
];

foreach ($comparison as $method => $details) {
    echo "\n{$method}:\n";
    foreach ($details as $key => $value) {
        echo "  {$key}: {$value}\n";
    }
}
?>

まとめ

pcntl_alarm 関数は、CLI環境でのタイムアウト処理を実装する強力なツールです。

主な特徴:

  • UNIXシグナルベース: 低レベルなタイマー機能
  • 柔軟な制御: 任意の処理にタイムアウトを設定可能
  • CLI専用: コマンドライン環境での使用に特化

主な用途:

  • 長時間実行される可能性のある処理の監視
  • 外部コマンド実行のタイムアウト管理
  • バックグラウンドジョブの実行時間制限
  • データベースクエリの監視

重要な注意点:

  • CLI環境でのみ動作(Web環境では使用不可)
  • プロセスごとに1つのアラームのみ設定可能
  • シグナルディスパッチ(pcntl_signal_dispatch())の呼び出しが必須
  • Web環境では set_time_limitstream_set_timeout などの代替手段を使用

適切に使用することで、PHPのバッチ処理やCLIツールにおいて、堅牢なタイムアウト管理を実現できます。ただし、シグナル処理の理解が必要なため、使用前に十分な検証を行うことをお勧めします。


プロセス制御の理解は、PHPでの高度なシステムプログラミングへの第一歩です。この記事が、より堅牢なCLIアプリケーション開発の一助となれば幸いです。

タイトルとURLをコピーしました