Квазиполезное
От операционной системы процессам нужны интерфейсы для работы с железом, потому основными точками пересечения произвольного процесса и ядра ОС будут места, обращающиеся к "железу", реальному (например, запись на диск) или абстрактному (вывод в терминал). Эти обращения происходят с помощью системных вызовов.
Рассмотрим простейшую программу:
def main():
name = read()
s = "hello, " + name
write(s)
exit(0)
Обращение к системе происходит в трех местах: чтение с терминала, вывод в терминал и завершение процесса (да, управление процессами - это тоже взаимодействие с ядром). С точки зрения ядра процесс выглядит как последовательность непрерывных вычислений, перемежающихся системными вызовами. При системном вызове процесс должен (явно или неявно) сообщить ОС три вещи:
Для наглядности нашей модели все эти вещи процесс будет сообщать ядру явно 1. Для этого код программы должен выглядеть немного по-другому. Тут-то на сцену и выходит CPS (continuation passing style): передача кода-продолжения как аргумента чего-либо (на это еще можно смотреть как на callback).
Пусть теперь процесс будет выглядеть не как последовательность непрерывных вычислений вперемешку с системными вызовами, а как одно непрерывное вычисление, завершающееся системным вызовом и указанием, что должно исполняться в этом процессе после системного вызова. Тогда код программы можно представить как набор функций, каждая из которых может быть рассмотрена как процесс из предыдущего предложения. Эти функции, согласно описанию процесса:
Тогда return
является единственным способом сделать системный вызов в такой модели, а параметром системного вызова логично оказывается указанное в return
значение.
Параметры системного вызова должны содержать три вещи: указание, какой системный вызов совершается, его параметры и продолжение процесса. Пусть тогда в нашей модели процессы-функции в return
указывают тройку из:
READ_TAG
, WRITE_TAG
или EXIT_TAG
);При этом результат системного вызова должен быть как-то передан функции-продолжению перед началом ее исполнения. Логично результат сделать параметром функции-продолжения.
Тогда приведенный выше код программы будет выглядеть примерно так:
def main():
return (READ_TAG, [], cont)
def cont(name):
s = "hello, " + name
return (WRITE_TAG, [s], cont2)
def cont2():
return (EXIT_TAG, [0], None)
Это уже не очень похоже на программу, которую можно просто "взять и исполнить". Ей нужен интерпретатор, знающий о соглашениях, наложенных на представление этой программы: почему она в некоторых местах делает странные return
и как их обрабатывать. Этим интерпретатором и является ядро.
Исполнение процесса с точки зрения модели ядра выглядит так:
while True:
(tag, syscall_args, cont) = process(args)
if tag == READ_TAG:
result = do_readline()
elif tag == WRITE_TAG:
do_writeline(syscall_args)
result = None
else:
print("ERROR: No such syscall")
process = cont
args = result
Ядро поддерживает внутри очередь исполняющихся процессов, исполняющихся по очереди. В очереди лежат пары из функции-процесса и его аргументов-результатов последнего системного вызова.2
while processes:
(prog, args) = processes.pop()
(tag, syscall_args, cont) = prog(*args)
if tag == READ_TAG:
result = do_readline()
processes.append((cont, [result]))
elif tag == WRITE_TAG:
do_writeline(syscall_args)
processes.append((cont, []))
else:
print("ERROR: No such syscall")
Обрабатывать системные вызовы уже существующих процессов, конечно, полезно, но не несет особого смысла, если эти процессы некому создать. Традиционно в unix-like системах создание нового процесса происходит с помощью дублирования текущего процесса и всей сопутствующей ему информации. Это делается системным вызовом fork
. Он дублирует процесс, инициировавший этот системный вызов, а полученным двум процессам-близнецам возвращает разные результаты, чтобы они могли понять, какой из процессов — новый, а какой — старый.3
flag = fork()
if flag:
write("Old process")
else:
write("New process")
Для того, чтобы иметь возможность запускать сторонние программы, предусмотрен системный вызов exec()
. Он выкидывает код текущего процесса и замещает его новым, загруженным из указанного бинарника. В таких условиях, очевидно, любой код, следующий за вызовом exec()
, никогда не будет исполнен: его просто больше не будет в процессе после успешного исполнения exec()
. Тем не менее, в случае ошибки при вызове exec()
, например, при указании несуществуюшего бинарника, старый код процесса никуда не денется и будет исполняться дальше.
flag = fork()
if not flag:
exec("bash")
write("This will never be printed")
else:
exec("wrongbinaryname")
write("exec() failed")
В ядре обработка этих системных вызовов выглядит достаточно очевидно:
elif tag == FORK_TAG:
processes.append((cont, [True]))
processes.append((cont, [False]))
elif tag == EXEC_TAG:
new_cont = load_binary(syscall_args[0])
processes.append((new_cont, syscall_args[1:]))
Надо заметить, что cont
в приведенной обработке системного вызова exec()
не используется, что хорошо согласуется с описанной семантикой этого системного вызова.
Завершение процессов происходит тоже с помощью системного вызова, который называется exit()
. Его обработка в ядре тривиальна: нужно просто выкинуть процесс из списка процессов (и освободить соответствующие ему ресурсы):4
elif tag == EXIT_TAG:
# NB: no "processes.append((cont, ...))" here
pass
TODO: возможно, здесь должно быть еще что-нибудь про kill
TODO: наверное, это должно быть вообще не здесь, потому что в приведенной реализации этого нет
У этой модели есть примитивная реализация на питоне, которую можно позапускать и посмотреть, как она себя ведет. Ее можно найти здесь.
В реальных ОС, конечно же, это не так. Как эту модель можно перевести в что-то, близкое к реальности, будет рассказано вместе с обработкой прерываний.↩
Приведенная многозадачность является кооперативной и имеет все присущие ей минусы. Вытесняющую многозадачность в модели в текущем виде не сделать, но она легко делается в ядрe ОС, обрабатывающем прерывания.↩
В реальных ОС процессы образуют дерево процессов, и новые процессы становятся детьми того процесса, который их породил. При этом родителю как результат системного вызова fork()
возвращается число-идентификатор порожденного процесса, а ребенку возвращается 0
.↩
Обычно процессы при завершении хотят сообщить какую-то информацию о результате своей работы. Традиционно это делается с помощью кода возврата. Добавление этой концепции в ОС приводит к появлению концепции зомби-процессов, здесь не описанных.↩