2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
The main runtime environment for giambio
|
|
|
|
|
|
|
|
Copyright (C) 2020 nocturn9x
|
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
limitations under the License.
|
|
|
|
"""
|
|
|
|
|
|
|
|
# Import libraries and internal resources
|
|
|
|
import types
|
2021-05-31 11:11:56 +02:00
|
|
|
import socket
|
2020-12-20 15:58:53 +01:00
|
|
|
from itertools import chain
|
2021-06-08 17:21:59 +02:00
|
|
|
from giambio.task import Task
|
|
|
|
from giambio.sync import Event
|
2020-11-17 10:54:18 +01:00
|
|
|
from timeit import default_timer
|
2020-12-20 15:58:53 +01:00
|
|
|
from giambio.context import TaskManager
|
|
|
|
from typing import List, Optional, Set, Any
|
2020-12-05 17:09:59 +01:00
|
|
|
from giambio.util.debug import BaseDebugger
|
2020-12-20 15:58:53 +01:00
|
|
|
from giambio.traps import want_read, want_write
|
2021-06-08 17:21:59 +02:00
|
|
|
from giambio.internal import TimeQueue, DeadlinesQueue
|
2020-11-17 10:54:18 +01:00
|
|
|
from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE
|
2021-06-08 17:21:59 +02:00
|
|
|
from giambio.exceptions import (
|
|
|
|
InternalError,
|
|
|
|
CancelledError,
|
|
|
|
ResourceBusy,
|
|
|
|
GiambioError,
|
|
|
|
TooSlowError,
|
|
|
|
)
|
2020-11-29 12:46:08 +01:00
|
|
|
|
|
|
|
|
2020-11-17 10:54:18 +01:00
|
|
|
class AsyncScheduler:
|
|
|
|
"""
|
2021-06-03 16:34:26 +02:00
|
|
|
A simple task scheduler implementation that tries to mimic thread programming
|
|
|
|
in its simplicity, without using actual threads, but rather alternating
|
2020-11-17 10:54:18 +01:00
|
|
|
across coroutines execution to let more than one thing at a time to proceed
|
|
|
|
with its calculations. An attempt to fix the threaded model has been made
|
2021-06-03 16:34:26 +02:00
|
|
|
without making the API unnecessarily complicated.
|
|
|
|
|
2021-06-08 17:21:59 +02:00
|
|
|
This loop only takes care of task scheduling, I/O multiplexing and basic
|
|
|
|
suspension: any other feature should therefore be implemented in object
|
|
|
|
wrappers (see io.py and sync.py for example). An object wrapper should
|
2021-06-03 16:34:26 +02:00
|
|
|
not depend on the loop's implementation details such as internal state or
|
2021-06-08 17:21:59 +02:00
|
|
|
directly access its methods: traps should be used instead. This is to
|
2021-06-03 16:34:26 +02:00
|
|
|
ensure that the wrapper will keep working even if the scheduler giambio
|
|
|
|
is using changes, which means it is entirely possible, and reasonable, to
|
|
|
|
write your own event loop and run giambio on top of it, provided the required
|
|
|
|
traps are correctly implemented.
|
2020-12-20 15:58:53 +01:00
|
|
|
|
|
|
|
:param clock: A callable returning monotonically increasing values at each call,
|
2021-06-08 17:21:59 +02:00
|
|
|
usually using seconds as units, but this is not enforced, defaults to timeit.default_timer
|
2020-12-20 15:58:53 +01:00
|
|
|
:type clock: :class: types.FunctionType
|
|
|
|
:param debugger: A subclass of giambio.util.BaseDebugger or None if no debugging output
|
2021-06-08 17:21:59 +02:00
|
|
|
is desired, defaults to None
|
2020-12-20 15:58:53 +01:00
|
|
|
:type debugger: :class: giambio.util.BaseDebugger
|
2021-06-03 16:34:26 +02:00
|
|
|
:param selector: The selector to use for I/O multiplexing, defaults to selectors.DefaultSelector
|
|
|
|
:param io_skip_limit: The max. amount of times I/O checks can be skipped when
|
|
|
|
there are tasks to run. This makes sure that highly concurrent systems do not starve
|
|
|
|
I/O waiting tasks. Defaults to 5
|
|
|
|
:type io_skip_limit: int, optional
|
|
|
|
:param io_max_timeout: The max. amount of seconds to pause for an I/O timeout.
|
|
|
|
Keep in mind that this timeout is only valid if there are no deadlines happening before
|
|
|
|
the timeout expires. Defaults to 86400 (1 day)
|
|
|
|
:type io_max_timeout: int, optional
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
|
2021-06-08 17:21:59 +02:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
clock: types.FunctionType = default_timer,
|
|
|
|
debugger: Optional[BaseDebugger] = None,
|
|
|
|
selector: Optional[Any] = None,
|
|
|
|
io_skip_limit: Optional[int] = None,
|
|
|
|
io_max_timeout: Optional[int] = None,
|
|
|
|
):
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
Object constructor
|
|
|
|
"""
|
|
|
|
|
2020-11-18 12:13:46 +01:00
|
|
|
# The debugger object. If it is none we create a dummy object that immediately returns an empty
|
2020-12-20 15:58:53 +01:00
|
|
|
# lambda which in turn returns None every time we access any of its attributes to avoid lots of
|
|
|
|
# if self.debugger clauses
|
2020-11-18 12:13:46 +01:00
|
|
|
if debugger:
|
2021-06-08 17:21:59 +02:00
|
|
|
assert issubclass(
|
|
|
|
type(debugger), BaseDebugger
|
|
|
|
), "The debugger must be a subclass of giambio.util.BaseDebugger"
|
|
|
|
self.debugger = (
|
|
|
|
debugger
|
|
|
|
or type(
|
|
|
|
"DumbDebugger",
|
|
|
|
(object,),
|
|
|
|
{"__getattr__": lambda *args: lambda *arg: None},
|
|
|
|
)()
|
|
|
|
)
|
|
|
|
# All tasks the loop has
|
2020-12-20 15:58:53 +01:00
|
|
|
self.tasks: List[Task] = []
|
2021-06-08 17:21:59 +02:00
|
|
|
# Tasks that are ready to run
|
|
|
|
self.run_ready: List[Task] = []
|
2020-11-17 10:54:18 +01:00
|
|
|
# Selector object to perform I/O multiplexing
|
2020-12-20 15:58:53 +01:00
|
|
|
self.selector: DefaultSelector = DefaultSelector()
|
2020-11-17 10:54:18 +01:00
|
|
|
# This will always point to the currently running coroutine (Task object)
|
2020-12-20 15:58:53 +01:00
|
|
|
self.current_task: Optional[Task] = None
|
2020-11-17 10:54:18 +01:00
|
|
|
# Monotonic clock to keep track of elapsed time reliably
|
2020-12-20 15:58:53 +01:00
|
|
|
self.clock: types.FunctionType = clock
|
2020-11-17 10:54:18 +01:00
|
|
|
# Tasks that are asleep
|
2020-12-20 15:58:53 +01:00
|
|
|
self.paused: TimeQueue = TimeQueue(self.clock)
|
2020-11-17 10:54:18 +01:00
|
|
|
# Have we ever ran?
|
2020-12-20 15:58:53 +01:00
|
|
|
self.has_ran: bool = False
|
2020-11-28 13:04:27 +01:00
|
|
|
# The current pool
|
2020-12-20 15:58:53 +01:00
|
|
|
self.current_pool: Optional[TaskManager] = None
|
2020-12-19 15:18:12 +01:00
|
|
|
# How many times we skipped I/O checks to let a task run.
|
2020-12-05 17:09:59 +01:00
|
|
|
# We limit the number of times we skip such checks to avoid
|
|
|
|
# I/O starvation in highly concurrent systems
|
2020-12-20 15:58:53 +01:00
|
|
|
self.io_skip: int = 0
|
2020-12-05 17:09:59 +01:00
|
|
|
# A heap queue of deadlines to be checked
|
2020-12-20 15:58:53 +01:00
|
|
|
self.deadlines: DeadlinesQueue = DeadlinesQueue()
|
2021-06-03 16:34:26 +02:00
|
|
|
# Data to send back to a trap
|
|
|
|
self._data: Optional[Any] = None
|
|
|
|
# The I/O skip limit. TODO: Back up this value with euristics
|
|
|
|
self.io_skip_limit = io_skip_limit
|
|
|
|
# The max. I/O timeout
|
|
|
|
self.io_max_timeout = io_max_timeout
|
|
|
|
|
2020-12-20 15:58:53 +01:00
|
|
|
def done(self) -> bool:
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
2020-12-19 15:18:12 +01:00
|
|
|
Returns True if there is no work to do
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
|
2021-06-08 17:21:59 +02:00
|
|
|
if any([self.paused, self.run_ready, self.selector.get_map()]):
|
2020-11-17 10:54:18 +01:00
|
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
|
|
def shutdown(self):
|
|
|
|
"""
|
|
|
|
Shuts down the event loop
|
|
|
|
"""
|
|
|
|
|
|
|
|
self.selector.close()
|
2020-12-19 15:18:12 +01:00
|
|
|
# TODO: Anything else?
|
2020-11-17 10:54:18 +01:00
|
|
|
|
|
|
|
def run(self):
|
|
|
|
"""
|
2021-06-03 16:34:26 +02:00
|
|
|
The event loop's runner function. This method drives
|
|
|
|
execution for the entire framework and orchestrates I/O,
|
|
|
|
events, sleeping, cancellations and deadlines, but the
|
|
|
|
actual functionality for all of that is implemented in
|
|
|
|
object wrappers (see socket.py or event.py for example).
|
|
|
|
|
|
|
|
This keeps the size of this module to a minimum while
|
|
|
|
allowing anyone to replace it with their own, as long
|
|
|
|
as the traps required by higher-level giambio objects
|
|
|
|
are implemented. If you want to add features to the
|
|
|
|
library, don't add them here, but take inspiration
|
|
|
|
from the current object wrappers (i.e. not depending
|
|
|
|
on any implementation detail from the loop other than
|
|
|
|
traps)
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
|
|
|
|
while True:
|
|
|
|
try:
|
|
|
|
if self.done():
|
2020-12-19 15:18:12 +01:00
|
|
|
# If we're done, which means there are
|
|
|
|
# both no paused tasks and no running tasks, we
|
2020-11-18 12:13:46 +01:00
|
|
|
# simply tear us down and return to self.start
|
2020-12-05 17:09:59 +01:00
|
|
|
self.close()
|
2020-11-17 10:54:18 +01:00
|
|
|
break
|
2021-06-08 17:21:59 +02:00
|
|
|
elif not self.run_ready:
|
2021-06-03 16:34:26 +02:00
|
|
|
# If there are no actively running tasks, we start by
|
|
|
|
# checking for I/O. This method will wait for I/O until
|
|
|
|
# the closest deadline to avoid starving sleeping tasks
|
2020-12-19 15:18:12 +01:00
|
|
|
if self.selector.get_map():
|
|
|
|
self.check_io()
|
|
|
|
if self.deadlines:
|
|
|
|
# Then we start checking for deadlines, if there are any
|
|
|
|
self.expire_deadlines()
|
2020-11-26 16:57:20 +01:00
|
|
|
if self.paused:
|
2020-12-19 15:18:12 +01:00
|
|
|
# Next we try to (re)schedule the asleep tasks
|
2020-11-26 16:57:20 +01:00
|
|
|
self.awake_sleeping()
|
2021-06-08 17:21:59 +02:00
|
|
|
if (
|
|
|
|
self.current_pool
|
|
|
|
and self.current_pool.timeout
|
|
|
|
and not self.current_pool.timed_out
|
|
|
|
):
|
2021-04-23 09:17:55 +02:00
|
|
|
# Stores deadlines for tasks (deadlines are pool-specific).
|
|
|
|
# The deadlines queue will internally make sure not to store
|
|
|
|
# a deadline for the same pool twice. This makes the timeouts
|
|
|
|
# model less flexible, because one can't change the timeout
|
2021-06-03 16:34:26 +02:00
|
|
|
# after it is set, but it makes the implementation easier
|
2021-04-23 09:17:55 +02:00
|
|
|
self.deadlines.put(self.current_pool)
|
2020-12-19 15:18:12 +01:00
|
|
|
# Otherwise, while there are tasks ready to run, we run them!
|
2021-06-08 17:21:59 +02:00
|
|
|
while self.run_ready:
|
2021-06-03 16:34:26 +02:00
|
|
|
self.run_task_step()
|
2020-11-17 10:54:18 +01:00
|
|
|
except StopIteration as ret:
|
2020-12-05 17:09:59 +01:00
|
|
|
# At the end of the day, coroutines are generator functions with
|
|
|
|
# some tricky behaviors, and this is one of them. When a coroutine
|
|
|
|
# hits a return statement (either explicit or implicit), it raises
|
|
|
|
# a StopIteration exception, which has an attribute named value that
|
|
|
|
# represents the return value of the coroutine, if any. Of course this
|
|
|
|
# exception is not an error and we should happily keep going after it,
|
|
|
|
# most of this code below is just useful for internal/debugging purposes
|
2020-11-17 10:54:18 +01:00
|
|
|
self.current_task.status = "end"
|
|
|
|
self.current_task.result = ret.value
|
|
|
|
self.current_task.finished = True
|
2020-11-22 14:35:07 +01:00
|
|
|
self.join(self.current_task)
|
2020-11-17 10:54:18 +01:00
|
|
|
except BaseException as err:
|
2021-06-08 17:21:59 +02:00
|
|
|
# Our handy join mechanism will handle all the hassle of
|
|
|
|
# rescheduling joiners and propagating errors, so we
|
|
|
|
# just need to set the task's exception object and let
|
|
|
|
# self.join() work its magic
|
2020-11-17 10:54:18 +01:00
|
|
|
self.current_task.exc = err
|
2020-12-19 15:18:12 +01:00
|
|
|
self.join(self.current_task)
|
2021-06-03 16:34:26 +02:00
|
|
|
|
2021-06-08 17:21:59 +02:00
|
|
|
def create_task(self, coro, *args) -> Task:
|
|
|
|
"""
|
|
|
|
Creates a task
|
|
|
|
"""
|
|
|
|
|
|
|
|
task = Task(coro.__name__ or str(coro), coro(*args), self.current_pool)
|
|
|
|
task.next_deadline = self.current_pool.timeout or 0.0
|
|
|
|
task.joiners = {self.current_task}
|
|
|
|
self.tasks.append(task)
|
|
|
|
self.run_ready.append(task)
|
|
|
|
self.debugger.on_task_spawn(task)
|
|
|
|
self.current_pool.tasks.append(task)
|
|
|
|
self.reschedule_running()
|
|
|
|
return task
|
|
|
|
|
2021-06-03 16:34:26 +02:00
|
|
|
def run_task_step(self):
|
|
|
|
"""
|
|
|
|
Runs a single step for the current task.
|
|
|
|
A step ends when the task awaits any of
|
|
|
|
giambio's primitives or async methods.
|
|
|
|
|
|
|
|
Note that this method does NOT catch any
|
|
|
|
exception arising from tasks, nor does it
|
|
|
|
take StopIteration or CancelledError into
|
|
|
|
account, that's self.run's job!
|
|
|
|
"""
|
|
|
|
|
|
|
|
# Sets the currently running task
|
2021-06-08 17:21:59 +02:00
|
|
|
data = None
|
|
|
|
self.current_task = self.run_ready.pop(0)
|
|
|
|
self.debugger.before_task_step(self.current_task)
|
2021-06-03 16:34:26 +02:00
|
|
|
if self.current_task.done():
|
|
|
|
# We need to make sure we don't try to execute
|
|
|
|
# exited tasks that are on the running queue
|
|
|
|
return
|
|
|
|
if self.current_task.cancel_pending:
|
|
|
|
# We perform the deferred cancellation
|
|
|
|
# if it was previously scheduled
|
|
|
|
self.cancel(self.current_task)
|
|
|
|
# Little boilerplate to send data back to an async trap
|
|
|
|
if self.current_task.status != "init":
|
|
|
|
data = self._data
|
|
|
|
# Run a single step with the calculation (i.e. until a yield
|
|
|
|
# somewhere)
|
|
|
|
method, *args = self.current_task.run(data)
|
2021-06-08 17:21:59 +02:00
|
|
|
if data is self._data:
|
|
|
|
self._data = None
|
2021-06-03 16:34:26 +02:00
|
|
|
# Some debugging and internal chatter here
|
|
|
|
self.current_task.status = "run"
|
|
|
|
self.current_task.steps += 1
|
|
|
|
self.debugger.after_task_step(self.current_task)
|
|
|
|
if not hasattr(self, method):
|
|
|
|
# If this happens, that's quite bad!
|
|
|
|
# This if block is meant to be triggered by other async
|
|
|
|
# libraries, which most likely have different trap names and behaviors
|
|
|
|
# compared to us. If you get this exception and you're 100% sure you're
|
|
|
|
# not mixing async primitives from other libraries, then it's a bug!
|
2021-06-08 17:21:59 +02:00
|
|
|
raise InternalError(
|
|
|
|
"Uh oh! Something very bad just happened, did"
|
|
|
|
" you try to mix primitives from other async libraries?"
|
|
|
|
) from None
|
2021-06-03 16:34:26 +02:00
|
|
|
# Sneaky method call, thanks to David Beazley for this ;)
|
|
|
|
getattr(self, method)(*args)
|
|
|
|
|
|
|
|
def io_release_task(self, task: Task):
|
|
|
|
"""
|
|
|
|
Calls self.io_release in a loop
|
|
|
|
for each I/O resource the given task owns
|
|
|
|
"""
|
|
|
|
|
|
|
|
if self.selector.get_map():
|
2021-06-08 17:21:59 +02:00
|
|
|
for k in filter(
|
|
|
|
lambda o: o.data == self.current_task,
|
|
|
|
dict(self.selector.get_map()).values(),
|
|
|
|
):
|
2021-06-03 16:34:26 +02:00
|
|
|
self.io_release(k.fileobj)
|
|
|
|
task.last_io = ()
|
|
|
|
|
|
|
|
def io_release(self, sock):
|
|
|
|
"""
|
|
|
|
Releases the given resource from our
|
|
|
|
selector.
|
|
|
|
:param sock: The resource to be released
|
|
|
|
"""
|
|
|
|
|
|
|
|
if self.selector.get_map() and sock in self.selector.get_map():
|
|
|
|
self.selector.unregister(sock)
|
2020-11-17 10:54:18 +01:00
|
|
|
|
2021-06-08 17:21:59 +02:00
|
|
|
def suspend(self):
|
|
|
|
"""
|
|
|
|
Suspends execution of the current task
|
|
|
|
"""
|
|
|
|
|
|
|
|
... # TODO: Unschedule I/O?
|
|
|
|
|
|
|
|
def reschedule_running(self):
|
|
|
|
"""
|
|
|
|
Reschedules the currently running task
|
|
|
|
"""
|
|
|
|
|
|
|
|
if self.current_task:
|
|
|
|
self.run_ready.append(self.current_task)
|
|
|
|
|
2020-12-19 15:18:12 +01:00
|
|
|
def do_cancel(self, task: Task):
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
2020-11-28 13:04:27 +01:00
|
|
|
Performs task cancellation by throwing CancelledError inside the given
|
2020-12-20 15:58:53 +01:00
|
|
|
task in order to stop it from running
|
|
|
|
|
|
|
|
:param task: The task to cancel
|
|
|
|
:type task: :class: Task
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
|
2020-12-20 15:58:53 +01:00
|
|
|
self.debugger.before_cancel(task)
|
2021-06-03 16:34:26 +02:00
|
|
|
error = CancelledError()
|
|
|
|
error.task = task
|
|
|
|
task.throw(error)
|
2020-11-18 12:13:46 +01:00
|
|
|
|
2021-06-08 17:21:59 +02:00
|
|
|
def get_current_task(self):
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
2021-06-03 16:34:26 +02:00
|
|
|
'Returns' the current task to an async caller
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
|
2021-06-03 16:34:26 +02:00
|
|
|
self._data = self.current_task
|
2021-06-08 17:21:59 +02:00
|
|
|
self.reschedule_running()
|
|
|
|
|
|
|
|
|
|
|
|
def get_current_pool(self):
|
|
|
|
"""
|
|
|
|
'Returns' the current pool to an async caller
|
|
|
|
"""
|
|
|
|
|
|
|
|
self._data = self.current_pool
|
|
|
|
self.reschedule_running()
|
|
|
|
|
|
|
|
|
|
|
|
def get_current_loop(self):
|
|
|
|
"""
|
|
|
|
'Returns' self to an async caller
|
|
|
|
"""
|
|
|
|
|
|
|
|
self._data = self
|
|
|
|
self.reschedule_running()
|
2020-11-17 10:54:18 +01:00
|
|
|
|
2020-12-19 15:18:12 +01:00
|
|
|
def expire_deadlines(self):
|
|
|
|
"""
|
|
|
|
Handles expiring deadlines by raising an exception
|
|
|
|
inside the correct pool if its timeout expired
|
|
|
|
"""
|
|
|
|
|
2021-06-08 17:21:59 +02:00
|
|
|
while self.deadlines.get_closest_deadline() <= self.clock():
|
2020-12-20 15:58:53 +01:00
|
|
|
pool = self.deadlines.get()
|
2020-12-19 15:18:12 +01:00
|
|
|
pool.timed_out = True
|
2021-06-08 17:21:59 +02:00
|
|
|
self.cancel_pool(pool)
|
2020-12-19 15:18:12 +01:00
|
|
|
|
2021-06-08 17:21:59 +02:00
|
|
|
def schedule_tasks(self, tasks: List[Task]):
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
2021-06-08 17:21:59 +02:00
|
|
|
Schedules the given tasks for execution
|
|
|
|
|
|
|
|
:param tasks: The list of task objects to schedule
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
|
2021-06-08 17:21:59 +02:00
|
|
|
self.run_ready.extend(tasks)
|
2020-11-17 10:54:18 +01:00
|
|
|
|
|
|
|
def awake_sleeping(self):
|
|
|
|
"""
|
2020-12-05 17:09:59 +01:00
|
|
|
Reschedules sleeping tasks if their deadline
|
|
|
|
has elapsed
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
|
2020-12-20 15:58:53 +01:00
|
|
|
while self.paused and self.paused.get_closest_deadline() <= self.clock():
|
2020-11-17 10:54:18 +01:00
|
|
|
# Reschedules tasks when their deadline has elapsed
|
2020-11-18 12:13:46 +01:00
|
|
|
task = self.paused.get()
|
2020-12-20 15:58:53 +01:00
|
|
|
slept = self.clock() - task.sleep_start
|
2021-06-08 17:21:59 +02:00
|
|
|
self.run_ready.append(task)
|
2020-12-20 15:58:53 +01:00
|
|
|
self.debugger.after_sleep(task, slept)
|
2020-11-17 10:54:18 +01:00
|
|
|
|
2021-04-22 11:30:35 +02:00
|
|
|
def get_closest_deadline(self) -> float:
|
|
|
|
"""
|
|
|
|
Gets the closest expiration deadline (asleep tasks, timeouts)
|
|
|
|
|
|
|
|
:return: The closest deadline according to our clock
|
|
|
|
:rtype: float
|
|
|
|
"""
|
|
|
|
|
|
|
|
if not self.deadlines:
|
|
|
|
# If there are no deadlines just wait until the first task wakeup
|
|
|
|
timeout = max(0.0, self.paused.get_closest_deadline() - self.clock())
|
|
|
|
elif not self.paused:
|
|
|
|
# If there are no sleeping tasks just wait until the first deadline
|
|
|
|
timeout = max(0.0, self.deadlines.get_closest_deadline() - self.clock())
|
|
|
|
else:
|
|
|
|
# If there are both deadlines AND sleeping tasks scheduled, we calculate
|
|
|
|
# the absolute closest deadline among the two sets and use that as a timeout
|
|
|
|
clock = self.clock()
|
2021-06-08 17:21:59 +02:00
|
|
|
timeout = min(
|
|
|
|
[
|
|
|
|
max(0.0, self.paused.get_closest_deadline() - clock),
|
|
|
|
self.deadlines.get_closest_deadline() - clock,
|
|
|
|
]
|
|
|
|
)
|
2021-04-22 11:30:35 +02:00
|
|
|
return timeout
|
|
|
|
|
2020-11-17 10:54:18 +01:00
|
|
|
def check_io(self):
|
|
|
|
"""
|
2020-12-20 15:58:53 +01:00
|
|
|
Checks for I/O and implements part of the sleeping mechanism
|
2020-12-19 15:18:12 +01:00
|
|
|
for the event loop
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
|
2021-06-08 17:21:59 +02:00
|
|
|
before_time = self.clock() # Used for the debugger
|
|
|
|
if self.run_ready:
|
2021-06-03 16:34:26 +02:00
|
|
|
# If there is work to do immediately (tasks to run) we prefer to
|
2020-12-20 15:58:53 +01:00
|
|
|
# do that first unless some conditions are met, see below
|
2020-12-05 17:09:59 +01:00
|
|
|
self.io_skip += 1
|
2021-06-03 16:34:26 +02:00
|
|
|
if self.io_skip == self.io_skip_limit:
|
2020-12-05 17:09:59 +01:00
|
|
|
# We can't skip every time there's some task ready
|
2021-06-03 16:34:26 +02:00
|
|
|
# or else we might starve I/O waiting tasks when a
|
|
|
|
# lot of things are running at the same time
|
2020-12-05 17:09:59 +01:00
|
|
|
self.io_skip = 0
|
2021-06-03 16:34:26 +02:00
|
|
|
timeout = self.io_max_timeout
|
2020-12-05 17:09:59 +01:00
|
|
|
else:
|
|
|
|
# If there are either tasks or events and no I/O, don't wait
|
|
|
|
# (unless we already skipped this check too many times)
|
|
|
|
timeout = 0.0
|
2020-12-19 15:18:12 +01:00
|
|
|
elif self.paused or self.deadlines:
|
|
|
|
# If there are asleep tasks or deadlines, wait until the closest date
|
2021-04-22 11:30:35 +02:00
|
|
|
timeout = self.get_closest_deadline()
|
2020-11-27 21:52:45 +01:00
|
|
|
else:
|
2020-11-18 12:13:46 +01:00
|
|
|
# If there is *only* I/O, we wait a fixed amount of time
|
2021-06-03 16:34:26 +02:00
|
|
|
timeout = self.io_max_timeout
|
2020-12-19 15:18:12 +01:00
|
|
|
self.debugger.before_io(timeout)
|
|
|
|
io_ready = self.selector.select(timeout)
|
|
|
|
# Get sockets that are ready and schedule their tasks
|
|
|
|
for key, _ in io_ready:
|
2021-06-08 17:21:59 +02:00
|
|
|
self.run_ready.append(key.data) # Resource ready? Schedule its task
|
2020-12-19 15:18:12 +01:00
|
|
|
self.debugger.after_io(self.clock() - before_time)
|
2020-11-17 10:54:18 +01:00
|
|
|
|
|
|
|
def start(self, func: types.FunctionType, *args):
|
|
|
|
"""
|
|
|
|
Starts the event loop from a sync context
|
|
|
|
"""
|
|
|
|
|
2020-12-20 15:58:53 +01:00
|
|
|
entry = Task(func.__name__ or str(func), func(*args), None)
|
2020-11-17 10:54:18 +01:00
|
|
|
self.tasks.append(entry)
|
2021-06-08 17:21:59 +02:00
|
|
|
self.run_ready.append(entry)
|
2020-11-18 12:13:46 +01:00
|
|
|
self.debugger.on_start()
|
2020-11-17 10:54:18 +01:00
|
|
|
self.run()
|
|
|
|
self.has_ran = True
|
2020-11-18 12:13:46 +01:00
|
|
|
self.debugger.on_exit()
|
2020-11-17 10:54:18 +01:00
|
|
|
|
2021-04-22 11:30:35 +02:00
|
|
|
def cancel_pool(self, pool: TaskManager) -> bool:
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
2020-12-20 15:58:53 +01:00
|
|
|
Cancels all tasks in the given pool
|
2020-11-17 10:54:18 +01:00
|
|
|
|
2020-12-20 15:58:53 +01:00
|
|
|
:param pool: The pool to be cancelled
|
|
|
|
:type pool: :class: TaskManager
|
2020-11-26 16:57:20 +01:00
|
|
|
"""
|
|
|
|
|
2020-12-05 17:09:59 +01:00
|
|
|
if pool:
|
|
|
|
for to_cancel in pool.tasks:
|
|
|
|
self.cancel(to_cancel)
|
2020-12-19 15:18:12 +01:00
|
|
|
# If pool.done() equals True, then self.join() can
|
|
|
|
# safely proceed and reschedule the parent of the
|
|
|
|
# current pool. If, however, there are still some
|
|
|
|
# tasks running, we wait for them to exit in order
|
|
|
|
# to avoid orphaned tasks
|
|
|
|
return pool.done()
|
2021-06-08 17:21:59 +02:00
|
|
|
else: # If we're at the main task, we're sure everything else exited
|
2020-12-05 17:09:59 +01:00
|
|
|
return True
|
2020-11-26 16:57:20 +01:00
|
|
|
|
2021-04-22 11:30:35 +02:00
|
|
|
def get_all_tasks(self) -> chain:
|
2020-12-19 15:18:12 +01:00
|
|
|
"""
|
2021-06-08 17:21:59 +02:00
|
|
|
Returns a list of all the tasks the loop is currently
|
2020-12-20 15:58:53 +01:00
|
|
|
keeping track of: this includes both running and paused tasks.
|
2020-12-19 22:09:48 +01:00
|
|
|
A paused task is a task which is either waiting on an I/O resource,
|
|
|
|
sleeping, or waiting on an event to be triggered
|
2020-12-19 15:18:12 +01:00
|
|
|
"""
|
|
|
|
|
2021-06-08 17:21:59 +02:00
|
|
|
return self.tasks
|
2020-12-19 15:18:12 +01:00
|
|
|
|
2020-12-20 15:58:53 +01:00
|
|
|
def cancel_all(self) -> bool:
|
2020-11-29 12:06:09 +01:00
|
|
|
"""
|
2020-12-20 15:58:53 +01:00
|
|
|
Cancels ALL tasks as returned by self.get_all_tasks() and returns
|
|
|
|
whether all tasks exited or not
|
2020-11-29 12:06:09 +01:00
|
|
|
"""
|
|
|
|
|
2020-12-19 15:18:12 +01:00
|
|
|
for to_cancel in self.get_all_tasks():
|
2020-12-05 17:09:59 +01:00
|
|
|
self.cancel(to_cancel)
|
2020-12-19 15:18:12 +01:00
|
|
|
return all([t.done() for t in self.get_all_tasks()])
|
2020-11-29 12:06:09 +01:00
|
|
|
|
2020-12-05 17:09:59 +01:00
|
|
|
def close(self, *, ensure_done: bool = True):
|
2020-11-29 12:06:09 +01:00
|
|
|
"""
|
|
|
|
Closes the event loop, terminating all tasks
|
2020-12-05 17:09:59 +01:00
|
|
|
inside it and tearing down any extra machinery.
|
2020-12-20 15:58:53 +01:00
|
|
|
If ensure_done equals False, the loop will cancel ALL
|
2020-12-05 17:09:59 +01:00
|
|
|
running and scheduled tasks and then tear itself down.
|
2020-12-19 15:18:12 +01:00
|
|
|
If ensure_done equals True, which is the default behavior,
|
2020-12-05 17:09:59 +01:00
|
|
|
this method will raise a GiambioError if the loop hasn't
|
|
|
|
finished running.
|
|
|
|
"""
|
|
|
|
|
|
|
|
if ensure_done:
|
|
|
|
self.cancel_all()
|
|
|
|
elif not self.done():
|
2021-06-08 17:21:59 +02:00
|
|
|
raise GiambioError(
|
|
|
|
"event loop not terminated, call this method with ensure_done=False to forcefully exit"
|
|
|
|
)
|
2020-11-29 12:46:08 +01:00
|
|
|
self.shutdown()
|
2020-11-29 12:06:09 +01:00
|
|
|
|
2020-12-20 15:58:53 +01:00
|
|
|
def reschedule_joiners(self, task: Task):
|
|
|
|
"""
|
|
|
|
Reschedules the parent(s) of the
|
|
|
|
given task, if any
|
|
|
|
"""
|
|
|
|
|
|
|
|
for t in task.joiners:
|
2021-06-08 17:21:59 +02:00
|
|
|
if t not in self.run_ready:
|
2020-12-20 15:58:53 +01:00
|
|
|
# Since a task can be the parent
|
|
|
|
# of multiple children, we need to
|
|
|
|
# make sure we reschedule it only
|
|
|
|
# once, otherwise a RuntimeError will
|
|
|
|
# occur
|
2021-06-08 17:21:59 +02:00
|
|
|
self.run_ready.append(t)
|
2021-04-22 11:30:35 +02:00
|
|
|
|
2020-11-26 16:57:20 +01:00
|
|
|
def join(self, task: Task):
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
2020-11-28 13:04:27 +01:00
|
|
|
Joins a task to its callers (implicitly, the parent
|
|
|
|
task, but also every other task who called await
|
|
|
|
task.join() on the task object)
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
|
2020-11-26 16:57:20 +01:00
|
|
|
task.joined = True
|
2020-11-27 21:52:45 +01:00
|
|
|
if task.finished or task.cancelled:
|
2021-06-08 17:21:59 +02:00
|
|
|
if not task.cancelled:
|
|
|
|
self.debugger.on_task_exit(task)
|
|
|
|
if task.last_io:
|
|
|
|
self.io_release_task(task)
|
|
|
|
if task.pool is None:
|
|
|
|
return
|
|
|
|
if self.current_pool and self.current_pool.done():
|
2020-12-20 15:58:53 +01:00
|
|
|
# If the current pool has finished executing or we're at the first parent
|
|
|
|
# task that kicked the loop, we can safely reschedule the parent(s)
|
2020-12-05 17:09:59 +01:00
|
|
|
self.reschedule_joiners(task)
|
2020-11-26 16:57:20 +01:00
|
|
|
elif task.exc:
|
2021-06-08 17:21:59 +02:00
|
|
|
task.status = "crashed"
|
|
|
|
# TODO: We might want to do a bit more complex traceback hacking to remove any extra
|
|
|
|
# frames from the exception call stack, but for now removing at least the first one
|
|
|
|
# seems a sensible approach (it's us catching it so we don't care about that)
|
|
|
|
task.exc.__traceback__ = task.exc.__traceback__.tb_next
|
|
|
|
if task.last_io:
|
|
|
|
self.io_release_task(task)
|
|
|
|
self.debugger.on_exception_raised(task, task.exc)
|
|
|
|
if task.pool is None:
|
|
|
|
# Parent task has no pool, so we propagate
|
|
|
|
raise
|
2020-12-20 15:58:53 +01:00
|
|
|
if self.cancel_pool(self.current_pool):
|
|
|
|
# This will reschedule the parent(s)
|
|
|
|
# only if all the tasks inside the current
|
|
|
|
# pool have finished executing, either
|
2020-12-05 17:09:59 +01:00
|
|
|
# by cancellation, an exception
|
|
|
|
# or just returned
|
2021-04-22 12:02:40 +02:00
|
|
|
for t in task.joiners:
|
|
|
|
# Propagate the exception
|
|
|
|
try:
|
|
|
|
t.throw(task.exc)
|
2021-06-08 17:21:59 +02:00
|
|
|
except (StopIteration, CancelledError):
|
2021-04-22 12:02:40 +02:00
|
|
|
# TODO: Need anything else?
|
|
|
|
task.joiners.remove(t)
|
2020-11-29 19:34:23 +01:00
|
|
|
self.reschedule_joiners(task)
|
2020-11-17 10:54:18 +01:00
|
|
|
|
|
|
|
def sleep(self, seconds: int or float):
|
|
|
|
"""
|
2020-12-20 15:58:53 +01:00
|
|
|
Puts the current task to sleep for a given amount of seconds
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
|
2020-11-18 12:13:46 +01:00
|
|
|
self.debugger.before_sleep(self.current_task, seconds)
|
2020-12-20 15:58:53 +01:00
|
|
|
if seconds:
|
2020-11-17 10:54:18 +01:00
|
|
|
self.current_task.status = "sleep"
|
2020-11-18 12:13:46 +01:00
|
|
|
self.current_task.sleep_start = self.clock()
|
2020-11-17 10:54:18 +01:00
|
|
|
self.paused.put(self.current_task, seconds)
|
2020-12-19 15:18:12 +01:00
|
|
|
self.current_task.next_deadline = self.current_task.sleep_start + seconds
|
2020-11-17 10:54:18 +01:00
|
|
|
else:
|
2020-12-20 15:58:53 +01:00
|
|
|
# When we're called with a timeout of 0 (the type checking is done
|
|
|
|
# way before this point) this method acts as a checkpoint that allows
|
|
|
|
# giambio to kick in and to its job without pausing the task's execution
|
|
|
|
# for too long. It is recommended to put a couple of checkpoints like these
|
|
|
|
# in your code if you see degraded concurrent performance in parts of your code
|
|
|
|
# that block the loop
|
2021-06-08 17:21:59 +02:00
|
|
|
self.reschedule_running()
|
2020-11-17 10:54:18 +01:00
|
|
|
|
2020-12-05 17:09:59 +01:00
|
|
|
def cancel(self, task: Task):
|
2020-11-22 14:35:07 +01:00
|
|
|
"""
|
2020-11-28 13:04:27 +01:00
|
|
|
Schedules the task to be cancelled later
|
2020-11-22 14:35:07 +01:00
|
|
|
or does so straight away if it is safe to do so
|
|
|
|
"""
|
|
|
|
|
2021-06-03 16:34:26 +02:00
|
|
|
if task.done() or task.status == "init":
|
2020-12-19 15:18:12 +01:00
|
|
|
# The task isn't running already!
|
2021-06-03 16:34:26 +02:00
|
|
|
task.cancel_pending = False
|
2020-12-19 15:18:12 +01:00
|
|
|
return
|
2021-06-03 16:34:26 +02:00
|
|
|
elif task.status in ("io", "sleep"):
|
2020-12-05 17:09:59 +01:00
|
|
|
# We cancel immediately only in a context where it's safer to do
|
|
|
|
# so. The concept of "safer" is quite tricky, because even though the
|
|
|
|
# task is technically not running, it might leave some unfinished state
|
|
|
|
# or dangling resource open after being cancelled, so maybe we need
|
|
|
|
# a different approach altogether
|
2021-06-03 16:34:26 +02:00
|
|
|
if task.status == "io":
|
2021-06-08 17:21:59 +02:00
|
|
|
for k in filter(
|
|
|
|
lambda o: o.data == task, dict(self.selector.get_map()).values()
|
|
|
|
):
|
2021-06-03 16:34:26 +02:00
|
|
|
self.selector.unregister(k.fileobj)
|
|
|
|
elif task.status == "sleep":
|
|
|
|
self.paused.discard(task)
|
2020-12-05 17:09:59 +01:00
|
|
|
try:
|
2020-11-22 14:35:07 +01:00
|
|
|
self.do_cancel(task)
|
2021-06-03 16:34:26 +02:00
|
|
|
except CancelledError as cancel:
|
2020-12-19 15:18:12 +01:00
|
|
|
# When a task needs to be cancelled, giambio tries to do it gracefully
|
|
|
|
# first: if the task is paused in either I/O or sleeping, that's perfect.
|
|
|
|
# But we also need to cancel a task if it was not sleeping or waiting on
|
|
|
|
# any I/O because it could never do so (therefore blocking everything
|
|
|
|
# forever). So, when cancellation can't be done right away, we schedule
|
2021-04-22 11:30:35 +02:00
|
|
|
# it for the next execution step of the task. Giambio will also make sure
|
2020-12-19 15:18:12 +01:00
|
|
|
# to re-raise cancellations at every checkpoint until the task lets the
|
|
|
|
# exception propagate into us, because we *really* want the task to be
|
2020-12-20 15:58:53 +01:00
|
|
|
# cancelled
|
2021-06-03 16:34:26 +02:00
|
|
|
task = cancel.task
|
2020-12-05 17:09:59 +01:00
|
|
|
task.cancel_pending = False
|
2021-06-03 16:34:26 +02:00
|
|
|
task.cancelled = True
|
|
|
|
task.status = "cancelled"
|
|
|
|
self.io_release_task(self.current_task)
|
2020-12-05 17:09:59 +01:00
|
|
|
self.debugger.after_cancel(task)
|
|
|
|
else:
|
|
|
|
# If we can't cancel in a somewhat "graceful" way, we just
|
|
|
|
# defer this operation for later (check run() for more info)
|
|
|
|
task.cancel_pending = True # Cancellation is deferred
|
2020-11-22 14:35:07 +01:00
|
|
|
|
2021-05-31 22:56:03 +02:00
|
|
|
def register_sock(self, sock, evt_type: str):
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
2020-11-28 13:04:27 +01:00
|
|
|
Registers the given socket inside the
|
2020-11-17 10:54:18 +01:00
|
|
|
selector to perform I/0 multiplexing
|
2020-12-20 15:58:53 +01:00
|
|
|
|
|
|
|
:param sock: The socket on which a read or write operation
|
|
|
|
has to be performed
|
|
|
|
:param evt_type: The type of event to perform on the given
|
|
|
|
socket, either "read" or "write"
|
|
|
|
:type evt_type: str
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
|
2020-11-27 21:52:45 +01:00
|
|
|
self.current_task.status = "io"
|
2020-12-20 15:58:53 +01:00
|
|
|
evt = EVENT_READ if evt_type == "read" else EVENT_WRITE
|
2020-11-17 10:54:18 +01:00
|
|
|
if self.current_task.last_io:
|
2020-12-20 15:58:53 +01:00
|
|
|
# Since most of the times tasks will perform multiple
|
|
|
|
# I/O operations on a given socket, unregistering them
|
|
|
|
# every time isn't a sensible approach. A quick and
|
|
|
|
# easy optimization to address this problem is to
|
|
|
|
# store the last I/O operation that the task performed
|
|
|
|
# together with the resource itself, inside the task
|
|
|
|
# object. If the task wants to perform the same
|
|
|
|
# operation on the same socket again, then this method
|
|
|
|
# returns immediately as the socket is already being
|
|
|
|
# watched by the selector. If the resource is the same,
|
|
|
|
# but the event has changed, then we modify the resource's
|
|
|
|
# associated event. Only if the resource is different from
|
|
|
|
# the last used one this method will register a new socket
|
2020-11-28 13:04:27 +01:00
|
|
|
if self.current_task.last_io == (evt_type, sock):
|
2020-12-20 15:58:53 +01:00
|
|
|
# Socket is already listening for that event!
|
2020-11-17 10:54:18 +01:00
|
|
|
return
|
2020-12-20 15:58:53 +01:00
|
|
|
elif self.current_task.last_io[1] == sock:
|
|
|
|
# If the event to listen for has changed we just modify it
|
|
|
|
self.selector.modify(sock, evt, self.current_task)
|
|
|
|
self.current_task.last_io = (evt_type, sock)
|
2021-05-31 22:56:03 +02:00
|
|
|
elif not self.current_task.last_io or self.current_task.last_io[1] != sock:
|
|
|
|
# The task has either registered a new socket or is doing
|
|
|
|
# I/O for the first time. In both cases, we register a new socket
|
2020-12-20 15:58:53 +01:00
|
|
|
self.current_task.last_io = evt_type, sock
|
|
|
|
try:
|
|
|
|
self.selector.register(sock, evt, self.current_task)
|
|
|
|
except KeyError:
|
|
|
|
# The socket is already registered doing something else
|
2021-06-08 17:21:59 +02:00
|
|
|
raise ResourceBusy(
|
|
|
|
"The given socket is being read/written by another task"
|
|
|
|
) from None
|
2020-11-26 16:57:20 +01:00
|
|
|
|
2020-11-29 19:34:23 +01:00
|
|
|
# noinspection PyMethodMayBeStatic
|
2020-12-20 15:58:53 +01:00
|
|
|
async def connect_sock(self, sock: socket.socket, address_tuple: tuple):
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
2020-12-20 15:58:53 +01:00
|
|
|
Connects a socket asynchronously to a given endpoint
|
|
|
|
|
|
|
|
:param sock: The socket that must to be connected
|
|
|
|
:type sock: socket.socket
|
|
|
|
:param address_tuple: A tuple in the same form as the one
|
|
|
|
passed to socket.socket.connect with an address as a string
|
|
|
|
and a port as an integer
|
|
|
|
:type address_tuple: tuple
|
2020-11-17 10:54:18 +01:00
|
|
|
"""
|
|
|
|
|
2020-11-29 12:46:08 +01:00
|
|
|
await want_write(sock)
|
2021-04-22 11:30:35 +02:00
|
|
|
try:
|
|
|
|
return sock.connect(address_tuple)
|
|
|
|
except BlockingIOError:
|
|
|
|
await want_write(sock)
|
|
|
|
return sock.connect(address_tuple)
|