0

Безопасно ли делать fork из потока?

9

Описание проблемы:

Я разрабатываю приложение на Linux, которое создает дочерний процесс (fork) и запускает внешний бинарный файл (exec), дожидаясь его завершения. Результаты передаются через файлы совместного доступа (shm), которые уникальны для каждого порожденного процесса. Весь код находится в рамках одного класса.

Теперь я подумываю о том, чтобы использовать потоки для ускорения процесса. Планируется, что несколько разных экземпляров методов класса будут одновременно создавать дочерние процессы и выполнять бинарный файл с различными параметрами, а результаты будут передаваться через собственные уникальные файлы shm.

Вопрос в следующем: безопасно ли использовать fork в потоке? Если это безопасно, есть ли какие-либо нюансы, на которые мне следует обратить внимание при реализации? Буду признателен за любые советы или помощь!

5 ответ(ов)

0

Проблема заключается в том, что функция fork() копирует только вызывающий поток, и любые мьютексы, удерживаемые дочерними потоками, навсегда останутся заблокированными в дочернем процессе. Решение для работы с потоками заключалось в использовании обработчиков pthread_atfork(). Идея заключается в том, что можно зарегистрировать три обработчика: один для выполнения перед fork, один для родительского процесса и один для дочернего. Когда происходит fork(), сначала вызывается обработчик для выполнения перед fork, который должен получить все мьютексы приложения. Оба процесса (родительский и дочерний) должны освободить все мьютексы соответственно.

Однако это не завершает историю! Библиотеки вызывают pthread_atfork, чтобы зарегистрировать обработчики для своих специфичных мьютексов, например, это делает библиотека Libc. Это правильно, потому что приложение не может знать о мьютексах, удерживаемых сторонними библиотеками, и каждая библиотека должна вызывать pthread_atfork, чтобы гарантировать, что её собственные мьютексы будут правильно освобождены в случае fork().

Проблема в том, что порядок вызова обработчиков pthread_atfork для несвязанных библиотек не определён и зависит от порядка, в котором библиотеки загружаются программой. Это означает, что технически может произойти взаимная блокировка внутри обработчика before-fork из-за состояния гонки.

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

  1. Поток T1 вызывает fork()
  2. Обработчики prefork библиотеки libc вызываются в T1 (например, T1 теперь удерживает все блокировки libc)
  3. Затем в потоке T2 сторонняя библиотека A захватывает свой собственный мьютекс AM и вызывает функцию libc, которая требует использования мьютекса. Это блокируется, потому что мьютексы libc удерживаются T1.
  4. Поток T1 выполняет обработчик prefork для библиотеки A, который блокируется, ожидая захвата AM, который удерживается T2.

Вот вам и взаимная блокировка, не связанная с вашими собственными мьютексами или кодом.

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

0

В общем, использование fork в многопоточной программе безопасно, если вы очень осторожны с кодом между fork и exec. В этом промежутке вы должны использовать только реентерабельные (также известные как асинхронно-безопасные) системные вызовы. Теоретически вам нельзя использовать malloc или free в этот момент, хотя на практике стандартный аллокатор Linux безопасен, и многие библиотеки Linux на это полагаются. В итоге вы должны использовать стандартный аллокатор.

0

В начале времен мы называли потоки "легковесными процессами", потому что, хотя они ведут себя очень похоже на процессы, они не являются идентичными. Главное отличие заключается в том, что потоки, по определению, живут в одном пространстве адресов одного процесса. Это имеет свои преимущества: переключение между потоками происходит быстро, они по своей природе разделяют память, что ускоряет межпоточные коммуникации, а создание и уничтожение потоков также происходит быстро.

Отличие здесь заключается в "тяжелых процессах", которые представляют собой полные адресные пространства. Новый тяжелый процесс создается с помощью fork(2). Когда виртуальная память пришла в мир UNIX, это было дополнено такими вызовами, как vfork(2) и другими.

Вызов fork(2) копирует все адресное пространство процесса, включая все регистры, и передает этот процесс под контроль планировщика операционной системы; в следующий раз, когда планировщик вернется, счетчик команд продолжит с следующей инструкции — созданный дочерний процесс является клоном родительского. (Если вы хотите запустить другую программу, скажем, потому что вы пишете оболочку, то после fork последует вызов exec(2), который загрузит новое адресное пространство с новой программой, заменяя клонированное.)

В общем, ваш ответ заключен в этом объяснении: когда у вас есть процесс с несколькими потоками и вы вызываете fork, у вас получится два независимых процесса с множеством потоков, которые работают одновременно.

