Функция fork() модуля os в Python
Функция fork() модуля os создает клон текущего процесса как дочерний процесс. Возвращает 0 в дочернем процессе и идентификатор дочернего процесса в родительском элементе. Если возникает ошибка, то возникает исключение OSError .
Вызывает событие аудита os.fork без аргументов.
Изменено в версии Python 3.8: вызов os.fork() в субинтерпретаторе больше не поддерживается (возникает ошибка RuntimeError ).
Изменено в версии Python 3.12: если Python обнаруживает, что процесс имеет несколько потоков, то os.fork() теперь выдает предупреждение об устаревании DeprecationWarning .
Чтобы лучше информировать разработчиков о проблеме проектирования, которую платформа POSIX специально отмечает как не поддерживаемую, команда разработчиков Python решила отображать это как предупреждение. Даже в коде, который, кажется, что работает, на платформах POSIX небезопасно смешивать многопоточность с os.fork() . Сама среда выполнения CPython всегда выполняла вызовы API, которые небезопасны для использования в дочернем процессе, когда потоки существовали в родительском (например, malloc и free ).
Пользователи macOS или пользователи реализаций libc или malloc , отличных от тех, которые обычно встречаются в glibc на сегодняшний день, относятся к числу тех, кто уже с большей вероятностью столкнется с взаимоблокировками при запуске такого кода.
Доступность: POSIX, но недоступно на Emscripten и WASI.
Примеры создания дочерних процессов
После разветвления, два процесса выполняют один и тот же код. Чтобы программа узнала, в каком она находится, она должна проверить возвращаемое значение os.fork() . Если значение равно 0 , текущий процесс является дочерним. Если это не 0 , программа выполняется в родительском процессе, а возвращаемое значение является идентификатором процесса дочернего процесса.
# test_fork.py import os pid = os.fork() if pid: print('Child process id:', pid) else: print('I am the child') # $ python3 test_fork.py # Child process id: 20131 # I am the child
Родитель может отправлять сигналы дочернему процессу, используя функцию os.kill() используя модуль signal . В этом примере используется короткая пауза, чтобы дать дочернему процессу время для настройки обработчика сигнала. Реальное приложение, не будет нуждаться в вызове time.sleep() . В дочернем процессе установим обработчик сигнала и сделаем небольшую паузу, чтобы предоставить родительскому процессу достаточно времени для отправки сигнала.
# test_kill.py import os import signal import time def signal_usr1(signum, frame): "Обратный вызов вызывается при получении сигнала" pid = os.getpid() print(f'Получен USR1 в процессе pid>') print('Forking. ') child_pid = os.fork() if child_pid: print('PARENT: Пауза перед отправкой сигнала . ') time.sleep(1) print(f'PARENT: передача сигналов child_pid>') os.kill(child_pid, signal.SIGUSR1) else: print('CHILD: Настройка обработчика сигнала') signal.signal(signal.SIGUSR1, signal_usr1) print('CHILD: Пауза в ожидании сигнала') time.sleep(5) # $ python3 test_kill.py # Forking. # PARENT: Пауза перед отправкой сигнала . # CHILD: Настройка обработчика сигнала # CHILD: Пауза в ожидании сигнала # PARENT: передача сигналов 21168 # Получен USR1 в процессе 21168
Простой способ обработать отдельное поведение в дочернем процессе — проверить возвращаемое значение os.fork() и branch . Более сложное поведение может потребовать большего разделения кода, чем простая ветвь. В других случаях может существовать программа, которую необходимо обернуть. Для обеих этих ситуаций можно использовать серию функций os.exec*() для запуска другой программы.
# test_exec.py import os child_pid = os.fork() if child_pid: os.waitpid(child_pid, 0) else: os.execlp('pwd', 'pwd', '-P') # $ python3 test_exec.py # /home/docs-python
Когда программа запускается функцией os.exec() , код из этой программы заменяет код из существующего процесса.
Примеры ожидания дочерних процессов
Много ресурсоемких программ, используется несколько процессов, чтобы обойти ограничения многопоточности в Python и глобальную блокировку интерпретатора GIL. При запуске нескольких процессов для выполнения отдельных задач мастеру нужно будет дождаться завершения одного или нескольких из них, прежде чем запускать новые, чтобы избежать перегрузки сервера. Существует несколько различных способов сделать это с помощью функции os.wait() и связанных с ней функций.
Когда не имеет значения, какой дочерний процесс может завершиться первым, используйте os.wait() . Он возвращается, как только завершается любой дочерний процесс.
# test_wait.py import os import sys import time for i in range(2): print(f'PARENT os.getpid()>: Forking i>') worker_pid = os.fork() if not worker_pid: print(f'WORKER i>: Starting') time.sleep(2 + i) print(f'WORKER i>: Finishing') sys.exit(i) for i in range(2): print(f'PARENT: Waiting for i>') done = os.wait() print(f'PARENT: Child done: done>') # $ python3 test_wait.py # PARENT 24512: Forking 0 # PARENT 24512: Forking 1 # WORKER 0: Starting # PARENT: Waiting for 0 # WORKER 1: Starting # WORKER 0: Finishing # PARENT: Child done: (24513, 0) # PARENT: Waiting for 1 # WORKER 1: Finishing # PARENT: Child done: (24514, 256)
Функция os.wait() возвращает идентификатор процесса и код завершения, упакованный в 16-битовое значение. Младший байт представляет номер сигнала, прекратившего выполнение процесса, а старший — код состояния, возвращенный процессом по его завершении.
Чтобы дождаться определенного процесса, используйте функцию os.waitpid() .
Функция os.waitpid() , которой передан идентификатор целевого процесса, будет блокироваться до тех пор, пока процесс не завершится.
# test_waitpid.py import os import sys import time workers = [] for i in range(2): print(f'PARENT os.getpid()>: Forking i>') worker_pid = os.fork() if not worker_pid: print(f'WORKER i>: Starting') time.sleep(2 + i) print(f'WORKER i>: Finishing') sys.exit(i) workers.append(worker_pid) for pid in workers: print(f'PARENT: Waiting for pid>') done = os.waitpid(pid, 0) print(f'PARENT: Child done: done>') # $ python3 test_waitpid.py # PARENT 25144: Forking 0 # PARENT 25144: Forking 1 # WORKER 0: Starting # PARENT: Waiting for 25145 # WORKER 1: Starting # WORKER 0: Finishing # PARENT: Child done: (25145, 0) # PARENT: Waiting for 25146 # WORKER 1: Finishing # PARENT: Child done: (25146, 256)
- ОБЗОРНАЯ СТРАНИЦА РАЗДЕЛА
- Управление переменной средой окружения системы
- Представление пути в файловой системе
- Извлечение/установка uid, gid и pid процесса
- Наследование файловых дескрипторов
- Создание дескриптора файла, чтение, запись и его закрытие
- Функция listdir() модуля os
- Функция walk() модуля os
- Функция scandir() модуля os
- Объект DirEntry() модуля os
- Функция stat() модуля os
- Объект stat_result, результаты выполнения os.stat()
- Функция lstat() модуля os
- Функция access() модуля os
- Функция chdir() модуля os
- Функция chmod() модуля os
- Функция chown() модуля os
- Функция chroot() модуля os
- Функция getcwd() модуля os
- Функция link() модуля os
- Функция mkdir() модуля os
- Функция makedirs() модуля os
- Функция symlink() модуля os
- Функция readlink() модуля os
- Функция remove() модуля os, удалить файл
- Функция removedirs() модуля os
- Функция rename() модуля os
- Функция renames() модуля os
- Функция replace() модуля os
- Функция rmdir() модуля os
- Функция strerror() модуля os
- Функция supports_dir_fd модуля os
- Функция supports_effective_ids модуля os
- Функция supports_fd модуля os
- Функция supports_follow_symlinks модуля os
- Функция truncate() модуля os
- Функция utime() модуля os
- Манипулирование списком контроля доступа ACL в Linux
- Функция abort() модуля os
- Функция exec*() модуля os
- Функция popen() модуля os
- Функция system() модуля os
- Функция _exit() модуля os
- Функция fork() модуля os
- Функция kill() модуля os
- Функция spawn() модуля os
- Функция umask() модуля os
- Функция uname() модуля os
- Функция wait() модуля os
- Функция waitpid() модуля os
- Определение состояния процесса
- Константы для поддержки операций с путями
- Генератор случайных байтов на основе модуля os
- Функция startfile() модуля os
- Функция times() модуля os
- Функции getloadavg() и cpu_count() модуля os
- Функция waitstatus_to_exitcode() модуля os
- Функция listdrives() модуля os, имена дисков Windows
- Функция listmounts() модуля os, точки монтирования Windows
- Функция listvolumes() модуля os, список томов Windows
Дочерний процесс fork
Начал разбираться с функцией os.fork() в Python. В процессе гугления понял, что это стандартная функция в Linux для создания дочерних процессов. Как её использовать в терминале у себя на Ubuntu не понял. Внятного примера применения в Python тоже не нашел. Обычно это pid = os.fork() , а дальше какая-то магия. Что при этом происходит?
Отслеживать
19.1k 6 6 золотых знаков 30 30 серебряных знаков 44 44 бронзовых знака
задан 29 июл 2016 в 13:18
7,748 17 17 золотых знаков 72 72 серебряных знака 133 133 бронзовых знака
29 июл 2016 в 13:28
29 июл 2016 в 14:16
1 ответ 1
Сортировка: Сброс на вариант по умолчанию
Создаётся полная КОПИЯ процесса, выполнившего вызов fork(). С точки зрения ОС они различаются только тем, что первый процесс был запущен Вами (допустим — из консоли), а второй — вашей программой, вызвавшей fork. Соответственно, у них всё абсолютно одинаковое. За исключением ppid — id родительского процесса. Первый будет иметь ppid==pid Вашего shell, а второй — pid этого процесса.
Соответственно, для того, что бы эти два ОДИНАКОВЫХ процесса могли работать далее по разному, ОС возвращает через fork() pid созданного процесса.
Для родителя это будет реальный pid процесса, который он создал. Для дочернего — ноль, так как он ничего не создавал.
Отсюда следует, что сразу после вызова fork(), нужно ОБЯЗАТЕЛЬНО использовать конструкцию вида:
if (pid > 0 ) < // Делаем что-то в родителе >else if (pid == 0) < // Делаем что-то в дочернем >else < // Обрабатываем ошибку >
Запуск независимого процесса
Ваяю на коленке программу, которая по определенным событиям должна запускать различные приложения. Это могут быть как простенькие скрипты, так и GUI-приложения. Причем хочу добиться того, чтобы запускаемые приложения и скрипты продолжали работать и после смерти (неважно от чего) «родительской» программы. Подскажите куда копать.

