Запутанный кейс system(3)

[developer-notes] [posix] [glibc] [musl] [gnu] [bsd] [solaris]

Читая документацию на system(3), обратил внимание, что функция указана как MT-Safe.

В тоже время, функция манипулирует signal disposition, а обработчики сигналов — общие для всего процесса. Так что её потокобезопасность под вопросом.

Здравый смысл подсказывает, что если параллельно system() в другом потоке работает другой вызов system() или любой пользовательский код, изменяющий или читающий обработчики сигналов SIGINT и SIGQUIT, то будет состояние гонки и спецэффекты.

Как написано в SUS, функция не обязана быть потокбезоасной, и с реализацией потокобезопасности в ней всё сложно — не только по причинам, которые я описал.

В этом месте всё окончательно запуталось, и я пошел смотреть, что пишет документация других ОС. Во FreeBSD ман-страница очень краткая и просто отсылает к POSIX. То же самое в остальных BSD.

В Solaris 10 находим больше информации. Функция помечена как MT-Unsafe. И добавлено следующее прмиечание:

   The system() function  manipulates  the	signal	handlers  for  SIGINT,
   SIGQUIT,	 and  SIGCHLD.	It is therefore	not safe to call system() in a
   multithreaded process, since some other thread that  manipulates	 these
   signal  handlers	 and a thread that concurrently	calls system() can in-
   terfere with each other in a destructive	manner.	 If, however, no  such
   other thread is active, system()	can safely be called concurrently from
   multiple	threads. See popen(3C) for an alternative to system() that  is
   thread-safe.

Итак, в случае, если другой поток манипулирует обработчиками сигналов, то будет состояние гонки. Но если несколько потоков выполняют вызов system() конкурентно, этот случай является безопасным.

Если подумать как это может быть реализовано, приходит на ум способ с использованием атомарного счётчика, считающего конкурентные вызовы system(). При изменении счётчика с 0 на 1 (нет других конкурентных вызовов, мы первые) сохраняем старое состояние сигналов в глобальной переменной и устанавливаем новое. При изменении счётчика с 1 на 0 (больше не осталось других конкурентных вызовов, мы последние) восстанавливаем сохранённое состояние сигналов. При этом поток, который сохранил состояние, не обязан быть тем же самым, что будет состояние восстанавливать.

А теперь посмотрим исходники:

Ну и самое интересное… musl!

Функция в musl является потоконебезопасной. Реализована практически так же, как в BSD. Это не было бы большим грехом, но в glibc функция заявлена как безопасная, а на странице Functional differences from glibc упоминания о system() отсутствуют. Таким образом при отсутствии формального бага в коде имеется баг в документации.

Так что я зарепортил баг относительно лакуны в документации musl:

Относительно потокобезопасности и проблем с документацией вокруг неё вроде бы всё, но история была бы неполной, если бы я не упомянул об еще одном различии.

Вызов system(NULL) может использоваться для того, чтобы определить, доступен ли интерпретатор команд в системе вообще. И здесь спецификация SUS делает интересный финт. Следите за руками:

Что за противоречивые параграфы?

В данном случае дело в том, что первое предложение цитирует стандарт Си, который охватывает весь спектр платформ, где может быть запущен код на Си, и не требует от системы быть POSIX-compliant.

А второе предложение — уже собственное требование SUS о том, что POSIX-совместимая реализация должна возвращать ненулевое значение. Поскольку система, в которой нет sh-совместимого интерпретатора команд, не является POSIX-совместимой.

Таким образом POSIX-совместимая реализация libc не обязана вообще проверять наличие интерпретатора, а может всегда возвращать 1.

Следующие реализации так и сделали:

Но здесь следует еще один финт. Вопрос такой. Любая ли ран-тайм конфигурация Unix-подобной системы является POSIX-совместимой? И если в системе есть поддержка chroot() или любого типа контейнеризации, то ответ — нет.

Если мы делаем chroot в отдельный каталог, чтобы ограничить потенциальное воздействие процесса на систему, то там может не быть sh. Там может вообще ничего не быть. И процесс в данном случае работает в окружении, которое не является POSIX-compliant. Поэтому более корректно — всё же проверять наличие интерпретатора.

Следующие реализации действительно проверяют, что sh имеется:

В том, как именно вызывается sh, у разных реализаций также есть различия. Но здесь я остановлюсь, чтобы не превращать эту заметку в трактат.

2021.11.22