Этот трюк даже полезен: во многих программах есть родительский процесс, который может иметь много потоков, некоторые из которых создают новые дочерние процессы. (Например, HTTP-сервер может делать это: каждое соединение на порту 80 обрабатывается потоком, а затем может быть создан дочерний процесс для выполнения программы CGI; затем будет вызван exec(2) для запуска CGI-программы вместо закрытия родительского процесса.)

0

Если вы быстро вызываете exec() или _exit() в дочернем процессе, то на практике все будет в порядке.

Вы также можете рассмотреть возможность использования posix_spawn(), который, вероятно, сделает всё правильно.

0

Мой опыт использования fork() в многопоточном программировании довольно плохой. Как правило, программное обеспечение начинает давать сбои довольно быстро.

Я нашел несколько решений этой проблемы, хоть они могут вам и не понравиться. Тем не менее, я считаю, что это в общем плане лучшие способы избежать практически недебугебельных ошибок.

  1. Сначала создайте процессы

    Предполагая, что вы знаете количество внешних процессов, необходимых в начале, вы можете создать их заранее и просто позволить им ждать события (например, чтение из блокирующего канала, ожидание семафора и т.д.)

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

    Обратите внимание, что вы сможете использовать потоки как в основном процессе, так и в fork()'нутом процессе.

  2. Знайте свое состояние

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

    Помните, что «остановка потока» с использованием ожидания не сработает. Вам нужно дождаться завершения потока с помощью join, так как ожидание требует мьютекса, а мьютексы должны быть разблокированы при вызове fork(). Вам просто не известно, когда ожидание разблокирует/заблокирует мьютекс, и именно здесь вы обычно сталкиваетесь с проблемами.

  3. Выберите одно из двух

    Явная возможность - выбрать одно из двух и не заботиться о том, будете ли вы мешать друг другу. Это, безусловно, самый простой метод, если это вообще возможно в вашем ПО.

  4. Создавайте потоки только по мере необходимости

    В некоторых программах создается один или несколько потоков в функции, используются эти потоки, а затем выполняется join для всех при выходе из функции. Это отчасти эквивалентно пункту (2) выше, только вы (микро-)управляете потоками по мере необходимости, а не создаете потоки, которые будут просто ждать выполнения. Это тоже сработает, но имейте в виду, что создание потока - это дорогостоящий вызов. Необходимо выделить новое задание со стеком и собственным набором регистров... это сложная функция. Тем не менее, это упрощает понимание, когда у вас работают потоки и за пределами этих функций вы свободны вызывать fork().

В моей практике я использовал все эти решения. Я применял пункт (2), так как использовал многопоточную версию log4cplus и мне нужно было использовать fork() для некоторых частей моего ПО.

Как упоминали другие, если вы используете fork() и затем вызываете execve(), то идея заключается в том, чтобы использовать как можно меньше между двумя вызовами. Это, скорее всего, будет работать 99.999% времени (многие люди также используют system() или popen() с довольно хорошими успехами, и они делают аналогичные вещи). Дело в том, что если вы не задеваете ни один из мьютексов, удерживаемых другими потоками, тогда это будет работать без проблем.

С другой стороны, если, как и я, вы хотите сделать fork() и никогда не вызывать execve(), то это, скорее всего, не сработает, пока какой-либо поток будет активен.


Что на самом деле происходит?

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

Каждый раз, когда вы создаете новый поток (pthread_create()), вы также создаете новую задачу, но в пределах одного процесса (т.е. новая задача делит пространство процесса: память, дескрипторы файлов, владение и т.д.). Однако fork() игнорирует эти дополнительные задачи при дублировании текущей выполняемой задачи.

+-----------------------------------------------+
|                                     Процесс A |
|                                               |
| +----------+    +----------+    +----------+  |
| | поток 1  |    | поток 2  |    | поток 3  |  |
| +----------+    +----+-----+    +----------+  |
|                      |                        |
+----------------------|------------------------+
                       | fork()
                       |
+----------------------|------------------------+
|                      v              Процесс B |
|               +----------+                    |
|               | поток 1  |                    |
|               +----------+                    |
|                                               |
+-----------------------------------------------+

Таким образом, в Процессе B мы теряем поток 1 и поток 3 из Процесса A. Это означает, что если у любого из этих потоков есть блокировка на мьютексах или что-то подобное, то Процесс B быстро зависнет. Заблокированные ресурсы - это самое худшее, но любые ресурсы, которые у любого потока есть на момент выполнения fork(), теряются (сокетное соединение, выделенные память, дескриптор устройства и т.д.). Именно здесь возникает необходимость в пункте (2) выше. Вам нужно знать свое состояние перед выполнением fork(). Если у вас очень небольшое количество потоков или рабочих потоков, определенных в одном месте, и вы можете легко их остановить, это будет довольно просто.

Чтобы ответить на вопрос, пожалуйста, войдите или зарегистрируйтесь