Как правильно обрабатывать fork() с boost:: asio в многопоточной программе?

У меня возникают проблемы с тем, как правильно обрабатывать создание дочернего процесса из многопоточной программы, которая использует Boost Asio многопоточным способом.

Если я правильно понимаю, способ запуска дочернего процесса в мире Unix - вызвать fork(), а затем exec*(). Кроме того, если я правильно понимаю, вызов fork() будет дублировать все дескрипторы файлов и т.д., И они должны быть закрыты в дочернем процессе, если они не помечены как FD_CLOEXEC (и тем самым атомарно закрываются при вызове exec*()).

Boost Asio требует уведомления, когда fork() вызывается для правильной работы, вызывая notify_fork(). Однако в многопоточной программе это создает несколько проблем:

  • Сокеты по умолчанию унаследованы дочерними процессами, если я правильно понимаю. Они могут быть установлены в SOCK_CLOEXEC, но не непосредственно при создании *, что приводит к временному окну, если дочерний процесс создается из другого потока.

  • notify_fork() требует, чтобы ни один другой поток не вызывал какую-либо другую функцию io_service, ни любую функцию на любом другом объекте ввода-вывода, связанного с io_service. Это на самом деле не представляется возможным - ведь вся программа многопоточна по какой-то причине.

  • Если я правильно понимаю, любой вызов функции между fork() и exec*() должен быть безопасным для асинхронного сигнала (см. fork() documentation), Документация о том, что вызов notify_fork() является безопасным для асинхронного сигнала, отсутствует. На самом деле, если я смотрю на исходный код Boost Asio (по крайней мере, в версии 1.54), могут быть вызовы pthread_mutex_lock, которые не являются безопасными для асинхронного сигнала если я правильно понимаю (см. Концепции сигналов, есть и другие вызовы, которые не входят в белый список).

Проблема №1 Возможно, я обойдусь, отделив создание дочерних процессов и сокетов + файлов, чтобы я мог гарантировать, что в окне между создаваемым сокетом и настройкой SOCK_CLOEXEC не создается дочерний процесс. Проблема №2 сложнее, мне, вероятно, нужно будет убедиться, что все потоки обработчика asio остановлены, разворачивают вилку, а затем снова воссоздают их, что в лучшем случае является отличным, и действительно очень плохо в худшем случае (как насчет моих ожидающих таймеров?). Проблема № 3, похоже, делает это совершенно невозможным для правильного использования.

Как правильно использовать Boost Asio в многопоточной программе вместе с fork() + exec*()? ... или я "раздвоен"?

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

Изменить: * - На самом деле можно создать сокеты с SOCK_CLOEXEC, установленными непосредственно в Linux, доступными с версии 2.6.27 (см. socket() documentation). В Windows соответствующий флаг WSA_FLAG_NO_HANDLE_INHERIT доступен с Windows 7 SP 1/Windows Server 2008 R2 SP 1 (см. WSASocket() документация). OS X, похоже, не поддерживает это.

Ответ 1

В многопоточной программе io_service::notify_fork() небезопасно ссылаться на ребенка. Тем не менее, Boost.Asio ожидает, что он будет вызван на основе fork() support, так как это когда дочерний элемент закрывает родительские предыдущие внутренние файловые дескрипторы и создает новые. Хотя Boost.Asio явно перечисляет предварительные условия для вызова io_service::notify_fork(), гарантируя состояние его внутренних компонентов во время fork(), краткий взгляд на реализацию указывает, что std::vector::push_back() может выделять память из свободного хранилища, а распределение не гарантируется как безопасное для асинхронного сигнала.

С учетом сказанного, одним из решений, которое стоит рассмотреть, является fork() процесс, когда он все еще однопоточный. Детский процесс будет оставаться однопоточным и выполнять fork() и exec(), когда это будет сказано родительским процессом посредством межпроцессного взаимодействия. Это разделение упрощает проблему, устраняя необходимость управления состоянием нескольких потоков при выполнении fork() и exec().


Вот полный пример, демонстрирующий этот подход, когда многопоточный сервер будет получать имена файлов через UDP, а дочерний процесс будет выполнять fork() и exec() для запуска /usr/bin/touch в имени файла. В надежде сделать этот пример немного читабельнее, я решил использовать stackful coroutines.