kernelpanic ★★★★★
08.08.13 21:11:57 MSK
А от чего помирают дочерние процессы? Программа консольная?
arturpub ★★
( 08.08.13 21:13:31 MSK )

google: double fork
provaton ★★★★★
( 08.08.13 21:15:10 MSK )
Ответ на: комментарий от arturpub 08.08.13 21:13:31 MSK

А от чего помирают дочерние процессы?
Они помирают от того, что являются дочерними. Мне же нужно, чтобы они были независимы.
Да, консольная. В конечном итоге демоном, думаю, будет.
kernelpanic ★★★★★
( 08.08.13 21:18:20 MSK ) автор топика
Ответ на: комментарий от provaton 08.08.13 21:15:10 MSK

А каким образом я применю сей замечательный механизм ну, скажем, к firefox’у или deadbeef’у?
kernelpanic ★★★★★
( 08.08.13 21:19:49 MSK ) автор топика
Ответ на: комментарий от kernelpanic 08.08.13 21:18:20 MSK
Они помирают от того, что являются дочерними.
Что конкретно ты делаешь, блин? Можно закрыть терминал, можно снять родителя по с-с, например.
arturpub ★★
( 08.08.13 21:21:26 MSK )
Ответ на: комментарий от kernelpanic 08.08.13 21:19:49 MSK
fork, fork, exit — всё, отвязались от родителя, дальше процесс живет сам по себе.
gh0stwizard ★★★★★
( 08.08.13 21:21:34 MSK )
Ответ на: комментарий от kernelpanic 08.08.13 21:19:49 MSK

