Как сделать, чтобы асинхронные операции async выполнялись параллельно

На сервере цикл осуществляет по базе определенную работу. В основном файле есть 2 кнопки. На одной начало цикла, на второй приостановка. Первая подает команду - цикл начинает осуществляться. К кнопкам на js через fetch привязаны обработчики, осуществляющие дальнейшее выполнение команд. В цикле предусмотрена проверка на изменение флага в отдельном файле продолжать-остановить. Вторая команда подает сигнал на отдельный файл - изменить файл где флаг. По идее изменение флага должно останавливать выполнение php скрипта. Но на практике fetch от кнопки остановки получает ответ лишь после завершения всего цикла в скрипте php. Получается, что 2 асинхронные операции работают последовательно. Перечитал множество постов, но решения не нашел Немного кода:

<script>
        const startButton = document.getElementById('startButton');
        const stopButton = document.getElementById('stopButton');
        const statusDiv = document.getElementById('status');
        let isSending = false;
        async function start_work() {
            isSending = true;
            startButton.disabled = true;
            stopButton.disabled = false;
            statusDiv.textContent = 'Работаем...';
            try {
                // Используем fetch для запроса к серверному скрипту
                const response = await fetch('start_work.php', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({'isDoing': true})
                });
                const result = await response.json();
                isDoing=result.message;
                statusDiv.textContent = result.message;
                console.log(result.message);
            } catch (error) {
                console.error('Ошибка при запуске:', error);
                statusDiv.textContent = 'Ошибка при запуске: ' + error.message;
            } finally {
                // После завершения (успешного или нет) кнопка "запуск" становится активна, а "прервать" — нет
                isDoing= false;
                startButton.disabled = false;
                stopButton.disabled = true;
            }
        }
        async function stop_work() {
            statusDiv.textContent = 'Прерывание...';
            try {
                const response = await fetch('stop_work.php', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({ start_work: false })
                });
                const result = await response.json();
                statusDiv.textContent = result.message;
                console.log(result.message);
            } catch (error) {
                console.error('Ошибка при остановке:', error);
                statusDiv.textContent = 'Ошибка при остановке: ' + error.message;
            } finally {
                isSending = false;
                startButton.disabled = false;
                stopButton.disabled = true;
            }
        }
        startButton.addEventListener('click', start_work);
        stopButton.addEventListener('click', stop_work);
</script> 
php: stop_work.php
session_start(); // Запускаем сессию для отслеживания статуса
$data = json_decode(file_get_contents('php://input'), true);
// Устанавливаем флаг остановки в файле
file_put_contents('doing_status.txt', 'stopped');
$_SESSION['doing_status']=$data['isDoing']; // для подстраховки
$isDoing =false;

start_work.php
<?php 
require_once('../../../private/initialize.php'); 
$data = json_decode(file_get_contents('php://input'), true);
session_start(); // Запускаем сессию для отслеживания статуса
use App\Mailsender;
use App\User;
use App\Subsciber;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
$user=User::find_by_id($session->user_id);
$errors=[];
$emailMessage=Mailsender::find_by_id(1);
$mail = new PHPMailer(true);
$isWorking = $data['isWorking'] ?? false;
$_SESSION['doing_status'] = $isWorking; 
file_put_contents('doing_status.txt', 'running'); 
$recipients = Subsciber::find_all();
$mail_settings=mail_settings($user->email)['mail_settings_prod'];

  $mail->isSMTP();                                            //Send using SMTP
  $mail->Host       = $mail_settings['host'];                 //Set the SMTP server to send through
  $mail->SMTPAuth   = $mail_settings['auth'];                 //Enable SMTP authentication
  $mail->Username   = $mail_settings['username'];             //SMTP username
  $mail->Password   = $mail_settings['password'];             //SMTP password
  $mail->SMTPSecure = $mail_settings['secure'];               //Enable implicit SSL encryption
  $mail->Port       = $mail_settings['port'];                 //Enable port
  $mail->CharSet    = $mail_settings['charset'];              //Enable port
  $mail->setFrom($mail_settings['from_email'], $mail_settings['from_name']);
  $mail->isHTML($mail_settings['is_html']);                                  //Set email format to HTML
  $mail->Subject = $emailMessage->subject;
  $mail->Body   .= "<p>{$emailMessage->body}</p>";
  $mail->Body   .= "<p>С уважением,</p>";
  $mail->Body   .= "<p>{$user->full_name()}</p>";
  $mail->Body   .= "<p>{$user->position}</p>";
  $emailMessage->body=$mail->Body;
  $mail->Body   .= "<p></p>";
  $mail->AltBody = 'Test';
  $html_content = file_get_contents('../../letter_templates/cerberus-fluid.php');
  if ($html_content !== false) {
  // Содержимое файла успешно прочитано и сохранено в $html_content
    $html_content = str_replace('{{subject}}', $emailMessage->subject, $html_content);
  // $html_content = str_replace('{{dates}}', $emailMessage->body, $html_content);
    $mail->body    .= "<h1>{$emailMessage->subject}</h1>{$html_content}";
  } else {
  // Обработка ошибки, если файл не удалось прочитать
    $errors[] =  "Ошибка: Не удалось прочитать файл.\n";
  }
  $emailMessage->Body=$mail->Body;//$mail->Body;
    echo json_encode(['message' => 'Рассылка запущена','isWorking'=>$isWorking]);
  foreach ($recipients as $recipient) {
  $mail->addAddress($recipient->email);
  if (file_get_contents('mailing_status.txt') !== 'running') {
    echo json_encode(['message' => 'Рассылка остановлена','isWorking'=>false]);
        break; // Прерываем цикл, если отправка остановлена
    }
      try {
        $result=$mail->send();
// ====================================================================
        if ($result) {
          $args=[];
          $result = $emailMessage->save();
          if($result === true) {
            $new_id = $emailMessage->id;
        } else {
    // show errors
            $session->message(show_errors($errors));
            redirect_to(url_for('/staff/mailcampaigns/index.php'));
        }
    }
  }
catch (Exception $e) {
    $session->message(show_errors($errors), "Mailer Error: " . $mail->ErrorInfo);
    echo json_encode(['success' => false, 'message' => "Ошибка рассылки: {$mail->ErrorInfo}"]);    
    redirect_to(url_for('/staff/subscribers/show.php?id=' . $recipient->id));
}
$mail->clearAddresses();
sleep(1);
}
$_SESSION['doing_status'] = false; // Сбрасываем флаг после завершения цикла
echo json_encode(['message' => 'Рассылка завершена','isWorking'=>$_SESSION['doing_status']]);
?>

