Многозадачность в Euphoria


Введение

Euphoria позволяет вам поставить на решение множественные независимые задачи. Каждая задача имеет свой текущий исполняемый оператор, свой собственный стек вызова и свой собственный набор частных переменных. Задачи решаются параллельно. Это означает, что перед тем, как любая данная задача будет закончена решением, все другие задачи получат шанс продвинуться в процессе решения. Диспетчер задач Euphoria устанавливает, какая задача должна решаться в любой данный момент времени.


Зачем нужна эта многозадачность?

Большинство программ, действительно, не нуждается в этой самой многозадачности и не будет в выигрыше от её наличия. Тем не менее, она очень полезна в некоторых случаях:

  • Игры действия, где многочисленные персонажи, снаряды и т.п. необходимо отображать как можно более реалистично, так, как будто они все движутся независимо один от другого. Игра Language War является хорошим тому примером.

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

  • Windows, Linux и FreeBSD все имеют специальные функции API, которые позволяют вам инициировать некоторые операции ввода/вывода, а затем продолжить вычисления, не ожидая завершения этих операций. Отдельная задача может периодически проверять, не закончился ли ввод/вывод, в то время как другие задачи выполняют другую полезную работу, или, например, начинают другую операцию ввода/вывода.

  • Ситуации, когда ваша программа предназначена для обслуживания многих пользователей одновременно. С множественными задачами легко можно контролировать состояние взаимодействия вашей программы со всеми этими отдельными независимыми пользователями.

  • Возможно, вы можете разделить свою программу на два логических процесса, а затем связать каждый из процессов с отдельной задачей. Один процесс будет вырабатывать данные и хранить их, а другой - читать данные и обрабатывать их. Первый процесс может быть критичным ко времени, так как взаимодействует с пользователем, а второй - допускает работу в паузах, пока пользователь обдумывает свой очередной шаг или полученные результаты.


Типы задач

Euphoria поддерживает два типа задач: задачи реального времени и задачи общего времени.

Задачи реального времени (ЗРВ) диспетчируются по интервалам, задаваемым числом секунд или долей секунды. Вы можете назначить активацию одной ЗРВ через каждые 3 секунды, в то время как вторая ЗРВ активизируется через каждые 0.1 секунды.

Задачи общего времени (ЗОВ) нуждаются в разделении доступа к процессору машины, но не требуют жёсткой привязки ни к каким часам. 4 задачи сортировки в программе demo\dos32\tasksort.ex делят доступ к процессору, но для них неважно, чтобы переключение процессора с одной задачи на другую происходило в определённое время. С другой стороны, в программе Language War, когда корабль Euphoria идёт на скорости 4, или торпеда движется через весь экран, важно, чтобы они двигались с неизменным временнЫм шагом.

Возможно редиспетчирование задачи в любое время и динамическое преобразование задачи из одного типа в другой.


Небольшой пример

Этот пример показывает главную задачу (ГЗ, с которой стартуют все программы Euphoria), создающую две дополнительные ЗРВ.

Вы можете скопировать и запустить этот пример на своей машине. Вы увидите, что ЗРВ-1 получает управление через каждые 2,5..3 секунды, в то время как ЗРВ-2 включается через каждые 5..5,1 секунды. В промежутках управление принадлежит ГЗ, задаче-0, так как она проверяет нажатие на клавишу 'q', чтобы завершить исполнение программы.

constant TRUE = 1, FALSE = 0

type boolean(integer x)
	return x = 0 or x = 1
end type

boolean t1_running, t2_running

procedure task1(sequence message)
	for i = 1 to 10 do
		printf(1, "task1 (%d) %s\n", {i, message})
		task_yield()
	end for
	t1_running = FALSE
end procedure

procedure task2(sequence message)
	for i = 1 to 10 do
		printf(1, "task2 (%d) %s\n", {i, message})
		task_yield()
	end for
	t2_running = FALSE
end procedure

puts(1, "main task: start\n")

atom t1, t2

t1 = task_create(routine_id("task1"), {"Hello"})
t2 = task_create(routine_id("task2"), {"Goodbye"})

task_schedule(t1, {2.5, 3})
task_schedule(t2, {5, 5.1})

t1_running = TRUE
t2_running = TRUE

while t1_running or t2_running do
	if get_key() = 'q' then
		exit
	end if  
	task_yield()
end while

puts(1, "main task: stop\n")
-- программа завершается, когда ГЗ закончена


Сравнение с более ранними схемами многозадачности

В ранних выпусках Euphoria игра Language War уже имела механизм для осуществления многозадачности, и некоторые люди представляли на странице Свежих поступлений свои собственные схемы многозадачности. Эти схемы были воплощены с использованием обычного кода Euphoria, в то время как новая система многозадачности встроена в интерпретатор. В старой системе Language War диспетчер *вызывал* очередную задачу, которая со временем должна была вернуться в диспетчер, чтобы он мог запустить следующую задачу.

В новой системе задача может вызывать встроенную процедуру task_yield() в любой точке, например, с любой глубины во вложенных вызовах подпрограммы, и диспетчер, который теперь является частью интерпретатора, будет способен передать управление любой другой задаче. Когда управление вернётся к исходной задаче, её исполнение продолжится с точки, следующей за оператором task_yield(), с её стеком вызова и всеми частными переменными, остающимися в неприкосновенности. Каждая задача имеет свой собственный стек вызова, программный счётчик (т.е. текущий оператор, подлежащий исполнению) и частные переменные. Вы можете иметь несколько задач, использующих одну и ту же подпрограмму в одно и то же время, но каждая из задач будет иметь свой собственный набор частных переменных для этой подпрограммы. Глобальные и местные переменные являются общими для всех задач.

