QNX/UNIX: Анатомия параллелизма - Цилюрик Олег Иванович
addr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(ls, (struct sockaddr*)&addr, sizeof(sockaddr));
listen(ls, 25);
while(true) {
rc = accept(ls, NULL, NULL);
pid_t pid = fork();
if (pid < 0) ...; // что-то произошло!
if (pid == 0) {
close(ls);
char data[MAXLINE];
int nd = read(rc, &data, MAXLINE);
if (nd > 0) write(rc, &data, nd);
close(rs);
exit(EXIT_SUCCESS);
}
else close(rs); // единственное действие родителя
}
exit(EXIT_SUCCESS);
}
Приведенный фрагмент может в процессе своей работы породить достаточно много идентичных процессов (один родительский, пассивно прослушивающий канал; остальные — порожденные, активно взаимодействующие с клиентами, по одному на каждого клиента). Все порождаемые процессы наследуют весь набор дескрипторов (в данном случае сокетов), доступных родительскому процессу. Лучшее, что могут сделать процессы (как родительский, так и дочерний), — немедленно после вызова fork()(и это хорошая практика в общем случае) тщательно закрыть все унаследованные дескрипторы, не имеющие отношения к их работе.
ПримечаниеОперация fork()должна создать не только структуру адресного пространства нового процесса, но и побайтную копию этой области. В операционных системах общего назначения (Win32, Linux, FreeBSD) для облегчения этого трудоемкого процесса используется виртуализация страниц по технологии COW (copy on write), детально описанная, например, применительно к Win32, Джеффри Рихтером. Накладные расходы процесса копирования здесь демпфированы тем, что копирование каждой физической страницы памяти фактически производится только при записи в эту страницу, то есть затраты на копирование «размазываются» достаточно случайным образом по ходу последующего выполнения дочернего процесса (здесь нет практически никакого итогового выигрыша а производительности, есть только сокрытие от пользователя одноразового размера требуемых затрат).
Системы реального времени не имеют права на такую роскошь: непредсказуемое рассредоточение копирующих операций по всему последующему выполнению, а поэтому и использование в них COW вообще, выглядит весьма сомнительно. В [4] мы описывали эксперименты в QNX, когда в код сервера, построенного на fork(), была внесена «пассивная» строка, никак не используемая в программе, но определяющая весьма протяженную инициализированную область данных:
static long MEM[2500000];
При этом время реакции (ответа) сервера (затраты времени на выполнение fork()) возросло в 50 раз и составило 0,12 сек на процессоре 400 МГц. Еще раз, но в другом контексте эта особенность будет обсуждена ниже при сравнении затрат производительности на создание процессов и потоков.
Дополнительным вызовом этого класса (для полноты обзора) является использование функции:
pid_t vfork(void);
В отличие от fork(), этот вызов, впервые введенный в BSD UNIX, делает разделяемым для дочернего процесса адресное пространство родителя. Родительский процесс приостанавливается до тех пор, пока порожденный процесс не выполнит exec()(загружая новый программный код дочернего процесса) или не завершится с помощью exit()или аналогичных средств.
Функция vfork()может быть реализована на аппаратных платформах с физической моделью памяти (без виртуализации памяти), a fork()— не может (или реализуется с большими накладными расходами), так как требует создания абсолютной копии области адресного пространства, что в физической модели повлечет сложную динамическую модификацию адресных полей кода. Тем не менее (при некоторых кажущихся достоинствах) в BSD также подчеркивалось, что vfork()таит в себе серьезную потенциальную опасность, поскольку позволяет одному процессу использовать или даже модифицировать адресное пространство другого, то есть фактически нарушает парадигму защищенных адресных пространств.
Запуск нового программного кода
Наконец, рассмотрим запуск на выполнение нового, отличного от родительского процесса программного кода, образ которого содержится в отдельном исполняемом файле в качестве дочернего процесса. Для этой цели в QNX существуют две группы функций: exec()(их всего 8: execl(), execle(), execlp(), execlpe(), execv(), execve(), execvp(), execvpe()) и spawn()(их 10: spawn(), spawnl(), spawnle(), spawnlp(), spawnlpe(), spawnp(), spawnv(), spawnve(), spawnvp(), spawnvpe()).
Это множество форм записи отличается синтаксисом, который определяет формат списка аргументов командной строки, полученного нами в качестве параметров функции main(), передаваемых программе, а также некоторыми другими дополнительными деталями. Суффиксы в именах функций обозначают следующее:
• l— список аргументов определяется через список параметров, заданных непосредственно в самом вызове. Этот список завершается нулевым аргументом NULL;
• e— окружение для процесса указывается посредством определения массива переменных окружения;
• p— относительный путь поиска: если не указан полный путь к файлу программы (то есть имя файла не содержит разделителей « /»), для его поиска используется переменная окружения PATH;
• v— список аргументов определяется через указатель на массив аргументов.
В нашу задачу не входит описание всех возможностей вызовов, тем более что они обстоятельно описаны в [1, 2, 5, 6], и мы будем использовать по тексту любую, более удобную для нас форму без дополнительных объяснений.