Странное поведение системного вызова fork Linux x86-64 по отношению к стандартному вводу-выводу libc FILE C (ключевые слова: fork, fclose, linux)

История

Я попытался диагностировать ошибку в приложении, написанном на C для Linux. Оказалось, что ошибка была вызвана забыванием fclose в дочернем процессе, когда дескриптор FILE * все еще открыт в родительском процессе.

Файловая операция только read. Нет операции записи.

Дело 1

Приложение работает на Linux 5.4.0-58-generic. В данном случае произошла ошибка.

Случай 2

Приложение работает на Linux 5.10.0-051000-generic. В данном случае бага нет, чего я и ожидал.

В чем ошибка?

Родительский процесс выполняет случайное число системных вызовов fork, если в дочернем процессе нет fclose.

Утверждение случая 2

Я прекрасно понимаю, что забывание fclose приведет к утечке памяти, но:

  • Я думаю, просто в этом случае это не является строго необходимым, потому что дочерний процесс собирается завершиться как можно скорее, а выход, который я использую, exit(3) не _exit(2).
  • Странно то, как забывание fclose в дочернем процессе влияет на родительский процесс?

Мое текущее предположение:

Это ошибка ядра Linux, исправленная в версии после 5.4. Пока у меня нет доказательств, но мой тест показал мне это.


Вопрос

Я смог исправить эту ошибку приложения, вызвав fclose в дочернем процессе перед его выходом. Но я хочу знать, что на самом деле происходит в этом случае. Итак, мой вопрос: почему забывание fclose в дочернем процессе влияет на родительский процесс?


Очень простой код для воспроизведения проблемы (прикреплены 3 файла).

Примечание. Разница между test1.c и test2.c только в fclose в дочернем процессе. test2.c не вызывает fclose в дочернем процессе.

Файл test.txt

123123123
123123123
123123123
123123123
123123123
123123123

Файл test1.c

#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#include <stdint.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
#define TICK do { putchar('.'); fflush(stdout); } while(0)
int main() {
  char buff[1024] = {0};
  FILE *handle = fopen("test.txt", "r");

  uint32_t num_of_forks = 0;

  while (fgets(buff, 1024, handle) != NULL) {

    TICK;
    num_of_forks++;

    pid_t pid = fork();
    if (pid == -1) {
      printf("Fork error: %s\n", strerror(errno));
      continue;
    }

    if (pid == 0) {
      fclose(handle);
      exit(0);
    }
  }

  fclose(handle);
  putchar('\n');
  printf("Number of forks: %d\n", num_of_forks);
  wait(NULL);
}

Файл test2.c

#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#include <stdint.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
#define TICK do { putchar('.'); fflush(stdout); } while(0)
int main() {
  char buff[1024] = {0};
  FILE *handle = fopen("test.txt", "r");

  uint32_t num_of_forks = 0;

  while (fgets(buff, 1024, handle) != NULL) {

    TICK;
    num_of_forks++;

    pid_t pid = fork();
    if (pid == -1) {
      printf("Fork error: %s\n", strerror(errno));
      continue;
    }

    if (pid == 0) {
      // fclose(handle);
      exit(0);
    }
  }

  fclose(handle);
  putchar('\n');
  printf("Number of forks: %d\n", num_of_forks);
  wait(NULL);
}


Запустить программу


Запуск на Linux 5.4.0-58-generic (где возникает ошибка)

Посмотрите на выполнение test2 (баг), это приводит к случайному количеству системных вызовов fork.

ammarfaizi2@integral:/tmp$ uname -r
5.4.0-58-generic
ammarfaizi2@integral:/tmp$ gcc --version
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

ammarfaizi2@integral:/tmp$ ldd --version
ldd (Ubuntu GLIBC 2.31-0ubuntu9.1) 2.31
Copyright (C) 2020 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.
ammarfaizi2@integral:/tmp$ cat test.txt
123123123
123123123
123123123
123123123
123123123
123123123
ammarfaizi2@integral:/tmp$ diff test1.c test2.c
27c27
<       fclose(handle);
---
>       // fclose(handle);
ammarfaizi2@integral:/tmp$ gcc test1.c -o test1 && gcc test2.c -o test2
ammarfaizi2@integral:/tmp$ ./test1
......
Number of forks: 6
ammarfaizi2@integral:/tmp$ ./test1
......
Number of forks: 6
ammarfaizi2@integral:/tmp$ ./test1
......
Number of forks: 6
ammarfaizi2@integral:/tmp$ ./test2
..................................................................................................................................................................................
Number of forks: 178
ammarfaizi2@integral:/tmp$ ./test2
............................................................................................................................................................................................................................................................................................................................................................
Number of forks: 348
ammarfaizi2@integral:/tmp$ ./test2
...........................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
Number of forks: 475
ammarfaizi2@integral:/tmp$ md5sum test1 test2
c32d03916b9b72546b966223837fd115  test1
f314d2135092362288a66f53b37ffa4d  test2

Запуск на Linux 5.10.0-051000-generic (тот же код, без ошибок)

root@esteh:/tmp# uname -r
5.10.0-051000-generic
root@esteh:/tmp# gcc --version
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

root@esteh:/tmp# ldd --version
ldd (Ubuntu GLIBC 2.31-0ubuntu9.1) 2.31
Copyright (C) 2020 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.
root@esteh:/tmp# cat test.txt
123123123
123123123
123123123
123123123
123123123
123123123
root@esteh:/tmp# diff test1.c test2.c
27c27
<       fclose(handle);
---
>       // fclose(handle);
root@esteh:/tmp# gcc test1.c -o test1 && gcc test2.c -o test2
root@esteh:/tmp# ./test1
......
Number of forks: 6
root@esteh:/tmp# ./test1
......
Number of forks: 6
root@esteh:/tmp# ./test1
......
Number of forks: 6
root@esteh:/tmp# ./test2
......
Number of forks: 6
root@esteh:/tmp# ./test2
......
Number of forks: 6
root@esteh:/tmp# ./test2
......
Number of forks: 6
root@esteh:/tmp# md5sum test1 test2 # Make sure the files are identical with case 1
c32d03916b9b72546b966223837fd115  test1
f314d2135092362288a66f53b37ffa4d  test2