А в чем собственно проблема? Установи переменные окружения, необходимые для их запуска (DISPLAY, etc), потом два раза fork и exec. Ну и еще chuid сделать если главный процесс из-под рута работает.
provaton ★★★★★
( 08.08.13 21:22:55 MSK )
Ответ на: комментарий от kernelpanic 08.08.13 21:18:20 MSK
arturpub ★★
( 08.08.13 21:23:28 MSK )
Ответ на: комментарий от arturpub 08.08.13 21:21:26 MSK

Ctrl+C, SIGTERM, SIGKILL, exception, .
Да неважно от чего умрет «родительская» софтина. Надо чтобы запущенные ею приложения продолжали жить.
kernelpanic ★★★★★
( 08.08.13 21:25:56 MSK ) автор топика

vel ★★★★★
( 08.08.13 21:30:13 MSK )
Ответ на: комментарий от provaton 08.08.13 21:22:55 MSK

Я сегодня забыл принять таблетки и похоже туплю. Сформулирую вопрос иначе: как из питонового скрипта с бесконечным циклом запустить из этого цикла по определенному событию firefox таким образом, чтобы в случае завершения скрипта firefox продолжал жить?
kernelpanic ★★★★★
( 08.08.13 21:31:52 MSK ) автор топика
Ответ на: комментарий от kernelpanic 08.08.13 21:25:56 MSK
Дело в том, что кирдык приходит не от родителя, а от терминала. Увидев С-с, терминал посылает SIGINT to foreground process group, в которой они все участвуют. Чтобы расово верно отделиться от терминала, нужно два раза форкнуться, закрыть стандартные дескрипторы, а также сделать setsid(), ну и потом exec(). Как это выглядит на питоне — без понятия.
Пишем свой веб-сервер на Python: процессы, потоки и асинхронный I/O
Подпишись на обновления блогa, чтобы не пропустить следующий пост!
Оглавление
- Введение
- Один клиент — один процесс
- Один клиент — один поток
- Много клиентов — один поток с асинхронным I/O
- Гибридный подход
- Заключение
Введение
В прошлой части мы познакомились с TCP-сокетами, как инструментом передачи данных по сети, основами их использования на стороне сервера и клиента. В этот раз речь пойдет про обработку запросов. Существует множество способов организации обработки запросов сервером: один клиент — один процесс, один клиент — один поток, много клиентов — один поток с асинхронным I/O, а также гибридные подходы. Мы рассмотрим основные моменты каждого из подходов, их достоинства и недостатки.