#include <unistd.h> // execl, fork
#include <iostream>
#include <string>
#include <boost/bind.hpp>
#include <boost/asio.hpp>
#include <boost/asio/spawn.hpp>
#include <boost/make_shared.hpp>
#include <boost/shared_ptr.hpp>
#include <boost/thread.hpp>

/// @brief launcher receives a command from inter-process communication,
///        and will then fork, allowing the child process to return to
///        the caller.
class launcher
{
public:
  launcher(boost::asio::io_service& io_service,
           boost::asio::local::datagram_protocol::socket& socket,
           std::string& command)
    : io_service_(io_service),
      socket_(socket),
      command_(command)
  {}

  void operator()(boost::asio::yield_context yield)
  {
    std::vector<char> buffer;
    while (command_.empty())
    {
      // Wait for server to write data.
      std::cout << "launcher is waiting for data" << std::endl;
      socket_.async_receive(boost::asio::null_buffers(), yield);

      // Resize buffer and read all data.
      buffer.resize(socket_.available());
      socket_.receive(boost::asio::buffer(buffer));

      io_service_.notify_fork(boost::asio::io_service::fork_prepare);
      if (fork() == 0) // child
      {
        io_service_.notify_fork(boost::asio::io_service::fork_child);
        command_.assign(buffer.begin(), buffer.end());
      }
      else // parent
      {
        io_service_.notify_fork(boost::asio::io_service::fork_parent);
      }
    }
  }

private:
  boost::asio::io_service& io_service_;
  boost::asio::local::datagram_protocol::socket& socket_;
  std::string& command_;
};

using boost::asio::ip::udp;

/// @brief server reads filenames from UDP and then uses
///        inter-process communication to delegate forking and exec
///        to the child launcher process.
class server
{
public:
  server(boost::asio::io_service& io_service,
         boost::asio::local::datagram_protocol::socket& socket,
          short port)
    : io_service_(io_service),
      launcher_socket_(socket),
      socket_(boost::make_shared<udp::socket>(
        boost::ref(io_service), udp::endpoint(udp::v4(), port)))
  {}

  void operator()(boost::asio::yield_context yield)
  {
    udp::endpoint sender_endpoint;
    std::vector<char> buffer;
    for (;;)
    {
      std::cout << "server is waiting for data" << std::endl;
      // Wait for data to become available.
      socket_->async_receive_from(boost::asio::null_buffers(),
          sender_endpoint, yield);

      // Resize buffer and read all data.
      buffer.resize(socket_->available());
      socket_->receive_from(boost::asio::buffer(buffer), sender_endpoint);
      std::cout << "server got data: ";
      std::cout.write(&buffer[0], buffer.size());
      std::cout << std::endl;

      // Write filename to launcher.
      launcher_socket_.async_send(boost::asio::buffer(buffer), yield);
    }
  }

private:
  boost::asio::io_service& io_service_;
  boost::asio::local::datagram_protocol::socket& launcher_socket_;

  // To be used as a coroutine, server must be copyable, so make socket_
  // copyable.
  boost::shared_ptr<udp::socket> socket_;
};

int main(int argc, char* argv[])
{
  std::string filename;

  // Try/catch provides exception handling, but also allows for the lifetime
  // of the io_service and its IO objects to be controlled.
  try
  {
    if (argc != 2)
    {
      std::cerr << "Usage: <port>\n";
      return 1;
    }

    boost::thread_group threads;
    boost::asio::io_service io_service;

    // Create two connected sockets for inter-process communication.
    boost::asio::local::datagram_protocol::socket parent_socket(io_service);
    boost::asio::local::datagram_protocol::socket child_socket(io_service);
    boost::asio::local::connect_pair(parent_socket, child_socket);

    io_service.notify_fork(boost::asio::io_service::fork_prepare);
    if (fork() == 0) // child
    {
      io_service.notify_fork(boost::asio::io_service::fork_child);
      parent_socket.close();
      boost::asio::spawn(io_service,
          launcher(io_service, child_socket, filename));
    }
    else // parent
    {
      io_service.notify_fork(boost::asio::io_service::fork_parent);
      child_socket.close();
      boost::asio::spawn(io_service, 
          server(io_service, parent_socket, std::atoi(argv[1])));

      // Spawn additional threads.
      for (std::size_t i = 0; i < 3; ++i)
      {
        threads.create_thread(
          boost::bind(&boost::asio::io_service::run, &io_service));
      }
    }

    io_service.run();
    threads.join_all();
  }
  catch (std::exception& e)
  {
    std::cerr << "Exception: " << e.what() << "\n";
  }

  // Now that the io_service and IO objects have been destroyed, all internal
  // Boost.Asio file descriptors have been closed, so the execl should be
  // in a clean state.  If the filename has been set, then exec touch.
  if (!filename.empty())
  {
    std::cout << "creating file: " << filename << std::endl;
    execl("/usr/bin/touch", "touch", filename.c_str(), static_cast<char*>(0));
  }
}