Краткое содержание

  • Если забыть fclose в дочернем процессе на Linux 5.4.0-58-generic, системный вызов fork в родительском процессе будет странным.
  • Ошибка, похоже, не существует на Linux 5.10.0-051000-generic.

Дескриптор файла в дочернем процессе не должен зависеть от дескриптора файла в родительском процессе. Это неправильно. В конце концов, FD в разветвлениях — это разные дескрипторы одного и того же FD, точно так же, как то, что вы получили бы с dup(2) или dup2(2). Независимым является только закрытие (и дублирование), и когда вы читаете/записываете/lseek(2) из одного FD, это также влияет на другой FD.

iBug 26.12.2020 14:23

@iBug спасибо за исправление. Я хочу кое-что прояснить, чтобы убедиться, что я понимаю ваш комментарий на более высоком уровне (вид из utils в man 3 xxxx). -- Итак, если я открываю файл с помощью fopen(3), затем вызываю fork(2), затем изменяю смещение file handle which is created by the parent process с помощью fseek(3) от дочернего процесса, тогда смещение дескриптора файла в родительском будет меняться в соответствии с вызовом fseek(3), который я делаю в дочернем процессе. . Это правильно?

Ammar Faizi 26.12.2020 14:38

Да, точно. Но имейте в виду, что планирование процесса может сделать результат непредсказуемым, если вы не реализуете какую-то «синхронизацию».

iBug 26.12.2020 14:39

Обеспечьте версию времени выполнения glibc в каждой системе.

Hadi Brais 27.12.2020 04:14

@HadiBrais хорошо, я только что предоставил версию glibc для каждой системы (пост тоже был отредактирован). Обе версии идентичны ldd (Ubuntu GLIBC 2.31-0ubuntu9.1) 2.31. Я проверил это командой ldd --version.

Ammar Faizi 27.12.2020 05:03

Я думаю, что ваша проблема может быть тесно связана с проблемой, проанализированной в Почему разветвление моего процесса приводит к бесконечному чтению файла?

Jonathan Leffler 27.12.2020 05:10

Благодаря @JonathanLeffler я смог обнаружить реальную проблему из вашего ответа в этой теме.

Ammar Faizi 27.12.2020 08:58

Спасибо @iBug за понимание планирования.

Ammar Faizi 27.12.2020 08:58

Я подозревал, что у вас другая версия glibc в системе Linux 5.10, в которой код очистки ввода-вывода отличается. Поэтому и спросил версии. Но теперь мы знаем, что «странное» поведение происходит в обеих системах, так что это уже не имеет значения. Кстати, ldd --version дает вам версию компоновщика, а не версию времени выполнения glibc, которую можно получить, вызвав gnu_get_libc_version().

Hadi Brais 28.12.2020 00:21
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
6
9
274
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Спасибо @Jonathan Leffler!

Эта проблема является дубликатом Почему разветвление моего процесса приводит к бесконечному чтению файла

Недостающее знание, почему ошибка не возникает на Linux 5.10.0-051000-generic оказалось, что она не связана с ядром.


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

  • Примечание: изменение смещения дескриптора файла от дочернего процесса также изменит смещение в родительском процессе, если дескриптор создан родителем.
  • Если в дочерних элементах нет fclose(3), дочерние процессы будут вызывать lseek(2), как только они вызовут exit(3). Это приведет к тому, что родитель повторно прочитает одно и то же смещение, потому что дочерние элементы вызывают lseek(2) с отрицательным смещением + SEEK_CUR.

(Я не знаю, почему необходимо вызывать lseek(2) перед выходом, возможно, это было объяснено в ответе @Jonathan Leffler, я не читал внимательно весь ответ).

  • Если родитель закончит читать весь файл до того, как дочерний вызов вызовет lseek(2). Тогда вообще нет проблем.

Кроме того, как уже упоминал @iBug. Но имейте в виду, что планирование процесса может сделать результат непредсказуемым, если вы не реализуете какую-то «синхронизацию».

Родительский процесс на машине Linux 5.10.0-051000-generic, которую я использовал, был просто удачным процессом, который всегда выигрывал, чтобы сначала прочитать весь файл, прежде чем дочерний вызов lseek(2).

Я попытался добавить больше строк в файл (до 150 строк), поэтому родитель в основном будет медленнее, чем чтение 6 строк, и произойдет неопределенное поведение.

Результат теста: https://gist.githubusercontent.com/ammarfaizi2/b72bd03fcc13779f96b8bbeef9253e66/raw/da1eff4ed5434aa51929e5c810d54de8ffe15548/test2_fix.txt

Почему происходит lseek: это неудачная часть fflush, которая нужна в общем случае fflush, но, вероятно, не при выходе. (Если только придерживаться буквы некоторых спецификаций не требует, например, если exit(3) указывает fflush(3) по имени.) И да, я нашел это внизу отличного ответа Джонатана. Обходной путь заключается в fflush(stdin) перед разветвлением, поэтому выход обнаружит, что буфер stdin пуст (и, таким образом, позиция fd Unix синхронизируется с позицией чтения stdio, поэтому lseek не требуется).

Peter Cordes 27.12.2020 09:08

Другие вопросы по теме