Один клиент — один процесс
Начнем с самого простого способа. Однопоточный сервер постоянно ожидает клиентские подключения ( accept_client_conn ). Получив новое соединение, сервер вычитывает запрос ( read_request ), обрабатывает его ( handle_request ) и отправляет клиенту ответ ( write_response ):
# python3 import socket import sys import time def run_server(port=53210): serv_sock = create_serv_sock(port) cid = 0 while True: client_sock = accept_client_conn(serv_sock, cid) serve_client(client_sock, cid) cid += 1 def serve_client(client_sock, cid): request = read_request(client_sock) if request is None: print(f'Client # unexpectedly disconnected') else: response = handle_request(request) write_response(client_sock, response, cid) def create_serv_sock(serv_port): serv_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, proto=0) serv_sock.bind(('', serv_port)) serv_sock.listen() return serv_sock def accept_client_conn(serv_sock, cid): client_sock, client_addr = serv_sock.accept() print(f'Client # connected ' f':') return client_sock def read_request(client_sock, delimiter=b'!'): request = bytearray() try: while True: chunk = client_sock.recv(4) if not chunk: # Клиент преждевременно отключился. return None request += chunk if delimiter in request: return request except ConnectionResetError: # Соединение было неожиданно разорвано. return None except: raise def handle_request(request): time.sleep(5) return request[::-1] def write_response(client_sock, response, cid): client_sock.sendall(response) client_sock.close() print(f'Client # has been served') if __name__ == '__main__': run_server(port=int(sys.argv[1]))
Разумно было бы полагать, что у такой простоты есть своя цена. Наш сервер работает в одном процессе (и одном потоке). Обработка клиентов происходит синхронно. Таким образом, пока сервер занят одним клиентом, остальные клиенты вынуждены ждать в очереди (на видео в левом нижнем углу необходимо обратить внимание на количество процессов и потоков nTH команды python в выводе top ):
Один процесс на всех клиентов, синхронная обработка.
Очевидно, что это далеко не самый эффектинвый способ огранизовать обработку запросов. Попробуем его улучшить, назначив каждому клиенту выделенный процесс:
# python3 . import os def run_server(port=53210): serv_sock = create_serv_sock(port) active_children = set() cid = 0 while True: client_sock = accept_client_conn(serv_sock, cid) child_pid = serve_client(client_sock, cid) active_children.add(child_pid) reap_children(active_children) cid += 1 def serve_client(client_sock, cid): child_pid = os.fork() if child_pid: # Родительский процесс, не делаем ничего client_sock.close() return child_pid # Дочерний процесс: # - читаем запрос # - обрабатываем # - записываем ответ # - закрываем сокет # - завершаем процесс (os._exit()) request = read_request(client_sock) if request is None: print(f'Client # unexpectedly disconnected') else: response = handle_request(request) write_response(client_sock, response, cid) os._exit(0) def reap_children(active_children): for child_pid in active_children.copy(): child_pid, _ = os.waitpid(child_pid, os.WNOHANG) if child_pid: active_children.discard(child_pid) # create_serv_sock, accept_client_conn, \ # read_request, handle_request, write_response # . см. пример выше
Структура нашего сервера осталась практически неизменной. В то же время, он показывает новые свойства. Входящие запросы не блокируют сервер и могут быть обслужены конкурентно. Функция обработки запросов serve_client() использует fork() для запуска новых процессов на каждый входящий запрос. Таким образом, главный процесс сервера ответственен только за прием входящих соединений. Сразу после того, как был создан клиентский сокет, он передается в serve_client() , которая, уже в дочернем процессе, вычитывает запрос, производит его обработку, отсылает ответ, после чего закрывает сокет и завершает дочерний процесс используя функцию os._exit() .
Демонстрацию работы нового сервера можно посмотреть на следующем видео (традиционно обращаем внимание на вкладку top в левом нижнем углу):
Один клиент — один процесс.
Запуск процессов всегда сопряжен с дополнительной сложностью по контролю за дочерними процессами, а также за общими ресурсами. Код нашего сервера сильно упрощен, и в нем отсутвтует обработка ошибок и сигналов. В то же время, хотелось бы обратить внимание на часть функции serve_client() , которая выполняется в родительском (главном) процессе сервера. После запуска дочернего процесса, родительский процесс немедленно закрывает клиентский сокет и сохраняет pid дочернего процесса. Для чего нужны эти лишние действия?
Для начала разберемся с сокетом. Как мы уже знаем из прошлой статьи, сокеты представлены файловыми дескрипторами. Особенность fork() состоит в том, что как родительский, так и дочерний процессы могут иметь доступ к файловым дескрипторам, открытым до вызова fork() . Чтобы избежать использования лишних ресурсов и конкурентного доступа к клиентскому сокету, главный процесс сервера должен закрыть его на своей стороне.
Вторая особенность fork() заключается в том, что даже завершившиеся сами по себе дочерние процессы (см. вызов os._exit() в serve_client() ) нуждаются в явной обработке этого действия родительским процессом. До тех пор, пока родительский процесс не вызовет os.waitpid(child_pid) часть ресурсов (хоть и небольшая) дочернего процесса не будет освобождена операционной системой, а соответствующий дочерний процесс будет в состоянии зомби. Для простоты, в нашем примере главный процесс производит освобождение reap_children() завершившихся дочерних процессов после подключения каждого следующего клиента. Правильной же организацией была бы периодическая попытка вызывать reap_children() при наличии активных дочерних процессов не реже, чем раз в несколько секунд.
Когда используется подход на основе fork() ? Исторически, это был один из первых способ решения проблемы конкурентной обработки запросов. Знаменитый режим prefork Apache HTTP Server — яркий пример такого использования. В то же время, в современном коде все еще имеет смысл использовать этот подход, когда сложно добиться потокобезопасности. При создании процесса вызовом fork() происходит копирование (хоть и отложенное) адресного пространства родительского процесса в дочерний. Модификации переменных в дочерних процессах не имеют эффекта в родительском процессе. Таким образом, каждый из процессов-обработчиков выполняется в изолированном окружении без риска попасть в состояние гонки. Также, благодаря изоляции, проблемы при обработке одного запроса не распространяются на остальные процессы-обработчики.
Когда не стоит использовать подход на основе fork() ? Запуск процессов, в частности накладные расходы, связанные с копированием адресного пространства и последующего переключение контекста между процессами, является основным недостатком подхода. У изоляции своя цена и она достаточно высока. При большом числе запросов в секунду доля процессорного времени, затрачиваемая на накладные расходы, может оказаться чуть ли не выше, чем доля, приходящаяся на полезную нагрузку. Существуют и оптимизации, когда используется пул процессов-обработчиков (см. документацию Apache HTTP Server prefork выше), призванный устранить расходы на копирование адресного пространства и ограничить число переключений контекста за счет фиксированного числа процессов-обработчиков в пуле. Очевидно, что при пуле из K процессов-обработчиков не возможно конкурентно обрабатывать более K запросов. Типичный размер пула это десятки, реже сотни, процессов.
Один клиент — один поток
Естественным развитием идеи, описанной выше, является обработка запросов в отдельных потоках:
# python3 . import threading def run_server(port=53210): serv_sock = create_serv_sock(port) cid = 0 while True: client_sock = accept_client_conn(serv_sock, cid) t = threading.Thread(target=serve_client, args=(client_sock, cid)) t.start() # Запуск нового потока cid += 1 def serve_client(client_sock, cid): # Реализация совпадает с версией из синхронной обработки. request = read_request(client_sock) if request is None: print(f'Client # unexpectedly disconnected') else: response = handle_request(request) write_response(client_sock, response, cid) # create_serv_sock, accept_client_conn, \ # read_request, handle_request, write_response # . см. пример выше
И снова структура сервера практически не изменилась. Главный поток (соответсвтующий главному процессу сервера) как и прежде занимается лишь приемом входящих подключений. Создав очередной клиентский сокет ( accept_client_conn ), он запускает дополнительный поток, входной точкой для которого является простая версия serve_client() . Выполнив обработку запроса, дополнительный поток немедленно завершается (традиционно обращаем внимание на вкладку top в левом нижнем углу, колонка nTH показывает количесто одновременны запущенных потоков команды python):
Один клиент — один поток.
Использование потока вместо процесса на каждое новое подключение является более эффективным способом организации обработки запросов. В то же время, переключение контекста все еще присутствует, хоть и не включает в себя самую дорогостоящую фазу — переключение адресного пространства. Как обычно, мы наблюдаем очередной компромисс. Увеличив производительность, мы потеряли преимущества изоляции. Все потоки-обработчики имеют доступ к одному и тому же адресному пространству главного процесса. При необходимости иметь разделяемое состояние, код нашего сервера должен быть написан в потоко-безопасной манере.
Накладные расходы, связанные с запуском нового потока на каждое входящее соединение могут быть устранены с помощью введения пула потоков. Как и в случае с пулом процессов, мы можем при старте сервера запустить K потоков и назначать свободный поток из пула каждому новому соединению (пример из конфигурации Apache HTTP Server). Несмотря на то, что мы снова не сможем иметь более K обслуживаемых запросов в один момент времени, размер пула потоков может быть на один-два порядка (то есть сотни, реже тысячи, потоков) выше размера пула процессов при том же объеме накладных расходов на переключение контекста.
Пул потоков достаточно распространенный способ реализации обработки запросов в современном коде. В то же время, современные сервисы могут иметь тысячи и десятки тысяч одновременных подключений, поэтому ситуация с ожиданием клиентов в очереди все еще может возникнуть. Но еще более распространенная ситуация для современных веб-сервисов — это наличие большого количества обращений к сторонним API в процессе построения ответа на очередной запрос. Например, наш сервер может быть под нагрузкой в 100 запросов в секунду и иметь пул из 100 потоков-обработчиков. В среднем, обработка одного запроса занимает 0.01 сек процессорного времени и 0.2 сек ожидания I/O от сторонних сервисов. Таким образом, каждую секунду нам необходимо 0.01 x 100 = 1 сек процессорного времени и 0.2 x 100 = 20 сек ожидания. На 8-ядерной машине мы можем ожидать до 8 сек процессорного времени в секунду, то есть наш процессор загружен лишь на 1/8, а при 100 потоках мы можем иметь до 100 сек ожидания каждую секунду, то есть мы загружены не более, чем на 1/5 от емкости. Получается, что наша конфигурация имеет достаточный запас прочности чтобы справиться даже с пятикратным ростом нагрузки. Но, как только один или несколько сторонных сервисов начинают обрабатывать запросы медленее, чем в среднем 1 сек на запрос, суммарное время ожидания каждую секунду может легко превысить 100 сек. И, несмотря на то, что наш процессор будет все еще загружен лишь на 12.5%, очередь запросов начнет расти. Динамическое увеличение размера пула потоков с последующим его скоращением могло бы быть потенциальным решением возникшей проблемы. Но при росте числа потоков выше некоторого предела, накладные нагрузки на переключение контекста становятся ощутимыми. Таким образом, пул потоков становится неэффективным при наличии интенсивной работы с I/O.
Много клиентов — один поток с асинхронным I/O
Принципиально отличным способом обработки запросов является подход, основанный на асинхронном вводе-выводе. Основная идея заключается в том, что чтение или запись в сокет (и другой ввод-вывод) может являться неблокирующим. Тогда один и тот же процесс (и поток) может инициировать чтение из сокета первого клиента и, пока оно выполняется операционной системой на заднем плане, перейти к обработке запроса от следующего клиента. Таким образом, возможно иметь множество конкурентных запросов, обслуживаемых всего одним потоком:
# python3 import asyncio import sys counter = 0 async def run_server(host, port): server = await asyncio.start_server(serve_client, host, port) await server.serve_forever() async def serve_client(reader, writer): global counter cid = counter counter += 1 # Потоко-безопасно, так все выполняется в одном потоке print(f'Client # connected') request = await read_request(reader) if request is None: print(f'Client # unexpectedly disconnected') else: response = await handle_request(request) await write_response(writer, response, cid) async def read_request(reader, delimiter=b'!'): request = bytearray() while True: chunk = await reader.read(4) if not chunk: # Клиент преждевременно отключился. break request += chunk if delimiter in request: return request return None async def handle_request(request): await asyncio.sleep(5) return request[::-1] async def write_response(writer, response, cid): writer.write(response) await writer.drain() writer.close() print(f'Client # has been served') if __name__ == '__main__': asyncio.run(run_server('127.0.0.1', int(sys.argv[1])))
В этот раз структура сервера значительно отличается от всего, что мы успели рассмотреть выше. В примере используются встроенные возможности (как синтаксические, так и функции стандартной библиотеки) Python 3.5+ , но подход на основе асинхронного ввода-вывода можно реализовать и без async/await .
Зачастую реализация программы, использующей асинхронный ввод-вывод, строится на основе цикла событий. Это бесконечный цикл, читающий события (представленные в виде функций обратного вызова) из очереди с приоритетами. Приоритетом обычно является время готовности к выполнению. Клиентский код, встретив необходимость чтения или записи в сокет, иницирует операцию I/O и регистрирует в очереди событий колбек, который необходимо вызвать для продолжения обработки запроса в момент, когда сокет будет готов. После чего, уступает управление циклу событий. Цикл событий опрашивает все файловые дескрипторы, ожидающие I/O и на основе этой информации обновляет приоритеты событий в очереди. Затем цикл выбирает самый высокоприоритетный элемент и передает выполнение ему.
Модуль asyncio предоставляет цикл событий «из коробки». Строка asyncio.run(. ) запускает наш сервер на выполнение внутри цикла событий. Сервер при этом реализован также этим модулем ( asyncio.start_server ). Нам же лишь остается определить функцию-обработчик ( serve_client ), которую asyncio сервер будет вызывать на каждое входящее соединение.
Код, основанный на колбеках обычно достаточно тяжело как писать, так и читать, так как он выглядит нелинейно. При этом колбеки необходимы, чтобы продолжить выполнение кода, прерванного на операции I/O, когда данные станут доступными. С другой стороны, мы знаем, что корутины умеют приостанавливать и возобновлять собственное выполнение в произвольных точках, сохраняя стек. Разумно предположить, что можно воспользоваться этим свойством, приостанавливая выполнения функции и передавая управление циклу событий, встретив необходимость I/O. Для этого Python вводит ключевое слово await , а функция, желающая его использовать, должна быть объявлена через async def , что делает ее корутиной.
Такой код выполняется в одном потоке, при этом возможно обрабатывать большое число одновременных соединений:
Много клиентов — один поток с асинхронным I/O.
Подход отлично справляется с проблемой, описанной выше, когда суммарное ожидание в секунду превышает число потоков в пуле. Практическое ограничение на количество одновременных обработчиков очень высокое (десятки, реже сотни, тысяч), а накладные расходы на запуск новых обработчиков минимальны (сопоставимы с вызовом функции, переключение контекста на уровне операционной системы отсутствует). Данный подход идеально подходит в частности для создания WebSocket-серверов с десятками тысяч одновременных соединений, находящимися в режиме ожидания сообщений бОльшую часть времени.
К сожалению, преимущество подхода является и его недостатком. Так как весь код выполняется в одном потоке, утилизация всех ядер процессора затрудняется. Поэтому к коду, требующему большого числа вычислений необходимо относиться с осторожностью, не запуская его в основном цикле событий.
Другими известными апологетами обработки запросов с использованием асинхронного ввода-вывода являются веб-сервер nginx и программная платформа Node.js.
Гибридный подход
В реальных серверах обычно используется комбинация подходов. Широкоизвестные веб-сервер nginx и Apache HTTP Server в режиме MPM event имеют пул процессов, каждый из которых запускает пул потоков-обработчиков, каждый из которых использует модель обработки на основе асинхронного ввода-вывода.
Из документации Apache HTTP Server:
event (mode) is based on the worker MPM, which implements a hybrid multi-process multi-threaded server. A single control process (the parent) is responsible for launching child processes. Each child process creates a fixed number of server threads as specified in the ThreadsPerChild directive, as well as a listener thread which listens for connections and passes them to a worker thread for processing when they arrive.
Run-time configuration directives are identical to those provided by worker, with the only addition of the AsyncRequestWorkerFactor.
Заключение
Мы рассмотрели способы организации обработки запросов сервером. Примеры кода в статье были намеренно упрощены и не годятся для использования в боевых проектах. На взрослый код, реализующий примеры из статьи, можно посмотреть в стандартной библиотеке Python socketserver.py, а соответствующую документацию изучить здесь socketserver — A framework for network servers. В следующей статье мы ознакомимся с протоколом HTTP и реализуем простейший HTTP-сервер.
Make code not war!
Автор Ivan Velichko
Следовать в twitter @iximiuz