Безопасно ли делать fork из потока?
Описание проблемы:
Я разрабатываю приложение на Linux, которое создает дочерний процесс (fork) и запускает внешний бинарный файл (exec), дожидаясь его завершения. Результаты передаются через файлы совместного доступа (shm), которые уникальны для каждого порожденного процесса. Весь код находится в рамках одного класса.
Теперь я подумываю о том, чтобы использовать потоки для ускорения процесса. Планируется, что несколько разных экземпляров методов класса будут одновременно создавать дочерние процессы и выполнять бинарный файл с различными параметрами, а результаты будут передаваться через собственные уникальные файлы shm.
Вопрос в следующем: безопасно ли использовать fork в потоке? Если это безопасно, есть ли какие-либо нюансы, на которые мне следует обратить внимание при реализации? Буду признателен за любые советы или помощь!
5 ответ(ов)
Проблема заключается в том, что функция fork()
копирует только вызывающий поток, и любые мьютексы, удерживаемые дочерними потоками, навсегда останутся заблокированными в дочернем процессе. Решение для работы с потоками заключалось в использовании обработчиков pthread_atfork()
. Идея заключается в том, что можно зарегистрировать три обработчика: один для выполнения перед fork
, один для родительского процесса и один для дочернего. Когда происходит fork()
, сначала вызывается обработчик для выполнения перед fork
, который должен получить все мьютексы приложения. Оба процесса (родительский и дочерний) должны освободить все мьютексы соответственно.
Однако это не завершает историю! Библиотеки вызывают pthread_atfork
, чтобы зарегистрировать обработчики для своих специфичных мьютексов, например, это делает библиотека Libc. Это правильно, потому что приложение не может знать о мьютексах, удерживаемых сторонними библиотеками, и каждая библиотека должна вызывать pthread_atfork
, чтобы гарантировать, что её собственные мьютексы будут правильно освобождены в случае fork()
.
Проблема в том, что порядок вызова обработчиков pthread_atfork
для несвязанных библиотек не определён и зависит от порядка, в котором библиотеки загружаются программой. Это означает, что технически может произойти взаимная блокировка внутри обработчика before-fork из-за состояния гонки.
Рассмотрим следующий пример:
- Поток T1 вызывает
fork()
- Обработчики prefork библиотеки libc вызываются в T1 (например, T1 теперь удерживает все блокировки libc)
- Затем в потоке T2 сторонняя библиотека A захватывает свой собственный мьютекс AM и вызывает функцию libc, которая требует использования мьютекса. Это блокируется, потому что мьютексы libc удерживаются T1.
- Поток T1 выполняет обработчик prefork для библиотеки A, который блокируется, ожидая захвата AM, который удерживается T2.
Вот вам и взаимная блокировка, не связанная с вашими собственными мьютексами или кодом.
Это действительно произошло в одном из проектов, над которым я когда-то работал. Совет, который я тогда нашел, заключался в том, чтобы выбрать между fork
или потоками, но не использовать оба варианта одновременно. Однако для некоторых приложений это может быть нецелесообразно.
В общем, использование fork
в многопоточной программе безопасно, если вы очень осторожны с кодом между fork
и exec
. В этом промежутке вы должны использовать только реентерабельные (также известные как асинхронно-безопасные) системные вызовы. Теоретически вам нельзя использовать malloc
или free
в этот момент, хотя на практике стандартный аллокатор Linux безопасен, и многие библиотеки Linux на это полагаются. В итоге вы должны использовать стандартный аллокатор.
В начале времен мы называли потоки "легковесными процессами", потому что, хотя они ведут себя очень похоже на процессы, они не являются идентичными. Главное отличие заключается в том, что потоки, по определению, живут в одном пространстве адресов одного процесса. Это имеет свои преимущества: переключение между потоками происходит быстро, они по своей природе разделяют память, что ускоряет межпоточные коммуникации, а создание и уничтожение потоков также происходит быстро.
Отличие здесь заключается в "тяжелых процессах", которые представляют собой полные адресные пространства. Новый тяжелый процесс создается с помощью fork(2). Когда виртуальная память пришла в мир UNIX, это было дополнено такими вызовами, как vfork(2) и другими.
Вызов fork(2) копирует все адресное пространство процесса, включая все регистры, и передает этот процесс под контроль планировщика операционной системы; в следующий раз, когда планировщик вернется, счетчик команд продолжит с следующей инструкции — созданный дочерний процесс является клоном родительского. (Если вы хотите запустить другую программу, скажем, потому что вы пишете оболочку, то после fork последует вызов exec(2), который загрузит новое адресное пространство с новой программой, заменяя клонированное.)
В общем, ваш ответ заключен в этом объяснении: когда у вас есть процесс с несколькими потоками и вы вызываете fork, у вас получится два независимых процесса с множеством потоков, которые работают одновременно.
Этот трюк даже полезен: во многих программах есть родительский процесс, который может иметь много потоков, некоторые из которых создают новые дочерние процессы. (Например, HTTP-сервер может делать это: каждое соединение на порту 80 обрабатывается потоком, а затем может быть создан дочерний процесс для выполнения программы CGI; затем будет вызван exec(2) для запуска CGI-программы вместо закрытия родительского процесса.)
Если вы быстро вызываете exec()
или _exit()
в дочернем процессе, то на практике все будет в порядке.
Вы также можете рассмотреть возможность использования posix_spawn()
, который, вероятно, сделает всё правильно.
Мой опыт использования fork()
в многопоточном программировании довольно плохой. Как правило, программное обеспечение начинает давать сбои довольно быстро.
Я нашел несколько решений этой проблемы, хоть они могут вам и не понравиться. Тем не менее, я считаю, что это в общем плане лучшие способы избежать практически недебугебельных ошибок.
Сначала создайте процессы
Предполагая, что вы знаете количество внешних процессов, необходимых в начале, вы можете создать их заранее и просто позволить им ждать события (например, чтение из блокирующего канала, ожидание семафора и т.д.)
Как только вы создадите достаточное количество дочерних процессов, вы можете использовать потоки и общаться с этими процессами через ваши каналы, семафоры и т.д. С момента создания первого потока больше не следует вызывать
fork()
. Имейте в виду, что если вы используете сторонние библиотеки, которые могут создавать потоки, их нужно инициализировать после вызововfork()
.Обратите внимание, что вы сможете использовать потоки как в основном процессе, так и в
fork()
'нутом процессе.Знайте свое состояние
В некоторых случаях возможно остановить все ваши потоки, чтобы запустить процесс, а затем перезапустить ваши потоки. Это отчасти похоже на пункт (1) в том смысле, что вы не хотите, чтобы потоки работали в момент вызова
fork()
, хотя это требует от вас способа знать о всех потоках, которые в данный момент работают в вашем программном обеспечении (что не всегда возможно с библиотеками третьих сторон).Помните, что «остановка потока» с использованием ожидания не сработает. Вам нужно дождаться завершения потока с помощью
join
, так как ожидание требует мьютекса, а мьютексы должны быть разблокированы при вызовеfork()
. Вам просто не известно, когда ожидание разблокирует/заблокирует мьютекс, и именно здесь вы обычно сталкиваетесь с проблемами.Выберите одно из двух
Явная возможность - выбрать одно из двух и не заботиться о том, будете ли вы мешать друг другу. Это, безусловно, самый простой метод, если это вообще возможно в вашем ПО.
Создавайте потоки только по мере необходимости
В некоторых программах создается один или несколько потоков в функции, используются эти потоки, а затем выполняется
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()
. Если у вас очень небольшое количество потоков или рабочих потоков, определенных в одном месте, и вы можете легко их остановить, это будет довольно просто.
Что такое процесс и поток?
fork() и вывод данных
Как изменить цвет вывода echo в Linux
Сон на миллисекунды
Ошибка: версия `CXXABI_1.3.8` не найдена (требуется для ...)