Терминал 1:

$ ls
a.out  example.cpp
$ ./a.out 12345
server is waiting for data
launcher is waiting for data
server got data: a
server is waiting for data
launcher is waiting for data
creating file: a
server got data: b
server is waiting for data
launcher is waiting for data
creating file: b
server got data: c
server is waiting for data
launcher is waiting for data
creating file: c
ctrl + c
$ ls
a  a.out  b  c  example.cpp

Терминал 2:

$ nc -u 127.0.0.1 12345
actrl + dbctrl + dcctrl + d

Ответ 2

Рассмотрим следующее:

  • fork() создает только один поток в дочернем процессе. Вам нужно будет воссоздать другие потоки.
  • Мьютексы, удерживаемые другими потоками в родительском процессе, остаются заблокированными навсегда в дочернем процессе, потому что принадлежащие им потоки не выживают fork(). Обратные вызовы, зарегистрированные с помощью pthread_atfork(), могут освобождать мьютексы, но большинство библиотек никогда не беспокоятся об использовании pthread_atfork(). Другими словами, дочерний процесс может зависать вечно при вызове malloc() или new, потому что стандартный распределитель кучи использует мьютексы.

В свете вышеизложенного единственной надежной опцией в многопоточном процессе является вызов fork(), а затем exec().

Обратите внимание, что на ваш родительский процесс не влияет fork(), пока обработчики pthread_atfork() не используются.


Что касается форкирования и boost::asio, то функция io_service::notify_fork() должна быть вызвана перед разветвлением в родительском объекте и после разветвления в обоих родитель и ребенок. В конечном итоге это зависит от используемого реактора. Для реакторов Linux/UNIX select_reactor, epoll_reactor, dev_poll_reactor, kqueue_reactor эта функция ничего не делает для родителя до после fork, но у ребенка она воссоздает состояние реактора и повторно регистрирует дескрипторы файла, Однако я не уверен, что он делает в Windows.

Пример его использования можно найти в process_per_connection.cpp, вы можете просто скопировать его:

void handle_accept(const boost::system::error_code& ec)
{
  if (!ec)
  {
    // Inform the io_service that we are about to fork. The io_service cleans
    // up any internal resources, such as threads, that may interfere with
    // forking.
    io_service_.notify_fork(boost::asio::io_service::fork_prepare);

    if (fork() == 0)
    {
      // Inform the io_service that the fork is finished and that this is the
      // child process. The io_service uses this opportunity to create any
      // internal file descriptors that must be private to the new process.
      io_service_.notify_fork(boost::asio::io_service::fork_child);

      // The child won't be accepting new connections, so we can close the
      // acceptor. It remains open in the parent.
      acceptor_.close();

      // The child process is not interested in processing the SIGCHLD signal.
      signal_.cancel();

      start_read();
    }
    else
    {
      // Inform the io_service that the fork is finished (or failed) and that
      // this is the parent process. The io_service uses this opportunity to
      // recreate any internal resources that were cleaned up during
      // preparation for the fork.
      io_service_.notify_fork(boost::asio::io_service::fork_parent);

      socket_.close();
      start_accept();
    }
  }
  else
  {
    std::cerr << "Accept error: " << ec.message() << std::endl;
    start_accept();
  }
}