Ответы (1 шт):

Автор решения: Solt

Задача может быть решена несколькими способами. При желании можно решения даже комбинировать.

(Примеры на работоспособность не проверяю, просто излагаю идеи. Пожалуйста, тестируйте сами)

1. Прерывание fetch с помощью AbortController

start_work() запускает длительный fetch, получающий результат start_work.php. stop_work() подаёт сигнал в выполняющийся fetch для прерывания. И я бы не делал функции асинхронными с использованием await. Возвращать промисы - полезная и гибкая возможность.

    const startButton = document.getElementById('startButton');
    const stopButton = document.getElementById('stopButton');
    const statusDiv = document.getElementById('status');
    let isSending = false;
    let controller;

    startButton.addEventListener('click', start_work);
    stopButton.addEventListener('click', stop_work);
    
    function start_work() {
        isSending = true;
        startButton.disabled = true;
        stopButton.disabled = false;
        statusDiv.textContent = 'Работаем...';
        controller = new AbortController();
        return fetch('start_work.php', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({'isDoing': true}),
            signal: controller.signal
        })
        .then(response => response.json())
        .then(result => {
            console.log(result.message);
            isDoing=result.message;
            statusDiv.textContent = result.message;
            return result;
        })
        .catch(error => {
            if (error.name === 'AbortError') {
                console.log('Запрос отменен');
            } else {
                console.error('Ошибка при запуске:', error);
                statusDiv.textContent = 'Ошибка при запуске: ' + error.message;
            }

        })
        .finally(() => {
            // После завершения (успешного или нет) кнопка "запуск" становится активна, а "прервать" — нет
            isDoing= false;
            startButton.disabled = false;
            stopButton.disabled = true;
        });
    }
    
    function stop_work() {
        statusDiv.textContent = 'Прерывание...';
        if (controller) {
            controller.abort();
        }
    }

Плюсы - задача решается полностью на фронте.

Минусы - php-скрипт непредсказуемо прервётся и для надёжности в нем придётся делать хэндлер завершения через register_shutdown_function().

2. Решение на стороне start_work.php с использованием ignore_user_abort

Скрипт на php может продолжить работу, отдав ответ во фронт. Файлы использовать не обязательно, ибо открытие/закрытие сессии по факту делает то же самое, но гибче, так как может быть настроено на работу с БД/памятью и т.д. Так что будем периодически смотреть статус и ждать, пока другой скрипт не укажет там, что пора остановиться.

start_work.php

<?
ignore_user_abort(1);
set_time_limit(600); //Всё-таки ограничим выполнение каким-то разумным временем
ob_start();
session_start();
$_SESSION['status']='working';
session_write_close();
//Вывод JSON фронту

$size = ob_get_length();
header("Connection: close",1);
header("Content-Length: $size");
ob_end_flush();
flush();
if(function_exists('fastcgi_finish_request')) fastcgi_finish_request();

//Длительный процесс уже без связи с фронтом с периодической проверкой сессии
do{
    session_start();
    //Проверяем свой статус
    $working= $_SESSION['status']=='working';
    //Кладём в сессию что-нибудь полезное
    $_SESSION['progress']=[];
    session_write_close();
    //Полезная долгая работа
}while($working)

session_start();
$_SESSION['status']='finished';
session_write_close();

Остановку осуществляем отдельным скриптом изменяя сессию.

stop_work.php

<?
session_start();
$_SESSION['status']='stop';
//о результате сообщаем фронту, например
echo json_encode(['ststus'=>'finished']);
session_write_close();

Проверяем статус отдельным скриптом

status_work.php

<?
session_start();
//Что-нибудь отвечаем фронту
echo json_encode($_SESSION['progress']);
session_write_close();

Плюсы - управление осуществляется на стороне сервера, фронт работает с быстро завершающимися fetch, не требующими ожидания.

Минусы - если фронт "забудет" о бэке, сервер будет заниматься никому не нужной работой.

3. Полноценный процесс, оторванный от веб-сервера.

Приводить код не буду, но суть состоит в том, чтобы написать отдельную программу, выполняющую работу в фоне и способную слушать внешние сигналы для старта, останова и отдавания статуса. Это могут быть linux-сокеты, файлы, БД, TCP/UDP. Это может быть и не php вовсе.

Задача же php-скриптов будет передавать команды в этот процесс и опрашивать статус, отдавая это всё фронту.

→ Ссылка