Очень легко можно взять любой кусок кода и запустить его как задачу. Просто вставьте в него несколько операторов task_yield(), так чтобы этот кусок не застрял в горле у процессора.


Сравнение с "многониточностью" (multithreading)

Когда люди говорят о "нитях" (threads), они обычно имеют в виду тот механизм многозадачности, который обеспечивается операционной системой. Поэтому мы предпочитаем термин "многозадачность". Нити в общем случае работают по "вытесняющей" схеме, в то время как задачи Euphoria работают по "кооперативной". С вытесняющими нитями операционная система может жёстко переключаться с одной нити на другую практически в любое время. С кооперативными задачами каждая задача сама "знает", когда отдать процессор и позволить другой задаче принять управление им. Если задача "жадная", она может держать процессор для себя длительные периоды времени. Тем не менее, поскольку программу пишет один человек или группа единомышленников, всегда найдётся достаточно здравого смысла, чтобы скоординировать работу задач внутри одной программы должным образом. Необходимо пытаться сбалансировать нужды задач так, чтобы программа в целом работала по требованиям пользователя. Операционная система обычно исполняет множество нитей (процессов, программ), которые написаны разными людьми. И в этих условиях необходимо обеспечить некоторую степень справедливости в использовании процессорного времени между параллельно исполняемыми программами. Вытесняющая схема имеет смысл для операционной системы в целом. И в ней гораздо меньше смысла, если речь идёт об отдельной программе.

Более того, система с вытесняющими нитями печально знаменита своими скрытыми и неуловимыми ошибками. Отвратительные вещи могут случаться, когда "нить разговора" рвётся и задача теряет управление в неверный момент времени. Это может быть момент обновления глобальной переменной, которая в результате неожиданно остаётся со старым значением. Иногда даже такие тривиальные вещи, как просто счетчик, могут сбиваться. Например, рассмотрим две нити. Одна имеет:

    x = x + 1

а вторая тоже имеет:

    x = x + 1

На машинном уровне первая нить загружает величину x в регистр, а затем теряет контроль в пользу второй нити, которая увеличивает x и помещает результат обратно в x (в оперативной памяти). Со временем управление переходит обратно к первой нити, которая также увеличивает x *используя величину x в регистре*, и затем помещает её в x в памяти. Таким образом, x оказывается увеличенной только однажды, вместо того, чтобы быть увеличенной дважды, как это было предусмотрено. Чтобы избежать данной проблемы, каждая нить должна иметь что-либо наподобие:

    lock x
    x = x + 1
    unlock x

где lock (закрыть) и unlock (открыть) являются специальными командами, обеспечивающими безопасность для вытесняющей схемы. Нередко программист забывает об этих специальных средствах, но программа может месяцами работать нормально. Потом, в один прекрасный день, программа рушится самым мистическим образом.

Кооперативная многозадачность намного безопаснее и требует гораздо меньше затратных блокирующих операций. Задачи переключаются в безопасных точках, предусмотренных заранее.


Встроенные подпрограммы многозадачности

Все эти подрограммы встроены в Euphoria, так что нет никакой необходимости в специальной библиотеке.

task_create - Вызывается, когда необходимо создать новую задачу. Вам нужно подавать сюда номер подпрограммы Euphoria, а также список начальных аргументов, требуемых для этой подпрограммы. Это основная процедура для задачи. Задачи всегда являются процедурами, так как для задач не свойственна выдача каких-либо результатов, которые ожидались бы другими задачами. task_create() выдает номер задачи (малое целое). Используйте этот номер для обозначения данной задачи в других подпрограммах обеспечения многозадачности. Имейте в виду, что все программы Euphoria стартуют с одной уже исполняемой начальной (главной) задачей.
task_yield - Задача вызывает эту процедуру, чтобы отдать управление диспетчеру, который выбирает для исполнения другую задачу. Задача должна вызывать эту процедуру достаточно часто, чтобы избегать захвата процессора, но не слишком часто, чтобы не тратить слишком много времени на собственно диспетчирование. Чтобы избежать порчи структур данных, задача должна отдавать управление в конце какого-то логически завершённого своего шага. Возможно, что диспетчер решит продолжить исполнение той же самой только что сброшенной задачи, так что возврат из task_yield() может произойти без передачи управления другим задачам.
task_schedule - Передаёт задачу диспетчеру на исполнение. После того как задача создана, необходимо передать её диспетчеру, иначе она никогда не запустится на исполнение. Имеются два главных пути передачи задачи на исполнение. Первый задаёт интервал реального времени, min...max. Это даёт диспетчеру знать, что задача должна исполняться (если возможно) от минимального интервала времени до максимального интервала времени с момента каждого её очередного включения. Последовательные запуски задачи также будут ждать в тех же самых интервалах. Так что можно сказать, что задача должна исполняться каждые 3,5 .. 4.0 секунды. Второй путь использования системы разделения времени подразумевает, что задача может исполнять task_yield() заданное число раз, прежде чем отдаст управление процессором. Так что можно сказать, что одной из задач разрешено 10 task_yields, когда другая, например, менее приоритетная задача, может отдавать управление после каждого task_yield(), если возможно.
task_list - Выдаёт список всех задач.
task_self - Выдаёт номер текущей задачи.
task_status - Выдаёт текущее состояние (активна, ожидает, завершена) задачи.
task_suspend - Ожидание нового запуска.
task_clock_start - Перезапуск часов диспетчера. Используется в играх, где часы реального времени должны быть защищены на время перерывов в игре.
task_clock_stop - Остановка часов диспетчера.