Вопрос или проблема
Я написал следующий код для IPC с использованием pipe()
:
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
int main(void) {
char message_buffer[15] = "Hello World \n";
char read_buffer[15];
int fd[2];
int return_value = pipe(fd);
if (return_value < 0) {
printf("Ошибка при создании канала");
}
int rc = fork();
if (rc < 0) {
printf("Ошибка при создании дочернего процесса");
}
if (rc > 0) {
close(fd[0]);
write(fd[1], message_buffer, 15);
close(fd[1]);
wait(NULL);
} else {
close(fd[1]);
read(fd[0], read_buffer, 15);
close(fd[0]);
printf("Сообщение: %s", read_buffer);
}
return 0;
}
Я только начинаю разбираться в каналах, и у меня есть следующие вопросы:
- Я не понимаю, почему родительский процесс должен закрывать сторону чтения перед записью и закрывать сторону записи после записи?
- То же самое касается дочернего процесса, почему ему нужно закрывать сторону записи перед чтением, а затем закрывать сторону чтения после чтения?
- Так как родитель и дочерний процессы работают параллельно, что произойдет, если дочерний процесс будет читать, пока родитель записывает сообщение?
- Так как родитель и дочерний процессы работают параллельно, что произойдет, если дочерний процесс прочитает, а родитель еще не записал ничего в канал?
Мои вопросы кажутся глупыми, но пожалуйста, помогите ответить на них, так как я изучаю каналы для экзамена по курсу.
Ответ на вопросы 1 и 2 находится в pipe
странице man (раздел “Примеры”):
После вызова fork каждый процесс закрывает дескрипторы файлов, которые ему не нужны для канала (см. pipe(7)).
Так как канал однонаправленный, он имеет определенные концы – конец чтения и конец записи. Если этот канал будет использоваться родителем для записи данных в дочерний процесс, родителю нет смысла держать конец чтения открытым. В свою очередь, если дочерний процесс собирается читать данные из канала, ему не нужно оставлять конец записи открытым.
Но более важная причина для закрытия ненужных концов канала была описана пользователем @ilkkachu. Пожалуйста, ознакомьтесь со ссылкой.
Правка:
Вы также спрашивали, почему родитель должен закрыть сторону записи после записи и почему дочернему процессу нужно закрыть конец чтения после чтения.
Им не обязательно это делать. Если два процесса собираются продолжать работать и обмениваться данными с использованием канала, они должны держать его открытым. В коротком примере программы, который просто демонстрирует использование канала и завершается после передачи одного сообщения, родитель и дочерний процессы закрывают дескрипторы файлов канала, возможно, ради того, чтобы правильно освободить ресурсы перед завершением программы.
Ответы на вопросы #3 и #4 находятся на странице pipe(7)
man.
Ваш вопрос #3:
Так как родитель и дочерний процессы могут работать параллельно, что произойдет, если дочерний процесс прочитает, пока родитель записывает сообщение ??
Дочерний процесс сможет прочитать любые данные, которые уже были записаны родителем в канал. Согласно странице man:
POSIX.1 утверждает, что записи менее чем на PIPE_BUF байтов должны быть атомарными: выходные данные записываются в канал как непрерывная последовательность. Записи больше чем на PIPE_BUF байтов могут быть неатомарными: ядро может чередовать данные с данными, записанными другими процессами. POSIX.1 требует, чтобы PIPE_BUF был не менее 512 байтов. (На Linux PIPE_BUF составляет 4096 байтов.)
Ваш вопрос #4:
Так как родитель и дочерний процессы могут работать параллельно, что произойдет, если дочерний процесс прочитает, а родитель еще не записал ничего в канал ??
На странице man сказано:
Если процесс пытается читать из пустого канала, то read(2) будет блокироваться до тех пор, пока данные не станут доступными. Если процесс пытается записать в полный канал (см. ниже), то write(2) блокирует выполнение до тех пор, пока из канала не будет прочитано достаточно данных, чтобы завершить запись.
Ответы на вопросы из комментариев:
Для вопросов 1 и 2, это означает, что если я не закрою ненужные концы, это не повлияет на программу никаким образом?
Это не должно препятствовать работе канала, но это создаст некоторый “футпинт” на ресурсах, используемых программой. Закрывая ненужные концы канала, эти ресурсы не удерживаются.
Для вопроса 3, это означает, что дочерний процесс будет читать то, что записывается родителем, как дочерний процесс будет знать, что родитель закончил то, что ему нужно было записать?
На странице man сказано:
Коммуникационный канал, предоставленный каналом, представляет собой поток байтов: концепция границ сообщений отсутствует.
Это означает, что канал не заботится о данных, которые вы передаете. Он не знает, что значит “сообщение” или закончил ли родитель запись или хочет записать больше данных.
Вам нужно будет реализовать собственную технику для определения, что такое “полное сообщение”. Например, родитель может обозначить дочернему процессу, что полное сообщение было записано, отправив специальный символ, например \0
или что-то другое, что имеет смысл в данном контексте, где используется канал.
Смотрите pipe(7)
страницу man.
В разделе “I/O на каналах и FIFO” говорится:
Если все дескрипторы файлов, ссылающиеся на конец записи канала, были закрыты, то попытка выполнить read(2) из канала увидит конец файла (read(2) вернет 0).
Если все дескрипторы файлов, ссылающиеся на конец чтения канала, были закрыты, то write(2) вызовет сигнал SIGPIPE для вызывающего процесса.
Закрытие дочерним процессом своего копии конца записи (который он не собирается использовать) позволяет дочернему процессу обнаружить, когда родитель это сделает. Если дочерний процесс оставит конец записи открытым, он никогда не увидит EOF на канале, так как он будет в основном ждать себя. (2)
Аналогично, закрытие родителем своего копию конца чтения также позволяет родителю обнаружить, если дочерний процесс исчезнет. (1)
Обратите внимание, что код, который у вас есть, никогда не проверяет возвращаемые значения read()
и write()
или не пытается читать/писать переменное количество данных, так что это в основном несущественно, кроме того, что родитель получает сигнал SIGPIPE.
Закрытие конца записи в родителе после записи и закрытие конца чтения после чтения в дочернем процессе является просто обычным делом по уборке. Если процессы все равно выходят сразу после этого, явное закрытие ничего не изменит.
Я не уверен, схожи ли ваши вопросы 3 и 4, но если читатель читает, когда нечего читать, системный вызов будет блокироваться:
Если процесс пытается читать из пустого канала, то read(2) будет блокироваться до тех пор, пока данные не станут доступными.
Если писатель записывает, когда читатель занят чем-то другим, данные будут скопированы в буфер в ОС, по крайней мере, если есть достаточно места. Если его нет, писатель будет блокироваться:
Если процесс пытается записать в полный канал (см. ниже), то write(2) блокируется до тех пор, пока из канала не будет прочитано достаточно данных, чтобы завершить запись.
Этот “ниже” – это раздел о “Вместимости канала”.
Если они будут делать это одновременно, ОС просто скопирует данные.
Другие предоставили хорошие ответы. Это расширение комментария ilkkachu к его ответу выше и предназначено для того, чтобы помочь вам экспериментально. Рассмотрите следующую программу, которая является слегка модифицированной версией того, что вы опубликовали изначально:
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
struct message {
char content[15];
};
int main(void) {
int fds[2];
if (pipe(fds) < 0) {
printf("Ошибка при создании канала\n");
return 1;
}
srand(time(NULL));
const pid_t pid = fork();
if (pid < 0) {
printf("Ошибка при создании дочернего процесса");
return 1;
}
if (pid > 0) {
const int message_count = (rand() % 9) + 1;
const struct message message_buffer = {
.content = "Hello, World\n",
};
/* 1 */ close(fds[0]);
for (int i = 0; i < message_count; ++i) {
write(fds[1], &message_buffer, sizeof(message_buffer));
}
/* 2 */ close(fds[1]);
wait(NULL);
} else {
struct message read_buffer;
/* 3 */ close(fds[1]);
while (read(fds[0], &read_buffer, sizeof(read_buffer)) > 0) {
printf("Сообщение: %s", read_buffer.content);
}
/* 4 */ close(fds[0]);
}
return 0;
}
Эта версия не отправляет одно сообщение, она отправляет случайное количество сообщений от 1 до 10. Таким образом, дочерний процесс не знает заранее, сколько сообщений нужно прочитать – он останавливается, когда прочитал все из канала и ничего больше не может быть записано в канал (т.е. когда все концы записи закрыты и read
возвращает отрицательное значение). Вот несколько примеров выполнения:
$ ./a.out
Сообщение: Hello, World
$ ./a.out
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
$ ./a.out
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
$
Обратите внимание, что я добавил комментарии перед каждым вызовом close()
, связанным с каналом. Если вы закомментируете только строку (1), заметных изменений в поведении программы не будет:
$ ./a.out
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
$ ./a.out
Сообщение: Hello, World
Сообщение: Hello, World
$
Если вы закомментируете только строку (2), тогда программа застрянет при выполнении, например:
$ ./a.out
Сообщение: Hello, World
Сообщение: Hello, World
(программа зависает здесь)
Почему? Если родительский процесс не закроет конец записи канала, то дочерний процесс будет блокироваться навсегда в вызове read
, ожидая больше данных. (Вызов read
будет блокироваться, если есть любой открытый дескриптор файлов, связанный с концом записи канала.) Родитель тогда заблокируется навсегда на вызове wait
. Оба, родитель и дочерний процесс будут блокироваться навсегда, ожидая друг друга.
Если вы закомментируете только строку (3), то программа застрянет при выполнении, например:
$ ./a.out
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
(программа зависает здесь)
Почему? Снова, вызов read
будет блокироваться, пока существует любой открытый дескриптор файлов, связанный с концом записи канала, и дочерний процесс имеет один. В результате дочерний процесс блокируется на вызове read
, а родитель блокируется на вызове wait
, и ничего не движется дальше.
Наконец, если вы закомментируете только строку (4), то заметных изменений в поведении программы не будет:
$ ./a.out
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
$ ./a.out
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
Сообщение: Hello, World
$
Обеспечивая закрытие всех процессов дескрипторов файлов, которые им не нужны, вы гарантируете, что ни один процесс не останется заблокированным, ожидая чтения данных из канала, которые никогда не придут.
Неправильное закрытие неиспользуемых дескрипторов канала имеет важное значение в определенных обстоятельствах:
- Почему писатель должен закрыть дескриптор чтения
Попытка записать в канал без читателя генерирует сигнал SIGPIPE
. Это должно произойти, когда читатель отсутствует (например, процесс завершился).
Но когда писатель оставляет открытым дескриптор чтения канала, сигнал SIGPIPE
не может возникнуть, потому что технически канал не поврежден, сам писатель является возможным читателем.
- Почему читатель должен закрыть дескриптор записи
Закрытие канала генерирует состояние EOF. Если читатель не закроет свою копию дескриптора записи, писатель не сможет отправить EOF, потому что в канал все еще могут быть записаны данные читателем.
Ответ или решение
Закрытие различных концов канала (pipe) в IPC
Введение в проблему
При работе с каналами (pipes) в междупроцессном взаимодействии (IPC) важно правильно управлять открытием и закрытием файловых дескрипторов. В вашем представленном коде на C используется функция pipe()
, которая создает односторонний канал для передачи данных между процессами. После создания канала каждый процесс должен закрыть его ненужные концы. Это важно для корректного выполнения программы и предотвращения блокировок.
Вот основные вопросы, с которыми вы столкнулись:
- Почему родительский процесс должен закрывать читающий конец перед записью и закрывать писательский конец после записи?
- Почему дочерний процесс должен закрывать писательский конец перед чтением и закрывать читающий конец после чтения?
- Что произойдет, если дочерний процесс попытается прочитать данные, пока родительский процесс их пишет?
- Что произойдет, если дочерний процесс попытается прочитать данные, но родительский процесс еще ничего не записал?
Давайте завершим разбор каждого из этих вопросов.
1. Закрытие ненужных концов канала
При создании канала с помощью pipe(fd)
, fd[0]
– это читающий конец, а fd[1]
– писательский. Каждый процесс имеет свои копии этих дескрипторов после вызова fork()
.
-
Родительский процесс закрывает
fd[0]
, потому что ему нужно только писать в канал. Оставление открытым этого дескриптора может привести к блокировкам при дальнейших операциях. -
После завершения записи в канал, родитель закрывает
fd[1]
для корректного освобождения ресурсов. Это освобождает дескриптор и сигнализирует дочернему процессу о завершении записи. -
Дочерний процесс закрывает
fd[1]
, так как ему нужно только читать данные. Это подобно предыдущему пункту; если дочерний процесс оставит открытым писательский конец, он может оставаться заблокированным, ожидая новых данных, даже если родитель завершит свое выполнение. -
Закрытие
fd[0]
после чтения также является хорошей практикой для освобождения ресурсов.
2. Блокировка операций чтения и записи
Когда процессы работают параллельно, важно понимать, как синхронизация между ними действует на операции чтения:
-
Если дочерний процесс начнет чтение во время записи родительского процесса, он сможет прочитать уже записанные данные. Важно отметить, что операции записи в pipe, выполняемые между процессами, работают на основе потоков, следовательно, если данные еще не записаны, это не повлияет на возможность чтения.
-
Если дочерний процесс попытается читать из пустого канала перед тем, как родитель начнет запись, он будет заблокирован до тех пор, пока данные не появятся. Это поведение гарантирует, что дочерний процесс будет ожидать, пока данные не станут доступны.
3. Операции без должного закрытия
Если родительский процесс не закроет свой писательский конец после выполнения записи, дочерний будет заблокирован на операции чтения. Это происходит потому, что даже если нет больше данных для чтения, пока остается открытым любой писательский конец, дочерний процесс будет ожидать дальнейшего ввода.
С другой стороны, если дочерний процесс не закроет конечный писательский конец, а родитель завершит процесс записи, тогда родительский процесс не получит сигнал EOF и также будет заблокирован.
Заключение
Управление дескрипторами в IPC — это основополагающий аспект, который не только влияет на производительность приложения, но и на его правильную работу. Следует помнить, что правильное использование и закрытие файловых дескрипторов в каналах позволяет избежать блокировок и утечек ресурсов. Важно разрабатывать свои собственные методы сигнализации между процессами о завершении передачи данных, например, используя специальные символы.
Этот опыт поможет вам полностью понять работу с каналами в IPC и эффективно применять полученные знания в будущих проектах.